phronomy 0.7.0 → 0.7.1

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -7
  3. data/CHANGELOG.md +151 -1
  4. data/README.md +155 -32
  5. data/Rakefile +33 -0
  6. data/benchmark/baseline.json +1 -1
  7. data/benchmark/bench_regression.rb +1 -0
  8. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
  9. data/docs/decisions/006-no-built-in-guardrails.md +20 -2
  10. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  11. data/lib/phronomy/agent/base.rb +250 -65
  12. data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
  13. data/lib/phronomy/agent/fsm.rb +41 -64
  14. data/lib/phronomy/agent/orchestrator.rb +146 -121
  15. data/lib/phronomy/agent/parallel_tool_chat.rb +79 -22
  16. data/lib/phronomy/agent/react_agent.rb +8 -0
  17. data/lib/phronomy/async_queue.rb +155 -0
  18. data/lib/phronomy/blocking_adapter_pool.rb +435 -0
  19. data/lib/phronomy/cancellation_scope.rb +123 -0
  20. data/lib/phronomy/cancellation_token.rb +43 -2
  21. data/lib/phronomy/concurrency_gate.rb +155 -0
  22. data/lib/phronomy/configuration.rb +142 -0
  23. data/lib/phronomy/deadline.rb +63 -0
  24. data/lib/phronomy/diagnostics.rb +62 -0
  25. data/lib/phronomy/embeddings/base.rb +17 -0
  26. data/lib/phronomy/eval/runner.rb +9 -9
  27. data/lib/phronomy/event_loop.rb +181 -43
  28. data/lib/phronomy/fsm_session.rb +50 -4
  29. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  30. data/lib/phronomy/invocation_context.rb +152 -0
  31. data/lib/phronomy/knowledge_source/base.rb +18 -0
  32. data/lib/phronomy/llm_adapter/base.rb +104 -0
  33. data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
  34. data/lib/phronomy/llm_adapter.rb +20 -0
  35. data/lib/phronomy/metrics.rb +38 -0
  36. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  37. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  38. data/lib/phronomy/runtime/gate_registry.rb +52 -0
  39. data/lib/phronomy/runtime/pool_registry.rb +57 -0
  40. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  41. data/lib/phronomy/runtime/scheduler.rb +98 -0
  42. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  43. data/lib/phronomy/runtime/task_registry.rb +48 -0
  44. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  45. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  46. data/lib/phronomy/runtime/timer_service.rb +42 -0
  47. data/lib/phronomy/runtime.rb +374 -0
  48. data/lib/phronomy/task/backend.rb +80 -0
  49. data/lib/phronomy/task/fiber_backend.rb +157 -0
  50. data/lib/phronomy/task/immediate_backend.rb +89 -0
  51. data/lib/phronomy/task/thread_backend.rb +84 -0
  52. data/lib/phronomy/task.rb +275 -0
  53. data/lib/phronomy/task_group.rb +265 -0
  54. data/lib/phronomy/testing/fake_clock.rb +109 -0
  55. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  56. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  57. data/lib/phronomy/testing.rb +12 -0
  58. data/lib/phronomy/tool/base.rb +110 -2
  59. data/lib/phronomy/tool/mcp_tool.rb +47 -16
  60. data/lib/phronomy/tool/scope_policy.rb +50 -0
  61. data/lib/phronomy/tool_executor.rb +106 -0
  62. data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
  63. data/lib/phronomy/vector_store/async_backend.rb +110 -0
  64. data/lib/phronomy/vector_store/base.rb +7 -0
  65. data/lib/phronomy/version.rb +1 -1
  66. data/lib/phronomy/workflow.rb +52 -5
  67. data/lib/phronomy/workflow_context.rb +29 -2
  68. data/lib/phronomy/workflow_runner.rb +74 -3
  69. data/lib/phronomy.rb +42 -0
  70. metadata +40 -2
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # A counting semaphore that enforces a concurrency cap across a named
5
+ # resource category (e.g. agent tasks, tool tasks, LLM calls).
6
+ #
7
+ # When +max_concurrent+ is +nil+ the gate is a no-op and all callers
8
+ # pass through immediately without acquiring a slot.
9
+ #
10
+ # Backpressure behaviour when the gate is full is controlled by the
11
+ # +on_full:+ keyword:
12
+ # +:reject+ — raise {Phronomy::BackpressureError} immediately
13
+ # +:wait+ — block the calling fiber/thread until a slot is free
14
+ # +:timeout+ — like +:wait+ but raises {Phronomy::BackpressureError}
15
+ # after +timeout:+ seconds if no slot becomes available
16
+ #
17
+ # @example
18
+ # gate = Phronomy::ConcurrencyGate.new(max_concurrent: 5, name: :agent)
19
+ # gate.acquire(on_full: :reject) do
20
+ # run_agent_task
21
+ # end
22
+ class ConcurrencyGate
23
+ # @param max_concurrent [Integer, nil] concurrency cap; nil = unlimited
24
+ # @param name [Symbol, String, nil] human-readable label used in error messages
25
+ # @api private
26
+ def initialize(max_concurrent:, name: nil)
27
+ @max = max_concurrent
28
+ @name = name
29
+ @mutex = Mutex.new
30
+ @cond = ConditionVariable.new
31
+ @count = 0
32
+ end
33
+
34
+ # Returns the configured cap (or nil when unlimited).
35
+ attr_reader :max
36
+
37
+ # Returns the name label.
38
+ attr_reader :name
39
+
40
+ # Returns the number of slots currently in use.
41
+ def current_count
42
+ @mutex.synchronize { @count }
43
+ end
44
+
45
+ # Acquires a slot, executes +block+, then releases the slot.
46
+ # When the gate is unlimited (max is nil) the block runs directly.
47
+ #
48
+ # @param on_full [:reject, :wait, :timeout] backpressure strategy
49
+ # @param timeout [Numeric, nil] seconds before +:timeout+ gives up
50
+ # @yield
51
+ # @return block return value
52
+ # @raise [Phronomy::BackpressureError] when +:reject+ or +:timeout+ fires
53
+ # @api private
54
+ def acquire(on_full: :wait, timeout: nil, &block)
55
+ return block.call if @max.nil?
56
+
57
+ _acquire_slot(on_full: on_full, timeout: timeout)
58
+ begin
59
+ block.call
60
+ ensure
61
+ _release_slot
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def _acquire_slot(on_full:, timeout:)
68
+ scheduler = Phronomy::Runtime::Scheduler.current
69
+ if scheduler
70
+ _acquire_slot_coop(scheduler, on_full: on_full, timeout: timeout)
71
+ else
72
+ _acquire_slot_threaded(on_full: on_full, timeout: timeout)
73
+ end
74
+ end
75
+
76
+ def _acquire_slot_coop(scheduler, on_full:, timeout:)
77
+ # In cooperative mode all tasks run on the same thread, so no mutex needed.
78
+ deadline = timeout ? (scheduler.virtual_time + timeout) : nil
79
+ @coop_signal ||= scheduler.new_signal
80
+
81
+ loop do
82
+ if @count < @max
83
+ @count += 1
84
+ return
85
+ end
86
+
87
+ case on_full
88
+ when :reject
89
+ raise Phronomy::BackpressureError,
90
+ "ConcurrencyGate[#{@name}] at capacity (#{@max}); " \
91
+ "increase max_concurrent_#{@name}_tasks or retry later"
92
+ when :timeout
93
+ if deadline && scheduler.virtual_time >= deadline
94
+ raise Phronomy::BackpressureError,
95
+ "ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
96
+ end
97
+ scheduler.wait_for_signal(@coop_signal)
98
+ if deadline && scheduler.virtual_time >= deadline
99
+ raise Phronomy::BackpressureError,
100
+ "ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
101
+ end
102
+ else # :wait
103
+ scheduler.wait_for_signal(@coop_signal)
104
+ end
105
+ end
106
+ end
107
+
108
+ def _acquire_slot_threaded(on_full:, timeout:)
109
+ deadline = timeout ? (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout) : nil
110
+
111
+ @mutex.synchronize do
112
+ loop do
113
+ if @count < @max
114
+ @count += 1
115
+ return
116
+ end
117
+
118
+ case on_full
119
+ when :reject
120
+ raise Phronomy::BackpressureError,
121
+ "ConcurrencyGate[#{@name}] at capacity (#{@max}); " \
122
+ "increase max_concurrent_#{@name}_tasks or retry later"
123
+ when :timeout
124
+ remaining = deadline ? (deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)) : nil
125
+ if remaining && remaining <= 0
126
+ raise Phronomy::BackpressureError,
127
+ "ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
128
+ end
129
+ @cond.wait(@mutex, remaining || nil)
130
+ # re-check deadline after wakeup
131
+ if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
132
+ raise Phronomy::BackpressureError,
133
+ "ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
134
+ end
135
+ else # :wait
136
+ @cond.wait(@mutex)
137
+ end
138
+ end
139
+ end
140
+ end
141
+
142
+ def _release_slot
143
+ scheduler = Phronomy::Runtime::Scheduler.current
144
+ if scheduler && @coop_signal
145
+ @count -= 1
146
+ scheduler.raise_signal(@coop_signal)
147
+ else
148
+ @mutex.synchronize do
149
+ @count -= 1
150
+ @cond.signal
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -61,12 +61,154 @@ module Phronomy
61
61
  # Phronomy.configure { |c| c.state_store = Phronomy::StateStore::InMemory.new }
