Public: anyone can read this; the crew can change it.

The backup alarm is a dead man's switch: a missed check is itself red #8980

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/8959-the-backup-alarm-is-a-dead-man-s-switch → main 3 commits fork 4 threads · 4 unresolved

  • Merged. scout/8959-the-backup-alarm-is-a-dead-man-s-switch was merged into main as bc57460.

Review

Sign in to review this change.
6 files changed +121 −9
docs/features/backup-truth.md +2 −1
@@ -2,7 +2,8 @@
22
33 - **Read from the bucket.** `src/snapshot/bucket.ts` lists the primary and mirror stores every 5 minutes, in the serving process (no CronJob to die). Home's backup tile, vitals (one `backup` card) and the MCP `backups` tool's `verdict` all call `backupVerdict`.
44 - **Three states.** `green`, `red`, and `unknown`: nothing read yet since boot, or no restore drill yet. Unknown is never a calm tile and never an alarm; only red raises the one `backup` card. A store error is scrubbed of keys and presigned queries before it is kept.
5 - **Red when:** the newest snapshot is over 26 h old; a lane cannot be read or holds nothing; the newest restore drill failed, did not run, or brought back no rows; or the check itself has not run for 20 min (dead man's switch).
5 - **Red when:** the newest snapshot is over 26 h old; a lane cannot be read or holds nothing; the newest restore drill failed, did not run, or brought back no rows; or the check itself has not run for 20 min (dead man's switch). The reason says when it last ran: `The backup check has not run for 25 min (last ran 2026-11-02T08:00:00Z); it is the alarm, and it is down.`
6 - **The run record.** Every run of the check appends one JSON line (`at`, and each lane's `ok` and `newestAt`) to `backup-checks.jsonl` under the data directory. The count is kept in memory; once the file holds 400 runs it is cut back to the last 200. A disk that refuses the write never stops the check. The verdict carries `runs` and `checkLine` (`The backup check last ran <ISO> (3 min ago); 42 runs on record.`), shown in the Resilience dialog, the home tile's title and the `backup` card, so a person can see the check is alive.
67 - **Catch-up.** A boot with the newest snapshot older than the last 08:00 UTC slot takes one two minutes later (`snapshotDue`). A snapshot cut short by a release has no manifest, so it counts as lost.
78 - **In the terminal.** The Resilience dialog (palette: Resilience, admins) opens with the same verdict, each lane's newest, and every reason. On the web it is home's backup tile and `/settings/backups`; for an agent, `verdict` in the MCP `backups` tool.
89 - **Off the serving thread.** `VACUUM INTO` runs in a worker (`vacuum.ts`).
src/kernel/vitals.ts +1 −1
@@ -479,7 +479,7 @@ export function vitals(input: VitalsInput): Vitals {
479479 // The bucket itself, read by this process: late, unreadable, a failed drill, or a check that stopped is one card.
480480 if (isWatched(input.dataDir)) {
481481 const backup = backupVerdict(input.dataDir, lastDrill(input.db), now);
482 if (backup.state === "red") need("backup", backup.reasons.join(" "), backup.checkedAt ?? undefined, "/settings/backups");
482 if (backup.state === "red") need("backup", [...backup.reasons, ...(backup.reasons.some((r) => r.includes("last ran")) ? [] : [backup.checkLine])].join(" "), backup.checkedAt ?? undefined, "/settings/backups");

Non-blocking: matching reason text with includes("last ran") is brittle, and this line is long. Split it into a small helper or have the verdict carry the finished sentence.

483483 }
484484
485485 // A snapshot that did not land off the box, or a mirror a day behind, is a card until it does.
src/snapshot/bucket.ts +53 −4
@@ -13,6 +13,8 @@
1313 * without touching the network. Plain data in, plain data out.
1414 */
1515
16 import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17 import { join } from "node:path";
1618 import type { DrillResult } from "./drill.ts";
1719 import { isOffBox } from "./off_box.ts";
1820 import { listSnapshots } from "./snapshot.ts";
@@ -63,6 +65,44 @@ export interface BackupVerdict {
6365 readonly newestAt: string | null;
6466 /** When the bucket was last read; null when it has not been. */
6567 readonly checkedAt: string | null;
68 /** Runs of the check on record (`backup-checks.jsonl`). */
69 readonly runs: number;
70 /** When the check last ran and how many runs are on record, as a person reads it; the proof the check is alive. */
71 readonly checkLine: string;
72 }
73
74 /** Where each run of the check is written down, under the data directory: one JSON line per run. */
75 export const CHECK_LOG = "backup-checks.jsonl";
76 /** The log keeps this many runs; it is cut back to them once it holds twice as many. */
77 export const CHECK_LOG_KEEP = 200;
78
79 /** Runs in each log, counted in memory so a run never reads the file to know its size. */
outdated

Reads and splits the whole file on every run just to decide on trimming. Keep a count in memory, or trim only when size passes a bound.

80 const runCounts = new Map<string, number>();
81
82 function countRuns(dataDir: string): number {
83 try {
84 return readFileSync(join(dataDir, CHECK_LOG), "utf8").split("\n").filter(Boolean).length;
85 } catch {
86 return 0;
87 }
88 }
89
90 /** Writes one run down; a disk that refuses must not stop the check, so it never throws. */
91 export function recordRun(dataDir: string, at: string, lanes: readonly LaneRead[]): void {
outdated

Exported, but only the test calls it. No page, API or terminal screen shows the run record. Wire it into the tile/vitals card and the TUI backups screen, or remove the log.

92 try {
93 mkdirSync(dataDir, { recursive: true });
94 const file = join(dataDir, CHECK_LOG);
95 const had = runCounts.get(dataDir) ?? countRuns(dataDir);
96 appendFileSync(file, `${JSON.stringify({ at, lanes: lanes.map((l) => ({ lane: l.lane, ok: l.error === undefined, newestAt: l.newestAt })) })}\n`);
97 let n = had + 1;
98 if (n > CHECK_LOG_KEEP * 2) {
99 writeFileSync(file, `${readFileSync(file, "utf8").split("\n").filter(Boolean).slice(-CHECK_LOG_KEEP).join("\n")}\n`);
100 n = CHECK_LOG_KEEP;
101 }
102 runCounts.set(dataDir, n);
103 } catch {
104 /* the verdict does not depend on the log */
105 }
66106 }
67107
68108 const reads = new Map<string, { lanes: LaneRead[]; checkedAt: string; since: string }>();
@@ -154,7 +194,7 @@ export function judge(
154194 drill: DrillResult | undefined,
155195 now: number,
156196 booted: number = now,
157 ): BackupVerdict {
197 ): Omit<BackupVerdict, "runs" | "checkLine"> {
158198 const reasons: string[] = [];
159199 /** A few words for each reason, for a tile's note. */
160200 const causes: string[] = [];
@@ -167,7 +207,7 @@ export function judge(
167207 reasons.push(
168208 checkedAt === null
169209 ? "The backup check has never run, so nothing says a backup exists."
170 : `The backup check has not run for ${ageWords(sinceCheck)}; it is the alarm, and it is down.`,
210 : `The backup check has not run for ${ageWords(sinceCheck)} (last ran ${checkedAt}); it is the alarm, and it is down.`,
171211 );
172212 } else if (checkedAt === null) unknowns.push("The bucket has not been read yet, so nothing is known about the backups.");
173213 for (const r of state?.lanes ?? []) {
@@ -246,7 +286,13 @@ export function judge(
246286 /** The verdict now, from what the watch last read. */
247287 export function backupVerdict(dataDir: string, drill: DrillResult | undefined, now = Date.now()): BackupVerdict {
248288 const booted = Date.parse(watching.get(dataDir) ?? "") || now;
249 return judge(reads.get(dataDir), drill, now, booted);
289 const v = judge(reads.get(dataDir), drill, now, booted);
290 const runs = runCounts.get(dataDir) ?? 0;
291 const checkLine =
292 v.checkedAt === null
293 ? "The backup check has not run yet."
294 : `The backup check last ran ${v.checkedAt} (${ageWords(now - Date.parse(v.checkedAt))} ago); ${runs} runs on record.`;
295 return { ...v, runs, checkLine };
250296 }
251297
252298 /**
@@ -264,6 +310,7 @@ export function watchBucket(
264310 declared: (now: number) => Promise<readonly LaneRead[]> = async () => [],
265311 ): { stop: () => void; pass: () => Promise<void> } {
266312 watching.set(dataDir, new Date().toISOString());
313 runCounts.set(dataDir, countRuns(dataDir));
267314 let stopped = false;
268315 let timer: ReturnType<typeof setTimeout> | undefined;
269316 const pass = async (): Promise<void> => {
@@ -273,7 +320,9 @@ export function watchBucket(
273320 Promise.all(stores.map((l) => readLane(l.lane, l.store, now, targets))),
274321 declared(now).catch(() => []),
275322 ]);
276 keepReads(dataDir, [...own, ...more]);
323 const all = [...own, ...more];
324 keepReads(dataDir, all);
325 recordRun(dataDir, new Date().toISOString(), all);
277326 };
278327 const next = (ms: number): void => {
279328 if (stopped) return;
src/tui/screens/resilience.ts +1
@@ -81,6 +81,7 @@ export function verdictLines(f: Session): string[] {
8181 const mark = v.state === "red" ? "RED" : v.state === "unknown" ? "unknown" : "ok";
8282 return [
8383 ...wrap(`Backups ${mark}: ${v.line}`, INNER),
84 ...wrap(` ${v.checkLine}`, INNER),
8485 ...v.lanes.flatMap((l) => wrap(` ${l.state === "red" ? "!" : " "} ${l.says}`, INNER)),
8586 ...[...v.reasons, ...v.unknowns].flatMap((r) => wrap(` - ${r}`, INNER)),
8687 ];
src/web/studio/kpi_row.ts +1 −1
@@ -145,7 +145,7 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now
145145 k.backup?.state === "unknown" && k.backupAt === null ? "—" : backupAge(k.backupAt, now),
146146 backupFull(k.backupAt),
147147 k.backup ? k.backup.note : k.backupAt === null ? "no backup off the machine yet" : overdue ? "overdue, more than 24 h" : "off the machine",
148 k.backup ? (k.backup.reasons[0] ?? k.backup.unknowns[0] ?? k.backup.line) : overdue ? "Overdue: the last off-box backup is more than 24 h old" : "Age of the last off-box backup",
148 k.backup ? `${k.backup.reasons[0] ?? k.backup.unknowns[0] ?? k.backup.line} ${k.backup.checkLine}` : overdue ? "Overdue: the last off-box backup is more than 24 h old" : "Age of the last off-box backup",
149149 null,
150150 overdue ? "attn" : null,
151151 )}
test/backup-truth.test.ts +63 −2
@@ -7,19 +7,23 @@
77
88 import { afterAll, beforeAll, expect, test } from "bun:test";
99 import { Database } from "bun:sqlite";
10 import { existsSync } from "node:fs";
10 import { existsSync, readFileSync, writeFileSync } from "node:fs";
1111 import { mkdtemp, rm } from "node:fs/promises";
1212 import { tmpdir } from "node:os";
1313 import { join } from "node:path";
1414 import { createApp, type App } from "../src/app.ts";
1515 import { vitals } from "../src/kernel/vitals.ts";
16 import { backupVerdict, CHECK_STALE_MS, judge, keepReads, LATE_MS, readLane, watchBucket, type LaneRead } from "../src/snapshot/bucket.ts";
16 import { backupVerdict, CHECK_LOG, CHECK_LOG_KEEP, CHECK_STALE_MS, recordRun, judge, keepReads, LATE_MS, readLane, watchBucket, type LaneRead } from "../src/snapshot/bucket.ts";
1717 import type { DrillResult } from "../src/snapshot/drill.ts";
1818 import { lastNightly, snapshotDue } from "../src/snapshot/due.ts";
1919 import { snapshotsOf, startSnapshots } from "../src/snapshot/service.ts";
2020 import { listSnapshots } from "../src/snapshot/snapshot.ts";
2121 import { dirStore } from "../src/snapshot/store.ts";
2222 import { verdictLines } from "../src/tui/screens/resilience.ts";
23 import { readHealth } from "../src/actions/health.ts";
24 import type { User } from "../src/core/users.ts";
25 import { NO_REACH } from "../src/flow/reach.ts";
26 import { kpiRowFor } from "../src/web/studio/kpi_row.ts";
2327 import { vacuumInto } from "../src/snapshot/vacuum.ts";
2428
2529 let dataDir: string;
@@ -243,3 +247,60 @@ test("copying the database does not hold the serving thread (#7559)", async () =
243247 copy.close();
outdated

Use a fresh temp data dir so the count does not depend on other tests, and add assertions for the home tile and /api/v1/health, which box 2 names; add a trim test.

244248 db.close();
245249 }, 60_000);
250
251 const runLines = (dir: string): string[] => readFileSync(join(dir, CHECK_LOG), "utf8").split("\n").filter(Boolean);
252
253 test("the check records each run; stopped past its interval, the verdict, tile, card, terminal and health are red saying when it last ran", async () => {
254 const dir = await mkdtemp(join(tmpdir(), "forge-check-runs-"));
255 const own = createApp({ dataDir: dir, port: 0 });
256 const w = watchBucket(dir, () => [{ lane: "primary", store: dirStore(dir) }], 3_600_000, 3_600_000);
257 const realNow = Date.now;
258 try {
259 await w.pass();
260 await w.pass();
261 expect(runLines(dir)).toHaveLength(2);
262 expect(JSON.parse(runLines(dir)[1]!).lanes[0].lane).toBe("primary");
263 const ranAt = backupVerdict(dir, undefined).checkedAt!;
264 expect(backupVerdict(dir, undefined).runs).toBe(2);
265 // The check is stopped: nothing reads the bucket while the clock moves on.
266 const later = Date.parse(ranAt) + CHECK_STALE_MS + 60_000;
267 const v = backupVerdict(dir, drill(), later);
268 expect(v.state).toBe("red");
269 expect(v.reasons.join(" ")).toContain(`last ran ${ranAt}`);
270 expect(v.checkLine).toContain("2 runs on record");
271 const tile = kpiRowFor({ landedHours: Array(24).fill(0), landed: 0, live: null, builders: { running: 0, slots: 1, idle: null }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: null, backupAt: null, backup: v, reach: NO_REACH }, null, new Date(later)).toString();
272 expect(tile).toContain("attn");
273 expect(tile).toContain("check stopped");
274 expect(tile).toContain(`last ran ${ranAt}`);
275 Date.now = () => later;
276 const card = vitals({ db: own.db, signals: own.signals, dataDir: dir, now: later }).degraded.find((d) => d.cause === "backup");
277 expect(card?.sentence).toContain(`last ran ${ranAt}`);
278 const health = readHealth(own, { isAdmin: true } as User) as { ok: boolean; degraded: { cause: string; sentence: string }[] };
279 expect(health.ok).toBe(false);
280 expect(health.degraded.find((d) => d.cause === "backup")?.sentence).toContain(`last ran ${ranAt}`);
281 expect(verdictLines({ app: own } as never).join("\n")).toContain("runs on record");
282 } finally {
283 Date.now = realNow;
284 w.stop();
285 own.db.close();
286 await rm(dir, { recursive: true, force: true });
287 }
288 });
289
290 test("the run record is cut back to its keep once it holds twice as many, and a disk that refuses never throws", async () => {
291 const dir = await mkdtemp(join(tmpdir(), "forge-check-trim-"));
292 try {
293 for (let i = 0; i < CHECK_LOG_KEEP * 2; i++) recordRun(dir, `2026-11-02T00:00:${String(i % 60).padStart(2, "0")}Z`, [lane(null)]);
294 expect(runLines(dir)).toHaveLength(CHECK_LOG_KEEP * 2);
295 recordRun(dir, "2026-11-02T01:00:00Z", [lane(null)]);
296 const kept = runLines(dir);
297 expect(kept).toHaveLength(CHECK_LOG_KEEP);
298 expect(JSON.parse(kept.at(-1)!).at).toBe("2026-11-02T01:00:00Z");
299 // A data directory that is a file cannot be written to.
300 const file = join(dir, "not-a-dir");
301 writeFileSync(file, "x");
302 expect(() => recordRun(file, "2026-11-02T02:00:00Z", [])).not.toThrow();
303 } finally {
304 await rm(dir, { recursive: true, force: true });
305 }
306 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs