phronomy 0.16.0 → 0.18.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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +151 -1134
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +69 -1249
  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 +26 -6
  10. data/benchmark/bench_tool_schema.rb +2 -35
  11. data/docs/changelog/0.14-and-earlier.md +1137 -0
  12. data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
  13. data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
  14. data/docs/decisions/010-cooperative-first-concurrency.md +155 -228
  15. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +2 -2
  16. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  17. data/docs/features.md +87 -0
  18. data/docs/getting-started.md +351 -0
  19. data/docs/migrations/0.15.md +35 -0
  20. data/docs/migrations/0.16.md +43 -0
  21. data/docs/runtime-and-concurrency.md +258 -0
  22. data/examples/workflows/generic_task_event_mapping.rb +14 -6
  23. data/lib/phronomy/agent/agent_invocation.rb +2 -36
  24. data/lib/phronomy/agent/agent_invocation_session_builder.rb +157 -94
  25. data/lib/phronomy/agent/agent_root.rb +1 -2
  26. data/lib/phronomy/agent/async_event_api.rb +3 -3
  27. data/lib/phronomy/agent/base.rb +170 -333
  28. data/lib/phronomy/agent/context/capability/base.rb +177 -298
  29. data/lib/phronomy/agent/context_assembler.rb +65 -29
  30. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
  31. data/lib/phronomy/agent/context_plan_validator.rb +0 -33
  32. data/lib/phronomy/agent/execution_coordinator.rb +6 -7
  33. data/lib/phronomy/agent/journal_projection.rb +28 -2
  34. data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
  35. data/lib/phronomy/agent/shared_state.rb +48 -138
  36. data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
  37. data/lib/phronomy/agent/tool_executor.rb +29 -71
  38. data/lib/phronomy/agent/tool_invocation.rb +180 -336
  39. data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
  40. data/lib/phronomy/agent.rb +6 -10
  41. data/lib/phronomy/configuration.rb +4 -171
  42. data/lib/phronomy/diagnostics.rb +12 -41
  43. data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
  44. data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
  45. data/lib/phronomy/engine/concurrency/cancellation_token.rb +51 -79
  46. data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
  47. data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
  48. data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
  49. data/lib/phronomy/engine/event_loop.rb +89 -190
  50. data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
  51. data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
  52. data/lib/phronomy/engine/runtime.rb +49 -377
  53. data/lib/phronomy/engine/task.rb +136 -277
  54. data/lib/phronomy/llm_adapter/base.rb +14 -14
  55. data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
  56. data/lib/phronomy/llm_adapter.rb +2 -2
  57. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  58. data/lib/phronomy/metrics.rb +15 -30
  59. data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
  60. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
  61. data/lib/phronomy/multi_agent/handoff.rb +1 -0
  62. data/lib/phronomy/multi_agent/orchestrator.rb +252 -256
  63. data/lib/phronomy/multi_agent/team_coordinator.rb +44 -133
  64. data/lib/phronomy/testing/eval/comparison.rb +23 -0
  65. data/lib/phronomy/testing/eval/dataset.rb +27 -0
  66. data/lib/phronomy/testing/eval/eval_case.rb +13 -0
  67. data/lib/phronomy/testing/eval/eval_result.rb +16 -0
  68. data/lib/phronomy/testing/eval/metrics.rb +43 -0
  69. data/lib/phronomy/testing/eval/runner.rb +52 -0
  70. data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
  71. data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
  72. data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
  73. data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
  74. data/lib/phronomy/testing/eval/scorer.rb +10 -0
  75. data/lib/phronomy/testing/eval.rb +9 -0
  76. data/lib/phronomy/testing/fake_clock.rb +6 -53
  77. data/lib/phronomy/testing.rb +2 -6
  78. data/lib/phronomy/tools/agent.rb +141 -6
  79. data/lib/phronomy/vector_store/async_backend.rb +21 -17
  80. data/lib/phronomy/vector_store/base.rb +2 -2
  81. data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
  82. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  83. data/lib/phronomy/version.rb +1 -1
  84. data/lib/phronomy/workflow_runner.rb +2 -4
  85. data/lib/phronomy.rb +7 -121
  86. data/scripts/api_snapshot.rb +4 -15
  87. metadata +24 -38
  88. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  89. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  90. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  91. data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
  92. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
  93. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
  94. data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
  95. data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
  96. data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
  97. data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
  98. data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
  99. data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
  100. data/lib/phronomy/engine/task/backend.rb +0 -80
  101. data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
  102. data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
  103. data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
  104. data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
  105. data/lib/phronomy/engine/task/thread_backend.rb +0 -84
  106. data/lib/phronomy/engine/task_group.rb +0 -265
  107. data/lib/phronomy/eval/comparison.rb +0 -47
  108. data/lib/phronomy/eval/dataset.rb +0 -45
  109. data/lib/phronomy/eval/eval_case.rb +0 -17
  110. data/lib/phronomy/eval/eval_result.rb +0 -29
  111. data/lib/phronomy/eval/metrics.rb +0 -66
  112. data/lib/phronomy/eval/runner.rb +0 -94
  113. data/lib/phronomy/eval/scorer/base.rb +0 -22
  114. data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
  115. data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
  116. data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
  117. data/lib/phronomy/eval/scorer.rb +0 -9
  118. data/lib/phronomy/eval.rb +0 -7
  119. data/lib/phronomy/knowledge_source.rb +0 -12
  120. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  121. data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
  122. data/lib/phronomy/testing/fake_scheduler.rb +0 -104
  123. data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
