fix(missions): capture from the clone point, and let agents create files
ci / gates (push) Failing after 7s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped

Two defects found by running a real coding mission (019fc372) rather than a
test. Both made a coding phase look like it produced nothing.

**Capture measured the wrong baseline.** It diffed the working tree against
HEAD, which is correct only while work stays uncommitted. `rust_sdlc` has a
*committer* role, so committing is the intended path — meaning a mission that
did its job properly leaves a clean tree and captured nothing. That is exactly
what happened: the agent created `DELIVERY_PROBE.md`, committed it as
`aa3be95`, and the artifact recorded `empty: true` beside a commit that
plainly contained the work.

`mission_workspace` now records the clone point in `.git/clawmates-base` (in
`.git/`, so it travels with the checkout, stays invisible to the repository,
and cannot be reached by an agent through its pinned workspace), refreshed
whenever `fetch_and_reset` moves HEAD. Capture diffs from there, covering
committed, staged and unstaged changes in one pass. Checkouts predating the
marker fall back to HEAD and say so via `base_recorded: false`.

**Agents could not create files.** `coding_readwrite` granted `file_edit` but
not `file_write`. `file_edit` replaces an exact existing string and rejects an
empty `old_string`, so creating a new file was impossible. The mission
transcript is unambiguous: "the tool rejected empty old_string... the shell is
restricted", after which the agent worked around it through `shell`. The
comment above that profile has claimed it grants file_write since the day it
was written; the list never contained it.

Also broadens capture from coding/benchmark/security_scan to every phase kind
of a repo-bearing mission: `phase_task_text` tells research phases to "save
findings under /mission/repo/research/", so filtering by kind would have
discarded every research brief such a mission produced.

Regression tests cover committed-only and committed-plus-uncommitted work
against a real git repo.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-02 10:20:00 -07:00
co-authored by Claude Opus 5
parent 322c1be89c
commit 409ca65ee7
5 changed files with 189 additions and 11 deletions
+53 -1
View File
@@ -121,9 +121,58 @@ async fn clone(path: &std::path::Path, url: &str) -> Result<(), String> {
}
scrub_remote_credentials(path, url);
ignore_agent_scaffolding(path);
record_base_commit(path);
Ok(())
}
/// Remember the commit the mission started from.
///
/// Delivery needs to answer "what did this mission change", and the obvious
/// reading — working tree versus `HEAD` — is wrong the moment an agent
/// commits. `rust_sdlc` has a *committer* role, so committing is the normal
/// path, not an edge case: a mission that did its job properly would have a
/// clean tree and capture nothing at all. Observed exactly that on mission
/// 019fc372, where the agent committed `DELIVERY_PROBE.md` and the diff came
/// back empty.
///
/// Written into `.git/` so it travels with the checkout, is invisible to the
/// repository, and cannot be edited by an agent through its pinned workspace.
pub(crate) fn record_base_commit(path: &std::path::Path) {
let out = std::process::Command::new("git")
.args([
"-C",
&path.display().to_string(),
"-c",
&format!("safe.directory={}", path.display()),
"rev-parse",
"HEAD",
])
.output();
let Ok(out) = out else { return };
if !out.status.success() {
return;
}
let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
if sha.is_empty() {
return;
}
if let Err(e) = std::fs::write(path.join(".git/clawmates-base"), format!("{sha}\n")) {
eprintln!(
"mission_workspace: could not record base commit for {} ({e}) — delivery will \
fall back to diffing against HEAD and will miss committed work",
path.display()
);
}
}
/// The commit this mission's checkout started from, if it was recorded.
pub(crate) fn base_commit(path: &std::path::Path) -> Option<String> {
std::fs::read_to_string(path.join(".git/clawmates-base"))
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
/// Take the access token back out of `.git/config`.
///
/// `with_ambient_auth` embeds `GITEA_TOKEN` in the clone URL so the clone can
@@ -326,12 +375,15 @@ async fn fetch_and_reset(path: &std::path::Path, branch: &str) -> Result<(), Str
return Err(format!(
"git reset --hard origin/{branch} → exit {}: {}",
reset.status,
String::from_utf8_lossy(&reset.stderr)
redact_token(&String::from_utf8_lossy(&reset.stderr))
.chars()
.take(400)
.collect::<String>()
));
}
// HEAD just moved to the freshly fetched tip; that is this run's starting
// point, so the recorded base moves with it.
record_base_commit(path);
Ok(())
}