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
@@ -25,9 +25,11 @@ module Phronomy
25
25
  event_loop:,
26
26
  resume_event: nil,
27
27
  resume_phase: nil,
28
- stable_observer: nil
28
+ stable_observer: nil,
29
+ graph_thread_id: nil
29
30
  )
30
31
  @id = id
32
+ @graph_thread_id = graph_thread_id || id
31
33
  @ctx = context
32
34
  @context = context
33
35
  @entry_point = entry_point
@@ -217,7 +219,7 @@ module Phronomy
217
219
  return if @done
218
220
 
219
221
  @done = true
220
- @ctx.set_graph_metadata(thread_id: @id, phase: :__end__)
222
+ @ctx.set_graph_metadata(thread_id: @graph_thread_id, phase: :__end__)
221
223
  post_terminal_event(:finished, @ctx)
222
224
  end
223
225
 
@@ -225,7 +227,7 @@ module Phronomy
225
227
  return if @done
226
228
 
227
229
  @done = true
228
- @ctx.set_graph_metadata(thread_id: @id, phase: @current_state)
230
+ @ctx.set_graph_metadata(thread_id: @graph_thread_id, phase: @current_state)
229
231
  post_terminal_event(:halted, @ctx)
230
232
  end
231
233
 
@@ -248,7 +250,7 @@ module Phronomy
248
250
 
249
251
  Phronomy.configuration.logger&.warn(
250
252
  "[Phronomy::FSMSession] EventLoop rejected terminal event " \
251
- "#{type.inspect} for #{@id}"
253
+ "#{type.inspect} for #{@id}"
252
254
  )
253
255
  end
254
256
 
@@ -2,104 +2,81 @@
2
2
 
3
3
  module Phronomy
4
4
  class Runtime
5
- # A thread-safe timer queue backed by a single background thread.
6
- #
7
- # Replaces the pattern of spawning one +Thread.new { sleep(t); callback }+
8
- # per deadline. Any number of timers share a single background thread that
9
- # sleeps until the earliest pending deadline.
10
- #
11
- # Use {#schedule} to register a one-shot callback; call {#shutdown} when the
12
- # queue is no longer needed (e.g. on process exit) to stop the background
13
- # thread cleanly.
5
+ # Threadless monotonic timer heap driven by EventLoop.
14
6
  class TimerQueue
15
- # @param clock [#call] zero-argument callable that returns the current
16
- # monotonic time in seconds (defaults to +Process::CLOCK_MONOTONIC+).
17
- # Override in tests to inject a fake clock.
18
- # @api private
19
7
  def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
20
8
  @clock = clock
21
- @heap = [] # [[fire_at, callback], ...]
9
+ @heap = []
22
10
  @mutex = Mutex.new
23
- @cond = ConditionVariable.new
24
11
  @stopped = false
25
- @thread = Thread.new { run_loop }
26
- @thread.name = "phronomy-timer-queue"
12
+ @wake = nil
13
+ end
14
+
15
+ # Installs a lightweight wake callback used when a newly scheduled timer
16
+ # may change EventLoop's current wait deadline.
17
+ def wake_with(&block)
18
+ @mutex.synchronize { @wake = block }
19
+ self
27
20
  end
28
21
 
29
- # Schedule a one-shot callback to fire after +seconds+ from now.
30
- #
31
- # @param seconds [Numeric] delay before the callback fires
32
- # @yield called (in the timer thread) when the deadline is reached
33
- # @return [self]
34
- # @api private
35
22
  def schedule(seconds:, &callback)
23
+ raise ArgumentError, "schedule requires a block" unless callback
24
+
36
25
  fire_at = @clock.call + seconds.to_f
26
+ wake = nil
37
27
  @mutex.synchronize do
38
28
  raise Phronomy::PoolShutdownError, "TimerQueue has been shut down" if @stopped
39
- insert_sorted(fire_at, callback)
40
- @cond.signal
29
+
30
+ previous_first = @heap.first&.first
31
+ @heap << [fire_at, callback]
32
+ @heap.sort_by!(&:first)
33
+ wake = @wake if previous_first.nil? || fire_at < previous_first
41
34
  end
35
+ wake&.call
42
36
  self
43
37
  end
44
38
 
45
- # Stop the background thread. Pending (un-fired) callbacks are discarded.
46
- #
47
- # @return [self]
48
- # @api private
49
- def shutdown
39
+ # Seconds until the next timer is due, nil when no timers are pending.
40
+ def seconds_until_next
50
41
  @mutex.synchronize do
51
- @stopped = true
52
- @cond.signal
42
+ return nil if @stopped || @heap.empty?
43
+ [@heap.first.first - @clock.call, 0.0].max
53
44
  end
54
- @thread.join
55
- self
56
- end
57
-
58
- # Number of pending (not yet fired) callbacks. Primarily for testing.
59
- # @return [Integer]
60
- # @api private
61
- def pending_count
62
- @mutex.synchronize { @heap.size }
63
45
  end
64
46
 
65
- private
47
+ # Executes all callbacks whose deadline is due. Must be called by EventLoop.
48
+ def fire_due
49
+ callbacks = @mutex.synchronize do
50
+ return 0 if @stopped
66
51
 
67
- def insert_sorted(fire_at, callback)
68
- @heap << [fire_at, callback]
69
- @heap.sort_by! { |(t, _)| t }
70
- end
52
+ now = @clock.call
53
+ due_count = @heap.bsearch_index { |(fire_at, _)| fire_at > now } || @heap.length
54
+ @heap.shift(due_count).map(&:last)
55
+ end
71
56
 
72
- def run_loop
73
- loop do
74
- callback = next_callback
75
- break if callback == :stopped
76
- begin
77
- callback&.call
78
- rescue => e
79
- Phronomy.configuration.logger&.error { "[TimerQueue] callback raised #{e.class}: #{e.message}" }
57
+ callbacks.each do |callback|
58
+ callback.call
59
+ rescue => error
60
+ Phronomy.configuration.logger&.error do
61
+ "[TimerQueue] callback raised #{error.class}: #{error.message}"
80
62
  end
81
63
  end
64
+ callbacks.length
82
65
  end
83
66
 
84
- def next_callback
85
- @mutex.synchronize do
86
- loop do
87
- return :stopped if @stopped
67
+ def pending_count
68
+ @mutex.synchronize { @heap.size }
69
+ end
88
70
 
89
- if @heap.empty?
90
- @cond.wait(@mutex)
91
- else
92
- now = @clock.call
93
- fire_at, = @heap.first
94
- if fire_at <= now
95
- return @heap.shift[1]
96
- else
97
- remaining = fire_at - now
98
- @cond.wait(@mutex, remaining)
99
- end
100
- end
101
- end
71
+ def shutdown
72
+ wake = @mutex.synchronize do
73
+ return self if @stopped
74
+ @stopped = true
75
+ @heap.clear
76
+ @wake
102
77
  end
78
+ wake&.call
79
+ self
103
80
  end
104
81
  end
105
82
  end
@@ -2,38 +2,30 @@
2
2
 
3
3
  module Phronomy
4
4
  class Runtime
5
- # Lazy-initialised timer service for a {Runtime} instance.
6
- #
7
- # Returns a {SchedulerTimerAdapter} when the backing scheduler is a
8
- # {DeterministicScheduler} (enabling virtual-time integration for the
9
- # `:fiber` backend), or a standard {TimerQueue} (OS-thread backed) for all
10
- # other schedulers.
11
- # @api private
5
+ # Lazy owner of the Runtime's threadless timer queue.
12
6
  class TimerService
13
- # @param scheduler [Scheduler]
14
- # @api private
15
- def initialize(scheduler)
16
- @scheduler = scheduler
7
+ def initialize
17
8
  @mutex = Mutex.new
18
9
  @timer = nil
10
+ @waker = nil
19
11
  end
20
12
 
21
- # Returns (or lazily creates) the timer queue for this runtime.
22
- # @return [TimerQueue, SchedulerTimerAdapter]
23
- # @api private
24
13
  def timer_queue
25
14
  @mutex.synchronize do
26
- @timer ||= if @scheduler.is_a?(DeterministicScheduler)
27
- SchedulerTimerAdapter.new(@scheduler)
28
- else
29
- TimerQueue.new
15
+ @timer ||= TimerQueue.new.tap do |timer|
16
+ timer.wake_with(&@waker) if @waker
30
17
  end
31
18
  end
32
19
  end
33
20
 
34
- # Shuts down the timer queue if it was started.
35
- # @return [void]
36
- # @api private
21
+ def wake_with(&block)
22
+ @mutex.synchronize do
23
+ @waker = block
24
+ @timer&.wake_with(&block)
25
+ end
26
+ self
27
+ end
28
+
37
29
  def shutdown
38
30
  @mutex.synchronize { @timer&.shutdown }
39
31
  end
@@ -1,25 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "runtime/scheduler"
4
- require_relative "runtime/thread_scheduler"
5
- require_relative "runtime/fake_scheduler"
6
- require_relative "runtime/deterministic_scheduler"
7
3
  require_relative "runtime/timer_queue"
8
- require_relative "runtime/scheduler_timer_adapter"
9
- require_relative "runtime/task_registry"
10
- require_relative "runtime/runtime_metrics"
11
4
  require_relative "runtime/shutdown_result"
12
5
  require_relative "runtime/timer_service"
13
6
 
14
7
  module Phronomy
15
- # Central authority for concurrent primitives.
8
+ # Owns the EventLoop, offloaded synchronous work, timers and shutdown lifecycle.
9
+ #
10
+ # Runtime no longer schedules arbitrary Tasks. Framework control flow belongs
11
+ # to EventLoop/FSMSession; synchronous work that must not run on the EventLoop
12
+ # belongs to OffloadPool.
16
13
  class Runtime
17
14
  @instance_mutex = Mutex.new
18
15
 
19
16
  class << self
20
17
  def instance
21
18
  instance_mutex.synchronize do
22
- @instance ||= build_default_runtime
19
+ @instance ||= new
23
20
  end
24
21
  end
25
22
 
@@ -65,55 +62,14 @@ module Phronomy
65
62
  def instance_mutex
66
63
  @instance_mutex ||= Mutex.new
67
64
  end
68
-
69
- def build_default_runtime
70
- scheduler = case Phronomy.configuration.runtime_backend
71
- when :thread
72
- ThreadScheduler.new
73
- when :immediate
74
- FakeScheduler.new
75
- when :fiber
76
- Phronomy.configuration.logger&.warn(
77
- "[phronomy] runtime_backend: :fiber uses DeterministicScheduler in autorun mode. " \
78
- "This is an EXPERIMENTAL Fiber-based cooperative scheduler. " \
79
- "Wall-clock timer integration is available via SchedulerTimerAdapter (Issues #331, #337). " \
80
- "Not recommended for production use."
81
- )
82
- DeterministicScheduler.new(autorun: true)
83
- else
84
- raise Phronomy::ConfigurationError,
85
- "unknown runtime_backend: #{Phronomy.configuration.runtime_backend.inspect}"
86
- end
87
- new(scheduler: scheduler)
88
- end
89
- end
90
-
91
- def self.in_scheduler_context?
92
- !Task.current.nil?
93
- end
94
-
95
- def self.measure_ms
96
- t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
97
- result = yield
98
- elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round
99
- [result, elapsed_ms]
100
- end
101
-
102
- attr_reader :scheduler
103
-
104
- def state
105
- @lifecycle_mutex.synchronize { @state }
106
65
  end
107
66
 
108
- def initialize(scheduler: ThreadScheduler.new)
109
- @scheduler = scheduler
110
- @event_loop_scheduler = ThreadScheduler.new
111
- @task_registry = TaskRegistry.new
112
- @metrics = RuntimeMetrics.new
113
- @timer_service = TimerService.new(scheduler)
67
+ def initialize
68
+ @timer_service = TimerService.new
114
69
  @pool_registry = Phronomy::Concurrency::PoolRegistry.new(
115
- timer_queue_provider: -> { @timer_service.timer_queue }
70
+ timer_queue_provider: -> { timer_queue }
116
71
  )
72
+ @agent_activations = Phronomy::Agent::ActivationRegistry.new
117
73
  @lifecycle_mutex = Mutex.new
118
74
  @shutdown_mutex = Mutex.new
119
75
  @state = :running
@@ -122,73 +78,13 @@ module Phronomy
122
78
  @shutdown_result = nil
123
79
  end
124
80
 
125
- def yield
126
- if (threshold = Phronomy.configuration.blocking_detect_threshold_ms)
127
- slice_start = Task.current_cpu_slice_start_ms
128
- if slice_start
129
- elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - slice_start
130
- if elapsed > threshold
131
- name = Task.current&.name || "unknown"
132
- Phronomy.configuration.logger&.warn(
133
- "[Phronomy] CPU-bound task detected: '#{name}' ran #{elapsed.round}ms " \
134
- "without yielding (threshold: #{threshold}ms)"
135
- )
136
- @metrics.increment_starvation
137
- end
138
- end
139
- end
140
- Task.record_yield!
141
- @scheduler.yield
142
- end
143
-
144
- def non_yield_threshold_violation_count
145
- @metrics.starvation_count
146
- end
147
-
148
- def yield_if_needed(every: 1000)
149
- self.yield if (Task.increment_yield_counter! % every).zero?
150
- end
151
-
152
- def task_group(limit: Float::INFINITY, failure_policy: :fail_fast)
153
- ensure_accepting_work!
154
- TaskGroup.new(limit: limit, failure_policy: failure_policy, runtime: self)
155
- end
156
-
157
- def spawn(name: nil, &block)
158
- ensure_accepting_work!
159
- type = _task_type(name)
160
- spawn_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
161
- @metrics.record_start(type)
162
-
163
- task = @scheduler.spawn(name: name, parent: Task.current) do
164
- run_start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
165
- @metrics.record_wait(run_start - spawn_at)
166
- begin
167
- result = block.call
168
- @metrics.record_end(type, :completed, run_start)
169
- result
170
- rescue CancellationError
171
- @metrics.record_end(type, :cancelled, run_start)
172
- raise
173
- rescue => error
174
- @metrics.record_end(type, :failed, run_start)
175
- raise error
176
- ensure
177
- current = Task.current
178
- @task_registry.deregister(current) if current
179
- end
180
- end
181
- @task_registry.register(task)
182
- task
183
- end
184
-
185
- def task_snapshot
186
- @metrics.snapshot
81
+ def state
82
+ @lifecycle_mutex.synchronize { @state }
187
83
  end
188
84
 
189
- def blocking_io(
190
- pool_size: Phronomy.configuration.blocking_io_pool_size,
191
- queue_size: Phronomy.configuration.blocking_io_queue_size
85
+ def offload(
86
+ pool_size: Phronomy.configuration.offload_pool_size,
87
+ queue_size: Phronomy.configuration.offload_queue_size
192
88
  )
193
89
  ensure_accepting_work!
194
90
  @pool_registry.default_pool(pool_size: pool_size, queue_size: queue_size)
@@ -199,16 +95,34 @@ module Phronomy
199
95
  @pool_registry.named_pool(name, size: size, queue_size: queue_size)
200
96
  end
201
97
 
98
+ # Public timer access also ensures that the EventLoop that drives timers is alive.
202
99
  def timer_queue
203
100
  ensure_accepting_work!
101
+ timer = @timer_service.timer_queue
102
+ event_loop
103
+ timer
104
+ end
105
+
106
+ # Internal EventLoop access that does not recursively initialise EventLoop.
107
+ def __timer_queue
204
108
  @timer_service.timer_queue
205
109
  end
206
110
 
111
+ # Process-local live Agent executions. Activations are transient runtime
112
+ # state and deliberately do not belong to Persistence.
113
+ def __agent_activations
114
+ @agent_activations
115
+ end
116
+
207
117
  def event_loop
208
118
  @lifecycle_mutex.synchronize do
209
119
  case @state
210
120
  when :running
211
- @event_loop ||= EventLoop.new(runtime: self)
121
+ unless @event_loop
122
+ @event_loop = EventLoop.new(runtime: self)
123
+ @timer_service.wake_with { @event_loop&.wake }
124
+ end
125
+ @event_loop
212
126
  when :draining
213
127
  return @event_loop if @event_loop
214
128
 
@@ -222,12 +136,8 @@ module Phronomy
222
136
  end
223
137
 
224
138
  def event_loop_current?
225
- event_loop = @lifecycle_mutex.synchronize { @event_loop }
226
- event_loop&.current? || false
227
- end
228
-
229
- def __spawn_event_loop_service(&block)
230
- @event_loop_scheduler.spawn(name: "event-loop", parent: nil, &block)
139
+ loop_instance = @lifecycle_mutex.synchronize { @event_loop }
140
+ loop_instance&.current? || false
231
141
  end
232
142
 
233
143
  def __event_loop_failed(error)
@@ -243,7 +153,7 @@ module Phronomy
243
153
  timeout: Phronomy.configuration.event_loop_stop_grace_seconds,
244
154
  cancel_grace: timeout
245
155
  )
