Files
orca/src/shared/agent-launch-intent.ts
T
Brennan Benson 0bf815a480 fix(agent-launch): make a lost launch safe to retry (#21106)
* feat(agent-launch): make a lost launch safe to retry

`agent.launch` could not be retried safely. Only a create-worktree target
carrying a clientMutationId got any idempotency at all, and that was a 60s
in-memory cache with no caller partition that dies with the process; an
existing-workspace launch got none. Mobile retries a lost create by design,
so the retry is the ordinary case — and a retry past that cache meant a
second worktree and a second agent.

A caller may now name its launch with an optional `operationId` and get one
execution, the recorded answer on every replay, and a truthful refusal when
the outcome is unknown. Admission runs before the worktree selector is
resolved, so a replay answers from the record rather than re-deciding
against today's world.

The core is an atomic claim. Admission alone cannot decide who runs: two
replays both read `pending`, and settling `unknown` replaces the outcome
blind, so two serialized writes are not a compare-and-swap and both callers
execute. A conditional current-state swap now reports which caller won, and
settlement is monotone so a late `unknown` cannot erase a recorded success.

Also here: a host-computed fingerprint over the launch intent that excludes
mutable settings, the full launch result persisted so a replay returns the
receipt and warning that cannot be recomputed once settings move, and a
derived child operation id for the inner attach — the ledger key carries no
method, so forwarding the launch id would make the attach conflict with its
own launch.

Safety, not recovery. Nothing here probes for a surface a dead attempt left
behind, adopts one, or finishes an interrupted publication.

Callers that send no `operationId` keep today's behaviour exactly, which is
why the field is optional and the host advertises `agent.launch.replay.v1`:
an older host strips an unknown param and launches anyway, so a client may
only treat a retry as safe once the host has said it enforces the ledger.

* fix(agent-launch): keep an unreadable launch payload from costing the store

Review follow-ups on the replay-safety ledger.

A recorded `launch` payload must not gate row validity. `isAgentLaunchResult`
is a hand-maintained mirror of a result type later work will edit, and
`isAgentSessionOperationRow` is consulted by the store loader, where one
rejected row makes the whole file unparseable — a primary and backup that both
fail to parse raise `agent_session_store_corrupt` and the profile loses every
lease. That is the same argument the row already makes for keeping `sessionId`
required, applied to the field this PR added. The payload is now typed
`unknown`, left out of the row guard, and narrowed where it is read, so a
payload this build cannot read refuses exactly one replay.

A recorded failure now replays as the code the launch raised. Narrowing it
through the closed `agentSession.*` refusal list answered `worktree_not_found`
with `agent_session_operation_invalid` — the ledger's "your id is malformed"
signal, which invites a client to mint a fresh id when the truthful answer is
that this launch definitively did not run and the same id is safe to retry.

The persisted failure code is bounded on the way in. A code is an identifier,
but `error.message` is free text: an errno sentence carrying an absolute path
arrived here as one and was written into a file re-serialized whole on every
later operation. Bounded on write only — a length check in the row validator
would reject rows this same build wrote, which is the hazard above.

Comments: the caller key does not give one client a single namespace across
surfaces, because the structured attach this launch performs partitions under
`structuredCallerFor`; the two coincide only for a bearer-identity caller with
no paired device, which is exactly when the derived child id is load-bearing.
Recorded as a known limit that a `lost` claim cannot tell a sibling executing
now from one a restart abandoned; telling them apart needs execution-generation
tagging, which is recovery.

Tests: the store-level ablation was inert — it defined a local stand-in and
passed identically with and without the guard. It now substitutes the
non-atomic composition into the handler's own store and watches one tap create
two workspaces. Each of the four new guards was watched failing against the
unfixed code: `agent_session_store_corrupt` on reopen, `expected false to be
true` on the row guard, `agent_session_operation_invalid` in place of
`worktree_not_found`, and a 6042-character code where 128 is the bound.

* fix(agent-launch): keep live retries in one execution

* docs(agent-launch): clarify failed replay guidance
2026-09-16 18:19:02 -07:00

239 lines
10 KiB
TypeScript

/**
* What a caller asks for when it wants an agent running somewhere, independent of which surface
* asked and of whether the answer turns out to be a structured session or a terminal.
*
* Every launch surface builds one of these: the renderer's agent tabs and workspace creates,
* mobile's create sheet and new-tab button, `orchestration.workerStart`, and the CLI. The host
* resolves it once — settings default plus per-launch feasibility from
* `structured-native-chat-launch-route` — so no surface carries its own copy of that decision.
*
* The intent deliberately does NOT name a mode. A caller states what it wants to happen, not how
* to deliver it; picking structured vs terminal is the host's job and is reported back in the
* receipt rather than requested here.
*/
import type { TuiAgent } from './tui-agent'
/** How a launch's initial text reaches the agent. */
export type AgentLaunchPromptDelivery =
/** Sent as the agent's first turn once it is ready. */
| 'submit'
/** Left unsent for the user to edit and send. Historically this forced a terminal, because a
* draft lived in the TUI's input and chat only mirrored it; a structured session accepts one
* directly, so it no longer decides the route. */
| 'draft'
export type AgentLaunchPrompt = {
text: string
delivery: AgentLaunchPromptDelivery
}
/**
* Where the agent lands.
*
* `create-worktree` is part of the intent rather than a separate call the caller makes first,
* because the route cannot be settled before the workspace exists: `agentSession.createSupport`
* can only answer for a workspace the host can resolve. Splitting the two is exactly what made
* every new-worktree launch a terminal — the worktree was created agent-first, so the structured
* branch below it was unreachable.
*/
export type AgentLaunchTarget =
/** A workspace that already exists, addressed by any selector the runtime resolves. */
| { kind: 'existing'; worktree: string }
/** A worktree this launch creates. `create` is the `worktree.create` request minus its agent
* fields — the launch owns those, so a caller cannot set a startup agent behind the router. */
| { kind: 'create-worktree'; create: Readonly<Record<string, unknown>> }
/** An existing terminal the caller wants reused rather than a fresh surface. Always resolves to a
* terminal agent: a running PTY keeps its execution transport. */
export type AgentLaunchReusedTerminal = { handle: string }
export type AgentLaunchIntent = {
agent: TuiAgent
target: AgentLaunchTarget
prompt?: AgentLaunchPrompt
/** Seeded launch options, narrowed by the host to what a structured create accepts. */
sessionOptions?: Readonly<Record<string, unknown>>
reuseTerminal?: AgentLaunchReusedTerminal
}
/** The surface the host actually created. */
export type AgentLaunchOutcome =
| { kind: 'structured'; sessionId: string; handle: string }
| { kind: 'terminal'; handle: string }
/**
* What became of the launch text.
*
* An enum rather than a boolean because "not delivered" and "handed to a surface that delivers it
* out of band" are different answers, and a caller deciding whether to resend needs to tell them
* apart. A receipt may under-claim — reporting a delivery it cannot vouch for as `not-delivered` is
* a wasted resend, while over-claiming loses the text silently.
*/
export type AgentLaunchPromptOutcome = AgentLaunchPromptDisposal['outcome']
/** `messageId` hangs off the `journaled` arm rather than sitting optional beside all three: a
* producer must not be able to claim the text was committed and then not say where. */
type AgentLaunchPromptDisposal =
/** Committed to the session's transcript, which `messageId` names. */
| { outcome: 'journaled'; messageId: string }
/** Written to a PTY, whose consumption only the pane's owner observes. */
| { outcome: 'handed-to-terminal' }
/** Not delivered by this call; the caller still owns the text. */
| { outcome: 'not-delivered' }
export type AgentLaunchPromptReceipt = {
delivery: AgentLaunchPromptDelivery
} & AgentLaunchPromptDisposal
export type AgentLaunchResult = {
outcome: AgentLaunchOutcome
/** The workspace the agent runs in, resolved or created. */
worktreeId: string
/**
* The launch completed but something in it did not: a startup terminal that failed to spawn,
* untracked files that could not be copied. `worktree.create` returns this at the top level and
* mobile already surfaces it, so a launch that drops it lands the user on a workspace that is
* quietly incomplete.
*
* Top level rather than on the outcome, and deliberately the ONLY place a launch warning lives:
* it is produced by the create as often as by the surface, it applies to a structured session
* and a terminal alike, and a reader should not have to branch on `outcome.kind` to discover
* that the workspace it just opened is missing something.
*/
warning?: string
/** Why the outcome is what it is — always populated, so a downgrade is never silent. */
receipt: AgentLaunchModeReceipt
prompt?: AgentLaunchPromptReceipt
}
export type AgentLaunchMode = 'structured' | 'terminal'
/** Why a launch ran in the mode it did. `user_default` is the preference being honoured; every
* other member is a reason the preference could not be applied to this launch. */
export type AgentLaunchModeReason =
| 'user_default'
| 'remote_execution_host'
| 'reused_terminal'
| 'agent_without_structured_session'
| 'tui_launch_command'
| 'structured_sessions_unavailable'
| 'structured_support_unknown'
| 'wsl_execution_runtime'
| 'codex_on_windows'
| 'structured_unsupported_on_host'
/** Restates `WorkerStartModeReceipt` in surface-neutral terms so orchestration's receipt and a
* mobile or renderer launch report the same vocabulary. */
export type AgentLaunchModeReceipt = {
/** The mode the launch actually ran in. */
mode: AgentLaunchMode
/** The user's settings default for a new agent tab. */
preferred: AgentLaunchMode
reason: AgentLaunchModeReason
/** One sentence, always present, so a fallback is never silent. */
detail: string
}
/**
* Narrows a launch result read back from durable storage.
*
* Lives beside the type rather than in the store so the two cannot drift: a field added above and
* not checked here is a field a replay can hand back unvalidated. Every optional field is checked
* when present and ignored when absent, so a row written by an older host still reads.
*/
export function isAgentLaunchResult(value: unknown): value is AgentLaunchResult {
if (typeof value !== 'object' || value === null) {
return false
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: narrowing an unknown for field-by-field validation; every field read below is checked before use.
const result = value as Partial<AgentLaunchResult>
return (
isAgentLaunchOutcome(result.outcome) &&
typeof result.worktreeId === 'string' &&
isAgentLaunchModeReceipt(result.receipt) &&
(result.warning === undefined || typeof result.warning === 'string') &&
(result.prompt === undefined || isAgentLaunchPromptReceipt(result.prompt))
)
}
function isAgentLaunchPromptReceipt(value: unknown): value is AgentLaunchPromptReceipt {
if (typeof value !== 'object' || value === null) {
return false
}
if (!('delivery' in value) || !isAgentLaunchPromptDelivery(value.delivery)) {
return false
}
if (!('outcome' in value)) {
return false
}
return value.outcome === 'journaled'
? 'messageId' in value && typeof value.messageId === 'string'
: value.outcome === 'handed-to-terminal' || value.outcome === 'not-delivered'
}
function isAgentLaunchOutcome(value: unknown): value is AgentLaunchOutcome {
if (typeof value !== 'object' || value === null) {
return false
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the assertion claims only that the keys may be present and unknown, which is true of any object.
const outcome = value as { kind?: unknown; handle?: unknown; sessionId?: unknown }
if (typeof outcome.handle !== 'string' || outcome.handle.length === 0) {
return false
}
return outcome.kind === 'terminal'
? true
: outcome.kind === 'structured' &&
typeof outcome.sessionId === 'string' &&
outcome.sessionId.length > 0
}
function isAgentLaunchModeReceipt(value: unknown): value is AgentLaunchModeReceipt {
if (typeof value !== 'object' || value === null) {
return false
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: narrowing an unknown for field-by-field validation; every field read below is checked before use.
const receipt = value as Partial<AgentLaunchModeReceipt>
return (
(receipt.mode === 'structured' || receipt.mode === 'terminal') &&
(receipt.preferred === 'structured' || receipt.preferred === 'terminal') &&
typeof receipt.reason === 'string' &&
typeof receipt.detail === 'string'
)
}
function isAgentLaunchPromptDelivery(value: unknown): value is AgentLaunchPromptDelivery {
return value === 'submit' || value === 'draft'
}
export function agentLaunchTargetIsCreate(
target: AgentLaunchTarget
): target is Extract<AgentLaunchTarget, { kind: 'create-worktree' }> {
return target.kind === 'create-worktree'
}
/** The agent fields a create payload must not carry: the launch owns placement, and a caller that
* sets one of these would route itself around the host's decision. */
export const AGENT_LAUNCH_RESERVED_CREATE_FIELDS = [
'startupAgent',
'startupCommand',
'startupPrompt',
'startupDraft',
'startupLaunchConfig',
'startupEnv',
'startupCommandDelivery'
] as const
/** Strips the reserved agent fields from a create payload. Callers migrating from
* `worktree.create` pass their existing params; this keeps a stale `startupAgent` from
* re-creating the agent-first path the router exists to replace. */
export function withoutReservedAgentCreateFields<Create extends Readonly<Record<string, unknown>>>(
create: Create
): Create {
const stripped: Record<string, unknown> = { ...create }
for (const field of AGENT_LAUNCH_RESERVED_CREATE_FIELDS) {
delete stripped[field]
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every reserved field is optional on a create payload, so dropping them leaves the caller's own shape.
return stripped as Create
}