phronomy 0.7.0 → 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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -7
  3. data/CHANGELOG.md +151 -1
  4. data/README.md +170 -47
  5. data/Rakefile +33 -0
  6. data/benchmark/baseline.json +1 -1
  7. data/benchmark/bench_context_assembler.rb +2 -2
  8. data/benchmark/bench_regression.rb +6 -5
  9. data/benchmark/bench_token_estimator.rb +5 -5
  10. data/benchmark/bench_tool_schema.rb +1 -1
  11. data/benchmark/bench_vector_store.rb +1 -1
  12. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
  13. data/docs/decisions/006-no-built-in-guardrails.md +20 -2
  14. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  15. data/lib/phronomy/agent/base.rb +285 -137
  16. data/lib/phronomy/agent/checkpoint.rb +118 -0
  17. data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
  18. data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
  19. data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
  20. data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
  21. data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
  22. data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
  23. data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
  24. data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
  25. data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
  26. data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
  27. data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
  28. data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
  29. data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
  30. data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
  31. data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
  32. data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
  33. data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
  34. data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
  35. data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
  36. data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
  37. data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
  38. data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
  39. data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
  40. data/lib/phronomy/agent/fsm.rb +42 -65
  41. data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
  42. data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
  43. data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
  44. data/lib/phronomy/agent/react_agent.rb +27 -14
  45. data/lib/phronomy/agent/runner.rb +2 -2
  46. data/lib/phronomy/agent/tool_executor.rb +108 -0
  47. data/lib/phronomy/concurrency/async_queue.rb +157 -0
  48. data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
  49. data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
  50. data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
  51. data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
  52. data/lib/phronomy/concurrency/deadline.rb +65 -0
  53. data/lib/phronomy/concurrency/gate_registry.rb +52 -0
  54. data/lib/phronomy/concurrency/pool_registry.rb +57 -0
  55. data/lib/phronomy/configuration.rb +142 -0
  56. data/lib/phronomy/context.rb +2 -8
  57. data/lib/phronomy/diagnostics.rb +62 -0
  58. data/lib/phronomy/embeddings.rb +2 -2
  59. data/lib/phronomy/eval/runner.rb +13 -9
  60. data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
  61. data/lib/phronomy/event_loop.rb +184 -46
  62. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  63. data/lib/phronomy/invocation_context.rb +152 -0
  64. data/lib/phronomy/knowledge_source.rb +0 -5
  65. data/lib/phronomy/llm_adapter/base.rb +104 -0
  66. data/lib/phronomy/llm_adapter/ruby_llm.rb +47 -0
  67. data/lib/phronomy/llm_adapter.rb +20 -0
  68. data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
  69. data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
  70. data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
  71. data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
  72. data/lib/phronomy/loader.rb +4 -4
  73. data/lib/phronomy/metrics.rb +38 -0
  74. data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
  75. data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +151 -126
  76. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +149 -0
  77. data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
  78. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  79. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  80. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  81. data/lib/phronomy/runtime/scheduler.rb +98 -0
  82. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  83. data/lib/phronomy/runtime/task_registry.rb +48 -0
  84. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  85. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  86. data/lib/phronomy/runtime/timer_service.rb +42 -0
  87. data/lib/phronomy/runtime.rb +389 -0
  88. data/lib/phronomy/splitter.rb +3 -3
  89. data/lib/phronomy/task/backend.rb +80 -0
  90. data/lib/phronomy/task/fiber_backend.rb +157 -0
  91. data/lib/phronomy/task/immediate_backend.rb +89 -0
  92. data/lib/phronomy/task/thread_backend.rb +84 -0
  93. data/lib/phronomy/task.rb +275 -0
  94. data/lib/phronomy/task_group.rb +265 -0
  95. data/lib/phronomy/testing/fake_clock.rb +109 -0
  96. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  97. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  98. data/lib/phronomy/testing.rb +12 -0
  99. data/lib/phronomy/tool/base.rb +156 -7
  100. data/lib/phronomy/tool/mcp_tool.rb +47 -16
  101. data/lib/phronomy/tool/scope_policy.rb +50 -0
  102. data/lib/phronomy/tracing/null_tracer.rb +3 -1
  103. data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
  104. data/lib/phronomy/vector_store.rb +2 -2
  105. data/lib/phronomy/version.rb +1 -1
  106. data/lib/phronomy/workflow.rb +52 -5
  107. data/lib/phronomy/workflow_context.rb +37 -2
  108. data/lib/phronomy/workflow_runner.rb +28 -77
  109. data/lib/phronomy.rb +43 -0
  110. metadata +73 -33
  111. data/lib/phronomy/agent/parallel_tool_chat.rb +0 -92
  112. data/lib/phronomy/cancellation_token.rb +0 -92
  113. data/lib/phronomy/context/compaction_context.rb +0 -111
  114. data/lib/phronomy/context/trigger_context.rb +0 -39
  115. data/lib/phronomy/context/trim_context.rb +0 -75
  116. data/lib/phronomy/embeddings/base.rb +0 -22
  117. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
  118. data/lib/phronomy/fsm_session.rb +0 -201
  119. data/lib/phronomy/knowledge_source/base.rb +0 -36
  120. data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
  121. data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
  122. data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
  123. data/lib/phronomy/loader/base.rb +0 -25
  124. data/lib/phronomy/loader/csv_loader.rb +0 -56
  125. data/lib/phronomy/loader/markdown_loader.rb +0 -76
  126. data/lib/phronomy/loader/plain_text_loader.rb +0 -22
  127. data/lib/phronomy/prompt_template.rb +0 -96
  128. data/lib/phronomy/splitter/base.rb +0 -47
  129. data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
  130. data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
  131. data/lib/phronomy/vector_store/base.rb +0 -82
  132. data/lib/phronomy/vector_store/in_memory.rb +0 -93
  133. data/lib/phronomy/vector_store/pgvector.rb +0 -127
  134. data/lib/phronomy/vector_store/redis_search.rb +0 -192
