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).
504 lines
20 KiB
Rust
504 lines
20 KiB
Rust
//! Background worker that drains durable topology run jobs.
|
|
//!
|
|
//! A `POST /api/topologies/run` enqueues a job (`topology_runs` row, status
|
|
//! `queued`); this loop claims it, drives the topology turn-by-turn via the
|
|
//! ZeroClaw runtime, and checkpoints the [`RunProgress`] after every step. If
|
|
//! the worker (or the whole server) dies mid-run, the row is left `running`;
|
|
//! the stale sweep requeues it and the next claim resumes it from the last
|
|
//! checkpointed step — so long-horizon runs survive restarts.
|
|
//!
|
|
//! This reuses the agent-run durability pattern (claim CAS, checkpoint, resume
|
|
//! sweep) without coupling topology runs to the chat-session schema.
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use cm_domain::WorkspaceId;
|
|
use cm_orchestrator::{execute_resumable, OrchestratorError, RunProgress, RunRecord, TurnExecutor};
|
|
use cm_topology::TopologyGraph;
|
|
use sqlx::PgPool;
|
|
use uuid::Uuid;
|
|
|
|
use crate::recursive_exec::{SubTopologyExecutor, Tier};
|
|
use crate::topology_exec::ZeroClawDriveExecutor;
|
|
|
|
/// Requeue a `running` job whose worker hasn't checkpointed within this window.
|
|
const STALE_AFTER_SECS: f64 = 180.0;
|
|
|
|
/// Spawn the durable topology job worker. Polls for queued jobs every `poll`
|
|
/// interval; runs each to completion (or failure), checkpointing per step.
|
|
pub fn spawn(pool: PgPool, runtime: cm_runtime::Runtime, poll: Duration) {
|
|
tokio::spawn(async move {
|
|
loop {
|
|
// Recover jobs orphaned by a dead worker before claiming new ones.
|
|
if let Err(e) = cm_db::repo::topology_runs::requeue_stale(&pool, STALE_AFTER_SECS).await
|
|
{
|
|
eprintln!("topology_worker: requeue_stale failed: {e}");
|
|
}
|
|
match cm_db::repo::topology_runs::claim_next_queued(&pool).await {
|
|
Ok(Some(job)) => run_job(&pool, &runtime, job).await,
|
|
Ok(None) => tokio::time::sleep(poll).await,
|
|
Err(e) => {
|
|
eprintln!("topology_worker: claim failed: {e}");
|
|
tokio::time::sleep(poll).await;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Drive one claimed job to a terminal state, persisting checkpoints as it goes.
|
|
async fn run_job(
|
|
pool: &PgPool,
|
|
runtime: &cm_runtime::Runtime,
|
|
job: cm_db::repo::topology_runs::ClaimedTopologyRun,
|
|
) {
|
|
let id = job.id;
|
|
|
|
// Swarm runs aren't graph topologies — the `graph` JSONB holds the swarm
|
|
// config. Branch before the graph parse and run the self-verifying loop.
|
|
if job.tier == "swarm" {
|
|
let cfg = job.graph.clone().unwrap_or(serde_json::Value::Null);
|
|
let swarm_job: crate::swarm::SwarmJob = serde_json::from_value(cfg).unwrap_or_default();
|
|
let result = crate::swarm::run_swarm_job(pool, runtime, id, swarm_job, &job.task).await;
|
|
match result {
|
|
Ok(record) => {
|
|
let value = serde_json::to_value(&record).unwrap_or(serde_json::Value::Null);
|
|
if let Err(e) = cm_db::repo::topology_runs::complete(pool, id, &value).await {
|
|
eprintln!("topology_worker: swarm complete({id}) failed: {e}");
|
|
}
|
|
}
|
|
Err(e) => {
|
|
let _ = cm_db::repo::topology_runs::fail(pool, id, &e).await;
|
|
}
|
|
}
|
|
maybe_transition_research_topic(pool, id).await;
|
|
return;
|
|
}
|
|
|
|
let Some(graph) = job
|
|
.graph
|
|
.as_ref()
|
|
.and_then(|g| serde_json::from_value::<TopologyGraph>(g.clone()).ok())
|
|
else {
|
|
let _ = cm_db::repo::topology_runs::fail(pool, id, "missing or invalid graph").await;
|
|
return;
|
|
};
|
|
|
|
// Resume from the last checkpoint, or start fresh.
|
|
let progress: RunProgress = job
|
|
.checkpoint
|
|
.and_then(|c| serde_json::from_value(c).ok())
|
|
.unwrap_or_default();
|
|
|
|
// If this run belongs to a research topic OR a loop with a per-team
|
|
// ZeroClaw container spawned, point the executor at THAT container's
|
|
// gateway URL so the run's turns hit its isolated daemon instead of
|
|
// the workspace-wide one. Falls back to the env-derived executor when
|
|
// there's no per-topic/loop container (chat sessions, or research/loop
|
|
// runs where spawn failed and we recorded no URL).
|
|
let per_topic_url = match cm_db::repo::topology_runs::research_topic_id(pool, id).await {
|
|
Ok(Some(topic_id)) => {
|
|
match cm_db::repo::research_topics::get(pool, topic_id, job.workspace_id).await {
|
|
Ok(Some(t)) => t.zeroclaw_gateway_url,
|
|
_ => None,
|
|
}
|
|
}
|
|
_ => None,
|
|
};
|
|
// Loop lookup runs only when the research lookup didn't hit — a run
|
|
// is bound to at most one of {topic, loop}. This preserves the
|
|
// existing research fast path unchanged.
|
|
let per_topic_url = if per_topic_url.is_some() {
|
|
per_topic_url
|
|
} else {
|
|
match cm_db::repo::topology_runs::loop_id_for_run(pool, id).await {
|
|
Ok(Some(loop_id)) => cm_db::repo::loops::zeroclaw_gateway_url(pool, loop_id)
|
|
.await
|
|
.unwrap_or(None),
|
|
_ => None,
|
|
}
|
|
};
|
|
if let Some(url) = &per_topic_url {
|
|
// Best-effort readiness gate — a freshly-spawned team container may
|
|
// still be starting when the worker claims the run. Cap the wait so
|
|
// a broken image can't hang the worker.
|
|
if let Err(e) =
|
|
crate::research_container::wait_ready(url, std::time::Duration::from_secs(30)).await
|
|
{
|
|
eprintln!("topology_worker: research team {url} readiness: {e} — proceeding anyway");
|
|
}
|
|
}
|
|
let leaf_result = match &per_topic_url {
|
|
Some(url) => ZeroClawDriveExecutor::from_env_for_gateway(url.clone()),
|
|
None => ZeroClawDriveExecutor::from_env(),
|
|
};
|
|
let leaf = match leaf_result {
|
|
Ok(e) => e,
|
|
Err(e) => {
|
|
let _ = cm_db::repo::topology_runs::fail(pool, id, &e).await;
|
|
return;
|
|
}
|
|
};
|
|
|
|
// Select the executor by deploy tier: `team` drives claws directly; the
|
|
// upper tiers drive the recursive sub-topology executor (which runs each
|
|
// child tier's graph, all the way down to the same leaf claw executor).
|
|
let result = match job.tier.as_str() {
|
|
"company" | "org" => {
|
|
let tier = if job.tier == "org" {
|
|
Tier::Org
|
|
} else {
|
|
Tier::Company
|
|
};
|
|
let exec = SubTopologyExecutor::new(
|
|
pool.clone(),
|
|
WorkspaceId::from(job.workspace_id),
|
|
tier,
|
|
id,
|
|
Arc::new(leaf),
|
|
);
|
|
drive(pool, id, &graph, &job.task, progress, &exec).await
|
|
}
|
|
_ => drive(pool, id, &graph, &job.task, progress, &leaf).await,
|
|
};
|
|
|
|
match result {
|
|
Ok(record) => {
|
|
let value = serde_json::to_value(&record).unwrap_or(serde_json::Value::Null);
|
|
if let Err(e) = cm_db::repo::topology_runs::complete(pool, id, &value).await {
|
|
eprintln!("topology_worker: complete({id}) failed: {e}");
|
|
}
|
|
freeze_research_outcome(pool, id, &record.final_output).await;
|
|
advance_loop_after_completion(pool, id, &record.final_output).await;
|
|
continue_initial_burst(pool, id).await;
|
|
}
|
|
Err(e) => {
|
|
// Don't clobber a cancellation (or any already-terminal state) with `failed`.
|
|
let terminal = matches!(
|
|
cm_db::repo::topology_runs::current_status(pool, id).await,
|
|
Ok(Some(ref s)) if s == "cancelled" || s == "completed" || s == "failed"
|
|
);
|
|
if !terminal {
|
|
let _ = cm_db::repo::topology_runs::fail(pool, id, &format!("{e}")).await;
|
|
}
|
|
}
|
|
}
|
|
maybe_transition_research_topic(pool, id).await;
|
|
}
|
|
|
|
/// If this run belongs to a research topic, snapshot the orchestrator's
|
|
/// final synthesis as a versioned `research_outcomes` row. The frontend
|
|
/// canvas reads `latest_outcome` for anything past `standby` so reviewers
|
|
/// see the produced draft rather than the original prompt. Best-effort:
|
|
/// a failure here logs but doesn't fail the run.
|
|
async fn freeze_research_outcome(pool: &PgPool, run_id: Uuid, final_output: &str) {
|
|
let topic_id = match cm_db::repo::topology_runs::research_topic_id(pool, run_id).await {
|
|
Ok(Some(id)) => id,
|
|
Ok(None) => return,
|
|
Err(e) => {
|
|
eprintln!("topology_worker: research_topic_id({run_id}) failed: {e}");
|
|
return;
|
|
}
|
|
};
|
|
if final_output.trim().is_empty() {
|
|
return;
|
|
}
|
|
if let Err(e) =
|
|
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}");
|
|
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}");
|
|
}
|
|
}
|
|
|
|
/// Post-terminal hook for loop-bound runs. Parses `COMPLETED: INT-<NN>`
|
|
/// markers out of the run's final output and advances the loop's
|
|
/// `consumed_int_ids` + `current_int_index`. Only fires for runs that
|
|
/// belong to a loop AND that loop is bound to a source research topic
|
|
/// (the integrations flow). Standalone loops or unbound runs no-op.
|
|
///
|
|
/// The marker parser is deliberately forgiving — accepts INT-XX and
|
|
/// INT-XXX, optionally with surrounding backticks or dashes, so
|
|
/// coordinator prompts that emit slightly different formats still
|
|
/// advance the pointer.
|
|
async fn advance_loop_after_completion(pool: &PgPool, run_id: Uuid, final_output: &str) {
|
|
let loop_id = match cm_db::repo::topology_runs::loop_id_for_run(pool, run_id).await {
|
|
Ok(Some(id)) => id,
|
|
_ => return,
|
|
};
|
|
let ctx = match cm_db::repo::loops::source_research_context(pool, loop_id).await {
|
|
Ok(Some(c)) => c,
|
|
_ => return, // Not a research-bound loop; nothing to advance.
|
|
};
|
|
let mut completed = parse_completed_int_ids(final_output);
|
|
// Drop items already recorded so re-runs don't double-count.
|
|
let (_topic, already, _idx) = ctx;
|
|
completed.retain(|id| !already.contains(id));
|
|
if !completed.is_empty() {
|
|
if let Err(e) =
|
|
cm_db::repo::loops::advance_after_completion(pool, loop_id, &completed).await
|
|
{
|
|
eprintln!("topology_worker: loops::advance_after_completion({loop_id}) failed: {e}");
|
|
}
|
|
}
|
|
|
|
// Reorder rationale — coordinator emits "REORDER: <text>" when it
|
|
// works on an INT-XX out of order (usually because a prereq was
|
|
// unmet). Append each occurrence to the loop's reorder_events
|
|
// array so a mini-timeline UI can surface the history. Iteration
|
|
// number comes from topology_runs; -1 if the lookup fails (best-
|
|
// effort — we still record the event with a sentinel).
|
|
let iteration = cm_db::repo::topology_runs::iteration_for_run(pool, run_id)
|
|
.await
|
|
.unwrap_or(Some(-1))
|
|
.unwrap_or(-1);
|
|
for text in parse_reorder_rationale(final_output) {
|
|
if let Err(e) =
|
|
cm_db::repo::loops::append_reorder_event(pool, loop_id, run_id, iteration, &text).await
|
|
{
|
|
eprintln!("topology_worker: loops::append_reorder_event({loop_id}) failed: {e}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Extract "REORDER: <text>" rationales — one per line the coordinator
|
|
/// emits when it works out of order. Same permissive line matcher as
|
|
/// the completed-marker parser (list dashes, backticks, emphasis).
|
|
/// Returns the text after the colon, trimmed. Skips empty rationales.
|
|
fn parse_reorder_rationale(text: &str) -> Vec<String> {
|
|
let mut out = Vec::new();
|
|
for line in text.lines() {
|
|
let normalized = line.trim_start_matches(|c: char| {
|
|
c.is_whitespace() || c == '-' || c == '*' || c == '#' || c == '>'
|
|
});
|
|
let upper = normalized.to_ascii_uppercase();
|
|
if !upper.starts_with("REORDER:") {
|
|
continue;
|
|
}
|
|
// Preserve original case of the rationale text — only the
|
|
// marker matched case-insensitively.
|
|
let colon = normalized.find(':').map(|i| i + 1).unwrap_or(0);
|
|
let rationale = normalized[colon..].trim();
|
|
if !rationale.is_empty() {
|
|
out.push(rationale.to_string());
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Extract stable INT-XX ids from a completion line. Matches
|
|
/// `COMPLETED: INT-01`, `COMPLETED: INT-01, INT-02`, or `- COMPLETED: `INT-01``.
|
|
/// De-duplicates within a single output.
|
|
fn parse_completed_int_ids(text: &str) -> Vec<String> {
|
|
let mut out = Vec::new();
|
|
let mut seen = std::collections::HashSet::new();
|
|
for line in text.lines() {
|
|
// Case-insensitive, tolerates surrounding whitespace, list dashes,
|
|
// markdown emphasis, and backticks.
|
|
let normalized = line.trim_start_matches(|c: char| {
|
|
c.is_whitespace() || c == '-' || c == '*' || c == '#' || c == '>'
|
|
});
|
|
let upper = normalized.to_ascii_uppercase();
|
|
if !upper.starts_with("COMPLETED:") {
|
|
continue;
|
|
}
|
|
for token in upper
|
|
.trim_start_matches("COMPLETED:")
|
|
.split(|c: char| c == ',' || c == ';' || c.is_whitespace())
|
|
{
|
|
let stripped = token.trim_matches(|c: char| c == '`' || c == '*' || c == '.');
|
|
if stripped.starts_with("INT-")
|
|
&& stripped.len() >= 5
|
|
&& seen.insert(stripped.to_string())
|
|
{
|
|
out.push(stripped.to_string());
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Post-terminal hook: if this run belongs to a research topic and it was
|
|
/// the last sibling in flight, transition the topic `processing → reviewing`.
|
|
/// Best-effort — a DB hiccup here logs but doesn't fail the run.
|
|
async fn maybe_transition_research_topic(pool: &PgPool, id: Uuid) {
|
|
match cm_db::repo::topology_runs::notify_run_completed(pool, id).await {
|
|
Ok(true) => {
|
|
// Left intentionally quiet on success; the UI polls the topic
|
|
// status. Future: emit a run_event so live viewers see it flip.
|
|
}
|
|
Ok(false) => {}
|
|
Err(e) => eprintln!("topology_worker: notify_run_completed({id}) failed: {e}"),
|
|
}
|
|
maybe_teardown_ephemeral_team(pool, id).await;
|
|
}
|
|
|
|
/// Post-terminal hook: if this run's team is `ephemeral` and no siblings are
|
|
/// still in flight, deprovision every bound claw on the ZeroClaw daemon,
|
|
/// delete the claw rows, and delete the team row. Best-effort — a failure to
|
|
/// tear down leaves the team intact and logs; a future sweep can retry.
|
|
async fn maybe_teardown_ephemeral_team(pool: &PgPool, id: Uuid) {
|
|
let teardown = match cm_db::repo::topology_runs::check_ephemeral_teardown(pool, id).await {
|
|
Ok(Some(t)) => t,
|
|
Ok(None) => return,
|
|
Err(e) => {
|
|
eprintln!("topology_worker: check_ephemeral_teardown({id}) failed: {e}");
|
|
return;
|
|
}
|
|
};
|
|
// Deprovision each claw on the daemon before deleting rows — if the daemon
|
|
// side fails we still delete our rows (the daemon can be swept for orphans
|
|
// by the fleet-reconcile timer). This is the trade cm-api owns everywhere:
|
|
// Postgres is authoritative, the daemon config is a cache.
|
|
if let Some(prov) = crate::runtime_provision::RuntimeProvisioner::from_env() {
|
|
for cid in &teardown.claw_ids {
|
|
if let Err(e) = prov.deprovision_claw(*cid).await {
|
|
eprintln!("topology_worker: deprovision_claw({cid}) failed: {e}");
|
|
}
|
|
}
|
|
}
|
|
for cid in &teardown.claw_ids {
|
|
if let Err(e) = cm_db::repo::agents::hard_purge(pool, cm_domain::AgentId::from(*cid)).await
|
|
{
|
|
eprintln!("topology_worker: agents::hard_purge({cid}) failed: {e}");
|
|
}
|
|
}
|
|
if let Err(e) = cm_db::repo::teams::delete_team(
|
|
pool,
|
|
teardown.team_id,
|
|
cm_domain::WorkspaceId::from(teardown.workspace_id),
|
|
)
|
|
.await
|
|
{
|
|
eprintln!(
|
|
"topology_worker: teams::delete_team({}) failed: {e}",
|
|
teardown.team_id
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Drive a graph to completion with the durable per-step checkpoint +
|
|
/// cancellation closure, generic over the executor so the team (leaf) and
|
|
/// company/org (recursive) tiers share the same outer durability logic. The
|
|
/// checkpoint here is parent-node-level (coarse resume); the recursive executor
|
|
/// additionally touches `updated_at` from each inner leaf step to stay alive.
|
|
async fn drive<E: TurnExecutor>(
|
|
pool: &PgPool,
|
|
id: Uuid,
|
|
graph: &TopologyGraph,
|
|
task: &str,
|
|
progress: RunProgress,
|
|
executor: &E,
|
|
) -> Result<RunRecord, OrchestratorError> {
|
|
let pool_cb = pool.clone();
|
|
execute_resumable(graph, task, executor, progress, move |snap| {
|
|
let pool = pool_cb.clone();
|
|
async move {
|
|
// Best-effort checkpoint: a failed write just means we re-run the
|
|
// step on resume (idempotent — topology turns are pure reads here).
|
|
if let Ok(v) = serde_json::to_value(&snap) {
|
|
let _ =
|
|
cm_db::repo::topology_runs::checkpoint(&pool, id, &v, snap.completed as i64)
|
|
.await;
|
|
}
|
|
// Honor cancellation at the step boundary: stop before the next turn.
|
|
if matches!(
|
|
cm_db::repo::topology_runs::current_status(&pool, id).await,
|
|
Ok(Some(ref s)) if s == "cancelled"
|
|
) {
|
|
return Err(OrchestratorError::Executor("run cancelled".into()));
|
|
}
|
|
Ok(())
|
|
}
|
|
})
|
|
.await
|
|
}
|