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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d89c372f196a3cd8b81d057f63c8abcdafa765585fcde6abaa15dd8ec81c48bf
4
- data.tar.gz: 473d304e0ed3fe9bafc232490e4be505b17b12b09c2cebf6b194d2547b108203
3
+ metadata.gz: b20e2186e4b0e888878382bcced5b98530f92b642b69a1e20cab0add06301e58
4
+ data.tar.gz: 0da49468966d94932958c778386e432dd00b4feb9c0ee3e0432380100f472bf4
5
5
  SHA512:
6
- metadata.gz: c0d446a0a798acb225933b1cd4c1f31c3425112ac025a2bc684b468e47bc57869b56f892683a4c278cbbb788ac5cd7f5f56fa8286671c98d160d115d72313c4c
7
- data.tar.gz: 3914718aa114dfe375b4783dfe9c6d5e0408d43d319c013e400f4628912e15984f937417043409b6dfd9b3766cb2e6c3ed7f2a74ae678cc52813a35f57560f81
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,Grep,Glob,mcp__gitbroker__*",
11
- "--disallowedTools", "Bash,Edit,Write,NotebookEdit,WebFetch,WebSearch" ].freeze
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 ones with uncommitted work)
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))
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gitbroker
4
- VERSION = "0.7.1"
4
+ VERSION = "0.8.0"
5
5
  end
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__)
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "gitbroker",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Skills for working on Git Broker agent tasks: the gitbroker MCP tools, write rules and report_task."
5
5
  }
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.7.1
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