mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
fix(orchestration): preserve mutation recovery identity
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import { createServer, type Server } from 'node:net'
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
|
||||
import { orchestrationMutationRecoveryError } from '../orchestration-mutation-recovery'
|
||||
import { RuntimeClient } from './client'
|
||||
|
||||
const servers = new Set<Server>()
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
[...servers].map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.close(() => resolve())
|
||||
})
|
||||
)
|
||||
)
|
||||
servers.clear()
|
||||
})
|
||||
|
||||
describe('RuntimeClient orchestration recovery identity', () => {
|
||||
it('attaches the request and exact retry identity to a real RPC failure response', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-recovery-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
const server = createServer((socket) => {
|
||||
let buffer = ''
|
||||
socket.setEncoding('utf8')
|
||||
socket.on('data', (chunk: string) => {
|
||||
buffer += chunk
|
||||
const newline = buffer.indexOf('\n')
|
||||
if (newline === -1) {
|
||||
return
|
||||
}
|
||||
const request = JSON.parse(buffer.slice(0, newline)) as { id: string; method: string }
|
||||
const response =
|
||||
request.method === 'status.get'
|
||||
? {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: { capabilities: [ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY] },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
: {
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'runtime_timeout',
|
||||
message: 'request timed out',
|
||||
data: { requestId: 'request_1', dispatchId: 'dispatch_1' }
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
socket.end(`${JSON.stringify(response)}\n`)
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeFileSync(
|
||||
join(userDataPath, 'orca-runtime.json'),
|
||||
JSON.stringify({
|
||||
runtimeId: 'runtime-1',
|
||||
pid: 1,
|
||||
transports: [{ kind: 'unix', endpoint }],
|
||||
authToken: 'token',
|
||||
startedAt: 1
|
||||
})
|
||||
)
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 500)
|
||||
try {
|
||||
await client.call('orchestration.workerStart', { task: 'task_1' })
|
||||
throw new Error('expected worker-start failure')
|
||||
} catch (error) {
|
||||
const recovered = orchestrationMutationRecoveryError(error) as {
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
expect(recovered.data).toMatchObject({
|
||||
orchestrationRequestId: expect.any(String),
|
||||
dispatchId: 'dispatch_1',
|
||||
originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_1'],
|
||||
recovery: {
|
||||
queryCommand: [
|
||||
'orca',
|
||||
'orchestration',
|
||||
'worker-show',
|
||||
'--dispatch',
|
||||
'dispatch_1',
|
||||
'--json'
|
||||
],
|
||||
retryCommand: [
|
||||
'orca',
|
||||
'orchestration',
|
||||
'worker-start',
|
||||
'--task',
|
||||
'task_1',
|
||||
'--retry-request',
|
||||
expect.any(String)
|
||||
],
|
||||
recoveryBlocked: false
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,7 @@ import { RemoteRuntimeCompatGate } from './remote-runtime-compat-gate'
|
||||
import { createOrchestrationCompatibilityEnvelope } from './orchestration-compatibility-envelope'
|
||||
import { getTimeoutMsParam, isWaitingCheck } from './runtime-request-timeout'
|
||||
import { resolveWorkerStartClientTimeoutMs } from '../../shared/orchestration-timing-budgets'
|
||||
import { buildOrchestrationRecoveryCommand } from './orchestration-recovery-command'
|
||||
|
||||
// Why: for long-poll methods the caller's method-level
|
||||
// `params.timeoutMs` is the inner waiter budget; we extend the client-side
|
||||
@@ -80,6 +81,9 @@ export class RuntimeClient {
|
||||
const orchestrationRequestId = orchestrationMutation
|
||||
? (options?.orchestrationRequestId ?? randomUUID())
|
||||
: undefined
|
||||
const originalCommand = orchestrationMutation
|
||||
? buildOrchestrationRecoveryCommand(method, params)
|
||||
: undefined
|
||||
const compatibilityEnvelope = method.startsWith('orchestration.')
|
||||
? {
|
||||
...this.orchestrationCompatibility,
|
||||
@@ -108,10 +112,14 @@ export class RuntimeClient {
|
||||
envelope
|
||||
})
|
||||
} catch (error) {
|
||||
throw attachMutationRecovery(error, orchestrationRequestId)
|
||||
throw attachMutationRecovery(error, orchestrationRequestId, originalCommand)
|
||||
}
|
||||
if (response.ok === false) {
|
||||
throw new RuntimeRpcFailureError(response)
|
||||
throw attachMutationRecovery(
|
||||
new RuntimeRpcFailureError(response),
|
||||
orchestrationRequestId,
|
||||
originalCommand
|
||||
)
|
||||
}
|
||||
if (this.environmentSelector) {
|
||||
markEnvironmentUsed(this.userDataPath, this.environmentSelector, {
|
||||
@@ -125,10 +133,14 @@ export class RuntimeClient {
|
||||
try {
|
||||
response = await sendRequest<TResult>(metadata, method, params, effectiveTimeoutMs, envelope)
|
||||
} catch (error) {
|
||||
throw attachMutationRecovery(error, orchestrationRequestId)
|
||||
throw attachMutationRecovery(error, orchestrationRequestId, originalCommand)
|
||||
}
|
||||
if (response.ok === false) {
|
||||
throw new RuntimeRpcFailureError(response)
|
||||
throw attachMutationRecovery(
|
||||
new RuntimeRpcFailureError(response),
|
||||
orchestrationRequestId,
|
||||
originalCommand
|
||||
)
|
||||
}
|
||||
return response
|
||||
}
|
||||
@@ -246,7 +258,11 @@ export class RuntimeClient {
|
||||
}
|
||||
}
|
||||
|
||||
function attachMutationRecovery(error: unknown, requestId: string | undefined): unknown {
|
||||
function attachMutationRecovery(
|
||||
error: unknown,
|
||||
requestId: string | undefined,
|
||||
originalCommand?: string[]
|
||||
): unknown {
|
||||
if (!requestId || !(error instanceof RuntimeClientError)) {
|
||||
return error
|
||||
}
|
||||
@@ -255,7 +271,11 @@ function attachMutationRecovery(error: unknown, requestId: string | undefined):
|
||||
`${error.message} Orchestration mutation request ID: ${requestId}.`,
|
||||
{
|
||||
...(error.data && typeof error.data === 'object' ? error.data : {}),
|
||||
orchestrationRequestId: requestId
|
||||
orchestrationRequestId: requestId,
|
||||
...(originalCommand &&
|
||||
!(error.data && typeof error.data === 'object' && 'originalCommand' in error.data)
|
||||
? { originalCommand }
|
||||
: {})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildOrchestrationRecoveryCommand } from './orchestration-recovery-command'
|
||||
|
||||
describe('orchestration recovery command identity', () => {
|
||||
it('reconstructs the keyed worker-start command from its RPC params', () => {
|
||||
expect(
|
||||
buildOrchestrationRecoveryCommand('orchestration.workerStart', {
|
||||
task: 'task_1',
|
||||
on: 'windows',
|
||||
worktree: 'new-top-level',
|
||||
timeoutMs: 90_000,
|
||||
devMode: false
|
||||
})
|
||||
).toEqual([
|
||||
'orca',
|
||||
'orchestration',
|
||||
'worker-start',
|
||||
'--task',
|
||||
'task_1',
|
||||
'--on',
|
||||
'windows',
|
||||
'--worktree',
|
||||
'new-top-level',
|
||||
'--timeout-ms',
|
||||
'90000'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not fabricate identity for unsupported mutations or malformed params', () => {
|
||||
expect(buildOrchestrationRecoveryCommand('orchestration.send', {})).toBeUndefined()
|
||||
expect(buildOrchestrationRecoveryCommand('orchestration.workerStart', null)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
export function buildOrchestrationRecoveryCommand(
|
||||
method: string,
|
||||
params: unknown
|
||||
): string[] | undefined {
|
||||
const command =
|
||||
method === 'orchestration.workerStart'
|
||||
? 'worker-start'
|
||||
: method === 'orchestration.workerStop'
|
||||
? 'worker-stop'
|
||||
: method === 'orchestration.workerAbandon'
|
||||
? 'worker-abandon'
|
||||
: method === 'orchestration.workerRelease'
|
||||
? 'worker-release'
|
||||
: undefined
|
||||
if (!command || params === null || typeof params !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
const record = params as Record<string, unknown>
|
||||
const requiredKey = command === 'worker-start' ? 'task' : 'dispatch'
|
||||
if (typeof record[requiredKey] !== 'string' || record[requiredKey].length === 0) {
|
||||
return undefined
|
||||
}
|
||||
const args = ['orca', 'orchestration', command]
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (value === undefined || value === null || value === false) {
|
||||
continue
|
||||
}
|
||||
const flag = `--${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`
|
||||
if (value === true) {
|
||||
args.push(flag)
|
||||
} else if (typeof value === 'string' || typeof value === 'number') {
|
||||
args.push(flag, String(value))
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error'
|
||||
import { orchestrationMutationRecoveryError } from '../orchestration-mutation-recovery'
|
||||
|
||||
const { sendRemoteRuntimeRequest, sendRemoteRuntimeRequestWithStatusPreflight } = vi.hoisted(
|
||||
() => ({
|
||||
sendRemoteRuntimeRequest: vi.fn(),
|
||||
sendRemoteRuntimeRequestWithStatusPreflight: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('../../shared/remote-runtime-client', () => ({
|
||||
RemoteRuntimeClientError,
|
||||
sendRemoteRuntimeRequest,
|
||||
sendRemoteRuntimeRequestWithStatusPreflight
|
||||
}))
|
||||
|
||||
import {
|
||||
sendWebSocketRequest,
|
||||
sendWebSocketRequestWithStatusPreflight
|
||||
} from './websocket-transport'
|
||||
|
||||
const pairing = {
|
||||
v: 2,
|
||||
endpoint: 'ws://127.0.0.1:1',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: 'key'
|
||||
} as never
|
||||
|
||||
describe('CLI WebSocket transport error identity', () => {
|
||||
it('preserves recovery data from one-shot transport errors', async () => {
|
||||
sendRemoteRuntimeRequest.mockRejectedValueOnce(
|
||||
new RemoteRuntimeClientError('runtime_timeout', 'timed out', {
|
||||
data: {
|
||||
orchestrationRequestId: 'request_1',
|
||||
dispatchId: 'dispatch_1',
|
||||
originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_1']
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
await sendWebSocketRequest(pairing, 'orchestration.workerStart', {}, 100)
|
||||
throw new Error('expected transport failure')
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({
|
||||
code: 'runtime_timeout',
|
||||
data: {
|
||||
orchestrationRequestId: 'request_1',
|
||||
dispatchId: 'dispatch_1',
|
||||
originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_1']
|
||||
}
|
||||
})
|
||||
expect(orchestrationMutationRecoveryError(error)).toMatchObject({
|
||||
data: {
|
||||
recovery: {
|
||||
dispatchId: 'dispatch_1',
|
||||
queryCommand: [
|
||||
'orca',
|
||||
'orchestration',
|
||||
'worker-show',
|
||||
'--dispatch',
|
||||
'dispatch_1',
|
||||
'--json'
|
||||
],
|
||||
retryCommand: [
|
||||
'orca',
|
||||
'orchestration',
|
||||
'worker-start',
|
||||
'--task',
|
||||
'task_1',
|
||||
'--retry-request',
|
||||
'request_1'
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves recovery data through status-preflight transport errors', async () => {
|
||||
sendRemoteRuntimeRequestWithStatusPreflight.mockRejectedValueOnce(
|
||||
new RemoteRuntimeClientError('runtime_timeout', 'timed out', {
|
||||
data: { orchestrationRequestId: 'request_2' }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
sendWebSocketRequestWithStatusPreflight(
|
||||
pairing,
|
||||
'orchestration.workerStart',
|
||||
{},
|
||||
100,
|
||||
() => {}
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: 'runtime_timeout',
|
||||
data: { orchestrationRequestId: 'request_2' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,7 @@ export async function sendWebSocketRequest<TResult>(
|
||||
return await sendRemoteRuntimeRequest<TResult>(pairing, method, params, timeoutMs, envelope)
|
||||
} catch (error) {
|
||||
if (error instanceof RemoteRuntimeClientError) {
|
||||
throw new RuntimeClientError(error.code, error.message)
|
||||
throw new RuntimeClientError(error.code, error.message, error.data)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export async function sendWebSocketRequestWithStatusPreflight<TResult>(
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof RemoteRuntimeClientError) {
|
||||
throw new RuntimeClientError(error.code, error.message)
|
||||
throw new RuntimeClientError(error.code, error.message, error.data)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { OrchestrationDb } from './orchestration/db'
|
||||
import { readRuntimeMetadata } from './runtime-metadata'
|
||||
import { OrcaRuntimeRpcServer } from './runtime-rpc'
|
||||
import { longPollClassOf, OrcaRuntimeRpcServer } from './runtime-rpc'
|
||||
import {
|
||||
sendRequest,
|
||||
openFramedSession,
|
||||
@@ -32,6 +32,25 @@ vi.mock('../git/worktree', () => {
|
||||
})
|
||||
|
||||
describe('OrcaRuntimeRpcServer', () => {
|
||||
it('keeps agent-prompt submission sockets alive during verification', () => {
|
||||
expect(
|
||||
longPollClassOf({
|
||||
id: 'req_prompt',
|
||||
authToken: 'token',
|
||||
method: 'terminal.send',
|
||||
params: { agentPrompt: true }
|
||||
})
|
||||
).toBe('wait')
|
||||
expect(
|
||||
longPollClassOf({
|
||||
id: 'req_direct',
|
||||
authToken: 'token',
|
||||
method: 'terminal.send',
|
||||
params: { agentPrompt: false }
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects oversized RPC frames instead of buffering them indefinitely', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const runtime = new OrcaRuntimeService()
|
||||
@@ -241,6 +260,45 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('emits keepalive frames while agent-prompt verification blocks', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime,
|
||||
userDataPath,
|
||||
keepaliveIntervalMs: 30
|
||||
})
|
||||
const dispatch = server['dispatcher']
|
||||
vi.spyOn(dispatch, 'dispatch').mockImplementation(async (request) => {
|
||||
await sleep(120)
|
||||
return {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: { send: { accepted: true } },
|
||||
_meta: { runtimeId: runtime.getRuntimeId() }
|
||||
}
|
||||
})
|
||||
await server.start()
|
||||
|
||||
try {
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
const session = openFramedSession(metadata!.transports[0]!.endpoint, {
|
||||
id: 'req_prompt',
|
||||
authToken: metadata!.authToken,
|
||||
method: 'terminal.send',
|
||||
params: { agentPrompt: true }
|
||||
})
|
||||
await session.done
|
||||
|
||||
expect(
|
||||
session.frames.filter((frame) => frame._keepalive === true).length
|
||||
).toBeGreaterThanOrEqual(2)
|
||||
expect(session.frames.filter((frame) => frame.ok !== undefined)).toHaveLength(1)
|
||||
} finally {
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('releases terminal.wait long-poll slot when the client closes mid-wait', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const runtime = new OrcaRuntimeService()
|
||||
|
||||
@@ -445,10 +445,20 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
||||
type LongPollClass = 'ask' | 'wait'
|
||||
|
||||
// Why: single classifier for long-poll requests (handlers that block on an external event), shared by counter/abort/keepalive. See §3.1.
|
||||
function longPollClassOf(request: RpcRequest): LongPollClass | null {
|
||||
export function longPollClassOf(request: RpcRequest): LongPollClass | null {
|
||||
if (request.method === 'terminal.wait') {
|
||||
return 'wait'
|
||||
}
|
||||
// Agent-prompt submission waits for the PTY's lifecycle transition (up to
|
||||
// the verification budget); keep the local socket alive for that wait.
|
||||
if (
|
||||
request.method === 'terminal.send' &&
|
||||
typeof request.params === 'object' &&
|
||||
request.params !== null &&
|
||||
(request.params as { agentPrompt?: unknown }).agentPrompt === true
|
||||
) {
|
||||
return 'wait'
|
||||
}
|
||||
// Why: orchestration.ask blocks unconditionally (default 600 s) holding the
|
||||
// RPC open until a reply lands or the deadline passes, so it needs the same
|
||||
// keepalive as check --wait or the 30 s socket idle timer tears it down. It
|
||||
|
||||
@@ -9,6 +9,8 @@ export type RemoteRuntimePairingStage = 'connect' | 'host-identity' | 'access-gr
|
||||
*/
|
||||
export class RemoteRuntimeClientError extends Error {
|
||||
readonly code: string
|
||||
/** Structured recovery data must survive the WebSocket adapter unchanged. */
|
||||
readonly data?: unknown
|
||||
readonly pairingStage?: RemoteRuntimePairingStage
|
||||
readonly closeCode?: number
|
||||
|
||||
@@ -16,6 +18,7 @@ export class RemoteRuntimeClientError extends Error {
|
||||
code: string,
|
||||
message: string,
|
||||
details?: {
|
||||
data?: unknown
|
||||
pairingStage?: RemoteRuntimePairingStage
|
||||
closeCode?: number
|
||||
}
|
||||
@@ -23,6 +26,7 @@ export class RemoteRuntimeClientError extends Error {
|
||||
super(message)
|
||||
this.name = 'RemoteRuntimeClientError'
|
||||
this.code = code
|
||||
this.data = details?.data
|
||||
this.pairingStage = details?.pairingStage
|
||||
this.closeCode = details?.closeCode
|
||||
}
|
||||
|
||||
@@ -157,7 +157,11 @@ export function toRemoteRuntimeClientError(error: unknown): RemoteRuntimeClientE
|
||||
return error
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return new RemoteRuntimeClientError('runtime_error', error.message)
|
||||
const data =
|
||||
typeof error === 'object' && error !== null && 'data' in error
|
||||
? (error as { data?: unknown }).data
|
||||
: undefined
|
||||
return new RemoteRuntimeClientError('runtime_error', error.message, { data })
|
||||
}
|
||||
return new RemoteRuntimeClientError('runtime_error', String(error))
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { waitForSessionReady } from './helpers/store'
|
||||
import { SWALLOWED_ENTER_FIXTURE_TIMEOUT_MS } from '../../src/shared/orchestration-timing-budgets'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const fixtureRoot = mkdtempSync(path.join(os.tmpdir(), 'orca-terminal-send-agent-prompt-'))
|
||||
@@ -13,7 +14,7 @@ const fixtureReport = path.join(fixtureRoot, 'report.json')
|
||||
const fixtureMarker = `ORCA_TERMINAL_SEND_E2E_${process.pid}`
|
||||
const fixtureScript = path.join(process.cwd(), 'tests', 'tools', 'repro-terminal-send-submit.mjs')
|
||||
const fakeCodex = path.join(fixtureBin, process.platform === 'win32' ? 'codex.cmd' : 'codex')
|
||||
const swallowedEnterFixtureTimeoutMs = 60_000
|
||||
const swallowedEnterFixtureTimeoutMs = SWALLOWED_ENTER_FIXTURE_TIMEOUT_MS
|
||||
|
||||
mkdirSync(fixtureBin)
|
||||
writeFileSync(
|
||||
|
||||
Reference in New Issue
Block a user