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
2 changes: 1 addition & 1 deletion FEATURES-SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ happens while nobody is at the keyboard.
- Start an agent from a queue entry's play button
- "Run now" on a routine
- "Configure first, then run" on a routine β€” the launcher opens with its prompt, so the model and location can be set first
- What a routine's "Run now" is about to spend, on hover β€” what that routine does, which model it will use, and where it runs
- What a routine's "Run now" is about to spend, on hover β€” what that routine does, how many agents it costs, which model it will use, and where it runs
- The whole CLI is one command: `the-framework` serves the dashboard β€” four options, no verbs
- `--host` / `--port`, the two things a browser cannot be asked; `--help` / `--version`
- Reach the dashboard from another machine β€” non-loopback bind behind a generated shared token
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ 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 queue-draining routine's Run now fires a drain-only sweep β€” the only path that can fan out several agents, up to the concurrency setting; 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. 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.
- 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. The queue-draining routine answers differently, since its Run now is the sweep rather than one start: it visits every project the daemon watches, and each of those decides its own model and place.
- Beside each Run now sits "Configure first, then run": it opens the picked project's launcher with that routine's prompt already in the box, so the model and where it runs can be set before an agent is spent. For the queue-draining routine it says what it costs β€” the launcher sends one agent, not the fan-out.
- 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.
- Beside each Run now sits "Configure first, then run": it opens the picked project's launcher with that routine's prompt already in the box, so the model and where it runs can be set before an agent is spent. For either routine that fans out it says what it costs β€” the launcher sends one agent, not the fan-out.

## Before modifying/creating SPEC.md files

Expand Down
45 changes: 44 additions & 1 deletion packages/the-framework/dashboard/components/RoutineWork.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,47 @@ describe('RoutineWork (#1159)', () => {
expect(hint.textContent).not.toContain('Opus')
})

test("the planning routine's menu item says it is one agent too, not its fan-out (#1204/#1507)", async () => {
renderCard()
await openRunMenu(AUTO_PM_ROUTINES.find(job => job.fansOut)!)
// The launcher sends one agent whichever routine it is handed, so the moment a Run now stops
// being one start its menu item has to say what it gives up.
expect(await screen.findByText(/one agent, not the fan-out/)).toBeTruthy()
})

// #1204: the planning routine is the other one that fans out, so its Run now goes to the sweep
// too β€” a plain start could only ever be one agent, which is the one thing the concurrency
// setting could not reach.
const PLAN_JOB = AUTO_PM_ROUTINES.find(job => job.fansOut)!

test("the planning routine's Run now fires the sweep for its project, not a single start (#1204)", async () => {
renderCard()
fireEvent.click(await runNowOf(PLAN_JOB))
await waitFor(() => expect(sendAutoPmSweep).toHaveBeenCalled())
// Narrowed to planning, and scoped to the project the picker shows: the drain's Run now
// deliberately sends no id because it sweeps every project, and this one is not that.
expect(sendAutoPmSweep).toHaveBeenCalledWith({ only: 'plan', projectId: 'p1' })
// The fan-out is the sweep's whole point here: a plain start is the pre-#1204 behaviour and
// is exactly what must not happen.
expect(start).not.toHaveBeenCalled()
})

test("the planning routine's Run now says it spends the concurrency, one agent per ticket (#1204)", async () => {
prefs = { model: 'opus', autoPmConcurrency: 3 }
renderCard()
const hint = await hoverTooltip(await runNowOf(PLAN_JOB))
expect(hint.textContent).toMatch(/Starts up to 3 agents in gemstack, one per open ticket, unattended/)
// Not the drain's line: this one stays in the picked project and does resolve these settings.
expect(hint.textContent).not.toContain('Sweeps every project')
expect(hint.textContent).toContain('Claude Code Β· Opus Β· This machine')
})

test('a concurrency of one is said as one agent, not "up to 1 agents" (#1204)', async () => {
prefs = { autoPmConcurrency: 1 }
renderCard()
expect((await hoverTooltip(await runNowOf(PLAN_JOB))).textContent).toMatch(/Starts up to 1 agent in gemstack/)
})

