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
|
@@ -1,119 +1,57 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
|
-
#
|
|
4
|
+
# Runtime-owned event loop that manages all FSMSession instances.
|
|
5
5
|
#
|
|
6
|
-
# A
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# +BlockingAdapterPool+, then post results back to the loop via
|
|
10
|
-
# {EventLoop#post}.
|
|
6
|
+
# A dedicated real thread reads from a Runtime-local AsyncQueue and dispatches
|
|
7
|
+
# events to their target FSMSession. The EventLoop is created at most once by
|
|
8
|
+
# its owning Runtime and is never restarted after shutdown.
|
|
11
9
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# == Threading exception (see ADR-010 Rule 2)
|
|
15
|
-
#
|
|
16
|
-
# +EventLoop+ is a **deliberate exception** to Phronomy's cooperative-first
|
|
17
|
-
# concurrency model. Its dispatch loop is an infinite +while @running+ loop
|
|
18
|
-
# that must never block the framework's own event processing.
|
|
19
|
-
# Running it on a shared scheduler task would consume the scheduler, preventing
|
|
20
|
-
# other tasks from running. Therefore {#start} creates a dedicated
|
|
21
|
-
# {Runtime::ThreadScheduler} — this is correct and intentional per ADR-010.
|
|
22
|
-
# No other framework component should do the same; see the ADR-010 checklist.
|
|
23
|
-
#
|
|
24
|
-
# == Handler constraints
|
|
25
|
-
#
|
|
26
|
-
# Handlers dispatched by the EventLoop run **on the EventLoop thread**.
|
|
27
|
-
# They must not:
|
|
28
|
-
#
|
|
29
|
-
# * Perform blocking operations directly (database queries, LLM calls, HTTP
|
|
30
|
-
# requests). Schedule blocking work via +Runtime.instance.spawn+ or
|
|
31
|
-
# +BlockingAdapterPool+, then post results back with {#post}.
|
|
32
|
-
# * Call +Workflow#invoke+ (or any synchronous +invoke+) from within a
|
|
33
|
-
# handler. That method would block waiting for the EventLoop to process
|
|
34
|
-
# events, causing a deadlock. Use the async pattern: post a follow-up
|
|
35
|
-
# event instead.
|
|
36
|
-
#
|
|
37
|
-
# == Fork safety
|
|
38
|
-
#
|
|
39
|
-
# +EventLoop.instance+ is lazily initialized. The background thread is not
|
|
40
|
-
# created until the first call, so Puma worker forking does not duplicate the
|
|
41
|
-
# thread. No +after_fork+ hook is required.
|
|
42
|
-
#
|
|
43
|
-
# == Deadlock warning
|
|
44
|
-
#
|
|
45
|
-
# Do NOT call +Workflow#invoke+ (in EventLoop mode) from within a workflow
|
|
46
|
-
# entry action. The entry action runs on the EventLoop thread; a nested
|
|
47
|
-
# +invoke+ would block waiting for the same thread to process events →
|
|
48
|
-
# deadlock. Use the async pattern instead: schedule work via
|
|
49
|
-
# +Runtime.instance.spawn+ or +BlockingAdapterPool+, then post events back
|
|
50
|
-
# via +Phronomy::EventLoop.instance.post(...)+.
|
|
10
|
+
# FSMSession handlers run on the EventLoop thread. They must not perform
|
|
11
|
+
# blocking work or call synchronous invoke APIs from that thread.
|
|
51
12
|
class EventLoop
|
|
52
|
-
# Sentinel target_id for EventLoop management events (:start, :finished, :halted, :error).
|
|
53
|
-
# Events with this target_id are processed directly by the EventLoop and never
|
|
54
|
-
# routed to any FSMSession via handle(event).
|
|
55
13
|
SYSTEM_CHANNEL_ID = "__event_loop__"
|
|
56
14
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
@instance ||= new.tap(&:start)
|
|
60
|
-
end
|
|
15
|
+
STOP = Object.new.freeze
|
|
16
|
+
private_constant :STOP
|
|
61
17
|
|
|
62
|
-
#
|
|
63
|
-
# Uses a task-local key set by the Runtime-spawned dispatch task so that
|
|
64
|
-
# the check works correctly for both thread-based and future fiber-based
|
|
65
|
-
# scheduler backends.
|
|
66
|
-
# @return [Boolean]
|
|
18
|
+
# @param runtime [Phronomy::Runtime] owning Runtime
|
|
67
19
|
# @api private
|
|
68
|
-
def
|
|
69
|
-
|
|
70
|
-
|
|
20
|
+
def initialize(runtime:)
|
|
21
|
+
@runtime = runtime
|
|
22
|
+
@queue = Phronomy::Concurrency::AsyncQueue.new
|
|
23
|
+
@fsms = {}
|
|
24
|
+
@waiting = {}
|
|
25
|
+
|
|
26
|
+
@lifecycle_mutex = Mutex.new
|
|
27
|
+
@idle_cond = ConditionVariable.new
|
|
28
|
+
@shutdown_mutex = Mutex.new
|
|
29
|
+
@state = :running
|
|
30
|
+
@outstanding_sessions = 0
|
|
31
|
+
@cancel_requested = false
|
|
32
|
+
@shutdown_status = nil
|
|
71
33
|
|
|
72
|
-
# Stops and destroys the singleton. Primarily used in tests.
|
|
73
|
-
# @api private
|
|
74
|
-
def self.reset!
|
|
75
|
-
@instance&.stop
|
|
76
|
-
@instance = nil
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def initialize
|
|
80
|
-
@queue = Phronomy::Concurrency::AsyncQueue.new # global event queue (thread-safe; no Mutex needed)
|
|
81
|
-
@fsms = {} # { id => FSMSession } — EventLoop thread only
|
|
82
|
-
@waiting = {} # { id => completion_queue } — EventLoop thread only
|
|
83
|
-
# Mutex-backed FSM count for drain-mode shutdown.
|
|
84
|
-
@fsm_count_mutex = Mutex.new
|
|
85
|
-
@fsm_count_cond = ConditionVariable.new
|
|
86
|
-
@fsm_count = 0
|
|
87
|
-
# Token cancelled when shutdown is requested; new child sessions receive it.
|
|
88
|
-
@shutdown_token = Phronomy::Concurrency::CancellationToken.new
|
|
89
|
-
# Fairness metrics (EventLoop thread only, except where noted)
|
|
90
34
|
@lag_mutex = Mutex.new
|
|
91
35
|
@last_lag_ns = 0
|
|
92
36
|
@max_lag_ns = 0
|
|
93
37
|
@dispatch_count = 0
|
|
94
38
|
@total_lag_ns = 0
|
|
39
|
+
|
|
40
|
+
@task = @runtime.__spawn_event_loop_service { run_loop }
|
|
95
41
|
end
|
|
96
42
|
|
|
97
|
-
# Returns the most recently measured event-loop lag in seconds.
|
|
98
|
-
# Lag is the wall-clock time between {#post} and the moment the event
|
|
99
|
-
# is dequeued for dispatch. Thread-safe.
|
|
100
43
|
# @return [Float]
|
|
101
44
|
# @api private
|
|
102
45
|
def last_lag_seconds
|
|
103
46
|
@lag_mutex.synchronize { @last_lag_ns } / 1_000_000_000.0
|
|
104
47
|
end
|
|
105
48
|
|
|
106
|
-
# Returns the maximum event-loop lag seen since the loop was started.
|
|
107
|
-
# Thread-safe.
|
|
108
49
|
# @return [Float]
|
|
109
50
|
# @api private
|
|
110
51
|
def max_lag_seconds
|
|
111
52
|
@lag_mutex.synchronize { @max_lag_ns } / 1_000_000_000.0
|
|
112
53
|
end
|
|
113
54
|
|
|
114
|
-
# Returns the mean event-loop lag across all dispatched events since the
|
|
115
|
-
# loop was started. Returns 0.0 when no events have been dispatched.
|
|
116
|
-
# Thread-safe.
|
|
117
55
|
# @return [Float]
|
|
118
56
|
# @api private
|
|
119
57
|
def average_lag_seconds
|
|
@@ -124,234 +62,335 @@ module Phronomy
|
|
|
124
62
|
end
|
|
125
63
|
end
|
|
126
64
|
|
|
127
|
-
# Registers an FSMSession
|
|
128
|
-
#
|
|
129
|
-
# The session and its completion queue are handed off to the EventLoop thread
|
|
130
|
-
# via the queue payload, so +@fsms+ and +@waiting+ are exclusively written
|
|
131
|
-
# and read by the EventLoop thread. No Mutex is required.
|
|
65
|
+
# Registers an FSMSession and returns its completion queue.
|
|
132
66
|
#
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
#
|
|
67
|
+
# +outstanding_sessions+ is incremented before the :start event is enqueued,
|
|
68
|
+
# so shutdown also accounts for accepted sessions that have not yet been
|
|
69
|
+
# dispatched.
|
|
136
70
|
#
|
|
137
71
|
# @param fsm_session [Phronomy::FSMSession]
|
|
138
|
-
# @
|
|
72
|
+
# @param completion [Phronomy::Task, nil]
|
|
73
|
+
# @return [Phronomy::Concurrency::AsyncQueue, Phronomy::Task]
|
|
139
74
|
# @api private
|
|
140
75
|
def register(fsm_session, completion: nil)
|
|
141
|
-
if
|
|
76
|
+
if current? && !completion.is_a?(Phronomy::Task)
|
|
142
77
|
raise Phronomy::Error,
|
|
143
|
-
"Cannot call Workflow#invoke
|
|
144
|
-
"
|
|
145
|
-
"BlockingAdapterPool, then post events back via " \
|
|
146
|
-
"Phronomy::EventLoop.instance.post(...) instead."
|
|
78
|
+
"Cannot call synchronous Workflow#invoke from an EventLoop action. " \
|
|
79
|
+
"Schedule work asynchronously instead."
|
|
147
80
|
end
|
|
148
81
|
|
|
149
82
|
completion_queue = completion || Phronomy::Concurrency::AsyncQueue.new
|
|
150
|
-
# When called from a DeterministicScheduler Fiber (e.g. :fiber backend),
|
|
151
|
-
# mark the queue so that _pop_cooperative uses track_blocking_await.
|
|
152
|
-
# This prevents run_until_idle from exiting before the EventLoop thread
|
|
153
|
-
# (a different OS thread where Scheduler.current is nil) pushes the result.
|
|
154
83
|
scheduler = Phronomy::Runtime::Scheduler.current
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
84
|
+
if scheduler && completion_queue.respond_to?(:expect_cross_thread_push)
|
|
85
|
+
completion_queue.expect_cross_thread_push(scheduler)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
event = Phronomy::Event.new(
|
|
89
|
+
type: :start,
|
|
90
|
+
target_id: SYSTEM_CHANNEL_ID,
|
|
91
|
+
payload: {session: fsm_session, completion: completion_queue}
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@lifecycle_mutex.synchronize do
|
|
95
|
+
ensure_accepting_registrations!
|
|
96
|
+
@outstanding_sessions += 1
|
|
97
|
+
begin
|
|
98
|
+
@queue.push([event, monotonic_nanoseconds])
|
|
99
|
+
rescue
|
|
100
|
+
@outstanding_sessions -= 1
|
|
101
|
+
@idle_cond.broadcast if @outstanding_sessions.zero?
|
|
102
|
+
raise
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
162
106
|
completion_queue
|
|
163
107
|
end
|
|
164
108
|
|
|
165
|
-
# Posts an event
|
|
166
|
-
#
|
|
167
|
-
#
|
|
109
|
+
# Posts an event. Returns false after stopping begins.
|
|
110
|
+
#
|
|
111
|
+
# Async completion callbacks may race with shutdown, so rejection is a
|
|
112
|
+
# boolean result rather than an exception.
|
|
168
113
|
#
|
|
169
|
-
# @note **Handler constraint**: do not perform blocking operations or call
|
|
170
|
-
# +Workflow#invoke+ directly from within the handler that processes a
|
|
171
|
-
# posted event. Handlers run on the EventLoop thread; blocking there
|
|
172
|
-
# stalls all session processing. For blocking work, post a new event
|
|
173
|
-
# after the result is ready.
|
|
174
114
|
# @param event [Phronomy::Event]
|
|
115
|
+
# @return [Boolean]
|
|
175
116
|
# @api private
|
|
176
117
|
def post(event)
|
|
177
|
-
@
|
|
118
|
+
@lifecycle_mutex.synchronize do
|
|
119
|
+
return false unless accepting_events?
|
|
120
|
+
|
|
121
|
+
@queue.push([event, monotonic_nanoseconds])
|
|
122
|
+
end
|
|
123
|
+
true
|
|
178
124
|
end
|
|
179
125
|
|
|
180
|
-
#
|
|
181
|
-
#
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
# @return [
|
|
126
|
+
# Returns true only for this EventLoop's dispatcher Task.
|
|
127
|
+
# @api private
|
|
128
|
+
def current?
|
|
129
|
+
Phronomy::Task.current.equal?(@task)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @return [Symbol]
|
|
133
|
+
# @api private
|
|
134
|
+
def state
|
|
135
|
+
@lifecycle_mutex.synchronize { @state }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Stops external admission at the Runtime boundary while allowing already
|
|
139
|
+
# accepted work to complete on this EventLoop.
|
|
187
140
|
# @api private
|
|
188
|
-
def
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
# Reset shutdown state so the loop can be restarted after a stop.
|
|
192
|
-
@shutdown_token = Phronomy::Concurrency::CancellationToken.new
|
|
193
|
-
@fsm_count_mutex.synchronize { @fsm_count = 0 }
|
|
194
|
-
@running = true
|
|
195
|
-
# The dispatch loop must always run in a real background thread.
|
|
196
|
-
# A cooperative scheduler (FakeScheduler/ImmediateBackend) executes tasks
|
|
197
|
-
# synchronously on the caller's thread, which would block forever inside
|
|
198
|
-
# the run_loop infinite loop. Create a dedicated Runtime with
|
|
199
|
-
# ThreadScheduler to guarantee async execution regardless of the global
|
|
200
|
-
# runtime_backend setting.
|
|
201
|
-
thread_runtime = Phronomy::Runtime.new(scheduler: Phronomy::Runtime::ThreadScheduler.new)
|
|
202
|
-
@task = thread_runtime.spawn(name: "event-loop") do
|
|
203
|
-
run_loop
|
|
141
|
+
def begin_draining
|
|
142
|
+
@lifecycle_mutex.synchronize do
|
|
143
|
+
@state = :draining if @state == :running
|
|
204
144
|
end
|
|
205
145
|
self
|
|
206
146
|
end
|
|
207
147
|
|
|
208
|
-
#
|
|
148
|
+
# @return [Boolean]
|
|
149
|
+
# @api private
|
|
150
|
+
def idle?
|
|
151
|
+
@lifecycle_mutex.synchronize { @outstanding_sessions.zero? }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Waits until no queued :start or active session remains.
|
|
155
|
+
# @param deadline [Numeric] absolute monotonic deadline
|
|
156
|
+
# @return [Boolean] false on timeout
|
|
157
|
+
# @api private
|
|
158
|
+
def wait_until_idle(deadline)
|
|
159
|
+
@lifecycle_mutex.synchronize do
|
|
160
|
+
until @outstanding_sessions.zero?
|
|
161
|
+
remaining = deadline - monotonic_now
|
|
162
|
+
return false if remaining <= 0
|
|
163
|
+
|
|
164
|
+
@idle_cond.wait(@lifecycle_mutex, remaining)
|
|
165
|
+
end
|
|
166
|
+
true
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Runtime-only terminal shutdown.
|
|
209
171
|
#
|
|
210
|
-
#
|
|
211
|
-
#
|
|
212
|
-
#
|
|
213
|
-
# afterwards it is cancelled (cooperative cancellation via {Task#cancel!}).
|
|
172
|
+
# On graceful timeout the dispatcher is cancelled. Queue cleanup is only
|
|
173
|
+
# performed after the dispatcher is confirmed dead, so there is never more
|
|
174
|
+
# than one queue consumer.
|
|
214
175
|
#
|
|
215
|
-
# @param
|
|
216
|
-
#
|
|
217
|
-
# @
|
|
218
|
-
# complete before signalling the loop to stop. Bounded by +timeout+.
|
|
219
|
-
# Defaults to +false+.
|
|
220
|
-
# @param force_kill [Boolean] deprecated — retained for backward compatibility.
|
|
221
|
-
# When +true+, the dispatch task is cancelled via {Task#cancel!} if it does
|
|
222
|
-
# not stop within +timeout+. +Thread#kill+ is no longer used; cooperative
|
|
223
|
-
# cancellation (raising {CancellationError}) replaces it.
|
|
224
|
-
# @return [Symbol] shutdown status:
|
|
225
|
-
# - +:clean+ — loop exited cooperatively with no active sessions discarded
|
|
226
|
-
# - +:drained_with_discards+ — drain mode requested but sessions remained;
|
|
227
|
-
# they were discarded and the loop was stopped
|
|
228
|
-
# - +:timeout+ — the task did not stop in time and +force_kill:+ is +false+
|
|
229
|
-
# - +:force_killed+ — the task was cancelled because it did not stop in time
|
|
176
|
+
# @param deadline [Numeric] absolute monotonic deadline
|
|
177
|
+
# @param cancel_grace [Numeric] seconds to wait after Task#cancel!
|
|
178
|
+
# @return [Symbol] :terminated, :cancelled, :cancel_timeout, or :failed
|
|
230
179
|
# @api private
|
|
231
|
-
def
|
|
232
|
-
@
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
while @fsm_count > 0
|
|
240
|
-
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
241
|
-
break if remaining <= 0
|
|
242
|
-
@fsm_count_cond.wait(@fsm_count_mutex, remaining)
|
|
243
|
-
end
|
|
244
|
-
status = :drained_with_discards if @fsm_count > 0
|
|
180
|
+
def shutdown(deadline:, cancel_grace:)
|
|
181
|
+
@shutdown_mutex.synchronize do
|
|
182
|
+
return @shutdown_status if @shutdown_status
|
|
183
|
+
|
|
184
|
+
if state == :failed
|
|
185
|
+
join_until(deadline)
|
|
186
|
+
@shutdown_status = :failed
|
|
187
|
+
return @shutdown_status
|
|
245
188
|
end
|
|
246
|
-
end
|
|
247
189
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if force_kill
|
|
259
|
-
Phronomy.configuration.logger&.warn(
|
|
260
|
-
"[Phronomy] EventLoop task did not stop within #{timeout}s; cancelling. " \
|
|
261
|
-
"This is a last resort — check for blocking operations in event handlers."
|
|
262
|
-
)
|
|
263
|
-
@task.cancel!
|
|
264
|
-
status = :force_killed
|
|
190
|
+
begin_draining
|
|
191
|
+
if wait_until_idle(deadline)
|
|
192
|
+
begin_stopping_if_idle
|
|
193
|
+
join_until(deadline)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
@shutdown_status = if task_alive?
|
|
197
|
+
cancel_and_cleanup(cancel_grace)
|
|
198
|
+
elsif state == :failed
|
|
199
|
+
:failed
|
|
265
200
|
else
|
|
266
|
-
|
|
267
|
-
"[Phronomy] EventLoop task did not stop within #{timeout}s; abandoning " \
|
|
268
|
-
"(force_kill: false). Check for blocking operations in event handlers."
|
|
269
|
-
)
|
|
270
|
-
status = :timeout
|
|
201
|
+
finalize_terminated(:terminated)
|
|
271
202
|
end
|
|
272
203
|
end
|
|
273
|
-
|
|
274
|
-
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# @return [Boolean]
|
|
207
|
+
# @api private
|
|
208
|
+
def task_alive?
|
|
209
|
+
@task&.alive? || false
|
|
275
210
|
end
|
|
276
211
|
|
|
277
212
|
private
|
|
278
213
|
|
|
279
214
|
def run_loop
|
|
280
|
-
|
|
215
|
+
loop do
|
|
281
216
|
item = @queue.pop
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
# Treating it as `next` instead of `break` prevents a stale sentinel
|
|
285
|
-
# (left by a previous stop call that raced with thread start) from
|
|
286
|
-
# immediately terminating a freshly restarted EventLoop.
|
|
287
|
-
next if item == :__stop__
|
|
288
|
-
|
|
289
|
-
# item is [event, posted_at_ns] — unwrap and measure lag
|
|
217
|
+
break if item.equal?(STOP)
|
|
218
|
+
|
|
290
219
|
event, posted_at_ns = item
|
|
291
|
-
dequeued_at_ns =
|
|
220
|
+
dequeued_at_ns = monotonic_nanoseconds
|
|
292
221
|
lag_ns = dequeued_at_ns - posted_at_ns
|
|
293
222
|
update_lag_metrics(lag_ns)
|
|
294
223
|
check_starvation_lag(lag_ns, event)
|
|
295
224
|
|
|
296
225
|
dispatch_start_ns = dequeued_at_ns
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
@fsm_count_cond.signal if @fsm_count <= 0
|
|
312
|
-
end
|
|
313
|
-
|
|
314
|
-
when :start
|
|
315
|
-
# session and completion_queue arrive together in the payload so that
|
|
316
|
-
# this thread is the sole writer of @fsms and @waiting.
|
|
317
|
-
# completion may be nil for fire-and-forget child sessions (AgentFSM).
|
|
318
|
-
session = event.payload[:session]
|
|
319
|
-
cq = event.payload[:completion]
|
|
320
|
-
|
|
321
|
-
# When shutdown has been requested, reject new sessions with a
|
|
322
|
-
# CancellationError rather than starting new LLM calls that would
|
|
323
|
-
# be interrupted by force-kill.
|
|
324
|
-
if @shutdown_token.cancelled? && cq
|
|
325
|
-
complete_waiter(cq, Phronomy::CancellationError.new("EventLoop is shutting down"))
|
|
326
|
-
next
|
|
327
|
-
end
|
|
328
|
-
|
|
329
|
-
@fsms[session.id] = session
|
|
330
|
-
@waiting[session.id] = cq if cq
|
|
331
|
-
@fsm_count_mutex.synchronize { @fsm_count += 1 }
|
|
332
|
-
session.start
|
|
333
|
-
end
|
|
226
|
+
dispatch(event)
|
|
227
|
+
check_dispatch_time(dispatch_start_ns, event)
|
|
228
|
+
end
|
|
229
|
+
rescue Phronomy::CancellationError => error
|
|
230
|
+
unless shutdown_cancel_requested?
|
|
231
|
+
notify_unexpected_dispatcher_failure(error)
|
|
232
|
+
raise
|
|
233
|
+
end
|
|
234
|
+
rescue => error
|
|
235
|
+
notify_unexpected_dispatcher_failure(error)
|
|
236
|
+
raise
|
|
237
|
+
ensure
|
|
238
|
+
@lifecycle_mutex.synchronize { @idle_cond.broadcast }
|
|
239
|
+
end
|
|
334
240
|
|
|
241
|
+
def dispatch(event)
|
|
242
|
+
if event.target_id == SYSTEM_CHANNEL_ID
|
|
243
|
+
dispatch_management(event)
|
|
244
|
+
else
|
|
245
|
+
fsm = @fsms[event.target_id]
|
|
246
|
+
if fsm
|
|
247
|
+
fsm.handle(event)
|
|
335
248
|
else
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
if fsm
|
|
339
|
-
fsm.handle(event)
|
|
340
|
-
else
|
|
341
|
-
# Warn when an event is dropped due to an unknown target_id so that
|
|
342
|
-
# mis-typed IDs and handler-deregistration races are visible.
|
|
343
|
-
warn "[Phronomy::EventLoop] Dropped event #{event.type.inspect} — " \
|
|
344
|
-
"no handler for target_id #{event.target_id.inspect}"
|
|
345
|
-
end
|
|
249
|
+
warn "[Phronomy::EventLoop] Dropped event #{event.type.inspect} — " \
|
|
250
|
+
"no handler for target_id #{event.target_id.inspect}"
|
|
346
251
|
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
347
254
|
|
|
348
|
-
|
|
349
|
-
|
|
255
|
+
def dispatch_management(event)
|
|
256
|
+
case event.type
|
|
257
|
+
when :finished, :halted, :error
|
|
258
|
+
session_id = event.payload[:session_id]
|
|
259
|
+
session = @fsms.delete(session_id)
|
|
260
|
+
waiter = @waiting.delete(session_id)
|
|
261
|
+
complete_waiter(waiter, event.payload[:result])
|
|
262
|
+
decrement_outstanding if session
|
|
263
|
+
when :start
|
|
264
|
+
session = event.payload[:session]
|
|
265
|
+
waiter = event.payload[:completion]
|
|
266
|
+
@fsms[session.id] = session
|
|
267
|
+
@waiting[session.id] = waiter if waiter
|
|
268
|
+
session.start
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def begin_stopping_if_idle
|
|
273
|
+
@lifecycle_mutex.synchronize do
|
|
274
|
+
return false unless @state == :draining
|
|
275
|
+
return false unless @outstanding_sessions.zero?
|
|
276
|
+
|
|
277
|
+
@state = :stopping
|
|
278
|
+
@queue.push(STOP)
|
|
279
|
+
true
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def cancel_and_cleanup(cancel_grace)
|
|
284
|
+
task = @task
|
|
285
|
+
@lifecycle_mutex.synchronize do
|
|
286
|
+
@state = :stopping unless @state == :failed
|
|
287
|
+
@cancel_requested = true
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
task&.cancel!
|
|
291
|
+
begin
|
|
292
|
+
task&.join(cancel_grace)
|
|
293
|
+
rescue
|
|
294
|
+
nil
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
if task&.alive?
|
|
298
|
+
@lifecycle_mutex.synchronize { @state = :failed }
|
|
299
|
+
return :cancel_timeout
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
return :failed if state == :failed
|
|
303
|
+
|
|
304
|
+
cleanup_abandoned_work(
|
|
305
|
+
Phronomy::CancellationError.new("Runtime shutdown timed out")
|
|
306
|
+
)
|
|
307
|
+
finalize_terminated(:cancelled)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Called only after the dispatcher is confirmed dead.
|
|
311
|
+
def cleanup_abandoned_work(error)
|
|
312
|
+
drain_queued_items.each do |item|
|
|
313
|
+
next if item.equal?(STOP)
|
|
314
|
+
|
|
315
|
+
event, = item
|
|
316
|
+
next unless event.target_id == SYSTEM_CHANNEL_ID && event.type == :start
|
|
317
|
+
|
|
318
|
+
complete_waiter(event.payload[:completion], error)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
@waiting.values.each { |waiter| complete_waiter(waiter, error) }
|
|
322
|
+
@waiting.clear
|
|
323
|
+
@fsms.clear
|
|
324
|
+
@lifecycle_mutex.synchronize do
|
|
325
|
+
@outstanding_sessions = 0
|
|
326
|
+
@idle_cond.broadcast
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def drain_queued_items
|
|
331
|
+
items = []
|
|
332
|
+
loop do
|
|
333
|
+
item = @queue.pop(timeout: 0)
|
|
334
|
+
break unless item
|
|
335
|
+
|
|
336
|
+
items << item
|
|
337
|
+
end
|
|
338
|
+
items
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Framework failures are reported and made terminal. No automatic restart,
|
|
342
|
+
# replay, or pending-queue recovery is attempted.
|
|
343
|
+
def notify_unexpected_dispatcher_failure(error)
|
|
344
|
+
@lifecycle_mutex.synchronize do
|
|
345
|
+
@state = :failed
|
|
346
|
+
@idle_cond.broadcast
|
|
347
|
+
end
|
|
348
|
+
@waiting.values.each { |waiter| complete_waiter(waiter, error) }
|
|
349
|
+
@runtime.__event_loop_failed(error)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def shutdown_cancel_requested?
|
|
353
|
+
@lifecycle_mutex.synchronize do
|
|
354
|
+
@cancel_requested && @state == :stopping
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def accepting_events?
|
|
359
|
+
%i[running draining].include?(@state)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def ensure_accepting_registrations!
|
|
363
|
+
return if accepting_events?
|
|
364
|
+
|
|
365
|
+
raise Phronomy::RuntimeShutdownError,
|
|
366
|
+
"EventLoop is #{@state}; new sessions are not accepted"
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def decrement_outstanding
|
|
370
|
+
@lifecycle_mutex.synchronize do
|
|
371
|
+
@outstanding_sessions -= 1 if @outstanding_sessions.positive?
|
|
372
|
+
@idle_cond.broadcast if @outstanding_sessions.zero?
|
|
350
373
|
end
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def join_until(deadline)
|
|
377
|
+
remaining = deadline - monotonic_now
|
|
378
|
+
return if remaining <= 0
|
|
379
|
+
|
|
380
|
+
begin
|
|
381
|
+
@task&.join(remaining)
|
|
382
|
+
rescue
|
|
383
|
+
nil
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def finalize_terminated(status)
|
|
388
|
+
@lifecycle_mutex.synchronize do
|
|
389
|
+
@state = :terminated
|
|
390
|
+
@task = nil unless @task&.alive?
|
|
391
|
+
@idle_cond.broadcast
|
|
392
|
+
end
|
|
393
|
+
status
|
|
355
394
|
end
|
|
356
395
|
|
|
357
396
|
def complete_waiter(waiter, payload)
|
|
@@ -385,9 +424,9 @@ module Phronomy
|
|
|
385
424
|
|
|
386
425
|
Phronomy.configuration.logger&.warn do
|
|
387
426
|
"[Phronomy::EventLoop] Starvation detected: event #{event.type.inspect} " \
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
427
|
+
"for target #{event.target_id.inspect} waited " \
|
|
428
|
+
"#{format("%.3f", lag_ns / 1_000_000_000.0)}s in queue " \
|
|
429
|
+
"(threshold: #{threshold}s)"
|
|
391
430
|
end
|
|
392
431
|
end
|
|
393
432
|
|
|
@@ -395,15 +434,23 @@ module Phronomy
|
|
|
395
434
|
threshold = Phronomy.configuration.event_loop_dispatch_threshold_seconds
|
|
396
435
|
return unless threshold
|
|
397
436
|
|
|
398
|
-
elapsed_ns =
|
|
437
|
+
elapsed_ns = monotonic_nanoseconds - dispatch_start_ns
|
|
399
438
|
return unless elapsed_ns > (threshold * 1_000_000_000)
|
|
400
439
|
|
|
401
440
|
Phronomy.configuration.logger&.warn do
|
|
402
441
|
"[Phronomy::EventLoop] Long dispatch: event #{event.type.inspect} " \
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
442
|
+
"for target #{event.target_id.inspect} took " \
|
|
443
|
+
"#{format("%.3f", elapsed_ns / 1_000_000_000.0)}s on the EventLoop thread " \
|
|
444
|
+
"(threshold: #{threshold}s). Consider moving blocking work to BlockingAdapterPool."
|
|
406
445
|
end
|
|
407
446
|
end
|
|
447
|
+
|
|
448
|
+
def monotonic_now
|
|
449
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def monotonic_nanoseconds
|
|
453
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
|
|
454
|
+
end
|
|
408
455
|
end
|
|
409
456
|
end
|