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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7422-an-issue-whose-body-says-relieves-path-i → main 2 commits fork 2 threads · 2 unresolved
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 | ||
| 516 | 516 | how it stands: _Autonomous · $12.40 of $40 today · 1 of 2 slots_, and why |
| 517 | 517 | it is paused when it is. |
| 518 | 518 | |
| 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 | ||
| 519 | 523 | ## Holding a pull |
| 520 | 524 | |
| 521 | 525 | 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( | ||
| 407 | 407 | new Map( |
| 408 | 408 | [...held.pinned].map(([n, h]) => [n, { path: h.path, byName: getUserById(app.db, h.by)?.username ?? "someone" }]), |
| 409 | 409 | ), |
| 410 | held.relieving, | |
Non-blocking: this argument is not covered by any test. Deleting it leaves the suite green. A driveRepo-level assertion would pin it. |
||
| 410 | 411 | ); |
| 411 | 412 | const ready = held.startable; |
| 412 | 413 | if (slotted.length >= paced.slots) { |
src/crew/held.ts +39 −3
| @@ -34,7 +34,7 @@ import type { App } from "../app.ts"; | ||
| 34 | 34 | import { listCrewRoles, type Agent, type CrewRole } from "../core/agents.ts"; |
| 35 | 35 | import { readyNow, repoGraph, type Graph, type Ranked } from "../core/dependencies.ts"; |
| 36 | 36 | 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"; | |
| 38 | 38 | import { withPlanner } from "../core/limb_replan.ts"; |
| 39 | 39 | import type { Repo } from "../core/repos.ts"; |
| 40 | 40 | import { sealedRowByNumber } from "../core/sealed.ts"; |
| @@ -47,7 +47,7 @@ import { flowOnlyHolds } from "./flow_only.ts"; | ||
| 47 | 47 | import { interruptionOf } from "./interrupted.ts"; |
| 48 | 48 | import { PARK_STRIKES, parkedCount } from "./parks.ts"; |
| 49 | 49 | 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"; | |
| 51 | 51 | import { coveredByRedFix } from "./red_fix.ts"; |
| 52 | 52 | |
| 53 | 53 | export const HOLDS = [ |
| @@ -76,6 +76,8 @@ export interface Held { | ||
| 76 | 76 | readonly by: Readonly<Record<HoldName, readonly Issue[]>>; |
| 77 | 77 | /** Of the `hot_file` ones, those an admin's hold keeps: issue number to the hold that does. */ |
| 78 | 78 | 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 }>; | |
| 79 | 81 | } |
| 80 | 82 | |
| 81 | 83 | /** |
| @@ -141,6 +143,36 @@ export function heldBy( | ||
| 141 | 143 | // The hold an admin set on a file the issue names, if any: the first by hold order. |
| 142 | 144 | const pinOf = (issue: Issue): FileHold | undefined => |
| 143 | 145 | 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 | }; | |
| 144 | 176 | const holdOf = (issue: Issue): HoldName | null => { |
| 145 | 177 | if (busyIssues.has(issue.id)) return "running"; |
| 146 | 178 | if (proposed.has(issue.number)) return "open_pull"; |
| @@ -158,6 +190,7 @@ export function heldBy( | ||
| 158 | 190 | return "hot_file"; |
| 159 | 191 | } |
| 160 | 192 | if (hotOverlap(issue, hot).length > 0) return "hot_file"; |
| 193 | if (waitsOnRelief(issue)) return "hot_file"; | |
| 161 | 194 | return null; |
| 162 | 195 | }; |
| 163 | 196 | const by = Object.fromEntries(HOLDS.map((h) => [h, [] as Issue[]])) as Record< |
| @@ -171,7 +204,7 @@ export function heldBy( | ||
| 171 | 204 | if (hold === null) startable.push(r); |
| 172 | 205 | else by[hold].push(r.issue); |
| 173 | 206 | } |
| 174 | return { startable, by, pinned }; | |
| 207 | return { startable, by, pinned, relieving }; | |
| 175 | 208 | } |
| 176 | 209 | |
| 177 | 210 | /** The count per filter, in `HOLDS` order. */ |
| @@ -259,7 +292,10 @@ export function mapHolds( | ||
| 259 | 292 | if (name === "hot_file") { |
| 260 | 293 | const pin = held.pinned.get(issue.number); |
| 261 | 294 | const top = hotOverlap(issue, hot)[0]; |
| 295 | const rel = held.relieving.get(issue.number); | |
| 262 | 296 | 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}`; | |
| 263 | 299 | else if (top !== undefined) |
| 264 | 300 | detail = `${top.path} is in ${top.pulls} open ${top.pulls === 1 ? "pull" : "pulls"}`; |
| 265 | 301 | } |
src/crew/hot_files.ts +35 −2
| @@ -44,7 +44,26 @@ const QUOTED = /`([\w-][\w.-]*\.[A-Za-z0-9]+)`/g; | ||
| 44 | 44 | /** The repository paths an issue names, in the order it names them, each once. */ |
| 45 | 45 | export function expectedFiles(issue: Pick<Issue, "title" | "body">): string[] { |
| 46 | 46 | 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[] { | |
| 48 | 67 | const found = new Set<string>(); |
| 49 | 68 | for (const pattern of [PATH, QUOTED]) |
| 50 | 69 | for (const match of text.matchAll(pattern)) { |
| @@ -179,7 +198,9 @@ export function hotOverlap( | ||
| 179 | 198 | hot: ReadonlyMap<string, number>, |
| 180 | 199 | ): HotFile[] { |
| 181 | 200 | if (hot.size === 0) return []; |
| 201 | const relieved = new Set(relievedFiles(issue)); | |
| 182 | 202 | return expectedFiles(issue) |
| 203 | .filter((path) => !relieved.has(path)) | |
| 183 | 204 | .flatMap((path) => { |
| 184 | 205 | const pulls = hot.get(path); |
| 185 | 206 | return pulls === undefined ? [] : [{ path, pulls }]; |
| @@ -194,6 +215,8 @@ export interface HotHold { | ||
| 194 | 215 | readonly files: readonly HotFile[]; |
| 195 | 216 | /** The admin's hold (`file_holds.ts`) that keeps this build, with the setter's name. */ |
| 196 | 217 | 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 }; | |
| 197 | 220 | } |
| 198 | 221 | |
| 199 | 222 | /** 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( | ||
| 213 | 236 | issues: readonly Issue[], |
| 214 | 237 | at = new Date(), |
| 215 | 238 | pins: ReadonlyMap<number, { path: string; byName: string }> = new Map(), |
| 239 | relieving: ReadonlyMap<number, { path: string; by: number }> = new Map(), | |
| 216 | 240 | ): void { |
| 217 | 241 | const wide = widePulls(repoId); |
| 218 | 242 | if (wide.length === 0) standingWideBy.delete(repoId); |
| @@ -226,7 +250,14 @@ export function holdHot( | ||
| 226 | 250 | at: at.toISOString(), |
| 227 | 251 | holds: issues.map((i) => { |
| 228 | 252 | 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 | }; | |
| 230 | 261 | }), |
| 231 | 262 | wide, |
| 232 | 263 | }); |
| @@ -250,6 +281,8 @@ export function hotWords(hot: ReadonlyMap<string, number>): string { | ||
| 250 | 281 | /** One sentence for a held build: `#12 waits: src/http/mcp.ts is in 6 open pulls (hot at 5).` */ |
| 251 | 282 | export function hotSays(hold: HotHold): string { |
| 252 | 283 | 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}.`; | |
| 253 | 286 | const files = hold.files |
| 254 | 287 | .slice(0, 3) |
| 255 | 288 | .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 | }); |
new above
offline
No test fails if this argument is removed. Cover holdHot with a relieving map and assert the feed sentence.