fix(missions): close the three seams behind this run of failures
ci / gates (push) Failing after 6s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped

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:
Omar Sobh
2026-08-02 18:58:45 -07:00
co-authored by Claude Opus 5
parent bb34ef1b7e
commit ec85f6c8da
6 changed files with 415 additions and 19 deletions
+93 -1
View File
@@ -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));
}
}