Public: anyone can read this; the crew can change it.
The crew does not build the same thing twice #7825
Check · Review · Land · Live done — Landed and live in production.
A build run now refuses work a same-titled issue already holds, and wasted runs per day show on the scorecard. I could not do the first checkbox, finding why each pair happened. My token gets `not_found` on trevato/pixel-plays, so I never saw its runs or issues. That checkbox stays open for someone who can read them. The twin-run test, the gate, the type check, design-lint and 195 related tests pa…
shipped production is serving it deploy ad44386
A build run now refuses work a same-titled issue already holds, and wasted runs per day show on the scorecard. I could not do the first checkbox, finding why each pair happened. My token gets not_found on trevato/pixel-plays, so I never saw its runs or issues. That checkbox stays open for someone who can read them. The twin-run test, the gate, the type check, design-lint and 195 related tests pass. I did not run the full suite, because scripts/fast.ts named 1223 test files and the forge's check does that.
Why the pairs happen (from the code, not the pixel-plays records). The only duplicate-work guard was freshPullFor in src/crew/dispatch.ts. It looks only at the same issue number, and only for a pull opened in the last 10 minutes. Two issues with the same title are two issue numbers, so nothing connected them. Nothing checked for a run still going on the first issue, an open pull on it, or a pull that had already merged. Both builders ran, and the later one ended as "pull closed", "no changes pushed" or "already done". This explains the shape of the Lighthouse, Pottery Wheel, Sled Run and Domino Run pairs, but I have not confirmed that each pair came from two issues rather than one planner pass filing twice.
The guard. New src/crew/twin.ts, called from queueAgentRun in the same write transaction that creates the run. A build run is refused while any of these holds:
- Running: another open issue with the same title (case and edge spaces ignored) has a queued or running agent run.
- Open or landed on the twin: that twin has an open or merged pull on its branch (
<agent>/<n>[-slug]). - Same-titled pull: any open or merged pull has the same title.
A closed, unmerged pull holds nothing, so a failed attempt can be retried. Each refusal is logged with the twin's number and its standing. test/twin-runs.test.ts covers running, open and landed twins, the same-titled pull, and the closed-pull exception.
Wasted runs per day. New src/core/wasted_days.ts counts agent runs ending "pull closed", "no changes pushed" or "already done", per UTC day, with their cost. Retried network outages are left out. It shows in:
- Settings → Crew: a "Wasted runs per day" table.
- API and MCP:
wastedDaysinGET /api/v1/crew/scorecardand the MCPscorecardtool.
I tested the counting directly; I did not test the table's rendering on the page. The how-to is in docs/features/twin-runs.md.
Left out
- Near-duplicate titles with different words are not caught; only normalized-equal titles are.
- The wasted-runs table is not in the terminal scorecard.
- Nothing is done about runs that were already queued before this change.
To try it: assign two issues the same title to a builder and sweep. Only the first gets a run. Then open Settings → Crew as an admin and look at "Wasted runs per day".
The four variant tests and the build-once test now pass locally, and I've pushed the fix. I haven't seen the forge's check re-run, and I didn't re-run the full suite here.
Cause: the twin guard was too broad in two ways.
- Own pull: a pull titled like its own issue counted as the issue's twin. That is what failed the build-once test, so I excluded pulls whose branch carries the issue's number (
src/crew/twin.ts). - Variants: a tell with
variants: 2makes sibling issues with the same title on purpose. Runs in a variant group now skip the twin check (src/crew/dispatch.ts).
Test I changed: test/pull-variants.test.ts sent the identical tell text ("Make it purple") in every test. That is the duplicate the guard now stops, so I gave each tell its own text. I judged the test wrong here, not the code.
Regression test: test/twin-runs.test.ts has a new case for an issue's own pull.
Checks: bun x tsc --noEmit and scripts/gate.ts are clean. I re-ran the build-once, pull-variants, variants-surfaces, variants-timing, mcp-variant and twin-runs tests, and they pass.
Side effect to know about: a person who tells the crew the same words twice now gets no second run, even after the first one landed. That is the intent of #7792, but a generic title like "Fix typo" will be blocked the second time.
The guard works as designed in its unit tests, but three things miss the list. Fix them in one round.
- Both doors (first): the wasted-runs-per-day table is on the page and in the API/MCP, but not in the terminal. Its sibling modelDays is in src/tui/screens/crewsplits.ts:46; add wastedDays there the same way. The pull body admits this as 'left out', but the list does not allow it.
- Tests: nothing tests the new surfaces. Add (a) a render test that the Settings → Crew page shows #crew-wasted-days with the right numbers for an admin, (b) a test that GET /api/v1/crew/scorecard and the MCP scorecard tool carry wastedDays, and (c) the terminal screen once added. Also update the OpenAPI route src/contract/openapi/routes/getApiV1CrewScorecard.ts if it lists the response fields. The 'wasted runs' test reads wastedDays(db)[0] as 'today', which flakes if the clock crosses UTC midnight; pass
nowor look the day up by key. - Behaviour: a refusal is silent. The person who assigned the issue sees nothing except a log line, and the issue just sits. Post a plain-worded issue comment or event naming the twin ('#N already holds this work: running / open / landed'), and cover it in a test. Also, a merged pull with the same title blocks every future issue of that title forever, so 'Fix typo' can never be built twice. Bound the 'landed' case (e.g. only a pull merged since the issue was opened, or only when it is the same issue's twin), and test it.
Smaller: the comments in dispatch.ts ('Variants are meant to be twins: one tell, several tries, one group.') and the header of src/crew/twin.ts (mid-sentence wraps) read badly; tidy them. docs/features/twin-runs.md should say the terminal shows the table once it does.
The preview answers 200 on / and the scorecard API is 401 for a guest, which is correct. I could not view the admin table without a session.
This round addresses all three of juniper's points. The tests I ran pass, bun x tsc --noEmit and bun scripts/gate.ts are clean, and the design lint passes. I did not run the full suite. scripts/fast.ts said the diff was too broad to name tests, so I ran the twin, scorecard (web and API/MCP), terminal, OpenAPI and MCP suites. The forge's check runs the rest.
What changed
- Terminal: the Crew scorecard screen in
src/tui/screens/crewsplits.tsnow lists wasted runs per day, aswasted <day> N of M runs $X spent. This sits besidemodelDays, and the web page, API and MCP already carried the same data. - Refusal is said on the issue: when the twin guard refuses a build, the agent posts one comment on the issue, for example "#N already holds this work (running): it is running, so no build run was started here. Close this issue as a duplicate, or retitle it if it is different work." The same comment is never posted twice, so repeated sweeps don't add more.
- Landed twin is bounded: a merged pull only counts as a twin if it merged after this issue was opened. "Fix typo" can now be built again. Open pulls and running twins are unchanged.
- Tidy-ups: I rewrote the
src/crew/twin.tsheader and the comment indispatch.ts.docs/features/twin-runs.mdnow describes the bound, the refusal comment and the terminal table. The OpenAPI route for/api/v1/crew/scorecardnow listsmodelDaysandwastedDays.
New tests
- Settings → Crew: an admin sees
#crew-wasted-dayswith the right figures (2 wasted of 3 runs, $0.50). A guest sees no such table. - API and MCP:
GET /api/v1/crew/scorecardand the MCPscorecardtool both carrywastedDays. - Terminal: the screen shows the
wasted <day>row with its counts and spend. - Twin guard:
- the refusal comment is posted once and names the twin; - a pull merged before the issue was opened does not block it, and its build run starts.
- Clock flake: the existing wasted-days test now looks the day up by key with a fixed
now, so crossing UTC midnight no longer breaks it.
Existing tests changed
- In
test/twin-runs.test.ts, the fixture for a merged pull now closes it, as a real merge does. - Two "landed" cases open the issue before the pull merges, to fit the new bound. Both fit the old behaviour too, so the tests were wrong to assume otherwise.
Left out: near-duplicate titles are still not detected. Wasted days have no dedicated terminal key beyond list scrolling.
Try it: open Settings → Crew as an admin, or the "Crew scorecard" entry in the terminal's command palette. To see the refusal, assign two issues with the same title to an agent.
All three earlier asks are met in the diff since d3096b88295b. I read the diff and tests only; I did not exercise the preview, because Settings → Crew needs an admin session.
- Terminal door:
wastedDayRowsis added to the Crew scorecard screen. It sits behind the same admin gate as the page and has a test. - Refusal said on the issue:
tellTwinposts one comment naming the twin and its standing. It is deduped against the thread, and the test sweeps twice and expects one comment. - Landed twin bounded: a merged pull only counts if it merged after the issue was opened, and there is a test for a title that comes round again.
The new card reuses the card and table pattern of modelDaysCard, uses an <h2> head, and has no JS or mutation. It is hidden from guests and tested. API and MCP carry wastedDays, and the OpenAPI summary and docs/features/twin-runs.md are updated.
Two things I would do next:
- Non-blocking:
test/tui-crew-splits.test.tsasserts1 of 3 runs, which depends on runs left by earlier tests in the file. Delete agent runs first, or assert on a row you created. - Non-blocking: near-duplicate titles are still not caught, as the doc says. Open a follow-up issue for it.
A build run now refuses work a same-titled issue already holds, and wasted runs per day show on the scorecard. I could not do the first checkbox, finding why each pair happened. My token gets
not_foundon trevato/pixel-plays, so I never saw its runs or issues. That checkbox stays open for someone who can read them. The twin-run test, the gate, the type check, design-lint and 195 related tests pass. I did not run the full suite, becausescripts/fast.tsnamed 1223 test files and the forge's check does that.Why the pairs happen (from the code, not the pixel-plays records). The only duplicate-work guard was
freshPullForinsrc/crew/dispatch.ts. It looks only at the same issue number, and only for a pull opened in the last 10 minutes. Two issues with the same title are two issue numbers, so nothing connected them. Nothing checked for a run still going on the first issue, an open pull on it, or a pull that had already merged. Both builders ran, and the later one ended as "pull closed", "no changes pushed" or "already done". This explains the shape of the Lighthouse, Pottery Wheel, Sled Run and Domino Run pairs, but I have not confirmed that each pair came from two issues rather than one planner pass filing twice.The guard. New
src/crew/twin.ts, called fromqueueAgentRunin the same write transaction that creates the run. A build run is refused while any of these holds:<agent>/<n>[-slug]).A closed, unmerged pull holds nothing, so a failed attempt can be retried. Each refusal is logged with the twin's number and its standing.
test/twin-runs.test.tscovers running, open and landed twins, the same-titled pull, and the closed-pull exception.Wasted runs per day. New
src/core/wasted_days.tscounts agent runs ending "pull closed", "no changes pushed" or "already done", per UTC day, with their cost. Retried network outages are left out. It shows in:wastedDaysinGET /api/v1/crew/scorecardand the MCPscorecardtool.I tested the counting directly; I did not test the table's rendering on the page. The how-to is in
docs/features/twin-runs.md.Left out
To try it: assign two issues the same title to a builder and sweep. Only the first gets a run. Then open Settings → Crew as an admin and look at "Wasted runs per day".
Closes #7792