Skip to content

fix(claude): a queued message Claude withdrew is settled from Claude's own cancelled event - #23862

Merged
brennanb2025 merged 3 commits into
mainfrom
brennanb2025/claude-lifecycle-settles-pending
Sep 29, 2026
Merged

brennanb2025 merged 3 commits into
mainfrom
brennanb2025/claude-lifecycle-settles-pending

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 6 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​611 0 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​611
Prod 6 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​67 $\color{#cf222e}{\Huge{\mathbf{−}}}$​14 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​53

ELI5

When you stopped a Claude chat that had a follow-up message queued, the follow-up could be left marked "sent" forever and the chat kept saying Working, if Orca missed Claude's reply to the Stop. Claude also announces, message by message, when it drops one. Orca now listens for that and marks the dropped follow-up withdrawn, so the chat settles.

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:

  • Step 1, this PR: Claude reports each message as queued, started, completed or cancelled. A message cancelled before it started is marked withdrawn. When Claude goes idle, a message whose outcome was already unknown is released.
  • Step 2, fix(claude): end a message Claude started but never confirmed, and keep Claude running while it holds one #23898 (stacked on this one): a message Claude started and then ended without confirming ends as unconfirmed; Claude's discarded and refused reports are read; and the idle cleanup no longer stops Claude while it is working on a message.

What Changed

Before

  • Orca learned that Claude withdrew a queued message only from Claude's reply to the Stop request, or from the one-at-a-time cancel answer.
  • If that reply was lost or timed out, or a one-at-a-time cancel failed or answered "not cancelled", the queued message stayed "sent, waiting" until the Claude process exited, and the chat read Working the whole time. In practice that meant until the idle cleanup stopped Claude about 30 minutes later.
  • A Claude message left with an unknown outcome while the Claude process was still running also had no way to be released until the process exited.

After

  • Claude reports each message's progress: queued, started, completed or cancelled, keyed by the id Orca sent. When Claude reports a message it never started as cancelled, Orca marks it withdrawn, with the same wording as when the Stop reply says so. This works whether or not the Stop reply arrives, and for the one-at-a-time cancel.
  • A message Claude had already started, or had already repeated back, is never marked withdrawn from this event. The interrupted reply's own message also ends "cancelled", and it stays delivered. So does a turn that failed because Claude was not logged in.
  • When Claude reports its session idle, a message whose outcome was unknown is released, the same way an idle Codex thread already releases one. A message that is still pending is never touched.

Mechanism

  • claude-replay-turn-resolution.ts routes each command_lifecycle frame to claude-command-lifecycle.ts. queued and started mark the matching send in memory. cancelled for a send that never reached started goes through the existing settleCancelledClaudeDispatchWaiters.
  • claude-structured-session-acquisition.ts: session_state_changed idle calls the runtime's existing idle release, which Codex already uses (structured-agent-session-runtime.ts).
  • Nothing new is saved, and nothing changes on the wire. How these frames display is unchanged.

Why

Claude's own per-message event is the provider's fact about what it dropped, so settling from it gives every such message a way to end without a timer and without guessing. The existing Stop-reply path already settles the same way, so both now write through one function.

Alternatives considered:

  • End the Claude session on every Stop. Any message not yet taken would then die with the process. But it would also end background tasks and subagents Orca deliberately keeps running across Stop, turn a provable withdrawal into doubt, and make the next message wait for a cold start.
  • A timeout on a pending message. A timer cannot tell "Claude is slow" from "Claude dropped it".

Differences from the common pattern

  • A common approach ends the Claude session on Stop. Messages not yet taken are then rejected on the client side, and the per-message events are ignored as noise.
  • Orca keeps the Claude process running across Stop, so it tracks each message after handing it over. It therefore reads the per-message events that approach discards: they are the only per-message fact once the session stays alive.

Linked Issue

No issue. Follow-up from #23553 and #23026: every pending Claude message should have a way to end.

Visual Proof

Live check on a second Mac with the real Claude CLI (2.1.283), at head 45212a99100, through the app's own Stop button.

Scenario Result
A reply running, a follow-up queued, then Stop (3 runs) the follow-up was withdrawn 21–29 ms after the Stop, with the same wording as before, and the chat left Working. In Claude's output its cancelled event came before Claude's reply to the Stop.
The same, with Claude's reply to the Stop held back for 1.5 s by the test rig (1 run) the follow-up was settled as withdrawn from its cancelled event alone, before the held reply arrived; the chat looked the same as in the other runs
A plain send, and a Stop mid-reply normal: the reply arrived; the Stop interrupted within about 10 ms

The run with Claude's reply held back: the reply stops and the follow-up's text is back in the message box:

Stop with Claude's reply held back: the story is cut off, the follow-up is withdrawn and its text is back

Not exercised live, covered by the captured-frame tests: the one-at-a-time cancel (the app does not send it), and releasing an unknown-outcome message on Claude's idle report (it cannot be produced from the app).

Testing

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

  • The frames were captured live from Claude Code 2.1.280, with account identifiers scrubbed, as __fixtures__/claude-lifecycle-capture-{interrupt-lost,cancel-async,batch-lead,auth-failed}.jsonl.

  • claude-command-lifecycle.test.ts (16 tests) replays them:

    • a Stop whose reply is recorded or lost: the follow-up is withdrawn once, and the stopped reply's message stays delivered;
    • a one-at-a-time cancel that times out, errors or answers "not cancelled": withdrawn;
    • cancelling the first of a batch: it is withdrawn and the next runs normally;
    • a failed-login turn stays delivered;
    • a started, un-echoed, cancelled message is left alone, including when a redelivery re-emits "queued" first;
    • events for unknown or already-settled messages do nothing.
  • claude-structured-idle-releases-unknown.test.ts (the real runtime and host): idle releases an unknown message, and a pending one is untouched.

  • 11 of the 14 new behaviour tests fail on main; the other three pin behaviour that already held. Each behaviour was also removed in turn, and its test failed by assertion.

  • pnpm tc:node and tc:cli, oxlint, check:code-quality:changed, check:react-doctor:changed and the anti-slop audit pass.

AI Disclosure

Review

One focused review loop: CLEAN. It read the 2.1.280 lifecycle code and found no way for a cancelled event to withdraw a message the model received. Two small fixes from its notes: a message's started mark now only moves forward (a redelivered command can re-emit "queued"), and the fixtures name the real msg_lifecycle_v1 capability.

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

Author: @BrennanKB5

…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`.
@greptile-apps

greptile-apps Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Medium risk] Fixes how queued messages are settled when the CLI withdraws them.

The PR appears safe to merge; no actionable new issue or outstanding previous finding was identified.

Summary

This PR settles Claude sends withdrawn before they start from per-command lifecycle events and releases unknown dispatches when Claude reports its session idle.

  • Since the previous review, it prevents a repeated queued event from erasing an observed started state and corrects the recorded fixture capability.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Claude command lifecycle] --> B{Command started?}
  B -->|No; cancelled| C[Settle send as withdrawn]
  B -->|Yes; cancelled| D[Do not infer withdrawal]
  E[Claude session idle] --> F[Release unknown dispatches]
  F --> G[Leave pending dispatches unchanged]
Loading

Reviews (2) · Last reviewed commit: "fix(claude): keep a command's started ma..."

@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

📝 Walkthrough

Walkthrough

Claude command-lifecycle frames now update dispatch waiter state and can settle a queued command when it is cancelled before starting. Claude session-idle notifications now trigger release of unanswered dispatches. Added replay captures and tests cover command cancellation, withdrawal, idle notifications, and recovery of unknown dispatches.

Priority: ⚪ Not assessed

Merge Risk: 🔵 Low · up to 45212

Repeated Claude idle notifications can add redundant recovery history, and retired-waiter cancellation still lacks replay coverage. The known effects are bounded, but should be addressed or accepted with owner awareness.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 45212

The change appears confined to an existing Claude session and improves recovery of cancelled or unanswered messages. Repeated idle notifications could cause redundant recovery writes, but no new external entrypoint or verified security vulnerability was identified.

Retained concerns

  • Low · reliability · inferred: Overlapping Claude idle notifications can scan the same unanswered dispatch before its first recovery write commits, producing redundant durable recovery rows. This extends an existing recovery path to Claude; the observed submission projection does not duplicate the submission.
Security review details

Security Blast Radius

  • inferred — The demonstrated effects are bounded to dispatch waiters of the acquired Claude provider session and recovery entries in the named logical session; no cross-session release is shown.

Trust Boundaries and Controls

  • observed — Provider-emitted lifecycle and idle frames cross into host dispatch state only after the provider-session check. Command settlement additionally requires an exact sent UUID and does not regress a started waiter on a later queued frame.

Resilience and Maintainability Implications

  • inferred — Concurrent idle callbacks can create redundant durable recovery writes because the unrecovered-submission scan is outside a serialized scan-and-write operation. The available reducer evidence indicates these rows collapse in the current submission projection, rather than creating another submission.

Hardening Proposals

  • proposed — Serialize or deduplicate each session’s recovery scan and journal writes, and verify repeated idle notifications and provider reacquisition through the host’s durable journal path.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 15 functions across 8 files. (4 skipped: 4… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the Claude lifecycle change that settles a queued message from Claude's own cancelled event.
Description check ✅ Passed The description is detailed and covers the user impact, implementation, rationale, alternatives, testing, visual proof, compatibility considerations, and review notes. It also references the related i…
Full details: Docstring Coverage

Explanation

Docstring coverage is 6.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 15 functions across 8 files. (4 skipped: 4 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No critical issues — one coverage suggestion inline.

Reviewed changes

  • Claude command_lifecycle settlement (new claude-command-lifecycle.ts): resolveClaudeReplayTurn routes each per-command frame to observeClaudeCommandLifecycle, which records queued/started on the in-memory waiter and settles a cancelled send that never reached started as withdrawn through the existing settleCancelledClaudeDispatchWaiters.
  • Idle release for doubted sends: session_state_changed idle now fires a new onSessionIdle dep, wired in the runtime to the same releaseUnansweredDispatches path Codex already uses; only unknown submissions are released, never pending.
  • Runtime refactor: the Codex inline release callback becomes the shared releaseUnansweredDispatches; structured-claude-runtime-adapter.ts threads onSessionIdle through.
  • Tests and fixtures: four scrubbed Claude 2.1.280 captures plus claude-command-lifecycle.test.ts (15 tests) and claude-structured-idle-releases-unknown.test.ts (real runtime/host) — 20/20 pass locally.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment on lines +20 to +22
const waiter = [...session.dispatchWaiters, ...session.retiredDispatchWaiters].find(
(candidate) => candidate.sentUuid === commandUuid
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The retiredDispatchWaiters half of this lookup is load-bearing but untested: a send whose write ended in doubt can still have been queued and later withdrawn by the CLI, and this branch is what upgrades that lingering unknown to a proven rejected. The four replay fixtures all dispatch successfully, so they exercise only the live-waiter path.

Technical details
# Untested retired-waiter settlement path

## Affected sites
- `src/main/claude/claude-command-lifecycle.ts:20` — `retiredDispatchWaiters` lookup; no test reaches it
- `src/main/claude/claude-command-lifecycle.test.ts` — every replayed dispatch resolves `{ state: 'admitted' }`, so waiters stay live

## Required outcome
A test in which `connection.send` throws after `beforeDispatch` (dispatch recorded `unknown`, waiter retired), the CLI then emits `command_lifecycle queued` and `command_lifecycle cancelled` for the same `command_uuid`, and the send settles as `rejected`/`cancelled` rather than staying `unknown`.

## Suggested approach
Reuse the `claude-command-lifecycle.test.ts` replay harness or the host harness in `claude-structured-idle-releases-unknown.test.ts`; make `send` throw (as the idle test does) and deliver the two captured lifecycle frames.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to GitHub limitations.

⚠️ Outside diff range comments (1)

🟡 Minor · Serialize idle recovery at the host boundary. · structured-agent-session-runtime.ts:218-230

src/main/runtime/structured-agent-session-runtime.ts:218-230
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Serialize idle recovery at the host boundary.

onSessionIdle can invoke the shared callback for multiple idle frames. The runtime does not await the host release. The host scans the unrecovered entries before JournalWriteQueue commits the deferred row. Two idle frames can therefore select the same entry. Each call inserts a new recovered dispatch row with a new sequence. The reducer keeps one current submission, but the journal retains duplicate recovery transitions and emits an extra commit.

Serialize the scan and writes in the host operation:

Suggested fix
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts
@@
 export async function releaseStructuredAgentSessionUnansweredDispatches(
-  context: Pick<StructuredAgentSessionMutationContext, 'sessions'> & {
+  context: Pick<StructuredAgentSessionMutationContext, 'serialize' | 'sessions'> & {
     deps: { store: Pick<StructuredAgentSessionHostDeps['store'], 'getRecord'> }
   },
   input: { sessionId: string; reason: string }
 ): Promise<void> {
-  const session = context.sessions.get(input.sessionId)
-  if (!session) {
-    return
-  }
-  const stranded = session.journal
-    .submissions()
-    .filter((entry) => entry.dispatchState === 'unknown' && entry.recovered !== true)
-  if (stranded.length === 0) {
-    return
-  }
-  for (const entry of stranded) {
-    await session.journal.resolveDispatch({
-      clientMessageId: entry.clientMessageId,
-      state: 'unknown',
-      // The earlier reason names a sharper fact than this one does.
-      reason: entry.reason ?? input.reason,
-      fence: structuredAgentSessionConversationFence(context.deps.store, input.sessionId),
-      recovered: true
+  return context.serialize(input.sessionId, async () => {
+    const session = context.sessions.get(input.sessionId)
+    if (!session) {
+      return
+    }
+    const stranded = session.journal
+      .submissions()
+      .filter((entry) => entry.dispatchState === 'unknown' && entry.recovered !== true)
+    if (stranded.length === 0) {
+      return
+    }
+    for (const entry of stranded) {
+      await session.journal.resolveDispatch({
+        clientMessageId: entry.clientMessageId,
+        state: 'unknown',
+        // The earlier reason names a sharper fact than this one does.
+        reason: entry.reason ?? input.reason,
+        fence: structuredAgentSessionConversationFence(context.deps.store, input.sessionId),
+        recovered: true
+      })
+    }
+  })
-    })
-  }
 }
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts
@@
   const context = {
     sessions: new Map([['s-1', { journal }]]),
+    serialize: async (_sessionId: string, task: () => Promise<unknown>) => task(),
     deps: { store: { getRecord: () => ({ lease: { runtimeFence: FENCE } }) } }
   } as unknown as ReleaseContext

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 08a55e9e-7fb3-4e0c-acbb-4338037406cc

📥 Commits

Reviewing files that changed from the base of the PR and between 4461244 and 45212a9.

📒 Files selected for processing (6)
  • src/main/claude/__fixtures__/claude-lifecycle-capture-auth-failed.jsonl
  • src/main/claude/__fixtures__/claude-lifecycle-capture-batch-lead.jsonl
  • src/main/claude/__fixtures__/claude-lifecycle-capture-cancel-async.jsonl
  • src/main/claude/__fixtures__/claude-lifecycle-capture-interrupt-lost.jsonl
  • src/main/claude/claude-command-lifecycle.test.ts
  • src/main/claude/claude-command-lifecycle.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 3 remain after this review.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No new issues in the new commits — the forward-only lifecycle guard and its test look correct. One prior-coverage thread is still outstanding, so this stays unapproved rather than routing an auto-fix.

Reviewed changes

  • Renamed the fixture capability token msg_scrubbed01_v1 → msg_lifecycle_v1, the token actually observed from Claude CLI 2.1.280; no production code reads either string.
  • Made observeClaudeCommandLifecycle's mark forward-only: a redelivered queued no longer downgrades a waiter already marked started, so a later cancelled cannot falsely settle a command the CLI had started.
  • Added an afterFrame hook to the replay harness and a test injecting a queued frame after started. I confirmed the test fails against the pre-guard logic (the send settles rejected), so it is not theatre.

The prior review's one open suggestion — direct coverage for the retiredDispatchWaiters branch — is unchanged by these commits, so that thread stays open.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Status

Ready. Head 45212a99100, based on main. CI green (33 checks).

The failure: a queued Claude follow-up that Claude withdrew at a Stop stayed "sent, waiting", and the chat read Working, whenever Orca missed Claude's reply to the Stop. Nothing else recorded the withdrawal.

The fix: Claude reports each message's progress by the id Orca sent. A message Claude cancels before starting it is now settled as withdrawn from that event, through the same function the Stop reply uses. A message Claude already started or repeated back is never withdrawn this way. Claude's idle report now releases a message whose outcome was unknown, the way Codex already does. Nothing new is saved, and there is no wire change.

Review: one focused loop, CLEAN. Two small fixes came from its notes: the started mark only moves forward, and the fixtures name the real capability.

Validation:

  • Built against live captures of Claude Code 2.1.280, scrubbed of account identifiers. 16 replay tests plus a real-runtime idle test; 11 of the 14 behaviour tests fail on main, and every ablation fails by assertion.
  • Live on a second Mac with the real CLI:
    • the follow-up was withdrawn 21–29 ms after Stop, with the same wording as before;
    • with Claude's reply to the Stop held back by the rig, it still settled, from Claude's own event;
    • plain sends and a mid-reply Stop are unchanged.
  • The one-at-a-time cancel and the idle release are covered by tests only, since the app cannot produce them.

Known, next PR: a message Claude started but never repeated back, then cancelled, still waits until the process exits.

@brennanb2025
brennanb2025 merged commit 18327d9 into main Sep 29, 2026
33 checks passed
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