From 33585efe8fb1ae5d198cf84a150d7966273aaa0e Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Sun, 23 Aug 2026 02:00:13 +0300 Subject: [PATCH] Run now on a triage routine releases its stale branch first, like the sweep (#1643) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run now on either triage routine was a plain start outside the sweep, so it never reached the release the sweep does before firing a pinned job. A leftover copy of the triage branch made every click abort in seconds as "triage already pending", until someone deleted the branch by hand. The card now asks the sweep for that one routine — `only: { pinned: }`, scoped to the picked project — the way the plan click does since #1640. The sweep finds the job by the branch it declares, never by name; a switched-off routine stands the click down with a reason; the named job takes the tick outright, so neither a full queue nor the rotation index borrows it, and the rotation keeps its turn. --- FEATURES-SPEC.md | 2 +- .../dashboard/components/RoutineWork.SPEC.md | 2 +- .../components/RoutineWork.test.SPEC.md | 2 +- .../dashboard/components/RoutineWork.test.tsx | 19 +++++ .../dashboard/components/RoutineWork.tsx | 42 ++++++++--- packages/the-framework/src/auto-pm.SPEC.md | 2 + .../the-framework/src/auto-pm.test.SPEC.md | 2 +- packages/the-framework/src/auto-pm.test.ts | 74 +++++++++++++++++++ packages/the-framework/src/auto-pm.ts | 58 +++++++++++---- .../src/dashboard-rpc/quota.test.ts | 8 +- .../the-framework/src/dashboard-rpc/quota.ts | 6 +- 11 files changed, 182 insertions(+), 35 deletions(-) diff --git a/FEATURES-SPEC.md b/FEATURES-SPEC.md index ae9705698..c3698f2b8 100644 --- a/FEATURES-SPEC.md +++ b/FEATURES-SPEC.md @@ -129,7 +129,7 @@ happens while nobody is at the keyboard. - CI watch: merge a PR once its checks pass - CI watch: one fix agent per red head commit, max two attempts - Reclaim the checkout of an agent whose work is on the remote — never by publishing what a `handoff: local` agent refused to -- Release a pinned routine branch left behind by a closed PR +- Release a pinned routine branch left behind by a closed PR — before the schedule fires the routine, and before its "Run now" does - The agent drains its own TODO backlog, one entry per turn ## Spending diff --git a/packages/the-framework/dashboard/components/RoutineWork.SPEC.md b/packages/the-framework/dashboard/components/RoutineWork.SPEC.md index 25008363b..68f946c20 100644 --- a/packages/the-framework/dashboard/components/RoutineWork.SPEC.md +++ b/packages/the-framework/dashboard/components/RoutineWork.SPEC.md @@ -3,7 +3,7 @@ The Overview's Routine work card: the jobs fired by the scheduled sweep — the ## Flows - The list is read straight from the definition the daemon runs, so screen and schedule cannot drift; Run now starts the work at once rather than asking the sweep to come sooner. -- The two routines that fan out — queue-draining and ticket-planning — have their Run now ask the sweep for that routine's work only, which is the one path that can spin up several agents, up to the concurrency setting. Draining visits every project; planning stays in the project the card has picked. Every other routine's Run now is one agent, because concurrent copies of it would undo each other. Card-fired routines run unattended, like the sweep's own. +- The two routines that fan out — queue-draining and ticket-planning — have their Run now ask the sweep for that routine's work only, which is the one path that can spin up several agents, up to the concurrency setting. Draining visits every project; planning stays in the project the card has picked. The two triage routines ask the sweep too, for a different reason: each works on one fixed branch, and only the sweep deletes a leftover copy of it before starting — started directly, the agent read the leftover as a triage already under way and gave up. Every other routine's Run now is one agent started directly, because concurrent copies of it would undo each other. Card-fired routines run unattended, like the sweep's own. - Two checkbox tiers: the master switch turns the schedule on or off, a row's box takes that one routine in or out of it — recorded as opt-outs, so a routine added by a later version runs by default. - "Trigger routine now" sweeps once even with auto-run off (the click is the consent), and the sweep answers on the card per project, so "ran and found nothing" never looks like "never ran". - Hovering a Run now says what it is about to spend before it is spent: what that routine does, which model it will use, and where it runs — none of which the card can otherwise show, because all three come from the Global options on another page. It also says how many agents the click costs, which is one for most routines but the concurrency setting for the two that fan out. The queue-draining routine answers the model and place differently, since its Run now visits every project the daemon watches and each of those decides its own. diff --git a/packages/the-framework/dashboard/components/RoutineWork.test.SPEC.md b/packages/the-framework/dashboard/components/RoutineWork.test.SPEC.md index 510823f0f..02d0d20ed 100644 --- a/packages/the-framework/dashboard/components/RoutineWork.test.SPEC.md +++ b/packages/the-framework/dashboard/components/RoutineWork.test.SPEC.md @@ -1,4 +1,4 @@ -Covers the Routine work card: routines listed by label with Run now starting the prompt verbatim (unattended, then jumping to the agent), the drain firing a fan-out-capable sweep instead, per-routine opt-out boxes versus the master auto-agent switch, the on-demand trigger working with auto-run off, the sweep's per-project answers landing on the card, the concurrency setting's clamping, "Configure first, then run" carrying the prompt to the picked project's launcher instead of starting anything (and saying so on the drain), each Run now naming on hover what the routine does and the model and place its start would use (and the drain saying instead that every project decides its own), and the picker, empty, failure, and busy states. +Covers the Routine work card: routines listed by label with Run now starting the prompt verbatim (unattended, then jumping to the agent), the drain and the planning routine firing a fan-out-capable sweep instead and each triage routine asking the sweep for itself by its branch, per-routine opt-out boxes versus the master auto-agent switch, the on-demand trigger working with auto-run off, the sweep's per-project answers landing on the card, the concurrency setting's clamping, "Configure first, then run" carrying the prompt to the picked project's launcher instead of starting anything (and saying so on the drain), each Run now naming on hover what the routine does and the model and place its start would use (and the drain saying instead that every project decides its own), and the picker, empty, failure, and busy states. ## Before modifying/creating SPEC.md files diff --git a/packages/the-framework/dashboard/components/RoutineWork.test.tsx b/packages/the-framework/dashboard/components/RoutineWork.test.tsx index 9c0b03fbd..35875de49 100644 --- a/packages/the-framework/dashboard/components/RoutineWork.test.tsx +++ b/packages/the-framework/dashboard/components/RoutineWork.test.tsx @@ -194,6 +194,25 @@ describe('RoutineWork (#1159)', () => { expect((await hoverTooltip(await runNowOf(PLAN_JOB))).textContent).toMatch(/Starts up to 1 agent in gemstack/) }) + // #1643: a routine pinned to a branch goes through the sweep too — one agent still, but the + // sweep releases a stale copy of its branch before the start, and a plain start never did: the + // agent read the leftover as a triage already pending and aborted, on every click. + test("a pinned routine's Run now asks the sweep for it by its branch, not a plain start (#1643)", async () => { + const pinned = AUTO_PM_ROUTINES.filter(job => job.pinnedBranch !== undefined) + expect(pinned.length).toBeGreaterThan(0) + renderCard() + for (const job of pinned) { + fireEvent.click(await runNowOf(job)) + // By the branch the job declares, never its name, and scoped to the picked project like + // the plan click: the routine is that project's own work. + await waitFor(() => + expect(sendAutoPmSweep).toHaveBeenCalledWith({ only: { pinned: job.pinnedBranch }, projectId: 'p1' }), + ) + } + expect(sendAutoPmSweep).toHaveBeenCalledTimes(pinned.length) + expect(start).not.toHaveBeenCalled() + }) + /** Open one row's secondary half — the chevron beside its Run now. */ const openRunMenu = async (job: AutoPmJob) => { await waitFor(() => expect(screen.getAllByText('Run now').length).toBe(AUTO_PM_ROUTINES.length)) diff --git a/packages/the-framework/dashboard/components/RoutineWork.tsx b/packages/the-framework/dashboard/components/RoutineWork.tsx index c57655f62..892cd6657 100644 --- a/packages/the-framework/dashboard/components/RoutineWork.tsx +++ b/packages/the-framework/dashboard/components/RoutineWork.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import type { AutoPmJob, AutoPmOutcome, ProjectSummary } from '../../src/index.js' +import type { AutoPmJob, AutoPmOnly, AutoPmOutcome, ProjectSummary } from '../../src/index.js' import { AUTO_PM_ROUTINES, DEFAULT_AUTO_PM_CONCURRENCY, @@ -34,8 +34,10 @@ import { // // The list is `AUTO_PM_ROUTINES` itself, straight off the browser-safe client entry, so what is on // screen is what the daemon runs rather than a second copy of it: no read of its own, and no way -// for the two to drift. Run now takes the same path the launcher does (`sendStart` with the job's -// prompt verbatim), so it starts the work now instead of asking the sweep to come round sooner. +// for the two to drift. Run now on an ordinary routine takes the same path the launcher does +// (`sendStart` with the job's prompt verbatim), so it starts the work now instead of asking the +// sweep to come round sooner; a routine that needs the sweep's own preparation asks the sweep for +// that one routine's work instead (see `narrowedSweep`). // // Two tiers of checkbox, and they control different things (#1209). The one at the foot is the // `autoPm` preference (#685): whether the schedule runs at all. The one on each row is that @@ -48,6 +50,28 @@ import { /** Captured once: `useLoaded` treats a fresh `[]` literal as a new value on every render. */ const NO_PROJECTS: ProjectSummary[] = [] +/** + * The sweep a routine's Run now asks for, when it is a sweep rather than a plain start — or + * nothing, for the routine a plain start serves exactly. Decided by what the job declares about + * itself (it drains, fans out, or pins a branch), never by its name, so a renamed routine keeps + * its path. + * + * The two that fan out (#1204) go because only the sweep can: it claims the work before each + * agent starts — a queue entry for a drain, a ticket lock for planning — and a plain start could + * only ever be one agent. A routine pinned to a branch (#1643) goes for the sweep's other + * preparation: it releases a stale copy of that branch before the start, and without it the + * agent read the leftover as a triage already pending and aborted, on every click. + * + * The drain visits every project, which is what its tooltip says and why it sends no id. The + * rest are the picked project's own work, so they carry one. + */ +function narrowedSweep(job: AutoPmJob, projectId: string): { only: AutoPmOnly; projectId?: string } | undefined { + if (job.drains) return { only: 'drain' } + if (job.fansOut) return { only: 'plan', projectId } + if (job.pinnedBranch !== undefined) return { only: { pinned: job.pinnedBranch }, projectId } + return undefined +} + /** * The triggered sweep's answer as one card line (#1433): a single project speaks its message * plainly, several are prefixed with the folder name so the reader can tell whose line is whose. @@ -156,19 +180,13 @@ export function RoutineWork({ const runNow = async (job: AutoPmJob) => { if (!projectId || busy) return - // The two routines that fan out go through the sweep (#1204), because only the sweep can: - // it claims the work before each agent starts — a queue entry for a drain, a ticket lock for - // planning — and a plain start could only ever be one agent, reading whatever is first. - // Narrowed to the one routine the click named, so having nothing to work is reported on the + // Narrowed to the one routine the click named, so having nothing to do is reported on the // card rather than the click quietly borrowing a different rotation job. No navigation on // purpose: the agents land in the Agents card, which is where a batch is watchable. - // - // The drain visits every project, which is what its tooltip says and why it sends no id. - // Planning is the picked project's own work, so it carries one. - if (job.drains || job.fansOut) { + const narrowed = narrowedSweep(job, projectId) + if (narrowed) { setStarting(job.name) setSweepNote(null) - const narrowed = job.drains ? { only: 'drain' as const } : { only: 'plan' as const, projectId } const result = await sendAutoPmSweep(narrowed).catch(() => ({ ok: false as const })) setStarting(null) if (!result.ok) setSweepNote('This dashboard is not running the sweep, so there is nothing to trigger here.') diff --git a/packages/the-framework/src/auto-pm.SPEC.md b/packages/the-framework/src/auto-pm.SPEC.md index 8a9fe18bb..9687c7686 100644 --- a/packages/the-framework/src/auto-pm.SPEC.md +++ b/packages/the-framework/src/auto-pm.SPEC.md @@ -15,6 +15,7 @@ Auto PM spends leftover subscription quota on the product's own roadmap: while t - 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. - A claim whose agent settled with nothing to hand off is released by the sweep: the pull request that normally lifts the lock is never coming, and without the release the queue would jam forever on a dead claim. The freed work is not respawned by this daemon — one commitless run is evidence for a human, not an invitation to repeat it every cooldown. A claim whose agent never even started (a refused spawn, a stop mid-batch) is freed the same way. - The queue coordinates a ticketless entry only once its check-off is on the data branch, and that leaves a window: an agent handed off to a cloud session settles locally before its published work is adopted, so until the check-off lands the entry still reads open, and past the cooldown it can be fanned out to a second agent. The same window opens when the daemon restarts, since only its in-memory pin covered the wait. +- The two triage routines each work on one fixed branch, and their agent refuses to start when that branch already exists, so two triages never run at once. Before firing either — from the schedule or from its "Run now" — the daemon deletes a leftover copy of that branch whose pull request is closed or merged; a branch with an open pull request, or with no pull request history at all, is kept, since that may be a triage still under way. - Each routine can be switched off individually, and every stand-down is reported with its reason: a wedged sweep must not look like a healthy idle one. - Switching the draining routine off means "do not *work* the queue", not "do nothing": the pass falls through to the rotation, which puts entries *on* the queue rather than taking them off. The one exception is a click that asked for the queue by name: a drain-only sweep says why it cannot, rather than borrowing the click. @@ -24,6 +25,7 @@ Auto PM spends leftover subscription quota on the product's own roadmap: while t - 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 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 triage's "Run now" goes through the pass rather than starting its agent directly, so it gets the same branch release the schedule gets: started directly, the click died on the branch the previous triage left behind, and kept dying on every click until someone deleted it by hand. - 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.SPEC.md b/packages/the-framework/src/auto-pm.test.SPEC.md index 7bcb11e17..a9384d258 100644 --- a/packages/the-framework/src/auto-pm.test.SPEC.md +++ b/packages/the-framework/src/auto-pm.test.SPEC.md @@ -1,4 +1,4 @@ -Covers auto PM's decision policy — quota and queue both failing closed when unreadable, cooldowns, concurrency caps — the drain-before-refill cycle, rotation order and per-routine opt-outs (an unticked drain routine falls through to the rotation; a drain-only click still stands down), the calendar-paced maintenance sweep, fan-out with pinned entries and locked tickets, drains claiming their entry's ticket before starting (ticketless entries exempt; a lost claim drops the entry, not the batch), durable claims surviving restarts and hand-offs, a claim released when its agent settles with nothing to hand off and only then (the freed work not respawned; refused-spawn claims freed too; failed releases retried under a bound), on-demand and drain-only passes, and the report the dashboard shows. +Covers auto PM's decision policy — quota and queue both failing closed when unreadable, cooldowns, concurrency caps — the drain-before-refill cycle, rotation order and per-routine opt-outs (an unticked drain routine falls through to the rotation; a drain-only click still stands down), the calendar-paced maintenance sweep, fan-out with pinned entries and locked tickets, drains claiming their entry's ticket before starting (ticketless entries exempt; a lost claim drops the entry, not the batch), durable claims surviving restarts and hand-offs, a claim released when its agent settles with nothing to hand off and only then (the freed work not respawned; refused-spawn claims freed too; failed releases retried under a bound), on-demand and drain-only passes, a pass narrowed to a triage routine releasing its leftover branch before its one start (standing down when the routine is off, and never falling through to the queue or another routine), and the report the dashboard shows. ## Before modifying/creating SPEC.md files diff --git a/packages/the-framework/src/auto-pm.test.ts b/packages/the-framework/src/auto-pm.test.ts index 24397b45b..289aa9037 100644 --- a/packages/the-framework/src/auto-pm.test.ts +++ b/packages/the-framework/src/auto-pm.test.ts @@ -1483,6 +1483,80 @@ test('an unpinned job never asks for a release, and a failing release does not s assert.deepEqual(failing.ran, ['triage-quick']) }) +// #1643: Run now on a pinned routine reaches the same release-then-start the sweep does. It used +// to be a plain start outside the sweep, so a leftover copy of the branch — which the sweep +// releases before firing — made the agent abort as "triage already pending" on every click. + +const PINNED_JOB: AutoPmJob = { + name: 'triage-quick', + prompt: 'Triage.', + label: 'Triage quick wins', + pinnedBranch: 'the-framework/triage-quick', +} + +test('a sweep narrowed to a pinned job releases its branch, then starts exactly one agent (#1643)', async () => { + const order: string[] = [] + const { loop } = harness({ + // The rotation is on another job's turn, so a tick that ignored the narrowing would start + // that one instead — the release-then-start below is the click's doing, not the cycle's. + jobs: [{ name: 'update', prompt: 'Update.' }, PINNED_JOB], + cooldownMs: 0, + // Room for three, so a single start is the routine's own shape and not the cap's doing. + concurrency: async () => 3, + releasePinned: async (_project, branch) => { + order.push(`release:${branch}`) + }, + start: async (_project, job) => { + order.push(`start:${job.name}`) + return `run-${order.length}` + }, + }) + await loop.tick({ onDemand: true, only: { pinned: 'the-framework/triage-quick' }, projectId: 'p1' }) + loop.stop() + assert.deepEqual(order, ['release:the-framework/triage-quick', 'start:triage-quick']) +}) + +test('a switched-off pinned routine stands the click down, and so does every other gate (#1643)', async () => { + const off = harness({ jobs: [PINNED_JOB], cooldownMs: 0, optedOut: async () => ['triage-quick'] }) + await off.loop.tick({ onDemand: true, only: { pinned: 'the-framework/triage-quick' }, projectId: 'p1' }) + off.loop.stop() + assert.deepEqual(off.ran, [], 'an unticked box is not overridden by the click') + assert.equal(off.loop.report().outcomes[0]?.message, 'Triage quick wins is switched off') + + // A branch nothing pins is said as such, not as a setting the user could go and undo. + const unknown = harness({ jobs: [PINNED_JOB], cooldownMs: 0 }) + await unknown.loop.tick({ onDemand: true, only: { pinned: 'the-framework/nobody' }, projectId: 'p1' }) + unknown.loop.stop() + assert.deepEqual(unknown.ran, []) + assert.equal(unknown.loop.report().outcomes[0]?.message, 'no routine is pinned to the-framework/nobody') + + // The click skips the master switch and the cooldown, not the cap (#1204/#1642). + const capped = harness({ jobs: [PINNED_JOB], cooldownMs: 0, activeAgents: () => 1 }) + await capped.loop.tick({ onDemand: true, only: { pinned: 'the-framework/triage-quick' }, projectId: 'p1' }) + capped.loop.stop() + assert.deepEqual(capped.ran, [], 'a live agent at the cap holds the click like it holds the sweep') +}) + +test('a sweep narrowed to a pinned job never falls through to the drain or another rotation job (#1643)', async () => { + // The queue is full, so the queue-picked mode would drain; the rotation is on another job's + // turn, so the index would fire that one. The click named the pinned routine and gets it alone + // — and the scheduled tick after it still gets the rotation job it was owed. + const other: AutoPmJob = { name: 'update', prompt: 'Update.' } + let entries = ['work one'] + const { loop, ran } = harness({ + jobs: [other, PINNED_JOB], + cooldownMs: 0, + queue: async () => entries, + drainJob: { name: 'drain', prompt: 'Work the queue.', drains: true }, + }) + await loop.tick({ onDemand: true, only: { pinned: 'the-framework/triage-quick' }, projectId: 'p1' }) + assert.deepEqual(ran, ['triage-quick'], 'neither the full queue nor the rotation index took the click') + entries = [] + await loop.tick() + loop.stop() + assert.deepEqual(ran, ['triage-quick', 'update'], 'the scheduled tick still gets the rotation job it was owed') +}) + test('only the drain job lands its own PRs (#1216)', () => { // The drain implements queue entries whose triage a human could have vetoed, so its review // happened before the agent. Every other job writes tickets/plans and has nothing to merge — diff --git a/packages/the-framework/src/auto-pm.ts b/packages/the-framework/src/auto-pm.ts index 9d09f20a8..842a2a608 100644 --- a/packages/the-framework/src/auto-pm.ts +++ b/packages/the-framework/src/auto-pm.ts @@ -647,6 +647,10 @@ export interface AutoPmLoop { * Its Run now used to be a plain single start, so the concurrency setting was the one thing * that click ignored; narrowing here reaches the same claim-then-start path the rotation * takes, locks included. + * - `{ pinned }` — the rotation job pinned to that branch ({@link AutoPmJob.pinnedBranch}), for + * a triage's Run now (#1643). One agent, like the rotation's own firing, but through the + * sweep so the stale copy of its branch is released before the start — the one preparation a + * plain start skipped, and the one that made every click die on the previous run's leftover. * * `projectId` scopes the sweep to one project, which is what a Run now fired from a card with a * project picked means. Absent, every project the daemon watches is visited, which is what the @@ -659,12 +663,18 @@ export interface AutoPmLoop { } /** - * Which routine's work a narrowed sweep is for (#1204). Both are the fan-out kinds: a drain takes - * one entry *off* the queue and a pinned plan agent writes one ticket's own sibling files, so - * several agents do disjoint work. The rotation jobs that rewrite the shared queue document have - * nothing to narrow to — one agent is all they can ever use. + * Which routine's work a narrowed sweep is for (#1204/#1643). `'drain'` and `'plan'` are the + * fan-out kinds: a drain takes one entry *off* the queue and a pinned plan agent writes one + * ticket's own sibling files, so several agents do disjoint work. `{ pinned }` names a rotation + * job by the branch it pins ({@link AutoPmJob.pinnedBranch}): one agent is all it can use, but its + * prompt aborts when that branch already exists, and the release that clears a stale copy is the + * sweep's (#1293) — a Run now that started it outside the sweep died on the leftover (#1643). + * Keyed on the branch rather than the job's name for the same no-name-matching reason the job + * carries the property at all: the card sends whatever the job it renders declares, so no string + * anywhere has to agree with a name. A rotation job that neither fans out nor pins a branch has + * nothing to narrow to — a plain start is exactly what it is. */ -export type AutoPmOnly = 'drain' | 'plan' +export type AutoPmOnly = 'drain' | 'plan' | { pinned: string } /** * Start the auto-PM sweep (#685): every {@link DEFAULT_AUTO_PM_INTERVAL_MS}, ask @@ -841,6 +851,23 @@ export function startAutoPm(deps: AutoPmDeps): AutoPmLoop { note(project, false, 'the planning routine is switched off') continue } + // The pinned routine this click asked for (#1643), by the branch it declares rather than + // its name, and read off the enabled rotation for the same reason the plan click is. A + // branch no enabled job pins is told apart: the routine the catalog shows is switched + // off, which is a setting the user can undo, or nothing pins that branch at all, which + // is a dashboard older than its daemon. + const pinnedBranch = typeof opts?.only === 'object' ? opts.only.pinned : undefined + const pinnedJob = + pinnedBranch === undefined ? undefined : rotation.find(item => item.pinnedBranch === pinnedBranch) + if (pinnedBranch !== undefined && !pinnedJob) { + const off = deps.jobs.find(item => item.pinnedBranch === pinnedBranch) + const reason = off ? `${off.label ?? off.name} is switched off` : `no routine is pinned to ${pinnedBranch}` + deps.log(`[framework] auto PM: standing down for ${project.path} — ${reason}`) + note(project, false, reason) + continue + } + // The one routine the click named, when it named one: it takes the tick outright below. + const named = planJob ?? pinnedJob /** * What this tick does, which is the queue-picked mode (#855) unless the draining routine * is switched off — then the rotation gets the tick instead of the sweep standing down. @@ -854,10 +881,9 @@ export function startAutoPm(deps: AutoPmDeps): AutoPmLoop { * A drain-only sweep never gets here: it has already stood down above, because the click * that fires it asked for the queue specifically. */ - // A plan-only click is never a drain, however full the queue is: the click named the - // planning routine, and the queue-picked mode would otherwise send it to work entries. - const mode: 'pm' | 'drain' = - opts?.only === 'plan' ? 'pm' : decision.mode === 'drain' && !drainJob ? 'pm' : decision.mode + // A click that named a routine is never a drain, however full the queue is: the queue-picked + // mode would otherwise send it to work entries instead of the routine it asked for. + const mode: 'pm' | 'drain' = named ? 'pm' : decision.mode === 'drain' && !drainJob ? 'pm' : decision.mode const index = nextJob.get(project.id) ?? 0 // A due codebase sweep (#882) outranks the rotation: the rotation invents work, and the // sweep is a standing instruction to go find some. Only ever while the queue is empty -- @@ -874,9 +900,9 @@ export function startAutoPm(deps: AutoPmDeps): AutoPmLoop { decision.mode === 'pm' && maintenanceJob !== undefined && (await deps.maintenanceDue?.(project).catch(() => false)) === true - // The plan click outranks both the calendar and the rotation index: it asked for one - // routine by name, so neither a due codebase sweep nor whose turn it is may take its tick. - const job = planJob ?? (sweep ? maintenanceJob : mode === 'drain' ? drainJob : rotation[index % rotation.length]) + // The routine a click named outranks both the calendar and the rotation index: it asked for + // that one, so neither a due codebase sweep nor whose turn it is may take its tick. + const job = named ?? (sweep ? maintenanceJob : mode === 'drain' ? drainJob : rotation[index % rotation.length]) if (!job) { // Told apart on purpose: a rotation emptied by the checkboxes is a setting the user can // see and undo, and reads nothing like a daemon wired without jobs at all. @@ -979,7 +1005,7 @@ export function startAutoPm(deps: AutoPmDeps): AutoPmLoop { // Nothing left to plan is this job's work being done, not a refusal: the rotation // advances, so the next tick tries the next job instead of re-asking forever. A click // that named this routine advances nothing — it did not come from the cycle. - if (!planJob) nextJob.set(project.id, index + 1) + if (!named) nextJob.set(project.id, index + 1) note(project, false, 'every open ticket already has a plan, or an agent on the way to one') continue } @@ -1049,10 +1075,10 @@ export function startAutoPm(deps: AutoPmDeps): AutoPmLoop { // by the calendar, not the cycle, so borrowing this tick must not cost the rotation its // turn. Stamped after the start took for the same reason the rotation is -- a sweep the // daemon refused should be retried next tick, not postponed a whole interval. - // A plan click does not advance it either, for the reason a sweep does not: it borrows - // the tick for a routine it named, so the rotation keeps the turn it was on. + // A click that named a routine does not advance it either, for the reason a sweep does + // not: it borrows the tick for that routine, so the rotation keeps the turn it was on. if (sweep) await deps.recordMaintenance?.(project).catch(() => {}) - else if (mode === 'pm' && !planJob) nextJob.set(project.id, index + 1) + else if (mode === 'pm' && !named) nextJob.set(project.id, index + 1) // One line per project however many agents went out, and a single start keeps the old // wording exactly. const described = started.map(item => doing(item)).join('; ') diff --git a/packages/the-framework/src/dashboard-rpc/quota.test.ts b/packages/the-framework/src/dashboard-rpc/quota.test.ts index 8846f8912..20154e0e8 100644 --- a/packages/the-framework/src/dashboard-rpc/quota.test.ts +++ b/packages/the-framework/src/dashboard-rpc/quota.test.ts @@ -75,8 +75,14 @@ test('the narrowing travels to the loop untouched (#1204)', async () => { }) await sendAutoPmSweep({ only: 'drain' }) await sendAutoPmSweep({ only: 'plan', projectId: 'p1' }) + await sendAutoPmSweep({ only: { pinned: 'tf-triage-quick' }, projectId: 'p1' }) await sendAutoPmSweep() // Passed through rather than rebuilt: a wrapper that reconstructed the object is how a new // narrowing silently stops reaching the loop. - assert.deepEqual(seen, [{ only: 'drain' }, { only: 'plan', projectId: 'p1' }, undefined]) + assert.deepEqual(seen, [ + { only: 'drain' }, + { only: 'plan', projectId: 'p1' }, + { only: { pinned: 'tf-triage-quick' }, projectId: 'p1' }, + undefined, + ]) }) diff --git a/packages/the-framework/src/dashboard-rpc/quota.ts b/packages/the-framework/src/dashboard-rpc/quota.ts index 78c03ffa4..41afd4ef2 100644 --- a/packages/the-framework/src/dashboard-rpc/quota.ts +++ b/packages/the-framework/src/dashboard-rpc/quota.ts @@ -52,8 +52,10 @@ export async function onAutoPm(): Promise { * `only` narrows the sweep to one routine's work (#1204), which is what a Run now that fans out * means: `'drain'` spins agents up on the queue's entries, `'plan'` on the open tickets. Either * way the fan-out is the sweep's — a plain start could only ever be one agent — and having - * nothing to work is reported rather than borrowed for a rotation job. `projectId` scopes it to - * the project the card has picked. + * nothing to work is reported rather than borrowed for a rotation job. `{ pinned }` is the + * routine pinned to that branch (#1643): one agent, but the sweep releases a stale copy of the + * branch before starting it, which a plain start never did. `projectId` scopes it to the project + * the card has picked. */ export async function sendAutoPmSweep(opts?: { only?: AutoPmOnly; projectId?: string }): Promise<{ ok: boolean; outcomes?: AutoPmOutcome[] }> { const sweep = contextAutoPmSweep()