phronomy 0.16.0 → 0.18.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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +151 -1134
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +69 -1249
  6. data/benchmark/baseline.json +2 -3
  7. data/benchmark/bench_agent_invoke.rb +4 -4
  8. data/benchmark/bench_context_assembler.rb +134 -34
  9. data/benchmark/bench_regression.rb +26 -6
  10. data/benchmark/bench_tool_schema.rb +2 -35
  11. data/docs/changelog/0.14-and-earlier.md +1137 -0
  12. data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
  13. data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
  14. data/docs/decisions/010-cooperative-first-concurrency.md +155 -228
  15. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +2 -2
  16. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  17. data/docs/features.md +87 -0
  18. data/docs/getting-started.md +351 -0
  19. data/docs/migrations/0.15.md +35 -0
  20. data/docs/migrations/0.16.md +43 -0
  21. data/docs/runtime-and-concurrency.md +258 -0
  22. data/examples/workflows/generic_task_event_mapping.rb +14 -6
  23. data/lib/phronomy/agent/agent_invocation.rb +2 -36
  24. data/lib/phronomy/agent/agent_invocation_session_builder.rb +157 -94
  25. data/lib/phronomy/agent/agent_root.rb +1 -2
  26. data/lib/phronomy/agent/async_event_api.rb +3 -3
  27. data/lib/phronomy/agent/base.rb +170 -333
  28. data/lib/phronomy/agent/context/capability/base.rb +177 -298
  29. data/lib/phronomy/agent/context_assembler.rb +65 -29
  30. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
  31. data/lib/phronomy/agent/context_plan_validator.rb +0 -33
  32. data/lib/phronomy/agent/execution_coordinator.rb +6 -7
  33. data/lib/phronomy/agent/journal_projection.rb +28 -2
  34. data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
  35. data/lib/phronomy/agent/shared_state.rb +48 -138
  36. data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
  37. data/lib/phronomy/agent/tool_executor.rb +29 -71
  38. data/lib/phronomy/agent/tool_invocation.rb +180 -336
  39. data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
  40. data/lib/phronomy/agent.rb +6 -10
  41. data/lib/phronomy/configuration.rb +4 -171
  42. data/lib/phronomy/diagnostics.rb +12 -41
  43. data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
  44. data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
  45. data/lib/phronomy/engine/concurrency/cancellation_token.rb +51 -79
  46. data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
  47. data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
  48. data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
  49. data/lib/phronomy/engine/event_loop.rb +89 -190
  50. data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
  51. data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
  52. data/lib/phronomy/engine/runtime.rb +49 -377
  53. data/lib/phronomy/engine/task.rb +136 -277
  54. data/lib/phronomy/llm_adapter/base.rb +14 -14
  55. data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
  56. data/lib/phronomy/llm_adapter.rb +2 -2
  57. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  58. data/lib/phronomy/metrics.rb +15 -30
  59. data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
  60. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
  61. data/lib/phronomy/multi_agent/handoff.rb +1 -0
  62. data/lib/phronomy/multi_agent/orchestrator.rb +252 -256
  63. data/lib/phronomy/multi_agent/team_coordinator.rb +44 -133
  64. data/lib/phronomy/testing/eval/comparison.rb +23 -0
  65. data/lib/phronomy/testing/eval/dataset.rb +27 -0
  66. data/lib/phronomy/testing/eval/eval_case.rb +13 -0
  67. data/lib/phronomy/testing/eval/eval_result.rb +16 -0
  68. data/lib/phronomy/testing/eval/metrics.rb +43 -0
  69. data/lib/phronomy/testing/eval/runner.rb +52 -0
  70. data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
  71. data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
  72. data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
  73. data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
  74. data/lib/phronomy/testing/eval/scorer.rb +10 -0
  75. data/lib/phronomy/testing/eval.rb +9 -0
  76. data/lib/phronomy/testing/fake_clock.rb +6 -53
  77. data/lib/phronomy/testing.rb +2 -6
  78. data/lib/phronomy/tools/agent.rb +141 -6
  79. data/lib/phronomy/vector_store/async_backend.rb +21 -17
  80. data/lib/phronomy/vector_store/base.rb +2 -2
  81. data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
  82. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  83. data/lib/phronomy/version.rb +1 -1
  84. data/lib/phronomy/workflow_runner.rb +2 -4
  85. data/lib/phronomy.rb +7 -121
  86. data/scripts/api_snapshot.rb +4 -15
  87. metadata +24 -38
  88. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  89. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  90. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  91. data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
  92. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
  93. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
  94. data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
  95. data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
  96. data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
  97. data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
  98. data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
  99. data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
  100. data/lib/phronomy/engine/task/backend.rb +0 -80
  101. data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
  102. data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
  103. data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
  104. data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
  105. data/lib/phronomy/engine/task/thread_backend.rb +0 -84
  106. data/lib/phronomy/engine/task_group.rb +0 -265
  107. data/lib/phronomy/eval/comparison.rb +0 -47
  108. data/lib/phronomy/eval/dataset.rb +0 -45
  109. data/lib/phronomy/eval/eval_case.rb +0 -17
  110. data/lib/phronomy/eval/eval_result.rb +0 -29
  111. data/lib/phronomy/eval/metrics.rb +0 -66
  112. data/lib/phronomy/eval/runner.rb +0 -94
  113. data/lib/phronomy/eval/scorer/base.rb +0 -22
  114. data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
  115. data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
  116. data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
  117. data/lib/phronomy/eval/scorer.rb +0 -9
  118. data/lib/phronomy/eval.rb +0 -7
  119. data/lib/phronomy/knowledge_source.rb +0 -12
  120. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  121. data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
  122. data/lib/phronomy/testing/fake_scheduler.rb +0 -104
  123. data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
@@ -1,73 +1,29 @@
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.
16
9
  #
17
- # +Runtime+ is the single place that creates {Task}s, {TaskGroup}s, and
18
- # manages the lifecycle of all concurrency in Phronomy. It owns:
19
- #
20
- # * a pluggable {Scheduler} (default: {ThreadScheduler})
21
- # * a task registry for graceful shutdown
22
- # * the shared {BlockingAdapterPool}
23
- #
24
- # In production, use the process-wide singleton via {.instance}.
25
- # In tests, construct a Runtime with a {FakeScheduler} to run tasks
26
- # synchronously without spawning additional threads:
27
- #
28
- # @example Production usage
29
- # group = Phronomy::Runtime.instance.task_group(limit: 4)
30
- # tools.each { |t| group.spawn { t.call } }
31
- # results = group.await_all
32
- #
33
- # @example Test usage — no extra threads
34
- # runtime = Phronomy::Runtime.new(scheduler: Phronomy::Runtime::FakeScheduler.new)
35
- # task = runtime.spawn { 42 }
36
- # expect(task.wait_result).to eq(42)
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.
37
13
  class Runtime
38
- # Returns the process-wide default Runtime.
39
- #
40
- # Auto-creates an instance using the scheduler backend specified by
41
- # +Phronomy.configuration.runtime_backend+:
42
- # - +:thread+ (default) — {ThreadScheduler} (one OS thread per task)
43
- # - +:immediate+ — {FakeScheduler} (synchronous, no extra threads)
44
- # - +:fiber+ — {DeterministicScheduler} in autorun mode (EXPERIMENTAL;
45
- # Fiber-based synchronous execution; not yet suitable for production
46
- # because it uses virtual time rather than real wall-clock timers)
47
- # - +:cooperative+ — deprecated alias for +:immediate+
48
- #
49
- # @return [Runtime]
50
- # @api private
51
14
  @instance_mutex = Mutex.new
52
15
 
53
16
  class << self
54
17
  def instance
55
18
  instance_mutex.synchronize do
56
- @instance ||= build_default_runtime
19
+ @instance ||= new
57
20
  end
58
21
  end
59
22
 
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
23
  def default_if_initialized_for_test
67
24
  instance_mutex.synchronize { @instance }
68
25
  end
69
26
 
70
- # Test-only replacement. The caller owns both Runtime lifecycles.
71
27
  def replace_default_for_test(runtime)
72
28
  instance_mutex.synchronize do
73
29
  previous = @instance
@@ -76,7 +32,6 @@ module Phronomy
76
32
  end
77
33
  end
78
34
 
79
- # Test-only restoration of a previously captured Runtime.
80
35
  def restore_default_for_test(runtime)
81
36
  instance_mutex.synchronize { @instance = runtime }
82
37
  end
@@ -97,7 +52,6 @@ module Phronomy
97
52
  result
98
53
  end
99
54
 
100
- # Does not create a Runtime or EventLoop.
101
55
  def in_event_loop_context?
102
56
  runtime = instance_mutex.synchronize { @instance }
103
57
  runtime&.event_loop_current? || false
@@ -108,85 +62,12 @@ module Phronomy
108
62
  def instance_mutex
109
63
  @instance_mutex ||= Mutex.new
110
64
  end
111
-
112
- def build_default_runtime
113
- scheduler = case Phronomy.configuration.runtime_backend
114
- when :cooperative
115
- Phronomy.configuration.logger&.warn(
116
- "[phronomy] runtime_backend: :cooperative is a deprecated alias for :immediate. " \
117
- "Use :immediate for synchronous/test execution. " \
118
- ":cooperative will be reassigned when a real cooperative Fiber-based scheduler is available."
119
- )
120
- FakeScheduler.new
121
- when :immediate
122
- FakeScheduler.new
123
- when :fiber
124
- Phronomy.configuration.logger&.warn(
125
- "[phronomy] runtime_backend: :fiber uses DeterministicScheduler in autorun mode. " \
126
- "This is an EXPERIMENTAL Fiber-based cooperative scheduler. " \
127
- "Wall-clock timer integration is available via SchedulerTimerAdapter (Issues #331, #337). " \
128
- "Not recommended for production use."
129
- )
130
- DeterministicScheduler.new(autorun: true)
131
- else
132
- ThreadScheduler.new
133
- end
134
- new(scheduler: scheduler)
135
- end
136
- end
137
-
138
- # Returns +true+ when the calling thread is executing inside an active
139
- # scheduler task (i.e. {Task.current} is non-nil). Code running inside
140
- # a {Runtime#spawn} block is always in a scheduler context.
141
- #
142
- # Use this to detect potential scheduler-blocking calls:
143
- # if Phronomy::Runtime.in_scheduler_context?
144
- # Phronomy.configuration.logger&.warn("blocking call inside scheduler task")
145
- # end
146
- #
147
- # @return [Boolean]
148
- # @api private
149
- def self.in_scheduler_context?
150
- !Task.current.nil?
151
- end
152
-
153
- # Executes +block+ and returns +[result, elapsed_ms]+ where +elapsed_ms+
154
- # is the wall-clock duration in milliseconds (Integer, rounded).
155
- #
156
- # Isolates all direct references to +Process.clock_gettime+ /
157
- # +Process::CLOCK_MONOTONIC+ in one place so that callers stay at the
158
- # framework abstraction level.
159
- #
160
- # @yield block to time
161
- # @return [Array(Object, Integer)] +[block_return_value, elapsed_ms]+
162
- # @api private
163
- def self.measure_ms
164
- t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
165
- result = yield
166
- elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round
167
- [result, elapsed_ms]
168
65
  end
169
66
 
170
- # The scheduler backing this runtime instance.
171
- # @return [Scheduler]
172
- attr_reader :scheduler
173
-
174
- # @return [Symbol] current Runtime lifecycle state
175
- # @api private
176
- def state
177
- @lifecycle_mutex.synchronize { @state }
178
- end
179
-
180
- # @param scheduler [Scheduler] execution backend (default: {ThreadScheduler})
181
- # @api private
182
- def initialize(scheduler: ThreadScheduler.new)
183
- @scheduler = scheduler
184
- @event_loop_scheduler = ThreadScheduler.new
185
- @task_registry = TaskRegistry.new
186
- @metrics = RuntimeMetrics.new
187
- @timer_service = TimerService.new(scheduler)
67
+ def initialize
68
+ @timer_service = TimerService.new
188
69
  @pool_registry = Phronomy::Concurrency::PoolRegistry.new(
189
- timer_queue_provider: -> { @timer_service.timer_queue }
70
+ timer_queue_provider: -> { timer_queue }
190
71
  )
191
72
  @lifecycle_mutex = Mutex.new
192
73
  @shutdown_mutex = Mutex.new
@@ -196,220 +77,45 @@ module Phronomy
196
77
  @shutdown_result = nil
197
78
  end
198
79
 
199
- # Cooperative yield point.
200
- #
201
- # Signals the scheduler that the current task is willing to give up CPU time
202
- # so that other ready tasks can run. On the default {ThreadScheduler} this
203
- # calls +Thread.pass+. On a future fiber-based scheduler this would switch
204
- # to the next runnable fiber.
205
- #
206
- # When +blocking_detect_threshold_ms+ is configured, checks whether the
207
- # current task has exceeded that threshold without yielding; if so, emits a
208
- # warning via the configured logger and increments
209
- # +non_yield_threshold_violation_count+.
210
- #
211
- # Call this inside tight loops or CPU-intensive sections of tool +execute+
212
- # methods and Workflow actions to keep the scheduler responsive.
213
- #
214
- # @return [void]
215
- # @api private
216
- def yield
217
- if (threshold = Phronomy.configuration.blocking_detect_threshold_ms)
218
- slice_start = Task.current_cpu_slice_start_ms
219
- if slice_start
220
- elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - slice_start
221
- if elapsed > threshold
222
- name = Task.current&.name || "unknown"
223
- Phronomy.configuration.logger&.warn(
224
- "[Phronomy] CPU-bound task detected: '#{name}' ran #{elapsed.round}ms " \
225
- "without yielding (threshold: #{threshold}ms)"
226
- )
227
- @metrics.increment_starvation
228
- end
229
- end
230
- end
231
- Task.record_yield!
232
- @scheduler.yield
233
- end
234
-
235
- # Number of times a task has exceeded the CPU-bound detection threshold
236
- # (i.e. ran longer than +blocking_detect_threshold_ms+ without yielding).
237
- # Resets to 0 when the Runtime is recreated.
238
- # @return [Integer]
239
- # @api private
240
- def non_yield_threshold_violation_count
241
- @metrics.starvation_count
242
- end
243
-
244
- # Cooperative yield point with a call-count gate.
245
- #
246
- # Increments a per-thread counter and calls {#yield} when the counter
247
- # reaches a multiple of +every+. The counter is thread-local so concurrent
248
- # tasks each maintain their own independent loop counter without requiring
249
- # a mutex.
250
- #
251
- # @example
252
- # data.each_with_index do |row, i|
253
- # process(row)
254
- # Phronomy::Runtime.instance.yield_if_needed(every: 500)
255
- # end
256
- #
257
- # @param every [Integer] yield once every N calls (default: 1000)
258
- # @return [void]
259
- # @api private
260
- def yield_if_needed(every: 1000)
261
- # Delegate Thread.current access to Task so that runtime.rb stays outside
262
- # the Thread.current allowlist (Issue #302).
263
- self.yield if (Task.increment_yield_counter! % every).zero?
264
- end
265
-
266
- # Creates a new {TaskGroup} with an optional concurrency cap.
267
- #
268
- # @param limit [Integer, Float::INFINITY] max simultaneous tasks
269
- # @param failure_policy [Symbol] one of :fail_fast, :collect_all, :skip_failed (default :fail_fast)
270
- # @return [TaskGroup]
271
- # @api private
272
- def task_group(limit: Float::INFINITY, failure_policy: :fail_fast)
273
- ensure_accepting_work!
274
- TaskGroup.new(limit: limit, failure_policy: failure_policy, runtime: self)
275
- end
276
-
277
- # Spawns a single {Task} using the runtime's scheduler.
278
- #
279
- # The spawned task is registered in the task registry so {#shutdown}
280
- # can wait for it to complete. The task is automatically deregistered
281
- # from the registry when it finishes (success, failure, or cancellation)
282
- # so long-lived runtimes do not accumulate stale references.
283
- #
284
- # Task names beginning with a recognised type prefix are counted in the
285
- # task-centric metrics returned by {#task_snapshot}. Recognised prefixes:
286
- # +agent-+, +tool-+, +workflow-+, +rag-+, +llm-+, +vector-+.
287
- #
288
- # @param name [String, nil] optional label for debugging
289
- # @yield block to execute (concurrently or synchronously, depending on
290
- # the configured scheduler)
291
- # @return [Task]
292
- # @api private
293
- def spawn(name: nil, &block)
294
- ensure_accepting_work!
295
- type = _task_type(name)
296
- spawn_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
297
- @metrics.record_start(type)
298
-
299
- task = @scheduler.spawn(name: name, parent: Task.current) do
300
- run_start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
301
- @metrics.record_wait(run_start - spawn_at)
302
- begin
303
- result = block.call
304
- @metrics.record_end(type, :completed, run_start)
305
- result
306
- rescue CancellationError
307
- @metrics.record_end(type, :cancelled, run_start)
308
- raise
309
- rescue => e
310
- @metrics.record_end(type, :failed, run_start)
311
- raise e
312
- ensure
313
- current = Task.current
314
- @task_registry.deregister(current) if current
315
- end
316
- end
317
- @task_registry.register(task)
318
- task
319
- end
320
-
321
- # Returns a snapshot of task-centric metrics for the current Runtime.
322
- #
323
- # | Key | Description |
324
- # |-----|-------------|
325
- # | `active_agent_tasks` | currently running agent spawns |
326
- # | `active_tool_tasks` | currently running tool spawns |
327
- # | `active_workflow_tasks` | currently running workflow spawns |
328
- # | `active_llm_tasks` | currently running LLM calls |
329
- # | `task_wait_time_p50_ms` | p50 spawn-to-start latency (ms) |
330
- # | `task_wait_time_p95_ms` | p95 spawn-to-start latency (ms) |
331
- # | `task_run_time_p50_ms` | p50 execution duration (ms) |
332
- # | `task_run_time_p95_ms` | p95 execution duration (ms) |
333
- # | `cancelled_tasks` | total cancelled task count |
334
- # | `failed_tasks` | total failed task count |
335
- # | `non_yield_threshold_violation_count` | cumulative count of tasks that ran past `blocking_detect_threshold_ms` without yielding |
336
- #
337
- # @return [Hash{Symbol => Numeric}]
338
- # @api private
339
- def task_snapshot
340
- @metrics.snapshot
80
+ def state
81
+ @lifecycle_mutex.synchronize { @state }
341
82
  end
342
83
 
343
- # Returns the shared {BlockingAdapterPool} for this Runtime.
344
- # All blocking I/O (LLM HTTP, MCP, ActiveRecord, Redis) should be
345
- # submitted through this pool.
346
- #
347
- # Pool settings default to 10 workers / 100-deep queue. Override by
348
- # constructing a Runtime with custom pool options or by replacing the
349
- # shared Runtime via {.instance=} in tests.
350
- #
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)
355
- # @return [BlockingAdapterPool]
356
- # @api private
357
- def blocking_io(pool_size: Phronomy.configuration.blocking_io_pool_size,
358
- queue_size: Phronomy.configuration.blocking_io_queue_size)
84
+ def offload(
85
+ pool_size: Phronomy.configuration.offload_pool_size,
86
+ queue_size: Phronomy.configuration.offload_queue_size
87
+ )
359
88
  ensure_accepting_work!
360
89
  @pool_registry.default_pool(pool_size: pool_size, queue_size: queue_size)
361
90
  end
362
91
 
363
- # Returns (or lazily creates) a named {BlockingAdapterPool}.
364
- #
365
- # Named pools allow per-subsystem thread-budget control and observability.
366
- # Recommended pool names: +:llm+, +:mcp+, +:db+, +:redis+, +:tool+.
367
- # Each pool gets its own dedicated worker threads labelled with the pool name.
368
- #
369
- # @example
370
- # runtime.pool(:llm) # default size (10 workers)
371
- # runtime.pool(:db, size: 20) # custom size
372
- #
373
- # @param name [Symbol, String] pool identifier
374
- # @param size [Integer] worker thread count (default: 10)
375
- # @param queue_size [Integer] max pending operations (default: 100)
376
- # @return [BlockingAdapterPool]
377
- # @api private
378
92
  def pool(name, size: 10, queue_size: 100)
