phronomy 0.7.1 → 0.8.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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -16
  3. data/benchmark/bench_context_assembler.rb +2 -2
  4. data/benchmark/bench_regression.rb +5 -5
  5. data/benchmark/bench_token_estimator.rb +5 -5
  6. data/benchmark/bench_tool_schema.rb +1 -1
  7. data/benchmark/bench_vector_store.rb +1 -1
  8. data/lib/phronomy/agent/base.rb +86 -123
  9. data/lib/phronomy/agent/checkpoint.rb +118 -0
  10. data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
  11. data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
  12. data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
  13. data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
  14. data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
  15. data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
  16. data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
  17. data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
  18. data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
  19. data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
  20. data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
  21. data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
  22. data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
  23. data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
  24. data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
  25. data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
  26. data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
  27. data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
  28. data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
  29. data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
  30. data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
  31. data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
  32. data/lib/phronomy/agent/fsm.rb +1 -1
  33. data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
  34. data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
  35. data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
  36. data/lib/phronomy/agent/react_agent.rb +19 -14
  37. data/lib/phronomy/agent/runner.rb +2 -2
  38. data/lib/phronomy/agent/tool_executor.rb +108 -0
  39. data/lib/phronomy/concurrency/async_queue.rb +157 -0
  40. data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
  41. data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
  42. data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
  43. data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
  44. data/lib/phronomy/concurrency/deadline.rb +65 -0
  45. data/lib/phronomy/{runtime → concurrency}/gate_registry.rb +1 -1
  46. data/lib/phronomy/{runtime → concurrency}/pool_registry.rb +1 -1
  47. data/lib/phronomy/context.rb +2 -8
  48. data/lib/phronomy/embeddings.rb +2 -2
  49. data/lib/phronomy/eval/runner.rb +4 -0
  50. data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
  51. data/lib/phronomy/event_loop.rb +7 -7
  52. data/lib/phronomy/invocation_context.rb +3 -3
  53. data/lib/phronomy/knowledge_source.rb +0 -5
  54. data/lib/phronomy/llm_adapter/ruby_llm.rb +17 -11
  55. data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
  56. data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
  57. data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
  58. data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
  59. data/lib/phronomy/loader.rb +4 -4
  60. data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
  61. data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +6 -6
  62. data/lib/phronomy/{agent → multi_agent}/parallel_tool_chat.rb +4 -4
  63. data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
  64. data/lib/phronomy/runtime.rb +19 -4
  65. data/lib/phronomy/splitter.rb +3 -3
  66. data/lib/phronomy/task_group.rb +1 -1
  67. data/lib/phronomy/tool/base.rb +50 -9
  68. data/lib/phronomy/tracing/null_tracer.rb +3 -1
  69. data/lib/phronomy/vector_store.rb +2 -2
  70. data/lib/phronomy/version.rb +1 -1
  71. data/lib/phronomy/workflow_context.rb +8 -0
  72. data/lib/phronomy/workflow_runner.rb +11 -131
  73. data/lib/phronomy.rb +1 -0
  74. metadata +44 -42
  75. data/lib/phronomy/async_queue.rb +0 -155
  76. data/lib/phronomy/blocking_adapter_pool.rb +0 -435
  77. data/lib/phronomy/cancellation_scope.rb +0 -123
  78. data/lib/phronomy/cancellation_token.rb +0 -133
  79. data/lib/phronomy/concurrency_gate.rb +0 -155
  80. data/lib/phronomy/context/compaction_context.rb +0 -111
  81. data/lib/phronomy/context/trigger_context.rb +0 -39
  82. data/lib/phronomy/context/trim_context.rb +0 -75
  83. data/lib/phronomy/deadline.rb +0 -63
  84. data/lib/phronomy/embeddings/base.rb +0 -39
  85. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
  86. data/lib/phronomy/fsm_session.rb +0 -247
  87. data/lib/phronomy/knowledge_source/base.rb +0 -54
  88. data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
  89. data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
  90. data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
  91. data/lib/phronomy/loader/base.rb +0 -25
  92. data/lib/phronomy/loader/csv_loader.rb +0 -56
  93. data/lib/phronomy/loader/markdown_loader.rb +0 -76
  94. data/lib/phronomy/loader/plain_text_loader.rb +0 -22
  95. data/lib/phronomy/prompt_template.rb +0 -96
  96. data/lib/phronomy/splitter/base.rb +0 -47
  97. data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
  98. data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
  99. data/lib/phronomy/tool_executor.rb +0 -106
  100. data/lib/phronomy/vector_store/async_backend.rb +0 -110
  101. data/lib/phronomy/vector_store/base.rb +0 -89
  102. data/lib/phronomy/vector_store/in_memory.rb +0 -93
  103. data/lib/phronomy/vector_store/pgvector.rb +0 -127
  104. data/lib/phronomy/vector_store/redis_search.rb +0 -192
