Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
ad2b5ac
fix(supervisor): add a one-shot lifetime that runs past stdin end, an…
brennanb2025 Oct 5, 2026
7b44d9f
fix(text-generation): run agent one-shots under the provider supervis…
brennanb2025 Oct 5, 2026
ef72be7
fix(supervisor): carry the provider argv on the supervisor's argv, ma…
brennanb2025 Oct 5, 2026
24b29ab
fix(supervisor): report every provider spawn failure on one marked st…
brennanb2025 Oct 5, 2026
904043f
fix(supervisor): keep the user's Node options away from the superviso…
brennanb2025 Oct 5, 2026
7bef501
fix(text-generation): file a forced agent one-shot teardown under its…
brennanb2025 Oct 5, 2026
f95551d
test(text-generation): cover the supervised stop under timeout and ou…
brennanb2025 Oct 5, 2026
5cd8c8e
fix(codex): run short-lived app-server sessions under the provider su…
brennanb2025 Oct 5, 2026
a3d89b4
refactor(supervisor): share the stop of a supervised child process
brennanb2025 Oct 5, 2026
924c2f3
fix(codex): supervise the app-server that keeps a Codex index backfil…
brennanb2025 Oct 5, 2026
216eabd
fix(opencode): stop the launch model preflight server with Orca on POSIX
brennanb2025 Oct 5, 2026
4f1bc54
test(codex): pin when supervised session and backfill stops escalate
brennanb2025 Oct 5, 2026
e7ecdd4
test(text-generation): run the direct-child suites on the Windows pat…
brennanb2025 Oct 5, 2026
b2eced4
fix(supervisor): show a supervised spawn failure in native chat as th…
brennanb2025 Oct 5, 2026
0c8ebc4
fix(supervisor): report a spawn that failed without a pid instead of …
brennanb2025 Oct 5, 2026
ed5201b
refactor(supervisor): share the stop of a supervised child process
brennanb2025 Oct 5, 2026
395000b
Merge PR1 branch brennanb2025/supervised-one-shot-cli (ed5201b1c84)
brennanb2025 Oct 5, 2026
1cd1195
fix(codex,opencode): file forced backfill and preflight teardowns und…
brennanb2025 Oct 5, 2026
10c154d
test(codex): write the stand-in pid report atomically
brennanb2025 Oct 5, 2026
e4071ab
fix(supervisor): give a session provider its stdin end and grace when…
brennanb2025 Oct 5, 2026
80e4cea
fix(opencode): stop the preflight server through the shared supervise…
brennanb2025 Oct 5, 2026
ea5e3a6
chore(codex): state the supervised stop time in the probe and trust g…
brennanb2025 Oct 5, 2026
76999bb
test(codex): assert the signals a supervised stop sends itself, not a…
brennanb2025 Oct 5, 2026
c01233e
fix(supervisor): kill the rest of the provider group once the provide…
brennanb2025 Oct 5, 2026
6800a8b
test(text-generation): cover a supervised Codex discovery past its ou…
brennanb2025 Oct 5, 2026
5f91769
fix(supervisor): close a provider whose owner is gone the way its own…
brennanb2025 Oct 5, 2026
2e7b4e9
Merge PR1 branch brennanb2025/supervised-one-shot-cli (5f917691349)
brennanb2025 Oct 5, 2026
5ecc87f
fix(codex): close short-lived sessions and the backfill app-server th…
brennanb2025 Oct 5, 2026
f97d201
test(codex,opencode): an owner's death drains Codex before SIGTERM, a…
brennanb2025 Oct 5, 2026
f7d024a
test(codex): give the trust-grant deadline tests room for a supervise…
brennanb2025 Oct 5, 2026
0a2de70
fix(supervisor): keep the SIGTERM grace for a session's group after i…
brennanb2025 Oct 5, 2026
d8f8358
test(claude): pin that Claude's spawn passes its close request explic…
brennanb2025 Oct 5, 2026
cc04350
refactor(codex): give the Codex app-server close request its own module
brennanb2025 Oct 5, 2026
18d4ae0
Merge origin/main (9def4b9ba19) into brennanb2025/supervised-one-shot…
brennanb2025 Oct 5, 2026
5018d20
Merge origin/main (e65bbc95cec) into brennanb2025/supervised-one-shot…
brennanb2025 Oct 5, 2026
5dfeaff
build(cli): list the Codex close request and the provider supervisor …
brennanb2025 Oct 5, 2026
64647c2
Merge PR1 branch brennanb2025/supervised-one-shot-cli (5dfeaff9956, o…
brennanb2025 Oct 6, 2026
2e23019
test(codex): give a stand-in 20 s to report its pids on a loaded host
brennanb2025 Oct 6, 2026
b132737
Merge origin/main (a47e0f56578) into brennanb2025/supervised-one-shot…
brennanb2025 Oct 6, 2026
55fccd8
Merge PR1 branch brennanb2025/supervised-one-shot-cli (b13273729f8, o…
brennanb2025 Oct 6, 2026
91fb935
test(codex): start the deadline tests' clocks past the stand-in's sta…
brennanb2025 Oct 6, 2026
f31774a
test(text-generation): check the ENOEXEC start failure only where Nod…
brennanb2025 Oct 6, 2026
a9b91fc
Merge PR1 branch brennanb2025/supervised-one-shot-cli (f31774ac7e1)
brennanb2025 Oct 6, 2026
b86c5b2
test(wsl): follow the backfill's wsl.exe spawn into its new process m…
brennanb2025 Oct 6, 2026
f289d1e
test(codex): keep factory child_process mocks loadable now that Codex…
brennanb2025 Oct 6, 2026
dca2bdd
Merge origin/main (29e669680d6) into brennanb2025/supervised-one-shot…
brennanb2025 Oct 6, 2026
e535ee4
Merge PR1 branch brennanb2025/supervised-one-shot-cli (dca2bdde14d, o…
brennanb2025 Oct 6, 2026
fba19a9
fix(opencode): count no forced preflight stop as proof the server is …
brennanb2025 Oct 6, 2026
01b4af7
Merge origin/main (b922342199b) into brennanb2025/supervised-codex-op…
brennanb2025 Oct 6, 2026
5dce900
Merge origin/main (51062e653ea) into brennanb2025/supervised-codex-op…
brennanb2025 Oct 6, 2026
285b36c
Merge origin/main (c893048aebc) into brennanb2025/supervised-codex-op…
brennanb2025 Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions config/tsconfig.cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,21 @@
"../src/main/windows-native-registry.ts",
"../src/main/windows/windows-command-line-recovery-health.ts",
"../src/main/windows/windows-process-table.ts",
"../src/main/windows/windows-pty-job.ts",
"../src/main/crash-reporting/crash-breadcrumb-store.ts",
"../src/main/crash-reporting/durable-crash-breadcrumb.ts",
"../src/main/crash-reporting/main-process-lifecycle-identity.ts",
"../src/main/crash-reporting/self-initiated-tree-kill-log.ts",
"../src/main/observability/redactor.ts",
"../src/main/observability/tracer.ts",
"../src/main/orca-chromium-process-pids.ts",
"../src/main/own-chromium-tree-kill-guard.ts",
"../src/main/providers/windows-foreground-process-rows.ts",
"../src/main/pty-descendant-exit-verification.ts",
"../src/main/pty-descendant-termination.ts",
"../src/main/pty-process-table-parser.ts",
"../src/main/windows-process-tree-kill.ts",
"../src/main/windows-pty-root-identity.ts",
"../src/main/windows/windows-process-table-cim-scan.ts",
"../src/main/daemon/daemon-process-start-time.ts",
"../src/main/daemon/daemon-process-identity-query.ts",
Expand Down Expand Up @@ -157,7 +172,12 @@
"../src/main/codex/codex-hook-wsl-runtime.ts",
"../src/main/codex/codex-managed-trust-reconciliation.ts",
"../src/main/provider-process/provider-process-exit-deadline.ts",
"../src/main/provider-process/managed-provider-process.ts",
"../src/main/provider-process/provider-process-close.ts",
"../src/main/provider-process/provider-process-launch.ts",
"../src/main/provider-process/provider-process-supervisor.ts",
"../src/main/provider-process/provider-process-teardown.ts",
"../src/main/provider-process/provider-spawn-failure-report.ts",
"../src/main/codex/codex-real-home-hook-entry-plan.ts",
"../src/main/codex/codex-real-home-hook-install.ts",
"../src/main/codex/codex-real-home-hook-sweep.ts",
Expand Down
12 changes: 12 additions & 0 deletions src/main/codex-accounts/service-account-add-login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ describe('CodexAccountService config sync', () => {
)

vi.doMock('node:child_process', () => ({
// The supervised Codex teardown's process-table reader binds execFile when it loads.
execFile: vi.fn(),
execFileSync: vi.fn(),
spawn: spawnMock
}))
Expand Down Expand Up @@ -146,6 +148,8 @@ describe('CodexAccountService config sync', () => {
)

vi.doMock('node:child_process', () => ({
// The supervised Codex teardown's process-table reader binds execFile when it loads.
execFile: vi.fn(),
execFileSync: vi.fn(),
spawn: spawnMock
}))
Expand Down Expand Up @@ -254,6 +258,8 @@ describe('CodexAccountService config sync', () => {
)

vi.doMock('node:child_process', () => ({
// The supervised Codex teardown's process-table reader binds execFile when it loads.
execFile: vi.fn(),
execFileSync: vi.fn(),
spawn: spawnMock
}))
Expand Down Expand Up @@ -411,6 +417,8 @@ describe('CodexAccountService config sync', () => {
}
)
vi.doMock('node:child_process', () => ({
// The supervised Codex teardown's process-table reader binds execFile when it loads.
execFile: vi.fn(),
execFileSync: vi.fn(),
spawn: spawnMock
}))
Expand Down Expand Up @@ -486,6 +494,8 @@ describe('CodexAccountService config sync', () => {
const spawnMock = vi.fn()

vi.doMock('node:child_process', () => ({
// The supervised Codex teardown's process-table reader binds execFile when it loads.
execFile: vi.fn(),
execFileSync: vi.fn(),
spawn: spawnMock
}))
Expand Down Expand Up @@ -532,6 +542,8 @@ describe('CodexAccountService config sync', () => {
const spawnMock = vi.fn()

vi.doMock('node:child_process', () => ({
// The supervised Codex teardown's process-table reader binds execFile when it loads.
execFile: vi.fn(),
execFileSync: vi.fn(),
spawn: spawnMock
}))
Expand Down
53 changes: 45 additions & 8 deletions src/main/codex/codex-app-server-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,22 @@ import {
type CodexHookTrustGrantRequest
} from './codex-app-server-client'
import { killCodexAppServerProcessTree } from './codex-app-server-process-tree-kill'
import {
PROVIDER_SIGTERM_GRACE_MS,
PROVIDER_SUPERVISOR_MAX_STOP_MS
} from '../provider-process/provider-process-supervisor'
import { runCodexAppServerSession } from './codex-app-server-session'

// Stub codex app-server speaking the same JSONL protocol: initialize →
// initialized → hooks/list → config/batchWrite → hooks/list. Scenario-driven
// via STUB_CONFIG so each test controls listings, errors, and hangs.
const STUB_SERVER_SOURCE = `
const config = JSON.parse(process.env.STUB_CONFIG)
// A wedged server ignores SIGTERM and its stdin end, so only SIGKILL ends it.
if (config.scenario === 'wedged') {
process.on('SIGTERM', () => {})
setInterval(() => {}, 60000)
}
require('node:fs').writeFileSync(config.pidFile, String(process.pid))
const trusted = new Set(config.hooks.filter(h => h.trustStatus === 'trusted').map(h => h.key))
let buffer = ''
Expand Down Expand Up @@ -68,7 +77,7 @@ process.stdin.on('data', (chunk) => {
continue
}
if (message.method === 'initialized') continue
if (config.scenario === 'hang') continue
if (config.scenario === 'hang' || config.scenario === 'wedged') continue
if (message.method === 'hooks/list') {
if (config.scenario === 'unknown-method') {
send({ id: message.id, error: { code: -32601, message: 'Method not found' } })
Expand All @@ -88,7 +97,7 @@ process.stdin.on('data', (chunk) => {
}
}
})
process.stdin.on('end', () => process.exit(0))
process.stdin.on('end', () => { if (config.scenario !== 'wedged') process.exit(0) })
function writeFileSyncSafe(file, contents) { require('node:fs').writeFileSync(file, contents) }
`

Expand Down Expand Up @@ -149,6 +158,9 @@ function createStubRequest(options: {
}

const MANAGED_COMMAND = "/bin/sh '/tmp/orca/codex-hook.sh'"
// The deadline starts at spawn and the stub writes its pid only once it runs; on a loaded host the
// supervisor and stub have taken over 2 s to start, so a shorter deadline can stop it first.
const STUB_START_DEADLINE_MS = 8_000

function managedHook(key: string, trustStatus = 'untrusted'): StubHook {
return { key, command: MANAGED_COMMAND, currentHash: `sha256:hash-of-${key}`, trustStatus }
Expand Down Expand Up @@ -393,34 +405,59 @@ describe('runCodexHookTrustGrantSession', () => {
hooks: keys.map((key) => managedHook(key)),
expectedTrustKeys: keys,
managedCommand: MANAGED_COMMAND,
timeoutMs: 500
timeoutMs: STUB_START_DEADLINE_MS
})

const startedAt = Date.now()
await expect(runCodexHookTrustGrantSession(request)).rejects.toBeInstanceOf(
CodexAppServerTimeoutError
)
// Why: the reap path must not stack the grace periods on top of the
// deadline — a wedged server may ignore everything but SIGKILL.
expect(Date.now() - startedAt).toBeLessThan(5_000)
// Why: this stub exits on its stdin end and on SIGTERM, so the reap adds no grace on top of
// the deadline; a server that ignores both is the case below.
expect(Date.now() - startedAt - STUB_START_DEADLINE_MS).toBeLessThan(2_000)
const childPid = Number(readFileSync(pidFile, 'utf8'))
expect(() => process.kill(childPid, 0)).toThrow()
})

it.runIf(process.platform !== 'win32')(
'stops a server that ignores its stdin end and SIGTERM after the SIGTERM grace',
async () => {
const keys = ['/home/a/.codex/hooks.json:session_start:0:0']
const { request, pidFile } = createStubRequest({
scenario: 'wedged',
hooks: keys.map((key) => managedHook(key)),
expectedTrustKeys: keys,
managedCommand: MANAGED_COMMAND,
timeoutMs: STUB_START_DEADLINE_MS
})

const startedAt = Date.now()
await expect(runCodexHookTrustGrantSession(request)).rejects.toBeInstanceOf(
CodexAppServerTimeoutError
)
// The supervisor SIGTERMs its group at the deadline and SIGKILLs it after the grace.
const pastDeadline = Date.now() - startedAt - STUB_START_DEADLINE_MS
expect(pastDeadline).toBeGreaterThanOrEqual(PROVIDER_SIGTERM_GRACE_MS - 100)
expect(pastDeadline).toBeLessThan(PROVIDER_SUPERVISOR_MAX_STOP_MS + 1_000)
const childPid = Number(readFileSync(pidFile, 'utf8'))
expect(() => process.kill(childPid, 0)).toThrow()
}
)

it('bounds a callback that stalls between RPC requests', async () => {
const { request, pidFile } = createStubRequest({
scenario: 'happy',
hooks: [],
expectedTrustKeys: [],
managedCommand: MANAGED_COMMAND,
timeoutMs: 500
timeoutMs: STUB_START_DEADLINE_MS
})

const startedAt = Date.now()
await expect(
runCodexAppServerSession(request.invocation, async () => new Promise<never>(() => {}))
).rejects.toBeInstanceOf(CodexAppServerTimeoutError)
expect(Date.now() - startedAt).toBeLessThan(5_000)
expect(Date.now() - startedAt - STUB_START_DEADLINE_MS).toBeLessThan(2_000)
const childPid = Number(readFileSync(pidFile, 'utf8'))
expect(() => process.kill(childPid, 0)).toThrow()
})
Expand Down
134 changes: 134 additions & 0 deletions src/main/codex/codex-app-server-session-stop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor'
import { terminateProviderProcessTree } from '../provider-process/provider-process-teardown'
import type * as ProcessTreeKill from './codex-app-server-process-tree-kill'
import {
killCodexAppServerProcessTree,
type CodexAppServerSpawn
} from './codex-app-server-process-tree-kill'
import { CodexAppServerTimeoutError, runCodexAppServerSession } from './codex-app-server-session'

vi.mock('../provider-process/provider-process-teardown', () => ({
terminateProviderProcessTree: vi.fn(async () => 'exited' as const)
}))
vi.mock('./codex-app-server-process-tree-kill', async (importOriginal) => ({
...(await importOriginal<typeof ProcessTreeKill>()),
killCodexAppServerProcessTree: vi.fn()
}))

type FakeChild = EventEmitter & {
pid: number
stdin: PassThrough
stdout: PassThrough
stderr: PassThrough
kill: ReturnType<typeof vi.fn<(signal?: NodeJS.Signals) => boolean>>
}

/** A child that answers initialize and then nothing; it exits only on `exitsOn`. */
function fakeChild(exitsOn: NodeJS.Signals | null): FakeChild {
const child: FakeChild = Object.assign(new EventEmitter(), {
pid: 4242,
stdin: new PassThrough(),
stdout: new PassThrough(),
stderr: new PassThrough(),
kill: vi.fn((signal?: NodeJS.Signals) => {
if (signal === exitsOn) {
child.emit('exit', null, signal)
child.emit('close', null, signal)
}
return true
})
})
child.stdin.on('data', (chunk: Buffer) => {
const message = JSON.parse(chunk.toString())
if (message.method === 'initialize') {
child.stdout.write(`${JSON.stringify({ id: message.id, result: {} })}\n`)
}
})
return child
}

const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')

afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
})

function startWedgedSession(child: FakeChild, platform: NodeJS.Platform): Promise<unknown> {
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the session reads only pid, stdio, kill and the exit/close/error events, which the fake implements.
const spawnImpl: CodexAppServerSpawn = () => child as never
return runCodexAppServerSession(
{ command: '/bin/codex', cliPath: null, args: ['app-server'], timeoutMs: 1_000 },
() => new Promise<never>(() => {}),
spawnImpl
).catch((error: unknown) => error)
}

describe('runCodexAppServerSession stop', () => {
it('SIGTERMs a supervised session at its deadline instead of waiting out its stdin grace', async () => {
vi.useFakeTimers()
const child = fakeChild('SIGTERM')
let stdinEnded = false
child.stdin.on('finish', () => (stdinEnded = true))

const outcome = startWedgedSession(child, 'darwin')
await vi.advanceTimersByTimeAsync(1_000)

expect(child.kill.mock.calls).toEqual([['SIGTERM']])
expect(stdinEnded).toBe(true)
expect(await outcome).toBeInstanceOf(CodexAppServerTimeoutError)
expect(terminateProviderProcessTree).not.toHaveBeenCalled()
expect(killCodexAppServerProcessTree).not.toHaveBeenCalled()
})

it('tears a supervised session down only once the supervisor has had its full stop time', async () => {
vi.useFakeTimers()
const child = fakeChild(null)

const outcome = startWedgedSession(child, 'linux')
await vi.advanceTimersByTimeAsync(1_000 + PROVIDER_SUPERVISOR_MAX_STOP_MS - 1)
expect(terminateProviderProcessTree).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)

expect(terminateProviderProcessTree).toHaveBeenCalledWith(child, {
site: 'codex-app-server-session',
platform: 'linux'
})
await vi.advanceTimersByTimeAsync(1_000)
expect(await outcome).toBeInstanceOf(CodexAppServerTimeoutError)
// The session's only own signal is the deadline SIGTERM; only the teardown forces.
expect(child.kill.mock.calls).toEqual([['SIGTERM']])
expect(killCodexAppServerProcessTree).not.toHaveBeenCalled()
})

it('keeps the Windows deadline kill and its 1.5 s close wait', async () => {
vi.useFakeTimers()
const child = fakeChild(null)

const outcome = startWedgedSession(child, 'win32')
await vi.advanceTimersByTimeAsync(1_000)
expect(killCodexAppServerProcessTree).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1_499)
expect(terminateProviderProcessTree).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
// The shared close's Windows teardown (taskkill of the tree), as the Codex connection's.
expect(terminateProviderProcessTree).toHaveBeenCalledWith(child, {
site: 'codex-app-server-session',
platform: 'win32'
})

await vi.advanceTimersByTimeAsync(1_000)
expect(await outcome).toBeInstanceOf(CodexAppServerTimeoutError)
// The session sends no signal of its own on Windows (a SIGTERM there is TerminateProcess);
// the teardowns, mocked here, own the kill.
expect(child.kill).not.toHaveBeenCalled()
expect(killCodexAppServerProcessTree).toHaveBeenCalledTimes(1)
})
})
Loading
Loading