Files
orca/src/shared/rpc-contract/agent-launch-params.ts
T
Brennan Benson abc8386e14 fix(mobile): name a create's launch so a lost reply cannot build two workspaces (#21137)
* fix(mobile): name a create's launch so a lost reply cannot build two workspaces

`agent.launch` admits a caller-supplied `operationId` through a durable ledger, so
exactly one execution happens and every replay returns the recorded answer. No client
sent one, so the machinery was inert and the original defect was still live: mobile
retries a lost create by design, and a retried launch built a second agent in a second
workspace.

Mobile now mints an operation id per create candidate and sends it whenever the host
advertises `agent.launch.replay.v1`.

The invariant is one operation per candidate. `computeAgentLaunchFingerprint` folds
`target` whole, so the workspace name is inside the fingerprint; carrying one id across
a name-collision bump would meet its own row under a differing fingerprint and refuse
`agent_session_operation_conflict`, failing the create outright on the second candidate.
The id is therefore minted beside `clientMutationId` at the top of each loop iteration
and reused verbatim by every retry arm inside that candidate — never re-minted, since a
new id is a new operation.

Admission runs ahead of every effect, so `_invalid` / `_expired` / `_capacity` prove
nothing launched: those re-send the same candidate unnamed rather than let bookkeeping
fail a create the host would have performed. `_unknown` is the one refusal that is not
safe to re-send, and it surfaces.

Also corrects a false comment: the legacy path caches the whole launch under
`clientMutationId`, so inside its 60s window a replay adds neither a workspace nor a
surface, and outside it adds both — not "a second surface, never a second workspace".

* fix(mobile): preserve launch identity on refusals

* fix(mobile): use launch receipts to authorize replay

* test: move mobile launch replay coverage outside node project

* fix(mobile): enforce replay-safe launch delivery at the host

* test: run mobile launch contracts in mobile checks

* test: cover mobile launch contract workflow dependencies
2026-09-17 10:06:11 -07:00

75 lines
3.2 KiB
TypeScript

/**
* The wire shape of `agent.launch`, mirroring `AgentLaunchIntent`.
*
* A caller states WHERE the agent lands and WHAT it should say; it never names a mode. There is
* deliberately no `structured` / `terminal` field and no startup-agent field on the create
* payload — the host decides, and `withoutReservedAgentCreateFields` strips a stale one out of a
* payload a caller migrated over from `worktree.create`.
*
* Shared rather than main-side because every field resolves to a shared schema: a remote client
* that sends this method needs `RpcSendParams<'agent.launch'>` to exist, and a method missing from
* the catalog can only be sent through the raw request port.
*/
import { z } from 'zod'
import { parseAgentSessionOperationTimestamp } from '../agent-session-host-authority'
import { isTuiAgent } from '../tui-agent-config'
import type { TuiAgent } from '../tui-agent'
import { WorktreeCreate } from './worktree-create-params'
const LaunchAgent = z
.unknown()
.superRefine((value, ctx) => {
if (!isTuiAgent(value)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Unknown TUI agent' })
}
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the superRefine above rejects anything isTuiAgent refuses, so the transform only ever runs on a TuiAgent.
.transform((value): TuiAgent => value as TuiAgent)
export const AgentLaunch = z.object({
agent: LaunchAgent,
/**
* Names this launch so a retry replays instead of starting a second agent.
*
* Optional, and optional forever: shipped mobile sends none, and a host that required one would
* refuse every live client. Its absence is not a silent downgrade to a weaker guarantee — it is
* the caller declining the guarantee, and the host must never mint an id on a caller's behalf
* after an ambiguous launch, because an id minted on the retry is a brand new operation.
*/
operationId: z
.string()
.refine(
(value) => parseAgentSessionOperationTimestamp(value) !== null,
'Malformed launch operation id'
)
.optional(),
target: z.discriminatedUnion('kind', [
z.object({
kind: z.literal('existing'),
/** Any selector the runtime resolves, the same as every other worktree-addressed method. */
worktree: z.string().min(1, 'Missing worktree selector')
}),
z.object({
kind: z.literal('create-worktree'),
/** The `worktree.create` request verbatim, so a caller migrating to this method keeps its
* existing payload; the agent fields in it are stripped rather than honoured. */
create: WorktreeCreate
})
]),
prompt: z
.object({
text: z.string(),
delivery: z.enum(['submit', 'draft'])
})
.optional(),
/** Only the seedable string options a structured create accepts; a terminal launch ignores them. */
sessionOptions: z.record(z.string(), z.string()).optional(),
reuseTerminal: z.object({ handle: z.string().min(1, 'Missing terminal handle') }).optional()
})
export type AgentLaunchParams = z.infer<typeof AgentLaunch>
// A distinct method prevents an older receiver from silently dropping the replay requirement.
export const AgentLaunchReplay = AgentLaunch.required({ operationId: true })