Skip to content

🤖 fix: defer bash-monitor wakes until the active turn ends - #4114

Draft
ibetitsmike wants to merge 13 commits into
mainfrom
mike/bash-wake-turn-end
Draft

🤖 fix: defer bash-monitor wakes until the active turn ends#4114
ibetitsmike wants to merge 13 commits into
mainfrom
mike/bash-wake-turn-end

Conversation

@ibetitsmike

@ibetitsmike ibetitsmike commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Bash-monitor attention no longer cuts an active turn. While the owner workspace is busy (a queued or preparing turn, or a session-backed stream), a monitor match is deferred through the existing reconcile-after-idle path instead of being queued as a tool-end message, and the wake itself is sent with requireIdle. The turn finishes in its original stream and the agent answers on the same tool result; the owed attention arrives as a separate wake once the workspace is idle, or is withdrawn if the agent already consumed that output. A hard Stop retires the attention that is currently owed while leaving the monitor armed. Two independent fixes found along the way ride along: queue correlation readers skip withdrawn entries, and aborted-stream usage is priced against the effective (fallback) model.

Supersedes #4065, which is left open for its author to close.

Background

#4065 addressed this incident: monitored background bash tasks settled while the agent was streaming, the wake was queued as a tool-end message, the stream was stopped at the next tool boundary (finishReason: "tool-calls"), the agent's own task_await had already consumed the output so the reconciler withdrew the wake, and the turn was left stranded on an unanswered tool result until a human typed. #4065 kept the mid-turn cut and added detection of stranded turns plus a synthetic [CONTINUE] restart, a continuation lifecycle, a recovery loop and caps (+1,563 net lines across 22 files).

This PR removes the cause instead: attention that the running turn can consume must never be allowed to cut that turn. Production diff here is +99 / -66 lines; the rest is tests.

Implementation

  • WorkspaceService.dispatchBashMonitorWake: when the owner has a pending or preparing turn or a session-backed busy state, schedule scheduleBashMonitorWakeReconcileAfterIdle and return "deferred"; otherwise send the wake with requireIdle: true (no queueDispatchMode: "tool-end", no queue dedupe key, no abort listener that removed queued entries). A wake that loses the race with a user send is skipped by the existing requireIdle preflight and re-reconciled after idle, so manual input is never held behind background attention.
  • WorkspaceService.interruptStream with the new retireBashMonitorAttention option (passed by the user Stop entry points: the Stop button, the Escape keybind, the command palette Stop and ACP cancel; internal interrupts such as goal promotion, archive, ACP disconnect and send-now leave monitor output owed): before the interrupt, BashMonitorWakeReconciler.consumeCurrent withdraws any in-flight wake dispatch and marks the currently owed attention consumed, so the abort's own idle transition has nothing to send and no wake fires for output the user just stopped around. It runs before the abort because session.interruptStream returns only after the abort settled, when an idle-triggered dispatch may already be admitting; it is best-effort (a persistence failure is logged, the Stop still completes) and does not take bashMonitorHistoryLocks, so Stop never waits behind a wake admission that holds the lock across preflight and stream construction. Monitors stay registered and later output still wakes. consumeCurrent becomes public; the now-unread dedupeKey field is dropped from BashMonitorWakeDispatch.
  • AgentSession.sendMessage: a cancelable wake whose signal fires after the acceptance point of no return (its row is already durable) but before PREPARING now resolves Ok without starting a stream, the same contract as cancelBeforeAcceptance and the disposed path. Without this, a Stop issued while the wake was in goal sync saw no turn to abort and the wake started a stream after the Stop returned.
  • MessageQueue: hasAllWorkspaceTurnContinuations, hasAllWorkspaceTurnContinuationsAheadOfPromotedToolEnd, hasNextWorkspaceTurnContinuation, getNextQueueCutCandidate and isNextEntryBashMonitorWake now read the first entry whose cancel signal has not fired (the rule getNextDispatchableMode already used), so a withdrawn entry can neither supersede nor misattribute a delegated turn's correlation.
  • StreamManager.cleanupAbortedStream adds model: streamInfo.model and the request-pinned metadataModel to the stream-abort metadata (schema gains both as optional), and AgentSession.handleTurnAbort prefers the effective model over the requested model string and passes metadataModel into goal accounting, mirroring the stream-end path. Usage of an aborted stream that fell back to another model is priced against the model that actually ran, and a Coder runtime ID keeps its pinned pricing identity instead of recording $0.

