phronomy 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +151 -1134
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +69 -1249
  6. data/benchmark/baseline.json +2 -3
  7. data/benchmark/bench_agent_invoke.rb +4 -4
  8. data/benchmark/bench_context_assembler.rb +134 -34
  9. data/benchmark/bench_regression.rb +26 -6
  10. data/benchmark/bench_tool_schema.rb +2 -35
  11. data/docs/changelog/0.14-and-earlier.md +1137 -0
  12. data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
  13. data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
  14. data/docs/decisions/010-cooperative-first-concurrency.md +155 -228
  15. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +2 -2
  16. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  17. data/docs/features.md +87 -0
  18. data/docs/getting-started.md +351 -0
  19. data/docs/migrations/0.15.md +35 -0
  20. data/docs/migrations/0.16.md +43 -0
  21. data/docs/runtime-and-concurrency.md +258 -0
  22. data/examples/workflows/generic_task_event_mapping.rb +14 -6
  23. data/lib/phronomy/agent/agent_invocation.rb +2 -36
  24. data/lib/phronomy/agent/agent_invocation_session_builder.rb +157 -94
  25. data/lib/phronomy/agent/agent_root.rb +1 -2
  26. data/lib/phronomy/agent/async_event_api.rb +3 -3
  27. data/lib/phronomy/agent/base.rb +170 -333
  28. data/lib/phronomy/agent/context/capability/base.rb +177 -298
  29. data/lib/phronomy/agent/context_assembler.rb +65 -29
  30. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
  31. data/lib/phronomy/agent/context_plan_validator.rb +0 -33
  32. data/lib/phronomy/agent/execution_coordinator.rb +6 -7
  33. data/lib/phronomy/agent/journal_projection.rb +28 -2
  34. data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
  35. data/lib/phronomy/agent/shared_state.rb +48 -138
  36. data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
  37. data/lib/phronomy/agent/tool_executor.rb +29 -71
  38. data/lib/phronomy/agent/tool_invocation.rb +180 -336
  39. data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
  40. data/lib/phronomy/agent.rb +6 -10
  41. data/lib/phronomy/configuration.rb +4 -171
  42. data/lib/phronomy/diagnostics.rb +12 -41
  43. data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
  44. data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
  45. data/lib/phronomy/engine/concurrency/cancellation_token.rb +51 -79
  46. data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
  47. data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
  48. data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
  49. data/lib/phronomy/engine/event_loop.rb +89 -190
  50. data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
  51. data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
  52. data/lib/phronomy/engine/runtime.rb +49 -377
  53. data/lib/phronomy/engine/task.rb +136 -277
  54. data/lib/phronomy/llm_adapter/base.rb +14 -14
  55. data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
  56. data/lib/phronomy/llm_adapter.rb +2 -2
  57. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  58. data/lib/phronomy/metrics.rb +15 -30
  59. data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
  60. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
  61. data/lib/phronomy/multi_agent/handoff.rb +1 -0
  62. data/lib/phronomy/multi_agent/orchestrator.rb +252 -256
  63. data/lib/phronomy/multi_agent/team_coordinator.rb +44 -133
  64. data/lib/phronomy/testing/eval/comparison.rb +23 -0
  65. data/lib/phronomy/testing/eval/dataset.rb +27 -0
  66. data/lib/phronomy/testing/eval/eval_case.rb +13 -0
  67. data/lib/phronomy/testing/eval/eval_result.rb +16 -0
  68. data/lib/phronomy/testing/eval/metrics.rb +43 -0
  69. data/lib/phronomy/testing/eval/runner.rb +52 -0
  70. data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
  71. data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
  72. data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
  73. data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
  74. data/lib/phronomy/testing/eval/scorer.rb +10 -0
  75. data/lib/phronomy/testing/eval.rb +9 -0
  76. data/lib/phronomy/testing/fake_clock.rb +6 -53
  77. data/lib/phronomy/testing.rb +2 -6
  78. data/lib/phronomy/tools/agent.rb +141 -6
  79. data/lib/phronomy/vector_store/async_backend.rb +21 -17
  80. data/lib/phronomy/vector_store/base.rb +2 -2
  81. data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
  82. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  83. data/lib/phronomy/version.rb +1 -1
  84. data/lib/phronomy/workflow_runner.rb +2 -4
  85. data/lib/phronomy.rb +7 -121
  86. data/scripts/api_snapshot.rb +4 -15
  87. metadata +24 -38
  88. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  89. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  90. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  91. data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
  92. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
  93. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
  94. data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
  95. data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
  96. data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
  97. data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
  98. data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
  99. data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
  100. data/lib/phronomy/engine/task/backend.rb +0 -80
  101. data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
  102. data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
  103. data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
  104. data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
  105. data/lib/phronomy/engine/task/thread_backend.rb +0 -84
  106. data/lib/phronomy/engine/task_group.rb +0 -265
  107. data/lib/phronomy/eval/comparison.rb +0 -47
  108. data/lib/phronomy/eval/dataset.rb +0 -45
  109. data/lib/phronomy/eval/eval_case.rb +0 -17
  110. data/lib/phronomy/eval/eval_result.rb +0 -29
  111. data/lib/phronomy/eval/metrics.rb +0 -66
  112. data/lib/phronomy/eval/runner.rb +0 -94
  113. data/lib/phronomy/eval/scorer/base.rb +0 -22
  114. data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
  115. data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
  116. data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
  117. data/lib/phronomy/eval/scorer.rb +0 -9
  118. data/lib/phronomy/eval.rb +0 -7
  119. data/lib/phronomy/knowledge_source.rb +0 -12
  120. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  121. data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
  122. data/lib/phronomy/testing/fake_scheduler.rb +0 -104
  123. data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
