mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 18:17:36 +00:00
* 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
284 lines
11 KiB
TypeScript
284 lines
11 KiB
TypeScript
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()
|
||
})
|
||
})
|