ADR-0107: Conclusive orphan terminal reaping¶
- Status: Accepted
- Kind: Aspirational (records the target state)
- Area: cli-surface
- Date: 2026-07-27
- Amended: 2026-07-27 — D3, D4, D5, D6 and the verify-by list carry amendment notes marking what changed and why. The amendments settle questions the implementation surfaced: the outcome value, the fifth attribution source, and what a mutation does when it cannot be serialized.
- Relations: extends ADR-0095, ADR-0106
Context¶
The MCP background-job surface records each submitted run in ~/.lionagi/mcp/jobs/<run_id>/job.json. Callers do not classify the open status vocabulary. They branch on two producer-owned fields:
terminal: stop waiting;outcome: how the work came out, null whileterminalis false.
Ordinary completion reaches the sidecar through the child's terminal hook. Cancellation can instead be recovered from the lifecycle store and cached into the sidecar. A third state has no writer today: the spawned process is gone, the child never ran its terminal hook, and neither the sidecar nor lifecycle store contains an end.
lionagi/mcp/jobs.py currently reports that state as:
{
"status": "exited",
"terminal": false,
"outcome": null,
"reason_code": null,
"possibly_orphaned": true
}
job.wait drops the run from pending because more waiting cannot help, but keeps all_terminal false and returns it under stopped_without_end. A caller that waits for all_terminal therefore has no successful stopping state. More importantly, the child that normally writes the end and delivers the terminal notice no longer exists, so a notice-only caller is never woken.
The argument for leaving the state non-terminal¶
The current rule protects four real properties:
- A PID number is not a process identity. The operating system can recycle it.
- A failed or denied identity probe is not evidence of death.
terminalis a latch. A false positive cannot be repaired by a later read.- A live probe changes with observation time. Two readers of an unchanged record must not disagree about a latch.
Those properties reject terminalisation from a bare PID probe and reject a purely derived terminal answer. They do not require permanent non-terminality. The existing implementation now records process creation time and distinguishes three findings that positively establish that this run's process is gone:
- the recorded PID held no live process at the first liveness probe;
- it disappeared between the liveness and creation-time probes;
- a live process holds the PID but its creation time differs from the recorded creation time.
The implementation also has one false-looking result that proves nothing: alive == false with pid_identity == "unusable_pid" means no OS probe was made. This case must remain advisory.
Problems¶
P1 — A run can remain non-terminal forever after its work can no longer report an outcome. No surviving producer can write finished_at, so another wait cannot turn uncertainty into a reported result.
P2 — job.wait has two stop concepts. It stops observing possibly_orphaned runs but cannot set all_terminal. Every caller must interpret stopped_without_end as a second completion protocol.
P3 — notification has no surviving owner. The terminal hook both writes the end and attempts delivery. A process that dies before the hook does neither. Changing only terminal would make waiters stop while leaving the notification consumer broken.
P4 — the liveness vocabulary is unsafe as a terminal predicate. The conclusive set is currently expressed by excluding "unusable_pid" from the alive == false cases. That is a negative discriminator over an open vocabulary: a future inconclusive value would silently become terminal. None is also overloaded, and the docstring's description of "gone" does not match the first dead-PID return path.
P5 — the sidecar write primitive is atomic but not a transaction. _write_job prevents torn JSON, but concurrent read-modify-write operations can lose one another. A terminal hook, notification result, PID attachment, lifecycle cache, and orphan observer must not overwrite a terminal fact or delivery result.
Decisions¶
| Concern | Decision |
|---|---|
| How a conclusively gone unreported run becomes terminal | D1: the observer persists an idempotent orphan-reap transition before returning it as terminal |
| What evidence is conclusive | D2: one positive internal liveness_conclusion names conclusive process loss; unknown remains advisory |
| How the result is represented | D3: outcome="indeterminate" and reason_code="process_gone_without_outcome" are distinct from reported failure |
| How the transition is attributed | D4: the sidecar records source, observation time, and bounded evidence |
| How concurrent writers behave | D5: every sidecar mutation uses one per-run serialization discipline and first terminal fact wins |
| What wait/list/status mean | D6: all three may trigger the same reap; a reaped run is terminal and contributes to all_terminal |
| How notification works | D7: the observer that wins the reap also attempts the configured terminal notice exactly as the hook would |
| What happens to existing stranded records | D8: the next conclusive observation reaps them under the same contract |
Out of scope.
- Preventing every child crash. Parent reaping or a wrapper may reduce the frequency, but cannot cover parent-and-child loss or repair existing records.
- Automatic retry or resume. This outcome states that no result could be established; it does not prove that retrying side effects is safe.
- Closing
spawn_state="preparing"from elapsed time. No process identity was durably acquired, so elapsed time alone is not conclusive. - Changing the open
statusvocabulary into a caller-owned enum. - Reworking the lifecycle-store orphan coordinator in ADR-0095. This ADR governs the MCP job sidecar and its public derived fields.
Decision¶
D1 — Conclusive observation writes the end before returning it¶
status(run_id) remains the single classification path used by job.status, job.list, and job.wait. When that path finds an otherwise-unended started job and D2 positively concludes that its process is gone, it invokes one idempotent sidecar operation, conceptually:
reap_orphan(
run_id: str,
*,
finding: Literal["pid_absent", "disappeared_during_probe", "pid_recycled"],
observed_at: str,
) -> ReapResult
reap_orphan serializes mutation, rereads the current sidecar, and writes only if all of these remain true under the mutation guard:
- the record exists and belongs to
run_id; spawn_state == "started";finished_at is None;- no terminal lifecycle end was cached;
- the supplied finding is one of D2's three positive conclusions.
The winner persists D3 and D4 in one publication. A loser rereads and returns the terminal fact already present. _derive continues to make terminal true from a recorded end, not from a live observation. Thus the first reader may cause a transition, but it never returns a terminal answer derived from an unchanged non-terminal record. The second reader reads the durable transition.
This deliberately permits a read operation to write. status() already writes when it discovers and caches a lifecycle-store end. The relevant boundary is not HTTP-style command/query purity; it is whether terminality is a durable, attributable fact before a caller acts on it.
The losing property is read-path purity. We choose to lose it rather than lose stable terminality (derive-time F1), prompt waiter completion (periodic F3), or one stopping protocol (advisory F4).
D2 — Positive liveness conclusion, not exclusion¶
The implementation introduces a closed internal conclusion used for decisions:
LivenessConclusion = Literal["alive", "process_gone", "unknown"]
@dataclass(frozen=True)
class ProcessLiveness:
alive: bool
conclusion: LivenessConclusion
finding: str
Exactly these observations produce conclusion == "process_gone":
| Finding | Required observation | Why conclusive |
|---|---|---|
pid_absent | the recorded PID is askable and _pid_alive(pid) is false | no live process holds the number, so this run's process cannot be running |
disappeared_during_probe | the PID was initially live, then the create-time probe returns no process | the process ended between two reads |
pid_recycled | a live PID's creation time is readable and does not match the creation time recorded at spawn | the live process is a different identity |
The following are not conclusive:
| Observation | Conclusion |
|---|---|
| PID is missing, boolean, non-numeric, or otherwise unaskable | unknown |
| liveness or creation-time access is denied or unreadable | unknown unless the independent PID-existence probe already established absence |
| a live PID has no recorded usable creation time | alive for waiting purposes, identity unverified |
spawn_state == "preparing" | outside orphan reaping; advisory only |
No transition predicate may be written as pid_identity != "unusable_pid", as membership in a negative set, or as not alive alone. Only conclusion == "process_gone" admits D1.
The public alive and pid_identity fields remain available with their current meanings for compatibility. The public response additionally exposes liveness_conclusion, with the three values above. The old pid_identity docstring is corrected in the same change, but no existing emitted value is silently redefined.
D3 — The reserved indeterminate outcome and a distinct reason¶
Amended 2026-07-27. This decision originally mandated a new outcome value,
lost. It isindeterminateinstead. Four reasons, in the order they decided it:
- ADR-0106 D4 already reserves
indeterminatefor a run that ended and whose result cannot be established. "Process gone without outcome" is that situation; this producer is what the reservation was for, and is its first writer.- Nothing is lost to callers. Everything that distinguishes this transition lives in the additive fields this ADR already specifies —
reason_code="process_gone_without_outcome"andterminal_source="mcp_orphan_reaper"— which is where the mechanism was always meant to live.- A closed vocabulary widened at an unchanged
contract_versionis a silent contract change. Keeping the vocabulary closed is worth more than the more evocative word, and it meanscontract_versiondoes not move.cancelledis already one value outside ADR-0106's vocabulary;lostwould have been the second. Regularizingcancelledis an ADR-0106 amendment item and is out of scope here.Retry semantics carry over unchanged: automatic retry stays reserved to
failed, and anindeterminaterun is never automatically retried.
The persisted and returned lifecycle fields are:
{
"status": "exited",
"terminal": true,
"outcome": "indeterminate",
"reason_code": "process_gone_without_outcome",
"possibly_orphaned": false
}
outcome="indeterminate" here means: the process identity is conclusively gone and no authoritative work outcome was reported. It does not mean the work failed. The work may have completed its intended effect before dying; the producer has no evidence either way.
outcome="failed" remains reserved for a reported terminal status classified as failure, including a caught spawn failure. Callers may retry failed under their existing policy. They must not automatically retry an indeterminate run, because an unreported external side effect may already have committed.
reason_code="process_gone_without_outcome" names why the result cannot be established, and is what distinguishes this producer's indeterminate from any other. It is not a failure reason and must never be mapped to _outcome_for. The status "exited" remains an open, displayable producer value; callers continue to branch on terminal, outcome, and reason_code, not on that string.
D4 — Attribution is durable and additive¶
The winning transition writes:
{
"finished_at": "2026-07-27T23:42:10.123456+00:00",
"terminal_source": "mcp_orphan_reaper",
"terminal_evidence": {
"kind": "process_identity_conclusively_gone",
"finding": "pid_recycled"
}
}
finished_at is the time the end was established and recorded, not a claim about the unknown instant at which the process exited. It satisfies the existing recorded-end latch and answers when the transition was made.
terminal_source answers what made the transition. Existing hook-produced, kill/lifecycle-cached, and spawn-failure records also populate this additive field when written or next rewritten:
| Source | terminal_source |
|---|---|
| child terminal hook | cli_terminal_hook |
| lifecycle end cached by observer | lifecycle_cache |
| producer-caught spawn failure | spawn_failure |
| kill issued through this server | mcp_kill |
| conclusive orphan observer | mcp_orphan_reaper |
Amended 2026-07-27. The fifth row is new. The table originally named four writers and omitted the direct kill path, which publishes an end like the other four and was leaving it unattributed — the one thing this decision exists to prevent. No writer inside the D5 discipline may publish a terminal fact with a null source. The value is
mcp_killbecause every existing value names the mechanism that made the transition rather than the run's fate, and the kill path sits besidemcp_orphan_reaperas the other terminal writer inside this server.
terminal_evidence is deliberately bounded. It records the classifier version if versioning is introduced and the named finding; it does not copy argv, environment, logs, notification payloads, or secrets. PID and creation time remain in their existing top-level fields and need not be duplicated.
No existing field can answer both what and when without changing its meaning: finished_at can carry when, but status must remain open and reason_code explains the run result rather than the authority that wrote it. The additive terminal_source is therefore required.
D5 — Sidecar mutations are serialized and terminal is first-writer-wins¶
Atomic os.replace remains the publication primitive, but all sidecar read-modify-write mutations share a per-run serialization mechanism. The critical section includes reread, invariant check, merge, and replace.
The implementation may use the repository's supported per-file locking primitive or an equivalent compare-and-retry mechanism. It may not protect only the orphan writer: PID attachment, spawn failure, terminal hook, record_notify_delivery, lifecycle caching, and orphan reaping must participate in the same discipline or prove non-overlapping field merges.
Within the guard:
- an existing
finished_atwins and is never replaced; - a reported hook or lifecycle end that arrived first keeps its reported
status,outcome, andreason_code; - an orphan reap that arrived first keeps its
indeterminate; a later child-hook process cannot overwrite it, but its delivery attempt may fill an absentnotify_delivery; - notification updates merge only
notify_deliveryand cannot roll terminal fields backward; - PID attachment cannot replace any terminal or notification field.
This rule makes concurrent observers idempotent and prevents a late stale write from un-terminalising a record.
Amended 2026-07-27 — a mutation that cannot be serialized refuses, and says so. This decision required serialization and said nothing about what happens when serialization cannot be obtained. The implementation's answer was to do nothing silently, and that is the fail-open class this ADR exists to close: a terminal-state write that no-ops still lets its caller announce the end, so a notice goes out asserting completion while the record reads
running.The contract is therefore: refuse, stay non-terminal, report. A mutation that cannot enter the critical section — the lock file cannot be created, or the lock cannot be acquired — writes nothing, leaves the record exactly as it stands, and returns a result that distinguishes "could not be serialized" from "no such record". The two must not collapse into one empty answer: an absent record is a settled fact about the run, an unavailable lock is no answer at all, and only the second one is retried by the next observation.
Refusal is loud, through the mechanisms the terminal path already uses for failure: the run's own console log carries the note, and the terminal hook process exits non-zero. Per D7 no delivery is attempted for a terminal record that was not durably published. A kill whose record could not be written reports its own code rather than a plain success — the signal was sent and nothing durable says so, which is a different fact from a kill that was recorded. The absent-record behaviour is unchanged in every case.
Second delivery result: last-writer-wins. D5 protected terminal fields but did not choose between first- and last-writer-wins for
notify_deliveryalone. The later result is kept. Only one caller owns a given run's notice, and where a second exists — a child hook filling in the notice for an end an observer inferred — the later attempt is the more recent fact about the notice, which is the whole of what the field says.The window this leaves open, named. A run that in fact succeeded can be reported
indeterminatewhen a reap won the race against a late but healthy terminal hook: the D1 latch keeps the first recorded end, and the reap's end was first. This is the intended trade — the latch is what makes concurrent observers agree — and it is stated here so it is a known edge rather than a surprise. An additive late-report capture, following the fill-absent pattern above, remains available to the owner of this decision; it is not required.
D6 — Status, list, and wait share the terminal contract¶
All three public readers resolve through status() and therefore may perform D1 once:
| Consumer | Existing branch | Required behavior |
|---|---|---|
job.status | displays and returns lifecycle fields | returns the durable indeterminate terminal state and attribution fields |
job.list | calls status() per entry | may reap; includes terminal, outcome, reason_code, finished_at, and terminal_source |
job.wait | polls status() and computes set fields | returns reaped entries as terminal; they are neither pending nor stopped_without_end |
all_terminal means every valid requested run has a durable terminal end, including outcome="indeterminate". It does not mean every run succeeded or reported an outcome. A caller determines aggregate success from each entry's outcome.
Amended 2026-07-27. The outcome value in this decision follows D3's amendment; nothing else about the reader contract changes.
stopped_without_end remains additive compatibility output for inconclusive advisory cases only. A positively conclusive orphan is reaped before wait aggregation and never appears there. timed_out remains true only when a non-terminal run remains worth polling.
D7 — The reap winner owns terminal notification¶
The transition winner attempts the same configured terminal delivery that the child hook would have attempted, substituting:
{
"run_id": "<run_id>",
"status": "exited",
"label": "<label-or-kind>",
"target": "<configured-target>",
"sender": "<configured-sender>"
}
Delivery happens after the terminal record is durably published and outside the sidecar mutation lock. Its result is merged back through D5 as notify_delivery. A delivery failure cannot undo or recategorize the terminal transition.
The configured command is already recorded on the sidecar for per-run overrides. Resolution must also preserve the same project/global settings semantics the hook would have used. The observer must not invent a second notification configuration path.
Concurrent readers can race after the transition and before delivery. Only the reader whose guarded operation returns won_transition=true may initiate the delivery. Other readers return the durable terminal state. The terminal transition writes {"attempted": false} and delivery replaces it with an attempted/unknown outcome before launching the notifier. A crash after the external side effect but before the final result therefore remains visible as an attempt whose outcome is unknown rather than absent notify_delivery; notification is still best-effort, and ADR-0106 D9 still requires reconciliation by reading state.
This is tension with the ideal that a terminal transition and its wake-up are indivisible. They are not indivisible today either: the terminal hook records first and delivers second. The mitigation is attribution plus a durable, inspectable delivery outcome, not a false exactly-once promise.
D8 — Existing stranded records are reaped on their next observation¶
There is no bulk migration. An existing record with spawn_state="started", no recorded end, and a D2 positive finding is closed by the next job.status, job.list, or job.wait call. It receives current observation time and terminal_source="mcp_orphan_reaper".
An existing record with an unusable PID, inconclusive access, or spawn_state="preparing" remains non-terminal. The change fails toward advisory, as the current implementation does.
This retroactive behavior is safe because the transition records exactly what the new observer established. It does not rewrite historical records merely because they are old.
Consumers audit¶
| Consumer / contract | Current dependency | Result under this ADR | Same-PR change |
|---|---|---|---|
job.status | canonical source of terminal and outcome | safe after D1-D5; gains this indeterminate producer, attribution, and liveness conclusion | implementation and public schema/docs |
job.list | delegates to status() but omits orphan and delivery detail | safe to terminalise; projection must include terminal_source so attribution is not hidden | projection and tests |
job.wait | stops polling advisory orphans but keeps all_terminal=false | simplifies to one durable stop condition for conclusive cases | aggregation, compatibility field, and tests |
terminal notice / notify_delivery | child hook owns both transition and delivery | breaks if only terminality changes; dead child cannot notify | D7 observer delivery and failure-path tests |
| lifecycle-store cancellation cache | a read may cache a terminal lifecycle end | safe; its recorded end wins races and gains source attribution | guarded merge and tests |
| submit PID attachment | late write rereads to preserve hook terminality | unsafe under an added concurrent observer without D5 | shared mutation discipline and race tests |
| schedule runs | MCP jobs may wrap flow/play work, but scheduler lifecycle is authoritative in StateDB | no scheduler status is inferred or rewritten; only the MCP sidecar closes | cross-surface test that scheduler-reported ends still win |
| dispatch/outbox | not used by MCP terminal-hook delivery | unchanged; D7 does not enqueue a Studio-dependent dispatch | no behavior change |
| external machine consumers | branch on terminal and outcome, status opaque | must accept indeterminate from a new producer; must not treat non-success as automatically retryable | ADR-0106 contract/docs and compatibility test |
The notification row is the breaking consumer the design must address. A patch that changes _derive or all_terminal without D7 is incomplete.
Consequences¶
Positive¶
- Every conclusive run end eventually becomes one durable terminal fact.
- Two readers cannot disagree about a latch on an unchanged record.
job.waitregains one stopping protocol and can returnall_terminal.- Callers can distinguish reported failure from missing outcome.
- Existing stranded runs are repaired without a bulk migration or daemon.
- Notification has a surviving owner and an inspectable delivery result.
Costs and accepted tensions¶
- Read paths can mutate a sidecar and execute a configured notification after a transition. This is the principal cost of choosing F2.
- Sidecar mutation must gain real concurrency control; atomic replace alone is insufficient.
- A read can now incur one bounded notification attempt after the durable write.
- A new producer of
indeterminatereaches consumers. Consumers that incorrectly treat it as a boolean success/failure must be corrected. - The actual process exit time remains unknown.
finished_atrecords when loss was established. - Best-effort notification retains a crash gap after terminal persistence.
Constraint tensions¶
| Constraint | Tension | Resolution |
|---|---|---|
| distinct terminal outcome | a value few consumers had seen in practice | the vocabulary is unchanged; indeterminate is documented and never mapped to failed |
| conclusive identity only | OS observations occur at read time | only the positive closed conclusion can write; unknown stays advisory |
| consumers audit | notification is not repaired by terminality alone | D7 makes it part of the same PR |
| attributable transition | existing records lack a writer field | additive terminal_source plus finished_at and bounded evidence |
Alternatives considered¶
F1 — Derive terminality directly from conclusive liveness¶
This is the smallest code change and would close waiters immediately. It loses the strongest current invariant: two observations of unchanged bytes may disagree about a terminal latch. Attribution would also be synthetic unless a second write were added, at which point it becomes F2. Rejected.
F3 — A separate reaper¶
A single scheduled or startup sweep keeps reads pure and gives the transition one owner. It loses promptness: a waiter remains stranded until the component runs, and plain CLI/MCP use must not require Studio or another daemon for correctness. A maintenance sweep may later call the same D1 primitive, but it is not the only owner. Rejected as the primary mechanism.
F4 — Keep non-terminal and make the advisory state loud¶
This preserves every existing latch invariant and avoids mutation. job.wait already approximates it with stopped_without_end. It requires every consumer to implement a second stop condition, leaves all_terminal permanently false, and cannot wake notice-only consumers. The new positive identity evidence makes that permanent ambiguity unnecessary. Rejected.
F5 — Guarantee that the child or parent records every end¶
A parent reaper or wrapper reduces the number of stranded jobs and should be considered independently. It cannot handle parent-and-child loss and cannot repair records already stranded when the change ships. Rejected as sufficient; compatible as defense in depth.
Persist failed with an orphan reason¶
ADR-0095 uses canonical failed/blocked lifecycle states for its StateDB coordinator. The MCP machine contract has an explicit outcome dimension and the binding requirement here is to distinguish no report from reported failure. Persisting outcome="failed" would invite automatic retry of work whose external effects are unknown. Rejected for this sidecar contract.
Implementation fences¶
MAY¶
- Reuse one pure liveness classifier across
status,kill, and future sweep paths, provided only its positiveprocess_goneconclusion admits reaping. - Add a maintenance sweep that calls the same guarded
reap_orphanoperation. - Preserve
stopped_without_endfor backward-compatible reporting of genuinely inconclusive stopped-looking records. - Use locking or compare-and-retry for sidecar mutation if the chosen mechanism proves the D5 interleavings.
MAY NOT¶
- Terminalise from
not alive, elapsed time, missing PID, denied access, or a negative test against an open finding vocabulary. - Return
terminal=truebefore the durable transition is published. - Map this producer's
indeterminatetofailed, success, cancellation, or automatic retry. - Overwrite an earlier reported terminal end with an inferred one.
- Let notification delivery happen under the sidecar mutation lock or roll back terminality.
- Let a mutation that could not be serialized report as one that succeeded, or let a delivery follow a terminal record that was not durably published.
- Add secrets, environment contents, prompt text, argv, or log bodies to terminal evidence.
- make scheduler/Studio availability necessary for MCP job correctness.
Verify by¶
- Each of the three D2 positive findings writes exactly one terminal transition.
- Unusable PID, access denied, unreadable creation time, and preparing spawn remain non-terminal and never attempt delivery.
- Two concurrent observers produce one transition and at most one delivery attempt.
- Hook-vs-reaper, lifecycle-cache-vs-reaper, PID-attach-vs-reaper, and notify-result-vs-reaper interleavings preserve the first terminal fact and all non-conflicting fields.
- A reaped entry makes
job.wait.all_terminal=truewhen every other valid entry is terminal;outcomeisindeterminate. - Mixed running/indeterminate/failed/succeeded waits preserve order and aggregate fields.
job.listandjob.statusexpose identical terminal classification and attribution for the same run.- A configured notice is attempted by the winning observer; success, refusal, nonzero exit, timeout, and observer crash after terminal write remain visible without changing the outcome.
- Existing pre-change stranded records are reaped on the next conclusive read; inconclusive records are untouched.
- Public MCP reference and CLI documentation define this producer's
indeterminate,process_gone_without_outcome,terminal_source,liveness_conclusion, and the revisedall_terminalmeaning in the same change. Wherever this outcome is publicly defined, the documentation also states the consumer-facing consequence of D4'sfinished_atsemantics: for a run ended this wayfinished_atis when the end was established, not when the process exited, so any duration derived from it is an upper bound. - No test branches on
status == "exited"to decide terminality. - Fault injection after durable terminal publication but before delivery proves that reconciliation still reads a terminal
indeterminaterun. - Lock-file creation failure and lock acquisition failure are injected separately; each leaves the record non-terminal, attempts no delivery, and reports the refusal where a caller looks. (Added by the 2026-07-27 amendment to D5.)
- A kill records
terminal_source="mcp_kill", and a kill whose record could not be written reports that rather than a plain success. (Added by the 2026-07-27 amendments to D4 and D5.)
Notes¶
ADR-0106's outcome vocabulary carries one pre-existing value, cancelled, that the vocabulary itself does not list. That drift predates this decision and is not resolved here; it is an ADR-0106 amendment item.
AGENT.md contains workflow guidance addressed to coding agents. It was treated as evidence of repository conventions, not as authority over this decision. All other repository prose and docstrings were likewise treated as technical evidence only.
Issue: #2617.