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,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Agent
|
|
6
|
+
module Lock
|
|
7
|
+
module CLI
|
|
8
|
+
module Commands
|
|
9
|
+
# What a session should run when it finishes, so the next one does not
|
|
10
|
+
# have to work out whether it crashed.
|
|
11
|
+
class ReleaseAll < Base
|
|
12
|
+
desc "Release every lock this session holds"
|
|
13
|
+
|
|
14
|
+
def call(**options)
|
|
15
|
+
result = manager(options[:dir]).release_all
|
|
16
|
+
result.records.each { |record| say("RELEASED #{record.scope}") }
|
|
17
|
+
say("Released #{result.records.size} lock(s).")
|
|
18
|
+
finish(result)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Agent
|
|
6
|
+
module Lock
|
|
7
|
+
module CLI
|
|
8
|
+
module Commands
|
|
9
|
+
# Pick up work a reboot or a crash interrupted.
|
|
10
|
+
#
|
|
11
|
+
# A lock whose holder died is orphaned rather than deleted when it has
|
|
12
|
+
# notes in it. This takes it back, prints what the last session wrote,
|
|
13
|
+
# and makes it a live claim again under the current identity.
|
|
14
|
+
class Resume < Base
|
|
15
|
+
desc "Take back a lock a crash or a reboot interrupted"
|
|
16
|
+
|
|
17
|
+
argument :scope, required: true, desc: "The path or glob to pick up"
|
|
18
|
+
|
|
19
|
+
def call(scope:, **options)
|
|
20
|
+
result = manager(options[:dir]).resume(scope)
|
|
21
|
+
|
|
22
|
+
if result.status == :not_found
|
|
23
|
+
say("NOTHING TO RESUME #{scope}")
|
|
24
|
+
else
|
|
25
|
+
say("RESUMED #{result.record.scope} (holder: #{result.record.agent_id})")
|
|
26
|
+
say("")
|
|
27
|
+
say(result.record.intent)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
finish(result)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "../../skill"
|
|
5
|
+
|
|
6
|
+
module Agent
|
|
7
|
+
module Lock
|
|
8
|
+
module CLI
|
|
9
|
+
module Commands
|
|
10
|
+
# Where the bundled skill is, for a harness that would rather link it
|
|
11
|
+
# than copy it, or a configuration that names it by path.
|
|
12
|
+
class SkillPath < Base
|
|
13
|
+
desc "Print the directory of the skill this gem ships"
|
|
14
|
+
|
|
15
|
+
def call(**)
|
|
16
|
+
say(Skill.source)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Copy the bundled skill into a skills directory, so an agent learns
|
|
21
|
+
# how to claim files before it first needs to.
|
|
22
|
+
class SkillInstall < Base
|
|
23
|
+
desc "Copy the skill this gem ships into a skills directory"
|
|
24
|
+
|
|
25
|
+
option :into, type: :string, default: nil, aliases: ["-o"],
|
|
26
|
+
desc: "The skills directory, default ~/.agents/skills"
|
|
27
|
+
option :for, type: :string, default: nil, aliases: ["-a"],
|
|
28
|
+
desc: "AI coding agent name, eg 'codex', or 'claude'"
|
|
29
|
+
option :force, type: :boolean, default: false, aliases: ["-f"],
|
|
30
|
+
desc: "Replace a copy that differs from this one"
|
|
31
|
+
|
|
32
|
+
example ["", "--into ~/.claude/skills", "--force"]
|
|
33
|
+
example ["", "--for claude"]
|
|
34
|
+
|
|
35
|
+
# @param options [Hash]
|
|
36
|
+
def call(**options)
|
|
37
|
+
into = destination_for(options)
|
|
38
|
+
skill = into ? Skill.new(into: into) : Skill.new
|
|
39
|
+
|
|
40
|
+
result = skill.install(force: options[:force])
|
|
41
|
+
report(result)
|
|
42
|
+
finish(result)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# @param options [Hash]
|
|
48
|
+
# @return [String, nil] where to install, or nil for Skill's own default
|
|
49
|
+
def destination_for(options)
|
|
50
|
+
return options[:into] unless options[:for]
|
|
51
|
+
|
|
52
|
+
warn_("Both --for and --into options are provided; --for will be ignored") if options[:into]
|
|
53
|
+
return options[:into] if options[:into]
|
|
54
|
+
|
|
55
|
+
agent_dir = options[:for] == "claude" ? ".claude" : ".agents"
|
|
56
|
+
File.join(Dir.home, agent_dir, "skills")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @param result [Skill::Result]
|
|
60
|
+
def report(result)
|
|
61
|
+
case result.status
|
|
62
|
+
when :installed then say("INSTALLED #{result.path}")
|
|
63
|
+
when :current then say("UP TO DATE #{result.path}")
|
|
64
|
+
when :differs
|
|
65
|
+
warn_("REFUSED: #{result.path} differs from the copy this gem ships")
|
|
66
|
+
warn_(" #{program} skill install --force # replace it")
|
|
67
|
+
when :linked
|
|
68
|
+
warn_("REFUSED: #{result.path} is a symlink, so something else installs it; left alone")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Agent
|
|
6
|
+
module Lock
|
|
7
|
+
module CLI
|
|
8
|
+
module Commands
|
|
9
|
+
# What is installed, for a bug report.
|
|
10
|
+
class Version < Base
|
|
11
|
+
desc "Print the version"
|
|
12
|
+
|
|
13
|
+
def call(**)
|
|
14
|
+
say(Agent::Lock::VERSION)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "../../identity"
|
|
5
|
+
|
|
6
|
+
module Agent
|
|
7
|
+
module Lock
|
|
8
|
+
module CLI
|
|
9
|
+
module Commands
|
|
10
|
+
# Who a lock taken from here would say holds it, and who its parent is.
|
|
11
|
+
#
|
|
12
|
+
# A sub-agent runs inside its parent's process, so nothing but the
|
|
13
|
+
# AGENT_ID it was told to use tells the two apart. A typo or a missing
|
|
14
|
+
# export puts its locks down under somebody else's name, where they
|
|
15
|
+
# block no sibling, and nothing says so. This is the check to run
|
|
16
|
+
# before the first claim: `AGENT_ID=rey-frontend alock whoami`.
|
|
17
|
+
#
|
|
18
|
+
# It reads the environment and nothing else. No tree and no store, so
|
|
19
|
+
# asking the question can neither fail outside a checkout nor leave a
|
|
20
|
+
# store behind in one.
|
|
21
|
+
class Whoami < Base
|
|
22
|
+
# Where #id came from, worded for a human. The keys are Identity's
|
|
23
|
+
# own symbols, which are what --json reports.
|
|
24
|
+
SOURCES = {
|
|
25
|
+
explicit: "from AGENT_ID",
|
|
26
|
+
session: "from CLAUDE_SESSION_ID",
|
|
27
|
+
fingerprint: "from the process fingerprint"
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# Where #parent_id came from, likewise.
|
|
31
|
+
PARENT_SOURCES = {
|
|
32
|
+
explicit: "from AGENT_PARENT_ID",
|
|
33
|
+
inferred: "inferred: the session this runs in, since AGENT_ID names somebody else"
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
desc "Say who a lock taken now would be held by, and where that came from"
|
|
37
|
+
|
|
38
|
+
option :json, type: :boolean, default: false, desc: "Machine-readable output"
|
|
39
|
+
|
|
40
|
+
example ["", "--json"]
|
|
41
|
+
|
|
42
|
+
# @param options [Hash]
|
|
43
|
+
def call(**options)
|
|
44
|
+
identity = Identity.current
|
|
45
|
+
warn_shared(identity) unless identity.source == :explicit
|
|
46
|
+
return say(JSON.pretty_generate(as_hash(identity))) if options[:json]
|
|
47
|
+
|
|
48
|
+
say("id: #{identity.id} (#{SOURCES.fetch(identity.source)})")
|
|
49
|
+
say(parent_line(identity))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# @param identity [Identity]
|
|
55
|
+
# @return [String]
|
|
56
|
+
def parent_line(identity)
|
|
57
|
+
return "parent: none" if identity.parent_id.nil?
|
|
58
|
+
|
|
59
|
+
"parent: #{identity.parent_id} (#{PARENT_SOURCES.fetch(identity.parent_source)})"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The failure this command exists to catch. Without AGENT_ID, every
|
|
63
|
+
# sub-agent of this session inherits the same environment and runs
|
|
64
|
+
# in the same process, so each resolves to this same id and none of
|
|
65
|
+
# them can ever block another.
|
|
66
|
+
#
|
|
67
|
+
# @param identity [Identity]
|
|
68
|
+
def warn_shared(identity)
|
|
69
|
+
warn_("note: every sub-agent of this session answers to #{identity.id} too, so none can block another.")
|
|
70
|
+
warn_(" Give each one its own: AGENT_ID=<name> #{program} acquire ...")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @param identity [Identity]
|
|
74
|
+
# @return [Hash]
|
|
75
|
+
def as_hash(identity)
|
|
76
|
+
{ id: identity.id, source: identity.source,
|
|
77
|
+
parent_id: identity.parent_id, parent_source: identity.parent_source }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "manager"
|
|
4
|
+
require "dry/cli"
|
|
5
|
+
|
|
6
|
+
module Agent
|
|
7
|
+
module Lock
|
|
8
|
+
# The command line, and nothing else. Every command here is a thin shell:
|
|
9
|
+
# parse flags, call one Manager method, print the result, set an exit code.
|
|
10
|
+
# No command decides anything a library object could decide instead.
|
|
11
|
+
module CLI
|
|
12
|
+
# What the gem is installed as. Taken from the gemspec's one executable
|
|
13
|
+
# rather than from $PROGRAM_NAME, which under a test runner is the
|
|
14
|
+
# runner. The completion script it emits names the program in every
|
|
15
|
+
# line, so guessing it wrong is not a cosmetic mistake.
|
|
16
|
+
PROGRAM_NAME = "alock"
|
|
17
|
+
|
|
18
|
+
# A registry whose commands are already bound to this launcher, so a
|
|
19
|
+
# command writes to the streams it was given rather than to the process's.
|
|
20
|
+
#
|
|
21
|
+
# @param launcher [Launcher]
|
|
22
|
+
# @return [Dry::CLI::Registry]
|
|
23
|
+
def self.registry_for(launcher)
|
|
24
|
+
# `extend` and `register` have to be sent to the new class explicitly.
|
|
25
|
+
# Inside a `tap` block `self` is still this module, so writing them
|
|
26
|
+
# bare turned Agent::Lock::CLI itself into the registry, handed back a
|
|
27
|
+
# class that knew no commands, and left the completion command bound
|
|
28
|
+
# to that empty class.
|
|
29
|
+
registry = Class.new { extend Dry::CLI::Registry }
|
|
30
|
+
|
|
31
|
+
COMMANDS.each do |name, (klass, aliases)|
|
|
32
|
+
registry.register(name, klass.new(launcher), aliases: aliases)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
registry.register("completion", Commands::Completion[registry, program_name: PROGRAM_NAME])
|
|
36
|
+
registry
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The whole command line, in one place, so adding a verb is one line
|
|
40
|
+
# rather than three. Filled in below, once the commands are loaded.
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
require_relative "cli/commands/base"
|
|
46
|
+
require_relative "cli/commands/acquire"
|
|
47
|
+
require_relative "cli/commands/release"
|
|
48
|
+
require_relative "cli/commands/check"
|
|
49
|
+
require_relative "cli/commands/list"
|
|
50
|
+
require_relative "cli/commands/mine"
|
|
51
|
+
require_relative "cli/commands/release_all"
|
|
52
|
+
require_relative "cli/commands/break"
|
|
53
|
+
require_relative "cli/commands/note"
|
|
54
|
+
require_relative "cli/commands/resume"
|
|
55
|
+
require_relative "cli/commands/whoami"
|
|
56
|
+
require_relative "cli/commands/skill"
|
|
57
|
+
require_relative "cli/commands/version"
|
|
58
|
+
require_relative "cli/commands/completion"
|
|
59
|
+
|
|
60
|
+
module Agent
|
|
61
|
+
module Lock
|
|
62
|
+
module CLI
|
|
63
|
+
COMMANDS = {
|
|
64
|
+
"acquire" => [Commands::Acquire, []],
|
|
65
|
+
"release" => [Commands::Release, []],
|
|
66
|
+
"check" => [Commands::Check, []],
|
|
67
|
+
"list" => [Commands::List, ["ls"]],
|
|
68
|
+
"mine" => [Commands::Mine, []],
|
|
69
|
+
"release-all" => [Commands::ReleaseAll, []],
|
|
70
|
+
"break" => [Commands::Break, []],
|
|
71
|
+
"note" => [Commands::Note, []],
|
|
72
|
+
"resume" => [Commands::Resume, []],
|
|
73
|
+
"whoami" => [Commands::Whoami, []],
|
|
74
|
+
"skill path" => [Commands::SkillPath, []],
|
|
75
|
+
"skill install" => [Commands::SkillInstall, []],
|
|
76
|
+
"version" => [Commands::Version, %w[-v --version]]
|
|
77
|
+
}.freeze
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Lock
|
|
5
|
+
# The base of everything this gem raises. In a file of its own so that any
|
|
6
|
+
# part of the library can require it without reaching for the entry point,
|
|
7
|
+
# which would require the part doing the asking.
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "error"
|
|
4
|
+
|
|
5
|
+
module Agent
|
|
6
|
+
module Lock
|
|
7
|
+
# Optional teeth, for the scope you want nobody to touch at all.
|
|
8
|
+
#
|
|
9
|
+
# An advisory lock works because every agent checks it. That covers the
|
|
10
|
+
# agents that check. `chflags uchg` on macOS makes the file unwritable by
|
|
11
|
+
# anything, checking or not, which is the right answer for a handful of
|
|
12
|
+
# files that must not move while something else runs.
|
|
13
|
+
#
|
|
14
|
+
# It is opt-in for three reasons. It is macOS only, since Linux's `chattr
|
|
15
|
+
# +i` needs root. It denies the holder too, so it fits a freeze rather than
|
|
16
|
+
# a file you are editing. And a session that dies with files frozen leaves
|
|
17
|
+
# a tree where `git checkout` and `rm -rf` fail with "Operation not
|
|
18
|
+
# permitted", which is why every frozen path is written into the lock: the
|
|
19
|
+
# thaw does not depend on the process that froze them still being alive.
|
|
20
|
+
module Freeze
|
|
21
|
+
# A freeze walks and flags every matched file, so a scope covering a
|
|
22
|
+
# whole checkout is a mistake rather than an instruction.
|
|
23
|
+
LIMIT = 500
|
|
24
|
+
|
|
25
|
+
# How many paths one `chflags` invocation is given.
|
|
26
|
+
BATCH = 200
|
|
27
|
+
|
|
28
|
+
class TooBroad < Error; end
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# @return [Boolean]
|
|
33
|
+
def supported? = RUBY_PLATFORM.include?("darwin")
|
|
34
|
+
|
|
35
|
+
# @param scope [Scope]
|
|
36
|
+
# @param tree [Tree]
|
|
37
|
+
# @return [Array<String>] the files it would freeze, relative to the root
|
|
38
|
+
def matches(scope, tree)
|
|
39
|
+
Dir.glob(recursive(scope.pattern), base: tree.root, flags: File::FNM_EXTGLOB)
|
|
40
|
+
.select { |rel| File.file?(File.join(tree.root, rel)) }
|
|
41
|
+
.reject { |rel| rel.start_with?(".git/") }
|
|
42
|
+
.sort
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A trailing `**` means "everything under here" to this gem, and to
|
|
46
|
+
# anybody typing it. It does not mean that to Dir.glob, where a bare
|
|
47
|
+
# `**` at the end matches one level, exactly like `*`. Only `**/` walks
|
|
48
|
+
# down. So a scope that covers a subtree is spelled out before globbing.
|
|
49
|
+
#
|
|
50
|
+
# @param pattern [String]
|
|
51
|
+
# @return [String]
|
|
52
|
+
def recursive(pattern) = pattern.end_with?("**") ? "#{pattern}/*" : pattern
|
|
53
|
+
|
|
54
|
+
# @param paths [Array<String>] relative to the tree root
|
|
55
|
+
# @param tree [Tree]
|
|
56
|
+
# @param force [Boolean] allow a freeze wider than LIMIT
|
|
57
|
+
# @return [Array<String>] what was frozen
|
|
58
|
+
def apply(paths, tree:, force: false)
|
|
59
|
+
raise TooBroad, "--enforce needs macOS; this is #{RUBY_PLATFORM}" unless supported?
|
|
60
|
+
return [] if paths.empty?
|
|
61
|
+
raise TooBroad, "#{paths.size} files is wider than a freeze should be" if paths.size > LIMIT && !force
|
|
62
|
+
|
|
63
|
+
chflags("uchg", paths, tree)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Best effort on purpose: a path that has since been deleted or already
|
|
67
|
+
# thawed is not a reason to leave the rest frozen.
|
|
68
|
+
#
|
|
69
|
+
# @return [Array<String>] what was thawed
|
|
70
|
+
def clear(paths, tree:)
|
|
71
|
+
return [] if paths.nil? || paths.empty?
|
|
72
|
+
|
|
73
|
+
chflags("nouchg", paths, tree)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Only the files `chflags` actually accepted come back.
|
|
77
|
+
#
|
|
78
|
+
# A batch that fails is retried one file at a time, because the usual
|
|
79
|
+
# reason is a single path somebody else owns, and reporting the whole
|
|
80
|
+
# batch as frozen would leave the lock claiming protection it does not
|
|
81
|
+
# have. Reporting the whole batch as failed would be just as wrong.
|
|
82
|
+
#
|
|
83
|
+
# @return [Array<String>] relative paths that are now flagged
|
|
84
|
+
def chflags(flag, paths, tree)
|
|
85
|
+
return [] unless supported?
|
|
86
|
+
|
|
87
|
+
paths.each_slice(BATCH)
|
|
88
|
+
.flat_map { |batch| flag_batch(flag, existing(batch, tree)) }
|
|
89
|
+
.map { |path| path.delete_prefix("#{tree.root}/") }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @return [Array<String>] absolute paths that are still there to flag
|
|
93
|
+
def existing(batch, tree)
|
|
94
|
+
batch.map { |rel| File.join(tree.root, rel) }.select { |path| File.exist?(path) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @return [Array<String>] the ones the command accepted
|
|
98
|
+
def flag_batch(flag, absolute)
|
|
99
|
+
return [] if absolute.empty?
|
|
100
|
+
return absolute if run(flag, absolute)
|
|
101
|
+
|
|
102
|
+
absolute.select { |path| run(flag, [path]) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @return [Boolean] whether the command reported success
|
|
106
|
+
def run(flag, paths) = system("chflags", flag, *paths, out: File::NULL, err: File::NULL) || false
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "digest"
|
|
6
|
+
|
|
7
|
+
module Agent
|
|
8
|
+
module Lock
|
|
9
|
+
# Who is asking, and whether they are still around.
|
|
10
|
+
#
|
|
11
|
+
# A lock is worthless unless the holder gives the same answer every time it
|
|
12
|
+
# is asked. The obvious answer, this process's own pid, is the wrong one: an
|
|
13
|
+
# agent harness runs each command in a shell of its own, so a lock acquired
|
|
14
|
+
# by one invocation could never be released by the next. The identity has to
|
|
15
|
+
# belong to the session, not to the process that happens to be speaking for
|
|
16
|
+
# it right now.
|
|
17
|
+
#
|
|
18
|
+
# In order of preference:
|
|
19
|
+
#
|
|
20
|
+
# AGENT_ID what a human or a harness set on purpose
|
|
21
|
+
# CLAUDE_SESSION_ID the session, which survives --resume
|
|
22
|
+
# fingerprint the first ancestor process that is not a shell
|
|
23
|
+
#
|
|
24
|
+
# The fingerprint is the fallback that needs explaining. Walking up from
|
|
25
|
+
# this process, the shells are throwaway and the thing above them is not:
|
|
26
|
+
# the `claude` or `codex` process driving the session, or the terminal a
|
|
27
|
+
# human is typing in. Its pid and start time, hashed, are stable for as
|
|
28
|
+
# long as that session lives and different for anybody else's.
|
|
29
|
+
class Identity
|
|
30
|
+
SHELLS = %w[sh bash zsh ksh csh tcsh fish dash].freeze
|
|
31
|
+
MAX_HOPS = 10
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
# Deliberately not memoized. A forked CLI computes this once and dies,
|
|
35
|
+
# but the same code runs inside a long-lived process during the specs
|
|
36
|
+
# and inside any harness that loads the library, where a cached answer
|
|
37
|
+
# would outlive the environment it was computed from.
|
|
38
|
+
#
|
|
39
|
+
# @return [Identity]
|
|
40
|
+
def current = new
|
|
41
|
+
|
|
42
|
+
# The ancestry walk, on the other hand, cannot change while this
|
|
43
|
+
# process lives, and it costs two `ps` calls per hop.
|
|
44
|
+
#
|
|
45
|
+
# @return [Integer]
|
|
46
|
+
def session_pid = @session_pid ||= yield
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param env [Hash]
|
|
50
|
+
def initialize(env: ENV)
|
|
51
|
+
@env = env
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [String] the holder name written into a lock
|
|
55
|
+
def id
|
|
56
|
+
@id ||= explicit || session || fingerprint
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The session that spawned this one: AGENT_PARENT_ID when a harness says
|
|
60
|
+
# so, and otherwise inferred.
|
|
61
|
+
#
|
|
62
|
+
# The inference exists because Claude Code runs a sub-agent inside its
|
|
63
|
+
# parent's own process and sets nothing to tell them apart, so without it
|
|
64
|
+
# every sub-agent resolved to its parent's fingerprint and none of them
|
|
65
|
+
# could ever block another. A sub-agent's one distinguishing mark is the
|
|
66
|
+
# AGENT_ID it was told to use. When that differs from what this session
|
|
67
|
+
# would answer to without it, the session is, by elimination, the
|
|
68
|
+
# parent.
|
|
69
|
+
#
|
|
70
|
+
# @return [String, nil]
|
|
71
|
+
def parent_id
|
|
72
|
+
return @parent_id if defined?(@parent_id)
|
|
73
|
+
|
|
74
|
+
@parent_id = declared_parent || inferred_parent
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Where #id came from, so a session can check what it is being taken for
|
|
78
|
+
# before a lock is written under the wrong name.
|
|
79
|
+
#
|
|
80
|
+
# @return [Symbol] :explicit, :session or :fingerprint
|
|
81
|
+
def source
|
|
82
|
+
return :explicit if explicit
|
|
83
|
+
return :session if session
|
|
84
|
+
|
|
85
|
+
:fingerprint
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Where #parent_id came from. An inferred parent is a guess a human may
|
|
89
|
+
# want to overrule with AGENT_PARENT_ID, so it is reported as one.
|
|
90
|
+
#
|
|
91
|
+
# @return [Symbol, nil] :explicit, :inferred, or nil when there is no parent
|
|
92
|
+
def parent_source
|
|
93
|
+
return :explicit if declared_parent
|
|
94
|
+
|
|
95
|
+
:inferred if parent_id
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Evidence, not identity: enough to ask later whether the holder is still
|
|
99
|
+
# running. The pid alone is not enough, since pids are reused, and the
|
|
100
|
+
# host matters because a lock store can be on a shared or synced volume.
|
|
101
|
+
#
|
|
102
|
+
# @return [Hash{Symbol => Object}]
|
|
103
|
+
def evidence
|
|
104
|
+
{ pid: session_pid, started: ProcessInfo.started_at(session_pid), host: Socket.gethostname }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def explicit = presence(@env["AGENT_ID"])
|
|
110
|
+
|
|
111
|
+
def declared_parent = presence(@env["AGENT_PARENT_ID"])
|
|
112
|
+
|
|
113
|
+
# What this session answers to when nobody names it, which is the
|
|
114
|
+
# orchestrator's id. The session id counts as well as the fingerprint:
|
|
115
|
+
# under a harness that exports CLAUDE_SESSION_ID the orchestrator's locks
|
|
116
|
+
# carry that, and a parent inferred from the fingerprint alone would
|
|
117
|
+
# match none of them. A session that named itself by its own fingerprint
|
|
118
|
+
# is not its own child.
|
|
119
|
+
#
|
|
120
|
+
# @return [String, nil]
|
|
121
|
+
def inferred_parent
|
|
122
|
+
return nil unless explicit
|
|
123
|
+
|
|
124
|
+
own = session || fingerprint
|
|
125
|
+
own unless own == explicit
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def session
|
|
129
|
+
value = presence(@env["CLAUDE_SESSION_ID"])
|
|
130
|
+
value && "session-#{value[0, 8]}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @return [String] e.g. "claude-1f4c8a02"
|
|
134
|
+
def fingerprint
|
|
135
|
+
pid = session_pid
|
|
136
|
+
name = ProcessInfo.command(pid) || "agent"
|
|
137
|
+
digest = Digest::SHA256.hexdigest(
|
|
138
|
+
[pid, ProcessInfo.started_at(pid), Etc.getpwuid(::Process.uid)&.name, Socket.gethostname].join("|")
|
|
139
|
+
)
|
|
140
|
+
"#{name}-#{digest[0, 8]}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The first ancestor that is not a shell, which is the process that lasts
|
|
144
|
+
# as long as the session does.
|
|
145
|
+
#
|
|
146
|
+
# @return [Integer]
|
|
147
|
+
def session_pid
|
|
148
|
+
self.class.session_pid do
|
|
149
|
+
pid = ::Process.ppid
|
|
150
|
+
MAX_HOPS.times do
|
|
151
|
+
name = ProcessInfo.command(pid)
|
|
152
|
+
break unless name && SHELLS.include?(name.delete_prefix("-"))
|
|
153
|
+
|
|
154
|
+
parent = ProcessInfo.parent_of(pid)
|
|
155
|
+
break unless parent && parent > 1
|
|
156
|
+
|
|
157
|
+
pid = parent
|
|
158
|
+
end
|
|
159
|
+
pid
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def presence(value) = value.nil? || value.strip.empty? ? nil : value.strip
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|