Skip to content

🤖 fix(tasks): sanitize a direct task's forked checkout before publishing its record - #4362

Open
ThomasK33 wants to merge 69 commits into
mainfrom
fix/workflow-direct-prepublication-sanitize
Open

ThomasK33 wants to merge 69 commits into
mainfrom
fix/workflow-direct-prepublication-sanitize

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

Summary

A direct (unqueued) TaskService.create now strictly sanitizes its fresh worktree's tracked plugin: MCP enables before it publishes the task record. Previously, the record was published first and the checkout was sanitized afterwards.

Stacked on #4308. That PR is currently blocked by its own open review findings. This PR cannot merge until #4308 is ready. It makes no readiness claim for #4308.

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.prunePluginOverrideKeysForUnregisteredCheckout is a strict pruner for a checkout that has no registry entry yet.

    • It locks on the spelled host path plus its realpath, so it contends with every registered writer or rename of the same physical checkout.
    • A read-only shouldPrune verdict runs inside the held checkout and global override locks.
  • WorkspaceService.registerSanitizedTaskCheckout(target, publish) runs under one hold of the registration lock:

    1. Run the strict sibling scan inside the prune's locks.
    2. Prune the tracked plugin: enables.
    3. Publish the task record.

    An Err result means nothing was published. The scan reads the registry strictly: an unreadable config.json refuses instead of reading as "no siblings".

  • TaskService.create publishes inside that callback.

    • A refusal names the retained, unregistered worktree and never deletes it.
    • After an uncertain config commit, the committed row is verified before deciding the outcome. Only a row this launch still owns is marked as a failed launch.
  • Two test-only commits restore a leaked writeFile spy between the prune deadline tests and lint the leak guards.

Validation

  • Rebased head f94e8564: make static-check, and 1,475 tests in the taskService.directCreateSanitize, workspaceMcpOverridesService, workspaceService, and taskService suites under Bun 1.3.5.
  • The upper layer's recorded hosted-CLI runs on the stack's final build exercise the same direct-creation path:
    • A malformed override refuses creation, retains the directory, and creates no row.
    • A clean creation is sanitized before its first launch.
  • Pre-rebase hosted-CLI dogfood on the patch-identical source 4ce8bc7: CP1–CP5 behavioral pass. The mcp set and interrupt-stream CLI 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.

Pre-rebase direct-create dogfood, final frame (source 4ce8bc7)

candidate4ce8-final-bounded-run.accelerated-slowed4x.webm
candidate4ce8-final-bounded-run.accelerated-slowed4x.webm

Risks

  • Moderate, task creation only. The registration lock is now held across the sibling scan, prune, and publication of a direct task. Concurrent registrations wait up to the existing acquire timeout.
  • Refused creations intentionally leave an unregistered worktree on disk. The worktree's name is unique to its task ID, and it is never adopted.
  • A malformed config.json now 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

…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 -->
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-23T22:31:38.376181Z d856308 New commits
🔒 Security Review ✅ Completed 2026-09-23T22:34:47.951520Z d856308 New commits
ℹ️ 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" or "@codex security review".

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 -->
@ThomasK33
ThomasK33 force-pushed the fix/workflow-direct-prepublication-sanitize branch from f94e856 to 16553a1 Compare September 23, 2026 21:40
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 -->
@ThomasK33
ThomasK33 force-pushed the fix/workflow-direct-prepublication-sanitize branch from 16553a1 to d856308 Compare September 23, 2026 22:19

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@ThomasK33

Copy link
Copy Markdown
Member Author

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 xum • Model: anthropic:claude-opus-5-5 • Thinking: max • Cost: $2743.69

Base automatically changed from thomask33/workflow-attempt-admission to main September 24, 2026 12:24
@ThomasK33

Copy link
Copy Markdown
Member Author

#4308 has merged (fbb2d8a22). GitHub retargeted this PR to main. It now shows conflicts because it was built on an older #4308 head (3d717051a), not on the squash-merged version. That is expected: the preparation PRs stay paused at the design gate (details on #4364), and this branch will be rebased onto main when that work resumes.


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: max • Cost: $2812.71

@ThomasK33

Copy link
Copy Markdown
Member Author

Parked with the rest of the stack; details: #4364 (latest comment) and #4476.


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant