diff --git a/src/main/claude/claude-agent-sdk-exit-proof.test.ts b/src/main/claude/claude-agent-sdk-exit-proof.test.ts index 3f15f8e8fca..f9412a10488 100644 --- a/src/main/claude/claude-agent-sdk-exit-proof.test.ts +++ b/src/main/claude/claude-agent-sdk-exit-proof.test.ts @@ -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') diff --git a/src/main/claude/claude-agent-sdk-exit-proof.ts b/src/main/claude/claude-agent-sdk-exit-proof.ts index 17533f87a70..883bf8efd6a 100644 --- a/src/main/claude/claude-agent-sdk-exit-proof.ts +++ b/src/main/claude/claude-agent-sdk-exit-proof.ts @@ -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 = { unverifiable: 0, @@ -89,8 +89,8 @@ export type ClaudeChildTreeReaper = { reap(): Promise /** * `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 } diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index 1a2a65bca34..c6da4419f79 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -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 } diff --git a/src/main/claude/claude-structured-acquisition-release.ts b/src/main/claude/claude-structured-acquisition-release.ts index 6be06c86e93..3df76971df1 100644 --- a/src/main/claude/claude-structured-acquisition-release.ts +++ b/src/main/claude/claude-structured-acquisition-release.ts @@ -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 } diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index 04bc6d5ffff..376cac9d52f 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -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[0] = {} diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index e9e233e4934..b8cc5c1dec5 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -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 => drainClaudeObservedExits(this.exits) diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index f0bba7ae2b0..e27b2a41e93 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -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( diff --git a/src/main/claude/claude-structured-session-exit-lifecycle.ts b/src/main/claude/claude-structured-session-exit-lifecycle.ts index fdcfed379c5..4f39a8dc467 100644 --- a/src/main/claude/claude-structured-session-exit-lifecycle.ts +++ b/src/main/claude/claude-structured-session-exit-lifecycle.ts @@ -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, diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index a106c172c45..362f4d691d0 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -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 diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 7d99aff577b..37cc762c6f5 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -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. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 27cd3c30240..c99b16d38a8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -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 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): Promise { try { return (await stop()) === true diff --git a/src/main/runtime/agent-session-acquisition-failure-settlement.ts b/src/main/runtime/agent-session-acquisition-failure-settlement.ts index 9335dd230bb..fea8d2e68f5 100644 --- a/src/main/runtime/agent-session-acquisition-failure-settlement.ts +++ b/src/main/runtime/agent-session-acquisition-failure-settlement.ts @@ -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 } } diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 480720da93b..6c2555e9c76 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -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' }