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 -->
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 -->
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 -->
…to the attempt they decided Three admission omissions of the G1 layer, each reproduced first: - A reserved launch adopted whatever attempt the row named when it ran. With another backend recovering the `starting` row and re-reserving it, both processes owned and dispatched one attempt, and this launch's failure handling interrupted or deleted the successor's record and checkout. The launch now belongs to its plan's attempt: it abandons silently when the row names another (at start, after materializing, before the send), its fence carries `expectedAttemptId`, and `markTaskLaunchFailed` / `cleanupMaterializedTaskWorkspace` never touch a row this process's owned attempt no longer matches. - `failAgentTaskTerminally` sampled activity, then awaited the config write that closed the attempt; a send admitted in between ran under an attempt the no-record branch recorded as settled. The closure is now recorded synchronously before the sample, so a late send is refused and everything admitted earlier is visible to the sample and captured by the stop record. - The startup compaction follow-up reached AgentSession without the task-attempt fence. The re-drive now binds an obligation to exactly the attempt it rotated (`admitTaskWorkspaceTurn` with `expectedAttemptId`, refused when another writer already owns the row) and carries it through the existing `dispatchPendingCompactionFollowUp` seam to the session's send as token and staleness probe; a stale refusal ends the decision, and the remaining startup sends (guidance replay, restart nudge, completion prompt) are bound to the rotated attempt the same way. Witnesses cover supersession before launch, during materialization and on failure; a send admitted after the terminal-failure sample and one pending before it; and follow-up rotation/retirement before the wrapper and during its awaits. Two deferrals are pinned natively: a reawaken whose send fails leaves its owner unsettled (obligation discharged, explicit Stop recovers, marker inherited), and the classifier keeps in-process settlement authority. --- _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 -->
… failed direct launch Three admission gaps in the G1 attempt lifecycle, each reproduced natively before the repair: - A reported child whose report is durable has its owner and settlement entry released (releaseReportedTaskAttempt), so the manual send/resume rescue saw no settled predecessor, left the completed id in place, and the fence admitted the user's continuation under it. markInterruptedTaskRunning now reads that released shape (reported row, no owner, no entry) as a predecessor that is not live in this process and mints a fresh owned attempt for it — the same rotation the parent's reactivation performs, lineage unproven — while the status and the historical report stay as they were. Owned (live) continuations, closing and stop-closed ids keep refusing; the decision is rechecked under the mutex after the lineage awaits. - AgentSession.resumeStream ran its asynchronous preflight (compaction admission, pricing) after the host's admission check and then claimed PREPARING without re-asking the token, so an attempt rotated, retired or stopped meanwhile could still be admitted. The token is now checked in the same synchronous block as the coordinator's prepare and refused as stale. - A direct (unqueued) create whose launch failed rolled the workspace back — row, checkout and session dir — before closing the attempt or draining what had been admitted under it while the launch send was in flight. Every failure past the persisted entry now closes the attempt, samples liveness, runs the task's own stop cascade (Phase A directly under the mutex create already holds, Phase B bounded) and waits, bounded, for the captured owners to settle before the first deletion. Tests: the reported-child lifecycle through the real producers (reawaken → agent_report stream end → release → rescue/fence), the stale-during-preflight resume, and the failed direct launch with a pending and with an admitted racing send (order and state at the first deletion). --- _Generated with `xum` • Model: `coder:anthropic/claude-fable-5-1` • Thinking: `xhigh` • Cost: `$121.18`_ <!-- mux-attribution: model=coder:anthropic/claude-fable-5-1 thinking=xhigh costs=121.18 -->
…pt is still live Corrects the failed-direct-launch drain landed in the previous commit, which waited a bounded time for the owners its stop cascade captured and then rolled the workspace back and settled the attempt regardless — deleting the row, checkout and session dir underneath an owner that outlived the bound and minting settlement its owner had not given — and which marked the stop record persisted without a durable stop write. - The durable marker now precedes the mark: the row is persisted `interrupted` with the launch error (the shape markTaskLaunchFailed leaves) before Phase B; a failed write retains the latch. - The rollback runs only once nothing is live under the attempt. An owner that outlives the bound defers it: the task stays an interrupted workspace with its launch error (removable like any other), the latch holds until that owner settles, and only its settlement — Phase C — settles the attempt. The bound keeps create's hold on the global mutex finite. - Liveness includes a live workspace-turn registration, as failAgentTaskTerminally samples it. Tests: the pending and admitted racing sends now also outlive the bound (the closure-settle wait genuinely elapses by moving the clock past its deadline when its timer fires): no rollback, no settlement proof, cleanup-pending, latch held and the fence refusing until the owner's actual disposal or turn settlement releases the record — and the mutex is free meanwhile. --- _Generated with `xum` • Model: `coder:anthropic/claude-fable-5-1` • Thinking: `xhigh` • Cost: `$133.61`_ <!-- mux-attribution: model=coder:anthropic/claude-fable-5-1 thinking=xhigh costs=133.61 -->
… a published launch on failure A direct (unqueued) create persists and announces its row before the launch send. Another writer (a second backend's startup re-drive or reawaken) can re-admit that row under its own attempt (A → B) in that window. The launch admission carried no expectedAttemptId, so the prompt decided for A dispatched bound to B (unowned); and the failure path rolled the workspace back unconditionally — deleting B's row, checkout and session dir. - The launch admission is bound to the attempt the create persisted; a row that no longer names it refuses (SEND_ADMISSION_STALE_MESSAGE), never adopts the successor. - A published launch is never rolled back. It ends as an interrupted workspace with its launch error (as markTaskLaunchFailed leaves a queued launch): the marker is a CAS on the row still naming this attempt; a superseded or removed row gets no marker, no stop cascade, no metadata emission. Only A's own obligations (ledger-scoped) decide its settlement: idle → settled, live → its own end. The stop cascade runs only under a row this launch still owns; an owner outliving the bound defers settlement exactly as before. - Destructive rollback remains only for genuinely unpublished resources (a checkout whose entry was never persisted). Tests: direct-launch supersession matrix (rotation before admission, before/after the marker CAS with A idle/live, during the stop cascade) against a second Config on the same root; existing launch-failure and rollback tests moved to the retention contract; the vacuous post-publication deregistration test removed. The queued launch's cleanupMaterializedTaskWorkspace is unchanged (same shape, out of scope here). --- _Generated with `xum` • Model: `coder:anthropic/claude-fable-5-1` • Thinking: `xhigh` • Cost: `$30.39`_ <!-- mux-attribution: model=coder:anthropic/claude-fable-5-1 thinking=xhigh costs=30.39 -->
…re-drive rotated The awaiting_report startup re-drive rotates the row to a fresh unowned attempt R, checks the id once, then calls promptTaskForRequiredCompletionTool, which performs several awaits and a config write before a generic (unfenced) sendMessage. Another writer's admission of the same row (R → B) landing in that window let the handoff bind the completion prompt to B. - The re-drive binds the prompt's obligation to R before calling (expectedAttemptId) and hands the token to the helper as a fence; the helper carries it through its awaits: the recovery-budget charge is a CAS on R (no write to a row another writer took), the recovery-limit terminal failure is skipped once the token reads stale, and the send rides the token as its obligation and staleness probe. Every return before the send disposes the token. - Unfenced callers (stream-end, error recovery) are unchanged: same-attempt continuation bound at the handoff. Test: real WorkspaceService + AgentSession + MessageQueue (AI stream mocked) startup re-drive of an awaiting_report task; a second Config on the same root rotates the row during the helper's budget write → nothing reaches the stream, nothing binds to B, B's row and budget are untouched. Control: untouched row → the prompt is issued once, admitted under R (unowned) and the budget is charged to R. --- _Generated with `xum` • Model: `coder:anthropic/claude-fable-5-1` • Thinking: `xhigh` • Cost: `$30.39`_ <!-- mux-attribution: model=coder:anthropic/claude-fable-5-1 thinking=xhigh costs=30.39 -->
…to their release Extends the direct-launch supersession matrix with an admitted live turn under A (besides idle and pending): after another writer re-admits the row (before or after the marker CAS), the launch failure neither stops that turn nor mints settlement for A. The test then drives the existing release signals — a pending obligation reads stale and is disposed by its host at its next gate; an admitted one is discharged when its turn settles (recordWorkspaceTurnSettled) — and asserts that only A's memory-only `closing` entry remains: no orphan obligation, no stop record, no latch, B's row untouched. --- _Generated with `xum` • Model: `coder:anthropic/claude-fable-5-1` • Thinking: `xhigh` • Cost: `$41.66`_ <!-- mux-attribution: model=coder:anthropic/claude-fable-5-1 thinking=xhigh costs=41.66 -->
…re-handoff exit promptTaskForRequiredCompletionTool disposed a caller's fence token on its early returns only; a throw from one of its awaits before the handoff (owned-work probe, plan-likeness read, recovery-budget write, terminal failure) leaked the token as a pending obligation nobody held, which keeps the attempt's stop cascade waiting on it for good. Ownership is now explicit: the helper owns the token from entry until the handoff to WorkspaceService.sendMessage and disposes it in a finally on every other exit (return or throw). From the handoff on the token is WorkspaceService.sendMessage's — its scoped disposal covers refusal and throw, the queue owns an enqueued token, the session an admitted one — and is never disposed here. Test: fault injection (budget write throws; owned-work probe rejects) during the startup awaiting_report re-drive — no pending obligation remains, no budget charged, row keeps R. Red on 17444d4, green with the fix. --- _Generated with `xum` • Model: `coder:anthropic/claude-fable-5-1` • Thinking: `xhigh` • Cost: `$41.66`_ <!-- mux-attribution: model=coder:anthropic/claude-fable-5-1 thinking=xhigh costs=41.66 -->
…asserting its release gate The "re-admits during its stop cascade" trace resolved its `rotation` promise from inside the mocked stopStream, a few microtasks before the cascade's cleanup promise settled and handed its in-flight count back. On the clean base the test passed only 1 of 3 runs. Wait for `cleanupInFlight === 0` (the invariant the record's release actually checks) and assert the stop is still in progress before settling A's turn, so the test proves that only A's turn holds the record. --- _Generated with `xum` • Model: `coder:anthropic/claude-fable-5-1` • Thinking: `xhigh` • Cost: `$68.21`_ <!-- mux-attribution: model=coder:anthropic/claude-fable-5-1 thinking=xhigh costs=68.21 -->
…d report decision, then hand refused text back unsent A message queued into a sub-agent while its last turn streamed could be dequeued by the session's turn-completion drain before TaskService (under its event lock, a later microtask) had classified that stream as the task's terminal report. The follow-up then ran under an attempt that was being completed — or, once the attempt had been released, its stale-attempt refusal dropped the user's text with only a notification. TaskService now registers a pending stream-end decision for the owned attempt synchronously in the stream-end event's own tick and resolves it exactly once from inside the handler: `nonreport` early (before the recovery/nudge sends, so a queued user follow-up still dispatches ahead of them), `published` after the durable report released the attempt, `indeterminate` when the handler threw, the artifact was not durable everywhere, or the row has no parent. The queued entry's token exposes `resolveDispatch`, consulted by the session's dequeue gate BEFORE the stale gate: `hold` retains the entry and returns without blocking (the turn's own completion — compaction decision, finishTurn — proceeds; no await on TaskService), TaskService re-runs the idle drain when the decision lands; `proceed` continues to the ordinary gates; a refusal removes the entry. A direct send still in preflight reads stale while the decision is pending or decided against continuation. Decisions are bound to the exact OwnedTaskAttempt object and dropped once no pending/enqueued obligation of that attempt can read them, or when a new attempt begins. No automatic continuation: a completed, indeterminate or superseded attempt never mints a successor or forwards the token. A refused MANUAL entry's text is handed back through `restore-to-input` with the new `mode: "append"`, so the composer keeps what the user typed since and shows the unsent text after it; sending it again is a new, normally admitted send. Synthetic entries keep their existing cancel/error semantics. Tests: real WorkspaceService+AgentSession+MessageQueue stack — both drain/decision orderings never run the follow-up under the completed attempt; nonreport continues under the same attempt ahead of the nudge; partial artifact → indeterminate fail-closed; no drain trigger or direct send bypasses a pending decision and the hold never blocks the session; synthetic origin keeps cancel semantics; a foreign re-admission during the wait refuses as stale and hands the text back. Jest UI (full App): append mode preserves the draft; the default replace restore is unchanged. --- _Generated with `xum` • Model: `coder:anthropic/claude-fable-5-1` • Thinking: `xhigh` • Cost: `$68.21`_ <!-- mux-attribution: model=coder:anthropic/claude-fable-5-1 thinking=xhigh costs=68.21 -->
…Stop during a held report decision The dequeue gate's refusal handed a manual entry's text back only when the text was non-empty, so an attachment-only follow-up (valid input: AgentSession accepts files without text) refused after the report was dropped. The hand-back now fires when the unsent input has text, file parts or reviews. Tests (real WorkspaceService+AgentSession+MessageQueue stack): - attachment-only manual follow-up refused after the report is handed back with its file parts (RED on the previous commit: restore events 0). - Stop while the decision is pending and the entry is held — user Stop on the child: the queue is cleared once (no dispatch), the Stop's own restore hands the text back (replace, like any Stop, because the token is not stale while pending), the obligation is discharged, the decision stays the handler's until it settles (published), then is pruned; a later send mints a fresh attempt. Parent cascade Stop: the already-`reported` row is preserved, the child is latched, its queue cleared without restore (cascade semantics), the obligation discharged, the stop record released, the decision pruned after the handler settles. - Harness: the mocked AIService is shared with the root workspace, whose terminal-attention wake (the `<mux_subagent_report>` delivery) was counted as a second child stream when it landed before the assertions (~2/24 runs under CPU load). Streams for other workspaces now return a settled handle and are not counted. Invariant stated for the decision finder: at most one PENDING decision exists per attempt — a decision is registered only when a stream under that attempt ends, and no new turn can be claimed under it while one is pending (queued entries hold, direct/internal preflights read stale, the handler's own recovery sends follow its early `nonreport`). The listener resolves the exact object it registered; the attempt-keyed lookup is used only inside that same handler. --- _Generated with `xum` • Model: `coder:anthropic/claude-fable-5-1` • Thinking: `xhigh` • Cost: `$88.34`_ <!-- mux-attribution: model=coder:anthropic/claude-fable-5-1 thinking=xhigh costs=88.34 -->
… replacing its attachments or review notes
The composer's append-mode restore reused the replacing draft helper when the restored message
carried file parts or reviews: the current draft's own attachments were overwritten by the restored
ones and the restored review notes were never attached. A queued message handed back as unsent
input (`restore-to-input` with `mode: "append"`) can carry both, and the user may have attached
more to the draft since queueing it — that draft data is theirs to keep.
`appendDraftFromPending` now merges: draft text first, then the restored text; the draft's
attachments first, then the restored ones (fresh restored ids, so nothing collides); the effective
review list (draft override or the panel's attached notes) first, then the restored notes, as the
draft override. The replacing helper is unchanged for restores that own the whole draft (Stop,
queued-message edit), and text-only appends still only touch the text.
Full-App UI tests (Jest): draft attachment kept with the refused file part after it (persisted
order asserted); text-only refusal keeps the draft attachment; two appends with review notes show
both notes ("2 reviews attached") instead of the last one.
---
_Generated with `xum` • Model: `coder:anthropic/claude-fable-5-1` • Thinking: `xhigh` • Cost: `$117.79`_
<!-- mux-attribution: model=coder:anthropic/claude-fable-5-1 thinking=xhigh costs=117.79 -->
Brings current main (80efaa2) into the attempt-admission layer (PR #4308 head 514ce85) so the unpublished direct-publication and checkout-preparation layers can be rebased and validated on top of current main. Textually clean merge; no manual conflict resolution. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh` • Cost: `$1439.64`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=xhigh costs=1439.64 -->
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. |
Cause: pruneStreamEndDecisions kept a resolved stream-end decision only while a pending/enqueued obligation of its attempt could read it. When the decision resolved `published` (or `indeterminate`) with no reader yet, it was dropped at once, although the reporting turn was still completing. A manual send in that window then found no decision: its preflight passed, the busy session queued it under the completed attempt, and at dequeue findStreamEndDecision() returned undefined, so it dispatched as a user turn of the reported attempt (PR #4308 thread PRRT_kwDOPxxmWM6lSxVY). Fix: a StreamEndDecision records the session turn generation its stream ended in (captured at registration, rebound on turn supersession like stop-record captured turns). A terminal decision is kept while that turn is live; recordWorkspaceTurnSettled clears the binding and prunes, so the decision is dropped once the turn settled and no reader remains (bounded, no leak). Nonreport decisions and hosts without a live turn keep the reader-only lifetime. No ownership, settlement or receipt authority is granted. Tests: taskService.reportDecisionHold.test.ts gains "a manual follow-up sent after the report decision resolved but before the reporting turn settles is refused ...". The harness now wires AgentSession onTurnSettled/onTurnSuperseded to WorkspaceService's events as production sessions do. Red before the fix (14 pass / 1 fail: the late send was accepted and persisted as a user turn of the reported attempt); green after (15 / 0): the late send is refused in its preflight (SEND_ADMISSION_STALE_MESSAGE, text stays with the caller), nothing queues or streams, and the decision is gone after the turn settles; a later manual send mints a fresh attempt. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `max` • Cost: `$2648.96`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=max costs=2648.96 -->
Cause: the stream-end listener (and handleStreamEnd's fallback) took an unowned stream's attempt from the persisted row at stream end. With XUM_ALLOW_MULTIPLE_INSTANCES, backend A can stream its unowned startup attempt while backend B re-admits the row under attempt B. A's stream end then registered its report decision against B (fencing B's queued input), and its report publication wrote taskStatus "reported" plus the report artifacts onto B's row (PR #4308 thread PRRT_kwDOPxxmWM6lTYgF). Fix: - streamAttemptIdWithoutOwner: an unowned stream's attempt is the one its live turn was admitted for (the admitted obligation bound to the session's active turn generation); the persisted id is only the fallback for turns admitted without an obligation. - handleStreamEnd ignores an unowned stream whose row no longer names that attempt (no publication, status write or recovery prompt; the decision resolves in the listener's finally and A's held entries read stale). - The report publication is a CAS on the unowned attempt inside the editWorkspaceEntry transform; a superseded row gets no status write, artifacts or parent delivery, the decision resolves indeterminate, and finalizeAgentTaskReport returns finalized:false/superseded_attempt. Owned attempts are unchanged; unowned attempts stay unowned. Tests (taskService.reportDecisionHold.test.ts): - "an unowned re-drive whose row another backend re-admitted meanwhile ...": red before the fix (the decision was keyed by B; after binding the decision alone, A's report still flipped B's row to reported); green after: B's queued follow-up dispatches under B, no restore, B's row untouched, no report artifact, no ownership. - "an unowned re-drive's report publication is a CAS ...": rotates the row inside the publication's own write; red with the CAS disabled (B's row became reported), green with it. Suite 17/0; attemptAdmission + taskService 833/0; typecheck clean. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `max` • Cost: `$2666.99`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=max costs=2666.99 -->
…them not-a-task Cause: admitTaskWorkspaceTurn caught a strict registry read failure and refused only workspaces present in currentAttemptIdByTaskId (tasks this process had rotated). A task loaded from disk and never rotated here (for example a reported child from a prior process) had no entry, so the fence returned not-a-task and a manual send bypassed the attempt fence while the registry was corrupt (PR #4308 thread PRRT_kwDOPxxmWM6lTYgL). Fix: on a registry read failure the classification is indeterminate, so every send refuses ("Workspace registry unreadable; send refused: ..."). Absence from the local identity map is no longer treated as proof of "not a task". This deliberately includes root/non-task workspaces: nothing can tell them apart without the registry, and such a send could not run anyway (lenient readers then return a config with no projects, so Config.getWorkspaceMetadataById, and with it streamMessage, finds no metadata); refusing at the fence fails it before anything is persisted. Documented at the catch. currentAttemptIdByTaskId is no longer read by production code; its doc comments now say so (tests still use it as an oracle). Test (taskService.attemptAdmission.test.ts): "an unreadable registry refuses a send into %s" for a task never rotated here and for a root workspace. Red before (both returned not-a-task), green after (refused, no obligation created). The existing locally-rotated unreadable-registry test stays green. Suite 86/0. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `max` • Cost: `$2666.99`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=max costs=2666.99 -->
…restoring them into the composer Cause: when a sub-agent task reports, the dequeue gate refuses queued manual messages so they never run under the completed attempt. The refused input was handed to the renderer as a retained `restore-to-input` event that the composer had to apply and acknowledge (`workspace.acknowledgeInputRestore`). That handoff moved the only copy into renderer draft storage, and Codex kept finding ways to lose it; the latest (PRRT_kwDOPxxmWM6lTYf_): the composer acknowledged even when its draft writes failed (localStorage quota, oversized attachments), so the backend deleted its only copy. Design: remove the handoff. The session keeps each refused manual send as HELD input (`AgentSession.heldInputs`): the full original send (provider message, send options with file parts and review metadata, authored text for display), oldest first. Held inputs are not queue entries: never batched, drained, force-sent or counted as dispatchable work, and untouched by Stop/clearQueue. They live as long as the session (lost on backend restart, like a queued message). The session publishes the list as `held-inputs-changed` on every change and replays it on subscription while non-empty; the renderer shows each as a "Not sent" banner next to the queued message with two explicit actions: Send (`workspace.sendHeldInput`: the ordinary WorkspaceService.sendMessage path as a new manual send, so a reported task mints a fresh attempt; removed only once accepted, concurrent sends refused) and Discard (`workspace.discardHeldInput`). No edit/move-to-composer action. Removed: pendingInputRestores replay/ack, workspace.acknowledgeInputRestore, the RestoreToInput `mode`/`restoreId` fields, WorkspaceStore's restore registry and the `appliedInputRestores` storage key, ChatInput's restore consumer/history-edit hold, onAttachReviews routing, and the eager useComposerDraft attachment writes that existed only for the handoff. Kept: authored text in the queue (held preview and Stop restores). Stop and queued Edit restores keep main's behavior. Tests: taskService.reportDecisionHold covers held order + full payload, no automatic dispatch (drains, force-send, a later manual turn), Stop/clearQueue, replay, failed/concurrent Send, Send under a fresh attempt with each review once, and Discard (12 of 15 failed at 48fde63, 15/15 now). messageQueue covers the refused-send capture. tests/ui/chat/heldQueuedMessage.test.ts (renamed from unsentQueuedMessageRestore) covers the banner after switching back and after a replay-only reload, untouched draft, failed Send, Discard, and Send/double-click with each review sent once (2 of 3 failed at 48fde63). --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `max` • Cost: `$2666.99`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=max costs=2666.99 -->
Cause: resumeStream installed the disposal scope for a caller-supplied internal.turnAdmission only after its preflight (rename/remove/archive/stop guards, workspace lookup, queued-task guard, interrupted-busy guard, compaction capture, pricing gate, stale-resume check, reawaken). Any of those early returns left the token that admitTaskWorkspaceTurn registered neither disposed nor handed to a turn, so a later Stop captured a permanently pending admission and never released its latch (PR #4308 thread PRRT_kwDOPxxmWM6lUwvI). Fix: taskTurnAdmission and its disposal scope move to method entry (as in sendMessage), so every exit before the session handoff, including a throw, disposes the token: refused on error, no-work for the successful no-start resume (stale-resume and the existing started:false paths). A token the fence mints later is assigned to the same variable and covered the same way; an admitted token still belongs to its turn and ignores the disposal. Test (workspaceService.turnAdmission.test.ts): "resumeStream owns a caller-minted token from entry ..." for three early returns (workspace being renamed, compaction capture failed, reawaken lost the race). Red before (the token recorded no event in all three); green after (disposed:refused, no stream started). Suite 11/0. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `max` • Cost: `$2666.99`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=max costs=2666.99 -->
Cause: the held-input UI test's history helper constructed a second HistoryService from the harness config (Codex PRRT_kwDOPxxmWM6lUwvM), against AGENTS.md "Testing: HistoryService". Fix: read the persisted rows through the harness ServiceContainer's real HistoryService (toORPCContext().historyService), the same instance that wrote them. Tests: tests/ui/chat/heldQueuedMessage.test.ts. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `max` • Cost: `$2666.99`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=max costs=2666.99 -->
Cause: the held-input banner's Send and Discard were buttons only (Codex PRRT_kwDOPxxmWM6lUwvC); AGENTS.md requires a keyboard shortcut for every operation, hidden on mobile. Design: two central keybinds, SEND_HELD_INPUT and DISCARD_HELD_INPUT, handled in the composer's keydown exactly like the queued banner's Send now: only from an empty composer (no text, attachments, reviews or open edit), so they never fire while typing, and ignoring key repeat. They always target the oldest held input (ChatPane passes its id). The composer asks that banner to run the action through a HELD_INPUT_ACTION event, so shortcut and button share the banner's in-flight guard and inline error. The oldest banner shows the hints, hidden at mobile widths like the queued menu's; the Keybinds settings list both. Tests: tests/ui/chat/heldQueuedMessage.test.ts drives the real composer keydown: no action while the composer has text, Discard then Send act on the oldest held input, hints only on the target (fails without this change). --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `max` • Cost: `$2666.99`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=max costs=2666.99 -->
…llback Cause: make static-check (react-hooks/exhaustive-deps) flagged the held-input banner's shortcut listener: its effect depended on runAction, a new function every render, so the window listener re-subscribed on every render. Fix: runAction is a useCallback keyed on the API client and the held input's identity (for the effect's stability, not memoization). Tests: tests/ui/chat/heldQueuedMessage.test.ts; make static-check. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `max` • Cost: `$2666.99`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=max costs=2666.99 -->
Cause: discardHeldInput ignored the in-flight send claim (Codex PRRT_kwDOPxxmWM6lVm49). A Discard issued during a Send removed the held copy, and if that send then failed the user's unsent message was gone. Fix: discard participates in the same claim as Send. AgentSession.discardHeldInput returns `busy` while a send of that id is in flight, and WorkspaceService.discardHeldInput turns that into an error the banner shows inline like its other action errors. A failed send keeps the input held and Discard works again once the send has settled. Tests: taskService.reportDecisionHold "Discard while a Send of the same held input is in flight..." (fails at 7f40ba2, passes now). --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `max` • Cost: `$2666.99`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=max costs=2666.99 -->
…ing a report Cause: queued messages refused with the indeterminate report outcome (report handling or artifact persistence failed) or as stale (attempt closed or superseded) landed in the same held list, and the banner always said the task had reported (Codex PRRT_kwDOPxxmWM6lVm4q). Fix: each held input carries a `reason` in the session and in the held-inputs-changed event: `reported` only when the dequeue gate refused it because the preceding turn was the task's terminal report, `indeterminate` for every other refusal. The banner words each accordingly; the indeterminate wording does not claim a report. The refusal messages delivered to cancel callbacks no longer say the text is "back in the composer" (it is held). Tests: taskService.reportDecisionHold asserts `reported` (and the published event) for a report refusal and `indeterminate` for the non-durable-artifact case (3 fail at 774d5f7cc); tests/ui/chat/heldQueuedMessage.test.ts shows the reported and not-confirmed banner wording side by side. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `max` • Cost: `$2666.99`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=max costs=2666.99 -->
…eued-dock story Cause: the held-input banner hides its shortcut hints below the narrow breakpoint, but no pinned phone Pixel variant or play contract covered it (Codex PRRT_kwDOPxxmWM6lVm4w). Design: held inputs share the queued message's dock, so the existing QueuedFollowUp story (story viewport mobile1, Pixel matrix phone + laptop x dark/light) now also renders two held inputs (reported and indeterminate). That pins the phone variant without a new export: the retained-snapshot budget counts stay 115 files / 605 snapshots (the budget test is already red at this branch's base, whose stale limits are 79/305; main allows 115/605 with no headroom, so a separate story would exceed it). The play adds a static contract (every hint carries the narrow-breakpoint hide rule) and a rendered contract guarded on the actual media query: no visible hints at phone width, hints only on the oldest banner at desktop width (where the test-runner plays), plus reason-specific wording. Tests: test-storybook against a static build of ChatInput.stories (12 passed); at 390px the story's play runs past the held assertions (computed display of both hints: none), at 1280px both hints show on the oldest banner. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `max` • Cost: `$2666.99`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=max costs=2666.99 -->
f94e856 to
16553a1
Compare
Cause: handleStreamEnd's supersession guard ran only for unowned streams (ownedAttempt == null). With XUM_ALLOW_MULTIPLE_INSTANCES, backend B can rotate the row from A to B while backend A's owned stream end waits on the event lock; A's end then ran recovery or report publication for B's row (taskStatus reported, report artifacts with A's output). The guard was also one-shot: every later status write in the stream-end path was unconditional (PR #4308 thread PRRT_kwDOPxxmWM6lWOjD). Fix: - rowSupersedes(row, expectedAttemptId): one shared CAS predicate, evaluated inside each write's own transform. True only when an attempt was captured and the row names another one; no captured attempt keeps today's write. - handleStreamEnd captures streamAttemptId (owned attemptId, else the unowned admitted attempt) and ignores the event when the row names another attempt, owned or not. - Every stream-end write is CAS'd on streamAttemptId: setTaskStatus (new expectedAttemptId option) for the running/awaiting_report transitions, recoverTaskFromIncompleteStreamEnd, the completion prompt's budget write and recovery-limit failure (new expectedAttemptId prompt option), report publication and finalize's invalid-output write (owned and unowned), the workflow propose_plan failure/awaiting writes, and the propose_plan auto-handoff's settings write and status. No ownership, settlement or receipt authority is granted to anyone. Rows still naming the captured attempt (all single-instance flows) are unchanged. Tests (taskService.reportDecisionHold.test.ts): "an OWNED attempt whose row another backend re-admitted %s" rotates the row via a second Config before the stream ends and inside the publication write. Red before (both variants marked B reported); green after (B untouched, no reportedAt, no artifact). Hold suite 20/0; attemptAdmission + taskService 835/0; typecheck clean. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `max` • Cost: `$2666.99`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=max costs=2666.99 -->
Cause: handleTaskStreamError called failAgentTaskTerminally without an expected attempt. With XUM_ALLOW_MULTIPLE_INSTANCES, when backend B rotates the row from A to B before backend A processes A's terminal stream error, the helper's fallback closed B's admission and persisted B as interrupted with A's failure (PR #4308 thread PRRT_kwDOPxxmWM6lWOjN). Fix: the error listener captures the failing stream's attempt in the event's own tick (streamAttemptIdAtEvent: the owned attempt, else the attempt the live turn was admitted under) and hands it to handleTaskStreamError. The handler ignores an error whose row names another attempt, and passes the attempt as expectedAttemptId to both terminal failAgentTaskTerminally calls (CAS on the status write; no closure, stop, clear, settlement or artifacts for a superseded row) and to the recovery prompt (CAS'd budget write). Errors with no captured attempt keep today's behavior. Test (taskService.attemptAdmission.test.ts): "a terminal stream error is settled only for the failing attempt ...": a second Config rotates the row inside the handler's awaited owned-work probe. Red before (B became interrupted with "The model refused."); green after (B untouched, no failure artifact, no clearQueue/stopStream, no stop record, no successor closure). attemptAdmission + taskService + hold 856/0; typecheck clean. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `max` • Cost: `$2666.99`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=max costs=2666.99 -->
Cause: restoreInterruptedTaskAfterResumeFailure only checked that the row was `running`. With XUM_ALLOW_MULTIPLE_INSTANCES, backend A can reawaken an interrupted child as attempt A, backend B can stop and reawaken it as B while A awaits session admission, and when A's now-stale send fails its rollback flipped B to interrupted (PR #4308 thread PRRT_kwDOPxxmWM6lWOja). Fix: sendMessage and resumeStream remember the attempt their own reawaken won (resumedAttemptId) and pass it to every rollback call; the seam gains an optional expectedAttemptId and TaskService makes the status restoration a CAS on it (rowSupersedes). A resume that reawakened nothing passes no attempt and keeps today's rollback. Tests: - taskService.attemptAdmission.test.ts "a resume-failure rollback restores only the reawakened attempt ...": a second Config re-admits the row as B after A's reawaken. Red before (B became interrupted); green after (B keeps running; control with the row still naming A restores it). - workspaceService.turnAdmission.test.ts "resumeStream's failure rollback is bound to the attempt its reawaken won": red before (rollback called without the attempt), green after. typecheck clean. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `max` • Cost: `$2666.99`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=max costs=2666.99 -->
Class sweep for the multi-instance attempt CAS (PR #4308 round 18): the reservation/launch writes that act for one planned attempt now use the shared rowSupersedes predicate on plan.attemptId, next to their existing status guards: - createMany's reservation cancel (interrupted + TASK_RESERVATION_CANCELED), - settleFailedReservations (interrupted + launch error), - startReservedAgentTask's stop-deferral revert (starting -> queued), - startReservedAgentTask's starting -> running transition (setTaskStatus expectedAttemptId). A row another writer re-admitted under its own attempt is left to it. Plans without an attempt id keep today's writes; single-instance rows still name the plan's attempt, so their behavior is unchanged. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `max` • Cost: `$2666.99`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=max costs=2666.99 -->
Follow-up to 59afe95a2 (resume-failure rollback CAS): sendMessage and resumeStream now pass the attempt their own reawaken won to restoreInterruptedTaskAfterResumeFailure. The "sendMessage status clearing" tests reawaken through the seam fake, so their rollback expectations now include FAKE_REAWAKENED_ATTEMPT_ID (3 assertions covering 5 cases). No behavior change beyond that commit; workspaceService.test.ts 579/0. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `max` • Cost: `$2666.99`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=max costs=2666.99 -->
Cause: held inputs (refused queued messages kept for the user) live only in
AgentSession memory, but collectRestartBlockers counted only queued messages,
so an app update could restart without warning and lose the held text,
attachments and reviews (Codex PRRT_kwDOPxxmWM6lWOjQ).
Fix: a precise `held-inputs` restart-blocker kind ("Sessions with unsent
messages" in the About dialog), counted per session holding at least one
held input. Held inputs stay out of queued-work accounting; the queued
wording would have misdescribed them.
Tests: taskService.reportDecisionHold "a session holding refused input blocks
an app restart..." drives the real refusal, asserts the blocker while the
input is held and no queued work exists, and its removal after Discard
(fails at 3a77e6e; the harness's init-state mock gained
runningInitWorkspaceIds so collectRestartBlockers can run there).
---
_Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `max` • Cost: `$2666.99`_
<!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=max costs=2666.99 -->
…ing its record A direct (unqueued) TaskService.create published the task record (config write, attempt owned) and only then sanitized the fresh worktree's tracked `plugin:` enables. Every ordinary reader — a user send, another backend's startup re-drive, an older build — could discover and admit the task in that window, and a refused sanitization left the record behind as an interrupted task whose manual rescue read the stale enable and selected a default-disabled plugin server. The forked checkout is now sanitized BEFORE the config write, under one hold of the cross-process workspace-registration lock covering sibling scan → prune → publication: - WorkspaceMcpOverridesService.prunePluginOverrideKeysForUnregisteredCheckout: the strict pruner for a checkout with no registry entry yet. Its checkout lock keys are the spelled host path plus realpath (shared hostCheckoutLockKeys), so it contends with every registered writer/rename of the same physical path instead of the id fallback key; withWorkspaceLocks is generalized into withCheckoutLocks over any key derivation. - WorkspaceService.registerSanitizedTaskCheckout(target, publish): registration lock → findLiveSiblingForCheckout (no own row) → prune → publish. Err = refused, nothing published. Off-host runtimes publish without the lock; a live host-local sibling (project-dir parent, alias registration) skips the prune. - TaskService.create publishes inside that callback. A refusal returns an error naming the retained, unregistered worktree (never deleted: the refusal may stem from an indeterminate identity; unregistered it is unreachable and its name is unique to the task id). Once the publication transform ran, an unset launch owner no longer implies a rollback: the committed row is verified (attempt, path, runtime) and only an owned row is ended as a published launch failure; anything else leaves rows and files untouched. Tests: real-stack (Config + WorkspaceService + WorkspaceMcpOverridesService + TaskService over real git worktrees) coverage of the held-sanitizer barrier, refusal without publication, isolation: none and project-dir consent, alias registrations before the scan and after the prune, consent surviving Stop + resume, and write-fails / commits-then-throws / commits-then-superseded config writes; unit coverage of the explicit-target prune's key identity and lock contention. --- _Generated with `xum` • Model: `coder:anthropic/claude-fable-5-1` • Thinking: `xhigh` • Cost: `$15.99`_ <!-- mux-attribution: model=coder:anthropic/claude-fable-5-1 thinking=xhigh costs=15.99 -->
…ng scan registerSanitizedTaskCheckout reused the registration-time sibling scan, whose own-config read is lenient: a malformed config.json parses to an empty project map, which reads as "no live sibling" and would prune a sibling's legitimate consent — and then publish the task into a defaults-rewritten registry. The existing registration callers keep the lenient read (their own config write just proved the store readable); the pre-publication scan has written nothing yet, so it now reads in throwing mode and refuses, publishing nothing. Tests: malformed own config refuses without pruning or publishing (unit and end-to-end with the registry corrupted right after the real fork; the lenient read is red on the same test), and the registration lock is held through the publication callback and released afterwards. --- _Generated with `xum` • Model: `coder:anthropic/claude-fable-5-1` • Thinking: `xhigh` • Cost: `$15.99`_ <!-- mux-attribution: model=coder:anthropic/claude-fable-5-1 thinking=xhigh costs=15.99 -->
…out locks registerSanitizedTaskCheckout scanned for live siblings under the registration lock only and then acquired the checkout locks to prune. An in-place registration of the same physical checkout (an older CLI run; it takes no registration lock) could register through a symlinked spelling and save legitimate plugin consent in between — its save takes the very path lock the creator was waiting for — and the creator's prune then revoked that consent. The same window exists at the baseline's published-then-sanitized order. prunePluginOverrideKeysForUnregisteredCheckout now takes a read-only `shouldPrune` verdict that runs INSIDE the held checkout and global override locks, right before the prune, on the prune's own budget: the verdict draws on the same publication budget as the rewrite, and an expired budget fails the operation before any mutating prune is launched. WorkspaceService passes its strict sibling scan as that verdict and drops the pre-lock scan (one authoritative scan). Publication stays outside the checkout/global locks and inside the registration lock. Tests: the exact during-lock-wait alias schedule (creator paused at its lock acquisition; alias registers and saves consent through the ordinary save path; consent survives, the document is not rewritten) — red on the previous commits and on the baseline; a verdict that outlives the budget launches no prune; a deadline firing while the rewrite is in flight keeps the locks until the write lands (unit, and end-to-end: the creation stays pending and unpublished, then fails naming the retained path while the pruned text is on disk). --- _Generated with `xum` • Model: `coder:anthropic/claude-fable-5-1` • Thinking: `xhigh` • Cost: `$15.99`_ <!-- mux-attribution: model=coder:anthropic/claude-fable-5-1 thinking=xhigh costs=15.99 -->
… deadline tests The explicit-target deadline test in workspaceMcpOverridesService.test.ts spied LocalBaseRuntime.prototype.writeFile and restored only its clock spy. bun test runs files in one process, so the prototype spy outlived the file, and bun's spyOn on an already-spied method returns that same mock: the end-to-end deadline test in taskService.directCreateSanitize.test.ts then captured the mock as "the real writeFile" and its wrapper recursed (RangeError). The prune's write threw before the deadline, the creation failed early, and the test's wait for an in-flight write timed out (writeStarted=false, cancelled=false) — only when the two files ran in that order (green isolated, red combined). Restore every spy the unit tests take (writeFile, pruneResolvedWorkspace) in finally, and have both deadline tests fail loudly if a mocked writeFile is already installed when they start. --- _Generated with `xum` • Model: `coder:anthropic/claude-fable-5-1` • Thinking: `xhigh` • Cost: `$15.99`_ <!-- mux-attribution: model=coder:anthropic/claude-fable-5-1 thinking=xhigh costs=15.99 -->
The guards added in the previous commit read `"mockRestore" in LocalBaseRuntime.prototype.writeFile`; the unbound-method rule flags the property access even though nothing is called. Same disable the capture line already carries. --- _Generated with `xum` • Model: `coder:anthropic/claude-fable-5-1` • Thinking: `xhigh` • Cost: `$15.99`_ <!-- mux-attribution: model=coder:anthropic/claude-fable-5-1 thinking=xhigh costs=15.99 -->
16553a1 to
d856308
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d856308677
ℹ️ 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".
| } | ||
| return Ok(await publish()); | ||
| } finally { | ||
| await releaseRegistrationLock(); |
There was a problem hiding this comment.
Preserve sanitization refusal when lock release fails
If sanitization returns Err but releaseRegistrationLock() throws (for example because the config-root filesystem disappears or becomes inaccessible while releasing), the finally exception replaces that result. TaskService therefore never sets unregisteredCheckoutRetained; with configWriteAttempted still false, it enters the rollback branch and deletes the checkout that this path intentionally retains because its identity or sibling ownership may be indeterminate. Preserve the refusal result even when cleanup fails so a release error cannot trigger unsafe checkout deletion.
AGENTS.md reference: AGENTS.md:L103-L106
Useful? React with 👍 / 👎.
| } catch (error) { | ||
| return Err(unsanitizableOverridesMessage(target.workspacePath, error)); | ||
| } | ||
| return Ok(await publish()); |
There was a problem hiding this comment.
Keep a sharing sibling live until publication
When shouldPrune returns false because a live workspace shares this checkout, the checkout/global locks are released before publish() runs. A second backend can then acquire the checkout lock and remove the last sharing workspace—removal does not take workspace-registration.lock and deliberately does not prune—before this task record is written. The new task is consequently published as the sole owner of the checkout while retaining the removed workspace's plugin: enables, allowing its first send to activate a default-disabled server. Make removal contend with this registration transaction, or revalidate and prune atomically before publication.
AGENTS.md reference: AGENTS.md:L103-L106
Useful? React with 👍 / 👎.
|
Paused with the checkout-preparation layers. Nothing is pushed or merged. Details and the blocker are on #4364 (comment). The plan folds this PR into #4364, because its publication race (lXMXq) is closed only by #4364's lock and ancestry checks. The open threads stay open. Generated with |
|
#4308 has merged ( Generated with |
Summary
A direct (unqueued)
TaskService.createnow strictly sanitizes its fresh worktree's trackedplugin:MCP enables before it publishes the task record. Previously, the record was published first and the checkout was sanitized afterwards.Background
Before this change, any ordinary reader could discover and admit a new direct task between publication and sanitization. Readers include a user send, another backend's startup re-drive, and an older build. If sanitization then failed, an interrupted task record remained behind. A manual rescue of that task read the stale enable and could start a default-disabled plugin server.
Implementation
WorkspaceMcpOverridesService.prunePluginOverrideKeysForUnregisteredCheckoutis a strict pruner for a checkout that has no registry entry yet.shouldPruneverdict runs inside the held checkout and global override locks.WorkspaceService.registerSanitizedTaskCheckout(target, publish)runs under one hold of the registration lock:plugin:enables.An
Errresult means nothing was published. The scan reads the registry strictly: an unreadableconfig.jsonrefuses instead of reading as "no siblings".TaskService.createpublishes inside that callback.Two test-only commits restore a leaked
writeFilespy between the prune deadline tests and lint the leak guards.Validation
f94e8564:make static-check, and 1,475 tests in thetaskService.directCreateSanitize,workspaceMcpOverridesService,workspaceService, andtaskServicesuites under Bun 1.3.5.4ce8bc7: CP1–CP5 behavioral pass. Themcp setandinterrupt-streamCLI calls fail natively because their void results are dropped in JSON output. Those failures are retained; the effects were confirmed through public read-backs. This is not a clean CLI pass.candidate4ce8-final-bounded-run.accelerated-slowed4x.webm
candidate4ce8-final-bounded-run.accelerated-slowed4x.webm
Risks
config.jsonnow refuses direct task creation instead of pruning against an empty registry.Generated with
xum• Model:anthropic:claude-opus-5-5• Thinking:xhigh• Cost:$1479.40