diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-19 04:50:21 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-19 04:50:21 -0500 |
| commit | a8b6cf4fba4420d8cc657604473259b5242d8c37 (patch) | |
| tree | 0b9070a650bfa5253d74bd5d0caaf0f3afbd51a3 /claude-templates | |
| parent | 495e36b9cc95458305d4aecdf6ba88935176c178 (diff) | |
| download | rulesets-a8b6cf4fba4420d8cc657604473259b5242d8c37.tar.gz rulesets-a8b6cf4fba4420d8cc657604473259b5242d8c37.zip | |
feat(sentry): add agent-lock mkdir-atomic advisory lock helper
Phase 1 of the sentry supervisor workflow. sentry needs two locks: a single-runner lock so overlapping /loop fires don't stack, and a roam-write lock so agent edits to the shared roam tree serialize. flock can't serve either, because every Bash call an agent makes is its own short-lived shell, so an flock dies with the call that took it.
agent-lock persists the lock on disk between calls. An atomic mkdir is the acquire. A metadata file inside records pid, host, ISO timestamp, and TTL. Staleness is age-based on the meta mtime, so a crashed holder's lock expires instead of wedging every later acquire, and every reclaim surfaces a note rather than clearing silently. refresh re-touches the mtime as a heartbeat, release is idempotent, status reports free/held/stale, path prints the resolved dir. Locks live under XDG_RUNTIME_DIR/agent-locks (tmpfs: host-local, out of every repo, cleared on reboot), with a ~/.cache fallback where no runtime dir exists.
Reclaim claims the stale dir with an atomic rename before removing it, so two acquirers that both see a lock stale can't both win. A plain remove-then-mkdir would let the loser delete the winner's fresh lock and double-acquire, defeating the roam-write lock's purpose.
Nothing calls it yet. 18 bats tests cover fresh win, contention, release, stale reclaim, heartbeat, path resolution, and usage errors.
Diffstat (limited to 'claude-templates')
| -rwxr-xr-x | claude-templates/.ai/scripts/agent-lock | 248 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/agent-lock.bats | 214 |
2 files changed, 462 insertions, 0 deletions
diff --git a/claude-templates/.ai/scripts/agent-lock b/claude-templates/.ai/scripts/agent-lock new file mode 100755 index 0000000..634412c --- /dev/null +++ b/claude-templates/.ai/scripts/agent-lock @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +# agent-lock — a mkdir-atomic advisory lock for agent workflows. +# +# Why not flock: every Bash call an agent makes is its own short-lived shell, +# so an flock taken in one /loop turn is gone by the next. This helper persists +# the lock on disk between calls (an atomic mkdir is the acquire), and a crashed +# holder's lock self-clears via age-based staleness reclaim instead of wedging +# every later acquire. +# +# Serves both of sentry's locks (the single-runner lock and the roam-write +# lock); callers pass a name, never a path — the helper owns the path scheme. +# +# Usage: +# agent-lock acquire <name> [--ttl=SECONDS] [--wait[=SECONDS]] +# Atomic acquire. exit 0 on win (fresh, or reclaimed from a stale holder); +# exit 1 when a live lock already holds <name> (deferred — a note names the +# holder on stderr). --wait polls up to SECONDS (default 30) before +# deferring; without it, acquire is single-shot win-or-lose. --ttl records +# the staleness horizon in the lock's metadata (default below). +# agent-lock refresh <name> +# Heartbeat: re-touch a held lock's mtime so it stays young. A runner +# refreshes its own lock between passes, so a live run's lock is never older +# than one pass and the TTL sizes to the longest single pass. exit 1 if the +# lock is absent (nothing to refresh). +# agent-lock release <name> +# Remove the lock. Idempotent: exit 0 even if already free. +# agent-lock status <name> +# Print "free" | "held ..." | "stale ..." plus metadata. exit 0 (a query +# never fails on lock state). +# agent-lock path <name> +# Print the resolved lock-directory path without creating it. +# +# Lock home (the helper owns this; callers pass names only): +# $AGENT_LOCK_DIR/<name>/ when AGENT_LOCK_DIR is set (tests / advanced) +# $XDG_RUNTIME_DIR/agent-locks/<name>/ the tmpfs runtime dir /run/user/<uid> +# (host-local, out of every repo, +# cleared on reboot). XDG_RUNTIME_DIR is +# the standard handle for it and is set +# in sentry's interactive launch. +# ${XDG_CACHE_HOME:-~/.cache}/agent-locks/<name>/ fallback where no runtime +# dir exists (XDG_RUNTIME_DIR unset or +# unwritable — a headless/container box) +# +# tmpfs residence is deliberate: a lock under ~/org/roam would ride roam-sync's +# `git add -A` to the other machine as a phantom hold. Host-locality is by +# construction, and reboot clears any lock a crash left behind for free. +# +# Staleness is age-based on the metadata file's mtime versus the lock's own +# recorded TTL. Heartbeat re-touches the mtime; a reclaim is always surfaced, +# never silent. + +set -euo pipefail + +DEFAULT_TTL=600 # 10 min: sized to the longest single sentry pass, since a + # live runner heartbeats between passes and stays young. +DEFAULT_WAIT=30 # bounded-wait budget for --wait (capture-guard's shape). +WAIT_INTERVAL=3 # poll cadence while waiting on a busy lock. + +usage() { + echo "usage: agent-lock {acquire|refresh|release|status|path} <name> [--ttl=N] [--wait[=N]]" >&2 + exit 2 +} + +# Resolve the base directory that holds all lock dirs, per the home scheme above. +lock_base() { + if [ -n "${AGENT_LOCK_DIR:-}" ]; then + printf '%s\n' "$AGENT_LOCK_DIR" + elif [ -n "${XDG_RUNTIME_DIR:-}" ] && [ -d "$XDG_RUNTIME_DIR" ] && [ -w "$XDG_RUNTIME_DIR" ]; then + printf '%s/agent-locks\n' "$XDG_RUNTIME_DIR" + else + printf '%s/agent-locks\n' "${XDG_CACHE_HOME:-$HOME/.cache}" + fi +} + +# Validate a lock name: non-empty, no path separators (so a name can never +# escape the base dir). +valid_name() { + case "$1" in + ''|*/*|.|..) return 1 ;; + *) return 0 ;; + esac +} + +lock_dir() { printf '%s/%s\n' "$(lock_base)" "$1"; } +meta_path() { printf '%s/meta\n' "$(lock_dir "$1")"; } + +# Read a key from a lock's metadata file; empty if absent. +meta_get() { + local key="$1" file="$2" + [ -f "$file" ] || return 0 + sed -n "s/^${key}=//p" "$file" | head -n1 +} + +# Age of a lock in whole seconds, from the metadata mtime. +lock_age() { + local file="$1" mtime now + mtime=$(stat -c %Y "$file" 2>/dev/null) || return 1 + now=$(date +%s) + printf '%s\n' "$((now - mtime))" +} + +# True when a lock dir exists but its age exceeds its recorded TTL. +is_stale() { + local name="$1" file age ttl + file="$(meta_path "$name")" + [ -f "$file" ] || return 1 + age="$(lock_age "$file")" || return 1 + ttl="$(meta_get ttl "$file")" + [ -n "$ttl" ] || ttl="$DEFAULT_TTL" + [ "$age" -gt "$ttl" ] +} + +# Write the metadata file for a freshly-taken lock. +write_meta() { + local name="$1" ttl="$2" file + file="$(meta_path "$name")" + { + printf 'pid=%s\n' "$$" + printf 'host=%s\n' "$(uname -n)" + printf 'acquired=%s\n' "$(date +%Y-%m-%dT%H:%M:%S%z)" + printf 'ttl=%s\n' "$ttl" + } > "$file" +} + +# One-line holder description for surfaced notes. +holder_desc() { + local file="$1" + printf "pid=%s host=%s age=%ss ttl=%ss" \ + "$(meta_get pid "$file")" "$(meta_get host "$file")" \ + "$(lock_age "$file" 2>/dev/null || echo '?')" "$(meta_get ttl "$file")" +} + +# Attempt a single atomic acquire. exit 0 win, 1 busy (live holder). +try_acquire() { + local name="$1" ttl="$2" dir file + dir="$(lock_dir "$name")" + file="$(meta_path "$name")" + mkdir -p "$(lock_base)" + + if mkdir "$dir" 2>/dev/null; then + write_meta "$name" "$ttl" + return 0 + fi + + # Directory exists. Reclaim it if the holder is stale; otherwise it's busy. + if is_stale "$name"; then + # Claim the stale dir atomically before removing it. `mv` of a directory is + # atomic, so when two acquirers both see the lock stale, only one's rename + # of $dir succeeds — the other's fails because $dir is already gone, and it + # falls through to busy. Never `rm -rf $dir` directly: a plain remove lets + # the loser delete the winner's freshly-created lock and double-acquire. + local claimed="$dir.stale.$$" + if mv "$dir" "$claimed" 2>/dev/null; then + echo "agent-lock: reclaimed stale lock '$name' ($(holder_desc "$claimed/meta"))" >&2 + rm -rf "$claimed" + # mkdir stays the sole grant: a concurrent fresh acquirer may win here, + # in which case our mkdir fails and we correctly defer to it. + if mkdir "$dir" 2>/dev/null; then + write_meta "$name" "$ttl" + return 0 + fi + fi + fi + return 1 +} + +cmd_acquire() { + local name="$1"; shift + local ttl="$DEFAULT_TTL" wait_total=0 + while [ $# -gt 0 ]; do + case "$1" in + --ttl=*) ttl="${1#--ttl=}" ;; + --ttl) shift; ttl="${1:-}" ;; + --wait) wait_total="$DEFAULT_WAIT" ;; + --wait=*) wait_total="${1#--wait=}" ;; + *) usage ;; + esac + shift + done + case "$ttl" in ''|*[!0-9]*) usage ;; esac + case "$wait_total" in *[!0-9]*) usage ;; esac + + local elapsed=0 + while :; do + if try_acquire "$name" "$ttl"; then + exit 0 + fi + if [ "$elapsed" -ge "$wait_total" ]; then + echo "agent-lock: '$name' busy ($(holder_desc "$(meta_path "$name")")); deferring" >&2 + exit 1 + fi + local remaining=$((wait_total - elapsed)) step + step=$(( remaining < WAIT_INTERVAL ? remaining : WAIT_INTERVAL )) + sleep "$step" + elapsed=$((elapsed + step)) + done +} + +cmd_refresh() { + local name="$1" file + file="$(meta_path "$name")" + [ -f "$file" ] || exit 1 + # Re-stamp acquired and bump mtime so the age clock restarts. + local ttl; ttl="$(meta_get ttl "$file")"; [ -n "$ttl" ] || ttl="$DEFAULT_TTL" + write_meta "$name" "$ttl" + exit 0 +} + +cmd_release() { + local name="$1" dir + dir="$(lock_dir "$name")" + rm -rf "$dir" + exit 0 +} + +cmd_status() { + local name="$1" dir file + dir="$(lock_dir "$name")" + file="$(meta_path "$name")" + if [ ! -d "$dir" ]; then + echo "free $name" + exit 0 + fi + local state="held" + is_stale "$name" && state="stale" + echo "$state $name pid=$(meta_get pid "$file") host=$(meta_get host "$file") acquired=$(meta_get acquired "$file") ttl=$(meta_get ttl "$file") age=$(lock_age "$file" 2>/dev/null || echo '?')s" + exit 0 +} + +cmd_path() { + lock_dir "$1" + exit 0 +} + +[ $# -ge 1 ] || usage +subcmd="$1"; shift +[ $# -ge 1 ] || usage +name="$1"; shift +valid_name "$name" || usage + +case "$subcmd" in + acquire) cmd_acquire "$name" "$@" ;; + refresh) cmd_refresh "$name" ;; + release) cmd_release "$name" ;; + status) cmd_status "$name" ;; + path) cmd_path "$name" ;; + *) usage ;; +esac diff --git a/claude-templates/.ai/scripts/tests/agent-lock.bats b/claude-templates/.ai/scripts/tests/agent-lock.bats new file mode 100644 index 0000000..dbcffe1 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/agent-lock.bats @@ -0,0 +1,214 @@ +#!/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 <name> [--ttl=SECONDS] [--wait[=SECONDS]] +# exit 0 → acquired (fresh, or reclaimed from a stale prior holder). +# exit 1 → busy: a live lock holds <name>; deferred (note on stderr). +# exit 2 → usage error (bad/absent name, unknown subcommand). +# agent-lock refresh <name> → re-touch a held lock (heartbeat); exit 1 if absent. +# agent-lock release <name> → remove the lock; idempotent (exit 0 if already free). +# agent-lock status <name> → print free|held|stale + metadata; exit 0 (query). +# agent-lock path <name> → 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/<uid>/agent-locks/<name>/ (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 ] +} |
