aboutsummaryrefslogtreecommitdiff
path: root/claude-templates/.ai/scripts/agent-lock
diff options
context:
space:
mode:
Diffstat (limited to 'claude-templates/.ai/scripts/agent-lock')
-rwxr-xr-xclaude-templates/.ai/scripts/agent-lock248
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