feat(missions): Continuous Research harvests at launch, and cards launch by clicking
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:
co-authored by
Claude Opus 5
parent
a2d7e3ea92
commit
a02e0cba69
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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}"),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user