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
|
#!/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 ]
}
|