Files
orca/mobile/src/tasks/worktree-create-launch-operation.test.ts
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

334 lines
13 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { parseAgentSessionOperationTimestamp } from '../../../src/shared/agent-session-host-authority'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import { WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS } from './worktree-create-idempotency-policy'
import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry'
type Attempt = { method: string; params: Record<string, unknown> }
const IDEMPOTENT_CREATE_SUPPORT = { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS }
// Lets a parked replay reach its state wait before the test resumes the transport.
async function flush(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0))
}
// A transport scripted per call, recording what each attempt put on the wire. Narrower than the
// one `worktree-create-retry.test.ts` drives: the launch route needs a receipt, a coded refusal
// and one ambiguous drop, and nothing here reads the replay deadline.
function scriptedLaunchClient(
outcomes: Array<
| { launched: string }
| { created: string }
| { errorCode: string; errorMessage?: string }
| { throws: unknown; dropsConnection?: boolean }
>,
attempts: Attempt[]
): RpcClient & { reconnect: () => void } {
let call = 0
let state: ConnectionState = 'connected'
const listeners = new Set<(next: ConnectionState) => void>()
const setState = (next: ConnectionState): void => {
state = next
for (const listener of listeners) {
listener(next)
}
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The retry loop reads only these members of RpcClient; spelling out the rest would be a fake transport pretending to be a real one.
return {
reconnect: () => setState('connected'),
getState: () => state,
getLastInboundAt: () => null,
onStateChange: (listener: (next: ConnectionState) => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
sendRequest: async (method: string, params?: unknown) => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The port is untyped by construction; the suite asserts on what was sent, not on its declared shape.
attempts.push({ method, params: (params ?? {}) as Record<string, unknown> })
const outcome = outcomes[Math.min(call, outcomes.length - 1)]!
call += 1
if ('throws' in outcome) {
if (outcome.dropsConnection) {
setState('reconnecting')
}
throw outcome.throws
}
if ('errorCode' in outcome) {
return {
id: '1',
ok: false,
error: { code: outcome.errorCode, message: outcome.errorMessage ?? outcome.errorCode },
_meta: { runtimeId: 'r' }
}
}
return {
id: '1',
ok: true,
// A launch answers with a bare `worktreeId`; `worktree.create` wraps one in `worktree`.
result:
'launched' in outcome
? {
worktreeId: outcome.launched,
outcome: { kind: 'terminal', handle: 'term_x' },
receipt: {
mode: 'terminal',
preferred: 'terminal',
reason: 'user_default',
detail: 'Terminal selected'
}
}
: { worktree: { id: outcome.created } },
_meta: { runtimeId: 'r' }
}
}
} as unknown as RpcClient & { reconnect: () => void }
}
// operationId names the full launch; the host owns suffix selection and bypasses the legacy cache.
describe('agent.launch operation id', () => {
const launchOperationIds = (attempts: Attempt[]): unknown[] =>
attempts.map((attempt) => attempt.params.operationId)
const launchCandidateNames = (attempts: Attempt[]): unknown[] =>
attempts.map((attempt) => {
const target = attempt.params.target
if (!target || typeof target !== 'object' || !('create' in target)) {
return undefined
}
const create = target.create
return create && typeof create === 'object' && 'name' in create ? create.name : undefined
})
function launchRetry(args: {
client: RpcClient
attempts: Attempt[]
replay?: boolean
supported?: boolean
worktreeCreateIdempotency?: false
mintLaunchOperationId?: () => string
}): Promise<WorktreeCreateResult> {
let minted = 0
return createWorktreeWithNameRetry({
client: args.client,
baseName: 'otter',
buildParams: (name) => ({ repo: 'id:r', name }),
worktreeCreateIdempotency: args.worktreeCreateIdempotency ?? IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-launch',
agentLaunch: {
agent: 'claude',
supported: args.supported === false ? false : { replay: args.replay !== false }
},
mintLaunchOperationId: args.mintLaunchOperationId ?? (() => `op-${(minted += 1)}`)
})
}
it('names the launch and reuses that name on an ambiguous replay of the same candidate', async () => {
const attempts: Attempt[] = []
const client = scriptedLaunchClient(
[
{
throws: markRpcDeliveryUnknown(new Error('Connection interrupted')),
dropsConnection: true
},
{ launched: 'wt-launch' }
],
attempts
)
const pending = launchRetry({ client, attempts })
await flush()
client.reconnect()
await expect(pending).resolves.toEqual({ worktreeId: 'wt-launch', name: 'otter' })
expect(attempts.map((attempt) => attempt.method)).toEqual([
'agent.launchReplay',
'agent.launchReplay'
])
// The whole point: the replay is the SAME operation, so the host returns the recorded
// answer instead of launching a second agent in a second workspace.
expect(launchOperationIds(attempts)).toEqual(['op-1', 'op-1'])
})
it('reuses the launch name across a connection-migration cutover too', async () => {
const attempts: Attempt[] = []
const client = scriptedLaunchClient(
[{ throws: new LogicalClientCutoverError() }, { launched: 'wt-mig' }],
attempts
)
await expect(launchRetry({ client, attempts })).resolves.toEqual({
worktreeId: 'wt-mig',
name: 'otter'
})
expect(launchOperationIds(attempts)).toEqual(['op-1', 'op-1'])
})
it('uses launch replay support independently of worktree.create idempotency', async () => {
const attempts: Attempt[] = []
const client = scriptedLaunchClient(
[{ throws: new LogicalClientCutoverError() }, { launched: 'wt-replay' }],
attempts
)
await expect(
launchRetry({ client, attempts, worktreeCreateIdempotency: false })
).resolves.toEqual({ worktreeId: 'wt-replay', name: 'otter' })
expect(launchOperationIds(attempts)).toEqual(['op-1', 'op-1'])
})
it('bounds named timeout retries without minting another operation', async () => {
const attempts: Attempt[] = []
const error = markRpcDeliveryUnknown(new Error('Request timed out'))
const client = scriptedLaunchClient([{ throws: error }], attempts)
await expect(launchRetry({ client, attempts })).rejects.toBe(error)
expect(attempts).toHaveLength(3)
expect(launchOperationIds(attempts)).toEqual(['op-1', 'op-1', 'op-1'])
})
it('does not restart the host name search with another operation after a collision', async () => {
const attempts: Attempt[] = []
const client = scriptedLaunchClient(
[
{ errorCode: 'worktree_create_collision', errorMessage: 'already exists locally' },
{ launched: 'wt-bumped' }
],
attempts
)
await expect(launchRetry({ client, attempts })).resolves.toEqual({
error: 'already exists locally'
})
expect(launchCandidateNames(attempts)).toEqual(['otter'])
expect(launchOperationIds(attempts)).toEqual(['op-1'])
})
it('sends no launch name to a host that advertises agent.launch without the ledger', async () => {
const attempts: Attempt[] = []
const client = scriptedLaunchClient([{ launched: 'wt-plain' }], attempts)
await expect(launchRetry({ client, attempts, replay: false })).resolves.toEqual({
worktreeId: 'wt-plain',
name: 'otter'
})
expect(attempts[0]!.method).toBe('agent.launch')
expect(attempts[0]!.params.operationId).toBeUndefined()
// Byte-identical to today: the rest of the payload is untouched.
expect(attempts[0]!.params.target).toEqual({
kind: 'create-worktree',
create: { repo: 'id:r', name: 'otter', clientMutationId: 'key-launch' }
})
})
it('sends no launch name at all when the host has no agent.launch', async () => {
const attempts: Attempt[] = []
const client = scriptedLaunchClient([{ created: 'wt-legacy-route' }], attempts)
await expect(launchRetry({ client, attempts, supported: false })).resolves.toEqual({
worktreeId: 'wt-legacy-route',
name: 'otter'
})
expect(attempts[0]!.method).toBe('worktree.create')
expect(attempts[0]!.params.operationId).toBeUndefined()
})
// These codes also describe a refused nested attach or an expired receipt after creation.
it.each([
'agent_session_operation_capacity',
'agent_session_operation_invalid',
'agent_session_operation_expired'
])('preserves the operation identity when the host refuses with %s', async (code) => {
const attempts: Attempt[] = []
const client = scriptedLaunchClient([{ errorCode: code }, { launched: 'wt-unnamed' }], attempts)
await expect(launchRetry({ client, attempts })).resolves.toEqual({ error: code })
expect(attempts).toHaveLength(1)
expect(launchOperationIds(attempts)).toEqual(['op-1'])
})
// An unsettled claim is evidence that the launch may already have run.
it('surfaces an unknown operation without re-sending and without re-minting', async () => {
const attempts: Attempt[] = []
const client = scriptedLaunchClient(
[{ errorCode: 'agent_session_operation_unknown' }],
attempts
)
await expect(launchRetry({ client, attempts })).resolves.toEqual({
error: 'agent_session_operation_unknown'
})
expect(attempts).toHaveLength(1)
expect(launchOperationIds(attempts)).toEqual(['op-1'])
})
// A conflict can only come from a client that reused one id across two payloads. Re-sending it
// unnamed would hide that bug behind a create that quietly works.
it('surfaces an operation conflict rather than re-sending unnamed', async () => {
const attempts: Attempt[] = []
const client = scriptedLaunchClient(
[{ errorCode: 'agent_session_operation_conflict' }],
attempts
)
await expect(launchRetry({ client, attempts })).resolves.toEqual({
error: 'agent_session_operation_conflict'
})
expect(attempts).toHaveLength(1)
})
// The pre-existing downgrade arm: a host that refuses the method drops to `worktree.create`,
// which has no operation ledger, so the id must not ride along.
it('drops the launch name when the host refuses agent.launch itself', async () => {
const attempts: Attempt[] = []
const client = scriptedLaunchClient(
[
{ errorCode: 'method_not_found', errorMessage: 'Unknown method' },
{ created: 'wt-downgraded' }
],
attempts
)
await expect(launchRetry({ client, attempts })).resolves.toEqual({
worktreeId: 'wt-downgraded',
name: 'otter'
})
expect(attempts.map((attempt) => attempt.method)).toEqual([
'agent.launchReplay',
'worktree.create'
])
expect(launchOperationIds(attempts)).toEqual(['op-1', undefined])
})
it('does not downgrade a launch when a replacement connection refuses the method', async () => {
const attempts: Attempt[] = []
const client = scriptedLaunchClient(
[
{ throws: new LogicalClientCutoverError() },
{ errorCode: 'agent_launch_unsupported' },
{ created: 'wt-duplicate' }
],
attempts
)
await expect(launchRetry({ client, attempts })).resolves.toEqual({
error: 'agent_launch_unsupported'
})
expect(attempts.map((attempt) => attempt.method)).toEqual([
'agent.launchReplay',
'agent.launchReplay'
])
expect(launchOperationIds(attempts)).toEqual(['op-1', 'op-1'])
})
it('mints a real durable id in production', async () => {
const attempts: Attempt[] = []
const client = scriptedLaunchClient([{ launched: 'wt-real' }], attempts)
await createWorktreeWithNameRetry({
client,
baseName: 'otter',
buildParams: (name) => ({ repo: 'id:r', name }),
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
agentLaunch: { agent: 'claude', supported: { replay: true } }
})
// The host refuses anything else on the wire; parsing it back is what proves the default
// minter, not a test double, produces the shipped shape.
expect(
parseAgentSessionOperationTimestamp(String(attempts[0]!.params.operationId))
).toBeCloseTo(Date.now(), -4)
})
})