Files
boshu2__agentops/scripts/check-compile-health.sh
boshu e9a210edd5 fix(gates): portable mtime-newest find in 3 compile/corpus checks (age-7jm6)
check-corpus-freshness.sh, check-compile-health.sh, check-compile-oscillation.sh
used 'find ... -printf %T@ %p | sort | awk' to pick the newest file by mtime.
-printf is GNU-only; BSD/macOS find errors. corpus-freshness (set -uo) silently
false-SKIPs even when snapshots exist; compile-health/oscillation (set -euo
pipefail) crash via pipefail when the overnight-defrag fallback path is hit.
Same class as age-iue5 (#850) but the harder mtime-sort form. No bats coverage
-> not in the failing-12; found by class-sweep.

Fix (after a cross-family pawl refuted the first 'find -exec ls -t {} + | head'
attempt — that is NOT a global sort when find batches across multiple ls calls,
and an added -type f dropped symlinked snapshots): emit '<mtime>\t<path>' per
match via portable stat (BSD 'stat -f %m' / GNU 'stat -c %Y' fallback), then a
single global 'sort -n | tail -n 1' (tail consumes all input, avoiding the
head+pipefail SIGPIPE trap). corpus-freshness keeps the original symlink-
inclusive match (no -type f). Integer-second precision (vs the original's
fractional %T@) is adequate for these seconds-apart dirs.

Verified on real BSD find+stat: global-newest correct across distinct mtimes,
zero-match -> empty, corpus-freshness now PASSES (was false-SKIP), health/
oscillation exit 0, shellcheck clean, no -printf left in scripts/.
2026-06-23 09:41:56 -04:00

90 lines
3.9 KiB
Bash
Executable File

#!/usr/bin/env bash
# check-compile-health.sh — Gate: Compile defrag report is fresh and stale count is acceptable.
#
# Exit 0 = PASS, Exit 1 = FAIL
#
# Environment overrides:
# COMPILE_OUTPUT_DIR Directory where ao defrag writes (default: $AGENTS_DIR)
# AGENTS_DIR .agents base dir (default: .agents)
# COMPILE_MAX_AGE_HOURS Max age of latest defrag report in hours (default: 26)
# COMPILE_MAX_STALE Max allowed stale learning count (default: 5)
set -euo pipefail
AGENTS_DIR="${AGENTS_DIR:-.agents}"
MAX_AGE_HOURS="${COMPILE_MAX_AGE_HOURS:-26}"
MAX_STALE="${COMPILE_MAX_STALE:-5}"
# COMPILE_OUTPUT_DIR overrides AGENTS_DIR prefix (used in CI where defrag writes to /tmp/)
DEFRAG_LATEST="${COMPILE_OUTPUT_DIR:-$AGENTS_DIR}/defrag/latest.json"
# Gate 1: defrag report must exist.
# Fall back to the most recent Dream overnight preview when no canonical
# report is present and COMPILE_OUTPUT_DIR is unset — Dream writes a
# dry-run defrag at .agents/overnight/<run>/defrag/latest.json every night,
# and that report exposes .timestamp + .prune.stale_count in the same shape
# this gate consumes. Only apply the fallback for the canonical AGENTS_DIR
# path so CI overrides remain strict.
if [[ ! -f "$DEFRAG_LATEST" && -z "${COMPILE_OUTPUT_DIR:-}" ]]; then
overnight_root="$AGENTS_DIR/overnight"
if [[ -d "$overnight_root" ]]; then
# `-printf` is GNU-only; on BSD/macOS find errors and (under set -euo
# pipefail) the suppressed failure propagates -> this assignment crashes
# the script when the fallback path is hit. Portable TRUE-global mtime
# sort: emit "<mtime>\t<path>" per match (BSD `stat -f %m` / GNU `stat -c
# %Y` fallback), then a single global `sort -n | tail` (ascending + tail
# = newest; tail consumes all input, avoiding the head+pipefail SIGPIPE
# trap; no per-batch mis-sort). Zero matches -> empty fallback.
fallback="$(find "$overnight_root" -path '*/defrag/latest.json' -type f 2>/dev/null \
| while IFS= read -r _f; do
printf '%s\t%s\n' "$(stat -f %m "$_f" 2>/dev/null || stat -c %Y "$_f" 2>/dev/null)" "$_f"
done | sort -n | tail -n 1 | cut -f2-)"
if [[ -n "$fallback" && -f "$fallback" ]]; then
echo "INFO: $DEFRAG_LATEST not found; falling back to overnight preview $fallback"
DEFRAG_LATEST="$fallback"
fi
fi
fi
if [[ ! -f "$DEFRAG_LATEST" ]]; then
echo "FAIL: $DEFRAG_LATEST not found — run 'ao defrag' first"
exit 1
fi
# Gate 2: defrag report must be recent
ts=$(jq -r '.timestamp' "$DEFRAG_LATEST" 2>/dev/null || echo "")
if [[ -z "$ts" || "$ts" == "null" ]]; then
echo "FAIL: could not read .timestamp from $DEFRAG_LATEST"
exit 1
fi
# Parse timestamp — support both Linux (date -d) and macOS (date -j)
if date --version >/dev/null 2>&1; then
# GNU date (Linux)
ts_epoch=$(date -d "$ts" +%s 2>/dev/null) || { echo "FAIL: could not parse timestamp '$ts'"; exit 1; }
else
# BSD date (macOS) — strip trailing Z, handle both RFC3339 and ISO8601
ts_clean="${ts%Z}"
ts_epoch=$(date -j -f "%Y-%m-%dT%H:%M:%S%z" "$ts_clean" +%s 2>/dev/null) \
|| ts_epoch=$(date -j -f "%Y-%m-%dT%H:%M:%S" "$ts_clean" +%s 2>/dev/null) \
|| { echo "FAIL: could not parse timestamp '$ts'"; exit 1; }
fi
now_epoch=$(date +%s)
age_seconds=$(( now_epoch - ts_epoch ))
age_hours=$(( age_seconds / 3600 ))
if [[ $age_hours -gt $MAX_AGE_HOURS ]]; then
echo "FAIL: last defrag was ${age_hours}h ago (max: ${MAX_AGE_HOURS}h) — run 'ao defrag'"
exit 1
fi
# Gate 3: stale learning count must be within budget
stale=$(jq -r '.prune.stale_count // 0' "$DEFRAG_LATEST" 2>/dev/null || echo "0")
if [[ "$stale" -gt "$MAX_STALE" ]]; then
echo "FAIL: $stale stale learnings (max: $MAX_STALE) — run 'ao defrag --prune'"
exit 1
fi
echo "PASS: Compile health OK (defrag ${age_hours}h ago, stale=${stale}/${MAX_STALE})"
exit 0