phronomy 0.13.0 → 0.15.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 +155 -0
- data/README.md +266 -38
- data/benchmark/bench_agent_invoke.rb +2 -3
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +14 -67
- data/docs/decisions/011-delegate-transport-policy-to-adapters.md +82 -0
- data/docs/mcp-client.md +75 -0
- data/examples/workflows/agent_event_mapping.rb +104 -0
- data/examples/workflows/generic_task_event_mapping.rb +58 -0
- data/gemfiles/mcp_1_0.gemfile +9 -0
- data/lib/phronomy/agent/agent_invocation.rb +385 -0
- data/lib/phronomy/agent/agent_invocation_registry.rb +75 -0
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +448 -0
- data/lib/phronomy/agent/approval_evaluation_request.rb +102 -0
- data/lib/phronomy/agent/async_event_api.rb +471 -0
- data/lib/phronomy/agent/base.rb +509 -420
- data/lib/phronomy/agent/context/capability/base.rb +57 -119
- data/lib/phronomy/agent/llm_operation_result.rb +23 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +75 -136
- data/lib/phronomy/agent/tool_approval_request.rb +121 -0
- data/lib/phronomy/agent/tool_call_intercepted.rb +11 -15
- data/lib/phronomy/agent/tool_executor.rb +47 -69
- data/lib/phronomy/agent/tool_invocation.rb +634 -0
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +378 -0
- data/lib/phronomy/agent.rb +21 -9
- data/lib/phronomy/configuration.rb +58 -53
- 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 +507 -303
- data/lib/phronomy/engine/fsm_session.rb +181 -140
- 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/engine/task.rb +5 -10
- data/lib/phronomy/event.rb +8 -8
- data/lib/phronomy/generator_verifier.rb +253 -142
- data/lib/phronomy/invalid_async_entry_action_error.rb +9 -0
- data/lib/phronomy/invalid_async_transition_action_error.rb +11 -0
- data/lib/phronomy/invalid_async_workflow_action_error.rb +9 -0
- data/lib/phronomy/invocation_context.rb +5 -19
- data/lib/phronomy/llm_adapter/base.rb +25 -34
- data/lib/phronomy/metrics.rb +6 -3
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
- data/lib/phronomy/stream_callback_error.rb +35 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +12 -3
- data/lib/phronomy/tools/mcp.rb +410 -81
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/phase_machine_builder.rb +129 -182
- data/lib/phronomy/workflow.rb +122 -261
- data/lib/phronomy/workflow_context.rb +55 -104
- data/lib/phronomy/workflow_runner.rb +239 -291
- data/lib/phronomy.rb +30 -23
- data/scripts/check_readme_runnable.rb +4 -1
- metadata +63 -11
- data/lib/phronomy/agent/concerns/retryable.rb +0 -103
- data/lib/phronomy/agent/context/capability/scope_policy.rb +0 -54
- data/lib/phronomy/agent/invocation_context.rb +0 -171
- data/lib/phronomy/agent/invocation_session.rb +0 -346
- data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
- data/lib/phronomy/engine/concurrency/concurrency_gate.rb +0 -157
- data/lib/phronomy/engine/concurrency/gate_registry.rb +0 -51
|
@@ -1,121 +1,61 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
# A single background thread reads from a global {Phronomy::Concurrency::AsyncQueue} and
|
|
7
|
-
# dispatches events to their target FSMSession. IO work (LLM calls, tool
|
|
8
|
-
# calls) must be dispatched via +Runtime.instance.spawn+ or
|
|
9
|
-
# +BlockingAdapterPool+, then post results back to the loop via
|
|
10
|
-
# {EventLoop#post}.
|
|
11
|
-
#
|
|
12
|
-
# Always active — all Workflow and Agent invocations use the EventLoop.
|
|
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(...)+.
|
|
4
|
+
# Runtime-owned FIFO event loop for FSMSession instances.
|
|
51
5
|
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
6
|
SYSTEM_CHANNEL_ID = "__event_loop__"
|
|
56
7
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
@
|
|
76
|
-
@
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
@
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
@
|
|
85
|
-
@
|
|
86
|
-
@
|
|
87
|
-
|
|
88
|
-
@
|
|
89
|
-
|
|
8
|
+
QUEUE_BACKLOG_WARNING_THRESHOLD = 1_000
|
|
9
|
+
QUEUE_BACKLOG_WARNING_INTERVAL_SECONDS = 60.0
|
|
10
|
+
|
|
11
|
+
TERMINAL_MANAGEMENT_EVENTS = %i[finished halted error].freeze
|
|
12
|
+
private_constant :TERMINAL_MANAGEMENT_EVENTS
|
|
13
|
+
|
|
14
|
+
STOP = Object.new.freeze
|
|
15
|
+
private_constant :STOP
|
|
16
|
+
|
|
17
|
+
def initialize(runtime:)
|
|
18
|
+
@runtime = runtime
|
|
19
|
+
@queue = Phronomy::Concurrency::AsyncQueue.new
|
|
20
|
+
@queue_metrics_mutex = Mutex.new
|
|
21
|
+
@queue_depth = 0
|
|
22
|
+
@max_queue_depth = 0
|
|
23
|
+
@last_queue_backlog_warning_at = nil
|
|
24
|
+
|
|
25
|
+
# @fsms and @waiting are dispatcher-thread-owned.
|
|
26
|
+
@fsms = {}
|
|
27
|
+
@waiting = {}
|
|
28
|
+
|
|
29
|
+
# Admission is shared by caller threads and the dispatcher. A session ID
|
|
30
|
+
# enters this set before its :start event is queued and leaves when its
|
|
31
|
+
# terminal management event is queued.
|
|
32
|
+
@admitted_session_ids = Set.new
|
|
33
|
+
|
|
34
|
+
@lifecycle_mutex = Mutex.new
|
|
35
|
+
@idle_cond = ConditionVariable.new
|
|
36
|
+
@shutdown_mutex = Mutex.new
|
|
37
|
+
@state = :running
|
|
38
|
+
@outstanding_sessions = 0
|
|
39
|
+
@cancel_requested = false
|
|
40
|
+
@shutdown_status = nil
|
|
41
|
+
|
|
90
42
|
@lag_mutex = Mutex.new
|
|
91
43
|
@last_lag_ns = 0
|
|
92
44
|
@max_lag_ns = 0
|
|
93
45
|
@dispatch_count = 0
|
|
94
46
|
@total_lag_ns = 0
|
|
47
|
+
|
|
48
|
+
@task = @runtime.__spawn_event_loop_service { run_loop }
|
|
95
49
|
end
|
|
96
50
|
|
|
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
|
-
# @return [Float]
|
|
101
|
-
# @api private
|
|
102
51
|
def last_lag_seconds
|
|
103
52
|
@lag_mutex.synchronize { @last_lag_ns } / 1_000_000_000.0
|
|
104
53
|
end
|
|
105
54
|
|
|
106
|
-
# Returns the maximum event-loop lag seen since the loop was started.
|
|
107
|
-
# Thread-safe.
|
|
108
|
-
# @return [Float]
|
|
109
|
-
# @api private
|
|
110
55
|
def max_lag_seconds
|
|
111
56
|
@lag_mutex.synchronize { @max_lag_ns } / 1_000_000_000.0
|
|
112
57
|
end
|
|
113
58
|
|
|
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
|
-
# @return [Float]
|
|
118
|
-
# @api private
|
|
119
59
|
def average_lag_seconds
|
|
120
60
|
@lag_mutex.synchronize do
|
|
121
61
|
return 0.0 if @dispatch_count.zero?
|
|
@@ -124,234 +64,407 @@ module Phronomy
|
|
|
124
64
|
end
|
|
125
65
|
end
|
|
126
66
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
# the popped value will be an Exception — callers are responsible for re-raising it.
|
|
136
|
-
#
|
|
137
|
-
# @param fsm_session [Phronomy::FSMSession]
|
|
138
|
-
# @return [Phronomy::Concurrency::AsyncQueue] resolves to final/halted context, or an Exception
|
|
139
|
-
# @api private
|
|
67
|
+
def queue_depth
|
|
68
|
+
@queue_metrics_mutex.synchronize { @queue_depth }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def max_queue_depth
|
|
72
|
+
@queue_metrics_mutex.synchronize { @max_queue_depth }
|
|
73
|
+
end
|
|
74
|
+
|
|
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
|
|
144
|
-
"
|
|
145
|
-
"BlockingAdapterPool, then post events back via " \
|
|
146
|
-
"Phronomy::EventLoop.instance.post(...) instead."
|
|
78
|
+
"Cannot call a synchronous invocation API from an EventLoop action. " \
|
|
79
|
+
"Schedule work asynchronously instead."
|
|
147
80
|
end
|
|
148
81
|
|
|
149
|
-
completion_queue =
|
|
150
|
-
|
|
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.
|
|
82
|
+
completion_queue =
|
|
83
|
+
completion || Phronomy::Concurrency::AsyncQueue.new
|
|
154
84
|
scheduler = Phronomy::Runtime::Scheduler.current
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
85
|
+
if scheduler &&
|
|
86
|
+
completion_queue.respond_to?(:expect_cross_thread_push)
|
|
87
|
+
completion_queue.expect_cross_thread_push(scheduler)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
event = Phronomy::Event.new(
|
|
91
|
+
type: :start,
|
|
92
|
+
target_id: SYSTEM_CHANNEL_ID,
|
|
93
|
+
payload: {
|
|
94
|
+
session: fsm_session,
|
|
95
|
+
completion: completion_queue
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
queued_depth = nil
|
|
99
|
+
|
|
100
|
+
@lifecycle_mutex.synchronize do
|
|
101
|
+
ensure_accepting_registrations!
|
|
102
|
+
if @admitted_session_ids.include?(fsm_session.id)
|
|
103
|
+
raise Phronomy::Error,
|
|
104
|
+
"FSMSession #{fsm_session.id.inspect} is already registered"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
@admitted_session_ids.add(fsm_session.id)
|
|
108
|
+
@outstanding_sessions += 1
|
|
109
|
+
begin
|
|
110
|
+
queued_depth = enqueue(
|
|
111
|
+
[event, monotonic_nanoseconds]
|
|
112
|
+
)
|
|
113
|
+
rescue
|
|
114
|
+
@admitted_session_ids.delete(fsm_session.id)
|
|
115
|
+
@outstanding_sessions -= 1
|
|
116
|
+
@idle_cond.broadcast if @outstanding_sessions.zero?
|
|
117
|
+
raise
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
check_queue_backlog(queued_depth, event)
|
|
162
122
|
completion_queue
|
|
163
123
|
end
|
|
164
124
|
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
# measure the dispatch lag when it dequeues the event.
|
|
168
|
-
#
|
|
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
|
-
# @param event [Phronomy::Event]
|
|
175
|
-
# @api private
|
|
125
|
+
# Internal post operation. Management terminal events close admission for
|
|
126
|
+
# their session before the terminal event is enqueued.
|
|
176
127
|
def post(event)
|
|
177
|
-
|
|
128
|
+
queued_depth = nil
|
|
129
|
+
accepted = @lifecycle_mutex.synchronize do
|
|
130
|
+
next false unless accepting_events?
|
|
131
|
+
|
|
132
|
+
terminal_session_id = nil
|
|
133
|
+
if terminal_management_event?(event)
|
|
134
|
+
terminal_session_id = event.payload.fetch(:session_id)
|
|
135
|
+
@admitted_session_ids.delete(terminal_session_id)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
begin
|
|
139
|
+
queued_depth = enqueue(
|
|
140
|
+
[event, monotonic_nanoseconds]
|
|
141
|
+
)
|
|
142
|
+
rescue
|
|
143
|
+
@admitted_session_ids.add(terminal_session_id) if terminal_session_id
|
|
144
|
+
raise
|
|
145
|
+
end
|
|
146
|
+
true
|
|
147
|
+
end
|
|
148
|
+
return false unless accepted
|
|
149
|
+
|
|
150
|
+
check_queue_backlog(queued_depth, event)
|
|
151
|
+
true
|
|
178
152
|
end
|
|
179
153
|
|
|
180
|
-
#
|
|
154
|
+
# Posts an event only when the target session has been admitted and has not
|
|
155
|
+
# queued a terminal management event. The event remains FIFO with all other
|
|
156
|
+
# EventLoop work.
|
|
181
157
|
#
|
|
182
|
-
#
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
158
|
+
# A true result reports admission, not transition success.
|
|
159
|
+
def post_to_session(event)
|
|
160
|
+
if event.target_id == SYSTEM_CHANNEL_ID
|
|
161
|
+
raise ArgumentError,
|
|
162
|
+
"post_to_session cannot target the system channel"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
queued_depth = nil
|
|
166
|
+
accepted = @lifecycle_mutex.synchronize do
|
|
167
|
+
next false unless accepting_events?
|
|
168
|
+
next false unless @admitted_session_ids.include?(event.target_id)
|
|
169
|
+
|
|
170
|
+
queued_depth = enqueue(
|
|
171
|
+
[event, monotonic_nanoseconds]
|
|
172
|
+
)
|
|
173
|
+
true
|
|
174
|
+
end
|
|
175
|
+
return false unless accepted
|
|
176
|
+
|
|
177
|
+
check_queue_backlog(queued_depth, event)
|
|
178
|
+
true
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def admitted_session?(session_id)
|
|
182
|
+
@lifecycle_mutex.synchronize do
|
|
183
|
+
@admitted_session_ids.include?(session_id)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def current?
|
|
188
|
+
Phronomy::Task.current.equal?(@task)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def state
|
|
192
|
+
@lifecycle_mutex.synchronize { @state }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def begin_draining
|
|
196
|
+
@lifecycle_mutex.synchronize do
|
|
197
|
+
@state = :draining if @state == :running
|
|
204
198
|
end
|
|
205
199
|
self
|
|
206
200
|
end
|
|
207
201
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
# dispatch task can finish any in-flight handler before exiting. Waits up
|
|
212
|
-
# to +timeout+ seconds for a clean shutdown; if the task is still alive
|
|
213
|
-
# afterwards it is cancelled (cooperative cancellation via {Task#cancel!}).
|
|
214
|
-
#
|
|
215
|
-
# @param timeout [Numeric] seconds to wait for cooperative shutdown. Defaults
|
|
216
|
-
# to +Phronomy.configuration.event_loop_stop_grace_seconds+ (5 s).
|
|
217
|
-
# @param drain [Boolean] when +true+, wait for all active FSMSessions to
|
|
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
|
|
230
|
-
# @api private
|
|
231
|
-
def stop(timeout: Phronomy.configuration.event_loop_stop_grace_seconds, drain: false, force_kill: false)
|
|
232
|
-
@shutdown_token.cancel!
|
|
233
|
-
status = :clean
|
|
234
|
-
|
|
235
|
-
if drain
|
|
236
|
-
# Wait for active sessions to finish, bounded by timeout.
|
|
237
|
-
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
238
|
-
@fsm_count_mutex.synchronize do
|
|
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
|
|
245
|
-
end
|
|
202
|
+
def idle?
|
|
203
|
+
@lifecycle_mutex.synchronize do
|
|
204
|
+
@outstanding_sessions.zero?
|
|
246
205
|
end
|
|
206
|
+
end
|
|
247
207
|
|
|
248
|
-
|
|
249
|
-
@
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
208
|
+
def wait_until_idle(deadline)
|
|
209
|
+
@lifecycle_mutex.synchronize do
|
|
210
|
+
until @outstanding_sessions.zero?
|
|
211
|
+
remaining = deadline - monotonic_now
|
|
212
|
+
return false if remaining <= 0
|
|
213
|
+
|
|
214
|
+
@idle_cond.wait(@lifecycle_mutex, remaining)
|
|
215
|
+
end
|
|
216
|
+
true
|
|
256
217
|
end
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def shutdown(deadline:, cancel_grace:)
|
|
221
|
+
@shutdown_mutex.synchronize do
|
|
222
|
+
return @shutdown_status if @shutdown_status
|
|
223
|
+
|
|
224
|
+
if state == :failed
|
|
225
|
+
join_until(deadline)
|
|
226
|
+
@shutdown_status = :failed
|
|
227
|
+
return @shutdown_status
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
begin_draining
|
|
231
|
+
if wait_until_idle(deadline)
|
|
232
|
+
begin_stopping_if_idle
|
|
233
|
+
join_until(deadline)
|
|
271
234
|
end
|
|
235
|
+
|
|
236
|
+
@shutdown_status =
|
|
237
|
+
if task_alive?
|
|
238
|
+
cancel_and_cleanup(cancel_grace)
|
|
239
|
+
elsif state == :failed
|
|
240
|
+
:failed
|
|
241
|
+
else
|
|
242
|
+
finalize_terminated(:terminated)
|
|
243
|
+
end
|
|
272
244
|
end
|
|
273
|
-
|
|
274
|
-
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def task_alive?
|
|
248
|
+
@task&.alive? || false
|
|
275
249
|
end
|
|
276
250
|
|
|
277
251
|
private
|
|
278
252
|
|
|
279
253
|
def run_loop
|
|
280
|
-
|
|
281
|
-
item =
|
|
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
|
|
254
|
+
loop do
|
|
255
|
+
item = dequeue
|
|
256
|
+
break if item.equal?(STOP)
|
|
257
|
+
|
|
290
258
|
event, posted_at_ns = item
|
|
291
|
-
dequeued_at_ns =
|
|
259
|
+
dequeued_at_ns = monotonic_nanoseconds
|
|
292
260
|
lag_ns = dequeued_at_ns - posted_at_ns
|
|
293
261
|
update_lag_metrics(lag_ns)
|
|
294
262
|
check_starvation_lag(lag_ns, event)
|
|
295
263
|
|
|
296
264
|
dispatch_start_ns = dequeued_at_ns
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
|
265
|
+
dispatch(event)
|
|
266
|
+
check_dispatch_time(dispatch_start_ns, event)
|
|
267
|
+
end
|
|
268
|
+
rescue Phronomy::CancellationError => error
|
|
269
|
+
if shutdown_cancel_requested?
|
|
270
|
+
cleanup_abandoned_work(
|
|
271
|
+
Phronomy::CancellationError.new(
|
|
272
|
+
"Runtime shutdown timed out"
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
else
|
|
276
|
+
notify_unexpected_dispatcher_failure(error)
|
|
277
|
+
raise
|
|
278
|
+
end
|
|
279
|
+
rescue => error
|
|
280
|
+
notify_unexpected_dispatcher_failure(error)
|
|
281
|
+
raise
|
|
282
|
+
ensure
|
|
283
|
+
@lifecycle_mutex.synchronize do
|
|
284
|
+
@idle_cond.broadcast
|
|
285
|
+
end
|
|
286
|
+
end
|
|
334
287
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
288
|
+
def dispatch(event)
|
|
289
|
+
if event.target_id == SYSTEM_CHANNEL_ID
|
|
290
|
+
dispatch_management(event)
|
|
291
|
+
return
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
fsm = @fsms[event.target_id]
|
|
295
|
+
if fsm
|
|
296
|
+
fsm.handle(event)
|
|
297
|
+
else
|
|
298
|
+
warn(
|
|
299
|
+
"[Phronomy::EventLoop] Dropped event #{event.type.inspect} — " \
|
|
300
|
+
"no handler for target_id #{event.target_id.inspect}"
|
|
301
|
+
)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def dispatch_management(event)
|
|
306
|
+
case event.type
|
|
307
|
+
when :finished, :halted, :error
|
|
308
|
+
session_id = event.payload.fetch(:session_id)
|
|
309
|
+
session = @fsms.delete(session_id)
|
|
310
|
+
waiter = @waiting.delete(session_id)
|
|
311
|
+
complete_waiter(
|
|
312
|
+
waiter,
|
|
313
|
+
event.payload.fetch(:result)
|
|
314
|
+
)
|
|
315
|
+
decrement_outstanding if session
|
|
316
|
+
when :start
|
|
317
|
+
session = event.payload.fetch(:session)
|
|
318
|
+
waiter = event.payload[:completion]
|
|
319
|
+
@fsms[session.id] = session
|
|
320
|
+
@waiting[session.id] = waiter if waiter
|
|
321
|
+
session.start
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def terminal_management_event?(event)
|
|
326
|
+
event.target_id == SYSTEM_CHANNEL_ID &&
|
|
327
|
+
TERMINAL_MANAGEMENT_EVENTS.include?(event.type) &&
|
|
328
|
+
event.payload.is_a?(Hash) &&
|
|
329
|
+
event.payload.key?(:session_id)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def begin_stopping_if_idle
|
|
333
|
+
@lifecycle_mutex.synchronize do
|
|
334
|
+
return false unless @state == :draining
|
|
335
|
+
return false unless @outstanding_sessions.zero?
|
|
336
|
+
|
|
337
|
+
@state = :stopping
|
|
338
|
+
enqueue(STOP)
|
|
339
|
+
true
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def cancel_and_cleanup(cancel_grace)
|
|
344
|
+
task = @task
|
|
345
|
+
@lifecycle_mutex.synchronize do
|
|
346
|
+
@state = :stopping unless @state == :failed
|
|
347
|
+
@cancel_requested = true
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
task&.cancel!
|
|
351
|
+
begin
|
|
352
|
+
task&.join(cancel_grace)
|
|
353
|
+
rescue
|
|
354
|
+
nil
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
if task&.alive?
|
|
358
|
+
@lifecycle_mutex.synchronize do
|
|
359
|
+
@state = :failed
|
|
346
360
|
end
|
|
361
|
+
return :cancel_timeout
|
|
362
|
+
end
|
|
347
363
|
|
|
348
|
-
|
|
349
|
-
|
|
364
|
+
return :failed if state == :failed
|
|
365
|
+
|
|
366
|
+
cleanup_abandoned_work(
|
|
367
|
+
Phronomy::CancellationError.new(
|
|
368
|
+
"Runtime shutdown timed out"
|
|
369
|
+
)
|
|
370
|
+
)
|
|
371
|
+
finalize_terminated(:cancelled)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def cleanup_abandoned_work(error)
|
|
375
|
+
drain_queued_items.each do |item|
|
|
376
|
+
next if item.equal?(STOP)
|
|
377
|
+
|
|
378
|
+
event, = item
|
|
379
|
+
next unless event.target_id == SYSTEM_CHANNEL_ID
|
|
380
|
+
next unless event.type == :start
|
|
381
|
+
|
|
382
|
+
complete_waiter(
|
|
383
|
+
event.payload[:completion],
|
|
384
|
+
error
|
|
385
|
+
)
|
|
350
386
|
end
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
387
|
+
|
|
388
|
+
@waiting.values.each do |waiter|
|
|
389
|
+
complete_waiter(waiter, error)
|
|
390
|
+
end
|
|
391
|
+
@waiting.clear
|
|
392
|
+
@fsms.clear
|
|
393
|
+
@lifecycle_mutex.synchronize do
|
|
394
|
+
@admitted_session_ids.clear
|
|
395
|
+
@outstanding_sessions = 0
|
|
396
|
+
@idle_cond.broadcast
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def drain_queued_items
|
|
401
|
+
items = []
|
|
402
|
+
loop do
|
|
403
|
+
item = dequeue(timeout: 0)
|
|
404
|
+
break unless item
|
|
405
|
+
|
|
406
|
+
items << item
|
|
407
|
+
end
|
|
408
|
+
items
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def notify_unexpected_dispatcher_failure(error)
|
|
412
|
+
@lifecycle_mutex.synchronize do
|
|
413
|
+
@state = :failed
|
|
414
|
+
@admitted_session_ids.clear
|
|
415
|
+
@idle_cond.broadcast
|
|
416
|
+
end
|
|
417
|
+
@waiting.values.each do |waiter|
|
|
418
|
+
complete_waiter(waiter, error)
|
|
419
|
+
end
|
|
420
|
+
@runtime.__event_loop_failed(error)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def shutdown_cancel_requested?
|
|
424
|
+
@lifecycle_mutex.synchronize do
|
|
425
|
+
@cancel_requested && @state == :stopping
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def accepting_events?
|
|
430
|
+
%i[running draining].include?(@state)
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def ensure_accepting_registrations!
|
|
434
|
+
return if accepting_events?
|
|
435
|
+
|
|
436
|
+
raise Phronomy::RuntimeShutdownError,
|
|
437
|
+
"EventLoop is #{@state}; new sessions are not accepted"
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def decrement_outstanding
|
|
441
|
+
@lifecycle_mutex.synchronize do
|
|
442
|
+
if @outstanding_sessions.positive?
|
|
443
|
+
@outstanding_sessions -= 1
|
|
444
|
+
end
|
|
445
|
+
@idle_cond.broadcast if @outstanding_sessions.zero?
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def join_until(deadline)
|
|
450
|
+
remaining = deadline - monotonic_now
|
|
451
|
+
return if remaining <= 0
|
|
452
|
+
|
|
453
|
+
begin
|
|
454
|
+
@task&.join(remaining)
|
|
455
|
+
rescue
|
|
456
|
+
nil
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def finalize_terminated(status)
|
|
461
|
+
@lifecycle_mutex.synchronize do
|
|
462
|
+
@state = :terminated
|
|
463
|
+
@admitted_session_ids.clear
|
|
464
|
+
@task = nil unless @task&.alive?
|
|
465
|
+
@idle_cond.broadcast
|
|
466
|
+
end
|
|
467
|
+
status
|
|
355
468
|
end
|
|
356
469
|
|
|
357
470
|
def complete_waiter(waiter, payload)
|
|
@@ -363,13 +476,84 @@ module Phronomy
|
|
|
363
476
|
waiter.transition!(:failed, error: payload)
|
|
364
477
|
else
|
|
365
478
|
waiter.backend.unblock(payload, nil)
|
|
366
|
-
waiter.transition!(
|
|
479
|
+
waiter.transition!(
|
|
480
|
+
:completed,
|
|
481
|
+
value: payload
|
|
482
|
+
)
|
|
367
483
|
end
|
|
368
484
|
else
|
|
369
485
|
waiter.push(payload)
|
|
370
486
|
end
|
|
371
487
|
end
|
|
372
488
|
|
|
489
|
+
def enqueue(item)
|
|
490
|
+
depth = @queue_metrics_mutex.synchronize do
|
|
491
|
+
@queue_depth += 1
|
|
492
|
+
if @queue_depth > @max_queue_depth
|
|
493
|
+
@max_queue_depth = @queue_depth
|
|
494
|
+
end
|
|
495
|
+
@queue_depth
|
|
496
|
+
end
|
|
497
|
+
@queue.push(item)
|
|
498
|
+
depth
|
|
499
|
+
rescue
|
|
500
|
+
@queue_metrics_mutex.synchronize do
|
|
501
|
+
@queue_depth -= 1 if @queue_depth.positive?
|
|
502
|
+
end
|
|
503
|
+
raise
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def dequeue(timeout: nil)
|
|
507
|
+
item = nil
|
|
508
|
+
begin
|
|
509
|
+
item = @queue.pop(timeout: timeout)
|
|
510
|
+
ensure
|
|
511
|
+
if item
|
|
512
|
+
@queue_metrics_mutex.synchronize do
|
|
513
|
+
@queue_depth -= 1 if @queue_depth.positive?
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
item
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def check_queue_backlog(depth, event)
|
|
521
|
+
return unless depth >= QUEUE_BACKLOG_WARNING_THRESHOLD
|
|
522
|
+
|
|
523
|
+
now = monotonic_now
|
|
524
|
+
max_depth = nil
|
|
525
|
+
should_warn = @queue_metrics_mutex.synchronize do
|
|
526
|
+
last = @last_queue_backlog_warning_at
|
|
527
|
+
if last &&
|
|
528
|
+
(now - last) <
|
|
529
|
+
QUEUE_BACKLOG_WARNING_INTERVAL_SECONDS
|
|
530
|
+
next false
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
@last_queue_backlog_warning_at = now
|
|
534
|
+
max_depth = @max_queue_depth
|
|
535
|
+
true
|
|
536
|
+
end
|
|
537
|
+
return unless should_warn
|
|
538
|
+
|
|
539
|
+
warn_queue_backlog(
|
|
540
|
+
"[Phronomy::EventLoop] Queue backlog is high: " \
|
|
541
|
+
"depth=#{depth} max_depth=#{max_depth} " \
|
|
542
|
+
"threshold=#{QUEUE_BACKLOG_WARNING_THRESHOLD} " \
|
|
543
|
+
"event=#{event.type.inspect} " \
|
|
544
|
+
"target_id=#{event.target_id.inspect}. " \
|
|
545
|
+
"Events are not dropped; inspect slow callbacks or " \
|
|
546
|
+
"high streaming concurrency."
|
|
547
|
+
)
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def warn_queue_backlog(message)
|
|
551
|
+
logger = Phronomy.configuration.logger
|
|
552
|
+
logger ? logger.warn(message) : Kernel.warn(message)
|
|
553
|
+
rescue
|
|
554
|
+
nil
|
|
555
|
+
end
|
|
556
|
+
|
|
373
557
|
def update_lag_metrics(lag_ns)
|
|
374
558
|
@lag_mutex.synchronize do
|
|
375
559
|
@last_lag_ns = lag_ns
|
|
@@ -380,30 +564,50 @@ module Phronomy
|
|
|
380
564
|
end
|
|
381
565
|
|
|
382
566
|
def check_starvation_lag(lag_ns, event)
|
|
383
|
-
threshold =
|
|
384
|
-
|
|
567
|
+
threshold =
|
|
568
|
+
Phronomy.configuration
|
|
569
|
+
.event_loop_starvation_threshold_seconds
|
|
570
|
+
return unless threshold
|
|
571
|
+
return unless lag_ns > (threshold * 1_000_000_000)
|
|
385
572
|
|
|
386
573
|
Phronomy.configuration.logger&.warn do
|
|
387
|
-
"[Phronomy::EventLoop] Starvation detected:
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
574
|
+
"[Phronomy::EventLoop] Starvation detected: " \
|
|
575
|
+
"event #{event.type.inspect} " \
|
|
576
|
+
"for target #{event.target_id.inspect} waited " \
|
|
577
|
+
"#{format("%.3f", lag_ns / 1_000_000_000.0)}s " \
|
|
578
|
+
"in queue (threshold: #{threshold}s)"
|
|
391
579
|
end
|
|
392
580
|
end
|
|
393
581
|
|
|
394
582
|
def check_dispatch_time(dispatch_start_ns, event)
|
|
395
|
-
threshold =
|
|
583
|
+
threshold =
|
|
584
|
+
Phronomy.configuration
|
|
585
|
+
.event_loop_dispatch_threshold_seconds
|
|
396
586
|
return unless threshold
|
|
397
587
|
|
|
398
|
-
elapsed_ns =
|
|
399
|
-
return unless elapsed_ns >
|
|
588
|
+
elapsed_ns = monotonic_nanoseconds - dispatch_start_ns
|
|
589
|
+
return unless elapsed_ns >
|
|
590
|
+
(threshold * 1_000_000_000)
|
|
400
591
|
|
|
401
592
|
Phronomy.configuration.logger&.warn do
|
|
402
|
-
"[Phronomy::EventLoop] Long dispatch:
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
593
|
+
"[Phronomy::EventLoop] Long dispatch: " \
|
|
594
|
+
"event #{event.type.inspect} " \
|
|
595
|
+
"for target #{event.target_id.inspect} took " \
|
|
596
|
+
"#{format("%.3f", elapsed_ns / 1_000_000_000.0)}s " \
|
|
597
|
+
"on the EventLoop thread (threshold: #{threshold}s). " \
|
|
598
|
+
"Consider moving blocking work to BlockingAdapterPool."
|
|
406
599
|
end
|
|
407
600
|
end
|
|
601
|
+
|
|
602
|
+
def monotonic_now
|
|
603
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
def monotonic_nanoseconds
|
|
607
|
+
Process.clock_gettime(
|
|
608
|
+
Process::CLOCK_MONOTONIC,
|
|
609
|
+
:nanosecond
|
|
610
|
+
)
|
|
611
|
+
end
|
|
408
612
|
end
|
|
409
613
|
end
|