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
@@ -0,0 +1,696 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Concurrency
5
+ # A bounded, observable thread pool for synchronous work that must not run on
6
+ # the Runtime EventLoop.
7
+ #
8
+ # ## Architectural boundary
9
+ #
10
+ # `OffloadPool` is the bounded OS-thread execution boundary for synchronous
11
+ # operations that would otherwise occupy the EventLoop for too long. The work
12
+ # may be blocking I/O, CPU-bound Ruby processing, or another application-defined
13
+ # synchronous call. Phronomy deliberately does not classify the workload by
14
+ # cause; the application decides whether a unit of work is EventLoop-safe.
15
+ #
16
+ # Logical waits are different. Waiting for another Phronomy Task, Agent,
17
+ # Workflow, ToolInvocation, timer, or FSMSession must remain an explicit
18
+ # EventLoop/FSMSession continuation and must not consume an OffloadPool worker.
19
+ # See ADR-010.
20
+ #
21
+ # Submitted work is bounded so that:
22
+ #
23
+ # 1. The total number of worker OS threads is capped.
24
+ # 2. Queue depth is bounded (backpressure when the pool is saturated).
25
+ # 3. Per-operation timeouts and cancellation settle the caller-facing handle.
26
+ # 4. Operations that settle after worker execution has started are tracked as
27
+ # abandoned until that worker returns.
28
+ # 5. Metrics expose active work, queue depth, cumulative abandonment,
29
+ # currently-active abandonment, and average queue wait time.
30
+ #
31
+ # OffloadPool does not provide CPU isolation, CPU parallelism guarantees, or
32
+ # fairness between I/O and CPU-heavy work classes. Applications that need
33
+ # resource isolation may use named pools via {Runtime#pool}.
34
+ #
35
+ # @example Submitting synchronous work
36
+ # op = runtime.offload.submit(timeout: 30) { expensive_call }
37
+ # result = op.blocking_wait # blocks the calling thread until done
38
+ #
39
+ # @example With cancellation
40
+ # token = Phronomy::Concurrency::CancellationToken.timeout_after(60)
41
+ # op = pool.submit(timeout: 30, cancellation_token: token) { expensive_call }
42
+ # result = op.blocking_wait
43
+ class OffloadPool
44
+ # Represents the pending result of submitted offloaded work.
45
+ # Returned immediately by {OffloadPool#submit}; call {#blocking_wait}
46
+ # to synchronously wait from a non-EventLoop caller such as a low-level test.
47
+ class PendingOperation
48
+ # @return [Boolean] true when the caller-facing result has settled
49
+ # (success, failure, cancellation, or submit-time timeout)
50
+ # @api private
51
+ def done?
52
+ @mutex.synchronize { @done }
53
+ end
54
+
55
+ # @return [Boolean] true when the submit-time deadline settled the operation
56
+ # @api private
57
+ def timed_out?
58
+ @mutex.synchronize { @timed_out }
59
+ end
60
+
61
+ # @return [Boolean] true when submit cancellation settled the operation
62
+ # @api private
63
+ def cancelled?
64
+ @mutex.synchronize { @cancelled }
65
+ end
66
+
67
+ # @return [Boolean] true when timeout/cancellation settled the caller-facing
68
+ # operation after worker execution had started. The worker is not forcibly
69
+ # interrupted and its eventual result is discarded.
70
+ # @api private
71
+ def abandoned?
72
+ @mutex.synchronize { @abandoned }
73
+ end
74
+
75
+ # @return [Float] seconds spent in the queue before execution started
76
+ # @api private
77
+ def wait_time
78
+ @wait_time || 0.0
79
+ end
80
+
81
+ # Blocks the calling thread until the operation settles and returns its value.
82
+ #
83
+ # A +timeout+ passed here is local to this synchronous waiter. When it expires,
84
+ # {Phronomy::TimeoutError} is raised to this caller, but the operation is not
85
+ # settled, marked abandoned, or otherwise changed. The worker continues, and
86
+ # another waiter or an +on_complete+ callback may receive the eventual result
87
+ # unless the submit-time deadline or submit cancellation settles the operation
88
+ # first.
89
+ #
90
+ # Operation-wide cancellation belongs exclusively to the
91
+ # +cancellation_token:+ passed to {OffloadPool#submit}. PendingOperation does
92
+ # not define a separate waiter-local cancellation-token lifecycle.
93
+ #
94
+ # @param timeout [Numeric, nil] maximum seconds this waiter will block
95
+ # @return [Object]
96
+ # @raise [Phronomy::TimeoutError]
97
+ # @raise [Exception] error that settled the submitted operation
98
+ # @api private
99
+ def blocking_wait(timeout: nil)
100
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout if timeout
101
+ value, error = @mutex.synchronize do
102
+ until @done
103
+ if deadline
104
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
105
+ if remaining <= 0
106
+ raise Phronomy::TimeoutError,
107
+ "timed out waiting for offloaded operation after #{timeout}s"
108
+ end
109
+ @cond.wait(@mutex, remaining)
110
+ else
111
+ @cond.wait(@mutex)
112
+ end
113
+ end
114
+
115
+ [@value, @error]
116
+ end
117
+
118
+ raise error if error
119
+
120
+ value
121
+ end
122
+
123
+ # Unified wait interface compatible with {Phronomy::Task#wait_result}.
124
+ alias_method :wait_result, :blocking_wait
125
+
126
+ # Registers an independent callback to be called when the operation settles.
127
+ #
128
+ # If the operation has already settled, the callback is invoked immediately
129
+ # on the calling thread. Otherwise it may be invoked on a pool worker thread,
130
+ # on the EventLoop thread when a timer fires, or on the thread that explicitly
131
+ # cancels the submit cancellation token. The execution thread is not
132
+ # guaranteed; callbacks must be thread-safe and should complete quickly.
133
+ #
134
+ # Completion callback failures are logged and isolated. One callback cannot
135
+ # suppress delivery to later callbacks or change the operation's settled
136
+ # result.
137
+ #
138
+ # The callback receives +result+ and +error+ (one of them will be +nil+).
139
+ #
140
+ # @yield [result, error]
141
+ # @return [self]
142
+ # @api private
143
+ def on_complete(&callback)
144
+ raise ArgumentError, "on_complete requires a block" unless callback
145
+
146
+ fire_args = nil
147
+ @mutex.synchronize do
148
+ if @done
149
+ fire_args = [@value, @error]
150
+ else
151
+ @callbacks ||= []
152
+ @callbacks << callback
153
+ end
154
+ end
155
+ deliver_completion_callback(callback, *fire_args) if fire_args
156
+ self
157
+ end
158
+
159
+ # @api private
160
+ def initialize(
161
+ block,
162
+ timeout: nil,
163
+ cancellation_token: nil,
164
+ on_abandoned: nil,
165
+ submitted_at: nil
166
+ )
167
+ @block = block
168
+ @timeout = timeout
169
+ @cancellation_token = cancellation_token
170
+ @on_abandoned = on_abandoned
171
+ @value = nil
172
+ @error = nil
173
+ @done = false
174
+ @timed_out = false
175
+ @cancelled = false
176
+ @started = false
177
+ @abandoned = false
178
+ @wait_time = nil
179
+ @submitted_at = submitted_at ||
180
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
181
+ @mutex = Mutex.new
182
+ @cond = ConditionVariable.new
183
+
184
+ # Explicit submit cancellation is operation-wide. Deadline-only tokens are
185
+ # promoted to cancel! by OffloadPool#submit using the Runtime timer queue.
186
+ @cancellation_callback = if @cancellation_token
187
+ -> { fire_cancellation! }
188
+ end
189
+ @cancellation_token&.on_cancel(&@cancellation_callback)
190
+ end
191
+
192
+ # Settles the operation with a submit-time timeout.
193
+ #
194
+ # The worker is not interrupted. If execution has already started, the
195
+ # operation is marked abandoned and the worker's eventual result is discarded.
196
+ #
197
+ # @return [Boolean] true when this call settled the operation, false when the
198
+ # operation had already settled
199
+ # @api private
200
+ def fire_timeout!
201
+ settle_early!(timed_out: true) do
202
+ Phronomy::TimeoutError.new(
203
+ "offloaded operation timed out after #{@timeout}s"
204
+ )
205
+ end
206
+ end
207
+
208
+ # Settles the operation because its submit cancellation token was cancelled.
209
+ #
210
+ # Cancellation is caller-facing settlement, not asynchronous worker
211
+ # interruption. If execution has already started, the operation is marked
212
+ # abandoned and the worker continues until the synchronous call returns.
213
+ #
214
+ # @return [Boolean] true when this call settled the operation
215
+ # @api private
216
+ def fire_cancellation!
217
+ settle_early!(cancelled: true) do |started|
218
+ message = if started
219
+ "offloaded operation cancelled during execution"
220
+ else
221
+ "offloaded operation cancelled before execution"
222
+ end
223
+ CancellationError.new(message)
224
+ end
225
+ end
226
+
227
+ # Marks an operation that could not be admitted to the pool as settled, so a
228
+ # previously armed submit-time timer becomes a harmless no-op.
229
+ #
230
+ # @param error [Exception, nil]
231
+ # @return [Boolean] true when this call changed the state
232
+ # @api private
233
+ def fail_submission!(error = nil)
234
+ callbacks = nil
235
+ changed = @mutex.synchronize do
236
+ next false if @done
237
+
238
+ @done = true
239
+ @error = error if error
240
+ @cond.broadcast
241
+ callbacks = @callbacks
242
+ @callbacks = nil
243
+ true
244
+ end
245
+
246
+ detach_submit_cancellation if changed
247
+ deliver_completion_callbacks(callbacks, nil, error) if changed
248
+ changed
249
+ end
250
+
251
+ # Executes the operation on a pool worker.
252
+ # @api private
253
+ def execute!
254
+ @wait_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @submitted_at
255
+
256
+ # A monotonic deadline can make cancelled? true before the timer callback
257
+ # runs. Promote that state to explicit cancellation so callbacks and all
258
+ # observers see the same operation-wide settlement.
259
+ if @cancellation_token&.cancelled?
260
+ @cancellation_token.cancel!
261
+ end
262
+
263
+ should_run = @mutex.synchronize do
264
+ if @done
265
+ false
266
+ else
267
+ # Linearization point: after this assignment, a concurrent timeout or
268
+ # cancellation is classified as in-flight abandonment and the block
269
+ # itself is allowed to finish without Thread#raise.
270
+ @started = true
271
+ true
272
+ end
273
+ end
274
+ return unless should_run
275
+
276
+ # Do NOT use Timeout.timeout here — it delivers an async Thread#raise
277
+ # that can corrupt library/application state (mutexes, C extensions, etc.).
278
+ # I/O libraries should set native connection/read timeouts. CPU-heavy work
279
+ # that needs hard termination should use a future process-offload facility.
280
+ begin
281
+ complete_with_value!(@block.call)
282
+ rescue Exception => e # rubocop:disable Lint/RescueException
283
+ # Rescue all Exception subclasses so non-StandardError raises still
284
+ # settle the operation and unblock waiters.
285
+ complete_with_error!(e)
286
+ raise if e.is_a?(SignalException) || e.is_a?(SystemExit)
287
+ end
288
+ end
289
+
290
+ private
291
+
292
+ def settle_early!(timed_out: false, cancelled: false)
293
+ callbacks = nil
294
+ abandoned_now = false
295
+ error = nil
296
+
297
+ changed = @mutex.synchronize do
298
+ next false if @done
299
+
300
+ # The error and @started classification are decided under the same lock
301
+ # as settlement. This is the cancellation/worker-start linearization
302
+ # point: cancellation that wins here prevents execution; worker start
303
+ # that wins first produces an abandoned in-flight operation.
304
+ error = yield(@started)
305
+ @done = true
306
+ @timed_out = timed_out
307
+ @cancelled = cancelled
308
+ @error = error
309
+ @abandoned = @started
310
+ abandoned_now = @abandoned
311
+ @cond.broadcast
312
+ callbacks = @callbacks
313
+ @callbacks = nil
314
+ true
315
+ end
316
+ return false unless changed
317
+
318
+ detach_submit_cancellation
319
+ notify_abandoned if abandoned_now
320
+ deliver_completion_callbacks(callbacks, nil, error)
321
+ true
322
+ end
323
+
324
+ def notify_abandoned
325
+ @on_abandoned&.call(self)
326
+ rescue => error
327
+ Phronomy.configuration.logger&.error do
328
+ "OffloadPool abandoned callback failed: #{error.class}: #{error.message}"
329
+ end
330
+ end
331
+
332
+ def complete_with_value!(value)
333
+ callbacks = nil
334
+ changed = @mutex.synchronize do
335
+ next false if @done
336
+
337
+ @value = value
338
+ @done = true
339
+ @cond.broadcast
340
+ callbacks = @callbacks
341
+ @callbacks = nil
342
+ true
343
+ end
344
+ detach_submit_cancellation if changed
345
+ deliver_completion_callbacks(callbacks, value, nil) if changed
346
+ changed
347
+ end
348
+
349
+ def complete_with_error!(error)
350
+ callbacks = nil
351
+ changed = @mutex.synchronize do
352
+ next false if @done
353
+
354
+ @error = error
355
+ @done = true
356
+ @cond.broadcast
357
+ callbacks = @callbacks
358
+ @callbacks = nil
359
+ true
360
+ end
361
+ detach_submit_cancellation if changed
362
+ deliver_completion_callbacks(callbacks, nil, error) if changed
363
+ changed
364
+ end
365
+
366
+ def deliver_completion_callbacks(callbacks, value, error)
367
+ callbacks&.each do |callback|
368
+ deliver_completion_callback(callback, value, error)
369
+ end
370
+ end
371
+
372
+ def deliver_completion_callback(callback, value, error)
373
+ callback.call(value, error)
374
+ rescue => callback_error
375
+ Phronomy.configuration.logger&.error do
376
+ "[OffloadPool::PendingOperation] on_complete callback raised " \
377
+ "#{callback_error.class}: #{callback_error.message}"
378
+ end
379
+ end
380
+
381
+ def detach_submit_cancellation
382
+ return unless @cancellation_token && @cancellation_callback
383
+
384
+ @cancellation_token.send(
385
+ :unregister_cancel_callback,
386
+ @cancellation_callback
387
+ )
388
+ @cancellation_callback = nil
389
+ end
390
+ end
391
+
392
+ # @param pool_size [Integer] maximum number of worker threads
393
+ # @param queue_size [Integer] maximum pending operations waiting for a worker
394
+ # @param name [String, Symbol, nil] optional pool name used in thread labels
395
+ # @param logger [Logger, nil] optional logger for warnings
396
+ # @param timer_queue_provider [#call, nil] returns a TimerQueue-compatible
397
+ # object. Required when +submit(timeout:)+ or a monotonic-deadline
398
+ # cancellation token is used.
399
+ # @api private
400
+ def initialize(
401
+ pool_size: 10,
402
+ queue_size: 100,
403
+ name: nil,
404
+ logger: nil,
405
+ timer_queue_provider: nil
406
+ )
407
+ @pool_size = pool_size
408
+ @queue_size = queue_size
409
+ @name = name
410
+ @logger = logger
411
+ @timer_queue_provider = timer_queue_provider
412
+ @queue = SizedQueue.new(queue_size)
413
+ @active_count = 0
414
+ @abandoned_count = 0
415
+ @running_operation_ids = {}
416
+ @abandoned_active_operation_ids = {}
417
+ @total_wait_ns = 0
418
+ @completed_count = 0
419
+ @mutex = Mutex.new
420
+ @shutdown = false
421
+ @workers = Array.new(pool_size) { |i| spawn_worker(i) }
422
+ end
423
+
424
+ # Submits synchronous off-EventLoop work to the pool.
425
+ # Returns a {PendingOperation} immediately after queue admission; the block runs
426
+ # on a worker thread. Do not submit logical waits (for example waiting for a
427
+ # child Agent Task) merely to make them asynchronous; those belong to
428
+ # FSMSession/EventLoop completion events.
429
+ #
430
+ # A submit-time +timeout+ is an operation-wide deadline measured from the start
431
+ # of this method, including queue wait. The timer settles the PendingOperation
432
+ # and notifies +on_complete+ without forcibly interrupting a running worker.
433
+ # If the deadline fires before worker execution starts, the block is skipped.
434
+ # If it fires after execution starts, the operation is marked abandoned and the
435
+ # eventual worker result is discarded.
436
+ #
437
+ # The submit +cancellation_token+ is also operation-wide. Explicit cancellation
438
+ # settles the PendingOperation immediately. A token with a monotonic deadline is
439
+ # attached to the Runtime timer queue so deadline expiry becomes explicit
440
+ # cancellation without adding a polling Thread. Cancellation before execution
441
+ # skips the block; cancellation after execution starts abandons only the
442
+ # caller-facing result and never uses Thread#raise.
443
+ #
444
+ # Synchronous queue admission may delay return from this method when
445
+ # +on_full: :wait+ is used. EventLoop-owned framework paths therefore submit
446
+ # with +on_full: :raise+ and handle backpressure asynchronously.
447
+ #
448
+ # @param timeout [Numeric, nil] operation-wide deadline in seconds
449
+ # @param cancellation_token [CancellationToken, nil] operation-wide token
450
+ # @param on_full [Symbol] +:wait+, +:raise+, or +:timeout+
451
+ # @param full_timeout [Numeric, nil] queue-admission timeout for +on_full: :timeout+
452
+ # @yield block containing synchronous work
453
+ # @return [PendingOperation]
454
+ # @raise [Phronomy::ConfigurationError] when a timer is required but no
455
+ # timer queue provider is configured
456
+ # @raise [Phronomy::PoolShutdownError] when the pool has been shut down
457
+ # @raise [Phronomy::BackpressureError] when +on_full: :raise+ and queue is full
458
+ # @raise [Phronomy::TimeoutError] when +on_full: :timeout+ exceeds +full_timeout+
459
+ # @api private
460
+ def submit(
461
+ timeout: nil,
462
+ cancellation_token: nil,
463
+ on_full: :wait,
464
+ full_timeout: nil,
465
+ &block
466
+ )
467
+ raise Phronomy::PoolShutdownError, "pool has been shut down" if @shutdown
468
+
469
+ submitted_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
470
+ already_cancelled = cancellation_token&.cancelled? || false
471
+ cancellation_remaining = if already_cancelled
472
+ nil
473
+ else
474
+ cancellation_token&.remaining_monotonic_seconds
475
+ end
476
+
477
+ needs_timer = !timeout.nil? ||
478
+ (!cancellation_remaining.nil? && cancellation_remaining > 0)
479
+ timer_queue = @timer_queue_provider&.call if needs_timer
480
+ if needs_timer && !timer_queue
481
+ raise Phronomy::ConfigurationError,
482
+ "timer_queue is required when submit timeout or cancellation deadline is specified"
483
+ end
484
+
485
+ op = PendingOperation.new(
486
+ block,
487
+ timeout: timeout,
488
+ cancellation_token: cancellation_token,
489
+ submitted_at: submitted_at,
490
+ on_abandoned: method(:record_abandoned)
491
+ )
492
+
493
+ # on_cancel only reacts to explicit cancel!, whereas cancelled? also covers a
494
+ # monotonic deadline. Promote an already-expired deadline immediately.
495
+ if already_cancelled
496
+ cancellation_token.cancel!
497
+ return op
498
+ end
499
+
500
+ begin
501
+ if timeout
502
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - submitted_at
503
+ remaining = timeout.to_f - elapsed
504
+ if remaining <= 0
505
+ op.fire_timeout!
506
+ return op
507
+ end
508
+
509
+ # Arm before queue admission so the deadline includes time spent waiting
510
+ # for a queue slot.
511
+ timer_queue.schedule(seconds: remaining) { op.fire_timeout! }
512
+ end
513
+
514
+ if cancellation_remaining
515
+ # Re-read after timeout setup so the scheduled delay reflects setup time.
516
+ remaining = cancellation_token.remaining_monotonic_seconds
517
+ if remaining <= 0
518
+ cancellation_token.cancel!
519
+ return op
520
+ end
521
+ timer_queue.schedule(seconds: remaining) { cancellation_token.cancel! }
522
+ end
523
+
524
+ # Cancellation/timeout can race with timer registration. Do not enqueue
525
+ # already-settled work when the race is observable here.
526
+ return op if op.done?
527
+
528
+ case on_full
529
+ when :raise
530
+ begin
531
+ @queue.push(op, true)
532
+ rescue ThreadError
533
+ raise Phronomy::BackpressureError,
534
+ "OffloadPool queue is full (depth: #{@queue_size})"
535
+ end
536
+ when :timeout
537
+ deadline = full_timeout ?
538
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) + full_timeout) :
539
+ nil
540
+ loop do
541
+ return op if op.done?
542
+
543
+ @queue.push(op, true)
544
+ break
545
+ rescue ThreadError
546
+ if deadline &&
547
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
548
+ raise Phronomy::TimeoutError,
549
+ "timed out waiting for a free slot in OffloadPool"
550
+ end
551
+ sleep(0.005)
552
+ end
553
+ else # :wait (default)
554
+ @queue.push(op)
555
+ end
556
+ rescue ClosedQueueError => e
557
+ # Shutdown raced with this submit — preserve the existing public error.
558
+ op.fail_submission!(e)
559
+ raise Phronomy::PoolShutdownError, "pool has been shut down"
560
+ rescue => e
561
+ op.fail_submission!(e)
562
+ raise
563
+ end
564
+
565
+ op
566
+ end
567
+
568
+ # Gracefully drains the pool and terminates all worker threads.
569
+ # Waits up to +drain_timeout+ seconds for in-flight operations to finish.
570
+ #
571
+ # Closing the underlying SizedQueue signals workers to exit after draining
572
+ # remaining items, without blocking on a full-queue push.
573
+ #
574
+ # @param drain_timeout [Numeric] seconds to wait for workers to finish
575
+ # @return [self]
576
+ # @api private
577
+ def shutdown(drain_timeout: 30)
578
+ @shutdown = true
579
+ @queue.close
580
+ @workers.each { |thread| thread.join(drain_timeout) }
581
+ self
582
+ end
583
+
584
+ # --- Metrics ----------------------------------------------------------
585
+
586
+ # @return [Integer] number of operations currently executing on workers
587
+ # @api private
588
+ def active_count
589
+ @mutex.synchronize { @active_count }
590
+ end
591
+
592
+ # @return [Integer] number of operations waiting in the queue
593
+ # @api private
594
+ def queue_depth
595
+ @queue.size
596
+ end
597
+
598
+ # @return [Integer] cumulative number of operations whose caller-facing timeout
599
+ # or cancellation settled after worker execution had started
600
+ # @api private
601
+ def abandoned_count
602
+ @mutex.synchronize { @abandoned_count }
603
+ end
604
+
605
+ # @return [Integer] number of abandoned operations that still occupy worker
606
+ # capacity at this instant
607
+ # @api private
608
+ def abandoned_active_count
609
+ @mutex.synchronize { @abandoned_active_operation_ids.size }
610
+ end
611
+
612
+ # Average time (in seconds) that completed or skipped operations spent in the
613
+ # queue waiting for a worker. Returns 0.0 when none have been processed yet.
614
+ # @return [Float]
615
+ # @api private
616
+ def average_wait_seconds
617
+ @mutex.synchronize do
618
+ return 0.0 if @completed_count.zero?
619
+
620
+ @total_wait_ns / @completed_count.to_f / 1_000_000_000.0
621
+ end
622
+ end
623
+
624
+ # @return [Integer] configured maximum number of worker threads
625
+ attr_reader :pool_size
626
+
627
+ # @return [Integer] configured maximum queue depth
628
+ attr_reader :queue_size
629
+
630
+ # @return [String, Symbol, nil] pool name used in thread labels
631
+ attr_reader :name
632
+
633
+ private
634
+
635
+ SENTINEL = :shutdown
636
+ private_constant :SENTINEL
637
+
638
+ def spawn_worker(index = nil)
639
+ label = ["phronomy", "offload-pool", @name, index].compact.join("-")
640
+ Thread.new do
641
+ Thread.current.name = label
642
+ loop do
643
+ op = begin
644
+ @queue.pop
645
+ rescue ClosedQueueError
646
+ break
647
+ end
648
+ # nil is returned by a closed, empty Queue on some Ruby versions
649
+ break if op.nil? || op == SENTINEL
650
+
651
+ run_operation(op)
652
+ end
653
+ end
654
+ end
655
+
656
+ def record_abandoned(operation)
657
+ operation_id = operation.object_id
658
+ @mutex.synchronize do
659
+ @abandoned_count += 1
660
+ if @running_operation_ids.key?(operation_id)
661
+ @abandoned_active_operation_ids[operation_id] = true
662
+ end
663
+ end
664
+ end
665
+
666
+ def run_operation(op)
667
+ operation_id = op.object_id
668
+ @mutex.synchronize do
669
+ @active_count += 1
670
+ @running_operation_ids[operation_id] = true
671
+ end
672
+
673
+ begin
674
+ op.execute!
675
+ ensure
676
+ abandoned = op.abandoned?
677
+ wait_ns = (op.wait_time * 1_000_000_000).to_i
678
+
679
+ @mutex.synchronize do
680
+ @active_count -= 1
681
+ @running_operation_ids.delete(operation_id)
682
+ @abandoned_active_operation_ids.delete(operation_id)
683
+ @total_wait_ns += wait_ns
684
+ @completed_count += 1
685
+ end
686
+
687
+ if abandoned
688
+ @logger&.warn do
689
+ "OffloadPool: worker finished after caller-facing timeout/cancellation settlement"
690
+ end
691
+ end
692
+ end
693
+ end
694
+ end
695
+ end
696
+ end