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
+66 -41
View File
@@ -86,6 +86,11 @@ pub async fn compose_and_enqueue_iteration(
let Some(topic_id) = source_topic else {
return Err(cm_db::DbError::NotFound);
};
// Clone + spawn container BEFORE enqueuing so the run has real
// repo files + an isolated daemon to hit. Idempotent — the
// second iteration reattaches to the existing container. Runs
// even when the topic has no repo (harmless no-op).
crate::routes::research::prepare_topic_runtime(pool, workspace_id, topic_id).await;
let task = compose_research_iteration_task(pool, topic_id, template_ref).await;
cm_db::repo::loops::enqueue_iteration_with_topic(
pool,
@@ -400,47 +405,21 @@ pub async fn create_loop(
}
}
// initial_burst — fire the first iteration inline, so every loop
// runs at least once when the trigger requests it (D1). The burst
// continues via the on_completion trigger up to `initial_burst`
// (tracked in loops.initial_burst_remaining, decremented on each
// completion). Best-effort: a docker/DB failure on the FIRST fire
// logs but the loop still lives with next_fire_at set for its
// cron.
let parsed = parse_triggers(&body.triggers);
let initial_burst = parsed.as_ref().map(|t| t.initial_burst).unwrap_or(0);
let chain_on_completion = parsed.as_ref().map(|t| t.on_completion).unwrap_or(false);
if initial_burst > 0 {
ensure_loop_container(&state.pool, user.workspace_id.as_uuid(), id).await;
// Kind-aware compose + enqueue — research-kind loops get a
// research prompt and research_topic_id set on the run.
let template = body.task_template.trim();
match compose_and_enqueue_iteration(
&state.pool,
id,
user.workspace_id.as_uuid(),
&body.graph,
None,
Some(template),
)
.await
{
Ok(run_id) => {
let _ = cm_db::repo::loops::mark_fired(&state.pool, id, run_id, next_fire_at).await;
// Log the burst remaining so on_completion can advance it.
// Stored on loops.initial_burst_remaining (added by the
// same migration as loops.kind). Falls back silently on
// schema absence so we don't break dev DBs mid-rollout.
let remaining = initial_burst.saturating_sub(1) as i32;
if remaining > 0 || chain_on_completion {
let _ =
cm_db::repo::loops::set_initial_burst_remaining(&state.pool, id, remaining)
.await;
}
}
Err(e) => eprintln!("loops::create: initial_burst enqueue failed: {e:?}"),
}
}
// Fire the initial burst if the triggers request it. Extracted so
// materialize_topic_loops (the wizard-materialized loops path) can
// reuse the same logic — previously the burst logic lived only in
// this handler and wizard-created loops never fired their first
// iteration.
fire_initial_burst_if_set(
&state.pool,
user.workspace_id.as_uuid(),
id,
&body.triggers,
body.task_template.trim(),
&body.graph,
next_fire_at,
)
.await;
Ok((
StatusCode::CREATED,
@@ -452,6 +431,52 @@ pub async fn create_loop(
))
}
/// Fire the initial_burst if the loop's triggers request one. On the
/// first fire, ensures the per-loop container is spawned and
/// (for kind='research' loops) that the topic's repo is cloned and
/// the topic container is up. Sets `initial_burst_remaining = burst
/// - 1` so the completion hook can continue the chain. Best-effort:
/// a docker or DB hiccup on the FIRST fire logs but the loop row
/// still lives — cron / on_artifact_update / webhook can still fire
/// it later.
pub async fn fire_initial_burst_if_set(
pool: &sqlx::PgPool,
workspace_id: Uuid,
loop_id: Uuid,
triggers: &Value,
task_template: &str,
graph: &Value,
next_fire_at: Option<OffsetDateTime>,
) {
let parsed = parse_triggers(triggers);
let initial_burst = parsed.as_ref().map(|t| t.initial_burst).unwrap_or(0);
let chain_on_completion = parsed.as_ref().map(|t| t.on_completion).unwrap_or(false);
if initial_burst == 0 {
return;
}
ensure_loop_container(pool, workspace_id, loop_id).await;
match compose_and_enqueue_iteration(
pool,
loop_id,
workspace_id,
graph,
None,
Some(task_template),
)
.await
{
Ok(run_id) => {
let _ = cm_db::repo::loops::mark_fired(pool, loop_id, run_id, next_fire_at).await;
let remaining = initial_burst.saturating_sub(1) as i32;
if remaining > 0 || chain_on_completion {
let _ =
cm_db::repo::loops::set_initial_burst_remaining(pool, loop_id, remaining).await;
}
}
Err(e) => eprintln!("fire_initial_burst_if_set({loop_id}): enqueue failed: {e:?}"),
}
}
async fn apply_staffing(
pool: &sqlx::PgPool,
loop_id: Uuid,