import type { IdGen } from "../platform/ports"; import type { CandidateSet } from "../config/types"; export type { CandidateSet } from "../config/types"; export type ModelKey = string; export interface ModelParts { provider: string; modelId: string; } export function parseModelKey(value: unknown): ModelParts | undefined { if (typeof value !== "string") return undefined; const slash = value.indexOf("/"); return slash > 0 && slash < value.length - 1 ? { provider: value.slice(0, slash), modelId: value.slice(slash + 1) } : undefined; } export type ActiveSubstate = "discriminating" | "hourly-probing" | "awaiting-resets_at" | "outage-pacing"; export type EpisodeStatus = "idle" | "active" | "reverted" | "suspended-by-user" | "given-up"; export type ProbeOutcome = "usable" | "quota" | "outage" | "unknown"; export type ProbeKind = "early" | "scheduled"; export interface DeferredProbe { kind: ProbeKind; generation: number; } /** R4/R5/R16: which role a segment entered its candidate in. `1` is a tier-1 substitute — a peer * replacement drawn from the operator's ordered list; `2` is the tier-2 assistant, entered only once * every tier-1 substitute is consumed, with a narrower mandate. `beginSegment` is the ONE writer, it * derives the value from the root primary's candidate set at the moment of entry, and every later reader * takes it from the segment. Nothing re-derives it from current configuration afterwards: an operator * who re-roles the assistant as a substitute changes future entries, never a recorded one. */ export type Tier = 1 | 2; export interface Segment { primary: ModelKey; active: ModelKey; enteredAt: number; tier: Tier; resetsAt?: number; } /** OD-14: the typed candidate ledger that replaces the untyped `visited` set. `entered` means the * episode ran on the model — the root primary from episode open (R1), plus every candidate a fallback * segment was entered on. `skipped` means the episode selected it and could not activate it (R3). * Only `entered` keys are recovery targets (R7); the two are never merged and never downgraded. */ export type ConsumedState = "entered" | "skipped"; export interface ConsumedEntry { readonly state: ConsumedState; readonly resetsAt?: number; /** R9 producer contract, PRESENCE-ONLY: only `true` is ever written, by `probe()`'s usable branch, * and only on an `entered` entry — a `skipped` candidate never ran, so it is never confirmed (R7). * Readers derive the confirmed set with `=== true`. Optional in BOTH snapshot directions (R17: no * version bump, unknown fields already survive); `quota()`'s fresh-exhaustion replacement * deliberately drops it — a fresh exhaustion invalidates a stale confirmation. */ readonly confirmedUsable?: boolean; } export interface EpisodeState { readonly status: EpisodeStatus; readonly activeModel: ModelKey; readonly rootPrimary?: ModelKey; readonly segments: readonly Segment[]; readonly consumed: Readonly>; readonly probeCount: number; /** OD-4: the position of the ONE shared per-episode probe slot in the rotation over * `recoveryTargets(state)`. Monotonic; the modulus is taken at read time because the target set grows * as candidates are entered. Persisted, so a session reload cannot restart the rotation at the root * primary and starve every later candidate. */ readonly probeCursor: number; readonly maxProbes: number; readonly maxOutageProbes: number; readonly substate?: ActiveSubstate; readonly consecutiveOutage: number; readonly pendingResume: boolean; readonly generation: number; readonly episodeId: string; readonly fallbacks: Readonly>; readonly resetMarginSec: number; readonly intervalMin: number; readonly outageIntervalMin: number; readonly nextDueAt?: number; readonly busy: boolean; readonly deferredProbe?: DeferredProbe; } export interface InitialStateOptions { episodeId?: string; idGen?: IdGen; maxProbes?: number; maxOutageProbes?: number; resetMarginSec?: number; intervalMin?: number; outageIntervalMin?: number; } export interface EpisodeTemplate { fallbacks: Readonly>; maxProbes?: number; maxOutageProbes?: number; resetMarginSec?: number; intervalMin?: number; outageIntervalMin?: number; } export interface ReduceOptions { idGen?: IdGen; template?: EpisodeTemplate; } export type EpisodeEvent = // OD-15 option (i): the adapter resolves the next enterable candidate before dispatching and reports // the keys it walked past in `skipped`. `targetAvailable` is deleted: it was never written by // production code, so its half of the old guard was dead in the shipping binary. | { type: "quota_error"; model: ModelKey; candidate?: ModelKey; skipped?: readonly ModelKey[]; resetsAt?: number; at?: number } | { type: "fallback_unavailable" | "target_unavailable"; model?: ModelKey; candidate?: ModelKey } // `target` is the model the probe was actually sent to (R7). It is optional at this boundary so a // caller that has only one possible target need not restate it; `probe()` falls back to the root // primary. `confirmedUsable` carries EVERY target confirmed usable at one decision point, for R9's // "before either activation is applied" case, and OD-13 picks among them. | { type: "probe_outcome"; outcome: ProbeOutcome; target?: ModelKey; confirmedUsable?: readonly ModelKey[]; scheduled?: boolean; probeKind?: ProbeKind; generation?: number; resetsAt?: number } | { type: "probe_due"; kind: ProbeKind; generation?: number } | { type: "agent_busy" | "agent_idle"; generation?: number } | { type: "teardown"; reason: string } | { type: "rebuild"; snapshot: unknown; currentModel: ModelKey } | { type: "user_model_select"; model: ModelKey }; export type Effect = | { type: "setModel"; model: ModelKey; reason: "quota" | "revert" } | { type: "notify"; message: string; reason?: string } | { type: "setStatus"; status: "active"; model: ModelKey; substate: ActiveSubstate } | { type: "scheduleProbe"; kind: ProbeKind; generation: number; substate: ActiveSubstate; delayMs?: number; dueAt?: number; resetsAt?: number } | { type: "startProbe"; kind: ProbeKind; generation: number; substate: ActiveSubstate } | { type: "startReviewCapture"; episodeId: string } | { type: "stopReviewCapture"; episodeId: string } | { type: "armAutoSwitchLatch"; expected: ModelKey } | { type: "cancelProbes"; generation: number } | { type: "flushSnapshot"; episodeId: string; generation: number; reason: string } | { type: "clearStatus" }; export interface ReduceResult { state: EpisodeState; effects: readonly Effect[]; }