phronomy 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +54 -0
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +124 -92
  6. data/benchmark/baseline.json +2 -3
  7. data/benchmark/bench_agent_invoke.rb +4 -4
  8. data/benchmark/bench_context_assembler.rb +134 -34
  9. data/benchmark/bench_regression.rb +1 -1
  10. data/benchmark/bench_tool_schema.rb +2 -35
  11. data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
  12. data/docs/decisions/010-cooperative-first-concurrency.md +7 -0
  13. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +2 -2
  14. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  15. data/lib/phronomy/agent/agent_invocation.rb +2 -36
  16. data/lib/phronomy/agent/agent_invocation_session_builder.rb +156 -93
  17. data/lib/phronomy/agent/agent_root.rb +1 -2
  18. data/lib/phronomy/agent/base.rb +135 -314
  19. data/lib/phronomy/agent/context/capability/base.rb +166 -297
  20. data/lib/phronomy/agent/context_assembler.rb +65 -29
  21. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
  22. data/lib/phronomy/agent/context_plan_validator.rb +0 -33
  23. data/lib/phronomy/agent/execution_coordinator.rb +0 -1
  24. data/lib/phronomy/agent/journal_projection.rb +28 -2
  25. data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
  26. data/lib/phronomy/agent/shared_state.rb +46 -138
  27. data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
  28. data/lib/phronomy/agent/tool_invocation.rb +108 -314
  29. data/lib/phronomy/agent.rb +6 -10
  30. data/lib/phronomy/configuration.rb +15 -158
  31. data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
  32. data/lib/phronomy/engine/runtime.rb +15 -230
  33. data/lib/phronomy/engine/task_group.rb +30 -102
  34. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  35. data/lib/phronomy/multi_agent/orchestrator.rb +152 -204
  36. data/lib/phronomy/multi_agent/team_coordinator.rb +42 -133
  37. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  38. data/lib/phronomy/version.rb +1 -1
  39. data/lib/phronomy.rb +3 -120
  40. data/scripts/api_snapshot.rb +1 -12
  41. metadata +3 -9
  42. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  43. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  44. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  45. data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
  46. data/lib/phronomy/knowledge_source.rb +0 -12
  47. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  48. data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
@@ -13,41 +13,7 @@ require_relative "runtime/timer_service"
13
13
 
14
14
  module Phronomy
15
15
  # Central authority for concurrent primitives.
16
- #
17
- # +Runtime+ is the single place that creates {Task}s, {TaskGroup}s, and
18
- # manages the lifecycle of all concurrency in Phronomy. It owns:
19
- #
20
- # * a pluggable {Scheduler} (default: {ThreadScheduler})
21
- # * a task registry for graceful shutdown
22
- # * the shared {BlockingAdapterPool}
23
- #
24
- # In production, use the process-wide singleton via {.instance}.
25
- # In tests, construct a Runtime with a {FakeScheduler} to run tasks
26
- # synchronously without spawning additional threads:
27
- #
28
- # @example Production usage
29
- # group = Phronomy::Runtime.instance.task_group(limit: 4)
30
- # tools.each { |t| group.spawn { t.call } }
31
- # results = group.await_all
32
- #
33
- # @example Test usage — no extra threads
34
- # runtime = Phronomy::Runtime.new(scheduler: Phronomy::Runtime::FakeScheduler.new)
35
- # task = runtime.spawn { 42 }
36
- # expect(task.wait_result).to eq(42)
37
16
  class Runtime
38
- # Returns the process-wide default Runtime.
39
- #
40
- # Auto-creates an instance using the scheduler backend specified by
41
- # +Phronomy.configuration.runtime_backend+:
42
- # - +:thread+ (default) — {ThreadScheduler} (one OS thread per task)
43
- # - +:immediate+ — {FakeScheduler} (synchronous, no extra threads)
44
- # - +:fiber+ — {DeterministicScheduler} in autorun mode (EXPERIMENTAL;
45
- # Fiber-based synchronous execution; not yet suitable for production
46
- # because it uses virtual time rather than real wall-clock timers)
47
- # - +:cooperative+ — deprecated alias for +:immediate+
48
- #
49
- # @return [Runtime]
50
- # @api private
51
17
  @instance_mutex = Mutex.new
52
18
 
53
19
  class << self
@@ -57,17 +23,10 @@ module Phronomy
57
23
  end
58
24
  end
59
25
 
60
- # Compatibility setter retained for existing tests.
61
- def instance=(runtime)
62
- replace_default_for_test(runtime)
63
- end
64
-
65
- # Test-only, non-creating access to the default Runtime.
66
26
  def default_if_initialized_for_test
67
27
  instance_mutex.synchronize { @instance }
68
28
  end
69
29
 
70
- # Test-only replacement. The caller owns both Runtime lifecycles.
71
30
  def replace_default_for_test(runtime)
72
31
  instance_mutex.synchronize do
73
32
  previous = @instance
@@ -76,7 +35,6 @@ module Phronomy
76
35
  end
77
36
  end
78
37
 
79
- # Test-only restoration of a previously captured Runtime.
80
38
  def restore_default_for_test(runtime)
81
39
  instance_mutex.synchronize { @instance = runtime }
82
40
  end
@@ -97,7 +55,6 @@ module Phronomy
97
55
  result
98
56
  end
99
57
 
100
- # Does not create a Runtime or EventLoop.
101
58
  def in_event_loop_context?
102
59
  runtime = instance_mutex.synchronize { @instance }
103
60
  runtime&.event_loop_current? || false
@@ -111,13 +68,8 @@ module Phronomy
111
68
 
112
69
  def build_default_runtime
113
70
  scheduler = case Phronomy.configuration.runtime_backend
114
- when :cooperative
115
- Phronomy.configuration.logger&.warn(
116
- "[phronomy] runtime_backend: :cooperative is a deprecated alias for :immediate. " \
117
- "Use :immediate for synchronous/test execution. " \
118
- ":cooperative will be reassigned when a real cooperative Fiber-based scheduler is available."
119
- )
120
- FakeScheduler.new
71
+ when :thread
72
+ ThreadScheduler.new
121
73
  when :immediate
122
74
  FakeScheduler.new
123
75
  when :fiber
@@ -129,37 +81,17 @@ module Phronomy
129
81
  )
130
82
  DeterministicScheduler.new(autorun: true)
131
83
  else
132
- ThreadScheduler.new
84
+ raise Phronomy::ConfigurationError,
85
+ "unknown runtime_backend: #{Phronomy.configuration.runtime_backend.inspect}"
133
86
  end
134
87
  new(scheduler: scheduler)
135
88
  end
136
89
  end
137
90
 
138
- # Returns +true+ when the calling thread is executing inside an active
139
- # scheduler task (i.e. {Task.current} is non-nil). Code running inside
140
- # a {Runtime#spawn} block is always in a scheduler context.
141
- #
142
- # Use this to detect potential scheduler-blocking calls:
143
- # if Phronomy::Runtime.in_scheduler_context?
144
- # Phronomy.configuration.logger&.warn("blocking call inside scheduler task")
145
- # end
146
- #
147
- # @return [Boolean]
148
- # @api private
149
91
  def self.in_scheduler_context?
150
92
  !Task.current.nil?
151
93
  end
152
94
 
153
- # Executes +block+ and returns +[result, elapsed_ms]+ where +elapsed_ms+
154
- # is the wall-clock duration in milliseconds (Integer, rounded).
155
- #
156
- # Isolates all direct references to +Process.clock_gettime+ /
157
- # +Process::CLOCK_MONOTONIC+ in one place so that callers stay at the
158
- # framework abstraction level.
159
- #
160
- # @yield block to time
161
- # @return [Array(Object, Integer)] +[block_return_value, elapsed_ms]+
162
- # @api private
163
95
  def self.measure_ms
164
96
  t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
165
97
  result = yield
@@ -167,18 +99,12 @@ module Phronomy
167
99
  [result, elapsed_ms]
168
100
  end
169
101
 
170
- # The scheduler backing this runtime instance.
171
- # @return [Scheduler]
172
102
  attr_reader :scheduler
173
103
 
174
- # @return [Symbol] current Runtime lifecycle state
175
- # @api private
176
104
  def state
177
105
  @lifecycle_mutex.synchronize { @state }
178
106
  end
179
107
 
180
- # @param scheduler [Scheduler] execution backend (default: {ThreadScheduler})
181
- # @api private
182
108
  def initialize(scheduler: ThreadScheduler.new)
183
109
  @scheduler = scheduler
184
110
  @event_loop_scheduler = ThreadScheduler.new
@@ -196,23 +122,6 @@ module Phronomy
196
122
  @shutdown_result = nil
197
123
  end
198
124
 
199
- # Cooperative yield point.
200
- #
201
- # Signals the scheduler that the current task is willing to give up CPU time
202
- # so that other ready tasks can run. On the default {ThreadScheduler} this
203
- # calls +Thread.pass+. On a future fiber-based scheduler this would switch
204
- # to the next runnable fiber.
205
- #
206
- # When +blocking_detect_threshold_ms+ is configured, checks whether the
207
- # current task has exceeded that threshold without yielding; if so, emits a
208
- # warning via the configured logger and increments
209
- # +non_yield_threshold_violation_count+.
210
- #
211
- # Call this inside tight loops or CPU-intensive sections of tool +execute+
212
- # methods and Workflow actions to keep the scheduler responsive.
213
- #
214
- # @return [void]
215
- # @api private
216
125
  def yield
217
126
  if (threshold = Phronomy.configuration.blocking_detect_threshold_ms)
218
127
  slice_start = Task.current_cpu_slice_start_ms
@@ -232,64 +141,19 @@ module Phronomy
232
141
  @scheduler.yield
233
142
  end
234
143
 
235
- # Number of times a task has exceeded the CPU-bound detection threshold
236
- # (i.e. ran longer than +blocking_detect_threshold_ms+ without yielding).
237
- # Resets to 0 when the Runtime is recreated.
238
- # @return [Integer]
239
- # @api private
240
144
  def non_yield_threshold_violation_count
241
145
  @metrics.starvation_count
242
146
  end
243
147
 
244
- # Cooperative yield point with a call-count gate.
245
- #
246
- # Increments a per-thread counter and calls {#yield} when the counter
247
- # reaches a multiple of +every+. The counter is thread-local so concurrent
248
- # tasks each maintain their own independent loop counter without requiring
249
- # a mutex.
250
- #
251
- # @example
252
- # data.each_with_index do |row, i|
253
- # process(row)
254
- # Phronomy::Runtime.instance.yield_if_needed(every: 500)
255
- # end
256
- #
257
- # @param every [Integer] yield once every N calls (default: 1000)
258
- # @return [void]
259
- # @api private
260
148
  def yield_if_needed(every: 1000)
261
- # Delegate Thread.current access to Task so that runtime.rb stays outside
262
- # the Thread.current allowlist (Issue #302).
263
149
  self.yield if (Task.increment_yield_counter! % every).zero?
264
150
  end
265
151
 
266
- # Creates a new {TaskGroup} with an optional concurrency cap.
267
- #
268
- # @param limit [Integer, Float::INFINITY] max simultaneous tasks
269
- # @param failure_policy [Symbol] one of :fail_fast, :collect_all, :skip_failed (default :fail_fast)
270
- # @return [TaskGroup]
271
- # @api private
272
152
  def task_group(limit: Float::INFINITY, failure_policy: :fail_fast)
273
153
  ensure_accepting_work!
274
154
  TaskGroup.new(limit: limit, failure_policy: failure_policy, runtime: self)
275
155
  end
276
156
 
277
- # Spawns a single {Task} using the runtime's scheduler.
278
- #
279
- # The spawned task is registered in the task registry so {#shutdown}
280
- # can wait for it to complete. The task is automatically deregistered
281
- # from the registry when it finishes (success, failure, or cancellation)
282
- # so long-lived runtimes do not accumulate stale references.
283
- #
284
- # Task names beginning with a recognised type prefix are counted in the
285
- # task-centric metrics returned by {#task_snapshot}. Recognised prefixes:
286
- # +agent-+, +tool-+, +workflow-+, +rag-+, +llm-+, +vector-+.
287
- #
288
- # @param name [String, nil] optional label for debugging
289
- # @yield block to execute (concurrently or synchronously, depending on
290
- # the configured scheduler)
291
- # @return [Task]
292
- # @api private
293
157
  def spawn(name: nil, &block)
294
158
  ensure_accepting_work!
295
159
  type = _task_type(name)
@@ -306,9 +170,9 @@ module Phronomy
306
170
  rescue CancellationError
307
171
  @metrics.record_end(type, :cancelled, run_start)
308
172
  raise
309
- rescue => e
173
+ rescue => error
310
174
  @metrics.record_end(type, :failed, run_start)
311
- raise e
175
+ raise error
312
176
  ensure
313
177
  current = Task.current
314
178
  @task_registry.deregister(current) if current
@@ -318,93 +182,28 @@ module Phronomy
318
182
  task
319
183
  end
320
184
 
321
- # Returns a snapshot of task-centric metrics for the current Runtime.
322
- #
323
- # | Key | Description |
324
- # |-----|-------------|
325
- # | `active_agent_tasks` | currently running agent spawns |
326
- # | `active_tool_tasks` | currently running tool spawns |
327
- # | `active_workflow_tasks` | currently running workflow spawns |
328
- # | `active_llm_tasks` | currently running LLM calls |
329
- # | `task_wait_time_p50_ms` | p50 spawn-to-start latency (ms) |
330
- # | `task_wait_time_p95_ms` | p95 spawn-to-start latency (ms) |
331
- # | `task_run_time_p50_ms` | p50 execution duration (ms) |
332
- # | `task_run_time_p95_ms` | p95 execution duration (ms) |
333
- # | `cancelled_tasks` | total cancelled task count |
334
- # | `failed_tasks` | total failed task count |
335
- # | `non_yield_threshold_violation_count` | cumulative count of tasks that ran past `blocking_detect_threshold_ms` without yielding |
336
- #
337
- # @return [Hash{Symbol => Numeric}]
338
- # @api private
339
185
  def task_snapshot
340
186
  @metrics.snapshot
341
187
  end
342
188
 
343
- # Returns the shared {BlockingAdapterPool} for this Runtime.
344
- # All blocking I/O (LLM HTTP, MCP, ActiveRecord, Redis) should be
345
- # submitted through this pool.
346
- #
347
- # Pool settings default to 10 workers / 100-deep queue. Override by
348
- # constructing a Runtime with custom pool options or by replacing the
349
- # shared Runtime via {.instance=} in tests.
350
- #
351
- # @param pool_size [Integer] worker thread count
352
- # (default: {Phronomy::Configuration#blocking_io_pool_size}, currently 10)
353
- # @param queue_size [Integer] max pending operations
354
- # (default: {Phronomy::Configuration#blocking_io_queue_size}, currently 100)
355
- # @return [BlockingAdapterPool]
356
- # @api private
357
- def blocking_io(pool_size: Phronomy.configuration.blocking_io_pool_size,
358
- queue_size: Phronomy.configuration.blocking_io_queue_size)
189
+ def blocking_io(
190
+ pool_size: Phronomy.configuration.blocking_io_pool_size,
191
+ queue_size: Phronomy.configuration.blocking_io_queue_size
192
+ )
359
193
  ensure_accepting_work!
360
194
  @pool_registry.default_pool(pool_size: pool_size, queue_size: queue_size)
361
195
  end
362
196
 
363
- # Returns (or lazily creates) a named {BlockingAdapterPool}.
364
- #
365
- # Named pools allow per-subsystem thread-budget control and observability.
366
- # Recommended pool names: +:llm+, +:mcp+, +:db+, +:redis+, +:tool+.
367
- # Each pool gets its own dedicated worker threads labelled with the pool name.
368
- #
369
- # @example
370
- # runtime.pool(:llm) # default size (10 workers)
371
- # runtime.pool(:db, size: 20) # custom size
372
- #
373
- # @param name [Symbol, String] pool identifier
374
- # @param size [Integer] worker thread count (default: 10)
375
- # @param queue_size [Integer] max pending operations (default: 100)
376
- # @return [BlockingAdapterPool]
377
- # @api private
378
197
  def pool(name, size: 10, queue_size: 100)
379
198
  ensure_accepting_work!
380
199
  @pool_registry.named_pool(name, size: size, queue_size: queue_size)
381
200
  end
382
201
 
383
- # Returns the shared timer queue for this Runtime.
384
- #
385
- # When the scheduler is a {DeterministicScheduler} (e.g. the +:fiber+
386
- # runtime backend), returns a {SchedulerTimerAdapter} that integrates with
387
- # the scheduler's tick cycle instead of spawning a background OS thread.
388
- # This is the first concrete step of the TimerQueue scheduler-tick integration
389
- # described in ADR-010 (Issue #331).
390
- #
391
- # For all other schedulers, returns a {TimerQueue} backed by a single
392
- # background thread.
393
- #
394
- # All deadline-based cancellation should be registered here instead of
395
- # spawning one-off sleep threads. Lazily created on first access.
396
- #
397
- # @return [TimerQueue, SchedulerTimerAdapter]
398
- # @api private
399
202
  def timer_queue
400
203
  ensure_accepting_work!
401
204
  @timer_service.timer_queue
402
205
  end
403
206
 
404
- # Returns the Runtime-owned EventLoop, creating it once on first use.
405
- # During draining an existing loop remains available, but an unused loop
406
- # is never created after shutdown begins.
407
- # @api private
408
207
  def event_loop
409
208
  @lifecycle_mutex.synchronize do
410
209
  case @state
@@ -422,22 +221,15 @@ module Phronomy
422
221
  end
423
222
  end
424
223
 
425
- # Does not create an EventLoop.
426
- # @api private
427
224
  def event_loop_current?
428
225
  event_loop = @lifecycle_mutex.synchronize { @event_loop }
429
226
  event_loop&.current? || false
430
227
  end
431
228
 
432
- # Internal EventLoop service spawn. Always uses a real OS thread and is
433
- # deliberately excluded from the normal TaskRegistry drain.
434
- # @api private
435
229
  def __spawn_event_loop_service(&block)
436
230
  @event_loop_scheduler.spawn(name: "event-loop", parent: nil, &block)
437
231
  end
438
232
 
439
- # Called only for an unexpected dispatcher failure.
440
- # @api private
441
233
  def __event_loop_failed(error)
442
234
  @lifecycle_mutex.synchronize do
443
235
  return if @shutdown_result || @state == :terminated
@@ -447,13 +239,6 @@ module Phronomy
447
239
  end
448
240
  end
449
241
 
450
- # Synchronous, bounded Runtime shutdown. Must be invoked from an external
451
- # management thread, lifecycle hook, or test teardown—not a Phronomy Task.
452
- #
453
- # +timeout+ bounds TaskRegistry and EventLoop graceful shutdown. Existing
454
- # pool and timer shutdown contracts are unchanged by this proposal.
455
- # @return [Runtime::ShutdownResult]
456
- # @api public
457
242
  def shutdown(
458
243
  timeout: Phronomy.configuration.event_loop_stop_grace_seconds,
459
244
  cancel_grace: timeout
@@ -541,13 +326,13 @@ module Phronomy
541
326
  error = nil
542
327
  begin
543
328
  @pool_registry.shutdown
544
- rescue => e
545
- error ||= e
329
+ rescue => caught
330
+ error ||= caught
546
331
  ensure
547
332
  begin
548
333
  @timer_service.shutdown
549
- rescue => e
550
- error ||= e
334
+ rescue => caught
335
+ error ||= caught
551
336
  end
552
337
  end
553
338
  error
@@ -569,7 +354,7 @@ module Phronomy
569
354
  def _task_type(name)
570
355
  return :other if name.nil?
571
356
 
572
- prefix = TASK_TYPE_PREFIXES.find { |p| name.to_s.start_with?("#{p}-") }
357
+ prefix = TASK_TYPE_PREFIXES.find { |candidate| name.to_s.start_with?("#{candidate}-") }
573
358
  prefix ? prefix.to_sym : :other
574
359
  end
575
360
  end
@@ -2,41 +2,20 @@
2
2
 
3
3
  module Phronomy
4
4
  # Manages a bounded set of concurrent {Task}s with structured concurrency.
5
- #
6
- # Enforces an upper bound on simultaneously running tasks (+limit+).
7
- # When the limit is reached, {#spawn} blocks the caller until a slot
8
- # becomes available. Results are always returned in the order tasks
9
- # were spawned, regardless of completion order.
10
- #
11
- # A configurable +failure_policy+ controls how errors propagate:
12
- # - +:fail_fast+ (default) — cancels all remaining tasks on the first error
13
- # - +:collect_all+ — waits for every task to complete, then raises the first error
14
- # - +:skip_failed+ — ignores failed tasks and returns only successful results
15
- #
16
- # {#cancel_all!} cancels every task in the group and joins them, guaranteeing
17
- # that the active child task count reaches zero before returning.
18
- #
19
- # @example Parallel tool calls with a concurrency cap
20
- # group = Phronomy::TaskGroup.new(limit: 5)
21
- # tasks = items.map { |item| group.spawn { process(item) } }
22
- # results = group.await_all # Array in spawn order
23
- #
24
- # @example Collect-all failure policy
25
- # group = Phronomy::TaskGroup.new(failure_policy: :collect_all)
26
- # …
27
5
  class TaskGroup
28
- # Valid failure policies.
29
6
  FAILURE_POLICIES = %i[fail_fast collect_all skip_failed].freeze
30
7
 
31
- # @param limit [Integer, Float::INFINITY] maximum simultaneous active tasks
32
- # @param failure_policy [Symbol] one of {FAILURE_POLICIES} (default +:fail_fast+)
33
- # @param runtime [Runtime, nil] runtime used to spawn tasks via {Runtime#spawn};
34
- # when +nil+, tasks are created directly via +Task.new+ (backward-compatible mode).
35
- # Pass +runtime: self+ from {Runtime#task_group} to keep task execution consistent
36
- # with the configured scheduler backend.
8
+ # @param limit [Integer, Float::INFINITY]
9
+ # @param failure_policy [Symbol]
10
+ # @param runtime [Runtime] runtime authority used to spawn every child Task
37
11
  # @api private
38
- def initialize(limit: Float::INFINITY, failure_policy: :fail_fast, runtime: nil)
39
- raise ArgumentError, "unknown failure_policy: #{failure_policy}" unless FAILURE_POLICIES.include?(failure_policy)
12
+ def initialize(runtime:, limit: Float::INFINITY, failure_policy: :fail_fast)
13
+ unless FAILURE_POLICIES.include?(failure_policy)
14
+ raise ArgumentError, "unknown failure_policy: #{failure_policy}"
15
+ end
16
+ unless runtime
17
+ raise ArgumentError, "runtime is required"
18
+ end
40
19
 
41
20
  @limit = limit
42
21
  @failure_policy = failure_policy
@@ -47,44 +26,20 @@ module Phronomy
47
26
  @active = 0
48
27
  end
49
28
 
50
- # Spawns a new task within the group.
51
- # Blocks if the number of currently active tasks equals +limit+.
52
- #
53
- # @yield block to execute concurrently
54
- # @return [Task] the spawned task
55
29
  # @api private
56
30
  def spawn(&block)
57
31
  wait_for_slot!
58
32
 
59
- task = if @runtime
60
- @runtime.spawn(name: "task-group-worker") do
61
- block.call
62
- ensure
63
- release_slot!
64
- end
65
- else
66
- Task.new do
67
- block.call
68
- ensure
69
- release_slot!
70
- end
33
+ task = @runtime.spawn(name: "task-group-worker") do
34
+ block.call
35
+ ensure
36
+ release_slot!
71
37
  end
72
38
 
73
39
  @mutex.synchronize { @tasks << task }
74
40
  task
75
41
  end
76
42
 
77
- # Waits for all spawned tasks to complete.
78
- # Returns results in spawn order.
79
- #
80
- # Failure behaviour is controlled by the +failure_policy+ set at
81
- # construction time:
82
- # - +:fail_fast+ — raises the first error after cancelling unfinished tasks
83
- # - +:collect_all+ — waits for all tasks, then raises the first error
84
- # - +:skip_failed+ — returns only the values of successful tasks
85
- #
86
- # @return [Array] results in spawn order (or successful-only for :skip_failed)
87
- # @raise [Exception] when any task failed (except :skip_failed)
88
43
  # @api private
89
44
  def await_all
90
45
  tasks = @mutex.synchronize { @tasks.dup }
@@ -99,14 +54,6 @@ module Phronomy
99
54
 
100
55
  private
101
56
 
102
- # Cooperative await_all for DeterministicScheduler context.
103
- # Uses on_complete callbacks + AsyncQueue to observe task completions in
104
- # arrival order (not spawn order), matching the fail-fast semantics of the
105
- # threaded path. AsyncQueue#pop suspends the current Fiber cooperatively
106
- # rather than blocking the OS thread.
107
- # @api private
108
- # @param tasks [Array<Task>]
109
- # @return [Array]
110
57
  def _await_all_cooperative(tasks)
111
58
  completion_q = Phronomy::Concurrency::AsyncQueue.new
112
59
  tasks.each_with_index do |task, idx|
@@ -120,34 +67,29 @@ module Phronomy
120
67
  fail_fast_error = nil
121
68
 
122
69
  tasks.length.times do
123
- entry = completion_q.pop # cooperative suspend via scheduler signal
70
+ entry = completion_q.pop
124
71
  entries[entry[:index]] = entry
125
72
 
126
73
  if entry[:error] && @failure_policy == :fail_fast && !cancelled
127
74
  cancelled = true
128
75
  fail_fast_error = entry[:error]
129
- tasks.each { |t| t.cancel! unless t.done? }
76
+ tasks.each { |task| task.cancel! unless task.done? }
130
77
  end
131
78
  end
132
79
 
133
80
  case @failure_policy
134
81
  when :fail_fast
135
82
  raise fail_fast_error if fail_fast_error
136
- entries.map { |r| r[:value] }
83
+ entries.map { |entry| entry[:value] }
137
84
  when :skip_failed
138
- entries.filter_map { |r| r[:value] unless r[:error] }
139
- else # :collect_all
140
- errors = entries.filter_map { |r| r[:error] }
85
+ entries.filter_map { |entry| entry[:value] unless entry[:error] }
86
+ else
87
+ errors = entries.filter_map { |entry| entry[:error] }
141
88
  raise errors.first if errors.any?
142
- entries.map { |r| r[:value] }
89
+ entries.map { |entry| entry[:value] }
143
90
  end
144
91
  end
145
92
 
146
- # Thread-blocking await_all for ThreadBackend / ImmediateBackend context.
147
- # Uses Task#on_complete callbacks instead of spawning N additional watcher
148
- # tasks (Issue #328). on_complete receives the task's value and error
149
- # directly — no await call is needed, eliminating the risk of a self-join
150
- # when the callback fires inside the task's own execution thread.
151
93
  def _await_all_threaded(tasks)
152
94
  completion_q = Queue.new
153
95
  tasks.each_with_index do |task, idx|
@@ -158,8 +100,6 @@ module Phronomy
158
100
 
159
101
  entries = Array.new(tasks.length)
160
102
  cancelled = false
161
- # The error that triggered fail_fast cancellation (tracked separately so
162
- # we raise it rather than a secondary CancellationError from cancelled tasks).
163
103
  fail_fast_error = nil
164
104
 
165
105
  tasks.length.times do
@@ -169,45 +109,35 @@ module Phronomy
169
109
  if entry[:error] && @failure_policy == :fail_fast && !cancelled
170
110
  cancelled = true
171
111
  fail_fast_error = entry[:error]
172
- tasks.each { |t| t.cancel! unless t.done? }
112
+ tasks.each { |task| task.cancel! unless task.done? }
173
113
  end
174
114
  end
175
115
 
176
116
  case @failure_policy
177
117
  when :fail_fast
178
118
  raise fail_fast_error if fail_fast_error
179
- entries.map { |r| r[:value] }
119
+ entries.map { |entry| entry[:value] }
180
120
  when :skip_failed
181
- entries.filter_map { |r| r[:value] unless r[:error] }
182
- else # :collect_all
183
- errors = entries.filter_map { |r| r[:error] }
121
+ entries.filter_map { |entry| entry[:value] unless entry[:error] }
122
+ else
123
+ errors = entries.filter_map { |entry| entry[:error] }
184
124
  raise errors.first if errors.any?
185
- entries.map { |r| r[:value] }
125
+ entries.map { |entry| entry[:value] }
186
126
  end
187
127
  end
188
128
 
189
129
  public
190
130
 
191
- # Cancels all tasks currently in the group and waits for each to finish.
192
- # After this method returns, the active child task count is guaranteed to
193
- # be zero.
194
- #
195
- # Note: if a task is cancelled before its block has started executing, the
196
- # internal +ensure+ clause inside the block may not run, so @active is
197
- # reset explicitly after all tasks are joined.
198
- #
199
- # @return [self]
200
131
  # @api private
201
132
  def cancel_all!
202
133
  tasks = @mutex.synchronize { @tasks.dup }
203
134
  tasks.each(&:cancel!)
204
- tasks.each do |t|
205
- t.join
135
+ tasks.each do |task|
136
+ task.join
206
137
  rescue
207
138
  nil
208
139
  end
209
- # Force @active to zero: tasks cancelled before block execution starts
210
- # may not decrement @active via their ensure clause.
140
+
211
141
  scheduler = Phronomy::Runtime::Scheduler.current
212
142
  if scheduler && @coop_signal
213
143
  @active = 0
@@ -221,8 +151,6 @@ module Phronomy
221
151
  self
222
152
  end
223
153
 
224
- # Returns the number of currently executing child tasks.
225
- # @return [Integer]
226
154
  # @api private
227
155
  def active_task_count
228
156
  @mutex.synchronize { @active }