feat(agents): make delete permanent, and expose the census
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:
co-authored by
Claude Opus 5
parent
ccbc387f4b
commit
b290025fc4
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user