Files
orca/src/shared/agent-session-write-failure.ts
T
Brennan Benson 4d65146f2d fix(native-chat): Stop is saved before it acts, and a late or repeated Stop does nothing (#27076)
* feat(native-chat): add a chat-owned command receipt store

Adds the agent_session_command_receipts table and an insert-if-absent
receipt write that runs inside a command's own journal transaction. A
receipt records the command's identity, canonical hash, status and a
pointer to the row holding its result; it dies with its chat record.
Nothing uses it yet: the existing operation ledger stays authoritative.

* fix(native-chat): key command receipts like today's ledger ids

Rows are keyed by caller and operation id; a send looks across callers and
other methods look up only their caller's row, matching the existing ledger.
Bad rows and newer table shapes read as unreadable instead of throwing.

* fix(native-chat): order receipt lookups by insertion and key by operation id

Global lookups now take rows in insertion order, so a read and an insert
agree on which receipt answers an id regardless of the host clock. The
primary key leads with the operation id, so its own index serves both
lookups.

* fix(native-chat): accept sends through their own receipt

Send (direct, queued, steer) and /compact no longer commit a separate
ledger reservation before the message is saved. Their command receipt is
inserted-if-absent in the same transaction as the submission or queued
draft; a repeated id replays from it, a changed request conflicts.
Operation ids are checked for syntax only, so age, clock skew, pruning
and the reservation's own storage can no longer refuse a send.

The ledger success row they already co-committed stays as a temporary
insert-if-absent write so families not yet switched keep seeing these
ids; it is never read on this path.

* fix(native-chat): answer a send's receipt one way, share /compact's id space with Send, wake delivery once

- After its commit, a send whose publication fails answers through the same receipt replay a retry takes, so one id gets one answer; the in-memory copy and journal reload are gone.
- /compact ids are global like Send's: a retry under a new connection replays instead of needing a second acknowledgment write.
- The receipt route calls its runner directly; the runner wakes delivery when a new message row commits.
- The compatibility ledger row expires from host time; each temporary site is labelled.
- Receipt read errors and post-commit failures are logged.

* test(native-chat): expect receipt answers where tests assumed the send ledger

Restart-continuation tests now model the case Send cannot vouch for (saved but
not loaded into memory) and keep it unconfirmed; a later throw after the message
is in the chat answers continued, as a resend would. Drop /compact's unreachable
legacy replay branch and fix stale comments.

* test: run the queued tests that open the journal database in the Node runtime project

* fix(native-chat): queued-message actions and prompt answers accept through their own receipt

Send Now, Delete, Resume and prompt answers move onto the command-receipt route
Send uses: the receipt is inserted with the command's own write. A no-op answers
only once its receipt commits (otherwise outcome unknown), and a prompt answer's
replay reads the exact revision it was accepted at.

* fix(native-chat): replay a queued action from what its receipt recorded

Send Now's acknowledgement points at the submission it handed off; a question
answer's receipt keeps the accepted resolution; a no-op that cannot save its
receipt answers outcome unknown and holds nothing in memory; classified refusals
pass through.

* docs(native-chat): receipt comments say what a replay answers from

* fix(native-chat): Stop saves first, captures its target, and holds queued messages

Stop accepts through its own receipt: one transaction saves the Stop record
(target turn, agent generation, cutoff), returns unsent messages to the queue as
paused cards, and writes the receipt; only then does it interrupt the captured
target. A failed save leaves the agent running and says so. A late Stop is a
recorded no-op, and a retry is answered from the receipt.

* fix(native-chat): Stop round 1: late Stop is a no-op, saved-then-failed refuses, hold only what the Stop did not stop

- A Stop naming a turn that already ended is an accepted no-op.
- A card's Cancel saves its receipt with the dismissal row.
- A Stop saved but not carried out is refused as stopFailed ("Couldn't stop <agent>. Try again.") and its receipt is rewritten as rejected.
- A Stop during a start is saved first, then aborts that start; the abort-before-admission path is removed.
- The message the Stop stops is withdrawn as before; messages behind it are held as paused cards. Non-text messages are still withdrawn (TEMPORARY).
- Cleanups: one receipt commit path, dead fields and helpers removed, the returned-to-queue text names the agent.

* fix(native-chat): Stop round 2: narrow to receipts and save-first; message fate as on main

- Stop's withdrawal of queued sends is main's own rows (rejected, cancelled), written in the same
  transaction as the Stop event and its receipt; a failed save withdraws nothing.
- A Stop arriving during a start aborts that wait on arrival, as on main (admits-now guard), then
  captures its target once in the session lane. The out-of-lane admission is removed.
- A committed receipt is never rewritten: a failure after the save answers from the receipt and
  writes the existing 'Cancellation was not confirmed.' note. 'Couldn't stop <agent>' only when
  the save fails; a card Cancel that can't be saved says the question or approval wasn't cancelled.
- A Stop naming a turn a rewind removed is late (an accepted no-op).
- Removes the held-card settlement and returnedToQueue: what happens to a stopped message stays as
  on main.

* test(native-chat): name the Stop row comparison for what it drops

* fix(native-chat): Stop round 3: a committed Stop always acts; notes only for unconfirmed interrupts

- A Stop whose write throws after its receipt committed still interrupts (never 'Couldn't stop').
- An interrupt that worked answers stopped even when only its note fails to write.
- A strict card interrupt that throws after its receipt committed gets the 'Cancellation was not
  confirmed.' note, through one shared helper with the turn path.
- A target-less Stop stands while the main agent works, so a turn that opened while its save waited
  (a send made before the Stop) is interrupted, as on main.
- A card Cancel that can't be saved says 'Couldn't cancel this question or approval. Try again.'

* fix(native-chat): Stop round 4: name the turn the Stop actually stops

- The Stop event's turn is resolved when its write runs (the named turn, else the live turn), so a
  turn that opened while the save waited is the one marked interrupted and shown stopping.
- The unconfirmed note is keyed by that turn; tests cover it and its own write failing.
- Whether a Stop withdrew anything comes from its capture, so a throw after the commit can't flip it.
- A Stop counts as saved anyway only when its own write committed the receipt.

* fix(native-chat): a new chat's first send gets the Stop-aware operation receipt

Main's create path (from #26332) hands performSend the founding receipt; the
Stop changes widened the turn context's receipt to track its commit. Wrap it
with the existing observedReceipt.

* fix(native-chat): a background task the agent won't stop still says not done

A background-task Stop now saves its receipt before it asks the agent. A
refusal after that save (task still running, or a timeout / lost connection)
was being replaced by the receipt's replay, so the person never heard the Stop
failed. That refusal is now the first answer, as on main; the receipt stays
saved and a retry of the same id is answered from it without asking again.

* test(acp): a Grok Stop naming an ended turn leaves the unechoed follow-up alone

The late-Stop rule makes a Stop naming a turn that has ended an accepted no-op
for every agent. The Claude case was rewritten with it; this is Grok's.

* test(native-chat): Stop tests exercise what they name after the late-Stop rule

- The "leaves the child alone" Stop now reaches the provider (handed over, a
  turn with no row yet); the at-rest refusal case was a duplicate of the late
  no-op test and is removed.
- The adapter comment and two test names say the gap rule covers a named turn
  with no journal row yet.
- The Grok late-Stop test checks no status row and that the follow-up completes.
- Name the two refusals refusalAnswers passes through.

* fix(native-chat): a background-task Stop answers its acceptance and notes what happened

A background-task Stop is saved before it asks the agent. The answer is
that saved acceptance: a stopped task answers cancelled:true as before
(the phone keeps the row "stopping" on it), and anything else answers
cancelled:false. A retry answers from the receipt without asking again.

What the agent then says comes back as a note keyed by the Stop's own id,
written through the existing Stop-note path, and never as a refusal:
- still running: "The background task wasn't stopped." (or "The
  background tasks weren't stopped." for Stop all), the new status-row
  failure kind backgroundTaskNotStopped;
- timed out or lost the agent: "Cancellation was not confirmed."
The note is the thread's, since tasks outlive their turn. A note that
cannot be written is logged and changes no answer.

The background-task body moves out of performCancel into the targeted
cancel, and the refusalAnswers flag and its receipt are deleted.

* test(runtime): the Codex strip's Stop notes a command it could not stop

A background-task Stop now answers its saved acceptance and says what happened in a note: "The background task wasn't stopped." when the command still runs, "Cancellation was not confirmed." when Codex was lost.

* test(native-chat): keep the Stop tests that pin behaviour, cut the rest

Merges the receipt, save-first, failed-after-save and background-task Stop
tests into one flow file; drops unit and per-branch tests already covered
by a flow test.
2026-10-13 15:30:55 -07:00

209 lines
8.3 KiB
TypeScript

// A chat write that did not happen, as a fact: what the person was doing, and what the host said.
// Saved with a queued message, so it holds only what stays true after a reload; the words are
// chosen from it when it is shown (`agent-session-refusal-notice.ts`).
import type { AgentSessionAccountKind } from './agent-session-availability'
import {
readAgentSessionRefusalDetails,
type AgentSessionRefusalReason
} from './agent-session-refusal-details'
import type { AgentSessionRewindReason } from './agent-session-rewind'
import type { AgentSessionArgumentProblem } from './agent-session-argument-problem'
import type { TuiAgent } from './tui-agent'
import type { CodexInstallationProblem } from './codex-cli-installation'
import {
isAgentSessionWireRefusalCode,
readAgentSessionRefusalReference,
type AgentSessionOwnerVerdict,
type AgentSessionRefusalReference,
type AgentSessionWireRefusal,
type AgentSessionWireRefusalCode
} from './agent-session-wire-refusals'
/** What the person was doing, which decides what the notice says did not happen. `send` keeps
* the message behind a Retry control; `composer-send` puts it back in the composer, as the phone
* does. `read-history` is opening the chat to show its history, which writes nothing. */
export type AgentSessionWriteKind =
| 'read-history'
| 'send'
| 'composer-send'
| 'stop'
| 'stop-task'
| 'stop-tasks'
| 'answer'
| 'option'
| 'command'
/** A /clear or /compact: refused while the agent works, in words of its own. */
| 'clear'
| 'compact'
| 'goal'
/** The kind of write an `agentSession.*` call stands for. */
export function agentSessionWriteKindForMethod(
fingerprintMethod: string,
fields: Record<string, unknown>
): AgentSessionWriteKind {
if (fingerprintMethod === 'agentSession.send') {
return 'send'
}
if (fingerprintMethod === 'agentSession.cancel') {
// A background-task stop never asked the agent to stop.
if (fields.scope !== 'background-tasks') {
return 'stop'
}
return typeof fields.taskId === 'string' ? 'stop-task' : 'stop-tasks'
}
if (fingerprintMethod.startsWith('agentSession.respondTo')) {
return 'answer'
}
if (fingerprintMethod === 'agentSession.setOption') {
return 'option'
}
if (fingerprintMethod === 'agentSession.threadGoal') {
return 'goal'
}
if (
fingerprintMethod === 'agentSession.conversationCommand' &&
(fields.command === 'clear' || fields.command === 'compact')
) {
return fields.command
}
return 'command'
}
/** What a saved refusal keeps beside its reason: facts about the refused operation that stay true
* after a reload. Never the fence, revision or resolution, which move, nor any provider text. */
type DurableRefusalFacts = {
agent_session_operation_invalid: {
rewindReason?: AgentSessionRewindReason
account?: AgentSessionAccountKind
argumentProblem?: AgentSessionArgumentProblem
agent?: TuiAgent
codexInstallation?: CodexInstallationProblem
}
agent_session_operation_unknown: { rewindReason?: AgentSessionRewindReason }
/** A snapshot from when it was refused; see `agentSessionOwnerVerdictAllowsFreshOperationId`. */
agent_session_ownership_unknown: { ownerVerdict?: AgentSessionOwnerVerdict }
}
const DURABLE_FACT_KEYS: Partial<Record<AgentSessionWireRefusalCode, readonly string[]>> = {
agent_session_operation_invalid: [
'rewindReason',
'account',
'argumentProblem',
'agent',
'codexInstallation'
],
agent_session_operation_unknown: ['rewindReason'],
agent_session_ownership_unknown: ['ownerVerdict']
} satisfies { [C in keyof DurableRefusalFacts]: readonly (keyof DurableRefusalFacts[C])[] }
export type AgentSessionWriteRefusalDetails<C extends AgentSessionWireRefusalCode> = {
reason?: AgentSessionRefusalReason<C>
} & (C extends keyof DurableRefusalFacts ? DurableRefusalFacts[C] : Record<never, never>)
/** A refused write: its code, and what the host said about it that outlives the reply. */
export type AgentSessionWriteRefusal = {
[C in AgentSessionWireRefusalCode]: {
kind: 'refused'
code: C
/** Absent from older hosts and from entries saved before refusals carried a reason. */
details?: AgentSessionWriteRefusalDetails<C>
}
}[AgentSessionWireRefusalCode]
/** Why a write did not happen, or that nothing proves it did not. */
export type AgentSessionWriteFailure =
| AgentSessionWriteRefusal
/** The request failed without a refusal before the host ran it, so nothing is known about why. */
| { kind: 'failed' }
/** The request failed where the host may already have run it (a timeout, a lost connection, an
* error inside the method). */
| { kind: 'unconfirmed' }
/** The refusal as a write keeps it. Details are read against the code, so a reason or verdict
* another build added is dropped and the code's own words stand. */
function agentSessionWriteRefusal(
code: AgentSessionWireRefusalCode,
details: unknown
): AgentSessionWriteRefusal {
const durable: readonly string[] = ['reason', ...(DURABLE_FACT_KEYS[code] ?? [])]
const kept = Object.fromEntries(
Object.entries(readAgentSessionRefusalDetails(code, details) ?? {}).filter(([key]) =>
durable.includes(key)
)
)
const refusal = {
kind: 'refused',
code,
...(Object.keys(kept).length > 0 ? { details: kept } : {})
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `kept` holds only a reason and facts read against `code` and allowed for it above.
return refusal as AgentSessionWriteRefusal
}
export function agentSessionRefusalFailure(
refusal: Pick<AgentSessionWireRefusal, 'code' | 'details'>
): AgentSessionWriteRefusal {
// A newer host can send a code this client has never heard of; its details mean nothing here.
return isAgentSessionWireRefusalCode(refusal.code)
? agentSessionWriteRefusal(refusal.code, refusal.details)
: { kind: 'refused', code: refusal.code }
}
/** A request that threw, from the RPC error code the host answered with (undefined when none came
* back). Only a host that turned it away before running the method proves the write did not
* happen. */
export function agentSessionRpcErrorFailure(code: string | undefined): AgentSessionWriteFailure {
if (code === 'method_not_found' || code === 'method_not_supported') {
return { kind: 'refused', code: 'structured_agent_session_unsupported' }
}
return code === 'invalid_argument' || code === 'unauthorized'
? { kind: 'refused', code: 'agent_session_operation_invalid' }
: { kind: 'unconfirmed' }
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
/** The refusal a failed request's error carries in its data. Takes both shapes a client meets: an
* RPC call's thrown error, whose payload is on `response.error`, and the payload a stream hands
* its error callback. Undefined from an older host, or for a failure that was not a refusal. */
export function readAgentSessionErrorRefusal(
error: unknown
): AgentSessionRefusalReference | undefined {
const payload =
isRecord(error) && isRecord(error.response) && isRecord(error.response.error)
? error.response.error
: error
const data = isRecord(payload) ? payload.data : undefined
return isRecord(data) ? readAgentSessionRefusalReference(data.refusal) : undefined
}
/** What to say about a request that threw: the host's refusal when its error carried one, else
* what the RPC error code proves. Words only: whether the write may have happened stays the
* caller's own classification. */
export function agentSessionThrownFailure(
error: unknown,
rpcCode: string | undefined
): AgentSessionWriteFailure {
const refusal = readAgentSessionErrorRefusal(error)
return refusal ? agentSessionRefusalFailure(refusal) : agentSessionRpcErrorFailure(rpcCode)
}
/** A saved failure, or undefined when it is not one this build wrote. */
export function parseAgentSessionWriteFailure(
value: unknown
): AgentSessionWriteFailure | undefined {
if (typeof value !== 'object' || value === null || !('kind' in value)) {
return undefined
}
if (value.kind === 'failed') {
return { kind: 'failed' }
}
return value.kind === 'refused' && 'code' in value && isAgentSessionWireRefusalCode(value.code)
? agentSessionWriteRefusal(value.code, 'details' in value ? value.details : undefined)
: undefined
}