phronomy 0.17.0 → 0.19.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +122 -1134
  3. data/README.md +70 -1280
  4. data/benchmark/bench_regression.rb +25 -5
  5. data/docs/changelog/0.14-and-earlier.md +1137 -0
  6. data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
  7. data/docs/decisions/009-state-store-abstraction.md +1 -1
  8. data/docs/decisions/010-cooperative-first-concurrency.md +155 -235
  9. data/docs/decisions/014-unified-persistence-durable-state.md +268 -0
  10. data/docs/features.md +93 -0
  11. data/docs/getting-started.md +387 -0
  12. data/docs/migrations/0.15.md +35 -0
  13. data/docs/migrations/0.16.md +43 -0
  14. data/docs/migrations/0.19.md +148 -0
  15. data/docs/runtime-and-concurrency.md +349 -0
  16. data/examples/workflows/generic_task_event_mapping.rb +14 -6
  17. data/lib/phronomy/agent/agent_invocation_session_builder.rb +2 -2
  18. data/lib/phronomy/agent/async_event_api.rb +3 -3
  19. data/lib/phronomy/agent/base.rb +116 -55
  20. data/lib/phronomy/agent/context/capability/base.rb +13 -3
  21. data/lib/phronomy/agent/context_assembler.rb +13 -3
  22. data/lib/phronomy/agent/execution_coordinator.rb +423 -252
  23. data/lib/phronomy/agent/journal_projection.rb +5 -1
  24. data/lib/phronomy/agent/shared_state.rb +2 -0
  25. data/lib/phronomy/agent/tool_executor.rb +29 -71
  26. data/lib/phronomy/agent/tool_invocation.rb +97 -47
  27. data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
  28. data/lib/phronomy/configuration.rb +7 -30
  29. data/lib/phronomy/diagnostics.rb +12 -41
  30. data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
  31. data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
  32. data/lib/phronomy/engine/concurrency/cancellation_token.rb +48 -3
  33. data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
  34. data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
  35. data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
  36. data/lib/phronomy/engine/event_loop.rb +170 -193
  37. data/lib/phronomy/engine/fsm_session.rb +6 -4
  38. data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
  39. data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
  40. data/lib/phronomy/engine/runtime.rb +52 -158
  41. data/lib/phronomy/engine/task.rb +136 -277
  42. data/lib/phronomy/llm_adapter/base.rb +14 -14
  43. data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
  44. data/lib/phronomy/llm_adapter.rb +2 -2
  45. data/lib/phronomy/metrics.rb +15 -30
  46. data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
  47. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
  48. data/lib/phronomy/multi_agent/handoff.rb +1 -0
  49. data/lib/phronomy/multi_agent/orchestrator.rb +147 -99
  50. data/lib/phronomy/multi_agent/team_coordinator.rb +2 -0
  51. data/lib/phronomy/persistence/in_memory.rb +113 -8
  52. data/lib/phronomy/persistence.rb +12 -3
  53. data/lib/phronomy/testing/eval/comparison.rb +23 -0
  54. data/lib/phronomy/testing/eval/dataset.rb +27 -0
  55. data/lib/phronomy/testing/eval/eval_case.rb +13 -0
  56. data/lib/phronomy/testing/eval/eval_result.rb +16 -0
  57. data/lib/phronomy/testing/eval/metrics.rb +43 -0
  58. data/lib/phronomy/testing/eval/runner.rb +52 -0
  59. data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
  60. data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
  61. data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
  62. data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
  63. data/lib/phronomy/testing/eval/scorer.rb +10 -0
  64. data/lib/phronomy/testing/eval.rb +9 -0
  65. data/lib/phronomy/testing/fake_clock.rb +6 -53
  66. data/lib/phronomy/testing.rb +2 -6
  67. data/lib/phronomy/tools/agent.rb +141 -6
  68. data/lib/phronomy/vector_store/async_backend.rb +21 -17
  69. data/lib/phronomy/vector_store/base.rb +2 -2
  70. data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
  71. data/lib/phronomy/version.rb +1 -1
  72. data/lib/phronomy/workflow.rb +10 -9
  73. data/lib/phronomy/workflow_runner.rb +363 -99
  74. data/lib/phronomy.rb +10 -4
  75. data/scripts/api_snapshot.rb +4 -4
  76. metadata +25 -33
  77. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
  78. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
  79. data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
  80. data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
  81. data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
  82. data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
  83. data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
  84. data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
  85. data/lib/phronomy/engine/task/backend.rb +0 -80
  86. data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
  87. data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
  88. data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
  89. data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
  90. data/lib/phronomy/engine/task/thread_backend.rb +0 -84
  91. data/lib/phronomy/engine/task_group.rb +0 -193
  92. data/lib/phronomy/eval/comparison.rb +0 -47
  93. data/lib/phronomy/eval/dataset.rb +0 -45
  94. data/lib/phronomy/eval/eval_case.rb +0 -17
  95. data/lib/phronomy/eval/eval_result.rb +0 -29
  96. data/lib/phronomy/eval/metrics.rb +0 -66
  97. data/lib/phronomy/eval/runner.rb +0 -94
  98. data/lib/phronomy/eval/scorer/base.rb +0 -22
  99. data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
  100. data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
  101. data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
  102. data/lib/phronomy/eval/scorer.rb +0 -9
  103. data/lib/phronomy/eval.rb +0 -7
  104. data/lib/phronomy/state_store/base.rb +0 -48
  105. data/lib/phronomy/state_store/in_memory.rb +0 -62
  106. data/lib/phronomy/testing/fake_scheduler.rb +0 -104
  107. data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Phronomy