Validation

  • Producer-to-stream tests in workspaceService.test.ts drive a fake SDK stream through AIService.streamMessage and assert: repeated owed wakes during a turn never cut it and the answer arrives in the original stream; unconsumed attention coalesces into one wake after natural completion while idle attention starts promptly; a hard Stop retires owed attention without disarming later idle wakes, completes while another holder owns bashMonitorHistoryLocks, and still succeeds when consumeCurrent rejects; an interrupt without retireBashMonitorAttention keeps the attention owed; a Stop issued during a wake's acceptance window leaves the session idle with no stream and a later match still wakes; owed attention neither holds a delegated completion open nor inherits its closed correlation; a withdrawn idle wake rolls back its admission and a fresh delivery succeeds.
  • Red-green for the effective-model fix: removing the metadata.model fallback or the metadataModel pass-through fails the new streamManager and agentSession.queueDispatch cases (the Coder-ID case asserts a non-zero cost). Red-green for the Stop ordering: reinstating the lock-around-interrupt version times out the lock-holder test and fails the best-effort test.
  • Remote UAT (Coder Agents on dogfood, claude-sonnet-5 through the gateway, xum built at c91286c and driven through the UI): PASS on all 10 scenarios. Three runs of a monitored task consumed in-turn by task_await ended in the original stream with no wake; unconsumed attention arrived as exactly one wake ~60 ms after the turn ended; an idle match woke promptly; a user message typed during a turn dispatched ahead of the wake and the wake followed that turn; Stop produced no wake for the stopped output while the next match woke normally; 10 rapid matches coalesced into one wake; reload kept the history intact. 7 wake rows total, all accounted for; no finishReason: "tool-calls" turn was left unanswered except the E1 turn the tester interrupted by hand, where the queued user message backgrounded the running foreground bash and dispatched at the tool boundary (existing behavior on main, not touched here).

Risks

Moderate, scoped to bash-monitor wakes and turn correlation.

  • Behavior change: a monitor match during a long turn is reported after the turn ends instead of at the next tool boundary. An agent that needs the output mid-turn still gets it through task_await; it is only the unsolicited wake that moves later.
  • The hard-Stop path retires owed attention before the abort rather than after it. Output that arrives during the few milliseconds the abort takes to settle is treated as post-Stop output and wakes normally once idle. session.interruptStream returns Ok even with no active stream, so pressing Stop on an idle workspace retires its owed attention as well; that matches the previous head. Only callers that pass retireBashMonitorAttention retire attention, so a caller added later without the option keeps the pre-PR behavior (output stays owed).
  • The queue reader change only affects entries whose cancel signal has already fired; those entries were already dispatched as no-ops.

Pains

The branch was rebased across #4109 (turn lifecycle centralization), which required porting the test harnesses from direct aiEmitter events to settling TurnStreamHandle.completion. Locally, bun on PATH resolved to 1.2.15 while the repo pins 1.3.5; under 1.2.15 the injected-failure tests in streamManager.test.ts and agentSession.queueDispatch.test.ts fail identically on main, which cost a diagnosis round.


Generated with xum • Model: anthropic:claude-fable-5-1 • Thinking: xhigh • Cost: $649.72

@ibetitsmike

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

This comment has been minimized.

@chatgpt-codex-connector

This comment has been minimized.

@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: c91286c4da

ℹ️ 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".

Comment thread src/node/services/streamManager.ts
Comment thread src/node/services/workspaceService.ts Outdated
Comment thread src/node/services/workspaceService.ts Outdated
Codex review on c91286c:
- hard Stop no longer takes bashMonitorHistoryLocks (a wake admission holds it
  across preflight and stream construction); consumeCurrent runs before the
  abort and is best-effort so a persistence failure cannot fail the Stop
- stream-abort metadata carries the request-pinned metadataModel so aborted
  Coder-runtime streams keep their pricing identity in goal accounting
@ibetitsmike

Copy link
Copy Markdown
Contributor Author

@codex review

Addressed all three findings in 27acae7: abort metadata carries metadataModel for pinned pricing, hard Stop retires owed attention best-effort before the abort, and no longer takes bashMonitorHistoryLocks.

@chatgpt-codex-connector

This comment has been minimized.

@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: 27acae7dd7

ℹ️ 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".

Comment thread src/node/services/workspaceService.ts Outdated
Comment thread src/node/services/workspaceService.ts Outdated
…ly on user Stop

A cancelable monitor wake withdrawn past the acceptance point of no return (a hard
Stop retiring owed attention while the wake is in goal sync or acceptance) keeps its
durable rows but resolves Ok without starting a stream instead of claiming PREPARING
after the Stop returned.

