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

A pull red only on a test its diff does not touch gets one fresh re-check before the decider may park it #7682

Merged opened this

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

Merged

mason/forge:mason/7630-a-pull-red-only-on-a-test-its-diff-does → main 3 commits fork

  • Merged. mason/7630-a-pull-red-only-on-a-test-its-diff-does was merged into main as 8de0302.

Review

Sign in to review this change.
4 files changed +400 −40
src/checks/impact-store.ts +4
@@ -34,6 +34,10 @@ function readValid(path: string): Map<string, string[]> | undefined {
3434 }
3535 }
3636
37 /** The repository's kept map, when there is a valid one. */
38 export const keptImpact = (dataDir: string, repoId: string): Map<string, string[]> | undefined =>
39 readValid(storeOf(dataDir, repoId));
40
3741 /** Copies the kept map into a checkout; how many tests it maps, or 0 for none. */
3842 export function seedImpact(dataDir: string, repoId: string, checkout: string): number {
3943 const map = readValid(storeOf(dataDir, repoId));
src/checks/outside_diff.ts added +102
@@ -0,0 +1,102 @@
1 /**
2 * A pull red only on tests its diff does not reach is checked once more on
3 * the same head before one of its builder's tries is spent on it (#7630).
4 * An order-dependent test the change never touched parked #7552 twice, a
5 * day of the owner's ask each time. So when every failing test file is one
6 * the impact map (`impact.ts`) says executes none of the pull's changed
7 * files, the check runs again; a second red on that head goes back to the
8 * builder as any red does.
9 *
10 * One fresh re-check per head, recorded: the re-check is a check run with
11 * the reason {@link OUTSIDE_DIFF} on that head, so a red on it — or any
12 * later red on the head, after a restart too — is never re-checked again.
13 *
14 * Plain data in, a verdict out ({@link redOutsideDiff}); the forge's reads
15 * (the kept map, the run's log, the diff) are {@link outsideDiffCandidate}
16 * and {@link pullChanged}.
17 */
18
19 import type { Database } from "bun:sqlite";
20 import type { Pull } from "../core/pulls.ts";
21 import type { Run } from "../core/runs.ts";
22 import { runGitCapture } from "../git/env.ts";
23 import { resolveOid } from "../git/merge.ts";
24 import { readRunLog } from "../runs/log.ts";
25 import { failuresOf } from "./failures.ts";
26 import { keptImpact } from "./impact-store.ts";
27 import { needsFull, reach, type Impact } from "./impact.ts";
28 import { RULE_TESTS } from "./tiers.ts";
29
30 /** The re-check's run reason, which is also its record. */
31 export const OUTSIDE_DIFF = "outside_diff";
32
33 /** What the re-check's log says first. */
34 export const recheckLine = (files: readonly string[]): string =>
35 `re-checked: red outside the diff (${files.join(", ")})`;
36
37 /** Files that shape every test: a pull changing one owns any red. */
38 const SHAPING = /(^|\/)(bun\.lockb?|bunfig\.toml|tsconfig[^/]*\.json|\.env[^/]*)$/;
39
40 /**
41 * The failing test files when each is outside what the diff reaches, by
42 * the map; `undefined` when any may be the change's. Conservative: a test
43 * the map has never measured, a repo-wide rule test, a changed test, a
44 * changed test helper (the map is of `src/**` only), or a diff the map
45 * cannot bound at all, and the red is the pull's.
46 */
47 export function redOutsideDiff(
48 failing: readonly string[],
49 changed: readonly string[],
50 impact: Impact,
51 ): string[] | undefined {
52 if (failing.length === 0 || changed.length === 0) return undefined;
53 if (needsFull(changed) !== undefined || changed.some((f) => SHAPING.test(f))) return undefined;
54 if (changed.some((f) => f.startsWith("test/") && !/\.test\.[cm]?[jt]sx?$/.test(f))) return undefined;
55 const reached = reach(changed, impact).tests;
56 const inside = (file: string): boolean =>
57 !impact.has(file) || RULE_TESTS.includes(file) || changed.includes(file) || reached.has(file);
58 return failing.some(inside) ? undefined : [...failing].sort();
59 }
60
61 /** Whether this head of the pull already had its re-check. */
62 export function recheckedHead(db: Database, issueId: string, commitOid: string): boolean {
63 return (
64 db
65 .query<{ n: number }, [string, string, string]>(
66 `SELECT 1 AS n FROM run WHERE kind = 'check' AND issue_id = ?1 AND commit_oid = ?2 AND reason = ?3 LIMIT 1`,
67 )
68 .get(issueId, commitOid, OUTSIDE_DIFF) !== null
69 );
70 }
71
72 /**
73 * What can be known without git, in the event's own tick: a failed pull
74 * check not yet re-checked on its head, whose log names failing test files
75 * that the repository's kept map has all measured. The answer is the map
76 * and the files; `undefined` is today's path at once.
77 */
78 export function outsideDiffCandidate(
79 db: Database,
80 dataDir: string,
81 run: Run | undefined,
82 ): { impact: Impact; failing: string[] } | undefined {
83 if (run === undefined || run.kind !== "check" || run.state !== "failed") return undefined;
84 if (run.issueId === null || run.commitOid === "") return undefined;
85 if (run.reason === OUTSIDE_DIFF || recheckedHead(db, run.issueId, run.commitOid)) return undefined;
86 const impact = keptImpact(dataDir, run.repoId);
87 if (impact === undefined) return undefined;
88 const { files } = failuresOf(readRunLog(dataDir, run.id).lines);
89 if (files.length === 0 || !files.every((f) => impact.has(f))) return undefined;
90 return { impact, failing: files };
91 }
92
93 /** The files the pull's head changes from its base; `undefined` when git cannot say. */
94 export async function pullChanged(dir: string, pull: Pull, head: string): Promise<string[] | undefined> {
95 const base = await resolveOid(dir, `refs/heads/${pull.baseBranch}`);
96 if (base === undefined) return undefined;
97 const out = await runGitCapture(
98 ["diff", "--name-only", "-z", "--no-renames", `${base}...${head}`, "--"],
99 { cwd: dir },
100 );
101 return out.code === 0 ? out.stdout.split("\0").filter((f) => f !== "") : undefined;
102 }
src/crew/dispatch.ts +115 −40
@@ -29,7 +29,7 @@ import {
2929 listComments,
3030 type Issue,
3131 } from "../core/issues.ts";
32 import { closesFromBody, getPull, type Pull } from "../core/pulls.ts";
32 import { closesFromBody, getPull, pullDir, type Pull } from "../core/pulls.ts";
3333 import { claimedClones, sweepOrphanClones } from "./orphans.ts";
3434 import { pruneRunFiles } from "../runs/prune.ts";
3535 import { crewRoot } from "./workdir.ts";
@@ -96,6 +96,15 @@ import { checksFor } from "../checks/service.ts";
9696 import { redMain, sharedWithMain } from "../checks/red_main.ts";
9797 import { diskRecovered, diskRed } from "../checks/disk_red.ts";
9898 import { sharedWithPulls } from "../checks/shared_red.ts";
99 import type { Impact } from "../checks/impact.ts";
100 import {
101 OUTSIDE_DIFF,
102 outsideDiffCandidate,
103 pullChanged,
104 recheckedHead,
105 recheckLine,
106 redOutsideDiff,
107 } from "../checks/outside_diff.ts";
99108 import { certain } from "./decide.ts";
100109 import { readRunLog } from "../runs/log.ts";
101110 import { whenText } from "./limits.ts";
@@ -718,47 +727,24 @@ function route(app: App, event: ForgeEvent): void {
718727 // the hour (#2266), is not the pull's: no rework, no park, no
719728 // strike; it waits and is checked again when main is green.
720729 if (waitOnMain(app, repo, issue, event.payload)) return;
721 // Bounded: after a few failed checks in a row the agent stops and a
722 // person must speak (docs/APPS.md, rule 7).
723 const since = lastHumanWord(app, issue, agent);
724 if (automaticRunsSince(app.db, issue.id, since) >= CHECK_FAILED_LIMIT) {
725 const pull = getPull(app.db, repo.id, issue.number);
726 if (
727 pull &&
728 parkPull(
729 app,
730 repo,
731 pull,
732 `the check stayed red through ${CHECK_FAILED_LIMIT} automatic tries`,
733 )
734 )
735 return;
736 log.info("check failed again; waiting for a person", {
737 issue: issue.number,
738 });
730 // A red only on tests the diff does not reach (#7630) is checked once
731 // more on the same head before a try is spent on it.
732 const failedRun = typeof event.payload["runId"] === "string" ? getRun(app.db, event.payload["runId"]) : undefined;
733 const outside = outsideDiffCandidate(app.db, app.config.dataDir, failedRun);
734 if (outside === undefined) {
735 answerRed(app, repo, issue, agent, event.payload);
739736 return;
740737 }
741 const runId = event.payload["runId"];
742 // Without a decider the rule answers at once, and the rework queues
743 // in the same tick the event landed — nothing waits on a promise.
744 if (app.config.decider === undefined || typeof runId !== "string") {
745 queueAgentRun(app, repo, issue, agent, "check_failed");
746 return;
747 }
748 const commitOid = event.payload["commitOid"];
749 const env = event.payload["env"];
750 void answerFailedCheck(app, repo, issue, agent, {
751 runId,
752 kind: String(event.payload["kind"] ?? ""),
753 commitOid: typeof commitOid === "string" ? commitOid : undefined,
754 env: typeof env === "string" ? env : undefined,
755 since,
756 }).catch((error) =>
757 log.error("a failed check went unanswered", {
758 issue: issue.number,
759 error: String(error),
760 }),
761 );
738 void recheckOutsideDiff(app, repo, issue, event.payload, outside)
739 .then((rechecked) => {
740 if (!rechecked) answerRed(app, repo, issue, agent, event.payload);
741 })
742 .catch((error) =>
743 log.error("a red outside the diff went unanswered", {
744 issue: issue.number,
745 error: String(error),
746 }),
747 );
762748 return;
763749 }
764750 case "pull.closed":
@@ -800,6 +786,95 @@ function route(app: App, event: ForgeEvent): void {
800786 }
801787 }
802788
789 /**
790 * A red that is the pull's to answer: parked once the automatic tries are
791 * spent, else reworked — through the decider, when there is one.
792 */
793 function answerRed(
794 app: App,
795 repo: Repo,
796 issue: Issue,
797 agent: Agent,
798 payload: Record<string, unknown>,
799 ): void {
800 // Bounded: after a few failed checks in a row the agent stops and a
801 // person must speak (docs/APPS.md, rule 7).
802 const since = lastHumanWord(app, issue, agent);
803 if (automaticRunsSince(app.db, issue.id, since) >= CHECK_FAILED_LIMIT) {
804 const pull = getPull(app.db, repo.id, issue.number);
805 if (
806 pull &&
807 parkPull(
808 app,
809 repo,
810 pull,
811 `the check stayed red through ${CHECK_FAILED_LIMIT} automatic tries`,
812 )
813 )
814 return;
815 log.info("check failed again; waiting for a person", {
816 issue: issue.number,
817 });
818 return;
819 }
820 const runId = payload["runId"];
821 // Without a decider the rule answers at once, and the rework queues
822 // in the same tick the event landed — nothing waits on a promise.
823 if (app.config.decider === undefined || typeof runId !== "string") {
824 queueAgentRun(app, repo, issue, agent, "check_failed");
825 return;
826 }
827 const commitOid = payload["commitOid"];
828 const env = payload["env"];
829 void answerFailedCheck(app, repo, issue, agent, {
830 runId,
831 kind: String(payload["kind"] ?? ""),
832 commitOid: typeof commitOid === "string" ? commitOid : undefined,
833 env: typeof env === "string" ? env : undefined,
834 since,
835 }).catch((error) =>
836 log.error("a failed check went unanswered", {
837 issue: issue.number,
838 error: String(error),
839 }),
840 );
841 }
842
843 /**
844 * The re-check of a red outside the diff, when the diff says it is one:
845 * whether it was asked for (or the head moved on and the red is history).
846 * `false` is today's answer: the red is the pull's.
847 */
848 async function recheckOutsideDiff(
849 app: App,
850 repo: Repo,
851 issue: Issue,
852 payload: Record<string, unknown>,
853 outside: { impact: Impact; failing: string[] },
854 ): Promise<boolean> {
855 const { commitOid, env } = payload;
856 const checks = checksFor(app);
857 const pull = getPull(app.db, repo.id, issue.number);
858 if (checks === undefined || !pull || typeof commitOid !== "string" || typeof env !== "string")
859 return false;
860 const dir = await pullDir(app.db, app.reposRoot, repo, pull);
861 const changed = await pullChanged(dir, pull, commitOid);
862 const files = changed && redOutsideDiff(outside.failing, changed, outside.impact);
863 if (files === undefined) return false;
864 const now = getPull(app.db, repo.id, issue.number);
865 // A push since then made a newer check; this red is history.
866 if (!now || now.state !== "open" || now.headOid !== commitOid) return true;
867 if (recheckedHead(app.db, issue.id, commitOid)) return false;
868 const run = await checks.request(repo, env, commitOid, OUTSIDE_DIFF, issue.id);
869 if (run === undefined) return false;
870 if (run.reason === OUTSIDE_DIFF) appendRunLog(app.config.dataDir, run.id, recheckLine(files));
871 log.info("check failed only outside the diff; checking the head once more", {
872 issue: issue.number,
873 files: files.join(", "),
874 });
875 return true;
876 }
877
803878 /**
804879 * Failures that arrived while the builder's run was still at work on the
805880 * pull, by pull. Memory only: a restart forgets them, and the pull's next
test/crew-red-outside-diff.test.ts added +179
@@ -0,0 +1,179 @@
1 /**
2 * A pull red only on a test its diff does not reach gets one fresh re-check
3 * before a try is spent on it (#7630). Through the crew's event path, with
4 * the impact map kept for the repository and a decider that always says
5 * stop (which no longer parks: one red goes back to its builder): a red
6 * outside the diff is checked once more on the same head, and the re-check
7 * says so in its log; green then, the decider is never asked and no rework
8 * is queued; red again, it is, and the builder reworks it. A red inside
9 * the diff goes to the decider and the builder at once. Each red names its
10 * own test, so no two pulls share one more than twice in the hour
11 * (`shared_red.ts`).
12 */
13
14 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
15 import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
16 import { tmpdir } from "node:os";
17 import { join } from "node:path";
18 import type { App } from "../src/app.ts";
19 import { announceRun } from "../src/actions/runs.ts";
20 import { commitToBranch } from "../src/actions/repos.ts";
21 import { openPull } from "../src/actions/pulls.ts";
22 import { formatImpact } from "../src/checks/impact.ts";
23 import { IMPACT_FILE, keepImpact } from "../src/checks/impact-store.ts";
24 import { OUTSIDE_DIFF, recheckLine, redOutsideDiff } from "../src/checks/outside_diff.ts";
25 import { getIssueById } from "../src/core/issues.ts";
26 import { getPull, type Pull } from "../src/core/pulls.ts";
27 import type { Repo } from "../src/core/repos.ts";
28 import { claimRun, createRun, finishRun, listRuns, type Run } from "../src/core/runs.ts";
29 import type { User } from "../src/core/users.ts";
30 import type { Decider } from "../src/crew/decide.ts";
31 import { appendRunLog, readRunLog } from "../src/runs/log.ts";
32 import { startStandInCrew, stopStandInCrew, type StandInCrew } from "./helpers/crew-standin.ts";
33
34 const IMPACT = new Map([
35 ["test/people.test.ts", ["src/people.ts"]],
36 ["test/feature.test.ts", ["src/feature.ts", "src/shared.ts"]],
37 ["test/flake.test.ts", ["src/shared.ts"]],
38 ]);
39
40 describe("which reds are outside the diff", () => {
41 test("a mapped test that executes none of the changed files", () => {
42 expect(redOutsideDiff(["test/people.test.ts"], ["src/feature.ts"], IMPACT)).toEqual(["test/people.test.ts"]);
43 });
44 test("any failing test the diff reaches, changes, or the map has not measured is the pull's", () => {
45 expect(redOutsideDiff(["test/people.test.ts", "test/feature.test.ts"], ["src/feature.ts"], IMPACT)).toBeUndefined();
46 expect(redOutsideDiff(["test/people.test.ts"], ["src/feature.ts", "test/people.test.ts"], IMPACT)).toBeUndefined();
47 expect(redOutsideDiff(["test/new.test.ts"], ["src/feature.ts"], IMPACT)).toBeUndefined();
48 });
49 test("a diff the map cannot bound, a changed helper, or a repo-wide rule test is the pull's", () => {
50 for (const unbounded of ["package.json", "bun.lock", "scripts/gate.ts", "test/helpers/x.ts"])
51 expect(redOutsideDiff(["test/people.test.ts"], ["src/feature.ts", unbounded], IMPACT)).toBeUndefined();
52 expect(redOutsideDiff(["test/flake.test.ts"], ["src/feature.ts"], IMPACT)).toBeUndefined();
53 expect(redOutsideDiff([], ["src/feature.ts"], IMPACT)).toBeUndefined();
54 });
55 });
56
57 describe("the crew, with a red outside the diff", () => {
58 let standIn: StandInCrew;
59 let app: App;
60 let alice: User;
61 let bot: User;
62 let repo: Repo;
63 let asked: string[] = [];
64 let n = 0;
65
66 beforeAll(async () => {
67 standIn = await startStandInCrew();
68 ({ app, alice, bot, repo } = standIn);
69 standIn.checks.request = async (...args: unknown[]) => {
70 const [r, env, commitOid, reason, issueId] = args as [Repo, string, string, string, string];
71 return createRun(app.db, { repoId: r.id, kind: "check", env, commitOid, reason, issueId });
72 };
73 const decider: Decider = {
74 name: "stand-in",
75 async decide(state) {
76 asked.push(String((state as { pull?: { number: number } }).pull?.number));
77 return {
78 retry: { choice: "stop", p: 1, probabilities: { again: 0, stop: 1 } },
79 cause: { choice: "change", p: 1, probabilities: { change: 1, environment: 0, base: 0 } },
80 } as never;
81 },
82 };
83 (app.config as { decider?: Decider }).decider = decider;
84 // Parking is autonomy's: the repository runs on its own, on alice's word.
85 app.db.run("UPDATE repo SET autonomy = 'autonomous', autonomy_by = ?1 WHERE id = ?2", [alice.id, repo.id]);
86 const checkout = mkdtempSync(join(tmpdir(), "forge-outside-diff-"));
87 writeFileSync(join(checkout, IMPACT_FILE), formatImpact(IMPACT));
88 expect(keepImpact(app.config.dataDir, repo.id, checkout)).toBe("impact map kept: 3 tests");
89 rmSync(checkout, { recursive: true, force: true });
90 }, 30_000);
91 afterAll(() => stopStandInCrew(standIn));
92
93 /** The crew's pull changing src/feature.ts. */
94 const featurePull = async (): Promise<Pull> => {
95 const branch = `feat-${++n}`;
96 const c = await commitToBranch(app, alice, repo, {
97 message: branch,
98 branch,
99 files: [{ path: "src/feature.ts", content: `export const n = ${n};\n` }],
100 });
101 if (!c.ok) throw new Error(JSON.stringify(c.error));
102 const o = await openPull(app, bot, repo, { title: branch, baseBranch: "main", headBranch: branch });
103 if (!o.ok) throw new Error(String(o.error));
104 app.db.run("UPDATE issue SET assignee_id = ?1 WHERE id = ?2", [bot.id, o.value.id]);
105 return getPull(app.db, repo.id, o.value.number)!;
106 };
107 /** A check run on the pull's head, finished and announced; its log names `file` red. */
108 const finish = (run: Run, state: "ok" | "failed", file = "test/people.test.ts"): void => {
109 claimRun(app.db, run.id);
110 if (state === "failed") {
111 appendRunLog(app.config.dataDir, run.id, `${file}:`);
112 appendRunLog(app.config.dataDir, run.id, `(fail) ${file} > verify [3.00ms]`);
113 }
114 const done = finishRun(app.db, run.id, {
115 state,
116 summary: state === "ok" ? "check passed" : "check failed",
117 ...(state === "failed" ? { checkDetail: { passed: 9, failed: 1, names: [`${file} > verify`] } } : {}),
118 })!;
119 announceRun(app, done);
120 };
121 const pushRed = (pull: Pull, file?: string): void =>
122 finish(
123 createRun(app.db, { repoId: repo.id, kind: "check", env: `pull-${pull.number}`, commitOid: pull.headOid, reason: "push", issueId: pull.id }),
124 "failed",
125 file,
126 );
127 const rechecks = (pull: Pull): Run[] =>
128 listRuns(app.db, repo.id, { kind: "check", limit: 50 }).filter((r) => r.issueId === pull.id && r.reason === OUTSIDE_DIFF);
129 const open = (pull: Pull): boolean => getIssueById(app.db, pull.id)?.state === "open";
130 /** The builder's tries spent on the pull's reds. */
131 const reworks = (pull: Pull): number =>
132 listRuns(app.db, repo.id, { kind: "agent", limit: 50 }).filter((r) => r.issueId === pull.id && r.reason === "check_failed").length;
133 const until = async (done: () => boolean): Promise<void> => {
134 const deadline = Date.now() + 10_000;
135 while (!done() && Date.now() < deadline) await Bun.sleep(20);
136 expect(done()).toBe(true);
137 };
138
139 test("red outside the diff: one re-check on the same head, which says why; green, no try is spent", async () => {
140 asked = [];
141 const pull = await featurePull();
142 pushRed(pull);
143 await until(() => rechecks(pull).length === 1);
144 const recheck = rechecks(pull)[0]!;
145 expect(recheck.commitOid).toBe(pull.headOid);
146 expect(readRunLog(app.config.dataDir, recheck.id).lines).toContain(recheckLine(["test/people.test.ts"]));
147 expect(recheckLine(["test/people.test.ts"])).toBe("re-checked: red outside the diff (test/people.test.ts)");
148 finish(recheck, "ok");
149 await Bun.sleep(100);
150 expect(asked).toEqual([]);
151 expect(reworks(pull)).toBe(0);
152 expect(open(pull)).toBe(true);
153 expect(rechecks(pull)).toHaveLength(1);
154 });
155
156 test("red outside the diff twice: the second red is the builder's, one try spent, never parked", async () => {
157 asked = [];
158 const pull = await featurePull();
159 pushRed(pull);
160 await until(() => rechecks(pull).length === 1);
161 expect(asked).toEqual([]);
162 expect(reworks(pull)).toBe(0);
163 finish(rechecks(pull)[0]!, "failed");
164 await until(() => reworks(pull) === 1);
165 expect(asked).toEqual([String(pull.number)]);
166 expect(open(pull)).toBe(true);
167 expect(rechecks(pull)).toHaveLength(1);
168 });
169
170 test("red inside the diff: no re-check, the builder's at once", async () => {
171 asked = [];
172 const pull = await featurePull();
173 pushRed(pull, "test/feature.test.ts");
174 await until(() => reworks(pull) === 1);
175 expect(asked).toEqual([String(pull.number)]);
176 expect(open(pull)).toBe(true);
177 expect(rechecks(pull)).toEqual([]);
178 });
179 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs