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

Weekly pacing aims at spending what's left by the reset, not at an even week: it must not idle the crew until Thursday #8898

Merged opened this in answer to

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

Merged

mason/forge:mason/8897-weekly-pacing-aims-at-spending-what-s-le → main 3 commits fork

  • Merged. mason/8897-weekly-pacing-aims-at-spending-what-s-le was merged into main as 709e4ca.

Review

Sign in to review this change.
3 files changed +82 −71
src/crew/week.ts +29 −29
@@ -8,14 +8,16 @@
88 * The figures are percents of the week the provider's limit holds, the same
99 * `free` share the `usage` tool prints, never a dollar cap of our own.
1010 *
11 * - Pace: the week's elapsed share is what should be spent by now. Within
12 * `WEEK.tolerance` of it the crew is on pace; below, behind; above, ahead.
13 * - Burn: the last hours' spend, held against what the week can still take.
14 * A burn that empties it before the reset slows the crew whatever the
15 * week's total says, and names when it would run out.
11 * - Allowed: what the week has left above the reserve, over the hours to the
12 * reset. What was spent is sunk; the only question is whether the current
13 * burn spends the rest by then. Within `WEEK.tolerance` of allowed the crew
14 * is on pace; burning faster slows it; burning slower lets it rise to the
15 * ceiling and, with builders idle, releases work.
16 * - No burn known yet: the crew starts at `WEEK.unknownSlots` and adjusts as
17 * the hour's spend arrives.
1618 * - Unknown: no limit known is not a fine week. The crew holds at
1719 * `WEEK.unknownSlots` and home says the pace is unknown.
18 * - Behind with builders idle and nothing startable: the next gated issue
20 * - Under allowed with builders idle and nothing startable: the next gated issue
1921 * is released, one per look.
2022 * - Always: `WEEK.reserve` of the week's crew capacity is kept for `/talk`
2123 * and a person's own requests; the crew stops at it.
@@ -25,11 +27,11 @@ import { WINDOW_MS } from "../core/standing.ts";
2527
2628 /** The week's settings: plain values, read here and nowhere else. */
2729 export const WEEK = {
28 /** How far from the even pace still counts as on pace, as a share of the week's capacity. */
29 tolerance: 0.05,
30 /** How far from allowed a burn may sit and still count as on pace, as a share of allowed. */
31 tolerance: 0.25,
3032 /** The share of the week's crew capacity kept for conversations and a person's own requests. */
3133 reserve: 0.1,
32 /** Slots ahead of pace never fall below this while the reserve holds. */
34 /** Slots never fall below this while the reserve holds. */
3335 floorSlots: 1,
3436 /** Slots the crew holds at while the week's capacity is unknown. */
3537 unknownSlots: 2,
@@ -68,8 +70,8 @@ export interface WeekDecision {
6870 readonly status: Status;
6971 /** Percent of the week's limit that would be spent an hour to use the crew's rest by the reset; null when unknown. */
7072 readonly targetPctPerHour: number | null;
71 /** Spent minus what an even pace would have spent by now, in percent of the limit; 0 when unknown. */
72 readonly offPct: number;
73 /** Percent of the limit the crew may spend an hour and still have the reserve at the reset; null when unknown. */
74 readonly allowedPctPerHour: number | null;
7375 /** When the burn would empty the crew's share of the week; null when it lasts to the reset or is unread. */
7476 readonly runsOutAt: Date | null;
7577 /** The slots the week allows, 0..ceiling. */
@@ -92,7 +94,7 @@ export function weekDecision(input: WeekInput): WeekDecision | null {
9294 return {
9395 status: "unknown",
9496 targetPctPerHour: null,
95 offPct: 0,
97 allowedPctPerHour: null,
9698 runsOutAt: null,
9799 slots,
98100 release: false,
@@ -102,27 +104,23 @@ export function weekDecision(input: WeekInput): WeekDecision | null {
102104 const left = meter.resetsAt.getTime() - now.getTime();
103105 if (left <= 0 || left > WINDOW_MS.week) return null;
104106 const hours = left / 3_600_000;
105 const elapsed = 1 - left / WINDOW_MS.week;
106107 const capPct = Math.max(0, 100 - meter.keptPct);
107108 const used = 100 - meter.freePct;
108109 const wall = capPct * (1 - WEEK.reserve);
109 const should = capPct * elapsed;
110 const offPct = used - should;
111 const band = capPct * WEEK.tolerance;
112110 const targetPctPerHour = Math.max(0, capPct - used) / hours;
113111
114 // Burn: what the last hours spent, held against what the crew may still spend before the reserve.
115 const burn = meter.burnPctPerHour;
112 // Allowed: what the crew may still spend before the reserve, over the hours left.
116113 const room = Math.max(0, wall - used);
117114 const allowed = room / hours;
118 const burning = burn !== null && burn > allowed;
115 const burn = meter.burnPctPerHour;
116 const burning = used >= wall || (burn !== null && burn > allowed * (1 + WEEK.tolerance));
117 const idling = used < wall && burn !== null && burn < allowed * (1 - WEEK.tolerance);
119118 const runsOutAt = burn !== null && burn > 0 && burning ? new Date(now.getTime() + (room / burn) * 3_600_000) : null;
120119
121 const status: Status = offPct > band || burning ? "ahead" : offPct < -band ? "behind" : "on pace";
120 const status: Status = burning ? "ahead" : idling ? "behind" : "on pace";
122121
123 let slots = ceiling;
124 if (offPct > band) slots = Math.max(WEEK.floorSlots, Math.floor((ceiling * should) / Math.max(used, 0.0001)));
125 if (burning) slots = Math.min(slots, Math.max(WEEK.floorSlots, Math.floor((ceiling * allowed) / burn)));
122 let slots = burn === null ? Math.min(ceiling, WEEK.unknownSlots) : ceiling;
123 if (burning && burn !== null && burn > 0) slots = Math.min(slots, Math.max(WEEK.floorSlots, Math.floor((ceiling * allowed) / burn)));
126124 // The reserve is a wall: past it the crew waits for the reset.
127125 if (used >= wall) slots = 0;
128126 slots = Math.min(ceiling, Math.max(0, slots));
@@ -130,12 +128,14 @@ export function weekDecision(input: WeekInput): WeekDecision | null {
130128 const release = status === "behind" && input.idleSlots > 0 && input.startable === 0 && input.gated > 0;
131129 const weekLeft = `${pct(meter.freePct)} of the week left, reset ${utc(meter.resetsAt)}`;
132130 const sentence =
133 status === "on pace"
134 ? `On pace: ${weekLeft}; ${pct(targetPctPerHour)} an hour spends it by then.`
135 : status === "ahead"
136 ? `Ahead of pace: ${weekLeft}${burn === null ? "" : `, burning ${pct(burn)} an hour`}${runsOutAt === null ? "" : `, running out about ${utc(runsOutAt)}`}; ${slots === 0 ? "holding the crew for the reset" : `slowing to ${slots} of ${ceiling} slots`}.`
137 : `Behind pace by ${pct(-offPct)}: ${weekLeft}; ${pct(targetPctPerHour)} an hour would spend it by then.`;
138 return { status, targetPctPerHour, offPct, runsOutAt, slots, release, sentence };
131 burn === null
132 ? `Burn not known yet: ${weekLeft}; starting at ${slots} of ${ceiling} slots and adjusting within the hour.`
133 : status === "on pace"
134 ? `On pace: ${weekLeft}; burning ${pct(burn)} an hour, ${pct(allowed)} is allowed.`
135 : status === "ahead"
136 ? `Ahead of pace: ${weekLeft}, burning ${pct(burn)} an hour against ${pct(allowed)} allowed${runsOutAt === null ? "" : `, running out about ${utc(runsOutAt)}`}; ${slots === 0 ? "holding the crew for the reset" : `slowing to ${slots} of ${ceiling} slots`}.`
137 : `Behind pace: ${weekLeft}, burning ${pct(burn)} an hour against ${pct(allowed)} allowed; ${slots === 0 ? "holding the crew for the reset" : `speeding up to ${slots} of ${ceiling} slots`}${release ? " and releasing the next issue" : ""}.`;
138 return { status, targetPctPerHour, allowedPctPerHour: allowed, runsOutAt, slots, release, sentence };
139139 }
140140
141141 /** What each real landing cost, and how many runs it took, over a window. */
test/week-wiring.test.ts +10 −8
@@ -55,7 +55,7 @@ function spend(usd: number, at: Date): void {
5555 }
5656
5757 /** The week is half over; last week the provider refused at $100, so the cap is known. */
58 function week(usedSoFar: number): void {
58 function week(usedSoFar: number, recent = 0): void {
5959 app.db.query("DELETE FROM usage").run();
6060 spend(100, new Date(NOW.getTime() - 4 * DAY));
6161 noteLimit(app.db, sub, {
@@ -64,7 +64,9 @@ function week(usedSoFar: number): void {
6464 observedAt: new Date(NOW.getTime() - 3.5 * DAY - 3_600_000).toISOString(),
6565 said: "weekly limit",
6666 });
67 if (usedSoFar > 0) spend(usedSoFar, new Date(NOW.getTime() - DAY));
67 if (usedSoFar - recent > 0) spend(usedSoFar - recent, new Date(NOW.getTime() - DAY));
68 // `recent` dollars fall inside the burn's window: recent / 3 points an hour.
69 if (recent > 0) spend(recent, new Date(NOW.getTime() - 3_600_000));
6870 forgetWeek();
6971 }
7072
@@ -124,7 +126,7 @@ test("on pace, the gated issue stays behind the gate", async () => {
124126 if (!again.ok) throw new Error("issue");
125127 expect(addDependency(app.db, again.value, gate, alice.id).ok).toBe(true);
126128 // Nothing startable remains for the first issue once it is picked; pace is what decides.
127 week(50);
129 week(50, 1.4);
128130 expect(await driveRepo(app, repo, NOW)).not.toBe("released");
129131 expect(getIssue(app.db, repo.id, again.value.number)).toBeDefined();
130132 const blockers = app.db.query("SELECT COUNT(*) AS n FROM issue_dependency WHERE issue_id = ?1").get(again.value.id) as { n: number };
@@ -132,21 +134,21 @@ test("on pace, the gated issue stays behind the gate", async () => {
132134 });
133135
134136 test("ahead of pace, the pace cuts the slots, and the reserve stops them", () => {
135 week(70);
136 expect(repoPace(app, repo, NOW).slots).toBe(2);
137 week(70, 6);
138 expect(repoPace(app, repo, NOW).slots).toBe(1);
137139 expect(repoPace(app, repo, NOW).reason).toContain("Ahead of pace");
138140 week(95);
139141 expect(repoPace(app, repo, NOW).slots).toBe(0);
140 week(50);
142 week(50, 1.4);
141143 expect(repoPace(app, repo, NOW).slots).toBe(4);
142144 });
143145
144146 test("home and the terminal print the same sentence, the terminal for an admin", async () => {
145147 week(20);
146148 const words = weekWords(app, NOW)!;
147 expect(words).toContain("Behind pace by 30%");
149 expect(words).toContain("Behind pace: 80% of the week left");
148150 expect(words).toContain("Nothing has landed");
149 expect(String(weekLine(app, NOW))).toContain("Behind pace by 30%");
151 expect(String(weekLine(app, NOW))).toContain("Behind pace: 80% of the week left");
150152 const terminal = headlessTerminal(240, 32);
151153 const { app: tui } = createForgeApp(
152154 app,
test/week.test.ts +43 −34
@@ -32,40 +32,41 @@ describe("weekDecision", () => {
3232 });
3333
3434 test("settings are plain values", () => {
35 expect(WEEK).toEqual({ tolerance: 0.05, reserve: 0.1, floorSlots: 1, unknownSlots: 2, burnHours: 3 });
35 expect(WEEK).toEqual({ tolerance: 0.25, reserve: 0.1, floorSlots: 1, unknownSlots: 2, burnHours: 3 });
3636 });
3737
38 test("on pace: the target burn is the rest over the time left", () => {
39 const d = decide({});
38 test("on pace: a burn near allowed keeps the ceiling", () => {
39 // 50% used, 84 hours left, 10% reserve: 40 / 84 = 0.476 an hour allowed.
40 const d = decide({ meter: meterOf({ burnPctPerHour: 0.45 }) });
4041 expect(d.status).toBe("on pace");
4142 expect(d.targetPctPerHour).toBeCloseTo(50 / 84);
43 expect(d.allowedPctPerHour).toBeCloseTo(40 / 84);
4244 expect(d.release).toBe(false);
4345 expect(d.slots).toBe(4);
4446 expect(d.sentence).toContain("On pace: 50% of the week left");
4547 });
4648
47 test("behind with builders idle and nothing startable releases the next gated issue", () => {
49 test("burning under allowed with builders idle and nothing startable releases the next gated issue", () => {
4850 const d = used(20, { burnPctPerHour: 0 });
4951 expect(d.status).toBe("behind");
5052 expect(d.release).toBe(true);
51 expect(d.sentence).toContain("Behind pace by 30%");
53 expect(d.slots).toBe(4);
54 expect(d.sentence).toContain("releasing the next issue");
5255 });
5356
54 test("behind does not release while work is startable, builders are busy, or nothing is gated", () => {
57 test("what was spent is sunk: a heavy week so far does not slow a crew that burns under allowed", () => {
58 const d = used(70, { burnPctPerHour: 0 });
59 expect(d.status).toBe("behind");
60 expect(d.slots).toBe(4);
61 });
62
63 test("under allowed does not release while work is startable, builders are busy, or nothing is gated", () => {
5564 const meter = meterOf({ freePct: 80, burnPctPerHour: 0 });
5665 expect(decide({ meter, startable: 1 }).release).toBe(false);
5766 expect(decide({ meter, idleSlots: 0 }).release).toBe(false);
5867 expect(decide({ meter, gated: 0 }).release).toBe(false);
5968 });
6069
61 test("ahead brings the slots down before the limit", () => {
62 const d = used(70, { burnPctPerHour: 0 });
63 expect(d.status).toBe("ahead");
64 expect(d.slots).toBe(2);
65 expect(d.release).toBe(false);
66 expect(decide({ ceiling: 2, meter: meterOf({ freePct: 11, burnPctPerHour: 0 }) }).slots).toBe(1);
67 });
68
6970 test("the reserve for people is a wall", () => {
7071 expect(used(90, { burnPctPerHour: 0 }).slots).toBe(0);
7172 expect(used(89, { burnPctPerHour: 0 }).slots).toBeGreaterThan(0);
@@ -75,9 +76,7 @@ describe("weekDecision", () => {
7576 expect(used(45, { keptPct: 50, burnPctPerHour: 0 }).slots).toBe(0);
7677 });
7778
78 test("a fast burn slows the crew even when the week's total is on pace", () => {
79 const calm = used(50, { burnPctPerHour: 0.3 });
80 expect(calm.slots).toBe(4);
79 test("a fast burn slows the crew", () => {
8180 const fast = used(50, { burnPctPerHour: 2.7 });
8281 expect(fast.status).toBe("ahead");
8382 expect(fast.slots).toBe(1);
@@ -85,23 +84,33 @@ describe("weekDecision", () => {
8584 expect(fast.sentence).toContain("running out about");
8685 });
8786
88 test("today's numbers: 46% free, 2.7 points an hour, 5.3 days to the reset, slows the crew", () => {
89 const now = new Date("2026-11-02T19:25:00Z");
90 const d = weekDecision({
91 meter: { freePct: 46, keptPct: 0, burnPctPerHour: 2.7, resetsAt: new Date("2026-11-08T02:00:00Z") },
92 now,
93 ceiling: 5,
94 idleSlots: 0,
95 startable: 0,
96 gated: 0,
97 })!;
98 expect(d.status).toBe("ahead");
99 expect(d.slots).toBeLessThan(5);
100 expect(d.slots).toBeGreaterThanOrEqual(1);
101 expect(d.sentence).toContain("46% of the week left");
102 expect(d.sentence).toContain("running out about 2026-11-03");
103 // `/talk` keeps its reserve: at the wall the crew stops, with the reserve still unspent.
104 expect(d.runsOutAt!.getTime()).toBeLessThan(now.getTime() + 17 * 3_600_000);
87 test("2026-11-02: 2.7 points an hour slows hard, then flat at 45% free speeds up and releases", () => {
88 const resetsAt = new Date("2026-11-08T02:00:00Z");
89 const at = (iso: string, freePct: number, burnPctPerHour: number) =>
90 weekDecision({
91 meter: { freePct, keptPct: 0, burnPctPerHour, resetsAt },
92 now: new Date(iso),
93 ceiling: 5,
94 idleSlots: 3,
95 startable: 0,
96 gated: 2,
97 })!;
98 const hot = at("2026-11-02T19:25:00Z", 46, 2.7);
99 expect(hot.status).toBe("ahead");
100 expect(hot.slots).toBe(WEEK.floorSlots);
101 expect(hot.release).toBe(false);
102 expect(hot.sentence).toContain("running out about 2026-11-03");
103 const flat = at("2026-11-02T20:45:00Z", 45, 0);
104 expect(flat.status).toBe("behind");
105 expect(flat.slots).toBe(5);
106 expect(flat.release).toBe(true);
107 });
108
109 test("with no burn known yet the crew starts modest", () => {
110 const d = used(50, { burnPctPerHour: null });
111 expect(d.slots).toBe(WEEK.unknownSlots);
112 expect(d.release).toBe(false);
113 expect(d.sentence).toContain("adjusting within the hour");
105114 });
106115
107116 test("unknown capacity is not fine: the crew holds at a modest level and home says so", () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs