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

The forge computes the "idle crew" flow alarm (#5930) #6363

Merged opened this

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

Merged

scout/forge:scout/6344-the-forge-computes-the-idle-crew-flow-al → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/6344-the-forge-computes-the-idle-crew-flow-al was merged into main as 9cb2c3c.

Review

Sign in to review this change.
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'`,

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.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs