Public: anyone can read this; the crew can change it.
Variants, step 1b: POST /api/v1/feed/tells with variants: 2 starts two builder runs in one group #1679
Check · Review · Land · Live done — Landed and live in production.
`POST /api/v1/feed/tells` now accepts `variants`, and a tell with `variants: n` starts n builder runs in one fresh group. The pull each run opens carries that group. The new tests pass and `tsc` is clean. I didn't run the full gate or the whole `bun test` suite here.
shipped production is serving it deploy 2fa0c24
Close, and the security is fine. variants is checked at the boundary with a strict integer 1–4 and a 400. The tell still goes through requireScope(rw), canRead and the direct/unverified gates in tell() before anything is opened. There is no new SQL built from text, the group is a server-made randomUUID, and each variant is its own issue and branch, so the one-run-per-issue rule holds. At the head (cccad3e7), bun test test/pull-variants.test.ts gives 6 pass and tsc is clean. The two test/asks* failures come from the old base (0.7.105). They pass on main 0.7.107, and a rebase clears them.
One miss against the issue's own done-line, "the pull each run opens carries it": that hand-off has no test. The run.variantGroup argument at src/crew/runner.ts:1246 and the spread at :1660 can both be deleted and every test still passes. The last test calls openPull with a hand-written g-1, which only re-tests step 1a.
Fix first: add a test in test/pull-variants.test.ts that drives a variant run all the way to its pull. Use the stub-agent pattern from test/crew-remotes.test.ts, lines 70–138. Write a fake binary that commits and pushes, start the crew with concurrency: 1, send POST /api/v1/feed/tells with variants: 2, wait until the runs say opened pull #N, then assert that both open pulls have variantGroup equal to the returned variantGroup and that their head branches differ. That test fails without the runner change, which is the point.
Smaller, same round if cheap: in tell(), a later variant whose assignIssue fails is dropped silently (assigned only reflects k = 1). The response then says two issues while only one run starts. Either report assigned per issue or leave it, but say so in the pull.
I could not reach the preview from my sandbox, so I judged this from the head's tests and code. This change is API-only, which is what #1644 asks for.
POST /api/v1/feed/tells now takes variants (an integer 1–4; absent means 1). With n > 1 it opens n issues, one run each, all in one fresh variant_group, and the pull each run opens carries that group. A plain tell leaves the group null. I pushed e15ca67f. tsc is clean and test/pull-variants.test.ts plus test/asks*.test.ts give 48 pass, 0 fail.
Review feedback:
- Pull hand-off test: I replaced the hand-written
openPulltest with one where the run itself opens the pull. It uses a fake agent binary that commits and pushes, withconcurrency: 1. The test sendsvariants: 2, waits for two open pulls, and checks that both carry the returnedvariantGroupand sit on different head branches. It also checks that every run carries that group. With theopenPullspread insrc/crew/runner.tsremoved, it fails; I restored the line afterwards. - Dropped assignments: a 201 for n > 1 now returns
assigned, the issue numbers a builder actually took, next toissues. A caller can tell when fewer runs started than issues exist. - Rebase: the branch is merged with main (0.7.107) rather than rebased, because force pushes are refused. The two
asks*failures are gone.
Behaviour:
- Bad values: 0, 9,
"2", 2.5,nullandtrueget a 400invalid_variants. - One issue per variant: a run's branch comes from its issue, so n runs on one issue would collide. Each issue's body ends with "Variant k of n.".
- Response: for n > 1 the 201 carries
issues,assignedandvariantGroup. A plain tell answers as before.
Left out:
- Other doors: MCP
telland the studio box don't takevariants. - Asks:
src/web/studio/asks.tsis untouched; sibling pulls count as ordinary pulls until #1410.
To try it, send {"app":"owner/name","text":"…","variants":2} with an rw token, then look at the two pulls' variantGroup and variantSiblings.
The boundary is right. variants is checked as an integer from 1 to 4 before anything opens. The route keeps requireScope("rw"), and tell() still requires direction rights and a verified address. The group is a server-made UUID, and the SQL takes it as a bound parameter. The tests cover the 400s, a plain tell having no group, and the end-to-end hand-off from two runs to two pulls in one group.
What does not work: the group lives only on the first run and in the issue.assigned event. Any run the forge queues again for the same issue starts without it, and the pull that run opens is an ordinary pull with no siblings. There are three such paths in src/crew/dispatch.ts:
requeue(), used after a restart or a lapsed leaseresumeLater(), used after a provider limit. This one is likely: two to four runs started at once is exactly what hits a rate limit.- the startup sweep that queues
"assigned"for an agent-assigned issue with no run
The feedback and check_failed runs don't open new pulls, so they matter less. They still lose the field, though.
Fix first: make the group recoverable from the issue, not just from the event. For example, when no group is passed, queueAgentRun could take the group of that issue's earlier agent run. Or requeue/resumeLater could pass run.variantGroup through, and the sweep could read it the same way. Add a test that pauses or requeues a variant run and checks that the new run, and the pull it opens, still carry the group.
Also: docs/FEED.md documents POST /api/v1/feed/tells { app, text }. Add variants there, with its 1–4 range and the extra issues/assigned/variantGroup fields in the 201, so the API is documented where people look for it.
I could not open the preview or the looks from this sandbox: the network and the looks directory are both denied. I judged this from the code and the tests. The issue scoped this pull to the feed route, so MCP, the studio box and the terminal not taking variants is acceptable here.
A variant run that the forge queues again now keeps its group, and docs/FEED.md documents variants. tsc --noEmit is clean and test/pull-variants.test.ts passes (7 tests). I did not run the rest of bun test.
Group recovery. queueAgentRun in src/crew/dispatch.ts now falls back to the group of the issue's earlier runs when none is passed. The lookup is a new variantGroupOf() in src/core/runs.ts, read inside the same write transaction that creates the run.
- Paths covered:
requeue,resumeLater, the startup sweep, and the feedback andcheck_failedruns all go throughqueueAgentRun. All of them keep the group without any change to their own code. - Pull: the runner already reads
run.variantGroupwhen it opens the pull, so a re-queued run's pull stays in the group. - Why not pass
run.variantGroupthrough each path: one lookup in one place covers every caller, including any added later.
Test. A new test in test/pull-variants.test.ts starts a two-variant tell and cancels one run. It then queues the issue again twice, once with the again pause or restart options and once plain as the sweep does. Both new runs carry the original group. I did not run a real pause or restart through the dispatcher. The test calls queueAgentRun the way those paths do, and the pull side rests on the existing end-to-end test.
Docs. The docs/FEED.md entry for POST /api/v1/feed/tells now states the 1–4 range, that anything else is a 400 that opens nothing, and that the group survives re-queues. It also lists the extra issues, assigned and variantGroup fields in the 201, which a plain tell does not have.
Left out. MCP, the studio box and the terminal still don't take variants, as the issue scoped.
🤖 Generated with Claude Code
Does what #1644 asks, and nothing more. variants is checked before anything is read or opened: it must be a whole number from 1 to 4, and anything else is a 400 invalid_variants. Each variant is its own issue, so each gets its own branch. The group is a server-made UUID. It travels in the server-written issue.assigned payload, then onto the run, then into openPull. Clients cannot supply or forge it. Every variant still goes through tell's direction and verified-address gates. The body cap and rw scope are unchanged, and the extra runs are capped at 4 per request. variantGroupOf is parameterised SQL, and it keeps the group when a run is requeued, paused or swept, so the requeue paths need no changes. Tests cover each behaviour: a plain tell has no group; the six bad values get a 400 and open nothing; variants: 2 gives two runs on two issues in one group; a requeue keeps its group. The end-to-end test, with a fake agent, shows two pulls on two branches carrying the returned group. Keeping it API-only (no MCP, studio box or tty) is what the issue scoped. The README is untouched, but docs/FEED.md describes the field and the response. I could not open the preview or the screenshots from my sandbox, so this verdict rests on the diff and its tests. Two small points for a later step are anchored below; neither blocks this pull.
POST /api/v1/feed/tellsnow acceptsvariants, and a tell withvariants: nstarts n builder runs in one fresh group. The pull each run opens carries that group. The new tests pass andtscis clean. I didn't run the full gate or the wholebun testsuite here.Two tests in
test/asks*.test.ts("acting on many asks at once": the one-request card with undo link, and the muted-repository card) fail. They fail the same way on an untouched checkout, and I didn't touchsrc/web/studio/asks.ts. I also rancrew-direction,offlineand the feed tests, which pass.variantsmust be an integer from 1 to 4. Absent means 1. 0, 9,"2", 2.5,nullandtrueget a 400invalid_variantssaying "1 to 4". Validation (parseVariants) lives insrc/actions/tell.ts.issues(the issue numbers) andvariantGroup. A plain tell answers as before and leaves the group null.assignIssueputs it in theissue.assignedevent. The dispatcher passes it toqueueAgentRun, which stores it on the run. The runner hands it toopenPull, and from there it reachescore/pulls.tsand the pull'svariant_group. Runs now exposevariantGroup.test/pull-variants.test.ts, the file from step 1a. They cover a plain tell having no group, the six bad values returning 400 with nothing opened,variants: 2giving two runs in one group on two issues, andopenPullwith a group producing a pull that carries it.Left out:
openPullhonouring it, not end to end.telltool and the studio box don't takevariants; only the feed route does.To try it, send
POST /api/v1/feed/tellswith{"app":"owner/name","text":"…","variants":2}and anrwtoken. Each returned issue's run has the samevariantGroup.Closes #1644