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.
Files changed (104) hide show
  1. checksums.yaml +7 -0
  2. data/bin/rspec-agents +24 -0
  3. data/lib/async_workers/channel_config.rb +34 -0
  4. data/lib/async_workers/doc/process_manager_design.md +512 -0
  5. data/lib/async_workers/errors.rb +21 -0
  6. data/lib/async_workers/managed_process.rb +284 -0
  7. data/lib/async_workers/output_stream.rb +86 -0
  8. data/lib/async_workers/rpc_channel.rb +159 -0
  9. data/lib/async_workers/transport/base.rb +57 -0
  10. data/lib/async_workers/transport/stdio_transport.rb +91 -0
  11. data/lib/async_workers/transport/unix_socket_transport.rb +112 -0
  12. data/lib/async_workers/worker_group.rb +175 -0
  13. data/lib/async_workers.rb +17 -0
  14. data/lib/rspec/agents/agent_response.rb +61 -0
  15. data/lib/rspec/agents/agents/base.rb +123 -0
  16. data/lib/rspec/agents/cli.rb +342 -0
  17. data/lib/rspec/agents/conversation.rb +308 -0
  18. data/lib/rspec/agents/criterion.rb +237 -0
  19. data/lib/rspec/agents/doc/2026_01_22_observer-system-design.md +757 -0
  20. data/lib/rspec/agents/doc/2026_01_23_parallel_spec_runner-design.md +1060 -0
  21. data/lib/rspec/agents/doc/2026_01_27_event_serialization-design.md +294 -0
  22. data/lib/rspec/agents/doc/2026_01_27_experiment_aggregation_design.md +831 -0
  23. data/lib/rspec/agents/doc/2026_01_29_rspec-agents-studio-design.md +1332 -0
  24. data/lib/rspec/agents/doc/2026_01_29_testing-framework-design.md +1037 -0
  25. data/lib/rspec/agents/doc/2026_02_04-parallel-runner-ui.md +537 -0
  26. data/lib/rspec/agents/doc/2026_02_05_html_renderer_extensions.md +708 -0
  27. data/lib/rspec/agents/doc/scenario_guide.md +289 -0
  28. data/lib/rspec/agents/dsl/agent_proxy.rb +141 -0
  29. data/lib/rspec/agents/dsl/criterion_definition.rb +78 -0
  30. data/lib/rspec/agents/dsl/graph_builder.rb +38 -0
  31. data/lib/rspec/agents/dsl/runner_factory.rb +52 -0
  32. data/lib/rspec/agents/dsl/scenario_set_dsl.rb +166 -0
  33. data/lib/rspec/agents/dsl/test_context.rb +223 -0
  34. data/lib/rspec/agents/dsl/user_proxy.rb +71 -0
  35. data/lib/rspec/agents/dsl.rb +398 -0
  36. data/lib/rspec/agents/evaluation_result.rb +44 -0
  37. data/lib/rspec/agents/event_bus.rb +78 -0
  38. data/lib/rspec/agents/events.rb +141 -0
  39. data/lib/rspec/agents/isolated_event_bus.rb +86 -0
  40. data/lib/rspec/agents/judge.rb +244 -0
  41. data/lib/rspec/agents/llm/anthropic.rb +143 -0
  42. data/lib/rspec/agents/llm/base.rb +64 -0
  43. data/lib/rspec/agents/llm/mock.rb +181 -0
  44. data/lib/rspec/agents/llm/response.rb +52 -0
  45. data/lib/rspec/agents/matchers.rb +554 -0
  46. data/lib/rspec/agents/message.rb +81 -0
  47. data/lib/rspec/agents/metadata.rb +120 -0
  48. data/lib/rspec/agents/observers/base.rb +70 -0
  49. data/lib/rspec/agents/observers/parallel_terminal_observer.rb +151 -0
  50. data/lib/rspec/agents/observers/rpc_notify_observer.rb +43 -0
  51. data/lib/rspec/agents/observers/terminal_observer.rb +103 -0
  52. data/lib/rspec/agents/parallel/controller.rb +284 -0
  53. data/lib/rspec/agents/parallel/example_discovery.rb +153 -0
  54. data/lib/rspec/agents/parallel/partitioner.rb +31 -0
  55. data/lib/rspec/agents/parallel/run_result.rb +22 -0
  56. data/lib/rspec/agents/parallel/ui/interactive_ui.rb +605 -0
  57. data/lib/rspec/agents/parallel/ui/interleaved_ui.rb +139 -0
  58. data/lib/rspec/agents/parallel/ui/output_adapter.rb +127 -0
  59. data/lib/rspec/agents/parallel/ui/quiet_ui.rb +100 -0
  60. data/lib/rspec/agents/parallel/ui/ui_factory.rb +53 -0
  61. data/lib/rspec/agents/parallel/ui/ui_mode.rb +101 -0
  62. data/lib/rspec/agents/prompt_builders/base.rb +113 -0
  63. data/lib/rspec/agents/prompt_builders/criterion_evaluation.rb +136 -0
  64. data/lib/rspec/agents/prompt_builders/goal_achievement_evaluation.rb +142 -0
  65. data/lib/rspec/agents/prompt_builders/grounding_evaluation.rb +172 -0
  66. data/lib/rspec/agents/prompt_builders/intent_evaluation.rb +111 -0
  67. data/lib/rspec/agents/prompt_builders/topic_classification.rb +105 -0
  68. data/lib/rspec/agents/prompt_builders/user_simulation.rb +131 -0
  69. data/lib/rspec/agents/runners/headless_runner.rb +272 -0
  70. data/lib/rspec/agents/runners/parallel_terminal_runner.rb +220 -0
  71. data/lib/rspec/agents/runners/terminal_runner.rb +186 -0
  72. data/lib/rspec/agents/runners/user_simulator.rb +261 -0
  73. data/lib/rspec/agents/scenario.rb +133 -0
  74. data/lib/rspec/agents/scenario_loader.rb +145 -0
  75. data/lib/rspec/agents/serialization/conversation_renderer.rb +161 -0
  76. data/lib/rspec/agents/serialization/extension.rb +199 -0
  77. data/lib/rspec/agents/serialization/extensions/core_extension.rb +66 -0
  78. data/lib/rspec/agents/serialization/presenters.rb +281 -0
  79. data/lib/rspec/agents/serialization/run_data_aggregator.rb +197 -0
  80. data/lib/rspec/agents/serialization/run_data_builder.rb +189 -0
  81. data/lib/rspec/agents/serialization/templates/_alpine.min.js +5 -0
  82. data/lib/rspec/agents/serialization/templates/_base_components.css +196 -0
  83. data/lib/rspec/agents/serialization/templates/_base_components.js +46 -0
  84. data/lib/rspec/agents/serialization/templates/_conversation_fragment.html.haml +34 -0
  85. data/lib/rspec/agents/serialization/templates/_metadata_default.html.haml +17 -0
  86. data/lib/rspec/agents/serialization/templates/_scripts.js +89 -0
  87. data/lib/rspec/agents/serialization/templates/_styles.css +1211 -0
  88. data/lib/rspec/agents/serialization/templates/conversation_document.html.haml +29 -0
  89. data/lib/rspec/agents/serialization/templates/test_suite.html.haml +238 -0
  90. data/lib/rspec/agents/serialization/test_suite_renderer.rb +207 -0
  91. data/lib/rspec/agents/serialization.rb +374 -0
  92. data/lib/rspec/agents/simulator_config.rb +336 -0
  93. data/lib/rspec/agents/spec_executor.rb +494 -0
  94. data/lib/rspec/agents/stable_example_id.rb +147 -0
  95. data/lib/rspec/agents/templates/user_simulation.erb +9 -0
  96. data/lib/rspec/agents/tool_call.rb +53 -0
  97. data/lib/rspec/agents/topic.rb +307 -0
  98. data/lib/rspec/agents/topic_graph.rb +236 -0
  99. data/lib/rspec/agents/triggers.rb +122 -0
  100. data/lib/rspec/agents/turn.rb +63 -0
  101. data/lib/rspec/agents/turn_executor.rb +91 -0
  102. data/lib/rspec/agents/version.rb +7 -0
  103. data/lib/rspec/agents.rb +145 -0
  104. 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