#!/usr/bin/env bats # # Tests for claude-templates/.ai/scripts/agent-lock — a mkdir-atomic advisory # lock helper for agent workflows (sentry's single-runner and roam-write # locks). flock can't span an agent's tool calls: every Bash call is its own # short-lived shell, so a flock dies with the call that took it. This helper # persists the lock on disk between calls and self-clears after a crash via # age-based staleness reclaim. # # Contract under test: # agent-lock acquire [--ttl=SECONDS] [--wait[=SECONDS]] # exit 0 → acquired (fresh, or reclaimed from a stale prior holder). # exit 1 → busy: a live lock holds ; deferred (note on stderr). # exit 2 → usage error (bad/absent name, unknown subcommand). # agent-lock refresh → re-touch a held lock (heartbeat); exit 1 if absent. # agent-lock release → remove the lock; idempotent (exit 0 if already free). # agent-lock status → print free|held|stale + metadata; exit 0 (query). # agent-lock path → print the resolved lock dir path; does not create it. # # Staleness is age-based on the metadata file's mtime versus the lock's own # recorded TTL, so a crashed holder's lock expires instead of wedging every # later acquire. Heartbeat (refresh) re-touches the mtime, keeping a live # holder's lock young. Every reclaim surfaces a note (never silent). # # Lock home: /run/user//agent-locks// (tmpfs: host-local, out of # every repo, cleared on reboot), with ~/.cache/agent-locks/ as the fallback # where no runtime dir exists. AGENT_LOCK_DIR overrides the base for tests and # advanced callers; the helper otherwise owns the path scheme and callers pass # only names. # # Strategy: AGENT_LOCK_DIR points every lock at a temp base, so tests never # touch a real runtime dir. Staleness is exercised by aging the metadata # file's mtime with `touch` rather than sleeping. SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/agent-lock" BASH_BIN="$(command -v bash)" setup() { TEST_DIR="$(mktemp -d -t agent-lock-bats.XXXXXX)" LOCK_BASE="$TEST_DIR/locks" } teardown() { rm -rf "$TEST_DIR" } lock() { run env AGENT_LOCK_DIR="$LOCK_BASE" "$BASH_BIN" "$SCRIPT" "$@" } # meta-file path for a lock name, for direct inspection / aging. meta_of() { printf '%s/%s/meta\n' "$LOCK_BASE" "$1" } # ---- acquire: fresh win + metadata -------------------------------------- @test "acquire: fresh name wins (exit 0) and writes pid/host/timestamp/ttl" { lock acquire job [ "$status" -eq 0 ] local meta; meta="$(meta_of job)" [ -f "$meta" ] grep -q "^pid=$$\|^pid=[0-9][0-9]*$" "$meta" grep -q "^host=$(uname -n)$" "$meta" grep -qE "^acquired=[0-9]{4}-[0-9]{2}-[0-9]{2}T" "$meta" grep -qE "^ttl=[0-9]+$" "$meta" } @test "acquire: honors an explicit --ttl in the metadata" { lock acquire job --ttl=45 [ "$status" -eq 0 ] grep -q "^ttl=45$" "$(meta_of job)" } # ---- acquire: contention (one winner) ----------------------------------- @test "acquire: a second acquire of a live lock defers (exit 1, note)" { lock acquire job [ "$status" -eq 0 ] lock acquire job [ "$status" -eq 1 ] [[ "$output" == *job* ]] } @test "acquire: two racing acquires yield exactly one winner" { # Fire both without releasing; exactly one mkdir wins. env AGENT_LOCK_DIR="$LOCK_BASE" "$BASH_BIN" "$SCRIPT" acquire race & p1=$! env AGENT_LOCK_DIR="$LOCK_BASE" "$BASH_BIN" "$SCRIPT" acquire race & p2=$! local r1=0 r2=0 wait $p1 || r1=$? wait $p2 || r2=$? # One exits 0 (won), one exits 1 (deferred). [ "$((r1 + r2))" -eq 1 ] } # ---- release: frees the lock -------------------------------------------- @test "release: frees a held lock so the next acquire wins" { lock acquire job [ "$status" -eq 0 ] lock release job [ "$status" -eq 0 ] [ ! -d "$LOCK_BASE/job" ] lock acquire job [ "$status" -eq 0 ] } @test "release: is idempotent on an already-free lock (exit 0)" { lock release never-held [ "$status" -eq 0 ] } # ---- staleness reclaim (surfaced, never silent) ------------------------- @test "acquire: reclaims a stale lock and surfaces the reclaim note" { lock acquire job --ttl=1 [ "$status" -eq 0 ] # Age the metadata mtime well past the 1s TTL. touch -d '1 hour ago' "$(meta_of job)" lock acquire job --ttl=1 [ "$status" -eq 0 ] [[ "$output" == *reclaim* ]] [[ "$output" == *job* ]] # The reclaim installed fresh metadata (young again), not the aged holder's. lock status job [[ "$output" == *held* ]] [[ "$output" != *stale* ]] } @test "acquire: a lock inside its TTL is not stale (stays deferred)" { lock acquire job --ttl=3600 [ "$status" -eq 0 ] lock acquire job --ttl=3600 [ "$status" -eq 1 ] } # ---- heartbeat (refresh keeps a live lock young) ------------------------ @test "refresh: re-touches a held lock so it is no longer stale" { lock acquire job --ttl=1 [ "$status" -eq 0 ] touch -d '1 hour ago' "$(meta_of job)" lock status job [[ "$output" == *stale* ]] lock refresh job [ "$status" -eq 0 ] lock status job [[ "$output" == *held* ]] [[ "$output" != *stale* ]] } @test "refresh: an absent lock cannot be refreshed (exit 1)" { lock refresh nothing [ "$status" -eq 1 ] } # ---- status query ------------------------------------------------------- @test "status: reports free for an unheld lock (exit 0)" { lock status job [ "$status" -eq 0 ] [[ "$output" == *free* ]] } @test "status: reports held with metadata for a live lock" { lock acquire job --ttl=3600 lock status job [ "$status" -eq 0 ] [[ "$output" == *held* ]] [[ "$output" == *"host=$(uname -n)"* ]] } # ---- path resolution: runtime dir home with cache fallback -------------- @test "path: resolves under AGENT_LOCK_DIR when set" { lock path job [ "$status" -eq 0 ] [ "$output" = "$LOCK_BASE/job" ] [ ! -d "$LOCK_BASE/job" ] # path does not create the lock } @test "path: prefers the runtime dir home when no override is set" { local rt="$TEST_DIR/run" mkdir -p "$rt" run env -u AGENT_LOCK_DIR XDG_RUNTIME_DIR="$rt" "$BASH_BIN" "$SCRIPT" path job [ "$status" -eq 0 ] [ "$output" = "$rt/agent-locks/job" ] } @test "path: falls back to the cache home when no runtime dir exists" { local home="$TEST_DIR/home" mkdir -p "$home" run env -u AGENT_LOCK_DIR -u XDG_RUNTIME_DIR -u XDG_CACHE_HOME \ HOME="$home" "$BASH_BIN" "$SCRIPT" path job [ "$status" -eq 0 ] [ "$output" = "$home/.cache/agent-locks/job" ] } # ---- usage errors ------------------------------------------------------- @test "usage: a missing name is a usage error (exit 2)" { lock acquire [ "$status" -eq 2 ] } @test "usage: a name with a slash is rejected (exit 2)" { lock acquire bad/name [ "$status" -eq 2 ] } @test "usage: an unknown subcommand is a usage error (exit 2)" { lock frobnicate job [ "$status" -eq 2 ] }