Files
orca/src/shared/structured-agent-session-outbox.ts
T
Brennan Benson f02d09c1ba fix(native-chat): deliver queued messages while the chat pane is hidden (#20659)
* fix(native-chat): deliver queued messages while the chat pane is hidden

With two or more messages queued, everything behind the head waited on the
user's attention. The drain only inspected the head and returned unless it was
`queued`, and a `pending` send deliberately leaves the head `dispatching`. An
entry only leaves that state through the journal subscription, which is torn
down when the pane goes hidden -- and a worktree switch hides it.

Two changes, both needed:

- One shared admission rule now says what the queue does next, and the drain
  takes its `dispatch`: the first `queued` entry, skipping entries the host has
  already acknowledged. It still stops at an `unconfirmed` entry or a refusal
  the user must act on. Order is not the outbox's to keep -- the host appends
  the submission inside the per-session serialize chain before dispatching, so
  journal order is arrival order. Holding the tail bought no ordering guarantee
  and cost delivery. Single-flight still keeps sends strictly sequential, and a
  launch prompt's in-flight send, which runs outside it, still stops the queue.
- The journal subscription now stays open while a session has undelivered outbox
  entries, published from the `writeOutbox` choke point. The subscription's
  retaining hold is what also keeps the host from evicting the session 15s after
  the last turn, which would otherwise turn the stall into a blocked head
  refusing `agent_session_ownership_unknown`.

An acknowledged entry stays in the outbox rather than retiring on `pending`: the
text is safe either way, since the journal upserts a render item from the
submission's own body, but a `pending` can still settle `rejected` or `unknown`
and only the entry carries the retry state that answer needs.

Follow-on corrections the head-only assumption had hidden:

- Single-flight is released where the disposition is applied, not in a later
  `.finally`. That state write is what re-runs the drain, so the release has to
  land first or the queue has no trigger left.
- One ref now holds the in-flight entry's id instead of a bare boolean, and the
  reconcile effect keys its release on that, not on the head, so a journal update
  about the head can no longer discard a still-unsettled send of the tail.
- A refusal blocks the entry it refused, read back by index so a rotated id is
  preserved.
- The automatic unknown probe and the Retry affordance both read the blocker at
  whatever index it sits, the Retry through the same shared rule as the drain.

`raises no delivery notice for a stuck message behind a healthy head` asserted
that a message behind an admitted head raises nothing, because a Retry could not
act on it. It now can, so that guard is rewritten to assert the notice names
that entry and its Retry sends that entry.

* fix(native-chat): resume outbox after journal admission and scope subscriptions

* test: name outbox send request by domain role
2026-09-16 11:26:53 -07:00

224 lines
7.4 KiB
TypeScript

import type { AgentJournalMessageItem, AgentJournalSubmission } from './agent-session-journal-types'
import { agentSessionRefusalOperationState } from './agent-session-refusal-retry'
import type { AgentSessionWireRefusalCode } from './agent-session-wire'
import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation'
import { DISPATCH_REJECTED_CANCELLED } from './structured-agent-session-dispatch-rejection'
export type StructuredAgentSessionOutboxState = 'queued' | 'dispatching' | 'unconfirmed'
export type StructuredAgentSessionOutboxEntry = {
clientMessageId: string
sessionId: string
body: AgentJournalMessageItem
previewUris: string[]
state: StructuredAgentSessionOutboxState
queuedAt: number
lastAttemptAt: number | null
retryAfterUnknownSubmittedAt: number | null
source?: 'launch'
}
export type StructuredAgentSessionAttachment = {
path: string
previewUri: string
}
export function structuredAgentSessionSendBody(
text: string,
attachments: readonly StructuredAgentSessionAttachment[]
): AgentJournalMessageItem {
return {
kind: 'message',
role: 'user',
blocks: [
...(text.trim().length > 0 ? [{ type: 'text' as const, text: text.trimEnd() }] : []),
...attachments.map((attachment) => ({ type: 'image-ref' as const, path: attachment.path }))
]
}
}
export function createStructuredAgentSessionOutboxEntry(args: {
clientMessageId: string
sessionId: string
text: string
attachments: readonly StructuredAgentSessionAttachment[]
queuedAt: number
}): StructuredAgentSessionOutboxEntry {
return {
clientMessageId: args.clientMessageId,
sessionId: args.sessionId,
body: structuredAgentSessionSendBody(args.text, args.attachments),
previewUris: args.attachments.map((attachment) => attachment.previewUri),
state: 'queued',
queuedAt: args.queuedAt,
lastAttemptAt: null,
retryAfterUnknownSubmittedAt: null
}
}
export function updateStructuredAgentSessionOutboxEntry(
entries: readonly StructuredAgentSessionOutboxEntry[],
id: string,
update: (entry: StructuredAgentSessionOutboxEntry) => StructuredAgentSessionOutboxEntry | null
): StructuredAgentSessionOutboxEntry[] {
return entries.flatMap((entry) => {
if (entry.clientMessageId !== id) {
return [entry]
}
const next = update(entry)
return next ? [next] : []
})
}
export function requeueStructuredAgentSessionSendRefusal(
entry: StructuredAgentSessionOutboxEntry,
code: AgentSessionWireRefusalCode,
createOperationId: () => string,
retainOperationId = false
): StructuredAgentSessionOutboxEntry {
const refusalState = agentSessionRefusalOperationState('agentSession.send', code)
if (
refusalState !== 'settled-rejected' ||
retainOperationId ||
entry.state === 'unconfirmed' ||
entry.retryAfterUnknownSubmittedAt !== null
) {
return { ...entry, state: 'queued' }
}
return {
...entry,
clientMessageId: createOperationId(),
state: 'queued',
lastAttemptAt: null,
retryAfterUnknownSubmittedAt: null
}
}
export function reconcileStructuredAgentSessionOutbox(
entries: readonly StructuredAgentSessionOutboxEntry[],
submissions: readonly AgentJournalSubmission[]
): StructuredAgentSessionOutboxEntry[] {
const settled = new Map(submissions.map((entry) => [entry.clientMessageId, entry]))
return entries.flatMap((entry) => {
const submission = settled.get(entry.clientMessageId)
if (submission?.dispatchState === 'accepted') {
return []
}
if (
submission?.dispatchState === 'rejected' &&
submission.reason === DISPATCH_REJECTED_CANCELLED
) {
return []
}
if (submission?.dispatchState === 'pending') {
return entry.state === 'dispatching' ? [entry] : [{ ...entry, state: 'dispatching' as const }]
}
if (
submission?.dispatchState === 'unknown' &&
entry.retryAfterUnknownSubmittedAt !== -1 &&
entry.retryAfterUnknownSubmittedAt !== submission.submittedAt
) {
return [{ ...entry, state: 'unconfirmed' as const }]
}
return [entry]
})
}
export type StructuredAgentSessionOutboxAdmission =
| { state: 'dispatch'; entry: StructuredAgentSessionOutboxEntry }
| { state: 'blocked'; entry: StructuredAgentSessionOutboxEntry }
| { state: 'idle'; entry: null }
/**
* What the queue does next. The drain and the Retry affordance both read it, so neither can
* disagree with the other about which entry is holding the queue.
*
* A `dispatching` entry is not a barrier: the host appended its journal row inside the
* per-session serialize chain before dispatching, so nothing behind it can overtake it, and
* waiting for its echo costs delivery of everything queued behind it. An `unconfirmed` entry,
* or one the user must act on, is a barrier — sending past either would reorder around a
* message that may yet land.
*/
export function admitStructuredAgentSessionOutboxEntry(
entries: readonly StructuredAgentSessionOutboxEntry[],
blockedClientMessageId: string | null
): StructuredAgentSessionOutboxAdmission {
for (const entry of entries) {
if (entry.state === 'unconfirmed' || entry.clientMessageId === blockedClientMessageId) {
return { state: 'blocked', entry }
}
if (entry.state === 'queued') {
return { state: 'dispatch', entry }
}
}
return { state: 'idle', entry: null }
}
export function parseStructuredAgentSessionOutboxEntry(
value: unknown,
sessionId: string
): StructuredAgentSessionOutboxEntry | null {
if (typeof value !== 'object' || value === null) {
return null
}
const entry = value as Partial<StructuredAgentSessionOutboxEntry>
const body = entry.body
if (
entry.sessionId !== sessionId ||
typeof entry.clientMessageId !== 'string' ||
typeof entry.queuedAt !== 'number' ||
!body ||
body.kind !== 'message' ||
body.role !== 'user' ||
!Array.isArray(body.blocks) ||
!Array.isArray(entry.previewUris) ||
!entry.previewUris.every((uri) => typeof uri === 'string') ||
!['queued', 'dispatching', 'unconfirmed'].includes(entry.state ?? '')
) {
return null
}
return {
clientMessageId: entry.clientMessageId,
sessionId,
body,
previewUris: entry.previewUris,
state: entry.state as StructuredAgentSessionOutboxState,
queuedAt: entry.queuedAt,
lastAttemptAt: typeof entry.lastAttemptAt === 'number' ? entry.lastAttemptAt : null,
retryAfterUnknownSubmittedAt:
typeof entry.retryAfterUnknownSubmittedAt === 'number'
? entry.retryAfterUnknownSubmittedAt
: null,
...(entry.source === 'launch' ? { source: 'launch' as const } : {})
}
}
export function structuredAgentSessionSendRequest(
entry: StructuredAgentSessionOutboxEntry,
expectedRuntimeFence: number
): Record<string, unknown> {
const fields = { body: entry.body }
return {
envelope: {
sessionId: entry.sessionId,
clientOperationId: entry.clientMessageId,
expectedRuntimeFence,
payloadFingerprint: structuredAgentSessionPayloadFingerprint({
method: 'agentSession.send',
sessionId: entry.sessionId,
fields
})
},
...fields
}
}
export type StructuredAgentSessionSendFailure = 'delivery-unknown' | 'failed'
export function classifyStructuredAgentSessionSendFailure(
error: unknown,
isDeliveryUnknown: (error: unknown) => boolean
): StructuredAgentSessionSendFailure {
return isDeliveryUnknown(error) ? 'delivery-unknown' : 'failed'
}