Skip to content

fix(native-chat): a kill that reached the agent counts as gone, so the next message starts a fresh one - #25866

Draft
brennanb2025 wants to merge 3 commits into
mainfrom
brennanb2025/native-chat-kill-counts-as-gone
Draft

brennanb2025 wants to merge 3 commits into
mainfrom
brennanb2025/native-chat-kill-counts-as-gone

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 20 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1064 $\color{#cf222e}{\Huge{\mathbf{−}}}$​132 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​932
Prod 34 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​512 $\color{#cf222e}{\Huge{\mathbf{−}}}$​196 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​316

ELI5

When you stop Claude or Codex in a native chat, Orca shuts down the agent's process. Before this change, Orca would not start a new process for your next message until it actually saw the old one exit. If that exit report never came, your next message failed with "Couldn't stop Claude from before. Send your message again to try once more.", even though Orca had already force-killed the old process and it could not write anything anymore.

Now, once Orca's kill has actually reached the old agent, it counts as gone, and your next message starts a fresh agent right away. Orca still refuses only when the kill itself could not be delivered, which is the one case where two agents could write to the same conversation.

While making this change I found a real hole on macOS and Linux. In some cases Orca's force-kill reached only a small wrapper process and left the agent itself running, and Orca then treated it as gone. This PR closes that hole too.

What Changed

The problem, as a user sees it. After a Stop, closing a chat tab, or the 30-minute idle rest, Orca stops the agent's process. It sends a polite stop first, then a force-kill, then waits about 1 second to see the process exit. If it did not see the exit in time, everything that needed the agent was refused, after up to about 10 seconds of waiting:

  • the next message failed with "Couldn't stop {agent} from before. Send your message again to try once more.";
  • a model or effort change, an answer to a question card, a goal change, a rewind, or a background-task stop failed with "The previous agent in this chat may still be running.";
  • a failed start whose cleanup hit the same case left the chat refusing until a later start re-checked.

A process that has received a force-kill (SIGKILL on macOS/Linux, TerminateProcess on Windows) never runs its own code again, so it cannot add another turn to the conversation. The refusal protected Orca's bookkeeping, not the conversation. It is rare: a process stuck in the kernel (a hung network drive, for example) or an exit Orca had not processed yet. The common pattern for this does not make the next message wait on an exit report at all.

The hole I found. On macOS and Linux, each agent runs under a small supervisor process. The supervisor starts the agent in a separate process group, so killing the supervisor does not kill the agent. Two paths killed only the supervisor:

  • the Codex forced close when the process table could not be read (provider-process-teardown.ts), which then even reported the tree as handled;
  • the Claude forced close when no snapshot of the agent's processes existed (claude-agent-sdk-exit-proof.ts).

In both cases the agent kept running with nobody watching it, and Orca then saw the supervisor exit and counted the agent as gone. For Codex releases without their own conversation lock, that could mean two Codex processes writing one conversation.

After this change:

  1. One forced step for both agents on macOS/Linux (provider-supervised-group-kill.ts).
    • Orca pauses the supervisor, so it cannot clean up its child and the child's process number cannot be reused.
    • It then finds the agent as the supervisor's own child that leads its own process group, and kills that whole group, the agent and its tools.
    • Only then does it kill the supervisor.
    • If it cannot name the group exactly (the process table cannot be read, or the answer is ambiguous), it kills nothing and resumes the supervisor, so the supervisor's own stop still reaches the agent. The supervisor is never killed without the group kill.
    • A group that has nothing left to signal counts as gone. Linux answers "no such process" (ESRCH) for that. macOS answers "operation not permitted" (EPERM) when the group holds only already-dead entries, checked on this Mac.
  2. A kill that reached the agent counts as gone.
    • The adapters report a third outcome next to "exited" and "unproven": AgentSessionProviderKilledError.
    • The host ends the child's record with rootGone: false.
    • It releases the chat's lease with no exit evidence: deathEvidence: null, the same stored shape restart recovery already writes, so nothing new is persisted.
    • The next start, option change, card answer, goal change, rewind, or Stop then goes ahead. When the old process's exit report finally arrives, it no longer matches the current child, so it is ignored.
    • A failed start whose cleanup killed the agent now settles as an ordinary failed start, not as "may still be running".
  3. Codex resume waits out Codex's own lock.
    • Codex locks each conversation per process, and the operating system drops that lock the moment the holder dies.
    • If a resume right after the kill meets "thread … already has an active writer" (JSON-RPC error -32600), Orca tries again after 100 ms, 400 ms and 1 s.
    • If the lock is still held after that, that one message fails with the existing start-failure wording. Nothing is latched, and the next message tries again.
  4. Codex gets the same 0.5 s margin Claude already had past the supervisor's own worst-case stop time, so it does not force a supervisor that is about to finish on a busy machine.

What stays the same.

  • "Couldn't stop {agent} from before" stays. It is still the answer when the kill could not be delivered, and older hosts still send it.
  • Remote and terminal-agent ownership claims are untouched. Structured Claude and Codex chats only run on the local host, and a chat whose owner is on another host still waits for that host, as before.

Why

The rule now asks the question that matters: can the old process still write to this conversation? It no longer asks whether Orca has processed the old process's exit event yet.

  • A delivered SIGKILL or TerminateProcess answers that question as well as an observed exit does. It also does not depend on how fast the machine reports the exit.
  • Codex releases from late July 2026 on add their own lock (at first for paginated threads only, then for every thread), so a second writer is refused by Codex itself, and Orca waits that lock out briefly.
  • Closing the supervisor-only kill paths was required for this to be safe. Without that fix, "killed" would not have meant "the agent is dead" on macOS/Linux.

Alternatives considered.

  • Keep waiting for the exit report, but longer. This turns a refusal into a long hang and still fails for a process stuck in the kernel.
  • Proceed after a timer, without checking that the kill landed. This is closer to the common pattern, but it would let a second agent write while the first is still alive if the kill never landed. This PR still refuses that case.
  • Keep the old record alive in a "dying" slot until its exit arrives. This adds per-session state and a new stored lease transition, for a fact the kill delivery already answers.

How this differs from the common pattern, each labelled:

  • Intended: Orca proceeds once the kill is delivered to the agent's own process group. The common pattern proceeds after a bounded wait without checking delivery. Orca is stricter only in the case where the kill could not land, which is exactly when a second writer is possible.
  • Intended: on macOS/Linux, the supervisor's polite stop (stdin end, then SIGTERM, then SIGKILL of the group) still runs before the forced step. The common pattern signals the single process. Keeping it gives the agent time to save its turn.
  • Intended: on Windows, Orca relies on taskkill /T /F (tree kill) plus TerminateProcess of the root, plus Codex's own lock. That is the common pattern's mechanism. For an npm Codex install on Windows, the process Orca starts is a Node wrapper whose child does the writing. That child is reached by the tree kill, whose outcome cannot be read, and by Codex's lock. Codex releases older than the lock get the same assurance as before this PR: the wrapper's exit never said anything about its child either.
  • Intended: the Codex lock retry covers resume only. A new conversation (thread/start) has a fresh id that no other process can hold.
  • Temporary: two paths still wait for an observed exit. Both need a follow-up, with no ticket yet.
    • A Stop while the agent is still starting (the start-cancellation path).
    • Restart recovery, which still kills a previous run's supervisor by its process number. It has always released the chat even when unproven, so it never refuses the user.

Linked Issue

Fixes # (none; internal follow-up to the native-chat agent lifecycle work in #24862)

Visual Proof

N/A. This adds no new UI. The change is that an existing error stops appearing in a rare fault state, which needs a process stuck in the kernel to reproduce, so it cannot be staged in the app. It is covered by host-level tests that drive the real host and adapters.

Testing

  • I manually tested these changes locally
  • Automated tests added/updated, or explained why not below

Platforms: macOS (local). Linux and Windows branches are covered by unit tests with fake kill results. Linux's ESRCH answer for an empty group was not tried on a real Linux machine.

  • Forced step, unit: order (pause, then group kill, then supervisor kill); ESRCH and EPERM read as nothing left; any other error, an unreadable table, a walk that missed the supervisor, or two candidate groups resume the supervisor and kill nothing; a provider already reaped; a supervisor that already exited.
  • Forced step, real processes (macOS):
    • the provider and its tool die, which killing the supervisor alone cannot do;
    • a provider that died while the supervisor was paused reads as gone.
  • Claude reaper: the supervised order; an unreadable table never kills the supervisor alone; Windows reads TerminateProcess delivered or not.
  • Codex connection and adapter: the kill is reported until the exit is seen; a killed close ends the session and its late exit ends nothing; a close that neither saw the exit nor killed keeps the session.
  • Codex resume: retry, then success; retry, then a single failure; other errors are not waited on; Codex's raw JSON-RPC frame through the real connection.
  • Host, Claude: after a Stop whose kill landed, the next message goes to a fresh process with no refusal and no second kill; the lease is released with no exit evidence; the late exit is ignored; an option change is taken at rest.
  • Host, Codex: the same, with the new process meeting the lock once.
  • The kill-not-landed refusal is unchanged: the existing tests that expect "Couldn't stop {agent} from before" and the option-change refusal still pass.
  • Failed start whose cleanup killed the agent: released, not parked in recovery, before and after the owner proved its handle.
  • Margin: a supervised root-only provider is not forced before the supervisor's stop plus 0.5 s.
  • Ablations (each reverted one change and checked that the named tests fail): killing the supervisor on an unknown answer; skipping the group kill; the host reading "killed" as unproven; writing exit evidence on a kill; no Codex retry; the Codex close ignoring the kill; the Claude close ignoring the kill; no 0.5 s margin; a killed failed start parked in recovery. All 9 were caught.
  • Suites: every test file importing a changed module, plus the fix(native-chat): the agent's exit ends its record, and an unconfirmed stop is joined instead of held #24862 stop/close suites and the cross-version wire unit test: 147 files, 1,347 passed, 1 skipped. pnpm run typecheck (CI's command) is clean. oxlint on the changed files is clean. check:code-quality:changed passed.
  • Real Claude CLI (claude-structured-real-cli*.test.ts, run once as the repo rule asks): 9 of 10 passed. "withdraws a follow-up queued behind a turn that is stopped" failed. The CLI had already accepted the queued follow-up before the cancel (both messages came back with provider ids), so nothing was left to withdraw. That test covers turn cancellation, which this PR does not touch, but I did not re-run it on main to prove it fails there too.

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

  • Cross-platform: the forced step is POSIX-only. Windows keeps its tree kill and now reports TerminateProcess delivery.
  • SSH/remote: no change. Structured chats run on the local host, and loss of contact with a remote host is still never treated as death.
  • Mixed versions: no wire change. The refusal reason previousExitUnverifiable and its copy are kept.

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)

…, so the next message starts a fresh one

- POSIX close kills the agent's own process group while its supervisor is paused, then the supervisor; never the supervisor alone.
- The host ends the child's record on a delivered kill (no exit evidence) and starts the next message's agent without refusing.
- Codex resume waits out the previous app-server's thread lock (100/400/1000 ms), then fails that one message.
- Codex waits the same 0.5 s past its supervisor's worst case that Claude does.

This branch has not been deployed

No deployments
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