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

Clog note: measure time to detect #6778

Merged opened this

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

Merged

scout/forge:scout/6757-clog-note-measure-time-to-detect → main 7 commits fork 2 threads · 2 unresolved

  • Merged. scout/6757-clog-note-measure-time-to-detect was merged into main as 8a73b98.

Review

Sign in to review this change.
15 files changed +323 −42
README.md +2
@@ -46,6 +46,8 @@ A hand-over card on `/asks` (an agent's pull the reviewer stopped on after three
4646
4747 Home opens with "Since you were here · 2 days": the pulls that landed in public repositories since you last loaded `/` (1 hour to 7 days back, a day on a first visit); the terminal's home shows the same lines, and MCP `feed` answers them as `digest`. See `docs/features/since-you-were-here.md`.
4848
49 An admin's home and the terminal's Flow dialog also say when the forge is clogged and how long the last clog took to notice and to clear: `docs/features/clog-watch.md`.
50
4951 A pull the merge queue holds as red alone (red on its own merge at its current head; cleared by a new head or a moved main) says so under its checks line: "red alone at <time>, waiting for a new head or a new main", on the pull page, its live checks line and the studio change step, and in the checks row of the pull's terminal screen (`redMark` in `src/checks/queue.ts`). Nothing is drawn when it is not held.
5052
5153 On a sealed issue, a writer gets the private repository to fix it in: `POST /api/v1/repos/:owner/:name/issues/:number/private`, the issue page's button, and `p` on the issue's terminal screen, which shows the repository and its clone URL (built from the forge's origin). All three refuse alike: not found for anyone who may not see sealed reports, `not_sealed`, and `unverified`.
docs/features/clog-watch.md added +8
@@ -0,0 +1,8 @@
1 # Clog watch (#6757)
2
3 Once a minute the forge reads its flow (open pulls that conflict, pulls opened and landed over 2 h, the oldest approved wait) and declares a clog on the thresholds in `src/flow/clog.ts`. A clog clears after two quiet readings.
4
5 - **Where to see it (admin only):** home shows one muted line while a clog stands (`Clogged for 30 h (noticed 30 h in): <cause>.`) and, for a day after, the last cleared note (cause, time to detect, time to clear). The terminal's Flow dialog shows the same lines. A guest and an agent see neither.
6 - **Time to detect** is declared-minus-started. The start is the oldest approved pull's wait when that is what clogs; for any other reason it is the reading itself, so the note leaves the line out.
7 - **State:** `clog_state` (the standing clog) (with `started_at`, when it really began); the last cleared note is the `clog.cleared` event. Events `clog.started` and `clog.cleared` go to the outbox for admins.
8 - **Code:** `flow/clog_tick.ts` (the tick), `flow/clog_read.ts` (the reader), `flow/clog_watch.ts` and `flow/clog_note.ts` (the pure steps).
src/core/event_audience.ts +2
@@ -154,6 +154,8 @@ export function classifiedAudience(kind: string): EventAudience | undefined {
154154 kind.startsWith("drill.") ||
155155 // A snapshot moving is the forge owner's too, and has no repository.
156156 kind.startsWith("backup.") ||
157 // A clog is the whole forge's flow, the owner's to hear (`flow/clog_tick.ts`).
158 kind.startsWith("clog.") ||

Untested audience rule. Add a classifiedAudience test for clog.started and clog.cleared that fails without this line.

157159 // A report from outside waits for a person who decides; its arrival is
158160 // theirs to see, never the readers' (`actions/reports.ts`).
159161 kind.startsWith("report.") ||
src/core/migration-pins/20261027120000-clog-started-at.pin added +1
@@ -0,0 +1,1 @@
1 7bdec6f0cc9f155bd48d8180db5c653c0cff4fc594885cc2008bcba76b2a9db3
src/core/migrations/20261027120000-clog-started-at.ts added +5
@@ -0,0 +1,5 @@
1 // When a standing clog really began (#6757), epoch ms, beside `since` (when it
2 // was declared). Zero means not recorded: the clog began when it was declared.
3 export default `
4 ALTER TABLE clog_state ADD COLUMN started_at INTEGER NOT NULL DEFAULT 0;
5 `;
src/flow/clog_read.ts added +37
@@ -0,0 +1,37 @@
1 /**
2 * What the clog watch knows, for a person (#6757): the clog standing now and
3 * the note of the last one cleared, as plain lines. The reader is the owner's
4 * alone (`clog.*` is an admins' event); a page or screen that shows these
5 * lines gates on that before it calls.
6 */
7
8 import type { Database } from "bun:sqlite";
9 import { duration } from "./clog_note.ts";
10 import { lastClearedNote, loadClog } from "./clog_tick.ts";
11
12 export interface ClogRead {
13 readonly standing: { readonly since: number; readonly startedAt: number; readonly reasons: readonly string[] } | null;
14 /** The last cleared note within a day, on one line. */
15 readonly last: string | null;
16 }
17
18 export function readClog(db: Database, now = new Date()): ClogRead {
19 const state = loadClog(db);
20 return {
21 standing: state ? { since: state.since, startedAt: state.startedAt ?? state.since, reasons: state.firstReasons } : null,
22 last: lastClearedNote(db, now) ?? null,
23 };
24 }
25
26 /** "Clogged for 2.5 h (noticed 12 min in): <cause>." and the last cleared note, one line each; none, no lines. */
27 export function clogLines(read: ClogRead, now: number): string[] {
28 const out: string[] = [];
29 if (read.standing) {
30 const s = read.standing;
31 const cause = s.reasons.length ? s.reasons.join("; ") : "cause not recorded";
32 const lag = s.since > s.startedAt ? ` (noticed ${duration(s.since - s.startedAt)} in)` : "";
33 out.push(`Clogged for ${duration(now - s.startedAt)}${lag}: ${cause}.`);
outdated

The comment says "declared 12 min in" but the code prints "noticed … in". Make them match.

34 }
35 if (read.last) out.push(read.last);
36 return out;
37 }
src/flow/clog_tick.ts +36 −10
@@ -11,7 +11,7 @@ import { listEvents, type EventBus } from "../core/events.ts";
1111 import { getRepo } from "../core/repos.ts";
1212 import { createLog } from "../kernel/log.ts";
1313 import { ownRepoPath } from "../kernel/own_repo.ts";
14 import { clogOf } from "./clog.ts";
14 import { CLOG_APPROVED_HOURS, clogOf } from "./clog.ts";
1515 import { CLOG_NOTE_WINDOW_MS, clogNote, flattenNote } from "./clog_note.ts";
1616 import { clogStep, type ClogEvent, type ClogState } from "./clog_watch.ts";
1717 import { readOldestApproved } from "./oldest_approved.ts";
@@ -26,14 +26,20 @@ interface StateRow {
2626 first_reasons: string;
2727 quiet: number;
2828 peak_usd: number;
29 started_at: number;
2930 }
3031
3132 export function loadClog(db: Database): ClogState | null {
32 const row = db.query<StateRow, []>("SELECT since, first_reasons, quiet, peak_usd FROM clog_state WHERE id = 1").get();
33 const row = db.query<StateRow, []>("SELECT since, first_reasons, quiet, peak_usd, started_at FROM clog_state WHERE id = 1").get();
3334 if (!row) return null;
34 const parsed: unknown = JSON.parse(row.first_reasons);
35 const firstReasons = Array.isArray(parsed) ? parsed.filter((r): r is string => typeof r === "string") : [];
36 return { since: row.since, firstReasons, ...(row.quiet > 0 ? { quiet: row.quiet } : {}), peakUsd: row.peak_usd };
35 let firstReasons: string[] = [];
36 try {
37 const parsed: unknown = JSON.parse(row.first_reasons);
38 if (Array.isArray(parsed)) firstReasons = parsed.filter((r): r is string => typeof r === "string");
39 } catch {
40 // A row we cannot read is a clog with no recorded cause.
41 }
42 return { since: row.since, firstReasons, ...(row.started_at > 0 ? { startedAt: row.started_at } : {}), ...(row.quiet > 0 ? { quiet: row.quiet } : {}), peakUsd: row.peak_usd };
3743 }
3844
3945 function saveClog(db: Database, state: ClogState | null): void {
@@ -42,9 +48,9 @@ function saveClog(db: Database, state: ClogState | null): void {
4248 return;
4349 }
4450 db.query(
45 `INSERT INTO clog_state (id, since, first_reasons, quiet, peak_usd) VALUES (1, ?1, ?2, ?3, ?4)
46 ON CONFLICT (id) DO UPDATE SET since = ?1, first_reasons = ?2, quiet = ?3, peak_usd = ?4`,
47 ).run(state.since, JSON.stringify(state.firstReasons), state.quiet ?? 0, state.peakUsd ?? 0);
51 `INSERT INTO clog_state (id, since, first_reasons, quiet, peak_usd, started_at) VALUES (1, ?1, ?2, ?3, ?4, ?5)
52 ON CONFLICT (id) DO UPDATE SET since = ?1, first_reasons = ?2, quiet = ?3, peak_usd = ?4, started_at = ?5`,
53 ).run(state.since, JSON.stringify(state.firstReasons), state.quiet ?? 0, state.peakUsd ?? 0, state.startedAt ?? 0);
4854 }
4955
5056 /** Pulls opened and landed over the last 2 h, forge-wide, as the clog thresholds read them. */
@@ -75,6 +81,23 @@ export function lastClearedNote(db: Database, now = new Date()): string | undefi
7581 return flattenNote(note);
7682 }
7783
84 /** Ticks once a minute; a tick that throws is logged and the next one runs. Returns the stop. */
85 export function startClogTick(
86 db: Database,
87 bus: Pick<EventBus, "append">,
88 onError: (error: unknown) => void,
89 everyMs = 60_000,
90 ): () => void {
91 const timer = setInterval(() => {
92 try {
93 tickClog(db, bus);
94 } catch (error) {
95 onError(error);
96 }
97 }, everyMs);
98 return () => clearInterval(timer);
99 }
100
78101 /** One tick. With no own repository there is nobody to tell, and the watch still steps. */
79102 export function tickClog(db: Database, bus: Pick<EventBus, "append">, now = new Date()): ClogTick {
80103 const vitals = readVitals(db, now);
@@ -86,12 +109,15 @@ export function tickClog(db: Database, bus: Pick<EventBus, "append">, now = new
86109 landed,
87110 oldestApprovedHours: oldest ? Math.floor(oldest.hours) : 0,
88111 }).reasons;
112 // When the oldest approved pull is what clogs, the clog began when it began to wait.
113 const waited = oldest?.hours ?? 0;
114 const startedAt = waited > CLOG_APPROVED_HOURS ? now.getTime() - waited * HOUR_MS : now.getTime();
89115 const [owner, name] = ownRepoPath().split("/");
90116 const repo = owner && name ? getRepo(db, owner, name) : undefined;
91117 // Read, step and write in one transaction: two overlapping processes cannot both clear.
92118 return db.transaction((): ClogTick => {
93119 const prev = loadClog(db);
94 const step = clogStep(prev, reading, now.getTime());
120 const step = clogStep(prev, reading, now.getTime(), startedAt);
95121 const usd = readStranded(db).usd;
96122 // The peak is the most seen while the clog stood: by the clear the pile is gone.
97123 const state = step.state && { ...step.state, peakUsd: Math.max(prev?.peakUsd ?? 0, usd) };
@@ -100,7 +126,7 @@ export function tickClog(db: Database, bus: Pick<EventBus, "append">, now = new
100126 if (step.event === "cleared" && repo && prev) {
101127 const note = clogNote({
102128 reasons: prev.firstReasons,
103 startedAt: prev.since,
129 startedAt: prev.startedAt ?? prev.since,
104130 detectedAt: prev.since,
105131 clearedAt: now.getTime(),
106132 strandedUsd: Math.max(prev.peakUsd ?? 0, usd),
src/flow/clog_watch.ts +18 −4
@@ -12,6 +12,8 @@ export interface ClogState {
1212 readonly since: number;
1313 /** The reasons on the reading that started it. */
1414 readonly firstReasons: readonly string[];
15 /** When it really began (the oldest stuck item's wait, say); never after `since`. Absent: it began when declared. */
16 readonly startedAt?: number;
1517 /** Quiet readings in a row so far; absent when zero. */
1618 readonly quiet?: number;
1719 /** The most stranded dollars seen while it stood; kept by the tick, not by the step. */
@@ -25,16 +27,28 @@ export interface ClogStep {
2527 readonly event: ClogEvent | null;
2628 /** On "cleared": when the clog had started. */
2729 readonly since?: number;
30 /** On "cleared": when it really began. */
31 readonly startedAt?: number;
2832 }
2933
30 export function clogStep(prev: ClogState | null, reading: readonly string[], now: number): ClogStep {
34 /** `startedAt` is when the reading says the clog began (the reading's own time when it cannot tell); it counts only on the reading that declares it. */
35 export function clogStep(prev: ClogState | null, reading: readonly string[], now: number, startedAt: number = now): ClogStep {
3136 if (!prev) {
3237 if (reading.length === 0) return { state: null, event: null };
33 return { state: { since: now, firstReasons: [...reading] }, event: "started" };
38 const began = Number.isFinite(startedAt) ? Math.min(startedAt, now) : now;
39 return { state: { since: now, firstReasons: [...reading], startedAt: began }, event: "started" };
3440 }
3541 if (reading.length > 0)
36 return { state: { since: prev.since, firstReasons: prev.firstReasons, ...(prev.peakUsd === undefined ? {} : { peakUsd: prev.peakUsd }) }, event: null };
42 return {
43 state: {
44 since: prev.since,
45 firstReasons: prev.firstReasons,
46 ...(prev.startedAt === undefined ? {} : { startedAt: prev.startedAt }),
47 ...(prev.peakUsd === undefined ? {} : { peakUsd: prev.peakUsd }),
48 },
49 event: null,
50 };
3751 const quiet = (prev.quiet ?? 0) + 1;
38 if (quiet >= QUIET_TO_CLEAR) return { state: null, event: "cleared", since: prev.since };
52 if (quiet >= QUIET_TO_CLEAR) return { state: null, event: "cleared", since: prev.since, ...(prev.startedAt === undefined ? {} : { startedAt: prev.startedAt }) };
3953 return { state: { ...prev, quiet }, event: null };
4054 }
src/main.ts +4 −11
@@ -54,7 +54,7 @@ import { typeSafeDecider } from "./crew/decide.ts";
5454 import { graphInstagram } from "./crew/instagram.ts";
5555 import { peopleOnCrews } from "./core/agents.ts";
5656 import { startNotifier } from "./crew/notify.ts";
57 import { tickClog } from "./flow/clog_tick.ts";
57 import { startClogTick } from "./flow/clog_tick.ts";
5858 import { sweepStaleAsks } from "./crew/stale_asks.ts";
5959 import { recordAskLedger } from "./web/studio/asks.ts";
6060 import { startPromptRoutine } from "./crew/prompts.ts";
@@ -551,15 +551,8 @@ const processTick = (): void => {
551551 app.log.warn("process census failed", { error: String(error) });
552552 }
553553 };
554 // The clog watch (#6524) reads the flow every minute; its state is the database's.
555 const clogTick = (): void => {
556 try {
557 tickClog(app.db, app.bus);
558 } catch (error) {
559 app.log.warn("clog tick failed", { error: String(error) });
560 }
561 };
562 const clogTimer = setInterval(clogTick, 60_000);
554 // The clog watch (#6524, #6757) reads the flow every minute; its state is the database's.
555 const stopClogTick = startClogTick(app.db, app.bus, (error) => app.log.warn("clog tick failed", { error: String(error) }));
563556 const processTimer = setInterval(processTick, 60_000);
564557 processTick();
565558 // A new version proves itself against the public URL once (release/prove.ts).
@@ -677,8 +670,8 @@ function drain(signal: NodeJS.Signals): Promise<unknown> {
677670 clearInterval(syncTimer);
678671 clearInterval(staleTimer);
679672 clearInterval(followTimer);
673 stopClogTick();
680674 clearInterval(processTimer);
681 clearInterval(clogTimer);
682675 clearInterval(leaseTimer);
683676 // The platform's SIGTERM: the listener stays open while routers learn
684677 // this process is going. A person's Ctrl-C does not wait for routers.
src/tui/screens/flow.ts +2
@@ -29,6 +29,7 @@ import { readLeadTime, type LeadTime } from "../../flow/lead_time.ts";
2929 import { buildYieldSays, readBuildYield } from "../../flow/build_yield.ts";
3030 import { readReviewLatency, reviewLatencySays } from "../../flow/review_latency.ts";
3131 import { readOpeners, type Opener } from "../../flow/openers.ts";
32 import { clogLines, readClog } from "../../flow/clog_read.ts";
3233 import { readQueues, redBatchWords } from "../../flow/queue.ts";
3334 import { reviewersBusy, reviewingSays } from "../../flow/reviewing.ts";
3435 import { requestedSays, requestedWaits } from "../../flow/requested.ts";
@@ -74,6 +75,7 @@ export function flowLines(
7475 ...wrap(verdict(stages, now.getTime()), inner),
7576 "",
7677 ...wrap(vitalsLine(vitalsFor(readVitals(db, now, agent !== undefined), agent === undefined)), inner),
78 ...(agent === undefined ? clogLines(readClog(db, now), now.getTime()).flatMap((l) => wrap(l, inner)) : []),
7779 ...firedAlarms(db, now, { publicOnly: agent !== undefined, ceilingUsd }).flatMap((a) => wrap(a.says, inner)),
7880 "",
7981 ...wrap(parkedSays(readParked(db, now, agent !== undefined)), inner),
src/web/studio/home.ts +2 −1
@@ -53,7 +53,7 @@ import { needsYouSection } from "./asks_page.ts";
5353 import { flowCards } from "./flow_card.ts";
5454 import { composer, feedJs, feedNote, feedRegion } from "./feed.ts";
5555 import { alarmLines, cachedAlarms } from "./alarm_lines.ts";
56 import { kpiRow, vitalsRow, waitingLine } from "./kpi_row.ts";
56 import { clogRow, kpiRow, vitalsRow, waitingLine } from "./kpi_row.ts";
5757 import { feedCandidateCache } from "../../feed/feed.ts";
5858 import { notePhase, notePhaseRest, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
5959 import { kpiCache, panelCache, waitingCache } from "./home_cache.ts";
@@ -290,6 +290,7 @@ export async function studioHome(
290290 ? timePhase(ctx.request, "kpis", () => kpiRow(ctx.app.db, kpiOpts), () => kpiCache(ctx.app.db))
291291 : liveLine(ctx)}
292292 ${figures ? timePhase(ctx.request, "vitals", () => vitalsRow(ctx.app.db)) : null}
293 ${figures ? clogRow(ctx.app.db) : null}
293294 ${figures ? alarmLines(cachedAlarms(ctx.app.db, new Date(), ceilingUsd)) : null}
294295 ${timePhase(ctx.request, "waiting", () => waitingLine(ctx.app.db, user), () => waitingCache(ctx.app.db))}
295296 ${figures
src/web/studio/kpi_row.ts +8
@@ -10,6 +10,7 @@
1010 import type { Database } from "bun:sqlite";
1111 import { backupAge, backupFull, backupOverdue, buildersValue, checkTime, reactNote, speedWords, spendCeilingWords, spendFull, spendValue, usersValue, visitorsNote, visitorsValue, waitingWords } from "../../flow/kpi_words.ts";
1212 import type { Kpis } from "../../flow/kpis.ts";
13 import { clogLines, readClog } from "../../flow/clog_read.ts";
1314 import { vitalsLine } from "../../flow/vitals.ts";
1415 import { cachedKpis, cachedVitals, cachedWaiting } from "./home_cache.ts";
1516 import { escapeHtml, html, raw, type Html } from "../html.ts";
@@ -196,3 +197,10 @@ export function vitalsRow(db: Database, now: Date = new Date()): Html {
196197 const rest = vitalsLine(v).split(" · ").slice(1).join(" · ");
197198 return html`<p class="small muted"><a href="/boards">${v.open} open pulls</a> · ${rest}</p>`;
198199 }
200
201 /** Home's clog lines for an admin (#6757): the clog standing now and the last cleared note; nothing when there is neither. */
202 export function clogRow(db: Database, now: Date = new Date()): Html {
203 const lines = clogLines(readClog(db, now), now.getTime());
204 if (lines.length === 0) return html``;
205 return html`${lines.map((l) => html`<p class="small muted break">${l}</p>`)}`;
206 }
test/event-audience-coverage.test.ts +7
@@ -177,6 +177,13 @@ describe("event audience coverage", () => {
177177 expect(eventAudience("brand.new_kind")).toBe("admins");
178178 });
179179
180 test("a clog's events carry its cause and are classified for admins, never guests or agents", () => {
181 for (const kind of ["clog.started", "clog.cleared"]) {
182 expect(classifiedAudience(kind)).toBe("admins");
183 expect(eventAudience(kind)).toBe("admins");
184 }
185 });
186
180187 test("the scan names a kind it cannot read literally", () => {
181188 const { unreadable } = emittedKinds({
182189 "src/x.ts": "app.bus.append({ kind: pick(a), payload: {} });",
test/flow-clog-door.test.ts added +159
@@ -0,0 +1,159 @@
1 /**
2 * The clog on a door (#6757): the tick's other reasons print no "Time to
3 * detect", a corrupt row and a restart are survived, and the standing clog and
4 * the last cleared note show to the admin on home and in the Flow dialog, never to a guest or an agent.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtempSync, rmSync } from "node:fs";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { lastClearedNote, startClogTick, tickClog } from "../src/flow/clog_tick.ts";
13 import { clogLines, readClog } from "../src/flow/clog_read.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { flowLines } from "../src/tui/screens/flow.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17
18 quietLogs();
19
20 const T0 = new Date("2026-10-26T12:00:00.000Z");
21 const ago = (): string => new Date(T0.getTime() - 5 * 3_600_000).toISOString();
22 const after = (min: number): Date => new Date(T0.getTime() + min * 60_000);
23
24 let root: string;
25 let app: App;
26 let servers: Servers;
27 let uid: string;
28 let n = 0;
29 let was: string | undefined;
30 let adminCookie = "";
31 let guestCookie = "";
32
33 async function signIn(username: string): Promise<string> {
34 const res = await fetch(`${servers.url}/signin`, {
35 method: "POST",
36 headers: { origin: servers.url },
37 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
38 redirect: "manual",
39 });
40 return (res.headers.get("set-cookie") ?? "").split(";")[0]!;
41 }
42
43 beforeAll(async () => {
44 was = process.env.FORGE_SENTINEL_REPO;
45 process.env.FORGE_SENTINEL_REPO = "alice/forge";
46 root = mkdtempSync(join(tmpdir(), "clog-door-"));
47 app = createApp({ dataDir: root, port: 0 });
48 for (const [username, first] of [["alice", true], ["guest", false]] as const) {
49 const u = await createUser(app.db, {
50 origin: "seed",
51 trusted: true,
52 username,
53 email: `${username}@forge.local`,
54 password: "a-good-passphrase",
55 adminIfFirst: first,
56 });
57 if (!u.ok) throw new Error(u.error);
58 if (first) uid = u.value.id;
59 }
60 app.db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('pub', ?1, 'forge', 'public')", [uid]);
61 servers = await serve(app);
62 adminCookie = await signIn("alice");
63 guestCookie = await signIn("guest");
64 });
65 afterAll(() => {
66 if (was === undefined) delete process.env.FORGE_SENTINEL_REPO;
67 else process.env.FORGE_SENTINEL_REPO = was;
68 servers.stop();
69 app.close();
70 rmSync(root, { recursive: true, force: true });
71 });
72
73 const reset = (): void => {
74 app.db.run("DELETE FROM clog_state");
75 app.db.run("DELETE FROM event");
76 app.db.run("DELETE FROM issue");
77 };
78
79 function pull(createdAt: string, merged: string | null = null): string {
80 const id = `p${++n}`;
81 app.db.run("INSERT INTO issue (id, repo_id, number, kind, title, author_id, state, created_at) VALUES (?1, 'pub', ?2, 'pull', 't', ?3, 'open', ?4)", [id, n, uid, createdAt]);
82 app.db.run("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES (?1, 'main', ?2, '', 'h', ?3)", [id, `b${n}`, merged]);
83 return id;
84 }
85
86 const cleared = (at: Date): string => lastClearedNote(app.db, at) ?? "";
87
88 test("a landing-ratio clog starts at the reading and its note prints no Time to detect", () => {
89 reset();
90 for (let i = 0; i < 10; i++) pull(T0.toISOString());
91 expect(tickClog(app.db, app.bus, T0).event).toBe("started");
92 const row = app.db.query<{ since: number; started_at: number }, []>("SELECT since, started_at FROM clog_state").get()!;
93 expect(row.started_at).toBe(row.since);
94 app.db.run("DELETE FROM issue");
95 tickClog(app.db, app.bus, after(1));
96 expect(tickClog(app.db, app.bus, after(2)).event).toBe("cleared");
97 expect(cleared(after(2))).toContain("Cause: 0 landed of 10 opened");
98 expect(cleared(after(2))).not.toContain("Time to detect");
99 });
100
101 test("a conflict clog starts at the reading too", () => {
102 reset();
103 for (let i = 0; i < 11; i++) {
104 const id = pull(ago());
105 app.db.run("INSERT INTO run (id, repo_id, kind, state, issue_id, reason, created_at) VALUES (?1, 'pub', 'agent', 'queued', ?2, 'conflict', ?3)", [`r${id}`, id, T0.toISOString()]);
106 }
107 expect(tickClog(app.db, app.bus, T0).event).toBe("started");
108 const row = app.db.query<{ since: number; started_at: number }, []>("SELECT since, started_at FROM clog_state").get()!;
109 expect(row.started_at).toBe(row.since);
110 app.db.run("DELETE FROM run");
111 });
112
113 test("a corrupt reasons row is a clog with no recorded cause, and a standing row resumes after a restart", () => {
114 reset();
115 app.db.run("INSERT INTO clog_state (id, since, started_at, first_reasons, quiet) VALUES (1, ?1, ?2, '{not json', 1)", [T0.getTime(), T0.getTime() - 3_600_000]);
116 const read = readClog(app.db);
117 expect(read.standing?.reasons).toEqual([]);
118 expect(clogLines(read, T0.getTime())[0]).toContain("cause not recorded");
119 // The next quiet reading (a fresh tick, as after a restart) finishes the clear from the stored row.
120 expect(tickClog(app.db, app.bus, after(1)).event).toBe("cleared");
121 expect(cleared(after(1))).toContain("Cause: not recorded");
122 expect(cleared(after(1))).toContain("Time to detect: 60 min");
123 });
124
125 test("clogLines: the standing clog, then the last cleared note", () => {
126 const read = { standing: { since: T0.getTime(), startedAt: T0.getTime() - 30 * 3_600_000, reasons: ["a", "b"] }, last: "Clog cleared. Cause: x. · Time to detect: 5 min · Time to clear: 2 min" };
127 const lines = clogLines(read, T0.getTime());
128 expect(lines[0]).toBe("Clogged for 30 h (noticed 30 h in): a; b.");
129 expect(lines[1]).toBe("Clog cleared. Cause: x. · Time to detect: 5 min · Time to clear: 2 min");
130 expect(clogLines({ standing: null, last: null }, T0.getTime())).toEqual([]);
131 });
132
133 test("the admin sees the clog on home and in the Flow dialog; a guest and an agent see neither", async () => {
134 reset();
135 app.db.run("INSERT INTO clog_state (id, since, started_at, first_reasons, quiet) VALUES (1, ?1, ?1, ?2, 0)", [Date.now(), JSON.stringify(["31 pulls conflict <b>"])]);
136 const home = async (cookie: string): Promise<string> => (await fetch(`${servers.url}/`, { headers: { cookie } })).text();
137 const mine = await home(adminCookie);
138 expect(mine).toContain("31 pulls conflict &lt;b&gt;");
139 expect(mine).not.toContain("<b>");
140 expect(await home(guestCookie)).not.toContain("pulls conflict");
141 expect(flowLines(app.db, new Date(), [], undefined, []).join("\n")).toContain("31 pulls conflict");
142 expect(flowLines(app.db, new Date(), [], { queues: [] }, []).join("\n")).not.toContain("31 pulls conflict");
143 });
144
145 test("startClogTick runs the tick on its interval, reports a throw, and stops", async () => {
146 reset();
147 for (let i = 0; i < 10; i++) pull(new Date().toISOString());
148 const errors: unknown[] = [];
149 const stop = startClogTick(app.db, app.bus, (e) => errors.push(e), 5);
150 await Bun.sleep(40);
151 stop();
152 expect(app.db.query("SELECT 1 FROM clog_state").get()).not.toBeNull();
153 expect(errors).toEqual([]);
154 app.db.run("DROP TABLE clog_state");
155 const broken = startClogTick(app.db, app.bus, (e) => errors.push(e), 5);
156 await Bun.sleep(40);
157 broken();
158 expect(errors.length).toBeGreaterThan(0);
159 });
test/flow-clog-watch.test.ts +32 −16
@@ -1,40 +1,56 @@
11 import { expect, test } from "bun:test";
2 import { clogNote } from "../src/flow/clog_note.ts";
23 import { clogStep } from "../src/flow/clog_watch.ts";
34
45 test("quiet with no clog stays null", () => {
5 expect(clogStep(null, [], 1)).toEqual({ state: null, event: null });
6 expect(clogStep(null, [], 1, 1)).toEqual({ state: null, event: null });
67 });
78
89 test("a reason starts a clog once and it stands without repeats", () => {
9 const a = clogStep(null, ["red main"], 10);
10 const a = clogStep(null, ["red main"], 10, 10);
1011 expect(a.event).toBe("started");
11 expect(a.state).toEqual({ since: 10, firstReasons: ["red main"] });
12 const b = clogStep(a.state, ["red main", "stuck"], 20);
12 expect(a.state).toEqual({ since: 10, startedAt: 10, firstReasons: ["red main"] });
13 const b = clogStep(a.state, ["red main", "stuck"], 20, 20);
1314 expect(b.event).toBeNull();
14 expect(b.state).toEqual({ since: 10, firstReasons: ["red main"] });
15 expect(b.state).toEqual({ since: 10, startedAt: 10, firstReasons: ["red main"] });
1516 });
1617
1718 test("one quiet reading is still standing; a reason resets the count", () => {
18 const a = clogStep(null, ["x"], 10);
19 const b = clogStep(a.state, [], 20);
19 const a = clogStep(null, ["x"], 10, 10);
20 const b = clogStep(a.state, [], 20, 20);
2021 expect(b.event).toBeNull();
2122 expect(b.state?.since).toBe(10);
22 const c = clogStep(b.state, ["x"], 30);
23 const d = clogStep(c.state, [], 40);
23 const c = clogStep(b.state, ["x"], 30, 30);
24 const d = clogStep(c.state, [], 40, 40);
2425 expect(d.event).toBeNull();
2526 expect(d.state).not.toBeNull();
2627 });
2728
2829 test("two quiet readings in a row clear it with the start time", () => {
29 let s = clogStep(null, ["x"], 10).state;
30 s = clogStep(s, [], 20).state;
31 const c = clogStep(s, [], 30);
32 expect(c).toEqual({ state: null, event: "cleared", since: 10 });
30 let s = clogStep(null, ["x"], 10, 10).state;
31 s = clogStep(s, [], 20, 20).state;
32 const c = clogStep(s, [], 30, 30);
33 expect(c).toEqual({ state: null, event: "cleared", since: 10, startedAt: 10 });
3334 });
3435
3536 test("a clog after a clear starts anew", () => {
36 const c = clogStep(clogStep(clogStep(null, ["x"], 10).state, [], 20).state, [], 30);
37 const r = clogStep(c.state, ["y"], 50);
37 const c = clogStep(clogStep(clogStep(null, ["x"], 10, 10).state, [], 20, 20).state, [], 30, 30);
38 const r = clogStep(c.state, ["y"], 50, 50);
3839 expect(r.event).toBe("started");
39 expect(r.state).toEqual({ since: 50, firstReasons: ["y"] });
40 expect(r.state).toEqual({ since: 50, startedAt: 50, firstReasons: ["y"] });
41 });
42
43 test("a real start rides the state to the clear and into the note", () => {
44 const a = clogStep(null, ["x"], 600_000, -300_000);
45 expect(a.state).toEqual({ since: 600_000, startedAt: -300_000, firstReasons: ["x"] });
46 const b = clogStep(clogStep(a.state, ["x"], 660_000, 660_000).state, [], 720_000, 720_000);
47 const c = clogStep(b.state, [], 780_000, 780_000);
48 expect(c.startedAt).toBe(-300_000);
49 const note = clogNote({ reasons: ["x"], startedAt: c.startedAt!, detectedAt: c.since!, clearedAt: 780_000 });
50 expect(note).toContain("Time to detect: 15 min");
51 });
52
53 test("a start after now, or not a number, falls back to now", () => {
54 expect(clogStep(null, ["x"], 10, 99).state?.startedAt).toBe(10);
55 expect(clogStep(null, ["x"], 10, NaN).state?.startedAt).toBe(10);
4056 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs