feat(missions): Continuous Research harvests at launch, and cards launch by clicking
deploy / test (push) Successful in 4m21s
deploy / build (push) Successful in 5m14s

The card shipped in e20b321 could not actually be used. Three things were
missing, each of which failed at a different distance from its cause.

**1. `default_team_template` was parsed and never read.** Every recipe declares
one; `WorkflowRecipe` carries the field; nothing consumed it. A mission created
from a card with no explicitly chosen team was rejected at LAUNCH with "no
team_id, no team_template_id, no config.phase_teams" — one step removed from the
real cause, which is that creation ignored the recipe. Create now resolves it
via `team_templates::get_by_key`, only when the caller named no team of any
kind, so an explicit choice still wins. A test asserts every shipped recipe
names a template that has a `templates/teams/<key>.toml`, because a mismatch
there produces an unlaunchable card.

**2. The harvest ran nowhere.** `harvest_for_mission` existed and nothing called
it. `on_launch` now runs it for `continuous_research` missions, before the
phases start, and threads the blob store through from `main` (the route already
had it on `AppState`; the scheduler needed it). Deliberately non-fatal: a
harvest that fails still starts the phases, because the phase is what reports
whether today was quiet or broken and those must stay distinguishable — but
never silent, so both outcomes log their counts.

**3. Nothing wrote the manifest.** `templates/teams/continuous_research.toml`
has pointed its reader role at `ContinuousResearch/<date>/harvest.jsonl` since it
was authored, and the file did not exist — agents aimed at a path nothing
produced. `run_to_vault` now writes it beside the notes and stages it, but only
for a mission-attributed run. `Harvest` carries the shelved `Paper`s to build
it; re-parsing the notes we had just written would have been a parse of our own
output and one more place for the two to drift.

Also: the blob root. `storage.data_dir` defaults to "./data" and the container's
cwd is `/`, so the server tried to create `/data` as uid 65532 and EVERY shelve
failed with "storage io: Permission denied". The image now creates
/var/lib/clawmates-blobs owned by 65532 so a mounted volume inherits it rather
than arriving root:root. Kept off /var/lib/clawmates-missions on purpose: that
tree is swept, and a paper shelved there would be deleted out from under its own
catalogue note.

Proven end to end on a real mission: 15 candidates, 2 already held, 13 shelved,
0 failed; branch auto-merged as additive-only; manifest on vault `main` with
every documented key. The "already held" counts are the seen-set deduping across
topics within a single run, which is the behaviour the whole design exists for.

The project brief now comes from the mission description — `phase_task_text`
already places it under BRIEF verbatim, so no new field was needed.

346 tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-17 15:05:37 -07:00
co-authored by Claude Opus 5
parent a2d7e3ea92
commit a02e0cba69
10 changed files with 222 additions and 13 deletions
+1
View File
@@ -390,6 +390,7 @@ async fn run() -> Result<(), String> {
cm_api::mission_schedule::spawn( cm_api::mission_schedule::spawn(
pool.clone(), pool.clone(),
Some(node_hub.clone()), Some(node_hub.clone()),
Some(blob.clone()),
std::time::Duration::from_secs(60), std::time::Duration::from_secs(60),
); );
// Per-mission runtime container sweeper (C3): tears down mission // Per-mission runtime container sweeper (C3): tears down mission
+44
View File
@@ -41,6 +41,28 @@ pub fn today() -> String {
) )
} }
/// The arXiv queries this mission tracks.
///
/// `config.topics` on the mission when the operator set them, otherwise the
/// project-wide defaults. Read from config rather than a new column because the
/// wizard already round-trips `config` untouched, so a topic list needs no
/// schema change and no UI work to reach here.
pub fn topics_for(config: &serde_json::Value) -> Vec<String> {
config
.get("topics")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|t| t.as_str())
.map(str::trim)
.filter(|t| !t.is_empty())
.map(str::to_string)
.collect::<Vec<_>>()
})
.filter(|t: &Vec<String>| !t.is_empty())
.unwrap_or_else(crate::library::default_topics)
}
/// Run the harvest for a mission and leave a manifest the agents can read. /// Run the harvest for a mission and leave a manifest the agents can read.
/// ///
/// Non-fatal by contract: a launch whose harvest fails still starts its phases, /// Non-fatal by contract: a launch whose harvest fails still starts its phases,
@@ -124,6 +146,28 @@ mod tests {
); );
} }
/// An operator's topic list must win over the defaults, and a blank or
/// missing list must fall back rather than harvesting nothing.
#[test]
fn topics_come_from_config_and_fall_back_when_absent() {
assert_eq!(
topics_for(&serde_json::json!({"topics": ["world models", " robots "]})),
vec!["world models".to_string(), "robots".to_string()],
"operator topics win, and are trimmed"
);
for empty in [
serde_json::json!({}),
serde_json::json!({"topics": []}),
serde_json::json!({"topics": [" "]}),
] {
assert_eq!(
topics_for(&empty),
crate::library::default_topics(),
"an absent or blank list must fall back, not harvest nothing: {empty}"
);
}
}
#[test] #[test]
fn the_date_stamp_is_zero_padded() { fn the_date_stamp_is_zero_padded() {
let d = today(); let d = today();
+9
View File
@@ -44,6 +44,14 @@ pub struct Harvest {
pub failed: Vec<(String, String)>, pub failed: Vec<(String, String)>,
/// Vault-relative paths of the notes written. /// Vault-relative paths of the notes written.
pub notes_written: Vec<String>, pub notes_written: Vec<String>,
/// The papers actually shelved this run, in shelve order.
///
/// `shelved` carries only source ids, which is all the seen-set needs. The
/// run manifest a Continuous Research mission hands its agents needs the
/// title and abstract too, and re-reading them back out of the notes we
/// just wrote would be a parse of our own output — one more place for the
/// two to drift.
pub papers: Vec<crate::papers::Paper>,
} }
impl Harvest { impl Harvest {
@@ -167,6 +175,7 @@ pub async fn shelve(
.await?; .await?;
out.notes_written.push(paper.note_path()); out.notes_written.push(paper.note_path());
out.papers.push(paper.clone());
out.shelved.push(sid); out.shelved.push(sid);
} }
+33 -1
View File
@@ -153,6 +153,7 @@ pub async fn run_to_vault(
total.shelved.extend(h.shelved); total.shelved.extend(h.shelved);
total.failed.extend(h.failed); total.failed.extend(h.failed);
total.notes_written.extend(h.notes_written); total.notes_written.extend(h.notes_written);
total.papers.extend(h.papers);
} }
// The TAIL of the uuid, not the head. UUIDv7 leads with a 48-bit // The TAIL of the uuid, not the head. UUIDv7 leads with a 48-bit
@@ -175,7 +176,38 @@ pub async fn run_to_vault(
} }
git(&vault, &["checkout", "-B", &branch]).await?; git(&vault, &["checkout", "-B", &branch]).await?;
git(&vault, &["add", "--", "60 Papers"]).await?;
// The run manifest, beside the notes.
//
// `templates/teams/continuous_research.toml` has told its reader role to
// start from `ContinuousResearch/<date>/harvest.jsonl` since it was
// authored, and nothing wrote it — the agents were pointed at a file that
// did not exist. This is the code producing what the prompt already
// promises, so "nothing new today" is a fact the agent reads rather than a
// conclusion it guesses from an empty folder.
//
// Written only when the run is attributed to a mission: a plain library run
// has no agent waiting on it and does not need the extra file in the vault.
let mut staged: Vec<&str> = vec!["60 Papers"];
let manifest_rel = crate::continuous_research::manifest_path(&crate::continuous_research::today());
if mission_id.is_some() {
let manifest_abs = vault.join(&manifest_rel);
if let Some(parent) = manifest_abs.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("create {}: {e}", parent.display()))?;
}
let body = crate::continuous_research::manifest_lines(
&total.papers,
&crate::continuous_research::today(),
);
std::fs::write(&manifest_abs, format!("{body}\n"))
.map_err(|e| format!("write {}: {e}", manifest_abs.display()))?;
staged.push("ContinuousResearch");
}
let mut add: Vec<&str> = vec!["add", "--"];
add.extend(staged);
git(&vault, &add).await?;
let message = format!( let message = format!(
"library: {} new paper(s)\n\n{}\n\nShelved in the blob store; this commit is the catalogue.", "library: {} new paper(s)\n\n{}\n\nShelved in the blob store; this commit is the catalogue.",
total.shelved.len(), total.shelved.len(),
+44
View File
@@ -44,6 +44,7 @@ pub async fn on_launch(
user_id: cm_domain::UserId, user_id: cm_domain::UserId,
mission_id: Uuid, mission_id: Uuid,
node_hub: Option<std::sync::Arc<crate::fleet::NodeHub>>, node_hub: Option<std::sync::Arc<crate::fleet::NodeHub>>,
blobs: Option<std::sync::Arc<dyn cm_files::BlobStore>>,
) -> Result<Option<Uuid>, String> { ) -> Result<Option<Uuid>, String> {
eprintln!("mission_orchestrator::on_launch fired mission_id={mission_id}"); eprintln!("mission_orchestrator::on_launch fired mission_id={mission_id}");
let Some(mission) = cm_db::repo::missions::get(pool, mission_id, workspace_id.as_uuid()) let Some(mission) = cm_db::repo::missions::get(pool, mission_id, workspace_id.as_uuid())
@@ -71,6 +72,49 @@ pub async fn on_launch(
), ),
} }
// A Continuous Research mission harvests BEFORE its agents start.
//
// Finding papers is not agent work: `library::run_to_vault` searches arXiv,
// checks the `corpus_items` seen-set, fetches and verifies each PDF, shelves
// it and writes the catalogue note — deterministically, in seconds. The
// seen-set is the entire reason a recurring mission knows what it already
// covered, and an agent re-searching arXiv would leave it wrong.
//
// Deliberately NON-FATAL. A harvest that fails still lets the phases run,
// because the phase is what reports whether today was quiet or broken, and
// those must stay distinguishable. What is never acceptable is silence, so
// both outcomes are logged with their counts.
if mission.template_kind == crate::continuous_research::TEMPLATE_KIND {
match blobs.as_ref() {
Some(b) => {
let topics = crate::continuous_research::topics_for(&mission.config);
match crate::continuous_research::harvest_for_mission(
pool,
b,
workspace_id.as_uuid(),
mission_id,
&topics,
5,
)
.await
{
Ok(n) => eprintln!(
"mission_orchestrator: continuous research harvest shelved {n} paper(s) for mission {mission_id}"
),
Err(e) => eprintln!(
"mission_orchestrator: continuous research harvest FAILED for {mission_id} (phases still start, and will report an empty day): {e}"
),
}
}
// Not a warning to bury: without blob storage there is nowhere to
// shelve a PDF, so the mission will find an empty manifest and
// correctly report that nothing arrived.
None => eprintln!(
"mission_orchestrator: mission {mission_id} is continuous_research but blob storage is not configured — no harvest, so today's manifest will be empty"
),
}
}
// Provision the per-mission ZeroClaw runtime container (C3). // Provision the per-mission ZeroClaw runtime container (C3).
// Idempotent: returns the endpoint if the container is already // Idempotent: returns the endpoint if the container is already
// running. Falls back silently when docker is unreachable so // running. Falls back silently when docker is unreachable so
+4 -1
View File
@@ -156,6 +156,7 @@ async fn reschedule(pool: &PgPool, m: &DueMission, after: OffsetDateTime) {
pub async fn tick( pub async fn tick(
pool: &PgPool, pool: &PgPool,
node_hub: Option<std::sync::Arc<crate::fleet::NodeHub>>, node_hub: Option<std::sync::Arc<crate::fleet::NodeHub>>,
blobs: Option<std::sync::Arc<dyn cm_files::BlobStore>>,
now: OffsetDateTime, now: OffsetDateTime,
) -> Result<usize, String> { ) -> Result<usize, String> {
let due = claim_due(pool, now).await?; let due = claim_due(pool, now).await?;
@@ -192,6 +193,7 @@ pub async fn tick(
owner, owner,
m.id, m.id,
node_hub.clone(), node_hub.clone(),
blobs.clone(),
) )
.await .await
{ {
@@ -233,6 +235,7 @@ pub async fn tick(
pub fn spawn( pub fn spawn(
pool: PgPool, pool: PgPool,
node_hub: Option<std::sync::Arc<crate::fleet::NodeHub>>, node_hub: Option<std::sync::Arc<crate::fleet::NodeHub>>,
blobs: Option<std::sync::Arc<dyn cm_files::BlobStore>>,
interval: std::time::Duration, interval: std::time::Duration,
) { ) {
tokio::spawn(async move { tokio::spawn(async move {
@@ -243,7 +246,7 @@ pub fn spawn(
loop { loop {
ticker.tick().await; ticker.tick().await;
let now = OffsetDateTime::now_utc(); let now = OffsetDateTime::now_utc();
match tick(&pool, node_hub.clone(), now).await { match tick(&pool, node_hub.clone(), blobs.clone(), now).await {
Ok(n) if n > 0 => eprintln!("mission_schedule: launched {n} due mission(s)"), Ok(n) if n > 0 => eprintln!("mission_schedule: launched {n} due mission(s)"),
Ok(_) => {} Ok(_) => {}
Err(e) => eprintln!("mission_schedule: sweep failed: {e}"), Err(e) => eprintln!("mission_schedule: sweep failed: {e}"),
+66 -1
View File
@@ -279,12 +279,48 @@ pub async fn create(
_ => return Err(ApiError::BadRequest), _ => return Err(ApiError::BadRequest),
} }
// Honour the recipe's `default_team_template`.
//
// Every recipe declares one and NOTHING read it: the field was parsed into
// `WorkflowRecipe` and then ignored, so a mission created from a card with
// no explicit team was rejected at launch with "no team_id, no
// team_template_id, no config.phase_teams" — a card that cannot be launched
// by clicking it. Only resolved when the caller named no team of any kind,
// so an explicit choice always wins.
let recipe = crate::workflow_registry::get(body.template_kind.trim());
let mut team_template_id = body.team_template_id;
let has_phase_teams = body
.config
.get("phase_teams")
.and_then(|v| v.as_object())
.is_some_and(|o| o.values().any(|v| v.as_array().is_some_and(|a| !a.is_empty())));
if team_template_id.is_none() && body.team_id.is_none() && !has_phase_teams {
if let Some(key) = recipe.and_then(|r| r.default_team_template.as_deref()) {
match cm_db::repo::team_templates::get_by_key(&state.pool, key).await {
Ok(Some(t)) => {
eprintln!(
"missions: {} defaults to team template {key}",
body.template_kind.trim()
);
team_template_id = Some(t.id);
}
// Loud: a recipe naming a template that is not loaded would
// otherwise fail at launch, one step removed from the cause.
Ok(None) => eprintln!(
"missions: recipe {} names default_team_template {key:?}, which is not loaded — the mission will have no team",
body.template_kind.trim()
),
Err(e) => eprintln!("missions: looking up team template {key:?}: {e}"),
}
}
}
let new = NewMission { let new = NewMission {
workspace_id: user.workspace_id.as_uuid(), workspace_id: user.workspace_id.as_uuid(),
title: body.title.trim(), title: body.title.trim(),
template_kind: body.template_kind.trim(), template_kind: body.template_kind.trim(),
team_id: body.team_id, team_id: body.team_id,
team_template_id: body.team_template_id, team_template_id,
repo_id: body.repo_id, repo_id: body.repo_id,
schedule: body.schedule, schedule: body.schedule,
description: body.description.as_deref(), description: body.description.as_deref(),
@@ -1341,6 +1377,7 @@ pub async fn set_status(
user.user_id, user.user_id,
id, id,
Some(state.node_hub.clone()), Some(state.node_hub.clone()),
state.blobs.clone(),
) )
.await .await
{ {
@@ -1550,6 +1587,34 @@ mod tests {
/// per-phase settings are read from at run time. Before this, every /// per-phase settings are read from at run time. Before this, every
/// wizard-created mission stored a null config and every recipe setting /// wizard-created mission stored a null config and every recipe setting
/// was inert. /// was inert.
/// Every shipped recipe must name a team template that is actually
/// authored. `default_team_template` was parsed and never read, so a
/// mismatch here used to surface as "launch rejected — no team_id" on a
/// card the user simply clicked.
#[test]
fn every_recipe_names_a_team_template_that_exists() {
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/teams");
let authored: std::collections::HashSet<String> = std::fs::read_dir(dir)
.expect("templates/teams is readable")
.filter_map(Result::ok)
.filter_map(|e| {
let n = e.file_name().to_string_lossy().to_string();
n.strip_suffix(".toml").map(str::to_string)
})
.collect();
for r in crate::workflow_registry::load() {
let Some(key) = r.default_team_template.as_deref() else {
continue;
};
assert!(
authored.contains(key),
"recipe {:?} defaults to team template {key:?}, which has no \
templates/teams/{key}.toml — the card would be unlaunchable",
r.key
);
}
}
#[test] #[test]
fn phase_config_is_backfilled_from_the_recipe() { fn phase_config_is_backfilled_from_the_recipe() {
let recipe = test_recipe(); let recipe = test_recipe();
+5 -5
View File
@@ -128,7 +128,7 @@ async fn on_launch_materializes_team_from_template() {
let template_id = seed_test_template(&pool).await; let template_id = seed_test_template(&pool).await;
let mission_id = seed_mission(&pool, ws, template_id, "Test Mission").await; let mission_id = seed_mission(&pool, ws, template_id, "Test Mission").await;
let team_id = mission_orchestrator::on_launch(&pool, ws, owner, mission_id, None) let team_id = mission_orchestrator::on_launch(&pool, ws, owner, mission_id, None, None)
.await .await
.expect("on_launch succeeds") .expect("on_launch succeeds")
.expect("returns a team id"); .expect("returns a team id");
@@ -205,11 +205,11 @@ async fn on_launch_is_idempotent() {
let template_id = seed_test_template(&pool).await; let template_id = seed_test_template(&pool).await;
let mission_id = seed_mission(&pool, ws, template_id, "Idempotency Mission").await; let mission_id = seed_mission(&pool, ws, template_id, "Idempotency Mission").await;
let team_a = mission_orchestrator::on_launch(&pool, ws, owner, mission_id, None) let team_a = mission_orchestrator::on_launch(&pool, ws, owner, mission_id, None, None)
.await .await
.unwrap() .unwrap()
.unwrap(); .unwrap();
let team_b = mission_orchestrator::on_launch(&pool, ws, owner, mission_id, None) let team_b = mission_orchestrator::on_launch(&pool, ws, owner, mission_id, None, None)
.await .await
.unwrap() .unwrap()
.unwrap(); .unwrap();
@@ -248,7 +248,7 @@ async fn on_launch_no_template_hard_fails() {
.await .await
.unwrap(); .unwrap();
let result = mission_orchestrator::on_launch(&pool, ws, owner, mission_id, None).await; let result = mission_orchestrator::on_launch(&pool, ws, owner, mission_id, None, None).await;
let err = result.expect_err("no template + no team must be a hard error"); let err = result.expect_err("no template + no team must be a hard error");
assert!( assert!(
err.contains("no team_template_id") && err.contains("config.phase_teams"), err.contains("no team_template_id") && err.contains("config.phase_teams"),
@@ -344,7 +344,7 @@ async fn a_template_role_may_run_on_its_own_model() {
let template_id = seed_test_template(&pool).await; let template_id = seed_test_template(&pool).await;
let mission = seed_mission(&pool, ws, template_id, "per-role models").await; let mission = seed_mission(&pool, ws, template_id, "per-role models").await;
mission_orchestrator::on_launch(&pool, ws, user, mission, None) mission_orchestrator::on_launch(&pool, ws, user, mission, None, None)
.await .await
.expect("launch"); .expect("launch");
+7
View File
@@ -66,5 +66,12 @@ COPY skills /etc/clawmates/skills
# nonroot runtime user unable to read them and silently skip the builtin # nonroot runtime user unable to read them and silently skip the builtin
# skills/team-template seed. a+rX = dirs traversable, files readable. # skills/team-template seed. a+rX = dirs traversable, files readable.
RUN chmod -R a+rX /etc/clawmates/templates /etc/clawmates/skills RUN chmod -R a+rX /etc/clawmates/templates /etc/clawmates/skills
# Blob-store root, created and owned by the runtime user BEFORE the volume is
# attached. A docker named volume mounted over a path that does not exist in the
# image is created root:root, and the server runs as 65532 — so every shelve
# failed with "storage io: Permission denied" and no PDF could ever be stored.
# Creating it here means the volume inherits this ownership on first mount, so a
# fresh deployment works without a manual chown.
RUN mkdir -p /var/lib/clawmates-blobs && chown 65532:65532 /var/lib/clawmates-blobs
USER 65532 USER 65532
ENTRYPOINT ["/usr/local/bin/clawmates-server"] ENTRYPOINT ["/usr/local/bin/clawmates-server"]
+9 -5
View File
@@ -36,7 +36,7 @@ default_topology = "hub_spoke"
# empty-delivery rule — a phase that could produce nothing and still pass. Two # empty-delivery rule — a phase that could produce nothing and still pass. Two
# `research` phases differentiated by `task` keeps both guards. # `research` phases differentiated by `task` keeps both guards.
task = """ task = """
Read today's harvested papers and judge them against the projects listed below. Read today's harvested papers and judge them against the operator's projects.
Start from ContinuousResearch/<today>/harvest.jsonl — that is the list of papers \ Start from ContinuousResearch/<today>/harvest.jsonl — that is the list of papers \
that are NEW since the last run. Papers already covered are not in it, and you \ that are NEW since the last run. Papers already covered are not in it, and you \
@@ -44,8 +44,8 @@ should not go looking for them.
For each paper write an entry in ContinuousResearch/<today>/analysis.md \ For each paper write an entry in ContinuousResearch/<today>/analysis.md \
containing: what it actually does (not what its abstract claims), whether the \ containing: what it actually does (not what its abstract claims), whether the \
evidence supports it, and — the part that matters — WHICH of the projects below \ evidence supports it, and — the part that matters — WHICH of the operator's \
it bears on and what concrete change it would imply. Name a file, a module or a \ projects it bears on and what concrete change it would imply. Name a file, a module or a \
roadmap item wherever you can. roadmap item wherever you can.
Depth comes from the paper itself: the note carries the abstract, and `curl` on \ Depth comes from the paper itself: the note carries the abstract, and `curl` on \
@@ -54,8 +54,12 @@ the arXiv abstract page gets you the rest. Do not review a paper from its title.
A paper with no bearing on any project is a real and useful finding — say so in \ A paper with no bearing on any project is a real and useful finding — say so in \
one line and move on. Do not manufacture relevance. one line and move on. Do not manufacture relevance.
PROJECTS THIS RESEARCH SERVES — replace this block when creating the mission: THE PROJECTS THIS SERVES ARE IN THE BRIEF ABOVE. `phase_task_text` puts the \
(none configured yet) mission's description there verbatim, so that is where the operator names what \
they are working on and what each project needs. If the brief names no \
projects, say so plainly in analysis.md rather than inventing a target — a \
digest that guesses at relevance is worse than one that admits it has no \
context.
""" """
done_when = "ContinuousResearch/<today>/analysis.md exists and contains, for every paper in that day's harvest.jsonl, a judgement of the work and a statement of which project it bears on or that it bears on none" done_when = "ContinuousResearch/<today>/analysis.md exists and contains, for every paper in that day's harvest.jsonl, a judgement of the work and a statement of which project it bears on or that it bears on none"
max_iterations = 2 max_iterations = 2