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

The guard records a cap once per issue per window, and the sentinel's parks signal names the capped issues #2319

Merged opened this

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

Merged

scout/forge:scout/2286-the-guard-records-a-cap-once-per-issue-p → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/2286-the-guard-records-a-cap-once-per-issue-p was merged into main as c6eb0b4.

Review

Sign in to review this change.
3 files changed +73 −8
src/crew/guard.ts +15 −2
@@ -189,13 +189,25 @@ export function capNoted(
189189 );
190190 }
191191
192 /** Record that `issue` was capped: once per park, beside the note. */
192 /**
193 * Record that `issue` was capped: at most once per cap window, however
194 * often the clock asks again. Returns whether a decision was written.
195 */
193196 export function recordCap(
194197 db: Database,
195198 repo: Repo,
196199 issue: Issue,
197200 cap: number,
198 ): void {
201 now = new Date(),
202 ): boolean {
203 const seen = db
204 .query<{ id: string }, [string, string]>(
205 `SELECT id FROM decision
206 WHERE issue_id = ?1 AND kind = 'guard' AND answer = 'capped'
207 AND created_at >= ?2 LIMIT 1`,
208 )
209 .get(issue.id, capFrom(db, repo, issue, now));
210 if (seen !== null) return false;
199211 recordDecision(db, {
200212 kind: "guard",
201213 repoId: repo.id,
@@ -206,6 +218,7 @@ export function recordCap(
206218 by: "rule",
207219 ms: 0,
208220 });
221 return true;
209222 }
210223
211224 export interface GuardCounts {
src/crew/sentinel.ts +20 −5
@@ -235,20 +235,35 @@ export function ledgerStreaks(db: Database, now: number): Finding[] {
235235 }
236236 // A skip is a run the guard saved — nothing new to act on — not a run it
237237 // refused; only the decisions that parked work are counted.
238 const parks = new Map<string, number>();
238 const parks = new Map<string, Set<string>>();
239239 const skips = new Map<string, number>();
240240 for (const d of decisionsSince(db, "guard", new Date(now - DAY_MS).toISOString())) {
241 const into = d.answer === "skipped" ? skips : parks;
242 into.set(d.repoId, (into.get(d.repoId) ?? 0) + 1);
241 if (d.answer === "skipped") {
242 skips.set(d.repoId, (skips.get(d.repoId) ?? 0) + 1);
243 continue;
244 }
245 // A cap is one park per issue however often it was asked again.
246 const set = parks.get(d.repoId) ?? new Set<string>();
247 set.add(d.issueId ?? d.id);
248 parks.set(d.repoId, set);
243249 }
244 for (const [repoId, n] of parks) {
250 for (const [repoId, issues] of parks) {
251 const n = issues.size;
245252 if (n < 20) continue;
253 const ids = [...issues];
254 const numbers = db
255 .query<{ number: number }, string[]>(
256 `SELECT number FROM issue WHERE id IN (${ids.map(() => "?").join(",")}) ORDER BY number LIMIT 5`,
257 )
258 .all(...ids)

Binds one placeholder per capped issue; with very many capped issues this can hit the SQLite variable limit. Fix: take the lowest five ids in a subquery or slice before binding.

259 .map((r) => `#${r.number}`);
246260 const skipped = skips.get(repoId) ?? 0;
247261 out.push({
248262 key: `parks:${repoId}`,
249263 title: `The guard parked ${n} runs in ${repoSlug(db, repoId)} in a day`,
250264 evidence: [
251 `${n} \`guard\` decisions that parked a run since ${new Date(now - DAY_MS).toISOString()}`,
265 `${n} \`guard\` decisions that parked distinct issues since ${new Date(now - DAY_MS).toISOString()}`,
266 ...(numbers.length > 0 ? [`capped issues: ${numbers.join(", ")}`] : []),
252267 ...(skipped > 0 ? [`${skipped} skips of a run with nothing new are not counted`] : []),
253268 ],
254269 pillar: "Throughput: the crew spends on work, not on being refused",
test/sentinel.test.ts +38 −1
@@ -14,12 +14,13 @@ import { createRepo } from "../src/actions/repos.ts";
1414 import { setCapacity } from "../src/core/capacity.ts";
1515 import { clearErrors, createLog } from "../src/kernel/log.ts";
1616 import { recordDecision } from "../src/core/decisions.ts";
17 import { addComment, createLabel, labelsFor, listComments, listIssues } from "../src/core/issues.ts";
17 import { addComment, createIssue, createLabel, labelsFor, listComments, listIssues } from "../src/core/issues.ts";
1818 import { addCrew, createAgentRow } from "../src/core/agents.ts";
1919 import { listEvents } from "../src/core/events.ts";
2020 import { getRepoById, type Repo } from "../src/core/repos.ts";
2121 import { createRun } from "../src/core/runs.ts";
2222 import { createUser, type User } from "../src/core/users.ts";
23 import { recordCap } from "../src/crew/guard.ts";
2324 import {
2425 budgetBreaches,
2526 degradedCauses,
@@ -297,6 +298,42 @@ describe("the guard", () => {
297298 });
298299 });
299300
301 describe("a cap", () => {
302 const issueOf = (n: number) => {
303 const r = createIssue(app.db, { repoId: repo.id, authorId: alice.id, title: `capped ${n}` });
304 if (!r.ok) throw new Error("issue");
305 return r.value;
306 };
307
308 test("is recorded once per issue per window, however often it is asked", () => {
309 app.db.query("DELETE FROM decision WHERE kind = 'guard'").run();
310 const issue = issueOf(0);
311 expect(recordCap(app.db, repo, issue, 6)).toBe(true);
312 expect(recordCap(app.db, repo, issue, 6)).toBe(false);
313 const n = () =>
314 app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM decision WHERE kind = 'guard' AND answer = 'capped'").get()?.n;
315 expect(n()).toBe(1);
316 // A later window is a new park.
317 expect(recordCap(app.db, repo, issue, 6, new Date(Date.now() + 2 * DAY))).toBe(true);
318 expect(n()).toBe(2);
319 });
320
321 test("the parks signal counts distinct issues and names up to five", () => {
322 app.db.query("DELETE FROM decision WHERE kind = 'guard'").run();
323 const issues = Array.from({ length: 20 }, (_, i) => issueOf(i + 1));
324 for (const i of issues) {
325 recordCap(app.db, repo, i, 6);
326 // A second decision for the same issue, as an old clock wrote them.
327 recordDecision(app.db, { kind: "guard", repoId: repo.id, issueId: i.id, state: {}, answer: "capped", p: 1, by: "rule", ms: 0 });
328 }
329 const parks = findFaults(app.db, Date.now()).find((x) => x.key === `parks:${repo.id}`);
330 expect(parks?.title).toContain("parked 20 runs");
331 const line = parks?.evidence.find((e) => e.startsWith("capped issues:")) ?? "";
332 expect(line.split(",").length).toBe(5);
333 expect(line).toContain(`#${issues[0]!.number}`);
334 });
335 });
336
300337 describe("the log", () => {
301338 test("the same ERROR line three times is named once, with the line", () => {
302339 clearErrors();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs