loops: kind column + initial_burst + on_artifact_update trigger fan-out
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 28s
ci / rust (push) Failing after 1m0s
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

Foundation for folding research into loops as a first-class kind.
This commit ships the plumbing; the research-kind dispatch itself
lands next. Behavior for existing exec-kind loops is unchanged unless
they opt into the new trigger fields.

Migration 0044:
- kind TEXT NOT NULL DEFAULT 'exec' CHECK ('exec' | 'research'). New
  research-kind will run the research pipeline each iteration (next
  commit); 'exec' preserves today's behavior.
- initial_burst_remaining INT NOT NULL DEFAULT 0 — countdown for the
  triggers.initial_burst quota. Decremented CAS-safely on each
  completion until it hits 0.
- Two partial indexes: (kind, source_research_topic_id) for kind-
  aware lookups, and (source_research_topic_id) filtered on
  on_artifact_update=true + enabled=true for the fan-out hook.

Trigger schema extended with two optional fields:
- initial_burst: N — fire N iterations back-to-back at create time.
  create_loop enqueues the first iteration inline (subject to
  empty-roster gate), sets remaining=N-1, and the completion hook
  continues the chain until exhausted.
- on_artifact_update: true — when a bound research_outcomes row is
  inserted for the source topic, wake up one iteration of this loop.
  Coalesced against has_active_run so a burst of rapid revisions
  doesn't queue duplicates.

Backend:
- cm_db::repo::loops helpers (all dynamic sqlx, no offline cache
  regen needed):
  - set_initial_burst_remaining
  - take_initial_burst_slot (CAS UPDATE returning prev value; 0 on
    exhausted or race loss)
  - loops_awaiting_topic (fan-out query: kind=exec + enabled +
    on_artifact_update=true bound to the given topic)
  - has_active_run (queued|running iteration existence check)
  - get_any_workspace (bypasses the workspace scope guard; used by
    the completion hook where the run row is authoritative)
- routes/loops::compose_iteration_task made pub so the completion
  hook can build the same enriched task string as run_now.
- topology_worker::freeze_research_outcome now fans out to awakened
  loops after the outcome insert, using compose_iteration_task and
  coalescing on has_active_run.
- topology_worker::continue_initial_burst runs on every completion:
  · take_initial_burst_slot (CAS) — no-op if already exhausted
  · has_active_run coalesce guard
  · re-fetches the loop via get_any_workspace + compose_iteration_task
  · enqueues via loops::enqueue_iteration with parent_run_id set

Follow-ups already queued:
- kind='research' dispatch in run_job — build the research
  coordinator task from the topic config, run the research pipeline
  each iteration. Requires factoring start_topic's task-build.
- ResearchWizard "When should this run?" step (Just once / Nightly /
  Manual) creating the topic + paired research-kind loop.
- LoopsWizard trigger UI matching the design proposal (burst count,
  cron, on-artifact checkbox).
