phronomy 0.13.0 → 0.14.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/CHANGELOG.md +90 -0
- data/README.md +51 -2
- data/docs/mcp-client.md +75 -0
- data/gemfiles/mcp_1_0.gemfile +9 -0
- data/lib/phronomy/agent/base.rb +17 -17
- data/lib/phronomy/agent/context/capability/base.rb +6 -0
- data/lib/phronomy/agent/invocation_session.rb +10 -4
- data/lib/phronomy/agent/phase_machine_builder.rb +5 -4
- data/lib/phronomy/configuration.rb +15 -46
- data/lib/phronomy/diagnostics.rb +1 -1
- data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +230 -118
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +5 -1
- data/lib/phronomy/engine/concurrency/pool_registry.rb +8 -3
- data/lib/phronomy/engine/event_loop.rb +319 -272
- data/lib/phronomy/engine/fsm_session.rb +17 -14
- data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +1 -1
- data/lib/phronomy/engine/runtime/shutdown_result.rb +62 -0
- data/lib/phronomy/engine/runtime/task_registry.rb +62 -15
- data/lib/phronomy/engine/runtime.rb +247 -57
- data/lib/phronomy/metrics.rb +4 -3
- data/lib/phronomy/testing/scheduler_helpers.rb +12 -3
- data/lib/phronomy/tools/mcp.rb +385 -81
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/phase_machine_builder.rb +13 -9
- data/lib/phronomy/workflow_context.rb +1 -2
- data/lib/phronomy/workflow_runner.rb +22 -12
- data/lib/phronomy.rb +24 -19
- metadata +47 -6
- data/lib/phronomy/engine/concurrency/concurrency_gate.rb +0 -157
- data/lib/phronomy/engine/concurrency/gate_registry.rb +0 -51
|
@@ -32,7 +32,8 @@ module Phronomy
|
|
|
32
32
|
# @api private
|
|
33
33
|
def initialize(id:, context:, entry_point:, entry_actions:, auto_state_set:,
|
|
34
34
|
declared_states:, wait_state_names:, external_events:, phase_machine_class:,
|
|
35
|
-
recursion_limit:, action_timeouts: {},
|
|
35
|
+
recursion_limit:, event_loop:, timer_queue_provider:, action_timeouts: {},
|
|
36
|
+
resume_event: nil, resume_phase: nil)
|
|
36
37
|
@id = id
|
|
37
38
|
@ctx = context
|
|
38
39
|
@entry_point = entry_point
|
|
@@ -44,6 +45,8 @@ module Phronomy
|
|
|
44
45
|
@phase_machine_class = phase_machine_class
|
|
45
46
|
@recursion_limit = recursion_limit
|
|
46
47
|
@action_timeouts = action_timeouts
|
|
48
|
+
@event_loop = event_loop
|
|
49
|
+
@timer_queue_provider = timer_queue_provider
|
|
47
50
|
@resume_event = resume_event
|
|
48
51
|
@resume_phase = resume_phase
|
|
49
52
|
@step = 0
|
|
@@ -79,10 +82,10 @@ module Phronomy
|
|
|
79
82
|
current_state_name = @current_state
|
|
80
83
|
timeout_secs = @action_timeouts[current_state_name]
|
|
81
84
|
if timeout_secs
|
|
82
|
-
|
|
85
|
+
@timer_queue_provider.call.schedule(seconds: timeout_secs) do
|
|
83
86
|
next if result.done?
|
|
84
87
|
|
|
85
|
-
event_loop.post(
|
|
88
|
+
@event_loop.post(
|
|
86
89
|
Event.new(
|
|
87
90
|
type: :error,
|
|
88
91
|
target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID,
|
|
@@ -95,13 +98,13 @@ module Phronomy
|
|
|
95
98
|
end
|
|
96
99
|
result.on_complete do |task_result, error|
|
|
97
100
|
if error
|
|
98
|
-
event_loop.post(Event.new(type: :error, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: session_id, result: error}))
|
|
101
|
+
@event_loop.post(Event.new(type: :error, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: session_id, result: error}))
|
|
99
102
|
next
|
|
100
103
|
end
|
|
101
104
|
if _fsm_context?(task_result)
|
|
102
|
-
event_loop.post(Event.new(type: :action_completed, target_id: session_id, payload: task_result))
|
|
105
|
+
@event_loop.post(Event.new(type: :action_completed, target_id: session_id, payload: task_result))
|
|
103
106
|
else
|
|
104
|
-
event_loop.post(Event.new(type: :state_completed, target_id: session_id, payload: nil))
|
|
107
|
+
@event_loop.post(Event.new(type: :state_completed, target_id: session_id, payload: nil))
|
|
105
108
|
end
|
|
106
109
|
end
|
|
107
110
|
break # Only one async action at a time per state
|
|
@@ -179,7 +182,7 @@ module Phronomy
|
|
|
179
182
|
end
|
|
180
183
|
|
|
181
184
|
if @auto_state_set.key?(@current_state)
|
|
182
|
-
event_loop.post(Event.new(type: :state_completed, target_id: @id, payload: nil))
|
|
185
|
+
@event_loop.post(Event.new(type: :state_completed, target_id: @id, payload: nil))
|
|
183
186
|
return
|
|
184
187
|
end
|
|
185
188
|
|
|
@@ -200,18 +203,18 @@ module Phronomy
|
|
|
200
203
|
def finish!
|
|
201
204
|
@done = true
|
|
202
205
|
@ctx.set_graph_metadata(thread_id: @id, phase: :__end__)
|
|
203
|
-
event_loop.post(Event.new(type: :finished, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: @id, result: @ctx}))
|
|
206
|
+
@event_loop.post(Event.new(type: :finished, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: @id, result: @ctx}))
|
|
204
207
|
end
|
|
205
208
|
|
|
206
209
|
def halt!
|
|
207
210
|
@done = true
|
|
208
211
|
@ctx.set_graph_metadata(thread_id: @id, phase: @current_state)
|
|
209
|
-
event_loop.post(Event.new(type: :halted, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: @id, result: @ctx}))
|
|
212
|
+
@event_loop.post(Event.new(type: :halted, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: @id, result: @ctx}))
|
|
210
213
|
end
|
|
211
214
|
|
|
212
215
|
def finish_with_error(err)
|
|
213
216
|
@done = true
|
|
214
|
-
event_loop.post(Event.new(type: :error, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: @id, result: err}))
|
|
217
|
+
@event_loop.post(Event.new(type: :error, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: @id, result: err}))
|
|
215
218
|
end
|
|
216
219
|
|
|
217
220
|
def fire_event!(tracker, event_name, from_state)
|
|
@@ -229,13 +232,13 @@ module Phronomy
|
|
|
229
232
|
def build_tracker(from_state)
|
|
230
233
|
machine = @phase_machine_class.new
|
|
231
234
|
machine.instance_variable_set(:@phase, from_state.to_s)
|
|
235
|
+
machine.event_loop = @event_loop if machine.respond_to?(:event_loop=)
|
|
236
|
+
if machine.respond_to?(:timer_queue_provider=)
|
|
237
|
+
machine.timer_queue_provider = @timer_queue_provider
|
|
238
|
+
end
|
|
232
239
|
machine
|
|
233
240
|
end
|
|
234
241
|
|
|
235
|
-
def event_loop
|
|
236
|
-
Phronomy::EventLoop.instance
|
|
237
|
-
end
|
|
238
|
-
|
|
239
242
|
# Returns true when +obj+ is an FSM execution context (responds to
|
|
240
243
|
# +set_graph_metadata+). Used to distinguish context objects from other
|
|
241
244
|
# Task return values (strings, hashes, etc.) without hard-coding a specific
|
|
@@ -38,7 +38,7 @@ module Phronomy
|
|
|
38
38
|
class DeterministicScheduler < Scheduler
|
|
39
39
|
# Scheduler-aware signal for cooperative suspension.
|
|
40
40
|
#
|
|
41
|
-
# Used by {
|
|
41
|
+
# Used by {TaskGroup} to suspend a Fiber until a
|
|
42
42
|
# slot or condition becomes available, without blocking the OS thread.
|
|
43
43
|
# All methods must be called from within a {DeterministicScheduler} tick.
|
|
44
44
|
# @api private
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
class Runtime
|
|
5
|
+
# Immutable result returned by {Runtime#shutdown}.
|
|
6
|
+
#
|
|
7
|
+
# +runtime_outcome+ records whether execution remained healthy. It is
|
|
8
|
+
# intentionally independent from +cleanup_status+: a Runtime may fail
|
|
9
|
+
# during execution but still release every owned resource successfully.
|
|
10
|
+
# @api private
|
|
11
|
+
class ShutdownResult
|
|
12
|
+
attr_reader :runtime_outcome,
|
|
13
|
+
:cleanup_status,
|
|
14
|
+
:event_loop_status,
|
|
15
|
+
:task_registry_status,
|
|
16
|
+
:error
|
|
17
|
+
|
|
18
|
+
def self.not_started
|
|
19
|
+
new(
|
|
20
|
+
runtime_outcome: :terminated,
|
|
21
|
+
cleanup_status: :complete,
|
|
22
|
+
event_loop_status: :not_started,
|
|
23
|
+
task_registry_status: :empty
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(
|
|
28
|
+
runtime_outcome:,
|
|
29
|
+
cleanup_status:,
|
|
30
|
+
event_loop_status:,
|
|
31
|
+
task_registry_status:,
|
|
32
|
+
error: nil
|
|
33
|
+
)
|
|
34
|
+
@runtime_outcome = runtime_outcome
|
|
35
|
+
@cleanup_status = cleanup_status
|
|
36
|
+
@event_loop_status = event_loop_status
|
|
37
|
+
@task_registry_status = task_registry_status
|
|
38
|
+
@error = error
|
|
39
|
+
freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns true when every Runtime-owned resource is known to have stopped.
|
|
43
|
+
def cleanup_complete?
|
|
44
|
+
@cleanup_status == :complete
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Returns true only for a graceful, error-free shutdown.
|
|
48
|
+
# A cancellation may release every resource, but is not considered clean.
|
|
49
|
+
def clean?
|
|
50
|
+
@runtime_outcome == :terminated &&
|
|
51
|
+
@cleanup_status == :complete &&
|
|
52
|
+
%i[not_started terminated].include?(@event_loop_status) &&
|
|
53
|
+
@task_registry_status == :empty
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Runtime reset is safe when cleanup completed, even if execution failed.
|
|
57
|
+
def success?
|
|
58
|
+
cleanup_complete?
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -5,10 +5,8 @@ module Phronomy
|
|
|
5
5
|
# Internal registry of active {Task} instances for a {Runtime}.
|
|
6
6
|
#
|
|
7
7
|
# Tracks every task that has been spawned but not yet completed so that
|
|
8
|
-
# {Runtime#shutdown} can drain them.
|
|
9
|
-
#
|
|
10
|
-
# before the caller returns from {Runtime#spawn}, so they are never added
|
|
11
|
-
# to the registry in the first place.
|
|
8
|
+
# {Runtime#shutdown} can drain them. Tasks that complete synchronously
|
|
9
|
+
# deregister themselves before the caller returns from {Runtime#spawn}.
|
|
12
10
|
# @api private
|
|
13
11
|
class TaskRegistry
|
|
14
12
|
def initialize
|
|
@@ -16,33 +14,82 @@ module Phronomy
|
|
|
16
14
|
@tasks = []
|
|
17
15
|
end
|
|
18
16
|
|
|
19
|
-
# Adds +task+
|
|
20
|
-
# @param task [Task]
|
|
21
|
-
# @return [void]
|
|
17
|
+
# Adds +task+ unless it already completed synchronously.
|
|
22
18
|
# @api private
|
|
23
19
|
def register(task)
|
|
24
20
|
@mutex.synchronize { @tasks << task unless task.done? }
|
|
25
21
|
end
|
|
26
22
|
|
|
27
|
-
# Removes +task+ from the registry
|
|
28
|
-
# @param task [Task]
|
|
29
|
-
# @return [void]
|
|
23
|
+
# Removes +task+ from the registry.
|
|
30
24
|
# @api private
|
|
31
25
|
def deregister(task)
|
|
32
26
|
@mutex.synchronize { @tasks.delete(task) }
|
|
33
27
|
end
|
|
34
28
|
|
|
35
|
-
# Waits for
|
|
36
|
-
#
|
|
29
|
+
# Waits for registered tasks until the absolute monotonic +deadline+.
|
|
30
|
+
# The registry is re-snapshotted because tasks may create follow-up tasks
|
|
31
|
+
# while Runtime shutdown is draining accepted work.
|
|
32
|
+
#
|
|
33
|
+
# @param deadline [Numeric] absolute Process::CLOCK_MONOTONIC value
|
|
34
|
+
# @return [Symbol] +:empty+ or +:timeout+
|
|
35
|
+
# @api private
|
|
36
|
+
def drain_until(deadline)
|
|
37
|
+
loop do
|
|
38
|
+
tasks = snapshot
|
|
39
|
+
return :empty if tasks.empty?
|
|
40
|
+
|
|
41
|
+
tasks.each do |task|
|
|
42
|
+
remaining = deadline - monotonic_now
|
|
43
|
+
return :timeout if remaining <= 0
|
|
44
|
+
|
|
45
|
+
begin
|
|
46
|
+
task.join(remaining)
|
|
47
|
+
rescue
|
|
48
|
+
# Some backends re-raise the task error from join. The task's
|
|
49
|
+
# ensure block still deregisters it, so continue the drain.
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
return :timeout if task.alive? && monotonic_now >= deadline
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
return :empty if empty?
|
|
57
|
+
return :timeout if monotonic_now >= deadline
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Compatibility helper for callers that explicitly require an unbounded
|
|
62
|
+
# drain. Runtime#shutdown uses {#drain_until}.
|
|
37
63
|
# @api private
|
|
38
64
|
def drain
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
t.join
|
|
65
|
+
snapshot.each do |task|
|
|
66
|
+
task.join
|
|
42
67
|
rescue
|
|
43
68
|
nil
|
|
44
69
|
end
|
|
45
70
|
end
|
|
71
|
+
|
|
72
|
+
# @return [Boolean]
|
|
73
|
+
# @api private
|
|
74
|
+
def empty?
|
|
75
|
+
@mutex.synchronize { @tasks.empty? }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [Integer]
|
|
79
|
+
# @api private
|
|
80
|
+
def size
|
|
81
|
+
@mutex.synchronize { @tasks.size }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def snapshot
|
|
87
|
+
@mutex.synchronize { @tasks.dup }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def monotonic_now
|
|
91
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
92
|
+
end
|
|
46
93
|
end
|
|
47
94
|
end
|
|
48
95
|
end
|
|
@@ -8,6 +8,7 @@ require_relative "runtime/timer_queue"
|
|
|
8
8
|
require_relative "runtime/scheduler_timer_adapter"
|
|
9
9
|
require_relative "runtime/task_registry"
|
|
10
10
|
require_relative "runtime/runtime_metrics"
|
|
11
|
+
require_relative "runtime/shutdown_result"
|
|
11
12
|
require_relative "runtime/timer_service"
|
|
12
13
|
|
|
13
14
|
module Phronomy
|
|
@@ -47,8 +48,68 @@ module Phronomy
|
|
|
47
48
|
#
|
|
48
49
|
# @return [Runtime]
|
|
49
50
|
# @api private
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
@instance_mutex = Mutex.new
|
|
52
|
+
|
|
53
|
+
class << self
|
|
54
|
+
def instance
|
|
55
|
+
instance_mutex.synchronize do
|
|
56
|
+
@instance ||= build_default_runtime
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
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
|
+
def default_if_initialized_for_test
|
|
67
|
+
instance_mutex.synchronize { @instance }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Test-only replacement. The caller owns both Runtime lifecycles.
|
|
71
|
+
def replace_default_for_test(runtime)
|
|
72
|
+
instance_mutex.synchronize do
|
|
73
|
+
previous = @instance
|
|
74
|
+
@instance = runtime
|
|
75
|
+
previous
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Test-only restoration of a previously captured Runtime.
|
|
80
|
+
def restore_default_for_test(runtime)
|
|
81
|
+
instance_mutex.synchronize { @instance = runtime }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def reset_default!(timeout: Phronomy.configuration.event_loop_stop_grace_seconds)
|
|
85
|
+
runtime = instance_mutex.synchronize { @instance }
|
|
86
|
+
return ShutdownResult.not_started unless runtime
|
|
87
|
+
|
|
88
|
+
result = runtime.shutdown(timeout: timeout)
|
|
89
|
+
unless result.cleanup_complete?
|
|
90
|
+
raise Phronomy::RuntimeShutdownError,
|
|
91
|
+
"Runtime cleanup is incomplete; default Runtime was retained"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
instance_mutex.synchronize do
|
|
95
|
+
@instance = nil if @instance.equal?(runtime)
|
|
96
|
+
end
|
|
97
|
+
result
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Does not create a Runtime or EventLoop.
|
|
101
|
+
def in_event_loop_context?
|
|
102
|
+
runtime = instance_mutex.synchronize { @instance }
|
|
103
|
+
runtime&.event_loop_current? || false
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def instance_mutex
|
|
109
|
+
@instance_mutex ||= Mutex.new
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def build_default_runtime
|
|
52
113
|
scheduler = case Phronomy.configuration.runtime_backend
|
|
53
114
|
when :cooperative
|
|
54
115
|
Phronomy.configuration.logger&.warn(
|
|
@@ -74,14 +135,6 @@ module Phronomy
|
|
|
74
135
|
end
|
|
75
136
|
end
|
|
76
137
|
|
|
77
|
-
# Replaces the process-wide default Runtime. Useful in tests.
|
|
78
|
-
# @param runtime [Runtime]
|
|
79
|
-
# @return [Runtime]
|
|
80
|
-
# @api private
|
|
81
|
-
def self.instance=(runtime)
|
|
82
|
-
@instance = runtime
|
|
83
|
-
end
|
|
84
|
-
|
|
85
138
|
# Returns +true+ when the calling thread is executing inside an active
|
|
86
139
|
# scheduler task (i.e. {Task.current} is non-nil). Code running inside
|
|
87
140
|
# a {Runtime#spawn} block is always in a scheduler context.
|
|
@@ -118,38 +171,29 @@ module Phronomy
|
|
|
118
171
|
# @return [Scheduler]
|
|
119
172
|
attr_reader :scheduler
|
|
120
173
|
|
|
174
|
+
# @return [Symbol] current Runtime lifecycle state
|
|
175
|
+
# @api private
|
|
176
|
+
def state
|
|
177
|
+
@lifecycle_mutex.synchronize { @state }
|
|
178
|
+
end
|
|
179
|
+
|
|
121
180
|
# @param scheduler [Scheduler] execution backend (default: {ThreadScheduler})
|
|
122
181
|
# @api private
|
|
123
182
|
def initialize(scheduler: ThreadScheduler.new)
|
|
124
183
|
@scheduler = scheduler
|
|
184
|
+
@event_loop_scheduler = ThreadScheduler.new
|
|
125
185
|
@task_registry = TaskRegistry.new
|
|
126
186
|
@metrics = RuntimeMetrics.new
|
|
127
|
-
@gate_registry = Phronomy::Concurrency::GateRegistry.new
|
|
128
|
-
@pool_registry = Phronomy::Concurrency::PoolRegistry.new
|
|
129
187
|
@timer_service = TimerService.new(scheduler)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
# @return [ConcurrencyGate]
|
|
140
|
-
# @api private
|
|
141
|
-
def gate(name)
|
|
142
|
-
@gate_registry.get(name.to_sym)
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
# Drops the cached gate for +name+ so that the next call to {#gate} rebuilds
|
|
146
|
-
# it from the current configuration. Useful in tests.
|
|
147
|
-
#
|
|
148
|
-
# @param name [Symbol]
|
|
149
|
-
# @return [void]
|
|
150
|
-
# @api private
|
|
151
|
-
def reset_gate(name)
|
|
152
|
-
@gate_registry.reset(name.to_sym)
|
|
188
|
+
@pool_registry = Phronomy::Concurrency::PoolRegistry.new(
|
|
189
|
+
timer_queue_provider: -> { @timer_service.timer_queue }
|
|
190
|
+
)
|
|
191
|
+
@lifecycle_mutex = Mutex.new
|
|
192
|
+
@shutdown_mutex = Mutex.new
|
|
193
|
+
@state = :running
|
|
194
|
+
@event_loop = nil
|
|
195
|
+
@failure = nil
|
|
196
|
+
@shutdown_result = nil
|
|
153
197
|
end
|
|
154
198
|
|
|
155
199
|
# Cooperative yield point.
|
|
@@ -226,6 +270,7 @@ module Phronomy
|
|
|
226
270
|
# @return [TaskGroup]
|
|
227
271
|
# @api private
|
|
228
272
|
def task_group(limit: Float::INFINITY, failure_policy: :fail_fast)
|
|
273
|
+
ensure_accepting_work!
|
|
229
274
|
TaskGroup.new(limit: limit, failure_policy: failure_policy, runtime: self)
|
|
230
275
|
end
|
|
231
276
|
|
|
@@ -246,6 +291,7 @@ module Phronomy
|
|
|
246
291
|
# @return [Task]
|
|
247
292
|
# @api private
|
|
248
293
|
def spawn(name: nil, &block)
|
|
294
|
+
ensure_accepting_work!
|
|
249
295
|
type = _task_type(name)
|
|
250
296
|
spawn_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
251
297
|
@metrics.record_start(type)
|
|
@@ -302,11 +348,15 @@ module Phronomy
|
|
|
302
348
|
# constructing a Runtime with custom pool options or by replacing the
|
|
303
349
|
# shared Runtime via {.instance=} in tests.
|
|
304
350
|
#
|
|
305
|
-
# @param pool_size [Integer] worker thread count
|
|
306
|
-
#
|
|
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)
|
|
307
355
|
# @return [BlockingAdapterPool]
|
|
308
356
|
# @api private
|
|
309
|
-
def blocking_io(pool_size:
|
|
357
|
+
def blocking_io(pool_size: Phronomy.configuration.blocking_io_pool_size,
|
|
358
|
+
queue_size: Phronomy.configuration.blocking_io_queue_size)
|
|
359
|
+
ensure_accepting_work!
|
|
310
360
|
@pool_registry.default_pool(pool_size: pool_size, queue_size: queue_size)
|
|
311
361
|
end
|
|
312
362
|
|
|
@@ -326,6 +376,7 @@ module Phronomy
|
|
|
326
376
|
# @return [BlockingAdapterPool]
|
|
327
377
|
# @api private
|
|
328
378
|
def pool(name, size: 10, queue_size: 100)
|
|
379
|
+
ensure_accepting_work!
|
|
329
380
|
@pool_registry.named_pool(name, size: size, queue_size: queue_size)
|
|
330
381
|
end
|
|
331
382
|
|
|
@@ -346,33 +397,172 @@ module Phronomy
|
|
|
346
397
|
# @return [TimerQueue, SchedulerTimerAdapter]
|
|
347
398
|
# @api private
|
|
348
399
|
def timer_queue
|
|
400
|
+
ensure_accepting_work!
|
|
349
401
|
@timer_service.timer_queue
|
|
350
402
|
end
|
|
351
403
|
|
|
352
|
-
#
|
|
353
|
-
#
|
|
354
|
-
#
|
|
355
|
-
#
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
+
def event_loop
|
|
409
|
+
@lifecycle_mutex.synchronize do
|
|
410
|
+
case @state
|
|
411
|
+
when :running
|
|
412
|
+
@event_loop ||= EventLoop.new(runtime: self)
|
|
413
|
+
when :draining
|
|
414
|
+
return @event_loop if @event_loop
|
|
415
|
+
|
|
416
|
+
raise Phronomy::RuntimeShutdownError,
|
|
417
|
+
"EventLoop was not initialized before Runtime shutdown began"
|
|
418
|
+
else
|
|
419
|
+
raise Phronomy::RuntimeShutdownError,
|
|
420
|
+
"Runtime is #{@state}; EventLoop is unavailable"
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Does not create an EventLoop.
|
|
426
|
+
# @api private
|
|
427
|
+
def event_loop_current?
|
|
428
|
+
event_loop = @lifecycle_mutex.synchronize { @event_loop }
|
|
429
|
+
event_loop&.current? || false
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Internal EventLoop service spawn. Always uses a real OS thread and is
|
|
433
|
+
# deliberately excluded from the normal TaskRegistry drain.
|
|
364
434
|
# @api private
|
|
365
|
-
def
|
|
366
|
-
@
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
+
def __event_loop_failed(error)
|
|
442
|
+
@lifecycle_mutex.synchronize do
|
|
443
|
+
return if @shutdown_result || @state == :terminated
|
|
444
|
+
|
|
445
|
+
@failure ||= error
|
|
446
|
+
@state = :failed
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
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
|
+
def shutdown(
|
|
458
|
+
timeout: Phronomy.configuration.event_loop_stop_grace_seconds,
|
|
459
|
+
cancel_grace: timeout
|
|
460
|
+
)
|
|
461
|
+
if Phronomy::Task.current
|
|
462
|
+
raise Phronomy::RuntimeShutdownReentrancyError,
|
|
463
|
+
"Runtime#shutdown must be called from an external management thread"
|
|
464
|
+
end
|
|
465
|
+
validate_timeout!(timeout, :timeout)
|
|
466
|
+
validate_timeout!(cancel_grace, :cancel_grace)
|
|
467
|
+
|
|
468
|
+
@shutdown_mutex.synchronize do
|
|
469
|
+
return @shutdown_result if @shutdown_result
|
|
470
|
+
|
|
471
|
+
deadline = monotonic_now + timeout
|
|
472
|
+
event_loop = @lifecycle_mutex.synchronize do
|
|
473
|
+
@state = :draining unless @state == :failed
|
|
474
|
+
@event_loop
|
|
475
|
+
end
|
|
476
|
+
event_loop&.begin_draining
|
|
477
|
+
|
|
478
|
+
task_status = drain_runtime_work(event_loop, deadline)
|
|
479
|
+
|
|
480
|
+
@lifecycle_mutex.synchronize do
|
|
481
|
+
@state = :stopping unless @state == :failed
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
event_loop_status = if event_loop
|
|
485
|
+
event_loop.shutdown(deadline: deadline, cancel_grace: cancel_grace)
|
|
486
|
+
else
|
|
487
|
+
:not_started
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
subsystem_error = shutdown_pools_and_timer
|
|
491
|
+
final_task_status = @task_registry.empty? ? :empty : task_status
|
|
492
|
+
cleanup_complete = final_task_status == :empty &&
|
|
493
|
+
(!event_loop || !event_loop.task_alive?) &&
|
|
494
|
+
event_loop_status != :cancel_timeout &&
|
|
495
|
+
subsystem_error.nil?
|
|
496
|
+
|
|
497
|
+
failure = @lifecycle_mutex.synchronize { @failure } || subsystem_error
|
|
498
|
+
runtime_outcome = if failure || event_loop_status == :failed
|
|
499
|
+
:failed
|
|
500
|
+
else
|
|
501
|
+
:terminated
|
|
502
|
+
end
|
|
503
|
+
result = ShutdownResult.new(
|
|
504
|
+
runtime_outcome: runtime_outcome,
|
|
505
|
+
cleanup_status: cleanup_complete ? :complete : :incomplete,
|
|
506
|
+
event_loop_status: event_loop_status,
|
|
507
|
+
task_registry_status: final_task_status,
|
|
508
|
+
error: failure
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
@lifecycle_mutex.synchronize do
|
|
512
|
+
@state = cleanup_complete ? runtime_outcome : :failed
|
|
513
|
+
@shutdown_result = result
|
|
514
|
+
end
|
|
515
|
+
result
|
|
516
|
+
end
|
|
372
517
|
end
|
|
373
518
|
|
|
374
519
|
private
|
|
375
520
|
|
|
521
|
+
def ensure_accepting_work!
|
|
522
|
+
current_state = @lifecycle_mutex.synchronize { @state }
|
|
523
|
+
return if %i[running draining].include?(current_state)
|
|
524
|
+
|
|
525
|
+
raise Phronomy::RuntimeShutdownError,
|
|
526
|
+
"Runtime is #{current_state}; new work is not accepted"
|
|
527
|
+
end
|
|
528
|
+
|
|
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
|
+
def shutdown_pools_and_timer
|
|
541
|
+
error = nil
|
|
542
|
+
begin
|
|
543
|
+
@pool_registry.shutdown
|
|
544
|
+
rescue => e
|
|
545
|
+
error ||= e
|
|
546
|
+
ensure
|
|
547
|
+
begin
|
|
548
|
+
@timer_service.shutdown
|
|
549
|
+
rescue => e
|
|
550
|
+
error ||= e
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
error
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def validate_timeout!(value, name)
|
|
557
|
+
return if value.is_a?(Numeric) && value >= 0
|
|
558
|
+
|
|
559
|
+
raise ArgumentError, "#{name} must be a non-negative Numeric"
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def monotonic_now
|
|
563
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
564
|
+
end
|
|
565
|
+
|
|
376
566
|
TASK_TYPE_PREFIXES = %w[agent tool workflow rag llm vector].freeze
|
|
377
567
|
private_constant :TASK_TYPE_PREFIXES
|
|
378
568
|
|
data/lib/phronomy/metrics.rb
CHANGED
|
@@ -20,9 +20,10 @@ module Phronomy
|
|
|
20
20
|
# @return [Hash{Symbol => Numeric}]
|
|
21
21
|
# @api public
|
|
22
22
|
def self.snapshot
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
runtime = Runtime.instance
|
|
24
|
+
pool = runtime.blocking_io
|
|
25
|
+
el = runtime.event_loop
|
|
26
|
+
task_snap = runtime.task_snapshot
|
|
26
27
|
|
|
27
28
|
{
|
|
28
29
|
blocking_pool_active: pool.active_count,
|