@saadnvd1/agent-os 0.3.616 → 0.3.624

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +1 -0
  2. package/app/api/health/anomalies/route.ts +45 -0
  3. package/app/api/health/config/route.ts +31 -0
  4. package/app/api/health/notes/route.ts +33 -0
  5. package/components/Chat/ChatPanel.tsx +2 -1
  6. package/components/Health/HealthDot.tsx +35 -0
  7. package/components/Health/HealthPanel.tsx +412 -0
  8. package/components/Pane/DesktopTabBar.tsx +4 -0
  9. package/components/Pane/MobileTabBar.tsx +4 -0
  10. package/components/Pane/MobileViewPicker.test.ts +8 -0
  11. package/components/Pane/MobileViewPicker.tsx +4 -1
  12. package/components/Pane/PaneViewToggle.test.ts +82 -0
  13. package/components/Pane/PaneViewToggle.tsx +16 -8
  14. package/components/Pane/git-visibility.ts +9 -0
  15. package/components/Pane/index.tsx +18 -2
  16. package/components/SessionList/SidebarHeader.tsx +2 -0
  17. package/components/Settings/SettingsDialog.tsx +9 -0
  18. package/components/views/DesktopAppActions.tsx +23 -17
  19. package/components/views/DesktopView.tsx +1 -1
  20. package/data/git/queries.ts +16 -3
  21. package/data/health/index.ts +111 -0
  22. package/lib/chat/pending.ts +13 -1
  23. package/lib/db/migrations/20261015T1500_health_watch.ts +68 -0
  24. package/lib/health/boots.ts +79 -0
  25. package/lib/health/ci.ts +90 -0
  26. package/lib/health/config.ts +79 -0
  27. package/lib/health/detectors.ts +227 -0
  28. package/lib/health/errors.ts +90 -0
  29. package/lib/health/evidence.ts +172 -0
  30. package/lib/health/facts.ts +159 -0
  31. package/lib/health/fixtures.ts +30 -0
  32. package/lib/health/notes.ts +76 -0
  33. package/lib/health/pieces.ts +120 -0
  34. package/lib/health/route.ts +113 -0
  35. package/lib/health/store.ts +212 -0
  36. package/lib/health/tick.ts +149 -0
  37. package/lib/health/types.ts +148 -0
  38. package/lib/health/view.ts +120 -0
  39. package/lib/health/watchers.ts +176 -0
  40. package/lib/learning-cards/apply.ts +54 -8
  41. package/lib/load/alert.ts +4 -8
  42. package/lib/models/plan-step.ts +18 -3
  43. package/lib/models/roles.ts +6 -0
  44. package/lib/orchestrator/deliver.ts +1 -0
  45. package/lib/orchestrator/events.ts +13 -4
  46. package/lib/orchestrator/watcher.ts +12 -0
  47. package/lib/plans/release.ts +7 -0
  48. package/lib/settings-url.ts +1 -0
  49. package/lib/tasks/refresh-base.ts +78 -0
  50. package/package.json +1 -1
  51. package/scripts/autodeploy +2 -2
  52. package/scripts/autodeploy.test.ts +2 -2
  53. package/server.ts +11 -0
@@ -0,0 +1,90 @@
1
+ /**
2
+ * How long CI takes, learned from what the orchestrator already sees. Each
3
+ * diff it reads every open PR's verdict; the first time a head is pending is
4
+ * when its run started (to within a diff), and the first time it isn't is
5
+ * when it settled. No extra gh call.
6
+ */
7
+
8
+ import { db } from "../db";
9
+ import type { SessionFacts } from "../orchestrator/facts";
10
+ import { MIN, type CiFact } from "./types";
11
+
12
+ const BASELINE_RUNS = 20;
13
+ // A pending row older than this is a lost one, not a slow run.
14
+ const LOST_AFTER_MS = 24 * 60 * MIN;
15
+
16
+ export function observeCi(
17
+ workspaceId: string,
18
+ facts: Pick<SessionFacts, "project" | "task">[],
19
+ now: number
20
+ ): void {
21
+ db.transaction(() => {
22
+ for (const f of facts) {
23
+ const pr = f.task?.pr;
24
+ const project = f.project;
25
+ if (!pr || !project) continue;
26
+ const head = pr.head;
27
+ if (pr.state !== "OPEN" || !head) {
28
+ db.prepare(
29
+ `DELETE FROM ci_runs WHERE project_id = ? AND pr_number = ?
30
+ AND settled_at IS NULL`
31
+ ).run(project, pr.number);
32
+ continue;
33
+ }
34
+ if (pr.checks === "pending") {
35
+ // A newer head replaces the run still pending on an older one.
36
+ db.prepare(
37
+ `DELETE FROM ci_runs WHERE project_id = ? AND pr_number = ?
38
+ AND head != ? AND settled_at IS NULL`
39
+ ).run(project, pr.number, head);
40
+ db.prepare(
41
+ `INSERT OR IGNORE INTO ci_runs
42
+ (project_id, workspace_id, pr_number, head, pending_since)
43
+ VALUES (?, ?, ?, ?, ?)`
44
+ ).run(project, workspaceId, pr.number, head, now);
45
+ } else if (pr.checks === "pass" || pr.checks === "fail") {
46
+ db.prepare(
47
+ `UPDATE ci_runs SET settled_at = ?
48
+ WHERE project_id = ? AND pr_number = ? AND head = ?
49
+ AND settled_at IS NULL`
50
+ ).run(now, project, pr.number, head);
51
+ }
52
+ }
53
+ })();
54
+ }
55
+
56
+ export function pendingCi(now: number): CiFact[] {
57
+ const rows = db
58
+ .prepare(
59
+ `SELECT * FROM ci_runs WHERE settled_at IS NULL AND pending_since >= ?`
60
+ )
61
+ .all(now - LOST_AFTER_MS) as {
62
+ project_id: string;
63
+ workspace_id: string | null;
64
+ pr_number: number;
65
+ head: string;
66
+ pending_since: number;
67
+ }[];
68
+ return rows.map((r) => ({
69
+ projectId: r.project_id,
70
+ workspaceId: r.workspace_id,
71
+ prNumber: r.pr_number,
72
+ head: r.head,
73
+ pendingSince: r.pending_since,
74
+ baseline: baseline(r.project_id),
75
+ }));
76
+ }
77
+
78
+ export function baseline(projectId: string): number[] {
79
+ return (
80
+ db
81
+ .prepare(
82
+ `SELECT settled_at - pending_since AS ms FROM ci_runs
83
+ WHERE project_id = ? AND settled_at IS NOT NULL
84
+ ORDER BY settled_at DESC LIMIT ?`
85
+ )
86
+ .all(projectId, BASELINE_RUNS) as { ms: number }[]
87
+ )
88
+ .map((r) => r.ms)
89
+ .filter((ms) => ms > 0);
90
+ }
@@ -0,0 +1,79 @@
1
+ // The thresholds, all editable in Settings → Health. A setting holds a
2
+ // number as text under health.<name>; anything missing or invalid is the
3
+ // default, so a bad edit can't turn a check off by accident.
4
+
5
+ import { getSetting, putSetting } from "../settings-store";
6
+
7
+ export const HEALTH_DEFAULTS = {
8
+ loadRedMinutes: 10,
9
+ queueHeldMinutes: 60,
10
+ bgQuietMinutes: 20,
11
+ pieceDupOverlap: 0.4,
12
+ restartsMax: 2,
13
+ restartsWindowMinutes: 60,
14
+ ciSlowFallbackMinutes: 20,
15
+ armedStuckMinutes: 120,
16
+ serverErrorsMin: 20,
17
+ serverErrorsFactor: 5,
18
+ // The watchers stop waking past this share of the 5-hour usage window.
19
+ wakeUsageCeilingPct: 90,
20
+ // 0 turns the watchers off: anomalies still open and reach the chat.
21
+ watchersOn: 1,
22
+ } as const;
23
+
24
+ export type HealthKey = keyof typeof HEALTH_DEFAULTS;
25
+ export type HealthConfig = Record<HealthKey, number>;
26
+
27
+ export const HEALTH_LABELS: Record<HealthKey, string> = {
28
+ loadRedMinutes: "Machine load red for (minutes)",
29
+ queueHeldMinutes: "Queued task held for (minutes)",
30
+ bgQuietMinutes: "Background job quiet for (minutes)",
31
+ pieceDupOverlap: "Plan piece matches merged work (0 to 1 overlap)",
32
+ restartsMax: "Server restarts allowed per window",
33
+ restartsWindowMinutes: "Restart window (minutes)",
34
+ ciSlowFallbackMinutes: "CI pending, when no history yet (minutes)",
35
+ armedStuckMinutes: "PR armed without merging for (minutes)",
36
+ serverErrorsMin: "Server errors per 10 minutes, at least",
37
+ serverErrorsFactor: "Server errors, times the usual rate",
38
+ wakeUsageCeilingPct: "Stop waking watchers above usage (%)",
39
+ watchersOn: "Watchers on (1) or off (0)",
40
+ };
41
+
42
+ const settingKey = (k: HealthKey) => `health.${k}`;
43
+
44
+ export function healthConfig(
45
+ read: (key: string) => string | null = getSetting
46
+ ): HealthConfig {
47
+ const out = {} as HealthConfig;
48
+ for (const k of Object.keys(HEALTH_DEFAULTS) as HealthKey[]) {
49
+ const raw = read(settingKey(k));
50
+ const n = raw === null || raw.trim() === "" ? NaN : Number(raw);
51
+ out[k] = Number.isFinite(n) && n >= 0 ? n : HEALTH_DEFAULTS[k];
52
+ }
53
+ return out;
54
+ }
55
+
56
+ // Stores a patch; null puts the default back. Returns what it refused.
57
+ export function setHealthConfig(patch: Record<string, unknown>): {
58
+ errors: string[];
59
+ } {
60
+ const errors: string[] = [];
61
+ for (const [k, v] of Object.entries(patch)) {
62
+ if (!(k in HEALTH_DEFAULTS)) {
63
+ errors.push(`Unknown setting ${k}`);
64
+ continue;
65
+ }
66
+ const key = k as HealthKey;
67
+ if (v === null) {
68
+ putSetting(settingKey(key), null);
69
+ continue;
70
+ }
71
+ const n = typeof v === "number" ? v : Number(v);
72
+ if (!Number.isFinite(n) || n < 0) {
73
+ errors.push(`${HEALTH_LABELS[key]} must be a number, 0 or more`);
74
+ continue;
75
+ }
76
+ putSetting(settingKey(key), String(n));
77
+ }
78
+ return { errors };
79
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The checks. Each is a pure function of one snapshot (facts.ts) and the
3
+ * thresholds (config.ts): no I/O, no model, no tokens. A check that finds
4
+ * something returns a Trip; store.ts decides whether that's news.
5
+ */
6
+
7
+ import type { HealthConfig } from "./config";
8
+ import { duration } from "./view";
9
+ import {
10
+ MIN,
11
+ type BgFact,
12
+ type HealthFacts,
13
+ type Severity,
14
+ type Trip,
15
+ } from "./types";
16
+
17
+ export { duration };
18
+
19
+ const trip = (
20
+ t: Omit<Trip, "workspaceId" | "detail"> &
21
+ Partial<Pick<Trip, "workspaceId" | "detail">>
22
+ ): Trip => ({ workspaceId: null, detail: {}, ...t });
23
+
24
+ // More than this many times the threshold reads as high.
25
+ const HIGH_AFTER = 3;
26
+ const severityOf = (age: number, limit: number): Severity =>
27
+ limit > 0 && age >= limit * HIGH_AFTER ? "high" : "warn";
28
+
29
+ export function loadRed(f: HealthFacts, c: HealthConfig): Trip[] {
30
+ const limit = c.loadRedMinutes * MIN;
31
+ return f.machines
32
+ .filter((m) => m.redSince !== null && f.now - m.redSince >= limit)
33
+ .map((m) => {
34
+ const age = f.now - (m.redSince as number);
35
+ return trip({
36
+ check: "load-red",
37
+ subject: m.id,
38
+ severity: severityOf(age, limit),
39
+ line: `${m.label} load has been red for ${duration(age)}.`,
40
+ detail: { load1: m.load1 ?? null, cores: m.cores ?? null },
41
+ });
42
+ });
43
+ }
44
+
45
+ export function queueHeld(f: HealthFacts, c: HealthConfig): Trip[] {
46
+ const limit = c.queueHeldMinutes * MIN;
47
+ return f.queue
48
+ .filter((q) => f.now - q.createdAt >= limit)
49
+ .map((q) => {
50
+ const age = f.now - q.createdAt;
51
+ return trip({
52
+ check: "queue-held",
53
+ subject: q.id,
54
+ severity: severityOf(age, limit),
55
+ line: `Queued task "${q.name ?? q.id}" has been held ${duration(age)}: ${q.heldBy}.`,
56
+ workspaceId: q.workspaceId,
57
+ detail: { heldBy: q.heldBy },
58
+ });
59
+ });
60
+ }
61
+
62
+ const bgLine = (b: BgFact, quietMs: number) =>
63
+ b.outputAt === null
64
+ ? `Background job "${b.description}" in ${b.sessionName} has printed nothing in ${duration(quietMs)}.`
65
+ : `Background job "${b.description}" in ${b.sessionName} has been quiet ${duration(quietMs)}.`;
66
+
67
+ export function bgQuiet(f: HealthFacts, c: HealthConfig): Trip[] {
68
+ const limit = c.bgQuietMinutes * MIN;
69
+ return f.bg
70
+ .map((b) => ({ b, quietMs: f.now - (b.outputAt ?? b.startedAt) }))
71
+ .filter(({ quietMs }) => quietMs >= limit)
72
+ .map(({ b, quietMs }) =>
73
+ trip({
74
+ check: "bg-quiet",
75
+ subject: `${b.sessionId}:${b.taskId}`,
76
+ severity: severityOf(quietMs, limit),
77
+ line: bgLine(b, quietMs),
78
+ workspaceId: b.workspaceId,
79
+ detail: {
80
+ sessionId: b.sessionId,
81
+ taskId: b.taskId,
82
+ neverPrinted: b.outputAt === null,
83
+ },
84
+ })
85
+ );
86
+ }
87
+
88
+ export function restarts(f: HealthFacts, c: HealthConfig): Trip[] {
89
+ const recent = f.boots.filter(
90
+ (t) => f.now - t <= c.restartsWindowMinutes * MIN
91
+ );
92
+ if (recent.length <= c.restartsMax) return [];
93
+ return [
94
+ trip({
95
+ check: "restarts",
96
+ subject: "server",
97
+ severity: "high",
98
+ line: `AgentOS restarted ${recent.length} times in the last ${c.restartsWindowMinutes} minutes.`,
99
+ detail: { boots: recent },
100
+ }),
101
+ ];
102
+ }
103
+
104
+ // The oldest a CI run may be and still be usual: twice the median, or the
105
+ // slowest tenth plus five minutes, whichever is longer. With too little
106
+ // history, the fallback.
107
+ export function ciLimit(baseline: number[], fallbackMs: number): number {
108
+ if (baseline.length < 5) return fallbackMs;
109
+ const sorted = [...baseline].sort((a, b) => a - b);
110
+ const at = (p: number) =>
111
+ sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
112
+ return Math.max(2 * at(0.5), at(0.9) + 5 * MIN);
113
+ }
114
+
115
+ export function ciSlow(f: HealthFacts, c: HealthConfig): Trip[] {
116
+ return f.ci
117
+ .map((r) => ({
118
+ r,
119
+ limit: ciLimit(r.baseline, c.ciSlowFallbackMinutes * MIN),
120
+ }))
121
+ .filter(({ r, limit }) => f.now - r.pendingSince > limit)
122
+ .map(({ r, limit }) => {
123
+ const age = f.now - r.pendingSince;
124
+ return trip({
125
+ check: "ci-slow",
126
+ subject: `${r.projectId}#${r.prNumber}:${r.head.slice(0, 8)}`,
127
+ severity: severityOf(age, limit),
128
+ line: `CI on PR #${r.prNumber} has been pending ${duration(age)}; it usually settles within ${duration(limit)}.`,
129
+ workspaceId: r.workspaceId,
130
+ detail: { prNumber: r.prNumber, head: r.head, limitMs: limit },
131
+ });
132
+ });
133
+ }
134
+
135
+ export function armedStuck(f: HealthFacts, c: HealthConfig): Trip[] {
136
+ const limit = c.armedStuckMinutes * MIN;
137
+ return f.armed
138
+ .filter((a) => f.now - a.armedAt >= limit)
139
+ .map((a) => {
140
+ const age = f.now - a.armedAt;
141
+ return trip({
142
+ check: "armed-stuck",
143
+ subject: a.sessionId,
144
+ severity: severityOf(age, limit),
145
+ line: `PR${a.prNumber ? ` #${a.prNumber}` : ""} of ${a.name} has been armed ${duration(age)} without merging.`,
146
+ workspaceId: a.workspaceId,
147
+ detail: { prNumber: a.prNumber },
148
+ });
149
+ });
150
+ }
151
+
152
+ export function serverErrors(f: HealthFacts, c: HealthConfig): Trip[] {
153
+ const limit = Math.max(
154
+ c.serverErrorsMin,
155
+ c.serverErrorsFactor * f.errors.baselineMean
156
+ );
157
+ if (f.errors.recent < limit) return [];
158
+ return [
159
+ trip({
160
+ check: "server-errors",
161
+ subject: "server",
162
+ severity: f.errors.recent >= limit * 2 ? "high" : "warn",
163
+ line: `${f.errors.recent} server errors in the last 10 minutes (usually ${Math.round(f.errors.baselineMean)}).`,
164
+ detail: { recent: f.errors.recent },
165
+ }),
166
+ ];
167
+ }
168
+
169
+ export function runDetectors(f: HealthFacts, c: HealthConfig): Trip[] {
170
+ return [
171
+ ...loadRed(f, c),
172
+ ...queueHeld(f, c),
173
+ ...bgQuiet(f, c),
174
+ ...restarts(f, c),
175
+ ...ciSlow(f, c),
176
+ ...armedStuck(f, c),
177
+ ...serverErrors(f, c),
178
+ ];
179
+ }
180
+
181
+ // --- piece-dup: raised where a piece is about to start, not on the tick ---
182
+
183
+ const STOP = new Set(
184
+ "a an and the of to for in on with from by is are as at be it this that into add fix feat feature update make use new piece slice".split(
185
+ " "
186
+ )
187
+ );
188
+
189
+ export function words(text: string): Set<string> {
190
+ const out = new Set<string>();
191
+ for (const w of text.toLowerCase().match(/[a-z][a-z0-9]{2,}/g) ?? []) {
192
+ if (STOP.has(w)) continue;
193
+ out.add(w.replace(/(ing|ed|es|s)$/, ""));
194
+ }
195
+ return out;
196
+ }
197
+
198
+ // How much of the piece's words the merged work's words cover, 0 to 1.
199
+ export function overlap(piece: string, merged: string): number {
200
+ const a = words(piece);
201
+ if (!a.size) return 0;
202
+ const b = words(merged);
203
+ let hit = 0;
204
+ for (const w of a) if (b.has(w)) hit++;
205
+ return hit / a.size;
206
+ }
207
+
208
+ export interface MergedWork {
209
+ sessionId: string;
210
+ prNumber: number | null;
211
+ text: string;
212
+ }
213
+
214
+ export function matchingMerge(
215
+ piece: { name: string; slice: string },
216
+ merged: MergedWork[],
217
+ threshold: number
218
+ ): { work: MergedWork; score: number } | null {
219
+ const text = `${piece.name} ${piece.slice}`;
220
+ let best: { work: MergedWork; score: number } | null = null;
221
+ for (const work of merged) {
222
+ const score = overlap(text, work.text);
223
+ if (score >= threshold && (!best || score > best.score))
224
+ best = { work, score };
225
+ }
226
+ return best;
227
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Server errors, counted. console.error calls go into ten-minute
3
+ * buckets (a day of them is the baseline), and the last lines
4
+ * are kept, redacted, for a watcher that asks to see them.
5
+ */
6
+
7
+ import { redact } from "../models/secrets";
8
+ import { MIN } from "./types";
9
+
10
+ const BUCKET_MS = 10 * MIN;
11
+ const BUCKETS = 144;
12
+ const KEEP_LINES = 200;
13
+ // Fewer full buckets than this is too little to call a baseline.
14
+ const MIN_HISTORY = 6;
15
+
16
+ const buckets = new Map<number, number>();
17
+ const lines: { at: number; text: string }[] = [];
18
+ let installed = false;
19
+
20
+ const bucketOf = (t: number) => Math.floor(t / BUCKET_MS);
21
+
22
+ export function countError(text: string, now = Date.now()): void {
23
+ const b = bucketOf(now);
24
+ buckets.set(b, (buckets.get(b) ?? 0) + 1);
25
+ for (const k of buckets.keys()) if (k <= b - BUCKETS) buckets.delete(k);
26
+ lines.push({ at: now, text: redact(text).slice(0, 400) });
27
+ if (lines.length > KEEP_LINES) lines.splice(0, lines.length - KEEP_LINES);
28
+ }
29
+
30
+ // Errors in the current and previous bucket, so "the last 10 minutes" never
31
+ // reads as zero right after a bucket turns over; and the mean per bucket
32
+ // before those.
33
+ export function errorRate(now = Date.now()): {
34
+ recent: number;
35
+ baselineMean: number;
36
+ } {
37
+ const b = bucketOf(now);
38
+ const recent = (buckets.get(b) ?? 0) + (buckets.get(b - 1) ?? 0);
39
+ const before = [...buckets.entries()].filter(([k]) => k < b - 1);
40
+ if (before.length < MIN_HISTORY) return { recent, baselineMean: 0 };
41
+ const sum = before.reduce((n, [, v]) => n + v, 0);
42
+ // Buckets with no errors aren't stored: the mean is over the whole span.
43
+ const span = BUCKETS - 2;
44
+ return { recent, baselineMean: sum / span };
45
+ }
46
+
47
+ export const recentErrorLines = (max = 30, since = Date.now() - 60 * MIN) =>
48
+ lines
49
+ .filter((l) => l.at >= since)
50
+ .slice(-max)
51
+ .map((l) => l.text);
52
+
53
+ export function resetErrors(): void {
54
+ buckets.clear();
55
+ lines.length = 0;
56
+ }
57
+
58
+ function text(args: unknown[]): string {
59
+ return args
60
+ .map((a) =>
61
+ a instanceof Error
62
+ ? (a.stack ?? a.message)
63
+ : typeof a === "string"
64
+ ? a
65
+ : safeJson(a)
66
+ )
67
+ .join(" ");
68
+ }
69
+
70
+ function safeJson(v: unknown): string {
71
+ try {
72
+ return JSON.stringify(v) ?? String(v);
73
+ } catch {
74
+ return String(v);
75
+ }
76
+ }
77
+
78
+ export function installErrorCounter(): void {
79
+ if (installed) return;
80
+ installed = true;
81
+ const original = console.error.bind(console);
82
+ console.error = (...args: unknown[]) => {
83
+ try {
84
+ countError(text(args));
85
+ } catch {
86
+ // Counting must never break logging.
87
+ }
88
+ original(...args);
89
+ };
90
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * What a watcher is shown: the anomaly, a snapshot of its whole area (so it
3
+ * can name a neighbour no check was written for), and a bounded excerpt of
4
+ * the relevant logs. Built here, deterministically, so the model needs no
5
+ * shell, and everything a third party wrote is fenced as data.
6
+ */
7
+
8
+ import { db } from "../db";
9
+ import { taskOutputTail } from "../chat/task-output";
10
+ import { itemsOfKind } from "../chat/store";
11
+ import { untrusted } from "../orchestrator/untrusted";
12
+ import { recentBoots } from "./boots";
13
+ import { recentErrorLines } from "./errors";
14
+ import { duration, ciLimit } from "./detectors";
15
+ import { MIN, type AnomalyRow, type Area, type HealthFacts } from "./types";
16
+
17
+ // About 5k tokens.
18
+ export const EVIDENCE_CHARS = 18_000;
19
+ const TAIL_CHARS = 3_000;
20
+
21
+ export const PROBES = [
22
+ "server_log",
23
+ "task_output",
24
+ "queue_state",
25
+ "boots",
26
+ ] as const;
27
+ export type Probe = (typeof PROBES)[number];
28
+ export const isProbe = (v: unknown): v is Probe =>
29
+ typeof v === "string" && (PROBES as readonly string[]).includes(v);
30
+
31
+ const ago = (now: number, t: number) => `${duration(Math.max(0, now - t))} ago`;
32
+
33
+ function detailOf(row: AnomalyRow): Record<string, unknown> {
34
+ try {
35
+ return JSON.parse(row.detail) as Record<string, unknown>;
36
+ } catch {
37
+ return {};
38
+ }
39
+ }
40
+
41
+ function anomalySection(row: AnomalyRow, now: number): string {
42
+ return [
43
+ `Anomaly: ${row.check_id} on ${row.subject}`,
44
+ `Severity ${row.severity}, open ${duration(now - row.opened_at)}, seen ${row.hits} time(s).`,
45
+ `Detector said: ${row.line}`,
46
+ ].join("\n");
47
+ }
48
+
49
+ function schedulerSnapshot(f: HealthFacts): string {
50
+ const lines = ["Machines:"];
51
+ for (const m of f.machines)
52
+ lines.push(
53
+ `- ${m.label}: ${m.level ?? "unknown"}${m.redSince ? ` (red for ${duration(f.now - m.redSince)})` : ""}${m.load1 !== undefined ? `, load ${m.load1} on ${m.cores} cores` : ""}`
54
+ );
55
+ lines.push("Queued tasks:");
56
+ for (const q of f.queue.slice(0, 12))
57
+ lines.push(
58
+ `- ${q.name ?? q.id}: queued ${duration(f.now - q.createdAt)}, ${q.heldBy}`
59
+ );
60
+ if (!f.queue.length) lines.push("- none");
61
+ const running = db
62
+ .prepare(
63
+ `SELECT s.name, s.pause_state, s.plan_only, s.plan_phase, s.task_status
64
+ FROM sessions s WHERE s.task_status IN ('running', 'moving')
65
+ AND s.archived_at IS NULL AND s.quick_of IS NULL LIMIT 20`
66
+ )
67
+ .all() as {
68
+ name: string;
69
+ pause_state: string | null;
70
+ plan_only: number | null;
71
+ plan_phase: string | null;
72
+ task_status: string;
73
+ }[];
74
+ lines.push("Tasks holding or near a slot:");
75
+ for (const r of running)
76
+ lines.push(
77
+ `- ${r.name}: ${r.task_status}${r.pause_state === "paused" ? ", paused" : ""}${r.plan_only ? `, plan-only (${r.plan_phase ?? "planning"})` : ""}`
78
+ );
79
+ if (!running.length) lines.push("- none");
80
+ return lines.join("\n");
81
+ }
82
+
83
+ function pipelineSnapshot(f: HealthFacts): string {
84
+ const lines = ["Armed PRs:"];
85
+ for (const a of f.armed.slice(0, 10))
86
+ lines.push(
87
+ `- ${a.name}${a.prNumber ? ` #${a.prNumber}` : ""}: armed ${duration(f.now - a.armedAt)}`
88
+ );
89
+ if (!f.armed.length) lines.push("- none");
90
+ lines.push("CI pending:");
91
+ for (const c of f.ci.slice(0, 10))
92
+ lines.push(
93
+ `- PR #${c.prNumber}: pending ${duration(f.now - c.pendingSince)} (usual limit ${duration(ciLimit(c.baseline, 20 * MIN))}, ${c.baseline.length} past runs)`
94
+ );
95
+ if (!f.ci.length) lines.push("- none");
96
+ lines.push("Recent server starts:");
97
+ for (const b of recentBoots(6))
98
+ lines.push(
99
+ `- ${ago(f.now, b.started_at)}${b.head ? ` at ${b.head}` : ""}${b.prev_seen_at ? `; the previous run was last seen ${ago(f.now, b.prev_seen_at)}` : ""}`
100
+ );
101
+ lines.push(
102
+ `Server errors in the last 10 minutes: ${f.errors.recent} (usual ${Math.round(f.errors.baselineMean)}).`
103
+ );
104
+ return lines.join("\n");
105
+ }
106
+
107
+ function tasksSnapshot(f: HealthFacts): string {
108
+ const lines = ["Background jobs running longer than the quiet limit:"];
109
+ for (const b of f.bg.slice(0, 12))
110
+ lines.push(
111
+ `- "${b.description}" in ${b.sessionName}: started ${ago(f.now, b.startedAt)}, ${b.outputAt ? `last output ${ago(f.now, b.outputAt)}` : "no output yet"}`
112
+ );
113
+ if (!f.bg.length) lines.push("- none");
114
+ return lines.join("\n");
115
+ }
116
+
117
+ const SNAPSHOTS: Record<Area, (f: HealthFacts) => string> = {
118
+ scheduler: schedulerSnapshot,
119
+ pipeline: pipelineSnapshot,
120
+ tasks: tasksSnapshot,
121
+ };
122
+
123
+ // The output of the background job an anomaly names.
124
+ function taskTail(row: AnomalyRow, chars: number): string | null {
125
+ const d = detailOf(row);
126
+ const sessionId = typeof d.sessionId === "string" ? d.sessionId : null;
127
+ const taskId = typeof d.taskId === "string" ? d.taskId : null;
128
+ if (!sessionId || !taskId) return null;
129
+ const item = itemsOfKind(sessionId, "task").find((t) => t.taskId === taskId);
130
+ const tail = taskOutputTail(taskId, item?.outputFile);
131
+ if (!tail?.text)
132
+ return "(the job has printed nothing, or its output file is gone)";
133
+ return tail.text.slice(-chars);
134
+ }
135
+
136
+ export function evidencePack(row: AnomalyRow, f: HealthFacts): string {
137
+ const parts = [anomalySection(row, f.now), SNAPSHOTS[row.area](f)];
138
+ const tail = row.check_id === "bg-quiet" ? taskTail(row, TAIL_CHARS) : null;
139
+ if (tail) parts.push(untrusted("background job output", tail));
140
+ if (row.check_id === "server-errors" || row.check_id === "restarts")
141
+ parts.push(
142
+ untrusted("server error log", recentErrorLines(15).join("\n") || "(none)")
143
+ );
144
+ return parts.join("\n\n").slice(0, EVIDENCE_CHARS);
145
+ }
146
+
147
+ // What a watcher asked to see next. Always bounded, always fenced.
148
+ export function probe(name: Probe, row: AnomalyRow, f: HealthFacts): string {
149
+ const cap = 4_000;
150
+ switch (name) {
151
+ case "server_log":
152
+ return untrusted(
153
+ "server error log",
154
+ recentErrorLines(40).join("\n") || "(no errors recorded)"
155
+ ).slice(0, cap);
156
+ case "task_output":
157
+ return untrusted(
158
+ "background job output",
159
+ taskTail(row, cap) ?? "(this anomaly names no background job)"
160
+ );
161
+ case "queue_state":
162
+ return schedulerSnapshot(f).slice(0, cap);
163
+ case "boots":
164
+ return recentBoots(12)
165
+ .map(
166
+ (b) =>
167
+ `${ago(f.now, b.started_at)} head ${b.head ?? "?"}; previous run last seen ${b.prev_seen_at ? ago(f.now, b.prev_seen_at) : "unknown"}; background jobs then: ${untrusted("boot record", b.prev_bg ?? "[]")}`
168
+ )
169
+ .join("\n")
170
+ .slice(0, cap);
171
+ }
172
+ }