test(orphans): prove the sweep against real containers, both directions

`sweep_orphans` force-removes containers and had never run against a
daemon — only its pure decision logic was covered. The two Docker-touching
seams are exactly the ones worth exercising for real: what it can see, and
whether a checkout holds work no remote has.

Three fixtures, three outcomes, one sweep:

  - unpushed commits, no remote ref  → SURVIVES
  - every commit on a remote ref     → reaped
  - inside the grace window          → survives anyway

Negative-controlled: making `unpushed_commits` return `None` for a dirty
checkout fails with "the probe said a checkout with an unpushed commit
holds nothing — this is the exact answer that destroys work".

Two real hazards the test surfaced, neither of them in the sweep:

1. **The tests raced each other.** The sweep is global — it reaps every
   orphaned mission container on the daemon, including fixtures another
   test in this file just started. A `FIXTURES` mutex serialises them.
   Found the honest way: the reap test deleted the listing test's fixture
   and the listing test reported a container it could not see.

2. **The test destroyed real local state.** The sweep asks the DATABASE
   whether a container is known, and `test_pool()` knows nothing — so on
   a developer machine it classified the live stack's mission containers
   as orphans and reaped two of them on the first run. `adopt_existing`
   now gives every pre-existing mission container a row before sweeping,
   which makes the test safe AND covers the one case the other
   assertions missed: a container the platform still knows about is never
   touched.

   Negative-controlled both ways with a bystander container: without
   adoption REAPED, with adoption SURVIVED.

Skips cleanly with no Docker, so a runner without one reports "not run"
rather than failing — the placeholder-as-result shape
`scripts/verify-mission-delivery.sh` was written to avoid.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
This commit is contained in:
Omar Sobh
2026-08-21 12:26:23 -07:00
co-authored by Claude Opus 5
parent 42e014976d
commit 4b160c5a1b
2 changed files with 332 additions and 1 deletions
+331
View File
@@ -0,0 +1,331 @@
//! The orphan sweep's two Docker-touching seams, against real containers.
//!
//! `sweep_orphans` force-removes containers. Its decision logic is pure and
//! unit-tested in `mission_runtime`, but the two calls that talk to Docker —
//! "which containers exist" and "does this checkout hold work no remote has" —
//! had never run against a daemon. Those are exactly the ones worth exercising
//! for real: the first decides what is considered at all, and the second is the
//! only thing standing between a reaper and ten unpushed commits.
//!
//! That is not hypothetical. The orphan that motivated this sweep held
//! +3451/-30 across 30 files on a branch that existed nowhere else.
//!
//! Skips cleanly when there is no Docker, so a machine or CI runner without one
//! reports "not run" rather than failing.
use cm_api::mission_runtime::{container_name, MissionRuntimeProvisioner, UnpushedWork};
use uuid::Uuid;
/// The image is already local on any machine that runs missions, and it has
/// `git`, which the probe needs.
const FIXTURE_IMAGE: &str = "clawmates-runtime:hooks";
/// These tests must not run at the same time as each other.
///
/// `sweep_orphans` is global: it reaps EVERY orphaned `cm-runtime-mission-*`
/// container on the daemon, which on a parallel test runner includes the
/// fixtures another test in this file just started. That is not a flaw in the
/// sweep — it is what a sweep is — but it means anything here that creates a
/// mission-shaped container has to hold this lock.
///
/// Found the honest way: the reap test deleted the listing test's fixture
/// mid-run and the listing test reported a container it could not see.
static FIXTURES: std::sync::LazyLock<tokio::sync::Mutex<()>> =
std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
fn docker_available() -> bool {
std::process::Command::new("docker")
.args(["image", "inspect", FIXTURE_IMAGE])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
/// Start a fixture container named like a mission runtime, running a shell
/// script that leaves `/mission/repo` in a known state.
fn start_fixture(id: Uuid, setup: &str) -> String {
let name = container_name(id);
let _ = std::process::Command::new("docker")
.args(["rm", "-f", &name])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
let script = format!("{setup}\nsleep 3600");
let out = std::process::Command::new("docker")
.args([
"run", "-d", "--name", &name, "--entrypoint", "sh", FIXTURE_IMAGE, "-c", &script,
])
.output()
.expect("docker run");
assert!(
out.status.success(),
"could not start fixture {name}: {}",
String::from_utf8_lossy(&out.stderr)
);
// The script has to have finished its git work before the probe runs.
std::thread::sleep(std::time::Duration::from_secs(3));
name
}
fn remove(name: &str) {
let _ = std::process::Command::new("docker")
.args(["rm", "-f", name])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
const GIT_INIT: &str = "set -e
mkdir -p /mission/repo && cd /mission/repo
git init -q .
git config user.email t@t && git config user.name t
echo hello > a.txt && git add a.txt && git commit -qm 'work nobody else has'";
#[tokio::test]
async fn the_sweep_can_see_containers_and_refuses_the_ones_holding_work() {
if !docker_available() {
eprintln!("orphan_sweep: no docker or no {FIXTURE_IMAGE} — not run");
return;
}
let Some(prov) = MissionRuntimeProvisioner::from_env() else {
eprintln!("orphan_sweep: no docker connection — not run");
return;
};
let _serial = FIXTURES.lock().await;
let dirty_id = Uuid::now_v7();
let clean_id = Uuid::now_v7();
let empty_id = Uuid::now_v7();
// Commits, and no remote ref anywhere: this is the container that must
// survive. It is the one the real orphan looked like.
let dirty = start_fixture(dirty_id, GIT_INIT);
// The same repo, but every commit is reachable from a remote-tracking ref,
// which is what "already pushed" looks like to `git rev-list --not
// --remotes`.
let clean = start_fixture(
clean_id,
&format!("{GIT_INIT}\ngit update-ref refs/remotes/origin/main HEAD"),
);
// No checkout at all — nothing to lose.
let empty = start_fixture(empty_id, "set -e\nmkdir -p /root");
let result = async {
let names = prov.list_mission_containers().await?;
let found: Vec<&String> = names.iter().map(|(n, _)| n).collect();
for expected in [&dirty, &clean, &empty] {
assert!(
found.iter().any(|n| *n == expected),
"the sweep cannot see {expected}; a container it cannot list is \
one it can never reap, which is the whole defect this closes. \
saw: {found:?}"
);
}
// Docker's own creation timestamp must come back, or the sweep declines
// to reap for want of an age.
let (_, created) = names
.iter()
.find(|(n, _)| n == &dirty)
.expect("dirty in listing");
assert!(
MissionRuntimeProvisioner::container_age(*created).is_some(),
"a container docker will not date is never reaped, so an absent \
timestamp here would silently disable the sweep"
);
match prov.unpushed_commits(&dirty).await {
UnpushedWork::SomeOrUnknown(why) => {
assert!(why.contains("no remote"), "{why}");
}
UnpushedWork::None => panic!(
"the probe said a checkout with an unpushed commit holds nothing — \
this is the exact answer that destroys work"
),
}
assert_eq!(
prov.unpushed_commits(&clean).await,
UnpushedWork::None,
"every commit is reachable from a remote ref, so there is nothing to lose"
);
assert_eq!(
prov.unpushed_commits(&empty).await,
UnpushedWork::None,
"no /mission/repo at all means nothing to lose"
);
Ok::<(), String>(())
}
.await;
remove(&dirty);
remove(&clean);
remove(&empty);
result.expect("orphan sweep probes");
}
/// A container we cannot question is not a container we may delete.
#[tokio::test]
async fn a_container_that_is_gone_reads_as_holding_work() {
if !docker_available() {
eprintln!("orphan_sweep: no docker — not run");
return;
}
let Some(prov) = MissionRuntimeProvisioner::from_env() else {
return;
};
match prov
.unpushed_commits("cm-runtime-mission-does-not-exist-at-all")
.await
{
UnpushedWork::SomeOrUnknown(_) => {}
UnpushedWork::None => panic!(
"an unanswerable probe must never read as 'safe to delete' — every \
failure path in this check is one-sided for that reason"
),
}
}
/// The reap decision itself, against real containers.
///
/// The probes above are the inputs; this is the act. A clean orphan past its
/// grace must go, an orphan holding unpushed work must stay, and a young one
/// must stay regardless — and all three have to be true of the same sweep, in
/// one pass, because that is how it runs.
#[tokio::test]
async fn the_sweep_reaps_the_clean_orphan_and_spares_the_others() {
if !docker_available() {
eprintln!("orphan_sweep: no docker — not run");
return;
}
if MissionRuntimeProvisioner::from_env().is_none() {
return;
}
let _serial = FIXTURES.lock().await;
let pool = cm_testkit::test_pool().await;
// Adopt every mission container that already exists on this daemon.
//
// The sweep asks the DATABASE whether a container is known, and a fresh
// test database knows nothing — so on a developer machine the sweep
// classifies the live local stack's mission containers as orphans and
// reaps them. It did exactly that on the first run of this test, deleting
// two real mission containers.
//
// Giving each a row makes the test safe AND covers the case the other
// assertions do not: a container the platform still knows about is never
// touched, whatever its checkout looks like.
let adopted = adopt_existing(&pool).await;
let clean_id = Uuid::now_v7();
let dirty_id = Uuid::now_v7();
let young_id = Uuid::now_v7();
let alive = |name: &str| {
std::process::Command::new("docker")
.args(["inspect", name])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
};
let clean = start_fixture(
clean_id,
&format!("{GIT_INIT}\ngit update-ref refs/remotes/origin/main HEAD"),
);
let dirty = start_fixture(dirty_id, GIT_INIT);
// Pass one, no grace: everything present is past its window, so the
// decision is made purely on whether the checkout holds work.
let swept = cm_api::mission_runtime::sweep_orphans(&pool, std::time::Duration::ZERO).await;
let clean_gone = !alive(&clean);
let dirty_alive = alive(&dirty);
// Pass two, a real grace, on a container minted seconds ago. It is clean
// and orphaned — reapable on every axis except its age — so if the grace is
// decorative this is where that shows.
//
// Started AFTER the first pass on purpose: a grace applies to every
// container in the sweep, so a fixture created before a zero-grace pass is
// reaped by that pass and proves nothing about the window. The first
// version of this test made exactly that mistake and failed itself.
let young = start_fixture(
young_id,
&format!("{GIT_INIT}\ngit update-ref refs/remotes/origin/main HEAD"),
);
let swept2 =
cm_api::mission_runtime::sweep_orphans(&pool, std::time::Duration::from_secs(3600)).await;
let young_alive = alive(&young);
remove(&clean);
remove(&dirty);
remove(&young);
for name in &adopted {
assert!(
alive(name),
"the sweep reaped {name}, which HAS a mission row — a container the \
platform still knows about must never be touched"
);
}
swept.expect("first sweep");
swept2.expect("second sweep");
assert!(
clean_gone,
"a clean orphan past its grace is exactly what this sweep exists to \
reclaim; leaving it means the disk leak is still open"
);
assert!(
dirty_alive,
"an orphan holding commits no remote has MUST survive — the container \
that motivated this held ten of them"
);
assert!(
young_alive,
"a container inside the grace window must be left alone even when it is \
otherwise reapable, or the grace is decorative"
);
}
/// Give every mission container already on this daemon a row, so the sweep
/// treats it as known and leaves it alone.
///
/// Returns the names, which then double as an assertion: none of them may be
/// reaped.
async fn adopt_existing(pool: &sqlx::PgPool) -> Vec<String> {
let Some(prov) = MissionRuntimeProvisioner::from_env() else {
return Vec::new();
};
let Ok(existing) = prov.list_mission_containers().await else {
return Vec::new();
};
let ws = Uuid::now_v7();
sqlx::query("INSERT INTO workspaces (id, name, plan) VALUES ($1, 'orphan-test', 'free')")
.bind(ws)
.execute(pool)
.await
.expect("seed workspace");
let mut names = Vec::new();
for (name, _) in existing {
let Some(id) = cm_api::mission_runtime::mission_id_from_container(&name) else {
continue;
};
sqlx::query(
"INSERT INTO missions (id, workspace_id, title, template_kind)
VALUES ($1, $2, 'adopted by orphan_sweep test', 'research_only')
ON CONFLICT (id) DO NOTHING",
)
.bind(id)
.bind(ws)
.execute(pool)
.await
.expect("adopt container");
names.push(name);
}
names
}