mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
* 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
163 lines
5.9 KiB
TypeScript
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.'
|
|
}
|
|
}
|