mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
* Require Codex 0.136.0 for native chats
* Add host-owned Codex install and update actions
* Bind Codex checks and maintenance to the selected installation
* Resolve Windows updater cleanup through system helpers
* Use one child environment for Codex checks and launch
* Expire Codex readiness and reconcile current host jobs
* Resolve Codex checks against the child command search path
* Restrict global npm repair to the selected Codex install layout
* Pin the host updater directory through Windows supervision
* Synchronize Codex maintenance fixtures on readiness
* Unify Codex notices and recover version-refused chats
* Route Codex recovery to the operation that remains
* Preserve Codex setup execution errors in the job log
* Preserve UTF-8 in Codex setup logs
* Remove unused Codex host installation detector
* Require host administration for Codex maintenance
* Show a missing Codex explanation once
* test(native-chat): give scripted Codex runtimes their installation fact; keep the registry out of the relay
Runtime tests that drive a scripted Codex app-server still ran the new
version check against whatever codex was on the machine's PATH: refused
on CI (no codex), and on a dev machine it ran the real codex --version.
A scripted transport now must state the Codex it stands for
(readCodexInstallation), asserted at install, and the tests get it from
one scriptedCodexTransport fixture.
The relay's Codex maintenance path reached @orca/windows-registry through
resolvePathEnvKey's re-export in windows-environment-path; import it from
its registry-free module instead.
Also update the catalog-probe test for the shared child environment.
* refactor(native-chat): drop the Update Codex action; keep Install for a missing Codex
A too-old Codex now gets only the plain message ("Codex X is too old for
chats. Update to 0.136.0 or newer.") with no button and no job. The
existing rechecks (window focus, the <=30 s expiry of a saved result)
retry the refused chat once the user has updated Codex themselves.
A missing Codex keeps the Install Codex button: `npm install -g
@openai/codex` with the npm on the chat's PATH, as a host-run job with
its log, then a recheck and recovery. A configured Command that is
missing gets the message only, since a global npm install would not
land at that path.
Removed: the `codex update` command and its 0.126 floor, manual
instructions, npm layout and launcher identity detection, the action
descriptor (install|update) and its titles, the legacy capability, and
the SSH relay maintenance path (structured chat never runs over SSH or
WSL), so the relay preflight handler is back to main's.
* refactor(native-chat): keep Install Codex in Settings only
When Codex isn't installed, main already hides Codex from the New tab menu,
so the in-chat Install button was almost never reachable. Settings → Agents
already shows "Codex isn't installed." with Install Codex.
- Chats and the new-workspace composer now only state "Codex isn't
installed." (no button, no install job from the chat).
- The Install action, its busy spinner and the host-busy subscription move
into the Settings Codex row, the only place that starts the job.
- The store no longer re-checks every other workspace context when a job
finishes. A refused chat recovers through the same re-check a too-old
Codex uses (window focus, or the <=30 s expiry of a saved result).
- The chat recovery key no longer includes the install job id.
- Tests: Settings drives the install; a new test proves a chat refused for
a missing Codex recovers through focus after a Settings install, not
through the job; the expiry recovery test covers missing and too old.
* refactor(codex): check chat compatibility when acquiring the child
* Restore workspace submit guard and simplify Codex maintenance
* Keep only the Codex chat minimum version
439 lines
16 KiB
TypeScript
439 lines
16 KiB
TypeScript
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 {
|
|
CodexAppServerConnection,
|
|
CodexAppServerConnectionHandlers,
|
|
openCodexAppServerConnection
|
|
} from '../codex/codex-app-server-connection'
|
|
import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope'
|
|
import {
|
|
HOST_TEST_SESSION as SESSION,
|
|
hostTestAttachParams,
|
|
hostTestMessage
|
|
} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data'
|
|
import {
|
|
ensureStructuredAgentSessionHost,
|
|
stopStructuredAgentSessionRuntime
|
|
} from './structured-agent-session-runtime'
|
|
import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
|
|
import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
|
|
import { scriptedCodexTransport } from './scripted-codex-transport.test-fixture'
|
|
|
|
describe('structured session runtime provider-exit wiring', () => {
|
|
let root: string | null = null
|
|
let operations = 0
|
|
|
|
const operationId = (): string => `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}`
|
|
|
|
afterEach(async () => {
|
|
await stopStructuredAgentSessionRuntime()
|
|
if (root) {
|
|
await rm(root, { recursive: true, force: true })
|
|
root = null
|
|
}
|
|
})
|
|
|
|
it('does not respawn after a provider exit, and the next message starts a new child', async () => {
|
|
root = await mkdtemp(join(tmpdir(), 'orca-runtime-provider-exit-'))
|
|
operations = 0
|
|
const connections: {
|
|
connection: CodexAppServerConnection
|
|
handlers: CodexAppServerConnectionHandlers
|
|
}[] = []
|
|
let turn = 0
|
|
const openConnection = (async (_launch, handlers = {}) => {
|
|
const connection: CodexAppServerConnection = {
|
|
pid: 4321,
|
|
closed: false,
|
|
request: async (method, params) => {
|
|
if (method === 'thread/start') {
|
|
return { thread: { id: 'thread-runtime-exit' } }
|
|
}
|
|
if (method === 'thread/resume') {
|
|
return { thread: { id: (params as { threadId: string }).threadId } }
|
|
}
|
|
if (method === 'turn/start') {
|
|
return { turn: { id: `turn-${++turn}` } }
|
|
}
|
|
if (method === 'model/list') {
|
|
return {
|
|
data: [
|
|
{
|
|
model: 'gpt-test',
|
|
displayName: 'GPT Test',
|
|
hidden: false,
|
|
supportedReasoningEfforts: [],
|
|
defaultReasoningEffort: null,
|
|
isDefault: true
|
|
}
|
|
],
|
|
nextCursor: null
|
|
}
|
|
}
|
|
return {}
|
|
},
|
|
notify: () => {},
|
|
respond: () => {},
|
|
respondWithError: () => {},
|
|
close: async () => true
|
|
}
|
|
connections.push({ connection, handlers })
|
|
return connection
|
|
}) as typeof openCodexAppServerConnection
|
|
const host = await ensureStructuredAgentSessionHost({
|
|
logger: createStructuredAgentSessionLogger(),
|
|
stateDirectory: root,
|
|
hostId: 'local',
|
|
claimKeyId: 'key-1',
|
|
resolveWorkspacePath: async () => root!,
|
|
resolveLaunchArgs: () => [],
|
|
resolveClaudeAuthPolicy: () => ({ account: 'managed' }),
|
|
resolveCodexCommand: () => 'codex',
|
|
resolveEnvironment: async () => ({ PATH: process.env.PATH }),
|
|
...scriptedCodexTransport(openConnection)
|
|
})
|
|
const attachParams = hostTestAttachParams(null, { providerHandle: undefined })
|
|
attachParams.envelope.clientOperationId = operationId()
|
|
const attached = await host.attach({ callerKey: 'runtime-test' }, attachParams)
|
|
if (!attached.ok) {
|
|
throw new Error(
|
|
JSON.stringify({ refusal: attached.refusal, connections: connections.length })
|
|
)
|
|
}
|
|
const exited = connections[0]
|
|
exited?.handlers.onExit?.(new Error('scripted provider exit'))
|
|
await vi.waitFor(() =>
|
|
expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released')
|
|
)
|
|
// Nothing restarts the child on its own; the exit is shown and the chat waits for a send.
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 50))
|
|
expect(connections).toHaveLength(1)
|
|
const recoveredFence = host.deps.store.getRecord(SESSION)?.lease.runtimeFence
|
|
if (recoveredFence === undefined) {
|
|
throw new Error('released lease omitted its fence')
|
|
}
|
|
const body = hostTestMessage('continue with a distinct message')
|
|
const envelope = {
|
|
sessionId: SESSION,
|
|
clientOperationId: operationId(),
|
|
expectedRuntimeFence: recoveredFence,
|
|
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
|
method: 'agentSession.send',
|
|
sessionId: SESSION,
|
|
fields: { body }
|
|
})
|
|
}
|
|
|
|
// `pending` is this send's real answer now, not a weaker one: admission settles
|
|
// when the transport takes the frame, and identity arrives later on the
|
|
// provider's echo. What proves the message reached the REACQUIRED provider is
|
|
// the turn it starts below, which is what this test exists to check.
|
|
await expect(
|
|
host.send({ callerKey: 'runtime-test' }, { envelope, body })
|
|
).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } })
|
|
// The send answers at acceptance; the delivery loop starts a new child and hands it over.
|
|
await vi.waitFor(() => expect(connections).toHaveLength(2))
|
|
await vi.waitFor(() => expect(turn).toBe(1))
|
|
})
|
|
|
|
it('does not reacquire when the production exit callback comes from a requested close', async () => {
|
|
root = await mkdtemp(join(tmpdir(), 'orca-runtime-requested-close-'))
|
|
operations = 0
|
|
const connections: {
|
|
connection: CodexAppServerConnection
|
|
handlers: CodexAppServerConnectionHandlers
|
|
}[] = []
|
|
const openConnection = (async (_launch, handlers = {}) => {
|
|
const connection: CodexAppServerConnection = {
|
|
pid: 4321,
|
|
closed: false,
|
|
request: async (method, params) => {
|
|
if (method === 'thread/start') {
|
|
return { thread: { id: 'thread-runtime-close' } }
|
|
}
|
|
if (method === 'thread/resume') {
|
|
return { thread: { id: (params as { threadId: string }).threadId } }
|
|
}
|
|
if (method === 'turn/start') {
|
|
return { turn: { id: 'turn-close' } }
|
|
}
|
|
if (method === 'model/list') {
|
|
return {
|
|
data: [
|
|
{
|
|
model: 'gpt-test',
|
|
displayName: 'GPT Test',
|
|
hidden: false,
|
|
supportedReasoningEfforts: [],
|
|
defaultReasoningEffort: null,
|
|
isDefault: true
|
|
}
|
|
],
|
|
nextCursor: null
|
|
}
|
|
}
|
|
return {}
|
|
},
|
|
notify: () => {},
|
|
respond: () => {},
|
|
respondWithError: () => {},
|
|
close: async () => {
|
|
handlers.onExit?.(new Error('requested close'))
|
|
return true
|
|
}
|
|
}
|
|
connections.push({ connection, handlers })
|
|
return connection
|
|
}) as typeof openCodexAppServerConnection
|
|
const host = await ensureStructuredAgentSessionHost({
|
|
logger: createStructuredAgentSessionLogger(),
|
|
stateDirectory: root,
|
|
hostId: 'local',
|
|
claimKeyId: 'key-1',
|
|
resolveWorkspacePath: async () => root!,
|
|
resolveLaunchArgs: () => [],
|
|
resolveClaudeAuthPolicy: () => ({ account: 'managed' }),
|
|
resolveCodexCommand: () => 'codex',
|
|
resolveEnvironment: async () => ({ PATH: process.env.PATH }),
|
|
...scriptedCodexTransport(openConnection)
|
|
})
|
|
const attachParams = hostTestAttachParams(null, { providerHandle: undefined })
|
|
attachParams.envelope.clientOperationId = operationId()
|
|
const attached = await host.attach({ callerKey: 'runtime-test' }, attachParams)
|
|
if (!attached.ok) {
|
|
throw new Error(
|
|
JSON.stringify({ refusal: attached.refusal, connections: connections.length })
|
|
)
|
|
}
|
|
|
|
await stopStructuredAgentSessionRuntime()
|
|
await new Promise<void>((resolve) => setImmediate(resolve))
|
|
|
|
expect(connections).toHaveLength(1)
|
|
expect(host.deps.store.getRecord(SESSION)?.lease).toMatchObject({
|
|
claimStatus: 'released',
|
|
ownerProcess: null,
|
|
handoffStage: null
|
|
})
|
|
|
|
const restarted = await ensureStructuredAgentSessionHost({
|
|
logger: createStructuredAgentSessionLogger(),
|
|
stateDirectory: root,
|
|
hostId: 'local',
|
|
claimKeyId: 'key-1',
|
|
resolveWorkspacePath: async () => root!,
|
|
resolveLaunchArgs: () => [],
|
|
resolveClaudeAuthPolicy: () => ({ account: 'managed' }),
|
|
resolveCodexCommand: () => 'codex',
|
|
resolveEnvironment: async () => ({ PATH: process.env.PATH }),
|
|
...scriptedCodexTransport(openConnection)
|
|
})
|
|
await restarted.restoreReadableSessions()
|
|
const history = await restarted.history({ sessionId: SESSION, direction: 'tail' })
|
|
expect(history.ok && history.page.items.some((item) => item.body.kind === 'status')).toBe(false)
|
|
expect(restarted.deps.store.getRecord(SESSION)?.providerHandleChain.at(-1)?.handle).toEqual(
|
|
codexProviderHandle('thread-runtime-close')
|
|
)
|
|
})
|
|
|
|
it('waits for a start a send began before tearing down the runtime', async () => {
|
|
root = await mkdtemp(join(tmpdir(), 'orca-runtime-recovery-shutdown-'))
|
|
let releaseRecovery!: () => void
|
|
const recoveryReleased = new Promise<void>((resolve) => {
|
|
releaseRecovery = resolve
|
|
})
|
|
const connections: {
|
|
connection: CodexAppServerConnection
|
|
handlers: CodexAppServerConnectionHandlers
|
|
}[] = []
|
|
let opens = 0
|
|
const openConnection = (async (_launch, handlers = {}) => {
|
|
opens += 1
|
|
if (opens === 2) {
|
|
await recoveryReleased
|
|
}
|
|
const connection: CodexAppServerConnection = {
|
|
pid: 4321 + opens,
|
|
closed: false,
|
|
request: async (method, params) => {
|
|
if (method === 'thread/start') {
|
|
return { thread: { id: 'thread-runtime-shutdown' } }
|
|
}
|
|
if (method === 'thread/resume') {
|
|
return { thread: { id: (params as { threadId: string }).threadId } }
|
|
}
|
|
if (method === 'turn/start') {
|
|
return { turn: { id: 'turn-shutdown' } }
|
|
}
|
|
if (method === 'model/list') {
|
|
return {
|
|
data: [
|
|
{
|
|
model: 'gpt-test',
|
|
displayName: 'GPT Test',
|
|
hidden: false,
|
|
supportedReasoningEfforts: [],
|
|
defaultReasoningEffort: null,
|
|
isDefault: true
|
|
}
|
|
],
|
|
nextCursor: null
|
|
}
|
|
}
|
|
return {}
|
|
},
|
|
notify: () => {},
|
|
respond: () => {},
|
|
respondWithError: () => {},
|
|
close: async () => true
|
|
}
|
|
connections.push({ connection, handlers })
|
|
return connection
|
|
}) as typeof openCodexAppServerConnection
|
|
const host = await ensureStructuredAgentSessionHost({
|
|
logger: createStructuredAgentSessionLogger(),
|
|
stateDirectory: root,
|
|
hostId: 'local',
|
|
claimKeyId: 'key-1',
|
|
resolveWorkspacePath: async () => root!,
|
|
resolveLaunchArgs: () => [],
|
|
resolveClaudeAuthPolicy: () => ({ account: 'managed' }),
|
|
resolveCodexCommand: () => 'codex',
|
|
resolveEnvironment: async () => ({ PATH: process.env.PATH }),
|
|
...scriptedCodexTransport(openConnection)
|
|
})
|
|
const attachParams = hostTestAttachParams(null, { providerHandle: undefined })
|
|
attachParams.envelope.clientOperationId = operationId()
|
|
const attached = await host.attach({ callerKey: 'runtime-test' }, attachParams)
|
|
expect(attached.ok).toBe(true)
|
|
connections[0]?.handlers.onExit?.(new Error('the first child exited'))
|
|
await vi.waitFor(() =>
|
|
expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released')
|
|
)
|
|
const body = hostTestMessage('start again')
|
|
await host.send(
|
|
{ callerKey: 'runtime-test' },
|
|
{
|
|
envelope: {
|
|
sessionId: SESSION,
|
|
clientOperationId: operationId(),
|
|
expectedRuntimeFence: host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0,
|
|
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
|
method: 'agentSession.send',
|
|
sessionId: SESSION,
|
|
fields: { body }
|
|
})
|
|
},
|
|
body
|
|
}
|
|
)
|
|
// The send's start is still opening its connection when the quit begins.
|
|
await vi.waitFor(() => expect(opens).toBe(2))
|
|
|
|
let stopped = false
|
|
const stopping = stopStructuredAgentSessionRuntime().then(() => {
|
|
stopped = true
|
|
})
|
|
await new Promise<void>((resolve) => setImmediate(resolve))
|
|
expect(stopped).toBe(false)
|
|
releaseRecovery()
|
|
await stopping
|
|
expect(stopped).toBe(true)
|
|
})
|
|
it('drains a final exit callback delivered by the adapter backstop and keeps the retry real', async () => {
|
|
// The first stop refuses, so host eviction cannot prove the child gone and aborts with the
|
|
// session still indexed. What finally stops it is `closeAll`, which delivers the exit
|
|
// callback AFTER host teardown has already run.
|
|
root = await mkdtemp(join(tmpdir(), 'orca-runtime-backstop-exit-'))
|
|
operations = 0
|
|
const connections: {
|
|
connection: CodexAppServerConnection
|
|
handlers: CodexAppServerConnectionHandlers
|
|
}[] = []
|
|
let closeAttempts = 0
|
|
const openConnection: typeof openCodexAppServerConnection = async (_launch, handlers = {}) => {
|
|
const connection: CodexAppServerConnection = {
|
|
pid: 4321,
|
|
closed: false,
|
|
request: async (method, params) => {
|
|
if (method === 'thread/start') {
|
|
return { thread: { id: 'thread-runtime-backstop' } }
|
|
}
|
|
if (method === 'thread/resume') {
|
|
return { thread: { id: (params as { threadId: string }).threadId } }
|
|
}
|
|
if (method === 'turn/start') {
|
|
return { turn: { id: 'turn-backstop' } }
|
|
}
|
|
if (method === 'model/list') {
|
|
return {
|
|
data: [
|
|
{
|
|
model: 'gpt-test',
|
|
displayName: 'GPT Test',
|
|
hidden: false,
|
|
supportedReasoningEfforts: [],
|
|
defaultReasoningEffort: null,
|
|
isDefault: true
|
|
}
|
|
],
|
|
nextCursor: null
|
|
}
|
|
}
|
|
return {}
|
|
},
|
|
notify: () => {},
|
|
respond: () => {},
|
|
respondWithError: () => {},
|
|
close: async () => {
|
|
closeAttempts += 1
|
|
if (closeAttempts === 1) {
|
|
return false
|
|
}
|
|
handlers.onExit?.(new Error('adapter backstop close'))
|
|
return true
|
|
}
|
|
}
|
|
connections.push({ connection, handlers })
|
|
return connection
|
|
}
|
|
const host = await ensureStructuredAgentSessionHost({
|
|
logger: createStructuredAgentSessionLogger(),
|
|
stateDirectory: root,
|
|
hostId: 'local',
|
|
claimKeyId: 'key-1',
|
|
resolveWorkspacePath: async () => root!,
|
|
resolveLaunchArgs: () => [],
|
|
resolveClaudeAuthPolicy: () => ({ account: 'managed' }),
|
|
resolveCodexCommand: () => 'codex',
|
|
resolveEnvironment: async () => ({ PATH: process.env.PATH }),
|
|
...scriptedCodexTransport(openConnection)
|
|
})
|
|
const attachParams = hostTestAttachParams(null, { providerHandle: undefined })
|
|
attachParams.envelope.clientOperationId = operationId()
|
|
expect(await host.attach({ callerKey: 'runtime-test' }, attachParams)).toMatchObject({
|
|
ok: true
|
|
})
|
|
|
|
await expect(stopStructuredAgentSessionRuntime()).rejects.toThrow()
|
|
await new Promise<void>((resolve) => setImmediate(resolve))
|
|
|
|
// The backstop, not host eviction, is what stopped the child.
|
|
expect(closeAttempts).toBeGreaterThanOrEqual(2)
|
|
// The callback it delivered neither reacquired nor wrote a technical row.
|
|
expect(connections).toHaveLength(1)
|
|
const history = await host.history({ sessionId: SESSION, direction: 'tail' })
|
|
expect(history.ok && history.page.items.some((item) => item.body.kind === 'status')).toBe(false)
|
|
|
|
// The aborted eviction left the session reachable, so the next teardown is a real retry.
|
|
await stopStructuredAgentSessionRuntime()
|
|
expect(host.deps.store.getRecord(SESSION)?.lease).toMatchObject({
|
|
claimStatus: 'released',
|
|
ownerProcess: null,
|
|
handoffStage: null
|
|
})
|
|
})
|
|
})
|