mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
* 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
75 lines
3.2 KiB
TypeScript
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 })
|