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.
- checksums.yaml +4 -4
- data/.mutant.yml +8 -9
- data/CHANGELOG.md +151 -1134
- data/CONTRIBUTING.md +28 -16
- data/README.md +69 -1249
- 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 +26 -6
- data/benchmark/bench_tool_schema.rb +2 -35
- data/docs/changelog/0.14-and-earlier.md +1137 -0
- data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
- data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
- data/docs/decisions/010-cooperative-first-concurrency.md +155 -228
- 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/docs/features.md +87 -0
- data/docs/getting-started.md +351 -0
- data/docs/migrations/0.15.md +35 -0
- data/docs/migrations/0.16.md +43 -0
- data/docs/runtime-and-concurrency.md +258 -0
- data/examples/workflows/generic_task_event_mapping.rb +14 -6
- data/lib/phronomy/agent/agent_invocation.rb +2 -36
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +157 -94
- data/lib/phronomy/agent/agent_root.rb +1 -2
- data/lib/phronomy/agent/async_event_api.rb +3 -3
- data/lib/phronomy/agent/base.rb +170 -333
- data/lib/phronomy/agent/context/capability/base.rb +177 -298
- 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 +6 -7
- 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 +48 -138
- data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
- data/lib/phronomy/agent/tool_executor.rb +29 -71
- data/lib/phronomy/agent/tool_invocation.rb +180 -336
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
- data/lib/phronomy/agent.rb +6 -10
- data/lib/phronomy/configuration.rb +4 -171
- data/lib/phronomy/diagnostics.rb +12 -41
- data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
- data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +51 -79
- data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
- data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
- data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
- data/lib/phronomy/engine/event_loop.rb +89 -190
- data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
- data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
- data/lib/phronomy/engine/runtime.rb +49 -377
- data/lib/phronomy/engine/task.rb +136 -277
- data/lib/phronomy/llm_adapter/base.rb +14 -14
- data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
- data/lib/phronomy/llm_adapter.rb +2 -2
- data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
- data/lib/phronomy/metrics.rb +15 -30
- data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
- data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
- data/lib/phronomy/multi_agent/handoff.rb +1 -0
- data/lib/phronomy/multi_agent/orchestrator.rb +252 -256
- data/lib/phronomy/multi_agent/team_coordinator.rb +44 -133
- data/lib/phronomy/testing/eval/comparison.rb +23 -0
- data/lib/phronomy/testing/eval/dataset.rb +27 -0
- data/lib/phronomy/testing/eval/eval_case.rb +13 -0
- data/lib/phronomy/testing/eval/eval_result.rb +16 -0
- data/lib/phronomy/testing/eval/metrics.rb +43 -0
- data/lib/phronomy/testing/eval/runner.rb +52 -0
- data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
- data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
- data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
- data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
- data/lib/phronomy/testing/eval/scorer.rb +10 -0
- data/lib/phronomy/testing/eval.rb +9 -0
- data/lib/phronomy/testing/fake_clock.rb +6 -53
- data/lib/phronomy/testing.rb +2 -6
- data/lib/phronomy/tools/agent.rb +141 -6
- data/lib/phronomy/vector_store/async_backend.rb +21 -17
- data/lib/phronomy/vector_store/base.rb +2 -2
- data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
- data/lib/phronomy/vector_store/in_memory.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow_runner.rb +2 -4
- data/lib/phronomy.rb +7 -121
- data/scripts/api_snapshot.rb +4 -15
- metadata +24 -38
- 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/engine/concurrency/blocking_adapter_pool.rb +0 -561
- data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
- data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
- data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
- data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
- data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
- data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
- data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
- data/lib/phronomy/engine/task/backend.rb +0 -80
- data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
- data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
- data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
- data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
- data/lib/phronomy/engine/task/thread_backend.rb +0 -84
- data/lib/phronomy/engine/task_group.rb +0 -265
- data/lib/phronomy/eval/comparison.rb +0 -47
- data/lib/phronomy/eval/dataset.rb +0 -45
- data/lib/phronomy/eval/eval_case.rb +0 -17
- data/lib/phronomy/eval/eval_result.rb +0 -29
- data/lib/phronomy/eval/metrics.rb +0 -66
- data/lib/phronomy/eval/runner.rb +0 -94
- data/lib/phronomy/eval/scorer/base.rb +0 -22
- data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
- data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
- data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
- data/lib/phronomy/eval/scorer.rb +0 -9
- data/lib/phronomy/eval.rb +0 -7
- 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
- data/lib/phronomy/testing/fake_scheduler.rb +0 -104
- data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
|
@@ -1,73 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "runtime/scheduler"
|
|
4
|
-
require_relative "runtime/thread_scheduler"
|
|
5
|
-
require_relative "runtime/fake_scheduler"
|
|
6
|
-
require_relative "runtime/deterministic_scheduler"
|
|
7
3
|
require_relative "runtime/timer_queue"
|
|
8
|
-
require_relative "runtime/scheduler_timer_adapter"
|
|
9
|
-
require_relative "runtime/task_registry"
|
|
10
|
-
require_relative "runtime/runtime_metrics"
|
|
11
4
|
require_relative "runtime/shutdown_result"
|
|
12
5
|
require_relative "runtime/timer_service"
|
|
13
6
|
|
|
14
7
|
module Phronomy
|
|
15
|
-
#
|
|
8
|
+
# Owns the EventLoop, offloaded synchronous work, timers and shutdown lifecycle.
|
|
16
9
|
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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)
|
|
10
|
+
# Runtime no longer schedules arbitrary Tasks. Framework control flow belongs
|
|
11
|
+
# to EventLoop/FSMSession; synchronous work that must not run on the EventLoop
|
|
12
|
+
# belongs to OffloadPool.
|
|
37
13
|
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
14
|
@instance_mutex = Mutex.new
|
|
52
15
|
|
|
53
16
|
class << self
|
|
54
17
|
def instance
|
|
55
18
|
instance_mutex.synchronize do
|
|
56
|
-
@instance ||=
|
|
19
|
+
@instance ||= new
|
|
57
20
|
end
|
|
58
21
|
end
|
|
59
22
|
|
|
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
23
|
def default_if_initialized_for_test
|
|
67
24
|
instance_mutex.synchronize { @instance }
|
|
68
25
|
end
|
|
69
26
|
|
|
70
|
-
# Test-only replacement. The caller owns both Runtime lifecycles.
|
|
71
27
|
def replace_default_for_test(runtime)
|
|
72
28
|
instance_mutex.synchronize do
|
|
73
29
|
previous = @instance
|
|
@@ -76,7 +32,6 @@ module Phronomy
|
|
|
76
32
|
end
|
|
77
33
|
end
|
|
78
34
|
|
|
79
|
-
# Test-only restoration of a previously captured Runtime.
|
|
80
35
|
def restore_default_for_test(runtime)
|
|
81
36
|
instance_mutex.synchronize { @instance = runtime }
|
|
82
37
|
end
|
|
@@ -97,7 +52,6 @@ module Phronomy
|
|
|
97
52
|
result
|
|
98
53
|
end
|
|
99
54
|
|
|
100
|
-
# Does not create a Runtime or EventLoop.
|
|
101
55
|
def in_event_loop_context?
|
|
102
56
|
runtime = instance_mutex.synchronize { @instance }
|
|
103
57
|
runtime&.event_loop_current? || false
|
|
@@ -108,85 +62,12 @@ module Phronomy
|
|
|
108
62
|
def instance_mutex
|
|
109
63
|
@instance_mutex ||= Mutex.new
|
|
110
64
|
end
|
|
111
|
-
|
|
112
|
-
def build_default_runtime
|
|
113
|
-
scheduler = case Phronomy.configuration.runtime_backend
|
|
114
|
-
when :cooperative
|
|
115
|
-
Phronomy.configuration.logger&.warn(
|
|
116
|
-
"[phronomy] runtime_backend: :cooperative is a deprecated alias for :immediate. " \
|
|
117
|
-
"Use :immediate for synchronous/test execution. " \
|
|
118
|
-
":cooperative will be reassigned when a real cooperative Fiber-based scheduler is available."
|
|
119
|
-
)
|
|
120
|
-
FakeScheduler.new
|
|
121
|
-
when :immediate
|
|
122
|
-
FakeScheduler.new
|
|
123
|
-
when :fiber
|
|
124
|
-
Phronomy.configuration.logger&.warn(
|
|
125
|
-
"[phronomy] runtime_backend: :fiber uses DeterministicScheduler in autorun mode. " \
|
|
126
|
-
"This is an EXPERIMENTAL Fiber-based cooperative scheduler. " \
|
|
127
|
-
"Wall-clock timer integration is available via SchedulerTimerAdapter (Issues #331, #337). " \
|
|
128
|
-
"Not recommended for production use."
|
|
129
|
-
)
|
|
130
|
-
DeterministicScheduler.new(autorun: true)
|
|
131
|
-
else
|
|
132
|
-
ThreadScheduler.new
|
|
133
|
-
end
|
|
134
|
-
new(scheduler: scheduler)
|
|
135
|
-
end
|
|
136
|
-
end
|
|
137
|
-
|
|
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
|
-
def self.in_scheduler_context?
|
|
150
|
-
!Task.current.nil?
|
|
151
|
-
end
|
|
152
|
-
|
|
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
|
-
def self.measure_ms
|
|
164
|
-
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
165
|
-
result = yield
|
|
166
|
-
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round
|
|
167
|
-
[result, elapsed_ms]
|
|
168
65
|
end
|
|
169
66
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
attr_reader :scheduler
|
|
173
|
-
|
|
174
|
-
# @return [Symbol] current Runtime lifecycle state
|
|
175
|
-
# @api private
|
|
176
|
-
def state
|
|
177
|
-
@lifecycle_mutex.synchronize { @state }
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
# @param scheduler [Scheduler] execution backend (default: {ThreadScheduler})
|
|
181
|
-
# @api private
|
|
182
|
-
def initialize(scheduler: ThreadScheduler.new)
|
|
183
|
-
@scheduler = scheduler
|
|
184
|
-
@event_loop_scheduler = ThreadScheduler.new
|
|
185
|
-
@task_registry = TaskRegistry.new
|
|
186
|
-
@metrics = RuntimeMetrics.new
|
|
187
|
-
@timer_service = TimerService.new(scheduler)
|
|
67
|
+
def initialize
|
|
68
|
+
@timer_service = TimerService.new
|
|
188
69
|
@pool_registry = Phronomy::Concurrency::PoolRegistry.new(
|
|
189
|
-
timer_queue_provider: -> {
|
|
70
|
+
timer_queue_provider: -> { timer_queue }
|
|
190
71
|
)
|
|
191
72
|
@lifecycle_mutex = Mutex.new
|
|
192
73
|
@shutdown_mutex = Mutex.new
|
|
@@ -196,220 +77,45 @@ module Phronomy
|
|
|
196
77
|
@shutdown_result = nil
|
|
197
78
|
end
|
|
198
79
|
|
|
199
|
-
|
|
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
|
-
def yield
|
|
217
|
-
if (threshold = Phronomy.configuration.blocking_detect_threshold_ms)
|
|
218
|
-
slice_start = Task.current_cpu_slice_start_ms
|
|
219
|
-
if slice_start
|
|
220
|
-
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - slice_start
|
|
221
|
-
if elapsed > threshold
|
|
222
|
-
name = Task.current&.name || "unknown"
|
|
223
|
-
Phronomy.configuration.logger&.warn(
|
|
224
|
-
"[Phronomy] CPU-bound task detected: '#{name}' ran #{elapsed.round}ms " \
|
|
225
|
-
"without yielding (threshold: #{threshold}ms)"
|
|
226
|
-
)
|
|
227
|
-
@metrics.increment_starvation
|
|
228
|
-
end
|
|
229
|
-
end
|
|
230
|
-
end
|
|
231
|
-
Task.record_yield!
|
|
232
|
-
@scheduler.yield
|
|
233
|
-
end
|
|
234
|
-
|
|
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
|
-
def non_yield_threshold_violation_count
|
|
241
|
-
@metrics.starvation_count
|
|
242
|
-
end
|
|
243
|
-
|
|
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
|
-
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
|
-
self.yield if (Task.increment_yield_counter! % every).zero?
|
|
264
|
-
end
|
|
265
|
-
|
|
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
|
-
def task_group(limit: Float::INFINITY, failure_policy: :fail_fast)
|
|
273
|
-
ensure_accepting_work!
|
|
274
|
-
TaskGroup.new(limit: limit, failure_policy: failure_policy, runtime: self)
|
|
275
|
-
end
|
|
276
|
-
|
|
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
|
-
def spawn(name: nil, &block)
|
|
294
|
-
ensure_accepting_work!
|
|
295
|
-
type = _task_type(name)
|
|
296
|
-
spawn_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
297
|
-
@metrics.record_start(type)
|
|
298
|
-
|
|
299
|
-
task = @scheduler.spawn(name: name, parent: Task.current) do
|
|
300
|
-
run_start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
301
|
-
@metrics.record_wait(run_start - spawn_at)
|
|
302
|
-
begin
|
|
303
|
-
result = block.call
|
|
304
|
-
@metrics.record_end(type, :completed, run_start)
|
|
305
|
-
result
|
|
306
|
-
rescue CancellationError
|
|
307
|
-
@metrics.record_end(type, :cancelled, run_start)
|
|
308
|
-
raise
|
|
309
|
-
rescue => e
|
|
310
|
-
@metrics.record_end(type, :failed, run_start)
|
|
311
|
-
raise e
|
|
312
|
-
ensure
|
|
313
|
-
current = Task.current
|
|
314
|
-
@task_registry.deregister(current) if current
|
|
315
|
-
end
|
|
316
|
-
end
|
|
317
|
-
@task_registry.register(task)
|
|
318
|
-
task
|
|
319
|
-
end
|
|
320
|
-
|
|
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
|
-
def task_snapshot
|
|
340
|
-
@metrics.snapshot
|
|
80
|
+
def state
|
|
81
|
+
@lifecycle_mutex.synchronize { @state }
|
|
341
82
|
end
|
|
342
83
|
|
|
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)
|
|
84
|
+
def offload(
|
|
85
|
+
pool_size: Phronomy.configuration.offload_pool_size,
|
|
86
|
+
queue_size: Phronomy.configuration.offload_queue_size
|
|
87
|
+
)
|
|
359
88
|
ensure_accepting_work!
|
|
360
89
|
@pool_registry.default_pool(pool_size: pool_size, queue_size: queue_size)
|
|
361
90
|
end
|
|
362
91
|
|
|
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
92
|
def pool(name, size: 10, queue_size: 100)
|
|
379
93
|
ensure_accepting_work!
|
|
380
94
|
@pool_registry.named_pool(name, size: size, queue_size: queue_size)
|
|
381
95
|
end
|
|
382
96
|
|
|
383
|
-
#
|
|
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
|
|
97
|
+
# Public timer access also ensures that the EventLoop that drives timers is alive.
|
|
399
98
|
def timer_queue
|
|
400
99
|
ensure_accepting_work!
|
|
100
|
+
timer = @timer_service.timer_queue
|
|
101
|
+
event_loop
|
|
102
|
+
timer
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Internal EventLoop access that does not recursively initialise EventLoop.
|
|
106
|
+
def __timer_queue
|
|
401
107
|
@timer_service.timer_queue
|
|
402
108
|
end
|
|
403
109
|
|
|
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
110
|
def event_loop
|
|
409
111
|
@lifecycle_mutex.synchronize do
|
|
410
112
|
case @state
|
|
411
113
|
when :running
|
|
412
|
-
@event_loop
|
|
114
|
+
unless @event_loop
|
|
115
|
+
@event_loop = EventLoop.new(runtime: self)
|
|
116
|
+
@timer_service.wake_with { @event_loop&.wake }
|
|
117
|
+
end
|
|
118
|
+
@event_loop
|
|
413
119
|
when :draining
|
|
414
120
|
return @event_loop if @event_loop
|
|
415
121
|
|
|
@@ -422,22 +128,11 @@ module Phronomy
|
|
|
422
128
|
end
|
|
423
129
|
end
|
|
424
130
|
|
|
425
|
-
# Does not create an EventLoop.
|
|
426
|
-
# @api private
|
|
427
131
|
def event_loop_current?
|
|
428
|
-
|
|
429
|
-
|
|
132
|
+
loop_instance = @lifecycle_mutex.synchronize { @event_loop }
|
|
133
|
+
loop_instance&.current? || false
|
|
430
134
|
end
|
|
431
135
|
|
|
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
|
-
def __spawn_event_loop_service(&block)
|
|
436
|
-
@event_loop_scheduler.spawn(name: "event-loop", parent: nil, &block)
|
|
437
|
-
end
|
|
438
|
-
|
|
439
|
-
# Called only for an unexpected dispatcher failure.
|
|
440
|
-
# @api private
|
|
441
136
|
def __event_loop_failed(error)
|
|
442
137
|
@lifecycle_mutex.synchronize do
|
|
443
138
|
return if @shutdown_result || @state == :terminated
|
|
@@ -447,18 +142,11 @@ module Phronomy
|
|
|
447
142
|
end
|
|
448
143
|
end
|
|
449
144
|
|
|
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
145
|
def shutdown(
|
|
458
146
|
timeout: Phronomy.configuration.event_loop_stop_grace_seconds,
|
|
459
147
|
cancel_grace: timeout
|
|
460
148
|
)
|
|
461
|
-
if
|
|
149
|
+
if event_loop_current?
|
|
462
150
|
raise Phronomy::RuntimeShutdownReentrancyError,
|
|
463
151
|
"Runtime#shutdown must be called from an external management thread"
|
|
464
152
|
end
|
|
@@ -468,29 +156,33 @@ module Phronomy
|
|
|
468
156
|
@shutdown_mutex.synchronize do
|
|
469
157
|
return @shutdown_result if @shutdown_result
|
|
470
158
|
|
|
471
|
-
|
|
472
|
-
|
|
159
|
+
# Phase 1 — drain sessions with the full configured grace.
|
|
160
|
+
drain_deadline = monotonic_now + timeout
|
|
161
|
+
loop_instance = @lifecycle_mutex.synchronize do
|
|
473
162
|
@state = :draining unless @state == :failed
|
|
474
163
|
@event_loop
|
|
475
164
|
end
|
|
476
|
-
|
|
165
|
+
loop_instance&.begin_draining
|
|
477
166
|
|
|
478
|
-
|
|
167
|
+
loop_idle = !loop_instance || loop_instance.wait_until_idle(drain_deadline)
|
|
479
168
|
|
|
480
169
|
@lifecycle_mutex.synchronize do
|
|
481
170
|
@state = :stopping unless @state == :failed
|
|
482
171
|
end
|
|
483
172
|
|
|
484
|
-
|
|
485
|
-
|
|
173
|
+
# Phase 2 — stop the EventLoop thread with a short independent budget.
|
|
174
|
+
# An idle EventLoop processes STOP and exits in < 1ms normally; the 0.2s
|
|
175
|
+
# budget here is only a safety net for OS scheduling jitter.
|
|
176
|
+
stop_deadline = monotonic_now + [cancel_grace.to_f, 0.2].max
|
|
177
|
+
event_loop_status = if loop_instance
|
|
178
|
+
loop_instance.stop_and_join(deadline: stop_deadline)
|
|
486
179
|
else
|
|
487
180
|
:not_started
|
|
488
181
|
end
|
|
489
182
|
|
|
490
183
|
subsystem_error = shutdown_pools_and_timer
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
(!event_loop || !event_loop.task_alive?) &&
|
|
184
|
+
cleanup_complete = loop_idle &&
|
|
185
|
+
(!loop_instance || !loop_instance.thread_alive?) &&
|
|
494
186
|
event_loop_status != :cancel_timeout &&
|
|
495
187
|
subsystem_error.nil?
|
|
496
188
|
|
|
@@ -500,11 +192,12 @@ module Phronomy
|
|
|
500
192
|
else
|
|
501
193
|
:terminated
|
|
502
194
|
end
|
|
195
|
+
|
|
503
196
|
result = ShutdownResult.new(
|
|
504
197
|
runtime_outcome: runtime_outcome,
|
|
505
198
|
cleanup_status: cleanup_complete ? :complete : :incomplete,
|
|
506
199
|
event_loop_status: event_loop_status,
|
|
507
|
-
task_registry_status:
|
|
200
|
+
task_registry_status: :empty,
|
|
508
201
|
error: failure
|
|
509
202
|
)
|
|
510
203
|
|
|
@@ -526,28 +219,17 @@ module Phronomy
|
|
|
526
219
|
"Runtime is #{current_state}; new work is not accepted"
|
|
527
220
|
end
|
|
528
221
|
|
|
529
|
-
def drain_runtime_work(event_loop, deadline)
|
|
530
|
-
loop do
|
|
531
|
-
task_status = @task_registry.drain_until(deadline)
|
|
532
|
-
return :timeout if task_status == :timeout
|
|
533
|
-
|
|
534
|
-
event_loop_idle = !event_loop || event_loop.wait_until_idle(deadline)
|
|
535
|
-
return :timeout unless event_loop_idle
|
|
536
|
-
return :empty if @task_registry.empty?
|
|
537
|
-
end
|
|
538
|
-
end
|
|
539
|
-
|
|
540
222
|
def shutdown_pools_and_timer
|
|
541
223
|
error = nil
|
|
542
224
|
begin
|
|
543
225
|
@pool_registry.shutdown
|
|
544
|
-
rescue =>
|
|
545
|
-
error ||=
|
|
226
|
+
rescue => caught
|
|
227
|
+
error ||= caught
|
|
546
228
|
ensure
|
|
547
229
|
begin
|
|
548
230
|
@timer_service.shutdown
|
|
549
|
-
rescue =>
|
|
550
|
-
error ||=
|
|
231
|
+
rescue => caught
|
|
232
|
+
error ||= caught
|
|
551
233
|
end
|
|
552
234
|
end
|
|
553
235
|
error
|
|
@@ -562,15 +244,5 @@ module Phronomy
|
|
|
562
244
|
def monotonic_now
|
|
563
245
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
564
246
|
end
|
|
565
|
-
|
|
566
|
-
TASK_TYPE_PREFIXES = %w[agent tool workflow rag llm vector].freeze
|
|
567
|
-
private_constant :TASK_TYPE_PREFIXES
|
|
568
|
-
|
|
569
|
-
def _task_type(name)
|
|
570
|
-
return :other if name.nil?
|
|
571
|
-
|
|
572
|
-
prefix = TASK_TYPE_PREFIXES.find { |p| name.to_s.start_with?("#{p}-") }
|
|
573
|
-
prefix ? prefix.to_sym : :other
|
|
574
|
-
end
|
|
575
247
|
end
|
|
576
248
|
end
|