agent: propose_patch ergonomics from the phase2-comrak-004 audit

- Standalone-proposal semantics: workspace resets to pristine before
  every apply, so probe patches can't accumulate into a tested tree the
  ledger's patch field doesn't describe.
- Friendly errors: explicit trailing-newline check, and git apply's
  stderr relayed on refusal instead of a generic message.
- Tool docs + task prompt updated to match.
- phase2-comrak-004 audited: honest rejected-bench (median 1.0124,
  CI [1.0011, 1.0267] — real but under the 2% bar), Mutex pre-flagged
  for human review, cumulative-probe state disclosed in the hypothesis.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EcKPWQ3MQD4NdEHwar96u2
This commit is contained in:
Claude
2026-07-10 05:32:08 +00:00
parent 393ca7117f
commit 23ab522445
4 changed files with 61 additions and 16 deletions
+3 -1
View File
@@ -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"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"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"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"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"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). " f"5. Poll read_verdict('{run_id}') until it returns a verdict (not a 'running' status). "
+6 -3
View File
@@ -50,9 +50,12 @@ def read_target_source(target: str, path: str, offset: int = 0, limit: int = 400
@mcp.tool() @mcp.tool()
def propose_patch(target: str, diff: str, hypothesis: str) -> str: def propose_patch(target: str, diff: str, hypothesis: str) -> str:
"""Submit a unified git diff (a/ b/ prefixes, paths relative to the target workspace) """Submit a unified git diff (a/ b/ prefixes, paths relative to the target
plus the hypothesis it tests. The harness applies it only after a path-allowlist workspace, MUST end with a trailing newline) plus the hypothesis it tests.
check. Returns a patch_id, or an error string if refused.""" 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) return harness.propose_patch(target, diff, hypothesis)
+50 -10
View File
@@ -222,6 +222,13 @@ fn handle(root: &Path, line: &str) -> Result<Value> {
let diff = field(&req, "diff")?; let diff = field(&req, "diff")?;
let hypothesis = field(&req, "hypothesis")?; let hypothesis = field(&req, "hypothesis")?;
check_target(t)?; 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 // Allowlist: every path named by the diff must be a safe
// relative path (the git -C below roots them in the target // relative path (the git -C below roots them in the target
// workspace; nothing outside it is reachable). // workspace; nothing outside it is reachable).
@@ -238,18 +245,51 @@ fn handle(root: &Path, line: &str) -> Result<Value> {
); );
} }
let ws = root.join(format!("targets/{t}/workspace")); let ws = root.join(format!("targets/{t}/workspace"));
let mut check = Command::new("git") // Every proposal stands alone: reset the workspace to HEAD
.args(["-C", ws.to_str().unwrap(), "apply", "--check", "-"]) // 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<std::process::Output> {
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()) .stdin(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?; .spawn()?;
check.stdin.take().unwrap().write_all(diff.as_bytes())?; child.stdin.take().unwrap().write_all(diff.as_bytes())?;
ensure!(check.wait()?.success(), "diff does not apply cleanly"); Ok(child.wait_with_output()?)
let mut apply = Command::new("git") };
.args(["-C", ws.to_str().unwrap(), "apply", "-"]) let checked = run_git_apply(true)?;
.stdin(std::process::Stdio::piped()) ensure!(
.spawn()?; checked.status.success(),
apply.stdin.take().unwrap().write_all(diff.as_bytes())?; "diff does not apply cleanly against the pristine workspace \
ensure!(apply.wait()?.success(), "git apply failed"); (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 patch_id = format!("{:x}", Sha256::digest(diff.as_bytes()))[..12].to_string();
let pending = root.join("results/pending"); let pending = root.join("results/pending");
std::fs::create_dir_all(&pending)?; std::fs::create_dir_all(&pending)?;
Binary file not shown.