Files
clawmates/crates/cm-api/src/mission_delivery.rs
T
Omar SobhandClaude Opus 5 cb8184e784 feat(delivery): record WHICH files a phase touched, not just how many
`capture_phase_diff_at` parsed `git diff --stat` down to three integers and
threw the filenames away. Nothing downstream could name a single file a coding
phase changed: the World can draw a coding station but nothing underneath it,
and an operator reading a mission sees "11 files" with no way to learn which.

A second `--name-status` call now records the paths into the code_diff metadata
and into `names.txt` beside `diffstat.txt`, so raw evidence survives
independently of the JSONB.

Three ways this could have been wrong, each guarded:

  - Different revision or excludes from the `--stat` call would make
    `files_changed` and the path list describe different diffs, with no way to
    tell which lied. A source-walk test pins both to the same `base_sha` and
    the same `excludes`.
  - Running after `git reset --quiet` would drop newly CREATED files, since
    `--intent-to-add` is what makes them visible to diff at all — and the stat
    would still count them, so the list would look merely incomplete rather
    than wrong. A test asserts the ordering.
  - A rename is `R100\told\tnew` — three fields. Taking field two records where
    the file USED to be, naming a path nobody can open, and the bug is
    invisible in any repo where nothing was renamed. `changed_paths` is now
    shared with auto_merge (which had the same parse) and takes the NEW path,
    with tests for renames and copies.

The list is capped at 500 paths with `files_truncated` beside it: a cap that
silently clips is worse than no cap, because "touched 12 files" and "touched at
least 500" would look identical.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-10 22:34:39 -07:00

1443 lines
61 KiB
Rust

//! Getting a coding phase's work out of the mission and somewhere durable.
//!
//! Until now a coding mission produced nothing. Agents cloned a repo, edited
//! files, and thirty minutes after the mission finished `teardown_container`
//! called `remove_dir_all` on the checkout (`mission_runtime`). Nothing
//! pushed, no artifact was registered — `register_artifact` had no callers at
//! all — and the only durable output was an LLM-written narrative of what the
//! agents *said* they had done.
//!
//! This module captures the work first and publishes it second, in that
//! order and never the reverse. The patch is written to disk before any
//! remote is contacted, so a push that fails — a rotated token, a rejected
//! ref, an unreachable forge — costs a branch and not the work.
//!
//! ## Why the server does this and not the agent
//!
//! An agent that reports "I committed and pushed" is making a claim, and this
//! codebase has spent a lot of effort learning not to bank claims (see
//! `evaluator`). Server-side git means the exit codes, the shas and the
//! diffstat are ours: what lands in `mission_artifacts` is what git actually
//! did. It is also the only version that can be gated — a `commit_policy`
//! enforced by asking an agent to please check its tests first is a wish.
//!
//! ## Retention
//!
//! Output goes to `<missions_root>/_outputs/<mission>/<phase>/`, a *sibling*
//! of the per-mission directories the sweeper deletes, and outside every bind
//! mount handed to a container (`ensure_container` mounts only
//! `<root>/<mission_id>`). So agents cannot reach their own delivery record,
//! and teardown cannot take it with them.
use std::path::{Path, PathBuf};
use serde_json::json;
use uuid::Uuid;
use crate::mission_workspace;
/// Paths never worth capturing: build output and vendored dependencies. A
/// coding phase that ran `cargo build` leaves a `target/` directory larger
/// than most repositories, and a patch containing it is unreadable as well as
/// enormous.
///
/// `mission_fs` uses this same list for the TRANSPORT, and that is not a
/// convenience — it is the fix for a real failure. The diff excluded `target/`
/// while the tar that carried the tree in and out did not, so a phase that ran
/// `cargo test` shipped its whole build directory over vsock twice. `vm_collect`
/// timed out at 300s on mission 019fd43e with the agent's work finished and
/// stranded inside a VM. Two layers, one list.
pub(crate) const EXCLUDED_PATHS: &[&str] = &[
"target",
"node_modules",
".venv",
"venv",
"dist",
"build",
".next",
"__pycache__",
".pytest_cache",
".mypy_cache",
"vendor",
// Agent workaround debris. `.gitconfig_temp` appeared on mission 019fc3ba
// when an agent hit git's ownership check and wrote its own safe.directory
// config into the repository root. The cause is fixed (mission containers
// now carry GIT_CONFIG_* env), but excluding the artefact keeps a stray
// workaround out of a user's repository if an agent invents another one.
".gitconfig_temp",
".gitconfig.tmp",
];
/// Cap on the captured patch. Past this the diff is truncated with a marker
/// rather than dropped: a 40 MB patch is a signal in itself (something
/// generated or vendored got committed), and the head of it is what an
/// operator needs to see to work out what happened.
const MAX_PATCH_BYTES: usize = 4 * 1024 * 1024;
/// Cap on the recorded path list. A cap that silently truncates is worse than
/// no cap, so the metadata carries `files_truncated` beside it — a reader must
/// be able to tell "touched 12 files" from "touched at least 500".
const MAX_CAPTURED_PATHS: usize = 500;
/// Who delivery commits as.
///
/// The operator's identity by default, so pushed commits associate with their
/// forge account the way their own commits do. Overridable per deployment via
/// `CLAWMATES_COMMIT_NAME` / `CLAWMATES_COMMIT_EMAIL` — a shared instance
/// wants a bot identity here, not a person's.
///
/// What matters for correctness is only that *some* identity is always set:
/// the server container has none of its own, so `git commit` fails outright
/// without this. The particular value is attribution, not function.
///
/// Attribution alone, to be clear — the push credential is `GITEA_TOKEN` and
/// is unaffected by any of this.
const DEFAULT_COMMIT_NAME: &str = "Omar Sobh";
const DEFAULT_COMMIT_EMAIL: &str = "[email protected]";
pub(crate) fn commit_identity() -> (String, String) {
let name = std::env::var("CLAWMATES_COMMIT_NAME")
.ok()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| DEFAULT_COMMIT_NAME.to_string());
let email = std::env::var("CLAWMATES_COMMIT_EMAIL")
.ok()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| DEFAULT_COMMIT_EMAIL.to_string());
(name, email)
}
/// Ceiling on the gate's test run. Long enough for a real suite, short enough
/// that a hung test does not hold a phase open indefinitely.
const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(900);
/// What a phase produced.
#[derive(Debug, Clone)]
pub struct Capture {
pub base_sha: String,
pub files_changed: usize,
pub insertions: usize,
pub deletions: usize,
/// The phase changed nothing. Still recorded — "this coding phase wrote no
/// code" is currently invisible to an operator, and it is worth saying.
pub empty: bool,
/// Why the diff could not be computed, if it could not be. `empty` is only
/// meaningful when this is `None`: otherwise the tree was never read, and
/// callers deciding anything on the strength of "no changes" must not.
pub diff_error: Option<String>,
pub truncated: bool,
/// The paths this phase touched, with their `--name-status` letter. The
/// diffstat gives counts only; this is what lets anything downstream say
/// WHICH files changed.
pub files: Vec<(char, String)>,
/// The path list hit `MAX_CAPTURED_PATHS`. Recorded so a reader can tell a
/// complete list from a clipped one.
pub files_truncated: bool,
pub patch_path: PathBuf,
/// Set once the work has been committed to a mission branch.
pub committed: Option<Commit>,
}
/// A commit made on the mission's own branch.
#[derive(Debug, Clone)]
pub struct Commit {
pub branch: String,
pub sha: String,
}
/// Where a mission's durable output lives. Sibling of the swept per-mission
/// directories, deliberately.
pub fn outputs_root(mission_id: Uuid) -> PathBuf {
mission_workspace::missions_root()
.join("_outputs")
.join(mission_id.to_string())
}
/// Capture the working-tree diff for one phase and register it as an artifact.
///
/// Runs against the host checkout. Returns `Ok(None)` when the mission has no
/// repository — a research-only phase has nothing to capture and that is not
/// an error.
pub async fn capture_phase_diff(
pool: &sqlx::PgPool,
mission_id: Uuid,
phase_id: Uuid,
) -> Result<Option<Capture>, String> {
// The phase's pass number, so a re-run lands on its own branch instead of
// colliding with the previous attempt.
let iteration: i32 = sqlx::query_scalar("SELECT iteration FROM mission_phases WHERE id = $1")
.bind(phase_id)
.fetch_optional(pool)
.await
.ok()
.flatten()
.unwrap_or(0);
// `commit_policy` lives in the phase config, merged there from the
// workflow recipe by `phases_for_create`.
let policy: Option<String> =
sqlx::query_scalar("SELECT config->>'commit_policy' FROM mission_phases WHERE id = $1")
.bind(phase_id)
.fetch_optional(pool)
.await
.ok()
.flatten();
capture_phase_diff_at(
pool,
mission_id,
phase_id,
&mission_workspace::checkout_path(mission_id),
&outputs_root(mission_id),
iteration,
Gate::parse(policy.as_deref()),
)
.await
}
/// [`capture_phase_diff`] with the two paths supplied.
///
/// Path resolution reads `CLAWMATES_MISSIONS_ROOT`, which is process-global;
/// tests that set it race each other and silently capture the wrong tree. The
/// seam keeps the interesting behaviour — what git sees, what lands on disk,
/// what is registered — testable in parallel without touching the environment.
pub async fn capture_phase_diff_at(
pool: &sqlx::PgPool,
mission_id: Uuid,
phase_id: Uuid,
repo: &Path,
outputs: &Path,
iteration: i32,
gate: Gate,
) -> Result<Option<Capture>, String> {
let repo = repo.to_path_buf();
if !repo.is_dir() {
return Ok(None);
}
// Diff from where the mission *started*, not from HEAD.
//
// `HEAD` is the wrong baseline the moment an agent commits, and committing
// is the normal path — `rust_sdlc` has a committer role. A mission that did
// its job properly leaves a clean tree, so a HEAD-relative diff reports
// nothing changed. That is exactly what happened on mission 019fc372: the
// agent committed the file it was asked to create and capture recorded
// `empty: true` beside a commit that plainly contained the work.
//
// Diffing against the recorded clone point covers committed, staged and
// unstaged changes in one pass. Falling back to HEAD keeps checkouts made
// before the base was recorded working, at the cost of missing committed
// work — which is why the fallback says so in the metadata.
let recorded_base = mission_workspace::base_commit(&repo);
let base_is_recorded = recorded_base.is_some();
let base_sha = match recorded_base {
Some(sha) => sha,
None => git(&repo, &["rev-parse", "HEAD"])
.await
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "HEAD".to_string()),
};
// `--intent-to-add` registers untracked files with the index without
// staging their content, which is what makes them appear in `git diff`.
// Without it a phase that only *created* files would produce an empty
// patch — the most likely shape for generated code, and the worst one to
// silently lose.
let mut add = vec!["add", "--intent-to-add", "--", "."];
let excludes: Vec<String> = EXCLUDED_PATHS
.iter()
.map(|p| format!(":(exclude){p}"))
.collect();
add.extend(excludes.iter().map(String::as_str));
// A repo with nothing to add is fine; keep going and let the diff be empty.
let _ = git(&repo, &add).await;
// A failed `git diff` and a phase that changed nothing both yield an empty
// string, and `unwrap_or_default` used to erase the difference: a corrupt
// index or an unreadable base would land `empty: true, files_changed: 0` —
// byte-identical to an honest no-op, and just as quiet. Whatever went
// wrong is recorded so the artifact can say which of the two it was.
let mut diff_error: Option<String> = None;
let mut note_diff_failure = |what: &str, e: String| {
eprintln!(
"mission_delivery: mission {mission_id} phase {phase_id} could not compute \
{what} against {base_sha}: {e}"
);
if diff_error.is_none() {
diff_error = Some(format!("{what}: {}", e.chars().take(300).collect::<String>()));
}
};
let mut diff_args = vec!["diff", base_sha.as_str(), "--"];
diff_args.extend(excludes.iter().map(String::as_str));
let patch = match git(&repo, &diff_args).await {
Ok(p) => p,
Err(e) => {
note_diff_failure("patch", e);
String::new()
}
};
let mut stat_args = vec!["diff", base_sha.as_str(), "--stat", "--"];
stat_args.extend(excludes.iter().map(String::as_str));
let diffstat = match git(&repo, &stat_args).await {
Ok(s) => s,
Err(e) => {
note_diff_failure("diffstat", e);
String::new()
}
};
// The paths themselves, not just the counts.
//
// The diffstat gives three integers and throws the filenames away, so
// nothing downstream could say WHICH files a phase touched — the World
// could draw a "coding" station but nothing under it. Same `base_sha` and
// the same excludes as the `--stat` call above: if the two disagreed,
// `files_changed` and this list would contradict each other and nobody
// could tell which one lied.
//
// Must run BEFORE the reset below — `--intent-to-add` is what makes newly
// created files visible to diff at all.
let mut name_args = vec!["diff", base_sha.as_str(), "--name-status", "--"];
name_args.extend(excludes.iter().map(String::as_str));
let name_status = match git(&repo, &name_args).await {
Ok(s) => s,
Err(e) => {
note_diff_failure("name-status", e);
String::new()
}
};
// Put the index back. `--intent-to-add` is a mutation of the agent's
// workspace, and capture must not change what a later commit would see.
let _ = git(&repo, &["reset", "--quiet"]).await;
let (files_changed, insertions, deletions) = parse_diffstat(&diffstat);
// Shared with auto_merge so the two cannot disagree about what a
// `--name-status` line means (renames are three fields; the NEW path is the
// one that changed).
let all_paths = crate::auto_merge::changed_paths(&name_status);
let files_truncated = all_paths.len() > MAX_CAPTURED_PATHS;
let files: Vec<(char, String)> = all_paths.into_iter().take(MAX_CAPTURED_PATHS).collect();
let empty = patch.trim().is_empty();
let truncated = patch.len() > MAX_PATCH_BYTES;
let stored = if truncated {
let head: String = patch.chars().take(MAX_PATCH_BYTES).collect();
format!(
"{head}\n\n… patch truncated at {MAX_PATCH_BYTES} bytes \
({} bytes total) …\n",
patch.len()
)
} else {
patch
};
let dir = outputs.join(phase_id.to_string());
std::fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
let patch_path = dir.join("diff.patch");
std::fs::write(&patch_path, &stored)
.map_err(|e| format!("write {}: {e}", patch_path.display()))?;
// Raw evidence on disk, independent of the JSONB. When the metadata and
// the picture disagree, this is the tiebreaker.
let _ = std::fs::write(dir.join("names.txt"), &name_status);
std::fs::write(dir.join("diffstat.txt"), &diffstat)
.map_err(|e| format!("write diffstat: {e}"))?;
// Commit only after the patch is safely on disk. If this fails, the work
// is still captured and the artifact still lands — the branch is the
// convenience, the patch is the guarantee.
// Why a phase has no branch belongs in the artifact, not only in the log.
// Mission `019fc437` recorded `branch: null, push_error: null` for both
// phases — indistinguishable from a phase that was never eligible to
// commit. The reason was in stderr on the host, where nothing reading the
// mission would find it.
let mut commit_error: Option<String> = None;
let committed = match commit_phase_work(&repo, mission_id, phase_id, iteration).await {
Ok(c) => c,
Err(e) => {
eprintln!(
"mission_delivery: mission {mission_id} phase {phase_id} captured but not \
committed: {e}"
);
commit_error = Some(e.chars().take(500).collect());
None
}
};
// Hand the next phase a base that excludes this one's work. Done here
// rather than inside `commit_phase_work` so the patch on disk is already
// written: if the process dies between the two, the worst case is a phase
// that re-reports work, not a phase whose work is invisible.
if let Some(c) = committed.as_ref() {
mission_workspace::advance_base_commit(&repo, &c.sha);
}
// Gate, then publish. Both are best-effort on top of an artifact that has
// already landed: a phase whose tests fail, or whose push is rejected,
// still has its patch on disk and its work on a local branch.
//
// `empty` suppresses publishing, so a diff we could not COMPUTE would
// otherwise skip the push and leave `push_error: null` — the phase looking
// exactly like one that correctly had nothing to publish. See
// [`untrusted_empty_reason`].
let mut outcome: Option<TestOutcome> = None;
let mut published: Option<Publish> = None;
let mut publish_error: Option<String> = untrusted_empty_reason(empty, diff_error.as_deref());
if let Some(c) = committed.as_ref() {
if !empty {
if gate == Gate::OnGreenTests {
let container = std::env::var("CLAWMATES_RUNTIME_CONTAINER")
.unwrap_or_else(|_| "clawmates-runtime".to_string());
// Against a COPY, never the checkout. `verify_tests` execs
// `cargo test` in a container running as ROOT, which writes
// `target/` — in the live tree that leaves root-owned build
// output in a checkout owned by uid 65532 and breaks the
// single-writer invariant. Measured the first time this gate
// ever ran end to end: `uids=0,65532`.
//
// The gate had been implemented but never exercised (every
// harness fixture used `commit_policy: "always"`), which is why
// a bug this mechanical survived in it.
let gate_root = crate::root_copy::copy_root("_gate", mission_id);
crate::root_copy::purge(&container, &gate_root).await;
let o = match crate::root_copy::RootCopy::of(&repo, &gate_root) {
Ok(copy) => {
let r = verify_tests(copy.workdir(), &container).await;
crate::root_copy::purge(&container, &gate_root).await;
r
}
// Fail-closed: an unverifiable suite must not license a push.
Err(e) => TestOutcome::CouldNotRun(format!(
"could not copy the checkout to test it: {e}"
)),
};
// An infrastructure fault must be loud. The gate degrades
// safely either way, but "we could not run the suite" is a
// problem with the platform and needs to look like one.
if let TestOutcome::CouldNotRun(why) = &o {
eprintln!(
"mission_delivery: mission {mission_id} phase {phase_id} could NOT \
run the test suite — gating as unverified: {why}"
);
}
outcome = Some(o);
}
match push_url_for(pool, mission_id).await {
Ok(Some(url)) => {
let verified = outcome.as_ref().and_then(TestOutcome::verified);
match publish_phase_branch(&repo, &url, &c.branch, gate, verified).await {
Ok(p) => published = Some(p),
// `publish_phase_branch` only returns Err for a local
// git failure; a rejected push is Ok with an error
// inside. Both must reach the artifact.
Err(e) => {
eprintln!(
"mission_delivery: mission {mission_id} phase {phase_id} \
could not publish {}: {e}",
c.branch
);
// Both ends, not the first 500 chars. Git prints its
// REASON last — "non-fast-forward", "fetch first",
// "protected branch" — so a head-only clamp keeps the
// noise and drops the answer. A real push failure was
// recorded as two auth lines plus a branch name cut
// off mid-word, with the reject reason gone.
publish_error =
Some(crate::evaluator_tools::clamp_output(&e));
}
}
}
Ok(None) => {
// Legitimate: a mission with no repo bound has nowhere to
// push. Still recorded, because "not pushed" with no reason
// is the ambiguity this whole pass exists to remove.
publish_error =
Some("mission has no repo bound; work is committed locally only".into());
}
Err(e) => {
eprintln!(
"mission_delivery: mission {mission_id} could not resolve a push \
URL ({e}) — work is committed locally on {} but not published",
c.branch
);
publish_error = Some(format!("could not resolve push URL: {e}"));
}
}
}
}
let meta = json!({
"base_sha": base_sha,
"base_recorded": base_is_recorded,
"branch": published
.as_ref()
.map(|p| p.branch.clone())
.or_else(|| committed.as_ref().map(|c| c.branch.clone())),
"head_sha": committed.as_ref().map(|c| c.sha.clone()),
"commit_policy": match gate {
Gate::Always => "always",
Gate::OnGreenTests => "on_green_tests",
Gate::OnReviewerApproval => "on_reviewer_approval",
},
// `tests_verified` keeps its original tri-state meaning for existing
// readers; `tests_status` is what distinguishes the two ways of being
// null — a repo with no suite from a runtime that could not run one.
"tests_verified": outcome.as_ref().and_then(TestOutcome::verified),
"tests_status": outcome.as_ref().map(TestOutcome::status),
"tests_detail": outcome.as_ref().and_then(TestOutcome::detail),
"pushed": published.as_ref().map(|p| p.pushed),
"push_error": published
.as_ref()
.and_then(|p| p.error.clone())
.or(publish_error),
"commit_error": commit_error,
"files_changed": files_changed,
"insertions": insertions,
"deletions": deletions,
"empty": empty,
// Non-null means `empty`/`files_changed` describe a failed read, not
// an unchanged tree. Readers that treat `empty: true` as "the phase
// did nothing" must check this first.
"diff_error": diff_error,
"truncated": truncated,
// WHICH files, not just how many. Same base_sha and the same excludes
// as `files_changed`, so the two describe the same diff.
"files": files
.iter()
.map(|(st, path)| serde_json::json!({ "status": st.to_string(), "path": path }))
.collect::<Vec<_>>(),
"files_truncated": files_truncated,
"excluded_paths": EXCLUDED_PATHS,
});
std::fs::write(
dir.join("delivery.json"),
serde_json::to_string_pretty(&meta).unwrap_or_default(),
)
.map_err(|e| format!("write delivery.json: {e}"))?;
// Path is stored relative to the MISSIONS ROOT — the convention every
// artifact uses, and what `routes::missions::artifact_content` resolves
// against. (The old `pdf_renderer` claimed to match this and did not: it
// joined the mission id first, producing a doubled id and ENOENT. It is
// gone; this comment named it as the authority, which it never was.)
let rel = format!("_outputs/{mission_id}/{phase_id}/diff.patch");
cm_db::repo::missions::register_artifact(
pool,
cm_db::repo::missions::RegisterArtifact {
mission_id,
phase_id: Some(phase_id),
path: &rel,
kind: "code_diff",
mime: Some("text/x-patch"),
title: Some(if empty {
"No code changes"
} else {
"Code changes"
}),
generated_by_run: None,
render_pdf: false,
metadata: Some(meta),
},
)
.await
.map_err(|e| format!("register code_diff artifact: {e}"))?;
eprintln!(
"mission_delivery: mission {mission_id} phase {phase_id} → {} \
(+{insertions}/-{deletions} across {files_changed} file(s)){}",
if empty { "no changes" } else { "captured" },
if truncated { ", truncated" } else { "" },
);
Ok(Some(Capture {
committed,
base_sha,
files_changed,
insertions,
deletions,
empty,
diff_error,
truncated,
files,
files_truncated,
patch_path,
}))
}
/// Run git in `repo`, returning stdout.
///
/// Every invocation carries `-c safe.directory`: under `CLAWMATES_MISSION_FS=bind`
/// the server clones as uid 65532 while agents write into the same tree as
/// root, so without it git refuses the repository outright — the failure that
/// had the phase evaluator silently falling back to guesswork. Copy mode makes
/// the tree single-uid and this redundant, but it stays while the bind path is
/// still selectable: a workaround may only be deleted once the situation it
/// works around can no longer be chosen.
async fn git(repo: &Path, args: &[&str]) -> Result<String, String> {
let repo_s = repo.display().to_string();
// Owned, not `Box::leak`. The leak was justified as "the process is
// short-lived", which is true of a CLI and false of cm-api — it is a
// long-running server, so that was one permanently leaked allocation per
// git call, growing with every phase of every mission for the life of the
// process.
let mut full: Vec<String> = vec![
"-C".into(),
repo_s.clone(),
"-c".into(),
format!("safe.directory={repo_s}"),
];
full.extend(args.iter().map(|a| (*a).to_string()));
let (name, email) = commit_identity();
let mut cmd = tokio::process::Command::new("git");
cmd.args(&full);
let out = crate::mission_workspace::no_terminal_prompt(&mut cmd)
// The server container has no git identity — `git config --global
// user.email` exits 1 — so `git commit` fails with "Author identity
// unknown" unless one is supplied. Mission `019fc450` lost its first
// phase to exactly that.
//
// This was the third failure in a row whose trigger was *agent
// behaviour rather than our code*: earlier runs committed only because
// an agent had happened to run `git config user.email` in the
// checkout, leaving a local identity the server then inherited. Config
// the agent may or may not have written is not a dependency delivery
// can hold, so the identity is supplied here on every call.
//
// Environment rather than `-c`, because these override config without
// needing a leaked string per invocation, and because they name the
// committer as the pipeline — which is the truth. The agents' own
// commits keep whatever identity they set.
.env("GIT_AUTHOR_NAME", &name)
.env("GIT_AUTHOR_EMAIL", &email)
.env("GIT_COMMITTER_NAME", &name)
.env("GIT_COMMITTER_EMAIL", &email)
.output()
.await
.map_err(|e| format!("spawn git: {e}"))?;
if !out.status.success() {
return Err(format!(
"git {} → {}: {}",
args.first().copied().unwrap_or("?"),
out.status,
// Both ends, never a head-only clamp. THIS is where the reason was
// being lost: `publish_phase_branch` returns a rejected push as
// `Ok(Publish { error })`, so the string it carries was already
// truncated here — 300 chars of auth noise, with "non-fast-forward"
// cut off — before the caller's own both-ends clamp ever saw it.
// Clamping the caller fixed the path that was already fine.
crate::mission_workspace::redact_token(&crate::evaluator_tools::clamp_output(
&String::from_utf8_lossy(&out.stderr)
))
));
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
/// Pull `(files, insertions, deletions)` out of `git diff --stat`'s summary.
///
/// The last line looks like
/// `3 files changed, 12 insertions(+), 4 deletions(-)`, with any of the three
/// clauses absent when its count is zero.
pub fn parse_diffstat(stat: &str) -> (usize, usize, usize) {
let Some(summary) = stat.lines().last() else {
return (0, 0, 0);
};
let mut files = 0;
let mut ins = 0;
let mut del = 0;
for part in summary.split(',') {
let part = part.trim();
let Some((count, rest)) = part.split_once(' ') else {
continue;
};
let Ok(n) = count.parse::<usize>() else {
continue;
};
if rest.starts_with("file") {
files = n;
} else if rest.starts_with("insertion") {
ins = n;
} else if rest.starts_with("deletion") {
del = n;
}
}
(files, ins, del)
}
/// Commit a phase's work onto a branch of its own.
///
/// Runs after capture, never before: the patch is already on disk and
/// registered, so a commit that goes wrong costs a branch and not the work.
///
/// Three rules, none of them negotiable:
///
/// - **Never the default branch.** The branch name is derived from the mission
/// and phase, so a mission can only ever add a ref nobody else owns.
/// - **Never force.** A rejected update is reported, not overwritten.
/// - **Same exclusions as capture.** Whatever was too noisy to put in a patch
/// is too noisy to put in someone's history — build output, vendored trees,
/// and the workaround files agents write when infrastructure fights them.
///
/// Returns `Ok(None)` when there is nothing to commit, which is a normal
/// outcome and not an error: the phase may have changed nothing, or the agents
/// may have committed their own work already.
pub async fn commit_phase_work(
repo: &Path,
mission_id: Uuid,
phase_id: Uuid,
iteration: i32,
) -> Result<Option<Commit>, String> {
let branch = branch_name(mission_id, phase_id, iteration);
// Work already committed by the agents still needs a branch pointing at
// it, or it is unreachable once the checkout is reaped. So the branch is
// created regardless, and only the staging step is conditional.
git(repo, &["checkout", "-B", &branch]).await?;
let mut add: Vec<&str> = vec!["add", "--", "."];
let excludes: Vec<String> = EXCLUDED_PATHS
.iter()
.map(|p| format!(":(exclude){p}"))
.collect();
add.extend(excludes.iter().map(String::as_str));
git(repo, &add).await?;
// `--cached` compares the index against HEAD: empty means the agents left
// nothing unstaged for us, which is the normal case when they committed
// themselves.
let staged = git(repo, &["diff", "--cached", "--stat"])
.await
.unwrap_or_default();
if !staged.trim().is_empty() {
let message = format!(
// The trailer is the provenance record. It matters more now that
// the author line carries a person's name: without it, autonomous
// work would be indistinguishable from hand-written commits in
// `git log`. Keep it on any change to this message.
"clawmates: phase work{}\n\
\n\
Mission: {mission_id}\n\
Phase: {phase_id}\n\
\n\
Committed by the ClawMates delivery pipeline from the agents' \
working tree. Authored by agents, not by the named committer.",
if iteration > 0 {
format!(" (pass {})", iteration + 1)
} else {
String::new()
}
);
clear_stale_commit_editmsg(repo);
git(repo, &["commit", "--no-verify", "-m", &message]).await?;
}
let sha = git(repo, &["rev-parse", "HEAD"])
.await
.map(|s| s.trim().to_string())
.unwrap_or_default();
if sha.is_empty() {
return Ok(None);
}
eprintln!(
"mission_delivery: mission {mission_id} phase {phase_id} → branch {branch} at {}",
&sha[..sha.len().min(8)]
);
Ok(Some(Commit { branch, sha }))
}
/// The branch a phase's work lands on.
///
/// Namespaced under `clawmates/` so it is obvious in a branch list who created
/// it and safe to delete in bulk.
///
/// The two segments are taken from opposite ends of the ids, and that is
/// load-bearing. Both are UUIDv7, which leads with a 48-bit timestamp, so ids
/// minted in the same millisecond share their leading hex — taking `[..8]` of
/// each produced `clawmates/mission-019fc40e-019fc40e` in production, the same
/// branch for every phase of the mission, each one silently moving the ref the
/// last phase had just set. The mission keeps its time-ordered prefix so
/// branches group and sort usefully; the phase contributes its random tail so
/// sibling phases cannot collide.
pub fn branch_name(mission_id: Uuid, phase_id: Uuid, iteration: i32) -> String {
let m = mission_id.simple().to_string();
let p = phase_id.simple().to_string();
let base = format!("clawmates/mission-{}-{}", &m[..8], &p[p.len() - 8..]);
if iteration > 0 {
format!("{base}-i{}", iteration + 1)
} else {
base
}
}
/// What a phase's `commit_policy` requires before its branch may be published.
///
/// Declared in `templates/workflows/*.toml` and merged into `mission_phases.
/// config`. Until now it had no reader at all — three recipes have been
/// carrying `commit_policy = "on_green_tests"` that did precisely nothing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Gate {
/// Publish unconditionally.
Always,
/// Publish to the mission branch only if the project's own tests pass.
OnGreenTests,
/// Publish to a review branch and wait for a human.
OnReviewerApproval,
}
impl Gate {
pub fn parse(policy: Option<&str>) -> Gate {
match policy.map(str::trim) {
Some("on_green_tests") => Gate::OnGreenTests,
Some("on_reviewer_approval") => Gate::OnReviewerApproval,
Some("always") | None | Some("") => Gate::Always,
Some(other) => {
eprintln!(
"mission_delivery: unknown commit_policy {other:?} — treating as `always`"
);
Gate::Always
}
}
}
/// The branch suffix that carries the verdict to a human.
///
/// A failed gate never discards work — it changes where the work lands.
/// Deleting a red-test branch is how you get back to the old behaviour
/// (work destroyed) with extra steps; a `-wip` branch is a thing someone
/// can look at, fix, and push properly.
pub fn branch_suffix(self, verified: Option<bool>) -> &'static str {
match (self, verified) {
(Gate::Always, _) => "",
(Gate::OnGreenTests, Some(true)) => "",
// Red, unrunnable, or no test command found — all "not proven".
(Gate::OnGreenTests, _) => "-wip",
(Gate::OnReviewerApproval, _) => "-review",
}
}
}
/// The command that runs a project's own tests, inferred from what is in the
/// tree.
///
/// Returns `None` when nothing recognisable is present, which
/// [`Gate::branch_suffix`] treats as unproven rather than as passing —
/// "we could not check" must never read as "it is fine".
pub fn discover_test_command(repo: &Path) -> Option<Vec<String>> {
let has = |f: &str| repo.join(f).exists();
if has("Cargo.toml") {
return Some(vec!["cargo".into(), "test".into(), "--quiet".into()]);
}
if has("package.json") {
let pkg = std::fs::read_to_string(repo.join("package.json")).unwrap_or_default();
// Only claim a test command when the project actually declares one;
// `npm test` on a package without a test script exits non-zero and
// would read as a red suite rather than as "nothing to run".
if pkg.contains("\"test\"") {
return Some(vec!["npm".into(), "test".into(), "--silent".into()]);
}
return None;
}
if has("pyproject.toml") || has("pytest.ini") || repo.join("tests").is_dir() {
return Some(vec!["pytest".into(), "-q".into()]);
}
None
}
/// The authenticated URL to push this mission's work to.
///
/// Built fresh from the repo row and the ambient token rather than read from
/// `.git/config`, which no longer carries credentials — the token is scrubbed
/// after clone because agents run as root in a container that mounts the
/// checkout. Building it here also means a rotated token takes effect
/// immediately instead of at the next clone.
async fn push_url_for(pool: &sqlx::PgPool, mission_id: Uuid) -> Result<Option<String>, String> {
let url: Option<String> = sqlx::query_scalar(
"SELECT r.clone_url FROM missions m JOIN repos r ON r.id = m.repo_id WHERE m.id = $1",
)
.bind(mission_id)
.fetch_optional(pool)
.await
// `.ok().flatten()` used to collapse a failed query into the same `None`
// as a mission with no repo bound, so a database fault was recorded as
// "nothing to push to" — the shape that made `commit_error` necessary.
.map_err(|e| format!("query push URL: {e}"))?
.flatten();
let Some(url) = url else { return Ok(None) };
let auth = mission_workspace::with_ambient_auth(&url);
// Fail here, not at the tty. An unauthenticated URL to OUR forge cannot
// push, and every second it survives past this point is spent producing a
// symptom that looks like something else: git asking for a username, then
// `/dev/tty: No such device or address`, then a `push_error` about auth that
// sent #55's investigation after credentials which were never the problem.
// A third-party host is left alone — ssh keys and .netrc are legitimate.
if let Some(why) = &auth.unauthenticated {
if auth.is_forge() {
return Err(format!(
"cannot authenticate the push URL for this mission — {why}. The work \
is committed locally; fix the credential and re-run delivery."
));
}
eprintln!("mission_delivery: pushing to a non-forge remote unauthenticated — {why}");
}
Ok(Some(auth.url))
}
/// Did the forge reject this push because our history diverged from the ref?
///
/// Git says this several ways depending on version and refspec, and all of them
/// mean the same thing here: the branch already exists with commits ours does not
/// contain.
fn is_non_fast_forward(err: &str) -> bool {
let e = err.to_ascii_lowercase();
e.contains("non-fast-forward")
|| e.contains("fetch first")
|| e.contains("updates were rejected")
|| (e.contains("[rejected]") && !e.contains("stale info"))
}
/// Run the gate, then push the branch if the gate allows it.
///
/// Publishing is last on purpose. By the time this runs the patch is on disk,
/// the artifact is registered and the work is committed to a local branch — so
/// every failure mode here costs a ref that did not reach the forge, and
/// nothing that was already captured.
///
/// The branch name carries the verdict. A gate that fails redirects to
/// `<branch>-wip` or `<branch>-review` and pushes it anyway: a human can
/// inspect, fix and re-push a branch, but cannot recover work that was thrown
/// away for failing a test. Deleting a red branch reproduces the old
/// behaviour — work destroyed — deliberately rather than by accident.
pub async fn publish_phase_branch(
repo: &Path,
push_url: &str,
branch: &str,
gate: Gate,
verified: Option<bool>,
) -> Result<Publish, String> {
let suffix = gate.branch_suffix(verified);
let target = format!("{branch}{suffix}");
if !suffix.is_empty() {
// Move the local ref too, so the checkout and the forge agree about
// where this work lives.
git(repo, &["branch", "-f", &target, "HEAD"]).await?;
}
// Never force. A rejected update is reported and left alone: the remote
// ref belongs to whoever set it, and overwriting it to make delivery look
// tidy is how a mission eats someone else's commit.
let refspec = format!("HEAD:refs/heads/{target}");
match git(repo, &["push", push_url, &refspec]).await {
Ok(_) => {
eprintln!("mission_delivery: pushed {target}");
Ok(Publish {
branch: target,
pushed: true,
error: None,
})
}
// #55: a mission whose checkout was re-cloned — a retry, a container
// teardown, disk loss — builds divergent history against its OWN
// deterministic branch, and every push it ever attempts is rejected.
// Before this, that was terminal: the work stayed on a local branch in a
// directory that gets reaped.
//
// The escape is a NEW ref, not `--force`. Forcing would overwrite
// whatever the earlier attempt pushed — which may be the only copy of
// that work — to make this attempt look tidy. Suffixing with the commit
// sha is deterministic (the same history always lands on the same ref),
// self-describing in a branch list, and cannot collide, since divergent
// history is by definition a different sha.
Err(e) if is_non_fast_forward(&e) => {
let sha = git(repo, &["rev-parse", "HEAD"])
.await
.map(|s| s.trim().to_string())
.unwrap_or_default();
let Some(short) = sha.get(..8) else {
eprintln!("mission_delivery: push of {target} rejected and HEAD unreadable: {e}");
return Ok(Publish {
branch: target,
pushed: false,
error: Some(e),
});
};
let alt = format!("{target}-{short}");
eprintln!(
"mission_delivery: {target} exists on the forge with history this \
checkout does not contain — pushing to {alt} instead of forcing. \
Original: {e}"
);
git(repo, &["branch", "-f", &alt, "HEAD"]).await?;
match git(repo, &["push", push_url, &format!("HEAD:refs/heads/{alt}")]).await {
Ok(_) => Ok(Publish {
branch: alt,
pushed: true,
// Not an error — the work reached the forge — but the
// redirect is a fact the operator needs, or two branches for
// one phase look like a bug rather than a rescue.
error: None,
}),
Err(e2) => Ok(Publish {
branch: alt,
pushed: false,
error: Some(format!("{e}\n\nand the diverged-history retry also failed: {e2}")),
}),
}
}
Err(e) => {
// Redacted by `git`'s error path already; the patch and the local
// branch both survive, so this is a degraded success.
eprintln!("mission_delivery: push of {target} failed: {e}");
Ok(Publish {
branch: target,
pushed: false,
error: Some(e),
})
}
}
}
/// Where a phase's work ended up, and whether the forge has it.
#[derive(Debug, Clone)]
pub struct Publish {
pub branch: String,
pub pushed: bool,
pub error: Option<String>,
}
/// Run the project's own tests to decide whether a green-tests gate is met.
///
/// `None` means "could not establish", which the gate treats as unproven. That
/// is the same fail-closed stance the phase evaluator takes, and for the same
/// reason: this codebase has repeatedly found things reporting success while
/// doing nothing, and a test suite that never ran must not license a push to a
/// mission branch.
pub async fn verify_tests(repo: &Path, container: &str) -> TestOutcome {
let Some(argv) = discover_test_command(repo) else {
return TestOutcome::NoSuite;
};
let workdir = repo.display().to_string();
let docker = match crate::container_exec::connect() {
Ok(d) => d,
Err(e) => return TestOutcome::CouldNotRun(format!("docker unreachable: {e}")),
};
match crate::container_exec::exec(&docker, container, Some(&workdir), &argv, TEST_TIMEOUT).await
{
Ok(out) => {
eprintln!(
"mission_delivery: {} → exit {:?}",
argv.join(" "),
out.exit_code
);
match out.exit_code {
Some(0) => TestOutcome::Passed,
// An unreadable status is not a pass, and it is not a red
// suite either — the command may never have started.
None => TestOutcome::CouldNotRun(format!(
"`{}` produced no exit status: {}",
argv.join(" "),
out.combined().chars().take(300).collect::<String>()
)),
Some(code) => TestOutcome::Failed(code),
}
}
Err(e) => TestOutcome::CouldNotRun(format!("exec in `{container}` failed: {e}")),
}
}
/// What happened when the gate tried to verify a phase.
///
/// This was `Option<bool>`, and collapsing four outcomes into `None` is what
/// let a missing toolchain hide for days. `clawmates-runtime` shipped without
/// `cargo`, so `verify_tests` returned `None` on every mission — identical to
/// the reading for "this repository has no test suite", which is what I
/// concluded at the time and stated in a summary. The gate behaved correctly
/// throughout (unproven is not a pass); it simply could not say *why* it was
/// unproven, so nobody could tell a repo without tests from a runtime without
/// a test runner.
///
/// Only `Passed` clears the gate. The rest differ in what an operator should
/// do about them, which is the entire reason they are separate variants.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TestOutcome {
/// The suite ran and passed.
Passed,
/// The suite ran and failed, with its exit code.
Failed(i64),
/// No test command could be discovered for this repository.
NoSuite,
/// A suite exists but could not be executed. Always an infrastructure
/// fault on our side, never a verdict about the code.
CouldNotRun(String),
}
impl TestOutcome {
/// The gate's view: `Some(true)` only when the suite actually passed.
/// Preserved so `Gate::branch_suffix` keeps its existing contract.
pub fn verified(&self) -> Option<bool> {
match self {
TestOutcome::Passed => Some(true),
TestOutcome::Failed(_) => Some(false),
TestOutcome::NoSuite | TestOutcome::CouldNotRun(_) => None,
}
}
/// Stable machine-readable label for artifact metadata.
pub fn status(&self) -> &'static str {
match self {
TestOutcome::Passed => "passed",
TestOutcome::Failed(_) => "failed",
TestOutcome::NoSuite => "no_suite",
TestOutcome::CouldNotRun(_) => "could_not_run",
}
}
/// Human-readable detail, when there is any beyond the label.
pub fn detail(&self) -> Option<String> {
match self {
TestOutcome::Passed | TestOutcome::NoSuite => None,
TestOutcome::Failed(code) => Some(format!("test command exited {code}")),
TestOutcome::CouldNotRun(why) => Some(why.clone()),
}
}
}
/// Remove a `COMMIT_EDITMSG` the agent left behind as root.
///
/// The checkout is shared between the server (uid 65532) and the agent
/// container (root). `core.sharedRepository` makes git create *objects and
/// refs* group-writable — `.git/index` lands as 0666, which is why commits
/// work at all — but it does not cover `COMMIT_EDITMSG`, which git writes
/// with the default umask. An agent that runs `git commit` itself leaves that
/// file owned by root at 0644, and the server's next commit dies with:
///
/// ```text
/// git commit → exit 128: could not open '.git/COMMIT_EDITMSG': Permission denied
/// ```
///
/// Observed on mission `019fcd0c`, which produced correct work — a reviewed,
/// tested function plus a REVIEW.md quoting a real `cargo test` summary — and
/// then delivered none of it.
///
/// Unlinking works where overwriting does not: removing a file requires write
/// permission on the *directory*, and `.git/` is owned by the server. Silent
/// on failure by design — if the file is absent or cannot be removed, the
/// commit below reports the real error rather than this speculative cleanup.
fn clear_stale_commit_editmsg(repo: &Path) {
let msg = repo.join(".git/COMMIT_EDITMSG");
if msg.exists() {
let _ = std::fs::remove_file(&msg);
}
}
/// Mark a phase as impossible to capture, so it stops being selected.
///
/// A phase whose checkout has already been reaped can never be captured. It
/// must still be recorded: the capture batch is bounded, and a row that stays
/// eligible forever occupies a slot forever. Enough of them and no live
/// mission is ever captured again — head-of-line blocking with a silent
/// failure mode, which is how this was found.
///
/// The artifact is deliberately honest about *why* it is empty. "No changes"
/// and "we lost the checkout before looking" are different facts, and an
/// operator reading the mission needs to be able to tell them apart.
pub async fn record_uncapturable(
pool: &sqlx::PgPool,
mission_id: Uuid,
phase_id: Uuid,
) -> Result<(), String> {
// Write a real file behind the artifact. `_outputs` survives teardown, so
// it is still writable even though the checkout is gone — and an artifact
// row pointing at a path with nothing behind it turns every reader into a
// 404 with no explanation.
let dir = outputs_root(mission_id).join(phase_id.to_string());
if std::fs::create_dir_all(&dir).is_ok() {
let _ = std::fs::write(
dir.join("diff.patch"),
"The mission checkout was removed before this phase's changes could be\n captured. Nothing was lost that had already been captured; this phase\n simply finished after its working tree had been reaped.\n",
);
}
let rel = format!("_outputs/{mission_id}/{phase_id}/diff.patch");
cm_db::repo::missions::register_artifact(
pool,
cm_db::repo::missions::RegisterArtifact {
mission_id,
phase_id: Some(phase_id),
path: &rel,
kind: "code_diff",
mime: Some("text/x-patch"),
title: Some("Not captured — checkout unavailable"),
generated_by_run: None,
render_pdf: false,
metadata: Some(json!({
"empty": true,
"captured": false,
"reason": "the mission checkout was removed before the diff could be captured",
})),
},
)
.await
.map(|_| ())
.map_err(|e| format!("register uncapturable marker: {e}"))
}
/// Why an empty patch must not be believed, if it must not be believed.
///
/// An empty patch has two causes that produce identical bytes: the tree really
/// did not change, or `git diff` failed and we have no idea what the tree
/// looks like. The first is an ordinary outcome; the second is a platform
/// fault. Returning `Some` for the second is what stops the fault from being
/// filed under the ordinary outcome — the recurring shape where a failure and
/// a legitimate negative share one representation.
fn untrusted_empty_reason(empty: bool, diff_error: Option<&str>) -> Option<String> {
match (empty, diff_error) {
(true, Some(why)) => Some(format!(
"not published: the diff could not be computed, so an empty patch \
cannot be trusted to mean an unchanged tree ({why})"
)),
_ => None,
}
}
#[cfg(test)]
mod changed_path_capture_tests {
/// The path list and `files_changed` must describe the SAME diff.
///
/// They come from two separate git invocations — `--stat` and
/// `--name-status`. If those are ever given different revisions or
/// different exclude pathspecs, the count and the list disagree and there
/// is no way to tell which is right: both look like plausible output.
#[test]
fn both_diff_calls_use_the_same_revision_and_excludes() {
let src = include_str!("mission_delivery.rs");
let body = src
.split("let mut stat_args")
.nth(1)
.and_then(|s| s.split("let (files_changed").next())
.expect("the capture block");
assert!(
body.contains("let mut name_args = vec![\"diff\", base_sha.as_str(), \"--name-status\", \"--\"]"),
"the name-status call must use the same base_sha as --stat"
);
assert!(
body.contains("name_args.extend(excludes.iter().map(String::as_str))"),
"and the same excludes, or files_changed and the path list describe \
different diffs"
);
}
/// `--name-status` must run before the index is put back, or newly created
/// files — which `--intent-to-add` is what makes visible — vanish from the
/// list while still being counted by the stat.
#[test]
fn paths_are_read_before_the_index_reset() {
let src = include_str!("mission_delivery.rs");
let name_at = src.find("--name-status").expect("name-status call");
let reset_at = src
.find("git(&repo, &[\"reset\", \"--quiet\"])")
.expect("index reset");
assert!(
name_at < reset_at,
"the path list must be captured while --intent-to-add is still in \
effect, or created files are invisible to it"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Git says "your history diverged" several ways, and the one production
/// actually produced (`! [rejected] ... (fetch first)`) is not the phrase
/// anyone reaches for first. Missing a phrasing means the rescue does not
/// fire and the work stays on a local branch in a directory that gets
/// reaped — silently, since the push failure is a degraded success.
#[test]
fn every_way_git_says_diverged_is_recognised() {
for e in [
"git push → exit 1: ! [rejected] HEAD -> b (fetch first)\nhint: …",
" ! [rejected] HEAD -> b (non-fast-forward)",
"hint: Updates were rejected because the remote contains work that you \
do not have locally.",
] {
assert!(is_non_fast_forward(e), "not recognised: {e}");
}
}
/// And it must not fire on failures a new branch cannot fix. Retrying a
/// permissions or network error onto a second ref just produces a second
/// failure and a confusing branch name.
#[test]
fn other_push_failures_are_not_mistaken_for_divergence() {
for e in [
"fatal: repository 'https://forge/x.git' not found",
"remote: error: GH006: Protected branch update failed",
"fatal: could not read Username for 'https://forge': terminal prompts disabled",
" ! [rejected] (stale info)",
] {
assert!(!is_non_fast_forward(e), "wrongly recognised: {e}");
}
}
// ── The gate ───────────────────────────────────────────────────────
/// Three recipes have declared `commit_policy` since they were written and
/// nothing has ever read it. The parse must at least be forgiving about an
/// unknown value rather than refusing to deliver.
#[test]
fn commit_policy_parses_the_declared_values() {
assert_eq!(Gate::parse(Some("on_green_tests")), Gate::OnGreenTests);
assert_eq!(
Gate::parse(Some("on_reviewer_approval")),
Gate::OnReviewerApproval
);
assert_eq!(Gate::parse(Some("always")), Gate::Always);
assert_eq!(Gate::parse(None), Gate::Always);
assert_eq!(Gate::parse(Some(" on_green_tests ")), Gate::OnGreenTests);
assert_eq!(
Gate::parse(Some("nonsense")),
Gate::Always,
"unknown policy still delivers"
);
}
/// A failed gate must move the work, never drop it. Deleting a red-test
/// branch reproduces the old behaviour — work destroyed — with extra steps.
#[test]
fn a_failed_gate_redirects_rather_than_discards() {
assert_eq!(Gate::OnGreenTests.branch_suffix(Some(true)), "");
assert_eq!(Gate::OnGreenTests.branch_suffix(Some(false)), "-wip");
assert_eq!(
Gate::OnReviewerApproval.branch_suffix(Some(true)),
"-review"
);
assert_eq!(
Gate::Always.branch_suffix(Some(false)),
"",
"always means always"
);
}
/// "We could not check" must not read as "it passed". An unrunnable or
/// undiscoverable test suite lands on `-wip` exactly like a red one.
#[test]
fn an_unverifiable_suite_is_not_treated_as_green() {
assert_eq!(Gate::OnGreenTests.branch_suffix(None), "-wip");
}
#[test]
fn test_command_is_discovered_from_the_tree() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(
discover_test_command(dir.path()),
None,
"nothing recognisable"
);
std::fs::write(dir.path().join("Cargo.toml"), "[package]\nname=\"x\"\n").unwrap();
assert_eq!(
discover_test_command(dir.path()),
Some(vec!["cargo".into(), "test".into(), "--quiet".into()])
);
}
/// A `package.json` with no test script must yield None, not `npm test` —
/// npm exits non-zero for a missing script, which would look like a red
/// suite instead of an absent one.
#[test]
fn a_package_without_a_test_script_yields_no_command() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("package.json"), r#"{"name":"x"}"#).unwrap();
assert_eq!(discover_test_command(dir.path()), None);
std::fs::write(
dir.path().join("package.json"),
r#"{"name":"x","scripts":{"test":"vitest"}}"#,
)
.unwrap();
assert!(discover_test_command(dir.path()).is_some());
}
#[test]
fn diffstat_summary_is_parsed() {
assert_eq!(
parse_diffstat(" src/a.rs | 2 +-\n 3 files changed, 12 insertions(+), 4 deletions(-)"),
(3, 12, 4)
);
// Clauses are omitted when zero.
assert_eq!(
parse_diffstat(" a.rs | 1 +\n 1 file changed, 1 insertion(+)"),
(1, 1, 0)
);
assert_eq!(
parse_diffstat(" a.rs | 1 -\n 1 file changed, 1 deletion(-)"),
(1, 0, 1)
);
}
/// An empty stat means an empty phase, not a parse failure. This is the
/// case that must still produce an artifact.
/// The whole point: a tree that genuinely did not change stays silent, and
/// a diff that could not be computed does not get to borrow that silence.
#[test]
fn an_uncomputable_diff_is_not_an_unchanged_tree() {
assert_eq!(
untrusted_empty_reason(true, None),
None,
"a genuinely unchanged tree must not report an error"
);
let reason = untrusted_empty_reason(true, Some("patch: fatal: bad object"))
.expect("an empty patch from a FAILED diff must be reported, not accepted");
assert!(
reason.contains("bad object"),
"the reason must name what went wrong, got: {reason}"
);
assert_eq!(
untrusted_empty_reason(false, Some("diffstat: fatal: bad object")),
None,
"a non-empty patch stands on its own even if the diffstat failed"
);
}
#[test]
fn an_empty_diffstat_is_all_zeroes() {
assert_eq!(parse_diffstat(""), (0, 0, 0));
assert_eq!(parse_diffstat("\n"), (0, 0, 0));
}
/// Build output and agent workaround debris must never reach a patch. A
/// phase that ran `cargo build` leaves a `target/` bigger than the
/// repository, and an agent that fought git's ownership check left a
/// `.gitconfig_temp` beside the real work.
#[test]
fn build_output_is_excluded() {
for p in [
"target",
"node_modules",
".venv",
"dist",
"__pycache__",
".gitconfig_temp",
] {
assert!(
EXCLUDED_PATHS.contains(&p),
"{p} must be excluded from capture"
);
}
}
/// The outputs directory must be a sibling of the per-mission directories
/// the sweeper deletes — not inside one, or teardown takes the record with
/// it, and not inside a bind mount, or agents can edit their own evidence.
#[test]
fn outputs_live_outside_the_swept_mission_directory() {
let mission = Uuid::now_v7();
let out = outputs_root(mission);
let swept = mission_workspace::checkout_path(mission);
assert!(
!out.starts_with(swept.parent().unwrap()),
"outputs must not sit under the directory teardown removes"
);
assert!(out.to_string_lossy().contains("_outputs"));
}
}