agent-lock 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.claude/CLAUDE.md +1 -0
- data/.envrc +2 -0
- data/.rubocop_todo.yml +90 -0
- data/.ruby-version +1 -0
- data/AGENTS.md +54 -0
- data/CHANGELOG.md +48 -0
- data/LICENSE.txt +21 -0
- data/README.md +374 -0
- data/Rakefile +12 -0
- data/exe/agent-lock +8 -0
- data/exe/alock +9 -0
- data/justfile +127 -0
- data/lib/agent/lock/cli/commands/acquire.rb +87 -0
- data/lib/agent/lock/cli/commands/base.rb +148 -0
- data/lib/agent/lock/cli/commands/break.rb +36 -0
- data/lib/agent/lock/cli/commands/check.rb +56 -0
- data/lib/agent/lock/cli/commands/completion.rb +50 -0
- data/lib/agent/lock/cli/commands/list.rb +70 -0
- data/lib/agent/lock/cli/commands/mine.rb +35 -0
- data/lib/agent/lock/cli/commands/note.rb +37 -0
- data/lib/agent/lock/cli/commands/release.rb +33 -0
- data/lib/agent/lock/cli/commands/release_all.rb +24 -0
- data/lib/agent/lock/cli/commands/resume.rb +36 -0
- data/lib/agent/lock/cli/commands/skill.rb +75 -0
- data/lib/agent/lock/cli/commands/version.rb +20 -0
- data/lib/agent/lock/cli/commands/whoami.rb +83 -0
- data/lib/agent/lock/cli.rb +80 -0
- data/lib/agent/lock/error.rb +10 -0
- data/lib/agent/lock/freeze.rb +109 -0
- data/lib/agent/lock/identity.rb +166 -0
- data/lib/agent/lock/launcher.rb +126 -0
- data/lib/agent/lock/manager.rb +313 -0
- data/lib/agent/lock/process_info.rb +60 -0
- data/lib/agent/lock/record.rb +216 -0
- data/lib/agent/lock/scope.rb +173 -0
- data/lib/agent/lock/skill.rb +104 -0
- data/lib/agent/lock/store/file_system_store.rb +161 -0
- data/lib/agent/lock/store/redis_store.rb +225 -0
- data/lib/agent/lock/store.rb +104 -0
- data/lib/agent/lock/tree.rb +126 -0
- data/lib/agent/lock/version.rb +7 -0
- data/lib/agent/lock.rb +30 -0
- data/sig/agent/lock.rbs +6 -0
- data/skills/agent-lock/SKILL.md +59 -0
- metadata +150 -0
|
@@ -0,0 +1,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
|