Repository navigation
fix(claude): end a message Claude started but never confirmed, and keep Claude running while it holds one - #23898
Conversation
…lled frame Claude reports each uuid-stamped command's lifecycle (queued, started, completed, cancelled). A send it withdraws from its queue gets `cancelled` before the interrupt or cancel_async_message answer, so a lost or failed answer no longer leaves that send pending: it settles as withdrawn, with the same reason and words as the receipt path. A command the CLI already started also ends `cancelled` when its turn is interrupted or fails, so `cancelled` after `started` is not a withdrawal; an echoed send has left the waiter lists and is never reached. Tests replay real 2.1.280 captures, scrubbed.
…idle A Claude send whose write ended in doubt is recorded `unknown`, and a live `unknown` reads as work still owed, so the chat showed Working until the child exited. Claude sends `session_state_changed idle` only once its whole queue has drained, so it can no longer be holding that send. The runtime now routes that report to the host's existing release, the same one Codex's thread-stopped report uses; it retires `unknown` only, never `pending`.
… queued; fixtures name msg_lifecycle_v1
…never echoed A send the CLI started, then cancelled before any echo, stayed pending: it may already be in the conversation, so it is released as doubt (unknown, recovered), never withdrawn and never re-sent. A late echo still accepts it. The 2.1.280 schema has two more terminal states. `discarded` (the CLI ended its session with the send still queued) settles as not delivered; `refused` (declined before it queued) settles as not accepted by the provider. After `started`, either one is doubt, as `cancelled` is. The late-settlement path gains an `unknown` outcome, which the host records as released doubt.
…goes idle `session_state_changed idle` comes only once the CLI's queue has drained, so a send it took that is still unanswered there got no echo and never will: a turn that throws can leave `started` with no terminal state. Idle releases it as doubt. What proves the CLI took a send is its lifecycle frame. On a CLI that reports no lifecycle, it is the send's place on stdin: one whose write finished before an interrupt went out was read before the interrupt was, so the first idle after that interrupt releases it too. A send armed ahead of the interrupt but written after it is left alone, since the CLI may still run it.
… send A Claude retrying a rate-limited request has taken the send but echoes nothing, so no turn row exists yet and the sweep rested the child after the idle window, turning the send into doubt. The adapter now reports whether the CLI holds a send (lifecycle `queued` or `started`, not yet echoed or ended), derived from the live waiters, and owed work counts it. Nothing is stored: every held send leaves the live set on its echo, its terminal lifecycle state, the CLI's idle, or the child's exit, so the hold ends with the send.
2.1.280's end-of-turn cleanup can report idle before it re-reads its queue, so a send read in that window goes queued, idle, started. Releasing every taken send at idle doubted that live send and dropped Working. Only a `started` send is released at idle or keeps the child from the idle sweep; a `queued` one ends by starting and echoing, by a terminal lifecycle frame, or with the child. The stdin-order path for CLIs without lifecycle frames is removed: a doubted send retired there disables content matching on CLIs that mint their own echo ids, and no Orca failure called for it. Those CLIs keep the earlier behaviour. Comments that said only a failed write or child exit ends a waiter, or that idle comes only once the queue has drained, now say what ends one.
Status: ready for review (step 2 of 2, stacked on #23862)Head What it fixes: a Claude message that Claude started but never confirmed, then ended or failed, stayed "waiting" with the chat saying Working until the process exited. It now shows "Message delivery is unconfirmed." with Retry. Claude's Review: two loops.
Validation:
Not done:
Known limits: listed in the description.
Not merged. It's ready for a maintainer's decision. |
|
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Terminal lifecycle frames now settle unstarted sends —
claude-command-lifecycle.tsreadscancelled/discarded/refused; beforestartedeach rejects with its matching fact, and afterstartedany of them releases the send as doubt rather than withdrawing it. - Idle releases a started, unechoed send —
resolveClaudeReplayTurncallsreleaseClaudeDispatchesUnansweredAtIdleonsession_state_changed idle, retiring only waiters still markedstarted(a still-queuedone is left, per 2.1.280 idling before re-reading its queue). - The idle sweep holds a Claude child that has taken a send —
holdsDispatchis derived per check from the in-memory pairing records and plumbed adapter → router → conversation-lifetime →StructuredAgentSessionIdleSweep.owesWork. - New doubt settlement outcome —
settleStructuredAgentSessionLateDispatchwrites{state:'unknown', reason}asrecovered: true, so the send is retired without any automatic re-send. - Tests — capture-replay coverage for each new ending, a production-runtime test, and an idle-sweep hold test.
I read the full diff and traced the hold/release paths. The hold is derived and bounded: every started waiter leaves dispatchWaiters via an echo, a terminal frame, idle, a failed write, or child exit, so it cannot latch. The only place one send is settled twice (unknown then rejected) is the pre-existing Stop path that ends the started-during-hooks case, which the PR already documents. I ran the three changed test files (claude-command-lifecycle.test.ts, structured-agent-session-idle-sweep.test.ts, claude-structured-idle-releases-unknown.test.ts): 44 tests pass.
One trade worth keeping in mind, already stated in the PR body: the hold has no timer, so a CLI that starts a command and never emits idle nor exits keeps its child until Stop or tab close. Nothing in the captured frames or the 2.1.280 code shows that state reachable, so it reads as intentional.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (3)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 3 remain after this review. 📝 WalkthroughWalkthroughClaude dispatch settlement now distinguishes sends that started from sends that did not. Started sends can be released in doubt when the CLI ends a command or reports idle; a later echo can still accept a send. Unknown outcomes are recorded as recovered submissions. Adapter hold state also prevents the idle sweep from stopping a session while a dispatch remains unanswered. Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to The change ends unanswered started Claude messages without automatically resending them and protects provider-held work from idle shutdown. No concrete merge-blocking risk is established; merge after normal checks pass. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to The changes conservatively distinguish messages that may have run from messages that were never delivered, and prevent idle shutdown while work remains. No new external access or permissions were identified. Deployment exposure and some recovery guarantees remain incompletely verified. Retained concerns Security review detailsSecurity Blast Radius
Trust Boundaries and Controls
Resilience and Maintainability Implications
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
Full details: Description checkExplanation The description is detailed and covers the change, user impact, rationale, testing, visual proof, limitations, and review notes. However, the required Linked Issue section explicitly states that no issue exists, despite the template requiring an actual issue link. ✨ Finishing Touches 💡 2📝 Generate docstrings 💡
🛠️ Fix failing CI checks 💡
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Merged
origin/maininto the branch — the only commit added since the prior review; it brings in step 1 (#23862, now merged) and unrelated main work. - Reconciled three PR files to
origin/main—claude-structured-session-adapter.tsadopts main's compaction-to-dispatchClaudeCommandrefactor,claude-structured-session-state.tsre-points the journal-translator import to its contract module, andclaude-structured-dispatch.tstakes main's optionalsentUuidondispatchClaudeTurn. - No PR-authored behavior changed — every other touched file is byte-identical to the previously reviewed head
a71b3ec; theholdsDispatchplumbing and lifecycle release logic survived the merge verbatim.
I confirmed the delta by diffing a71b3ec..72b6481 across the 20 PR files, then ran pnpm tc:node and six affected test files (76 tests: claude-command-lifecycle, structured-agent-session-idle-sweep, claude-structured-idle-releases-unknown, structured-agent-session-conversation-close, claude-structured-control-actions, structured-agent-session-command-readers) — all pass. The hold/release semantics are unchanged from the prior review, so the earlier analysis still stands.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Merged
origin/maininto the branch — the only commit added since the prior review (e97c0ed), bringing in main CI fix #23923. Every file it touches is main-side (theconstants.tssplit, onboarding defaults, native-chat turn-fold/transcript slots, i18n); no PR-authored file changed.
git diff 72b6481..e97c0ed -- <PR source paths> is empty, so the lifecycle, hold, and idle-release logic already reviewed at a71b3ec/72b6481 survives the merge verbatim. I re-ran the three key test files on the merged tree (claude-command-lifecycle, structured-agent-session-idle-sweep, claude-structured-idle-releases-unknown): 44 tests pass, and the head SHA has no failing check-runs. The prior review's conclusions still stand.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
|
Status update: #23862 merged, so this PR now targets |
…only started The sweep rested a child whose CLI had queued a follow-up behind a turn, dropping the send it had already taken. The hold now spans the CLI reporting it took the send until its echo, a terminal lifecycle state, or the child's exit. The idle release still covers only started sends: 2.1.280 can report idle before it re-reads its queue.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- The idle-sweep hold now spans a queued send, not only a started one —
claudeHoldsDispatch(claude-command-lifecycle.ts:70) returns true for any waiter carrying a non-terminalcommandLifecycle(queuedorstarted), so a follow-up the CLI queued behind a running turn keeps its child off the idle sweep until it is echoed, withdrawn, or started and then released. The idle release itself staysstarted-only, matching 2.1.280 idling before it re-reads its queue. - Tests for the widened hold — two capture-replay cases (queued until withdrawn, queued until start-and-echo), the
auth-failedhold expectation flipped to true, a production-runtime case where a queued send survives idle as still-pending, and an idle-sweep test that a provider-held send blocks the stop and grants a fresh window once released. - Docs — the
holdsDispatchJSDoc and the adapter/waiter comments now say "took a send" rather than "started a send".
The delta is one commit (6f2c729c1a). I verified it is load-bearing: reverting claudeHoldsDispatch to === 'started' fails 4 tests across the two touched suites, and the change passes all 47 tests in the three affected suites. pnpm tc:node passes. Terminal-frame handling, the started-only idle release, and the wire shape are untouched by this delta.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Extracted the provider-proven late-dispatch settlement into its own module —
settleStructuredAgentSessionLateDispatchmoved verbatim fromstructured-agent-session-host-mutations.tsto a newstructured-agent-session-late-dispatch.ts;structured-agent-session-host.tsimports it from there, and the now-unused imports were dropped. Signature and behavior are unchanged. - Merged
origin/main— brings in main's mid-turn queued-messages work (#23726) and the one-journal-database-per-host refactor (#23613), reconciling the PR's host wiring (structured-agent-session-host{,-mutations}.ts,structured-agent-session-conversation-lifetime.ts,structured-agent-session-runtime.ts,structured-agent-session-idle-sweep.ts) against them. No PR-authored lifecycle, hold, or idle-release logic changed in the merge.
The delta is one PR-authored refactor commit plus the merge. I confirmed the moved function has a single import site and no duplicate export, ran pnpm tc:node, and ran the affected suites (claude-command-lifecycle, structured-agent-session-idle-sweep, claude-structured-idle-releases-unknown, structured-agent-session-conversation-close, claude-api-retry-idle-sweep): 59 tests pass. The hold/release semantics reviewed at a71b3ec/6f2c729 are untouched.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Pinned a Stop that interrupts an already-started steer as doubt, not withdrawal — a new capture-replay case in
claude-command-lifecycle.test.tsreplays thebatch-leadfixture'sclient-Csteer (queued while the lead's turn runs, then started as its own turn), omits every frame after the steer'sstarted, drivescancelTurnwith an interrupt that leaves nothing queued, then injects the CLI'scancelledand idle frames. It assertsclient-Csettles asunknown/provider_ended_before_acknowledgement— never withdrawn — and thatholdsDispatchgoes true then false.
The delta since the prior review (ff9d0a45ec) is one test-only commit (5471ca443b, +46 lines, one file); no production code changed. I confirmed the test is load-bearing: disabling the waiter.commandLifecycle === 'started' doubt branch in claude-command-lifecycle.ts:52 fails this test along with the five sibling started-terminal cases, and the full suite (31 tests) passes on the restored tree. The prior review's hold/release conclusions still stand.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
|
Status: live QA done, ready for review. Head Live QA ran on a second Mac with the real Claude CLI 2.1.285, current
Not run live: Also traced, with no bug found: drafts queued in Orca while Claude is busy are never counted as Claude's pending messages, so this PR's hold and release never touch them. A Stop with the queue paused still settles every message. |

ELI5
When you send a Claude chat a message, Orca marks it "sent, waiting" until Claude confirms it. Sometimes Claude starts on a message and then ends that turn early, or goes quiet, without ever confirming it. That message then stayed "waiting" until the Claude process exited, and the chat kept saying Working. Now Orca reads what Claude reports about each message and gives it an honest ending. It never calls a message withdrawn if Claude may already have acted on it.
The plan this belongs to
Goal: every Claude message Orca hands over has a way to end, with no timers and nothing new saved. It ships as two PRs:
What Changed
The problem. After #23862, three cases still had no ending:
discardedandrefused, were ignored. Onlycancelledwas read.Scope. Claude chats, on the desktop app and on SSH hosts: the host runs Claude and reads its reports. It needs a Claude CLI that sends per-message reports (2.1.261 or newer). Claude CLIs older than 2.1.261 keep #23862's behaviour, where the idle cleanup stopping the process is what ends a held message. Codex chats are not affected.
Before / After
discardedbefore it started (Claude ended its session with it still queued)refusedbefore it startedcancelledbefore it startedqueuedwhen Claude goes idleIf Claude's repeat-back of the message arrives after Orca called it unconfirmed, the message is still marked delivered.
The idle cleanup no longer stops a Claude process while it holds a message it has taken, queued or started, and not yet confirmed.
Mechanism
claude-command-lifecycle.ts: a terminal report (cancelled,discardedorrefused) for a message that never started rejects it with the matching reason. Afterstarted, the same report means only that its turn ended, so the message is released as doubt through the newreleaseClaudeDispatchWaitersInDoubt(claude-structured-dispatch.ts). The pairing record is retired, not dropped, so a late repeat-back still marks it delivered. The host writes the result through the existing late-settlement path as "unknown, recovered", so it is never re-sent on its own.claude-replay-turn-resolution.ts: when Claude reports its session idle, a message still markedstartedis released the same way.claudeHoldsDispatchis derived on every check from the in-memory pairing records: true while one isqueuedorstartedand unconfirmed. Releasing at idle still covers onlystarted. It reaches the idle cleanup's "work still owed" check (structured-agent-session-idle-sweep.ts) through the Claude adapter and the router.Why
These reports are Claude's own per-message facts, so every held message ends from something Claude said, not from a timer or a guess. A message Claude started may already be in the conversation, so it is never called withdrawn or refused. It is "unconfirmed", the same word Orca already uses when it cannot prove delivery.
Why nothing can be held forever. The hold is derived, not stored. Claude's per-message schema gives every taken command
queued, thenstarted, then exactly one end state; its one documented stuck-queued case is a peer message, which Orca never sends, and every queued command in the captured runs ends. A pairing record stops holding on any of these:A Claude process that has taken a message (queued or started) but not yet repeated it back is now kept running for as long as it stays in that state, for example while it retries a rate-limited request before its first reply. Before, the idle cleanup stopped it after 30 minutes of silence. This trade is intended: Claude is still working on the message. Stop still works, and Claude going idle or exiting ends the hold. In every capture and in the 2.1.280 code, Claude repeats a message back before it can go idle, and a turn that fails without going idle happens only while background tasks run, which already keep the process running.
Alternatives considered:
queuedmessage at idle too. This would wrongly mark a message unconfirmed when it is about to start. In the 2.1.280 code, Claude reports idle first and checks its queue after. That order is read from the code, since no capture caught a message in that window.Differences from the common pattern
Linked Issue
No issue. Step 2 of the plan above, following #23553, #23026 and #23862.
Visual Proof
Live on a second Mac (the M4 Air) with the real Claude CLI 2.1.285, one run per arm. Before is current
main(26bb7c23f19, which includes #23862). After is this PR (5471ca443bf; case 2 again at8dd0f818ace). Both cases need Claude to go quiet, or to end a turn, at a precise moment, which the app can't trigger. So both arms carry the same two QA-only changes, never committed:ORCA_QA_IDLE_MSenvironment variable. This applies to the first case only.QAHOLD. Once Claude reports that message started, the filter holds back Claude's replies, and passes through only Claude's per-message reports and control frames.1. Claude stays quiet for 45 s while working on a message (as in a silent rate-limit retry).
The filter releases everything after 45 s.
Before: Claude's process ended during the silence, before the 45 s release, so the filter never delivered its held output. With the 20 s idle window, what ends it there is the idle cleanup, which counted only chat rows and saw none owed. The message ends "Message delivery is unconfirmed." and its answer never arrives.
After: Claude kept running while it held the message. When the output was released at 45 s, the answer landed.
2. Stop on a message Claude started but never repeated back.
The filter throws away Claude's repeat-back and replies. Claude still reports the message
cancelled.Before: 98 s after Claude reported the message cancelled, the chat still says Working, with no ending for the message.
After: the message reads "Message delivery is unconfirmed." with Retry, and the chat stops saying Working 251 ms after the Stop. It is not called withdrawn, because Claude had started on it. This case was re-run on the head after merging main (
8dd0f818ace), because that merge brought in #23861's Stop changes; the result was the same as on5471ca443bf.A steer sent while Claude was running tool calls still folded into the running turn on this PR, and Claude answered it in the same reply.
discarded,refused, and a turn that fails without an end report were not run live: the CLI can't be made to produce them. They are covered by the tests below.Testing
I manually tested these changes locally
Automated tests added/updated, or explained why not below
claude-command-lifecycle.test.ts:discardedandrefused, rewritten from the captures, since neither can be produced from the app. Beforestarted, they give not delivered and provider rejected. Claude 2.1.280 sends onlycancelledafter a start, so the tests with these afterstartedjust check that any end there is still unconfirmed, never withdrawn;cancelled.structured-agent-session-idle-sweep.test.ts: the cleanup stays off while a taken message is unconfirmed, and runs again once it ends.claude-structured-idle-releases-unknown.test.tsruns the real runtime, router and host. A started message that is then cancelled, or left behind when Claude goes idle, is recorded as unknown and recovered. It holds the process from the cleanup until then, and afterwards the chat stops reading Working.Every new behaviour test fails on fix(claude): a queued message Claude withdrew is settled from Claude's own cancelled event #23862's head. Each behaviour was also removed in turn, and its test failed by assertion. That makes 10 removals, including counting a queued message at idle and in the hold.
73 related test files pass: 691 tests.
tc:nodeandtc:cli, oxlint,check:code-quality:changed,check:react-doctor:changedand the anti-slop audit pass.AI Disclosure
Review
Two review loops.
queuedmessage at idle wrongly marked a message unconfirmed in the moment before it starts, so onlystartedcounts now.started-only. Four new tests fail without it.Agent skill upstream boundary
docs/reference/agent-skill-sharing-upstream-boundary.mdand copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.Notes
Known limits:
startedand thencancelled, and then its Stop reply lists the message as withdrawn. The chat briefly shows "unconfirmed", then "withdrawn". The final state is right.queuedand never starts still ends when the process exits.Same on macOS, Linux, Windows and SSH hosts: the host reads its own Claude process.
Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)Author: @BrennanKB5