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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1277-the-lane-is-visible-queue-depth-the-runn → main 6 commits fork 5 threads · 5 unresolved
8 files
changed +184
−8
README.md +4
| @@ -561,6 +561,10 @@ work here. | ||
| 561 | 561 | flags new activity, no refresh. |
| 562 | 562 | - **Commit pages** — every commit's diff through the same viewer. |
| 563 | 563 | - **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. | |
| 564 | 568 | - **A terminal UI** — `ssh` in for the whole forge: dashboard, code browsing |
| 565 | 569 | with syntax highlighting, issues and pulls with live-updating threads, the |
| 566 | 570 | 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"; | ||
| 13 | 13 | import { listApps } from "../core/apps.ts"; |
| 14 | 14 | import type { User } from "../core/users.ts"; |
| 15 | 15 | 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"; | |
| 17 | 17 | |
| 18 | 18 | export function readHealth( |
| 19 | 19 | app: App, |
| @@ -47,3 +47,13 @@ export function forgeNeeds(app: App, actor: User | undefined): string[] | undefi | ||
| 47 | 47 | ...h.needs, |
| 48 | 48 | ]; |
| 49 | 49 | } |
| 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; | |
| 58 | return laneLine(h.lane); | |
| 59 | } |
src/kernel/vitals.ts +93 −2
| @@ -35,6 +35,17 @@ export interface EnvCount { | ||
| 35 | 35 | readonly state: string; |
| 36 | 36 | } |
| 37 | 37 | |
| 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 | ||
| 38 | 49 | export interface Vitals { |
| 39 | 50 | readonly ok: boolean; |
| 40 | 51 | readonly needs: string[]; |
| @@ -45,6 +56,7 @@ export interface Vitals { | ||
| 45 | 56 | hostTotalBytes: number; |
| 46 | 57 | }; |
| 47 | 58 | readonly queue: Record<string, number>; |
| 59 | readonly lane: Lane; | |
| 48 | 60 | readonly stuck: { |
| 49 | 61 | runId: string; |
| 50 | 62 | repo: string; |
| @@ -65,6 +77,74 @@ export interface VitalsInput { | ||
| 65 | 77 | readonly now?: number; |
| 66 | 78 | } |
| 67 | 79 | |
| 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 | ||
| 68 | 148 | export function vitals(input: VitalsInput): Vitals { |
| 69 | 149 | const now = input.now ?? Date.now(); |
| 70 | 150 | const needs: string[] = []; |
| @@ -79,17 +159,27 @@ export function vitals(input: VitalsInput): Vitals { | ||
| 79 | 159 | `Disk is nearly full: ${Math.round((disk.freeBytes / disk.totalBytes) * 100)}% free.`, |
| 80 | 160 | ); |
| 81 | 161 | |
| 162 | const running = listRunning(input.db); | |
| 163 | const lane = readLane(input.db, running, now); | |
| 82 | 164 | const queue: Record<string, number> = {}; |
| 83 | 165 | for (const row of queueDepth(input.db, now)) { |
| 84 | 166 | queue[row.kind] = row.queued; |
| 85 | 167 | if (now - new Date(row.oldest).getTime() > QUEUE_WAIT_MAX_MS) |
| 86 | 168 | 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.`, | |
| 88 | 178 | ); |
| 89 | 179 | } |
| 90 | 180 | |
| 91 | 181 | const stuck: Vitals["stuck"] = []; |
| 92 | for (const { run, repo, issue } of listRunning(input.db)) { | |
| 182 | for (const { run, repo, issue } of running) { | |
| 93 | 183 | const started = new Date(run.startedAt ?? run.createdAt).getTime(); |
| 94 | 184 | if (leaseLapsed(run, STALE_AFTER_MS, now) || now - started > NO_PROGRESS_MS) |
| 95 | 185 | stuck.push({ |
| @@ -140,6 +230,7 @@ export function vitals(input: VitalsInput): Vitals { | ||
| 140 | 230 | hostTotalBytes: totalmem(), |
| 141 | 231 | }, |
| 142 | 232 | queue, |
| 233 | lane, | |
| 143 | 234 | stuck, |
| 144 | 235 | apps, |
| 145 | 236 | errors: { lastHour: seen.lastHour, latest: seen.latest }, |
src/tui/screens/dashboard.ts +5 −1
| @@ -15,7 +15,7 @@ | ||
| 15 | 15 | */ |
| 16 | 16 | |
| 17 | 17 | import { holdWords, plannerHold } from "../../crew/autonomy.ts"; |
| 18 | import { forgeNeeds } from "../../actions/health.ts"; | |
| 18 | import { forgeLane, forgeNeeds } from "../../actions/health.ts"; | |
| 19 | 19 | import { mayView } from "../../core/sealed.ts"; |
| 20 | 20 | import { pinRepo, unpinRepo } from "../../actions/origins.ts"; |
| 21 | 21 | import { listAssignedTo, type Issue } from "../../core/issues.ts"; |
| @@ -93,6 +93,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 93 | 93 | /** Why planning is paused, for the autonomous apps where it is; as on the board. */ |
| 94 | 94 | let holds = new Map<string, string>(); |
| 95 | 95 | 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; | |
| 96 | 98 | const filter = new Filter(); |
| 97 | 99 | /** One cursor per pane: moving in one must never move another. */ |
| 98 | 100 | const cursors: Record<PaneId, ListState> = { |
| @@ -118,6 +120,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 118 | 120 | }), |
| 119 | 121 | ); |
| 120 | 122 | forge = forgeNeeds(f.app, f.user); |
| 123 | lane = forgeLane(f.app, f.user); | |
| 121 | 124 | // "Yours" is ownership plus the orgs you are on a team of; typing in the |
| 122 | 125 | // filter reaches every repository you can see. |
| 123 | 126 | const orgs = f.user |
| @@ -281,6 +284,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 281 | 284 | let body = area; |
| 282 | 285 | if (forge !== undefined) { |
| 283 | 286 | const lines = forge.length === 0 ? ["All fine"] : forge.slice(0, 3); |
| 287 | if (lane !== null) lines.push(`Lane: ${lane}`); | |
| 284 | 288 | const [strip, rest] = split(area, "v", [len(lines.length), fill(1)]); |
| 285 | 289 | lines.forEach((line, i) => |
| 286 | 290 | s.text( |
src/web/pages/board.ts +3 −1
| @@ -38,7 +38,7 @@ import { readSpend } from "../../actions/spend.ts"; | ||
| 38 | 38 | import { spendView } from "./spend.ts"; |
| 39 | 39 | import { listApps } from "../../core/apps.ts"; |
| 40 | 40 | 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"; | |
| 42 | 42 | import { getIssue, getIssuesByIds, type Issue } from "../../core/issues.ts"; |
| 43 | 43 | import { looksForApps } from "../../core/looks.ts"; |
| 44 | 44 | import { |
| @@ -334,6 +334,7 @@ function forgeStrip(ctx: Ctx): Html | null { | ||
| 334 | 334 | (i) => i.key !== "stuck" && i.key !== "held", |
| 335 | 335 | ); |
| 336 | 336 | const ok = v.ok && fh.ok; |
| 337 | const lane = laneLine(v.lane); | |
| 337 | 338 | return html`<div |
| 338 | 339 | class="board-forge" |
| 339 | 340 | id="forge" |
| @@ -344,6 +345,7 @@ function forgeStrip(ctx: Ctx): Html | null { | ||
| 344 | 345 | > |
| 345 | 346 | ${rollout === null ? "" : html`<span class="board-forge-need">${rollout}</span>`} |
| 346 | 347 | ${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>`} | |
| 347 | 349 | ${ |
| 348 | 350 | ok |
| 349 | 351 | ? html`<span class="board-forge-ok">All fine</span>` |
test/board.test.ts +13
| @@ -456,6 +456,19 @@ describe("the board's forge strip", () => { | ||
| 456 | 456 | expect(other).not.toContain("stuck."); |
| 457 | 457 | }); |
| 458 | 458 | |
| 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 | ||
| 459 | 472 | test("a setting that is not the default is no incident: the strip stays fine and the cards render", async () => { |
| 460 | 473 | const made = await createRepo(app, admin, { name: "calm" }); |
| 461 | 474 | if (!made.ok) throw new Error(String(made.error)); |
test/tui-screens.test.ts +11
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | * session rather than the process. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import { createRun } from "../src/core/runs.ts"; | |
| 13 | 14 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 14 | 15 | import { writeFileSync } from "node:fs"; |
| 15 | 16 | import { mkdtemp, rm } from "node:fs/promises"; |
| @@ -741,11 +742,21 @@ describe("the forge's health in the terminal", () => { | ||
| 741 | 742 | await admin.settle(); |
| 742 | 743 | expect(admin.text()).toContain("rollout blocked: quota refused"); |
| 743 | 744 | expect(admin.text()).not.toContain("All fine"); |
| 745 | expect(admin.text()).not.toContain("Lane:"); | |
| 744 | 746 | admin.stop(); |
| 745 | 747 | |
| 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 | ||
| 746 | 756 | const other = boot(forge, as(b.value)); |
| 747 | 757 | await other.settle(); |
| 748 | 758 | expect(other.text()).not.toContain("rollout blocked"); |
| 759 | expect(other.text()).not.toContain("Lane:"); | |
| 749 | 760 | expect(other.text()).not.toContain("All fine"); |
| 750 | 761 | other.stop(); |
| 751 | 762 | } 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 | ||
| 82 | 82 | expect(Object.keys(v).sort()).toEqual( |
| 83 | 83 | [ |
| 84 | 84 | "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", | |
| 86 | 86 | "version", |
| 87 | 87 | ].sort(), |
| 88 | 88 | ); |
| @@ -149,8 +149,8 @@ test("the number of queries does not grow with runs", () => { | ||
| 149 | 149 | if (i % 2) app.db.query("UPDATE run SET state='running' WHERE id=?1").run(r.id); |
| 150 | 150 | } |
| 151 | 151 | 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); | |
| 154 | 154 | }); |
| 155 | 155 | |
| 156 | 156 | 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 () => { | ||
| 185 | 185 | } |
| 186 | 186 | }); |
| 187 | 187 | |
| 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 | ||
| 188 | 229 | test("an unauthenticated internal call is refused, logged and flagged; the probe is not", async () => { |
| 189 | 230 | const { resetRefusedInternal } = await import("../src/kernel/internal_refused.ts"); |
| 190 | 231 | resetRefusedInternal(); |
new above
offline
Next: this reads health again; share the single read with forgeNeeds.