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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8897-weekly-pacing-aims-at-spending-what-s-le → main 3 commits fork
3 files
changed +82
−71
src/crew/week.ts +29 −29
| @@ -8,14 +8,16 @@ | ||
| 8 | 8 | * The figures are percents of the week the provider's limit holds, the same |
| 9 | 9 | * `free` share the `usage` tool prints, never a dollar cap of our own. |
| 10 | 10 | * |
| 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. | |
| 16 | 18 | * - Unknown: no limit known is not a fine week. The crew holds at |
| 17 | 19 | * `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 | |
| 19 | 21 | * is released, one per look. |
| 20 | 22 | * - Always: `WEEK.reserve` of the week's crew capacity is kept for `/talk` |
| 21 | 23 | * and a person's own requests; the crew stops at it. |
| @@ -25,11 +27,11 @@ import { WINDOW_MS } from "../core/standing.ts"; | ||
| 25 | 27 | |
| 26 | 28 | /** The week's settings: plain values, read here and nowhere else. */ |
| 27 | 29 | 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, | |
| 30 | 32 | /** The share of the week's crew capacity kept for conversations and a person's own requests. */ |
| 31 | 33 | 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. */ | |
| 33 | 35 | floorSlots: 1, |
| 34 | 36 | /** Slots the crew holds at while the week's capacity is unknown. */ |
| 35 | 37 | unknownSlots: 2, |
| @@ -68,8 +70,8 @@ export interface WeekDecision { | ||
| 68 | 70 | readonly status: Status; |
| 69 | 71 | /** Percent of the week's limit that would be spent an hour to use the crew's rest by the reset; null when unknown. */ |
| 70 | 72 | 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; | |
| 73 | 75 | /** When the burn would empty the crew's share of the week; null when it lasts to the reset or is unread. */ |
| 74 | 76 | readonly runsOutAt: Date | null; |
| 75 | 77 | /** The slots the week allows, 0..ceiling. */ |
| @@ -92,7 +94,7 @@ export function weekDecision(input: WeekInput): WeekDecision | null { | ||
| 92 | 94 | return { |
| 93 | 95 | status: "unknown", |
| 94 | 96 | targetPctPerHour: null, |
| 95 | offPct: 0, | |
| 97 | allowedPctPerHour: null, | |
| 96 | 98 | runsOutAt: null, |
| 97 | 99 | slots, |
| 98 | 100 | release: false, |
| @@ -102,27 +104,23 @@ export function weekDecision(input: WeekInput): WeekDecision | null { | ||
| 102 | 104 | const left = meter.resetsAt.getTime() - now.getTime(); |
| 103 | 105 | if (left <= 0 || left > WINDOW_MS.week) return null; |
| 104 | 106 | const hours = left / 3_600_000; |
| 105 | const elapsed = 1 - left / WINDOW_MS.week; | |
| 106 | 107 | const capPct = Math.max(0, 100 - meter.keptPct); |
| 107 | 108 | const used = 100 - meter.freePct; |
| 108 | 109 | const wall = capPct * (1 - WEEK.reserve); |
| 109 | const should = capPct * elapsed; | |
| 110 | const offPct = used - should; | |
| 111 | const band = capPct * WEEK.tolerance; | |
| 112 | 110 | const targetPctPerHour = Math.max(0, capPct - used) / hours; |
| 113 | 111 | |
| 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. | |
| 116 | 113 | const room = Math.max(0, wall - used); |
| 117 | 114 | 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); | |
| 119 | 118 | const runsOutAt = burn !== null && burn > 0 && burning ? new Date(now.getTime() + (room / burn) * 3_600_000) : null; |
| 120 | 119 | |
| 121 | const status: Status = offPct > band || burning ? "ahead" : offPct < -band ? "behind" : "on pace"; | |
| 120 | const status: Status = burning ? "ahead" : idling ? "behind" : "on pace"; | |
| 122 | 121 | |
| 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))); | |
| 126 | 124 | // The reserve is a wall: past it the crew waits for the reset. |
| 127 | 125 | if (used >= wall) slots = 0; |
| 128 | 126 | slots = Math.min(ceiling, Math.max(0, slots)); |
| @@ -130,12 +128,14 @@ export function weekDecision(input: WeekInput): WeekDecision | null { | ||
| 130 | 128 | const release = status === "behind" && input.idleSlots > 0 && input.startable === 0 && input.gated > 0; |
| 131 | 129 | const weekLeft = `${pct(meter.freePct)} of the week left, reset ${utc(meter.resetsAt)}`; |
| 132 | 130 | 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 }; | |
| 139 | 139 | } |
| 140 | 140 | |
| 141 | 141 | /** 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 { | ||
| 55 | 55 | } |
| 56 | 56 | |
| 57 | 57 | /** 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 { | |
| 59 | 59 | app.db.query("DELETE FROM usage").run(); |
| 60 | 60 | spend(100, new Date(NOW.getTime() - 4 * DAY)); |
| 61 | 61 | noteLimit(app.db, sub, { |
| @@ -64,7 +64,9 @@ function week(usedSoFar: number): void { | ||
| 64 | 64 | observedAt: new Date(NOW.getTime() - 3.5 * DAY - 3_600_000).toISOString(), |
| 65 | 65 | said: "weekly limit", |
| 66 | 66 | }); |
| 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)); | |
| 68 | 70 | forgetWeek(); |
| 69 | 71 | } |
| 70 | 72 | |
| @@ -124,7 +126,7 @@ test("on pace, the gated issue stays behind the gate", async () => { | ||
| 124 | 126 | if (!again.ok) throw new Error("issue"); |
| 125 | 127 | expect(addDependency(app.db, again.value, gate, alice.id).ok).toBe(true); |
| 126 | 128 | // Nothing startable remains for the first issue once it is picked; pace is what decides. |
| 127 | week(50); | |
| 129 | week(50, 1.4); | |
| 128 | 130 | expect(await driveRepo(app, repo, NOW)).not.toBe("released"); |
| 129 | 131 | expect(getIssue(app.db, repo.id, again.value.number)).toBeDefined(); |
| 130 | 132 | 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 () => { | ||
| 132 | 134 | }); |
| 133 | 135 | |
| 134 | 136 | 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); | |
| 137 | 139 | expect(repoPace(app, repo, NOW).reason).toContain("Ahead of pace"); |
| 138 | 140 | week(95); |
| 139 | 141 | expect(repoPace(app, repo, NOW).slots).toBe(0); |
| 140 | week(50); | |
| 142 | week(50, 1.4); | |
| 141 | 143 | expect(repoPace(app, repo, NOW).slots).toBe(4); |
| 142 | 144 | }); |
| 143 | 145 | |
| 144 | 146 | test("home and the terminal print the same sentence, the terminal for an admin", async () => { |
| 145 | 147 | week(20); |
| 146 | 148 | const words = weekWords(app, NOW)!; |
| 147 | expect(words).toContain("Behind pace by 30%"); | |
| 149 | expect(words).toContain("Behind pace: 80% of the week left"); | |
| 148 | 150 | 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"); | |
| 150 | 152 | const terminal = headlessTerminal(240, 32); |
| 151 | 153 | const { app: tui } = createForgeApp( |
| 152 | 154 | app, |
test/week.test.ts +43 −34
| @@ -32,40 +32,41 @@ describe("weekDecision", () => { | ||
| 32 | 32 | }); |
| 33 | 33 | |
| 34 | 34 | 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 }); | |
| 36 | 36 | }); |
| 37 | 37 | |
| 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 }) }); | |
| 40 | 41 | expect(d.status).toBe("on pace"); |
| 41 | 42 | expect(d.targetPctPerHour).toBeCloseTo(50 / 84); |
| 43 | expect(d.allowedPctPerHour).toBeCloseTo(40 / 84); | |
| 42 | 44 | expect(d.release).toBe(false); |
| 43 | 45 | expect(d.slots).toBe(4); |
| 44 | 46 | expect(d.sentence).toContain("On pace: 50% of the week left"); |
| 45 | 47 | }); |
| 46 | 48 | |
| 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", () => { | |
| 48 | 50 | const d = used(20, { burnPctPerHour: 0 }); |
| 49 | 51 | expect(d.status).toBe("behind"); |
| 50 | 52 | 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"); | |
| 52 | 55 | }); |
| 53 | 56 | |
| 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", () => { | |
| 55 | 64 | const meter = meterOf({ freePct: 80, burnPctPerHour: 0 }); |
| 56 | 65 | expect(decide({ meter, startable: 1 }).release).toBe(false); |
| 57 | 66 | expect(decide({ meter, idleSlots: 0 }).release).toBe(false); |
| 58 | 67 | expect(decide({ meter, gated: 0 }).release).toBe(false); |
| 59 | 68 | }); |
| 60 | 69 | |
| 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 | ||
| 69 | 70 | test("the reserve for people is a wall", () => { |
| 70 | 71 | expect(used(90, { burnPctPerHour: 0 }).slots).toBe(0); |
| 71 | 72 | expect(used(89, { burnPctPerHour: 0 }).slots).toBeGreaterThan(0); |
| @@ -75,9 +76,7 @@ describe("weekDecision", () => { | ||
| 75 | 76 | expect(used(45, { keptPct: 50, burnPctPerHour: 0 }).slots).toBe(0); |
| 76 | 77 | }); |
| 77 | 78 | |
| 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", () => { | |
| 81 | 80 | const fast = used(50, { burnPctPerHour: 2.7 }); |
| 82 | 81 | expect(fast.status).toBe("ahead"); |
| 83 | 82 | expect(fast.slots).toBe(1); |
| @@ -85,23 +84,33 @@ describe("weekDecision", () => { | ||
| 85 | 84 | expect(fast.sentence).toContain("running out about"); |
| 86 | 85 | }); |
| 87 | 86 | |
| 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"); | |
| 105 | 114 | }); |
| 106 | 115 | |
| 107 | 116 | test("unknown capacity is not fine: the crew holds at a modest level and home says so", () => { |
new above
offline