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:
@@ -37,6 +37,8 @@ pub struct TeamMember {
|
||||
}
|
||||
|
||||
/// Insert a team (the topology graph). Members are added separately.
|
||||
/// Lifecycle defaults to `permanent`; use `insert_ephemeral_team` for the
|
||||
/// scheduled / triggered flows.
|
||||
pub async fn insert_team(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
@@ -44,15 +46,31 @@ pub async fn insert_team(
|
||||
name: &str,
|
||||
kind: &str,
|
||||
graph: &Value,
|
||||
) -> Result<(), DbError> {
|
||||
insert_team_with_lifecycle(pool, id, workspace_id, name, kind, graph, "permanent").await
|
||||
}
|
||||
|
||||
/// Insert a team with an explicit `lifecycle` (`permanent` | `ephemeral`).
|
||||
/// Ephemeral teams are torn down after the last in-flight run terminates —
|
||||
/// see cm-api::topology_worker::maybe_teardown_ephemeral_team.
|
||||
pub async fn insert_team_with_lifecycle(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
workspace_id: WorkspaceId,
|
||||
name: &str,
|
||||
kind: &str,
|
||||
graph: &Value,
|
||||
lifecycle: &str,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"INSERT INTO teams (id, workspace_id, name, kind, graph)
|
||||
VALUES ($1, $2, $3, $4, $5)",
|
||||
"INSERT INTO teams (id, workspace_id, name, kind, graph, lifecycle)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
id,
|
||||
workspace_id.as_uuid(),
|
||||
name,
|
||||
kind,
|
||||
graph,
|
||||
lifecycle,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
@@ -122,6 +122,78 @@ pub async fn enqueue_run_tier(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enqueue a durable run bound to a specific team. `team_id` is stored so the
|
||||
/// post-terminal ephemeral-teardown hook can find the team from a completed run.
|
||||
pub async fn enqueue_run_for_team(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
workspace_id: WorkspaceId,
|
||||
task: &str,
|
||||
graph: &Value,
|
||||
team_id: Uuid,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"INSERT INTO topology_runs
|
||||
(id, workspace_id, task, kind, status, graph, tier, team_id)
|
||||
VALUES ($1, $2, $3, 'run', 'queued', $4, 'team', $5)",
|
||||
id,
|
||||
workspace_id.as_uuid(),
|
||||
task,
|
||||
graph,
|
||||
team_id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Result of `check_ephemeral_teardown` when this run's terminal completion
|
||||
/// should tear down its team.
|
||||
pub struct EphemeralTeardown {
|
||||
pub team_id: Uuid,
|
||||
pub workspace_id: Uuid,
|
||||
pub claw_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
/// If this run belongs to an ephemeral team AND no siblings are still queued /
|
||||
/// running, return the team's teardown context (team id + workspace + bound
|
||||
/// claws). Callers then deprovision each claw and delete the team. Returns
|
||||
/// `None` if there's nothing to do (permanent team, still-running siblings, or
|
||||
/// no team back-ref at all).
|
||||
pub async fn check_ephemeral_teardown(
|
||||
pool: &PgPool,
|
||||
run_id: Uuid,
|
||||
) -> Result<Option<EphemeralTeardown>, DbError> {
|
||||
let row = sqlx::query!(
|
||||
"SELECT t.id AS team_id, t.workspace_id
|
||||
FROM topology_runs r
|
||||
JOIN teams t ON t.id = r.team_id
|
||||
WHERE r.id = $1
|
||||
AND t.lifecycle = 'ephemeral'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM topology_runs sib
|
||||
WHERE sib.team_id = t.id
|
||||
AND sib.id <> r.id
|
||||
AND sib.status IN ('queued', 'running')
|
||||
)",
|
||||
run_id,
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
let Some(row) = row else { return Ok(None) };
|
||||
let claws = sqlx::query!(
|
||||
"SELECT claw_id FROM team_members WHERE team_id = $1",
|
||||
row.team_id,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(Some(EphemeralTeardown {
|
||||
team_id: row.team_id,
|
||||
workspace_id: row.workspace_id,
|
||||
claw_ids: claws.into_iter().map(|r| r.claw_id).collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Atomically claim the oldest queued job, flipping it to `running`. Uses
|
||||
/// `FOR UPDATE SKIP LOCKED` so multiple workers never claim the same job.
|
||||
/// Returns `None` when the queue is empty.
|
||||
|
||||
Reference in New Issue
Block a user