Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
11009c5
fix(native-chat): a child's root exit is reported even during its clo…
brennanb2025 Oct 2, 2026
b9667b5
fix(native-chat): the exit ends the child's record; an unfinished sto…
brennanb2025 Oct 2, 2026
103598f
fix(native-chat): a message refused because the old process's exit is…
brennanb2025 Oct 2, 2026
902354e
fix(native-chat): a close's verdict is the root's exit alone, and wha…
brennanb2025 Oct 2, 2026
11fb593
fix(native-chat): one handler ends every child's exit, and a join wai…
brennanb2025 Oct 2, 2026
e97710f
fix(native-chat): an exit's lease release lands whatever the length o…
brennanb2025 Oct 2, 2026
ce52df9
fix(claude): a proven close waits at most 2 s for the output it alrea…
brennanb2025 Oct 2, 2026
1d315b8
fix(codex): an exit reported inside Orca's close keeps the reason Orc…
brennanb2025 Oct 2, 2026
31819db
fix(native-chat): quit stops delivery before it drains exit recovery
brennanb2025 Oct 2, 2026
aa7928c
docs(native-chat): the unverifiable-exit refusal no longer names a ca…
brennanb2025 Oct 2, 2026
121150b
fix(native-chat): a stop whose kill did not take is logged, and the n…
brennanb2025 Oct 2, 2026
1f4a19c
fix(native-chat): a start refused over the old process says Orca coul…
brennanb2025 Oct 2, 2026
54512de
docs(native-chat): an unverifiable close verdict is a root that survi…
brennanb2025 Oct 2, 2026
0a98f69
fix(native-chat): a kill that did not take is reported once, by whoev…
brennanb2025 Oct 2, 2026
6cf2520
Merge remote-tracking branch 'origin/main' into brennanb2025/owed-sto…
brennanb2025 Oct 2, 2026
45c746d
Merge remote-tracking branch 'origin/main' into brennanb2025/owed-sto…
brennanb2025 Oct 3, 2026
1bf8f29
test(native-chat): a second Stop joins a close the first could not pr…
brennanb2025 Oct 3, 2026
83073d8
Merge remote-tracking branch 'origin/main' into brennanb2025/owed-sto…
brennanb2025 Oct 3, 2026
8fb8aaf
fix(native-chat): say a start refused beside an unstopped process pla…
brennanb2025 Oct 3, 2026
00001f1
Move provider process supervision and stream reading out of Codex
brennanb2025 Oct 3, 2026
ef2bb10
Preserve teardown behavior with checked mock types after move
brennanb2025 Oct 3, 2026
0b95df2
Apply provider launch environment and caller teardown labels
brennanb2025 Oct 3, 2026
5b7781c
Merge remote-tracking branch 'origin/brennanb2025/acp-a4-provider-pro…
brennanb2025 Oct 4, 2026
38e8e2a
Merge remote-tracking branch 'origin/main' into brennanb2025/acp-a6-p…
brennanb2025 Oct 4, 2026
892d823
Share managed provider launch, exit observation, and close
brennanb2025 Oct 4, 2026
b5be93c
Preserve synchronous stdin closure before the grace wait
brennanb2025 Oct 4, 2026
7a245c4
Keep the stacked Claude adapter within the module size limit
brennanb2025 Oct 4, 2026
d47bf9d
Handle nullable provider stdin and processless fixture identity
brennanb2025 Oct 4, 2026
74a648c
Preserve close-time cleanup diagnostics after a late provider exit
brennanb2025 Oct 4, 2026
9a2fce9
Merge remote-tracking branch 'origin/main' into brennanb2025/acp-a6-p…
brennanb2025 Oct 4, 2026
67e7fab
Separate provider root and descendant exit observations
brennanb2025 Oct 4, 2026
7912755
Merge remote-tracking branch 'origin/main' into brennanb2025/acp-a4-p…
brennanb2025 Oct 5, 2026
987fb53
Give provider child env one owner and gate Codex contract on the shar…
brennanb2025 Oct 5, 2026
bef5670
Merge remote-tracking branch 'origin/brennanb2025/acp-a4-provider-pro…
brennanb2025 Oct 5, 2026
97016f3
Merge remote-tracking branch 'origin/main' into brennanb2025/owed-sto…
brennanb2025 Oct 5, 2026
6579424
Give every provider one close result, stderr tail and root-only default
brennanb2025 Oct 5, 2026
a1de848
Merge remote-tracking branch 'origin/brennanb2025/owed-stop-exit-ends…
brennanb2025 Oct 5, 2026
b629fcf
Merge remote-tracking branch 'origin/main' into brennanb2025/acp-a6-p…
brennanb2025 Oct 5, 2026
0b9aa43
Report only observed descendant exits from the fallback teardown
brennanb2025 Oct 5, 2026
eb94d9b
Give two test fixtures' casts a SAFETY rationale for the changed-code…
brennanb2025 Oct 5, 2026
52becf7
Claim no observation from an empty process group
brennanb2025 Oct 5, 2026
c2bfd63
Merge origin/main into acp-a6-provider-lifecycle
brennanb2025 Oct 5, 2026
fa8e969
fix(native-chat): a person's close joining a failed one still binds t…
brennanb2025 Oct 5, 2026
636f6f1
Merge origin/main into acp-a6-provider-lifecycle (main #24862 landed)
brennanb2025 Oct 5, 2026
f1e98c3
Merge origin/main into acp-a6-provider-lifecycle (main squash of #249…
brennanb2025 Oct 5, 2026
5c6a834
test(native-chat): build the Stop-opened-turn test's identity with th…
brennanb2025 Oct 5, 2026
e35bf18
test(ratchet): require src/main/provider-process now that it has landed
brennanb2025 Oct 5, 2026
4e53819
Merge origin/main into acp-a6-provider-lifecycle (main #25706, #25073)
brennanb2025 Oct 5, 2026
1a5c70e
test(native-chat): keep main's opaque-handle import in the Stop-opene…
brennanb2025 Oct 5, 2026
ab066e6
Merge brennanb2025/ratchet-provider-process-required (#25710) into ac…
brennanb2025 Oct 5, 2026
5d011d7
Merge origin/main into acp-a6-provider-lifecycle (main's submission-p…
brennanb2025 Oct 5, 2026
a26ef64
Merge origin/main into acp-a6-provider-lifecycle (main's duplicate-im…
brennanb2025 Oct 6, 2026
e21865a
Merge 8127d9478218 into acp-a6-provider-lifecycle
brennanb2025 Oct 6, 2026
5df4db2
Merge 08a970a3f117 into acp-a6-provider-lifecycle
brennanb2025 Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 28 additions & 36 deletions src/main/claude/claude-agent-sdk-exit-proof.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
proveClaudeChildExit,
type ClaudeChildTreeReaper
} from './claude-agent-sdk-exit-proof'
import { managedChild } from './claude-child-exit-proof-fixture'
import { GRACEFUL_EXIT_MS } from './claude-child-exit-proof-ladder'

// The descendant models an MCP server: it either cooperates or, when it traps
Expand Down Expand Up @@ -99,11 +100,13 @@ async function proveExitWithRetries(
}

function spawnScript(script: string): ReturnType<typeof spawnProcess> {
return spawnProcess({
const child = spawnProcess({
program: process.execPath,
args: ['-e', script],
stdio: ['pipe', 'pipe', 'pipe']
})
managedChild(child)
return child
}

function firstStdoutLine(child: ReturnType<typeof spawnProcess>): Promise<string> {
Expand All @@ -112,28 +115,19 @@ function firstStdoutLine(child: ReturnType<typeof spawnProcess>): Promise<string
})
}

function observeExit(child: EventEmitter): { exitPromise: Promise<void>; exited: () => boolean } {
let exited = false
const exitPromise = new Promise<void>((resolve) => {
child.once('exit', () => {
exited = true
resolve()
})
})
return { exitPromise, exited: () => exited }
}

/** `null` models a spawn that failed before a pid existed. */
function mockChild(
pid: number | null = 424242
): EventEmitter &
Pick<SpawnedProcess, 'pid' | 'kill' | 'stdin'> & { kill: ReturnType<typeof vi.fn> } {
const child = new EventEmitter()
return Object.assign(child, {
Pick<SpawnedProcess, 'pid' | 'kill' | 'stdin' | 'stderr'> & { kill: ReturnType<typeof vi.fn> } {
const child = Object.assign(new EventEmitter(), {
pid: pid ?? undefined,
stdin: new PassThrough(),
stderr: new PassThrough(),
kill: vi.fn(() => true)
}) as never
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The proof reads only pid, events, kill, stdin and stderr from this fixture.
return child as never
}

/** A tree whose verdict is scripted per reap, recording when it was armed. */
Expand Down Expand Up @@ -200,7 +194,7 @@ describe('claude child exit proof', () => {
await ageDescendantPastTheCaptureSecond()

try {
const proven = await proveExitWithRetries({ child, ...observeExit(child) })
const proven = await proveExitWithRetries({ managed: managedChild(child) })
// Evaluated AT the boundary, not by polling until a deferred sweep timer
// wins: true releases the lease, so a descendant still running here is
// exactly the orphan the proof exists to prevent. False would be the
Expand Down Expand Up @@ -237,7 +231,7 @@ describe('claude child exit proof', () => {
await ageDescendantPastTheCaptureSecond()

try {
const proven = await proveExitWithRetries({ child, ...observeExit(child) })
const proven = await proveExitWithRetries({ managed: managedChild(child) })
expect({ proven, descendant: descendantState(descendantPid) }).toEqual({
proven: true,
descendant: 'exited'
Expand All @@ -263,7 +257,7 @@ describe('claude child exit proof', () => {
descendantPid: number
}
try {
const proven = await proveExitWithRetries({ child, ...observeExit(child) })
const proven = await proveExitWithRetries({ managed: managedChild(child) })
expect({ proven, descendant: descendantState(descendantPid) }).toEqual({
proven: true,
descendant: 'exited'
Expand All @@ -282,36 +276,34 @@ describe('claude child exit proof', () => {
it('arms the snapshot before stdin closes and verifies it after a clean exit', async () => {
const child = spawnScript(COOPERATIVE_CHILD)
expect(await firstStdoutLine(child)).toBe('ready')
const exit = observeExit(child)
const managed = managedChild(child)
const tree = mockTree(['exited'])
let exitedWhenArmed: boolean | null = null
tree.capture.mockImplementation(async () => {
exitedWhenArmed = exit.exited()
exitedWhenArmed = managed.rootVerdict === 'exited'
})

await expect(proveClaudeChildExit({ child, ...exit, tree })).resolves.toBe(true)
await expect(proveClaudeChildExit({ managed, tree })).resolves.toBe(true)
// The snapshot is the only proof that survives the root: taken while it lived,
// verified once it left. A reap before the exit would have been the forced ladder.
expect(exitedWhenArmed).toBe(false)
expect(tree.reap).toHaveBeenCalledTimes(1)
expect(exit.exited()).toBe(true)
expect(managed.rootVerdict).toBe('exited')
}, 20_000)

it('proves a clean close of a childless root with one snapshot and no signal', async () => {
const child = spawnScript(COOPERATIVE_CHILD)
expect(await firstStdoutLine(child)).toBe('ready')

await expect(proveClaudeChildExit({ child, ...observeExit(child) })).resolves.toBe(true)
await expect(proveClaudeChildExit({ managed: managedChild(child) })).resolves.toBe(true)
}, 20_000)

it('reports an unprovable exit as false rather than assuming the child died', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
try {
const tree = mockTree(['exited'])
const proof = proveClaudeChildExit({
child: mockChild(),
exitPromise: new Promise<void>(() => {}),
exited: () => false,
managed: managedChild(mockChild()),
tree
})
await new Promise((resolve) => setImmediate(resolve))
Expand All @@ -330,18 +322,18 @@ describe('claude child exit proof', () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
try {
const child = mockChild()
const exit = observeExit(child)
const managed = managedChild(child)
const tree = mockTree(['live'])
tree.reap.mockImplementation(async () => {
child.emit('exit', null, 'SIGKILL')
return 'live'
})
const proof = proveClaudeChildExit({ child, ...exit, tree })
const proof = proveClaudeChildExit({ managed, tree })
await new Promise((resolve) => setImmediate(resolve))
await vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS)

await expect(proof).resolves.toBe(false)
expect(exit.exited()).toBe(true)
expect(managed.rootVerdict).toBe('exited')
// One verification per attempt: the retried close re-verifies, this one does not.
expect(tree.reap).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(0)
Expand All @@ -352,16 +344,18 @@ describe('claude child exit proof', () => {

it('re-verifies an unproven tree on a retried close instead of trusting the dead root', async () => {
const child = mockChild()
const managed = managedChild(child)
child.emit('exit', 0, null)
const tree = mockTree(['exited'])

await expect(
proveClaudeChildExit({ child, exitPromise: Promise.resolve(), exited: () => true, tree })
).resolves.toBe(true)
await expect(proveClaudeChildExit({ managed, tree })).resolves.toBe(true)
expect(tree.reap).toHaveBeenCalledTimes(1)
})

it('stays unproven for a root that left before any snapshot could be armed', async () => {
const child = mockChild()
const managed = managedChild(child)
child.emit('exit', 0, null)
const captureDescendants = vi.fn(async () => snapshotOf(4243))
const terminateDescendants = vi.fn()
const tree = createClaudeChildTreeReaper(child, {
Expand All @@ -371,9 +365,7 @@ describe('claude child exit proof', () => {
terminateDescendants
})

await expect(
proveClaudeChildExit({ child, exitPromise: Promise.resolve(), exited: () => true, tree })
).resolves.toBe(false)
await expect(proveClaudeChildExit({ managed, tree })).resolves.toBe(false)
// A dead root's descendants have reparented: walking its pid now could only
// sweep a stranger, so no walk is attempted and nothing is proven.
expect(captureDescendants).not.toHaveBeenCalled()
Expand Down
4 changes: 3 additions & 1 deletion src/main/claude/claude-agent-sdk-exit-proof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,8 @@ export function createClaudeChildTreeReaper(
*/
export function proveClaudeChildExit(input: ClaudeChildExitProofInput): Promise<boolean> {
return proveClaudeChildExitWithReaper(input, () =>
createClaudeChildTreeReaper(input.child, { exited: input.exited })
createClaudeChildTreeReaper(input.managed.child, {
exited: () => input.managed.rootVerdict === 'exited'
})
)
}
31 changes: 10 additions & 21 deletions src/main/claude/claude-agent-sdk-process-spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,32 +121,21 @@ describe('claude agent SDK process spawn', () => {
spawn.spawn(sdkOptions())
expect(spawn.supervised).toBe(specSupervised)

let exited = false
let settle = (): void => {}
const exitPromise = new Promise<void>((resolve) => {
settle = resolve
})
// Claude leaves shortly after stdin ends, so the ladder never needs its forced rung.
const managed = spawn.managed
if (!managed) {
throw new Error('Claude spawner did not retain its managed child')
}
// Claude leaves shortly after stdin ends, before the forced stop.
process.child.stdin.on('finish', () =>
setTimeout(() => {
exited = true
settle()
}, 10)
setTimeout(() => process.child.emit('exit', 0, null), 10)
)
const tree = {
capture: vi.fn(async () => {}),
reap: vi.fn(async () => 'exited' as const),
treeVerdict: 'exited' as const
}
await proveClaudeChildExitWithReaper(
{
child: process.child,
exitPromise,
exited: () => exited,
tree,
supervised: spawn.supervised
},
() => tree
await expect(proveClaudeChildExitWithReaper({ managed, tree }, () => tree)).resolves.toBe(
true
)
// SIGTERM to an unsupervised Claude on Windows is TerminateProcess; a skipped one leaves it running.
if (specSupervised) {
Expand Down Expand Up @@ -176,8 +165,8 @@ describe('claude agent SDK process spawn', () => {
process.child.stderr.write('claude: not signed in')
await new Promise((resolve) => setImmediate(resolve))

expect(spawn.stderrTail).toMatch(/claude: not signed in$/)
expect(spawn.stderrTail.length).toBe(8192)
expect(spawn.managed?.stderrTail()).toMatch(/claude: not signed in$/)
expect(spawn.managed?.stderrTail().length).toBe(8192)
})

it('hands a Windows .cmd shim to Orca\u2019s argument encoder', () => {
Expand Down
59 changes: 25 additions & 34 deletions src/main/claude/claude-agent-sdk-process-spawn.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
import type { SpawnOptions as ClaudeAgentSdkSpawnOptions } from '@anthropic-ai/claude-agent-sdk'
import { spawnProcess } from '../../shared/child-process/run-process'
import { createProviderSpawnSpec } from '../provider-process/provider-process-supervisor'
import {
spawnManagedProviderProcess,
type ManagedProviderProcess
} from '../provider-process/managed-provider-process'
import { claudeChildClosePolicy } from './claude-child-exit-proof-ladder'

/** Derived rather than imported: only src/shared/child-process may name node:child_process. */
type ClaudeCodeChild = ReturnType<typeof spawnProcess>

const STDERR_TAIL_MAX_BYTES = 8192

export type ClaudeCodeProcessSpawn = {
/** Pass as the SDK's `spawnClaudeCodeProcess`; the SDK never learns the pid because it never owns it. */
spawn: (options: ClaudeAgentSdkSpawnOptions) => ClaudeCodeChild
/** The retained child, so Orca keeps its own tree-kill and exit-proof ladder. Null until the SDK spawns. */
readonly child: ClaudeCodeChild | null
readonly managed: ManagedProviderProcess | null
/**
* Ownership proof: the durable lease adjudicates on this pid plus start time plus the spawn
* token. On POSIX it is the provider supervisor's, which outlives Claude by construction.
*/
readonly pid: number | undefined
/** The spawn spec's verdict, so the close ladder never re-decides it. False until the SDK spawns. */
readonly supervised: boolean
readonly stderrTail: string
}

function definedEnv(env: Record<string, string | undefined>): Record<string, string> {
Expand All @@ -46,50 +48,39 @@ export function createClaudeCodeProcessSpawn(
spawnImpl: typeof spawnProcess = spawnProcess,
platform: NodeJS.Platform = process.platform
): ClaudeCodeProcessSpawn {
let child: ClaudeCodeChild | null = null
let stderrTail = ''
let supervised = false
let managed: ManagedProviderProcess | null = null
return {
spawn: (options) => {
const spec = createProviderSpawnSpec(
// SDK abort would kill the child outside Orca's ladder, losing observed exit proof.
managed = spawnManagedProviderProcess(
{
command: options.command,
args: [...options.args],
...(options.cwd === undefined ? {} : { cwd: options.cwd })
},
definedEnv(options.env),
platform
{
spawnImpl,
platform,
inheritedEnv: definedEnv(options.env),
site: 'claude-stream-json-teardown',
policy: claudeChildClosePolicy,
acceptClose: (result) => result.root === 'exited' && result.tree === 'exited'
}
)
// Why `options.signal` is dropped: it would let the SDK kill the child outside
// Orca's ladder, and close() may never report an exit it did not observe.
const spawned = spawnImpl({
program: spec.program,
args: spec.args,
cwd: spec.cwd,
env: spec.env,
detached: spec.detached,
stdio: ['pipe', 'pipe', 'pipe']
})
child = spawned
supervised = spec.supervised
// The SDK drains stderr only for its own local spawn, so a custom spawner must:
// otherwise the child blocks on a full pipe and exit errors lose their tail.
spawned.stderr.setEncoding('utf8').on('data', (chunk: string) => {
stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_MAX_BYTES)
})
return spawned
// The SDK drains stderr only for its own local spawn; the managed process drains it here.
return managed.child
},
get child() {
return child
return managed?.child ?? null
},
get managed() {
return managed
},
get pid() {
return child?.pid
return managed?.child.pid
},
get supervised() {
return supervised
},
get stderrTail() {
return stderrTail
return managed?.supervised ?? false
}
}
}
31 changes: 31 additions & 0 deletions src/main/claude/claude-child-exit-proof-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { EventEmitter } from 'node:events'
import type { spawnProcess, SpawnedProcess } from '../../shared/child-process/run-process'
import {
spawnManagedProviderProcess,
type ManagedProviderProcess
} from '../provider-process/managed-provider-process'
import { claudeChildClosePolicy } from './claude-child-exit-proof-ladder'

const managedChildren = new WeakMap<object, ManagedProviderProcess>()

export function managedChild(
child: Pick<SpawnedProcess, 'pid' | 'kill' | 'stdin' | 'stderr'> & EventEmitter
): ManagedProviderProcess {
const existing = managedChildren.get(child)
if (existing) {
return existing
}
const managed = spawnManagedProviderProcess(
{ command: 'fixture-provider', args: [] },
{
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The managed process reads only the fixture's owned pid, events, kill, stdin and stderr.
spawnImpl: () => child as ReturnType<typeof spawnProcess>,
platform: 'win32',
site: 'claude-proof-fixture',
policy: claudeChildClosePolicy,
acceptClose: (result) => result.root === 'exited' && result.tree === 'exited'
}
)
managedChildren.set(child, managed)
return managed
}
Loading
Loading