phronomy 0.13.0 → 0.14.0

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