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

A red pull never sits: the forge reworks or closes it, and it stops holding its issue #8246

Merged opened this in answer to

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

Merged

scout/forge:scout/8245-a-red-pull-never-sits-the-forge-reworks → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/8245-a-red-pull-never-sits-the-forge-reworks was merged into main as dae9cf5.

Review

Sign in to review this change.
11 files changed +613 −101
docs/FLOW.md +6
@@ -117,3 +117,9 @@ toward no file's heat and is named on the feed (`too wide to count: #4840
117117 ## Flow reads, in one page
118118
119119 `bun scripts/flow-report.ts [--db <path>] [--public] [--json]` (database read-only, default `forge.db` under `FORGE_DATA`; `--json` for raw values) prints one line per read of `src/flow/report.ts`: `build_yield` (agent runs finished in 24 h: how many pushed, landed, closed), `review_latency` (median hours from opening to first verdict), `outsiders` (open pulls by non-writers and non-crew, oldest in hours), `checks_pile` (open pulls red or waiting for a check slot), `gate_minutes` (check-slot minutes in 1 h and 6 h), `oldest_approved` (the longest-waiting approved pull), `idle_pulls` (crew pulls untouched for 72 h), `lead_time` (opening to landing, 24 h) and `openers` (who opened the last hour's pulls). The script reads everything; `--public` cuts it to public repositories.
120
121 ## Red pulls that sit (#8245)
122
123 `src/crew/stale_reds.ts` `sweepStaleReds(app, repo, now, room)` runs each autonomy tick and reads only the database, so a restart or roll loses nothing. An open crew pull whose latest check on its head failed, with no run in 30 minutes and no person holding or assigned to it, is re-checked (reason `sweep`); still red, its builder is queued with the failing test named, within the free slots and 3 a tick. Past its tries it is parked and its issue is ready again. Red for over 24 h and conflicting with main, or with its issue closed, it is closed with one line. The flow report's `stale_reds` line (`src/flow/stale_reds.ts`) counts the ones not yet handled, and the keeper names `stale_reds` as a cause from 3.
124
125 How a person sees stale reds: `bun scripts/flow-report.ts` prints the `stale_reds:` line (count and oldest hours), and the keeper names `stale_reds` as the cause when they are what holds the flow; the terminal Flow dialog draws the keeper's line (`keeperLine`), so it shows there when the keeper picks that cause. It has no line of its own on the dialog.
docs/features/keeper.md +2 −2
@@ -3,8 +3,8 @@
33 The forge watches its own landing rate, names the one thing binding it, and acts where the act is reversible and its own. `src/flow/keeper.ts` ticks once a minute. It reads the database and asks no model, so it still speaks when the subscription is out.
44
55 1. **Measure** (`keeper_rate.ts`): pulls landed per hour over the last 3 h against the best 3 h rate of the week before, and the share of the last 24 hours that ran (a landing or a builder run started). Low is under half the best and under one an hour.
6 2. **Find** (`keeper_cause.ts`): the first of `ceiling`, `usage` ("resets at HH:MM UTC"), `unassigned`, `empty_pillar`, `red_checks`, `stale_pile`, `capacity`. It asks the dispatcher's own questions (`heldNow`, `atCeiling`, `pace`).
7 3. **Act**: `unassigned` sweeps assigned issues with no run. `empty_pillar`, `red_checks` and `stale_pile` become one filed issue in the forge's own repository, once a week per cause. `ceiling`, `usage` and `capacity` are only said.
6 2. **Find** (`keeper_cause.ts`): the first of `ceiling`, `usage` ("resets at HH:MM UTC"), `unassigned`, `empty_pillar`, `stale_reds`, `red_checks`, `stale_pile`, `capacity`. It asks the dispatcher's own questions (`heldNow`, `atCeiling`, `pace`).
7 3. **Act**: `unassigned` sweeps assigned issues with no run. `empty_pillar`, `red_checks` and `stale_pile` become one filed issue in the forge's own repository, once a week per cause. `ceiling`, `usage`, `capacity` and `stale_reds` (the crew's red-pull sweep is already at it) are only said.
88 4. **Check** after 2 h: landings rose and the act stands, or it is undone, the cause is left alone for 6 h and the owner is asked one question with one button (`POST /flow/keeper/answer`).
99
1010 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`.
src/crew/autonomy.ts +11 −95
@@ -14,7 +14,6 @@
1414
1515 import { onlyListDocs, resolveListConflicts } from "../actions/list-conflicts.ts";
1616 import { closedToday, closeStale } from "./stale_pulls.ts";
17 import { authorsInRepoPull } from "./authors_pull.ts";
1817 import type { Database } from "bun:sqlite";
1918 import type { App } from "../app.ts";
2019 import { ceilingReached, forgeCeiling } from "../flow/ceiling.ts";
@@ -116,13 +115,8 @@ import { ALREADY_NOTE, alreadyDoneOids } from "./already.ts";
116115 import { removedBy, retiredNames } from "./retired.ts";
117116 import { interruptionOf } from "./interrupted.ts";
118117 import {
119 PARK_NOTE,
120 PARK_STRIKES,
121 PLANNER_HINT,
122118 parkedCount as countParks,
123119 parkedTwice,
124 parkReason,
125 parkReasons,
126120 } from "./parks.ts";
127121 import { reserveOf, reserveWords, short } from "./reserve.ts";
128122 import { createLog } from "../kernel/log.ts";
@@ -135,6 +129,8 @@ import {
135129 import { crewOffInPreview } from "./preview.ts";
136130 import { approvedOnHead, finishApproved, finishFields } from "./finishing.ts";
137131 import { recheckRedApproved } from "./red_approved.ts";
132 import { sweepStaleReds } from "./stale_reds.ts";
133 import { parkPull, strikeNote } from "./park_pull.ts";
138134 import { keepBatch } from "../flow/red_batches.ts";
139135 import { recordQueueFlakes } from "../checks/flake.ts";
140136 import { readVitals, vitalsMisses, vitalsWords } from "../flow/vitals.ts";
@@ -145,6 +141,8 @@ import { bootCounts } from "../kernel/boots.ts";
145141
146142 const log = createLog("autonomy");
147143
144 export { parkPull };
145
148146 export const AUTONOMY_TICK_MS = 60_000;
149147 /**
150148 * How soon a planner whose last pass did not end well — a decline a person
@@ -938,39 +936,6 @@ export function parkedCount(app: App, repo: Repo, issueId: string): number {
938936 return countParks(app.db, repo, issueId);
939937 }
940938
941 /** What happens to an issue the crew hands over, said once on the issue. */
942 const HANDED_OVER =
943 "The planner splits it, replaces it with a fresh issue, or defers it; a person can also say what to try. Nothing starts here until then, and the board lists it.";
944
945 /**
946 * What a park says on its issue. A first strike is the reason — and, when a
947 * pull closed with it, a word to the planner. The strike that takes the
948 * issue out of the crew's hands also says every reason since someone who
949 * directs the crew last spoke, and whose it is now: an issue parked twice
950 * is handed over, never dropped.
951 */
952 function strikeNote(
953 db: Database,
954 repo: Repo,
955 issue: Issue,
956 reason: string,
957 hint = "",
958 ): string {
959 const note = `${PARK_NOTE}${reason}`;
960 const reasons = [...parkReasons(db, repo, issue.id), parkReason(note)];
961 if (reasons.length < PARK_STRIKES)
962 return hint === "" ? note : `${note} ${hint}`;
963 return [
964 note,
965 "",
966 `Parked ${reasons.length === 2 ? "twice" : `${reasons.length} times`} since a person last spoke here, so it is the planner's now. Every reason:`,
967 "",
968 ...reasons.map((r, n) => `${n + 1}. ${r}`),
969 "",
970 HANDED_OVER,
971 ].join("\n");
972 }
973
974939 /**
975940 * Park an issue: a note with the reason and the assignee cleared, so the
976941 * planner rewrites it on its next pass and the crew tries it fresh once.
@@ -1117,62 +1082,6 @@ function answeredWithoutChange(
11171082 );
11181083 }
11191084
1120 /**
1121 * Park a pull the crew cannot finish: close it with the reason, put its
1122 * issues back on the map unassigned with the same reason, and move on. A
1123 * repository on its own never waits for a person; a parked issue is the
1124 * planner's to split or drop on its next pass, and one parked twice is
1125 * handed to the planner with every reason (`strikeNote`). A pull a person
1126 * holds is not parked: closing it would hand its issue back to the crew, to
1127 * build the same change again beside the one that was told to wait. A
1128 * person's pull from a branch of the repository itself is not parked
1129 * either: closing it is its author's, and closed it would stop following
1130 * the pushes that answer its review (#7572).
1131 */
1132 export function parkPull(
1133 app: App,
1134 repo: Repo,
1135 pull: Pull,
1136 why: string,
1137 ): boolean {
1138 if (repo.autonomy !== "autonomous" || repo.autonomyBy === null) return false;
1139 if (getHold(app.db, pull.id) !== undefined) return false;
1140 if (authorsInRepoPull(pull)) return false;
1141 const by = getUserById(app.db, repo.autonomyBy);
1142 if (!by) return false;
1143 const issue = getIssueById(app.db, pull.id);
1144 if (!issue || issue.state !== "open") return false;
1145 addComment(app, by, repo, issue, `${PARK_NOTE}${why}`);
1146 const closed = setIssueState(app, by, repo, issue, "closed", {
1147 reason: "parked",
1148 });
1149 if (!closed.ok) return false;
1150 for (const number of closesFromBody(pull.body)) {
1151 const about = getIssue(app.db, repo.id, number);
1152 if (!about || about.state !== "open") continue;
1153 if (about.assigneeId !== null) assignIssue(app, by, repo, about, null);
1154 addComment(
1155 app,
1156 by,
1157 repo,
1158 about,
1159 strikeNote(
1160 app.db,
1161 repo,
1162 about,
1163 `${why} (pull #${pull.number} closed).`,
1164 PLANNER_HINT,
1165 ),
1166 );
1167 }
1168 log.info("parked", {
1169 repo: `${repo.ownerName}/${repo.name}`,
1170 pull: pull.number,
1171 why,
1172 });
1173 return true;
1174 }
1175
11761085 /**
11771086 * Green pulls that have not landed — a check that passed before autonomy
11781087 * was turned on, or one the event missed — land on the tick, so nothing
@@ -1330,6 +1239,13 @@ export async function autonomyTick(
13301239 }),
13311240 );
13321241 const current = getRepoById(app.db, repo.id) ?? repo;
1242 // A red pull nobody is working is checked again, reworked, parked or closed (#8245).
1243 await sweepStaleReds(app, current, now, freeSlots(app, current, now)).catch((error) => {
1244 log.error("stale red sweep failed", {
1245 repo: `${repo.ownerName}/${repo.name}`,
1246 error: String(error),
1247 });
1248 });
13331249 // Before the picks: a pull a reviewer answered is finished before new
13341250 // work takes the slots it needs (#2172).
13351251 const requested = await moveRequested(app, current, freeSlots(app, current, now), now);
src/crew/park_pull.ts added +114
@@ -0,0 +1,114 @@
1 /**
2 * Parking a pull the crew cannot finish, and the note a park leaves. Its own
3 * module so `autonomy.ts` and the stale-red sweep both reach it without
4 * importing each other.
5 */
6
7 import type { Database } from "bun:sqlite";
8 import type { App } from "../app.ts";
9 import { addComment, assignIssue, setIssueState } from "../actions/issues.ts";
10 import { getHold } from "../core/holds.ts";
11 import { getIssue, getIssueById, type Issue } from "../core/issues.ts";
12 import { closesFromBody, type Pull } from "../core/pulls.ts";
13 import type { Repo } from "../core/repos.ts";
14 import { getUserById } from "../core/users.ts";
15 import { createLog } from "../kernel/log.ts";
16 import { authorsInRepoPull } from "./authors_pull.ts";
17 import {
18 PARK_NOTE,
19 PARK_STRIKES,
20 PLANNER_HINT,
21 parkReason,
22 parkReasons,
23 } from "./parks.ts";
24
25 const log = createLog("autonomy");
26
27 /** What happens to an issue the crew hands over, said once on the issue. */
28 const HANDED_OVER =
29 "The planner splits it, replaces it with a fresh issue, or defers it; a person can also say what to try. Nothing starts here until then, and the board lists it.";
30
31 /**
32 * What a park says on its issue. A first strike is the reason — and, when a
33 * pull closed with it, a word to the planner. The strike that takes the
34 * issue out of the crew's hands also says every reason since someone who
35 * directs the crew last spoke, and whose it is now: an issue parked twice
36 * is handed over, never dropped.
37 */
38 export function strikeNote(
39 db: Database,
40 repo: Repo,
41 issue: Issue,
42 reason: string,
43 hint = "",
44 ): string {
45 const note = `${PARK_NOTE}${reason}`;
46 const reasons = [...parkReasons(db, repo, issue.id), parkReason(note)];
47 if (reasons.length < PARK_STRIKES)
48 return hint === "" ? note : `${note} ${hint}`;
49 return [
50 note,
51 "",
52 `Parked ${reasons.length === 2 ? "twice" : `${reasons.length} times`} since a person last spoke here, so it is the planner's now. Every reason:`,
53 "",
54 ...reasons.map((r, n) => `${n + 1}. ${r}`),
55 "",
56 HANDED_OVER,
57 ].join("\n");
58 }
59
60 /**
61 * Park a pull the crew cannot finish: close it with the reason, put its
62 * issues back on the map unassigned with the same reason, and move on. A
63 * repository on its own never waits for a person; a parked issue is the
64 * planner's to split or drop on its next pass, and one parked twice is
65 * handed to the planner with every reason (`strikeNote`). A pull a person
66 * holds is not parked: closing it would hand its issue back to the crew, to
67 * build the same change again beside the one that was told to wait. A
68 * person's pull from a branch of the repository itself is not parked
69 * either: closing it is its author's, and closed it would stop following
70 * the pushes that answer its review (#7572).
71 */
72 export function parkPull(
73 app: App,
74 repo: Repo,
75 pull: Pull,
76 why: string,
77 ): boolean {
78 if (repo.autonomy !== "autonomous" || repo.autonomyBy === null) return false;
79 if (getHold(app.db, pull.id) !== undefined) return false;
80 if (authorsInRepoPull(pull)) return false;
81 const by = getUserById(app.db, repo.autonomyBy);
82 if (!by) return false;
83 const issue = getIssueById(app.db, pull.id);
84 if (!issue || issue.state !== "open") return false;
85 addComment(app, by, repo, issue, `${PARK_NOTE}${why}`);
86 const closed = setIssueState(app, by, repo, issue, "closed", {
87 reason: "parked",
88 });
89 if (!closed.ok) return false;
90 for (const number of closesFromBody(pull.body)) {
91 const about = getIssue(app.db, repo.id, number);
92 if (!about || about.state !== "open") continue;
93 if (about.assigneeId !== null) assignIssue(app, by, repo, about, null);
94 addComment(
95 app,
96 by,
97 repo,
98 about,
99 strikeNote(
100 app.db,
101 repo,
102 about,
103 `${why} (pull #${pull.number} closed).`,
104 PLANNER_HINT,
105 ),
106 );
107 }
108 log.info("parked", {
109 repo: `${repo.ownerName}/${repo.name}`,
110 pull: pull.number,
111 why,
112 });
113 return true;
114 }
src/crew/stale_reds.ts added +195
@@ -0,0 +1,195 @@
1 /**
2 * A red pull never sits (#8245). Reworking a red is event-driven
3 * (`dispatch.ts`): a missed event, a restart or a roll leaves it red and
4 * assigned to a builder with no run, holding its issue. This sweep reads the
5 * database each tick, so it needs no memory of what it did:
6 *
7 * - an open crew pull whose latest check on its head failed, with no run of
8 * its own in the last {@link QUIET_MS} and no person holding it, is checked
9 * again (reason `sweep`); still red, its builder is queued with the failing
10 * test named, within the free slots and {@link PER_TICK} a tick;
11 * - past its tries it is parked (`parkPull`): closed with the reason, its
12 * issue unassigned and ready again;
13 * - red for over {@link STALE_MS} and conflicting with main, or with no
14 * issue left open to close, it is closed with one line saying why.
15 *
16 * Left alone: a pull a person holds or is assigned, a person's pull from a
17 * branch of the repository, one approved on its head (`red_approved.ts`),
18 * a red main shares (`dispatch.ts` waits those out) and a machine failure.
19 */
20
21 import type { App } from "../app.ts";
22 import { addComment, assignIssue, setIssueState } from "../actions/issues.ts";
23 import { checksFor } from "../checks/service.ts";
24 import { redMain, sharedWithMain } from "../checks/red_main.ts";
25 import { getHold } from "../core/holds.ts";
26 import { getIssue, getIssueById } from "../core/issues.ts";
27 import { closesFromBody, listOpenPulls, pullDir, type Pull } from "../core/pulls.ts";
28 import type { Repo } from "../core/repos.ts";
29 import { automaticRunsSince, getRun, INFRA_SUMMARY, type Run } from "../core/runs.ts";
30 import { getUserById } from "../core/users.ts";
31 import { QUIET_MS, SWEEP_REASON } from "../flow/stale_reds.ts";
32 import { mergeable, resolveOid } from "../git/merge.ts";
33 import { createLog } from "../kernel/log.ts";
34 import { authorsInRepoPull } from "./authors_pull.ts";
35 import { agentFor, CHECK_FAILED_LIMIT, lastHumanWord, queueAgentRun } from "./dispatch.ts";
36 import { approvedOnHead } from "./finishing.ts";

Circular import with autonomy.ts (it imports this file). Extract parkPull to its own module.

37 import { parkPull } from "./park_pull.ts";
38 import { failingLine } from "./red_approved.ts";
39
40 const log = createLog("stale-reds");
41
42 /** How long a pull may stay red before a conflict or a closed issue closes it. */
43 export const STALE_MS = 24 * 3_600_000;
44
45 /** Pulls the sweep re-checks or hands to a builder in one tick: paced, not a burst. */
46 export const PER_TICK = 3;
47
48 /** What the sweep did with one pull. */
49 export interface StaleMove {
50 readonly pull: number;
51 readonly did: "re-check queued" | "builder queued" | "parked" | "closed";
52 }
53
54 /** The line a closed stale red carries. */
55 export const closedLine = (why: string): string =>
56 `Closed: red for over 24 h and ${why}; the pull cannot land as it is.`;
57
58 interface Row {
59 id: string;
60 kind: string;
61 state: string;
62 reason: string;
63 created_at: string;
64 finished_at: string | null;
65 commit_oid: string;
66 }
67
68 /**
69 * The red check this head sits on: its latest non-sweep check failed (not a
70 * machine failure), and nothing but the sweep has run on the pull since
71 * `cutoff`. Undefined for a pull that is not sitting.
72 */
73 function sittingRed(app: App, pull: Pull, now: Date): { run: Run; at: string } | undefined {
74 const cutoff = new Date(now.getTime() - QUIET_MS).toISOString();
75 const rows = app.db
76 .query<Row, [string]>(
77 `SELECT id, kind, state, reason, created_at, finished_at, commit_oid FROM run
78 WHERE issue_id = ?1 AND kind IN ('agent','check','deploy') AND reason != '${SWEEP_REASON}'
79 ORDER BY id DESC LIMIT 20`,
80 )
81 .all(pull.id);
82 for (const r of rows)
83 if (r.state === "queued" || r.state === "running" || (r.finished_at ?? r.created_at) > cutoff)
84 return undefined;
85 const last = rows.find((r) => r.kind === "check" && r.commit_oid === pull.headOid);
86 if (last?.state !== "failed" || last.finished_at === null) return undefined;
87 const run = getRun(app.db, last.id);
88 if (run === undefined || run.summary.startsWith(INFRA_SUMMARY)) return undefined;
89 return { run, at: last.finished_at };
90 }
91
92 /** The sweep's own re-check of this head, newest, if one was queued. */
93 function sweepRun(app: App, pull: Pull): Run | undefined {
outdated

Redundant wrapper around readRun; import and call it directly.

94 const row = app.db
95 .query<{ id: string }, [string, string]>(
96 `SELECT id FROM run WHERE kind = 'check' AND issue_id = ?1 AND commit_oid = ?2
97 AND reason = '${SWEEP_REASON}' ORDER BY id DESC LIMIT 1`,
98 )
99 .get(pull.id, pull.headOid);
100 return row === null ? undefined : getRun(app.db, row.id);
101 }
102
103 /** Close a stale red with one line; the issue it was for goes back to ready, unassigned. */
104 function closeRed(app: App, repo: Repo, pull: Pull, why: string): boolean {
105 const by = repo.autonomyBy === null ? undefined : getUserById(app.db, repo.autonomyBy);
106 const issue = getIssueById(app.db, pull.id);
107 if (!by || !issue || issue.state !== "open") return false;
108 addComment(app, by, repo, issue, closedLine(why));
109 if (!setIssueState(app, by, repo, issue, "closed", { reason: "parked", stale: true }).ok) return false;
110 for (const n of closesFromBody(pull.body)) {
111 const about = getIssue(app.db, repo.id, n);
112 if (about?.state === "open" && about.assigneeId !== null) assignIssue(app, by, repo, about, null);
113 }
114 log.info("stale red closed", { repo: `${repo.ownerName}/${repo.name}`, pull: pull.number, why });
115 return true;
116 }
117
118 /**
119 * One pass over a repository's open pulls. `room` is the free builder slots;
120 * the re-checks and rework runs together stay within it and {@link PER_TICK}.
121 */
122 export async function sweepStaleReds(
123 app: App,
124 repo: Repo,
125 now: Date,
126 room: number,
127 ): Promise<StaleMove[]> {
128 const moves: StaleMove[] = [];
129 if (repo.autonomy !== "autonomous" || repo.autonomyBy === null) return moves;
130 let budget = Math.min(PER_TICK, Math.max(room, 0));
131 const main = redMain(app.db, repo.id);
132 for (const pull of listOpenPulls(app.db, repo.id)) {
133 if (!pull.authorIsAgent || pull.mergedAt !== null || pull.headOid === "") continue;
134 if (getHold(app.db, pull.id) !== undefined || authorsInRepoPull(pull)) continue;
135 const issue = getIssueById(app.db, pull.id);
136 if (!issue || issue.state !== "open") continue;
137 const agent = agentFor(app, issue);
138 if (!agent) continue;
139 if (approvedOnHead(app.db, pull)) continue;
140 const red = sittingRed(app, pull, now);
141 if (red === undefined || sharedWithMain(main, red.run)) continue;
142
143 // Stale: red over a day and it conflicts, or its issue is gone.
144 if (now.getTime() - Date.parse(red.at) >= STALE_MS) {
145 const named = closesFromBody(pull.body);
146 const live = named.some((n) => getIssue(app.db, repo.id, n)?.state === "open");
outdated

The conflict branch is untested. Add a test with a conflicting head and a red older than 24 h.

147 let why: string | undefined =
148 named.length > 0 && !live ? "the issue it was for is no longer open" : undefined;
149 if (why === undefined) {
150 const dir = await pullDir(app.db, app.reposRoot, repo, pull);
151 const tip = await resolveOid(dir, `refs/heads/${pull.baseBranch}`);
152 if (tip !== undefined && !(await mergeable(dir, tip, pull.headOid)).ok)
153 why = "it conflicts with main";
154 }
155 if (why !== undefined && closeRed(app, repo, pull, why)) {
156 moves.push({ pull: pull.number, did: "closed" });
157 continue;
158 }
159 }
160
161 const since = lastHumanWord(app, issue, agent);
162 if (automaticRunsSince(app.db, issue.id, since) >= CHECK_FAILED_LIMIT) {
163 const why = `the check stayed red through ${CHECK_FAILED_LIMIT} automatic tries: ${failingLine(red.run)}`;
164 if (parkPull(app, repo, pull, why)) moves.push({ pull: pull.number, did: "parked" });
165 continue;
166 }
167
168 if (budget <= 0) continue;
169 const again = sweepRun(app, pull);
170 if (again === undefined) {
171 const checks = checksFor(app);
172 if (checks === undefined) continue;
173 const env = `pull-${pull.number}`;
174 const queued = await checks.request(repo, env, pull.headOid, SWEEP_REASON, pull.id);
175 if (queued?.reason !== SWEEP_REASON) continue;
176 budget--;
177 moves.push({ pull: pull.number, did: "re-check queued" });
178 continue;
179 }
180 if (again.state !== "failed" || again.summary.startsWith(INFRA_SUMMARY)) continue;
181 // Said as the builder, not as a person: a person's word restarts the tries.
182 addComment(
183 app,
184 agent.user,
185 repo,
186 issue,
187 `Still red on current main: ${failingLine(again)}. Fix it and push.`,
188 );
189 if (queueAgentRun(app, repo, issue, agent, "check_failed") !== undefined) {
190 budget--;
191 moves.push({ pull: pull.number, did: "builder queued" });
192 }
193 }
194 return moves;
195 }
src/flow/keeper.ts +1
@@ -210,6 +210,7 @@ const GENERIC: Record<KeeperCause, string> = {
210210 usage: "Usage is the limit: no subscription has headroom.",
211211 unassigned: "Ready issues wait and no builder is running.",
212212 empty_pillar: "Nothing is ready: no pillar has an issue a builder can start.",
213 stale_reds: "Open crew pulls are red and no run is working them.",
213214 red_checks: "Open crew pulls have a red check.",
214215 stale_pile: "Open crew pulls are waiting on a builder or a reviewer.",
215216 capacity: "Every builder slot is busy.",
src/flow/keeper_cause.ts +10 −1
@@ -17,12 +17,14 @@ import { atCeiling } from "../crew/dispatch.ts";
1717 import { heldNow } from "../crew/held.ts";
1818 import { pace } from "../crew/pacing.ts";
1919 import { readPile } from "./pile.ts";
20 import { readStaleReds } from "./stale_reds.ts";
2021
2122 export const KEEPER_CAUSES = [
2223 "ceiling",
2324 "usage",
2425 "unassigned",
2526 "empty_pillar",
27 "stale_reds",
2628 "red_checks",
2729 "stale_pile",
2830 "capacity",
@@ -51,6 +53,8 @@ export interface RepoFacts {
5153 export interface KeeperFacts {
5254 readonly repos: readonly RepoFacts[];
5355 readonly red: number;
56 /** Red pulls with no run for 30 min and no person holding them. */
57 readonly staleReds?: number;
5458 readonly stale: number;
5559 }
5660
@@ -65,6 +69,7 @@ export interface Found {
6569 /** The pile that is not moving counts as stale from this many pulls; red from this many. */
6670 export const STALE_PULLS = 20;
6771 export const RED_PULLS = 5;
72 export const STALE_REDS = 3;
6873
6974 const hhmm = (iso: string): string => `${iso.slice(11, 16)} UTC`;
7075
@@ -101,6 +106,10 @@ export function pickCause(f: KeeperFacts, skip: ReadonlySet<KeeperCause> = new S
101106 const found = hit({ cause: "empty_pillar", move: "file", repoId: first.repoId, says: "Nothing is ready: no pillar has an issue a builder can start." });
102107 if (found) return found;
103108 }
109 if (first && (f.staleReds ?? 0) >= STALE_REDS) {
110 const found = hit({ cause: "stale_reds", move: "say", repoId: first.repoId, says: `${f.staleReds} open crew pulls are red and no run is working them: the sweep reworks, parks or closes them a few a tick.` });
111 if (found) return found;
112 }
104113 if (first && f.red >= RED_PULLS) {
105114 const found = hit({ cause: "red_checks", move: "file", repoId: first.repoId, says: `${f.red} open crew pulls have a red check.` });
106115 if (found) return found;
@@ -154,5 +163,5 @@ export function readKeeperFacts(app: App, now: Date): KeeperFacts {
154163 });
155164 }
156165 const pile = readPile(db);
157 return { repos, red: pile.red, stale: pile.awaitingReview + pile.changesAsked + pile.approved };
166 return { repos, red: pile.red, staleReds: readStaleReds(db, now).count, stale: pile.awaitingReview + pile.changesAsked + pile.approved };
158167 }
src/flow/report.ts +4 −1
@@ -1,5 +1,5 @@
11 /**
2 * Every flow read in one page: the nine reads under src/flow/ that nothing
2 * Every flow read in one page: the reads under src/flow/ that nothing
33 * else calls yet, each as one plain line, in a fixed order. `flowReadings`
44 * is the raw values; `flowReportLines` words them. `publicOnly` is the rule every
55 * read keeps: public repositories alone.
@@ -15,6 +15,7 @@ import { readOldestApproved } from "./oldest_approved.ts";
1515 import { readOpeners } from "./openers.ts";
1616 import { readOutsiders } from "./outsiders.ts";
1717 import { readReviewLatency } from "./review_latency.ts";
18 import { readStaleReds } from "./stale_reds.ts";
1819
1920 /** The raw value of each read, keyed by its name, in report order. */
2021 export function flowReadings(db: Database, now = new Date(), publicOnly = false) {
@@ -23,6 +24,7 @@ export function flowReadings(db: Database, now = new Date(), publicOnly = false)
2324 review_latency: readReviewLatency(db, now, publicOnly),
2425 outsiders: readOutsiders(db, now, publicOnly),
2526 checks_pile: readChecksPile(db, publicOnly),
27 stale_reds: readStaleReds(db, now, publicOnly),
2628 gate_minutes: readGateMinutes(db, now, publicOnly),
2729 oldest_approved: readOldestApproved(db, now, publicOnly),
2830 idle_pulls: readIdlePulls(db, now, publicOnly),
@@ -46,6 +48,7 @@ export function flowReportLines(r: FlowReadings): string[] {
4648 `review_latency: ${r.review_latency ? `median ${hours(r.review_latency.median)} over ${r.review_latency.count} pulls in 24 h` : "no first verdict in 24 h"}`,
4749 `outsiders: ${r.outsiders.open} open, oldest ${r.outsiders.oldestHours} h`,
4850 `checks_pile: ${r.checks_pile.red} red, ${r.checks_pile.queued} queued`,
51 `stale_reds: ${r.stale_reds.count === 0 ? "none" : `${r.stale_reds.count} red with no run for 30 min, oldest ${hours(r.stale_reds.oldestHours)}`}`,
4952 `gate_minutes: gates ${g.gate1h} min in 1 h, ${g.gate6h} min in 6 h; pull checks ${g.pull1h} min in 1 h, ${g.pull6h} min in 6 h`,
5053 `oldest_approved: ${o ? `${o.repo}#${o.pull} approved ${hours(o.hours)} ago` : "none waiting"}`,
5154 `idle_pulls: ${r.idle_pulls.length === 0 ? "none" : r.idle_pulls.map((p) => `${p.repo}#${p.pull}${p.issue === null ? "" : ` (closes #${p.issue})`} ${hours(p.hours)}`).join(", ")}`,
src/flow/stale_reds.ts added +47
@@ -0,0 +1,47 @@
1 /**
2 * Open pulls that are red and sit (#8245): the latest check on the current
3 * head failed at least `QUIET_MS` ago, no person holds the pull, and no
4 * agent or check run touched it since. A sweep (`crew/stale_reds.ts`)
5 * reworks, parks or closes these; the report counts the ones it has not yet.
6 * A re-check the sweep itself queued (reason `sweep`) is not a touch.
7 */
8
9 import type { Database } from "bun:sqlite";
10
11 /** How long a red pull must be left alone before it counts as sitting. */
12 export const QUIET_MS = 30 * 60_000;
13
14 /** The reason of the check the sweep queues on a red head. */
15 export const SWEEP_REASON = "sweep";
16
17 export interface StaleReds {
18 readonly count: number;
19 /** Hours the longest-sitting one has been red; null when none is. */
20 readonly oldestHours: number | null;
21 }
22
23 /** `publicOnly` counts public repositories alone, as `readVitals` does. */
24 export function readStaleReds(db: Database, now = new Date(), publicOnly = false): StaleReds {
25 const cutoff = new Date(now.getTime() - QUIET_MS).toISOString();
26 const rows = db
27 .query<{ at: string }, [string, number]>(
28 `SELECT l.finished_at AS at FROM issue i
29 JOIN pull p ON p.issue_id = i.id JOIN repo r ON r.id = i.repo_id
30 JOIN run l ON l.id = (SELECT c.id FROM run c WHERE c.kind = 'check' AND c.issue_id = i.id
31 AND c.commit_oid = p.head_oid AND c.reason != '${SWEEP_REASON}'
32 ORDER BY c.id DESC LIMIT 1)
33 WHERE i.state = 'open' AND p.merged_at IS NULL AND (?2 = 0 OR r.visibility = 'public')
34 AND l.state = 'failed' AND l.finished_at IS NOT NULL AND l.finished_at <= ?1
35 AND NOT EXISTS (SELECT 1 FROM pull_hold h WHERE h.issue_id = i.id AND h.released_at IS NULL)
36 AND NOT EXISTS (SELECT 1 FROM user u WHERE u.id = i.assignee_id AND u.is_agent = 0)
37 AND NOT EXISTS (SELECT 1 FROM run t WHERE t.issue_id = i.id AND t.kind IN ('agent','check','deploy')
38 AND t.reason != '${SWEEP_REASON}'
39 AND (t.state IN ('queued','running') OR COALESCE(t.finished_at, t.created_at) > ?1))`,
40 )
41 .all(cutoff, publicOnly ? 1 : 0);
42 const oldest = rows.reduce<string | null>((o, r) => (o === null || r.at < o ? r.at : o), null);
43 return {
44 count: rows.length,
45 oldestHours: oldest === null ? null : (now.getTime() - Date.parse(oldest)) / 3_600_000,
46 };
47 }
test/flow-report.test.ts +4 −2
@@ -12,7 +12,7 @@ import { flowReadings, flowReportLines } from "../src/flow/report.ts";
1212 const NOW = new Date("2026-10-26T12:00:00.000Z");
1313 const ago = (min: number) => new Date(NOW.getTime() - min * 60_000).toISOString();
1414 const ORDER = [
15 "build_yield", "review_latency", "outsiders", "checks_pile", "gate_minutes",
15 "build_yield", "review_latency", "outsiders", "checks_pile", "stale_reds", "gate_minutes",
1616 "oldest_approved", "idle_pulls", "lead_time", "openers",
1717 ];
1818 const name = (line: string) => line.split(":")[0]!;
@@ -76,7 +76,7 @@ beforeEach(async () => {
7676 review(landed, "changes_requested", 2 * 60);
7777 const outsider = issue(priv, carol, 20); // an outsider's pull, red check, opened in the last hour
7878 pull(outsider, "h3");
79 run(priv, "check", { issue: outsider, oid: "h3", state: "failed", start: 30, end: 10 });
79 run(priv, "check", { issue: outsider, oid: "h3", state: "failed", start: 60, end: 40 });
8080 run(priv, "agent", { state: "ok", start: 180, end: 120, reason: "build" });
8181 });
8282 afterEach(() => {
@@ -102,6 +102,7 @@ describe("flowReportLines", () => {
102102 "review_latency: no first verdict in 24 h",
103103 "outsiders: 0 open, oldest 0 h",
104104 "checks_pile: 0 red, 0 queued",
105 "stale_reds: none",
105106 "gate_minutes: gates 0 min in 1 h, 0 min in 6 h; pull checks 0 min in 1 h, 0 min in 6 h",
106107 "oldest_approved: none waiting",
107108 "idle_pulls: none",
@@ -116,6 +117,7 @@ describe("flowReportLines", () => {
116117 expect(all.review_latency).toContain("over 1 pulls");
117118 expect(all.outsiders).toBe("outsiders: 1 open, oldest 0 h");
118119 expect(all.checks_pile).toBe("checks_pile: 1 red, 0 queued");
120 expect(all.stale_reds).toBe("stale_reds: 1 red with no run for 30 min, oldest 0.7 h");
119121 expect(all.gate_minutes).toContain("pull checks 20 min in 1 h");
120122 expect(all.oldest_approved).toContain("priv#1");
121123 expect(all.idle_pulls).toContain("priv#1");
test/stale_reds.test.ts added +219
@@ -0,0 +1,219 @@
1 /**
2 * A red pull never sits (#8245): the sweep reads the database with `now` given,
3 * so a restart in the middle changes nothing; a person's hold is left alone.
4 */
5 import { afterAll, beforeAll, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { addToCrew, createAgent } from "../src/actions/crew.ts";
11 import { openIssue } from "../src/actions/issues.ts";
12 import { openPull } from "../src/actions/pulls.ts";
13 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
14 import { provideChecksForTest, type ChecksHandle } from "../src/checks/service.ts";
15 import { setHold } from "../src/core/holds.ts";
16 import { getIssueById, listComments } from "../src/core/issues.ts";
17 import { getPull } from "../src/core/pulls.ts";
18 import { getRepoById, type Repo } from "../src/core/repos.ts";
19 import { createUser, getUserByName, type User } from "../src/core/users.ts";
20 import { createRun, listRuns } from "../src/core/runs.ts";
21 import { sweepStaleReds, PER_TICK } from "../src/crew/stale_reds.ts";
22 import { readStaleReds, SWEEP_REASON } from "../src/flow/stale_reds.ts";
23 import { pickCause, STALE_REDS } from "../src/flow/keeper_cause.ts";
24 import { flowReadings, flowReportLines } from "../src/flow/report.ts";
25
26 let dataDir: string;
27 let app: App;
28 let alice: User;
29 let mason: User;
30 let repo: Repo;
31 let n = 0;
32 const requested: string[] = [];
33
34 const T0 = new Date("2026-11-02T12:00:00.000Z");
35 const at = (minutes: number) => new Date(T0.getTime() + minutes * 60_000);
36
37 beforeAll(async () => {
38 dataDir = await mkdtemp(join(tmpdir(), "forge-stale-reds-"));
39 app = createApp({ dataDir, port: 0 });
40 const a = await createUser(app.db, {
41 origin: "seed",
42 trusted: true,
43 username: "alice",
44 email: "[email protected]",
45 password: "a-good-passphrase",
46 adminIfFirst: true,
47 });
48 if (!a.ok) throw new Error(a.error);
49 alice = a.value;
50 const made = await createRepo(app, alice, { name: "engine", visibility: "private" });
51 if (!made.ok) throw new Error(String(made.error));
52 repo = made.value;
53 const seeded = await commitToBranch(app, alice, repo, {
54 message: "Start",
55 files: [{ path: "shared.ts", content: "export const shared = 0;\n" }],
56 });
57 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
58 const agent = await createAgent(app, alice, { username: "mason" });
59 if (!agent.ok) throw new Error(String(agent.error));
60 expect(addToCrew(app, alice, repo, "mason").ok).toBe(true);
61 mason = getUserByName(app.db, "mason")!;
62 app.db
63 .query("UPDATE repo SET autonomy = 'autonomous', autonomy_by = ?2 WHERE id = ?1")
64 .run(repo.id, alice.id);
65 repo = getRepoById(app.db, repo.id)!;
66 const handle = {
67 request: async (r: Repo, env: string, oid: string, reason: string, issueId?: string) => {
68 requested.push(env);
69 return createRun(app.db, { repoId: r.id, kind: "check", env, commitOid: oid, reason, issueId });
70 },
71 } as unknown as ChecksHandle;
72 provideChecksForTest(app, handle);
73 });
74
75 afterAll(async () => {
76 app.db.close();
77 await rm(dataDir, { recursive: true, force: true });
78 });
79
80 /** An open crew pull on its own file, closing a fresh issue assigned to mason. */
81 async function pullBy(who: User, file?: string) {
82 const k = ++n;
83 const ask = openIssue(app, alice, repo, { title: `Ask ${k}` });
84 if (!ask.ok) throw new Error(String(ask.error));
85 app.db.query("UPDATE issue SET assignee_id = ?2 WHERE id = ?1").run(ask.value.id, mason.id);
86 const branch = await commitToBranch(app, alice, repo, {
87 branch: `feature/r${k}`,
88 base: "main",
89 message: `Red ${k}`,
90 files: [{ path: file ?? `r${k}.ts`, content: `export const r = ${k};\n` }],
91 });
92 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
93 const opened = await openPull(app, who, repo, {
94 title: `Red ${k}`,
95 body: `Closes #${ask.value.number}`,
96 baseBranch: "main",
97 headBranch: `feature/r${k}`,
98 });
99 if (!opened.ok) throw new Error(String(opened.error));
100 return { pull: getPull(app.db, repo.id, opened.value.number)!, ask: ask.value };
101 }
102
103 /** A finished run on the pull's head, `minutes` after T0. */
104 function finished(p: { pull: { id: string; headOid: string } }, kind: "check" | "agent", state: string, minutes: number, reason = "", test = "") {
105 const run = createRun(app.db, { repoId: repo.id, kind, issueId: p.pull.id, commitOid: p.pull.headOid, reason });
106 app.db
107 .query("UPDATE run SET state = ?2, created_at = ?3, finished_at = ?3, summary = ?4, check_detail = ?5 WHERE id = ?1")
108 .run(run.id, state, at(minutes).toISOString(), state === "failed" ? `failed: ${test}` : "", test === "" ? "" : JSON.stringify({ names: [test] }));
109 return run;
110 }
111
112 const runsOf = (id: string) => listRuns(app.db, repo.id, { issueId: id, limit: 50 });
113 const open = (id: string) => getIssueById(app.db, id)!.state === "open";
114
115 test("a red pull quiet for 30 minutes is re-checked, then its builder is queued with the failing test named", async () => {
116 const p = await pullBy(mason);
117 finished(p, "check", "failed", 0, "", "engine > adds up");
118 // 20 minutes after the failure: not yet quiet.
119 expect(await sweepStaleReds(app, repo, at(20), 3)).toEqual([]);
120 // A restart here loses nothing: the sweep keeps no memory.
121 const first = await sweepStaleReds(app, repo, at(31), 3);
122 expect(first).toContainEqual({ pull: p.pull.number, did: "re-check queued" });
123 expect(requested).toContain(`pull-${p.pull.number}`);
124 // The re-check is in flight: nothing more.
125 expect((await sweepStaleReds(app, repo, at(32), 3)).filter((m) => m.pull === p.pull.number)).toEqual([]);
126 // It ends red.
127 const again = runsOf(p.pull.id).find((r) => r.reason === SWEEP_REASON)!;
128 app.db
129 .query("UPDATE run SET state = 'failed', finished_at = ?2, summary = 'failed', check_detail = ?3 WHERE id = ?1")
130 .run(again.id, at(40).toISOString(), JSON.stringify({ names: ["engine > adds up"] }));
131 const second = await sweepStaleReds(app, repo, at(41), 3);
132 expect(second).toContainEqual({ pull: p.pull.number, did: "builder queued" });
133 expect(listComments(app.db, p.pull.id).map((c) => c.body).join("\n")).toContain("engine > adds up");
134 expect(runsOf(p.pull.id).some((r) => r.kind === "agent" && r.reason === "check_failed")).toBe(true);
135 });
136
137 test("a pull a person holds, or is assigned to, is left alone", async () => {
138 const held = await pullBy(mason);
139 const mine = await pullBy(mason);
140 finished(held, "check", "failed", 100, "", "engine > held");
141 finished(mine, "check", "failed", 100, "", "engine > mine");
142 setHold(app.db, held.pull.id, alice.id, "wait for the design");
143 app.db.query("UPDATE issue SET assignee_id = ?2 WHERE id = ?1").run(mine.pull.id, alice.id);
144 const moves = await sweepStaleReds(app, repo, at(200), 3);
145 expect(moves.filter((m) => m.pull === held.pull.number || m.pull === mine.pull.number)).toEqual([]);
146 expect(runsOf(held.pull.id).some((r) => r.reason === SWEEP_REASON || r.kind === "agent")).toBe(false);
147 expect(runsOf(mine.pull.id).some((r) => r.reason === SWEEP_REASON || r.kind === "agent")).toBe(false);
148 expect(open(held.pull.id)).toBe(true);
149 });
150
151 test("a pull past its tries is parked and its issue is ready again", async () => {
152 const p = await pullBy(mason);
153 finished(p, "check", "failed", 300, "", "engine > stubborn");
154 finished(p, "agent", "ok", 301, "check_failed");
155 finished(p, "agent", "ok", 302, "check_failed");
156 const moves = await sweepStaleReds(app, repo, at(400), 3);
157 expect(moves).toContainEqual({ pull: p.pull.number, did: "parked" });
158 expect(open(p.pull.id)).toBe(false);
159 const freed = getIssueById(app.db, p.ask.id)!;
160 expect(freed.state).toBe("open");
161 expect(freed.assigneeId).toBeNull();
162 });
163
164 test("a pull red for over a day whose issue is closed is closed with a line saying why", async () => {
165 const p = await pullBy(mason);
166 finished(p, "check", "failed", 1000, "", "engine > old");
167 app.db.query("UPDATE issue SET state = 'closed' WHERE id = ?1").run(p.ask.id);
168 expect(await sweepStaleReds(app, repo, at(1000 + 23 * 60), 3)).not.toContainEqual({ pull: p.pull.number, did: "closed" });
169 const moves = await sweepStaleReds(app, repo, at(1000 + 25 * 60), 3);
170 expect(moves).toContainEqual({ pull: p.pull.number, did: "closed" });
171 expect(open(p.pull.id)).toBe(false);
172 expect(listComments(app.db, p.pull.id).map((c) => c.body).join("\n")).toContain("the issue it was for is no longer open");
173 });
174
175 test("a pull red for over a day that conflicts with main is closed saying so", async () => {
176 const p = await pullBy(mason, "shared.ts");
177 finished(p, "check", "failed", 2000, "", "engine > conflicting");
178 const moved = await commitToBranch(app, alice, repo, {
179 message: "Main moves on",
180 files: [{ path: "shared.ts", content: "export const shared = 99;\n" }],
181 });
182 if (!moved.ok) throw new Error(JSON.stringify(moved.error));
183 // Red for under a day: it conflicts, but it is reworked, not closed.
184 expect(await sweepStaleReds(app, repo, at(2000 + 23 * 60), 3)).not.toContainEqual({ pull: p.pull.number, did: "closed" });
185 expect(open(p.pull.id)).toBe(true);
186 const moves = await sweepStaleReds(app, repo, at(2000 + 25 * 60), 3);
187 expect(moves).toContainEqual({ pull: p.pull.number, did: "closed" });
188 expect(open(p.pull.id)).toBe(false);
189 expect(listComments(app.db, p.pull.id).map((c) => c.body).join("\n")).toContain("it conflicts with main");
190 expect(getIssueById(app.db, p.ask.id)!.assigneeId).toBeNull();
191 });
192
193 test("a burst of reds is paced: no more than PER_TICK start in one tick", async () => {
194 const made: Awaited<ReturnType<typeof pullBy>>[] = [];
195 for (let i = 0; i < PER_TICK + 2; i++) {
196 const p = await pullBy(mason);
197 finished(p, "check", "failed", 3000, "", `engine > burst ${i}`);
198 made.push(p);
199 }
200 const moves = await sweepStaleReds(app, repo, at(3100), 99);
201 expect(moves.filter((m) => made.some((p) => p.pull.number === m.pull)).length).toBe(PER_TICK);
202 // No free slot, no start.
203 expect(
204 (await sweepStaleReds(app, repo, at(3101), 0)).filter((m) => made.some((p) => p.pull.number === m.pull)),
205 ).toEqual([]);
206 });
207
208 test("the flow report counts stale reds and the keeper names them as a cause", () => {
209 const now = at(3100 + 600);
210 const read = readStaleReds(app.db, now);
211 // The burst's five reds, failed at minute 3000, 700 minutes before `now`; the held,
212 // person-assigned, parked and closed ones are not counted.
213 expect(read.count).toBe(PER_TICK + 2);
214 expect(read.oldestHours).toBeCloseTo(700 / 60, 5);
215 const line = flowReportLines(flowReadings(app.db, now)).find((l) => l.startsWith("stale_reds:"))!;
216 expect(line).toBe("stale_reds: 5 red with no run for 30 min, oldest 11.7 h");
217 const found = pickCause({ repos: [{ repoId: "r", slug: "a/b", startable: 1, running: 1, slots: 4, ceilingUsd: 10, atCeiling: false, noHeadroom: false, resetsAt: null }], red: 0, staleReds: STALE_REDS, stale: 0 });
218 expect(found?.cause).toBe("stale_reds");
219 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs