From 0d7030cc309e110449bf3b2955ff200ab900c26a Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Sun, 23 Aug 2026 00:35:15 +0300 Subject: [PATCH] A routine's Run now is not held back by the sweep's cooldown (#1642) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After any run on a project, the sweep's 30-minute cooldown stood every later pass down with "a run was started for this project a moment ago". Since #1210 a Run now is a sweep, so for half an hour the button could start nothing — the stand-down is reported in small text under the fold of the card, after the click, while the button stays enabled and its tooltip still promises agents. Restarting the daemon cleared it, which is how the #1640 dogfood got past it. The cooldown paces work nobody asked for. A click is someone asking, the same reading that already lets an on-demand sweep outrank the master switch. So `autoPmDecision` takes `onDemand` and skips the cooldown for it; the sweep passes the flag through. The concurrency cap is deliberately not waived with it. The #685 test names the cooldown as what stops a tick landing before the spawn registers from doubling up; for a click that guard is the cap alone, which holds because `start()` resolves after the spawn and ticks are serialized, so a second click counts the first. The new decision test pins both halves. `auto-pm.SPEC.md` said Run now skips only the master switch; it now names the cooldown too, and why. Co-Authored-By: Claude Fable 5 --- packages/the-framework/src/auto-pm.SPEC.md | 4 ++-- packages/the-framework/src/auto-pm.test.ts | 22 +++++++++++++++++++++- packages/the-framework/src/auto-pm.ts | 15 ++++++++++++--- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/the-framework/src/auto-pm.SPEC.md b/packages/the-framework/src/auto-pm.SPEC.md index 4c0faa70a..a544bf044 100644 --- a/packages/the-framework/src/auto-pm.SPEC.md +++ b/packages/the-framework/src/auto-pm.SPEC.md @@ -9,7 +9,7 @@ Auto PM spends leftover subscription quota on the product's own roadmap: while t ## Flows -- Whether a pass starts anything is one pure policy question per project — enabled, under the concurrency cap, past the cooldown, queue readable, quota under the boundary; the sweep loop only supplies the readings. +- Whether a pass starts anything is one pure policy question per project — enabled, under the concurrency cap, past the cooldown unless a person asked, queue readable, quota under the boundary; the sweep loop only supplies the readings. - A standing queue is drained before new work is invented; a calendar-paced codebase maintenance sweep outranks the rotation when due, and only ever while the queue is genuinely empty. - Draining and planning fan out, one pinned queue entry or locked ticket per agent, so concurrent agents do disjoint work; every other routine stays one per pass since concurrent copies would undo each other. - Both phases claim their ticket with a pushed lock file before the agent starts, so agents on other machines cannot double-book it: planning locks the ticket it will plan, and draining locks the ticket its queue entry links back to. An entry claimed elsewhere is dropped from the batch, and an entry with no ticket behind it keeps the queue itself as the coordination point. @@ -22,7 +22,7 @@ Auto PM spends leftover subscription quota on the product's own roadmap: while t - An unreadable quota fails closed — the opposite of the per-agent guard: quietly burning quota on work nobody asked for is worse than skipping a pass. - Where the account stands is asked per project rather than once per pass, because the model a project's work would run on is a project setting and each model's own weekly allowance binds alongside the account's. Two projects on two models can therefore stand at two different places against the same reading. -- "Run now" skips only the master switch: the click is the consent the preference exists to record; every other stand-down holds. +- "Run now" skips the master switch and the cooldown: the click is the consent the preference exists to record, and the cooldown paces work nobody asked for — without skipping it, the button could start nothing for half an hour after any run. Every other stand-down holds: a click cannot exceed the concurrency cap, and that cap is what keeps a second click from doubling up. - A switched-off draining routine falls through to the rotation rather than standing the pass down, because a stand-down would make every inventing routine unreachable whenever the queue holds anything — and the queue is auto-populated, so it usually does. - The ticketless hand-off window is accepted rather than closed: closing it would take a durable per-entry claim — a second claim shape beside the pushed ticket lock that already covers the queue's normal case — for a race whose cost is a duplicated attempt, never lost work. diff --git a/packages/the-framework/src/auto-pm.test.ts b/packages/the-framework/src/auto-pm.test.ts index 5f74d8064..9b1602fa2 100644 --- a/packages/the-framework/src/auto-pm.test.ts +++ b/packages/the-framework/src/auto-pm.test.ts @@ -92,6 +92,15 @@ test('autoPmDecision holds off during the cooldown after a start (#685)', () => assert.deepEqual(later, { start: true, mode: 'pm' }) }) +test('autoPmDecision lets an asked-for pass through the cooldown (#1642)', () => { + // The cooldown paces the unattended sweep; a click is a person asking, so it does not apply. + const decision = autoPmDecision({ ...IDLE, sinceLastStartMs: 60_000, onDemand: true }) + assert.deepEqual(decision, { start: true, mode: 'pm' }) + // The concurrency cap is not waived with it: that is what stops a second click doubling up. + const capped = autoPmDecision({ ...IDLE, sinceLastStartMs: 60_000, onDemand: true, activeAgents: 1, concurrency: 1 }) + assert.equal(capped.start, false) +}) + test('quotaHeadroom refuses to start when the quota cannot be read (#685)', () => { // The inverse of the per-agent guard's fail-open (#519): that one must never STOP the user's // own work, this one must never START work nobody asked for on an unknown budget. @@ -200,7 +209,7 @@ test('an on-demand tick sweeps with the preference off: the click is the ask (#1 assert.equal(report.outcomes[0]?.started, true) }) -test('on demand skips only the master switch: every other stand-down still holds (#1210)', async () => { +test('on demand skips the master switch and the cooldown: every other stand-down still holds (#1210/#1642)', async () => { const { loop, started } = harness({ enabled: async () => false, activeAgents: () => 1 }) await loop.tick({ onDemand: true }) loop.stop() @@ -208,6 +217,17 @@ test('on demand skips only the master switch: every other stand-down still holds assert.match(loop.report().outcomes[0]?.message ?? '', /already going/) }) +test('a Run now right after a run starts anyway: the cooldown is for work nobody asked for (#1642)', async () => { + // Same two ticks as the #685 double-up test above, the second one a click. The sweep's own + // cooldown held the button for half an hour after any run, and the card said so in small + // text under the fold — a button that did nothing, to anyone who clicked and looked away. + const { loop, started } = harness() + await loop.tick() + await loop.tick({ onDemand: true }) + loop.stop() + assert.deepEqual(started, ['p1', 'p1']) +}) + test('startAutoPm does not start a second run for the same project (#685)', async () => { // The cooldown is what stops a tick that lands before the spawn registers from doubling up. const { loop, started } = harness() diff --git a/packages/the-framework/src/auto-pm.ts b/packages/the-framework/src/auto-pm.ts index f100e4733..f03923c7b 100644 --- a/packages/the-framework/src/auto-pm.ts +++ b/packages/the-framework/src/auto-pm.ts @@ -52,6 +52,12 @@ export interface AutoPmInputs { sinceLastStartMs?: number /** Override {@link DEFAULT_AUTO_PM_COOLDOWN_MS}. */ cooldownMs?: number + /** + * A person asked for this pass (#1210's Run now), so the cooldown does not apply (#1642): it + * paces work nobody asked for, and a click is asking. The concurrency cap still does — that is + * what keeps a second click from doubling up, since a start registers before the sweep moves on. + */ + onDemand?: boolean } /** Why the sweep is not starting anything. Logged, so it reads as a sentence. */ @@ -123,7 +129,7 @@ export function autoPmDecision(input: AutoPmInputs): AutoPmDecision { return { start: false, reason: concurrency === 1 ? going : `${going}, and the routine keeps at most ${concurrency} at once` } } const cooldownMs = input.cooldownMs ?? DEFAULT_AUTO_PM_COOLDOWN_MS - if (input.sinceLastStartMs !== undefined && input.sinceLastStartMs < cooldownMs) { + if (!input.onDemand && input.sinceLastStartMs !== undefined && input.sinceLastStartMs < cooldownMs) { return { start: false, reason: 'a run was started for this project a moment ago' } } if (input.backlogEmpty === undefined) { @@ -627,8 +633,10 @@ export interface AutoPmLoop { * * `onDemand` marks a sweep a person explicitly asked for (#1210's trigger button). The `autoPm` * preference is consent to spend quota *unasked*, and a click is asking — so an on-demand sweep - * runs with the preference off, and the master switch is the only gate it skips: every other - * reason to stand down (live agents, cooldowns, the quota boundary, unticked routines) still holds. + * runs with the preference off. It skips the cooldown for the same reason (#1642): the cooldown + * paces the unattended sweep, and for half an hour after any run it made Run now a button that + * could start nothing. Every other reason to stand down (live agents, the quota boundary, + * unticked routines) still holds. * * `drainOnly` narrows the sweep to working the queue (#1204): the drain row's Run now means * "spin agents up on the queue", so a tick that would fall through to a rotation job (the queue @@ -777,6 +785,7 @@ export function startAutoPm(deps: AutoPmDeps): AutoPmLoop { quota, ...(since !== undefined ? { sinceLastStartMs: now() - since } : {}), ...(deps.cooldownMs !== undefined ? { cooldownMs: deps.cooldownMs } : {}), + ...(opts?.onDemand ? { onDemand: true } : {}), }) if (!decision.start) { // Logged, so a wedged sweep is distinguishable from a healthy idle one (#855).