Files
orca/src/main/opencode/opencode-launch-model-context-cleanup.test.ts
T
Brennan Benson 98f5d13689 A kill that reached the agent ends it, so the next message after Stop goes through (#34653)
* fix(native-chat): a kill that reached the agent ends it, so the next message goes through

After Stop, tab close, idle rest or workspace sleep, Orca force-kills the
agent. It used to accept the old agent as gone only when it saw the exit
report within about a second; otherwise every later message re-ran the kill
and was refused with "Couldn't stop {agent} from before." A kill delivered to
the agent itself now ends it and releases the chat with kill-delivered
evidence; the refusal remains only when the kill could not reach the agent.

When writing the chat's journal fails and the following kill can't be
confirmed, recovery now records the close with its fault instead of silently
returning, so the turn no longer stays Working and later sends retry the stop.

Tracked in STA-12354 and STA-12355.

* Preserve provider kill evidence across cleanup retries

* Exercise ACP setup gate with real managed teardown

* fix: preserve Claude writer termination evidence through settlement

* test: consolidate termination evidence contracts

* fix: restore Pi connection exit listener contract

* test: load actual ACP teardown before timed cases

* fix: correct lifecycle contract typing and test boundaries

* test: make Claude piped fixture mock types portable

* test: expect supervised OpenCode probe teardown

* test: align remaining supervised teardown expectations

* fix: align Stop feedback with terminal writer evidence

* test: remove redundant mocked Codex continuation case

* fix: keep adapter declaration and Stop fixture checks portable

* test: keep Stop fixture clock unchanged

* test: read canonical provider handle when reopening Stop journal
2026-10-29 12:15:56 -07:00

284 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { z } from 'zod'
import { ChildProcess } from 'node:child_process'
import { PassThrough } from 'node:stream'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { spawnProcess } from '@orca/process-host'
import {
forceTerminateProcessTree,
signalProcessTree
} from '@orca/process-host/process-tree-termination'
import { probeOpenCodeLaunchModelContext } from './opencode-launch-model-context'
import { readFetchResponseJsonWithinLimit } from '../../shared/fetch-response-body'
import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor'
import { terminateProviderProcessTree } from '../provider-process/provider-process-teardown'
vi.mock('@orca/process-host', () => ({ spawnProcess: vi.fn() }))
vi.mock('@orca/process-host/process-tree-termination', () => ({
signalProcessTree: vi.fn(),
forceTerminateProcessTree: vi.fn()
}))
vi.mock('../provider-process/provider-process-teardown', () => ({
terminateProviderProcessTree: vi.fn()
}))
vi.mock('../../shared/fetch-response-body', () => ({ readFetchResponseJsonWithinLimit: vi.fn() }))
class ProbeChild extends ChildProcess {
override stdin = new PassThrough()
override stdout = new PassThrough()
override stderr = new PassThrough()
override pid = 123456
override exitCode: number | null = null
override signalCode: NodeJS.Signals | null = null
// A handle-less ChildProcess's own kill can signal this test's process group.
override kill = vi.fn((_signal?: NodeJS.Signals | number): boolean => true)
override stdio: [PassThrough, PassThrough, PassThrough, undefined, undefined] = [
this.stdin,
this.stdout,
this.stderr,
undefined,
undefined
]
}
const directory = '/private/project'
const model = { id: 'model-b', providerID: 'private-proof', enabled: true }
const snapshot = {
model: { location: { directory }, data: [model] },
agent: {
location: { directory },
data: [{ id: 'build', mode: 'primary', hidden: false, model }]
},
'model/default': { location: { directory }, data: model },
config: [{ type: 'document', info: { default_agent: 'build' } }]
}
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
let child: ProbeChild
let closeDuringFetch = false
function close() {
child.exitCode = 0
child.emit('exit', 0, null)
child.emit('close', 0, null)
}
function supervisorExits(code: number | null, signal: NodeJS.Signals | null) {
child.exitCode = code
child.signalCode = signal
child.emit('exit', code, signal)
child.emit('close', code, signal)
}
const options = { executable: '/private/opencode', cwd: directory, env: {} }
function decodedSupervisorSpec(env: unknown): unknown {
const encoded =
typeof env === 'object' && env !== null && 'ORCA_PROVIDER_SUPERVISOR_SPEC' in env
? String(env.ORCA_PROVIDER_SUPERVISOR_SPEC)
: ''
return JSON.parse(Buffer.from(encoded, 'base64').toString() || 'null')
}
async function untilStopRequested(
requested = () => child.kill.mock.calls.length > 0
): Promise<void> {
while (!requested()) {
await new Promise((resolve) => setImmediate(resolve))
}
}
describe('OpenCode model probe termination evidence', () => {
beforeEach(() => {
vi.resetAllMocks()
Object.defineProperty(process, 'platform', { value: 'darwin' })
vi.mocked(readFetchResponseJsonWithinLimit).mockImplementation((response) => response.json())
child = new ProbeChild()
closeDuringFetch = false
vi.mocked(spawnProcess).mockImplementation(() => {
queueMicrotask(() => child.stdout.write('server listening on http://127.0.0.1:45678\n'))
return child
})
vi.stubGlobal(
'fetch',
vi.fn(async (input: URL) => {
const endpoint = z
.enum(['model', 'agent', 'model/default', 'config'])
.parse(input.pathname.slice('/api/'.length))
const response = snapshot[endpoint]
if (closeDuringFetch && endpoint === 'agent') {
queueMicrotask(close)
}
return new Response(JSON.stringify(response), { status: 200 })
})
)
vi.mocked(signalProcessTree).mockImplementation(async () => {
close()
return true
})
vi.mocked(forceTerminateProcessTree).mockResolvedValue(false)
child.kill.mockImplementation((signal) => {
if (signal === 'SIGTERM') {
supervisorExits(null, 'SIGTERM')
}
return true
})
})
afterEach(() => {
vi.useRealTimers()
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
vi.unstubAllGlobals()
})
it('cancels all unread responses if the bounded body reader fails', async () => {
const cancel = vi.fn()
vi.mocked(readFetchResponseJsonWithinLimit).mockRejectedValue(new Error('body_reader_failed'))
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(new ReadableStream({ cancel }), { status: 200 }))
)
expect(await probeOpenCodeLaunchModelContext(options)).toBeNull()
expect(cancel).toHaveBeenCalledTimes(1)
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
})
it('accepts an already-closed Windows probe without signaling its former pid', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
closeDuringFetch = true
vi.mocked(signalProcessTree).mockResolvedValue(false)
expect(await probeOpenCodeLaunchModelContext(options)).toMatchObject({ primaryAgent: 'build' })
expect(signalProcessTree).not.toHaveBeenCalled()
expect(forceTerminateProcessTree).not.toHaveBeenCalled()
})
it('runs a POSIX probe under a one-shot supervisor and stops it through that supervisor', async () => {
expect(await probeOpenCodeLaunchModelContext(options)).toMatchObject({ primaryAgent: 'build' })
const [spec] = vi.mocked(spawnProcess).mock.calls[0]
expect(spec).toMatchObject({ program: process.execPath, cwd: directory, detached: true })
const args = spec.args ?? []
expect(args.slice(args.indexOf('--') + 1)).toEqual([
'/private/opencode',
'serve',
'--hostname',
'127.0.0.1',
'--port',
'0'
])
// Session lifetime would stop this probe after its stdin closes.
expect(decodedSupervisorSpec(spec.env)).toMatchObject({ lifetime: 'one-shot', cwd: directory })
expect(child.kill.mock.calls).toEqual([['SIGTERM']])
// Killing the supervisor’s group can orphan the server’s detached group.
expect(signalProcessTree).not.toHaveBeenCalled()
expect(forceTerminateProcessTree).not.toHaveBeenCalled()
expect(terminateProviderProcessTree).not.toHaveBeenCalled()
})
it('forces a POSIX supervisor tree only after its full stop time, and trusts no forced stop', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
child.kill.mockReturnValue(true)
// Supervisor teardown alone cannot prove the detached server group is gone.
vi.mocked(terminateProviderProcessTree).mockImplementation(async () => {
supervisorExits(null, 'SIGKILL')
return null
})
const probe = probeOpenCodeLaunchModelContext(options)
await untilStopRequested()
await vi.advanceTimersByTimeAsync(PROVIDER_SUPERVISOR_MAX_STOP_MS - 1)
expect(terminateProviderProcessTree).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(await probe).toBeNull()
expect(terminateProviderProcessTree).toHaveBeenCalledWith(child, {
site: 'opencode-launch-model-preflight',
supervised: true
})
})
it('trusts no forced POSIX stop, even one whose teardown reports the tree gone', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
child.kill.mockReturnValue(true)
vi.mocked(terminateProviderProcessTree).mockImplementation(async () => {
supervisorExits(null, 'SIGKILL')
return 'exited'
})
const probe = probeOpenCodeLaunchModelContext(options)
await untilStopRequested()
await vi.advanceTimersByTimeAsync(PROVIDER_SUPERVISOR_MAX_STOP_MS)
expect(await probe).toBeNull()
})
it('trusts no POSIX stop whose supervisor exited 1, which a failed group reap also exits', async () => {
child.kill.mockImplementation(() => {
supervisorExits(1, null)
return true
})
expect(await probeOpenCodeLaunchModelContext(options)).toBeNull()
expect(terminateProviderProcessTree).not.toHaveBeenCalled()
})
it('accepts a POSIX supervisor that relayed the server exiting on its own', async () => {
child.kill.mockImplementation(() => {
supervisorExits(0, null)
return true
})
expect(await probeOpenCodeLaunchModelContext(options)).toMatchObject({ primaryAgent: 'build' })
})
it('never forces a POSIX supervisor whose root exited without closing its pipes', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
child.kill.mockImplementation(() => {
child.exitCode = 0
child.emit('exit', 0, null)
return true
})
const probe = probeOpenCodeLaunchModelContext(options)
await untilStopRequested()
await vi.advanceTimersByTimeAsync(PROVIDER_SUPERVISOR_MAX_STOP_MS)
expect(await probe).toBeNull()
expect(terminateProviderProcessTree).not.toHaveBeenCalled()
})
it('keeps a Windows root exit with inherited pipes unverified and avoids its former pid', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
Object.defineProperty(process, 'platform', { value: 'win32' })
vi.mocked(signalProcessTree).mockImplementation(async () => {
child.exitCode = 0
child.emit('exit', 0, null)
return false
})
let settled = false
const probe = probeOpenCodeLaunchModelContext(options).finally(() => (settled = true))
await untilStopRequested(() => vi.mocked(signalProcessTree).mock.calls.length > 0)
await vi.advanceTimersByTimeAsync(1_499)
expect(settled).toBe(false)
expect(forceTerminateProcessTree).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(await probe).toBeNull()
expect(forceTerminateProcessTree).not.toHaveBeenCalled()
})
it('requires verified termination when a still-live Windows probe does not close', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
Object.defineProperty(process, 'platform', { value: 'win32' })
vi.mocked(signalProcessTree).mockResolvedValue(true)
let settled = false
const probe = probeOpenCodeLaunchModelContext(options).finally(() => (settled = true))
await untilStopRequested(() => vi.mocked(signalProcessTree).mock.calls.length > 0)
await vi.advanceTimersByTimeAsync(1_499)
expect(settled).toBe(false)
expect(forceTerminateProcessTree).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(await probe).toBeNull()
expect(forceTerminateProcessTree).toHaveBeenCalledOnce()
})
})