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:
co-authored by
Claude Opus 5
parent
16cfc29074
commit
f27d2605eb
@@ -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; }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user