agent-council 0.1.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/DESIGN.md +378 -0
- data/INSTALL.md +98 -0
- data/LICENSE.txt +21 -0
- data/README.md +852 -0
- data/RELEASE_NOTES.md +25 -0
- data/code-review-agents.md +28 -0
- data/examples/configs/consolidated.yml +22 -0
- data/examples/configs/single.yml +10 -0
- data/examples/prompts/dnsruby-pull-221-prompt.md +4 -0
- data/examples/prompts/project-name-prompt.md +3 -0
- data/examples/prompts/recommend-model-ensemble-prompt.md +62 -0
- data/examples/prompts/review-docs-prompt.md +101 -0
- data/examples/prompts/review-latest-commit-prompt.md +46 -0
- data/examples/prompts/review-pull-request-prompt.md +91 -0
- data/examples/prompts/smoke-test-prompt.md +1 -0
- data/examples/prompts/state-of-the-code-base-prompt.md +172 -0
- data/examples/prompts/test-coverage-analysis-prompt.md +346 -0
- data/exe/agent-council +12 -0
- data/exe/pi-models +39 -0
- data/lib/agent_council/harnesses/pi_harness.rb +169 -0
- data/lib/agent_council/pi_models.rb +185 -0
- data/lib/agent_council/platform.rb +27 -0
- data/lib/agent_council/templates/analyst_format.md +12 -0
- data/lib/agent_council/templates/consolidate_prompt.md +72 -0
- data/lib/agent_council/version.rb +5 -0
- data/lib/agent_council/worker_pool.rb +217 -0
- data/lib/agent_council.rb +1220 -0
- metadata +84 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
|
|
6
|
+
module AgentCouncil
|
|
7
|
+
# Reads and formats the models available to pi.
|
|
8
|
+
module PiModels
|
|
9
|
+
FIELD_NAMES = {
|
|
10
|
+
provider: 'provider',
|
|
11
|
+
id: 'id',
|
|
12
|
+
name: 'name',
|
|
13
|
+
api: 'api',
|
|
14
|
+
reasoning: 'reasoning',
|
|
15
|
+
context_window: 'contextWindow',
|
|
16
|
+
max_tokens: 'maxTokens',
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
Model = Data.define(*FIELD_NAMES.keys) do
|
|
20
|
+
def self.from_hash(raw)
|
|
21
|
+
missing = FIELD_NAMES.values - raw.keys
|
|
22
|
+
raise ArgumentError, "missing model field(s): #{missing.join(', ')}" unless missing.empty?
|
|
23
|
+
|
|
24
|
+
new(**FIELD_NAMES.to_h { |member, field| [member, raw[field]] })
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Keep pi's JSON field names in --json output while exposing Ruby-style
|
|
28
|
+
# accessors to callers.
|
|
29
|
+
def to_h
|
|
30
|
+
FIELD_NAMES.to_h { |member, field| [field, public_send(member)] }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Raised for failures talking to pi (missing executable, early exit, RPC error).
|
|
35
|
+
class Error < RuntimeError; end
|
|
36
|
+
|
|
37
|
+
RPC_GET_MODELS = { 'type' => 'get_available_models' }.freeze
|
|
38
|
+
RPC_REQUEST_ID = 'list-models'
|
|
39
|
+
|
|
40
|
+
module_function def fetch_models
|
|
41
|
+
Open3.popen3('pi', '--mode', 'rpc', '--no-session') do |stdin, stdout, stderr, wait_thr|
|
|
42
|
+
# pi's output is UTF-8, whatever the locale.
|
|
43
|
+
[stdout, stderr].each { |io| io.set_encoding(Encoding::UTF_8) }
|
|
44
|
+
# Drain stderr concurrently so a chatty pi can't block on a full pipe,
|
|
45
|
+
# and so we can report it if pi fails.
|
|
46
|
+
# An I/O failure while reading is treated as "no diagnostics" so it
|
|
47
|
+
# can't escape as something other than PiModels::Error.
|
|
48
|
+
stderr_reader = Thread.new do
|
|
49
|
+
stderr.read
|
|
50
|
+
rescue IOError, SystemCallError
|
|
51
|
+
''
|
|
52
|
+
end
|
|
53
|
+
begin
|
|
54
|
+
send_rpc_command(stdin, RPC_GET_MODELS.merge('id' => RPC_REQUEST_ID))
|
|
55
|
+
rescue Errno::EPIPE, IOError
|
|
56
|
+
# pi exited (or closed stdin) before we could send the request; fall
|
|
57
|
+
# through so the exit-status/diagnostics check below explains why.
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# No chomp: true: JRuby rejects it on a blockless each_line, and
|
|
61
|
+
# JSON.parse ignores the newline anyway.
|
|
62
|
+
events = stdout.each_line.lazy.filter_map { |line| parse_event(line) }
|
|
63
|
+
response = events.detect { |event| event['id'] == RPC_REQUEST_ID && event['type'] == 'response' }
|
|
64
|
+
return event_to_models(response) if response
|
|
65
|
+
|
|
66
|
+
diagnostics = stderr_reader.value.to_s.strip
|
|
67
|
+
message = "pi exited (#{describe_status(wait_thr.value)}) before responding"
|
|
68
|
+
message += ": #{diagnostics}" unless diagnostics.empty?
|
|
69
|
+
raise Error, message
|
|
70
|
+
ensure
|
|
71
|
+
stderr_reader&.kill
|
|
72
|
+
end
|
|
73
|
+
rescue Errno::ENOENT
|
|
74
|
+
raise Error, 'pi executable not found; is `pi` installed and on your PATH?'
|
|
75
|
+
rescue SystemCallError => e
|
|
76
|
+
# Covers EACCES (not executable) and other spawn-time failures that
|
|
77
|
+
# Errno::ENOENT doesn't catch.
|
|
78
|
+
raise Error, "pi could not be started: #{e.message}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Describes a Process::Status for diagnostics, covering both normal exits
|
|
82
|
+
# and termination by signal (where #exitstatus is nil).
|
|
83
|
+
module_function def describe_status(status)
|
|
84
|
+
return "signal #{status.termsig}" if status.signaled?
|
|
85
|
+
|
|
86
|
+
status.exitstatus.to_s
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Returns the parsed event, or nil for lines that aren't JSON objects
|
|
90
|
+
# (blank lines, stray non-protocol output).
|
|
91
|
+
module_function def parse_event(line)
|
|
92
|
+
event = JSON.parse(line)
|
|
93
|
+
event if event.is_a?(Hash)
|
|
94
|
+
rescue JSON::ParserError
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
module_function def send_rpc_command(stdin, command)
|
|
99
|
+
stdin.sync = true
|
|
100
|
+
stdin.puts(JSON.generate(command))
|
|
101
|
+
stdin.close
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
module_function def event_to_models(event)
|
|
105
|
+
raise Error, "pi error: #{event['error']}" unless event['success']
|
|
106
|
+
|
|
107
|
+
raw_models = event.dig('data', 'models')
|
|
108
|
+
raise Error, "pi response missing data.models: #{event.inspect}" unless raw_models.is_a?(Array)
|
|
109
|
+
|
|
110
|
+
raw_models.map do |raw_model|
|
|
111
|
+
Model.from_hash(raw_model)
|
|
112
|
+
rescue ArgumentError => e
|
|
113
|
+
raise Error, "pi returned a malformed model: #{e.message}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
module_function def help_text
|
|
118
|
+
<<~HELP
|
|
119
|
+
Usage: pi-models [options]
|
|
120
|
+
|
|
121
|
+
List provider/model pairs available to `pi`.
|
|
122
|
+
|
|
123
|
+
Options:
|
|
124
|
+
-t, --table Formatted table (default)
|
|
125
|
+
-j, --json Pretty-printed JSON array of model hashes, with pi's fields:
|
|
126
|
+
#{FIELD_NAMES.values.join(', ')}
|
|
127
|
+
-i, --ids One "provider/id" per line (shell-friendly)
|
|
128
|
+
-h, --help Show this help and exit
|
|
129
|
+
|
|
130
|
+
Examples:
|
|
131
|
+
pi-models
|
|
132
|
+
pi-models --json | jq '.[0]'
|
|
133
|
+
pi-models --ids | head -5
|
|
134
|
+
pi-models --table # explicit default
|
|
135
|
+
HELP
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
module_function def format_as_table(models)
|
|
139
|
+
return "No models configured. Run `pi` interactively and add a provider first.\n" if models.empty?
|
|
140
|
+
|
|
141
|
+
columns = [
|
|
142
|
+
{ label: 'PROVIDER', value: ->(model) { model.provider.to_s }, align: :left },
|
|
143
|
+
{ label: 'MODEL ID', value: ->(model) { model.id.to_s }, align: :left },
|
|
144
|
+
{ label: 'API', value: ->(model) { model.api.to_s }, align: :left },
|
|
145
|
+
{ label: 'REASONING', value: ->(model) { model.reasoning ? 'yes' : 'no' }, align: :left },
|
|
146
|
+
{ label: 'CONTEXT', value: ->(model) { model.context_window.to_s }, align: :right },
|
|
147
|
+
{ label: 'MAX TOKENS', value: ->(model) { model.max_tokens.to_s }, align: :right },
|
|
148
|
+
{ label: 'NAME', value: ->(model) { model.name.to_s }, align: :left },
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
widths = columns.map do |column|
|
|
152
|
+
max_value_length = models.map { |model| column[:value].(model).length }.max
|
|
153
|
+
[max_value_length, column[:label].length].max
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
last_index = columns.length - 1
|
|
157
|
+
format_string = columns.each_with_index.map do |column, index|
|
|
158
|
+
# Don't pad the final left-aligned column; it would only add trailing whitespace.
|
|
159
|
+
next '%s' if index == last_index && column[:align] == :left
|
|
160
|
+
|
|
161
|
+
align_sign = column[:align] == :right ? '' : '-'
|
|
162
|
+
"%#{align_sign}#{widths[index]}s"
|
|
163
|
+
end.join(' ') + "\n"
|
|
164
|
+
|
|
165
|
+
separator = widths.map { |width| '-' * width }.join(' ') + "\n"
|
|
166
|
+
header = format(format_string, *columns.map { |column| column[:label] })
|
|
167
|
+
body_lines = models.map do |model|
|
|
168
|
+
format(format_string, *columns.map do |column|
|
|
169
|
+
column[:value].(model)
|
|
170
|
+
end)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
separator + header + separator + body_lines.join + separator
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
module_function def format_as_json(models)
|
|
177
|
+
JSON.pretty_generate(models.map(&:to_h)) + "\n"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Empty for an empty list, so shell loops and `wc -l` see no models.
|
|
181
|
+
module_function def format_as_ids(models)
|
|
182
|
+
models.map { |model| "#{model.provider}/#{model.id}\n" }.join
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Kept free of other project requires so both executables can load it:
|
|
4
|
+
# pi-models doesn't load lib/agent_council.rb.
|
|
5
|
+
|
|
6
|
+
require 'rbconfig'
|
|
7
|
+
|
|
8
|
+
module AgentCouncil
|
|
9
|
+
# host_os rather than RUBY_PLATFORM, which is "java" on JRuby whatever the
|
|
10
|
+
# OS, and rather than Gem.win_platform?, so as not to depend on RubyGems.
|
|
11
|
+
module_function def windows?
|
|
12
|
+
/mswin|mingw/.match?(RbConfig::CONFIG['host_os'])
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Native Windows is unsupported; WSL works as Linux does. Ruby runs npm's
|
|
16
|
+
# pi.cmd through cmd.exe, which truncates arguments at newlines (breaking
|
|
17
|
+
# the multi-line system prompt), and WorkerPool relies on process groups
|
|
18
|
+
# and SIGTERM.
|
|
19
|
+
module_function def abort_if_native_windows(command = 'agent-council')
|
|
20
|
+
return unless windows?
|
|
21
|
+
|
|
22
|
+
abort <<~MESSAGE
|
|
23
|
+
#{command} does not support native Windows; please run it inside WSL.
|
|
24
|
+
See "Prerequisites" in #{File.expand_path('../../README.md', __dir__)}
|
|
25
|
+
MESSAGE
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Report format
|
|
2
|
+
|
|
3
|
+
Your response will be saved as a standalone Markdown report. A metadata
|
|
4
|
+
table (role, agent, query, harness, provider, model, timestamp, duration)
|
|
5
|
+
is prepended to it
|
|
6
|
+
automatically, so:
|
|
7
|
+
|
|
8
|
+
- Do not add a document title or any top-level (`#`) heading.
|
|
9
|
+
- Begin directly with your first `##` section heading.
|
|
10
|
+
- Use Markdown throughout: headings, lists, and fenced code blocks with
|
|
11
|
+
language tags.
|
|
12
|
+
- Keep file paths and line references concrete and exact.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Consolidation task
|
|
2
|
+
|
|
3
|
+
You are consolidating several independent analyses of the same repository.
|
|
4
|
+
Every analyst received the identical prompt (included below as "Original
|
|
5
|
+
prompt"), followed by formatting instructions for its report. Their reports follow under "Analyst reports", each headed "Report
|
|
6
|
+
from" its source label (`#<agent number> <provider> / <model>`), with a
|
|
7
|
+
"Harness:" line below it. Agents that produced no report are listed last,
|
|
8
|
+
under "Agents without a report", each with the reason shown there (its
|
|
9
|
+
failure, when known).
|
|
10
|
+
|
|
11
|
+
Produce a consolidated report in two parts, with exactly these sections in
|
|
12
|
+
this order. The first part is the findings; the second describes the
|
|
13
|
+
consultation itself. Between them, write a line containing exactly
|
|
14
|
+
`=== META ===` and nothing else (once, and nowhere else in the report).
|
|
15
|
+
|
|
16
|
+
## Findings
|
|
17
|
+
|
|
18
|
+
- Output the findings of each section (consolidated or unique) in a _numbered_ list.
|
|
19
|
+
- For each finding, add explanatory text if it would be helpful to the reader.
|
|
20
|
+
|
|
21
|
+
### Consensus findings
|
|
22
|
+
|
|
23
|
+
Issues identified independently by two or more analysts.
|
|
24
|
+
|
|
25
|
+
For each, list the sources and briefly note any differences in emphasis.
|
|
26
|
+
|
|
27
|
+
### Unique findings
|
|
28
|
+
|
|
29
|
+
Issues raised by exactly one analyst, attributed to its source.
|
|
30
|
+
|
|
31
|
+
- Assess whether each is plausible.
|
|
32
|
+
- Group findings by agent; for each group:
|
|
33
|
+
- output a heading with the agent number, harness, provider, and model
|
|
34
|
+
name (the harness is shown under each report's heading)
|
|
35
|
+
|
|
36
|
+
## Contradictions
|
|
37
|
+
|
|
38
|
+
Points where analysts disagree, with each position attributed.
|
|
39
|
+
|
|
40
|
+
## Overall assessment
|
|
41
|
+
|
|
42
|
+
The most important findings overall and your confidence in each. If you can
|
|
43
|
+
verify a claim against the repository, do so (read-only) and state the
|
|
44
|
+
result.
|
|
45
|
+
|
|
46
|
+
=== META ===
|
|
47
|
+
|
|
48
|
+
## Agent Consultants
|
|
49
|
+
|
|
50
|
+
A list of provider/model agents consulted. For each agent listed under
|
|
51
|
+
"Agents without a report", say it produced no report and give the reason
|
|
52
|
+
shown there; do not infer failures any other way. If a report shows its
|
|
53
|
+
agent could not properly do the task, say so too.
|
|
54
|
+
|
|
55
|
+
## Agent performance
|
|
56
|
+
|
|
57
|
+
A brief review of each agent that produced a report: how deep its research
|
|
58
|
+
went (e.g. did it read code, run commands, verify claims) and how much it
|
|
59
|
+
added to the review (unique or confirmed findings vs. noise or errors).
|
|
60
|
+
For each agent, print a header line with agent number, duration, harness, provider,
|
|
61
|
+
and model. Then print a single text string below it with the performance information.
|
|
62
|
+
|
|
63
|
+
Rules:
|
|
64
|
+
- Begin directly with the "## Findings" section; do not add a
|
|
65
|
+
document title or top-level heading.
|
|
66
|
+
- Attribute every claim to its source model(s); never drop attribution.
|
|
67
|
+
- Explain findings, don't just list them: when listing findings, unanimous
|
|
68
|
+
or otherwise, add enough explanation that the reader can easily
|
|
69
|
+
understand what the issue is. Avoid limiting the text to single-phrase
|
|
70
|
+
attributions.
|
|
71
|
+
- Do not invent findings that appear in no report.
|
|
72
|
+
- Deduplicate, but do not dilute: keep concrete file and line references.
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module AgentCouncil
|
|
6
|
+
# Worker threads and agent processes for a run, and what Ctrl-C (or
|
|
7
|
+
# SIGTERM, SIGHUP, or SIGQUIT) does to them. main's rescue Interrupt calls
|
|
8
|
+
# into this class for everything but its own message.
|
|
9
|
+
#
|
|
10
|
+
# Each agent process runs in its own process group (see #capture3), so a
|
|
11
|
+
# terminal's Ctrl-C reaches only agent-council, never the agents. Once
|
|
12
|
+
# #trap_signals is installed, the first INT, TERM, HUP, or QUIT (not on
|
|
13
|
+
# JRuby; see TERMINATING_SIGNALS) raises Interrupt in the main thread,
|
|
14
|
+
# whose rescue calls #shutdown: it sends every agent SIGTERM (pi then also
|
|
15
|
+
# stops any commands its bash tool started, which run detached and so would
|
|
16
|
+
# survive the agent), waits up to the grace period for the workers to
|
|
17
|
+
# finish, and SIGKILLs agents still running. A second trapped signal other
|
|
18
|
+
# than HUP SIGKILLs them at once. Any HUP after the first signal is
|
|
19
|
+
# ignored: a closing terminal can deliver two at once (one forwarded by the
|
|
20
|
+
# shell, one from the kernel), which must not skip the graceful stop. A
|
|
21
|
+
# signal agent-council was started ignoring (e.g. HUP under nohup) stays
|
|
22
|
+
# ignored. Either way, agent-council then dies by the signal (see
|
|
23
|
+
# .exit_by_signal).
|
|
24
|
+
#
|
|
25
|
+
# Agents run in worker threads, never the main thread, so the main thread
|
|
26
|
+
# is always free to take the Interrupt. Workers report completion through
|
|
27
|
+
# a queue and only the main thread prints, so nothing more is printed once
|
|
28
|
+
# the Interrupt has been taken.
|
|
29
|
+
class WorkerPool
|
|
30
|
+
# Not QUIT on JRuby: the JVM reserves it for thread dumps, so trapping
|
|
31
|
+
# it only prints a warning.
|
|
32
|
+
TERMINATING_SIGNALS = (RUBY_ENGINE == 'jruby' ? %w[INT TERM HUP] : %w[INT TERM HUP QUIT]).freeze
|
|
33
|
+
|
|
34
|
+
# Each agent leads a new process group, so terminal signals skip it and
|
|
35
|
+
# #signal_child reaches the processes in that group (not ones a tool
|
|
36
|
+
# detached into a group of their own).
|
|
37
|
+
SPAWN_OPTIONS = { pgroup: true }.freeze
|
|
38
|
+
|
|
39
|
+
# Ends the process as the signal would have, so the caller (e.g. a shell
|
|
40
|
+
# running agent-council in a loop) sees it was killed by the signal, not
|
|
41
|
+
# that it handled it. QUIT exits 131 instead, rather than dumping core.
|
|
42
|
+
def self.exit_by_signal(name)
|
|
43
|
+
unless name == 'QUIT'
|
|
44
|
+
trap(name, 'SYSTEM_DEFAULT')
|
|
45
|
+
Process.kill(name, Process.pid)
|
|
46
|
+
sleep 1 # in case delivery isn't immediate
|
|
47
|
+
end
|
|
48
|
+
exit!(128 + Signal.list.fetch(name))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def initialize(grace:)
|
|
52
|
+
@grace = grace
|
|
53
|
+
@threads = []
|
|
54
|
+
@finished = Queue.new # indexes of threads as they finish
|
|
55
|
+
@uncollected = 0 # threads not yet #collect'ed
|
|
56
|
+
@children = {} # agent pids (each also its process group id)
|
|
57
|
+
@children_lock = Mutex.new
|
|
58
|
+
@stop_signal = nil # set once shutdown starts signalling agents
|
|
59
|
+
@first_signal = nil # the signal that started the shutdown
|
|
60
|
+
# Taken by the first signal. A Queue, because JRuby can run two traps
|
|
61
|
+
# at once (so checking @first_signal could let both through), and
|
|
62
|
+
# MRI forbids Mutex#lock in a trap but not Queue#pop.
|
|
63
|
+
@first_signal_token = Queue.new << true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Installs the traps described above. The Interrupt is raised with
|
|
67
|
+
# Thread#raise, so it respects the deferral in #start.
|
|
68
|
+
def trap_signals
|
|
69
|
+
TERMINATING_SIGNALS.each do |name|
|
|
70
|
+
previous = trap(name) do
|
|
71
|
+
if first_signal?
|
|
72
|
+
@first_signal = name
|
|
73
|
+
Thread.main.raise(Interrupt)
|
|
74
|
+
elsif name != 'HUP'
|
|
75
|
+
kill_children_and_exit(name)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
trap(name, previous) if previous == 'IGNORE'
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Runs the block in a new worker thread. The Interrupt is deferred while
|
|
83
|
+
# the thread is started and recorded, so #shutdown sees every started
|
|
84
|
+
# thread. The thread would inherit that deferral, so it's undone inside.
|
|
85
|
+
def start(&block)
|
|
86
|
+
Thread.handle_interrupt(Interrupt => :never) do
|
|
87
|
+
index = @threads.size
|
|
88
|
+
@uncollected += 1
|
|
89
|
+
@threads << Thread.new do
|
|
90
|
+
# A worker's exception is re-raised by #collect; don't print it
|
|
91
|
+
# twice.
|
|
92
|
+
Thread.current.report_on_exception = false
|
|
93
|
+
Thread.handle_interrupt(Interrupt => :immediate, &block)
|
|
94
|
+
ensure
|
|
95
|
+
@finished << index
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Waits for the threads started since the last call, yielding each
|
|
101
|
+
# one's value as it finishes. Returns their values in start order.
|
|
102
|
+
# Re-raises if a worker raised.
|
|
103
|
+
def collect
|
|
104
|
+
count = @uncollected
|
|
105
|
+
@uncollected = 0
|
|
106
|
+
finished = Array.new(count) do
|
|
107
|
+
index = @finished.pop
|
|
108
|
+
[index, @threads[index].value.tap { |value| yield value if block_given? }]
|
|
109
|
+
end
|
|
110
|
+
finished.sort_by(&:first).map(&:last)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Open3.capture3, but with the agent in its own process group and
|
|
114
|
+
# tracked for #shutdown while it runs, and its output read as UTF-8
|
|
115
|
+
# whatever the locale. An agent spawned after shutdown has started
|
|
116
|
+
# signalling gets the same signal at once.
|
|
117
|
+
def capture3(env, *argv, stdin_data:)
|
|
118
|
+
Open3.popen3(env, *argv, **SPAWN_OPTIONS) do |stdin, stdout, stderr, wait_thr|
|
|
119
|
+
[stdout, stderr].each { |io| io.set_encoding(Encoding::UTF_8) }
|
|
120
|
+
pid = wait_thr.pid
|
|
121
|
+
begin
|
|
122
|
+
# Read after registering; signal_children sets it before reading
|
|
123
|
+
# @children, so an agent is signalled by one or the other.
|
|
124
|
+
signal = @children_lock.synchronize do
|
|
125
|
+
@children[pid] = true
|
|
126
|
+
@stop_signal
|
|
127
|
+
end
|
|
128
|
+
signal_child(pid, signal) if signal
|
|
129
|
+
out_reader = Thread.new { stdout.read }
|
|
130
|
+
err_reader = Thread.new { stderr.read }
|
|
131
|
+
begin
|
|
132
|
+
stdin.write(stdin_data)
|
|
133
|
+
rescue Errno::EPIPE
|
|
134
|
+
nil # the agent exited without reading its input
|
|
135
|
+
end
|
|
136
|
+
stdin.close
|
|
137
|
+
[out_reader.value, err_reader.value, wait_thr.value]
|
|
138
|
+
ensure
|
|
139
|
+
@children_lock.synchronize { @children.delete(pid) }
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Called from main's rescue Interrupt: yields so the caller can report
|
|
145
|
+
# it, then stops the agents as described above. Returns once the
|
|
146
|
+
# workers are done or the agents have been killed; the caller then
|
|
147
|
+
# calls #exit_by_signal. A second signal meanwhile exits from the trap.
|
|
148
|
+
def shutdown
|
|
149
|
+
flush_stdout # dying by a signal skips this
|
|
150
|
+
yield if block_given?
|
|
151
|
+
signal_children('TERM')
|
|
152
|
+
return if all_joined_by?(monotonic_now + @grace)
|
|
153
|
+
|
|
154
|
+
signal_children('KILL')
|
|
155
|
+
all_joined_by?(monotonic_now + 1) # let killed agents' workers clean up
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# .exit_by_signal for the signal that stopped the run.
|
|
159
|
+
def exit_by_signal
|
|
160
|
+
self.class.exit_by_signal(@first_signal || 'INT')
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# name: the signal that called it, for when it arrived together with the
|
|
164
|
+
# first one, whose trap may not have set @first_signal yet.
|
|
165
|
+
private def kill_children_and_exit(name)
|
|
166
|
+
signal_children('KILL')
|
|
167
|
+
flush_stdout
|
|
168
|
+
self.class.exit_by_signal(@first_signal || name)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private def first_signal?
|
|
172
|
+
@first_signal_token.pop(true)
|
|
173
|
+
rescue ThreadError # already taken
|
|
174
|
+
false
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Signals every running agent's process group. Callable from a trap.
|
|
178
|
+
private def signal_children(signal)
|
|
179
|
+
@stop_signal = signal
|
|
180
|
+
children_snapshot.each { |pid| signal_child(pid, signal) }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# A snapshot, so a concurrent spawn can't modify the hash mid-iteration.
|
|
184
|
+
# MRI forbids Mutex#lock in a trap, but its GVL makes Hash#keys atomic.
|
|
185
|
+
# JRuby has no GVL, but its traps run in ordinary threads, which may lock.
|
|
186
|
+
private def children_snapshot
|
|
187
|
+
return @children.keys unless RUBY_ENGINE == 'jruby'
|
|
188
|
+
|
|
189
|
+
@children_lock.synchronize { @children.keys }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
private def signal_child(pid, signal)
|
|
193
|
+
Process.kill(signal, -pid)
|
|
194
|
+
rescue SystemCallError
|
|
195
|
+
nil # already gone
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# A hung-up terminal can make writing fail; that mustn't stop shutdown.
|
|
199
|
+
private def flush_stdout
|
|
200
|
+
$stdout.flush
|
|
201
|
+
rescue IOError, SystemCallError
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Joins every thread until the deadline; true if all finished. A worker
|
|
206
|
+
# that raised has finished (#collect reports its exception).
|
|
207
|
+
private def all_joined_by?(deadline)
|
|
208
|
+
@threads.map do |thread|
|
|
209
|
+
thread.join([deadline - monotonic_now, 0].max)
|
|
210
|
+
rescue
|
|
211
|
+
true
|
|
212
|
+
end.all?
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
private def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
216
|
+
end
|
|
217
|
+
end
|