mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 08:02:38 +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
207 lines
8.2 KiB
TypeScript
207 lines
8.2 KiB
TypeScript
// How one send outcome changes the outbox.
|
|
//
|
|
// The sibling of `reconcileStructuredAgentSessionOutbox`: that one folds the
|
|
// journal's view of a submission into the queue, this one folds the answer to a
|
|
// single `agentSession.send`. Both write the same state, so they live together
|
|
// and speak the same vocabulary. Pure on purpose — the hook that calls this owns
|
|
// the refs, the React state and the storage write, and nothing else decides an
|
|
// entry's state.
|
|
|
|
import type { AgentSessionMutationResult, AgentSessionSendResult } from './agent-session-wire'
|
|
import {
|
|
dispatchRejectionReasonIsInternal,
|
|
dispatchRejectionWasTransportWriteFailure
|
|
} from './structured-agent-session-dispatch-rejection'
|
|
import {
|
|
classifyStructuredAgentSessionSendFailure,
|
|
requeueStructuredAgentSessionSendRefusal,
|
|
type StructuredAgentSessionOutboxEntry
|
|
} from './structured-agent-session-outbox'
|
|
|
|
export type StructuredAgentSessionSendDisposition = {
|
|
entries: StructuredAgentSessionOutboxEntry[]
|
|
error: string | null
|
|
/** The entry the queue is stuck on, or null when nothing blocks it. Always the
|
|
* next value, never "unchanged": the caller assigns it verbatim. */
|
|
blockedClientMessageId: string | null
|
|
/** A rejected result arrived before the journal snapshot; Retry must rotate this id. */
|
|
retryWithFreshClientMessageId: string | null
|
|
}
|
|
|
|
type SendDispositionInput = {
|
|
entries: readonly StructuredAgentSessionOutboxEntry[]
|
|
entry: StructuredAgentSessionOutboxEntry
|
|
blockedClientMessageId: string | null
|
|
}
|
|
|
|
function replaceEntryState(
|
|
input: SendDispositionInput,
|
|
state: StructuredAgentSessionOutboxEntry['state']
|
|
): StructuredAgentSessionOutboxEntry[] {
|
|
return input.entries.map((candidate) =>
|
|
candidate.clientMessageId === input.entry.clientMessageId ? { ...candidate, state } : candidate
|
|
)
|
|
}
|
|
|
|
function dropEntry(input: SendDispositionInput): StructuredAgentSessionOutboxEntry[] {
|
|
return input.entries.filter(
|
|
(candidate) => candidate.clientMessageId !== input.entry.clientMessageId
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The user force-retried a host-confirmed `unknown` and got the same submission
|
|
* back. That is now the only answer such a retry can get: `unknown` means the
|
|
* host cannot tell whether the provider has the message, and no reason it
|
|
* records ever makes a second delivery safe. Parking the entry would offer a
|
|
* Retry that does nothing in front of a queue nothing can drain, so it leaves
|
|
* the outbox. Nothing is lost from the conversation: the durable submission row
|
|
* already renders the message.
|
|
*
|
|
* A `rejected` submission takes the other path — the message provably did not
|
|
* happen, so Retry rotates the id and sends it as a genuinely new message.
|
|
*/
|
|
function refusedRedelivery(
|
|
entry: StructuredAgentSessionOutboxEntry,
|
|
submission: AgentSessionSendResult['submission']
|
|
): boolean {
|
|
return (
|
|
entry.retryAfterUnknownSubmittedAt !== null &&
|
|
submission.dispatchState === 'unknown' &&
|
|
submission.submittedAt === entry.retryAfterUnknownSubmittedAt
|
|
)
|
|
}
|
|
|
|
/**
|
|
* What to put on screen for a rejection.
|
|
*
|
|
* A content rejection's reason is the provider explaining itself, so it is shown
|
|
* verbatim — "Claude does not support the image type .bmp" is the whole answer and
|
|
* a generic string would throw it away. A transport rejection's reason is an
|
|
* internal marker; printing it put `provider_write_failed: broken pipe` in front of
|
|
* users, which names nothing they can act on. That case gets copy that says what
|
|
* happened and that the message is safe to send again — which it is, because the
|
|
* frame provably never left, so a resend cannot duplicate.
|
|
*
|
|
* The null default claims no cause, because at that point we know none: all it
|
|
* asserts is the one thing every rejection shares.
|
|
*
|
|
* Exported because a client without an outbox needs the same copy: the rule about
|
|
* which reasons a person may read is a property of the reason, not of the queue.
|
|
*/
|
|
export function structuredAgentSessionRejectionNotice(reason: string | null): string {
|
|
if (reason === null) {
|
|
return 'Message was not sent.'
|
|
}
|
|
if (dispatchRejectionWasTransportWriteFailure(reason)) {
|
|
return "Couldn't reach the agent. Your message was not sent — Retry to send it again."
|
|
}
|
|
// Any other reason we minted is an internal cause with no user-facing meaning;
|
|
// only a provider's own explanation is worth reading verbatim.
|
|
return dispatchRejectionReasonIsInternal(reason)
|
|
? 'Orca could not send your message — Retry to send it again.'
|
|
: reason
|
|
}
|
|
|
|
export function disposeStructuredAgentSessionSendResult(
|
|
input: SendDispositionInput & {
|
|
result: AgentSessionMutationResult<AgentSessionSendResult>
|
|
createOperationId: () => string
|
|
}
|
|
): StructuredAgentSessionSendDisposition {
|
|
const result = input.result
|
|
if (!result.ok) {
|
|
const refusedIndex = input.entries.findIndex(
|
|
(candidate) => candidate.clientMessageId === input.entry.clientMessageId
|
|
)
|
|
const entries = input.entries.map((candidate) =>
|
|
candidate.clientMessageId === input.entry.clientMessageId
|
|
? requeueStructuredAgentSessionSendRefusal(
|
|
candidate,
|
|
result.refusal.code,
|
|
input.createOperationId,
|
|
input.entry.lastAttemptAt !== null
|
|
)
|
|
: candidate
|
|
)
|
|
return {
|
|
entries,
|
|
error: result.refusal.message,
|
|
// Read back by index rather than from the input: a refusal can rotate the id, and the
|
|
// refused entry is not always the head now that an admitted one no longer holds the queue.
|
|
blockedClientMessageId: entries[refusedIndex]?.clientMessageId ?? null,
|
|
retryWithFreshClientMessageId: null
|
|
}
|
|
}
|
|
const submission = result.value.submission
|
|
if (refusedRedelivery(input.entry, submission)) {
|
|
return {
|
|
entries: dropEntry(input),
|
|
error: 'Message delivery is unconfirmed and Orca will not send it again',
|
|
blockedClientMessageId: input.blockedClientMessageId,
|
|
retryWithFreshClientMessageId: null
|
|
}
|
|
}
|
|
if (submission.dispatchState === 'accepted') {
|
|
return {
|
|
entries: dropEntry(input),
|
|
error: null,
|
|
blockedClientMessageId: input.blockedClientMessageId,
|
|
retryWithFreshClientMessageId: null
|
|
}
|
|
}
|
|
if (submission.dispatchState === 'rejected') {
|
|
return {
|
|
entries: replaceEntryState(input, 'queued'),
|
|
error: structuredAgentSessionRejectionNotice(submission.reason),
|
|
blockedClientMessageId: input.entry.clientMessageId,
|
|
retryWithFreshClientMessageId: input.entry.clientMessageId
|
|
}
|
|
}
|
|
if (submission.dispatchState === 'unknown' && submission.recovered) {
|
|
return {
|
|
entries: input.entries.map((candidate) =>
|
|
candidate.clientMessageId === input.entry.clientMessageId
|
|
? { ...candidate, state: 'unconfirmed', retryAfterUnknownSubmittedAt: -1 }
|
|
: candidate
|
|
),
|
|
error: null,
|
|
blockedClientMessageId: input.blockedClientMessageId,
|
|
retryWithFreshClientMessageId: null
|
|
}
|
|
}
|
|
// `pending` is the host saying the message was written and is awaiting the
|
|
// provider's acknowledgement, which cannot arrive until the turn ahead of it
|
|
// ends. That is not doubt, and keeping order is no longer the reason to hold
|
|
// the entry -- the host fixed the order when it wrote the row. It stays
|
|
// because a `pending` can still settle `rejected` or `unknown`, and only the
|
|
// entry carries the retry state that answer needs.
|
|
return {
|
|
entries: replaceEntryState(
|
|
input,
|
|
submission.dispatchState === 'unknown' ? 'unconfirmed' : 'dispatching'
|
|
),
|
|
error: null,
|
|
blockedClientMessageId: input.blockedClientMessageId,
|
|
retryWithFreshClientMessageId: null
|
|
}
|
|
}
|
|
|
|
export function disposeStructuredAgentSessionSendFailure(
|
|
input: SendDispositionInput & {
|
|
cause: unknown
|
|
isDeliveryUnknown: (error: unknown) => boolean
|
|
}
|
|
): StructuredAgentSessionSendDisposition {
|
|
const failure = classifyStructuredAgentSessionSendFailure(input.cause, input.isDeliveryUnknown)
|
|
const deliveryUnknown = failure === 'delivery-unknown'
|
|
return {
|
|
entries: replaceEntryState(input, deliveryUnknown ? 'unconfirmed' : 'queued'),
|
|
error: deliveryUnknown ? 'Message delivery is unconfirmed' : String(input.cause),
|
|
blockedClientMessageId: deliveryUnknown
|
|
? input.blockedClientMessageId
|
|
: input.entry.clientMessageId,
|
|
retryWithFreshClientMessageId: null
|
|
}
|
|
}
|