Skip to content

fix(claude): end a Claude chat on its root's exit even when a descendant survived - #22946

Merged
brennanb2025 merged 8 commits into
mainfrom
brennanb2025/claude-root-exit-ends-session
Sep 26, 2026
Merged

brennanb2025 merged 8 commits into
mainfrom
brennanb2025/claude-root-exit-ends-session

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 4 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​126 $\color{#cf222e}{\Huge{\mathbf{−}}}$​143 $\color{#cf222e}{\Huge{\mathbf{−}}}$​17
Prod 9 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​28 $\color{#cf222e}{\Huge{\mathbf{−}}}$​22 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​6

ELI5

When Claude crashed or was stopped, Orca tried to make sure every helper process Claude had started was gone. If one helper refused to die (for example a tool server that ignores the polite "please exit" signal), Orca refused to consider the chat over. The chat then stayed stuck: messages failed, and it could not restart until you restarted Orca. Now Claude's own process exiting is what ends the chat. A helper that outlived it is logged, and the chat restarts normally.

What Changed

  • Before: Claude's own process exits, and one of its descendants is seen still running after Orca's close ladder. Orca then withheld the chat's ended event. The lease release refused, and every later start of that chat refused with "provider close unproven". For an open chat, sends failed with the crash message and nothing restarted Claude until the app restarted.
  • After: the same exit ends the session at once, exactly as fix(claude): let a Claude chat start again after its root exited with unverifiable descendants #22802 made an exit with unverifiable descendants do. The host settles the in-flight turn, releases the lease and restarts Claude on its saved session. A later stop, eviction or restart of that chat goes through.
  • Mechanism: claudeRootExitObserved (claude-structured-session-close.ts) is the one place that decides whether a first-hand root exit ends a Claude session. It used to require tree === 'unverifiable', which excluded tree === 'live'. It now depends only on the root's first-hand exit. Crash publication (observeClaudeSessionExit), close finalization (finalizeClaudePublishedSession), acquisition cleanup and the host's stopAgentSessionProviderRoot all read it, so they change together. There is no new state, timer or retry.
  • A root exit with a descendant still live logs [claude-stream-json] root exited but a descendant survived the close with the root pid. The verdict stays live: nothing reports that descendant as gone.
  • The recorded lease death evidence now reads "its descendants were not proven gone" rather than "were not verifiable", which would be false for a descendant seen alive. The string is a diagnostic and nothing parses it.

How a descendant stays live, reproduced with real processes on main: the root starts a helper that ignores SIGTERM, and the tree snapshot is taken in the same wall-clock second the helper was born. The root then crashes. The ladder sends SIGTERM but never SIGKILL, because ps start times have one-second resolution and it will not force-kill a pid it cannot tell apart from a reused one (hasUnambiguousStartIdentity). The retained snapshot's capture time never changes, and a dead root cannot be re-walked, so no later close can ever force it either. A process the user's account cannot signal, or one stuck in the kernel, ends the same way.

Why

The lease is keyed on the root process's pid and start time (#20502), and the root is the Claude CLI itself for the launches Orca resolves: the native installer, npm and Homebrew installs, and the version-manager shims that exec into the CLI. Once Orca has seen that process exit first-hand, a surviving descendant is not a writer of the conversation, so holding the chat for it only blocks the user. That was already the rule for descendants Orca could not see (#22802). This PR applies it to descendants Orca could see but not kill.

Where the root is a wrapper that forks rather than execs, the CLI is itself a descendant. That applies to a Volta shim, an unrecognised Windows .cmd run through cmd.exe, or a hand-written script. This PR does not open a new path to a second writer there. On a crash, such a wrapper waits for the CLI, so the root cannot exit first. On a stop, Orca closes the CLI's stdin and sends it SIGTERM, which the CLI exits on, so a live CLI after the ladder needs a CLI that ignores both. The same wrapper shape already reaches the unverifiable rule from #22802, which this PR does not change. Making the root always be the CLI is separate work.

How this differs from a plain stop-on-exit design: Orca still runs its descendant ladder (an identity-checked SIGTERM, then a SIGKILL only for processes it can identify unambiguously) and reports the verdict. It no longer waits on that verdict. It does not signal the whole process group.

Alternatives considered:

  • Re-running the close ladder on a schedule (for example at 5s, 30s and 120s) and giving up after that. For the case above, every retry fails the same way, so it only delays recovery by about two and a half minutes, and it adds a scheduler, timers and a shutdown pass.
  • Letting the ladder SIGKILL a same-second descendant once a later process-table read confirms its identity. That removes one cause, but a process that truly cannot be killed would still wedge the chat until restart. It also changes shared PTY kill code.

Deliberate behaviour change: main pinned the old rule in four tests. The rule was "a live descendant still holds the provider session: releasing would hand a second writer to it". Those tests are now parameterized over unverifiable and live and assert the new behaviour: a surviving descendant no longer holds the chat, and it is logged as live. These are not weakened tests: the lease follows the root, so the surviving descendant no longer holds it.

Out of scope: a root Orca did NOT see exit (an unproven stop) still fails closed everywhere, unchanged.

Linked Issue

No separate issue. This is the remaining piece of #22641, which is closed as superseded by #22802. #22802 fixed the unverifiable-tree case. This PR covers a descendant seen alive, which #22802 intentionally left failing closed.

Visual Proof

Live Electron QA on macOS: an isolated background dev instance with CDP screenshots and a real Claude Code CLI. A stand-in claude launcher started a helper that ignores SIGTERM in the same second the tree snapshot is taken, then exec'd the real CLI. The Claude root was then killed with kill -9 after its first answer. The BEFORE arm is this branch with only claude-structured-session-close.ts swapped back to main, and the built predicate was checked in out/main/index.js.

Before (main's rule): crash, then send After (this PR): crash, then send
before after
  • Before: the helper survived (reparented to pid 1) and no new Claude process started. The second message stayed on "Working for 1m 46s" with "Message delivery is unconfirmed" and never answered.
  • After: the helper also survived, and the log shows [claude-stream-json] root exited but a descendant survived the close. Sending the next message started a new Claude root with --resume=<same session>, which answered. The history reads ONE then TWO, with no error row, no duplicate and no stuck turn.
  • Regression check (After, no crash): a normal chat answered two messages in a row on one Claude process.
After: right after the crash, and the no-crash regression chat

after-crash
regression

Testing

  • Automated tests added/updated
  • I manually tested these changes locally (live Electron QA on macOS, see Visual Proof)

Automated tests, each run over tree unverifiable and live:

  • claude-structured-session-integration.test.ts, production-wired host:
    • an open chat whose Claude crashes with a message in flight settles that message, restarts Claude on the saved session, and accepts the next send;
    • a chat stopped by the idle clock reopens;
    • a create whose CLI self-exited releases the reservation with its diagnostic intact.
  • claude-structured-session-adapter.test.ts: a failed start and a post-publish cleanup classify the exit as a root exit, not as unproven.

Ablation: with only the predicate reverted, all five live cases fail. The crash-restart case fails exactly the user-visible way: no restart, and the in-flight message never settles. The unverifiable cases stay green when run on their own (-t unverifiable). In a whole-file run, a failed live case leaves the shared test host unable to shut down, so later cases in the integration file fail during teardown as well.

The real-process reachability check above was a scratch test, not committed: its outcome depends on wall-clock second boundaries and on process-table reads that time out under load.

pnpm tc:node, oxlint on the touched paths and check:code-quality:changed are clean. The Claude adapter, close, startup, connection, root-exit and settlement suites pass.

AI Disclosure

Review

Please challenge the claim the change rests on: that nothing but Claude's own process writes the conversation, so a surviving descendant never makes a restart unsafe.

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

  • SSH/remote/WSL: structured Claude runs on the local host only. The verdict vocabulary stays live / unverifiable / exited. Loss of contact is still never treated as death: only a root exit Orca saw first-hand ends the session.
  • Windows: the same predicate applies to the Windows tree verdicts. There is no platform-specific code. When the root exits on its own, the existing Windows ladder only verifies the snapshot and does not kill it, so live is likelier there. This PR turns those cases from a stuck chat into a logged leftover process. Killing that snapshot is separate work in the shared kill code.
  • Mobile / wire: no RPC, stream or persisted-shape change. Only the free-text death evidence detail changes.
  • Codex: it does not produce this verdict, so it is unaffected.
  • Performance: none. This removes repeated close-ladder runs against a wedged chat.

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)
  • Typecheck, oxlint on touched paths, the changed-code gate and the affected suites pass locally

…ant survived

When Claude's own process exited but a descendant it started survived the close ladder (for example an MCP server that ignores SIGTERM and was born in the second the tree was snapshotted, which the ladder never force-kills), the adapter withheld `ended`, and both the lease release and every later start refused with "provider close unproven". The chat stayed stuck until Orca restarted.

The root is the conversation's only writer and the lease follows it, so a first-hand root exit now ends the session whatever the tree verdict. `claudeRootExitObserved` is the one place that decides it, and crash publication, close finalization and acquisition all read it. A descendant seen alive is logged, and never reported as gone.
@coderabbitai

coderabbitai Bot commented Sep 25, 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

An observed Claude root exit now counts as observed regardless of the descendant-tree verdict. When exit proof fails and the root has exited while a descendant remains live, close() logs a warning with the process PID. Acquisition and session lifecycle tests now cover both live and unverifiable descendant-tree verdicts.

Priority: ⬇️ Low

Merge Risk: 🟡 Moderate · up to 08138

A Claude root can exit while a descendant continues workspace work, yet the lease is released and a replacement may start alongside it. Keep known-live descendants in recovery before merging.

Architecture Summary

Architecture risk: 🔵 Low · up to 08138

The change affects 1 system.

Changed systems: src

Architecture concerns
No architecture-level concerns identified.

Review details

Systems and components

  • observed — src (service) was modified; 13 changed files map to changed impact.

Before / after behavior

  • observed — Modified behavior in src/main/claude/claude-agent-sdk-exit-proof.ts: The comment now says a failed later table read cannot erase a descendant previously observed alive, replacing the lease-gate wording.
  • observed — Modified behavior in src/main/claude/claude-agent-sdk-exit-proof.ts: The comment now identifies exited as the only verdict that proves a close, replacing the claim that it lets a close release the lease; it retains the statement that live cannot later become “unknown.”
  • observed — Modified behavior in src/main/claude/claude-stream-json-connection.ts: When exit proof fails and the root exited while the descendant tree is still live, close() now logs a warning with the process PID. No warning is added for other failed-proof outcomes.
  • observed — Modified behavior in src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts: The error documentation now describes an observed provider-root exit without proving descendants gone, including when a descendant was observed alive; it removes the prior caveat that such a case must remain unproven.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 12 functions across 13 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main behavior change: ending a Claude chat when its root exits even if a descendant survives.
Description check ✅ Passed The description is complete and detailed. It covers the user impact, mechanism, rationale, alternatives, testing, visual proof, platform considerations, and checklist items. The Linked Issue section d…
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.
  • 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 new issues found.

Reviewed changes

  • Predicate widened — claudeRootExitObserved (claude-structured-session-close.ts:24) now keys only on a first-hand root exit, so a session is released and restarted even when the descendant-tree verdict is live.
  • Survivor logged — openClaudeStreamJsonConnection.close (claude-stream-json-connection.ts:317) warns with the root pid when the root exited but a descendant survived the close ladder; the latched verdict stays live.
  • Diagnostic wording — lease death evidence now reads "its descendants were not proven gone", with matching updates to the error-class, predicate, tree-verdict, and stopAgentSessionProviderRoot comments.
  • Tests parameterized — the five acquisition/close/recovery cases now run over unverifiable and live and assert release plus restart for both.

ℹ️ The sole-writer premise is unverified for workflow-runtime agents

The change rests on the root being the conversation's only writer, which the author asked reviewers to challenge. I found no counterexample for the descendants Claude Code can leave running: subagents are in-process (their transcripts are separate files, and the main JSONL's sidechain rows are written by the root), agent-team teammates are the only separate Claude Code process and are not spawned in the -p/Agent-SDK mode Orca uses, and MCP servers, background bash tasks, and hooks communicate with the CLI over stdio rather than appending to the transcript. No action is required; the one residual is noted below.

Technical details
# Sole-writer premise for a surviving descendant

## What was checked
- Subagents (`claude-subagent-roster.ts`, `claude-child-work-evidence.ts`) are in-process; the root writes the sidechain rows in the main transcript (`claude-transcript-branch-graph.ts`).
- Teammates (a separate Claude Code process) require interactive mode; the Agent SDK / `query()` path in `claude-stream-json-connection.ts` does not spawn them.
- MCP servers, background bash tasks (`claude-background-task-*.ts`), and hooks speak stdio to the CLI; the root turns that into transcript rows.

## Residual (no action required)
- Workflow-runtime agents may be OS processes and are relaunch-guarded per session, but nothing documents those agents appending to the provider JSONL. If a future CLI release makes one a transcript writer, this predicate would need the tree verdict back.
- A surviving background bash task can still mutate the working tree while the resumed root edits — the same tradeoff #22802 accepted for `unverifiable` descendants, now extended to ones Orca can see but not kill.

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

@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)

🟠 Major · Do not release the lease while the descendant tree is… · claude-structured-session-close.ts:22-27

src/main/claude/claude-structured-session-close.ts:22-27
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not release the lease while the descendant tree is live.

When exitVerdict.root is exited and exitVerdict.tree is live, this predicate returns true. The acquisition path then settles the old session and starts a replacement in the same workspace. Supported Bash tool processes can outlive their parent turn, and no inspected cancellation or workspace-isolation guard stops a surviving process from mutating that workspace. Exclude the live verdict at this shared boundary.

Suggested fix
-  return connection?.exitVerdict.root === 'exited'
+  const verdict = connection?.exitVerdict
+  return verdict?.root === 'exited' && verdict.tree !== 'live'

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 7707bb7a-49f2-43c9-a727-334deebd72a3

📥 Commits

Reviewing files that changed from the base of the PR and between f17b69b and 68f919a.

📒 Files selected for processing (6)
  • src/main/claude/claude-agent-sdk-exit-proof.test.ts
  • src/main/claude/claude-structured-session-adapter.test.ts
  • src/main/claude/claude-structured-session-close.ts
  • src/main/claude/claude-structured-session-startup.test.ts
  • src/main/runtime/agent-session-acquisition-failure-settlement.ts
  • src/main/runtime/claude-structured-session-integration.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/main/runtime/agent-session-acquisition-failure-settlement.ts
  • src/main/claude/claude-structured-session-close.ts
  • src/main/claude/claude-structured-session-adapter.test.ts
  • src/main/runtime/claude-structured-session-integration.test.ts

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

@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)

🟠 Major · Keep a live descendant in recovery. · claude-structured-session-close.ts:22-27

src/main/claude/claude-structured-session-close.ts:22-27
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep a live descendant in recovery.

When exitVerdict is { root: 'exited', tree: 'live' }, claudeRootExitObserved returns true. The attach-failure path maps that result to root-exit-observed, and settlement clears the owner and marks the lease released. A later attach can then start a replacement while the descendant remains live after bounded cleanup. The runtime fence updates lease metadata only; it does not fence the surviving process or its workspace writes.

Suggested fix
 export function claudeRootExitObserved(
   connection: ClaudeStreamJsonConnection | null | undefined
 ): boolean {
-  return connection?.exitVerdict.root === 'exited'
+  const verdict = connection?.exitVerdict
+  return verdict?.root === 'exited' && verdict.tree === 'unverifiable'
 }

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: d9bb59c5-34e4-425e-ada1-213b6cb00f4b

📥 Commits

Reviewing files that changed from the base of the PR and between 4e39b2e and 081386a.

📒 Files selected for processing (1)
  • src/main/claude/claude-structured-acquisition-release.ts

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

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status: ready to merge (head 081386a, CI green: 21 passed, 11 skipped)

What this fixes. When Claude's own process exited but a helper it started survived Orca's cleanup, the chat got stuck until Orca restarted. An example is an MCP server that ignores SIGTERM and was started in the same second the process tree was recorded. Messages hung on "Working" with "Message delivery is unconfirmed". A first-hand exit of the Claude root now ends the session, whatever happened to its helpers. The chat restarts on its saved session, and a surviving helper is logged as live and never reported as gone. The whole behaviour change is one predicate, claudeRootExitObserved, and every path that reads the exit verdict goes through it.

Review loop (independent reviewers, each told to fix only meaningful issues):

  • Loops 1-5 found no code or test defect. Each fixed out-of-date comment or PR-body wording that still said a surviving helper holds the chat, or that the tree proof gates recovery. Most of that wording predates this PR.
  • Loop 6: clean.
  • Because the loops kept finding the same kind of wording, a separate architecture challenge was run. Verdict: keep. The PR removes a gate that nothing could ever re-check; it does not add a guard beside it. The weak spot is process containment (below), not the ownership rule.
  • Readiness checklist at the start and at the end: pass, nothing blocking.

Live QA (macOS, isolated background dev instance, real Claude CLI; screenshots in the PR body):

  • Before, with main's rule: after the crash, the helper survived, no Claude restarted, and the next message never answered.
  • After: the helper survived, a new Claude resumed the same session and answered, and history stayed intact with no error row.
  • A normal chat with no crash was unaffected.

Scope held: one predicate, a diagnostic log line, comment and doc corrections, and tests parameterized over unverifiable and live. A root Orca did not see exit still fails closed. No wire, RPC or persisted-shape change.

Known, disclosed, not caused by this PR:

  • If Claude is launched through a wrapper that forks instead of execing (a Volta shim, or an unrecognised Windows .cmd), the real CLI is a child of the watched process. This PR does not open a new path to a second writer there. On a crash, such a wrapper waits for the CLI. On a stop, the CLI gets EOF and SIGTERM. The same shape already reached the unverifiable rule from fix(claude): let a Claude chat start again after its root exited with unverifiable descendants #22802.
  • A helper that survives is now left running and logged, not reaped later. Before, the same helper stayed alive and the chat was also stuck.

Suggested follow-ups (out of scope here):

  1. Run the Claude child in its own process group and kill the group after the SIGTERM grace, as the Codex provider already does.
  2. On Windows, kill identified descendants after the root exits. Today it only verifies them.
  3. Have close() return "root settled" plus a reported tree verdict, instead of false turned into an error and back.
  4. Keep a re-derived snapshot row's latest capture time in mergeRowsByPid.
  5. Resolve forking wrapper shims, mainly Windows .cmd, to their target.

Merge order: independent of the other structured-chat PRs, based on main. It should land with or before #22835.

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