@@ -1,439 +0,0 @@
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 {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
- # Tracks which OS thread is currently executing run_until_idle.
103
- # When a spawn is called from a DIFFERENT thread while run_until_idle is
104
- # active, creating a FiberBackend on that thread would produce a Fiber
105
- # that the run_until_idle owner thread cannot resume (Ruby 3+ restriction).
106
- # The mutex is separate from @mutex/@await_mutex to avoid lock-ordering issues.
107
- @rui_mutex = Mutex.new
108
- @run_until_idle_thread = nil
109
- end
110
-
111
- # Returns +true+ when this scheduler is in autorun mode.
112
- # @return [Boolean]
113
- # @api private
114
- def autorun?
115
- @autorun
116
- end
117
-
118
- # Spawns a new {Task} backed by {Task::FiberBackend} and enqueues it.
119
- # The task does NOT start executing until {#tick} is called.
120
- #
121
- # @param name [String, nil]
122
- # @param parent [Task, nil]
123
- # @return [Task]
124
- # @api private
125
- def spawn(name:, parent:, &block)
126
- inside_tick = Thread.current.thread_variable_get(SCHEDULER_KEY)
127
- rui_thread = @rui_mutex.synchronize { @run_until_idle_thread }
128
-
129
- # Cross-thread guard: if run_until_idle is active on a DIFFERENT OS thread
130
- # (e.g. the EventLoop calling Runtime.instance.spawn while the test thread
131
- # is blocked in run_until_idle waiting for a cross-thread push), creating a
132
- # FiberBackend here would produce a Fiber owned by THIS thread. The
133
- # run_until_idle owner thread cannot resume it, which raises:
134
- # FiberError: fiber called across threads
135
- # Fall back to ThreadBackend so the block runs on its own OS thread instead,
136
- # and broadcast @await_cond so the owner thread is notified of new work.
137
- if !inside_tick && rui_thread && rui_thread != Thread.current
138
- task = Task.spawn(name: name, parent: parent, backend_class: Task::ThreadBackend, &block)
139
- @await_mutex.synchronize { @await_cond.broadcast }
140
- return task
141
- end
142
-
143
- task = Task.spawn(name: name, parent: parent, backend_class: Task::FiberBackend, &block)
144
- backend = task.backend
145
- # Build a self-rescheduling step: after each step, re-enqueue if the
146
- # Fiber yielded cooperatively and is still alive.
147
- step_callable = nil
148
- step_callable = lambda do
149
- backend.step
150
- enqueue_fiber(step_callable) if backend.alive? && !backend.cooperative_suspend?
151
- end
152
- enqueue_fiber(step_callable)
153
- # Auto-run only when called from outside a running scheduler tick.
154
- # When SCHEDULER_KEY is set, the calling code is already inside a managed
155
- # Fiber; the outer run_until_idle loop will pick up the new task on the
156
- # next iteration without a recursive re-entry.
157
- run_until_idle if @autorun && !inside_tick
158
- task
159
- end
160
-
161
- # Creates a new cooperative signal backed by {CoopSignal}.
162
- # @return [CoopSignal]
163
- # @api private
164
- def new_signal
165
- CoopSignal.new(self)
166
- end
167
-
168
- # Suspends the current Fiber until +signal+ is notified.
169
- # @param signal [CoopSignal]
170
- # @return [void]
171
- # @api private
172
- def wait_for_signal(signal)
173
- signal.wait
174
- end
175
-
176
- # Wakes up one Fiber waiting on +signal+.
177
- # @param signal [CoopSignal]
178
- # @return [void]
179
- # @api private
180
- def raise_signal(signal)
181
- signal.notify_one
182
- end
183
-
184
- # Wakes up all Fibers waiting on +signal+.
185
- # @param signal [CoopSignal]
186
- # @return [void]
187
- # @api private
188
- def raise_signal_all(signal)
189
- signal.notify_all
190
- end
191
-
192
- # Executes one ready entry (a fiber step or a timer callback).
193
- # Sets the thread-local scheduler reference so that +FiberBackend#await+
194
- # can suspend cooperatively.
195
- #
196
- # @return [self]
197
- # @api private
198
- def tick
199
- callable = @mutex.synchronize { @ready.shift }
200
- return self unless callable
201
-
202
- # Use thread_variable_set (not Thread#[]) so the value is accessible from
203
- # any Fiber running on this OS thread, not just the current Fiber.
204
- prev = Thread.current.thread_variable_get(SCHEDULER_KEY)
205
- Thread.current.thread_variable_set(SCHEDULER_KEY, self)
206
- callable.call
207
- ensure
208
- Thread.current.thread_variable_set(SCHEDULER_KEY, prev)
209
- end
210
-
211
- # Drains the ready queue by calling {#tick} until it is empty.
212
- #
213
- # In autorun mode ({#autorun?} is +true+), also handles wall-clock timers
214
- # and cooperative blocking-I/O awaits:
215
- # - Fires any timers whose deadline has already passed on each iteration.
216
- # - When all ready tasks are done but future timers remain pending, sleeps
217
- # until the next deadline and fires them.
218
- # - When Fibers are suspended in {BlockingAdapterPool::PendingOperation#await}
219
- # (tracked via {#track_blocking_await}), waits on a condition variable
220
- # that is broadcast by {#enqueue_fiber} when the worker thread completes
221
- # (Issue #338). This ensures run_until_idle does not exit while blocking
222
- # I/O operations are still in flight.
223
- #
224
- # Does not fire pending virtual timers — call {#advance} for those.
225
- #
226
- # @return [self]
227
- # @api private
228
- def run_until_idle
229
- @rui_mutex.synchronize { @run_until_idle_thread = Thread.current }
230
- if @autorun
231
- loop do
232
- fire_real_timers
233
- tick until idle?
234
-
235
- # Atomically check all exit conditions.
236
- should_break = @await_mutex.synchronize do
237
- idle? && pending_real_timer_count.zero? && @pending_awaits.zero?
238
- end
239
- break if should_break
240
-
241
- if idle?
242
- if pending_real_timer_count > 0 &&
243
- @await_mutex.synchronize { @pending_awaits.zero? }
244
- # Only real timers pending — sleep until the next deadline.
245
- sleep_until_next_real_timer
246
- else
247
- # Pending blocking awaits (pool workers still running).
248
- # Wait for the completion signal broadcast by enqueue_fiber /
249
- # complete_blocking_await (30-second safety cap).
250
- @await_mutex.synchronize { @await_cond.wait(@await_mutex, 30) }
251
- end
252
- end
253
- end
254
- else
255
- tick until idle?
256
- end
257
- self
258
- ensure
259
- @rui_mutex.synchronize { @run_until_idle_thread = nil }
260
- end
261
-
262
- # Advances the virtual clock by +seconds+ and enqueues any timer
263
- # callbacks that are now due.
264
- #
265
- # @param seconds [Numeric]
266
- # @return [self]
267
- # @api private
268
- def advance(seconds)
269
- @virtual_time += seconds
270
- fire_due_timers
271
- self
272
- end
273
-
274
- # Schedules +callback+ to fire at the given absolute virtual time.
275
- #
276
- # @param absolute_time [Float]
277
- # @yield callback to invoke when the virtual clock reaches +absolute_time+
278
- # @return [self]
279
- # @api private
280
- def schedule_at(absolute_time, &callback)
281
- @mutex.synchronize do
282
- @timer_heap << {fire_at: absolute_time, callback: callback}
283
- @timer_heap.sort_by! { |e| e[:fire_at] }
284
- end
285
- self
286
- end
287
-
288
- # Schedules +callback+ to fire +delay+ seconds from now (virtual time).
289
- #
290
- # @param delay [Numeric]
291
- # @yield callback
292
- # @return [self]
293
- # @api private
294
- def schedule_after(delay, &callback)
295
- schedule_at(@virtual_time + delay, &callback)
296
- end
297
-
298
- # Enqueues a callable (Fiber step or arbitrary block) onto the ready queue.
299
- # Called by {Task::FiberBackend#await} to resume a waiting Fiber.
300
- # Also wakes any thread blocked in {#run_until_idle} waiting for external
301
- # completion signals (e.g. from {BlockingAdapterPool} worker threads).
302
- #
303
- # @param callable [#call]
304
- # @return [self]
305
- # @api private
306
- def enqueue_fiber(callable)
307
- @mutex.synchronize { @ready << callable }
308
- # Broadcast to wake run_until_idle if it is sleeping on @await_cond.
309
- # @await_mutex is always acquired AFTER releasing @mutex (never nested)
310
- # to guarantee consistent lock ordering and avoid deadlocks.
311
- @await_mutex.synchronize { @await_cond.broadcast }
312
- self
313
- end
314
-
315
- # Returns +true+ when there are no ready entries to dispatch.
316
- # @return [Boolean]
317
- # @api private
318
- def idle?
319
- @mutex.synchronize { @ready.empty? }
320
- end
321
-
322
- # Returns the number of entries currently in the ready queue.
323
- # @return [Integer]
324
- # @api private
325
- def ready_count
326
- @mutex.synchronize { @ready.size }
327
- end
328
-
329
- # Returns a list of pending timer entries (not yet fired).
330
- # Each entry has +:fire_at+ and +:description+ (if set) keys.
331
- # @return [Array<Hash>]
332
- # @api private
333
- def pending_timers
334
- @mutex.synchronize { @timer_heap.dup }
335
- end
336
-
337
- # Schedules +callback+ to fire +seconds+ from now (wall-clock time).
338
- #
339
- # Unlike {#schedule_after} (which uses virtual time), this method uses
340
- # the real monotonic clock. Callbacks are fired during {#run_until_idle}
341
- # when {#autorun?} is +true+, or explicitly via {#fire_real_timers}.
342
- #
343
- # This is the integration point for {TimerQueue} replacement: when a
344
- # {Runtime} is backed by a +DeterministicScheduler+, its {Runtime#timer_queue}
345
- # returns a {SchedulerTimerAdapter} that delegates here instead of spawning
346
- # a background OS thread.
347
- #
348
- # @param seconds [Numeric] delay before the callback fires
349
- # @yield called when the deadline is reached
350
- # @return [self]
351
- # @api private
352
- def schedule_real_after(seconds, &callback)
353
- fire_at = @clock.call + seconds.to_f
354
- @mutex.synchronize do
355
- @real_timer_heap << [fire_at, callback]
356
- @real_timer_heap.sort_by! { |(t, _)| t }
357
- end
358
- self
359
- end
360
-
361
- # Fires all wall-clock timer callbacks whose deadline has passed.
362
- # Enqueues each fired callback onto the ready queue for scheduler dispatch.
363
- #
364
- # @return [self]
365
- # @api private
366
- def fire_real_timers
367
- now = @clock.call
368
- due = @mutex.synchronize do
369
- ready, pending = @real_timer_heap.partition { |(t, _)| t <= now }
370
- @real_timer_heap.replace(pending)
371
- ready
372
- end
373
- due.each { |(_, cb)| enqueue_fiber(cb) }
374
- self
375
- end
376
-
377
- # Returns the number of pending wall-clock timer entries (not yet fired).
378
- # @return [Integer]
379
- # @api private
380
- def pending_real_timer_count
381
- @mutex.synchronize { @real_timer_heap.size }
382
- end
383
-
384
- # Registers one pending cooperative blocking-I/O await.
385
- # Called by {BlockingAdapterPool::PendingOperation#await} before
386
- # +Fiber.yield+ so that {#run_until_idle} knows not to exit yet.
387
- # Each call must be balanced by a {#complete_blocking_await} call.
388
- # @return [self]
389
- # @api private
390
- def track_blocking_await
391
- @await_mutex.synchronize { @pending_awaits += 1 }
392
- self
393
- end
394
-
395
- # Marks one pending cooperative blocking-I/O await as complete.
396
- # Called from the {BlockingAdapterPool::PendingOperation#on_complete}
397
- # callback (on the pool worker thread) after the result is ready.
398
- # Decrements the counter and broadcasts to wake {#run_until_idle}.
399
- # @return [self]
400
- # @api private
401
- def complete_blocking_await
402
- @await_mutex.synchronize do
403
- @pending_awaits -= 1
404
- @await_cond.broadcast
405
- end
406
- self
407
- end
408
-
409
- private
410
-
411
- def real_timers_due?
412
- now = @clock.call
413
- @mutex.synchronize { @real_timer_heap.any? { |(t, _)| t <= now } }
414
- end
415
-
416
- # Sleeps until the nearest pending real-timer deadline, then returns.
417
- # Called only from run_until_idle when the ready queue is empty and at
418
- # least one future real-timer is pending. Ensures we never overshoot:
419
- # sleep is bounded to the exact remaining time to the next deadline.
420
- # @api private
421
- def sleep_until_next_real_timer
422
- next_at = @mutex.synchronize { @real_timer_heap.first&.first }
423
- return unless next_at
424
-
425
- wait_duration = [next_at - @clock.call, 0.0].max
426
- sleep(wait_duration) if wait_duration > 0
427
- end
428
-
429
- def fire_due_timers
430
- due = @mutex.synchronize do
431
- ready, pending = @timer_heap.partition { |e| e[:fire_at] <= @virtual_time }
432
- @timer_heap.replace(pending)
433
- ready
434
- end
435
- due.each { |e| enqueue_fiber(e[:callback]) }
436
- end
437
- end
438
- end
439
- end
@@ -1,165 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- class Runtime
5
- # Synchronous scheduler for use in tests.
6
- #
7
- # Each spawned task is executed immediately on the calling thread using
8
- # {Task::ImmediateBackend}. No new threads are created, so a
9
- # {Runtime} that uses +FakeScheduler+ does not increase the process
10
- # Thread count on {Runtime#spawn}.
11
- #
12
- # In addition to the basic synchronous execution, +FakeScheduler+ records
13
- # all task lifecycle events in {#event_log} and all spawned tasks in
14
- # {#tasks}. This allows specs to assert event ordering and task state
15
- # without relying on wall-clock sleeps.
16
- #
17
- # === tick / tick_until
18
- #
19
- # Because +FakeScheduler+ uses {Task::ImmediateBackend}, every task runs
20
- # to completion before {Runtime#spawn} returns. Consequently {#tick} is
21
- # semantically a no-op -- the "ready task" has already executed. It is
22
- # provided so that test code written against the cooperative scheduler
23
- # interface compiles and documents intent (e.g. "advance by one step").
24
- #
25
- # === pending_timers
26
- #
27
- # If a {Phronomy::Testing::FakeClock} is injected via {#clock=}, its
28
- # pending callbacks are surfaced as +pending_timers+.
29
- #
30
- # @example
31
- # runtime = Phronomy::Runtime.new(scheduler: Phronomy::Runtime::FakeScheduler.new)
32
- # task = runtime.spawn(name: "agent-test") { 42 }
33
- # expect(task.wait_result).to eq(42)
34
- # expect(task.status).to eq(:completed)
35
- # @api private
36
- class FakeScheduler < Scheduler
37
- # @return [Array<Hash>] ordered list of task lifecycle events.
38
- # Each entry is +{ type:, task_name:, at: }+ where +type+ is one of
39
- # +:spawned+, +:started+, +:completed+, +:cancelled+, +:failed+ and
40
- # +at+ is a Float monotonic timestamp (seconds).
41
- attr_reader :event_log
42
-
43
- # @return [Array<Hash>] all tasks spawned by this scheduler.
44
- # Each entry is +{ task:, name:, status: }+.
45
- attr_reader :tasks
46
-
47
- # Optional {Phronomy::Testing::FakeClock} used to timestamp events and
48
- # surface pending timers. When +nil+, a real monotonic clock is used.
49
- # @return [Phronomy::Testing::FakeClock, nil]
50
- attr_accessor :clock
51
-
52
- def initialize
53
- @event_log = []
54
- @tasks = []
55
- @clock = nil
56
- @mutex = Mutex.new
57
- end
58
-
59
- # Spawns +block+ as a {Task} backed by {Task::ImmediateBackend}.
60
- # The block executes synchronously before this method returns.
61
- # Lifecycle events are recorded in {#event_log}.
62
- #
63
- # @param name [String, nil]
64
- # @param parent [Task, nil]
65
- # @return [Task]
66
- # @api private
67
- def spawn(name:, parent:, &block)
68
- _log_event(:spawned, name)
69
- task = Task.spawn(name: name, parent: parent, backend_class: Task::ImmediateBackend) do
70
- _log_event(:started, name)
71
- begin
72
- result = block.call
73
- _log_event(:completed, name)
74
- result
75
- rescue CancellationError
76
- _log_event(:cancelled, name)
77
- raise
78
- rescue => e
79
- _log_event(:failed, name)
80
- raise e
81
- end
82
- end
83
- @mutex.synchronize { @tasks << {task: task, name: name, status: task.status} }
84
- task
85
- end
86
-
87
- # Execute one ready task.
88
- #
89
- # Because {Task::ImmediateBackend} runs tasks synchronously inside
90
- # {#spawn}, all ready tasks have already executed by the time this
91
- # method is called. This method is a no-op provided for API
92
- # compatibility with cooperative scheduler interfaces.
93
- #
94
- # @return [self]
95
- # @api private
96
- def tick
97
- self
98
- end
99
-
100
- # Run +block+ repeatedly until it returns truthy or +max_ticks+ is
101
- # reached. Because tasks execute synchronously, the condition is
102
- # evaluated once; if it is already met this method returns immediately.
103
- #
104
- # @param max_ticks [Integer] safety bound (default: 1000)
105
- # @yield condition evaluated after each tick
106
- # @return [Boolean] +true+ if condition was satisfied
107
- # @api private
108
- def tick_until(max_ticks: 1000)
109
- max_ticks.times do
110
- return true if yield
111
- tick
112
- end
113
- yield ? true : false
114
- end
115
-
116
- # Returns a list of pending timer entries surfaced from the injected
117
- # {clock}. Returns an empty array when no clock is set.
118
- #
119
- # @return [Array<Hash>] each entry: +{ fire_at:, description: }+
120
- # @api private
121
- def pending_timers
122
- return [] unless @clock
123
-
124
- @clock.pending_timer_entries
125
- end
126
-
127
- # Assert that the named tasks completed in the given order.
128
- # Raises +RSpec::Expectations::ExpectationNotMetError+ if order is wrong.
129
- # Intended for use inside RSpec examples.
130
- #
131
- # @param names [Array<String, nil>] task names in expected order
132
- # @return [void]
133
- # @api private
134
- def assert_order(*names)
135
- completed = @event_log.select { |e| e[:type] == :completed }.map { |e| e[:task_name] }
136
- indices = names.map { |n| completed.index(n) }
137
- unless indices.none?(&:nil?) && indices == indices.sort
138
- raise RSpec::Expectations::ExpectationNotMetError,
139
- "Expected tasks to complete in order #{names.inspect} " + "but completed order was #{completed.inspect}"
140
- end
141
- end
142
-
143
- # Assert that the named tasks reached +:cancelled+ state.
144
- #
145
- # @param names [Array<String, nil>] task names expected to be cancelled
146
- # @return [void]
147
- # @api private
148
- def assert_cancelled(*names)
149
- cancelled = @event_log.select { |e| e[:type] == :cancelled }.map { |e| e[:task_name] }
150
- missing = names.reject { |n| cancelled.include?(n) }
151
- return if missing.empty?
152
-
153
- raise RSpec::Expectations::ExpectationNotMetError,
154
- "Expected tasks #{missing.inspect} to be cancelled " + "but cancelled tasks were #{cancelled.inspect}"
155
- end
156
-
157
- private
158
-
159
- def _log_event(type, task_name)
160
- at = @clock ? @clock.now : Process.clock_gettime(Process::CLOCK_MONOTONIC)
161
- @mutex.synchronize { @event_log << {type: type, task_name: task_name, at: at} }
162
- end
163
- end
164
- end
165
- end