fix(missions): close the three seams behind this run of failures
Seam 1 — delivery inferred checkout state from the tree, so whether work survived depended on what the agent happened to do. 019fc444 committed and left a clean tree; 019fc476 had its base 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. mark_phase_started records the fact at phase launch, before the agent acts, so every one of those states answers identically. The tree checks remain as a second line of defence for pre-existing checkouts. Seam 2 — phase config was accepted, stored and read by nobody. That was `task`: every phase of every mission got identical instructions. The new phase_config registry names the reader for each live key and lists the eight that are declared-but-unimplemented, reporting both at mission creation so an author sees what will not happen. Its CI test found one I had missed: security_hardening.toml sets phase-level mcp_bundles asking for gitea_forge + security_scan, but bundles come from the TEAM template and the phase gets neither. Seam 4 — push_url_for collapsed a failed query, an unbound repo and a missing clone_url into one None, so a database fault was recorded as "nothing to push to" and metadata read `pushed: null, push_error: null` — the same ambiguity commit_error already fixed. Each case now carries its reason into the artifact, and a local git failure during publish is recorded rather than dropped by .ok(). Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bb34ef1b7e
commit
ec85f6c8da
@@ -73,7 +73,11 @@ pub async fn ensure_checkout(
|
||||
// `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`.
|
||||
if has_local_work(&path, default_branch) {
|
||||
// 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",
|
||||
@@ -140,6 +144,41 @@ async fn clone(path: &std::path::Path, url: &str) -> Result<(), String> {
|
||||
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,
|
||||
@@ -729,4 +768,57 @@ mod tests {
|
||||
.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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user