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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3265-live-triage-closes-an-issue-noted-alread → main 2 commits fork 3 threads · 3 unresolved
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 | ||
| 1046 | 1046 | on main in …" on the thread. A claim with no commit, or with one that is not |
| 1047 | 1047 | on the base, is an ordinary decline and is parked as one. |
| 1048 | 1048 | |
| 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 | ||
| 1049 | 1058 | **A red pull.** The decider stops a red check only when it is at least 70% |
| 1050 | 1059 | sure; below that the pull gets another feedback run. A stop parks the pull: it |
| 1051 | 1060 | 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( | ||
| 457 | 457 | authorId: user.value.id, |
| 458 | 458 | body: `${CLOSED_NOTE}${doneBy |
| 459 | 459 | .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 | }`, | |
| 461 | 465 | }); |
| 462 | 466 | return written; |
| 463 | 467 | })(); |
| @@ -568,6 +572,8 @@ export interface Closing { | ||
| 568 | 572 | readonly reason?: CloseReason; |
| 569 | 573 | /** Issue numbers a split issue became. */ |
| 570 | 574 | readonly into?: readonly number[]; |
| 575 | /** The crew's live triage closed it, not the person whose name it carries. */ | |
| 576 | readonly triage?: boolean; | |
| 571 | 577 | } |
| 572 | 578 | |
| 573 | 579 | /** |
src/crew/autonomy.ts +10
| @@ -50,6 +50,7 @@ import { certain } from "./decide.ts"; | ||
| 50 | 50 | import { decideAndRecord } from "./ledger.ts"; |
| 51 | 51 | import { sentinelTick } from "./sentinel.ts"; |
| 52 | 52 | import { triageTick } from "./triage.ts"; |
| 53 | import { liveTriage, none as noTriage } from "./livetriage.ts"; | |
| 53 | 54 | import { wallOf, wallSentence } from "../contract/policy.ts"; |
| 54 | 55 | import { |
| 55 | 56 | getIssue, |
| @@ -1154,6 +1155,14 @@ export async function autonomyTick( | ||
| 1154 | 1155 | // work takes the slots it needs (#2172). |
| 1155 | 1156 | const requested = await moveRequested(app, current, freeSlots(app, current, now), now); |
| 1156 | 1157 | 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 | }); | |
| 1157 | 1166 | // Every tick says what it did and why, so an app that sits still is |
| 1158 | 1167 | // never a mystery: the numbers behind "idle" are on the line. |
| 1159 | 1168 | const status = autonomyStatus(app, current, now); |
| @@ -1169,6 +1178,7 @@ export async function autonomyTick( | ||
| 1169 | 1178 | log.info("tick", { |
| 1170 | 1179 | repo: `${repo.ownerName}/${repo.name}`, |
| 1171 | 1180 | did, |
| 1181 | triaged: triaged.closed, | |
| 1172 | 1182 | ready: ready.length, |
| 1173 | 1183 | // What the builders could start after the filters (#2143), and |
| 1174 | 1184 | // 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"; | |
| 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; | |
|
||
| 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 | }); |
new above
offline
Drop
labelled/foldedandtriageWords' slashes until their slices exist; unused fields are dead code today.