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

Sentinel: The guard parked 20 runs in trevato/forge in a day #1949

Merged opened this

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

Merged

mason/forge:mason/1775-sentinel-the-guard-parked-20-runs-in-tre → main 7 commits fork

  • Merged. mason/1775-sentinel-the-guard-parked-20-runs-in-tre was merged into main as 3b99a11.

Review

Sign in to review this change.
3 files changed +72 −16
src/crew/dispatch.ts +5 −1
@@ -58,6 +58,7 @@ import { followDivergence, settleAll, settleOnPush } from "./converge.ts";
5858 import { breakerTripped } from "./breaker.ts";
5959 import {
6060 CAP_NOTE,
61 capNoted,
6162 capReached,
6263 HANDOVER_NOTE,
6364 handoverNote,
@@ -310,7 +311,10 @@ export function queueAgentRun(
310311 // a pause or a restart is the same start, never a new one.
311312 const cap = app.config.issueRunCap ?? ISSUE_RUN_CAP;
312313 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 )
314318 recordCap(app.db, repo, issue, cap);
315319 return undefined;
316320 }
src/crew/guard.ts +44 −12
@@ -90,17 +90,11 @@ export function skipsReplan(
9090 }
9191
9292 /**
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.
9696 */
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 {
10498 const from = new Date(now.getTime() - CAP_WINDOW_MS).toISOString();
10599 const spoke = db
106100 .query<{ id: string; at: string }, [string, string]>(
@@ -117,22 +111,60 @@ export function capReached(
117111 spoke.map((s) => s.id),
118112 ),
119113 );
120 const since = spoke
114 return spoke
121115 .filter((s) => directors.has(s.id))
122116 .map((s) => s.at)
123117 .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 {
124134 const row = db
125135 .query<{ n: number }, [string, string]>(
126136 `SELECT COUNT(*) AS n FROM run
127137 WHERE issue_id = ?1 AND kind = 'agent' AND created_at > ?2
128138 AND attempt = 0
139 AND reason != 'review'
129140 AND summary NOT LIKE 'interrupted: %'
130141 AND summary NOT LIKE 'skipped: %'`,
131142 )
132 .get(issue.id, since);
143 .get(issue.id, capFrom(db, repo, issue, now));
133144 return (row?.n ?? 0) >= cap;
134145 }
135146
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
136168 /** Record that `issue` was capped: once per park, beside the note. */
137169 export function recordCap(
138170 db: Database,
test/crew-guard.test.ts +23 −3
@@ -86,13 +86,18 @@ const guards = (answer: string) =>
8686 ).length;
8787
8888 /** 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 ) {
9095 const run = createRun(app.db, {
9196 repoId: repo.id,
9297 kind: "agent",
9398 issueId: issue.id,
9499 actorId: bot.user.id,
95 reason: "assigned",
100 reason,
96101 });
97102 claimRun(app.db, run.id);
98103 finishRun(app.db, run.id, {
@@ -310,11 +315,15 @@ describe("an issue cannot loop", () => {
310315 breakerParked(app.db, [repo.id]).filter((p) => p.issueId === issue.id),
311316 ).toHaveLength(1);
312317
313 // A reader speaks: still capped.
318 // A reader speaks: still capped, and still the one park.
314319 ok(addComment(app, mallory, repo, issue, "Try again!"));
315320 expect(
316321 queueAgentRun(app, fresh(), issue, bot, "assigned", alice.id),
317322 ).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);
318327 // A person who may direct the crew speaks: it runs again.
319328 ok(addComment(app, alice, repo, issue, "Smaller radius."));
320329 expect(
@@ -322,6 +331,17 @@ describe("an issue cannot loop", () => {
322331 ).toBe("queued");
323332 });
324333
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
325345 test("the cap is a setting", () => {
326346 const issue = held("Softer shadow");
327347 ran(issue);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs