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

A test pins the forge's daily crew ceiling: no cap, under, exactly at, and which run carried spend across it #6706

Merged opened this

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

Merged

scout/forge:scout/6700-a-test-pins-the-forge-s-daily-crew-ceili → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/6700-a-test-pins-the-forge-s-daily-crew-ceili was merged into main as d12dee2.

Review

Sign in to review this change.
6 files changed +103 −7
src/flow/ceiling.ts +7 −2
@@ -8,9 +8,14 @@
88 import type { Database } from "bun:sqlite";
99 import { startOfTodayUtc } from "../crew/autonomy.ts";
1010
11 /** The forge cap in dollars, or null when none is set. */
11 /**
12 * The forge cap in dollars, or null when none is set. Only a positive number
13 * is a cap: 0, a negative, NaN or a missing value all mean no cap (0 does not
14 * mean "no crew"; to stop the crew, pause it).
15 */
1216 export function forgeCeiling(config: { crewCeilingUsd?: number }): number | null {

home_truth.ts:23 and tui/screens/dashboard.ts:271 pass crewCeilingUsd ?? null, so they still treat 0 as a cap. Use forgeCeiling there so the module is the one rule.

13 return config.crewCeilingUsd ?? null;
17 const cap = config.crewCeilingUsd;
18 return typeof cap === "number" && cap > 0 ? cap : null;
1419 }
1520
1621 /** Spend has reached the cap: the very test that pauses the builders. */
src/spawn/heartbeat.ts +3 −2
@@ -25,6 +25,7 @@ import { isParentLocal } from "./beat-door.ts";
2525 import { upstreamState } from "./upstream.ts";
2626 import { progress } from "../core/next-steps.ts";
2727 import { running } from "../kernel/version.ts";
28 import { forgeCeiling } from "../flow/ceiling.ts";
2829
2930 export const SIGNATURE_HEADER = "x-forge-signature";
3031 /** A child reports this often; one silent for three beats reads as quiet. */
@@ -254,10 +255,10 @@ export function standing(app: App, name: string, at = Date.now()): Beat {
254255
255256 /** What the crew spent today, and the daily ceiling that pauses it (null when the forge sets none). */
256257 export function spendReport(app: App, now = new Date()): { spendTodayUsd: number; ceilingUsd: number | null } {
257 const cap = app.config.crewCeilingUsd;
258 const cap = forgeCeiling(app.config);
258259 return {
259260 spendTodayUsd: Math.min(Math.round(spentTodayEverywhere(app.db, now) * 100) / 100, USD_MAX),
260 ceilingUsd: cap === undefined || !(cap > 0) ? null : Math.min(cap, USD_MAX),
261 ceilingUsd: cap === null ? null : Math.min(cap, USD_MAX),
261262 };
262263 }
263264
src/system/home_truth.ts +2 −1
@@ -8,6 +8,7 @@
88 import type { App } from "../app.ts";
99 import { listReposVisibleTo } from "../core/repos.ts";
1010 import type { User } from "../core/users.ts";
11 import { forgeCeiling } from "../flow/ceiling.ts";
1112 import { readKpis } from "../flow/kpis.ts";
1213 import { waitingOn } from "../web/studio/asks.ts";
1314
@@ -20,7 +21,7 @@ export function idleReason(b: { running: number; slots: number }, s: { todayUsd:
2021 }
2122
2223 export function homeTruth(app: App, user: User, now: Date = new Date()) {
23 const k = readKpis(app.db, now, { ceilingUsd: app.config.crewCeilingUsd ?? null });
24 const k = readKpis(app.db, now, { ceilingUsd: forgeCeiling(app.config) });
2425 const asks = waitingOn(app.db, user, listReposVisibleTo(app.db, user, { limit: 200, withoutArchived: true }));
2526 const oldest = asks.reduce<string | null>((o, a) => (o === null || Date.parse(a.since) < Date.parse(o) ? a.since : o), null);
2627 return {
src/tui/screens/dashboard.ts +1 −1
@@ -268,7 +268,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
268268 ? [
269269 alarmStuck(f.db, now),
270270 redMainAlarm(f.db, now),
271 alarmIdleCrew(f.db, now, { ceilingUsd: f.app.config.crewCeilingUsd ?? null }),
271 alarmIdleCrew(f.db, now, { ceilingUsd: forgeCeiling(f.app.config) }),
272272 noReleaseAlarm(f.db, now),
273273 ]
274274 .filter((a) => a.fired)
src/web/pages/crew.ts +2 −1
@@ -62,6 +62,7 @@ import { accountSections } from "./origins.ts";
6262 import { markOf } from "../../core/marks.ts";
6363 import { limitValue } from "../../settings/limits.ts";
6464 import { USERNAME_TAKEN } from "../../actions/signup_words.ts";
65 import { forgeCeiling } from "../../flow/ceiling.ts";
6566
6667 /** The admin, or the response that turns everyone else away. */
6768 function requireAdmin(ctx: Ctx): Response | undefined {
@@ -479,7 +480,7 @@ function crewView(
479480 const handover = crewHandover(ctx.app.db);
480481 return html`${crewReadiness(ctx)}
481482 ${when(spending.length > 0, () =>
482 spendingCard(spending, ctx.app.config.crewCeilingUsd),
483 spendingCard(spending, forgeCeiling(ctx.app.config) ?? undefined),
483484 )}
484485 ${agents.length === 0
485486 ? empty({
test/ceiling.test.ts added +88
@@ -0,0 +1,88 @@
1 /** The forge's daily crew ceiling (#5798): reading the cap, reaching it, and which run carried spend across it. */
2
3 import { beforeEach, describe, expect, test } from "bun:test";
4 import { Database } from "bun:sqlite";
5 import { ceilingHitAt, ceilingReached, forgeCeiling } from "../src/flow/ceiling.ts";
6
7 const NOW = new Date("2026-10-27T12:00:00.000Z");
8
9 describe("forgeCeiling", () => {
10 test("reads a positive cap", () => {
11 expect(forgeCeiling({ crewCeilingUsd: 25 })).toBe(25);
12 expect(forgeCeiling({ crewCeilingUsd: 0.5 })).toBe(0.5);
13 });
14 test("0, negative, NaN and missing are no cap", () => {
15 expect(forgeCeiling({ crewCeilingUsd: 0 })).toBeNull();
16 expect(forgeCeiling({ crewCeilingUsd: -1 })).toBeNull();
17 expect(forgeCeiling({ crewCeilingUsd: Number.NaN })).toBeNull();
18 expect(forgeCeiling({})).toBeNull();
19 });
20 });
21
22 describe("ceilingReached", () => {
23 test("true at exactly the cap and over, false just under", () => {
24 expect(ceilingReached(10, 10)).toBe(true);
25 expect(ceilingReached(10.01, 10)).toBe(true);
26 expect(ceilingReached(9.99, 10)).toBe(false);
27 });
28 test("false with no cap", () => {
29 expect(ceilingReached(1e9, null)).toBe(false);
30 });
31 });
32
33 describe("ceilingHitAt", () => {
34 let db: Database;
35 beforeEach(() => {
36 db = new Database(":memory:");
37 db.run("CREATE TABLE run (id TEXT PRIMARY KEY, kind TEXT NOT NULL, cost_usd REAL, created_at TEXT NOT NULL)");
38 });
39 let n = 0;
40 const add = (kind: string, cost: number | null, at: string, id = `r${++n}`) =>
41 db.run("INSERT INTO run (id, kind, cost_usd, created_at) VALUES (?, ?, ?, ?)", [id, kind, cost, at]);
42
43 test("names the run whose cost carried the day across the cap", () => {
44 add("agent", 4, "2026-10-27T01:00:00.000Z");
45 add("agent", 4, "2026-10-27T02:00:00.000Z");
46 add("agent", 4, "2026-10-27T03:00:00.000Z");
47 expect(ceilingHitAt(db, NOW, 10)).toBe("2026-10-27T03:00:00.000Z");
48 expect(ceilingHitAt(db, NOW, 8)).toBe("2026-10-27T02:00:00.000Z");
49 });
50
51 test("is null while under the cap, and with no cap", () => {
52 add("agent", 4, "2026-10-27T01:00:00.000Z");
53 expect(ceilingHitAt(db, NOW, 4.01)).toBeNull();
54 expect(ceilingHitAt(db, NOW, null)).toBeNull();
55 });
56
57 test("a run that lands exactly on the cap is the one", () => {
58 add("agent", 4, "2026-10-27T01:00:00.000Z");
59 add("agent", 6, "2026-10-27T02:00:00.000Z");
60 expect(ceilingHitAt(db, NOW, 10)).toBe("2026-10-27T02:00:00.000Z");
61 });
62
63 test("yesterday, non-agent runs and null costs do not count", () => {
64 add("agent", 100, "2026-10-26T23:59:59.999Z");
65 add("deploy", 100, "2026-10-27T00:30:00.000Z");
66 add("check", 100, "2026-10-27T00:40:00.000Z");
67 add("agent", null, "2026-10-27T01:00:00.000Z");
68 add("agent", 3, "2026-10-27T02:00:00.000Z");
69 expect(ceilingHitAt(db, NOW, 5)).toBeNull();
70 add("agent", 2, "2026-10-27T03:00:00.000Z");
71 expect(ceilingHitAt(db, NOW, 5)).toBe("2026-10-27T03:00:00.000Z");
72 });
73
74 test("runs are summed in time order, whatever order they were inserted", () => {
75 add("agent", 6, "2026-10-27T05:00:00.000Z");
76 add("agent", 6, "2026-10-27T01:00:00.000Z");
77 expect(ceilingHitAt(db, NOW, 10)).toBe("2026-10-27T05:00:00.000Z");
78 });
79
80 test("two runs in the same millisecond both count, and a later run follows them", () => {
outdated

This test passes with ORDER BY created_at alone, so it does not pin the rowid tie-break its name promises. Rename it to what it checks (a same-ms pair plus a later run) or delete it.

81 const at = "2026-10-27T04:00:00.000Z";
82 add("agent", 6, at, "z-first");
83 add("agent", 6, at, "a-second");
84 add("agent", 6, "2026-10-27T04:00:00.001Z", "later");
85 expect(ceilingHitAt(db, NOW, 10)).toBe(at);
86 expect(ceilingHitAt(db, NOW, 18)).toBe("2026-10-27T04:00:00.001Z");
87 });
88 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs