gitbroker 0.3.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 +7 -0
- data/LICENSE.txt +21 -0
- data/exe/gitbroker +10 -0
- data/lib/gitbroker/agents/base.rb +48 -0
- data/lib/gitbroker/agents/claude.rb +92 -0
- data/lib/gitbroker/agents/codex.rb +72 -0
- data/lib/gitbroker/agents.rb +18 -0
- data/lib/gitbroker/api.rb +53 -0
- data/lib/gitbroker/cable_client.rb +213 -0
- data/lib/gitbroker/cli.rb +199 -0
- data/lib/gitbroker/config.rb +88 -0
- data/lib/gitbroker/daemon.rb +166 -0
- data/lib/gitbroker/explain_verifier.rb +31 -0
- data/lib/gitbroker/gh_check.rb +22 -0
- data/lib/gitbroker/git_context.rb +105 -0
- data/lib/gitbroker/hook.rb +50 -0
- data/lib/gitbroker/hooks_installer.rb +114 -0
- data/lib/gitbroker/launcher.rb +62 -0
- data/lib/gitbroker/lifecycle.rb +114 -0
- data/lib/gitbroker/local_relay.rb +100 -0
- data/lib/gitbroker/login.rb +61 -0
- data/lib/gitbroker/neutralizer.rb +80 -0
- data/lib/gitbroker/process_scanner.rb +103 -0
- data/lib/gitbroker/repo_scanner.rb +50 -0
- data/lib/gitbroker/run_script.rb +58 -0
- data/lib/gitbroker/runner.rb +41 -0
- data/lib/gitbroker/service_installer.rb +145 -0
- data/lib/gitbroker/skill_installer.rb +30 -0
- data/lib/gitbroker/task_files.rb +90 -0
- data/lib/gitbroker/task_runner.rb +258 -0
- data/lib/gitbroker/url_handler_installer.rb +87 -0
- data/lib/gitbroker/version.rb +5 -0
- data/lib/gitbroker/worktree.rb +93 -0
- data/lib/gitbroker.rb +39 -0
- data/plugin/.claude-plugin/plugin.json +5 -0
- data/plugin/skills/gitbroker/SKILL.md +36 -0
- data/plugin/skills/gitbroker-explain/SKILL.md +141 -0
- metadata +95 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
|
|
7
|
+
module Gitbroker
|
|
8
|
+
# Terminal mode (spec §4.2 step 6): the script the terminal runs. The task token lives only here (0700, private
|
|
9
|
+
# state dir, deleted on start) and in the agent's environment. The prompt travels in an environment variable, so
|
|
10
|
+
# quoting never matters. When the agent exits, `gitbroker task-ended` tells GitBroker.
|
|
11
|
+
class RunScript
|
|
12
|
+
TASK_ID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
|
|
13
|
+
EXE = File.join(Gitbroker::ROOT, "exe", "gitbroker")
|
|
14
|
+
|
|
15
|
+
def initialize(state_dir:, ruby: RbConfig.ruby, exe: EXE)
|
|
16
|
+
@state_dir = state_dir
|
|
17
|
+
@ruby = ruby
|
|
18
|
+
@exe = exe
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def task_dir(task_id)
|
|
22
|
+
raise Error, "Invalid task id #{task_id.inspect}" unless task_id.to_s.match?(TASK_ID)
|
|
23
|
+
|
|
24
|
+
File.join(@state_dir, "tasks", task_id)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def write!(task_id:, worktree:, token:, server:, argv:, prompt:, notice: nil)
|
|
28
|
+
dir = task_dir(task_id)
|
|
29
|
+
FileUtils.mkdir_p(dir, mode: 0o700)
|
|
30
|
+
File.chmod(0o700, dir)
|
|
31
|
+
path = File.join(dir, "run.sh")
|
|
32
|
+
FileUtils.rm_f(path)
|
|
33
|
+
File.write(path, render(task_id:, worktree:, token:, server:, argv:, prompt:, notice:), perm: 0o700)
|
|
34
|
+
File.chmod(0o700, path)
|
|
35
|
+
path
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def render(task_id:, worktree:, token:, server:, argv:, prompt:, notice:)
|
|
41
|
+
inner = %(#{Shellwords.join(argv)} "$GITBROKER_PROMPT")
|
|
42
|
+
<<~SH
|
|
43
|
+
#!/bin/bash
|
|
44
|
+
# Written by the GitBroker companion for agent task #{task_id}. Deletes itself on start.
|
|
45
|
+
rm -f -- "$0"
|
|
46
|
+
#{"printf '%s\\n' #{Shellwords.escape(notice)}" if notice}
|
|
47
|
+
export GITBROKER_TASK_TOKEN=#{Shellwords.escape(token)}
|
|
48
|
+
export GITBROKER_TASK_ID=#{Shellwords.escape(task_id)}
|
|
49
|
+
export GITBROKER_SERVER=#{Shellwords.escape(server)}
|
|
50
|
+
export GITBROKER_PROMPT=#{Shellwords.escape(prompt)}
|
|
51
|
+
cd #{Shellwords.escape(worktree)} || exit 1
|
|
52
|
+
"${SHELL:-/bin/bash}" -lic #{Shellwords.escape(inner)}
|
|
53
|
+
status=$?
|
|
54
|
+
#{Shellwords.escape(@ruby)} #{Shellwords.escape(@exe)} task-ended "$status"
|
|
55
|
+
SH
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Gitbroker
|
|
6
|
+
class Runner
|
|
7
|
+
# Spec §4.1: never let a repository's own config run code through git.
|
|
8
|
+
SAFE_GIT = [ "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null" ].freeze
|
|
9
|
+
# Git exports these to hooks (and `git rebase` etc.); inherited, they would point every git command at another
|
|
10
|
+
# repository, so each subprocess starts without them.
|
|
11
|
+
GIT_ENV = %w[GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES
|
|
12
|
+
GIT_NAMESPACE GIT_PREFIX GIT_QUARANTINE_PATH].to_h { [ _1, nil ] }.freeze
|
|
13
|
+
|
|
14
|
+
def run!(*argv, chdir: nil)
|
|
15
|
+
options = chdir ? { chdir: } : {}
|
|
16
|
+
out, status = Open3.capture2e(GIT_ENV, *argv, **options)
|
|
17
|
+
raise Error, "`#{argv.join(" ")}` failed: #{out.strip}" unless status.success?
|
|
18
|
+
|
|
19
|
+
out
|
|
20
|
+
rescue Errno::ENOENT
|
|
21
|
+
raise Error, "#{argv.first} not found on PATH"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def success?(*argv)
|
|
25
|
+
_out, status = Open3.capture2e(GIT_ENV, *argv)
|
|
26
|
+
status.success?
|
|
27
|
+
rescue Errno::ENOENT
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def capture(*argv)
|
|
32
|
+
out, _status = Open3.capture2e(GIT_ENV, *argv)
|
|
33
|
+
out
|
|
34
|
+
rescue Errno::ENOENT
|
|
35
|
+
""
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def git(dir, *args) = run!("git", *SAFE_GIT, "-C", dir, *args)
|
|
39
|
+
def git_success?(dir, *args) = success?("git", *SAFE_GIT, "-C", dir, *args)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
|
|
6
|
+
module Gitbroker
|
|
7
|
+
# Runs `gitbroker start` in the background as the logged-in user (launchd on macOS, systemd --user on Linux).
|
|
8
|
+
class ServiceInstaller
|
|
9
|
+
LABEL = "broker.git.companion"
|
|
10
|
+
EXE = File.join(Gitbroker::ROOT, "exe", "gitbroker")
|
|
11
|
+
|
|
12
|
+
def initialize(platform: Gitbroker.platform, home: Dir.home, ruby: RbConfig.ruby, exe: EXE, env: ENV, runner: Runner.new,
|
|
13
|
+
uid: Process.uid)
|
|
14
|
+
@platform = platform
|
|
15
|
+
@home = home
|
|
16
|
+
@ruby = ruby
|
|
17
|
+
@exe = exe
|
|
18
|
+
@env = env
|
|
19
|
+
@runner = runner
|
|
20
|
+
@uid = uid
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def install! = @platform == "macos" ? install_launchd! : install_systemd!
|
|
24
|
+
|
|
25
|
+
def installed? = File.exist?(@platform == "macos" ? launchd_path : systemd_path)
|
|
26
|
+
|
|
27
|
+
# Starts the installed service if it is not running (a running one is left alone). False when there is no
|
|
28
|
+
# service or the manager refused, so the caller can start the daemon itself.
|
|
29
|
+
def start!
|
|
30
|
+
return false unless installed?
|
|
31
|
+
|
|
32
|
+
if @platform == "macos"
|
|
33
|
+
@runner.success?("launchctl", "kickstart", target) || bootstrap_launchd
|
|
34
|
+
else
|
|
35
|
+
@runner.success?("systemctl", "--user", "start", "gitbroker.service")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Stops the service through its manager so KeepAlive / Restart=always does not bring it back; `start!` undoes it
|
|
40
|
+
# (a booted-out launchd job is bootstrapped again).
|
|
41
|
+
def stop!
|
|
42
|
+
return false unless installed?
|
|
43
|
+
|
|
44
|
+
if @platform == "macos"
|
|
45
|
+
@runner.success?("launchctl", "bootout", target)
|
|
46
|
+
else
|
|
47
|
+
@runner.success?("systemctl", "--user", "stop", "gitbroker.service")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Restarts the service so it runs the currently installed gem code. The manager does the restart: the daemon asking
|
|
52
|
+
# for it may be killed mid-call and the new process still starts.
|
|
53
|
+
def restart!
|
|
54
|
+
return false unless installed?
|
|
55
|
+
|
|
56
|
+
if @platform == "macos"
|
|
57
|
+
@runner.success?("launchctl", "kickstart", "-k", target) || bootstrap_launchd
|
|
58
|
+
else
|
|
59
|
+
@runner.success?("systemctl", "--user", "restart", "gitbroker.service")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def plist
|
|
64
|
+
arguments = [ @ruby, @exe, "start" ].map { " <string>#{xml(_1)}</string>" }.join("\n")
|
|
65
|
+
environment = { "PATH" => @env["PATH"].to_s, "SHELL" => @env["SHELL"] || "/bin/zsh", "LANG" => @env["LANG"] || "en_US.UTF-8" }
|
|
66
|
+
.map { |key, value| " <key>#{key}</key>\n <string>#{xml(value)}</string>" }.join("\n")
|
|
67
|
+
log = xml(File.join(@home, "Library", "Logs", "gitbroker.log"))
|
|
68
|
+
<<~XML
|
|
69
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
70
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
71
|
+
<plist version="1.0">
|
|
72
|
+
<dict>
|
|
73
|
+
<key>Label</key>
|
|
74
|
+
<string>#{LABEL}</string>
|
|
75
|
+
<key>ProgramArguments</key>
|
|
76
|
+
<array>
|
|
77
|
+
#{arguments}
|
|
78
|
+
</array>
|
|
79
|
+
<key>EnvironmentVariables</key>
|
|
80
|
+
<dict>
|
|
81
|
+
#{environment}
|
|
82
|
+
</dict>
|
|
83
|
+
<key>RunAtLoad</key>
|
|
84
|
+
<true/>
|
|
85
|
+
<key>KeepAlive</key>
|
|
86
|
+
<true/>
|
|
87
|
+
<key>StandardOutPath</key>
|
|
88
|
+
<string>#{log}</string>
|
|
89
|
+
<key>StandardErrorPath</key>
|
|
90
|
+
<string>#{log}</string>
|
|
91
|
+
</dict>
|
|
92
|
+
</plist>
|
|
93
|
+
XML
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def unit
|
|
97
|
+
<<~UNIT
|
|
98
|
+
[Unit]
|
|
99
|
+
Description=GitBroker companion (runs your own agents on your pull requests)
|
|
100
|
+
After=network-online.target
|
|
101
|
+
|
|
102
|
+
[Service]
|
|
103
|
+
ExecStart="#{@ruby}" "#{@exe}" start
|
|
104
|
+
Restart=always
|
|
105
|
+
RestartSec=5
|
|
106
|
+
Environment="PATH=#{@env["PATH"]}"
|
|
107
|
+
Environment="SHELL=#{@env["SHELL"] || "/bin/bash"}"
|
|
108
|
+
|
|
109
|
+
[Install]
|
|
110
|
+
WantedBy=default.target
|
|
111
|
+
UNIT
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def target = "gui/#{@uid}/#{LABEL}"
|
|
117
|
+
|
|
118
|
+
# kickstart fails once the job was booted out (`stop!`): load it again, RunAtLoad starts it.
|
|
119
|
+
def bootstrap_launchd = @runner.success?("launchctl", "bootstrap", "gui/#{@uid}", launchd_path)
|
|
120
|
+
|
|
121
|
+
def xml(value) = value.to_s.encode(xml: :text)
|
|
122
|
+
|
|
123
|
+
def launchd_path = File.join(@home, "Library", "LaunchAgents", "#{LABEL}.plist")
|
|
124
|
+
def systemd_path = File.join(@home, ".config", "systemd", "user", "gitbroker.service")
|
|
125
|
+
|
|
126
|
+
def install_launchd!
|
|
127
|
+
path = launchd_path
|
|
128
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
129
|
+
FileUtils.mkdir_p(File.join(@home, "Library", "Logs"))
|
|
130
|
+
File.write(path, plist)
|
|
131
|
+
@runner.success?("launchctl", "bootout", "gui/#{@uid}/#{LABEL}") # not loaded yet is fine
|
|
132
|
+
@runner.run!("launchctl", "bootstrap", "gui/#{@uid}", path)
|
|
133
|
+
path
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def install_systemd!
|
|
137
|
+
path = systemd_path
|
|
138
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
139
|
+
File.write(path, unit)
|
|
140
|
+
@runner.run!("systemctl", "--user", "daemon-reload")
|
|
141
|
+
@runner.run!("systemctl", "--user", "enable", "--now", "gitbroker.service")
|
|
142
|
+
path
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Gitbroker
|
|
6
|
+
# Copies the plugin's skills to ${CLAUDE_CONFIG_DIR:-~/.claude}/skills/<name>, for running your own agent without
|
|
7
|
+
# the companion (the copy command). Companion runs load them with --plugin-dir and need no install.
|
|
8
|
+
class SkillInstaller
|
|
9
|
+
SOURCE = File.join(Gitbroker::ROOT, "plugin", "skills")
|
|
10
|
+
|
|
11
|
+
def initialize(home: Dir.home, env: ENV, source: SOURCE)
|
|
12
|
+
@home = home
|
|
13
|
+
@env = env
|
|
14
|
+
@source = source
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def install!
|
|
18
|
+
skills = Dir[File.join(@source, "*", "SKILL.md")].map { File.dirname(_1) }.sort
|
|
19
|
+
raise Error, "No skills found at #{@source}" if skills.empty?
|
|
20
|
+
|
|
21
|
+
skills.map do |skill|
|
|
22
|
+
target = File.join(@env["CLAUDE_CONFIG_DIR"] || File.join(@home, ".claude"), "skills", File.basename(skill))
|
|
23
|
+
FileUtils.rm_rf(target)
|
|
24
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
25
|
+
FileUtils.cp_r(skill, target)
|
|
26
|
+
target
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Gitbroker
|
|
7
|
+
# Per-task files. They reference the task token as ${GITBROKER_TASK_TOKEN}; the value only exists in the agent
|
|
8
|
+
# process environment. Spec §4.2 step 4: the GitBroker MCP config lives outside the repository.
|
|
9
|
+
class TaskFiles
|
|
10
|
+
TOKEN_ENV = "GITBROKER_TASK_TOKEN"
|
|
11
|
+
HOOK_EVENTS = %w[SessionStart Stop SessionEnd].freeze
|
|
12
|
+
SETTINGS_PATH = File.join(".claude", "settings.local.json")
|
|
13
|
+
|
|
14
|
+
def initialize(task_dir:, worktree:, server:, task_id:, runner: Runner.new)
|
|
15
|
+
@task_dir = task_dir
|
|
16
|
+
@worktree = worktree
|
|
17
|
+
@server = server.to_s.chomp("/")
|
|
18
|
+
raise Error, "Invalid task id #{task_id.inspect}" unless task_id.to_s.match?(RunScript::TASK_ID)
|
|
19
|
+
|
|
20
|
+
@task_id = task_id
|
|
21
|
+
@runner = runner
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def mcp_url = "#{@server}/mcp/messages"
|
|
25
|
+
def events_url = "#{@server}/agent_tasks/#{@task_id}/events"
|
|
26
|
+
|
|
27
|
+
def mcp_config!
|
|
28
|
+
FileUtils.mkdir_p(@task_dir, mode: 0o700)
|
|
29
|
+
File.chmod(0o700, @task_dir)
|
|
30
|
+
path = File.join(@task_dir, "mcp.json")
|
|
31
|
+
FileUtils.rm_f(path)
|
|
32
|
+
server = { "type" => "http", "url" => mcp_url, "headers" => { "x-task-token" => "${#{TOKEN_ENV}}" } }
|
|
33
|
+
File.write(path, "#{JSON.pretty_generate({ "mcpServers" => { "gitbroker" => server } })}\n", perm: 0o600)
|
|
34
|
+
File.chmod(0o600, path)
|
|
35
|
+
path
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Terminal mode with Claude Code (spec §4.2 step 6): HTTP hooks report start, stop and end.
|
|
39
|
+
def claude_hooks!
|
|
40
|
+
if @runner.git_success?(@worktree, "ls-files", "--error-unmatch", SETTINGS_PATH)
|
|
41
|
+
raise Error, "The repository tracks #{SETTINGS_PATH}; GitBroker will not edit tracked files."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
path = File.join(@worktree, SETTINGS_PATH)
|
|
45
|
+
if [ File.dirname(path), path ].any? { File.symlink?(_1) }
|
|
46
|
+
raise Error, "#{SETTINGS_PATH} in #{@worktree} is a symlink; GitBroker will not write through it."
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
50
|
+
data = read_json(path)
|
|
51
|
+
existing = data["hooks"] || {}
|
|
52
|
+
raise Error, "#{path} has a \"hooks\" value that is not an object; fix it, then start the task again" unless existing.is_a?(Hash)
|
|
53
|
+
|
|
54
|
+
data["hooks"] = existing.merge(hooks) { |_event, theirs, ours| Array(theirs).reject { ours?(_1) } + ours }
|
|
55
|
+
File.write(path, "#{JSON.pretty_generate(data)}\n")
|
|
56
|
+
exclude_settings
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def cleanup! = FileUtils.rm_rf(@task_dir)
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def hooks
|
|
64
|
+
hook = { "type" => "http", "url" => events_url, "headers" => { "Authorization" => "Bearer ${#{TOKEN_ENV}}" },
|
|
65
|
+
"allowedEnvVars" => [ TOKEN_ENV ], "timeout" => 10 }
|
|
66
|
+
HOOK_EVENTS.to_h { [ _1, [ { "hooks" => [ hook ] } ] ] }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def ours?(group)
|
|
70
|
+
group.is_a?(Hash) && Array(group["hooks"]).any? { _1.is_a?(Hash) && _1["url"].to_s.start_with?("#{@server}/agent_tasks/") }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def exclude_settings
|
|
74
|
+
exclude = File.expand_path(@runner.git(@worktree, "rev-parse", "--git-path", "info/exclude").strip, @worktree)
|
|
75
|
+
FileUtils.mkdir_p(File.dirname(exclude))
|
|
76
|
+
content = File.exist?(exclude) ? File.read(exclude) : ""
|
|
77
|
+
entry = "/#{SETTINGS_PATH}"
|
|
78
|
+
return if content.lines(chomp: true).include?(entry)
|
|
79
|
+
|
|
80
|
+
File.write(exclude, "#{content}#{"\n" unless content.empty? || content.end_with?("\n")}#{entry}\n")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def read_json(path)
|
|
84
|
+
data = File.exist?(path) ? JSON.parse(File.read(path)) : {}
|
|
85
|
+
data.is_a?(Hash) ? data : raise(JSON::ParserError)
|
|
86
|
+
rescue JSON::ParserError
|
|
87
|
+
raise Error, "#{path} is not valid JSON; fix or remove it, then start the task again"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Gitbroker
|
|
6
|
+
# Runs one agent task (spec §4.2): worktree → neutralize (untrusted only) → gh or MCP prompt → MCP config →
|
|
7
|
+
# autonomous agent process or a terminal → progress and the final `finished` report.
|
|
8
|
+
class TaskRunner
|
|
9
|
+
CANCEL_GRACE = 10
|
|
10
|
+
PROGRESS_INTERVAL = 3
|
|
11
|
+
MAX_OUTPUT = 500
|
|
12
|
+
PRINT_OUTPUT = 120
|
|
13
|
+
|
|
14
|
+
Handle = Struct.new(:pid, :cancelled)
|
|
15
|
+
Outcome = Struct.new(:status, :cost_usd, :error_code, :last_output)
|
|
16
|
+
|
|
17
|
+
def initialize(config:, report:, worktree: Worktree.new(root: config.worktree_root), neutralizer: Neutralizer.new,
|
|
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))
|
|
20
|
+
@config = config
|
|
21
|
+
@report = report
|
|
22
|
+
@worktree = worktree
|
|
23
|
+
@neutralizer = neutralizer
|
|
24
|
+
@gh_check = gh_check
|
|
25
|
+
@launcher = launcher
|
|
26
|
+
@out = out
|
|
27
|
+
@grace = grace
|
|
28
|
+
@progress_interval = progress_interval
|
|
29
|
+
@run_script = run_script
|
|
30
|
+
@handles = {}
|
|
31
|
+
@running = Set.new
|
|
32
|
+
@cancelled = Set.new
|
|
33
|
+
@lock = Mutex.new
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def run(task, repositories:)
|
|
37
|
+
id = task.fetch("task_id")
|
|
38
|
+
checkout = Array(repositories).find { _1["full_name"].to_s.casecmp?(task.fetch("repo").to_s) }
|
|
39
|
+
unless checkout
|
|
40
|
+
return finished(id, "failed", 0, "no_checkout", "No local checkout of #{task["repo"]} under #{@config.roots.join(", ")}")
|
|
41
|
+
end
|
|
42
|
+
# The server never offers this (spec §5.1); refuse anyway rather than run a stranger's code, or publish what an
|
|
43
|
+
# injected agent read, unattended.
|
|
44
|
+
if task["untrusted"] && (task["code_changing"] || task["terminal_only"]) && task["mode"] != "terminal"
|
|
45
|
+
return finished(id, "failed", 0, "untrusted_head", "This task runs only in a terminal on an untrusted head")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Codex has no --strict-mcp-config equivalent: a headless run would load the user's own MCP servers next to a
|
|
49
|
+
# prompt-injected PR. Untrusted heads run Codex only in a terminal, where the user watches and approves.
|
|
50
|
+
if task["untrusted"] && task["mode"] != "terminal" && @config.agent == "codex"
|
|
51
|
+
return finished(id, "failed", 0, "untrusted_head",
|
|
52
|
+
"Codex does not run untrusted heads unattended (it would load your own MCP servers). Use Open in terminal, or set agent: claude.")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
repo_path = checkout["path"]
|
|
56
|
+
dir = @worktree.prepare(repo_path:, full_name: task["repo"], number: task["pr_number"], head_sha: task["head_sha"],
|
|
57
|
+
kind: task["kind"], head_ref: task["head_ref"], branch: task["code_changing"] == true,
|
|
58
|
+
cross_repository: task["cross_repository"] == true)
|
|
59
|
+
# Agents report real paths (macOS: /private/var, not /var); progress paths are matched against this form.
|
|
60
|
+
dir = File.realpath(dir)
|
|
61
|
+
restored = task["untrusted"] ? @neutralizer.neutralize!(dir, repo_path:, number: task["pr_number"], base_ref: base_ref(task)) : []
|
|
62
|
+
task = task.merge("prompt" => prompt_for(task))
|
|
63
|
+
agent = Agents.for(@config, worktree: dir)
|
|
64
|
+
say(id, "#{task["kind"]} on #{task["repo"]}##{task["pr_number"]}: starting (#{agent.name}, #{task["mode"] == "terminal" ? "terminal" : "autonomous"})")
|
|
65
|
+
say(id, "Worktree: #{dir}")
|
|
66
|
+
files = TaskFiles.new(task_dir: @run_script.task_dir(id), worktree: dir, server: @config.server, task_id: id)
|
|
67
|
+
mcp_config = files.mcp_config!
|
|
68
|
+
|
|
69
|
+
if task["mode"] == "terminal"
|
|
70
|
+
open_terminal(task, agent, files, mcp_config, dir, restored)
|
|
71
|
+
else
|
|
72
|
+
begin
|
|
73
|
+
run_autonomous(task, agent, mcp_config, dir)
|
|
74
|
+
ensure
|
|
75
|
+
files.cleanup!
|
|
76
|
+
end
|
|
77
|
+
if @worktree.remove!(dir, repo_path:)
|
|
78
|
+
say(id, "Removed worktree #{dir}")
|
|
79
|
+
else
|
|
80
|
+
say(id, "Kept #{dir}: it has uncommitted work.")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
rescue Error, SystemCallError, KeyError => e
|
|
84
|
+
finished(id, "failed", 0, "agent_failed", e.message)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Spec §4.2 step 9: SIGINT to the agent's process group, SIGTERM after the grace period if it is still running.
|
|
88
|
+
def cancel(task_id)
|
|
89
|
+
# A cancel that lands between an explain run's two processes has no handle; remember it so the verifier never starts.
|
|
90
|
+
handle = @lock.synchronize do
|
|
91
|
+
@cancelled << task_id if @running.include?(task_id)
|
|
92
|
+
@handles[task_id]
|
|
93
|
+
end
|
|
94
|
+
return @lock.synchronize { @cancelled.include?(task_id) } unless handle
|
|
95
|
+
|
|
96
|
+
handle.cancelled = true
|
|
97
|
+
signal("INT", handle.pid)
|
|
98
|
+
Thread.new do
|
|
99
|
+
sleep @grace
|
|
100
|
+
signal("TERM", handle.pid) if @lock.synchronize { @handles[task_id].equal?(handle) }
|
|
101
|
+
end
|
|
102
|
+
true
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def base_ref(task) = task["base_ref"].to_s.empty? ? "main" : task["base_ref"]
|
|
108
|
+
|
|
109
|
+
def prompt_for(task)
|
|
110
|
+
gh = task["gh_prompt"].to_s
|
|
111
|
+
!task["read_only"] && !gh.empty? && @gh_check.matches?(task["github_login"]) ? gh : task.fetch("prompt")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def open_terminal(task, agent, files, mcp_config, dir, restored)
|
|
115
|
+
files.claude_hooks! if agent.name == "claude_code"
|
|
116
|
+
lines = [ ("Untrusted head: this PR's own code and tests run on your machine." if task["untrusted"]),
|
|
117
|
+
("Removed or restored from #{base_ref(task)}: #{restored.join(", ")}." if restored.any?) ].compact
|
|
118
|
+
script = @run_script.write!(task_id: task["task_id"], worktree: dir, token: task.fetch("token"), server: @config.server,
|
|
119
|
+
argv: agent.terminal_command(task, mcp_config:), prompt: task["prompt"],
|
|
120
|
+
notice: lines.empty? ? nil : lines.join("\n"))
|
|
121
|
+
@launcher.launch!(script:, cwd: dir, title: "#{task["repo"].split("/").last.downcase}-#{task["pr_number"]}")
|
|
122
|
+
progress(task["task_id"], "opened in terminal", agent.name)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# An explain task gets a verifier pass in a fresh process after a successful write (understanding spec §6.2). The
|
|
126
|
+
# server only accepts verify_explanation once this process announces itself with progress phase "verifier".
|
|
127
|
+
def run_autonomous(task, agent, mcp_config, dir)
|
|
128
|
+
id = task["task_id"]
|
|
129
|
+
@lock.synchronize { @running << id }
|
|
130
|
+
outcome = run_process(task, agent, mcp_config, dir)
|
|
131
|
+
outcome = verify_after_write(task, outcome, agent, mcp_config, dir) if task["kind"] == "explain" && outcome.status == "done"
|
|
132
|
+
finished(id, outcome.status, outcome.cost_usd, outcome.error_code, outcome.last_output)
|
|
133
|
+
ensure
|
|
134
|
+
@lock.synchronize do
|
|
135
|
+
@running.delete(id)
|
|
136
|
+
@cancelled.delete(id)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def verify_after_write(task, write, agent, mcp_config, dir)
|
|
141
|
+
id = task["task_id"]
|
|
142
|
+
prompt = begin
|
|
143
|
+
ExplainVerifier.prompt(task)
|
|
144
|
+
rescue Error => e
|
|
145
|
+
# The explanation is written; keep its cost and let the server show it as not verified.
|
|
146
|
+
say(id, "Verifier skipped: #{e.message}")
|
|
147
|
+
return Outcome.new("done", write.cost_usd, nil, "Verifier skipped: #{e.message}")
|
|
148
|
+
end
|
|
149
|
+
return Outcome.new("cancelled", write.cost_usd, "cancelled", write.last_output) if @lock.synchronize { @cancelled.include?(id) }
|
|
150
|
+
|
|
151
|
+
verify = run_process(task.merge("prompt" => prompt), agent, mcp_config, dir, step: "verifying", phase: "verifier")
|
|
152
|
+
ExplainVerifier.combine(write, verify)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def run_process(task, agent, mcp_config, dir, step: "starting", phase: nil)
|
|
156
|
+
id = task["task_id"]
|
|
157
|
+
reader, writer = IO.pipe
|
|
158
|
+
env = Runner::GIT_ENV.merge(agent.env(task.fetch("token"), id))
|
|
159
|
+
pid = Process.spawn(env, *agent.autonomous_command(task, mcp_config:),
|
|
160
|
+
chdir: dir, in: File::NULL, out: writer, err: writer, pgroup: true)
|
|
161
|
+
writer.close
|
|
162
|
+
handle = Handle.new(pid, false)
|
|
163
|
+
@lock.synchronize { @handles[id] = handle }
|
|
164
|
+
progress(id, step, agent.name, phase)
|
|
165
|
+
result = relay(id, agent, reader)
|
|
166
|
+
_pid, status = Process.wait2(pid)
|
|
167
|
+
# Only the agent's own result text: a raw stream-json line can carry file contents (spec §5.2).
|
|
168
|
+
output = result&.text
|
|
169
|
+
|
|
170
|
+
if handle.cancelled then Outcome.new("cancelled", result&.cost_usd, "cancelled", output)
|
|
171
|
+
elsif status.success? && !result&.error then Outcome.new("done", result&.cost_usd, nil, output)
|
|
172
|
+
else Outcome.new("failed", result&.cost_usd, "agent_failed", output)
|
|
173
|
+
end
|
|
174
|
+
ensure
|
|
175
|
+
writer.close if writer && !writer.closed?
|
|
176
|
+
reader&.close
|
|
177
|
+
@lock.synchronize { @handles.delete(id) }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Coarse progress only (spec §5.2): a step when it changes, at most every progress_interval seconds.
|
|
181
|
+
def relay(id, agent, reader)
|
|
182
|
+
result = nil
|
|
183
|
+
last_step = "starting"
|
|
184
|
+
last_sent = monotonic
|
|
185
|
+
# A log that cannot be sent never ends the run: the work matters more than its transcript.
|
|
186
|
+
log = LogBuffer.new do |entries|
|
|
187
|
+
@report.call("log", { "task_id" => id, "entries" => entries })
|
|
188
|
+
rescue StandardError => e
|
|
189
|
+
say(id, "log not sent: #{e.class}")
|
|
190
|
+
end
|
|
191
|
+
reader.each_line do |line|
|
|
192
|
+
log.add(agent.parse_log(line))
|
|
193
|
+
case (event = agent.parse_stream(line))
|
|
194
|
+
when Agents::Result then result = event
|
|
195
|
+
when Agents::Progress
|
|
196
|
+
next if event.step == last_step || monotonic - last_sent < @progress_interval
|
|
197
|
+
|
|
198
|
+
progress(id, event.step)
|
|
199
|
+
last_step = event.step
|
|
200
|
+
last_sent = monotonic
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
result
|
|
204
|
+
ensure
|
|
205
|
+
log&.flush
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Batches the run's log lines: sent when a second has passed or ten are waiting, and once more at the end.
|
|
209
|
+
class LogBuffer
|
|
210
|
+
INTERVAL = 1.0
|
|
211
|
+
BATCH = 10
|
|
212
|
+
|
|
213
|
+
def initialize(&sender)
|
|
214
|
+
@sender = sender
|
|
215
|
+
@pending = []
|
|
216
|
+
@sent_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def add(entries)
|
|
220
|
+
@pending.concat(entries.map { { "kind" => _1.kind, "text" => _1.text.to_s.scrub } })
|
|
221
|
+
flush if @pending.size >= BATCH || (@pending.any? && Process.clock_gettime(Process::CLOCK_MONOTONIC) - @sent_at >= INTERVAL)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def flush
|
|
225
|
+
@pending.each_slice(BATCH) { @sender.call(_1) }
|
|
226
|
+
@pending = []
|
|
227
|
+
@sent_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def progress(id, step, agent = nil, phase = nil)
|
|
232
|
+
@report.call("progress", { "task_id" => id, "step" => step, "agent" => agent, "phase" => phase }.compact)
|
|
233
|
+
say(id, "progress: #{step}") unless step == "starting"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def finished(id, status, cost_usd, error_code, last_output)
|
|
237
|
+
last = last_output.to_s.lines.last.to_s.strip
|
|
238
|
+
@report.call("finished", { "task_id" => id, "status" => status, "cost_usd" => cost_usd, "error_code" => error_code,
|
|
239
|
+
"last_output" => last[0, MAX_OUTPUT] })
|
|
240
|
+
summary = [ (error_code && error_code != status ? "#{status} (#{error_code})" : status), (format("$%.2f", cost_usd) if cost_usd&.positive?),
|
|
241
|
+
(last[0, PRINT_OUTPUT] unless last.empty?) ].compact.join(" · ")
|
|
242
|
+
say(id, summary)
|
|
243
|
+
nil
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Timestamped one-liners for the terminal running `gitbroker start`. Only ids, steps and the agent's own final
|
|
247
|
+
# result line: never tokens, prompts or file contents.
|
|
248
|
+
def say(id, message) = @out.puts("#{Time.now.strftime("%H:%M:%S")} Task #{id.to_s[0, 8]} #{message}")
|
|
249
|
+
|
|
250
|
+
def signal(name, pid)
|
|
251
|
+
Process.kill(name, -pid)
|
|
252
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
253
|
+
nil
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
257
|
+
end
|
|
258
|
+
end
|