agent-lock 0.2.1 → 2.1.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 13449d302065507190249f6bcf9cea27e2e4ce283a1aa422213122951826d78e
4
- data.tar.gz: db759d6540ea353eda999c77eb02d2b6d8d5887f86e8fad52036ef93774124b6
3
+ metadata.gz: d1f106a6394f81598c390390a2d83aeb051b9e22d799c23129eeed6aba0f0822
4
+ data.tar.gz: d31b63c4c1469eee913e33ca102dcb35f108d7b1aabb24408ec8bdd8b92958d4
5
5
  SHA512:
6
- metadata.gz: 2f56d58d5250a8dda75cae4f6a39b741f2186864bf91296954b6996e36b9cf9984413ddb19c92d96570f2286bfc0b88feea58d1ade5e7fcbb2a8e595e74450b5
7
- data.tar.gz: 9421c3c1397eedbd49fbe3ac735aec9f649ef7938bf4f0d16e2bb70368278e5ff60caf5dc291356e43adef931f23f34c3632f120ed8731a78c182be2b9e2ce7e
6
+ metadata.gz: 8a947455256070a8bc2eb2211f63fdcc970b64e25d479ed57724ac17dbd39a28c4317fcf28465fa91b58942f4a24231af72190c34519640c905c4e04a254b38a
7
+ data.tar.gz: ce09be390c7cbbd93315e7b359fd30efbb53fb064d8b98b849fcb8e3fa59756fb85eec0c46b0c23d837aa87aa5d815d726c152bf4be56f44d48163eaa402310d
data/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [2.1.0]
6
+
7
+ ### Added
8
+
9
+ - Help screens come from `dry-cli-help`: `alock -h` now opens with the gem's title and a description of what it does and how the two backends are chosen, lists commands in registration order, and wraps everything at 85 columns. The hand-written backend banner that used to print above dry-cli's own help is gone.
10
+
11
+ ### Changed
12
+
13
+ - Finding a Redis now cascades: `REDIS_URL` is tried first when it is set, then `redis://127.0.0.1:6379/0`, and only when neither answers does a virgin tree default to the file store. Previously `REDIS_URL` replaced the local default rather than preceding it, so a shared Redis being down sent the tree straight to the filesystem even with one running locally. `RedisStore.url` now reports the URL that actually answered.
14
+
5
15
  ## [0.2.0]
6
16
 
7
17
  ### Added
data/README.md CHANGED
@@ -1,8 +1,17 @@
1
- # Agent::Lock (v0.2.0)
1
+ # Agent::Lock (v2.0.1)
2
2
 
3
3
  [![Ruby](https://github.com/kigster/agent-lock/actions/workflows/main.yml/badge.svg)](https://github.com/kigster/agent-lock/actions/workflows/main.yml)
4
4
 
5
- Advisory file locks for coding agents that share a checkout.
5
+ Advisory locks on files and folders with pluggable backends (redis and file-system provided out the box, with redis being the default if it's available locally). This locking tool is meant to be used by the coding agents that share a checkout.
6
+
7
+ > [!NOTE]
8
+ >
9
+ > Please note that `agent-lock` is part of a three-part system:
10
+ >
11
+ > 1. `agentilda` is the Ruby Gem, which is a CLI tool that creates and manages the `.plans` folder, and comes with eight or so specialized agents that take a spec.md file and work through it until it's a set of PRs open, reviewed, and passing on your CI. It does not automatically merge anything.
12
+ > 2. [`agentilda-ai-setup`](https://kigster/agentilda-ai-setup) is the GitHub repo that's a mixture of BASH and Ruby installers. It's comes with the [`configuration.yml`](https://github.com/kigster/agentilda-ai-setup/blob/main/configuration.example.yml) file, which lists the installation commands for the coding agents you'd like to install locally, any other executables you might want (for instance, it installs `bt` — braintrust's CLI utility), and then you can list any number of Github Repos and use them to install skills, plugins, commands from them, specifying exactly which you want to install and which you want to exclude. Moreover you can specify a sub-directory of a github repo to install from.
13
+ >
14
+ > Together, the three repos, after installation provide you with the consistent way to replicate your `~/.agents` and `~/.claude` folders on multiple computers, and to also replicate a consistent agentic software team workflow across many projects.
6
15
 
7
16
  ## What it is
8
17
 
@@ -316,11 +325,13 @@ Names every command and flag `alock` currently knows, since the script is genera
316
325
  | `AGENT_LOCK_MUTEX_TIMEOUT` | `15` | Seconds a claim waits for the store's mutex before giving up with exit 2 |
317
326
  | `AGENT_LOCK_BACKEND` | `redis` if one answers, else `file` | `file` or `redis` |
318
327
  | `AGENT_LOCK_TTL_SECONDS` | `0` | Redis expiry. `0` means no TTL |
319
- | `REDIS_URL` | `redis://127.0.0.1:6379/0` | Where Redis is |
328
+ | `REDIS_URL` | unset | A shared Redis, tried before `redis://127.0.0.1:6379/0` |
320
329
 
321
330
  ## Backends
322
331
 
323
- Redis stores the same documents as the file store, and buys two things a filesystem cannot: its mutex holds across machines, and a TTL expires an abandoned lock without anybody having to reason about liveness. A tree defaults to Redis when one answers on `REDIS_URL`, and falls back to the file store, which needs nothing installed, when none does.
332
+ Redis stores the same documents as the file store, and buys two things a filesystem cannot: its mutex holds across machines, and a TTL expires an abandoned lock without anybody having to reason about liveness. A tree defaults to Redis when one answers, and falls back to the file store, which needs nothing installed, when none does.
333
+
334
+ The search for a Redis runs in a fixed order: `REDIS_URL` first, when it is set, then `redis://127.0.0.1:6379/0`. A `REDIS_URL` nobody answers on is not the end of it, since the usual reason to set one is a shared instance that is occasionally down, and a local Redis is still a better lock store than the filesystem. Only when neither answers does the tree land on the file store. `alock list` names the store in use, so it is never a guess which one you are on.
324
335
 
325
336
  ```bash
326
337
  AGENT_LOCK_BACKEND=redis alock acquire workflow/** # force it, rather than autodetect
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "manager"
4
+ require_relative "version"
4
5
  require "dry/cli"
6
+ require "dry/cli/help"
5
7
 
6
8
  module Agent
7
9
  module Lock
@@ -15,6 +17,32 @@ module Agent
15
17
  # line, so guessing it wrong is not a cosmetic mistake.
16
18
  PROGRAM_NAME = "alock"
17
19
 
20
+ # What `alock -h` says before it lists the commands. Wrapped by
21
+ # dry-cli-help at HELP_WIDTH rather than the terminal's width, so the
22
+ # screen reads the same in a wide terminal, a narrow one, and a log.
23
+ HELP_WIDTH = 85
24
+
25
+ DESCRIPTION = <<~TEXT
26
+ Advisory locks for AI coding agents working concurrently in one checkout.
27
+ An agent claims the file or directory it is about to write, and every other
28
+ agent that checks sees who holds it and why. Nothing is enforced: the locks
29
+ work because every agent checks before it writes.
30
+
31
+ Locks live in one of two backends. Redis, when one answers on REDIS_URL or
32
+ on localhost:6379, holds across machines and can expire an abandoned lock.
33
+ The file store, used when no Redis answers, needs nothing installed. The
34
+ first process in a tree records which backend it chose, and every later
35
+ process in that tree stays on it. Set AGENT_LOCK_BACKEND to `redis` or
36
+ `file` to choose one yourself.
37
+ TEXT
38
+
39
+ Dry::CLI::Help.configure do
40
+ title "Agent Lock, Version #{Agent::Lock::VERSION}"
41
+ description DESCRIPTION
42
+ epilogue "Documentation: https://github.com/kigster/agent-lock"
43
+ width HELP_WIDTH
44
+ end
45
+
18
46
  # A registry whose commands are already bound to this launcher, so a
19
47
  # command writes to the streams it was given rather than to the process's.
20
48
  #
@@ -70,8 +70,6 @@ module Agent
70
70
  # @return [void] always exits, with 0 unless something said otherwise
71
71
  # rubocop:disable-next Metrics/AbcSize
72
72
  def execute!
73
- backend_banner if %w[-h --help].intersect?(argv)
74
-
75
73
  code = 0
76
74
  Dry::CLI.new(CLI.registry_for(self)).call(arguments: argv, out: stdout, err: stderr)
77
75
  rescue SystemExit => e
@@ -93,34 +91,6 @@ module Agent
93
91
  def p(msg = "")
94
92
  stdout.puts(msg)
95
93
  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
94
  end
125
95
  end
126
96
  end
@@ -20,10 +20,14 @@ module Agent
20
20
  # markdown document either way.
21
21
  #
22
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.
23
+ # REDIS_URL, or failing that on the local default. See Store's moduledoc
24
+ # for how that default is decided.
24
25
  class RedisStore
25
26
  NAMESPACE = "agent-lock"
26
27
 
28
+ # Tried after REDIS_URL, or alone when it is unset.
29
+ LOCAL_URL = "redis://127.0.0.1:6379/0"
30
+
27
31
  # How long the store mutex outlives a holder that died holding it. A
28
32
  # claim's critical section is a SCAN and one SET, so ten seconds is
29
33
  # somebody gone, not somebody busy.
@@ -206,15 +210,59 @@ module Agent
206
210
  end
207
211
 
208
212
  class << self
209
- def url = ENV.fetch("REDIS_URL", "redis://127.0.0.1:6379/0")
213
+ # The URL of the Redis actually in use, once one has answered, or
214
+ # the first one that will be tried before then.
215
+ #
216
+ # @return [String]
217
+ def url = @url || candidate_urls.first
218
+
219
+ # Where to look, in order: `REDIS_URL` when set, then the local
220
+ # default. A `REDIS_URL` that nobody answers on is not the end of
221
+ # the search, because the usual reason it is set is a shared
222
+ # instance that is sometimes down, and a local one is still better
223
+ # than falling all the way back to the filesystem.
224
+ #
225
+ # @return [Array<String>]
226
+ def candidate_urls
227
+ configured = ENV["REDIS_URL"].to_s.strip
228
+ [configured.empty? ? nil : configured, LOCAL_URL].compact.uniq
229
+ end
210
230
 
231
+ # Tries each candidate URL in turn and keeps the first that answers.
232
+ #
211
233
  # @return Array[RedisClient,NilClass,Exception] the client if it could be created,
212
- # or the error that prevented it
234
+ # or the error that prevented it (the last one tried)
213
235
  def create_client
214
- @client ||= ::Redis.new(url: url).tap do |client|
215
- _version = client.info["redis_version"]
236
+ return [@client, nil] if @client
237
+
238
+ error = nil
239
+ candidate_urls.each do |candidate|
240
+ client, error = connect(candidate)
241
+ next unless client
242
+
243
+ @url = candidate
244
+ @client = client
245
+ return [@client, nil]
216
246
  end
217
- [@client, nil]
247
+ [nil, error]
248
+ end
249
+
250
+ # Drops the memoized client, so the next `create_client` probes again.
251
+ #
252
+ # @return [void]
253
+ def reset!
254
+ @client = nil
255
+ @url = nil
256
+ end
257
+
258
+ private
259
+
260
+ # @param candidate [String]
261
+ # @return Array[RedisClient,NilClass,Exception]
262
+ def connect(candidate)
263
+ client = ::Redis.new(url: candidate)
264
+ client.info["redis_version"]
265
+ [client, nil]
218
266
  rescue Redis::CannotConnectError, Redis::BaseError, Errno::ECONNREFUSED, SocketError => e
219
267
  [nil, e]
220
268
  end
@@ -12,7 +12,8 @@ module Agent
12
12
  #
13
13
  # AGENT_LOCK_BACKEND wins when set. Otherwise a tree that already has a
14
14
  # marker keeps using it. A virgin tree defaults to Redis when one answers
15
- # locally, file when none does.
15
+ # on REDIS_URL or, failing that, on the local default; file when neither
16
+ # does.
16
17
  #
17
18
  # That default is still never a runtime auto-*switch*: once a marker
18
19
  # exists, every later process in that tree is bound to it regardless of
@@ -90,7 +91,8 @@ module Agent
90
91
 
91
92
  def marker_path(tree) = File.join(tree.store_dir, MARKER)
92
93
 
93
- # @return [String] "redis" when one answers on REDIS_URL, "file" otherwise
94
+ # @return [String] "redis" when one answers on REDIS_URL or, failing
95
+ # that, on RedisStore::LOCAL_URL; "file" when neither does
94
96
  def default_backend
95
97
  local_redis_available? ? "redis" : "file"
96
98
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Agent
4
4
  module Lock
5
- VERSION = "0.2.1"
5
+ VERSION = "2.1.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: agent-lock
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Konstantin Gredeskoul
@@ -29,14 +29,42 @@ dependencies:
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '0.1'
32
+ version: '0.5'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '0.1'
39
+ version: '0.5'
40
+ - !ruby/object:Gem::Dependency
41
+ name: dry-cli-help
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.5'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.5'
54
+ - !ruby/object:Gem::Dependency
55
+ name: dry-cli-ui
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.5'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.5'
40
68
  - !ruby/object:Gem::Dependency
41
69
  name: pastel
42
70
  requirement: !ruby/object:Gem::Requirement
@@ -65,10 +93,22 @@ dependencies:
65
93
  - - "~>"
66
94
  - !ruby/object:Gem::Version
67
95
  version: '5.0'
68
- description: 'A CLI an agent runs before it writes: claim a path or a glob, see who
69
- holds one, record progress inside the lock, and pick the work back up after a crash.
70
- Identity belongs to the session rather than the process, so a lock taken by one
71
- command can be released by the next.'
96
+ description: 'This compact ruby gem is purely CLI utility: it''s meant to be used
97
+ by a team of AI agentsexecuting along one or more the plans in a given repo. Using
98
+ worktrees and parallelism it''s easy to 10x the speed of develpoment of software
99
+ development compared to even a single agent working on it. Plus each agent can specialize.
100
+ Such agents require globsl shared locking mechanism to ensure they are not working
101
+ on the same directory, or the same files in same worktree. That is exactly what
102
+ this gem does. The CLI binary you invoke is called ''alock''which comes with sub-command
103
+ ''completion'' which you can load for BASH or ZSH. However, you are not very likely
104
+ going toinvoke this gem directly. It''s used by an Agentic Workflow gem ''agentilda''
105
+ to protect shared resources. To try the entire system, it''s recommended to download
106
+ the repo https://github.com/kigster/agentilda-ai-setup which both installs the two
107
+ gems, and offers a configuration file that installs a set of coding agents, skills,
108
+ plugins, commands, from various guthub folders, or via running commands, and so
109
+ on. In other words the repo''s purpose is to ensure your agentic setup is identical
110
+ from machine to machine, and by modifying the config file you can pick and choose
111
+ your skills, your AGENT.md/CLAUDE.md file and so on.'
72
112
  email:
73
113
  - kigster@gmail.com
74
114
  executables:
@@ -78,8 +118,6 @@ extensions: []
78
118
  extra_rdoc_files: []
79
119
  files:
80
120
  - ".claude/CLAUDE.md"
81
- - ".envrc"
82
- - ".rubocop_todo.yml"
83
121
  - ".ruby-version"
84
122
  - AGENTS.md
85
123
  - CHANGELOG.md
@@ -144,7 +182,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
144
182
  - !ruby/object:Gem::Version
145
183
  version: '0'
146
184
  requirements: []
147
- rubygems_version: 4.0.20
185
+ rubygems_version: 4.0.21
148
186
  specification_version: 4
149
- summary: Advisory locks for the several coding agents that end up in one checkout
187
+ summary: Advisory locks to prevent race conditions and general mayhem when multiple
188
+ AI agents unknowinly modify the same souce three.
150
189
  test_files: []
data/.envrc DELETED
@@ -1,2 +0,0 @@
1
- PATH_add bin
2
- PATH_add exe
data/.rubocop_todo.yml DELETED
@@ -1,90 +0,0 @@
1
- # This configuration was generated by
2
- # `rubocop --auto-gen-config --no-auto-gen-timestamp`
3
- # using RuboCop version 1.91.0.
4
- # The point is for the user to remove these configuration records
5
- # one by one as the offenses are removed from the code base.
6
- # Note that changes in the inspected code, or installation of new
7
- # versions of RuboCop, may require this file to be generated again.
8
-
9
- # Offense count: 1
10
- Gemspec/RequiredRubyVersion:
11
- Exclude:
12
- - 'agent-lock.gemspec'
13
-
14
- # Offense count: 3
15
- # Configuration parameters: Prefixes, AllowedPatterns.
16
- # Prefixes: when, with, without
17
- RSpec/ContextWording:
18
- Exclude:
19
- - 'spec/agent/lock/tree_spec.rb'
20
- - 'spec/support/aruba.rb'
21
- - 'spec/support/checkout.rb'
22
-
23
- # Offense count: 3
24
- # Configuration parameters: IgnoredMetadata.
25
- RSpec/DescribeClass:
26
- Exclude:
27
- - '**/spec/features/**/*'
28
- - '**/spec/requests/**/*'
29
- - '**/spec/routing/**/*'
30
- - '**/spec/system/**/*'
31
- - '**/spec/views/**/*'
32
- - 'spec/agent/lock/concurrency_spec.rb'
33
- - 'spec/agent/lock/loading_spec.rb'
34
- - 'spec/agent/lock/manager_spec.rb'
35
-
36
- # Offense count: 1
37
- # This cop supports unsafe autocorrection (--autocorrect-all).
38
- # Configuration parameters: SkipBlocks, EnforcedStyle, OnlyStaticConstants.
39
- # SupportedStyles: described_class, explicit
40
- RSpec/DescribedClass:
41
- Exclude:
42
- - 'spec/agent/lock/manager_spec.rb'
43
-
44
- # Offense count: 46
45
- # Configuration parameters: CountAsOne.
46
- RSpec/ExampleLength:
47
- Max: 20
48
-
49
- # Offense count: 2
50
- # This cop supports unsafe autocorrection (--autocorrect-all).
51
- RSpec/IncludeExamples:
52
- Exclude:
53
- - 'spec/agent/lock/concurrency_spec.rb'
54
-
55
- # Offense count: 5
56
- # Configuration parameters: AssignmentOnly.
57
- RSpec/InstanceVariable:
58
- Exclude:
59
- - 'spec/agent/lock/tree_spec.rb'
60
- - 'spec/support/checkout.rb'
61
-
62
- # Offense count: 2
63
- RSpec/LeakyLocalVariable:
64
- Exclude:
65
- - 'spec/agent/lock/loading_spec.rb'
66
-
67
- # Offense count: 2
68
- # Configuration parameters: EnforcedStyle.
69
- # SupportedStyles: have_received, receive
70
- RSpec/MessageSpies:
71
- Exclude:
72
- - 'spec/agent/lock/freeze_spec.rb'
73
- - 'spec/agent/lock/store_spec.rb'
74
-
75
- # Offense count: 1
76
- RSpec/MultipleDescribes:
77
- Exclude:
78
- - 'spec/agent/lock/manager_spec.rb'
79
-
80
- # Offense count: 6
81
- RSpec/MultipleExpectations:
82
- Max: 2
83
-
84
- # Offense count: 1
85
- # Configuration parameters: AllowedClasses.
86
- Style/OneClassPerFile:
87
- Exclude:
88
- - 'spec/**/*'
89
- - 'test/**/*'
90
- - 'lib/agent/lock/cli.rb'