mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 08:02:43 +00:00
fix(agent-launch): report a launch that failed before spawning as failed, with its cause (#22913)
* fix(agent-launch): settle a launch that failed before spawning as failed, with its cause A launch into an existing workspace whose terminal create threw before the spawn request left this process (agent disabled, no launch command, runtime unavailable) created nothing, yet agent.launchReplay recorded and answered it as agent_session_operation_unknown. createTerminal now reports when it hands the spawn to the pty controller; a failure before that point settles the ledger row as failed and returns the original error. After the request leaves, the outcome stays unknown: an SSH or daemon spawn whose reply was lost may still have started. * test(agent-launch): expect the spawn-dispatch hook on the launch's terminal create * test(agent-launch): drive the pre-spawn failure with a missing launch command A disabled agent is moving to a check made before either launch route runs, so the tests use a failure that stays inside the terminal build. * fix(agent-launch): keep the not-started verdict on the launch, not the shared error A failed pane spawn rejects the same error object into the spawner (after its request left) and into a concurrent create waiting on that pane (before its own). Marking the error object globally let the waiting launch's verdict clear the spawner's, recording a launch that may have started an agent as failed. The launch now owns its dispatch tracker and carries the decision on its execution error instead of re-deriving it from the error. Also names the test's launch parameter type for the anti-slop audit. * refactor(agent-launch): move launch failure classification into its own module Rebasing onto the caller-selection change took agent-launch.ts past its line limit; the failure-code helpers are a self-contained concern.
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* A launch whose terminal failed before its spawn was requested: nothing was created, so the
|
||||
* launch can settle as failed with its real cause instead of an unknown outcome.
|
||||
*
|
||||
* The original error is remembered rather than wrapped, so the caller still receives the host's own
|
||||
* code and message. After the request leaves this process a failure proves nothing — an SSH or
|
||||
* daemon spawn whose reply was lost may still have started — so only this earlier point counts.
|
||||
*
|
||||
* The verdict belongs to the launch, not to the error object: a failed pane spawn rejects the same
|
||||
* error into the spawner (after its request left) and into a concurrent create waiting on that pane
|
||||
* (before its own request left), so a mark on the error itself would clear both.
|
||||
*/
|
||||
export type TerminalSpawnDispatch = {
|
||||
onPtySpawnDispatched: () => void
|
||||
/** Rethrows the create's failure, remembering it when the spawn request had not yet left. */
|
||||
rethrow: (error: unknown) => never
|
||||
/** True only for the error this launch's create threw before its spawn request left. */
|
||||
failedBeforeDispatch: (error: unknown) => boolean
|
||||
}
|
||||
|
||||
export function trackTerminalSpawnDispatch(): TerminalSpawnDispatch {
|
||||
let dispatched = false
|
||||
let notStarted: { error: unknown } | undefined
|
||||
return {
|
||||
onPtySpawnDispatched: () => {
|
||||
dispatched = true
|
||||
},
|
||||
rethrow: (error) => {
|
||||
if (!dispatched) {
|
||||
notStarted = { error }
|
||||
}
|
||||
throw error
|
||||
},
|
||||
failedBeforeDispatch: (error) =>
|
||||
!dispatched && notStarted !== undefined && notStarted.error === error
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
|
||||
}
|
||||
let result: Awaited<ReturnType<NonNullable<dependencies.RuntimePtyController['spawn']>>>
|
||||
try {
|
||||
launchOpts.onPtySpawnDispatched?.()
|
||||
result = await this.ptyController.spawn({
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
@@ -292,6 +293,8 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
|
||||
releaseStablePaneCreate()
|
||||
}
|
||||
}
|
||||
// The renderer owns this spawn, so this process cannot see when it is requested.
|
||||
opts.onPtySpawnDispatched?.()
|
||||
return createDesktopTerminal(this, worktreeSelector, opts, presentation, rendererWindow)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec'
|
||||
import { TEST_WORKTREE_PATH, store } from '../orca-runtime-test-fixtures.spec'
|
||||
|
||||
// `agent.launch` settles a launch as failed only when its create threw before this hook ran.
|
||||
describe('OrcaRuntimeService createTerminal spawn dispatch', () => {
|
||||
it('reports the spawn request before it leaves for the pty controller', async () => {
|
||||
const dispatched = vi.fn()
|
||||
const spawn = vi.fn(async () => {
|
||||
expect(dispatched).toHaveBeenCalledOnce()
|
||||
return { id: 'pty-dispatch' }
|
||||
})
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
|
||||
command: 'codex',
|
||||
onPtySpawnDispatched: dispatched
|
||||
})
|
||||
|
||||
expect(spawn).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reports it even when the spawn itself then fails', async () => {
|
||||
const dispatched = vi.fn()
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn(async () => {
|
||||
throw new Error('ssh_channel_closed')
|
||||
}),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
|
||||
command: 'codex',
|
||||
onPtySpawnDispatched: dispatched
|
||||
})
|
||||
).rejects.toThrow('ssh_channel_closed')
|
||||
expect(dispatched).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not report it when the create fails before any spawn request', async () => {
|
||||
const dispatched = vi.fn()
|
||||
const spawn = vi.fn()
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.createTerminal('path:/no/such/workspace', {
|
||||
command: 'codex',
|
||||
onPtySpawnDispatched: dispatched
|
||||
})
|
||||
).rejects.toThrow()
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
expect(dispatched).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -51,6 +51,7 @@ await import('./orca-runtime-tests/terminal-creation-and-readiness-part-09.spec'
|
||||
await import('./orca-runtime-tests/terminal-creation-and-readiness-part-10.spec')
|
||||
await import('./orca-runtime-tests/terminal-creation-and-readiness-part-11.spec')
|
||||
await import('./orca-runtime-tests/terminal-creation-and-readiness-part-12.spec')
|
||||
await import('./orca-runtime-tests/terminal-spawn-dispatch.spec')
|
||||
await import('./orca-runtime-tests/terminal-output-and-worker-recovery.spec')
|
||||
await import('./orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec')
|
||||
await import('./orca-runtime-tests/terminal-output-and-worker-recovery-part-03.spec')
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { AgentLaunchTarget } from '../../../../shared/agent-launch-intent'
|
||||
import {
|
||||
WorktreeCreateCollisionError,
|
||||
WORKTREE_CREATE_COLLISION_CODE
|
||||
} from '../../../../shared/new-workspace/worktree-create-collision'
|
||||
import {
|
||||
AgentLaunchPaneAlreadyLiveError,
|
||||
AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE
|
||||
} from '../../../../shared/agent-launch-pane-already-live'
|
||||
import {
|
||||
AgentLaunchSessionAlreadyExistsError,
|
||||
AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE
|
||||
} from '../../../../shared/agent-launch-session-already-exists'
|
||||
import type { TerminalSpawnDispatch } from '../../../agent-launch/agent-launch-not-started'
|
||||
|
||||
/** Long enough for every code this path raises, with room for one a later guard adds. */
|
||||
const LAUNCH_FAILURE_CODE_MAX_LENGTH = 128
|
||||
|
||||
/**
|
||||
* This path raises its refusals as the thrown code, the way the method's own guards do — and the
|
||||
* recorded code is what a replay answers with, so it is worth keeping.
|
||||
*
|
||||
* Bounded because a code is an identifier but `error.message` is free text: an errno sentence
|
||||
* carrying an absolute path arrives here as one, and it would be written into a ledger file that is
|
||||
* re-serialized whole on every subsequent operation. Bounded on the way IN only. A length check in
|
||||
* `isAgentSessionOperationRow` would reject rows this same build wrote, and one rejected row costs
|
||||
* the entire store.
|
||||
*/
|
||||
export function agentLaunchFailureCode(error: unknown): string {
|
||||
const code = error instanceof Error ? error.message : ''
|
||||
return code.length > 0 ? code.slice(0, LAUNCH_FAILURE_CODE_MAX_LENGTH) : 'agent_launch_failed'
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a typed refusal raised before anything was created proves the claimed launch had no effects.
|
||||
* A live reserved pane or an existing reserved session proves it only for an existing workspace; on
|
||||
* create-worktree the workspace already exists by the time the surface is refused.
|
||||
*/
|
||||
export function launchFailureWithoutEffectsCode(
|
||||
error: unknown,
|
||||
targetKind: AgentLaunchTarget['kind'],
|
||||
terminalSpawn: TerminalSpawnDispatch
|
||||
): string | null {
|
||||
if (error instanceof WorktreeCreateCollisionError) {
|
||||
return WORKTREE_CREATE_COLLISION_CODE
|
||||
}
|
||||
if (error instanceof AgentLaunchPaneAlreadyLiveError && targetKind === 'existing') {
|
||||
return AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE
|
||||
}
|
||||
if (error instanceof AgentLaunchSessionAlreadyExistsError && targetKind === 'existing') {
|
||||
return AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE
|
||||
}
|
||||
if (terminalSpawn.failedBeforeDispatch(error) && targetKind === 'existing') {
|
||||
return agentLaunchFailureCode(error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -64,7 +64,7 @@ describe('agent.launch with the real floating workspace resolver', () => {
|
||||
expect(structuredHost).not.toHaveBeenCalled()
|
||||
expect(createTerminal).toHaveBeenCalledExactlyOnceWith(
|
||||
`id:${FLOATING_TERMINAL_WORKTREE_ID}`,
|
||||
{ startupAgent: 'claude' }
|
||||
{ startupAgent: 'claude', onPtySpawnDispatched: expect.any(Function) }
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* A terminal launch that fails before its spawn is requested — no launch command, runtime
|
||||
* unavailable — created nothing, so a named operation settles as failed with its real cause.
|
||||
* Once the request has left, a failure proves nothing and the outcome stays unknown.
|
||||
*/
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AgentSessionRecordStore } from '../../agent-session-record-store'
|
||||
import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import { methodNamed, runtimeStub, type AgentLaunchRuntimeStub } from './agent-launch.test-fixture'
|
||||
|
||||
vi.mock('./structured-agent-session-create', () => ({
|
||||
createStructuredAgentSessionForWorktree: async () => ({
|
||||
ok: true,
|
||||
value: { sessionId: 'sess-1' }
|
||||
})
|
||||
}))
|
||||
|
||||
const { AGENT_LAUNCH_METHODS } = await import('./agent-launch')
|
||||
const AGENT_LAUNCH_REPLAY = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launchReplay')
|
||||
|
||||
const EXISTING_LAUNCH = { agent: 'claude', target: { kind: 'existing', worktree: 'id:wt-7' } }
|
||||
const CREATE_LAUNCH = {
|
||||
agent: 'claude',
|
||||
target: { kind: 'create-worktree', create: { repo: 'id:repo-1', name: 'task' } }
|
||||
}
|
||||
const NO_LAUNCH_COMMAND = 'Could not build launch command for claude.'
|
||||
type Launch = typeof EXISTING_LAUNCH | typeof CREATE_LAUNCH
|
||||
|
||||
/** The create throws; `afterDispatch` says whether the spawn request had already left. */
|
||||
function failingCreate(runtime: AgentLaunchRuntimeStub, error: Error, afterDispatch: boolean) {
|
||||
runtime.createTerminal.mockImplementation(
|
||||
async (_selector: string, options?: Record<string, unknown>) => {
|
||||
const dispatched = options?.onPtySpawnDispatched
|
||||
if (afterDispatch && typeof dispatched === 'function') {
|
||||
dispatched()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
describe('a launch whose terminal fails', () => {
|
||||
// The ledger admits against `Date.now()`, so the ids must be dated now.
|
||||
const OPERATION_ID = `${Date.now()}-000000000000000000000000000000cc`
|
||||
const OTHER_OPERATION_ID = `${Date.now()}-000000000000000000000000000000dd`
|
||||
let directory: string
|
||||
let store: AgentSessionRecordStore
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-prestart-'))
|
||||
store = await AgentSessionRecordStore.open({ directory, hostId: 'local' })
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `deps.store` is the only member `agent.launch` reads, and a member it omits throws on call.
|
||||
setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
setStructuredAgentSessionHost(null)
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function outcomeOf(operationId: string) {
|
||||
return store.listOperationRows().find((row) => row.operationId === operationId)?.outcome
|
||||
}
|
||||
|
||||
async function replay(
|
||||
runtime: AgentLaunchRuntimeStub,
|
||||
launch: Launch,
|
||||
operationId: string = OPERATION_ID
|
||||
) {
|
||||
const dispatcher = new RpcDispatcher({
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture implements every runtime method reached by agent.launch and dispatcher metadata.
|
||||
runtime: { ...runtime, getRuntimeId: () => 'runtime-1' } as unknown as OrcaRuntimeService,
|
||||
methods: AGENT_LAUNCH_METHODS
|
||||
})
|
||||
return dispatcher.dispatch({
|
||||
id: 'request-1',
|
||||
authToken: 'token',
|
||||
method: 'agent.launchReplay',
|
||||
params: AGENT_LAUNCH_REPLAY.params.parse({ ...launch, operationId })
|
||||
})
|
||||
}
|
||||
|
||||
it('reports a failure before the spawn request with its real cause and records it', async () => {
|
||||
const runtime = runtimeStub({ settings: {} })
|
||||
failingCreate(runtime, new Error(NO_LAUNCH_COMMAND), false)
|
||||
|
||||
const response = await replay(runtime, EXISTING_LAUNCH)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { message: NO_LAUNCH_COMMAND } })
|
||||
expect(outcomeOf(OPERATION_ID)).toMatchObject({ status: 'failed', code: NO_LAUNCH_COMMAND })
|
||||
})
|
||||
|
||||
it('keeps a stable runtime code such as runtime_unavailable', async () => {
|
||||
const runtime = runtimeStub({ settings: {} })
|
||||
failingCreate(runtime, new Error('runtime_unavailable'), false)
|
||||
|
||||
const response = await replay(runtime, EXISTING_LAUNCH)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'runtime_unavailable' } })
|
||||
})
|
||||
|
||||
it('answers a retry of the same operation from the record instead of launching again', async () => {
|
||||
const first = runtimeStub({ settings: {} })
|
||||
failingCreate(first, new Error(NO_LAUNCH_COMMAND), false)
|
||||
await replay(first, EXISTING_LAUNCH)
|
||||
|
||||
const retry = runtimeStub({ settings: {} })
|
||||
const response = await replay(retry, EXISTING_LAUNCH)
|
||||
|
||||
expect(retry.createTerminal).not.toHaveBeenCalled()
|
||||
expect(response).toMatchObject({ ok: false, error: { message: NO_LAUNCH_COMMAND } })
|
||||
})
|
||||
|
||||
it('stays unknown when the failure came after the spawn request left', async () => {
|
||||
// An SSH or daemon spawn whose reply was lost may still have started an agent.
|
||||
const runtime = runtimeStub({ settings: {} })
|
||||
failingCreate(runtime, new Error('ssh_channel_closed'), true)
|
||||
|
||||
const response = await replay(runtime, EXISTING_LAUNCH)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'agent_session_operation_unknown' }
|
||||
})
|
||||
expect(outcomeOf(OPERATION_ID)?.status).toBe('unknown')
|
||||
})
|
||||
|
||||
it('stays unknown when another launch saw the same error before its own spawn request', async () => {
|
||||
// A failed pane spawn rejects one error into the spawner and into a create waiting on that pane.
|
||||
const shared = new Error('ssh_channel_closed')
|
||||
const waiting = runtimeStub({ settings: {} })
|
||||
failingCreate(waiting, shared, false)
|
||||
await replay(waiting, EXISTING_LAUNCH, OTHER_OPERATION_ID)
|
||||
expect(outcomeOf(OTHER_OPERATION_ID)?.status).toBe('failed')
|
||||
|
||||
const spawner = runtimeStub({ settings: {} })
|
||||
failingCreate(spawner, shared, true)
|
||||
const response = await replay(spawner, EXISTING_LAUNCH)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'agent_session_operation_unknown' }
|
||||
})
|
||||
expect(outcomeOf(OPERATION_ID)?.status).toBe('unknown')
|
||||
})
|
||||
|
||||
it('stays unknown for a launch that created its workspace first', async () => {
|
||||
const runtime = runtimeStub({ settings: {} })
|
||||
// No startup terminal came back, so the launch builds its own in the new workspace.
|
||||
runtime.createManagedWorktree.mockResolvedValueOnce({
|
||||
worktree: { id: 'wt-new' },
|
||||
startupTerminal: undefined
|
||||
})
|
||||
failingCreate(runtime, new Error(NO_LAUNCH_COMMAND), false)
|
||||
|
||||
const response = await replay(runtime, CREATE_LAUNCH)
|
||||
|
||||
expect(runtime.createTerminal).toHaveBeenCalledTimes(1)
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'agent_session_operation_unknown' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -36,6 +36,10 @@ import { AgentLaunchSessionAlreadyExistsError } from '../../../../shared/agent-l
|
||||
import { createStructuredAgentSessionId } from '../../../../shared/structured-agent-session-create'
|
||||
import { toAgentLaunchPreferences } from '../../../../shared/agent-launch-preferences'
|
||||
import { paneIdentity } from '../../runtime-terminal-pane-identity'
|
||||
import {
|
||||
trackTerminalSpawnDispatch,
|
||||
type TerminalSpawnDispatch
|
||||
} from '../../../agent-launch/agent-launch-not-started'
|
||||
|
||||
/** Replay-safe launches keep the nested attach in the same stable caller namespace as the launch. */
|
||||
export function agentLaunchSurfaceFactory(
|
||||
@@ -43,7 +47,8 @@ export function agentLaunchSurfaceFactory(
|
||||
attachOperationId?: string,
|
||||
operationCallerKey?: string,
|
||||
// False when the launch selects the chat for its paired caller instead of for everyone.
|
||||
activateChat = true
|
||||
activateChat = true,
|
||||
terminalSpawn: TerminalSpawnDispatch = trackTerminalSpawnDispatch()
|
||||
): AgentLaunchSurfaceFactory {
|
||||
return {
|
||||
createStructuredSession: async ({
|
||||
@@ -120,7 +125,7 @@ export function agentLaunchSurfaceFactory(
|
||||
options
|
||||
}) => {
|
||||
const launchPreferences = toAgentLaunchPreferences(options)
|
||||
const terminal = await context.runtime.createTerminal(`id:${worktreeId}`, {
|
||||
const created = context.runtime.createTerminal(`id:${worktreeId}`, {
|
||||
// The agent id is not a shell command — `cursor` is the desktop app, its CLI is
|
||||
// `cursor-agent` — so the runtime builds the configured launcher.
|
||||
startupAgent: agent,
|
||||
@@ -133,8 +138,10 @@ export function agentLaunchSurfaceFactory(
|
||||
...(launchPreferences ? { launchPreferences } : {}),
|
||||
// A live reserved pane would be attached, not launched into, so the runtime refuses it.
|
||||
...(paneKey ? { ...paneIdentity(paneKey), requireFreshPane: true } : {}),
|
||||
...agentLaunchTelemetry(agent, launchSource)
|
||||
...agentLaunchTelemetry(agent, launchSource),
|
||||
onPtySpawnDispatched: terminalSpawn.onPtySpawnDispatched
|
||||
})
|
||||
const terminal = await created.catch(terminalSpawn.rethrow)
|
||||
return {
|
||||
handle: terminal.handle,
|
||||
// The runtime already minted this pane and baked it into the PTY's env and its own reveal;
|
||||
|
||||
@@ -461,7 +461,10 @@ describe('the terminal factory', () => {
|
||||
const runtime = runtimeStub({ createSupport: { supported: false, reason: 'wsl' } })
|
||||
const result = await launch(CREATE_LAUNCH, runtime)
|
||||
|
||||
expect(runtime.createTerminal).toHaveBeenCalledWith('id:wt-new', { startupAgent: 'claude' })
|
||||
expect(runtime.createTerminal).toHaveBeenCalledWith('id:wt-new', {
|
||||
startupAgent: 'claude',
|
||||
onPtySpawnDispatched: expect.any(Function)
|
||||
})
|
||||
expect(createStructuredSession).not.toHaveBeenCalled()
|
||||
expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_1' })
|
||||
// Never a failed launch, and never a silent downgrade.
|
||||
@@ -481,7 +484,10 @@ describe('the terminal factory', () => {
|
||||
expect(runtime.showManagedTerminalWorkspace).not.toHaveBeenCalled()
|
||||
// Resolved to an id first: everything below re-prefixes it, so a raw selector reaches the
|
||||
// runtime as `id:id:wt-7`.
|
||||
expect(runtime.createTerminal).toHaveBeenCalledWith('id:wt-7', { startupAgent: 'grok' })
|
||||
expect(runtime.createTerminal).toHaveBeenCalledWith('id:wt-7', {
|
||||
startupAgent: 'grok',
|
||||
onPtySpawnDispatched: expect.any(Function)
|
||||
})
|
||||
expect(result.worktreeId).toBe('wt-7')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,20 +31,20 @@ import {
|
||||
WorktreeCreateCollisionError,
|
||||
WORKTREE_CREATE_COLLISION_CODE
|
||||
} from '../../../../shared/new-workspace/worktree-create-collision'
|
||||
import {
|
||||
AgentLaunchPaneAlreadyLiveError,
|
||||
AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE
|
||||
} from '../../../../shared/agent-launch-pane-already-live'
|
||||
import {
|
||||
AgentLaunchSessionAlreadyExistsError,
|
||||
AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE
|
||||
} from '../../../../shared/agent-launch-session-already-exists'
|
||||
import { executeAgentLaunch } from '../../../agent-launch/agent-launch-executor'
|
||||
import {
|
||||
trackTerminalSpawnDispatch,
|
||||
type TerminalSpawnDispatch
|
||||
} from '../../../agent-launch/agent-launch-not-started'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { defineMethod, type RpcContext } from '../core'
|
||||
import { admitAgentLaunchOperation, agentLaunchOperationCallerKey } from './agent-launch-replay'
|
||||
import { AgentLaunch, AgentLaunchReplay, type AgentLaunchParams } from './agent-launch-schemas'
|
||||
import { agentLaunchSurfaceFactory } from './agent-launch-surfaces'
|
||||
import {
|
||||
agentLaunchFailureCode,
|
||||
launchFailureWithoutEffectsCode
|
||||
} from './agent-launch-failure-code'
|
||||
import {
|
||||
agentLaunchCallerNavigationId,
|
||||
selectAgentLaunchTabForCaller
|
||||
@@ -146,7 +146,8 @@ async function runAgentLaunch(
|
||||
intent: AgentLaunchIntent,
|
||||
context: RpcContext,
|
||||
attachOperationId?: string,
|
||||
operationCallerKey?: string
|
||||
operationCallerKey?: string,
|
||||
terminalSpawn?: TerminalSpawnDispatch
|
||||
): Promise<AgentLaunchResult> {
|
||||
const callerNavigationId = agentLaunchCallerNavigationId(intent.target, context)
|
||||
const result = await executeAgentLaunch({
|
||||
@@ -156,7 +157,8 @@ async function runAgentLaunch(
|
||||
context,
|
||||
attachOperationId,
|
||||
operationCallerKey,
|
||||
callerNavigationId === null
|
||||
callerNavigationId === null,
|
||||
terminalSpawn
|
||||
),
|
||||
workspaces: agentLaunchWorkspaceFactory(context, intent.agent)
|
||||
})
|
||||
@@ -198,52 +200,17 @@ function settleQuietly(settlement: Promise<void>): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
/** Long enough for every code this path raises, with room for one a later guard adds. */
|
||||
const LAUNCH_FAILURE_CODE_MAX_LENGTH = 128
|
||||
|
||||
/**
|
||||
* This path raises its refusals as the thrown code, the way the method's own guards do — and the
|
||||
* recorded code is what a replay answers with, so it is worth keeping.
|
||||
*
|
||||
* Bounded because a code is an identifier but `error.message` is free text: an errno sentence
|
||||
* carrying an absolute path arrives here as one, and it would be written into a ledger file that is
|
||||
* re-serialized whole on every subsequent operation. Bounded on the way IN only. A length check in
|
||||
* `isAgentSessionOperationRow` would reject rows this same build wrote, and one rejected row costs
|
||||
* the entire store.
|
||||
*/
|
||||
function agentLaunchFailureCode(error: unknown): string {
|
||||
const code = error instanceof Error ? error.message : ''
|
||||
return code.length > 0 ? code.slice(0, LAUNCH_FAILURE_CODE_MAX_LENGTH) : 'agent_launch_failed'
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a typed refusal raised before anything was created proves the claimed launch had no effects.
|
||||
* A live reserved pane or an existing reserved session proves it only for an existing workspace; on
|
||||
* create-worktree the workspace already exists by the time the surface is refused.
|
||||
*/
|
||||
function launchFailureWithoutEffectsCode(
|
||||
error: unknown,
|
||||
targetKind: AgentLaunchTarget['kind']
|
||||
): string | null {
|
||||
if (error instanceof WorktreeCreateCollisionError) {
|
||||
return WORKTREE_CREATE_COLLISION_CODE
|
||||
}
|
||||
if (error instanceof AgentLaunchPaneAlreadyLiveError && targetKind === 'existing') {
|
||||
return AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE
|
||||
}
|
||||
if (error instanceof AgentLaunchSessionAlreadyExistsError && targetKind === 'existing') {
|
||||
return AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
type ActiveAgentLaunch = {
|
||||
fingerprint: string
|
||||
promise: Promise<AgentLaunchResult>
|
||||
}
|
||||
|
||||
class AgentLaunchExecutionError extends Error {
|
||||
constructor(cause: unknown) {
|
||||
constructor(
|
||||
cause: unknown,
|
||||
/** Decided once, by the launch that ran; a later reader cannot re-derive it from the error. */
|
||||
readonly failedWithoutEffects: boolean
|
||||
) {
|
||||
super('agent_session_operation_unknown', { cause })
|
||||
}
|
||||
}
|
||||
@@ -282,15 +249,26 @@ async function executeReplaySafeAgentLaunch(
|
||||
await settleQuietly(admission.fail(agentLaunchFailureCode(error)))
|
||||
throw error
|
||||
}
|
||||
const terminalSpawn = trackTerminalSpawnDispatch()
|
||||
let result: AgentLaunchResult
|
||||
try {
|
||||
result = await runAgentLaunch(intent, context, admission.attachOperationId, admission.callerKey)
|
||||
result = await runAgentLaunch(
|
||||
intent,
|
||||
context,
|
||||
admission.attachOperationId,
|
||||
admission.callerKey,
|
||||
terminalSpawn
|
||||
)
|
||||
} catch (error) {
|
||||
const failedWithoutEffects = launchFailureWithoutEffectsCode(error, intent.target.kind)
|
||||
const failedWithoutEffects = launchFailureWithoutEffectsCode(
|
||||
error,
|
||||
intent.target.kind,
|
||||
terminalSpawn
|
||||
)
|
||||
if (failedWithoutEffects) {
|
||||
await settleQuietly(admission.fail(failedWithoutEffects))
|
||||
}
|
||||
throw new AgentLaunchExecutionError(error)
|
||||
throw new AgentLaunchExecutionError(error, failedWithoutEffects !== null)
|
||||
}
|
||||
// Settlement is bookkeeping; failure leaves the truthful `unknown` refusal for later retries.
|
||||
await settleQuietly(admission.settle(result))
|
||||
@@ -341,7 +319,7 @@ export const AGENT_LAUNCH_METHODS = [
|
||||
code: WORKTREE_CREATE_COLLISION_CODE
|
||||
})
|
||||
}
|
||||
if (launchFailureWithoutEffectsCode(error.cause, params.target.kind)) {
|
||||
if (error.failedWithoutEffects) {
|
||||
throw error.cause
|
||||
}
|
||||
throw new Error('agent_session_operation_unknown', { cause: error.cause })
|
||||
|
||||
@@ -79,6 +79,8 @@ export type TerminalCreateOptions = {
|
||||
agentSessionCreateOperationId?: string
|
||||
signal?: AbortSignal
|
||||
onPtySpawnCommitted?: () => void
|
||||
/** Called before the spawn request leaves this process; a throw before it proves nothing spawned. */
|
||||
onPtySpawnDispatched?: () => void
|
||||
deferMobileSessionPublish?: boolean
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user