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

Live triage closes an issue noted "already done on main" with its commits, on the next autonomy tick #3284

Merged opened this

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

Merged

scout/forge:scout/3265-live-triage-closes-an-issue-noted-alread → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/3265-live-triage-closes-an-issue-noted-alread was merged into main as 9c2b7d5.

Review

Sign in to review this change.
5 files changed +269 −1
README.md +9
@@ -1046,6 +1046,15 @@ or `x` on the issue in the terminal); the close writes "Closed: already done
10461046 on main in …" on the thread. A claim with no commit, or with one that is not
10471047 on the base, is an ordinary decline and is parked as one.
10481048
1049 **Live triage.** On each autonomy tick, an open issue carrying the crew's own
1050 "already done" note (a person's note counts for nothing) is closed as *done*
1051 when every commit the note names is on the base branch; one missing commit
1052 leaves it open. At most 25 per repository per tick, rules only, no run and no
1053 spend. The close is made in the name of the person who set the app autonomous,
1054 so its comment says "Closed by the crew's live triage". Reopening the issue
1055 undoes it and the reopen stands: an issue that already has a close note is
1056 never closed by triage again. The tick line counts them as `triaged=N`.
1057
10491058 **A red pull.** The decider stops a red check only when it is at least 70%
10501059 sure; below that the pull gets another feedback run. A stop parks the pull: it
10511060 closes as *parked*, not done, with a comment that says why. The pull page's
src/actions/issues.ts +7 −1
@@ -457,7 +457,11 @@ export function setIssueState(
457457 authorId: user.value.id,
458458 body: `${CLOSED_NOTE}${doneBy
459459 .map((o) => `[\`${o.slice(0, 7)}\`](/${repo.ownerName}/${repo.name}/commit/${o})`)
460 .join(", ")}.`,
460 .join(", ")}.${
461 closing.triage
462 ? " Closed by the crew's live triage; reopen it to undo, and it stays open."
463 : ""
464 }`,
461465 });
462466 return written;
463467 })();
@@ -568,6 +572,8 @@ export interface Closing {
568572 readonly reason?: CloseReason;
569573 /** Issue numbers a split issue became. */
570574 readonly into?: readonly number[];
575 /** The crew's live triage closed it, not the person whose name it carries. */
576 readonly triage?: boolean;
571577 }
572578
573579 /**
src/crew/autonomy.ts +10
@@ -50,6 +50,7 @@ import { certain } from "./decide.ts";
5050 import { decideAndRecord } from "./ledger.ts";
5151 import { sentinelTick } from "./sentinel.ts";
5252 import { triageTick } from "./triage.ts";
53 import { liveTriage, none as noTriage } from "./livetriage.ts";
5354 import { wallOf, wallSentence } from "../contract/policy.ts";
5455 import {
5556 getIssue,
@@ -1154,6 +1155,14 @@ export async function autonomyTick(
11541155 // work takes the slots it needs (#2172).
11551156 const requested = await moveRequested(app, current, freeSlots(app, current, now), now);
11561157 const did = await driveRepo(app, current, now);
1158 // Rules only, no run: an issue noted already done on the base closes.
1159 const triaged = await liveTriage(app, current).catch((error) => {
1160 log.error("live triage failed", {
1161 repo: `${repo.ownerName}/${repo.name}`,
1162 error: String(error),
1163 });
1164 return noTriage();
1165 });
11571166 // Every tick says what it did and why, so an app that sits still is
11581167 // never a mystery: the numbers behind "idle" are on the line.
11591168 const status = autonomyStatus(app, current, now);
@@ -1169,6 +1178,7 @@ export async function autonomyTick(
11691178 log.info("tick", {
11701179 repo: `${repo.ownerName}/${repo.name}`,
11711180 did,
1181 triaged: triaged.closed,
11721182 ready: ready.length,
11731183 // What the builders could start after the filters (#2143), and
11741184 // what holds the rest, per filter (#2244).
src/crew/livetriage.ts added +93
@@ -0,0 +1,93 @@
1 /**
2 * Live triage (#3242), first slice: an issue the crew found already done on
3 * the base branch stops waiting for a person to close it. Rules only, no
4 * run, no spend. Only the crew's own note (`ALREADY_NOTE`, read through
5 * `alreadyDone`) with every commit it names reachable from the default
6 * branch closes anything; one commit that is not there leaves the issue
7 * open. A second pass over the same state changes nothing, because a closed
8 * issue is no longer a candidate.
9 *
10 * The close is `setIssueState`, whose state change is committed before the
11 * event is published; the counters here move only after it returns, so a
12 * change that rolled back is neither counted nor announced.
13 */
14
15 import type { App } from "../app.ts";
16 import { setIssueState } from "../actions/issues.ts";
17 import { alreadyDone, CLOSED_NOTE, DONE_LIKE } from "../core/done_on_main.ts";
18 import { getIssueById } from "../core/issues.ts";
19 import type { Repo } from "../core/repos.ts";
20 import { getUserById } from "../core/users.ts";
21 import { repoDir } from "../git/repos.ts";
22 import { isMirror } from "../origins/write-through.ts";
23 import { createLog } from "../kernel/log.ts";
24 import { onBase } from "./already.ts";

