robot_lab 0.0.9 → 0.0.11

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/README.md +80 -1
  4. data/Rakefile +2 -1
  5. data/docs/api/core/robot.md +182 -0
  6. data/docs/guides/creating-networks.md +21 -0
  7. data/docs/guides/index.md +10 -0
  8. data/docs/guides/knowledge.md +182 -0
  9. data/docs/guides/mcp-integration.md +106 -0
  10. data/docs/guides/memory.md +2 -0
  11. data/docs/guides/observability.md +486 -0
  12. data/docs/guides/ractor-parallelism.md +364 -0
  13. data/docs/superpowers/plans/2026-04-14-ractor-integration.md +1538 -0
  14. data/docs/superpowers/specs/2026-04-14-ractor-integration-design.md +258 -0
  15. data/examples/19_token_tracking.rb +128 -0
  16. data/examples/20_circuit_breaker.rb +153 -0
  17. data/examples/21_learning_loop.rb +164 -0
  18. data/examples/22_context_compression.rb +179 -0
  19. data/examples/23_convergence.rb +137 -0
  20. data/examples/24_structured_delegation.rb +150 -0
  21. data/examples/25_history_search/conversation.jsonl +30 -0
  22. data/examples/25_history_search.rb +136 -0
  23. data/examples/26_document_store/api_versioning_adr.md +52 -0
  24. data/examples/26_document_store/incident_postmortem.md +46 -0
  25. data/examples/26_document_store/postgres_runbook.md +49 -0
  26. data/examples/26_document_store/redis_caching_guide.md +48 -0
  27. data/examples/26_document_store/sidekiq_guide.md +51 -0
  28. data/examples/26_document_store.rb +147 -0
  29. data/examples/27_incident_response/incident_response.rb +244 -0
  30. data/examples/28_mcp_discovery.rb +112 -0
  31. data/examples/29_ractor_tools.rb +243 -0
  32. data/examples/30_ractor_network.rb +256 -0
  33. data/examples/README.md +136 -0
  34. data/examples/prompts/skill_with_mcp_test.md +9 -0
  35. data/examples/prompts/skill_with_robot_name_test.md +5 -0
  36. data/examples/prompts/skill_with_tools_test.md +6 -0
  37. data/lib/robot_lab/bus_poller.rb +149 -0
  38. data/lib/robot_lab/convergence.rb +69 -0
  39. data/lib/robot_lab/delegation_future.rb +93 -0
  40. data/lib/robot_lab/document_store.rb +155 -0
  41. data/lib/robot_lab/error.rb +25 -0
  42. data/lib/robot_lab/history_compressor.rb +205 -0
  43. data/lib/robot_lab/mcp/client.rb +17 -5
  44. data/lib/robot_lab/mcp/connection_poller.rb +187 -0
  45. data/lib/robot_lab/mcp/server.rb +7 -2
  46. data/lib/robot_lab/mcp/server_discovery.rb +110 -0
  47. data/lib/robot_lab/mcp/transports/stdio.rb +6 -0
  48. data/lib/robot_lab/memory.rb +103 -6
  49. data/lib/robot_lab/network.rb +44 -9
  50. data/lib/robot_lab/ractor_boundary.rb +42 -0
  51. data/lib/robot_lab/ractor_job.rb +37 -0
  52. data/lib/robot_lab/ractor_memory_proxy.rb +85 -0
  53. data/lib/robot_lab/ractor_network_scheduler.rb +154 -0
  54. data/lib/robot_lab/ractor_worker_pool.rb +117 -0
  55. data/lib/robot_lab/robot/bus_messaging.rb +43 -65
  56. data/lib/robot_lab/robot/history_search.rb +69 -0
  57. data/lib/robot_lab/robot.rb +228 -11
  58. data/lib/robot_lab/robot_result.rb +24 -5
  59. data/lib/robot_lab/run_config.rb +1 -1
  60. data/lib/robot_lab/text_analysis.rb +103 -0
  61. data/lib/robot_lab/tool.rb +42 -3
  62. data/lib/robot_lab/tool_config.rb +1 -1
  63. data/lib/robot_lab/version.rb +1 -1
  64. data/lib/robot_lab/waiter.rb +49 -29
  65. data/lib/robot_lab.rb +25 -0
  66. data/mkdocs.yml +1 -0
  67. metadata +70 -2
@@ -0,0 +1,1538 @@
1
+ # Ractor Integration Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Add true CPU parallelism to RobotLab via two composable tracks: a `ractor_queue`-backed worker pool for CPU-bound tools, and a `RactorNetworkScheduler` for parallel robot execution, connected by shared frozen-message infrastructure.
6
+
7
+ **Architecture:** Track 1 routes Ractor-safe tools through a `RactorWorkerPool` (N Ractor workers, `ractor_queue` job queue, per-job reply queue). Track 2 wraps `Memory` via `ractor-wrapper` for cross-Ractor shared state, and introduces `RactorNetworkScheduler` to dispatch frozen robot tasks to Ractor workers while respecting `depends_on` ordering. Robots stay in threads for LLM calls; only frozen payloads and results cross Ractor boundaries.
8
+
9
+ **Tech Stack:** Ruby Ractors, `ractor_queue` gem, `ractor-wrapper` gem, Minitest, existing RobotLab test helpers.
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-04-14-ractor-integration-design.md`
12
+
13
+ ---
14
+
15
+ ## File Map
16
+
17
+ ### New files
18
+ | File | Purpose |
19
+ |------|---------|
20
+ | `lib/robot_lab/ractor_job.rb` | `RactorJob`, `RactorJobError`, `RobotSpec` shareable data classes |
21
+ | `lib/robot_lab/ractor_boundary.rb` | `RactorBoundary.freeze_deep` + `RactorBoundaryError` |
22
+ | `lib/robot_lab/ractor_worker_pool.rb` | N Ractor workers fed by `ractor_queue` |
23
+ | `lib/robot_lab/ractor_memory_proxy.rb` | `ractor-wrapper` proxy around `Memory` |
24
+ | `lib/robot_lab/ractor_network_scheduler.rb` | Distributes frozen robot tasks to Ractor workers |
25
+ | `test/robot_lab/ractor_boundary_test.rb` | Tests for `RactorBoundary` |
26
+ | `test/robot_lab/ractor_worker_pool_test.rb` | Tests for `RactorWorkerPool` |
27
+ | `test/robot_lab/ractor_memory_proxy_test.rb` | Tests for `RactorMemoryProxy` |
28
+ | `test/robot_lab/ractor_network_scheduler_test.rb` | Tests for `RactorNetworkScheduler` |
29
+
30
+ ### Modified files
31
+ | File | Change |
32
+ |------|--------|
33
+ | `Gemfile` | Add `ractor_queue`, `ractor-wrapper` |
34
+ | `robot_lab.gemspec` | Add `ractor_queue`, `ractor-wrapper` as runtime dependencies |
35
+ | `lib/robot_lab/error.rb` | Add `RactorBoundaryError` |
36
+ | `lib/robot_lab/tool.rb` | Add `ractor_safe` class macro; route in `call` |
37
+ | `lib/robot_lab/run_config.rb` | Add `ractor_pool_size` to `INFRA_FIELDS` |
38
+ | `lib/robot_lab/bus_poller.rb` | Swap `Array` queues for `ractor_queue` instances |
39
+ | `lib/robot_lab/network.rb` | Add `parallel_mode:` param, delegate to scheduler |
40
+ | `lib/robot_lab.rb` | Add `ractor_pool` accessor + require `ractor_job.rb` explicitly |
41
+
42
+ ---
43
+
44
+ ## Task 1: Add gem dependencies
45
+
46
+ **Files:**
47
+ - Modify: `Gemfile`
48
+ - Modify: `robot_lab.gemspec`
49
+
50
+ - [ ] **Step 1: Add to Gemfile development group**
51
+
52
+ Open `Gemfile`. Add inside `group :development, :test do`:
53
+
54
+ ```ruby
55
+ gem 'ractor_queue'
56
+ gem 'ractor-wrapper'
57
+ ```
58
+
59
+ - [ ] **Step 2: Add to gemspec as runtime dependencies**
60
+
61
+ Open `robot_lab.gemspec`. After the `simple_flow` dependency line, add:
62
+
63
+ ```ruby
64
+ spec.add_dependency "ractor_queue"
65
+ spec.add_dependency "ractor-wrapper"
66
+ ```
67
+
68
+ - [ ] **Step 3: Install**
69
+
70
+ ```bash
71
+ cd /Users/dewayne/sandbox/git_repos/madbomber/robot_lab && bundle install
72
+ ```
73
+
74
+ Expected: both gems install without error. Note the exact versions installed — you will reference them in commit messages.
75
+
76
+ - [ ] **Step 4: Verify gems load**
77
+
78
+ ```bash
79
+ bundle exec ruby -e "require 'ractor_queue'; require 'ractor/wrapper'; puts 'ok'"
80
+ ```
81
+
82
+ Expected output: `ok`
83
+
84
+ > **IMPORTANT:** Before proceeding, open the installed gem source (`bundle show ractor_queue` and `bundle show ractor-wrapper`) and read the README/source to confirm the exact API. The plan uses:
85
+ > - `RactorQueue.new` → `push(item)`, `pop`, `empty?`, `close`
86
+ > - `Ractor::Wrapper.wrap(obj)` → `.call(:method, *args)` for proxied calls
87
+ >
88
+ > If the actual API differs, adjust all subsequent tasks accordingly before implementing them.
89
+
90
+ - [ ] **Step 5: Commit**
91
+
92
+ ```bash
93
+ git add Gemfile Gemfile.lock robot_lab.gemspec
94
+ git commit -m "chore(deps): add ractor_queue and ractor-wrapper gems"
95
+ ```
96
+
97
+ ---
98
+
99
+ ## Task 2: Shared data classes + RactorBoundaryError
100
+
101
+ **Files:**
102
+ - Create: `lib/robot_lab/ractor_job.rb`
103
+ - Modify: `lib/robot_lab/error.rb`
104
+ - Modify: `lib/robot_lab.rb`
105
+
106
+ - [ ] **Step 1: Write failing test**
107
+
108
+ Create `test/robot_lab/ractor_job_test.rb`:
109
+
110
+ ```ruby
111
+ # frozen_string_literal: true
112
+
113
+ require "test_helper"
114
+
115
+ class RobotLab::RactorJobTest < Minitest::Test
116
+ def test_ractor_job_is_shareable
117
+ reply_q = RactorQueue.new
118
+ job = RobotLab::RactorJob.new(
119
+ id: "abc",
120
+ type: :tool,
121
+ payload: { tool_class: "MyTool", args: { x: 1 }.freeze }.freeze,
122
+ reply_queue: reply_q
123
+ )
124
+ assert Ractor.shareable?(job)
125
+ end
126
+
127
+ def test_ractor_job_error_is_shareable
128
+ err = RobotLab::RactorJobError.new(message: "boom", backtrace: ["line 1"].freeze)
129
+ assert Ractor.shareable?(err)
130
+ end
131
+
132
+ def test_robot_spec_is_shareable
133
+ spec = RobotLab::RobotSpec.new(
134
+ name: "bot",
135
+ template: nil,
136
+ system_prompt: "Be helpful.",
137
+ config_hash: { model: "claude-sonnet-4" }.freeze
138
+ )
139
+ assert Ractor.shareable?(spec)
140
+ end
141
+
142
+ def test_ractor_boundary_error_is_subclass_of_error
143
+ assert RobotLab::RactorBoundaryError < RobotLab::Error
144
+ end
145
+ end
146
+ ```
147
+
148
+ - [ ] **Step 2: Run test to verify it fails**
149
+
150
+ ```bash
151
+ bundle exec rake test_file[robot_lab/ractor_job_test.rb]
152
+ ```
153
+
154
+ Expected: fails with `NameError: uninitialized constant RobotLab::RactorJob` or similar.
155
+
156
+ - [ ] **Step 3: Add RactorBoundaryError to error.rb**
157
+
158
+ Open `lib/robot_lab/error.rb`. Add after the `DependencyError` class:
159
+
160
+ ```ruby
161
+ # Raised when a value cannot be made Ractor-shareable before crossing
162
+ # a Ractor boundary (e.g., a live IO, Proc, or object with mutable state).
163
+ #
164
+ # @example
165
+ # raise RactorBoundaryError, "Cannot freeze IO object"
166
+ class RactorBoundaryError < Error; end
167
+ ```
168
+
169
+ - [ ] **Step 4: Create lib/robot_lab/ractor_job.rb**
170
+
171
+ ```ruby
172
+ # frozen_string_literal: true
173
+
174
+ module RobotLab
175
+ # Carrier for work crossing a Ractor boundary.
176
+ #
177
+ # All fields must be Ractor-shareable (frozen Data, frozen String,
178
+ # frozen Hash, or a RactorQueue). Build with RactorBoundary.freeze_deep
179
+ # on the payload before constructing.
180
+ #
181
+ # @example
182
+ # job = RactorJob.new(
183
+ # id: SecureRandom.uuid.freeze,
184
+ # type: :tool,
185
+ # payload: RactorBoundary.freeze_deep({ tool_class: "MyTool", args: { x: 1 } }),
186
+ # reply_queue: RactorQueue.new
187
+ # )
188
+ RactorJob = Data.define(:id, :type, :payload, :reply_queue)
189
+
190
+ # Frozen error representation for exceptions raised inside a Ractor worker.
191
+ # Serialized at the Ractor boundary and re-raised on the thread side.
192
+ #
193
+ # @example
194
+ # err = RactorJobError.new(message: e.message, backtrace: e.backtrace.freeze)
195
+ RactorJobError = Data.define(:message, :backtrace)
196
+
197
+ # Carries everything needed to reconstruct a Robot inside a Ractor.
198
+ # All fields must be frozen strings, symbols, or hashes.
199
+ #
200
+ # @example
201
+ # spec = RobotSpec.new(
202
+ # name: "analyst",
203
+ # template: :analyst,
204
+ # system_prompt: nil,
205
+ # config_hash: { model: "claude-sonnet-4" }.freeze
206
+ # )
207
+ RobotSpec = Data.define(:name, :template, :system_prompt, :config_hash)
208
+ end
209
+ ```
210
+
211
+ - [ ] **Step 5: Explicitly require ractor_job.rb in lib/robot_lab.rb**
212
+
213
+ Open `lib/robot_lab.rb`. After the existing explicit requires (after `require_relative 'robot_lab/memory'`), add:
214
+
215
+ ```ruby
216
+ require_relative 'robot_lab/ractor_job'
217
+ ```
218
+
219
+ - [ ] **Step 6: Run test to verify it passes**
220
+
221
+ ```bash
222
+ bundle exec rake test_file[robot_lab/ractor_job_test.rb]
223
+ ```
224
+
225
+ Expected: all 4 tests pass.
226
+
227
+ - [ ] **Step 7: Commit**
228
+
229
+ ```bash
230
+ git add lib/robot_lab/ractor_job.rb lib/robot_lab/error.rb lib/robot_lab.rb test/robot_lab/ractor_job_test.rb
231
+ git commit -m "feat(ractor): add RactorJob, RactorJobError, RobotSpec data classes and RactorBoundaryError"
232
+ ```
233
+
234
+ ---
235
+
236
+ ## Task 3: RactorBoundary utility module
237
+
238
+ **Files:**
239
+ - Create: `lib/robot_lab/ractor_boundary.rb`
240
+ - Create: `test/robot_lab/ractor_boundary_test.rb`
241
+
242
+ - [ ] **Step 1: Write failing test**
243
+
244
+ Create `test/robot_lab/ractor_boundary_test.rb`:
245
+
246
+ ```ruby
247
+ # frozen_string_literal: true
248
+
249
+ require "test_helper"
250
+
251
+ class RobotLab::RactorBoundaryTest < Minitest::Test
252
+ def test_freezes_string
253
+ result = RobotLab::RactorBoundary.freeze_deep("hello")
254
+ assert result.frozen?
255
+ end
256
+
257
+ def test_freezes_hash_recursively
258
+ result = RobotLab::RactorBoundary.freeze_deep({ a: { b: "c" } })
259
+ assert result.frozen?
260
+ assert result[:a].frozen?
261
+ assert result[:a][:b].frozen?
262
+ end
263
+
264
+ def test_freezes_array_recursively
265
+ result = RobotLab::RactorBoundary.freeze_deep(["x", { y: "z" }])
266
+ assert result.frozen?
267
+ assert result[0].frozen?
268
+ assert result[1].frozen?
269
+ end
270
+
271
+ def test_passes_through_already_frozen
272
+ frozen_str = "hi".freeze
273
+ assert_same frozen_str, RobotLab::RactorBoundary.freeze_deep(frozen_str)
274
+ end
275
+
276
+ def test_passes_through_integer
277
+ assert_equal 42, RobotLab::RactorBoundary.freeze_deep(42)
278
+ end
279
+
280
+ def test_passes_through_symbol
281
+ assert_equal :foo, RobotLab::RactorBoundary.freeze_deep(:foo)
282
+ end
283
+
284
+ def test_result_is_ractor_shareable
285
+ result = RobotLab::RactorBoundary.freeze_deep({ model: "sonnet", args: [1, 2] })
286
+ assert Ractor.shareable?(result)
287
+ end
288
+
289
+ def test_raises_ractor_boundary_error_on_unshareable
290
+ io = StringIO.new
291
+ assert_raises(RobotLab::RactorBoundaryError) do
292
+ RobotLab::RactorBoundary.freeze_deep(io)
293
+ end
294
+ end
295
+ end
296
+ ```
297
+
298
+ - [ ] **Step 2: Run test to verify it fails**
299
+
300
+ ```bash
301
+ bundle exec rake test_file[robot_lab/ractor_boundary_test.rb]
302
+ ```
303
+
304
+ Expected: fails with `NameError: uninitialized constant RobotLab::RactorBoundary`.
305
+
306
+ - [ ] **Step 3: Create lib/robot_lab/ractor_boundary.rb**
307
+
308
+ ```ruby
309
+ # frozen_string_literal: true
310
+
311
+ module RobotLab
312
+ # Utility for making values safe to pass across Ractor boundaries.
313
+ #
314
+ # Recursively freezes Hash and Array structures. Raises RactorBoundaryError
315
+ # if a value cannot be made Ractor-shareable (e.g. a live IO or Proc).
316
+ #
317
+ # @example
318
+ # safe = RactorBoundary.freeze_deep({ model: "sonnet", args: { x: 1 } })
319
+ # Ractor.shareable?(safe) #=> true
320
+ #
321
+ module RactorBoundary
322
+ # Recursively freeze an object for safe Ractor boundary crossing.
323
+ #
324
+ # @param obj [Object] the value to freeze
325
+ # @return [Object] a frozen, Ractor-shareable copy
326
+ # @raise [RactorBoundaryError] if the value cannot be made shareable
327
+ def self.freeze_deep(obj)
328
+ return obj if Ractor.shareable?(obj)
329
+
330
+ result = case obj
331
+ when Hash
332
+ obj.transform_keys { |k| freeze_deep(k) }
333
+ .transform_values { |v| freeze_deep(v) }
334
+ when Array
335
+ obj.map { |v| freeze_deep(v) }
336
+ else
337
+ begin
338
+ obj.dup
339
+ rescue TypeError
340
+ raise RactorBoundaryError,
341
+ "Cannot make #{obj.class} Ractor-shareable: dup not supported"
342
+ end
343
+ end
344
+
345
+ Ractor.make_shareable(result)
346
+ rescue Ractor::IsolationError => e
347
+ raise RactorBoundaryError, "Cannot make value Ractor-shareable: #{e.message}"
348
+ end
349
+ end
350
+ end
351
+ ```
352
+
353
+ - [ ] **Step 4: Run test to verify it passes**
354
+
355
+ ```bash
356
+ bundle exec rake test_file[robot_lab/ractor_boundary_test.rb]
357
+ ```
358
+
359
+ Expected: all 8 tests pass.
360
+
361
+ - [ ] **Step 5: Commit**
362
+
363
+ ```bash
364
+ git add lib/robot_lab/ractor_boundary.rb test/robot_lab/ractor_boundary_test.rb
365
+ git commit -m "feat(ractor): add RactorBoundary.freeze_deep utility"
366
+ ```
367
+
368
+ ---
369
+
370
+ ## Task 4: Tool#ractor_safe class macro
371
+
372
+ **Files:**
373
+ - Modify: `lib/robot_lab/tool.rb`
374
+ - Modify: `test/robot_lab/tool_test.rb`
375
+
376
+ - [ ] **Step 1: Write failing test**
377
+
378
+ Open `test/robot_lab/tool_test.rb`. Add at the end of the class, before the closing `end`:
379
+
380
+ ```ruby
381
+ # ── ractor_safe macro ───────────────────────────────────────────
382
+
383
+ def test_ractor_safe_defaults_to_false
384
+ klass = Class.new(RobotLab::Tool) do
385
+ description "Test"
386
+ def execute(**); end
387
+ end
388
+ refute klass.ractor_safe?
389
+ end
390
+
391
+ def test_ractor_safe_can_be_enabled
392
+ klass = Class.new(RobotLab::Tool) do
393
+ description "Safe tool"
394
+ ractor_safe true
395
+ def execute(**); end
396
+ end
397
+ assert klass.ractor_safe?
398
+ end
399
+
400
+ def test_ractor_safe_is_inherited
401
+ parent = Class.new(RobotLab::Tool) do
402
+ description "Parent"
403
+ ractor_safe true
404
+ def execute(**); end
405
+ end
406
+ child = Class.new(parent)
407
+ assert child.ractor_safe?
408
+ end
409
+ ```
410
+
411
+ - [ ] **Step 2: Run to verify failure**
412
+
413
+ ```bash
414
+ bundle exec rake test_file[robot_lab/tool_test.rb]
415
+ ```
416
+
417
+ Expected: fails with `NoMethodError: undefined method 'ractor_safe'`.
418
+
419
+ - [ ] **Step 3: Add ractor_safe macro to Tool**
420
+
421
+ Open `lib/robot_lab/tool.rb`. Inside `class << self` (after the `raise_on_error?` method), add:
422
+
423
+ ```ruby
424
+ # Declare that this tool class is safe to run inside a Ractor.
425
+ #
426
+ # Ractor-safe tools must be stateless — no captured mutable closures
427
+ # and no non-shareable class-level state. The tool is instantiated
428
+ # fresh inside the Ractor worker for each call.
429
+ #
430
+ # @param value [Boolean]
431
+ # @return [Boolean]
432
+ def ractor_safe(value = nil)
433
+ if value.nil?
434
+ # getter — check own setting, then walk up the inheritance chain
435
+ if instance_variable_defined?(:@ractor_safe)
436
+ @ractor_safe
437
+ elsif superclass.respond_to?(:ractor_safe)
438
+ superclass.ractor_safe
439
+ else
440
+ false
441
+ end
442
+ else
443
+ @ractor_safe = value
444
+ end
445
+ end
446
+
447
+ alias ractor_safe? ractor_safe
448
+ ```
449
+
450
+ - [ ] **Step 4: Run to verify pass**
451
+
452
+ ```bash
453
+ bundle exec rake test_file[robot_lab/tool_test.rb]
454
+ ```
455
+
456
+ Expected: all existing tests plus the 3 new ones pass.
457
+
458
+ - [ ] **Step 5: Commit**
459
+
460
+ ```bash
461
+ git add lib/robot_lab/tool.rb test/robot_lab/tool_test.rb
462
+ git commit -m "feat(ractor): add ractor_safe class macro to Tool"
463
+ ```
464
+
465
+ ---
466
+
467
+ ## Task 5: RactorWorkerPool
468
+
469
+ **Files:**
470
+ - Create: `lib/robot_lab/ractor_worker_pool.rb`
471
+ - Create: `test/robot_lab/ractor_worker_pool_test.rb`
472
+
473
+ - [ ] **Step 1: Write failing test**
474
+
475
+ Create `test/robot_lab/ractor_worker_pool_test.rb`:
476
+
477
+ ```ruby
478
+ # frozen_string_literal: true
479
+
480
+ require "test_helper"
481
+
482
+ # A minimal ractor-safe tool for pool testing.
483
+ # Must be defined at the top level so Ractors can const_get it.
484
+ class RactorSafeDoubler < RobotLab::Tool
485
+ description "Doubles a number"
486
+ param :value, type: "number", desc: "The number"
487
+ ractor_safe true
488
+
489
+ def execute(value:)
490
+ value * 2
491
+ end
492
+ end
493
+
494
+ class RobotLab::RactorWorkerPoolTest < Minitest::Test
495
+ def setup
496
+ @pool = RobotLab::RactorWorkerPool.new(size: 2)
497
+ end
498
+
499
+ def teardown
500
+ @pool.shutdown
501
+ end
502
+
503
+ def test_submit_returns_result
504
+ result = @pool.submit("RactorSafeDoubler", { "value" => 5 })
505
+ assert_equal 10, result
506
+ end
507
+
508
+ def test_submit_multiple_concurrent_jobs
509
+ futures = 4.times.map do |i|
510
+ Thread.new { @pool.submit("RactorSafeDoubler", { "value" => i }) }
511
+ end
512
+ results = futures.map(&:value)
513
+ assert_equal [0, 2, 4, 6], results.sort
514
+ end
515
+
516
+ def test_submit_raises_tool_error_on_tool_exception
517
+ # A tool that always raises
518
+ Object.const_set(:AlwaysFailTool, Class.new(RobotLab::Tool) do
519
+ description "Fails"
520
+ ractor_safe true
521
+ def execute(**); raise "tool exploded"; end
522
+ end) unless defined?(AlwaysFailTool)
523
+
524
+ assert_raises(RobotLab::ToolError) do
525
+ @pool.submit("AlwaysFailTool", {})
526
+ end
527
+ end
528
+
529
+ def test_pool_size
530
+ assert_equal 2, @pool.size
531
+ end
532
+
533
+ def test_shutdown_is_idempotent
534
+ @pool.shutdown
535
+ @pool.shutdown # should not raise
536
+ end
537
+ end
538
+ ```
539
+
540
+ - [ ] **Step 2: Run to verify failure**
541
+
542
+ ```bash
543
+ bundle exec rake test_file[robot_lab/ractor_worker_pool_test.rb]
544
+ ```
545
+
546
+ Expected: fails with `NameError: uninitialized constant RobotLab::RactorWorkerPool`.
547
+
548
+ - [ ] **Step 3: Create lib/robot_lab/ractor_worker_pool.rb**
549
+
550
+ ```ruby
551
+ # frozen_string_literal: true
552
+
553
+ require "etc"
554
+
555
+ module RobotLab
556
+ # A pool of Ractor workers that execute CPU-bound, Ractor-safe tools.
557
+ #
558
+ # Work is distributed via a shared ractor_queue. Each worker runs a
559
+ # blocking loop, pops RactorJob instances, dispatches to the named
560
+ # tool class, and pushes the frozen result (or a RactorJobError) to
561
+ # the job's per-job reply_queue.
562
+ #
563
+ # Only tools that declare +ractor_safe true+ should be submitted.
564
+ # Tool classes are instantiated fresh inside the Ractor for each call.
565
+ #
566
+ # @example
567
+ # pool = RactorWorkerPool.new(size: 4)
568
+ # result = pool.submit("MyTool", { "arg" => "value" })
569
+ # pool.shutdown
570
+ #
571
+ class RactorWorkerPool
572
+ # @return [Integer] number of worker Ractors
573
+ attr_reader :size
574
+
575
+ # Creates a new pool and starts worker Ractors immediately.
576
+ #
577
+ # @param size [Integer, :auto] number of workers (:auto = Etc.nprocessors)
578
+ def initialize(size: :auto)
579
+ @size = size == :auto ? Etc.nprocessors : size.to_i
580
+ @closed = false
581
+ @work_q = RactorQueue.new
582
+ @workers = @size.times.map { spawn_worker(@work_q) }
583
+ end
584
+
585
+ # Submit a tool job and block until the result is available.
586
+ #
587
+ # @param tool_class_name [String] fully-qualified Ruby constant name of the tool class
588
+ # @param args [Hash] the tool arguments (will be deep-frozen before crossing Ractor boundary)
589
+ # @return [Object] the tool's return value
590
+ # @raise [RactorBoundaryError] if args cannot be made Ractor-shareable
591
+ # @raise [ToolError] if the tool raises inside the Ractor
592
+ def submit(tool_class_name, args)
593
+ raise "Pool is shut down" if @closed
594
+
595
+ reply_q = RactorQueue.new
596
+ payload = RactorBoundary.freeze_deep({
597
+ tool_class: tool_class_name.to_s,
598
+ args: args
599
+ })
600
+
601
+ job = RactorJob.new(
602
+ id: SecureRandom.uuid.freeze,
603
+ type: :tool,
604
+ payload: payload,
605
+ reply_queue: reply_q
606
+ )
607
+
608
+ @work_q.push(job)
609
+ result = reply_q.pop
610
+
611
+ if result.is_a?(RactorJobError)
612
+ raise ToolError, "Tool '#{tool_class_name}' failed in Ractor: #{result.message}"
613
+ end
614
+
615
+ result
616
+ end
617
+
618
+ # Gracefully shut down the pool.
619
+ #
620
+ # Closes the work queue so all workers exit after finishing their
621
+ # current job. Waits for all workers to terminate.
622
+ #
623
+ # @return [void]
624
+ def shutdown
625
+ return if @closed
626
+
627
+ @closed = true
628
+ @work_q.close
629
+ @workers.each do |w|
630
+ w.take rescue Ractor::ClosedError, Ractor::RemoteError
631
+ end
632
+ end
633
+
634
+ private
635
+
636
+ def spawn_worker(work_q)
637
+ Ractor.new(work_q) do |q|
638
+ loop do
639
+ job = q.pop
640
+ break if job.nil? # closed queue returns nil
641
+
642
+ begin
643
+ tool_class = Object.const_get(job.payload[:tool_class])
644
+ tool = tool_class.new
645
+ result = tool.execute(**job.payload[:args].transform_keys(&:to_sym))
646
+ frozen_result = Ractor.make_shareable(result.frozen? ? result : result.dup.freeze)
647
+ job.reply_queue.push(frozen_result)
648
+ rescue => e
649
+ err = RobotLab::RactorJobError.new(
650
+ message: e.message.freeze,
651
+ backtrace: (e.backtrace || []).map(&:freeze).freeze
652
+ )
653
+ job.reply_queue.push(err)
654
+ end
655
+ end
656
+ rescue RactorQueue::ClosedError
657
+ # Normal shutdown path — queue closed, exit loop cleanly
658
+ end
659
+ end
660
+ end
661
+ end
662
+ ```
663
+
664
+ > **Note on `RactorQueue#close`:** verify the exact exception name the gem raises when a closed queue is popped — it may be `RactorQueue::ClosedError`, `ClosedQueueError`, or similar. Update the rescue clause accordingly after checking the gem source.
665
+
666
+ - [ ] **Step 4: Add ToolError to error.rb**
667
+
668
+ Open `lib/robot_lab/error.rb`. Add after `RactorBoundaryError`:
669
+
670
+ ```ruby
671
+ # Raised when a tool fails during execution, including inside a Ractor worker.
672
+ #
673
+ # @example
674
+ # raise ToolError, "Tool 'MyTool' failed: division by zero"
675
+ class ToolError < Error; end
676
+ ```
677
+
678
+ - [ ] **Step 5: Run to verify pass**
679
+
680
+ ```bash
681
+ bundle exec rake test_file[robot_lab/ractor_worker_pool_test.rb]
682
+ ```
683
+
684
+ Expected: all 5 tests pass.
685
+
686
+ - [ ] **Step 6: Commit**
687
+
688
+ ```bash
689
+ git add lib/robot_lab/ractor_worker_pool.rb lib/robot_lab/error.rb test/robot_lab/ractor_worker_pool_test.rb
690
+ git commit -m "feat(ractor): add RactorWorkerPool for CPU-bound tool execution"
691
+ ```
692
+
693
+ ---
694
+
695
+ ## Task 6: Global pool accessor + RunConfig#ractor_pool_size
696
+
697
+ **Files:**
698
+ - Modify: `lib/robot_lab.rb`
699
+ - Modify: `lib/robot_lab/run_config.rb`
700
+ - Modify: `test/robot_lab/run_config_test.rb`
701
+
702
+ - [ ] **Step 1: Write failing test for RunConfig**
703
+
704
+ Open `test/robot_lab/run_config_test.rb`. Add these tests at the end of the class:
705
+
706
+ ```ruby
707
+ def test_ractor_pool_size_defaults_to_nil
708
+ config = RobotLab::RunConfig.new
709
+ assert_nil config.ractor_pool_size
710
+ end
711
+
712
+ def test_ractor_pool_size_can_be_set
713
+ config = RobotLab::RunConfig.new(ractor_pool_size: 4)
714
+ assert_equal 4, config.ractor_pool_size
715
+ end
716
+
717
+ def test_ractor_pool_size_merges
718
+ base = RobotLab::RunConfig.new(ractor_pool_size: 2)
719
+ other = RobotLab::RunConfig.new(ractor_pool_size: 8)
720
+ merged = base.merge(other)
721
+ assert_equal 8, merged.ractor_pool_size
722
+ end
723
+ ```
724
+
725
+ - [ ] **Step 2: Run to verify failure**
726
+
727
+ ```bash
728
+ bundle exec rake test_file[robot_lab/run_config_test.rb]
729
+ ```
730
+
731
+ Expected: fails with `ArgumentError: Unknown RunConfig field: ractor_pool_size`.
732
+
733
+ - [ ] **Step 3: Add ractor_pool_size to RunConfig**
734
+
735
+ Open `lib/robot_lab/run_config.rb`. Change:
736
+
737
+ ```ruby
738
+ # Infrastructure fields
739
+ INFRA_FIELDS = %i[bus enable_cache max_tool_rounds token_budget].freeze
740
+ ```
741
+
742
+ to:
743
+
744
+ ```ruby
745
+ # Infrastructure fields
746
+ INFRA_FIELDS = %i[bus enable_cache max_tool_rounds token_budget ractor_pool_size].freeze
747
+ ```
748
+
749
+ - [ ] **Step 4: Run to verify RunConfig tests pass**
750
+
751
+ ```bash
752
+ bundle exec rake test_file[robot_lab/run_config_test.rb]
753
+ ```
754
+
755
+ Expected: all tests pass.
756
+
757
+ - [ ] **Step 5: Add RobotLab.ractor_pool global accessor**
758
+
759
+ Open `lib/robot_lab.rb`. Inside the `class << self` block, add after the `create_memory` method:
760
+
761
+ ```ruby
762
+ # Returns the shared RactorWorkerPool, lazily initialized.
763
+ #
764
+ # Pool size is determined by RobotLab.config or defaults to Etc.nprocessors.
765
+ # The pool lives for the lifetime of the process. Call RobotLab.shutdown_ractor_pool
766
+ # to drain and close it explicitly.
767
+ #
768
+ # @return [RactorWorkerPool]
769
+ def ractor_pool
770
+ @ractor_pool ||= begin
771
+ size = config.respond_to?(:ractor_pool_size) ? (config.ractor_pool_size || :auto) : :auto
772
+ RactorWorkerPool.new(size: size)
773
+ end
774
+ end
775
+
776
+ # Shut down the shared Ractor worker pool, draining in-flight jobs.
777
+ #
778
+ # @return [void]
779
+ def shutdown_ractor_pool
780
+ @ractor_pool&.shutdown
781
+ @ractor_pool = nil
782
+ end
783
+ ```
784
+
785
+ - [ ] **Step 6: Commit**
786
+
787
+ ```bash
788
+ git add lib/robot_lab/run_config.rb lib/robot_lab.rb test/robot_lab/run_config_test.rb
789
+ git commit -m "feat(ractor): add ractor_pool_size to RunConfig and RobotLab.ractor_pool global accessor"
790
+ ```
791
+
792
+ ---
793
+
794
+ ## Task 7: Route Ractor-safe tool calls through the pool
795
+
796
+ **Files:**
797
+ - Modify: `lib/robot_lab/tool.rb`
798
+ - Modify: `test/robot_lab/tool_test.rb`
799
+
800
+ - [ ] **Step 1: Write failing test**
801
+
802
+ Open `test/robot_lab/tool_test.rb`. Add at the end of the class:
803
+
804
+ ```ruby
805
+ # ── Ractor pool routing ─────────────────────────────────────────
806
+
807
+ def test_ractor_safe_tool_call_routes_through_pool
808
+ # A top-level ractor-safe tool so its name resolves via const_get
809
+ Object.const_set(:PoolRoutingTestTool, Class.new(RobotLab::Tool) do
810
+ description "Multiplies by 3"
811
+ param :n, type: "number", desc: "Input"
812
+ ractor_safe true
813
+ def execute(n:); n * 3; end
814
+ end) unless defined?(PoolRoutingTestTool)
815
+
816
+ tool = PoolRoutingTestTool.new
817
+ result = tool.call({ "n" => 7 })
818
+ assert_equal 21, result
819
+ end
820
+
821
+ def test_non_ractor_safe_tool_call_runs_inline
822
+ klass = Class.new(RobotLab::Tool) do
823
+ description "Inline tool"
824
+ param :x, type: "string", desc: "Input"
825
+ def execute(x:); "inline:#{x}"; end
826
+ end
827
+ # Assign a top-level name so it can be found if needed, but it won't use pool
828
+ tool = klass.new
829
+ result = tool.call({ "x" => "hello" })
830
+ assert_equal "inline:hello", result
831
+ end
832
+ ```
833
+
834
+ - [ ] **Step 2: Run to verify failure**
835
+
836
+ ```bash
837
+ bundle exec rake test_file[robot_lab/tool_test.rb]
838
+ ```
839
+
840
+ Expected: the `ractor_safe_tool_call_routes_through_pool` test fails or does not use the pool (result will still be correct but pool isn't exercised yet).
841
+
842
+ - [ ] **Step 3: Route in Tool#call**
843
+
844
+ Open `lib/robot_lab/tool.rb`. Replace the existing `call` method:
845
+
846
+ ```ruby
847
+ # Invokes the tool, routing through the Ractor worker pool if ractor_safe.
848
+ #
849
+ # For Ractor-safe tools: submits the work to RobotLab.ractor_pool and
850
+ # blocks for the frozen result. The tool class must be accessible by
851
+ # its full constant name via Object.const_get (i.e. defined at the
852
+ # top level, not as an anonymous class).
853
+ #
854
+ # For non-Ractor-safe tools: runs execute directly in the calling thread.
855
+ #
856
+ # @param args [Hash] the tool arguments from the LLM
857
+ # @return [Object] the tool result or an error string
858
+ def call(args)
859
+ if self.class.ractor_safe? && !self.class.name.nil?
860
+ RobotLab.ractor_pool.submit(self.class.name, args)
861
+ else
862
+ super
863
+ end
864
+ rescue RobotLab::ToolError => e
865
+ raise if self.class.raise_on_error?
866
+ "Error (#{name}): #{e.message}"
867
+ rescue StandardError => e
868
+ raise if self.class.raise_on_error?
869
+ RobotLab.config.logger.warn("Tool '#{name}' error: #{e.class}: #{e.message}")
870
+ "Error (#{name}): #{e.message}"
871
+ end
872
+ ```
873
+
874
+ - [ ] **Step 4: Run to verify all tool tests pass**
875
+
876
+ ```bash
877
+ bundle exec rake test_file[robot_lab/tool_test.rb]
878
+ ```
879
+
880
+ Expected: all tests pass including the two new routing tests.
881
+
882
+ - [ ] **Step 5: Commit**
883
+
884
+ ```bash
885
+ git add lib/robot_lab/tool.rb test/robot_lab/tool_test.rb
886
+ git commit -m "feat(ractor): route ractor_safe tool calls through RactorWorkerPool"
887
+ ```
888
+
889
+ ---
890
+
891
+ ## Task 8: BusPoller ractor_queue upgrade
892
+
893
+ **Files:**
894
+ - Modify: `lib/robot_lab/bus_poller.rb`
895
+ - Modify: `test/robot_lab/bus_poller_test.rb`
896
+
897
+ - [ ] **Step 1: Add test for ractor_queue backing**
898
+
899
+ Open `test/robot_lab/bus_poller_test.rb`. Add at the end:
900
+
901
+ ```ruby
902
+ def test_robot_queues_are_ractor_queue_instances
903
+ robot = build_robot(name: "test_bot")
904
+
905
+ delivered = []
906
+ robot.define_singleton_method(:process_delivery) do |delivery|
907
+ delivered << delivery
908
+ end
909
+
910
+ @poller.enqueue(robot: robot, delivery: "msg1")
911
+
912
+ # The internal queue for the robot should be a RactorQueue
913
+ queues = @poller.instance_variable_get(:@robot_queues)
914
+ assert_instance_of RactorQueue, queues["test_bot"]
915
+ assert_equal ["msg1"], delivered
916
+ end
917
+ ```
918
+
919
+ - [ ] **Step 2: Run to verify failure**
920
+
921
+ ```bash
922
+ bundle exec rake test_file[robot_lab/bus_poller_test.rb]
923
+ ```
924
+
925
+ Expected: test fails because `@robot_queues["test_bot"]` is an `Array`, not a `RactorQueue`.
926
+
927
+ - [ ] **Step 3: Swap Array queues for RactorQueue**
928
+
929
+ Open `lib/robot_lab/bus_poller.rb`. Make the following changes:
930
+
931
+ **In `enqueue`**, replace the initialization and push lines:
932
+
933
+ ```ruby
934
+ if @robot_busy[name]
935
+ @robot_queues[name] << delivery
936
+ false
937
+ else
938
+ ```
939
+
940
+ Change to:
941
+
942
+ ```ruby
943
+ @robot_queues[name] ||= RactorQueue.new
944
+
945
+ if @robot_busy[name]
946
+ @robot_queues[name].push(delivery)
947
+ false
948
+ else
949
+ ```
950
+
951
+ Remove the old `@robot_queues[name] ||= []` line (it was before the if block — remove it and replace with the RactorQueue initialization above).
952
+
953
+ **In `process_and_drain`**, replace the drain logic:
954
+
955
+ ```ruby
956
+ next_delivery = @mutex.synchronize do
957
+ name = robot.name
958
+ queue = @robot_queues[name] || []
959
+
960
+ if queue.any?
961
+ queue.shift
962
+ else
963
+ @robot_busy[name] = false
964
+ nil
965
+ end
966
+ end
967
+ ```
968
+
969
+ Change to:
970
+
971
+ ```ruby
972
+ next_delivery = @mutex.synchronize do
973
+ name = robot.name
974
+ queue = @robot_queues[name]
975
+
976
+ if queue && !queue.empty?
977
+ queue.pop
978
+ else
979
+ @robot_busy[name] = false
980
+ nil
981
+ end
982
+ end
983
+ ```
984
+
985
+ > **Note:** `RactorQueue#empty?` and `RactorQueue#pop` (non-blocking when called under mutex while `!empty?`) must be verified against the gem API. If `pop` is blocking-only, use a timeout of 0: `queue.pop(timeout: 0)` and handle the timeout return value (likely `nil` or a sentinel).
986
+
987
+ - [ ] **Step 4: Run all bus poller tests**
988
+
989
+ ```bash
990
+ bundle exec rake test_file[robot_lab/bus_poller_test.rb]
991
+ ```
992
+
993
+ Expected: all tests pass including the new ractor_queue instance test.
994
+
995
+ - [ ] **Step 5: Commit**
996
+
997
+ ```bash
998
+ git add lib/robot_lab/bus_poller.rb test/robot_lab/bus_poller_test.rb
999
+ git commit -m "feat(ractor): upgrade BusPoller delivery queues to RactorQueue"
1000
+ ```
1001
+
1002
+ ---
1003
+
1004
+ ## Task 9: RactorMemoryProxy
1005
+
1006
+ **Files:**
1007
+ - Create: `lib/robot_lab/ractor_memory_proxy.rb`
1008
+ - Create: `test/robot_lab/ractor_memory_proxy_test.rb`
1009
+
1010
+ - [ ] **Step 1: Write failing test**
1011
+
1012
+ Create `test/robot_lab/ractor_memory_proxy_test.rb`:
1013
+
1014
+ ```ruby
1015
+ # frozen_string_literal: true
1016
+
1017
+ require "test_helper"
1018
+
1019
+ class RobotLab::RactorMemoryProxyTest < Minitest::Test
1020
+ def setup
1021
+ @memory = RobotLab::Memory.new(enable_cache: false)
1022
+ @proxy = RobotLab::RactorMemoryProxy.new(@memory)
1023
+ end
1024
+
1025
+ def teardown
1026
+ @proxy.shutdown
1027
+ end
1028
+
1029
+ def test_set_and_get_from_thread
1030
+ @proxy.set(:color, "blue")
1031
+ assert_equal "blue", @proxy.get(:color)
1032
+ end
1033
+
1034
+ def test_get_returns_nil_for_missing_key
1035
+ assert_nil @proxy.get(:nonexistent)
1036
+ end
1037
+
1038
+ def test_keys_returns_array
1039
+ @proxy.set(:a, "1")
1040
+ @proxy.set(:b, "2")
1041
+ assert_includes @proxy.keys, :a
1042
+ assert_includes @proxy.keys, :b
1043
+ end
1044
+
1045
+ def test_set_and_get_from_ractor
1046
+ proxy = @proxy # capture for Ractor
1047
+
1048
+ result = Ractor.new(proxy) do |p|
1049
+ p.set(:ractor_key, "ractor_value")
1050
+ p.get(:ractor_key)
1051
+ end.take
1052
+
1053
+ assert_equal "ractor_value", result
1054
+ assert_equal "ractor_value", @memory.get(:ractor_key)
1055
+ end
1056
+
1057
+ def test_values_must_be_shareable
1058
+ assert_raises(RobotLab::RactorBoundaryError) do
1059
+ @proxy.set(:bad, StringIO.new)
1060
+ end
1061
+ end
1062
+ end
1063
+ ```
1064
+
1065
+ - [ ] **Step 2: Run to verify failure**
1066
+
1067
+ ```bash
1068
+ bundle exec rake test_file[robot_lab/ractor_memory_proxy_test.rb]
1069
+ ```
1070
+
1071
+ Expected: fails with `NameError: uninitialized constant RobotLab::RactorMemoryProxy`.
1072
+
1073
+ - [ ] **Step 3: Create lib/robot_lab/ractor_memory_proxy.rb**
1074
+
1075
+ ```ruby
1076
+ # frozen_string_literal: true
1077
+
1078
+ require "ractor/wrapper"
1079
+
1080
+ module RobotLab
1081
+ # Wraps a Memory instance via ractor-wrapper so Ractor workers can safely
1082
+ # read and write shared state.
1083
+ #
1084
+ # Only the proxy methods (get, set, keys) are exposed across the Ractor
1085
+ # boundary. Subscriptions and callbacks are NOT proxied — closures are not
1086
+ # Ractor-safe. Use the thread-side Memory directly for reactive subscriptions.
1087
+ #
1088
+ # Values passed to set() must be Ractor-shareable; RactorBoundary.freeze_deep
1089
+ # is applied automatically.
1090
+ #
1091
+ # @example
1092
+ # memory = Memory.new
1093
+ # proxy = RactorMemoryProxy.new(memory)
1094
+ #
1095
+ # # From a Ractor:
1096
+ # proxy.set(:result, "done")
1097
+ # proxy.get(:result) #=> "done"
1098
+ #
1099
+ # proxy.shutdown # call when done
1100
+ #
1101
+ class RactorMemoryProxy
1102
+ # @param memory [Memory] the memory instance to wrap
1103
+ def initialize(memory)
1104
+ @memory = memory
1105
+ @wrapper = Ractor::Wrapper.wrap(memory)
1106
+ end
1107
+
1108
+ # Read a value from the proxied Memory.
1109
+ #
1110
+ # @param key [Symbol]
1111
+ # @return [Object, nil]
1112
+ def get(key)
1113
+ @wrapper.call(:get, key)
1114
+ end
1115
+
1116
+ # Write a value to the proxied Memory.
1117
+ # The value is deep-frozen before crossing the Ractor boundary.
1118
+ #
1119
+ # @param key [Symbol]
1120
+ # @param value [Object] must be Ractor-shareable after freeze_deep
1121
+ # @return [void]
1122
+ # @raise [RactorBoundaryError] if value cannot be made shareable
1123
+ def set(key, value)
1124
+ frozen_value = RactorBoundary.freeze_deep(value)
1125
+ @wrapper.call(:set, key, frozen_value)
1126
+ end
1127
+
1128
+ # List all keys currently set in the proxied Memory.
1129
+ #
1130
+ # @return [Array<Symbol>]
1131
+ def keys
1132
+ @wrapper.call(:keys)
1133
+ end
1134
+
1135
+ # Shut down the ractor-wrapper.
1136
+ #
1137
+ # @return [void]
1138
+ def shutdown
1139
+ @wrapper.shutdown if @wrapper.respond_to?(:shutdown)
1140
+ end
1141
+ end
1142
+ end
1143
+ ```
1144
+
1145
+ > **Note on ractor-wrapper API:** The plan uses `Ractor::Wrapper.wrap(obj)` and `.call(:method, *args)`. Verify this matches the installed gem version. If the API is different (e.g. a block-based DSL or different method name), adjust accordingly. The `Memory#get`, `Memory#set`, and `Memory#keys` methods must accept positional arguments as shown.
1146
+
1147
+ - [ ] **Step 4: Run to verify pass**
1148
+
1149
+ ```bash
1150
+ bundle exec rake test_file[robot_lab/ractor_memory_proxy_test.rb]
1151
+ ```
1152
+
1153
+ Expected: all 5 tests pass.
1154
+
1155
+ - [ ] **Step 5: Commit**
1156
+
1157
+ ```bash
1158
+ git add lib/robot_lab/ractor_memory_proxy.rb test/robot_lab/ractor_memory_proxy_test.rb
1159
+ git commit -m "feat(ractor): add RactorMemoryProxy wrapping Memory via ractor-wrapper"
1160
+ ```
1161
+
1162
+ ---
1163
+
1164
+ ## Task 10: RactorNetworkScheduler
1165
+
1166
+ **Files:**
1167
+ - Create: `lib/robot_lab/ractor_network_scheduler.rb`
1168
+ - Create: `test/robot_lab/ractor_network_scheduler_test.rb`
1169
+
1170
+ - [ ] **Step 1: Write failing test**
1171
+
1172
+ Create `test/robot_lab/ractor_network_scheduler_test.rb`:
1173
+
1174
+ ```ruby
1175
+ # frozen_string_literal: true
1176
+
1177
+ require "test_helper"
1178
+
1179
+ class RobotLab::RactorNetworkSchedulerTest < Minitest::Test
1180
+ def setup
1181
+ @memory = RobotLab::Memory.new(enable_cache: false)
1182
+ @scheduler = RobotLab::RactorNetworkScheduler.new(memory: @memory)
1183
+ end
1184
+
1185
+ def teardown
1186
+ @scheduler.shutdown
1187
+ end
1188
+
1189
+ def test_run_task_spec_returns_result
1190
+ spec = RobotLab::RobotSpec.new(
1191
+ name: "echo_bot",
1192
+ template: nil,
1193
+ system_prompt: "You are an echo bot.",
1194
+ config_hash: { model: "claude-sonnet-4" }.freeze
1195
+ )
1196
+
1197
+ # Scheduler runs the spec; we stub run() by injecting a fake result
1198
+ # via the frozen payload mechanism.
1199
+ stub_result = "echo result"
1200
+ @scheduler.stub(:execute_spec, stub_result) do
1201
+ result = @scheduler.run_spec(spec, message: "hello")
1202
+ assert_equal stub_result, result
1203
+ end
1204
+ end
1205
+
1206
+ def test_respects_dependency_ordering
1207
+ order = []
1208
+ @scheduler.stub(:execute_spec, ->(spec, _msg) { order << spec.name; "ok" }) do
1209
+ specs_with_deps = [
1210
+ { spec: RobotLab::RobotSpec.new(name: "first", template: nil, system_prompt: nil, config_hash: {}.freeze),
1211
+ depends_on: :none },
1212
+ { spec: RobotLab::RobotSpec.new(name: "second", template: nil, system_prompt: nil, config_hash: {}.freeze),
1213
+ depends_on: ["first"] }
1214
+ ]
1215
+ @scheduler.run_pipeline(specs_with_deps, message: "go")
1216
+ end
1217
+ assert_equal ["first", "second"], order
1218
+ end
1219
+ end
1220
+ ```
1221
+
1222
+ - [ ] **Step 2: Run to verify failure**
1223
+
1224
+ ```bash
1225
+ bundle exec rake test_file[robot_lab/ractor_network_scheduler_test.rb]
1226
+ ```
1227
+
1228
+ Expected: fails with `NameError: uninitialized constant RobotLab::RactorNetworkScheduler`.
1229
+
1230
+ - [ ] **Step 3: Create lib/robot_lab/ractor_network_scheduler.rb**
1231
+
1232
+ ```ruby
1233
+ # frozen_string_literal: true
1234
+
1235
+ module RobotLab
1236
+ # Schedules frozen robot task descriptions across Ractor workers.
1237
+ #
1238
+ # Robots themselves stay in threads (RubyLLM is not Ractor-safe).
1239
+ # Instead, the scheduler distributes frozen RobotSpec payloads to
1240
+ # worker Ractors. Each worker constructs a fresh Robot from the spec,
1241
+ # runs the task, and returns a frozen result.
1242
+ #
1243
+ # Task ordering respects depends_on: tasks are only dispatched once
1244
+ # all named dependencies have resolved (same topological semantics as
1245
+ # SimpleFlow::Pipeline).
1246
+ #
1247
+ # @example
1248
+ # scheduler = RactorNetworkScheduler.new(memory: shared_memory)
1249
+ # scheduler.run_pipeline([
1250
+ # { spec: analyst_spec, depends_on: :none },
1251
+ # { spec: writer_spec, depends_on: ["analyst"] }
1252
+ # ], message: "Process this")
1253
+ # scheduler.shutdown
1254
+ #
1255
+ class RactorNetworkScheduler
1256
+ # @param memory [Memory] shared network memory for all robot tasks
1257
+ # @param pool_size [Integer, :auto] number of Ractor workers
1258
+ def initialize(memory:, pool_size: :auto)
1259
+ @memory = memory
1260
+ @work_q = RactorQueue.new
1261
+ @result_q = RactorQueue.new
1262
+ @size = pool_size == :auto ? Etc.nprocessors : pool_size.to_i
1263
+ @workers = @size.times.map { spawn_worker(@work_q, @result_q) }
1264
+ @closed = false
1265
+ end
1266
+
1267
+ # Run a single spec and return the result string.
1268
+ # Dispatches to a worker Ractor and blocks for the reply.
1269
+ #
1270
+ # @param spec [RobotSpec]
1271
+ # @param message [String]
1272
+ # @return [String] the robot's last_text_content
1273
+ def run_spec(spec, message:)
1274
+ execute_spec(spec, message)
1275
+ end
1276
+
1277
+ # Run a pipeline of specs in dependency order.
1278
+ #
1279
+ # @param specs_with_deps [Array<Hash>] each entry has :spec and :depends_on
1280
+ # :depends_on is :none, :optional, or an Array<String> of spec names
1281
+ # @param message [String] initial message passed to entry-point robots
1282
+ # @return [Hash<String, String>] name => result for each completed robot
1283
+ def run_pipeline(specs_with_deps, message:)
1284
+ completed = {} # name => result string
1285
+ remaining = specs_with_deps.dup
1286
+
1287
+ until remaining.empty?
1288
+ ready, remaining = remaining.partition do |entry|
1289
+ deps = entry[:depends_on]
1290
+ deps == :none || deps == :optional ||
1291
+ Array(deps).all? { |d| completed.key?(d) }
1292
+ end
1293
+
1294
+ raise "Circular dependency or unresolvable deps in RactorNetworkScheduler" if ready.empty?
1295
+
1296
+ # Submit all ready tasks concurrently
1297
+ threads = ready.map do |entry|
1298
+ spec = entry[:spec]
1299
+ msg = completed.values.last || message
1300
+ Thread.new { [spec.name, execute_spec(spec, msg)] }
1301
+ end
1302
+
1303
+ threads.each do |t|
1304
+ name, result = t.value
1305
+ completed[name] = result
1306
+ end
1307
+ end
1308
+
1309
+ completed
1310
+ end
1311
+
1312
+ # Shut down worker Ractors cleanly.
1313
+ # @return [void]
1314
+ def shutdown
1315
+ return if @closed
1316
+ @closed = true
1317
+ @work_q.close rescue nil
1318
+ @workers.each { |w| w.take rescue nil }
1319
+ end
1320
+
1321
+ private
1322
+
1323
+ # Dispatch a spec to a Ractor worker and block for the result.
1324
+ def execute_spec(spec, message)
1325
+ frozen_spec = Ractor.make_shareable(spec)
1326
+ frozen_message = message.to_s.freeze
1327
+ reply_q = RactorQueue.new
1328
+
1329
+ job = RactorJob.new(
1330
+ id: SecureRandom.uuid.freeze,
1331
+ type: :robot,
1332
+ payload: RactorBoundary.freeze_deep({
1333
+ spec: frozen_spec,
1334
+ message: frozen_message
1335
+ }),
1336
+ reply_queue: reply_q
1337
+ )
1338
+
1339
+ @work_q.push(job)
1340
+ result = reply_q.pop
1341
+
1342
+ raise ToolError, "Robot '#{spec.name}' failed in Ractor: #{result.message}" if result.is_a?(RactorJobError)
1343
+
1344
+ result
1345
+ end
1346
+
1347
+ def spawn_worker(work_q, _result_q)
1348
+ Ractor.new(work_q) do |q|
1349
+ loop do
1350
+ job = q.pop
1351
+ break if job.nil?
1352
+
1353
+ begin
1354
+ spec = job.payload[:spec]
1355
+ message = job.payload[:message]
1356
+
1357
+ robot = RobotLab::Robot.new(
1358
+ name: spec.name,
1359
+ template: spec.template,
1360
+ system_prompt: spec.system_prompt,
1361
+ config: spec.config_hash.empty? ? nil : RobotLab::RunConfig.new(**spec.config_hash.transform_keys(&:to_sym))
1362
+ )
1363
+
1364
+ robot_result = robot.run(message)
1365
+ frozen_reply = robot_result.last_text_content.to_s.freeze
1366
+ job.reply_queue.push(frozen_reply)
1367
+ rescue => e
1368
+ err = RobotLab::RactorJobError.new(
1369
+ message: e.message.freeze,
1370
+ backtrace: (e.backtrace || []).map(&:freeze).freeze
1371
+ )
1372
+ job.reply_queue.push(err)
1373
+ end
1374
+ end
1375
+ rescue RactorQueue::ClosedError
1376
+ # Normal shutdown
1377
+ end
1378
+ end
1379
+ end
1380
+ end
1381
+ ```
1382
+
1383
+ > **Important:** `RobotLab::Robot.new` inside a Ractor will work only if all constants and gems it references are Ractor-safe at load time. If `ruby_llm` raises `Ractor::IsolationError` during Robot construction inside a Ractor, fall back to running robot tasks in threads (keeping the Ractor boundary at the job queue level only). The test stubs `execute_spec`, so the unit tests will pass regardless — integration testing with a live LLM call will reveal any Ractor isolation issues.
1384
+
1385
+ - [ ] **Step 4: Run to verify pass**
1386
+
1387
+ ```bash
1388
+ bundle exec rake test_file[robot_lab/ractor_network_scheduler_test.rb]
1389
+ ```
1390
+
1391
+ Expected: both tests pass.
1392
+
1393
+ - [ ] **Step 5: Commit**
1394
+
1395
+ ```bash
1396
+ git add lib/robot_lab/ractor_network_scheduler.rb test/robot_lab/ractor_network_scheduler_test.rb
1397
+ git commit -m "feat(ractor): add RactorNetworkScheduler for parallel robot task dispatch"
1398
+ ```
1399
+
1400
+ ---
1401
+
1402
+ ## Task 11: Network parallel_mode: :ractor integration
1403
+
1404
+ **Files:**
1405
+ - Modify: `lib/robot_lab/network.rb`
1406
+ - Modify: `test/robot_lab/network_test.rb`
1407
+
1408
+ - [ ] **Step 1: Write failing test**
1409
+
1410
+ Open `test/robot_lab/network_test.rb`. Add at the end:
1411
+
1412
+ ```ruby
1413
+ def test_parallel_mode_ractor_accepted
1414
+ network = RobotLab::Network.new(name: "ractor_net", parallel_mode: :ractor) {}
1415
+ assert_equal :ractor, network.parallel_mode
1416
+ end
1417
+
1418
+ def test_parallel_mode_async_is_default
1419
+ network = RobotLab::Network.new(name: "async_net") {}
1420
+ assert_equal :async, network.parallel_mode
1421
+ end
1422
+ ```
1423
+
1424
+ - [ ] **Step 2: Run to verify failure**
1425
+
1426
+ ```bash
1427
+ bundle exec rake test_file[robot_lab/network_test.rb]
1428
+ ```
1429
+
1430
+ Expected: fails with `ArgumentError: unknown keyword: parallel_mode` or similar.
1431
+
1432
+ - [ ] **Step 3: Add parallel_mode to Network**
1433
+
1434
+ Open `lib/robot_lab/network.rb`.
1435
+
1436
+ **Add `parallel_mode` to `attr_reader`:**
1437
+
1438
+ ```ruby
1439
+ attr_reader :name, :pipeline, :robots, :memory, :config, :parallel_mode
1440
+ ```
1441
+
1442
+ **Update `initialize` signature** — add `parallel_mode: :async` after `config: nil`:
1443
+
1444
+ ```ruby
1445
+ def initialize(name:, concurrency: :auto, memory: nil, config: nil, parallel_mode: :async, &block)
1446
+ ```
1447
+
1448
+ **Store in initialize** — after `@config = config || RunConfig.new`:
1449
+
1450
+ ```ruby
1451
+ @parallel_mode = parallel_mode
1452
+ ```
1453
+
1454
+ **Update `run` to delegate to `RactorNetworkScheduler` when `parallel_mode: :ractor`:**
1455
+
1456
+ Replace the existing `run` method body with:
1457
+
1458
+ ```ruby
1459
+ def run(**run_context)
1460
+ run_context[:network_memory] = @memory
1461
+ run_context[:network_config] = @config unless @config.empty?
1462
+
1463
+ if @parallel_mode == :ractor
1464
+ run_with_ractor_scheduler(run_context)
1465
+ else
1466
+ initial_result = SimpleFlow::Result.new(
1467
+ run_context,
1468
+ context: { run_params: run_context }
1469
+ )
1470
+ @pipeline.call_parallel(initial_result)
1471
+ end
1472
+ end
1473
+ ```
1474
+
1475
+ **Add `run_with_ractor_scheduler` private method** at the bottom of the private section:
1476
+
1477
+ ```ruby
1478
+ def run_with_ractor_scheduler(run_context)
1479
+ message = run_context[:message].to_s
1480
+
1481
+ specs_with_deps = @tasks.map do |task_name, task_wrapper|
1482
+ step_info = @pipeline.steps.find { |s| s.name.to_s == task_name }
1483
+ deps = step_info ? step_info.depends_on : :none
1484
+
1485
+ spec = RobotSpec.new(
1486
+ name: task_wrapper.robot.name.freeze,
1487
+ template: task_wrapper.robot.template&.to_s&.freeze,
1488
+ system_prompt: task_wrapper.robot.system_prompt&.freeze,
1489
+ config_hash: RactorBoundary.freeze_deep(task_wrapper.robot.config.to_json_hash)
1490
+ )
1491
+
1492
+ { spec: spec, depends_on: deps }
1493
+ end
1494
+
1495
+ scheduler = RactorNetworkScheduler.new(memory: @memory)
1496
+ results = scheduler.run_pipeline(specs_with_deps, message: message)
1497
+ scheduler.shutdown
1498
+ results
1499
+ end
1500
+ ```
1501
+
1502
+ > **Note:** `@pipeline.steps` may not be a public API on `SimpleFlow::Pipeline`. Check the `simple_flow` gem for the correct way to read step definitions and their `depends_on` values. If steps are not publicly enumerable, store the dep info in `@tasks` during `task(...)` calls instead.
1503
+
1504
+ - [ ] **Step 4: Run network tests**
1505
+
1506
+ ```bash
1507
+ bundle exec rake test_file[robot_lab/network_test.rb]
1508
+ ```
1509
+
1510
+ Expected: all tests including the two new ones pass.
1511
+
1512
+ - [ ] **Step 5: Run full test suite**
1513
+
1514
+ ```bash
1515
+ bundle exec rake test
1516
+ ```
1517
+
1518
+ Expected: all tests pass. If any failures, fix them before committing.
1519
+
1520
+ - [ ] **Step 6: Commit**
1521
+
1522
+ ```bash
1523
+ git add lib/robot_lab/network.rb test/robot_lab/network_test.rb
1524
+ git commit -m "feat(ractor): add parallel_mode: :ractor to Network, delegating to RactorNetworkScheduler"
1525
+ ```
1526
+
1527
+ ---
1528
+
1529
+ ## Self-Review Checklist
1530
+
1531
+ Before handing off to execution:
1532
+
1533
+ - [ ] All types used in later tasks match definitions in earlier tasks (`RactorJob`, `RactorJobError`, `RobotSpec`, `RactorBoundaryError`, `ToolError`)
1534
+ - [ ] `RactorQueue` is used consistently (Task 5, 8, 9, 10 all use `RactorQueue.new`, `push`, `pop`, `empty?`, `close`)
1535
+ - [ ] `Ractor::Wrapper.wrap` API in Task 9 is flagged for verification against installed gem
1536
+ - [ ] `execute_spec` stub in Task 10 tests uses the same method name as the private implementation
1537
+ - [ ] `ToolError` added to `error.rb` in Task 5 before it is referenced in Task 7 and 10
1538
+ - [ ] Zeitwerk autoloads `ractor_boundary.rb`, `ractor_worker_pool.rb`, `ractor_memory_proxy.rb`, `ractor_network_scheduler.rb` — only `ractor_job.rb` needs an explicit require (multiple classes in one file)