teams: ephemeral lifecycle for Scheduled + Triggered planner modes
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 38s
ci / rust (push) Successful in 3m6s
ci / e2e (push) Has been skipped
ci / publish (push) Successful in 2m23s

Migration 0033: adds teams.lifecycle ('permanent' | 'ephemeral') and a
topology_runs.team_id back-ref with a partial index for the sibling-in-
flight check.

cm-db repo:
- teams::insert_team_with_lifecycle (insert_team keeps the permanent default)
- topology_runs::enqueue_run_for_team (populates team_id)
- topology_runs::check_ephemeral_teardown — atomic SELECT that only
  returns Some when the team is ephemeral AND no siblings are still
  queued/running; carries the workspace + bound claw ids for cleanup.

cm-api:
- topology_worker post-terminal hook maybe_teardown_ephemeral_team
  runs deprovision_claw on each bound claw (best-effort; failures log
  but don't block Postgres deletion), then hard_purge each agent row,
  then delete_team.
- routes::teams::build_team_with_lifecycle (build_team keeps default);
  run_team enqueues with team_id.
- planner ScaffoldRequest gains mode; lifecycle_for(mode) sets the team
  to ephemeral for scheduled + triggered, permanent otherwise.

Frontend MasterPlannerModal passes mode in the scaffold payload so the
backend can derive lifecycle without duplicating the mode taxonomy.

Tests: 3 new (returns claws when no siblings, holds when siblings queued,
ignores permanent teams). 10/10 topology_jobs green; workspace clippy
--tests clean.
This commit is contained in:
Omar Sobh
2026-07-07 04:30:07 -07:00
parent b0acdfd987
commit 806ba869e5
12 changed files with 412 additions and 14 deletions
+15 -2
View File
@@ -11,7 +11,7 @@ use serde_json::{json, Value};
use std::convert::Infallible;
use crate::routes::claws::{apply_reference_to_claw, enhance_and_publish, extract_json};
use crate::routes::teams::{build_team, TeamMemberInput};
use crate::routes::teams::TeamMemberInput;
use crate::{AppState, Authed};
fn sse(v: Value) -> Result<Event, Infallible> {
@@ -173,11 +173,23 @@ pub struct ScaffoldRequest {
#[serde(default)]
pub schedule: Option<ScaffoldSchedule>,
pub members: Vec<ScaffoldMember>,
/// Original planner mode — decides the team's lifecycle. `scheduled` and
/// `triggered` produce `ephemeral` teams (torn down by the topology_worker
/// after the last run terminates). Anything else is `permanent`.
#[serde(default)]
pub mode: String,
}
fn default_kind() -> String {
"hub_spoke".to_string()
}
fn lifecycle_for(mode: &str) -> &'static str {
match mode {
"scheduled" | "triggered" => "ephemeral",
_ => "permanent",
}
}
/// `POST /api/planner/scaffold` — build the approved team (SSE progress): create
/// agents + topology, refine+attach a brain per agent, set up the nightly loop.
pub async fn planner_scaffold(
@@ -200,7 +212,8 @@ pub async fn planner_scaffold(
system_prompt: m.system_prompt.clone(),
accent: String::new(),
}).collect();
let (team_id, claw_ids) = match build_team(&state, user.workspace_id, user.user_id, &body.team_name, &body.topology_kind, &members).await {
let lifecycle = lifecycle_for(&body.mode);
let (team_id, claw_ids) = match crate::routes::teams::build_team_with_lifecycle(&state, user.workspace_id, user.user_id, &body.team_name, &body.topology_kind, &members, lifecycle).await {
Ok(r) => r,
Err(_) => { yield sse(json!({"stage":"error","pct":100,"label":"Team creation failed"})); return; }
};
+28 -2
View File
@@ -56,6 +56,30 @@ pub(crate) async fn build_team(
name: &str,
kind_str: &str,
members: &[TeamMemberInput],
) -> Result<(Uuid, Vec<Uuid>), ApiError> {
build_team_with_lifecycle(
state,
workspace_id,
user_id,
name,
kind_str,
members,
"permanent",
)
.await
}
/// Same as `build_team` but with an explicit `lifecycle` (`permanent` |
/// `ephemeral`). Ephemeral teams are torn down by the topology_worker after
/// their last run terminates — used by the Scheduled + Triggered planner modes.
pub(crate) async fn build_team_with_lifecycle(
state: &AppState,
workspace_id: cm_domain::WorkspaceId,
user_id: cm_domain::UserId,
name: &str,
kind_str: &str,
members: &[TeamMemberInput],
lifecycle: &str,
) -> Result<(Uuid, Vec<Uuid>), ApiError> {
if members.is_empty() {
return Err(ApiError::BadRequest);
@@ -101,13 +125,14 @@ pub(crate) async fn build_team(
let team_id = Uuid::now_v7();
let graph_json = serde_json::to_value(&graph).map_err(|_| ApiError::Internal)?;
cm_db::repo::teams::insert_team(
cm_db::repo::teams::insert_team_with_lifecycle(
&state.pool,
team_id,
workspace_id,
name,
kind.as_str(),
&graph_json,
lifecycle,
)
.await?;
for (i, node) in graph.nodes.iter().enumerate() {
@@ -373,12 +398,13 @@ pub async fn run_team(
let team = cm_db::repo::teams::get_team(&state.pool, id, user.workspace_id).await?;
crate::quota::enforce_new_run(&state, user.workspace_id).await?;
let run_id = Uuid::now_v7();
cm_db::repo::topology_runs::enqueue_run(
cm_db::repo::topology_runs::enqueue_run_for_team(
&state.pool,
run_id,
user.workspace_id,
&body.task,
&team.graph,
id,
)
.await?;
Ok((