fix(missions): capture from the clone point, and let agents create files
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:
co-authored by
Claude Opus 5
parent
322c1be89c
commit
409ca65ee7
@@ -45,6 +45,21 @@ fn git(repo: &Path, args: &[&str]) {
|
||||
|
||||
/// A repo with one commit, at `<root>/<mission>/repo` so `checkout_path`
|
||||
/// finds it.
|
||||
/// Record the clone point the way `mission_workspace` does after a clone.
|
||||
fn record_base(repo: &Path) {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.output()
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
repo.join(".git/clawmates-base"),
|
||||
String::from_utf8_lossy(&out.stdout).trim(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn seed_repo(root: &Path, mission: Uuid) -> std::path::PathBuf {
|
||||
let repo = root.join(mission.to_string()).join("repo");
|
||||
std::fs::create_dir_all(&repo).unwrap();
|
||||
@@ -54,6 +69,7 @@ fn seed_repo(root: &Path, mission: Uuid) -> std::path::PathBuf {
|
||||
std::fs::write(repo.join("README.md"), "# base\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "--quiet", "-m", "base"]);
|
||||
record_base(&repo);
|
||||
repo
|
||||
}
|
||||
|
||||
@@ -222,3 +238,77 @@ async fn seed_mission_phase(pool: &sqlx::PgPool, mission: Uuid) -> (Uuid, Uuid)
|
||||
.unwrap();
|
||||
(ws, phase)
|
||||
}
|
||||
|
||||
/// The production failure this pairs with. Mission 019fc372's agent created
|
||||
/// the file it was asked for and *committed* it — `rust_sdlc` has a committer
|
||||
/// role, so that is the intended path — leaving a clean working tree. Capture
|
||||
/// diffed against HEAD, found nothing, and recorded `empty: true` next to a
|
||||
/// commit that plainly contained the work.
|
||||
#[tokio::test]
|
||||
async fn work_the_agent_committed_is_captured() {
|
||||
let pool = cm_testkit::test_pool().await;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
let mission = Uuid::now_v7();
|
||||
let repo = seed_repo(tmp.path(), mission);
|
||||
let (_, phase) = seed_mission_phase(&pool, mission).await;
|
||||
|
||||
std::fs::write(repo.join("DELIVERY_PROBE.md"), "CAPTURED-BY-CLAWMATES\n").unwrap();
|
||||
git(&repo, &["add", "DELIVERY_PROBE.md"]);
|
||||
git(&repo, &["commit", "--quiet", "-m", "Add DELIVERY_PROBE.md"]);
|
||||
|
||||
// The tree is clean — `git status --porcelain` is empty here, which is
|
||||
// precisely why the HEAD-relative version saw nothing.
|
||||
let status = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(["status", "--porcelain"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
String::from_utf8_lossy(&status.stdout).trim().is_empty(),
|
||||
"the agent committed, so the tree is clean"
|
||||
);
|
||||
|
||||
let cap = capture(&pool, tmp.path(), mission, phase)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!cap.empty,
|
||||
"committed work must be captured, not reported as empty"
|
||||
);
|
||||
assert_eq!(cap.files_changed, 1);
|
||||
|
||||
let patch = std::fs::read_to_string(&cap.patch_path).unwrap();
|
||||
assert!(
|
||||
patch.contains("CAPTURED-BY-CLAWMATES"),
|
||||
"the committed content is in the patch"
|
||||
);
|
||||
}
|
||||
|
||||
/// Committed *and* uncommitted work in the same phase — a coder that committed
|
||||
/// one change and left another in progress.
|
||||
#[tokio::test]
|
||||
async fn committed_and_uncommitted_changes_are_both_captured() {
|
||||
let pool = cm_testkit::test_pool().await;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
let mission = Uuid::now_v7();
|
||||
let repo = seed_repo(tmp.path(), mission);
|
||||
let (_, phase) = seed_mission_phase(&pool, mission).await;
|
||||
|
||||
std::fs::write(repo.join("committed.rs"), "fn done() {}\n").unwrap();
|
||||
git(&repo, &["add", "committed.rs"]);
|
||||
git(&repo, &["commit", "--quiet", "-m", "first"]);
|
||||
std::fs::write(repo.join("in_progress.rs"), "fn wip() {}\n").unwrap();
|
||||
|
||||
let cap = capture(&pool, tmp.path(), mission, phase)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let patch = std::fs::read_to_string(&cap.patch_path).unwrap();
|
||||
assert!(patch.contains("fn done()"), "committed work");
|
||||
assert!(patch.contains("fn wip()"), "uncommitted work");
|
||||
assert_eq!(cap.files_changed, 2);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user