mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 16:03:03 +00:00
* fix(native-chat): keep a message accepted before a quit or crash as a held card A send the host accepted while the agent was still starting, and never handed over, was rejected unseen at quit or at the next open after a crash. The next open now keeps a person's message (typed, or a launch's first prompt) as a waiting card at the head of the queue, held until Resume, Send now, Edit or Delete; quit no longer rejects it. Each submission records its source so a restart knows which leftovers to keep. A direct send's replay answers from its own record, never the queued arm. Cards shown without the queue capability hide Turn off queueing and the steer chord. * fix(native-chat): keep an unsent message as a held card at every close, not only a restart The host now keeps a person's message it accepted and never handed over with one rule wherever it can no longer hand it over: a quit or crash (settled at the next open) and a close of the chat (tab close, worktree teardown, orchestration stop). - The hold is card state: a new per-card hold_reason 'kept', published as the existing pausedReason, instead of a fake host_instance value. host_instance means the owner again, and the pause clause, adoption filter and /clear carry special cases are gone. A kept card holds the cards behind it until the person sends, edits or deletes it; a send_failed card still does not. - A card hand-off rejected by a restart or a close returns as a kept card (rejectedDraftSettlement), so a person's next message can never release it. - One hold function, parameterized by cause (hostRestarted / chatClosed), replaces the close path's plain rejection. - The phone shows published cards and per-card holds whatever the queue capability says; only queueing a new send stays gated. - source gains 'dispatch' for the orchestration preamble (still rejected); an unknown source is kept as written and never takes the legacy rule. - Kept cards from earlier settlements stay ahead of a batch's new ones. * test(native-chat): the dispatch preamble records its source * fix(native-chat): skip a kept card like a failed one, and make a quit leave the queue as a crash does - A kept card is held on its own, as a send_failed one is: the queue sends the cards behind it, and the "a message ahead needs attention" caption no longer appears behind it (desktop and phone). - Quit disposes the queue's drain together with delivery, so it mints no hand-off that only the next process could settle. - Only a Send the person asked for (origin client) that a restart or close cut short returns kept; the queue's own hand-off returns where it stood, under the restart's pause, as on main. - Comments that said only a capable host gets cards or the card actions now say the capability gates only queueing a new send. * refactor(native-chat): one dispose gate in the queue drain's step * test(native-chat): the downgrade test names the older build's /clear exception * fix(native-chat): re-check the drain's quit gate right before it appends a hand-off A drain step already past its first check when quit begins no longer makes a hand-off. Adds regression tests for that, for Send now on an ordinary card cut short by a quit, and for the open-time repair deriving kept from the hand-off's origin; fixes the pause and settlement comments that called a kept card one the queue never passes. * fix(native-chat): a card held on its own starts no restart pause for the others After a second restart a kept (or send_failed) card is another process's, but it waits for its own Send, so it no longer pauses every other card under "Queue paused because Orca restarted". * fix(native-chat): record on a kept send which card holds it, so no trace survives an Edit or Delete The rejection row of a send the host kept as a card now names that card (`keptAsQueuedMessageId`), in the same transaction that writes the card, and the fold publishes it on the submission. The shared projection draws such a send only as its card: once the card is sent, edited or deleted, neither the send nor the sending desktop's local copy of it shows. * chore(native-chat): leave the unused submission schema as main has it No client parses published submissions with it (they arrive as typed frames, and the host's history pages carry the field, as the Edit/Delete test reads); listing the field put the file over its line budget. * fix(native-chat): retire a kept send's local copy instead of only hiding it The outbox reconcile and the send disposition drop an entry whose submission the host rejected as kept as a card, as they already do for a Stop's withdrawal, so the copy never comes back as "Not sent / Retry" once the submission falls out of the loaded page. The projection reads the reconciled outbox, so its separate filter goes. * test(native-chat): move the queued-message rig's scripted provider into its own fixture The rig fixture grew past the 300-line limit once main's changes merged in. * fix(native-chat): hide a kept send by its own record, not by its card still existing The transcript hid a rejected send while a queued card held it under its id, so the card's Edit or Delete brought back a "Not sent" row. It now reads the send's own keptAsQueuedMessageId, which the host records with the rejection, and the live card list is no longer threaded to the transcript or the delivery notices. * refactor(native-chat): move a sent message's row writes into their own journal collaborator The journal store went past its line limit once main's ledger receipt joined this branch's transaction hook. The submission and dispatch-transition writes, and what commits in their transaction, now live in JournalSubmissionWriter; the store's methods delegate to it unchanged. * fix(native-chat): list a kept send's card id in the submission schema The schema drops keys it does not list, so a reader that kept a parsed submission would lose keptAsQueuedMessageId and source, both persisted with the row. The submission schema and the failure fact it shares with item bodies move to their own modules, with room for both fields. * test(native-chat): match the transcript and outbox hook signatures main and the swap changed * test(native-chat): import the journal types once in the queue-delivery test * fix(mobile): a resend the host kept as a card shows no error and returns no text The host answers a resend of a message it kept as a card with that message's rejected submission, marked keptAsQueuedMessageId. The phone read it as any rejection: "Message not sent" and the text back in the composer, while the card showed the same text. It now answers like a queued send, as the desktop's send disposition does: the id is spent, no error, and the card holds the text. * test(native-chat): pin that quit's first step stops the queue's hand-off Quit now stops delivery, the queue drain included, at its first step (stopDelivery), before teardown drains recovery. The drain-step quit test runs from that step as well as from the flush. * test(native-chat): give cards their source and store unknown sources as another build would #25078 made a card's source required, so the tests that insert a card pass the person's. The hold's unknown-kind and unreadable-source cases now rewrite the stored row the way a newer build would leave it, instead of casting a type. * refactor(native-chat): stop delivery and the queue drain in one line at quit Main's #25159 left the host at its line limit; quit's stop now disposes both in one expression instead of a block. * test(native-chat): hold the drain step without reading the call stack Bun formats a method's stack frame without its class ("at step"), so the quit test's caller check never matched, the step was never held, and both cases timed out once CI ran Vitest on Bun (#25840). Only the drain step heals owed queue bookkeeping, so the hold needs no caller check.
339 lines
13 KiB
TypeScript
339 lines
13 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 { AgentJournalSubmission } from './agent-session-journal-types'
|
|
import type { AgentSessionMutationResult, AgentSessionSendResult } from './agent-session-wire'
|
|
import {
|
|
agentSessionWriteNoticeEnglish,
|
|
agentSessionWriteNoticeParts,
|
|
agentSessionWriteNotDoneParts
|
|
} from './agent-session-refusal-notice'
|
|
import type { AgentSessionWriteNoticePart } from './agent-session-write-notice-copy'
|
|
import {
|
|
agentSessionRefusalFailure,
|
|
type AgentSessionWriteRefusal
|
|
} from './agent-session-write-failure'
|
|
import type { AgentSessionFailureFact } from './agent-session-failure'
|
|
import type { AgentSessionFailureWordsContext } from './agent-session-failure-words'
|
|
import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection'
|
|
import {
|
|
classifyStructuredAgentSessionSendFailure,
|
|
requeueStructuredAgentSessionSendRefusal,
|
|
structuredAgentSessionRejectedFailure,
|
|
type StructuredAgentSessionAttemptFailure,
|
|
type StructuredAgentSessionOutboxEntry
|
|
} from './structured-agent-session-outbox'
|
|
|
|
export type StructuredAgentSessionSendDisposition = {
|
|
entries: StructuredAgentSessionOutboxEntry[]
|
|
/** Only for an outcome with no entry left to carry it; a kept entry holds its own failure. */
|
|
error: AgentSessionWriteNoticePart[] | null
|
|
}
|
|
|
|
/** A message this client couldn't store to send; the composer's draft or the row's Retry still
|
|
* has it. */
|
|
export const STRUCTURED_AGENT_SESSION_OUTBOX_NOT_SAVED: readonly AgentSessionWriteNoticePart[] = [
|
|
'messageNotSaved',
|
|
'tryAgain'
|
|
]
|
|
|
|
type SendDispositionInput = {
|
|
entries: readonly StructuredAgentSessionOutboxEntry[]
|
|
entry: StructuredAgentSessionOutboxEntry
|
|
}
|
|
|
|
function replaceEntryState(
|
|
input: SendDispositionInput,
|
|
state: StructuredAgentSessionOutboxEntry['state'],
|
|
lastFailure?: StructuredAgentSessionAttemptFailure
|
|
): StructuredAgentSessionOutboxEntry[] {
|
|
return input.entries.map((candidate) =>
|
|
candidate.clientMessageId === input.entry.clientMessageId
|
|
? withLastFailure({ ...candidate, state }, lastFailure)
|
|
: candidate
|
|
)
|
|
}
|
|
|
|
function withLastFailure(
|
|
entry: StructuredAgentSessionOutboxEntry,
|
|
lastFailure: StructuredAgentSessionAttemptFailure | undefined
|
|
): StructuredAgentSessionOutboxEntry {
|
|
const { lastFailure: _previous, ...rest } = entry
|
|
return lastFailure === undefined ? rest : { ...rest, lastFailure }
|
|
}
|
|
|
|
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 is the host's to show, with no Retry: the reconcile
|
|
* drops its entry once the journal carries it.
|
|
*/
|
|
function refusedRedelivery(
|
|
entry: StructuredAgentSessionOutboxEntry,
|
|
submission: AgentJournalSubmission
|
|
): boolean {
|
|
return (
|
|
entry.retryAfterUnknownSubmittedAt !== null &&
|
|
submission.dispatchState === 'unknown' &&
|
|
submission.submittedAt === entry.retryAfterUnknownSubmittedAt
|
|
)
|
|
}
|
|
|
|
/** Whether the journal already answers a send still in flight, so its own reply adds nothing: the
|
|
* host holds the message, rejected it, or handed it off as a queued draft — a later `pending`
|
|
* reply must not undo that. */
|
|
export function journalAnswersInFlightSend(
|
|
submissions: readonly AgentJournalSubmission[],
|
|
clientMessageId: string | null
|
|
): boolean {
|
|
return submissions.some(
|
|
(submission) =>
|
|
(submission.clientMessageId === clientMessageId && submission.dispatchState !== 'unknown') ||
|
|
(clientMessageId !== null && submission.queuedMessageId === clientMessageId)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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 on the phone that the message can be sent again — which it can,
|
|
* because the frame provably never left, so a resend cannot duplicate.
|
|
*
|
|
* The null default claims no cause and no next step, because at that point we know
|
|
* neither: 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,
|
|
write: 'send' | 'composer-send'
|
|
): string {
|
|
return agentSessionWriteNoticeEnglish(structuredAgentSessionRejectionParts(reason, write))
|
|
}
|
|
|
|
export function structuredAgentSessionRejectionParts(
|
|
reason: string | null,
|
|
write: 'send' | 'composer-send',
|
|
/** The host's typed fact, which decides when the row carried one. */
|
|
fact?: AgentSessionFailureFact,
|
|
context: AgentSessionFailureWordsContext = {}
|
|
): AgentSessionWriteNoticePart[] {
|
|
if (fact) {
|
|
return rejectionFactParts(write, fact, context)
|
|
}
|
|
if (reason === null) {
|
|
return ['notDoneSend']
|
|
}
|
|
const rejection = classifyDispatchRejection({ reason })
|
|
if (rejection.kind === 'writeFailed') {
|
|
return ['unreachable', ...agentSessionWriteNotDoneParts(write)]
|
|
}
|
|
// A legacy marker is an internal cause with no user-facing meaning; any other reason is a
|
|
// sentence written to be read — the provider's, or the host's own.
|
|
return rejection.kind ? agentSessionWriteNotDoneParts(write) : [{ text: reason }]
|
|
}
|
|
|
|
function rejectionFactParts(
|
|
write: 'send' | 'composer-send',
|
|
fact: AgentSessionFailureFact,
|
|
context: AgentSessionFailureWordsContext
|
|
): AgentSessionWriteNoticePart[] {
|
|
const { kind } = classifyDispatchRejection({ reason: null, rejection: fact })
|
|
if (kind === 'writeFailed') {
|
|
return ['unreachable', ...agentSessionWriteNotDoneParts(write)]
|
|
}
|
|
// A fact this build cannot place proves only that the message did not happen.
|
|
return kind
|
|
? [{ failure: { ...fact, kind }, surface: 'rejection', context }]
|
|
: agentSessionWriteNotDoneParts(write)
|
|
}
|
|
|
|
/** Kinds whose words need what the message's copy drops: the provider's detail, or the refusal. */
|
|
const WORDED_FROM_WHOLE_FACT: ReadonlySet<AgentSessionFailureFact['kind']> = new Set<
|
|
AgentSessionFailureFact['kind']
|
|
>(['providerRejected', 'startFailed', 'restartFailed'])
|
|
|
|
/** What the Retry row says about why its message did not go through. */
|
|
export function structuredAgentSessionAttemptFailureParts(
|
|
failure: StructuredAgentSessionAttemptFailure,
|
|
context: AgentSessionFailureWordsContext = {},
|
|
/** The journal's whole fact for a recorded rejection, when its submission is loaded: the
|
|
* message's own copy keeps only its kind and attachment. */
|
|
recorded?: AgentSessionFailureFact
|
|
): AgentSessionWriteNoticePart[] {
|
|
if (failure.kind !== 'rejected') {
|
|
return agentSessionWriteNoticeParts(failure, 'send', context)
|
|
}
|
|
const fact = recorded ?? failure.rejection
|
|
// Without the journal's fact, the host's sentence still holds what the copy dropped.
|
|
if (!recorded && fact && WORDED_FROM_WHOLE_FACT.has(fact.kind) && failure.reason !== null) {
|
|
return [{ text: failure.reason }]
|
|
}
|
|
return structuredAgentSessionRejectionParts(failure.reason, 'send', fact, context)
|
|
}
|
|
|
|
/** A send the host refused, whether it returned the refusal or threw it. */
|
|
export function disposeStructuredAgentSessionSendRefusal(
|
|
input: SendDispositionInput & {
|
|
refusal: AgentSessionWriteRefusal
|
|
createOperationId: () => string
|
|
}
|
|
): StructuredAgentSessionSendDisposition {
|
|
// The refusal saved on a message it keeps `queued` is what holds it for the user's Retry.
|
|
const entries: StructuredAgentSessionOutboxEntry[] = input.entries.map((candidate) =>
|
|
candidate.clientMessageId === input.entry.clientMessageId
|
|
? withLastFailure(
|
|
requeueStructuredAgentSessionSendRefusal(
|
|
candidate,
|
|
input.refusal,
|
|
input.createOperationId,
|
|
input.entry.lastAttemptAt !== null
|
|
),
|
|
input.refusal
|
|
)
|
|
: candidate
|
|
)
|
|
return { entries, error: null }
|
|
}
|
|
|
|
export function disposeStructuredAgentSessionSendResult(
|
|
input: SendDispositionInput & {
|
|
result: AgentSessionMutationResult<AgentSessionSendResult>
|
|
createOperationId: () => string
|
|
}
|
|
): StructuredAgentSessionSendDisposition {
|
|
const result = input.result
|
|
if (!result.ok) {
|
|
return disposeStructuredAgentSessionSendRefusal({
|
|
...input,
|
|
refusal: agentSessionRefusalFailure(result.refusal)
|
|
})
|
|
}
|
|
if ('queued' in result.value) {
|
|
// The host holds the draft (or already settled it, on a replay). Either way
|
|
// the send is spent and the queue owns it now: the outbox entry retires,
|
|
// and the draft card — not this queue — carries any later refusal.
|
|
return {
|
|
entries: dropEntry(input),
|
|
error: null
|
|
}
|
|
}
|
|
const submission = result.value.submission
|
|
// A replay of a send the host queued and then handed off answers with that hand-off, under its
|
|
// own id: the host owns the message, and the entry leaves as the reconcile drops it.
|
|
if (submission.queuedMessageId === input.entry.clientMessageId) {
|
|
return {
|
|
entries: dropEntry(input),
|
|
error: null
|
|
}
|
|
}
|
|
if (refusedRedelivery(input.entry, submission)) {
|
|
return {
|
|
entries: dropEntry(input),
|
|
error: ['sendOutcomeLost']
|
|
}
|
|
}
|
|
if (submission.dispatchState === 'accepted') {
|
|
return {
|
|
entries: dropEntry(input),
|
|
error: null
|
|
}
|
|
}
|
|
// A Stop's withdrawal failed nothing, first reply or replay: the entry leaves as the reconcile
|
|
// drops it, with no notice.
|
|
if (
|
|
submission.dispatchState === 'rejected' &&
|
|
classifyDispatchRejection(submission).category === 'withdrawn'
|
|
) {
|
|
return {
|
|
entries: dropEntry(input),
|
|
error: null
|
|
}
|
|
}
|
|
// The host kept it as a card, first reply or replay: the card owns the text, so the entry
|
|
// leaves as the reconcile drops it, with no Retry that could send words the card's Delete took.
|
|
if (submission.dispatchState === 'rejected' && submission.keptAsQueuedMessageId !== undefined) {
|
|
return {
|
|
entries: dropEntry(input),
|
|
error: null
|
|
}
|
|
}
|
|
// Recorded, so the journal's row shows it once it arrives; until then the entry draws it, saying
|
|
// why and offering no Retry.
|
|
if (submission.dispatchState === 'rejected') {
|
|
return {
|
|
entries: replaceEntryState(
|
|
input,
|
|
'rejected',
|
|
structuredAgentSessionRejectedFailure(submission)
|
|
),
|
|
error: null
|
|
}
|
|
}
|
|
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
|
|
}
|
|
}
|
|
// `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
|
|
}
|
|
}
|
|
|
|
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 {
|
|
// An unconfirmed entry's Retry row already says delivery is unconfirmed; the saved failure
|
|
// holds the other for its Retry.
|
|
entries: deliveryUnknown
|
|
? replaceEntryState(input, 'unconfirmed')
|
|
: replaceEntryState(input, 'queued', { kind: 'failed' }),
|
|
error: null
|
|
}
|
|
}
|