Files
orca/src/shared/agent-session-mutation-envelope.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

163 lines
5.9 KiB
TypeScript

// Admission for one mutating `agentSession.*` call.
//
// The rules themselves live in the durable ledger and the lease adjudicator;
// this is only the fixed order they are applied in, plus the payload
// fingerprint both peers derive from the same request fields. Nothing here
// re-derives who may write — that answer comes from
// `agentSessionLeaseAdmitsWriter` alone.
import { createHash } from 'node:crypto'
import type {
AgentSessionOperationDecision,
AgentSessionOperationRow
} from './agent-session-operation-ledger'
import {
agentSessionLeaseAdmitsWriter,
isAgentSessionFenceCurrent
} from './agent-session-lease-adjudication'
import type { AgentSessionLease } from './agent-session-record'
import type { AgentSessionMutationEnvelope, AgentSessionWireRefusal } from './agent-session-wire'
/**
* Stable digest over the fields that define what this call DOES. Keys are
* emitted in sorted order at every depth so two peers serializing the same
* request in different property order agree, and an undefined field is dropped
* rather than hashed as present-but-empty.
*/
export function computeAgentSessionPayloadFingerprint(input: {
method: string
sessionId: string
fields: Record<string, unknown>
}): string {
return canonicalAgentSessionDigest({
method: input.method,
sessionId: input.sessionId,
fields: input.fields
})
}
/** The same digest for an operation that has no session to name — a launch decides which surface it
* gets, so it has no session id until after it runs. */
export function canonicalAgentSessionDigest(value: Record<string, unknown>): string {
return createHash('sha256').update(canonicalize(value)).digest('hex')
}
function canonicalize(value: unknown): string {
if (value === null || typeof value !== 'object') {
return JSON.stringify(value ?? null)
}
if (Array.isArray(value)) {
return `[${value.map(canonicalize).join(',')}]`
}
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, entry]) => entry !== undefined)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalize(entry)}`).join(',')}}`
}
/**
* A retry whose payload changed is a different call wearing the same id.
* Checked BEFORE the ledger is consulted, so a refused call never leaves an
* admitted row that a later honest retry would replay as already-done.
*/
export function agentSessionFingerprintConflict(
envelope: AgentSessionMutationEnvelope,
hostFingerprint: string
): AgentSessionWireRefusal | null {
return envelope.payloadFingerprint === hostFingerprint
? null
: {
code: 'agent_session_operation_conflict',
message:
'The payload does not match the fingerprint the client declared for this operation.'
}
}
export type AgentSessionMutationAdmission =
| { decision: 'admit'; row: AgentSessionOperationRow }
/** The recorded outcome answers this call; do not run the effect again. */
| { decision: 'replay'; row: AgentSessionOperationRow }
| { decision: 'refused'; refusal: AgentSessionWireRefusal }
/**
* Fixed order: fingerprint agreement, then the ledger (so a retry replays
* before anything else can refuse it), then the lease, then the fence. Putting
* the ledger ahead of the fence is deliberate — a retry that crossed an owner
* change must still return its recorded answer instead of a stale-checkpoint
* refusal the client would then resend as a second effect.
*/
export function admitAgentSessionMutation(input: {
envelope: AgentSessionMutationEnvelope
/** Fingerprint the host computed from the request it actually received. */
hostFingerprint: string
/** Decision from the durable ledger, evaluated under `hostFingerprint`. */
ledger: AgentSessionOperationDecision
lease: AgentSessionLease
}): AgentSessionMutationAdmission {
const { envelope, lease, ledger } = input
const mismatch = agentSessionFingerprintConflict(envelope, input.hostFingerprint)
if (mismatch) {
return { decision: 'refused', refusal: mismatch }
}
if (ledger.decision === 'refused') {
return {
decision: 'refused',
refusal: {
code: ledger.code,
message: `Operation ${envelope.clientOperationId} was refused: ${ledger.code}.`
}
}
}
if (ledger.decision === 'replay') {
return { decision: 'replay', row: ledger.row }
}
const leaseRefusal = refuseUnlessWriterAdmitted(lease)
if (leaseRefusal) {
return { decision: 'refused', refusal: leaseRefusal }
}
if (
envelope.expectedRuntimeFence === null ||
!isAgentSessionFenceCurrent(lease, envelope.expectedRuntimeFence)
) {
return {
decision: 'refused',
refusal: {
code: 'agent_session_checkpoint_stale',
message: `Expected runtime fence ${envelope.expectedRuntimeFence ?? 'none'}; the session is at ${lease.runtimeFence}.`,
currentFence: lease.runtimeFence
}
}
}
return { decision: 'admit', row: ledger.row }
}
/** Why the single admission oracle said no, mapped to what the client can do
* about it. The predicate itself is never re-implemented here. */
function refuseUnlessWriterAdmitted(lease: AgentSessionLease): AgentSessionWireRefusal | null {
if (lease.runtimeKind === 'native' && agentSessionLeaseAdmitsWriter(lease)) {
return null
}
if (lease.unreconciled) {
return {
code: 'execution_owner_reconciling',
message: 'This host has not yet adjudicated the session lease.'
}
}
if (lease.handoffStage !== null) {
return {
code: 'agent_session_conflict',
message: `The session is mid-handoff (${lease.handoffStage}).`
}
}
if (lease.runtimeKind === 'tui' && agentSessionLeaseAdmitsWriter(lease)) {
return {
code: 'agent_session_conflict',
message: 'The agent terminal owns this session.'
}
}
return {
code: 'agent_session_ownership_unknown',
message: 'The session has no live owner to accept writes.'
}
}