aboutsummaryrefslogtreecommitdiff
path: root/claude-templates
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-19 04:50:21 -0500
committerCraig Jennings <c@cjennings.net>2026-07-19 04:50:21 -0500
commita8b6cf4fba4420d8cc657604473259b5242d8c37 (patch)
tree0b9070a650bfa5253d74bd5d0caaf0f3afbd51a3 /claude-templates
parent495e36b9cc95458305d4aecdf6ba88935176c178 (diff)
downloadrulesets-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-xclaude-templates/.ai/scripts/agent-lock248
-rw-r--r--claude-templates/.ai/scripts/tests/agent-lock.bats214
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 ]
+}