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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +155 -0
  3. data/README.md +266 -38
  4. data/benchmark/bench_agent_invoke.rb +2 -3
  5. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +14 -67
  6. data/docs/decisions/011-delegate-transport-policy-to-adapters.md +82 -0
  7. data/docs/mcp-client.md +75 -0
  8. data/examples/workflows/agent_event_mapping.rb +104 -0
  9. data/examples/workflows/generic_task_event_mapping.rb +58 -0
  10. data/gemfiles/mcp_1_0.gemfile +9 -0
  11. data/lib/phronomy/agent/agent_invocation.rb +385 -0
  12. data/lib/phronomy/agent/agent_invocation_registry.rb +75 -0
  13. data/lib/phronomy/agent/agent_invocation_session_builder.rb +448 -0
  14. data/lib/phronomy/agent/approval_evaluation_request.rb +102 -0
  15. data/lib/phronomy/agent/async_event_api.rb +471 -0
  16. data/lib/phronomy/agent/base.rb +509 -420
  17. data/lib/phronomy/agent/context/capability/base.rb +57 -119
  18. data/lib/phronomy/agent/llm_operation_result.rb +23 -0
  19. data/lib/phronomy/agent/phase_machine_builder.rb +75 -136
  20. data/lib/phronomy/agent/tool_approval_request.rb +121 -0
  21. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -15
  22. data/lib/phronomy/agent/tool_executor.rb +47 -69
  23. data/lib/phronomy/agent/tool_invocation.rb +634 -0
  24. data/lib/phronomy/agent/tool_invocation_session_builder.rb +378 -0
  25. data/lib/phronomy/agent.rb +21 -9
  26. data/lib/phronomy/configuration.rb +58 -53
  27. data/lib/phronomy/diagnostics.rb +1 -1
  28. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +230 -118
  29. data/lib/phronomy/engine/concurrency/cancellation_token.rb +5 -1
  30. data/lib/phronomy/engine/concurrency/pool_registry.rb +8 -3
  31. data/lib/phronomy/engine/event_loop.rb +507 -303
  32. data/lib/phronomy/engine/fsm_session.rb +181 -140
  33. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +1 -1
  34. data/lib/phronomy/engine/runtime/shutdown_result.rb +62 -0
  35. data/lib/phronomy/engine/runtime/task_registry.rb +62 -15
  36. data/lib/phronomy/engine/runtime.rb +247 -57
  37. data/lib/phronomy/engine/task.rb +5 -10
  38. data/lib/phronomy/event.rb +8 -8
  39. data/lib/phronomy/generator_verifier.rb +253 -142
  40. data/lib/phronomy/invalid_async_entry_action_error.rb +9 -0
  41. data/lib/phronomy/invalid_async_transition_action_error.rb +11 -0
  42. data/lib/phronomy/invalid_async_workflow_action_error.rb +9 -0
  43. data/lib/phronomy/invocation_context.rb +5 -19
  44. data/lib/phronomy/llm_adapter/base.rb +25 -34
  45. data/lib/phronomy/metrics.rb +6 -3
  46. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
  47. data/lib/phronomy/stream_callback_error.rb +35 -0
  48. data/lib/phronomy/testing/scheduler_helpers.rb +12 -3
  49. data/lib/phronomy/tools/mcp.rb +410 -81
  50. data/lib/phronomy/version.rb +1 -1
  51. data/lib/phronomy/workflow/phase_machine_builder.rb +129 -182
  52. data/lib/phronomy/workflow.rb +122 -261
  53. data/lib/phronomy/workflow_context.rb +55 -104
  54. data/lib/phronomy/workflow_runner.rb +239 -291
  55. data/lib/phronomy.rb +30 -23
  56. data/scripts/check_readme_runnable.rb +4 -1
  57. metadata +63 -11
  58. data/lib/phronomy/agent/concerns/retryable.rb +0 -103
  59. data/lib/phronomy/agent/context/capability/scope_policy.rb +0 -54
  60. data/lib/phronomy/agent/invocation_context.rb +0 -171
  61. data/lib/phronomy/agent/invocation_session.rb +0 -346
  62. data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
  63. data/lib/phronomy/engine/concurrency/concurrency_gate.rb +0 -157
  64. 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
- # Singleton event loop that manages all FSMSession instances.
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
- # Returns the singleton instance, creating and starting it on first call.
58
- def self.instance
59
- @instance ||= new.tap(&:start)
60
- end
61
-
62
- # Returns true when called from within the EventLoop dispatch task.
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]
67
- # @api private
68
- def self.current?
69
- Phronomy::Task.current&.name == "event-loop"
70
- end
71
-
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)
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
- # Registers an FSMSession for execution and returns a completion queue.
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.
132
- #
133
- # The caller blocks on +completion_queue.pop+ to receive the final context
134
- # (WorkflowContext) once the workflow finishes or halts. If an error occurred,
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 Phronomy::EventLoop.current? && !completion.is_a?(Phronomy::Task)
76
+ if current? && !completion.is_a?(Phronomy::Task)
142
77
  raise Phronomy::Error,
143
- "Cannot call Workflow#invoke (EventLoop mode) from within an EventLoop " \
144
- "entry action. Schedule work via Runtime.instance.spawn or " \
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 = 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.
82
+ completion_queue =
83
+ completion || Phronomy::Concurrency::AsyncQueue.new
154
84
  scheduler = Phronomy::Runtime::Scheduler.current
155
- completion_queue.expect_cross_thread_push(scheduler) if scheduler && completion_queue.respond_to?(:expect_cross_thread_push)
156
- # Pass both session and completion_queue in the event payload so that the
157
- # EventLoop thread is the sole writer of @fsms and @waiting.
158
- # Use SYSTEM_CHANNEL_ID so the management event is never routed to an FSM.
159
- @queue.push([Event.new(type: :start, target_id: SYSTEM_CHANNEL_ID,
160
- payload: {session: fsm_session, completion: completion_queue}),
161
- Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)])
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
- # Posts an event to the loop. Safe to call from any thread (including IO threads).
166
- # The current monotonic clock time is recorded so that the EventLoop can
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
- @queue.push([event, Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)])
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
- # Starts the EventLoop dispatch task under {Runtime} ownership.
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
- # The dispatch loop runs as a {Phronomy::Task} so that {Runtime#shutdown}
183
- # can drain it together with all other in-flight tasks. The task is named
184
- # +"event-loop"+ so that {.current?} can identify it via
185
- # +Task.current&.name+.
186
- # @return [self]
187
- # @api private
188
- def start
189
- return self if @task&.alive?
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
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
- # Stops the EventLoop dispatch task.
209
- #
210
- # Sends a cooperative shutdown sentinel to the event queue so that the
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
- @running = false
249
- @queue.push(:__stop__) # unblock queue.pop so the task can see @running = false
250
- begin
251
- @task&.join(timeout)
252
- rescue
253
- # Task may have terminated with an error (e.g. simulated crash in tests).
254
- # Suppress the re-raise so the cleanup below always runs.
255
- nil
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
- if @task&.alive?
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
265
- else
266
- Phronomy.configuration.logger&.warn(
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
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
- @task = nil
274
- status
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
- while @running
281
- item = @queue.pop
282
- # :__stop__ is used purely as an unblock signal for @queue.pop; the
283
- # actual stop condition is @running == false (set before the push).
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 = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
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
- if event.target_id == SYSTEM_CHANNEL_ID
298
- # Management channel: lifecycle events processed directly by EventLoop.
299
- # Never routed to any FSMSession.
300
- case event.type
301
- when :finished, :halted, :error
302
- # session_id is carried in the payload so the FSM can use its own ID
303
- # as the FSM-facing target_id for other events.
304
- session_id = event.payload[:session_id]
305
- @fsms.delete(session_id)
306
- cq = @waiting.delete(session_id)
307
- complete_waiter(cq, event.payload[:result])
308
- # Decrement active FSM count and signal drain waiters.
309
- @fsm_count_mutex.synchronize do
310
- @fsm_count -= 1
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
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
- else
336
- # FSM channel: route to the target FSMSession by target_id.
337
- fsm = @fsms[event.target_id]
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
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
- # Check how long this dispatch took; warn if it exceeds the threshold.
349
- check_dispatch_time(dispatch_start_ns, event)
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
- rescue => e
352
- # Unblock all waiting callers if the loop dies unexpectedly.
353
- @waiting.values.each { |cq| complete_waiter(cq, e) }
354
- raise
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!(:completed, value: payload)
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 = Phronomy.configuration.event_loop_starvation_threshold_seconds
384
- return unless threshold && lag_ns > (threshold * 1_000_000_000)
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: event #{event.type.inspect} " \
388
- "for target #{event.target_id.inspect} waited " \
389
- "#{format("%.3f", lag_ns / 1_000_000_000.0)}s in queue " \
390
- "(threshold: #{threshold}s)"
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 = Phronomy.configuration.event_loop_dispatch_threshold_seconds
583
+ threshold =
584
+ Phronomy.configuration
585
+ .event_loop_dispatch_threshold_seconds
396
586
  return unless threshold
397
587
 
398
- elapsed_ns = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) - dispatch_start_ns
399
- return unless elapsed_ns > (threshold * 1_000_000_000)
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: event #{event.type.inspect} " \
403
- "for target #{event.target_id.inspect} took " \
404
- "#{format("%.3f", elapsed_ns / 1_000_000_000.0)}s on the EventLoop thread " \
405
- "(threshold: #{threshold}s). Consider moving blocking work to BlockingAdapterPool."
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