62
62
  attr_accessor :state_store
63
63
 
64
+ # Maximum byte length of a tool result returned to the LLM.
65
+ # When a tool returns a String longer than this limit, the string is truncated
66
+ # and a warning is logged. Set to +nil+ (default) to disable truncation.
67
+ # @example
68
+ # Phronomy.configure { |c| c.tool_result_max_size = 8192 }
69
+ attr_accessor :tool_result_max_size
70
+
71
+ # LLM adapter used by Agent::Base to perform LLM calls.
72
+ # Must be an instance of a class that inherits from
73
+ # {Phronomy::LLMAdapter::Base}. Defaults to
74
+ # {Phronomy::LLMAdapter::RubyLLM} which delegates to +chat.ask+ via
75
+ # {BlockingAdapterPool}.
76
+ # Set to a custom adapter to swap in an alternative LLM client without
77
+ # changing any agent code.
78
+ # @example
79
+ # Phronomy.configure { |c| c.llm_adapter = MyAsyncLLMAdapter.new }
80
+ attr_accessor :llm_adapter
81
+
82
+ # Default backpressure strategy for {BlockingAdapterPool#submit} when the
83
+ # queue is full. One of +:wait+ (block until a slot is available),
84
+ # +:raise+ (raise {Phronomy::BackpressureError}), or +:timeout+ (raise
85
+ # {Phronomy::TimeoutError} after +backpressure_timeout+ seconds).
86
+ # @return [:wait, :raise, :timeout]
87
+ attr_accessor :backpressure
88
+
89
+ # Seconds to wait before raising {Phronomy::TimeoutError} when
90
+ # +backpressure+ is +:timeout+.
91
+ # @return [Numeric, nil]
92
+ attr_accessor :backpressure_timeout
93
+
94
+ # Warn when an event spends longer than this many seconds waiting in the
95
+ # EventLoop queue before being dispatched (starvation detection).
96
+ # Set to +nil+ to disable the warning.
97
+ # @return [Numeric, nil]
98
+ attr_accessor :event_loop_starvation_threshold_seconds
99
+
100
+ # Warn when processing a single event on the EventLoop thread takes longer
101
+ # than this many seconds (long-running task / blocking-on-loop detection).
102
+ # Set to +nil+ to disable the warning.
103
+ # @return [Numeric, nil]
104
+ attr_accessor :event_loop_dispatch_threshold_seconds
105
+
106
+ # When true, enables all blocking operation diagnostics (Issue #279).
107
+ # Equivalent to setting all diagnostic thresholds to their defaults.
108
+ # @return [Boolean]
109
+ attr_accessor :scheduler_debug
110
+
111
+ # Wall-clock threshold (milliseconds) after which a task that has not
112
+ # yielded the scheduler emits a warning log. nil disables the check.
113
+ # @return [Float, nil]
114
+ attr_accessor :blocking_detect_threshold_ms
115
+
116
+ # Maximum number of concurrent agent tasks (invoke_async calls in-flight).
117
+ # nil = unlimited (default). When at capacity, behaviour is controlled by
118
+ # +backpressure+ (:wait, :raise/:reject, :timeout).
119
+ # @return [Integer, nil]
120
+ attr_accessor :max_concurrent_agent_tasks
121
+
122
+ # Maximum number of concurrent tool tasks (parallel tool calls in-flight).
123
+ # nil = unlimited (default).
124
+ # @return [Integer, nil]
125
+ attr_accessor :max_concurrent_tool_tasks
126
+
127
+ # Maximum number of concurrent workflow tasks.
128
+ # nil = unlimited (default).
129
+ # @return [Integer, nil]
130
+ attr_accessor :max_concurrent_workflow_tasks
131
+
132
+ # Maximum number of concurrent LLM calls in-flight.
133
+ # nil = unlimited (default).
134
+ # @return [Integer, nil]
135
+ attr_accessor :max_concurrent_llm_calls
136
+
137
+ # Upper bound on the number of streaming token chunks that may be buffered
138
+ # in the {AsyncQueue} used by {Agent#stream} before the LLM producer is
139
+ # throttled. When nil (default), the queue is unbounded.
140
+ # @return [Integer, nil]
141
+ attr_accessor :stream_queue_max_size
142
+
143
+ # Maximum number of concurrent RAG knowledge-source fetches in-flight.
144
+ # nil = unlimited (default).
145
+ # @return [Integer, nil]
146
+ attr_accessor :max_concurrent_rag_fetches
147
+
148
+ # Maximum number of concurrent vector-store searches in-flight.
149
+ # nil = unlimited (default).
150
+ # @return [Integer, nil]
151
+ attr_accessor :max_concurrent_vector_searches
152
+
153
+ # Scheduler starvation threshold (milliseconds).
154
+ # When a task waits more than this many milliseconds after calling
155
+ # +runtime.yield+ before being resumed, the wait is counted as a starvation
156
+ # event. Used by the fairness regression test and by the
157
+ # +tasks_waiting_over_threshold+ metric on {Phronomy::Runtime}.
158
+ # Default: 50ms.
159
+ # @return [Numeric]
160
+ attr_accessor :starvation_threshold_ms
161
+
162
+ # Scheduler backend to use for new {Phronomy::Runtime} instances.
163
+ #
164
+ # | Value | Scheduler | Typical use |
165
+ # |-------|-----------|-------------|
166
+ # | +:thread+ | {Runtime::ThreadScheduler} | **Default** — production-ready; one OS thread per task |
167
+ # | +:immediate+ | {Runtime::FakeScheduler} | Tests — tasks run synchronously, no extra threads |
168
+ # | +:fiber+ | {Runtime::DeterministicScheduler} (autorun) | **EXPERIMENTAL** — Fiber-based cooperative scheduler; do not use as production default |
169
+ # | +:cooperative+ | {Runtime::FakeScheduler} | **Deprecated** — alias for +:immediate+; do not use in new code |
170
+ #
171
+ # The default is +:thread+. The +:fiber+ backend remains experimental and opt-in;
172
+ # it will not become the default until integration test coverage is production grade
173
+ # and virtual-time/timeout semantics are fully resolved (see Issues #350, #347, #348).
174
+ #
175
+ # When this setting is changed, the change only takes effect on the NEXT
176
+ # call to {Runtime.instance} that auto-creates a new instance (i.e. after the
177
+ # previous instance has been replaced or reset). To replace the current
178
+ # instance immediately call +Phronomy::Runtime.instance = nil+ first.
179
+ #
180
+ # @return [:thread, :immediate, :fiber]
181
+ attr_accessor :runtime_backend
182
+
183
+ # When +true+, calling {Agent#invoke} from inside a scheduler task
184
+ # raises {SchedulerReentrancyError}. When +false+ (default), a warning
185
+ # is logged instead so that existing callers have time to migrate.
186
+ # @return [Boolean]
187
+ attr_accessor :strict_runtime_guards
188
+
64
189
  def initialize
65
190
  @recursion_limit = 25
66
191
  @tracer = Phronomy::Tracing::NullTracer.new
67
192
  @trace_pii = false
68
193
  @event_loop = false
69
194
  @event_loop_stop_grace_seconds = 5
195
+ @llm_adapter = Phronomy::LLMAdapter::RubyLLM.new
196
+ @backpressure = :wait
197
+ @backpressure_timeout = nil
198
+ @event_loop_starvation_threshold_seconds = nil
199
+ @event_loop_dispatch_threshold_seconds = nil
200
+ @scheduler_debug = false
201
+ @blocking_detect_threshold_ms = nil
202
+ @max_concurrent_agent_tasks = nil
203
+ @max_concurrent_tool_tasks = nil
204
+ @max_concurrent_workflow_tasks = nil
205
+ @max_concurrent_llm_calls = nil
206
+ @stream_queue_max_size = nil
207
+ @max_concurrent_rag_fetches = nil
208
+ @max_concurrent_vector_searches = nil
209
+ @starvation_threshold_ms = 50
210
+ @runtime_backend = :thread
211
+ @strict_runtime_guards = false
70
212
  end
71
213
  end
72
214
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # A point in time used as an upper bound for an operation.
5
+ #
6
+ # Uses the monotonic clock (+Process::CLOCK_MONOTONIC+) internally to avoid
7
+ # skew from NTP adjustments or DST transitions.
8
+ #
9
+ # @example Create a 30-second deadline and check remaining time
10
+ # deadline = Phronomy::Deadline.in(30)
11
+ # sleep 1
12
+ # deadline.remaining_seconds # => ~29.0
13
+ # deadline.expired? # => false
14
+ class Deadline
15
+ # Creates a deadline that expires +seconds+ from now.
16
+ #
17
+ # @param seconds [Numeric] seconds from now until expiry
18
+ # @return [Deadline]
19
+ # @api private
20
+ def self.in(seconds)
21
+ new(Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds)
22
+ end
23
+
24
+ # @param monotonic_at [Float] absolute monotonic timestamp of expiry
25
+ # @api private
26
+ def initialize(monotonic_at)
27
+ @monotonic_at = monotonic_at
28
+ end
29
+
30
+ # Returns +true+ when the deadline has passed.
31
+ # @return [Boolean]
32
+ # @api private
33
+ def expired?
34
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @monotonic_at
35
+ end
36
+
37
+ # Seconds remaining until expiry. Returns 0 when already expired.
38
+ # @return [Float]
39
+ # @api private
40
+ def remaining_seconds
41
+ remaining = @monotonic_at - Process.clock_gettime(Process::CLOCK_MONOTONIC)
42
+ [remaining, 0.0].max
43
+ end
44
+
45
+ # Attaches this deadline to a {CancellationToken} by cancelling the token
46
+ # when the deadline expires. Uses the Runtime timer queue (a single
47
+ # background thread shared by all deadlines) instead of spawning one thread
48
+ # per deadline.
49
+ #
50
+ # @param token [CancellationToken]
51
+ # @param timer_queue [Runtime::TimerQueue, nil] queue to register with;
52
+ # defaults to +Phronomy::Runtime.instance.timer_queue+
53
+ # @return [self]
54
+ # @api private
55
+ def attach_to(token, timer_queue: Phronomy::Runtime.instance.timer_queue)
56
+ seconds = remaining_seconds
57
+ return self if seconds <= 0
58
+
59
+ timer_queue.schedule(seconds: seconds) { token.cancel! }
60
+ self
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Developer-facing diagnostics for blocking operation detection (Issue #279).
5
+ #
6
+ # Provides debug dump utilities that can be called from an IRB / Rails console
7
+ # or in test helpers to inspect the current state of the Runtime.
8
+ #
9
+ # @example Enable diagnostics and print a dump
10
+ # Phronomy.configure { |c| c.scheduler_debug = true }
11
+ # Phronomy::Diagnostics.dump
12
+ module Diagnostics
13
+ # Prints a formatted summary of the current Runtime state to +$stderr+
14
+ # (or the supplied IO).
15
+ #
16
+ # Includes:
17
+ # - BlockingAdapterPool: active workers, queue depth, abandoned count
18
+ # - EventLoop: last / max / average lag in milliseconds
19
+ #
20
+ # @param out [IO] output destination (default: $stderr)
21
+ # @return [void]
22
+ # @api public
23
+ def self.dump(out: $stderr)
24
+ snap = Phronomy::Metrics.snapshot
25
+
26
+ out.puts "[Phronomy::Diagnostics] Runtime state dump"
27
+ out.puts " BlockingAdapterPool:"
28
+ out.puts " pool_size : #{snap[:blocking_pool_size]}"
29
+ out.puts " active_count : #{snap[:blocking_pool_active]}"
30
+ out.puts " queue_depth : #{snap[:blocking_pool_queue_length]}"
31
+ out.puts " abandoned_total : #{snap[:blocking_pool_abandoned_total]}"
32
+ out.puts " EventLoop:"
33
+ out.puts " last_lag_ms : #{snap[:event_loop_lag_last_ms]}"
34
+ out.puts " max_lag_ms : #{snap[:event_loop_lag_max_ms]}"
35
+ out.puts " average_lag_ms : #{snap[:event_loop_lag_average_ms]}"
36
+ end
37
+
38
+ # Returns the diagnostics state as a plain Hash (useful for JSON export).
39
+ #
40
+ # @return [Hash]
41
+ # @api public
42
+ def self.snapshot
43
+ Phronomy::Metrics.snapshot
44
+ end
45
+
46
+ # Raises an error if +invoke+ (blocking) is called from inside an EventLoop
47
+ # action, preventing accidental scheduler stalls.
48
+ #
49
+ # Called by Agent::Base#invoke and Workflow#invoke before executing.
50
+ #
51
+ # @raise [Phronomy::SchedulerReentrancyError] when called from EventLoop thread
52
+ # @return [void]
53
+ # @api private
54
+ def self.assert_not_in_event_loop!
55
+ return unless Phronomy::EventLoop.current?
56
+
57
+ raise Phronomy::SchedulerReentrancyError,
58
+ "Blocking invoke called from inside an EventLoop action. " \
59
+ "Use invoke_async instead."
60
+ end
61
+ end
62
+ end
@@ -17,6 +17,23 @@ module Phronomy
17
17
  cancellation_token&.raise_if_cancelled!
18
18
  raise NotImplementedError, "#{self.class}#embed is not implemented"
19
19
  end
20
+
21
+ # Submits an {#embed} call to {BlockingAdapterPool} and returns a
22
+ # {BlockingAdapterPool::PendingOperation}.
23
+ #
24
+ # @param text [String]
25
+ # @param cancellation_token [Phronomy::CancellationToken, nil]
26
+ # @param timeout [Numeric, nil] seconds before the operation is abandoned
27
+ # @return [BlockingAdapterPool::PendingOperation]
28
+ # @api public
29
+ def embed_async(text, cancellation_token = nil, timeout: nil)
30
+ Phronomy::Runtime.instance.blocking_io.submit(
31
+ timeout: timeout,
32
+ cancellation_token: cancellation_token
33
+ ) do
34
+ embed(text, cancellation_token)
35
+ end
36
+ end
20
37
  end
21
38
  end
22
39
  end
@@ -32,25 +32,25 @@ module Phronomy
32
32
  cases = dataset.to_a
33
33
  return cases.map { |eval_case| run_one(eval_case, callable) } if concurrency <= 1
34
34
 
35
- # Run cases in slices of +concurrency+ threads. Each slice is joined
36
- # before the next starts, bounding peak thread count to +concurrency+.
37
- # Writing to pre-allocated slots (one per thread) is safe because each
38
- # thread writes to a unique index and all threads in a slice are joined
35
+ # Run cases in slices of +concurrency+ tasks. Each slice is joined
36
+ # before the next starts, bounding peak task count to +concurrency+.
37
+ # Writing to pre-allocated slots (one per task) is safe because each
38
+ # task writes to a unique index and all tasks in a slice are joined
39
39
  # before the next slice begins.
40
- # Exceptions in worker threads are collected and re-raised after all
41
- # threads in the slice are joined, preventing orphaned threads.
40
+ # Exceptions in worker tasks are collected and re-raised after all
41
+ # tasks in the slice are joined, preventing orphaned tasks.
42
42
  results = Array.new(cases.length)
43
43
  cases.each_with_index.each_slice(concurrency) do |batch|
44
44
  errors = []
45
45
  errors_mu = Mutex.new
46
- threads = batch.map do |eval_case, i|
47
- Thread.new do
46
+ tasks = batch.map do |eval_case, i|
47
+ Phronomy::Runtime.instance.spawn(name: "eval-case-#{i}") do
48
48
  results[i] = run_one(eval_case, callable)
49
49
  rescue => e
50
50
  errors_mu.synchronize { errors << e }
51
51
  end
52
52
  end
53
- threads.each(&:join)
53
+ tasks.each(&:join)
54
54
  raise errors.first if errors.any?
55
55
  end
56
56
  results