Public: anyone can read this; the crew can change it.
An issue the crew found done on main is held as done_on_main, not stalled #2516
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2513-an-issue-the-crew-found-done-on-main-is → main 2 commits fork 2 threads · 2 unresolved
4 files
changed +44
−6
README.md +2 −1
| @@ -822,7 +822,8 @@ the hold; an issue handed to the planner is still owed its pass. | ||
| 822 | 822 | Every tick says why each ready issue a builder cannot start is held, counted |
| 823 | 823 | under the first filter that holds it (`crew/held.ts`): `held: running=1 |
| 824 | 824 | open_pull=10 stalled=12 run_cap=11` — a run on it, an open pull proposing it, |
| 825 | the planner's, stalled, at its run cap, parked twice, covered by a red-main | |
| 825 | the planner's, found done on main and waiting for a person to close it | |
| 826 | (`done_on_main`, never re-planned), stalled, at its run cap, parked twice, covered by a red-main | |
| 826 | 827 | fix, or a setting's finding. `/flow` draws the same per autonomous app as one |
| 827 | 828 | bar, and the terminal's Flow command prints it as a line per app |
| 828 | 829 | (`flow/held.ts`, kept 5 s). When nothing is startable while builder slots are free and some of the |
src/crew/held.ts +5 −1
| @@ -9,6 +9,8 @@ | ||
| 9 | 9 | * - `running`: a run is in flight on it. |
| 10 | 10 | * - `open_pull`: an open pull already proposes it (`direct.ts`). |
| 11 | 11 | * - `planner`: the planner or a reviewer holds it, not a builder. |
| 12 | * - `done_on_main`: the crew found it done on the base branch and waits for | |
| 13 | * a person to close it (`done_on_main.ts`); nothing is left to plan. | |
| 12 | 14 | * - `stalled`: a builder ran on it and left without a pull, and nobody |
| 13 | 15 | * who directs the crew has spoken since. |
| 14 | 16 | * - `run_cap`: it has used its runs for the day (`guard.ts`). |
| @@ -23,7 +25,7 @@ | ||
| 23 | 25 | import type { App } from "../app.ts"; |
| 24 | 26 | import { listCrewRoles, type Agent, type CrewRole } from "../core/agents.ts"; |
| 25 | 27 | import { readyNow, repoGraph, type Ranked } from "../core/dependencies.ts"; |
| 26 | import { BY_CREW, DONE_LIKE } from "../core/done_on_main.ts"; | |
| 28 | import { alreadyDone, BY_CREW, DONE_LIKE } from "../core/done_on_main.ts"; | |
| 27 | 29 | import type { Issue } from "../core/issues.ts"; |
| 28 | 30 | import type { Repo } from "../core/repos.ts"; |
| 29 | 31 | import { listActive, type Run } from "../core/runs.ts"; |
| @@ -40,6 +42,7 @@ export const HOLDS = [ | ||
| 40 | 42 | "running", |
| 41 | 43 | "open_pull", |
| 42 | 44 | "planner", |
| 45 | "done_on_main", | |
New hold name: every HOLDS consumer needs a matching style. |
||
| 43 | 46 | "stalled", |
| 44 | 47 | "run_cap", |
| 45 | 48 | "parked_twice", |
| @@ -116,6 +119,7 @@ export function heldBy( | ||
| 116 | 119 | if (busyIssues.has(issue.id)) return "running"; |
| 117 | 120 | if (proposed.has(issue.number)) return "open_pull"; |
| 118 | 121 | if (issue.assigneeId !== null && planners.has(issue.assigneeId)) return "planner"; |
| 122 | if (alreadyDone(app.db, [issue.id]).has(issue.id)) return "done_on_main"; | |
| 119 | 123 | if (stalled(app, repo, issue)) return "stalled"; |
| 120 | 124 | if (atRunCap(app, repo, issue)) return "run_cap"; |
| 121 | 125 | if (parkedCount(app.db, repo, issue.id) >= PARK_STRIKES) return "parked_twice"; |
src/web/pages/flow.ts +1
| @@ -72,6 +72,7 @@ const css = /* css */ ` | ||
| 72 | 72 | .held-running { fill: var(--accent); } |
| 73 | 73 | .held-open_pull { fill: var(--merged); } |
| 74 | 74 | .held-planner { fill: var(--primary); } |
| 75 | .held-done_on_main { fill: var(--add-fg); } | |
| 75 | 76 | .held-stalled { fill: var(--attn); } |
| 76 | 77 | .held-run_cap { fill: var(--danger); } |
| 77 | 78 | .held-parked_twice { fill: var(--del-fg); } |
test/crew-held.test.ts +36 −4
| @@ -18,12 +18,13 @@ import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/r | ||
| 18 | 18 | import { getAgentByName, setCrewRole } from "../src/core/agents.ts"; |
| 19 | 19 | import { getIssueById, type Issue } from "../src/core/issues.ts"; |
| 20 | 20 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| 21 | import { DONE_NOTE } from "../src/core/done_on_main.ts"; | |
| 21 | 22 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; |
| 22 | 23 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 23 | 24 | import { driveRepo } from "../src/crew/autonomy.ts"; |
| 24 | 25 | import { taskPrompt } from "../src/crew/brief.ts"; |
| 25 | 26 | import { ISSUE_RUN_CAP } from "../src/crew/guard.ts"; |
| 26 | import { heldCounts, heldNow, heldWords, stuckOf } from "../src/crew/held.ts"; | |
| 27 | import { heldCounts, heldNow, heldWords, replannable, stuckOf } from "../src/crew/held.ts"; | |
| 27 | 28 | import { PARK_NOTE } from "../src/crew/parks.ts"; |
| 28 | 29 | import { keyPart } from "../src/crew/sentinel.ts"; |
| 29 | 30 | import { HELD_TTL_MS, readHeld } from "../src/flow/held.ts"; |
| @@ -145,6 +146,9 @@ describe("the tick says why each ready issue cannot start", () => { | ||
| 145 | 146 | const planned = issue(repo, "The planner's"); |
| 146 | 147 | expect(assignIssue(app, alice, repo, planned, atlas).ok).toBe(true); |
| 147 | 148 | |
| 149 | const done = issue(repo, "Done already"); | |
| 150 | addComment(app, mason, repo, done, `${DONE_NOTE}${"b".repeat(40)}`); | |
| 151 | ||
| 148 | 152 | const stuck = issue(repo, "Stalled"); |
| 149 | 153 | ran(repo, stuck, mason, "declined: the ask is unclear"); |
| 150 | 154 | |
| @@ -185,6 +189,7 @@ describe("the tick says why each ready issue cannot start", () => { | ||
| 185 | 189 | expect(named("running")).toEqual([running.number]); |
| 186 | 190 | expect(named("open_pull")).toEqual([proposed.number]); |
| 187 | 191 | expect(named("planner")).toEqual([planned.number]); |
| 192 | expect(named("done_on_main")).toEqual([done.number]); | |
| 188 | 193 | expect(named("stalled")).toEqual([stuck.number]); |
| 189 | 194 | expect(named("run_cap")).toEqual([capped.number]); |
| 190 | 195 | expect(named("parked_twice")).toEqual([parked.number]); |
| @@ -192,12 +197,32 @@ describe("the tick says why each ready issue cannot start", () => { | ||
| 192 | 197 | expect(named("setting")).toEqual([finding.number]); |
| 193 | 198 | expect(heldCounts(held).open_pull).toBe(1); |
| 194 | 199 | expect(heldWords(held)).toBe( |
| 195 | "running=1 open_pull=1 planner=1 stalled=1 run_cap=1 parked_twice=1 red_fix=1 setting=1", | |
| 200 | "running=1 open_pull=1 planner=1 done_on_main=1 stalled=1 run_cap=1 parked_twice=1 red_fix=1 setting=1", | |
| 196 | 201 | ); |
| 197 | 202 | // Something can start: the planner is not told the crew is stuck. |
| 198 | 203 | expect(stuckOf(held)).toBeUndefined(); |
| 199 | 204 | }); |
| 200 | 205 | |
| 206 | test("an issue found done on main is held as done_on_main, never re-planned, until the crew parks it", async () => { | |
| 207 | const { repo, mason } = await crewed(app, alice, "closing"); | |
| 208 | const fresh = () => getRepoById(app.db, repo.id)!; | |
| 209 | const done = issue(fresh(), "Already shipped"); | |
| 210 | addComment(app, mason, fresh(), done, `${DONE_NOTE}${"c".repeat(40)}`); | |
| 211 | const held = heldNow(app, fresh()); | |
| 212 | expect(held.by.done_on_main.map((i) => i.number)).toEqual([done.number]); | |
| 213 | expect(held.by.stalled).toHaveLength(0); | |
| 214 | expect(replannable(held)).toHaveLength(0); | |
| 215 | expect(heldWords(held)).toBe("done_on_main=1"); | |
| 216 | expect(stuckOf(held)).toBeUndefined(); | |
| 217 | ||
| 218 | addComment(app, mason, fresh(), getIssueById(app.db, done.id)!, `${PARK_NOTE}not done after all`); | |
| 219 | // The builder that tried it since left without a pull: stalled, so the planner's. | |
| 220 | ran(fresh(), done, mason, "declined: it is not there", 0); | |
| 221 | const again = heldNow(app, fresh()); | |
| 222 | expect(again.by.done_on_main).toHaveLength(0); | |
| 223 | expect(replannable(again).map((i) => i.number)).toEqual([done.number]); | |
| 224 | }); | |
| 225 | ||
| 201 | 226 | test("nothing startable with a slot free asks the planner once, with the breakdown, whatever the plate", async () => { |
| 202 | 227 | const { repo, atlas, mason } = await crewed(app, alice, "stuck"); |
| 203 | 228 | const fresh = () => getRepoById(app.db, repo.id)!; |
| @@ -272,6 +297,9 @@ describe("the tick says why each ready issue cannot start", () => { | ||
| 272 | 297 | const shut = await crewed(app, alice, "shut-door"); |
| 273 | 298 | for (const { repo, mason } of [open, shut]) { |
| 274 | 299 | ran(repo, issue(repo, "Stalled"), mason, "declined: too big", 10); |
| 300 | if (repo === open.repo) { | |
| 301 | addComment(app, mason, repo, issue(repo, "Shipped"), `${DONE_NOTE}${"d".repeat(40)}`); | |
| 302 | } | |
| 275 | 303 | const capped = issue(repo, "Capped"); |
| 276 | 304 | for (let n = 0; n < ISSUE_RUN_CAP; n++) ran(repo, capped, mason, `opened pull #${90 + n}`, 20 - n, "ok"); |
| 277 | 305 | } |
| @@ -290,6 +318,10 @@ describe("the tick says why each ready issue cannot start", () => { | ||
| 290 | 318 | expect(row).toContain('class="held-bar"'); |
| 291 | 319 | expect(row).toContain('<span class="mono">startable=0</span>'); |
| 292 | 320 | expect(row).toContain('<span class="mono">stalled=1</span>'); |
| 321 | expect(row).toContain('<span class="mono">done_on_main=1</span>'); | |
| 322 | expect(row).toContain('data-held="done_on_main"'); | |
| 323 | expect(row).toContain('<rect class="held-done_on_main"'); | |
| 324 | expect(page).toContain(".held-done_on_main { fill:"); | |
| 293 | 325 | expect(row).toContain('<span class="mono">run_cap=1</span>'); |
| 294 | 326 | expect(page).toContain('data-held-repo="alice/shut-door"'); |
| 295 | 327 | |
| @@ -297,10 +329,10 @@ describe("the tick says why each ready issue cannot start", () => { | ||
| 297 | 329 | // reader the public one alone. |
| 298 | 330 | const owner = flowLines(app.db, new Date(), [], undefined, readHeld(app, true)).join("\n"); |
| 299 | 331 | expect(owner).toContain("Ready, and why it waits:"); |
| 300 | expect(owner).toContain("alice/open-door startable=0 stalled=1 run_cap=1"); | |
| 332 | expect(owner).toContain("alice/open-door startable=0 done_on_main=1 stalled=1"); | |
| 301 | 333 | expect(owner).toContain("alice/shut-door startable=0 stalled=1 run_cap=1"); |
| 302 | 334 | const agent = flowLines(app.db, new Date(), [], { queues: [] }, readHeld(app, false)).join("\n"); |
| 303 | expect(agent).toContain("alice/open-door startable=0 stalled=1 run_cap=1"); | |
| 335 | expect(agent).toContain("alice/open-door startable=0 done_on_main=1 stalled=1"); | |
| 304 | 336 | expect(agent).not.toContain("shut-door"); |
| 305 | 337 | expect(heldLines([], 58)).toEqual(["Ready, and why it waits: no repository runs on its own."]); |
| 306 | 338 | }); |
new above
offline
This line runs past the wrap width of the lines around it; rewrap the paragraph.