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,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class Runtime
5
+ # A thread-safe timer queue backed by a single background thread.
6
+ #
7
+ # Replaces the pattern of spawning one +Thread.new { sleep(t); callback }+
8
+ # per deadline. Any number of timers share a single background thread that
9
+ # sleeps until the earliest pending deadline.
10
+ #
11
+ # Use {#schedule} to register a one-shot callback; call {#shutdown} when the
12
+ # queue is no longer needed (e.g. on process exit) to stop the background
13
+ # thread cleanly.
14
+ class TimerQueue
15
+ # @param clock [#call] zero-argument callable that returns the current
16
+ # monotonic time in seconds (defaults to +Process::CLOCK_MONOTONIC+).
17
+ # Override in tests to inject a fake clock.
18
+ # @api private
19
+ def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
20
+ @clock = clock
21
+ @heap = [] # [[fire_at, callback], ...]
22
+ @mutex = Mutex.new
23
+ @cond = ConditionVariable.new
24
+ @stopped = false
25
+ @thread = Thread.new { run_loop }
26
+ @thread.name = "phronomy-timer-queue"
27
+ end
28
+
29
+ # Schedule a one-shot callback to fire after +seconds+ from now.
30
+ #
31
+ # @param seconds [Numeric] delay before the callback fires
32
+ # @yield called (in the timer thread) when the deadline is reached
33
+ # @return [self]
34
+ # @api private
35
+ def schedule(seconds:, &callback)
36
+ fire_at = @clock.call + seconds.to_f
37
+ @mutex.synchronize do
38
+ raise Phronomy::PoolShutdownError, "TimerQueue has been shut down" if @stopped
39
+ insert_sorted(fire_at, callback)
40
+ @cond.signal
41
+ end
42
+ self
43
+ end
44
+
45
+ # Stop the background thread. Pending (un-fired) callbacks are discarded.
46
+ #
47
+ # @return [self]
48
+ # @api private
49
+ def shutdown
50
+ @mutex.synchronize do
51
+ @stopped = true
52
+ @cond.signal
53
+ end
54
+ @thread.join
55
+ self
56
+ end
57
+
58
+ # Number of pending (not yet fired) callbacks. Primarily for testing.
59
+ # @return [Integer]
60
+ # @api private
61
+ def pending_count
62
+ @mutex.synchronize { @heap.size }
63
+ end
64
+
65
+ private
66
+
67
+ def insert_sorted(fire_at, callback)
68
+ @heap << [fire_at, callback]
69
+ @heap.sort_by! { |(t, _)| t }
70
+ end
71
+
72
+ def run_loop
73
+ loop do
74
+ callback = next_callback
75
+ break if callback == :stopped
76
+ begin
77
+ callback&.call
78
+ rescue => e
79
+ Phronomy.configuration.logger&.error { "[TimerQueue] callback raised #{e.class}: #{e.message}" }
80
+ end
81
+ end
82
+ end
83
+
84
+ def next_callback
85
+ @mutex.synchronize do
86
+ loop do
87
+ return :stopped if @stopped
88
+
89
+ if @heap.empty?
90
+ @cond.wait(@mutex)
91
+ else
92
+ now = @clock.call
93
+ fire_at, = @heap.first
94
+ if fire_at <= now
95
+ return @heap.shift[1]
96
+ else
97
+ remaining = fire_at - now
98
+ @cond.wait(@mutex, remaining)
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class Runtime
5
+ # Lazy-initialised timer service for a {Runtime} instance.
6
+ #
7
+ # Returns a {SchedulerTimerAdapter} when the backing scheduler is a
8
+ # {DeterministicScheduler} (enabling virtual-time integration for the
9
+ # `:fiber` backend), or a standard {TimerQueue} (OS-thread backed) for all
10
+ # other schedulers.
11
+ # @api private
12
+ class TimerService
13
+ # @param scheduler [Scheduler]
14
+ # @api private
15
+ def initialize(scheduler)
16
+ @scheduler = scheduler
17
+ @mutex = Mutex.new
18
+ @timer = nil
19
+ end
20
+
21
+ # Returns (or lazily creates) the timer queue for this runtime.
22
+ # @return [TimerQueue, SchedulerTimerAdapter]
23
+ # @api private
24
+ def timer_queue
25
+ @mutex.synchronize do
26
+ @timer ||= if @scheduler.is_a?(DeterministicScheduler)
27
+ SchedulerTimerAdapter.new(@scheduler)
28
+ else
29
+ TimerQueue.new
30
+ end
31
+ end
32
+ end
33
+
34
+ # Shuts down the timer queue if it was started.
35
+ # @return [void]
36
+ # @api private
37
+ def shutdown
38
+ @mutex.synchronize { @timer&.shutdown }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,374 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "runtime/scheduler"
4
+ require_relative "runtime/thread_scheduler"
5
+ require_relative "runtime/fake_scheduler"
6
+ require_relative "runtime/deterministic_scheduler"
7
+ require_relative "runtime/timer_queue"
8
+ require_relative "runtime/scheduler_timer_adapter"
9
+ require_relative "runtime/task_registry"
10
+ require_relative "runtime/runtime_metrics"
11
+ require_relative "runtime/gate_registry"
12
+ require_relative "runtime/pool_registry"
13
+ require_relative "runtime/timer_service"
14
+
15
+ module Phronomy
16
+ # Central authority for concurrent primitives.
17
+ #
18
+ # +Runtime+ is the single place that creates {Task}s, {TaskGroup}s, and
19
+ # manages the lifecycle of all concurrency in Phronomy. It owns:
20
+ #
21
+ # * a pluggable {Scheduler} (default: {ThreadScheduler})
22
+ # * a task registry for graceful shutdown
23
+ # * the shared {BlockingAdapterPool}
24
+ #
25
+ # In production, use the process-wide singleton via {.instance}.
26
+ # In tests, construct a Runtime with a {FakeScheduler} to run tasks
27
+ # synchronously without spawning additional threads:
28
+ #
29
+ # @example Production usage
30
+ # group = Phronomy::Runtime.instance.task_group(limit: 4)
31
+ # tools.each { |t| group.spawn { t.call } }
32
+ # results = group.await_all
33
+ #
34
+ # @example Test usage — no extra threads
35
+ # runtime = Phronomy::Runtime.new(scheduler: Phronomy::Runtime::FakeScheduler.new)
36
+ # task = runtime.spawn { 42 }
37
+ # expect(task.await).to eq(42)
38
+ class Runtime
39
+ # Returns the process-wide default Runtime.
40
+ #
41
+ # Auto-creates an instance using the scheduler backend specified by
42
+ # +Phronomy.configuration.runtime_backend+:
43
+ # - +:thread+ (default) — {ThreadScheduler} (one OS thread per task)
44
+ # - +:immediate+ — {FakeScheduler} (synchronous, no extra threads)
45
+ # - +:fiber+ — {DeterministicScheduler} in autorun mode (EXPERIMENTAL;
46
+ # Fiber-based synchronous execution; not yet suitable for production
47
+ # because it uses virtual time rather than real wall-clock timers)
48
+ # - +:cooperative+ — deprecated alias for +:immediate+
49
+ #
50
+ # @return [Runtime]
51
+ # @api private
52
+ def self.instance
53
+ @instance ||= begin
54
+ scheduler = case Phronomy.configuration.runtime_backend
55
+ when :cooperative
56
+ Phronomy.configuration.logger&.warn(
57
+ "[phronomy] runtime_backend: :cooperative is a deprecated alias for :immediate. " \
58
+ "Use :immediate for synchronous/test execution. " \
59
+ ":cooperative will be reassigned when a real cooperative Fiber-based scheduler is available."
60
+ )
61
+ FakeScheduler.new
62
+ when :immediate
63
+ FakeScheduler.new
64
+ when :fiber
65
+ Phronomy.configuration.logger&.warn(
66
+ "[phronomy] runtime_backend: :fiber uses DeterministicScheduler in autorun mode. " \
67
+ "This is an EXPERIMENTAL Fiber-based cooperative scheduler. " \
68
+ "Wall-clock timer integration is available via SchedulerTimerAdapter (Issues #331, #337). " \
69
+ "Not recommended for production use."
70
+ )
71
+ DeterministicScheduler.new(autorun: true)
72
+ else
73
+ ThreadScheduler.new
74
+ end
75
+ new(scheduler: scheduler)
76
+ end
77
+ end
78
+
79
+ # Replaces the process-wide default Runtime. Useful in tests.
80
+ # @param runtime [Runtime]
81
+ # @return [Runtime]
82
+ # @api private
83
+ def self.instance=(runtime)
84
+ @instance = runtime
85
+ end
86
+
87
+ # Returns +true+ when the calling thread is executing inside an active
88
+ # scheduler task (i.e. {Task.current} is non-nil). Code running inside
89
+ # a {Runtime#spawn} block is always in a scheduler context.
90
+ #
91
+ # Use this to detect potential scheduler-blocking calls:
92
+ # if Phronomy::Runtime.in_scheduler_context?
93
+ # Phronomy.configuration.logger&.warn("blocking call inside scheduler task")
94
+ # end
95
+ #
96
+ # @return [Boolean]
97
+ # @api private
98
+ def self.in_scheduler_context?
99
+ !Task.current.nil?
100
+ end
101
+
102
+ # The scheduler backing this runtime instance.
103
+ # @return [Scheduler]
104
+ attr_reader :scheduler
105
+
106
+ # @param scheduler [Scheduler] execution backend (default: {ThreadScheduler})
107
+ # @api private
108
+ def initialize(scheduler: ThreadScheduler.new)
109
+ @scheduler = scheduler
110
+ @task_registry = TaskRegistry.new
111
+ @metrics = RuntimeMetrics.new
112
+ @gate_registry = GateRegistry.new
113
+ @pool_registry = PoolRegistry.new
114
+ @timer_service = TimerService.new(scheduler)
115
+ end
116
+
117
+ # Returns (or lazily creates) the {ConcurrencyGate} for the named resource.
118
+ #
119
+ # Gate caps are read from the global {Phronomy::Configuration} when the gate
120
+ # is first accessed; subsequent calls return the cached gate. To change the
121
+ # cap at runtime, call {#reset_gate} first.
122
+ #
123
+ # @param name [:agent, :tool, :workflow, :llm, :rag, :vector] resource name
124
+ # @return [ConcurrencyGate]
125
+ # @api private
126
+ def gate(name)
127
+ @gate_registry.get(name.to_sym)
128
+ end
129
+
130
+ # Drops the cached gate for +name+ so that the next call to {#gate} rebuilds
131
+ # it from the current configuration. Useful in tests.
132
+ #
133
+ # @param name [Symbol]
134
+ # @return [void]
135
+ # @api private
136
+ def reset_gate(name)
137
+ @gate_registry.reset(name.to_sym)
138
+ end
139
+
140
+ # Cooperative yield point.
141
+ #
142
+ # Signals the scheduler that the current task is willing to give up CPU time
143
+ # so that other ready tasks can run. On the default {ThreadScheduler} this
144
+ # calls +Thread.pass+. On a future fiber-based scheduler this would switch
145
+ # to the next runnable fiber.
146
+ #
147
+ # When +blocking_detect_threshold_ms+ is configured, checks whether the
148
+ # current task has exceeded that threshold without yielding; if so, emits a
149
+ # warning via the configured logger and increments
150
+ # +non_yield_threshold_violation_count+.
151
+ #
152
+ # Call this inside tight loops or CPU-intensive sections of tool +execute+
153
+ # methods and Workflow actions to keep the scheduler responsive.
154
+ #
155
+ # @return [void]
156
+ # @api private
157
+ def yield
158
+ if (threshold = Phronomy.configuration.blocking_detect_threshold_ms)
159
+ slice_start = Task.current_cpu_slice_start_ms
160
+ if slice_start
161
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - slice_start
162
+ if elapsed > threshold
163
+ name = Task.current&.name || "unknown"
164
+ Phronomy.configuration.logger&.warn(
165
+ "[Phronomy] CPU-bound task detected: '#{name}' ran #{elapsed.round}ms " \
166
+ "without yielding (threshold: #{threshold}ms)"
167
+ )
168
+ @metrics.increment_starvation
169
+ end
170
+ end
171
+ end
172
+ Task.record_yield!
173
+ @scheduler.yield
174
+ end
175
+
176
+ # Number of times a task has exceeded the CPU-bound detection threshold
177
+ # (i.e. ran longer than +blocking_detect_threshold_ms+ without yielding).
178
+ # Resets to 0 when the Runtime is recreated.
179
+ # @return [Integer]
180
+ # @api private
181
+ def non_yield_threshold_violation_count
182
+ @metrics.starvation_count
183
+ end
184
+
185
+ # Cooperative yield point with a call-count gate.
186
+ #
187
+ # Increments a per-thread counter and calls {#yield} when the counter
188
+ # reaches a multiple of +every+. The counter is thread-local so concurrent
189
+ # tasks each maintain their own independent loop counter without requiring
190
+ # a mutex.
191
+ #
192
+ # @example
193
+ # data.each_with_index do |row, i|
194
+ # process(row)
195
+ # Phronomy::Runtime.instance.yield_if_needed(every: 500)
196
+ # end
197
+ #
198
+ # @param every [Integer] yield once every N calls (default: 1000)
199
+ # @return [void]
200
+ # @api private
201
+ def yield_if_needed(every: 1000)
202
+ # Delegate Thread.current access to Task so that runtime.rb stays outside
203
+ # the Thread.current allowlist (Issue #302).
204
+ self.yield if (Task.increment_yield_counter! % every).zero?
205
+ end
206
+
207
+ # Creates a new {TaskGroup} with an optional concurrency cap.
208
+ #
209
+ # @param limit [Integer, Float::INFINITY] max simultaneous tasks
210
+ # @param failure_policy [Symbol] one of :fail_fast, :collect_all, :skip_failed (default :fail_fast)
211
+ # @return [TaskGroup]
212
+ # @api private
213
+ def task_group(limit: Float::INFINITY, failure_policy: :fail_fast)
214
+ TaskGroup.new(limit: limit, failure_policy: failure_policy, runtime: self)
215
+ end
216
+
217
+ # Spawns a single {Task} using the runtime's scheduler.
218
+ #
219
+ # The spawned task is registered in the task registry so {#shutdown}
220
+ # can wait for it to complete. The task is automatically deregistered
221
+ # from the registry when it finishes (success, failure, or cancellation)
222
+ # so long-lived runtimes do not accumulate stale references.
223
+ #
224
+ # Task names beginning with a recognised type prefix are counted in the
225
+ # task-centric metrics returned by {#task_snapshot}. Recognised prefixes:
226
+ # +agent-+, +tool-+, +workflow-+, +rag-+, +llm-+, +vector-+.
227
+ #
228
+ # @param name [String, nil] optional label for debugging
229
+ # @yield block to execute (concurrently or synchronously, depending on
230
+ # the configured scheduler)
231
+ # @return [Task]
232
+ # @api private
233
+ def spawn(name: nil, &block)
234
+ type = _task_type(name)
235
+ spawn_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
236
+ @metrics.record_start(type)
237
+
238
+ task = @scheduler.spawn(name: name, parent: Task.current) do
239
+ run_start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
240
+ @metrics.record_wait(run_start - spawn_at)
241
+ begin
242
+ result = block.call
243
+ @metrics.record_end(type, :completed, run_start)
244
+ result
245
+ rescue CancellationError
246
+ @metrics.record_end(type, :cancelled, run_start)
247
+ raise
248
+ rescue => e
249
+ @metrics.record_end(type, :failed, run_start)
250
+ raise e
251
+ ensure
252
+ current = Task.current
253
+ @task_registry.deregister(current) if current
254
+ end
255
+ end
256
+ @task_registry.register(task)
257
+ task
258
+ end
259
+
260
+ # Returns a snapshot of task-centric metrics for the current Runtime.
261
+ #
262
+ # | Key | Description |
263
+ # |-----|-------------|
264
+ # | `active_agent_tasks` | currently running agent spawns |
265
+ # | `active_tool_tasks` | currently running tool spawns |
266
+ # | `active_workflow_tasks` | currently running workflow spawns |
267
+ # | `active_rag_tasks` | currently running RAG fetches |
268
+ # | `active_llm_tasks` | currently running LLM calls |
269
+ # | `task_wait_time_p50_ms` | p50 spawn-to-start latency (ms) |
270
+ # | `task_wait_time_p95_ms` | p95 spawn-to-start latency (ms) |
271
+ # | `task_run_time_p50_ms` | p50 execution duration (ms) |
272
+ # | `task_run_time_p95_ms` | p95 execution duration (ms) |
273
+ # | `cancelled_tasks` | total cancelled task count |
274
+ # | `failed_tasks` | total failed task count |
275
+ # | `non_yield_threshold_violation_count` | cumulative count of tasks that ran past `blocking_detect_threshold_ms` without yielding |
276
+ #
277
+ # @return [Hash{Symbol => Numeric}]
278
+ # @api private
279
+ def task_snapshot
280
+ @metrics.snapshot
281
+ end
282
+
283
+ # Returns the shared {BlockingAdapterPool} for this Runtime.
284
+ # All blocking I/O (LLM HTTP, MCP, ActiveRecord, Redis) should be
285
+ # submitted through this pool.
286
+ #
287
+ # Pool settings default to 10 workers / 100-deep queue. Override by
288
+ # constructing a Runtime with custom pool options or by replacing the
289
+ # shared Runtime via {.instance=} in tests.
290
+ #
291
+ # @param pool_size [Integer] worker thread count (default: 10)
292
+ # @param queue_size [Integer] max pending operations (default: 100)
293
+ # @return [BlockingAdapterPool]
294
+ # @api private
295
+ def blocking_io(pool_size: 10, queue_size: 100)
296
+ @pool_registry.default_pool(pool_size: pool_size, queue_size: queue_size)
297
+ end
298
+
299
+ # Returns (or lazily creates) a named {BlockingAdapterPool}.
300
+ #
301
+ # Named pools allow per-subsystem thread-budget control and observability.
302
+ # Recommended pool names: +:llm+, +:mcp+, +:db+, +:redis+, +:tool+.
303
+ # Each pool gets its own dedicated worker threads labelled with the pool name.
304
+ #
305
+ # @example
306
+ # runtime.pool(:llm) # default size (10 workers)
307
+ # runtime.pool(:db, size: 20) # custom size
308
+ #
309
+ # @param name [Symbol, String] pool identifier
310
+ # @param size [Integer] worker thread count (default: 10)
311
+ # @param queue_size [Integer] max pending operations (default: 100)
312
+ # @return [BlockingAdapterPool]
313
+ # @api private
314
+ def pool(name, size: 10, queue_size: 100)
315
+ @pool_registry.named_pool(name, size: size, queue_size: queue_size)
316
+ end
317
+
318
+ # Returns the shared timer queue for this Runtime.
319
+ #
320
+ # When the scheduler is a {DeterministicScheduler} (e.g. the +:fiber+
321
+ # runtime backend), returns a {SchedulerTimerAdapter} that integrates with
322
+ # the scheduler's tick cycle instead of spawning a background OS thread.
323
+ # This is the first concrete step of the TimerQueue scheduler-tick integration
324
+ # described in ADR-010 (Issue #331).
325
+ #
326
+ # For all other schedulers, returns a {TimerQueue} backed by a single
327
+ # background thread.
328
+ #
329
+ # All deadline-based cancellation should be registered here instead of
330
+ # spawning one-off sleep threads. Lazily created on first access.
331
+ #
332
+ # @return [TimerQueue, SchedulerTimerAdapter]
333
+ # @api private
334
+ def timer_queue
335
+ @timer_service.timer_queue
336
+ end
337
+
338
+ # Waits for all registered tasks to finish, then shuts down the
339
+ # EventLoop (if active), blocking adapter pool, named pools, and timer queue
340
+ # (if they were started).
341
+ #
342
+ # When EventLoop mode is enabled, all pending Workflow and Agent FSM events
343
+ # are drained before pools are shut down, ensuring in-flight sessions
344
+ # complete cleanly.
345
+ #
346
+ # Call this before process exit to avoid leaving orphaned threads or
347
+ # pending work items.
348
+ #
349
+ # @return [void]
350
+ # @api private
351
+ def shutdown
352
+ @task_registry.drain
353
+ # Drain EventLoop events before stopping pools so that in-flight
354
+ # Workflow / Agent FSM sessions can complete their final LLM calls.
355
+ if Phronomy.configuration.event_loop
356
+ Phronomy::EventLoop.instance.stop(drain: true)
357
+ end
358
+ @pool_registry.shutdown
359
+ @timer_service.shutdown
360
+ end
361
+
362
+ private
363
+
364
+ TASK_TYPE_PREFIXES = %w[agent tool workflow rag llm vector].freeze
365
+ private_constant :TASK_TYPE_PREFIXES
366
+
367
+ def _task_type(name)
368
+ return :other if name.nil?
369
+
370
+ prefix = TASK_TYPE_PREFIXES.find { |p| name.to_s.start_with?("#{p}-") }
371
+ prefix ? prefix.to_sym : :other
372
+ end
373
+ end
374
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class Task
5
+ # Abstract base class for Task execution backends.
6
+ #
7
+ # A backend encapsulates the execution primitive (Thread, Fiber, etc.) and
8
+ # the lifecycle transitions it drives. Concrete backends must implement all
9
+ # abstract methods. The default concrete implementation is {ThreadBackend}.
10
+ #
11
+ # Backends receive a reference to the owning {Task} so they can call
12
+ # {Task#transition!} at the appropriate lifecycle points.
13
+ class Backend
14
+ # @param task [Task] the owning Task (used for status callbacks)
15
+ # @param block [Proc] the work to execute
16
+ # @api private
17
+ def initialize(task:, &block)
18
+ @task = task
19
+ @block = block
20
+ end
21
+
22
+ # Blocks until the task completes and returns its value.
23
+ # Re-raises errors from the block.
24
+ # @return [Object]
25
+ # @raise [Exception]
26
+ # @api private
27
+ def await
28
+ raise NotImplementedError, "#{self.class}#await not implemented"
29
+ end
30
+
31
+ # Returns +true+ while execution is still ongoing.
32
+ # @return [Boolean]
33
+ # @api private
34
+ def alive?
35
+ raise NotImplementedError, "#{self.class}#alive? not implemented"
36
+ end
37
+
38
+ # Requests cancellation.
39
+ # Thread-based backends may use +Thread#raise+; cooperative backends
40
+ # should mark the task cancelled and rely on {Task.checkpoint!}.
41
+ # @return [self]
42
+ # @api private
43
+ def cancel!
44
+ raise NotImplementedError, "#{self.class}#cancel! not implemented"
45
+ end
46
+
47
+ # Joins the execution context with an optional timeout.
48
+ # Returns +nil+ when a non-nil +limit+ expires before completion,
49
+ # matching +Thread#join+ semantics.
50
+ # @param limit [Numeric, nil]
51
+ # @return [Object, nil]
52
+ # @api private
53
+ def join(limit = nil)
54
+ raise NotImplementedError, "#{self.class}#join not implemented"
55
+ end
56
+
57
+ # Returns the task's result value once it has reached a terminal state.
58
+ # Only valid to call after the task is done.
59
+ # Subclasses should override if they store the result.
60
+ # @return [Object, nil]
61
+ # @api private
62
+ def completed_value
63
+ nil
64
+ end
65
+
66
+ # Returns the exception raised by the task, or +nil+ on success/cancellation.
67
+ # Only valid to call after the task is done.
68
+ # Subclasses should override if they store errors.
69
+ # @return [Exception, nil]
70
+ # @api private
71
+ def completed_error
72
+ nil
73
+ end
74
+
75
+ private
76
+
77
+ attr_reader :task, :block
78
+ end
79
+ end
80
+ end