This commit is contained in:
Omar Sobh
2026-07-09 22:50:01 -07:00
parent ce111273bb
commit 4ada5557f2
4 changed files with 321 additions and 1 deletions
+65 -1
View File
@@ -55,7 +55,7 @@ fn loop_state_root() -> std::path::PathBuf {
/// <task_template> /// <task_template>
/// The topology_worker's completion hook (P3) parses the COMPLETED /// The topology_worker's completion hook (P3) parses the COMPLETED
/// marker to update `consumed_int_ids`. /// marker to update `consumed_int_ids`.
async fn compose_iteration_task(pool: &PgPool, loop_id: Uuid, task_template: &str) -> String { pub async fn compose_iteration_task(pool: &PgPool, loop_id: Uuid, task_template: &str) -> String {
let ctx = cm_db::repo::loops::source_research_context(pool, loop_id) let ctx = cm_db::repo::loops::source_research_context(pool, loop_id)
.await .await
.unwrap_or(None); .unwrap_or(None);
@@ -183,6 +183,24 @@ struct Triggers {
on_completion: bool, on_completion: bool,
#[serde(default)] #[serde(default)]
webhook_enabled: bool, webhook_enabled: bool,
/// NEW — number of iterations to fire back-to-back at loop-create
/// time. Enqueue path: fire once immediately, then chain each
/// subsequent one via on_completion until the burst quota is
/// exhausted (tracked in run metadata). Defaults to 0 for existing
/// loops (no auto-fire); new wizards typically set 1 (D1: every
/// runnable thing runs at least once).
#[serde(default)]
initial_burst: u32,
/// NEW — when this loop is bound to a source_research_topic and
/// that topic gets a fresh research_outcomes row (via
/// freeze_research_outcome), enqueue one iteration on this loop.
/// Coalesced with any in-flight run (D3: coordinator resolves;
/// no race, just one wake per artifact update). Read directly
/// from the loops.triggers jsonb by loops_awaiting_topic — no
/// need for the Rust parser to hold it after the fact.
#[serde(default)]
#[allow(dead_code)]
on_artifact_update: bool,
} }
fn make_webhook_material() -> (String, String) { fn make_webhook_material() -> (String, String) {
@@ -276,6 +294,52 @@ 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;
let iter = cm_db::repo::loops::next_iteration(&state.pool, id)
.await
.unwrap_or(0);
let task = compose_iteration_task(&state.pool, id, body.task_template.trim()).await;
// Best-effort — we don't want to abort the loop-create response
// just because Docker didn't answer the daemon health check.
match cm_db::repo::loops::enqueue_iteration(
&state.pool,
id,
user.workspace_id.as_uuid(),
&task,
&body.graph,
iter,
None,
)
.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:?}"),
}
}
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
Json(LoopCreated { Json(LoopCreated {
+89
View File
@@ -171,6 +171,7 @@ async fn run_job(
} }
freeze_research_outcome(pool, id, &record.final_output).await; freeze_research_outcome(pool, id, &record.final_output).await;
advance_loop_after_completion(pool, id, &record.final_output).await; advance_loop_after_completion(pool, id, &record.final_output).await;
continue_initial_burst(pool, id).await;
} }
Err(e) => { Err(e) => {
// Don't clobber a cancellation (or any already-terminal state) with `failed`. // Don't clobber a cancellation (or any already-terminal state) with `failed`.
@@ -207,6 +208,94 @@ async fn freeze_research_outcome(pool: &PgPool, run_id: Uuid, final_output: &str
cm_db::repo::research_outcomes::insert(pool, topic_id, final_output, Some(run_id)).await cm_db::repo::research_outcomes::insert(pool, topic_id, final_output, Some(run_id)).await
{ {
eprintln!("topology_worker: research_outcomes::insert({run_id}) failed: {e}"); eprintln!("topology_worker: research_outcomes::insert({run_id}) failed: {e}");
return;
}
// Fan-out: any exec-kind loop bound to this topic with the
// on_artifact_update trigger enabled wakes up now. Coalesced —
// if a loop already has a queued/running run we skip (D3 fallback:
// the coordinator sees the fresh artifact on its next iteration
// anyway). Best-effort per loop; one loop's Docker/DB hiccup
// doesn't affect the others.
let awakened = cm_db::repo::loops::loops_awaiting_topic(pool, topic_id)
.await
.unwrap_or_default();
for (loop_id, workspace_id, task_template, graph) in awakened {
if cm_db::repo::loops::has_active_run(pool, loop_id)
.await
.unwrap_or(false)
{
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(
pool,
loop_id,
workspace_id,
&task,
&graph,
iter,
Some(run_id),
)
.await
{
eprintln!("topology_worker: on_artifact_update enqueue({loop_id}) failed: {e}");
}
}
}
/// If the just-completed run was a loop iteration with
/// initial_burst_remaining > 0, enqueue the next iteration and
/// decrement the counter (CAS-safe via take_initial_burst_slot).
/// No-op for non-loop runs and for loops whose burst is exhausted.
async fn continue_initial_burst(pool: &PgPool, run_id: Uuid) {
let loop_id = match cm_db::repo::topology_runs::loop_id_for_run(pool, run_id).await {
Ok(Some(id)) => id,
_ => return,
};
// If another worker races us, only ONE gets the slot; the other
// sees 0 (no-op).
let prev = cm_db::repo::loops::take_initial_burst_slot(pool, loop_id)
.await
.unwrap_or(0);
if prev == 0 {
return;
}
// Coalesce with a concurrently-in-flight iteration (a webhook
// arriving during a burst, say).
if cm_db::repo::loops::has_active_run(pool, loop_id)
.await
.unwrap_or(false)
{
return;
}
// Fetch the loop so we have the workspace + graph + task_template
// to enqueue the next iteration.
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(
pool,
loop_id,
l.workspace_id,
&task,
&l.graph,
iter,
Some(run_id),
)
.await
{
eprintln!("topology_worker: continue_initial_burst enqueue failed: {e}");
} }
} }
+128
View File
@@ -104,6 +104,44 @@ pub async fn list(pool: &PgPool, workspace_id: Uuid) -> Result<Vec<Loop>, DbErro
Ok(rows) Ok(rows)
} }
/// Cross-workspace fetch used by internal callers (topology_worker
/// completion hooks) where the run row is authoritative for the
/// workspace binding — no need for a second scoping check. Returns
/// None if the loop was deleted between run enqueue and completion.
pub async fn get_any_workspace(pool: &PgPool, id: Uuid) -> Result<Option<Loop>, DbError> {
// Dynamic query so this callsite doesn't require an offline sqlx
// cache regen — used from the completion hook, not on the hot path.
use sqlx::Row;
let row: Option<sqlx::postgres::PgRow> = sqlx::query(
"SELECT id, workspace_id, title, description, graph, task_template,
triggers, repeat_policy, enabled, next_fire_at, last_run_id,
webhook_token, webhook_signing_key, created_by, created_at, updated_at
FROM loops
WHERE id = $1",
)
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| Loop {
id: r.get("id"),
workspace_id: r.get("workspace_id"),
title: r.get("title"),
description: r.get("description"),
graph: r.get("graph"),
task_template: r.get("task_template"),
triggers: r.get("triggers"),
repeat_policy: r.get("repeat_policy"),
enabled: r.get("enabled"),
next_fire_at: r.try_get("next_fire_at").ok().flatten(),
last_run_id: r.try_get("last_run_id").ok().flatten(),
webhook_token: r.try_get("webhook_token").ok().flatten(),
webhook_signing_key: r.try_get("webhook_signing_key").ok().flatten(),
created_by: r.get("created_by"),
created_at: r.get("created_at"),
updated_at: r.get("updated_at"),
}))
}
pub async fn get(pool: &PgPool, id: Uuid, workspace_id: Uuid) -> Result<Option<Loop>, DbError> { pub async fn get(pool: &PgPool, id: Uuid, workspace_id: Uuid) -> Result<Option<Loop>, DbError> {
let row = sqlx::query_as!( let row = sqlx::query_as!(
Loop, Loop,
@@ -254,6 +292,96 @@ pub async fn set_zeroclaw_container(
/// downstream mini-timeline can show WHEN the plan was adjusted and /// downstream mini-timeline can show WHEN the plan was adjusted and
/// WHY. Idempotent: appending a duplicate text/run_id combo is allowed /// WHY. Idempotent: appending a duplicate text/run_id combo is allowed
/// (rare — indicates the parser matched twice on the same line). /// (rare — indicates the parser matched twice on the same line).
/// Set the countdown for the triggers.initial_burst quota. On
/// create_loop we set this to `burst - 1` after firing the first
/// iteration inline; on each subsequent completion we decrement and,
/// while it's > 0, enqueue another iteration. Dynamic query so the
/// new column doesn't need an offline sqlx cache regen.
pub async fn set_initial_burst_remaining(
pool: &PgPool,
loop_id: Uuid,
remaining: i32,
) -> Result<(), DbError> {
sqlx::query("UPDATE loops SET initial_burst_remaining = $2 WHERE id = $1")
.bind(loop_id)
.bind(remaining)
.execute(pool)
.await?;
Ok(())
}
/// Atomically decrement initial_burst_remaining, returning the value
/// BEFORE decrement. Zero is a no-op (returns 0). Used by the
/// completion hook: caller enqueues a new iteration if the returned
/// value is > 0. CAS-safe: two workers can't race and both enqueue.
pub async fn take_initial_burst_slot(pool: &PgPool, loop_id: Uuid) -> Result<i32, DbError> {
use sqlx::Row;
let row: Option<sqlx::postgres::PgRow> = sqlx::query(
"UPDATE loops
SET initial_burst_remaining = GREATEST(initial_burst_remaining - 1, 0)
WHERE id = $1 AND initial_burst_remaining > 0
RETURNING initial_burst_remaining + 1 AS prev",
)
.bind(loop_id)
.fetch_optional(pool)
.await?;
Ok(row
.and_then(|r| r.try_get::<i32, _>("prev").ok())
.unwrap_or(0))
}
/// Enumerate loops that should wake up when a research topic gets a
/// new outcome. Filters to kind='exec', enabled, and
/// triggers.on_artifact_update = true. Called by the completion hook
/// after freeze_research_outcome inserts a new row. Returns
/// (loop_id, workspace_id, task_template, graph) so the caller can
/// enqueue directly without a second fetch.
pub async fn loops_awaiting_topic(
pool: &PgPool,
topic_id: Uuid,
) -> Result<Vec<(Uuid, Uuid, String, Value)>, DbError> {
use sqlx::Row;
let rows = sqlx::query(
"SELECT id, workspace_id, task_template, graph
FROM loops
WHERE source_research_topic_id = $1
AND kind = 'exec'
AND enabled = true
AND (triggers ->> 'on_artifact_update')::boolean = true",
)
.bind(topic_id)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| {
(
r.get::<Uuid, _>("id"),
r.get::<Uuid, _>("workspace_id"),
r.get::<String, _>("task_template"),
r.get::<Value, _>("graph"),
)
})
.collect())
}
/// Truthy when the loop currently has a queued or running iteration.
/// Used by triggers (on_artifact_update, on_completion chain) to
/// coalesce — no point enqueuing another iteration while one is
/// already pending.
pub async fn has_active_run(pool: &PgPool, loop_id: Uuid) -> Result<bool, DbError> {
use sqlx::Row;
let row: Option<sqlx::postgres::PgRow> = sqlx::query(
"SELECT 1 AS one FROM topology_runs
WHERE loop_id = $1 AND status IN ('queued', 'running')
LIMIT 1",
)
.bind(loop_id)
.fetch_optional(pool)
.await?;
Ok(row.is_some())
}
/// Read the reorder_events array for a loop, newest-first, capped at /// Read the reorder_events array for a loop, newest-first, capped at
/// `limit`. Used by the progress endpoint to surface a compact recent /// `limit`. Used by the progress endpoint to surface a compact recent
/// history on the sidebar card. Empty array for standalone loops or /// history on the sidebar card. Empty array for standalone loops or
@@ -0,0 +1,39 @@
-- Folds research into loops as a "kind" of loop. Two kinds exist:
--
-- 'exec' — today's behavior. Each iteration runs the coordinator
-- task_template (optionally prepended with a research
-- artifact + focus instructions for INT-consumption
-- loops).
-- 'research' — the fire runs the research pipeline against a bound
-- topic config, appending a versioned research_outcomes
-- row each time. Used for nightly paper-survey loops
-- (D1 answer: every runnable thing is a loop; even a
-- one-shot topic gets its own kind='research' loop with
-- initial_burst=1).
--
-- Default 'exec' + CHECK constraint keeps every existing row valid.
-- The paired research + coding pattern (nightly research produces
-- outcome, coding loop wakes on on_artifact_update to consume next INT)
-- composes two loops of different kinds sharing a source topic id.
ALTER TABLE loops
ADD COLUMN kind TEXT NOT NULL DEFAULT 'exec'
CHECK (kind IN ('exec', 'research')),
-- Countdown for the triggers.initial_burst quota. On create_loop
-- we enqueue the first iteration inline and set this to burst-1;
-- on each completion the topology_worker checks and, when > 0,
-- enqueues the next iteration + decrements. Once it hits 0 the
-- burst is exhausted (further iterations happen only through
-- cron / webhook / on_completion / on_artifact_update).
ADD COLUMN initial_burst_remaining INT NOT NULL DEFAULT 0;
CREATE INDEX loops_kind_source_idx
ON loops (kind, source_research_topic_id)
WHERE source_research_topic_id IS NOT NULL;
-- Fast lookup for the on_artifact_update hook: given a topic_id, find
-- all exec-kind loops bound to it with the trigger enabled.
CREATE INDEX loops_on_artifact_update_idx
ON loops (source_research_topic_id)
WHERE source_research_topic_id IS NOT NULL
AND (triggers ->> 'on_artifact_update')::boolean = true
AND enabled = true;