Public: anyone can read this; the crew can change it.
/flow says how many crew runs declined in the last day, and why #10880
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10862-flow-says-how-many-crew-runs-declined-in → main 4 commits fork 2 threads · 2 unresolved
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 @@ | ||
| 8 | 8 | "src/flow/approved_wait.ts", |
| 9 | 9 | "src/flow/clog_words.ts", |
| 10 | 10 | "src/flow/conflict_pairs.ts", |
| 11 | "src/flow/declines.ts", | |
| 12 | 11 | "src/flow/handovers.ts", |
| 13 | 12 | "src/flow/landing_eta.ts", |
| 14 | 13 | "src/flow/rebase_first.ts", |
src/flow/declines.ts +59 −5
| @@ -8,6 +8,8 @@ | ||
| 8 | 8 | * A run ending exactly 24 h ago is in; one in the future is out. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import type { Database } from "bun:sqlite"; | |
| 12 | ||
| 11 | 13 | const DAY_MS = 86_400_000; |
| 12 | 14 | const SHOWN = 3; |
| 13 | 15 | |
| @@ -28,7 +30,7 @@ export interface DeclineGroup { | ||
| 28 | 30 | |
| 29 | 31 | export type Declines = Readonly<Record<DeclineReason, DeclineGroup>>; |
| 30 | 32 | |
| 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"]; | |
| 32 | 34 | |
| 33 | 35 | export function declineReason(summary: string): DeclineReason | null { |
| 34 | 36 | const s = summary.trimStart(); |
| @@ -51,14 +53,14 @@ export function declines(rows: readonly DeclineRow[], now: Date): Declines { | ||
| 51 | 53 | found[reason].push(row.issue); |
| 52 | 54 | } |
| 53 | 55 | const out = {} as Record<DeclineReason, DeclineGroup>; |
| 54 | for (const r of REASONS) { | |
| 56 | for (const r of DECLINE_REASONS) { | |
| 55 | 57 | const issues = [...new Set(found[r])]; |
| 56 | 58 | out[r] = { count: found[r].length, distinct: issues.length, issues: issues.slice(0, SHOWN) }; |
| 57 | 59 | } |
| 58 | 60 | return out; |
| 59 | 61 | } |
| 60 | 62 | |
| 61 | const LABELS: Readonly<Record<DeclineReason, string>> = { | |
| 63 | export const DECLINE_LABELS: Readonly<Record<DeclineReason, string>> = { | |
| 62 | 64 | done_on_main: "already on main", |
| 63 | 65 | not_on_main: "not on main yet", |
| 64 | 66 | off_direction: "off direction", |
| @@ -67,10 +69,62 @@ const LABELS: Readonly<Record<DeclineReason, string>> = { | ||
| 67 | 69 | |
| 68 | 70 | /** One line, empty when nothing was declined: `Declined in 24 h: 3 already on main (#1, #2), 1 other (#9)`. */ |
| 69 | 71 | 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) => { | |
| 71 | 73 | const g = result[r]; |
| 72 | 74 | 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})`; | |
| 74 | 76 | }); |
| 75 | 77 | return parts.length ? `Declined in 24 h: ${parts.join(", ")}` : ""; |
| 76 | 78 | } |
| 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"; | ||
| 17 | 17 | import { readLeadTime, type LeadTime } from "./lead_time.ts"; |
| 18 | 18 | import { readBuildYield, type BuildYield } from "./build_yield.ts"; |
| 19 | 19 | import { readReviewLatency, type ReviewLatency } from "./review_latency.ts"; |
| 20 | import { readDeclines, type DeclineReading } from "./declines.ts"; | |
| 20 | 21 | import { readOpeners, type Opener } from "./openers.ts"; |
| 21 | 22 | |
| 22 | 23 | export const PANEL_TTL_MS = 30_000; |
| @@ -36,6 +37,7 @@ export interface PanelFigures { | ||
| 36 | 37 | readonly yielded: BuildYield; |
| 37 | 38 | readonly latency: ReviewLatency | null; |
| 38 | 39 | readonly openers: Opener[]; |
| 40 | readonly declined: DeclineReading; | |
| 39 | 41 | } |
| 40 | 42 | |
| 41 | 43 | /** Every reader the panel's figures come from, run once. */ |
| @@ -53,6 +55,7 @@ export function readPanelFigures(db: Database, now: Date, publicOnly: boolean): | ||
| 53 | 55 | yielded: readBuildYield(db, now, publicOnly), |
| 54 | 56 | latency: readReviewLatency(db, now, publicOnly), |
| 55 | 57 | openers: readOpeners(db, now, publicOnly), |
| 58 | declined: readDeclines(db, now, publicOnly), | |
| 56 | 59 | }; |
| 57 | 60 | } |
| 58 | 61 |
src/http/mcp/tools/flow.ts +1 −1
| @@ -27,7 +27,7 @@ const said = (subject: string, why: Why) => | ||
| 27 | 27 | |
| 28 | 28 | export const tool: Tool = { |
| 29 | 29 | 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", | |
| 31 | 31 | description: |
| 32 | 32 | "Pipeline (admin); `q`: `/fleet` rows, `action` plans an op, `apply` does it; `kind:bugs`; `repo`: why stuck.", |
| 33 | 33 | inputSchema: { |
src/system/telemetry.ts +3
| @@ -16,6 +16,7 @@ import { readTalkTurns, talkTurnsWords } from "../flow/talk_turns.ts"; | ||
| 16 | 16 | import { readTalkResumes, talkResumesWords } from "../flow/talk_resumes.ts"; |
| 17 | 17 | import { readVitals, vitalsFor, vitalsLine, vitalsMisses } from "../flow/vitals.ts"; |
| 18 | 18 | import { parkedSays, readParked } from "../flow/parked.ts"; |
| 19 | import { readDeclines } from "../flow/declines.ts"; | |
| 19 | 20 | import { readKeeper } from "../flow/keeper.ts"; |
| 20 | 21 | import { readPipeline, readStepStats, statWords, verdict } from "../flow/pipeline.ts"; |
| 21 | 22 | import { type QueueReading, readQueues } from "../flow/queue.ts"; |
| @@ -113,6 +114,8 @@ function flowBody(x: { | ||
| 113 | 114 | redBatches: x.redBatches, |
| 114 | 115 | // Findings reviewers repeated, proposed as checks and retired, beside review comments per pull (#8716). |
| 115 | 116 | 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"), | |
| 116 | 119 | // The four alarms (#5930) that are firing now; empty when none is. |
| 117 | 120 | alarms: firedAlarms(app.db, now, { publicOnly: reader.kind === "agent" }), |
| 118 | 121 | // 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( | ||
| 110 | 110 | ...wrap(leadTimeSays(fig.lead), inner), |
| 111 | 111 | ...wrap(buildYieldSays(fig.yielded), inner), |
| 112 | 112 | ...wrap(reviewLatencySays(fig.latency), inner), |
| 113 | ...(fig.declined.says === "" ? [] : wrap(fig.declined.says, inner)), | |
| 113 | 114 | ...openerLines(fig.openers, inner), |
| 114 | 115 | "", |
| 115 | 116 | `${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"; | ||
| 40 | 40 | import { panelFigures } from "../../flow/panel_cache.ts"; |
| 41 | 41 | import { hoursWords } from "../../flow/lead_time.ts"; |
| 42 | 42 | import { buildYieldSays } from "../../flow/build_yield.ts"; |
| 43 | import { DECLINE_LABELS, DECLINE_REASONS } from "../../flow/declines.ts"; | |
| 43 | 44 | import { reviewLatencySays } from "../../flow/review_latency.ts"; |
| 44 | 45 | import { openerCounts } from "../../flow/openers.ts"; |
| 45 | 46 | import { readRepeats, type Repeats } from "../../flow/repeats.ts"; |
| @@ -157,6 +158,13 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm | ||
| 157 | 158 | : html`median <span data-median-hours>${hoursWords(lead.medianHours)}</span>, slowest <span data-max-hours>${hoursWords(lead.maxHours ?? 0)}</span>`} |
| 158 | 159 | </p> |
| 159 | 160 | <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( | |
| 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>`} | |
| 160 | 168 | <p id="review-latency" class="muted">${reviewLatencySays(latency)}</p> |
| 161 | 169 | <p id="openers" class="muted small"> |
| 162 | 170 | 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 | }); |
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.