fix(claude): never let a later failed look collapse an observed live descendant into unverifiable

The reaper's single assignment site latched only 'exited', so a second reap
whose table reads all missed their deadline overwrote an earlier completed
verification's 'live' with 'unverifiable'. The acquisition release gate
discriminates on exactly that pair, so a root exit after such a decay released
the lease over a descendant that had been observed alive. The latch is now
monotone in trust order: exited is final, and live is only ever raised to exited.

Claude-Session: https://claude.ai/code/session_01HfdhsvSJucLw4cTZxzg2CP
This commit is contained in:
Merge Sim
2026-09-02 04:20:35 -07:00
parent d25e3eb31f
commit 59b019c77e
2 changed files with 34 additions and 2 deletions
@@ -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
+13 -2
View File
@@ -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<DescendantTreeVerdict, number> = {
unverifiable: 0,
live: 1,
exited: 2
}
type ReapableChild = Pick<SpawnedProcess, 'pid' | 'kill'>
/** 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