gitbroker 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/gitbroker/agents/claude.rb +10 -2
- data/lib/gitbroker/cli.rb +3 -1
- data/lib/gitbroker/daemon.rb +16 -4
- data/lib/gitbroker/process_scanner.rb +16 -15
- data/lib/gitbroker/reaper.rb +111 -0
- data/lib/gitbroker/task_runner.rb +5 -1
- data/lib/gitbroker/version.rb +1 -1
- data/lib/gitbroker.rb +1 -0
- data/plugin/.claude-plugin/plugin.json +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b20e2186e4b0e888878382bcced5b98530f92b642b69a1e20cab0add06301e58
|
|
4
|
+
data.tar.gz: 0da49468966d94932958c778386e432dd00b4feb9c0ee3e0432380100f472bf4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4515180233432eabd09be57c026794ca06c1141f82bb41da8097cc8b76d80e47464c6aaf6bd27216dcd3f580d3872b0287d333a9dea1dadcb0b86592eea745df
|
|
7
|
+
data.tar.gz: '09ec99fb2e88657b6b80a0eaccd37f62be01d84cdd3c4f1fd4814a2fb27425ee1610cae031bd37805b009c65ee731599f2b00dd5ca8d4fb653b6ef7ed6d6f431'
|
|
@@ -6,9 +6,17 @@ module Gitbroker
|
|
|
6
6
|
# credit pool pays for it (spec §2). Terminal: interactive `claude`.
|
|
7
7
|
class Claude < Base
|
|
8
8
|
PLUGIN_DIR = File.join(Gitbroker::ROOT, "plugin")
|
|
9
|
+
# A bare `Read` in --allowedTools means every file on the machine, so a prompt-injected agent could read
|
|
10
|
+
# ~/.aws/credentials. `Read(./**)` is the worktree only (Grep and Glob follow Read rules), the settings flag makes
|
|
11
|
+
# the file tools refuse anything outside the working directory in every mode (a symlink out of the worktree
|
|
12
|
+
# included), and the deny rules name the usual secrets. Either of the first two alone was verified to block
|
|
13
|
+
# reads outside the worktree, in Read, Grep and Glob.
|
|
14
|
+
SECRET_PATHS = %w[Read(~/.ssh/**) Read(//**/.aws/**) Read(~/.gnupg/**) Read(~/.config/**) Read(~/.netrc)
|
|
15
|
+
Read(~/.git-credentials) Read(//**/.env)].freeze
|
|
9
16
|
READ_ONLY_ARGS = [ "--permission-mode", "dontAsk", "--permission-prompts", "none",
|
|
10
|
-
"--allowedTools", "Read,
|
|
11
|
-
"--disallowedTools",
|
|
17
|
+
"--allowedTools", "Read(./**),mcp__gitbroker__*",
|
|
18
|
+
"--disallowedTools", [ *%w[Bash Edit Write NotebookEdit WebFetch WebSearch], *SECRET_PATHS ].join(","),
|
|
19
|
+
"--settings", { permissions: { blockReadsOutsideWorkingDirectories: true } }.to_json ].freeze
|
|
12
20
|
READS = %w[Read NotebookRead].freeze
|
|
13
21
|
EDITS = %w[Edit MultiEdit Write NotebookEdit].freeze
|
|
14
22
|
|
data/lib/gitbroker/cli.rb
CHANGED
|
@@ -26,7 +26,7 @@ module Gitbroker
|
|
|
26
26
|
"Start companion" handler (`start` does this when needed)
|
|
27
27
|
install-skill Install the gitbroker skills into ~/.claude/skills
|
|
28
28
|
status Show pairing, configuration and the repositories found
|
|
29
|
-
clean Remove finished agent worktrees (keeps
|
|
29
|
+
clean Remove finished agent worktrees and stop their leftover processes (keeps worktrees with uncommitted work)
|
|
30
30
|
install-hooks [--yes] Show your own Claude Code sessions on your cards (asks first)
|
|
31
31
|
uninstall-hooks Remove those hooks
|
|
32
32
|
help Show this help
|
|
@@ -261,6 +261,8 @@ module Gitbroker
|
|
|
261
261
|
# `git worktree remove` without --force refuses a worktree with modified or untracked files, so work is never lost.
|
|
262
262
|
def clean
|
|
263
263
|
worktree = Worktree.new(root: config.worktree_root)
|
|
264
|
+
stopped = Reaper.new(root: config.worktree_root).sweep!
|
|
265
|
+
@out.puts "Stopped #{stopped} leftover process#{"es" unless stopped == 1} (rubocop --server and the like)." if stopped.positive?
|
|
264
266
|
worktree.list.each do |dir|
|
|
265
267
|
git_dir = GitContext.git_dir(dir)
|
|
266
268
|
repo_path = git_dir && File.dirname(GitContext.common_dir(git_dir))
|
data/lib/gitbroker/daemon.rb
CHANGED
|
@@ -25,8 +25,8 @@ module Gitbroker
|
|
|
25
25
|
|
|
26
26
|
new(scanner: config.repo_scanner, roots_changed: roots_changed(config), device_roots: config.device_roots, ignored_roots: config.ignored_roots,
|
|
27
27
|
client_factory: -> { CableClient.new(url: config.cable_url, token: config.device_token, origin: config.server) },
|
|
28
|
-
task_runner_factory: ->(report) { TaskRunner.new(config:, report:, out:) }, out:, relay:, process_scanner:,
|
|
29
|
-
lifecycle: Lifecycle.new(dir: config.dir, server: config.server))
|
|
28
|
+
task_runner_factory: ->(report) { TaskRunner.new(config:, report:, out:) }, out:, relay:, process_scanner:, agent: config.agent,
|
|
29
|
+
lifecycle: Lifecycle.new(dir: config.dir, server: config.server), reaper: Reaper.new(root: config.worktree_root))
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
# The server's "roots" message: remember the folders in config.yml (so they survive a restart without the server) and
|
|
@@ -54,7 +54,7 @@ module Gitbroker
|
|
|
54
54
|
|
|
55
55
|
def initialize(scanner:, client_factory:, task_runner_factory:, out: $stdout, sleeper: ->(seconds) { sleep(seconds) },
|
|
56
56
|
clock: -> { Time.now }, spawner: ->(&block) { Thread.new(&block) }, relay: nil, process_scanner: nil, lifecycle: nil,
|
|
57
|
-
maintenance: Maintenance.new, roots_changed: nil, device_roots: [], ignored_roots: [])
|
|
57
|
+
maintenance: Maintenance.new, roots_changed: nil, device_roots: [], ignored_roots: [], agent: "claude", reaper: nil)
|
|
58
58
|
@scanner = scanner
|
|
59
59
|
@device_roots = device_roots
|
|
60
60
|
@ignored_roots = ignored_roots
|
|
@@ -69,6 +69,8 @@ module Gitbroker
|
|
|
69
69
|
@process_scanner = process_scanner
|
|
70
70
|
@lifecycle = lifecycle
|
|
71
71
|
@maintenance = maintenance
|
|
72
|
+
@agent = agent
|
|
73
|
+
@reaper = reaper
|
|
72
74
|
@repositories = []
|
|
73
75
|
@running = Set.new
|
|
74
76
|
@running_lock = Mutex.new
|
|
@@ -79,6 +81,7 @@ module Gitbroker
|
|
|
79
81
|
@rejections = 0
|
|
80
82
|
@relay&.start
|
|
81
83
|
@lifecycle&.write_pid
|
|
84
|
+
sweep_strays
|
|
82
85
|
until @stopping
|
|
83
86
|
begin
|
|
84
87
|
@client = @client_factory.call
|
|
@@ -121,7 +124,7 @@ module Gitbroker
|
|
|
121
124
|
@rejections = 0
|
|
122
125
|
rescan(client)
|
|
123
126
|
client.perform("hello", "version" => VERSION, "platform" => Gitbroker.platform,
|
|
124
|
-
"service" => @lifecycle ? @lifecycle.service_installed? : false)
|
|
127
|
+
"service" => @lifecycle ? @lifecycle.service_installed? : false, "agent" => @agent)
|
|
125
128
|
@out.puts "Connected. #{@repositories.size} repositories reported."
|
|
126
129
|
end
|
|
127
130
|
client.on_tick do
|
|
@@ -259,7 +262,16 @@ module Gitbroker
|
|
|
259
262
|
ensure
|
|
260
263
|
upgrade_now = @running_lock.synchronize { @running.delete(id) && @pending_upgrade && @running.empty? }
|
|
261
264
|
upgrade if upgrade_now
|
|
265
|
+
sweep_strays
|
|
262
266
|
end
|
|
263
267
|
end
|
|
268
|
+
|
|
269
|
+
# Helpers an agent left in worktrees nobody works in (rubocop --server and the like). Only while no task runs here.
|
|
270
|
+
def sweep_strays
|
|
271
|
+
return unless @reaper && @running_lock.synchronize { @running.empty? }
|
|
272
|
+
|
|
273
|
+
stopped = @reaper.sweep!
|
|
274
|
+
@out.puts "Stopped #{stopped} leftover process#{"es" unless stopped == 1} in finished task worktrees." if stopped.positive?
|
|
275
|
+
end
|
|
264
276
|
end
|
|
265
277
|
end
|
|
@@ -44,6 +44,22 @@ module Gitbroker
|
|
|
44
44
|
running + exited
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
# pid => cwd for the given pids (lsof on macOS, /proc on Linux); a pid that vanished is left out.
|
|
48
|
+
def working_dirs(pids)
|
|
49
|
+
return {} if pids.empty?
|
|
50
|
+
return pids.to_h { [ _1, proc_cwd(_1) ] }.compact if @platform == "linux"
|
|
51
|
+
|
|
52
|
+
cwds = {}
|
|
53
|
+
pid = nil
|
|
54
|
+
@runner.capture("lsof", "-a", "-d", "cwd", "-Fn", "-p", pids.sort.join(",")).each_line do |line|
|
|
55
|
+
case line[0]
|
|
56
|
+
when "p" then pid = line[1..].to_i
|
|
57
|
+
when "n" then cwds[pid] = line[1..].chomp if pid
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
cwds
|
|
61
|
+
end
|
|
62
|
+
|
|
47
63
|
private
|
|
48
64
|
|
|
49
65
|
# { "proc-<pid>" => { agent:, context: } }, one per (agent, cwd), only in GitHub checkouts outside `exclude`.
|
|
@@ -79,21 +95,6 @@ module Gitbroker
|
|
|
79
95
|
{}
|
|
80
96
|
end
|
|
81
97
|
|
|
82
|
-
def working_dirs(pids)
|
|
83
|
-
return {} if pids.empty?
|
|
84
|
-
return pids.to_h { [ _1, proc_cwd(_1) ] }.compact if @platform == "linux"
|
|
85
|
-
|
|
86
|
-
cwds = {}
|
|
87
|
-
pid = nil
|
|
88
|
-
@runner.capture("lsof", "-a", "-d", "cwd", "-Fn", "-p", pids.sort.join(",")).each_line do |line|
|
|
89
|
-
case line[0]
|
|
90
|
-
when "p" then pid = line[1..].to_i
|
|
91
|
-
when "n" then cwds[pid] = line[1..].chomp if pid
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
cwds
|
|
95
|
-
end
|
|
96
|
-
|
|
97
98
|
def proc_cwd(pid)
|
|
98
99
|
File.readlink(File.join(@proc_root, pid.to_s, "cwd"))
|
|
99
100
|
rescue SystemCallError
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitbroker
|
|
4
|
+
# Stops the processes an agent leaves behind in a task worktree. Tools such as `rubocop --server`, spring or a language
|
|
5
|
+
# server detach from the agent (their parent becomes launchd/init), so they outlive it and its process group, keep the
|
|
6
|
+
# worktree as their working directory and pile up for days. Only processes whose working directory is inside the
|
|
7
|
+
# worktree root are ever touched, and only by pid: a process is never found by name or killed by group.
|
|
8
|
+
class Reaper
|
|
9
|
+
GRACE = 3
|
|
10
|
+
POLL = 0.1
|
|
11
|
+
INIT = /\A(?:\S*\/)?(?:systemd|launchd)\b/
|
|
12
|
+
|
|
13
|
+
def initialize(root:, runner: Runner.new, platform: Gitbroker.platform, proc_root: "/proc", grace: GRACE,
|
|
14
|
+
signal: Process.method(:kill), sleeper: ->(seconds) { sleep(seconds) })
|
|
15
|
+
@root = real(root)
|
|
16
|
+
@runner = runner
|
|
17
|
+
@scanner = ProcessScanner.new(runner:, platform:, proc_root:)
|
|
18
|
+
@grace = grace
|
|
19
|
+
@signal = signal
|
|
20
|
+
@sleeper = sleeper
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# A task ended in `dir`: whatever still runs there is a stray. Returns how many processes were stopped.
|
|
24
|
+
def reap!(dir)
|
|
25
|
+
dir = real(dir)
|
|
26
|
+
return 0 unless inside?(dir, @root)
|
|
27
|
+
|
|
28
|
+
stop(processes.select { inside?(_1[:cwd], dir) }.map { _1[:pid] })
|
|
29
|
+
rescue Error, SystemCallError
|
|
30
|
+
0
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Worktrees nobody works in (no agent process inside): their orphaned helpers are strays. A worktree with a live
|
|
34
|
+
# agent (a terminal run the user is still in) is left alone. Call it only when no task is running here.
|
|
35
|
+
def sweep!
|
|
36
|
+
pids = processes.group_by { worktree_of(_1[:cwd]) }.flat_map do |dir, inside|
|
|
37
|
+
next [] if dir.nil? || inside.any? { ProcessScanner.agent_for(_1[:args]) }
|
|
38
|
+
|
|
39
|
+
inside.select { _1[:orphan] }.map { _1[:pid] }
|
|
40
|
+
end
|
|
41
|
+
stop(pids)
|
|
42
|
+
rescue Error, SystemCallError
|
|
43
|
+
0
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# [{ pid:, args:, cwd:, orphan: }] for every process with a working directory under the worktree root.
|
|
49
|
+
def processes
|
|
50
|
+
table = @runner.run!("ps", "-Ao", "pid=,ppid=,args=").lines.filter_map do |line|
|
|
51
|
+
pid, ppid, args = line.strip.split(" ", 3)
|
|
52
|
+
next unless pid.to_s.match?(/\A\d+\z/) && ppid.to_s.match?(/\A\d+\z/) && pid.to_i > 1 && pid.to_i != Process.pid
|
|
53
|
+
|
|
54
|
+
{ pid: pid.to_i, ppid: ppid.to_i, args: args.to_s }
|
|
55
|
+
end
|
|
56
|
+
args_of = table.to_h { [ _1[:pid], _1[:args] ] }
|
|
57
|
+
cwds = @scanner.working_dirs(table.map { _1[:pid] })
|
|
58
|
+
table.filter_map do |process|
|
|
59
|
+
cwd = cwds[process[:pid]]
|
|
60
|
+
next unless cwd && inside?(cwd, @root)
|
|
61
|
+
|
|
62
|
+
process.merge(cwd:, orphan: process[:ppid] == 1 || args_of[process[:ppid]].to_s.match?(INIT))
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# <root>/<owner>-<repo>/pr-<n>-<kind>, whatever depth the process is working at.
|
|
67
|
+
def worktree_of(cwd)
|
|
68
|
+
return unless cwd.start_with?("#{@root}/")
|
|
69
|
+
|
|
70
|
+
owner_repo, pr = cwd.delete_prefix("#{@root}/").split("/", 3).first(2)
|
|
71
|
+
File.join(@root, owner_repo, pr) if pr
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def stop(pids)
|
|
75
|
+
pids = pids.uniq.select { deliver("TERM", _1) }
|
|
76
|
+
return 0 if pids.empty?
|
|
77
|
+
|
|
78
|
+
(@grace / POLL).ceil.times do
|
|
79
|
+
break if pids.none? { alive?(_1) }
|
|
80
|
+
|
|
81
|
+
@sleeper.call(POLL)
|
|
82
|
+
end
|
|
83
|
+
pids.each { deliver("KILL", _1) if alive?(_1) }
|
|
84
|
+
pids.size
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def deliver(name, pid)
|
|
88
|
+
@signal.call(name, pid)
|
|
89
|
+
true
|
|
90
|
+
rescue SystemCallError
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def alive?(pid)
|
|
95
|
+
@signal.call(0, pid)
|
|
96
|
+
true
|
|
97
|
+
rescue Errno::EPERM
|
|
98
|
+
true
|
|
99
|
+
rescue SystemCallError
|
|
100
|
+
false
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def inside?(path, dir) = path == dir || path.start_with?("#{dir}/")
|
|
104
|
+
|
|
105
|
+
def real(path)
|
|
106
|
+
File.realpath(path)
|
|
107
|
+
rescue SystemCallError
|
|
108
|
+
File.expand_path(path)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -16,7 +16,8 @@ module Gitbroker
|
|
|
16
16
|
|
|
17
17
|
def initialize(config:, report:, worktree: Worktree.new(root: config.worktree_root), neutralizer: Neutralizer.new,
|
|
18
18
|
gh_check: GhCheck.new, launcher: Launcher.new(name: config.terminal), out: $stdout, grace: CANCEL_GRACE,
|
|
19
|
-
progress_interval: PROGRESS_INTERVAL, run_script: RunScript.new(state_dir: config.state_dir)
|
|
19
|
+
progress_interval: PROGRESS_INTERVAL, run_script: RunScript.new(state_dir: config.state_dir),
|
|
20
|
+
reaper: Reaper.new(root: config.worktree_root))
|
|
20
21
|
@config = config
|
|
21
22
|
@report = report
|
|
22
23
|
@worktree = worktree
|
|
@@ -27,6 +28,7 @@ module Gitbroker
|
|
|
27
28
|
@grace = grace
|
|
28
29
|
@progress_interval = progress_interval
|
|
29
30
|
@run_script = run_script
|
|
31
|
+
@reaper = reaper
|
|
30
32
|
@handles = {}
|
|
31
33
|
@running = Set.new
|
|
32
34
|
@cancelled = Set.new
|
|
@@ -74,6 +76,8 @@ module Gitbroker
|
|
|
74
76
|
ensure
|
|
75
77
|
files.cleanup!
|
|
76
78
|
end
|
|
79
|
+
stopped = @reaper.reap!(dir)
|
|
80
|
+
say(id, "Stopped #{stopped} leftover process#{"es" unless stopped == 1} in #{dir}") if stopped.positive?
|
|
77
81
|
if @worktree.remove!(dir, repo_path:)
|
|
78
82
|
say(id, "Removed worktree #{dir}")
|
|
79
83
|
else
|
data/lib/gitbroker/version.rb
CHANGED
data/lib/gitbroker.rb
CHANGED
|
@@ -34,6 +34,7 @@ module Gitbroker
|
|
|
34
34
|
autoload :Login, File.expand_path("gitbroker/login", __dir__)
|
|
35
35
|
autoload :Neutralizer, File.expand_path("gitbroker/neutralizer", __dir__)
|
|
36
36
|
autoload :ProcessScanner, File.expand_path("gitbroker/process_scanner", __dir__)
|
|
37
|
+
autoload :Reaper, File.expand_path("gitbroker/reaper", __dir__)
|
|
37
38
|
autoload :RepoScanner, File.expand_path("gitbroker/repo_scanner", __dir__)
|
|
38
39
|
autoload :RunScript, File.expand_path("gitbroker/run_script", __dir__)
|
|
39
40
|
autoload :Runner, File.expand_path("gitbroker/runner", __dir__)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gitbroker
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yuri Sidorov (@newstler)
|
|
@@ -58,6 +58,7 @@ files:
|
|
|
58
58
|
- lib/gitbroker/maintenance.rb
|
|
59
59
|
- lib/gitbroker/neutralizer.rb
|
|
60
60
|
- lib/gitbroker/process_scanner.rb
|
|
61
|
+
- lib/gitbroker/reaper.rb
|
|
61
62
|
- lib/gitbroker/repo_scanner.rb
|
|
62
63
|
- lib/gitbroker/run_script.rb
|
|
63
64
|
- lib/gitbroker/runner.rb
|