interruptStream retires owed attention only when the caller passes
retireBashMonitorAttention (user Stop button, Escape, command palette, ACP cancel);
goal promotion, archive, ACP disconnect and send-now keep monitor output owed.
@ibetitsmike

Copy link
Copy Markdown
Contributor Author

@codex review

Round 3 (12dd020): a wake withdrawn past the acceptance point of no return now resolves without claiming PREPARING, and interruptStream retires owed bash-monitor attention only when the caller passes retireBashMonitorAttention (user Stop entry points), not on every non-soft interrupt.

@chatgpt-codex-connector

This comment has been minimized.

@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: 12dd020f18

ℹ️ 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".

Comment thread src/browser/features/Messages/ChatBarrier/StreamingBarrier.tsx
Comment thread src/node/services/workspaceService.ts Outdated
Comment thread src/node/services/agentSession.ts
Comment thread src/node/services/workspaceService.ts
…onously; defer during pending compaction

- Compaction Stop paths (cancelCompaction, StreamingBarrier fallback) pass
  retireBashMonitorAttention so a match during compaction does not wake the agent.
- consumeCurrent aborts the in-flight dispatch before taking the owner lock;
  interruptStream starts retirement before the abort and awaits it after.
- Point-of-no-return withdrawal persists the user-abort abandon marker so startup
  recovery does not replay the retired wake.
- Wake dispatch treats a pending mid-stream compaction as turn work; the idle waiter
  waits on a deterministic settle signal from AgentSession.
@ibetitsmike

Copy link
Copy Markdown
Contributor Author

@codex review

Round 4 (a8f06c1). Addressed the four round 3 findings:

  • Compaction Stop paths (cancelCompaction, StreamingBarrier fallback) pass retireBashMonitorAttention so a monitor match during compaction does not wake the agent once the session goes idle.
  • consumeCurrent aborts the in-flight dispatch synchronously before taking the owner lock; interruptStream starts retirement before session.interruptStream and awaits it afterwards.
  • The point-of-no-return withdrawal persists the user-abort abandon marker so startup recovery does not replay the retired wake.
  • Wake dispatch treats a pending mid-stream compaction as turn work (hasActiveOrPendingTurnWork); the idle waiter waits on a deterministic waitForMidStreamCompactionSettled signal.

Each fix has a red-green test; the ACP prompt-correlation expectation is updated for the retireBashMonitorAttention option.

@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: a8f06c1964

ℹ️ 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".

Comment thread src/node/services/agentSession.ts Outdated
Comment thread src/node/services/workspaceService.ts Outdated

@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 Security Review

Here are some automated security review suggestions for this pull request.

Reviewed commit: a8f06c1964

ℹ️ 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.

Comment thread src/node/services/workspaceService.ts
…mmit retirement only after a successful stop

- The prefix-swap-invalidated path no longer settles the mid-stream compaction window
  before finishContinuousCompaction dispatches the saved continuation; the observation's
  finally settles it afterwards, as the usage-delta path already does.
- consumeCurrent takes a commit gate: it still withdraws the in-flight wake and reserves
  the reconciler lock before the abort, but advances watermarks only once the stop
  succeeded. A failed stop leaves the signals owed and schedules a reconcile.
@ibetitsmike

Copy link
Copy Markdown
Contributor Author

@codex review

Round 5 (head 1f3cc0b). Addresses both Round 4 findings:

  • The prefix-swap-invalidated path no longer settles the mid-stream compaction window before finishContinuousCompaction dispatches the continuation; the observation's finally settles it afterwards, so idle monitor wakes cannot race the follow-up send for turn admission.
  • consumeCurrent takes a commit gate: it still withdraws the in-flight wake synchronously, but advances watermarks and cleans up registry rows only after session.interruptStream succeeded. A failed Stop leaves the signals owed and schedules a reconcile.

@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: 1f3cc0be6d

ℹ️ 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".

Comment thread src/node/services/bashMonitorWakeReconciler.ts Outdated
@chatgpt-codex-connector

This comment has been minimized.

A wake withdrawn past its point of no return (durable row, not yet PREPARING)
resolves only after recording the startup abandon marker for that row. Stop now
waits for that send to settle, so a forced exit right after Stop cannot leave the
row eligible for startup replay.
@ibetitsmike

Copy link
Copy Markdown
Contributor Author

@codex review

