Files
clawmates/crates/cm-api/tests/mission_delivery.rs
T
Omar SobhandClaude Opus 5 409ca65ee7
ci / gates (push) Failing after 7s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
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]>
2026-08-02 10:20:00 -07:00

315 lines
11 KiB
Rust

//! Diff capture, against a real git repository.
//!
//! Deliberately not mocked. Every bug this area has produced came from git
//! behaving differently than assumed — a shallow clone refusing a push, an
//! ownership check refusing the repo, untracked files invisible to `git diff`.
//! A fake `git` would agree with whatever the code believed and prove nothing.
use std::path::Path;
use std::process::Command;
use cm_api::mission_delivery;
use uuid::Uuid;
/// Capture against an explicit root, so parallel tests cannot race each other
/// through the process-global `CLAWMATES_MISSIONS_ROOT`.
async fn capture(
pool: &sqlx::PgPool,
root: &Path,
mission: Uuid,
phase: Uuid,
) -> Result<Option<mission_delivery::Capture>, String> {
mission_delivery::capture_phase_diff_at(
pool,
mission,
phase,
&root.join(mission.to_string()).join("repo"),
&root.join("_outputs").join(mission.to_string()),
)
.await
}
fn git(repo: &Path, args: &[&str]) {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()
.expect("run git");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
/// 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();
git(&repo, &["init", "--quiet"]);
git(&repo, &["config", "user.email", "[email protected]"]);
git(&repo, &["config", "user.name", "Test"]);
std::fs::write(repo.join("README.md"), "# base\n").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "--quiet", "-m", "base"]);
record_base(&repo);
repo
}
#[tokio::test]
async fn captures_modified_and_untracked_files() {
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 (ws, phase) = seed_mission_phase(&pool, mission).await;
let _ = ws;
// A modified file and a brand-new one. The new file is the case that
// matters: without `--intent-to-add` it would not appear in `git diff`,
// and a phase that only creates files is the likeliest shape of all.
std::fs::write(repo.join("README.md"), "# base\nchanged\n").unwrap();
std::fs::write(repo.join("new_module.rs"), "fn added() {}\n").unwrap();
let cap = capture(&pool, tmp.path(), mission, phase)
.await
.unwrap()
.expect("mission has a checkout");
assert!(!cap.empty, "the phase changed files");
assert_eq!(cap.files_changed, 2, "one modified, one created");
assert!(cap.insertions >= 2);
let patch = std::fs::read_to_string(&cap.patch_path).unwrap();
assert!(
patch.contains("new_module.rs"),
"untracked file is captured"
);
assert!(patch.contains("fn added()"), "its content is captured");
assert!(patch.contains("changed"), "the modification is captured");
// Capture must leave the working tree exactly as the agents left it — a
// later commit has to see the same thing this diff described.
let status = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["status", "--porcelain"])
.output()
.unwrap();
let status = String::from_utf8_lossy(&status.stdout);
assert!(
status.contains("new_module.rs"),
"the new file is still untracked after capture, not left staged: {status}"
);
let row: (String, serde_json::Value) = sqlx::query_as(
"SELECT kind, metadata FROM mission_artifacts WHERE mission_id = $1 AND phase_id = $2",
)
.bind(mission)
.bind(phase)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(row.0, "code_diff");
assert_eq!(row.1["files_changed"], 2);
assert_eq!(row.1["empty"], false);
assert!(row.1["base_sha"].as_str().unwrap().len() >= 7);
}
/// "This coding phase wrote no code" is a result, and currently an invisible
/// one. It must still produce an artifact.
#[tokio::test]
async fn an_empty_phase_still_produces_an_artifact() {
let pool = cm_testkit::test_pool().await;
let tmp = tempfile::tempdir().unwrap();
let mission = Uuid::now_v7();
seed_repo(tmp.path(), mission);
let (_, phase) = seed_mission_phase(&pool, mission).await;
let cap = capture(&pool, tmp.path(), mission, phase)
.await
.unwrap()
.unwrap();
assert!(cap.empty);
assert_eq!(cap.files_changed, 0);
let (kind, meta): (String, serde_json::Value) =
sqlx::query_as("SELECT kind, metadata FROM mission_artifacts WHERE mission_id = $1")
.bind(mission)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(kind, "code_diff");
assert_eq!(meta["empty"], true, "the emptiness is recorded, not hidden");
}
/// Build output must never reach the patch. A phase that ran `cargo build`
/// leaves a `target/` larger than the repository, and committing it would be
/// worse than losing the diff.
#[tokio::test]
async fn build_output_is_not_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::create_dir_all(repo.join("target/debug")).unwrap();
std::fs::write(repo.join("target/debug/huge.bin"), vec![b'x'; 200_000]).unwrap();
std::fs::create_dir_all(repo.join("node_modules/left-pad")).unwrap();
std::fs::write(
repo.join("node_modules/left-pad/index.js"),
"module.exports=0",
)
.unwrap();
std::fs::write(repo.join("real_change.rs"), "fn kept() {}\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("real_change.rs"), "genuine work is captured");
assert!(!patch.contains("huge.bin"), "target/ is excluded");
assert!(!patch.contains("left-pad"), "node_modules is excluded");
assert_eq!(cap.files_changed, 1, "only the real change counts");
}
/// A research mission has no checkout. That is not an error.
#[tokio::test]
async fn a_mission_without_a_checkout_captures_nothing() {
let pool = cm_testkit::test_pool().await;
let tmp = tempfile::tempdir().unwrap();
let mission = Uuid::now_v7();
let (_, phase) = seed_mission_phase(&pool, mission).await;
assert!(capture(&pool, tmp.path(), mission, phase)
.await
.unwrap()
.is_none());
}
async fn seed_mission_phase(pool: &sqlx::PgPool, mission: Uuid) -> (Uuid, Uuid) {
let ws = Uuid::now_v7();
sqlx::query("INSERT INTO workspaces (id, name, plan) VALUES ($1,'t','team')")
.bind(ws)
.execute(pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO missions (id, workspace_id, title, template_kind, schedule, status, config)
VALUES ($1,$2,'t','research_and_code','{}'::jsonb,'running','{}'::jsonb)",
)
.bind(mission)
.bind(ws)
.execute(pool)
.await
.unwrap();
let phase = Uuid::now_v7();
sqlx::query(
"INSERT INTO mission_phases (id, mission_id, kind, order_idx, status, config)
VALUES ($1,$2,'coding',0,'completed','{}'::jsonb)",
)
.bind(phase)
.bind(mission)
.execute(pool)
.await
.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);
}