🤖 fix: bash-monitor wakes are a level, never a queued edge (silent turn end after task_await) - #4071
🤖 fix: bash-monitor wakes are a level, never a queued edge (silent turn end after task_await)#4071ethanndickson wants to merge 28 commits into
Conversation
A monitored bash task matching mid-step queued a tool-end wake that cut the stream, then task_await showed the lines and the wake was withdrawn, leaving the turn to end silently with finishReason tool-calls. The stream now reads the reconciler level (hasPendingToolEndInput) at each tool boundary and the reconciler dispatches only when the owner is idle, so the queued-wake cancellation protocol is deleted.
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Automations to automatically generate PRs for you. |
|
@codex review |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
Security findingsAdvisory findings (4)
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e01c03f358
ℹ️ 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".
There was a problem hiding this comment.
💡 Codex Security Review
Here are some automated security review suggestions for this pull request.
Reviewed commit: e01c03f358
ℹ️ About Codex security reviews in GitHub
This is an experimental Codex feature. Security reviews are triggered when:
- You comment "@codex security review"
- A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review
Once complete, Codex will leave suggestions, or a comment if no findings are found.
…-off wakes on clear/dispose Codex review on #4071: - P1: a direct wake send lowers the reconciler level at row persistence, before its stream is observable. AgentSession now marks a bash-monitor-wake send in PREPARING (hasPendingBashMonitorWakeTurn) and WorkspaceService folds it into hasOutstandingBashMonitorWake so delegated-turn settlement still sees the continuation. - P1: a full-history clear or disposal forgets the in-flight dispatch; the receiver checks dispatch.isCurrent() under the history lock before sending. - P2: session dispose and reconciler dispose lower the mirrored tool-end yield flag so a re-created session does not inherit a stale early-return.
…e before send Codex security P2 on #4071: monitor cancellation cleared the level but a dispatch already in the receiver's hands was still sent. discardProcess (pull-side, no abort signal) forgets such a dispatch so isCurrent() turns false, and dispatchBashMonitorWake re-checks isCurrent() after awaiting the continuation send options, right before sendMessage.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a5944da474
ℹ️ 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".
…t latch - hasPendingToolEndInput/yield flag: a non-empty queue arbitrates alone; the wake level only cuts over an empty queue (turn-end head is not promoted). - dispatchBashMonitorWake sends with requireIdle + admissionStale=!isCurrent(), so a racing manual send makes the wake a skip (re-armed after idle) instead of a queued entry the level can no longer retract. - AgentSession records a wake-caused cut (streamYieldedToBashMonitorWake) until the next admitted turn so delegated-turn settlement sees the cut even if the monitor is canceled before the stream-end handler runs.
|
@codex review Round 2 addressed in 6b64c5f: queue-head arbitration (turn-end head is never promoted by the wake level), the wake send is |
|
Security review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6b64c5f31b
ℹ️ 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".
… wake cut as cutter stage, probe try/catch
- Reconciler.outputShown(owner, processId) forgets a handed-out dispatch whose
signal covers that process, so a wake still resolving send options while a
manual turn showed its lines fails isCurrent()/admissionStale instead of
admitting stale output.
- hasPendingToolEndInput re-reads the queue head after the async level read;
a message queued meanwhile arbitrates (turn-end head => no cut).
- The wake-cut latch is now cut attribution only: getQueueCutCutter reports
{ stage: "bash-monitor-wake" } and hasPendingBashMonitorWakeTurn no longer
consults it. A wake retracted after the cut (monitor canceled) settles the
delegated handle as interrupted with a wake-specific supersede reason
instead of deferring until the waiter times out.
- hasSameTurnContinuation catches wake-probe failures and settles normally.
|
@codex review Round 3 addressed in 48afcd8: shown-frontier invalidation of handed-off wakes ( |
|
Security review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 48afcd8a46
ℹ️ 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".
…s wake compaction follow-up, gate background waits on effective yield flag
|
@codex review Round 4 addressed in b84c699: |
|
Security review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b84c699fa3
ℹ️ 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".
- AgentSession owns the tool-end yield edge (onToolEndYieldRequested); WorkspaceService backgrounds foreground waits from that hook instead of from enqueue/level-publish call sites (PRRT_kwDOPxxmWM6fGVw_). - Reconciler: accept() never throws; acknowledgment is attempted inline and retried by every reconcile pass while the accepted dispatch stays consumed (overlay in collect) (PRRT_kwDOPxxmWM6fGVxB). - Abandoned compaction follow-ups settle their delegated turn via onWorkspaceTurnContinuationAbandoned -> settleSupersededWorkspaceTurnContinuation (PRRT_kwDOPxxmWM6fGVxG).
|
@codex review Round 5 (fba966d) addresses all three findings with one principle — the layer that computes a transition owns its consequences: |
# Conflicts: # src/node/services/agentSession.continueMessageAgentId.test.ts
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b29ecfaae3
ℹ️ 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".
…le-before-erase - WorkspaceTurnManager.hasSameTurnContinuation consults the wake level only when the event-time cutter is the wake itself; a manual tool-end head owns the cut and settles the handle (PRRT_kwDOPxxmWM6fOH50). - A goal-sync failure on a durable bash-monitor wake row arms the in-session auto-retry resume before consuming the signal and schedules it (PRRT_kwDOPxxmWM6fOH54). - clearPendingFollowUpFromSummary settles the abandoned delegated turn before erasing the durable follow-up so a settlement failure stays retryable (PRRT_kwDOPxxmWM6fOH59).
|
@codex review Round 6 (44c09f2): wake deferral in |
|
Security review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
…dicates through the live session (transient recovery included); a failed wake-row read fails the reconcile instead of counting as no row; recheck the queue head when the level read fails
|
@codex review Round 16 pushed: cutter and sync session predicates resolve through the live session (transient recovery included); a failed durable wake-row read fails the reconcile rather than counting as "no row"; the failing-level-read path rechecks the queue head. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6594bca1b4
ℹ️ 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".
There was a problem hiding this comment.
💡 Codex Security Review
Here are some automated security review suggestions for this pull request.
Reviewed commit: 6594bca1b4
ℹ️ About Codex security reviews in GitHub
This is an experimental Codex feature. Security reviews are triggered when:
- You comment "@codex security review"
- A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review
Once complete, Codex will leave suggestions, or a comment if no findings are found.
…usy startup-recovery session does not spin
|
@codex review Round 17: idle waits ( |
|
Codex Review: Didn't find any major issues. Hooray! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
|
Security review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
|
@codex review Round 18: a refused wake whose on-send compaction row could not be rolled back now consumes its lease ( |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 551ca90cd7
ℹ️ 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".
…s, and arm the in-session resume when it does
|
@codex review Round 19: the compaction-row rollback now reports deleted/remains/unknown; a refused wake consumes its lease only when the row verifiably remains, and then arms the in-session resume like other durable pre-stream failures. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cd483a011e
ℹ️ 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".
|
@codex review Round 20: delivery recovery scans history backward with early stop instead of a fixed 50-row tail, so a wake row buried behind a long turn or a compaction boundary is still found. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 91c35e31eb
ℹ️ 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".
|
Security review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
… arm time and skip rows without usable identities
|
@codex review Round 21: the delivered-wake-row scan stops at the oldest outstanding monitor's arm time (a durable exclusion point passed by the reconciler) instead of reading the whole transcript, and skips wake rows whose persisted |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4f5a05df2f
ℹ️ 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".
|
Security review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
Keep-recent compaction re-appends copies of the pre-boundary tail after the boundary with their source timestamps, so an old-stamped copy at the tail tripped the notBefore cutoff before the scan reached the wake row, and a restart after a failed acknowledgment redelivered the wake. Copies carry nothing their originals lack; skip them before the cutoff and before reading wake metadata. Made-with: Xum
|
@codex review |
|
Security review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a11a718fe9
ℹ️ 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".
…ble erase Reconciler: an offer is a claim on signals that derive now. A pass that ran between a monitor's cancel and its registry-row removal offered a monitor-lost wake for the canceled monitor and kept it current after the row was gone; the pass now withdraws an offer whose signals no longer all derive outstanding, and discardProcess no longer schedules the mid-removal pass itself (the caller schedules after the removal). AgentSession: clearPendingFollowUpFromSummary settled the delegated turn before the follow-up erase was durable, so a failed rewrite left a live, dispatchable follow-up under an interrupted handle. The void now follows a successful rewrite, with a readback that recognises a rewrite that landed but reported failure. Pins the empty-queue drain keeping the wake's tool-end yield flag up. Made-with: Xum
|
@codex review |
|
Security review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1a71e62fc1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (this.wakeTurnInFlight && !carriesBashMonitorWake(retryMetadata)) { | ||
| this.wakeTurnInFlight = false; | ||
| } |
There was a problem hiding this comment.
Preserve durable wake continuations after retry abandonment
When a delegated stream yielded to this wake and sendMessageInner() persisted its wake row but failed before starting the stream, disabling or exhausting auto-retry makes hasPendingAutoRetry() false here, so the in-flight marker is cleared and the already-lowered wake level voids the continuation debt. The durable row is explicitly retained for startup recovery, but this premature void can interrupt the parent handle and delete a disposable workspace before that recovery runs. Keep the continuation alive while a durable wake row remains recoverable, even when no in-session retry is pending.
AGENTS.md reference: AGENTS.md:L112-L112
Useful? React with 👍 / 👎.
| if (params.abandonIf?.() === true) { | ||
| return null; | ||
| } |
There was a problem hiding this comment.
Synchronize continuation admission through the terminal write
When a correlated report has already obtained the active turn metadata but is still in WorkspaceService.sendMessage() preflight, this guard cannot see it; fresh evidence after the earlier commit-point fix is that the guard remains a one-shot synchronous check before the awaited upsertWorkspaceTurn(), during which that send can become queued. The settlement can therefore persist an interruption and delete a disposable workspace underneath a valid continuation. Hold a shared admission/settlement reservation through the terminal write, or revalidate under synchronization that also covers preflight sends.
AGENTS.md reference: AGENTS.md:L150-L150
Useful? React with 👍 / 👎.
| if (this.disposed || this.shuttingDown) return; | ||
| this.unsettledWakeVoids.push(voided); |
There was a problem hiding this comment.
Retain failed void settlements across transient disposal
When startup recovery discards a durable compaction follow-up, clearPendingFollowUpFromSummary() launches this owner-side settlement without awaiting it; recovery can therefore finish, decide the transient session has no stream or retry to retain, and dispose it before the settlement rejects. This branch then drops the failed settlement because disposed is true, while dispose() also clears the parked voids and timer; since the durable follow-up was already erased, no later correlated stream can settle the delegated handle and its waiter remains blocked until timeout. Track in-flight void settlements in shouldRetainAfterStartupRecovery() or move their retry ownership outside the disposable session.
AGENTS.md reference: AGENTS.md:L150-L150
Useful? React with 👍 / 👎.
| const outstandingKeys = new Set(collected.signals.map((signal) => signal.key)); | ||
| if (!state.offered.signals.every((signal) => outstandingKeys.has(signal.key))) { | ||
| this.releaseOffered(state); |
There was a problem hiding this comment.
Withdraw offers when the signal version changes
When an offered match changes to a terminal or runtime-lost signal before the owner accepts it, the process incarnation retains the same signal.key, so this key-only check leaves the obsolete offer current. Fresh evidence after the cancellation fix is that the replaced implementation compared a signature containing the kind, match offset, terminal timestamp, and shown state, whereas this check ignores all of them; the old prompt can therefore start a stale agent turn before the updated failure or settlement is delivered separately. Compare the offered signal versions, not just their process keys, when deciding whether the lease still derives.
Useful? React with 👍 / 👎.
| const recovered = signals.filter((signal) => | ||
| deliveredKeys.has(signal.processId + "\u0000" + wakeUpdatedAtOf(signal)) | ||
| ); |
There was a problem hiding this comment.
Recover match and terminal wake acknowledgments independently
When a durable match wake's watermark write fails and that process then exits or becomes lost before restart, the current signal's identity changes: wakeUpdatedAtOf() now prefers the terminal settledAt or lost failedAt, while the durable row records the earlier createdAt:matchOffset. This equality check consequently fails to recover the already-delivered match, and reconciliation repeats its output alongside the genuinely new terminal/lost notification. Apply the delivered row's match frontier independently of the current terminal/lost version so restart recovery does not duplicate acknowledged output.
AGENTS.md reference: AGENTS.md:L112-L112
Useful? React with 👍 / 👎.
Summary
Agent turns ended silently (
finishReason: "tool-calls", no text) right after atask_awaiton a monitored background bash task completed. Bash-monitor wakes are now a level the owner workspace reads at every tool boundary instead of a queued tool-end message that snapshots that level, which removes the race by construction and deletes the queued-wake cancellation protocol that existed only to retract those snapshots.Background
Reproduced from a real session:
bashwithrun_in_background: true+monitor, twotask_awaitcalls, the second returned "completed", and the turn ended with nothing said.Chain:
monitor:match→ reconciler →dispatchBashMonitorWakequeued the wake withqueueDispatchMode: "tool-end"while the owner streamed.streamManager.createStopWhenConditionsawhasQueuedMessages("tool-end")and cut the stream after the current step.task_awaitshowed the matched lines (shownThroughOffsetadvanced) → next reconcile auto-consumed →abortDispatch()withdrew the queued wake.cancelBeforeAcceptancestarted nothing → empty queue, stream gone → silent turn end.#4029 made the durable wake state level-triggered, but mid-stream delivery still materialized an edge artifact. #4067 (skip withdrawn entries in
hasQueuedMessages) does not close the window because withdrawal happens on the next reconcile (microtask + owner lock + disk reads) while the SDK evaluates the stop condition immediately after the tool result.Implementation
Two invariants replace the mirrors that earlier rounds kept patching:
BashMonitorWakeReconciler.hasOutstandingWake(owner): unshown signals exist (same-process blocking reads are not outstanding).ReconcileStateholdsoffered?: Lease/committed?: Lease; a new lease is handed out only when both slots are empty.isCurrent()isstatus !== "released"; withdrawal (forgetDispatchFor,consumeCurrent,defer,outputShown,discardProcess, full-history clear, dispose) releases only an offered lease.commit()never throws: a released lease still commits (its row is durable), it retires any replacement lease that was offered into the emptied slot (whatever else that replacement carried re-derives on the reconcile scheduled after commit), acknowledges inline, and on failure the nextreconcileOnceretriesstate.committedfirst whilecollect()overlays the committed signals so the level stays low and nothing is derived twice.hasQueuedMessages→hasPendingToolEndInput()inStreamRequestConfig/turnRequestBuilder(the AI SDKStopConditionacceptsPromiseLike<boolean>). A non-empty queue arbitrates alone (head === tool-end; re-read after the level read so a message queued meanwhile still arbitrates); the level cuts only over an empty queue, matching the dispatcher, so a turn-end head is never promoted. A true from the level is the cut:wakeContinuationDebt = { correlation }is taken in the same synchronous step as the decision. The level is read live rather than from the published mirror becausetask_awaitshowing matched lines lowers it through an async reconcile — a mirror read would cut every such stream for lines the step just displayed and then void the debt as retracted.AgentSession, each the only place its consequence is computed):sendMessage/resumeStreamare thin public wrappers that raisewakeTurnInFlightsynchronously for a wake before the first await (soonAcceptedlowering the level can never observe "no wake turn") and settle it on return; stream-start whose request metadata isbash-monitor-wakeredeems (a compaction stream's request is the compaction row, so it does not);settleWakeTurnInFlight()keeps the marker only while a turn is in progress or an auto-retry is armed for the wake's durable row, and runs on IDLE, send/resume return,retryActiveStreamreturn, andauto-retry-abandoned; the level lowering or the marker dropping with a debt outstanding voids it asretracted; other input voidssupersededonce its row is durable (settleWakeDebtForAcceptedInput; a send refused earlier leaves the wake owed, and a correlated re-dispatch continues the debt silently);clearPendingFollowUpFromSummaryvoidsabandonedbefore erasing the durable follow-up (the record is the correlation's only carrier; settlement is idempotent).getQueueCutCutter()reports{ stage: "bash-monitor-wake" }iff a debt exists;hasBashMonitorWakeContinuation()= debt ∨ in flight. A void whose owner hook fails (handle store / waiter / cleanup I/O) is parked inunsettledWakeVoidsand retried at the next debt transition and on a timer, so a deferred handle never waits for a restart. Dispose clears everything silently and lowers the mirror.dispatchBashMonitorWakedefers (re-arming the after-idle reconcile) while the owner has a pending turn or busy session; returns plain"deferred"when a stream exists without a busy session (stream-end/abort/error listeners already reconcile); otherwise sends a direct synthetic turn withrequireIdle+admissionStale: () => !isCurrent()carryingbash-monitor-wakemuxMetadata, so it is never queued behind a racing manual send and correlation inheritance from history is unchanged. A goal-sync failure on a durable wake row arms and schedules the in-session auto-retry before committing the lease. The send runs under the owner's workspace event lock (AgentTaskIntegration.withWorkspaceEventLock, outermost; workspace removal takes the history lock while holding it): the lock is FIFO with the stream-end handler of the stream that yielded, so a wake can never start — and redeem the debt — before that handler has read the debt to defer the delegated turn.hasSameTurnContinuationis synchronous: it defers iff the event-timequeueCutSnapshot.cutteris the wake andworkspaceService.hasBashMonitorWakeContinuation(workspaceId)(a sync read of the session debt). The snapshot is captured at stream-end emission, before the per-workspace event lock, so a void landing in between would be lost if the handler trusted the cutter alone; a cleared debt yieldsretracted_wakeevidence and settles the handle with the retracted-wake error. The void hook (onWorkspaceTurnContinuationVoided(correlation, reason)→AgentTaskIntegration.settleVoidedWorkspaceTurnContinuation) runs under that same lock and settles only records the stream-end already deferred (deferredOnlyforretracted/superseded;abandonedsettles running records too), so exactly one of the two paths settles a record whichever side of the lock the void lands. A void says nothing about other continuations of the same turn: if a correlated report is queued or already streaming (hasPendingWorkspaceTurnContinuation/ a correlated active stream the record has not deferred), the void leaves the record for that continuation's stream-end. A manual tool-end head owns its cut and settles the handle without consulting the debt.debt.assumed) until its stream starts, and an assumed correlation survives a compaction auto-retry (getCarriedWorkspaceTurnCorrelationunwraps the compaction request's follow-up). The session keeps a small stream-start ledger (hasCorrelatedStreamStartedAfter) so a continuation that already ended before the predecessor's stream-end handler ran still defers rather than settles; that check, plus "another correlated continuation is queued", is re-evaluated at the settlement commit point (settleWorkspaceTurn.abandonIf, under the settlement lock after the reread) rather than from a pre-lock snapshot. A retried void whose earlier attempt persisted the terminal row re-enters the idempotent terminal branch, which now also resumes the skipped phases (waiters, execution-state mirror, disposable cleanup while the workspace is still registered). The compaction-completion path never awaits the void hook (the owner's stream-end listener holds the workspace event lock while awaiting the compaction decision). After a restart, the reconciler recovers "already delivered" from the owner's last durable wake row — direct or carried by an on-send compaction request (getCarriedBashMonitorWake) — and acknowledges matching signals instead of re-dispatching them. The level, the settlement predicates (cutter, debt, busy/queued) and the after-idle waits all resolve through the live session including one still running startup recovery (getLiveSession), so a wake deferred on a busy recovery session waits for that session instead of re-deferring in a loop; a send refused at the admission gates after its on-send compaction row landed rolls that row back; a failed history read during delivery recovery fails the reconcile (retried) rather than being recorded as recovered;hasPendingToolEndInputfalls back to the queue head when the level read fails; and a wake refused after its on-send compaction row could not be rolled back consumes its lease — only when the readback confirms the row remains (an unknown outcome leaves the lease released) — and arms the in-session resume so the row stays its reachable single carrier. Delivery recovery finds the last durable wake row with an early-stopping backward scan bounded at the oldest outstanding monitor's arm time (a row older than that cannot acknowledge it) — no fixed tail depth, so a row buried behind a long turn or a compaction boundary is still recognised, and no whole-transcript read on an owner's first wake; rows whose persistedrecordslack usable identities are skipped, as are RLM preserved-tail copies (re-appended after the boundary with their source timestamps, so they would otherwise trip the cutoff before the scan reaches the wake row). A reconcile pass withdraws an offered lease whose signals no longer all derive outstanding (a monitor-lost wake offered from a registry row mid-removal is retired once the row is gone; the cancel itself schedules no pass), and a skipped compaction follow-up settles the delegated turn it carried only after its erase is durable (readback on a failed rewrite).syncToolEndYieldRequested()computes queue head ∪ wake level, drivesbackgroundProcessManager.setMessageQueued, and firesonToolEndYieldRequestedon every rising edge; WorkspaceService backgrounds foregroundtask_awaits from that hook alone.cancelBeforeAcceptance+cancelSignal/cancelState,cancelReasonBeforeAcceptance,isNextEntryBashMonitorWake,hasPendingBashMonitorWakeContinuation,materializeMcpPromptSnapshots's cancel signal; from the redesign,streamYieldedToBashMonitorWake,turnCarriesBashMonitorWake,DispatchState.accepted, the seam's asynchasOutstandingBashMonitorWakeprobe andsettleSupersededWorkspaceTurnContinuation/onWorkspaceTurnContinuationAbandoned(folded into the void hook).Earlier iterations (rounds 1–8) and why they were replaced
Rounds 1–8 fixed individual Codex findings by adding a marker per window: a PREPARING flag, dispatching-entry metadata, a cut latch, an
acceptedbit on the reconciler slot, and a settlement probe that unioned them with an async level read. Each fix moved the race to the next hand-off. The redesign replaces the union with a single owned debt and a single lease lifecycle; the model doc comments onwakeContinuationDebtandLeasecarry the transition tables.Net product code ≈ −350 LoC against
main.Validation
workspaceService.test.ts("the stream yields on the wake level, not on a queued snapshot of it"): real reconciler + mocked process state;hasPendingToolEndInput()is true while the match is unshown, false onceshownThroughOffsetcovers it, a queued tool-end message still yields, and the cut leavesgetQueueCutCutter()=bash-monitor-wake.outputShown/discardProcess) and the next reconcile persists it; a lease withdrawn under the send still commits and retires its replacement, with only the original signals acknowledged and the unseen process re-derived once.queueDispatch): debt taken at the cut and voidedsupersededonce other input's row is durable (a refused superseding send leaves it owed); a void whose settlement rejects is retried once with the original reason at the next level transition; a correlated re-dispatch continues it without settling; level lowering with no wake turn in flight voidsretracted;wakeTurnInFlightis true fromsendMessage's first synchronous step (whileonAcceptedlowers the mirror) until stream-start; a wake refused before its row is durable leaves the debt to the next dispatch; a post-durability goal-sync failure keeps the wake in flight while the auto-retry is armed and settles it when the retry is disabled; dispose lowers the mirror and lever.abandonedbefore the history erase, and a failed settlement retains the follow-up for retry.compaction-requestrow; a busy transient startup-recovery session defers the wake and the idle wait stays pending until that session goes idle; delivery recovery rejects (and is retried) when the history read fails; a level read that fails while a tool-end head was enqueued still yields.retracted/supersededvoid is a no-op on a running record, settles it once the handler has deferred it, and leaves it alone while another correlated continuation is queued.workspaceTurnManager.test.tshas 2terminal recovery ...failures that fail identically on pristinemain. The merge also carries a one-commit fix formain-inherited breakage surfaced by CI (getSessionDironPartial<Config>after 🤖 refactor: split the Config god-class into five focused stores #4017,archivingWorkspaceIdsmissing from 🤖 feat: optional flat chat list for the sidebar with project badges #3994's sidebar mocks after 🤖 feat: show "Archiving..." from every archive entry point while the request is in flight #4068, and an MCP snapshot assertion on thecancelSignalargument this PR removes).Risks
hasOutstandingWakerunscollect()at every tool boundary (registry list + watermark reads under the owner lock). Cheap in practice, but new I/O on the hot path.supersededvoid racing the stream-end handler is settled by the handler asretracted_wake(same terminal state, different reason text). A wake send that fails before its stream with auto-retry disabled or exhausted voidsretracted, so the parent settles instead of waiting for a startup resume; the durable row itself still self-heals through startup recovery.Generated with
xum• Model:anthropic:claude-fable-5-1• Thinking:high• Cost:$224.34