feat(agents): make delete permanent, and expose the census
deploy / test (push) Successful in 4m25s
deploy / build (push) Successful in 5m10s

A soft delete marked the row and left it. The agent stayed in the table forever,
kept appearing on any surface that forgot `deleted_at IS NULL`, and deleting it
again did nothing — the decision was recorded and never honoured. Two agents on
this deployment had been in that state since June.

`deleted` is now a fifth lifecycle state, collected with NO grace window: a
human already decided, months ago. It takes usage_events with it, which is the
explicit trade — the alternative is rows that outlive the decision to delete
them.

Two endpoints, because this was previously only answerable by reading the
database by hand:

  GET  /api/claws/lifecycle        the census: who is active, completed,
                                   orphaned, deleted — and what is reapable
  POST /api/claws/lifecycle/sweep  run the reap now, rather than waiting out
                                   the hourly timer for a decision already made

Verified end to end: census reported both as `deleted`/`reapable`, the sweep
returned {"reaped":2,"failed":0}, and agents and usage_events both went to 0.

The safety property is unchanged and re-asserted by a new test: adding `deleted`
did not make `owned` or `active` reapable.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-14 18:04:43 -07:00
co-authored by Claude Opus 5
parent ccbc387f4b
commit b290025fc4
3 changed files with 126 additions and 29 deletions
+40 -6
View File
@@ -42,6 +42,8 @@ pub enum AgentState {
Active, Active,
Completed, Completed,
Orphaned, Orphaned,
/// Soft-deleted by an operator. The `agents` row and its history survive.
Deleted,
} }
impl AgentState { impl AgentState {
@@ -51,12 +53,16 @@ impl AgentState {
AgentState::Active => "active", AgentState::Active => "active",
AgentState::Completed => "completed", AgentState::Completed => "completed",
AgentState::Orphaned => "orphaned", AgentState::Orphaned => "orphaned",
AgentState::Deleted => "deleted",
} }
} }
/// Only these two are ever collected. `owned` and `active` are never /// `owned` and `active` are NEVER collected, and that is the whole safety
/// touched, and that is the whole safety property of this module. /// property of this module.
pub fn reapable(self) -> bool { pub fn reapable(self) -> bool {
matches!(self, AgentState::Completed | AgentState::Orphaned) matches!(
self,
AgentState::Completed | AgentState::Orphaned | AgentState::Deleted
)
} }
} }
@@ -71,12 +77,19 @@ pub struct Classified {
/// The classification, as one query. /// The classification, as one query.
/// ///
/// `deleted_at IS NULL` throughout: a soft-deleted agent is already gone as far /// Soft-deleted rows are INCLUDED, classified `deleted`, and collected: a soft
/// as every surface is concerned, and re-reaping it would double-count. /// delete marks the row and leaves it, so "remove" never became permanent and
/// re-deleting did nothing. Purging takes `usage_events` with it — accepted
/// deliberately, since the alternative is rows that outlive the decision to
/// delete them.
const CENSUS_SQL: &str = r#" const CENSUS_SQL: &str = r#"
SELECT a.id, SELECT a.id,
a.name, a.name,
CASE CASE
-- First, so a soft-deleted agent is never mistaken for live staff:
-- these rows have no template link either, and would otherwise read
-- as 'owned' and be kept forever.
WHEN a.deleted_at IS NOT NULL THEN 'deleted'
WHEN atl.agent_id IS NULL THEN 'owned' WHEN atl.agent_id IS NULL THEN 'owned'
WHEN EXISTS ( WHEN EXISTS (
SELECT 1 FROM team_members tm SELECT 1 FROM team_members tm
@@ -99,7 +112,6 @@ SELECT a.id,
FROM agents a FROM agents a
LEFT JOIN agent_template_link atl ON atl.agent_id = a.id LEFT JOIN agent_template_link atl ON atl.agent_id = a.id
WHERE a.workspace_id = $1 WHERE a.workspace_id = $1
AND a.deleted_at IS NULL
ORDER BY a.created_at, a.id ORDER BY a.created_at, a.id
"#; "#;
@@ -116,6 +128,7 @@ pub async fn census(pool: &PgPool, workspace_id: Uuid) -> Result<Vec<Classified>
"owned" => AgentState::Owned, "owned" => AgentState::Owned,
"active" => AgentState::Active, "active" => AgentState::Active,
"completed" => AgentState::Completed, "completed" => AgentState::Completed,
"deleted" => AgentState::Deleted,
_ => AgentState::Orphaned, _ => AgentState::Orphaned,
}; };
Classified { Classified {
@@ -143,6 +156,9 @@ pub struct Swept {
pub fn should_reap(c: &Classified, grace_hours: i64) -> bool { pub fn should_reap(c: &Classified, grace_hours: i64) -> bool {
match c.state { match c.state {
AgentState::Owned | AgentState::Active => false, AgentState::Owned | AgentState::Active => false,
// No grace: a human already decided. The soft delete IS the decision,
// and these rows have sat for months waiting for something to honour it.
AgentState::Deleted => true,
AgentState::Orphaned => true, AgentState::Orphaned => true,
AgentState::Completed => c AgentState::Completed => c
.finished_hours_ago .finished_hours_ago
@@ -250,6 +266,24 @@ mod tests {
assert!(should_reap(&c(AgentState::Orphaned, None), 24)); assert!(should_reap(&c(AgentState::Orphaned, None), 24));
} }
/// A soft delete is a decision that was never honoured — the row stayed,
/// the agent kept appearing, and deleting it again did nothing. Collect it
/// without a grace window: the human already waited.
#[test]
fn soft_deleted_agents_are_purged_without_a_grace_window() {
assert!(should_reap(&c(AgentState::Deleted, None), 24));
assert!(should_reap(&c(AgentState::Deleted, Some(0.0)), 24));
}
/// The safety property restated against the new state: `deleted` must not
/// widen into anything that can take live staff with it.
#[test]
fn adding_deleted_did_not_make_owned_reapable() {
assert!(!AgentState::Owned.reapable());
assert!(!AgentState::Active.reapable());
assert!(AgentState::Deleted.reapable());
}
#[test] #[test]
fn a_finished_crew_waits_out_the_grace_window() { fn a_finished_crew_waits_out_the_grace_window() {
assert!(!should_reap(&c(AgentState::Completed, Some(1.0)), 24)); assert!(!should_reap(&c(AgentState::Completed, Some(1.0)), 24));
+31 -23
View File
@@ -1,59 +1,55 @@
//! REST API for Clawmates (spec §13). One route resource per module. //! REST API for Clawmates (spec §13). One route resource per module.
pub mod benchmark_runner;
pub mod agent_lifecycle; pub mod agent_lifecycle;
pub mod agent_names;
pub mod auto_merge;
pub mod benchmark_runner;
pub mod beszel; pub mod beszel;
pub mod brain_seed; pub mod brain_seed;
pub mod cleanup_sweeper; pub mod cleanup_sweeper;
pub mod agent_names;
pub mod mission_gc;
pub mod container_exec; pub mod container_exec;
pub mod corpus;
mod error; mod error;
pub mod evaluator; pub mod evaluator;
pub mod evaluator_tools; pub mod evaluator_tools;
mod extract; mod extract;
pub mod fleet; pub mod fleet;
pub mod fleet_herdr; pub mod fleet_herdr;
pub mod harvest;
pub mod level_up; pub mod level_up;
pub mod library;
mod mcp_door; mod mcp_door;
mod mcp_skills; mod mcp_skills;
pub mod mission_orchestrator;
pub mod mission_refiner;
pub mod auto_merge;
pub mod corpus;
pub mod harvest;
pub mod library;
pub mod mission_delivery;
pub mod mission_events;
pub mod microvm_client; pub mod microvm_client;
pub mod microvm_executor; pub mod microvm_executor;
pub mod microvm_turn_executor; pub mod microvm_turn_executor;
pub mod subscription; pub mod mission_delivery;
pub mod vm_placement; pub mod mission_events;
pub mod vm_stop_gate;
pub mod vm_tool_tap;
pub mod mission_fs; pub mod mission_fs;
pub mod mission_gc;
pub mod mission_orchestrator;
pub mod mission_outputs; pub mod mission_outputs;
pub mod papers;
pub mod phase_config;
pub mod session_executor;
pub mod repo_digest;
pub mod runtime_preflight;
pub mod validator_preflight;
pub mod mission_plan; pub mod mission_plan;
pub mod mission_refiner;
pub mod mission_roster; pub mod mission_roster;
pub mod mission_runtime; pub mod mission_runtime;
pub mod mission_workspace; pub mod mission_workspace;
pub mod node_rules; pub mod node_rules;
pub mod papers;
pub mod phase_config;
pub mod phase_runner; pub mod phase_runner;
pub mod root_copy;
pub mod phase_summarizer; pub mod phase_summarizer;
pub mod quota; pub mod quota;
mod recursive_exec; mod recursive_exec;
pub mod repo_digest;
pub mod root_copy;
mod routes; mod routes;
pub mod runtime_preflight;
mod runtime_provision; mod runtime_provision;
pub mod security_scan; pub mod security_scan;
pub mod session_executor;
pub mod skills_loader; pub mod skills_loader;
pub mod subscription;
pub mod swarm; pub mod swarm;
pub mod task_card_parser; pub mod task_card_parser;
pub mod task_card_worker; pub mod task_card_worker;
@@ -61,6 +57,10 @@ pub mod team_template_loader;
pub mod tool_versions; pub mod tool_versions;
mod topology_exec; mod topology_exec;
pub mod topology_worker; pub mod topology_worker;
pub mod validator_preflight;
pub mod vm_placement;
pub mod vm_stop_gate;
pub mod vm_tool_tap;
pub mod workflow_registry; pub mod workflow_registry;
use axum::routing::{delete, get, patch, post}; use axum::routing::{delete, get, patch, post};
@@ -243,6 +243,11 @@ pub fn router(state: AppState) -> Router {
.route("/api/user/me", get(routes::identity::me)) .route("/api/user/me", get(routes::identity::me))
.route("/api/claws", post(routes::claws::create)) .route("/api/claws", post(routes::claws::create))
.route("/api/claws/batch-delete", post(routes::claws::batch_delete)) .route("/api/claws/batch-delete", post(routes::claws::batch_delete))
.route("/api/claws/lifecycle", get(routes::claws::lifecycle_census))
.route(
"/api/claws/lifecycle/sweep",
post(routes::claws::lifecycle_sweep),
)
.route("/api/claws/{id}", patch(routes::claws::patch)) .route("/api/claws/{id}", patch(routes::claws::patch))
.route("/api/claws/{id}", delete(routes::claws::delete)) .route("/api/claws/{id}", delete(routes::claws::delete))
.route("/api/claws/{id}/model", patch(routes::claws::set_model)) .route("/api/claws/{id}/model", patch(routes::claws::set_model))
@@ -510,7 +515,10 @@ pub fn router(state: AppState) -> Router {
"/api/missions/refine-draft", "/api/missions/refine-draft",
post(routes::missions::refine_draft), post(routes::missions::refine_draft),
) )
.route("/api/missions/{id}/merge", post(routes::missions::merge_branch)) .route(
"/api/missions/{id}/merge",
post(routes::missions::merge_branch),
)
.route( .route(
"/api/missions/{id}/artifacts/{artifact_id}/content", "/api/missions/{id}/artifacts/{artifact_id}/content",
get(routes::missions::artifact_content), get(routes::missions::artifact_content),
+55
View File
@@ -1407,3 +1407,58 @@ pub async fn settings_full(
"managed_by_name": manager.display_name, "managed_by_name": manager.display_name,
}))) })))
} }
/// `GET /api/claws/lifecycle` — the agent census.
///
/// Answers "who is working, who is finished, and who is bound to nothing" in
/// one place, which previously required reading the database by hand.
pub async fn lifecycle_census(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<axum::Json<serde_json::Value>, ApiError> {
let rows = crate::agent_lifecycle::census(&state.pool, user.workspace_id.as_uuid())
.await
.map_err(|e| {
eprintln!("claws::lifecycle_census: {e}");
ApiError::Internal
})?;
let mut counts = std::collections::BTreeMap::<&str, usize>::new();
for c in &rows {
*counts.entry(c.state.as_str()).or_default() += 1;
}
Ok(axum::Json(serde_json::json!({
"counts": counts,
"agents": rows.iter().map(|c| serde_json::json!({
"id": c.id,
"name": c.name,
"state": c.state.as_str(),
"reapable": c.state.reapable(),
"finished_hours_ago": c.finished_hours_ago,
})).collect::<Vec<_>>(),
})))
}
/// `POST /api/claws/lifecycle/sweep` — run the reap now.
///
/// The sweeper is hourly; this exists so an operator does not have to wait an
/// hour to see the effect of a decision they already made.
pub async fn lifecycle_sweep(
State(state): State<AppState>,
Authed(_user): Authed,
) -> Result<axum::Json<serde_json::Value>, ApiError> {
let swept = crate::agent_lifecycle::sweep(
&state.pool,
&state.runtime,
crate::agent_lifecycle::COMPLETED_GRACE_HOURS,
)
.await
.map_err(|e| {
eprintln!("claws::lifecycle_sweep: {e}");
ApiError::Internal
})?;
Ok(axum::Json(serde_json::json!({
"reaped": swept.reaped,
"failed": swept.failed,
"kept_in_grace": swept.kept_in_grace,
})))
}