phronomy 0.7.0 → 0.8.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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -7
  3. data/CHANGELOG.md +151 -1
  4. data/README.md +170 -47
  5. data/Rakefile +33 -0
  6. data/benchmark/baseline.json +1 -1
  7. data/benchmark/bench_context_assembler.rb +2 -2
  8. data/benchmark/bench_regression.rb +6 -5
  9. data/benchmark/bench_token_estimator.rb +5 -5
  10. data/benchmark/bench_tool_schema.rb +1 -1
  11. data/benchmark/bench_vector_store.rb +1 -1
  12. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
  13. data/docs/decisions/006-no-built-in-guardrails.md +20 -2
  14. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  15. data/lib/phronomy/agent/base.rb +285 -137
  16. data/lib/phronomy/agent/checkpoint.rb +118 -0
  17. data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
  18. data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
  19. data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
  20. data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
  21. data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
  22. data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
  23. data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
  24. data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
  25. data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
  26. data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
  27. data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
  28. data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
  29. data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
  30. data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
  31. data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
  32. data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
  33. data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
  34. data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
  35. data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
  36. data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
  37. data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
  38. data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
  39. data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
  40. data/lib/phronomy/agent/fsm.rb +42 -65
  41. data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
  42. data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
  43. data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
  44. data/lib/phronomy/agent/react_agent.rb +27 -14
  45. data/lib/phronomy/agent/runner.rb +2 -2
  46. data/lib/phronomy/agent/tool_executor.rb +108 -0
  47. data/lib/phronomy/concurrency/async_queue.rb +157 -0
  48. data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
  49. data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
  50. data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
  51. data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
  52. data/lib/phronomy/concurrency/deadline.rb +65 -0
  53. data/lib/phronomy/concurrency/gate_registry.rb +52 -0
  54. data/lib/phronomy/concurrency/pool_registry.rb +57 -0
  55. data/lib/phronomy/configuration.rb +142 -0
  56. data/lib/phronomy/context.rb +2 -8
  57. data/lib/phronomy/diagnostics.rb +62 -0
  58. data/lib/phronomy/embeddings.rb +2 -2
  59. data/lib/phronomy/eval/runner.rb +13 -9
  60. data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
  61. data/lib/phronomy/event_loop.rb +184 -46
  62. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  63. data/lib/phronomy/invocation_context.rb +152 -0
  64. data/lib/phronomy/knowledge_source.rb +0 -5
  65. data/lib/phronomy/llm_adapter/base.rb +104 -0
  66. data/lib/phronomy/llm_adapter/ruby_llm.rb +47 -0
  67. data/lib/phronomy/llm_adapter.rb +20 -0
  68. data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
  69. data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
  70. data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
  71. data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
  72. data/lib/phronomy/loader.rb +4 -4
  73. data/lib/phronomy/metrics.rb +38 -0
  74. data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
  75. data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +151 -126
  76. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +149 -0
  77. data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
  78. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  79. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  80. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  81. data/lib/phronomy/runtime/scheduler.rb +98 -0
  82. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  83. data/lib/phronomy/runtime/task_registry.rb +48 -0
  84. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  85. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  86. data/lib/phronomy/runtime/timer_service.rb +42 -0
  87. data/lib/phronomy/runtime.rb +389 -0
  88. data/lib/phronomy/splitter.rb +3 -3
  89. data/lib/phronomy/task/backend.rb +80 -0
  90. data/lib/phronomy/task/fiber_backend.rb +157 -0
  91. data/lib/phronomy/task/immediate_backend.rb +89 -0
  92. data/lib/phronomy/task/thread_backend.rb +84 -0
  93. data/lib/phronomy/task.rb +275 -0
  94. data/lib/phronomy/task_group.rb +265 -0
  95. data/lib/phronomy/testing/fake_clock.rb +109 -0
  96. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  97. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  98. data/lib/phronomy/testing.rb +12 -0
  99. data/lib/phronomy/tool/base.rb +156 -7
  100. data/lib/phronomy/tool/mcp_tool.rb +47 -16
  101. data/lib/phronomy/tool/scope_policy.rb +50 -0
  102. data/lib/phronomy/tracing/null_tracer.rb +3 -1
  103. data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
  104. data/lib/phronomy/vector_store.rb +2 -2
  105. data/lib/phronomy/version.rb +1 -1
  106. data/lib/phronomy/workflow.rb +52 -5
  107. data/lib/phronomy/workflow_context.rb +37 -2
  108. data/lib/phronomy/workflow_runner.rb +28 -77
  109. data/lib/phronomy.rb +43 -0
  110. metadata +73 -33
  111. data/lib/phronomy/agent/parallel_tool_chat.rb +0 -92
  112. data/lib/phronomy/cancellation_token.rb +0 -92
  113. data/lib/phronomy/context/compaction_context.rb +0 -111
  114. data/lib/phronomy/context/trigger_context.rb +0 -39
  115. data/lib/phronomy/context/trim_context.rb +0 -75
  116. data/lib/phronomy/embeddings/base.rb +0 -22
  117. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
  118. data/lib/phronomy/fsm_session.rb +0 -201
  119. data/lib/phronomy/knowledge_source/base.rb +0 -36
  120. data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
  121. data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
  122. data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
  123. data/lib/phronomy/loader/base.rb +0 -25
  124. data/lib/phronomy/loader/csv_loader.rb +0 -56
  125. data/lib/phronomy/loader/markdown_loader.rb +0 -76
  126. data/lib/phronomy/loader/plain_text_loader.rb +0 -22
  127. data/lib/phronomy/prompt_template.rb +0 -96
  128. data/lib/phronomy/splitter/base.rb +0 -47
  129. data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
  130. data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
  131. data/lib/phronomy/vector_store/base.rb +0 -82
  132. data/lib/phronomy/vector_store/in_memory.rb +0 -93
  133. data/lib/phronomy/vector_store/pgvector.rb +0 -127
  134. data/lib/phronomy/vector_store/redis_search.rb +0 -192
