diff --git a/crates/cm-api/src/container_tool_hooks.rs b/crates/cm-api/src/container_tool_hooks.rs index e22e293..b30ac5b 100644 --- a/crates/cm-api/src/container_tool_hooks.rs +++ b/crates/cm-api/src/container_tool_hooks.rs @@ -482,6 +482,24 @@ mod tests { ); } + /// The taint file is never cleared, so its record needs its own + /// once-per-phase guard. Measured without one: the first live mission + /// recorded the same `taint.hosts` event four times, and the sweep that + /// revisits a finished phase for 30 minutes would have kept going. + #[test] + fn the_taint_record_is_written_once_per_phase() { + let runner = include_str!("phase_runner.rs"); + let body = runner + .split("drain_taint(&docker, &container).await") + .nth(1) + .expect("the sweep drains the taint file"); + let guard = body.find("SELECT EXISTS").expect("no once-per-phase guard"); + let record = body.find("TAINT_HOSTS,\n").unwrap_or(usize::MAX).min( + body.find("MissionEvent::new").expect("the record"), + ); + assert!(guard < record, "the guard must run before the record is written"); + } + /// The drain must use the connector that honours DOCKER_HOST. /// /// The server reaches Docker through a socket proxy, so diff --git a/crates/cm-api/src/phase_runner.rs b/crates/cm-api/src/phase_runner.rs index ab85262..e676472 100644 --- a/crates/cm-api/src/phase_runner.rs +++ b/crates/cm-api/src/phase_runner.rs @@ -684,8 +684,30 @@ async fn drain_finished_container_phases(pool: &PgPool) -> Result<(), String> { } // What fetched content named. Observed, not enforced — see // docs/TASK-PERMISSION-AND-TAINT.md, piece 2, stage 1. + // + // Once per phase. This sweep revisits every phase for 30 minutes, and + // the other drains are idempotent only because they truncate what they + // read; the taint file is deliberately never truncated, so without this + // check the first live run recorded the same event four times and would + // have gone on recording it every tick. A LARGER set is still recorded: + // that is new information. let hosts = crate::container_tool_hooks::drain_taint(&docker, &container).await; - if !hosts.is_empty() { + let already: bool = if hosts.is_empty() { + true + } else { + sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM mission_events + WHERE phase_id = $1 AND kind = $2 + AND (detail->>'count')::int >= $3)", + ) + .bind(phase_id) + .bind(crate::container_tool_hooks::TAINT_HOSTS) + .bind(hosts.len() as i32) + .fetch_one(pool) + .await + .unwrap_or(false) + }; + if !already { crate::mission_events::record( pool, crate::mission_events::MissionEvent::new(