379
93
  ensure_accepting_work!
380
94
  @pool_registry.named_pool(name, size: size, queue_size: queue_size)
381
95
  end
382
96
 
383
- # Returns the shared timer queue for this Runtime.
384
- #
385
- # When the scheduler is a {DeterministicScheduler} (e.g. the +:fiber+
386
- # runtime backend), returns a {SchedulerTimerAdapter} that integrates with
387
- # the scheduler's tick cycle instead of spawning a background OS thread.
388
- # This is the first concrete step of the TimerQueue scheduler-tick integration
389
- # described in ADR-010 (Issue #331).
390
- #
391
- # For all other schedulers, returns a {TimerQueue} backed by a single
392
- # background thread.
393
- #
394
- # All deadline-based cancellation should be registered here instead of
395
- # spawning one-off sleep threads. Lazily created on first access.
396
- #
397
- # @return [TimerQueue, SchedulerTimerAdapter]
398
- # @api private
97
+ # Public timer access also ensures that the EventLoop that drives timers is alive.
399
98
  def timer_queue
400
99
  ensure_accepting_work!
100
+ timer = @timer_service.timer_queue
101
+ event_loop
102
+ timer
103
+ end
104
+
105
+ # Internal EventLoop access that does not recursively initialise EventLoop.
106
+ def __timer_queue
401
107
  @timer_service.timer_queue
402
108
  end
403
109
 
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
110
  def event_loop
409
111
  @lifecycle_mutex.synchronize do
410
112
  case @state
411
113
  when :running
412
- @event_loop ||= EventLoop.new(runtime: self)
114
+ unless @event_loop
115
+ @event_loop = EventLoop.new(runtime: self)
116
+ @timer_service.wake_with { @event_loop&.wake }
117
+ end
118
+ @event_loop
413
119
  when :draining
414
120
  return @event_loop if @event_loop
415
121
 
@@ -422,22 +128,11 @@ module Phronomy
422
128
  end
423
129
  end
424
130
 
425
- # Does not create an EventLoop.
426
- # @api private
427
131
  def event_loop_current?
428
- event_loop = @lifecycle_mutex.synchronize { @event_loop }
429
- event_loop&.current? || false
132
+ loop_instance = @lifecycle_mutex.synchronize { @event_loop }
133
+ loop_instance&.current? || false
430
134
  end
431
135
 
432
- # Internal EventLoop service spawn. Always uses a real OS thread and is
433
- # deliberately excluded from the normal TaskRegistry drain.
434
- # @api private
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
136
  def __event_loop_failed(error)
442
137
  @lifecycle_mutex.synchronize do
443
138
  return if @shutdown_result || @state == :terminated
@@ -447,18 +142,11 @@ module Phronomy
447
142
  end
448
143
  end
449
144
 
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
145
  def shutdown(
458
146
  timeout: Phronomy.configuration.event_loop_stop_grace_seconds,
459
147
  cancel_grace: timeout
460
148
  )
