Public: anyone can read this; the crew can change it.
Sentinel: The guard parked 20 runs in trevato/forge in a day #1949
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/1775-sentinel-the-guard-parked-20-runs-in-tre → main 7 commits fork
3 files
changed +72
−16
src/crew/dispatch.ts +5 −1
| @@ -58,6 +58,7 @@ import { followDivergence, settleAll, settleOnPush } from "./converge.ts"; | ||
| 58 | 58 | import { breakerTripped } from "./breaker.ts"; |
| 59 | 59 | import { |
| 60 | 60 | CAP_NOTE, |
| 61 | capNoted, | |
| 61 | 62 | capReached, |
| 62 | 63 | HANDOVER_NOTE, |
| 63 | 64 | handoverNote, |
| @@ -310,7 +311,10 @@ export function queueAgentRun( | ||
| 310 | 311 | // a pause or a restart is the same start, never a new one. |
| 311 | 312 | const cap = app.config.issueRunCap ?? ISSUE_RUN_CAP; |
| 312 | 313 | if (again === undefined && capReached(app.db, repo, issue, cap)) { |
| 313 | if (parkForBreaker(app, repo, issue, agent, CAP_NOTE)) | |
| 314 | if ( | |
| 315 | !capNoted(app.db, repo, issue) && | |
| 316 | parkForBreaker(app, repo, issue, agent, CAP_NOTE) | |
| 317 | ) | |
| 314 | 318 | recordCap(app.db, repo, issue, cap); |
| 315 | 319 | return undefined; |
| 316 | 320 | } |
src/crew/guard.ts +44 −12
| @@ -90,17 +90,11 @@ export function skipsReplan( | ||
| 90 | 90 | } |
| 91 | 91 | |
| 92 | 92 | /** |
| 93 | * Whether `issue` has started `cap` runs inside the window since a person | |
| 94 | * who may direct the crew last spoke on it. A run a restart or the | |
| 95 | * provider cut short, a resumed run and a skipped one are not new starts. | |
| 93 | * Where the cap's count starts on `issue`: a day ago, or the last word of a | |
| 94 | * person who may direct the crew if that is later. The park note itself is | |
| 95 | * no one's word, whoever's name it carries. | |
| 96 | 96 | */ |
| 97 | export function capReached( | |
| 98 | db: Database, | |
| 99 | repo: Repo, | |
| 100 | issue: Issue, | |
| 101 | cap: number, | |
| 102 | now = new Date(), | |
| 103 | ): boolean { | |
| 97 | function capFrom(db: Database, repo: Repo, issue: Issue, now: Date): string { | |
| 104 | 98 | const from = new Date(now.getTime() - CAP_WINDOW_MS).toISOString(); |
| 105 | 99 | const spoke = db |
| 106 | 100 | .query<{ id: string; at: string }, [string, string]>( |
| @@ -117,22 +111,60 @@ export function capReached( | ||
| 117 | 111 | spoke.map((s) => s.id), |
| 118 | 112 | ), |
| 119 | 113 | ); |
| 120 | const since = spoke | |
| 114 | return spoke | |
| 121 | 115 | .filter((s) => directors.has(s.id)) |
| 122 | 116 | .map((s) => s.at) |
| 123 | 117 | .reduce((a, b) => (a > b ? a : b), from); |
| 118 | } | |
| 119 | ||
| 120 | /** | |
| 121 | * Whether `issue` has started `cap` runs inside the window since a person | |
| 122 | * who may direct the crew last spoke on it. A run a restart or the | |
| 123 | * provider cut short, a resumed run and a skipped one are not new starts; | |
| 124 | * nor is a reviewer's run on a pull, which only answers a push the counted | |
| 125 | * runs made and is bounded by the hand-over (`HANDOVER_ROUNDS`). | |
| 126 | */ | |
| 127 | export function capReached( | |
| 128 | db: Database, | |
| 129 | repo: Repo, | |
| 130 | issue: Issue, | |
| 131 | cap: number, | |
| 132 | now = new Date(), | |
| 133 | ): boolean { | |
| 124 | 134 | const row = db |
| 125 | 135 | .query<{ n: number }, [string, string]>( |
| 126 | 136 | `SELECT COUNT(*) AS n FROM run |
| 127 | 137 | WHERE issue_id = ?1 AND kind = 'agent' AND created_at > ?2 |
| 128 | 138 | AND attempt = 0 |
| 139 | AND reason != 'review' | |
| 129 | 140 | AND summary NOT LIKE 'interrupted: %' |
| 130 | 141 | AND summary NOT LIKE 'skipped: %'`, |
| 131 | 142 | ) |
| 132 | .get(issue.id, since); | |
| 143 | .get(issue.id, capFrom(db, repo, issue, now)); | |
| 133 | 144 | return (row?.n ?? 0) >= cap; |
| 134 | 145 | } |
| 135 | 146 | |
| 147 | /** | |
| 148 | * Whether `issue` already carries the cap's note inside the cap's window: | |
| 149 | * parked, and asked about, once. Words after the note — a review, a reader — | |
| 150 | * do not make it a second park; only a director's word starts a new window. | |
| 151 | */ | |
| 152 | export function capNoted( | |
| 153 | db: Database, | |
| 154 | repo: Repo, | |
| 155 | issue: Issue, | |
| 156 | now = new Date(), | |
| 157 | ): boolean { | |
| 158 | return ( | |
| 159 | db | |
| 160 | .query<{ id: string }, [string, string, string]>( | |
| 161 | `SELECT id FROM comment | |
| 162 | WHERE issue_id = ?1 AND body = ?2 AND created_at >= ?3 LIMIT 1`, | |
| 163 | ) | |
| 164 | .get(issue.id, CAP_NOTE, capFrom(db, repo, issue, now)) !== null | |
| 165 | ); | |
| 166 | } | |
| 167 | ||
| 136 | 168 | /** Record that `issue` was capped: once per park, beside the note. */ |
| 137 | 169 | export function recordCap( |
| 138 | 170 | db: Database, |
test/crew-guard.test.ts +23 −3
| @@ -86,13 +86,18 @@ const guards = (answer: string) => | ||
| 86 | 86 | ).length; |
| 87 | 87 | |
| 88 | 88 | /** A finished run of the bot on `issue`; its reply, when given, is posted after it. */ |
| 89 | function ran(issue: Issue, reply?: string, state: "ok" | "failed" = "ok") { | |
| 89 | function ran( | |
| 90 | issue: Issue, | |
| 91 | reply?: string, | |
| 92 | state: "ok" | "failed" = "ok", | |
| 93 | reason = "assigned", | |
| 94 | ) { | |
| 90 | 95 | const run = createRun(app.db, { |
| 91 | 96 | repoId: repo.id, |
| 92 | 97 | kind: "agent", |
| 93 | 98 | issueId: issue.id, |
| 94 | 99 | actorId: bot.user.id, |
| 95 | reason: "assigned", | |
| 100 | reason, | |
| 96 | 101 | }); |
| 97 | 102 | claimRun(app.db, run.id); |
| 98 | 103 | finishRun(app.db, run.id, { |
| @@ -310,11 +315,15 @@ describe("an issue cannot loop", () => { | ||
| 310 | 315 | breakerParked(app.db, [repo.id]).filter((p) => p.issueId === issue.id), |
| 311 | 316 | ).toHaveLength(1); |
| 312 | 317 | |
| 313 | // A reader speaks: still capped. | |
| 318 | // A reader speaks: still capped, and still the one park. | |
| 314 | 319 | ok(addComment(app, mallory, repo, issue, "Try again!")); |
| 315 | 320 | expect( |
| 316 | 321 | queueAgentRun(app, fresh(), issue, bot, "assigned", alice.id), |
| 317 | 322 | ).toBeUndefined(); |
| 323 | expect( | |
| 324 | listComments(app.db, issue.id).filter((c) => c.body === CAP_NOTE), | |
| 325 | ).toHaveLength(1); | |
| 326 | expect(guards("capped")).toBe(before + 1); | |
| 318 | 327 | // A person who may direct the crew speaks: it runs again. |
| 319 | 328 | ok(addComment(app, alice, repo, issue, "Smaller radius.")); |
| 320 | 329 | expect( |
| @@ -322,6 +331,17 @@ describe("an issue cannot loop", () => { | ||
| 322 | 331 | ).toBe("queued"); |
| 323 | 332 | }); |
| 324 | 333 | |
| 334 | test("a reviewer's runs on a pull are not the loop the cap counts", () => { | |
| 335 | const issue = held("Sharper icons"); | |
| 336 | for (let n = 0; n < ISSUE_RUN_CAP - 1; n++) { | |
| 337 | ran(issue); | |
| 338 | ran(issue, undefined, "ok", "review"); | |
| 339 | } | |
| 340 | expect( | |
| 341 | queueAgentRun(app, fresh(), issue, bot, "assigned", alice.id)?.state, | |
| 342 | ).toBe("queued"); | |
| 343 | }); | |
| 344 | ||
| 325 | 345 | test("the cap is a setting", () => { |
| 326 | 346 | const issue = held("Softer shadow"); |
| 327 | 347 | ran(issue); |
new above
offline