Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions contract-semantics.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -272,15 +272,26 @@ not_derived:
cancellable: [DRAFT, GOVERNANCE_ANALYSIS, APPROVED, REJECTED, TASK_PLANNING, IN_PROGRESS,
AWAITING_APPROVAL, VALIDATING, DEPLOYABLE]

# rfcs/0007 Amendment 2 (2026-08-20, issue #21) — job ที่ job ใหม่อ้างถึงด้วย
# supersedes_job_id ได้ = terminal ที่จบโดย "ไม่ได้ส่งมอบงาน" (= terminal ทั้งหมด ลบ COMPLETED)
# เดิมจำกัดไว้ที่ FAILED เท่านั้น ทั้งที่เจตนาของ Decision 1 คือสายโซ่ของความพยายามที่ตรวจสอบได้
# — job ที่หมดเวลาหรือถูกสั่งหยุดก็เป็นความพยายามที่ใช้ไปแล้วเหมือนกัน
# APPROVED ที่ approval หมดอายุจบที่ TIMED_OUT (Amendment 1) และเข้า FAILED ไม่ได้ (rfcs/0010)
# การ "ขอใหม่" ตาม approval/v1 จึงไม่มีทางลิงก์กลับเลยก่อนหน้านี้
# COMPLETED อ้างไม่ได้ — งานที่ส่งมอบแล้วไม่ใช่ความพยายามที่รอความพยายามถัดไป
# งานที่ต่อยอดจากมันคืองานใหม่ ไม่ใช่การลองใหม่ ต้องใช้ field คนละตัวที่ยังไม่มี
# ไม่แตะตาราง transition · terminal ยังคง terminal · ไม่มีการปลุก job เดิม
supersedable: [FAILED, CANCELLED, TIMED_OUT]

invariants:
- execution ห้ามเกิดก่อน APPROVED
- REJECTED ไม่ terminal — กลับไป DRAFT ได้
- FAILED terminal ที่ระดับ job · retry อยู่ที่ระดับ execution เท่านั้น
- recovery ของ job ที่ FAILED = job ใหม่ที่มี supersedes_job_id ไม่ใช่ปลุกตัวเดิม
- recovery ของ job ที่จบแล้ว = job ใหม่ที่มี supersedes_job_id ไม่ใช่ปลุกตัวเดิม · job ที่อ้างถึงได้คือ terminal ที่ไม่ได้ส่งมอบงาน (ดู supersedable) · COMPLETED อ้างไม่ได้ (rfcs/0007 Amendment 2)
- job ไม่เข้า FAILED เพราะ execution เดียวล้ม — orchestration ต้อง exhaust retry ก่อน
- AWAITING_APPROVAL คนละอย่างกับ GOVERNANCE_ANALYSIS — อันหลังคือประตูก่อนเริ่มงาน
- FAILED · CANCELLED · TIMED_OUT ต้องมี reason metadata
- job ที่ยังไม่ผ่าน APPROVED เข้า FAILED ไม่ได้ — recovery ด้วย supersedes_job_id จะไม่มีอะไรให้ supersede (rfcs/0010)
- job ที่ยังไม่ผ่าน APPROVED เข้า FAILED ไม่ได้ — ยังไม่มีงานให้ล้ม ผลลัพธ์ที่ซื่อสัตย์คือ REJECTED / CANCELLED / TIMED_OUT (rfcs/0010)
- approval ที่เลย expires_at แล้วใช้เดินงานต่อไม่ได้ — engine ปฏิเสธ ไม่ใช่ปล่อยผ่าน · job ที่ค้างอยู่เข้า TIMED_OUT ได้ (rfcs/0007 Amendment 1)
- REQUIRE_CHANGES กับ REJECT จบที่ DRAFT เหมือนกันแต่คนละเส้นทาง — REJECT ผ่าน REJECTED ส่วน REQUIRE_CHANGES ข้าม · แยกกันที่ trail ไม่ใช่แค่ที่ decision record (rfcs/0011)
- คำถามว่า transition นี้เป็นคำตัดสินไหม ต้องอ่านที่ edge ไม่ใช่ที่ปลายทาง — REJECTED → DRAFT ไม่ใช่คำตัดสิน (rfcs/0011)
Expand Down
20 changes: 20 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ enforced, so the engine rejects rather than repairs.
| --- | --- |
| an edge not in the table | `InvalidTransition`, naming what *was* allowed |
| anything out of `COMPLETED` / `FAILED` / `CANCELLED` / `TIMED_OUT` | `TerminalState` — recovery is `supersede()`, not a revival |
| `supersede()` on a job that has not settled, or on a `COMPLETED` one | `InvalidTransition` — an attempt that delivered is not one to retry |
| `TASK_PLANNING` before `APPROVED` | `InvalidTransition` — execution is forbidden before approval |
| any post-approval state under an approval past its `expires_at` | `ExpiredApproval` — it has to be granted again |
| `FAILED` / `CANCELLED` / `TIMED_OUT` without a reason | `MissingReason` |
Expand Down Expand Up @@ -152,3 +153,22 @@ expires. What changed with issue #17 is that `APPROVED` is now in `TIMEOUTABLE`
so a job holding an approval that ran out has an honest terminal to reach instead of
waiting for a human to cancel it. The timeout *policy* — how long an approval is good for
— stays out of scope, as it is in RFC-0007 and RFC-0010.

