1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
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
|