Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
108 changes: 108 additions & 0 deletions src/main/claude/claude-released-child-cleanup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection'
import {
ClaudeReleasedChildCleanup,
type ClaudeReleasedChildCleanupReport
} from './claude-released-child-cleanup'
import { fakeClaude } from './claude-structured-session-test-support'

async function releasedChild(
closeResults: boolean[],
verdict: ClaudeStreamJsonConnection['exitVerdict'] = { root: 'exited', tree: 'unverifiable' }
) {
const connection = await fakeClaude({ unprovenCloseVerdict: verdict }).openConnection({
pathToClaudeCodeExecutable: 'claude',
options: {},
cwd: '/work/repo'
})
const close = vi.fn(async () => closeResults.shift() ?? false)
connection.close = close
return { connection, close }
}

describe('ClaudeReleasedChildCleanup', () => {
let reports: ClaudeReleasedChildCleanupReport[]
let cleanup: ClaudeReleasedChildCleanup

beforeEach(() => {
vi.useFakeTimers()
reports = []
cleanup = new ClaudeReleasedChildCleanup({
retryDelaysMs: [10, 20],
report: (report) => reports.push(report)
})
})

afterEach(() => {
vi.useRealTimers()
})

it('gives up after its schedule and reports the verdict it last saw', async () => {
const { connection, close } = await releasedChild([])
cleanup.adopt('session-1', connection)

await vi.advanceTimersByTimeAsync(10)
expect(close).toHaveBeenCalledTimes(1)
expect(reports).toEqual([])
await vi.advanceTimersByTimeAsync(20)
expect(close).toHaveBeenCalledTimes(2)

expect(reports).toEqual([
{ sessionId: 'session-1', pid: 4321, verdict: { root: 'exited', tree: 'unverifiable' } }
])
expect(cleanup.size).toBe(0)
await vi.advanceTimersByTimeAsync(1_000)
expect(close).toHaveBeenCalledTimes(2)
})

it('stops as soon as a retry proves the tree gone', async () => {
const { connection, close } = await releasedChild([true])
cleanup.adopt('session-1', connection)

await vi.advanceTimersByTimeAsync(100)

expect(close).toHaveBeenCalledTimes(1)
expect(reports).toEqual([])
expect(cleanup.size).toBe(0)
})

it('tells its owner once whether a retry proved the tree or the schedule gave up', async () => {
const provenChild = await releasedChild([true])
const stuckChild = await releasedChild([])
const settled: [string, boolean][] = []
cleanup.adopt('session-1', provenChild.connection, (proven) => settled.push(['s1', proven]))
cleanup.adopt('session-2', stuckChild.connection, (proven) => settled.push(['s2', proven]))

await vi.advanceTimersByTimeAsync(10)
expect(settled).toEqual([['s1', true]])
await vi.advanceTimersByTimeAsync(20)

expect(settled).toEqual([
['s1', true],
['s2', false]
])
expect(reports.map((report) => report.sessionId)).toEqual(['session-2'])
})

it('never adopts a child whose tree is already proven', async () => {
const { connection } = await releasedChild([], { root: 'exited', tree: 'exited' })
cleanup.adopt('session-1', connection)

expect(cleanup.size).toBe(0)
})

it('makes one final attempt at shutdown and reports without throwing', async () => {
const unproven = await releasedChild([false])
const proven = await releasedChild([true])
cleanup.adopt('session-1', unproven.connection)
cleanup.adopt('session-2', proven.connection)

await expect(cleanup.closeAll()).resolves.toBeUndefined()

expect(unproven.close).toHaveBeenCalledOnce()
expect(proven.close).toHaveBeenCalledOnce()
expect(reports.map((report) => report.sessionId)).toEqual(['session-1'])
await vi.advanceTimersByTimeAsync(1_000)
expect(unproven.close).toHaveBeenCalledOnce()
})
})
151 changes: 151 additions & 0 deletions src/main/claude/claude-released-child-cleanup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import type {
ClaudeChildExitVerdict,
ClaudeStreamJsonConnection
} from './claude-stream-json-connection'

/** Each retry re-runs the connection's own close ladder, which re-verifies its snapshot. */
export const CLAUDE_RELEASED_CHILD_RETRY_DELAYS_MS: readonly number[] = [5_000, 30_000, 120_000]

export type ClaudeReleasedChildCleanupReport = {
sessionId: string
pid: number | undefined
verdict: ClaudeChildExitVerdict
}

/** Told once whether a retry proved the tree gone (`true`) or the schedule gave up (`false`). */
export type ClaudeReleasedChildSettled = (treeProven: boolean) => void

type PendingCleanup = {
sessionId: string
attempts: number
timer: ReturnType<typeof setTimeout> | undefined
onSettled: ClaudeReleasedChildSettled | undefined
}