@@ -0,0 +1,89 @@
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 await
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
@@ -0,0 +1,84 @@
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 await
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
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "task/backend"
4
+ require_relative "task/thread_backend"
5
+ require_relative "task/immediate_backend"
6
+ require_relative "task/fiber_backend"
7
+
8
+ module Phronomy
9
+ # A single unit of concurrent work.
10
+ #
11
+ # Decouples task semantics from the underlying execution primitive via a
12
+ # pluggable {Backend}. The default backend is {ThreadBackend}; a cooperative
13
+ # or test-double backend can be substituted via {.default_backend_class=} or
14
+ # by passing +backend_class:+ to {.spawn}.
15
+ #
16
+ # +Task.spawn+ is an **internal API** used by schedulers and the framework
17
+ # itself. Application code and framework components should use
18
+ # +Runtime.instance.spawn+ instead, which routes through the configured
19
+ # scheduler and respects the concurrency model.
20
+ #
21
+ # @example Basic usage (framework/test code only — prefer Runtime.instance.spawn in app code)
22
+ # task = Phronomy::Task.spawn { expensive_io() }
23
+ # result = task.await # blocks until done, re-raises errors
24
+ #
25
+ # @example Cancel a running task
26
+ # task = Phronomy::Task.spawn { loop { Phronomy::Task.checkpoint! } }
27
+ # task.cancel!
28
+ #
29
+ # @example Task tree — cancel parent cancels children
30
+ # parent = Phronomy::Task.spawn { sleep 10 }
31
+ # child = Phronomy::Task.spawn(parent: parent) { sleep 10 }
32
+ # parent.cancel! # child is also cancelled
33
+ class Task
34
+ # Valid task lifecycle states.
35
+ STATES = %i[pending running completed failed cancelled].freeze
36
+
37
+ # Returns the process-wide default backend class.
38
+ # Defaults to {ThreadBackend}.
39
+ # Override in tests or to enable a cooperative scheduler backend.
40
+ # @return [Class<Backend>]
41
+ # @api private
42
+ def self.default_backend_class
43
+ @default_backend_class || ThreadBackend
44
+ end
45
+
46
+ # Sets the process-wide default backend class.
47
+ # @param klass [Class<Backend>]
48
+ # @api private
49
+ def self.default_backend_class=(klass)
50
+ @default_backend_class = klass
51
+ end
52
+
53
+ # Returns the {Task} currently executing on this thread, or +nil+.
54
+ # Returns +nil+ when called from outside a task-managed execution context.
55
+ # @return [Task, nil]
56
+ # @api private
57
+ def self.current
58
+ Thread.current[:phronomy_current_task]
59
+ end
60
+
61
+ # Returns the monotonic clock value (ms) when the current task last recorded
62
+ # a yield (or when the task started), or +nil+ when not inside a task context.
63
+ # Used by {Runtime#yield} for CPU-bound detection without placing
64
+ # +Thread.current+ in files outside the allowlist.
65
+ # @return [Integer, nil]
66
+ # @api private
67
+ def self.current_cpu_slice_start_ms
68
+ Thread.current[:phronomy_task_cpu_slice_start_ms]
69
+ end
70
+
71
+ # Resets the CPU slice start clock for the current task to +now+.
72
+ # Call this immediately after the cooperative yield has been performed so
73
+ # that the next yield correctly measures only the time since the last yield.
74
+ # @api private
75
+ def self.record_yield!
76
+ Thread.current[:phronomy_task_cpu_slice_start_ms] =
77
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
78
+ end
79
+
80
+ # Returns and increments a per-thread yield-if-needed counter.
81
+ # Used by {Runtime#yield_if_needed} so that the counter is thread-local
82
+ # without putting +Thread.current+ in runtime.rb (which is outside the
83
+ # Thread.current allowlist).
84
+ # @return [Integer] the new counter value
85
+ # @api private
86
+ def self.increment_yield_counter!
87
+ count = (Thread.current[:phronomy_yield_if_needed_counter] || 0) + 1
88
+ Thread.current[:phronomy_yield_if_needed_counter] = count
89
+ count
90
+ end
91
+
92
+ # Cooperative cancellation checkpoint.
93
+ #
94
+ # Raises {CancellationError} if the current task's status is +:cancelled+.
95
+ # On {ThreadBackend}, cancellation is delivered via +Thread#raise+ so this
96
+ # is a no-op in practice; on future cooperative backends this will be the
97
+ # primary cancellation mechanism.
98
+ #
99
+ # Safe to call from outside a task context (no-op when no current task).
100
+ # @return [void]
101
+ # @raise [CancellationError] if the current task has been cancelled
102
+ # @api private
103
+ def self.checkpoint!
104
+ ct = current
105
+ return unless ct
106
+
107
+ raise CancellationError, "Task cancelled" if ct.status == :cancelled
108
+ end
109
+
110
+ # Spawns a new task executing +block+ concurrently.
111
+ #
112
+ # @param name [String, nil] optional human-readable label
113
+ # @param parent [Task, nil] parent task; cancelling the parent
114
+ # also cancels this task (default: currently running task)
115
+ # @param backend_class [Class<Backend>] backend to use
116
+ # @yieldreturn [Object] the task result
117
+ # @return [Task]
118
+ # @api private
119
+ def self.spawn(name: nil, parent: current, backend_class: default_backend_class, &block)
120
+ new(name: name, parent: parent, backend_class: backend_class, &block)
121
+ end
122
+
123
+ # @return [String, nil] optional human-readable label
124
+ attr_reader :name
125
+
126
+ # @return [Task, nil] parent task in the task tree, if any
127
+ attr_reader :parent
128
+
129
+ # @return [Backend] the execution backend for this task
130
+ # @api private
131
+ attr_reader :backend
132
+
133
+ # @param name [String, nil]
134
+ # @param parent [Task, nil]
135
+ # @param backend_class [Class<Backend>]
136
+ # @api private use {.spawn} instead
137
+ def initialize(name: nil, parent: nil, backend_class: self.class.default_backend_class, &block)
138
+ @name = name
139
+ @parent = parent
140
+ @status = :pending
141
+ @mutex = Mutex.new
142
+ @children = []
143
+ @on_complete_callbacks = []
144
+ @completed_value = nil
145
+ @completed_error = nil
146
+ parent&.register_child(self)
147
+ @backend = backend_class.new(task: self, &block)
148
+ end
149
+
150
+ # Returns the current lifecycle state.
151
+ # @return [Symbol] one of {STATES}
152
+ # @api private
153
+ def status
154
+ @mutex.synchronize { @status }
155
+ end
156
+
157
+ # Blocks until the task completes and returns its value.
158
+ # Re-raises any exception raised inside the block.
159
+ #
160
+ # @return [Object] the result produced by the block
161
+ # @raise [Exception] if the block raised an error
162
+ # @api private
163
+ def await
164
+ @backend.await
165
+ end
166
+
167
+ # Registers a callback to be invoked when the task reaches a terminal state
168
+ # (+:completed+, +:failed+, or +:cancelled+).
169
+ #
170
+ # The callback receives two arguments: +value+ (the task's return value,
171
+ # or +nil+) and +error+ (the exception, or +nil+). These are provided
172
+ # directly so the callback does not need to call +task.await+, which would
173
+ # risk a self-join error when the callback runs inside the task's own thread.
174
+ #
175
+ # If the task is already done when this method is called, the callback is
176
+ # invoked immediately (synchronously, on the calling thread).
177
+ #
178
+ # @yield [value, error] called when the task finishes
179
+ # @return [self]
180
+ # @api private
181
+ def on_complete(&callback)
182
+ fire_now = false
183
+ fire_args = nil
184
+ @mutex.synchronize do
185
+ # Check @status directly to avoid re-entering the mutex (done? calls
186
+ # status, which also takes @mutex).
187
+ if %i[completed failed cancelled].include?(@status)
188
+ fire_now = true
189
+ fire_args = [@completed_value, @completed_error]
190
+ else
191
+ @on_complete_callbacks << callback
192
+ end
193
+ end
194
+ callback.call(*fire_args) if fire_now
195
+ self
196
+ end
197
+
198
+ # Returns +true+ once the task has finished (success, error, or cancellation).
199
+ # @return [Boolean]
200
+ # @api private
201
+ def done?
202
+ %i[completed failed cancelled].include?(status)
203
+ end
204
+
205
+ # Requests cancellation. Propagates to all registered child tasks.
206
+ # Sets status to :cancelled immediately so that even tasks that have not
207
+ # started executing yet are correctly marked as cancelled after join.
208
+ # Passes a CancellationError to on_complete callbacks so callers do not
209
+ # need to call await to discover the error.
210
+ # @return [self]
211
+ # @api private
212
+ def cancel!
213
+ transition!(:cancelled, error: CancellationError.new("Task cancelled"))
214
+ # @backend may be nil if cancel! is called while ImmediateBackend is still
215
+ # initializing (the block runs synchronously inside .new, so register_child
216
+ # fires before @backend is assigned). Safe-navigate to avoid NoMethodError.
217
+ @backend&.cancel!
218
+ children = @mutex.synchronize { @children.dup }
219
+ children.each(&:cancel!)
220
+ self
221
+ end
222
+
223
+ # Joins the underlying execution context, optionally with a timeout.
224
+ # Returns +nil+ when the timeout expires before completion.
225
+ #
226
+ # @param limit [Numeric, nil] seconds to wait; nil waits indefinitely
227
+ # @return [Object, nil]
228
+ # @api private
229
+ def join(limit = nil)
230
+ @backend.join(limit)
231
+ end
232
+
233
+ # Returns +true+ while the task's block is still executing.
234
+ # @return [Boolean]
235
+ # @api private
236
+ def alive?
237
+ @backend.alive?
238
+ end
239
+
240
+ # Updates the task lifecycle state.
241
+ # Called by backends during execution transitions.
242
+ # Terminal states (completed/failed/cancelled) are never overwritten.
243
+ # When a terminal state is reached, fires on_complete callbacks (outside
244
+ # the mutex) passing the result value and error directly.
245
+ #
246
+ # @param new_status [Symbol]
247
+ # @param value [Object, nil] task return value (terminal states only)
248
+ # @param error [Exception, nil] exception raised by the block, if any
249
+ # @api private
250
+ def transition!(new_status, value: nil, error: nil)
251
+ callbacks = nil
252
+ @mutex.synchronize do
253
+ # Check @status directly (not via #done?) to avoid re-entering the mutex.
254
+ return if %i[completed failed cancelled].include?(@status)
255
+
256
+ @status = new_status
257
+ if %i[completed failed cancelled].include?(new_status)
258
+ @completed_value = value
259
+ @completed_error = error
260
+ callbacks = @on_complete_callbacks.dup
261
+ @on_complete_callbacks.clear
262
+ end
263
+ end
264
+ callbacks&.each { |cb| cb.call(value, error) }
265
+ end
266
+
267
+ # Registers +child+ as a child task for cancellation propagation.
268
+ # Called automatically during child task initialization.
269
+ # @param child [Task]
270
+ # @api private
271
+ def register_child(child)
272
+ @mutex.synchronize { @children << child }
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,265 @@
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