mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 00:02:39 +00:00
fix(native-chat): start Windows chats without reading process creation times (#25718)
* fix(native-chat): start Windows chats without reading process creation times
Native chat on Windows refused to start ("Orca can't run this agent in a chat
here") whenever the process-table addon could not report process creation
times. Chat never needed them; only the bookkeeping around stopping the agent
did.
Windows now follows the common pattern: Stop ends the agent's tree with
`taskkill /T /F` on the child Orca still holds, and reports the tree gone only
when taskkill exits 0. A saved pid is never signalled after a restart.
- Remove the Claude and Codex location gate and the Codex launch refusal.
- A start time that cannot be read is recorded as unknown instead of refusing
the session; recovery already releases such an owner without signalling it.
- Delete Claude's Windows creation-time descendant snapshot and its verifier.
- Codex's Windows teardown reports the real taskkill outcome.
- The renderer no longer waits on the capability flag; the host keeps
publishing it for older clients (temporary).
* fix(native-chat): treat a Windows Claude exit after stdin end as a proven close
On Windows an idle Claude leaves on its own once its stdin ends, so every Stop
and close read as unproven: live background work settled as unknown and the
trace logged a close that "did not finish cleanly". As with the Codex close,
that exit is now the close and Orca makes no claim about processes Claude
started; a forced close still rests on taskkill's own report.
Also drop the Settings clause about Windows needing process start times, and
fix comments and the tracked process-enumeration doc that still described the
removed start-time gate and descendant snapshot.
* fix(native-chat): renew a held child's lease without a PID probe
An owner recorded without a process start time could never renew: the renewer
re-proves every live owner by PID identity, and with no start time and no
spawn-token echo that probe is indeterminate. The lease's last renewal then
stayed at the spawn, so a turn cut by an Orca crash was dated to its own start,
and every tick logged a failed renewal and split the batch into one store
transaction per chat.
The runtime now renews a lease for a child it still holds at the record's
fence and whose exit it has not received, recorded as a `held-child` match.
Receipt of the exit ends that proof before the exit is settled, and a restart
holds no child, so a dead owner's lease still expires and restart adjudication
is unchanged. Records this runtime does not hold keep the PID probe.
Also correct the identity probe's comment about shipped addons and note that
recovery's stop ladder is POSIX-only.
* fix(native-chat): keep a failed Windows taskkill unproven across a retried close
A Windows close counts Claude leaving on its own after its stdin ends as the
close. That shortcut also caught a retried close whose first attempt forced a
taskkill that failed: once the root exited, the retry returned true and the
failure read as a proven close. The tree reaper now records whether a reap
ever reached the live root, here, on an earlier close or from a transport
failure, and the shortcut applies only when none did; otherwise taskkill's
verdict stands and no new taskkill runs against the exited root.
Pin the platform on the existing tests that assume the POSIX close, and say
what `exit-proven` means on Windows in the acquisition-failure docs.
* fix(native-chat): derive a held child's liveness from the adapter's own handle
Lease renewal trusted a held child until the host settled its exit, and the
host hears of an exit late: Claude runs its close ladder and a store write
first, unexpected exits wait on one delivery chain shared by every chat, and a
Claude close that cannot prove its tree publishes nothing at all. A dead root
could keep renewing through that window, so a crash in it dated the cut turn
late. Renewal now asks the adapter, which owns the process handle and sees the
exit first: a child is held only while it is on record at the lease's fence
and its adapter still runs that exact acquisition with no root exit seen. An
adapter that cannot answer falls back to the PID probe. The stored
exit-received mark is gone.
The held-child read is now required by the runtime state and the renewer, and
a host-level test drives renewal through the real host wiring.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<typeof vi.fn>
|
||||
reap: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
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<typeof createClaudeChildTreeReaperImpl>[0],
|
||||
deps: Parameters<typeof createClaudeChildTreeReaperImpl>[1] = {}
|
||||
): ReturnType<typeof createClaudeChildTreeReaperImpl> {
|
||||
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<void>()
|
||||
@@ -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<void>()
|
||||
const release = Promise.withResolvers<boolean>()
|
||||
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()
|
||||
|
||||
@@ -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<DescendantTreeVerdict, number> = {
|
||||
type ReapableChild = Pick<SpawnedProcess, 'pid' | 'kill'>
|
||||
|
||||
/**
|
||||
* 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<DescendantSnapshot | null>
|
||||
terminateDescendants?: (snapshot: DescendantSnapshot) => Promise<DescendantTreeVerdict>
|
||||
terminateWindowsTree?: (root: WindowsProcessIdentity) => Promise<void>
|
||||
captureWindowsDescendants?: (rootPid: number) => Promise<WindowsDescendantSnapshot | null>
|
||||
terminateWindowsDescendants?: (
|
||||
snapshot: WindowsDescendantSnapshot
|
||||
) => Promise<DescendantTreeVerdict>
|
||||
/** Identity probe for the bare-pid tree kill; only Windows has one to gate. */
|
||||
verifyRootIdentity?: (root: PosixProcessIdentity | WindowsProcessIdentity) => Promise<boolean>
|
||||
/** `taskkill /T /F` on the held root; true only when taskkill reports the tree terminated. */
|
||||
terminateWindowsTree?: (rootPid: number) => Promise<boolean>
|
||||
}
|
||||
|
||||
export type ClaudeChildTreeReaper = {
|
||||
@@ -88,11 +67,13 @@ export type ClaudeChildTreeReaper = {
|
||||
*/
|
||||
reap(): Promise<DescendantTreeVerdict>
|
||||
/**
|
||||
* `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<void> | null = null
|
||||
let refreshing: Promise<void> | null = null
|
||||
let queuedRefresh: Promise<void> | null = null
|
||||
let inFlight: Promise<DescendantTreeVerdict> | 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<void> {
|
||||
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<void> {
|
||||
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<DescendantTreeVerdict> {
|
||||
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<DescendantTreeVerdict> {
|
||||
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<boolean> {
|
||||
return proveClaudeChildExitWithReaper(input, () =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof spawnProcess>
|
||||
@@ -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.
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<object, ManagedProviderProcess>()
|
||||
|
||||
/** `closePlatform` picks the close rules; the spawn itself always skips the POSIX supervisor. */
|
||||
export function managedChild(
|
||||
child: Pick<SpawnedProcess, 'pid' | 'kill' | 'stdin' | 'stderr'> & EventEmitter
|
||||
child: Pick<SpawnedProcess, 'pid' | 'kill' | 'stdin' | 'stderr'> & EventEmitter,
|
||||
closePlatform: NodeJS.Platform = 'linux'
|
||||
): ManagedProviderProcess {
|
||||
const existing = managedChildren.get(child)
|
||||
if (existing) {
|
||||
@@ -22,8 +24,8 @@ export function managedChild(
|
||||
spawnImpl: () => child as ReturnType<typeof spawnProcess>,
|
||||
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)
|
||||
|
||||
@@ -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<typeof vi.fn> } {
|
||||
@@ -12,7 +16,8 @@ function fakeTree(): ClaudeChildTreeReaper & { reap: ReturnType<typeof vi.fn> }
|
||||
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<typeof fixtureRoot>,
|
||||
terminateWindowsTree: (rootPid: number) => Promise<boolean>
|
||||
) {
|
||||
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()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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<boolean> {
|
||||
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()))
|
||||
}
|
||||
|
||||
@@ -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<SpawnedProcess, 'kill'>
|
||||
@@ -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<boolean>
|
||||
terminateTree: (root: WindowsProcessIdentity) => Promise<void>
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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<Row extends { pid: number }>(
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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')(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<object>()),
|
||||
isWindowsProcessStartTimeAvailable
|
||||
}))
|
||||
|
||||
const SESSION_ID = 'session-1'
|
||||
const IDENTITY = { sessionId: SESSION_ID } as Parameters<
|
||||
ReturnType<typeof createCodexStructuredLaunchResolver>
|
||||
@@ -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']
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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<NodeJS.ProcessEnv>
|
||||
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}`)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -79,8 +79,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
|
||||
})
|
||||
}
|
||||
|
||||
supportsLocation = (location: Parameters<typeof supportsCodexStructuredLocation>[0]): boolean =>
|
||||
supportsCodexStructuredLocation(location, this.deps.isWindowsProcessStartTimeAvailable)
|
||||
supportsLocation = supportsCodexStructuredLocation
|
||||
|
||||
acquire = (input: StructuredAgentSessionAcquireInput): Promise<AgentSessionAcquisition> =>
|
||||
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<StructuredAgentSessionAdapter['backgroundTaskStops']> = (
|
||||
sessionId
|
||||
|
||||
@@ -86,8 +86,6 @@ export type CodexStructuredSessionAdapterDeps = {
|
||||
resolveLaunch: (input: {
|
||||
identity: AgentSessionJournalIdentity
|
||||
}) => Promise<CodexStructuredLaunch>
|
||||
/** 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
+203
@@ -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<typeof pidProbe>,
|
||||
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' })
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
+116
@@ -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()
|
||||
})
|
||||
})
|
||||
+2
-2
@@ -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<void> } }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof setInterval> | null = null
|
||||
private running = false
|
||||
@@ -22,6 +27,8 @@ export class StructuredAgentSessionLeaseRenewer {
|
||||
probeMany?: (
|
||||
records: readonly AgentSessionRecord[]
|
||||
) => Promise<Map<string, AgentSessionOwnerProbe>>
|
||||
/** 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<string, AgentSessionOwnerProbe>()
|
||||
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
|
||||
|
||||
@@ -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<ChildBearer, 'child'> | 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<ChildBearer, 'child'> | 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<StructuredAgentSessionAdapter, 'holdsLiveProviderProcess'> | 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,
|
||||
|
||||
+25
@@ -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))
|
||||
|
||||
+11
-6
@@ -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<void>
|
||||
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<StructuredSessionRecoveryStopSignal, number> = {
|
||||
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)
|
||||
|
||||
@@ -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<boolean> => true),
|
||||
capture: vi.fn(async (): Promise<DescendantSnapshot | null> => 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()
|
||||
|
||||
@@ -9,12 +9,17 @@ export type ProviderProcessTree = {
|
||||
refresh?: () => Promise<void>
|
||||
reap(): Promise<DescendantTreeVerdict>
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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<void>()
|
||||
const release = Promise.withResolvers<boolean>()
|
||||
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',
|
||||
|
||||
@@ -20,7 +20,8 @@ export type ProviderProcessTeardownDeps = {
|
||||
dedicatedProcessGroup?: boolean
|
||||
captureDescendants?: (rootPid: number) => Promise<DescendantSnapshot | null>
|
||||
terminateDescendants?: (snapshot: DescendantSnapshot) => Promise<DescendantTreeVerdict>
|
||||
terminateWindowsTree?: (rootPid: number, deps?: { site?: string }) => Promise<void>
|
||||
/** Resolves true only when taskkill reports the whole tree terminated. */
|
||||
terminateWindowsTree?: (rootPid: number, deps?: { site?: string }) => Promise<boolean>
|
||||
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)
|
||||
|
||||
@@ -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<Row extends { pid: number; ppid: number }>(
|
||||
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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<number | null> {
|
||||
// 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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<typeof AttachParams>, ctx: RpcContext) {
|
||||
await ensureHostInstalled(ctx)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<Record<string, number>>
|
||||
}
|
||||
|
||||
export type WindowsDescendantVerificationDeps = {
|
||||
readTable?: () => Promise<{ pid: number; ppid: number; creationTimeMs?: number }[]>
|
||||
now?: () => number
|
||||
wait?: (ms: number) => Promise<void>
|
||||
verifyMs?: number
|
||||
}
|
||||
|
||||
/** Revalidate a Windows PID/creation-time identity immediately before a kill. */
|
||||
export async function verifyWindowsProcessIdentity(
|
||||
target: WindowsProcessIdentity,
|
||||
deps: Pick<WindowsDescendantVerificationDeps, 'readTable'> = {}
|
||||
): Promise<boolean> {
|
||||
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<void> {
|
||||
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<WindowsDescendantSnapshot | null> {
|
||||
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<void>
|
||||
ownsRoot?: () => boolean
|
||||
}
|
||||
|
||||
/** Revalidate the captured root at the last async boundary before taskkill. */
|
||||
export async function terminateIdentifiedWindowsProcessTree(
|
||||
target: WindowsProcessIdentity,
|
||||
deps: IdentifiedWindowsTreeTerminationDeps = {}
|
||||
): Promise<boolean> {
|
||||
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<DescendantTreeVerdict> {
|
||||
// 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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<void> {
|
||||
): Promise<boolean> {
|
||||
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)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -141,7 +141,7 @@ export function NativeChatExperimentalSetting({
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{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.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 が再度開かれたときに作業中だったチャットが自動的に再開されます。",
|
||||
|
||||
@@ -7165,7 +7165,7 @@
|
||||
"shellEnvToggleLabel": "셸 환경 사용 전환",
|
||||
"structuredTitle": "업데이트된 구조화된 기본 채팅 사용",
|
||||
"structuredCopy": "새 Codex 및 Claude 에이전트를 구조화된 채팅으로 엽니다. 끄면 터미널 기반 채팅으로 엽니다. 이미 있는 채팅은 그대로 유지됩니다.",
|
||||
"structuredScope": "이 컴퓨터와 이를 지원하는 버전을 실행하는 페어링된 Orca 서버에서 실행됩니다. 이전 서버는 터미널 채팅을 유지합니다. WSL 및 SSH 호스트는 계속해서 터미널 채팅을 사용하며 Orca가 프로세스 시작 시간을 읽을 수 없으면 Windows는 터미널 채팅으로 대체됩니다.",
|
||||
"structuredScope": "이 컴퓨터와 이를 지원하는 버전을 실행하는 페어링된 Orca 서버에서 실행됩니다. 이전 서버는 터미널 채팅을 유지합니다. WSL 및 SSH 호스트는 계속해서 터미널 채팅을 사용합니다.",
|
||||
"structuredToggleLabel": "업데이트된 구조화된 기본 채팅 전환",
|
||||
"resumeTitle": "다시 시작한 후 작업 중인 채팅을 자동으로 재개합니다.",
|
||||
"resumeCopy": "Orca가 종료되거나 업데이트를 설치하면 Orca가 다시 열릴 때 작업 중이던 채팅이 자동으로 재개됩니다.",
|
||||
|
||||
@@ -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 后,正在工作的聊天会自动恢复。",
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -69,7 +69,6 @@ function stubTerminalCapabilityApi(args: {
|
||||
wslDistros?: string[]
|
||||
gitBashAvailable?: boolean
|
||||
hostPlatform?: NodeJS.Platform | null
|
||||
windowsProcessStartTimeAvailable?: boolean
|
||||
}): {
|
||||
wslIsAvailable: ReturnType<typeof vi.fn>
|
||||
wslListDistros: ReturnType<typeof vi.fn>
|
||||
@@ -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'])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -66,9 +66,6 @@ export async function readWindowsTerminalCapabilities(
|
||||
pwshAvailable,
|
||||
gitBashAvailable,
|
||||
hostPlatform: runtimeStatus?.hostPlatform ?? null,
|
||||
...(runtimeStatus?.windowsProcessStartTimeAvailable !== undefined
|
||||
? { windowsProcessStartTimeAvailable: runtimeStatus.windowsProcessStartTimeAvailable }
|
||||
: {}),
|
||||
isLoading: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user