## Recovery

`approval/v1` does not stop at "an expired approval cannot run work": it says
*ต้องขอใหม่*. Asking again is a **new job** that names the one it replaces — never a
revival, so the replacement starts at `DRAFT` and passes governance again.

```python
expired.state # TIMED_OUT — the approval lapsed where it sat
again = expired.supersede(job_id="job-007-next")
again.supersedes_job_id # "job-007", and it is in the JOB_CREATED event
```

Any terminal that settled **without delivering** may be named — `states.SUPERSEDABLE`,
which is `FAILED`, `CANCELLED`, and `TIMED_OUT`. `COMPLETED` may not:
[RFC-0007 Amendment 2](../../rfcs/0007-job-lifecycle-completeness.md#amendment-2--a-job-may-supersede-any-terminal-it-did-not-deliver-from-2026-08-20)
(issue #21) widened the field from `FAILED` alone and drew the line there, because a job
that delivered is not an attempt awaiting another one — work that builds on it is new work
and wants a link that says so.
16 changes: 11 additions & 5 deletions packages/core/devfactory_core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,22 @@ def __init__(self, current: str, requested: str, allowed: list[str]) -> None:
class TerminalState(JobStateMachineError):
"""A transition was requested out of a terminal state.

Recovery from FAILED is a new job carrying ``supersedes_job_id``, never a
transition out of it — RFC-0007 keeps FAILED terminal so that recovery has
to pass GOVERNANCE_ANALYSIS again rather than resume under a stale APPROVED.
Recovery from a terminal state is a new job carrying ``supersedes_job_id``,
never a transition out of it — RFC-0007 keeps these states terminal so that
recovery has to pass GOVERNANCE_ANALYSIS again rather than resume under a
stale APPROVED. Amendment 2 widened which of them may be recovered from;
``COMPLETED`` is not one, because a job that delivered has nothing to recover.
"""

def __init__(self, current: str) -> None:
self.current = current
recovery = (
"There is nothing to recover — it delivered its work"
if current == "COMPLETED"
else "Recovery is a new job with supersedes_job_id"
)
super().__init__(
f"{current} is terminal. Recovery is a new job with supersedes_job_id, "
f"not a transition out of {current}."
f"{current} is terminal. {recovery}, not a transition out of {current}."
)


Expand Down
31 changes: 26 additions & 5 deletions packages/core/devfactory_core/job.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
ต้องขอใหม่"* — so the engine refuses to move a job into execution under a lapsed
approval, and RFC-0007's 2026-08-19 amendment gives such a job somewhere honest to
land by putting ``APPROVED`` in ``states.TIMEOUTABLE`` (issue #17).

Asking again is :meth:`Job.supersede`, and since RFC-0007's Amendment 2 it works
from every terminal that settled without delivering — ``states.SUPERSEDABLE`` —
rather than from ``FAILED`` alone, so the second attempt at a job whose approval
ran out can say what it is a second attempt at (issue #21).
"""

from __future__ import annotations
Expand Down Expand Up @@ -57,6 +62,7 @@
APPROVAL_PAUSABLE,
DECISION_TARGET,
POST_APPROVAL,
SUPERSEDABLE,
TERMINAL,
JobState,
decision_for_edge,
Expand Down Expand Up @@ -454,17 +460,32 @@ def time_out(self, *, reason: str, principal: Principal | None = None) -> Event:
return self.transition(JobState.TIMED_OUT, reason=reason, principal=principal)

def supersede(self, *, job_id: str, principal: Principal | None = None) -> "Job":
"""Create the replacement job for a FAILED one.
"""Create the replacement job for one that settled without delivering.

RFC-0007: recovery is a new job, not a revival. The replacement starts at
DRAFT and passes GOVERNANCE_ANALYSIS again, which is the guarantee —
resuming the old one would continue under an APPROVED granted to a plan
that has since failed.

Amendment 2 widened *which* job may be pointed back at, from ``FAILED``
alone to every terminal in ``states.SUPERSEDABLE``. An approval that
lapses lands its job in ``TIMED_OUT``, and ``approval/v1`` answers that
with "ต้องขอใหม่"; asking again is this call, and before the amendment it
had no way to say what it was a second attempt at. ``COMPLETED`` is not in
the set: a job that delivered is not an attempt awaiting another one.
"""
if self._state is not JobState.FAILED:
raise InvalidTransition(
self._state.value, "supersede", ["only a FAILED job can be superseded"]
)
if self._state not in SUPERSEDABLE:
# Two refusals with the same shape and different reasons: one job has
# not finished trying, the other has nothing left to try.
if self._state is JobState.COMPLETED:
why = (
"COMPLETED delivered its work — what follows it is new work, "
"not another attempt at the same work"
)
else:
settled = ", ".join(sorted(s.value for s in SUPERSEDABLE))
why = f"a job is superseded once it has settled without delivering: {settled}"
raise InvalidTransition(self._state.value, "supersede", [why])
return Job(
job_id=job_id,
tenant_id=self._tenant_id,
Expand Down
22 changes: 22 additions & 0 deletions packages/core/devfactory_core/states.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,28 @@ class JobState(str, Enum):
{JobState.COMPLETED, JobState.FAILED, JobState.CANCELLED, JobState.TIMED_OUT}
)

#: Jobs a later job may name in ``supersedes_job_id`` — RFC-0007 Amendment 2
#: (issue #21). Every terminal that settled **without delivering the work**.
#:
#: Decision 1 wrote the rule against ``FAILED`` because failure was the only
#: recovery it was thinking about, but what it wanted is a *chain of attempts that
#: can be audited*, and a job that ran out of time or was stopped by a person is
#: as much a spent attempt as one that broke. Amendment 1 made that concrete: an
#: approval that lapses sends its job to ``TIMED_OUT``, and ``approval/v1`` says
#: what happens next — "ต้องขอใหม่". Without this set the asking-again produced an
#: unlinked job and the chain ended where the audit question began.
#:
#: ``COMPLETED`` is excluded on purpose. Superseding is a claim that an attempt
#: did not deliver and is being tried again; a completed job delivered, and work
#: that builds on it is *new work*, not another attempt at the same work. Letting
#: it in would turn a chain of attempts into an untyped "related to" pointer, and
#: a reader walking the chain back could no longer tell whether the earlier link
#: had produced anything.
#:
#: This changes who may be *referred to*. It relaxes nothing about the transition
#: table: every terminal here is still terminal, and the old job is not woken.
SUPERSEDABLE: frozenset[JobState] = TERMINAL - {JobState.COMPLETED}

#: States from which execution has been authorised. Reaching any of these without
#: passing APPROVED would violate "execution is forbidden before APPROVED".
POST_APPROVAL: frozenset[JobState] = frozenset(
Expand Down
45 changes: 44 additions & 1 deletion packages/core/state-machine.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ AWAITING_APPROVAL → <awaiting_from> | FAILED

Execution is forbidden before `APPROVED`.
`FAILED` is terminal — recovery is a new job carrying `supersedes_job_id`, not a retry.
So are `CANCELLED` and `TIMED_OUT`, and since
[RFC-0007 Amendment 2](../../rfcs/0007-job-lifecycle-completeness.md#amendment-2--a-job-may-supersede-any-terminal-it-did-not-deliver-from-2026-08-20)
a new job may point back at any of the three. See [Recovery](#recovery).

`CANCELLED` is reachable from every non-terminal state.
`TIMED_OUT` is reachable from `GOVERNANCE_ANALYSIS`, `APPROVED`, `TASK_PLANNING`,
Expand Down Expand Up @@ -121,10 +124,50 @@ authorised, including the way back out of a pause. What remains is a fresh `APPR
[RFC-0007 Amendment 1](../../rfcs/0007-job-lifecycle-completeness.md#amendment-1--approved-may-time-out-2026-08-19)
added the last of those so a job stalled in `APPROVED` has an honest terminal.

Once it has settled there, *"ต้องขอใหม่"* is [Recovery](#recovery): a new job that names the
timed-out one in `supersedes_job_id` and asks for approval again. Both halves of the rule
`approval/v1` states are now reachable — the refusal and the way to ask again.

Timeout **policy** — how long an approval is good for — is not set here. RFC-0007 and
RFC-0010 both leave the values out of scope, so nothing in this repository supplies a
default `expires_at` or fires a timeout on its own.

## Recovery

A terminal job is never woken. Trying again is a **new job** that records
`supersedes_job_id` naming the one it replaces, starts at `DRAFT`, and passes
`GOVERNANCE_ANALYSIS` again — RFC-0007 Decision 1. That re-approval is the guarantee, not
the overhead: resuming the old job would continue under an `APPROVED` granted in a context
that has since stopped holding.

Which job may be named is `states.SUPERSEDABLE` — every terminal that settled **without
delivering the work**:

| predecessor | may be superseded | why |
| --- | --- | --- |
| `FAILED` | ✅ | the attempt broke |
| `TIMED_OUT` | ✅ | the attempt ran out of time — including an approval that lapsed where it sat |
| `CANCELLED` | ✅ | a principal stopped the attempt |
| `COMPLETED` | ❌ | it delivered; what follows is new work, not another attempt at the same work |

`FAILED` was the only one until
[RFC-0007 Amendment 2](../../rfcs/0007-job-lifecycle-completeness.md#amendment-2--a-job-may-supersede-any-terminal-it-did-not-deliver-from-2026-08-20)
(issue #21), which left the transition table alone and relaxed only who may be *referred
to*. Before it, `approval/v1`'s *"ต้องขอใหม่"* had no way to be carried out with the chain
intact: an approval that expires sends its job to `TIMED_OUT`, `TIMED_OUT` cannot reach
`FAILED`, and so the job filed in its place was unlinked.

```python
expired.state # TIMED_OUT — the approval lapsed before planning began
again = expired.supersede(job_id="job-007-next")
again.state # DRAFT, holding no approval
again.supersedes_job_id # "job-007" — and it is in the JOB_CREATED event, so a
# replay reads the chain back from the log alone
```

Superseding a `COMPLETED` job raises `InvalidTransition`, as does superseding a job that
has not settled at all.

## AWAITING_APPROVAL

Entered when at least one execution of the job is in `awaiting_approval`
Expand All @@ -145,7 +188,7 @@ definitions:
| `workspace_id` | ✅ | a job is always work inside one workspace |
| `principal` | ✅ | who created the job |
| `awaiting_from` | when `AWAITING_APPROVAL` | state to return to |
| `supersedes_job_id` | when recovering | points at the `FAILED` job this replaces |
| `supersedes_job_id` | when recovering | points at the settled job this attempt replaces — see [Recovery](#recovery) |

## Guarantees

Expand Down
Loading
Loading