fix: three things that were known and written nowhere
All three have the same shape — the system learns something and only stderr
hears it — and each was flagged in the handoff as a silent-discard defect.
The gate's install outcome. `container_tool_hooks::install` returned Some or
None and both call sites wrote `let _ =`. A mission whose gate never installed
left a record indistinguishable from one whose gate stood there and matched
nothing. `EnsuredContainer` now carries the outcome to the callers that have a
pool, and they record `gate.installed` (with the settings path) or
`gate.absent` on the mission, so "was this mission gated?" is answerable from
the mission.
The inert marker. `vm_tool_gate` writes an `inert` file when it cannot parse
its input and allows everything, precisely so an inert gate does not look like
a permissive one. The only reader was a unit test. `drain_inert` now reads and
clears it at every tap drain, and a `gate.inert` event with the occurrence count
lands beside the calls that ran unchecked.
The judge's spend. `LlmEvent::Usage` arrived on every judge call and was
matched by `Ok(_) => {}`. Two plan exhaustions (2026-08-29, 2026-09-09) with
no row anywhere saying a judge token had been spent; `usage_events` had no
provider or model column. The loop now accumulates requests and tokens onto the
Verdict — counting a request BEFORE the stream opens, so a 429 the provider
refused still counts, because the retry storm was made of those — and
`record` writes a `kind = 'judge'` row with provider, model, mission and
request count. Migration 0085 adds the columns, all nullable, so the two
existing writers are untouched.
Tests: a scripted-provider verdict records one request and nonzero tokens; a
provider that refuses still records the request and zero tokens.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
This commit is contained in:
co-authored by
Claude Opus 5
parent
758760cedb
commit
248948cc84
@@ -630,6 +630,27 @@ async fn drain_finished_container_phases(pool: &PgPool) -> Result<(), String> {
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
// The gate's own confession, before its tool calls: if it could not
|
||||
// parse and allowed everything, every call drained below ran unchecked
|
||||
// — and until this read existed, the marker it left saying so was seen
|
||||
// by exactly one unit test and no production code.
|
||||
if let Some(text) = crate::container_tool_hooks::drain_inert(&docker, &container).await {
|
||||
let lines = text.lines().count();
|
||||
eprintln!(
|
||||
"phase_runner: the tool gate in {container} went INERT {lines} time(s) \
|
||||
during phase {phase_id} — those calls were allowed unchecked"
|
||||
);
|
||||
crate::mission_events::record(
|
||||
pool,
|
||||
crate::mission_events::MissionEvent::new(
|
||||
mission_id,
|
||||
crate::container_tool_hooks::GATE_INERT,
|
||||
)
|
||||
.phase(phase_id)
|
||||
.detail(serde_json::json!({ "occurrences": lines, "marker": text })),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let tools = crate::container_tool_hooks::drain(&docker, &container).await;
|
||||
if tools.is_empty() {
|
||||
continue;
|
||||
@@ -1053,6 +1074,13 @@ async fn launch_phase(
|
||||
match prov.ensure_container(mission_id).await {
|
||||
Ok(ec) => {
|
||||
let name = crate::mission_runtime::container_name(mission_id);
|
||||
crate::container_tool_hooks::record_install(
|
||||
pool,
|
||||
mission_id,
|
||||
Some(phase_id),
|
||||
ec.hooks.as_deref(),
|
||||
)
|
||||
.await;
|
||||
// Push the checkout into the container. A no-op in bind mode;
|
||||
// in copy mode it is how the agent gets the code at all, so a
|
||||
// failure must fail the launch rather than silently starting a
|
||||
|
||||
Reference in New Issue
Block a user