From 198c445452e4ad82ca89722e8b94fd4ca283e1fb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 29 Aug 2026 03:50:08 -0700 Subject: [PATCH 1/5] fix(cli): say why the runtime is unreachable instead of reporting "starting" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `orca status` swallowed every local RPC failure in a bare `catch {}` and reported `state: "starting"`, so a CLI that could not open the runtime's endpoint looked exactly like one waiting on a runtime that was genuinely still coming up. The transport erased the cause first: its socket `error` handler dropped the errno and replaced it with one fixed message. The runtime writes `orca-runtime.json` only after its transport is listening (RuntimeRpc.start awaits `server.listen` before `writeMetadata`, and tears the transports down if the write fails). So once metadata names an endpoint, a failure to talk to that endpoint is never evidence of a start still in progress — `starting` was unreachable-by-truth there. Keep the errno and the transport phase, classify the failure, and report `state: "unreachable"` with a `unreachableReason` naming the endpoint and what happened. `orca open`'s timeout now carries the same reason instead of a bare "may still be running headlessly". STA-3969 --- src/cli/format.ts | 10 +- src/cli/runtime-client.test.ts | 16 ++ src/cli/runtime/client.ts | 10 +- .../local-runtime-unreachable-reason.test.ts | 104 ++++++++++ .../local-runtime-unreachable-reason.ts | 100 ++++++++++ .../local-runtime-unreachable-status.test.ts | 185 ++++++++++++++++++ src/cli/runtime/status.ts | 40 ++-- src/cli/runtime/transport.ts | 18 +- src/cli/runtime/types.ts | 22 +++ .../runtime-client-export-parity.test.ts | 2 + src/shared/runtime-session-contracts.ts | 27 ++- src/shared/runtime-types.ts | 2 + 12 files changed, 516 insertions(+), 20 deletions(-) create mode 100644 src/cli/runtime/local-runtime-unreachable-reason.test.ts create mode 100644 src/cli/runtime/local-runtime-unreachable-reason.ts create mode 100644 src/cli/runtime/local-runtime-unreachable-status.test.ts diff --git a/src/cli/format.ts b/src/cli/format.ts index 8707ceef092..f72aafdf660 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -246,7 +246,15 @@ export function formatCliStatus(status: CliStatusResult): string { `runtimeState: ${status.runtime.state}`, `runtimeReachable: ${status.runtime.reachable}`, `runtimeId: ${status.runtime.runtimeId ?? 'none'}`, - `graphState: ${status.graph.state}` + `graphState: ${status.graph.state}`, + // Why: STA-3969 — an unreachable runtime is only actionable if the plain-text + // output carries the cause, not just the state word. + ...(status.runtime.unreachableReason + ? [ + `unreachableCode: ${status.runtime.unreachableReason.code}`, + `unreachableDetail: ${status.runtime.unreachableReason.message}` + ] + : []) ].join('\n') } diff --git a/src/cli/runtime-client.test.ts b/src/cli/runtime-client.test.ts index 2fa6130ce3d..71c46ad4dfe 100644 --- a/src/cli/runtime-client.test.ts +++ b/src/cli/runtime-client.test.ts @@ -316,6 +316,22 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { expect(launchOrcaApp).toHaveBeenCalledOnce() }) + // STA-3969: this loop only polls getCliStatus, so an unreachable runtime used to + // burn the whole budget and then report a bare timeout — the third symptom in the + // report, and the one that reads as "the app never finished starting". + it('openOrca reports why the runtime was unreachable instead of a bare timeout', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + writeMetadata(userDataPath, join(userDataPath, 'never-listened.sock'), 'token', process.pid) + + const client = new RuntimeClient(userDataPath, 100) + + await expect(client.openOrca(100)).rejects.toMatchObject({ + code: 'runtime_open_timeout', + message: expect.stringContaining('never-listened.sock'), + data: { unreachableReason: { code: 'endpoint_missing' } } + }) + }) + it('openOrca waits for a reachable headless runtime to expose a desktop window', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) const endpoint = join(userDataPath, 'runtime.sock') diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index f9a855ae929..de3051bd5ea 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -263,6 +263,7 @@ export class RuntimeClient { } const startedAt = Date.now() + let lastReason = initial.result.runtime.unreachableReason while (Date.now() - startedAt < timeoutMs) { const status = await this.getCliStatus() if (status.result.app.desktopWindowStatus === 'blocked') { @@ -271,12 +272,19 @@ export class RuntimeClient { if (status.result.app.desktopWindowStatus === 'available') { return status } + lastReason = status.result.runtime.unreachableReason await delay(250) } + // Why: STA-3969 — this loop polls getCliStatus, so when the runtime is + // unreachable it burns the whole timeout and then reported only that it timed + // out. Carry the reachability failure the poll already diagnosed. throw new RuntimeClientError( 'runtime_open_timeout', - 'Timed out waiting for an Orca desktop window. The runtime may still be running headlessly.' + lastReason + ? `Timed out waiting for an Orca desktop window: the Orca app process is running but its runtime is unreachable. ${lastReason.message}` + : 'Timed out waiting for an Orca desktop window. The runtime may still be running headlessly.', + lastReason ? { unreachableReason: lastReason } : undefined ) } } diff --git a/src/cli/runtime/local-runtime-unreachable-reason.test.ts b/src/cli/runtime/local-runtime-unreachable-reason.test.ts new file mode 100644 index 00000000000..0f091c77ee3 --- /dev/null +++ b/src/cli/runtime/local-runtime-unreachable-reason.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import type { RuntimeTransportMetadata } from '../../shared/runtime-bootstrap' +import { classifyLocalRuntimeUnreachable } from './local-runtime-unreachable-reason' +import { RuntimeClientError, RuntimeRpcFailureError, RuntimeTransportError } from './types' + +const PIPE: RuntimeTransportMetadata = { + kind: 'named-pipe', + endpoint: '\\\\.\\pipe\\orca-26272-abcd' +} +const SOCKET: RuntimeTransportMetadata = { kind: 'unix', endpoint: '/tmp/orca/o-1-ab.sock' } + +function connectError(osErrorCode: string | null): RuntimeTransportError { + return new RuntimeTransportError('runtime_unavailable', 'generic', 'connect', osErrorCode) +} + +describe('classifyLocalRuntimeUnreachable', () => { + it.each([ + ['ENOENT', 'endpoint_missing'], + ['EACCES', 'endpoint_permission_denied'], + ['EPERM', 'endpoint_permission_denied'], + ['ECONNREFUSED', 'connection_refused'], + ['ECONNRESET', 'connection_closed'], + ['EPIPE', 'connection_closed'] + ] as const)('maps a %s connect failure to %s', (osErrorCode, expected) => { + const reason = classifyLocalRuntimeUnreachable(connectError(osErrorCode), PIPE, 1000) + expect(reason.code).toBe(expected) + expect(reason.osErrorCode).toBe(osErrorCode) + }) + + it('classifies a peer close by phase, not by an errno the OS never reported', () => { + const reason = classifyLocalRuntimeUnreachable( + new RuntimeTransportError('runtime_unavailable', 'closed', 'peer_closed'), + SOCKET, + 1000 + ) + expect(reason.code).toBe('connection_closed') + expect(reason.osErrorCode).toBeUndefined() + }) + + it('maps a client-side timeout to request_timeout and names the budget', () => { + const reason = classifyLocalRuntimeUnreachable( + new RuntimeClientError('runtime_timeout', 'timed out'), + SOCKET, + 1234 + ) + expect(reason.code).toBe('request_timeout') + expect(reason.message).toContain('1234ms') + }) + + it('maps an unreadable frame to invalid_response', () => { + const reason = classifyLocalRuntimeUnreachable( + new RuntimeClientError('invalid_runtime_response', 'bad frame'), + SOCKET, + 1000 + ) + expect(reason.code).toBe('invalid_response') + }) + + // Why: a runtime that answers and declines is reachable. Routing it to a + // transport code would aim sandbox/permission advice at an auth failure. + it('quotes the runtime error when the runtime answers and refuses', () => { + const reason = classifyLocalRuntimeUnreachable( + new RuntimeRpcFailureError({ + id: 'req', + ok: false, + error: { code: 'unauthorized', message: 'Auth token is not valid.' } + }), + SOCKET, + 1000 + ) + expect(reason.code).toBe('request_rejected') + expect(reason.message).toContain('unauthorized') + expect(reason.message).toContain('Auth token is not valid.') + }) + + it('falls back to unknown without inventing a cause', () => { + const reason = classifyLocalRuntimeUnreachable(new Error('boom'), SOCKET, 1000) + expect(reason.code).toBe('unknown') + expect(reason.osErrorCode).toBeUndefined() + }) + + it('always reports the endpoint it actually tried, with the right noun', () => { + expect(classifyLocalRuntimeUnreachable(connectError('EACCES'), PIPE, 1000)).toMatchObject({ + endpoint: PIPE.endpoint, + endpointKind: 'named-pipe' + }) + expect(classifyLocalRuntimeUnreachable(connectError('EACCES'), PIPE, 1000).message).toContain( + 'named pipe' + ) + expect(classifyLocalRuntimeUnreachable(connectError('EACCES'), SOCKET, 1000).message).toContain( + 'socket' + ) + }) + + // The reported Windows 10 incident: the app process is alive, the pipe is not + // openable from this process. The guidance must name the isolation possibilities + // rather than telling the user to keep waiting. + it('offers session/sandbox guidance for a hidden Windows named pipe', () => { + const reason = classifyLocalRuntimeUnreachable(connectError('ENOENT'), PIPE, 1000) + expect(reason.message).toContain(PIPE.endpoint) + expect(reason.message).toMatch(/sandbox/i) + expect(reason.message).toMatch(/session/i) + }) +}) diff --git a/src/cli/runtime/local-runtime-unreachable-reason.ts b/src/cli/runtime/local-runtime-unreachable-reason.ts new file mode 100644 index 00000000000..4ac231efc9a --- /dev/null +++ b/src/cli/runtime/local-runtime-unreachable-reason.ts @@ -0,0 +1,100 @@ +import type { + CliRuntimeUnreachableCode, + CliRuntimeUnreachableReason +} from '../../shared/runtime-types' +import type { RuntimeTransportMetadata } from '../../shared/runtime-bootstrap' +import { RuntimeClientError, RuntimeRpcFailureError, RuntimeTransportError } from './types' + +/** + * Why: STA-3969 — `orca status` used to swallow every local RPC failure and report + * `starting`, so a CLI that could not reach the runtime looked identical to one + * waiting on a runtime that was genuinely still coming up. The runtime publishes + * `orca-runtime.json` only *after* its transport is listening (RuntimeRpc.start), + * so once metadata names an endpoint, a failure to talk to that endpoint is never + * evidence of a start still in progress. Name the failure instead. + */ +export function classifyLocalRuntimeUnreachable( + error: unknown, + transport: RuntimeTransportMetadata, + timeoutMs: number +): CliRuntimeUnreachableReason { + const endpoint = transport.endpoint + const endpointKind = transport.kind === 'named-pipe' ? 'named-pipe' : 'unix' + const noun = endpointKind === 'named-pipe' ? 'named pipe' : 'socket' + const osErrorCode = error instanceof RuntimeTransportError ? error.osErrorCode : null + const code = resolveCode(error, osErrorCode) + return { + code, + message: + code === 'request_rejected' && error instanceof RuntimeRpcFailureError + ? `The runtime at ${endpoint} answered but refused the status request (${error.response.error.code}): ${error.response.error.message}` + : describe(code, { endpoint, noun, timeoutMs }), + endpoint, + endpointKind, + ...(osErrorCode ? { osErrorCode } : {}) + } +} + +function resolveCode(error: unknown, osErrorCode: string | null): CliRuntimeUnreachableCode { + // Why: a runtime that answers and declines is not unreachable in the transport + // sense — keep it distinct so "fix your sandbox" advice never lands on an auth + // or version rejection. + if (error instanceof RuntimeRpcFailureError) { + return 'request_rejected' + } + if (error instanceof RuntimeTransportError && error.phase === 'peer_closed') { + return 'connection_closed' + } + switch (osErrorCode) { + case 'ENOENT': + return 'endpoint_missing' + case 'EACCES': + case 'EPERM': + return 'endpoint_permission_denied' + case 'ECONNREFUSED': + return 'connection_refused' + case 'EPIPE': + case 'ECONNRESET': + return 'connection_closed' + case null: + default: + break + } + if (error instanceof RuntimeClientError) { + if (error.code === 'runtime_timeout') { + return 'request_timeout' + } + if (error.code === 'invalid_runtime_response') { + return 'invalid_response' + } + } + return 'unknown' +} + +// Why: every branch names the endpoint and states only what was observed. The +// sandbox/session wording is a list of possibilities, not a verdict — the CLI +// cannot see why the OS hid the endpoint, and must not claim it can. +function describe( + code: CliRuntimeUnreachableCode, + ctx: { endpoint: string; noun: string; timeoutMs: number } +): string { + const { endpoint, noun, timeoutMs } = ctx + switch (code) { + case 'endpoint_missing': + return `Orca published the ${noun} ${endpoint}, but it does not exist for this process. Either the runtime shut its endpoint down, or this process cannot see it — a sandbox, container, or different user session each hide it this way. Run the CLI as the same user and outside any sandbox, or restart Orca.` + case 'endpoint_permission_denied': + return `The ${noun} ${endpoint} exists but this process is not permitted to open it. Run the CLI as the same user that runs Orca, outside any sandbox that restricts ${noun} access.` + case 'connection_refused': + return `The ${noun} ${endpoint} refused the connection. Orca's runtime is most likely shutting down; restart Orca.` + case 'connection_closed': + return `Connected to ${endpoint}, but the runtime closed the connection before replying. It may be shutting down or at its connection limit; restart Orca.` + case 'request_timeout': + return `Connected to ${endpoint}, but the runtime did not reply within ${timeoutMs}ms. It is reachable and not answering, so it is busy or wedged rather than starting.` + case 'request_rejected': + return `The runtime at ${endpoint} answered but refused the status request.` + case 'invalid_response': + return `The runtime at ${endpoint} returned a response this CLI could not read. The CLI and the Orca app are most likely different versions; reinstall the CLI from this Orca build.` + case 'unknown': + return `Could not talk to the Orca runtime at ${endpoint}. The Orca app process is running, so this is not a start still in progress.` + } +} diff --git a/src/cli/runtime/local-runtime-unreachable-status.test.ts b/src/cli/runtime/local-runtime-unreachable-status.test.ts new file mode 100644 index 00000000000..0d2f219250d --- /dev/null +++ b/src/cli/runtime/local-runtime-unreachable-status.test.ts @@ -0,0 +1,185 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { createServer, type Server, type Socket } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { getRuntimeMetadataPath } from '../../shared/runtime-bootstrap' +import { formatCliStatus } from '../format' +import { RuntimeClient } from './client' + +const servers = new Set() +const sockets = new Set() + +afterEach(async () => { + for (const socket of sockets) { + socket.destroy() + } + sockets.clear() + await Promise.all( + [...servers].map((server) => new Promise((resolve) => server.close(() => resolve()))) + ) + servers.clear() +}) + +function seedMetadata(endpoint: string, pid: number): string { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-unreachable-')) + writeFileSync( + getRuntimeMetadataPath(userDataPath), + JSON.stringify({ + runtimeId: 'runtime-under-test', + pid, + transports: [{ kind: 'unix', endpoint }], + authToken: 'token', + startedAt: Date.now() + }) + ) + return userDataPath +} + +async function listen(endpoint: string, onConnection: (socket: Socket) => void): Promise { + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + onConnection(socket) + }) + servers.add(server) + await new Promise((resolve) => server.listen(endpoint, resolve)) +} + +// Why: Windows publishes named pipes, which these Unix-socket fixtures cannot stand in for. +// The classification itself is platform-independent and covered by the reason unit tests. +describe.skipIf(process.platform === 'win32')('local runtime reachability status', () => { + // STA-3969: the reported incident. The Orca app process is alive and metadata + // names an endpoint, but the CLI cannot open it — reported for weeks as + // `starting`, which told the user to keep waiting for a start that had already + // happened. The runtime writes this metadata only after its transport is + // listening, so `starting` is never a truthful reading here. + it('names the missing endpoint instead of claiming the runtime is still starting', async () => { + const userDataPath = seedMetadata( + join(mkdtempSync(join(tmpdir(), 'orca-ep-')), 'gone.sock'), + process.pid + ) + + const status = await new RuntimeClient(userDataPath).getCliStatus() + + expect(status.result.app).toMatchObject({ running: true, pid: process.pid }) + expect(status.result.runtime.state).toBe('unreachable') + expect(status.result.runtime.reachable).toBe(false) + expect(status.result.graph.state).toBe('unreachable') + expect(status.result.runtime.unreachableReason).toMatchObject({ + code: 'endpoint_missing', + osErrorCode: 'ENOENT' + }) + expect(status.result.runtime.unreachableReason?.message).toContain('gone.sock') + }) + + it('reports a runtime that closes the connection before replying', async () => { + const endpoint = join(mkdtempSync(join(tmpdir(), 'orca-ep-')), 'closes.sock') + await listen(endpoint, (socket) => socket.destroy()) + const userDataPath = seedMetadata(endpoint, process.pid) + + const status = await new RuntimeClient(userDataPath).getCliStatus() + + expect(status.result.runtime.state).toBe('unreachable') + expect(status.result.runtime.unreachableReason?.code).toBe('connection_closed') + }) + + // Why: `socket.destroy()` above surfaces as an ECONNRESET on the client's error + // handler. A clean FIN with no reply takes the separate 'close' path, which has + // no errno at all — it is classified by transport phase, so it needs its own case. + it('reports a runtime that half-closes cleanly without replying', async () => { + const endpoint = join(mkdtempSync(join(tmpdir(), 'orca-ep-')), 'fin.sock') + await listen(endpoint, (socket) => { + socket.once('data', () => socket.end()) + }) + const userDataPath = seedMetadata(endpoint, process.pid) + + const status = await new RuntimeClient(userDataPath).getCliStatus() + + expect(status.result.runtime.state).toBe('unreachable') + expect(status.result.runtime.unreachableReason).toMatchObject({ code: 'connection_closed' }) + expect(status.result.runtime.unreachableReason?.osErrorCode).toBeUndefined() + }) + + it('reports a runtime that accepts the connection but never answers', async () => { + const endpoint = join(mkdtempSync(join(tmpdir(), 'orca-ep-')), 'silent.sock') + await listen(endpoint, () => {}) + const userDataPath = seedMetadata(endpoint, process.pid) + + const status = await new RuntimeClient(userDataPath).getCliStatus() + + expect(status.result.runtime.state).toBe('unreachable') + expect(status.result.runtime.unreachableReason?.code).toBe('request_timeout') + expect(status.result.runtime.unreachableReason?.message).toContain('rather than starting') + }) + + it('separates a runtime that answers and refuses from one it cannot reach', async () => { + const endpoint = join(mkdtempSync(join(tmpdir(), 'orca-ep-')), 'refuses.sock') + await listen(endpoint, (socket) => { + socket.once('data', (data) => { + const request = JSON.parse(String(data).trim()) as { id: string } + socket.write( + `${JSON.stringify({ + id: request.id, + ok: false, + error: { code: 'unauthorized', message: 'Auth token is not valid for this runtime.' }, + _meta: { runtimeId: 'runtime-under-test' } + })}\n` + ) + }) + }) + const userDataPath = seedMetadata(endpoint, process.pid) + + const status = await new RuntimeClient(userDataPath).getCliStatus() + + expect(status.result.runtime.unreachableReason?.code).toBe('request_rejected') + expect(status.result.runtime.unreachableReason?.message).toContain( + 'Auth token is not valid for this runtime.' + ) + }) + + // Preservation: a dead pid is still a stale bootstrap, not an unreachable runtime. + it('still reports a stale bootstrap when the recorded process is gone', async () => { + const userDataPath = seedMetadata( + join(mkdtempSync(join(tmpdir(), 'orca-ep-')), 'dead.sock'), + 2 ** 30 + ) + + const status = await new RuntimeClient(userDataPath).getCliStatus() + + expect(status.result.runtime.state).toBe('stale_bootstrap') + expect(status.result.runtime.unreachableReason).toBeUndefined() + expect(status.result.app).toMatchObject({ running: false, pid: null }) + }) + + // Why: the default `orca status` output is plain text, so the cause has to reach + // the non-JSON reader too — that is the surface the report was read from. + it('prints the cause in the plain-text status output', async () => { + const userDataPath = seedMetadata( + join(mkdtempSync(join(tmpdir(), 'orca-ep-')), 'gone.sock'), + process.pid + ) + + const text = formatCliStatus((await new RuntimeClient(userDataPath).getCliStatus()).result) + + expect(text).toContain('runtimeState: unreachable') + expect(text).toContain('unreachableCode: endpoint_missing') + expect(text).toContain('unreachableDetail: ') + expect(text).toContain('gone.sock') + }) + + // Constraint: nothing may report a runtime as usable when it is not. + it('never reports a reachable or ready runtime while the endpoint is unusable', async () => { + const userDataPath = seedMetadata( + join(mkdtempSync(join(tmpdir(), 'orca-ep-')), 'gone.sock'), + process.pid + ) + + const status = await new RuntimeClient(userDataPath).getCliStatus() + + expect(status.result.runtime.reachable).toBe(false) + expect(status.result.runtime.state).not.toBe('ready') + expect(status.result.runtime.runtimeId).toBeNull() + expect(status._meta?.runtimeId).toBe('none') + }) +}) diff --git a/src/cli/runtime/status.ts b/src/cli/runtime/status.ts index e4a181a8d51..cbaf9e9c5b2 100644 --- a/src/cli/runtime/status.ts +++ b/src/cli/runtime/status.ts @@ -7,6 +7,9 @@ import { resolveDesktopWindowStatus } from '../../shared/cli-app-status-projection' import { RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types' +import { classifyLocalRuntimeUnreachable } from './local-runtime-unreachable-reason' + +const STATUS_REQUEST_TIMEOUT_MS = 1000 export { projectRemoteAppStatus, resolveDesktopWindowStatus } @@ -36,7 +39,12 @@ export async function getCliStatus( } try { - const response = await sendRequest(metadata, 'status.get', undefined, 1000) + const response = await sendRequest( + metadata, + 'status.get', + undefined, + STATUS_REQUEST_TIMEOUT_MS + ) if (response.ok === false) { throw new RuntimeRpcFailureError(response) } @@ -63,21 +71,31 @@ export async function getCliStatus( state: graphState } }) - } catch { + } catch (error) { const running = isProcessRunning(metadata.pid) + if (!running) { + return buildCliStatusResponse({ + app: { running: false, pid: null }, + runtime: { state: 'stale_bootstrap', reachable: false, runtimeId: null }, + graph: { state: 'not_running' } + }) + } + // Why: STA-3969 — metadata naming this endpoint is only written after the + // runtime's transport is listening, so a live app plus a failed request is a + // reachability failure, never a start in progress. Report the cause. return buildCliStatusResponse({ - app: { - running, - pid: running ? metadata.pid : null - }, + app: { running: true, pid: metadata.pid }, runtime: { - state: running ? 'starting' : 'stale_bootstrap', + state: 'unreachable', reachable: false, - runtimeId: null + runtimeId: null, + unreachableReason: classifyLocalRuntimeUnreachable( + error, + transport, + STATUS_REQUEST_TIMEOUT_MS + ) }, - graph: { - state: running ? 'starting' : 'not_running' - } + graph: { state: 'unreachable' } }) } } diff --git a/src/cli/runtime/transport.ts b/src/cli/runtime/transport.ts index 091ca9da0e0..82a43793f8d 100644 --- a/src/cli/runtime/transport.ts +++ b/src/cli/runtime/transport.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto' import { findTransport, type RuntimeMetadata } from '../../shared/runtime-bootstrap' import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope' import { isKeepaliveFrame, RuntimeRpcEnvelopeSchema } from './envelope-schema' -import { RuntimeClientError, type RuntimeRpcResponse } from './types' +import { RuntimeClientError, RuntimeTransportError, type RuntimeRpcResponse } from './types' import { MAX_TIMER_DELAY_MS, isSafeTimerDelayMs } from '../../shared/timer-delay' export async function sendRequest( @@ -66,12 +66,17 @@ export async function sendRequest( } socket.setEncoding('utf8') - socket.once('error', () => { + // Why: STA-3969 — the errno is the whole diagnosis (ENOENT = endpoint gone, + // EACCES = visible but barred), so keep it instead of collapsing every + // connect failure into one message the caller cannot act on. + socket.once('error', (error: NodeJS.ErrnoException) => { finish({ ok: false, - error: new RuntimeClientError( + error: new RuntimeTransportError( 'runtime_unavailable', - 'Could not connect to the running Orca app. Restart Orca and try again.' + 'Could not connect to the running Orca app. Restart Orca and try again.', + 'connect', + error.code ?? null ) }) }) @@ -82,9 +87,10 @@ export async function sendRequest( socket.once('close', () => { finish({ ok: false, - error: new RuntimeClientError( + error: new RuntimeTransportError( 'runtime_unavailable', - 'The Orca runtime closed the connection before responding. Restart Orca and try again.' + 'The Orca runtime closed the connection before responding. Restart Orca and try again.', + 'peer_closed' ) }) }) diff --git a/src/cli/runtime/types.ts b/src/cli/runtime/types.ts index a15515acfdb..c38b1d5bd4f 100644 --- a/src/cli/runtime/types.ts +++ b/src/cli/runtime/types.ts @@ -20,6 +20,28 @@ export class RuntimeClientError extends Error { } } +// Why: STA-3969 — both of these failures share the `runtime_unavailable` code, and +// the errno is the only thing separating "the endpoint is gone" (ENOENT) from "this +// process may not open it" (EACCES). Carry the phase and the errno so callers can +// classify instead of guessing. osErrorCode stays null when the OS reported none. +export type RuntimeTransportPhase = 'connect' | 'peer_closed' + +export class RuntimeTransportError extends RuntimeClientError { + readonly phase: RuntimeTransportPhase + readonly osErrorCode: string | null + + constructor( + code: string, + message: string, + phase: RuntimeTransportPhase, + osErrorCode: string | null = null + ) { + super(code, message) + this.phase = phase + this.osErrorCode = osErrorCode + } +} + export class RuntimeRpcFailureError extends RuntimeClientError { readonly response: RuntimeRpcFailure diff --git a/src/shared/runtime-client-export-parity.test.ts b/src/shared/runtime-client-export-parity.test.ts index 2b86d2e6e99..bd9db429b34 100644 --- a/src/shared/runtime-client-export-parity.test.ts +++ b/src/shared/runtime-client-export-parity.test.ts @@ -68,6 +68,8 @@ type RuntimeTypeInventory = [ Runtime.BrowserViewportResult, Runtime.BrowserWaitResult, Runtime.CliRuntimeState, + Runtime.CliRuntimeUnreachableCode, + Runtime.CliRuntimeUnreachableReason, Runtime.CliStatusResult, Runtime.ComputerActionMetadata, Runtime.ComputerActionResult, diff --git a/src/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts index 1249870c548..add5e2967df 100644 --- a/src/shared/runtime-session-contracts.ts +++ b/src/shared/runtime-session-contracts.ts @@ -100,6 +100,28 @@ export type CliRuntimeState = | 'ready' | 'graph_not_ready' | 'stale_bootstrap' + // Why: STA-3969 — the CLI reached a definite failure talking to a published + // endpoint. Per docs/reference/ssh-execution-boundary.md a socket that failed + // is not an observation of the runtime, so it may not be reported as 'starting'. + | 'unreachable' + +export type CliRuntimeUnreachableCode = + | 'endpoint_missing' + | 'endpoint_permission_denied' + | 'connection_refused' + | 'connection_closed' + | 'request_timeout' + | 'request_rejected' + | 'invalid_response' + | 'unknown' + +export type CliRuntimeUnreachableReason = { + code: CliRuntimeUnreachableCode + message: string + endpoint: string + endpointKind: 'unix' | 'named-pipe' + osErrorCode?: string +} export type CliStatusResult = { target?: { kind: 'local' } | { kind: 'environment'; environment: string } @@ -116,9 +138,12 @@ export type CliStatusResult = { remoteUpdateSupport?: RemoteServerUpdateSupport capabilities?: RuntimeCapability[] degradations?: RuntimeDegradation[] + // Why: present exactly when state is 'unreachable' — the cause is the whole + // point of the state, and without it the caller is back to guessing. + unreachableReason?: CliRuntimeUnreachableReason } graph: { - state: RuntimeGraphStatus | 'not_running' | 'starting' + state: RuntimeGraphStatus | 'not_running' | 'starting' | 'unreachable' } } diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 856268217aa..b16d8fd1120 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -109,6 +109,8 @@ export type { } from './runtime-capability-degradation' export type { CliRuntimeState, + CliRuntimeUnreachableCode, + CliRuntimeUnreachableReason, CliStatusResult, DeviceScope, RuntimeBrowserDriverState, From 70dc4e28fd4930fb26197b9fd0cc62fbf8fafba0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 29 Aug 2026 04:10:00 -0700 Subject: [PATCH 2/5] fix(cli): preserve truthful runtime failure diagnostics --- src/cli/runtime-client.test.ts | 18 +++++++++++++++++- src/cli/runtime/client.ts | 2 +- .../local-runtime-unreachable-reason.test.ts | 7 +++++++ .../local-runtime-unreachable-reason.ts | 2 +- 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/cli/runtime-client.test.ts b/src/cli/runtime-client.test.ts index 71c46ad4dfe..d3d6917b54d 100644 --- a/src/cli/runtime-client.test.ts +++ b/src/cli/runtime-client.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createServer, type Socket } from 'node:net' @@ -332,6 +332,22 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { }) }) + it('openOrca keeps the last unreachable reason when later polls have no metadata', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + writeMetadata(userDataPath, join(userDataPath, 'never-listened.sock'), 'token', process.pid) + vi.mocked(launchOrcaApp).mockImplementationOnce(() => { + rmSync(join(userDataPath, 'orca-runtime.json')) + }) + + const client = new RuntimeClient(userDataPath, 100) + + await expect(client.openOrca(100)).rejects.toMatchObject({ + code: 'runtime_open_timeout', + message: expect.stringContaining('never-listened.sock'), + data: { unreachableReason: { code: 'endpoint_missing' } } + }) + }) + it('openOrca waits for a reachable headless runtime to expose a desktop window', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) const endpoint = join(userDataPath, 'runtime.sock') diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index de3051bd5ea..92afd58c35e 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -272,7 +272,7 @@ export class RuntimeClient { if (status.result.app.desktopWindowStatus === 'available') { return status } - lastReason = status.result.runtime.unreachableReason + lastReason = status.result.runtime.unreachableReason ?? lastReason await delay(250) } diff --git a/src/cli/runtime/local-runtime-unreachable-reason.test.ts b/src/cli/runtime/local-runtime-unreachable-reason.test.ts index 0f091c77ee3..83fcc2cd9b1 100644 --- a/src/cli/runtime/local-runtime-unreachable-reason.test.ts +++ b/src/cli/runtime/local-runtime-unreachable-reason.test.ts @@ -92,6 +92,13 @@ describe('classifyLocalRuntimeUnreachable', () => { ) }) + it('does not claim a permission-denied endpoint exists', () => { + const reason = classifyLocalRuntimeUnreachable(connectError('EACCES'), PIPE, 1000) + + expect(reason.message).not.toMatch(/\bexists\b/i) + expect(reason.message).toContain('OS denied') + }) + // The reported Windows 10 incident: the app process is alive, the pipe is not // openable from this process. The guidance must name the isolation possibilities // rather than telling the user to keep waiting. diff --git a/src/cli/runtime/local-runtime-unreachable-reason.ts b/src/cli/runtime/local-runtime-unreachable-reason.ts index 4ac231efc9a..d5204d61e6b 100644 --- a/src/cli/runtime/local-runtime-unreachable-reason.ts +++ b/src/cli/runtime/local-runtime-unreachable-reason.ts @@ -83,7 +83,7 @@ function describe( case 'endpoint_missing': return `Orca published the ${noun} ${endpoint}, but it does not exist for this process. Either the runtime shut its endpoint down, or this process cannot see it — a sandbox, container, or different user session each hide it this way. Run the CLI as the same user and outside any sandbox, or restart Orca.` case 'endpoint_permission_denied': - return `The ${noun} ${endpoint} exists but this process is not permitted to open it. Run the CLI as the same user that runs Orca, outside any sandbox that restricts ${noun} access.` + return `The OS denied this process access to the ${noun} ${endpoint}. Run the CLI as the same user that runs Orca, outside any sandbox that restricts ${noun} access.` case 'connection_refused': return `The ${noun} ${endpoint} refused the connection. Orca's runtime is most likely shutting down; restart Orca.` case 'connection_closed': From a90ce02e08b2fca4aaaa37b8be0dfd6e6d352033 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 29 Aug 2026 04:56:36 -0700 Subject: [PATCH 3/5] fix(cli): stop reporting a recovered runtime as unreachable The open-window poll carried the earlier reason forward with `?? lastReason`, so a runtime that came back mid-wait still timed out with "its runtime is unreachable" and a reason naming a socket it no longer uses -- a stale negative reported as the current diagnosis. Clear the reason whenever the status says reachable. That leaves two genuinely different timeouts, so give them different words: one never got an answer; the other answered the whole time and opened no window, where "may still be running headlessly" understates what the poll already proved. --- src/cli/runtime-client.test.ts | 50 ++++++++++++++++++++++++++++++++++ src/cli/runtime/client.ts | 21 +++++++++++--- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/cli/runtime-client.test.ts b/src/cli/runtime-client.test.ts index d3d6917b54d..ecb5030a73b 100644 --- a/src/cli/runtime-client.test.ts +++ b/src/cli/runtime-client.test.ts @@ -348,6 +348,56 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { }) }) + // STA-3969: the poll carried the earlier reason forward with `?? lastReason`, so a runtime + // that RECOVERED mid-wait was still reported unreachable at the timeout -- a stale negative + // presented as the current diagnosis. + it('openOrca stops reporting a runtime unreachable once it answers again', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const endpoint = join(userDataPath, 'runtime.sock') + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + socket.once('data', (data) => { + const request = JSON.parse(String(data).trim()) as { id: string } + socket.write( + `${JSON.stringify({ + id: request.id, + ok: true, + result: { + runtimeId: 'runtime-1', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: 0, + desktopWindowStatus: 'initializing', + liveTabCount: 0, + liveLeafCount: 0 + }, + _meta: { runtimeId: 'runtime-1' } + })}\n` + ) + }) + }) + servers.add(server) + await new Promise((resolve) => server.listen(endpoint, resolve)) + // Starts pointed at an endpoint nothing serves, then recovers onto the live one. + writeMetadata(userDataPath, join(userDataPath, 'never-listened.sock'), 'token', process.pid) + vi.mocked(launchOrcaApp).mockImplementationOnce(() => { + writeMetadata(userDataPath, endpoint, 'token', process.pid) + }) + + const client = new RuntimeClient(userDataPath, 100) + + const failure = await client.openOrca(1_000).then( + () => null, + (error: unknown) => error as { code: string; message: string; data?: unknown } + ) + expect(failure?.code).toBe('runtime_open_timeout') + expect(failure?.message).not.toContain('unreachable') + expect(failure?.data).toBeUndefined() + // The second timeout case: answers all the way through, just no window. + expect(failure?.message).toContain('is responding and still running headlessly') + }) + it('openOrca waits for a reachable headless runtime to expose a desktop window', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) const endpoint = join(userDataPath, 'runtime.sock') diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index 92afd58c35e..a3043139bb3 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -264,6 +264,7 @@ export class RuntimeClient { const startedAt = Date.now() let lastReason = initial.result.runtime.unreachableReason + let runtimeAnswered = initial.result.runtime.reachable while (Date.now() - startedAt < timeoutMs) { const status = await this.getCliStatus() if (status.result.app.desktopWindowStatus === 'blocked') { @@ -272,18 +273,30 @@ export class RuntimeClient { if (status.result.app.desktopWindowStatus === 'available') { return status } - lastReason = status.result.runtime.unreachableReason ?? lastReason + // Why (STA-3969): a runtime that answered is reachable NOW, so carrying the earlier + // reason forward would report a stale negative -- naming an endpoint it no longer + // uses -- as the current diagnosis. + runtimeAnswered = status.result.runtime.reachable + lastReason = runtimeAnswered + ? undefined + : (status.result.runtime.unreachableReason ?? lastReason) await delay(250) } // Why: STA-3969 — this loop polls getCliStatus, so when the runtime is // unreachable it burns the whole timeout and then reported only that it timed // out. Carry the reachability failure the poll already diagnosed. + // Why: the two timeouts are different failures. One never got an answer from the + // runtime; the other got answers the whole time and no window with them -- telling that + // user the runtime "may" be running headlessly understates what the poll already proved. + const timeoutDetail = lastReason + ? `: the Orca app process is running but its runtime is unreachable. ${lastReason.message}` + : runtimeAnswered + ? '. The Orca runtime is responding and still running headlessly; it did not open a window in time.' + : '. The runtime may still be running headlessly.' throw new RuntimeClientError( 'runtime_open_timeout', - lastReason - ? `Timed out waiting for an Orca desktop window: the Orca app process is running but its runtime is unreachable. ${lastReason.message}` - : 'Timed out waiting for an Orca desktop window. The runtime may still be running headlessly.', + `Timed out waiting for an Orca desktop window${timeoutDetail}`, lastReason ? { unreachableReason: lastReason } : undefined ) } From 833d7f8fb93e24be5460db7f2eb21ca2763edf48 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 29 Aug 2026 05:22:29 -0700 Subject: [PATCH 4/5] fix(cli): report the runtime failure the newest poll actually observed The open-window poll accumulated an unreachable reason across iterations, so the reason outlived the poll that observed it and was still printed as the current diagnosis after the status stopped supporting it. A runtime whose process exited mid-wait was therefore reported as "the Orca app process is running but its runtime is unreachable", naming an endpoint that was gone. The reason is now read off the newest status; an earlier one survives only as history, tense marked, and a poll that answered clears it outright. A rejected status request is also no longer described as unreachable, since it is an answer. --- src/cli/runtime-client.test.ts | 85 +++++++++++++++++-- src/cli/runtime/client.ts | 41 +++++---- .../runtime/runtime-open-timeout-reason.ts | 32 +++++++ 3 files changed, 132 insertions(+), 26 deletions(-) create mode 100644 src/cli/runtime/runtime-open-timeout-reason.ts diff --git a/src/cli/runtime-client.test.ts b/src/cli/runtime-client.test.ts index ecb5030a73b..73aad83b6a7 100644 --- a/src/cli/runtime-client.test.ts +++ b/src/cli/runtime-client.test.ts @@ -332,7 +332,10 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { }) }) - it('openOrca keeps the last unreachable reason when later polls have no metadata', async () => { + // STA-3969: the diagnosis is still worth surfacing once metadata disappears, but the newest + // poll no longer observes it -- so it is reported as the last failure seen, not as the live + // one. Presenting it under `unreachableReason` would be the same stale negative in machine form. + it('reports the last unreachable reason as history when later polls have no metadata', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) writeMetadata(userDataPath, join(userDataPath, 'never-listened.sock'), 'token', process.pid) vi.mocked(launchOrcaApp).mockImplementationOnce(() => { @@ -341,11 +344,83 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { const client = new RuntimeClient(userDataPath, 100) - await expect(client.openOrca(100)).rejects.toMatchObject({ - code: 'runtime_open_timeout', - message: expect.stringContaining('never-listened.sock'), - data: { unreachableReason: { code: 'endpoint_missing' } } + const failure = await client.openOrca(100).then( + () => null, + (error: unknown) => error as { code: string; message: string; data?: unknown } + ) + expect(failure?.code).toBe('runtime_open_timeout') + expect(failure?.message).toContain('never-listened.sock') + expect(failure?.message).toContain('The last failure it reported was') + expect(failure?.message).not.toContain('the Orca app process is running') + const data = failure?.data as { + unreachableReason?: unknown + lastObservedUnreachableReason?: { code?: string } + } + expect(data.unreachableReason).toBeUndefined() + expect(data.lastObservedUnreachableReason?.code).toBe('endpoint_missing') + }) + + // STA-3969: same stale negative one state further along -- the runtime was unreachable, then + // its PROCESS exited. The newest poll reports a dead process and no reason, so quoting the old + // endpoint failure claims a running process the latest status denies. + it('openOrca stops claiming the process is running once it exits mid-wait', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const endpoint = join(userDataPath, 'never-listened.sock') + writeMetadata(userDataPath, endpoint, 'token', process.pid) + vi.mocked(launchOrcaApp).mockImplementationOnce(() => { + writeMetadata(userDataPath, endpoint, 'token', findUnusedPid()) }) + + const client = new RuntimeClient(userDataPath, 100) + + const failure = await client.openOrca(100).then( + () => null, + (error: unknown) => error as { code: string; message: string; data?: unknown } + ) + expect(failure?.code).toBe('runtime_open_timeout') + expect(failure?.message).not.toContain('the Orca app process is running') + expect(failure?.message).toContain('no longer running') + expect( + (failure?.data as { unreachableReason?: unknown } | undefined)?.unreachableReason + ).toBeUndefined() + }) + + // STA-3969: `request_rejected` means the runtime ANSWERED and refused. Calling that + // "unreachable" contradicts the very reason being quoted alongside it. + it('openOrca says the runtime refused the request instead of calling it unreachable', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const endpoint = join(userDataPath, 'runtime.sock') + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + socket.on('data', (data) => { + const request = JSON.parse(String(data).trim()) as { id: string } + socket.write( + `${JSON.stringify({ + id: request.id, + ok: false, + error: { code: 'status_refused', message: 'status.get is disabled' } + })}\n` + ) + }) + }) + servers.add(server) + await new Promise((resolve) => server.listen(endpoint, resolve)) + writeMetadata(userDataPath, endpoint, 'token', process.pid) + + const client = new RuntimeClient(userDataPath, 100) + + const failure = await client.openOrca(100).then( + () => null, + (error: unknown) => error as { code: string; message: string; data?: unknown } + ) + expect(failure?.code).toBe('runtime_open_timeout') + expect( + (failure?.data as { unreachableReason?: { code?: string } } | undefined)?.unreachableReason + ?.code + ).toBe('request_rejected') + expect(failure?.message).toContain('refused the status request') + expect(failure?.message).not.toContain('its runtime is unreachable') }) // STA-3969: the poll carried the earlier reason forward with `?? lastReason`, so a runtime diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index a3043139bb3..28bd6e08d82 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -9,6 +9,7 @@ import { parsePairingCode, type PairingOffer } from '../../shared/pairing' import { launchOrcaApp } from './launch' import { getDefaultUserDataPath, readMetadata } from './metadata' import { getCliStatus, projectRemoteAppStatus } from './status' +import { describeOpenTimeout } from './runtime-open-timeout-reason' import { sendRequest } from './transport' import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types' import { attachMutationRecovery } from './client-error-recovery' @@ -263,8 +264,13 @@ export class RuntimeClient { } const startedAt = Date.now() - let lastReason = initial.result.runtime.unreachableReason - let runtimeAnswered = initial.result.runtime.reachable + // Why (STA-3969): the timeout must describe the NEWEST observation. A reason accumulated + // across polls outlives the poll that saw it and then gets reported as the current + // diagnosis -- first when the runtime recovered, and again when its process exited. So the + // loop keeps the latest status and the reason is read off that; the earlier one survives + // only as history, never as the live verdict. + let latest = initial.result + let lastObservedReason = initial.result.runtime.unreachableReason while (Date.now() - startedAt < timeoutMs) { const status = await this.getCliStatus() if (status.result.app.desktopWindowStatus === 'blocked') { @@ -273,31 +279,24 @@ export class RuntimeClient { if (status.result.app.desktopWindowStatus === 'available') { return status } - // Why (STA-3969): a runtime that answered is reachable NOW, so carrying the earlier - // reason forward would report a stale negative -- naming an endpoint it no longer - // uses -- as the current diagnosis. - runtimeAnswered = status.result.runtime.reachable - lastReason = runtimeAnswered + latest = status.result + // A poll that ANSWERED resolves the earlier failure outright, so it is not even history + // any more; only an unresolved failure we can no longer diagnose is worth carrying. + lastObservedReason = latest.runtime.reachable ? undefined - : (status.result.runtime.unreachableReason ?? lastReason) + : (latest.runtime.unreachableReason ?? lastObservedReason) await delay(250) } - // Why: STA-3969 — this loop polls getCliStatus, so when the runtime is - // unreachable it burns the whole timeout and then reported only that it timed - // out. Carry the reachability failure the poll already diagnosed. - // Why: the two timeouts are different failures. One never got an answer from the - // runtime; the other got answers the whole time and no window with them -- telling that - // user the runtime "may" be running headlessly understates what the poll already proved. - const timeoutDetail = lastReason - ? `: the Orca app process is running but its runtime is unreachable. ${lastReason.message}` - : runtimeAnswered - ? '. The Orca runtime is responding and still running headlessly; it did not open a window in time.' - : '. The runtime may still be running headlessly.' + const currentReason = latest.runtime.unreachableReason throw new RuntimeClientError( 'runtime_open_timeout', - `Timed out waiting for an Orca desktop window${timeoutDetail}`, - lastReason ? { unreachableReason: lastReason } : undefined + `Timed out waiting for an Orca desktop window${describeOpenTimeout(latest, lastObservedReason)}`, + currentReason + ? { unreachableReason: currentReason } + : lastObservedReason + ? { lastObservedUnreachableReason: lastObservedReason } + : undefined ) } } diff --git a/src/cli/runtime/runtime-open-timeout-reason.ts b/src/cli/runtime/runtime-open-timeout-reason.ts new file mode 100644 index 00000000000..29ceee4b8bc --- /dev/null +++ b/src/cli/runtime/runtime-open-timeout-reason.ts @@ -0,0 +1,32 @@ +import type { CliRuntimeUnreachableReason, CliStatusResult } from '../../shared/runtime-types' + +// Why (STA-3969): the rule this enforces is that every clause names the observation it came +// from. `unreachableReason` is produced in exactly one place -- the branch that checked the pid +// and found the process alive -- so its presence is what licenses saying the process is running, +// and its absence forbids it. A reason from an earlier poll is reported as history, tense marked, +// never folded into the live verdict. +export function describeOpenTimeout( + latest: CliStatusResult, + lastObservedReason: CliRuntimeUnreachableReason | undefined +): string { + const current = latest.runtime.unreachableReason + if (current) { + // A rejected request is an answer, so it cannot also be described as unreachable. + return current.code === 'request_rejected' + ? `: the Orca runtime answered but refused the status request. ${current.message}` + : `: the Orca app process is running but its runtime is unreachable. ${current.message}` + } + if (latest.runtime.reachable) { + return '. The Orca runtime is responding and still running headlessly; it did not open a window in time.' + } + if (latest.app.running) { + return '. The runtime may still be running headlessly.' + } + const state = + latest.runtime.state === 'stale_bootstrap' + ? '. The Orca app process is no longer running.' + : '. No Orca runtime is running.' + return lastObservedReason + ? `${state} The last failure it reported was: ${lastObservedReason.message}` + : state +} From 6ee5333248fa384c1fc4310ce192caa57ddd8fb1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 29 Aug 2026 06:06:36 -0700 Subject: [PATCH 5/5] test(cli): pin every open-timeout clause to the status it describes The open-timeout message is assembled from five mutually exclusive arms, but its only coverage ran through the poll loop, which can reach just the states today's local producer happens to emit. Deleting the arm that keeps a live app process from being reported as gone reddened nothing, so the rule the module exists to enforce was unpinned. Assert each arm directly against the status it describes, including the two the client harness cannot construct. --- .../runtime-open-timeout-reason.test.ts | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 src/cli/runtime/runtime-open-timeout-reason.test.ts diff --git a/src/cli/runtime/runtime-open-timeout-reason.test.ts b/src/cli/runtime/runtime-open-timeout-reason.test.ts new file mode 100644 index 00000000000..c31d485f414 --- /dev/null +++ b/src/cli/runtime/runtime-open-timeout-reason.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { describeOpenTimeout } from './runtime-open-timeout-reason' +import type { CliRuntimeUnreachableReason, CliStatusResult } from '../../shared/runtime-types' + +// Why: the poll loop can only feed this the states today's local producer happens to emit, so +// driving it through the client leaves whole arms unpinned. Each arm is a promise about what +// the message may claim, so each one is asserted against the status it describes (STA-3969). +function status(overrides: { + appRunning: boolean + reachable: boolean + state: CliStatusResult['runtime']['state'] + unreachableReason?: CliRuntimeUnreachableReason +}): CliStatusResult { + return { + app: { running: overrides.appRunning, pid: overrides.appRunning ? 4242 : null }, + runtime: { + state: overrides.state, + reachable: overrides.reachable, + runtimeId: overrides.reachable ? 'runtime-1' : null, + ...(overrides.unreachableReason ? { unreachableReason: overrides.unreachableReason } : {}) + }, + graph: { state: overrides.reachable ? 'ready' : 'unreachable' } + } +} + +const history: CliRuntimeUnreachableReason = { + code: 'endpoint_missing', + message: 'Orca published the socket /tmp/never-listened.sock, but it does not exist.', + endpoint: '/tmp/never-listened.sock', + endpointKind: 'unix' +} + +describe('describeOpenTimeout', () => { + it('reports the current reason as the live diagnosis', () => { + const text = describeOpenTimeout( + status({ + appRunning: true, + reachable: false, + state: 'unreachable', + unreachableReason: history + }), + undefined + ) + expect(text).toContain('the Orca app process is running but its runtime is unreachable') + expect(text).toContain(history.message) + }) + + it('calls a rejected request an answer, never unreachable', () => { + const text = describeOpenTimeout( + status({ + appRunning: true, + reachable: false, + state: 'unreachable', + unreachableReason: { ...history, code: 'request_rejected', message: 'refused: bad token' } + }), + undefined + ) + expect(text).toContain('answered but refused the status request') + expect(text).not.toContain('unreachable') + }) + + it('says the runtime answered when the newest poll was reachable', () => { + const text = describeOpenTimeout( + status({ appRunning: true, reachable: true, state: 'graph_not_ready' }), + history + ) + expect(text).toContain('is responding and still running headlessly') + // A poll that answered resolves the earlier failure, so it is not even history any more. + expect(text).not.toContain(history.message) + }) + + // Why: this is the arm the client's own producer cannot currently reach, and dropping it makes + // the message claim no process is running while the pid is alive — the exact false report + // STA-3969 exists to stop. + it('never claims the process is gone while the newest status says it is running', () => { + const text = describeOpenTimeout( + status({ appRunning: true, reachable: false, state: 'graph_not_ready' }), + undefined + ) + expect(text).toBe('. The runtime may still be running headlessly.') + expect(text).not.toContain('no longer running') + expect(text).not.toContain('No Orca runtime is running') + }) + + it('reports a dead app process without inheriting the old live verdict', () => { + const text = describeOpenTimeout( + status({ appRunning: false, reachable: false, state: 'stale_bootstrap' }), + history + ) + expect(text).toContain('The Orca app process is no longer running.') + expect(text).toContain('The last failure it reported was:') + expect(text).not.toContain('but its runtime is unreachable') + }) + + it('says no runtime is running when none was ever published, with no history to add', () => { + const text = describeOpenTimeout( + status({ appRunning: false, reachable: false, state: 'not_running' }), + undefined + ) + expect(text).toBe('. No Orca runtime is running.') + }) +})