feat(scripts): deterministic .AGENTTRASH purger with bats seam

Portable POSIX-sh purge of exactly the fleet's deletion-policy trash: a
root's own .AGENTTRASH is emptied in place (the convention survives),
deeper .AGENTTRASH trees are removed with their nested entries accounted
once, symlinked/foreign-owned candidates are refused, / is refused.
Dry-run by default; --execute performs the plan and appends one dated log
line; every printed line is fixed at plan time in the on-disk plan table,
so dry-run and execute output can never drift.

Documented in Operations/Payload and Disk Reclaim.md section 6 (one fact
one owner - no new vault page). Tests: 14 green, ./tests/run.sh 576 green.
This commit is contained in:
Frank committed 2026-10-28 15:54:23 -07:00
1 parent 58dc7a64c1
commit a3c2465638
3 files changed
+518

No files matched your search

+335
View File
@@ -0,0 +1,335 @@
#!/bin/sh
# Deterministically empty every .AGENTTRASH directory under the given roots.
#
# PURPOSE
# The fleet's deletion policy parks deleted files in .AGENTTRASH/ — in-repo
# beside the item, elsewhere in $HOME as .AGENTTRASH/YYYYMMDD-topic — and
# waits for approval to purge. This script is that purge: given a machine,
# it clears .AGENTTRASH directories and nothing else.
#
# CONTRACT
# Default dry-run: the plan prints and nothing is deleted; --execute deletes.
# Every line the script can ever print for a candidate is decided at plan time
# and stored in the plan table, so a dry run prints exactly what --execute
# will print. A .AGENTTRASH that is a root's own trash (a repo's
# .AGENTTRASH, a machine's ~/.AGENTTRASH) prints `plan empty <path>`: the
# directory is the convention and survives, its staged contents are cleared.
# A .AGENTTRASH deeper than that is content and prints `plan remove <path>`
# plus, once executed, `removed <path>`; its own nested .AGENTTRASH entries
# belong to that tree and are never planned again. Candidates are found with
# `find -xdev` per root, so a mounted tree can never be walked by accident;
# ordering is lexicographic (LC_ALL=C). `/`, symlinked, and foreign-owned
# candidates are refused and reported (exit 1), never touched.
# A summary line `removed=<candidates> deleted=<removed> refused=<n>` closes
# every run and one dated line is appended to the log.
# Exit: 0 success, 1 operational failure, 2 usage error. A second run over
# the same tree plans nothing further and exits 0.
#
# PORTABILITY
# POSIX sh, clean under `shellcheck -S style`. Externals: find (must support
# -delete), stat, rm, date, mkdir. No bashisms, no GNU-only flags, no arrays,
# no `local`. Paths containing whitespace are rejected at parse time — the
# same rule applied to roots and candidates — so the plan can be walked with
# plain word splitting and nothing can be silently mis-split.
#
# USAGE
# purge-agenttrash [--execute] [--quiet] [--log PATH] [PATH...]
# --execute delete; without it the run is a plan and deletes nothing
# --quiet suppress per-line output; errors and the summary still print
# --log PATH log destination (default: $HOME/.AGENTTRASH-cleanup.log)
# PATH... roots to search (default: $HOME)
#
# Everything else is fixed by design: the root default, the ordering, the
# guards. There is no configuration file and no environment override.
set -eu
usage() {
sed -n '3,44p' "$0" | sed 's/^# \{0,1\}//'
}
die() {
printf '%s: %s\n' 'purge-agenttrash' "$1" >&2
exit "${2:-1}"
}
# ------------------------------------------------------------------ arg parser
# Roots accumulate as one space-separated string: a root containing whitespace is
# rejected at parse time rather than silently split later. That leaves the plan
# iteratable with plain `$@`-free word splitting, the only portable construct
# that cannot corrupt a path it has already vetted.
MODE=plan
QUIET=0
LOGPATH=
ROOTS=
while [ "$#" -gt 0 ]; do
case "$1" in
--help|-h)
usage
exit 0
;;
--execute)
MODE=execute
;;
--quiet)
QUIET=1
;;
--log)
[ "$#" -ge 2 ] || die '--log needs a value' 2
LOGPATH=$2
shift
;;
--log=*)
LOGPATH=${1#--log=}
;;
-*)
die "unknown option: $1" 2
;;
*)
case $1 in
*' '*) die "root contains whitespace: $1" 2 ;;
esac
if [ -z "$ROOTS" ]; then
ROOTS=$1
else
ROOTS="$ROOTS $1"
fi
;;
esac
shift
done
[ -n "${HOME:-}" ] || die 'HOME is not set; pass roots explicitly or set HOME' 2
[ -n "$LOGPATH" ] || LOGPATH=$HOME/.AGENTTRASH-cleanup.log
# ------------------------------------------------------------ tool availability
require() {
command -v "$1" >/dev/null 2>&1 || die "required tool not found: $1" 1
}
require find
require stat
require rm
require date
require mkdir
# `find -delete` backs the in-place emptying. Probe it rather than trusting
# `find --version`: a shell function named `find` must not be able to fake it.
if ! find . -maxdepth 0 -name '.purge-probe-does-not-exist' -delete >/dev/null 2>&1; then
die 'find does not support -delete; refusing to guess at deletion' 1
fi
UID_SELF=$(id -u)
# ------------------------------------------------------------------- locking
# The lock dir also hosts this run's plan table, so both live and die together:
# the exit trap removes the whole directory and only the log survives the run.
LOCKDIR=$HOME/.agenttrash-purge.lock
PLANFILE=$LOCKDIR/plan
acquire_lock() {
if ! mkdir "$LOCKDIR" 2>/dev/null; then
held_pid=$(cat "$LOCKDIR/pid" 2>/dev/null || printf '%s\n' '')
if [ -n "$held_pid" ] && kill -0 "$held_pid" 2>/dev/null; then
die "another purge holds $LOCKDIR (pid $held_pid)" 1
fi
# Owner died mid-run: drop its pid, then retry exactly once. An
# unremovable/nonempty lock dir is not ours to reclaim.
rm -f "$LOCKDIR/pid" 2>/dev/null || true
rmdir "$LOCKDIR" 2>/dev/null || die "$LOCKDIR exists and is not ours to reclaim" 1
mkdir "$LOCKDIR" 2>/dev/null || die "cannot reacquire $LOCKDIR" 1
fi
printf '%s\n' "$$" >"$LOCKDIR/pid" || die "cannot stamp $LOCKDIR/pid" 1
: >"$PLANFILE" || die "cannot write $PLANFILE" 1
trap 'rm -rf "$LOCKDIR"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
}
# ------------------------------------------------------------------ predicates
is_dir() { # true only for a real directory, never a symlink to one
[ -d "$1" ] && [ ! -L "$1" ]
}
is_ours() { # true only when the invoking user owns the path
owner=$(stat -c '%u' "$1" 2>/dev/null) || return 1
[ "$owner" = "$UID_SELF" ]
}
refuse() { # refuse <path> <reason> — reported, never deleted
printf '%s: refused %s (%s)\n' 'purge-agenttrash' "$1" "$2" >&2
REFUSED=$((REFUSED + 1))
}
emit() { # emit <line> — silenced by --quiet
[ "$QUIET" -eq 1 ] || printf '%s\n' "$1"
}
# --------------------------------------------------------------- the plan table
# One row per candidate: `<action> <path> <line>`. The first two fields tell
# `act_on` what to do; the rest of the row is EXACTLY what the run prints for
# that candidate — in plan mode verbatim, in execute mode as the first line of
# its report. The table is a file, so no shell quoting rule (quoted-for,
# heredoc, IFS games) can corrupt an approved plan, and the two modes can
# never drift apart. A removal's second line (`removed <path>`) is derived from
# the row's action, so it is also fixed at plan time.
CANDIDATES=0
REFUSED=0
record() { # record <action> <path> <line> — append one row to the plan table
printf '%s %s %s\n' "$1" "$2" "$3" >>"$PLANFILE"
CANDIDATES=$((CANDIDATES + 1))
}
# ------------------------------------------------------------- candidate rule
# The whole contract in one place:
# `/` is refused outright (never a candidate, never walked);
# a root that is a symlink or a plain file is refused, never followed;
# a root (or any path) that IS a real .AGENTTRASH prints `plan empty <path>`:
# the convention persists, its staged contents are what gets cleared;
# otherwise the root's DIRECT .AGENTTRASH child is also `empty` — it is that
# level's own trash ($HOME/.AGENTTRASH, x1/.AGENTTRASH, repo-a/.AGENTTRASH);
# every .AGENTTRASH deeper than that is content: `remove`, printed as
# `plan remove <path>` (+ `removed <path>` when executed), unless it lives
# inside a tree already claimed (then it dies with that tree, never planned
# twice) or is foreign-owned/symlinked (then refused).
collect_root() { # collect_root <path> -> 0 ok, 1 refused
target=$1
if [ "$target" = / ]; then
refuse / 'root refusal'
return 1
fi
if [ -L "$target" ] || ! [ -d "$target" ]; then
refuse "$target" 'not a real directory'
return 1
fi
if ! is_ours "$target"; then
refuse "$target" "not owned by uid $UID_SELF"
return 1
fi
if [ "${target##*/}" = .AGENTTRASH ]; then
record empty "$target" "plan empty $target"
return 0
fi
# `own` is the root's own .AGENTTRASH once handled; hits below it belong to
# that tree and must never be planned a second time.
own=
if [ -e "$target/.AGENTTRASH" ]; then
if ! is_ours "$target/.AGENTTRASH"; then
refuse "$target/.AGENTTRASH" "not owned by uid $UID_SELF"
elif ! is_dir "$target/.AGENTTRASH"; then
refuse "$target/.AGENTTRASH" 'not a real directory'
else
record empty "$target/.AGENTTRASH" "plan empty $target/.AGENTTRASH"
own=$target/.AGENTTRASH
fi
fi
# No `-type d`: implementations disagree on whether that matches a
# symlink-to-directory (GNU find says no, others yes), so every entry named
# .AGENTTRASH is collected and classified here instead of by `find`.
if ! found=$(find "$target" -xdev -name .AGENTTRASH 2>/dev/null); then
refuse "$target" 'find failed'
return 1
fi
previous=
for entry in $found; do
[ -n "$entry" ] || continue
if [ -n "$own" ]; then
case $entry in
"$own"|"$own"/*) continue ;; # already emptied in place
esac
fi
if [ -n "$previous" ]; then
case $entry in
"$previous"/*) continue ;; # nested: dies with its parent
esac
fi
previous=$entry
if ! is_ours "$entry"; then
refuse "$entry" "not owned by uid $UID_SELF"
continue
fi
if ! is_dir "$entry"; then
refuse "$entry" 'not a real directory'
continue
fi
record remove "$entry" "plan remove $entry"
done
return 0
}
# ------------------------------------------------------------------ execution
remove_tree() { # rm -rf a real directory; symlinks inside die as entries
is_dir "$1" || return 1
rm -rf -- "$1"
}
empty_dir() { # empty a real directory in place, never following symlinks
is_dir "$1" || return 1
find "$1" -mindepth 1 -xdev -delete
}
# act_on <action> <path> <line> -> 0 when the action succeeded.
# remove: prints the stored line (`plan remove <path>`) then `removed <path>`.
# empty: prints the stored line (`plan empty <path>`) and nothing else — the
# directory survives, so the plan line IS the outcome.
act_on() { # act_on <action> <path> <line> -> 0 when the action succeeded
if [ "$1" = remove ]; then
if remove_tree "$2" 2>/dev/null; then
emit "$3"
emit "removed $2"
return 0
fi
printf '%s: failed to remove %s\n' 'purge-agenttrash' "$2" >&2
return 1
fi
if empty_dir "$2" 2>/dev/null; then
emit "$3"
return 0
fi
printf '%s: failed to empty %s\n' 'purge-agenttrash' "$2" >&2
return 1
}
# Walk the plan table once: plan mode prints every row's line verbatim, execute
# mode acts on each row and counts failures separately from plan-time refusals.
walk_plan() {
while read -r action path line; do
[ -n "$action" ] || continue
if [ "$MODE" = plan ]; then
emit "$line"
elif act_on "$action" "$path" "$line"; then
REMOVED=$((REMOVED + 1))
else
EXEC_REFUSED=$((EXEC_REFUSED + 1))
fi
done <"$PLANFILE"
}
# ----------------------------------------------------------------------- main
[ -n "$ROOTS" ] || ROOTS=$HOME
acquire_lock
for root in $ROOTS; do
collect_root "$root" || true
done
REMOVED=0
EXEC_REFUSED=0
walk_plan
STATUS=0
[ "$REFUSED" -eq 0 ] || STATUS=1
[ "$EXEC_REFUSED" -eq 0 ] || STATUS=1
printf 'removed=%s deleted=%s refused=%s\n' \
"$CANDIDATES" "$REMOVED" "$((REFUSED + EXEC_REFUSED))"
if ! printf '%s\tplan\tcandidates=%s\tremoved=%s\trefused=%s\n' \
"$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$CANDIDATES" "$REMOVED" \
"$((REFUSED + EXEC_REFUSED))" >>"$LOGPATH" 2>/dev/null; then
printf '%s: cannot write log %s\n' 'purge-agenttrash' "$LOGPATH" >&2
STATUS=1
fi
exit "$STATUS"