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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2442-the-board-s-forge-strip-says-needs-you-f → main 2 commits fork 2 threads · 2 unresolved
7 files
changed +85
−11
README.md +6 −1
| @@ -317,7 +317,12 @@ since, href}`. `cause` is a stable key (`disk`, `errors`, `internal`, | ||
| 317 | 317 | evidence (oldest queued run, last heartbeat, oldest error or refused call in |
| 318 | 318 | the hour) or else the first time the forge saw it, and holds until the cause |
| 319 | 319 | 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. | |
| 321 | 326 | |
| 322 | 327 | ## Origins |
| 323 | 328 |
src/kernel/guard_verdict.ts +24 −1
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | * view that was never written (no `checkedAt`) is not stale. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { GUARD_STALE_MS, type Guard } from "./guard.ts"; | |
| 7 | import { GUARD_STALE_MS, rolloutLine, type ForgeHealth, type Guard } from "./guard.ts"; | |
| 8 | 8 | |
| 9 | 9 | /** Draining longer than this is a release that did not finish. */ |
| 10 | 10 | export const DRAIN_LIMIT_MS = 5 * 60_000; |
| @@ -26,3 +26,26 @@ export function forgeVerdict(view: VerdictView, now: number): string | undefined | ||
| 26 | 26 | return `the platform's view is stale: its last check was ${view.checkedAt}`; |
| 27 | 27 | return undefined; |
| 28 | 28 | } |
| 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 @@ | ||
| 17 | 17 | import { protectionNotice } from "../../kernel/protection.ts"; |
| 18 | 18 | import { holdWords, plannerHold } from "../../crew/autonomy.ts"; |
| 19 | 19 | import { forgeLane, forgeNeeds } from "../../actions/health.ts"; |
| 20 | import { forgeHealth } from "../../kernel/guard.ts"; | |
| 21 | import { coveredByVerdict, forgeVerdict, verdictLine } from "../../kernel/guard_verdict.ts"; | |
| 20 | 22 | import { clientAppRows } from "../../core/client-apps.ts"; |
| 21 | 23 | import { nextStepsFor } from "../../core/next-steps.ts"; |
| 22 | 24 | import { agentsLine } from "../../web/studio/client_home.ts"; |
| @@ -183,6 +185,15 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 183 | 185 | }), |
| 184 | 186 | ); |
| 185 | 187 | 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 | } | |
| 186 | 197 | lane = forgeLane(f.app, f.user); |
| 187 | 198 | decider = deciderLine(f.app, f.user); |
| 188 | 199 | // "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"; | ||
| 44 | 44 | import { spendView } from "./spend.ts"; |
| 45 | 45 | import type { HandContext } from "../../crew/handover.ts"; |
| 46 | 46 | import { forgeHealth, rolloutLine } from "../../kernel/guard.ts"; |
| 47 | import { coveredByVerdict, forgeVerdict, verdictLine } from "../../kernel/guard_verdict.ts"; | |
| 47 | 48 | import { laneLine, type Vitals } from "../../kernel/vitals.ts"; |
| 48 | 49 | import { getIssue, getIssuesByIds, type Issue } from "../../core/issues.ts"; |
| 49 | 50 | import { looksForApps } from "../../core/looks.ts"; |
| @@ -328,11 +329,14 @@ function forgeStrip(ctx: Ctx): Html | null { | ||
| 328 | 329 | if (!ctx.auth.user?.isAdmin) return null; |
| 329 | 330 | const v = forgeVitals(ctx.app); |
| 330 | 331 | const fh = forgeHealth(); |
| 332 | const verdict = forgeVerdict(fh, Date.now()); | |
| 333 | const said = (line: string) => coveredByVerdict(line, verdict, fh); | |
| 331 | 334 | const rollout = rolloutLine(fh); |
| 332 | 335 | const others = fh.incidents.filter( |
| 333 | (i) => i.key !== "stuck" && i.key !== "held", | |
| 336 | (i) => | |
| 337 | i.key !== "stuck" && i.key !== "held" && !said(`${i.key}: ${i.message}`), | |
| 334 | 338 | ); |
| 335 | const ok = v.ok && fh.ok; | |
| 339 | const ok = v.ok && fh.ok && verdict === undefined; | |
| 336 | 340 | const lane = laneLine(v.lane); |
| 337 | 341 | return html`<div |
| 338 | 342 | class="board-forge" |
| @@ -343,7 +347,8 @@ function forgeStrip(ctx: Ctx): Html | null { | ||
| 343 | 347 | role="status" |
| 344 | 348 | > |
| 345 | 349 | ${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>`} | |
| 347 | 352 | ${others.map((i) => html`<span class="board-forge-need">${i.key}: ${i.message}</span>`)} |
| 348 | 353 | ${lane === null ? "" : html`<span class="board-forge-lane">Lane: ${lane}</span>`} |
| 349 | 354 | ${ |
test/board.test.ts +22 −3
| @@ -513,12 +513,31 @@ describe("the board's forge strip", () => { | ||
| 513 | 513 | ); |
| 514 | 514 | writeFileSync(join(guard, "checkedAt"), new Date().toISOString()); |
| 515 | 515 | const blocked = await board("root"); |
| 516 | expect(blocked).toContain("rollout blocked: quota refused <i>8Gi</i>"); | |
| 516 | expect(blocked).toContain("needs you: forge stuck: quota refused <i>8Gi</i>"); | |
| 517 | 517 | 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. | |
| 519 | 519 | expect(blocked.match(/class="board-forge-need"/g)?.length).toBe(1); |
| 520 | expect(blocked).not.toContain("rollout blocked"); | |
| 520 | 521 | 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"); | |
| 522 | 541 | } finally { |
| 523 | 542 | delete process.env["FORGE_GUARD_DIR"]; |
| 524 | 543 | rmSync(guard, { recursive: true, force: true }); |
test/guard-verdict.test.ts +11 −1
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 4 | |
| 4 | 5 | const NOW = Date.parse("2026-10-02T12:00:00Z"); |
| 5 | 6 | const fresh = "2026-10-02T11:59:00Z"; |
| @@ -31,3 +32,12 @@ test("an empty view and a never-written view give undefined", () => { | ||
| 31 | 32 | expect(forgeVerdict(view({}), NOW)).toBeUndefined(); |
| 32 | 33 | expect(forgeVerdict(view({}, null), NOW)).toBeUndefined(); |
| 33 | 34 | }); |
| 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", () => { | ||
| 789 | 789 | |
| 790 | 790 | const admin = boot(forge, as(a.value)); |
| 791 | 791 | 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"); | |
| 793 | 794 | expect(admin.text()).not.toContain("All fine"); |
| 794 | 795 | expect(admin.text()).not.toContain("Lane:"); |
| 795 | 796 | admin.stop(); |
| @@ -804,7 +805,7 @@ describe("the forge's health in the terminal", () => { | ||
| 804 | 805 | |
| 805 | 806 | const other = boot(forge, as(b.value)); |
| 806 | 807 | await other.settle(); |
| 807 | expect(other.text()).not.toContain("rollout blocked"); | |
| 808 | expect(other.text()).not.toContain("quota refused"); | |
| 808 | 809 | expect(other.text()).not.toContain("Lane:"); |
| 809 | 810 | expect(other.text()).not.toContain("All fine"); |
| 810 | 811 | other.stop(); |
new above
offline
No drainingSince here, so the draining branch of forgeVerdict can never fire on this page. The terminal dashboard needs the same verdict.