#!/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 [--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 (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 # 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 # Remove the lock. Idempotent: exit 0 even if already free. # agent-lock status # Print "free" | "held ..." | "stale ..." plus metadata. exit 0 (a query # never fails on lock state). # agent-lock path # Print the resolved lock-directory path without creating it. # # Lock home (the helper owns this; callers pass names only): # $AGENT_LOCK_DIR// when AGENT_LOCK_DIR is set (tests / advanced) # $XDG_RUNTIME_DIR/agent-locks// the tmpfs runtime dir /run/user/ # (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// 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} [--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