@@ -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
@@ -0,0 +1,165 @@
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.await).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
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class Runtime
5
+ # Internal store for task-centric counters and latency samples.
6
+ #
7
+ # All access is mutex-protected. The ring buffers for wait/run times are
8
+ # bounded to {WINDOW} samples so that long-lived runtimes do not grow
9
+ # unbounded.
10
+ # @api private
11
+ class RuntimeMetrics
12
+ WINDOW = 1000
13
+ private_constant :WINDOW
14
+
15
+ def initialize
16
+ @mutex = Mutex.new
17
+ @active_by_type = Hash.new(0)
18
+ @wait_ms = []
19
+ @run_ms = []
20
+ @cancelled = Hash.new(0)
21
+ @failed = Hash.new(0)
22
+ @starvation_count = 0
23
+ end
24
+
25
+ # Records that a new task of +type+ has been spawned.
26
+ # @param type [Symbol]
27
+ # @return [void]
28
+ # @api private
29
+ def record_start(type)
30
+ @mutex.synchronize { @active_by_type[type] += 1 }
31
+ end
32
+
33
+ # Appends a wait-time sample (milliseconds from spawn to start).
34
+ # @param wait_ms [Float]
35
+ # @return [void]
36
+ # @api private
37
+ def record_wait(wait_ms)
38
+ @mutex.synchronize do
39
+ @wait_ms << wait_ms
40
+ @wait_ms.shift if @wait_ms.size > WINDOW
41
+ end
42
+ end
43
+
44
+ # Records completion of a task (decrements active count, appends run time).
45
+ # @param type [Symbol]
46
+ # @param outcome [:completed, :cancelled, :failed]
47
+ # @param run_start_ms [Integer] monotonic millisecond timestamp from task start
48
+ # @return [void]
49
+ # @api private
50
+ def record_end(type, outcome, run_start_ms)
51
+ run_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - run_start_ms
52
+ @mutex.synchronize do
53
+ @active_by_type[type] = [@active_by_type[type] - 1, 0].max
54
+ @run_ms << run_ms
55
+ @run_ms.shift if @run_ms.size > WINDOW
56
+ case outcome
57
+ when :cancelled then @cancelled[type] += 1
58
+ when :failed then @failed[type] += 1
59
+ end
60
+ end
61
+ end
62
+
63
+ # Increments the CPU-starvation counter (task ran without yielding over
64
+ # the configured +blocking_detect_threshold_ms+ threshold).
65
+ # @return [void]
66
+ # @api private
67
+ def increment_starvation
68
+ @mutex.synchronize { @starvation_count += 1 }
69
+ end
70
+
71
+ # Returns the current starvation counter value.
72
+ # @return [Integer]
73
+ # @api private
74
+ def starvation_count
75
+ @mutex.synchronize { @starvation_count }
76
+ end
77
+
78
+ # Returns the full task-centric metrics hash (see {Runtime#task_snapshot}).
79
+ # @return [Hash{Symbol => Numeric}]
80
+ # @api private
81
+ def snapshot
82
+ @mutex.synchronize do
83
+ active = @active_by_type.dup
84
+ wait = @wait_ms.dup
85
+ run = @run_ms.dup
86
+ cancelled = @cancelled.values.sum
87
+ failed = @failed.values.sum
88
+ starvation = @starvation_count
89
+ {
90
+ active_agent_tasks: active[:agent].to_i,
91
+ active_tool_tasks: active[:tool].to_i,
92
+ active_workflow_tasks: active[:workflow].to_i,
93
+ active_rag_tasks: active[:rag].to_i,
94
+ active_llm_tasks: active[:llm].to_i,
95
+ task_wait_time_p50_ms: _percentile(wait, 50),
96
+ task_wait_time_p95_ms: _percentile(wait, 95),
97
+ task_run_time_p50_ms: _percentile(run, 50),
98
+ task_run_time_p95_ms: _percentile(run, 95),
99
+ cancelled_tasks: cancelled,
100
+ failed_tasks: failed,
101
+ non_yield_threshold_violation_count: starvation
102
+ }
103
+ end
104
+ end
105
+
106
+ private
107
+
108
+ def _percentile(samples, pct)
109
+ return 0.0 if samples.empty?
110
+
111
+ sorted = samples.sort
112
+ idx = ((pct / 100.0) * (sorted.size - 1)).round
113
+ sorted[idx].round(3)
114
+ end
115
+ end
116
+ end
117
+ end