fix(missions): a repo-less mission threw away everything its agents wrote
`capture_finished_coding_phases` selects `AND m.repo_id IS NOT NULL`. Every
`research_only` mission is repo-less by design (`requires_repo = false`), so the
whole capture path — including the `sync_out` that copies the agent's work OUT
of the container — never ran, and the container was reaped unread.
Measured on the real mission `019fdc35` ("ClawHDF5 Research"): four agents, 9.5
minutes, EIGHT research documents — an HDF5 parser design, a Rust ecosystem
survey, a seven-crate dependency map, tracing and fuzzing strategy. Result:
`mission_artifacts` = 0, mission `completed`. Not recoverable: no container, no
volume, nothing under the missions root.
The platform did not merely fail to save the work — it INSTRUCTED it. The task
preamble tells every agent "/mission/repo ... is the mission's git checkout",
whether or not one exists, and the research directive says to save findings
there. One agent recorded the contradiction verbatim: "No git repo — file is
written." It looked, saw no repo, complied anyway.
Three changes, one per link in that chain:
1. `mission_outputs::capture_repo_less_phases` — copies `/mission/repo` out of
the container and registers each file as an artifact under `_outputs/`,
which is a SIBLING of the mission dir and survives `teardown_container`.
This is also the code that finally reads `produces`, until now an inert key:
`produces = ["md","pdf"]` now drives `render_pdf` into the existing
pdf_renderer worker.
2. The preamble is conditional. A repo-less mission is told its workspace is
scratch, that git_operations has nothing to act on, and — the part that
matters — that files left there ARE collected and published. An agent told
only "there is no repo" has no reason to write anything to disk.
3. A repo-less phase that produced no files is FAILED, unless it declares
`allow_empty`. The same rule `empty_delivery_is_a_failure` applies to coding,
for the only channel these phases have. Note this is NOT that guard widened:
it keys on `files_changed`, which is meaningless with no checkout, and would
not have saved the ClawHDF5 documents.
Negative controls, each ablated and confirmed failing: ignore `has_repo` and the
preamble test fails; empty the skip-list and the capture test keeps `.git` and
`node_modules`; write artifacts inside the mission dir and the survives-the-reap
test fails.
236 lib tests pass.
This commit is contained in:
@@ -26,6 +26,7 @@ pub mod microvm_executor;
|
|||||||
pub mod microvm_turn_executor;
|
pub mod microvm_turn_executor;
|
||||||
pub mod vm_stop_gate;
|
pub mod vm_stop_gate;
|
||||||
pub mod mission_fs;
|
pub mod mission_fs;
|
||||||
|
pub mod mission_outputs;
|
||||||
pub mod papers;
|
pub mod papers;
|
||||||
pub mod phase_config;
|
pub mod phase_config;
|
||||||
pub mod session_executor;
|
pub mod session_executor;
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
//! Capture for missions that have no repository.
|
||||||
|
//!
|
||||||
|
//! `mission_delivery` captures a phase's work by diffing a git checkout. A
|
||||||
|
//! mission with `repo_id IS NULL` — every `research_only` mission, because that
|
||||||
|
//! recipe sets `requires_repo = false` — has no checkout, so
|
||||||
|
//! `capture_finished_coding_phases` filters it out at the SQL level
|
||||||
|
//! (`AND m.repo_id IS NOT NULL`) and never reads the container at all.
|
||||||
|
//!
|
||||||
|
//! The agents still write files. The research directive tells them to save
|
||||||
|
//! findings under `/mission/repo/research/`, and it says so whether or not a
|
||||||
|
//! repo exists. So the work lands in the container's own filesystem, is never
|
||||||
|
//! collected, and is destroyed when the sweeper reaps the container.
|
||||||
|
//!
|
||||||
|
//! # What this cost, measured
|
||||||
|
//!
|
||||||
|
//! Mission `019fdc35` ("ClawHDF5 Research"): four agents, 9.5 minutes, **eight
|
||||||
|
//! research documents** — an HDF5 parser design, a Rust ecosystem survey, a
|
||||||
|
//! seven-crate dependency map, tracing and fuzzing strategy. `mission_artifacts`
|
||||||
|
//! held zero rows and the mission reported `completed`. One agent's own summary
|
||||||
|
//! recorded the situation exactly: *"No git repo — file is written."* It noticed,
|
||||||
|
//! wrote anyway, and the platform threw the result away without a word.
|
||||||
|
//!
|
||||||
|
//! Nothing survived but the summarizer's account of it — which is the agents'
|
||||||
|
//! description of the work, not the work.
|
||||||
|
//!
|
||||||
|
//! # Why a separate path rather than widening the diff capture
|
||||||
|
//!
|
||||||
|
//! There is no base commit to diff against and no branch to push, so every
|
||||||
|
//! concept `capture_phase_diff` is built on is absent. What a repo-less mission
|
||||||
|
//! produces is simply *files*, and the honest capture is to copy them out and
|
||||||
|
//! register each as an artifact. `_outputs/` is deliberately a SIBLING of the
|
||||||
|
//! mission directory and survives `teardown_container`, so artifacts registered
|
||||||
|
//! here outlive the reap that destroyed the originals.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use sqlx::{PgPool, Row};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Directories never worth capturing, whatever an agent leaves behind.
|
||||||
|
///
|
||||||
|
/// Same intent as `mission_fs`'s exclusion list: a captured `.git` or
|
||||||
|
/// `node_modules` is noise that would bury the four documents that matter.
|
||||||
|
const SKIP_DIRS: &[&str] = &[
|
||||||
|
".git",
|
||||||
|
"node_modules",
|
||||||
|
"target",
|
||||||
|
".venv",
|
||||||
|
"venv",
|
||||||
|
"__pycache__",
|
||||||
|
".cache",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// How many phases to capture per tick, matching `CAPTURE_BATCH`.
|
||||||
|
const BATCH: i64 = 5;
|
||||||
|
|
||||||
|
/// The artifact kind this path registers. Also the idempotency key: a phase with
|
||||||
|
/// one of these has already been captured.
|
||||||
|
pub const OUTPUT_KIND: &str = "document";
|
||||||
|
|
||||||
|
/// Capture the outputs of finished phases on missions that have no repo.
|
||||||
|
pub async fn capture_repo_less_phases(pool: &PgPool) -> Result<(), String> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT mp.id, mp.mission_id, mp.kind, mp.config
|
||||||
|
FROM mission_phases mp
|
||||||
|
JOIN missions m ON m.id = mp.mission_id
|
||||||
|
WHERE mp.status IN ('completed', 'failed')
|
||||||
|
AND m.repo_id IS NULL
|
||||||
|
-- A microVM mission always has a checkout (`run_phase_in_vm` refuses
|
||||||
|
-- to boot without one), so this path is container-only.
|
||||||
|
AND m.runtime_kind <> 'microvm'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM mission_artifacts a
|
||||||
|
WHERE a.mission_id = mp.mission_id
|
||||||
|
AND a.phase_id = mp.id
|
||||||
|
AND a.kind = $2
|
||||||
|
)
|
||||||
|
ORDER BY mp.completed_at DESC NULLS LAST
|
||||||
|
LIMIT $1",
|
||||||
|
)
|
||||||
|
.bind(BATCH)
|
||||||
|
.bind(OUTPUT_KIND)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("select repo-less phases to capture: {e}"))?;
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let phase_id: Uuid = row.get("id");
|
||||||
|
let mission_id: Uuid = row.get("mission_id");
|
||||||
|
let kind: String = row.get("kind");
|
||||||
|
let config: serde_json::Value = row.get("config");
|
||||||
|
|
||||||
|
let dest = outputs_dir(mission_id, phase_id);
|
||||||
|
let captured = match collect_into(mission_id, &dest).await {
|
||||||
|
Ok(files) => files,
|
||||||
|
Err(e) => {
|
||||||
|
// Loud and retryable, never silently "captured nothing": the
|
||||||
|
// whole defect this module exists for is work disappearing
|
||||||
|
// without a word. The next tick tries again; if the container is
|
||||||
|
// already gone the phase is failed below on the next pass.
|
||||||
|
eprintln!(
|
||||||
|
"mission_outputs: could NOT collect outputs for phase {phase_id} \
|
||||||
|
of mission {mission_id}: {e}"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let wants_pdf = produces_pdf(&config);
|
||||||
|
for file in &captured {
|
||||||
|
let rel = match file.strip_prefix(missions_root()) {
|
||||||
|
Ok(r) => r.to_string_lossy().to_string(),
|
||||||
|
Err(_) => file.to_string_lossy().to_string(),
|
||||||
|
};
|
||||||
|
let title = file
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| rel.clone());
|
||||||
|
if let Err(e) = cm_db::repo::missions::register_artifact(
|
||||||
|
pool,
|
||||||
|
cm_db::repo::missions::RegisterArtifact {
|
||||||
|
mission_id,
|
||||||
|
phase_id: Some(phase_id),
|
||||||
|
path: &rel,
|
||||||
|
kind: OUTPUT_KIND,
|
||||||
|
mime: Some(mime_for(file)),
|
||||||
|
title: Some(&title),
|
||||||
|
generated_by_run: None,
|
||||||
|
// `produces` used to be inert — declared by the recipe and
|
||||||
|
// read by nothing. This is the code that reads it.
|
||||||
|
render_pdf: wants_pdf && is_markdown(file),
|
||||||
|
metadata: Some(serde_json::json!({
|
||||||
|
"bytes": std::fs::metadata(file).map(|m| m.len()).unwrap_or(0),
|
||||||
|
"captured_from": "/mission/repo",
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
eprintln!("mission_outputs: registering {rel}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if captured.is_empty() && !allow_empty(&config) {
|
||||||
|
// The same rule `empty_delivery_is_a_failure` applies to a coding
|
||||||
|
// phase, for the only channel a repo-less phase has. Without it a
|
||||||
|
// research mission that produced nothing is indistinguishable from
|
||||||
|
// one that produced eight documents — both `completed`.
|
||||||
|
eprintln!(
|
||||||
|
"mission_outputs: phase {phase_id} ({kind}) of mission {mission_id} produced \
|
||||||
|
NO output files — failing it. Set config.allow_empty = true if this phase is \
|
||||||
|
meant to think rather than produce."
|
||||||
|
);
|
||||||
|
if let Err(e) = sqlx::query("UPDATE mission_phases SET status = 'failed' WHERE id = $1")
|
||||||
|
.bind(phase_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
eprintln!("mission_outputs: failing empty phase {phase_id}: {e}");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"mission_outputs: captured {} file(s) from phase {phase_id} ({kind}) of \
|
||||||
|
mission {mission_id}",
|
||||||
|
captured.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy `/mission/repo` out of the mission's container and return the files kept.
|
||||||
|
async fn collect_into(mission_id: Uuid, dest: &Path) -> Result<Vec<PathBuf>, String> {
|
||||||
|
let container = crate::mission_runtime::container_name(mission_id);
|
||||||
|
let docker = crate::container_exec::connect()?;
|
||||||
|
// 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);
|
||||||
|
std::fs::create_dir_all(dest).map_err(|e| format!("create {}: {e}", dest.display()))?;
|
||||||
|
crate::mission_fs::copy_out(&docker, &container, "/mission/repo", dest).await?;
|
||||||
|
Ok(keep_files(&dest.join("repo")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every regular file worth keeping, recursively.
|
||||||
|
fn keep_files(root: &Path) -> Vec<PathBuf> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut stack = vec![root.to_path_buf()];
|
||||||
|
while let Some(dir) = stack.pop() {
|
||||||
|
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
if path.is_dir() {
|
||||||
|
if !SKIP_DIRS.contains(&name.as_str()) {
|
||||||
|
stack.push(path);
|
||||||
|
}
|
||||||
|
} else if path.is_file() && !name.starts_with('.') {
|
||||||
|
out.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<missions_root>/_outputs/<mission>/<phase>` — a sibling of the mission
|
||||||
|
/// directory, so `teardown_container` reaping the mission does not take the
|
||||||
|
/// captured artifacts with it.
|
||||||
|
fn outputs_dir(mission_id: Uuid, phase_id: Uuid) -> PathBuf {
|
||||||
|
missions_root()
|
||||||
|
.join("_outputs")
|
||||||
|
.join(mission_id.to_string())
|
||||||
|
.join(phase_id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn missions_root() -> PathBuf {
|
||||||
|
std::env::var("CLAWMATES_MISSIONS_ROOT")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("/var/lib/clawmates-missions"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_markdown(p: &Path) -> bool {
|
||||||
|
matches!(
|
||||||
|
p.extension().and_then(|e| e.to_str()),
|
||||||
|
Some("md") | Some("markdown")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mime_for(p: &Path) -> &'static str {
|
||||||
|
match p.extension().and_then(|e| e.to_str()) {
|
||||||
|
Some("md") | Some("markdown") => "text/markdown",
|
||||||
|
Some("json") => "application/json",
|
||||||
|
Some("csv") => "text/csv",
|
||||||
|
Some("html") => "text/html",
|
||||||
|
_ => "text/plain",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Did the recipe ask for a PDF? `research_only.toml` declares
|
||||||
|
/// `produces = ["md","pdf"]`.
|
||||||
|
fn produces_pdf(config: &serde_json::Value) -> bool {
|
||||||
|
config
|
||||||
|
.get("produces")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|a| a.iter().any(|v| v.as_str() == Some("pdf")))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allow_empty(config: &serde_json::Value) -> bool {
|
||||||
|
config.get("allow_empty").and_then(|v| v.as_bool()) == Some(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn touch(p: &Path) {
|
||||||
|
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||||
|
std::fs::write(p, "x").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The documents a research phase writes are what must come back — and the
|
||||||
|
/// machinery around them must not.
|
||||||
|
#[test]
|
||||||
|
fn research_documents_are_kept_and_scaffolding_is_not() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let repo = tmp.path().join("repo");
|
||||||
|
touch(&repo.join("research/01_repo_archaeology.md"));
|
||||||
|
touch(&repo.join("research/02_ecosystem.md"));
|
||||||
|
touch(&repo.join("notes.txt"));
|
||||||
|
touch(&repo.join(".git/HEAD"));
|
||||||
|
touch(&repo.join("node_modules/left-pad/index.js"));
|
||||||
|
touch(&repo.join("target/debug/thing"));
|
||||||
|
touch(&repo.join(".hidden"));
|
||||||
|
|
||||||
|
let kept: Vec<String> = keep_files(&repo)
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.strip_prefix(&repo).unwrap().to_string_lossy().to_string())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
kept,
|
||||||
|
vec![
|
||||||
|
"notes.txt".to_string(),
|
||||||
|
"research/01_repo_archaeology.md".to_string(),
|
||||||
|
"research/02_ecosystem.md".to_string(),
|
||||||
|
],
|
||||||
|
"kept: {kept:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `produces` was registered as an INERT key — declared by the recipe and
|
||||||
|
/// read by nothing, which is why `research_only`'s promised PDF never
|
||||||
|
/// appeared. This is the code that makes it mean something.
|
||||||
|
#[test]
|
||||||
|
fn the_recipes_produces_key_decides_pdf_rendering() {
|
||||||
|
let with = serde_json::json!({ "produces": ["md", "pdf"] });
|
||||||
|
let without = serde_json::json!({ "produces": ["md"] });
|
||||||
|
let absent = serde_json::json!({});
|
||||||
|
assert!(produces_pdf(&with));
|
||||||
|
assert!(!produces_pdf(&without));
|
||||||
|
assert!(!produces_pdf(&absent));
|
||||||
|
|
||||||
|
// Only markdown is rendered; a captured JSON side-file is not a document
|
||||||
|
// to typeset.
|
||||||
|
assert!(is_markdown(Path::new("/x/01_notes.md")));
|
||||||
|
assert!(!is_markdown(Path::new("/x/data.json")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Artifacts must land OUTSIDE the mission directory. `teardown_container`
|
||||||
|
/// removes `<missions_root>/<mission_id>` wholesale, so a capture written
|
||||||
|
/// inside it would be destroyed by the very reap it exists to survive.
|
||||||
|
#[test]
|
||||||
|
fn captures_survive_the_mission_directory_being_reaped() {
|
||||||
|
let mission = Uuid::now_v7();
|
||||||
|
let phase = Uuid::now_v7();
|
||||||
|
let out = outputs_dir(mission, phase);
|
||||||
|
let mission_dir = missions_root().join(mission.to_string());
|
||||||
|
assert!(
|
||||||
|
!out.starts_with(&mission_dir),
|
||||||
|
"{} must not be inside {}",
|
||||||
|
out.display(),
|
||||||
|
mission_dir.display()
|
||||||
|
);
|
||||||
|
assert!(out.starts_with(missions_root().join("_outputs")), "{out:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,9 @@ async fn sweep_once(
|
|||||||
// checkout. Idempotent, so a failure here is retried on the next tick
|
// checkout. Idempotent, so a failure here is retried on the next tick
|
||||||
// rather than losing the phase's work.
|
// rather than losing the phase's work.
|
||||||
capture_finished_coding_phases(pool).await?;
|
capture_finished_coding_phases(pool).await?;
|
||||||
|
// The same, for missions with no repository to diff. Without this the
|
||||||
|
// container holding a research phase's only output is reaped unread.
|
||||||
|
crate::mission_outputs::capture_repo_less_phases(pool).await?;
|
||||||
// A failed phase makes every later phase unreachable, and saying so is what
|
// A failed phase makes every later phase unreachable, and saying so is what
|
||||||
// lets the mission finish at all.
|
// lets the mission finish at all.
|
||||||
skip_unreachable_phases(pool).await?;
|
skip_unreachable_phases(pool).await?;
|
||||||
@@ -212,6 +215,44 @@ async fn capture_finished_coding_phases(pool: &PgPool) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A repo-less mission must not be told its scratch directory is a git checkout.
|
||||||
|
///
|
||||||
|
/// The eight ClawHDF5 research documents were written because the preamble said
|
||||||
|
/// "/mission/repo ... is the mission's git checkout" to a mission that had none.
|
||||||
|
/// One agent recorded the contradiction verbatim — "No git repo — file is
|
||||||
|
/// written" — and wrote into a container that was then reaped unread.
|
||||||
|
#[cfg(test)]
|
||||||
|
mod repo_less_text_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_repo_less_phase_is_not_told_it_has_a_checkout() {
|
||||||
|
let with = phase_task_text("research", "T", None, None, true);
|
||||||
|
let without = phase_task_text("research", "T", None, None, false);
|
||||||
|
|
||||||
|
assert!(with.contains("mission's git checkout"), "{with}");
|
||||||
|
assert!(
|
||||||
|
!without.contains("git checkout"),
|
||||||
|
"a mission with no repo must not be promised one: {without}"
|
||||||
|
);
|
||||||
|
// 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("NO git repository"), "{without}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both variants must keep the instruction that outputs are real files.
|
||||||
|
/// That line is what stops an agent pasting its work into the reply, and it
|
||||||
|
/// is load-bearing for the capture path either way.
|
||||||
|
#[test]
|
||||||
|
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 file_edit"), "has_repo={has_repo}: {t}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Did this phase finish without delivering the work it exists to produce?
|
/// Did this phase finish without delivering the work it exists to produce?
|
||||||
///
|
///
|
||||||
/// A coding phase that changes no files has done nothing, and until now that
|
/// A coding phase that changes no files has done nothing, and until now that
|
||||||
@@ -263,7 +304,10 @@ async fn start_pending_phases(
|
|||||||
-- Where and how this mission executes. `runtime_kind` decides
|
-- Where and how this mission executes. `runtime_kind` decides
|
||||||
-- which executor takes the phase; without it 'microvm' is a
|
-- which executor takes the phase; without it 'microvm' is a
|
||||||
-- value the placement code honours and nothing reads.
|
-- value the placement code honours and nothing reads.
|
||||||
m.runtime_kind, m.backend, m.target_node_id, m.team_engine
|
m.runtime_kind, m.backend, m.target_node_id, m.team_engine,
|
||||||
|
-- Whether a checkout exists at all. A repo-less mission's
|
||||||
|
-- /mission/repo is scratch space, and the task text must say so.
|
||||||
|
(m.repo_id IS NOT NULL) AS has_repo
|
||||||
FROM mission_phases mp
|
FROM mission_phases mp
|
||||||
JOIN missions m ON m.id = mp.mission_id
|
JOIN missions m ON m.id = mp.mission_id
|
||||||
WHERE mp.status = 'pending'
|
WHERE mp.status = 'pending'
|
||||||
@@ -294,6 +338,7 @@ async fn start_pending_phases(
|
|||||||
let backend: Option<String> = row.get("backend");
|
let backend: Option<String> = row.get("backend");
|
||||||
let target_node_id: Option<Uuid> = row.get("target_node_id");
|
let target_node_id: Option<Uuid> = row.get("target_node_id");
|
||||||
let team_engine: Option<String> = row.get("team_engine");
|
let team_engine: Option<String> = row.get("team_engine");
|
||||||
|
let has_repo: bool = row.get("has_repo");
|
||||||
|
|
||||||
if let Err(e) = launch_phase(
|
if let Err(e) = launch_phase(
|
||||||
pool,
|
pool,
|
||||||
@@ -312,6 +357,7 @@ async fn start_pending_phases(
|
|||||||
backend: backend.as_deref(),
|
backend: backend.as_deref(),
|
||||||
target_node_id,
|
target_node_id,
|
||||||
team_engine: team_engine.as_deref(),
|
team_engine: team_engine.as_deref(),
|
||||||
|
has_repo,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -354,6 +400,10 @@ struct PhaseLaunch<'a> {
|
|||||||
target_node_id: Option<Uuid>,
|
target_node_id: Option<Uuid>,
|
||||||
/// `missions.team_engine`. NULL = solo.
|
/// `missions.team_engine`. NULL = solo.
|
||||||
team_engine: Option<&'a str>,
|
team_engine: Option<&'a str>,
|
||||||
|
/// Whether `missions.repo_id` is set. Decides whether the agents are told
|
||||||
|
/// `/mission/repo` is a git checkout or a scratch workspace whose contents
|
||||||
|
/// are captured as artifacts.
|
||||||
|
has_repo: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn launch_phase(
|
async fn launch_phase(
|
||||||
@@ -369,6 +419,7 @@ async fn launch_phase(
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
phase_task,
|
phase_task,
|
||||||
|
has_repo,
|
||||||
iteration,
|
iteration,
|
||||||
// Destructured but read through `p` below, so the compiler keeps this
|
// Destructured but read through `p` below, so the compiler keeps this
|
||||||
// pattern honest if a field is added.
|
// pattern honest if a field is added.
|
||||||
@@ -505,7 +556,7 @@ async fn launch_phase(
|
|||||||
let prior = crate::evaluator::latest(pool, phase_id)
|
let prior = crate::evaluator::latest(pool, phase_id)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(None);
|
.unwrap_or(None);
|
||||||
let task = phase_task_text(kind, title, description, phase_task);
|
let task = phase_task_text(kind, title, description, phase_task, has_repo);
|
||||||
let task = match prior {
|
let task = match prior {
|
||||||
Some((iter, false, guidance)) => format!(
|
Some((iter, false, guidance)) => format!(
|
||||||
"{task}\n\nPASS {} DID NOT SATISFY THE COMPLETION CONDITION. What is \
|
"{task}\n\nPASS {} DID NOT SATISFY THE COMPLETION CONDITION. What is \
|
||||||
@@ -1084,6 +1135,11 @@ fn phase_task_text(
|
|||||||
title: &str,
|
title: &str,
|
||||||
description: Option<&str>,
|
description: Option<&str>,
|
||||||
phase_task: Option<&str>,
|
phase_task: Option<&str>,
|
||||||
|
// Whether the mission has a repository bound. A repo-less mission's
|
||||||
|
// `/mission/repo` is a scratch directory, and telling its agents otherwise
|
||||||
|
// is how eight research documents were written into a container that was
|
||||||
|
// then reaped unread.
|
||||||
|
has_repo: bool,
|
||||||
) -> String {
|
) -> String {
|
||||||
let base = description.unwrap_or("").trim();
|
let base = description.unwrap_or("").trim();
|
||||||
// The prior template-derived system prompts trained agents to look
|
// The prior template-derived system prompts trained agents to look
|
||||||
@@ -1093,7 +1149,7 @@ fn phase_task_text(
|
|||||||
// the real tool inventory + concrete workspace path stops the agent
|
// the real tool inventory + concrete workspace path stops the agent
|
||||||
// from hallucinating "I only have file_read" and dumping the entire
|
// from hallucinating "I only have file_read" and dumping the entire
|
||||||
// implementation into the context window instead of onto disk.
|
// implementation into the context window instead of onto disk.
|
||||||
let tool_preamble = "\
|
let tool_preamble = format!("\
|
||||||
TOOLS AVAILABLE (use these exact names — do NOT assume older tool names like file_read / file_write / bash exist):\n\
|
TOOLS AVAILABLE (use these exact names — do NOT assume older tool names like file_read / file_write / bash exist):\n\
|
||||||
- file_edit — create, overwrite, or patch files in your workspace\n\
|
- file_edit — create, overwrite, or patch files in your workspace\n\
|
||||||
- content_search — grep across your workspace (regex on file contents)\n\
|
- content_search — grep across your workspace (regex on file contents)\n\
|
||||||
@@ -1105,12 +1161,28 @@ fn phase_task_text(
|
|||||||
- delegate — call a peer role by name\n\
|
- delegate — call a peer role by name\n\
|
||||||
- memory_store / memory_recall — durable per-agent notes\n\
|
- memory_store / memory_recall — durable per-agent notes\n\
|
||||||
\n\
|
\n\
|
||||||
WORKSPACE: Your working directory is /mission/repo. That path is the\n\
|
{workspace}\n\
|
||||||
mission's git checkout. All file_edit / content_search / glob_search\n\
|
All file_edit / content_search / glob_search\n\
|
||||||
operations resolve there. To read a file: file_edit with mode='read'\n\
|
operations resolve there. To read a file: file_edit with mode='read'\n\
|
||||||
or content_search first, then file_edit to patch. Write your outputs\n\
|
or content_search first, then file_edit to patch. Write your outputs\n\
|
||||||
as REAL files with file_edit — do NOT paste code blocks in your reply\n\
|
as REAL files with file_edit — do NOT paste code blocks in your reply\n\
|
||||||
expecting the platform to save them; nothing else writes files for you.\n";
|
expecting the platform to save them; nothing else writes files for you.\n",
|
||||||
|
workspace = if has_repo {
|
||||||
|
"WORKSPACE: Your working directory is /mission/repo. That path is the\n\
|
||||||
|
mission's git checkout."
|
||||||
|
} else {
|
||||||
|
// Saying "git checkout" here to a mission that has none is what
|
||||||
|
// produced the eight destroyed ClawHDF5 documents: the agent looked,
|
||||||
|
// found no repo, wrote the files anyway, and nothing collected them.
|
||||||
|
// Now `mission_outputs` DOES collect them, and the agent is told so
|
||||||
|
// — an instruction the platform can actually keep.
|
||||||
|
"WORKSPACE: Your working directory is /mission/repo. This mission has\n\
|
||||||
|
NO git repository — that path is a scratch workspace, so git_operations\n\
|
||||||
|
and git_forge have nothing to act on. Every file you leave there is\n\
|
||||||
|
collected when the phase ends and published as a mission artifact, so\n\
|
||||||
|
write your output as files exactly as you would in a repo."
|
||||||
|
}
|
||||||
|
);
|
||||||
// The INT-XX markers are a machine contract, not a style preference:
|
// The INT-XX markers are a machine contract, not a style preference:
|
||||||
// task_card_parser.rs scans turn output line-by-line for these literals and
|
// task_card_parser.rs scans turn output line-by-line for these literals and
|
||||||
// materializes `mission_tasks` rows from them. The rules used to live only
|
// materializes `mission_tasks` rows from them. The rules used to live only
|
||||||
@@ -1625,8 +1697,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn phase_task_reaches_the_agent_and_distinguishes_phases() {
|
fn phase_task_reaches_the_agent_and_distinguishes_phases() {
|
||||||
let brief = Some("Add two marker files.");
|
let brief = Some("Add two marker files.");
|
||||||
let alpha = phase_task_text("coding", "Demo", brief, Some("Create ALPHA.md"));
|
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"));
|
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"));
|
||||||
@@ -1634,10 +1706,10 @@ mod tests {
|
|||||||
assert_ne!(alpha, beta, "sibling phases received identical instructions");
|
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);
|
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(" ")));
|
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.
|
||||||
@@ -1648,7 +1720,7 @@ mod tests {
|
|||||||
/// Every example line in the prompt is fed through the real parser here.
|
/// Every example line in the prompt is fed through the real parser here.
|
||||||
#[test]
|
#[test]
|
||||||
fn task_text_marker_examples_parse() {
|
fn task_text_marker_examples_parse() {
|
||||||
let text = phase_task_text("coding", "Demo", Some("brief"), None);
|
let text = phase_task_text("coding", "Demo", Some("brief"), None, true);
|
||||||
|
|
||||||
let examples: Vec<&str> = text
|
let examples: Vec<&str> = text
|
||||||
.lines()
|
.lines()
|
||||||
|
|||||||
Reference in New Issue
Block a user