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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +80 -1
- data/Rakefile +2 -1
- data/docs/api/core/robot.md +182 -0
- data/docs/guides/creating-networks.md +21 -0
- data/docs/guides/index.md +10 -0
- data/docs/guides/knowledge.md +182 -0
- data/docs/guides/mcp-integration.md +106 -0
- data/docs/guides/memory.md +2 -0
- data/docs/guides/observability.md +486 -0
- data/docs/guides/ractor-parallelism.md +364 -0
- data/docs/superpowers/plans/2026-04-14-ractor-integration.md +1538 -0
- data/docs/superpowers/specs/2026-04-14-ractor-integration-design.md +258 -0
- data/examples/19_token_tracking.rb +128 -0
- data/examples/20_circuit_breaker.rb +153 -0
- data/examples/21_learning_loop.rb +164 -0
- data/examples/22_context_compression.rb +179 -0
- data/examples/23_convergence.rb +137 -0
- data/examples/24_structured_delegation.rb +150 -0
- data/examples/25_history_search/conversation.jsonl +30 -0
- data/examples/25_history_search.rb +136 -0
- data/examples/26_document_store/api_versioning_adr.md +52 -0
- data/examples/26_document_store/incident_postmortem.md +46 -0
- data/examples/26_document_store/postgres_runbook.md +49 -0
- data/examples/26_document_store/redis_caching_guide.md +48 -0
- data/examples/26_document_store/sidekiq_guide.md +51 -0
- data/examples/26_document_store.rb +147 -0
- data/examples/27_incident_response/incident_response.rb +244 -0
- data/examples/28_mcp_discovery.rb +112 -0
- data/examples/29_ractor_tools.rb +243 -0
- data/examples/30_ractor_network.rb +256 -0
- data/examples/README.md +136 -0
- data/examples/prompts/skill_with_mcp_test.md +9 -0
- data/examples/prompts/skill_with_robot_name_test.md +5 -0
- data/examples/prompts/skill_with_tools_test.md +6 -0
- data/lib/robot_lab/bus_poller.rb +149 -0
- data/lib/robot_lab/convergence.rb +69 -0
- data/lib/robot_lab/delegation_future.rb +93 -0
- data/lib/robot_lab/document_store.rb +155 -0
- data/lib/robot_lab/error.rb +25 -0
- data/lib/robot_lab/history_compressor.rb +205 -0
- data/lib/robot_lab/mcp/client.rb +17 -5
- data/lib/robot_lab/mcp/connection_poller.rb +187 -0
- data/lib/robot_lab/mcp/server.rb +7 -2
- data/lib/robot_lab/mcp/server_discovery.rb +110 -0
- data/lib/robot_lab/mcp/transports/stdio.rb +6 -0
- data/lib/robot_lab/memory.rb +103 -6
- data/lib/robot_lab/network.rb +44 -9
- data/lib/robot_lab/ractor_boundary.rb +42 -0
- data/lib/robot_lab/ractor_job.rb +37 -0
- data/lib/robot_lab/ractor_memory_proxy.rb +85 -0
- data/lib/robot_lab/ractor_network_scheduler.rb +154 -0
- data/lib/robot_lab/ractor_worker_pool.rb +117 -0
- data/lib/robot_lab/robot/bus_messaging.rb +43 -65
- data/lib/robot_lab/robot/history_search.rb +69 -0
- data/lib/robot_lab/robot.rb +228 -11
- data/lib/robot_lab/robot_result.rb +24 -5
- data/lib/robot_lab/run_config.rb +1 -1
- data/lib/robot_lab/text_analysis.rb +103 -0
- data/lib/robot_lab/tool.rb +42 -3
- data/lib/robot_lab/tool_config.rb +1 -1
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab/waiter.rb +49 -29
- data/lib/robot_lab.rb +25 -0
- data/mkdocs.yml +1 -0
- 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)
|