agent-lock 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.claude/CLAUDE.md +1 -0
- data/.envrc +2 -0
- data/.rubocop_todo.yml +90 -0
- data/.ruby-version +1 -0
- data/AGENTS.md +54 -0
- data/CHANGELOG.md +48 -0
- data/LICENSE.txt +21 -0
- data/README.md +374 -0
- data/Rakefile +12 -0
- data/exe/agent-lock +8 -0
- data/exe/alock +9 -0
- data/justfile +127 -0
- data/lib/agent/lock/cli/commands/acquire.rb +87 -0
- data/lib/agent/lock/cli/commands/base.rb +148 -0
- data/lib/agent/lock/cli/commands/break.rb +36 -0
- data/lib/agent/lock/cli/commands/check.rb +56 -0
- data/lib/agent/lock/cli/commands/completion.rb +50 -0
- data/lib/agent/lock/cli/commands/list.rb +70 -0
- data/lib/agent/lock/cli/commands/mine.rb +35 -0
- data/lib/agent/lock/cli/commands/note.rb +37 -0
- data/lib/agent/lock/cli/commands/release.rb +33 -0
- data/lib/agent/lock/cli/commands/release_all.rb +24 -0
- data/lib/agent/lock/cli/commands/resume.rb +36 -0
- data/lib/agent/lock/cli/commands/skill.rb +75 -0
- data/lib/agent/lock/cli/commands/version.rb +20 -0
- data/lib/agent/lock/cli/commands/whoami.rb +83 -0
- data/lib/agent/lock/cli.rb +80 -0
- data/lib/agent/lock/error.rb +10 -0
- data/lib/agent/lock/freeze.rb +109 -0
- data/lib/agent/lock/identity.rb +166 -0
- data/lib/agent/lock/launcher.rb +126 -0
- data/lib/agent/lock/manager.rb +313 -0
- data/lib/agent/lock/process_info.rb +60 -0
- data/lib/agent/lock/record.rb +216 -0
- data/lib/agent/lock/scope.rb +173 -0
- data/lib/agent/lock/skill.rb +104 -0
- data/lib/agent/lock/store/file_system_store.rb +161 -0
- data/lib/agent/lock/store/redis_store.rb +225 -0
- data/lib/agent/lock/store.rb +104 -0
- data/lib/agent/lock/tree.rb +126 -0
- data/lib/agent/lock/version.rb +7 -0
- data/lib/agent/lock.rb +30 -0
- data/sig/agent/lock.rbs +6 -0
- data/skills/agent-lock/SKILL.md +59 -0
- metadata +150 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../error"
|
|
4
|
+
require_relative "../record"
|
|
5
|
+
|
|
6
|
+
require "redis"
|
|
7
|
+
require "digest"
|
|
8
|
+
require "securerandom"
|
|
9
|
+
|
|
10
|
+
module Agent
|
|
11
|
+
module Lock
|
|
12
|
+
module Store
|
|
13
|
+
# The same locks in a local Redis, for the machines that already run one.
|
|
14
|
+
#
|
|
15
|
+
# Redis buys two things the filesystem cannot: `SET NX` is atomic across
|
|
16
|
+
# machines rather than only across processes on one, and a TTL expires an
|
|
17
|
+
# abandoned lock without anybody having to reason about whether its holder
|
|
18
|
+
# is still alive. It costs the thing that makes the file store pleasant,
|
|
19
|
+
# which is that you can `cat` a lock, so the value stored is the same
|
|
20
|
+
# markdown document either way.
|
|
21
|
+
#
|
|
22
|
+
# Picked with AGENT_LOCK_BACKEND=redis, or by default when one answers on
|
|
23
|
+
# REDIS_URL. See Store's moduledoc for how that default is decided.
|
|
24
|
+
class RedisStore
|
|
25
|
+
NAMESPACE = "agent-lock"
|
|
26
|
+
|
|
27
|
+
# How long the store mutex outlives a holder that died holding it. A
|
|
28
|
+
# claim's critical section is a SCAN and one SET, so ten seconds is
|
|
29
|
+
# somebody gone, not somebody busy.
|
|
30
|
+
MUTEX_LEASE_MS = 10_000
|
|
31
|
+
|
|
32
|
+
# Seconds to wait for the mutex. Longer than the lease, so that a
|
|
33
|
+
# crashed holder is waited out rather than reported.
|
|
34
|
+
MUTEX_TIMEOUT = 15
|
|
35
|
+
|
|
36
|
+
LEASE_LOST = "the lock store's mutex lease (#{MUTEX_LEASE_MS}ms) ran out before the claim finished, " \
|
|
37
|
+
"so another process may have claimed an overlapping scope: check the listing".freeze
|
|
38
|
+
|
|
39
|
+
RELEASE = <<~LUA
|
|
40
|
+
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
|
41
|
+
return redis.call("DEL", KEYS[1])
|
|
42
|
+
end
|
|
43
|
+
return 0
|
|
44
|
+
LUA
|
|
45
|
+
|
|
46
|
+
attr_reader :tree, :client
|
|
47
|
+
|
|
48
|
+
def initialize(tree, client: nil)
|
|
49
|
+
@tree = tree
|
|
50
|
+
|
|
51
|
+
if client.nil?
|
|
52
|
+
client, error = self.class.create_client
|
|
53
|
+
raise(error) if client.nil? && error
|
|
54
|
+
|
|
55
|
+
@client = client if client
|
|
56
|
+
else
|
|
57
|
+
@client = client
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
raise "no functional Redis client could be created for #{self.class.url}" unless @client
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @return [String]
|
|
64
|
+
def describe = "redis #{url} (#{namespace})"
|
|
65
|
+
|
|
66
|
+
# SCAN rather than KEYS: this runs on whatever Redis the machine
|
|
67
|
+
# already has, which may be somebody's shared development instance,
|
|
68
|
+
# and KEYS blocks the server for the length of the scan.
|
|
69
|
+
#
|
|
70
|
+
# @return [Array<Record>]
|
|
71
|
+
def all
|
|
72
|
+
keys = client.scan_each(match: "#{namespace}:*").to_a.uniq.sort
|
|
73
|
+
keys.filter_map { |key| parse(client.get(key), key) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Runs the block with every other process in this store shut out, so a
|
|
77
|
+
# scan for conflicts and the write it justifies cannot be interleaved.
|
|
78
|
+
# SET NX alone settles a race for one scope, but `lib/**` and
|
|
79
|
+
# `lib/a1.rb` are two keys, and two agents that both scanned an empty
|
|
80
|
+
# namespace before either wrote both won.
|
|
81
|
+
#
|
|
82
|
+
# The mutex is a key set with NX to a token only this call knows, and
|
|
83
|
+
# leased rather than held: a holder that dies mid-claim cannot release
|
|
84
|
+
# it, so the lease does, within MUTEX_LEASE_MS. The wait for it is
|
|
85
|
+
# bounded, and longer than the lease, so a crashed holder costs the
|
|
86
|
+
# next agent a pause and never an error.
|
|
87
|
+
#
|
|
88
|
+
# Not re-entrant. A nested call waits on its own mutex until the lease
|
|
89
|
+
# frees it, and the outer call then finds it gone and raises.
|
|
90
|
+
#
|
|
91
|
+
# @yield the critical section
|
|
92
|
+
# @return [Object] whatever the block returns
|
|
93
|
+
# @raise [Error] when the mutex stayed held for longer than the timeout,
|
|
94
|
+
# or the block outlived its lease and ran unprotected for a while
|
|
95
|
+
def synchronize
|
|
96
|
+
token = SecureRandom.hex(16)
|
|
97
|
+
wait_for(token)
|
|
98
|
+
begin
|
|
99
|
+
outcome = yield
|
|
100
|
+
ensure
|
|
101
|
+
released = release(token)
|
|
102
|
+
end
|
|
103
|
+
raise Error, LEASE_LOST unless released
|
|
104
|
+
|
|
105
|
+
outcome
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Outside the namespace on purpose. `all` scans `<namespace>:*`, and a
|
|
109
|
+
# mutex inside it would be read back as a lock on every scan.
|
|
110
|
+
#
|
|
111
|
+
# @return [String]
|
|
112
|
+
def mutex_key = "#{NAMESPACE}-mutex:#{digest}"
|
|
113
|
+
|
|
114
|
+
# @param scope [Scope]
|
|
115
|
+
# @return [Record, nil]
|
|
116
|
+
def find(scope) = parse(client.get(key_for(Record.id_for(tree, scope))), nil)
|
|
117
|
+
|
|
118
|
+
# Named to match the Store interface FileSystemStore shares: an action
|
|
119
|
+
# with a boolean outcome, not a pure predicate.
|
|
120
|
+
#
|
|
121
|
+
# @param record [Record]
|
|
122
|
+
# @return [Boolean]
|
|
123
|
+
# rubocop:disable-next Naming/PredicateMethod
|
|
124
|
+
def create(record)
|
|
125
|
+
args = { nx: true }
|
|
126
|
+
args[:ex] = ttl_seconds if ttl_seconds.positive?
|
|
127
|
+
!!client.set(key_for(record.id), record.to_markdown, **args)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @param record [Record]
|
|
131
|
+
# @return [void]
|
|
132
|
+
def update(record) = client.set(key_for(record.id), record.to_markdown, keepttl: true)
|
|
133
|
+
|
|
134
|
+
# @param record [Record]
|
|
135
|
+
# @return [void]
|
|
136
|
+
def delete(record) = client.del(key_for(record.id))
|
|
137
|
+
|
|
138
|
+
# @param id [String]
|
|
139
|
+
# @return [String]
|
|
140
|
+
def key_for(id) = "#{namespace}:#{id}"
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
# Keyed by the tree, so one Redis instance serves every checkout on the
|
|
145
|
+
# machine without their locks colliding.
|
|
146
|
+
def namespace = "#{NAMESPACE}:#{digest}"
|
|
147
|
+
|
|
148
|
+
# @return [String]
|
|
149
|
+
def url = self.class.url
|
|
150
|
+
|
|
151
|
+
def digest = ::Digest::SHA256.hexdigest(tree.root)[0, 12]
|
|
152
|
+
|
|
153
|
+
# SET NX PX until it takes, backing off with jitter so that processes
|
|
154
|
+
# which all lost the same round do not all come back for the next one
|
|
155
|
+
# at the same instant.
|
|
156
|
+
#
|
|
157
|
+
# @param token [String] what the key is set to, so release can tell
|
|
158
|
+
# this holder's mutex from the next one's
|
|
159
|
+
# @return [void]
|
|
160
|
+
# @raise [Error] once the timeout has passed
|
|
161
|
+
def wait_for(token)
|
|
162
|
+
timeout = mutex_timeout
|
|
163
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
164
|
+
delay = 0.005
|
|
165
|
+
until client.set(mutex_key, token, nx: true, px: MUTEX_LEASE_MS)
|
|
166
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
167
|
+
raise Error, format("timed out after %<timeout>gs waiting for %<key>s in %<url>s, " \
|
|
168
|
+
"which another process claiming a lock is holding", timeout:, key: mutex_key, url:)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
sleep(delay * rand(0.5..1.0))
|
|
172
|
+
delay = [delay * 2, 0.1].min
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Compare and delete in one step. A plain DEL after a GET could land
|
|
177
|
+
# after the lease ran out and somebody else took the mutex, and would
|
|
178
|
+
# then let a third process in beside the second.
|
|
179
|
+
#
|
|
180
|
+
# @param token [String]
|
|
181
|
+
# @return [Boolean] false when the mutex was no longer this holder's
|
|
182
|
+
# rubocop:disable-next Naming/PredicateMethod
|
|
183
|
+
def release(token) = client.eval(RELEASE, keys: [mutex_key], argv: [token]) == 1
|
|
184
|
+
|
|
185
|
+
# @return [Float] seconds to wait for the mutex before giving up
|
|
186
|
+
# @raise [Error] when AGENT_LOCK_MUTEX_TIMEOUT is not a finite,
|
|
187
|
+
# non-negative number: `Float` accepts "Infinity" and "NaN", and
|
|
188
|
+
# either makes the deadline in `wait_for` unreachable, hanging the
|
|
189
|
+
# poll loop forever instead of timing out.
|
|
190
|
+
def mutex_timeout
|
|
191
|
+
timeout = Float(ENV.fetch("AGENT_LOCK_MUTEX_TIMEOUT", MUTEX_TIMEOUT))
|
|
192
|
+
unless timeout.finite? && timeout >= 0
|
|
193
|
+
raise Error, "AGENT_LOCK_MUTEX_TIMEOUT must be a finite, non-negative number, got #{timeout}"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
timeout
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def ttl_seconds = Integer(ENV.fetch("AGENT_LOCK_TTL_SECONDS", 0))
|
|
200
|
+
|
|
201
|
+
def parse(text, key)
|
|
202
|
+
return nil if text.nil?
|
|
203
|
+
|
|
204
|
+
record = Record.parse(text)
|
|
205
|
+
record && key ? record.with(id: key.split(":").last) : record
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
class << self
|
|
209
|
+
def url = ENV.fetch("REDIS_URL", "redis://127.0.0.1:6379/0")
|
|
210
|
+
|
|
211
|
+
# @return Array[RedisClient,NilClass,Exception] the client if it could be created,
|
|
212
|
+
# or the error that prevented it
|
|
213
|
+
def create_client
|
|
214
|
+
@client ||= ::Redis.new(url: url).tap do |client|
|
|
215
|
+
_version = client.info["redis_version"]
|
|
216
|
+
end
|
|
217
|
+
[@client, nil]
|
|
218
|
+
rescue Redis::CannotConnectError, Redis::BaseError, Errno::ECONNREFUSED, SocketError => e
|
|
219
|
+
[nil, e]
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "error"
|
|
4
|
+
require_relative "store/file_system_store"
|
|
5
|
+
require_relative "store/redis_store"
|
|
6
|
+
|
|
7
|
+
require "fileutils"
|
|
8
|
+
|
|
9
|
+
module Agent
|
|
10
|
+
module Lock
|
|
11
|
+
# Where locks are kept. One tree, one store, chosen on purpose.
|
|
12
|
+
#
|
|
13
|
+
# AGENT_LOCK_BACKEND wins when set. Otherwise a tree that already has a
|
|
14
|
+
# marker keeps using it. A virgin tree defaults to Redis when one answers
|
|
15
|
+
# locally, file when none does.
|
|
16
|
+
#
|
|
17
|
+
# That default is still never a runtime auto-*switch*: once a marker
|
|
18
|
+
# exists, every later process in that tree is bound to it regardless of
|
|
19
|
+
# what Redis is doing, because an agent that quietly moved to a different
|
|
20
|
+
# store than the agent beside it would give the two of them two stores in
|
|
21
|
+
# which neither can see the other's locks, a lock that is worse than no
|
|
22
|
+
# lock, because it reports success.
|
|
23
|
+
#
|
|
24
|
+
# The one moment that default is decided is also the one moment two
|
|
25
|
+
# processes could race: both find a virgin tree, both probe Redis, and a
|
|
26
|
+
# flaky answer could hand them different defaults before either writes the
|
|
27
|
+
# marker. So the marker is claimed atomically (first `O_CREAT|O_EXCL` wins)
|
|
28
|
+
# and every process builds from whatever ends up on disk, never from its
|
|
29
|
+
# own guess, so a race can pick either backend but never a split.
|
|
30
|
+
module Store
|
|
31
|
+
MARKER = "backend"
|
|
32
|
+
|
|
33
|
+
class Mismatch < Error; end
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
# @param tree [Tree]
|
|
38
|
+
# @return [Store::FileSystemStore, Store::RedisStore]
|
|
39
|
+
def for(tree)
|
|
40
|
+
requested = ENV["AGENT_LOCK_BACKEND"]&.downcase
|
|
41
|
+
established = recorded(tree)
|
|
42
|
+
|
|
43
|
+
if established
|
|
44
|
+
if requested && requested != established
|
|
45
|
+
raise Mismatch, "this tree's locks live in #{established}, not #{requested}, " \
|
|
46
|
+
"release them before switching backends"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
return build(established, tree)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
build(claim(tree, requested || default_backend), tree)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Store::FileSystemStore, Store::RedisStore]
|
|
56
|
+
def build(name, tree)
|
|
57
|
+
case name
|
|
58
|
+
when "redis" then RedisStore.new(tree)
|
|
59
|
+
when "file" then FileSystemStore.new(tree)
|
|
60
|
+
else raise Mismatch, "unknown backend #{name.inspect}, expected file or redis"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [String, nil] the backend this tree already uses
|
|
65
|
+
def recorded(tree)
|
|
66
|
+
path = marker_path(tree)
|
|
67
|
+
File.exist?(path) ? File.read(path).strip : nil
|
|
68
|
+
rescue SystemCallError
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Atomically claims the marker for a virgin tree with `name`, or, when a
|
|
73
|
+
# concurrent process already claimed it first, reads back whatever that
|
|
74
|
+
# process wrote instead. Either way, every caller ends up building the
|
|
75
|
+
# same backend for this tree.
|
|
76
|
+
#
|
|
77
|
+
# @param tree [Tree]
|
|
78
|
+
# @param name [String] this process's proposed backend
|
|
79
|
+
# @return [String] the backend actually recorded, which may not be `name`
|
|
80
|
+
def claim(tree, name)
|
|
81
|
+
path = marker_path(tree)
|
|
82
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
83
|
+
File.write(path, "#{name}\n", mode: File::WRONLY | File::CREAT | File::EXCL)
|
|
84
|
+
name
|
|
85
|
+
rescue Errno::EEXIST
|
|
86
|
+
recorded(tree) || name
|
|
87
|
+
rescue SystemCallError
|
|
88
|
+
name
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def marker_path(tree) = File.join(tree.store_dir, MARKER)
|
|
92
|
+
|
|
93
|
+
# @return [String] "redis" when one answers on REDIS_URL, "file" otherwise
|
|
94
|
+
def default_backend
|
|
95
|
+
local_redis_available? ? "redis" : "file"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def local_redis_available?
|
|
99
|
+
client, error = RedisStore.create_client
|
|
100
|
+
!client.nil? && error.nil?
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "scope"
|
|
5
|
+
|
|
6
|
+
module Agent
|
|
7
|
+
module Lock
|
|
8
|
+
# The working tree a lock is about, and where its locks are kept.
|
|
9
|
+
#
|
|
10
|
+
# Locks live in `<git-common-dir>/agent-locks`, which is chosen rather than
|
|
11
|
+
# `~/.agent-locks` or a dotfile at the root for three reasons. Git cannot
|
|
12
|
+
# track anything inside `.git`, so no repository needs a `.gitignore` line
|
|
13
|
+
# and `git clean -xdf` cannot wipe the locks. Every worktree of a
|
|
14
|
+
# repository resolves `--git-common-dir` to the same directory, so one
|
|
15
|
+
# store serves all of them. And it dies with the checkout, instead of
|
|
16
|
+
# outliving it in a home directory nobody thinks to sweep.
|
|
17
|
+
#
|
|
18
|
+
# Outside a repository the store falls back to `~/.agent-locks`, keyed by a
|
|
19
|
+
# digest of the tree, since there is no `.git` to hide in.
|
|
20
|
+
class Tree
|
|
21
|
+
class << self
|
|
22
|
+
# @param dir [String] anywhere inside the tree
|
|
23
|
+
# @return [Tree]
|
|
24
|
+
def for(dir = Dir.pwd) = new(dir)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [String] absolute, symlinks resolved, so /tmp and /private/tmp
|
|
28
|
+
# cannot become two names for one tree
|
|
29
|
+
attr_reader :root
|
|
30
|
+
|
|
31
|
+
def initialize(dir = Dir.pwd)
|
|
32
|
+
@dir = File.realpath(dir)
|
|
33
|
+
@root = git("rev-parse", "--show-toplevel") || @dir
|
|
34
|
+
@root = File.realpath(@root)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Boolean] a linked worktree rather than the original checkout
|
|
38
|
+
def worktree? = !git_dir.nil? && git_dir != common_dir
|
|
39
|
+
|
|
40
|
+
# @description Resolves the directory where locks are stored by either
|
|
41
|
+
# $AGENT_LOCK_DIR environment variable if defined, or 'agent-locks'
|
|
42
|
+
# inside .git if available, or the ~/.agent-locks/<tree-digest> in user's
|
|
43
|
+
# home folder.
|
|
44
|
+
# @return [String] where locks for this tree are written
|
|
45
|
+
def store_dir
|
|
46
|
+
return File.expand_path(ENV["AGENT_LOCK_DIR"]) if ENV["AGENT_LOCK_DIR"]
|
|
47
|
+
return File.join(common_dir, "agent-locks") if common_dir
|
|
48
|
+
|
|
49
|
+
File.join(Dir.home, ".agent-locks", ::Digest::SHA256.hexdigest(root)[0, 12])
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A path the user typed, read from where they stand, as a name inside
|
|
53
|
+
# this tree.
|
|
54
|
+
#
|
|
55
|
+
# There used to be a fallback to the basename for anything outside the
|
|
56
|
+
# root, so `/etc/passwd` quietly locked the tree's own `passwd`: an agent
|
|
57
|
+
# was told it held something it had never asked for, and the thing it had
|
|
58
|
+
# asked for stayed unguarded. Outside is now an error.
|
|
59
|
+
#
|
|
60
|
+
# The path is compared as typed first, then with symlinks resolved, so an
|
|
61
|
+
# alias of the tree (`/tmp` for `/private/tmp`) is still inside it, while
|
|
62
|
+
# an in-tree symlink that points elsewhere keeps its in-tree name, which is
|
|
63
|
+
# what the lock is about. The path need not exist: an agent claims a file
|
|
64
|
+
# before writing it.
|
|
65
|
+
#
|
|
66
|
+
# @param path [String] anything the user typed, absolute or relative
|
|
67
|
+
# @return [String] that path relative to the root, "." for the root itself
|
|
68
|
+
# @raise [Scope::Invalid] when the path resolves outside the tree
|
|
69
|
+
def relative(path)
|
|
70
|
+
absolute = File.absolute_path(path, @dir)
|
|
71
|
+
inside(absolute) || inside(resolve(absolute)) ||
|
|
72
|
+
raise(Scope::Invalid, "#{path} is outside the tree #{root}")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# The prefix test that `delete_prefix` alone got wrong: `/repo-other`
|
|
78
|
+
# starts with `/repo`, so the root is matched only whole or up to a slash.
|
|
79
|
+
#
|
|
80
|
+
# @param absolute [String]
|
|
81
|
+
# @return [String, nil] relative to the root, or nil when outside it
|
|
82
|
+
def inside(absolute)
|
|
83
|
+
return "." if absolute == root
|
|
84
|
+
|
|
85
|
+
prefix = root.end_with?("/") ? root : "#{root}/"
|
|
86
|
+
absolute.delete_prefix(prefix) if absolute.start_with?(prefix)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Symlinks resolved in the deepest part of the path that exists, with the
|
|
90
|
+
# rest appended, since `File.realpath` refuses a file not yet written.
|
|
91
|
+
#
|
|
92
|
+
# @param absolute [String]
|
|
93
|
+
# @return [String]
|
|
94
|
+
def resolve(absolute)
|
|
95
|
+
head = absolute
|
|
96
|
+
tail = []
|
|
97
|
+
until File.exist?(head)
|
|
98
|
+
tail.unshift(File.basename(head))
|
|
99
|
+
head = File.dirname(head)
|
|
100
|
+
end
|
|
101
|
+
File.join(File.realpath(head), *tail)
|
|
102
|
+
rescue SystemCallError
|
|
103
|
+
absolute
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def git_dir = @git_dir ||= absolute(git("rev-parse", "--git-dir"))
|
|
107
|
+
|
|
108
|
+
def common_dir = @common_dir ||= absolute(git("rev-parse", "--git-common-dir"))
|
|
109
|
+
|
|
110
|
+
def absolute(dir)
|
|
111
|
+
return nil if dir.nil?
|
|
112
|
+
|
|
113
|
+
File.realpath(File.absolute_path(dir, @dir))
|
|
114
|
+
rescue Errno::ENOENT
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def git(*args)
|
|
119
|
+
out = IO.popen(["git", "-C", @dir, *args], err: File::NULL, &:read).to_s.strip
|
|
120
|
+
out.empty? ? nil : out
|
|
121
|
+
rescue SystemCallError
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
data/lib/agent/lock.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# kept deliberate: this gem loads what it needs, in order
|
|
4
|
+
|
|
5
|
+
require_relative "lock/version"
|
|
6
|
+
require_relative "lock/error"
|
|
7
|
+
|
|
8
|
+
module Agent
|
|
9
|
+
# Advisory locks for the several coding agents that end up in one checkout.
|
|
10
|
+
#
|
|
11
|
+
# `Agent::Lock` is the library; `agent-lock` is the executable. Everything
|
|
12
|
+
# the CLI does is a method on Manager, which prints nothing and exits
|
|
13
|
+
# nothing, so the whole lifecycle is testable without capturing output.
|
|
14
|
+
module Lock
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
require_relative "lock/process_info"
|
|
19
|
+
require_relative "lock/identity"
|
|
20
|
+
require_relative "lock/tree"
|
|
21
|
+
require_relative "lock/scope"
|
|
22
|
+
require_relative "lock/record"
|
|
23
|
+
require_relative "lock/freeze"
|
|
24
|
+
require_relative "lock/skill"
|
|
25
|
+
require_relative "lock/store"
|
|
26
|
+
require_relative "lock/store/file_system_store"
|
|
27
|
+
require_relative "lock/store/redis_store"
|
|
28
|
+
require_relative "lock/manager"
|
|
29
|
+
require_relative "lock/launcher"
|
|
30
|
+
require_relative "lock/cli"
|
data/sig/agent/lock.rbs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: agent-lock
|
|
3
|
+
description: Use when several agents or sub-agents write in one checkout or worktree, before creating or editing files there, when launching sub-agents that will write files, or when `alock` or `agent-lock` answers REFUSED, HELD, STALE or INTERRUPTED WORK.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Claiming files with alo
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
`alock` (the `agent-lock` gem) keeps agents that share a checkout from writing over each other. A lock is advisory: it protects a file only if every writer claims first, under its own name, in the tree it writes in.
|
|
11
|
+
|
|
12
|
+
## Four ways a claim silently protects nothing
|
|
13
|
+
|
|
14
|
+
1. **An unnamed sub-agent signs as its parent.** Sub-agents run inside the parent's process, and every Bash call is a fresh shell, so an `export` from an earlier call is gone. Put the name on the same command line, every time: `AGENT_ID=<your-name> alock ...`. The `(holder: ...)` in the reply must be your name.
|
|
15
|
+
1. **Two siblings with one name are one holder**, and never block each other. Use the name your orchestrator gave you. Given none, pick the task plus four random characters once, such as `shipping-rates-7f3a`, and type that literal on every call; `$RANDOM` in the prefix changes each time.
|
|
16
|
+
1. **A lock in another checkout guards nothing here.** Run `alock` inside the checkout you write in: `cd <checkout> && AGENT_ID=<your-name> alock ...`, or pass `--dir <checkout>`.
|
|
17
|
+
1. **An orchestrator's lock does not keep siblings apart.** It keeps other sessions out. Each sub-agent still claims its own files inside it.
|
|
18
|
+
|
|
19
|
+
## Recipe
|
|
20
|
+
|
|
21
|
+
The orchestrator runs `alock` bare, which makes it the parent, and gives each sub-agent a distinct name and the checkout:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
cd ~/src/shop && alock acquire "src/**" "fanning out the pricing work"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
A sub-agent, under the name it was given. Given none, it uses its own task plus four random characters, chosen once and typed the same on every call; never the example below:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
cd ~/src/shop && AGENT_ID=shipping-rates-7f3a alock whoami # id shipping-rates-7f3a, parent inferred
|
|
31
|
+
cd ~/src/shop && AGENT_ID=shipping-rates-7f3a alock acquire "src/shipping/**" "rate tables"
|
|
32
|
+
cd ~/src/shop && AGENT_ID=shipping-rates-7f3a alock note "src/shipping/**" "rates done, specs red"
|
|
33
|
+
cd ~/src/shop && AGENT_ID=shipping-rates-7f3a alock release-all # yours and your sub-agents', never your parent's
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Write only inside what you claimed, and claim the narrowest scope that covers it. A directory means everything under it; `**` is rarely right. Quote globs.
|
|
37
|
+
|
|
38
|
+
## Reading the answer
|
|
39
|
+
|
|
40
|
+
| Reply | Exit | What to do |
|
|
41
|
+
| :--------------------------------------- | :--- | :---------------------------------------------------------------------------------- |
|
|
42
|
+
| `ACQUIRED`, `ALREADY YOURS` | 0 | Write inside the scope |
|
|
43
|
+
| `REFUSED` then `HELD ... by X` | 1 | Do not write. Work elsewhere, or tell the human who holds it |
|
|
44
|
+
| `REFUSED: ... your parent's whole claim` | 1 | Claim something narrower inside it |
|
|
45
|
+
| `HELD ... STALE` | 1 | The holder is alive but quiet. Ask, or announce, then `alock break` |
|
|
46
|
+
| `INTERRUPTED WORK` | 1 | `alock resume` takes it over with its notes; `alock break` discards it |
|
|
47
|
+
| `alo: ...` | 2 | The command could not run: empty scope, a path outside the tree, a backend mismatch |
|
|
48
|
+
|
|
49
|
+
## Common mistakes
|
|
50
|
+
|
|
51
|
+
| Mistake | Result |
|
|
52
|
+
| :------------------------------------------------------------------ | :----------------------------------------------------------- |
|
|
53
|
+
| `export AGENT_ID=x` in one call, `alock acquire` in the next | The lock is signed by the orchestrator and blocks no sibling |
|
|
54
|
+
| Two sub-agents inventing the same obvious name | They count as one holder, and both get `ALREADY YOURS` |
|
|
55
|
+
| Skipping your own claim because the orchestrator "already holds it" | Two siblings edit one file and the last writer wins |
|
|
56
|
+
| Running `alock` from wherever the shell started | The lock lands in another repository |
|
|
57
|
+
| Writing after a refusal | The collision the lock existed to prevent |
|
|
58
|
+
|
|
59
|
+
Needs `agent-lock` newer than 0.1.0 (`alock version`). Run `alock <command> --help` for flags.
|