Drop labelled/folded and triageWords' slashes until their slices exist; unused fields are dead code today.

25
26 const log = createLog("livetriage");
27
28 /** The most changes one repository gets in one tick. */
29 export const MOST_CHANGES = 25;
30
31 /** What a pass did. */
32 export interface Triaged {
33 closed: number;
34 }
35
36 export const none = (): Triaged => ({ closed: 0 });
37
38 /**
39 * Open issues that carry an already-done note, oldest first. One that
40 * already has a close note was closed once and reopened by a person: that
41 * reopen stands, so it is not closed again.
42 */
43 function candidates(app: App, repo: Repo): string[] {
44 return app.db
45 .query<{ id: string }, [string]>(
46 `SELECT i.id AS id FROM issue i
47 WHERE i.repo_id = ?1 AND i.kind = 'issue' AND i.state = 'open'
48 AND EXISTS (SELECT 1 FROM comment n WHERE n.issue_id = i.id
49 AND n.body LIKE '${DONE_LIKE}')
50 AND NOT EXISTS (SELECT 1 FROM comment c WHERE c.issue_id = i.id
51 AND c.body LIKE '${CLOSED_NOTE}%')
52 ORDER BY i.number`,
53 )
54 .all(repo.id)
55 .map((r) => r.id);
56 }
57
58 /** One repository's pass. */
59 export async function liveTriage(app: App, repo: Repo): Promise<Triaged> {
60 const did = none();
61 if (repo.archivedAt !== null || repo.autonomyBy === null) return did;
62 if (isMirror(app, repo)) return did;
63 const by = getUserById(app.db, repo.autonomyBy);
64 if (!by) return did;
65 const ids = candidates(app, repo);
66 const noted = alreadyDone(app.db, ids);
67 if (noted.size === 0) return did;

by is the owner, so the close reads as the owner's own act. If a person reopens the issue, the crew note is still there and the next tick closes it again. Skip issues that were reopened after the note, and test it.

68 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
69 for (const id of ids) {
70 const oids = noted.get(id);
71 if (oids === undefined) continue;

onBase spawns git for every candidate. Issues that stay open because a commit is missing are re-checked every tick and never count toward MOST_CHANGES. Bound the number of checks per tick, not only the number of closes.

72 if (did.closed >= MOST_CHANGES) break;
73 const issue = getIssueById(app.db, id);
74 if (!issue || issue.state !== "open") continue;
75 const there = await onBase(dir, repo.defaultBranch, oids);
76 if (there.length !== oids.length) continue;
77 try {
78 const closed = setIssueState(app, by, repo, issue, "closed", {
79 reason: "done",
80 triage: true,
81 });
82 if (closed.ok && closed.value.state === "closed") did.closed++;
83 } catch (error) {
84 // A rolled-back change: nothing was written and nothing announced.
85 log.error("close failed", {
86 repo: `${repo.ownerName}/${repo.name}`,
87 issue: issue.number,
88 error: String(error),
89 });
90 }
91 }
92 return did;
93 }
test/livetriage.test.ts added +150
@@ -0,0 +1,150 @@
1 /**
2 * Live triage closes an issue the crew noted already done on main, when
3 * every commit it names is on the base; nothing else, and nothing twice.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { addComment, openIssue, setIssueState } from "../src/actions/issues.ts";
11 import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts";
12 import { getIssueById, listComments, type Issue } from "../src/core/issues.ts";
13 import { getRepoById, type Repo } from "../src/core/repos.ts";
14 import { createUser, type User } from "../src/core/users.ts";
15 import { ALREADY_NOTE } from "../src/crew/already.ts";
16 import { liveTriage, MOST_CHANGES } from "../src/crew/livetriage.ts";
17
18 describe("live triage", () => {
19 let dir: string;
20 let app: App;
21 let alice: User;
22 let repo: Repo;
23 let oid: string;
24 const fresh = () => getRepoById(app.db, repo.id)!;
25 const issue = (title: string): Issue => {
26 const opened = openIssue(app, alice, fresh(), { title });
27 if (!opened.ok) throw new Error(String(opened.error));
28 return opened.value;
29 };
30 const note = (on: Issue, ...oids: string[]) =>
31 addComment(app, alice, fresh(), on, `${ALREADY_NOTE}${oids.join(" ")}`);
32 const closedEvents = () =>
33 app.db.query<{ n: number }, []>(`SELECT COUNT(*) AS n FROM event WHERE kind = 'issue.closed'`).get()!.n;
34 const state = (on: Issue) => getIssueById(app.db, on.id)!;
35
36 beforeAll(async () => {
37 dir = await mkdtemp(join(tmpdir(), "forge-livetriage-"));
38 app = createApp({ dataDir: dir, port: 0 });
39 const a = await createUser(app.db, {
40 origin: "seed",
41 trusted: true,
42 username: "alice",
43 email: "[email protected]",
44 password: "a-good-passphrase",
45 adminIfFirst: true,
46 });
47 if (!a.ok) throw new Error(a.error);
48 alice = a.value;
49 const made = await createRepo(app, alice, { name: "engine", visibility: "private" });
50 if (!made.ok) throw new Error(String(made.error));
51 repo = made.value;
52 const set = await updateRepoSettings(app, alice, repo, {
53 autonomy: "autonomous",
54 autonomyDirection: "Keep it small.",
55 autonomyCeilingUsd: 100,
56 autonomyBy: alice.id,
57 });
58 if (!set.ok) throw new Error(JSON.stringify(set.error));
59 const seeded = await commitToBranch(app, alice, fresh(), {
60 message: "Start",
61 files: [{ path: "README.md", content: "# engine\n" }],
62 });
63 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
64 oid = seeded.value.commitOid;
65 });
66 afterAll(async () => {
67 app.close();
68 await rm(dir, { recursive: true, force: true });
69 });
70
71 test("closes a noted issue on base, leaves one off base, and a second pass changes nothing", async () => {
72 const done = issue("Already built");
73 const off = issue("Not on base");
74 const plain = issue("Nothing noted");
75 note(done, oid);
76 note(off, oid, "a".repeat(40));
77 const before = closedEvents();
78
79 const first = await liveTriage(app, fresh());
80 expect(first).toEqual({ closed: 1 });
81 expect(state(done).state).toBe("closed");
82 expect(state(done).closeReason).toBe("done");
83 expect(listComments(app.db, done.id).some((c) => c.body.includes(oid.slice(0, 7)))).toBe(true);
84 expect(listComments(app.db, done.id).some((c) => c.body.includes("live triage"))).toBe(true);
85 expect(state(off).state).toBe("open");
86 expect(state(plain).state).toBe("open");
87 expect(closedEvents()).toBe(before + 1);
88
89 const second = await liveTriage(app, fresh());
90 expect(second.closed).toBe(0);
91 expect(closedEvents()).toBe(before + 1);
92 });
93
94 test("a person's reopen stands: the next tick does not close it again", async () => {
95 const back = issue("Reopened");
96 note(back, oid);
97 expect((await liveTriage(app, fresh())).closed).toBe(1);
98 const reopened = setIssueState(app, alice, fresh(), state(back), "open");
99 expect(reopened.ok).toBe(true);
100 const before = closedEvents();
101 expect((await liveTriage(app, fresh())).closed).toBe(0);
102 expect(state(back).state).toBe("open");
103 expect(closedEvents()).toBe(before);
104 });
105
106 test("a note a person wrote closes nothing", async () => {
107 const bob = await createUser(app.db, {
108 origin: "seed",
109 trusted: true,
110 username: "bob",
111 email: "[email protected]",
112 password: "a-good-passphrase",
113 });
114 if (!bob.ok) throw new Error(bob.error);
115 const mine = issue("Person's note");
116 app.db
117 .query(`INSERT INTO comment (id, issue_id, author_id, body, created_at) VALUES (?1, ?2, ?3, ?4, ?5)`)
118 .run("c-bob", mine.id, bob.value.id, `${ALREADY_NOTE}${oid}`, new Date().toISOString());
119 expect((await liveTriage(app, fresh())).closed).toBe(0);
120 expect(state(mine).state).toBe("open");
121 });
122
123 test("a close that rolls back is neither counted nor announced", async () => {
124 const stuck = issue("Rolls back");
125 note(stuck, oid);
126 const before = closedEvents();
127 app.db.run(
128 `CREATE TRIGGER no_close_note BEFORE INSERT ON comment
129 WHEN NEW.body LIKE 'Closed: already done%'
130 BEGIN SELECT RAISE(ABORT, 'rolled back'); END`,
131 );
132 try {
133 const did = await liveTriage(app, fresh());
134 expect(did.closed).toBe(0);
135 expect(state(stuck).state).toBe("open");
136 expect(closedEvents()).toBe(before);
137 } finally {
138 app.db.run(`DROP TRIGGER no_close_note`);
139 }
140 expect((await liveTriage(app, fresh())).closed).toBe(1);
141 expect(state(stuck).state).toBe("closed");
142 });
143
144 test("at most 25 changes per repository per tick", async () => {
145 const many = Array.from({ length: MOST_CHANGES + 3 }, (_, n) => issue(`Many ${n}`));
146 for (const m of many) note(m, oid);
147 expect((await liveTriage(app, fresh())).closed).toBe(MOST_CHANGES);
148 expect((await liveTriage(app, fresh())).closed).toBe(3);
149 });
150 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs