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

Health's ok means the forge is serving: each need is marked broken or todo, and only broken makes ok false #12222

Merged opened this

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

Merged

scout/forge:scout/12167-health-s-ok-means-the-forge-is-serving-e → main 15 commits fork 2 threads · 2 unresolved

  • Merged. scout/12167-health-s-ok-means-the-forge-is-serving-e was merged into main as 405fa2a.

Review

Sign in to review this change.
12 files changed +253 −76
docs/MCP.md +2 −2
@@ -122,7 +122,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
122122 | `fork_tree` | — | The fork tree around a repo: ancestors, siblings and descendants you may read | 440 |
123123 | `forks` | `chain` | A repo's forks you may see, paged by limit and cursor (next_cursor), each with commits ahead and behind | 480 |
124124 | `front_page` | — | The stranger's front page; admin sets `pinned`/`hidden`/`weights` | 457 |
125 | `health` | — | How this forge is: version, commit, draining or not, rollout, incidents | 285 |
125 | `health` | — | How this forge is: version, rollout, incidents | 316 |
126126 | `held` | — | What the Later gate holds, and one proposed pass over it | 361 |
127127 | `here` | — | What you may do on a forge page; given an app's link, what its /__forge/ door shows | 237 |
128128 | `hold` | — | Hold a pull (`why`) or `release` it; or `path`: hold new work on a file | 522 |
@@ -561,7 +561,7 @@ answers
561561 | `ui://forge/flow` | `flow` | The pipeline: per stage counts, oldest waiting, the bottleneck. |
562562 | `ui://forge/scorecard` | `scorecard` | The crew's scorecard, one row per agent: landed and red rate all time, then the week (forge admin). |
563563 | `ui://forge/crew` | `now`, `crew` | `now`: what the crew is doing right now, with Stop buttons. `crew` (listing): one row per seat, who fills it and what runs and waits, or what it costs to leave it empty; linked to Settings › Crew. |
564 | `ui://forge/health` | `health` | Green or red with the version, then one row per cause: draining, a blocked rollout, degraded lanes, incidents, apps (an admin sees all; a stranger only the verdict). |
564 | `ui://forge/health` | `health` | Green or red with the version, then one row per cause: draining, a blocked rollout, degraded lanes, incidents, apps (an admin sees all; a stranger only the verdict). Its answer splits causes into `broken` (act now; any makes `ok` false) and `todo` (would like done; `ok` stays true). |
565565 | `ui://forge/forge` | `forge_view` | The forge in one card: healthy or not and what is running, then the apps with work going, busiest first; quiet apps and what waits on you are counted. |
566566 | `ui://forge/capacity` | `capacity` (listing, admin) | One row per setting with its live value (set ones marked), linked to Settings › Capacity; says when the forge cannot swap itself. |
567567 | `ui://forge/board` | `boards` | One board's panels (person admins). |
src/actions/health.ts +10 −3
@@ -101,12 +101,19 @@ export function readHealth(
101101 }
102102 if (!actor?.isAdmin) return forge;
103103 const v = forgeVitals(app);
104 // A broken cost-ledger chain is one more need, from the verdict kept in memory (#12273).
104 // Draining is the release's, not the vitals': it is broken all the same (#12167).
105 // A broken cost-ledger chain is one more broken need, from the verdict kept in memory (#12273).
105106 const ledger = ledgerNeed();
106 if (ledger === null) return { ...v, ...forge, ok: forge.ok && v.ok };
107 return { ...v, ...forge, ok: false, needs: [...v.needs, ledger] };
107 const extra = [...(forge.draining ? [DRAINING] : []), ...(ledger === null ? [] : [ledger])];
108 const broken = [...extra, ...v.broken];
109 const needs = ledger === null ? v.needs : [...v.needs, ledger];
110 const ok = forge.ok && v.ok && ledger === null;
111 return { ...v, ...forge, ok, needs, broken, todo: v.todo };
108112 }
109113
114 /** The `broken` line a draining forge answers with. */
115 export const DRAINING = "The forge is draining: this process finishes what it holds and takes no new work.";
116
110117 /**
111118 * The forge's causes for concern as one sentence each, for a site admin: the
112119 * rollout, each platform incident, then the vitals' needs, in the order the
src/crew/routine_report.ts +1 −1
@@ -103,7 +103,7 @@ export async function facts(app: App, user: User, now: number, sinceMs = now - 3
103103 const story = await storyFacts(app, sinceMs);
104104 return {
105105 ...story,
106 health: vitals.ok ? [] : vitals.needs.slice(0, 3),
106 health: vitals.needs.slice(0, 3),
107107 redMain: redLines(app, user, now).map((l) => l.line),
108108 asks: askLinesFor(db, user).length,
109109 holds,
src/http/mcp/tools/health.ts +1 −1
@@ -5,7 +5,7 @@ export const tool: Tool = {
55 name: "health",
66 doc: "how this forge is: the version and commit it runs, draining or not, the rollout and its incidents",
77 description:
8 "How this forge is: version, commit, draining or not, rollout, incidents.",
8 "How this forge is: version, rollout, incidents. ok means serving, broken act now, todo would like done.",
99 inputSchema: { type: "object", properties: {} },
1010 writes: false,
1111 run: (ctx, user) => good(readHealth(ctx.app, user)),
src/kernel/vitals.ts +58 −16
@@ -74,6 +74,24 @@ export interface EnvCount {
7474 readonly state: string;
7575 }
outdated

This comment lists memory below its floor and a crash loop as broken, but no code raises either. Say so here, and open follow-up issues for the two detectors. Also say why disk and disk-floor are todo.

7676
77 /**
78 * A need is `broken` when the forge is not serving as it should and someone
79 * must act now: draining, the database not answering, a full disk refusing
80 * writes (`disk-full`), a red disk (`disk-red`), memory below its floor, a
81 * crash loop, a red main. Everything else is `todo`: a standing wish (a
82 * database over 2 GiB, a drill owed, a queue waiting) the forge serves
83 * through. Of those, vitals see `disk-full`, `disk-red`, `crash-loop` and
84 * `red:<repoId>`; draining is added by the health answer, and the database
85 * not answering is `/healthz`'s 503. Memory below its floor is `broken` by
86 * this rule, but nothing raises it yet (#12284).
87 *
88 * `disk` and `disk-floor` are `todo`: a low disk stops new starts (child
89 * forges, apps, checks), but the forge keeps serving what is already running
90 * while room is made. A disk is `broken` (`disk-red`) only once checks are
91 * failing on it, which means work is being lost and not just held back.
92 */
93 export type NeedKind = "broken" | "todo";
94
7795 /**
7896 * One cause of the forge being off: the `needs` sentence, a `cause` key that
7997 * holds for as long as its condition does (`disk`, `disk-floor`, `disk-80`, `disk-70`,
@@ -102,8 +120,14 @@ export interface Lane {
102120 }
103121
104122 export interface Vitals {
123 /** Serving: nothing is `broken`. Standing wishes (`todo`) never make it false. */
105124 readonly ok: boolean;
125 /** Every sentence, `broken` then `todo` in the order found: what callers before #12167 read. */
106126 readonly needs: string[];
127 /** What stops the forge serving: act now. */
128 readonly broken: string[];
129 /** What the forge would like done: it serves meanwhile. */
130 readonly todo: string[];
107131 readonly degraded: Degraded[];
108132 readonly disk: { freeBytes: number; totalBytes: number };
109133 readonly memory: {
@@ -333,7 +357,10 @@ export function vitals(input: VitalsInput): Vitals {
333357 const degraded: Degraded[] = [];
334358 let firstSeen = firstSeenByForge.get(input.signals.errors);
335359 if (!firstSeen) firstSeenByForge.set(input.signals.errors, (firstSeen = new Map()));
360 const broken: string[] = [];
361 const todo: string[] = [];
336362 const need = (
363 kind: NeedKind,
337364 cause: string,
338365 sentence: string,
339366 evidence: string | undefined,
@@ -345,17 +372,19 @@ export function vitals(input: VitalsInput): Vitals {
345372 firstSeen.set(cause, since);
346373 }
347374 needs.push(sentence);
375 (kind === "broken" ? broken : todo).push(sentence);
348376 degraded.push({ cause, sentence, since, href });
349377 };
350378
351379 const fullAt = fullSince(input.dataDir);
352 if (fullAt !== undefined) need("disk-full", fullSentence(input.dataDir, true), fullAt, "/system");
380 if (fullAt !== undefined) need("broken", "disk-full", fullSentence(input.dataDir, true), fullAt, "/system");
353381 const disk = (input.disk ?? readDisk)(input.dataDir);
354382 // Room to spare (#11940): at 100 GiB free no share-of-the-disk guard speaks,
355383 // the floor and the marks alike.
356384 const room = roomy(disk);
357385 if (disk.totalBytes > 0 && disk.freeBytes / disk.totalBytes < (input.diskFreeMin ?? DISK_FREE_MIN))
358386 need(
387 "todo",
359388 "disk",
360389 `Disk is nearly full: ${Math.round((disk.freeBytes / disk.totalBytes) * 100)}% free.`,
361390 undefined,
@@ -363,6 +392,7 @@ export function vitals(input: VitalsInput): Vitals {
363392 );
364393 else if (disk.totalBytes > 0 && !room && disk.freeBytes / disk.totalBytes < DISK_FLOOR)
365394 need(
395 "todo",
366396 "disk-floor",
367397 `Disk is under the ${DISK_FLOOR * 100}% the forge keeps for itself: ${Math.round((disk.freeBytes / disk.totalBytes) * 100)}% free. Child forges sleep, and no child forge, app or check starts, until room is made.`,
368398 undefined,
@@ -374,6 +404,7 @@ export function vitals(input: VitalsInput): Vitals {
374404 const mark = DISK_USED_MARKS.find((m) => used >= m);
375405 if (mark !== undefined)
376406 need(
407 "todo",
377408 `disk-${Math.round(mark * 100)}`,
378409 `Disk under the forge's data is ${Math.floor(used * 100)}% used, past the ${Math.round(mark * 100)}% mark.`,
379410 undefined,
@@ -382,10 +413,10 @@ export function vitals(input: VitalsInput): Vitals {
382413 }
383414
384415 // The hosted forges' sizes together past the machine's memory: said, and nothing is stopped.
385 if (input.forgesMemory !== undefined) need("forges-memory", input.forgesMemory, undefined, "/forges");
416 if (input.forgesMemory !== undefined) need("todo", "forges-memory", input.forgesMemory, undefined, "/forges");
386417
387418 for (const file of openDbFiles(input.dataDir))
388 need(`file:${file.name}`, file.sentence, undefined, "/settings/capacity");
419 need("todo", `file:${file.name}`, file.sentence, undefined, "/settings/capacity");
389420
390421 // A database past the 2 GiB Bun maps, or growing faster than 64 MiB a day, is said (#12010),
391422 // with what fills it from the last walk once there is one (#12051): the sentence is the
@@ -394,7 +425,7 @@ export function vitals(input: VitalsInput): Vitals {
394425 const dbGrows = dbGrowth(input.db, dbBytes, now);
395426 let dbBig = dbSizeNeed(dbBytes, dbGrows);
396427 if (dbBig !== undefined) dbBig = dbSizeNeed(dbBytes, dbGrows, (input.dbWalk ?? dbSize.last)(input.db, input.dataDir));
397 if (dbBig !== undefined) need(dbBig.cause, dbBig.sentence, undefined, "/system");
428 if (dbBig !== undefined) need("todo", dbBig.cause, dbBig.sentence, undefined, "/system");
398429
399430 // Snapshots on the volume they copy, and no mirror, share its one point of failure.
400431 const snaps = input.snapshots;
@@ -403,7 +434,7 @@ export function vitals(input: VitalsInput): Vitals {
403434 !snaps.mirrored &&
404435 sharesVolume(input.dataDir, snaps.dir)
405436 )
406 need("snapshots", SNAPSHOTS_ON_STATE_VOLUME, undefined, "/settings/backups");
437 need("todo", "snapshots", SNAPSHOTS_ON_STATE_VOLUME, undefined, "/settings/backups");
407438
408439 const running = listRunning(input.db);
409440 const lane = readLane(input.db, running, now);
@@ -421,6 +452,7 @@ export function vitals(input: VitalsInput): Vitals {
421452 // Age and "since" come from the free checks alone.
422453 if (free.oldest !== null && now - new Date(free.oldest).getTime() > BUDGETS.laneWait)
423454 need(
455 "todo",
424456 "queue:check",
425457 laneSentence(
426458 free.n,
@@ -455,6 +487,7 @@ export function vitals(input: VitalsInput): Vitals {
455487 const waited = now - new Date(oldest).getTime();
456488 if (waited > QUEUE_WAIT_MAX_MS)
457489 need(
490 "todo",
458491 `queue:${row.kind}`,
459492 queueSentence(
460493 row.kind,
@@ -484,6 +517,7 @@ export function vitals(input: VitalsInput): Vitals {
484517 }
485518 for (const s of stuck)
486519 need(
520 "todo",
487521 `stuck:${s.runId}`,
488522 now - new Date(s.since).getTime() > BUDGETS.heartbeat &&
489523 now - new Date(s.since).getTime() < 24 * 3_600_000
@@ -499,6 +533,7 @@ export function vitals(input: VitalsInput): Vitals {
499533 new Date(now - 24 * 3_600_000).toISOString(),
500534 ))
501535 need(
536 "todo",
502537 `budget:${run.id}`,
503538 `A run was killed twice: ${run.summary}.`,
504539 run.finishedAt ?? undefined,
@@ -508,6 +543,7 @@ export function vitals(input: VitalsInput): Vitals {
508543 // A red main holding its pulls' checks is one card, naming the test.
509544 for (const { red, held } of reds)
510545 need(
546 "broken",
511547 `red:${red.repoId}`,
512548 redSentence(red, held) + foreignNote(red.repo),
513549 red.since,
@@ -516,7 +552,7 @@ export function vitals(input: VitalsInput): Vitals {
516552
517553 // A test failing on many pulls in the hour is one card, not a park per pull.
518554 for (const shared of sharedTests(input.db, now))
519 need(sharedCause(shared), sharedSentence(shared) + foreignNote(shared.repo), shared.since, flakeHref(input.db, shared));
555 need("todo", sharedCause(shared), sharedSentence(shared) + foreignNote(shared.repo), shared.since, flakeHref(input.db, shared));
520556
521557 // Checks that failed on a full disk are one card, not a park per pull, and
522558 // only while the disk is still under the floor: once it is back the machine
@@ -524,13 +560,13 @@ export function vitals(input: VitalsInput): Vitals {
524560 const diskStillFull =
525561 disk.totalBytes > 0 && disk.freeBytes / disk.totalBytes < (input.diskFreeMin ?? DISK_FREE_MIN);
526562 const full = diskStillFull ? diskReds(input.db, now) : undefined;
527 if (full !== undefined) need("disk-red", diskRedSentence(full), full.since, "/settings/capacity");
563 if (full !== undefined) need("broken", "disk-red", diskRedSentence(full), full.since, "/settings/capacity");
528564
529565 // A red restore drill, or none in eight days, is a card until a drill passes.
530566 const drill = lastVerdict(input.dataDir);
531567 const owed = drillNeed(drill, now, () => drillFirstSeen(input.dataDir));
532568 if (owed !== undefined)
533 need("drill", owed.sentence, owed.since, "/settings/backups");
569 need("todo", "drill", owed.sentence, owed.since, "/settings/backups");
534570
535571 // A domain whose drill is red or eight days old is a card with the command that runs it.
536572 for (const owed of domainDrillNeeds(
@@ -540,42 +576,43 @@ export function vitals(input: VitalsInput): Vitals {
540576 input.forgeUrl ?? "http://localhost",
541577 DOMAINS,
542578 ))
543 need(owed.cause, owed.sentence, owed.since, "/");
579 need("todo", owed.cause, owed.sentence, owed.since, "/");
544580
545581 // A snapshot that failed is a card from that morning until one lands.
546582 const failing = snapshotNeed(lastSnapshotOutcome(input.dataDir));
547 if (failing !== undefined) need("snapshot-failed", failing.sentence, failing.since, "/settings/backups");
583 if (failing !== undefined) need("todo", "snapshot-failed", failing.sentence, failing.since, "/settings/backups");
548584
549585 // The bucket itself, read by this process: late, unreadable, a failed drill, or a check that stopped is one card.
550586 if (isWatched(input.dataDir)) {
551587 const backup = backupVerdict(input.dataDir, lastDrill(input.db), now);
552 if (backup.state === "red") need("backup", [...backup.reasons, ...(backup.reasons.some((r) => r.includes("last ran")) ? [] : [backup.checkLine])].join(" "), backup.checkedAt ?? undefined, "/settings/backups");
588 if (backup.state === "red") need("todo", "backup", [...backup.reasons, ...(backup.reasons.some((r) => r.includes("last ran")) ? [] : [backup.checkLine])].join(" "), backup.checkedAt ?? undefined, "/settings/backups");
553589 }
554590
555591 // A snapshot that did not land off the box, or a mirror a day behind, is a card until it does.
556592 const behind = mirrorNeed(lastMirror(input.dataDir), now);
557 if (behind !== undefined) need("mirror", behind.sentence, behind.since, "/settings/backups");
593 if (behind !== undefined) need("todo", "mirror", behind.sentence, behind.since, "/settings/backups");
558594
559595 // A release the forge could not prove against its own public URL is a card until a release does.
560596 // Only a release that is not serving as it should is a card; a proof that could not finish for
561597 // its own reasons (the sign-up limit, a slow answer)
562598 // is a notice on /releases and on the home.
563599 const unproven = proofNeed(input.dataDir, runningVersion().version);
564 if (unproven !== undefined && proofNotice(input.dataDir, runningVersion().version, now)?.tone === "alarm") need("release", unproven.sentence, unproven.since, "/releases#proof");
600 if (unproven !== undefined && proofNotice(input.dataDir, runningVersion().version, now)?.tone === "alarm") need("todo", "release", unproven.sentence, unproven.since, "/releases#proof");
565601
566602 // A roll the image's launcher undid is a card until a later version reads ready. Its sentence
567603 // carries the release's own last words (stderr), so anything credential-shaped is masked.
568604 const undone = rollbackNeed(input.dataDir);
569 if (undone !== undefined) need("release:rolled-back", redactSecrets(undone.sentence), undone.since, "/board");
605 if (undone !== undefined) need("todo", "release:rolled-back", redactSecrets(undone.sentence), undone.since, "/board");
570606
571607 // Production serving a version older than a release main has had over an hour is a card until they match.
572608 const lagging = behindStanding(now);
573 if (lagging !== undefined) need("release:behind", `${lagging.sentence}.`, lagging.since, "/board");
609 if (lagging !== undefined) need("todo", "release:behind", `${lagging.sentence}.`, lagging.since, "/board");
574610
575611 // Lines recorded and not landed for five minutes: the writer is failing, however quiet the site.
576612 const quiet = unwrittenFor(now);
577613 if (quiet !== undefined && quiet > SILENT_AFTER_S)
578614 need(
615 "todo",
579616 "access-log",
580617 `The access log is not writing: a recorded line has waited ${quiet} s to reach disk (limit ${SILENT_AFTER_S / 60} min).`,
581618 new Date(now - quiet * 1000).toISOString(),
@@ -592,6 +629,7 @@ export function vitals(input: VitalsInput): Vitals {
592629 const seen = recentErrors(5, now, input.signals.errors);
593630 if (seen.lastHour >= ERROR_BURST)
594631 need(
632 "todo",
595633 "errors",
596634 `${seen.lastHour} errors in the last hour (${ERROR_BURST} or more is a burst).`,
597635 seen.oldest,
@@ -601,6 +639,7 @@ export function vitals(input: VitalsInput): Vitals {
601639 const looping = crashLoop(input.dataDir, now);
602640 if (looping !== null)
603641 need(
642 "broken",
604643 "crash-loop",
605644 `The forge restarted ${looping.unclean} times without exiting cleanly in the last ${LOOP_WINDOW_MS / 60_000} minutes (${LOOP_STARTS} or more is a crash loop).`,
606645 new Date(looping.since).toISOString(),
@@ -610,6 +649,7 @@ export function vitals(input: VitalsInput): Vitals {
610649 const refused = refusedInternal(now, input.signals.refused);
611650 if (refused.lastHour > 0)
612651 need(
652 "todo",
613653 "internal",
614654 `${refused.lastHour} internal call${refused.lastHour === 1 ? "" : "s"} without the boot secret refused in the last hour${refused.latest ? ` (latest ${refused.latest.method} ${refused.latest.path})` : ""}.`,
615655 refused.oldest,
@@ -620,8 +660,10 @@ export function vitals(input: VitalsInput): Vitals {
620660 if (!degraded.some((d) => d.cause === cause)) firstSeen.delete(cause);
621661
622662 return {
623 ok: needs.length === 0,
663 ok: broken.length === 0,
624664 needs,
665 broken,
666 todo,
625667 degraded,
626668 disk,
627669 memory: {
src/web/pages/board.ts +3 −1
@@ -431,7 +431,9 @@ function forgeNotes(ctx: Ctx): { notes: Note[]; ok: boolean; placement: Html | n
431431 const said = (line: string) => coveredByVerdict(line, verdict, fh);
432432 const rollout = rolloutLine(fh);
433433 const others = fh.incidents.filter((i) => !said(`${i.key}: ${i.message}`));
434 const ok = v.ok && fh.ok && verdict === undefined;
434 // "All fine" is no need at all, todo included: `v.ok` is serving, which a
435 // standing wish keeps true (#12167).
436 const ok = v.needs.length === 0 && fh.ok && verdict === undefined;
435437 const lane = laneLine(v.lane);
436438 const flaky = flakeLine(v.flakes);
437439 const sentinel = sentinelNotice(ctx.app.db);
test/backup-truth.test.ts +2 −2
@@ -289,8 +289,8 @@ test("the check records each run; stopped past its interval, the verdict, tile,
289289 Date.now = () => later;
290290 const card = vitals({ db: own.db, signals: own.signals, dataDir: dir, now: later }).degraded.find((d) => d.cause === "backup");
291291 expect(card?.sentence).toContain(`last ran ${ranAt}`);
292 const health = readHealth(own, { isAdmin: true } as User) as { ok: boolean; degraded: { cause: string; sentence: string }[] };
293 expect(health.ok).toBe(false);
292 const health = readHealth(own, { isAdmin: true } as User) as { todo: string[]; degraded: { cause: string; sentence: string }[] };
293 expect(health.todo.join(" ")).toContain(`last ran ${ranAt}`);
294294 expect(health.degraded.find((d) => d.cause === "backup")?.sentence).toContain(`last ran ${ranAt}`);
295295 expect(verdictLines({ app: own } as never).join("\n")).toContain("runs on record");
296296 } finally {
test/check-budget.test.ts +1 −1
@@ -90,6 +90,6 @@ test("a check past its budget is re-queued once; the second overrun fails it and
9090 expect(second.summary).toMatch(/^budget: a full gate took \d+ min, limit \d+$/);
9191
9292 const seen = vitals({ db: app.db, signals: app.signals, dataDir });
93 expect(seen.ok).toBe(false);
93 expect(seen.todo.length).toBeGreaterThan(0);
9494 expect(seen.needs.some((n) => n.includes("budget: a full gate"))).toBe(true);
9595 }, 60_000);
test/full-disk.test.ts +1
@@ -84,6 +84,7 @@ describe("a full disk", () => {
8484
8585 const v = vitals({ db: app.db, dataDir: dir, signals: app.signals });
8686 expect(v.degraded.some((d) => d.cause === "disk-full")).toBe(true);
87 expect(v.ok).toBe(false); // writes refused: broken, not todo (#12167)
8788 expect(v.needs.some((n) => /full.*MiB|full.*GiB|full.*none free/.test(n) && n.includes("delete"))).toBe(true);
8889 expect((await fetch(`${servers.url}/healthz`)).status).toBe(200); // liveness never claims the sentence
8990
test/health-broken.test.ts +155 −44
@@ -1,57 +1,168 @@
1 /** A crash loop is a need: three unclean starts in ten minutes (#12285). */
2 import { afterAll, expect, test } from "bun:test";
3 import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
1 /**
2 * Health's ok means the forge is serving (#12167): a need is `broken` (act
3 * now) or `todo` (would like done), and only `broken` makes ok false. This
4 * file is the guard on the broken list: a real failure filed as todo would
5 * answer ok:true and fail here. A crash loop is one: three unclean starts in
6 * ten minutes (#12285).
7 */
8 import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
9 import type { Database } from "bun:sqlite";
10 import { mkdirSync, mkdtempSync, rmSync, truncateSync, writeFileSync } from "node:fs";
11 import { mkdtemp, rm } from "node:fs/promises";
412 import { tmpdir } from "node:os";
513 import { join } from "node:path";
6 import { createApp } from "../src/app.ts";
14 import { createApp, type App } from "../src/app.ts";
715 import { recordBoot, recordCleanExit, crashLoop } from "../src/kernel/boots.ts";
8 import { vitals } from "../src/kernel/vitals.ts";
16 import { DRAINING, readHealth } from "../src/actions/health.ts";
17 import { migrations } from "../src/core/schema.ts";
18 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
19 import type { User } from "../src/core/users.ts";
20 import { health } from "../src/http/health.ts";
21 import { openDb } from "../src/kernel/db.ts";
22 import { ErrorLedger } from "../src/kernel/log.ts";
23 import { RefusedLedger } from "../src/kernel/internal_refused.ts";
24 import { vitals, type Vitals } from "../src/kernel/vitals.ts";
25 import { DB_FILE_CAP, dbFile } from "../src/system/db_size.ts";
926 import { quietLogs } from "./helpers/globals.ts";
1027
1128 quietLogs();
12 const tmp = mkdtempSync(join(tmpdir(), "broken-"));
13 afterAll(() => rmSync(tmp, { recursive: true, force: true }));
14 const min = 60_000;
1529
16 function ledger(name: string) {
17 const dir = join(tmp, name);
18 mkdirSync(dir);
19 return dir;
20 }
30 const OID = (c: string) => c.repeat(40);
31 const roomy = () => ({ freeBytes: 900, totalBytes: 1000 });
2132
22 test("three unclean starts in ten minutes raise a crash-loop need; two do not", () => {
23 const dir = ledger("loop");
24 recordBoot(dir, "a", 0);
25 recordBoot(dir, "b", min);
26 recordBoot(dir, "c", 2 * min);
27 expect(crashLoop(dir, 3 * min)).toBeNull(); // a and b: two
28 recordBoot(dir, "d", 3 * min);
29 expect(crashLoop(dir, 3 * min)).toEqual({ unclean: 3, since: 0 });
33 let db: Database;
34 let dataDir: string;
35 beforeEach(() => {
36 db = openDb(":memory:", migrations);
37 dataDir = mkdtempSync(join(tmpdir(), "forge-health-broken-"));
38 db.run("INSERT INTO user (id, username, email, password_hash) VALUES ('u1', 'trevato', 't@x', '')");
39 db.run("INSERT INTO repo (id, owner_id, name) VALUES ('r1', 'u1', 'forge')");
40 db.run("INSERT INTO issue (id, repo_id, number, kind, title, author_id) VALUES ('i1', 'r1', 7, 'pull', 'p', 'u1')");
41 });
42 afterEach(() => {
43 db.close();
44 rmSync(dataDir, { recursive: true, force: true });
3045 });
3146
32 test("clean exits and old crashes are not a loop", () => {
33 const dir = ledger("calm");
34 for (const [i, id] of ["a", "b", "c", "d"].entries()) {
35 recordBoot(dir, id, i * min);
36 recordCleanExit(dir, id);
47 const read = (disk = roomy): Vitals =>
48 vitals({
49 db,
50 dataDir,
51 disk,
52 dbWalk: () => null,
53 signals: { errors: new ErrorLedger(), refused: new RefusedLedger() },
54 });
55
56 describe("broken makes ok false", () => {
57 test("a red main", () => {
58 const run = createRun(db, { repoId: "r1", kind: "check", env: "main", commitOid: OID("b"), reason: "push" });
59 claimRun(db, run.id);
60 finishRun(db, run.id, { state: "failed", summary: "check failed: 1 test failed", checkDetail: { passed: 9, failed: 1, names: ["t"] } });
61 createRun(db, { repoId: "r1", kind: "check", env: "pull-7", issueId: "i1", commitOid: OID("a"), reason: "push" });
62 const v = read();
63 const red = v.degraded.find((d) => d.cause === "red:r1")!;
64 expect(v.ok).toBe(false);
65 expect(v.broken).toContain(red.sentence);
66 expect(v.todo).not.toContain(red.sentence);
67 });
68
69 test("a red disk", () => {
70 const run = createRun(db, { repoId: "r1", kind: "check", env: "pull-7", issueId: "i1", commitOid: OID("a"), reason: "push" });
71 claimRun(db, run.id);
72 finishRun(db, run.id, { state: "failed", summary: "check failed", checkDetail: { passed: 1, failed: 1, names: ["t"], diskFree: 0.03 } });
73 const v = read(() => ({ freeBytes: 30, totalBytes: 1000 }));
74 const red = v.degraded.find((d) => d.cause === "disk-red")!;
75 expect(v.ok).toBe(false);
76 expect(v.broken).toContain(red.sentence);
77 });
78 });
79
80 describe("todo alone keeps ok true", () => {
81 test("a database over 2 GiB", () => {
82 writeFileSync(dbFile(dataDir), "", { mode: 0o600 });
83 truncateSync(dbFile(dataDir), DB_FILE_CAP + 1); // sparse: no disk spent
84 const v = read();
85 const big = v.degraded.find((d) => d.cause === "db-size")!;
86 expect(v.todo).toEqual([big.sentence]);
87 expect(v.broken).toEqual([]);
88 expect(v.ok).toBe(true);
89 // Callers before #12167 still read every sentence.
90 expect(v.needs).toEqual([big.sentence]);
91 });
92 });
93
94 describe("the health answer", () => {
95 let app: App;
96 let dir: string;
97 const admin = { isAdmin: true } as User;
98 beforeAll(async () => {
99 dir = await mkdtemp(join(tmpdir(), "forge-health-drain-"));
100 app = createApp({ dataDir: dir, port: 0 });
101 });
102 afterAll(async () => {
103 health.draining = false;
104 app.db.close();
105 await rm(dir, { recursive: true, force: true });
106 });
107
108 test("a draining forge answers ok:false with draining under broken", () => {
109 health.draining = true;
110 const h = readHealth(app, admin) as Vitals;
111 expect(h.ok).toBe(false);
112 expect(h.broken).toContain(DRAINING);
113 expect(h.todo).not.toContain(DRAINING);
114 health.draining = false;
115 expect((readHealth(app, admin) as Vitals).broken).not.toContain(DRAINING);
116 });
117 });
118
119 describe("a crash loop", () => {
120 const tmp = mkdtempSync(join(tmpdir(), "broken-"));
121 afterAll(() => rmSync(tmp, { recursive: true, force: true }));
122 const min = 60_000;
123
124 function ledger(name: string) {
125 const dir = join(tmp, name);
126 mkdirSync(dir);
127 return dir;
37128 }
38 recordBoot(dir, "e", 4 * min);
39 expect(crashLoop(dir, 4 * min)).toBeNull();
40 const old = ledger("old");
41 for (const id of ["a", "b", "c", "d"]) recordBoot(old, id, 0);
42 expect(crashLoop(old, 30 * min)).toBeNull();
43 });
44129
45 test("vitals carries the loop as a need that clears with the window", () => {
46 const dir = ledger("vitals");
47 const app = createApp({ dataDir: dir, url: "https://f.test" });
48 const now = Date.now();
49 for (const id of ["a", "b", "c", "d"]) recordBoot(dir, id, now - 60_000);
50 const v = vitals({ db: app.db, signals: app.signals, dataDir: dir, now, disk: () => ({ freeBytes: 1e12, totalBytes: 1e12 }) });
51 const d = v.degraded.find((x) => x.cause === "crash-loop");
52 expect(d?.sentence).toContain("restarted 3 times");
53 expect(v.ok).toBe(false);
54 const later = vitals({ db: app.db, signals: app.signals, dataDir: dir, now: now + 20 * min, disk: () => ({ freeBytes: 1e12, totalBytes: 1e12 }) });
55 expect(later.degraded.find((x) => x.cause === "crash-loop")).toBeUndefined();
56 app.close();
130 test("three unclean starts in ten minutes raise a crash-loop need; two do not", () => {
131 const dir = ledger("loop");
132 recordBoot(dir, "a", 0);
133 recordBoot(dir, "b", min);
134 recordBoot(dir, "c", 2 * min);
135 expect(crashLoop(dir, 3 * min)).toBeNull(); // a and b: two
136 recordBoot(dir, "d", 3 * min);
137 expect(crashLoop(dir, 3 * min)).toEqual({ unclean: 3, since: 0 });
138 });
139
140 test("clean exits and old crashes are not a loop", () => {
141 const dir = ledger("calm");
142 for (const [i, id] of ["a", "b", "c", "d"].entries()) {
143 recordBoot(dir, id, i * min);
144 recordCleanExit(dir, id);
145 }
146 recordBoot(dir, "e", 4 * min);
147 expect(crashLoop(dir, 4 * min)).toBeNull();
148 const old = ledger("old");
149 for (const id of ["a", "b", "c", "d"]) recordBoot(old, id, 0);
150 expect(crashLoop(old, 30 * min)).toBeNull();
151 });
152
153 test("vitals carries the loop as a need that clears with the window", () => {
154 const dir = ledger("vitals");
155 const app = createApp({ dataDir: dir, url: "https://f.test" });
156 const now = Date.now();
157 for (const id of ["a", "b", "c", "d"]) recordBoot(dir, id, now - 60_000);
158 const v = vitals({ db: app.db, signals: app.signals, dataDir: dir, now, disk: () => ({ freeBytes: 1e12, totalBytes: 1e12 }) });
159 const d = v.degraded.find((x) => x.cause === "crash-loop");
160 expect(d?.sentence).toContain("restarted 3 times");
161 expect(v.ok).toBe(false);
162 expect(v.broken).toContain(d!.sentence);
163 expect(v.todo).not.toContain(d!.sentence);
164 const later = vitals({ db: app.db, signals: app.signals, dataDir: dir, now: now + 20 * min, disk: () => ({ freeBytes: 1e12, totalBytes: 1e12 }) });
165 expect(later.degraded.find((x) => x.cause === "crash-loop")).toBeUndefined();
166 app.close();
167 });
57168 });
test/ledger-health.test.ts +10
@@ -111,3 +111,13 @@ test("the terminal's forge strip shows the need to an admin and not to a guest",
111111 app.close();
112112 }
113113 });
114
115 test("a broken chain is a broken need, not a todo: health is not ok (#12167)", async () => {
116 broken();
117 const { app, admin: a } = await admin();
118 const { readHealth } = await import("../src/actions/health.ts");
119 const h = readHealth(app, a) as { ok: boolean; broken: string[]; todo: string[] };
120 expect(h.ok).toBe(false);
121 expect(mentions(h.broken).length).toBe(1);
122 expect(mentions(h.todo)).toEqual([]);
123 });
test/vitals.test.ts +9 −5
@@ -94,9 +94,9 @@ test("a site admin with a read-only token gets the shape; others get the short o
9494 expect(Object.keys(v).sort()).toEqual(
9595 [
9696 "accessLog",
97 "apps", "at", "checkedAt", "commit", "degraded", "disk", "draining", "errors", "flakes",
97 "apps", "at", "broken", "checkedAt", "commit", "degraded", "disk", "draining", "errors", "flakes",
9898 "incidents", "lane", "memory", "needs", "ok", "queue", "rollout", "stale", "stuck",
99 "version",
99 "todo", "version",
100100 ].sort(),
101101 );
102102 expect(v.disk.totalBytes).toBeGreaterThan(0);
@@ -129,7 +129,9 @@ test("a full queue shows in queue, and an old one in needs", async () => {
129129 // Process-wide signals (errors, refused calls) from other suites may speak too.
130130 expect(v.needs.join(" ")).not.toContain("queue");
131131 const later = vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy, now: old + 2 * 3_600_000 + 3_600_000 });
132 expect(later.ok).toBe(false);
132 // A waiting queue is a todo: the forge still serves (#12167).
133 expect(later.ok).toBe(true);
134 expect(later.todo.join(" ")).toContain("agent queue");
133135 expect(later.needs.join(" ")).toContain("agent queue");
134136 });
135137
@@ -143,7 +145,8 @@ test("a running run with a lapsed lease is stuck, and needs says so", async () =
143145 const v = (await (await health(admin)).json()) as ReturnType<typeof vitals>;
144146 expect(v.stuck.map((s) => s.runId)).toEqual([run.id]);
145147 expect(v.stuck[0]!.repo).toBe("alice/r");
146 expect(v.ok).toBe(false);
148 expect(v.todo.some((n) => n.includes("stuck"))).toBe(true);
149 expect(v.broken.some((n) => n.includes("stuck"))).toBe(false);
147150 expect(v.needs.some((n) => n.includes("stuck"))).toBe(true);
148151 // A fresh heartbeat clears it.
149152 app.db
@@ -466,7 +469,8 @@ test("a lane older than its budget is degraded with its cause; a silent run is s
466469 expect(lane?.sentence).toMatch(
467470 /^3 checks wait behind one lane; the oldest since \d\d:\d\d; the lane runs a check for 31 min \(limit 60\)\.$/,
468471 );
469 expect(v.ok).toBe(false);
472 expect(v.todo).toContain(lane!.sentence);
473 expect(v.ok).toBe(true);
470474 // Five minutes in, the oldest is under its budget: nothing to say.
471475 app.db.query("UPDATE run SET created_at=?1 WHERE state='queued'").run(iso(now - 5 * 60_000));
472476 expect(vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy, now }).degraded.map((d) => d.cause)).toEqual([]);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs