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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6700-a-test-pins-the-forge-s-daily-crew-ceili → main 2 commits fork 2 threads · 2 unresolved
6 files
changed +103
−7
src/flow/ceiling.ts +7 −2
| @@ -8,9 +8,14 @@ | ||
| 8 | 8 | import type { Database } from "bun:sqlite"; |
| 9 | 9 | import { startOfTodayUtc } from "../crew/autonomy.ts"; |
| 10 | 10 | |
| 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 | */ | |
| 12 | 16 | export function forgeCeiling(config: { crewCeilingUsd?: number }): number | null { |
| 13 | return config.crewCeilingUsd ?? null; | |
| 17 | const cap = config.crewCeilingUsd; | |
| 18 | return typeof cap === "number" && cap > 0 ? cap : null; | |
| 14 | 19 | } |
| 15 | 20 | |
| 16 | 21 | /** 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"; | ||
| 25 | 25 | import { upstreamState } from "./upstream.ts"; |
| 26 | 26 | import { progress } from "../core/next-steps.ts"; |
| 27 | 27 | import { running } from "../kernel/version.ts"; |
| 28 | import { forgeCeiling } from "../flow/ceiling.ts"; | |
| 28 | 29 | |
| 29 | 30 | export const SIGNATURE_HEADER = "x-forge-signature"; |
| 30 | 31 | /** 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 { | ||
| 254 | 255 | |
| 255 | 256 | /** What the crew spent today, and the daily ceiling that pauses it (null when the forge sets none). */ |
| 256 | 257 | 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); | |
| 258 | 259 | return { |
| 259 | 260 | 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), | |
| 261 | 262 | }; |
| 262 | 263 | } |
| 263 | 264 |
src/system/home_truth.ts +2 −1
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | import type { App } from "../app.ts"; |
| 9 | 9 | import { listReposVisibleTo } from "../core/repos.ts"; |
| 10 | 10 | import type { User } from "../core/users.ts"; |
| 11 | import { forgeCeiling } from "../flow/ceiling.ts"; | |
| 11 | 12 | import { readKpis } from "../flow/kpis.ts"; |
| 12 | 13 | import { waitingOn } from "../web/studio/asks.ts"; |
| 13 | 14 | |
| @@ -20,7 +21,7 @@ export function idleReason(b: { running: number; slots: number }, s: { todayUsd: | ||
| 20 | 21 | } |
| 21 | 22 | |
| 22 | 23 | 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) }); | |
| 24 | 25 | const asks = waitingOn(app.db, user, listReposVisibleTo(app.db, user, { limit: 200, withoutArchived: true })); |
| 25 | 26 | const oldest = asks.reduce<string | null>((o, a) => (o === null || Date.parse(a.since) < Date.parse(o) ? a.since : o), null); |
| 26 | 27 | return { |
src/tui/screens/dashboard.ts +1 −1
| @@ -268,7 +268,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 268 | 268 | ? [ |
| 269 | 269 | alarmStuck(f.db, now), |
| 270 | 270 | 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) }), | |
| 272 | 272 | noReleaseAlarm(f.db, now), |
| 273 | 273 | ] |
| 274 | 274 | .filter((a) => a.fired) |
src/web/pages/crew.ts +2 −1
| @@ -62,6 +62,7 @@ import { accountSections } from "./origins.ts"; | ||
| 62 | 62 | import { markOf } from "../../core/marks.ts"; |
| 63 | 63 | import { limitValue } from "../../settings/limits.ts"; |
| 64 | 64 | import { USERNAME_TAKEN } from "../../actions/signup_words.ts"; |
| 65 | import { forgeCeiling } from "../../flow/ceiling.ts"; | |
| 65 | 66 | |
| 66 | 67 | /** The admin, or the response that turns everyone else away. */ |
| 67 | 68 | function requireAdmin(ctx: Ctx): Response | undefined { |
| @@ -479,7 +480,7 @@ function crewView( | ||
| 479 | 480 | const handover = crewHandover(ctx.app.db); |
| 480 | 481 | return html`${crewReadiness(ctx)} |
| 481 | 482 | ${when(spending.length > 0, () => |
| 482 | spendingCard(spending, ctx.app.config.crewCeilingUsd), | |
| 483 | spendingCard(spending, forgeCeiling(ctx.app.config) ?? undefined), | |
| 483 | 484 | )} |
| 484 | 485 | ${agents.length === 0 |
| 485 | 486 | ? 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 |
||
| 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 | }); |
new above
offline
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.