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

An issue whose body says `Relieves: <path>` is not held by that file being hot, one such build at a time per file #7431

Merged opened this

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

Merged

scout/forge:scout/7422-an-issue-whose-body-says-relieves-path-i → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/7422-an-issue-whose-body-says-relieves-path-i was merged into main as d1f52d1.

Review

Sign in to review this change.
5 files changed +202 −5
docs/PLANNING.md +4
@@ -516,6 +516,10 @@ person: autonomy re-queues nothing a person must decide. The map page says
516516 how it stands: _Autonomous · $12.40 of $40 today · 1 of 2 slots_, and why
517517 it is paused when it is.
518518
519 ## Relieving a hot file
520
521 A planner may write a `Relieves: <path>` line in an issue only for a change whose purpose is to cut what other pulls must touch in that file (a split, an extraction), and the path must also be on its `Files:` line. Such an issue is not held by that file being hot (three paths at most), but only one at a time per file: while an open pull or a run serves another issue that relieves the path, the second waits (`waits: <path> is already being relieved by #N`), and an admin's pin on the file still holds it. The line is read from any issue body and the author is not checked, on purpose: the exemption lifts only the hot-file count, never the other holds, it admits at most three paths that the issue also lists under `Files:`, and one relieving build per file bounds what a stray `Relieves:` can pile up.
522
519523 ## Holding a pull
520524
521525 A person who writes the repository can say "not this one yet" about any
src/crew/autonomy.ts +1
@@ -407,6 +407,7 @@ export async function driveRepo(
407407 new Map(
408408 [...held.pinned].map(([n, h]) => [n, { path: h.path, byName: getUserById(app.db, h.by)?.username ?? "someone" }]),
409409 ),
410 held.relieving,

No test fails if this argument is removed. Cover holdHot with a relieving map and assert the feed sentence.

Non-blocking: this argument is not covered by any test. Deleting it leaves the suite green. A driveRepo-level assertion would pin it.

410411 );
411412 const ready = held.startable;
412413 if (slotted.length >= paced.slots) {
src/crew/held.ts +39 −3
@@ -34,7 +34,7 @@ import type { App } from "../app.ts";
3434 import { listCrewRoles, type Agent, type CrewRole } from "../core/agents.ts";
3535 import { readyNow, repoGraph, type Graph, type Ranked } from "../core/dependencies.ts";
3636 import { alreadyDone, BY_CREW, DONE_LIKE } from "../core/done_on_main.ts";
37 import type { Issue } from "../core/issues.ts";
37 import { getIssuesByIds, getIssuesByNumbers, type Issue } from "../core/issues.ts";
3838 import { withPlanner } from "../core/limb_replan.ts";
3939 import type { Repo } from "../core/repos.ts";
4040 import { sealedRowByNumber } from "../core/sealed.ts";
@@ -47,7 +47,7 @@ import { flowOnlyHolds } from "./flow_only.ts";
4747 import { interruptionOf } from "./interrupted.ts";
4848 import { PARK_STRIKES, parkedCount } from "./parks.ts";
4949 import { heldFiles, type FileHold } from "./file_holds.ts";
50 import { expectedFiles, hotFiles, hotOverlap } from "./hot_files.ts";
50 import { expectedFiles, hotFiles, hotOverlap, relievedFiles } from "./hot_files.ts";
5151 import { coveredByRedFix } from "./red_fix.ts";
5252
5353 export const HOLDS = [
@@ -76,6 +76,8 @@ export interface Held {
7676 readonly by: Readonly<Record<HoldName, readonly Issue[]>>;
7777 /** Of the `hot_file` ones, those an admin's hold keeps: issue number to the hold that does. */
7878 readonly pinned: ReadonlyMap<number, FileHold>;
79 /** Of the `hot_file` ones, those that wait because another issue already relieves the same path: issue number to that path and issue. */
80 readonly relieving: ReadonlyMap<number, { path: string; by: number }>;
7981 }
8082
8183 /**
@@ -141,6 +143,36 @@ export function heldBy(
141143 // The hold an admin set on a file the issue names, if any: the first by hold order.
142144 const pinOf = (issue: Issue): FileHold | undefined =>
143145 pins.size === 0 ? undefined : expectedFiles(issue).map((f) => pins.get(f)).find((h) => h !== undefined);
146 // Who is relieving which path now: issues with an open pull or a run in flight (#7422).
147 const relievers = new Map<string, number>();
148 const learnRelievers = (): void => {
149 const served = [
150 ...getIssuesByNumbers(app.db, [...proposed.keys()].map((number) => ({ repoId: repo.id, number }))).values(),
151 ...getIssuesByIds(app.db, [...busyIssues].filter((id): id is string => id !== null)).values(),
152 ];
153 for (const i of served)
154 for (const path of relievedFiles(i)) if (!relievers.has(path) || i.number < relievers.get(path)!) relievers.set(path, i.number);
155 };
156 let relieversKnown = false;
157 const relieving = new Map<number, { path: string; by: number }>();
158 const waitsOnRelief = (issue: Issue): boolean => {
159 const mine = relievedFiles(issue).filter((p) => hot.has(p));
160 if (mine.length === 0) return false;
161 if (!relieversKnown) {
162 learnRelievers();
163 relieversKnown = true;
164 }
165 for (const path of mine) {
166 const by = relievers.get(path);
167 if (by !== undefined && by !== issue.number) {
168 relieving.set(issue.number, { path, by });
169 return true;
170 }
171 }
172 // It starts, so no other issue in this pass may relieve the same path.
173 for (const path of mine) relievers.set(path, issue.number);
174 return false;
175 };
144176 const holdOf = (issue: Issue): HoldName | null => {
145177 if (busyIssues.has(issue.id)) return "running";
146178 if (proposed.has(issue.number)) return "open_pull";
@@ -158,6 +190,7 @@ export function heldBy(
158190 return "hot_file";
159191 }
160192 if (hotOverlap(issue, hot).length > 0) return "hot_file";
193 if (waitsOnRelief(issue)) return "hot_file";
161194 return null;
162195 };
163196 const by = Object.fromEntries(HOLDS.map((h) => [h, [] as Issue[]])) as Record<
@@ -171,7 +204,7 @@ export function heldBy(
171204 if (hold === null) startable.push(r);
172205 else by[hold].push(r.issue);
173206 }
174 return { startable, by, pinned };
207 return { startable, by, pinned, relieving };
175208 }
176209
177210 /** The count per filter, in `HOLDS` order. */
@@ -259,7 +292,10 @@ export function mapHolds(
259292 if (name === "hot_file") {
260293 const pin = held.pinned.get(issue.number);
261294 const top = hotOverlap(issue, hot)[0];
295 const rel = held.relieving.get(issue.number);
262296 if (pin !== undefined) detail = `${pin.path} is held by an admin`;
297 else if (top === undefined && rel !== undefined)
298 detail = `waits: ${rel.path} is already being relieved by #${rel.by}`;
263299 else if (top !== undefined)
264300 detail = `${top.path} is in ${top.pulls} open ${top.pulls === 1 ? "pull" : "pulls"}`;
265301 }
src/crew/hot_files.ts +35 −2
@@ -44,7 +44,26 @@ const QUOTED = /`([\w-][\w.-]*\.[A-Za-z0-9]+)`/g;
4444 /** The repository paths an issue names, in the order it names them, each once. */
4545 export function expectedFiles(issue: Pick<Issue, "title" | "body">): string[] {
4646 const listed = issue.body.match(/^Files:(.*)$/m);
47 const text = listed ? listed[1]! : `${issue.title}\n${issue.body}`;
47 return pathsIn(listed ? listed[1]! : `${issue.title}\n${issue.body}`);
48 }
49
50 /** The most paths one issue may relieve. */
51 const MAX_RELIEVED = 3;
52
53 /**
54 * The paths an issue relieves (#7422): named on a line starting `Relieves:`
55 * and also on its `Files:` line, at most three. The change exists to shrink
56 * what other pulls must touch there, so the file being hot does not hold it.
57 */
58 export function relievedFiles(issue: Pick<Issue, "body">): string[] {
59 const files = issue.body.match(/^Files:(.*)$/m);
60 const line = issue.body.match(/^Relieves:(.*)$/m);

This reads Relieves: from any issue body regardless of author. Gate on a trusted author or document the choice.

61 if (!files || !line) return [];
62 const listed = new Set(pathsIn(files[1]!));
63 return pathsIn(line[1]!).filter((p) => listed.has(p)).slice(0, MAX_RELIEVED);
64 }
65
66 function pathsIn(text: string): string[] {
4867 const found = new Set<string>();
4968 for (const pattern of [PATH, QUOTED])
5069 for (const match of text.matchAll(pattern)) {
@@ -179,7 +198,9 @@ export function hotOverlap(
179198 hot: ReadonlyMap<string, number>,
180199 ): HotFile[] {
181200 if (hot.size === 0) return [];
201 const relieved = new Set(relievedFiles(issue));
182202 return expectedFiles(issue)
203 .filter((path) => !relieved.has(path))
183204 .flatMap((path) => {
184205 const pulls = hot.get(path);
185206 return pulls === undefined ? [] : [{ path, pulls }];
@@ -194,6 +215,8 @@ export interface HotHold {
194215 readonly files: readonly HotFile[];
195216 /** The admin's hold (`file_holds.ts`) that keeps this build, with the setter's name. */
196217 readonly pin?: { readonly path: string; readonly byName: string };
218 /** The path another issue (`#by`) is already relieving, which keeps this one waiting. */
219 readonly relieving?: { readonly path: string; readonly by: number };
197220 }
198221
199222 /** The pulls too wide to count as the picker last saw them, per repository, held builds or not. */
@@ -213,6 +236,7 @@ export function holdHot(
213236 issues: readonly Issue[],
214237 at = new Date(),
215238 pins: ReadonlyMap<number, { path: string; byName: string }> = new Map(),
239 relieving: ReadonlyMap<number, { path: string; by: number }> = new Map(),
216240 ): void {
217241 const wide = widePulls(repoId);
218242 if (wide.length === 0) standingWideBy.delete(repoId);
@@ -226,7 +250,14 @@ export function holdHot(
226250 at: at.toISOString(),
227251 holds: issues.map((i) => {
228252 const pin = pins.get(i.number);
229 return { number: i.number, title: i.title, files: hotOverlap(i, hot), ...(pin === undefined ? {} : { pin }) };
253 const rel = relieving.get(i.number);
254 return {
255 number: i.number,
256 title: i.title,
257 files: hotOverlap(i, hot),
258 ...(pin === undefined ? {} : { pin }),
259 ...(rel === undefined ? {} : { relieving: rel }),
260 };
230261 }),
231262 wide,
232263 });
@@ -250,6 +281,8 @@ export function hotWords(hot: ReadonlyMap<string, number>): string {
250281 /** One sentence for a held build: `#12 waits: src/http/mcp.ts is in 6 open pulls (hot at 5).` */
251282 export function hotSays(hold: HotHold): string {
252283 if (hold.pin !== undefined) return `#${hold.number} waits: ${hold.pin.byName} holds new work on ${hold.pin.path}.`;
284 if (hold.relieving !== undefined)
285 return `#${hold.number} waits: ${hold.relieving.path} is already being relieved by #${hold.relieving.by}.`;
253286 const files = hold.files
254287 .slice(0, 3)
255288 .map((f) => `${f.path} is in ${f.pulls} open pulls`)
test/hot-files-relieves.test.ts added +123
@@ -0,0 +1,123 @@
1 import { afterAll, beforeAll, expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp, type App } from "../src/app.ts";
6 import { openIssue } from "../src/actions/issues.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import type { Ranked } from "../src/core/dependencies.ts";
9 import type { Issue } from "../src/core/issues.ts";
10 import { getRepoById, type Repo } from "../src/core/repos.ts";
11 import type { Run } from "../src/core/runs.ts";
12 import { createUser, type User } from "../src/core/users.ts";
13 import { holdFile } from "../src/crew/file_holds.ts";
14 import { heldBy, mapHolds } from "../src/crew/held.ts";
15 import { holdHot, hotOverlap, hotSays, relievedFiles, rememberPullFiles, standingHot } from "../src/crew/hot_files.ts";
16
17 const HOT = "src/tui/forge.ts";
18
19 test("only paths on both lines are relieved, at most three", () => {
20 const body = (files: string, rel: string) => `Files: ${files}\nRelieves: ${rel}`;
21 expect(relievedFiles({ body: body("src/a.ts, src/b.ts", "src/a.ts, src/c.ts") })).toEqual(["src/a.ts"]);
22 expect(relievedFiles({ body: "Relieves: src/a.ts" })).toEqual([]);
23 expect(relievedFiles({ body: body("src/a.ts, src/b.ts, src/c.ts, src/d.ts", "src/a.ts src/b.ts src/c.ts src/d.ts") })).toHaveLength(3);
24 });
25
26 test("a relieved hot file is left out of the overlap, an unlisted one is not", () => {
27 const hot = new Map([[HOT, 7]]);
28 expect(hotOverlap({ title: "t", body: `Files: ${HOT}\nRelieves: ${HOT}` }, hot)).toEqual([]);
29 expect(hotOverlap({ title: "t", body: `Files: ${HOT}\nRelieves: src/other.ts` }, hot)).toEqual([{ path: HOT, pulls: 7 }]);
30 expect(hotOverlap({ title: "t", body: `Relieves: ${HOT}\nSee ${HOT}` }, hot)).toEqual([{ path: HOT, pulls: 7 }]);
31 });
32
33 let dataDir: string;
34 let app: App;
35 let alice: User;
36 let repo: Repo;
37
38 beforeAll(async () => {
39 dataDir = await mkdtemp(join(tmpdir(), "forge-relieves-"));
40 app = createApp({ dataDir, port: 0 });
41 const a = await createUser(app.db, {
42 origin: "seed",
43 trusted: true,
44 username: "alice",
45 email: "[email protected]",
46 password: "a-good-passphrase",
47 adminIfFirst: true,
48 });
49 if (!a.ok) throw new Error(a.error);
50 alice = a.value;
51 const made = await createRepo(app, alice, { name: "engine", visibility: "private" });
52 if (!made.ok) throw new Error(String(made.error));
53 repo = getRepoById(app.db, made.value.id)!;
54 rememberPullFiles(repo.id, Array.from({ length: 6 }, (_, i) => ({ number: 900 + i, files: [HOT] })));
55 });
56
57 afterAll(async () => {
58 app.close();
59 await rm(dataDir, { recursive: true, force: true });
60 });
61
62 function issue(title: string, body: string): Issue {
63 const opened = openIssue(app, alice, getRepoById(app.db, repo.id)!, { title, body });
64 if (!opened.ok) throw new Error(String(opened.error));
65 return opened.value;
66 }
67
68 const ranked = (i: Issue) => ({ issue: i }) as unknown as Ranked;
69 const running = (i: Issue) => ({ issueId: i.id }) as unknown as Run;
70 const sort = (issues: Issue[], active: Run[] = []) => heldBy(app, repo, issues.map(ranked), [], active);
71
72 test("a relieving issue starts on a hot file, a plain one waits", () => {
73 const relieve = issue("Split palette entries", `Files: ${HOT}\nRelieves: ${HOT}`);
74 const plain = issue("Touch the hot file", `Files: ${HOT}`);
75 const held = sort([relieve, plain]);
76 expect(held.startable.map((r) => r.issue.number)).toEqual([relieve.number]);
77 expect(held.by.hot_file.map((i) => i.number)).toEqual([plain.number]);
78 });
79
80 test("a second relieving issue on the same path waits for the one in flight", () => {
81 const first = issue("Split palette, part 1", `Files: ${HOT}\nRelieves: ${HOT}`);
82 const second = issue("Split palette, part 2", `Files: ${HOT}\nRelieves: ${HOT}`);
83 const held = sort([second], [running(first)]);
84 expect(held.startable).toEqual([]);
85 expect(held.by.hot_file.map((i) => i.number)).toEqual([second.number]);
86 const relieving = held.relieving.get(second.number)!;
87 expect(relieving).toEqual({ path: HOT, by: first.number });
88 expect(mapHolds(repo, held).holds.get(second.number)!.detail).toBe(
89 `waits: ${HOT} is already being relieved by #${first.number}`,
90 );
91 expect(hotSays({ number: second.number, title: "", files: [], relieving })).toBe(
92 `#${second.number} waits: ${HOT} is already being relieved by #${first.number}.`,
93 );
94 // Both ready at once: only the first starts.
95 const both = sort([first, second]);
96 expect(both.startable.map((r) => r.issue.number)).toEqual([first.number]);
97 });
98
99 test("a path on Relieves: but not on Files: changes nothing", () => {
100 const sly = issue("Sneak in", `Files: src/cold.ts\nRelieves: ${HOT}\nSee ${HOT}`);
101 const hot = new Map([[HOT, 6]]);
102 expect(hotOverlap(sly, hot)).toEqual([]); // Files: names no hot file, as before
103 const naming = issue("Name it", `Files: ${HOT}\nRelieves: src/cold.ts`);
104 expect(sort([naming]).by.hot_file.map((i) => i.number)).toEqual([naming.number]);
105 });
106
107 test("a pinned file still holds a relieving issue", () => {
108 const relieve = issue("Split again", `Files: ${HOT}\nRelieves: ${HOT}`);
109 const pinned = holdFile(app.db, repo.id, HOT, alice.id);
110 expect(pinned.ok).toBe(true);
111 const held = sort([relieve]);
112 expect(held.startable).toEqual([]);
113 expect(held.pinned.get(relieve.number)?.path).toBe(HOT);
114 });
115
116 test("the picker's record carries the wait, and the feed sentence reads from it", () => {
117 const second = issue("Split palette, part 3", `Files: ${HOT}\nRelieves: ${HOT}`);
118 holdHot(repo.id, [second], new Date(), new Map(), new Map([[second.number, { path: HOT, by: 7299 }]]));
119 const hold = standingHot(repo.id)!.holds[0]!;
120 expect(hold.relieving).toEqual({ path: HOT, by: 7299 });
121 expect(hotSays(hold)).toBe(`#${second.number} waits: ${HOT} is already being relieved by #7299.`);
122 holdHot(repo.id, []);
123 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs