Skip to content

fix(claude): run structured Claude under the POSIX provider supervisor - #23476

Merged
brennanb2025 merged 14 commits into
mainfrom
brennanb2025/claude-under-supervisor
Sep 29, 2026
Merged

brennanb2025 merged 14 commits into
mainfrom
brennanb2025/claude-under-supervisor

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 7 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​803 $\color{#cf222e}{\Huge{\mathbf{−}}}$​36 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​767
Prod 6 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​74 $\color{#cf222e}{\Huge{\mathbf{−}}}$​21 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​53

Follows #23466 (the provider supervisor hardening), which is now merged; this PR is based on main and uses its final timings: the provider gets 1 s after end-of-input, then 3 s after SIGTERM, and a supervisor takes at most 5.5 s to stop.

ELI5

When Orca crashed or quit with a Claude chat mid-task, the Claude process didn't stop. It saw its input close, took that as "no more messages", and finished the task on its own: running commands and editing files for up to minutes with nobody watching. Closing a chat mid-task did the same thing for a second and a half before Orca forcibly killed it. Now Claude runs under the same small watchdog process Codex already uses on macOS and Linux. When Orca goes away or closes the chat, the watchdog tells Claude to stop, and Claude stops its own running commands and exits, usually in under half a second.

What Changed

Before (macOS/Linux):

  • Orca spawned the Claude CLI directly, in Orca's own process group. If Orca died, Claude got end-of-input, finished the current turn, and then exited. Measured on Claude 2.1.283: about 7-9 s mid-reply, and about 120 s mid-command (the Bash tool's own timeout). Its tools kept running and writing the whole time.
  • A normal close ended Claude's input. A mid-turn Claude kept working until Orca's close sequence (end input, wait, then force-kill) SIGKILLed it after 1.5 s. A SIGKILL never lets Claude stop its own tool processes, and they each run in their own process group.

After (macOS/Linux):

  • Claude is spawned through the provider supervisor (createProviderSpawnSpec, the same inline supervisor the Codex app-server uses). Each chat has an owner record that names the one agent process allowed to write to its conversation; for Claude it now records the supervisor's pid, exactly as it already does for Codex.
  • A close ends stdin and immediately sends SIGTERM through the supervisor's Node handle. The supervisor SIGTERMs Claude's group; Claude kills its own tool processes (measured: within ~10-30 ms) and exits (0.25-0.4 s on the real CLI). If Claude ignores SIGTERM, the supervisor SIGKILLs it 3 s later, and it exits only once Claude's group is gone.
  • The close sends that SIGTERM only when the spawn built a supervisor: createProviderSpawnSpec returns supervised, and Claude's close reads it instead of repeating the platform check, so the stop cannot disagree with the process actually started.
  • Orca's wait before force-killing is derived from the supervisor's exported bound (PROVIDER_SUPERVISOR_MAX_STOP_MS, 5.5 s), plus 0.5 s, so Orca never force-kills the supervisor mid-stop and orphans Claude. A Claude that ignores SIGTERM can therefore take up to about 6 s to close, up from 4.5 s before fix(codex): the provider supervisor outlives its provider group when stopped #23466's timings.
  • Quit's time limit for stopping chats is now derived instead of hand-picked. It is the event flush before the stop (1 s), plus the longest supervised provider close (Claude's 6 s wait; Codex's is 5.5 s), plus a named 1 s margin. That still comes to 8 s today, and if a provider's close grows the limit grows with it. A normal quit is unchanged: the step ends as soon as every chat has closed, and only a provider that ignores the stop waits the full limit.
  • Deliberately outside that limit: the fallback that force-kills a close's process tree when the supervisor itself has not exited, which can add up to about 7.5 s more for Claude (process-table reads, the kill check, and a final wait). Once Orca's main process exits, the supervisor stops its provider's group by itself, now tested for a clean quit as well as a crash, and the next launch's recovery releases the chat's lease. Waiting for that fallback at quit would only squeeze the steps after it (saving resume markers, flushing events) under quit's overall 20 s deadline.
  • This was already true on main before fix(codex): the provider supervisor outlives its provider group when stopped #23466: a stuck Codex close, counting that fallback, could take about 11.5 s against the same 8 s limit. This PR states it rather than changing it.
  • If Orca's main process dies, the supervisor notices (parent-death watch plus stdin end) and runs the same SIGTERM-first stop, so Claude and its running command are gone within the supervisor's bound instead of finishing the turn.
  • A missing Claude binary: the supervisor now writes the spawn error to stderr before exiting 127, so the chat's error still says the file was not found (ENOENT). Before, Orca got it directly as a spawn error with no process ID.

Unchanged:

  • Windows: no supervisor, direct spawn, and ending stdin stays the only gentle step before the force-kill (a SIGTERM there is TerminateProcess, an immediate kill).
  • Processes that deliberately detach out of Claude's tree (for example setsid or a double-fork) are not signalled, same as today. A test pins this.

One visible difference in the chat: when a chat is closed mid-command, the running command's row now reads failed with "Exit code 137". That is the result Claude itself reports for the tool it killed on SIGTERM. Before, the row was left as running. The turn still reads interrupted, exactly as before, and nothing is reported as a crash. Recorded frames from the real CLI after the SIGTERM: tool_result is_error "Exit code 137", system/status, and no result frame.

Why

A Claude that loses its input finishes its turn, so after an Orca crash its tools kept editing the workspace unwatched. The watcher is the one process that outlives Orca and still owns Claude, and SIGTERM is the only signal that lets Claude stop its own tool processes.

The goal is to close the window in which a Claude that Orca no longer watches keeps writing to the user's workspace and conversation. Otherwise recovery could also start a second Claude on the same conversation while the first is still writing to it.

  • Why a supervisor, not a sweep of Claude's child processes from Orca: Orca can't sweep anything after it has crashed; the supervisor is still running then.
  • Why SIGTERM and not a process-group kill: Claude's Bash tools each lead their own process group, so a group kill of Claude never reaches them. Only Claude's own SIGTERM handler stops them, and SIGTERM also lets Claude flush its transcript.
  • Why stop immediately on close instead of giving end-of-input a grace: end-of-input means "finish the turn", and that is exactly what kept tools running.
  • Transcript safety, checked against the real CLI: after a SIGTERM mid-command, SIGTERM through the supervisor, and a close while idle, every transcript line is newline-terminated and parses, and --resume continues the conversation.

Alternatives rejected:

  • Keep stdin-end-then-SIGKILL: it leaves tools running and drops buffered transcript rows.
  • A periodic tree re-walk inside the supervisor to also kill detached daemons: that is subprocess churn, and a user's nohup dev server should survive an Orca crash.
  • Filtering the SIGTERM tool result out of the journal: that hides what Claude actually reported.

Differences from the common pattern

Supervisor mechanism: forward the stop signal to the child's process group, SIGKILL after a grace, and exit only after the group is gone. That matches #23466's supervisor, which this PR reuses unchanged apart from the stderr line on spawn failure.

How a stop reaches Claude: the common pattern for stopping a Claude SDK child is to close stdin, then SIGTERM after about 2 s, then SIGKILL after 5 more.

  • This PR deviates in timing: it SIGTERMs at once. The 2 s end-of-input window is where a mid-turn Claude keeps running tools, which is the behaviour being removed.
  • The common pattern has no process between the owner and the Claude child, so an owner crash leaves Claude finishing its turn. This PR deviates by putting the supervisor there (POSIX only).

Turn outcome of a requested stop: the turn reads interrupted on end-of-session in both. It matches.

Linked Issue

None. This is a maintainer follow-up to #23466; it closes the window in which an orphaned Claude keeps writing, for Claude structured chats on macOS and Linux.

Visual Proof

Full-app QA on macOS (an M4 MacBook Air) with the real Claude CLI, comparing the base (#23466 head 3b374050c6, where Claude is started directly) against this PR (297ea48cd0). That QA predates #23466's final timings; they apply only when Claude ignores SIGTERM, which no scenario below did. Every scenario started from a fresh, isolated profile; the build under test was confirmed by a marker string in the app bundle each time.

Scenario Base This PR
Orca force-quit (SIGKILL) while Claude runs sleep 120 Claude kept running unwatched: its command finished at +113 s and Claude exited at +116 s Claude's command was gone at +81 ms and Claude at +205 ms
Same, with a detached nohup sleep 300 started first The detached process survives, as intended The detached process survives, as intended; Claude and its command were gone at +166 ms / +54 ms
Close the chat tab mid-command Claude gone about 2.0 s after the click (a 1.5 s wait, then a force-kill) Gone about 0.55 s after the click
Quit Orca with one idle and one mid-command Claude The mid-command Claude was force-killed at +1.6 s; Orca exited at +1.8 s Stopped at +0.2 s; Orca exited at +0.44 s
Regression: Claude and Codex chats reply, quit, relaunch, reply again not run Both replied before and after the restart; no renderer or main-process errors

After the force-quit, the chat relaunches and answers the next message on both builds. The cut-off turn reads "Orca couldn't confirm" on both, because this stack predates #23456, which changes that.

A chat closed mid-command and reopened from Agent Session History keeps its conversation: the follow-up question was answered with the codeword from before the close, on both builds. The stopped command's row reads "1 failed".

After the force-quit and relaunch (base, then this PR):

Base: chat after relaunch PR: chat after relaunch

This PR, the next message after the relaunch is answered:

PR: next send answered after relaunch

Closed mid-command, reopened, context kept (base, then this PR):

Base: reopened chat Base: follow-up answered PR: reopened chat PR: follow-up answered

Testing

Quit's time limit (structured-agent-session-host-teardown.test.ts):

  • The limit equals the event flush plus the longer of Claude's and Codex's supervised close plus the margin, and it covers each provider's close, including the shorter Windows waits. The limit plus the 2 s resume-marker step stays under quit's 20 s deadline.
  • The eviction step ends as soon as every chat has closed, without waiting on its timer.
  • Ablations:
    • a hand-picked 8 s with Claude's close grown by 1 s: red (8000, expected 9000). The derived limit with the same growth stays green.
    • a margin that pushes the limit plus the marker step to 20 s: red.
    • a step that always waits out its limit: red.

Clean quit (codex-app-server-posix-supervisor.integration.test.ts): an owner that exits normally (exit code 0, not a kill) leaves the supervisor to stop the provider's group, including a child that ignores SIGTERM, and then exit. Ablation: with both the parent-death watch and the end-of-input trigger disabled, the group survives (red); with only the watch disabled, end-of-input alone still stops it.

Real-process tests (bounded; cleanup only by recorded pid):

  • claude-supervised-stop.integration.test.ts uses a fake Claude that ignores end-of-input like a mid-turn Claude, runs a tool in its own process group, and double-forks a daemon. It pins four behaviours:
    • a close stops it through SIGTERM in well under the supervisor's grace, its tool is stopped, and the supervisor exits by SIGTERM;
    • a SIGTERM-ignoring Claude is escalated by the supervisor itself, not by Orca force-killing the supervisor;
    • SIGKILL of an Orca stand-in stops Claude and its tool;
    • the double-forked daemon survives.
  • Ablations, each red by deleting the core line:
    • no SIGTERM step in the close sequence: 3 red;
    • Claude spawned directly instead of supervised: 5 red;
    • the supervisor's group SIGTERM removed: the tool survives parent death;
    • the supervisor's stderr write removed: the ENOENT is lost.

Unit tests:

  • claude-child-exit-proof-ladder.test.ts: a supervised child gets SIGTERM, and the close sequence waits out the supervisor's bound before forcing. Ablation: the fixed 1.5 s wait turns it red. An unsupervised (Windows) child is never sent SIGTERM.
  • claude-structured-requested-stop.test.ts: adapter close with the frames recorded from the real CLI. The turn reads interrupted, and ended carries no unexpected-exit cause. Ablation: the ended sweep removed turns it red.
  • Spawn shape per platform, and the missing-binary report.

Opt-in real CLI: claude-supervised-stop-real-cli.test.ts, gated by ORCA_REAL_CLAUDE_SUPERVISED_STOP=1 plus ORCA_REAL_CLAUDE_BIN. Run on macOS (M4Air) with Claude 2.1.283, haiku, an isolated HOME and an orca shim that exits 97.

  • Stop times: close mid-command 330 ms, SIGTERM to the supervisor mid-command 252 ms, close while idle 383 ms.
  • In all three, Claude and its tool were gone, the transcript was line-atomic, and --resume answered success.
  • In two earlier runs of the SIGTERM case the model never started the command within 90 s. That is a model-compliance flake before any stop, and the third run passed.

Suites and checks: src/main/claude, src/main/codex, src/main/native-chat and src/main/runtime passed locally before main was merged: 13914 tests, excluding the two known local real-binary suites. On the merged head this PR's test files plus the supervisor, connection, teardown, eviction and journal-close tests pass (12 files, 105 tests). pnpm tc:node and pnpm tc:web pass. oxlint on the changed files, check:code-quality:changed and audit:anti-slop are clean.

Platforms:

  • macOS only.

  • Linux QA is BLOCKED: the Linux QA host's Orca runtime was unreachable. Nothing was substituted.

  • Windows was not run; it is unchanged by construction and pinned by a unit test.

  • Full-app QA on macOS, one run per scenario: see Visual Proof.

  • I manually tested these changes locally

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

AI Disclosure

Written with AI assistance (Claude), and reviewed and validated by the author.

Review

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

Known gap: Windows keeps the window in which an orphaned Claude keeps writing. It has no supervisor, and a Claude orphaned there finishes its turn as before.

Detached descendants: unchanged. Anything that leaves Claude's tree on purpose is not stopped.

Mixed versions:

  • A build older than fix(codex): the provider supervisor outlives its provider group when stopped #23466 that recovers an owner record this build wrote finds the supervisor's pid, matched by pid, start time and spawn tag (nothing compares the command line). It SIGTERMs the supervisor, which stops Claude, and then SIGKILLs it after about 1 s. A Claude slower than that to stop (for example a slow SessionEnd hook of the user's) is orphaned the way it is today. This is the same exposure Codex has.
  • A newer build recovering an owner record an old build wrote signals Claude directly, as before.
  • No persisted shape change and no wire change.

Residuals:

  • If Orca's force-kill step is reached with no process-table snapshot at all (the table was unreadable), it can kill only the supervisor, and Claude, in its own group, survives. The close verdict is then unverifiable, never exited, so the owner record is not released on it.
  • If the supervisor's final wait for Claude's group fails (a process in uninterruptible sleep), it exits without proving Claude gone.

Cross-platform, SSH, folder workspaces: the spawn runs on whichever host executes the chat (local or a remote Orca server), with that host's process.execPath; this is the same path the Codex supervisor already takes there. Folder workspaces are unaffected.

Author X handle: @BrennanKB5

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)

…stopped

A signalled supervisor forwards the signal to the provider group, escalates to
SIGKILL after the grace, and exits only once the group is gone, so recovery's
proof that the recorded pid is dead also proves the provider is. It refuses to
spawn when its parent is already not the owner named in its spec, and watches
that owner rather than whichever parent it first saw. The grace is a spec
field. Recovery's SIGTERM stage now outlasts the supervisor's own stop, since a
SIGKILL that lands first cannot be handled and leaves the group running.
…its provider group

When Orca dies, the supervisor's stdout pipe has no reader. Provider output in the
window before the parent-death watch fired raised an unhandled EPIPE that exited the
supervisor with the provider group still running.
…ways covers it

Recovery sized its SIGTERM stage from the default grace, so a launch with a
longer grace would be SIGKILLed mid-stop and orphan its group with no test
noticing. The spec now refuses any grace above one exported maximum, and
recovery derives its SIGTERM stage from that maximum.
…ervisor' into brennanb2025/claude-under-supervisor
Owner death, stdin end after the grace, and a signal to the supervisor now all
take one path: SIGTERM the provider group, SIGKILL it after the grace, and exit
only once it is gone. The signal handlers are registered before the provider
is spawned, so a stop that lands in the spawn window still reaps it. The
longest stop grows to two graces plus the reap wait, and both recovery's
SIGTERM stage and the connection's graceful close now wait that long before
forcing, since forcing the supervisor sooner can orphan its group.
… supervisor

A close now stops Claude with a SIGTERM through the supervisor instead of letting
stdin end finish the turn, and Orca's death stops it through the supervisor.
…ervisor' into brennanb2025/claude-under-supervisor
@brennanb2025
brennanb2025 marked this pull request as ready for review September 28, 2026 04:11
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Status: ready for review. Stacked on #23466; merge that first.

Review loops: clean. Automated tests cover the close, a Claude that ignores SIGTERM, an Orca force-quit, and a detached daemon surviving; each test fails when its core line is removed.

Full-app QA (macOS, real Claude CLI): all five scenarios pass against the base; details and screenshots are in Visual Proof.

  • After an Orca force-quit mid-command, Claude and its command stop in about 0.2 s, instead of running on unwatched for about 2 minutes.
  • Closing a chat mid-command stops it in about 0.55 s instead of about 2.0 s, and quitting Orca finishes in 0.44 s instead of 1.8 s.
  • A detached background process the user started survives, as intended.
  • Claude and Codex chats reply before and after a restart, with no errors.

Not covered: Linux QA is blocked (the Linux QA host was unreachable). Windows is unchanged by construction and pinned by a unit test. There was one run per scenario.

Known and not from this PR: after a force-quit the cut-off turn reads "Orca couldn't confirm" on both builds; #23456 changes that.

@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 informational note on a downstream surface change worth confirming.

Reviewed changes

  • POSIX supervision: structured Claude on macOS/Linux now spawns through createProviderSpawnSpec, so the supervisor's pid becomes the owned root and the exit ladder learns it is supervised; Windows keeps a direct spawn.
  • Immediate supervised stop: the close ladder ends stdin then SIGTERMs the supervisor at once and waits PROVIDER_SUPERVISOR_MAX_STOP_MS + 500 before forcing, so closing a mid-turn chat stops Claude's own tools rather than letting end-of-input finish the turn.
  • Missing-binary diagnostic: the shared supervisor now writes the child spawn error to stderr before exiting 127, so a missing Claude binary still reports ENOENT.
  • Tests: per-platform spawn shape, a timing-bound ladder test, a requested-stop turn-outcome test from recorded frames, an opt-in real-CLI stop suite, and real-process integration tests covering SIGTERM reap, supervisor escalation, owner-death reap, and detached-daemon survival.

ℹ️ The missing-CLI failure now reads differently on POSIX

On Windows a missing Claude binary still yields processless / AgentSessionPreSpawnError, but on macOS/Linux the supervised path makes it a first-hand root exit: the acquisition now throws AgentSessionAcquisitionRootExitObservedError (exitProof root-exit-observed) instead of AgentSessionPreSpawnError (processless). Lease release, owner verdict, and retry admission are equivalent, but the first-attempt surface changes (a raw thrown error becomes a structured refusal) and the durable deathEvidence.kind flips pid-absent → exit-observed. None of this is mentioned in the PR body and the chain is only pinned at the connection level, not at the adapter.

Technical details
# Missing-binary classification change

## Affected sites
- `src/main/claude/claude-agent-sdk-process-spawn.ts` — POSIX now spawns the supervisor, so `spawner.pid` exists even when Claude never starts.
- `src/main/claude/claude-stream-json-connection.ts:315` — supervised failure reports `exitVerdict.root === 'exited'` (test added at `claude-stream-json-connection.test.ts`, diff lines 652-673).
- `src/main/claude/claude-structured-session-close.ts:30-41` — `claudeAcquisitionCleanupError` maps a first-hand root exit to `AgentSessionAcquisitionRootExitObservedError`.
- `src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts:30-59` — `root-exit-observed` → structured refusal; `AgentSessionPreSpawnError` is not handled and falls through to `classifyStoreFailure` (throws).
- `src/main/runtime/agent-session-acquisition-failure-settlement.ts:167-175` — `deathEvidence.kind` becomes `exit-observed` with detail "the provider process exited; its descendants were not proven gone".

## Required outcome
- Confirm the change from a thrown `AgentSessionPreSpawnError` to a structured `agent_session_operation_invalid` refusal (carrying `ownerVerdict: 'exited'`) is intended for a missing Claude binary, and that the `deathEvidence` copy is acceptable for a binary that never executed.

## Suggested approach
- If intended, state it in the PR body and add an adapter-level test that drives a missing `pathToClaudeCodeExecutable` end to end and asserts `AgentSessionAcquisitionRootExitObservedError` (the existing processless test is Windows-only, and the adapter coverage reaches this error only via `exitBeforeInit`).

## Open questions for the human
- Is the renderer's handling of a refusal vs a raw error acceptable here, or does it need the same diagnostic as before?

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

…adder cannot disagree with the spawn

createProviderSpawnSpec now reports whether it wrapped the provider in the supervisor, and the Claude spawner reads that instead of repeating the platform check. The close ladder's SIGTERM follows the process actually spawned.

@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

Reviewed the single commit since the prior pullfrog review (297ea48cd0 → a6a1605fe7); the delta is a behavior-preserving refactor of how supervision is decided, plus a test that pins it.

  • Supervision comes from the spawn spec: createProviderSpawnSpec now returns a supervised: boolean (its local supervised was renamed to supervisor), so the fact that a provider runs under the POSIX supervisor travels with the spec that actually built the spawn.
  • Claude reads that verdict: createClaudeCodeProcessSpawn stores spec.supervised in a closure and exposes it through a getter, replacing the duplicated platform !== 'win32' computation. The close ladder can no longer disagree with the spawn that ran; false until the SDK spawns matches the existing "null until the SDK spawns" contract for child/pid.
  • Test: a new spawn-suite case drives both a darwin spec reported unsupervised and a win32 spec reported supervised, asserting the ladder SIGTERMs (or does not) from the spec value rather than the platform. It fails against the old platform-derived implementation, so it is real coverage.
  • Test alignment: the Codex supervisor shape expectation gains supervised: false for win32, and the Claude spawn tests now pass an explicit platform.

No critical issues, no new regressions. The informational note from the prior review (the missing-CLI POSIX path now classifying as a root exit, with adapter-level coverage still absent) remains open and is unchanged by this commit.

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

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Status: ready for review. Head a6a1605fe7e, stacked on #23466 (merge that first), CI green (31 pass, 0 fail).

Review: the first review loop was clean. After the readiness check, one fix: whether Claude runs under the supervisor is now decided in one place, the spawn spec's supervised flag, instead of a second platform check. A narrow confirming review of that fix was clean, and its test goes red on five separate ablations.

Readiness check: passed with no release-blocking findings. It ran at the end of the review only, not at the start as well; that's disclosed here. Minor findings, not fixed: the shared supervisor still lives in a Codex module (it moves once #23466 lands); a Claude binary that disappears just before spawn is now recorded as an exit rather than a pre-spawn failure (the chat still releases, and the error text is kept); and one integration test asserts a close under 1.25 s, which could flake on a loaded CI machine.

Live QA, second full-app run on a remote Mac (a Claude worker ran it because the Grok quota was exhausted; results match the first run in Visual Proof):

  • Force-kill Orca while Claude runs sleep 120: the command was gone at +123 ms, Claude at +250 ms, the supervisor at +261 ms.
  • Close the chat tab mid-command: the command at +465 ms, Claude at +650 ms; the reopened chat kept its context.
  • A clean quit and relaunch: the next message is answered, with no errors.

After a force-kill and relaunch, the cut-off command, no process-id text
The next message after relaunch is answered
A chat closed mid-command and reopened from Session History
The reopened chat still remembers the codeword
After a clean quit and relaunch, the next message is answered

Not covered: Linux live QA of this PR, and Windows (unchanged by construction and pinned by a unit test).

@brennanb2025
brennanb2025 changed the base branch from brennanb2025/harden-provider-supervisor to main September 28, 2026 23:04
@coderabbitai

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

Provider spawn specifications now report whether a process is supervised, and the supervisor writes spawn errors to stderr. Claude launches through the provider spawn specification on POSIX and directly on Windows. Claude close passes the supervision state to exit proof, which sends SIGTERM and uses a longer graceful wait for supervised children. The child-eviction timeout now derives from snapshot-drain and provider stop limits. New tests cover platform-specific launches, shutdown paths, structured-session close behavior, and opt-in real-CLI scenarios.

Priority: ➖ Normal

Merge Risk: 🔵 Low · up to 07d68

The change is mergeable with a narrow test improvement: require the eviction phase before asserting that it completes.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 07d68

Supervised shutdown appears to reduce the time a Claude task can keep running after a chat closes or the application exits. The normal stop path is supported by tests, but exceptional cleanup and recovery timing are not fully established.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The relevant exposure is a local Claude child and its tools continuing to act with their existing workspace and credential access after their owner stops. The inspected change does not establish a new remote entrypoint or broader tenant authority.

Trust Boundaries and Controls

  • observed — The POSIX supervisor monitors its owner, signals the provider group, escalates after its grace period, and waits for that group to disappear before exiting. An owner-death integration test checks termination of Claude and a tool in its own group.

Resilience and Maintainability Implications

  • inferred — A provider stop exceeding the eviction deadline can finish after the one recovery-marker recording phase: the timeout does not cancel eviction, and a later confirmation is only added to the in-memory witness map. Whether another path persists that late marker, or coordinates overlapping host teardowns, remains unverified. The current 8,000 ms derived deadline equals the prior fixed bound, so this is not established as a PR-introduced failure.

Hardening Proposals

  • proposed — Establish a durable late-confirmation path, or prove that every provider stop completes before recovery-marker recording; also verify that overlapping host teardowns cannot reset one another’s witnesses.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.15% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 33 functions across 19 files. 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 and concisely describes the main change: running structured Claude under the POSIX provider supervisor.
Description check ✅ Passed The description is complete and relevant. It covers the user impact, implementation, rationale, alternatives, testing, visual proof, platform limitations, known gaps, and checklist items. The missing …
  • 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.

@greptile-apps

greptile-apps Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

[High risk] Restructures Claude process supervision on POSIX systems.

The PR does not appear safe to merge while supervisor escalation can leave a Claude tool running unwatched.

Findings

  1. P1 Tools survive supervisor escalation ▶
  2. P2 Test failure can orphan Claude ▶

Summary

This PR runs structured Claude through the POSIX provider supervisor, sends an immediate supervised stop on close, and adjusts shutdown bounds and tests.

  • The changes since the previous review export timeout constants and extend supervisor and teardown tests; they do not resolve the two existing findings.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Orca --> Supervisor
  Supervisor --> Claude
  Claude --> Tools
  Orca -- close / SIGTERM --> Supervisor
  Supervisor -- SIGTERM, then escalation --> Claude
Loading

Reviews (2) · Last reviewed commit: "fix(native-chat): derive quit's chat-evi..."

Comment on lines +54 to +62
const spec = createProviderSpawnSpec(
{
command: options.command,
args: [...options.args],
...(options.cwd === undefined ? {} : { cwd: options.cwd })
},
definedEnv(options.env),
platform
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Tools survive supervisor escalation If Orca dies while Claude is running a Bash tool and Claude ignores SIGTERM, the supervisor eventually SIGKILLs only Claude’s process group. The tool runs in a separate group, so it can continue working unwatched after Claude exits. The SIGTERM-ignoring test checks Claude’s exit but does not check the tool; the owner-death test checks the tool only when Claude handles SIGTERM.

Comment on lines +172 to +177
afterEach(async () => {
for (const pid of recordedPids) {
if (alive(pid)) {
process.kill(pid, 'SIGKILL')
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Test failure can orphan Claude If the test times out before finding the Bash tool, it has recorded only the supervisor PID. Cleanup then SIGKILLs the supervisor, bypassing its stop handler and leaving Claude and its tool running. Request a supervised stop before using SIGKILL as a fallback so a failed test does not leave those processes behind.

…supervised provider close

Quit's child-eviction phase was a hand-picked 8 s. It is now the sink drain plus the longest
supervised close over Claude and Codex plus a named 1 s margin, so a provider close that grows
widens it instead of silently outrunning it. A close's tree-kill fallback stays outside the bound:
once main exits, the supervisor stops its provider group on owner death, which a new test now
proves for a clean owner exit, and next launch's recovery settles the lease.

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

🧹 Nitpick comments (1)
src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts (1)

77-77: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert that the eviction phase exists before running it.

If evict-owned-sessions is removed or renamed, .find() returns undefined. Optional chaining then skips eviction, and the test can still pass. Assert the phase exists before calling run().

Suggested fix
+      expect(evict).toBeDefined()
       const run = Promise.resolve(evict?.run()).then(() => {

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 63b3671c-de25-4ebf-8f16-5d892909b1b5

📥 Commits

Reviewing files that changed from the base of the PR and between 05728a1 and 07d6876.

📒 Files selected for processing (5)
  • src/main/claude/claude-child-exit-proof-ladder.ts
  • src/main/codex/codex-app-server-connection.ts
  • src/main/codex/codex-app-server-posix-supervisor.integration.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 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 found.

Reviewed changes

Re-reviewed the single commit since the prior pullfrog review (05728a1f59 → 07d6876d5d), which replaces quit's hand-picked child-eviction bound with a derived one and adds coverage for the supervisor's clean-quit stop.

  • Derived the eviction bound: CHILD_EVICTION_TIMEOUT_MS is now SNAPSHOT_DRAIN_TIMEOUT_MS + Math.max(SUPERVISED_GRACEFUL_EXIT_MS, PROVIDER_SUPERVISOR_MAX_STOP_MS) + EVICTION_MARGIN_MS (1_000 + 6_000 + 1_000 = 8_000 ms, unchanged from the old literal), so a supervised close that grows widens the bound instead of silently outrunning it. The two Windows close constants were exported for the test to measure.
  • Pinned the invariant with tests: the teardown suite now asserts the bound equals that formula, that every provider's supervised and Windows close fits inside it, and that eviction plus the resume-marker step stays under quit's 20 s deadline; a fake-timer case pins that eviction finishes when the chats close rather than at its bound.
  • New clean-quit integration case: the supervisor is proven to reap the provider group and exit when its owner quits normally (exit 0, no stdin holder), complementing the existing owner-SIGKILL case.
  • Export/comment plumbing: GRACEFUL_EXIT_MS is exported from the Claude ladder and the Codex connection, and the recovery stop-probe comment now reads "structured owner".

I ran the four changed unit suites (17 tests) — all pass. The process-tree integration suites still fail in this container for the known PID-1 non-reaping reason: the untouched codex-app-server-posix-supervisor.integration.test.ts fails identically ({code:1} instead of SIGTERM), including the new clean-quit case, so this is environmental rather than a regression. CHILD_EVICTION_TIMEOUT_MS is unchanged in value, and eviction runs over sessions via Promise.all, so a per-phase max over the single longest close is the right shape.

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

@brennanb2025
brennanb2025 merged commit b4c19f1 into main Sep 29, 2026
44 of 45 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