loops: bridge research artifact into loop iterations (option C + b)
ci / gates (push) Successful in 7s
ci / frontend (push) Successful in 37s
ci / rust (push) Failing after 13m41s
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

The bridge lets a coding loop "consume" an integrations research
artifact one INT-XX item per iteration. Options b (order-sequential
iteration) and C (snapshot in task_template + save the pointer for
future refresh) from the design discussion.

Migration 0042 — three new loops columns:
- source_research_topic_id — nullable pointer to research_topics.
- consumed_int_ids TEXT[]   — INT-XX ids the loop has completed.
  Advances when topology_worker parses "COMPLETED: INT-<NN>" markers
  from the run's final output (wired in a follow-up commit).
- current_int_index INT     — monotonic pointer for order-sequential
  iteration. Coordinator addresses INT-<current+1> unless prereqs are
  unmet, in which case it works on the smallest unblocking INT-XX and
  logs the reorder rationale.

Backend:
- cm_db::repo::loops::set_source_research_topic — bind/unbind pointer.
- cm_db::repo::loops::source_research_context   — read pointer + state.
- routes::loops::compose_iteration_task         — new caller-side helper
  that reads the pointer, fetches the topic's latest research_outcome,
  and prepends the artifact + focus instruction to task_template.
- run_now + webhook_receive both pass task_template through
  compose_iteration_task before enqueue. Standalone loops (no pointer)
  behave identically to before.
- CreateLoopRequest accepts `source_research_topic_id`, ownership-
  checked via research_topics::get before persist.

Frontend:
- New ResearchArtifactPicker modal — lists published topics, fetches
  the artifact on pick, returns (topic_id, markdown) to caller.
- LoopsWizard task_template step gains "Import from research artifact"
  button (right-aligned). Click opens the picker. On pick: task
  populates with the artifact markdown, pointer saved, textarea
  expands to 8 rows, small info strip shows "Loop is bound to topic
  <id>. Each iteration will focus on the next unconsumed INT-XX."
- Unlink button reverts to standalone loop mode.

Follow-up (next commit):
- topology_worker completion hook — parse "COMPLETED: INT-<NN>" out
  of the run's final output + update consumed_int_ids +
  current_int_index atomically. Without this, current_int_index stays
  at 0 forever and every iteration works on the same INT.
- Loop card refresh button — re-read source topic's latest outcome
  (useful after a reject-with-revision cycle on the source topic).
This commit is contained in:
Omar Sobh
2026-07-09 18:47:17 -07:00
parent 3ed1d03d2b
commit ce73abe5ab
6 changed files with 428 additions and 4 deletions
+53
View File
@@ -247,6 +247,59 @@ pub async fn set_zeroclaw_container(
Ok(())
}
/// Bind (or unbind) a loop's source research topic. When set, the loop's
/// enqueue path prepends the topic's latest artifact + a "focus on the
/// next unconsumed INT" instruction to the coordinator task (option b,
/// order-sequential iteration).
pub async fn set_source_research_topic(
pool: &PgPool,
loop_id: Uuid,
workspace_id: Uuid,
source: Option<Uuid>,
) -> Result<(), DbError> {
sqlx::query(
"UPDATE loops
SET source_research_topic_id = $3, updated_at = now()
WHERE id = $1 AND workspace_id = $2",
)
.bind(loop_id)
.bind(workspace_id)
.bind(source)
.execute(pool)
.await?;
Ok(())
}
/// Read a loop's source research topic id + consumed INT ids +
/// current index. Used by the enqueue path when building the
/// coordinator task string. Missing rows / NULL columns return None
/// so the caller can fall back to the plain task_template.
pub async fn source_research_context(
pool: &PgPool,
loop_id: Uuid,
) -> Result<Option<(Uuid, Vec<String>, i32)>, DbError> {
use sqlx::Row;
let row = sqlx::query(
"SELECT source_research_topic_id, consumed_int_ids, current_int_index
FROM loops
WHERE id = $1",
)
.bind(loop_id)
.fetch_optional(pool)
.await?;
Ok(row.and_then(|r| {
let topic = r
.try_get::<Option<Uuid>, _>("source_research_topic_id")
.ok()
.flatten()?;
let consumed = r
.try_get::<Vec<String>, _>("consumed_int_ids")
.unwrap_or_default();
let idx = r.try_get::<i32, _>("current_int_index").unwrap_or(0);
Some((topic, consumed, idx))
}))
}
/// Read the per-loop gateway URL (or None if the loop hasn't spawned a
/// container yet). Used by `topology_worker` to prefer the isolated
/// daemon over the workspace-wide one.