diff options
Diffstat (limited to 'claude-templates/.ai/scripts/agent-lock')
| -rwxr-xr-x | claude-templates/.ai/scripts/agent-lock | 248 |
1 files changed, 248 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 |
