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

When a clog clears, the forge posts its note on the owner's feed once #6681

Merged opened this

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

Merged

scout/forge:scout/6524-when-a-clog-clears-the-forge-posts-its-n → main 11 commits fork 10 threads · 10 unresolved

  • Merged. scout/6524-when-a-clog-clears-the-forge-posts-its-n was merged into main as 4d80598.

Review

Sign in to review this change.
14 files changed +369 −4
docs/STATUS.md +1
@@ -37,6 +37,7 @@
3737 allowed).
3838 - **System memory** — `/system`, for a forge admin who is a person (anyone else gets 403): the forge's RSS beside its JS heap (`heapStats()` from bun:jsc), hourly mean and max for the last 24 h, drawn on the server. Sampled once a minute into `memory_hour` in `app.db` (a week kept). RSS rising while the heap stays flat is native memory growth. In the terminal the same admin has a **System memory** command in the palette: the last 24 hours as text rows (hour, RSS mean/max, heap mean/max in MiB), read-only. The account menu links `/system` for that admin.
3939 - **Flow alarms** — a firing alarm (red main, a stuck approved pull, an idle crew, no release for 90 min) shows as one linked line above home's feed and on `/boards` (admin), under the vitals line of the terminal's Flow dialog and home, as one phone notification when it starts (not repeated while it stands), and in the `flow` tool's `alarms` (MCP, `/api/v1/telemetry/flow`; an agent's cut to public repositories). Nothing firing draws nothing. The dialog also lists the idle crew pulls (number, repository, hours since the last push), oldest first, at most 20 read and five drawn with `+N more`; none draws nothing, and an agent's list is cut to public repositories.
40 - **Clog watch** — the minute tick (`src/flow/clog_tick.ts`) reads the flow; when a clog starts the admin gets one phone notification, and when it clears one `clog.cleared` note (cause, time to clear, peak stranded $) lands once on the admin's feed and as a line on the terminal's home. The watch state is in the database, so a restart repeats neither.
4041 - **Boards** — `/boards` also shows the pipeline (a row per stage, the bottleneck named) and the held counts (why each autonomous repository's ready issues wait; private repositories included), for the same admin who is a person; they sit as two cards under the board list, not as panels of a board, because they are not saved queries. The terminal's Boards screen does not draw them: its door to the same rows is the **Flow** dialog. `/boards/data`, for a forge admin who is a person (anyone else
4142 gets a 404 on the page): the query is the URL (`?source=runs&aggregate=count&by=day&fcolumn=state&fvalue=failed`),
4243 drawn server-side as the chart chosen beside it (`chart=line|bar|area|number|table|heat`)
src/core/event_audience.ts +2
@@ -170,6 +170,8 @@ export function classifiedAudience(kind: string): EventAudience | undefined {
170170 kind === "repo.upstream_follow" ||
171171 kind === "repo.upstream_checked" ||
172172 kind === "repo.crew_outsiders" ||
173 // A clog starting names the forge's own pile-up: the owner's to hear (#6524).

Nit: this prefix also covers clog.cleared, so the comment should say "a clog starting or clearing".

174 kind.startsWith("clog.") ||
173175 // A file held against new work is its admins' book-keeping (#6522).
174176 kind === "repo.file_held" ||
175177 kind === "repo.file_released"
src/core/migrations/20261027010000-clog-watch.ts added +11
@@ -0,0 +1,11 @@
1 // The clog watch survives a restart (#6524): the one standing clog (since
2 // when, the reasons that started it, quiet readings so far). The cleared
3 // note rides the event outbox as `clog.cleared`.
4 export default `
5 CREATE TABLE IF NOT EXISTS clog_state (
6 id INTEGER PRIMARY KEY CHECK (id = 1),
7 since INTEGER NOT NULL,
8 first_reasons TEXT NOT NULL,
9 quiet INTEGER NOT NULL DEFAULT 0
10 );
11 `;
src/core/migrations/20261027100000-clog-peak.ts added +5
@@ -0,0 +1,5 @@
1 // The most the stranded spend reached while a clog stood (#6524), so the
2 // cleared note can say "at peak" truthfully. Additive; zero before this column.
3 export default `
4 ALTER TABLE clog_state ADD COLUMN peak_usd REAL NOT NULL DEFAULT 0;
5 `;
src/crew/notify.ts +19
@@ -41,6 +41,7 @@ import { health } from "../http/health.ts";
4141 import { forgeVitals } from "../actions/health.ts";
4242 import { RECOVERED, drainIncident, nextPage, observe, recoveredLine, type Watch } from "./incidents.ts";
4343 import { firedAlarms, type FiredAlarm } from "../flow/alarms.ts";
44 import { loadClog } from "../flow/clog_tick.ts";
4445 import { forgeCeiling } from "../flow/ceiling.ts";
4546 import { askLinesFor } from "../web/studio/asks.ts";
4647
@@ -56,6 +57,7 @@ const BACKOFF_MAX_MS = HOUR_MS;
5657
5758 /** Events after which the set of asks may have changed. */
5859 const WAKES = new Set([
60 "clog.started",
5961 "run.finished",
6062 "pull.opened",
6163 "pull.pushed",
@@ -262,6 +264,22 @@ export function alarmLines(
262264 });
263265 }
264266
267 /**
268 * The owner hears once when a clog (#6524) starts: the id is the clog's own
269 * start, so the one standing clog is the same ask on every tick and a later
270 * one is new. The state is the database's, so a restart never says it twice.
271 */
272 export function clogLines(
273 app: App,
274 user: User,
275 ): { id: string; line: string; items: readonly string[]; link: string }[] {
276 if (!user.isAdmin || user.kind !== "user") return [];
277 const clog = loadClog(app.db);
278 if (clog === null) return [];
279 const id = `clog:${clog.since}`;
280 return [{ id, line: `Clogged: ${clog.firstReasons.join("; ")}.`, items: [id], link: "/" }];
281 }
282
265283 export interface Notifier {
266284 /** Resolves when the work queued so far is done. For tests and shutdown. */
267285 settled(): Promise<void>;
@@ -448,6 +466,7 @@ export function startNotifier(app: App, options: NotifyOptions): Notifier {
448466 ...mirrorLines(app, user, now()),
449467 ...redLines(app, user, now()),
450468 ...alarmLines(app, user, now(), options.alarms?.()),
469 ...clogLines(app, user),
451470 ];
452471 const before = seen.get(userId);
453472 // What is gone may ask again later, and then it is new again. Seen by
src/feed/items.ts +8
@@ -30,6 +30,7 @@ import {
3030 import { getRunErrorLine } from "../core/runs.ts";
3131 import { getUserById, type User } from "../core/users.ts";
3232 import { watchedBy } from "../core/watches.ts";
33 import { CLOG_NOTE_WINDOW_MS, flattenNote } from "../flow/clog_note.ts";
3334 import { chooseCandidates } from "./choose.ts";
3435 import { writeStamp } from "../core/write_clock.ts";
3536 import { Stamped } from "../kernel/stamped.ts";
@@ -379,6 +380,7 @@ export function candidatesFor(
379380 if (!repo) continue;
380381 const fold = folded.get(e.id);
381382 if (fold === "skip") continue;
383 if (e.kind === "clog.cleared" && now - Date.parse(e.createdAt) > CLOG_NOTE_WINDOW_MS) continue;
382384 const c = fromEvent(db, e, repo, owns(repo), readsLogs(repo), subjects);
383385 if (c)
384386 out.push({
@@ -686,6 +688,12 @@ function fromEvent(
686688 what: said === "" ? `replied on “${on}”` : `on “${on}”: ${clip(said)}`,
687689 };
688690 }
691 case "clog.cleared": {
692 const note = typeof p["note"] === "string" ? p["note"] : "";
693 if (note === "") return undefined;
694 const what = clip(flattenNote(note), 300);
695 return { ...common, kind: "map", link: base(repo), what, said: what };
696 }
689697 case "issue.blocked":
690698 case "issue.unblocked":
691699 return {
src/flow/clog_note.ts +12 −3
@@ -1,6 +1,6 @@
11 /**
22 * The note posted when a clog clears (#5930): the cause on one line, how long
3 * the clog took to be noticed and to clear, and the dollars stranded at the
3 * the clog took to be noticed (only when that was measured) and to clear, and the dollars stranded at the
44 * peak. Pure: plain data in, markdown out. Posting it is another module's job.
55 */
66
@@ -25,14 +25,23 @@ export function duration(ms: number): string {
2525 return `${(Math.round(min / 6) / 10).toString()} h`;
2626 }
2727
28 /** How long a cleared clog's note stays on the feed and the terminal's home line. */
29 export const CLOG_NOTE_WINDOW_MS = 24 * 3_600_000;
30
31 /** The note on one line: its blank line and bullets become " · ". */
32 export function flattenNote(note: string): string {
33 return note.replace(/\s*\n+\s*-?\s*/g, " · ").trim();

Skip this line when detectedAt === startedAt rather than print a figure that is always 0.

34 }
35
2836 export function clogNote(input: ClogNoteInput): string {
2937 const why = input.reasons.map((r) => r.trim()).filter(Boolean);
3038 const lines = [
3139 `Clog cleared. Cause: ${why.length ? why.join("; ") : "not recorded"}.`,
3240 "",
33 `- Time to detect: ${duration(input.detectedAt - input.startedAt)}`,
34 `- Time to clear: ${duration(input.clearedAt - input.detectedAt)}`,
3541 ];
42 // Detected the moment it started means detection was not measured: print no figure rather than a false 0.
43 if (input.detectedAt !== input.startedAt) lines.push(`- Time to detect: ${duration(input.detectedAt - input.startedAt)}`);
44 lines.push(`- Time to clear: ${duration(input.clearedAt - input.detectedAt)}`);
3645 const usd = input.strandedUsd;
3746 if (typeof usd === "number" && Number.isFinite(usd) && usd > 0) {
3847 lines.push(`- Stranded at peak: $${usd.toLocaleString("en-US", { maximumFractionDigits: 2 })}`);
src/flow/clog_tick.ts added +115
@@ -0,0 +1,115 @@
1 /**
2 * The minute's clog tick (#6524): reads the flow, asks `clogOf` whether it is
3 * clogged, steps the watch (`clogStep`) and acts on the one event: "started"
4 * is told on the bus once, "cleared" puts `clogNote` on the bus once (the feed
5 * and the terminal read it from there). The watch state lives in `clog_state`, so a restart reads where it
6 * left off and never repeats either.
7 */
8
9 import type { Database } from "bun:sqlite";
10 import { listEvents, type EventBus } from "../core/events.ts";
11 import { getRepo } from "../core/repos.ts";
12 import { createLog } from "../kernel/log.ts";
13 import { ownRepoPath } from "../kernel/own_repo.ts";
14 import { clogOf } from "./clog.ts";
15 import { CLOG_NOTE_WINDOW_MS, clogNote, flattenNote } from "./clog_note.ts";
16 import { clogStep, type ClogEvent, type ClogState } from "./clog_watch.ts";
17 import { readOldestApproved } from "./oldest_approved.ts";
18 import { readStranded } from "./stranded.ts";
19 import { readVitals } from "./vitals.ts";
20
21 const HOUR_MS = 3_600_000;
22 const log = createLog("clog");
23
24 interface StateRow {
25 since: number;
26 first_reasons: string;
27 quiet: number;
28 peak_usd: number;
29 }
30
31 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 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 };
37 }
38
39 function saveClog(db: Database, state: ClogState | null): void {
40 if (!state) {
41 db.query("DELETE FROM clog_state WHERE id = 1").run();
42 return;
43 }
44 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);
48 }
49
50 /** Pulls opened and landed over the last 2 h, forge-wide, as the clog thresholds read them. */
51 function twoHours(db: Database, now: Date): { opened: number; landed: number } {
52 const from = new Date(now.getTime() - 2 * HOUR_MS).toISOString();
53 const to = now.toISOString();
54 const row = db
55 .query<{ opened: number; landed: number }, [string, string]>(
56 `SELECT
57 (SELECT COUNT(*) FROM pull p JOIN issue i ON i.id = p.issue_id
58 WHERE i.created_at >= ?1 AND i.created_at <= ?2) AS opened,
59 (SELECT COUNT(*) FROM pull p JOIN repo r ON r.id = (SELECT repo_id FROM issue WHERE id = p.issue_id)
60 WHERE p.base_branch = r.default_branch AND p.merged_at >= ?1 AND p.merged_at <= ?2) AS landed`,
61 )
62 .get(from, to);
63 return { opened: row?.opened ?? 0, landed: row?.landed ?? 0 };
64 }
65
outdated

The replace(/\s*\n+\s*-?\s*/g, " · ") flattening is duplicated in src/feed/items.ts. Export one helper (next to clogNote) and use it in both. The 24 h cutoff here is also not applied on the web feed, so the two doors disagree about how long the note lives.

66 export interface ClogTick {
67 readonly event: ClogEvent | null;
outdated

The comment says first line only, but flattenNote returns the whole note on one line. Fix the comment.

68 }
69
70 /** The newest cleared clog's note within a day, on one line: the terminal's door to what the feed shows. */
71 export function lastClearedNote(db: Database, now = new Date()): string | undefined {
72 const [e] = listEvents(db, { kind: "clog.cleared", limit: 1 });
73 const note = e?.payload["note"];
74 if (typeof note !== "string" || now.getTime() - Date.parse(e!.createdAt) > CLOG_NOTE_WINDOW_MS) return undefined;
75 return flattenNote(note);
76 }
77
78 /** One tick. With no own repository there is nobody to tell, and the watch still steps. */
79 export function tickClog(db: Database, bus: Pick<EventBus, "append">, now = new Date()): ClogTick {
outdated

loadClog runs outside the transaction that saves and posts. If two processes tick while a rollout overlaps, both can read the same prev and both insert a clog_post, so the note is posted twice. Do the read, the step and the write in one db.transaction so the clear stays once-only.

80 const vitals = readVitals(db, now);
81 const { opened, landed } = twoHours(db, now);
82 const oldest = readOldestApproved(db, now);
83 const reading = clogOf({
outdated

While a clog stands, saveClog upserts an unchanged row every minute. Each upsert moves total_changes(), and with it the forge-wide write clock, so every cached page is invalidated once a minute for as long as the clog lasts. Write only when step.state differs from prev.

84 conflicted: vitals.conflicted,
85 opened,
86 landed,
87 oldestApprovedHours: oldest ? Math.floor(oldest.hours) : 0,
88 }).reasons;
89 const [owner, name] = ownRepoPath().split("/");
90 const repo = owner && name ? getRepo(db, owner, name) : undefined;
91 // Read, step and write in one transaction: two overlapping processes cannot both clear.
92 return db.transaction((): ClogTick => {
93 const prev = loadClog(db);
94 const step = clogStep(prev, reading, now.getTime());
95 const usd = readStranded(db).usd;
96 // The peak is the most seen while the clog stood: by the clear the pile is gone.
97 const state = step.state && { ...step.state, peakUsd: Math.max(prev?.peakUsd ?? 0, usd) };
98 if (JSON.stringify(state) !== JSON.stringify(prev)) saveClog(db, state);
outdated

detectedAt equals startedAt here, so the note always reports "Time to detect: 0 min". Omit that line until detection is measured.

99 if (step.event === "cleared" && !repo) log.warn("clog cleared but the own repository is missing; no note posted");
100 if (step.event === "cleared" && repo && prev) {
101 const note = clogNote({
102 reasons: prev.firstReasons,
103 startedAt: prev.since,
104 detectedAt: prev.since,
105 clearedAt: now.getTime(),
106 strandedUsd: Math.max(prev.peakUsd ?? 0, usd),
107 });
108 bus.append({ kind: "clog.cleared", repoId: repo.id, payload: { note, since: prev.since } });
109 }
110 if (step.event === "started" && repo) {
111 bus.append({ kind: "clog.started", repoId: repo.id, payload: { since: now.getTime() } });
112 }
113 return { event: step.event };
114 }).immediate();
115 }
src/flow/clog_watch.ts +4 −1
@@ -14,6 +14,8 @@ export interface ClogState {
1414 readonly firstReasons: readonly string[];
1515 /** Quiet readings in a row so far; absent when zero. */
1616 readonly quiet?: number;
17 /** The most stranded dollars seen while it stood; kept by the tick, not by the step. */
18 readonly peakUsd?: number;
1719 }
1820
1921 export type ClogEvent = "started" | "cleared";
@@ -30,7 +32,8 @@ export function clogStep(prev: ClogState | null, reading: readonly string[], now
3032 if (reading.length === 0) return { state: null, event: null };
3133 return { state: { since: now, firstReasons: [...reading] }, event: "started" };
3234 }
33 if (reading.length > 0) return { state: { since: prev.since, firstReasons: prev.firstReasons }, event: null };
35 if (reading.length > 0)
36 return { state: { since: prev.since, firstReasons: prev.firstReasons, ...(prev.peakUsd === undefined ? {} : { peakUsd: prev.peakUsd }) }, event: null };
3437 const quiet = (prev.quiet ?? 0) + 1;
3538 if (quiet >= QUIET_TO_CLEAR) return { state: null, event: "cleared", since: prev.since };
3639 return { state: { ...prev, quiet }, event: null };
src/main.ts +11
@@ -54,6 +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";
5758 import { sweepStaleAsks } from "./crew/stale_asks.ts";
5859 import { recordAskLedger } from "./web/studio/asks.ts";
5960 import { startPromptRoutine } from "./crew/prompts.ts";
@@ -550,6 +551,15 @@ const processTick = (): void => {
550551 app.log.warn("process census failed", { error: String(error) });
551552 }
552553 };
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);
553563 const processTimer = setInterval(processTick, 60_000);
554564 processTick();
555565 // A new version proves itself against the public URL once (release/prove.ts).
@@ -668,6 +678,7 @@ function drain(signal: NodeJS.Signals): Promise<unknown> {
668678 clearInterval(staleTimer);
669679 clearInterval(followTimer);
670680 clearInterval(processTimer);
681 clearInterval(clogTimer);
671682 clearInterval(leaseTimer);
672683 // The platform's SIGTERM: the listener stays open while routers learn
673684 // this process is going. A person's Ctrl-C does not wait for routers.
src/tui/screens/dashboard.ts +3
@@ -107,6 +107,7 @@ import { listPinned } from "../../actions/board-pins.ts";
107107 import { migrateScreen } from "./migrate.ts";
108108 import { newRepoScreen } from "./newrepo.ts";
109109 import { firedAlarms } from "../../flow/alarms.ts";
110 import { lastClearedNote } from "../../flow/clog_tick.ts";
110111 import { forgeCeiling } from "../../flow/ceiling.ts";
111112 import { alarmIdleCrew } from "../../flow/alarm_idle_crew.ts";
112113 import { noReleaseAlarm } from "../../flow/alarm_no_release.ts";
@@ -302,6 +303,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
302303 lane = forgeLane(f.app, f.user);
303304 decider = deciderLine(f.app, f.user);
304305 const commit = running().commit;
306 const cleared = lastClearedNote(f.db, now);

Nit: lastClearedNote runs for every viewer, but only an admin's figures use it. It can move inside the admin branch to skip one query per render for everyone else. This is not a leak, because the result is dropped for non-admins.

305307 figures =
306308 f.user?.isAdmin !== true
307309 ? []
@@ -309,6 +311,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
309311 ...kpiLines(readKpis(f.db, now, kpiForge(f.app)), commit === null ? null : { sha: commit, since: ago(BOOTED) }),
310312 vitalsLine(readVitals(f.db, now)),
311313 ...firedAlarms(f.db, now, { ceilingUsd: forgeCeiling(f.app.config) }).map((a) => a.says),
outdated

This line is the terminal door to the clear note and nothing renders it in a test. Add a dashboard test: admin sees the note, non-admin sees nothing. [x].flatMap(...) is also an indirect way to write a conditional spread; bind the note to a const and spread note === undefined ? [] : [note].

314 ...(cleared === undefined ? [] : [cleared]),
312315 ];
313316 // "Yours" is ownership plus the orgs you are on a team of; typing in the
314317 // filter reaches every repository you can see.
test/flow-clog-note.test.ts +6
@@ -31,3 +31,9 @@ test("a negative span reads as zero", () => {
3131 test("no reasons still reads", () => {
3232 expect(clogNote({ ...base, reasons: [] })).toContain("Cause: not recorded.");
3333 });
34
35 test("a detection that was not measured prints no time to detect", () => {
36 const note = clogNote({ ...base, detectedAt: base.startedAt });
37 expect(note).not.toContain("Time to detect");
38 expect(note).toContain("- Time to clear: 70 min");
39 });
test/flow-clog-tick.test.ts added +163
@@ -0,0 +1,163 @@
1 /** The minute's clog tick (#6524): start notifies once, clear posts once, a restart repeats neither. */
2
3 import { afterAll, beforeAll, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { addToCrew, createAgent } from "../src/actions/crew.ts";
9 import { openPull } from "../src/actions/pulls.ts";
10 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
11 import { createUser, getUserByName, type User } from "../src/core/users.ts";
12 import { setNotifyUrl, startNotifier, type Notifier } from "../src/crew/notify.ts";
13 import { candidatesFor } from "../src/feed/items.ts";
14 import { lastClearedNote, loadClog, tickClog } from "../src/flow/clog_tick.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16
17 quietLogs();
18
19 const MIN = 60_000;
20 let dataDir = "";
21 let app: App;
22 let alice: User;
23 let was: string | undefined;
24 const posts: string[] = [];
25 const notifiers: Notifier[] = [];
26
27 const boot = async (): Promise<Notifier> => {
28 const n = startNotifier(app, {
29 forgeUrl: "https://forge.example.test",
30 drainingSince: () => null,
31 send: async (_u, body) => {
32 posts.push(body);
33 return true;
34 },
35 });
36 notifiers.push(n);
37 await n.settled();
38 return n;
39 };
40
41 beforeAll(async () => {
42 was = process.env.FORGE_SENTINEL_REPO;
43 process.env.FORGE_SENTINEL_REPO = "alice/forge";
44 dataDir = await mkdtemp(join(tmpdir(), "forge-clog-tick-"));
45 app = createApp({ dataDir, port: 0 });
46 const a = await createUser(app.db, {
47 origin: "seed",
48 trusted: true,
49 username: "alice",
50 email: "[email protected]",
51 password: "a-long-clog-tick-passphrase",
52 adminIfFirst: true,
53 });
54 if (!a.ok) throw new Error(a.error);
55 alice = a.value;
56 setNotifyUrl(app.db, alice.id, "https://ntfy.example.test/root-topic");
57 await createAgent(app, alice, { username: "bot" });
58 const made = await createRepo(app, alice, { name: "forge", visibility: "public" });
59 if (!made.ok) throw new Error(String(made.error));
60 const repo = made.value;
61 await commitToBranch(app, alice, repo, { message: "seed", files: [{ path: "README.md", content: "# f\n" }] });
62 addToCrew(app, alice, repo, "bot");
63 // Ten pulls opened and none landed over 2 h: a clog by the landing ratio.
64 for (let i = 0; i < 10; i++) {
65 await commitToBranch(app, alice, repo, {
66 branch: `bot/${i}`,
67 base: "main",
68 message: `p${i}`,
69 files: [{ path: `F${i}.md`, content: `${i}\n` }],
70 });
71 const opened = await openPull(app, getUserByName(app.db, "bot")!, repo, {
72 title: `p${i}`,
73 body: "",
74 baseBranch: "main",
75 headBranch: `bot/${i}`,
76 });
77 if (!opened.ok) throw new Error(String(opened.error));
78 }
79 }, 60_000);
80
81 afterAll(async () => {
82 for (const n of notifiers) await n.close();
83 if (was === undefined) delete process.env.FORGE_SENTINEL_REPO;
84 else process.env.FORGE_SENTINEL_REPO = was;
85 await rm(dataDir, { recursive: true, force: true });
86 });
87
88 test("across ticks, start notifies once and clear posts once, through a restart", async () => {
89 const t0 = new Date();
90 const first = await boot();
91 expect(tickClog(app.db, app.bus, t0).event).toBe("started");
92 await first.settled();
93 expect(posts).toHaveLength(1);
94 expect(posts[0]).toContain("Clogged:");
95
96 // Still clogged: no second event, no second notification.
97 expect(tickClog(app.db, app.bus, new Date(t0.getTime() + MIN)).event).toBeNull();
98 await first.settled();
99 expect(posts).toHaveLength(1);
100
101 // A restart: the watch state is the database's, and the new notifier says nothing.
102 await first.close();
103 expect(loadClog(app.db)?.since).toBe(t0.getTime());
104 const second = await boot();
105 expect(tickClog(app.db, app.bus, new Date(t0.getTime() + 2 * MIN)).event).toBeNull();
106 await second.settled();
107 expect(posts).toHaveLength(1);
108
109 // The pile leaves the 2 h window: two quiet ticks clear it, once.
110 const later = t0.getTime() + 3 * 60 * MIN;
111 expect(tickClog(app.db, app.bus, new Date(later)).event).toBeNull();
112 expect(tickClog(app.db, app.bus, new Date(later + MIN)).event).toBe("cleared");
113 expect(tickClog(app.db, app.bus, new Date(later + 2 * MIN)).event).toBeNull();
114 expect(app.db.query("SELECT COUNT(*) AS n FROM event WHERE kind = 'clog.cleared'").get()).toEqual({ n: 1 });
115 expect(loadClog(app.db)).toBeNull();
116 await second.settled();
117 expect(posts).toHaveLength(1);
118
119 // The note is on the admin's feed, once.
120 const mine = candidatesFor(app.db, alice, later).filter((c) => c.what.startsWith("Clog cleared"));
121 expect(mine).toHaveLength(1);
122 expect(mine[0]!.what).toContain("Clog cleared. Cause:");
123 expect(mine[0]!.what).not.toContain("Time to detect");
124 // The terminal's home reads the same note from the same event.
125 expect(lastClearedNote(app.db, new Date(later))).toContain("Clog cleared. Cause:");
126 expect(lastClearedNote(app.db, new Date(later + 25 * 60 * MIN))).toBeUndefined();
127 // Both doors drop the note after the same window.
128 const dayOn = Date.now() + 25 * 60 * MIN;
129 expect(candidatesFor(app.db, alice, dayOn).filter((c) => c.what.startsWith("Clog cleared"))).toHaveLength(0);
130 const bob = await createUser(app.db, {
131 origin: "seed",
132 trusted: true,
133 username: "bob",
134 email: "[email protected]",
135 password: "a-long-clog-tick-passphrase",
136 });
137 if (!bob.ok) throw new Error(bob.error);
138 expect(candidatesFor(app.db, bob.value, later).filter((c) => c.what.startsWith("Clog cleared"))).toHaveLength(0);
139 }, 30_000);
140
141 test("the note reports the peak stranded dollars, not the figure at the clear", () => {
142 const far = Date.now() + 20 * 60 * MIN;
143 // A clog that stood with 50 dollars stranded; by now nothing is stranded.
144 app.db.query("INSERT INTO clog_state (id, since, first_reasons, quiet, peak_usd) VALUES (1, ?1, ?2, 0, 50)").run(far - 30 * MIN, "[\"x\"]");
145 expect(tickClog(app.db, app.bus, new Date(far)).event).toBeNull();
146 expect(loadClog(app.db)?.peakUsd).toBe(50);
147 expect(tickClog(app.db, app.bus, new Date(far + MIN)).event).toBe("cleared");
148 expect(lastClearedNote(app.db, new Date(far + MIN))).toContain("Stranded at peak: $50");
149 });
150
151 test("a clear with no own repository still clears the watch and posts no note", () => {
152 const was = process.env.FORGE_SENTINEL_REPO;
153 process.env.FORGE_SENTINEL_REPO = "alice/nowhere";
154 try {
155 const far = Date.now() + 10 * 60 * 60 * MIN;
156 app.db.query("INSERT INTO clog_state (id, since, first_reasons, quiet) VALUES (1, ?1, ?2, 1)").run(far - MIN, "[\"x\"]");
157 expect(tickClog(app.db, app.bus, new Date(far)).event).toBe("cleared");
158 expect(loadClog(app.db)).toBeNull();
159 expect(app.db.query("SELECT COUNT(*) AS n FROM event WHERE kind = 'clog.cleared'").get()).toEqual({ n: 2 });
160 } finally {
161 process.env.FORGE_SENTINEL_REPO = was;
162 }
163 });
test/tui-dashboard-alarms.test.ts +9
@@ -77,6 +77,15 @@ test("a stuck approved pull draws the stuck line for an admin; a non-admin reads
7777 expect(await firstScreen(bob)).toContain("alice/open#1 has sat approved");
7878 });
7979
80 test("a cleared clog's note is on the admin's home line and nobody else's", async () => {
81 forge.db.run(
82 "INSERT INTO event (id, kind, repo_id, actor_id, payload, created_at) VALUES ('clog1', 'clog.cleared', NULL, NULL, ?1, ?2)",
83 [JSON.stringify({ note: "Clog cleared. Cause: the pile.\n\n- Time to clear: 3 min\n" }), new Date().toISOString()],
84 );
85 expect(await firstScreen(alice)).toContain("Clog cleared. Cause: the pile.");
86 expect(await firstScreen(bob)).not.toContain("Clog cleared");
87 });
88
8089 test("the dashboard's since-you-were-here shows a release and a failed gate", async () => {
8190 const db = forge.db;
8291 const now = new Date().toISOString();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs