diff --git a/mobile/src/tasks/agent-launch-mobile-replay.test.ts b/mobile/src/tasks/agent-launch-mobile-replay.test.ts index 515cf353f3b4..51d790b85dd5 100644 --- a/mobile/src/tasks/agent-launch-mobile-replay.test.ts +++ b/mobile/src/tasks/agent-launch-mobile-replay.test.ts @@ -10,13 +10,14 @@ import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, AGENT_SESSION_OPERATION_FUTURE_SKEW_MS } from '../../../src/shared/agent-session-host-authority' -import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' -import type { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' import type { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store-test-harness' import type { OrcaRuntimeService } from '../../../src/main/runtime/orca-runtime' import { RpcDispatcher } from '../../../src/main/runtime/rpc/dispatcher' -import { runtimeStub } from '../../../src/main/runtime/rpc/methods/agent-launch.test-fixture' +import { + runtimeStub, + setAgentLaunchRecordStore +} from '../../../src/main/runtime/rpc/methods/agent-launch.test-fixture' import { AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY, AGENT_LAUNCH_RUNTIME_CAPABILITY @@ -40,13 +41,12 @@ beforeEach(async () => { createStructuredSession.mockResolvedValue({ ok: true, value: { sessionId: 'session-1' } }) directory = await mkdtemp(join(tmpdir(), 'orca-mobile-launch-replay-')) store = await openTestAgentSessionRecordStore(directory) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch reads only deps.store; structured session creation is the injected boundary above. - setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) + setAgentLaunchRecordStore(store) }) afterEach(async () => { vi.restoreAllMocks() - setStructuredAgentSessionHost(null) + setAgentLaunchRecordStore(null) await rm(directory, { recursive: true, force: true }) }) diff --git a/mobile/src/tasks/mobile-agent-launch-architecture.test.ts b/mobile/src/tasks/mobile-agent-launch-architecture.test.ts index 60f09c83a184..ff595a652bb7 100644 --- a/mobile/src/tasks/mobile-agent-launch-architecture.test.ts +++ b/mobile/src/tasks/mobile-agent-launch-architecture.test.ts @@ -9,9 +9,10 @@ import { DeviceRegistry } from '../../../src/main/runtime/device-registry' import type { AuthenticatedMobileSocket } from '../../../src/main/runtime/rpc/mobile-socket-wiring' import { RpcDispatcher } from '../../../src/main/runtime/rpc/dispatcher' import { AgentLaunchFields } from '../../../src/main/runtime/rpc/methods/agent-launch-schemas' -import { runtimeStub } from '../../../src/main/runtime/rpc/methods/agent-launch.test-fixture' -import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' -import type { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' +import { + runtimeStub, + setAgentLaunchRecordStore +} from '../../../src/main/runtime/rpc/methods/agent-launch.test-fixture' import type { OrcaRuntimeService } from '../../../src/main/runtime/orca-runtime' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { createStableLogicalRpcClient } from '../transport/stable-logical-rpc-client' @@ -39,12 +40,11 @@ beforeEach(async () => { createStructuredSession.mockResolvedValue({ ok: true, value: { sessionId: 'session-1' } }) directory = await mkdtemp(join(tmpdir(), 'orca-launch-architecture-')) store = await openTestAgentSessionRecordStore(directory) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch reads deps.store; session creation is injected above. - setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) + setAgentLaunchRecordStore(store) }) afterEach(async () => { - setStructuredAgentSessionHost(null) + setAgentLaunchRecordStore(null) await rm(directory, { recursive: true, force: true }) }) @@ -243,8 +243,7 @@ describe('mobile launch retry authority', () => { const first = await dispatcher.dispatch(request) expect(first.ok).toBe(true) store = await openTestAgentSessionRecordStore(directory) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: launch admission reads only deps.store from the installed host. - setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) + setAgentLaunchRecordStore(store) await expect( dispatcher.dispatch({ ...request, method: 'agent.launchReplay' }) ).resolves.toEqual(first) @@ -293,8 +292,7 @@ describe('mobile launch retry authority', () => { } }) store = await openTestAgentSessionRecordStore(directory) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: launch admission reads only deps.store from the installed host. - setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) + setAgentLaunchRecordStore(store) await expect(dispatcher.dispatch(request)).resolves.toMatchObject({ ok: false, error: { code: 'worktree_create_collision' } diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index 0e9c350c5ece..ce9b8af6b456 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -11,7 +11,7 @@ import { describe, expect, it, vi } from 'vitest' import { executeAgentLaunch, type AgentLaunchExecution } from './agent-launch-executor' import { AgentLaunchStructuredSessionRefusedError } from './agent-launch-surface-factories' -import type { AgentLaunchIntent } from '../../shared/agent-launch-intent' +import type { AgentLaunchIntent, AgentLaunchResult } from '../../shared/agent-launch-intent' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' const STRUCTURED_PREFERENCE = { @@ -29,6 +29,7 @@ function harness(options: { terminalPromptDelivered?: boolean /** Whether the surface reports that its typed line took the offered prompt. */ lineCarriesPrompt?: boolean + onSurfacePublished?: AgentLaunchExecution['onSurfacePublished'] }) { const calls: string[] = [] const carried = (startupPrompt: string | undefined) => @@ -96,7 +97,8 @@ function harness(options: { deliverStructuredPrompt, deliverTerminalPrompt }, - workspaces: { createWorktree } + workspaces: { createWorktree }, + ...(options.onSurfacePublished ? { onSurfacePublished: options.onSurfacePublished } : {}) }) } } @@ -607,3 +609,81 @@ describe('caller-supplied launch inputs', () => { expect(result.warning).toBeUndefined() }) }) + +describe('the surface is published as the launch stands, before its prompt is delivered', () => { + const PROMPTED_EXISTING: AgentLaunchIntent = { + agent: 'claude', + target: { kind: 'existing', worktree: 'wt-7' }, + prompt: { text: 'fix the build', delivery: 'submit' } + } + + function publishing(options: Parameters[0]) { + const published: AgentLaunchResult[] = [] + const launch = harness({ + ...options, + onSurfacePublished: (surface) => { + launch.calls.push('published') + published.push(surface) + } + }) + return { launch, published } + } + + it('records a prompt still owed as unconfirmed, then delivers it', async () => { + const { launch, published } = publishing({ settings: {}, lineCarriesPrompt: false }) + + const result = await launch.run(PROMPTED_EXISTING) + + expect(launch.calls).toEqual(['createTerminalAgent', 'published', 'deliverTerminalPrompt']) + expect(published).toEqual([ + { + outcome: { kind: 'terminal', handle: 'term_1' }, + worktreeId: 'wt-7', + receipt: result.receipt, + // A host that stops mid-paste cannot say whether it landed, so it must not say "not sent". + prompt: { delivery: 'submit', outcome: 'unconfirmed' } + } + ]) + expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' }) + }) + + it('records a draft as not delivered, since the host never delivers one', async () => { + const { launch, published } = publishing({ settings: {}, lineCarriesPrompt: false }) + + const result = await launch.run({ + ...PROMPTED_EXISTING, + prompt: { text: 'fix the build', delivery: 'draft' } + }) + + expect(published[0]?.prompt).toEqual({ delivery: 'draft', outcome: 'not-delivered' }) + expect(result.prompt).toEqual({ delivery: 'draft', outcome: 'not-delivered' }) + }) + + it('records a prompt the launch command carried as already handed over', async () => { + const { launch, published } = publishing({ settings: {}, lineCarriesPrompt: true }) + + const result = await launch.run(PROMPTED_EXISTING) + + expect(published[0]?.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' }) + expect(published[0]).toEqual(result) + expect(launch.deliverTerminalPrompt).not.toHaveBeenCalled() + }) + + it('records a chat before its first message is committed', async () => { + const { launch, published } = publishing({}) + + const result = await launch.run(PROMPTED_EXISTING) + + expect(launch.calls).toEqual([ + 'createSupport', + 'createStructuredSession', + 'published', + 'deliverStructuredPrompt' + ]) + expect(published[0]).toEqual({ + ...result, + prompt: { delivery: 'submit', outcome: 'unconfirmed' } + }) + expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'journaled', messageId: 'msg-1' }) + }) +}) diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 755bd55e2767..67cb117f9348 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -39,6 +39,7 @@ import { HANDED_TO_TERMINAL, launchCommandPrompt, promptReceipt, + settledAtCreation, settleLaunchPromptDisposal } from './agent-launch-prompt-delivery' import type { TuiAgent } from '../../shared/tui-agent' @@ -75,7 +76,13 @@ export type AgentLaunchExecution = { onSurfacePublished?: (surface: AgentLaunchPublishedSurface) => void } -export type AgentLaunchPublishedSurface = Pick +/** + * The launch as it stands once its surface exists: a complete result whose prompt receipt says only + * what creation itself settled — carried on the launch command, a draft the host never delivers, or + * a submit still `unconfirmed`. Complete so a host that dies during the delivery still leaves a + * truthful answer behind. + */ +export type AgentLaunchPublishedSurface = AgentLaunchResult export async function executeAgentLaunch( execution: AgentLaunchExecution @@ -105,11 +112,12 @@ export async function executeAgentLaunch( if (intent.reuseTerminal) { const reused = published(execution, { outcome: { kind: 'terminal', handle: intent.reuseTerminal.handle }, - worktreeId: existingWorktreeId(intent.target) + worktreeId: existingWorktreeId(intent.target), + receipt: preflight, + ...promptReceipt(intent, settledAtCreation(intent, {})) }) return { ...reused, - receipt: preflight, ...promptReceipt( intent, await deliverTerminalLaunchPrompt(execution, intent.reuseTerminal.handle, { @@ -128,12 +136,13 @@ export async function executeAgentLaunch( handle: placed.startupTerminalHandle, ...(placed.startupTerminalPaneKey ? { paneKey: placed.startupTerminalPaneKey } : {}) }, - worktreeId: placed.worktreeId + worktreeId: placed.worktreeId, + receipt: preflight, + ...(placed.warning ? { warning: placed.warning } : {}), + ...promptReceipt(intent, settledAtCreation(intent, placed)) }) return { ...startup, - receipt: preflight, - ...(placed.warning ? { warning: placed.warning } : {}), ...promptReceipt( intent, placed.promptRodeLaunchCommand @@ -183,11 +192,15 @@ export async function executeAgentLaunch( // not start while looking at it. Telling those apart needs `createManagedWorktree` to stop // multiplexing "couldn't copy untracked files" and "startup terminal failed" into one string. const warning = combineLaunchWarnings(placed.warning, created.warning) - const surface = published(execution, { outcome: created.outcome, worktreeId: placed.worktreeId }) - return { - ...surface, + const surface = published(execution, { + outcome: created.outcome, + worktreeId: placed.worktreeId, receipt: settled, ...(warning ? { warning } : {}), + ...promptReceipt(intent, settledAtCreation(intent, created)) + }) + return { + ...surface, ...promptReceipt(intent, await settleLaunchPromptDisposal(execution, created)) } } diff --git a/src/main/agent-launch/agent-launch-prompt-delivery.ts b/src/main/agent-launch/agent-launch-prompt-delivery.ts index b36519013761..a1e062b8cbce 100644 --- a/src/main/agent-launch/agent-launch-prompt-delivery.ts +++ b/src/main/agent-launch/agent-launch-prompt-delivery.ts @@ -9,6 +9,7 @@ * terminal, line fits -> folded into the command that execs the agent -> handed-to-terminal * terminal, otherwise -> bracketed paste into the live PTY once it is ready -> handed-to-terminal * anything unproven -> -> not-delivered + * host stopped mid-delivery (a replayed record only) -> unconfirmed * * argv has no readiness race, so it is offered wherever the agent's CLI takes a prompt argument * (`agentPromptRidesLaunchCommand`). But that command is TYPED into the user's shell, and a long or @@ -28,6 +29,23 @@ import type { AgentLaunchStructuredSurface } from './agent-launch-surface-factor export const HANDED_TO_TERMINAL: AgentLaunchPromptDisposal = { outcome: 'handed-to-terminal' } const NOT_DELIVERED: AgentLaunchPromptDisposal = { outcome: 'not-delivered' } +const UNCONFIRMED: AgentLaunchPromptDisposal = { outcome: 'unconfirmed' } + +/** + * What the record may say before any delivery runs: only what creating the surface already settled. + * A launch command that carried the text has handed it over, and a draft is never delivered by the + * host. A submit still owed is `unconfirmed`: a host that stops mid-delivery cannot say whether the + * paste or the commit landed, and "not delivered" would invite a duplicate turn. + */ +export function settledAtCreation( + intent: Pick, + created: { promptRodeLaunchCommand?: boolean } +): AgentLaunchPromptDisposal { + if (created.promptRodeLaunchCommand) { + return HANDED_TO_TERMINAL + } + return intent.prompt?.delivery === 'submit' ? UNCONFIRMED : NOT_DELIVERED +} /** Each surface delivers its own way, so the disposal is decided where the surface is known. */ export async function settleLaunchPromptDisposal( @@ -127,9 +145,9 @@ export function launchCommandPrompt( * Each arm is a consequence of the act it names, never a write-ahead of it: `journaled` is * reachable only from a committed message id, `handed-to-terminal` only from a launch command that * carried the text or a PTY write that returned, and everything else under-claims as - * `not-delivered`. There is deliberately no arm for "maybe" — a caller holding one could neither - * resend nor drop the text. Dispatch doubt is not this tier's to report: the submission row - * carries it. + * `not-delivered`. A live answer has no "maybe": `unconfirmed` is written only into the record + * before delivery runs (`settledAtCreation`), and is read back only by a replay. Dispatch doubt is + * not this tier's to report: the submission row carries it. */ export function promptReceipt( intent: AgentLaunchIntent, diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts index 7c9df1e6bc2a..3eedd3c80501 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts @@ -22,7 +22,10 @@ import { WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../shared/protocol-version' -import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../shared/agent-launch-runtime-capability' +import { + AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY, + AGENT_LAUNCH_RUNTIME_CAPABILITY +} from '../../shared/agent-launch-runtime-capability' import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import { remoteRuntimeClientCapabilities } from '../../shared/remote-runtime-client-capabilities' import { supportsAgentLaunch } from '../runtime/rpc/methods/agent-launch' @@ -63,6 +66,12 @@ const REMOTE_ONLY_BY_DECISION: readonly RuntimeCapability[] = [ SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY ] +/** Advertised to main and deliberately NOT to a remote host yet. */ +const LOCAL_ONLY_BY_DECISION: readonly RuntimeCapability[] = [ + // Read by the desktop's own launches first; a remote host is told when its launches move over. + AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY +] + function missingFrom( source: readonly RuntimeCapability[], other: readonly RuntimeCapability[] @@ -104,9 +113,9 @@ describe('desktop renderer runtime client capabilities', () => { expect(missingFrom(PAIRED_HOST_RECEIVES, DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES)).toEqual( [...REMOTE_ONLY_BY_DECISION].sort() ) - // The same renderer reads structured chats on either host, so it claims nothing only locally. + // The same renderer reads structured chats on either host, so only a recorded decision is local. expect(missingFrom(DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES, PAIRED_HOST_RECEIVES)).toEqual( - [] + [...LOCAL_ONLY_BY_DECISION].sort() ) }) }) diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index 64cda2148ed6..b7a34a12ac43 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -10,7 +10,10 @@ import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../shared/protocol-version' -import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../shared/agent-launch-runtime-capability' +import { + AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY, + AGENT_LAUNCH_RUNTIME_CAPABILITY +} from '../../shared/agent-launch-runtime-capability' import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from '../../shared/agent-session-background-task-child-views-capability' /** @@ -39,5 +42,8 @@ export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapab REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY, // Without this `supportsAgentLaunch` refuses the renderer outright, while the same renderer // targeting a remote host is admitted — the asymmetry this constant exists to close. - AGENT_LAUNCH_RUNTIME_CAPABILITY + AGENT_LAUNCH_RUNTIME_CAPABILITY, + // A replay after a restart mid-delivery answers the running agent with an `unconfirmed` prompt, + // which the desktop reads, rather than refusing it as unknown. + AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY ] as const diff --git a/src/main/ipc/runtime-agent-launch-capability.test.ts b/src/main/ipc/runtime-agent-launch-capability.test.ts index 6c85455a324d..504b1447ad04 100644 --- a/src/main/ipc/runtime-agent-launch-capability.test.ts +++ b/src/main/ipc/runtime-agent-launch-capability.test.ts @@ -8,10 +8,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { RuntimeCapability } from '../../shared/protocol-version' import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../shared/agent-launch-runtime-capability' +import { + resolveRpcCallerIdentity, + rpcCallerOperationKey, + type RpcCallerIdentity +} from '../runtime/rpc/rpc-caller-identity' type AdvertisedClient = { clientKind?: 'mobile' | 'runtime' clientCapabilities?: readonly RuntimeCapability[] + caller?: RpcCallerIdentity + pairedDeviceId?: string } const { handlers, advertised } = vi.hoisted( @@ -57,6 +64,7 @@ vi.mock('../runtime/rpc/dispatcher', () => ({ const { registerRuntimeHandlers } = await import('./runtime') const { supportsAgentLaunch } = await import('../runtime/rpc/methods/agent-launch') +const { agentLaunchOperationCallerKey } = await import('../runtime/rpc/methods/agent-launch-replay') function rendererEvent() { const mainFrame = {} @@ -126,4 +134,19 @@ describe('desktop renderer reaching agent.launch on its own main process', () => ) expect(supportsAgentLaunch(streaming)).toBe(true) }) + + it('names the desktop as its caller on both paths, so its launches replay under one identity', () => { + invoke('runtime:call', { method: 'status.get' }) + invoke('runtime:subscribe', { subscriptionId: 'sub-1', method: 'session.tabs.watch' }) + + for (const client of [advertised.unary, advertised.streaming].map(onlyAdvertisedClient)) { + const caller = resolveRpcCallerIdentity(client) + expect(caller).toEqual({ kind: 'desktop' }) + expect(agentLaunchOperationCallerKey({ caller })).toBe('trusted-local:desktop') + } + // Negative control: without the transport's word the same renderer has no identity at all. + const { caller: _named, ...unnamed } = onlyAdvertisedClient(advertised.unary) + expect(resolveRpcCallerIdentity(unnamed)).toBeUndefined() + expect(rpcCallerOperationKey({ kind: 'local-cli' })).toBe('trusted-local:runtime') + }) }) diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index e768de463d85..1736cbc6f60f 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -12,6 +12,7 @@ import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-br import { TERMINAL_FIT_RESTORE_DEADLINE_MS } from '../../shared/terminal-fit-restore-deadline' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from './desktop-renderer-runtime-capabilities' import { RpcDispatcher } from '../runtime/rpc/dispatcher' +import { DESKTOP_RPC_CALLER } from '../runtime/rpc/rpc-caller-identity' import { ALL_RPC_METHODS } from '../runtime/rpc/methods' import { DesktopRuntimeSenderLifecycle } from './desktop-runtime-sender-lifecycle' @@ -78,6 +79,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { }, { clientId: 'desktop-renderer', + caller: DESKTOP_RPC_CALLER, clientKind: 'runtime', connectionId: desktopSenders.connectionIdFor(event.sender), clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES @@ -120,6 +122,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { { signal: controller.signal, clientId: 'desktop-renderer', + caller: DESKTOP_RPC_CALLER, clientKind: 'runtime', connectionId, clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES diff --git a/src/main/runtime/agent-session-record-store-slot.test.ts b/src/main/runtime/agent-session-record-store-slot.test.ts new file mode 100644 index 000000000000..12be381a787f --- /dev/null +++ b/src/main/runtime/agent-session-record-store-slot.test.ts @@ -0,0 +1,99 @@ +/** + * The record store opened on its own, for launch admission, and the chat host built on it later. + * The store is a single writer, so the property that matters is that there is only ever one. + */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { openAgentSessionRecordStoreOnce } from './agent-session-record-store-slot' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const HOST_ID = 'local' +let stateDirectory: string + +function location(directory = stateDirectory) { + return { + stateDirectory: directory, + hostId: HOST_ID, + logger: createStructuredAgentSessionLogger() + } +} + +beforeEach(async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-record-store-slot-')) +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + vi.restoreAllMocks() + await rm(stateDirectory, { recursive: true, force: true }) +}) + +describe('the record store slot', () => { + it('hands every caller the same store', async () => { + const [first, second] = await Promise.all([ + openAgentSessionRecordStoreOnce(location()), + openAgentSessionRecordStoreOnce(location()) + ]) + + expect(second.store).toBe(first.store) + }) + + it('builds a chat host installed after admission on the store admission opened', async () => { + const { store } = await openAgentSessionRecordStoreOnce(location()) + const close = vi.spyOn(JournalHostDatabase.prototype, 'close') + + const host = await ensureStructuredAgentSessionHost({ + ...location(), + claimKeyId: 'key-1', + resolveWorkspacePath: async () => stateDirectory, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveEnvironment: async () => ({}) + }) + expect(host.deps.store).toBe(store) + + await stopStructuredAgentSessionRuntime() + // The host's teardown closes the one connection; stop does not close it a second time. + expect(close).toHaveBeenCalledOnce() + }) + + it('closes a store no chat host was built on when the runtime stops', async () => { + const { store } = await openAgentSessionRecordStoreOnce(location()) + const close = vi.spyOn(JournalHostDatabase.prototype, 'close') + + await stopStructuredAgentSessionRuntime() + + expect(close).toHaveBeenCalledOnce() + const reopened = await openAgentSessionRecordStoreOnce(location()) + expect(reopened.store).not.toBe(store) + }) + + it('refuses a second profile rather than opening a second store', async () => { + await openAgentSessionRecordStoreOnce(location()) + const elsewhere = await mkdtemp(join(tmpdir(), 'orca-record-store-slot-other-')) + try { + await expect(openAgentSessionRecordStoreOnce(location(elsewhere))).rejects.toThrow( + 'agent_session_record_store_location_changed' + ) + } finally { + await rm(elsewhere, { recursive: true, force: true }) + } + }) + + it('lets the next caller retry after an open fails', async () => { + const notADirectory = join(stateDirectory, 'profile-file') + await writeFile(notADirectory, 'not a profile') + + await expect(openAgentSessionRecordStoreOnce(location(notADirectory))).rejects.toThrow() + await expect(openAgentSessionRecordStoreOnce(location())).resolves.toMatchObject({ + store: expect.anything() + }) + }) +}) diff --git a/src/main/runtime/agent-session-record-store-slot.ts b/src/main/runtime/agent-session-record-store-slot.ts new file mode 100644 index 000000000000..3e74f88282ca --- /dev/null +++ b/src/main/runtime/agent-session-record-store-slot.ts @@ -0,0 +1,78 @@ +// The process's one durable agent-session record store, opened on its own. +// +// The launch ledger lives in this store, and admitting a launch is the first thing every replay-safe +// `agent.launch` does — a terminal launch included. Reaching the store used to mean installing the +// whole chat host (provider adapters, the host, the model catalog) first. The store is opened here +// instead and the chat host, when something needs it, is built on this same instance: the store is +// a single writer, so a second copy of it would diverge. +// +// Known edges, both reachable only once quit has begun (stop has no other production caller): a +// stop whose host teardown fails empties the slot while that host still holds its store open, so a +// later admission would open a second one; and a store opened after stop is closed by nothing but +// process exit. + +import { AgentSessionRecordStore } from './agent-session-record-store' +import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' + +export type AgentSessionRecordStoreLocation = { + stateDirectory: string + hostId: string + logger: StructuredAgentSessionLogger +} + +export type OpenedAgentSessionRecordStore = { + journalDatabase: JournalHostDatabase + store: AgentSessionRecordStore +} + +let opening: { stateDirectory: string; opened: Promise } | null = + null + +export function openAgentSessionRecordStoreOnce( + location: AgentSessionRecordStoreLocation +): Promise { + if (opening) { + // Loud rather than a silent second store: one process serves one profile. + if (opening.stateDirectory !== location.stateDirectory) { + return Promise.reject(new Error('agent_session_record_store_location_changed')) + } + return opening.opened + } + const slot = { + stateDirectory: location.stateDirectory, + opened: openRecordStore(location).catch((error: unknown) => { + // A failed open must not poison the slot: the next caller retries. + if (opening === slot) { + opening = null + } + throw error + }) + } + opening = slot + return slot.opened +} + +async function openRecordStore( + location: AgentSessionRecordStoreLocation +): Promise { + const journalDatabase = await openStructuredAgentSessionJournalDatabase(location) + try { + return { + journalDatabase, + store: AgentSessionRecordStore.open({ journalDatabase, hostId: location.hostId }) + } + } catch (error) { + journalDatabase.close() + throw error + } +} + +/** Empties the slot and hands back what it held, for whoever closes its database: the chat host's + * teardown when one was built on it, the caller otherwise. */ +export async function releaseAgentSessionRecordStore(): Promise { + const released = opening + opening = null + return released ? released.opened.catch(() => null) : null +} diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index f299e17b82ee..a4e1b6b454a0 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -14,7 +14,12 @@ import { compareWorktreePs } from './runtime-worktree-status-projection' import type { Repo } from '../../shared/repo-types' import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment' import { ensureStructuredAgentSessionHost as installStructuredAgentSessionHost } from './structured-agent-session-runtime' -import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { + createStructuredAgentSessionLogger, + neverThrowingStructuredAgentSessionLogger +} from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { openAgentSessionRecordStoreOnce } from './agent-session-record-store-slot' +import type { AgentSessionRecordStore } from './agent-session-record-store' import { maybeAutoRenameWorkspaceOnFirstStructuredTurn } from '../agent-hooks/first-work-structured-session-rename' import { firstWorkRenameDeps } from '../agent-hooks/first-work-rename-runtime' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' @@ -134,11 +139,23 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis }) } + /** The durable record store alone, without the chat host: launch admission needs only its + * ledger. A host installed later is built on this same store. */ + async openAgentSessionRecordStore(): Promise { + const { store } = await openAgentSessionRecordStoreOnce({ + stateDirectory: getProfileUserDataPath(), + hostId: LOCAL_EXECUTION_HOST_ID, + logger: neverThrowingStructuredAgentSessionLogger(createStructuredAgentSessionLogger()) + }) + return store + } + /** * Installs the structured agent-session host on first use. Lazy for the same * reason the orchestration DB is: the profile's user-data path is not final - * until the app is ready, and a runtime nobody drives a chat session on - * should never open the record store. + * until the app is ready, and a runtime nobody drives a chat session on never + * builds the chat host. The record store it sits on may already be open, from + * a launch's admission. */ async ensureStructuredAgentSessionHost(): Promise { await installStructuredAgentSessionHost({ diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 1402f10b2231..006bb7a982d3 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -11,6 +11,7 @@ import type { import type { RuntimeCapability } from '../../../shared/protocol-version' import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence' import type { OrchestrationSessionCaller } from '../orchestration/orchestration-caller-identity' +import type { RpcCallerIdentity } from './rpc-caller-identity' export type PairingRpcContext = { getEndpoints(params: PairingGetEndpointsParams): Promise @@ -75,6 +76,8 @@ export type RpcContext = { clientId?: string // Why: navigation is keyed by revocable device identity, never by the bearer credential or transient socket id. pairedDeviceId?: string + // Why: host-assigned from the transport (rpc-caller-identity.ts); absent when the transport could not name its caller. + caller?: RpcCallerIdentity // Why: lets handlers gate mobile payload truncation to phones only; undefined for in-process callers → treat as full-class (no clip). clientKind?: 'mobile' | 'runtime' // Why: negotiation is bound to the authenticated socket, never asserted by a destructive request. diff --git a/src/main/runtime/rpc/dispatcher-stream-options.ts b/src/main/runtime/rpc/dispatcher-stream-options.ts index e3151c66b0e7..cca01d83588a 100644 --- a/src/main/runtime/rpc/dispatcher-stream-options.ts +++ b/src/main/runtime/rpc/dispatcher-stream-options.ts @@ -1,6 +1,7 @@ import type { RuntimeCapability } from '../../../shared/protocol-version' import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol' import type { PairingRpcContext } from './core' +import type { RpcCallerIdentity } from './rpc-caller-identity' export type RpcDispatchStreamingOptions = { authenticatedCallerFingerprint?: string @@ -8,6 +9,8 @@ export type RpcDispatchStreamingOptions = { signal?: AbortSignal clientId?: string pairedDeviceId?: string + /** Set by a transport that knows its caller but carries no paired device (the desktop's IPC). */ + caller?: RpcCallerIdentity clientKind?: 'mobile' | 'runtime' clientCapabilities?: readonly RuntimeCapability[] updateClientCapabilities?: (capabilities: readonly RuntimeCapability[]) => void diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 04600fcc4ecd..4c87ceaaf509 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -24,6 +24,7 @@ import { mapDispatcherError } from './dispatcher-error-response' import { parseRpcRequestParams } from './dispatcher-request-parsing' import { RpcStreamingDispatcher } from './rpc-streaming-dispatcher' import { invokeDispatcherUnaryMethod } from './dispatcher-unary-method-invocation' +import { resolveRpcCallerIdentity } from './rpc-caller-identity' import { needsOrchestrationCallerResolution, resolveOrchestrationSessionCaller, @@ -116,6 +117,7 @@ export class RpcDispatcher { : undefined, requestId: request.id, clientId: options?.clientId, + caller: resolveRpcCallerIdentity(options), clientKind: options?.clientKind, clientCapabilities: options?.clientCapabilities, updateClientCapabilities: options?.updateClientCapabilities, diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts b/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts index 584cd4f72e6c..76afbc093349 100644 --- a/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts @@ -9,8 +9,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' -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 { RpcContext } from '../core' import { CAPABLE_CLIENT, @@ -18,6 +16,7 @@ import { methodNamed, rpcContext, runtimeStub, + setAgentLaunchRecordStore, type AgentLaunchRuntimeStub } from './agent-launch.test-fixture' @@ -209,12 +208,11 @@ describe('a replayed launch', () => { beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-caller-')) const store = await openTestAgentSessionRecordStore(directory) - // 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) + setAgentLaunchRecordStore(store) }) afterEach(async () => { - setStructuredAgentSessionHost(null) + setAgentLaunchRecordStore(null) await rm(directory, { recursive: true, force: true }) }) diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts b/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts index a8d913526cb0..2260c1af2f2c 100644 --- a/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts +++ b/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts @@ -17,12 +17,11 @@ import type { RpcContext } from '../core' /** The paired client whose view this launch should move, or null when it moves the host's. */ export function agentLaunchCallerNavigationId( target: AgentLaunchTarget, - context: Pick + context: Pick ): string | null { - if (target.kind !== 'existing' || context.clientKind === undefined) { - return null - } - return context.pairedDeviceId?.trim() || null + return target.kind === 'existing' && context.caller?.kind === 'paired-device' + ? context.caller.deviceId + : null } /** Bookkeeping, never a gate: the agent already runs, so a failure here only leaves the view as it was. */ diff --git a/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts b/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts index 3cc0806f680c..e053d64461d4 100644 --- a/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts @@ -14,8 +14,6 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecordStore } from '../../agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' -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 type { RpcContext } from '../core' import { RpcDispatcher } from '../dispatcher' @@ -25,6 +23,7 @@ import { methodNamed, rpcContext, runtimeStub, + setAgentLaunchRecordStore, type AgentLaunchRuntimeStub as RuntimeStub } from './agent-launch.test-fixture' @@ -223,12 +222,11 @@ describe('a live-pane refusal under a named operation', () => { beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-pane-')) store = await openTestAgentSessionRecordStore(directory) - // 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) + setAgentLaunchRecordStore(store) }) afterEach(async () => { - setStructuredAgentSessionHost(null) + setAgentLaunchRecordStore(null) await rm(directory, { recursive: true, force: true }) }) diff --git a/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts b/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts index 15a8b3a84996..6ea556fecb15 100644 --- a/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts @@ -12,11 +12,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { computeAgentLaunchFingerprint } from '../../../../shared/agent-launch-operation' import type { AgentSessionRecordStore } from '../../agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' -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' +import { + methodNamed, + runtimeStub, + setAgentLaunchRecordStore, + type AgentLaunchRuntimeStub +} from './agent-launch.test-fixture' vi.mock('./structured-agent-session-create', () => ({ createStructuredAgentSessionForWorktree: async () => ({ @@ -64,12 +67,11 @@ describe('a launch whose terminal fails', () => { beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-prestart-')) store = await openTestAgentSessionRecordStore(directory) - // 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) + setAgentLaunchRecordStore(store) }) afterEach(async () => { - setStructuredAgentSessionHost(null) + setAgentLaunchRecordStore(null) await rm(directory, { recursive: true, force: true }) }) diff --git a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts index 813c76800cd4..7d18b2d2f7c4 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts @@ -31,8 +31,6 @@ import { openTestAgentSessionRecordStore, readPersistedTestAgentSessionStore } from '../../agent-session-record-store-test-harness' -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 { RpcContext } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' import { RpcDispatcher } from '../dispatcher' @@ -40,6 +38,7 @@ import { methodNamed, rpcContext, runtimeStub, + setAgentLaunchRecordStore, type AgentLaunchRuntimeStub } from './agent-launch.test-fixture' @@ -126,14 +125,12 @@ beforeEach(async () => { createStructuredSession.mockClear() directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-replay-')) store = await openTestAgentSessionRecordStore(directory) - // The launch reaches the ledger through the installed host; nothing else on the host is used, - // because the structured create below it is mocked out. - // 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) + // The ledger alone, as admission opens it: no chat host is installed. + setAgentLaunchRecordStore(store) }) afterEach(async () => { - setStructuredAgentSessionHost(null) + setAgentLaunchRecordStore(null) await rm(directory, { recursive: true, force: true }) }) @@ -290,10 +287,7 @@ describe('a replay answers from the record', () => { const first = await launch(params, runtime) const reopened = await openTestAgentSessionRecordStore(directory) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: see the setup above. - setStructuredAgentSessionHost({ - deps: { store: reopened } - } as unknown as StructuredAgentSessionHost) + setAgentLaunchRecordStore(reopened) const afterRestart = runtimeStub() expect(await launch(params, afterRestart)).toEqual(first) @@ -504,10 +498,7 @@ describe('an unreadable launch payload costs one replay, never the store', () => await rewriteRecordedLaunch({ outcome: { kind: 'structured' }, worktreeId: 'wt-1' }) const reopened = await openTestAgentSessionRecordStore(directory) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: see the setup above. - setStructuredAgentSessionHost({ - deps: { store: reopened } - } as unknown as StructuredAgentSessionHost) + setAgentLaunchRecordStore(reopened) expect(reopened.listOperationRows()).toHaveLength(1) const retry = runtimeStub() diff --git a/src/main/runtime/rpc/methods/agent-launch-replay.ts b/src/main/runtime/rpc/methods/agent-launch-replay.ts index bb87d8098a9b..ea8d79fad5f2 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.ts @@ -16,43 +16,26 @@ */ import { deriveAgentLaunchChildOperationId } from '../../../../shared/agent-launch-operation' +import { AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability' import { isAgentLaunchResult, type AgentLaunchResult } from '../../../../shared/agent-launch-intent' import type { AgentSessionOperationOutcome, AgentSessionOperationRefusalCode } from '../../../../shared/agent-session-operation-ledger' import { resolveAgentSessionReplayOutcome } from '../../../native-chat/agent-session-wire/structured-agent-session-replay-outcome' -import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' -import type { AgentSessionRecordStore } from '../../agent-session-record-store' import type { RpcContext } from '../core' +import { rpcCallerOperationKey } from '../rpc-caller-identity' import type { AgentLaunchParams } from './agent-launch-schemas' -/** Remote replay needs the paired-device subject because its bearer credential can rotate. */ -export function agentLaunchOperationCallerKey( - context: Pick -): string { - if (context.clientKind === undefined) { - return 'trusted-local:runtime' - } - const pairedDeviceId = context.pairedDeviceId?.trim() - if (!pairedDeviceId) { - throw new Error('agent_session_identity_required') - } - return pairedDeviceId -} - /** - * The store is owned by the structured session host, so reaching it installs that host — already - * true of any structured launch, which installs it to attach. The change is that a terminal-bound - * launch now opens the record store too, when and only when its caller asked for replay safety. + * The ledger namespace of whoever the transport says is calling. A transport that could not name its + * caller gets no replay safety at all, rather than a namespace shared with strangers. */ -async function requireLaunchOperationStore(context: RpcContext): Promise { - await context.runtime.ensureStructuredAgentSessionHost() - const host = getStructuredAgentSessionHost() - if (!host) { - throw new Error('structured_agent_session_unsupported') +export function agentLaunchOperationCallerKey(context: Pick): string { + if (!context.caller) { + throw new Error('agent_session_identity_required') } - return host.deps.store + return rpcCallerOperationKey(context.caller) } /** @@ -67,6 +50,9 @@ export type AgentLaunchAdmission = /** This caller owns the operation. It alone runs the effect, and it must settle the row. */ | { decision: 'execute' + /** The surface exists: records the launch as it stands, so a restart before `settle` replays + * the running agent instead of refusing an unknown outcome. */ + record: (provisional: AgentLaunchResult) => Promise settle: (result: AgentLaunchResult) => Promise fail: (code: string) => Promise /** Distinct from the launch id: the inner attach reserves in this same ledger. */ @@ -115,6 +101,62 @@ function answerFromRecordedRow( : { decision: 'refuse', refusal: replay.refusal } } +/** The CLI (no declared client) ships with this host; any other caller must say it reads the word. */ +function readsUnconfirmedLaunchPrompt( + context: Pick +): boolean { + return ( + context.clientKind === undefined || + context.clientCapabilities?.includes(AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY) === + true + ) +} + +/** + * The handle this runtime uses for the recorded pane now. Usually that is the recorded handle: the + * daemon and the SSH relay keep each PTY's handle, and a restarted runtime re-adopts it. It does not + * when the PTY's incarnation changed, when it already issued that pane another handle before reading + * the inventory, or when the PTY stored none; the pane key, the durable name, finds it then. A pane + * this runtime no longer knows keeps the recorded handle, which reads as an exited terminal — the + * truth about one that is gone. The handle stays because shipped clients require one. + */ +function withLiveTerminalHandle( + recorded: AgentLaunchResult, + runtime: Pick +): AgentLaunchResult { + const { outcome } = recorded + if (outcome.kind !== 'terminal' || !outcome.paneKey) { + return recorded + } + const handle = runtime.getTerminalHandleForPaneKey(outcome.paneKey) + return handle && handle !== outcome.handle + ? { ...recorded, outcome: { ...outcome, handle } } + : recorded +} + +/** + * A recorded answer as this caller may read it now. A prompt the host stopped delivering is + * `unconfirmed` only to a caller that reads the word; every other caller gets the refusal it got + * before the first write existed, never a `not-delivered` that invites a duplicate turn. + */ +function presentRecordedAnswer( + context: RpcContext, + operationId: string, + answer: AgentLaunchAdmission +): AgentLaunchAdmission { + if (answer.decision !== 'replay') { + return answer + } + if (answer.result.prompt?.outcome === 'unconfirmed' && !readsUnconfirmedLaunchPrompt(context)) { + return refusal( + operationId, + 'agent_session_operation_unknown', + 'started its agent, but whether its prompt arrived is unknown' + ) + } + return { decision: 'replay', result: withLiveTerminalHandle(answer.result, context.runtime) } +} + /** * Admit, then claim, in one durable transaction so a launch writes the ledger once before its effect. * @@ -134,8 +176,9 @@ export async function admitAgentLaunchOperation( if (!attachOperationId) { return refusal(operationId, 'agent_session_operation_invalid', 'is not a durable operation id') } - const store = await requireLaunchOperationStore(context) const callerKey = agentLaunchOperationCallerKey(context) + // The ledger alone: admitting a terminal launch has no use for the chat host. + const store = await context.runtime.openAgentSessionRecordStore() const { decision: admitted, claim } = await store.admitAndClaimOperation( { callerKey, operationId, fingerprint, now }, // A fresh row, or a replayed one no one has answered yet, leaves the right to run open. @@ -150,7 +193,7 @@ export async function admitAgentLaunchOperation( if (admitted.decision === 'replay') { const answer = answerFromRecordedRow(operationId, admitted.row.outcome) if (answer) { - return answer + return presentRecordedAnswer(context, operationId, answer) } } // Unreachable with both steps in one transaction; answered as uncertain rather than run twice. @@ -164,26 +207,29 @@ export async function admitAgentLaunchOperation( if (claim.claim === 'lost') { // The handler joins same-process retries before admission. Reaching a claimed row here means // this runtime did not start it, so treating it as restart uncertainty is the safe answer. - return ( - answerFromRecordedRow(operationId, claim.row.outcome) ?? - refusal(operationId, 'agent_session_operation_unknown', 'is claimed but unsettled') - ) + const answer = answerFromRecordedRow(operationId, claim.row.outcome) + return answer + ? presentRecordedAnswer(context, operationId, answer) + : refusal(operationId, 'agent_session_operation_unknown', 'is claimed but unsettled') } + const succeeded = (result: AgentLaunchResult) => + store.recordOperationOutcome({ + callerKey, + operationId, + outcome: { + status: 'succeeded', + // A terminal surface has a handle, not a session id; `launch` carries whichever it is. + sessionId: result.outcome.kind === 'structured' ? result.outcome.sessionId : '', + launch: result + } + }) return { decision: 'execute', attachOperationId, callerKey, - settle: (result) => - store.recordOperationOutcome({ - callerKey, - operationId, - outcome: { - status: 'succeeded', - // A terminal surface has a handle, not a session id; `launch` carries whichever it is. - sessionId: result.outcome.kind === 'structured' ? result.outcome.sessionId : '', - launch: result - } - }), + // The same row shape twice: a build that predates the first write reads either one. + record: succeeded, + settle: succeeded, fail: (code) => store.recordOperationOutcome({ callerKey, diff --git a/src/main/runtime/rpc/methods/agent-launch-restart-replay.test.ts b/src/main/runtime/rpc/methods/agent-launch-restart-replay.test.ts new file mode 100644 index 000000000000..16abdd519933 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-restart-replay.test.ts @@ -0,0 +1,558 @@ +/** + * A launch's record across a host restart, against the real durable ledger. + * + * The record is written twice: once when the surface exists and once when the prompt's fate is + * known. A host that dies at any point leaves the replay a truthful answer — the running agent once + * its surface is recorded, an honest "unknown" before — and never a second agent. A "restart" here + * is what a new process sees: the store reopened from disk and a runtime with no in-flight launches. + * Its runtime issues a surviving pane a new handle — the case where the daemon's or relay's handle + * was not re-adopted, the only one in which re-deriving the handle from the pane key changes it. + */ + +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 { + AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY, + AGENT_LAUNCH_RUNTIME_CAPABILITY +} from '../../../../shared/agent-launch-runtime-capability' +import { isAgentLaunchResult, type AgentLaunchResult } from '../../../../shared/agent-launch-intent' +import type { AgentSessionOperationRow } from '../../../../shared/agent-session-operation-ledger' +import type { AgentSessionRecordStore } from '../../agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcContext } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { DESKTOP_RPC_CALLER } from '../rpc-caller-identity' +import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../ipc/desktop-renderer-runtime-capabilities' +import { + methodNamed, + rpcContext, + runtimeStub, + setAgentLaunchRecordStore, + type AgentLaunchRuntimeStub +} from './agent-launch.test-fixture' + +const deliverTerminalPrompt = vi.hoisted(() => + vi.fn(async (_args: { handle: string }): Promise => true) +) +vi.mock('./agent-launch-terminal-prompt', () => ({ + deliverTerminalAgentLaunchPrompt: deliverTerminalPrompt +})) + +const { AGENT_LAUNCH_METHODS } = await import('./agent-launch') +const AGENT_LAUNCH_REPLAY = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launchReplay') + +// The ledger admits against `Date.now()`, so the id must be dated now. +const OPERATION_ID = `${Date.now()}-000000000000000000000000000000ee` +const PANE_KEY = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d:3f2504e0-4f89-41d3-9a0c-0305e82c3301' +// No structured preference: every launch here is a terminal agent, the surface a restart outlives. +const TERMINAL_ONLY = {} +const PROMPTED_LAUNCH = { + agent: 'claude', + target: { kind: 'existing', worktree: 'id:wt-7' }, + prompt: { text: 'fix the failing test', delivery: 'submit' }, + operationId: OPERATION_ID +} +const PHONE: Partial = { + clientKind: 'mobile', + pairedDeviceId: 'device-1', + clientCapabilities: [AGENT_LAUNCH_RUNTIME_CAPABILITY] +} +/** The same paired device on a build that reads an `unconfirmed` prompt. */ +const UPGRADED_PHONE: Partial = { + ...PHONE, + clientCapabilities: [ + AGENT_LAUNCH_RUNTIME_CAPABILITY, + AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY + ] +} +/** Exactly what the desktop's `runtime:call` handler hands the dispatcher. */ +const DESKTOP_IPC = { + clientId: 'desktop-renderer', + caller: DESKTOP_RPC_CALLER, + clientKind: 'runtime' as const, + clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES +} +/** The handle a restarted host issues a pane that outlived the old one, when it could not + * re-adopt that pane's recorded handle. */ +const ADOPTED_HANDLE = 'term_adopted' + +let directory: string +let store: AgentSessionRecordStore + +function hostRuntime(adoptedPanes?: Record) { + // A phone's launch into an existing workspace also moves the phone's own view to the new tab. + return Object.assign( + // The paste path: the carry rule leaves this prompt for after the agent is ready. + runtimeStub({ + settings: TERMINAL_ONLY, + terminalPaneKey: PANE_KEY, + adoptedPanes, + lineCarriesPrompt: false + }), + { selectCreatedMobileSessionTabForClient: vi.fn(() => true) } + ) +} + +/** The host after a restart, still running the pane the dead one launched. */ +function restartedHostRuntime() { + return hostRuntime({ [PANE_KEY]: ADOPTED_HANDLE }) +} + +const UNCONFIRMED_AGENT = { + outcome: { kind: 'terminal', handle: ADOPTED_HANDLE, paneKey: PANE_KEY }, + worktreeId: 'wt-7', + receipt: expect.objectContaining({ mode: 'terminal' }), + prompt: { delivery: 'submit', outcome: 'unconfirmed' } +} + +function launch( + runtime: AgentLaunchRuntimeStub, + params: unknown = PROMPTED_LAUNCH, + context: Partial = PHONE +): Promise { + return AGENT_LAUNCH_REPLAY.handler( + AGENT_LAUNCH_REPLAY.params.parse(params), + rpcContext(runtime, context) + ) +} + +/** A new process: the store reread from disk, and nothing in flight. */ +async function restartHost(): Promise { + store = await openTestAgentSessionRecordStore(directory) + setAgentLaunchRecordStore(store) +} + +function row(callerKey = 'device-1'): AgentSessionOperationRow | null { + return store.getOperationRow(callerKey, OPERATION_ID) +} + +/** The first write is fired, not awaited, so it lands a moment after the tab is published. */ +async function untilRecorded(status: AgentSessionOperationRow['outcome']['status']): Promise { + const deadline = Date.now() + 2_000 + while (row()?.outcome.status !== status) { + if (Date.now() > deadline) { + throw new Error(`the launch row never reached ${status}`) + } + await new Promise((resolve) => setTimeout(resolve, 5)) + } +} + +/** The store commits in order, so a write queued now lands after every write already fired. */ +async function ledgerWritesQueuedBefore(ledger: AgentSessionRecordStore): Promise { + await ledger.recordOperationOutcome({ + callerKey: 'no-such-caller', + operationId: OPERATION_ID, + outcome: { status: 'unknown' } + }) +} + +/** Launches with a paste that never returns: the host dies while it waits for the agent. */ +async function launchUntilPasteStarts(runtime: AgentLaunchRuntimeStub): Promise { + let pasting: () => void = () => {} + const pasteStarted = new Promise((resolve) => { + pasting = resolve + }) + deliverTerminalPrompt.mockImplementationOnce(() => { + pasting() + return new Promise(() => {}) + }) + void launch(runtime) + await pasteStarted + await ledgerWritesQueuedBefore(store) +} + +/** The ledger as the launch reaches it, with the launch's `nth` write (1-based) replaced. */ +function replaceLaunchWrite( + ledger: AgentSessionRecordStore, + nth: number, + write: () => Promise +): void { + const recordOperationOutcome = ledger.recordOperationOutcome.bind(ledger) + let launchWrites = 0 + vi.spyOn(ledger, 'recordOperationOutcome').mockImplementation((input) => { + if (input.operationId === OPERATION_ID && input.callerKey === 'device-1') { + launchWrites += 1 + if (launchWrites === nth) { + return write() + } + } + return recordOperationOutcome(input) + }) +} + +function promptOutcome(outcome: AgentSessionOperationRow['outcome'] | undefined): unknown { + return outcome?.status === 'succeeded' && isAgentLaunchResult(outcome.launch) + ? outcome.launch.prompt?.outcome + : undefined +} + +function dispatcherFor(runtime: AgentLaunchRuntimeStub): RpcDispatcher { + return new RpcDispatcher({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture implements every runtime method agent.launch reaches, plus the id the dispatcher stamps on replies. + runtime: { ...runtime, getRuntimeId: () => 'runtime-1' } as unknown as OrcaRuntimeService, + methods: AGENT_LAUNCH_METHODS + }) +} + +beforeEach(async () => { + deliverTerminalPrompt.mockReset() + deliverTerminalPrompt.mockResolvedValue(true) + directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-restart-')) + store = await openTestAgentSessionRecordStore(directory) + setAgentLaunchRecordStore(store) +}) + +afterEach(async () => { + setAgentLaunchRecordStore(null) + await rm(directory, { recursive: true, force: true }) +}) + +describe('a host restart mid-launch', () => { + it('finds the running agent, under the handle the new host issued, after a restart mid-paste', async () => { + const dying = hostRuntime() + await launchUntilPasteStarts(dying) + + await restartHost() + const restarted = restartedHostRuntime() + + // The dead host's `term_1` was not re-adopted; the pane is the durable name. + await expect(launch(restarted, PROMPTED_LAUNCH, UPGRADED_PHONE)).resolves.toEqual( + UNCONFIRMED_AGENT + ) + expect(restarted.createTerminal).not.toHaveBeenCalled() + expect(dying.createTerminal).toHaveBeenCalledOnce() + expect(deliverTerminalPrompt).toHaveBeenCalledOnce() + }) + + it('answers a caller that cannot read "unconfirmed" as before: unknown, never "not sent"', async () => { + await launchUntilPasteStarts(hostRuntime()) + + await restartHost() + const restarted = restartedHostRuntime() + + await expect(launch(restarted)).rejects.toThrow('agent_session_operation_unknown') + // The same row still answers a caller that reads the word: the refusal is presentation only. + await expect(launch(restarted, PROMPTED_LAUNCH, UPGRADED_PHONE)).resolves.toEqual( + UNCONFIRMED_AGENT + ) + expect(restarted.createTerminal).not.toHaveBeenCalled() + expect(deliverTerminalPrompt).toHaveBeenCalledOnce() + }) + + it('stays unconfirmed when the host died after the paste returned, before the final write', async () => { + let pasted: () => void = () => {} + const pasteReturned = new Promise((resolve) => { + pasted = resolve + }) + deliverTerminalPrompt.mockImplementationOnce(async () => { + pasted() + return true + }) + // The final write never lands: the process is gone by then. + replaceLaunchWrite(store, 2, () => new Promise(() => {})) + void launch(hostRuntime()) + await pasteReturned + await untilRecorded('succeeded') + await ledgerWritesQueuedBefore(store) + + await restartHost() + const restarted = restartedHostRuntime() + + await expect(launch(restarted, PROMPTED_LAUNCH, UPGRADED_PHONE)).resolves.toEqual( + UNCONFIRMED_AGENT + ) + await expect(launch(restarted)).rejects.toThrow('agent_session_operation_unknown') + expect(deliverTerminalPrompt).toHaveBeenCalledOnce() + }) + + it('keeps the recorded handle when the restarted host no longer has the pane', async () => { + await launchUntilPasteStarts(hostRuntime()) + + await restartHost() + + await expect(launch(hostRuntime(), PROMPTED_LAUNCH, UPGRADED_PHONE)).resolves.toMatchObject({ + outcome: { kind: 'terminal', handle: 'term_1', paneKey: PANE_KEY } + }) + }) + + it('stays unknown when the host died before the surface was recorded', async () => { + const dying = hostRuntime() + let spawnRequested: () => void = () => {} + const spawning = new Promise((resolve) => { + spawnRequested = resolve + }) + dying.createTerminal.mockImplementationOnce(() => { + spawnRequested() + return new Promise(() => {}) + }) + void launch(dying) + await spawning + + await restartHost() + const restarted = hostRuntime() + + // A pane may exist that nothing recorded; "unknown" is the truthful answer, never a relaunch. + await expect(launch(restarted)).rejects.toThrow('agent_session_operation_unknown') + expect(row()?.outcome.status).toBe('unknown') + expect(restarted.createTerminal).not.toHaveBeenCalled() + }) + + it('replays the final answer when the host died after the prompt landed', async () => { + const first = await launch(hostRuntime()) + expect(first.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' }) + + await restartHost() + const restarted = restartedHostRuntime() + + await expect(launch(restarted)).resolves.toEqual({ + ...first, + outcome: { ...first.outcome, handle: ADOPTED_HANDLE } + }) + expect(restarted.createTerminal).not.toHaveBeenCalled() + expect(deliverTerminalPrompt).toHaveBeenCalledOnce() + }) + + it('lets the final write replace the first, never the other way round', async () => { + let releasePaste: (pasted: boolean) => void = () => {} + deliverTerminalPrompt.mockImplementationOnce( + () => + new Promise((resolve) => { + releasePaste = resolve + }) + ) + const running = launch(hostRuntime()) + await untilRecorded('succeeded') + releasePaste(true) + await running + + await restartHost() + const outcome = row()?.outcome + expect(outcome?.status === 'succeeded' && outcome.launch).toMatchObject({ + prompt: { outcome: 'handed-to-terminal' } + }) + }) +}) + +describe('a final write that fails', () => { + it('leaves the prompt unconfirmed, or unknown to a caller that cannot read it, never "not sent"', async () => { + replaceLaunchWrite(store, 2, () => Promise.reject(new Error('SQLITE_BUSY'))) + const host = hostRuntime() + + const first = await launch(host) + + // The live answer is the truth the host saw; only the record missed it. + expect(first.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' }) + await expect(launch(host)).rejects.toThrow('agent_session_operation_unknown') + await expect(launch(host, PROMPTED_LAUNCH, UPGRADED_PHONE)).resolves.toMatchObject({ + outcome: { kind: 'terminal', handle: 'term_1', paneKey: PANE_KEY }, + prompt: { delivery: 'submit', outcome: 'unconfirmed' } + }) + expect(host.createTerminal).toHaveBeenCalledOnce() + expect(deliverTerminalPrompt).toHaveBeenCalledOnce() + }) +}) + +describe('the two writes', () => { + it('land in order when the paste returns before the first write has committed', async () => { + const recorded = vi.spyOn(store, 'recordOperationOutcome') + + await launch(hostRuntime()) + + // Both were queued before either committed; the second is the one left standing. + const launchWrites = recorded.mock.calls.filter(([input]) => input.callerKey === 'device-1') + expect(launchWrites.map(([input]) => promptOutcome(input.outcome))).toEqual([ + 'unconfirmed', + 'handed-to-terminal' + ]) + expect(promptOutcome(row()?.outcome)).toBe('handed-to-terminal') + }) + + it('never lets a failed first write block the launch', async () => { + replaceLaunchWrite(store, 1, () => Promise.reject(new Error('SQLITE_BUSY'))) + + const first = await launch(hostRuntime()) + + expect(first.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' }) + expect(promptOutcome(row()?.outcome)).toBe('handed-to-terminal') + }) +}) + +describe('a reply lost three times', () => { + it('starts one agent, and every retry gets its answer', async () => { + const host = hostRuntime() + const first = await launch(host) + // Lost once and twice on the same host, then a third time across a restart. + const second = await launch(host) + const third = await launch(host) + await restartHost() + const afterRestart = hostRuntime() + const fourth = await launch(afterRestart) + + expect([second, third, fourth]).toEqual([first, first, first]) + expect(host.createTerminal).toHaveBeenCalledOnce() + expect(afterRestart.createTerminal).not.toHaveBeenCalled() + expect(deliverTerminalPrompt).toHaveBeenCalledOnce() + }) + + it('starts one agent when three retries arrive while the first is still running', async () => { + let releasePaste: (pasted: boolean) => void = () => {} + deliverTerminalPrompt.mockImplementationOnce( + () => + new Promise((resolve) => { + releasePaste = resolve + }) + ) + const host = hostRuntime() + const attempts = [launch(host), launch(host), launch(host)] + await untilRecorded('succeeded') + releasePaste(true) + const [first, ...retries] = await Promise.all(attempts) + + expect(retries).toEqual([first, first]) + expect(host.createTerminal).toHaveBeenCalledOnce() + }) +}) + +describe('the desktop launches replay-safely', () => { + it('admits the desktop window under its own host-assigned identity and replays its id', async () => { + const host = hostRuntime() + const request = { + id: 'request-1', + authToken: 'desktop-ipc', + method: 'agent.launchReplay', + params: PROMPTED_LAUNCH + } + + const first = await dispatcherFor(host).dispatch(request, DESKTOP_IPC) + const replayed = await dispatcherFor(host).dispatch(request, DESKTOP_IPC) + + expect(first).toMatchObject({ ok: true, result: { outcome: { kind: 'terminal' } } }) + expect(replayed).toMatchObject({ ok: true, result: first.ok ? first.result : null }) + expect(host.createTerminal).toHaveBeenCalledOnce() + expect(row('trusted-local:desktop')?.outcome.status).toBe('succeeded') + }) + + it('reads an unconfirmed prompt after a restart mid-paste, through its own IPC transport', async () => { + const request = { + id: 'request-1', + authToken: 'desktop-ipc', + method: 'agent.launchReplay', + params: PROMPTED_LAUNCH + } + deliverTerminalPrompt.mockImplementationOnce(() => new Promise(() => {})) + void dispatcherFor(hostRuntime()).dispatch(request, DESKTOP_IPC) + const deadline = Date.now() + 2_000 + while (row('trusted-local:desktop')?.outcome.status !== 'succeeded') { + if (Date.now() > deadline) { + throw new Error('the desktop launch was never recorded') + } + await new Promise((resolve) => setTimeout(resolve, 5)) + } + await ledgerWritesQueuedBefore(store) + + await restartHost() + const replayed = await dispatcherFor(restartedHostRuntime()).dispatch(request, DESKTOP_IPC) + + expect(replayed).toMatchObject({ ok: true, result: UNCONFIRMED_AGENT }) + }) + + it('opens the ledger alone, never the chat host, to admit a terminal launch', async () => { + const host = hostRuntime() + + await launch(host) + + expect(host.openAgentSessionRecordStore).toHaveBeenCalled() + expect(host.ensureStructuredAgentSessionHost).not.toHaveBeenCalled() + }) +}) + +describe('a caller cannot claim an identity', () => { + it('keys a paired device by its authenticated subject whatever its params say', async () => { + const host = hostRuntime() + const response = await new Promise((resolve) => { + void dispatcherFor(host).dispatchStreaming( + { + id: 'request-1', + authToken: 'device-token', + method: 'agent.launchReplay', + params: { + ...PROMPTED_LAUNCH, + caller: DESKTOP_RPC_CALLER, + callerKey: 'trusted-local:desktop', + pairedDeviceId: 'device-2' + } + }, + resolve, + { ...PHONE, clientId: 'device-token' } + ) + }) + + expect(JSON.parse(response)).toMatchObject({ ok: true }) + expect(store.listOperationRows().map((entry) => entry.callerKey)).toEqual(['device-1']) + }) + + it('refuses replay safety to a transport that cannot name its caller', async () => { + const host = hostRuntime() + + const response = await dispatcherFor(host).dispatch( + { + id: 'request-1', + authToken: 'token', + method: 'agent.launchReplay', + params: { ...PROMPTED_LAUNCH, caller: DESKTOP_RPC_CALLER } + }, + { clientKind: 'runtime', clientCapabilities: [AGENT_LAUNCH_RUNTIME_CAPABILITY] } + ) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'agent_session_identity_required' } + }) + expect(host.createTerminal).not.toHaveBeenCalled() + expect(store.listOperationRows()).toHaveLength(0) + }) +}) + +describe('the ledger stays bounded', () => { + it('keeps one row per launch, retained from admission, across both writes', async () => { + let releasePaste: (pasted: boolean) => void = () => {} + deliverTerminalPrompt.mockImplementationOnce( + () => + new Promise((resolve) => { + releasePaste = resolve + }) + ) + const running = launch(hostRuntime()) + await untilRecorded('succeeded') + const afterFirstWrite = row() + releasePaste(true) + await running + + expect(store.listOperationRows()).toHaveLength(1) + expect(row()?.expiresAt).toBe(afterFirstWrite?.expiresAt) + expect(row()?.recordedAt).toBe(afterFirstWrite?.recordedAt) + }) + + it('holds the desktop to the same per-caller cap as every other caller', async () => { + const now = Date.now() + for (let index = 0; index < 512; index += 1) { + await store.admitOperation({ + callerKey: 'trusted-local:desktop', + operationId: `${now}-${index.toString(16).padStart(32, '0')}`, + fingerprint: 'fp-seeded', + now + }) + } + const host = hostRuntime() + + await expect(launch(host, PROMPTED_LAUNCH, { ...DESKTOP_IPC })).rejects.toThrow( + 'agent_session_operation_capacity' + ) + expect(host.createTerminal).not.toHaveBeenCalled() + // Another caller's namespace is not starved by it. + await expect(launch(host)).resolves.toMatchObject({ outcome: { kind: 'terminal' } }) + }) +}) diff --git a/src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts b/src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts index 720f08193838..94f02830447c 100644 --- a/src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts @@ -13,8 +13,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { computeAgentLaunchFingerprint } from '../../../../shared/agent-launch-operation' import type { AgentSessionRecordStore } from '../../agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' -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 type { RpcContext } from '../core' import { RpcDispatcher } from '../dispatcher' @@ -24,6 +22,7 @@ import { methodNamed, rpcContext, runtimeStub, + setAgentLaunchRecordStore, type AgentLaunchRuntimeStub as RuntimeStub } from './agent-launch.test-fixture' @@ -211,12 +210,11 @@ describe('a taken session id under a named operation', () => { beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-session-')) store = await openTestAgentSessionRecordStore(directory) - // 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) + setAgentLaunchRecordStore(store) }) afterEach(async () => { - setStructuredAgentSessionHost(null) + setAgentLaunchRecordStore(null) await rm(directory, { recursive: true, force: true }) }) diff --git a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts index 909118c883fc..c0aeb4436cee 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts @@ -9,6 +9,8 @@ import { vi } from 'vitest' import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability' import { AgentLaunchPaneAlreadyLiveError } from '../../../../shared/agent-launch-pane-already-live' import type { RpcContext } from '../core' +import { resolveRpcCallerIdentity } from '../rpc-caller-identity' +import type { AgentSessionRecordStore } from '../../agent-session-record-store' export const STRUCTURED_PREFERENCE = { experimentalNativeChat: true, @@ -37,6 +39,9 @@ export type AgentLaunchRuntimeStubOptions = { terminalPaneAlreadyLive?: boolean /** What the runtime reports about an offered prompt's typed line; unset reports nothing. */ lineCarriesPrompt?: boolean + /** Panes this runtime found already running, by the handle it issued them: a restarted host + * that could not re-adopt a surviving PTY's handle issues a new one for the same pane. */ + adoptedPanes?: Record } function reportPromptCarry( @@ -49,8 +54,17 @@ function reportPromptCarry( } } +let launchRecordStore: AgentSessionRecordStore | null = null + +/** The ledger every stub's `openAgentSessionRecordStore` opens, as a process opens its one store. */ +export function setAgentLaunchRecordStore(store: AgentSessionRecordStore | null): void { + launchRecordStore = store +} + export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) { const worktreeCreateResults = new Map>() + // Only the panes this runtime created or adopted, under the handle it issued them. + const handlesByPaneKey = new Map(Object.entries(options.adoptedPanes ?? {})) const waitForSetupTerminalCompletion = vi.fn( async (_handle: string, _signal?: AbortSignal): Promise<{ exitCode: number | null }> => ({ exitCode: 0 @@ -80,6 +94,9 @@ export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) { showRepo: vi.fn(async () => ({ id: 'repo-1' })), createManagedWorktree: vi.fn(async (args: Record) => { reportPromptCarry(options, args.onStartupPromptCarry, args.startupPrompt) + if (args.startupAgent && options.startupTerminalPaneKey) { + handlesByPaneKey.set(options.startupTerminalPaneKey, 'term_agent_first') + } return { worktree: { id: 'wt-new' }, startupTerminal: args.startupAgent @@ -98,6 +115,9 @@ export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) { throw new AgentLaunchPaneAlreadyLiveError() } reportPromptCarry(options, createOptions?.onStartupPromptCarry, createOptions?.startupPrompt) + if (options.terminalPaneKey) { + handlesByPaneKey.set(options.terminalPaneKey, 'term_1') + } return { handle: 'term_1', ...(options.terminalPaneKey ? { paneKey: options.terminalPaneKey } : {}), @@ -105,6 +125,7 @@ export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) { } }), showTerminal: vi.fn(async (handle: string) => ({ handle, worktreeId: 'wt-7' })), + getTerminalHandleForPaneKey: vi.fn((paneKey: string) => handlesByPaneKey.get(paneKey) ?? null), isTerminalRunningAgent: vi.fn(async () => true), showManagedTerminalWorkspace: vi.fn(async (selector: string) => ({ id: selector.replace(/^id:/, '') @@ -119,6 +140,12 @@ export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) { folderWorkspace: null })), ensureStructuredAgentSessionHost: vi.fn(async () => {}), + openAgentSessionRecordStore: vi.fn(async (): Promise => { + if (!launchRecordStore) { + throw new Error('agent_session_record_store_unavailable') + } + return launchRecordStore + }), waitForSetupTerminalCompletion } } @@ -139,12 +166,14 @@ export function methodNamed ): RpcContext { + const caller = context.caller ?? resolveRpcCallerIdentity(context) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the runtime surface these methods reach, so a method it omits throws on call rather than reading a wrong value. - return { runtime, ...context } as unknown as RpcContext + return { runtime, ...context, ...(caller ? { caller } : {}) } as unknown as RpcContext } export const CAPABLE_CLIENT: Partial = { diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index d0d3a905b780..c405caacf75e 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -146,12 +146,21 @@ async function resolveUnlaunchedIntent( return intent } +/** What a launch admitted under an operation id carries into its execution. */ +type ReplaySafeLaunch = { + attachOperationId: string + callerKey: string + terminalSpawn: TerminalSpawnDispatch + /** Records the surface the moment it exists, an owed prompt as `unconfirmed`. Fired, never + * awaited: the ledger's transactions run in order, so the final settle still lands after it, and + * the prompt never waits on bookkeeping. */ + recordSurface: (provisional: AgentLaunchResult) => void +} + async function runAgentLaunch( intent: AgentLaunchIntent, context: RpcContext, - attachOperationId?: string, - operationCallerKey?: string, - terminalSpawn?: TerminalSpawnDispatch + replaySafe?: ReplaySafeLaunch ): Promise { const callerNavigationId = agentLaunchCallerNavigationId(intent.target, context) return executeAgentLaunch({ @@ -159,19 +168,19 @@ async function runAgentLaunch( intent, surfaces: agentLaunchSurfaceFactory( context, - attachOperationId, - operationCallerKey, + replaySafe?.attachOperationId, + replaySafe?.callerKey, callerNavigationId !== null, - terminalSpawn + replaySafe?.terminalSpawn ), workspaces: agentLaunchWorkspaceFactory(context, intent.agent), // The tab is shown as it is published, not after a prompt that can take a minute to land. - ...(callerNavigationId !== null - ? { - onSurfacePublished: (surface) => - selectAgentLaunchTabForCaller(context.runtime, surface, callerNavigationId) - } - : {}) + onSurfacePublished: (surface) => { + replaySafe?.recordSurface(surface) + if (callerNavigationId !== null) { + selectAgentLaunchTabForCaller(context.runtime, surface, callerNavigationId) + } + } }) } @@ -259,13 +268,12 @@ async function executeReplaySafeAgentLaunch( const terminalSpawn = trackTerminalSpawnDispatch() let result: AgentLaunchResult try { - result = await runAgentLaunch( - intent, - context, - admission.attachOperationId, - admission.callerKey, - terminalSpawn - ) + result = await runAgentLaunch(intent, context, { + attachOperationId: admission.attachOperationId, + callerKey: admission.callerKey, + terminalSpawn, + recordSurface: (provisional) => void settleQuietly(admission.record(provisional)) + }) } catch (error) { const failedWithoutEffects = launchFailureWithoutEffectsCode( error, @@ -277,7 +285,8 @@ async function executeReplaySafeAgentLaunch( } throw new AgentLaunchExecutionError(error, failedWithoutEffects !== null) } - // Settlement is bookkeeping; failure leaves the truthful `unknown` refusal for later retries. + // Bookkeeping: a failure leaves the first write, whose owed prompt replays as `unconfirmed` (or as + // `unknown` to a caller that cannot read it), never as `not-delivered`. await settleQuietly(admission.settle(result)) return result } diff --git a/src/main/runtime/rpc/rpc-caller-identity.ts b/src/main/runtime/rpc/rpc-caller-identity.ts new file mode 100644 index 000000000000..13e5f15e6aa2 --- /dev/null +++ b/src/main/runtime/rpc/rpc-caller-identity.ts @@ -0,0 +1,62 @@ +/** + * Who is calling, as the host's own transport established it. + * + * One identity per caller, stamped by the dispatcher from what the connection proved — the runtime + * socket, the desktop's IPC channel, or a paired device's authenticated socket — and never read + * from request params, so a caller cannot claim to be another. Durable per-caller state (the launch + * ledger's namespace) and per-caller behaviour (whose view a launch moves) both read this. + */ + +import type { RpcDispatchStreamingOptions } from './dispatcher-stream-options' + +export type RpcCallerIdentity = + /** The `orca` CLI over the runtime socket, and the in-process bridges that relay it. */ + | { kind: 'local-cli' } + /** This host's own desktop app over IPC. One identity for every window, so a reload or an app + * restart still names the same caller. */ + | { kind: 'desktop' } + /** A paired phone or remote desktop, by its device subject, which survives credential rotation. */ + | { kind: 'paired-device'; deviceId: string } + +export const DESKTOP_RPC_CALLER: RpcCallerIdentity = { kind: 'desktop' } + +/** + * A transport that names its caller is believed; one that declares no client at all is the + * in-process runtime socket, trusted as it always was; one that declares a client it cannot name has + * no identity, and anything that needs one refuses it. + * + * Temporary: "no declared client" means the local CLI, so the SSH remote CLI bridge and browser + * automation share its namespace, and a new transport that forgets to stamp its caller inherits it. + * Before plugins ship (plan §4) the runtime socket stamps `local-cli` itself and no stamp means no + * identity. + */ +export function resolveRpcCallerIdentity( + transport: + | Pick + | undefined +): RpcCallerIdentity | undefined { + if (transport?.caller) { + return transport.caller + } + const deviceId = transport?.pairedDeviceId?.trim() + if (deviceId) { + return { kind: 'paired-device', deviceId } + } + return transport?.clientKind === undefined ? { kind: 'local-cli' } : undefined +} + +/** + * The caller's namespace in the durable operation ledger. Rows outlive the process, so these strings + * are permanent: the CLI and paired-device keys are the ones rows were written under before this + * identity existed. + */ +export function rpcCallerOperationKey(caller: RpcCallerIdentity): string { + switch (caller.kind) { + case 'local-cli': + return 'trusted-local:runtime' + case 'desktop': + return 'trusted-local:desktop' + case 'paired-device': + return caller.deviceId + } +} diff --git a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts index 07706125c235..7dc5dc0586ac 100644 --- a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts +++ b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts @@ -21,6 +21,7 @@ import { parseRpcRequestParams } from './dispatcher-request-parsing' import { routeDispatcherClientHostedBrowserRpc } from './dispatcher-client-browser-routing' import { needsLocalCallerFingerprint } from './dispatcher-caller-fingerprint' import { createDispatcherStreamingFeatureEmitter } from './dispatcher-streaming-feature-emitter' +import { resolveRpcCallerIdentity } from './rpc-caller-identity' import { needsOrchestrationCallerResolution, resolveOrchestrationSessionCaller, @@ -138,6 +139,7 @@ export class RpcStreamingDispatcher { subscriptionRegistrationVersion, clientId: options?.clientId, pairedDeviceId: options?.pairedDeviceId, + caller: resolveRpcCallerIdentity(options), clientKind: options?.clientKind, clientCapabilities: options?.clientCapabilities, updateClientCapabilities: options?.updateClientCapabilities, @@ -192,6 +194,7 @@ export class RpcStreamingDispatcher { connectionId: options?.connectionId, clientId: options?.clientId, pairedDeviceId: options?.pairedDeviceId, + caller: resolveRpcCallerIdentity(options), clientKind: options?.clientKind, clientCapabilities: options?.clientCapabilities, updateClientCapabilities: options?.updateClientCapabilities, diff --git a/src/main/runtime/structured-agent-session-held-signal.test.ts b/src/main/runtime/structured-agent-session-held-signal.test.ts index 0bf61aa7843c..c0c6a2a8db37 100644 --- a/src/main/runtime/structured-agent-session-held-signal.test.ts +++ b/src/main/runtime/structured-agent-session-held-signal.test.ts @@ -1,8 +1,8 @@ import './rpc/unused-default-rpc-methods.test-fixture' /** * "This machine holds a structured chat" against a real host and record store. Session history, - * resume preparation and replay-safe phone launches all build the host for a user who never had a - * chat; only a chat record may turn the signal on, and the first one must turn it on at once. + * resume preparation and replay-safe phone launches all open the record store for a user who never + * had a chat; only a chat record may turn the signal on, and the first one must turn it on at once. */ import { mkdtemp, rm } from 'node:fs/promises' @@ -22,6 +22,7 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { openAgentSessionRecordStoreOnce } from './agent-session-record-store-slot' vi.mock('../ai-vault/session-scanner-service-spawn', async (importOriginal) => ({ ...(await importOriginal()), @@ -54,6 +55,14 @@ const runtime = { ensureStructuredAgentSessionHost: async () => { await installHost() }, + openAgentSessionRecordStore: async () => + ( + await openAgentSessionRecordStoreOnce({ + stateDirectory, + hostId: 'local', + logger: createStructuredAgentSessionLogger() + }) + ).store, listAiVaultSessions: async () => ({ sessions: [], issues: [], @@ -111,7 +120,12 @@ describe('whether this machine holds a structured chat', () => { it('stays false when a replay-safe phone launch records its operation', async () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: admission reads only these fields. - const context = { runtime, clientKind: 'mobile', pairedDeviceId: 'phone-1' } as RpcContext + const context = { + runtime, + clientKind: 'mobile', + pairedDeviceId: 'phone-1', + caller: { kind: 'paired-device', deviceId: 'phone-1' } + } as RpcContext // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: admission reads only the operation id. const params = { operationId: operationId('a1') } as Parameters< typeof admitAgentLaunchOperation diff --git a/src/main/runtime/structured-agent-session-install-failure.test.ts b/src/main/runtime/structured-agent-session-install-failure.test.ts index c00e9c6890f2..6628f06c13f3 100644 --- a/src/main/runtime/structured-agent-session-install-failure.test.ts +++ b/src/main/runtime/structured-agent-session-install-failure.test.ts @@ -1,5 +1,6 @@ -// An install that fails after it opened the chat journal closes that connection, so the next -// install does not leave a second one open in the same process. +// An install that fails after it opened the chat journal leaves that one connection to the record +// store slot, which launch admission may already be using, and the next install builds on it: the +// process never holds a second connection. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -54,18 +55,20 @@ afterEach(async () => { }) describe('an install that fails after opening the chat journal', () => { - it('closes the journal connection it opened, and the next install opens its own', async () => { + it('keeps the one connection it opened, and the next install builds on it', async () => { const open = vi.spyOn(JournalHostDatabase, 'open') mocks.failWiring.mockReturnValueOnce(true) await expect(install()).rejects.toThrow('model catalog wiring failed') - const failed = await open.mock.results[0]?.value - expect(failed).toBeInstanceOf(JournalHostDatabase) - expect(failed.isClosed).toBe(true) + const opened = await open.mock.results[0]?.value + expect(opened).toBeInstanceOf(JournalHostDatabase) + expect(opened.isClosed).toBe(false) await expect(install()).resolves.toBeDefined() - expect(open).toHaveBeenCalledTimes(2) - const reopened = await open.mock.results[1]?.value - expect(reopened?.isClosed).toBe(false) + expect(open).toHaveBeenCalledOnce() + expect(opened.isClosed).toBe(false) + + await stopStructuredAgentSessionRuntime() + expect(opened.isClosed).toBe(true) }) }) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 6913d3abc7c8..1e594d0665bd 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -31,9 +31,11 @@ import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-sessio import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' -import { AgentSessionRecordStore } from './agent-session-record-store' -import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' -import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' +import { + openAgentSessionRecordStoreOnce, + releaseAgentSessionRecordStore, + type OpenedAgentSessionRecordStore +} from './agent-session-record-store-slot' import { legacyAgentSessionStorePath } from './agent-session-record-store-file' import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' import { journalDatabaseHoldsAgentSessions } from '../native-chat/agent-session-journal/journal-database' @@ -192,6 +194,14 @@ export async function stopStructuredAgentSessionRuntime(options?: { if (installed) { outstanding.push(installed) } + // A store admission opened with no host built on it has no teardown to close its database. + const recordStore = await releaseAgentSessionRecordStore() + if ( + recordStore && + !outstanding.some((runtime) => runtime.journalDatabase === recordStore.journalDatabase) + ) { + recordStore.journalDatabase.close() + } const failures: unknown[] = [] for (const runtime of outstanding) { try { @@ -222,29 +232,20 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { const envResolvers = createStructuredAgentEnvironmentResolvers(deps) - const store = AgentSessionRecordStore.open({ - journalDatabase, - hostId: deps.hostId - }) let host: StructuredAgentSessionHost | null = null const lifecycle = createStructuredAgentSessionLifecycleDelivery({ handle: (event) => host?.handleAdapterEvent(event), diff --git a/src/shared/agent-launch-intent.ts b/src/shared/agent-launch-intent.ts index dad67996d4a9..c809b96b63a0 100644 --- a/src/shared/agent-launch-intent.ts +++ b/src/shared/agent-launch-intent.ts @@ -154,6 +154,13 @@ export type AgentLaunchPromptDisposal = | { outcome: 'handed-to-terminal' } /** Not delivered by this call; the caller still owns the text. */ | { outcome: 'not-delivered' } + /** + * Only ever replayed, never a live answer: the host recorded the running agent, then stopped + * before the delivery reported back, so the text may or may not have arrived. The caller must not + * resend. Sent only to a caller advertising `agent.launch.prompt-unconfirmed.v1`; every other + * caller is refused with `agent_session_operation_unknown` instead. + */ + | { outcome: 'unconfirmed' } export type AgentLaunchPromptReceipt = { delivery: AgentLaunchPromptDelivery @@ -244,7 +251,9 @@ function isAgentLaunchPromptReceipt(value: unknown): value is AgentLaunchPromptR } return value.outcome === 'journaled' ? 'messageId' in value && typeof value.messageId === 'string' - : value.outcome === 'handed-to-terminal' || value.outcome === 'not-delivered' + : value.outcome === 'handed-to-terminal' || + value.outcome === 'not-delivered' || + value.outcome === 'unconfirmed' } function isAgentLaunchOutcome(value: unknown): value is AgentLaunchOutcome { diff --git a/src/shared/agent-launch-runtime-capability.ts b/src/shared/agent-launch-runtime-capability.ts index 2094f705dc7b..e4853e84f872 100644 --- a/src/shared/agent-launch-runtime-capability.ts +++ b/src/shared/agent-launch-runtime-capability.ts @@ -29,9 +29,15 @@ export const AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY = 'agent.launch.prompt export const AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY = 'agent.launch.replay-required.v1' as const +// A client that reads `prompt.outcome: 'unconfirmed'` on a replayed launch. Without it, a launch +// whose host stopped mid-delivery replays as `agent_session_operation_unknown`, as it always has. +export const AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY = + 'agent.launch.prompt-unconfirmed.v1' as const + export const AGENT_LAUNCH_RUNTIME_CAPABILITIES = [ AGENT_LAUNCH_RUNTIME_CAPABILITY, AGENT_LAUNCH_REPLAY_RUNTIME_CAPABILITY, AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY, - AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY + AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY, + AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY ] as const