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

Merged opened this

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

Merged

torque/forge:torque/8482-an-issue-whose-pull-landed-with-boxes-un → main 1 commit fork

  • Merged. torque/8482-an-issue-whose-pull-landed-with-boxes-un was merged into main as 90bed12.

Review

Sign in to review this change.
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
278278 under the first filter that holds it (`crew/held.ts`): `held: running=1
279279 open_pull=10 stalled=12 run_cap=11` — a run on it, an open pull proposing it,
280280 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
282283 fix, or a setting's finding. The flow panel on `/boards` draws the same per autonomous app as one
283284 bar, and the terminal's Flow command prints it as a line per app
284285 (`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 @@
44
55 - 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.
66 - 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.
78
89 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 @@
55 */
66
77 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";
99 import { resolveOpen } from "../core/decisions.ts";
1010 import type { App } from "../app.ts";
1111 import { landVision } from "../crew/vision.ts";
@@ -467,6 +467,22 @@ export async function mergePull(
467467 payload: { number: pull.number, body, commentId: noted.value.id },
468468 });
469469 }
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 }
470486 continue;
471487 }
472488 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 {
104104 .slice(0, 3)
105105 .join("; ")}${left.length > 3 ? "; …" : ""}).`;
106106 }
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 @@
1414 * planner has not answered (`core/limb_replan.ts`).
1515 * - `done_on_main`: the crew found it done on the base branch and waits for
1616 * 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.
1720 * - `stalled`: a builder ran on it and left without a pull, and nobody
1821 * who directs the crew has spoken since. The next tick parks it in the
1922 * crew's name, which frees it (`stalls.ts`).
@@ -42,6 +45,7 @@ import type { Stuck } from "./brief.ts";
4245 import { proposedWork, speaksForCrew } from "./direct.ts";
4346 import { capReached, runCapOf } from "./guard.ts";
4447 import { interruptionOf } from "./interrupted.ts";
48 import { landedUnanswered } from "./landed.ts";
4549 import { PARK_STRIKES, parkedCount } from "./parks.ts";
4650 import { heldFiles, type FileHold } from "./file_holds.ts";
4751 import { expectedFiles, hotFiles, hotOverlap, pullsTouching, relievedFiles } from "./hot_files.ts";
@@ -53,6 +57,7 @@ export const HOLDS = [
5357 "planner",
5458 "with_planner",
5559 "done_on_main",
60 "landed",
5661 "stalled",
5762 "run_cap",
5863 "parked_twice",
@@ -175,6 +180,7 @@ export function heldBy(
175180 if (issue.assigneeId !== null && planners.has(issue.assigneeId)) return "planner";
176181 if (handed.has(issue.id)) return "with_planner";
177182 if (alreadyDone(app.db, [issue.id]).has(issue.id)) return "done_on_main";
183 if (landedUnanswered(app.db, repo, issue) !== undefined) return "landed";
178184 if (stalled(app, repo, issue)) return "stalled";
179185 if (atRunCap(app, repo, issue)) return "run_cap";
180186 if (parkedCount(app.db, repo, issue.id) >= PARK_STRIKES) return "parked_twice";
@@ -262,6 +268,7 @@ const HOLD_WORDS: Readonly<Record<HoldName, string>> = {
262268 planner: "the planner or a reviewer holds it",
263269 with_planner: "its limb waits on the planner",
264270 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",
265272 stalled: "a builder ran on it and left without a pull",
266273 run_cap: "it has used its runs for the day",
267274 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs