rspec-agents 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/bin/rspec-agents +24 -0
- data/lib/async_workers/channel_config.rb +34 -0
- data/lib/async_workers/doc/process_manager_design.md +512 -0
- data/lib/async_workers/errors.rb +21 -0
- data/lib/async_workers/managed_process.rb +284 -0
- data/lib/async_workers/output_stream.rb +86 -0
- data/lib/async_workers/rpc_channel.rb +159 -0
- data/lib/async_workers/transport/base.rb +57 -0
- data/lib/async_workers/transport/stdio_transport.rb +91 -0
- data/lib/async_workers/transport/unix_socket_transport.rb +112 -0
- data/lib/async_workers/worker_group.rb +175 -0
- data/lib/async_workers.rb +17 -0
- data/lib/rspec/agents/agent_response.rb +61 -0
- data/lib/rspec/agents/agents/base.rb +123 -0
- data/lib/rspec/agents/cli.rb +342 -0
- data/lib/rspec/agents/conversation.rb +308 -0
- data/lib/rspec/agents/criterion.rb +237 -0
- data/lib/rspec/agents/doc/2026_01_22_observer-system-design.md +757 -0
- data/lib/rspec/agents/doc/2026_01_23_parallel_spec_runner-design.md +1060 -0
- data/lib/rspec/agents/doc/2026_01_27_event_serialization-design.md +294 -0
- data/lib/rspec/agents/doc/2026_01_27_experiment_aggregation_design.md +831 -0
- data/lib/rspec/agents/doc/2026_01_29_rspec-agents-studio-design.md +1332 -0
- data/lib/rspec/agents/doc/2026_01_29_testing-framework-design.md +1037 -0
- data/lib/rspec/agents/doc/2026_02_04-parallel-runner-ui.md +537 -0
- data/lib/rspec/agents/doc/2026_02_05_html_renderer_extensions.md +708 -0
- data/lib/rspec/agents/doc/scenario_guide.md +289 -0
- data/lib/rspec/agents/dsl/agent_proxy.rb +141 -0
- data/lib/rspec/agents/dsl/criterion_definition.rb +78 -0
- data/lib/rspec/agents/dsl/graph_builder.rb +38 -0
- data/lib/rspec/agents/dsl/runner_factory.rb +52 -0
- data/lib/rspec/agents/dsl/scenario_set_dsl.rb +166 -0
- data/lib/rspec/agents/dsl/test_context.rb +223 -0
- data/lib/rspec/agents/dsl/user_proxy.rb +71 -0
- data/lib/rspec/agents/dsl.rb +398 -0
- data/lib/rspec/agents/evaluation_result.rb +44 -0
- data/lib/rspec/agents/event_bus.rb +78 -0
- data/lib/rspec/agents/events.rb +141 -0
- data/lib/rspec/agents/isolated_event_bus.rb +86 -0
- data/lib/rspec/agents/judge.rb +244 -0
- data/lib/rspec/agents/llm/anthropic.rb +143 -0
- data/lib/rspec/agents/llm/base.rb +64 -0
- data/lib/rspec/agents/llm/mock.rb +181 -0
- data/lib/rspec/agents/llm/response.rb +52 -0
- data/lib/rspec/agents/matchers.rb +554 -0
- data/lib/rspec/agents/message.rb +81 -0
- data/lib/rspec/agents/metadata.rb +120 -0
- data/lib/rspec/agents/observers/base.rb +70 -0
- data/lib/rspec/agents/observers/parallel_terminal_observer.rb +151 -0
- data/lib/rspec/agents/observers/rpc_notify_observer.rb +43 -0
- data/lib/rspec/agents/observers/terminal_observer.rb +103 -0
- data/lib/rspec/agents/parallel/controller.rb +284 -0
- data/lib/rspec/agents/parallel/example_discovery.rb +153 -0
- data/lib/rspec/agents/parallel/partitioner.rb +31 -0
- data/lib/rspec/agents/parallel/run_result.rb +22 -0
- data/lib/rspec/agents/parallel/ui/interactive_ui.rb +605 -0
- data/lib/rspec/agents/parallel/ui/interleaved_ui.rb +139 -0
- data/lib/rspec/agents/parallel/ui/output_adapter.rb +127 -0
- data/lib/rspec/agents/parallel/ui/quiet_ui.rb +100 -0
- data/lib/rspec/agents/parallel/ui/ui_factory.rb +53 -0
- data/lib/rspec/agents/parallel/ui/ui_mode.rb +101 -0
- data/lib/rspec/agents/prompt_builders/base.rb +113 -0
- data/lib/rspec/agents/prompt_builders/criterion_evaluation.rb +136 -0
- data/lib/rspec/agents/prompt_builders/goal_achievement_evaluation.rb +142 -0
- data/lib/rspec/agents/prompt_builders/grounding_evaluation.rb +172 -0
- data/lib/rspec/agents/prompt_builders/intent_evaluation.rb +111 -0
- data/lib/rspec/agents/prompt_builders/topic_classification.rb +105 -0
- data/lib/rspec/agents/prompt_builders/user_simulation.rb +131 -0
- data/lib/rspec/agents/runners/headless_runner.rb +272 -0
- data/lib/rspec/agents/runners/parallel_terminal_runner.rb +220 -0
- data/lib/rspec/agents/runners/terminal_runner.rb +186 -0
- data/lib/rspec/agents/runners/user_simulator.rb +261 -0
- data/lib/rspec/agents/scenario.rb +133 -0
- data/lib/rspec/agents/scenario_loader.rb +145 -0
- data/lib/rspec/agents/serialization/conversation_renderer.rb +161 -0
- data/lib/rspec/agents/serialization/extension.rb +199 -0
- data/lib/rspec/agents/serialization/extensions/core_extension.rb +66 -0
- data/lib/rspec/agents/serialization/presenters.rb +281 -0
- data/lib/rspec/agents/serialization/run_data_aggregator.rb +197 -0
- data/lib/rspec/agents/serialization/run_data_builder.rb +189 -0
- data/lib/rspec/agents/serialization/templates/_alpine.min.js +5 -0
- data/lib/rspec/agents/serialization/templates/_base_components.css +196 -0
- data/lib/rspec/agents/serialization/templates/_base_components.js +46 -0
- data/lib/rspec/agents/serialization/templates/_conversation_fragment.html.haml +34 -0
- data/lib/rspec/agents/serialization/templates/_metadata_default.html.haml +17 -0
- data/lib/rspec/agents/serialization/templates/_scripts.js +89 -0
- data/lib/rspec/agents/serialization/templates/_styles.css +1211 -0
- data/lib/rspec/agents/serialization/templates/conversation_document.html.haml +29 -0
- data/lib/rspec/agents/serialization/templates/test_suite.html.haml +238 -0
- data/lib/rspec/agents/serialization/test_suite_renderer.rb +207 -0
- data/lib/rspec/agents/serialization.rb +374 -0
- data/lib/rspec/agents/simulator_config.rb +336 -0
- data/lib/rspec/agents/spec_executor.rb +494 -0
- data/lib/rspec/agents/stable_example_id.rb +147 -0
- data/lib/rspec/agents/templates/user_simulation.erb +9 -0
- data/lib/rspec/agents/tool_call.rb +53 -0
- data/lib/rspec/agents/topic.rb +307 -0
- data/lib/rspec/agents/topic_graph.rb +236 -0
- data/lib/rspec/agents/triggers.rb +122 -0
- data/lib/rspec/agents/turn.rb +63 -0
- data/lib/rspec/agents/turn_executor.rb +91 -0
- data/lib/rspec/agents/version.rb +7 -0
- data/lib/rspec/agents.rb +145 -0
- metadata +242 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "channel_config"
|
|
6
|
+
require_relative "output_stream"
|
|
7
|
+
require_relative "rpc_channel"
|
|
8
|
+
require_relative "transport/stdio_transport"
|
|
9
|
+
require_relative "transport/unix_socket_transport"
|
|
10
|
+
|
|
11
|
+
module AsyncWorkers
|
|
12
|
+
# Wraps a single child process with lifecycle management and communication.
|
|
13
|
+
# Provides process spawning, RPC communication, output streaming, and health monitoring.
|
|
14
|
+
#
|
|
15
|
+
# @example Basic usage with RPC
|
|
16
|
+
# Async do |task|
|
|
17
|
+
# process = ManagedProcess.new(
|
|
18
|
+
# command: ['ruby', 'worker.rb'],
|
|
19
|
+
# rpc: ChannelConfig.stdio_rpc
|
|
20
|
+
# )
|
|
21
|
+
#
|
|
22
|
+
# process.stderr.on_data { |line| puts "[worker] #{line}" }
|
|
23
|
+
# process.start(task: task)
|
|
24
|
+
#
|
|
25
|
+
# result = process.rpc.request({ action: 'compute', x: 42 })
|
|
26
|
+
# process.stop
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
class ManagedProcess
|
|
30
|
+
# @return [Integer, nil] Process ID
|
|
31
|
+
attr_reader :pid
|
|
32
|
+
|
|
33
|
+
# @return [Symbol] :pending, :running, :stopping, :exited
|
|
34
|
+
attr_reader :status
|
|
35
|
+
|
|
36
|
+
# @return [Process::Status, nil] Exit status (nil until exited)
|
|
37
|
+
attr_reader :exit_status
|
|
38
|
+
|
|
39
|
+
# @return [RpcChannel, nil] RPC channel (nil if no_rpc)
|
|
40
|
+
attr_reader :rpc
|
|
41
|
+
|
|
42
|
+
# @return [OutputStream] stderr line stream
|
|
43
|
+
attr_reader :stderr
|
|
44
|
+
|
|
45
|
+
# @return [OutputStream] stdout line stream (empty if stdio_rpc mode)
|
|
46
|
+
attr_reader :stdout
|
|
47
|
+
|
|
48
|
+
# @param command [Array<String>] Command to execute
|
|
49
|
+
# @param env [Hash] Environment variables
|
|
50
|
+
# @param chdir [String, nil] Working directory for the process
|
|
51
|
+
# @param rpc [ChannelConfig] RPC configuration
|
|
52
|
+
# @param health_check_interval [Numeric] Health polling interval in seconds
|
|
53
|
+
def initialize(command:, env: {}, chdir: nil, rpc: ChannelConfig.no_rpc, health_check_interval: 0.5)
|
|
54
|
+
@command = command
|
|
55
|
+
@env = env
|
|
56
|
+
@chdir = chdir
|
|
57
|
+
@rpc_config = rpc
|
|
58
|
+
@health_check_interval = health_check_interval
|
|
59
|
+
|
|
60
|
+
@status = :pending
|
|
61
|
+
@pid = nil
|
|
62
|
+
@exit_status = nil
|
|
63
|
+
@transport = nil
|
|
64
|
+
@rpc = nil
|
|
65
|
+
@stderr = OutputStream.new
|
|
66
|
+
@stdout = OutputStream.new
|
|
67
|
+
@exit_callbacks = []
|
|
68
|
+
@exit_condition = nil
|
|
69
|
+
@exited_mutex = Mutex.new
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Spawn process and begin monitoring.
|
|
73
|
+
# @param task [Async::Task] Parent async task
|
|
74
|
+
def start(task:)
|
|
75
|
+
raise "Process already started" unless @status == :pending
|
|
76
|
+
|
|
77
|
+
@exit_condition = Async::Condition.new
|
|
78
|
+
|
|
79
|
+
# Create appropriate transport
|
|
80
|
+
@transport = create_transport
|
|
81
|
+
@pid = @transport.spawn
|
|
82
|
+
@status = :running
|
|
83
|
+
|
|
84
|
+
# Set up RPC if enabled
|
|
85
|
+
if @rpc_config.rpc_enabled?
|
|
86
|
+
@rpc = RpcChannel.new(@transport)
|
|
87
|
+
@rpc.start(task: task)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Start output readers
|
|
91
|
+
start_output_readers(task)
|
|
92
|
+
|
|
93
|
+
# Start health monitor
|
|
94
|
+
start_health_monitor(task)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Graceful shutdown: RPC shutdown -> SIGTERM -> SIGKILL.
|
|
98
|
+
# @param timeout [Numeric] Total timeout for shutdown
|
|
99
|
+
def stop(timeout: 5)
|
|
100
|
+
return if @status == :exited
|
|
101
|
+
|
|
102
|
+
@status = :stopping
|
|
103
|
+
half_timeout = timeout / 2.0
|
|
104
|
+
|
|
105
|
+
# Step 1: RPC shutdown request
|
|
106
|
+
if @rpc && !@rpc.closed?
|
|
107
|
+
@rpc.shutdown(timeout: half_timeout)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Step 2: Wait for process to exit after RPC shutdown, or send SIGTERM
|
|
111
|
+
if alive?
|
|
112
|
+
begin
|
|
113
|
+
wait(timeout: half_timeout)
|
|
114
|
+
rescue Async::TimeoutError
|
|
115
|
+
# Process didn't exit after RPC shutdown, send SIGTERM
|
|
116
|
+
send_signal(:TERM)
|
|
117
|
+
begin
|
|
118
|
+
wait(timeout: half_timeout)
|
|
119
|
+
rescue Async::TimeoutError
|
|
120
|
+
# Continue to SIGKILL
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Step 3: SIGKILL if still alive
|
|
126
|
+
if alive?
|
|
127
|
+
send_signal(:KILL)
|
|
128
|
+
wait
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Ensure we have exit status if process exited but health monitor hasn't run
|
|
132
|
+
collect_exit_status_if_needed
|
|
133
|
+
|
|
134
|
+
cleanup
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Immediate SIGKILL.
|
|
138
|
+
def kill
|
|
139
|
+
return unless alive?
|
|
140
|
+
send_signal(:KILL)
|
|
141
|
+
wait
|
|
142
|
+
cleanup
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Send arbitrary signal to the process.
|
|
146
|
+
# @param signal [Symbol, Integer] Signal to send (e.g., :TERM, :KILL, 9)
|
|
147
|
+
def send_signal(signal)
|
|
148
|
+
return unless @pid && alive?
|
|
149
|
+
Process.kill(signal, @pid)
|
|
150
|
+
rescue Errno::ESRCH
|
|
151
|
+
# Process already gone
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Check if process is running.
|
|
155
|
+
# @return [Boolean]
|
|
156
|
+
def alive?
|
|
157
|
+
return false unless @pid
|
|
158
|
+
Process.kill(0, @pid)
|
|
159
|
+
true
|
|
160
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
161
|
+
false
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Block (yield fiber) until process exits.
|
|
165
|
+
# @param timeout [Numeric, nil] Optional timeout in seconds
|
|
166
|
+
# @return [Process::Status] Exit status
|
|
167
|
+
# @raise [Async::TimeoutError] If timeout exceeded
|
|
168
|
+
def wait(timeout: nil)
|
|
169
|
+
return @exit_status if @status == :exited
|
|
170
|
+
|
|
171
|
+
if timeout
|
|
172
|
+
Async::Task.current.with_timeout(timeout) { @exit_condition.wait }
|
|
173
|
+
else
|
|
174
|
+
@exit_condition.wait
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
@exit_status
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Register exit callback.
|
|
181
|
+
# @yield [Process::Status] Called when process exits
|
|
182
|
+
def on_exit(&block)
|
|
183
|
+
@exit_callbacks << block
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
def create_transport
|
|
189
|
+
case @rpc_config.mode
|
|
190
|
+
when :stdio_rpc
|
|
191
|
+
Transport::StdioTransport.new(command: @command, env: @env, chdir: @chdir)
|
|
192
|
+
when :unix_socket_rpc
|
|
193
|
+
Transport::UnixSocketTransport.new(command: @command, env: @env, chdir: @chdir)
|
|
194
|
+
when :no_rpc
|
|
195
|
+
Transport::StdioTransport.new(command: @command, env: @env, chdir: @chdir)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def start_output_readers(task)
|
|
200
|
+
# Always read stderr
|
|
201
|
+
task.async do
|
|
202
|
+
while (line = @transport.stderr_reader.gets)
|
|
203
|
+
@stderr.emit(line.chomp)
|
|
204
|
+
end
|
|
205
|
+
rescue IOError
|
|
206
|
+
# Stream closed
|
|
207
|
+
ensure
|
|
208
|
+
@stderr.close
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Read stdout if not used for RPC (unix_socket or no_rpc mode)
|
|
212
|
+
unless @rpc_config.stdio?
|
|
213
|
+
stdout_reader = @transport.respond_to?(:stdout_reader) ? @transport.stdout_reader : nil
|
|
214
|
+
if stdout_reader
|
|
215
|
+
task.async do
|
|
216
|
+
while (line = stdout_reader.gets)
|
|
217
|
+
@stdout.emit(line.chomp)
|
|
218
|
+
end
|
|
219
|
+
rescue IOError
|
|
220
|
+
# Stream closed
|
|
221
|
+
ensure
|
|
222
|
+
@stdout.close
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def start_health_monitor(task)
|
|
229
|
+
task.async do
|
|
230
|
+
loop do
|
|
231
|
+
sleep(@health_check_interval)
|
|
232
|
+
|
|
233
|
+
# Check if process is still alive
|
|
234
|
+
unless alive?
|
|
235
|
+
# Process has exited, collect status via transport
|
|
236
|
+
@exit_status = @transport&.wait_for_exit
|
|
237
|
+
handle_exit
|
|
238
|
+
break
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def handle_exit
|
|
245
|
+
# Use mutex to ensure we only handle exit once
|
|
246
|
+
already_exited = @exited_mutex.synchronize do
|
|
247
|
+
was_exited = @status == :exited
|
|
248
|
+
@status = :exited unless was_exited
|
|
249
|
+
was_exited
|
|
250
|
+
end
|
|
251
|
+
return if already_exited
|
|
252
|
+
|
|
253
|
+
@rpc&.close
|
|
254
|
+
@transport&.close
|
|
255
|
+
|
|
256
|
+
@exit_condition.signal(@exit_status)
|
|
257
|
+
|
|
258
|
+
@exit_callbacks.each do |cb|
|
|
259
|
+
cb.call(@exit_status)
|
|
260
|
+
rescue => e
|
|
261
|
+
warn "[AsyncWorkers::ManagedProcess] Exit callback error: #{e.class}: #{e.message}"
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def cleanup
|
|
266
|
+
@rpc&.close
|
|
267
|
+
@transport&.close
|
|
268
|
+
@stderr.close
|
|
269
|
+
@stdout.close
|
|
270
|
+
@exited_mutex.synchronize { @status = :exited }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def collect_exit_status_if_needed
|
|
274
|
+
return if @exit_status
|
|
275
|
+
|
|
276
|
+
# Use transport's wait_for_exit which handles process reaping properly
|
|
277
|
+
status = @transport&.wait_for_exit
|
|
278
|
+
if status
|
|
279
|
+
@exit_status = status
|
|
280
|
+
handle_exit
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsyncWorkers
|
|
4
|
+
# Unified callback and iterator interface for streaming data.
|
|
5
|
+
# Thread-safe, supports multiple callbacks and blocking iteration.
|
|
6
|
+
#
|
|
7
|
+
# @example Callback style
|
|
8
|
+
# stream.on_data { |item| puts item }
|
|
9
|
+
#
|
|
10
|
+
# @example Iterator style (blocking, use in dedicated task)
|
|
11
|
+
# stream.each { |item| puts item }
|
|
12
|
+
#
|
|
13
|
+
# @example Enumerable
|
|
14
|
+
# stream.each.take(10)
|
|
15
|
+
#
|
|
16
|
+
class OutputStream
|
|
17
|
+
include Enumerable
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
@callbacks = []
|
|
21
|
+
@mutex = Mutex.new
|
|
22
|
+
@queue = Thread::Queue.new
|
|
23
|
+
@closed = false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Register callback for incoming data.
|
|
27
|
+
# Multiple callbacks can be registered and will all be called.
|
|
28
|
+
#
|
|
29
|
+
# @yield [item] Block called for each item
|
|
30
|
+
# @return [self]
|
|
31
|
+
def on_data(&block)
|
|
32
|
+
@mutex.synchronize { @callbacks << block }
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Blocking iterator - yields until stream closes.
|
|
37
|
+
# Use in a dedicated async task to avoid blocking other operations.
|
|
38
|
+
#
|
|
39
|
+
# @yield [item] Block called for each item
|
|
40
|
+
# @return [Enumerator] if no block given
|
|
41
|
+
def each
|
|
42
|
+
return enum_for(:each) unless block_given?
|
|
43
|
+
|
|
44
|
+
loop do
|
|
45
|
+
item = @queue.pop
|
|
46
|
+
break if item.equal?(CLOSED_SENTINEL)
|
|
47
|
+
yield item
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Check if stream is closed
|
|
52
|
+
# @return [Boolean]
|
|
53
|
+
def closed?
|
|
54
|
+
@closed
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @api private
|
|
58
|
+
# Emit item to all callbacks and the iterator queue
|
|
59
|
+
def emit(item)
|
|
60
|
+
return if @closed
|
|
61
|
+
|
|
62
|
+
callbacks = @mutex.synchronize { @callbacks.dup }
|
|
63
|
+
callbacks.each do |cb|
|
|
64
|
+
cb.call(item)
|
|
65
|
+
rescue => e
|
|
66
|
+
warn "[AsyncWorkers::OutputStream] Callback error: #{e.class}: #{e.message}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
@queue.push(item)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @api private
|
|
73
|
+
# Close the stream, signaling iterators to stop
|
|
74
|
+
def close
|
|
75
|
+
return if @closed
|
|
76
|
+
@closed = true
|
|
77
|
+
@queue.push(CLOSED_SENTINEL)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Sentinel object to signal stream closure
|
|
83
|
+
CLOSED_SENTINEL = Object.new.freeze
|
|
84
|
+
private_constant :CLOSED_SENTINEL
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "async"
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
require_relative "output_stream"
|
|
8
|
+
|
|
9
|
+
module AsyncWorkers
|
|
10
|
+
# JSON-RPC message framing and request/response correlation.
|
|
11
|
+
# Handles bidirectional communication with newline-delimited JSON.
|
|
12
|
+
class RpcChannel
|
|
13
|
+
SHUTDOWN_ACTION = "__shutdown__"
|
|
14
|
+
|
|
15
|
+
# @return [OutputStream] Incoming notifications (messages without reply_to)
|
|
16
|
+
attr_reader :notifications
|
|
17
|
+
|
|
18
|
+
# @param transport [Transport::Base] Underlying transport
|
|
19
|
+
def initialize(transport)
|
|
20
|
+
@transport = transport
|
|
21
|
+
@pending_requests = {}
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
@closed = false
|
|
24
|
+
@notifications = OutputStream.new
|
|
25
|
+
@reader_task = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Start the reader task to process incoming messages.
|
|
29
|
+
# Must be called after the process is started.
|
|
30
|
+
# @param task [Async::Task] Parent task
|
|
31
|
+
def start(task:)
|
|
32
|
+
@reader_task = task.async { reader_loop }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Send request and wait for response.
|
|
36
|
+
# @param payload [Hash] Request payload
|
|
37
|
+
# @param timeout [Numeric, nil] Optional timeout in seconds
|
|
38
|
+
# @return [Hash] Response payload
|
|
39
|
+
# @raise [ChannelClosedError] If channel is closed
|
|
40
|
+
# @raise [Async::TimeoutError] If timeout exceeded
|
|
41
|
+
def request(payload, timeout: nil)
|
|
42
|
+
raise ChannelClosedError, "Channel closed" if @closed
|
|
43
|
+
|
|
44
|
+
id = SecureRandom.uuid
|
|
45
|
+
condition = Async::Condition.new
|
|
46
|
+
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
@pending_requests[id] = { condition: condition, response: nil }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
send_message(payload.merge(id: id))
|
|
52
|
+
|
|
53
|
+
begin
|
|
54
|
+
if timeout
|
|
55
|
+
Async::Task.current.with_timeout(timeout) { condition.wait }
|
|
56
|
+
else
|
|
57
|
+
condition.wait
|
|
58
|
+
end
|
|
59
|
+
rescue Async::TimeoutError
|
|
60
|
+
@mutex.synchronize { @pending_requests.delete(id) }
|
|
61
|
+
raise
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
entry = @mutex.synchronize { @pending_requests.delete(id) }
|
|
65
|
+
response = entry&.fetch(:response, nil)
|
|
66
|
+
raise ChannelClosedError, "Channel closed while waiting" if response.nil? && @closed
|
|
67
|
+
|
|
68
|
+
response
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Send fire-and-forget notification.
|
|
72
|
+
# @param payload [Hash] Notification payload (should not have id field)
|
|
73
|
+
def notify(payload)
|
|
74
|
+
raise ChannelClosedError, "Channel closed" if @closed
|
|
75
|
+
send_message(payload.reject { |k, _| k == :id || k == "id" })
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Request graceful shutdown via protocol.
|
|
79
|
+
# Sends __shutdown__ action and waits for acknowledgment.
|
|
80
|
+
# @param timeout [Numeric] Timeout for shutdown acknowledgment
|
|
81
|
+
# @return [Hash, nil] Shutdown response or nil on timeout
|
|
82
|
+
def shutdown(timeout: 5)
|
|
83
|
+
return nil if @closed
|
|
84
|
+
|
|
85
|
+
begin
|
|
86
|
+
request({ action: SHUTDOWN_ACTION }, timeout: timeout)
|
|
87
|
+
rescue Async::TimeoutError
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Register callback for notifications (convenience method).
|
|
93
|
+
# @yield [Hash] Notification payload
|
|
94
|
+
def on_notification(&block)
|
|
95
|
+
@notifications.on_data(&block)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Check if channel is closed.
|
|
99
|
+
# @return [Boolean]
|
|
100
|
+
def closed?
|
|
101
|
+
@closed
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Close the channel.
|
|
105
|
+
# Signals all pending requests and stops the reader.
|
|
106
|
+
def close
|
|
107
|
+
return if @closed
|
|
108
|
+
@closed = true
|
|
109
|
+
|
|
110
|
+
# Signal all pending requests so they don't hang
|
|
111
|
+
@mutex.synchronize do
|
|
112
|
+
@pending_requests.each_value do |pending|
|
|
113
|
+
pending[:condition].signal
|
|
114
|
+
end
|
|
115
|
+
@pending_requests.clear
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
@notifications.close
|
|
119
|
+
@reader_task&.stop
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def send_message(payload)
|
|
125
|
+
@transport.write_line(payload.to_json)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def reader_loop
|
|
129
|
+
while (line = @transport.read_line)
|
|
130
|
+
begin
|
|
131
|
+
message = JSON.parse(line, symbolize_names: true)
|
|
132
|
+
handle_incoming(message)
|
|
133
|
+
rescue JSON::ParserError => e
|
|
134
|
+
warn "[AsyncWorkers::RpcChannel] JSON parse error: #{e.message}"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
|
138
|
+
# Transport closed
|
|
139
|
+
ensure
|
|
140
|
+
close
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def handle_incoming(message)
|
|
144
|
+
if message[:reply_to]
|
|
145
|
+
# Response to a request
|
|
146
|
+
@mutex.synchronize do
|
|
147
|
+
pending = @pending_requests[message[:reply_to]]
|
|
148
|
+
if pending
|
|
149
|
+
pending[:response] = message
|
|
150
|
+
pending[:condition].signal
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
else
|
|
154
|
+
# Notification (no reply_to field)
|
|
155
|
+
@notifications.emit(message)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsyncWorkers
|
|
4
|
+
module Transport
|
|
5
|
+
# Abstract base class for transport implementations.
|
|
6
|
+
# Transports handle raw I/O for RPC messages.
|
|
7
|
+
class Base
|
|
8
|
+
# Spawn the child process
|
|
9
|
+
# @return [Integer] Process ID
|
|
10
|
+
def spawn
|
|
11
|
+
raise NotImplementedError, "#{self.class}#spawn must be implemented"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Send a line to the transport (without newline)
|
|
15
|
+
# @param line [String] Line to send
|
|
16
|
+
def write_line(line)
|
|
17
|
+
raise NotImplementedError, "#{self.class}#write_line must be implemented"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Read a line from the transport
|
|
21
|
+
# @return [String, nil] Line read (without newline) or nil on EOF
|
|
22
|
+
def read_line
|
|
23
|
+
raise NotImplementedError, "#{self.class}#read_line must be implemented"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Get the stderr reader IO for log capture
|
|
27
|
+
# @return [IO]
|
|
28
|
+
def stderr_reader
|
|
29
|
+
raise NotImplementedError, "#{self.class}#stderr_reader must be implemented"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Close the transport
|
|
33
|
+
def close
|
|
34
|
+
raise NotImplementedError, "#{self.class}#close must be implemented"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Check if transport is closed
|
|
38
|
+
# @return [Boolean]
|
|
39
|
+
def closed?
|
|
40
|
+
raise NotImplementedError, "#{self.class}#closed? must be implemented"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Get the process ID
|
|
44
|
+
# @return [Integer, nil]
|
|
45
|
+
def pid
|
|
46
|
+
raise NotImplementedError, "#{self.class}#pid must be implemented"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Wait for the process to exit and return exit status.
|
|
50
|
+
# This is a blocking call.
|
|
51
|
+
# @return [Process::Status]
|
|
52
|
+
def wait_for_exit
|
|
53
|
+
raise NotImplementedError, "#{self.class}#wait_for_exit must be implemented"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
|
|
7
|
+
module AsyncWorkers
|
|
8
|
+
module Transport
|
|
9
|
+
# Transport over stdin/stdout using Open3.popen3.
|
|
10
|
+
# RPC messages go over stdin (write) and stdout (read).
|
|
11
|
+
# Stderr is available separately for log capture.
|
|
12
|
+
class StdioTransport < Base
|
|
13
|
+
attr_reader :wait_thread
|
|
14
|
+
|
|
15
|
+
# @param command [Array<String>] Command to execute
|
|
16
|
+
# @param env [Hash] Environment variables
|
|
17
|
+
# @param chdir [String, nil] Working directory for the process
|
|
18
|
+
def initialize(command:, env: {}, chdir: nil)
|
|
19
|
+
@command = command
|
|
20
|
+
@env = env
|
|
21
|
+
@chdir = chdir
|
|
22
|
+
@closed = false
|
|
23
|
+
@stdin = nil
|
|
24
|
+
@stdout = nil
|
|
25
|
+
@stderr = nil
|
|
26
|
+
@wait_thread = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Spawn the process using Open3.popen3
|
|
30
|
+
# @return [Integer] PID
|
|
31
|
+
def spawn
|
|
32
|
+
spawn_opts = {}
|
|
33
|
+
spawn_opts[:chdir] = @chdir if @chdir
|
|
34
|
+
|
|
35
|
+
@stdin, @stdout, @stderr, @wait_thread = Open3.popen3(@env, *@command, **spawn_opts)
|
|
36
|
+
|
|
37
|
+
# Enable sync for proper fiber scheduling
|
|
38
|
+
@stdin.sync = true
|
|
39
|
+
@stdout.sync = true
|
|
40
|
+
@stderr.sync = true
|
|
41
|
+
|
|
42
|
+
@wait_thread.pid
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def write_line(line)
|
|
46
|
+
raise ChannelClosedError, "Transport closed" if @closed
|
|
47
|
+
@stdin.puts(line)
|
|
48
|
+
@stdin.flush
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def read_line
|
|
52
|
+
return nil if @closed
|
|
53
|
+
line = @stdout.gets
|
|
54
|
+
return nil if line.nil?
|
|
55
|
+
line.chomp
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def stderr_reader
|
|
59
|
+
@stderr
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def stdout_reader
|
|
63
|
+
@stdout
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def close
|
|
67
|
+
return if @closed
|
|
68
|
+
@closed = true
|
|
69
|
+
|
|
70
|
+
@stdin&.close rescue nil
|
|
71
|
+
@stdout&.close rescue nil
|
|
72
|
+
@stderr&.close rescue nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def closed?
|
|
76
|
+
@closed
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def pid
|
|
80
|
+
@wait_thread&.pid
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Wait for the process to exit and return exit status.
|
|
84
|
+
# Uses Open3's wait_thread which handles process reaping.
|
|
85
|
+
# @return [Process::Status]
|
|
86
|
+
def wait_for_exit
|
|
87
|
+
@wait_thread&.value
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|