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

/flow says how many crew runs declined in the last day, and why #10880

Merged opened this

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

Merged

scout/forge:scout/10862-flow-says-how-many-crew-runs-declined-in → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/10862-flow-says-how-many-crew-runs-declined-in was merged into main as c7221e0.

Review

Sign in to review this change.
9 files changed +169 −7
docs/features/flow-declines.md added +15
@@ -0,0 +1,15 @@
1 # Flow: declined runs
2
3 When a crew run reads its issue and declines it (its summary starts `Declined:`), that is a planning mistake worth counting. The Flow views say how many runs declined in the last 24 hours, and why, in one line:
4
5 `Declined in 24 h: 3 already on main (#12, #15, #20), 1 other (#9)`
6
7 Reasons: *already on main*, *not on main yet*, *off direction*, *other*. Each shows its count and up to three issue numbers (`, …` when more). With no declines the line is absent.
8
9 ## Where to read it
10
11 - **Page:** `/boards`, the muted line under the build yield. Each issue number links to the declining run.
12 - **Terminal:** the Flow command (palette), the same line under the review latency.
13 - **Agents:** the `flow` MCP tool with no arguments returns `declines` (`says`, `groups`, `runs`).
14
15 One reader serves all three (`readDeclines` in `src/flow/declines.ts`). A site admin sees every repository; an agent or guest reader sees public repositories only. The figure is cached with the rest of the panel for 30 seconds.
scripts/unreached-allow.json −1
@@ -8,7 +8,6 @@
88 "src/flow/approved_wait.ts",
99 "src/flow/clog_words.ts",
1010 "src/flow/conflict_pairs.ts",
11 "src/flow/declines.ts",
1211 "src/flow/handovers.ts",
1312 "src/flow/landing_eta.ts",
1413 "src/flow/rebase_first.ts",
src/flow/declines.ts +59 −5
@@ -8,6 +8,8 @@
88 * A run ending exactly 24 h ago is in; one in the future is out.
99 */
1010
11 import type { Database } from "bun:sqlite";
12
1113 const DAY_MS = 86_400_000;
1214 const SHOWN = 3;
1315
@@ -28,7 +30,7 @@ export interface DeclineGroup {
2830
2931 export type Declines = Readonly<Record<DeclineReason, DeclineGroup>>;
3032
31 const REASONS: readonly DeclineReason[] = ["done_on_main", "not_on_main", "off_direction", "other"];
33 export const DECLINE_REASONS: readonly DeclineReason[] = ["done_on_main", "not_on_main", "off_direction", "other"];
3234
3335 export function declineReason(summary: string): DeclineReason | null {
3436 const s = summary.trimStart();
@@ -51,14 +53,14 @@ export function declines(rows: readonly DeclineRow[], now: Date): Declines {
5153 found[reason].push(row.issue);
5254 }
5355 const out = {} as Record<DeclineReason, DeclineGroup>;
54 for (const r of REASONS) {
56 for (const r of DECLINE_REASONS) {
5557 const issues = [...new Set(found[r])];
5658 out[r] = { count: found[r].length, distinct: issues.length, issues: issues.slice(0, SHOWN) };
5759 }
5860 return out;
5961 }
6062
61 const LABELS: Readonly<Record<DeclineReason, string>> = {
63 export const DECLINE_LABELS: Readonly<Record<DeclineReason, string>> = {
6264 done_on_main: "already on main",
6365 not_on_main: "not on main yet",
6466 off_direction: "off direction",
@@ -67,10 +69,62 @@ const LABELS: Readonly<Record<DeclineReason, string>> = {
6769
6870 /** One line, empty when nothing was declined: `Declined in 24 h: 3 already on main (#1, #2), 1 other (#9)`. */
6971 export function declinesSays(result: Declines): string {
70 const parts = REASONS.filter((r) => result[r].count > 0).map((r) => {
72 const parts = DECLINE_REASONS.filter((r) => result[r].count > 0).map((r) => {
7173 const g = result[r];
7274 const more = g.distinct > g.issues.length ? ", …" : "";
73 return `${g.count} ${LABELS[r]} (${g.issues.map((n) => `#${n}`).join(", ")}${more})`;
75 return `${g.count} ${DECLINE_LABELS[r]} (${g.issues.map((n) => `#${n}`).join(", ")}${more})`;
7476 });
7577 return parts.length ? `Declined in 24 h: ${parts.join(", ")}` : "";
7678 }
79
80 /** A declined run, named so a page can link to it. */
81 export interface DeclineRun {
82 readonly repo: string;
83 readonly runId: string;
84 readonly issue: number;
85 }
86
87 export interface DeclineReading {
88 readonly says: string;
89 readonly groups: Declines;
90 /** Per reason, the newest run of each of the first few issues, for the links. */
91 readonly runs: Readonly<Record<DeclineReason, readonly DeclineRun[]>>;
92 }
93
94 interface RunRow {
95 id: string;
96 owner: string;
97 name: string;
98 number: number;
99 summary: string;
100 finished_at: string;
101 }
102
103 /**
104 * Declined build runs of the last 24 h, newest first. Same `publicOnly` rule as
105 * `readVitals`: public repositories alone. One read.
106 */
107 export function readDeclines(db: Database, now = new Date(), publicOnly = false): DeclineReading {
108 const to = now.toISOString();
109 const from = new Date(now.getTime() - DAY_MS).toISOString();
110 const rows = db
111 .query<RunRow, [string, string, number]>(
112 `SELECT x.id, ro.username AS owner, r.name, i.number, x.summary, x.finished_at
113 FROM run x JOIN repo r ON r.id = x.repo_id JOIN user ro ON ro.id = r.owner_id
114 JOIN issue i ON i.id = x.issue_id
115 WHERE x.kind = 'agent' AND x.reason != 'review' AND x.summary LIKE '%eclined%'
116 AND x.finished_at >= ?1 AND x.finished_at <= ?2 AND (?3 = 0 OR r.visibility = 'public')
117 ORDER BY x.finished_at DESC`,
118 )
119 .all(from, to, publicOnly ? 1 : 0);
120 const groups = declines(rows.map((r) => ({ issue: r.number, endedAt: r.finished_at, summary: r.summary })), now);
121 const runs: Record<DeclineReason, DeclineRun[]> = { done_on_main: [], not_on_main: [], off_direction: [], other: [] };
122 for (const r of rows) {
123 const reason = declineReason(r.summary);
124 if (!reason) continue;
125 const list = runs[reason];
126 if (list.length < SHOWN && !list.some((x) => x.issue === r.number))
127 list.push({ repo: `${r.owner}/${r.name}`, runId: r.id, issue: r.number });
128 }
129 return { says: declinesSays(groups), groups, runs };
130 }
src/flow/panel_cache.ts +3
@@ -17,6 +17,7 @@ import { readParked, type Parked } from "./parked.ts";
1717 import { readLeadTime, type LeadTime } from "./lead_time.ts";
1818 import { readBuildYield, type BuildYield } from "./build_yield.ts";
1919 import { readReviewLatency, type ReviewLatency } from "./review_latency.ts";
20 import { readDeclines, type DeclineReading } from "./declines.ts";
2021 import { readOpeners, type Opener } from "./openers.ts";
2122
2223 export const PANEL_TTL_MS = 30_000;
@@ -36,6 +37,7 @@ export interface PanelFigures {
3637 readonly yielded: BuildYield;
3738 readonly latency: ReviewLatency | null;
3839 readonly openers: Opener[];
40 readonly declined: DeclineReading;
3941 }
4042
4143 /** Every reader the panel's figures come from, run once. */
@@ -53,6 +55,7 @@ export function readPanelFigures(db: Database, now: Date, publicOnly: boolean):
5355 yielded: readBuildYield(db, now, publicOnly),
5456 latency: readReviewLatency(db, now, publicOnly),
5557 openers: readOpeners(db, now, publicOnly),
58 declined: readDeclines(db, now, publicOnly),
5659 };
5760 }
5861
src/http/mcp/tools/flow.ts +1 −1
@@ -27,7 +27,7 @@ const said = (subject: string, why: Why) =>
2727
2828 export const tool: Tool = {
2929 name: "flow",
30 doc: "with no arguments, the forge's pipeline (a forge admin's, or an agent the owner granted `telemetry:read`; private repositories cut out): per stage counts, `oldestWaiting`, `capacity`, the bottleneck, the merge `queue` and what each reviewer has `reviewing` (the `ui://forge/flow` card); with `{ q, sort? }`, the rows a fleet query line selects: `{ kind, columns, items }`, only rows you may read, `sort` a column key led by `-` to reverse, a bad line answers its reason; with `{ q, action: { verb, value }, apply? }`, a fleet operation over those rows as `POST /api/v1/fleet/ops` answers it: the dry-run plan `{ dryRun: true, query, kind, verb, value, reversible, rows, counts }` and nothing changed, or with `apply: true` (an admin token, a person) the receipt `{ dryRun: false, id, rows, … }` whose id `limb` undoes; with `{ kind: bugs }`, `{ open, overdue, bugs: [{ repo, number, priority, dueAt }] }` soonest due first, at most 100 (an admin sees all, anyone else public repositories); with `{ repo, issue? or pull? }` (a repository writer), why it is not moving: `{ subject, moving, state?, cause, says, clears, next? }`, the first of wall (a person decides access), ceiling, hot_file, blocked_by, later_gate, no_builder, no_headroom, red_check, waiting_review that holds it (`flow/why.ts`); `repo` alone asks about the repository's builders",
30 doc: "with no arguments, the forge's pipeline (a forge admin's, or an agent the owner granted `telemetry:read`; private repositories cut out): per stage counts, `oldestWaiting`, `capacity`, the bottleneck, the merge `queue` and what each reviewer has `reviewing`, and `declines` (crew runs declined in 24 h, by reason) (the `ui://forge/flow` card); with `{ q, sort? }`, the rows a fleet query line selects: `{ kind, columns, items }`, only rows you may read, `sort` a column key led by `-` to reverse, a bad line answers its reason; with `{ q, action: { verb, value }, apply? }`, a fleet operation over those rows as `POST /api/v1/fleet/ops` answers it: the dry-run plan `{ dryRun: true, query, kind, verb, value, reversible, rows, counts }` and nothing changed, or with `apply: true` (an admin token, a person) the receipt `{ dryRun: false, id, rows, … }` whose id `limb` undoes; with `{ kind: bugs }`, `{ open, overdue, bugs: [{ repo, number, priority, dueAt }] }` soonest due first, at most 100 (an admin sees all, anyone else public repositories); with `{ repo, issue? or pull? }` (a repository writer), why it is not moving: `{ subject, moving, state?, cause, says, clears, next? }`, the first of wall (a person decides access), ceiling, hot_file, blocked_by, later_gate, no_builder, no_headroom, red_check, waiting_review that holds it (`flow/why.ts`); `repo` alone asks about the repository's builders",
3131 description:
3232 "Pipeline (admin); `q`: `/fleet` rows, `action` plans an op, `apply` does it; `kind:bugs`; `repo`: why stuck.",
3333 inputSchema: {
src/system/telemetry.ts +3
@@ -16,6 +16,7 @@ import { readTalkTurns, talkTurnsWords } from "../flow/talk_turns.ts";
1616 import { readTalkResumes, talkResumesWords } from "../flow/talk_resumes.ts";
1717 import { readVitals, vitalsFor, vitalsLine, vitalsMisses } from "../flow/vitals.ts";
1818 import { parkedSays, readParked } from "../flow/parked.ts";
19 import { readDeclines } from "../flow/declines.ts";
1920 import { readKeeper } from "../flow/keeper.ts";
2021 import { readPipeline, readStepStats, statWords, verdict } from "../flow/pipeline.ts";
2122 import { type QueueReading, readQueues } from "../flow/queue.ts";
@@ -113,6 +114,8 @@ function flowBody(x: {
113114 redBatches: x.redBatches,
114115 // Findings reviewers repeated, proposed as checks and retired, beside review comments per pull (#8716).
115116 reviewRepeats: x.reviewRepeats,
117 // Declined build runs of the last 24 h by reason, each with the runs behind it.
118 declines: readDeclines(app.db, now, reader.kind === "agent"),
116119 // The four alarms (#5930) that are firing now; empty when none is.
117120 alarms: firedAlarms(app.db, now, { publicOnly: reader.kind === "agent" }),
118121 // Review runs in flight per reviewer, cut to public repositories for an agent (#4733).
src/tui/screens/flow.ts +1
@@ -110,6 +110,7 @@ export function flowLines(
110110 ...wrap(leadTimeSays(fig.lead), inner),
111111 ...wrap(buildYieldSays(fig.yielded), inner),
112112 ...wrap(reviewLatencySays(fig.latency), inner),
113 ...(fig.declined.says === "" ? [] : wrap(fig.declined.says, inner)),
113114 ...openerLines(fig.openers, inner),
114115 "",
115116 `${pad("Out", 12)} ${pad("last hour", 9, "right")} ${pad("24 h", 5, "right")}`,
src/web/pages/flow-panel.ts +8
@@ -40,6 +40,7 @@ import { IDLE_LIMIT, type IdlePull } from "../../flow/idle_pulls.ts";
4040 import { panelFigures } from "../../flow/panel_cache.ts";
4141 import { hoursWords } from "../../flow/lead_time.ts";
4242 import { buildYieldSays } from "../../flow/build_yield.ts";
43 import { DECLINE_LABELS, DECLINE_REASONS } from "../../flow/declines.ts";
4344 import { reviewLatencySays } from "../../flow/review_latency.ts";
4445 import { openerCounts } from "../../flow/openers.ts";
4546 import { readRepeats, type Repeats } from "../../flow/repeats.ts";
@@ -157,6 +158,13 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm
157158 : html`median <span data-median-hours>${hoursWords(lead.medianHours)}</span>, slowest <span data-max-hours>${hoursWords(lead.maxHours ?? 0)}</span>`}
158159 </p>
159160 <p id="build-yield" class="muted">${buildYieldSays(yielded)}</p>
161 ${fig.declined.says === ""
162 ? ""
163 : html`<p id="declines" class="muted">Declined in 24 h: ${DECLINE_REASONS.filter((r) => fig.declined.groups[r].count > 0).map(

Principle 1/8: one plain sentence is right; the same figures must also appear in the terminal Flow screen (both doors). Add it in src/tui/ and test it.

164 (r, i) => html`${i === 0 ? "" : ", "}<span data-reason="${r}">${String(fig.declined.groups[r].count)} ${DECLINE_LABELS[r]}</span> (${fig.declined.runs[r].map(
165 (x, j) => html`${j === 0 ? "" : ", "}<a href="/${x.repo}/runs/${x.runId}">#${String(x.issue)}</a>`,
166 )}${fig.declined.groups[r].distinct > fig.declined.runs[r].length ? ", …" : ""})`,
167 )}</p>`}
160168 <p id="review-latency" class="muted">${reviewLatencySays(latency)}</p>
161169 <p id="openers" class="muted small">
162170 opened in the last hour:
test/flow-declines-reach.test.ts added +79
@@ -0,0 +1,79 @@
1 /** Declined runs reach /flow's panel and the flow tool's answer (#10862). */

Add a test for the terminal screen showing the declines line, gated like the page (agent/guest sees public only).

2 import { afterEach, beforeEach, expect, test } from "bun:test";
3 import { mkdtempSync, rmSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createUser } from "../src/core/users.ts";
8 import { id } from "../src/kernel/id.ts";
9 import { flowFor } from "../src/system/telemetry.ts";
10 import { flowPanel } from "../src/web/pages/flow-panel.ts";
11
12 let dir: string;
13 let app: App;
14 let owner: string;
15 const ago = (min: number) => new Date(Date.now() - min * 60_000).toISOString();
16
17 function seed(name: string, vis: string, n: number, summary: string, min: number): string {
18 const rid = id();
19 const iid = id();
20 const run = id();
21 app.db.query(`INSERT INTO repo (id, owner_id, name, visibility) VALUES (?1, ?2, ?3, ?4)`).run(rid, owner, name, vis);
22 app.db.query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id) VALUES (?1, ?2, ?3, 'issue', 't', '', ?4)`).run(iid, rid, n, owner);
23 app.db
24 .query(`INSERT INTO run (id, repo_id, kind, issue_id, state, reason, summary, started_at, finished_at) VALUES (?1, ?2, 'agent', ?3, 'ok', '', ?4, ?5, ?6)`)
25 .run(run, rid, iid, summary, ago(min + 5), ago(min));
26 return run;
27 }
28
29 beforeEach(async () => {
30 dir = mkdtempSync(join(tmpdir(), "forge-declines-"));
31 app = createApp({ dataDir: dir, port: 0 });
32 const u = await createUser(app.db, { username: "alice", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true });
33 if (!u.ok) throw new Error("seed");
34 owner = u.value.id;
35 });
36 afterEach(() => {
37 app.db.close();
38 rmSync(dir, { recursive: true, force: true });
39 });
40
41 const draw = (kind: "owner" | "agent") => String(flowPanel(app, { kind }, new URL("http://x/boards"), "/boards"));
42
43 test("nothing shows when nothing was declined", () => {
44 seed("pub", "public", 1, "Shipped it", 10);
45 expect(draw("owner")).not.toContain('id="declines"');
46 expect(flowFor(app, { kind: "owner" }).declines.says).toBe("");
47 });
48
49 test("the panel and the flow answer count declined runs by reason, linked to the runs", () => {
50 const a = seed("pub", "public", 1, "Declined: already done on main in abc", 10);
51 seed("pub2", "public", 1, "Declined: task unclear", 20);
52 seed("priv", "private", 1, "Declined: already done on main", 30);
53 seed("old", "public", 2, "Declined: already done on main", 60 * 25);
54 const page = draw("owner");
55 expect(page).toContain('id="declines"');
56 expect(page).toContain("2 already on main");
57 expect(page).toContain("1 other");
58 expect(page).toContain(`href="/alice/pub/runs/${a}"`);
59 const body = flowFor(app, { kind: "owner" }).declines;
60 expect(body.groups.done_on_main.count).toBe(2);
61 expect(body.groups.other.count).toBe(1);
62 expect(body.runs.done_on_main.map((r) => r.runId)).toContain(a);
63 // An agent reader gets public repositories alone.
64 expect(flowFor(app, { kind: "agent" }).declines.groups.done_on_main.count).toBe(1);
65 expect(draw("agent")).not.toContain("alice/priv");
66 });
67
68 test("the terminal Flow screen shows the declines line, public only for an agent", async () => {
69 const { flowLines } = await import("../src/tui/screens/flow.ts");
70 expect(flowLines(app.db, new Date(), [], undefined, []).join("\n")).not.toContain("Declined in 24 h");
71 seed("pub", "public", 1, "Declined: already done on main", 10);
72 seed("priv", "private", 1, "Declined: task unclear", 20);
73 const admin = flowLines(app.db, new Date(Date.now() + 1), [], undefined, []).join("\n");
74 expect(admin).toContain("Declined in 24 h: 1 already on main");
75 expect(admin).toContain("1 other");
76 const agent = flowLines(app.db, new Date(Date.now() + 2), [], { queues: [] }, []).join("\n");
77 expect(agent).toContain("1 already on main");
78 expect(agent).not.toContain("1 other");
79 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs