import type { Config } from "./config.js"; import type { LadderRung } from "./config-types.js"; /** * The lane KIND travels as data, never as two hand-copies: the `as const` array is the ONE * declaration — the type is indexed from it and the parser validates against it, so a new kind * is a compile error at the classifier rather than a silent fall-through (the closed-union * gotcha in CLAUDE.md). */ export declare const DISPATCH_LANE_KINDS: readonly ["cli", "relay"]; export type DispatchLaneKind = (typeof DISPATCH_LANE_KINDS)[number]; /** * Terminal lane states worth recording. A job the OPERATOR cancelled is discarded, never reported: * a caller changing its mind is not evidence about the lane. * * ⚠ `abandoned` is the fourth member and is the OPPOSITE case, which is why it had to be told * apart from `cancelled` rather than folded into it (`docs/backlog.md`: *"an operator cancellation * is distinguishable in the record from a lane that was never asked"*). The relay's own walk * stopped this lane because it did not answer inside the budget it was given. That IS evidence * about the lane, so it is recorded — and it is distinct from `timed_out`, which means the lane * exceeded its OWN configured ceiling (35 minutes on this machine's slowest rung, against a walk * budget measured in seconds). Conflating the two would report a 90-second miss as a 35-minute one. */ export declare const DISPATCH_LANE_STATUSES: readonly ["completed", "failed", "timed_out", "abandoned"]; export type DispatchLaneStatus = (typeof DISPATCH_LANE_STATUSES)[number]; /** * How a dispatch ran the lane: `answer` is one HTTP call to the relay's own `/v1/messages` (a relay * lane in answer mode); `agent` is a spawned harness running a tool loop (every other case). * * ⚠ It keys the lane-stats window since 2026-09-10, because the two are different populations: a * burst of short answer-mode calls set `free-pool`'s p80 to 39.5 s, and that one shared window then * cut every agent-mode task on the same lane at the 90 s floor * (`docs/history/dispatch-giveup-diagnosis-2026-09-10.md` §3). One `as const` array, the type indexed from * it and the parser validating against it — the closed-union rule. */ export declare const DISPATCH_MODES: readonly ["agent", "answer"]; export type DispatchMode = (typeof DISPATCH_MODES)[number]; /** * What `llm-relay mcp` reports to the daemon when an agent-mode lane run settles. Counts and * lengths only — never the task text, never the lane's output (logs-are-metadata-only). * * ⚠ No `providerKey`/`modelId`: an agent-mode lane's serving member is unknowable to the * reporter (a `cli` lane runs its own tool loop against its own credentials; a `relay` lane's * HTTP traffic is already metered by the daemon's own pipeline), so carrying them would label * a guess as a measurement. */ export interface DispatchedTelemetryReport { jobId: string; laneId: string; kind: DispatchLaneKind; spec?: string; /** * Ladder tier the lane was taken from, when the dispatch selected one. Absent for the legacy * single ladder. * * ⚠ It is here because the daemon's routing MEMORY is keyed by tier: each tier is its own * ladder with its own rungs, so a lane that answered a `low` task says nothing about the * `xhigh` ladder, and a pin recorded without the tier would let one cheap success steer every * reasoning level. Metadata like every other field — a configured ladder name, never task text. */ tier?: string; /** * How the lane ran (`DispatchMode`). Absent from an older MCP child, whose report then lands in * the mode-less legacy window rather than being guessed into one. */ mode?: DispatchMode; wallClockMs: number; exitCode: number | null; status: DispatchLaneStatus; estimatedInputTokens: number; estimatedOutputTokens: number; } /** * Fail-closed validation of one report body. Anything unexpected — unknown keys, a missing * field, a bad kind/status, a negative or fractional token count, an over-long id — yields * null rather than a guess. The 400 reason never echoes the body. */ export declare function parseTelemetryReport(value: unknown): DispatchedTelemetryReport | null; export interface LaneStats { /** Lane this window belongs to (the map key is composite, so the entry carries its parts). */ laneId: string; /** Ladder tier this window belongs to, or null for the legacy single ladder. */ tier: string | null; /** * Dispatch mode this window belongs to, or null for a legacy window recorded before modes were * reported. A legacy window mixes both populations, so it is read only as a fallback. */ mode: DispatchMode | null; calls: number; successes: number; failures: number; timeouts: number; /** * Runs that FAILED on their own (`failed` or `timed_out`) since the last `completed` one. A walk * abandonment is not counted: that is the relay's decision, not the lane's failure. A success * resets it to 0. The walk reads it (a later lane on a streak cannot be relied on to answer), and * so does the failing-lane demotion in `dispatch.ts`. */ consecutiveFailures: number; /** When this window last recorded a `completed` run, or null. */ lastSuccessAt: number | null; /** * Bounded wall-clock sample window, oldest dropped. Since 2026-09-10 ONLY a `completed` run adds a * sample: a failure's or timeout's wall clock is time to FAILURE, not time to answer, so it must * never enter a statistic labelled as the lane's time to answer. */ wallClockMs: number[]; /** * Per-sample ISO timestamps, parallel to `wallClockMs` — entry `i` is when sample `i` ran. * `null` on a sample recorded before timestamps existed. The parallelism is the invariant: * both windows trim together, so position `i` always means the same run in both. */ wallClockAt: (string | null)[]; lastAt: number | null; } /** * Rolling wall-clock window per lane; the bound keeps one chatty lane from growing the file. * * Raised 25 -> 100 on 2026-09-08 when this history began driving dispatch decisions. The old * attempt-budget consumer was removed after stopping became idle-only; the same bounded window is * still used for time-to-answer reporting and recent-vs-history outlier demotion. * * ⚠ Raising it is backward compatible in the direction that matters: `isLaneStatsRow` rejects a * window LONGER than this bound, so a file written under the old 25 still loads. Lowering it * later would drop every existing row instead, which is why this constant only ever grows. */ export declare const MAX_LANE_STAT_SAMPLES = 100; /** * Median of one lane's rolling wall-clock window, in milliseconds. Null when the window is * empty — unknown stays null, never 0, so a surface cannot print a measured-looking figure for * a lane that never ran here. Pure: `buildDispatch` (`dispatch.ts`) calls this rather than * re-implementing the percentile, so the view and the tests share the one definition. */ export declare function medianWallClockMs(samples: readonly number[]): number | null; /** * 95th percentile of one lane's rolling wall-clock window, in milliseconds. Null when the window * is empty, the same unknown-stays-null rule as the median beside it. * * ⚠ It exists because the MEDIAN HIDES THE TAIL, and the tail is what an operator giving up on a * lane is actually looking at. Measured on the live store 2026-09-05: median 111.5 s, p95 900 s, * max 1500 s — three figures that support three different conclusions, of which the ladder printed * only the smallest. `latency-demotion.ts` uses p95 on the HTTP path for exactly this reason, and * `docs/backlog.md` names the mismatch as a defect. * * ⚠ It is REPORTED, never acted on. Nothing here demotes a lane on a wall-clock threshold: the * recorded window mixes several sessions' traffic, so no threshold drawn from it means anything * yet, and borrowing the HTTP path's numbers would demote every healthy lane at once. The * demotion that DOES happen is first-party evidence from the walk (`lane-affinity.ts`). * * Nearest-rank, so the answer is always an OBSERVED sample rather than an interpolation between * two: a percentile that reports a duration nothing ever took is a fabricated measurement. */ export declare function p95WallClockMs(samples: readonly number[]): number | null; /** * Nearest-rank quantile over one lane's rolling window, in milliseconds. Null for an empty window * — unknown stays null, never 0. * * ⚠ Nearest-rank, so the answer is always an OBSERVED sample rather than an interpolation between * two: a duration nothing ever took would be a fabricated measurement. * * The quantile is bounded defensively here for direct callers; configuration consumers such as * outlier demotion validate their own declared quantiles before calling it. * * ⚠⚠ **Do not read the `Math.max(Number.EPSILON, …)` as protection against a quantile of 0 — it * changes no answer, and the comment here claimed the opposite until 2026-09-08.** With `q` at * EPSILON, `Math.ceil(q * n)` is 1; with `q` at 0 it is 0, which `Math.max(1, rank)` below then * lifts to 1. Both paths return `sorted[0]`, the fastest sample — which is precisely the outcome * the old comment said the clamp prevented. The clamp survives as a statement of the intended * domain; the `Math.max(1, rank)` floor is what actually holds the bottom, and a non-finite * quantile falling to 1 is what actually holds the top. */ export declare function quantileWallClockMs(samples: readonly number[], quantile: number): number | null; /** * The CLOSED list of env names that can route a lane's harness traffic back through this * relay. Any other name — however credential-shaped — never counts: an open list would let * a future env var silently reclassify a lane's accounting. */ export declare const RELAY_ROUTED_ENV_NAMES: readonly ["ANTHROPIC_BASE_URL", "OPENAI_BASE_URL"]; /** * Whether this rung's declared env routes its harness traffic back through the relay * listener at `listener` — i.e. the daemon's own HTTP pipeline already meters the run, so * a telemetry accounting row would double count it (finding C1). * * A labelled fact about the lane's own wiring, config-derived, never guessed: true only * when a closed-list env name holds a parseable URL whose origin (scheme://host:port, * IPv6-bracket-aware, default ports applied) equals the listener's own origin, built with * the same IPv6 bracketing rule `proxyUrl` applies. A `null` env value (unset), an absent * name, an unparseable URL, or any other name ⇒ false. Pure, so the daemon route and the * suite share the one definition. */ export declare function laneRoutesThroughRelay(rung: Pick, listener: { host: string; port: number; }): boolean; /** * Record one settled lane run. `completed` counts a success; `failed` a failure; `timed_out` * a timeout AND a failure (a timeout did not succeed). Stats never reorder the ladder. * * The run lands in the (lane, tier) window named by the report's own `tier` (null when the * report names none — the legacy single ladder). The `POST /dispatch/telemetry` route passes * nothing extra: the report already carries `tier`, so tiering flows through this one argument. */ export declare function recordLaneRun(cfg: Config, report: DispatchedTelemetryReport, now?: number): void; /** * One (lane, tier, mode) window for this config, or undefined when that lane never ran there. A * copy. A null `mode` is the legacy mode-less window. */ export declare function laneStatsFor(cfg: Config, laneId: string, tier?: string | null, mode?: DispatchMode | null): LaneStats | undefined; /** * Every (lane, tier, mode) window for this config, sorted by lane id, then tier (tier-less first), * then mode (mode-less first) so surfaces render stably. Copies. */ export declare function allLaneStats(cfg: Config): Array<{ laneId: string; } & LaneStats>; export declare function onLaneStatsChanged(cfg: Config, listener: () => void): void; /** * One exported/persisted lane row: the (lane, tier) key plus its counters and sample window. * * ⚠ `tier` and `wallClockAt` are OPTIONAL on the wire, and that is the whole backward-compat * story: a row written before tiering (no `tier` key, bare-number samples) loads here with * `tier: null` and all-null timestamps — nothing is rewritten or copied — and a row written * here loads on the previous release, whose validator reads the fields it knows (`laneId`, * the counters, the numeric `wallClockMs`, `lastAt`) and ignores the two keys it does not. * No existing field changed meaning, so the schema version does NOT bump (the * `breaker-persistence.ts` rule: bump only when the MEANING of an existing field changes). * * Since 2026-09-10 `mode`, `consecutiveFailures`, `abandonedSinceSuccess` and `lastSuccessAt` are * optional on the wire. `abandonedSinceSuccess` is retained only as a compatibility field for * files written by the retired attempt-budget mechanism; new rows omit it and restore ignores it. The * sample window NARROWED that day — only a completed run adds a sample — and the version still does * not bump: a bump would drop every lane's history, while an older row's mixed samples simply age * out of the bounded window as new runs arrive. */ export interface LaneStatsRow { laneId: string; tier?: string | null; mode?: DispatchMode | null; calls: number; successes: number; failures: number; timeouts: number; consecutiveFailures?: number; abandonedSinceSuccess?: number; lastSuccessAt?: number | null; wallClockMs: number[]; wallClockAt?: (string | null)[]; lastAt: number | null; } /** Still-live rows for persistence and ladder surfaces. */ export declare function exportLaneStatsRows(cfg: Config): LaneStatsRow[]; /** * Restore persisted rows into this config's live map. It NEVER overwrites stats the live * process already learned — the `restoreExhaustedRows` contract. Returns the count restored. */ export declare function restoreLaneStatsRows(cfg: Config, rows: readonly LaneStatsRow[]): number; /** Bumped when the row shape changes; a mismatch restores nothing rather than guessing. */ export declare const CURRENT_DISPATCH_LANE_STATS_VERSION = 1; export interface DispatchLaneStatsFile { version: number; rows: LaneStatsRow[]; } export declare function getDispatchLaneStatsPath(): string; /** * Read the persisted rows. Absent file, unreadable file, wrong version, or an unrecognized * envelope all yield an empty list — never a throw, never a partial row from a shape we did * not recognize. One malformed row is dropped alone. */ export declare function loadLaneStatsRows(opts?: { path?: string; }): LaneStatsRow[]; export declare function saveLaneStatsRows(rows: readonly LaneStatsRow[], opts?: { path?: string; }): void; /** * Wire a config's lane stats to the file: restore what was recorded, then flush on every * change, debounced through the shared `WriteBehindTimer`. Returns the number restored. */ export declare function installDispatchLaneStatsPersistence(cfg: Config, opts?: { path?: string; }): number; /** * The shutdown seam: write every dirty window NOW. Until 2026-09-08 nothing could — the timer * lived in a closure only the change listener held, so a lane run recorded in the last two * seconds before a graceful stop never reached the file. Returns how many files were written. */ export declare function flushDispatchLaneStatsPersistence(): number;