4
4
  module Concurrency
5
- # Registry and lifecycle manager for {BlockingAdapterPool} instances.
5
+ # Registry and lifecycle manager for {OffloadPool} instances.
6
6
  #
7
7
  # Maintains one unnamed "default" pool (accessed via {#default_pool}) and
8
8
  # an arbitrary number of named pools (accessed via {#named_pool}).
@@ -21,10 +21,10 @@ module Phronomy
21
21
  # Returns (or lazily creates) the unnamed default pool.
22
22
  # @param pool_size [Integer]
23
23
  # @param queue_size [Integer]
24
- # @return [BlockingAdapterPool]
24
+ # @return [OffloadPool]
25
25
  # @api private
26
26
  def default_pool(pool_size: 10, queue_size: 100)
27
- @default ||= BlockingAdapterPool.new(
27
+ @default ||= OffloadPool.new(
28
28
  name: :default,
29
29
  pool_size: pool_size,
30
30
  queue_size: queue_size,
@@ -36,11 +36,11 @@ module Phronomy
36
36
  # @param name [Symbol, String]
37
37
  # @param size [Integer]
38
38
  # @param queue_size [Integer]
39
- # @return [BlockingAdapterPool]
39
+ # @return [OffloadPool]
40
40
  # @api private
41
41
  def named_pool(name, size: 10, queue_size: 100)
42
42
  @mutex.synchronize do
43
- @pools[name.to_sym] ||= BlockingAdapterPool.new(
43
+ @pools[name.to_sym] ||= OffloadPool.new(
44
44
  name: name,
45
45
  pool_size: size,
46
46
  queue_size: queue_size,
@@ -2,6 +2,9 @@
2
2
 
3
3
  module Phronomy
4
4
  # Runtime-owned FIFO event loop for FSMSession instances.
5
+ #
6
+ # EventLoop owns the framework's sole control-plane OS thread. All session
7
+ # lifecycle progression happens by short event dispatches on this thread.
5
8
  class EventLoop
6
9
  SYSTEM_CHANNEL_ID = "__event_loop__"
7
10
 
@@ -12,7 +15,8 @@ module Phronomy
12
15
  private_constant :TERMINAL_MANAGEMENT_EVENTS
13
16
 
14
17
  STOP = Object.new.freeze
15
- private_constant :STOP
18
+ WAKE = Object.new.freeze
19
+ private_constant :STOP, :WAKE
16
20
 
17
21
  def initialize(runtime:)
18
22
  @runtime = runtime
@@ -22,21 +26,16 @@ module Phronomy
22
26
  @max_queue_depth = 0
23
27
  @last_queue_backlog_warning_at = nil
24
28
 
25
- # @fsms and @waiting are dispatcher-thread-owned.
26
29
  @fsms = {}
27
30
  @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
31
  @admitted_session_ids = Set.new
32
+ @workflow_admissions = {}
33
33
 
34
34
  @lifecycle_mutex = Mutex.new
35
35
  @idle_cond = ConditionVariable.new
36
36
  @shutdown_mutex = Mutex.new
37
37
  @state = :running
38
38
  @outstanding_sessions = 0
39
- @cancel_requested = false
40
39
  @shutdown_status = nil
41
40
 
42
41
  @lag_mutex = Mutex.new
@@ -45,7 +44,8 @@ module Phronomy
45
44
  @dispatch_count = 0
46
45
  @total_lag_ns = 0
47
46
 
48
- @task = @runtime.__spawn_event_loop_service { run_loop }
47
+ @thread = Thread.new { run_loop }
48
+ @thread.name = "phronomy-event-loop"
49
49
  end
50
50
 
51
51
  def last_lag_seconds
@@ -59,7 +59,6 @@ module Phronomy
59
59
  def average_lag_seconds
60
60
  @lag_mutex.synchronize do
61
61
  return 0.0 if @dispatch_count.zero?
62
-
63
62
  @total_lag_ns.to_f / @dispatch_count / 1_000_000_000.0
64
63
  end
65
64
  end
@@ -79,21 +78,11 @@ module Phronomy
79
78
  "Schedule work asynchronously instead."
80
79
  end
81
80
 
82
- completion_queue =
83
- completion || Phronomy::Concurrency::AsyncQueue.new
84
- scheduler = Phronomy::Runtime::Scheduler.current
85
- if scheduler &&
86
- completion_queue.respond_to?(:expect_cross_thread_push)
87
- completion_queue.expect_cross_thread_push(scheduler)
88
- end
89
-
81
+ completion_handle = completion || Phronomy::Concurrency::AsyncQueue.new
90
82
  event = Phronomy::Event.new(
91
83
  type: :start,
92
84
  target_id: SYSTEM_CHANNEL_ID,
93
- payload: {
94
- session: fsm_session,
95
- completion: completion_queue
96
- }
85
+ payload: {session: fsm_session, completion: completion_handle}
97
86
  )
98
87
  queued_depth = nil
99
88
 
@@ -107,23 +96,19 @@ module Phronomy
107
96
  @admitted_session_ids.add(fsm_session.id)
108
97
  @outstanding_sessions += 1
109
98
  begin
110
- queued_depth = enqueue(
111
- [event, monotonic_nanoseconds]
112
- )
99
+ queued_depth = enqueue([event, monotonic_nanoseconds])
113
100
  rescue
114
101
  @admitted_session_ids.delete(fsm_session.id)
115
102
  @outstanding_sessions -= 1
116
- @idle_cond.broadcast if @outstanding_sessions.zero?
103
+ @idle_cond.broadcast if runtime_idle_locked?
117
104
  raise
118
105
  end
119
106
  end
120
107
 
121
108
  check_queue_backlog(queued_depth, event)
122
- completion_queue
109
+ completion_handle
123
110
  end
124
111
 
125
- # Internal post operation. Management terminal events close admission for
126
- # their session before the terminal event is enqueued.
127
112
  def post(event)
128
113
  queued_depth = nil
129
114
  accepted = @lifecycle_mutex.synchronize do
@@ -136,9 +121,7 @@ module Phronomy
136
121
  end
137
122
 
138
123
  begin
139
- queued_depth = enqueue(
140
- [event, monotonic_nanoseconds]
141
- )
124
+ queued_depth = enqueue([event, monotonic_nanoseconds])
142
125
  rescue
143
126
  @admitted_session_ids.add(terminal_session_id) if terminal_session_id
144
127
  raise
@@ -151,15 +134,9 @@ module Phronomy
151
134
  true
152
135
  end
153
136
 
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.
157
- #
158
- # A true result reports admission, not transition success.
159
137
  def post_to_session(event)
160
138
  if event.target_id == SYSTEM_CHANNEL_ID
161
- raise ArgumentError,
162
- "post_to_session cannot target the system channel"
139
+ raise ArgumentError, "post_to_session cannot target the system channel"
163
140
  end
164
141
 
165
142
  queued_depth = nil
@@ -167,9 +144,7 @@ module Phronomy
167
144
  next false unless accepting_events?
168
145
  next false unless @admitted_session_ids.include?(event.target_id)
169
146
 
170
- queued_depth = enqueue(
171
- [event, monotonic_nanoseconds]
172
- )
147
+ queued_depth = enqueue([event, monotonic_nanoseconds])
173
148
  true
174
149
  end
175
150
  return false unless accepted
@@ -178,14 +153,87 @@ module Phronomy
178
153
  true
179
154
  end
180
155
 
181
- def admitted_session?(session_id)
156
+ # Reserves one logical Workflow thread for one concrete FSMSession execution.
157
+ # thread_id is durable Workflow identity; owner_fsm_session_id is the
158
+ # Runtime-only identity of the invocation/resume that currently owns it.
159
+ def admit_workflow(thread_id, owner_fsm_session_id:)
160
+ key = thread_id.to_s
161
+ owner = owner_fsm_session_id.to_s
162
+ raise ArgumentError, "thread_id must not be empty" if key.empty?
163
+ raise ArgumentError, "owner_fsm_session_id must not be empty" if owner.empty?
164
+
182
165
  @lifecycle_mutex.synchronize do
183
- @admitted_session_ids.include?(session_id)
166
+ ensure_accepting_registrations!
167
+ current_owner = @workflow_admissions[key]
168
+ if current_owner
169
+ raise Phronomy::Error,
170
+ "Workflow thread #{key.inspect} is already owned by " \
171
+ "FSMSession #{current_owner.inspect}"
172
+ end
173
+ @workflow_admissions[key] = owner
184
174
  end
175
+ true
176
+ end
177
+
178
+ # Releases a Workflow reservation only when the caller is its current owner.
179
+ # A failed competing admission can therefore never release another session's
180
+ # reservation during cleanup.
181
+ def release_workflow(thread_id, owner_fsm_session_id:)
182
+ key = thread_id.to_s
183
+ owner = owner_fsm_session_id.to_s
184
+ @lifecycle_mutex.synchronize do
185
+ next false unless @workflow_admissions[key] == owner
186
+
187
+ @workflow_admissions.delete(key)
188
+ @idle_cond.broadcast if runtime_idle_locked?
189
+ true
190
+ end
191
+ end
192
+
193
+ def workflow_admission_owner(thread_id)
194
+ @lifecycle_mutex.synchronize { @workflow_admissions[thread_id.to_s] }
195
+ end
196
+
197
+ # Resolves durable Workflow identity to the currently owning FSMSession and
198
+ # enqueues the event atomically with that ownership check.
199
+ def post_to_workflow(thread_id:, event:, payload: nil)
200
+ queued_depth = nil
201
+ posted_event = nil
202
+ accepted = @lifecycle_mutex.synchronize do
203
+ next false unless accepting_events?
204
+
205
+ owner = @workflow_admissions[thread_id.to_s]
206
+ next false unless owner
207
+ next false unless @admitted_session_ids.include?(owner)
208
+
209
+ posted_event = Phronomy::Event.new(
210
+ type: event.to_sym,
211
+ target_id: owner,
212
+ payload: payload
213
+ )
214
+ queued_depth = enqueue([posted_event, monotonic_nanoseconds])
215
+ true
216
+ end
217
+ return false unless accepted
218
+
219
+ check_queue_backlog(queued_depth, posted_event)
220
+ true
221
+ end
222
+
223
+ # Interrupts the queue wait so EventLoop can recompute the next timer deadline.
224
+ def wake
225
+ @queue.push(WAKE)
226
+ true
227
+ rescue ClosedQueueError
228
+ false
229
+ end
230
+
231
+ def admitted_session?(session_id)
232
+ @lifecycle_mutex.synchronize { @admitted_session_ids.include?(session_id) }
185
233
  end
186
234
 
187
235
  def current?
188
- Phronomy::Task.current.equal?(@task)
236
+ Thread.current.equal?(@thread)
189
237
  end
190
238
 
191
239
  def state
@@ -200,24 +248,23 @@ module Phronomy
200
248
  end
201
249
 
202
250
  def idle?
203
- @lifecycle_mutex.synchronize do
204
- @outstanding_sessions.zero?
205
- end
251
+ @lifecycle_mutex.synchronize { runtime_idle_locked? }
206
252
  end
207
253
 
208
254
  def wait_until_idle(deadline)
209
255
  @lifecycle_mutex.synchronize do
210
- until @outstanding_sessions.zero?
256
+ until runtime_idle_locked?
211
257
  remaining = deadline - monotonic_now
212
258
  return false if remaining <= 0
213
-
214
259
  @idle_cond.wait(@lifecycle_mutex, remaining)
215
260
  end
216
261
  true
217
262
  end
218
263
  end
219
264
 
220
- def shutdown(deadline:, cancel_grace:)
265
+ # Sends STOP to the queue and joins the EventLoop thread.
266
+ # Assumes sessions have already been drained before this call.
267
+ def stop_and_join(deadline:)
221
268
  @shutdown_mutex.synchronize do
222
269
  return @shutdown_status if @shutdown_status
223
270
 
@@ -227,32 +274,40 @@ module Phronomy
227
274
  return @shutdown_status
228
275
  end
229
276
 
230
- begin_draining
231
- if wait_until_idle(deadline)
232
- begin_stopping_if_idle
233
- join_until(deadline)
234
- end
277
+ begin_stopping_if_idle
278
+ join_until(deadline)
235
279
 
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
280
+ @shutdown_status = if thread_alive?
281
+ @lifecycle_mutex.synchronize { @state = :failed }
282
+ :cancel_timeout
283
+ elsif state == :failed
284
+ :failed
285
+ else
286
+ finalize_terminated(:terminated)
287
+ end
244
288
  end
245
289
  end
246
290
 
247
- def task_alive?
248
- @task&.alive? || false
291
+ # Legacy entry point kept for any callers that pass deadline:/cancel_grace:.
292
+ def shutdown(deadline:, cancel_grace: deadline)
293
+ stop_and_join(deadline: deadline)
249
294
  end
250
295
 
296
+ def thread_alive?
297
+ @thread&.alive? || false
298
+ end
299
+
300
+ # Compatibility for existing runtime/shutdown observers during migration.
301
+ alias_method :task_alive?, :thread_alive?
302
+
251
303
  private
252
304
 
253
305
  def run_loop
254
306
  loop do
255
- item = dequeue
307
+ fire_due_timers
308
+ timeout = @runtime.__timer_queue.seconds_until_next
309
+ item = dequeue(timeout: timeout)
310
+ next if item.nil? || item.equal?(WAKE)
256
311
  break if item.equal?(STOP)
257
312
 
258
313
  event, posted_at_ns = item
@@ -265,24 +320,16 @@ module Phronomy
265
320
  dispatch(event)
266
321
  check_dispatch_time(dispatch_start_ns, event)
267
322
  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
323
+ fire_due_timers
279
324
  rescue => error
280
325
  notify_unexpected_dispatcher_failure(error)
281
326
  raise
282
327
  ensure
283
- @lifecycle_mutex.synchronize do
284
- @idle_cond.broadcast
285
- end
328
+ @lifecycle_mutex.synchronize { @idle_cond.broadcast }
329
+ end
330
+
331
+ def fire_due_timers
332
+ @runtime.__timer_queue.fire_due
286
333
  end
287
334
 
288
335
  def dispatch(event)
@@ -308,11 +355,11 @@ module Phronomy
308
355
  session_id = event.payload.fetch(:session_id)
309
356
  session = @fsms.delete(session_id)
310
357
  waiter = @waiting.delete(session_id)
311
- complete_waiter(
312
- waiter,
313
- event.payload.fetch(:result)
314
- )
358
+ # decrement before waking caller so wait_until_idle sees the control-plane
359
+ # session count immediately; Workflow durable admission may intentionally
360
+ # keep Runtime non-idle until its terminal save completes.
315
361
  decrement_outstanding if session
362
+ complete_waiter(waiter, event.payload.fetch(:result))
316
363
  when :start
317
364
  session = event.payload.fetch(:session)
318
365
  waiter = event.payload[:completion]
@@ -322,6 +369,9 @@ module Phronomy
322
369
  when :agent_terminal_ready
323
370
  cmd = event.payload.fetch(:command)
324
371
  cmd.coordinator.deliver_on_event_loop(cmd)
372
+ when :workflow_persistence_ready
373
+ cmd = event.payload.fetch(:command)
374
+ cmd.runner.deliver_persistence_on_event_loop(cmd)
325
375
  end
326
376
  end
327
377
 
@@ -335,66 +385,30 @@ module Phronomy
335
385
  def begin_stopping_if_idle
336
386
  @lifecycle_mutex.synchronize do
337
387
  return false unless @state == :draining
338
- return false unless @outstanding_sessions.zero?
388
+ return false unless runtime_idle_locked?
339
389
 
340
390
  @state = :stopping
341
- enqueue(STOP)
391
+ @queue.push(STOP)
342
392
  true
343
393
  end
344
394
  end
345
395
 
346
- def cancel_and_cleanup(cancel_grace)
347
- task = @task
348
- @lifecycle_mutex.synchronize do
349
- @state = :stopping unless @state == :failed
350
- @cancel_requested = true
351
- end
352
-
353
- task&.cancel!
354
- begin
355
- task&.join(cancel_grace)
356
- rescue
357
- nil
358
- end
359
-
360
- if task&.alive?
361
- @lifecycle_mutex.synchronize do
362
- @state = :failed
363
- end
364
- return :cancel_timeout
365
- end
366
-
367
- return :failed if state == :failed
368
-
369
- cleanup_abandoned_work(
370
- Phronomy::CancellationError.new(
371
- "Runtime shutdown timed out"
372
- )
373
- )
374
- finalize_terminated(:cancelled)
375
- end
376
-
377
396
  def cleanup_abandoned_work(error)
378
397
  drain_queued_items.each do |item|
379
- next if item.equal?(STOP)
398
+ next if item.equal?(STOP) || item.equal?(WAKE)
380
399
 
381
400
  event, = item
382
401
  next unless event.target_id == SYSTEM_CHANNEL_ID
383
402
  next unless event.type == :start
384
-
385
- complete_waiter(
386
- event.payload[:completion],
387
- error
388
- )
403
+ complete_waiter(event.payload[:completion], error)
389
404
  end
390
405
 
391
- @waiting.values.each do |waiter|
392
- complete_waiter(waiter, error)
393
- end
406
+ @waiting.values.each { |waiter| complete_waiter(waiter, error) }
394
407
  @waiting.clear
395
408
  @fsms.clear
396
409
  @lifecycle_mutex.synchronize do
397
410
  @admitted_session_ids.clear
411
+ @workflow_admissions.clear
398
412
  @outstanding_sessions = 0
399
413
  @idle_cond.broadcast
400
414
  end
@@ -405,7 +419,6 @@ module Phronomy
405
419
  loop do
406
420
  item = dequeue(timeout: 0)
407
421
  break unless item
408
-
409
422
  items << item
410
423
  end
411
424
  items
@@ -415,56 +428,48 @@ module Phronomy
415
428
  @lifecycle_mutex.synchronize do
416
429
  @state = :failed
417
430
  @admitted_session_ids.clear
431
+ @workflow_admissions.clear
418
432
  @idle_cond.broadcast
419
433
  end
420
- @waiting.values.each do |waiter|
421
- complete_waiter(waiter, error)
422
- end
434
+ cleanup_abandoned_work(error)
423
435
  @runtime.__event_loop_failed(error)
424
436
  end
425
437
 
426
- def shutdown_cancel_requested?
427
- @lifecycle_mutex.synchronize do
428
- @cancel_requested && @state == :stopping
429
- end
430
- end
431
-
432
438
  def accepting_events?
433
439
  %i[running draining].include?(@state)
434
440
  end
435
441
 
436
442
  def ensure_accepting_registrations!
437
443
  return if accepting_events?
438
-
439
444
  raise Phronomy::RuntimeShutdownError,
440
445
  "EventLoop is #{@state}; new sessions are not accepted"
441
446
  end
442
447
 
443
448
  def decrement_outstanding
444
449
  @lifecycle_mutex.synchronize do
445
- if @outstanding_sessions.positive?
446
- @outstanding_sessions -= 1
447
- end
448
- @idle_cond.broadcast if @outstanding_sessions.zero?
450
+ @outstanding_sessions -= 1 if @outstanding_sessions.positive?
451
+ @idle_cond.broadcast if runtime_idle_locked?
449
452
  end
450
453
  end
451
454
 
455
+ def runtime_idle_locked?
456
+ @outstanding_sessions.zero? && @workflow_admissions.empty?
457
+ end
458
+
452
459
  def join_until(deadline)
453
460
  remaining = deadline - monotonic_now
454
461
  return if remaining <= 0
455
-
456
- begin
457
- @task&.join(remaining)
458
- rescue
459
- nil
460
- end
462
+ @thread&.join(remaining)
463
+ rescue
464
+ nil
461
465
  end
462
466
 
463
467
  def finalize_terminated(status)
464
468
  @lifecycle_mutex.synchronize do
465
469
  @state = :terminated
466
470
  @admitted_session_ids.clear
467
- @task = nil unless @task&.alive?
471
+ @workflow_admissions.clear
472
+ @thread = nil unless @thread&.alive?
468
473
  @idle_cond.broadcast
469
474
  end
470
475
  status
@@ -474,16 +479,7 @@ module Phronomy
474
479
  return unless waiter
475
480
 
476
481
  if waiter.is_a?(Phronomy::Task)
477
- if payload.is_a?(Exception)
478
- waiter.backend.unblock(nil, payload)
479
- waiter.transition!(:failed, error: payload)
480
- else
481
- waiter.backend.unblock(payload, nil)
482
- waiter.transition!(
483
- :completed,
484
- value: payload
485
- )
486
- end
482
+ payload.is_a?(Exception) ? waiter.fail(payload) : waiter.complete(payload)
487
483
  else
488
484
  waiter.push(payload)
489
485
  end
@@ -492,9 +488,7 @@ module Phronomy
492
488
  def enqueue(item)
493
489
  depth = @queue_metrics_mutex.synchronize do
494
490
  @queue_depth += 1
495
- if @queue_depth > @max_queue_depth
496
- @max_queue_depth = @queue_depth
497
- end
491
+ @max_queue_depth = @queue_depth if @queue_depth > @max_queue_depth
498
492
  @queue_depth
499
493
  end
500
494
  @queue.push(item)
@@ -511,7 +505,7 @@ module Phronomy
511
505
  begin
512
506
  item = @queue.pop(timeout: timeout)
513
507
  ensure
514
- if item
508
+ if item && !item.equal?(WAKE) && !item.equal?(STOP)
515
509
  @queue_metrics_mutex.synchronize do
516
510
  @queue_depth -= 1 if @queue_depth.positive?
517
511
  end
@@ -527,12 +521,9 @@ module Phronomy
527
521
  max_depth = nil
528
522
  should_warn = @queue_metrics_mutex.synchronize do
529
523
  last = @last_queue_backlog_warning_at
530
- if last &&
531
- (now - last) <
532
- QUEUE_BACKLOG_WARNING_INTERVAL_SECONDS
524
+ if last && (now - last) < QUEUE_BACKLOG_WARNING_INTERVAL_SECONDS
533
525
  next false
534
526
  end
535
-
536
527
  @last_queue_backlog_warning_at = now
537
528
  max_depth = @max_queue_depth
538
529
  true
@@ -541,12 +532,9 @@ module Phronomy
541
532
 
542
533
  warn_queue_backlog(
543
534
  "[Phronomy::EventLoop] Queue backlog is high: " \
544
- "depth=#{depth} max_depth=#{max_depth} " \
545
- "threshold=#{QUEUE_BACKLOG_WARNING_THRESHOLD} " \
546
- "event=#{event.type.inspect} " \
547
- "target_id=#{event.target_id.inspect}. " \
548
- "Events are not dropped; inspect slow callbacks or " \
549
- "high streaming concurrency."
535
+ "depth=#{depth} max_depth=#{max_depth} " \
536
+ "threshold=#{QUEUE_BACKLOG_WARNING_THRESHOLD} " \
537
+ "event=#{event.type.inspect} target_id=#{event.target_id.inspect}."
550
538
  )
551
539
  end
552
540
 
@@ -567,38 +555,30 @@ module Phronomy
567
555
  end
568
556
 
569
557
  def check_starvation_lag(lag_ns, event)
570
- threshold =
571
- Phronomy.configuration
572
- .event_loop_starvation_threshold_seconds
558
+ threshold = Phronomy.configuration.event_loop_starvation_threshold_seconds
573
559
  return unless threshold
574
560
  return unless lag_ns > (threshold * 1_000_000_000)
575
561
 
576
562
  Phronomy.configuration.logger&.warn do
577
- "[Phronomy::EventLoop] Starvation detected: " \
578
- "event #{event.type.inspect} " \
563
+ "[Phronomy::EventLoop] Starvation detected: event #{event.type.inspect} " \
579
564
  "for target #{event.target_id.inspect} waited " \
580
- "#{format("%.3f", lag_ns / 1_000_000_000.0)}s " \
581
- "in queue (threshold: #{threshold}s)"
565
+ "#{format("%.3f", lag_ns / 1_000_000_000.0)}s in queue " \
566
+ "(threshold: #{threshold}s)"
582
567
  end
583
568
  end
584
569
 
585
570
  def check_dispatch_time(dispatch_start_ns, event)
586
- threshold =
587
- Phronomy.configuration
588
- .event_loop_dispatch_threshold_seconds
571
+ threshold = Phronomy.configuration.event_loop_dispatch_threshold_seconds
589
572
  return unless threshold
590
573
 
591
574
  elapsed_ns = monotonic_nanoseconds - dispatch_start_ns
592
- return unless elapsed_ns >
593
- (threshold * 1_000_000_000)
575
+ return unless elapsed_ns > (threshold * 1_000_000_000)
594
576
 
595
577
  Phronomy.configuration.logger&.warn do
596
- "[Phronomy::EventLoop] Long dispatch: " \
597
- "event #{event.type.inspect} " \
578
+ "[Phronomy::EventLoop] Long dispatch: event #{event.type.inspect} " \
598
579
  "for target #{event.target_id.inspect} took " \
599
- "#{format("%.3f", elapsed_ns / 1_000_000_000.0)}s " \
600
- "on the EventLoop thread (threshold: #{threshold}s). " \
601
- "Consider moving blocking work to BlockingAdapterPool."
580
+ "#{format("%.3f", elapsed_ns / 1_000_000_000.0)}s on the EventLoop thread " \
581
+ "(threshold: #{threshold}s). Move blocking I/O to OffloadPool."
602
582
  end
603
583
  end
604
584
 
@@ -607,10 +587,7 @@ module Phronomy
607
587
  end
608
588
 
609
589
  def monotonic_nanoseconds
610
- Process.clock_gettime(
611
- Process::CLOCK_MONOTONIC,
612
- :nanosecond
613
- )
590
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
614
591
  end
615
592
  end
616
593
  end