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

A red that main shares never parks a pull #1973

Merged opened this in answer to

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

Merged

scout/forge:scout/1971-a-red-that-main-shares-never-parks-a-pul → main 6 commits fork 2 threads · 2 unresolved

  • Merged. scout/1971-a-red-that-main-shares-never-parks-a-pul was merged into main as e72e68e.

Review

Sign in to review this change.
8 files changed +297 −7
scripts/gate.ts +1
@@ -115,6 +115,7 @@ export const GATE: readonly string[] = [
115115 "test/crew-wrapup.test.ts",
116116 "test/crew-limits.test.ts",
117117 "test/crew-parks.test.ts",
118 "test/crew-red-main.test.ts",
118119 "test/crew-sandbox.test.ts",
119120 "test/crew-remotes.test.ts",
120121 "test/crew-brief-gate.test.ts",
src/checks/red_main.ts +12
@@ -173,3 +173,15 @@ export function redSentence(red: RedMain, held: number): string {
173173 : `"${first}" and ${red.failing.length - 1} more fail`;
174174 return `${red.repo}: main is red at ${red.commitOid.slice(0, 7)} — ${what}; ${held} pull check${held === 1 ? "" : "s"} wait for it to pass.`;
175175 }
176
177 /**
178 * Whether a failed check fails only what main's last check already fails
179 * (#1971): it named at least one test, and every one is red on main too.
180 * Such a failure is not the pull's, so the crew neither reworks nor parks
181 * it; it waits for main to be green. A check that named nothing (a build
182 * error, a timeout) or one test main does not fail is the pull's own.
183 */
184 export function sharedWithMain(red: RedMain | undefined, run: Pick<Run, "checkDetail"> | undefined): boolean {
185 const names = run?.checkDetail?.names ?? [];
186 return red !== undefined && names.length > 0 && names.every((n) => red.failing.includes(n));
187 }
src/crew/autonomy.ts +3 −1
@@ -32,6 +32,7 @@ import {
3232 crewRole,
3333 } from "../core/agents.ts";
3434 import { pickBuilder, routeIssue } from "./route.ts";
35 import { coveredByRedFix } from "./red_fix.ts";
3536 import { readyNow, repoGraph, type Ranked } from "../core/dependencies.ts";
3637 import {
3738 latestDecision,
@@ -365,7 +366,8 @@ export async function driveRepo(
365366 (r.issue.assigneeId === null || !planners.has(r.issue.assigneeId)) &&
366367 !stalled(app, repo, r.issue) &&
367368 !atRunCap(app, repo, r.issue) &&
368 parkedCount(app, repo, r.issue.id) < PARK_STRIKES,
369 parkedCount(app, repo, r.issue.id) < PARK_STRIKES &&
370 !coveredByRedFix(app.db, repo.id, r.issue),
369371 );
370372 if (slotted.length >= paced.slots) {
371373 // The builders are busy; the planner can still refill the map, when
src/crew/dispatch.ts +56 −1
@@ -81,6 +81,7 @@ import {
8181 spentToday,
8282 } from "./autonomy.ts";
8383 import { checksFor } from "../checks/service.ts";
84 import { redMain, sharedWithMain } from "../checks/red_main.ts";
8485 import { certain } from "./decide.ts";
8586 import { readRunLog } from "../runs/log.ts";
8687 import { whenText } from "./limits.ts";
@@ -542,7 +543,11 @@ function route(app: App, event: ForgeEvent): void {
542543 )
543544 return;
544545 const issueId = event.payload["issueId"];
545 if (typeof issueId !== "string") return;
546 if (typeof issueId !== "string") {
547 if (event.payload["kind"] === "check")
548 recheckWaiting(app, repo, event.payload["state"]);
549 return;
550 }
546551 const issue = getIssueById(app.db, issueId);
547552 if (!issue || issue.kind !== "pull" || issue.state !== "open") return;
548553 // A check's result is the outcome of the last "try again" decision.
@@ -580,6 +585,9 @@ function route(app: App, event: ForgeEvent): void {
580585 // is the agent's to look at.
581586 const agent = agentFor(app, issue);
outdated

This is the new control flow and nothing covers it. Add a dispatch-level test: shared red means no rework, no strike and the pull recorded as waiting; own red proceeds as before.

582587 if (!agent) return;
588 // A red that main shares is not the pull's: no rework, no park, no
589 // strike; it waits and is checked again when main is green (#1971).
590 if (waitOnMain(app, repo, issue, event.payload)) return;
583591 // Bounded: after a few failed checks in a row the agent stops and a
584592 // person must speak (docs/APPS.md, rule 7).
585593 const since = lastHumanWord(app, issue, agent);
@@ -662,6 +670,53 @@ function route(app: App, event: ForgeEvent): void {
662670 }
663671 }
664672
673 /**
674 * Pulls whose check failed only on what main fails, per repository, with
675 * the head that was checked. Memory only: a restart forgets them, and the
676 * pull's next push or a person's ask checks it again.
677 */
outdated

Memory-only wait set: after a restart a waiting pull is never rechecked and nothing says it is waiting. Persist it or derive it at boot, and surface it on the pull.

678 const waitingOnMain = new Map<string, Map<string, { env: string; commitOid: string }>>();
679
680 /** Whether a failed check is main's red, shared: if so, the pull is noted as waiting. */
681 function waitOnMain(
682 app: App,
683 repo: Repo,
684 issue: Issue,
685 payload: Record<string, unknown>,
686 ): boolean {
687 const { runId, commitOid, env } = payload;
688 if (payload["kind"] !== "check" || typeof runId !== "string") return false;
689 if (!sharedWithMain(redMain(app.db, repo.id), getRun(app.db, runId))) return false;
690 if (typeof commitOid === "string" && typeof env === "string") {
691 const waiting = waitingOnMain.get(repo.id) ?? new Map();
692 waiting.set(issue.id, { env, commitOid });
693 waitingOnMain.set(repo.id, waiting);
694 }
695 log.info("check failed only what main fails; waiting for main to be green", {
696 issue: issue.number,
697 });
698 return true;
699 }
700
701 /** Main's check finished: when it passed, the pulls that waited are checked again. */
702 function recheckWaiting(app: App, repo: Repo, state: unknown): void {
703 const waiting = waitingOnMain.get(repo.id);
704 if (state !== "ok" || waiting === undefined) return;
705 waitingOnMain.delete(repo.id);
706 const checks = checksFor(app);
707 if (checks === undefined) return;
708 for (const [issueId, head] of waiting) {
709 const issue = getIssueById(app.db, issueId);
710 const pull = issue ? getPull(app.db, repo.id, issue.number) : undefined;
711 if (!pull || pull.state !== "open" || pull.headOid !== head.commitOid) continue;
712 void checks
713 .request(repo, head.env, head.commitOid, "retry", issueId)
714 .catch((error) =>
715 log.warn("a waiting pull could not be checked again", { issue: issue?.number, error: String(error) }),
716 );
717 }
718 }
719
665720 /** Checks already run again once for a head; a second red is real. */
666721 const retriedChecks = new Set<string>();
667722
src/crew/red_fix.ts added +42
@@ -0,0 +1,42 @@
1 /**
2 * A red main gets one fix issue, and nobody else is handed its test (#1971).
3 * The sentinel files the fix as the vitals card's finding (`red:<repoId>`);
4 * while that issue is open, an issue that quotes a test main fails is the
5 * same work, and the autonomy loop does not hand it to a builder. Five
6 * builders once wrote one line in parallel for want of this.
7 */
8
9 import type { Database } from "bun:sqlite";
10 import { redMain } from "../checks/red_main.ts";
11 import type { Issue } from "../core/issues.ts";
12 import { keyPart } from "./sentinel.ts";
13
14 /** The open issue fixing this repository's red main, if the sentinel filed one. */
15 export function redFixIssue(db: Database, repoId: string): string | undefined {
16 return (
17 db
18 .query<{ issue_id: string }, [string]>(
19 `SELECT t.issue_id FROM sentinel_finding t JOIN issue i ON i.id = t.issue_id
20 WHERE t.key = ?1 AND i.state = 'open' LIMIT 1`,
21 )
22 .get(`degraded:${keyPart(`red:${repoId}`)}`)?.issue_id ?? undefined
23 );
24 }
25
26 /**
27 * Whether `issue` is work the red main's fix issue already carries: main is
28 * red, a fix issue is open and is not this one, and this one's title or
29 * body quotes a test main fails.
30 */
31 export function coveredByRedFix(
32 db: Database,
33 repoId: string,
34 issue: Pick<Issue, "id" | "title" | "body">,
35 ): boolean {
36 const red = redMain(db, repoId);
37 if (red === undefined || red.failing.length === 0) return false;
38 const fix = redFixIssue(db, repoId);
39 if (fix === undefined || fix === issue.id) return false;
40 const text = `${issue.title}\n${issue.body}`;
41 return red.failing.some((name) => text.includes(name));
42 }
src/crew/sentinel.ts +10 −4
@@ -75,7 +75,7 @@ const clip = (text: string, n = 200): string =>
7575 export const scrub = (text: string): string =>
7676 redactSecrets(text).replace(/[A-Za-z0-9_+/=-]{32,}/g, "[redacted]");
7777 const oneLine = (text: string): string => text.replace(/\s+/g, " ").trim();
78 const keyPart = (text: string): string =>
78 export const keyPart = (text: string): string =>
7979 text.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").slice(0, 60);
8080
8181 // ---- detectors ----
@@ -396,8 +396,12 @@ export function degradedCauses(db: Database, now: number): Finding[] {
396396 return out;
397397 }
398398
399 /** The causes that are a budget breached: the lane, a silent run, a run killed twice. */
400 const BREACH_CAUSE = /^(queue:check|stuck:|budget:)/;
399 /**
400 * The causes that are a budget breached: the lane, a silent run, a run
401 * killed twice, a red main holding its pulls' checks. The last gets one fix
402 * issue (#1971), and `crew/red_fix.ts` keeps other builders off its test.
403 */
404 const BREACH_CAUSE = /^(queue:check|stuck:|budget:|red:)/;
401405
402406 /**
403407 * Each budget breach that holds right now, however briefly: the crew hears
@@ -420,7 +424,9 @@ export function budgetBreaches(db: Database, now: number): Finding[] {
420424 ],
421425 pillar: "P0 Trust: the forge says when it is not well, and is well again",
422426 lookFor: `\`${s.cause}\` is gone from the degraded list and the cause is fixed, not waited out.`,
423 fix: "Diagnose the cause from the evidence and hand the mechanical fix to a builder as its own issue.",
427 fix: s.cause.startsWith("red:")
428 ? "Fix the failing test on main in one pull. This is the only issue for it: pulls red on the same test wait for main, and no other builder is handed it while this is open."
429 : "Diagnose the cause from the evidence and hand the mechanical fix to a builder as its own issue.",
424430 urgent: true,
425431 });
426432 }
test/crew-red-main.test.ts added +113
@@ -0,0 +1,113 @@
1 /**
2 * A red that main shares never parks a pull (#1971): through the crew's
3 * event path, a pull whose check fails only what main fails is not reworked
4 * or struck, waits, and is checked again (once, at the same head) when
5 * main's check passes. The stand-in forge is `helpers/crew-standin.ts`.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import type { App } from "../src/app.ts";
10 import { announceRun } from "../src/actions/runs.ts";
11 import { commitToBranch } from "../src/actions/repos.ts";
12 import { openPull } from "../src/actions/pulls.ts";
13 import { getPull, type Pull } from "../src/core/pulls.ts";
14 import type { Repo } from "../src/core/repos.ts";
15 import { claimRun, createRun, finishRun, listRuns } from "../src/core/runs.ts";
16 import type { User } from "../src/core/users.ts";
17 import { startStandInCrew, stopStandInCrew, type StandInCrew } from "./helpers/crew-standin.ts";
18
19 describe("the crew, with a red main it shares", () => {
20 let standIn: StandInCrew;
21 let app: App;
22 let alice: User;
23 let bot: User;
24 let repo: Repo;
25 let requested: unknown[][] = [];
26 let n = 0;
27
28 beforeAll(async () => {
29 standIn = await startStandInCrew();
30 ({ app, alice, bot, repo } = standIn);
31 // The checks handle is a plain object: record what the crew asks of it.
32 standIn.checks.request = async (...args: unknown[]) => {
33 requested.push(args);
34 return undefined;
35 };
36 }, 30_000);
37 afterAll(() => stopStandInCrew(standIn));
38
39 const pullOf = async (): Promise<Pull> => {
40 const branch = `feat-${++n}`;
41 const c = await commitToBranch(app, alice, repo, {
42 message: branch,
43 branch,
44 files: [{ path: `${branch}.md`, content: "x\n" }],
45 });
46 if (!c.ok) throw new Error(JSON.stringify(c.error));
47 const o = await openPull(app, alice, repo, { title: branch, baseBranch: "main", headBranch: branch });
48 if (!o.ok) throw new Error(String(o.error));
49 app.db.run("UPDATE issue SET assignee_id = ?1 WHERE id = ?2", [bot.id, o.value.id]);
50 return getPull(app.db, repo.id, o.value.number)!;
51 };
52 const finish = (env: string, state: "ok" | "failed", names: string[], pull?: Pull) => {
53 const run = createRun(app.db, {
54 repoId: repo.id,
55 kind: "check",
56 env,
57 commitOid: pull?.headOid ?? "a".repeat(40),
58 reason: "push",
59 ...(pull === undefined ? {} : { issueId: pull.id }),
60 });
61 claimRun(app.db, run.id);
62 const done = finishRun(app.db, run.id, {
63 state,
64 summary: state === "ok" ? "check passed" : "check failed",
65 ...(state === "failed" ? { checkDetail: { passed: 1, failed: names.length, names } } : {}),
66 })!;
67 announceRun(app, done);
68 return done;
69 };
70 const reworks = (pull: Pull) =>
71 listRuns(app.db, repo.id, { kind: "agent" }).filter((r) => r.issueId === pull.id && r.reason === "check_failed");
72
73 test("a main check's finished event is a check with no pull", () => {
74 const run = finish("main", "failed", ["X"]);
75 expect(run.issueId).toBeNull();
76 expect(run.kind).toBe("check");
77 });
78
79 test("shared red: no rework; an own red is reworked as ever; main failing again keeps the wait", async () => {
80 finish("main", "failed", ["X"]);
81 const shared = await pullOf();
82 const own = await pullOf();
83 finish(`pull-${shared.number}`, "failed", ["X"], shared);
84 finish(`pull-${own.number}`, "failed", ["X", "Y"], own);
85 expect(reworks(shared)).toHaveLength(0);
86 expect(reworks(own)).toHaveLength(1);
87 // Still red: nothing is checked again, the pull still waits.
88 requested = [];
89 finish("main", "failed", ["X"]);
90 expect(requested).toHaveLength(0);
91 // Green: the waiting pull, not the reworked one, is checked again, once.
92 finish("main", "ok", []);
93 expect(requested).toEqual([[repo, `pull-${shared.number}`, shared.headOid, "retry", shared.id]]);
94 finish("main", "ok", []);
95 expect(requested).toHaveLength(1);
96 });
97
98 test("a pull pushed, closed or merged meanwhile is not checked again", async () => {
99 finish("main", "failed", ["X"]);
100 const pushed = await pullOf();
101 const closed = await pullOf();
102 const merged = await pullOf();
103 const kept = await pullOf();
104 for (const p of [pushed, closed, merged, kept]) finish(`pull-${p.number}`, "failed", ["X"], p);
105 app.db.run("UPDATE pull SET head_oid = ?1 WHERE issue_id = ?2", ["b".repeat(40), pushed.id]);
106 app.db.run("UPDATE issue SET state = 'closed' WHERE id = ?1", [closed.id]);
107 app.db.run("UPDATE issue SET state = 'closed' WHERE id = ?1", [merged.id]);
108 for (const p of [pushed, closed, merged, kept]) expect(reworks(p)).toHaveLength(0);
109 requested = [];
110 finish("main", "ok", []);
111 expect(requested.map((r) => r[4])).toEqual([kept.id]);
112 });
113 });
test/red-main.test.ts +60 −1
@@ -9,7 +9,9 @@ import { tmpdir } from "node:os";
99 import { migrations } from "../src/core/schema.ts";
1010 import { openDb } from "../src/kernel/db.ts";
1111 import { claimRun, createRun, finishRun, listActive, type Run } from "../src/core/runs.ts";
12 import { RED_HOLD_MS, heldChecks, redMain, redSentence, unheld } from "../src/checks/red_main.ts";
12 import { RED_HOLD_MS, heldChecks, redMain, redSentence, sharedWithMain, unheld } from "../src/checks/red_main.ts";
13 import { coveredByRedFix } from "../src/crew/red_fix.ts";
14 import { budgetBreaches } from "../src/crew/sentinel.ts";
1315 import { vitals } from "../src/kernel/vitals.ts";
1416 import { ErrorLedger } from "../src/kernel/log.ts";
1517 import { RefusedLedger } from "../src/kernel/internal_refused.ts";
@@ -120,3 +122,60 @@ describe("red main", () => {
120122 expect(cards[0]!.href).toBe(`/trevato/forge/runs/${failed.id}`);
121123 });
122124 });
125
126 describe("a red main shares (#1971)", () => {
127 const failed = (names: string[]): Run => {
128 const run = createRun(db, { repoId: "r1", kind: "check", env: "pull-7", issueId: pull, commitOid: OID("a"), reason: "push" });
129 claimRun(db, run.id);
130 return finishRun(db, run.id, { state: "failed", summary: "check failed", checkDetail: { passed: 1, failed: names.length, names } })!;
131 };
132
133 test("a pull red on main's test only is main's; one more test is its own", () => {
134 main("b", "failed", ["X"]);
135 expect(sharedWithMain(redMain(db, "r1"), failed(["X"]))).toBe(true);
136 expect(sharedWithMain(redMain(db, "r1"), failed(["X", "Y"]))).toBe(false);
137 expect(sharedWithMain(redMain(db, "r1"), failed(["Y"]))).toBe(false);
138 });
139
140 test("nothing named, or a green main, shares nothing", () => {
141 main("b", "failed", ["X"]);
142 expect(sharedWithMain(redMain(db, "r1"), failed([]))).toBe(false);
143 main("c", "ok");
144 expect(sharedWithMain(redMain(db, "r1"), failed(["X"]))).toBe(false);
145 });
146 });
147
148 describe("one fix issue for a red main (#1971)", () => {
149 const dup = { id: "i9", title: 'Fix "a panel keeps its chart" on main', body: "" };
150 const fixRow = () =>
151 db.run(
152 "INSERT INTO sentinel_finding (key, issue_id, evidence, first_seen, last_seen) VALUES ('degraded:red-r1', 'i1', '', 'x', 'x')",
153 );
154
155 test("a red main's held checks are a breach: one finding, urgent, keyed by the repository", () => {
156 db.run(
157 "INSERT INTO degraded_span (cause, sentence, href, opened_at) VALUES ('red:r1', 'trevato/forge: main is red — \"a panel keeps its chart\" fails', '/x', 1000)",
158 );
159 const found = budgetBreaches(db, 2000);
160 expect(found.map((f) => f.key)).toEqual(["degraded:red-r1"]);
161 expect(found[0]!.urgent).toBe(true);
162 expect(found[0]!.fix).toContain("only issue");
163 });
164
165 test("while the fix issue is open, an issue quoting main's failing test is covered; not otherwise", () => {
166 main("b", "failed", ["a panel keeps its chart"]);
167 // No fix issue filed yet: nothing is covered.
168 expect(coveredByRedFix(db, "r1", dup)).toBe(false);
169 fixRow();
170 expect(coveredByRedFix(db, "r1", dup)).toBe(true);
171 // The fix issue itself, and an issue about another test, are handed out.
172 expect(coveredByRedFix(db, "r1", { ...dup, id: "i1" })).toBe(false);
173 expect(coveredByRedFix(db, "r1", { id: "i8", title: "Something else", body: "" })).toBe(false);
174 // Once the fix issue closes, or main is green, nothing is held back.
175 db.run("UPDATE issue SET state = 'closed' WHERE id = 'i1'");
176 expect(coveredByRedFix(db, "r1", dup)).toBe(false);
177 db.run("UPDATE issue SET state = 'open' WHERE id = 'i1'");
178 main("c", "ok");
179 expect(coveredByRedFix(db, "r1", dup)).toBe(false);
180 });
181 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs