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.
- checksums.yaml +4 -4
- data/.mutant.yml +8 -9
- data/CHANGELOG.md +54 -0
- data/CONTRIBUTING.md +28 -16
- data/README.md +124 -92
- data/benchmark/baseline.json +2 -3
- data/benchmark/bench_agent_invoke.rb +4 -4
- data/benchmark/bench_context_assembler.rb +134 -34
- data/benchmark/bench_regression.rb +1 -1
- data/benchmark/bench_tool_schema.rb +2 -35
- data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
- data/docs/decisions/010-cooperative-first-concurrency.md +7 -0
- data/docs/decisions/011-build-context-as-single-llm-input-authority.md +2 -2
- data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
- data/lib/phronomy/agent/agent_invocation.rb +2 -36
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +156 -93
- data/lib/phronomy/agent/agent_root.rb +1 -2
- data/lib/phronomy/agent/base.rb +135 -314
- data/lib/phronomy/agent/context/capability/base.rb +166 -297
- data/lib/phronomy/agent/context_assembler.rb +65 -29
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
- data/lib/phronomy/agent/context_plan_validator.rb +0 -33
- data/lib/phronomy/agent/execution_coordinator.rb +0 -1
- data/lib/phronomy/agent/journal_projection.rb +28 -2
- data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
- data/lib/phronomy/agent/shared_state.rb +46 -138
- data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
- data/lib/phronomy/agent/tool_invocation.rb +108 -314
- data/lib/phronomy/agent.rb +6 -10
- data/lib/phronomy/configuration.rb +15 -158
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
- data/lib/phronomy/engine/runtime.rb +15 -230
- data/lib/phronomy/engine/task_group.rb +30 -102
- data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
- data/lib/phronomy/multi_agent/orchestrator.rb +152 -204
- data/lib/phronomy/multi_agent/team_coordinator.rb +42 -133
- data/lib/phronomy/vector_store/in_memory.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +3 -120
- data/scripts/api_snapshot.rb +1 -12
- metadata +3 -9
- data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
- data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
- data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
- data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
- data/lib/phronomy/knowledge_source.rb +0 -12
- data/lib/phronomy/llm_context_window/assembler.rb +0 -191
- 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 :
|
|
115
|
-
|
|
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
|
-
|
|
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 =>
|
|
173
|
+
rescue => error
|
|
310
174
|
@metrics.record_end(type, :failed, run_start)
|
|
311
|
-
raise
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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 =>
|
|
545
|
-
error ||=
|
|
329
|
+
rescue => caught
|
|
330
|
+
error ||= caught
|
|
546
331
|
ensure
|
|
547
332
|
begin
|
|
548
333
|
@timer_service.shutdown
|
|
549
|
-
rescue =>
|
|
550
|
-
error ||=
|
|
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 { |
|
|
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
|
|
32
|
-
# @param failure_policy [Symbol]
|
|
33
|
-
# @param runtime
|
|
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
|
|
39
|
-
|
|
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 =
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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 { |
|
|
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 { |
|
|
83
|
+
entries.map { |entry| entry[:value] }
|
|
137
84
|
when :skip_failed
|
|
138
|
-
entries.filter_map { |
|
|
139
|
-
else
|
|
140
|
-
errors = entries.filter_map { |
|
|
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 { |
|
|
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 { |
|
|
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 { |
|
|
119
|
+
entries.map { |entry| entry[:value] }
|
|
180
120
|
when :skip_failed
|
|
181
|
-
entries.filter_map { |
|
|
182
|
-
else
|
|
183
|
-
errors = entries.filter_map { |
|
|
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 { |
|
|
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 |
|
|
205
|
-
|
|
135
|
+
tasks.each do |task|
|
|
136
|
+
task.join
|
|
206
137
|
rescue
|
|
207
138
|
nil
|
|
208
139
|
end
|
|
209
|
-
|
|
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 }
|