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,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
|