diff --git a/src/cli/orchestration-mutation-recovery.test.ts b/src/cli/orchestration-mutation-recovery.test.ts index 2acd3496241..c1870f5affe 100644 --- a/src/cli/orchestration-mutation-recovery.test.ts +++ b/src/cli/orchestration-mutation-recovery.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { orchestrationMutationRecoveryError } from './orchestration-mutation-recovery' +import { + orchestrationMutationRecoveryError, + renderCommand +} from './orchestration-mutation-recovery' import { RuntimeClientError } from './runtime-client' describe('orchestration mutation recovery', () => { @@ -111,4 +114,27 @@ describe('orchestration mutation recovery', () => { 'request_4' ]) }) + + it('renders Windows cmd recovery guidance without quote drift or percent expansion', () => { + expect( + renderCommand( + ['orca', 'orchestration', 'worker-start', '--comment', 'literal "quoted" %PATH% & safe'], + 'win32', + { ComSpec: 'C:\\Windows\\System32\\cmd.exe' } + ) + ).toBe( + '"orca" "orchestration" "worker-start" "--comment" "literal ""quoted"" "^%"PATH"^%" & safe"' + ) + }) + + it('keeps PowerShell and POSIX recovery guidance literal', () => { + expect( + renderCommand(['orca', 'literal "quoted" $HOME'], 'win32', { + ComSpec: 'powershell.exe' + }) + ).toBe("& 'orca' 'literal \\\"quoted\\\" $HOME'") + expect(renderCommand(['orca', 'literal $(do-not-run)'], 'darwin')).toBe( + "orca 'literal $(do-not-run)'" + ) + }) }) diff --git a/src/cli/orchestration-mutation-recovery.ts b/src/cli/orchestration-mutation-recovery.ts index e3fae18aa8c..9362d5bb2de 100644 --- a/src/cli/orchestration-mutation-recovery.ts +++ b/src/cli/orchestration-mutation-recovery.ts @@ -1,5 +1,9 @@ import { RuntimeClientError } from './runtime-client' import { resolveOrchestrationCliExecutable } from './runtime/orchestration-recovery-command' +import { quoteWindowsCmdArgument } from '../shared/child-process/windows-command-line' +import { quotePowerShellNativeArgument } from '../shared/powershell-native-argument' +import { resolveWindowsShellStartupFamily } from '../shared/windows-terminal-shell' +import type { AgentStartupShell } from '../shared/tui-agent-startup-shell' export function orchestrationMutationRecoveryError(error: unknown): unknown { if (!(error instanceof RuntimeClientError) || !isUnknownMutationOutcomeCode(error.code)) { @@ -129,8 +133,38 @@ function parseCommandLine(value: string): string[] | undefined { return parts.length > 0 ? parts : undefined } -function renderCommand(command: readonly string[]): string { - return command.map(shellQuote).join(' ') +export function renderCommand( + command: readonly string[], + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env +): string { + const shell = resolveRecoveryShell(platform, env) + const rendered = command.map((value) => quoteRecoveryArgument(value, shell)).join(' ') + return shell === 'powershell' && rendered ? `& ${rendered}` : rendered +} + +function resolveRecoveryShell( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv +): AgentStartupShell { + if (platform !== 'win32') { + return 'posix' + } + return resolveWindowsShellStartupFamily( + env.ORCA_TERMINAL_WINDOWS_SHELL ?? env.ORCA_WINDOWS_SHELL ?? env.ComSpec ?? env.COMSPEC + ) +} + +function quoteRecoveryArgument(value: string, shell: AgentStartupShell): string { + if (shell === 'cmd') { + // cmd has no single-quote syntax and expands %VAR% inside double quotes; + // use the argv encoder that keeps quote parity and escapes percent pairs. + return quoteWindowsCmdArgument(value) + } + if (shell === 'powershell') { + return quotePowerShellNativeArgument(value) + } + return shellQuote(value) } function shellQuote(value: string): string { diff --git a/src/main/runtime/runtime-rpc-long-poll-transport.test.ts b/src/main/runtime/runtime-rpc-long-poll-transport.test.ts index 2286827ddeb..0b32415c9e8 100644 --- a/src/main/runtime/runtime-rpc-long-poll-transport.test.ts +++ b/src/main/runtime/runtime-rpc-long-poll-transport.test.ts @@ -32,6 +32,17 @@ vi.mock('../git/worktree', () => { }) describe('OrcaRuntimeRpcServer', () => { + it('classifies worker-start as a keepalive-backed long poll', () => { + expect( + longPollClassOf({ + id: 'req_worker_start', + authToken: 'token', + method: 'orchestration.workerStart', + params: { task: 'task_1', timeoutMs: 60_000 } + }) + ).toBe('wait') + }) + it('keeps agent-prompt submission sockets alive during verification', () => { expect( longPollClassOf({ @@ -92,6 +103,45 @@ describe('OrcaRuntimeRpcServer', () => { // Exercise the real socket (not a mock) so we catch buffer/flush regressions // that a unit-level test would miss. describe('long-poll transport (§3.1)', () => { + it('emits keepalives while orchestration.workerStart 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: { dispatch: { id: 'dispatch_1' } }, + _meta: { runtimeId: runtime.getRuntimeId() } + } + }) + await server.start() + + try { + const metadata = readRuntimeMetadata(userDataPath) + const session = openFramedSession(metadata!.transports[0]!.endpoint, { + id: 'req_worker_start', + authToken: metadata!.authToken, + method: 'orchestration.workerStart', + params: { task: 'task_1', timeoutMs: 60_000 } + }) + 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('emits keepalive frames while a check --wait handler blocks', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const runtime = new OrcaRuntimeService() diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 6d897d19641..4ac6c3287c0 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -446,6 +446,12 @@ 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. export function longPollClassOf(request: RpcRequest): LongPollClass | null { + // Worker start waits for readiness and then verifies the submitted prompt; + // the complete operation can run for 90–110s. Keep every local transport + // (Unix sockets and Windows named pipes) alive for that long poll. + if (request.method === 'orchestration.workerStart') { + return 'wait' + } if (request.method === 'terminal.wait') { return 'wait' }