2 Commits
Author SHA1 Message Date
Omar SobhandClaude Opus 5 26e571fe01 ci: drop .github/workflows/ci.yml
deploy / test (push) Successful in 4m31s
deploy / build (push) Successful in 6m11s
It targets `runs-on: ubuntu-latest`, which no runner on this Gitea provides, so
every push left a failed job in the Actions tab. .gitea/workflows/deploy.yml now
covers the gates that actually hold: the full `cargo test --workspace` (including
the DB- and docker-backed integration suites, which this workflow never ran) plus
frontend typecheck and tests.

What is deliberately NOT carried over, because none of it passes today and
silently keeping a red gate is worse than removing it:
  cargo fmt --all --check      63 files drift
  clippy -D warnings           pre-existing warnings across the workspace
  ci/check-loc.sh              MissionWizard.tsx 1153 lines vs a 1100 soft limit
  ci/check-no-placeholders.sh  false positive on `vec!["rg", "TODO", "src"]`,
                               which is test DATA, not a placeholder
  playwright e2e               needs a browser toolchain on the runner

Re-adopting any of these is a cleanup project, not a workflow edit. The scripts
under ci/ are kept so that work has somewhere to start.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-13 12:19:43 -07:00
Omar SobhandClaude Opus 5 548f977212 style: rustfmt the four files the repo-less mission fix touched
Found while removing .github/workflows/ci.yml: three of the four files in that
change were unformatted, and four of the diffs were newly introduced (the new
prompt tests and the tool_preamble format! call). Formatting only the files that
change already touched — a repo-wide `cargo fmt` would be 63 files of unrelated
churn and belongs in its own commit.

Mechanical; `cargo test -p cm-api --lib` stays at 322 passed.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-13 12:19:43 -07:00
4 changed files with 90 additions and 257 deletions
-209
View File
@@ -1,209 +0,0 @@
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
gates:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: File size budget (1500 lines)
run: ./ci/check-loc.sh
- name: No placeholder markers
run: ./ci/check-no-placeholders.sh
- name: Compose config validates
run: POSTGRES_PASSWORD=ci docker compose -f deploy/compose/docker-compose.yml config -q
rust:
runs-on: ubuntu-latest
needs: gates
# Compile sqlx query! macros against the committed .sqlx cache (no DB needed).
# Tests need a live Postgres — locally cm-testkit reads CM_TEST_DATABASE_URL
# from .cargo/config.toml pointing at scripts/test-server.sh's host container.
# The fleet act_runner uses the `host` executor (jobs run on morpheus/tank/
# architect natively, not inside a container), so we start a per-run postgres
# container and reach it via its bridge IP. GITHUB_RUN_ID scopes the name so
# concurrent jobs on the same runner don't collide.
#
# GIT_CONFIG_GLOBAL points at a per-job empty file so cargo's git fetches
# bypass the runner's includeIf mapping of git.redclaw.dev → /slab/projects
# (local mirror lags and misses recently-pinned commits like the clawverse
# rev cm-brain depends on). clawverse is public; no auth needed.
env:
SQLX_OFFLINE: "true"
GIT_CONFIG_GLOBAL: /tmp/ci-empty-gitconfig-${{ github.run_id }}
steps:
- name: Prepare empty gitconfig for cargo fetches
run: touch "$GIT_CONFIG_GLOBAL"
- uses: actions/checkout@v4
- name: Start postgres sidecar
run: |
set -euo pipefail
NAME="ci-pg-${GITHUB_RUN_ID}"
docker rm -f "$NAME" >/dev/null 2>&1 || true
docker run -d --name "$NAME" \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=postgres \
postgres:16-alpine >/dev/null
# `.NetworkSettings.IPAddress` is empty (and template-parse errors) on
# modern Docker where the IP lives under `.Networks.<name>.IPAddress`.
# The range form picks the first non-empty IP across whatever network
# docker put the container on.
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$NAME")
if [ -z "$PG_IP" ]; then
echo "postgres has no reachable IP" >&2
docker inspect "$NAME" >&2
exit 1
fi
echo "PG_CONTAINER=$NAME" >> "$GITHUB_ENV"
echo "CM_TEST_DATABASE_URL=postgres://postgres:postgres@${PG_IP}:5432/postgres" >> "$GITHUB_ENV"
for i in $(seq 1 30); do
if docker exec "$NAME" pg_isready -U postgres -q >/dev/null 2>&1; then
echo "postgres ready at ${PG_IP} after ${i}s"
exit 0
fi
sleep 1
done
echo "postgres never became ready" >&2
docker logs "$NAME" >&2 || true
exit 1
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.96.0
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- name: Format
run: cargo fmt --all --check
- name: Clippy
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Test
run: |
set -euo pipefail
# Re-derive the postgres URL inline instead of trusting that
# CM_TEST_DATABASE_URL propagated through $GITHUB_ENV — act_runner
# v1.0.8 has been observed to swallow env-file writes here.
IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG_CONTAINER")
[ -n "$IP" ] || { echo "no PG IP" >&2; exit 1; }
export CM_TEST_DATABASE_URL="postgres://postgres:postgres@${IP}:5432/postgres"
echo "using $CM_TEST_DATABASE_URL"
cargo test --workspace
- name: Air-gapped installer verify path
run: ./ci/test-install.sh
- name: Cleanup postgres sidecar
if: always()
run: docker rm -f "${PG_CONTAINER:-}" >/dev/null 2>&1 || true
frontend:
runs-on: ubuntu-latest
needs: gates
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install
run: npm ci
if: ${{ hashFiles('frontend/package-lock.json') != '' }}
- name: Lint
run: npm run lint
if: ${{ hashFiles('frontend/package-lock.json') != '' }}
- name: Typecheck
run: npm run typecheck
if: ${{ hashFiles('frontend/package-lock.json') != '' }}
- name: Unit and component tests
run: npm test
if: ${{ hashFiles('frontend/package-lock.json') != '' }}
# e2e is intentionally disabled for now. The suite has real product/test
# drift (locators pointing at older versions of pages) that would need a
# dedicated pass to reconcile — see the earlier follow-up notes. Publish
# doesn't depend on this job anyway, but keeping it enabled produced a
# steady red on every push that wasn't actionable. Flip `if:` back to
# `true` (or delete the guard) when the tests get realigned.
e2e:
if: false
runs-on: ubuntu-latest
needs: [rust, frontend]
env:
SQLX_OFFLINE: "true"
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.96.0
- uses: Swatinem/rust-cache@v2
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Install Playwright browsers
working-directory: frontend
run: npx playwright install --with-deps chromium
- name: Run end-to-end journeys against the real backend
working-directory: frontend
run: npx playwright test --grep-invert "@visual"
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-traces
path: frontend/test-results/
# Rolling deploy: on green main only, build the three prod images, tag with
# :main-<sha> + :latest, push to the fleet registry (redclaw-web-01:5000 via
# its Tailscale IP — the fleet's daemons trust it in insecure-registries by
# IP, not by hostname). GW-04's clawmates-deploy.timer rolls forward within
# ~1 minute of the push. Skipped on PRs.
#
# `e2e` is intentionally NOT in `needs`: it launches its own postgres + dex
# via `docker run` on the host and then reaches them via 127.0.0.1, which
# fails from inside the act_runner container. Migrating e2e to a physical
# build node is a separate task; until then e2e is signal-only, not gating.
# `rust` was restored to `needs` once the flakes were rooted out (approvals
# SSE race + warm_pool agent-seeding + a couple health-check ambiguities).
publish:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
needs: [gates, rust, frontend]
env:
REGISTRY: 100.94.185.103:5000
NAMESPACE: clawmates
steps:
- uses: actions/checkout@v4
- name: Resolve short SHA
run: echo "SHA=${GITHUB_SHA::7}" >> "$GITHUB_ENV"
- name: Build images
run: |
set -euo pipefail
for svc in broker server frontend; do
docker build \
-t "${REGISTRY}/${NAMESPACE}/${svc}:main-${SHA}" \
-t "${REGISTRY}/${NAMESPACE}/${svc}:latest" \
-f "images/${svc}.Dockerfile" .
done
- name: Push images
run: |
set -euo pipefail
for svc in broker server frontend; do
docker push "${REGISTRY}/${NAMESPACE}/${svc}:main-${SHA}"
docker push "${REGISTRY}/${NAMESPACE}/${svc}:latest"
done
- name: Summary
run: |
{
echo "## Published images"
echo ""
for svc in broker server frontend; do
echo "- \`${REGISTRY}/${NAMESPACE}/${svc}:main-${SHA}\`"
echo "- \`${REGISTRY}/${NAMESPACE}/${svc}:latest\`"
done
echo ""
echo "GW-04 timer picks these up within ~1 minute."
} >> "$GITHUB_STEP_SUMMARY"
+14 -13
View File
@@ -280,7 +280,11 @@ async fn register_empty_marker(
/// the end of the turn (`microvm_executor` writes them over
/// `mission_workspace::checkout_path`). Asking docker for a VM mission's files
/// would query a container that never existed.
async fn collect_into(mission_id: Uuid, dest: &Path, runtime_kind: &str) -> Result<Vec<PathBuf>, String> {
async fn collect_into(
mission_id: Uuid,
dest: &Path,
runtime_kind: &str,
) -> Result<Vec<PathBuf>, String> {
// A stale copy from an earlier attempt would be registered as this pass's
// output — the same "captured a tree nobody wrote" shape capture avoids.
let _ = std::fs::remove_dir_all(dest);
@@ -308,8 +312,7 @@ async fn collect_into(mission_id: Uuid, dest: &Path, runtime_kind: &str) -> Resu
/// shell-out, and this runs as the server's own uid against its own directory.
fn copy_tree(src: &Path, dest: &Path) -> Result<(), String> {
std::fs::create_dir_all(dest).map_err(|e| format!("create {}: {e}", dest.display()))?;
let entries =
std::fs::read_dir(src).map_err(|e| format!("read {}: {e}", src.display()))?;
let entries = std::fs::read_dir(src).map_err(|e| format!("read {}: {e}", src.display()))?;
for entry in entries.flatten() {
let from = entry.path();
let to = dest.join(entry.file_name());
@@ -466,17 +469,15 @@ mod tests {
// ...and the traversal shape this guards against is not, once resolved.
let escaped = root.join("..").join("..").join("etc/passwd");
let normalised: PathBuf = escaped
.components()
.fold(PathBuf::new(), |mut acc, c| {
match c {
std::path::Component::ParentDir => {
acc.pop();
}
other => acc.push(other),
let normalised: PathBuf = escaped.components().fold(PathBuf::new(), |mut acc, c| {
match c {
std::path::Component::ParentDir => {
acc.pop();
}
acc
});
other => acc.push(other),
}
acc
});
assert!(
!normalised.starts_with(&root),
"a traversal must not resolve back inside the outputs root: {normalised:?}"
+10 -6
View File
@@ -361,8 +361,6 @@ use crate::container_exec::MISSION_UID;
const DEFAULT_SEED_DIR: &str = "/root/clawmates-runtime/data";
/// What a mission gets its own copy of.
///
/// An allow-list, not the whole directory. The seed dir is **1.7 GB** on
@@ -1466,7 +1464,10 @@ mod tests {
"GROQ_API_KEY" => Some("real".into()),
_ => None,
});
assert_eq!(pairs, vec![("GROQ_API_KEY".to_string(), "real".to_string())]);
assert_eq!(
pairs,
vec![("GROQ_API_KEY".to_string(), "real".to_string())]
);
}
#[test]
@@ -1505,8 +1506,8 @@ mod tests {
fn the_two_anthropic_credentials_are_mutually_exclusive() {
for mode in [RuntimeAuth::ApiKey, RuntimeAuth::Subscription] {
let keys = forwarded_provider_keys(mode);
let both = keys.contains(&"ANTHROPIC_API_KEY")
&& keys.contains(&"CLAUDE_CODE_OAUTH_TOKEN");
let both =
keys.contains(&"ANTHROPIC_API_KEY") && keys.contains(&"CLAUDE_CODE_OAUTH_TOKEN");
assert!(!both, "{mode:?} forwards both credentials: {keys:?}");
}
}
@@ -1761,7 +1762,10 @@ allowed_tools = ["file_read", "file_edit"]
let script = copy_script();
// A missing entry is normal on a fresh deployment (no .kimi-code
// until Kimi is first used) and must not fail the copy.
assert!(script.contains("if [ -e "), "absent paths must be tolerated");
assert!(
script.contains("if [ -e "),
"absent paths must be tolerated"
);
assert!(script.contains("/seed/.zeroclaw"));
assert!(!script.contains("/seed/.rustup"));
for p in SEEDED_PATHS {
+66 -29
View File
@@ -180,12 +180,11 @@ async fn capture_finished_coding_phases(pool: &PgPool) -> Result<(), String> {
delivered NO files — failing it. Set config.allow_empty = true if \
this phase is meant to verify rather than change."
);
if let Err(e) = sqlx::query(
"UPDATE mission_phases SET status = 'failed' WHERE id = $1",
)
.bind(phase_id)
.execute(pool)
.await
if let Err(e) =
sqlx::query("UPDATE mission_phases SET status = 'failed' WHERE id = $1")
.bind(phase_id)
.execute(pool)
.await
{
eprintln!("phase_runner: failing empty phase {phase_id}: {e}");
}
@@ -239,7 +238,10 @@ mod repo_less_text_tests {
);
// And it must say what DOES happen to the files, or an agent told only
// "there is no repo" has no reason to write them to disk at all.
assert!(without.contains("published as a mission artifact"), "{without}");
assert!(
without.contains("published as a mission artifact"),
"{without}"
);
assert!(without.contains("NO git repository"), "{without}");
}
@@ -250,7 +252,10 @@ mod repo_less_text_tests {
fn both_variants_still_demand_files_on_disk() {
for has_repo in [true, false] {
let t = phase_task_text("research", "T", None, None, has_repo);
assert!(t.contains("REAL files with Write/Edit"), "has_repo={has_repo}: {t}");
assert!(
t.contains("REAL files with Write/Edit"),
"has_repo={has_repo}: {t}"
);
}
}
@@ -266,7 +271,12 @@ mod repo_less_text_tests {
for real in ["Read", "Edit", "Write", "Bash", "Glob", "Grep"] {
assert!(t.contains(real), "missing {real}: {t}");
}
for absent in ["file_edit", "content_search", "glob_search", "git_operations"] {
for absent in [
"file_edit",
"content_search",
"glob_search",
"git_operations",
] {
assert!(
!t.contains(absent),
"{absent} does not exist under claude_cli — advertising it is the bug: {t}"
@@ -653,8 +663,8 @@ async fn launch_phase(
// and then sit idle for the life of the mission — while the pairing code and
// runtime binding it writes describe a runtime nothing is using.
let needs_container = p.runtime_kind != "microvm";
if let Some(prov) = crate::mission_runtime::MissionRuntimeProvisioner::from_env()
.filter(|_| needs_container)
if let Some(prov) =
crate::mission_runtime::MissionRuntimeProvisioner::from_env().filter(|_| needs_container)
{
match prov.ensure_container(mission_id).await {
Ok(ec) => {
@@ -813,7 +823,9 @@ async fn launch_phase(
capacity_note = $2 WHERE id = $1",
)
.bind(phase_id)
.bind(format!("waited {blocked_for:.0}s for fleet capacity:\n{msg}"))
.bind(format!(
"waited {blocked_for:.0}s for fleet capacity:\n{msg}"
))
.execute(pool)
.await;
return Err(format!("phase {phase_id} exhausted its capacity wait"));
@@ -1174,9 +1186,8 @@ async fn launch_microvm_phase(
));
}
if !has_repo {
std::fs::create_dir_all(&repo).map_err(|e| {
format!("create empty workspace {}: {e}", repo.display())
})?;
std::fs::create_dir_all(&repo)
.map_err(|e| format!("create empty workspace {}: {e}", repo.display()))?;
}
crate::microvm_executor::run_phase_in_vm(
&hub,
@@ -1212,19 +1223,28 @@ async fn launch_microvm_phase(
// Counted from the guest's own per-subagent transcripts. "-" means the
// probe could not run, which is not the same as "it delegated to nobody".
let subagents = match &outcome {
Ok(o) => o.subagents.map(|n| n.to_string()).unwrap_or_else(|| "?".into()),
Ok(o) => o
.subagents
.map(|n| n.to_string())
.unwrap_or_else(|| "?".into()),
Err(_) => "-".into(),
};
// Only meaningful for a mission that asked for a team; "-" otherwise.
let teammates = match &outcome {
Ok(o) => o.teammates.map(|n| n.to_string()).unwrap_or_else(|| "-".into()),
Ok(o) => o
.teammates
.map(|n| n.to_string())
.unwrap_or_else(|| "-".into()),
Err(_) => "-".into(),
};
// How often the completion gate sent the agent back inside its own turn.
// "-" is no gate; a number is how many second chances it took, which is
// the whole measurement of whether the gate is worth its hook.
let blocked = match &outcome {
Ok(o) => o.stop_blocks.map(|n| n.to_string()).unwrap_or_else(|| "-".into()),
Ok(o) => o
.stop_blocks
.map(|n| n.to_string())
.unwrap_or_else(|| "-".into()),
Err(_) => "-".into(),
};
// What the agent touched inside the VM, recorded before the outcome is
@@ -1260,7 +1280,10 @@ async fn launch_microvm_phase(
// is nothing for delivery to find.
Ok(o) if !o.collected => (
"failed",
format!("the agent's work could not be collected from the VM: {}", o.summary),
format!(
"the agent's work could not be collected from the VM: {}",
o.summary
),
),
Ok(o) => ("failed", o.summary),
Err(e) => ("failed", e),
@@ -1398,9 +1421,7 @@ async fn launch_direct_session(
}
});
eprintln!(
"phase_runner: mission {mission_id} phase {phase_id} launched as a DIRECT SESSION"
);
eprintln!("phase_runner: mission {mission_id} phase {phase_id} launched as a DIRECT SESSION");
Ok(())
}
@@ -1432,7 +1453,8 @@ fn phase_task_text(
// ones absent, and Bash was one of the ones present. Agents answered by
// describing the mismatch and asking what to do — five of them, on one
// mission, for 7.4k tokens and zero artifacts.
let tool_preamble = format!("\
let tool_preamble = format!(
"\
TOOLS AVAILABLE (Claude Code's standard tools — use these exact names):\n\
- Read — read a file\n\
- Edit — modify an existing file\n\
@@ -1625,8 +1647,11 @@ async fn mark_phase_running(pool: &PgPool, mission_id: Uuid, phase_id: Uuid) ->
if claimed.is_some() {
crate::mission_events::record(
pool,
crate::mission_events::MissionEvent::new(mission_id, crate::mission_events::PHASE_STARTED)
.phase(phase_id),
crate::mission_events::MissionEvent::new(
mission_id,
crate::mission_events::PHASE_STARTED,
)
.phase(phase_id),
)
.await;
}
@@ -2151,16 +2176,28 @@ mod tests {
let alpha = phase_task_text("coding", "Demo", brief, Some("Create ALPHA.md"), true);
let beta = phase_task_text("coding", "Demo", brief, Some("Create BETA.md"), true);
assert!(alpha.contains("Create ALPHA.md"), "phase task must be injected");
assert!(
alpha.contains("Create ALPHA.md"),
"phase task must be injected"
);
assert!(beta.contains("Create BETA.md"));
assert!(!alpha.contains("BETA.md"), "a phase must not see its sibling's task");
assert_ne!(alpha, beta, "sibling phases received identical instructions");
assert!(
!alpha.contains("BETA.md"),
"a phase must not see its sibling's task"
);
assert_ne!(
alpha, beta,
"sibling phases received identical instructions"
);
// A phase with no task of its own is unchanged from before the fix.
let bare = phase_task_text("coding", "Demo", brief, None, true);
assert!(!bare.contains("THIS PHASE'S TASK"));
// Empty and whitespace-only configs take the same path as absent.
assert_eq!(bare, phase_task_text("coding", "Demo", brief, Some(" "), true));
assert_eq!(
bare,
phase_task_text("coding", "Demo", brief, Some(" "), true)
);
}
/// The marker syntax we hand the agent must be the syntax we parse back.