Skip to content

Commit 78dd52e

Browse files
mabry1985claude
andauthored
feat(loop): Max-Mode N-parallel coders — build N ways, judge, ship the best (v0.20.0) (#51)
The best-of-N judge (`_judge_candidates`) landed in #22; this wires the N-parallel dispatch that feeds it. When `max_mode_n > 1`, a from-scratch build fans out N coders on the same feature instead of one: • create N throwaway candidate worktrees (`feat-<id>.c<k>`) off the same base • dispatch the coder into ALL of them concurrently (asyncio.gather), each keeping its own coder_timeout watchdog + finally teardown; return_exceptions so one candidate timing out/erroring leaves an empty tree the judge skips, not a dead batch • `_judge_candidates` picks the winning diff • PROMOTE the winner into the canonical `feat-<id>` worktree / `feat/<id>` branch (git worktree move + branch -m, uncommitted changes preserved) so the rest of the lifecycle — CI-fail bounce, crash recovery, reaping — is unchanged; reap the losers • all-empty → NoChangesError, which the loop escalates/blocks like a single coder that produced nothing Gating: only a FROM-SCRATCH build fans out. A carried-forward re-dispatch (CI bounce / goal-fix / gate-fix — all signalled by _ci_feedback) FIXES the existing diff with one coder, so it must not re-fan-out N. keep_wt fix retries stay single-candidate too. Default off (max_mode_n=1). Bound N × max_concurrent to the host. Isolation stays on git worktrees (container-use deferred to its own platform ADR). Closes #21 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5826b29 commit 78dd52e

6 files changed

Lines changed: 253 additions & 21 deletions

File tree

loop.py

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -604,20 +604,31 @@ async def _drive(self, feature: dict):
604604
if coder is None:
605605
store.flag_blocked(fid, f"coder delegate {coder_name!r} not configured/enabled")
606606
return
607-
# A goal-fix retry REUSES the worktree — the implementation is already
608-
# there, the coder just adds what the reviewer flagged (usually tests).
609-
# Rebuilding on a fresh worktree throws the impl away (the coder then
610-
# spends its budget re-implementing and never reaches the tests — the
611-
# bd-2fd/bd-3cj block). Otherwise: a fresh worktree per attempt.
612-
if keep_wt and wt is not None:
613-
keep_wt = False # consume the reuse
614-
else:
615-
wt, branch = await worktree.create_worktree(repo, base, fid, self.root)
616-
self._inflight[fid] = (repo, wt, branch) # track for shutdown reaping
617607
try:
618-
result = await worktree.dispatch_coder(
619-
coder, wt, prompt, timeout=self.coder_timeout or None
620-
) # reaps subprocess; CoderTimeout if it overruns
608+
# How this attempt gets its worktree + coder result:
609+
# • keep_wt → REUSE the kept worktree (impl intact), one re-dispatch.
610+
# A goal-fix/gate-fix retry must not throw the implementation away —
611+
# the coder only ADDS what the reviewer flagged (usually tests); a
612+
# fresh rebuild makes it re-implement and never reach the tests (the
613+
# bd-2fd/bd-3cj block).
614+
# • max-mode → N parallel candidates, judge, promote the winner (#21).
615+
# ONLY for a from-scratch build: a carried-forward re-dispatch (a CI
616+
# bounce / goal-fix / gate-fix — all signalled by _ci_feedback) FIXES
617+
# the existing diff with one coder, so it must NOT re-fan-out N.
618+
# • otherwise → one fresh worktree, one dispatch.
619+
if keep_wt and wt is not None:
620+
keep_wt = False # consume the reuse
621+
self._inflight[fid] = (repo, wt, branch)
622+
result = await worktree.dispatch_coder(coder, wt, prompt, timeout=self.coder_timeout or None)
623+
elif self.max_mode_n > 1 and not self._ci_feedback.get(fid):
624+
wt, branch, result = await self._dispatch_max_mode(feature, coder, prompt, repo, base, fid)
625+
self._inflight[fid] = (repo, wt, branch)
626+
else:
627+
wt, branch = await worktree.create_worktree(repo, base, fid, self.root)
628+
self._inflight[fid] = (repo, wt, branch) # track for shutdown reaping
629+
result = await worktree.dispatch_coder(
630+
coder, wt, prompt, timeout=self.coder_timeout or None
631+
) # reaps subprocess; CoderTimeout if it overruns
621632
# Goal-verification gate: confirm the diff meets the acceptance
622633
# criteria before opening a PR. A gap is a capability failure (the
623634
# coder didn't deliver) → escalate/block, don't open the PR.
@@ -940,6 +951,54 @@ async def _judge_candidates(self, feature: dict, base: str, worktrees: list[str]
940951
return idx
941952
return nonempty[0] # judge unclear → first non-empty candidate
942953

954+
async def _dispatch_max_mode(
955+
self, feature: dict, coder, prompt: str, repo: str, base: str, fid: str
956+
) -> tuple[str, str, str]:
957+
"""Max-Mode (#21): build the feature N ways in parallel and ship the best diff.
958+
959+
Creates ``max_mode_n`` throwaway candidate worktrees off the same base (suffixed
960+
``feat-<id>.c<k>`` so none collides with the canonical name), dispatches the coder
961+
into ALL of them concurrently — each keeps its own ``coder_timeout`` watchdog +
962+
``finally`` subprocess teardown (``dispatch_coder``), and ``return_exceptions``
963+
means one candidate timing out / erroring leaves an empty tree the judge skips
964+
rather than sinking the batch. ``_judge_candidates`` (the best-of-N judge that
965+
landed in #22) picks the winning index; the winner is PROMOTED into the canonical
966+
``feat-<id>`` worktree / ``feat/<id>`` branch (so the rest of the lifecycle is
967+
unchanged) and the losers are reaped. All-empty → ``NoChangesError``, which
968+
``_drive`` escalates/blocks exactly like a single coder that produced nothing.
969+
970+
Returns (canonical_wt, canonical_branch, winner_reply). The fan-out is bounded by
971+
``max_concurrent`` × ``max_mode_n`` coders; size those to the host."""
972+
n = self.max_mode_n
973+
cand_ids = [f"{fid}.c{i}" for i in range(n)]
974+
# Create the N worktrees sequentially (git serializes worktree-list writes); the
975+
# slow part — the coder dispatch — is what we then fan out in parallel.
976+
cands: list[tuple[str, str]] = []
977+
for cid in cand_ids:
978+
cands.append(await worktree.create_worktree(repo, base, cid, self.root))
979+
log.info("[project_board] %s max-mode: dispatching %d parallel candidates", fid, n)
980+
results = await asyncio.gather(
981+
*(worktree.dispatch_coder(coder, wt, prompt, timeout=self.coder_timeout or None) for wt, _b in cands),
982+
return_exceptions=True,
983+
)
984+
for i, r in enumerate(results):
985+
if isinstance(r, Exception):
986+
log.info("[project_board] %s max-mode candidate %d failed (skipped): %s", fid, i, r)
987+
idx = await self._judge_candidates(feature, base, [wt for wt, _b in cands])
988+
if idx is None:
989+
for cid in cand_ids:
990+
await worktree.reap_feature_worktree(repo, self.root, cid)
991+
raise worktree.NoChangesError(f"max-mode: all {n} candidates produced no diff")
992+
log.info("[project_board] %s max-mode: candidate %d/%d wins → promoting", fid, idx, n)
993+
win_wt, win_branch = cands[idx]
994+
canon_wt, canon_branch = await worktree.promote_worktree(repo, win_wt, win_branch, fid, self.root)
995+
# Reap the losers (the winner was moved out of its candidate name by promote).
996+
for i, cid in enumerate(cand_ids):
997+
if i != idx:
998+
await worktree.reap_feature_worktree(repo, self.root, cid)
999+
winner_reply = results[idx] if not isinstance(results[idx], Exception) else ""
1000+
return canon_wt, canon_branch, winner_reply
1001+
9431002
async def _fetch_kg_lessons(self, feature: dict) -> str:
9441003
"""Query the knowledge graph (via graph.sdk) for lessons relevant to THIS
9451004
feature — the read half of the flywheel. Builds the query from the feature's

protoagent.plugin.yaml

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
id: project_board
22
name: Project Board (coding orchestration)
3-
version: 0.19.1
3+
version: 0.20.0
44
description: >-
55
A board-driven coding-orchestration plugin: a lean 6-state board (backlog → ready
66
→ in_progress → in_review → done, + a blocked flag) backed by **beads** (`br`), an
@@ -53,10 +53,13 @@ config:
5353
# opens ANYWAY (CI + ci_fix_max stay the backstop — a flaky or
5454
# misconfigured gate never blocks good work).
5555
local_gate_timeout_s: 600 # hard cap on one gate run; a timeout degrades to "pass" (CI gates).
56-
max_mode_n: 1 # best-of-N "Max-Mode" (borrowed from MiMo-Code). 1 = off. >1 runs
57-
# N coders on the same feature; a low-temp judge picks the diff that
58-
# best satisfies acceptance_criteria. The N-parallel dispatch is WIP
59-
# (#21); the judge (_judge_candidates) ships + is unit-tested now.
56+
max_mode_n: 1 # best-of-N "Max-Mode" (borrowed from MiMo-Code). 1 = off. >1 builds a
57+
# feature N ways in parallel (N candidate worktrees, all dispatched at
58+
# once), a low-temp judge picks the diff that best satisfies
59+
# acceptance_criteria, the winner is promoted to the canonical branch
60+
# and the losers reaped (#21). Costs N× tokens — gate it to hard work;
61+
# only a FROM-SCRATCH build fans out (a CI/goal-fix re-dispatch fixes
62+
# the existing diff with one coder). Bound N × max_concurrent to the host.
6063
repo: "." # repo the coders work in (worktrees branch off it)
6164
base_branch: main # base branch worktrees/PRs target
6265
worktrees_root: .worktrees

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "project-board"
3-
version = "0.19.1"
3+
version = "0.20.0"
44
description = "Board-driven coding-orchestration plugin for protoAgent (beads board + ACP spawn loop + planning layer + console view)."
55
requires-python = ">=3.11"
66

tests/test_loop.py

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ def test_max_concurrent_floors_at_one():
7070
assert BoardLoop({"max_concurrent": 4}).max_concurrent == 4
7171

7272

73+
def test_max_mode_n_parsing():
74+
assert BoardLoop({}).max_mode_n == 1 # off by default
75+
assert BoardLoop({"max_mode_n": 5}).max_mode_n == 5
76+
assert BoardLoop({"max_mode_n": 0}).max_mode_n == 1 # floors at 1 (never < 1)
77+
78+
7379
# ── the coder prompt (ProtoMaker discipline: name the files, demand the diff) ────
7480

7581

@@ -163,12 +169,19 @@ async def _boom(*a, **k):
163169
# ── _drive: the state machine ───────────────────────────────────────────────────
164170

165171

166-
async def _drive_with(monkeypatch, *, open_pr, coder=object(), dispatch=None, cfg=None, gate=None):
172+
async def _drive_with(
173+
monkeypatch, *, open_pr, coder=object(), dispatch=None, cfg=None, gate=None, judge=None, seed=None
174+
):
167175
"""Run _drive over FEATURE with the worktree helpers + delegate stubbed.
168-
Returns the FakeLoopStore so the test can assert the recorded transitions."""
176+
Returns the FakeLoopStore so the test can assert the recorded transitions.
177+
178+
``judge`` stubs ``_judge_candidates`` (Max-Mode best-of-N); ``seed`` is a callable
179+
run on the loop before the drive (e.g. to pre-seed _ci_feedback for a CI-bounce test)."""
169180
store = FakeLoopStore()
170181
store.creates = [] # fids create_worktree was called for (a goal-fix retry reuses, so won't re-create)
171182
store.removes = [] # worktrees remove_worktree was called for
183+
store.reaps = [] # fids reap_feature_worktree was called for (Max-Mode loser teardown)
184+
store.promotes = [] # (src_wt, src_branch, fid) the Max-Mode winner was promoted with
172185
monkeypatch.setattr("project_board.loop.get_store", lambda **_kw: store)
173186

174187
async def _create(repo, base, fid, root):
@@ -182,15 +195,28 @@ async def _remove(repo, wt, branch=""):
182195
store.removes.append(wt)
183196
return None
184197

198+
async def _reap(repo, root, fid):
199+
store.reaps.append(fid)
200+
201+
async def _promote(repo, src_wt, src_branch, fid, root=".worktrees"):
202+
store.promotes.append((src_wt, src_branch, fid))
203+
return ("/wt/feat-" + fid, "feat/" + fid)
204+
185205
monkeypatch.setattr(worktree, "create_worktree", _create)
186206
monkeypatch.setattr(worktree, "dispatch_coder", dispatch or _default_dispatch)
187207
monkeypatch.setattr(worktree, "open_pr", open_pr)
188208
monkeypatch.setattr(worktree, "remove_worktree", _remove)
209+
monkeypatch.setattr(worktree, "reap_feature_worktree", _reap)
210+
monkeypatch.setattr(worktree, "promote_worktree", _promote)
189211

190212
loop = BoardLoop(cfg or {"coder": "proto"})
191213
monkeypatch.setattr(loop, "_resolve_delegate", lambda name, expect: coder)
192214
if gate is not None:
193215
monkeypatch.setattr(loop, "_run_local_gate", gate)
216+
if judge is not None:
217+
monkeypatch.setattr(loop, "_judge_candidates", judge)
218+
if seed is not None:
219+
seed(loop)
194220
await loop._drive(FEATURE)
195221
return loop, store
196222

@@ -204,6 +230,91 @@ async def _open_pr(wt, branch, *, base, title, body):
204230
assert loop._inflight == {} # a completed drive leaves nothing to reap
205231

206232

233+
# ── Max-Mode: N parallel candidates → judge → promote winner → ship (#21) ────────
234+
235+
236+
async def test_drive_max_mode_fans_out_and_ships_the_winner(monkeypatch):
237+
"""max_mode_n=3 → 3 candidate worktrees built + dispatched in parallel, the judge
238+
picks one, the winner is promoted to the canonical name, the losers are reaped, and
239+
ONLY the winner's PR opens (on the canonical branch)."""
240+
opened = []
241+
242+
async def _open_pr(wt, branch, *, base, title, body):
243+
opened.append((wt, branch))
244+
return "https://example/pr/7"
245+
246+
dispatched = []
247+
248+
async def _dispatch(c, wt, prompt, *, timeout=None):
249+
dispatched.append(wt)
250+
return f"reply from {wt}"
251+
252+
async def _judge(feature, base, worktrees):
253+
assert len(worktrees) == 3 # the judge sees every candidate
254+
return 2 # candidate index 2 wins
255+
256+
loop, store = await _drive_with(
257+
monkeypatch,
258+
open_pr=_open_pr,
259+
dispatch=_dispatch,
260+
judge=_judge,
261+
cfg={"coder": "proto", "max_mode_n": 3},
262+
)
263+
# Three candidate worktrees, suffixed so none collides with the canonical name.
264+
assert store.creates == ["bd-1.c0", "bd-1.c1", "bd-1.c2"]
265+
assert len(dispatched) == 3 # all three coders ran
266+
# The winner (c2) is promoted to canonical; the two losers are reaped (winner is not).
267+
assert store.promotes == [("/wt/feat-bd-1.c2", "feat/bd-1.c2", "bd-1")]
268+
assert set(store.reaps) == {"bd-1.c0", "bd-1.c1"}
269+
# Only the winner's PR opens, on the canonical branch.
270+
assert opened == [("/wt/feat-bd-1", "feat/bd-1")]
271+
assert ("open_review", "bd-1", "https://example/pr/7") in store.calls
272+
assert loop._inflight == {}
273+
274+
275+
async def test_drive_max_mode_all_empty_reaps_all_and_blocks(monkeypatch):
276+
"""Every candidate empty → judge returns None → all candidates reaped, no PR, and
277+
the feature blocks (NoChangesError, single coder with no ladder)."""
278+
279+
async def _open_pr(wt, branch, *, base, title, body):
280+
raise AssertionError("no PR should open when every candidate is empty")
281+
282+
async def _judge(feature, base, worktrees):
283+
return None # nothing to ship
284+
285+
loop, store = await _drive_with(
286+
monkeypatch,
287+
open_pr=_open_pr,
288+
judge=_judge,
289+
cfg={"coder": "proto", "max_mode_n": 3},
290+
)
291+
assert set(store.reaps) == {"bd-1.c0", "bd-1.c1", "bd-1.c2"} # every candidate torn down
292+
assert store.promotes == [] # nothing promoted
293+
assert "flag_blocked" in store.names()
294+
295+
296+
async def test_drive_max_mode_skips_fanout_on_a_carried_forward_fix(monkeypatch):
297+
"""A re-dispatch carrying _ci_feedback (a CI bounce / goal-fix / gate-fix) FIXES the
298+
existing diff with ONE coder — Max-Mode must not re-fan-out N candidates."""
299+
300+
async def _open_pr(wt, branch, *, base, title, body):
301+
return "https://example/pr/9"
302+
303+
async def _judge(feature, base, worktrees):
304+
raise AssertionError("the judge must not run on a single-candidate carried-forward fix")
305+
306+
loop, store = await _drive_with(
307+
monkeypatch,
308+
open_pr=_open_pr,
309+
judge=_judge,
310+
cfg={"coder": "proto", "max_mode_n": 3},
311+
seed=lambda lp: lp._ci_feedback.__setitem__("bd-1", "CI failed: lint"),
312+
)
313+
assert store.creates == ["bd-1"] # one canonical worktree, NOT N suffixed candidates
314+
assert store.promotes == [] and store.reaps == []
315+
assert ("open_review", "bd-1", "https://example/pr/9") in store.calls
316+
317+
207318
async def test_drive_local_gate_failure_redispatches_then_opens(monkeypatch):
208319
"""A pre-PR gate failure re-dispatches the SAME tier with the output injected,
209320
REUSING the worktree (one create), then opens the PR once the gate passes."""

tests/test_worktree.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,33 @@ async def test_create_worktree_falls_back_to_local_base_without_a_remote(monkeyp
159159
assert add[-1] == "main" # fell back to the local branch
160160

161161

162+
# ── promote_worktree: the Max-Mode winner takes the canonical name (#21) ─────────
163+
164+
165+
async def test_promote_worktree_moves_dir_and_renames_branch(monkeypatch):
166+
git = FakeGit()
167+
monkeypatch.setattr(worktree, "_git", git)
168+
canon_wt, canon_branch = await worktree.promote_worktree(
169+
"/repo", "/repo/.worktrees/feat-bd-1.c2", "feat/bd-1.c2", "bd-1", root=".worktrees"
170+
)
171+
assert canon_branch == "feat/bd-1"
172+
assert canon_wt.endswith("/.worktrees/feat-bd-1")
173+
move = next(a for a in git.calls if a[:2] == ("worktree", "move"))
174+
assert move[2].endswith("/.worktrees/feat-bd-1.c2") and move[3].endswith("/.worktrees/feat-bd-1")
175+
rename = next(a for a in git.calls if a[:1] == ("branch",) and "-m" in a)
176+
assert rename == ("branch", "-m", "feat/bd-1.c2", "feat/bd-1")
177+
178+
179+
async def test_promote_worktree_is_a_noop_when_already_canonical(monkeypatch):
180+
git = FakeGit()
181+
monkeypatch.setattr(worktree, "_git", git)
182+
_wt, branch = await worktree.promote_worktree(
183+
"/repo", "/repo/.worktrees/feat-bd-1", "feat/bd-1", "bd-1", root=".worktrees"
184+
)
185+
assert branch == "feat/bd-1"
186+
assert not git.ran("move") and not [a for a in git.calls if a[:1] == ("worktree",)]
187+
188+
162189
# ── pr_is_merged: the merge-poll probe ──────────────────────────────────────────
163190

164191

worktree.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,38 @@ async def reap_feature_worktree(repo: str, worktrees_root: str, fid: str) -> Non
125125
await remove_worktree(repo, wt, f"feat/{fid}")
126126

127127

128+
async def promote_worktree(
129+
repo: str, src_wt: str, src_branch: str, fid: str, root: str = ".worktrees"
130+
) -> tuple[str, str]:
131+
"""Promote a Max-Mode candidate worktree to the canonical ``feat-<id>`` /
132+
``feat/<id>`` name (#21). The N candidates build in throwaway ``feat-<id>.c<k>``
133+
worktrees; the winner has to take over the canonical name so the rest of the
134+
lifecycle — the CI-fail bounce, crash recovery (``pr_url_for_branch(feat/<id>)``),
135+
and reaping (``reap_feature_worktree(<id>)``) — all of which key off the canonical
136+
names — works unchanged.
137+
138+
Moves the worktree dir and renames its branch IN PLACE, so the coder's still-
139+
uncommitted changes ride along (verified: ``git worktree move`` + ``branch -m``
140+
preserve the dirty tree). Idempotently clears a stale canonical worktree/branch
141+
first so ``move`` has a free destination. A winner already at the canonical path is
142+
a no-op. Returns (canonical_path, canonical_branch)."""
143+
canon_branch = f"feat/{fid}"
144+
canon_rel = os.path.join(root, f"feat-{fid}")
145+
canon_path = os.path.join(repo, canon_rel)
146+
if os.path.abspath(src_wt) == os.path.abspath(canon_path):
147+
return os.path.abspath(canon_path), canon_branch
148+
# Free the destination: drop any stale canonical worktree/branch leftover.
149+
await _git(repo, "worktree", "remove", "--force", canon_rel)
150+
await _git(repo, "branch", "-D", canon_branch)
151+
rc, _o, err = await _git(repo, "worktree", "move", os.path.abspath(src_wt), os.path.abspath(canon_path))
152+
if rc != 0:
153+
raise WorktreeError(f"worktree move failed: {err.strip()[:200]}")
154+
rc, _o, err = await _git(canon_path, "branch", "-m", src_branch, canon_branch)
155+
if rc != 0:
156+
raise WorktreeError(f"branch rename failed: {err.strip()[:200]}")
157+
return os.path.abspath(canon_path), canon_branch
158+
159+
128160
def list_feature_worktrees(repo: str, worktrees_root: str) -> list[str]:
129161
"""The feature ids that currently have a ``feat-<id>`` worktree dir under
130162
``<repo>/<worktrees_root>`` — for the health sweep's orphan check. Sync (a quick

0 commit comments

Comments
 (0)