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,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "process_info"
4
+ require_relative "scope"
5
+
6
+ require "yaml"
7
+ require "time"
8
+ require "digest"
9
+ require "socket"
10
+
11
+ module Agent
12
+ module Lock
13
+ # One lock, on disk.
14
+ #
15
+ # A markdown file with YAML front matter, rather than plain YAML, because
16
+ # the interesting half of a lock is the sentence saying what the holder is
17
+ # doing. An agent that finds a file locked can read that and decide whether
18
+ # to wait or to work elsewhere; "occupied" tells it nothing. A human
19
+ # opening the file in an editor sees the same thing.
20
+ #
21
+ # ---
22
+ # agent_id: claude-9feca100
23
+ # scope: workflow/**
24
+ # pid: 69232
25
+ # ---
26
+ # Rewriting the installer's filter pair.
27
+ class Record
28
+ FIELDS = %i[id agent_id parent_agent_id scope tree worktree pid started host
29
+ created_at updated_at status frozen_paths].freeze
30
+
31
+ ACTIVE = "active"
32
+ ORPHANED = "orphaned"
33
+ SEPARATOR = "---"
34
+ NOTES_HEADING = "## Progress"
35
+
36
+ attr_reader(*FIELDS, :intent, :path)
37
+
38
+ class << self
39
+ # @param scope [Scope]
40
+ # @param tree [Tree]
41
+ # @param identity [Identity]
42
+ # @param intent [String]
43
+ # @return [Record]
44
+ def build(scope:, tree:, identity:, intent:)
45
+ evidence = identity.evidence
46
+ new(
47
+ id: id_for(tree, scope), agent_id: identity.id, parent_agent_id: identity.parent_id,
48
+ scope: scope.to_s, tree: tree.root, worktree: tree.worktree?,
49
+ pid: evidence[:pid], started: evidence[:started], host: evidence[:host],
50
+ created_at: Time.now.utc.iso8601, status: ACTIVE, frozen_paths: [], intent: intent
51
+ )
52
+ end
53
+
54
+ # Derived from the tree and the scope so that a second run looking for
55
+ # the same lock finds it without reading every file in the store.
56
+ #
57
+ # @return [String]
58
+ def id_for(tree, scope)
59
+ digest = Digest::SHA256.hexdigest("#{tree.root}\0#{scope}")[0, 8]
60
+ "#{scope.slug}-#{digest}"
61
+ end
62
+
63
+ # @param path [String]
64
+ # @return [Record, nil] nil for a file this gem did not write
65
+ def read(path)
66
+ parse(File.read(path), path: path)
67
+ rescue Errno::ENOENT, Errno::EISDIR
68
+ nil
69
+ end
70
+
71
+ # @param text [String] a lock document, from a file or from Redis
72
+ # @return [Record, nil]
73
+ def parse(text, path: nil)
74
+ _, front, body = text.to_s.split(/^#{SEPARATOR}\s*$/, 3)
75
+ data = YAML.safe_load(front.to_s, permitted_classes: [], aliases: false)
76
+ return nil unless data.is_a?(Hash)
77
+
78
+ new(**data.transform_keys(&:to_sym).slice(*FIELDS), intent: body.to_s.strip, path: path)
79
+ rescue Psych::Exception, ArgumentError
80
+ nil
81
+ end
82
+ end
83
+
84
+ def initialize(intent: "", path: nil, **fields)
85
+ FIELDS.each { |field| instance_variable_set(:"@#{field}", fields[field]) }
86
+ @frozen_paths = Array(@frozen_paths)
87
+ @intent = intent.to_s
88
+ @path = path
89
+ end
90
+
91
+ # @param changes [Hash] fields to replace
92
+ # @return [Record] a copy, since a record on disk is not edited in place
93
+ def with(intent: self.intent, **changes)
94
+ fields = FIELDS.to_h { |field| [field, public_send(field)] }
95
+ self.class.new(**fields, **changes, intent: intent, path: path)
96
+ end
97
+
98
+ # @return [String] the file's whole content
99
+ def to_markdown
100
+ front = FIELDS.to_h { |field| [field.to_s, public_send(field)] }.compact
101
+ "#{YAML.dump(front)}#{SEPARATOR}\n\n#{intent.strip}\n"
102
+ end
103
+
104
+ # @return [Boolean] the holder is gone, but the work it recorded is not
105
+ def orphaned? = status == ORPHANED
106
+
107
+ # @return [Boolean] a claim anybody has to respect. An orphaned lock is a
108
+ # message left for whoever comes next, not a claim, so it blocks nobody.
109
+ def active? = !orphaned?
110
+
111
+ # What the holder has written down since taking the lock.
112
+ #
113
+ # A lock outlives a reboot; the session that took it does not. Notes are
114
+ # kept in the lock itself, with the same lifespan, so that coming back to
115
+ # a tree after a crash is reading one file rather than guessing.
116
+ #
117
+ # @param text [String]
118
+ # @return [Record] a copy carrying the note
119
+ def note(text)
120
+ body = notes? ? intent : "#{intent}\n\n#{NOTES_HEADING}"
121
+ with(intent: "#{body}\n- #{Time.now.utc.iso8601} #{text.strip}", updated_at: Time.now.utc.iso8601)
122
+ end
123
+
124
+ # @return [Boolean] whether anything worth keeping was written down
125
+ def notes? = intent.include?(NOTES_HEADING)
126
+
127
+ # @return [Scope]
128
+ def scope_object = @scope_object ||= Scope.new(scope)
129
+
130
+ # Two different questions, deliberately not one.
131
+ #
132
+ # Ownership answers "may I release this, write notes in it, and does
133
+ # `mine` list it": yes for my own locks and my sub-agents', never for my
134
+ # parent's. A session cleaning up after itself has to be able to take
135
+ # its children's locks with it, or a crashed sub-agent's claim outlives
136
+ # everybody. The reverse is how a child's `release-all` used to drop the
137
+ # umbrella its parent had just fanned out under.
138
+ #
139
+ # @param identity [Identity]
140
+ # @return [Boolean]
141
+ def held_by?(identity) = mine?(identity) || descendant_of?(identity)
142
+
143
+ # Blocking answers "may I claim an overlapping scope": everything except
144
+ # my own lock and my parent's. The one that matters is the sibling: two
145
+ # sub-agents of one session, let loose in the same tree, are exactly the
146
+ # pair this gem exists to keep apart, and treating the whole family as
147
+ # one holder would let them write over each other freely.
148
+ #
149
+ # A child's lock blocks its parent too. The parent handed that scope out;
150
+ # taking it back while the child is still in there is the same collision
151
+ # from the other direction.
152
+ #
153
+ # @param identity [Identity]
154
+ # @return [Boolean]
155
+ def blocks?(identity) = !(mine?(identity) || ancestor_of?(identity))
156
+
157
+ # @return [Boolean] the holder's process is still running, on this host
158
+ def alive?
159
+ return true unless same_host?
160
+
161
+ ProcessInfo.alive?(pid, started: started)
162
+ end
163
+
164
+ # Whether the claim is void, so reaping it takes nothing from anybody.
165
+ #
166
+ # On this host the holder can be asked, and its answer is the only one
167
+ # that counts: a live holder's lock is never expired, however old. Age
168
+ # used to count here too, measured from `created_at`, so a session two
169
+ # hours into a refactor lost its lock mid-edit and the next agent walked
170
+ # straight in. A holder on another host cannot be asked, so time is all
171
+ # there is, measured from its last write so that notes act as a
172
+ # heartbeat.
173
+ #
174
+ # @param minutes [Integer] how long an unverifiable lock is trusted
175
+ # @return [Boolean]
176
+ def expired?(minutes) = same_host? ? !alive? : untouched_for?(minutes)
177
+
178
+ # A claim still standing, whose holder has not touched it in longer than
179
+ # anybody should need. Reported, never acted on: the holder may be alive
180
+ # and simply slow, so breaking it is a decision somebody announces.
181
+ #
182
+ # @param minutes [Integer]
183
+ # @return [Boolean]
184
+ def stale?(minutes) = active? && untouched_for?(minutes)
185
+
186
+ # @return [String] one line, for a listing
187
+ def summary
188
+ where = worktree ? "#{tree} (worktree)" : tree
189
+ "#{scope}\t#{agent_id}\t#{created_at}\t#{where}"
190
+ end
191
+
192
+ private
193
+
194
+ def mine?(identity) = agent_id == identity.id
195
+
196
+ # The lock belongs to the session that spawned this one.
197
+ def ancestor_of?(identity) = !identity.parent_id.nil? && agent_id == identity.parent_id
198
+
199
+ # The lock belongs to a sub-agent this session spawned.
200
+ def descendant_of?(identity) = !parent_agent_id.nil? && parent_agent_id == identity.id
201
+
202
+ def same_host? = host.nil? || host == Socket.gethostname
203
+
204
+ # A lock with no readable timestamp is left alone rather than guessed
205
+ # at: reaping on a parse error would delete live claims.
206
+ def untouched_for?(minutes)
207
+ touched = updated_at || created_at
208
+ return false if touched.nil?
209
+
210
+ Time.now.utc - Time.parse(touched) > minutes * 60
211
+ rescue ArgumentError
212
+ false
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+
5
+ module Agent
6
+ module Lock
7
+ # What a lock covers: one path, or a glob over many.
8
+ #
9
+ # `**` is the whole tree, `workflow/**` a corner of it, `lib/a.rb` a single
10
+ # file. A directory is taken to mean everything under it, since an agent
11
+ # that says it is working in `docs` is not promising to leave `docs/api`
12
+ # alone.
13
+ #
14
+ # Two scopes conflict when either one's fixed part contains the other's.
15
+ # `workflow/**` and `workflow/lib/cli.rb` conflict; `docs/**` and
16
+ # `workflow/**` do not. Comparing the fixed parts rather than trying to
17
+ # intersect two globs is deliberate: glob intersection has answers nobody
18
+ # can predict, and the failure it would buy is two agents editing one file.
19
+ # This errs the other way, toward refusing work that might have been safe.
20
+ class Scope
21
+ # A scope the tree cannot honestly lock: empty, or somewhere else. Raised
22
+ # rather than guessed at, since every guess so far locked the wrong thing
23
+ # and reported success. An `Error`, so the launcher exits 2 with the
24
+ # message, which is written for the agent that has to fix its argument.
25
+ class Invalid < Error; end
26
+
27
+ ALL = "**"
28
+
29
+ # The spellings of the whole tree, taken literally wherever the agent
30
+ # stands.
31
+ WHOLE_TREE = [".", "*", ALL].freeze
32
+
33
+ EMPTY = "empty scope: pass ** to claim the whole tree"
34
+
35
+ # @return [String] the pattern, relative to the tree root
36
+ attr_reader :pattern
37
+
38
+ class << self
39
+ # What the user typed, as a pattern relative to the tree root.
40
+ #
41
+ # An empty scope used to mean the whole tree, so `alock acquire "$SCOPE"`
42
+ # with the variable unset claimed everything, and nobody asked for
43
+ # that. Globs used to skip the tree entirely, so `../x/**` was stored
44
+ # verbatim and `/abs/tree/lib/**` never met `lib/**`. Both are read
45
+ # the way a plain path is now.
46
+ #
47
+ # @param path [String] a path or a glob, absolute or relative
48
+ # @param tree [Tree]
49
+ # @return [Scope]
50
+ # @raise [Invalid] when the scope is empty, or resolves outside the tree
51
+ def parse(path, tree:)
52
+ text = path.to_s.strip
53
+ raise Invalid, EMPTY if text.empty?
54
+ return new(ALL) if WHOLE_TREE.include?(text)
55
+
56
+ text = glob?(text) ? relative_glob(text, tree) : tree.relative(text)
57
+ return new(ALL) if text == "."
58
+
59
+ text = "#{text}/#{ALL}" if directory?(text, tree)
60
+ new(text)
61
+ end
62
+
63
+ # @param text [String]
64
+ # @return [Boolean] whether it has a wildcard anywhere in it
65
+ def glob?(text) = text.match?(/[*?\[{]/)
66
+
67
+ # @param text [String] relative to the tree root
68
+ # @param tree [Tree]
69
+ # @return [Boolean] a plain path naming a directory that exists
70
+ def directory?(text, tree)
71
+ return false if glob?(text)
72
+
73
+ File.directory?(File.join(tree.root, text))
74
+ end
75
+
76
+ private
77
+
78
+ # A glob with its fixed part, everything before the segment holding
79
+ # the first wildcard, read through the tree as a plain path would be.
80
+ # The wildcards are kept as typed.
81
+ #
82
+ # @param text [String] a glob, absolute or relative
83
+ # @param tree [Tree]
84
+ # @return [String] the glob relative to the tree root
85
+ # @raise [Invalid] when the fixed part is outside the tree, or a `..`
86
+ # follows a wildcard
87
+ def relative_glob(text, tree)
88
+ fixed, wild = split(text)
89
+ # The fixed part is what was checked against the tree; a `..` after a
90
+ # wildcard climbs past it to somewhere nobody checked.
91
+ if wild.split("/").include?("..")
92
+ raise Invalid, "#{text}: a .. after a wildcard could leave the tree; spell the path without it"
93
+ end
94
+
95
+ base = tree.relative(fixed.empty? ? "." : fixed)
96
+ base == "." ? wild : "#{base}/#{wild}"
97
+ end
98
+
99
+ # @param text [String] a glob
100
+ # @return [Array(String, String)] the fixed part, "/" for a glob at the
101
+ # filesystem root, and the rest from the first wildcard's segment on
102
+ def split(text)
103
+ cut = text.rindex("/", text.index(/[*?\[{]/))
104
+ return ["", text] if cut.nil?
105
+ return ["/", text[1..]] if cut.zero?
106
+
107
+ [text[0...cut], text[(cut + 1)..]]
108
+ end
109
+ end
110
+
111
+ def initialize(pattern)
112
+ @pattern = pattern.to_s.squeeze("/").delete_prefix("./")
113
+ end
114
+
115
+ # The leading segments with no wildcard in them, which is the deepest
116
+ # directory a pattern is certainly confined to.
117
+ #
118
+ # @return [String] "" for a pattern that starts with a wildcard
119
+ def fixed_part
120
+ @fixed_part ||= pattern.split("/").take_while { |part| !self.class.glob?(part) }.join("/")
121
+ end
122
+
123
+ # @param other [Scope]
124
+ # @return [Boolean]
125
+ def conflicts_with?(other)
126
+ return true if pattern == other.pattern
127
+
128
+ contains?(fixed_part, other.fixed_part) || contains?(other.fixed_part, fixed_part)
129
+ end
130
+
131
+ # Whether holding this scope already means holding `other`, which is a
132
+ # stricter question than whether the two overlap. Only a scope that
133
+ # takes everything under a directory can promise that; a partial glob
134
+ # such as `lib/*.rb` covers nothing but itself, since working out what
135
+ # else it matches is the guessing #conflicts_with? refuses to do.
136
+ #
137
+ # @example
138
+ # Scope.new("lib/**").covers?(Scope.new("lib/cli.rb")) # => true
139
+ # Scope.new("lib/cli.rb").covers?(Scope.new("lib/**")) # => false
140
+ #
141
+ # @param other [Scope]
142
+ # @return [Boolean]
143
+ def covers?(other)
144
+ return true if pattern == other.pattern
145
+
146
+ recursive? && contains?(fixed_part, other.fixed_part)
147
+ end
148
+
149
+ # @return [Boolean] the whole tree, or everything under one directory
150
+ def recursive? = [ALL, "#{fixed_part}/#{ALL}"].include?(pattern)
151
+
152
+ # @return [String] safe to use in a filename
153
+ def slug
154
+ text = pattern.gsub("**", "all").gsub(%r{[^A-Za-z0-9._/-]}, "").tr("/", "-").squeeze("-")
155
+ text = text.delete_prefix("-").delete_suffix("-")
156
+ text.empty? ? "tree" : text[0, 60]
157
+ end
158
+
159
+ def to_s = pattern
160
+
161
+ def ==(other) = other.is_a?(Scope) && pattern == other.pattern
162
+
163
+ private
164
+
165
+ # @return [Boolean] whether `outer` is `inner` or an ancestor of it
166
+ def contains?(outer, inner)
167
+ return true if outer.empty? || outer == inner
168
+
169
+ inner.start_with?("#{outer}/")
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Agent
6
+ module Lock
7
+ # The skill this gem ships: a SKILL.md that teaches an agent how to claim
8
+ # files with `alock`, copied into whichever skills directory the agent reads.
9
+ #
10
+ # It lives inside the gem rather than in a repository of its own so that
11
+ # the instructions an agent follows can never describe a different version
12
+ # of the tool than the one it runs.
13
+ #
14
+ # @example
15
+ # Agent::Lock::Skill.new(into: "~/.claude/skills").install.status # => :installed
16
+ class Skill
17
+ # The skill's directory name, which is also the name it answers to.
18
+ NAME = "agent-lock"
19
+
20
+ # Where the gem keeps its skills, one directory each.
21
+ ROOT = File.expand_path("../../../skills", __dir__)
22
+
23
+ # What #install did, and where.
24
+ Result = Data.define(:status, :path) do
25
+ # @return [Integer] 1 when the install was refused, 0 otherwise
26
+ def code = %i[differs linked].include?(status) ? 1 : 0
27
+ end
28
+
29
+ class << self
30
+ # @return [String] the bundled skill's directory
31
+ def source = File.join(ROOT, NAME)
32
+
33
+ # @return [String] where most agents other than Claude Code look for
34
+ # a user's own skills; the CLI's `--for claude` points at
35
+ # `~/.claude/skills` instead
36
+ def default_into = File.join(Dir.home, ".agents", "skills")
37
+ end
38
+
39
+ # @return [String] the skills directory being installed into
40
+ attr_reader :into
41
+
42
+ # @return [String] the skill directory being copied
43
+ attr_reader :source
44
+
45
+ # @param into [String] a skills directory, such as ~/.claude/skills
46
+ # @param source [String] the skill to copy, the bundled one by default
47
+ def initialize(into: self.class.default_into, source: self.class.source)
48
+ @into = File.expand_path(into)
49
+ @source = source
50
+ end
51
+
52
+ # @return [String] the directory the skill ends up in
53
+ def target = File.join(into, NAME)
54
+
55
+ # Copy the skill in, unless something is already there that this would
56
+ # destroy. A copy that differs may be one somebody edited on purpose,
57
+ # and a symlink is another installer's, such as a dotfiles repository
58
+ # that links every skill it manages; replacing either without being
59
+ # asked is the silent last-writer-wins this gem exists to prevent.
60
+ #
61
+ # @param force [Boolean] replace a copy that differs; never a symlink
62
+ # @return [Result] :installed, :current, :differs or :linked
63
+ def install(force: false)
64
+ refused = refusal(force)
65
+ return refused if refused
66
+
67
+ FileUtils.rm_rf(target)
68
+ FileUtils.mkdir_p(into)
69
+ FileUtils.cp_r(source, target)
70
+ result(:installed)
71
+ end
72
+
73
+ private
74
+
75
+ # @param force [Boolean]
76
+ # @return [Result, nil] why nothing should be copied, or nil to go ahead
77
+ def refusal(force)
78
+ return result(:linked) if File.symlink?(target)
79
+ return result(:current) if current?
80
+
81
+ result(:differs) if File.exist?(target) && !force
82
+ end
83
+
84
+ # @param status [Symbol]
85
+ # @return [Result]
86
+ def result(status) = Result.new(status: status, path: target)
87
+
88
+ # @return [Boolean] the same files are there, byte for byte
89
+ def current?
90
+ return false unless File.directory?(target)
91
+
92
+ wanted = files(source)
93
+ wanted == files(target) &&
94
+ wanted.all? { |rel| FileUtils.compare_file(File.join(source, rel), File.join(target, rel)) }
95
+ end
96
+
97
+ # @param dir [String]
98
+ # @return [Array<String>] every file under it, relative and sorted
99
+ def files(dir)
100
+ Dir.glob("**/*", File::FNM_DOTMATCH, base: dir).select { |rel| File.file?(File.join(dir, rel)) }.sort
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../error"
4
+ require_relative "../record"
5
+
6
+ require "fileutils"
7
+
8
+ module Agent
9
+ module Lock
10
+ module Store
11
+ # Locks as files, which is the default and the one that needs nothing
12
+ # installed. See Tree#store_dir for why they live inside `.git`.
13
+ class FileSystemStore
14
+ SUFFIX = ".lock.md"
15
+ MUTEX = ".mutex"
16
+
17
+ # Seconds. A claim's critical section is a directory scan and one
18
+ # write, so anything near this is somebody stuck, not somebody busy.
19
+ MUTEX_TIMEOUT = 15
20
+
21
+ attr_reader :tree
22
+
23
+ def initialize(tree)
24
+ @tree = tree
25
+ end
26
+
27
+ # @return [String]
28
+ def dir = tree.store_dir
29
+
30
+ # @return [String] how a listing names this store
31
+ def describe = dir
32
+
33
+ # FNM_DOTMATCH is load-bearing. A scope like `.plans/**` slugs to a
34
+ # filename that starts with a dot, and a plain glob skips it, so the
35
+ # lock was written, listed nowhere, and blocked nobody. Redis matches
36
+ # those keys either way, and a store that enumerates less than it holds
37
+ # is worse than no store at all.
38
+ #
39
+ # @return [Array<Record>] every lock here, other worktrees included
40
+ def all
41
+ Dir.glob(File.join(dir, "*#{SUFFIX}"), File::FNM_DOTMATCH)
42
+ .sort
43
+ .filter_map { |path| Record.read(path) }
44
+ end
45
+
46
+ # Runs the block with every other process in this store shut out, so a
47
+ # scan for conflicts and the write it justifies cannot be interleaved.
48
+ # O_EXCL alone settles a race for one scope, but `lib/**` and
49
+ # `lib/a1.rb` are two files, and two agents that both scanned an empty
50
+ # store before either wrote both won.
51
+ #
52
+ # An exclusive flock on `.mutex` beside the locks. The kernel drops it
53
+ # when the holder exits, crash included, so a dead agent cannot leave
54
+ # the store wedged. A live one that stops mid-claim can, which is why
55
+ # the wait is bounded and ends in an error rather than a hung agent.
56
+ #
57
+ # Not re-entrant. flock belongs to an open file, not to a process, so
58
+ # a nested call opens a second one, waits on the first, and raises
59
+ # once the timeout runs out.
60
+ #
61
+ # @yield the critical section
62
+ # @return [Object] whatever the block returns
63
+ # @raise [Error] when the mutex stayed held for longer than the timeout
64
+ def synchronize
65
+ FileUtils.mkdir_p(dir)
66
+ # Closing the file releases the flock, and the block form closes it
67
+ # on the way out whether the critical section returned or raised.
68
+ File.open(mutex_path, File::RDWR | File::CREAT, 0o644) do |file|
69
+ wait_for(file)
70
+ yield
71
+ end
72
+ end
73
+
74
+ # @param scope [Scope]
75
+ # @return [Record, nil]
76
+ def find(scope) = Record.read(path_for(Record.id_for(tree, scope)))
77
+
78
+ # Atomic: two agents racing for one scope cannot both win, because only
79
+ # one File::EXCL create succeeds.
80
+ #
81
+ # @param record [Record]
82
+ # @return [Boolean] false when somebody else got there first
83
+ def create(record)
84
+ FileUtils.mkdir_p(dir)
85
+ File.open(path_for(record.id), File::WRONLY | File::CREAT | File::EXCL) do |file|
86
+ file.write(record.to_markdown)
87
+ end
88
+ true
89
+ rescue Errno::EEXIST
90
+ false
91
+ end
92
+
93
+ # Replace a lock in place. Written beside itself and renamed, so a
94
+ # reader never sees half a document, and so a crash mid-write leaves
95
+ # the previous version rather than nothing.
96
+ #
97
+ # @param record [Record]
98
+ # @return [void]
99
+ def update(record)
100
+ path = record.path || path_for(record.id)
101
+ temp = "#{path}.#{Process.pid}.tmp"
102
+ File.write(temp, record.to_markdown)
103
+ File.rename(temp, path)
104
+ end
105
+
106
+ # @param record [Record]
107
+ # @return [void]
108
+ def delete(record) = FileUtils.rm_f(record.path || path_for(record.id))
109
+
110
+ # @param id [String]
111
+ # @return [String]
112
+ def path_for(id) = File.join(dir, "#{id}#{SUFFIX}")
113
+
114
+ # Named so that `all`'s `*.lock.md` can never match it, dot or no dot.
115
+ #
116
+ # @return [String]
117
+ def mutex_path = File.join(dir, MUTEX)
118
+
119
+ private
120
+
121
+ # Polls with a non-blocking flock rather than blocking on it, because
122
+ # a blocking flock cannot be given up on: an agent stuck behind a
123
+ # stopped `alock` would wait forever with nothing on its screen.
124
+ #
125
+ # @param file [File] the open mutex
126
+ # @return [void]
127
+ # @raise [Error] once the timeout has passed
128
+ def wait_for(file)
129
+ timeout = mutex_timeout
130
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
131
+ delay = 0.005
132
+ until file.flock(File::LOCK_EX | File::LOCK_NB)
133
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
134
+ raise Error, format("timed out after %<timeout>gs waiting for %<path>s, " \
135
+ "which another process claiming a lock is holding", timeout:, path: mutex_path)
136
+ end
137
+
138
+ # Jittered, so that processes which all lost the same round do not
139
+ # all come back for the next one at the same instant.
140
+ sleep(delay * rand(0.5..1.0))
141
+ delay = [delay * 2, 0.1].min
142
+ end
143
+ end
144
+
145
+ # @return [Float] seconds to wait for the mutex before giving up
146
+ # @raise [Error] when AGENT_LOCK_MUTEX_TIMEOUT is not a finite,
147
+ # non-negative number: `Float` accepts "Infinity" and "NaN", and
148
+ # either makes the deadline below unreachable, hanging the poll
149
+ # loop forever instead of timing out.
150
+ def mutex_timeout
151
+ timeout = Float(ENV.fetch("AGENT_LOCK_MUTEX_TIMEOUT", MUTEX_TIMEOUT))
152
+ unless timeout.finite? && timeout >= 0
153
+ raise Error, "AGENT_LOCK_MUTEX_TIMEOUT must be a finite, non-negative number, got #{timeout}"
154
+ end
155
+
156
+ timeout
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end