11"""The P2 board seam (ADR 0064): dispatch a feature's build through the `coder`
22plugin's execution-grounded ``solve()`` ladder instead of a single
3- ``delegate_to(acp)`` shot — greedy → best-of-k → tree-search, gated on the
4- feature's acceptance tests actually PASSING in a real worktree, never an LLM judge.
3+ ``delegate_to(acp)`` shot — greedy → best-of-k → tree-search → fusion, gated on
4+ the feature's acceptance tests actually PASSING in a real worktree, never an LLM
5+ judge.
56
67**Composes** `plugins.coder.solve` (a separate, git-URL-installed plugin — imported
78lazily/best-effort so this repo carries no hard dependency on it and no import-time
2829the acceptance criteria as part of its definition of done; this module's ``verify``
2930just RUNS whatever tests exist in a candidate's worktree via the configured command
3031and gates on its exit code — real execution, no fabricated grounding.
31- """
32+
33+ **Rung 4 — fusion (ADR 0064 P3).** Fusion (e.g. ``protolabs/fusion``) is a strong
34+ *generator* but, per the ADR, it **can't tool-call** — unlike the ``acp`` coder
35+ (a real edit/verify session in the worktree), it can only return a plain chat
36+ completion. So its candidate generation is a DIFFERENT shape from the ACP rungs:
37+ ``_fusion_prompt`` hands it the task + the CURRENT content of the feature's
38+ declared ``files_to_modify`` (read from the base repo — fusion has no tool access
39+ to look these up itself) and asks for the complete, final content of every file it
40+ creates or changes; ``_parse_fusion_files`` extracts ``{path: content}`` from the
41+ reply; ``_WorktreeSolveAdapter.generate_fusion`` writes those files into a fresh
42+ worktree (the same throwaway-per-candidate discipline as the ACP rungs) and hands
43+ the path to the SAME ``verify()`` — real acceptance tests, same oracle, no separate
44+ judge. Wholesale file replacement (not a unified diff) is deliberate: an LLM
45+ completion reliably reproduces a full file; a hand-rolled patch with drifted
46+ context lines is a common failure mode `git apply` doesn't forgive. Only reached
47+ when a ``fusion_delegate`` is configured (an ``openai``-type Delegate, already
48+ resolved by the caller) — absent that, ``solve()`` gets ``fusion_generate=None``
49+ and stops at tree-search exactly as before (honest degrade, unchanged)."""
3250
3351from __future__ import annotations
3452
3553import asyncio
3654import importlib
3755import logging
56+ import re
57+ from pathlib import Path
3858from typing import Callable
3959
4060from . import worktree
4161
4262log = logging .getLogger ("protoagent.plugins.project_board" )
4363
64+ # ``### path/to/file.py`` header, then a fenced block (any/no language hint) holding
65+ # that file's COMPLETE new content. Deliberately simple/strict: a fusion completion
66+ # that doesn't follow the format parses to no files, which just fails verify() like
67+ # any other empty candidate — never a silent partial/mangled write.
68+ _FUSION_FILE_RE = re .compile (r"^###\s+(\S.+?)\s*$\n```[^\n]*\n(.*?)```" , re .MULTILINE | re .DOTALL )
69+
70+ _FUSION_READ_MAX_CHARS = 20_000 # per-file context cap — fusion sees enough to edit, not a dump
71+
72+
73+ def _parse_fusion_files (reply : str ) -> dict [str , str ]:
74+ """Extract ``{relative path: full file content}`` from a fusion completion. No
75+ match ⇒ empty dict — the caller writes nothing, and the untouched worktree just
76+ fails ``verify()`` like any other candidate that didn't address the task."""
77+ return {path .strip (): content for path , content in _FUSION_FILE_RE .findall (reply or "" )}
78+
79+
80+ def _fusion_prompt (task : str , * , feedback : str | None , repo : str , files_to_modify : list [str ]) -> str :
81+ """Build fusion's prompt. Fusion can't read the repo itself (no tool-calling), so
82+ this hands it the CURRENT content of every file the feature declares — read from
83+ the base repo, best-effort (a listed-but-not-yet-created file is noted as new)."""
84+ file_blocks = []
85+ for rel in files_to_modify :
86+ p = Path (repo ) / rel
87+ try :
88+ text = p .read_text (errors = "replace" )[:_FUSION_READ_MAX_CHARS ]
89+ file_blocks .append (f"### { rel } (current content)\n ```\n { text } \n ```" )
90+ except OSError :
91+ file_blocks .append (f"### { rel } (does not exist yet — you are creating it)" )
92+ files_section = (
93+ "\n \n " .join (file_blocks ) if file_blocks else "(no existing files listed — create what the task needs)"
94+ )
95+ parts = [
96+ "Implement the task below. You have NO tool access — you cannot read or run "
97+ "anything else, so work only from what's given here." ,
98+ "" ,
99+ "## Task" ,
100+ task .strip (),
101+ "" ,
102+ "## Current file contents" ,
103+ files_section ,
104+ "" ,
105+ "## Your reply format — REQUIRED, exactly this shape per file" ,
106+ "For every file you create or change, return its COMPLETE, FINAL content "
107+ "(never a partial diff or `...` elisions) as:" ,
108+ "### relative/path/to/file.py" ,
109+ "```" ,
110+ "<the file's entire new content>" ,
111+ "```" ,
112+ "Only include files you're actually creating or changing. No prose outside the file blocks." ,
113+ ]
114+ if feedback :
115+ parts += [
116+ "" ,
117+ "## Your previous attempt FAILED the acceptance tests — fix exactly this" ,
118+ feedback .strip (),
119+ ]
120+ return "\n " .join (parts )
121+
44122
45123class SolveExhausted (worktree .WorktreeError ):
46124 """``coder.solve()`` spent its whole generation budget against this feature's
@@ -118,6 +196,9 @@ def __init__(
118196 test_cmd : str ,
119197 test_timeout : float ,
120198 verdict_cls ,
199+ fusion_delegate = None ,
200+ files_to_modify : list [str ] | None = None ,
201+ _fusion_dispatch = None ,
121202 ):
122203 self .repo = repo
123204 self .base = base
@@ -128,22 +209,70 @@ def __init__(
128209 self .test_cmd = test_cmd
129210 self .test_timeout = test_timeout
130211 self .verdict_cls = verdict_cls # `plugins.coder.solve.Verdict` — passed in, never imported here
212+ self .fusion_delegate = fusion_delegate # a resolved `openai`-type Delegate, or None
213+ self .files_to_modify = files_to_modify or []
214+ # Test-injection seam (mirrors `_solve`/`_budget_cls`/`_verdict_cls` on
215+ # `dispatch()`): production never passes this — the real lazy
216+ # `ADAPTERS["openai"].dispatch` import happens in `generate_fusion` below.
217+ self ._fusion_dispatch = _fusion_dispatch
131218 self .candidates : list [tuple [str , str ]] = [] # (worktree_path, branch)
132219 # `git worktree add` against the SAME repo must not run concurrently (best-
133220 # of-k dispatches `generate()` via asyncio.gather) — serialize just that
134221 # step; the slow part (the coder dispatch) still runs in parallel.
135222 self ._wt_lock = asyncio .Lock ()
136223 self ._n = 0
137224
138- async def generate (self , task : str , * , feedback : str | None = None ) -> str :
225+ async def _new_candidate_worktree (self ) -> tuple [ str , str ] :
139226 self ._n += 1
140227 cid = f"{ self .fid } .g{ self ._n } "
141228 async with self ._wt_lock :
142229 wt , branch = await worktree .create_worktree (self .repo , self .base , cid , self .root )
143230 self .candidates .append ((wt , branch ))
231+ return wt , branch
232+
233+ async def generate (self , task : str , * , feedback : str | None = None ) -> str :
234+ wt , _branch = await self ._new_candidate_worktree ()
144235 await worktree .dispatch_coder (self .coder , wt , _augment_prompt (task , feedback ), timeout = self .dispatch_timeout )
145236 return wt
146237
238+ async def generate_fusion (self , task : str , * , feedback : str | None = None ) -> str :
239+ """Rung 4 (ADR 0064 P3): fusion can't tool-call, so instead of dispatching an
240+ ACP session into the worktree, get a plain completion and write its files into
241+ one. Same candidate bookkeeping (``candidates``/``_wt_lock``) as ``generate``,
242+ so promote/reap treats a fusion winner identically to an ACP one."""
243+ if self ._fusion_dispatch is not None :
244+ openai_dispatch = self ._fusion_dispatch
245+ else :
246+ from plugins .delegates .adapters import ADAPTERS
247+
248+ openai_dispatch = ADAPTERS ["openai" ].dispatch
249+
250+ prompt = _fusion_prompt (task , feedback = feedback , repo = self .repo , files_to_modify = self .files_to_modify )
251+ reply = await openai_dispatch (self .fusion_delegate , prompt , timeout = self .dispatch_timeout )
252+ files = _parse_fusion_files (reply )
253+ wt , _branch = await self ._new_candidate_worktree ()
254+ wt_root = Path (wt ).resolve ()
255+ written = 0
256+ for rel , content in files .items ():
257+ # `rel` comes from a model completion — an absolute path or a `../` climb
258+ # would otherwise write outside the worktree (Path.__truediv__ with an
259+ # absolute right-hand side even discards the left side entirely). Resolve
260+ # and require containment; skip (don't crash the candidate) on a miss.
261+ dest = (wt_root / rel ).resolve ()
262+ if wt_root not in dest .parents and dest != wt_root :
263+ log .warning (
264+ "[project_board] %s fusion tried to write outside its worktree: %r — skipped" , self .fid , rel
265+ )
266+ continue
267+ dest .parent .mkdir (parents = True , exist_ok = True )
268+ dest .write_text (content )
269+ written += 1
270+ if not written :
271+ log .warning (
272+ "[project_board] %s fusion reply parsed to 0 writable files — candidate is unchanged base" , self .fid
273+ )
274+ return wt
275+
147276 async def verify (self , candidate_wt : str ):
148277 Verdict = self .verdict_cls
149278 try :
@@ -215,9 +344,13 @@ async def dispatch(
215344 k : int ,
216345 tree_depth : int ,
217346 record_gens : RecordGens | None = None ,
347+ fusion_delegate = None ,
348+ fusion_k : int = 2 ,
349+ files_to_modify : list [str ] | None = None ,
218350 _solve = None ,
219351 _budget_cls = None ,
220352 _verdict_cls = None ,
353+ _fusion_dispatch = None ,
221354) -> tuple [str , str , str ]:
222355 """Run the execution-grounded ladder for one feature build.
223356
@@ -234,6 +367,14 @@ async def dispatch(
234367 import happens here, deferred so this module carries no hard dependency on the
235368 `coder` plugin).
236369
370+ ``fusion_delegate`` (a resolved ``openai``-type Delegate, or ``None``) gates rung
371+ 4 (ADR 0064 P3) — the caller resolves it (mirroring how ``coder`` itself is
372+ resolved), so this module never does delegate lookup. ``None`` (unconfigured) ⇒
373+ ``solve()`` gets ``fusion_generate=None`` and stops at tree-search, unchanged from
374+ before this rung existed. ``files_to_modify`` feeds fusion's prompt (it can't read
375+ the repo itself, unlike the ACP rungs) — the same list the feature's Ready gate
376+ already required.
377+
237378 **``solve()`` itself can raise.** The ladder (`coder`'s own ``solve.py``) has no
238379 try/except around ``generate``/``verify`` — it assumes a candidate attempt never
239380 errors, only that it might fail its tests. A REAL dispatch can still raise
@@ -263,6 +404,9 @@ async def dispatch(
263404 test_cmd = test_cmd ,
264405 test_timeout = test_timeout ,
265406 verdict_cls = Verdict ,
407+ fusion_delegate = fusion_delegate ,
408+ files_to_modify = files_to_modify ,
409+ _fusion_dispatch = _fusion_dispatch ,
266410 )
267411 try :
268412 result = await solve (
@@ -272,6 +416,8 @@ async def dispatch(
272416 budget = Budget (budget ),
273417 k = k ,
274418 tree_depth = tree_depth ,
419+ fusion_generate = adapter .generate_fusion if fusion_delegate is not None else None ,
420+ fusion_k = fusion_k ,
275421 )
276422 except Exception as exc :
277423 for wt , branch in adapter .candidates :
0 commit comments