Compare commits
2
Commits
022ef98e44
...
26e571fe01
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26e571fe01 | ||
|
|
548f977212 |
@@ -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"
|
|
||||||
@@ -280,7 +280,11 @@ async fn register_empty_marker(
|
|||||||
/// the end of the turn (`microvm_executor` writes them over
|
/// the end of the turn (`microvm_executor` writes them over
|
||||||
/// `mission_workspace::checkout_path`). Asking docker for a VM mission's files
|
/// `mission_workspace::checkout_path`). Asking docker for a VM mission's files
|
||||||
/// would query a container that never existed.
|
/// 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
|
// 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.
|
// output — the same "captured a tree nobody wrote" shape capture avoids.
|
||||||
let _ = std::fs::remove_dir_all(dest);
|
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.
|
/// shell-out, and this runs as the server's own uid against its own directory.
|
||||||
fn copy_tree(src: &Path, dest: &Path) -> Result<(), String> {
|
fn copy_tree(src: &Path, dest: &Path) -> Result<(), String> {
|
||||||
std::fs::create_dir_all(dest).map_err(|e| format!("create {}: {e}", dest.display()))?;
|
std::fs::create_dir_all(dest).map_err(|e| format!("create {}: {e}", dest.display()))?;
|
||||||
let entries =
|
let entries = std::fs::read_dir(src).map_err(|e| format!("read {}: {e}", src.display()))?;
|
||||||
std::fs::read_dir(src).map_err(|e| format!("read {}: {e}", src.display()))?;
|
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
let from = entry.path();
|
let from = entry.path();
|
||||||
let to = dest.join(entry.file_name());
|
let to = dest.join(entry.file_name());
|
||||||
@@ -466,9 +469,7 @@ mod tests {
|
|||||||
|
|
||||||
// ...and the traversal shape this guards against is not, once resolved.
|
// ...and the traversal shape this guards against is not, once resolved.
|
||||||
let escaped = root.join("..").join("..").join("etc/passwd");
|
let escaped = root.join("..").join("..").join("etc/passwd");
|
||||||
let normalised: PathBuf = escaped
|
let normalised: PathBuf = escaped.components().fold(PathBuf::new(), |mut acc, c| {
|
||||||
.components()
|
|
||||||
.fold(PathBuf::new(), |mut acc, c| {
|
|
||||||
match c {
|
match c {
|
||||||
std::path::Component::ParentDir => {
|
std::path::Component::ParentDir => {
|
||||||
acc.pop();
|
acc.pop();
|
||||||
|
|||||||
@@ -361,8 +361,6 @@ use crate::container_exec::MISSION_UID;
|
|||||||
|
|
||||||
const DEFAULT_SEED_DIR: &str = "/root/clawmates-runtime/data";
|
const DEFAULT_SEED_DIR: &str = "/root/clawmates-runtime/data";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// What a mission gets its own copy of.
|
/// What a mission gets its own copy of.
|
||||||
///
|
///
|
||||||
/// An allow-list, not the whole directory. The seed dir is **1.7 GB** on
|
/// 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()),
|
"GROQ_API_KEY" => Some("real".into()),
|
||||||
_ => None,
|
_ => 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]
|
#[test]
|
||||||
@@ -1505,8 +1506,8 @@ mod tests {
|
|||||||
fn the_two_anthropic_credentials_are_mutually_exclusive() {
|
fn the_two_anthropic_credentials_are_mutually_exclusive() {
|
||||||
for mode in [RuntimeAuth::ApiKey, RuntimeAuth::Subscription] {
|
for mode in [RuntimeAuth::ApiKey, RuntimeAuth::Subscription] {
|
||||||
let keys = forwarded_provider_keys(mode);
|
let keys = forwarded_provider_keys(mode);
|
||||||
let both = keys.contains(&"ANTHROPIC_API_KEY")
|
let both =
|
||||||
&& keys.contains(&"CLAUDE_CODE_OAUTH_TOKEN");
|
keys.contains(&"ANTHROPIC_API_KEY") && keys.contains(&"CLAUDE_CODE_OAUTH_TOKEN");
|
||||||
assert!(!both, "{mode:?} forwards both credentials: {keys:?}");
|
assert!(!both, "{mode:?} forwards both credentials: {keys:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1761,7 +1762,10 @@ allowed_tools = ["file_read", "file_edit"]
|
|||||||
let script = copy_script();
|
let script = copy_script();
|
||||||
// A missing entry is normal on a fresh deployment (no .kimi-code
|
// A missing entry is normal on a fresh deployment (no .kimi-code
|
||||||
// until Kimi is first used) and must not fail the copy.
|
// 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/.zeroclaw"));
|
||||||
assert!(!script.contains("/seed/.rustup"));
|
assert!(!script.contains("/seed/.rustup"));
|
||||||
for p in SEEDED_PATHS {
|
for p in SEEDED_PATHS {
|
||||||
|
|||||||
@@ -180,9 +180,8 @@ async fn capture_finished_coding_phases(pool: &PgPool) -> Result<(), String> {
|
|||||||
delivered NO files — failing it. Set config.allow_empty = true if \
|
delivered NO files — failing it. Set config.allow_empty = true if \
|
||||||
this phase is meant to verify rather than change."
|
this phase is meant to verify rather than change."
|
||||||
);
|
);
|
||||||
if let Err(e) = sqlx::query(
|
if let Err(e) =
|
||||||
"UPDATE mission_phases SET status = 'failed' WHERE id = $1",
|
sqlx::query("UPDATE mission_phases SET status = 'failed' WHERE id = $1")
|
||||||
)
|
|
||||||
.bind(phase_id)
|
.bind(phase_id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await
|
.await
|
||||||
@@ -239,7 +238,10 @@ mod repo_less_text_tests {
|
|||||||
);
|
);
|
||||||
// And it must say what DOES happen to the files, or an agent told only
|
// 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.
|
// "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}");
|
assert!(without.contains("NO git repository"), "{without}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,7 +252,10 @@ mod repo_less_text_tests {
|
|||||||
fn both_variants_still_demand_files_on_disk() {
|
fn both_variants_still_demand_files_on_disk() {
|
||||||
for has_repo in [true, false] {
|
for has_repo in [true, false] {
|
||||||
let t = phase_task_text("research", "T", None, None, has_repo);
|
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"] {
|
for real in ["Read", "Edit", "Write", "Bash", "Glob", "Grep"] {
|
||||||
assert!(t.contains(real), "missing {real}: {t}");
|
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!(
|
assert!(
|
||||||
!t.contains(absent),
|
!t.contains(absent),
|
||||||
"{absent} does not exist under claude_cli — advertising it is the bug: {t}"
|
"{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
|
// 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.
|
// runtime binding it writes describe a runtime nothing is using.
|
||||||
let needs_container = p.runtime_kind != "microvm";
|
let needs_container = p.runtime_kind != "microvm";
|
||||||
if let Some(prov) = crate::mission_runtime::MissionRuntimeProvisioner::from_env()
|
if let Some(prov) =
|
||||||
.filter(|_| needs_container)
|
crate::mission_runtime::MissionRuntimeProvisioner::from_env().filter(|_| needs_container)
|
||||||
{
|
{
|
||||||
match prov.ensure_container(mission_id).await {
|
match prov.ensure_container(mission_id).await {
|
||||||
Ok(ec) => {
|
Ok(ec) => {
|
||||||
@@ -813,7 +823,9 @@ async fn launch_phase(
|
|||||||
capacity_note = $2 WHERE id = $1",
|
capacity_note = $2 WHERE id = $1",
|
||||||
)
|
)
|
||||||
.bind(phase_id)
|
.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)
|
.execute(pool)
|
||||||
.await;
|
.await;
|
||||||
return Err(format!("phase {phase_id} exhausted its capacity wait"));
|
return Err(format!("phase {phase_id} exhausted its capacity wait"));
|
||||||
@@ -1174,9 +1186,8 @@ async fn launch_microvm_phase(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
if !has_repo {
|
if !has_repo {
|
||||||
std::fs::create_dir_all(&repo).map_err(|e| {
|
std::fs::create_dir_all(&repo)
|
||||||
format!("create empty workspace {}: {e}", repo.display())
|
.map_err(|e| format!("create empty workspace {}: {e}", repo.display()))?;
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
crate::microvm_executor::run_phase_in_vm(
|
crate::microvm_executor::run_phase_in_vm(
|
||||||
&hub,
|
&hub,
|
||||||
@@ -1212,19 +1223,28 @@ async fn launch_microvm_phase(
|
|||||||
// Counted from the guest's own per-subagent transcripts. "-" means the
|
// 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".
|
// probe could not run, which is not the same as "it delegated to nobody".
|
||||||
let subagents = match &outcome {
|
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(),
|
Err(_) => "-".into(),
|
||||||
};
|
};
|
||||||
// Only meaningful for a mission that asked for a team; "-" otherwise.
|
// Only meaningful for a mission that asked for a team; "-" otherwise.
|
||||||
let teammates = match &outcome {
|
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(),
|
Err(_) => "-".into(),
|
||||||
};
|
};
|
||||||
// How often the completion gate sent the agent back inside its own turn.
|
// 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
|
// "-" 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.
|
// the whole measurement of whether the gate is worth its hook.
|
||||||
let blocked = match &outcome {
|
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(),
|
Err(_) => "-".into(),
|
||||||
};
|
};
|
||||||
// What the agent touched inside the VM, recorded before the outcome is
|
// 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.
|
// is nothing for delivery to find.
|
||||||
Ok(o) if !o.collected => (
|
Ok(o) if !o.collected => (
|
||||||
"failed",
|
"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),
|
Ok(o) => ("failed", o.summary),
|
||||||
Err(e) => ("failed", e),
|
Err(e) => ("failed", e),
|
||||||
@@ -1398,9 +1421,7 @@ async fn launch_direct_session(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
eprintln!(
|
eprintln!("phase_runner: mission {mission_id} phase {phase_id} launched as a DIRECT SESSION");
|
||||||
"phase_runner: mission {mission_id} phase {phase_id} launched as a DIRECT SESSION"
|
|
||||||
);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1432,7 +1453,8 @@ fn phase_task_text(
|
|||||||
// ones absent, and Bash was one of the ones present. Agents answered by
|
// 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
|
// describing the mismatch and asking what to do — five of them, on one
|
||||||
// mission, for 7.4k tokens and zero artifacts.
|
// 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\
|
TOOLS AVAILABLE (Claude Code's standard tools — use these exact names):\n\
|
||||||
- Read — read a file\n\
|
- Read — read a file\n\
|
||||||
- Edit — modify an existing file\n\
|
- Edit — modify an existing file\n\
|
||||||
@@ -1625,7 +1647,10 @@ async fn mark_phase_running(pool: &PgPool, mission_id: Uuid, phase_id: Uuid) ->
|
|||||||
if claimed.is_some() {
|
if claimed.is_some() {
|
||||||
crate::mission_events::record(
|
crate::mission_events::record(
|
||||||
pool,
|
pool,
|
||||||
crate::mission_events::MissionEvent::new(mission_id, crate::mission_events::PHASE_STARTED)
|
crate::mission_events::MissionEvent::new(
|
||||||
|
mission_id,
|
||||||
|
crate::mission_events::PHASE_STARTED,
|
||||||
|
)
|
||||||
.phase(phase_id),
|
.phase(phase_id),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -2151,16 +2176,28 @@ mod tests {
|
|||||||
let alpha = phase_task_text("coding", "Demo", brief, Some("Create ALPHA.md"), true);
|
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);
|
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!(beta.contains("Create BETA.md"));
|
||||||
assert!(!alpha.contains("BETA.md"), "a phase must not see its sibling's task");
|
assert!(
|
||||||
assert_ne!(alpha, beta, "sibling phases received identical instructions");
|
!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.
|
// A phase with no task of its own is unchanged from before the fix.
|
||||||
let bare = phase_task_text("coding", "Demo", brief, None, true);
|
let bare = phase_task_text("coding", "Demo", brief, None, true);
|
||||||
assert!(!bare.contains("THIS PHASE'S TASK"));
|
assert!(!bare.contains("THIS PHASE'S TASK"));
|
||||||
// Empty and whitespace-only configs take the same path as absent.
|
// 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.
|
/// The marker syntax we hand the agent must be the syntax we parse back.
|
||||||
|
|||||||
Reference in New Issue
Block a user