phronomy 0.7.0 → 0.7.1
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 -7
- data/CHANGELOG.md +151 -1
- data/README.md +155 -32
- data/Rakefile +33 -0
- data/benchmark/baseline.json +1 -1
- data/benchmark/bench_regression.rb +1 -0
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
- data/docs/decisions/006-no-built-in-guardrails.md +20 -2
- data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
- data/lib/phronomy/agent/base.rb +250 -65
- data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
- data/lib/phronomy/agent/fsm.rb +41 -64
- data/lib/phronomy/agent/orchestrator.rb +146 -121
- data/lib/phronomy/agent/parallel_tool_chat.rb +79 -22
- data/lib/phronomy/agent/react_agent.rb +8 -0
- data/lib/phronomy/async_queue.rb +155 -0
- data/lib/phronomy/blocking_adapter_pool.rb +435 -0
- data/lib/phronomy/cancellation_scope.rb +123 -0
- data/lib/phronomy/cancellation_token.rb +43 -2
- data/lib/phronomy/concurrency_gate.rb +155 -0
- data/lib/phronomy/configuration.rb +142 -0
- data/lib/phronomy/deadline.rb +63 -0
- data/lib/phronomy/diagnostics.rb +62 -0
- data/lib/phronomy/embeddings/base.rb +17 -0
- data/lib/phronomy/eval/runner.rb +9 -9
- data/lib/phronomy/event_loop.rb +181 -43
- data/lib/phronomy/fsm_session.rb +50 -4
- data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
- data/lib/phronomy/invocation_context.rb +152 -0
- data/lib/phronomy/knowledge_source/base.rb +18 -0
- data/lib/phronomy/llm_adapter/base.rb +104 -0
- data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
- data/lib/phronomy/llm_adapter.rb +20 -0
- data/lib/phronomy/metrics.rb +38 -0
- data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
- data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
- data/lib/phronomy/runtime/gate_registry.rb +52 -0
- data/lib/phronomy/runtime/pool_registry.rb +57 -0
- data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
- data/lib/phronomy/runtime/scheduler.rb +98 -0
- data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
- data/lib/phronomy/runtime/task_registry.rb +48 -0
- data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
- data/lib/phronomy/runtime/timer_queue.rb +106 -0
- data/lib/phronomy/runtime/timer_service.rb +42 -0
- data/lib/phronomy/runtime.rb +374 -0
- data/lib/phronomy/task/backend.rb +80 -0
- data/lib/phronomy/task/fiber_backend.rb +157 -0
- data/lib/phronomy/task/immediate_backend.rb +89 -0
- data/lib/phronomy/task/thread_backend.rb +84 -0
- data/lib/phronomy/task.rb +275 -0
- data/lib/phronomy/task_group.rb +265 -0
- data/lib/phronomy/testing/fake_clock.rb +109 -0
- data/lib/phronomy/testing/fake_scheduler.rb +104 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
- data/lib/phronomy/testing.rb +12 -0
- data/lib/phronomy/tool/base.rb +110 -2
- data/lib/phronomy/tool/mcp_tool.rb +47 -16
- data/lib/phronomy/tool/scope_policy.rb +50 -0
- data/lib/phronomy/tool_executor.rb +106 -0
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
- data/lib/phronomy/vector_store/async_backend.rb +110 -0
- data/lib/phronomy/vector_store/base.rb +7 -0
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +52 -5
- data/lib/phronomy/workflow_context.rb +29 -2
- data/lib/phronomy/workflow_runner.rb +74 -3
- data/lib/phronomy.rb +42 -0
- metadata +40 -2
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
class Runtime
|
|
5
|
+
# A thread-safe timer queue backed by a single background thread.
|
|
6
|
+
#
|
|
7
|
+
# Replaces the pattern of spawning one +Thread.new { sleep(t); callback }+
|
|
8
|
+
# per deadline. Any number of timers share a single background thread that
|
|
9
|
+
# sleeps until the earliest pending deadline.
|
|
10
|
+
#
|
|
11
|
+
# Use {#schedule} to register a one-shot callback; call {#shutdown} when the
|
|
12
|
+
# queue is no longer needed (e.g. on process exit) to stop the background
|
|
13
|
+
# thread cleanly.
|
|
14
|
+
class TimerQueue
|
|
15
|
+
# @param clock [#call] zero-argument callable that returns the current
|
|
16
|
+
# monotonic time in seconds (defaults to +Process::CLOCK_MONOTONIC+).
|
|
17
|
+
# Override in tests to inject a fake clock.
|
|
18
|
+
# @api private
|
|
19
|
+
def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
20
|
+
@clock = clock
|
|
21
|
+
@heap = [] # [[fire_at, callback], ...]
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
@cond = ConditionVariable.new
|
|
24
|
+
@stopped = false
|
|
25
|
+
@thread = Thread.new { run_loop }
|
|
26
|
+
@thread.name = "phronomy-timer-queue"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Schedule a one-shot callback to fire after +seconds+ from now.
|
|
30
|
+
#
|
|
31
|
+
# @param seconds [Numeric] delay before the callback fires
|
|
32
|
+
# @yield called (in the timer thread) when the deadline is reached
|
|
33
|
+
# @return [self]
|
|
34
|
+
# @api private
|
|
35
|
+
def schedule(seconds:, &callback)
|
|
36
|
+
fire_at = @clock.call + seconds.to_f
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
raise Phronomy::PoolShutdownError, "TimerQueue has been shut down" if @stopped
|
|
39
|
+
insert_sorted(fire_at, callback)
|
|
40
|
+
@cond.signal
|
|
41
|
+
end
|
|
42
|
+
self
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Stop the background thread. Pending (un-fired) callbacks are discarded.
|
|
46
|
+
#
|
|
47
|
+
# @return [self]
|
|
48
|
+
# @api private
|
|
49
|
+
def shutdown
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
@stopped = true
|
|
52
|
+
@cond.signal
|
|
53
|
+
end
|
|
54
|
+
@thread.join
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Number of pending (not yet fired) callbacks. Primarily for testing.
|
|
59
|
+
# @return [Integer]
|
|
60
|
+
# @api private
|
|
61
|
+
def pending_count
|
|
62
|
+
@mutex.synchronize { @heap.size }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def insert_sorted(fire_at, callback)
|
|
68
|
+
@heap << [fire_at, callback]
|
|
69
|
+
@heap.sort_by! { |(t, _)| t }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def run_loop
|
|
73
|
+
loop do
|
|
74
|
+
callback = next_callback
|
|
75
|
+
break if callback == :stopped
|
|
76
|
+
begin
|
|
77
|
+
callback&.call
|
|
78
|
+
rescue => e
|
|
79
|
+
Phronomy.configuration.logger&.error { "[TimerQueue] callback raised #{e.class}: #{e.message}" }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def next_callback
|
|
85
|
+
@mutex.synchronize do
|
|
86
|
+
loop do
|
|
87
|
+
return :stopped if @stopped
|
|
88
|
+
|
|
89
|
+
if @heap.empty?
|
|
90
|
+
@cond.wait(@mutex)
|
|
91
|
+
else
|
|
92
|
+
now = @clock.call
|
|
93
|
+
fire_at, = @heap.first
|
|
94
|
+
if fire_at <= now
|
|
95
|
+
return @heap.shift[1]
|
|
96
|
+
else
|
|
97
|
+
remaining = fire_at - now
|
|
98
|
+
@cond.wait(@mutex, remaining)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
class Runtime
|
|
5
|
+
# Lazy-initialised timer service for a {Runtime} instance.
|
|
6
|
+
#
|
|
7
|
+
# Returns a {SchedulerTimerAdapter} when the backing scheduler is a
|
|
8
|
+
# {DeterministicScheduler} (enabling virtual-time integration for the
|
|
9
|
+
# `:fiber` backend), or a standard {TimerQueue} (OS-thread backed) for all
|
|
10
|
+
# other schedulers.
|
|
11
|
+
# @api private
|
|
12
|
+
class TimerService
|
|
13
|
+
# @param scheduler [Scheduler]
|
|
14
|
+
# @api private
|
|
15
|
+
def initialize(scheduler)
|
|
16
|
+
@scheduler = scheduler
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
@timer = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Returns (or lazily creates) the timer queue for this runtime.
|
|
22
|
+
# @return [TimerQueue, SchedulerTimerAdapter]
|
|
23
|
+
# @api private
|
|
24
|
+
def timer_queue
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
@timer ||= if @scheduler.is_a?(DeterministicScheduler)
|
|
27
|
+
SchedulerTimerAdapter.new(@scheduler)
|
|
28
|
+
else
|
|
29
|
+
TimerQueue.new
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Shuts down the timer queue if it was started.
|
|
35
|
+
# @return [void]
|
|
36
|
+
# @api private
|
|
37
|
+
def shutdown
|
|
38
|
+
@mutex.synchronize { @timer&.shutdown }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
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
|
+
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
|
+
require_relative "runtime/gate_registry"
|
|
12
|
+
require_relative "runtime/pool_registry"
|
|
13
|
+
require_relative "runtime/timer_service"
|
|
14
|
+
|
|
15
|
+
module Phronomy
|
|
16
|
+
# Central authority for concurrent primitives.
|
|
17
|
+
#
|
|
18
|
+
# +Runtime+ is the single place that creates {Task}s, {TaskGroup}s, and
|
|
19
|
+
# manages the lifecycle of all concurrency in Phronomy. It owns:
|
|
20
|
+
#
|
|
21
|
+
# * a pluggable {Scheduler} (default: {ThreadScheduler})
|
|
22
|
+
# * a task registry for graceful shutdown
|
|
23
|
+
# * the shared {BlockingAdapterPool}
|
|
24
|
+
#
|
|
25
|
+
# In production, use the process-wide singleton via {.instance}.
|
|
26
|
+
# In tests, construct a Runtime with a {FakeScheduler} to run tasks
|
|
27
|
+
# synchronously without spawning additional threads:
|
|
28
|
+
#
|
|
29
|
+
# @example Production usage
|
|
30
|
+
# group = Phronomy::Runtime.instance.task_group(limit: 4)
|
|
31
|
+
# tools.each { |t| group.spawn { t.call } }
|
|
32
|
+
# results = group.await_all
|
|
33
|
+
#
|
|
34
|
+
# @example Test usage — no extra threads
|
|
35
|
+
# runtime = Phronomy::Runtime.new(scheduler: Phronomy::Runtime::FakeScheduler.new)
|
|
36
|
+
# task = runtime.spawn { 42 }
|
|
37
|
+
# expect(task.await).to eq(42)
|
|
38
|
+
class Runtime
|
|
39
|
+
# Returns the process-wide default Runtime.
|
|
40
|
+
#
|
|
41
|
+
# Auto-creates an instance using the scheduler backend specified by
|
|
42
|
+
# +Phronomy.configuration.runtime_backend+:
|
|
43
|
+
# - +:thread+ (default) — {ThreadScheduler} (one OS thread per task)
|
|
44
|
+
# - +:immediate+ — {FakeScheduler} (synchronous, no extra threads)
|
|
45
|
+
# - +:fiber+ — {DeterministicScheduler} in autorun mode (EXPERIMENTAL;
|
|
46
|
+
# Fiber-based synchronous execution; not yet suitable for production
|
|
47
|
+
# because it uses virtual time rather than real wall-clock timers)
|
|
48
|
+
# - +:cooperative+ — deprecated alias for +:immediate+
|
|
49
|
+
#
|
|
50
|
+
# @return [Runtime]
|
|
51
|
+
# @api private
|
|
52
|
+
def self.instance
|
|
53
|
+
@instance ||= begin
|
|
54
|
+
scheduler = case Phronomy.configuration.runtime_backend
|
|
55
|
+
when :cooperative
|
|
56
|
+
Phronomy.configuration.logger&.warn(
|
|
57
|
+
"[phronomy] runtime_backend: :cooperative is a deprecated alias for :immediate. " \
|
|
58
|
+
"Use :immediate for synchronous/test execution. " \
|
|
59
|
+
":cooperative will be reassigned when a real cooperative Fiber-based scheduler is available."
|
|
60
|
+
)
|
|
61
|
+
FakeScheduler.new
|
|
62
|
+
when :immediate
|
|
63
|
+
FakeScheduler.new
|
|
64
|
+
when :fiber
|
|
65
|
+
Phronomy.configuration.logger&.warn(
|
|
66
|
+
"[phronomy] runtime_backend: :fiber uses DeterministicScheduler in autorun mode. " \
|
|
67
|
+
"This is an EXPERIMENTAL Fiber-based cooperative scheduler. " \
|
|
68
|
+
"Wall-clock timer integration is available via SchedulerTimerAdapter (Issues #331, #337). " \
|
|
69
|
+
"Not recommended for production use."
|
|
70
|
+
)
|
|
71
|
+
DeterministicScheduler.new(autorun: true)
|
|
72
|
+
else
|
|
73
|
+
ThreadScheduler.new
|
|
74
|
+
end
|
|
75
|
+
new(scheduler: scheduler)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Replaces the process-wide default Runtime. Useful in tests.
|
|
80
|
+
# @param runtime [Runtime]
|
|
81
|
+
# @return [Runtime]
|
|
82
|
+
# @api private
|
|
83
|
+
def self.instance=(runtime)
|
|
84
|
+
@instance = runtime
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Returns +true+ when the calling thread is executing inside an active
|
|
88
|
+
# scheduler task (i.e. {Task.current} is non-nil). Code running inside
|
|
89
|
+
# a {Runtime#spawn} block is always in a scheduler context.
|
|
90
|
+
#
|
|
91
|
+
# Use this to detect potential scheduler-blocking calls:
|
|
92
|
+
# if Phronomy::Runtime.in_scheduler_context?
|
|
93
|
+
# Phronomy.configuration.logger&.warn("blocking call inside scheduler task")
|
|
94
|
+
# end
|
|
95
|
+
#
|
|
96
|
+
# @return [Boolean]
|
|
97
|
+
# @api private
|
|
98
|
+
def self.in_scheduler_context?
|
|
99
|
+
!Task.current.nil?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The scheduler backing this runtime instance.
|
|
103
|
+
# @return [Scheduler]
|
|
104
|
+
attr_reader :scheduler
|
|
105
|
+
|
|
106
|
+
# @param scheduler [Scheduler] execution backend (default: {ThreadScheduler})
|
|
107
|
+
# @api private
|
|
108
|
+
def initialize(scheduler: ThreadScheduler.new)
|
|
109
|
+
@scheduler = scheduler
|
|
110
|
+
@task_registry = TaskRegistry.new
|
|
111
|
+
@metrics = RuntimeMetrics.new
|
|
112
|
+
@gate_registry = GateRegistry.new
|
|
113
|
+
@pool_registry = PoolRegistry.new
|
|
114
|
+
@timer_service = TimerService.new(scheduler)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Returns (or lazily creates) the {ConcurrencyGate} for the named resource.
|
|
118
|
+
#
|
|
119
|
+
# Gate caps are read from the global {Phronomy::Configuration} when the gate
|
|
120
|
+
# is first accessed; subsequent calls return the cached gate. To change the
|
|
121
|
+
# cap at runtime, call {#reset_gate} first.
|
|
122
|
+
#
|
|
123
|
+
# @param name [:agent, :tool, :workflow, :llm, :rag, :vector] resource name
|
|
124
|
+
# @return [ConcurrencyGate]
|
|
125
|
+
# @api private
|
|
126
|
+
def gate(name)
|
|
127
|
+
@gate_registry.get(name.to_sym)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Drops the cached gate for +name+ so that the next call to {#gate} rebuilds
|
|
131
|
+
# it from the current configuration. Useful in tests.
|
|
132
|
+
#
|
|
133
|
+
# @param name [Symbol]
|
|
134
|
+
# @return [void]
|
|
135
|
+
# @api private
|
|
136
|
+
def reset_gate(name)
|
|
137
|
+
@gate_registry.reset(name.to_sym)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Cooperative yield point.
|
|
141
|
+
#
|
|
142
|
+
# Signals the scheduler that the current task is willing to give up CPU time
|
|
143
|
+
# so that other ready tasks can run. On the default {ThreadScheduler} this
|
|
144
|
+
# calls +Thread.pass+. On a future fiber-based scheduler this would switch
|
|
145
|
+
# to the next runnable fiber.
|
|
146
|
+
#
|
|
147
|
+
# When +blocking_detect_threshold_ms+ is configured, checks whether the
|
|
148
|
+
# current task has exceeded that threshold without yielding; if so, emits a
|
|
149
|
+
# warning via the configured logger and increments
|
|
150
|
+
# +non_yield_threshold_violation_count+.
|
|
151
|
+
#
|
|
152
|
+
# Call this inside tight loops or CPU-intensive sections of tool +execute+
|
|
153
|
+
# methods and Workflow actions to keep the scheduler responsive.
|
|
154
|
+
#
|
|
155
|
+
# @return [void]
|
|
156
|
+
# @api private
|
|
157
|
+
def yield
|
|
158
|
+
if (threshold = Phronomy.configuration.blocking_detect_threshold_ms)
|
|
159
|
+
slice_start = Task.current_cpu_slice_start_ms
|
|
160
|
+
if slice_start
|
|
161
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - slice_start
|
|
162
|
+
if elapsed > threshold
|
|
163
|
+
name = Task.current&.name || "unknown"
|
|
164
|
+
Phronomy.configuration.logger&.warn(
|
|
165
|
+
"[Phronomy] CPU-bound task detected: '#{name}' ran #{elapsed.round}ms " \
|
|
166
|
+
"without yielding (threshold: #{threshold}ms)"
|
|
167
|
+
)
|
|
168
|
+
@metrics.increment_starvation
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
Task.record_yield!
|
|
173
|
+
@scheduler.yield
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Number of times a task has exceeded the CPU-bound detection threshold
|
|
177
|
+
# (i.e. ran longer than +blocking_detect_threshold_ms+ without yielding).
|
|
178
|
+
# Resets to 0 when the Runtime is recreated.
|
|
179
|
+
# @return [Integer]
|
|
180
|
+
# @api private
|
|
181
|
+
def non_yield_threshold_violation_count
|
|
182
|
+
@metrics.starvation_count
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Cooperative yield point with a call-count gate.
|
|
186
|
+
#
|
|
187
|
+
# Increments a per-thread counter and calls {#yield} when the counter
|
|
188
|
+
# reaches a multiple of +every+. The counter is thread-local so concurrent
|
|
189
|
+
# tasks each maintain their own independent loop counter without requiring
|
|
190
|
+
# a mutex.
|
|
191
|
+
#
|
|
192
|
+
# @example
|
|
193
|
+
# data.each_with_index do |row, i|
|
|
194
|
+
# process(row)
|
|
195
|
+
# Phronomy::Runtime.instance.yield_if_needed(every: 500)
|
|
196
|
+
# end
|
|
197
|
+
#
|
|
198
|
+
# @param every [Integer] yield once every N calls (default: 1000)
|
|
199
|
+
# @return [void]
|
|
200
|
+
# @api private
|
|
201
|
+
def yield_if_needed(every: 1000)
|
|
202
|
+
# Delegate Thread.current access to Task so that runtime.rb stays outside
|
|
203
|
+
# the Thread.current allowlist (Issue #302).
|
|
204
|
+
self.yield if (Task.increment_yield_counter! % every).zero?
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Creates a new {TaskGroup} with an optional concurrency cap.
|
|
208
|
+
#
|
|
209
|
+
# @param limit [Integer, Float::INFINITY] max simultaneous tasks
|
|
210
|
+
# @param failure_policy [Symbol] one of :fail_fast, :collect_all, :skip_failed (default :fail_fast)
|
|
211
|
+
# @return [TaskGroup]
|
|
212
|
+
# @api private
|
|
213
|
+
def task_group(limit: Float::INFINITY, failure_policy: :fail_fast)
|
|
214
|
+
TaskGroup.new(limit: limit, failure_policy: failure_policy, runtime: self)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Spawns a single {Task} using the runtime's scheduler.
|
|
218
|
+
#
|
|
219
|
+
# The spawned task is registered in the task registry so {#shutdown}
|
|
220
|
+
# can wait for it to complete. The task is automatically deregistered
|
|
221
|
+
# from the registry when it finishes (success, failure, or cancellation)
|
|
222
|
+
# so long-lived runtimes do not accumulate stale references.
|
|
223
|
+
#
|
|
224
|
+
# Task names beginning with a recognised type prefix are counted in the
|
|
225
|
+
# task-centric metrics returned by {#task_snapshot}. Recognised prefixes:
|
|
226
|
+
# +agent-+, +tool-+, +workflow-+, +rag-+, +llm-+, +vector-+.
|
|
227
|
+
#
|
|
228
|
+
# @param name [String, nil] optional label for debugging
|
|
229
|
+
# @yield block to execute (concurrently or synchronously, depending on
|
|
230
|
+
# the configured scheduler)
|
|
231
|
+
# @return [Task]
|
|
232
|
+
# @api private
|
|
233
|
+
def spawn(name: nil, &block)
|
|
234
|
+
type = _task_type(name)
|
|
235
|
+
spawn_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
236
|
+
@metrics.record_start(type)
|
|
237
|
+
|
|
238
|
+
task = @scheduler.spawn(name: name, parent: Task.current) do
|
|
239
|
+
run_start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
240
|
+
@metrics.record_wait(run_start - spawn_at)
|
|
241
|
+
begin
|
|
242
|
+
result = block.call
|
|
243
|
+
@metrics.record_end(type, :completed, run_start)
|
|
244
|
+
result
|
|
245
|
+
rescue CancellationError
|
|
246
|
+
@metrics.record_end(type, :cancelled, run_start)
|
|
247
|
+
raise
|
|
248
|
+
rescue => e
|
|
249
|
+
@metrics.record_end(type, :failed, run_start)
|
|
250
|
+
raise e
|
|
251
|
+
ensure
|
|
252
|
+
current = Task.current
|
|
253
|
+
@task_registry.deregister(current) if current
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
@task_registry.register(task)
|
|
257
|
+
task
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Returns a snapshot of task-centric metrics for the current Runtime.
|
|
261
|
+
#
|
|
262
|
+
# | Key | Description |
|
|
263
|
+
# |-----|-------------|
|
|
264
|
+
# | `active_agent_tasks` | currently running agent spawns |
|
|
265
|
+
# | `active_tool_tasks` | currently running tool spawns |
|
|
266
|
+
# | `active_workflow_tasks` | currently running workflow spawns |
|
|
267
|
+
# | `active_rag_tasks` | currently running RAG fetches |
|
|
268
|
+
# | `active_llm_tasks` | currently running LLM calls |
|
|
269
|
+
# | `task_wait_time_p50_ms` | p50 spawn-to-start latency (ms) |
|
|
270
|
+
# | `task_wait_time_p95_ms` | p95 spawn-to-start latency (ms) |
|
|
271
|
+
# | `task_run_time_p50_ms` | p50 execution duration (ms) |
|
|
272
|
+
# | `task_run_time_p95_ms` | p95 execution duration (ms) |
|
|
273
|
+
# | `cancelled_tasks` | total cancelled task count |
|
|
274
|
+
# | `failed_tasks` | total failed task count |
|
|
275
|
+
# | `non_yield_threshold_violation_count` | cumulative count of tasks that ran past `blocking_detect_threshold_ms` without yielding |
|
|
276
|
+
#
|
|
277
|
+
# @return [Hash{Symbol => Numeric}]
|
|
278
|
+
# @api private
|
|
279
|
+
def task_snapshot
|
|
280
|
+
@metrics.snapshot
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Returns the shared {BlockingAdapterPool} for this Runtime.
|
|
284
|
+
# All blocking I/O (LLM HTTP, MCP, ActiveRecord, Redis) should be
|
|
285
|
+
# submitted through this pool.
|
|
286
|
+
#
|
|
287
|
+
# Pool settings default to 10 workers / 100-deep queue. Override by
|
|
288
|
+
# constructing a Runtime with custom pool options or by replacing the
|
|
289
|
+
# shared Runtime via {.instance=} in tests.
|
|
290
|
+
#
|
|
291
|
+
# @param pool_size [Integer] worker thread count (default: 10)
|
|
292
|
+
# @param queue_size [Integer] max pending operations (default: 100)
|
|
293
|
+
# @return [BlockingAdapterPool]
|
|
294
|
+
# @api private
|
|
295
|
+
def blocking_io(pool_size: 10, queue_size: 100)
|
|
296
|
+
@pool_registry.default_pool(pool_size: pool_size, queue_size: queue_size)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Returns (or lazily creates) a named {BlockingAdapterPool}.
|
|
300
|
+
#
|
|
301
|
+
# Named pools allow per-subsystem thread-budget control and observability.
|
|
302
|
+
# Recommended pool names: +:llm+, +:mcp+, +:db+, +:redis+, +:tool+.
|
|
303
|
+
# Each pool gets its own dedicated worker threads labelled with the pool name.
|
|
304
|
+
#
|
|
305
|
+
# @example
|
|
306
|
+
# runtime.pool(:llm) # default size (10 workers)
|
|
307
|
+
# runtime.pool(:db, size: 20) # custom size
|
|
308
|
+
#
|
|
309
|
+
# @param name [Symbol, String] pool identifier
|
|
310
|
+
# @param size [Integer] worker thread count (default: 10)
|
|
311
|
+
# @param queue_size [Integer] max pending operations (default: 100)
|
|
312
|
+
# @return [BlockingAdapterPool]
|
|
313
|
+
# @api private
|
|
314
|
+
def pool(name, size: 10, queue_size: 100)
|
|
315
|
+
@pool_registry.named_pool(name, size: size, queue_size: queue_size)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Returns the shared timer queue for this Runtime.
|
|
319
|
+
#
|
|
320
|
+
# When the scheduler is a {DeterministicScheduler} (e.g. the +:fiber+
|
|
321
|
+
# runtime backend), returns a {SchedulerTimerAdapter} that integrates with
|
|
322
|
+
# the scheduler's tick cycle instead of spawning a background OS thread.
|
|
323
|
+
# This is the first concrete step of the TimerQueue scheduler-tick integration
|
|
324
|
+
# described in ADR-010 (Issue #331).
|
|
325
|
+
#
|
|
326
|
+
# For all other schedulers, returns a {TimerQueue} backed by a single
|
|
327
|
+
# background thread.
|
|
328
|
+
#
|
|
329
|
+
# All deadline-based cancellation should be registered here instead of
|
|
330
|
+
# spawning one-off sleep threads. Lazily created on first access.
|
|
331
|
+
#
|
|
332
|
+
# @return [TimerQueue, SchedulerTimerAdapter]
|
|
333
|
+
# @api private
|
|
334
|
+
def timer_queue
|
|
335
|
+
@timer_service.timer_queue
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Waits for all registered tasks to finish, then shuts down the
|
|
339
|
+
# EventLoop (if active), blocking adapter pool, named pools, and timer queue
|
|
340
|
+
# (if they were started).
|
|
341
|
+
#
|
|
342
|
+
# When EventLoop mode is enabled, all pending Workflow and Agent FSM events
|
|
343
|
+
# are drained before pools are shut down, ensuring in-flight sessions
|
|
344
|
+
# complete cleanly.
|
|
345
|
+
#
|
|
346
|
+
# Call this before process exit to avoid leaving orphaned threads or
|
|
347
|
+
# pending work items.
|
|
348
|
+
#
|
|
349
|
+
# @return [void]
|
|
350
|
+
# @api private
|
|
351
|
+
def shutdown
|
|
352
|
+
@task_registry.drain
|
|
353
|
+
# Drain EventLoop events before stopping pools so that in-flight
|
|
354
|
+
# Workflow / Agent FSM sessions can complete their final LLM calls.
|
|
355
|
+
if Phronomy.configuration.event_loop
|
|
356
|
+
Phronomy::EventLoop.instance.stop(drain: true)
|
|
357
|
+
end
|
|
358
|
+
@pool_registry.shutdown
|
|
359
|
+
@timer_service.shutdown
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
private
|
|
363
|
+
|
|
364
|
+
TASK_TYPE_PREFIXES = %w[agent tool workflow rag llm vector].freeze
|
|
365
|
+
private_constant :TASK_TYPE_PREFIXES
|
|
366
|
+
|
|
367
|
+
def _task_type(name)
|
|
368
|
+
return :other if name.nil?
|
|
369
|
+
|
|
370
|
+
prefix = TASK_TYPE_PREFIXES.find { |p| name.to_s.start_with?("#{p}-") }
|
|
371
|
+
prefix ? prefix.to_sym : :other
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
class Task
|
|
5
|
+
# Abstract base class for Task execution backends.
|
|
6
|
+
#
|
|
7
|
+
# A backend encapsulates the execution primitive (Thread, Fiber, etc.) and
|
|
8
|
+
# the lifecycle transitions it drives. Concrete backends must implement all
|
|
9
|
+
# abstract methods. The default concrete implementation is {ThreadBackend}.
|
|
10
|
+
#
|
|
11
|
+
# Backends receive a reference to the owning {Task} so they can call
|
|
12
|
+
# {Task#transition!} at the appropriate lifecycle points.
|
|
13
|
+
class Backend
|
|
14
|
+
# @param task [Task] the owning Task (used for status callbacks)
|
|
15
|
+
# @param block [Proc] the work to execute
|
|
16
|
+
# @api private
|
|
17
|
+
def initialize(task:, &block)
|
|
18
|
+
@task = task
|
|
19
|
+
@block = block
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Blocks until the task completes and returns its value.
|
|
23
|
+
# Re-raises errors from the block.
|
|
24
|
+
# @return [Object]
|
|
25
|
+
# @raise [Exception]
|
|
26
|
+
# @api private
|
|
27
|
+
def await
|
|
28
|
+
raise NotImplementedError, "#{self.class}#await not implemented"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns +true+ while execution is still ongoing.
|
|
32
|
+
# @return [Boolean]
|
|
33
|
+
# @api private
|
|
34
|
+
def alive?
|
|
35
|
+
raise NotImplementedError, "#{self.class}#alive? not implemented"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Requests cancellation.
|
|
39
|
+
# Thread-based backends may use +Thread#raise+; cooperative backends
|
|
40
|
+
# should mark the task cancelled and rely on {Task.checkpoint!}.
|
|
41
|
+
# @return [self]
|
|
42
|
+
# @api private
|
|
43
|
+
def cancel!
|
|
44
|
+
raise NotImplementedError, "#{self.class}#cancel! not implemented"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Joins the execution context with an optional timeout.
|
|
48
|
+
# Returns +nil+ when a non-nil +limit+ expires before completion,
|
|
49
|
+
# matching +Thread#join+ semantics.
|
|
50
|
+
# @param limit [Numeric, nil]
|
|
51
|
+
# @return [Object, nil]
|
|
52
|
+
# @api private
|
|
53
|
+
def join(limit = nil)
|
|
54
|
+
raise NotImplementedError, "#{self.class}#join not implemented"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Returns the task's result value once it has reached a terminal state.
|
|
58
|
+
# Only valid to call after the task is done.
|
|
59
|
+
# Subclasses should override if they store the result.
|
|
60
|
+
# @return [Object, nil]
|
|
61
|
+
# @api private
|
|
62
|
+
def completed_value
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Returns the exception raised by the task, or +nil+ on success/cancellation.
|
|
67
|
+
# Only valid to call after the task is done.
|
|
68
|
+
# Subclasses should override if they store errors.
|
|
69
|
+
# @return [Exception, nil]
|
|
70
|
+
# @api private
|
|
71
|
+
def completed_error
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
attr_reader :task, :block
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|