mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 18:17:36 +00:00
* feat(agent.launch): a launch can name chat or terminal, and the CLI gets --surface chat|terminal agent.launch gains an optional `surface` field, read by hosts advertising agent.launch.surface.v1. `terminal` always runs the agent's CLI; `chat` runs a chat even when the user's default is a terminal, and one the host can prove it cannot open is refused with a typed no-effects error before anything is created, so a retry answers with the same refusal. The pick is part of the replay fingerprint. `orca agent launch` and `orca worktree create --agent` accept --surface chat|terminal and refuse it on a host that would drop it, including the legacy worktree.create fallback. * test(agent.launch): pin the surface pick beside the other pre-start failures * fix(agent.launch): settle requested surfaces from resolved workspace facts * fix(agent.launch): describe fallback boundaries and correct test types
110 lines
5.6 KiB
TypeScript
110 lines
5.6 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
|
|
/** In: unlike the user's default surface, an explicit pick is what the call asks to run. */
|
|
surface?: 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,
|
|
surface: input.surface
|
|
})
|
|
}
|
|
|
|
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}`
|
|
}
|