feat(microvm): Claude Code 2.1.276 rootfs pins, and a VM run that says what it ran
deploy / test (push) Successful in 5m18s
deploy / build (push) Successful in 5m54s

Every rootfs on the fleet had sat on Claude Code 2.1.223–2.1.226 since August
while the container tier moved to 2.1.276, and nothing recorded either. GLM
and Kimi exist only as microVM backends, so "have we upgraded GLM and Kimi"
is this change and the rebuild it drives.

Pins. All four agent-* images pin 2.1.276 — as separate ARGs, since Docker has
no include and each file has to stay reproducible alone — and
scripts/fc-build-rootfs.sh refuses to build if they disagree, naming the odd
one out. They had already drifted (claude 226, the rest 223) under comments
saying "same version on purpose". Between 2.1.226 and 2.1.276, 2.1.265 and
2.1.275 each broke every turn on ANTHROPIC_BASE_URL endpoints, which is how
glm and kimi reach `claude` inside a VM; the container-tier verification never
exercised that path, so the VM runs on those backends are the real test.

Provenance. `VmOutcome` carries the rootfs the node reported booting and the
guest's own `claude --version`; `launch_microvm_phase` persists both as
`checkpoint.vm` beside `records` (the two readers parse only `records`) and
names them in its log line. "Which image and CLI did this mission run on" is
a query now.

Independence. `evaluator` derived the implementer family from a constant
`"anthropic"`, true while every backend was Claude on Anthropic. With glm and
kimi rootfs it made a glm mission judged by glm:glm-5.3 read as
`independent = true` — the one claim that path exists to make honestly.
`implementer_family(missions.backend)` mirrors `microvm_credential_for`; the
subscription judge is now independent exactly when the agent did NOT run on
Anthropic.

Harness. `verify-mission-delivery.sh glm|kimi` run the microvm scenario on
each backend and add the proof the mission itself cannot give: the placed
node's journal must show the VM dialling that provider's host, never being
denied it, and dialling nothing else but the forge — a model's self-report is
measured worthless here. `assert_cli_version` reads checkpoint.vm. The stale
scratch-repo default (dead since the 09-14 wipe) is the re-synced id.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
This commit is contained in:
Omar Sobh
2026-09-18 20:16:06 -05:00
co-authored by Claude Opus 5
parent 3755699b41
commit 794f2124bc
10 changed files with 259 additions and 24 deletions
+64 -14
View File
@@ -291,13 +291,27 @@ fn names_a_provider(spec: &str) -> bool {
spec.contains(':')
}
/// The provider family the mission's agent ran on.
/// The provider family the mission's agent ran on, from `missions.backend`.
///
/// Today every mission backend is Claude Code (`agent-claude`), including the
/// microVM path. When `agent-glm` / `agent-kimi` images exist this should read
/// `missions.backend`; until then, hardcoding the truth is better than plumbing a
/// parameter that only ever has one value.
const IMPLEMENTER_FAMILY: &str = "anthropic";
/// Mirrors `mission_runtime::microvm_credential_for`: the backend decides which
/// credential the guest gets and which host its egress proxy allows, so it is
/// the one honest source for "who answered the agent's turns". This was a
/// hardcoded `"anthropic"` while every backend was Claude Code on Anthropic;
/// once `glm` and `kimi` rootfs existed that constant made a glm-backend
/// mission judged by `glm:glm-5.3` read as `independent = true`, which is the
/// one claim this path exists to make honestly.
///
/// `unknown` for anything unrecognised, for the same reason `provider_family`
/// says it: a guess in either direction misstates independence.
pub fn implementer_family(backend: Option<&str>) -> &'static str {
match backend.map(str::trim) {
None | Some("") | Some("default") | Some("claude") | Some("canary-claude") => "anthropic",
Some("glm") => "glm",
Some("kimi") => "kimi",
Some("local-ornith") => "local",
Some(_) => "unknown",
}
}
/// Which validator spec applies, given the mission's own setting and the
/// deployment default.
@@ -333,9 +347,23 @@ fn resolve_validator_spec(mission: Option<&str>, deployment: Option<&str>) -> Op
/// back Claude while the caller believed it had asked for GLM. The fallback is
/// detectable because the returned model still carries the `name:` prefix, and it
/// is checked here rather than trusted.
/// The mission's implementer family, read from its row. `anthropic` when the
/// row cannot be read — the pre-2026-09-18 behaviour, and the family every
/// backend actually had until then.
async fn mission_implementer_family(runtime: &cm_runtime::Runtime, mission_id: Uuid) -> &'static str {
let backend: Option<String> = sqlx::query_scalar("SELECT backend FROM missions WHERE id = $1")
.bind(mission_id)
.fetch_optional(runtime.pool())
.await
.unwrap_or(None)
.flatten();
implementer_family(backend.as_deref())
}
async fn cross_provider_judge(
runtime: &cm_runtime::Runtime,
mission_id: Uuid,
implementer: &str,
) -> Option<(std::sync::Arc<dyn cm_llm::LlmProvider>, String)> {
// Read per mission rather than widening `Mission` for one caller. One extra
// query per evaluation, against a path that is about to make a model call.
@@ -352,10 +380,10 @@ async fn cross_provider_judge(
)?;
let spec = spec.as_str();
let family = provider_family(spec);
if family == IMPLEMENTER_FAMILY {
if family == implementer {
eprintln!(
"evaluator: CLAWMATES_VALIDATOR_MODEL={spec} is the same provider family as the \
agent ({IMPLEMENTER_FAMILY}) — that is not an independent check, ignoring it"
agent ({implementer}) — that is not an independent check, ignoring it"
);
return None;
}
@@ -474,7 +502,8 @@ pub async fn evaluate(
// failure — the model that talked itself into a shortcut is the one disposed
// to accept it — and the tool loop is what makes the check evidence rather
// than opinion, so an independent judge must have it too.
if let Some((provider, model)) = cross_provider_judge(runtime, mission_id).await {
let implementer = mission_implementer_family(runtime, mission_id).await;
if let Some((provider, model)) = cross_provider_judge(runtime, mission_id, implementer).await {
let system = match &sandbox {
Some(_) => format!("{EVAL_SYSTEM_VERIFYING}\n\n{VERDICT_CONTRACT}"),
None => format!("{EVAL_SYSTEM_EVIDENCE_ONLY}\n\n{VERDICT_CONTRACT}"),
@@ -535,7 +564,9 @@ pub async fn evaluate(
let outcome =
judge_with_tools(&provider, &system, &user, &model, sandbox.as_ref(), &mut usage)
.await;
// Same family as the agent; `independent` stays false below.
// Independent exactly when the agent did NOT run on Anthropic: a
// glm- or kimi-backend mission judged by the Anthropic subscription
// is a cross-provider check, and a claude-backend one is not.
let mut v = match outcome {
Err(e) => Verdict::not_met(
&model,
@@ -550,6 +581,7 @@ pub async fn evaluate(
}
};
v.usage = usage;
v.independent = implementer != "anthropic";
return v;
}
@@ -1088,7 +1120,23 @@ mod cross_provider_tests {
#[test]
fn an_unrecognised_model_is_not_assumed_to_be_ours() {
assert_eq!(provider_family("some-new-model-v9"), "unknown");
assert_ne!(provider_family("some-new-model-v9"), IMPLEMENTER_FAMILY);
assert_ne!(provider_family("some-new-model-v9"), implementer_family(None));
}
/// The implementer family comes from the mission's backend, and the two
/// readers have to agree on the spelling of a family or a glm mission
/// judged by glm reads as independent — which it did, while this was a
/// constant.
#[test]
fn the_implementer_family_follows_the_backend() {
assert_eq!(implementer_family(None), "anthropic");
for b in ["", "default", "claude", "canary-claude"] {
assert_eq!(implementer_family(Some(b)), "anthropic", "{b}");
}
assert_eq!(implementer_family(Some("glm")), provider_family("glm:glm-5.3"));
assert_eq!(implementer_family(Some("kimi")), provider_family("kimi:kimi-k2"));
assert_ne!(implementer_family(Some("claude")), provider_family("glm:glm-5.3"));
assert_eq!(implementer_family(Some("something-else")), "unknown");
}
/// The whole point: a judge in the implementer's own family is not
@@ -1098,13 +1146,15 @@ mod cross_provider_tests {
for spec in ["claude-opus-4-8", "runtime:claw_x", "sonnet"] {
assert_eq!(
provider_family(spec),
IMPLEMENTER_FAMILY,
"{spec} would have to be rejected as a validator"
implementer_family(Some("claude")),
"{spec} would have to be rejected as a validator of a claude mission"
);
}
for spec in ["glm:glm-4.7", "kimi:kimi-k2"] {
assert_ne!(provider_family(spec), IMPLEMENTER_FAMILY, "{spec}");
assert_ne!(provider_family(spec), implementer_family(Some("claude")), "{spec}");
}
// And the other way round: glm judging a glm mission is the same trap.
assert_eq!(provider_family("glm:glm-5.3"), implementer_family(Some("glm")));
}
/// A mission's own choice wins over the deployment default.
+25
View File
@@ -306,6 +306,13 @@ const TEAMMATE_PROBE: &str = "cat /root/.claude/teams/*/config.json 2>/dev/null
/// says so, which is the difference between losing a check and losing the work.
const SETTINGS_PROBE: &str = "claude --help 2>&1 | grep -q -- '--settings' && echo SETTINGS-OK";
/// What the guest's `claude` reports itself as. Recorded beside the rootfs the
/// node said it booted, so "which CLI did this mission run on" is a query
/// against `topology_runs`, not an archaeology of image mtimes. Found necessary
/// on 2026-09-18: every rootfs on the fleet had been on 2.1.2232.1.226 for a
/// month while the container tier moved to 2.1.276, and nothing recorded either.
const CLI_VERSION_PROBE: &str = "claude --version 2>/dev/null | head -c 80";
/// How many times the stop gate refused to let the agent finish.
const BLOCKS_PROBE: &str = "cat /root/gate/blocks 2>/dev/null || echo 0";
@@ -400,6 +407,12 @@ pub struct VmOutcome {
/// field cannot tell them apart — the unmatched-frame log and the install
/// error are what separate them.
pub tools: Vec<crate::vm_tool_tap::Observed>,
/// The rootfs the node reported booting (`vm_create` reply), e.g.
/// `/opt/clawmates-fc/rootfs-glm.ext4`. `None` if the reply carried none.
pub rootfs: Option<String>,
/// The guest's own `claude --version`, e.g. `2.1.276 (Claude Code)`.
/// `None` if the probe failed — which is a fact worth seeing, not a zero.
pub cli_version: Option<String>,
}
/// Boot a VM, run the phase in it, collect the result, and destroy it.
@@ -618,6 +631,16 @@ async fn run_inside(
.await
.map(|p| p.stdout.contains("SETTINGS-OK"))
.unwrap_or(false);
let cli_version = vm
.exec(CLI_VERSION_PROBE, None, 60, &[])
.await
.ok()
.map(|p| p.stdout.trim().to_string())
.filter(|v| !v.is_empty());
let rootfs = created
.get("rootfs")
.and_then(serde_json::Value::as_str)
.map(str::to_string);
let gate_dir = match gate {
None => None,
Some(g) => {
@@ -954,6 +977,8 @@ async fn run_inside(
stop_blocks,
released_at_cap,
tools,
rootfs,
cli_version,
})
}
@@ -429,6 +429,8 @@ mod tests {
stop_blocks: None,
released_at_cap: None,
tools: Vec::new(),
rootfs: None,
cli_version: None,
})
}
}
@@ -624,6 +626,8 @@ mod tests {
stop_blocks: None,
released_at_cap: None,
tools: Vec::new(),
rootfs: None,
cli_version: None,
})
}
}
@@ -656,6 +660,8 @@ mod tests {
stop_blocks: Some(crate::vm_stop_gate::MAX_BLOCKS),
released_at_cap: Some(true),
tools: Vec::new(),
rootfs: None,
cli_version: None,
})
}
}
@@ -683,6 +689,8 @@ mod tests {
stop_blocks: Some(crate::vm_stop_gate::MAX_BLOCKS),
released_at_cap: Some(false),
tools: Vec::new(),
rootfs: None,
cli_version: None,
})
}
}
+18 -2
View File
@@ -1781,6 +1781,18 @@ async fn launch_microvm_phase(
if let Ok(o) = &outcome {
record_vm_tools(&pool2, mission_id, phase_id, run_id, &o.tools, &[]).await;
}
// What actually ran: the rootfs the node booted and the CLI the guest
// reported. Persisted on the run so "which image and version did this
// mission use" is a query, not an inference from file mtimes — on
// 2026-09-18 every fleet rootfs had sat on 2.1.2232.1.226 for a month
// while the container tier moved on, and nothing had recorded either.
let vm = serde_json::json!({
"vm_id": crate::microvm_executor::vm_id_for(phase_id, iteration, None),
"node_id": target_node_id,
"backend": backend,
"rootfs": outcome.as_ref().ok().and_then(|o| o.rootfs.clone()),
"cli_version": outcome.as_ref().ok().and_then(|o| o.cli_version.clone()),
});
let (status, note) = match outcome {
// The gate gave up. It is the ONLY thing that runs a
// `done_when_check`, so a release at the cap means the phase's own
@@ -1813,7 +1825,9 @@ async fn launch_microvm_phase(
eprintln!(
"phase_runner: microvm phase {phase_id} of mission {mission_id}{status} \
(subagents: {subagents}, teammates: {teammates}, stop-gate blocks: \
{blocked}) — {}",
{blocked}; rootfs: {}, cli: {}) — {}",
vm["rootfs"].as_str().unwrap_or("?"),
vm["cli_version"].as_str().unwrap_or("?"),
note.chars().take(300).collect::<String>()
);
// Never overwrite a cancellation. The operator asking to stop is a decision;
@@ -1843,7 +1857,9 @@ async fn launch_microvm_phase(
"output": note,
"tokens": 0,
"gated": [],
}]
}],
// Beside `records`, not inside: the two readers parse only `records`.
"vm": vm,
});
if let Err(e) = sqlx::query(
"UPDATE topology_runs