461
- if Phronomy::Task.current
149
+ if event_loop_current?
462
150
  raise Phronomy::RuntimeShutdownReentrancyError,
463
151
  "Runtime#shutdown must be called from an external management thread"
464
152
  end
@@ -468,29 +156,33 @@ module Phronomy
468
156
  @shutdown_mutex.synchronize do
469
157
  return @shutdown_result if @shutdown_result
470
158
 
471
- deadline = monotonic_now + timeout
472
- event_loop = @lifecycle_mutex.synchronize do
159
+ # Phase 1 drain sessions with the full configured grace.
160
+ drain_deadline = monotonic_now + timeout
161
+ loop_instance = @lifecycle_mutex.synchronize do
473
162
  @state = :draining unless @state == :failed
474
163
  @event_loop
475
164
  end
476
- event_loop&.begin_draining
165
+ loop_instance&.begin_draining
477
166
 
478
- task_status = drain_runtime_work(event_loop, deadline)
167
+ loop_idle = !loop_instance || loop_instance.wait_until_idle(drain_deadline)
479
168
 
480
169
  @lifecycle_mutex.synchronize do
481
170
  @state = :stopping unless @state == :failed
482
171
  end
483
172
 
484
- event_loop_status = if event_loop
485
- event_loop.shutdown(deadline: deadline, cancel_grace: cancel_grace)
173
+ # Phase 2 — stop the EventLoop thread with a short independent budget.
174
+ # An idle EventLoop processes STOP and exits in < 1ms normally; the 0.2s
175
+ # budget here is only a safety net for OS scheduling jitter.
176
+ stop_deadline = monotonic_now + [cancel_grace.to_f, 0.2].max
177
+ event_loop_status = if loop_instance
178
+ loop_instance.stop_and_join(deadline: stop_deadline)
486
179
  else
487
180
  :not_started
488
181
  end
489
182
 
490
183
  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?) &&
184
+ cleanup_complete = loop_idle &&
185
+ (!loop_instance || !loop_instance.thread_alive?) &&
494
186
  event_loop_status != :cancel_timeout &&
495
187
  subsystem_error.nil?
496
188
 
@@ -500,11 +192,12 @@ module Phronomy
500
192
  else
501
193
  :terminated
502
194
  end
195
+
503
196
  result = ShutdownResult.new(
504
197
  runtime_outcome: runtime_outcome,
505
198
  cleanup_status: cleanup_complete ? :complete : :incomplete,
506
199
  event_loop_status: event_loop_status,
507
- task_registry_status: final_task_status,
200
+ task_registry_status: :empty,
508
201
  error: failure
509
202
  )
510
203
 
@@ -526,28 +219,17 @@ module Phronomy
526
219
  "Runtime is #{current_state}; new work is not accepted"
527
220
  end
528
221
 
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
222
  def shutdown_pools_and_timer
541
223
  error = nil
542
224
  begin
543
225
  @pool_registry.shutdown
544
- rescue => e
545
- error ||= e
226
+ rescue => caught
227
+ error ||= caught
546
228
  ensure
547
229
  begin
548
230
  @timer_service.shutdown
549
- rescue => e
550
- error ||= e
231
+ rescue => caught
232
+ error ||= caught
551
233
  end
552
234
  end
553
235
  error
@@ -562,15 +244,5 @@ module Phronomy
562
244
  def monotonic_now
563
245
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
564
246
  end
565
-
566
- TASK_TYPE_PREFIXES = %w[agent tool workflow rag llm vector].freeze
567
- private_constant :TASK_TYPE_PREFIXES
568
-
569
- def _task_type(name)
570
- return :other if name.nil?
571
-
572
- prefix = TASK_TYPE_PREFIXES.find { |p| name.to_s.start_with?("#{p}-") }
573
- prefix ? prefix.to_sym : :other
574
- end
575
247
  end
576
248
  end