function reportUnverifiedChild(report: ClaudeReleasedChildCleanupReport): void {
console.warn('[claude-structured-session] released child tree was not verified gone', report)
}

function treeProven(verdict: ClaudeChildExitVerdict): boolean {
return verdict.root === 'processless' || (verdict.root === 'exited' && verdict.tree === 'exited')
}

/**
* Descendant verification for children whose lease the host already released. It is keyed by
* connection and nothing consults it before acquiring, so it can never gate a resume. It gives up
* after a fixed schedule and reports what it last observed, never claiming the tree gone.
*/
export class ClaudeReleasedChildCleanup {
private readonly pending = new Map<ClaudeStreamJsonConnection, PendingCleanup>()
private closed = false
private readonly retryDelaysMs: readonly number[]
private readonly report: (report: ClaudeReleasedChildCleanupReport) => void

constructor(
options: {
retryDelaysMs?: readonly number[]
report?: (report: ClaudeReleasedChildCleanupReport) => void
} = {}
) {
this.retryDelaysMs = options.retryDelaysMs ?? CLAUDE_RELEASED_CHILD_RETRY_DELAYS_MS
this.report = options.report ?? reportUnverifiedChild
}

get size(): number {
return this.pending.size
}

adopt(
sessionId: string,
connection: ClaudeStreamJsonConnection,
onSettled?: ClaudeReleasedChildSettled
): void {
if (this.pending.has(connection) || treeProven(connection.exitVerdict)) {
return
}
const entry: PendingCleanup = { sessionId, attempts: 0, timer: undefined, onSettled }
if (this.closed) {
// Shutdown already ran its final pass; this child still gets exactly one.
void this.finalAttempt(connection, entry)
return
}
this.pending.set(connection, entry)
this.schedule(connection, entry)
}

/** One last bounded attempt per child, then report whatever stays unverified. Never throws. */
async closeAll(): Promise<void> {
this.closed = true
const entries = [...this.pending]
this.pending.clear()
await Promise.all(
entries.map(([connection, entry]) => {
clearTimeout(entry.timer)
return this.finalAttempt(connection, entry)
})
)
}

private schedule(connection: ClaudeStreamJsonConnection, entry: PendingCleanup): void {
const delay = this.retryDelaysMs[entry.attempts]
if (delay === undefined) {
this.pending.delete(connection)
this.reportUnverified(connection, entry)
this.settle(entry, false)
return
}
entry.timer = setTimeout(() => void this.attempt(connection, entry), delay)
entry.timer.unref?.()
}

private async attempt(
connection: ClaudeStreamJsonConnection,
entry: PendingCleanup
): Promise<void> {
entry.timer = undefined
entry.attempts += 1
const proven = await connection.close().catch(() => false)
if (this.pending.get(connection) !== entry) {
return
}
if (proven) {
this.pending.delete(connection)
this.settle(entry, true)
return
}
this.schedule(connection, entry)
}

private async finalAttempt(
connection: ClaudeStreamJsonConnection,
entry: PendingCleanup
): Promise<void> {
const proven = await connection.close().catch(() => false)
if (!proven) {
this.reportUnverified(connection, entry)
}
this.settle(entry, proven)
}

private settle(entry: PendingCleanup, treeProven: boolean): void {
const onSettled = entry.onSettled
entry.onSettled = undefined
try {
onSettled?.(treeProven)
} catch {
// The owner's settlement is its own; cleanup still ends here.
}
}

private reportUnverified(connection: ClaudeStreamJsonConnection, entry: PendingCleanup): void {
try {
this.report({
sessionId: entry.sessionId,
pid: connection.pid,
verdict: connection.exitVerdict
})
} catch {
// Reporting is bookkeeping; a failed report must not surface as a close failure.
}
}
}
8 changes: 7 additions & 1 deletion src/main/claude/claude-structured-acquisition-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ export async function resolveClaudeAcquisitionLaunch(args: {
)
}
acquisitions.assertCurrent(sessionId, attempt)
let resumeSession = sessions.get(sessionId)
// A child whose lease the host already released is retired, never closed again: its tree
// proof is cleanup, not a precondition for this resume.
let resumeSession =
callbacks.retireSuperseded(sessionId, input.fence) ?? sessions.get(sessionId)
if (!(await closeClaudePublishedSessionForDeps(sessions, sessionId, deps))) {
throw new AgentSessionAcquisitionExitUnprovenError(
new Error(`claude session ${sessionId} could not be stopped`)
Expand All @@ -54,6 +57,9 @@ export async function resolveClaudeAcquisitionLaunch(args: {
}
// The superseded child must settle before its durable resume identity is reused.
await callbacks.settleExit(sessionId, retainedExit)
if (exits.get(sessionId) === retainedExit) {
exits.delete(sessionId)
}
resumeSession ??= retainedExit.session
}
acquisitions.assertCurrent(sessionId, attempt)
Expand Down
Loading
Loading