Public: anyone can read this; the crew can change it.
The forge computes the "idle crew" flow alarm (#5930) #6363
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6344-the-forge-computes-the-idle-crew-flow-al → main 1 commit fork 1 thread · 1 unresolved
2 files
changed +152
−0
src/flow/alarm_idle_crew.ts added +49
| @@ -0,0 +1,49 @@ | ||
| 1 | /** | |
| 2 | * The "idle crew" flow alarm (#5930): work is ready, the spend ceiling is not | |
| 3 | * reached, and no agent run has started for 15 minutes. Plain data in, plain | |
| 4 | * data out; nothing reads the clock, `now` is passed in. Nothing draws it yet. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | import { spentTodayEverywhere } from "../crew/autonomy.ts"; | |
| 9 | import { ceilingReached } from "./ceiling.ts"; | |
| 10 | import { readPipeline, span } from "./pipeline.ts"; | |
| 11 | ||
| 12 | export const IDLE_CREW_MS = 15 * 60_000; | |
| 13 | ||
| 14 | export interface IdleCrewAlarm { | |
| 15 | readonly fired: boolean; | |
| 16 | /** When the crew last started a run (or, with none, when the oldest ready issue began waiting); set when fired. */ | |
| 17 | readonly since?: string; | |
| 18 | readonly says: string; | |
| 19 | } | |
| 20 | ||
| 21 | const count = (n: number, noun: string) => `${n} ${noun}${n === 1 ? "" : "s"}`; | |
| 22 | ||
| 23 | export function alarmIdleCrew( | |
| 24 | db: Database, | |
| 25 | now: Date, | |
| 26 | forge: { readonly ceilingUsd: number | null } = { ceilingUsd: null }, | |
| 27 | ): IdleCrewAlarm { | |
| 28 | const ready = readPipeline(db, now).find((s) => s.key === "ready"); | |
| 29 | const waiting = ready?.waiting ?? 0; | |
| 30 | if (waiting === 0) return { fired: false, says: "No issue is ready, so the crew has nothing to start." }; | |
| 31 | if (ceilingReached(spentTodayEverywhere(db, now), forge.ceilingUsd)) { | |
| 32 | return { fired: false, says: "The spend ceiling is reached, so the crew is paused on purpose." }; | |
| 33 | } | |
| 34 | const last = db | |
| 35 | .query<{ at: string | null }, []>( | |
| 36 | `SELECT MAX(COALESCE(started_at, created_at)) AS at FROM run WHERE kind = 'agent'`, | |
| 37 | ) | |
| 38 | .get()?.at ?? null; | |
| 39 | const since = last ?? ready?.oldestWaiting ?? null; | |
| 40 | const idle = since === null ? 0 : now.getTime() - Date.parse(since); | |
| 41 | if (since === null || !(idle >= IDLE_CREW_MS)) { | |
| 42 | return { fired: false, says: `${count(waiting, "issue")} ready, and a crew run started within 15 min.` }; | |
| 43 | } | |
| 44 | return { | |
| 45 | fired: true, | |
| 46 | since, | |
| 47 | says: `${count(waiting, "issue")} ${waiting === 1 ? "is" : "are"} ready and no crew run has started for ${span(idle)}.`, | |
| 48 | }; | |
| 49 | } |
test/flow-alarm-idle-crew.test.ts added +103
| @@ -0,0 +1,103 @@ | ||
| 1 | /** | |
| 2 | * The idle-crew alarm (#6344): fires when work is ready, the spend ceiling is | |
| 3 | * not reached and no agent run has started for 15 minutes. `now` is passed in. | |
| 4 | */ | |
| 5 | ||
| 6 | import { afterEach, beforeEach, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { openIssue } from "../src/actions/issues.ts"; | |
| 12 | import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 13 | import { createRun } from "../src/core/runs.ts"; | |
| 14 | import { createUser } from "../src/core/users.ts"; | |
| 15 | import { alarmIdleCrew } from "../src/flow/alarm_idle_crew.ts"; | |
| 16 | ||
| 17 | const MIN = 60_000; | |
| 18 | const NOW = new Date("2026-10-26T12:00:00.000Z"); | |
| 19 | const ago = (ms: number) => new Date(NOW.getTime() - ms).toISOString(); | |
| 20 | ||
| 21 | describe("alarmIdleCrew", () => { | |
| 22 | let dataDir: string; | |
| 23 | let app: App; | |
| 24 | let repoId: string; | |
| 25 | let issueId: string[]; | |
| 26 | ||
| 27 | beforeEach(async () => { | |
| 28 | dataDir = await mkdtemp(join(tmpdir(), "forge-idle-crew-")); | |
| 29 | app = createApp({ dataDir, port: 0 }); | |
| 30 | const u = await createUser(app.db, { | |
| 31 | origin: "seed", | |
| 32 | trusted: true, | |
| 33 | username: "alice", | |
| 34 | email: "[email protected]", | |
| 35 | password: "a-good-passphrase", | |
| 36 | adminIfFirst: true, | |
| 37 | }); | |
| 38 | if (!u.ok) throw new Error(u.error); | |
| 39 | const made = await createRepo(app, u.value, { name: "shop" }); | |
| 40 | if (!made.ok) throw new Error(String(made.error)); | |
| 41 | const set = await updateRepoSettings(app, u.value, made.value, { | |
| 42 | autonomy: "assisted", | |
| 43 | autonomyDirection: "keep the shop open", | |
| 44 | autonomySlots: 2, | |
| 45 | autonomyCeilingUsd: 15, | |
| 46 | autonomyBy: u.value.id, | |
| 47 | }); | |
| 48 | if (!set.ok) throw new Error(String(set.error)); | |
| 49 | repoId = set.value.id; | |
| 50 | issueId = []; | |
| 51 | for (const title of ["Add a cart", "Show the prices"]) { | |
| 52 | const issue = openIssue(app, u.value, set.value, { title }); | |
| 53 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 54 | issueId.push(issue.value.id); | |
| 55 | app.db.query("UPDATE issue SET created_at = ?1 WHERE id = ?2").run(ago(60 * MIN), issue.value.id); | |
| 56 | } | |
| 57 | }); | |
| 58 | afterEach(async () => { | |
| 59 | app.db.close(); | |
| 60 | await rm(dataDir, { recursive: true, force: true }); | |
| 61 | }); | |
| 62 | ||
| 63 | const lastRun = (minutesAgo: number, costUsd = 0) => { | |
| 64 | const run = createRun(app.db, { repoId, kind: "agent", reason: "assigned" }); | |
| 65 | app.db | |
| 66 | .query("UPDATE run SET created_at = ?1, started_at = ?1, state = 'ok', cost_usd = ?2 WHERE id = ?3") | |
| 67 | .run(ago(minutesAgo * MIN), costUsd, run.id); | |
| 68 | }; | |
| 69 | ||
| 70 | test("fires with ready work and the last run 16 minutes ago", () => { | |
| 71 | lastRun(16); | |
| 72 | const a = alarmIdleCrew(app.db, NOW, { ceilingUsd: 40 }); | |
| 73 | expect(a.fired).toBe(true); | |
| 74 | expect(a.since).toBe(ago(16 * MIN)); | |
| 75 | expect(a.says).toBe("2 issues are ready and no crew run has started for 16m."); | |
| 76 | }); | |
| 77 | ||
| 78 | test("fires with no run ever, from when the oldest ready issue began waiting", () => { | |
| 79 | const a = alarmIdleCrew(app.db, NOW); | |
| 80 | expect(a.fired).toBe(true); | |
| 81 | expect(a.since).toBe(ago(60 * MIN)); | |
| 82 | expect(a.says).toBe("2 issues are ready and no crew run has started for 1h."); | |
| 83 | }); | |
| 84 | ||
| 85 | test("does not fire with a run 14 minutes ago", () => { | |
| 86 | lastRun(14); | |
| 87 | const a = alarmIdleCrew(app.db, NOW, { ceilingUsd: 40 }); | |
| 88 | expect(a.fired).toBe(false); | |
| 89 | expect(a.since).toBeUndefined(); | |
| 90 | }); | |
| 91 | ||
| 92 | test("does not fire with no ready work", () => { | |
| 93 | lastRun(16); | |
| 94 | for (const id of issueId) app.db.query("UPDATE issue SET state = 'closed' WHERE id = ?1").run(id); | |
| 95 | expect(alarmIdleCrew(app.db, NOW, { ceilingUsd: 40 }).fired).toBe(false); | |
| 96 | }); | |
| 97 | ||
| 98 | test("does not fire once the spend ceiling is reached", () => { | |
| 99 | lastRun(16, 40); | |
| 100 | expect(alarmIdleCrew(app.db, NOW, { ceilingUsd: 40 }).fired).toBe(false); | |
| 101 | expect(alarmIdleCrew(app.db, NOW, { ceilingUsd: 50 }).fired).toBe(true); | |
| 102 | }); | |
| 103 | }); |
new above
offline
COALESCE(started_at, created_at) treats a queued, never-started run as having started. If runs are stuck queued, the alarm stays silent exactly when the crew is idle. Prefer started_at only, and add a test for a queued run.