diff --git a/agent/hotpath_agent/loop.py b/agent/hotpath_agent/loop.py index b03bf0f..a431a3d 100644 --- a/agent/hotpath_agent/loop.py +++ b/agent/hotpath_agent/loop.py @@ -86,7 +86,9 @@ async def run_attempt(target: str, run_id: str, max_turns: int, repo_root: str) f"3. Read the relevant source with read_target_source — for large files pass " f"offset/limit and page through in windows (do NOT attempt any shell). State your " f"hypothesis, then propose_patch (unified diff, a/ b/ prefixes, paths relative to the " - f"target workspace root).\n" + f"target workspace root, trailing newline required). Each proposal is STANDALONE — " + f"the workspace resets to pristine before every apply, so always submit the complete " + f"patch, never an increment on a prior proposal.\n" f"4. run_verdict with run_id '{run_id}', the class number, and the hotspot string. " f"It launches a multi-minute pipeline and returns immediately.\n" f"5. Poll read_verdict('{run_id}') until it returns a verdict (not a 'running' status). " diff --git a/agent/hotpath_agent/mcp_server.py b/agent/hotpath_agent/mcp_server.py index a7b04f7..dbb51b6 100644 --- a/agent/hotpath_agent/mcp_server.py +++ b/agent/hotpath_agent/mcp_server.py @@ -50,9 +50,12 @@ def read_target_source(target: str, path: str, offset: int = 0, limit: int = 400 @mcp.tool() def propose_patch(target: str, diff: str, hypothesis: str) -> str: - """Submit a unified git diff (a/ b/ prefixes, paths relative to the target workspace) - plus the hypothesis it tests. The harness applies it only after a path-allowlist - check. Returns a patch_id, or an error string if refused.""" + """Submit a unified git diff (a/ b/ prefixes, paths relative to the target + workspace, MUST end with a trailing newline) plus the hypothesis it tests. + Each proposal is STANDALONE: the workspace is reset to pristine before the + diff is applied, so always submit the complete patch — never an increment + on top of an earlier proposal. The harness applies it only after a + path-allowlist check. Returns a patch_id, or an error string if refused.""" return harness.propose_patch(target, diff, hypothesis) diff --git a/crates/harnessd/src/main.rs b/crates/harnessd/src/main.rs index 580222f..a914edb 100644 --- a/crates/harnessd/src/main.rs +++ b/crates/harnessd/src/main.rs @@ -222,6 +222,13 @@ fn handle(root: &Path, line: &str) -> Result { let diff = field(&req, "diff")?; let hypothesis = field(&req, "hypothesis")?; check_target(t)?; + // git rejects a diff without a final newline with an opaque + // "corrupt patch" — say it plainly (phase2-comrak-004 burned + // several turns rediscovering this). + ensure!( + diff.ends_with('\n'), + "diff must end with a trailing newline (git would report 'corrupt patch' at EOF)" + ); // Allowlist: every path named by the diff must be a safe // relative path (the git -C below roots them in the target // workspace; nothing outside it is reachable). @@ -238,18 +245,51 @@ fn handle(root: &Path, line: &str) -> Result { ); } let ws = root.join(format!("targets/{t}/workspace")); - let mut check = Command::new("git") - .args(["-C", ws.to_str().unwrap(), "apply", "--check", "-"]) - .stdin(std::process::Stdio::piped()) - .spawn()?; - check.stdin.take().unwrap().write_all(diff.as_bytes())?; - ensure!(check.wait()?.success(), "diff does not apply cleanly"); - let mut apply = Command::new("git") - .args(["-C", ws.to_str().unwrap(), "apply", "-"]) - .stdin(std::process::Stdio::piped()) - .spawn()?; - apply.stdin.take().unwrap().write_all(diff.as_bytes())?; - ensure!(apply.wait()?.success(), "git apply failed"); + // Every proposal stands alone: reset the workspace to HEAD + // first so probe/iteration patches cannot accumulate into a + // tested tree the ledger's patch field doesn't describe + // (phase2-comrak-004 audit finding). + ensure!( + Command::new("git") + .args(["-C", ws.to_str().unwrap(), "checkout", "--", "."]) + .status()? + .success(), + "workspace reset failed" + ); + ensure!( + Command::new("git") + .args(["-C", ws.to_str().unwrap(), "clean", "-fdq"]) + .status()? + .success(), + "workspace clean failed" + ); + let run_git_apply = |check: bool| -> Result { + let mut args = vec!["-C", ws.to_str().unwrap(), "apply"]; + if check { + args.push("--check"); + } + args.push("-"); + let mut child = Command::new("git") + .args(&args) + .stdin(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn()?; + child.stdin.take().unwrap().write_all(diff.as_bytes())?; + Ok(child.wait_with_output()?) + }; + let checked = run_git_apply(true)?; + ensure!( + checked.status.success(), + "diff does not apply cleanly against the pristine workspace \ + (proposals are standalone; prior proposals were reset): {}", + String::from_utf8_lossy(&checked.stderr).trim() + ); + let applied = run_git_apply(false)?; + ensure!( + applied.status.success(), + "git apply failed: {}", + String::from_utf8_lossy(&applied.stderr).trim() + ); let patch_id = format!("{:x}", Sha256::digest(diff.as_bytes()))[..12].to_string(); let pending = root.join("results/pending"); std::fs::create_dir_all(&pending)?; diff --git a/results/ledger.sqlite b/results/ledger.sqlite index 0c1d7af..d2fbb0e 100644 Binary files a/results/ledger.sqlite and b/results/ledger.sqlite differ