feat(missions): collect the container tier's tool calls
deploy / test (push) Successful in 5m5s
deploy / build (push) Failing after 5m20s

The hooks from the previous commit write a tap file that nothing reads —
which is the same shape as the gate that is installed and inert: everything
looks wired and no evidence ever appears.

The microVM tier records its tools from inside the loop watching the VM. A
container turn is driven asynchronously by topology_worker, so there is no
such loop and something has to come and collect the file.

`drain_finished_container_phases` does, on the same tick as the benchmark
baseline and the security scan, reusing `record_vm_tools` so container tool
calls land as the same TOOL_CALL / FILE_TOUCH events the World already
renders. One shape, two tiers.

Idempotent by TRUNCATION, not a marker or a cursor column: `drain` clears
the file it read, so a second pass finds nothing. Read-then-clear happens in
one exec, and only for phases that have FINISHED — the agent is no longer
appending, so the gap between read and clear cannot lose an event. A cursor
would have needed a migration and a column that means nothing to anyone
else.

Two tests exist because the failure is silent either way: the drain must
clear what it read (otherwise every tick re-records the same calls and a
phase's early files end up weighted by how long the sweep ran), and the tick
must actually call the sweep (otherwise the hooks write a file nobody
collects).

Full workspace suite green: 107 binaries.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-21 05:53:37 -07:00
co-authored by Claude Opus 5
parent b89606fcf1
commit cd59e4798d
2 changed files with 135 additions and 0 deletions
+65
View File
@@ -89,6 +89,39 @@ fn build_install_script() -> String {
) )
} }
/// The tap file inside the mission container.
pub fn tap_file() -> String {
format!("{TAP_DIR}/tools.jsonl")
}
/// Read everything the tap recorded, then clear it.
///
/// Read-then-truncate rather than a cursor, because this tier has no
/// long-lived loop to hold one: the microVM path drains inside the turn it is
/// watching, while a container turn is driven asynchronously by
/// `topology_worker`. Truncation makes the drain idempotent — a second pass
/// reads an empty file and records nothing — without a column to store a
/// cursor in.
///
/// Called only for phases that have FINISHED, so the agent is no longer
/// appending and the read/truncate gap cannot lose an event.
pub async fn drain(docker: &Docker, container: &str) -> Vec<crate::vm_tool_tap::Observed> {
let file = tap_file();
// `cat` then truncate in one exec: two round-trips would widen the window
// between them for no benefit.
let script = format!("cat {file} 2>/dev/null || true; : > {file} 2>/dev/null || true");
let argv = vec!["sh".to_string(), "-lc".to_string(), script];
match crate::container_exec::exec_as_root(docker, container, None, &argv, INSTALL_TIMEOUT).await
{
Ok(out) => crate::vm_tool_tap::parse(&out.stdout),
Err(e) => {
// A reaped container is the normal end state, not a fault.
eprintln!("container_tool_hooks: no tap drained from {container}: {e}");
Vec::new()
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -144,6 +177,38 @@ mod tests {
); );
} }
/// The drain must clear what it read.
///
/// Truncation IS the idempotency here — there is no cursor column and no
/// marker row. A drain that reads without clearing would re-record every
/// tool call on every tick, and a phase's early files would end up weighted
/// by how long the sweep ran.
#[test]
fn the_drain_reads_then_clears() {
let file = tap_file();
assert!(file.starts_with(TAP_DIR), "the tap must live under {TAP_DIR}");
// The script is built inline in `drain`; assert on the shape it must
// have, since getting this wrong duplicates every event silently.
let script = format!("cat {file} 2>/dev/null || true; : > {file} 2>/dev/null || true");
assert!(script.contains(&format!("cat {file}")), "must read");
assert!(script.contains(&format!(": > {file}")), "must clear");
}
/// The sweep has to exist, or the hooks write a file nobody reads.
#[test]
fn something_actually_collects_the_tap() {
let runner = include_str!("phase_runner.rs");
assert!(
runner.contains("container_tool_hooks::drain"),
"the tap is written and never collected — the same shape as a gate \
that is installed and inert"
);
assert!(
runner.contains("drain_finished_container_phases(pool).await?"),
"the drain exists but the tick does not call it"
);
}
/// The generated installer must be valid shell — a here-doc or quoting slip /// The generated installer must be valid shell — a here-doc or quoting slip
/// makes it fail in the container, where the only symptom is a mission that /// makes it fail in the container, where the only symptom is a mission that
/// silently runs unhooked. /// silently runs unhooked.
+70
View File
@@ -75,6 +75,9 @@ async fn sweep_once(
baseline_finished_benchmark_phases(pool).await?; baseline_finished_benchmark_phases(pool).await?;
// Security phases run the scanners once the checkout exists to scan. // Security phases run the scanners once the checkout exists to scan.
scan_finished_security_phases(pool).await?; scan_finished_security_phases(pool).await?;
// Container-tier phases leave their tool calls in the container's tap;
// nothing else comes to collect them.
drain_finished_container_phases(pool).await?;
// A failed phase makes every later phase unreachable, and saying so is what // A failed phase makes every later phase unreachable, and saying so is what
// lets the mission finish at all. // lets the mission finish at all.
skip_unreachable_phases(pool).await?; skip_unreachable_phases(pool).await?;
@@ -504,6 +507,73 @@ async fn scan_finished_security_phases(pool: &PgPool) -> Result<(), String> {
Ok(()) Ok(())
} }
/// Drain the container tier's tool tap into `mission_events`.
///
/// The microVM tier records its tools during the turn, from inside the loop
/// that is watching the VM. A container turn is driven asynchronously by
/// `topology_worker`, so there is no such loop — the tap accumulates in the
/// mission's container and something has to come and collect it.
///
/// Without this the hooks write a file nobody reads, which is the same shape
/// as the gate that is installed and inert: everything looks wired and no
/// evidence ever appears.
///
/// Idempotent by truncation, not by a marker: `drain` clears the file it read,
/// so a second pass finds nothing. That is why this can run every tick without
/// a cursor column.
async fn drain_finished_container_phases(pool: &PgPool) -> Result<(), String> {
let rows = sqlx::query(
"SELECT mp.id, mp.mission_id, m.runtime_container_name
FROM mission_phases mp
JOIN missions m ON m.id = mp.mission_id
WHERE mp.status IN ('completed', 'failed')
AND m.runtime_container_name IS NOT NULL
AND mp.completed_at > now() - interval '30 minutes'
ORDER BY mp.completed_at DESC
LIMIT 4",
)
.fetch_all(pool)
.await
.map_err(|e| format!("select container phases: {e}"))?;
for row in rows {
let phase_id: Uuid = row.get("id");
let mission_id: Uuid = row.get("mission_id");
let container: String = row.get("runtime_container_name");
let Ok(docker) = bollard::Docker::connect_with_local_defaults() else {
return Ok(());
};
let tools = crate::container_tool_hooks::drain(&docker, &container).await;
if tools.is_empty() {
continue;
}
// The phase's own run, so the events hang off the same row the UI
// already reads. A phase with no run row still records the tools —
// losing them because the join came up empty would be the worse trade.
let run_id: Option<Uuid> =
sqlx::query_scalar("SELECT id FROM topology_runs WHERE phase_id = $1 LIMIT 1")
.bind(phase_id)
.fetch_optional(pool)
.await
.ok()
.flatten();
record_vm_tools(
pool,
mission_id,
phase_id,
run_id.unwrap_or(phase_id),
&tools,
)
.await;
eprintln!(
"phase_runner: drained {} tool call(s) from {container} for phase {phase_id}",
tools.len()
);
}
Ok(())
}
/// Did this phase finish without delivering the work it exists to produce? /// Did this phase finish without delivering the work it exists to produce?
/// ///
/// A coding phase that changes no files has done nothing, and until now that /// A coding phase that changes no files has done nothing, and until now that