//! Per-mission repo checkout. //! //! Missions execute their coding / benchmark / security phases against //! a filesystem checkout of `missions.repo_id` at //! `$CLAWMATES_MISSIONS_ROOT/{mission_id}/repo`. That path is what //! `security_scan::exec_target` + `benchmark_runner::exec_target` //! both `docker exec -w` into. //! //! `ensure_checkout` is called from `mission_orchestrator::on_launch` //! and is idempotent: //! - no repo_id → no-op (Ok(None)) //! - dir already a git repo → `fetch + reset --hard origin/` //! to bring it in sync //! - dir missing → `git clone --depth 1 ` //! //! Auth: for `git.redclaw.dev` clones we inject the ambient //! `GITEA_TOKEN` (already provisioned in the server container's env) //! into the clone URL as basic-auth. For any other host we fall back //! to the ambient credential setup (SSH agent, .netrc, git helper) — //! prod hosts run with those configured. Tokens are never logged. use std::path::PathBuf; use tokio::process::Command; use uuid::Uuid; pub(crate) fn missions_root() -> PathBuf { std::env::var("CLAWMATES_MISSIONS_ROOT") .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from("/var/lib/clawmates-missions")) } pub fn checkout_path(mission_id: Uuid) -> PathBuf { missions_root().join(mission_id.to_string()).join("repo") } /// Ensure the mission's repo is checked out at `checkout_path`. /// Returns Ok(None) when the mission has no repo bound, Ok(Some(path)) /// when a checkout is in place (freshly cloned or brought up-to-date). pub async fn ensure_checkout( pool: &sqlx::PgPool, workspace_id: cm_domain::WorkspaceId, mission_id: Uuid, ) -> Result, String> { let mission = cm_db::repo::missions::get(pool, mission_id, workspace_id.as_uuid()) .await .map_err(|e| format!("load mission: {e}"))? .ok_or_else(|| "mission not found".to_string())?; let Some(repo_id) = mission.repo_id else { return Ok(None); }; let repo = cm_db::repo::repos::get(pool, repo_id, workspace_id) .await .map_err(|e| format!("load repo {repo_id}: {e}"))?; let clone_url = repo .clone_url .as_deref() .ok_or_else(|| format!("repo {repo_id} has no clone_url"))?; let default_branch = repo.default_branch.as_deref().unwrap_or("main"); let path = checkout_path(mission_id); if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent) .await .map_err(|e| format!("mkdir {}: {e}", parent.display()))?; } let auth_url = with_ambient_auth(clone_url); if path.join(".git").exists() { // Checkouts cloned before this setting existed get it on reuse. It // governs objects created from now on, which is what delivery needs. share_repository_across_uids(&path); // `ensure_checkout` runs at every phase launch, not once per mission. // Freshening a pristine checkout is right; freshening one that already // holds this mission's work destroys it. See `has_local_work`. // Marker first: it is a fact we recorded, not a state we inferred. // The tree checks stay as a second line of defence for checkouts // created before the marker existed, and for the case where the // marker write itself failed. if checkout_in_use(&path) || has_local_work(&path, default_branch) { eprintln!( "mission_workspace: {} already holds mission work — skipping \ fetch/reset so earlier phases' output survives", path.display() ); } else { fetch_and_reset(&path, default_branch, &auth_url).await?; } } else { clone(&path, &auth_url).await?; } Ok(Some(path)) } /// If the URL points at git.redclaw.dev AND GITEA_TOKEN is set in the /// environment, rewrite it to include the token as basic-auth. Returns /// the URL unchanged otherwise. The token is never logged (we only /// pass the rewritten URL into `git clone` via argv). pub(crate) fn with_ambient_auth(url: &str) -> String { let Ok(token) = std::env::var("GITEA_TOKEN") else { return url.to_string(); }; if token.is_empty() { return url.to_string(); } if let Some(rest) = url.strip_prefix("https://git.redclaw.dev/") { return format!("https://oauth2:{token}@git.redclaw.dev/{rest}"); } url.to_string() } async fn clone(path: &std::path::Path, url: &str) -> Result<(), String> { // `--filter=blob:none` rather than `--depth 1`. A shallow clone cannot // usually push a new branch back ("shallow update not allowed"), and // mission delivery needs exactly that. A partial clone keeps full history // — so the base commit stays meaningful and a diff has something to be // relative to — while fetching file contents only on demand, which is // nearly as cheap as a shallow clone for a repo that gets read once. let out = Command::new("git") .args([ "clone", "--filter=blob:none", "--single-branch", url, &path.display().to_string(), ]) .output() .await .map_err(|e| format!("spawn git clone: {e}"))?; if !out.status.success() { return Err(format!( "git clone → exit {}: {}", out.status, redact_token(&String::from_utf8_lossy(&out.stderr)) .chars() .take(400) .collect::() )); } share_repository_across_uids(path); scrub_remote_credentials(path, url); ignore_agent_scaffolding(path); record_base_commit(path); Ok(()) } /// Record that a phase has started working in this checkout. /// /// The explicit half of the "is this checkout in use" question. `ensure_checkout` /// runs per phase launch and refreshes on reuse; whether that refresh is safe /// depends on whether a phase has already run here, which is a fact about the /// *mission* and not about the tree. /// /// It was previously inferred from the tree — dirty status, HEAD versus the /// remote tip — and inference is what made delivery depend on what an agent /// happened to do. Mission `019fc444` lost work because its phase committed and /// left a clean tree; `019fc476` lost work because the capture base had advanced /// to match HEAD; `019fc450` survived only because a phase *failed* to commit /// and left the tree dirty. Same code, opposite outcomes, decided by the agent. /// /// A marker is not a heuristic. Once a phase has begun, the checkout is in use /// until the mission ends, whatever the agent did or did not do inside it. pub(crate) fn mark_phase_started(path: &std::path::Path) { let marker = path.join(".git/clawmates-in-use"); if marker.exists() { return; } if let Err(e) = std::fs::write(&marker, "1\n") { eprintln!( "mission_workspace: could not mark {} as in use ({e}) — a later phase may \ refresh the checkout and discard earlier work", path.display() ); } } /// Has a phase already started work in this checkout? fn checkout_in_use(path: &std::path::Path) -> bool { path.join(".git/clawmates-in-use").exists() } /// Has anything happened in this checkout since it was created? /// /// `ensure_checkout` is called once per *phase launch*, not once per mission, /// and its reuse path runs `git reset --hard origin/`. That is correct /// for a checkout being picked up cold and destructive for one mid-mission: /// mission `019fc444` had its phase-0 file deleted from the working tree when /// phase 1 started, so the second phase never saw the first's output. /// /// Delivery is what made this reachable. Before the mission branch existed, /// agent output stayed *untracked* and `reset --hard` left it alone. Committing /// it — the whole point of the delivery slice — makes it tracked, and tracked /// files that are absent from `origin/` are exactly what a hard reset /// removes. The feature that preserves work is what put it in reach of the /// reset. /// /// "Local work" is either a commit that is not on the fetched tip, or a dirty /// tree. Both are checked because the two phases of the failure look different: /// an agent that committed leaves a clean tree at a new HEAD, and one that did /// not leaves a dirty tree at the old HEAD. fn has_local_work(path: &std::path::Path, branch: &str) -> bool { let git = |args: &[&str]| -> Option { let out = std::process::Command::new("git") .arg("-C") .arg(path) .args(["-c", &format!("safe.directory={}", path.display())]) .args(args) .output() .ok()?; out.status .success() .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string()) }; // A dirty tree is unambiguous: someone is mid-work here. if let Some(status) = git(&["status", "--porcelain"]) { if !status.is_empty() { return true; } } // Otherwise compare HEAD against the *remote tip*, which is the only // fixed point here. // // This deliberately does not use `.git/clawmates-base`. That marker is the // rolling capture base and `advance_base_commit` moves it to each phase's // committed head — so comparing HEAD against it asks "did anything happen // since the last commit we made", which is false immediately after every // successful delivery. Mission `019fc476` lost phase 0's file exactly that // way: phase 0 committed, the base advanced to match HEAD, and phase 1's // launch concluded the checkout was pristine and reset it. The preceding // mission survived only because its phase 0 *failed* to commit and left a // dirty tree. // // `origin/` does not move for the life of the mission, so "HEAD is // not the remote tip" means a phase committed, whether one commit ago or // five. If the remote ref cannot be resolved the answer is preserve: // wrongly skipping a refresh costs staleness, wrongly resetting destroys a // phase's output. match ( git(&["rev-parse", &format!("origin/{branch}")]), git(&["rev-parse", "HEAD"]), ) { (Some(tip), Some(head)) => tip != head, _ => true, } } /// Let the server and the agent container both write to this checkout. /// /// The checkout is one directory bind-mounted into two processes running as /// different users: cm-api is uid 65532, the mission runtime container is /// root. Git creates `.git/objects/xx/` fan-out directories on first write and /// they inherit the writer's ownership, so whichever party commits first locks /// the other out of that directory: /// /// ```text /// git add → exit 128: insufficient permission for adding an object /// to repository database .git/objects /// ``` /// /// The failure is intermittent, which is what makes it dangerous. Mission /// `019fc42b` delivered cleanly because its agents committed their own work, /// so the blobs already existed and the server's `git add` never had to write /// one. Mission `019fc437` ran the same template, its agents left the work /// uncommitted, and delivery lost both phases. /// /// `core.sharedRepository` is git's own answer to a repository shared between /// users: it makes git create objects and refs group- and world-writable. Both /// parties read this config from the shared `.git/config`, so it governs the /// agent's commits as much as ours. /// /// This grants the agent no access it lacks. It is already root inside a /// container with the entire checkout bind-mounted read-write, and could /// rewrite any of it. The party actually gaining something is the server, /// which is currently the one being locked out. pub fn share_repository_across_uids(path: &std::path::Path) { let out = std::process::Command::new("git") .args([ "-C", &path.display().to_string(), "-c", &format!("safe.directory={}", path.display()), "config", "core.sharedRepository", "0777", ]) .output(); match out { Ok(o) if o.status.success() => {} Ok(o) => eprintln!( "mission_workspace: could not set core.sharedRepository on {} ({}) — delivery \ may fail to commit if the agent writes git objects first", path.display(), String::from_utf8_lossy(&o.stderr).trim() ), Err(e) => eprintln!( "mission_workspace: could not set core.sharedRepository on {} ({e})", path.display() ), } } /// 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() ); } } /// Move the capture base forward to a commit a phase just produced. /// /// The base is recorded once at clone time, which is right for the mission's /// first phase and wrong for every phase after it: a later phase would diff /// against the original clone point and claim its predecessors' commits as its /// own work. Mission `019fc42b` showed this plainly — two coding phases, and /// the second phase's artifact reported the *union* of both phases' files. /// /// Advancing after each successful commit makes each artifact the incremental /// work of one phase. The pushed branch stays cumulative, because it is built /// from `HEAD` and therefore still carries the earlier commits. pub(crate) fn advance_base_commit(path: &std::path::Path, sha: &str) { let sha = sha.trim(); 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 advance base commit for {} ({e}) — the next \ phase will re-report this phase's work as its own", path.display() ); } } /// The commit this mission's checkout started from, if it was recorded. pub(crate) fn base_commit(path: &std::path::Path) -> Option { 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 /// authenticate, and git then persists that URL verbatim as the `origin` /// remote. The checkout is bind-mounted into a container the agents run in as /// **root**, so the token sits in a file every mission agent can read, and it /// reaches every repository that token reaches — not just this one. /// /// Rewriting the remote to the bare URL costs one command and removes a /// standing credential from the blast radius of any prompt injection that /// lands in a mission. Delivery does not depend on the stored URL: it builds a /// fresh authenticated URL at push time, which also means a rotated token /// starts working immediately instead of after the next clone. /// /// Best-effort and non-fatal: a checkout that keeps its token still works, and /// failing the mission over it would trade a real capability for a marginal /// improvement in a situation we have already logged. pub(crate) fn scrub_remote_credentials(path: &std::path::Path, original_url: &str) { if !original_url.contains('@') && !original_url.contains("oauth2:") { // Nothing was injected (SSH remote, or no token configured). return; } let bare = strip_credentials(original_url); let out = std::process::Command::new("git") .args([ "-C", &path.display().to_string(), "remote", "set-url", "origin", &bare, ]) .output(); match out { Ok(o) if o.status.success() => {} Ok(o) => eprintln!( "mission_workspace: could not scrub credentials from {} — the access token \ remains readable in .git/config: {}", path.display(), redact_token(&String::from_utf8_lossy(&o.stderr)) .chars() .take(200) .collect::() ), Err(e) => eprintln!( "mission_workspace: could not scrub credentials from {} ({e}) — the access \ token remains readable in .git/config", path.display() ), } } /// `https://user:secret@host/path` → `https://host/path`. fn strip_credentials(url: &str) -> String { let Some((scheme, rest)) = url.split_once("://") else { return url.to_string(); }; match rest.split_once('@') { // Only the *authority* may carry credentials; an `@` later in the path // is an ordinary character and must not be treated as a separator. Some((userinfo, host_and_path)) if !userinfo.contains('/') => { format!("{scheme}://{host_and_path}") } _ => url.to_string(), } } /// Files the agent runtime writes into its own workspace, which is pinned to /// the repository root (`MissionRuntimeProvisioner::pin_agent_workspaces`). /// /// They are the agent's identity scaffolding, not the user's code — `SOUL.md` /// opens "Who You Are / You're not a chatbot." Observed on mission 019fc058, /// where all seven appeared as untracked files in a freshly cloned repo. const AGENT_SCAFFOLDING: &[&str] = &[ "AGENTS.md", "HEARTBEAT.md", "IDENTITY.md", "MEMORY.md", "SOUL.md", "TOOLS.md", "USER.md", ]; /// Keep the agent's own scaffolding out of the user's repository. /// /// Two things went wrong without this. Every mission's tree was permanently /// dirty, so a `done_when` written about a clean tree could never pass. And /// once mission delivery starts committing, `git add -A` would have put the /// agent's `SOUL.md` and `MEMORY.md` into someone's repository and pushed /// them. /// /// Written to `.git/info/exclude` rather than `.gitignore`: the exclude file /// is local to this checkout and never itself appears as a change, so the /// repository the user gets back is untouched. Crucially it only suppresses /// *untracked* files — a repo that genuinely tracks its own `AGENTS.md` still /// reports modifications to it, which is the behaviour we want. /// /// Best-effort: a checkout that cannot be annotated is noisier, not broken. fn ignore_agent_scaffolding(path: &std::path::Path) { let exclude = path.join(".git/info/exclude"); let mut body = std::fs::read_to_string(&exclude).unwrap_or_default(); if body.contains("clawmates: agent scaffolding") { return; } body.push_str("\n# clawmates: agent scaffolding — written by the runtime into its\n"); body.push_str("# pinned workspace, never part of the repository.\n"); for name in AGENT_SCAFFOLDING { body.push_str(&format!("/{name}\n")); } if let Some(dir) = exclude.parent() { let _ = std::fs::create_dir_all(dir); } if let Err(e) = std::fs::write(&exclude, body) { eprintln!( "mission_workspace: could not write {} ({e}) — agent scaffolding will show as \ untracked in this checkout", exclude.display() ); } } pub(crate) fn redact_token(s: &str) -> String { // Strip any "oauth2:@" segment that git may echo back on // failures. Belt-and-braces: also nuke any raw token env value. let mut out = s.to_string(); if let Some(pos) = out.find("oauth2:") { if let Some(at) = out[pos..].find('@') { out.replace_range(pos..pos + at, "oauth2:***"); } } if let Ok(t) = std::env::var("GITEA_TOKEN") { if !t.is_empty() { out = out.replace(&t, "***"); } } out } async fn fetch_and_reset( path: &std::path::Path, branch: &str, auth_url: &str, ) -> Result<(), String> { // A checkout cloned before delivery existed is shallow, and a shallow repo // cannot push a new branch. Deepen it once, here, rather than discovering // the problem at push time when there is work on the line. `--unshallow` // errors on a repo that is already complete, so it is only attempted when // the marker file is present. if path.join(".git/shallow").exists() { let deepen = Command::new("git") .args([ "-C", &path.display().to_string(), "fetch", "--unshallow", auth_url, ]) .output() .await; match deepen { Ok(o) if o.status.success() => {} Ok(o) => eprintln!( "mission_workspace: could not deepen shallow checkout at {} — a delivery \ push may be rejected: {}", path.display(), redact_token(&String::from_utf8_lossy(&o.stderr)) .chars() .take(200) .collect::() ), Err(e) => eprintln!( "mission_workspace: could not deepen shallow checkout at {} ({e})", path.display() ), } } // Fetch from an explicitly authenticated URL rather than the stored // remote. `scrub_remote_credentials` strips the token out of // `.git/config` — the checkout is readable by agents running as root — // so `git fetch origin` has no credentials and fails with // "could not read Username". Building the URL here also means a rotated // token takes effect immediately instead of at the next clone. let fetch = Command::new("git") .args(["-C", &path.display().to_string(), "fetch", auth_url, branch]) .output() .await .map_err(|e| format!("spawn git fetch: {e}"))?; if !fetch.status.success() { return Err(format!( "git fetch origin {branch} → exit {}: {}", fetch.status, redact_token(&String::from_utf8_lossy(&fetch.stderr)) .chars() .take(400) .collect::() )); } let reset = Command::new("git") .args([ "-C", &path.display().to_string(), "reset", "--hard", &format!("origin/{branch}"), ]) .output() .await .map_err(|e| format!("spawn git reset: {e}"))?; if !reset.status.success() { return Err(format!( "git reset --hard origin/{branch} → exit {}: {}", reset.status, redact_token(&String::from_utf8_lossy(&reset.stderr)) .chars() .take(400) .collect::() )); } // 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(()) } #[cfg(test)] mod tests { use super::*; /// The exclude must be idempotent — `ensure_checkout` re-runs on every /// phase, and appending the same block each time would grow the file /// without bound. #[test] fn scaffolding_exclusion_is_written_once() { let dir = tempfile::tempdir().unwrap(); std::fs::create_dir_all(dir.path().join(".git/info")).unwrap(); ignore_agent_scaffolding(dir.path()); let first = std::fs::read_to_string(dir.path().join(".git/info/exclude")).unwrap(); assert!( first.contains("/SOUL.md"), "the agent's identity file is excluded" ); assert!(first.contains("/MEMORY.md")); ignore_agent_scaffolding(dir.path()); let second = std::fs::read_to_string(dir.path().join(".git/info/exclude")).unwrap(); assert_eq!(first, second, "re-running must not append a second block"); } #[test] fn credentials_are_stripped_from_a_remote_url() { assert_eq!( strip_credentials("https://oauth2:secret123@git.redclaw.dev/o/r.git"), "https://git.redclaw.dev/o/r.git" ); // No credentials: unchanged. assert_eq!( strip_credentials("https://git.redclaw.dev/o/r.git"), "https://git.redclaw.dev/o/r.git" ); // SSH form has no `://` authority to rewrite. assert_eq!( strip_credentials("git@github.com:o/r.git"), "git@github.com:o/r.git" ); // An `@` inside the path is not a credential separator. assert_eq!( strip_credentials("https://host/scope/@org/pkg.git"), "https://host/scope/@org/pkg.git" ); } /// An existing exclude file belongs to the repository; keep it. #[test] fn an_existing_exclude_is_preserved() { let dir = tempfile::tempdir().unwrap(); std::fs::create_dir_all(dir.path().join(".git/info")).unwrap(); std::fs::write(dir.path().join(".git/info/exclude"), "/local-scratch\n").unwrap(); ignore_agent_scaffolding(dir.path()); let body = std::fs::read_to_string(dir.path().join(".git/info/exclude")).unwrap(); assert!( body.contains("/local-scratch"), "pre-existing rules survive" ); assert!(body.contains("/AGENTS.md")); } /// Seed a checkout that has an `origin`, like a real clone does. Without /// one `origin/` does not resolve and `has_local_work` takes its /// preserve-by-default path, which would make the pristine case untestable. fn seed(dir: &std::path::Path, remote: &std::path::Path) { std::process::Command::new("git") .args(["init", "--quiet", "--bare"]) .arg(remote) .output() .unwrap(); let g = |args: &[&str]| { std::process::Command::new("git") .arg("-C") .arg(dir) .args(args) .output() .unwrap(); }; g(&["init", "--quiet"]); g(&["config", "user.email", "t@clawmates.local"]); g(&["config", "user.name", "T"]); g(&["checkout", "-q", "-B", "main"]); std::fs::write(dir.join("README.md"), "# base\n").unwrap(); g(&["add", "."]); g(&["commit", "--quiet", "-m", "base"]); g(&["remote", "add", "origin", &remote.display().to_string()]); g(&["push", "--quiet", "origin", "main"]); g(&["fetch", "--quiet", "origin", "main"]); record_base_commit(dir); } /// A checkout mid-mission must not be mistaken for a cold one. /// /// `ensure_checkout` runs per phase launch and resets on the reuse path. /// Mission `019fc444` lost phase 0's committed file that way. The fix then /// failed again on mission `019fc476` for a different reason, which the /// last case here pins down. #[test] fn local_work_is_recognized_before_a_checkout_is_reset() { let tmp = tempfile::tempdir().unwrap(); let repo = &tmp.path().join("repo"); std::fs::create_dir_all(repo).unwrap(); seed(repo, &tmp.path().join("remote.git")); let repo = repo.as_path(); assert!( !has_local_work(repo, "main"), "a freshly cloned checkout has no work and may be refreshed" ); // An agent that wrote files and did not commit: dirty tree, HEAD put. std::fs::write(repo.join("ALPHA.md"), "ALPHA\n").unwrap(); assert!(has_local_work(repo, "main"), "uncommitted agent output is work"); // An agent (or delivery) that committed: clean tree, HEAD moved. This // is the shape that was destroyed on 019fc444, because a hard reset // leaves untracked files alone but removes tracked ones. git_in(repo, &["add", "ALPHA.md"]); git_in(repo, &["commit", "--quiet", "-m", "phase 0"]); let status = std::process::Command::new("git") .arg("-C") .arg(repo) .args(["status", "--porcelain"]) .output() .unwrap(); assert!( String::from_utf8_lossy(&status.stdout).trim().is_empty(), "the commit left a clean tree — the case a dirty-tree check misses" ); assert!( has_local_work(repo, "main"), "committed phase output must not be reset away" ); // The regression from 019fc476. Delivery advances the capture base to // the commit it just made, so any check comparing HEAD against that // base reports "nothing happened" the instant a phase succeeds — and // the next phase resets the work away. Advancing it here is what makes // this a real reproduction rather than a restatement of the case above. let head = std::process::Command::new("git") .arg("-C") .arg(repo) .args(["rev-parse", "HEAD"]) .output() .unwrap(); let head = String::from_utf8_lossy(&head.stdout).trim().to_string(); advance_base_commit(repo, &head); assert_eq!( base_commit(repo).as_deref(), Some(head.as_str()), "the base now equals HEAD, which is the trap" ); assert!( has_local_work(repo, "main"), "a phase that committed successfully must still count as work \ after the capture base advances to match its commit" ); } fn git_in(dir: &std::path::Path, args: &[&str]) { std::process::Command::new("git") .arg("-C") .arg(dir) .args(args) .output() .unwrap(); } /// A checkout in use must be recognised regardless of what the agent did. /// /// This is the Seam-1 property. The tree-state heuristics were each correct /// in isolation and each blind to a different case: `019fc444` committed /// and left a clean tree, `019fc476` had its base advanced to match HEAD, /// `019fc450` survived only because a phase FAILED to commit. Whether the /// work survived was decided by the agent, not by us. /// /// The marker is set when a phase launches, before the agent does anything, /// so every one of those states answers the same way. #[test] fn an_in_use_checkout_is_recognized_whatever_the_agent_did() { let tmp = tempfile::tempdir().unwrap(); let repo = &tmp.path().join("repo"); std::fs::create_dir_all(repo).unwrap(); seed(repo, &tmp.path().join("remote.git")); let repo = repo.as_path(); assert!(!checkout_in_use(repo), "a fresh clone is not in use"); mark_phase_started(repo); assert!(checkout_in_use(repo), "a launched phase marks the checkout"); // The three production states, all of which must now answer the same. // (a) agent wrote nothing at all — the case every tree heuristic misses. assert!(checkout_in_use(repo), "clean tree at the base commit"); // (b) agent committed, leaving a clean tree at a moved HEAD. std::fs::write(repo.join("WORK.md"), "work\n").unwrap(); git_in(repo, &["add", "WORK.md"]); git_in(repo, &["commit", "--quiet", "-m", "phase work"]); let head = std::process::Command::new("git") .arg("-C") .arg(repo) .args(["rev-parse", "HEAD"]) .output() .unwrap(); let head = String::from_utf8_lossy(&head.stdout).trim().to_string(); assert!(checkout_in_use(repo)); // (c) capture advanced the base to match HEAD — the collision that // defeated the HEAD-versus-base check on 019fc476. advance_base_commit(repo, &head); assert!( checkout_in_use(repo), "an advanced base must not make an in-use checkout look pristine" ); // Marking twice is safe; phases launch repeatedly across a mission. mark_phase_started(repo); assert!(checkout_in_use(repo)); } }