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