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 aa19e24e1f4..76b1c6cb279 100644 --- a/src/main/claude/claude-agent-sdk-exit-proof.test.ts +++ b/src/main/claude/claude-agent-sdk-exit-proof.test.ts @@ -413,6 +413,27 @@ describe('claude child tree reaper', () => { expect(tree.treeVerdict).toBe('exited') }) + it('keeps an observed live descendant when a later re-read cannot see the table', async () => { + 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. + const terminateDescendants = vi + .fn() + .mockResolvedValueOnce('live') + .mockResolvedValueOnce('unverifiable') + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants: vi.fn(async () => snapshotOf(4243)), + terminateDescendants + }) + + await expect(tree.reap()).resolves.toBe('live') + await expect(tree.reap()).resolves.toBe('unverifiable') + expect(tree.treeVerdict).toBe('live') + }) + it('treats an unreadable process table as unproven and re-walks the live root', async () => { const child = mockChild() // A loaded host can miss the table's deadline; while the root still lives diff --git a/src/main/claude/claude-agent-sdk-exit-proof.ts b/src/main/claude/claude-agent-sdk-exit-proof.ts index eac4b1a95ab..3095107fe3f 100644 --- a/src/main/claude/claude-agent-sdk-exit-proof.ts +++ b/src/main/claude/claude-agent-sdk-exit-proof.ts @@ -15,6 +15,17 @@ import { terminateWindowsProcessTree } from '../windows-process-tree-kill' const GRACEFUL_EXIT_MS = 1_500 const FORCED_EXIT_MS = 1_000 +/** + * 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. + */ +const TREE_VERDICT_TRUST: Record = { + unverifiable: 0, + live: 1, + exited: 2 +} + type ReapableChild = Pick /** One platform's descendant tree, tagged so neither verifier can be handed the other's rows. */ @@ -191,8 +202,8 @@ export function createClaudeChildTreeReaper( const attempt = judgeTree() .catch((): DescendantTreeVerdict => 'unverifiable') .then((verdict) => { - // An observed exit is final; anything later can only be a stale re-read. - treeVerdict = treeVerdict === 'exited' ? 'exited' : verdict + treeVerdict = + TREE_VERDICT_TRUST[verdict] > TREE_VERDICT_TRUST[treeVerdict] ? verdict : treeVerdict return verdict }) inFlight = attempt