mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
* fix(native-chat): settle a structured send on admission, not on the provider echo Sending a message in structured native chat raised "Message delivery is unconfirmed." with a Retry button on a message that had in fact been delivered. Measured across 14 days of local journals: 44 of 173 delivered sends (25.4%) tripped it. The dispatch path wrote the message to the provider, then waited a fixed 10s for the provider to echo the message's uuid back. That echo is emitted when the provider STARTS the turn, so a message queued behind a running turn cannot be echoed until that turn ends. Echo latency is bounded by the previous turn's duration, which is unbounded -- one send took 105 minutes. The 10s constant sat at the p75 of real echo latency, with the slowest clean send at 9.76s, a margin of 0.24s. No constant can work: the wait was measuring the wrong event. The false banner was not cosmetic. It invited a Retry, and Retry bypassed the operation ledger to redeliver. One message reached the model five times through that path. Dispatch now returns as soon as the transport write completes and writes no dispatch row; the submission stays `pending`, a neutral state, and the provider's echo settles it `accepted` through the late-settlement channel whenever the turn ahead of it ends. Delivery doubt is reachable only from process facts -- a refused write, a dead child, a dead host -- never from elapsed time. Retry re-delivers only where the recorded reason proves the message never reached the provider. The list is deliberately fail-closed: refusing a legitimate retry costs the user a re-type, while allowing an illegitimate one sends the model a second copy of their message. A refused entry now leaves the outbox with an explicit notice instead of parking at the head, where it would have wedged every message queued behind it. The send-response classification moves to a pure module beside the existing outbox reconciler, so both writers of an entry's state now live together and the decision is unit-testable rather than reachable only through the hook. Scope and known gaps: - Codex carries the same 10s stopwatch. It has no late-settlement channel, matches waiters by queue order rather than identity, and has no waiter lifecycle at all, so there was no safe subset to land here. A marker constant records the debt and deletes itself when that lands. - A message refused re-delivery loses its standing delivery notice and leaves only a transient error line. A passive "waiting to be accepted" affordance is the follow-up. - The restart reconciler that would decide a dead child or a dead host on evidence rather than refusing them is fully written and has never had a production caller. Wiring it is the next change, and it removes the re-type cost above. * fix(native-chat): harden structured dispatch settlement * fix(native-chat): preserve dispatch recovery evidence * fix(native-chat): preserve pending send compatibility * fix(native-chat): satisfy native import audit * fix(native-chat): bound legacy send settlement --------- Co-authored-by: Merge Sim <sim@local>
139 lines
5.0 KiB
TypeScript
139 lines
5.0 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 {
|
|
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
|
|
}
|
|
|
|
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 and got the same observation back, so the host will not
|
|
* put this message on the wire again — it cannot prove doing so would be a first
|
|
* delivery. 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.
|
|
*/
|
|
function refusedRedelivery(
|
|
entry: StructuredAgentSessionOutboxEntry,
|
|
submission: AgentSessionSendResult['submission']
|
|
): boolean {
|
|
return (
|
|
entry.retryAfterUnknownSubmittedAt !== null &&
|
|
submission.dispatchState === 'unknown' &&
|
|
submission.submittedAt === entry.retryAfterUnknownSubmittedAt
|
|
)
|
|
}
|
|
|
|
export function disposeStructuredAgentSessionSendResult(
|
|
input: SendDispositionInput & {
|
|
result: AgentSessionMutationResult<AgentSessionSendResult>
|
|
createOperationId: () => string
|
|
}
|
|
): StructuredAgentSessionSendDisposition {
|
|
const result = input.result
|
|
if (!result.ok) {
|
|
const entries = input.entries.map((candidate) =>
|
|
candidate.clientMessageId === input.entry.clientMessageId
|
|
? requeueStructuredAgentSessionSendRefusal(
|
|
candidate,
|
|
result.refusal.code,
|
|
input.createOperationId
|
|
)
|
|
: candidate
|
|
)
|
|
return {
|
|
entries,
|
|
error: result.refusal.message,
|
|
blockedClientMessageId: entries[0]?.clientMessageId ?? 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
|
|
}
|
|
}
|
|
if (submission.dispatchState === 'accepted') {
|
|
return {
|
|
entries: dropEntry(input),
|
|
error: null,
|
|
blockedClientMessageId: input.blockedClientMessageId
|
|
}
|
|
}
|
|
if (submission.dispatchState === 'rejected') {
|
|
return {
|
|
entries: replaceEntryState(input, 'queued'),
|
|
error: submission.reason ?? 'Message was not accepted',
|
|
blockedClientMessageId: input.entry.clientMessageId
|
|
}
|
|
}
|
|
// `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: the entry stays `dispatching` and the queue behind
|
|
// it keeps its order until the echo settles it.
|
|
return {
|
|
entries: replaceEntryState(
|
|
input,
|
|
submission.dispatchState === 'unknown' ? 'unconfirmed' : 'dispatching'
|
|
),
|
|
error: null,
|
|
blockedClientMessageId: input.blockedClientMessageId
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|