diff --git a/coder_seam.py b/coder_seam.py index 88ac953..2f6bd57 100644 --- a/coder_seam.py +++ b/coder_seam.py @@ -338,6 +338,13 @@ def __init__( # step; the slow part (the coder dispatch) still runs in parallel. self._wt_lock = asyncio.Lock() self._n = 0 + # worktree_path -> the coder's own final reply (its clean PR summary, per + # `loop._build_prompt`'s "your FINAL message becomes the PR description" + # contract) — captured so `dispatch()` can use the WINNING candidate's real + # summary as the PR body instead of an internal rung/gens diagnostic string. + # Also what `_verify_goal`'s NO_TEST_NEEDED escape hatch reads. Fusion has no + # such reply (a plain completion, not a summary) — absent for fusion wins. + self._replies: dict[str, str] = {} async def _new_candidate_worktree(self) -> tuple[str, str]: self._n += 1 @@ -349,7 +356,11 @@ async def _new_candidate_worktree(self) -> tuple[str, str]: async def generate(self, task: str, *, feedback: str | None = None) -> str: wt, _branch = await self._new_candidate_worktree() - await worktree.dispatch_coder(self.coder, wt, _augment_prompt(task, feedback), timeout=self.dispatch_timeout) + reply = await worktree.dispatch_coder( + self.coder, wt, _augment_prompt(task, feedback), timeout=self.dispatch_timeout + ) + if (reply or "").strip(): + self._replies[wt] = reply return wt async def generate_fusion(self, task: str, *, feedback: str | None = None) -> str: @@ -510,7 +521,10 @@ async def dispatch( Returns ``(worktree, branch, result_text)`` on a passing candidate — the SAME 3-tuple shape ``_dispatch_max_mode`` returns, so the caller's downstream drive - (fixups, local gate, ``open_pr``) is unchanged. Raises :class:`SolveExhausted` + (fixups, local gate, ``open_pr``) is unchanged. ``result_text`` is the WINNING + candidate's own reply (its clean PR summary) when the ladder reached it via an + ACP rung; only a fusion win (no natural-language reply, just file content) falls + back to an internal rung/gens diagnostic string. Raises :class:`SolveExhausted` (a capability failure) when the budget is spent with no passing candidate, after reaping every candidate worktree it created. @@ -628,7 +642,14 @@ async def dispatch( result.gens_spent, budget, ) - result_text = f"[coder.solve rung={result.rung} gens={result.gens_spent}] {result.note}" + # The winning candidate's own reply (its clean PR summary, per the "your FINAL + # message becomes the PR description" contract every coder dispatch is given) is + # the real result — `loop.py` uses this verbatim as the PR body, and `_verify_goal` + # reads it for the NO_TEST_NEEDED escape hatch. Only fusion (a plain completion, + # no such reply) or an unexpectedly-empty one falls back to the diagnostic string. + result_text = ( + adapter._replies.get(win_wt) or f"[coder.solve rung={result.rung} gens={result.gens_spent}] {result.note}" + ) return canon_wt, canon_branch, result_text diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index 1e17280..ddffd73 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -1,6 +1,6 @@ id: project_board name: Project Board (coding orchestration) -version: 0.29.1 +version: 0.29.2 description: >- A board-driven coding-orchestration plugin: a lean 6-state board (backlog → ready → in_progress → in_review → done, + a blocked flag) backed by **beads** (`br`), an diff --git a/pyproject.toml b/pyproject.toml index 20bd2c6..e335396 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "project-board" -version = "0.29.1" +version = "0.29.2" description = "Board-driven coding-orchestration plugin for protoAgent (beads board + ACP spawn loop + planning layer + console view)." requires-python = ">=3.11" diff --git a/tests/test_coder_seam.py b/tests/test_coder_seam.py index b0a703c..6fa4ca7 100644 --- a/tests/test_coder_seam.py +++ b/tests/test_coder_seam.py @@ -143,10 +143,58 @@ async def _fake_solve(task, *, generate, verify, budget, k, tree_depth, fusion_g assert promoted == [("/wt/feat-bd-1.g2", "feat/bd-1.g2", "bd-1")] assert removed == ["/wt/feat-bd-1.g1"] # only the loser reaped assert (wt, branch) == ("/wt/feat-bd-1", "feat/bd-1") # canonical name - assert "best-of-k" in result and "gens=2" in result + # The winning candidate's OWN reply is the result — not an internal rung/gens + # diagnostic string. loop.py uses this verbatim as the PR body; _verify_goal + # reads it for the NO_TEST_NEEDED escape hatch. Losing candidate g1's reply + # must NOT leak through — only g2 (the winner) is used. + assert result == "reply from /wt/feat-bd-1.g2" assert gens == [2] # cost surfaced exactly once +async def test_dispatch_falls_back_to_a_diagnostic_string_when_the_winner_has_no_reply(monkeypatch, tmp_path): + """A fusion win (a plain completion, not a summary) — or any candidate whose + reply somehow never got captured — has nothing human-authored to report, so + dispatch() falls back to the rung/gens diagnostic string rather than an empty + PR body.""" + _, removed, promoted = _stub_worktree(monkeypatch) + + async def _create_in_tmp(repo, base, cid, root): + d = tmp_path / cid + d.mkdir(parents=True, exist_ok=True) + return (str(d), f"feat/{cid}") + + monkeypatch.setattr(worktree, "create_worktree", _create_in_tmp) + + async def _fake_openai_dispatch(delegate, prompt, *, timeout=None): + return "### x.py\n```\nhi\n```" + + async def _fake_solve(task, *, generate, verify, budget, k, tree_depth, fusion_generate=None, fusion_k=2): + c0 = await fusion_generate(task, feedback=None) # the REAL adapter.generate_fusion + return _FakeResult(solution=c0, passed=True, rung="fusion", gens_spent=1, candidates_tried=1, note="solved") + + _wt, _branch, result = await dispatch( + task="do the thing", + coder=object(), + repo="/repo", + base="main", + root=".worktrees", + fid="bd-2", + dispatch_timeout=None, + test_cmd="pytest -q", + test_timeout=30, + budget=6, + k=3, + tree_depth=2, + fusion_delegate=object(), + record_gens=lambda n: None, + _solve=_fake_solve, + _budget_cls=_FakeBudget, + _verdict_cls=_FakeVerdict, + _fusion_dispatch=_fake_openai_dispatch, + ) + assert result == "[coder.solve rung=fusion gens=1] solved" + + async def test_dispatch_records_gens_even_on_a_single_greedy_win(monkeypatch): _stub_worktree(monkeypatch)