import type { Loop, LoopRun, LoopStatus, RunStatus } from "../types.js"; import type { DaemonStatus } from "../daemon/control.js"; import type { DoctorCheck, DoctorReport } from "./doctor.js"; /** * The entire read surface the loop-health classifier needs: loops, and the * runs belonging to a loop. The local sqlite {@link Store} satisfies it * structurally, and so can an in-memory snapshot pre-fetched from the hosted * `/v1` API — which is what lets `loops health` answer against the hosted * control plane instead of refusing (task e3b6f1d4). * * Deliberately narrow: widening it re-couples the classifier to sqlite and * silently un-implements the hosted path. */ export interface HealthSource { listLoops(opts?: { status?: LoopStatus; includeArchived?: boolean; limit?: number; }): Loop[]; listRuns(opts?: { loopId?: string; status?: RunStatus; limit?: number; }): LoopRun[]; } /** * Grace period before an active loop whose scheduled slot has passed counts as * unclaimed. Matches the floor already used for stale-running detection. */ export declare const DEFAULT_OVERDUE_GRACE_MS: number; /** * A scheduled slot that came and went without the scheduler claiming it. * * This is the one signal that separates "the scheduler is alive" from "the * scheduler stopped claiming": every other check in this file classifies the * *outcome of the last run*, so a dead scheduler reports as uniformly healthy — * the last run of every loop succeeded, because it ran before the scheduler * died. That is exactly what an operator saw during the 2026-07-31 incident. * * Passing state: the loop is not active, has no nextRunAt, its nextRunAt is * still ahead of `now` (or within `graceMs` of it), or a run for that exact * slot is still in flight. Failing state: an active loop whose nextRunAt is * more than `graceMs` in the past with no run in flight for that slot. Both are * reachable from the same input by moving `nextRunAt` across `now - graceMs`, * or by flipping `latestRun` between running-at-slot and terminal. * * `latestRun` is load-bearing, not a refinement. `nextRunAt` is advanced only * AFTER a run finishes (`advanceLoop` in src/daemon/daemon.ts), so a loop whose * run is legitimately executing has its slot sitting in the past for the entire * run. Without this, a healthy 20-minute run reports as an unclaimed slot — * measured on this fleet, where 20 of 200 recent runs exceed 10 minutes. The * check would then manufacture the incident it exists to detect. * * The slot must match. A run wedged on an OLDER slot is not evidence the * scheduler is alive — the current slot still went unclaimed — so suppressing * on any running run would silence the dead-scheduler case instead. */ export declare function scheduleOverdue(loop: Loop, now: Date, graceMs?: number, latestRun?: Pick): { nextRunAt: string; byMs: number; } | undefined; export type RunFailureClassification = "rate_limit" | "auth" | "provider_capacity" | "provider_unavailable" | "model_not_found" | "context_length" | "schema_response_format" | "node_init" | "preflight" | "route_functional" | "timeout" | "sigsegv" | "restart_interrupted" | "skipped_previous_active" | "circuit_breaker" | "unknown"; export interface RunFailureSignal { classification: RunFailureClassification; fingerprint: string; evidence: { summary?: string; error?: string; stdout?: string; stderr?: string; exitCode?: number; }; } export interface RecommendedTaskUpsert { title: string; description: string; priority: "critical" | "high" | "medium" | "low"; tags: string[]; dedupeKey: string; search: { query: string; }; compatibilityFallback: { search: string[]; add: string[]; comment: string[]; }; futureNativeUpsert: { command: string; fields: Record; }; } export interface LoopExpectationResult { loop: Pick; ok: boolean; check: { id: "latest-run-succeeded" | "route-functional-health"; status: "pass" | "fail" | "warn"; message: string; }; latestRun?: LoopRun; failure?: RunFailureSignal; /** * Set when the loop's scheduled slot passed without being claimed. Additive * and independent of `check`/`ok`, so restoring this signal cannot change an * existing verdict or exit code — see {@link scheduleOverdue}. */ overdue?: { nextRunAt: string; byMs: number; }; route: { source: "openloops"; kind: "loop_expectation"; loopId: string; loopName: string; cwd?: string; provider?: string; }; recommendedTask?: RecommendedTaskUpsert; } export interface LoopsHealthReport { ok: boolean; generatedAt: string; summary: { loops: number; healthy: number; unhealthy: number; warnings: number; /** Active loops whose scheduled slot passed unclaimed. */ overdue: number; }; classifications: Record; expectations: LoopExpectationResult[]; } export type HealthScanStatus = "ok" | "degraded" | "critical"; export type HealthScanFindingKind = "daemon" | "doctor" | "preflight" | "latest-run" | "stale-running"; export type HealthScanFindingSeverity = "critical" | "high" | "medium" | "low"; export interface HealthScanSelfHealAction { kind: "daemon-start"; attempted: boolean; ok?: boolean; reason: string; result?: Record; } export interface HealthScanFinding { kind: HealthScanFindingKind; severity: HealthScanFindingSeverity; fingerprint: string; title: string; message: string; loop?: Pick & { leaseMs?: number; }; run?: LoopRun; route?: LoopExpectationResult["route"]; ageMs?: number; staleThresholdMs?: number; classification?: RunFailureClassification; doctorCheck?: DoctorCheck; recommendedTask?: RecommendedTaskUpsert; } export interface LoopsHealthScan { ok: boolean; status: HealthScanStatus; generatedAt: string; includedStatuses: LoopStatus[]; counts: { loops: number; active: number; paused: number; stopped: number; expired: number; latestRunFindings: number; staleRunning: number; daemonFindings: number; doctorFindings: number; preflightFindings: number; findings: number; reportedFindings: number; truncatedFindings: number; }; daemon?: Pick; doctor?: DoctorReport; health: LoopsHealthReport; selfHeals: HealthScanSelfHealAction[]; findings: HealthScanFinding[]; reports?: { dir: string; json: string; markdown: string; }; todos?: Record; } export interface BuildHealthScanOptions { includeStatuses?: LoopStatus[]; includeArchived?: boolean; limit?: number; latestRun?: boolean; doctor?: DoctorReport; daemon?: DaemonStatus; selfHeals?: HealthScanSelfHealAction[]; maxFindings?: number; staleRunningMs?: number; now?: Date; } export interface WriteHealthScanReportsOptions { reportDir?: string; } export declare const RESTART_INTERRUPTED_RUN_PREFIX = "daemon restart interrupted active run"; /** * Bun can briefly resolve a globally installed signal-exit package whose ESM * shape does not match the importing CLI. The command has not run yet and the * same invocation succeeds once the global install settles, so this specific * loader failure must not be treated as a deterministic workload failure. */ export declare function isTransientSignalExitInteropFailure(run: LoopRun): boolean; export declare function classifyRunFailure(run: LoopRun): RunFailureSignal | undefined; export interface ExpectationOptions { now?: Date; overdueGraceMs?: number; } /** * Classify a loop, then attach the unclaimed-slot observation. The two are kept * separate on purpose: `classifyExpectation` decides `ok`/`check` exactly as it * always has, and `overdue` rides alongside without touching either. * * `result.latestRun` is the run `classifyExpectation` already fetched, so * feeding it to `scheduleOverdue` costs no extra round trip — this matters on * the hosted path, where each loop's latest run is one HTTP request. */ export declare function expectationForLoop(store: HealthSource, loop: Loop, opts?: ExpectationOptions): LoopExpectationResult; export declare function buildHealthReport(store: HealthSource, opts?: { includeArchived?: boolean; includeInactive?: boolean; limit?: number; } & ExpectationOptions): LoopsHealthReport; export declare function buildHealthScan(store: HealthSource, opts?: BuildHealthScanOptions): LoopsHealthScan; export declare function writeHealthScanReports(scan: LoopsHealthScan, opts?: WriteHealthScanReportsOptions): LoopsHealthScan;