Files
orca/src/shared/agent-session-mutation-envelope.ts
T
Brennan Benson b99d28e32f A resent chat message gets its recorded answer, never an early refusal or a made-up record (#25158)
* fix(native-chat): a resent send id gets its recorded answer, never an early refusal or a made-up record

The host now looks a resent send id up before preparing the session. A row
that settled refused answers with its refusal before the chat is opened. A
resend whose chat cannot be opened or made ready answers unknown instead of a
refusal. A /clear in flight refuses only ids the ledger does not hold.

A send row now records the journal epoch it was admitted into. An unsettled
row with nothing written in that same epoch runs for the first time; under a
later epoch the host answers unknown instead of reconstructing a submission
it never had. The host advertises agent-session.send-answers-proof.v1.

* test(native-chat): pass the ledger row to the thread-goal rerun check

* fix(native-chat): a send's answer commits with its write, and a resend is answered before any write

A send (and /compact) settles its ledger row `succeeded` in the same SQLite transaction as the
submission or queued draft that accepts it, so a row still `pending` proves nothing was written and
a resend runs it for the first time. The unknown-before-run mark and the per-row journal epoch go.

A resent id is answered from its row and the journal before preparation starts an agent and before
any write transaction: a recorded refusal with nothing opened; otherwise the conversation is opened
(no agent start for a send) and replayed, and a conversation that will not open answers unknown.

* fix(native-chat): a ledger refusal is answered first, and a /clear refuses only a send's first run

An id the ledger refuses (expired, conflict, invalid, capacity) is answered as admission would,
with no journal read, preparation or write, so a closed chat or a read-only store answers it too.
A re-read after the replay open that comes back refused returns that refusal.

Whether a /clear is in flight is read when a send arrives and applied in the send's preparation
for a first run only: an id the ledger holds by the send's turn, including one whose earlier
attempt was queued ahead of the clear, is answered from its record. MutationPlan makes
settlesWithWrite and settledOutcome exclusive; the capability text no longer promises a refused
id never sends.

* docs(native-chat): say what a replay's preparation does for every plan
2026-10-04 14:01:29 -07:00

168 lines
6.5 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 } from './agent-session-lease-adjudication'
import type { AgentSessionLease } from './agent-session-record'
import { terminalOwnerRefusalMessage } from './agent-session-legacy-handoff-lease'
import type { AgentSessionMutationEnvelope, AgentSessionWireRefusal } from './agent-session-wire'
import { refuse } from './agent-session-wire-refusals'
/**
* 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
: refuse(
'agent_session_operation_conflict',
{ reason: 'fingerprintMismatch' },
'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.
*
* `expectedRuntimeFence` is not checked: each write names its own target (a
* turn, an item revision, an epoch) or is last-writer-wins, so an owner restart
* the client has not seen yet refuses nothing. Older hosts still check it.
*/
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
/** A write to the conversation, not to the provider child: a send is accepted and a Stop
* withdraws queued messages whoever owns the child, so the lease does not admit them. */
conversationWrite?: true
}): 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: agentSessionLedgerRefusal(envelope, ledger) }
}
if (ledger.decision === 'replay') {
return { decision: 'replay', row: ledger.row }
}
if (input.conversationWrite) {
return { decision: 'admit', row: ledger.row }
}
const leaseRefusal = refuseUnlessWriterAdmitted(lease)
if (leaseRefusal) {
return { decision: 'refused', refusal: leaseRefusal }
}
return { decision: 'admit', row: ledger.row }
}
/** What the ledger's own refusal of an operation id answers. */
export function agentSessionLedgerRefusal(
envelope: Pick<AgentSessionMutationEnvelope, 'clientOperationId'>,
ledger: Extract<AgentSessionOperationDecision, { decision: 'refused' }>
): AgentSessionWireRefusal {
return refuse(
ledger.code,
ledger.details,
`Operation ${envelope.clientOperationId} was refused: ${ledger.code}.`
)
}
/** 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 (agentSessionLeaseAdmitsWriter(lease)) {
return null
}
if (lease.unreconciled) {
return refuse(
'execution_owner_reconciling',
{ reason: 'hostReconciling' },
'This host has not yet adjudicated the session lease.'
)
}
if (lease.handoffStage !== null) {
if (lease.claimStatus === 'conflicted') {
return refuse(
'agent_session_conflict',
{ reason: 'claimConflicted' },
terminalOwnerRefusalMessage(lease)
)
}
return lease.handoffStage === 'new-owner-proving'
? refuse('agent_session_conflict', { reason: 'chatStarting' }, 'The chat is still starting.')
: refuse(
'agent_session_conflict',
{ reason: 'ownerUnproven' },
"Orca has not yet confirmed that this chat's previous agent process stopped. Reopen the chat to check again."
)
}
return refuse(
'agent_session_ownership_unknown',
{ reason: 'noLiveOwner' },
'The session has no live owner to accept writes.'
)
}