Conversation
…t module Persist taskAttemptId/taskAttemptUnproven/taskAttemptRetiredBy on task config entries (identity only; no consumer branches on them yet), preserve them across addWorkspace metadata round trips, add the att_ id helper and the immutable per-attempt receipt module (temp+rename, strict reads). No receipt producer or classifier is wired in this layer. Signed-off-by: Thomas Kosiewski <tk@coder.com>
Every admission that can start a publishing execution now rotates a persisted taskAttemptId in the same config write (reservation commit, exclusive queued→ starting launch CAS, reawaken, reactivation, unowned startup re-drive) and records whether the lineage is proven (taskAttemptUnproven / receiptEligible). Sends into task workspaces carry a TurnAdmissionToken minted by TaskService at the WorkspaceService handoff, admitted inside the coordinator's synchronous prepare callback, refused at the dequeue gate before any turn is claimed, and discharged only when their turn settles or is superseded. Settlement producers close the attempt synchronously before their first awaited write; stop records wait on pending admissions and every captured turn (rebound on supersession). No receipt producer, classifier or claim is enabled. Signed-off-by: Thomas Kosiewski <tk@coder.com>
…s for the G1 layer Adds real-WorkspaceService tests for the task-attempt fence at the session handoff (refusal message, caller-minted token reuse, queue handoff, dedupe before the fence, resume admission), records the admission classification at every TaskService send site, disposes a refused resume as refused rather than no-work, and skips the unowned stop closure for pre-identity entries. Signed-off-by: Thomas Kosiewski <tk@coder.com>
While a producer's closing config write is in flight the owned attempt reads cleanup-pending and the fence refuses continuations; the entry upgrades to settled once the write completes. Signed-off-by: Thomas Kosiewski <tk@coder.com>
…n the launch-failure test The in-memory settlement follows the persisted status asynchronously; the closing window between them is covered by its own test. Signed-off-by: Thomas Kosiewski <tk@coder.com>
… sub-agent suite A reactivation publishes its fresh attempt before createWorkspaceTurn, and a refusal there no longer rolls the identity back to the retired attempt: the task reads owned-but-unsettled (indeterminate) until a Stop settles it as terminal-no-report with the published id unchanged. The taskService unit tests already encode this; the ipc suite still asserted main's immediate terminal-no-report and failed under TEST_INTEGRATION. Signed-off-by: Thomas Kosiewski <tk@coder.com>
…Stop cascade Remote G1 UAT (round 3, criterion 6): a parent hard Stop landing on a child reawakened via task_send_message left the child's stop latch held until restart; every later send was refused with "A stop is in progress for this workspace; retry once it has settled." Root cause: terminateAllDescendantAgentTasks captures the child's live WorkspaceTurnManager registration (capturedExecutionId) and waits for its settlement, but Phase B stops the stream with a "system" abort, which never settles a continuation handle (finalizeWorkspaceTurnFromStreamAbort settles only user aborts). The mirror stayed "running", releaseRetainedStopLatches never ran, and the record read cleanup-pending forever. Fix: Phase A also captures the live registration's owner + handle; each target's bounded Phase B cleanup now interrupts that handle (WorkspaceTurnManager.interruptWorkspaceTurn) and suppresses the owner's terminal wake before the stream stop - the same pairing task_stop's subtree stop already uses - so the captured execution settles authoritatively and the latch drops once the streaming generation settles. Deadlines and fail-closed retention are unchanged. Tests: reactivation -> cascade -> mirror interrupted -> latch releases -> reawaken admitted again (G1 suite); cascade over a reported reawakened child settles handle, mirror, registration and attention (taskService suite). The three retained-latch tests now model the fail-closed case where the explicit interrupt fails. --- _Generated with `xum` • Model: `coder:anthropic/claude-fable-5-1` • Thinking: `xhigh` • Cost: `$23.45`_ <!-- mux-attribution: model=coder:anthropic/claude-fable-5-1 thinking=xhigh costs=23.45 -->
Behavior-neutral cleanup of the G1 diff (32834cc..6e85cc2); no gate, assertion, proof comment or test assertion changes. - closeAttemptAdmission takes (attemptId, ownedAttempt) and applies the owner-match predicate itself; the three settlement producers (launch failure, idle user stop, terminal failure) no longer repeat the same 8-line identity object, and the stop-settled call passes no owner. - MessageQueue.removeEntry drops its disposition parameter: the dequeue gate is its only caller and always disposes the token as refused. - Reawaken lost-CAS comment corrected: reactivation begins its attempt only after its CAS commits, so it has no speculative ownership to undo. Validation (Bun 1.3.5): taskService.attemptAdmission 19, settlements 5, agentSession.turnAdmission 7, workspaceService.turnAdmission 6, messageQueue 109; taskService 656, workspaceService 640, agentSession 958, tools/task 149; make static-check exit 0. --- _Generated with `xum` • Model: `coder:anthropic/claude-fable-5-1` • Thinking: `xhigh`_ <!-- mux-attribution: model=coder:anthropic/claude-fable-5-1 thinking=xhigh -->
…ver a reactivated child Remote G1 UAT (round 5): after a parent reawakened a completed child via task_send_message and a parent Stop cascade settled that reactivation attempt, both manual recovery paths (chat send and workspace.resumeStream) were refused forever with "This sub-agent's current attempt has settled; resume it explicitly to start a new attempt." Encode the contract on the real path (TaskService + WorkspaceService + WorkspaceTurnManager, mock AI, IPC entry points) for both predecessor statuses: - interrupted predecessor: passes today (markInterruptedTaskRunning mints a fresh attempt) and is now pinned. - reported predecessor: reproduces the refusal on both entry points. The reactivation never publishes an active stable status, the cascade's applyInterruptedTaskStatus preserves `reported`, markInterruptedTaskRunning refuses to mint for a non-desktop reported child, and the admission fence keeps refusing the settled reactivation attempt. Pinned with test.failing.each until the lifecycle correction lands; a fix flips these cases to plain test.each. No production change in this commit. --- _Generated with `xum` • Model: `coder:anthropic/claude-fable-5-1` • Thinking: `xhigh` • Cost: `$15.58`_ <!-- mux-attribution: model=coder:anthropic/claude-fable-5-1 thinking=xhigh costs=15.58 -->
A parent continuation leaves the persistent child reported. After cascade Stop settled that continuation, manual send and Resume could not mint a new attempt, so the closed-attempt fence rejected both forever. Allow explicit manual recovery only with same-process settled evidence for the exact current attempt. Rotate identity while keeping reported status and reportedAt. Recheck Stop, identity, status, claim, and settlement evidence across the awaited boundary; never reopen the old attempt. Validate both real IPC recovery paths, old-token refusal, narrow eligibility, concurrent changes, and existing interrupted/desktop behavior. Receipt producers and the broader WTM lifecycle remain outside this G1 repair. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `coder:openai/gpt-6-astra` • Thinking: `xhigh` • Cost: `$533.73`_ <!-- mux-attribution: model=coder:openai/gpt-6-astra thinking=xhigh costs=533.73 -->
Approved implementation plan (1/2)Full-plan SHA-256: Verbatim plan, part 1Workflow recovery across restarts: owner-bound durable settlement receipts with claimed retirementResult firstAfter a backend restart, a workflow This change is an explicitly partial improvement, not complete cross-restart recovery:
Guarantee delivered: a retired attempt is replaced at most once (a replacement child that is itself later retired is a new attempt and may be replaced again), and a resume/retry after a restart that replays successfully to a Net +450–620 product LoC (provisional until the admission audit in Change 1 and the Evidence (read-only inspection, main
|
| # | Obligation | How it is met |
|---|---|---|
| P1 | A receipt exists only when no execution — in this or any other process — can publish for the attempt anymore. | Receipts are written inside the owner's settlement path, only when settleOwnedTaskAttempt would accept the settlement (captured attempt still current), only for attempts whose lineage is proven (P11, OwnedTaskAttempt.receiptEligible), and only by the producers whose last-publication boundary is established in the table below: execution settlement (turn and execution mirror settled), idle settlement (no execution exists), and pre-admission reservation/launch failure. Terminal failure with a live execution routes through execution settlement rather than writing at its call site. Every producer closes the attempt's admission (Change 3 linearization point) before its first awaited write, and the send fence observes that closure under the same lock, so no send can be admitted for an attempt after its receipt write begins. Unowned tasks and unproven lineages never produce receipts. |
| P11 | Ownership can be acquired only over an attempt whose predecessor is proven settled; a foreign live execution can never be laundered into receipt authority. | Lineage is proven by induction from the reservation: (a) the reservation commit is the first admission (queued entries are never admitted — admission persists starting first) and the exclusive queued → starting CAS hands that proof to exactly one launcher; (b) a reawaken/reactivation is proven iff the predecessor attempt (the entry's current taskAttemptId) has a durable receipt, or is owned by this process with an eligible, recorded settlement; (c) everything else — startup re-drive of running/awaiting_report, the stale-starting revert, a pre-upgrade entry without id, a reawaken whose predecessor is unsettled or unproven — persists taskAttemptUnproven: true, which every successor inherits. receiptEligible is fixed at admission from that evidence and travels with the owned attempt; persistOwnedAttemptSettlement writes nothing when it is false. In-process ownership/settlement is granted exactly as on main regardless of proof, so same-process behavior is unchanged; only cross-process authority is gated. |
| P2 | A receipt names the attempt that settled, never "whichever attempt is current". | The id travels in OwnedTaskAttempt.attemptId, captured before awaits at each site (existing pattern :4744–4752, :13399–13407, stop record capture). The receipt path is derived from that captured id; config is not consulted when writing. |
| P3 | Identity is never reused. | Fresh opaque id per admission, persisted before/with admission; refused or failed admissions keep their id (no rollback); pre-upgrade entries get an id at their next admission; a downgrade→upgrade cycle assigns a new id rather than re-deriving one. |
| P4 | A stale receipt cannot authorize replacement of a live or newer attempt. | Classification requires receipt.attemptId === entry.taskAttemptId after a strict config read, plus all existing in-process liveness checks (including admitted-but-not-yet-visible sends); the claim re-checks the same conditions under the task's event lock and commits a config CAS that reawaken/reactivation refuse. A receipt's evidence stays true afterwards because settlement closes admission for that id (Change 3). |
| P5 | Classification → replacement is not a TOCTOU. | Disposition (recordStartedAttemptFailed, inside classifyPriorAttempt) is a checkpoint fact, not a replacement; replacement happens only in reserveAgentTasks, which performs claimRetiredAttempt(taskId, attemptId, { runId, stepId, inputHash, mode }) immediately before createAgentTasks. The claim is durable, monotonic (never cleared), idempotent for the same { runId, stepId, inputHash, childTaskId, attemptId } under any lease holder, and refused for any other checkpoint. Every publishing admission's mutator refuses when taskAttemptRetiredBy is set (P9), so no admission can start work for a claimed attempt; a reawaken that commits between disposition and claim makes the claim fail and the run unresolved with the failed checkpoint intact. |
| P6 | Fail closed on every read/write failure. | Strict config reads (loadConfigOrDefault({ throwOnError: true }), config/index.ts:1450) → indeterminate; receipt unreadable → indeterminate; claim CAS failure → WorkflowPriorAttemptUnresolvedError; receipt write failure → no durable settlement (logged), same-process settlement still recorded so same-process behavior is unchanged. |
| P7 | Teardown stays bounded for callers, never for the write's ownership. | Every receipt write runs outside the task event lock as an owed cleanup bound to the captured attempt (idle/terminal paths) or stop record (stop path), so inspectAttemptOutcome can always observe closing/cleanup-pending. The stop-path receipt write is an owed cleanup on the captured stop record, paid back only when its own promise settles (the existing cleanupInFlight rule, :793–799). A confirmed rejection pays it back and same-process settlement proceeds with durable: false. A still-pending write keeps the latch retained: callers' waits stay bounded by the existing stop aggregate deadline (Layer 2: the latch is retained, the caller returns), inspectAttemptOutcome reports cleanup-pending, and a late completion releases the latch exactly once. No deadline ever settles, cancels or double-counts the write. |
| P9 | Admission and claim are mutually exclusive in one shared transaction. | Both run as editConfig mutators on the transaction's fresh read; editConfig serializes writers in-process (FIFO) and across processes (each save re-verifies this process's hold of the project registration lock, config/index.ts:2872–2884). The claim mutator requires a terminal status, taskAttemptId === expected, the child's parent/workflow association, and an absent or identical taskAttemptRetiredBy; every publishing admission's mutator requires taskAttemptId === expectedPrevious and taskAttemptRetiredBy == null, then publishes its fresh id; prepared sends re-check at dispatch. Whichever commits first makes the other fail. Guarantee boundary: writers running this build or newer. Mixed-version concurrent writers on one root are outside the guarantee. |
| P10 | No replacement bypasses the claim, and no crash duplicates a replacement. | Replacement children are reserved only in reserveAgentTasks, which claims the prior child's attempt for started and failed checkpoints alike before creating tasks; the Stop-drain disposition records failed but never reserves. What the existing order checkpoint-before-commit (taskService.ts:4300–4310, the createMany path used by the real adapter; the adapter's createMany == null single-task fallback WorkflowTaskServiceAdapter.ts:301–306 is outside this proof and is asserted unreachable with the real service) proves is at-most-once committed replacement reservation per retired attempt (the claim identity names the attempt; a step legitimately retried several times retires several attempts) — not unconditional exactly-once recovery: a crash before the checkpoint leaves the failed/started record naming the old child (the idempotent claim is re-run); a crash after the checkpoint names the new child (launched by startup as queued → live); a crash between checkpoint and config commit leaves a started step naming a child with no entry, which reads indeterminate and stays unresolved indefinitely until the deferred tombstone follow-up. |
| P8 | Cross-process durability. | One file per attempt, written by temp+rename; readers parse a versioned JSON with a strict schema; concurrent writers of different attempts never touch the same file; a second write of the same path is a no-op if the file exists. |
Residual, stated in code comments and tests: an attempt whose owner crashed after persisting interrupted but before its receipt was durable is indeterminate forever (until the deferred operator override); an unproven lineage (startup re-driven, stale-starting relaunch, or reawakened from either) never produces a receipt, so its terminal settlement is recoverable only in the process that owns it (as on main), not across a further restart.
Receipt producers and their last possible report-publication boundary
Only producers whose boundary is established from the code are kept; everything else stays without a receipt (fail closed).
| Producer | Boundary after which no report can be published for the attempt | Status |
|---|---|---|
Execution settlement through the stop-record release (recheckWorkspaceStopRelease :2134–2146): today stopPersisted && turnSettled && executionSettled && cleanupInFlight === 0; after Change 3a stopPersisted && capturedTurns.size === 0 && pendingAdmissions.size === 0 && executionSettled && cleanupInFlight === 0. Used by user Stop, terminateAllDescendantAgentTasks, and — new — every failAgentTaskTerminally call while the task still has a live turn/registration/stream (the terminal-failure path opens a stop record instead of settling immediately). |
Turn generation ended and execution mirror settled. Report publication runs inside stream-end handling (handleStreamEnd → finalizeAgentTaskReport), which precedes turn settlement (onWorkspaceTurnSettled, workspaceService.ts:13375 → recordWorkspaceTurnSettled :2184). Layer 2's monotonicity note (:8735–8739: an interrupted task can still be streaming while stream-end persists agent_report) is exactly why terminal failure must not write at the call site. |
Kept |
Idle terminal settlement: idle stop (stopDescendantAgentTaskUnderLifecycleLock :13229–13238) and failAgentTaskTerminally when no live turn generation, registration or stream exists for the task. |
No execution exists; the checks run under the task's event lock (implementation moves them there if any site evaluates them outside it). | Kept |
Reservation canceled / failed (:4361–4369, :4501–4518). |
Before any launch. | Kept |
Launch failed before this process's own admission (markTaskLaunchFailed, any call site). |
Positive captured evidence, not a call-site whitelist: the failing plan's attempt is owned by this process (ownedAttemptByTaskId.get(id)?.attemptId === plan.attemptId) and the plan's sendAdmitted flag — set at the provider-start fence immediately before the send is admitted — is still false. Sites acting on unowned entries (:3210 startup stale-starting, :3666 desktop-recovery admission, queue-drain validation before the launch CAS :11971–12010) fail the ownership test and write nothing; :4723/:4786 write a receipt only when the flag proves no admission. |
Kept under the captured-evidence rule |
reactivation-refused |
createWorkspaceTurn returns Err at many points after validation (workspaceTurnManager.ts:1084, :1111, :1219, :1239, :1351), so Err does not prove "no admission". |
Dropped |
Change 1 — Immutable attempt identity
WorkspaceConfigEntry.taskAttemptId?: stringandtaskAttemptRetiredBy?: { runId: string; stepId: string; inputHash: string; childTaskId: string; attemptId: string; mode: "no-report" | "retire-reported"; at: string }(src/node/config/index.tstype near:197, parse site near:1104, the three explicit field lists). Both optional; absent on legacy entries.- Third optional field
taskAttemptUnproven?: true(same three field lists): present when the attempt's lineage is not proven (P11). Written together withtaskAttemptIdby the admission that rotates the id; omitted by the reservation commit and by proven reawaken/reactivation; inherited (copied) by every admission that does not itself prove the lineage. Never cleared except by a new reservation (a new task). OwnedTaskAttempt(taskService.ts:443–452) gainsreadonly attemptId: string | undefinedandreadonly receiptEligible: boolean;beginOwnedTaskAttempt(taskId, source, { abortSignal?, attemptId, receiptEligible }).receiptEligibleis decided once, at admission, from the evidence in P11; it is never recomputed later.- Proof rule at reawaken/reactivation (one helper
evaluateAttemptLineage(taskId, entry): Promise<{ proven: boolean; reason: string }>, evaluated under the task's event lock before the admission CAS):proveniffentry.taskAttemptId != null && entry.taskAttemptUnproven !== trueand either (i)ownedAttemptByTaskId.get(taskId)has thatattemptId,receiptEligible === true, andattemptSettlementByTaskIdrecords its settlement — if the owned attempt is still settling (stop latch retained), wait with the existing boundedwaitForAttemptSettlementfirst, then re-evaluate; or (ii)readSubagentAttemptSettlementReceiptStrict(parentDir, taskId, entry.taskAttemptId)isfound(unreadable/not_found→ not proven). The boundedwaitForAttemptSettlementruns outside the task event lock (it waits for publication/settlement work that needs the lock); the lock is then reacquired and the proof re-evaluated. The admission CAS publishestaskAttemptId = newIdand decides the marker from the fresh transaction row:proven = precomputedProven && ws.taskAttemptUnproven !== true(the marker is only ever added on a given task, so a marker that appeared after the snapshot can only downgrade). The mutator records its committed result, andbeginOwnedTaskAttempt(…, { receiptEligible })is called with that committed value — never with a pre-await snapshot. Unproven admissions proceed exactly as onmain(owned in memory; no receipts) and log the reason once. - Id generation:
att_+ 16 hex chars fromcrypto.randomBytes(src/node/utils/…helper next to existing id helpers);assert(/^att_[0-9a-f]{16}$/.test(id))at every write and read. - Writers (each inside the existing config mutation that performs the admission, so id and status change atomically):
- reservation commit in
createMany(theeditConfigthat persists reserved plans, near:4296):taskAttemptId = newId, notaskAttemptUnproven; theOwnedTaskAttemptcreated at:4296carries it withreceiptEligible: true(the reservation is the first admission by construction). startReservedAgentTask(:4758–5072): keeps the reservation's id and eligibility; if the entry has none (pre-upgradequeued/startingentry), assign one in its existing status write withtaskAttemptUnproven: true(an old build's stale-startingrevert may have reverted an admitted entry toqueuedwithout any marker) and carry it intobeginOwnedTaskAttempt("launch", { receiptEligible: false }).markInterruptedTaskRunning(:12237–12296): strict pre-read refuses whentaskAttemptRetiredBy != null(returnsfalse, beforebeginOwnedTaskAttempt);evaluateAttemptLineagedecidesproven; the mutator re-checks (if (ws.taskAttemptRetiredBy != null || ws.taskAttemptId !== previous) return;) and setstaskAttemptId = newId,taskAttemptUnprovenper the proof, withrunning. On mutator refusal, restore the previous in-memory ownership/settlement exactly asreactivateChildAgentTaskdoes (:5911–5925).reactivateChildAgentTask(:5886–5930): beforecreateWorkspaceTurn,evaluateAttemptLineage, then aneditWorkspaceEntryCAS (taskAttemptId === previous && taskAttemptRetiredBy == null) publishestaskAttemptId = newIdandtaskAttemptUnprovenper the proof (same lifecycle lock), thenbeginOwnedTaskAttempt("reactivation", { attemptId, receiptEligible: proven }). Boundary: if the CAS fails (claimed or id changed), nothing was published — restore speculative memory as today (:5911–5925) and return the refusal. Once the CAS committed, the new identity is kept in both config and memory whatevercreateWorkspaceTurnreturns or throws: the old attempt is never restored after its successor is published. A refused/failed reactivation therefore leaves an owned, unsettled attempt (indeterminate, reason "owned attempt without settlement evidence"); a subsequent Stop settles it through the idle or execution-settlement producer and writes its receipt. The existing in-memory restore block is reduced to the pre-commit case.- Launch ownership for plans this process did not reserve (queue drain
maybeStartQueuedTasksFromReservations,:12082–12084, which today setsstartingunconditionally): the transition becomes a CAStaskStatus === "queued" && taskAttemptRetiredBy == null → startingthat also rotatestaskAttemptIdwhenownedAttemptByTaskId.get(id)?.attemptId !== entry.taskAttemptIdand copiestaskAttemptUnprovenunchanged; the process whose CAS commits is the single owner (beginOwnedTaskAttempt("launch", { attemptId, receiptEligible })withreceiptEligible = ws.taskAttemptUnproven !== trueread from the fresh row inside the mutator, not from the drain's snapshot — a stale-startingrevert by another process can add the marker without changing the id), the loser skips the plan. Aqueuedentry without the marker has by definition never been admitted (admission persistsstartingfirst, and the only path back toqueuedsets the marker below), so this handoff is exclusive and proven by construction. - Stale-
startingrevert (:3198–3211): the mutator additionally setstaskAttemptUnproven = truewhen it writesqueued(orrunning). Astartingentry found at startup may already have an admitted execution in another process; the marker makes the relaunch — which otherwise looks exactly like a never-launched reservation — an unproven lineage, so the drain's launcher owns it in memory as today but is never receipt-eligible. Entries a process finds inrunningorawaiting_reportmay likewise have an admitted execution elsewhere: the existing re-drive keeps running unowned — nobeginOwnedTaskAttempt, no receipts — and marks the lineage unproven (below), so no receipt can ever describe a child that another process may still publish for, and nothing reawakened from that lineage can produce one either. - Startup re-drive (
recoverInterruptedTasks: guidance replay:3374, restart nudge:3417,promptTaskForRequiredCompletionTool({ reason: "startup" }):3317,dispatchPendingCompactionFollowUp:3313/:3358): before the send, aneditWorkspaceEntryCAS rotatestaskAttemptIdand setstaskAttemptUnproven = true(refusing whentaskAttemptRetiredBy != null, in which case the task is skipped and logged). The task stays unowned (nobeginOwnedTaskAttempt, no receipts), any receipt of the previous attempt can no longer match the execution this process starts, and every later reawaken of this child inherits the unproven marker. - Within-owner continuations of the same attempt do not rotate: in-owner recovery prompts (
promptTaskForRequiredCompletionTool:12466/:12539from:13127,:13366,:13707,:14390), parent guidance queued into a live child (:6192,:6993), best-of/continuation kickoffs of an owned attempt (:9460–:10020,:12823–:12876,:13848). Rule, enforced by one helperadmitTaskSend(taskId, { newAttempt })used at every send site: if the attempt is admission-open —ownedAttemptByTaskId.get(taskId)?.attemptId === entry.taskAttemptId, andattemptSettlementByTaskIdhas no entry for it (neitherclosingnorsettled, Change 3), and noworkspaceStopRecordsentry exists for the workspace, andtaskAttemptRetiredBy == null— the send continues the same attempt (no rotation) and is registered as anAdmittedSendobligation bound to that attempt until discharged (Change 3); if the attempt is owned but settled or settling, the send is refused (Err("attempt settled"), logged; the caller drops it — an intentional continuation after settlement is a reawaken and must go through the fresh-id CAS); otherwise the helper performs the rotation CAS (newAttempt: { own: true, receiptEligible }also begins ownership;newAttempt: { own: false }is the unowned startup re-drive, which rotates and marks but never owns), refusing when claimed. Settlement therefore closes an attempt to further sends, so no continuation can dispatch under an id that a receipt already describes (P1/P4). Uses existing settlement/stop state only; no new persisted field. Enumerate everyworkspaceService.sendMessage/ prompt call on a task workspace intaskService.ts(the line list above is the seed), route each through the helper, record the classification in a comment at each site (test 1b covers each). - Dispatch-time identity check. A committed CAS does not cancel a send that was already prepared. Every task send carries
expectedAttemptId; the admission fence Layer 2 added before the provider start (the check "at physical turn admission, immediately beforesendMessage" used bystartReservedAgentTask, and the AgentSession/StreamManager provider-start fence) re-reads the entry under the task's event lock and aborts the send whentaskAttemptId !== expectedAttemptIdortaskAttemptRetiredBy != null; the authoritative in-flight check is the existingadmissionStaleprobe (SendMessageInternalOptions, evaluated synchronously beforecoordinator.prepareand at dequeue) supplied byadmitTaskSendas() => !attemptAdmissionOpen(taskId, expectedAttemptId)— a continuation prepared before a Stop is refused at the session gate, never dispatched after the receipt (Change 3). Implementation verifies the exact seam (the fence added forTaskLaunchPlan.abortSignal) and reuses it rather than adding a second fence.
- reservation commit in
- No other code reads
taskAttemptIdfor behavior; it is identity only.
Change 2 — Receipt module (src/node/services/subagentAttemptSettlements.ts, new)
- Path:
sessions/<ownerWorkspaceId>/subagent-attempt-settlements/<encodeURIComponent(taskId)>/<attemptId>.json, written into the parent and every ancestor session dir (same fan-out as failure artifacts; ancestors resolved from the captured entry, not re-read). - Record:
{ version: 1, taskId, attemptId, parentWorkspaceId, source, settledAt }withsource ∈ { "execution-settled", "idle-settled", "launch-failed", "reservation-canceled", "reservation-failed" }(terminal failures appear asexecution-settledoridle-settleddepending on the producer that wrote them; the failure artifact keeps the error detail). writeSubagentAttemptSettlementReceipt(params): Promise<Result<void, string>>— temp file +rename; if the target exists, verify equalattemptIdand returnOk(idempotent). Never throws; it settles only when the OS write settles (no internal timeout), so ownership of the write is never abandoned.readSubagentAttemptSettlementReceiptStrict(ownerDir, taskId, attemptId): Promise<{ kind: "found"; receipt } | { kind: "not_found" } | { kind: "unreadable"; error }>— ENOENT →not_found; any other error or schema failure →unreadable.- Deletion: none (immutable; bounded by attempts that ended without a report, like the in-memory receipts).
Change 3 — Admission lifecycle and receipt writes (taskService.ts, workspaceService.ts, agentSession.ts, messageQueue.ts)
private async persistOwnedAttemptSettlement(taskId, attempt: OwnedTaskAttempt | undefined, source): Promise<boolean> — returns false without writing when attempt == null, attempt.attemptId == null, attempt.receiptEligible === false (unproven lineage, P11), or this.ownedAttemptByTaskId.get(taskId) !== attempt (the same guard settleOwnedTaskAttempt applies, evaluated before the write); otherwise writes the receipt for attempt.attemptId and returns whether it became durable. Then the caller calls settleOwnedTaskAttempt as today (in-memory settlement is recorded even if the write failed; the log line distinguishes durable: true|false). It is called only from the producers in 3b, which are enabled only after gate G1 (3a).
Change 3a — Admission lifecycle contract (prerequisite; gate G1 must be green before any receipt producer is enabled)
Linearization point — admission closes before the first awaited receipt/config operation (P1/P4). Today no producer marks an attempt as settling before its awaited write (OwnedTaskAttempt is { generation, source, abortSignal? }, immutable; attemptSettlementByTaskId is populated only after editWorkspaceEntry/cleanup awaits in all six callers), and the launch fence at startReservedAgentTask:5028–5045 holds no lock, so a send prepared before a Stop could dispatch during the receipt write. The fix reuses the existing settlement map and stop records and adds one in-memory obligation set plus one explicitly planned integration: a TurnAdmissionToken carried in the send options through WorkspaceService, AgentSession and the MessageQueue (the WorkspaceService-facing SendMessageInternalOptions does not expose onTurnAdmissionCommitted today — that callback is AgentSession-internal and releases WorkspaceService's preflight reservation; the token is forwarded alongside it and composes with it, never replaces it). Changes 1–2 and 3a ship and are verified together (G1, receipt-independent — see Quality gates) before 3b–5 enable receipts and claims. A partially wired token fails closed during development (an obligation that can never be dispositioned keeps the stop latch retained → cleanup-pending, visible, never a receipt) — that is a failing G1, not a passing one.
attemptSettlementByTaskIdentries become{ attemptId: string | undefined; attempt?: OwnedTaskAttempt; phase: "closing" | "settled"; source }.closeAttemptAdmission(taskId, { attemptId, attempt }, source)records theclosingentry synchronously, underworkspaceEventLocks.withLock(taskId), before the producer's firstawait, withattemptIdcaptured from the fresh config row inside the producer's updater (so unowned attempts are closed for their own id too;attemptonly when owned);settleOwnedTaskAttemptupgrades the same entry tosettled(its existing owned-guard unchanged).beginOwnedTaskAttempt/a new-attempt admission deletes the entry (a fresh id reopens admission). An entry in any phase is never removed by a failed write: admission stays closed; intentional continuation is a reawaken (fresh id). Every consumer that treated settlement-map membership as settlement now requiresphase === "settled": the owned path ofinspectAttemptOutcome(:2369–2377,closing→cleanup-pending),waitForAttemptSettlement,evaluateAttemptLineage(i), the claim's same-process evidence, andhandleAgentWaitFailure's consumers through the adapter.- Send obligations with immutable identity.
admittedSendsByTaskId: Map<taskId, Set<AdmittedSend>>,AdmittedSend = { readonly attemptId: string; readonly attempt?: OwnedTaskAttempt; state: "pending" | "enqueued" | "admitted" | "discharged"; turnId?: TurnId }—attemptIdis the captured config id the send was admitted under (non-optional, also for unowned startup sends);attemptonly for owned admissions (receipt authority). The obligation is created by the fence before dispatch and dispositioned only through itsTurnAdmissionToken; the send call's return value is never used as evidence. Discharge updates the obligation object and the record sets that captured it by reference, never whichever record currently occupies the task's map slot; an obligation admitted under attempt X can never enter, or be removed from, a successor attempt's record. TurnAdmissionToken(new field on the WorkspaceService-facingSendMessageInternalOptions,taskWorkspaceSeam.ts:330–370, forwarded into AgentSession's internal optionsagentSession.ts:745–831and stored on theMessageQueueentry exactly like the existingadmissionStale):{ admissionStale(): boolean; onAdmitted(turnId: TurnId): void; onDisposed(kind: "no-work" | "refused" | "canceled-before-admission"): void }.onAdmittedis idempotent perturnId(a dequeued item is prepared at:10128and then adopted bysendMessage(…, { turnReservation })at:10188— the second call must not create a second admission);onDisposedis idempotent and ignored onceadmitted. Fired at the first actual preparation admission of every path, never inferred later:
| Seam (verified) | Token event | Obligation |
|---|---|---|
workspaceService.sendMessage pre-queue/pre-session Err sites (:11696–11844, :11864, :11939/:11971, :12005–12010, :12051–12063, :12188–12190) and agentSession.sendMessage pre-prepare refusals (:3338, :3669/:4293, :4300, the admissionStale gate :4791–4805, prepare rejected/deferred :4834–4842) |
onDisposed("refused") |
discharged (never admitted) |
dedupe / restore returns (:11759, :11763) and resumeStream Ok({ started: false }) (busy :4970, closing :5004, prepare rejected :5039) |
onDisposed("no-work") |
discharged — no work exists for this token; the deduped-into item keeps its own token |
session.queueMessage (:12127–12159) |
(none yet; entry stores the token) | enqueued — stays until its own dequeue |
dequeue sendQueuedMessages (:10097–10201): before coordinator.prepare at :10128 evaluate the item's admissionStale (added at that gate if absent) → stale ⇒ item dropped, onDisposed("refused"); else prepare admitted ⇒ onAdmitted(turnId) here, before the sendMessage(…, { turnReservation }) re-entry at :10188 |
as stated | admitted from :10128 on — an early failure inside the :10188 re-entry is not "never admitted"; its turn settles through the preparation-failure path |
direct coordinator.prepare admitted (agentSession.ts:4817; existing onTurnAdmissionCommitted :4846 keeps releasing WorkspaceService's preflight reservation) and resumeStream prepare admitted (:5030) |
onAdmitted(turnId) with the admitted id passed explicitly |
admitted |
streamWithHistory failures after admission (:7480, :7483, :7736, :7813/:7879, resume :5071) |
(none) | stays admitted; discharged only when that turn settles (settlePreparationFailure → turn completion → recordWorkspaceTurnSettled; implementation verifies the turn-settled event fires for failed-start turns, otherwise extends that seam) |
cancellation (onCanceled, cancelSignal, clearQueue via Stop taskService.ts:7227/:7726 → workspaceService.ts:2078, interruptStream :12879 → agentSession.ts:10028) |
onDisposed("canceled-before-admission") only if not yet admitted |
pending/enqueued → discharged; admitted (PREPARING or later) → retained until the correlated turn settles — cancellation requests termination, it does not prove settlement |
turn settlement recordWorkspaceTurnSettled(turnId) |
(TaskService-internal) | admitted with that turnId → discharged; removed from every capturing record's capturedTurns |
supersession (a new taskAttemptId published) |
(none) | existing obligations keep their attemptId; admitted ones are retained until settlement; enqueued ones are refused at dequeue by their own admissionStale |
token.admissionStale = () => token.state === "discharged" || !attemptAdmissionOpen(taskId, token.attemptId) || (token.attempt != null && ownedAttemptByTaskId.get(taskId) !== token.attempt) reads TaskService in-memory state only and is the refusal authority: evaluated synchronously at the enqueue block (:12051, :12188), at the session gate (:4791) and at dequeue (:10128), it returns Err before any coordinator state changes. The common predicate attemptAdmissionOpen(taskId, id) (send authorization, no ownership) requires: currentAttemptIdByTaskId.get(taskId) === id — an in-memory mirror of the task's current taskAttemptId, updated synchronously by every local rotation (reservation commit, drain launch CAS, reawaken, reactivation, startup re-drive rotation, owned or not), so any later local rotation revokes every older pending/enqueued token by construction, including unowned startup generations (startup Y in preflight → startup Z rotates → Y reaches prepare is refused); no closure entry for id; no workspaceStopRecords entry. Ownership is checked separately and only where it applies: a same-attempt continuation requires ownedAttemptByTaskId.get(taskId)?.attemptId === id at the fence and, through token.attempt, at every later gate; a new owned attempt passes the common predicate for the just-published Y first and then, in the same synchronous block, installs ownership (beginOwnedTaskAttempt(Y)) and registers its obligation with token.attempt set — so owned X settled → CAS publishes Y → admission of Y succeeds (the previous owned X never blocks Y); a startup re-drive passes the common predicate and registers an unowned obligation (token.attempt undefined), never acquiring ownership to satisfy any check. beginOwnedTaskAttempt(newId) deletes the settlement entry only when entry.attemptId !== newId: a closure recorded for Y between the CAS and the block makes the block refuse and is never erased. A disposed token is terminal: onDisposed sets state = "discharged" and the token can never admit afterwards even if its attempt stays open. admitted tokens are unaffected by rotation (they are retained until their turn settles). So Y passes the fence → awaits → Stop closes Y → prepare and Y → Z supersedes → Y prepares are both refused. There is no "send settled + workspace idle" discharge and no inference from Ok/Err.
- Stop record.
WorkspaceStopRecordgainsattemptId(the captured attempt's id; for an unowned attempt the task index's current id at capture),pendingAdmissions: Set<AdmittedSend>(the exact obligation objects for thatattemptId, captured by reference in Phase A) andcapturedTurns: Set<turnId>(replacing the singlecapturedTurn; initialized from the current turn). Release (recheckWorkspaceStopRelease) requiresstopPersisted && capturedTurns.size === 0 && pendingAdmissions.size === 0 && executionSettled && cleanupInFlight === 0. When a pending admission's turn becomes visible, the record adds it tocapturedTurnsand owes one more cleanup (cleanupInFlight += 1) for a secondaiService.stopStream(taskId)so the late turn is actually stopped; its settlement removes it. A captured turn settling while another admission is pending therefore cannot release the record (the round-10 counterexample: T1 settles, receipt, then admitted T2 starts). - Per producer: idle stop (
releaseSharedDesktopTaskOnUserStop, already under the event lock:2692–2695; idleness verified inside the updater:13221–13227) andfailAgentTaskTerminally(under the event lock in every streaming caller:2704–2707,:10077; the startup caller:3317acts on an unowned task and writes no receipt) treat any pending admission as live (→ stop-record route, never an idle receipt) and otherwise callcloseAttemptAdmissionright after their idleness decision, beforeeditWorkspaceEntry. The stop cascade's Phase A record creation (beginWorkspaceStop:2017–2050, synchronous under the global mutex,ownedAttemptcaptured) is the closure for the execution-settlement producer: the admission-open predicate treats an existingworkspaceStopRecordsentry as closed. Reservation canceled/failed: the plan'sabortSignalis aborted before the write and the launch fence'sabortSignal.abortedcheck (:5028–5033, the existing linearization comment) refuses the launch — no new marker.markTaskLaunchFailed: takes the event lock, callscloseAttemptAdmission, and writes a receipt only ifplan.sendAdmitted === falseand no admission is pending for the attempt, decided inside that lock. - Receipt I/O runs outside the event lock on every path. Idle/terminal producers, under the lock: idleness decision →
closeAttemptAdmission→ the existingeditWorkspaceEntry→ register the receipt write as an owed cleanup bound to the capturedattempt→ release the lock. The write then runs outside; on completion (fulfilled or rejected)settleOwnedTaskAttempt(taskId, attempt, source)upgrades the entry tosettledagainst the captured identity (durable: true|false). While the write is pending,inspectAttemptOutcome(which takes the same lock) seesclosingand returnscleanup-pending, and a fence call returns a refusal immediately rather than queueing on the lock. The stop path keeps P7's owed-cleanup form. - Fence (
admitTaskSend, Change 1) runs underworkspaceEventLocks.withLock(taskId)up to dispatch. Its awaited step is the strict config read (continue same attempt) or the rotation CAS that publishes the fresh id (new attempt: reawaken, reactivation, drain launch, startup re-drive). Then, synchronously:expectedId(Xfor a continuation, the just-publishedY) equals the row'staskAttemptId;taskAttemptRetiredBy == null; the commonattemptAdmissionOpen(taskId, expectedId)(generation current, no closure entry forexpectedId, noworkspaceStopRecordsentry); for a continuation only,ownedAttemptByTaskId.get(taskId)?.attemptId === expectedId. Only then, still in the same synchronous block: owned new attemptsbeginOwnedTaskAttempt(expectedId, { receiptEligible }); registerAdmittedSend { attemptId: expectedId, attempt?, state: "pending" }(attemptset for owned admissions, undefined for startup re-drives); setplan.sendAdmitted = true; callsendMessage/resumeStreamwith the obligation'sTurnAdmissionToken. The pre-dispatch checks are an early exit; the authoritative refusal is the token'sadmissionStale, evaluated by the session synchronously before everycoordinator.prepare(direct:4791, dequeue:10128, resume) so the asynchronous work insidesendMessage(compaction admission, pricing gate, preflight, history publish —workspaceService.ts:11860–11982,agentSession.ts:4321–4370) cannot admit a stale identity:Y passes the fence → awaits → Stop closes Y → prepareis refused at:4791. A refusal after a committed CAS leaves the fresh id published (P3: no rollback), the owned-but-never-admitted attempt readable asindeterminate(owned, unsettled) until a Stop settles it with its receipt, and returnsErr. Send authorization is separate from receipt authority: startup re-drives pass the new-attempt branch unowned (fresh marked id, nobeginOwnedTaskAttempt, no receipts), exactly the behavior Change 1 preserves, but they cannot send through a concurrent Stop or a closed attempt; owned branches acquirereceiptEligibleonly per P11.closeAttemptAdmissionrecords the closedattemptIdalso for unowned attempts (captured from the fresh row inside the producer's updater), so an unowned startup attempt that is Stopped is closed for its own id as well. - Queued input. Dequeue does not re-enter
workspaceService.sendMessage(sendQueuedMessages,agentSession.ts:10097–10201, callscoordinator.prepareat:10128andagentSession.sendMessageat:10188directly), so the queued item's own token is the fence there (table above), together with Stop semantics: the task stop cascade runsrunWorkspaceStopCleanupwithclearQueue: true(taskService.ts:7227,:7726→workspaceService.clearQueue,:2078),sendQueuedMessagesfreezes while a stop latch is held (agentSession.ts:10109), andinterruptStreamrestores the queue to input (workspaceService.ts:12879→clearQueue,agentSession.ts:10028). Cleared, not-yet-admitted items are dispositionedcanceled-before-admission. The idle/terminal producers addworkspaceService.clearQueue(taskId)to their closure step (the terminal-failure stop-record route already gets it from Phase B), so no deferred dispatch can outlive a closure; a bareaiService.stopStreamis never used as a settlement producer (it does not touch the queue).
Change 3b — Receipt writes at the producers (enabled only after G1)
Ordering at every producer follows 3a: closure (closeAttemptAdmission, clearQueue where applicable) → existing config write → release the event lock → persistOwnedAttemptSettlement as an owed cleanup bound to the captured attempt → settleOwnedTaskAttempt (upgrade to settled, durable: true|false).
Generated with xum • Model: coder:openai/gpt-6-astra • Thinking: xhigh • Cost: $533.73
Approved implementation plan (2/2)Full-plan SHA-256: Verbatim plan, part 2
Change 4 — Classifier (
|
Merge pinned main 60d4039 without rewriting published G1 history. Keep both independent additions to agentMessaging constants; the other files merge automatically. Integration validation is recorded separately from the daed443 remote UAT snapshot. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `coder:openai/gpt-6-astra` • Thinking: `xhigh` • Cost: `$533.73`_ <!-- mux-attribution: model=coder:openai/gpt-6-astra thinking=xhigh costs=533.73 -->
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0333deb1c8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
A lifecycle observer may synchronously admit or settle a successor. Capture the transitioning generation and update its observation before any callback, so the predecessor cannot settle the successor or overwrite its live/idle state. Notify supersession before publishing nested transitions. Two AgentSession regressions fail on the reviewed head and pass after the fix. The full session/coordinator suites and make static-check also pass. Addresses Codex discussion_r4058106801 on PR #4308. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `coder:openai/gpt-6-astra` • Thinking: `xhigh` • Cost: `$604.72`_ <!-- mux-attribution: model=coder:openai/gpt-6-astra thinking=xhigh costs=604.72 -->
…direct launches Corrections to the G1 attempt-admission layer in TaskService, each reproduced red first (taskService.attemptAdmission.test.ts, 8 new tests): - markInterruptedTaskRunning: ownership follows the committed CAS (as the reactivation path already does). A reawaken losing its CAS to a concurrent one used to roll the in-memory mirror back to the predecessor id while config and ownership named the winner, so the next manual send was admitted against the retired id; a send racing the CAS was bound to a not-yet-persisted id. - currentTaskAttemptId: the persisted row is authoritative; the mirror is only a fallback for a row that lost its id. Tokens of an attempt another writer rotated are revoked at their next gate instead of riding the stale mirror. - Direct (unqueued) create: the attempt id stamped by the entry write is now owned by this process (first admission by construction, receipt-eligible), and every rollback settles it as launch-failed. Previously the task tool's primary spawn path left its children unowned, so a Stop closed but never settled them (indeterminate) and their lineage could never be proven. - rotateAttemptForStartupRedrive: CAS on the recovery snapshot's id and status so a row another writer admitted or stopped meanwhile is skipped rather than overwritten and re-driven. - admitTaskWorkspaceTurn: a workflow claim refuses before the id check, so a retired task with a missing or malformed id fails closed. - evaluateAttemptLineage: a receipt at the parent's path must name that parent. --- _Generated with `xum` • Model: `coder:anthropic/claude-fable-5-1` • Thinking: `xhigh` • Cost: `$7.03`_ <!-- mux-attribution: model=coder:anthropic/claude-fable-5-1 thinking=xhigh costs=7.03 -->
… owner A Stop cascade whose Phase A runs after a reawaken's identity CAS but before beginOwnedTaskAttempt captured either the superseded predecessor (write still in flight) or the fresh id unowned (commit visible). Its release then closed an id nobody owned or settled a predecessor nobody held, leaving the new owner permanently indeterminate with its id open or closed-but-unsettled. beginOwnedTaskAttempt now rebinds a live stop record for the task to the attempt being installed, so the cascade's existing Phase C settles the attempt that is current (terminal without report, closed to sends, lineage proven for the next reawaken). Nothing can run under that attempt meanwhile: the latch refuses every admission until release. Current-id closures recorded in the window are preserved as before. currentTaskAttemptId no longer falls back to the id this process remembers: a row that is missing, lost its id, or loads as the default view yields no attempt, so every token reads stale and every fence refuses instead of reviving a stale memory. currentAttemptIdByTaskId keeps its one read, the fence's fail-closed path on an unreadable registry. Deterministic witnesses (Phase B gated on stopStream) cover the Stop landing before and after the commit becomes visible, and the deleted/unreadable-row cases; each is red without its change. --- _Generated with `xum` • Model: `coder:anthropic/claude-fable-5-1` • Thinking: `xhigh` • Cost: `$7.03`_ <!-- mux-attribution: model=coder:anthropic/claude-fable-5-1 thinking=xhigh costs=7.03 -->
…pture
Replaces the stop-record rebind from the previous commit. Every cascade's
Phase A (beginWorkspaceStop) runs under TaskService's global mutex, so the
reawaken (markInterruptedTaskRunning) and the reactivation
(reactivateInactiveAgentTask) now run {latch recheck, identity CAS,
post-commit row check, publish, beginOwnedTaskAttempt} inside that same
mutex: no Stop can observe the fresh id, or the superseded predecessor,
between the commit and its owner. Lineage evaluation (receipt read, bounded
wait), metadata emission and the caller's send stay outside. A Stop that
completes while a reawaken is still evaluating overtakes it (stop-epoch
fence: refused, nothing rotated; a recovery started afterwards proceeds); a
reactivation into a task whose cascade is already latched is refused before
it publishes. A row rotated by a writer outside the mutex after the commit
is never republished or owned.
Lock audit: mutex holders that send (create's launch, WorkspaceTurnManager's
continuation) must never reach the rescue. WorkspaceTurnManager sends carry
their correlation, which WorkspaceService already exempts; the direct and
reserved launch sends now bind their obligation and pass the token as the
send's staleness probe, so WorkspaceService treats them as guarded sends and
skips the user-resume rescue by its existing rule. Lock order is mutex →
desktop gate → config queue, the one create/createWorkspaceTurn establish;
no event or tree lock is taken inside.
Witnesses pause inside the critical section (during the CAS write and after
the id is durable), request a real Stop and prove it cannot capture until
the owner installs, then complete it: exact new-id settlement, latch and
record gone, closed-id refusal, next reawaken proven. Also: a gap send binds
to the committed id and is drained before release; a completed Stop overtakes
an in-flight reawaken; a non-mutex successor is never overwritten; a throwing
CAS releases the mutex. Two baseline tests whose fake host re-entered the
rescue under the task-creation lock now model the serialized ordering, and
launch-option assertions match the guarded send.
---
_Generated with `xum` • Model: `coder:anthropic/claude-fable-5-1` • Thinking: `xhigh` • Cost: `$7.03`_
<!-- mux-attribution: model=coder:anthropic/claude-fable-5-1 thinking=xhigh costs=7.03 -->
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d40de5ce9d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (this.ownedAttemptByTaskId.get(plan.taskId)?.attemptId !== launchAttemptId) { | ||
| this.beginOwnedTaskAttempt(plan.taskId, "launch", { | ||
| attemptId: launchAttemptId, | ||
| receiptEligible: launchReceiptEligible, | ||
| }); |
There was a problem hiding this comment.
Refuse launches whose reserved attempt changed
With XUM_ALLOW_MULTIPLE_INSTANCES=1, another backend can recover this process's starting row back to queued and reserve it again with a new attempt ID before startReservedAgentTask reaches this block. Adopting that new ID here makes both backends own and dispatch the same attempt; compare the persisted ID with plan.attemptId and abandon the stale launch instead of taking ownership of the replacement.
AGENTS.md reference: AGENTS.md:L106-L106
Useful? React with 👍 / 👎.
| this.beginOwnedTaskAttempt(workspaceId, "reawaken", { | ||
| attemptId, | ||
| receiptEligible: committedProven, | ||
| }); |
There was a problem hiding this comment.
Settle reawakened attempts when their send fails
When an interrupted task is reawakened but the subsequent sendMessage/resumeStream fails or starts no turn, WorkspaceService restores the persisted status to interrupted, but nothing settles the fresh owner installed here. readAttemptOutcome therefore returns indeterminate, and the next retry inherits unproven lineage even though no execution survived; the rollback path needs to close and settle this exact attempt.
AGENTS.md reference: AGENTS.md:L167-L167
Useful? React with 👍 / 👎.
| const liveExecution = | ||
| this.workspaceService.getActiveTurnGeneration(workspaceId) != null || | ||
| this.getWorkspaceTurnManager().getLiveWorkspaceTurnRegistration(workspaceId) != null || | ||
| this.aiService.isStreaming(workspaceId) || | ||
| this.hasPendingAdmissions(workspaceId); |
There was a problem hiding this comment.
Close admission before sampling terminal-failure activity
If a send reaches onAdmitted after this liveExecution snapshot reports false but before the config updater closes the attempt, the later no-record branch clears only queued work and records the attempt as settled while that newly admitted turn continues running. Close admission synchronously before this snapshot, or take the stop latch under the same serialization boundary, so terminal-no-report evidence cannot race a live successor.
AGENTS.md reference: AGENTS.md:L167-L167
Useful? React with 👍 / 👎.
| if (!(await this.rotateAttemptForStartupRedrive(task.id, task))) { | ||
| failedAwaitingReportCount += 1; | ||
| continue; | ||
| } | ||
| const followUp = await this.workspaceService.dispatchPendingCompactionFollowUp(task.id); |
There was a problem hiding this comment.
Fence startup compaction follow-ups against the rotated attempt
When startup finds a pending compaction follow-up, this rotation is followed by dispatchPendingCompactionFollowUp, which calls AgentSession.dispatchPendingCompactionFollowUpIfNeeded directly and therefore never invokes admitTaskWorkspaceTurn or carries a TurnAdmissionToken. With XUM_ALLOW_MULTIPLE_INSTANCES=1, another backend can stop, retire, or rotate the fresh attempt after this CAS while the recovered follow-up still enters a turn; route this dispatch through the task-attempt fence or explicitly pass an admission token and staleness probe.
AGENTS.md reference: AGENTS.md:L106-L106
Useful? React with 👍 / 👎.
| const settlement = this.attemptSettlementByTaskId.get(taskId); | ||
| if (settlement?.attempt === owned) { | ||
| // Closed but not yet settled: the producer's awaited write (config, later the receipt) is | ||
| // still in flight. Its settlement is the guaranteed next signal (fail closed, not a wait). | ||
| if (settlement.phase === "closing") return { kind: "cleanup-pending" }; |
There was a problem hiding this comment.
Match settlement evidence to the persisted attempt
With XUM_ALLOW_MULTIPLE_INSTANCES=1, another backend can rotate the persisted row from attempt A to attempt B while this process retains A in ownedAttemptByTaskId; when A later settles, this branch accepts its in-memory settlement without comparing either ID to entry.workspace.taskAttemptId and returns terminal-no-report for the current, potentially live B attempt. The new admission reread does not cover this classifier, so require the owned and settled IDs to equal the freshly persisted attempt before using them as terminal evidence.
AGENTS.md reference: AGENTS.md:L106-L106
Useful? React with 👍 / 👎.
Summary
Establish the attempt identity and send-admission boundary needed for workflow recovery across backend restarts. This is G1, the prerequisite layer. Durable settlement producers, cross-restart classification, retirement claims, and child replacement belong in the dependent G2 PR.
Review focus
Validation and status
Current head:
0333deb1c864d10845e191e74c0a490de932071e, a normal merge of pinnedmain(60d40394d) into the earlier UAT snapshot. The constants conflict is resolved without rewriting history. Exact-head integration checks passed: G1/TaskService 908, WorkspaceService 652, AgentSession 1012, workflow/tool/config 248, IPC 9, andmake static-check. All 20 CI check-runs are successful or skipped; optional Pixel visual approval remains pending. Exact-head remote integration UAT passed. Codex review is now being requested. No merge is requested.The integration smoke independently verified closed-reported chat-box recovery and Resume, retained report metadata, new attempt IDs, and no dispatch of stale queued guidance. It also covered the newly integrated unrelated-recipient consent gate: default-off refusal, real-UI opt-in, Stop/revoke-before-dequeue non-dispatch, and fresh delivery after re-enabling. Evidence includes a recount of all 77 raw fixture request bodies. The first unrelated-Stop run completed before Stop and is excluded; a later correctly timed run supplies that evidence. One fresh-after-recovery send was only observed through admission because its default provider lacked a key; the subsequent fresh-send scenario proves provider delivery. No paid provider calls were made.
Exact-head integration evidence at 0333deb
integration-scenario1-stop.webm
Full receipts:
.mux-uat/integration-60d40394d/remote-uat/status.json, request-body recount, screenshots/video, and artifact hashes. The remote backend was restarted after verifying rebuilt-module timestamps. Remote servers and workspace were stopped after capture.make static-checkpassed..mux-uat/round-6/. Media below belongs todaed4432d, not a future integration head.Recovery evidence at daed443
r6-recovery.mp4
Video covers independent Resume and a repeated chat-box cycle; the primary chat-box run has desktop/phone PNGs. One mid-stream screenshot belongs to the separate Resume child; fixture request/abort logs prove the primary Stop. Six harness scripts listed in the remote manifest were not attached; their captured outputs are retained. These evidence limitations are recorded separately from the passing recovery checks.
Risks and limits
The main risks are refusing legitimate sends or retaining Stop obligations indefinitely. Regression tests cover those branches and concurrent Stop, identity, status, settlement, and claim changes. Legacy/unproven attempts still lack cross-process proof. Reactivation-owned turns still preserve stable reported status and are not startup-re-driven after backend death; that pre-existing lifecycle ownership issue is not fixed here.
Follow-up: this workspace owns G2 after G1 approval, as the upper native stacked PR. The broader reactivation/startup lifecycle correction remains a separately scoped follow-up, triggered when restart recovery for reactivated reported children is undertaken.
Implementation plan
The complete approved two-layer plan is 100,478 characters and exceeds GitHub's PR-body limit. Part 1 and part 2 preserves the complete text verbatim in numbered comments. Below are verbatim G1 excerpts; the full plan's G2 work is not part of this diff.
G1 implementation plan — verbatim excerpt
Change 1 — Immutable attempt identity
WorkspaceConfigEntry.taskAttemptId?: stringandtaskAttemptRetiredBy?: { runId: string; stepId: string; inputHash: string; childTaskId: string; attemptId: string; mode: "no-report" | "retire-reported"; at: string }(src/node/config/index.tstype near:197, parse site near:1104, the three explicit field lists). Both optional; absent on legacy entries.taskAttemptUnproven?: true(same three field lists): present when the attempt's lineage is not proven (P11). Written together withtaskAttemptIdby the admission that rotates the id; omitted by the reservation commit and by proven reawaken/reactivation; inherited (copied) by every admission that does not itself prove the lineage. Never cleared except by a new reservation (a new task).OwnedTaskAttempt(taskService.ts:443–452) gainsreadonly attemptId: string | undefinedandreadonly receiptEligible: boolean;beginOwnedTaskAttempt(taskId, source, { abortSignal?, attemptId, receiptEligible }).receiptEligibleis decided once, at admission, from the evidence in P11; it is never recomputed later.evaluateAttemptLineage(taskId, entry): Promise<{ proven: boolean; reason: string }>, evaluated under the task's event lock before the admission CAS):proveniffentry.taskAttemptId != null && entry.taskAttemptUnproven !== trueand either (i)ownedAttemptByTaskId.get(taskId)has thatattemptId,receiptEligible === true, andattemptSettlementByTaskIdrecords its settlement — if the owned attempt is still settling (stop latch retained), wait with the existing boundedwaitForAttemptSettlementfirst, then re-evaluate; or (ii)readSubagentAttemptSettlementReceiptStrict(parentDir, taskId, entry.taskAttemptId)isfound(unreadable/not_found→ not proven). The boundedwaitForAttemptSettlementruns outside the task event lock (it waits for publication/settlement work that needs the lock); the lock is then reacquired and the proof re-evaluated. The admission CAS publishestaskAttemptId = newIdand decides the marker from the fresh transaction row:proven = precomputedProven && ws.taskAttemptUnproven !== true(the marker is only ever added on a given task, so a marker that appeared after the snapshot can only downgrade). The mutator records its committed result, andbeginOwnedTaskAttempt(…, { receiptEligible })is called with that committed value — never with a pre-await snapshot. Unproven admissions proceed exactly as onmain(owned in memory; no receipts) and log the reason once.att_+ 16 hex chars fromcrypto.randomBytes(src/node/utils/…helper next to existing id helpers);assert(/^att_[0-9a-f]{16}$/.test(id))at every write and read.createMany(theeditConfigthat persists reserved plans, near:4296):taskAttemptId = newId, notaskAttemptUnproven; theOwnedTaskAttemptcreated at:4296carries it withreceiptEligible: true(the reservation is the first admission by construction).startReservedAgentTask(:4758–5072): keeps the reservation's id and eligibility; if the entry has none (pre-upgradequeued/startingentry), assign one in its existing status write withtaskAttemptUnproven: true(an old build's stale-startingrevert may have reverted an admitted entry toqueuedwithout any marker) and carry it intobeginOwnedTaskAttempt("launch", { receiptEligible: false }).markInterruptedTaskRunning(:12237–12296): strict pre-read refuses whentaskAttemptRetiredBy != null(returnsfalse, beforebeginOwnedTaskAttempt);evaluateAttemptLineagedecidesproven; the mutator re-checks (if (ws.taskAttemptRetiredBy != null || ws.taskAttemptId !== previous) return;) and setstaskAttemptId = newId,taskAttemptUnprovenper the proof, withrunning. On mutator refusal, restore the previous in-memory ownership/settlement exactly asreactivateChildAgentTaskdoes (:5911–5925).reactivateChildAgentTask(:5886–5930): beforecreateWorkspaceTurn,evaluateAttemptLineage, then aneditWorkspaceEntryCAS (taskAttemptId === previous && taskAttemptRetiredBy == null) publishestaskAttemptId = newIdandtaskAttemptUnprovenper the proof (same lifecycle lock), thenbeginOwnedTaskAttempt("reactivation", { attemptId, receiptEligible: proven }). Boundary: if the CAS fails (claimed or id changed), nothing was published — restore speculative memory as today (:5911–5925) and return the refusal. Once the CAS committed, the new identity is kept in both config and memory whatevercreateWorkspaceTurnreturns or throws: the old attempt is never restored after its successor is published. A refused/failed reactivation therefore leaves an owned, unsettled attempt (indeterminate, reason "owned attempt without settlement evidence"); a subsequent Stop settles it through the idle or execution-settlement producer and writes its receipt. The existing in-memory restore block is reduced to the pre-commit case.maybeStartQueuedTasksFromReservations,:12082–12084, which today setsstartingunconditionally): the transition becomes a CAStaskStatus === "queued" && taskAttemptRetiredBy == null → startingthat also rotatestaskAttemptIdwhenownedAttemptByTaskId.get(id)?.attemptId !== entry.taskAttemptIdand copiestaskAttemptUnprovenunchanged; the process whose CAS commits is the single owner (beginOwnedTaskAttempt("launch", { attemptId, receiptEligible })withreceiptEligible = ws.taskAttemptUnproven !== trueread from the fresh row inside the mutator, not from the drain's snapshot — a stale-startingrevert by another process can add the marker without changing the id), the loser skips the plan. Aqueuedentry without the marker has by definition never been admitted (admission persistsstartingfirst, and the only path back toqueuedsets the marker below), so this handoff is exclusive and proven by construction.startingrevert (:3198–3211): the mutator additionally setstaskAttemptUnproven = truewhen it writesqueued(orrunning). Astartingentry found at startup may already have an admitted execution in another process; the marker makes the relaunch — which otherwise looks exactly like a never-launched reservation — an unproven lineage, so the drain's launcher owns it in memory as today but is never receipt-eligible. Entries a process finds inrunningorawaiting_reportmay likewise have an admitted execution elsewhere: the existing re-drive keeps running unowned — nobeginOwnedTaskAttempt, no receipts — and marks the lineage unproven (below), so no receipt can ever describe a child that another process may still publish for, and nothing reawakened from that lineage can produce one either.recoverInterruptedTasks: guidance replay:3374, restart nudge:3417,promptTaskForRequiredCompletionTool({ reason: "startup" }):3317,dispatchPendingCompactionFollowUp:3313/:3358): before the send, aneditWorkspaceEntryCAS rotatestaskAttemptIdand setstaskAttemptUnproven = true(refusing whentaskAttemptRetiredBy != null, in which case the task is skipped and logged). The task stays unowned (nobeginOwnedTaskAttempt, no receipts), any receipt of the previous attempt can no longer match the execution this process starts, and every later reawaken of this child inherits the unproven marker.promptTaskForRequiredCompletionTool:12466/:12539from:13127,:13366,:13707,:14390), parent guidance queued into a live child (:6192,:6993), best-of/continuation kickoffs of an owned attempt (:9460–:10020,:12823–:12876,:13848). Rule, enforced by one helperadmitTaskSend(taskId, { newAttempt })used at every send site: if the attempt is admission-open —ownedAttemptByTaskId.get(taskId)?.attemptId === entry.taskAttemptId, andattemptSettlementByTaskIdhas no entry for it (neitherclosingnorsettled, Change 3), and noworkspaceStopRecordsentry exists for the workspace, andtaskAttemptRetiredBy == null— the send continues the same attempt (no rotation) and is registered as anAdmittedSendobligation bound to that attempt until discharged (Change 3); if the attempt is owned but settled or settling, the send is refused (Err("attempt settled"), logged; the caller drops it — an intentional continuation after settlement is a reawaken and must go through the fresh-id CAS); otherwise the helper performs the rotation CAS (newAttempt: { own: true, receiptEligible }also begins ownership;newAttempt: { own: false }is the unowned startup re-drive, which rotates and marks but never owns), refusing when claimed. Settlement therefore closes an attempt to further sends, so no continuation can dispatch under an id that a receipt already describes (P1/P4). Uses existing settlement/stop state only; no new persisted field. Enumerate everyworkspaceService.sendMessage/ prompt call on a task workspace intaskService.ts(the line list above is the seed), route each through the helper, record the classification in a comment at each site (test 1b covers each).expectedAttemptId; the admission fence Layer 2 added before the provider start (the check "at physical turn admission, immediately beforesendMessage" used bystartReservedAgentTask, and the AgentSession/StreamManager provider-start fence) re-reads the entry under the task's event lock and aborts the send whentaskAttemptId !== expectedAttemptIdortaskAttemptRetiredBy != null; the authoritative in-flight check is the existingadmissionStaleprobe (SendMessageInternalOptions, evaluated synchronously beforecoordinator.prepareand at dequeue) supplied byadmitTaskSendas() => !attemptAdmissionOpen(taskId, expectedAttemptId)— a continuation prepared before a Stop is refused at the session gate, never dispatched after the receipt (Change 3). Implementation verifies the exact seam (the fence added forTaskLaunchPlan.abortSignal) and reuses it rather than adding a second fence.taskAttemptIdfor behavior; it is identity only.Change 2 — Receipt module (
src/node/services/subagentAttemptSettlements.ts, new)sessions/<ownerWorkspaceId>/subagent-attempt-settlements/<encodeURIComponent(taskId)>/<attemptId>.json, written into the parent and every ancestor session dir (same fan-out as failure artifacts; ancestors resolved from the captured entry, not re-read).{ version: 1, taskId, attemptId, parentWorkspaceId, source, settledAt }withsource ∈ { "execution-settled", "idle-settled", "launch-failed", "reservation-canceled", "reservation-failed" }(terminal failures appear asexecution-settledoridle-settleddepending on the producer that wrote them; the failure artifact keeps the error detail).writeSubagentAttemptSettlementReceipt(params): Promise<Result<void, string>>— temp file +rename; if the target exists, verify equalattemptIdand returnOk(idempotent). Never throws; it settles only when the OS write settles (no internal timeout), so ownership of the write is never abandoned.readSubagentAttemptSettlementReceiptStrict(ownerDir, taskId, attemptId): Promise<{ kind: "found"; receipt } | { kind: "not_found" } | { kind: "unreadable"; error }>— ENOENT →not_found; any other error or schema failure →unreadable.Change 3 — Admission lifecycle and receipt writes (
taskService.ts,workspaceService.ts,agentSession.ts,messageQueue.ts)private async persistOwnedAttemptSettlement(taskId, attempt: OwnedTaskAttempt | undefined, source): Promise<boolean>— returnsfalsewithout writing whenattempt == null,attempt.attemptId == null,attempt.receiptEligible === false(unproven lineage, P11), orthis.ownedAttemptByTaskId.get(taskId) !== attempt(the same guardsettleOwnedTaskAttemptapplies, evaluated before the write); otherwise writes the receipt forattempt.attemptIdand returns whether it became durable. Then the caller callssettleOwnedTaskAttemptas today (in-memory settlement is recorded even if the write failed; the log line distinguishesdurable: true|false). It is called only from the producers in 3b, which are enabled only after gate G1 (3a).Change 3a — Admission lifecycle contract (prerequisite; gate G1 must be green before any receipt producer is enabled)
Linearization point — admission closes before the first awaited receipt/config operation (P1/P4). Today no producer marks an attempt as settling before its awaited write (
OwnedTaskAttemptis{ generation, source, abortSignal? }, immutable;attemptSettlementByTaskIdis populated only aftereditWorkspaceEntry/cleanup awaits in all six callers), and the launch fence atstartReservedAgentTask:5028–5045holds no lock, so a send prepared before a Stop could dispatch during the receipt write. The fix reuses the existing settlement map and stop records and adds one in-memory obligation set plus one explicitly planned integration: aTurnAdmissionTokencarried in the send options through WorkspaceService, AgentSession and the MessageQueue (the WorkspaceService-facingSendMessageInternalOptionsdoes not exposeonTurnAdmissionCommittedtoday — that callback is AgentSession-internal and releases WorkspaceService's preflight reservation; the token is forwarded alongside it and composes with it, never replaces it). Changes 1–2 and 3a ship and are verified together (G1, receipt-independent — see Quality gates) before 3b–5 enable receipts and claims. A partially wired token fails closed during development (an obligation that can never be dispositioned keeps the stop latch retained →cleanup-pending, visible, never a receipt) — that is a failing G1, not a passing one.attemptSettlementByTaskIdentries become{ attemptId: string | undefined; attempt?: OwnedTaskAttempt; phase: "closing" | "settled"; source }.closeAttemptAdmission(taskId, { attemptId, attempt }, source)records theclosingentry synchronously, underworkspaceEventLocks.withLock(taskId), before the producer's firstawait, withattemptIdcaptured from the fresh config row inside the producer's updater (so unowned attempts are closed for their own id too;attemptonly when owned);settleOwnedTaskAttemptupgrades the same entry tosettled(its existing owned-guard unchanged).beginOwnedTaskAttempt/a new-attempt admission deletes the entry (a fresh id reopens admission). An entry in any phase is never removed by a failed write: admission stays closed; intentional continuation is a reawaken (fresh id). Every consumer that treated settlement-map membership as settlement now requiresphase === "settled": the owned path ofinspectAttemptOutcome(:2369–2377,closing→cleanup-pending),waitForAttemptSettlement,evaluateAttemptLineage(i), the claim's same-process evidence, andhandleAgentWaitFailure's consumers through the adapter.admittedSendsByTaskId: Map<taskId, Set<AdmittedSend>>,AdmittedSend = { readonly attemptId: string; readonly attempt?: OwnedTaskAttempt; state: "pending" | "enqueued" | "admitted" | "discharged"; turnId?: TurnId }—attemptIdis the captured config id the send was admitted under (non-optional, also for unowned startup sends);attemptonly for owned admissions (receipt authority). The obligation is created by the fence before dispatch and dispositioned only through itsTurnAdmissionToken; the send call's return value is never used as evidence. Discharge updates the obligation object and the record sets that captured it by reference, never whichever record currently occupies the task's map slot; an obligation admitted under attempt X can never enter, or be removed from, a successor attempt's record.TurnAdmissionToken(new field on the WorkspaceService-facingSendMessageInternalOptions,taskWorkspaceSeam.ts:330–370, forwarded into AgentSession's internal optionsagentSession.ts:745–831and stored on theMessageQueueentry exactly like the existingadmissionStale):{ admissionStale(): boolean; onAdmitted(turnId: TurnId): void; onDisposed(kind: "no-work" | "refused" | "canceled-before-admission"): void }.onAdmittedis idempotent perturnId(a dequeued item is prepared at:10128and then adopted bysendMessage(…, { turnReservation })at:10188— the second call must not create a second admission);onDisposedis idempotent and ignored onceadmitted. Fired at the first actual preparation admission of every path, never inferred later:workspaceService.sendMessagepre-queue/pre-sessionErrsites (:11696–11844,:11864,:11939/:11971,:12005–12010,:12051–12063,:12188–12190) andagentSession.sendMessagepre-preparerefusals (:3338,:3669/:4293,:4300, theadmissionStalegate:4791–4805,preparerejected/deferred:4834–4842)onDisposed("refused"):11759,:11763) andresumeStreamOk({ started: false })(busy:4970, closing:5004, prepare rejected:5039)onDisposed("no-work")session.queueMessage(:12127–12159)enqueued— stays until its own dequeuesendQueuedMessages(:10097–10201): beforecoordinator.prepareat:10128evaluate the item'sadmissionStale(added at that gate if absent) → stale ⇒ item dropped,onDisposed("refused"); elseprepareadmitted ⇒onAdmitted(turnId)here, before thesendMessage(…, { turnReservation })re-entry at:10188admittedfrom:10128on — an early failure inside the:10188re-entry is not "never admitted"; its turn settles through the preparation-failure pathcoordinator.prepareadmitted (agentSession.ts:4817; existingonTurnAdmissionCommitted:4846keeps releasing WorkspaceService's preflight reservation) andresumeStreamprepareadmitted (:5030)onAdmitted(turnId)with the admitted id passed explicitlyadmittedstreamWithHistoryfailures after admission (:7480,:7483,:7736,:7813/:7879, resume:5071)admitted; discharged only when that turn settles (settlePreparationFailure→ turn completion →recordWorkspaceTurnSettled; implementation verifies the turn-settled event fires for failed-start turns, otherwise extends that seam)onCanceled,cancelSignal,clearQueuevia StoptaskService.ts:7227/:7726 → workspaceService.ts:2078,interruptStream :12879 → agentSession.ts:10028)onDisposed("canceled-before-admission")only if not yetadmittedadmitted(PREPARING or later) → retained until the correlated turn settles — cancellation requests termination, it does not prove settlementrecordWorkspaceTurnSettled(turnId)admittedwith thatturnId→ discharged; removed from every capturing record'scapturedTurnstaskAttemptIdpublished)attemptId;admittedones are retained until settlement;enqueuedones are refused at dequeue by their ownadmissionStaletoken.admissionStale = () => token.state === "discharged" || !attemptAdmissionOpen(taskId, token.attemptId) || (token.attempt != null && ownedAttemptByTaskId.get(taskId) !== token.attempt)reads TaskService in-memory state only and is the refusal authority: evaluated synchronously at the enqueue block (:12051,:12188), at the session gate (:4791) and at dequeue (:10128), it returnsErrbefore any coordinator state changes. The common predicateattemptAdmissionOpen(taskId, id)(send authorization, no ownership) requires:currentAttemptIdByTaskId.get(taskId) === id— an in-memory mirror of the task's currenttaskAttemptId, updated synchronously by every local rotation (reservation commit, drain launch CAS, reawaken, reactivation, startup re-drive rotation, owned or not), so any later local rotation revokes every olderpending/enqueuedtoken by construction, including unowned startup generations (startup Y in preflight → startup Z rotates → Y reaches prepareis refused); no closure entry forid; noworkspaceStopRecordsentry. Ownership is checked separately and only where it applies: a same-attempt continuation requiresownedAttemptByTaskId.get(taskId)?.attemptId === idat the fence and, throughtoken.attempt, at every later gate; a new owned attempt passes the common predicate for the just-publishedYfirst and then, in the same synchronous block, installs ownership (beginOwnedTaskAttempt(Y)) and registers its obligation withtoken.attemptset — soowned X settled → CAS publishes Y → admission of Ysucceeds (the previous ownedXnever blocksY); a startup re-drive passes the common predicate and registers an unowned obligation (token.attemptundefined), never acquiring ownership to satisfy any check.beginOwnedTaskAttempt(newId)deletes the settlement entry only whenentry.attemptId !== newId: a closure recorded forYbetween the CAS and the block makes the block refuse and is never erased. A disposed token is terminal:onDisposedsetsstate = "discharged"and the token can never admit afterwards even if its attempt stays open.admittedtokens are unaffected by rotation (they are retained until their turn settles). SoY passes the fence → awaits → Stop closes Y → prepareandY → Z supersedes → Y preparesare both refused. There is no "send settled + workspace idle" discharge and no inference fromOk/Err.WorkspaceStopRecordgainsattemptId(the captured attempt's id; for an unowned attempt the task index's current id at capture),pendingAdmissions: Set<AdmittedSend>(the exact obligation objects for thatattemptId, captured by reference in Phase A) andcapturedTurns: Set<turnId>(replacing the singlecapturedTurn; initialized from the current turn). Release (recheckWorkspaceStopRelease) requiresstopPersisted && capturedTurns.size === 0 && pendingAdmissions.size === 0 && executionSettled && cleanupInFlight === 0. When a pending admission's turn becomes visible, the record adds it tocapturedTurnsand owes one more cleanup (cleanupInFlight += 1) for a secondaiService.stopStream(taskId)so the late turn is actually stopped; its settlement removes it. A captured turn settling while another admission is pending therefore cannot release the record (the round-10 counterexample: T1 settles, receipt, then admitted T2 starts).releaseSharedDesktopTaskOnUserStop, already under the event lock:2692–2695; idleness verified inside the updater:13221–13227) andfailAgentTaskTerminally(under the event lock in every streaming caller:2704–2707,:10077; the startup caller:3317acts on an unowned task and writes no receipt) treat any pending admission as live (→ stop-record route, never an idle receipt) and otherwise callcloseAttemptAdmissionright after their idleness decision, beforeeditWorkspaceEntry. The stop cascade's Phase A record creation (beginWorkspaceStop:2017–2050, synchronous under the global mutex,ownedAttemptcaptured) is the closure for the execution-settlement producer: the admission-open predicate treats an existingworkspaceStopRecordsentry as closed. Reservation canceled/failed: the plan'sabortSignalis aborted before the write and the launch fence'sabortSignal.abortedcheck (:5028–5033, the existing linearization comment) refuses the launch — no new marker.markTaskLaunchFailed: takes the event lock, callscloseAttemptAdmission, and writes a receipt only ifplan.sendAdmitted === falseand no admission is pending for the attempt, decided inside that lock.closeAttemptAdmission→ the existingeditWorkspaceEntry→ register the receipt write as an owed cleanup bound to the capturedattempt→ release the lock. The write then runs outside; on completion (fulfilled or rejected)settleOwnedTaskAttempt(taskId, attempt, source)upgrades the entry tosettledagainst the captured identity (durable: true|false). While the write is pending,inspectAttemptOutcome(which takes the same lock) seesclosingand returnscleanup-pending, and a fence call returns a refusal immediately rather than queueing on the lock. The stop path keeps P7's owed-cleanup form.admitTaskSend, Change 1) runs underworkspaceEventLocks.withLock(taskId)up to dispatch. Its awaited step is the strict config read (continue same attempt) or the rotation CAS that publishes the fresh id (new attempt: reawaken, reactivation, drain launch, startup re-drive). Then, synchronously:expectedId(Xfor a continuation, the just-publishedY) equals the row'staskAttemptId;taskAttemptRetiredBy == null; the commonattemptAdmissionOpen(taskId, expectedId)(generation current, no closure entry forexpectedId, noworkspaceStopRecordsentry); for a continuation only,ownedAttemptByTaskId.get(taskId)?.attemptId === expectedId. Only then, still in the same synchronous block: owned new attemptsbeginOwnedTaskAttempt(expectedId, { receiptEligible }); registerAdmittedSend { attemptId: expectedId, attempt?, state: "pending" }(attemptset for owned admissions, undefined for startup re-drives); setplan.sendAdmitted = true; callsendMessage/resumeStreamwith the obligation'sTurnAdmissionToken. The pre-dispatch checks are an early exit; the authoritative refusal is the token'sadmissionStale, evaluated by the session synchronously before everycoordinator.prepare(direct:4791, dequeue:10128, resume) so the asynchronous work insidesendMessage(compaction admission, pricing gate, preflight, history publish —workspaceService.ts:11860–11982,agentSession.ts:4321–4370) cannot admit a stale identity:Y passes the fence → awaits → Stop closes Y → prepareis refused at:4791. A refusal after a committed CAS leaves the fresh id published (P3: no rollback), the owned-but-never-admitted attempt readable asindeterminate(owned, unsettled) until a Stop settles it with its receipt, and returnsErr. Send authorization is separate from receipt authority: startup re-drives pass the new-attempt branch unowned (fresh marked id, nobeginOwnedTaskAttempt, no receipts), exactly the behavior Change 1 preserves, but they cannot send through a concurrent Stop or a closed attempt; owned branches acquirereceiptEligibleonly per P11.closeAttemptAdmissionrecords the closedattemptIdalso for unowned attempts (captured from the fresh row inside the producer's updater), so an unowned startup attempt that is Stopped is closed for its own id as well.workspaceService.sendMessage(sendQueuedMessages,agentSession.ts:10097–10201, callscoordinator.prepareat:10128andagentSession.sendMessageat:10188directly), so the queued item's own token is the fence there (table above), together with Stop semantics: the task stop cascade runsrunWorkspaceStopCleanupwithclearQueue: true(taskService.ts:7227,:7726→workspaceService.clearQueue,:2078),sendQueuedMessagesfreezes while a stop latch is held (agentSession.ts:10109), andinterruptStreamrestores the queue to input (workspaceService.ts:12879→clearQueue,agentSession.ts:10028). Cleared, not-yet-admitted items are dispositionedcanceled-before-admission. The idle/terminal producers addworkspaceService.clearQueue(taskId)to their closure step (the terminal-failure stop-record route already gets it from Phase B), so no deferred dispatch can outlive a closure; a bareaiService.stopStreamis never used as a settlement producer (it does not touch the queue).Generated with
xum• Model:coder:openai/gpt-6-astra• Thinking:xhigh• Cost:$588.43