@@ -1,157 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- class Task
5
- # Cooperative task backend using Ruby Fibers.
6
- #
7
- # Unlike {ImmediateBackend} (which runs the block to completion on the
8
- # calling thread) or {ThreadBackend} (which runs the block on a new OS
9
- # thread), +FiberBackend+ wraps the block in a +Fiber+ that is NOT started
10
- # immediately. The owning scheduler calls {#step} to advance execution one
11
- # cooperative step at a time.
12
- #
13
- # This backend is used exclusively by {Runtime::DeterministicScheduler} to
14
- # enable deterministic, wall-clock-free testing of concurrent logic.
15
- #
16
- # Thread-local key under which the currently active {DeterministicScheduler}
17
- # is stored so that {#await} can suspend cooperatively.
18
- SCHEDULER_KEY = :phronomy_deterministic_scheduler
19
-
20
- # @api private
21
- class FiberBackend < Backend
22
- def initialize(task:, &block)
23
- super
24
- @value = nil
25
- @error = nil
26
- @cancel_error = nil
27
- @cancel_requested = false
28
- @started = false
29
- @cooperative_suspend = false
30
-
31
- # Capture `self` (the FiberBackend instance) in the closure so that
32
- # instance-variable writes from inside the Fiber update this object.
33
- @fiber = Fiber.new do
34
- task.transition!(:running)
35
- begin
36
- # If cancel! was called before the first step, raise immediately.
37
- raise @cancel_error if @cancel_error
38
-
39
- @value = block.call
40
- task.transition!(:completed, value: @value)
41
- rescue CancellationError => e
42
- task.transition!(:cancelled, error: e)
43
- @error = e
44
- rescue => e
45
- task.transition!(:failed, error: e)
46
- @error = e
47
- ensure
48
- task.transition!(:cancelled) unless task.done?
49
- end
50
- end
51
- end
52
-
53
- # Advances execution by one scheduler step.
54
- # Resumes the Fiber until it yields (via +Fiber.yield+) or finishes.
55
- # Cooperative cancellation is checked at the start of each step: if
56
- # +cancel!+ has been called, +CancellationError+ is raised inside the
57
- # Fiber at this controlled checkpoint rather than injected at an
58
- # arbitrary suspension point via +Fiber#raise+.
59
- # @return [self]
60
- # @api private
61
- def step
62
- return self unless @fiber.alive?
63
-
64
- @started = true
65
- # Deliver pending cancellation at this scheduler checkpoint rather than
66
- # injecting it mid-Fiber via Fiber#raise (which would be preemptive).
67
- if @cancel_requested && @cancel_error
68
- begin
69
- @fiber.raise(@cancel_error)
70
- rescue FiberError
71
- nil # Fiber completed between the check and raise — safe to ignore.
72
- end
73
- @cancel_requested = false
74
- return self
75
- end
76
- yield_value = @fiber.resume
77
- # A yield value of :cooperative_suspend signals that the Fiber deliberately
78
- # suspended itself (e.g. inside CoopSignal#wait) and must NOT be
79
- # re-enqueued by step_callable — it will be resumed by an explicit signal.
80
- @cooperative_suspend = (yield_value == :cooperative_suspend)
81
- self
82
- end
83
-
84
- # Returns +true+ if the Fiber yielded cooperatively (via a signal wait)
85
- # and should not be automatically re-enqueued by the scheduler.
86
- # @return [Boolean]
87
- # @api private
88
- def cooperative_suspend?
89
- @cooperative_suspend
90
- end
91
-
92
- # Blocks until the task completes.
93
- #
94
- # When called from within a {DeterministicScheduler}-managed Fiber,
95
- # suspends the current Fiber cooperatively and schedules it to resume
96
- # when this task completes. When called from outside a managed Fiber
97
- # (e.g. the main fiber or a regular thread), drives execution by calling
98
- # {#step} in a loop.
99
- #
100
- # @return [Object]
101
- # @raise [Exception]
102
- # @api private
103
- def wait_result
104
- unless @fiber.alive?
105
- raise @error if @error
106
- return @value
107
- end
108
-
109
- scheduler = Thread.current.thread_variable_get(SCHEDULER_KEY)
110
- # Fiber.main was added in Ruby 3.2.4+; fall back to true (assume we are
111
- # inside a managed Fiber whenever a scheduler is active).
112
- in_managed_fiber = !Fiber.respond_to?(:main) || Fiber.current != Fiber.main
113
- if scheduler && in_managed_fiber
114
- # Cooperative context: suspend current Fiber until task is done.
115
- waiting_fiber = Fiber.current
116
- @task.on_complete { scheduler.enqueue_fiber(-> { waiting_fiber.resume }) }
117
- Fiber.yield(:cooperative_suspend)
118
- else
119
- # Non-cooperative context: drive the fiber to completion.
120
- step while @fiber.alive?
121
- end
122
-
123
- raise @error if @error
124
- @value
125
- end
126
-
127
- # @return [Boolean] +true+ while the Fiber has not yet finished
128
- # @api private
129
- def alive?
130
- @fiber.alive?
131
- end
132
-
133
- # Requests cancellation using a cooperative checkpoint mechanism.
134
- # Sets a cancellation flag; the error is raised inside the Fiber at the
135
- # next +step+ call (i.e. when the scheduler next dispatches this task),
136
- # not injected at an arbitrary suspension point via +Fiber#raise+.
137
- # If the Fiber has not yet started, the error is recorded so it is raised
138
- # on the first {#step}.
139
- # @return [self]
140
- # @api private
141
- def cancel!
142
- @cancel_error = CancellationError.new("Task cancelled")
143
- @cancel_requested = true
144
- self
145
- end
146
-
147
- # Joins execution by stepping until the Fiber is no longer alive.
148
- # @param limit [Numeric, nil] ignored
149
- # @return [self]
150
- # @api private
151
- def join(_limit = nil)
152
- step while @fiber.alive?
153
- self
154
- end
155
- end
156
- end
157
- end
@@ -1,89 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- class Task
5
- # Synchronous task backend that executes the block on the calling thread.
6
- #
7
- # Used by {Runtime::FakeScheduler} to allow tests to verify cooperative
8
- # scheduling behaviour without spawning additional Threads. The block
9
- # runs to completion before {#initialize} returns, so {#await} and {#join}
10
- # always return immediately.
11
- #
12
- # Thread count invariant: +ImmediateBackend+ never creates a new Thread.
13
- class ImmediateBackend < Backend
14
- # Executes +block+ synchronously on the calling thread.
15
- # Saves and restores +Task.current+ so nested ImmediateBackend tasks
16
- # compose correctly.
17
- #
18
- # @param task [Task]
19
- # @yieldreturn [Object]
20
- # @api private
21
- def initialize(task:, &block)
22
- super
23
- @value = nil
24
- @error = nil
25
- previous_task = Thread.current[:phronomy_current_task]
26
- Thread.current[:phronomy_current_task] = task
27
- task.transition!(:running)
28
- begin
29
- @value = block.call
30
- task.transition!(:completed, value: @value)
31
- rescue CancellationError => e
32
- task.transition!(:cancelled, error: e)
33
- @error = e
34
- rescue => e
35
- task.transition!(:failed, error: e)
36
- @error = e
37
- ensure
38
- task.transition!(:cancelled) unless task.done?
39
- Thread.current[:phronomy_current_task] = previous_task
40
- end
41
- end
42
-
43
- # Returns the block's return value, or re-raises its exception.
44
- # @return [Object]
45
- # @raise [Exception]
46
- # @api private
47
- def wait_result
48
- raise @error if @error
49
-
50
- @value
51
- end
52
-
53
- # @return [Object, nil]
54
- # @api private
55
- def completed_value
56
- @value
57
- end
58
-
59
- # @return [Exception, nil]
60
- # @api private
61
- def completed_error
62
- @error
63
- end
64
-
65
- # Always +false+ — block has already completed by the time the task
66
- # is visible to callers.
67
- # @return [Boolean]
68
- # @api private
69
- def alive?
70
- false
71
- end
72
-
73
- # No-op: the block has already completed.
74
- # @return [self]
75
- # @api private
76
- def cancel!
77
- self
78
- end
79
-
80
- # Returns immediately — nothing to wait for.
81
- # @param limit [Numeric, nil] ignored
82
- # @return [self]
83
- # @api private
84
- def join(_limit = nil)
85
- self
86
- end
87
- end
88
- end
89
- end
@@ -1,90 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "timeout"
4
-
5
- module Phronomy
6
- class Task
7
- # Backend for Tasks created by {Task#map}.
8
- #
9
- # A mapped task's lifecycle is driven entirely by the +on_complete+
10
- # callback of its source task — it never spawns a thread of its own.
11
- # +MappedBackend+ transitions the owning task to +:running+ immediately
12
- # on initialization so that +FSMSession+ treats it as an in-progress
13
- # async action. Completion (or failure) is triggered externally via
14
- # {Task#transition!} from the +on_complete+ callback registered by
15
- # {Task#map}.
16
- #
17
- # +await+ and +join+ block until {#unblock} is called, which {Task#map}
18
- # arranges by registering a second +on_complete+ callback on the *mapped*
19
- # task itself after the transform callback has been registered.
20
- #
21
- # @api private
22
- class MappedBackend < Backend
23
- def initialize(task:, &)
24
- super
25
- @done_queue = Queue.new
26
- task.transition!(:running)
27
- end
28
-
29
- # Unblocks +await+ / +join+. Called by {Task#map} after the mapped task
30
- # reaches a terminal state.
31
- # @api private
32
- def unblock(value, error)
33
- @done_queue.push([value, error])
34
- end
35
-
36
- # Blocks until the mapped task reaches a terminal state.
37
- # @return [Object] the mapped value
38
- # @raise [Exception] if the source task or the map block raised an error
39
- # @api private
40
- def wait_result
41
- scheduler = Thread.current.thread_variable_get(Task::SCHEDULER_KEY)
42
- in_managed_fiber = !Fiber.respond_to?(:main) || Fiber.current != Fiber.main
43
- if scheduler && in_managed_fiber && !@task.done?
44
- scheduler.track_blocking_await
45
- waiting_fiber = Fiber.current
46
- @task.on_complete do |_value, _error|
47
- scheduler.complete_blocking_await
48
- scheduler.enqueue_fiber(-> { waiting_fiber.resume })
49
- end
50
- Fiber.yield(:cooperative_suspend)
51
- end
52
-
53
- value, error = @done_queue.pop
54
- raise error if error
55
-
56
- value
57
- end
58
-
59
- # Returns +false+ — a mapped task has no independent thread to kill.
60
- # @return [Boolean]
61
- # @api private
62
- def alive?
63
- false
64
- end
65
-
66
- # No-op — mapped tasks carry no independent thread to cancel.
67
- # @return [self]
68
- # @api private
69
- def cancel!
70
- self
71
- end
72
-
73
- # Blocks until the mapped task completes, with an optional timeout.
74
- # @param limit [Numeric, nil]
75
- # @return [Object, nil] +nil+ on timeout
76
- # @api private
77
- def join(limit = nil)
78
- if limit.nil?
79
- wait_result
80
- else
81
- begin
82
- Timeout.timeout(limit) { wait_result }
83
- rescue Timeout::Error
84
- nil
85
- end
86
- end
87
- end
88
- end
89
- end
90
- end
@@ -1,84 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- class Task
5
- # Thread-based Task backend (default).
6
- #
7
- # Each task runs on its own OS thread. Cancellation is delivered via
8
- # +Thread#raise(CancellationError)+, which cooperates with +rescue+ clauses
9
- # inside the block. This backend is always available and requires no
10
- # external dependencies.
11
- #
12
- # When the cooperative scheduler backend is introduced, this backend will
13
- # remain available as the fallback for blocking I/O operations that must
14
- # run outside the scheduler (e.g. inside {BlockingAdapterPool}).
15
- class ThreadBackend < Backend
16
- def initialize(task:, &block)
17
- super
18
- @value = nil
19
- @error = nil
20
- @thread = Thread.new do
21
- Thread.current.name = task.name if task.name
22
- Thread.current[:phronomy_current_task] = task
23
- Thread.current[:phronomy_task_cpu_slice_start_ms] =
24
- Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
25
- task.transition!(:running)
26
- @value = block.call
27
- task.transition!(:completed, value: @value)
28
- rescue CancellationError => e
29
- task.transition!(:cancelled, error: e)
30
- @error = e
31
- rescue => e
32
- task.transition!(:failed, error: e)
33
- @error = e
34
- ensure
35
- # Guard against Thread#raise firing before the rescue handler has a
36
- # chance to run (e.g. when cancel! is called immediately after spawn).
37
- task.transition!(:cancelled) unless task.done?
38
- end
39
- end
40
-
41
- # @return [Object]
42
- # @raise [Exception]
43
- # @api private
44
- def wait_result
45
- @thread.join
46
- raise @error if @error
47
-
48
- @value
49
- end
50
-
51
- # @return [Boolean]
52
- # @api private
53
- def alive?
54
- @thread.alive?
55
- end
56
-
57
- # @return [self]
58
- # @api private
59
- def cancel!
60
- @thread.raise(CancellationError, "Task cancelled") if @thread.alive?
61
- self
62
- end
63
-
64
- # @param limit [Numeric, nil]
65
- # @return [Thread, nil]
66
- # @api private
67
- def join(limit = nil)
68
- @thread.join(limit)
69
- end
70
-
71
- # @return [Object, nil]
72
- # @api private
73
- def completed_value
74
- @value
75
- end
76
-
77
- # @return [Exception, nil]
78
- # @api private
79
- def completed_error
80
- @error
81
- end
82
- end
83
- end
84
- end
@@ -1,265 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
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
- class TaskGroup
28
- # Valid failure policies.
29
- FAILURE_POLICIES = %i[fail_fast collect_all skip_failed].freeze
30
-
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.
37
- # @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)
40
-
41
- @limit = limit
42
- @failure_policy = failure_policy
43
- @runtime = runtime
44
- @tasks = []
45
- @mutex = Mutex.new
46
- @cond = ConditionVariable.new
47
- @active = 0
48
- end
49
-
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
- # @api private
56
- def spawn(&block)
57
- wait_for_slot!
58
-
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
71
- end
72
-
73
- @mutex.synchronize { @tasks << task }
74
- task
75
- end
76
-
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
- # @api private
89
- def await_all
90
- tasks = @mutex.synchronize { @tasks.dup }
91
- return [] if tasks.empty?
92
-
93
- if Phronomy::Runtime::Scheduler.current
94
- _await_all_cooperative(tasks)
95
- else
96
- _await_all_threaded(tasks)
97
- end
98
- end
99
-
100
- private
101
-
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
- def _await_all_cooperative(tasks)
111
- completion_q = Phronomy::Concurrency::AsyncQueue.new
112
- tasks.each_with_index do |task, idx|
113
- task.on_complete do |value, error|
114
- completion_q.push({index: idx, value: value, error: error})
115
- end
116
- end
117
-
118
- entries = Array.new(tasks.length)
119
- cancelled = false
120
- fail_fast_error = nil
121
-
122
- tasks.length.times do
123
- entry = completion_q.pop # cooperative suspend via scheduler signal
124
- entries[entry[:index]] = entry
125
-
126
- if entry[:error] && @failure_policy == :fail_fast && !cancelled
127
- cancelled = true
128
- fail_fast_error = entry[:error]
129
- tasks.each { |t| t.cancel! unless t.done? }
130
- end
131
- end
132
-
133
- case @failure_policy
134
- when :fail_fast
135
- raise fail_fast_error if fail_fast_error
136
- entries.map { |r| r[:value] }
137
- 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] }
141
- raise errors.first if errors.any?
142
- entries.map { |r| r[:value] }
143
- end
144
- end
145
-
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
- def _await_all_threaded(tasks)
152
- completion_q = Queue.new
153
- tasks.each_with_index do |task, idx|
154
- task.on_complete do |value, error|
155
- completion_q.push({index: idx, value: value, error: error})
156
- end
157
- end
158
-
159
- entries = Array.new(tasks.length)
160
- 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
- fail_fast_error = nil
164
-
165
- tasks.length.times do
166
- entry = completion_q.pop
167
- entries[entry[:index]] = entry
168
-
169
- if entry[:error] && @failure_policy == :fail_fast && !cancelled
170
- cancelled = true
171
- fail_fast_error = entry[:error]
172
- tasks.each { |t| t.cancel! unless t.done? }
173
- end
174
- end
175
-
176
- case @failure_policy
177
- when :fail_fast
178
- raise fail_fast_error if fail_fast_error
179
- entries.map { |r| r[:value] }
180
- 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] }
184
- raise errors.first if errors.any?
185
- entries.map { |r| r[:value] }
186
- end
187
- end
188
-
189
- public
190
-
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
- # @api private
201
- def cancel_all!
202
- tasks = @mutex.synchronize { @tasks.dup }
203
- tasks.each(&:cancel!)
204
- tasks.each do |t|
205
- t.join
206
- rescue
207
- nil
208
- end
209
- # Force @active to zero: tasks cancelled before block execution starts
210
- # may not decrement @active via their ensure clause.
211
- scheduler = Phronomy::Runtime::Scheduler.current
212
- if scheduler && @coop_signal
213
- @active = 0
214
- scheduler.raise_signal_all(@coop_signal)
215
- else
216
- @mutex.synchronize do
217
- @active = 0
218
- @cond.broadcast
219
- end
220
- end
221
- self
222
- end
223
-
224
- # Returns the number of currently executing child tasks.
225
- # @return [Integer]
226
- # @api private
227
- def active_task_count
228
- @mutex.synchronize { @active }
229
- end
230
-
231
- private
232
-
233
- def wait_for_slot!
234
- scheduler = Phronomy::Runtime::Scheduler.current
235
- if scheduler
236
- @coop_signal ||= scheduler.new_signal
237
- loop do
238
- if @active < @limit
239
- @active += 1
240
- return
241
- end
242
- scheduler.wait_for_signal(@coop_signal)
243
- end
244
- else
245
- @mutex.synchronize do
246
- @cond.wait(@mutex) while @active >= @limit
247
- @active += 1
248
- end
249
- end
250
- end
251
-
252
- def release_slot!
253
- scheduler = Phronomy::Runtime::Scheduler.current
254
- if scheduler && @coop_signal
255
- @active -= 1
256
- scheduler.raise_signal(@coop_signal)
257
- else
258
- @mutex.synchronize do
259
- @active -= 1
260
- @cond.signal
261
- end
262
- end
263
- end
264
- end
265
- end