/** * The model-failover chain (#286): which model the next attempt of one run * chain dispatches on, and how the choice is worded so a merged branch built * on a different model stays attributable months later. * * Pure and synchronous: the daemon reads the run rows from the store, hands * them here, and does what the returned choice says. Nothing in this module * reaches a store or a provider — the same split as `failure-class.ts`, kept * here (and NOT there) because `failure-class.ts` owns the *verdict* while * this owns the *response to a run of provider verdicts*. * * The trigger is deliberately counted from the store, never from the * harness's `modelFallbackMessage`: that message means the harness already * downgraded internally and is evidence about a run, not a decision; keying * on it would fire the feature when nothing needed switching and stay silent * when a provider returns 402 cleanly. */ import type { FailureClass, RunRecord } from "./types.ts"; /** * Failure classes that move a chain onto its next model — and ONLY these: a * cap kill means the slice was too big, and switching models in response * would hide a decomposition problem behind a provider one (#286, #490). */ export const FAILOVER_CLASSES: readonly FailureClass[] = [ "provider-transient", "provider-credit", "provider-capacity", ]; /** The default for a project's `modelFallbackThreshold` when it is absent or * unusable. Two consecutive provider-class failures, then the next attempt * moves to the next model. */ export const DEFAULT_MODEL_FALLBACK_THRESHOLD = 2; /** True for the classes {@link FAILOVER_CLASSES} names, so a non-provider * failure can never push a chain sideways. */ export function isProviderFailureClass(cls: string | undefined): cls is FailureClass { return cls !== undefined && (FAILOVER_CLASSES as readonly string[]).includes(cls); } /** Consecutive provider-class failures at the head of one run chain. */ export interface ProviderFailureFacts { /** How many terminal runs at the head of the chain failed as a provider. */ streak: number; /** Those runs' classes, newest first — the set is either one class or a mix * of `provider-transient`, `provider-credit` and `provider-capacity`. */ classes: FailureClass[]; /** * The model the most recent failure dispatched on, when its row recorded * one. Undefined when the rows predate the column or the project has no * chain — the log then falls back to the configured primary, or to "harness * default" for a project with no `workerModel`. */ previousModel: string | undefined; } /** * Counts the consecutive provider-class failures at the head of a run chain. * `runs` is in store order (oldest first, per `Store.runsForIssue`); the walk * starts at the newest row and stops at the first row that is not a * provider-class failure — a success, a non-provider failure, an unclassified * row, a live row — so "sticky per chain, never global" falls out: a fresh * issue has no rows and starts on the primary, and one issue's bad luck can * never move another issue's chain. */ export function providerFailureFacts(runs: readonly RunRecord[]): ProviderFailureFacts { const facts: ProviderFailureFacts = { streak: 0, classes: [], previousModel: undefined }; for (let i = runs.length - 1; i >= 0; i--) { const run = runs[i]!; if (!isProviderFailureClass(run.failureClass)) break; facts.streak += 1; facts.classes.push(run.failureClass); if (facts.previousModel === undefined && run.model !== undefined) { facts.previousModel = run.model; } } return facts; } /** The model the next attempt should dispatch on, and whether that is a * failover at all. */ export interface ModelChoice { /** Model to dispatch on; `undefined` leaves the harness default, exactly as * an unconfigured project dispatches today. */ model: string | undefined; /** True when the choice came from the fallback chain — the failover has * fired and the tick report must say so. */ fallback: boolean; } /** * Where one run chain's next attempt dispatches, given the chain configuration * and the store-derived failure streak. * * With no chain (empty or absent `modelFallbacks`) the primary model is * returned unchanged and `fallback` stays false — today's dispatch byte for * byte. Once the streak reaches the threshold the chain advances one slot per * extra failure, clamped to the last model: the chain is exhausted there, and * the existing escalation path (the provider-class strike cap) settles it, * naming every model tried. */ export function resolveDispatchModel(args: { workerModel: string | undefined; modelFallbacks: readonly string[] | undefined; threshold: number; streak: number; }): ModelChoice { const { workerModel, modelFallbacks, threshold, streak } = args; if (modelFallbacks === undefined || modelFallbacks.length === 0) { return { model: workerModel, fallback: false }; } if (streak < threshold) return { model: workerModel, fallback: false }; const index = Math.min(streak - threshold, modelFallbacks.length - 1); return { model: modelFallbacks[index] ?? workerModel, fallback: true }; } /** * The clause the tick report annexes to a failover dispatch: * `on after N provider-transient failures on `, the shape * the issue asked for (`#123 attempt 3 on after 2 * provider-transient failures on `). `undefined` when no failover * fired, so the ordinary dispatch log line is untouched. */ export function fallbackClause( choice: ModelChoice, facts: ProviderFailureFacts, workerModel: string | undefined, ): string | undefined { if (!choice.fallback) return undefined; const classWord = new Set(facts.classes).size === 1 ? facts.classes[0]! : "provider"; const countWord = facts.streak === 1 ? "failure" : "failures"; const previous = facts.previousModel ?? workerModel ?? "harness default"; return `on ${choice.model} after ${facts.streak} ${classWord} ${countWord} on ${previous}`; } /** One recorded model dispatch in a chain: which model, on which attempt. */ export interface ModelTried { model: string; attempt: number; } /** Every recorded model of a chain, in chain order — what the exhaustion * escalation names when it says "every model tried". Rows without a recorded * model (no chain configured, or written before the column existed) are * absent: an unnamed primary is the harness default, which the escalation * wording covers without pretending to know its name. */ export function modelsTried(runs: readonly RunRecord[]): ModelTried[] { const tried: ModelTried[] = []; for (const run of runs) { if (run.model === undefined) continue; tried.push({ model: run.model, attempt: run.attempt }); } return tried; } /** * `"acme/a (attempts 1-2), acme/b (attempt 3)"` — consecutive attempts * grouped under their model, in chain order. */ export function formatModelsTried(tried: readonly ModelTried[]): string { const groups: { model: string; attempts: number[] }[] = []; for (const { model, attempt } of tried) { const last = groups[groups.length - 1]; if (last !== undefined && last.model === model) last.attempts.push(attempt); else groups.push({ model, attempts: [attempt] }); } return groups .map(({ model, attempts }) => { const span = attempts.length === 1 ? `attempt ${attempts[0]}` : `attempts ${attempts[0]}-${attempts[attempts.length - 1]}`; return `${model} (${span})`; }) .join(", "); }