robot_lab-ractor 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.
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Example 30: Ractor Network Scheduler
5
+ #
6
+ # Demonstrates Track 2 of RobotLab's Ractor parallelism: running a
7
+ # multi-robot pipeline under RactorNetworkScheduler, which dispatches
8
+ # independent tasks in parallel waves and respects depends_on ordering.
9
+ #
10
+ # Concepts covered:
11
+ # - Network.new(parallel_mode: :ractor) — opt the network into Ractor dispatch
12
+ # - Dependency waves — independent tasks run concurrently,
13
+ # dependent tasks wait for their wave
14
+ # - RobotSpec — frozen, Ractor-shareable robot descriptor
15
+ # - RactorNetworkScheduler — direct use for testing / custom wiring
16
+ # - Result Hash — run_pipeline returns { name => result }
17
+ #
18
+ # Part 1 — simulated scheduler, no API key needed.
19
+ # Overrides execute_spec with a sleep-based stub so the wave ordering and
20
+ # timing characteristics are visible without real LLM calls.
21
+ #
22
+ # Part 2 — Network.new(parallel_mode: :ractor) API walkthrough.
23
+ # Shows how to configure the network and inspects the dependency graph.
24
+ # No run() call is made, so no API key is required.
25
+ #
26
+ # Part 3 — live LLM run (optional).
27
+ # Executes automatically when ANTHROPIC_API_KEY is set.
28
+ #
29
+ # Usage:
30
+ # bundle exec ruby examples/30_ractor_network.rb # Parts 1 & 2
31
+ # ANTHROPIC_API_KEY=key ruby examples/30_ractor_network.rb # Parts 1, 2 & 3
32
+
33
+ require "robot_lab"
34
+ require "robot_lab/ractor"
35
+
36
+ puts "=" * 62
37
+ puts "Example 30: Ractor Network Scheduler"
38
+ puts "=" * 62
39
+ puts
40
+
41
+ DIVIDER = ("─" * 54).freeze
42
+
43
+ # =============================================================================
44
+ # Part 1: Simulated scheduler — dependency waves and timing
45
+ # =============================================================================
46
+ #
47
+ # Pipeline topology:
48
+ #
49
+ # headline_finder ─────────────────────────────────┐
50
+ # ├──► report_writer
51
+ # background_brief ─────────────────────────────────┤
52
+ # │
53
+ # fact_checker ─────────────────────────────────┘
54
+ #
55
+ # Wave 1 — headline_finder, background_brief, fact_checker: parallel (none depend on each other)
56
+ # Wave 2 — report_writer: sequential (depends on all three)
57
+ #
58
+ # Simulated latencies (seconds):
59
+ LATENCIES = {
60
+ "headline_finder" => 0.60,
61
+ "background_brief" => 0.50,
62
+ "fact_checker" => 0.40,
63
+ "report_writer" => 0.70
64
+ }.freeze
65
+ #
66
+ # Expected times:
67
+ # Parallel — wave1 = max(0.60, 0.50, 0.40) = 0.60s
68
+ # wave2 = 0.70s
69
+ # total ≈ 1.30s
70
+ #
71
+ # Sequential — 0.60 + 0.50 + 0.40 + 0.70 = 2.20s
72
+ #
73
+ # Speedup ≈ 1.7×
74
+
75
+ puts "── Part 1: Simulated parallel run (no API key) ───────────"
76
+ puts
77
+
78
+ # SimulatedScheduler overrides execute_spec so that instead of
79
+ # constructing a real Robot and calling the LLM, it just sleeps for
80
+ # the robot's configured latency and returns a synthetic result string.
81
+ # All dependency-ordering and wave-dispatch logic is inherited unchanged.
82
+ class SimulatedScheduler < RobotLab::RactorNetworkScheduler
83
+ private
84
+
85
+ def execute_spec(spec, _message)
86
+ delay = LATENCIES[spec.name] || 0.30
87
+ sleep delay
88
+ "#{spec.name}: completed after #{delay}s".freeze
89
+ end
90
+ end
91
+
92
+ # Build RobotSpec objects directly.
93
+ # (Normally Network#run_with_ractor_scheduler builds these from Task wrappers.)
94
+ def make_spec(name, deps = :none)
95
+ spec = RobotLab::RobotSpec.new(
96
+ name: name.freeze,
97
+ template: nil,
98
+ system_prompt: "You are the #{name} robot.".freeze,
99
+ config_hash: {}.freeze
100
+ )
101
+ { spec: spec, depends_on: deps }
102
+ end
103
+
104
+ specs_with_deps = [
105
+ make_spec("headline_finder"),
106
+ make_spec("background_brief"),
107
+ make_spec("fact_checker"),
108
+ make_spec("report_writer", ["headline_finder", "background_brief", "fact_checker"])
109
+ ]
110
+
111
+ memory = RobotLab::Memory.new
112
+ scheduler = SimulatedScheduler.new(memory: memory)
113
+
114
+ topic = "artificial intelligence regulation in 2025"
115
+
116
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
117
+ results = scheduler.run_pipeline(specs_with_deps, message: topic)
118
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
119
+
120
+ scheduler.shutdown
121
+
122
+ sequential_total = LATENCIES.values.sum
123
+
124
+ puts " Pipeline topology:"
125
+ puts " Wave 1 (parallel): headline_finder · background_brief · fact_checker"
126
+ puts " Wave 2 (sequential): report_writer ← waits for all three"
127
+ puts
128
+
129
+ puts " Results (Hash { name => result_string }):"
130
+ results.each { |name, val| puts " \"#{name}\" => #{val.inspect}" }
131
+ puts
132
+
133
+ puts " Wall time: #{"%.3f" % elapsed}s"
134
+ puts " Sequential equiv: #{"%.3f" % sequential_total}s (#{LATENCIES.map { |n, d| "#{n}=#{d}" }.join(' + ')})"
135
+ puts " Speedup: #{(sequential_total / elapsed).round(1)}×"
136
+ puts
137
+
138
+ # =============================================================================
139
+ # Part 2: Network.new(parallel_mode: :ractor) API
140
+ # =============================================================================
141
+
142
+ puts "── Part 2: Network.new(parallel_mode: :ractor) ───────────"
143
+ puts
144
+
145
+ # When parallel_mode: :ractor is set on a Network, network.run(message:)
146
+ # routes through RactorNetworkScheduler instead of SimpleFlow::Pipeline.
147
+ # The default mode is :async (unchanged SimpleFlow behavior).
148
+
149
+ model = "claude-haiku-4-5-20251001"
150
+
151
+ network = RobotLab::Network.new(name: "research_pipeline", parallel_mode: :ractor) do
152
+ task :headline_finder, RobotLab.build(name: "headline_finder",
153
+ system_prompt: "Find the 3 most relevant news headlines. Be concise.",
154
+ model: model),
155
+ depends_on: :none
156
+
157
+ task :background_brief, RobotLab.build(name: "background_brief",
158
+ system_prompt: "Provide a 2-sentence background on the topic.",
159
+ model: model),
160
+ depends_on: :none
161
+
162
+ task :fact_checker, RobotLab.build(name: "fact_checker",
163
+ system_prompt: "List 3 verifiable facts about the topic.",
164
+ model: model),
165
+ depends_on: :none
166
+
167
+ task :report_writer, RobotLab.build(name: "report_writer",
168
+ system_prompt: "Synthesize the provided context into a 3-sentence report.",
169
+ model: model),
170
+ depends_on: ["headline_finder", "background_brief", "fact_checker"]
171
+ end
172
+
173
+ puts " network.name => #{network.name.inspect}"
174
+ puts " network.parallel_mode => #{network.parallel_mode.inspect}"
175
+ puts
176
+ puts " Dependency graph (from network.pipeline.step_dependencies):"
177
+
178
+ network.pipeline.step_dependencies.each do |step, deps|
179
+ dep_str = deps.empty? ? "(entry point — Wave 1)" : "depends on: #{deps.join(', ')}"
180
+ puts " :#{step.to_s.ljust(18)} #{dep_str}"
181
+ end
182
+ puts
183
+ puts " When network.run(message: ...) is called:"
184
+ puts " • RactorNetworkScheduler is created with the network's memory"
185
+ puts " • Each task is converted to a frozen RobotSpec"
186
+ puts " • Wave 1 tasks are dispatched concurrently (Thread per task)"
187
+ puts " • Each Thread submits a RactorJob to the worker queue"
188
+ puts " • A Ractor worker pops the job, constructs a fresh Robot from the"
189
+ puts " spec, calls robot.run(message), and returns the frozen result"
190
+ puts " • Wave 2 begins once all Wave 1 results are available"
191
+ puts " • Return value is a Hash { robot_name => result_string }"
192
+ puts
193
+
194
+ # =============================================================================
195
+ # Part 3: Live LLM run (optional — requires ANTHROPIC_API_KEY)
196
+ # =============================================================================
197
+
198
+ unless ENV["ANTHROPIC_API_KEY"]
199
+ puts "── Part 3: Live LLM run ──────────────────────────────────"
200
+ puts " Set ANTHROPIC_API_KEY to run the real pipeline."
201
+ puts " Expected behavior: headline_finder, background_brief, and"
202
+ puts " fact_checker run in parallel; report_writer follows."
203
+ puts
204
+ puts "=" * 62
205
+ puts "Example 30 complete."
206
+ puts "=" * 62
207
+ exit 0
208
+ end
209
+
210
+ puts "── Part 3: Live LLM run (ANTHROPIC_API_KEY detected) ─────"
211
+ puts
212
+
213
+ puts " Running 4-robot research pipeline on:"
214
+ puts " \"#{topic}\""
215
+ puts
216
+ puts " Wave 1 (parallel): headline_finder · background_brief · fact_checker"
217
+ puts " Wave 2: report_writer"
218
+ puts
219
+
220
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
221
+
222
+ begin
223
+ result = network.run(message: topic)
224
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
225
+
226
+ puts " Completed in #{"%.2f" % elapsed}s"
227
+ puts
228
+ puts " Results:"
229
+ result.each do |name, text|
230
+ snippet = text.to_s.strip.lines.first.to_s.strip
231
+ puts " [#{name}] #{snippet[0..90]}#{snippet.length > 90 ? '…' : ''}"
232
+ end
233
+
234
+ rescue Ractor::IsolationError, Ractor::Error => e
235
+ puts " Ractor error: #{e.class}: #{e.message[0..100]}"
236
+ puts
237
+ puts " ruby_llm is not yet Ractor-safe: it uses Procs in class"
238
+ puts " definitions that cannot cross Ractor boundaries."
239
+ puts " Track this at: https://github.com/crmne/ruby_llm"
240
+
241
+ rescue RobotLab::Error => e
242
+ if e.message.include?("un-shareable Proc")
243
+ puts " Live LLM runs require ruby_llm to be Ractor-safe."
244
+ puts " ruby_llm stores Procs in class-level hooks that cannot cross"
245
+ puts " Ractor boundaries. Part 1 (SimulatedScheduler) demonstrates wave"
246
+ puts " ordering today; Part 3 will work once ruby_llm gains Ractor support."
247
+ else
248
+ puts " RobotLab::Error: #{e.message}"
249
+ end
250
+ end
251
+
252
+ puts
253
+ puts "=" * 62
254
+ puts "Example 30 complete."
255
+ puts "=" * 62
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ # RactorPool (not Ractor) avoids shadowing Ruby's built-in Ractor class
5
+ # for all code within the RobotLab namespace.
6
+ module RactorPool
7
+ VERSION = "0.1.0"
8
+ end
9
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # NOTE: This file intentionally does NOT open module RobotLab::Ractor.
4
+ # Defining that module would shadow Ruby's built-in Ractor class for all
5
+ # code inside the RobotLab namespace, breaking Ractor.new / Ractor.make_shareable
6
+ # calls in RactorWorkerPool, RactorNetworkScheduler, etc.
7
+
8
+ require "etc"
9
+ require "ractor_queue"
10
+ require "ractor/wrapper"
11
+
12
+ require_relative "ractor/version"
13
+ require_relative "ractor_job"
14
+ require_relative "ractor_boundary"
15
+ require_relative "ractor_worker_pool"
16
+ require_relative "ractor_memory_proxy"
17
+ require_relative "ractor_network_scheduler"
18
+
19
+ # Extend the RobotLab module with Ractor pool lifecycle methods.
20
+ # Once robot_lab removes its own copies and adds robot_lab-ractor as a
21
+ # dependency, this becomes the single source of truth for ractor_pool.
22
+ module RobotLab
23
+ class << self
24
+ def ractor_pool
25
+ @ractor_pool ||= begin
26
+ size = respond_to?(:config) && config.respond_to?(:ractor_pool_size) \
27
+ ? (config.ractor_pool_size || :auto) : :auto
28
+ RactorWorkerPool.new(size: size)
29
+ end
30
+ end
31
+
32
+ def shutdown_ractor_pool
33
+ @ractor_pool&.shutdown
34
+ @ractor_pool = nil
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ # Utility for making values safe to pass across Ractor boundaries.
5
+ #
6
+ # Recursively freezes Hash and Array structures. Raises RactorBoundaryError
7
+ # if a value cannot be made Ractor-shareable (e.g. a live IO or Proc).
8
+ #
9
+ # @example
10
+ # safe = RactorBoundary.freeze_deep({ model: "sonnet", args: { x: 1 } })
11
+ # Ractor.shareable?(safe) #=> true
12
+ #
13
+ module RactorBoundary
14
+ # Recursively freeze an object for safe Ractor boundary crossing.
15
+ #
16
+ # @param obj [Object] the value to freeze
17
+ # @return [Object] a frozen, Ractor-shareable copy
18
+ # @raise [RactorBoundaryError] if the value cannot be made shareable
19
+ def self.freeze_deep(obj)
20
+ return obj if Ractor.shareable?(obj)
21
+
22
+ result = case obj
23
+ when Hash
24
+ obj.transform_keys { |k| freeze_deep(k) }
25
+ .transform_values { |v| freeze_deep(v) }
26
+ when Array
27
+ obj.map { |v| freeze_deep(v) }
28
+ else
29
+ begin
30
+ obj.dup
31
+ rescue TypeError
32
+ raise RactorBoundaryError,
33
+ "Cannot make #{obj.class} Ractor-shareable: dup not supported"
34
+ end
35
+ end
36
+
37
+ Ractor.make_shareable(result)
38
+ rescue ::Ractor::IsolationError, ::Ractor::Error => e
39
+ raise RactorBoundaryError, "Cannot make value Ractor-shareable: #{e.message}"
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ # Carrier for work crossing a Ractor boundary.
5
+ #
6
+ # All fields must be Ractor-shareable (frozen Data, frozen String,
7
+ # frozen Hash, or a RactorQueue). Build with RactorBoundary.freeze_deep
8
+ # on the payload before constructing.
9
+ #
10
+ # @example
11
+ # job = RactorJob.new(
12
+ # id: SecureRandom.uuid.freeze,
13
+ # type: :tool,
14
+ # payload: RactorBoundary.freeze_deep({ tool_class: "MyTool", args: { x: 1 } }),
15
+ # reply_queue: RactorQueue.new(capacity: 1)
16
+ # )
17
+ RactorJob = Data.define(:id, :type, :payload, :reply_queue)
18
+
19
+ # Frozen error representation for exceptions raised inside a Ractor worker.
20
+ # Serialized at the Ractor boundary and re-raised on the thread side.
21
+ #
22
+ # @example
23
+ # err = RactorJobError.new(message: e.message.freeze, backtrace: e.backtrace.freeze)
24
+ RactorJobError = Data.define(:message, :backtrace)
25
+
26
+ # Carries everything needed to reconstruct a Robot inside a Ractor.
27
+ # All fields must be frozen strings, symbols, or hashes.
28
+ #
29
+ # @example
30
+ # spec = RobotSpec.new(
31
+ # name: "analyst",
32
+ # template: :analyst,
33
+ # system_prompt: nil,
34
+ # config_hash: { model: "claude-sonnet-4" }.freeze
35
+ # )
36
+ RobotSpec = Data.define(:name, :template, :system_prompt, :config_hash)
37
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ # Wraps a Memory instance via Ractor::Wrapper so Ractor workers can safely
5
+ # read and write shared state.
6
+ #
7
+ # Only get, set, and keys are proxied across the Ractor boundary.
8
+ # Subscriptions and callbacks are NOT proxied — closures are not
9
+ # Ractor-safe. Use the thread-side Memory directly for reactive subscriptions.
10
+ #
11
+ # Values passed to set() must be Ractor-shareable; RactorBoundary.freeze_deep
12
+ # is applied automatically.
13
+ #
14
+ # @example
15
+ # memory = Memory.new
16
+ # proxy = RactorMemoryProxy.new(memory)
17
+ #
18
+ # # From any Ractor via the stub:
19
+ # proxy.set(:result, "done")
20
+ # proxy.get(:result) #=> "done"
21
+ #
22
+ # proxy.shutdown # call when done
23
+ #
24
+ class RactorMemoryProxy
25
+ # @param memory [Memory] the memory instance to wrap
26
+ def initialize(memory)
27
+ @memory = memory
28
+ @wrapper = ::Ractor::Wrapper.new(memory, use_current_ractor: true)
29
+ @stub = @wrapper.stub
30
+ end
31
+
32
+ # Returns the Ractor-shareable stub for use inside Ractors.
33
+ # @return [Ractor::Wrapper stub]
34
+ def stub
35
+ @stub
36
+ end
37
+
38
+ # Read a value from the proxied Memory.
39
+ # @param key [Symbol]
40
+ # @return [Object, nil]
41
+ def get(key)
42
+ @stub.get(key)
43
+ end
44
+
45
+ # Write a frozen value to the proxied Memory.
46
+ # @param key [Symbol]
47
+ # @param value [Object] must be Ractor-shareable after freeze_deep
48
+ # @raise [RactorBoundaryError] if value cannot be made shareable
49
+ def set(key, value)
50
+ frozen_value = RactorBoundary.freeze_deep(value)
51
+ @stub.set(key, frozen_value)
52
+ end
53
+
54
+ # List all keys currently set in the proxied Memory.
55
+ # @return [Array<Symbol>]
56
+ def keys
57
+ @stub.keys
58
+ end
59
+
60
+ # Shut down the ractor-wrapper.
61
+ def shutdown
62
+ @wrapper.async_stop
63
+ @wrapper.join
64
+ rescue => e
65
+ warn "RactorMemoryProxy shutdown error: #{e.message}"
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ # Schedules frozen robot task descriptions across Ractor workers.
5
+ #
6
+ # Robots stay in threads for LLM calls (ruby_llm is not Ractor-safe).
7
+ # The scheduler distributes frozen RobotSpec payloads; each worker
8
+ # constructs a fresh Robot, runs the task, and returns a frozen result.
9
+ #
10
+ # Task ordering respects depends_on: tasks are only dispatched once all
11
+ # named dependencies have resolved (same topological semantics as
12
+ # SimpleFlow::Pipeline).
13
+ #
14
+ # @example
15
+ # scheduler = RactorNetworkScheduler.new(memory: shared_memory)
16
+ # scheduler.run_pipeline([
17
+ # { spec: analyst_spec, depends_on: :none },
18
+ # { spec: writer_spec, depends_on: ["analyst"] }
19
+ # ], message: "Process this")
20
+ # scheduler.shutdown
21
+ #
22
+ class RactorNetworkScheduler
23
+ QUEUE_CAPACITY = 256
24
+
25
+ # @param memory [Memory] shared network memory for all robot tasks
26
+ # @param pool_size [Integer, :auto] number of Ractor workers
27
+ def initialize(memory:, pool_size: :auto)
28
+ @memory = memory
29
+ @work_q = RactorQueue.new(capacity: QUEUE_CAPACITY)
30
+ @size = pool_size == :auto ? Etc.nprocessors : pool_size.to_i
31
+ @workers = @size.times.map { spawn_worker(@work_q) }
32
+ @closed = false
33
+ end
34
+
35
+ # Run a single spec and return the result string.
36
+ # @param spec [RobotSpec]
37
+ # @param message [String]
38
+ # @return [String] the robot's last_text_content
39
+ def run_spec(spec, message:)
40
+ execute_spec(spec, message)
41
+ end
42
+
43
+ # Run a pipeline of specs in dependency order.
44
+ #
45
+ # @param specs_with_deps [Array<Hash>] each entry has :spec and :depends_on
46
+ # @param message [String] initial message passed to entry-point robots
47
+ # @return [Hash<String, String>] name => result for each completed robot
48
+ def run_pipeline(specs_with_deps, message:)
49
+ completed = {}
50
+ remaining = specs_with_deps.dup
51
+
52
+ until remaining.empty?
53
+ ready, remaining = remaining.partition do |entry|
54
+ deps = entry[:depends_on]
55
+ deps == :none || deps == :optional ||
56
+ Array(deps).all? { |d| completed.key?(d) }
57
+ end
58
+
59
+ raise RobotLab::Error, "Circular dependency or unresolvable deps in RactorNetworkScheduler" if ready.empty?
60
+
61
+ threads = ready.map do |entry|
62
+ spec = entry[:spec]
63
+ msg = completed.values.last || message
64
+ Thread.new { [spec.name, execute_spec(spec, msg)] }.tap { |t| t.report_on_exception = false }
65
+ end
66
+
67
+ threads.each do |t|
68
+ name, result = t.value
69
+ completed[name] = result
70
+ end
71
+ end
72
+
73
+ completed
74
+ end
75
+
76
+ # Gracefully shut down worker Ractors.
77
+ def shutdown
78
+ return if @closed
79
+
80
+ @closed = true
81
+ @size.times { @work_q.push(nil) }
82
+ @workers.each { |w| w.join rescue nil }
83
+ end
84
+
85
+ private
86
+
87
+ def execute_spec(spec, message)
88
+ frozen_spec = ::Ractor.make_shareable(spec)
89
+ frozen_message = message.to_s.freeze
90
+ reply_q = RactorQueue.new(capacity: 1)
91
+
92
+ job = RactorJob.new(
93
+ id: SecureRandom.uuid.freeze,
94
+ type: :robot,
95
+ payload: RactorBoundary.freeze_deep({ spec: frozen_spec, message: frozen_message }),
96
+ reply_queue: reply_q
97
+ )
98
+
99
+ @work_q.push(job)
100
+ result = reply_q.pop
101
+
102
+ if result.is_a?(RactorJobError)
103
+ raise RobotLab::Error, "Robot '#{spec.name}' failed in Ractor: #{result.message}"
104
+ end
105
+
106
+ result
107
+ end
108
+
109
+ def spawn_worker(work_q)
110
+ ::Ractor.new(work_q) do |q|
111
+ loop do
112
+ job = q.pop
113
+ break if job.nil?
114
+
115
+ begin
116
+ spec = job.payload[:spec]
117
+ message = job.payload[:message]
118
+
119
+ robot = RobotLab::Robot.new(
120
+ name: spec.name,
121
+ template: spec.template ? spec.template.to_sym : nil,
122
+ system_prompt: spec.system_prompt,
123
+ config: spec.config_hash.empty? ? nil : RobotLab::RunConfig.new(**spec.config_hash.transform_keys(&:to_sym))
124
+ )
125
+
126
+ robot_result = robot.run(message)
127
+ job.reply_queue.push(robot_result.last_text_content.to_s.freeze)
128
+ rescue => e
129
+ err = RobotLab::RactorJobError.new(
130
+ message: e.message.freeze,
131
+ backtrace: (e.backtrace || []).map(&:freeze).freeze
132
+ )
133
+ job.reply_queue.push(err)
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ # A pool of Ractor workers that execute CPU-bound, Ractor-safe tools.
5
+ #
6
+ # Work is distributed via a shared RactorQueue. Each worker runs a
7
+ # blocking loop, pops RactorJob instances, dispatches to the named
8
+ # tool class, and pushes the frozen result (or a RactorJobError) to
9
+ # the job's per-job reply_queue.
10
+ #
11
+ # Shutdown uses a poison-pill pattern: one nil sentinel per worker is
12
+ # pushed to the work queue; each worker exits when it pops nil.
13
+ #
14
+ # Only tools that declare +ractor_safe true+ should be submitted.
15
+ # Tool classes are instantiated fresh inside the Ractor for each call.
16
+ #
17
+ # @example
18
+ # pool = RactorWorkerPool.new(size: 4)
19
+ # result = pool.submit("MyTool", { "arg" => "value" })
20
+ # pool.shutdown
21
+ #
22
+ class RactorWorkerPool
23
+ QUEUE_CAPACITY = 1024
24
+
25
+ attr_reader :size
26
+
27
+ # @param size [Integer, :auto] number of workers (:auto = Etc.nprocessors)
28
+ def initialize(size: :auto)
29
+ @size = size == :auto ? Etc.nprocessors : size.to_i
30
+ @closed = false
31
+ @work_q = RactorQueue.new(capacity: QUEUE_CAPACITY)
32
+ @workers = @size.times.map { spawn_worker(@work_q) }
33
+ end
34
+
35
+ # Submit a tool job and block until the result is available.
36
+ #
37
+ # @param tool_class_name [String] fully-qualified Ruby constant name of the tool class
38
+ # @param args [Hash] tool arguments (deep-frozen before crossing Ractor boundary)
39
+ # @return [Object] the tool's return value
40
+ # @raise [ToolError] if the tool raises inside the Ractor
41
+ def submit(tool_class_name, args)
42
+ raise ToolError, "Pool is shut down" if @closed
43
+
44
+ reply_q = RactorQueue.new(capacity: 1)
45
+ payload = RactorBoundary.freeze_deep({
46
+ tool_class: tool_class_name.to_s,
47
+ args: args
48
+ })
49
+
50
+ job = RactorJob.new(
51
+ id: SecureRandom.uuid.freeze,
52
+ type: :tool,
53
+ payload: payload,
54
+ reply_queue: reply_q
55
+ )
56
+
57
+ @work_q.push(job)
58
+ result = reply_q.pop
59
+
60
+ if result.is_a?(RactorJobError)
61
+ raise ToolError, "Tool '#{tool_class_name}' failed in Ractor: #{result.message}"
62
+ end
63
+
64
+ result
65
+ end
66
+
67
+ # Gracefully shut down the pool via poison-pill pattern.
68
+ # @return [void]
69
+ def shutdown
70
+ return if @closed
71
+
72
+ @closed = true
73
+ @size.times { @work_q.push(nil) }
74
+ @workers.each { |w| w.join rescue nil }
75
+ end
76
+
77
+ private
78
+
79
+ def spawn_worker(work_q)
80
+ ::Ractor.new(work_q) do |q|
81
+ loop do
82
+ job = q.pop
83
+ break if job.nil?
84
+
85
+ begin
86
+ tool_class = Object.const_get(job.payload[:tool_class])
87
+ tool = tool_class.new
88
+ result = tool.execute(**job.payload[:args].transform_keys(&:to_sym))
89
+ frozen_result = ::Ractor.make_shareable(result.frozen? ? result : result.dup.freeze)
90
+ job.reply_queue.push(frozen_result)
91
+ rescue => e
92
+ err = RobotLab::RactorJobError.new(
93
+ message: e.message.freeze,
94
+ backtrace: (e.backtrace || []).map(&:freeze).freeze
95
+ )
96
+ job.reply_queue.push(err)
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end