@@ -0,0 +1,443 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Concurrency
5
+ # A bounded, observable thread pool for blocking I/O operations.
6
+ #
7
+ # ## Architectural boundary
8
+ #
9
+ # `BlockingAdapterPool` is the *only* place in Phronomy that uses raw OS threads
10
+ # for I/O. All third-party gem calls whose internal I/O Phronomy cannot control
11
+ # — including RubyLLM, ActiveRecord, Redis, Faraday, and MCP stdio transport —
12
+ # **must** route through this pool (or a named pool obtained via
13
+ # {Runtime#pool}). Custom non-blocking HTTP/selector runtimes are intentionally
14
+ # out of scope; the pool + cooperative scheduler combination satisfies all
15
+ # current concurrency requirements without that complexity. (See ADR-010.)
16
+ #
17
+ # All blocking calls (LLM HTTP, MCP stdio, ActiveRecord, Redis, etc.) must be
18
+ # submitted through this pool so that:
19
+ #
20
+ # 1. The total number of OS threads is capped.
21
+ # 2. Queue depth is bounded (backpressure when the pool is saturated).
22
+ # 3. Per-operation timeouts are enforced consistently.
23
+ # 4. Abandoned (timed-out) operations are tracked and logged.
24
+ # 5. Metrics (active count, queue depth, abandoned count, avg wait time) are
25
+ # observable at runtime.
26
+ #
27
+ # @example Submitting a blocking LLM call
28
+ # op = runtime.blocking_io.submit(timeout: 30) { chat.ask(message) }
29
+ # result = op.await # blocks the calling thread until done
30
+ #
31
+ # @example With cancellation
32
+ # token = Phronomy::Concurrency::CancellationToken.timeout_after(60)
33
+ # op = pool.submit(timeout: 30, cancellation_token: token) { expensive_call }
34
+ # result = op.await
35
+ class BlockingAdapterPool
36
+ # Represents the pending result of a submitted blocking operation.
37
+ # Returned immediately by {BlockingAdapterPool#submit}; call {#await} to
38
+ # wait for the result.
39
+ class PendingOperation
40
+ # @return [Boolean] true when the operation has finished (success or error)
41
+ # @api private
42
+ def done?
43
+ @mutex.synchronize { @done }
44
+ end
45
+
46
+ # @return [Boolean] true when the operation was abandoned due to timeout
47
+ # @api private
48
+ def abandoned?
49
+ @abandoned
50
+ end
51
+
52
+ # @return [Float] seconds spent in the queue before execution started
53
+ # @api private
54
+ def wait_time
55
+ @wait_time || 0.0
56
+ end
57
+
58
+ # Blocks until the operation completes and returns its value.
59
+ #
60
+ # An optional +timeout+ (in seconds) may be passed here; it is measured
61
+ # from the moment +await+ is called. If both a submit-time timeout and an
62
+ # await-time timeout are present, the earlier deadline wins. The worker
63
+ # thread is NOT interrupted — it runs to completion on its own.
64
+ #
65
+ # An optional +cancellation_token+ may be passed here (or at submit time).
66
+ # If the token is cancelled while waiting, {Phronomy::CancellationError} is
67
+ # raised immediately without interrupting the worker.
68
+ #
69
+ # **Cooperative path (`:fiber` / `DeterministicScheduler`):**
70
+ # When called from a Fiber managed by {DeterministicScheduler} (i.e. under
71
+ # the +:fiber+ runtime backend), the calling Fiber suspends cooperatively
72
+ # via +Fiber.yield+ rather than blocking the OS thread. The Fiber is
73
+ # resumed on the scheduler's ready queue once the worker thread completes
74
+ # the operation.
75
+ #
76
+ # @note **Cooperative cancellation semantics** (ADR-010):
77
+ # Phronomy uses a non-preemptive, cooperative-first concurrency model.
78
+ # Cancellation is *cooperative*, not preemptive:
79
+ # - When a +cancellation_token+ is cancelled, +CancellationError+ is
80
+ # raised to the +await+ caller immediately; when the timeout fires,
81
+ # +TimeoutError+ is raised instead. In both cases, the underlying
82
+ # worker thread is **not** forcibly stopped.
83
+ # - The worker thread will complete its submitted block naturally.
84
+ # Code inside the block must call +token.check!+ at suitable
85
+ # checkpoints to observe the cancelled state and exit early.
86
+ # - There is no +Thread#kill+ or +Thread#raise+ involved. The framework
87
+ # never forcibly terminates worker threads.
88
+ #
89
+ # @note **Cooperative timeout limitation**: the +timeout:+ parameter passed
90
+ # to +await+ is *not* enforced on the cooperative path. The calling Fiber
91
+ # remains suspended until the worker thread finishes regardless of how many
92
+ # seconds elapse. This is because the cooperative scheduler cannot
93
+ # preempt a running OS thread. If a time bound is required, set
94
+ # +timeout:+ at {BlockingAdapterPool#submit submit} time instead; the pool
95
+ # will then abandon the operation on the worker side and mark it as
96
+ # {#abandoned?}.
97
+ #
98
+ # @param timeout [Numeric, nil] seconds from now before raising TimeoutError
99
+ # (thread path only; ignored on the cooperative/fiber path)
100
+ # @param cancellation_token [CancellationToken, nil]
101
+ # @return [Object]
102
+ # @raise [Phronomy::TimeoutError]
103
+ # @raise [Phronomy::CancellationError]
104
+ # @raise [Exception] error raised inside the submitted block
105
+ # @api private
106
+ def await(timeout: nil, cancellation_token: nil)
107
+ effective_timeout = [timeout, @timeout].compact.min
108
+ effective_token = cancellation_token || @cancellation_token
109
+
110
+ raise CancellationError, "blocking operation cancelled" if effective_token&.cancelled?
111
+
112
+ # Cooperative context: suspend the calling Fiber rather than blocking
113
+ # the OS thread so that DeterministicScheduler can continue dispatching
114
+ # other tasks while waiting for the blocking worker to finish.
115
+ # (Issue #338, ADR-010 Rule 3)
116
+ # Uses the same thread-local key as Task::FiberBackend::SCHEDULER_KEY
117
+ # (:phronomy_deterministic_scheduler) to avoid a cross-file constant
118
+ # dependency at load time.
119
+ scheduler = Thread.current.thread_variable_get(:phronomy_deterministic_scheduler)
120
+ in_managed_fiber = !Fiber.respond_to?(:main) || Fiber.current != Fiber.main
121
+ if scheduler && in_managed_fiber
122
+ unless @done
123
+ # Register this await with the scheduler so run_until_idle knows
124
+ # not to exit until the worker thread completes (Issue #338).
125
+ scheduler.track_blocking_await
126
+ waiting_fiber = Fiber.current
127
+ on_complete do |_result, _error|
128
+ # Decrement the counter and wake run_until_idle, then re-enqueue
129
+ # the suspended Fiber for cooperative resumption.
130
+ scheduler.complete_blocking_await
131
+ scheduler.enqueue_fiber(-> { waiting_fiber.resume })
132
+ end
133
+ Fiber.yield(:cooperative_suspend)
134
+ end
135
+ raise CancellationError, "blocking operation cancelled" if effective_token&.cancelled?
136
+ raise @error if @error
137
+
138
+ return @value
139
+ end
140
+
141
+ # Wake up the waiting thread whenever the token is cancelled so we can
142
+ # propagate cancellation without sleeping until the timeout expires.
143
+ effective_token&.on_cancel { @mutex.synchronize { @cond.broadcast } }
144
+
145
+ if effective_timeout
146
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + effective_timeout
147
+ @mutex.synchronize do
148
+ until @done
149
+ raise CancellationError, "blocking operation cancelled" if effective_token&.cancelled?
150
+
151
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
152
+ if remaining <= 0
153
+ # Guard against double-counting when await is called multiple times.
154
+ unless @abandoned
155
+ @abandoned = true
156
+ @on_abandoned&.call
157
+ end
158
+ raise Phronomy::TimeoutError, "blocking operation timed out after #{effective_timeout}s"
159
+ end
160
+ @cond.wait(@mutex, remaining)
161
+ end
162
+ end
163
+ else
164
+ @mutex.synchronize do
165
+ until @done
166
+ raise CancellationError, "blocking operation cancelled" if effective_token&.cancelled?
167
+
168
+ @cond.wait(@mutex)
169
+ end
170
+ end
171
+ end
172
+ raise @error if @error
173
+
174
+ @value
175
+ end
176
+
177
+ # Registers a callback to be called when the operation finishes.
178
+ # If the operation has already finished the callback is invoked immediately
179
+ # on the calling thread. Otherwise it is invoked on the worker thread that
180
+ # completes the operation.
181
+ #
182
+ # The callback receives +result+ and +error+ (one of them will be +nil+).
183
+ #
184
+ # @yield [result, error]
185
+ # @return [self]
186
+ # @api private
187
+ def on_complete(&callback)
188
+ fire_args = nil
189
+ @mutex.synchronize do
190
+ if @done
191
+ fire_args = [@value, @error]
192
+ else
193
+ @callbacks ||= []
194
+ @callbacks << callback
195
+ end
196
+ end
197
+ callback.call(*fire_args) if fire_args
198
+ self
199
+ end
200
+
201
+ # @api private
202
+ def initialize(block, timeout: nil, cancellation_token: nil, on_abandoned: nil)
203
+ @block = block
204
+ @timeout = timeout
205
+ @cancellation_token = cancellation_token
206
+ @on_abandoned = on_abandoned
207
+ @value = nil
208
+ @error = nil
209
+ @done = false
210
+ @abandoned = false
211
+ @wait_time = nil
212
+ @submitted_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
213
+ @mutex = Mutex.new
214
+ @cond = ConditionVariable.new
215
+ end
216
+
217
+ # @api private
218
+ def execute!
219
+ @wait_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @submitted_at
220
+
221
+ if @cancellation_token&.cancelled?
222
+ complete_with_error!(CancellationError.new("operation cancelled before execution"))
223
+ return
224
+ end
225
+
226
+ # Do NOT use Timeout.timeout here — it delivers an async Thread#raise
227
+ # that can corrupt external library state (mutexes, C extensions, etc.).
228
+ # Timeout enforcement is handled cooperatively in #await instead.
229
+ # Each blocking library (Net::HTTP, pg, redis, etc.) should set its
230
+ # own native connection/read timeouts.
231
+ begin
232
+ complete_with_value!(@block.call)
233
+ rescue Exception => e # rubocop:disable Lint/RescueException
234
+ # Rescue all Exception subclasses (not just StandardError) so that
235
+ # non-StandardError raises such as NotImplementedError (< ScriptError)
236
+ # still complete the operation and unblock any waiting #await callers.
237
+ # Without this, a ScriptError in a pool worker would leave the
238
+ # PendingOperation permanently incomplete, causing #await to deadlock.
239
+ complete_with_error!(e)
240
+ raise if e.is_a?(SignalException) || e.is_a?(SystemExit)
241
+ end
242
+ end
243
+
244
+ private
245
+
246
+ def complete_with_value!(value)
247
+ cbs = nil
248
+ @mutex.synchronize do
249
+ @value = value
250
+ @done = true
251
+ @cond.broadcast
252
+ cbs = @callbacks
253
+ @callbacks = nil
254
+ end
255
+ cbs&.each { |cb| cb.call(value, nil) }
256
+ end
257
+
258
+ def complete_with_error!(error)
259
+ cbs = nil
260
+ @mutex.synchronize do
261
+ @error = error
262
+ @done = true
263
+ @cond.broadcast
264
+ cbs = @callbacks
265
+ @callbacks = nil
266
+ end
267
+ cbs&.each { |cb| cb.call(nil, error) }
268
+ end
269
+ end
270
+
271
+ # @param pool_size [Integer] maximum number of worker threads
272
+ # @param queue_size [Integer] maximum pending operations waiting for a worker
273
+ # @param name [String, Symbol, nil] optional pool name used in thread labels
274
+ # @param logger [Logger, nil] optional logger for warnings
275
+ # @api private
276
+ def initialize(pool_size: 10, queue_size: 100, name: nil, logger: nil)
277
+ @pool_size = pool_size
278
+ @queue_size = queue_size
279
+ @name = name
280
+ @logger = logger
281
+ @queue = SizedQueue.new(queue_size)
282
+ @active_count = 0
283
+ @abandoned_count = 0
284
+ @total_wait_ns = 0
285
+ @completed_count = 0
286
+ @mutex = Mutex.new
287
+ @shutdown = false
288
+ @workers = Array.new(pool_size) { |i| spawn_worker(i) }
289
+ end
290
+
291
+ # Submits a blocking operation to the pool.
292
+ # Returns a {PendingOperation} immediately; the block runs on a worker thread.
293
+ #
294
+ # @note **Cooperative callers**: if you are running under the `:fiber` backend
295
+ # (i.e. inside a {DeterministicScheduler} Fiber), set +timeout:+ here
296
+ # rather than on {PendingOperation#await}. The await-time timeout is not
297
+ # enforced on the cooperative path (the Fiber cannot preempt a running
298
+ # worker thread). A submit-time timeout triggers on the worker side and
299
+ # marks the operation {PendingOperation#abandoned? abandoned}, which
300
+ # unblocks the waiting Fiber via the normal on-complete callback.
301
+ # @param timeout [Numeric, nil] seconds before the operation is abandoned
302
+ # @param cancellation_token [CancellationToken, nil]
303
+ # @yield block containing the blocking call
304
+ # @return [PendingOperation]
305
+ # @raise [Phronomy::PoolShutdownError] when the pool has been shut down
306
+ # @raise [Phronomy::BackpressureError] when +on_full: :raise+ and queue is full
307
+ # @raise [Phronomy::TimeoutError] when +on_full: :timeout+ and wait exceeds +full_timeout+
308
+ # @api private
309
+ def submit(timeout: nil, cancellation_token: nil, on_full: :wait, full_timeout: nil, &block)
310
+ raise Phronomy::PoolShutdownError, "pool has been shut down" if @shutdown
311
+
312
+ op = PendingOperation.new(block, timeout: timeout, cancellation_token: cancellation_token,
313
+ on_abandoned: timeout ? -> { @mutex.synchronize { @abandoned_count += 1 } } : nil)
314
+ begin
315
+ case on_full
316
+ when :raise
317
+ begin
318
+ @queue.push(op, true)
319
+ rescue ThreadError
320
+ raise Phronomy::BackpressureError, "BlockingAdapterPool queue is full (depth: #{@queue_size})"
321
+ end
322
+ when :timeout
323
+ deadline = full_timeout ? (Process.clock_gettime(Process::CLOCK_MONOTONIC) + full_timeout) : nil
324
+ loop do
325
+ @queue.push(op, true)
326
+ break
327
+ rescue ThreadError
328
+ if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
329
+ raise Phronomy::TimeoutError, "timed out waiting for a free slot in BlockingAdapterPool"
330
+ end
331
+ sleep(0.005)
332
+ end
333
+ else # :wait (default)
334
+ @queue.push(op)
335
+ end
336
+ rescue ClosedQueueError
337
+ # Shutdown raced with this submit — treat as if @shutdown was already set.
338
+ raise Phronomy::PoolShutdownError, "pool has been shut down"
339
+ end
340
+ op
341
+ end
342
+
343
+ # Gracefully drains the pool and terminates all worker threads.
344
+ # Waits up to +drain_timeout+ seconds for in-flight operations to finish.
345
+ #
346
+ # Closing the underlying SizedQueue signals workers to exit after draining
347
+ # remaining items, without blocking on a full-queue push.
348
+ #
349
+ # @param drain_timeout [Numeric] seconds to wait for workers to finish
350
+ # @return [self]
351
+ # @api private
352
+ def shutdown(drain_timeout: 30)
353
+ @shutdown = true
354
+ @queue.close
355
+ @workers.each { |t| t.join(drain_timeout) }
356
+ self
357
+ end
358
+
359
+ # --- Metrics ----------------------------------------------------------
360
+
361
+ # @return [Integer] number of operations currently executing on workers
362
+ # @api private
363
+ def active_count
364
+ @mutex.synchronize { @active_count }
365
+ end
366
+
367
+ # @return [Integer] number of operations waiting in the queue
368
+ # @api private
369
+ def queue_depth
370
+ @queue.size
371
+ end
372
+
373
+ # @return [Integer] number of operations that were abandoned due to timeout
374
+ # @api private
375
+ def abandoned_count
376
+ @mutex.synchronize { @abandoned_count }
377
+ end
378
+
379
+ # Average time (in seconds) that completed operations spent in the queue
380
+ # waiting for a worker. Returns 0.0 when no operations have completed yet.
381
+ # @return [Float]
382
+ # @api private
383
+ def average_wait_seconds
384
+ @mutex.synchronize do
385
+ return 0.0 if @completed_count.zero?
386
+
387
+ @total_wait_ns / @completed_count.to_f / 1_000_000_000.0
388
+ end
389
+ end
390
+
391
+ # @return [Integer] configured maximum number of worker threads
392
+ attr_reader :pool_size
393
+
394
+ # @return [Integer] configured maximum queue depth
395
+ attr_reader :queue_size
396
+
397
+ # @return [String, Symbol, nil] pool name used in thread labels
398
+ attr_reader :name
399
+
400
+ private
401
+
402
+ SENTINEL = :shutdown
403
+ private_constant :SENTINEL
404
+
405
+ def spawn_worker(index = nil)
406
+ label = ["phronomy", "blocking-pool", @name, index].compact.join("-")
407
+ Thread.new do
408
+ Thread.current.name = label
409
+ loop do
410
+ op = begin
411
+ @queue.pop
412
+ rescue ClosedQueueError
413
+ break
414
+ end
415
+ # nil is returned by a closed, empty Queue on some Ruby versions
416
+ break if op.nil? || op == SENTINEL
417
+
418
+ run_operation(op)
419
+ end
420
+ end
421
+ end
422
+
423
+ def run_operation(op)
424
+ @mutex.synchronize { @active_count += 1 }
425
+
426
+ begin
427
+ op.execute!
428
+ ensure
429
+ @mutex.synchronize do
430
+ @active_count -= 1
431
+
432
+ if op.abandoned?
433
+ @logger&.warn { "BlockingAdapterPool: worker finished operation after caller timed out" }
434
+ end
435
+
436
+ @total_wait_ns += (op.wait_time * 1_000_000_000).to_i
437
+ @completed_count += 1
438
+ end
439
+ end
440
+ end
441
+ end
442
+ end
443
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Concurrency
5
+ # Represents a bounded execution scope that owns a {CancellationToken} and
6
+ # optionally a {Deadline}.
7
+ #
8
+ # +CancellationScope+ replaces ad-hoc +Timeout.timeout+ calls in agent and
9
+ # tool code. All work performed within a scope should observe the scope's
10
+ # token; when the scope is cancelled (explicitly or by deadline expiry) the
11
+ # token is cancelled and all child tasks that check it will stop.
12
+ #
13
+ # @example Time-bounded invocation
14
+ # scope = Phronomy::Concurrency::CancellationScope.new.deadline_in(30)
15
+ # result = scope.pop_queue(completion_queue) do
16
+ # raise Phronomy::TimeoutError, "timed out"
17
+ # end
18
+ #
19
+ # @example Explicit cancellation
20
+ # scope = Phronomy::Concurrency::CancellationScope.new
21
+ # Phronomy::Runtime.instance.spawn(name: "worker") do
22
+ # scope.token.raise_if_cancelled!
23
+ # # ... do work ...
24
+ # end
25
+ # scope.cancel! if some_condition
26
+ class CancellationScope
27
+ # @return [CancellationToken] the token owned by this scope
28
+ attr_reader :token
29
+
30
+ # @return [Deadline, nil] the deadline attached to this scope, if any
31
+ attr_reader :deadline
32
+
33
+ # @param parent_token [CancellationToken, nil] when provided, cancellation of
34
+ # the parent token is propagated to this scope's token via a callback
35
+ # (for explicit cancel) and/or the Runtime timer queue (for monotonic
36
+ # deadline expiry). No polling thread is spawned.
37
+ # @api private
38
+ def initialize(parent_token: nil)
39
+ @token = Phronomy::Concurrency::CancellationToken.new
40
+ @deadline = nil
41
+
42
+ if parent_token
43
+ # Propagate explicit cancel() from parent to child via callback.
44
+ parent_token.on_cancel { @token.cancel! }
45
+
46
+ # Propagate monotonic-deadline expiry from parent to child via the
47
+ # timer queue (avoids a polling thread).
48
+ remaining = parent_token.remaining_monotonic_seconds
49
+ if !remaining.nil?
50
+ if remaining <= 0
51
+ @token.cancel!
52
+ else
53
+ Phronomy::Runtime.instance.timer_queue.schedule(seconds: remaining) do
54
+ @token.cancel!
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ # Attaches a deadline that will cancel this scope after +seconds+.
62
+ #
63
+ # @param seconds [Numeric] timeout duration
64
+ # @return [self]
65
+ # @api private
66
+ def deadline_in(seconds)
67
+ @deadline = Phronomy::Concurrency::Deadline.in(seconds)
68
+ @deadline.attach_to(@token)
69
+ self
70
+ end
71
+
72
+ # Cancels this scope immediately.
73
+ # @return [void]
74
+ # @api private
75
+ def cancel!
76
+ @token.cancel!
77
+ end
78
+
79
+ # Returns +true+ if this scope has been cancelled.
80
+ # @return [Boolean]
81
+ # @api private
82
+ def cancelled?
83
+ @token.cancelled?
84
+ end
85
+
86
+ # Returns the remaining time in seconds before the deadline expires,
87
+ # or +nil+ when no deadline is set.
88
+ # @return [Float, nil]
89
+ # @api private
90
+ def remaining_seconds
91
+ @deadline&.remaining_seconds
92
+ end
93
+
94
+ # Pops from +queue+ with a timeout derived from the attached deadline (or
95
+ # +fallback_timeout+ seconds when no deadline is set). If the pop times out,
96
+ # the scope is cancelled and the block is called (or a {TimeoutError} raised).
97
+ #
98
+ # @param queue [Phronomy::Concurrency::AsyncQueue] the queue to pop from
99
+ # @param fallback_timeout [Numeric, nil] used when no deadline is attached
100
+ # @yield called when the operation times out
101
+ # @raise [Phronomy::TimeoutError] when no block is given and a timeout occurs
102
+ # @return [Object] the popped value
103
+ # @api private
104
+ def pop_queue(queue, fallback_timeout: nil)
105
+ timeout = @deadline&.remaining_seconds || fallback_timeout
106
+ result = if timeout
107
+ queue.pop(timeout: timeout)
108
+ else
109
+ queue.pop
110
+ end
111
+
112
+ if result.nil?
113
+ cancel!
114
+ if block_given?
115
+ yield
116
+ else
117
+ raise Phronomy::TimeoutError, "CancellationScope timed out"
118
+ end
119
+ end
120
+
121
+ result
122
+ end
123
+ end
124
+ end
125
+ end