teams: ephemeral lifecycle for Scheduled + Triggered planner modes
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:
@@ -2,8 +2,10 @@
|
||||
//! (CAS) → checkpoint → complete, plus the stale-run resume sweep. This is the
|
||||
//! foundation that lets long-horizon topology runs survive worker restarts.
|
||||
|
||||
use cm_db::repo::{loops, research_topics, topology_runs, users, workspaces};
|
||||
use cm_domain::{Role, User, UserId, Workspace, WorkspaceId};
|
||||
use cm_db::repo::{loops, research_topics, teams, topology_runs, users, workspaces};
|
||||
use cm_domain::{
|
||||
AccessPolicy, Agent, AgentId, AgentStatus, Role, User, UserId, Workspace, WorkspaceId,
|
||||
};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -373,3 +375,123 @@ async fn notify_run_completed_ignores_runs_with_no_research_topic() {
|
||||
"no research_topic_id → nothing to transition"
|
||||
);
|
||||
}
|
||||
|
||||
async fn seed_team(
|
||||
pool: &sqlx::PgPool,
|
||||
ws: WorkspaceId,
|
||||
user_id: UserId,
|
||||
lifecycle: &str,
|
||||
claw_count: usize,
|
||||
) -> (Uuid, Vec<Uuid>) {
|
||||
let team_id = Uuid::now_v7();
|
||||
let graph = json!({"kind": "hub_spoke", "nodes": [], "edges": []});
|
||||
teams::insert_team_with_lifecycle(pool, team_id, ws, "T", "hub_spoke", &graph, lifecycle)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut claws = Vec::with_capacity(claw_count);
|
||||
for i in 0..claw_count {
|
||||
let agent = Agent {
|
||||
id: AgentId::new(),
|
||||
workspace_id: ws,
|
||||
name: format!("claw{i}"),
|
||||
job_title: "worker".into(),
|
||||
system_prompt: String::new(),
|
||||
avatar: String::new(),
|
||||
accent: String::new(),
|
||||
wallpaper: String::new(),
|
||||
managed_by: user_id,
|
||||
status: AgentStatus::Online,
|
||||
};
|
||||
cm_db::repo::agents::insert(pool, &agent, &AccessPolicy::default())
|
||||
.await
|
||||
.unwrap();
|
||||
teams::add_member(
|
||||
pool,
|
||||
team_id,
|
||||
&format!("n{i}"),
|
||||
agent.id.as_uuid(),
|
||||
"worker",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
claws.push(agent.id.as_uuid());
|
||||
}
|
||||
(team_id, claws)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_ephemeral_teardown_returns_claws_when_no_siblings_left() {
|
||||
let pool = cm_testkit::test_pool().await;
|
||||
let ws = seed_workspace(&pool).await;
|
||||
let user_id = seed_user(&pool, ws, "[email protected]").await;
|
||||
let (team_id, claws) = seed_team(&pool, ws, user_id, "ephemeral", 3).await;
|
||||
|
||||
let graph = json!({"kind": "hub_spoke", "nodes": [], "edges": []});
|
||||
let run_id = Uuid::now_v7();
|
||||
topology_runs::enqueue_run_for_team(&pool, run_id, ws, "task", &graph, team_id)
|
||||
.await
|
||||
.unwrap();
|
||||
topology_runs::complete(&pool, run_id, &json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let teardown = topology_runs::check_ephemeral_teardown(&pool, run_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("ephemeral team, no siblings — should return teardown");
|
||||
assert_eq!(teardown.team_id, team_id);
|
||||
assert_eq!(teardown.workspace_id, ws.as_uuid());
|
||||
let mut got = teardown.claw_ids.clone();
|
||||
let mut want = claws.clone();
|
||||
got.sort();
|
||||
want.sort();
|
||||
assert_eq!(got, want, "returns every bound claw");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_ephemeral_teardown_holds_when_siblings_in_flight() {
|
||||
let pool = cm_testkit::test_pool().await;
|
||||
let ws = seed_workspace(&pool).await;
|
||||
let user_id = seed_user(&pool, ws, "[email protected]").await;
|
||||
let (team_id, _) = seed_team(&pool, ws, user_id, "ephemeral", 2).await;
|
||||
|
||||
let graph = json!({"kind": "hub_spoke", "nodes": [], "edges": []});
|
||||
let done = Uuid::now_v7();
|
||||
let still_queued = Uuid::now_v7();
|
||||
topology_runs::enqueue_run_for_team(&pool, done, ws, "first", &graph, team_id)
|
||||
.await
|
||||
.unwrap();
|
||||
topology_runs::enqueue_run_for_team(&pool, still_queued, ws, "second", &graph, team_id)
|
||||
.await
|
||||
.unwrap();
|
||||
topology_runs::complete(&pool, done, &json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let teardown = topology_runs::check_ephemeral_teardown(&pool, done)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(teardown.is_none(), "sibling still queued → hold teardown");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_ephemeral_teardown_ignores_permanent_teams() {
|
||||
let pool = cm_testkit::test_pool().await;
|
||||
let ws = seed_workspace(&pool).await;
|
||||
let user_id = seed_user(&pool, ws, "[email protected]").await;
|
||||
let (team_id, _) = seed_team(&pool, ws, user_id, "permanent", 1).await;
|
||||
|
||||
let graph = json!({"kind": "hub_spoke", "nodes": [], "edges": []});
|
||||
let run_id = Uuid::now_v7();
|
||||
topology_runs::enqueue_run_for_team(&pool, run_id, ws, "task", &graph, team_id)
|
||||
.await
|
||||
.unwrap();
|
||||
topology_runs::complete(&pool, run_id, &json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let teardown = topology_runs::check_ephemeral_teardown(&pool, run_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(teardown.is_none(), "permanent teams are never torn down");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user