Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/the-framework/src/auto-pm.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 "Run now" can ask for one routine's work in one project, rather than a whole pass: it never falls through to work the click did not name, a switched-off routine stands it down instead of being overridden, and it leaves the rotation on whichever turn it was on.
- 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.
Expand Down
22 changes: 21 additions & 1 deletion packages/the-framework/src/auto-pm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -200,14 +209,25 @@ 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()
assert.deepEqual(started, [])
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()
Expand Down
15 changes: 12 additions & 3 deletions packages/the-framework/src/auto-pm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
*
* `only` narrows the sweep to one routine's work (#1204), for a Run now that fans out:
*
Expand Down Expand Up @@ -799,6 +807,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).
Expand Down
Loading