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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/exe/gitbroker +10 -0
  4. data/lib/gitbroker/agents/base.rb +48 -0
  5. data/lib/gitbroker/agents/claude.rb +92 -0
  6. data/lib/gitbroker/agents/codex.rb +72 -0
  7. data/lib/gitbroker/agents.rb +18 -0
  8. data/lib/gitbroker/api.rb +53 -0
  9. data/lib/gitbroker/cable_client.rb +213 -0
  10. data/lib/gitbroker/cli.rb +199 -0
  11. data/lib/gitbroker/config.rb +88 -0
  12. data/lib/gitbroker/daemon.rb +166 -0
  13. data/lib/gitbroker/explain_verifier.rb +31 -0
  14. data/lib/gitbroker/gh_check.rb +22 -0
  15. data/lib/gitbroker/git_context.rb +105 -0
  16. data/lib/gitbroker/hook.rb +50 -0
  17. data/lib/gitbroker/hooks_installer.rb +114 -0
  18. data/lib/gitbroker/launcher.rb +62 -0
  19. data/lib/gitbroker/lifecycle.rb +114 -0
  20. data/lib/gitbroker/local_relay.rb +100 -0
  21. data/lib/gitbroker/login.rb +61 -0
  22. data/lib/gitbroker/neutralizer.rb +80 -0
  23. data/lib/gitbroker/process_scanner.rb +103 -0
  24. data/lib/gitbroker/repo_scanner.rb +50 -0
  25. data/lib/gitbroker/run_script.rb +58 -0
  26. data/lib/gitbroker/runner.rb +41 -0
  27. data/lib/gitbroker/service_installer.rb +145 -0
  28. data/lib/gitbroker/skill_installer.rb +30 -0
  29. data/lib/gitbroker/task_files.rb +90 -0
  30. data/lib/gitbroker/task_runner.rb +258 -0
  31. data/lib/gitbroker/url_handler_installer.rb +87 -0
  32. data/lib/gitbroker/version.rb +5 -0
  33. data/lib/gitbroker/worktree.rb +93 -0
  34. data/lib/gitbroker.rb +39 -0
  35. data/plugin/.claude-plugin/plugin.json +5 -0
  36. data/plugin/skills/gitbroker/SKILL.md +36 -0
  37. data/plugin/skills/gitbroker-explain/SKILL.md +141 -0
  38. 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