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
@@ -20,6 +20,24 @@ module Phronomy
20
20
  raise NotImplementedError, "#{self.class}#fetch is not implemented"
21
21
  end
22
22
 
23
+ # Submits a {#fetch} call to {BlockingAdapterPool} and returns a
24
+ # {BlockingAdapterPool::PendingOperation}.
25
+ # Callers can fan out multiple fetches in parallel and await them all.
26
+ #
27
+ # @param query [String, nil]
28
+ # @param cancellation_token [Phronomy::CancellationToken, nil]
29
+ # @param timeout [Numeric, nil] seconds before the operation is abandoned
30
+ # @return [BlockingAdapterPool::PendingOperation]
31
+ # @api public
32
+ def fetch_async(query: nil, cancellation_token: nil, timeout: nil)
33
+ Phronomy::Runtime.instance.blocking_io.submit(
34
+ timeout: timeout,
35
+ cancellation_token: cancellation_token
36
+ ) do
37
+ fetch(query: query, cancellation_token: cancellation_token)
38
+ end
39
+ end
40
+
23
41
  # Returns true when this source's content is considered static (i.e. does
24
42
  # not change between agent invocations). Static sources are eligible for
25
43
  # fingerprint-based caching in ContextVersionCache.
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module LLMAdapter
5
+ # Abstract base class for LLM adapters.
6
+ #
7
+ # Subclasses must implement {#complete} and {#stream}.
8
+ # The agent pipeline calls {#complete_async} / {#stream_async} which wrap
9
+ # those methods in a {BlockingAdapterPool} submission.
10
+ class Base
11
+ # Performs a blocking (non-streaming) LLM completion.
12
+ # Implementors must call +chat.ask(message)+ (or equivalent) and
13
+ # return the response object.
14
+ #
15
+ # @param chat [Object] the configured chat session object
16
+ # @param message [String] the user message
17
+ # @param config [Hash] the invocation config (e.g. +:cancellation_token+)
18
+ # @return [Object] LLM response object
19
+ # @raise [NotImplementedError]
20
+ # @api private
21
+ def complete(chat, message, config: {})
22
+ raise NotImplementedError, "#{self.class}#complete is not implemented"
23
+ end
24
+
25
+ # Performs a blocking streaming LLM completion.
26
+ # Implementors must call +chat.ask(message) { |chunk| block.call(chunk) }+
27
+ # (or equivalent) and return the response object.
28
+ #
29
+ # @param chat [Object] the configured chat session object
30
+ # @param message [String] the user message
31
+ # @param config [Hash] the invocation config
32
+ # @yield [chunk] streaming chunk from the LLM
33
+ # @return [Object] LLM response object
34
+ # @raise [NotImplementedError]
35
+ # @api private
36
+ def stream(chat, message, config: {}, &block)
37
+ raise NotImplementedError, "#{self.class}#stream is not implemented"
38
+ end
39
+
40
+ # Submits a non-streaming LLM call to {BlockingAdapterPool} and returns
41
+ # a {BlockingAdapterPool::PendingOperation}.
42
+ #
43
+ # @param chat [Object] configured chat session
44
+ # @param message [String] user message
45
+ # @param config [Hash] invocation config
46
+ # @param pool [BlockingAdapterPool] pool to submit to
47
+ # @return [BlockingAdapterPool::PendingOperation]
48
+ # @api private
49
+ def complete_async(chat, message, config: {}, pool: default_pool)
50
+ token = config[:cancellation_token]
51
+ timeout = config[:llm_timeout]
52
+ pool.submit(timeout: timeout, cancellation_token: token) do
53
+ complete(chat, message, config: config)
54
+ end
55
+ end
56
+
57
+ # Submits a streaming LLM call to {BlockingAdapterPool} and returns
58
+ # a {BlockingAdapterPool::PendingOperation}.
59
+ #
60
+ # When +enqueue_to:+ is given, streaming chunks are pushed into that
61
+ # {AsyncQueue} from the worker thread instead of being passed directly
62
+ # to the caller's block. The queue is closed (via +ensure+) after the
63
+ # LLM call finishes so the consumer's drain loop terminates naturally.
64
+ # This keeps user-supplied blocks off the blocking-pool worker thread.
65
+ #
66
+ # When +enqueue_to:+ is nil and a block is given, the block is invoked
67
+ # directly from the worker thread (legacy behaviour, preserved for
68
+ # backward compatibility).
69
+ #
70
+ # @param chat [Object] configured chat session
71
+ # @param message [String] user message
72
+ # @param config [Hash] invocation config
73
+ # @param pool [BlockingAdapterPool] pool to submit to
74
+ # @param enqueue_to [AsyncQueue, nil] when set, push chunks here instead of
75
+ # calling the block on the worker thread
76
+ # @yield [chunk] streaming chunk — only used when +enqueue_to:+ is nil
77
+ # @return [BlockingAdapterPool::PendingOperation]
78
+ # @api private
79
+ def stream_async(chat, message, config: {}, pool: default_pool, enqueue_to: nil, &block)
80
+ token = config[:cancellation_token]
81
+ timeout = config[:llm_timeout]
82
+ if enqueue_to
83
+ pool.submit(timeout: timeout, cancellation_token: token) do
84
+ stream(chat, message, config: config) do |chunk|
85
+ enqueue_to.push(chunk)
86
+ end
87
+ ensure
88
+ enqueue_to.close
89
+ end
90
+ else
91
+ pool.submit(timeout: timeout, cancellation_token: token) do
92
+ stream(chat, message, config: config, &block)
93
+ end
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ def default_pool
100
+ Phronomy::Runtime.instance.blocking_io
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module LLMAdapter
5
+ # LLM adapter that delegates to the RubyLLM blocking client.
6
+ #
7
+ # This is the default adapter used by Phronomy agents. It wraps
8
+ # +chat.ask+ (and its streaming variant) so that the blocking HTTP
9
+ # call runs inside {BlockingAdapterPool} rather than on the EventLoop
10
+ # thread or the caller's thread directly.
11
+ #
12
+ # @example Explicitly configuring this adapter
13
+ # Phronomy.configure do |c|
14
+ # c.llm_adapter = Phronomy::LLMAdapter::RubyLLM.new
15
+ # end
16
+ class RubyLLM < Base
17
+ # Delegates to +chat.ask(message)+.
18
+ #
19
+ # @param chat [Object] RubyLLM chat session
20
+ # @param message [String] user message
21
+ # @param config [Hash] invocation config (not used directly by this impl)
22
+ # @return [Object] RubyLLM response
23
+ # @api private
24
+ def complete(chat, message, config: {})
25
+ chat.ask(message)
26
+ end
27
+
28
+ # Delegates to +chat.ask(message) { |chunk| block.call(chunk) }+.
29
+ #
30
+ # @param chat [Object] RubyLLM chat session
31
+ # @param message [String] user message
32
+ # @param config [Hash] invocation config
33
+ # @yield [chunk] streaming chunk forwarded from +chat.ask+
34
+ # @return [Object] RubyLLM response
35
+ # @api private
36
+ def stream(chat, message, config: {}, &block)
37
+ chat.ask(message, &block)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Namespace for LLM adapter implementations.
5
+ #
6
+ # An LLMAdapter decouples Phronomy's agent pipeline from direct
7
+ # dependency on the RubyLLM blocking client. All LLM calls in
8
+ # {Agent::Base} are routed through the adapter so that:
9
+ #
10
+ # - Blocking HTTP can be submitted to {BlockingAdapterPool} for bounded
11
+ # concurrency and per-operation timeouts.
12
+ # - Alternative LLM clients can be swapped in without changing agent code.
13
+ #
14
+ # @example Configuring a custom adapter
15
+ # Phronomy.configure do |c|
16
+ # c.llm_adapter = MyCustomAdapter.new
17
+ # end
18
+ module LLMAdapter
19
+ end
20
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Task-centric observability snapshot (Issue #276, extended in #307).
5
+ #
6
+ # Collects live metrics from the shared Runtime components
7
+ # (BlockingAdapterPool, EventLoop, and Runtime task registry) and returns
8
+ # them as a plain Hash so they can be forwarded to any monitoring backend
9
+ # (Prometheus, OpenTelemetry, StatsD, etc.).
10
+ #
11
+ # All metrics are read at the moment {.snapshot} is called; no
12
+ # persistent state is held here.
13
+ #
14
+ # @example Exporting to a metrics endpoint
15
+ # data = Phronomy::Metrics.snapshot
16
+ # # => { blocking_pool_active: 2, active_agent_tasks: 1, ... }
17
+ module Metrics
18
+ # Returns a Hash of current observability metrics.
19
+ #
20
+ # @return [Hash{Symbol => Numeric}]
21
+ # @api public
22
+ def self.snapshot
23
+ pool = Runtime.instance.blocking_io
24
+ el = EventLoop.instance
25
+ task_snap = Runtime.instance.task_snapshot
26
+
27
+ {
28
+ blocking_pool_active: pool.active_count,
29
+ blocking_pool_queue_length: pool.queue_depth,
30
+ blocking_pool_abandoned_total: pool.abandoned_count,
31
+ blocking_pool_size: pool.pool_size,
32
+ event_loop_lag_last_ms: (el.last_lag_seconds * 1000).round(3),
33
+ event_loop_lag_max_ms: (el.max_lag_seconds * 1000).round(3),
34
+ event_loop_lag_average_ms: (el.average_lag_seconds * 1000).round(3)
35
+ }.merge(task_snap)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,412 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class Runtime
5
+ # Tick-based deterministic cooperative scheduler for testing.
6
+ #
7
+ # Unlike {FakeScheduler} (which runs every task synchronously to completion
8
+ # before +spawn+ returns), +DeterministicScheduler+ pushes each task to a
9
+ # ready queue and only advances execution one step at a time via {#tick}.
10
+ # This makes it possible to test:
11
+ #
12
+ # - Task interleaving (two tasks yielding control back and forth)
13
+ # - Virtual-time timer firing order
14
+ # - +await+ suspension and resumption
15
+ # - Cancellation while a task is suspended
16
+ #
17
+ # @example Basic usage
18
+ # sched = Phronomy::Runtime::DeterministicScheduler.new
19
+ # rt = Phronomy::Runtime.new(scheduler: sched)
20
+ #
21
+ # rt.spawn { Fiber.yield; :done } # not started yet
22
+ # sched.tick # runs until first Fiber.yield
23
+ # sched.tick # runs to completion
24
+ # sched.run_until_idle # same as calling tick until empty
25
+ #
26
+ # @example Virtual clock
27
+ # sched.schedule_after(1.0) { puts "fired at T=1" }
28
+ # sched.advance(1.0) # moves virtual clock forward, fires the timer
29
+ # sched.run_until_idle # dispatches the timer callback
30
+ # EXPERIMENTAL Fiber-based cooperative scheduler.
31
+ #
32
+ # Uses {Task::FiberBackend} to run tasks cooperatively without OS threads.
33
+ # Intended for deterministic testing and, in future, as a production
34
+ # cooperative scheduler. Not recommended for production use.
35
+ #
36
+ # Activated via +runtime_backend: :fiber+ in {Phronomy.configure}.
37
+ # @api private
38
+ class DeterministicScheduler < Scheduler
39
+ # Scheduler-aware signal for cooperative suspension.
40
+ #
41
+ # Used by {ConcurrencyGate} and {TaskGroup} to suspend a Fiber until a
42
+ # slot or condition becomes available, without blocking the OS thread.
43
+ # All methods must be called from within a {DeterministicScheduler} tick.
44
+ # @api private
45
+ class CoopSignal
46
+ def initialize(scheduler)
47
+ @scheduler = scheduler
48
+ @waiters = [] # Array of Fiber
49
+ end
50
+
51
+ # Suspends the current Fiber until {#notify_one} or {#notify_all} fires.
52
+ # @api private
53
+ # @return [void]
54
+ def wait
55
+ @waiters << Fiber.current
56
+ # Yield with :cooperative_suspend so step_callable knows not to
57
+ # automatically re-enqueue this Fiber — only an explicit notify call
58
+ # should resume it.
59
+ Fiber.yield(:cooperative_suspend)
60
+ end
61
+
62
+ # Wakes up one waiting Fiber.
63
+ # @api private
64
+ # @return [void]
65
+ def notify_one
66
+ waiter = @waiters.shift
67
+ @scheduler.enqueue_fiber(-> { waiter.resume }) if waiter
68
+ end
69
+
70
+ # Wakes up all waiting Fibers.
71
+ # @api private
72
+ # @return [void]
73
+ def notify_all
74
+ waiters, @waiters = @waiters, []
75
+ waiters.each { |w| @scheduler.enqueue_fiber(-> { w.resume }) }
76
+ end
77
+ end
78
+
79
+ # @return [Float] current virtual clock time (seconds since scheduler creation)
80
+ attr_reader :virtual_time
81
+
82
+ # @param autorun [Boolean] when +true+, each call to {#spawn} automatically
83
+ # drains the ready queue via {#run_until_idle} before returning the task.
84
+ # This makes +DeterministicScheduler+ behave like {FakeScheduler} (tasks
85
+ # complete synchronously) while still executing them on real Fibers.
86
+ # Used internally by the +:fiber+ runtime backend.
87
+ # @api private
88
+ def initialize(autorun: false)
89
+ @autorun = autorun
90
+ @ready = [] # Array of callables ({ fiber.resume } or timer callbacks)
91
+ @mutex = Mutex.new
92
+ @virtual_time = 0.0
93
+ @timer_heap = [] # Array of { fire_at:, callback: }
94
+ @real_timer_heap = [] # Array of [fire_at_monotonic, callback] for wall-clock timers
95
+ @clock = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
96
+ # Tracks Fibers suspended in BlockingAdapterPool#await so that
97
+ # run_until_idle knows to keep looping until worker threads complete.
98
+ # Protected by @await_mutex (separate from @mutex to avoid contention).
99
+ @pending_awaits = 0
100
+ @await_mutex = Mutex.new
101
+ @await_cond = ConditionVariable.new
102
+ end
103
+
104
+ # Returns +true+ when this scheduler is in autorun mode.
105
+ # @return [Boolean]
106
+ # @api private
107
+ def autorun?
108
+ @autorun
109
+ end
110
+
111
+ # Spawns a new {Task} backed by {Task::FiberBackend} and enqueues it.
112
+ # The task does NOT start executing until {#tick} is called.
113
+ #
114
+ # @param name [String, nil]
115
+ # @param parent [Task, nil]
116
+ # @return [Task]
117
+ # @api private
118
+ def spawn(name:, parent:, &block)
119
+ task = Task.spawn(name: name, parent: parent, backend_class: Task::FiberBackend, &block)
120
+ backend = task.backend
121
+ # Build a self-rescheduling step: after each step, re-enqueue if the
122
+ # Fiber yielded cooperatively and is still alive.
123
+ step_callable = nil
124
+ step_callable = lambda do
125
+ backend.step
126
+ enqueue_fiber(step_callable) if backend.alive? && !backend.cooperative_suspend?
127
+ end
128
+ enqueue_fiber(step_callable)
129
+ # Auto-run only when called from outside a running scheduler tick.
130
+ # When SCHEDULER_KEY is set, the calling code is already inside a managed
131
+ # Fiber; the outer run_until_idle loop will pick up the new task on the
132
+ # next iteration without a recursive re-entry.
133
+ run_until_idle if @autorun && Thread.current.thread_variable_get(SCHEDULER_KEY).nil?
134
+ task
135
+ end
136
+
137
+ # Creates a new cooperative signal backed by {CoopSignal}.
138
+ # @return [CoopSignal]
139
+ # @api private
140
+ def new_signal
141
+ CoopSignal.new(self)
142
+ end
143
+
144
+ # Suspends the current Fiber until +signal+ is notified.
145
+ # @param signal [CoopSignal]
146
+ # @return [void]
147
+ # @api private
148
+ def wait_for_signal(signal)
149
+ signal.wait
150
+ end
151
+
152
+ # Wakes up one Fiber waiting on +signal+.
153
+ # @param signal [CoopSignal]
154
+ # @return [void]
155
+ # @api private
156
+ def raise_signal(signal)
157
+ signal.notify_one
158
+ end
159
+
160
+ # Wakes up all Fibers waiting on +signal+.
161
+ # @param signal [CoopSignal]
162
+ # @return [void]
163
+ # @api private
164
+ def raise_signal_all(signal)
165
+ signal.notify_all
166
+ end
167
+
168
+ # Executes one ready entry (a fiber step or a timer callback).
169
+ # Sets the thread-local scheduler reference so that +FiberBackend#await+
170
+ # can suspend cooperatively.
171
+ #
172
+ # @return [self]
173
+ # @api private
174
+ def tick
175
+ callable = @mutex.synchronize { @ready.shift }
176
+ return self unless callable
177
+
178
+ # Use thread_variable_set (not Thread#[]) so the value is accessible from
179
+ # any Fiber running on this OS thread, not just the current Fiber.
180
+ prev = Thread.current.thread_variable_get(SCHEDULER_KEY)
181
+ Thread.current.thread_variable_set(SCHEDULER_KEY, self)
182
+ callable.call
183
+ ensure
184
+ Thread.current.thread_variable_set(SCHEDULER_KEY, prev)
185
+ end
186
+
187
+ # Drains the ready queue by calling {#tick} until it is empty.
188
+ #
189
+ # In autorun mode ({#autorun?} is +true+), also handles wall-clock timers
190
+ # and cooperative blocking-I/O awaits:
191
+ # - Fires any timers whose deadline has already passed on each iteration.
192
+ # - When all ready tasks are done but future timers remain pending, sleeps
193
+ # until the next deadline and fires them.
194
+ # - When Fibers are suspended in {BlockingAdapterPool::PendingOperation#await}
195
+ # (tracked via {#track_blocking_await}), waits on a condition variable
196
+ # that is broadcast by {#enqueue_fiber} when the worker thread completes
197
+ # (Issue #338). This ensures run_until_idle does not exit while blocking
198
+ # I/O operations are still in flight.
199
+ #
200
+ # Does not fire pending virtual timers — call {#advance} for those.
201
+ #
202
+ # @return [self]
203
+ # @api private
204
+ def run_until_idle
205
+ if @autorun
206
+ loop do
207
+ fire_real_timers
208
+ tick until idle?
209
+
210
+ # Atomically check all exit conditions.
211
+ should_break = @await_mutex.synchronize do
212
+ idle? && pending_real_timer_count.zero? && @pending_awaits.zero?
213
+ end
214
+ break if should_break
215
+
216
+ if idle?
217
+ if pending_real_timer_count > 0 &&
218
+ @await_mutex.synchronize { @pending_awaits.zero? }
219
+ # Only real timers pending — sleep until the next deadline.
220
+ sleep_until_next_real_timer
221
+ else
222
+ # Pending blocking awaits (pool workers still running).
223
+ # Wait for the completion signal broadcast by enqueue_fiber /
224
+ # complete_blocking_await (30-second safety cap).
225
+ @await_mutex.synchronize { @await_cond.wait(@await_mutex, 30) }
226
+ end
227
+ end
228
+ end
229
+ else
230
+ tick until idle?
231
+ end
232
+ self
233
+ end
234
+
235
+ # Advances the virtual clock by +seconds+ and enqueues any timer
236
+ # callbacks that are now due.
237
+ #
238
+ # @param seconds [Numeric]
239
+ # @return [self]
240
+ # @api private
241
+ def advance(seconds)
242
+ @virtual_time += seconds
243
+ fire_due_timers
244
+ self
245
+ end
246
+
247
+ # Schedules +callback+ to fire at the given absolute virtual time.
248
+ #
249
+ # @param absolute_time [Float]
250
+ # @yield callback to invoke when the virtual clock reaches +absolute_time+
251
+ # @return [self]
252
+ # @api private
253
+ def schedule_at(absolute_time, &callback)
254
+ @mutex.synchronize do
255
+ @timer_heap << {fire_at: absolute_time, callback: callback}
256
+ @timer_heap.sort_by! { |e| e[:fire_at] }
257
+ end
258
+ self
259
+ end
260
+
261
+ # Schedules +callback+ to fire +delay+ seconds from now (virtual time).
262
+ #
263
+ # @param delay [Numeric]
264
+ # @yield callback
265
+ # @return [self]
266
+ # @api private
267
+ def schedule_after(delay, &callback)
268
+ schedule_at(@virtual_time + delay, &callback)
269
+ end
270
+
271
+ # Enqueues a callable (Fiber step or arbitrary block) onto the ready queue.
272
+ # Called by {Task::FiberBackend#await} to resume a waiting Fiber.
273
+ # Also wakes any thread blocked in {#run_until_idle} waiting for external
274
+ # completion signals (e.g. from {BlockingAdapterPool} worker threads).
275
+ #
276
+ # @param callable [#call]
277
+ # @return [self]
278
+ # @api private
279
+ def enqueue_fiber(callable)
280
+ @mutex.synchronize { @ready << callable }
281
+ # Broadcast to wake run_until_idle if it is sleeping on @await_cond.
282
+ # @await_mutex is always acquired AFTER releasing @mutex (never nested)
283
+ # to guarantee consistent lock ordering and avoid deadlocks.
284
+ @await_mutex.synchronize { @await_cond.broadcast }
285
+ self
286
+ end
287
+
288
+ # Returns +true+ when there are no ready entries to dispatch.
289
+ # @return [Boolean]
290
+ # @api private
291
+ def idle?
292
+ @mutex.synchronize { @ready.empty? }
293
+ end
294
+
295
+ # Returns the number of entries currently in the ready queue.
296
+ # @return [Integer]
297
+ # @api private
298
+ def ready_count
299
+ @mutex.synchronize { @ready.size }
300
+ end
301
+
302
+ # Returns a list of pending timer entries (not yet fired).
303
+ # Each entry has +:fire_at+ and +:description+ (if set) keys.
304
+ # @return [Array<Hash>]
305
+ # @api private
306
+ def pending_timers
307
+ @mutex.synchronize { @timer_heap.dup }
308
+ end
309
+
310
+ # Schedules +callback+ to fire +seconds+ from now (wall-clock time).
311
+ #
312
+ # Unlike {#schedule_after} (which uses virtual time), this method uses
313
+ # the real monotonic clock. Callbacks are fired during {#run_until_idle}
314
+ # when {#autorun?} is +true+, or explicitly via {#fire_real_timers}.
315
+ #
316
+ # This is the integration point for {TimerQueue} replacement: when a
317
+ # {Runtime} is backed by a +DeterministicScheduler+, its {Runtime#timer_queue}
318
+ # returns a {SchedulerTimerAdapter} that delegates here instead of spawning
319
+ # a background OS thread.
320
+ #
321
+ # @param seconds [Numeric] delay before the callback fires
322
+ # @yield called when the deadline is reached
323
+ # @return [self]
324
+ # @api private
325
+ def schedule_real_after(seconds, &callback)
326
+ fire_at = @clock.call + seconds.to_f
327
+ @mutex.synchronize do
328
+ @real_timer_heap << [fire_at, callback]
329
+ @real_timer_heap.sort_by! { |(t, _)| t }
330
+ end
331
+ self
332
+ end
333
+
334
+ # Fires all wall-clock timer callbacks whose deadline has passed.
335
+ # Enqueues each fired callback onto the ready queue for scheduler dispatch.
336
+ #
337
+ # @return [self]
338
+ # @api private
339
+ def fire_real_timers
340
+ now = @clock.call
341
+ due = @mutex.synchronize do
342
+ ready, pending = @real_timer_heap.partition { |(t, _)| t <= now }
343
+ @real_timer_heap.replace(pending)
344
+ ready
345
+ end
346
+ due.each { |(_, cb)| enqueue_fiber(cb) }
347
+ self
348
+ end
349
+
350
+ # Returns the number of pending wall-clock timer entries (not yet fired).
351
+ # @return [Integer]
352
+ # @api private
353
+ def pending_real_timer_count
354
+ @mutex.synchronize { @real_timer_heap.size }
355
+ end
356
+
357
+ # Registers one pending cooperative blocking-I/O await.
358
+ # Called by {BlockingAdapterPool::PendingOperation#await} before
359
+ # +Fiber.yield+ so that {#run_until_idle} knows not to exit yet.
360
+ # Each call must be balanced by a {#complete_blocking_await} call.
361
+ # @return [self]
362
+ # @api private
363
+ def track_blocking_await
364
+ @await_mutex.synchronize { @pending_awaits += 1 }
365
+ self
366
+ end
367
+
368
+ # Marks one pending cooperative blocking-I/O await as complete.
369
+ # Called from the {BlockingAdapterPool::PendingOperation#on_complete}
370
+ # callback (on the pool worker thread) after the result is ready.
371
+ # Decrements the counter and broadcasts to wake {#run_until_idle}.
372
+ # @return [self]
373
+ # @api private
374
+ def complete_blocking_await
375
+ @await_mutex.synchronize do
376
+ @pending_awaits -= 1
377
+ @await_cond.broadcast
378
+ end
379
+ self
380
+ end
381
+
382
+ private
383
+
384
+ def real_timers_due?
385
+ now = @clock.call
386
+ @mutex.synchronize { @real_timer_heap.any? { |(t, _)| t <= now } }
387
+ end
388
+
389
+ # Sleeps until the nearest pending real-timer deadline, then returns.
390
+ # Called only from run_until_idle when the ready queue is empty and at
391
+ # least one future real-timer is pending. Ensures we never overshoot:
392
+ # sleep is bounded to the exact remaining time to the next deadline.
393
+ # @api private
394
+ def sleep_until_next_real_timer
395
+ next_at = @mutex.synchronize { @real_timer_heap.first&.first }
396
+ return unless next_at
397
+
398
+ wait_duration = [next_at - @clock.call, 0.0].max
399
+ sleep(wait_duration) if wait_duration > 0
400
+ end
401
+
402
+ def fire_due_timers
403
+ due = @mutex.synchronize do
404
+ ready, pending = @timer_heap.partition { |e| e[:fire_at] <= @virtual_time }
405
+ @timer_heap.replace(pending)
406
+ ready
407
+ end
408
+ due.each { |e| enqueue_fiber(e[:callback]) }
409
+ end
410
+ end
411
+ end
412
+ end