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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/CLAUDE.md +1 -0
  3. data/.envrc +2 -0
  4. data/.rubocop_todo.yml +90 -0
  5. data/.ruby-version +1 -0
  6. data/AGENTS.md +54 -0
  7. data/CHANGELOG.md +48 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +374 -0
  10. data/Rakefile +12 -0
  11. data/exe/agent-lock +8 -0
  12. data/exe/alock +9 -0
  13. data/justfile +127 -0
  14. data/lib/agent/lock/cli/commands/acquire.rb +87 -0
  15. data/lib/agent/lock/cli/commands/base.rb +148 -0
  16. data/lib/agent/lock/cli/commands/break.rb +36 -0
  17. data/lib/agent/lock/cli/commands/check.rb +56 -0
  18. data/lib/agent/lock/cli/commands/completion.rb +50 -0
  19. data/lib/agent/lock/cli/commands/list.rb +70 -0
  20. data/lib/agent/lock/cli/commands/mine.rb +35 -0
  21. data/lib/agent/lock/cli/commands/note.rb +37 -0
  22. data/lib/agent/lock/cli/commands/release.rb +33 -0
  23. data/lib/agent/lock/cli/commands/release_all.rb +24 -0
  24. data/lib/agent/lock/cli/commands/resume.rb +36 -0
  25. data/lib/agent/lock/cli/commands/skill.rb +75 -0
  26. data/lib/agent/lock/cli/commands/version.rb +20 -0
  27. data/lib/agent/lock/cli/commands/whoami.rb +83 -0
  28. data/lib/agent/lock/cli.rb +80 -0
  29. data/lib/agent/lock/error.rb +10 -0
  30. data/lib/agent/lock/freeze.rb +109 -0
  31. data/lib/agent/lock/identity.rb +166 -0
  32. data/lib/agent/lock/launcher.rb +126 -0
  33. data/lib/agent/lock/manager.rb +313 -0
  34. data/lib/agent/lock/process_info.rb +60 -0
  35. data/lib/agent/lock/record.rb +216 -0
  36. data/lib/agent/lock/scope.rb +173 -0
  37. data/lib/agent/lock/skill.rb +104 -0
  38. data/lib/agent/lock/store/file_system_store.rb +161 -0
  39. data/lib/agent/lock/store/redis_store.rb +225 -0
  40. data/lib/agent/lock/store.rb +104 -0
  41. data/lib/agent/lock/tree.rb +126 -0
  42. data/lib/agent/lock/version.rb +7 -0
  43. data/lib/agent/lock.rb +30 -0
  44. data/sig/agent/lock.rbs +6 -0
  45. data/skills/agent-lock/SKILL.md +59 -0
  46. 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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Lock
5
+ VERSION = "0.2.1"
6
+ end
7
+ 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"
@@ -0,0 +1,6 @@
1
+ module Agent
2
+ module Lock
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
@@ -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.