From 7983ef7318f877f86caec0fd98e1879065aa364e Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Fri, 3 Jul 2026 16:10:46 -0700 Subject: [PATCH] fix: gate fusion on file size, refuse a shrunk rewrite, restrict writes to declared files (v0.29.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fusion can't tool-call and returns whole-file "complete" replacements, not diffs — a large file (or one that just crosses the delegate's own max_tokens ceiling on output) risks a silently truncated rewrite with no signal that anything went wrong. fusion_viable_for_files() gates a feature's declared files_to_modify by real on-disk size BEFORE fusion is ever dispatched (loop.py and api.py's test-rung route both check it and skip straight to "no fusion this dispatch" on a miss, same as an unconfigured delegate). As defensive backstops in generate_fusion itself: refuse a returned file that's suspiciously smaller than the original (more likely truncated than an honest edit), and refuse writing any path outside the feature's declared set (fusion has no tool access, so an undeclared path is a hallucination, not a real change). Co-Authored-By: Claude Sonnet 5 --- api.py | 20 +++++ coder_seam.py | 143 ++++++++++++++++++++++++++++++++-- loop.py | 30 ++++++- protoagent.plugin.yaml | 2 +- pyproject.toml | 2 +- tests/test_api.py | 30 +++++++ tests/test_coder_seam.py | 164 +++++++++++++++++++++++++++++++++++++++ tests/test_loop.py | 37 +++++++++ 8 files changed, 419 insertions(+), 9 deletions(-) diff --git a/api.py b/api.py index dbc8f03..d930ee6 100644 --- a/api.py +++ b/api.py @@ -282,6 +282,9 @@ async def _test_rung(fid: str, body: dict = Body(...)): if coder is None: raise HTTPException(400, f"acp delegate {coder_name!r} not found — check `delegates:`") + fusion_max_file_chars = max( + 1, int((cfg or {}).get("coder_solve_fusion_max_file_chars", coder_seam.FUSION_MAX_FILE_CHARS_DEFAULT)) + ) fusion_delegate = None if rung == "fusion": fusion_name = str((cfg or {}).get("coder_solve_fusion_delegate") or "").strip() @@ -290,6 +293,22 @@ async def _test_rung(fid: str, body: dict = Body(...)): fusion_delegate = coder_seam.resolve_delegate(fusion_name, "openai") if fusion_delegate is None: raise HTTPException(400, f"openai delegate {fusion_name!r} not found — check `delegates:`") + # Same gate `_drive` applies before a real dispatch — fusion can't + # tool-call and returns whole-file replacements, so this diagnostic + # must refuse the same oversized files a real build would skip. + viable, reason = coder_seam.fusion_viable_for_files( + (cfg or {}).get("repo", "."), + f.get("files_to_modify") or [], + max_file_chars=fusion_max_file_chars, + max_total_chars=max( + 1, + int( + (cfg or {}).get("coder_solve_fusion_max_total_chars", coder_seam.FUSION_MAX_TOTAL_CHARS_DEFAULT) + ), + ), + ) + if not viable: + raise HTTPException(400, f"rung='fusion' not viable for this feature's files: {reason}") task = ( f"# {f.get('title', '')}\n\n" @@ -317,6 +336,7 @@ async def _test_rung(fid: str, body: dict = Body(...)): fusion_delegate=fusion_delegate, fusion_k=max(1, int((cfg or {}).get("coder_solve_fusion_k", 2))), files_to_modify=f.get("files_to_modify") or [], + fusion_max_file_chars=fusion_max_file_chars, ) except Exception as exc: # noqa: BLE001 — surface as a 400, not a raw 500 raise HTTPException(400, f"test-rung failed: {exc}") from exc diff --git a/coder_seam.py b/coder_seam.py index f4199c8..88ac953 100644 --- a/coder_seam.py +++ b/coder_seam.py @@ -48,6 +48,24 @@ resolved by the caller) — absent that, ``solve()`` gets ``fusion_generate=None`` and stops at tree-search exactly as before (honest degrade, unchanged). +**Fusion + large files — honest-degrade, not silent truncation.** Whole-file +replacement only works when a real completion can (a) see the WHOLE current file +and (b) reproduce the WHOLE new one — and the tighter constraint is usually the +OUTPUT side: a delegate's own ``max_tokens`` (often ~1024 by default, ~4K chars) +can truncate the response well before a merely-medium file's size, and +``OpenAiAdapter.dispatch`` doesn't surface ``finish_reason`` to tell a caller that +happened. So this module never attempts a full-file rewrite it can't stand behind: +``fusion_viable_for_files`` gates on the feature's ACTUAL on-disk file sizes +(per-file and combined) BEFORE fusion is ever dispatched — callers (``loop.py``, +``api.py``) check it and treat "not viable" exactly like "no fusion_delegate +configured" (``fusion_delegate=None`` for that dispatch). As a defensive backstop +(in case a caller skips the gate — direct ``test_rung`` callers, say) +``generate_fusion`` ALSO refuses to write a candidate file back over a +significantly larger original — a shrunk "complete" rewrite is far more likely a +truncated one than an honest tiny file than a real edit, so that file is left +unwritten (verify() then judges the candidate on what's actually there) rather +than risking data loss. + **``test_rung`` (operator-only diagnostic).** Verifying a specific rung — fusion especially, only otherwise reached after three cheaper rungs fail — shouldn't require contriving a task hard enough to fail its way there. ``test_rung`` runs @@ -93,7 +111,55 @@ def resolve_delegate(name: str, expect_type: str): # any other empty candidate — never a silent partial/mangled write. _FUSION_FILE_RE = re.compile(r"^###\s+(\S.+?)\s*$\n```[^\n]*\n(.*?)```", re.MULTILINE | re.DOTALL) -_FUSION_READ_MAX_CHARS = 20_000 # per-file context cap — fusion sees enough to edit, not a dump +# Defaults for `fusion_viable_for_files` — deliberately conservative. The binding +# constraint is usually the OUTPUT side (a delegate's own `max_tokens`, often +# ~1024 ⇒ ~4K chars, silently truncating a reply the adapter doesn't even expose +# `finish_reason` for), not this repo's own read logic — these caps exist so +# fusion is refused for a feature's files BEFORE a doomed rewrite is attempted, +# not tuned to "how much can Python read." Configurable per-board — see loop.py's +# `coder_solve_fusion_max_file_chars` / `_max_total_chars`. +FUSION_MAX_FILE_CHARS_DEFAULT = 8_000 +FUSION_MAX_TOTAL_CHARS_DEFAULT = 16_000 + +# Defense-in-depth for `generate_fusion`'s write guard: a returned file under this +# fraction of the ORIGINAL file's size is treated as a likely-truncated rewrite, +# not a legitimately smaller edit, and is refused. Only applies above a minimum +# original size (`_SHRINK_GUARD_MIN_ORIGINAL_CHARS`) — a real small edit to a +# small file (e.g. a 40-char file trimmed to 10) shouldn't trip a "big shrink" +# heuristic meant to catch multi-KB truncation. +_SHRINK_GUARD_RATIO = 0.5 +_SHRINK_GUARD_MIN_ORIGINAL_CHARS = 500 + + +def fusion_viable_for_files( + repo: str, + files_to_modify: list[str], + *, + max_file_chars: int = FUSION_MAX_FILE_CHARS_DEFAULT, + max_total_chars: int = FUSION_MAX_TOTAL_CHARS_DEFAULT, +) -> tuple[bool, str]: + """Gate fusion on the feature's files BEFORE ever dispatching to it — whole-file + replacement only works when a real completion can see the whole current file + and reproduce the whole new one. Checks actual on-disk size (``os.path.getsize``, + never reads the file into memory just to measure it). Returns ``(True, "")`` when + every file is small enough (or doesn't exist yet — nothing to be too large), + else ``(False, reason)``. Callers treat ``False`` exactly like "no + fusion_delegate configured": honest degrade, not a silent truncated attempt.""" + import os + + total = 0 + for rel in files_to_modify: + p = Path(repo) / rel + try: + size = os.path.getsize(p) + except OSError: + continue # doesn't exist yet — nothing to be too large + if size > max_file_chars: + return False, f"{rel} is {size} chars, over the {max_file_chars}-char per-file cap for a full rewrite" + total += size + if total > max_total_chars: + return False, f"files_to_modify total {total} chars, over the {max_total_chars}-char combined cap" + return True, "" def _parse_fusion_files(reply: str) -> dict[str, str]: @@ -103,18 +169,41 @@ def _parse_fusion_files(reply: str) -> dict[str, str]: return {path.strip(): content for path, content in _FUSION_FILE_RE.findall(reply or "")} -def _fusion_prompt(task: str, *, feedback: str | None, repo: str, files_to_modify: list[str]) -> str: +def _fusion_prompt( + task: str, + *, + feedback: str | None, + repo: str, + files_to_modify: list[str], + max_file_chars: int = FUSION_MAX_FILE_CHARS_DEFAULT, +) -> str: """Build fusion's prompt. Fusion can't read the repo itself (no tool-calling), so this hands it the CURRENT content of every file the feature declares — read from - the base repo, best-effort (a listed-but-not-yet-created file is noted as new).""" + the base repo, best-effort (a listed-but-not-yet-created file is noted as new). + + Callers are expected to have already checked ``fusion_viable_for_files`` (the + real gate) so a genuinely oversized file should never reach here — this + truncation is a DEFENSIVE backstop only (e.g. a direct ``test_rung`` call that + skipped the gate), and unlike the gate it's never silent: a truncated file is + marked as such so fusion knows not to claim a full-file replacement for it.""" file_blocks = [] for rel in files_to_modify: p = Path(repo) / rel try: - text = p.read_text(errors="replace")[:_FUSION_READ_MAX_CHARS] - file_blocks.append(f"### {rel} (current content)\n```\n{text}\n```") + raw = p.read_text(errors="replace") except OSError: file_blocks.append(f"### {rel} (does not exist yet — you are creating it)") + continue + if len(raw) > max_file_chars: + text = raw[:max_file_chars] + file_blocks.append( + f"### {rel} (current content — TRUNCATED at {max_file_chars} chars, " + f"real file is {len(raw)} chars — do NOT return this as a complete " + "replacement; skip this file instead)\n```\n" + f"{text}\n```" + ) + else: + file_blocks.append(f"### {rel} (current content)\n```\n{raw}\n```") files_section = ( "\n\n".join(file_blocks) if file_blocks else "(no existing files listed — create what the task needs)" ) @@ -224,6 +313,7 @@ def __init__( verdict_cls, fusion_delegate=None, files_to_modify: list[str] | None = None, + fusion_max_file_chars: int = FUSION_MAX_FILE_CHARS_DEFAULT, _fusion_dispatch=None, ): self.repo = repo @@ -237,6 +327,7 @@ def __init__( self.verdict_cls = verdict_cls # `plugins.coder.solve.Verdict` — passed in, never imported here self.fusion_delegate = fusion_delegate # a resolved `openai`-type Delegate, or None self.files_to_modify = files_to_modify or [] + self.fusion_max_file_chars = fusion_max_file_chars # Test-injection seam (mirrors `_solve`/`_budget_cls`/`_verdict_cls` on # `dispatch()`): production never passes this — the real lazy # `ADAPTERS["openai"].dispatch` import happens in `generate_fusion` below. @@ -273,7 +364,13 @@ async def generate_fusion(self, task: str, *, feedback: str | None = None) -> st openai_dispatch = ADAPTERS["openai"].dispatch - prompt = _fusion_prompt(task, feedback=feedback, repo=self.repo, files_to_modify=self.files_to_modify) + prompt = _fusion_prompt( + task, + feedback=feedback, + repo=self.repo, + files_to_modify=self.files_to_modify, + max_file_chars=self.fusion_max_file_chars, + ) reply = await openai_dispatch(self.fusion_delegate, prompt, timeout=self.dispatch_timeout) files = _parse_fusion_files(reply) wt, _branch = await self._new_candidate_worktree() @@ -290,6 +387,36 @@ async def generate_fusion(self, task: str, *, feedback: str | None = None) -> st "[project_board] %s fusion tried to write outside its worktree: %r — skipped", self.fid, rel ) continue + # Fusion has no tool access — it can only ever act on the files we showed + # it. A path outside the feature's declared set means it hallucinated a + # file (or the parser mis-split the reply); writing it would silently + # touch unrelated code with no test coverage backing the change. + if self.files_to_modify and rel not in self.files_to_modify: + log.warning("[project_board] %s fusion tried to write an undeclared path: %r — skipped", self.fid, rel) + continue + # Fusion returns whole-file replacements with no diff to sanity-check. + # A reply that's drastically smaller than the file it claims to replace + # is far more likely a truncated completion (see FUSION_MAX_FILE_CHARS_DEFAULT + # and the delegate's own max_tokens ceiling) than an intentional big + # deletion — refuse it rather than risk silent data loss. + if dest.exists(): + try: + original_size = dest.stat().st_size + except OSError: + original_size = 0 + if ( + original_size > _SHRINK_GUARD_MIN_ORIGINAL_CHARS + and len(content) < original_size * _SHRINK_GUARD_RATIO + ): + log.warning( + "[project_board] %s fusion's rewrite of %r (%d chars) is suspiciously smaller than " + "the original (%d chars) — refusing, likely truncated", + self.fid, + rel, + len(content), + original_size, + ) + continue dest.parent.mkdir(parents=True, exist_ok=True) dest.write_text(content) written += 1 @@ -373,6 +500,7 @@ async def dispatch( fusion_delegate=None, fusion_k: int = 2, files_to_modify: list[str] | None = None, + fusion_max_file_chars: int = FUSION_MAX_FILE_CHARS_DEFAULT, _solve=None, _budget_cls=None, _verdict_cls=None, @@ -432,6 +560,7 @@ async def dispatch( verdict_cls=Verdict, fusion_delegate=fusion_delegate, files_to_modify=files_to_modify, + fusion_max_file_chars=fusion_max_file_chars, _fusion_dispatch=_fusion_dispatch, ) try: @@ -521,6 +650,7 @@ async def test_rung( fusion_delegate=None, fusion_k: int = 2, files_to_modify: list[str] | None = None, + fusion_max_file_chars: int = FUSION_MAX_FILE_CHARS_DEFAULT, _solve=None, _budget_cls=None, _verdict_cls=None, @@ -557,6 +687,7 @@ async def test_rung( verdict_cls=Verdict, fusion_delegate=fusion_delegate, files_to_modify=files_to_modify, + fusion_max_file_chars=fusion_max_file_chars, _fusion_dispatch=_fusion_dispatch, ) try: diff --git a/loop.py b/loop.py index 1656e35..e1be91c 100644 --- a/loop.py +++ b/loop.py @@ -242,6 +242,17 @@ def __init__(self, cfg: dict): # Blank ⇒ no fusion rung; the ladder stops at tree-search exactly as before. self.coder_solve_fusion_delegate = str(self.cfg.get("coder_solve_fusion_delegate", "")).strip() self.coder_solve_fusion_k = max(1, int(self.cfg.get("coder_solve_fusion_k", 2))) + # Fusion can't tool-call and returns whole-file replacements with no diff — + # a file over this cap risks a silent truncated "complete" rewrite (see + # coder_seam.fusion_viable_for_files). Gated BEFORE dispatch, not after: + # an oversized feature just skips the fusion rung (fusion_delegate=None + # for that dispatch), it never gets to attempt-and-corrupt. + self.coder_solve_fusion_max_file_chars = max( + 1, int(self.cfg.get("coder_solve_fusion_max_file_chars", coder_seam.FUSION_MAX_FILE_CHARS_DEFAULT)) + ) + self.coder_solve_fusion_max_total_chars = max( + 1, int(self.cfg.get("coder_solve_fusion_max_total_chars", coder_seam.FUSION_MAX_TOTAL_CHARS_DEFAULT)) + ) # KG lessons (the flywheel READ half): before dispatching a coder, query the # knowledge graph (via graph.sdk) for distilled lessons relevant to THIS feature # and inject them into the prompt — so the coder heeds this area's known failure @@ -681,11 +692,27 @@ async def _drive(self, feature: dict): self._inflight[fid] = (repo, wt, branch) result = await worktree.dispatch_coder(coder, wt, prompt, timeout=self.coder_timeout or None) elif self._use_coder_solve(feature) and not self._ci_feedback.get(fid): + files_to_modify = feature.get("files_to_modify") or [] fusion = ( self._resolve_delegate(self.coder_solve_fusion_delegate, "openai") if self.coder_solve_fusion_delegate else None ) + if fusion is not None: + # Gate BEFORE dispatch: fusion can't tool-call and returns + # whole-file replacements, so an oversized file risks a + # silent truncated rewrite (coder_seam.fusion_viable_for_files). + # Not viable ⇒ this dispatch just skips the fusion rung — the + # ladder still runs greedy/best-of-k/tree-search unchanged. + viable, reason = coder_seam.fusion_viable_for_files( + repo, + files_to_modify, + max_file_chars=self.coder_solve_fusion_max_file_chars, + max_total_chars=self.coder_solve_fusion_max_total_chars, + ) + if not viable: + log.info("[project_board] %s fusion rung skipped for this dispatch: %s", fid, reason) + fusion = None wt, branch, result = await coder_seam.dispatch( task=prompt, coder=coder, @@ -702,7 +729,8 @@ async def _drive(self, feature: dict): record_gens=lambda n: store.record_gens_spent(fid, n), fusion_delegate=fusion, fusion_k=self.coder_solve_fusion_k, - files_to_modify=feature.get("files_to_modify") or [], + files_to_modify=files_to_modify, + fusion_max_file_chars=self.coder_solve_fusion_max_file_chars, ) self._inflight[fid] = (repo, wt, branch) elif self.max_mode_n > 1 and not self._ci_feedback.get(fid): diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index 59e4793..1e17280 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -1,6 +1,6 @@ id: project_board name: Project Board (coding orchestration) -version: 0.29.0 +version: 0.29.1 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 c013993..20bd2c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "project-board" -version = "0.29.0" +version = "0.29.1" 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_api.py b/tests/test_api.py index 2d1c616..03adc54 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -386,6 +386,36 @@ def test_test_rung_fusion_400s_without_a_configured_fusion_delegate(monkeypatch) assert "coder_solve_fusion_delegate" in r.json()["detail"] +def test_test_rung_fusion_400s_when_files_are_oversized(monkeypatch, tmp_path): + """Same gate `_drive` applies before a real dispatch: fusion can't tool-call + and returns whole-file replacements, so an oversized declared file must be + refused here too, before ever reaching coder_seam.test_rung.""" + (tmp_path / "big.py").write_text("x" * 1000) + store = FakeStore() + monkeypatch.setattr(store, "get_feature", lambda fid: _feature_with_ac(fid, files=["big.py"])) + monkeypatch.setattr(coder_seam, "_import_solve", lambda: object()) + monkeypatch.setattr(coder_seam, "resolve_delegate", lambda name, t: object()) + + async def _boom(**kwargs): + raise AssertionError("coder_seam.test_rung must not be reached when fusion isn't viable") + + monkeypatch.setattr(coder_seam, "test_rung", _boom) + c = _client( + monkeypatch, + store, + cfg={ + "coder_solve_test_cmd": "pytest -q", + "coder_solve_fusion_delegate": "fusion-model", + "coder_solve_fusion_max_file_chars": 10, + "repo": str(tmp_path), + }, + ) + r = c.post("/api/plugins/project_board/features/bd-7/test-rung", json={"rung": "fusion"}) + assert r.status_code == 400 + assert "not viable" in r.json()["detail"] + assert "big.py" in r.json()["detail"] + + def test_test_rung_happy_path_calls_coder_seam_test_rung_and_returns_its_result(monkeypatch): store = FakeStore() monkeypatch.setattr(store, "get_feature", lambda fid: _feature_with_ac(fid)) diff --git a/tests/test_coder_seam.py b/tests/test_coder_seam.py index c9775c0..b0a703c 100644 --- a/tests/test_coder_seam.py +++ b/tests/test_coder_seam.py @@ -637,6 +637,43 @@ def test_parse_fusion_files_no_match_returns_empty(): assert coder_seam._parse_fusion_files("") == {} +def test_fusion_viable_for_files_true_when_under_both_caps(tmp_path): + (tmp_path / "a.py").write_text("x" * 100) + (tmp_path / "b.py").write_text("y" * 100) + ok, reason = coder_seam.fusion_viable_for_files( + str(tmp_path), ["a.py", "b.py"], max_file_chars=1_000, max_total_chars=1_000 + ) + assert ok is True + assert reason == "" + + +def test_fusion_viable_for_files_false_over_per_file_cap(tmp_path): + (tmp_path / "huge.py").write_text("x" * 500) + ok, reason = coder_seam.fusion_viable_for_files( + str(tmp_path), ["huge.py"], max_file_chars=100, max_total_chars=10_000 + ) + assert ok is False + assert "huge.py" in reason + assert "100-char per-file cap" in reason + + +def test_fusion_viable_for_files_false_over_combined_cap(tmp_path): + (tmp_path / "a.py").write_text("x" * 100) + (tmp_path / "b.py").write_text("y" * 100) + ok, reason = coder_seam.fusion_viable_for_files( + str(tmp_path), ["a.py", "b.py"], max_file_chars=1_000, max_total_chars=150 + ) + assert ok is False + assert "combined cap" in reason + + +def test_fusion_viable_for_files_skips_files_that_do_not_exist_yet(tmp_path): + # A feature creating a brand-new file has nothing on disk to be too large yet. + ok, reason = coder_seam.fusion_viable_for_files(str(tmp_path), ["not_yet_created.py"]) + assert ok is True + assert reason == "" + + def test_fusion_prompt_includes_task_and_existing_file_content(tmp_path): (tmp_path / "existing.py").write_text("def old(): pass\n") prompt = coder_seam._fusion_prompt( @@ -660,6 +697,27 @@ def test_fusion_prompt_includes_feedback_when_refining(tmp_path): assert "AssertionError" in prompt +def test_fusion_prompt_truncation_is_visible_not_silent(tmp_path): + """Defensive backstop only (real callers gate via `fusion_viable_for_files` + first) — but if a caller ever skips that gate, a truncated read must tell + fusion to skip the file rather than let it return a "complete" replacement + of content it never actually saw in full.""" + (tmp_path / "big.py").write_text("x = 1\n" * 10) + prompt = coder_seam._fusion_prompt( + "fix it", feedback=None, repo=str(tmp_path), files_to_modify=["big.py"], max_file_chars=10 + ) + assert "TRUNCATED at 10 chars" in prompt + assert "do NOT return this as a complete replacement" in prompt + + +def test_fusion_prompt_no_truncation_marker_when_file_fits(tmp_path): + (tmp_path / "small.py").write_text("x = 1\n") + prompt = coder_seam._fusion_prompt( + "fix it", feedback=None, repo=str(tmp_path), files_to_modify=["small.py"], max_file_chars=10_000 + ) + assert "TRUNCATED" not in prompt + + async def test_generate_fusion_writes_parsed_files_into_a_fresh_worktree(monkeypatch, tmp_path): created, *_ = _stub_worktree(monkeypatch) @@ -731,6 +789,112 @@ async def _create_in_tmp(repo, base, cid, root): assert not Path("/etc/shadow_THIS_MUST_NOT_EXIST_pwned2").exists() +async def test_generate_fusion_restricts_writes_to_declared_files_to_modify(monkeypatch, tmp_path): + """Fusion has no tool access — it only ever sees the files we showed it. A + path outside the feature's declared `files_to_modify` means a hallucinated + file (or a parser mis-split); writing it would silently touch unrelated + code with no test coverage backing the change.""" + _stub_worktree(monkeypatch) + + async def _fake_openai_dispatch(delegate, prompt, *, timeout=None): + return "### declared.py\n```\nfine\n```\n\n### undeclared.py\n```\nsneaky\n```" + + 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) + + adapter = _WorktreeSolveAdapter( + repo="/repo", + base="main", + root=".worktrees", + fid="bd-1", + coder=object(), + dispatch_timeout=None, + test_cmd="pytest -q", + test_timeout=30, + verdict_cls=_FakeVerdict, + fusion_delegate=object(), + files_to_modify=["declared.py"], + _fusion_dispatch=_fake_openai_dispatch, + ) + wt = await adapter.generate_fusion("do the thing") + assert (Path(wt) / "declared.py").read_text() == "fine\n" + assert not (Path(wt) / "undeclared.py").exists() + + +async def test_generate_fusion_shrink_guard_refuses_a_suspiciously_smaller_rewrite(monkeypatch, tmp_path): + """A whole-file "complete replacement" that comes back drastically smaller + than the file it claims to replace is far more likely a truncated + completion (delegate max_tokens ceiling) than an intentional big deletion.""" + _stub_worktree(monkeypatch) + + async def _fake_openai_dispatch(delegate, prompt, *, timeout=None): + return "### big.py\n```\nx\n```" # a few chars back for a 1000-char original + + async def _create_in_tmp(repo, base, cid, root): + d = tmp_path / cid + d.mkdir(parents=True, exist_ok=True) + (d / "big.py").write_text("x = 1\n" * 200) # 1200 chars, well over the min-original floor + return (str(d), f"feat/{cid}") + + monkeypatch.setattr(worktree, "create_worktree", _create_in_tmp) + + adapter = _WorktreeSolveAdapter( + repo="/repo", + base="main", + root=".worktrees", + fid="bd-1", + coder=object(), + dispatch_timeout=None, + test_cmd="pytest -q", + test_timeout=30, + verdict_cls=_FakeVerdict, + fusion_delegate=object(), + files_to_modify=["big.py"], + _fusion_dispatch=_fake_openai_dispatch, + ) + wt = await adapter.generate_fusion("do the thing") + # refused — the pre-existing (larger) content must survive untouched + assert (Path(wt) / "big.py").read_text() == "x = 1\n" * 200 + + +async def test_generate_fusion_shrink_guard_allows_a_legitimately_smaller_edit(monkeypatch, tmp_path): + """The guard only kicks in above `_SHRINK_GUARD_MIN_ORIGINAL_CHARS` and below + `_SHRINK_GUARD_RATIO` — a real, modest trim must still go through.""" + _stub_worktree(monkeypatch) + + async def _fake_openai_dispatch(delegate, prompt, *, timeout=None): + return "### small.py\n```\nx = 1\n```" + + async def _create_in_tmp(repo, base, cid, root): + d = tmp_path / cid + d.mkdir(parents=True, exist_ok=True) + (d / "small.py").write_text("x = 1\ny = 2\n") # tiny original, under the min-original floor + return (str(d), f"feat/{cid}") + + monkeypatch.setattr(worktree, "create_worktree", _create_in_tmp) + + adapter = _WorktreeSolveAdapter( + repo="/repo", + base="main", + root=".worktrees", + fid="bd-1", + coder=object(), + dispatch_timeout=None, + test_cmd="pytest -q", + test_timeout=30, + verdict_cls=_FakeVerdict, + fusion_delegate=object(), + files_to_modify=["small.py"], + _fusion_dispatch=_fake_openai_dispatch, + ) + wt = await adapter.generate_fusion("do the thing") + assert (Path(wt) / "small.py").read_text() == "x = 1\n" + + async def test_generate_fusion_empty_reply_writes_nothing_and_does_not_crash(monkeypatch, tmp_path): _stub_worktree(monkeypatch) diff --git a/tests/test_loop.py b/tests/test_loop.py index b6b03e3..bc9ad3d 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -481,6 +481,7 @@ async def _fake_dispatch( fusion_delegate=None, fusion_k=2, files_to_modify=None, + fusion_max_file_chars=None, ): seen["fid"] = fid seen["test_cmd"] = test_cmd @@ -504,6 +505,42 @@ async def _open_pr(wt, branch, *, base, title, body): assert store.creates == [] # solve()'s own per-candidate worktrees replaced the single create +async def test_drive_skips_fusion_for_a_dispatch_when_files_are_oversized(monkeypatch, tmp_path): + """Fusion can't tool-call and returns whole-file replacements — an oversized + file must gate BEFORE dispatch (fusion_delegate=None for that dispatch), not + get attempted and risk a silently truncated rewrite. The ladder still runs + (greedy/best-of-k/tree-search), it just skips the fusion rung.""" + from project_board import coder_seam + + (tmp_path / "big.py").write_text("x" * 1000) + seen = {} + + async def _fake_dispatch(*, fusion_delegate=None, **kw): + seen["fusion_delegate"] = fusion_delegate + return (f"/wt/feat-{kw['fid']}", f"feat/{kw['fid']}", "[coder.solve rung=greedy gens=1] solved") + + monkeypatch.setattr(coder_seam, "_import_solve", lambda: object()) + monkeypatch.setattr(coder_seam, "dispatch", _fake_dispatch) + + async def _open_pr(wt, branch, *, base, title, body): + return "https://example/pr/42" + + feature = {**FEATURE, "repo": str(tmp_path), "files_to_modify": ["big.py"]} + loop, store = await _drive_with( + monkeypatch, + open_pr=_open_pr, + cfg={ + "coder": "proto", + "local_gate_cmd": "pytest -q", + "coder_solve_fusion_delegate": "fusion-model", + "coder_solve_fusion_max_file_chars": 10, + }, + gate=_pass_gate, + feature=feature, + ) + assert seen["fusion_delegate"] is None # gated out before dispatch, not attempted + + async def test_drive_falls_back_to_single_shot_without_acceptance_criteria(monkeypatch): """Honest degrade: even with the coder plugin available and a test command configured, a feature with NO acceptance criteria takes today's single