mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 00:02:54 +00:00
Both were inert in step 1 and existed only for step 2. agent.launch will become a public plugin API, so every wire field is permanent once shipped; a top-level viewMode reads as "choose terminal vs chat", which the host decides. Step 2 introduces placement and view intent under a placement object instead.
357 lines
14 KiB
TypeScript
357 lines
14 KiB
TypeScript
/**
|
|
* `agent.launch` — the one method that starts an agent, whatever surface it turns out to be.
|
|
*
|
|
* It exists because the routing decision had no host-side home: `worktree.create` never consulted
|
|
* it, so any client that created a worktree with `startupAgent` got a PTY agent no matter what the
|
|
* user's default said. That is not fixable inside `worktree.create`, because its contract is
|
|
* exactly "spawn a PTY agent and hand me its `agentTerminalHandle`" — a host that quietly answered
|
|
* it with a structured session would hand every older client a response with no handle and no
|
|
* error. So `worktree.create` keeps that meaning verbatim, forever, and everything that has to
|
|
* choose a surface comes here instead, behind a negotiated capability.
|
|
*
|
|
* A caller therefore never asks for a mode, and must read `outcome.kind` rather than assume one:
|
|
* the receipt always says which surface ran and why, so a downgrade is never silent.
|
|
*
|
|
* A launch is also the one call whose retry is most expensive to get wrong — a lost reply means the
|
|
* caller cannot tell "never ran" from "ran, answer lost" — so a caller may name the operation with
|
|
* `operationId` and get exactly one execution, a recorded answer on every replay, and a refusal
|
|
* when the outcome is genuinely unknown. That guarantee is safety, not recovery: it makes a retry
|
|
* harmless, and does nothing to reunite a caller with a surface a dead attempt left behind.
|
|
*/
|
|
|
|
import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability'
|
|
import { computeAgentLaunchFingerprint } from '../../../../shared/agent-launch-operation'
|
|
import type {
|
|
AgentLaunchIntent,
|
|
AgentLaunchResult,
|
|
AgentLaunchTarget
|
|
} from '../../../../shared/agent-launch-intent'
|
|
import { agentSessionOperationKey } from '../../../../shared/agent-session-operation-ledger'
|
|
import {
|
|
WorktreeCreateCollisionError,
|
|
WORKTREE_CREATE_COLLISION_CODE
|
|
} from '../../../../shared/new-workspace/worktree-create-collision'
|
|
import { executeAgentLaunch } from '../../../agent-launch/agent-launch-executor'
|
|
import {
|
|
trackTerminalSpawnDispatch,
|
|
type TerminalSpawnDispatch
|
|
} from '../../../agent-launch/agent-launch-not-started'
|
|
import type { OrcaRuntimeService } from '../../orca-runtime'
|
|
import { defineMethod, type RpcContext } from '../core'
|
|
import { admitAgentLaunchOperation, agentLaunchOperationCallerKey } from './agent-launch-replay'
|
|
import { AgentLaunch, AgentLaunchReplay, type AgentLaunchParams } from './agent-launch-schemas'
|
|
import { agentLaunchSurfaceFactory } from './agent-launch-surfaces'
|
|
import {
|
|
agentLaunchFailureCode,
|
|
launchFailureWithoutEffectsCode
|
|
} from './agent-launch-failure-code'
|
|
import {
|
|
agentLaunchCallerNavigationId,
|
|
selectAgentLaunchTabForCaller
|
|
} from './agent-launch-caller-selection'
|
|
import { agentLaunchWorkspaceFactory } from './agent-launch-worktree-creation'
|
|
|
|
/**
|
|
* Advertising `agent.launch.v2` is a client's statement that it understands EITHER outcome — a
|
|
* structured session it can open, or a terminal agent. A client that can only render one of the
|
|
* two must keep using the surface-specific methods instead. The `clientKind === undefined` branch
|
|
* is not "whatever ships in this build": it is the `orca` CLI over the runtime socket and the
|
|
* SSH-remote CLI bridges, which carry no capability list at all. The desktop renderer ships in
|
|
* this build and still arrives as `clientKind: 'runtime'`, so it advertises like any other client.
|
|
*/
|
|
export function supportsAgentLaunch(
|
|
context: Pick<RpcContext, 'clientKind' | 'clientCapabilities'>
|
|
): boolean {
|
|
return (
|
|
context.clientKind === undefined ||
|
|
context.clientCapabilities?.includes(AGENT_LAUNCH_RUNTIME_CAPABILITY) === true
|
|
)
|
|
}
|
|
|
|
/**
|
|
* A client addresses a workspace by selector, but the result's `worktreeId` is an id and every
|
|
* step below the executor re-prefixes it as `id:<worktreeId>`. Resolving here is what keeps a
|
|
* caller's `id:wt-7` from reaching the runtime as `id:id:wt-7`.
|
|
*
|
|
* The launch *scope* is what is asked for, because the id below is the only thing read off it. The
|
|
* git-worktree record is the narrower answer — it does not exist for the floating workspace, so
|
|
* asking for one refused a launch this method can perfectly well run, on a workspace whose id it
|
|
* had already resolved. A folder workspace survived that only because the resolver fabricates a
|
|
* worktree row for it; the scope is the answer that is real for all three kinds.
|
|
*/
|
|
async function agentLaunchTarget(
|
|
params: AgentLaunchParams,
|
|
runtime: Pick<OrcaRuntimeService, 'showTerminalWorkspaceLaunchScope'>
|
|
): Promise<AgentLaunchTarget> {
|
|
if (params.target.kind === 'create-worktree') {
|
|
return { kind: 'create-worktree', create: { ...params.target.create } }
|
|
}
|
|
const workspace = await runtime.showTerminalWorkspaceLaunchScope(params.target.worktree)
|
|
return { kind: 'existing', worktree: workspace.id, workspacePath: workspace.path }
|
|
}
|
|
|
|
async function agentLaunchIntent(
|
|
params: AgentLaunchParams,
|
|
runtime: OrcaRuntimeService
|
|
): Promise<AgentLaunchIntent> {
|
|
return {
|
|
agent: params.agent,
|
|
target: await agentLaunchTarget(params, runtime),
|
|
...(params.prompt ? { prompt: params.prompt } : {}),
|
|
...(params.sessionOptions ? { sessionOptions: params.sessionOptions } : {}),
|
|
...(params.reuseTerminal ? { reuseTerminal: params.reuseTerminal } : {}),
|
|
// `null` means "no arguments" and must survive; only absence falls back to the settings default.
|
|
...(params.agentArgs !== undefined ? { agentArgs: params.agentArgs } : {}),
|
|
...(params.cwd ? { cwd: params.cwd } : {}),
|
|
...(params.launchSource ? { launchSource: params.launchSource } : {}),
|
|
...(params.paneKey ? { paneKey: params.paneKey } : {}),
|
|
...(params.sessionId ? { sessionId: params.sessionId } : {})
|
|
}
|
|
}
|
|
|
|
async function validateReusedTerminal(
|
|
intent: AgentLaunchIntent,
|
|
runtime: Pick<OrcaRuntimeService, 'showTerminal' | 'isTerminalRunningAgent'>
|
|
): Promise<void> {
|
|
if (!intent.reuseTerminal) {
|
|
return
|
|
}
|
|
if (intent.target.kind !== 'existing') {
|
|
throw new Error('agent_launch_reuse_requires_existing_workspace')
|
|
}
|
|
const terminal = await runtime.showTerminal(intent.reuseTerminal.handle)
|
|
if (terminal.worktreeId !== intent.target.worktree) {
|
|
throw new Error('agent_launch_terminal_worktree_mismatch')
|
|
}
|
|
if (!(await runtime.isTerminalRunningAgent(intent.reuseTerminal.handle))) {
|
|
throw new Error('agent_launch_terminal_not_running_agent')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The half before anything is created: resolve the caller's selector, then check a reused terminal.
|
|
* A throw from here proves no surface was built, which is what lets the ledger record a launch that
|
|
* failed in it as `failed` rather than `unknown`.
|
|
*/
|
|
async function resolveUnlaunchedIntent(
|
|
params: AgentLaunchParams,
|
|
runtime: OrcaRuntimeService
|
|
): Promise<AgentLaunchIntent> {
|
|
const intent = await agentLaunchIntent(params, runtime)
|
|
await validateReusedTerminal(intent, runtime)
|
|
return intent
|
|
}
|
|
|
|
async function runAgentLaunch(
|
|
intent: AgentLaunchIntent,
|
|
context: RpcContext,
|
|
attachOperationId?: string,
|
|
operationCallerKey?: string,
|
|
terminalSpawn?: TerminalSpawnDispatch
|
|
): Promise<AgentLaunchResult> {
|
|
const callerNavigationId = agentLaunchCallerNavigationId(intent.target, context)
|
|
return executeAgentLaunch({
|
|
runtime: context.runtime,
|
|
intent,
|
|
surfaces: agentLaunchSurfaceFactory(
|
|
context,
|
|
attachOperationId,
|
|
operationCallerKey,
|
|
callerNavigationId !== null,
|
|
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)
|
|
}
|
|
: {})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* The pre-ledger path, unchanged and kept for every caller that names no operation.
|
|
*
|
|
* `dedupeWorktreeCreate` is an in-memory 60-second window over the create half of a launch, keyed
|
|
* on repo plus mutation id with no caller partition, and it dies with the process. That was the
|
|
* only idempotency `agent.launch` ever had, and an existing-workspace launch never got even that.
|
|
* It is deliberately NOT a second correctness authority now: once a caller supplies `operationId`,
|
|
* durable admission encloses the whole operation and this cache is bypassed entirely, so there is
|
|
* one place that decides whether a launch runs.
|
|
*/
|
|
function runLegacyAgentLaunch(
|
|
params: AgentLaunchParams,
|
|
context: RpcContext
|
|
): Promise<AgentLaunchResult> {
|
|
const execute = async () =>
|
|
runAgentLaunch(await resolveUnlaunchedIntent(params, context.runtime), context)
|
|
if (params.target.kind === 'create-worktree' && params.target.create.clientMutationId) {
|
|
return context.runtime.dedupeWorktreeCreate(
|
|
params.target.create.repo,
|
|
`agent.launch:${params.target.create.clientMutationId}`,
|
|
execute
|
|
)
|
|
}
|
|
return execute()
|
|
}
|
|
|
|
function settleQuietly(settlement: Promise<void>): Promise<void> {
|
|
return settlement.catch((error: unknown) => {
|
|
console.warn('[agent-launch] the launch settled, its operation row did not', error)
|
|
})
|
|
}
|
|
|
|
type ActiveAgentLaunch = {
|
|
fingerprint: string
|
|
promise: Promise<AgentLaunchResult>
|
|
}
|
|
|
|
class AgentLaunchExecutionError extends Error {
|
|
constructor(
|
|
cause: unknown,
|
|
/** Decided once, by the launch that ran; a later reader cannot re-derive it from the error. */
|
|
readonly failedWithoutEffects: boolean
|
|
) {
|
|
super('agent_session_operation_unknown', { cause })
|
|
}
|
|
}
|
|
|
|
const activeAgentLaunchesByRuntime = new WeakMap<
|
|
OrcaRuntimeService,
|
|
Map<string, ActiveAgentLaunch>
|
|
>()
|
|
|
|
function activeAgentLaunchesFor(runtime: OrcaRuntimeService): Map<string, ActiveAgentLaunch> {
|
|
const existing = activeAgentLaunchesByRuntime.get(runtime)
|
|
if (existing) {
|
|
return existing
|
|
}
|
|
const active = new Map<string, ActiveAgentLaunch>()
|
|
activeAgentLaunchesByRuntime.set(runtime, active)
|
|
return active
|
|
}
|
|
|
|
async function executeReplaySafeAgentLaunch(
|
|
params: AgentLaunchParams & { operationId: string },
|
|
context: RpcContext,
|
|
fingerprint: string
|
|
): Promise<AgentLaunchResult> {
|
|
const admission = await admitAgentLaunchOperation(context, params, fingerprint)
|
|
if (admission.decision === 'refuse') {
|
|
throw new Error(admission.refusal.code)
|
|
}
|
|
if (admission.decision === 'replay') {
|
|
return admission.result
|
|
}
|
|
let intent: AgentLaunchIntent
|
|
try {
|
|
intent = await resolveUnlaunchedIntent(params, context.runtime)
|
|
} catch (error) {
|
|
await settleQuietly(admission.fail(agentLaunchFailureCode(error)))
|
|
throw error
|
|
}
|
|
const terminalSpawn = trackTerminalSpawnDispatch()
|
|
let result: AgentLaunchResult
|
|
try {
|
|
result = await runAgentLaunch(
|
|
intent,
|
|
context,
|
|
admission.attachOperationId,
|
|
admission.callerKey,
|
|
terminalSpawn
|
|
)
|
|
} catch (error) {
|
|
const failedWithoutEffects = launchFailureWithoutEffectsCode(
|
|
error,
|
|
intent.target.kind,
|
|
terminalSpawn
|
|
)
|
|
if (failedWithoutEffects) {
|
|
await settleQuietly(admission.fail(failedWithoutEffects))
|
|
}
|
|
throw new AgentLaunchExecutionError(error, failedWithoutEffects !== null)
|
|
}
|
|
// Settlement is bookkeeping; failure leaves the truthful `unknown` refusal for later retries.
|
|
await settleQuietly(admission.settle(result))
|
|
return result
|
|
}
|
|
|
|
function runReplaySafeAgentLaunch(
|
|
params: AgentLaunchParams & { operationId: string },
|
|
context: RpcContext
|
|
): Promise<AgentLaunchResult> {
|
|
const callerKey = agentLaunchOperationCallerKey(context)
|
|
const key = agentSessionOperationKey(callerKey, params.operationId)
|
|
const fingerprint = computeAgentLaunchFingerprint(params)
|
|
const activeAgentLaunches = activeAgentLaunchesFor(context.runtime)
|
|
const active = activeAgentLaunches.get(key)
|
|
if (active) {
|
|
if (active.fingerprint !== fingerprint) {
|
|
return Promise.reject(new Error('agent_session_operation_conflict'))
|
|
}
|
|
return active.promise
|
|
}
|
|
|
|
let promise: Promise<AgentLaunchResult>
|
|
promise = executeReplaySafeAgentLaunch(params, context, fingerprint).finally(() => {
|
|
if (activeAgentLaunches.get(key)?.promise === promise) {
|
|
activeAgentLaunches.delete(key)
|
|
}
|
|
})
|
|
activeAgentLaunches.set(key, { fingerprint, promise })
|
|
return promise
|
|
}
|
|
|
|
export const AGENT_LAUNCH_METHODS = [
|
|
defineMethod({
|
|
name: 'agent.launchReplay',
|
|
params: AgentLaunchReplay,
|
|
handler: async (params, context): Promise<AgentLaunchResult> => {
|
|
if (!supportsAgentLaunch(context)) {
|
|
throw new Error('agent_launch_replay_unsupported')
|
|
}
|
|
try {
|
|
return await runReplaySafeAgentLaunch(params, context)
|
|
} catch (error) {
|
|
// Nested failures cannot authorize another workspace, regardless of their message or code.
|
|
if (error instanceof AgentLaunchExecutionError) {
|
|
if (error.cause instanceof WorktreeCreateCollisionError) {
|
|
throw Object.assign(new Error(error.cause.message, { cause: error.cause }), {
|
|
code: WORKTREE_CREATE_COLLISION_CODE
|
|
})
|
|
}
|
|
if (error.failedWithoutEffects) {
|
|
throw error.cause
|
|
}
|
|
throw new Error('agent_session_operation_unknown', { cause: error.cause })
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
}),
|
|
defineMethod({
|
|
name: 'agent.launch',
|
|
params: AgentLaunch,
|
|
handler: async (params, context): Promise<AgentLaunchResult> => {
|
|
if (!supportsAgentLaunch(context)) {
|
|
throw new Error('agent_launch_unsupported')
|
|
}
|
|
if (!params.operationId) {
|
|
return runLegacyAgentLaunch(params, context)
|
|
}
|
|
return runReplaySafeAgentLaunch(
|
|
{
|
|
...params,
|
|
operationId: params.operationId
|
|
},
|
|
context
|
|
).catch((error: unknown) => {
|
|
// Preserve the original error contract for callers of the optional-identity method.
|
|
throw error instanceof AgentLaunchExecutionError ? error.cause : error
|
|
})
|
|
}
|
|
})
|
|
]
|