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
+55
View File
@@ -1407,3 +1407,58 @@ pub async fn settings_full(
"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,
})))
}