fix(agents): a soft-deleted agent could never be purged

Clearing the fleet's four leftover agents returned 404 on every one. They had
been soft-deleted back in June — correctly invisible in the UI ever since — and
`agents::get` filters `deleted_at IS NULL`, so `workspace_agent` could not find
them. Every route uses it, including `batch-delete`, the one that exists to
HARD-purge. So a soft-deleted agent was unreachable from the application
entirely and its row stayed forever.

`get_any` sees them, and only the purge path uses it: hiding soft-deleted rows
is right for every read, and wrong for the one operation whose whole job is
removing them. Written with `query_as` rather than the checked macro so it does
not force an offline-cache regeneration on every machine that builds this.

`fleet-reset.sh` now uses `batch-delete` for agents rather than
`DELETE /api/claws/{id}`. The latter is a SOFT delete, so pointing a reset
script at it would have quietly added to the pile it was meant to clear.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-09 18:15:56 -07:00
co-authored by Claude Opus 5
parent 16cfc29074
commit f27d2605eb
3 changed files with 131 additions and 8 deletions
+19 -1
View File
@@ -26,6 +26,19 @@ pub(crate) async fn workspace_agent(
Ok(agent) Ok(agent)
} }
/// As [`workspace_agent`], but sees soft-deleted agents too. PURGE ONLY.
pub(crate) async fn workspace_agent_any(
state: &AppState,
user: &cm_auth::AuthedUser,
agent_id: AgentId,
) -> Result<Agent, ApiError> {
let agent = cm_db::repo::agents::get_any(&state.pool, agent_id).await?;
if agent.workspace_id != user.workspace_id {
return Err(ApiError::NotFound);
}
Ok(agent)
}
/// `GET /api/claws/{id}/runtime-config` — the claw's model + §15 sandbox facts /// `GET /api/claws/{id}/runtime-config` — the claw's model + §15 sandbox facts
/// (for the claw card / anatomy view's model badge). /// (for the claw card / anatomy view's model badge).
#[derive(Serialize)] #[derive(Serialize)]
@@ -1289,7 +1302,12 @@ pub async fn batch_delete(
let mut done = 0usize; let mut done = 0usize;
for id in agent_ids { for id in agent_ids {
let base = 100 * done / total; let base = 100 * done / total;
let agent = match workspace_agent(&state, &user, id).await { // `workspace_agent_any`, not `workspace_agent`: a purge has to be
// able to see the rows it exists to remove. The soft-delete path
// correctly hides them from every read, which also hid them from
// the only route that could reap them — four soft-deleted agents
// from June were unreachable from the application entirely.
let agent = match workspace_agent_any(&state, &user, id).await {
Ok(a) => a, Ok(a) => a,
Err(_) => { yield sse(json!({"stage":"skip","pct":base,"label":format!("{id}: not found or no access")})); done += 1; continue; } Err(_) => { yield sse(json!({"stage":"skip","pct":base,"label":format!("{id}: not found or no access")})); done += 1; continue; }
}; };
+46
View File
@@ -64,6 +64,52 @@ pub async fn insert(pool: &PgPool, agent: &Agent, policy: &AccessPolicy) -> Resu
Ok(()) Ok(())
} }
/// Fetch an agent even if it has been soft-deleted.
///
/// For the PURGE path only. `get` hides soft-deleted rows, which is right for
/// every read — but it also meant the hard purge could not see the rows it
/// exists to remove: a soft-deleted agent was unreachable from every route and
/// accumulated forever with no way out of the application. Four of them dated
/// from June before anyone noticed, because the UI correctly never showed them.
pub async fn get_any(pool: &PgPool, agent_id: AgentId) -> Result<Agent, DbError> {
// `sqlx::query_as` rather than the checked macro: this is the same columns
// as `get` minus one predicate, and adding a second compile-time query for
// that would mean regenerating the offline cache on every machine that
// builds this.
let row: Option<(
uuid::Uuid,
uuid::Uuid,
String,
String,
String,
String,
String,
String,
uuid::Uuid,
String,
)> = sqlx::query_as(
"SELECT id, workspace_id, name, job_title, system_prompt, avatar,
accent, wallpaper, managed_by, status
FROM agents WHERE id = $1",
)
.bind(agent_id.as_uuid())
.fetch_optional(pool)
.await?;
let row = row.ok_or(DbError::NotFound)?;
Ok(Agent {
id: AgentId::from(row.0),
workspace_id: WorkspaceId::from(row.1),
name: row.2,
job_title: row.3,
system_prompt: row.4,
avatar: row.5,
accent: row.6,
wallpaper: row.7,
managed_by: UserId::from(row.8),
status: row.9.parse().expect("status CHECK constraint"),
})
}
pub async fn get(pool: &PgPool, agent_id: AgentId) -> Result<Agent, DbError> { pub async fn get(pool: &PgPool, agent_id: AgentId) -> Result<Agent, DbError> {
let row = sqlx::query!( let row = sqlx::query!(
"SELECT id, workspace_id, name, job_title, system_prompt, avatar, "SELECT id, workspace_id, name, job_title, system_prompt, avatar,
+66 -7
View File
@@ -19,6 +19,7 @@
# scripts/fleet-reset.sh # dry run — says what it would delete # scripts/fleet-reset.sh # dry run — says what it would delete
# scripts/fleet-reset.sh --yes # do it # scripts/fleet-reset.sh --yes # do it
# KEEP='verify: a model sizes' scripts/fleet-reset.sh --yes # keep matches # KEEP='verify: a model sizes' scripts/fleet-reset.sh --yes # keep matches
# KEEP_AGENTS=1 scripts/fleet-reset.sh --yes # missions only
set -uo pipefail set -uo pipefail
HOST="${CLAWMATES_HOST:-gw-04}" HOST="${CLAWMATES_HOST:-gw-04}"
@@ -69,18 +70,27 @@ filter=""
[ -n "$KEEP" ] && filter=" and title not like '%${KEEP}%'" [ -n "$KEEP" ] && filter=" and title not like '%${KEEP}%'"
ids=$(psql_ "select id from missions where true$filter order by created_at;" | tr -d '\r' | grep -v '^[[:space:]]*$') ids=$(psql_ "select id from missions where true$filter order by created_at;" | tr -d '\r' | grep -v '^[[:space:]]*$')
count=$(printf '%s\n' "$ids" | grep -c '[^[:space:]]') count=$(printf '%s\n' "$ids" | grep -c '[^[:space:]]')
[ "${count:-0}" -gt 0 ] || { echo "nothing to delete"; exit 0; } agents_n=$(psql_ "select count(*) from agents;" | tr -d '[:space:]')
[ "${KEEP_AGENTS:-}" = "1" ] && agents_n=0
# Missions AND agents. Checking only missions here meant a fleet with zero
# missions and four agents reported "nothing to delete" and exited — the reset
# silently doing half its job, which is the shape this script exists to catch.
if [ "${count:-0}" = "0" ] && [ "${agents_n:-0}" = "0" ]; then
echo "nothing to delete"
exit 0
fi
if [ "$GO" != "1" ]; then if [ "$GO" != "1" ]; then
printf '\nwould delete %s mission(s)%s. Re-run with --yes.\n' "$count" \ printf '\nwould delete %s mission(s)%s and %s agent(s)%s. Re-run with --yes.\n' "$count" \
"${KEEP:+ (keeping titles matching '$KEEP')}" "${KEEP:+ (keeping titles matching '$KEEP')}" "$agents_n" \
"${KEEP_AGENTS:+ (KEEP_AGENTS=1)}"
exit 0 exit 0
fi fi
token=$(mint_session) || die "could not mint a session for $OWNER" token=$(mint_session) || die "could not mint a session for $OWNER"
ok=0; bad=0 ok=0; bad=0
for id in $ids; do for id in ${ids:-}; do
code=$(api "$token" DELETE "/api/missions/$id") code=$(api "$token" DELETE "/api/missions/$id")
case "$code" in case "$code" in
2*) ok=$((ok + 1)) ;; 2*) ok=$((ok + 1)) ;;
@@ -114,11 +124,60 @@ done
# and cannot delete what the per-mission daemon wrote as root. # and cannot delete what the per-mission daemon wrote as root.
rootfiles=$(ssh "$HOST" "find $MISSIONS_ROOT ! -uid 65532 2>/dev/null | wc -l" | tr -d '[:space:]') rootfiles=$(ssh "$HOST" "find $MISSIONS_ROOT ! -uid 65532 2>/dev/null | wc -l" | tr -d '[:space:]')
# ── Agents ───────────────────────────────────────────────────────
#
# Deleted through the API for the same reason missions are: `purge_agent` is the
# only path that deprovisions the ZeroClaw runtime agent, reaps the
# sandbox/browser/terminal containers, unlinks the `.brain`/`.onion`, and THEN
# purges the FK graph. Three call sites used to inline their own variant of that
# sequence and two had silently drifted, leaving live containers behind — which
# is exactly what a SQL delete would do here, every time.
agents_deleted=0; agents_failed=0
if [ "${KEEP_AGENTS:-}" != "1" ]; then
# `batch-delete`, not `DELETE /api/claws/{id}`. The single-agent route is a
# SOFT delete, so it 404s on an agent already soft-deleted — which is every
# agent that needs reaping. batch-delete hard-purges: runtime agent,
# containers, `.brain`, then the FK graph.
agent_ids=$(psql_ "select id from agents order by created_at;" | tr -d '\r' | grep -v '^[[:space:]]*$')
before_agents=$(printf '%s\n' "$agent_ids" | grep -c '[^[:space:]]')
if [ "${before_agents:-0}" -gt 0 ]; then
body=$(printf '%s\n' "$agent_ids" | python3 -c 'import json,sys; print(json.dumps({"ids":[l.strip() for l in sys.stdin if l.strip()]}))')
printf '%s' "$body" | ssh "$HOST" "docker run --rm -i --network clawmates_core curlimages/curl:latest -s -N \
-X POST -H 'Authorization: Bearer $token' -H 'Content-Type: application/json' -d @- \
http://clawmates_server_1:8080/api/claws/batch-delete" >/dev/null 2>&1
sleep 3
remaining=$(psql_ "select count(*) from agents;" | tr -d '[:space:]')
agents_deleted=$((before_agents - ${remaining:-0}))
agents_failed=${remaining:-0}
fi
[ "$agents_deleted$agents_failed" = "00" ] || \
printf 'agents: deleted %s, failed %s\n' "$agents_deleted" "$agents_failed"
fi
# Resources an agent owns beyond its row. A count of each, because "the row is
# gone" has never meant "the container is gone" in this codebase.
agent_rows=$(psql_ "select count(*) from agents;" | tr -d '[:space:]')
stray_containers=0
stray_volumes=0
for h in $HOST ${CLAWMATES_NODES:-osobh@tank architect morpheus}; do
c=$(ssh "$h" 'docker ps -a --format "{{.Names}}" 2>/dev/null | grep -cE "^(tc-agent|cm-sandbox|cm-browser)"' 2>/dev/null | tr -d '[:space:]')
v=$(ssh "$h" 'docker volume ls -q 2>/dev/null | grep -c "^clawmates_agent_"' 2>/dev/null | tr -d '[:space:]')
stray_containers=$((stray_containers + ${c:-0}))
stray_volumes=$((stray_volumes + ${v:-0}))
done
echo echo
if [ "$bad" = "0" ] && [ "$left" = "0" ]; then if [ "$bad" = "0" ] && [ "$left" = "0" ] && [ "$agents_failed" = "0" ]; then
printf 'clean: %s mission(s) deleted, no orphaned directories\n' "$ok" printf 'clean: %s mission(s) and %s agent(s) deleted, no orphaned directories\n' \
"$ok" "$agents_deleted"
[ "${rootfiles:-0}" = "0" ] || printf 'note: %s root-owned file(s) remain under %s\n' "$rootfiles" "$MISSIONS_ROOT" [ "${rootfiles:-0}" = "0" ] || printf 'note: %s root-owned file(s) remain under %s\n' "$rootfiles" "$MISSIONS_ROOT"
[ "${agent_rows:-0}" = "0" ] || printf 'note: %s agent row(s) remain (KEEP_AGENTS?)\n' "$agent_rows"
# Reported, never auto-removed. An agent volume is `~/drives` — a user's
# files — and a reset script is not the place to decide those are disposable.
[ "$stray_containers" = "0" ] || printf 'note: %s agent container(s) still on the fleet\n' "$stray_containers"
[ "$stray_volumes" = "0" ] || printf 'note: %s agent volume(s) (~/drives) remain — kept on purpose, they hold user files\n' "$stray_volumes"
exit 0 exit 0
fi fi
printf 'NOT clean: %s delete(s) failed, %s orphaned director(y|ies) left\n' "$bad" "$left" printf 'NOT clean: %s mission delete(s) failed, %s orphaned director(y|ies), %s agent delete(s) failed\n' \
"$bad" "$left" "$agents_failed"
exit 1 exit 1