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,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+ require "pastel"
5
+ require "dry/cli"
6
+
7
+ require_relative "cli"
8
+
9
+ module Agent
10
+ module Lock
11
+ # The one place this gem touches the outside world.
12
+ #
13
+ # Streams and Kernel arrive as arguments so a test can pass its own and
14
+ # read back what happened. Nothing below this class may call `puts`, touch
15
+ # STDOUT, or call `exit` without a receiver: do that and the suite either
16
+ # cannot see the output or dies in the middle of an example. Aruba's
17
+ # in-process launcher expects exactly this shape, which is what makes the
18
+ # end-to-end specs fast enough to run on every save.
19
+ class Launcher
20
+ DEFAULT_PROGRAM = "agent-lock"
21
+
22
+ extend Forwardable
23
+
24
+ def_delegators :@pastel, :red, :green, :yellow, :blue, :magenta, :cyan, :white,
25
+ :bg_black, :bg_red, :bg_green, :bg_yellow, :bg_blue, :bg_magenta, :bg_cyan, :bg_white,
26
+ :bold, :underline, :italic, :strikethrough
27
+
28
+ attr_accessor :argv, :stdin, :stdout, :stderr, :kernel, :pastel
29
+
30
+ # What a command asked the process to exit with. Commands set it rather
31
+ # than exiting, so that one `kernel.exit` in `ensure` is the only way out.
32
+ #
33
+ # @return [Integer, nil]
34
+ attr_accessor :exit_code
35
+
36
+ # The name the user typed, `alock` or `agent-lock`, for every hint and
37
+ # error prefix. A hint naming the other one may not be on the PATH, or
38
+ # may be the old shell script that shadows this gem's `agent-lock`.
39
+ #
40
+ # @return [String]
41
+ attr_reader :program
42
+
43
+ # The positional signature is fixed by what Aruba's in-process launcher
44
+ # constructs, and by the pattern it comes from, so the cop loses this
45
+ # one. The program name is a keyword so that Aruba, which knows nothing
46
+ # of it, still gets a working default.
47
+ #
48
+ # It is handed in rather than read from `$PROGRAM_NAME` here, because
49
+ # under the test runner that says `rspec`.
50
+ #
51
+ # @param argv [Array<String>]
52
+ # @param program [String] e.g. `File.basename($PROGRAM_NAME)`
53
+ # rubocop:disable-next Metrics/ParameterLists
54
+ def initialize(argv = ARGV,
55
+ stdin = $stdin,
56
+ stdout = $stdout,
57
+ stderr = $stderr,
58
+ kernel = Kernel,
59
+ pastel = Pastel.new(enabled: stdout.respond_to?(:tty?) && stdout.tty?),
60
+ program: DEFAULT_PROGRAM)
61
+ self.argv = Array(argv)
62
+ self.stdin = stdin
63
+ self.stdout = stdout
64
+ self.stderr = stderr
65
+ self.kernel = kernel
66
+ self.pastel = pastel
67
+ @program = program
68
+ end
69
+
70
+ # @return [void] always exits, with 0 unless something said otherwise
71
+ # rubocop:disable-next Metrics/AbcSize
72
+ def execute!
73
+ backend_banner if %w[-h --help].intersect?(argv)
74
+
75
+ code = 0
76
+ Dry::CLI.new(CLI.registry_for(self)).call(arguments: argv, out: stdout, err: stderr)
77
+ rescue SystemExit => e
78
+ # dry-cli exits directly for `--help` and for a bad flag. Catching it
79
+ # keeps that from taking the whole test process down with it.
80
+ code = e.status
81
+ rescue Dry::CLI::Error, Error => e
82
+ stderr.puts(bold(red("ERROR: #{program}: #{e.message}")))
83
+ code = 2
84
+ rescue Interrupt
85
+ stderr.puts(bold(yellow("WARNING: #{program}: interrupted")))
86
+ code = 130
87
+ ensure
88
+ kernel.exit(exit_code || code)
89
+ end
90
+
91
+ private
92
+
93
+ def p(msg = "")
94
+ stdout.puts(msg)
95
+ end
96
+
97
+ # The two backends, ahead of dry-cli's own `--help` for each command, so
98
+ # a reader learns the shape of the choice before any subcommand's flags.
99
+ #
100
+ # @return [void]
101
+ # rubocop:disable-next Metrics/AbcSize
102
+ def backend_banner
103
+ p(bold(yellow("Agent Lock, Version #{green(Agent::Lock::VERSION)}")))
104
+ p
105
+ p(bold(blue("Usage:")))
106
+ p(" alock [command [ subcommand ]] [options]")
107
+ p
108
+ p(bold(blue("Description:")))
109
+ p(" This is a CLI utility aimed at the agents working concurrently in the same")
110
+ p(" environment, sharing filesystem, worktrees, etc. Agent Lock allows fine-grained")
111
+ p(" and effective locking, and can use multiple backends to store and maintain locks.")
112
+ p
113
+ p(cyan(" • Redis-Based Locking"))
114
+ p(" This mechanism uses locally running Redis instance to coordinate access to shared")
115
+ p(" resources (default, if Redis is available and accessible).")
116
+ p
117
+ p(cyan(" • File System Locking"))
118
+ p(" This mechanism uses file system locks to coordinate access to shared resources.")
119
+ p
120
+ p(" You can set the environment variable #{yellow("AGENT_LOCK_BACKEND")} to either")
121
+ p(" 'redis' or 'file' to override the default.")
122
+ p
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,313 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "freeze"
4
+ require_relative "identity"
5
+ require_relative "record"
6
+ require_relative "scope"
7
+ require_relative "store"
8
+ require_relative "tree"
9
+
10
+ module Agent
11
+ module Lock
12
+ # Every decision this gem makes, with nothing printed.
13
+ #
14
+ # Each verb returns a Result: a status the caller switches on, the records
15
+ # involved, and the exit code the CLI should hand back. Keeping the
16
+ # decisions here and the wording in the commands is what lets the whole
17
+ # lifecycle be tested without capturing output.
18
+ class Manager
19
+ Result = Data.define(:status, :records, :message) do
20
+ # @return [Integer] what the process should exit with
21
+ def code = %i[held refused not_found interrupted parent_scope].include?(status) ? 1 : 0
22
+
23
+ # @return [Record, nil] the one record most results are about
24
+ def record = records.first
25
+ end
26
+
27
+ DEFAULT_STALE_MINUTES = 120
28
+
29
+ attr_reader :tree, :store, :identity
30
+
31
+ def initialize(tree: Tree.for, identity: Identity.current, stale_minutes: nil, store: nil)
32
+ @tree = tree
33
+ @store = store || Store.for(tree)
34
+ @identity = identity
35
+ @stale_minutes = stale_minutes
36
+ end
37
+
38
+ # @return [Integer] how long a lock whose holder cannot be checked is
39
+ # trusted for, and how long a live one goes untouched before it is
40
+ # reported stale
41
+ def stale_minutes
42
+ @stale_minutes ||= Integer(ENV.fetch("AGENT_LOCK_STALE_MINUTES", DEFAULT_STALE_MINUTES))
43
+ end
44
+
45
+ # Claim a scope, unless something overlapping stands in the way.
46
+ #
47
+ # The scan for conflicts and the write it justifies happen inside the
48
+ # store's mutex. `create` refuses only an identical scope, so without it
49
+ # ten sessions claiming `lib/**` and `lib/a.rb` at once each saw a clear
50
+ # field and each wrote a lock, leaving overlapping claims that all
51
+ # reported success. Freezing stays outside: it walks the tree and runs
52
+ # `chflags`, and every other session would be waiting on it.
53
+ #
54
+ # @param path [String] a path or a glob
55
+ # @param intent [String]
56
+ # @param enforce [Boolean] also make the matched files unwritable
57
+ # @param force [Boolean] freeze even a very wide scope
58
+ # @return [Result] :acquired, or :already_mine, :held, :parent_scope or
59
+ # :interrupted with the records that explain why not
60
+ def acquire(path, intent: "unspecified", enforce: false, force: false)
61
+ scope = Scope.parse(path, tree: tree)
62
+ outcome = store.synchronize { take(scope, intent) }
63
+ return outcome unless enforce && outcome.status == :acquired
64
+
65
+ enforce_on(outcome.record, scope, force: force)
66
+ end
67
+
68
+ # @param path [String]
69
+ # @return [Result]
70
+ def release(path)
71
+ scope = Scope.parse(path, tree: tree)
72
+ record = store.find(scope)
73
+ return result(:not_found, []) if record.nil?
74
+ return result(:refused, [record]) unless record.held_by?(identity)
75
+
76
+ drop(record)
77
+ result(:released, [record])
78
+ end
79
+
80
+ # Whether this session may write in a scope. A parent's lock alone does
81
+ # not make it :mine, since the child still has to claim its own corner
82
+ # before its siblings can see it there; to the child, that scope is
83
+ # :free to claim.
84
+ #
85
+ # @param path [String]
86
+ # @return [Result] :held, :mine or :free
87
+ def check(path)
88
+ scope = Scope.parse(path, tree: tree)
89
+ reap
90
+ blocking, family = conflicts(scope).partition { |record| record.blocks?(identity) }
91
+ return result(:held, blocking) if blocking.any?
92
+
93
+ own = family.select { |record| record.held_by?(identity) }
94
+ own.any? ? result(:mine, own) : result(:free, [])
95
+ end
96
+
97
+ # @return [Result] every lock in the store, this tree's siblings included
98
+ def list
99
+ reap
100
+ result(:listed, store.all)
101
+ end
102
+
103
+ # @return [Result]
104
+ def mine
105
+ reap
106
+ result(:listed, store.all.select { |record| record.held_by?(identity) })
107
+ end
108
+
109
+ # @return [Result]
110
+ def release_all
111
+ held = store.all.select { |record| record.held_by?(identity) }
112
+ held.each { |record| drop(record) }
113
+ result(:released, held)
114
+ end
115
+
116
+ # @param path [String]
117
+ # @return [Result]
118
+ def break_lock(path)
119
+ scope = Scope.parse(path, tree: tree)
120
+ record = store.find(scope)
121
+ return result(:not_found, []) if record.nil?
122
+
123
+ drop(record)
124
+ result(:broken, [record])
125
+ end
126
+
127
+ # Locks whose holder is provably gone, or, for a holder on another host
128
+ # that cannot be asked, that nobody has touched in so long that nobody
129
+ # can say. A live holder's lock is never among them, however old; see
130
+ # Record#expired?. Reaped before any decision that depends on them,
131
+ # never on a schedule.
132
+ #
133
+ # A lock with nothing written in it is deleted. One whose holder wrote
134
+ # down what it was doing is orphaned instead: the process is gone, the
135
+ # claim is void, but the notes are the only record of work interrupted
136
+ # halfway, and a reboot is the most likely reason there are any. Whoever
137
+ # comes next sees them, and `resume` takes the lock and the notes back.
138
+ #
139
+ # @return [Array<Record>] what it cleared out of the way
140
+ def reap
141
+ store.all.select { |record| record.active? && record.expired?(stale_minutes) }.map do |record|
142
+ record.notes? ? orphan(record) : drop(record)
143
+ record
144
+ end
145
+ end
146
+
147
+ # Write a line into a lock this session holds, so that a machine coming
148
+ # back up has something better than the diff to work out where it was.
149
+ #
150
+ # @param path [String]
151
+ # @param text [String]
152
+ # @return [Result]
153
+ def note(path, text)
154
+ scope = Scope.parse(path, tree: tree)
155
+ record = store.find(scope)
156
+ return result(:not_found, []) if record.nil?
157
+ return result(:refused, [record]) unless record.held_by?(identity)
158
+
159
+ updated = record.note(text)
160
+ store.update(updated)
161
+ result(:noted, [updated])
162
+ end
163
+
164
+ # Take an interrupted lock back, notes and all.
165
+ #
166
+ # Inside the store's mutex, because the check and the write are two
167
+ # steps: two sessions resuming one orphan would otherwise both find it
168
+ # interrupted, both write themselves in, and both report :resumed.
169
+ #
170
+ # @param path [String]
171
+ # @return [Result]
172
+ def resume(path)
173
+ scope = Scope.parse(path, tree: tree)
174
+ store.synchronize do
175
+ record = store.find(scope)
176
+ next result(:not_found, []) if record.nil? || record.active?
177
+
178
+ adopted = record.with(status: Record::ACTIVE, updated_at: Time.now.utc.iso8601, **claim)
179
+ store.update(adopted)
180
+ result(:resumed, [adopted])
181
+ end
182
+ end
183
+
184
+ private
185
+
186
+ # The critical section of #acquire: the scan, and the write it justifies.
187
+ #
188
+ # @param scope [Scope]
189
+ # @param intent [String]
190
+ # @return [Result]
191
+ def take(scope, intent)
192
+ reap
193
+ refusal = why_not(scope)
194
+ return refusal if refusal
195
+
196
+ record = Record.build(scope: scope, tree: tree, identity: identity, intent: intent)
197
+ store.create(record) ? result(:acquired, [record]) : result(:held, conflicts(scope))
198
+ end
199
+
200
+ # Everything standing between this session and the scope it asked for,
201
+ # in the order the caller can do something about.
202
+ #
203
+ # @param scope [Scope]
204
+ # @return [Result, nil] nil when the scope is there to be taken
205
+ def why_not(scope)
206
+ blocking, family = conflicts(scope).partition { |record| record.blocks?(identity) }
207
+ own, inherited = family.partition { |record| record.held_by?(identity) }
208
+
209
+ # First, because no amount of waiting fixes it. Records are keyed by
210
+ # tree and scope, so the parent's record is the one this child would
211
+ # have to write, and `create` would refuse it as though a stranger
212
+ # held the scope.
213
+ umbrella = exactly(scope, inherited)
214
+ return result(:parent_scope, [umbrella]) if umbrella
215
+ return result(:held, blocking) if blocking.any?
216
+
217
+ # Only this session's own lock covers it, and only one that contains
218
+ # the scope. Taking a second lock inside your own would leave a stale
219
+ # one behind on release. A parent's lock does not count: a child that
220
+ # stopped there recorded nothing, and two siblings both "acquired" the
221
+ # same file. Nor does a narrower lock of your own: holding one file and
222
+ # being told the whole tree was yours left the rest of it open.
223
+ covering = covering(scope, own)
224
+ return result(:already_mine, covering) if covering.any?
225
+
226
+ interrupted(scope)
227
+ end
228
+
229
+ # @param scope [Scope]
230
+ # @param records [Array<Record>] this session's own overlapping locks
231
+ # @return [Array<Record>] the ones that contain the whole of `scope`
232
+ def covering(scope, records) = records.select { |record| record.scope_object.covers?(scope) }
233
+
234
+ # @param scope [Scope]
235
+ # @param records [Array<Record>]
236
+ # @return [Record, nil] the one stored under this scope's own id
237
+ def exactly(scope, records) = records.find { |record| record.id == Record.id_for(tree, scope) }
238
+
239
+ # An orphan sitting on this exact scope is somebody's interrupted work.
240
+ # Overwriting it would take the only record of it, so say so and let the
241
+ # caller choose `resume` or `break`.
242
+ #
243
+ # @param scope [Scope]
244
+ # @return [Result, nil]
245
+ def interrupted(scope)
246
+ record = store.find(scope)
247
+ result(:interrupted, [record]) if record&.orphaned?
248
+ end
249
+
250
+ # Who this session is, as a lock records it.
251
+ #
252
+ # @return [Hash]
253
+ def claim
254
+ evidence = identity.evidence
255
+ { agent_id: identity.id, parent_agent_id: identity.parent_id,
256
+ pid: evidence[:pid], started: evidence[:started], host: evidence[:host] }
257
+ end
258
+
259
+ # Freezing happens after the claim is won, never before. Flagging files
260
+ # first and then losing the race would leave a tree full of unwritable
261
+ # files that no lock admits to having frozen, which is the one failure
262
+ # this whole feature is supposed to prevent.
263
+ #
264
+ # @param record [Record] the lock, already created
265
+ # @return [Result]
266
+ def enforce_on(record, scope, force:)
267
+ wanted = Freeze.matches(scope, tree)
268
+ frozen = Freeze.apply(wanted, tree: tree, force: force)
269
+ updated = record.with(frozen_paths: frozen)
270
+ store.update(updated)
271
+
272
+ result(:acquired, [updated], freeze_shortfall(wanted, frozen))
273
+ rescue Freeze::TooBroad
274
+ # The lock stands; the freeze does not. Undoing the claim here would
275
+ # be a second surprise on top of the first.
276
+ drop(record)
277
+ raise
278
+ end
279
+
280
+ # @return [String, nil] said only when the filesystem refused some of it
281
+ def freeze_shortfall(wanted, frozen)
282
+ missed = wanted.size - frozen.size
283
+ return nil if missed.zero?
284
+
285
+ "could not freeze #{missed} of #{wanted.size} file(s); the lock still stands"
286
+ end
287
+
288
+ # Orphaned locks are left out: they carry the notes of a session that
289
+ # died, which is information rather than a claim, and a machine that
290
+ # rebooted should not lock its owner out of their own checkout.
291
+ #
292
+ # @return [Array<Record>]
293
+ def conflicts(scope)
294
+ store.all.select do |record|
295
+ record.active? && record.tree == tree.root && record.scope_object.conflicts_with?(scope)
296
+ end
297
+ end
298
+
299
+ # @param record [Record]
300
+ def orphan(record)
301
+ Freeze.clear(record.frozen_paths, tree: tree)
302
+ store.update(record.with(status: Record::ORPHANED, frozen_paths: [], updated_at: Time.now.utc.iso8601))
303
+ end
304
+
305
+ def drop(record)
306
+ Freeze.clear(record.frozen_paths, tree: tree)
307
+ store.delete(record)
308
+ end
309
+
310
+ def result(status, records, message = nil) = Result.new(status:, records:, message:)
311
+ end
312
+ end
313
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Lock
5
+ # The three questions this gem asks the operating system about a process:
6
+ # what it is called, who its parent is, and when it started.
7
+ #
8
+ # `ps` rather than /proc, because this has to work the same on macOS and on
9
+ # Linux, and because none of it is on a hot path. Every answer is nil when
10
+ # the process is gone, which callers read as "no longer running".
11
+ module ProcessInfo
12
+ module_function
13
+
14
+ # @param pid [Integer]
15
+ # @return [String, nil] the executable's name, without its path
16
+ def command(pid)
17
+ value = ps(pid, "comm")
18
+ value && File.basename(value)
19
+ end
20
+
21
+ # @param pid [Integer]
22
+ # @return [Integer, nil]
23
+ def parent_of(pid)
24
+ value = ps(pid, "ppid")
25
+ value && Integer(value, exception: false)
26
+ end
27
+
28
+ # The wall-clock start time, which is what tells a live process from a
29
+ # different one that inherited its recycled pid.
30
+ #
31
+ # @param pid [Integer]
32
+ # @return [String, nil]
33
+ def started_at(pid) = ps(pid, "lstart")
34
+
35
+ # @param pid [Integer, nil]
36
+ # @param started [String, nil] as recorded when the lock was taken
37
+ # @return [Boolean] whether that exact process is still running
38
+ def alive?(pid, started: nil)
39
+ return false if pid.nil?
40
+
41
+ now = started_at(pid)
42
+ return false if now.nil?
43
+ return true if started.nil? || started.empty?
44
+
45
+ now == started
46
+ end
47
+
48
+ # @return [String, nil] the field, or nil for a process that is gone
49
+ def ps(pid, field)
50
+ return nil if pid.nil?
51
+
52
+ out = IO.popen(["ps", "-p", pid.to_s, "-o", "#{field}="], err: File::NULL, &:read)
53
+ out = out.to_s.strip
54
+ out.empty? ? nil : out
55
+ rescue SystemCallError
56
+ nil
57
+ end
58
+ end
59
+ end
60
+ end