Public: anyone can read this; the crew can change it.
Throughput: Nothing is ready: no pillar has an issue a builder can start. #11952
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11951-throughput-nothing-is-ready-no-pillar-ha → main 1 commit fork 2 threads · 2 unresolved
3 files
changed +22
−1
docs/features/keeper.md +2
| @@ -9,6 +9,8 @@ The forge watches its own landing rate, names the one thing binding it, and acts | ||
| 9 | 9 | |
| 10 | 10 | Where it shows: one line on home and on the boards panel, `keeper` in the MCP `flow` report, the terminal's Flow dialog (the line, the question, and `l` for its one button after a confirm) and its crew scorecard ("Forge per day" rows), and "Forge per day" (hours running, landed) on the crew scorecard and in `scorecard` (`keeperDays`). Every attempt is a row in `keeper_attempt`. |
| 11 | 11 | |
| 12 | An `empty_pillar` issue lists the ready issues and the hold on each (`landed`, `open_pull`, `stalled`...), so a person sees why nothing can start. | |
| 13 | ||
| 12 | 14 | Not done: DORA change-fail rate, revert rate, queue-wait p95 and rework per agent from the issue discussion are not measured yet. |
| 13 | 15 | |
| 14 | 16 | Who reads what: a site admin reads the sentence with repository names and the question with its button. Everyone else, guests and agents included, reads the cause with no repository in it (`readKeeper(db, full)`). The Flow only switch is retired (#7771), so no act asks a question today; one already on the books is answered with its button, and the answer is a `keeper.answered` event carrying the owner's id. |
src/flow/keeper.ts +6 −1
| @@ -17,6 +17,7 @@ import { createLog } from "../kernel/log.ts"; | ||
| 17 | 17 | import { ownRepoPath } from "../kernel/own_repo.ts"; |
| 18 | 18 | import { KEEPER_CAUSES, pickCause, readKeeperFacts, type KeeperCause, type KeeperMove } from "./keeper_cause.ts"; |
| 19 | 19 | import { isLow, landedBetween, readRate, type Rate } from "./keeper_rate.ts"; |
| 20 | import { HOLDS, heldNow, heldWords } from "../crew/held.ts"; | |
| 20 | 21 | import { oldestWaitingOn } from "./pile.ts"; |
| 21 | 22 | import { during } from "../perf/jobs.ts"; |
| 22 | 23 | |
| @@ -119,11 +120,15 @@ export function fileOnce(app: App, found: { cause: KeeperCause; says: string }, | ||
| 119 | 120 | if (!repo) return log.warn("keeper cannot file: the forge's own repository is missing", { path: ownRepoPath() }); |
| 120 | 121 | const oldest = found.cause === "stale_pile" ? oldestWaitingOn(app.db, 10, repo.visibility !== "private") : []; |
| 121 | 122 | const list = oldest.length === 0 ? "" : `\n\nOldest waiting, start here:\n${oldest.map((s) => `- ${s.ref} (waits on ${s.on})`).join("\n")}`; |
| 123 | const held = found.cause === "empty_pillar" ? heldNow(app, repo) : undefined; | |
| 124 | const heldList = held | |
| 125 | ? `\n\nReady issues and what holds them (${heldWords(held)}):\n${HOLDS.flatMap((h) => held.by[h].slice(0, 5).map((i) => `- #${i.number} (${h})`)).slice(0, 10).join("\n")}` | |
| 126 | : ""; | |
| 122 | 127 | const made = createIssue(app.db, { |
| 123 | 128 | repoId: repo.id, |
| 124 | 129 | authorId: repo.ownerId, |
| 125 | 130 | title: `Throughput: ${found.says}`.slice(0, 200), |
| 126 | body: `The throughput keeper found this binding landings (${rate.recent.toFixed(1)} an hour against a best of ${rate.best.toFixed(1)}) and cannot act on it itself.\n\n${found.says}${list}\n\nFiles: docs/features/keeper.md`, | |
| 131 | body: `The throughput keeper found this binding landings (${rate.recent.toFixed(1)} an hour against a best of ${rate.best.toFixed(1)}) and cannot act on it itself.\n\n${found.says}${list}${heldList}\n\nFiles: docs/features/keeper.md`, | |
| 127 | 132 | }); |
| 128 | 133 | if (!made.ok) return; |
| 129 | 134 | app.bus.append({ kind: "issue.opened", repoId: repo.id, payload: { number: made.value.number } }); |
test/flow-pile-oldest.test.ts +14
| @@ -115,3 +115,17 @@ test("a changes_requested after an approval reads as a builder", () => { | ||
| 115 | 115 | app.db.query(`INSERT INTO review (id, issue_id, author_id, state, body, created_at) VALUES ('rv4', ?1, ?2, 'changes_requested', '', '2026-11-05T00:00:00.000Z')`).run(issue, agent); |
| 116 | 116 | expect(oldestWaitingOn(app.db, 10, false).find((w) => w.ref === "trevato/open#4")?.on).toBe("a builder"); |
| 117 | 117 | }); |
| 118 | ||
| 119 | test("the filed empty_pillar issue says what holds the ready issues", () => { | |
| 120 | const was = process.env.FORGE_SENTINEL_REPO; | |
| 121 | process.env.FORGE_SENTINEL_REPO = "trevato/open"; | |
| 122 | try { | |
| 123 | const rate = { lastHour: 0, recent: 0.5, best: 3, ranShare: 1 }; | |
| 124 | fileOnce(app, { cause: "empty_pillar", says: "Nothing is ready." }, rate, Date.now()); | |
| 125 | const b = app.db.query<{ body: string }, []>("SELECT body FROM issue WHERE title LIKE 'Throughput:%' ORDER BY created_at DESC, rowid DESC LIMIT 1").get()!.body; | |
| 126 | expect(b).toContain("Ready issues and what holds them ("); | |
| 127 | } finally { | |
| 128 | if (was === undefined) delete process.env.FORGE_SENTINEL_REPO; | |
Also assert a held issue line (e.g. |
||
| 129 | else process.env.FORGE_SENTINEL_REPO = was; | |
| 130 | } | |
| 131 | }); |
new above
offline
Cap is applied after flatMap in HOLDS order: if the early holds have 10+ issues, later holds vanish from the list. Round-robin, or add "and N more".