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

The board's forge strip says 'needs you: forge <text>' when forgeVerdict has something to say #2451

Merged opened this

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

Merged

scout/forge:scout/2442-the-board-s-forge-strip-says-needs-you-f → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/2442-the-board-s-forge-strip-says-needs-you-f was merged into main as 0518717.

Review

Sign in to review this change.
7 files changed +85 −11
README.md +6 −1
@@ -317,7 +317,12 @@ since, href}`. `cause` is a stable key (`disk`, `errors`, `internal`,
317317 evidence (oldest queued run, last heartbeat, oldest error or refused call in
318318 the hour) or else the first time the forge saw it, and holds until the cause
319319 clears; `href` is the page to look at. The board's forge strip links each
320 entry, and the terminal dashboard prints each with its start.
320 entry, and the terminal dashboard prints each with its start. When the guard
321 view holds something that needs a person (a held or stuck rollout, an OOM kill,
322 or a platform view gone stale), the board's strip and the terminal dashboard
323 both lead with one line, `needs you: forge <what>`, and do not repeat it as a
324 rollout line. A drain that runs too long is not covered on either: the forge
325 keeps no start time for it.
321326
322327 ## Origins
323328
src/kernel/guard_verdict.ts +24 −1
@@ -4,7 +4,7 @@
44 * view that was never written (no `checkedAt`) is not stale.
55 */
66
7 import { GUARD_STALE_MS, type Guard } from "./guard.ts";
7 import { GUARD_STALE_MS, rolloutLine, type ForgeHealth, type Guard } from "./guard.ts";
88
99 /** Draining longer than this is a release that did not finish. */
1010 export const DRAIN_LIMIT_MS = 5 * 60_000;
@@ -26,3 +26,26 @@ export function forgeVerdict(view: VerdictView, now: number): string | undefined
2626 return `the platform's view is stale: its last check was ${view.checkedAt}`;
2727 return undefined;
2828 }
29
30 /**
31 * Whether a strip line says what the verdict already says: the verdict itself,
32 * or the rollout line over the same reason. Shown once, not twice.
33 */
34 export function coveredByVerdict(
35 line: string,
36 verdict: string | undefined,
37 h: ForgeHealth,
38 ): boolean {
39 if (verdict === undefined) return false;
40 if (line === verdict) return true;
41 return (
42 line === rolloutLine(h) &&
43 h.rollout.reason !== "" &&
44 verdict.includes(h.rollout.reason)
45 );
46 }
47
48 /** The verdict as the strip's first line. */
49 export function verdictLine(verdict: string): string {
50 return `needs you: forge ${verdict}`;
51 }
src/tui/screens/dashboard.ts +11
@@ -17,6 +17,8 @@
1717 import { protectionNotice } from "../../kernel/protection.ts";
1818 import { holdWords, plannerHold } from "../../crew/autonomy.ts";
1919 import { forgeLane, forgeNeeds } from "../../actions/health.ts";
20 import { forgeHealth } from "../../kernel/guard.ts";
21 import { coveredByVerdict, forgeVerdict, verdictLine } from "../../kernel/guard_verdict.ts";
2022 import { clientAppRows } from "../../core/client-apps.ts";
2123 import { nextStepsFor } from "../../core/next-steps.ts";
2224 import { agentsLine } from "../../web/studio/client_home.ts";
@@ -183,6 +185,15 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
183185 }),
184186 );
185187 forge = forgeNeeds(f.app, f.user);
188 if (forge !== undefined) {
189 const fh = forgeHealth();
190 const verdict = forgeVerdict(fh, Date.now());
191 if (verdict !== undefined)
192 forge = [
193 verdictLine(verdict),
194 ...forge.filter((line) => !coveredByVerdict(line, verdict, fh)),
195 ];
196 }
186197 lane = forgeLane(f.app, f.user);
187198 decider = deciderLine(f.app, f.user);
188199 // "Yours" is ownership plus the orgs you are on a team of; typing in the
src/web/pages/board.ts +8 −3
@@ -44,6 +44,7 @@ import { readSpend } from "../../actions/spend.ts";
4444 import { spendView } from "./spend.ts";
4545 import type { HandContext } from "../../crew/handover.ts";
4646 import { forgeHealth, rolloutLine } from "../../kernel/guard.ts";
47 import { coveredByVerdict, forgeVerdict, verdictLine } from "../../kernel/guard_verdict.ts";
4748 import { laneLine, type Vitals } from "../../kernel/vitals.ts";
4849 import { getIssue, getIssuesByIds, type Issue } from "../../core/issues.ts";
4950 import { looksForApps } from "../../core/looks.ts";
@@ -328,11 +329,14 @@ function forgeStrip(ctx: Ctx): Html | null {
328329 if (!ctx.auth.user?.isAdmin) return null;
329330 const v = forgeVitals(ctx.app);
330331 const fh = forgeHealth();
332 const verdict = forgeVerdict(fh, Date.now());
333 const said = (line: string) => coveredByVerdict(line, verdict, fh);
331334 const rollout = rolloutLine(fh);
332335 const others = fh.incidents.filter(
333 (i) => i.key !== "stuck" && i.key !== "held",
336 (i) =>
outdated

No drainingSince here, so the draining branch of forgeVerdict can never fire on this page. The terminal dashboard needs the same verdict.

337 i.key !== "stuck" && i.key !== "held" && !said(`${i.key}: ${i.message}`),
334338 );
335 const ok = v.ok && fh.ok;
339 const ok = v.ok && fh.ok && verdict === undefined;
336340 const lane = laneLine(v.lane);
337341 return html`<div
338342 class="board-forge"
@@ -343,7 +347,8 @@ function forgeStrip(ctx: Ctx): Html | null {
343347 role="status"
344348 >
345349 ${sentinelSpan(ctx.app)}
outdated

This duplicates the rollout line above for held and stuck. Show one, not both.

346 ${rollout === null ? "" : html`<span class="board-forge-need">${rollout}</span>`}
350 ${rollout === null || said(rollout) ? "" : html`<span class="board-forge-need">${rollout}</span>`}
351 ${verdict === undefined ? "" : html`<span class="board-forge-need">${verdictLine(verdict)}</span>`}
347352 ${others.map((i) => html`<span class="board-forge-need">${i.key}: ${i.message}</span>`)}
348353 ${lane === null ? "" : html`<span class="board-forge-lane">Lane: ${lane}</span>`}
349354 ${
test/board.test.ts +22 −3
@@ -513,12 +513,31 @@ describe("the board's forge strip", () => {
513513 );
514514 writeFileSync(join(guard, "checkedAt"), new Date().toISOString());
515515 const blocked = await board("root");
516 expect(blocked).toContain("rollout blocked: quota refused &lt;i&gt;8Gi&lt;/i&gt;");
516 expect(blocked).toContain("needs you: forge stuck: quota refused &lt;i&gt;8Gi&lt;/i&gt;");
517517 expect(blocked).not.toContain("<i>8Gi</i>");
518 // One line, and the board beneath it still renders.
518 // One line, said once, and the board beneath it still renders.
519519 expect(blocked.match(/class="board-forge-need"/g)?.length).toBe(1);
520 expect(blocked).not.toContain("rollout blocked");
520521 expect(blocked).toContain("board-card board-run");
521 expect(await board("bob")).not.toContain("rollout blocked");
522 expect(await board("bob")).not.toContain("quota refused");
523 } finally {
524 delete process.env["FORGE_GUARD_DIR"];
525 rmSync(guard, { recursive: true, force: true });
526 }
527 });
528
529 test("a held incident reads needs you: forge held: …, and no incident leaves All fine", async () => {
530 const guard = mkdtempSync(join(tmpdir(), "forge-board-guard-"));
531 process.env["FORGE_GUARD_DIR"] = guard;
532 try {
533 writeFileSync(join(guard, "status.json"), JSON.stringify({ incidents: { held: "preflight refused 1.2" } }));
534 writeFileSync(join(guard, "checkedAt"), new Date().toISOString());
535 const held = await board("root");
536 expect(held).toContain("needs you: forge held: preflight refused 1.2");
537 expect(held).toMatch(/data-ok="no"/);
538 expect(held).not.toContain("All fine");
539 writeFileSync(join(guard, "status.json"), JSON.stringify({ incidents: {} }));
540 expect(await board("root")).toContain("All fine");
522541 } finally {
523542 delete process.env["FORGE_GUARD_DIR"];
524543 rmSync(guard, { recursive: true, force: true });
test/guard-verdict.test.ts +11 −1
@@ -1,5 +1,6 @@
11 import { expect, test } from "bun:test";
2 import { forgeVerdict } from "../src/kernel/guard_verdict.ts";
2 import { forgeHealth } from "../src/kernel/guard.ts";
3 import { coveredByVerdict, forgeVerdict } from "../src/kernel/guard_verdict.ts";
34
45 const NOW = Date.parse("2026-10-02T12:00:00Z");
56 const fresh = "2026-10-02T11:59:00Z";
@@ -31,3 +32,12 @@ test("an empty view and a never-written view give undefined", () => {
3132 expect(forgeVerdict(view({}), NOW)).toBeUndefined();
3233 expect(forgeVerdict(view({}, null), NOW)).toBeUndefined();
3334 });
35
36 test("the verdict covers the rollout line and its own incident line, and nothing else", () => {
37 const h = forgeHealth(view({ held: "preflight refused", "oom:web": "killed" }), NOW);
38 const verdict = forgeVerdict(h, NOW);
39 expect(coveredByVerdict("rollout held: preflight refused", verdict, h)).toBe(true);
40 expect(coveredByVerdict(verdict!, verdict, h)).toBe(true);
41 expect(coveredByVerdict("oom:web: killed", verdict, h)).toBe(false);
42 expect(coveredByVerdict("rollout held: preflight refused", undefined, h)).toBe(false);
43 });
test/tui-screens.test.ts +3 −2
@@ -789,7 +789,8 @@ describe("the forge's health in the terminal", () => {
789789
790790 const admin = boot(forge, as(a.value));
791791 await admin.settle();
792 expect(admin.text()).toContain("rollout blocked: quota refused");
792 expect(admin.text()).toContain("needs you: forge stuck: quota refused");
793 expect(admin.text()).not.toContain("rollout blocked");
793794 expect(admin.text()).not.toContain("All fine");
794795 expect(admin.text()).not.toContain("Lane:");
795796 admin.stop();
@@ -804,7 +805,7 @@ describe("the forge's health in the terminal", () => {
804805
805806 const other = boot(forge, as(b.value));
806807 await other.settle();
807 expect(other.text()).not.toContain("rollout blocked");
808 expect(other.text()).not.toContain("quota refused");
808809 expect(other.text()).not.toContain("Lane:");
809810 expect(other.text()).not.toContain("All fine");
810811 other.stop();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs