15 — Reliability: QoS, crash, recovery, start/stop/resume
Status: implemented in 2.0.0
Tool and MCP error recovery
Tool and MCP failures are append-only, model-visible values rather than terminal exceptions. When a failure is plausibly correctable, Vak appends a recovery hint to that result so the next LLM turn can inspect the failure and retry with corrected arguments or choose an alternative capability. This is a bounded repair loop, not blind replay: the model must produce the new call, identical arguments must not be repeated, and every new call is authorized and executed through the normal broker.
Recovery hints are deliberately omitted for user cancellation, permission or approval denial, revocation, authentication, and rate-limit failures. Those conditions require an external state change or human decision. Existing turn limits, doom-loop detection, provider dispatch ceilings, and append-only session recording remain the upper bounds for the repair loop.
Per-run repair budget (escalating, system-authored)
A recovery hint alone is a nudge to the model; it is not a backstop. The loop
tracks consecutive turns that end with an unresolved classifiable
(vak_tools::ToolErrorKind::is_correctable()) tool failure and escalates:
- First correctable failure this run — the per-call
[recovery]hint is appended to the result (as above; the model still produces the next call). - Second consecutive turn failing on a correctable fault — the loop
injects an authoritative, schema-resurfacing directive, a tagged
ControlKind::RepairDirectivemessage ([repair-directive] …) that no client shows as the person's words: it re-surfaces the exact admittedTool::schema()for each rejected tool (or, for an unknown name, the full admitted inventory) so the repair is no longer a guess. This is issued by the runtime, not the model. - Third consecutive turn still failing — the budget is exhausted: the loop
stops re-dispatching and returns a degraded, system-authored
TurnOutcome::Completedthat states the failure instead of inventing an answer. ADiagnosticactivity is appended to the ledger (tool-repair-exhausted).
The budget is per Agent run (RepairState, reset on every run) and is
general across all tool-error classes — it does not special-case the mcp
broker; ToolErrorKind::classify is the single classification authority.
Outcome linkage (no false Produced)
The outcome gate (vak_core → vak_intent::evaluate_response_with_failures)
inspects the latest turn: when it ends with classifiable correctable failures
and zero successful receipts, the run is downgraded to Unknown rather than
Produced — a non-empty fallback answer is not evidence. Combined with the
budget above, a run that cannot repair its tool calls fails honest instead of
signing off a fabricated result.
The standard failure matrix and where each case is handled.
Transient failures (QoS)
- Retry with backoff: every model step (connect + stream + collect) is
retried up to
max_retries(default 3) when the error is retryable (429 / 529 / network). Delay =retry_base_backoff_ms * 2^(n-1)with ±50% jitter, capped at 30s; server-advisedRetry-Afteroverrides. User aborts and partial-output aborts are never retried. - Watchdog: each step runs under
request_timeout(default 600s,request_timeout_secs = 0disables). A hung stream becomes a retryable deadline error instead of hanging forever. - Surfaced as
AgentEvent::RetryScheduled{attempt, delay_ms, reason}so all UIs show the wait; config keysmax_retries,retry_base_backoff_ms,request_timeout_secs. - Run-level endurance (
run_retry_attempts, default 6;run_retry_base_backoff_ms, default 2s): a fault window can outlast one step's retry budget — a sustained 429 window or slow/hung upstream used to kill the whole run on first step exhaustion (found in the live chaos campaign). Now, when a step exhausts its retries with a transient error (429/529/network/truncated stream), the loop backs off cancel-aware and re-attempts the same turn: nothing was committed to the ledger, so the re-attempt is exact and turn budgets are not consumed by infrastructure pain. An OPEN circuit breaker still fails fast — fresh evidence of a dead provider must not cost the user minutes of waiting. Permanent errors (auth/bad request/api) are never endured. - Planner calls retry too (found in live testing):
plan's model calls bypass the agent loop, so they carry their own bounded retry (3 attempts, exponential backoff,Retry-Afterhonored, cancel-aware). A transient failure mid-planning no longer fails the whole run closed. - Truncated SSE streams on OpenAI-compatible proxies: some free-tier endpoints
end the body after the last content chunk without
[DONE]/finish_reason. The openai-completions adapter treats a clean close with content as de facto completion (EndTurn); a close with no content still fails closed asParse.
Crash & recovery
- Session ledgers are append-only JSONL — a crash mid-write loses at most
the trailing partial line, which
SessionLog::openskips; nothing else is rewritten. Model context is always derived from the log, so a crashed run resumes with full history. - Checkpoint ledgers write via tmp-file + rename (atomic).
- Flow state ledgers persist after every node completion;
flow run --resumereplays only non-completed nodes. - Terminal restore: TUI raw mode is restored by a Drop guard even on panic.
Start / stop / resume
| operation | path |
|---|---|
| start | exec, TUI, serve, flow run, plan |
| stop (user) | Ctrl-C → cancel token → Aborted{partial}; partial assistant output is persisted |
| stop (server) | POST /sessions/:id/cancel → same path + RunFinished{cancelled} |
| resume (session) | exec --session <id> reopens the ledger; projection includes all history |
| resume (flow) | flow run <name> --resume skips completed nodes |
| shutdown (server) | ctrl_c → graceful drain (with_graceful_shutdown) |
Circuit breaker (cross-run QoS)
Per-step retries protect one run; the circuit breaker protects every run from a dead provider. Shared via Core across all runs of a process, with one state machine per provider endpoint and credential fingerprint:
- Only blind failures count: network loss, watchdog deadlines, truncated
or malformed streams. Informed transience — 429 with
Retry-After, explicit 503/529 overload — is the server saying "try again later"; it feeds endurance and must not open the circuit mid-window (found in the live chaos campaign: an opened breaker killed runs the window would have released seconds later). circuit_breaker_thresholdconsecutive failures (default 5) open the circuit; while open, steps fail fast with the remaining cooldown instead of burning their retry budget.- After
circuit_breaker_cooldown_secs(default 60) the circuit half-closes: one probe gets through, and any success resets the counter. - A frozen ladder checks each leg's circuit independently, so an open primary fails fast while a healthy fallback can still serve. Run-level endurance uses normal cancel-aware backoff; each leg check prevents no-op calls until that leg's cooldown expires.
Config keys: circuit_breaker_threshold,
circuit_breaker_cooldown_secs (0 cooldown disables opening).
Frozen route ladder (landed)
The frozen ladder is live: at session admission an
ordered candidate ladder is computed (primary + warm-discovery fallbacks
only — no invented ids, no network) and frozen INTO the contract header.
Dispatch walks legs top-down on typed failure domains; the first dispatch
of each next leg is receipted route-fallback and surfaced as a
RouteFallback event. Ceiling, receipts, and endurance budget are shared
across all legs, so walking the ladder is contract execution, never
mid-contract switching (invariant 7 above carries the new wording).
Phase R (vakrouter adoption) upgrades the ordering machinery:
- Attribution is real: every attempt records the
(provider, model)leg that actually served or failed; evidence rows land keyed correctly inrouting-evidence.jsonlwith true p50 latency. - Demand-scored objectives (
order_ladder_v2): request difficulty picks utility/balanced/quality-critical ordering;[route].objectiveoverrides;[route].quality_hintsreplaces hardcoded model-name bands (invariant 9). v1 stays only for replaying old contracts. The difficulty facts themselves come from the intent kernel (docs/design/47-commitment-kernel.md):reasoning_required,evidence_requiredandstructured_outputare derived from the turn's reading. Until that landed,plan_route_ladderpassed all three asfalsewithestimated_input_tokens: 0, so every session scored identical demand and this ordering function never actually varied. A turn may also restrict the frozen ladder to a prefix — never reordering, never extending — which keeps dispatch inside the committed contract and leaves replay exact. - Cross-model fallbacks are opt-in:
[route].fallback_modelsallowlist ∩ warm discovery; the user's primary never loses the head position. - Diversity caps + annotations: ⌈max_total/3⌉ seats per provider; thin-chain/dominant-domain/unreachable warnings frozen into the header, visible in TUI introspection.
- Beliefs: domain-weighted doubt demotes flaky legs below trusted peers until one success clears them; governance failures are not evidence.
Evidence rows land in routing-evidence.jsonl; unknown settlements shrink
confidence without punishing direction. FinOps attribution follows the
serving leg per dispatch (CostRow.provider).
Invariants
- Retry never changes the frozen contract: same model, same request body.
- Retries are unbounded by wall-clock but bounded by count and cancel token.
- A denied/failed tool result is data, not an exception — the loop continues; only provider-step exhaustion or required-node failure ends a run.
Network events (docs/design/31-network-resilience.md)
| Event | Handling | Proof |
|---|---|---|
| DHCP change / network switch | local plane loopback-immune; outbound reconnectors own recovery | telegram_bridge::bridge_survives_outage_window_and_resumes_cursor |
| Multi-minute outage on a channel | bridge never exits; capped backoff, cursor resumes gap-free via ownership probe | same regression |
| Inference outage window | keyed ladder circuits + endurance ride it; healthy fallback legs remain eligible while a dead leg cools down | fault_proxy scenario (scripts/fault_proxy.py) |
| Hibernation / wake | tokio timers collapse across sleep; watchdog bounds dead sockets; scheduler per-tick evaluation fires each missed slot once | scheduler catch-up tests |
| Full restart | sessions append-only + resume; gateway bindings + task store persisted; telegram cursor re-synced by probe | existing resume/bindings suites |
| Delivery while channel down | inbox chokepoint stores durably; transports best-effort | P6 zero-transports test |
§Turn-Level Routing
Status: shipped.
Problem with session-frozen ladders
The original Phase R design froze provider, model, and route_ladder into the
FrozenContract at session creation (start_session_with_route_for). Every subsequent
turn in that session read the frozen values unconditionally, which caused:
- A user changing provider in Settings had zero effect on open sessions.
- The auto-routing algorithm (
plan_route_ladder: evidence, beliefs, v2 ordering, demand-scoring) ran exactly once at admission, then was inert for the session's life. - If a provider failed mid-session the frozen ladder was always tried first, even if the user had switched away from it.
- Workers inherited the parent session's frozen route rather than the current effective route at spawn time.
Design change
Route planning is now per-turn, not per-session.
run_turn_inner (vak-core/src/lib.rs) now:
- Resolves
(provider, model)fromCore::effective_route()— always the live operator selection, never the session header's initial snapshot. - Calls
Core::plan_route_ladder(turn_primary_leg, Some(engagement.posture.demand))after intent resolution to assemble a fresh ladder using:- Current evidence ledger (
routing-evidence.jsonl, 30-day TTL) - Session belief state (domain-weighted doubt, clears on success)
- Warm discovery cache (TTL 5 min)
- Demand facts from this turn's intent reading (not hardcoded constants)
- Current evidence ledger (
- Uses the fresh
turn_plan.ladderto populatecfg.ladder(fallback legs), notsession_contract.route_ladder.
What the FrozenContract still governs (immutable)
| Field | Still frozen | Reason |
|---|---|---|
capabilities | Yes | Security/audit boundary — which tools are admitted |
permission_mode | Yes | Security boundary — cannot escalate permissions mid-session |
system_prompt / prompt_layers | Yes | Drift detection and prompt attribution |
app_version | Yes | Audit context |
What changed to initial snapshot only
| Field | Was | Now |
|---|---|---|
provider | Dispatch authority | Admission snapshot for audit |
model | Dispatch authority | Admission snapshot for audit |
route_ladder | Dispatch authority | Admission snapshot for audit |
Audit preservation
WorkReceipt records the exact provider, model, and attempt outcome for every
dispatch, per turn. Per-turn audit is strictly richer than the previous
session-level frozen record — you can now reconstruct which provider handled
turn 3 even if the user switched providers between turn 2 and turn 3.
Affected call sites
vak-core/src/lib.rs—run_turn_inner: provider/model resolution + ladder assemblyvak-agent/src/lib.rs—run_turn,run_judge,write_handoff, managed-work authoring: all readself.config.model(set per-turn byrun_turn_inner)vak-server/src/lib.rs—configuration_mismatchfield: alwaysfalse(concept retired)vak-server/src/admin.rs—stale_reasons:provider_changed/model_changedremovedvak-server/src/gateway.rs—session_matches_route: no longer checks initial contractvak-server/src/agent_chats.rs— candidate sessions no longer filtered by initial routevak-session/src/types.rs—FrozenContractdoc comments updatedAGENTS.md— invariant 7 updated