Files
orca/src/shared/agent-launch-operation.ts
T
Brennan Benson 39a01f9c0b feat(agent-launch): create a folder workspace and start its agent in one host operation (#26083)
* feat(agent-launch): accept a create-folder-workspace target

agent.launch can now create a folder workspace and start its agent there,
the same operation it already runs for a new git worktree. The host
creates the folder workspace (metadata only, no startup terminal), resolves
it the way an existing target is resolved, then settles the mode and builds
the surface as for any existing folder workspace.

Hosts advertise agent.launch.create-folder-workspace.v1 so a client offers
the target only to a host that accepts it.

* fix(agent-launch): authorize, classify and navigate folder creates like their create method

A create-folder-workspace launch could do three things its two-call
equivalent never did:

- A phone could create folder workspaces. The mobile allowlist admits
  agent.launch but not folderWorkspace.create, and is checked by method
  name only. A create target now needs the authorization of the create
  method it stands for, read from the same allowlist, and a phone is
  refused with agent_launch_target_forbidden before admission, so no
  ledger row and no create. create-worktree maps to worktree.create,
  which phones already may call, so nothing changes for it.
- A create refused before anything was stored (group gone, folder
  missing, SSH host unreachable) replayed as
  agent_session_operation_unknown, and a retry under the same operation
  id was refused until the row expired. runtime.createFolderWorkspace now
  raises those refusals as FolderWorkspaceCreateRefusedError, with the
  same message text, and the launch records it as failed for any target.
  A failure after the create still replays unknown.
- A paired caller's folder create activated the new chat on the host
  window and did not select the caller's own tab. Only create-worktree,
  whose host activation runs setup, keeps the host navigation now; a
  folder create navigates like an existing workspace.

Tests cover the phone refusal, replay of a success, a refused create
settling failed and replaying that failure, an after-create failure
staying unknown, an SSH connection reaching the agent's view mode, paired
navigation, and an older host refusing the target at parse.

* fix(agent-launch): replay a refused folder create with its whole path

A folder create refused before anything was stored is recorded as the
launch's failure, and a retry under the same operation id answers from
that record. The record went through the 128-character cap every launch
failure code gets, and folder refusals carry the folder's absolute path,
so a retry could show the user a path cut short: a different folder.

The refusal is now recorded whole when it fits a path as long as Linux's
PATH_MAX after its code. Past that, which no folder a stat accepts
reaches, only the code before the path is kept, so the replay degrades to
the bare code instead of a partial path.

Also: the controller test asserts the exact refusal message, the
create-folder-workspace capability comment says a phone needs its own
gate, and the forbidden-target code says why it is not `forbidden`.

* test(folder-workspace): type the controller test's store stub without an assertion

The changed-code quality gate refuses the `as never` cast on the stub
store. It is now a RuntimeStore whose unreached members throw.

* test(agent-launch): run the folder-create launch tests in the Node runtime project

The operationId replay tests open the real SQLite record store, so the
file belongs with the other ledger-backed agent.launch tests.

* test(agent-launch): pin that a paired folder create's terminal stays off the host window

The folder-create test now asserts surfaceOwner: false on the terminal a
paired caller's launch creates, rather than matching any options. The
caller-selection header now says only an in-process worktree create keeps
worktree.create's navigation.

* test(agent-launch): drop the per-connection view-mode expectation main removed
2026-10-09 01:23:06 -07:00

107 lines
5.4 KiB
TypeScript

/**
* Replay safety for `agent.launch`.
*
* A launch creates a workspace, an agent, or both. When its reply is lost, the caller cannot tell
* "the host never saw it" from "the host ran it and the answer went missing" — and mobile retries a
* lost create by design (`worktree-create-retry.ts`), so the retry is the ordinary case, not the
* edge. Retrying without an operation id is how one tap becomes two agents in two workspaces.
*
* The fix is the ledger `agentSession.*` already runs on: the caller names the operation once, the
* host records that name durably before doing anything, and every later arrival of that name gets
* the recorded answer rather than a second agent. What this module adds is the launch-shaped parts
* of that contract — the digest over what a launch DOES, and the child id its inner attach reserves
* under.
*
* This is safety, not recovery. Nothing here probes for a surface a previous attempt may have left
* behind, adopts one, or finishes an interrupted publication; an operation whose outcome is unknown
* stays unknown and is refused.
*/
import { canonicalAgentSessionDigest } from './agent-session-mutation-envelope'
import { parseAgentSessionOperationTimestamp } from './agent-session-host-authority'
/**
* The launch fields that decide what the call does. The operation id itself is excluded — it names
* the operation, it is not part of it — and so is every mutable host setting: the route a launch
* takes depends on the user's default surface, and folding that in would make an honest retry
* conflict merely because the setting moved between the two attempts.
*/
export type AgentLaunchFingerprintInput = {
agent: string
target:
| { kind: 'existing'; worktree: string }
| { kind: 'create-worktree'; create: Readonly<Record<string, unknown>> }
| { kind: 'create-folder-workspace'; create: Readonly<Record<string, unknown>> }
prompt?: { text: string; delivery: string }
sessionOptions?: Readonly<Record<string, string>>
reuseTerminal?: { handle: string }
/** In: a launch carrying `--model opus` is a different operation from one without, so a retry
* that changed them must conflict rather than replay the first answer. `null` is a value here,
* not an absence — "explicitly no arguments" differs from "use the settings default". */
agentArgs?: string | null
/** In: it decides both where the agent runs and, through the `tui_launch_command` downgrade,
* which surface it gets. Two launches differing only in `cwd` are genuinely two operations. */
cwd?: string
/** In: it is baked into the pane's PTY env and names the tab the caller placed, so a retry that
* reserved another pane must conflict rather than replay a key its placement cannot find. */
paneKey?: string
/** In: a retry that minted another session is a different request, since replaying would answer
* with a conversation this caller did not mint. */
sessionId?: string
/**
* `launchSource` is deliberately absent, and this is the reasoned exclusion rather than an
* oversight: it is telemetry, so two launches differing only in which button produced them do the
* same thing, and folding it in would make an honest retry that got re-attributed conflict with
* its own original. That is the rule the mutable host settings above are excluded under — the
* digest covers what the call DOES — and the cost of leaving it out is only that a replay reports
* the first attempt's attribution, which is the truthful answer: one launch happened.
*
* `placement` and `presentation` are absent by the same rule: they say where the tab sits and whose
* view moves, not what runs, so a retry that moved them replays the first answer.
*/
}
/** Host-computed, never accepted from the caller: a digest a client supplies is a digest a buggy
* client can make agree with anything. */
export function computeAgentLaunchFingerprint(input: AgentLaunchFingerprintInput): string {
return canonicalAgentSessionDigest({
method: 'agent.launch',
agent: input.agent,
target: input.target,
prompt: input.prompt,
sessionOptions: input.sessionOptions,
reuseTerminal: input.reuseTerminal,
agentArgs: input.agentArgs,
cwd: input.cwd,
// Absent keys are dropped by the canonical form, so every digest without one is unchanged.
paneKey: input.paneKey,
sessionId: input.sessionId
})
}
const OPERATION_ID_ENTROPY_LENGTH = 32
/**
* The id the launch's inner `agentSession.attach` reserves under.
*
* The ledger keys a row on `(callerKey, operationId)` with no method in it, and a structured launch
* reserves in that same ledger under the same caller. Forwarding the launch's own id would make the
* attach meet the launch's row, disagree with its fingerprint, and refuse a conflict before
* anything was created. Derived rather than random so the child of a given launch is always the
* same id — a launch is claimed once, and a claim that could name a different child each time would
* be ownership in name only.
*
* The launch's timestamp is kept so the child ages out on the same schedule as its parent.
*/
export function deriveAgentLaunchChildOperationId(operationId: string): string | null {
const timestamp = parseAgentSessionOperationTimestamp(operationId)
if (timestamp === null) {
return null
}
const entropy = canonicalAgentSessionDigest({
child: 'agent.launch:attach',
operationId
}).slice(0, OPERATION_ID_ENTROPY_LENGTH)
return `${timestamp}-${entropy}`
}