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

The lane is visible: queue depth, the running check and its age, median duration and the time to clear, on the home and in /api/v1/health; the 30-minute card names the cause #1538

Merged opened this in answer to

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

Merged

scout/forge:scout/1277-the-lane-is-visible-queue-depth-the-runn → main 6 commits fork 5 threads · 5 unresolved

  • Merged. scout/1277-the-lane-is-visible-queue-depth-the-runn was merged into main as 217a2ac.

Review

Sign in to review this change.
8 files changed +184 −8
README.md +4
@@ -561,6 +561,10 @@ work here.
561561 flags new activity, no refresh.
562562 - **Commit pages** — every commit's diff through the same viewer.
563563 - **Settings** — visibility, branch protection, and labels.
564 - **The check lane is visible.** Checks run one at a time; an admin's home
565 strip, the terminal dashboard's strip and `GET /api/v1/health` (`lane`) show
566 the queue depth, the running check and its age, the median of the last
567 twenty and the time to clear, and the 30-minute queue card names that cause.
564568 - **A terminal UI** — `ssh` in for the whole forge: dashboard, code browsing
565569 with syntax highlighting, issues and pulls with live-updating threads, the
566570 diff viewer with inline review comments, merges, settings, orgs, tokens and
src/actions/health.ts +11 −1
@@ -13,7 +13,7 @@ import { appsRuntime } from "../apps/runtime.ts";
1313 import { listApps } from "../core/apps.ts";
1414 import type { User } from "../core/users.ts";
1515 import { forgeHealth, rolloutLine, type ForgeHealth } from "../kernel/guard.ts";
16 import { vitals, type Vitals } from "../kernel/vitals.ts";
16 import { laneLine, vitals, type Vitals } from "../kernel/vitals.ts";
1717
1818 export function readHealth(
1919 app: App,
@@ -47,3 +47,13 @@ export function forgeNeeds(app: App, actor: User | undefined): string[] | undefi
4747 ...h.needs,
4848 ];
4949 }
50
51 /**
52 * The check lane as one line for a site admin, as the board's forge strip
53 * prints it; `null` for anyone else, and when no check is queued.
54 */
55 export function forgeLane(app: App, actor: User | undefined): string | null {
56 if (!actor?.isAdmin) return null;
57 const h = readHealth(app, actor) as Vitals & ForgeHealth;

Next: this reads health again; share the single read with forgeNeeds.

58 return laneLine(h.lane);
59 }
src/kernel/vitals.ts +93 −2
@@ -35,6 +35,17 @@ export interface EnvCount {
3535 readonly state: string;
3636 }
3737
38 /** How many finished checks the median is taken over. */
39 export const MEDIAN_OVER = 20;
40
41 /** The check lane: one at a time, so depth times the median is the wait. */
42 export interface Lane {
43 readonly queued: number;
44 readonly running: { pull: number | null; since: string } | null;
45 readonly medianMinutes: number | null;
46 readonly clearsInMinutes: number | null;
47 }
48
3849 export interface Vitals {
3950 readonly ok: boolean;
4051 readonly needs: string[];
@@ -45,6 +56,7 @@ export interface Vitals {
4556 hostTotalBytes: number;
4657 };
4758 readonly queue: Record<string, number>;
59 readonly lane: Lane;
4860 readonly stuck: {
4961 runId: string;
5062 repo: string;
@@ -65,6 +77,74 @@ export interface VitalsInput {
6577 readonly now?: number;
6678 }
6779
80 /** The lane as one line (no label; callers add their own), or null when nothing is queued. */
81 export function laneLine(lane: Lane, now = Date.now()): string | null {
82 if (lane.queued === 0) return null;

Next: return a line for a running check even when queued is 0, so the lane is visible when idle-but-busy.

83 const parts = [`${lane.queued} check${lane.queued === 1 ? "" : "s"} queued`];
84 if (lane.running !== null) {
85 const age = Math.max(
86 0,
87 Math.round((now - new Date(lane.running.since).getTime()) / 60_000),
88 );
89 parts.push(
90 `running ${lane.running.pull === null ? "a check" : `#${lane.running.pull}`} for ${age} min`,
91 );
92 }
93 if (lane.medianMinutes !== null) parts.push(`median ${lane.medianMinutes} min`);
94 if (lane.clearsInMinutes !== null)
95 parts.push(
96 `clears in ~${lane.clearsInMinutes >= 120 ? `${Math.round(lane.clearsInMinutes / 60)} h` : `${lane.clearsInMinutes} min`}`,
97 );
98 return parts.join(" · ");
99 }
100
101 /** Depth, the running check, and the median of the last finished ones. */
102 function readLane(
103 db: Database,
104 running: ReturnType<typeof listRunning>,
105 now: number,
106 ): Lane {
107 const queued =
108 db
109 .query<{ n: number }, []>(
110 "SELECT COUNT(*) AS n FROM run WHERE kind = 'check' AND state = 'queued'",
111 )
112 .get()?.n ?? 0;
113 const first = running.find(
114 (r) => r.run.kind === "check" && !leaseLapsed(r.run, STALE_AFTER_MS, now),
115 );
116 const durations = db
117 .query<{ ms: number }, [number]>(
118 `SELECT (julianday(finished_at) - julianday(started_at)) * 86400000 AS ms
119 FROM run WHERE kind = 'check' AND state IN ('ok','failed')
120 AND started_at IS NOT NULL AND finished_at IS NOT NULL
121 ORDER BY finished_at DESC LIMIT ?1`,
122 )
123 .all(MEDIAN_OVER)
124 .map((r) => r.ms)
125 .sort((a, b) => a - b);
126 const mid = durations.length >> 1;
127 const medianMs =
128 durations.length === 0
129 ? null
130 : durations.length % 2
131 ? durations[mid]!
132 : (durations[mid - 1]! + durations[mid]!) / 2;
133 const medianMinutes = medianMs === null ? null : Math.round(medianMs / 60_000);
134 return {
135 queued,
136 running: first
137 ? {
138 pull: first.issue,
139 since: first.run.startedAt ?? first.run.createdAt,
140 }
141 : null,
142 medianMinutes,
143 clearsInMinutes:
144 medianMinutes === null || queued === 0 ? null : queued * medianMinutes,
145 };
146 }
147
68148 export function vitals(input: VitalsInput): Vitals {
69149 const now = input.now ?? Date.now();
70150 const needs: string[] = [];
@@ -79,17 +159,27 @@ export function vitals(input: VitalsInput): Vitals {
79159 `Disk is nearly full: ${Math.round((disk.freeBytes / disk.totalBytes) * 100)}% free.`,
80160 );
81161
162 const running = listRunning(input.db);
163 const lane = readLane(input.db, running, now);
82164 const queue: Record<string, number> = {};
83165 for (const row of queueDepth(input.db, now)) {
84166 queue[row.kind] = row.queued;
85167 if (now - new Date(row.oldest).getTime() > QUEUE_WAIT_MAX_MS)
86168 needs.push(
87 `The ${row.kind} queue has waited over ${QUEUE_WAIT_MAX_MS / 60_000} minutes.`,
169 row.kind === "check"
170 ? [
outdated

A null laneLine leaves '. ; one lane'. Build this sentence from the parts. Keep the 'Lane:' prefix out of the middle of a sentence.

171 `The check queue has waited over ${QUEUE_WAIT_MAX_MS / 60_000} minutes`,
172 laneLine(lane, now),
173 "one lane, checks run one at a time",
174 ]
175 .filter((part) => part !== null)
176 .join("; ") + "."
177 : `The ${row.kind} queue has waited over ${QUEUE_WAIT_MAX_MS / 60_000} minutes.`,
88178 );
89179 }
90180
91181 const stuck: Vitals["stuck"] = [];
92 for (const { run, repo, issue } of listRunning(input.db)) {
182 for (const { run, repo, issue } of running) {
93183 const started = new Date(run.startedAt ?? run.createdAt).getTime();
94184 if (leaseLapsed(run, STALE_AFTER_MS, now) || now - started > NO_PROGRESS_MS)
95185 stuck.push({
@@ -140,6 +230,7 @@ export function vitals(input: VitalsInput): Vitals {
140230 hostTotalBytes: totalmem(),
141231 },
142232 queue,
233 lane,
143234 stuck,
144235 apps,
145236 errors: { lastHour: seen.lastHour, latest: seen.latest },
src/tui/screens/dashboard.ts +5 −1
@@ -15,7 +15,7 @@
1515 */
1616
1717 import { holdWords, plannerHold } from "../../crew/autonomy.ts";
18 import { forgeNeeds } from "../../actions/health.ts";
18 import { forgeLane, forgeNeeds } from "../../actions/health.ts";
1919 import { mayView } from "../../core/sealed.ts";
2020 import { pinRepo, unpinRepo } from "../../actions/origins.ts";
2121 import { listAssignedTo, type Issue } from "../../core/issues.ts";
@@ -93,6 +93,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
9393 /** Why planning is paused, for the autonomous apps where it is; as on the board. */
9494 let holds = new Map<string, string>();
9595 let forge: string[] | undefined;
96 /** The check lane, as the board's strip says it; null when nothing is queued. */
97 let lane: string | null = null;
9698 const filter = new Filter();
9799 /** One cursor per pane: moving in one must never move another. */
98100 const cursors: Record<PaneId, ListState> = {
@@ -118,6 +120,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
118120 }),
119121 );
120122 forge = forgeNeeds(f.app, f.user);
123 lane = forgeLane(f.app, f.user);
121124 // "Yours" is ownership plus the orgs you are on a team of; typing in the
122125 // filter reaches every repository you can see.
123126 const orgs = f.user
@@ -281,6 +284,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
281284 let body = area;
282285 if (forge !== undefined) {
283286 const lines = forge.length === 0 ? ["All fine"] : forge.slice(0, 3);
287 if (lane !== null) lines.push(`Lane: ${lane}`);
284288 const [strip, rest] = split(area, "v", [len(lines.length), fill(1)]);
285289 lines.forEach((line, i) =>
286290 s.text(
src/web/pages/board.ts +3 −1
@@ -38,7 +38,7 @@ import { readSpend } from "../../actions/spend.ts";
3838 import { spendView } from "./spend.ts";
3939 import { listApps } from "../../core/apps.ts";
4040 import { forgeHealth, rolloutLine } from "../../kernel/guard.ts";
41 import { vitals, type Vitals } from "../../kernel/vitals.ts";
41 import { laneLine, vitals, type Vitals } from "../../kernel/vitals.ts";
4242 import { getIssue, getIssuesByIds, type Issue } from "../../core/issues.ts";
4343 import { looksForApps } from "../../core/looks.ts";
4444 import {
@@ -334,6 +334,7 @@ function forgeStrip(ctx: Ctx): Html | null {
334334 (i) => i.key !== "stuck" && i.key !== "held",
335335 );
336336 const ok = v.ok && fh.ok;
337 const lane = laneLine(v.lane);
337338 return html`<div
338339 class="board-forge"
339340 id="forge"
@@ -344,6 +345,7 @@ function forgeStrip(ctx: Ctx): Html | null {
344345 >
345346 ${rollout === null ? "" : html`<span class="board-forge-need">${rollout}</span>`}
346347 ${others.map((i) => html`<span class="board-forge-need">${i.key}: ${i.message}</span>`)}
348 ${lane === null ? "" : html`<span class="board-forge-lane">Lane: ${lane}</span>`}
347349 ${
348350 ok
349351 ? html`<span class="board-forge-ok">All fine</span>`
test/board.test.ts +13
@@ -456,6 +456,19 @@ describe("the board's forge strip", () => {
456456 expect(other).not.toContain("stuck.");
457457 });
458458
459 test("the home says the lane when checks are queued, to an admin only", async () => {
460 app.db.query("UPDATE run SET state = 'cancelled' WHERE state IN ('queued','running')").run();
461 const idle = await board("root");
462 expect(idle).not.toContain("Lane:");
463 const made = await createRepo(app, admin, { name: "laned" });
464 if (!made.ok) throw new Error(String(made.error));
465 for (let i = 0; i < 3; i++) createRun(app.db, { repoId: made.value.id, kind: "check" });
466 const busy = await board("root");
467 expect(busy).toContain("Lane: 3 checks queued");
468 expect(await board("bob")).not.toContain("Lane:");
469 app.db.query("UPDATE run SET state = 'cancelled' WHERE state IN ('queued','running')").run();
470 });
471
459472 test("a setting that is not the default is no incident: the strip stays fine and the cards render", async () => {
460473 const made = await createRepo(app, admin, { name: "calm" });
461474 if (!made.ok) throw new Error(String(made.error));
test/tui-screens.test.ts +11
@@ -10,6 +10,7 @@
1010 * session rather than the process.
1111 */
1212
13 import { createRun } from "../src/core/runs.ts";
1314 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1415 import { writeFileSync } from "node:fs";
1516 import { mkdtemp, rm } from "node:fs/promises";
@@ -741,11 +742,21 @@ describe("the forge's health in the terminal", () => {
741742 await admin.settle();
742743 expect(admin.text()).toContain("rollout blocked: quota refused");
743744 expect(admin.text()).not.toContain("All fine");
745 expect(admin.text()).not.toContain("Lane:");
744746 admin.stop();
745747
748 const made = await createRepo(forge, a.value, { name: "laned" });
749 if (!made.ok) throw new Error("repo creation failed");
750 for (let i = 0; i < 2; i++) createRun(forge.db, { repoId: made.value.id, kind: "check" });
751 const laned = boot(forge, as(a.value));
752 await laned.settle();
753 expect(laned.text()).toContain("Lane: 2 checks queued");
754 laned.stop();
755
746756 const other = boot(forge, as(b.value));
747757 await other.settle();
748758 expect(other.text()).not.toContain("rollout blocked");
759 expect(other.text()).not.toContain("Lane:");
749760 expect(other.text()).not.toContain("All fine");
750761 other.stop();
751762 } finally {
test/vitals.test.ts +44 −3
@@ -82,7 +82,7 @@ test("a site admin with a read-only token gets the shape; others get the short o
8282 expect(Object.keys(v).sort()).toEqual(
8383 [
8484 "apps", "at", "checkedAt", "commit", "disk", "draining", "errors",
85 "incidents", "memory", "needs", "ok", "queue", "rollout", "stale", "stuck",
85 "incidents", "lane", "memory", "needs", "ok", "queue", "rollout", "stale", "stuck",
8686 "version",
8787 ].sort(),
8888 );
@@ -149,8 +149,8 @@ test("the number of queries does not grow with runs", () => {
149149 if (i % 2) app.db.query("UPDATE run SET state='running' WHERE id=?1").run(r.id);
150150 }
151151 expect(count()).toBe(before);
152 // The queue, the running runs, and the runs killed twice for their budget.
153 expect(before).toBe(3);
152 // The queue, the running runs, the runs killed twice for their budget, and the lane's depth and finished checks.
153 expect(before).toBe(5);
154154 });
155155
156156 test("errors are counted, bounded, redacted, and a burst is said", async () => {
@@ -185,6 +185,47 @@ test("errors are counted, bounded, redacted, and a burst is said", async () => {
185185 }
186186 });
187187
188 test("the lane: depth, the running check and its age, the median of the last finished, the time to clear", async () => {
189 const { laneLine } = await import("../src/kernel/vitals.ts");
190 app.db.query("DELETE FROM run").run();
191 const now = Date.now();
192 const iso = (ms: number) => new Date(ms).toISOString();
193 // Empty lane: no line.
194 expect(vitals({ db: app.db, dataDir, now }).lane).toEqual({
195 queued: 0, running: null, medianMinutes: null, clearsInMinutes: null,
196 });
197 expect(laneLine(vitals({ db: app.db, dataDir, now }).lane, now)).toBeNull();
198 // Three finished checks of 10, 20 and 30 minutes: the median is 20.
199 for (const min of [10, 20, 30]) {
200 const r = createRun(app.db, { repoId, kind: "check" });
201 app.db
202 .query("UPDATE run SET state='ok', started_at=?2, finished_at=?3 WHERE id=?1")
203 .run(r.id, iso(now - 3_600_000), iso(now - 3_600_000 + min * 60_000));
204 }
205 const live = createRun(app.db, { repoId, kind: "check" });
206 app.db
207 .query("UPDATE run SET state='running', started_at=?2, heartbeat_at=?3 WHERE id=?1")
208 .run(live.id, iso(now - 6 * 60_000), iso(now));
209 for (let i = 0; i < 6; i++) createRun(app.db, { repoId, kind: "check" });
210 const lane = vitals({ db: app.db, dataDir, now }).lane;
211 expect(lane.queued).toBe(6);
212 expect(lane.running?.since).toBe(iso(now - 6 * 60_000));
213 expect(lane.medianMinutes).toBe(20);
214 expect(lane.clearsInMinutes).toBe(120);
215 expect(laneLine(lane, now)).toBe(
216 "6 checks queued · running a check for 6 min · median 20 min · clears in ~2 h",
217 );
218 // The same, in one call for an admin.
219 const body = (await (await health(admin)).json()) as { lane: typeof lane };
220 expect(body.lane.queued).toBe(6);
221 expect(body.lane.medianMinutes).toBe(20);
222 // Waiting past thirty minutes names the cause, not only the symptom.
223 const later = vitals({ db: app.db, dataDir, now: now + 3_600_000 });
224 const cause = later.needs.find((n) => n.includes("one lane"));
225 expect(cause).toMatch(/^The check queue has waited over 30 minutes; 6 checks queued .*; one lane, checks run one at a time\.$/);
226 expect(cause).not.toContain("Lane:");
227 });
228
188229 test("an unauthenticated internal call is refused, logged and flagged; the probe is not", async () => {
189230 const { resetRefusedInternal } = await import("../src/kernel/internal_refused.ts");
190231 resetRefusedInternal();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs