feat(missions): reap orphaned runtime containers — unless they hold work

`sweep_once` selects `FROM missions`, and `teardown_container` is only
ever called with an id from that query. So a container whose row is gone
is invisible to every reaper: nothing enumerates docker, nothing errors,
and the only symptom is disk.

Found on gw-04 today — `cm-runtime-mission-019ff5b1…`, Up nine days,
2.5G, against a `missions` table with zero rows.

`list_mission_containers` is the piece that never existed: without it
"which containers exist" is a question the platform cannot ask, and a
container the database has forgotten is not merely unreaped, it is
unseeable.

**The sweep refuses to reap work that exists nowhere else.** That
container's checkout held ten commits on a branch that had never been
pushed — +3451/-30 across 30 files, eighteen INT items including
AES-256-GCM, Ed25519 signing and HNSW batch insert. A reaper that deleted
on sight would have destroyed all of it silently, as its designed
behaviour. `unpushed_commits` asks the checkout (`git rev-list --all
--not --remotes`) and leaves the container alone, loudly, every tick,
when the answer is not zero.

Every failure path returns `SomeOrUnknown`: a container we cannot
question is not a container we may delete. Same for one docker will not
date — including a future `Created` from clock skew, which would
otherwise underflow into an age past any grace period.

Grace is 24h, long on purpose. The row-driven sweep already handles
everything the platform knows about, so anything reaching this path is
already unexpected.

The container above was handled by hand first: bundled, verified,
branch pushed to git.redclaw.dev, confirmed on the remote at the branch
tip, then removed. 59G free, up from 57G.

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 10:30:17 -07:00
co-authored by Claude Opus 5
parent ceec0423ad
commit 6af1149e45
+243
View File
@@ -1070,6 +1070,103 @@ impl MissionRuntimeProvisioner {
/// this to decide whether to KEEP a binding for a retry, and answering /// this to decide whether to KEEP a binding for a retry, and answering
/// "still there" when docker cannot be reached would pin the binding open /// "still there" when docker cannot be reached would pin the binding open
/// on an unreachable daemon rather than on a real container. /// on an unreachable daemon rather than on a real container.
/// Every `cm-runtime-mission-*` container on this engine, running or not.
///
/// The piece the row-driven sweep never had. Without it "which containers
/// exist" is a question the platform cannot ask, and a container the
/// database has forgotten is not merely unreaped — it is unseeable.
pub async fn list_mission_containers(&self) -> Result<Vec<(String, Option<i64>)>, String> {
let mut filters = std::collections::HashMap::new();
filters.insert("name".to_string(), vec!["cm-runtime-mission-".to_string()]);
let opts = bollard::query_parameters::ListContainersOptionsBuilder::default()
.all(true)
.filters(&filters)
.build();
let list = self
.docker
.list_containers(Some(opts))
.await
.map_err(|e| format!("list mission containers: {e}"))?;
Ok(list
.into_iter()
.filter_map(|c| {
let name = c
.names
.unwrap_or_default()
.into_iter()
// Docker returns names with a leading slash.
.map(|n| n.trim_start_matches('/').to_string())
.find(|n| n.starts_with("cm-runtime-mission-"))?;
Some((name, c.created))
})
.collect())
}
/// How long ago docker says this container was created.
///
/// Taken from the listing rather than a second `inspect`: `created` is
/// already a unix timestamp there, so this needs neither a date parser nor
/// another round-trip. `None` when docker reported none, and the caller
/// treats that as "do not reap" — a container we cannot date is exactly the
/// one worth leaving.
pub fn container_age(created_epoch: Option<i64>) -> Option<std::time::Duration> {
let created = created_epoch?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_secs() as i64;
u64::try_from(now - created).ok().map(std::time::Duration::from_secs)
}
/// Does this container's checkout hold commits no remote has?
///
/// Answered by `git` inside the container, because only it knows which
/// refs the remote had. `--not --remotes` lists every commit reachable
/// from any local ref and from no remote-tracking ref — which is exactly
/// "work that exists only here".
///
/// Every failure path returns `SomeOrUnknown`. A container we cannot
/// question is not a container we may delete.
pub async fn unpushed_commits(&self, name: &str) -> UnpushedWork {
let script = "cd /mission/repo 2>/dev/null || exit 91; \
git rev-list --all --not --remotes 2>/dev/null | wc -l";
let argv = vec!["sh".to_string(), "-lc".to_string(), script.to_string()];
let out = match crate::container_exec::exec_as_root(
&self.docker,
name,
None,
&argv,
std::time::Duration::from_secs(30),
)
.await
{
Ok(o) => o,
Err(e) => {
return UnpushedWork::SomeOrUnknown(format!("could not ask git ({e})"));
}
};
if out.exit_code == Some(91) {
// No checkout at all — nothing to lose.
return UnpushedWork::None;
}
if out.exit_code != Some(0) {
return UnpushedWork::SomeOrUnknown(format!(
"git probe exited {:?}",
out.exit_code
));
}
match out.stdout.trim().parse::<u64>() {
Ok(0) => UnpushedWork::None,
Ok(n) => UnpushedWork::SomeOrUnknown(format!(
"{n} commit(s) in its checkout are on no remote"
)),
Err(_) => UnpushedWork::SomeOrUnknown(format!(
"unreadable git output {:?}",
out.stdout.trim()
)),
}
}
pub async fn container_exists(&self, mission_id: Uuid) -> bool { pub async fn container_exists(&self, mission_id: Uuid) -> bool {
self.docker self.docker
.inspect_container(&container_name(mission_id), None::<InspectContainerOptions>) .inspect_container(&container_name(mission_id), None::<InspectContainerOptions>)
@@ -1152,10 +1249,114 @@ pub fn spawn_sweeper(pool: sqlx::PgPool, grace: std::time::Duration) {
if let Err(e) = sweep_once(&pool, grace).await { if let Err(e) = sweep_once(&pool, grace).await {
eprintln!("mission_runtime::sweeper: sweep failed: {e}"); eprintln!("mission_runtime::sweeper: sweep failed: {e}");
} }
if let Err(e) = sweep_orphans(&pool, ORPHAN_GRACE).await {
eprintln!("mission_runtime::sweeper: orphan sweep failed: {e}");
}
} }
}); });
} }
/// How long a container with no mission row may sit before it is reaped.
///
/// Long, deliberately. The row-driven sweep above handles every container the
/// platform still knows about, so anything reaching this path is already
/// unexpected — and the one real orphan we have seen held ten unpushed commits.
/// A day of disk is cheaper than being wrong about that.
const ORPHAN_GRACE: std::time::Duration = std::time::Duration::from_secs(24 * 3600);
/// Reap `cm-runtime-mission-*` containers that no `missions` row points at.
///
/// [`sweep_once`] selects `FROM missions`, and `teardown_container` is only
/// ever called with an id that came from that query. So a container whose row
/// is gone is invisible to every reaper: nothing enumerates docker, nothing
/// errors, and the only symptom is disk.
///
/// Found on gw-04 2026-08-21 — a container `Up` for nine days holding 2.5G,
/// against a `missions` table with **zero rows**.
///
/// # It refuses to reap work that exists nowhere else
///
/// That container's checkout held **ten commits on a branch that had never
/// been pushed** (+3451/-30 across 30 files). A reaper that deleted on sight
/// would have destroyed all of it, silently, as its designed behaviour. So
/// before removing anything this asks the checkout whether it holds commits
/// that no remote has, and leaves the container alone — loudly, every tick —
/// when it does.
///
/// The check is deliberately one-sided in the safe direction: an inspection
/// that fails for any reason counts as "might hold work", never as "safe to
/// delete". Losing a day of disk to an unreadable container is recoverable;
/// the other way round is not.
async fn sweep_orphans(pool: &sqlx::PgPool, grace: std::time::Duration) -> Result<(), String> {
let Some(prov) = MissionRuntimeProvisioner::from_env() else {
return Ok(());
};
let names = prov.list_mission_containers().await?;
if names.is_empty() {
return Ok(());
}
for (name, created) in names {
let Some(id) = mission_id_from_container(&name) else {
continue;
};
// `WHERE id = $1` across every workspace on purpose: the question is
// whether ANY row still points at this container, not whether one the
// caller can see does.
let known: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM missions WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await
.map_err(|e| format!("look up mission {id}: {e}"))?;
if known.is_some() {
continue;
}
match MissionRuntimeProvisioner::container_age(created) {
Some(age) if age < grace => continue,
None => continue,
Some(_) => {}
}
match prov.unpushed_commits(&name).await {
// The safe answer, and the one an error also produces.
UnpushedWork::SomeOrUnknown(why) => {
eprintln!(
"mission_runtime::orphans: {name} has no mission row and is older than \
the grace period, but it is NOT safe to reap: {why}. Recover the work \
(`git bundle create … origin/main..HEAD`, or push the branch) and then \
remove it by hand."
);
}
UnpushedWork::None => {
eprintln!(
"mission_runtime::orphans: reaping {name} — no mission row, older than \
the grace period, and its checkout holds nothing a remote does not"
);
if let Err(e) = prov.teardown_container(id).await {
eprintln!("mission_runtime::orphans: reap {name}: {e}");
}
}
}
}
Ok(())
}
/// Whether an orphan's checkout holds commits no remote has.
#[derive(Debug, PartialEq, Eq)]
pub enum UnpushedWork {
/// Every commit is reachable from a remote ref — nothing is lost.
None,
/// There IS unpushed work, or the question could not be answered. One
/// variant for both, because the reaper must treat them identically.
SomeOrUnknown(String),
}
/// The mission id encoded in a runtime container's name, if it is one.
///
/// The inverse of [`container_name`], which formats the uuid `simple` (no
/// dashes). Anything that does not parse is not ours and is left alone.
pub fn mission_id_from_container(name: &str) -> Option<Uuid> {
Uuid::parse_str(name.strip_prefix("cm-runtime-mission-")?).ok()
}
async fn sweep_once(pool: &sqlx::PgPool, grace: std::time::Duration) -> Result<(), String> { async fn sweep_once(pool: &sqlx::PgPool, grace: std::time::Duration) -> Result<(), String> {
use sqlx::Row; use sqlx::Row;
let grace_secs = grace.as_secs() as f64; let grace_secs = grace.as_secs() as f64;
@@ -1569,6 +1770,48 @@ mod tests {
} }
} }
/// The name→id round trip the orphan sweep depends on.
///
/// If this is wrong the sweep either skips every orphan (harmless) or
/// resolves a container to the WRONG mission id and asks the database
/// about a mission that does exist — reading "still live, leave it" for a
/// container that is not. Cheap to get right, expensive to get wrong.
#[test]
fn a_container_name_round_trips_to_its_mission() {
let id = Uuid::now_v7();
assert_eq!(mission_id_from_container(&container_name(id)), Some(id));
// Not ours, and not a panic.
assert_eq!(mission_id_from_container("clawmates_server_1"), None);
assert_eq!(mission_id_from_container("cm-runtime-mission-nonsense"), None);
assert_eq!(mission_id_from_container("cm-sandbox-abc"), None);
}
/// A container docker will not date must not be reaped.
#[test]
fn an_undatable_container_has_no_age() {
assert_eq!(MissionRuntimeProvisioner::container_age(None), None);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let age = MissionRuntimeProvisioner::container_age(Some(now - 3600)).expect("age");
assert!(age.as_secs() >= 3500 && age.as_secs() <= 3700, "{age:?}");
// A clock skew that puts creation in the future must not underflow into
// a colossal age that reads as "long past the grace period".
assert_eq!(MissionRuntimeProvisioner::container_age(Some(now + 600)), None);
}
/// The grace period is long, and that is the point.
#[test]
fn the_orphan_grace_is_generous() {
assert!(
ORPHAN_GRACE >= std::time::Duration::from_secs(12 * 3600),
"the row-driven sweep already handles everything the platform knows \
about, so anything reaching the orphan path is unexpected — and the \
one real orphan held ten unpushed commits"
);
}
const SAMPLE_CONFIG: &str = r#"# top comment const SAMPLE_CONFIG: &str = r#"# top comment
[agents.claw_a] [agents.claw_a]
model_provider = "anthropic.default" model_provider = "anthropic.default"