ADR-0052: Message regeneration and two-tier, operator-cancellable retry
- Status: Proposed
- Date: 2026-07-01
- Deciders: @karasu, with colleagues
- Supersedes: —
- Superseded by: —
Context
When a primary run fails on a transient provider error (e.g. "The service may be
temporarily overloaded, please try again later" — an upstream 429/503), the run
transitions to failed, the session returns to idle, and the failing
assistant message is persisted with an error_message. The operator sees the
error but has no path back to a successful reply short of retyping the prompt.
Two capabilities are wanted:
- Regenerate — re-run the agent from a chosen point. Clicking regenerate on an agent reply supersedes that reply and everything after it, then re-runs from the operator message that produced it.
- Retry mode — on a transient failure, automatically regenerate on a visible, cancellable backoff until it succeeds or the operator cancels.
The load-bearing question is where retry lives and how many tiers there are.
docs/specs/llm.md §Retry policy already specifies a retry middleware in every
resolved model (max 3 attempts, base 1 s, cap 30 s, jitter, retries 429/5xx +
network errors, honors Retry-After). In code the middleware (wrapWithRetry,
packages/llm/src/middleware/retry.ts) is implemented and unit-tested but not
wired into resolveModel — the code lags its own spec. Meanwhile the daemon
already performs exactly one context_overflow retry (compact then re-run,
primary-runner.ts, per ADR-0024).
So there are really two natural retry tiers, and the operator has asked that
both be cancellable and operator-legible — including the case where a
provider says "no quota for 50 minutes": the operator must be able to cancel and
come back rather than have the system hammer the endpoint, and a long
Retry-After should inform (not be ignored by) the next attempt.
Decision
kaged will implement two explicit retry tiers with a hard boundary, both operator-cancellable, and will wire the specced retry middleware into
resolveModelto make Tier 1 real.
Tier 1 — fast, automatic, in-run (transport). The
wrapWithRetrymiddleware, wired intoresolveModelper llm.md. It retries the open-call (doStream/doGeneratesetup) only — never after the first delta has been published — so live streaming is never replayed. It uses the run'sAbortSignal, so cancelling the run aborts an in-flight Tier 1 retry sleep. Bounded (3 attempts, 30 s cap). If aRetry-Afterexceeds Tier 1's window, Tier 1 does not burn attempts waiting; it stops and hands an absolute next-eligible time to Tier 2.Tier 2 — slow, visible, operator-driven (scheduler). The frontend. After a run fails, the UI arms only from a persisted, normalized retry classification on the failed run (not from a coarse
run_failedalone). When armed, it shows a countdown (base 2 s, ×2, cap 300 s, unlimited) — or, when the failed run carries a far-futureretry_after_until, a scheduled "retry after HH:MM" with the countdown paused — and on expiry callsPOST /api/v1/sessions/:id/messages/:mid/regenerate. The operator can cancel at any point between attempts.Regenerate endpoint — supersedes all messages after the operator message
:midand dispatches a fresh run from that message's content, reusing the ordinary session concurrency/transition logic (it may queue like a posted message). It creates no new message row and does not mutate the preserved operator row (message immutability intact); it returns a receipt.Retry classification (the tier boundary). A failed run persists a normalized
{ retryable: boolean, retry_class: string, retry_after_until?: number }. Tier 2 auto-arms only whenretryableis true for a transient/network/rate-limit class. It must not auto-arm forcontext_overflow(daemon already compaction-retried),auth_failed,credentials_unresolved,spend_limit_exceeded, or hard-quota failures. The message wire exposesrun_id(for correlation) and this classification (for arming).Anchor. Regeneration anchors on the operator message id (stable across regenerations), never
run_id(which changes each regeneration).Cancellation semantics. Tier 1 cancel = abort the run (existing
POST .../runs/:rid/cancel), which aborts the in-flight retry sleep. Tier 2 cancel = a frontend timer stop (no server run exists during the countdown), modeled as anarmed → dispatching → cancelledstate machine with a generation token and a fetchAbortController; client-only cancel is best-effort (no server lease), with an immediaterun.cancelfallback if a run was already dispatched. Prerequisite fix: a cancelled run must be recorded ascancelled, notfailed— todayprimary-runner.tscan map an abortedfinishReasontofailed, which would wrongly arm Tier 2 on a cancel.
Consequences
What this commits us to
- Wiring
wrapWithRetryintoresolveModel(closing the code/spec gap), with the explicit invariant: no automatic in-run retry once the first delta is published. - A normalized retry classification persisted on failed runs and exposed on the
message wire, plus
run_id. - Fixing the cancel-vs-failed outcome mismatch in
primary-runner.tsbefore Tier 2 can trust the failed-run signal. - Persisting an absolute
retry_after_untilwhen a provider advises a backoff longer than Tier 1's window, so Tier 2 respects it across a fresh run.
What this forecloses
- Retries that survive a closed browser tab (Tier 2 is interactive-only — accepted).
- A strict "no stray run on cancel" guarantee, unless a future server-side retry lease is added (explicitly out of scope; best-effort cancel accepted).
What becomes easier
- Transient blips recover automatically and invisibly (Tier 1); persistent transient failure becomes a visible, cancellable, honest wait (Tier 2).
- One code path backs both regenerate and Tier 2 retry.
What becomes harder
- Two retry mechanisms plus compaction-retry coexist; the boundaries are drawn on the persisted retry classification and the first-delta invariant, and must be respected on both sides.
Alternatives considered
Alternative A — single frontend-only tier, remove middleware retry
The original framing. Rejected: it contradicts llm.md (higher authority than a plan), throws away automatic fast recovery, and would require amending ADR-0049's retry stance.
Alternative B — daemon-only retry (wrapWithRetry, no visible tier)
Survives tab close, needs no UI. Rejected: hides the transient error the operator wants to see, gives no countdown/cancel, and cannot express a long cooldown to the operator.
Alternative C — anchor on run_id
Rejected: a reply's run id changes on every regeneration while the operator row persists; run id is not a stable anchor.
Alternative D — arm Tier 2 on run_failed alone (no classification)
Simpler, but retries things it must not (auth, hard quota, context overflow). Rejected in favor of an explicit normalized classification.
References
- Spec amendments:
docs/specs/http-api.md,docs/specs/session-manager.md,docs/specs/llm.md,docs/specs/ui/README.md. - ADR-0016 (streaming-first UI), ADR-0024 (context compaction / the
context_overflow retry excluded from Tier 2), ADR-0026 (spend limits — a
non-retryable class), ADR-0049 (
@kaged/llmresolver + middleware; the retry middleware this ADR wires in), ADR-0039 (@kaged/wire). - Design consult (colleagues): two-tier boundary, first-delta invariant,
retry_after_untilhandoff, and the cancel-vs-failed prerequisite fix.