research: fix wizard loops never firing + missing clone/spawn (regression)
ci / gates (push) Failing after 6s
ci / rust (push) Has been skipped
ci / frontend (push) Has been skipped
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

Two bugs surfaced by the first end-to-end wizard run — the pipeline
diagnostic showed "0 run(s)", "Repo bound but never cloned", and
"Container not spawned" for a topic that had been created with a
nightly research loop.

Bug 1: materialize_topic_loops bypassed initial_burst firing.
The wizard-materialized loops path calls cm_db::repo::loops::create
directly (a plain INSERT). The initial_burst-fires-first-iteration
logic lived inside the create_loop HTTP handler, so wizard loops
landed in the DB but never fired their initial iteration.

Fix: extract routes::loops::fire_initial_burst_if_set as a pub helper
that read triggers, ensures the loop container, and calls the
kind-aware compose_and_enqueue_iteration. Both create_loop and
materialize_topic_loops now call it.

Bug 2: research-kind loop iterations skipped clone/spawn.
compose_research_iteration_task only built the coordinator prompt;
the repo clone and topic container spawn lived only in start_topic.
So the first research iteration ran against a nonexistent clone
directory and a stale gateway, and every run failed.

Fix: extract routes::research::prepare_topic_runtime as a pub helper
that runs ensure_repo_workspace + research_container::spawn.
Idempotent — second iteration reattaches. Called from
compose_and_enqueue_iteration before enqueuing a research iteration.
Topics without a repo bound are a no-op.

Both fixes ship as one commit because they surface together on the
same user path (wizard → research loop → first iteration) — you
can't hit one without the other manifesting.

Follow-up: start_topic still runs its own inline clone/spawn code
(now duplicated with prepare_topic_runtime). Next commit collapses
start_topic to just call prepare_topic_runtime + build_task like
the loop path does, so the one-shot and loop paths agree on setup.
This commit is contained in:
Omar Sobh
2026-07-10 07:38:52 -07:00
parent 3e42b1ea39
commit 80c23e57ed
2 changed files with 157 additions and 41 deletions
+91
View File
@@ -329,6 +329,68 @@ fn research_workspace_root() -> std::path::PathBuf {
std::env::temp_dir().join("clawmates-research")
}
/// Set up the on-disk workspace + container for a research topic —
/// clone repo (idempotent) + spawn ZeroClaw team container (idempotent).
/// Callable from both the one-shot `start_topic` handler and the
/// kind='research' loop iteration path in routes::loops. Fully
/// best-effort: any failure (docker unreachable, no clone_url) logs
/// and returns, letting the caller enqueue the run against the
/// workspace-wide gateway instead.
pub async fn prepare_topic_runtime(pool: &sqlx::PgPool, workspace_id: Uuid, topic_id: Uuid) {
let topic = match cm_db::repo::research_topics::get_any_workspace(pool, topic_id).await {
Ok(Some(t)) => t,
_ => return,
};
// Repo binding is optional; without it we just skip clone + spawn.
let Some(repo_id) = topic.repo_id else {
return;
};
let repo =
match cm_db::repo::repos::get(pool, repo_id, cm_domain::WorkspaceId::from(workspace_id))
.await
{
Ok(r) => r,
Err(e) => {
eprintln!("prepare_topic_runtime({topic_id}): repo fetch failed: {e:?}");
return;
}
};
let ctx = match ensure_repo_workspace(pool, topic_id, workspace_id, &repo, &topic).await {
Ok(c) => c,
Err(e) => {
eprintln!("prepare_topic_runtime({topic_id}): clone failed: {e}");
return;
}
};
let repo_path = std::path::PathBuf::from(&ctx.path);
let state_root = research_workspace_root()
.join(topic_id.to_string())
.join("state");
let docker = match crate::research_container::connect() {
Ok(d) => d,
Err(e) => {
eprintln!("prepare_topic_runtime({topic_id}): docker connect failed: {e}");
return;
}
};
match crate::research_container::spawn(&docker, topic_id, &repo_path, &state_root).await {
Ok(spawned) => {
if let Err(e) = cm_db::repo::research_topics::set_zeroclaw_container(
pool,
topic_id,
workspace_id,
Some(&spawned.name),
Some(&spawned.gateway_url),
)
.await
{
eprintln!("prepare_topic_runtime({topic_id}): persist container failed: {e}");
}
}
Err(e) => eprintln!("prepare_topic_runtime({topic_id}): spawn failed: {e}"),
}
}
/// Clone the bound repo (shallow, single branch) into a per-topic
/// workspace and gather a tree preview for the coordinator prompt.
/// Persists the clone path on the topic so a re-start reuses it instead
@@ -583,6 +645,21 @@ async fn materialize_topic_loops(
)
.await;
let _ = cm_db::repo::loops::set_kind(pool, loop_id, "research").await;
// Fire the initial burst NOW (this is what create_loop's
// handler does after inserting the row; materialize_topic_loops
// bypasses that handler). Without this call the loop lands
// in the DB but its initial_burst=1 never fires and the
// diagnostic shows "0 runs".
crate::routes::loops::fire_initial_burst_if_set(
pool,
workspace_id,
loop_id,
&r_triggers,
"Refresh the topic's research per the outcome kind.",
&graph,
next_fire_at,
)
.await;
}
Err(e) => {
eprintln!("materialize_topic_loops: research loop create failed: {e:?}");
@@ -621,6 +698,20 @@ async fn materialize_topic_loops(
Some(topic_id),
)
.await;
// Fire the coding loop's initial burst too. It's
// typically 1 (single wake to consume the first
// artifact) and on_artifact_update handles subsequent
// waves via the fan-out hook.
crate::routes::loops::fire_initial_burst_if_set(
pool,
workspace_id,
loop_id,
&c_triggers,
"Execute the next unconsumed INT-XX from the artifact.",
&graph,
None,
)
.await;
}
Err(e) => {
eprintln!("materialize_topic_loops: coding loop create failed: {e:?}");