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

* fix(claude): end a Claude chat on its root's exit even when a descendant 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.

* test(claude): drop comments that still say a live descendant holds the lease

* docs(claude): state the root-exit rule without an unqualified only-writer claim

* test(claude): drop the remaining unqualified only-writer claims from root-exit tests

* docs(runtime): say a root-exit settlement's descendants were not proven gone

* docs(claude): say exit recovery also publishes on an observed root exit

* docs(claude): an observed root exit also settles a retained exit

* docs(claude): say an observed root exit is dropped by its own settlement, not the release
This commit is contained in:
Brennan Benson
2026-09-25 18:00:48 -07:00
committed by GitHub
parent 6627c6503c
commit 01ce5edf1a
13 changed files with 151 additions and 162 deletions
@@ -432,8 +432,8 @@ describe('claude child tree reaper', () => {
const child = mockChild()
// Reap #1 completed and saw a descendant alive at its deadline; the root then
// left on its own and the re-verification on a loaded host could not read the
// table. "Could not look" must not erase "was seen alive": the lease release
// gate is exactly the pair this distinguishes.
// table. "Could not look" must not erase "was seen alive": the report never
// calls a survivor gone.
const terminateDescendants = vi
.fn()
.mockResolvedValueOnce('live')
@@ -26,7 +26,7 @@ import {
/**
* A later reap may only raise the latched verdict. An observed exit is final, and
* a descendant seen alive at a deadline is never forgotten by a later look that
* could not read the table: the lease gate discriminates on exactly that pair.
* could not read the table, so the report never calls a survivor gone.
*/
const TREE_VERDICT_TRUST: Record<DescendantTreeVerdict, number> = {
unverifiable: 0,
@@ -89,8 +89,8 @@ export type ClaudeChildTreeReaper = {
reap(): Promise<DescendantTreeVerdict>
/**
* `unverifiable` until a reap observes otherwise. `exited` is the only verdict
* that lets a close release the lease; `live` names a descendant that was seen
* still running, which no later caller may collapse into "unknown".
* that proves a close; `live` names a descendant that was seen still running,
* which no later caller may collapse into "unknown".
*/
readonly treeVerdict: DescendantTreeVerdict
}
@@ -315,6 +315,11 @@ export async function openClaudeStreamJsonConnection(
})
inbox.fail(new Error('claude stream-json connection closed'))
if (!proven) {
if (exited && tree.treeVerdict === 'live') {
console.warn('[claude-stream-json] root exited but a descendant survived the close:', {
pid: spawner.pid
})
}
closePromise = null
return false
}
@@ -35,8 +35,8 @@ export async function releaseClaudeAcquisition(input: {
const retriedProof = firstProof || (await exit.connection.close())
if (retriedProof) {
await input.onExitProven?.(input.sessionId, exit)
// Keep the first-hand exit evidence indexed until the tree proof succeeds;
// a failed close must be retryable and cannot look like an absent session.
// Only a proven close drops the exit here. An unknown one stays indexed so it is retryable and
// cannot look like an absent session; an observed root exit is dropped by its own settlement.
input.exits.delete(input.sessionId)
return true
}
@@ -556,21 +556,18 @@ describe('ClaudeStructuredSessionAdapter acquisition cleanup', () => {
.catch((error: unknown) => error)
}
it('releases on a first-hand root exit while still carrying the CLI diagnostic', async () => {
// The root's pid and start time are the lease's identity, and they are
// provably dead: latching the session would strand a signed-out user.
const error = await failedStart({ root: 'exited', tree: 'unverifiable' })
// The root's pid and start time are the lease's identity, and they are provably dead: latching
// the session would strand a signed-out user. The lease follows the root, so a descendant seen
// alive does not hold the session either.
it.each(['unverifiable', 'live'] as const)(
'releases on a first-hand root exit with its tree %s while still carrying the CLI diagnostic',
async (tree) => {
const error = await failedStart({ root: 'exited', tree })
expect(error).toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError)
expect((error as Error).message).toBe('claude stream-json exited (code 1): not logged in')
})
it('never releases while a descendant was observed alive', async () => {
const error = await failedStart({ root: 'exited', tree: 'live' })
expect(error).toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError)
expect(error).not.toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError)
})
expect(error).toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError)
expect((error as Error).message).toBe('claude stream-json exited (code 1): not logged in')
}
)
it('never releases for a root Orca never saw leave', async () => {
const error = await failedStart({ root: 'live', tree: 'unverifiable' })
@@ -590,27 +587,19 @@ describe('ClaudeStructuredSessionAdapter acquisition cleanup', () => {
return { adapter, connection }
}
it('classifies cleanup after a first-hand exit removed the session as a root exit, never as proven', async () => {
// The host may still be committing or proving the lease when the child dies;
// its cleanup must find the exit the ladder observed, not an absence.
const { adapter, connection } = await exitedAfterPublish({
root: 'exited',
tree: 'unverifiable'
})
const error = await adapter.releaseAcquisition({ sessionId: 'session-1' }).catch((e) => e)
// The host may still be committing or proving the lease when the child dies; its cleanup must
// find the exit the ladder observed, not an absence.
it.each(['unverifiable', 'live'] as const)(
'classifies cleanup after a first-hand exit with its tree %s as a root exit, never as proven',
async (tree) => {
const { adapter, connection } = await exitedAfterPublish({ root: 'exited', tree })
const error = await adapter.releaseAcquisition({ sessionId: 'session-1' }).catch((e) => e)
expect(error).toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError)
expect((error as Error).message).toBe('claude stream-json exited (code 1): crashed')
expect(connection.closeCount).toBe(2)
})
it('never releases after an exit that left a descendant observed alive', async () => {
const { adapter } = await exitedAfterPublish({ root: 'exited', tree: 'live' })
const error = await adapter.releaseAcquisition({ sessionId: 'session-1' }).catch((e) => e)
expect(error).toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError)
expect(error).not.toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError)
})
expect(error).toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError)
expect((error as Error).message).toBe('claude stream-json exited (code 1): crashed')
expect(connection.closeCount).toBe(2)
}
)
it('forgets a retained exit once the session is acquired again', async () => {
const options: Parameters<typeof fakeClaude>[0] = {}
@@ -109,8 +109,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
}
/** Resolves once every first-hand exit observed so far has published its
* lifecycle event — or has failed its tree proof and stayed indexed for a
* retry. Publication trails observation by the close ladder and the
* lifecycle event — or, with neither its tree proven gone nor its root's exit
* observed, stayed indexed for a retry. Publication trails observation by the close ladder and the
* transcript cursor write, so nothing outside can otherwise tell the two
* apart without guessing at wall-clock. */
drainObservedExits = (): Promise<void> => drainClaudeObservedExits(this.exits)
@@ -19,12 +19,12 @@ import { closeProcessRegistry } from '../../shared/child-process/close-process-r
import { retireClaudeDispatchWaiters } from './claude-structured-dispatch'
import { settledClaudeTurnEndLeaf } from './claude-structured-resume-point'
/** The root's own exit was seen first-hand; only its descendants went unverified. */
/** The root's own exit was seen first-hand. The lease follows the root, so a descendant
* left unverified or seen alive does not hold it. */
export function claudeRootExitObserved(
connection: ClaudeStreamJsonConnection | null | undefined
): boolean {
const verdict = connection?.exitVerdict
return verdict?.root === 'exited' && verdict.tree === 'unverifiable'
return connection?.exitVerdict.root === 'exited'
}
export function claudeAcquisitionCleanupError(
@@ -55,7 +55,8 @@ export function observeClaudeSessionExit(
.catch(() => undefined)
}
/** Lifecycle recovery is published only after the child tree proof is true. */
/** Lifecycle recovery is published only after the close ladder ran and proved the tree gone or
* observed the root's own exit. */
export function settleClaudeUnexpectedExit(
lifecycle: ClaudeExitLifecycle,
sessionId: string,
@@ -206,8 +206,7 @@ describe('Claude structured session publishes before the CLI answers initialize'
await adapter.drainStartup('session-1')
await adapter.drainObservedExits()
// A failed start is released on the same evidence a failed create is; a proven-live
// descendant would have answered `tree: 'live'` instead.
// A failed start is released on the same evidence a failed create is.
expect(events.find((event) => event.type === 'ended')).toMatchObject({
cause: 'unexpected-exit',
startupUnproven: true
@@ -210,7 +210,7 @@ export function mintClaudeAcquisitionGeneration(deps: ClaudeStructuredSessionAda
*/
export type ClaudeSessionExit = {
connection: ClaudeStreamJsonConnection
/** Full session identity retained until its child tree is proven gone. */
/** Full session identity retained until the exit settles. */
session: ClaudeSession
error: Error
/** The exit path's first proof attempt; retries must observe this result. */
@@ -63,9 +63,9 @@ export class AgentSessionPromptAnswerRejectedError extends Error {
/**
* The provider's own root process was observed to exit, but its descendant tree
* could not be verified. The lease keys on the root's pid and start time, so its
* observed death releases the reservation; nothing is claimed about descendants.
* Never thrown when a descendant was observed still alive — that stays unproven.
* was not proven gone. The lease keys on the root's pid and start time, so its
* observed death releases the reservation; nothing is claimed about descendants,
* including one seen still alive.
*/
export class AgentSessionAcquisitionRootExitObservedError extends Error {
constructor(cause: unknown) {
@@ -195,7 +195,7 @@ export type StructuredAgentSessionAdapter = {
/** Reaps an acquired provider when the host cannot commit or prove its lease.
* Returns true only after provider child exit is proven. Throws
* `AgentSessionAcquisitionRootExitObservedError` when the provider root's own
* exit was observed first-hand but its descendants could not be verified. */
* exit was observed first-hand but its descendants were not proven gone. */
releaseAcquisition?(input: { sessionId: string }): Promise<boolean>
dispatch(input: {
sessionId: string
@@ -353,8 +353,8 @@ function provenExitAcquisitionFailure(cause: unknown): unknown {
}
/** Whether a stop left the provider root gone. The lease follows the root, so a first-hand root
* exit or a processless child ends the session even with descendants unverified; any other
* failure, including known-live descendants, still throws. */
* exit or a processless child ends the session whatever its descendants did; any other
* failure still throws. */
export async function stopAgentSessionProviderRoot(stop: () => Promise<boolean>): Promise<boolean> {
try {
return (await stop()) === true
@@ -15,8 +15,8 @@ import type { AgentSessionStoreState } from './agent-session-record-store-file'
* How the failed attempt's provider process was accounted for.
* - `exit-proven`: cleanup observed the whole tree gone.
* - `root-exit-observed`: the owner root's exit was observed first-hand, so the
* identity this lease is keyed on is dead, but its descendants could not be
* verified. Releases the lease and says exactly that, claiming nothing more.
* identity this lease is keyed on is dead, but its descendants were not proven
* gone. Releases the lease and says exactly that, claiming nothing more.
* - `processless`: the attempt failed before a process existed.
* - `unproven`: nothing about the process was observed; the reservation latches.
*/
@@ -104,7 +104,7 @@ export function settleFailedAgentSessionPostAcquisitionAttachment(
args.exitProof === 'root-exit-observed'
? {
kind: 'exit-observed',
detail: 'the provider process exited; its descendants were not verifiable',
detail: 'the provider process exited; its descendants were not proven gone',
observedAt: args.now
}
: {
@@ -166,7 +166,7 @@ function acquisitionDeathEvidence(
if (exitProof === 'root-exit-observed') {
return {
kind: 'exit-observed',
detail: 'the provider process exited; its descendants were not verifiable',
detail: 'the provider process exited; its descendants were not proven gone',
observedAt
}
}
@@ -389,40 +389,44 @@ describe('a structured Claude session over agentSession.*', () => {
expect(claude.connections).toHaveLength(1)
})
it('releases a session whose CLI self-exited during create, with its diagnostic intact', async () => {
claude.setSelfExit({
message: 'claude stream-json exited (code 1): claude: not signed in',
// The root's death is first-hand; its descendants were never snapshottable.
exitVerdict: { root: 'exited', tree: 'unverifiable' }
})
// The root's death is first-hand. Its descendants were never snapshottable, or one was seen
// alive; either way the lease follows the root, so the reservation goes with it.
it.each(['unverifiable', 'live'] as const)(
'releases a session whose CLI self-exited during create with its tree %s, with its diagnostic intact',
async (tree) => {
claude.setSelfExit({
message: 'claude stream-json exited (code 1): claude: not signed in',
exitVerdict: { root: 'exited', tree }
})
const failed = await call('agentSession.create', createIntentParams())
const failed = await call('agentSession.create', createIntentParams())
// Answered once, as the refusal a replay of this operation gives, never thrown first.
expect(failed).toMatchObject({
ok: true,
result: {
ok: false,
refusal: {
code: 'agent_session_operation_invalid',
message: expect.stringContaining('claude: not signed in'),
ownerVerdict: 'exited'
// Answered once, as the refusal a replay of this operation gives, never thrown first.
expect(failed).toMatchObject({
ok: true,
result: {
ok: false,
refusal: {
code: 'agent_session_operation_invalid',
message: expect.stringContaining('claude: not signed in'),
ownerVerdict: 'exited'
}
}
}
})
const lease = leaseOf(SESSION)
// Latching here would refuse every later attach with agent_session_ownership_unknown,
// wedging a user who only needs to sign in.
expect(lease).toMatchObject({ claimStatus: 'released', handoffStage: null })
expect(lease.deathEvidence).toMatchObject({
kind: 'exit-observed',
detail: 'the provider process exited; its descendants were not verifiable'
})
})
const lease = leaseOf(SESSION)
// Latching here would refuse every later attach with agent_session_ownership_unknown,
// wedging a user who only needs to sign in.
expect(lease).toMatchObject({ claimStatus: 'released', handoffStage: null })
expect(lease.deathEvidence).toMatchObject({
kind: 'exit-observed',
detail: 'the provider process exited; its descendants were not proven gone'
})
claude.setSelfExit(null)
// Signing in and reopening the chat works: the reservation was not latched.
await ok<{ fence: number }>('agentSession.ensure', ensureParams(lease.runtimeFence))
})
claude.setSelfExit(null)
// Signing in and reopening the chat works: the reservation was not latched.
await ok<{ fence: number }>('agentSession.ensure', ensureParams(lease.runtimeFence))
}
)
it('answers a create whose whole CLI tree exited as exited on the first call', async () => {
claude.setSelfExit({
@@ -448,42 +452,28 @@ describe('a structured Claude session over agentSession.*', () => {
claude.setSelfExit(null)
})
it('keeps a session reserved when a descendant of the failed start was seen alive', async () => {
claude.setSelfExit({
message: 'claude stream-json exited (code 1): claude: not signed in',
exitVerdict: { root: 'exited', tree: 'live' }
})
it.each(['unverifiable', 'live'] as const)(
'reopens a chat whose stop saw the Claude root exit with its tree %s',
async (tree) => {
await ok('agentSession.create', createIntentParams())
const first = claude.live()
first.exitVerdict = { root: 'exited', tree }
first.close = async () => {
first.closed = true
return false
}
const host = getStructuredAgentSessionHost()
// The idle release clock's eviction: the lease follows the root, so the host lets go.
await host?.close(SESSION)
expect(host?.hasSession(SESSION)).toBe(false)
await call('agentSession.create', createIntentParams())
// What the chat surface's `agentSession.hold` does when the user comes back to it.
await host?.hold(SESSION, 'desktop-chat:reopen')
// A live descendant still holds the provider session: releasing would hand a
// second writer to it.
expect(leaseOf(SESSION)).toMatchObject({
claimStatus: 'reserved',
handoffStage: 'manual-recovery'
})
claude.setSelfExit(null)
})
it('reopens a chat whose stop saw the Claude root exit but not its descendants', async () => {
await ok('agentSession.create', createIntentParams())
const first = claude.live()
first.exitVerdict = { root: 'exited', tree: 'unverifiable' }
first.close = async () => {
first.closed = true
return false
expect(claude.connections).toHaveLength(2)
expect(host?.hasSession(SESSION)).toBe(true)
}
const host = getStructuredAgentSessionHost()
// The idle release clock's eviction: the lease follows the root, so the host lets go.
await host?.close(SESSION)
expect(host?.hasSession(SESSION)).toBe(false)
// What the chat surface's `agentSession.hold` does when the user comes back to it.
await host?.hold(SESSION, 'desktop-chat:reopen')
expect(claude.connections).toHaveLength(2)
expect(host?.hasSession(SESSION)).toBe(true)
})
)
it('routes a published Claude first-hand exit through fenced host reconciliation', async () => {
await ok<{ fence: number }>('agentSession.create', createIntentParams())
@@ -497,46 +487,51 @@ describe('a structured Claude session over agentSession.*', () => {
expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null })
})
it('restarts an open chat after a Claude crash whose descendants could not be verified', async () => {
const created = await ok<{ fence: number }>('agentSession.create', createIntentParams())
// The open chat surface is what asks the host to bring Claude back.
await getStructuredAgentSessionHost()?.hold(SESSION, 'desktop-chat:open')
const connection = claude.live()
connection.exitVerdict = { root: 'exited', tree: 'unverifiable' }
connection.close = async () => {
connection.closed = true
return false
}
// Claude takes the message but crashes before echoing it.
connection.send = async (message) => {
connection.sent.push(message)
}
const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'in flight' }] }
const inFlight = ok<{ submission: { dispatchState: string; reason: string | null } }>(
'agentSession.send',
{ envelope: envelope('agentSession.send', { body }, created.fence), body }
)
await vi.waitFor(() => expect(connection.sent).toHaveLength(1))
connection.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed'))
// A descendant seen alive is one that survived the close ladder, such as an MCP server that
// ignores SIGTERM; it no longer holds the chat.
it.each(['unverifiable', 'live'] as const)(
'restarts an open chat after a Claude crash whose tree was %s',
async (tree) => {
const created = await ok<{ fence: number }>('agentSession.create', createIntentParams())
// The open chat surface is what asks the host to bring Claude back.
await getStructuredAgentSessionHost()?.hold(SESSION, 'desktop-chat:open')
const connection = claude.live()
connection.exitVerdict = { root: 'exited', tree }
connection.close = async () => {
connection.closed = true
return false
}
// Claude takes the message but crashes before echoing it.
connection.send = async (message) => {
connection.sent.push(message)
}
const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'in flight' }] }
const inFlight = ok<{ submission: { dispatchState: string; reason: string | null } }>(
'agentSession.send',
{ envelope: envelope('agentSession.send', { body }, created.fence), body }
)
await vi.waitFor(() => expect(connection.sent).toHaveLength(1))
connection.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed'))
expect((await inFlight).submission).toMatchObject({
dispatchState: 'unknown',
reason: 'provider_exited_before_acknowledgement'
})
// Held back, sends failed with the crash until the idle clock stopped the chat.
await waitForStructuredAgentSessionRecovery()
expect(claude.connections).toHaveLength(2)
expect(claude.live().launch.options).toMatchObject({ resume: PROVIDER_SESSION })
const lease = leaseOf(SESSION)
expect(lease).toMatchObject({ claimStatus: 'live', handoffStage: null })
const next = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'after' }] }
const sent = await ok<{ submission: { dispatchState: string } }>('agentSession.send', {
envelope: envelope('agentSession.send', { body: next }, lease.runtimeFence),
body: next
})
expect(sent.submission.dispatchState).toBe('accepted')
expect(claude.live().sent).toHaveLength(1)
})
expect((await inFlight).submission).toMatchObject({
dispatchState: 'unknown',
reason: 'provider_exited_before_acknowledgement'
})
// Held back, sends failed with the crash until the idle clock stopped the chat.
await waitForStructuredAgentSessionRecovery()
expect(claude.connections).toHaveLength(2)
expect(claude.live().launch.options).toMatchObject({ resume: PROVIDER_SESSION })
const lease = leaseOf(SESSION)
expect(lease).toMatchObject({ claimStatus: 'live', handoffStage: null })
const next = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'after' }] }
const sent = await ok<{ submission: { dispatchState: string } }>('agentSession.send', {
envelope: envelope('agentSession.send', { body: next }, lease.runtimeFence),
body: next
})
expect(sent.submission.dispatchState).toBe('accepted')
expect(claude.live().sent).toHaveLength(1)
}
)
it('creates, sends, streams, approves, interrupts, and resumes from the chain head', async () => {
shellEnv = { ...shellEnv, ANTHROPIC_API_KEY: 'sk-ant-SHELL-LEAK' }