Skip to content

Run now on the planning routine spends the concurrency, not one agent (#1204) - #1640

Merged
suleimansh merged 1 commit into
mainfrom
suleimansh/feat/1204-plan-run-now-fans-out
Aug 22, 2026
Merged

Run now on the planning routine spends the concurrency, not one agent (#1204)#1640
suleimansh merged 1 commit into
mainfrom
suleimansh/feat/1204-plan-run-now-fans-out

Conversation

@suleimansh

Copy link
Copy Markdown
Contributor

🤖 curated

Draft: this is Option B from #1204, built so the choice can be seen rather than imagined. One word merges or closes it.

@brillout — you wrote "as long as there isn't any redundant work being done, I don't see a problem with concurrent agents." This is the one place that condition was met and the setting still did nothing.

What was wrong

Two routines can fan out safely — a drain takes one entry off the queue, a plan agent writes one ticket's own sibling files — so their agents do disjoint work. Only one of them did it from the card:

the daemon, unattended clicking Run now
drain up to Concurrent agents up to Concurrent agents
Plan tickets up to Concurrent agents exactly one, always

Same routine, two behaviours depending on who fired it. The tooltip said "Starts one agent" and was telling the truth about the click — which is why this reads as a missing feature rather than a bug.

What changed

The sweep's narrowing stops being drain-shaped: drainOnly: true becomes only: 'drain' | 'plan'. A plan-only tick reaches the same claim-then-start path the rotation already takes — candidates enumerated, ticket locks pushed before any agent starts, one agent per locked ticket.

The locks are the point. Without them two clicks a second apart plan the same ticket twice, which is the exact double-work this thread is about. This reuses the sweep's existing lock path rather than adding a second one.

projectId scopes a sweep to one project. The drain sends none and visits them all — what its tooltip promises. Planning is the picked project's own work and carries one.

Three things a narrowed click must not do

Each is its own test:

  • work the queue instead — the queue-picked mode would send a plan click to the drain whenever entries are waiting
  • override a switched-off routine — an unticked box stands the click down and says so, rather than firing a routine the card shows as off
  • cost the rotation its turn — a click borrows the tick for a routine it named, so the cycle stays where it was; the rule a due maintenance sweep already follows

The tooltip follows the behaviour

Before → after, same Concurrent agents: 2:

Starts one agent in gemstack, unattended — nothing is asked mid-run.

Starts up to 2 agents in gemstack, one per open ticket, unattended.

Verified in the running dashboard, not only in tests.

The menu item moved too: "one agent, not the fan-out" was true of the drain and became true of planning the moment its button stopped being one start. It reads fansOut, not a routine name.

On testing

Every new test was checked by breaking the code it guards — five deliberate reversions in auto-pm.ts, three in RoutineWork.tsx, each caught by exactly the test that should catch it.

One test failed that check and had to be rewritten. The plans-instead-of-draining test passed with the fix reverted: it asserted the job's name, which stays plan either way, when the real difference is the batch — entries off the queue instead of tickets. It asserts the prompts now, and fails as it should.

pnpm typecheck on both packages; 1541 node tests + 796 vitest, all passing.

If you'd rather have Option A

Close this. A is coherent — Run now means "start this routine once", and fan-out is what unattended running adds. The code even had a comment defending it. Nothing else in the branch depends on this.

…#1204)

Two routines can fan out — a drain takes one entry off the queue, a plan agent
writes one ticket's own sibling files — and until now only one of them did so
from the card. The drain's Run now fires the sweep; [Plan tickets] fell through
to a plain single start, so on the one rotation job where the concurrency
setting is safe to spend, the click was the thing that ignored it. The daemon
running the same routine unattended started up to `Concurrent agents`.

The sweep's narrowing stops being drain-shaped. `drainOnly: true` becomes
`only: 'drain' | 'plan'`, and a plan-only tick reaches the same claim-then-start
path the rotation takes: candidates enumerated, ticket locks pushed before any
agent starts, one agent per locked ticket. The locks are the point — without
them two clicks a second apart plan the same ticket twice, which is the exact
double-work this issue is about.

Three things a narrowed tick must not do, each its own test:

- work the queue instead. The queue-picked mode would send a plan click to the
  drain whenever entries are waiting; the click named a routine, so it plans.
- override a switched-off routine. An unticked box stands the click down, and
  says so, rather than firing a routine the card shows as off.
- cost the rotation its turn. A click borrows the tick for a routine it named,
  so the cycle stays where it was — the rule a due maintenance sweep follows.

`projectId` scopes a sweep to one project. The drain sends none and visits them
all, which is what its tooltip promises; planning is the picked project's own
work and carries one.

Both surfaces that quote a number now read `fansOut` rather than only `drains`:
the tooltip says "Starts up to 2 agents in gemstack, one per open ticket"
instead of "Starts one agent", and the menu item's "one agent, not the fan-out"
warning — which was true of the drain and became true here the moment the
button stopped being one start — now shows on both.

Every new test was checked by breaking the code it guards. The first version of
the plans-instead-of-draining test passed with the fix reverted: it asserted the
job's name, which stays `plan` either way, when the difference is the batch —
entries off the queue instead of tickets. It asserts the prompts now.
@suleimansh

Copy link
Copy Markdown
Contributor Author

🤖 automated

Dogfooded on a real daemon and a real repo — the fan-out is no longer a claim resting on unit tests.

Rebuilt, restarted the daemon on this branch, and drove the dashboard through the browser. Three throwaway tickets in tf-1334-dogfood, concurrency set to 3, one click on Run now → Plan tickets.

One click, three agents

[framework] auto PM: planning "2026-08-22_add-contributing-file.md" in .../tf-1334-dogfood
[framework] auto PM: planning "2026-08-22_greet-by-name.md"        in .../tf-1334-dogfood
[framework] auto PM: planning "2026-08-22_readme-usage-section.md" in .../tf-1334-dogfood

The card said the same thing: "started 3 agents: planning …; planning …; planning …".

Each ticket was claimed before its agent started — three .lock.md files appeared together, then released as each run finished. That is the property the whole change rests on: without it two clicks plan the same ticket twice.

All three plans landed, and they are real work, not stubs — each read the repo (all three independently noted index.js is one line and there is no package.json) and carries its own Effort/Uncertainty spike:

ticket plan effort / uncertainty
Add a CONTRIBUTING.md 62 lines 1 / 1
index.js greets by name 59 lines 0 / 1
README gets a Usage section 47 lines 0 / 0

Before this branch the same click started one agent and the other two tickets sat untouched.

The tooltip tracks both the setting and the project

Starts up to 3 agents in tf-1334-dogfood, one per open ticket, unattended.

Read live off Concurrent agents: 3 and the picked project, not hardcoded.

Project scoping, proven without spending anything

Four projects are registered. With the routine switched off — so no agent could start either way — two clicks produced exactly two log lines:

standing down for .../gemstack           — the planning routine is switched off
standing down for .../tf-1334-dogfood    — the planning routine is switched off

One line per click, naming the project the picker showed. Unscoped, that would have been four lines each. gemstack has 71 plannable tickets, so a scoping bug would have been loud.

A fix I had not claimed

That stand-down is also a behaviour change worth naming: the old Run now called start() directly and ignored the routine's checkbox, so clicking a switched-off routine still spent an agent. It now stands down and says why.

Unrelated, confirmed in passing

The same daemon printed ✅ Up to date (v0.7.0)#1639's PACKAGE_NAME change resolving against the real registry.

@suleimansh

Copy link
Copy Markdown
Contributor Author

🤖 automated

The whole chain, run end to end on a throwaway repo — three tickets in, two pull requests with real code out.

Follow-up to the fan-out dogfood above. This one answers "where does planning fit, and who implements the plans?" by running every step for real on tf-1334-dogfood.

The chain

Update from GitHub     GitHub issues  →  tickets/*.md
       ↓
Plan tickets           tickets/*.md   →  tickets/*.plan.md          fans out
       ↓
Add quick-win work     tickets        →  entries on TODO_AGENTS.md  one agent
       ↓
Spin up agents…        queue entries  →  code + pull requests       fans out

Planning never touches code. Implementation is the drain, and it works from the queue, not from plans directly — a triage routine is what promotes a ticket onto the queue, and the entry links back to its ticket so the agent gets the ticket and its plan.

The two ends fan out; the middle cannot, because it rewrites the whole queue document.

What actually ran

step agents result
Plan tickets 3 3 plans, one per ticket, each with its own lock
Add quick-win work 1 2 entries queued, priority-ordered
Spin up agents… 2 2 PRs
auto PM: draining the queue entry "[index.js greets by name](...)"   in .../tf-1334-dogfood
auto PM: draining the queue entry "[Add a CONTRIBUTING.md](...)"     in .../tf-1334-dogfood

The other three registered projects stood down in the same sweep — "the queue is empty, so there is nothing to drain".

The output is real work

#8index.js +2/-1, exactly what its plan specified:

-console.log("hello")
+const name = process.argv[2] || "world"
+console.log(`hello ${name}`)

#7CONTRIBUTING.md +22, and it re-verified the claim its plan flagged: "There is no package.json, so there are no dependencies to install and no build step."

Two problems this turned up, neither caused by this branch

The triage implemented a ticket instead of queueing it. tf-triage-quick carries a 14-line README change and opened its own PR (#6), so only 2 of 3 tickets reached the queue. Triage is meant to promote work, not do it — a prompt-behaviour issue worth its own ticket.

A Run now can be silently eaten by the 30-minute cooldown. Clicking the drain right after the triage gave:

standing down for .../tf-1334-dogfood — a run was started for this project a moment ago

Nothing started. It is reported on the card, but a user who clicks and looks away sees a button that did nothing. A daemon restart clears the cooldown, which is what unblocked this run.

And a third instance of this branch's own theme. The first triage click aborted on Branch tf-triage-quick already exists. The sweep releases a stale pinned branch before starting (auto-pm.ts:1006); the card's plain-start path never calls releasePinned, so Run now cannot clear it and just dies — $0.16 for a turn that read nothing. Same shape as the asymmetry this PR fixes for the fan-out routines, still present for the pinned ones.

@suleimansh
suleimansh marked this pull request as ready for review August 22, 2026 21:29
@suleimansh
suleimansh merged commit f011e33 into main Aug 22, 2026
2 checks passed
@suleimansh
suleimansh deleted the suleimansh/feat/1204-plan-run-now-fans-out branch August 22, 2026 21:55
suleimansh added a commit that referenced this pull request Aug 22, 2026
Dogfooding the rotation (#1640) showed `Add quick-win work to AI Queue`
queueing two of three tickets and implementing the third itself: a README
change committed onto `tf-triage-quick` with its own pull request. Nothing
in `auto-pm.ts` asked for that; the prompt said "choose tickets to work on"
and "add tickets to TODO_AGENTS.md" and never said not to do them.

Both triage presets now end with the rule: the only file a triage changes
is `TODO_AGENTS.md`, and a ticket is never implemented inside one, however
small its plan. The reason is stated in the prompt so the agent weighs it
rather than obeys it — the queue is where a human can veto a ticket before
an agent builds it.

The rule lives once, as `prompts/triage_scope.md`, beside the queue format
it protects; the catalog appends it to both triage rows, so the two preset
files carry only what differs between them. The pair had already drifted
once on a shared sentence (the collision guard is worded two ways), which
is why it is not pasted into each. A test asserts each render ends with the
shared file's text.

The split matters for concurrency as much as for the veto: a triage
rewrites the shared queue document and so runs as one agent, while the
drain runs one agent per entry. A ticket built inside a triage takes the
single-agent lane and skips the fan-out. `prompts/SPEC.md` and
`preset-catalog.SPEC.md` record the rule and that rationale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
suleimansh added a commit that referenced this pull request Aug 22, 2026
…1645)

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 <noreply@anthropic.com>
suleimansh added a commit that referenced this pull request Aug 22, 2026
… sweep (#1643) (#1649)

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: <branch> }`,
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant