loops: kind='research' dispatch — research runs as loops
ci / gates (push) Successful in 5s
ci / frontend (push) Successful in 30s
ci / rust (push) Failing after 48s
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

Delivers the research/loop fold: kind='research' loops run the
research pipeline each iteration, appending a new research_outcomes
version. The paired on_artifact_update fan-out then wakes up any
kind='exec' loops bound to the same topic to consume new INTs. Every
runnable thing is now a loop (D1).

Backend — DB helpers:
- cm_db::repo::loops::kind_and_binding — reads (kind, source_topic,
  task_template) so callers can dispatch without hydrating the whole
  Loop struct.
- cm_db::repo::loops::enqueue_iteration_with_topic — new variant that
  sets research_topic_id on topology_runs alongside loop_id, so the
  completion hook's freeze_research_outcome writes a new outcome
  version for research-kind iterations.
- cm_db::repo::research_topics::get_any_workspace — cross-workspace
  fetch used by the research task builder (the loop row is
  authoritative for the workspace binding via kind_and_binding).

Backend — dispatch:
- routes::loops::compose_research_iteration_task — builds the
  coordinator prompt for a research iteration: topic title +
  description + outcome_kind + prior version pointer + refresh
  instructions (survey new sources, preserve stable INT ids, mark
  superseded items as deprecated rather than delete). The completion
  hook writes the resulting synthesis as research_outcomes v(prior+1).
- routes::loops::compose_and_enqueue_iteration — one-shot dispatch:
  reads the kind, picks compose_iteration_task (exec) or
  compose_research_iteration_task (research), enqueues with or
  without research_topic_id set.

All four enqueue callsites now route through compose_and_enqueue:
- create_loop (initial_burst)
- run_now
- webhook_receive
- topology_worker::continue_initial_burst
- topology_worker::freeze_research_outcome (on_artifact_update fan-out)

Research-kind loops naturally form the "nightly refresh" side of a
paired research + coding loop: research writes a fresh outcome
version → fan-out wakes exec loops with on_artifact_update →
coding loops consume the next INT (which the research loop may have
just added). D2 answer (inherit repo binding): repo lives on the
topic; both loops sharing the source topic id read from the same
context, no duplication. D3 answer (coordinator resolves): the
research iteration prompt tells the team to preserve stable INT ids
and mark deprecations rather than delete, so coding loops' consumed
lists stay valid across versions.

Follow-up (next commit): ResearchWizard schedule step — "Just once /
Nightly / Manual" that creates the paired research-kind loop with
initial_burst=1 (just once) or cron 0 3 * * * (nightly) + optional
paired coding loop with on_artifact_update.
This commit is contained in:
Omar Sobh
2026-07-09 22:56:19 -07:00
parent 5aa2de7f30
commit 91a51dce11
4 changed files with 239 additions and 49 deletions
+13 -22
View File
@@ -226,26 +226,20 @@ async fn freeze_research_outcome(pool: &PgPool, run_id: Uuid, final_output: &str
{
continue; // Coalesce.
}
let iter = cm_db::repo::loops::next_iteration(pool, loop_id)
.await
.unwrap_or(0);
// Build the enriched task with the freshly-inserted artifact
// (compose_iteration_task reads latest, which is what we just
// wrote).
let task =
crate::routes::loops::compose_iteration_task(pool, loop_id, &task_template).await;
if let Err(e) = cm_db::repo::loops::enqueue_iteration(
// Fan-outs always target kind='exec' (filter enforced in
// loops_awaiting_topic). compose_and_enqueue_iteration takes
// the exec path and prepends the freshly-inserted artifact.
if let Err(e) = crate::routes::loops::compose_and_enqueue_iteration(
pool,
loop_id,
workspace_id,
&task,
&graph,
iter,
Some(run_id),
Some(&task_template),
)
.await
{
eprintln!("topology_worker: on_artifact_update enqueue({loop_id}) failed: {e}");
eprintln!("topology_worker: on_artifact_update enqueue({loop_id}) failed: {e:?}");
}
}
}
@@ -275,27 +269,24 @@ async fn continue_initial_burst(pool: &PgPool, run_id: Uuid) {
{
return;
}
// Fetch the loop so we have the workspace + graph + task_template
// to enqueue the next iteration.
// Fetch the loop so we have the workspace + graph. The kind-aware
// dispatcher pulls task_template + kind from the same helper it
// uses at first fire, so bursts across an exec + research pair
// behave identically.
let Ok(Some(l)) = cm_db::repo::loops::get_any_workspace(pool, loop_id).await else {
return;
};
let iter = cm_db::repo::loops::next_iteration(pool, loop_id)
.await
.unwrap_or(0);
let task = crate::routes::loops::compose_iteration_task(pool, loop_id, &l.task_template).await;
if let Err(e) = cm_db::repo::loops::enqueue_iteration(
if let Err(e) = crate::routes::loops::compose_and_enqueue_iteration(
pool,
loop_id,
l.workspace_id,
&task,
&l.graph,
iter,
Some(run_id),
None,
)
.await
{
eprintln!("topology_worker: continue_initial_burst enqueue failed: {e}");
eprintln!("topology_worker: continue_initial_burst enqueue failed: {e:?}");
}
}