Head is now 4876b42 (supersedes the round 5 request on 1f3cc0b). Adds the fix for the security finding: interruptStream joins the withdrawn wake's send before acknowledging Stop, so the startup abandon marker for its durable row is written before Stop returns.

@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: 4876b427e7

ℹ️ 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".

Comment thread src/node/services/workspaceService.ts
consumeCurrent snapshots the outstanding signals before waiting on the stop
gate, so output that arrives while the stop settles is new and stays owed to
the idle agent instead of being consumed by the successful Stop.
@ibetitsmike

Copy link
Copy Markdown
Contributor Author

@codex review

Head is now 47f3759. Adds the fix for the snapshot finding: consumeCurrent collects the owed signals before waiting on the stop gate, so output arriving while Stop settles stays owed to the idle agent.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: 47f37591cf

ℹ️ 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".

@chatgpt-codex-connector

This comment has been minimized.

… of no return

A hard Stop that withdraws a bash monitor wake after its row is durable but
while goal sync is pending left the row eligible for startup replay when goal
sync threw: AgentSession.sendMessage finalized onAccepted and rethrew before
reaching the abort-marker write, WorkspaceService.sendMessage resolved Err, and
interruptStream discarded the joined Result. Route the goal-sync catch, the
disposed exit, the onAccepted failure exit, and the existing withdrawn exit
through one abandonWithdrawnSend helper so the marker lands before the send
settles on every path, which is what the Stop join relies on.
@ibetitsmike

Copy link
Copy Markdown
Contributor Author

@codex review

Round 6: 72038d8 records the startup abandon marker on every withdrawn-wake exit past the point of no return (goal-sync failure, disposed, onAccepted failure), so the Stop join always observes a recorded marker.

@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: 72038d802c

ℹ️ 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".

Comment thread src/node/services/agentSession.ts Outdated
… point of no return

The goal-sync failure and disposed exits checked the cancel signal before
awaiting onAccepted, so a Stop that aborted the signal while the reconciler
was persisting acceptance was missed: the catch rethrew without an abandon
marker and Stop joined the send and returned success with the durable wake
row still eligible for startup replay. Run abandonWithdrawnSend in a finally
after onAccepted on those exits and after the accepted admission-stale
failure, so the check is the last await of every exit that skips PREPARING.
@ibetitsmike

Copy link
Copy Markdown
Contributor Author

@codex review

Round 7: 80e6716 makes the withdrawal check the last await of every exit past the point of no return (onAccepted wrapped in try/finally on the goal-sync failure and disposed exits, plus the accepted admission-stale exit), so a Stop landing during acceptance I/O still records the abandon marker.

@chatgpt-codex-connector

This comment has been minimized.

@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: 80e6716d9e

ℹ️ 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".

Comment thread src/node/services/agentSession.ts
persistAutoRetryState swallowed write errors, so a wake withdrawn past its
point of no return resolved Ok even when the startup abandon marker never
reached disk, and Stop acknowledged success while leaving the trailing
synthetic row eligible for startup replay. The marker writer now reports
whether the file reflects memory; a withdrawn wake whose marker could not
be recorded fails with WITHDRAWN_WAKE_UNRECORDED_MESSAGE, and the joining
interruptStream propagates that failure. Other preference writes stay
best-effort.
@ibetitsmike

Copy link
Copy Markdown
Contributor Author

@codex review

@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: 8c9f5f68d3

ℹ️ 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".

Comment thread src/node/services/workspaceService.ts
Comment thread src/node/services/workspaceService.ts
Comment thread src/node/services/bashMonitorWakeReconciler.ts Outdated
Comment thread src/node/services/workspaceService.ts
Comment thread src/browser/hooks/useAIViewKeybinds.ts
…wake markers

Round 8 review fixes for bash monitor wakes:
- consumeCurrent snapshots the process frontier synchronously on entry and
  keeps it as an owed retirement in reconciler state once the stop commits;
  reconcileOnce retries it before any dispatch, so retirement I/O that fails
  cannot let the stop's idle reconcile re-dispatch dismissed attention, and
  output arriving while the stop waits behind acceptance stays owed
- the unrecorded startup abandon marker is tracked in AgentSession and
  retried on every later user Stop, which fails with STOP_UNRECORDED_MESSAGE
  until the marker is on disk
- the idle waiter releases its map slot before scheduling reconciliation so a
  reconcile that loses the race to a new turn installs the next idle wait
- the RetryBarrier Stop button issues the same attention-retiring interrupt
  as its keyboard shortcut
@ibetitsmike

Copy link
Copy Markdown
Contributor Author

@codex review

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