Files
orca/src/main/codex/codex-structured-launch-resolution.test.ts
T
Brennan Benson ab6389045e 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.
2026-10-06 12:09:58 -07:00

311 lines
12 KiB
TypeScript

import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { createCodexStructuredLaunchResolver } from './codex-structured-launch-resolution'
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>
>[0]['identity']
async function withPlatform<T>(platform: NodeJS.Platform, run: () => Promise<T>): Promise<T> {
const original = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
try {
return await run()
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: original })
}
}
function record(overrides: Partial<AgentSessionRecord> = {}): AgentSessionRecord {
return {
sessionId: SESSION_ID,
provider: 'codex',
location: {
executionHostId: LOCAL_EXECUTION_HOST_ID,
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
},
accountHome: { variable: 'CODEX_HOME', path: '/home/work/.codex' },
providerHandleChain: [],
...overrides
} as AgentSessionRecord
}
function resolverFor(
value: AgentSessionRecord | null,
resolveWorkspacePath: (workspaceId: string) => Promise<string> = async (id) => `/repos/${id}`,
resolveRollout: () => Promise<string | null> = async () => null,
agentDefaultArgs: Record<string, string> = { codex: '' }
) {
return createCodexStructuredLaunchResolver({
store: { getRecord: () => value, pinLaunchDirectory: vi.fn() },
resolveWorkspacePath,
resolveCommand: () => '/usr/local/bin/codex',
resolveRollout,
resolvePermissionPolicy: () => codexStructuredPermissionPolicyForSettings({ agentDefaultArgs })
})
}
describe('codex structured launch resolution', () => {
it('resumes a floating session in its pinned folder, not the current floating setting', async () => {
const pinned = mkdtempSync(join(tmpdir(), 'orca-codex-floating-'))
const resolveWorkspacePath = vi.fn(async () => '/floating/current-setting')
const floating = record({
location: { ...record().location, workspaceId: FLOATING_TERMINAL_WORKTREE_ID },
launchDirectory: pinned
})
const launch = await resolverFor(floating, resolveWorkspacePath)({ identity: IDENTITY })
expect(launch.cwd).toBe(pinned)
expect(resolveWorkspacePath).not.toHaveBeenCalled()
})
it('repairs the first launch directory of an unpinned legacy floating session', async () => {
const pinLaunchDirectory = vi.fn()
const resolveLaunch = createCodexStructuredLaunchResolver({
store: {
getRecord: () =>
record({
location: { ...record().location, workspaceId: FLOATING_TERMINAL_WORKTREE_ID }
}),
pinLaunchDirectory
},
resolveWorkspacePath: async () => '/floating/start-folder',
resolveCommand: () => '/usr/local/bin/codex'
})
await expect(resolveLaunch({ identity: IDENTITY })).resolves.toMatchObject({
cwd: '/floating/start-folder'
})
expect(pinLaunchDirectory).toHaveBeenCalledExactlyOnceWith(SESSION_ID, '/floating/start-folder')
})
it('launches the app server in the workspace and account home the record pinned', async () => {
const launch = await resolverFor(record())({ identity: IDENTITY })
expect(launch).toEqual({
command: '/usr/local/bin/codex',
args: ['app-server'],
cwd: '/repos/workspace-1',
codexHome: '/home/work/.codex',
resumeThreadId: null,
// Every launch now carries a posture; neither one is left for config.toml to decide.
permissionPolicy: { approvalPolicy: 'on-request', sandbox: 'workspace-write' }
})
})
it('passes a Windows .cmd path containing cmd syntax directly to the safe spawn layer', async () => {
const command = String.raw`C:\Users\r&d\npm-prefix\codex.cmd`
await withPlatform('win32', async () => {
const resolveLaunch = createCodexStructuredLaunchResolver({
store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() },
resolveWorkspacePath: async () => String.raw`C:\workspaces\orca`,
resolveCommand: () => command
})
await expect(resolveLaunch({ identity: IDENTITY })).resolves.toMatchObject({
command,
args: ['app-server']
})
})
})
it('resolves a Windows launch on a host that cannot read process creation times', async () => {
isWindowsProcessStartTimeAvailable.mockReturnValue(false)
await withPlatform('win32', async () => {
const resolveLaunch = createCodexStructuredLaunchResolver({
store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() },
resolveWorkspacePath: async () => String.raw`C:\workspaces\orca`,
resolveCommand: () => 'codex.exe'
})
await expect(resolveLaunch({ identity: IDENTITY })).resolves.toMatchObject({
command: 'codex.exe',
args: ['app-server']
})
})
})
it('resumes the last thread this session actually proved, not one a caller names', async () => {
const launch = await resolverFor(
record({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only each link's handle, so the link's other fields stay unset.
providerHandleChain: [
{ handle: codexProviderHandle('thread-old') },
{ handle: codexProviderHandle('thread-current') }
] as AgentSessionRecord['providerHandleChain']
})
)({ identity: IDENTITY })
expect(launch.resumeThreadId).toBe('thread-current')
})
it('lets only a thread this session created be superseded when Codex never saved it', async () => {
const link = (
origin: AgentSessionProviderHandleLink['origin'],
mintedAtFence: number
): AgentSessionProviderHandleLink => ({
linkId: `link-${mintedAtFence}`,
handle: codexProviderHandle('t'),
origin,
mintedAtFence,
observedAt: 1
})
const chainFor = (origin: 'created' | 'resumed' | 'adopted') =>
origin === 'resumed' ? [link('created', 1), link('resumed', 2)] : [link(origin, 1)]
const created = await resolverFor(record({ providerHandleChain: chainFor('created') }))({
identity: IDENTITY
})
expect(created).toMatchObject({ resumeThreadId: 't', supersedeIfUnsaved: true })
for (const origin of ['resumed', 'adopted'] as const) {
const launch = await resolverFor(record({ providerHandleChain: chainFor(origin) }))({
identity: IDENTITY
})
expect(launch.resumeThreadId).toBe('t')
expect(launch).not.toHaveProperty('supersedeIfUnsaved')
}
const fresh = await resolverFor(record())({ identity: IDENTITY })
expect(fresh).not.toHaveProperty('supersedeIfUnsaved')
})
// Agent Permissions is the only thing derived from the arguments field. app-server owns it on
// the thread RPC rather than through the interactive CLI's process flags.
it('resolves the bypass posture as app-server thread policy', async () => {
const launch = await resolverFor(record(), undefined, undefined, {
codex: '--dangerously-bypass-approvals-and-sandbox --model gpt-5.6-sol'
})({ identity: IDENTITY })
expect(launch.args).toEqual(['app-server'])
expect(launch.permissionPolicy).toEqual({
approvalPolicy: 'never',
sandbox: 'danger-full-access'
})
})
it('bypasses approvals for a profile that never opened Agent settings', async () => {
const launch = await resolverFor(record(), undefined, undefined, {})({ identity: IDENTITY })
expect(launch.args).toEqual(['app-server'])
expect(launch.permissionPolicy).toEqual({
approvalPolicy: 'never',
sandbox: 'danger-full-access'
})
})
// Stated, not omitted: app-server resolves an absent field through the mirrored config.toml,
// so a Manual session on a home carrying `approval_policy = "never"` never prompted at all.
it('states the approval posture under Manual', async () => {
const launch = await resolverFor(record())({ identity: IDENTITY })
expect(launch.args).toEqual(['app-server'])
expect(launch.permissionPolicy).toEqual({
approvalPolicy: 'on-request',
sandbox: 'workspace-write'
})
})
// A thread opened on the configured default and then given a turn on the saved model reads to
// Codex as a model switch, and it injects the saved model's whole prompt a second time.
it('opens the thread on the model the record saved', async () => {
const launch = await resolverFor(
record({ options: { model: 'gpt-chosen', effort: 'high', fastMode: 'false' } })
)({ identity: IDENTITY })
expect(launch.model).toBe('gpt-chosen')
})
// The configured CLI arguments are a terminal concern: a durable record written before they
// stopped being read must not smuggle one back into app-server's argv.
it("ignores the record's durable launch arguments", async () => {
const launch = await resolverFor(
record({ launchArgs: ['--profile', 'review', '-c', 'model_reasoning_effort=high'] })
)({ identity: IDENTITY })
expect(launch.args).toEqual(['app-server'])
})
it('pins resume to the rollout file that proved the durable thread', async () => {
const resolveRollout = vi.fn(async () => '/home/work/.codex/sessions/rollout.jsonl')
const launch = await resolverFor(
record({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only each link's handle, so the link's other fields stay unset.
providerHandleChain: [
{ handle: codexProviderHandle('thread-current') }
] as AgentSessionRecord['providerHandleChain']
}),
async (id) => `/repos/${id}`,
resolveRollout
)({ identity: IDENTITY })
expect(resolveRollout).toHaveBeenCalledWith('/home/work/.codex', 'thread-current')
expect(launch.resumePath).toBe('/home/work/.codex/sessions/rollout.jsonl')
})
it('refuses a session pinned to another host rather than starting a second writer here', async () => {
await expect(
resolverFor(
record({
location: { ...record().location, executionHostId: 'ssh:build-box' }
} as Partial<AgentSessionRecord>)
)({ identity: IDENTITY })
).rejects.toThrow(/local host/)
})
it('refuses a WSL session, which is a separate filesystem and process namespace', async () => {
await expect(
resolverFor(record({ location: { ...record().location, wslDistro: 'Ubuntu' } }))({
identity: IDENTITY
})
).rejects.toThrow(/local host/)
})
it('refuses a record this adapter does not speak for', async () => {
await expect(
resolverFor(record({ provider: 'claude' } as Partial<AgentSessionRecord>))({
identity: IDENTITY
})
).rejects.toThrow(/is a claude session/)
await expect(
resolverFor(
record({ accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/.claude' } })
)({
identity: IDENTITY
})
).rejects.toThrow(/CODEX_HOME/)
})
it('refuses to launch for a session the store has no record of', async () => {
await expect(resolverFor(null)({ identity: IDENTITY })).rejects.toThrow(/no durable/)
})
it('surfaces a workspace that no longer resolves instead of falling back to a default cwd', async () => {
await expect(
resolverFor(record(), async () => {
throw new Error('workspace-1 is gone')
})({ identity: IDENTITY })
).rejects.toThrow('workspace-1 is gone')
})
})