import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { classify } from "../classifier/classifier"; import { readSources } from "../config/loader"; import { ConfigStore } from "../config/store"; import type { ConfigSnapshot } from "../config/types"; import { createInitialState, reduce, selectCandidate, type CandidateSelection } from "../episode/state-machine"; import { emptyLedger, recoveryTargets } from "../episode/ledger"; import { encodeSnapshot, rebuild as rebuildSnapshot } from "../episode/snapshot"; import type { Effect, EpisodeEvent, EpisodeState, ModelKey, ReduceOptions } from "../episode/types"; import { parseModelKey } from "../episode/types"; import { freezeCandidateSets } from "../config/schema"; import { createFallbackCommand, type FallbackCommandDefinition } from "../commands/fallback-command"; import { RecoveryProber } from "../recovery/prober"; import { decision, nextDue, MAX_TIMER_SLICE_MS } from "../recovery/scheduler"; import { MAX_EPISODE_PROBES } from "../recovery/limits"; import { ReviewFlagger } from "../review/flagging"; import { ReviewIndexStore } from "../review/index-store"; import { pendingReviewCount } from "../review/handoff"; import type { FallbackReviewIndex, ReviewRebuildResult, ReviewScheduleMetadata } from "../review/types"; import type { FsPort } from "./fs"; import type { Clock } from "./clock"; import type { TimerHandle, Timers } from "./timers"; import type { IdGen } from "./ports"; import { failedMessageKey, STEERING, TIER2_STEERING } from "../activation/resume"; type Any = any; type PiPublicCompletion = { value: string; label: string; description?: string }; type PiPublicCommandDefinition = { description?: string; getArgumentCompletions?: (prefix: string) => PiPublicCompletion[] | null | Promise; handler: (args: string, ctx: Any) => Promise }; export interface PiPublicApi { on(event: string, handler: (event: Any, ctx: Any) => Any): void; registerCommand(name: string, definition: PiPublicCommandDefinition): void; setModel(model: Any): Promise; sendMessage(message: Any, options?: Any): void | Promise; appendEntry(type: string, data?: unknown): void; } export const modelKey = (model: Any): ModelKey | undefined => model && typeof model.provider === "string" && typeof model.id === "string" ? `${model.provider}/${model.id}` : undefined; const realClock = (): Clock => ({ now: () => Date.now(), iso: () => new Date().toISOString() }); const realTimers = (): Timers => ({ schedule: (run, delay) => setTimeout(run, delay) as unknown as number, cancel: (handle) => clearTimeout(handle as Any) }); const realFs = (): FsPort => ({ read: async (path) => { try { return await readFile(path, "utf8"); } catch { return undefined; } }, exists: async (path) => { try { await readFile(path); return true; } catch { return false; } }, writeAtomic: async (path, contents) => { await mkdir(dirname(path), { recursive: true }); const temp = join(dirname(path), `.${path.split(/[\\/]/).pop()}.${process.pid}.tmp`); await writeFile(temp, contents, "utf8"); await rename(temp, path); }, }); export class PiFallbackAdapter { private ctx?: Any; private fs?: FsPort; private clock?: Clock; private timers?: Timers; private entries?: Any; private idGen?: IdGen; private config?: ConfigStore; private review?: ReviewIndexStore; private flagger?: ReviewFlagger; private prober?: RecoveryProber; private futureConfig?: ConfigSnapshot; private state?: EpisodeState; private command?: FallbackCommandDefinition; private timer?: TimerHandle; private latch?: { expected: ModelKey; generation: number; handle?: TimerHandle }; private captureEpisodeId?: string; private hint?: { status: number }; private closed = false; private readonly failed = new Set(); private readonly sentResume = new Set(); private compactionEvents = 0; private readonly declined = new Set(); constructor(private readonly pi: PiPublicApi) {} register(): void { const on = (event: string, handler: (value: Any, ctx: Any) => Any) => this.pi.on(event, handler); on("message_start", () => { this.hint = undefined; }); on("after_provider_response", (event) => this.prober?.busy ? this.prober.afterProviderResponse(event) : (this.hint = { status: event.status })); on("message_end", (event) => this.messageEnd(event)); on("model_select", (event) => this.modelSelect(event)); on("agent_start", () => this.dispatch({ type: "agent_busy" })); on("agent_settled", () => this.settled()); on("tool_call", (event) => this.flagger?.captureToolCall(event, this.state!)); on("tool_result", (event) => this.toolResult(event)); on("session_start", (event, ctx) => this.start(event, ctx)); on("session_before_switch", (event) => this.teardown(event?.reason ?? "switch")); on("session_before_fork", () => this.teardown("fork")); on("session_before_tree", () => this.prepareTreeReset()); on("session_tree", () => this.rebuild()); on("session_shutdown", (event) => this.teardown(event?.reason ?? "shutdown")); // R18: compaction events are counted through the SAME `pi.on` surface the host emits through (`_emit` // iterates `_eventListeners`), so the per-attempt counter works without touching dist internals. on("compaction_start", () => { this.compactionEvents++; }); on("compaction_end", () => { this.compactionEvents++; }); on("session_compact_failed", (event) => { this.compactionEvents++; if (event?.reason === "overflow" && this.state?.status === "active") this.ctx?.ui.notify(this.compactionFailureNotice(), "warning"); }); this.armCompactionWatch(); this.pi.registerCommand("fallback", { description: "configure and inspect model fallbacks", getArgumentCompletions: (prefix: string) => this.command?.getArgumentCompletions(prefix) ?? [], handler: async (args: string, ctx: Any): Promise => { if (!this.command) return ctx.ui.notify("fallback is not ready"); await this.command.handler(args, { ui: { notify: (m: string) => ctx.ui.notify(m), confirm: (m: string) => ctx.ui.confirm("Fallback", m), // R22/D-W3: pass-through with verbatim titles, mapped ONLY when the host actually provides // the dialog — a wrapper that always exists would defeat the route's structural gate // (D-W2: a host claiming hasUI without the methods must degrade, not crash). RPC parity // rides the existing JSON sub-protocol; the wizard uses only the three basic dialogs, so // no `custom()`/`editor` mapping exists (AC-W1). ...(typeof ctx.ui?.select === "function" ? { select: (title: string, options: readonly string[]) => ctx.ui.select(title, options) } : {}), ...(typeof ctx.ui?.input === "function" ? { input: (title: string, placeholder?: string) => ctx.ui.input(title, placeholder) } : {}), }, hasUI: ctx.hasUI === true, currentModel: modelKey(ctx.model ?? this.ctx?.model), }); }, }); } /** D3/R18: guarded coupling to Pi core's silent automatic-compaction declines. Feature detection is * STRUCTURAL only — never a version string. Anything unfamiliar — method absent, non-function, wrap-hostile * frozen host — degrades silently: no wrap, no signal, no behavior change. The armed marker is keyed to the * WRAPPED SESSION INSTANCE, not to this adapter: a replacement Pi session must get its own wrap, while a * re-armed registration on the same session must not double-wrap. */ private armCompactionWatch(): void { try { const host = this.pi as Any; if (typeof host?._runAutoCompaction !== "function" || host.__fallbackCompactionWatchArmed === true) return; const original = host._runAutoCompaction.bind(host); const wrapped = (...args: unknown[]): Any => { this.compactionEvents = 0; const result = original(...args); // Side channel only: the host receives `result` untouched (sync stays sync, promise by identity), so // rejections and values reach it exactly as before. A resolved `false` with ZERO compaction events is // the only decline shape distinguishable from a reported failure — which also returns `false` after // emitting its events. The rejection handler resets the counter and does nothing else. void Promise.resolve(result).then( (value: Any) => { if (value === false && this.compactionEvents === 0) this.onSilentCompactionDecline(); this.compactionEvents = 0; }, () => { this.compactionEvents = 0; }, ); return result; }; host._runAutoCompaction = wrapped; host.__fallbackCompactionWatchArmed = true; } catch { /* unfamiliar host shape — degrade silently: no wrap, no signal, no behavior change. */ } } /** D3.3: mirrors the reported-failure handler — no active episode, no message; then per-episode dedup keyed * EXACTLY `${episodeId}:compaction-decline:${condition}`; then the ONE shared compactionFailureNotice(). */ private onSilentCompactionDecline(): void { if (this.closed || this.state?.status !== "active") return; const condition = modelKey(this.ctx?.model) === undefined ? "no-model" : "no-preparation"; const dedupId = `${this.state.episodeId}:compaction-decline:${condition}`; if (this.declined.has(dedupId)) return; this.declined.add(dedupId); this.ctx?.ui.notify(this.compactionFailureNotice(), "warning"); } /** D3.3: one literal shared by the reported-failure handler and the silent-decline path. */ private compactionFailureNotice(): string { return `Automatic context recovery failed on ${this.state?.activeModel}; no retry, compaction, or model change will follow from this extension.`; } private async start(_event: Any, ctx: Any): Promise { this.ctx = ctx; this.closed = false; this.idGen = typeof ctx.idGen === "function" ? ctx.idGen : undefined; const fs = this.fs = ctx.fs ?? realFs(); const clock = this.clock = ctx.clock ?? realClock(); this.timers = ctx.timers ?? realTimers(); this.entries = ctx.entries ?? { append: (type: string, data?: unknown) => this.pi.appendEntry(type, data), branch: () => ctx.sessionManager?.getBranch?.() ?? [] }; this.config = new ConfigStore({ cwd: ctx.cwd, homeDir: ctx.homeDir ?? homedir(), fs, ui: { notify: (m) => ctx.ui.notify(m, "error") } }); const loaded = await this.config.reload(); this.futureConfig = loaded.config; this.review = new ReviewIndexStore({ cwd: ctx.cwd, entries: this.entries, fs, clock }); const rebuiltReview = await this.review.rebuild("session-start"); this.notifyMalformedReview(rebuiltReview); const active = modelKey(ctx.model); if (!active) return; const runtime = this.futureConfig ?? loaded.config; this.state = this.initialState(active, runtime); this.flagger = new ReviewFlagger({ clock, flagShellActivity: runtime.policy.review.flagShellActivity }); this.prober = new RecoveryProber({ modelRegistry: ctx.modelRegistry, clock: this.clock, isGenerationCurrent: (g) => this.state?.generation === g && this.state.status === "active", onOutcome: (event) => void this.dispatch(event) }); this.command = createFallbackCommand(this.commandPorts()); const saved = [...(this.entries.branch?.() ?? [])].reverse().find((entry: Any) => entry?.type === "custom" && entry.customType === "fallback-episode-snapshot"); if (saved?.data) await this.apply(this.rebuildEpisode(saved.data, active, rebuiltReview), this.state); } private commandPorts(): Any { return { config: this.config!, review: this.review!, readSources: () => readSources({ cwd: this.ctx.cwd, homeDir: this.ctx.homeDir ?? homedir(), fs: this.fs! }), modelRegistry: { getAll: () => this.ctx?.modelRegistry?.getAll?.() ?? [] }, onConfigChange: (config: ConfigSnapshot) => this.applyConfigChange(config), getEpisode: () => this.state, sendMessage: (message: Any, options: Any) => this.pi.sendMessage({ customType: "fallback-review-handoff", content: message.content, display: false }, options), }; } private initialState(active: ModelKey, runtime: ConfigSnapshot): EpisodeState { const policy = runtime.policy; return createInitialState(active, runtime.fallbacks, { idGen: this.idGen, intervalMin: policy.probe.intervalMin, maxProbes: policy.probe.maxProbes, maxOutageProbes: policy.probe.maxOutageProbes, resetMarginSec: policy.probe.resetMarginSec, outageIntervalMin: policy.probe.outageIntervalMin }); } private applyConfigChange(config: ConfigSnapshot): void { this.futureConfig = config; const state = this.state; if (!state || state.status !== "idle") return; const policy = config.policy; this.state = { ...state, // TRAP 2 fix: deep-freeze via the shared helper — a shallow `Object.freeze({...config.fallbacks})` // would leave each CandidateSet's nested `substitutes` array mutable through this runtime copy. fallbacks: freezeCandidateSets(config.fallbacks), maxProbes: Math.min(MAX_EPISODE_PROBES, Math.max(1, policy.probe.maxProbes)), maxOutageProbes: Math.max(1, policy.probe.maxOutageProbes), resetMarginSec: policy.probe.resetMarginSec, intervalMin: policy.probe.intervalMin, outageIntervalMin: policy.probe.outageIntervalMin, }; if (this.clock) this.flagger = new ReviewFlagger({ clock: this.clock, flagShellActivity: policy.review.flagShellActivity }); } private async messageEnd(event: Any): Promise { const message = event?.message, state = this.state, active = modelKey(this.ctx?.model); if (!message || !state || message.role !== "assistant" || message.stopReason !== "error" || !active || message.provider !== state.activeModel.split("/", 1)[0] || message.model !== state.activeModel.slice(state.activeModel.indexOf("/") + 1)) return; const verdict = classify({ provider: message.provider, modelId: message.model, errorMessage: message.errorMessage, diagnostics: message.diagnostics, httpHint: this.hint }); this.hint = undefined; if (verdict.kind !== "quota") { if (verdict.kind === "unknown") this.ctx.ui.notify(`Provider signal unsupported for ${message.provider}/${message.model}.`, "warning"); return; } const id = failedMessageKey(message), dedupId = `${state.episodeId}:${id}`; if ((state.status === "idle" || state.status === "active") && this.failed.has(dedupId)) return; this.failed.add(dedupId); const before = state, selection = await this.resolveCandidate(state); await this.apply(reduce(state, { type: "quota_error", model: state.activeModel, candidate: selection.candidate, skipped: selection.skipped, resetsAt: verdict.resetsAt, at: message.timestamp }, this.reduceOptions()), before, id); } /** OD-15 option (i): resolve the next enterable candidate BEFORE dispatching, so nothing is committed * before the registry has spoken. The old shape assigned `this.state = result.state` at `apply()` and * only then checked the registry inside the `setModel` effect, so the segment was already committed * and the candidate already in the traversal record when the check failed — and the unwind forked: * rolling the episode back from `idle` (candidate left unconsumed) or keeping the entered segment and * giving up from `active`. Selection stays pure; only enterability is async here. * * SCOPE, precisely: this pre-check covers REGISTRY availability — the one R3 kind resolved before the * reduce. A host that accepts the candidate here and then refuses it at `pi.setModel` is NOT forfeited * either: the refused branch dispatches the existing `fallback_unavailable` event, and the reducer owns * the unwind (ledger `skipped`) and the advance to the next candidate. The same dispatch serves the * residual registry-lost-during-effect race below, so both unwinds are one mechanism, in one place. */ private async resolveCandidate(state: EpisodeState): Promise { // A terminal state restarts inside `quota()` from a fresh initial state, so mirror that here: the // root primary becomes the active model and the ledger is empty. const open = state.status === "idle" || state.status === "active"; const rootPrimary = open ? state.rootPrimary ?? state.activeModel : state.activeModel; const consumed = open ? state.consumed : emptyLedger(); const set = (open ? state.fallbacks : this.futureConfig?.fallbacks ?? state.fallbacks)[rootPrimary]; // R4: the tier-2 assistant is walked by `selectCandidate` too, so it must be in the enterability // pre-check as well. Left out, `enterable(assistant)` is false for every assistant that ever // existed, the walk marks it skipped, and the episode stops one hop short of tier 2 — with the // reducer's gate fully implemented and every unit test green. The tier-1/tier-2 ORDER is not // re-stated here; this list only decides which keys get a registry lookup. const candidates = set?.assistant === undefined ? set?.substitutes ?? [] : [...set.substitutes, set.assistant]; const pending = candidates.filter((key) => !consumed[key] && parseModelKey(key)); let available = new Set(pending.filter((key) => this.findModel(key))); if (pending.some((key) => !available.has(key))) { // One refresh for the whole walk, matching the single `refresh()` retry the `setModel` effect // already performs — not one per candidate. try { await this.ctx.modelRegistry.refresh(); } catch {} available = new Set(pending.filter((key) => this.findModel(key))); } return selectCandidate(set, consumed, (key) => available.has(key)); } private findModel(key: ModelKey): Any { const [provider, ...rest] = key.split("/"); return this.ctx?.modelRegistry?.find(provider, rest.join("/")); } private async modelSelect(event: Any): Promise { const selected = modelKey(event?.model); if (!selected || !this.state) return; if (this.latch && event.source === "set" && selected === this.latch.expected) { this.clearLatch(); return; } if (this.latch) this.clearLatch(); await this.dispatch({ type: "user_model_select", model: selected }); } private async settled(): Promise { if (this.closed || !this.state) return; await this.dispatch({ type: "agent_idle" }); const state = this.state, segment = state?.segments.at(-1), id = segment && `${state.episodeId}:${state.segments.length}:${[...this.failed].at(-1) ?? ""}`; if (!state || state.status !== "active" || !state.pendingResume || !segment || !id || this.sentResume.has(id)) return; // R5: the ENTERED SEGMENT decides which text goes out, because the segment is where the tier was // recorded at entry. Nothing here consults the current configuration, so re-roling the assistant // mid-episode cannot retroactively change how an already-entered segment is steered. const steering = segment.tier === 2 ? TIER2_STEERING(segment.primary, segment.active) : STEERING(segment.primary, segment.active); this.sentResume.add(id); await this.pi.sendMessage({ customType: "fallback-redrive-steering", content: steering, display: false }, { triggerTurn: true }); this.state = { ...this.state, pendingResume: false }; } private async toolResult(event: Any): Promise { if (!this.flagger || !this.state) return undefined; const patch = this.flagger.captureToolResult(event, this.state); if (this.state.status === "active") await this.persistReview(); return patch; } private async dispatch(event: EpisodeEvent): Promise { if (!this.state) return; const before = this.state; await this.apply(reduce(this.state, event, this.reduceOptions()), before); } private reduceOptions(): ReduceOptions { const runtime = this.futureConfig; if (!runtime) return { idGen: this.idGen }; const policy = runtime.policy.probe; return { idGen: this.idGen, template: { fallbacks: runtime.fallbacks, maxProbes: policy.maxProbes, maxOutageProbes: policy.maxOutageProbes, resetMarginSec: policy.resetMarginSec, intervalMin: policy.intervalMin, outageIntervalMin: policy.outageIntervalMin } }; } private async apply(result: { state: EpisodeState; effects: readonly Effect[] }, before?: EpisodeState, failedId?: string): Promise { this.state = result.state; for (const effect of result.effects) if (await this.effect(effect, before, failedId)) break; } private async effect(effect: Effect, before?: EpisodeState, failedId?: string): Promise { if (!this.state || !this.ctx) return true; if (effect.type === "setModel") { const [provider, ...rest] = effect.model.split("/"); let target = this.ctx.modelRegistry.find(provider, rest.join("/")); if (!target) { try { await this.ctx.modelRegistry.refresh(); } catch {} target = this.ctx.modelRegistry.find(provider, rest.join("/")); } if (!target) { await this.unavailable(effect.model); return true; } this.clearLatch(); const latch: { expected: ModelKey; generation: number; handle?: TimerHandle } = this.latch = { expected: effect.model, generation: this.state.generation }; latch.handle = this.timers!.schedule(() => { if (this.latch === latch) this.clearLatch(); }, 2_000); let changed = false; try { changed = await this.pi.setModel(target); } catch {} if (this.closed || !this.state || this.state.generation !== latch.generation) { this.clearLatch(); return true; } if (!changed) { this.clearLatch(); await this.unavailable(effect.model); return true; } if (modelKey(this.ctx?.model) !== latch.expected) { this.clearLatch(); return true; } return false; } // R8: `restore()` uses the `reverted` reason for BOTH of its branches, but only the root-primary // branch is an actual revert. Substituting the pending-review notice on a mid-episode return to a // substitute told the operator their changes "await primary review" while the primary was still // exhausted — a revert announced that had not happened. if (effect.type === "notify") { const message = effect.reason === "reverted" && this.state.status === "reverted" ? this.revertNotice() : effect.message; this.ctx.ui.notify(message, effect.reason === "activation" ? "info" : "warning"); return false; } if (effect.type === "setStatus") { this.ctx.ui.setStatus("fallback", `FALLBACK ${effect.model}`); return false; } if (effect.type === "clearStatus") { this.ctx.ui.setStatus("fallback", undefined); return false; } if (effect.type === "cancelProbes") { this.cancelTimer(); this.prober?.cancel(before?.generation); return false; } if (effect.type === "scheduleProbe") { this.schedule(effect); return false; } if (effect.type === "startProbe") { await this.probe(effect); return false; } if (effect.type === "startReviewCapture") { if (this.captureEpisodeId !== effect.episodeId) { this.captureEpisodeId = effect.episodeId; this.flagger = new ReviewFlagger({ clock: this.clock!, flagShellActivity: this.futureConfig?.policy.review.flagShellActivity }); } await this.persistReview(); return false; } if (effect.type === "stopReviewCapture") { await this.persistReview(); return false; } if (effect.type === "flushSnapshot") { this.cancelTimer(); this.prober?.cancel(effect.generation); this.entries?.append("fallback-episode-snapshot", encodeSnapshot(this.state)); return false; } return false; } private notifyMalformedReview(rebuiltReview?: ReviewRebuildResult): void { if (!rebuiltReview) return; // Each condition below is an independent, distinguishable signal: a corrupt whole mirror file // (R3-whole-mirror-corruption-still-silent), a seed forced onto an older record because the newest // one was discarded (R3-stale-seed-on-newest-malformed), and a per-episode malformed-entry count. // None of these implies "the mirror is simply absent", which stays silent by design. if (rebuiltReview.mirrorVersionAhead) this.ctx?.ui.notify("The fallback review history file was written by a newer version of this extension and cannot be read by this one; nothing was lost, but this session starts a fresh history.", "warning"); if (rebuiltReview.mirrorCorrupt) this.ctx?.ui.notify("The fallback review history file was corrupt and could not be read; any records it held are unrecoverable.", "warning"); if (rebuiltReview.staleSeed) this.ctx?.ui.notify("The most recent fallback review record was corrupt and has been discarded; resuming from an older, valid record instead.", "warning"); if (rebuiltReview.malformedEntries > 0) this.ctx?.ui.notify(`The fallback review history had ${rebuiltReview.malformedEntries} malformed record(s) that could not be restored.`, "warning"); } private revertNotice(): string { const index = this.review?.snapshot(), pending = index ? pendingReviewCount(index) : 0; const noun = pending === 1 ? "fallback change" : "fallback changes"; const verb = pending === 1 ? "awaits" : "await"; return `Restored ${this.state?.activeModel ?? "unknown/model"}; ${pending} ${noun} ${verb} primary review.`; } /** D1: both former forks (adapter-side idle restore, give-up dispatch) are ONE dispatch of the * existing `fallback_unavailable` event — the reducer owns the unwind, the ledger write (`skipped`) * and the advance, so R1's consumption record has a single owner and the idle terminal is reproduced * by the reducer's `fresh()` rather than by state surgery here. */ private async unavailable(candidate: ModelKey): Promise { await this.dispatch({ type: "fallback_unavailable", candidate }); } private schedule(effect: Extract): void { this.cancelTimer(); const now = this.clock!.now(); const resetsAt = effect.resetsAt ?? this.state?.segments.at(-1)?.resetsAt; const due = effect.delayMs !== undefined ? now + effect.delayMs : effect.dueAt ?? nextDue({ now, substate: effect.substate, resetsAt, resetMarginSec: this.state?.resetMarginSec, intervalMin: this.state?.intervalMin, outageIntervalMin: this.state?.outageIntervalMin }); const state = this.state; if (state?.generation === effect.generation) { this.state = { ...state, nextDueAt: due }; if (state.rootPrimary) void this.review?.updateSchedule({ substate: state.substate ?? "hourly-probing", probeCount: state.probeCount, consecutiveOutage: state.consecutiveOutage, resetsAt: state.segments.at(-1)?.resetsAt ?? null, lastProbeAt: now, nextDueAt: due, segmentDepth: state.segments.length, segments: [state.rootPrimary, ...state.segments.map((s) => s.active)] }); } const run = () => { if (this.clock!.now() < due) { this.timer = this.timers!.schedule(run, Math.min(MAX_TIMER_SLICE_MS, due - this.clock!.now())); return; } this.timer = undefined; void this.dispatch({ type: "probe_due", kind: effect.kind, generation: effect.generation }); }; this.timer = this.timers!.schedule(run, Math.min(MAX_TIMER_SLICE_MS, Math.max(0, due - now))); } /** R7 + OD-4 (tasks 4.4/4.5). Replaces the single hard-coded `segments.at(-1)?.primary` target, which * probed the root primary forever and left every other entered candidate unchecked — the starvation * `explore.md` §7.3 recorded. The interval governs ONE shared per-episode schedule: each tick sends * exactly one probe to the cursor's current target and advances the cursor by one, so for `N` entered * candidates any single one is checked at most once per `N * intervalMin`. The cursor advances BEFORE * the registry lookup on purpose — a target the registry has lost must not pin the rotation on itself * and starve the rest. */ private async probe(effect: Extract): Promise { const state = this.state; if (!state || !this.prober) return; // An empty target set in an ACTIVE episode means the ledger and the traversal disagree — a truncated // or hand-edited snapshot, since `decodeSnapshot` never cross-checks the two. Falling back to the // root primary keeps exactly the behaviour of the hard-coded target this replaced; returning here // instead would leave the episode in FALLBACK for ever with no probe ever sent. const targets = recoveryTargets(state); const rotation = targets.length > 0 ? targets : state.rootPrimary !== undefined && state.rootPrimary !== state.activeModel ? [state.rootPrimary] : []; if (rotation.length === 0) return; const target = rotation[state.probeCursor % rotation.length]; this.state = { ...state, probeCursor: state.probeCursor + 1 }; const [provider, ...rest] = target.split("/"); const model = this.ctx.modelRegistry.find(provider, rest.join("/")); if (model) await this.prober.probe({ model, provider, modelId: rest.join("/"), generation: effect.generation, kind: effect.kind, target }); } private async persistReview(): Promise { if (!this.review || !this.flagger || !this.state?.rootPrimary) return; const old = this.review.snapshot(), records = this.flagger.snapshot(), status = this.state.status === "idle" ? "active" : this.state.status; const index: FallbackReviewIndex = { version: 1, episodeId: this.state.episodeId, primary: this.state.rootPrimary, status: status as Any, reviewed: old?.episodeId === this.state.episodeId ? old.reviewed : false, flaggedFiles: records.flaggedFiles, shellActivity: records.shellActivity }; await this.review.persist(index, { substate: this.state.substate ?? "hourly-probing", probeCount: this.state.probeCount, consecutiveOutage: this.state.consecutiveOutage, resetsAt: this.state.segments.at(-1)?.resetsAt ?? null, lastProbeAt: this.clock!.now(), nextDueAt: this.state.nextDueAt, segmentDepth: this.state.segments.length, segments: [this.state.rootPrimary, ...this.state.segments.map((s) => s.active)] }); } private rebuildEpisode(snapshot: Any, active: ModelKey, rebuiltReview?: ReviewRebuildResult): { state: EpisodeState; effects: readonly Effect[] } { const schedule = rebuiltReview && rebuiltReview.index?.episodeId === snapshot?.episodeId ? rebuiltReview.schedule : undefined; const persisted = schedule?.nextDueAt === undefined || !snapshot || typeof snapshot !== "object" ? snapshot : { ...snapshot, nextDueAt: schedule.nextDueAt }; const due = persisted?.status === "active" && Number.isFinite(persisted.nextDueAt) && typeof persisted.substate === "string" ? decision({ now: this.clock!.now(), substate: persisted.substate, dueAt: persisted.nextDueAt, busy: persisted.busy === true, kind: persisted.substate === "discriminating" ? "early" : "scheduled", probeCount: persisted.probeCount, maxProbes: persisted.maxProbes, consecutiveOutage: persisted.consecutiveOutage, maxOutageProbes: persisted.maxOutageProbes }).action !== "wait" : false; return rebuildSnapshot(persisted, active, { probeDue: due }) ?? reduce(this.state!, { type: "rebuild", snapshot: persisted, currentModel: active }); } private async rebuild(): Promise { this.prepareTreeReset(); const active = modelKey(this.ctx?.model), runtime = this.futureConfig; if (!active || !runtime) { if (this.review) this.notifyMalformedReview(await this.review.rebuild("session-tree")); return; } this.state = this.initialState(active, runtime); const rebuiltReview = this.review ? await this.review.rebuild("session-tree") : undefined; this.notifyMalformedReview(rebuiltReview); const saved = [...(this.entries?.branch?.() ?? [])].reverse().find((entry: Any) => entry?.type === "custom" && entry.customType === "fallback-episode-snapshot"); if (!saved?.data) return; await this.apply(this.rebuildEpisode(saved.data, active, rebuiltReview), this.state); if (this.state.status === "active") { this.flagger = new ReviewFlagger({ clock: this.clock!, flagShellActivity: runtime.policy.review.flagShellActivity }); this.captureEpisodeId = this.state.episodeId; this.ctx?.ui.setStatus("fallback", `FALLBACK ${this.state.activeModel}`); } } private prepareTreeReset(): void { this.cancelTimer(); this.prober?.cancel(); this.clearLatch(); this.ctx?.ui.setStatus("fallback", undefined); this.state = undefined; this.flagger = undefined; this.captureEpisodeId = undefined; this.failed.clear(); this.sentResume.clear(); this.declined.clear(); } private async teardown(reason: string): Promise { if (this.closed || !this.state) return; this.closed = true; this.cancelTimer(); this.clearLatch(); this.prober?.cancel(this.state.generation); await this.dispatch({ type: "teardown", reason }); await this.persistReview(); } private cancelTimer(): void { if (this.timer !== undefined) this.timers?.cancel(this.timer); this.timer = undefined; } private clearLatch(): void { if (this.latch?.handle !== undefined) this.timers?.cancel(this.latch.handle); this.latch = undefined; } } export const createPiFallbackAdapter = (pi: PiPublicApi): PiFallbackAdapter => new PiFallbackAdapter(pi);