fix(missions): make an unrunnable test suite legible, and check the runtime at boot
ci / gates (push) Failing after 6s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped

Two changes against the same defect: the platform could not tell a missing
capability from a legitimate negative result.

verify_tests returned Option<bool>, collapsing four outcomes into None:
no suite found, docker unreachable, exec failed, and no exit status. When
clawmates-runtime shipped without cargo, every on_green_tests phase
returned None and landed on -wip — identical to the reading for "this
repo has no tests", which is the conclusion I drew and reported. The gate
was correct throughout; it simply could not say why it was unproven.

TestOutcome now names the four cases. Gating is unchanged (only Passed
clears, unproven is never a pass), and tests_verified keeps its tri-state
meaning for existing readers. tests_status and tests_detail are new, so an
artifact distinguishes no_suite from could_not_run, and a CouldNotRun is
logged as the infrastructure fault it is rather than passing quietly.

runtime_preflight probes the runtime container at boot for every tool the
platform invokes inside it and names what each absence disables. This is
the check that was missing: the Dockerfile gained a toolchain, the image
was never built, gw-04 ran the old one for days, and the only symptoms
were an ungated suite and a security scan that scanned nothing. A report,
not a gate — a missing scanner should stop us believing a scan, not stop
the server. Its test guards the probes themselves, since a typo would
produce a permanent false "missing" and train operators to ignore it.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-02 17:22:47 -07:00
co-authored by Claude Opus 5
parent 9bdc3cd89b
commit f7e336ff5f
5 changed files with 350 additions and 16 deletions
+106 -16
View File
@@ -293,17 +293,28 @@ pub async fn capture_phase_diff_at(
// Gate, then publish. Both are best-effort on top of an artifact that has
// already landed: a phase whose tests fail, or whose push is rejected,
// still has its patch on disk and its work on a local branch.
let mut verified: Option<bool> = None;
let mut outcome: Option<TestOutcome> = None;
let mut published: Option<Publish> = None;
if let Some(c) = committed.as_ref() {
if !empty {
if gate == Gate::OnGreenTests {
let container = std::env::var("CLAWMATES_RUNTIME_CONTAINER")
.unwrap_or_else(|_| "clawmates-runtime".to_string());
verified = verify_tests(&repo, &container).await;
let o = verify_tests(&repo, &container).await;
// An infrastructure fault must be loud. The gate degrades
// safely either way, but "we could not run the suite" is a
// problem with the platform and needs to look like one.
if let TestOutcome::CouldNotRun(why) = &o {
eprintln!(
"mission_delivery: mission {mission_id} phase {phase_id} could NOT \
run the test suite — gating as unverified: {why}"
);
}
outcome = Some(o);
}
match push_url_for(pool, mission_id).await {
Some(url) => {
let verified = outcome.as_ref().and_then(TestOutcome::verified);
published = publish_phase_branch(&repo, &url, &c.branch, gate, verified)
.await
.ok();
@@ -330,7 +341,12 @@ pub async fn capture_phase_diff_at(
Gate::OnGreenTests => "on_green_tests",
Gate::OnReviewerApproval => "on_reviewer_approval",
},
"tests_verified": verified,
// `tests_verified` keeps its original tri-state meaning for existing
// readers; `tests_status` is what distinguishes the two ways of being
// null — a repo with no suite from a runtime that could not run one.
"tests_verified": outcome.as_ref().and_then(TestOutcome::verified),
"tests_status": outcome.as_ref().map(TestOutcome::status),
"tests_detail": outcome.as_ref().and_then(TestOutcome::detail),
"pushed": published.as_ref().map(|p| p.pushed),
"push_error": published.as_ref().and_then(|p| p.error.clone()),
"commit_error": commit_error,
@@ -744,20 +760,94 @@ pub struct Publish {
/// reason: this codebase has repeatedly found things reporting success while
/// doing nothing, and a test suite that never ran must not license a push to a
/// mission branch.
pub async fn verify_tests(repo: &Path, container: &str) -> Option<bool> {
let argv = discover_test_command(repo)?;
pub async fn verify_tests(repo: &Path, container: &str) -> TestOutcome {
let Some(argv) = discover_test_command(repo) else {
return TestOutcome::NoSuite;
};
let workdir = repo.display().to_string();
let docker = crate::container_exec::connect().ok()?;
let out = crate::container_exec::exec(&docker, container, Some(&workdir), &argv, TEST_TIMEOUT)
.await
.ok()?;
eprintln!(
"mission_delivery: {} → exit {:?}",
argv.join(" "),
out.exit_code
);
// An unreadable status is not a pass.
Some(out.success())
let docker = match crate::container_exec::connect() {
Ok(d) => d,
Err(e) => return TestOutcome::CouldNotRun(format!("docker unreachable: {e}")),
};
match crate::container_exec::exec(&docker, container, Some(&workdir), &argv, TEST_TIMEOUT).await
{
Ok(out) => {
eprintln!(
"mission_delivery: {} → exit {:?}",
argv.join(" "),
out.exit_code
);
match out.exit_code {
Some(0) => TestOutcome::Passed,
// An unreadable status is not a pass, and it is not a red
// suite either — the command may never have started.
None => TestOutcome::CouldNotRun(format!(
"`{}` produced no exit status: {}",
argv.join(" "),
out.combined().chars().take(300).collect::<String>()
)),
Some(code) => TestOutcome::Failed(code),
}
}
Err(e) => TestOutcome::CouldNotRun(format!("exec in `{container}` failed: {e}")),
}
}
/// What happened when the gate tried to verify a phase.
///
/// This was `Option<bool>`, and collapsing four outcomes into `None` is what
/// let a missing toolchain hide for days. `clawmates-runtime` shipped without
/// `cargo`, so `verify_tests` returned `None` on every mission — identical to
/// the reading for "this repository has no test suite", which is what I
/// concluded at the time and stated in a summary. The gate behaved correctly
/// throughout (unproven is not a pass); it simply could not say *why* it was
/// unproven, so nobody could tell a repo without tests from a runtime without
/// a test runner.
///
/// Only `Passed` clears the gate. The rest differ in what an operator should
/// do about them, which is the entire reason they are separate variants.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TestOutcome {
/// The suite ran and passed.
Passed,
/// The suite ran and failed, with its exit code.
Failed(i64),
/// No test command could be discovered for this repository.
NoSuite,
/// A suite exists but could not be executed. Always an infrastructure
/// fault on our side, never a verdict about the code.
CouldNotRun(String),
}
impl TestOutcome {
/// The gate's view: `Some(true)` only when the suite actually passed.
/// Preserved so `Gate::branch_suffix` keeps its existing contract.
pub fn verified(&self) -> Option<bool> {
match self {
TestOutcome::Passed => Some(true),
TestOutcome::Failed(_) => Some(false),
TestOutcome::NoSuite | TestOutcome::CouldNotRun(_) => None,
}
}
/// Stable machine-readable label for artifact metadata.
pub fn status(&self) -> &'static str {
match self {
TestOutcome::Passed => "passed",
TestOutcome::Failed(_) => "failed",
TestOutcome::NoSuite => "no_suite",
TestOutcome::CouldNotRun(_) => "could_not_run",
}
}
/// Human-readable detail, when there is any beyond the label.
pub fn detail(&self) -> Option<String> {
match self {
TestOutcome::Passed | TestOutcome::NoSuite => None,
TestOutcome::Failed(code) => Some(format!("test command exited {code}")),
TestOutcome::CouldNotRun(why) => Some(why.clone()),
}
}
}
/// Mark a phase as impossible to capture, so it stops being selected.