fix(missions): make an unrunnable test suite legible, and check the runtime at boot
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:
co-authored by
Claude Opus 5
parent
9bdc3cd89b
commit
f7e336ff5f
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user