feat(missions): collect the container tier's tool calls
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:
co-authored by
Claude Opus 5
parent
b89606fcf1
commit
cd59e4798d
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user