Public: anyone can read this; the crew can change it.
An issue whose pull landed with boxes unticked goes to a person, not back to a builder #8497
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8482-an-issue-whose-pull-landed-with-boxes-un → main 1 commit fork
7 files
changed +244
−2
docs/CREW.md +2 −1
| @@ -278,7 +278,8 @@ Every tick says why each ready issue a builder cannot start is held, counted | ||
| 278 | 278 | under the first filter that holds it (`crew/held.ts`): `held: running=1 |
| 279 | 279 | open_pull=10 stalled=12 run_cap=11` — a run on it, an open pull proposing it, |
| 280 | 280 | the planner's, found done on main and waiting for a person to close it |
| 281 | (`done_on_main`, never re-planned), stalled, at its run cap, parked twice, covered by a red-main | |
| 281 | (`done_on_main`, never re-planned), shipped by a merged pull that left boxes | |
| 282 | unticked and waiting for a person to answer (`landed`, `crew/landed.ts`), stalled, at its run cap, parked twice, covered by a red-main | |
| 282 | 283 | fix, or a setting's finding. The flow panel on `/boards` draws the same per autonomous app as one |
| 283 | 284 | bar, and the terminal's Flow command prints it as a line per app |
| 284 | 285 | (`flow/held.ts`, kept 5 s). Of the startable, what the owner raised again |
docs/features/done-on-main.md +1
| @@ -4,5 +4,6 @@ | ||
| 4 | 4 | |
| 5 | 5 | - When the newest close or reopen of an issue is a reopen by a real, non-agent user, nothing the crew notes about the base closes it again: not live triage, not a superseded pull carrying `Closes #N`. A reopen with no actor does not count as a person's. A person closing it, or a pull landing for it, ends the reopen. |
| 6 | 6 | - An issue body with unticked `- [ ]` items holds a `Closes #N` open: neither a landing nor a superseded pull closes it. The pull gets one comment, `Left #N open: …`, naming the items, and it reaches viewers live as `pull.commented`. Checkboxes inside fenced code blocks are examples and are not counted. |
| 7 | - A landing that leaves an issue open also says so on the issue (#8482): `Left open after pull #P landed: …`, naming the pull and the items, live as `issue.commented`. The crew does not pick it again — it is held as `landed` (`crew/landed.ts`) — until a person who may direct the crew comments on it, edits it (ticking a box is an edit) or reopens it. An agent's comment does not lift it, nor does the landing's own note. | |
| 7 | 8 | |
| 8 | 9 | Not done: no setting turns this off; tick the items, or close the issue by hand. |
src/actions/pulls.ts +17 −1
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { addComment as addIssueComment } from "../core/issues.ts"; |
| 8 | import { leftNote, unticked } from "../core/done_on_main.ts"; | |
| 8 | import { landedNote, leftNote, unticked } from "../core/done_on_main.ts"; | |
| 9 | 9 | import { resolveOpen } from "../core/decisions.ts"; |
| 10 | 10 | import type { App } from "../app.ts"; |
| 11 | 11 | import { landVision } from "../crew/vision.ts"; |
| @@ -467,6 +467,22 @@ export async function mergePull( | ||
| 467 | 467 | payload: { number: pull.number, body, commentId: noted.value.id }, |
| 468 | 468 | }); |
| 469 | 469 | } |
| 470 | // And on the issue, so whoever reads it sees why it is still open; | |
| 471 | // the picker holds it until a person answers (`crew/landed.ts`). | |
| 472 | const why = landedNote(pull.number, left); | |
| 473 | const told = addIssueComment(app.db, { | |
| 474 | issueId: issue.id, | |
| 475 | authorId: writer.value.id, | |
| 476 | body: why, | |
| 477 | }); | |
| 478 | if (told.ok) { | |
| 479 | app.bus.append({ | |
| 480 | kind: "issue.commented", | |
| 481 | repoId: repo.id, | |
| 482 | actorId: writer.value.id, | |
| 483 | payload: { number: issue.number, body: why, commentId: told.value.id }, | |
| 484 | }); | |
| 485 | } | |
| 470 | 486 | continue; |
| 471 | 487 | } |
| 472 | 488 | const closed = setIssueStateCore(app.db, issue.id, "closed"); |
src/core/done_on_main.ts +14
| @@ -104,3 +104,17 @@ export function leftNote(number: number, left: readonly string[]): string { | ||
| 104 | 104 | .slice(0, 3) |
| 105 | 105 | .join("; ")}${left.length > 3 ? "; …" : ""}).`; |
| 106 | 106 | } |
| 107 | ||
| 108 | /** What the note on an issue left open by a landing starts with (#8482). */ | |
| 109 | export const LANDED_LEAD = "Left open after pull #"; | |
| 110 | ||
| 111 | /** | |
| 112 | * The note the issue itself carries when a pull that closes it lands with | |
| 113 | * boxes unticked: which pull, what is left, and that the crew waits for a | |
| 114 | * person rather than sending a builder to redo shipped work. | |
| 115 | */ | |
| 116 | export function landedNote(pull: number, left: readonly string[]): string { | |
| 117 | return `${LANDED_LEAD}${pull} landed: ${left.length} unticked ${left.length === 1 ? "item remains" : "items remain"} (${left | |
| 118 | .slice(0, 3) | |
| 119 | .join("; ")}${left.length > 3 ? "; …" : ""}). Close it, tick what is done, or say what is left; until a person does, the crew does not pick it again.`; | |
| 120 | } |
src/crew/held.ts +7
| @@ -14,6 +14,9 @@ | ||
| 14 | 14 | * planner has not answered (`core/limb_replan.ts`). |
| 15 | 15 | * - `done_on_main`: the crew found it done on the base branch and waits for |
| 16 | 16 | * a person to close it (`done_on_main.ts`); nothing is left to plan. |
| 17 | * - `landed`: a merged pull closes it, but it stayed open with boxes | |
| 18 | * unticked, and no person has answered since (`landed.ts`): a builder | |
| 19 | * would only find the pull landed. | |
| 17 | 20 | * - `stalled`: a builder ran on it and left without a pull, and nobody |
| 18 | 21 | * who directs the crew has spoken since. The next tick parks it in the |
| 19 | 22 | * crew's name, which frees it (`stalls.ts`). |
| @@ -42,6 +45,7 @@ import type { Stuck } from "./brief.ts"; | ||
| 42 | 45 | import { proposedWork, speaksForCrew } from "./direct.ts"; |
| 43 | 46 | import { capReached, runCapOf } from "./guard.ts"; |
| 44 | 47 | import { interruptionOf } from "./interrupted.ts"; |
| 48 | import { landedUnanswered } from "./landed.ts"; | |
| 45 | 49 | import { PARK_STRIKES, parkedCount } from "./parks.ts"; |
| 46 | 50 | import { heldFiles, type FileHold } from "./file_holds.ts"; |
| 47 | 51 | import { expectedFiles, hotFiles, hotOverlap, pullsTouching, relievedFiles } from "./hot_files.ts"; |
| @@ -53,6 +57,7 @@ export const HOLDS = [ | ||
| 53 | 57 | "planner", |
| 54 | 58 | "with_planner", |
| 55 | 59 | "done_on_main", |
| 60 | "landed", | |
| 56 | 61 | "stalled", |
| 57 | 62 | "run_cap", |
| 58 | 63 | "parked_twice", |
| @@ -175,6 +180,7 @@ export function heldBy( | ||
| 175 | 180 | if (issue.assigneeId !== null && planners.has(issue.assigneeId)) return "planner"; |
| 176 | 181 | if (handed.has(issue.id)) return "with_planner"; |
| 177 | 182 | if (alreadyDone(app.db, [issue.id]).has(issue.id)) return "done_on_main"; |
| 183 | if (landedUnanswered(app.db, repo, issue) !== undefined) return "landed"; | |
| 178 | 184 | if (stalled(app, repo, issue)) return "stalled"; |
| 179 | 185 | if (atRunCap(app, repo, issue)) return "run_cap"; |
| 180 | 186 | if (parkedCount(app.db, repo, issue.id) >= PARK_STRIKES) return "parked_twice"; |
| @@ -262,6 +268,7 @@ const HOLD_WORDS: Readonly<Record<HoldName, string>> = { | ||
| 262 | 268 | planner: "the planner or a reviewer holds it", |
| 263 | 269 | with_planner: "its limb waits on the planner", |
| 264 | 270 | done_on_main: "found done on the base branch; waits for a person to close it", |
| 271 | landed: "a merged pull closes it with boxes unticked; waits for a person", | |
| 265 | 272 | stalled: "a builder ran on it and left without a pull", |
| 266 | 273 | run_cap: "it has used its runs for the day", |
| 267 | 274 | parked_twice: "parked twice; it is the planner's", |
src/crew/landed.ts added +50
| @@ -0,0 +1,50 @@ | ||
| 1 | /** | |
| 2 | * An open issue a merged pull says it closes (#8482). A landing closes what | |
| 3 | * it names only when every box is ticked (`mergePull`); otherwise the issue | |
| 4 | * stays open with a note saying which pull landed and what is left. Handing | |
| 5 | * it to a builder then sends them to redo shipped work, so the picker holds | |
| 6 | * it (`held.ts`, `landed`) until a person answers: comments, edits the body | |
| 7 | * — ticking is an edit — or reopens it. Closing it takes it off the map. | |
| 8 | * | |
| 9 | * Only an issue with a box still unticked is read: one with none left that a | |
| 10 | * merge did not close was reopened since, or is the plan's own (umbrellas, | |
| 11 | * the Later gate), which no landing closes whatever its boxes say. | |
| 12 | */ | |
| 13 | ||
| 14 | import type { Database } from "bun:sqlite"; | |
| 15 | import { LANDED_LEAD, unticked } from "../core/done_on_main.ts"; | |
| 16 | import type { Issue } from "../core/issues.ts"; | |
| 17 | import { mergedPullClosing, type Pull } from "../core/pulls.ts"; | |
| 18 | import type { Repo } from "../core/repos.ts"; | |
| 19 | import { getUserById } from "../core/users.ts"; | |
| 20 | import { mayDirectCrew } from "./direct.ts"; | |
| 21 | ||
| 22 | /** The landing's own note on the issue, which is not an answer to it. */ | |
| 23 | const NOT_THE_NOTE = `NOT (e.kind = 'issue.commented' | |
| 24 | AND json_extract(e.payload, '$.body') LIKE '${LANDED_LEAD.replace(/[%_']/g, "")}%')`; | |
| 25 | ||
| 26 | /** | |
| 27 | * The merged pull that shipped `issue` while it stays open with boxes | |
| 28 | * unticked, when no person who may direct the crew has commented on it, | |
| 29 | * edited it or reopened it since that pull landed. Undefined otherwise. | |
| 30 | * Walks `event_repo_kind_number_at`. | |
| 31 | */ | |
| 32 | export function landedUnanswered(db: Database, repo: Repo, issue: Issue): Pull | undefined { | |
| 33 | if (issue.kind !== "issue" || issue.state !== "open") return undefined; | |
| 34 | if (unticked(issue.body).length === 0) return undefined; | |
| 35 | const pull = mergedPullClosing(db, repo.id, issue.number); | |
| 36 | if (pull === undefined || pull.mergedAt === null) return undefined; | |
| 37 | const actors = db | |
| 38 | .query<{ actor: string }, [string, number, string]>( | |
| 39 | `SELECT DISTINCT e.actor_id AS actor FROM event e | |
| 40 | WHERE e.repo_id = ?1 AND e.kind IN ('issue.commented', 'issue.edited', 'issue.reopened') | |
| 41 | AND json_extract(e.payload, '$.number') = ?2 AND e.created_at >= ?3 | |
| 42 | AND e.actor_id IS NOT NULL AND ${NOT_THE_NOTE}`, | |
| 43 | ) | |
| 44 | .all(repo.id, issue.number, pull.mergedAt); | |
| 45 | const answered = actors.some((r) => { | |
| 46 | const who = getUserById(db, r.actor); | |
| 47 | return who !== undefined && who.kind !== "agent" && mayDirectCrew(db, repo, who); | |
| 48 | }); | |
| 49 | return answered ? undefined : pull; | |
| 50 | } |
test/landed-held.test.ts added +153
| @@ -0,0 +1,153 @@ | ||
| 1 | /** | |
| 2 | * #8482: an issue whose pull landed with boxes unticked goes to a person, | |
| 3 | * not back to a builder. The landing notes it on the issue, the picker | |
| 4 | * holds it under `landed`, and a person's comment or edit since the merge | |
| 5 | * makes it pickable again; an agent's word does not. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 13 | import { addComment, editIssue, openIssue } from "../src/actions/issues.ts"; | |
| 14 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 15 | import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 16 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 17 | import { landedNote } from "../src/core/done_on_main.ts"; | |
| 18 | import { getIssueById, listComments, type Issue } from "../src/core/issues.ts"; | |
| 19 | import { getPull } from "../src/core/pulls.ts"; | |
| 20 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 21 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 22 | import { driveRepo } from "../src/crew/autonomy.ts"; | |
| 23 | import { heldNow, heldWords } from "../src/crew/held.ts"; | |
| 24 | import { landedUnanswered } from "../src/crew/landed.ts"; | |
| 25 | ||
| 26 | describe("a landing with boxes unticked waits for a person", () => { | |
| 27 | let dir: string; | |
| 28 | let app: App; | |
| 29 | let alice: User; | |
| 30 | let mason: User; | |
| 31 | let repo: Repo; | |
| 32 | const fresh = () => getRepoById(app.db, repo.id)!; | |
| 33 | const now = (on: Issue) => getIssueById(app.db, on.id)!; | |
| 34 | const issue = (title: string, body: string): Issue => { | |
| 35 | const opened = openIssue(app, alice, fresh(), { title, body }); | |
| 36 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 37 | return opened.value; | |
| 38 | }; | |
| 39 | const land = async (branch: string, closes: Issue) => { | |
| 40 | const pushed = await commitToBranch(app, alice, fresh(), { | |
| 41 | branch, | |
| 42 | message: branch, | |
| 43 | files: [{ path: `${branch}.txt`, content: "x\n" }], | |
| 44 | }); | |
| 45 | if (!pushed.ok) throw new Error(JSON.stringify(pushed.error)); | |
| 46 | const opened = await openPull(app, alice, fresh(), { | |
| 47 | title: branch, | |
| 48 | body: `Closes #${closes.number}`, | |
| 49 | baseBranch: "main", | |
| 50 | headBranch: branch, | |
| 51 | }); | |
| 52 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 53 | const pull = getPull(app.db, repo.id, opened.value.number)!; | |
| 54 | const merged = await mergePull(app, alice, fresh(), pull, "squash"); | |
| 55 | if (!merged.ok) throw new Error(String(merged.error)); | |
| 56 | return pull; | |
| 57 | }; | |
| 58 | const held = (on: Issue) => heldNow(app, fresh()).by.landed.some((i) => i.id === on.id); | |
| 59 | ||
| 60 | beforeAll(async () => { | |
| 61 | dir = await mkdtemp(join(tmpdir(), "forge-landed-held-")); | |
| 62 | app = createApp({ dataDir: dir, port: 0 }); | |
| 63 | const a = await createUser(app.db, { | |
| 64 | origin: "seed", | |
| 65 | trusted: true, | |
| 66 | username: "alice", | |
| 67 | email: "[email protected]", | |
| 68 | password: "a-good-passphrase", | |
| 69 | adminIfFirst: true, | |
| 70 | }); | |
| 71 | if (!a.ok) throw new Error(a.error); | |
| 72 | alice = a.value; | |
| 73 | const made = await createRepo(app, alice, { name: "engine", visibility: "private" }); | |
| 74 | if (!made.ok) throw new Error(String(made.error)); | |
| 75 | for (const agent of ["atlas", "mason"]) { | |
| 76 | const created = await createAgent(app, alice, { username: agent }); | |
| 77 | if (!created.ok) throw new Error(String(created.error)); | |
| 78 | expect(addToCrew(app, alice, made.value, agent).ok).toBe(true); | |
| 79 | } | |
| 80 | setCrewRole(app.db, made.value.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 81 | mason = getUserByName(app.db, "mason")!; | |
| 82 | const set = await updateRepoSettings(app, alice, made.value, { | |
| 83 | autonomy: "autonomous", | |
| 84 | autonomyDirection: "Keep the engine small.", | |
| 85 | autonomyCeilingUsd: 100, | |
| 86 | autonomySlots: 1, | |
| 87 | autonomyBy: alice.id, | |
| 88 | }); | |
| 89 | if (!set.ok) throw new Error(JSON.stringify(set.error)); | |
| 90 | repo = getRepoById(app.db, made.value.id)!; | |
| 91 | const seeded = await commitToBranch(app, alice, fresh(), { | |
| 92 | message: "Start", | |
| 93 | files: [{ path: "README.md", content: "# engine\n" }], | |
| 94 | }); | |
| 95 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 96 | }, 30_000); | |
| 97 | ||
| 98 | afterAll(async () => { | |
| 99 | app.close(); | |
| 100 | await rm(dir, { recursive: true, force: true }); | |
| 101 | }); | |
| 102 | ||
| 103 | test("the landing leaves it open, notes it on the issue, and the picker passes it by", async () => { | |
| 104 | const partial = issue("Partial", "- [x] one\n- [ ] two"); | |
| 105 | const pull = await land("lands-partial", partial); | |
| 106 | expect(now(partial).state).toBe("open"); | |
| 107 | const notes = listComments(app.db, partial.id).map((c) => c.body); | |
| 108 | expect(notes).toEqual([landedNote(pull.number, ["two"])]); | |
| 109 | expect(notes[0]).toBe( | |
| 110 | `Left open after pull #${pull.number} landed: 1 unticked item remains (two). Close it, tick what is done, or say what is left; until a person does, the crew does not pick it again.`, | |
| 111 | ); | |
| 112 | // The landing's own note, though alice wrote it, is not an answer. | |
| 113 | expect(landedUnanswered(app.db, fresh(), now(partial))?.number).toBe(pull.number); | |
| 114 | const sorted = heldNow(app, fresh()); | |
| 115 | expect(sorted.by.landed.map((i) => i.number)).toEqual([partial.number]); | |
| 116 | expect(sorted.startable.some((r) => r.issue.id === partial.id)).toBe(false); | |
| 117 | expect(heldWords(sorted)).toContain("landed=1"); | |
| 118 | await driveRepo(app, fresh()); | |
| 119 | expect(now(partial).assigneeId).toBeNull(); | |
| 120 | }); | |
| 121 | ||
| 122 | test("an agent's comment does not lift it; a person's does, and the picker hands it out", async () => { | |
| 123 | const partial = issue("Spoken", "- [ ] left"); | |
| 124 | await land("lands-spoken", partial); | |
| 125 | expect(held(partial)).toBe(true); | |
| 126 | expect(addComment(app, mason, fresh(), now(partial), "I could redo this.").ok).toBe(true); | |
| 127 | expect(held(partial)).toBe(true); | |
| 128 | expect(addComment(app, alice, fresh(), now(partial), "The second half is still to do.").ok).toBe(true); | |
| 129 | expect(held(partial)).toBe(false); | |
| 130 | expect(heldNow(app, fresh()).startable.some((r) => r.issue.id === partial.id)).toBe(true); | |
| 131 | }); | |
| 132 | ||
| 133 | test("a person ticking a box lifts it", async () => { | |
| 134 | const partial = issue("Ticked", "- [ ] a\n- [ ] b"); | |
| 135 | await land("lands-ticked", partial); | |
| 136 | expect(held(partial)).toBe(true); | |
| 137 | expect(editIssue(app, alice, fresh(), now(partial), { body: "- [x] a\n- [ ] b" }).ok).toBe(true); | |
| 138 | expect(held(partial)).toBe(false); | |
| 139 | }); | |
| 140 | ||
| 141 | test("an issue with every box ticked closes on the landing and holds nothing", async () => { | |
| 142 | const whole = issue("Whole", "- [x] all"); | |
| 143 | await land("lands-whole", whole); | |
| 144 | expect(now(whole).state).toBe("closed"); | |
| 145 | expect(listComments(app.db, whole.id)).toEqual([]); | |
| 146 | expect(landedUnanswered(app.db, fresh(), now(whole))).toBeUndefined(); | |
| 147 | }); | |
| 148 | ||
| 149 | test("an open issue no merged pull closes is not held", () => { | |
| 150 | const plain = issue("Plain", "- [ ] todo"); | |
| 151 | expect(landedUnanswered(app.db, fresh(), now(plain))).toBeUndefined(); | |
| 152 | }); | |
| 153 | }); |
new above
offline