Durable topology jobs (3+4/4): background worker + async run API
ci / gates (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / sandbox-k8s (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / e2e (push) Has been cancelled

POST /api/topologies/run now ENQUEUES a durable job and returns 202
{run_id, status:queued} instead of executing inside the HTTP request — the
prerequisite for long-horizon runs (no client/proxy/LB timeout, survives
restarts).

topology_worker: a spawned loop that requeues stale running jobs, claims the
next queued one (CAS via FOR UPDATE SKIP LOCKED), drives it through
execute_resumable, and checkpoints RunProgress after every step; on crash the
stale sweep requeues it and the next claim resumes from the last checkpoint.
Wired into server startup beside the scheduler + resume sweeper.

GET /api/topology-runs/{id} now reports lifecycle status/kind/error/checkpoint
+ the result blob (kept the `comparison` field name for back-compat with the
compare UI; null until completed). list_runs includes status + kind.

Tests: durable lifecycle (enqueue→claim→checkpoint→complete) + stale-requeue
resume, both green; p0 endpoints (compare path) unchanged. 13 + 2 tests pass,
clippy clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-17 19:12:54 -07:00
co-authored by Claude Opus 4.8
parent fc9bfc3e61
commit 272669e1f5
5 changed files with 244 additions and 30 deletions
+52 -30
View File
@@ -4,8 +4,9 @@
//! provider-backed endpoint.
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use cm_orchestrator::{compare, execute, Comparison, JudgeScorer, ProviderExecutor, RunRecord};
use cm_orchestrator::{compare, Comparison, JudgeScorer, ProviderExecutor};
use cm_topology::{build, classify, heuristics, Classification, TopologyGraph, TopologyKind};
use serde::{Deserialize, Serialize};
use time::format_description::well_known::Rfc3339;
@@ -128,44 +129,50 @@ pub struct RunRequest {
pub graph: TopologyGraph,
}
/// `POST /api/topologies/run` — execute one topology by driving real ZeroClaw
/// role-agents (in a container) for each turn, returning the run journal. The
/// orchestrator owns the graph; agents are tool-free behind the Clawmates MCP
/// door, so §15 holds by construction. Result is persisted best-effort to the
/// existing `topology_runs` table.
/// Response to an accepted async run job.
#[derive(Serialize)]
pub struct RunAccepted {
pub run_id: String,
pub status: String,
}
/// `POST /api/topologies/run` — ENQUEUE a durable single-topology run and return
/// its id immediately (202). The background worker (`topology_worker`) claims it,
/// drives the ZeroClaw role-agents turn-by-turn (orchestrator owns the graph;
/// agents are tool-free behind the §15 MCP door), and checkpoints per step so a
/// long-horizon run survives restarts. Poll `GET /api/topology-runs/{id}` for
/// status and the final result. This async model is what makes minutes-to-hours
/// runs viable — the work no longer lives inside the HTTP request.
pub async fn run_topology(
State(state): State<AppState>,
Authed(user): Authed,
Json(req): Json<RunRequest>,
) -> Result<Json<RunRecord>, ApiError> {
let executor =
crate::topology_exec::ZeroClawDriveExecutor::from_env().map_err(|_| ApiError::Internal)?;
let record = execute(&req.graph, &req.task, &executor)
.await
.map_err(|_| ApiError::Internal)?;
if let Ok(value) = serde_json::to_value(&record) {
let _ = cm_db::repo::topology_runs::insert(
&state.pool,
Uuid::now_v7(),
user.workspace_id,
&req.task,
&value,
)
.await;
}
Ok(Json(record))
) -> Result<(StatusCode, Json<RunAccepted>), ApiError> {
let graph = serde_json::to_value(&req.graph).map_err(|_| ApiError::Internal)?;
let id = Uuid::now_v7();
cm_db::repo::topology_runs::enqueue_run(&state.pool, id, user.workspace_id, &req.task, &graph)
.await?;
Ok((
StatusCode::ACCEPTED,
Json(RunAccepted {
run_id: id.to_string(),
status: "queued".into(),
}),
))
}
/// A saved comparison run, summarized.
/// A saved/queued run, summarized (now includes lifecycle status + kind).
#[derive(Serialize)]
pub struct RunSummary {
pub id: String,
pub task: String,
pub status: String,
pub kind: String,
pub created_at: String,
}
/// `GET /api/topology-runs` — recent saved comparison runs for the workspace.
/// `GET /api/topology-runs` — recent runs for the workspace (compares + durable
/// run jobs), newest first.
pub async fn list_runs(
State(state): State<AppState>,
Authed(user): Authed,
@@ -176,32 +183,47 @@ pub async fn list_runs(
.map(|r| RunSummary {
id: r.id.to_string(),
task: r.task,
status: r.status,
kind: r.kind,
created_at: r.created_at.format(&Rfc3339).unwrap_or_default(),
})
.collect();
Ok(Json(out))
}
/// A full saved comparison run.
/// A single run with lifecycle status + progress. `comparison` is the result
/// blob (a `Comparison` for compares, a `RunRecord` for run jobs) and is `null`
/// until the job completes; pollers watch `status` and read `comparison` when it
/// flips to `completed`. `checkpoint` exposes mid-run progress for live views.
#[derive(Serialize)]
pub struct RunDetail {
pub id: String,
pub task: String,
pub kind: String,
pub status: String,
pub error: Option<String>,
pub created_at: String,
pub updated_at: String,
pub comparison: serde_json::Value,
pub checkpoint: Option<serde_json::Value>,
}
/// `GET /api/topology-runs/{id}` — a single saved comparison run.
/// `GET /api/topology-runs/{id}` — a single run with status + result.
pub async fn get_run(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<RunDetail>, ApiError> {
let run = cm_db::repo::topology_runs::get(&state.pool, id, user.workspace_id).await?;
let run = cm_db::repo::topology_runs::status(&state.pool, id, user.workspace_id).await?;
Ok(Json(RunDetail {
id: run.id.to_string(),
task: run.task,
kind: run.kind,
status: run.status,
error: run.error,
created_at: run.created_at.format(&Rfc3339).unwrap_or_default(),
comparison: run.comparison,
updated_at: run.updated_at.format(&Rfc3339).unwrap_or_default(),
comparison: run.result.unwrap_or(serde_json::Value::Null),
checkpoint: run.checkpoint,
}))
}