feat(missions): capture a coding phase's diff to durable storage
First half of mission delivery: the work is captured before anything is published. A coding mission has until now produced nothing durable — the checkout is deleted thirty minutes after completion and `register_artifact` had no callers at all, so the only surviving output was an LLM narrative of what the agents said they did. `capture_phase_diff` writes `diff.patch`, `diffstat.txt` and `delivery.json` under `<missions_root>/_outputs/<mission>/<phase>/` and registers a `code_diff` artifact. That directory is a *sibling* of the per-mission directories the sweeper removes, and outside every bind mount handed to a container — so teardown cannot take the record with it and agents cannot edit their own evidence. Three details that decide whether this works at all: - `git add --intent-to-add` before diffing. Untracked files are invisible to `git diff`, and a phase that only *creates* files is the likeliest shape for generated code — silently capturing an empty patch would be the worst possible failure. The index is reset afterwards so capture leaves the tree exactly as the agents left it, which the test asserts. - Build output is excluded by pathspec (`target`, `node_modules`, `.venv`, …). A phase that ran `cargo build` leaves a directory larger than the repo. - An empty diff is still an artifact, flagged `empty: true`. "This coding phase wrote no code" is currently invisible to an operator and is worth saying out loud. `RegisterArtifact` gains `metadata`, which the column has had since 0047 and nothing ever wrote; the diffstat and base sha go there. No migration needed — `kind` is unconstrained TEXT and the column already exists. Tests run against a real `git init` repo rather than a mock: every bug in this area so far came from git behaving differently than assumed, and a fake git would have agreed with the assumption. `capture_phase_diff_at` takes explicit paths so parallel tests cannot race through the process-global CLAWMATES_MISSIONS_ROOT — the first version of these tests did exactly that and two of four failed non-deterministically. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ea3d145aac
commit
716ee9a304
@@ -0,0 +1,224 @@
|
||||
//! 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.
|
||||
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"]);
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user