import { randomUUID } from "node:crypto"; import type { ActiveSubstate, CandidateSet, Effect, EpisodeEvent, EpisodeState, EpisodeTemplate, InitialStateOptions, ModelKey, ProbeKind, ReduceOptions, ReduceResult, Segment, Tier } from "./types"; import { parseModelKey } from "./types"; import { confirmedUsableTargets, emptyLedger, episodeResetsAt, ledger, preferRecovery, type Ledger } from "./ledger"; import { rebuild as rebuildSnapshot, snapshotVersionAhead } from "./snapshot"; import { MAX_EPISODE_PROBES } from "../recovery/limits"; import { freezeCandidateSets } from "../config/schema"; export { parseModelKey } from "./types"; /** Defence in depth only (design.md §11): termination rests on the monotonic ledger over a finite * candidate universe (R11), not on this cap, so it fires only on a bug. Doubling today's 8 keeps it * comfortably above any realistic candidate set. Its stop carries the `segment-budget` reason and is * never labelled `cycle` (R10). Value 16 CONFIRMED WITH THE PROJECT OWNER (2026-08-27). */ export const MAX_EPISODE_SEGMENTS = 16; export interface CandidateSelection { readonly candidate?: ModelKey; readonly skipped: readonly ModelKey[]; } /** R5, activation half: a tier-2 entry must NOT read as a peer replacement. The tier-1 text says only * that the model changed; this one says why there was nothing else left, names the role, and states the * narrower mandate the operator is now working under. Deliberately not string-equal to the tier-1 * message for the same `(from, to)` pair. */ export const TIER2_ACTIVATION = (model: ModelKey, assistant: ModelKey): string => `Switched ${model} -> ${assistant}: every substitute is exhausted, so ${assistant} is now active as the tier-2 assistant, with a narrower mandate — finishing in-flight mechanical work rather than replacing the primary.`; /** OD-5 form D + R3 + R4: walk the root primary's own ordered tier-1 list, skipping every key already * consumed for this episode and every key that cannot be entered, and return the first enterable one. * Pure and synchronous: the adapter supplies `enterable` so the registry lookup stays out of `reduce` * (OD-15 option (i)). * * Falling out of the tier-1 loop IS R4's gate, and it is the only one: reaching the assistant means * every tier-1 substitute is consumed — entered on an earlier hop, or skipped by this very walk. R6's * assistant-only set satisfies it vacuously, which is the design's chosen disposition (ii): activate * directly. The assistant is walked with the same skip-and-advance rules as a substitute (R3), so an * unparseable or registry-missing assistant is announced and the episode stops on its own terms rather * than dying at the entry. */ export function selectCandidate(set: CandidateSet | undefined, consumed: Readonly, enterable: (key: ModelKey) => boolean = () => true): CandidateSelection { const skipped: ModelKey[] = []; // `Object.hasOwn`, not a bare index: this predicate must be true only for keys the ledger really // holds. The ledger is null-prototype (C3), so a bare read no longer resolves `"constructor"` // through `Object.prototype`, but `hasOwn` states the intent directly and survives a caller that // hands in an ordinary record. const walk = (key: ModelKey): boolean => { if (!parseModelKey(key) || !enterable(key)) { skipped.push(key); return false; } return true; }; for (const key of set?.substitutes ?? []) { if (Object.hasOwn(consumed, key)) continue; if (walk(key)) return { candidate: key, skipped }; } const assistant = set?.assistant; if (assistant !== undefined && !Object.hasOwn(consumed, assistant) && walk(assistant)) return { candidate: assistant, skipped }; return { skipped }; } const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value); const positive = (value: unknown, fallback: number): number => finite(value) && (value as number) > 0 ? value as number : fallback; const defaultIdGen = (): string => randomUUID(); const noChange = (state: EpisodeState): ReduceResult => ({ state, effects: [] }); const notify = (message: string, reason?: string): Effect => ({ type: "notify", message, ...(reason ? { reason } : {}) }); /** R3/R17: one shared voice for every skip kind — an unparseable key, a registry-missing candidate, and * (since the refusal advance) a candidate the host refused at `pi.setModel`. Quota skips and refusal * skips must be announced identically, so the literal lives here once. */ const skippedNotice = (skipped: readonly ModelKey[]): Effect => notify(`Fallback candidates unavailable and skipped for this episode: ${skipped.join(", ")}.`, "skipped"); const terminalEffects = (state: EpisodeState, message: string, reason: string): readonly Effect[] => [ { type: "cancelProbes", generation: state.generation + 1 }, { type: "clearStatus" }, { type: "stopReviewCapture", episodeId: state.episodeId }, notify(message, reason), ]; export function createInitialState(activeModel: ModelKey, fallbacks: Readonly> = {}, options: InitialStateOptions = {}): EpisodeState { const maxProbes = finite(options.maxProbes) ? Math.min(MAX_EPISODE_PROBES, Math.max(1, options.maxProbes as number)) : MAX_EPISODE_PROBES; const maxOutageProbes = finite(options.maxOutageProbes) ? Math.max(1, options.maxOutageProbes as number) : 6; return { // The ledger starts EMPTY: an idle model has not run and exhausted, so calling it `entered` would // make it a probe target and hand R8 a verdict about a model that never failed. The root primary is // written by the per-`quota_error` rule below, at the moment the episode actually opens (R1/OD-14). status: "idle", activeModel, segments: [], consumed: emptyLedger(), probeCount: 0, probeCursor: 0, maxProbes, maxOutageProbes, consecutiveOutage: 0, pendingResume: false, generation: 0, episodeId: options.episodeId ?? (options.idGen ?? defaultIdGen)(), busy: false, fallbacks: freezeCandidateSets(fallbacks), resetMarginSec: options.resetMarginSec ?? 60, intervalMin: options.intervalMin ?? 60, outageIntervalMin: positive(options.outageIntervalMin, 10), }; } function beginSegment(state: EpisodeState, model: ModelKey, candidate: ModelKey, at: number): ReduceResult { // OD-5 form D: every segment of one episode carries the episode's root primary, so `segments` is an // append-only traversal history rather than a nesting stack. `activeModel` is the sole live pointer. const primary = state.rootPrimary ?? model; // The failing model's `resetsAt` is deliberately NOT written onto the segment. Under form D `primary` // is the root primary, so from the second hop onward that value belongs to a DIFFERENT model, and // `probe()` consumes it as the root primary's own reset when choosing awaiting-resets_at over hourly // probing — a substitute reporting a week-long reset would stall probing of the root for a week. The // ledger entry written in `quota()` is the correctly attributed home for every model's reset. That // leaves `schedule()` as the sole writer of this field, and `episodeReset` below is what keeps its // content honest: since the rotation landed, a probe outcome can be about a substitute, so only a // verdict about the ROOT primary is allowed to reach `schedule()` as the episode's reset. // R4/R5/R16, and the SOLE writer of `Segment.tier`. The role is read off the ROOT primary's set, // which is the only set form-D traversal ever consults, and a key is forbidden from being both a // substitute and the assistant of one set at BOTH enforcement points — config validation // (`validateSource`, on load and `/fallback set`) AND the `decodeSnapshot` rehydration guard (the // shared `candidateSetViolation`) — so this equality identifies the role unambiguously. It is // evaluated once, here, at entry: a later re-role changes what the NEXT entry gets, never what // this segment recorded. const tier: Tier = state.fallbacks[primary]?.assistant === candidate ? 2 : 1; const segments = [...state.segments, { primary, active: candidate, enteredAt: at, tier } satisfies Segment]; const next: EpisodeState = { ...state, status: "active", activeModel: candidate, rootPrimary: primary, segments, consumed: ledger(state.consumed, { [model]: state.consumed[model] ?? { state: "entered" }, [candidate]: { state: "entered" } }), substate: "discriminating", pendingResume: true, generation: state.generation + 1, probeCount: state.status === "idle" ? 0 : state.probeCount, consecutiveOutage: 0, nextDueAt: undefined, deferredProbe: undefined, }; const effects: Effect[] = [ { type: "setModel", model: candidate, reason: "quota" }, notify(tier === 2 ? TIER2_ACTIVATION(model, candidate) : `Switched ${model} -> ${candidate} after quota exhaustion.`, "activation"), { type: "setStatus", status: "active", model: candidate, substate: "discriminating" }, { type: "scheduleProbe", kind: "early", generation: next.generation, substate: "discriminating", delayMs: 30_000 }, { type: "startReviewCapture", episodeId: next.episodeId }, { type: "armAutoSwitchLatch", expected: candidate }, ]; return { state: next, effects }; } function giveUp(state: EpisodeState, reason: string, message: string, probeCount = state.probeCount): ReduceResult { const generation = state.generation + 1; const next: EpisodeState = { ...state, status: "given-up", substate: undefined, pendingResume: false, probeCount, generation, segments: [...state.segments], consumed: ledger(state.consumed), busy: false, deferredProbe: undefined, nextDueAt: undefined }; return { state: next, effects: terminalEffects(state, message, reason) }; } /** D1 — a candidate the host accepted on pre-check but refused at `pi.setModel` cannot be entered: R3 * consumes it as `skipped` and advances while an enterable candidate remains. The advance applies ONLY * to a pending quota-flow entry — the guard below — because a refused REVERT `setModel` (restore()'s * effect) retracts no entry: the revert target is never the top segment's `active`, so a revert refusal * keeps the old forfeit behavior exactly and the root primary is never marked skipped. Re-selection is * pure and registry-free: the adapter's `resolveCandidate` pre-checked every pending candidate this * round (one shared refresh), so the refusal is the only new fact; if the registry changed since, the * next `setModel` effect's own pre-check catches it and the same advance applies. Each refusal consumes * its candidate in the monotonic ledger, so the loop terminates within the candidate universe (R11), * and `selectCandidate` skips `hasOwn(consumed)` keys, so a refused candidate is never re-attempted. * R7: a `skipped` entry is never a recovery target (it never ran). R10: the active-episode terminal * reuses `candidates-exhausted` — no new reason token. */ function unavailable(state: EpisodeState, candidate: ModelKey, model: ModelKey): ReduceResult { // PENDING-ENTRY GUARD, load-bearing (see above): dropping it marks the root primary `skipped` on a // refused revert — the exact trap this slice's mutation proof exists to catch. if (state.status === "active" && state.segments.at(-1)?.active === candidate) { const segments = state.segments.slice(0, -1); const rootPrimary = state.rootPrimary ?? model; const failingModel = segments.at(-1)?.active ?? rootPrimary; const consumed = ledger(state.consumed, { [candidate]: { state: "skipped" } }); const unwound: EpisodeState = { ...state, segments, activeModel: failingModel, consumed }; const selection = selectCandidate(state.fallbacks[rootPrimary], consumed); const notice = skippedNotice([candidate, ...selection.skipped]); if (selection.candidate) { const result = beginSegment(unwound, failingModel, selection.candidate, 0); return { state: result.state, effects: [notice, ...result.effects] }; } if (segments.length === 0) { // Opening hop refused: unwinding the episode's first segment leaves no history, so the old idle // fork is reproduced exactly — a clean idle state on the failing model, empty ledger, generation // + 1, and today's exact notice. The skip never lands: an idle model has nothing consumed. return { state: fresh(unwound, failingModel), effects: [notify(`Fallback ${candidate} is unavailable; automation stopped.`, "fallback-unavailable")] }; } const givenUp = giveUp(unwound, "candidates-exhausted", `No further fallback candidate is configured for ${rootPrimary}; keeping ${failingModel} active.`); return { state: givenUp.state, effects: [notice, ...givenUp.effects] }; } const message = `Fallback ${candidate} is unavailable; automation stopped.`; return state.status === "active" ? giveUp(state, "fallback-unavailable", message) : { state, effects: [notify(message, "fallback-unavailable")] }; } function freshTemplate(state: EpisodeState, options: ReduceOptions): { fallbacks: Readonly>; options: InitialStateOptions } { const template: EpisodeTemplate = options.template ?? { fallbacks: state.fallbacks }; return { fallbacks: template.fallbacks, options: { idGen: options.idGen, maxProbes: template.maxProbes ?? state.maxProbes, maxOutageProbes: template.maxOutageProbes ?? state.maxOutageProbes, resetMarginSec: template.resetMarginSec ?? state.resetMarginSec, intervalMin: template.intervalMin ?? state.intervalMin, outageIntervalMin: template.outageIntervalMin ?? state.outageIntervalMin, }, }; } function quota(state: EpisodeState, event: Extract, options: ReduceOptions): ReduceResult { if (event.model !== state.activeModel) return noChange(state); if (state.status !== "idle" && state.status !== "active") { const template = freshTemplate(state, options); return quota(createInitialState(state.activeModel, template.fallbacks, template.options), event, options); } // OD-14, first write rule: the failing model is `entered` on EVERY quota_error, before the // skip/enter/exhaust branch below. On the opening failure `event.model` IS the root primary by // construction (the guard above, plus `rootPrimary = state.rootPrimary ?? model`), so the root // primary is in the ledger from the moment the episode opens. Idempotent on later hops. // This replacement DELIBERATELY drops any `confirmedUsable` marker the entry may carry (R9, writer #1): // a fresh quota_error from this model means it exhausted again, so a stale "confirmed usable" would be // false information — a fresh exhaustion invalidates the stale confirmation. const consumed: Ledger = ledger(state.consumed, { [event.model]: { state: "entered", ...(finite(event.resetsAt) ? { resetsAt: event.resetsAt } : {}) } }); const skipped: ModelKey[] = []; const skip = (keys: readonly ModelKey[]): void => { for (const key of keys) if (!Object.hasOwn(consumed, key)) { consumed[key] = { state: "skipped" }; skipped.push(key); } }; skip(event.skipped ?? []); // OD-5 form D + R14: traversal always resolves against the ROOT primary's candidate set, never the // failing model's own, so a chained scalar map never consults an entered substitute's successors. const rootPrimary = state.rootPrimary ?? event.model; const selection = event.candidate === undefined ? selectCandidate(state.fallbacks[rootPrimary], consumed) : { candidate: event.candidate, skipped: [] as readonly ModelKey[] }; skip(selection.skipped); // R3/R17: a skipped candidate is a real operator-visible fact (a key that does not parse, or one the // registry does not know), so advancing past it is announced rather than swallowed. const notices: readonly Effect[] = skipped.length === 0 ? [] : [skippedNotice(skipped)]; const announce = (result: ReduceResult): ReduceResult => notices.length === 0 ? result : { state: result.state, effects: [...notices, ...result.effects] }; const candidate = selection.candidate; // R10's three distinguishable stop reasons. `no-fallback` means the operator configured nothing; // `candidates-exhausted` means every configured candidate is consumed; `segment-budget` means the // defence-in-depth cap fired. None of the three is ever labelled `cycle`. if (!candidate) { // R4/R6/R10: the assistant counts. Reading `substitutes` alone here would report an assistant-only // set — and an assistant-bearing set whose assistant has now exhausted — as if the operator had // configured nothing at all, which is a different fact with a different operator action. const set = state.fallbacks[rootPrimary]; const empty = (set?.substitutes.length ?? 0) === 0 && set?.assistant === undefined; const message = empty ? (state.status === "idle" ? `No fallback configured for ${event.model}.` : `No fallback configured for ${event.model}; giving up.`) : `No further fallback candidate is configured for ${rootPrimary}; keeping ${state.activeModel} active.`; const reason = empty ? "no-fallback" : "candidates-exhausted"; if (state.status === "idle") return announce({ state, effects: [notify(message, reason)] }); return announce(giveUp({ ...state, consumed }, reason, message)); } // OD-7 invariant assertion: the normal walk can never return a consumed candidate, so reaching this // means the ledger and the candidate set disagree — a bug, a hand-edited snapshot, or a rehydrated // ledger that lost entries. It is no longer a normal traversal outcome. if (Object.hasOwn(consumed, candidate)) { const message = `Fallback cycle detected at ${candidate}; keeping ${state.activeModel} active.`; if (state.status === "idle") return announce({ state, effects: [notify(message, "cycle")] }); return announce(giveUp({ ...state, consumed }, "cycle", message)); } if (state.status === "active" && state.segments.length >= MAX_EPISODE_SEGMENTS) { return announce(giveUp({ ...state, consumed }, "segment-budget", `Fallback traversal reached the ${MAX_EPISODE_SEGMENTS}-segment budget; keeping ${state.activeModel} active.`)); } return announce(beginSegment({ ...state, consumed }, event.model, candidate, event.at ?? 0)); } /** R8 + OD-5 form D: `restore` selects rather than pops. It activates exactly the model the verdict was * about and truncates the traversal history to the entries strictly before that model's position, so a * verdict about the root primary can never activate a still-exhausted substitute (the AC-5 failure mode * of the deleted pop-to-`parent.active` branch). `reverted` holds iff the target is the root primary and * no fallback segment remains open. Re-activating a consumed model is not a new segment entry (R11), so * the ledger carries across unchanged. Slice 4 supplies the verdict's own target here. */ export function restore(state: EpisodeState, model: ModelKey | undefined): ReduceResult { if (state.segments.length === 0) return noChange(state); const traversal = [state.rootPrimary ?? state.activeModel, ...state.segments.map((segment) => segment.active)]; const position = model === undefined ? -1 : traversal.indexOf(model); if (position < 0) return noChange(state); const segments = state.segments.slice(0, position), target = traversal[position]; if (position === 0) { const next: EpisodeState = { ...state, status: "reverted", activeModel: target, segments: [], substate: undefined, pendingResume: false, consecutiveOutage: 0, generation: state.generation + 1, consumed: ledger(state.consumed), busy: false, deferredProbe: undefined, nextDueAt: undefined }; return { state: next, effects: [{ type: "setModel", model: target, reason: "revert" }, notify(`Restored ${target}; fallback changes await primary review.`, "reverted"), { type: "cancelProbes", generation: next.generation }, { type: "clearStatus" }, { type: "stopReviewCapture", episodeId: next.episodeId }] }; } const resetsAt = segments[segments.length - 1]?.resetsAt, substate: ActiveSubstate = resetsAt === undefined ? "hourly-probing" : "awaiting-resets_at"; const next: EpisodeState = { ...state, status: "active", activeModel: target, segments, substate, pendingResume: false, consecutiveOutage: 0, generation: state.generation + 1, consumed: ledger(state.consumed), deferredProbe: undefined, nextDueAt: undefined }; return { state: next, effects: [{ type: "setModel", model: target, reason: "revert" }, notify(`Restored ${target}; continuing the fallback episode.`, "reverted"), { type: "setStatus", status: "active", model: target, substate }, { type: "scheduleProbe", kind: "scheduled", generation: next.generation, substate, ...(resetsAt === undefined ? {} : { resetsAt }) }] }; } function schedule(state: EpisodeState, substate: ActiveSubstate, patch: Partial> = {}, resetsAt?: number): ReduceResult { const top = state.segments.at(-1); const segments = top ? [...state.segments.slice(0, -1), finite(resetsAt) ? { ...top, resetsAt } : (({ resetsAt: _reset, ...rest }) => rest)(top)] : [...state.segments]; const next: EpisodeState = { ...state, ...patch, status: "active", substate, segments, consumed: ledger(state.consumed), deferredProbe: undefined, nextDueAt: undefined }; return { state: next, effects: [{ type: "scheduleProbe", kind: "scheduled", generation: next.generation, substate, ...(finite(resetsAt) ? { resetsAt } : {}) }] }; } /** The reset an outcome may contribute to the episode's ONE shared schedule (OD-4). A verdict is only * about the model it was SENT to, and since the rotation landed those are substitutes as well as the * root primary. Only the root primary's own reset may arm the shared timer: `schedule()` writes it onto * the top segment, whose `primary` IS the root, and the `awaiting-resets_at` substate it selects means * "waiting for the root primary's window". Letting a substitute's week-long window arm that timer is the * same mis-attribution the ledger was built to prevent, arriving through the rotation instead of through * `beginSegment`. An event carrying no `target` predates the rotation, when the only possible target was * the root primary, so it is trusted. */ const episodeReset = (state: EpisodeState, event: Extract): number | undefined => (event.target === undefined || event.target === state.rootPrimary) && finite(event.resetsAt) ? event.resetsAt : undefined; /** A rotated substitute's reset is real information about THAT model, so it is recorded on its own * ledger entry rather than discarded — the ledger is already the correctly attributed home for every * model's reset. Only an `entered` candidate is updated: a `skipped` key was never run on. */ const recordProbeReset = (state: EpisodeState, event: Extract): EpisodeState => { const target = event.target; if (target === undefined || !finite(event.resetsAt) || state.consumed[target]?.state !== "entered") return state; // SPREAD the prior entry, never replace it whole: the entry may already carry a durable usable // confirmation (R9), and a fresh `{ state: "entered", resetsAt }` would silently erase it — the exact // field-dropping failure mode the previous change fixed at the decode layer, one layer down. return { ...state, consumed: ledger(state.consumed, { [target]: { ...state.consumed[target], state: "entered", resetsAt: event.resetsAt } }) }; }; /** R9: a usable verdict is a ledger FACT about the model it was SENT to, recorded at its own decision * point — BEFORE winner selection and before any cap/give-up path, so the verdict is durable regardless * of what the episode decides to do with it. Only an `entered` entry is confirmed: a `skipped` candidate * never ran, so it is never entitled to a confirmation (R7) — the same guard `recordProbeReset` applies. * The prior entry is SPREAD, not replaced: a reset or a confirmation must never silently erase the * other field the entry legitimately carries. */ const recordUsableConfirmation = (state: EpisodeState, target: ModelKey | undefined): EpisodeState => { if (target === undefined || state.consumed[target]?.state !== "entered") return state; return { ...state, consumed: ledger(state.consumed, { [target]: { ...state.consumed[target], state: "entered", confirmedUsable: true } }) }; }; function probe(state: EpisodeState, event: Extract): ReduceResult { // R8 (task 4.7): the verdict's OWN target decides what is activated. The unconditional // `restore(state, state.rootPrimary)` this replaces was correct only while the adapter probed one // hard-coded target; under the rotation it handed a verdict about `S1` to `P` and activated a model // that had produced no successful verdict at all. `rootPrimary` remains the fallback for a caller // that never had more than one possible target, which is every pre-rotation event shape. if (event.outcome === "usable") { // R9/OD-13: `confirmedUsable` carries every target confirmed usable at THIS decision point, so the // winner is chosen by preference rather than by which verdict happened to be reduced first. The // single-slot prober never sends more than one, in which case this collapses to the event's own // target; the fallback covers a caller that predates the field entirely. const requested = event.target ?? state.rootPrimary; // R9 producer: the verdict is a ledger fact at its OWN decision point. The marker lands on the // target's entry BEFORE winner selection and before any cap/give-up path — the verdict is real // regardless of the ceiling — and only on an `entered` entry (R7; mirrors `recordProbeReset`). const marked = recordUsableConfirmation(state, requested); // R9/OD-13: the decision point's confirmed set is the UNION of the event's `confirmedUsable` (the // existing contributor — helper-fed callers keep working unchanged) and the ledger-derived // `confirmedUsableTargets`, so confirmations persist across decision points. The single-slot prober // never sends more than one, in which case this collapses to the event's own target; the fallback // covers a caller that predates the field entirely. const confirmed = [...new Set([...(event.confirmedUsable ?? []), ...confirmedUsableTargets(marked)])]; const winner = preferRecovery(marked, confirmed) ?? requested; const activated = winner === undefined ? noChange(marked) : restore(marked, winner); if (activated.effects.length > 0) return activated; // The rotation probes every entered candidate, including one the episode has already moved back // PAST, whose ledger entry survives the truncation. Activating such a candidate is not a recovery — // it would move the user to a LESS preferred model — so `restore` correctly declines. But declining // must not be a SILENT no-op: an empty effects list emits no `scheduleProbe`, and that effect is the // only thing that re-arms the adapter's single per-episode timer, which the firing probe has already // cleared. Returning here ended all probing for the rest of the session. Reschedule instead, so the // rotation keeps running and the root primary is still checked. // R12: a declined SCHEDULED verdict still consumed one of the ceiling's schedule slots, so the // decline path mirrors the shared counting site below EXACTLY — same sent gate, same count, same // cap with the SAME imported constant (no new literal). An activating verdict already returned // `restore()`'s result above and is never counted. const sentUsable = event.scheduled === true || event.probeKind === "scheduled"; if (!sentUsable) return schedule(marked, marked.substate ?? "hourly-probing", {}, episodeResetsAt(marked)); const usableCount = marked.probeCount + 1; if (usableCount >= marked.maxProbes) return giveUp(marked, "cap", `Fallback probing reached the ${marked.maxProbes}-probe cap; keeping ${marked.activeModel} active.`, marked.maxProbes); return schedule(marked, marked.substate ?? "hourly-probing", { probeCount: usableCount }, episodeResetsAt(marked)); } const scoped = recordProbeReset(state, event); const reported = episodeReset(scoped, event); const early = scoped.substate === "discriminating"; if (early) { if (event.outcome === "outage") return schedule(scoped, "outage-pacing", { consecutiveOutage: 1 }); if (event.outcome === "unknown") return schedule(scoped, "hourly-probing", { consecutiveOutage: 0 }); const resetsAt = reported ?? episodeResetsAt(scoped); const substate: ActiveSubstate = finite(resetsAt) ? "awaiting-resets_at" : "hourly-probing"; return schedule(scoped, substate, { consecutiveOutage: 0 }, resetsAt); } const sent = event.scheduled === true || event.probeKind === "scheduled"; // DELIBERATE, and the one read here not migrated to `scoped` (slice 5 reviewed and kept it): an event // the reducer ignores must leave no trace, so an unsolicited outcome cannot write a reset onto a // ledger entry. Returning `scoped` would persist that reset — a behaviour change, not a cleanup. if (!sent) return noChange(state); if (scoped.substate === "outage-pacing") { if (event.outcome === "outage") { return scoped.consecutiveOutage >= scoped.maxOutageProbes ? schedule(scoped, "hourly-probing", { consecutiveOutage: 0 }) : schedule(scoped, "outage-pacing", { consecutiveOutage: scoped.consecutiveOutage + 1 }); } if (event.outcome === "unknown") return schedule(scoped, "hourly-probing", { consecutiveOutage: 0 }); return schedule(scoped, finite(reported) ? "awaiting-resets_at" : "hourly-probing", { consecutiveOutage: 0 }, reported); } const count = scoped.probeCount + 1; if (count >= scoped.maxProbes) return giveUp(scoped, "cap", `Fallback probing reached the ${scoped.maxProbes}-probe cap; keeping ${scoped.activeModel} active.`, scoped.maxProbes); if (event.outcome === "outage") return schedule(scoped, "outage-pacing", { probeCount: count, consecutiveOutage: 1 }); if (event.outcome === "unknown") return schedule(scoped, "hourly-probing", { probeCount: count, consecutiveOutage: 0 }); const resetsAt = reported ?? episodeResetsAt(scoped); return schedule(scoped, finite(resetsAt) ? "awaiting-resets_at" : "hourly-probing", { probeCount: count, consecutiveOutage: 0 }, resetsAt); } const fresh = (state: EpisodeState, activeModel: ModelKey): EpisodeState => ({ ...state, status: "idle", activeModel, rootPrimary: undefined, segments: [], substate: undefined, pendingResume: false, probeCursor: 0, generation: state.generation + 1, busy: false, deferredProbe: undefined, nextDueAt: undefined, consumed: emptyLedger() }); const matchesGeneration = (state: EpisodeState, event: Extract): boolean => event.generation === undefined || event.generation === state.generation; export function reduce(state: EpisodeState, event: EpisodeEvent, options: ReduceOptions = {}): ReduceResult { if ((event.type === "probe_outcome" || event.type === "probe_due" || event.type === "agent_busy" || event.type === "agent_idle") && !matchesGeneration(state, event)) return noChange(state); if (event.type === "user_model_select") { if (state.status !== "active") return { state: { ...state, activeModel: event.model }, effects: [] }; const next: EpisodeState = { ...state, status: "suspended-by-user", activeModel: event.model, substate: undefined, pendingResume: false, generation: state.generation + 1, segments: [...state.segments], consumed: ledger(state.consumed), busy: false, deferredProbe: undefined, nextDueAt: undefined }; return { state: next, effects: [{ type: "cancelProbes", generation: next.generation }, { type: "clearStatus" }, { type: "stopReviewCapture", episodeId: next.episodeId }, notify(`Fallback automation suspended by user selection of ${event.model}.`, "suspended-by-user")] }; } if (event.type === "probe_outcome") return state.status === "active" ? probe(state, event) : noChange(state); if (event.type === "probe_due") { if (state.status !== "active" || !state.substate || state.deferredProbe) return noChange(state); if (state.busy) return { state: { ...state, deferredProbe: { kind: event.kind, generation: state.generation } }, effects: [] }; return { state: { ...state, nextDueAt: undefined }, effects: [{ type: "startProbe", kind: event.kind, generation: state.generation, substate: state.substate }] }; } if (event.type === "agent_busy") return state.status === "active" ? { state: { ...state, busy: true }, effects: [] } : noChange(state); if (event.type === "agent_idle") { const next = { ...state, busy: false, deferredProbe: undefined }; if (state.status !== "active" || !state.deferredProbe || state.deferredProbe.generation !== state.generation || !state.substate) return { state: next, effects: [] }; return { state: next, effects: [{ type: "startProbe", kind: state.deferredProbe.kind, generation: state.generation, substate: state.substate }] }; } if (event.type === "teardown") { const generation = state.generation + 1; const next = { ...state, generation, busy: false, deferredProbe: undefined }; const effects: Effect[] = [{ type: "cancelProbes", generation }, { type: "flushSnapshot", episodeId: state.episodeId, generation, reason: event.reason }]; if (state.status === "active") effects.push({ type: "clearStatus" }, { type: "stopReviewCapture", episodeId: state.episodeId }); return { state: next, effects }; } if (event.type === "rebuild") { const rebuilt = rebuildSnapshot(event.snapshot, event.currentModel); if (rebuilt) return rebuilt; const next = fresh(state, parseModelKey(event.currentModel) ? event.currentModel : state.activeModel); // R17 + carried finding `R3-version-mismatch-classified-as-corruption`: both paths are loud, but a // snapshot from a newer build is not damage and must not be described as if it were. const ahead = snapshotVersionAhead(event.snapshot); const message = ahead ? "An in-flight fallback episode was written by a newer version of this extension and could not be restored; it has been cleared." : "An in-flight fallback episode could not be restored and has been cleared."; return { state: next, effects: [{ type: "cancelProbes", generation: next.generation }, { type: "clearStatus" }, { type: "stopReviewCapture", episodeId: state.episodeId }, notify(message, ahead ? "rebuild-version-ahead" : "rebuild-failed")] }; } if (event.type === "fallback_unavailable" || event.type === "target_unavailable") { const candidate = event.candidate ?? state.fallbacks[event.model ?? state.activeModel]?.substitutes[0] ?? "unknown/model"; return unavailable(state, candidate, event.model ?? state.activeModel); } if (state.status !== "idle" && state.status !== "active" && event.type !== "quota_error") return noChange(state); return event.type === "quota_error" ? quota(state, event, options) : noChange(state); }