246
- if Phronomy::Task.current
156
+ if event_loop_current?
247
157
  raise Phronomy::RuntimeShutdownReentrancyError,
248
158
  "Runtime#shutdown must be called from an external management thread"
249
159
  end
@@ -253,29 +163,33 @@ module Phronomy
253
163
  @shutdown_mutex.synchronize do
254
164
  return @shutdown_result if @shutdown_result
255
165
 
256
- deadline = monotonic_now + timeout
257
- event_loop = @lifecycle_mutex.synchronize do
166
+ # Phase 1 drain sessions with the full configured grace.
167
+ drain_deadline = monotonic_now + timeout
168
+ loop_instance = @lifecycle_mutex.synchronize do
258
169
  @state = :draining unless @state == :failed
259
170
  @event_loop
260
171
  end
261
- event_loop&.begin_draining
172
+ loop_instance&.begin_draining
262
173
 
263
- task_status = drain_runtime_work(event_loop, deadline)
174
+ loop_idle = !loop_instance || loop_instance.wait_until_idle(drain_deadline)
264
175
 
265
176
  @lifecycle_mutex.synchronize do
266
177
  @state = :stopping unless @state == :failed
267
178
  end
268
179
 
269
- event_loop_status = if event_loop
270
- event_loop.shutdown(deadline: deadline, cancel_grace: cancel_grace)
180
+ # Phase 2 — stop the EventLoop thread with a short independent budget.
181
+ # An idle EventLoop processes STOP and exits in < 1ms normally; the 0.2s
182
+ # budget here is only a safety net for OS scheduling jitter.
183
+ stop_deadline = monotonic_now + [cancel_grace.to_f, 0.2].max
184
+ event_loop_status = if loop_instance
185
+ loop_instance.stop_and_join(deadline: stop_deadline)
271
186
  else
272
187
  :not_started
273
188
  end
274
189
 
275
190
  subsystem_error = shutdown_pools_and_timer
276
- final_task_status = @task_registry.empty? ? :empty : task_status
277
- cleanup_complete = final_task_status == :empty &&
278
- (!event_loop || !event_loop.task_alive?) &&
191
+ cleanup_complete = loop_idle &&
192
+ (!loop_instance || !loop_instance.thread_alive?) &&
279
193
  event_loop_status != :cancel_timeout &&
280
194
  subsystem_error.nil?
281
195
 
@@ -285,11 +199,12 @@ module Phronomy
285
199
  else
286
200
  :terminated
287
201
  end
202
+
288
203
  result = ShutdownResult.new(
289
204
  runtime_outcome: runtime_outcome,
290
205
  cleanup_status: cleanup_complete ? :complete : :incomplete,
291
206
  event_loop_status: event_loop_status,
292
- task_registry_status: final_task_status,
207
+ task_registry_status: :empty,
293
208
  error: failure
294
209
  )
295
210
 
@@ -311,17 +226,6 @@ module Phronomy
311
226
  "Runtime is #{current_state}; new work is not accepted"
312
227
  end
313
228
 
314
- def drain_runtime_work(event_loop, deadline)
315
- loop do
316
- task_status = @task_registry.drain_until(deadline)
317
- return :timeout if task_status == :timeout
318
-
319
- event_loop_idle = !event_loop || event_loop.wait_until_idle(deadline)
320
- return :timeout unless event_loop_idle
321
- return :empty if @task_registry.empty?
322
- end
323
- end
324
-
325
229
  def shutdown_pools_and_timer
326
230
  error = nil
327
231
  begin
@@ -347,15 +251,5 @@ module Phronomy
347
251
  def monotonic_now
348
252
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
349
253
  end
350
-
351
- TASK_TYPE_PREFIXES = %w[agent tool workflow rag llm vector].freeze
352
- private_constant :TASK_TYPE_PREFIXES
353
-
354
- def _task_type(name)
355
- return :other if name.nil?
356
-
357
- prefix = TASK_TYPE_PREFIXES.find { |candidate| name.to_s.start_with?("#{candidate}-") }
358
- prefix ? prefix.to_sym : :other
359
- end
360
254
  end
361
255
  end