fix(runtime): a mission could not build the repo it was given
`clawmates-runtime` shipped with `gcc` and `make` but no `cmake`, no `g++` and no `python3-dev`. Measured on clawhdf5, three probes: no cmake → "is `cmake` not installed?" exit 101 after 13s no python3-dev → "cannot find -lpython3.11" exit 101 at link with both → cargo test PASSES exit 0 after 69s This is not only the delivery gate. The AGENTS run in this image, so a coding phase was writing Rust it had no way to compile or test — which reframes the last run's 11 agent commits as unverifiable by construction. `images/agent-toolchain/Dockerfile` (the microVM path) has had `cmake build-essential` all along, and its own header warns about precisely this: "if `cargo` is present in one image and absent in another, the same mission passes or fails depending on which backend it landed on, and nothing says why." Both images now install the same set — it was missing `python3-dev` too. `images/runtime-toolchain.Dockerfile` is a thin local overlay so the laptop can run today without recompiling zeroclaw from the fork; it is meant to be deleted once a runtime image built from the corrected deploy/ Dockerfile is published. Also: a build failure is no longer reported as a red suite. Both are cargo exit 101, and `verify_tests` mapped every non-zero to `Failed(code)` — so a missing toolchain was recorded as the USER's tests failing. It now returns `CouldNotRun` with the reason when the output shows a compile or link failure. Deliberately narrow: a failing `assert!` still reads as red, because letting broken code past `on_green_tests` is the expensive direction to be wrong in. Both directions are pinned by tests built from today's two real samples. And the coding phase finally has a loop: `research_and_code.toml` declared `loop = "until_no_more_int_items"`, which `phase_config.rs` lists as DECLARED_BUT_UNREAD. Iteration is driven by `max_iterations` + `done_when`, and with `max_iterations = 1` and no `done_when` the phase ran ONCE and was never judged — reporting `completed` whatever it produced. Now 3 passes against a stated goal, wording per the measured rule (say what the tree must CONTAIN). Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
10341cf7fe
commit
53da4d7e6d
@@ -1029,6 +1029,39 @@ pub struct Publish {
|
|||||||
/// reason: this codebase has repeatedly found things reporting success while
|
/// 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
|
/// doing nothing, and a test suite that never ran must not license a push to a
|
||||||
/// mission branch.
|
/// mission branch.
|
||||||
|
/// Did the toolchain fail to BUILD the project, as opposed to building it and
|
||||||
|
/// finding failing tests?
|
||||||
|
///
|
||||||
|
/// Deliberately narrow. These three phrases are emitted by cargo/rustc only
|
||||||
|
/// when compilation or linking did not complete; a failing `assert!` produces
|
||||||
|
/// none of them. Anything not matched here stays a red suite, because guessing
|
||||||
|
/// "probably an environment problem" over a genuine test failure is the far
|
||||||
|
/// more expensive mistake — it would let broken code through the gate.
|
||||||
|
fn build_failed(output: &str) -> bool {
|
||||||
|
let o = output.to_ascii_lowercase();
|
||||||
|
o.contains("error: could not compile")
|
||||||
|
|| o.contains("error: linking with")
|
||||||
|
|| o.contains("error: failed to run custom build command")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first line that explains a build failure, for the artifact.
|
||||||
|
fn build_failure_excerpt(output: &str) -> String {
|
||||||
|
output
|
||||||
|
.lines()
|
||||||
|
.find(|l| {
|
||||||
|
let l = l.to_ascii_lowercase();
|
||||||
|
l.contains("error: could not compile")
|
||||||
|
|| l.contains("error: linking with")
|
||||||
|
|| l.contains("error: failed to run custom build command")
|
||||||
|
|| l.contains("cannot find -l")
|
||||||
|
|| l.contains("not installed")
|
||||||
|
})
|
||||||
|
.unwrap_or("")
|
||||||
|
.chars()
|
||||||
|
.take(300)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn verify_tests(repo: &Path, container: &str) -> TestOutcome {
|
pub async fn verify_tests(repo: &Path, container: &str) -> TestOutcome {
|
||||||
let Some(argv) = discover_test_command(repo) else {
|
let Some(argv) = discover_test_command(repo) else {
|
||||||
return TestOutcome::NoSuite;
|
return TestOutcome::NoSuite;
|
||||||
@@ -1046,14 +1079,32 @@ pub async fn verify_tests(repo: &Path, container: &str) -> TestOutcome {
|
|||||||
argv.join(" "),
|
argv.join(" "),
|
||||||
out.exit_code
|
out.exit_code
|
||||||
);
|
);
|
||||||
|
let text = out.combined();
|
||||||
match out.exit_code {
|
match out.exit_code {
|
||||||
Some(0) => TestOutcome::Passed,
|
Some(0) => TestOutcome::Passed,
|
||||||
|
// A suite that never COMPILED is not a red suite. Both are
|
||||||
|
// non-zero (cargo exits 101 either way), and calling the
|
||||||
|
// difference is what stops a missing toolchain being reported
|
||||||
|
// as the user's code being broken.
|
||||||
|
//
|
||||||
|
// Measured twice on clawhdf5 in one sitting: no `cmake` gave
|
||||||
|
// "is `cmake` not installed?", and no `python3-dev` gave
|
||||||
|
// "cannot find -lpython3.11" — both exit 101, both would have
|
||||||
|
// been recorded as `tests_status: "failed"` on a repo whose
|
||||||
|
// tests were never run. The branch suffix is `-wip` either way,
|
||||||
|
// so nothing ships differently; what changes is that the
|
||||||
|
// artifact now says which of the two happened.
|
||||||
|
Some(_) if build_failed(&text) => TestOutcome::CouldNotRun(format!(
|
||||||
|
"`{}` could not build the project: {}",
|
||||||
|
argv.join(" "),
|
||||||
|
build_failure_excerpt(&text)
|
||||||
|
)),
|
||||||
// An unreadable status is not a pass, and it is not a red
|
// An unreadable status is not a pass, and it is not a red
|
||||||
// suite either — the command may never have started.
|
// suite either — the command may never have started.
|
||||||
None => TestOutcome::CouldNotRun(format!(
|
None => TestOutcome::CouldNotRun(format!(
|
||||||
"`{}` produced no exit status: {}",
|
"`{}` produced no exit status: {}",
|
||||||
argv.join(" "),
|
argv.join(" "),
|
||||||
out.combined().chars().take(300).collect::<String>()
|
text.chars().take(300).collect::<String>()
|
||||||
)),
|
)),
|
||||||
Some(code) => TestOutcome::Failed(code),
|
Some(code) => TestOutcome::Failed(code),
|
||||||
}
|
}
|
||||||
@@ -1409,6 +1460,37 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A missing toolchain must not be reported as the user's tests failing.
|
||||||
|
/// Both are cargo exit 101; only the output distinguishes them, and this
|
||||||
|
/// session produced both real samples on clawhdf5.
|
||||||
|
#[test]
|
||||||
|
fn a_build_failure_is_not_a_red_suite() {
|
||||||
|
let no_cmake = "error: failed to run custom build command for `libz-ng-sys v1.1.29`\n\
|
||||||
|
is `cmake` not installed?";
|
||||||
|
let no_python = "= note: /usr/bin/ld: cannot find -lpython3.11: No such file or directory\n\
|
||||||
|
error: could not compile `clawhdf5-py` (lib) due to 1 previous error";
|
||||||
|
for sample in [no_cmake, no_python] {
|
||||||
|
assert!(build_failed(sample), "must read as a build failure: {sample}");
|
||||||
|
assert!(
|
||||||
|
!build_failure_excerpt(sample).is_empty(),
|
||||||
|
"the artifact needs a reason, not an empty string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The half that protects the gate: a genuinely failing test must STAY a
|
||||||
|
/// red suite. Mistaking one for an environment problem would let broken
|
||||||
|
/// code past `on_green_tests`, which is the expensive direction to be
|
||||||
|
/// wrong in.
|
||||||
|
#[test]
|
||||||
|
fn a_failing_test_is_still_a_red_suite() {
|
||||||
|
let red = "running 3 tests\n\
|
||||||
|
test math::adds ... FAILED\n\
|
||||||
|
failures:\n math::adds\n\
|
||||||
|
test result: FAILED. 2 passed; 1 failed; 0 ignored";
|
||||||
|
assert!(!build_failed(red), "a failing assertion is not a build failure");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_command_is_discovered_from_the_tree() {
|
fn test_command_is_discovered_from_the_tree() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -104,8 +104,29 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
ENV RUSTUP_HOME=/usr/local/rustup \
|
ENV RUSTUP_HOME=/usr/local/rustup \
|
||||||
CARGO_HOME=/usr/local/cargo \
|
CARGO_HOME=/usr/local/cargo \
|
||||||
PATH=/usr/local/cargo/bin:$PATH
|
PATH=/usr/local/cargo/bin:$PATH
|
||||||
|
# `cmake` and `build-essential` (for `g++`) are NOT optional here, even though
|
||||||
|
# the builder stage above already has them: a mission compiles the USER's repo
|
||||||
|
# in this stage, and a great many Rust crates drive a C/C++ build from their
|
||||||
|
# build script. `clawhdf5` is one — `libz-ng-sys` shells out to cmake, so
|
||||||
|
# `cargo test` died in 13 seconds with "is `cmake` not installed?" and exit 101.
|
||||||
|
#
|
||||||
|
# That exit is read as `TestOutcome::Failed(101)` — a RED SUITE — so the
|
||||||
|
# `on_green_tests` gate reported the repo's tests as failing when in truth they
|
||||||
|
# never compiled. A missing toolchain must not be indistinguishable from broken
|
||||||
|
# code.
|
||||||
|
#
|
||||||
|
# `python3-dev` is here for the same reason one layer down: `clawhdf5-py` is a
|
||||||
|
# pyo3 crate, so the link step wants `-lpython3.11` and fails with
|
||||||
|
# "cannot find -lpython3.11" without the dev package. `python3` alone ships no
|
||||||
|
# shared library to link against.
|
||||||
|
#
|
||||||
|
# `images/agent-toolchain/Dockerfile` (the microVM path) has installed
|
||||||
|
# `cmake build-essential` all along. Its own header warns about exactly this
|
||||||
|
# divergence: "if `cargo` is present in one image and absent in another, the
|
||||||
|
# same mission passes or fails depending on which backend it landed on, and
|
||||||
|
# nothing says why." This is that, one package down.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
gcc libc6-dev pkg-config libssl-dev make \
|
gcc libc6-dev pkg-config libssl-dev make cmake build-essential python3-dev \
|
||||||
&& curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable \
|
&& curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable \
|
||||||
&& cargo install cargo-audit --locked --no-default-features \
|
&& cargo install cargo-audit --locked --no-default-features \
|
||||||
&& rm -rf /var/lib/apt/lists/* "$CARGO_HOME/registry" "$CARGO_HOME/git" \
|
&& rm -rf /var/lib/apt/lists/* "$CARGO_HOME/registry" "$CARGO_HOME/git" \
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ ENV RUSTUP_HOME=/usr/local/rustup \
|
|||||||
CARGO_HOME=/usr/local/cargo \
|
CARGO_HOME=/usr/local/cargo \
|
||||||
PATH=/usr/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
PATH=/usr/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
gcc libc6-dev pkg-config libssl-dev make cmake build-essential \
|
gcc libc6-dev pkg-config libssl-dev make cmake build-essential python3-dev \
|
||||||
&& curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable \
|
&& curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable \
|
||||||
&& cargo install cargo-audit --locked --no-default-features \
|
&& cargo install cargo-audit --locked --no-default-features \
|
||||||
&& rm -rf /var/lib/apt/lists/* "$CARGO_HOME/registry" "$CARGO_HOME/git" \
|
&& rm -rf /var/lib/apt/lists/* "$CARGO_HOME/registry" "$CARGO_HOME/git" \
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# A thin overlay that adds the C/C++ build toolchain to an already-built
|
||||||
|
# runtime image.
|
||||||
|
#
|
||||||
|
# The durable fix lives in `deploy/clawmates-runtime/Dockerfile`, which now
|
||||||
|
# installs `cmake build-essential` in its runtime stage. But that Dockerfile
|
||||||
|
# also COMPILES zeroclaw from our fork in an earlier stage — a long build that
|
||||||
|
# needs credentials for git.redclaw.dev — so rebuilding it just to add two apt
|
||||||
|
# packages is the wrong trade on a laptop.
|
||||||
|
#
|
||||||
|
# This layers those packages onto the existing image instead, which is exact:
|
||||||
|
# the same apt, the same distro, on top of the same base. Build and point the
|
||||||
|
# server at it with:
|
||||||
|
#
|
||||||
|
# docker build -f images/runtime-toolchain.Dockerfile \
|
||||||
|
# -t clawmates-runtime:toolchain images/
|
||||||
|
# CLAWMATES_RUNTIME_IMAGE=clawmates-runtime:toolchain
|
||||||
|
#
|
||||||
|
# Once a runtime image built from the corrected deploy/ Dockerfile is published,
|
||||||
|
# this file has no reason to exist — delete it rather than letting a second
|
||||||
|
# source of toolchain truth persist.
|
||||||
|
ARG BASE=clawmates-runtime:sync
|
||||||
|
FROM ${BASE}
|
||||||
|
|
||||||
|
# `cmake` and `g++` (via build-essential) plus `python3-dev` for pyo3 crates
|
||||||
|
# that link `-lpython3.11`. Missing them made `cargo test` on a repo whose
|
||||||
|
# deps drive a cmake build exit 101, which the delivery gate reads as a red
|
||||||
|
# suite rather than as "could not build".
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends cmake build-essential python3-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& cmake --version | head -1 \
|
||||||
|
&& c++ --version | head -1 \
|
||||||
|
&& ls /usr/lib/*/libpython3*.so | head -1
|
||||||
@@ -16,10 +16,23 @@ default_topology = "hub_spoke"
|
|||||||
kind = "coding"
|
kind = "coding"
|
||||||
order_idx = 1
|
order_idx = 1
|
||||||
[phases.config]
|
[phases.config]
|
||||||
# Loop policy: keep iterating until the artifact yields no more
|
# NOTE: `loop` is INERT. `phase_config.rs` lists it under
|
||||||
# unconsumed INT-XX items. Scheduler stops when `consumed_int_ids`
|
# DECLARED_BUT_UNREAD — "NOT IMPLEMENTED — phase iteration uses
|
||||||
# equals the artifact's declared set.
|
# max_iterations + done_when". It is kept only so the intent stays
|
||||||
|
# visible next to the keys that actually drive the loop.
|
||||||
loop = "until_no_more_int_items"
|
loop = "until_no_more_int_items"
|
||||||
|
# The real loop. Without `done_when` the phase never enters
|
||||||
|
# `evaluating`, is never judged, and reports `completed` whatever it
|
||||||
|
# did — which is how this workflow ran once, unvalidated, and still
|
||||||
|
# went green. With it, `evaluate_finished_phases` judges each pass and
|
||||||
|
# re-queues with the verdict's guidance until the condition is met or
|
||||||
|
# the passes run out (running out is a FAILURE, not a quiet success).
|
||||||
|
#
|
||||||
|
# Wording follows the measured rule: say what the tree must CONTAIN.
|
||||||
|
# Positional phrasing or "and nothing else" makes the judge invent
|
||||||
|
# requirements it was never given.
|
||||||
|
done_when = "the repository contains an implementation for each INT-XX item listed in the research phase's IMPLEMENTATION_BRIEF, and `cargo test` passes"
|
||||||
|
max_iterations = 3
|
||||||
# Preamble injected at the head of each iteration's task text so
|
# Preamble injected at the head of each iteration's task text so
|
||||||
# the agents know where the repo lives + how to commit. Covered by
|
# the agents know where the repo lives + how to commit. Covered by
|
||||||
# Slice 3.5c's `workspace-repo-commit-protocol` skill.
|
# Slice 3.5c's `workspace-repo-commit-protocol` skill.
|
||||||
|
|||||||
Reference in New Issue
Block a user