/** 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))
Expand Down Expand Up @@ -218,7 +259,9 @@ describe('RoutineWork (#1159)', () => {
renderCard({ onAgentStarted: (...args) => started.push(args) })
await waitFor(() => expect(screen.getAllByText('Run now').length).toBeGreaterThan(0))
fireEvent.click(screen.getAllByText('Run now')[0]!)
await waitFor(() => expect(sendAutoPmSweep).toHaveBeenCalledWith({ drainOnly: true }))
// No project id on purpose: the drain sweeps every project the daemon watches, which is what
// its own tooltip promises β€” unlike the planning routine's click, which stays in the picked one.
await waitFor(() => expect(sendAutoPmSweep).toHaveBeenCalledWith({ only: 'drain' }))
expect(start).not.toHaveBeenCalled()
// No navigation: the batch lands in the Agents card, not one session's page.
expect(started).toHaveLength(0)
Expand Down
35 changes: 24 additions & 11 deletions packages/the-framework/dashboard/components/RoutineWork.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,15 +156,20 @@ export function RoutineWork({

const runNow = async (job: AutoPmJob) => {
if (!projectId || busy) return
// The drain's Run now means "spin agents up on the queue" (#1204), and only the sweep can fan
// out β€” one agent per entry, up to the concurrency. A plain start could only ever be one
// agent reading the first entry. Drain-only, so an empty queue is reported on the card
// rather than the click quietly borrowing a rotation job. No navigation on purpose: the
// agents land in the Agents card, which is where a batch is watchable.
if (job.drains) {
// 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
// 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) {
setStarting(job.name)
setSweepNote(null)
const result = await sendAutoPmSweep({ drainOnly: true }).catch(() => ({ ok: false as const }))
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.')
else setSweepNote(describeOutcomes('outcomes' in result ? result.outcomes : undefined))
Expand Down Expand Up @@ -267,10 +272,16 @@ export function RoutineWork({
<span className="block text-muted-foreground">
{job.drains ? "Each project's own settings decide the model and where it runs." : settings}
</span>
{/* Three cases, because the click does three different things (#1204).
A routine that fans out spends what the setting allows rather than one
agent, so a tooltip that exists to say what a click costs has to say
so β€” and "one per ticket" is why several here is not redundant work. */}
<span className="block text-muted-foreground">
{job.drains
? `Sweeps every project the daemon watches, up to ${concurrency} ${concurrency === 1 ? 'agent' : 'agents'} each, unattended.`
: `Starts one agent${projectName ? ` in ${projectName}` : ''}, unattended β€” nothing is asked mid-run.`}
: job.fansOut
? `Starts up to ${concurrency} ${concurrency === 1 ? 'agent' : 'agents'}${projectName ? ` in ${projectName}` : ''}, one per open ticket, unattended.`
: `Starts one agent${projectName ? ` in ${projectName}` : ''}, unattended β€” nothing is asked mid-run.`}
</span>
</TooltipContent>
</Tooltip>
Expand All @@ -291,11 +302,13 @@ export function RoutineWork({
>
<OptionLabel
label="Configure first, then run"
/* The drain's Run now is a fan-out sweep, and the launcher can only
/* A fan-out routine's Run now is a sweep, and the launcher can only
ever send one agent β€” so this row's secondary action really is a
different job, and says so rather than looking like the same one. */
different job, and says so rather than looking like the same one.
Both fan-out routines (#1204), not just the drain: the same
sentence is true the moment a Run now stops being one start. */
description={
job.drains
job.drains || job.fansOut
? 'Opens the launcher with this prompt β€” one agent, not the fan-out.'
: 'Opens the launcher with this prompt, so you can set the model and where it runs.'
}
Expand Down
1 change: 1 addition & 0 deletions packages/the-framework/src/auto-pm.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,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.
- 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
107 changes: 104 additions & 3 deletions packages/the-framework/src/auto-pm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,7 +644,7 @@ test('a drain-only sweep still stands down when the drain routine is off (#1204/
queue: async () => ['entry a'],
optedOut: async () => [AUTO_PM_DRAIN_JOB.name],
})
await loop.tick({ onDemand: true, drainOnly: true })
await loop.tick({ onDemand: true, only: 'drain' })
loop.stop()
assert.deepEqual(ran, [])
assert.equal(loop.report().outcomes[0]?.message, 'the queue has work waiting and its routine is switched off')
Expand Down Expand Up @@ -1142,6 +1142,107 @@ test('a fansOut job fans out to the concurrency, one locked ticket per agent (#1
assert.match(prompts[0]!, new RegExp(`CLAIMED: ${lockCalls[0]![0]!.agentId}`))
})

// #1204: Run now on the planning routine reaches the same fan-out the daemon uses. It used to be
// a plain single start, so the concurrency setting was the one thing that click ignored.

test("a plan-only sweep fans out the planning routine, one locked ticket per agent (#1204)", async () => {
const prompts: string[] = []
const { loop } = harness({
jobs: [PLAN_JOB],
cooldownMs: 0,
concurrency: async () => 3,
planCandidates: async () => ['a.md', 'b.md', 'c.md'],
lockPlans: async (_p, assignments) => assignments,
start: async (_p, job) => {
prompts.push(job.prompt)
return `run-${prompts.length}`
},
})
await loop.tick({ onDemand: true, only: 'plan', projectId: 'p1' })
loop.stop()
assert.equal(prompts.length, 3, 'the click spends the concurrency, not one agent')
assert.match(prompts[0]!, /tickets\/a\.md/)
assert.match(prompts[2]!, /tickets\/c\.md/)
})

test("a plan-only sweep plans instead of draining, however full the queue is (#1204)", async () => {
// The queue-picked mode would send this tick to the drain. The click named the planning
// routine, so the queue is not its business.
//
// Asserted on the prompts rather than the job name: the name stays `plan` even when the tick
// falls through to the drain's fan-out, because it is the *batch* that differs β€” entries off
// the queue instead of tickets. The name alone passes either way, which is no guard at all.
const prompts: string[] = []
const { loop, ran } = harness({
jobs: [PLAN_JOB],
cooldownMs: 0,
concurrency: async () => 2,
queue: async () => ['work one', 'work two'],
drainJob: { name: 'drain', prompt: 'Work the queue.', drains: true },
planCandidates: async () => ['a.md'],
lockPlans: async (_p, assignments) => assignments,
start: async (_p, job) => {
prompts.push(job.prompt)
return `run-${prompts.length}`
},
})
await loop.tick({ onDemand: true, only: 'plan', projectId: 'p1' })
loop.stop()
assert.deepEqual(ran, [])
assert.equal(prompts.length, 1, 'one open ticket is one agent, not one per queue entry')
assert.match(prompts[0]!, /tickets\/a\.md/)
assert.ok(
!prompts.some(prompt => prompt.includes('work one')),
'no agent was handed a queue entry: the click asked for planning, not draining',
)
})

test("a plan-only sweep stands down when the planning routine is switched off (#1204)", async () => {
const { loop, ran, logs } = harness({
jobs: [PLAN_JOB],
cooldownMs: 0,
optedOut: async () => ['plan'],
planCandidates: async () => ['a.md'],
lockPlans: async (_p, assignments) => assignments,
})
await loop.tick({ onDemand: true, only: 'plan', projectId: 'p1' })
loop.stop()
assert.deepEqual(ran, [], 'an unticked box is not overridden by the click')
assert.ok(logs.some(line => line.includes('the planning routine is switched off')))
})

test("a plan-only sweep visits only the project the card picked (#1204)", async () => {
const { loop, started } = harness({
projects: async () => [
{ id: 'p1', path: '/one' },
{ id: 'p2', path: '/two' },
],
jobs: [PLAN_JOB],
cooldownMs: 0,
planCandidates: async () => ['a.md'],
lockPlans: async (_p, assignments) => assignments,
})
await loop.tick({ onDemand: true, only: 'plan', projectId: 'p2' })
loop.stop()
assert.deepEqual(started, ['p2'], 'the other project is not swept by a click that named one')
})

test("a plan click does not cost the rotation its turn (#1204)", async () => {
// The rotation is mid-cycle; a click that borrows the tick for a routine it named must leave
// the cycle where it was, the same way a due maintenance sweep does.
const other: AutoPmJob = { name: 'triage', prompt: 'Triage.' }
const { loop, ran } = harness({
jobs: [other, PLAN_JOB],
cooldownMs: 0,
planCandidates: async () => ['a.md'],
lockPlans: async (_p, assignments) => assignments,
})
await loop.tick({ onDemand: true, only: 'plan', projectId: 'p1' })
await loop.tick()
loop.stop()
assert.deepEqual(ran, ['plan', 'triage'], 'the scheduled tick still gets the rotation job it was owed')
})

test('only the tickets the lock actually claimed go out (#1327)', async () => {
// A lost race β€” b.md's sibling appeared between the enumeration and the lock β€” costs that one
// agent, not the batch.
Expand Down Expand Up @@ -1308,13 +1409,13 @@ test('a drain-only sweep works the queue and never borrows the tick for the rota
return `run-${prompts.length}`
},
})
await loop.tick({ onDemand: true, drainOnly: true })
await loop.tick({ onDemand: true, only: 'drain' })
loop.stop()
assert.equal(prompts.length, 2)

// ...and with an empty queue it says so instead of starting a rotation job.
const { loop: empty, started } = harness({ cooldownMs: 0, queue: async () => [] })
await empty.tick({ onDemand: true, drainOnly: true })
await empty.tick({ onDemand: true, only: 'drain' })
empty.stop()
assert.equal(started.length, 0)
assert.equal(empty.report().outcomes[0]?.message, 'the queue is empty, so there is nothing to drain')
Expand Down
Loading
Loading