diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index deee669865c..441112c2105 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -4604,7 +4604,6 @@ "src/main/window/terminal-tab-close-request-relay.test.ts": 55, "src/main/window/updater-package-recovery-ipc.test.ts": 4430, "src/main/window/window-close-decision.test.ts": 41, - "src/main/windows-descendant-exit-verification.test.ts": 100, "src/main/windows-live-tree-kill.win32.test.ts": 46, "src/main/windows-process-tree-kill.test.ts": 61, "src/main/windows-pty-root-identity.test.ts": 94, diff --git a/docs/reference/windows-process-enumeration.md b/docs/reference/windows-process-enumeration.md index affa11f7e4e..432a1fe4690 100644 --- a/docs/reference/windows-process-enumeration.md +++ b/docs/reference/windows-process-enumeration.md @@ -374,10 +374,9 @@ relay uses the scan and the WMI launch fallback. `node_addon_api.gyp` resolves outside the repo and hourly Windows builds die at configure. `node-pty` is patched the same way for the same reason. 4. **No PEB reads, no `PROCESS_VM_READ`.** See below. -5. **The `CreationTime` flag (4).** Upstream exposes no process start time, and - `isWindowsProcessStartTimeAvailable()` gates structured Claude and Codex - chat on it, so without this change win32 silently fell back to the legacy - transcript path. `GetProcessCreationTime` opens +5. **The `CreationTime` flag (4).** Upstream exposes no process start time. + Structured Claude and Codex chat no longer depend on it; the owner probe and + the orcad runtime preflight still read it. `GetProcessCreationTime` opens `PROCESS_QUERY_LIMITED_INFORMATION` and converts `GetProcessTimes`' FILETIME to Unix ms; a process that denies the handle is emitted with the field absent, never zero, because callers must be able to tell "cannot identify" @@ -395,9 +394,9 @@ relay uses the scan and the WMI launch fallback. so itself. Two readers depend on it. `isWindowsProcessStartTimeAvailable()` returns - false unless this bit is set, because claiming otherwise leaves - `captureWindowsDescendantSnapshot` returning null forever while structured - chat believes it has a reaper. And `windows-process-tree-creation-time.cjs` + false unless this bit is set, so the owner probe never scans the whole table + for times it cannot get (the Windows Claude descendant snapshot that once + relied on it is gone). And `windows-process-tree-creation-time.cjs` asserts it during install, which is what forces a from-source rebuild — the same role `node-pty-job-ownership.cjs` plays for node-pty's job exports. diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts index bd69aba3f1a..7b3105b07d8 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -207,7 +207,7 @@ async function readStructuredCreateSupport( /** * Applies the executing host's `agentSession.createSupport` answer, which is the authority on WSL, - * remoteness and the Windows process-start-time gate for the resolved workspace. + * remoteness and per-agent support for the resolved workspace. */ export function downgradeAgentLaunchModeForHost( receipt: AgentLaunchModeReceipt, diff --git a/src/main/claude/claude-agent-sdk-exit-proof-identity.test.ts b/src/main/claude/claude-agent-sdk-exit-proof-identity.test.ts index 04d58067353..24ec5ba9bb0 100644 --- a/src/main/claude/claude-agent-sdk-exit-proof-identity.test.ts +++ b/src/main/claude/claude-agent-sdk-exit-proof-identity.test.ts @@ -1,9 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import type { DescendantSnapshot } from '../pty-descendant-termination' -import type { WindowsDescendantSnapshot } from '../windows-descendant-exit-verification' import { collectDescendantRows } from '../pty-descendant-termination' import { createClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof' -import { mergeClaudeCapturedTrees } from './claude-child-tree-snapshot' +import { mergeClaudeDescendantSnapshots } from './claude-child-tree-snapshot' function posixSnapshot(capturedAtMs: number): DescendantSnapshot { return { @@ -14,43 +13,28 @@ function posixSnapshot(capturedAtMs: number): DescendantSnapshot { } } -function windowsSnapshot(): WindowsDescendantSnapshot { - return { - root: { pid: 100, creationTimeMs: 5 }, - descendants: [{ pid: 200, creationTimeMs: 7 }], - unidentifiedCount: 0, - capturedAtMs: 1 - } -} - describe('Claude child root identity', () => { it('keeps a retained row boundary when a refresh observes no new descendants', () => { const previous = posixSnapshot(1_700_000_000_900) const next = posixSnapshot(1_700_000_002_100) - expect( - mergeClaudeCapturedTrees( - { platform: 'posix', tree: previous }, - { platform: 'posix', tree: next } - ) - ).toEqual({ - platform: 'posix', - tree: { ...next, capturedAtMsByPid: { '200': previous.capturedAtMs } } + expect(mergeClaudeDescendantSnapshots(previous, next)).toEqual({ + ...next, + capturedAtMsByPid: { '200': previous.capturedAtMs } }) }) - it('keeps the descendant verdict when a POSIX root probe is unavailable', async () => { + it('keeps the descendant verdict alongside the POSIX root kill', async () => { const child = { pid: 100, kill: vi.fn(() => true) } const terminateDescendants = vi.fn(async () => 'exited' as const) const tree = createClaudeChildTreeReaper(child, { platform: 'linux', captureDescendants: vi.fn(async () => posixSnapshot(1)), - terminateDescendants, - verifyRootIdentity: vi.fn(async () => false) + terminateDescendants }) - // POSIX runs no bare-pid root operation, so a declined probe withholds - // nothing: the handle kill still lands and the verification still speaks. + // POSIX runs no bare-pid root operation: the handle kill lands and the + // verification still speaks. await expect(tree.reap()).resolves.toBe('exited') expect(terminateDescendants).toHaveBeenCalled() expect(child.kill).toHaveBeenCalledWith('SIGKILL') @@ -72,8 +56,7 @@ describe('Claude child root identity', () => { 1 ) ), - terminateDescendants, - verifyRootIdentity: vi.fn(async () => true) + terminateDescendants }) await expect(tree.reap()).resolves.toBe('unverifiable') @@ -82,23 +65,4 @@ describe('Claude child root identity', () => { expect(terminateDescendants).not.toHaveBeenCalled() expect(child.kill).toHaveBeenCalledWith('SIGKILL') }) - - it('fails closed when Windows root identity revalidation is unavailable', async () => { - const child = { pid: 100, kill: vi.fn(() => true) } - const terminateWindowsTree = vi.fn(async () => {}) - const terminateWindowsDescendants = vi.fn(async () => 'exited' as const) - const tree = createClaudeChildTreeReaper(child, { - platform: 'win32', - captureWindowsDescendants: vi.fn(async () => windowsSnapshot()), - terminateWindowsTree, - terminateWindowsDescendants, - verifyRootIdentity: vi.fn(async () => false) - }) - - await expect(tree.reap()).resolves.toBe('unverifiable') - // taskkill /T /F addresses a bare pid and stays gated; the handle does not. - expect(terminateWindowsTree).not.toHaveBeenCalled() - expect(terminateWindowsDescendants).not.toHaveBeenCalled() - expect(child.kill).toHaveBeenCalledWith('SIGKILL') - }) }) 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 e27e74046d6..219faa221fa 100644 --- a/src/main/claude/claude-agent-sdk-exit-proof.test.ts +++ b/src/main/claude/claude-agent-sdk-exit-proof.test.ts @@ -5,9 +5,8 @@ import { describe, expect, it, vi } from 'vitest' import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process' import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification' import type { DescendantSnapshot } from '../pty-descendant-termination' -import type { WindowsDescendantSnapshot } from '../windows-descendant-exit-verification' import { - createClaudeChildTreeReaper as createClaudeChildTreeReaperImpl, + createClaudeChildTreeReaper, proveClaudeChildExit, type ClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof' @@ -131,12 +130,16 @@ function mockChild( } /** A tree whose verdict is scripted per reap, recording when it was armed. */ -function mockTree(verdicts: DescendantTreeVerdict[]): ClaudeChildTreeReaper & { +function mockTree( + verdicts: DescendantTreeVerdict[], + forcedReapAttempted = false +): ClaudeChildTreeReaper & { capture: ReturnType reap: ReturnType } { let treeVerdict: DescendantTreeVerdict = 'unverifiable' return { + forcedReapAttempted, capture: vi.fn(async () => {}), reap: vi.fn(async () => { treeVerdict = verdicts.shift() ?? treeVerdict @@ -148,15 +151,6 @@ function mockTree(verdicts: DescendantTreeVerdict[]): ClaudeChildTreeReaper & { } } -function windowsSnapshotOf(descendantPid: number): WindowsDescendantSnapshot { - return { - root: { pid: 424242, creationTimeMs: 1_700_000_000_001 }, - descendants: [{ pid: descendantPid, creationTimeMs: 1_700_000_000_000 }], - unidentifiedCount: 0, - capturedAtMs: 1 - } -} - function snapshotOf(descendantPid: number): DescendantSnapshot { return { root: { pid: 424242, startedAt: 'Mon Jan 1 00:00:00 2026' }, @@ -168,18 +162,6 @@ function snapshotOf(descendantPid: number): DescendantSnapshot { } } -// Unit tests use synthetic process ids; production always supplies the fresh -// identity probe, so the harness explicitly models a matching probe. -function createClaudeChildTreeReaper( - child: Parameters[0], - deps: Parameters[1] = {} -): ReturnType { - return createClaudeChildTreeReaperImpl(child, { - verifyRootIdentity: async () => true, - ...deps - }) -} - describe('claude child exit proof', () => { it.runIf(process.platform !== 'win32')( 'reports a proven exit only once a SIGTERM-resistant descendant is gone at the close boundary', @@ -283,6 +265,7 @@ describe('claude child exit proof', () => { exitedWhenArmed = managed.rootVerdict === 'exited' }) + // The fixture closes under POSIX rules; on Windows a self-exit after stdin end is the close. 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. @@ -352,6 +335,17 @@ describe('claude child exit proof', () => { expect(tree.reap).toHaveBeenCalledTimes(1) }) + it('on Windows re-asks the tree on a retried close once a forced reap has run', async () => { + const child = mockChild() + const managed = managedChild(child, 'win32') + child.emit('exit', 0, null) + // The earlier close forced a reap whose taskkill failed; the root has since exited. + const tree = mockTree(['unverifiable'], true) + + await expect(proveClaudeChildExit({ managed, tree })).resolves.toBe(false) + 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) @@ -597,35 +591,6 @@ describe('claude child tree reaper', () => { expect(child.kill).toHaveBeenCalledWith('SIGKILL') }) - it('fails closed when a Windows refresh reuses a PID with a new creation time', async () => { - const child = mockChild() - const first = windowsSnapshotOf(4243) - const replacement = { - ...first, - descendants: [{ pid: 4243, creationTimeMs: first.descendants[0].creationTimeMs + 1 }] - } - const captureWindowsDescendants = vi - .fn() - .mockResolvedValueOnce(first) - .mockResolvedValueOnce(replacement) - const terminateWindowsTree = vi.fn(async () => {}) - const terminateWindowsDescendants = vi.fn(async () => 'exited' as const) - const tree = createClaudeChildTreeReaper(child, { - platform: 'win32', - captureWindowsDescendants, - terminateWindowsTree, - terminateWindowsDescendants - }) - - await tree.capture() - await tree.refresh?.() - - await expect(tree.reap()).resolves.toBe('unverifiable') - expect(terminateWindowsTree).not.toHaveBeenCalled() - expect(terminateWindowsDescendants).not.toHaveBeenCalled() - expect(child.kill).toHaveBeenCalledWith('SIGKILL') - }) - it('queues a fresh boundary behind an output-triggered capture already in flight', async () => { const child = mockChild() const firstDone = Promise.withResolvers() @@ -690,39 +655,6 @@ describe('claude child tree reaper', () => { }) }) - it('retains a Windows replacement descendant while preserving unidentified rows', async () => { - const child = mockChild() - const first = windowsSnapshotOf(4243) - const replacement = { - ...windowsSnapshotOf(4244), - unidentifiedCount: 0 - } - const captureWindowsDescendants = vi - .fn() - .mockResolvedValueOnce({ ...first, unidentifiedCount: 1 }) - .mockResolvedValueOnce(replacement) - const terminateWindowsTree = vi.fn(async () => {}) - const terminateWindowsDescendants = vi.fn(async (snapshot: WindowsDescendantSnapshot) => - snapshot.descendants.some((row) => row.pid === 4244) ? ('live' as const) : ('exited' as const) - ) - const tree = createClaudeChildTreeReaper(child, { - platform: 'win32', - captureWindowsDescendants, - terminateWindowsTree, - terminateWindowsDescendants - }) - - await tree.capture() - await tree.refresh?.() - await expect(tree.reap()).resolves.toBe('live') - - expect(terminateWindowsDescendants).toHaveBeenCalledWith({ - ...replacement, - descendants: [...first.descendants, ...replacement.descendants], - unidentifiedCount: 1 - }) - }) - it('retains the prior identity-safe snapshot when a refresh is partial', async () => { const child = mockChild() const first = { @@ -832,106 +764,71 @@ describe('claude child tree reaper', () => { expect(child.kill).toHaveBeenCalledWith('SIGKILL') }) - it('waits for the Windows tree kill before releasing the root', async () => { + it('kills a held Windows root with one taskkill and never snapshots it', async () => { const child = mockChild() - const release = Promise.withResolvers() + const release = Promise.withResolvers() const terminateWindowsTree = vi.fn(() => release.promise) const captureDescendants = vi.fn() - const terminateWindowsDescendants = vi.fn(async () => 'exited' as const) const tree = createClaudeChildTreeReaper(child, { platform: 'win32', captureDescendants, - captureWindowsDescendants: vi.fn(async () => windowsSnapshotOf(4243)), - terminateWindowsTree, - terminateWindowsDescendants - }) - - const reap = tree.reap() - await vi.waitFor(() => - expect(terminateWindowsTree).toHaveBeenCalledWith({ - pid: 424242, - creationTimeMs: 1_700_000_000_001 - }) - ) - expect(child.kill).not.toHaveBeenCalled() - expect(terminateWindowsDescendants).not.toHaveBeenCalled() - release.resolve() - await expect(reap).resolves.toBe('exited') - expect(child.kill).toHaveBeenCalledWith('SIGKILL') - expect(terminateWindowsDescendants).toHaveBeenCalledWith(windowsSnapshotOf(4243)) - expect(captureDescendants).not.toHaveBeenCalled() - }) - - it('stays unproven on Windows when taskkill fails and a descendant is still observed', async () => { - const child = mockChild() - const tree = createClaudeChildTreeReaper(child, { - platform: 'win32', - captureWindowsDescendants: vi.fn(async () => windowsSnapshotOf(4243)), - terminateWindowsTree: vi.fn(async () => { - throw new Error('taskkill: access denied') - }), - terminateWindowsDescendants: vi.fn(async () => 'live' as const) - }) - - // taskkill's own outcome is not the proof; the table read after it is. - await expect(tree.reap()).resolves.toBe('live') - expect(tree.treeVerdict).toBe('live') - expect(child.kill).toHaveBeenCalledWith('SIGKILL') - }) - - it('stays unproven on Windows when taskkill resolves but a descendant survives it', async () => { - const child = mockChild() - const terminateWindowsTree = vi.fn(async () => {}) - const tree = createClaudeChildTreeReaper(child, { - platform: 'win32', - captureWindowsDescendants: vi.fn(async () => windowsSnapshotOf(4243)), - terminateWindowsTree, - terminateWindowsDescendants: vi.fn(async () => 'live' as const) - }) - - await expect(tree.reap()).resolves.toBe('live') - expect(terminateWindowsTree).toHaveBeenCalledTimes(1) - expect(tree.treeVerdict).toBe('live') - }) - - it('never taskkills a Windows root that already exited, but still verifies its snapshot', async () => { - const child = mockChild() - let exited = false - const terminateWindowsTree = vi.fn(async () => {}) - const terminateWindowsDescendants = vi.fn(async () => 'exited' as const) - const tree = createClaudeChildTreeReaper(child, { - platform: 'win32', - exited: () => exited, - captureWindowsDescendants: vi.fn(async () => windowsSnapshotOf(4243)), - terminateWindowsTree, - terminateWindowsDescendants + terminateWindowsTree }) await tree.capture() - exited = true - await expect(tree.reap()).resolves.toBe('exited') - // A dead root's pid may already belong to a stranger: taskkill /T /F on it - // would take down an unrelated tree. - expect(terminateWindowsTree).not.toHaveBeenCalled() - expect(terminateWindowsDescendants).toHaveBeenCalledWith(windowsSnapshotOf(4243)) + await tree.refresh?.() + const reap = tree.reap() + await vi.waitFor(() => expect(terminateWindowsTree).toHaveBeenCalledWith(424242)) + expect(child.kill).not.toHaveBeenCalled() + release.resolve(true) + // taskkill's clean exit is the one outcome that reports every process in the tree terminated. + await expect(reap).resolves.toBe('exited') + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + // No creation-time snapshot: a host whose table has no creation times reaps the same way. + expect(captureDescendants).not.toHaveBeenCalled() }) - it('treats an unreadable Windows table as unproven', async () => { + it('reports a Windows tree unverifiable when taskkill does not exit cleanly', async () => { const child = mockChild() - const terminateWindowsDescendants = vi.fn() const tree = createClaudeChildTreeReaper(child, { platform: 'win32', - captureWindowsDescendants: vi.fn(async () => null), - terminateWindowsTree: vi.fn(async () => {}), - terminateWindowsDescendants + terminateWindowsTree: vi.fn(async () => false) }) await expect(tree.reap()).resolves.toBe('unverifiable') - expect(terminateWindowsDescendants).not.toHaveBeenCalled() - // A host that cannot supply creation times blocks taskkill, not the root kill. + expect(tree.treeVerdict).toBe('unverifiable') + // The held handle still takes the root down. expect(child.kill).toHaveBeenCalledWith('SIGKILL') }) + it('reports a Windows tree unverifiable when taskkill could not run', async () => { + const child = mockChild() + const tree = createClaudeChildTreeReaper(child, { + platform: 'win32', + terminateWindowsTree: vi.fn(async () => { + throw new Error('taskkill: spawn failed') + }) + }) + + await expect(tree.reap()).resolves.toBe('unverifiable') + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('never taskkills a Windows root that already exited', async () => { + const child = mockChild() + const terminateWindowsTree = vi.fn(async () => true) + const tree = createClaudeChildTreeReaper(child, { + platform: 'win32', + exited: () => true, + terminateWindowsTree + }) + + // Its pid left with it, so its tree can no longer be addressed. + await expect(tree.reap()).resolves.toBe('unverifiable') + expect(terminateWindowsTree).not.toHaveBeenCalled() + expect(child.kill).not.toHaveBeenCalled() + }) + it('has nothing to reap for a child that never spawned', async () => { const child = mockChild(null) const captureDescendants = vi.fn() diff --git a/src/main/claude/claude-agent-sdk-exit-proof.ts b/src/main/claude/claude-agent-sdk-exit-proof.ts index 82db647cb5a..ec31475c0a6 100644 --- a/src/main/claude/claude-agent-sdk-exit-proof.ts +++ b/src/main/claude/claude-agent-sdk-exit-proof.ts @@ -3,21 +3,10 @@ import { terminateDescendantSnapshotWithVerdict, type DescendantTreeVerdict } from '../pty-descendant-exit-verification' -import { - captureDescendantSnapshot, - type DescendantSnapshot, - type PosixProcessIdentity -} from '../pty-descendant-termination' -import { - captureWindowsDescendantSnapshot, - terminateIdentifiedWindowsProcessTree, - verifyWindowsDescendantSnapshotExit, - verifyWindowsProcessIdentity, - type WindowsDescendantSnapshot, - type WindowsProcessIdentity -} from '../windows-descendant-exit-verification' -import { mergeClaudeCapturedTrees, type ClaudeCapturedTree } from './claude-child-tree-snapshot' -import { terminateClaudeRoot, terminateClaudeWindowsRoot } from './claude-child-root-termination' +import { captureDescendantSnapshot, type DescendantSnapshot } from '../pty-descendant-termination' +import { terminateWindowsProcessTree } from '../windows-process-tree-kill' +import { mergeClaudeDescendantSnapshots } from './claude-child-tree-snapshot' +import { terminateClaudeRoot } from './claude-child-root-termination' import { proveClaudeChildExitWithReaper, type ClaudeChildExitProofInput @@ -37,24 +26,19 @@ const TREE_VERDICT_TRUST: Record = { type ReapableChild = Pick /** - * A walk is only admissible while the root it walked was alive. A POSIX walk - * that found no root says so with a null pgid; either platform's walk can also - * have raced the root's death. Both can only have missed descendants that - * already reparented away, so neither is evidence about the tree. + * A walk is only admissible while the root it walked was alive. A walk that + * found no root says so with a null pgid; it can also have raced the root's + * death. Both can only have missed descendants that already reparented away, + * so neither is evidence about the tree. */ function admissibleTree( - captured: DescendantSnapshot | WindowsDescendantSnapshot | null, - platform: NodeJS.Platform, + captured: DescendantSnapshot | null, exited: boolean -): ClaudeCapturedTree | null { +): DescendantSnapshot | null { if (!captured || exited) { return null } - if (platform === 'win32') { - return { platform: 'win32', tree: captured as WindowsDescendantSnapshot } - } - const tree = captured as DescendantSnapshot - return tree.rootPgid === null ? null : { platform: 'posix', tree } + return captured.rootPgid === null ? null : captured } export type ClaudeChildTreeReaperDeps = { @@ -63,13 +47,8 @@ export type ClaudeChildTreeReaperDeps = { exited?: () => boolean captureDescendants?: (rootPid: number) => Promise terminateDescendants?: (snapshot: DescendantSnapshot) => Promise - terminateWindowsTree?: (root: WindowsProcessIdentity) => Promise - captureWindowsDescendants?: (rootPid: number) => Promise - terminateWindowsDescendants?: ( - snapshot: WindowsDescendantSnapshot - ) => Promise - /** Identity probe for the bare-pid tree kill; only Windows has one to gate. */ - verifyRootIdentity?: (root: PosixProcessIdentity | WindowsProcessIdentity) => Promise + /** `taskkill /T /F` on the held root; true only when taskkill reports the tree terminated. */ + terminateWindowsTree?: (rootPid: number) => Promise } export type ClaudeChildTreeReaper = { @@ -88,11 +67,13 @@ export type ClaudeChildTreeReaper = { */ reap(): Promise /** - * `unverifiable` until a reap observes otherwise. `exited` is the only verdict - * that proves a close; `live` names a descendant that was seen still running, - * which no later caller may collapse into "unknown". + * `unverifiable` until a reap answers otherwise. `exited` is observed on POSIX + * and is taskkill's own report on Windows; `live` names a descendant that was + * seen still running, which no later caller may collapse into "unknown". */ readonly treeVerdict: DescendantTreeVerdict + /** A reap has reached the root while it lived: its exit since then is no longer its own. */ + readonly forcedReapAttempted: boolean } /** @@ -110,24 +91,24 @@ export function createClaudeChildTreeReaper( ): ClaudeChildTreeReaper { const platform = deps.platform ?? process.platform const exited = deps.exited ?? (() => false) + // Windows reaches the tree through the held root instead, so it never snapshots. + const snapshots = platform !== 'win32' + const capture = deps.captureDescendants ?? captureDescendantSnapshot // Undefined until captured; null when no admissible snapshot exists — the root // was already gone, or the table could not be read while it was alive — which // no later read can make up for. - let snapshot: ClaudeCapturedTree | null | undefined + let snapshot: DescendantSnapshot | null | undefined let capturing: Promise | null = null let refreshing: Promise | null = null let queuedRefresh: Promise | null = null let inFlight: Promise | null = null let treeVerdict: DescendantTreeVerdict = 'unverifiable' - - // Consulted only on win32: POSIX signals descendants by revalidated identity - // and reaches the root solely through Node's handle, so neither needs a probe. - const verifyRoot = - deps.verifyRootIdentity ?? - ((root: PosixProcessIdentity | WindowsProcessIdentity) => - verifyWindowsProcessIdentity(root as WindowsProcessIdentity)) + let forcedReapAttempted = false function captureOnce(): Promise { + if (!snapshots) { + return Promise.resolve() + } if (refreshing) { const pending = refreshing return pending.then(() => queuedRefresh ?? undefined) @@ -146,10 +127,6 @@ export function createClaudeChildTreeReaper( snapshot = exited() ? null : snapshot return Promise.resolve() } - const capture = - platform === 'win32' - ? (deps.captureWindowsDescendants ?? captureWindowsDescendantSnapshot) - : (deps.captureDescendants ?? captureDescendantSnapshot) capturing = capture(rootPid) .catch(() => null) .then((captured) => { @@ -159,7 +136,7 @@ export function createClaudeChildTreeReaper( // still lives the walk is simply retried, rather than latching a failed // read as proof that there was nothing to find. const rootExited = exited() - const tree = admissibleTree(captured, platform, rootExited) + const tree = admissibleTree(captured, rootExited) if (tree) { snapshot = tree } else if (rootExited) { @@ -186,16 +163,12 @@ export function createClaudeChildTreeReaper( if (!rootPid) { return Promise.resolve() } - const capture = - platform === 'win32' - ? (deps.captureWindowsDescendants ?? captureWindowsDescendantSnapshot) - : (deps.captureDescendants ?? captureDescendantSnapshot) const operation = (async () => { const captured = await capture(rootPid).catch(() => null) if (exited()) { return } - const tree = admissibleTree(captured, platform, false) + const tree = admissibleTree(captured, false) if (!tree) { return } @@ -208,7 +181,7 @@ export function createClaudeChildTreeReaper( // recycle/replace decision, not an absent descendant, so no row here may // be signalled from its number. Only the descendant evidence is lost — // the root still leaves through the handle no recycled pid can reach. - snapshot = mergeClaudeCapturedTrees(snapshot, tree) + snapshot = mergeClaudeDescendantSnapshots(snapshot, tree) } // Keep an earlier admissible snapshot when this close-boundary read fails; // it remains the only identity-safe evidence after root exit. @@ -244,6 +217,9 @@ export function createClaudeChildTreeReaper( } async function refresh(): Promise { + if (!snapshots) { + return + } const pending = capturing ?? refreshing if (pending) { await queueRefreshAfter(pending) @@ -260,7 +236,23 @@ export function createClaudeChildTreeReaper( } } - /** The only source of a tree verdict: every `exited` here is an observation. */ + /** The common pattern: one `taskkill /T /F` addressed through the root Orca still holds. */ + async function judgeWindowsTree(rootPid: number): Promise { + if (exited()) { + // The root's pid left with it, so its tree can no longer be addressed. + return 'unverifiable' + } + const terminateTree = + deps.terminateWindowsTree ?? + ((pid: number) => terminateWindowsProcessTree(pid, { site: 'claude-structured-child-close' })) + const terminated = await terminateTree(rootPid).catch(() => false) + terminateClaudeRoot({ child, exited }) + // Only taskkill's clean exit reports the whole tree terminated. + return terminated ? 'exited' : 'unverifiable' + } + + /** The only source of a tree verdict: `exited` is observed on POSIX and is taskkill's report + * on Windows. */ async function judgeTree(): Promise { const killRoot = (): boolean => terminateClaudeRoot({ child, exited }) const rootPid = child.pid @@ -268,36 +260,18 @@ export function createClaudeChildTreeReaper( // Never spawned, so the OS never created a tree to orphan. return 'exited' } - await captureOnce() - if (platform === 'win32') { - // Why taskkill's own outcome is never the verdict: it resolves identically - // on a timeout, an access denial, a recycled root and a real kill. - const { rootVerified } = await terminateClaudeWindowsRoot({ - snapshot: snapshot?.platform === 'win32' ? snapshot.tree : null, - exited, - verifyRoot: (root) => verifyRoot(root), - terminateTree: (root) => - deps.terminateWindowsTree - ? deps.terminateWindowsTree(root) - : terminateIdentifiedWindowsProcessTree(root, { - ownsRoot: () => !exited() - }).then(() => undefined), - killRoot - }) - if (!rootVerified && !exited()) { - return 'unverifiable' - } - return snapshot?.platform === 'win32' - ? await (deps.terminateWindowsDescendants ?? verifyWindowsDescendantSnapshotExit)( - snapshot.tree - ) - : 'unverifiable' + if (!exited()) { + forcedReapAttempted = true } - if (snapshot?.platform !== 'posix') { + if (!snapshots) { + return judgeWindowsTree(rootPid) + } + await captureOnce() + if (!snapshot) { killRoot() return 'unverifiable' } - if (snapshot.tree.descendants.length === 0) { + if (snapshot.descendants.length === 0) { // Read while the root was alive and childless: a later table read has no // row it could match, so it would add nothing to this observation. killRoot() @@ -312,8 +286,8 @@ export function createClaudeChildTreeReaper( // reaps them. After a root exit the kill is a no-op: Node drops the handle // on exit and never signals a possibly recycled pid. const verdictPromise = deps.terminateDescendants - ? deps.terminateDescendants(snapshot.tree) - : terminateDescendantSnapshotWithVerdict(snapshot.tree, { + ? deps.terminateDescendants(snapshot) + : terminateDescendantSnapshotWithVerdict(snapshot, { requireIdentityBeforeSignal: true }) killRoot() @@ -346,6 +320,9 @@ export function createClaudeChildTreeReaper( }, get treeVerdict() { return treeVerdict + }, + get forcedReapAttempted() { + return forcedReapAttempted } } } @@ -354,10 +331,13 @@ export function createClaudeChildTreeReaper( * Orca's own shutdown ladder on the child it spawned, kept because the SDK's * close path returns no proof and Orca never releases a lease on an assumed exit. * - * Resolves true only after the child actually emitted exit and its snapshotted - * descendants were observed gone; false is unproven. A root that left on its - * own before a snapshot could be armed stays unproven: its descendants had - * already reparented out of reach when the ladder first looked. + * Resolves true only after the child actually emitted exit and, on POSIX, its + * snapshotted descendants were observed gone; on Windows, after it left on its + * own once its stdin ended with no forced reap before, or a forced + * `taskkill /T /F` reported its tree terminated. False is unproven. On POSIX a + * root that left on its own before a snapshot could be armed stays unproven: + * its descendants had already reparented out of reach when the ladder first + * looked. */ export function proveClaudeChildExit(input: ClaudeChildExitProofInput): Promise { return proveClaudeChildExitWithReaper(input, () => diff --git a/src/main/claude/claude-agent-sdk-process-spawn.test.ts b/src/main/claude/claude-agent-sdk-process-spawn.test.ts index cadfb056a30..11e5be6d524 100644 --- a/src/main/claude/claude-agent-sdk-process-spawn.test.ts +++ b/src/main/claude/claude-agent-sdk-process-spawn.test.ts @@ -132,7 +132,8 @@ describe('claude agent SDK process spawn', () => { const tree = { capture: vi.fn(async () => {}), reap: vi.fn(async () => 'exited' as const), - treeVerdict: 'exited' as const + treeVerdict: 'exited' as const, + forcedReapAttempted: false } await expect(proveClaudeChildExitWithReaper({ managed, tree }, () => tree)).resolves.toBe( true diff --git a/src/main/claude/claude-agent-sdk-process-spawn.ts b/src/main/claude/claude-agent-sdk-process-spawn.ts index 42c138290f7..23bbe5950a0 100644 --- a/src/main/claude/claude-agent-sdk-process-spawn.ts +++ b/src/main/claude/claude-agent-sdk-process-spawn.ts @@ -4,7 +4,7 @@ import { spawnManagedProviderProcess, type ManagedProviderProcess } from '../provider-process/managed-provider-process' -import { claudeChildClosePolicy } from './claude-child-exit-proof-ladder' +import { claudeChildClosePolicy, claudeChildCloseProven } from './claude-child-exit-proof-ladder' /** Derived rather than imported: only src/shared/child-process may name node:child_process. */ type ClaudeCodeChild = ReturnType @@ -63,8 +63,8 @@ export function createClaudeCodeProcessSpawn( platform, inheritedEnv: definedEnv(options.env), site: 'claude-stream-json-teardown', - policy: claudeChildClosePolicy, - acceptClose: (result) => result.root === 'exited' && result.tree === 'exited' + policy: (supervised) => claudeChildClosePolicy(supervised, platform), + acceptClose: claudeChildCloseProven } ) // The SDK drains stderr only for its own local spawn; the managed process drains it here. diff --git a/src/main/claude/claude-agent-sdk-root-kill-fallback.test.ts b/src/main/claude/claude-agent-sdk-root-kill-fallback.test.ts index 9f843527e30..bb1f84bf5cd 100644 --- a/src/main/claude/claude-agent-sdk-root-kill-fallback.test.ts +++ b/src/main/claude/claude-agent-sdk-root-kill-fallback.test.ts @@ -3,9 +3,7 @@ import { PassThrough } from 'node:stream' import { describe, expect, it, vi } from 'vitest' import type { SpawnedProcess } from '../../shared/child-process/run-process' import type { DescendantSnapshot } from '../pty-descendant-termination' -import type { WindowsDescendantSnapshot } from '../windows-descendant-exit-verification' import { createClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof' -import { mergeClaudeCapturedTrees } from './claude-child-tree-snapshot' const ROOT_PID = 424242 const ROOT_STARTED_AT = 'Mon Jan 1 00:00:00 2026' @@ -32,15 +30,6 @@ function posixSnapshot(input: { } } -function windowsSnapshot(capturedAtMs = 1): WindowsDescendantSnapshot { - return { - root: { pid: ROOT_PID, creationTimeMs: 1_700_000_000_001 }, - descendants: [{ pid: 4243, creationTimeMs: 1_700_000_000_000 }], - unidentifiedCount: 0, - capturedAtMs - } -} - describe('Claude root kill fallback', () => { it('kills the root when the first capture landed in the fork second', async () => { // The production POSIX verifier declines a root born in its capture second, @@ -78,8 +67,7 @@ describe('Claude root kill fallback', () => { platform: 'linux', exited: () => false, captureDescendants, - terminateDescendants: vi.fn(async () => 'exited' as const), - verifyRootIdentity: vi.fn(async () => true) + terminateDescendants: vi.fn(async () => 'exited' as const) }) await tree.capture() @@ -89,7 +77,7 @@ describe('Claude root kill fallback', () => { expect(child.kill).toHaveBeenCalledWith('SIGKILL') }) - it('keeps an observed live descendant when the root identity probe declined', async () => { + it('keeps an observed live descendant through the root kill', async () => { const child = mockChild() const tree = createClaudeChildTreeReaper(child, { platform: 'linux', @@ -100,8 +88,7 @@ describe('Claude root kill fallback', () => { descendants: [{ pid: 100, ppid: ROOT_PID, pgid: ROOT_PID, startedAt: ROOT_STARTED_AT }] }) ), - terminateDescendants: vi.fn(async () => 'live' as const), - verifyRootIdentity: vi.fn(async () => false) + terminateDescendants: vi.fn(async () => 'live' as const) }) await expect(tree.reap()).resolves.toBe('live') @@ -109,22 +96,6 @@ describe('Claude root kill fallback', () => { expect(child.kill).toHaveBeenCalledWith('SIGKILL') }) - it('reports a Windows taskkill that worked as exited, not unverifiable', async () => { - const child = mockChild() - // Probe 1 gates taskkill; a later probe correctly finds the root already dead. - const verifyRootIdentity = vi.fn().mockResolvedValueOnce(true).mockResolvedValue(false) - const tree = createClaudeChildTreeReaper(child, { - platform: 'win32', - exited: () => false, - captureWindowsDescendants: vi.fn(async () => windowsSnapshot()), - terminateWindowsTree: vi.fn(async () => {}), - terminateWindowsDescendants: vi.fn(async () => 'exited' as const), - verifyRootIdentity - }) - - await expect(tree.reap()).resolves.toBe('exited') - }) - it('kills the root when no POSIX snapshot could be read', async () => { const child = mockChild() const tree = createClaudeChildTreeReaper(child, { @@ -138,23 +109,6 @@ describe('Claude root kill fallback', () => { expect(child.kill).toHaveBeenCalledWith('SIGKILL') }) - it('kills the root when the Windows process table is unreadable', async () => { - const child = mockChild() - const terminateWindowsTree = vi.fn(async () => {}) - const tree = createClaudeChildTreeReaper(child, { - platform: 'win32', - exited: () => false, - captureWindowsDescendants: vi.fn(async () => null), - terminateWindowsTree, - terminateWindowsDescendants: vi.fn() - }) - - await expect(tree.reap()).resolves.toBe('unverifiable') - // No identity means no bare-pid tree kill, but the owned handle is still ours. - expect(terminateWindowsTree).not.toHaveBeenCalled() - expect(child.kill).toHaveBeenCalledWith('SIGKILL') - }) - it('never signals a root the reaper already saw exit', async () => { const child = mockChild() const tree = createClaudeChildTreeReaper(child, { @@ -166,25 +120,4 @@ describe('Claude root kill fallback', () => { await expect(tree.reap()).resolves.toBe('unverifiable') expect(child.kill).not.toHaveBeenCalled() }) - - it('chains per-pid Windows boundaries across a second merge', async () => { - const first = windowsSnapshot(1_000) - const second: WindowsDescendantSnapshot = { - ...windowsSnapshot(2_000), - descendants: [ - { pid: 4243, creationTimeMs: 1_700_000_000_000 }, - { pid: 4244, creationTimeMs: 1_700_000_000_002 } - ] - } - const third: WindowsDescendantSnapshot = { ...second, capturedAtMs: 3_000 } - - const merged = mergeClaudeCapturedTrees( - { platform: 'win32', tree: first }, - { platform: 'win32', tree: second } - ) - expect(merged?.tree.capturedAtMsByPid).toEqual({ '4243': 1_000, '4244': 2_000 }) - const rechained = mergeClaudeCapturedTrees(merged!, { platform: 'win32', tree: third }) - - expect(rechained?.tree.capturedAtMsByPid).toEqual({ '4243': 1_000, '4244': 2_000 }) - }) }) diff --git a/src/main/claude/claude-child-exit-proof-fixture.ts b/src/main/claude/claude-child-exit-proof-fixture.ts index e10e156757e..7357730ae75 100644 --- a/src/main/claude/claude-child-exit-proof-fixture.ts +++ b/src/main/claude/claude-child-exit-proof-fixture.ts @@ -4,12 +4,14 @@ import { spawnManagedProviderProcess, type ManagedProviderProcess } from '../provider-process/managed-provider-process' -import { claudeChildClosePolicy } from './claude-child-exit-proof-ladder' +import { claudeChildClosePolicy, claudeChildCloseProven } from './claude-child-exit-proof-ladder' const managedChildren = new WeakMap() +/** `closePlatform` picks the close rules; the spawn itself always skips the POSIX supervisor. */ export function managedChild( - child: Pick & EventEmitter + child: Pick & EventEmitter, + closePlatform: NodeJS.Platform = 'linux' ): ManagedProviderProcess { const existing = managedChildren.get(child) if (existing) { @@ -22,8 +24,8 @@ export function managedChild( spawnImpl: () => child as ReturnType, platform: 'win32', site: 'claude-proof-fixture', - policy: claudeChildClosePolicy, - acceptClose: (result) => result.root === 'exited' && result.tree === 'exited' + policy: (supervised) => claudeChildClosePolicy(supervised, closePlatform), + acceptClose: claudeChildCloseProven } ) managedChildren.set(child, managed) diff --git a/src/main/claude/claude-child-exit-proof-ladder.test.ts b/src/main/claude/claude-child-exit-proof-ladder.test.ts index e327d361d58..2e2bfa7f2be 100644 --- a/src/main/claude/claude-child-exit-proof-ladder.test.ts +++ b/src/main/claude/claude-child-exit-proof-ladder.test.ts @@ -3,8 +3,12 @@ import { PassThrough } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' import type { spawnProcess } from '../../shared/child-process/run-process' import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor' -import type { ClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof' +import { + createClaudeChildTreeReaper, + type ClaudeChildTreeReaper +} from './claude-agent-sdk-exit-proof' import { createClaudeCodeProcessSpawn } from './claude-agent-sdk-process-spawn' +import { managedChild } from './claude-child-exit-proof-fixture' import { proveClaudeChildExitWithReaper } from './claude-child-exit-proof-ladder' function fakeTree(): ClaudeChildTreeReaper & { reap: ReturnType } { @@ -12,7 +16,8 @@ function fakeTree(): ClaudeChildTreeReaper & { reap: ReturnType } capture: vi.fn(async () => {}), refresh: vi.fn(async () => {}), reap: vi.fn(async () => 'exited' as const), - treeVerdict: 'exited' + treeVerdict: 'exited', + forcedReapAttempted: false } } @@ -46,6 +51,45 @@ function rootStoppedBySigterm(stopMs: number, platform: NodeJS.Platform) { return { child, managed } } +/** A root that leaves on stdin end when `leavesOnStdinEnd`, otherwise once killed (or, with + * `leavesOnKill` false, only when the test emits its exit). Closed under `closePlatform`'s rules. */ +function fixtureRoot( + closePlatform: NodeJS.Platform, + leavesOnStdinEnd: boolean, + leavesOnKill = true +) { + const child = Object.assign(new EventEmitter(), { + pid: 4321, + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(() => { + if (leavesOnKill) { + child.emit('exit', null, 'SIGKILL') + } + return true + }) + }) + if (leavesOnStdinEnd) { + child.stdin.on('finish', () => child.emit('exit', 0, null)) + } + return { child, managed: managedChild(child, closePlatform) } +} + +function windowsTree( + root: ReturnType, + terminateWindowsTree: (rootPid: number) => Promise +) { + const captureDescendants = vi.fn(async () => null) + const tree = createClaudeChildTreeReaper(root.child, { + platform: 'win32', + exited: () => root.managed.rootVerdict === 'exited', + captureDescendants, + terminateWindowsTree + }) + return { tree, reap: vi.spyOn(tree, 'reap'), captureDescendants } +} + afterEach(() => vi.useRealTimers()) describe('Claude child exit proof ladder', () => { @@ -77,4 +121,92 @@ describe('Claude child exit proof ladder', () => { expect(root.managed.lastCloseResult).toEqual({ root: 'live', tree: 'exited' }) expect(vi.getTimerCount()).toBe(0) }) + + it('on Windows proves a close when Claude leaves on its own after its stdin ends', async () => { + const root = fixtureRoot('win32', true) + const terminateWindowsTree = vi.fn(async () => true) + const { tree, reap, captureDescendants } = windowsTree(root, terminateWindowsTree) + + await expect( + proveClaudeChildExitWithReaper({ managed: root.managed, tree }, () => tree) + ).resolves.toBe(true) + + // No claim about what Claude started: nothing is read, reaped or taskkilled after it left. + expect(reap).not.toHaveBeenCalled() + expect(terminateWindowsTree).not.toHaveBeenCalled() + expect(captureDescendants).not.toHaveBeenCalled() + expect(root.child.kill).not.toHaveBeenCalled() + expect(root.managed.lastCloseResult).toEqual({ + root: 'exited', + tree: 'unverifiable', + selfExit: true + }) + }) + + it.each([ + { taskkill: true, proven: true }, + { taskkill: false, proven: false } + ])( + 'on Windows a forced close is proven only by taskkill: taskkill $taskkill', + async ({ taskkill, proven }) => { + const root = fixtureRoot('win32', false) + const terminateWindowsTree = vi.fn(async () => taskkill) + const { tree } = windowsTree(root, terminateWindowsTree) + + await expect( + proveClaudeChildExitWithReaper({ managed: root.managed, tree }, () => tree) + ).resolves.toBe(proven) + + expect(terminateWindowsTree).toHaveBeenCalledWith(4321) + // The root still leaves through its held handle whatever taskkill reported. + expect(root.managed.rootVerdict).toBe('exited') + }, + 10_000 + ) + + it('on POSIX still reaps a root that left on its own and keeps the tree verdict', async () => { + const root = fixtureRoot('linux', true) + const tree = { ...fakeTree(), treeVerdict: 'unverifiable' as const } + + await expect( + proveClaudeChildExitWithReaper({ managed: root.managed, tree }, () => tree) + ).resolves.toBe(false) + expect(tree.reap).toHaveBeenCalledOnce() + }) + + it('on Windows a retried close after a failed taskkill stays unproven once the root exits', async () => { + const root = fixtureRoot('win32', false, false) + const terminateWindowsTree = vi.fn(async () => false) + const { tree } = windowsTree(root, terminateWindowsTree) + const close = () => proveClaudeChildExitWithReaper({ managed: root.managed, tree }, () => tree) + + await expect(close()).resolves.toBe(false) + // The exit lands only after the forced wait: it is not Claude leaving on its own. + root.child.emit('exit', null, 'SIGKILL') + await expect(close()).resolves.toBe(false) + + expect(terminateWindowsTree).toHaveBeenCalledOnce() + expect(tree.forcedReapAttempted).toBe(true) + expect(tree.treeVerdict).toBe('unverifiable') + }, 10_000) + + it.each([ + { taskkill: true, proven: true }, + { taskkill: false, proven: false } + ])( + 'on Windows a reap outside a close keeps taskkill as the verdict: taskkill $taskkill', + async ({ taskkill, proven }) => { + // The transport-failure reap (stdin error, reader failure) kills the live tree itself. + const root = fixtureRoot('win32', false) + const terminateWindowsTree = vi.fn(async () => taskkill) + const { tree } = windowsTree(root, terminateWindowsTree) + await tree.reap() + expect(root.managed.rootVerdict).toBe('exited') + + await expect( + proveClaudeChildExitWithReaper({ managed: root.managed, tree }, () => tree) + ).resolves.toBe(proven) + expect(terminateWindowsTree).toHaveBeenCalledOnce() + } + ) }) diff --git a/src/main/claude/claude-child-exit-proof-ladder.ts b/src/main/claude/claude-child-exit-proof-ladder.ts index a3f1de84236..06f1c5a2b9b 100644 --- a/src/main/claude/claude-child-exit-proof-ladder.ts +++ b/src/main/claude/claude-child-exit-proof-ladder.ts @@ -1,6 +1,9 @@ import type { ManagedProviderProcess } from '../provider-process/managed-provider-process' import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor' -import type { ProviderProcessClosePolicy } from '../provider-process/provider-process-close' +import type { + ProviderProcessClosePolicy, + ProviderProcessCloseResult +} from '../provider-process/provider-process-close' import type { ClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof' export const GRACEFUL_EXIT_MS = 1_500 @@ -8,14 +11,26 @@ export const GRACEFUL_EXIT_MS = 1_500 export const SUPERVISED_GRACEFUL_EXIT_MS = PROVIDER_SUPERVISOR_MAX_STOP_MS + 500 const FORCED_EXIT_MS = 1_000 -export function claudeChildClosePolicy(supervised: boolean): ProviderProcessClosePolicy { +export function claudeChildClosePolicy( + supervised: boolean, + platform: NodeJS.Platform = process.platform +): ProviderProcessClosePolicy { return { gracefulExitMs: supervised ? SUPERVISED_GRACEFUL_EXIT_MS : GRACEFUL_EXIT_MS, forcedExitMs: FORCED_EXIT_MS, - signalSupervisorOnClose: true + signalSupervisorOnClose: true, + // On Windows, as with the Codex close, Claude leaving on its own after its stdin ends is the + // close: Orca makes no claim about processes Claude started. An exit after any forced reap on + // this tree, in this close or an earlier one, keeps taskkill's verdict. + selfExitIsClose: platform === 'win32' } } +/** The root exited, and its tree was seen gone or, on Windows, it left on its own. */ +export function claudeChildCloseProven(result: ProviderProcessCloseResult): boolean { + return result.root === 'exited' && (result.tree === 'exited' || result.selfExit === true) +} + export type ClaudeChildExitProofInput = { managed: ManagedProviderProcess tree?: ClaudeChildTreeReaper @@ -25,6 +40,5 @@ export async function proveClaudeChildExitWithReaper( input: ClaudeChildExitProofInput, createTree: () => ClaudeChildTreeReaper ): Promise { - const result = await input.managed.close(input.tree ?? createTree()) - return result.root === 'exited' && result.tree === 'exited' + return claudeChildCloseProven(await input.managed.close(input.tree ?? createTree())) } diff --git a/src/main/claude/claude-child-root-termination.ts b/src/main/claude/claude-child-root-termination.ts index bba29919578..3b3f138cd54 100644 --- a/src/main/claude/claude-child-root-termination.ts +++ b/src/main/claude/claude-child-root-termination.ts @@ -1,11 +1,4 @@ import type { SpawnedProcess } from '../../shared/child-process/run-process' -import type { PosixProcessIdentity } from '../pty-descendant-termination' -import type { - WindowsDescendantSnapshot, - WindowsProcessIdentity -} from '../windows-descendant-exit-verification' - -export type ClaudeRootIdentity = PosixProcessIdentity | WindowsProcessIdentity type RootTerminationInput = { child: Pick @@ -27,31 +20,3 @@ type RootTerminationInput = { export function terminateClaudeRoot(input: RootTerminationInput): boolean { return input.exited() ? false : input.child.kill('SIGKILL') } - -type WindowsRootTerminationInput = { - snapshot: WindowsDescendantSnapshot | null - exited: () => boolean - verifyRoot: (root: WindowsProcessIdentity) => Promise - terminateTree: (root: WindowsProcessIdentity) => Promise - killRoot: () => boolean -} - -/** - * `taskkill /T /F` addresses a bare pid, so a dead root's pid may already belong - * to a stranger whose whole tree it would take down: that one is identity-gated. - * The direct root kill after it runs however the probe decided. - */ -export async function terminateClaudeWindowsRoot( - input: WindowsRootTerminationInput -): Promise<{ rootVerified: boolean }> { - const { snapshot, exited, verifyRoot, terminateTree, killRoot } = input - let rootVerified = false - if (!exited() && snapshot) { - rootVerified = await verifyRoot(snapshot.root).catch(() => false) - if (rootVerified && !exited()) { - await terminateTree(snapshot.root).catch(() => {}) - } - } - killRoot() - return { rootVerified } -} diff --git a/src/main/claude/claude-child-tree-snapshot.ts b/src/main/claude/claude-child-tree-snapshot.ts index e0955648b02..c8f517daac6 100644 --- a/src/main/claude/claude-child-tree-snapshot.ts +++ b/src/main/claude/claude-child-tree-snapshot.ts @@ -1,10 +1,4 @@ import type { DescendantSnapshot } from '../pty-descendant-termination' -import type { WindowsDescendantSnapshot } from '../windows-descendant-exit-verification' - -/** One platform's descendant tree, tagged so neither verifier can be handed the other's rows. */ -export type ClaudeCapturedTree = - | { platform: 'posix'; tree: DescendantSnapshot } - | { platform: 'win32'; tree: WindowsDescendantSnapshot } /** * Process-table reads are not atomic: a refresh can omit a still-live row, but @@ -48,81 +42,37 @@ function mergeRowsByPid( } } -export function mergeClaudeCapturedTrees( - previous: ClaudeCapturedTree, - next: ClaudeCapturedTree -): ClaudeCapturedTree | null { - if (previous.platform !== next.platform) { +export function mergeClaudeDescendantSnapshots( + previous: DescendantSnapshot, + next: DescendantSnapshot +): DescendantSnapshot | null { + if (previous.rootPgid !== next.rootPgid) { return null } - if (previous.platform === 'posix' && next.platform === 'posix') { - if (previous.tree.rootPgid !== next.tree.rootPgid) { - return null - } - // A refresh cannot repair an earlier capture that lacked root identity; - // retaining those rows would permit a later numeric-pid kill without proof. - if (!previous.tree.root || !next.tree.root) { - return null - } - if ( - previous.tree.root.pid !== next.tree.root.pid || - previous.tree.root.startedAt !== next.tree.root.startedAt - ) { - return null - } - const descendants = mergeRowsByPid( - previous.tree.descendants, - next.tree.descendants, - (left, right) => left.pgid === right.pgid && left.startedAt === right.startedAt, - (row) => previous.tree.capturedAtMsByPid?.[String(row.pid)] ?? previous.tree.capturedAtMs, - (row) => next.tree.capturedAtMsByPid?.[String(row.pid)] ?? next.tree.capturedAtMs, - next.tree.capturedAtMs - ) - if (!descendants) { - return null - } - return { - platform: 'posix', - tree: { - ...next.tree, - // Retained rows keep their earlier boundary; new rows use the refresh - // boundary. The scalar remains the latest scan for legacy consumers. - descendants: descendants.rows, - ...(descendants.capturedAtMsByPid - ? { capturedAtMsByPid: descendants.capturedAtMsByPid } - : {}) - } - } + // A refresh cannot repair an earlier capture that lacked root identity; + // retaining those rows would permit a later numeric-pid kill without proof. + if (!previous.root || !next.root) { + return null } - if (previous.platform === 'win32' && next.platform === 'win32') { - if ( - previous.tree.root.pid !== next.tree.root.pid || - previous.tree.root.creationTimeMs !== next.tree.root.creationTimeMs - ) { - return null - } - const descendants = mergeRowsByPid( - previous.tree.descendants, - next.tree.descendants, - (left, right) => left.creationTimeMs === right.creationTimeMs, - (row) => previous.tree.capturedAtMsByPid?.[String(row.pid)] ?? previous.tree.capturedAtMs, - (row) => next.tree.capturedAtMsByPid?.[String(row.pid)] ?? next.tree.capturedAtMs, - next.tree.capturedAtMs - ) - if (!descendants) { - return null - } - return { - platform: 'win32', - tree: { - ...next.tree, - descendants: descendants.rows, - ...(descendants.capturedAtMsByPid - ? { capturedAtMsByPid: descendants.capturedAtMsByPid } - : {}), - unidentifiedCount: Math.max(previous.tree.unidentifiedCount, next.tree.unidentifiedCount) - } - } + if (previous.root.pid !== next.root.pid || previous.root.startedAt !== next.root.startedAt) { + return null + } + const descendants = mergeRowsByPid( + previous.descendants, + next.descendants, + (left, right) => left.pgid === right.pgid && left.startedAt === right.startedAt, + (row) => previous.capturedAtMsByPid?.[String(row.pid)] ?? previous.capturedAtMs, + (row) => next.capturedAtMsByPid?.[String(row.pid)] ?? next.capturedAtMs, + next.capturedAtMs + ) + if (!descendants) { + return null + } + return { + ...next, + // Retained rows keep their earlier boundary; new rows use the refresh + // boundary. The scalar remains the latest scan for legacy consumers. + descendants: descendants.rows, + ...(descendants.capturedAtMsByPid ? { capturedAtMsByPid: descendants.capturedAtMsByPid } : {}) } - return null } diff --git a/src/main/claude/claude-child-work-decoder.ts b/src/main/claude/claude-child-work-decoder.ts index e6af733b6fd..ce975e8d39f 100644 --- a/src/main/claude/claude-child-work-decoder.ts +++ b/src/main/claude/claude-child-work-decoder.ts @@ -4,7 +4,7 @@ // `task_notification`; nothing else ends it. A roster (`background_tasks_changed`), a turn ending // or a spawn call returning is the parent's view of the child, not the child's, and the CLI sends // every child its own terminal frame, so none of them settles one. When Orca ends the session and -// proves its tree gone, what is still live is stopped (`stopLive`); any other end leaves it for the +// proves the close, what is still live is stopped (`stopLive`); any other end leaves it for the // host to settle as unknown. A live child blocked on a permission request reads waiting; no task // frame says so, so the caller hands over which children a request blocks. Edges wait here until // the frame is journaled, then take the host clock. @@ -142,7 +142,7 @@ export class ClaudeChildWorkDecoder { } } - /** Orca ended the session and proved its process tree gone: what still ran is stopped. The + /** Orca ended the session and proved the close: what still ran is stopped. The * ending is Orca's, not the child's, so a frame of the child's own still replaces it. */ stopLive(): void { for (const id of this.live.keys()) { diff --git a/src/main/claude/claude-stream-json-connection-close.test.ts b/src/main/claude/claude-stream-json-connection-close.test.ts index 348601bbd48..c1078a82a57 100644 --- a/src/main/claude/claude-stream-json-connection-close.test.ts +++ b/src/main/claude/claude-stream-json-connection-close.test.ts @@ -16,7 +16,8 @@ const mocks = vi.hoisted(() => { capture: vi.fn(async () => {}), refresh: (...args: unknown[]) => refresh(...args), reap: vi.fn(async () => 'exited' as const), - treeVerdict: 'unverifiable' as const + treeVerdict: 'unverifiable' as const, + forcedReapAttempted: false } return { proveClaudeChildExit, refresh, tree } }) diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 0c1118e5e9e..142c29dc329 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -624,7 +624,12 @@ describe('Claude stream-json connection', () => { const closed = await connection.close() expect(connection.exitVerdict.root).toBe('exited') expect(['exited', 'unverifiable']).toContain(connection.exitVerdict.tree) - expect(closed).toBe(connection.exitVerdict.tree === 'exited') + if (process.platform === 'win32') { + // The self-exit is the close, unless a reap racing it already forced the tree. + expect(closed || connection.exitVerdict.tree === 'unverifiable').toBe(true) + } else { + expect(closed).toBe(connection.exitVerdict.tree === 'exited') + } }) it.runIf(process.platform !== 'win32')( diff --git a/src/main/claude/claude-structured-acquisition-release.ts b/src/main/claude/claude-structured-acquisition-release.ts index d29879bb73e..d8f0a1afd9f 100644 --- a/src/main/claude/claude-structured-acquisition-release.ts +++ b/src/main/claude/claude-structured-acquisition-release.ts @@ -12,8 +12,8 @@ import type { /** * Cleanup for an acquisition the host could not commit or prove. A session that * a first-hand exit already removed is not an absence to report as proven: the - * ladder on its connection still answers, and that answer is classified exactly - * as a start-time failure would be. + * ladder on its connection still answers, and that answer is classified like + * any other unproven acquisition cleanup. */ export async function releaseClaudeAcquisition(input: { sessionId: string diff --git a/src/main/claude/claude-structured-live-provider-process.test.ts b/src/main/claude/claude-structured-live-provider-process.test.ts new file mode 100644 index 00000000000..a829f0906a2 --- /dev/null +++ b/src/main/claude/claude-structured-live-provider-process.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { + adapterFor, + fakeClaude, + identityFor, + recordingJournalSink +} from './claude-structured-session-test-support' + +describe('Claude adapter liveness for lease renewal', () => { + it('holds its acquisition until the root exit is seen', async () => { + const claude = fakeClaude() + const adapter = adapterFor(claude) + const { acquisitionGeneration } = await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: recordingJournalSink() + }) + + expect(adapter.holdsLiveProviderProcess('session-1', acquisitionGeneration!)).toBe(true) + expect(adapter.holdsLiveProviderProcess('session-1', 'another-acquisition')).toBe(false) + expect(adapter.holdsLiveProviderProcess('session-2', acquisitionGeneration!)).toBe(false) + + // The connection's own observation, before any exit event reaches the host. + claude.connections[0]!.exitVerdict = { root: 'exited', tree: 'unverifiable' } + expect(adapter.holdsLiveProviderProcess('session-1', acquisitionGeneration!)).toBe(false) + }) +}) diff --git a/src/main/claude/claude-structured-location-support.ts b/src/main/claude/claude-structured-location-support.ts index 9c784a91325..41b45683546 100644 --- a/src/main/claude/claude-structured-location-support.ts +++ b/src/main/claude/claude-structured-location-support.ts @@ -1,11 +1,6 @@ import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' export function supportsClaudeStructuredLocation(location: AgentSessionExecutionLocation): boolean { - return ( - location.executionHostId === LOCAL_EXECUTION_HOST_ID && - location.wslDistro === null && - (process.platform !== 'win32' || isWindowsProcessStartTimeAvailable()) - ) + return location.executionHostId === LOCAL_EXECUTION_HOST_ID && location.wslDistro === null } diff --git a/src/main/claude/claude-structured-owner-identity.test.ts b/src/main/claude/claude-structured-owner-identity.test.ts index 729aecbec87..376b9d83a49 100644 --- a/src/main/claude/claude-structured-owner-identity.test.ts +++ b/src/main/claude/claude-structured-owner-identity.test.ts @@ -37,4 +37,12 @@ describe('claude structured owner identity', () => { ).resolves.toMatchObject({ processStartTimeMs: 456 }) expect(readStartTime).toHaveBeenCalledTimes(3) }) + + it('records an owner whose start time is unreadable instead of refusing the session', async () => { + const readStartTime = vi.fn(async () => null) + await expect( + claudeProcessIdentity({ identity: IDENTITY, spawnToken: 'spawn-a', pid: 4242 }, readStartTime) + ).resolves.toMatchObject({ pid: 4242, processStartTimeMs: null, spawnToken: 'spawn-a' }) + expect(readStartTime).toHaveBeenCalledTimes(3) + }) }) diff --git a/src/main/claude/claude-structured-owner-identity.ts b/src/main/claude/claude-structured-owner-identity.ts index 80f6ca6977d..65589b53aa4 100644 --- a/src/main/claude/claude-structured-owner-identity.ts +++ b/src/main/claude/claude-structured-owner-identity.ts @@ -41,6 +41,8 @@ export async function claudeProcessIdentity( if (input.pid === undefined) { throw new Error('claude app-server started without a pid') } + // Best-effort: without it a later owner probe is indeterminate, and recovery releases such an + // owner without signalling it, so an unreadable start time must not refuse the session. let processStartTimeMs: number | null = null for ( let attempt = 0; @@ -49,11 +51,6 @@ export async function claudeProcessIdentity( ) { processStartTimeMs = await readStartTime(input.pid) } - if (processStartTimeMs === null) { - // Why: recording null makes every later owner probe indeterminate — a durable latch. - // Failing here reaps the child and leaves a retryable refusal instead. - throw new Error(`claude app-server start time for pid ${input.pid} could not be read`) - } return { hostId: input.identity.hostId, pid: input.pid, diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index bbe23bb0d2c..285606fb8bf 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -228,7 +228,7 @@ export async function acquireClaudeSession({ { ...input, pid: connection.pid }, deps.readProcessStartTime ).catch((error: unknown) => { - // A child that already ended explains why its start time could not be read. + // A child that already ended explains a missing pid or a failed read. throw childEnded ?? error }) acquisitions.assertCurrent(sessionId, attempt) diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 2b065bb5639..6d4ec1c3153 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -226,6 +226,14 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda const session = this.sessions.get(sessionId) return session ? claudeHoldsDispatch(session) : false } + holdsLiveProviderProcess = (sessionId: string, acquisitionGeneration: string): boolean => { + const session = this.sessions.get(sessionId) + return ( + session?.acquisitionGeneration === acquisitionGeneration && + session.connection.pid !== undefined && + session.connection.exitVerdict.root === 'live' + ) + } answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (request) => settleClaudePromptFreeingChild(this.asker(request), answerClaudeStructuredPrompt) setOption: StructuredAgentSessionAdapter['setOption'] = (input) => diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index 3093d2ead0d..f066699c4f1 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -119,8 +119,9 @@ async function finalizeClaudePublishedSession( rootExitVerdict = cleanupError } // Queues the session's ending for the host's child records; the adapter delivers it after close. - // A close that proved the whole tree gone stopped what still ran. One that saw a descendant - // survive, like an exit of the session's own, leaves how it ended unknown. + // A proven close stopped what still ran: on POSIX the whole tree was seen gone; on Windows Claude + // left after its stdin ended, or taskkill reported its tree terminated. Any other end, like an + // exit of the session's own, leaves how it ended unknown. if (connectionClosed === true) { session.childWork.stopLive() } diff --git a/src/main/codex/codex-structured-acquisition-exit-proof.test.ts b/src/main/codex/codex-structured-acquisition-exit-proof.test.ts index f86d0aff6c1..059c0f4851f 100644 --- a/src/main/codex/codex-structured-acquisition-exit-proof.test.ts +++ b/src/main/codex/codex-structured-acquisition-exit-proof.test.ts @@ -79,7 +79,10 @@ describe('Codex failed-acquisition exit proof', () => { resumeThreadId: 'thread-1' }), openConnection: async () => connection, - readProcessStartTime: async () => null + // An unreadable start time no longer fails a start, so the identity read itself fails here. + readProcessStartTime: async () => { + throw new Error('process table unavailable') + } }) await expect( diff --git a/src/main/codex/codex-structured-launch-resolution.test.ts b/src/main/codex/codex-structured-launch-resolution.test.ts index 9b62398ed87..4fb64e33056 100644 --- a/src/main/codex/codex-structured-launch-resolution.test.ts +++ b/src/main/codex/codex-structured-launch-resolution.test.ts @@ -10,6 +10,15 @@ import { createCodexStructuredLaunchResolver } from './codex-structured-launch-r import { codexStructuredPermissionPolicyForSettings } from './codex-structured-permission-policy' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +const { isWindowsProcessStartTimeAvailable } = vi.hoisted(() => ({ + isWindowsProcessStartTimeAvailable: vi.fn(() => true) +})) + +vi.mock('../windows/windows-process-table', async (importOriginal) => ({ + ...(await importOriginal()), + isWindowsProcessStartTimeAvailable +})) + const SESSION_ID = 'session-1' const IDENTITY = { sessionId: SESSION_ID } as Parameters< ReturnType @@ -52,7 +61,6 @@ function resolverFor( resolveWorkspacePath, resolveCommand: () => '/usr/local/bin/codex', resolveRollout, - isWindowsProcessStartTimeAvailable: () => true, resolvePermissionPolicy: () => codexStructuredPermissionPolicyForSettings({ agentDefaultArgs }) }) } @@ -83,8 +91,7 @@ describe('codex structured launch resolution', () => { pinLaunchDirectory }, resolveWorkspacePath: async () => '/floating/start-folder', - resolveCommand: () => '/usr/local/bin/codex', - isWindowsProcessStartTimeAvailable: () => true + resolveCommand: () => '/usr/local/bin/codex' }) await expect(resolveLaunch({ identity: IDENTITY })).resolves.toMatchObject({ @@ -114,8 +121,7 @@ describe('codex structured launch resolution', () => { const resolveLaunch = createCodexStructuredLaunchResolver({ store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async () => String.raw`C:\workspaces\orca`, - resolveCommand: () => command, - isWindowsProcessStartTimeAvailable: () => true + resolveCommand: () => command }) await expect(resolveLaunch({ identity: IDENTITY })).resolves.toMatchObject({ @@ -125,19 +131,19 @@ describe('codex structured launch resolution', () => { }) }) - it('fails closed before resolving a Windows launch without creation-time proof', async () => { + it('resolves a Windows launch on a host that cannot read process creation times', async () => { + isWindowsProcessStartTimeAvailable.mockReturnValue(false) await withPlatform('win32', async () => { - const resolveWorkspacePath = vi.fn(async () => String.raw`C:\workspaces\orca`) const resolveLaunch = createCodexStructuredLaunchResolver({ store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, - resolveWorkspacePath, - isWindowsProcessStartTimeAvailable: () => false + resolveWorkspacePath: async () => String.raw`C:\workspaces\orca`, + resolveCommand: () => 'codex.exe' }) - await expect(resolveLaunch({ identity: IDENTITY })).rejects.toThrow( - 'Windows process creation-time proof' - ) - expect(resolveWorkspacePath).not.toHaveBeenCalled() + await expect(resolveLaunch({ identity: IDENTITY })).resolves.toMatchObject({ + command: 'codex.exe', + args: ['app-server'] + }) }) }) diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 0f5d21b41d4..723281c590a 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -15,7 +15,6 @@ import { resolveAgentSessionLaunchDirectory } from '../runtime/agent-session-lau import type { CodexStructuredLaunch } from './codex-structured-session-adapter' import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' import { resolvePinnedCodexRolloutProof } from './codex-pinned-rollout-proof' -import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' import { CODEX_STRUCTURED_AGENT } from './codex-structured-agent-definition' export type CodexStructuredLaunchResolverDeps = { @@ -28,8 +27,6 @@ export type CodexStructuredLaunchResolverDeps = { /** Fresh shell/configured environment for this spawn; never written to the session record. */ resolveEnvironment?: () => Promise resolveRollout?: typeof resolvePinnedCodexRolloutProof - /** Test seam for the host capability; production uses the native process table. */ - isWindowsProcessStartTimeAvailable?: () => boolean /** The user's Agent Permissions setting as thread policy, re-read per acquisition. * States both postures outright — a resume inherits the last one for any field left absent. */ resolvePermissionPolicy?: () => CodexStructuredPermissionPolicy @@ -80,13 +77,6 @@ export function createCodexStructuredLaunchResolver( `codex structured sessions run on the local host, not ${location.executionHostId}` ) } - // Refuse before resolving launch data; a PID alone cannot prove Windows ownership. - if ( - process.platform === 'win32' && - !(deps.isWindowsProcessStartTimeAvailable ?? isWindowsProcessStartTimeAvailable)() - ) { - throw new Error('codex structured sessions require Windows process creation-time proof') - } const pinned = CODEX_STRUCTURED_AGENT.accountHomeVariable if (accountHome.variable !== pinned) { throw new Error(`codex sessions pin ${pinned}, not ${accountHome.variable}`) diff --git a/src/main/codex/codex-structured-live-provider-process.test.ts b/src/main/codex/codex-structured-live-provider-process.test.ts new file mode 100644 index 00000000000..f6163cf0558 --- /dev/null +++ b/src/main/codex/codex-structured-live-provider-process.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { adapterFor, fakeCodex, identityFor } from './codex-structured-session-adapter-fixture' + +describe('Codex adapter liveness for lease renewal', () => { + it('holds its acquisition until the connection reports the root exit', async () => { + const codex = fakeCodex() + const adapter = adapterFor(codex) + const { acquisitionGeneration } = await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9' + }) + + expect(adapter.holdsLiveProviderProcess('session-1', acquisitionGeneration!)).toBe(true) + expect(adapter.holdsLiveProviderProcess('session-1', 'another-acquisition')).toBe(false) + + codex.connections[0]!.handlers.onExit?.(new Error('codex app-server exited')) + expect(adapter.holdsLiveProviderProcess('session-1', acquisitionGeneration!)).toBe(false) + }) +}) diff --git a/src/main/codex/codex-structured-location-support.ts b/src/main/codex/codex-structured-location-support.ts index ad0bbefa4d3..618a415ebb2 100644 --- a/src/main/codex/codex-structured-location-support.ts +++ b/src/main/codex/codex-structured-location-support.ts @@ -1,15 +1,6 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' -import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' -export function supportsCodexStructuredLocation( - location: AgentSessionExecutionLocation, - // Injected by the adapter, which owns this dep for every other Codex gate too. - hasWindowsProcessStartTimeProof: () => boolean = isWindowsProcessStartTimeAvailable -): boolean { - return ( - location.executionHostId === LOCAL_EXECUTION_HOST_ID && - location.wslDistro === null && - (process.platform !== 'win32' || hasWindowsProcessStartTimeProof()) - ) +export function supportsCodexStructuredLocation(location: AgentSessionExecutionLocation): boolean { + return location.executionHostId === LOCAL_EXECUTION_HOST_ID && location.wslDistro === null } diff --git a/src/main/codex/codex-structured-owner-identity.test.ts b/src/main/codex/codex-structured-owner-identity.test.ts index 229da5e00bd..e5d8df1f939 100644 --- a/src/main/codex/codex-structured-owner-identity.test.ts +++ b/src/main/codex/codex-structured-owner-identity.test.ts @@ -37,13 +37,16 @@ describe('codex process identity', () => { expect(readStartTime).toHaveBeenCalledTimes(3) }) - it('refuses an owner whose start time is unreadable rather than record one no probe can verify', async () => { - // A null start time guarantees every later owner probe answers indeterminate, which is - // a durable latch; refusing here is a retryable failure instead. + it('records an owner whose start time is unreadable instead of refusing the session', async () => { const readStartTime = vi.fn(async () => null) await expect( codexProcessIdentity({ identity: IDENTITY, spawnToken: 'spawn-a', pid: 4242 }, readStartTime) - ).rejects.toThrow('start time') + ).resolves.toEqual({ + hostId: 'local', + pid: 4242, + processStartTimeMs: null, + spawnToken: 'spawn-a' + }) expect(readStartTime).toHaveBeenCalledTimes(3) }) }) diff --git a/src/main/codex/codex-structured-owner-identity.ts b/src/main/codex/codex-structured-owner-identity.ts index 9b0d3e8e548..00dd0bfb61f 100644 --- a/src/main/codex/codex-structured-owner-identity.ts +++ b/src/main/codex/codex-structured-owner-identity.ts @@ -55,6 +55,8 @@ export async function codexProcessIdentity( if (input.pid === undefined) { throw new Error('codex app-server started without a pid') } + // Best-effort: without it a later owner probe is indeterminate, and recovery releases such an + // owner without signalling it, so an unreadable start time must not refuse the session. let processStartTimeMs: number | null = null for ( let attempt = 0; @@ -63,11 +65,6 @@ export async function codexProcessIdentity( ) { processStartTimeMs = await readStartTime(input.pid) } - if (processStartTimeMs === null) { - // Why: recording null makes every later owner probe indeterminate — a durable latch. - // Failing here reaps the child and leaves a retryable refusal instead. - throw new Error(`codex app-server start time for pid ${input.pid} could not be read`) - } return { hostId: input.identity.hostId, pid: input.pid, diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 31d67ef0d88..d55ba5c12bf 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -79,8 +79,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap }) } - supportsLocation = (location: Parameters[0]): boolean => - supportsCodexStructuredLocation(location, this.deps.isWindowsProcessStartTimeAvailable) + supportsLocation = supportsCodexStructuredLocation acquire = (input: StructuredAgentSessionAcquireInput): Promise => acquireCodexStructuredSession({ @@ -168,6 +167,17 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap backgroundTaskState = (sessionId: string): AgentSessionBackgroundTaskState | null | undefined => this.sessions.get(sessionId)?.backgroundTasks.state + // `ended` is set in the same turn as the connection's own exit report. + holdsLiveProviderProcess = (sessionId: string, acquisitionGeneration: string): boolean => { + const session = this.sessions.get(sessionId) + return ( + session?.acquisitionGeneration === acquisitionGeneration && + session.connection.pid !== undefined && + !session.ended && + session.exitObservedAt === undefined + ) + } + // Codex exposes no honest stop for a child thread or a persistent command. backgroundTaskStops: NonNullable = ( sessionId diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 7025014d87a..21383cb821f 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -86,8 +86,6 @@ export type CodexStructuredSessionAdapterDeps = { resolveLaunch: (input: { identity: AgentSessionJournalIdentity }) => Promise - /** Host capability seam; production uses the native Windows process table. */ - isWindowsProcessStartTimeAvailable?: () => boolean onEvent?: (event: CodexStructuredSessionEvent) => void /** Where bookkeeping a close or exit does after the child is gone reports a failure. */ logger?: StructuredAgentSessionLogger diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 7156d497606..6f879a0fab9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -125,6 +125,10 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi holdsDispatch = (sessionId: string): boolean => this.liveOwnerOrNull(sessionId)?.holdsDispatch?.(sessionId) ?? false + holdsLiveProviderProcess = (sessionId: string, acquisitionGeneration: string): boolean => + this.liveOwnerOrNull(sessionId)?.holdsLiveProviderProcess?.(sessionId, acquisitionGeneration) ?? + false + stopEndsSession = (sessionId: string): boolean => this.liveOwnerOrNull(sessionId)?.stopEndsSession?.(sessionId) ?? false 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 62546e4a88e..8acac49cb68 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 @@ -97,8 +97,9 @@ export class AgentSessionAcquisitionRootExitObservedError extends Error { } } -/** The provider child failed and cleanup proved its whole tree gone. As with a root exit, the - * provider's own diagnostic is the message. */ +/** The provider child failed and cleanup proved its whole tree gone — on Windows, that its root + * left on its own after stdin end (descendants not addressed, as with Codex) or taskkill reported + * the tree terminated. As with a root exit, the provider's own diagnostic is the message. */ export class AgentSessionAcquisitionExitProvenError extends Error { constructor(cause: unknown) { super(cause instanceof Error ? cause.message : String(cause), { cause }) @@ -340,6 +341,9 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & /** The provider reported taking a send it has neither answered nor ended, as a queued follow-up * or a silent retry does. Derived from the live child; false with none. */ holdsDispatch?(sessionId: string): boolean + /** The adapter's own child for this exact acquisition has a pid and its root exit has not been + * seen: first-hand proof of life for lease renewal. Absent or false falls back to a PID probe. */ + holdsLiveProviderProcess?(sessionId: string, acquisitionGeneration: string): boolean /** The `/` surface the running provider reports for itself. Undefined when the * provider never reports one, which is what keeps the client on its catalog. */ readCommands?(sessionId: string): AgentSessionSlashCommand[] | undefined diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts index 201242a95e2..2ea3a16024b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts @@ -128,7 +128,7 @@ describe('Claude root-exit stop', () => { claimKeyId: 'key-1', logger: createStructuredAgentSessionLogger() } - const runtimeState = new StructuredAgentSessionHostRuntimeState(deps) + const runtimeState = new StructuredAgentSessionHostRuntimeState(deps, new Map()) claude.connections[0]!.handlers.onExit?.(new Error('provider exited')) await expect( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts index a2de3f33874..5f9d2c216fe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts @@ -318,10 +318,11 @@ describe('deferred structured agent-session event sink', () => { it('replaces a failed cached sink before recovery drain', async () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the cached-sink path reads only the logger, on the failed drain. - const runtime = new StructuredAgentSessionHostRuntimeState({ + const deps = { store: {}, logger: createStructuredAgentSessionLogger() - } as never) + } as never + const runtime = new StructuredAgentSessionHostRuntimeState(deps, new Map()) const failed = runtime.eventSinkFor('session-1') failed.bind(target(1, [], 0)) failed.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts index 4da10e213f6..a8fcbedfa43 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts @@ -38,11 +38,12 @@ function context(closeError?: Error): StructuredAgentSessionEvictionContext & { function runtimeState(): StructuredAgentSessionHostRuntimeState { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: eviction against the sink cache reads only the sinks and the logger; store and adapter are never reached. - return new StructuredAgentSessionHostRuntimeState({ + const deps = { store: {}, adapter: {}, logger: recordingStructuredAgentSessionLogger().logger - } as never) + } as never + return new StructuredAgentSessionHostRuntimeState(deps, new Map()) } describe("the route release after a child's exit", () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-held-child-lease-renewal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-held-child-lease-renewal.test.ts new file mode 100644 index 00000000000..30e1399f91c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-held-child-lease-renewal.test.ts @@ -0,0 +1,203 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { probeAgentSessionProcessIdentity } from '../../runtime/agent-session-process-identity-probe' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' +import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { holdsLiveProviderChild } from './structured-agent-session-provider-child' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-held' +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +/** A live owner recorded without a start time, as on a host that could not read one. */ +async function liveStoreWithoutStartTime(): Promise<{ + root: string + store: AgentSessionRecordStore + fence: number +}> { + const root = await mkdtemp(join(tmpdir(), 'orca-held-child-renewal-')) + roots.push(root) + const store = await openTestAgentSessionRecordStore(root) + const reserved = await store.reserveOwner({ + sessionId: SESSION, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: root }, + expectedFence: null, + spawnToken: 'spawn-held', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'test', + operationId: `${NOW}-00000000000000000000000000000001`, + fingerprint: 'create' + }, + now: NOW + }) + const fence = reserved.record.lease.runtimeFence + await store.commitProcessIdentity({ + sessionId: SESSION, + fence, + process: { hostId: 'local', pid: 4242, processStartTimeMs: null, spawnToken: 'spawn-held' }, + now: NOW + }) + await store.proveOwner({ + sessionId: SESSION, + fence, + link: { + linkId: 'link-held', + handle: codexProviderHandle('thread-held'), + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + }, + now: NOW + }) + return { root, store, fence } +} + +/** The real PID probe on `platform`, with the recorded pid present: no start time, no token echo. */ +function pidProbe(platform: NodeJS.Platform) { + return vi.fn((record: AgentSessionRecord) => + probeAgentSessionProcessIdentity({ + identity: record.lease.ownerProcess!, + deps: { platform, isPidPresent: () => true } + }) + ) +} + +/** The host's child record, read against an adapter that owns the process for `gen-1` until the + * test says its root exited. */ +function hostWith(child: StructuredAgentSessionProviderChild | null) { + const session = { child } + const adapter: { liveGeneration: string | null } = { liveGeneration: 'gen-1' } + return { + adapter, + holdsLiveChild: (sessionId: string, fence: number) => + holdsLiveProviderChild( + sessionId === SESSION ? session : undefined, + fence, + (generation) => generation === adapter.liveGeneration + ) + } +} + +function renewerFor( + store: AgentSessionRecordStore, + probe: ReturnType, + clock: { now: number }, + holdsLiveChild: (sessionId: string, fence: number) => boolean +) { + const log = recordingStructuredAgentSessionLogger() + const renewer = new StructuredAgentSessionLeaseRenewer({ + store, + probe, + holdsLiveChild, + now: () => clock.now, + logger: log.logger + }) + return { renewer, log } +} + +describe.each(['darwin', 'win32'] as const)('lease renewal of a held child on %s', (platform) => { + it('keeps renewing a held child with no start time, without a PID probe', async () => { + const { store, fence } = await liveStoreWithoutStartTime() + const { holdsLiveChild } = hostWith({ generation: 'gen-1', fence, phase: 'ready' }) + const probe = pidProbe(platform) + const clock = { now: NOW + 10_000 } + const { renewer, log } = renewerFor(store, probe, clock, holdsLiveChild) + + await renewer.renewNow() + clock.now = NOW + 20_000 + await renewer.renewNow() + + expect(probe).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.lease.lastRenewedAt).toBe(NOW + 20_000) + expect(log.entries).toEqual([]) + }) + + it('stops renewing the moment the adapter sees the root exit', async () => { + const { store, fence } = await liveStoreWithoutStartTime() + const { adapter, holdsLiveChild } = hostWith({ generation: 'gen-1', fence, phase: 'ready' }) + const probe = pidProbe(platform) + const clock = { now: NOW + 10_000 } + const { renewer } = renewerFor(store, probe, clock, holdsLiveChild) + await renewer.renewNow() + + // The child is still on the host's record: only the adapter has seen the exit so far. + adapter.liveGeneration = null + clock.now = NOW + 20_000 + await renewer.renewNow() + + // Back on the PID probe, which cannot vouch for it: the lease keeps its last proof. + expect(probe).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease.lastRenewedAt).toBe(NOW + 10_000) + }) + + it('dates a crash death to the last held renewal, not to the spawn', async () => { + const { root, store, fence } = await liveStoreWithoutStartTime() + const { holdsLiveChild } = hostWith({ generation: 'gen-1', fence, phase: 'ready' }) + const clock = { now: NOW + 10_000 } + const { renewer } = renewerFor(store, pidProbe(platform), clock, holdsLiveChild) + await renewer.renewNow() + clock.now = NOW + 20_000 + await renewer.renewNow() + + // Orca crashed: the next runtime holds no child and finds the recorded pid gone. + const reopened = await openTestAgentSessionRecordStore(root) + await reopened.reconcileOnRestart({ + probe: async () => ({ outcome: 'pid-absent' }), + now: NOW + 600_000 + }) + + expect(reopened.getRecord(SESSION)?.lease.deathEvidence).toMatchObject({ + kind: 'pid-absent', + lastProvenAliveAt: NOW + 20_000 + }) + }) + + it('still probes a record this runtime does not hold at its fence', async () => { + const { store, fence } = await liveStoreWithoutStartTime() + // An older child, one whose acquisition the adapter does not run, or none at all. + for (const child of [ + { generation: 'gen-0', fence: fence - 1, phase: 'ready' } as const, + { generation: 'gen-2', fence, phase: 'ready' } as const, + { generation: null, fence, phase: 'ready' } as const, + null + ]) { + const { holdsLiveChild } = hostWith(child) + const probe = pidProbe(platform) + const { renewer, log } = renewerFor(store, probe, { now: NOW + 10_000 }, holdsLiveChild) + + await renewer.renewNow() + + expect(probe).toHaveBeenCalledOnce() + await expect(probe.mock.results[0]?.value).resolves.toMatchObject({ + outcome: 'indeterminate' + }) + expect(store.getRecord(SESSION)?.lease.lastRenewedAt).toBe(NOW) + expect(log.entries.map((entry) => entry.fields)).toContainEqual({ + scope: 'lease-renewal', + sessionId: SESSION, + error: expect.objectContaining({ message: 'agent_session_ownership_unknown' }) + }) + } + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-held-child-renewal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-held-child-renewal.test.ts new file mode 100644 index 00000000000..8669a3c92d2 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-held-child-renewal.test.ts @@ -0,0 +1,116 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { probeAgentSessionProcessIdentity } from '../../runtime/agent-session-process-identity-probe' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { + closeTestJournalHostDatabase, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams +} from './structured-agent-session-host-test-data' +import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' + +const RENEW_INTERVAL_MS = 10_000 +const roots: string[] = [] + +afterEach(async () => { + vi.useRealTimers() + for (const root of roots) { + closeTestJournalHostDatabase(root) + } + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +/** A real host whose adapter spawned a child it could not read a start time for. */ +async function hostWithStartTimeLessChild(platform: NodeJS.Platform) { + const root = await mkdtemp(join(tmpdir(), 'orca-host-held-renewal-')) + roots.push(root) + const store = await openTestAgentSessionRecordStore(root) + const clock = { now: NOW } + const process = { live: true } + const holdsLiveProviderProcess = vi.fn( + (_sessionId: string, generation: string) => process.live && generation === 'gen-1' + ) + const adapter: StructuredAgentSessionAdapter = { + acquire: async ({ fence }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: null, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: codexProviderHandle(THREAD), + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + }, + acquisitionGeneration: 'gen-1' + }), + holdsLiveProviderProcess, + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + } + // The real PID probe with the pid present: no start time and no token echo to match. + const probeOwner = vi.fn((record: AgentSessionRecord) => + probeAgentSessionProcessIdentity({ + identity: record.lease.ownerProcess!, + deps: { platform, isPidPresent: () => true } + }) + ) + const host = new StructuredAgentSessionHost({ + logger: recordingProductionStructuredAgentSessionLogger().logger, + store, + adapter, + agents: claudeAndCodexDeclared(), + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + probeOwner, + now: () => clock.now + }) + return { host, store, clock, process, probeOwner, holdsLiveProviderProcess } +} + +describe.each(['darwin', 'win32'] as const)('host lease renewal on %s', (platform) => { + it('renews a held child without a PID probe until the adapter sees its root exit', async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) + const { host, store, clock, process, probeOwner, holdsLiveProviderProcess } = + await hostWithStartTimeLessChild(platform) + const attached = await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null)) + expect(attached.ok).toBe(true) + const fence = store.getRecord(SESSION)!.lease.runtimeFence + + clock.now = NOW + RENEW_INTERVAL_MS + await vi.advanceTimersByTimeAsync(RENEW_INTERVAL_MS) + await vi.waitFor(() => + expect(store.getRecord(SESSION)?.lease.lastRenewedAt).toBe(NOW + RENEW_INTERVAL_MS) + ) + expect(probeOwner).not.toHaveBeenCalled() + expect(holdsLiveProviderProcess).toHaveBeenCalledWith(SESSION, 'gen-1') + + // The adapter saw the root exit; the host has not settled it, so its child is still on record. + process.live = false + clock.now = NOW + 2 * RENEW_INTERVAL_MS + await vi.advanceTimersByTimeAsync(RENEW_INTERVAL_MS) + await vi.waitFor(() => expect(probeOwner).toHaveBeenCalledOnce()) + + expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(fence) + expect(store.getRecord(SESSION)?.lease.lastRenewedAt).toBe(NOW + RENEW_INTERVAL_MS) + await host.flushAllStreamedEvents() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts index e8421b02893..dd4d0976bd8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts @@ -73,7 +73,7 @@ function runtimeState( probeOwner, logger: createStructuredAgentSessionLogger() } as StructuredAgentSessionHostDeps - return new StructuredAgentSessionHostRuntimeState(deps) + return new StructuredAgentSessionHostRuntimeState(deps, new Map()) } function liveRecord(): AgentSessionRecord { @@ -149,7 +149,7 @@ describe('host runtime-state owner probe', () => { probeOwner, logger: log.logger } as unknown as StructuredAgentSessionHostDeps - const state = new StructuredAgentSessionHostRuntimeState(deps, onEventSinkFailure) + const state = new StructuredAgentSessionHostRuntimeState(deps, new Map(), onEventSinkFailure) await ( state as unknown as { leaseRenewer: { renewNow: () => Promise } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts index fd0fd17b71e..83b53777d4e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts @@ -7,6 +7,10 @@ import { } from './structured-agent-session-event-sink' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host' import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer' +import { + heldProviderChildReader, + type ProviderChildSessions +} from './structured-agent-session-provider-child' import { resolveStructuredSessionRecovery } from './structured-agent-session-recovery-resolution' export class StructuredAgentSessionHostRuntimeState { @@ -16,6 +20,8 @@ export class StructuredAgentSessionHostRuntimeState { constructor( private readonly deps: StructuredAgentSessionHostDeps, + /** Required: a child held here renews its lease without a PID probe. */ + sessions: ProviderChildSessions, onEventSinkFailure?: (sessionId: string, error: unknown) => void ) { this.onEventSinkFailure = onEventSinkFailure @@ -23,6 +29,7 @@ export class StructuredAgentSessionHostRuntimeState { store: deps.store, probe: (record) => this.probeRecord(record), ...(deps.probeOwners ? { probeMany: deps.probeOwners } : {}), + holdsLiveChild: heldProviderChildReader(sessions, deps.adapter), now: () => deps.now?.() ?? Date.now(), // Lease/ownership failures are transient and stay on the visible lease-error path. // Only deferred sink I/O failures are terminal and may force-close a provider. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 863ab9edd68..91ae48ea173 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -109,8 +109,8 @@ export class StructuredAgentSessionHost { (sessionId) => this.lifetime.conversation(sessionId), this.clientDelivery.readChildWork ) - this.runtimeState = new StructuredAgentSessionHostRuntimeState(deps, (sessionId, error) => - this.eventRecovery.recoverAfterSinkFailure(sessionId, error) + this.runtimeState = new StructuredAgentSessionHostRuntimeState(deps, this.sessions, (id, e) => + this.eventRecovery.recoverAfterSinkFailure(id, e) ) this.reconcileLeases = createRestartReconciler({ store: deps.store, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts index f428ab59a1b..a937458f24b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts @@ -105,6 +105,7 @@ describe('structured agent-session lease renewal', () => { ) ) const renewer = new StructuredAgentSessionLeaseRenewer({ + holdsLiveChild: () => false, logger: recordingStructuredAgentSessionLogger().logger, store: { listRecords: () => records, renewLeases } as unknown as AgentSessionRecordStore, probe: vi.fn(), @@ -149,6 +150,7 @@ describe('structured agent-session lease renewal', () => { }) const log = recordingStructuredAgentSessionLogger() const renewer = new StructuredAgentSessionLeaseRenewer({ + holdsLiveChild: () => false, store: { listRecords: () => records, renewLeases, @@ -178,6 +180,7 @@ describe('structured agent-session lease renewal', () => { const store = await liveStore() let now = NOW const renewer = new StructuredAgentSessionLeaseRenewer({ + holdsLiveChild: () => false, logger: recordingStructuredAgentSessionLogger().logger, store, probe: async () => ({ @@ -208,6 +211,7 @@ describe('structured agent-session lease renewal', () => { releaseProbe = resolve }) const renewer = new StructuredAgentSessionLeaseRenewer({ + holdsLiveChild: () => false, logger: recordingStructuredAgentSessionLogger().logger, store, probe: async () => { @@ -234,6 +238,7 @@ describe('structured agent-session lease renewal', () => { matchedOn: ['process-start-time' as const] })) const renewer = new StructuredAgentSessionLeaseRenewer({ + holdsLiveChild: () => false, logger: recordingStructuredAgentSessionLogger().logger, store, probe, @@ -250,6 +255,7 @@ describe('structured agent-session lease renewal', () => { const store = await liveStore() const log = recordingStructuredAgentSessionLogger() const renewer = new StructuredAgentSessionLeaseRenewer({ + holdsLiveChild: () => false, store, probe: async () => ({ outcome: 'indeterminate', reason: 'probe unavailable' }), now: () => NOW + 10_000, @@ -279,6 +285,7 @@ describe('structured agent-session lease renewal', () => { matchedOn: ['process-start-time' as const] })) const renewer = new StructuredAgentSessionLeaseRenewer({ + holdsLiveChild: () => false, logger: recordingStructuredAgentSessionLogger().logger, store, probe, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts index 7888d4867e2..f969c65428f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts @@ -8,6 +8,11 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo const RENEW_INTERVAL_MS = Math.floor(AGENT_SESSION_LEASE_TTL_MS / 3) +const HELD_CHILD: AgentSessionOwnerProbe = { + outcome: 'identity-matched', + matchedOn: ['held-child'] +} + export class StructuredAgentSessionLeaseRenewer { private timer: ReturnType | null = null private running = false @@ -22,6 +27,8 @@ export class StructuredAgentSessionLeaseRenewer { probeMany?: ( records: readonly AgentSessionRecord[] ) => Promise> + /** Whether this runtime holds the session's child at `fence` and has not seen it exit. */ + holdsLiveChild: (sessionId: string, fence: number) => boolean now: () => number logger: StructuredAgentSessionLogger intervalMs?: number @@ -72,7 +79,20 @@ export class StructuredAgentSessionLeaseRenewer { // keeps an orphan pid's lease reading as a healthy owner. record.lease.handoffStage !== 'recovering' ) - const probes = await this.probe(records) + // A child this runtime holds proves itself; a PID probe can be indeterminate for it (no start + // time, no token echo), which would freeze the lease at its last proof. + const holds = (record: AgentSessionRecord): boolean => + this.input.holdsLiveChild(record.sessionId, record.lease.runtimeFence) + const held = records.filter(holds) + const unheld = records.filter((record) => !held.includes(record)) + const probes = + unheld.length > 0 ? await this.probe(unheld) : new Map() + for (const record of held) { + // Re-read after the probes: an exit received meanwhile ends the child's proof. + if (holds(record)) { + probes.set(record.sessionId, HELD_CHILD) + } + } const renewals: { sessionId: string fence: number diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts index d4236f395da..978d65d055b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts @@ -8,6 +8,7 @@ import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionEndedChild, StructuredAgentSessionHostSession, @@ -47,6 +48,37 @@ export function markProviderChildStarted( return child !== null } +/** This runtime holds a child at `fence` whose process its adapter still sees running: first-hand + * proof of life that needs no PID probe. A previous runtime's child is never on record here. */ +export function holdsLiveProviderChild( + session: Pick | undefined, + fence: number, + processLive: (acquisitionGeneration: string) => boolean +): boolean { + const child = session?.child + return ( + !!child && child.fence === fence && child.generation !== null && processLive(child.generation) + ) +} + +/** The host's conversations, as lease renewal reads their children. */ +export type ProviderChildSessions = { + get(sessionId: string): Pick | undefined +} + +/** Lease renewal's held-child read, from the host's child record and the adapter's own handle. */ +export function heldProviderChildReader( + sessions: ProviderChildSessions, + adapter: Pick | undefined +): (sessionId: string, fence: number) => boolean { + return (sessionId, fence) => + holdsLiveProviderChild( + sessions.get(sessionId), + fence, + (generation) => adapter?.holdsLiveProviderProcess?.(sessionId, generation) === true + ) +} + /** `endedAt` is a close's ask; an exit of the child's own ends where the journal stands. */ export function endProviderChild( session: ChildBearer, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts index 93c9b2f569a..d92eaddadc3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts @@ -243,6 +243,31 @@ describe('structured session recovery resolution', () => { }) }) + it('releases a Windows owner the probe matches without signalling its saved pid', async () => { + const store = await openStore() + await liveOwner(store) + await latch(store) + const stopOwnerProcess = vi.fn() + + const result = await resolveStructuredSessionRecovery( + deps(store, () => ({ outcome: 'identity-matched', matchedOn: ['process-start-time'] }), { + stopOwnerProcess, + platform: 'win32' + }), + SESSION + ) + + expect(result).toBe('resolved') + // Windows stops a tree only through a child it still holds; a saved pid is never signalled. + expect(stopOwnerProcess).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + ownerProcess: null, + deathEvidence: null + }) + }) + it('waits out a terminal owner an older build recorded, and never stops it', async () => { const directory = await newStoreDirectory() await liveOwner(await openStore(directory)) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts index 5dbd9a4071b..257072f7c23 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts @@ -4,9 +4,10 @@ * evicted on proof. A live one is stopped by identity and evicted once * proven gone. One that outlives the stop, or whose identity cannot be verified, is released * anyway: its transport died with the runtime that held it, so nothing can drive it, and no signal - * is sent to a pid that cannot be verified as the one recorded. Only a conflicted claim, which is - * how a terminal owner an older build recorded now loads, is waited out and never stopped: it is - * the user's own agent, and its exit is its way out. + * is sent to a pid that cannot be verified as the one recorded. Windows never signals a saved pid + * at all: Orca stops a Windows tree only through a child it still holds. Only a conflicted claim, + * which is how a terminal owner an older build recorded now loads, is waited out and never + * stopped: it is the user's own agent, and its exit is its way out. */ import { @@ -27,11 +28,13 @@ export type StructuredSessionRecoveryResolutionDeps = { now: () => number stopOwnerProcess?: (pid: number, signal: StructuredSessionRecoveryStopSignal) => void delay?: (ms: number) => Promise + platform?: NodeJS.Platform } const STOP_PROBE_INTERVAL_MS = 250 -// A POSIX structured owner is its provider supervisor, which exits only after its provider -// group. A SIGKILL that lands first leaves the group running, so SIGTERM outlasts its stop. +// POSIX only: Windows never signals a recorded owner. The owner is its provider supervisor, which +// exits only after its provider group. A SIGKILL that lands first leaves the group running, so +// SIGTERM outlasts its stop. const STOP_PROBES: Record = { SIGTERM: Math.ceil(PROVIDER_SUPERVISOR_MAX_STOP_MS / STOP_PROBE_INTERVAL_MS) + 1, SIGKILL: 4 @@ -62,7 +65,9 @@ export async function resolveStructuredSessionRecovery( if (owner.hostId !== deps.store.hostId) { return 'unresolved' } - probe = await stopOwnerAndReprobe(deps, record, owner.pid) + if ((deps.platform ?? process.platform) !== 'win32') { + probe = await stopOwnerAndReprobe(deps, record, owner.pid) + } } try { await (owner && !isProvenDeadProbe(probe) diff --git a/src/main/provider-process/managed-provider-process-fallback-tree.test.ts b/src/main/provider-process/managed-provider-process-fallback-tree.test.ts index 4ec32aa9a8b..27369311656 100644 --- a/src/main/provider-process/managed-provider-process-fallback-tree.test.ts +++ b/src/main/provider-process/managed-provider-process-fallback-tree.test.ts @@ -7,7 +7,7 @@ import { spawnManagedProviderProcess } from './managed-provider-process' // The real fallback teardown; only the primitives that touch the OS are faked. const os = vi.hoisted(() => ({ - taskkill: vi.fn(async () => {}), + taskkill: vi.fn(async (): Promise => true), capture: vi.fn(async (): Promise => null), verifySnapshot: vi.fn(async () => 'exited' as const) })) @@ -48,13 +48,20 @@ function rootOnly(platform: NodeJS.Platform) { } describe('fallback teardown never claims descendants it did not observe', () => { - it('reports no observation after a Windows tree kill, whose outcome is unreadable', async () => { - vi.useFakeTimers() - const closing = rootOnly('win32').close() - await vi.advanceTimersByTimeAsync(1_500) - await expect(closing).resolves.toEqual({ root: 'exited', tree: null }) - expect(os.taskkill).toHaveBeenCalledOnce() - }) + it.each([ + { taskkill: true, tree: 'exited' }, + { taskkill: false, tree: 'unverifiable' } + ] as const)( + "reports taskkill's own verdict after a Windows tree kill: taskkill $taskkill", + async ({ taskkill, tree }) => { + vi.useFakeTimers() + os.taskkill.mockResolvedValueOnce(taskkill) + const closing = rootOnly('win32').close() + await vi.advanceTimersByTimeAsync(1_500) + await expect(closing).resolves.toEqual({ root: 'exited', tree }) + expect(os.taskkill).toHaveBeenCalledOnce() + } + ) it('reports no observation when the POSIX process table cannot be read', async () => { vi.useFakeTimers() diff --git a/src/main/provider-process/provider-process-close.ts b/src/main/provider-process/provider-process-close.ts index e67645f2cf2..24347bea933 100644 --- a/src/main/provider-process/provider-process-close.ts +++ b/src/main/provider-process/provider-process-close.ts @@ -9,12 +9,17 @@ export type ProviderProcessTree = { refresh?: () => Promise reap(): Promise readonly treeVerdict: DescendantTreeVerdict + /** A reap has reached the root while it lived: its exit since then is no longer its own. */ + readonly forcedReapAttempted?: boolean } export type ProviderProcessClosePolicy = { gracefulExitMs: number forcedExitMs: number signalSupervisorOnClose?: boolean + /** A root that leaves on its own after its stdin ends, with no forced reap ever on its tree, is + * the close: no claim is made about its descendants, and no post-exit reap runs. */ + selfExitIsClose?: boolean } export type ProviderProcessCloseInput = { @@ -31,6 +36,8 @@ export type ProviderProcessCloseResult = { root: DescendantTreeVerdict /** Null when this close made no observation of the descendants. */ tree: DescendantTreeVerdict | null + /** Set when `selfExitIsClose` decided the close. */ + selfExit?: true } export const ROOT_ONLY_GRACEFUL_EXIT_MS = 1_500 @@ -80,6 +87,14 @@ export async function closeProviderProcess( await waitForProcessExitUntil(input.exitPromise, policy.forcedExitMs) } } + if ( + policy.selfExitIsClose && + !reaped && + !tree?.forcedReapAttempted && + input.rootVerdict() === 'exited' + ) { + return { root: 'exited', tree: tree ? tree.treeVerdict : null, selfExit: true } + } if (!reaped && input.rootVerdict() === 'exited' && tree && tree.treeVerdict !== 'exited') { await tree.reap() } diff --git a/src/main/provider-process/provider-process-teardown.test.ts b/src/main/provider-process/provider-process-teardown.test.ts index 75e181aaa46..62aeb53ccba 100644 --- a/src/main/provider-process/provider-process-teardown.test.ts +++ b/src/main/provider-process/provider-process-teardown.test.ts @@ -22,9 +22,9 @@ describe('terminateProviderProcessTree', () => { resetSelfInitiatedTreeKillLogForTest() }) - it('waits for the Windows tree kill before releasing the wrapper, and claims no observation', async () => { + it('waits for the Windows tree kill before releasing the wrapper, and reports what taskkill reported', async () => { const target = child() - const release = Promise.withResolvers() + const release = Promise.withResolvers() const terminateWindowsTree = vi.fn(() => release.promise) const teardown = terminateProviderProcessTree(target, { @@ -33,16 +33,29 @@ describe('terminateProviderProcessTree', () => { terminateWindowsTree }) expect(target.kill).not.toHaveBeenCalled() - release.resolve() - // taskkill resolves alike on success, failure and timeout. - await expect(teardown).resolves.toBeNull() + release.resolve(true) + await expect(teardown).resolves.toBe('exited') expect(terminateWindowsTree).toHaveBeenCalledWith(1234, { site: 'codex-app-server-teardown' }) expect(target.kill).toHaveBeenCalledWith('SIGKILL') }) + it('reports an unproven Windows tree when taskkill does not exit cleanly', async () => { + const target = child() + + await expect( + terminateProviderProcessTree(target, { + site: 'codex-app-server-teardown', + platform: 'win32', + terminateWindowsTree: async () => false + }) + ).resolves.toBe('unverifiable') + // The held child is still killed through its handle. + expect(target.kill).toHaveBeenCalledWith('SIGKILL') + }) + it('passes a non-Codex diagnostic label to Windows teardown', async () => { - const terminateWindowsTree = vi.fn(async () => undefined) + const terminateWindowsTree = vi.fn(async () => true) await terminateProviderProcessTree(child(), { site: 'provider-test-teardown', diff --git a/src/main/provider-process/provider-process-teardown.ts b/src/main/provider-process/provider-process-teardown.ts index 7d30586777b..ef414d2b4c3 100644 --- a/src/main/provider-process/provider-process-teardown.ts +++ b/src/main/provider-process/provider-process-teardown.ts @@ -20,7 +20,8 @@ export type ProviderProcessTeardownDeps = { dedicatedProcessGroup?: boolean captureDescendants?: (rootPid: number) => Promise terminateDescendants?: (snapshot: DescendantSnapshot) => Promise - terminateWindowsTree?: (rootPid: number, deps?: { site?: string }) => Promise + /** Resolves true only when taskkill reports the whole tree terminated. */ + terminateWindowsTree?: (rootPid: number, deps?: { site?: string }) => Promise signalProcessGroup?: (pgid: number, signal: NodeJS.Signals) => void } @@ -114,11 +115,11 @@ async function terminateOnce( } if ((deps.platform ?? process.platform) === 'win32') { const terminate = deps.terminateWindowsTree ?? terminateWindowsProcessTree - await terminate(rootPid, { site: deps.site }) + const treeTerminated = await terminate(rootPid, { site: deps.site }) // taskkill owns the tree; this preserves the prior direct-child fallback when it fails. child.kill('SIGKILL') - // taskkill resolves alike on success, failure and timeout, so nothing was observed. - return null + // Taskkill's own report, not an observation: only its clean exit says the tree is gone. + return treeTerminated ? 'exited' : 'unverifiable' } if (deps.dedicatedProcessGroup) { return terminateDedicatedPosixGroup(rootPid, deps) diff --git a/src/main/providers/windows-foreground-process-rows.ts b/src/main/providers/windows-foreground-process-rows.ts index de24a271712..8d93b4deb83 100644 --- a/src/main/providers/windows-foreground-process-rows.ts +++ b/src/main/providers/windows-foreground-process-rows.ts @@ -106,26 +106,6 @@ export async function queryWindowsPaneProcessInventory( } } -/** - * The descendant walk over rows the caller already read. - * - * Why exported: a caller that needs a field this module's projection drops — - * process creation time, for a PID-reuse-safe teardown snapshot — would - * otherwise read the whole table a second time to get it. - * Null when the root is absent, which is a stale or filtered snapshot rather - * than a root with no descendants. - */ -export function windowsDescendantsFromRows( - rows: Row[], - rootPid: number -): (Row & { depth: number })[] | null { - const index = getProcessTableIndex(rows) - if (!index.byPid.has(rootPid)) { - return null - } - return collectDescendantsFromIndex(index, rootPid).sort((a, b) => b.depth - a.depth) -} - /** Test-only: clear the shared snapshot so one case's rows never serve the next. */ export function resetWindowsProcessRowsSnapshotForTests(): void { resetWindowsProcessTableForTests() diff --git a/src/main/runtime/agent-session-acquisition-failure-settlement.ts b/src/main/runtime/agent-session-acquisition-failure-settlement.ts index f6efaa890c3..1be4974fc65 100644 --- a/src/main/runtime/agent-session-acquisition-failure-settlement.ts +++ b/src/main/runtime/agent-session-acquisition-failure-settlement.ts @@ -13,7 +13,9 @@ 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. + * - `exit-proven`: cleanup observed the whole tree gone. On Windows it is the provider close's own + * proof: the root left on its own after its stdin ended, with its descendants not addressed (as + * with Codex), or a forced `taskkill /T` reported the tree terminated. * - `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 were not proven * gone. Releases the lease and says exactly that, claiming nothing more. diff --git a/src/main/runtime/agent-session-lease-transitions.ts b/src/main/runtime/agent-session-lease-transitions.ts index 1737ad45d3f..950e05a8e78 100644 --- a/src/main/runtime/agent-session-lease-transitions.ts +++ b/src/main/runtime/agent-session-lease-transitions.ts @@ -178,8 +178,9 @@ export function proveAgentSessionOwner(args: { /** * A renewal asserts two things at once: the host is running its loop, and the child still matches - * the recorded identity. A host that cannot re-verify the child stops renewing rather than - * extending a lease it can no longer vouch for. + * the recorded identity — re-proven by a PID probe, or held by this runtime with no exit seen. A + * host that cannot re-verify the child stops renewing rather than extending a lease it can no + * longer vouch for. */ export function renewAgentSessionLease(args: { record: AgentSessionRecord diff --git a/src/main/runtime/agent-session-process-identity-probe.ts b/src/main/runtime/agent-session-process-identity-probe.ts index 0fe0a16f4ee..2c7992af6f5 100644 --- a/src/main/runtime/agent-session-process-identity-probe.ts +++ b/src/main/runtime/agent-session-process-identity-probe.ts @@ -10,8 +10,8 @@ import { readFile } from 'node:fs/promises' import type { - AgentSessionIdentityMatchField, - AgentSessionOwnerProbe + AgentSessionOwnerProbe, + AgentSessionProcessIdentityField } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' import { runProcess } from '../../shared/child-process/run-process' @@ -114,8 +114,8 @@ async function readDarwinProcessStartTimesMs( } async function readWindowsProcessStartTimeMs(pid: number): Promise { - // No shipped addon build exposes the creation-time flag, so without this the - // whole table gets scanned to produce `null` every time. + // A binary without the creation-time flag (one built before Orca's patch) would + // otherwise scan the whole table to produce `null` every time. if (!isWindowsProcessStartTimeAvailable()) { return null } @@ -242,7 +242,7 @@ export async function probeAgentSessionProcessIdentity(args: { if (!isPidPresent(identity.pid)) { return { outcome: 'pid-absent' } } - const matchedOn: AgentSessionIdentityMatchField[] = [] + const matchedOn: AgentSessionProcessIdentityField[] = [] const echoedToken = await deps.readEchoedSpawnToken?.(identity).catch(() => null) if (echoedToken !== null && echoedToken !== undefined) { if (echoedToken !== identity.spawnToken) { diff --git a/src/main/runtime/orca-runtime-get-status.ts b/src/main/runtime/orca-runtime-get-status.ts index 39b8dbf0dcf..3451cd8af30 100644 --- a/src/main/runtime/orca-runtime-get-status.ts +++ b/src/main/runtime/orca-runtime-get-status.ts @@ -75,8 +75,9 @@ export class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntimeId { const hasOffscreen = !hasRenderer && Boolean(this.offscreenBrowserBackend) const hasHeadlessCommands = runtimeBrowserCommandsFactoryIsHeadless() const canBrowse = hasRenderer || hasOffscreen - // This field reports current Windows process-identity proof. Structured RPC - // support itself stays advertised; agentSession.createSupport owns current eligibility. + // TEMPORARY: nothing on this host reads it any more. Clients older than this build keep + // re-probing their WSL capabilities until it is true, so it is published for one + // compatibility window and then removed. const windowsProcessStartTimeAvailable = process.platform === 'win32' && isWindowsProcessStartTimeAvailable() const capabilities: RuntimeCapability[] = RUNTIME_CAPABILITIES.filter( diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index f829560697d..d2da7b0f92c 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -71,8 +71,8 @@ import { sendStructuredAgentSessionForClient } from './structured-agent-session- /** * The attach-shaped entries take the location from the client instead of resolving it from a * worktree, so they never reach the worktree-resolving create-support check. Ask the executing - * host the same question directly: the answer includes host-measured facts the client cannot see - * or forge, such as whether this machine can read a provider child's process start time. + * host the same question directly: only it knows which agents and locations it runs, and a client + * cannot forge that answer. */ async function resolveClientSuppliedAttach(params: z.infer, ctx: RpcContext) { await ensureHostInstalled(ctx) diff --git a/src/main/runtime/structured-agent-session-failure-logging.test.ts b/src/main/runtime/structured-agent-session-failure-logging.test.ts index 14efe114f90..239ff65f11c 100644 --- a/src/main/runtime/structured-agent-session-failure-logging.test.ts +++ b/src/main/runtime/structured-agent-session-failure-logging.test.ts @@ -132,9 +132,8 @@ describe('failures the desktop host used to drop', () => { it('logs provider events the chat journal would not take', async () => { const log = recordingStructuredAgentSessionLogger() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: minting and binding a sink reads no other host dependency. - const runtime = new StructuredAgentSessionHostRuntimeState({ - logger: log.logger - } as unknown as StructuredAgentSessionHostDeps) + const deps = { logger: log.logger } as unknown as StructuredAgentSessionHostDeps + const runtime = new StructuredAgentSessionHostRuntimeState(deps, new Map()) const sink = runtime.eventSinkFor(SESSION) const error = new Error('journal append failed') // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the sink calls only appendItem before it fails. diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts index f572e890983..eb0bb48c8a0 100644 --- a/src/main/runtime/structured-agent-session-runtime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime.test.ts @@ -286,7 +286,7 @@ describe('structured agent-session runtime install', () => { expect(stopped).toBe(true) }) - it('does not infer Windows process identity support from an injected reader', async () => { + it('supports native Windows creation without the process-table addon', async () => { stateDirectory = await mkdtemp(join(tmpdir(), 'orca-structured-runtime-')) const originalPlatform = process.platform const location: AgentSessionExecutionLocation = { @@ -309,7 +309,9 @@ describe('structured agent-session runtime install', () => { readProcessStartTime: async () => 1_700_000_000_000 }) - expect(host.supportsCreate(location, 'codex')).toBe(false) + // No addon means no creation times; chat no longer depends on them. + expect(host.supportsCreate(location, 'codex')).toBe(true) + expect(host.supportsCreate(location, 'claude')).toBe(true) } finally { __setWindowsProcessTreeLoaderForTests() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) diff --git a/src/main/runtime/structured-agent-session-support-probe.test.ts b/src/main/runtime/structured-agent-session-support-probe.test.ts index ea0ab3298d9..6bfdcddba0f 100644 --- a/src/main/runtime/structured-agent-session-support-probe.test.ts +++ b/src/main/runtime/structured-agent-session-support-probe.test.ts @@ -115,23 +115,21 @@ describe('structured agent-session create-support probe', () => { ) it.each([ - ['codex', true, { supported: true }], - ['codex', false, { supported: false, reason: 'agent' }], - ['claude', true, { supported: true }], - ['claude', false, { supported: false, reason: 'agent' }] + ['codex', true], + ['codex', false], + ['claude', true], + ['claude', false] ] as const)( - 'requires native Windows process identity proof before answering %s support (%s)', - async (agent, proofAvailable, expected) => { + 'answers native Windows %s support whether or not process creation times are readable (%s)', + async (agent, creationTimesReadable) => { setPlatform('win32') - isWindowsProcessStartTimeAvailable.mockReturnValue(proofAvailable) + isWindowsProcessStartTimeAvailable.mockReturnValue(creationTimesReadable) await expectSupportWithoutInstall({ agent, location: { executionHostId: 'local', wslDistro: null }, - expected + expected: { supported: true } }) - - expect(isWindowsProcessStartTimeAvailable).toHaveBeenCalled() } ) diff --git a/src/main/windows-descendant-exit-verification.test.ts b/src/main/windows-descendant-exit-verification.test.ts deleted file mode 100644 index d1944f5ef86..00000000000 --- a/src/main/windows-descendant-exit-verification.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - captureWindowsDescendantSnapshot, - terminateIdentifiedWindowsProcessTree, - verifyWindowsDescendantSnapshotExit, - type WindowsDescendantSnapshot -} from './windows-descendant-exit-verification' - -function snapshot( - descendants: { pid: number; creationTimeMs: number }[], - unidentifiedCount = 0 -): WindowsDescendantSnapshot { - return { - root: { pid: 100, creationTimeMs: 5 }, - descendants, - unidentifiedCount, - capturedAtMs: 1_700_000_000_000 - } -} - -describe('captureWindowsDescendantSnapshot', () => { - it('does not claim an older process whose former parent PID was reused by the root', async () => { - const olderProcess = { pid: 50244, ppid: 36084, creationTimeMs: 1788659167395 } - const captured = await captureWindowsDescendantSnapshot(36084, { - readTable: async () => [ - { pid: 36084, ppid: 60976, creationTimeMs: 1788733587893 }, - olderProcess - ] - }) - - expect(captured?.descendants).toEqual([]) - await expect( - verifyWindowsDescendantSnapshotExit(captured!, { readTable: async () => [olderProcess] }) - ).resolves.toBe('exited') - }) - - it('prunes a stale parent link and its subtree at any depth', async () => { - const captured = await captureWindowsDescendantSnapshot(100, { - readTable: async () => [ - { pid: 100, ppid: 1, creationTimeMs: 5 }, - { pid: 200, ppid: 100, creationTimeMs: 10 }, - { pid: 300, ppid: 200, creationTimeMs: 7 }, - { pid: 400, ppid: 300, creationTimeMs: 12 }, - { pid: 500, ppid: 100, creationTimeMs: 4 }, - { pid: 600, ppid: 500, creationTimeMs: 13 }, - { pid: 700, ppid: 200, creationTimeMs: 10 } - ] - }) - - expect(captured?.descendants).toEqual([ - { pid: 700, creationTimeMs: 10 }, - { pid: 200, creationTimeMs: 10 } - ]) - }) - - it('keeps the root when its own parent PID was reused by a newer process', async () => { - // The root's retained ppid now names a process created after it. Pruning the - // root drops the whole snapshot, so its own link is never evidence about it. - const captured = await captureWindowsDescendantSnapshot(100, { - readTable: async () => [ - { pid: 100, ppid: 900, creationTimeMs: 5 }, - { pid: 900, ppid: 1, creationTimeMs: 50 }, - { pid: 200, ppid: 100, creationTimeMs: 7 } - ], - now: () => 42 - }) - - expect(captured).toEqual({ - root: { pid: 100, creationTimeMs: 5 }, - descendants: [{ pid: 200, creationTimeMs: 7 }], - unidentifiedCount: 0, - capturedAtMs: 42 - }) - }) - - it('bounds a link by the root when the claimed parent denied its creation time', async () => { - // 300 has no creation time for a child to be compared against, so the root's - // start is the only bound left: 350 ties with it, which a same-millisecond - // spawn does routinely, while 360 predates the whole tree. - const captured = await captureWindowsDescendantSnapshot(100, { - readTable: async () => [ - { pid: 100, ppid: 1, creationTimeMs: 5 }, - { pid: 300, ppid: 100 }, - { pid: 350, ppid: 300, creationTimeMs: 5 }, - { pid: 360, ppid: 300, creationTimeMs: 2 } - ], - now: () => 42 - }) - - expect(captured).toEqual({ - root: { pid: 100, creationTimeMs: 5 }, - descendants: [{ pid: 350, creationTimeMs: 5 }], - unidentifiedCount: 1, - capturedAtMs: 42 - }) - }) - - it('drops an unidentified row whose parent link was pruned', async () => { - // 250 denied its creation time, but 200's claim on the root is impossible, so - // 250 was never in this tree: counting it would cap the verdict at - // unverifiable over a process the root does not own. - const captured = await captureWindowsDescendantSnapshot(100, { - readTable: async () => [ - { pid: 100, ppid: 1, creationTimeMs: 10 }, - { pid: 200, ppid: 100, creationTimeMs: 5 }, - { pid: 250, ppid: 200 } - ] - }) - - expect(captured?.descendants).toEqual([]) - expect(captured?.unidentifiedCount).toBe(0) - await expect( - verifyWindowsDescendantSnapshotExit(captured!, { readTable: async () => [] }) - ).resolves.toBe('exited') - }) - - it('walks the whole subtree and keeps only rows a later read can re-identify', async () => { - const captured = await captureWindowsDescendantSnapshot(100, { - // 400 is a grandchild; 300 denied a creation-time query, so no later read - // could tell it from a recycled pid and signalling it would risk a stranger. - readTable: vi.fn(async () => [ - { pid: 100, ppid: 1, creationTimeMs: 5 }, - { pid: 200, ppid: 100, creationTimeMs: 7 }, - { pid: 300, ppid: 100 }, - { pid: 400, ppid: 200, creationTimeMs: 9 }, - { pid: 500, ppid: 1, creationTimeMs: 11 } - ]), - now: () => 42 - }) - - expect(captured).toEqual({ - root: { pid: 100, creationTimeMs: 5 }, - descendants: [ - { pid: 400, creationTimeMs: 9 }, - { pid: 200, creationTimeMs: 7 } - ], - // Seen but not re-identifiable: counted, so no later read can prove it gone. - unidentifiedCount: 1, - capturedAtMs: 42 - }) - }) - - it('reports an unreadable or rootless table as no snapshot rather than an empty one', async () => { - await expect( - captureWindowsDescendantSnapshot(100, { - readTable: vi.fn(async () => { - throw new Error('table unavailable') - }) - }) - ).resolves.toBeNull() - // A snapshot without the root is stale or filtered; only an observed root - // can authoritatively have no descendants. - await expect( - captureWindowsDescendantSnapshot(100, { - readTable: vi.fn(async () => [{ pid: 999, ppid: 1, creationTimeMs: 5 }]) - }) - ).resolves.toBeNull() - }) - - it('refuses an invalid root pid', async () => { - const readTable = vi.fn() - await expect(captureWindowsDescendantSnapshot(0, { readTable })).resolves.toBeNull() - expect(readTable).not.toHaveBeenCalled() - }) -}) - -describe('verifyWindowsDescendantSnapshotExit', () => { - it('proves an empty tree without reading the table', async () => { - const readTable = vi.fn() - await expect(verifyWindowsDescendantSnapshotExit(snapshot([]), { readTable })).resolves.toBe( - 'exited' - ) - expect(readTable).not.toHaveBeenCalled() - }) - - it('never proves a tree that held a descendant it could not identify', async () => { - // A descendant that denied the creation-time query was seen in the table; - // being unable to re-identify it is "could not look", never "it is gone". - const readTable = vi.fn() - await expect(verifyWindowsDescendantSnapshotExit(snapshot([], 1), { readTable })).resolves.toBe( - 'unverifiable' - ) - expect(readTable).not.toHaveBeenCalled() - - // The identified sibling leaving proves nothing about the unidentified one. - await expect( - verifyWindowsDescendantSnapshotExit(snapshot([{ pid: 200, creationTimeMs: 7 }], 1), { - readTable: vi.fn(async () => []), - wait: async () => {}, - now: vi.fn().mockReturnValueOnce(0).mockReturnValue(1) - }) - ).resolves.toBe('unverifiable') - }) - - it('reports exited once no identity-matched row remains', async () => { - const readTable = vi - .fn() - .mockResolvedValueOnce([{ pid: 200, ppid: 100, creationTimeMs: 7 }]) - // The pid came back on a different process; that is a recycle, not a survivor. - .mockResolvedValueOnce([{ pid: 200, ppid: 100, creationTimeMs: 99 }]) - - await expect( - verifyWindowsDescendantSnapshotExit(snapshot([{ pid: 200, creationTimeMs: 7 }]), { - readTable, - wait: async () => {}, - now: vi.fn().mockReturnValueOnce(0).mockReturnValue(1) - }) - ).resolves.toBe('exited') - expect(readTable).toHaveBeenCalledTimes(2) - }) - - it('reports live for a descendant still matched at the deadline', async () => { - let clock = 0 - await expect( - verifyWindowsDescendantSnapshotExit(snapshot([{ pid: 200, creationTimeMs: 7 }]), { - readTable: vi.fn(async () => [{ pid: 200, ppid: 100, creationTimeMs: 7 }]), - wait: async () => { - clock += 100 - }, - now: () => clock, - verifyMs: 250 - }) - ).resolves.toBe('live') - }) - - it('reports unverifiable when the table cannot be read at the deadline', async () => { - await expect( - verifyWindowsDescendantSnapshotExit(snapshot([{ pid: 200, creationTimeMs: 7 }]), { - readTable: vi.fn(async () => { - throw new Error('table unavailable') - }), - wait: async () => {}, - now: vi.fn().mockReturnValueOnce(0).mockReturnValue(9_999) - }) - ).resolves.toBe('unverifiable') - }) -}) - -describe('terminateIdentifiedWindowsProcessTree', () => { - it('never taskkills a replacement that reused the captured root pid', async () => { - const terminateTree = vi.fn(async () => {}) - - await expect( - terminateIdentifiedWindowsProcessTree( - { pid: 100, creationTimeMs: 5 }, - { - readTable: vi.fn(async () => [{ pid: 100, ppid: 1, creationTimeMs: 99 }]), - terminateTree - } - ) - ).resolves.toBe(false) - expect(terminateTree).not.toHaveBeenCalled() - }) - - it('rechecks retained-child ownership after the identity read settles', async () => { - const terminateTree = vi.fn(async () => {}) - - await expect( - terminateIdentifiedWindowsProcessTree( - { pid: 100, creationTimeMs: 5 }, - { - readTable: vi.fn(async () => [{ pid: 100, ppid: 1, creationTimeMs: 5 }]), - ownsRoot: () => false, - terminateTree - } - ) - ).resolves.toBe(false) - expect(terminateTree).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/windows-descendant-exit-verification.ts b/src/main/windows-descendant-exit-verification.ts deleted file mode 100644 index 5e365a57e5a..00000000000 --- a/src/main/windows-descendant-exit-verification.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { getProcessTableIndex } from '../shared/process-table-index' -import type { DescendantTreeVerdict } from './pty-descendant-exit-verification' -import { windowsDescendantsFromRows } from './providers/windows-foreground-process-rows' -import { readWindowsProcessTableFresh } from './windows/windows-process-table' -import { terminateWindowsProcessTree } from './windows-process-tree-kill' - -export const WINDOWS_DESCENDANT_KILL_VERIFY_MS = 3_500 -const WINDOWS_DESCENDANT_POLL_MS = 100 - -/** - * A Windows descendant tree captured while its root was alive, with the - * PID-reuse guard the POSIX snapshot gets from ps lstart: a row only counts as - * the same process when its creation time still matches. Rows without a - * creation time are never signalled, because a bare pid cannot be re-identified, - * but they are counted: a descendant that was seen and denied identification - * is one no later read can prove gone. - */ -export type WindowsProcessIdentity = { pid: number; creationTimeMs: number } - -export type WindowsDescendantSnapshot = { - root: WindowsProcessIdentity - descendants: WindowsProcessIdentity[] - /** Descendants seen in the walk that denied the creation-time query. */ - unidentifiedCount: number - capturedAtMs: number - /** Per-PID boundaries retained when close refreshes merge snapshots. */ - capturedAtMsByPid?: Readonly> -} - -export type WindowsDescendantVerificationDeps = { - readTable?: () => Promise<{ pid: number; ppid: number; creationTimeMs?: number }[]> - now?: () => number - wait?: (ms: number) => Promise - verifyMs?: number -} - -/** Revalidate a Windows PID/creation-time identity immediately before a kill. */ -export async function verifyWindowsProcessIdentity( - target: WindowsProcessIdentity, - deps: Pick = {} -): Promise { - if (!Number.isInteger(target.pid) || target.pid <= 0 || !Number.isFinite(target.creationTimeMs)) { - return false - } - const table = await (deps.readTable ?? readWindowsProcessTableFresh)().catch(() => null) - const current = table?.filter((row) => row.pid === target.pid) ?? [] - return current.length === 1 && current[0]?.creationTimeMs === target.creationTimeMs -} - -function delay(ms: number): Promise { - return new Promise((resolve) => { - const timer = setTimeout(resolve, ms) - timer.unref?.() - }) -} - -/** - * Snapshot a Windows root's descendants while it is still alive. Resolves null - * (never rejects) when the table is unreadable or the root is absent — the same - * contract as the POSIX walk, because "cannot see" is never "nothing is there". - * - * Stale parent links are pruned by creation time, so a backwards clock step - * between two spawns can drop a live descendant — accepted over a certain stall. - */ -export async function captureWindowsDescendantSnapshot( - rootPid: number, - deps: WindowsDescendantVerificationDeps = {} -): Promise { - if (!Number.isInteger(rootPid) || rootPid <= 0) { - return null - } - const capturedAtMs = (deps.now ?? Date.now)() - // One table read, not a walk plus an identity read: each is bounded in - // seconds, and this runs inside the close ladder's budget. - const table = await (deps.readTable ?? readWindowsProcessTableFresh)().catch(() => null) - if (!table) { - return null - } - // One index for both lookups, so a repeated pid resolves to the same row for - // the root and for a parent link: `byPid` is first-wins, a Map is not. - const rowsByPid = getProcessTableIndex(table).byPid - const root = rowsByPid.get(rootPid) - if (typeof root?.creationTimeMs !== 'number') { - return null - } - const rootCreationTimeMs = root.creationTimeMs - // Windows keeps a process's original parent PID after that parent exits, so a - // reused PID is not ancestry: no real child predates the parent it claims. - // The root's start backstops the undefined-time bypass, which admits a row - // unchecked and leaves its children no parent time to compare against. Ties - // pass -- FILETIMEs truncated to ms make a same-millisecond parent and child - // collide exactly, so `>` would drop true descendants. - const currentRows = table.filter((row) => { - const parentCreationTimeMs = rowsByPid.get(row.ppid)?.creationTimeMs - return ( - // Its own ppid can be recycled too, and a pruned root loses the snapshot. - row.pid === rootPid || - row.creationTimeMs === undefined || - (row.creationTimeMs >= rootCreationTimeMs && - (parentCreationTimeMs === undefined || row.creationTimeMs >= parentCreationTimeMs)) - ) - }) - const descendants = windowsDescendantsFromRows(currentRows, rootPid) - if (!descendants) { - return null - } - return { - root: { pid: root.pid, creationTimeMs: root.creationTimeMs }, - descendants: descendants.flatMap((row) => - // A descendant that denied a creation-time query cannot be told from a - // recycled pid later, so it is never signalled on a bare pid. - typeof row.creationTimeMs === 'number' - ? [{ pid: row.pid, creationTimeMs: row.creationTimeMs }] - : [] - ), - unidentifiedCount: descendants.filter((row) => typeof row.creationTimeMs !== 'number').length, - capturedAtMs - } -} - -export type IdentifiedWindowsTreeTerminationDeps = { - readTable?: WindowsDescendantVerificationDeps['readTable'] - terminateTree?: (target: WindowsProcessIdentity) => Promise - ownsRoot?: () => boolean -} - -/** Revalidate the captured root at the last async boundary before taskkill. */ -export async function terminateIdentifiedWindowsProcessTree( - target: WindowsProcessIdentity, - deps: IdentifiedWindowsTreeTerminationDeps = {} -): Promise { - if (!(await verifyWindowsProcessIdentity(target, { readTable: deps.readTable }))) { - return false - } - if (deps.ownsRoot?.() === false) { - return false - } - await ( - deps.terminateTree ?? - ((identified: WindowsProcessIdentity) => terminateWindowsProcessTree(identified.pid)) - )(target) - return true -} - -/** - * Whether a snapshotted Windows tree is gone, polled to a bounded deadline. - * - * Why a verification pass at all: `taskkill /T /F` resolves the same way on a - * timeout, an access denial and a recycled root as it does on a successful - * kill, so its completion is never evidence. Only a table read that no longer - * shows an identity-matched row is. - */ -export async function verifyWindowsDescendantSnapshotExit( - snapshot: WindowsDescendantSnapshot, - deps: WindowsDescendantVerificationDeps = {} -): Promise { - // The most a read can prove: a descendant that denied identification was seen - // and can never be matched gone, so "could not look" caps the verdict. - const proven: DescendantTreeVerdict = snapshot.unidentifiedCount > 0 ? 'unverifiable' : 'exited' - if (snapshot.descendants.length === 0) { - return proven - } - const now = deps.now ?? Date.now - const readTable = deps.readTable ?? readWindowsProcessTableFresh - const deadline = now() + (deps.verifyMs ?? WINDOWS_DESCENDANT_KILL_VERIFY_MS) - let verdict: DescendantTreeVerdict = 'unverifiable' - do { - const table = await readTable().catch(() => null) - if (!table) { - verdict = 'unverifiable' - } else { - const live = new Map(table.map((row) => [row.pid, row.creationTimeMs])) - verdict = snapshot.descendants.some((row) => live.get(row.pid) === row.creationTimeMs) - ? 'live' - : proven - if (verdict === proven) { - return verdict - } - } - if (now() >= deadline) { - return verdict - } - await (deps.wait ?? delay)(WINDOWS_DESCENDANT_POLL_MS) - } while (now() < deadline) - return verdict -} diff --git a/src/main/windows-process-tree-kill.test.ts b/src/main/windows-process-tree-kill.test.ts index 0e6cbb739c8..a2eb82a0d52 100644 --- a/src/main/windows-process-tree-kill.test.ts +++ b/src/main/windows-process-tree-kill.test.ts @@ -16,9 +16,11 @@ describe('terminateWindowsProcessTree', () => { callback(null) } ) - await terminateWindowsProcessTree(1234, { - execFileImpl: execFileImpl as never - }) + await expect( + terminateWindowsProcessTree(1234, { + execFileImpl: execFileImpl as never + }) + ).resolves.toBe(true) expect(execFileImpl).toHaveBeenCalledWith( 'taskkill', ['/pid', '1234', '/T', '/F'], @@ -30,7 +32,7 @@ describe('terminateWindowsProcessTree', () => { ) }) - it('resolves even when taskkill reports failure (already dead)', async () => { + it('resolves false, never throws, when taskkill reports failure (already dead)', async () => { const execFileImpl = vi.fn( ( _cmd: string, @@ -43,7 +45,7 @@ describe('terminateWindowsProcessTree', () => { ) await expect( terminateWindowsProcessTree(55, { execFileImpl: execFileImpl as never }) - ).resolves.toBeUndefined() + ).resolves.toBe(false) }) it('skips taskkill for invalid pids', async () => { @@ -51,5 +53,7 @@ describe('terminateWindowsProcessTree', () => { await terminateWindowsProcessTree(0, { execFileImpl: execFileImpl as never }) await terminateWindowsProcessTree(-1, { execFileImpl: execFileImpl as never }) expect(execFileImpl).not.toHaveBeenCalled() + // No tree was addressed, so nothing reports it terminated. + await expect(terminateWindowsProcessTree(0)).resolves.toBe(false) }) }) diff --git a/src/main/windows-process-tree-kill.ts b/src/main/windows-process-tree-kill.ts index 31e6b18da2a..8194948f419 100644 --- a/src/main/windows-process-tree-kill.ts +++ b/src/main/windows-process-tree-kill.ts @@ -9,7 +9,8 @@ export const WINDOWS_PROCESS_TREE_KILL_TIMEOUT_MS = 5_000 /** * Force-kill a Windows process and every descendant (`taskkill /T /F`). * Best-effort: missing/already-dead roots still resolve so callers can finish - * their own handle cleanup via killRoot. + * their own handle cleanup via killRoot. Resolves true only when taskkill exited 0, + * the one outcome that reports every process in the tree terminated. * * Most main-process taskkills run through here; the families that keep their own * spawn (account-login teardowns, codex app-server deadline, git-command abort, @@ -19,13 +20,13 @@ export const WINDOWS_PROCESS_TREE_KILL_TIMEOUT_MS = 5_000 export function terminateWindowsProcessTree( rootPid: number, deps: { execFileImpl?: typeof execFile; site?: string } = {} -): Promise { +): Promise { if (!Number.isInteger(rootPid) || rootPid <= 0) { - return Promise.resolve() + return Promise.resolve(false) } const site = deps.site ?? 'windows-process-tree-kill' if (!admitSelfInitiatedTreeKill({ pid: rootPid, site, scope: 'win-taskkill-tree' })) { - return Promise.resolve() + return Promise.resolve(false) } const run = deps.execFileImpl ?? execFile return new Promise((resolve) => { @@ -37,8 +38,8 @@ export function terminateWindowsProcessTree( timeout: WINDOWS_PROCESS_TREE_KILL_TIMEOUT_MS, windowsHide: true }, - () => { - resolve() + (error) => { + resolve(error === null) } ) }) diff --git a/src/main/windows/windows-process-table.ts b/src/main/windows/windows-process-table.ts index 20f1ca000c2..433771c81ff 100644 --- a/src/main/windows/windows-process-table.ts +++ b/src/main/windows/windows-process-table.ts @@ -533,9 +533,8 @@ export function isWindowsProcessTableAvailable(): boolean { * `build/Release/` path, so a host can hold a patched `lib/index.js` — enum and * all — over a binary that ignores flag 4. CI produced exactly that: the enum * said available, and every row came back without `creationTimeMs`. Answering - * true there is worse than answering false: the descendant snapshot then - * returns null forever and the exit proof latches `unverifiable`, while - * structured chat believes it has a reaper. + * true there is worse than answering false: the owner probe would scan the + * whole table for nulls, and the published status would claim start times. */ export function isWindowsProcessStartTimeAvailable(): boolean { const native = moduleLoader() diff --git a/src/renderer/src/components/settings/ExperimentalPane.test.tsx b/src/renderer/src/components/settings/ExperimentalPane.test.tsx index 6513e9de22b..8325045138d 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.test.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.test.tsx @@ -265,7 +265,7 @@ describe('ExperimentalPane', () => { expect(container.textContent).toContain('Chats that already exist stay as they are.') // Paired Orca servers run structured chats too; only WSL and SSH stay on terminal chat. expect(container.textContent).toContain( - 'Runs on this machine and on paired Orca servers running a version that supports it; older servers keep terminal chat. WSL and SSH hosts continue to use terminal chat, and Windows falls back to it unless Orca can read process start times.' + 'Runs on this machine and on paired Orca servers running a version that supports it; older servers keep terminal chat. WSL and SSH hosts continue to use terminal chat.' ) expect(container.textContent).toContain('Default view') root.unmount() diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx index 6c85a178965..2e1fef30a21 100644 --- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx @@ -141,7 +141,7 @@ export function NativeChatExperimentalSetting({

{translate( 'auto.components.settings.ExperimentalPane.nativeChat.structuredScope', - 'Runs on this machine and on paired Orca servers running a version that supports it; older servers keep terminal chat. WSL and SSH hosts continue to use terminal chat, and Windows falls back to it unless Orca can read process start times.' + 'Runs on this machine and on paired Orca servers running a version that supports it; older servers keep terminal chat. WSL and SSH hosts continue to use terminal chat.' )}

diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d3a19ec6cda..28d852e580a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -7393,7 +7393,7 @@ "defaultViewNative": "Chat UI", "structuredTitle": "Use updated structured native chat", "structuredCopy": "Open new Codex and Claude agents as structured chats. Off opens them in the terminal-backed chat. Chats that already exist stay as they are.", - "structuredScope": "Runs on this machine and on paired Orca servers running a version that supports it; older servers keep terminal chat. WSL and SSH hosts continue to use terminal chat, and Windows falls back to it unless Orca can read process start times.", + "structuredScope": "Runs on this machine and on paired Orca servers running a version that supports it; older servers keep terminal chat. WSL and SSH hosts continue to use terminal chat.", "structuredToggleLabel": "Toggle updated structured native chat", "resumeTitle": "Resume working chats automatically after a restart", "resumeCopy": "When Orca quits or installs an update, chats that were working are automatically resumed when Orca is reopened.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 81fbdbe4c99..bcfbd7393ec 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -7181,7 +7181,7 @@ "resumeCopy": "Cuando Orca se cierra o instala una actualización, los chats que estaban trabajando se reanudan automáticamente al volver a abrir Orca.", "structuredTitle": "Utilice el chat nativo estructurado actualizado", "structuredCopy": "Abra nuevos agentes de Codex y Claude como chats estructurados. Si está desactivado, los abre en el chat respaldado por la terminal. Los chats que ya existen permanecen como están.", - "structuredScope": "Se ejecuta en esta máquina y en servidores Orca emparejados que ejecutan una versión que lo admita; los servidores más antiguos mantienen el chat de terminal. Los hosts WSL y SSH continúan usando el chat de terminal y Windows recurre a él a menos que Orca pueda leer las horas de inicio del proceso.", + "structuredScope": "Se ejecuta en esta máquina y en servidores Orca emparejados que ejecutan una versión que lo admita; los servidores más antiguos mantienen el chat de terminal. Los hosts WSL y SSH continúan usando el chat de terminal.", "structuredToggleLabel": "Alternar chat nativo estructurado actualizado", "resumeTitle": "Reanudar los chats de trabajo automáticamente después de reiniciar", "resumeToggleLabel": "Alternar reanudación automática después de un reinicio" diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 22ecb1c2052..79e64d93bda 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -7283,7 +7283,7 @@ "shellEnvToggleLabel": "Activer l'utilisation de l'environnement de votre shell", "structuredTitle": "Utiliser le chat natif structuré mis à jour", "structuredCopy": "Ouvre les nouveaux agents Codex et Claude sous forme de discussions structurées. Désactivé, ils s'ouvrent dans le chat basé sur le terminal. Les discussions existantes restent telles quelles.", - "structuredScope": "Fonctionne sur cette machine et sur les serveurs Orca appairés dont la version le prend en charge ; les serveurs plus anciens conservent le chat du terminal. WSL et les hôtes SSH continuent d'utiliser le chat du terminal, et Windows y revient à moins qu'Orca ne puisse lire les heures de début des processus.", + "structuredScope": "Fonctionne sur cette machine et sur les serveurs Orca appairés dont la version le prend en charge ; les serveurs plus anciens conservent le chat du terminal. WSL et les hôtes SSH continuent d'utiliser le chat du terminal.", "structuredToggleLabel": "Basculer le chat natif structuré mis à jour", "resumeTitle": "Reprendre automatiquement les discussions de travail après un redémarrage", "resumeCopy": "Lorsqu'Orca quitte ou installe une mise à jour, les discussions en cours reprennent automatiquement à la réouverture d'Orca.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 887ddd9b93a..047d9cfea6b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -7165,7 +7165,7 @@ "shellEnvToggleLabel": "シェル環境の使用の切り替え", "structuredTitle": "更新された構造化ネイティブチャットを使用する", "structuredCopy": "新規の Codex および Claude の Agent を構造化チャットとして開きます。オフにすると、ターミナルを使用したチャットで開きます。既存のチャットはそのまま残ります。", - "structuredScope": "このマシンと、対応するバージョンを実行しているペアリング済みの Orca サーバーで動作します。古いサーバーはターミナルチャットのままです。WSL および SSH ホストは引き続きターミナルチャットを使用し、Orca がプロセスの開始時間を読み取ることができない限り、Windows はターミナルチャットにフォールバックします。", + "structuredScope": "このマシンと、対応するバージョンを実行しているペアリング済みの Orca サーバーで動作します。古いサーバーはターミナルチャットのままです。WSL および SSH ホストは引き続きターミナルチャットを使用します。", "structuredToggleLabel": "更新された構造化ネイティブチャットの切り替え", "resumeTitle": "再起動後に作業中のチャットを自動的に再開する", "resumeCopy": "Orca が終了するかアップデートをインストールすると、Orca が再度開かれたときに作業中だったチャットが自動的に再開されます。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 41eb6d72242..ca3692cacd2 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -7165,7 +7165,7 @@ "shellEnvToggleLabel": "셸 환경 사용 전환", "structuredTitle": "업데이트된 구조화된 기본 채팅 사용", "structuredCopy": "새 Codex 및 Claude 에이전트를 구조화된 채팅으로 엽니다. 끄면 터미널 기반 채팅으로 엽니다. 이미 있는 채팅은 그대로 유지됩니다.", - "structuredScope": "이 컴퓨터와 이를 지원하는 버전을 실행하는 페어링된 Orca 서버에서 실행됩니다. 이전 서버는 터미널 채팅을 유지합니다. WSL 및 SSH 호스트는 계속해서 터미널 채팅을 사용하며 Orca가 프로세스 시작 시간을 읽을 수 없으면 Windows는 터미널 채팅으로 대체됩니다.", + "structuredScope": "이 컴퓨터와 이를 지원하는 버전을 실행하는 페어링된 Orca 서버에서 실행됩니다. 이전 서버는 터미널 채팅을 유지합니다. WSL 및 SSH 호스트는 계속해서 터미널 채팅을 사용합니다.", "structuredToggleLabel": "업데이트된 구조화된 기본 채팅 전환", "resumeTitle": "다시 시작한 후 작업 중인 채팅을 자동으로 재개합니다.", "resumeCopy": "Orca가 종료되거나 업데이트를 설치하면 Orca가 다시 열릴 때 작업 중이던 채팅이 자동으로 재개됩니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index e355a865a2c..a698cd4b791 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -7165,7 +7165,7 @@ "shellEnvToggleLabel": "切换使用 shell 环境", "structuredTitle": "使用更新的结构化本机聊天", "structuredCopy": "将新的 Codex 和 Claude 智能体作为结构化聊天打开。关闭时会在基于终端的聊天中打开。已有的聊天保持不变。", - "structuredScope": "在本机和运行支持该功能版本的已配对 Orca 服务器上运行;较旧的服务器保留终端聊天。WSL 和 SSH 主机继续使用终端聊天,Windows 会回退到它,除非 Orca 可以读取进程启动时间。", + "structuredScope": "在本机和运行支持该功能版本的已配对 Orca 服务器上运行;较旧的服务器保留终端聊天。WSL 和 SSH 主机继续使用终端聊天。", "structuredToggleLabel": "切换更新的结构化本机聊天", "resumeTitle": "重启后自动恢复工作聊天", "resumeCopy": "当 Orca 退出或安装更新时,重新打开 Orca 后,正在工作的聊天会自动恢复。", diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index 0f807928d3d..1762d88d20d 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -232,9 +232,8 @@ export function abandonStructuredAgentSessionLaunchIntent( } /** - * Only the host that will execute the session can answer whether it supports creating one there — - * on Windows that means reading the provider child's process start time, which a client cannot - * observe. Both providers ask: the host classifies per agent, and Codex inherits the + * Only the host that will execute the session can answer whether it supports creating one there. + * Both providers ask: the host classifies per agent, and Codex inherits the * unresolvable-selector retry above along with the probe. The unknown branch stays on the chat for * reconciliation: a retry may follow a create whose reply was lost. Answers the seed create will use. */ diff --git a/src/renderer/src/lib/windows-terminal-capabilities-race.test.ts b/src/renderer/src/lib/windows-terminal-capabilities-race.test.ts index 0439e5c76bf..f5dd3e36afc 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities-race.test.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities-race.test.ts @@ -15,19 +15,13 @@ describe('Windows terminal capability probe ordering', () => { vi.unstubAllGlobals() }) - it('does not let an older forced probe overwrite a newer identity proof', async () => { - let resolveOlderStatus!: (status: { hostPlatform: NodeJS.Platform }) => void - let resolveNewerStatus!: (status: { - hostPlatform: NodeJS.Platform - windowsProcessStartTimeAvailable: boolean - }) => void - const olderStatus = new Promise<{ hostPlatform: NodeJS.Platform }>((resolve) => { + it('does not let an older forced probe overwrite a newer answer', async () => { + let resolveOlderStatus!: (status: { hostPlatform: NodeJS.Platform | null }) => void + let resolveNewerStatus!: (status: { hostPlatform: NodeJS.Platform | null }) => void + const olderStatus = new Promise<{ hostPlatform: NodeJS.Platform | null }>((resolve) => { resolveOlderStatus = resolve }) - const newerStatus = new Promise<{ - hostPlatform: NodeJS.Platform - windowsProcessStartTimeAvailable: boolean - }>((resolve) => { + const newerStatus = new Promise<{ hostPlatform: NodeJS.Platform | null }>((resolve) => { resolveNewerStatus = resolve }) const runtimeGetStatus = vi @@ -57,24 +51,13 @@ describe('Windows terminal capability probe ordering', () => { now: 2_000 }) - resolveNewerStatus({ hostPlatform: 'win32', windowsProcessStartTimeAvailable: true }) - await expect(newerProbe).resolves.toMatchObject({ - hostPlatform: 'win32', - windowsProcessStartTimeAvailable: true - }) - expect(getCachedWindowsTerminalCapabilities('local')).toMatchObject({ - hostPlatform: 'win32', - windowsProcessStartTimeAvailable: true - }) + resolveNewerStatus({ hostPlatform: 'win32' }) + await expect(newerProbe).resolves.toMatchObject({ hostPlatform: 'win32' }) + expect(getCachedWindowsTerminalCapabilities('local')).toMatchObject({ hostPlatform: 'win32' }) - resolveOlderStatus({ hostPlatform: 'win32' }) - await expect(olderProbe).resolves.toMatchObject({ - hostPlatform: 'win32', - windowsProcessStartTimeAvailable: true - }) - expect(getCachedWindowsTerminalCapabilities('local')).toMatchObject({ - hostPlatform: 'win32', - windowsProcessStartTimeAvailable: true - }) + // The older probe started first; its late, emptier answer must not replace the newer one. + resolveOlderStatus({ hostPlatform: null }) + await expect(olderProbe).resolves.toMatchObject({ hostPlatform: 'win32' }) + expect(getCachedWindowsTerminalCapabilities('local')).toMatchObject({ hostPlatform: 'win32' }) }) }) diff --git a/src/renderer/src/lib/windows-terminal-capabilities.test.ts b/src/renderer/src/lib/windows-terminal-capabilities.test.ts index 7e7568f2e09..85c2a971443 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.test.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.test.ts @@ -69,7 +69,6 @@ function stubTerminalCapabilityApi(args: { wslDistros?: string[] gitBashAvailable?: boolean hostPlatform?: NodeJS.Platform | null - windowsProcessStartTimeAvailable?: boolean }): { wslIsAvailable: ReturnType wslListDistros: ReturnType @@ -82,10 +81,7 @@ function stubTerminalCapabilityApi(args: { const pwshIsAvailable = vi.fn().mockResolvedValue(args.pwshAvailable) const isGitBashAvailable = vi.fn().mockResolvedValue(args.gitBashAvailable ?? false) const runtimeGetStatus = vi.fn().mockResolvedValue({ - hostPlatform: 'hostPlatform' in args ? args.hostPlatform : 'win32', - ...(args.windowsProcessStartTimeAvailable !== undefined - ? { windowsProcessStartTimeAvailable: args.windowsProcessStartTimeAvailable } - : {}) + hostPlatform: 'hostPlatform' in args ? args.hostPlatform : 'win32' }) vi.stubGlobal('window', { @@ -590,8 +586,7 @@ describe('windows terminal capabilities', () => { const { wslIsAvailable, wslListDistros } = stubTerminalCapabilityApi({ wslAvailable: false, pwshAvailable: true, - wslDistros: [], - windowsProcessStartTimeAvailable: true + wslDistros: [] }) wslIsAvailable.mockResolvedValueOnce(false).mockResolvedValue(true) wslListDistros.mockResolvedValueOnce([]).mockResolvedValue(['Ubuntu']) diff --git a/src/renderer/src/lib/windows-terminal-capabilities.ts b/src/renderer/src/lib/windows-terminal-capabilities.ts index 8a2f468b134..3818ce8733b 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.ts @@ -11,8 +11,6 @@ export type WindowsTerminalCapabilities = { pwshAvailable: boolean gitBashAvailable: boolean hostPlatform: NodeJS.Platform | null - /** Host-owned PID-reuse proof; absent means the host did not advertise it. */ - windowsProcessStartTimeAvailable?: boolean isLoading: boolean } diff --git a/src/renderer/src/lib/windows-terminal-capability-read.ts b/src/renderer/src/lib/windows-terminal-capability-read.ts index 9a77538cefc..345181452c0 100644 --- a/src/renderer/src/lib/windows-terminal-capability-read.ts +++ b/src/renderer/src/lib/windows-terminal-capability-read.ts @@ -66,9 +66,6 @@ export async function readWindowsTerminalCapabilities( pwshAvailable, gitBashAvailable, hostPlatform: runtimeStatus?.hostPlatform ?? null, - ...(runtimeStatus?.windowsProcessStartTimeAvailable !== undefined - ? { windowsProcessStartTimeAvailable: runtimeStatus.windowsProcessStartTimeAvailable } - : {}), isLoading: false } } diff --git a/src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts b/src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts index 3d3d476753e..f2361c93076 100644 --- a/src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts +++ b/src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts @@ -40,36 +40,18 @@ afterEach(() => { }) describe('windows terminal capability re-probe', () => { - it('reprobes usable WSL until Windows process identity is proved', async () => { + it('settles usable WSL on a Windows host without waiting for process identity', async () => { vi.useFakeTimers() - let current: WindowsTerminalCapabilities = USABLE_WSL - const probe = vi.fn(async () => { - current = { ...current, windowsProcessStartTimeAvailable: true } - return current - }) - const readCached = () => current + const { probe, readCached } = createWatcher([USABLE_WSL]) startWindowsTerminalCapabilityReprobe({ ownerKey: 'local', probe, readCached }) await vi.advanceTimersByTimeAsync(30_000) expect(probe).toHaveBeenCalledTimes(1) - expect(readCached().windowsProcessStartTimeAvailable).toBe(true) await vi.advanceTimersByTimeAsync(30 * 60_000) expect(probe).toHaveBeenCalledTimes(1) }) - it('resets the backoff when only process identity capability changes', async () => { - vi.useFakeTimers() - const identityAvailable = { ...ABSENT_WSL, windowsProcessStartTimeAvailable: true } - const { probe, readCached } = createWatcher([identityAvailable, identityAvailable]) - startWindowsTerminalCapabilityReprobe({ ownerKey: 'local', probe, readCached }) - - await vi.advanceTimersByTimeAsync(30_000) - expect(probe).toHaveBeenCalledTimes(1) - await vi.advanceTimersByTimeAsync(30_000) - expect(probe).toHaveBeenCalledTimes(2) - }) - it('backs off to a five-minute ceiling on a stable answer', async () => { vi.useFakeTimers() const { probe, readCached } = createWatcher() @@ -85,9 +67,7 @@ describe('windows terminal capability re-probe', () => { it('still re-checks a transient absent answer, then stops once WSL answers', async () => { vi.useFakeTimers() - const { probe, readCached } = createWatcher([ - { ...USABLE_WSL, windowsProcessStartTimeAvailable: true } - ]) + const { probe, readCached } = createWatcher([USABLE_WSL]) startWindowsTerminalCapabilityReprobe({ ownerKey: 'local', probe, readCached }) await vi.advanceTimersByTimeAsync(30_000) diff --git a/src/renderer/src/lib/windows-terminal-capability-reprobe.ts b/src/renderer/src/lib/windows-terminal-capability-reprobe.ts index f9b9d44b025..4cda89d9bf8 100644 --- a/src/renderer/src/lib/windows-terminal-capability-reprobe.ts +++ b/src/renderer/src/lib/windows-terminal-capability-reprobe.ts @@ -31,19 +31,15 @@ function capabilitySignature(capabilities: WindowsTerminalCapabilities): string capabilities.wslDistros.join('\u0000'), capabilities.pwshAvailable, capabilities.gitBashAvailable, - capabilities.hostPlatform ?? '', - capabilities.windowsProcessStartTimeAvailable + capabilities.hostPlatform ?? '' ].join('|') } -/** A usable WSL is settled only after Windows hosts also prove PID identity. */ +/** A usable WSL is settled once the host's platform is known. */ function isSettled(capabilities: WindowsTerminalCapabilities): boolean { if (!capabilities.wslAvailable || capabilities.wslDistros.length === 0) { return false } - if (capabilities.hostPlatform === 'win32') { - return capabilities.windowsProcessStartTimeAvailable === true - } // A missing platform means the status probe may have failed; keep checking until it recovers. return capabilities.hostPlatform !== null } diff --git a/src/shared/agent-session-lease-adjudication.ts b/src/shared/agent-session-lease-adjudication.ts index 271b136456f..75de4a74c40 100644 --- a/src/shared/agent-session-lease-adjudication.ts +++ b/src/shared/agent-session-lease-adjudication.ts @@ -20,7 +20,12 @@ import type { AgentSessionRefusalDetailsByCode } from './agent-session-wire-refusals' -export type AgentSessionIdentityMatchField = 'process-start-time' | 'spawn-token' +/** What a PID probe can compare against the recorded owner. */ +export type AgentSessionProcessIdentityField = 'process-start-time' | 'spawn-token' + +/** `held-child`: this runtime still holds the child at the record's fence and has not seen it exit. + * Only lease renewal in that runtime asserts it; restart and recovery probes never can. */ +export type AgentSessionIdentityMatchField = AgentSessionProcessIdentityField | 'held-child' export type AgentSessionOwnerProbe = /** Orca watched this exact process exit. */ @@ -28,7 +33,7 @@ export type AgentSessionOwnerProbe = /** The recorded pid is not present on the host. */ | { outcome: 'pid-absent' } /** The pid is present but is a different process. */ - | { outcome: 'identity-mismatch'; field: AgentSessionIdentityMatchField | 'command-line' } + | { outcome: 'identity-mismatch'; field: AgentSessionProcessIdentityField | 'command-line' } /** The pid is present and at least one identity element was verified. */ | { outcome: 'identity-matched'; matchedOn: readonly AgentSessionIdentityMatchField[] } /** No process carries the reserved spawn token and the provider saw no activity after it. */ diff --git a/src/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts index 6cf53c7deeb..9780ff1487a 100644 --- a/src/shared/runtime-session-contracts.ts +++ b/src/shared/runtime-session-contracts.ts @@ -79,7 +79,8 @@ export type RuntimeStatus = { worktreeCreateIdempotency?: { dedupeTtlMs: number } - /** True only when this Windows host can prove process creation times for PID ownership. */ + /** True only when this Windows host can read process creation times. TEMPORARY: read only by + * older clients, which keep re-probing WSL until it is true; remove after their window. */ windowsProcessStartTimeAvailable?: boolean /** * Optional for mixed-version peers. Absence means the host predates structured