phronomy 0.6.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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +22 -0
  3. data/CHANGELOG.md +488 -0
  4. data/CONTRIBUTING.md +102 -0
  5. data/README.md +374 -36
  6. data/RELEASE_CHECKLIST.md +86 -0
  7. data/Rakefile +33 -0
  8. data/SECURITY.md +80 -0
  9. data/benchmark/baseline.json +9 -0
  10. data/benchmark/bench_agent_invoke.rb +105 -0
  11. data/benchmark/bench_context_assembler.rb +46 -0
  12. data/benchmark/bench_regression.rb +172 -0
  13. data/benchmark/bench_token_estimator.rb +44 -0
  14. data/benchmark/bench_tool_schema.rb +69 -0
  15. data/benchmark/bench_vector_store.rb +39 -0
  16. data/benchmark/bench_workflow.rb +55 -0
  17. data/benchmark/run_all.rb +118 -0
  18. data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
  19. data/docs/decisions/002-workflow-context-immutability.md +42 -0
  20. data/docs/decisions/003-event-loop-singleton.md +48 -0
  21. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +75 -0
  22. data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
  23. data/docs/decisions/006-no-built-in-guardrails.md +66 -0
  24. data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
  25. data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
  26. data/docs/decisions/009-state-store-abstraction.md +141 -0
  27. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  28. data/lib/phronomy/agent/base.rb +416 -49
  29. data/lib/phronomy/agent/before_completion_context.rb +1 -0
  30. data/lib/phronomy/agent/checkpoint.rb +1 -0
  31. data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
  32. data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
  33. data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
  34. data/lib/phronomy/agent/concerns/retryable.rb +12 -1
  35. data/lib/phronomy/agent/concerns/suspendable.rb +19 -0
  36. data/lib/phronomy/agent/fsm.rb +44 -52
  37. data/lib/phronomy/agent/handoff.rb +3 -0
  38. data/lib/phronomy/agent/orchestrator.rb +191 -54
  39. data/lib/phronomy/agent/parallel_tool_chat.rb +87 -13
  40. data/lib/phronomy/agent/react_agent.rb +16 -6
  41. data/lib/phronomy/agent/runner.rb +2 -0
  42. data/lib/phronomy/agent/shared_state.rb +11 -0
  43. data/lib/phronomy/agent/suspend_signal.rb +2 -0
  44. data/lib/phronomy/agent/team_coordinator.rb +17 -5
  45. data/lib/phronomy/async_queue.rb +155 -0
  46. data/lib/phronomy/blocking_adapter_pool.rb +435 -0
  47. data/lib/phronomy/cancellation_scope.rb +123 -0
  48. data/lib/phronomy/cancellation_token.rb +133 -0
  49. data/lib/phronomy/concurrency_gate.rb +155 -0
  50. data/lib/phronomy/configuration.rb +168 -2
  51. data/lib/phronomy/context/assembler.rb +6 -0
  52. data/lib/phronomy/context/compaction_context.rb +2 -0
  53. data/lib/phronomy/context/context_version_cache.rb +2 -0
  54. data/lib/phronomy/context/token_budget.rb +3 -0
  55. data/lib/phronomy/context/token_estimator.rb +9 -2
  56. data/lib/phronomy/context/trigger_context.rb +1 -0
  57. data/lib/phronomy/context/trim_context.rb +4 -0
  58. data/lib/phronomy/deadline.rb +63 -0
  59. data/lib/phronomy/diagnostics.rb +62 -0
  60. data/lib/phronomy/embeddings/base.rb +22 -2
  61. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
  62. data/lib/phronomy/eval/comparison.rb +2 -0
  63. data/lib/phronomy/eval/dataset.rb +4 -0
  64. data/lib/phronomy/eval/metrics.rb +6 -0
  65. data/lib/phronomy/eval/runner.rb +11 -9
  66. data/lib/phronomy/eval/scorer/base.rb +1 -0
  67. data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
  68. data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
  69. data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
  70. data/lib/phronomy/event_loop.rb +275 -30
  71. data/lib/phronomy/fsm_session.rb +57 -4
  72. data/lib/phronomy/generator_verifier.rb +2 -0
  73. data/lib/phronomy/guardrail/base.rb +3 -0
  74. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  75. data/lib/phronomy/invocation_context.rb +152 -0
  76. data/lib/phronomy/knowledge_source/base.rb +24 -2
  77. data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
  78. data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
  79. data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
  80. data/lib/phronomy/llm_adapter/base.rb +104 -0
  81. data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
  82. data/lib/phronomy/llm_adapter.rb +20 -0
  83. data/lib/phronomy/loader/base.rb +1 -0
  84. data/lib/phronomy/loader/csv_loader.rb +2 -0
  85. data/lib/phronomy/loader/markdown_loader.rb +2 -0
  86. data/lib/phronomy/loader/plain_text_loader.rb +1 -0
  87. data/lib/phronomy/metrics.rb +38 -0
  88. data/lib/phronomy/output_parser/base.rb +1 -0
  89. data/lib/phronomy/output_parser/json_parser.rb +22 -3
  90. data/lib/phronomy/output_parser/structured_parser.rb +2 -0
  91. data/lib/phronomy/prompt_template.rb +5 -0
  92. data/lib/phronomy/runnable.rb +20 -3
  93. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  94. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  95. data/lib/phronomy/runtime/gate_registry.rb +52 -0
  96. data/lib/phronomy/runtime/pool_registry.rb +57 -0
  97. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  98. data/lib/phronomy/runtime/scheduler.rb +98 -0
  99. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  100. data/lib/phronomy/runtime/task_registry.rb +48 -0
  101. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  102. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  103. data/lib/phronomy/runtime/timer_service.rb +42 -0
  104. data/lib/phronomy/runtime.rb +374 -0
  105. data/lib/phronomy/splitter/base.rb +2 -0
  106. data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
  107. data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
  108. data/lib/phronomy/state_store/base.rb +48 -0
  109. data/lib/phronomy/state_store/in_memory.rb +62 -0
  110. data/lib/phronomy/task/backend.rb +80 -0
  111. data/lib/phronomy/task/fiber_backend.rb +157 -0
  112. data/lib/phronomy/task/immediate_backend.rb +89 -0
  113. data/lib/phronomy/task/thread_backend.rb +84 -0
  114. data/lib/phronomy/task.rb +275 -0
  115. data/lib/phronomy/task_group.rb +265 -0
  116. data/lib/phronomy/testing/fake_clock.rb +109 -0
  117. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  118. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  119. data/lib/phronomy/testing.rb +12 -0
  120. data/lib/phronomy/tool/agent_tool.rb +1 -0
  121. data/lib/phronomy/tool/base.rb +298 -28
  122. data/lib/phronomy/tool/mcp_tool.rb +103 -17
  123. data/lib/phronomy/tool/scope_policy.rb +50 -0
  124. data/lib/phronomy/tool_executor.rb +106 -0
  125. data/lib/phronomy/tracing/base.rb +3 -0
  126. data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
  127. data/lib/phronomy/tracing/open_telemetry_tracer.rb +36 -0
  128. data/lib/phronomy/vector_store/async_backend.rb +110 -0
  129. data/lib/phronomy/vector_store/base.rb +40 -7
  130. data/lib/phronomy/vector_store/in_memory.rb +16 -7
  131. data/lib/phronomy/vector_store/pgvector.rb +40 -9
  132. data/lib/phronomy/vector_store/redis_search.rb +29 -8
  133. data/lib/phronomy/version.rb +1 -1
  134. data/lib/phronomy/workflow.rb +147 -11
  135. data/lib/phronomy/workflow_context.rb +83 -6
  136. data/lib/phronomy/workflow_runner.rb +106 -7
  137. data/lib/phronomy.rb +112 -1
  138. data/scripts/api_snapshot.rb +91 -0
  139. data/scripts/check_api_annotations.rb +68 -0
  140. data/scripts/check_private_enforcement.rb +93 -0
  141. data/scripts/check_readme_runnable.rb +98 -0
  142. data/scripts/run_mutation.sh +46 -0
  143. metadata +83 -2
@@ -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
@@ -18,6 +18,7 @@ module Phronomy
18
18
  # returned by a Loader, or a plain String.
19
19
  # @return [Array<Hash>] array of <tt>{ text: String, metadata: Hash }</tt>
20
20
  # @raise [NotImplementedError] when not overridden by a subclass
21
+ # @api public
21
22
  def split(document)
22
23
  raise NotImplementedError, "#{self.class}#split is not implemented"
23
24
  end
@@ -26,6 +27,7 @@ module Phronomy
26
27
  #
27
28
  # @param documents [Array<Hash, String>]
28
29
  # @return [Array<Hash>]
30
+ # @api public
29
31
  def split_all(documents)
30
32
  documents.flat_map { |doc| split(doc) }
31
33
  end
@@ -15,6 +15,7 @@ module Phronomy
15
15
  # @param chunk_size [Integer] maximum characters per chunk (default: 1000)
16
16
  # @param chunk_overlap [Integer] characters to repeat at the start of each
17
17
  # subsequent chunk (default: 200); must be less than chunk_size
18
+ # @api public
18
19
  def initialize(chunk_size: 1000, chunk_overlap: 200)
19
20
  raise ArgumentError, "chunk_overlap must be less than chunk_size" if chunk_overlap >= chunk_size
20
21
 
@@ -24,6 +25,7 @@ module Phronomy
24
25
 
25
26
  # @param document [Hash, String]
26
27
  # @return [Array<Hash>]
28
+ # @api public
27
29
  def split(document)
28
30
  doc = normalise(document)
29
31
  text = doc[:text]
@@ -25,6 +25,7 @@ module Phronomy
25
25
  # @param chunk_size [Integer] maximum characters per chunk (default: 1000)
26
26
  # @param chunk_overlap [Integer] overlap characters (default: 200)
27
27
  # @param separators [Array<String>] separator list in priority order
28
+ # @api public
28
29
  def initialize(chunk_size: 1000, chunk_overlap: 200, separators: DEFAULT_SEPARATORS)
29
30
  raise ArgumentError, "chunk_overlap must be less than chunk_size" if chunk_overlap >= chunk_size
30
31
 
@@ -35,6 +36,7 @@ module Phronomy
35
36
 
36
37
  # @param document [Hash, String]
37
38
  # @return [Array<Hash>]
39
+ # @api public
38
40
  def split(document)
39
41
  doc = normalise(document)
40
42
  texts = recursive_split(doc[:text], @separators)
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module StateStore
5
+ # Abstract base class for workflow state persistence backends.
6
+ #
7
+ # Subclasses must implement {#load}, {#save}, and {#delete}.
8
+ # A snapshot is a plain +Hash+ with two keys:
9
+ # +:fields+ — output of +context.to_h+
10
+ # +:phase+ — +context.phase.to_s+
11
+ #
12
+ # @example Implementing a custom backend
13
+ # class MyStore < Phronomy::StateStore::Base
14
+ # def load(thread_id) = MyRecord.find_by(thread_id:)&.to_h
15
+ # def save(thread_id, snapshot) = MyRecord.upsert(thread_id:, data: snapshot)
16
+ # def delete(thread_id) = MyRecord.where(thread_id:).delete_all
17
+ # end
18
+ class Base
19
+ # Load the stored snapshot for +thread_id+.
20
+ #
21
+ # @param thread_id [String]
22
+ # @return [Hash, nil] stored snapshot hash, or +nil+ if absent
23
+ # @api public
24
+ def load(thread_id)
25
+ raise NotImplementedError, "#{self.class}#load is not implemented"
26
+ end
27
+
28
+ # Persist +snapshot+ for +thread_id+. Overwrites any existing snapshot.
29
+ #
30
+ # @param thread_id [String]
31
+ # @param snapshot [Hash] serialisable hash of workflow state
32
+ # @return [void]
33
+ # @api public
34
+ def save(thread_id, snapshot)
35
+ raise NotImplementedError, "#{self.class}#save is not implemented"
36
+ end
37
+
38
+ # Delete the stored snapshot for +thread_id+. No-op if absent.
39
+ #
40
+ # @param thread_id [String]
41
+ # @return [void]
42
+ # @api public
43
+ def delete(thread_id)
44
+ raise NotImplementedError, "#{self.class}#delete is not implemented"
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module StateStore
5
+ # Thread-safe in-process state store backed by a plain Ruby Hash.
6
+ #
7
+ # Used as the recommended default for single-process applications and tests.
8
+ # State does not survive process restart.
9
+ #
10
+ # @example
11
+ # store = Phronomy::StateStore::InMemory.new
12
+ # store.save("t1", { fields: { count: 1 }, phase: "__end__" })
13
+ # store.load("t1") # => { fields: { count: 1 }, phase: "__end__" }
14
+ # store.delete("t1")
15
+ # store.load("t1") # => nil
16
+ class InMemory < Base
17
+ def initialize
18
+ @data = {}
19
+ @mutex = Mutex.new
20
+ end
21
+
22
+ # @param thread_id [String]
23
+ # @return [Hash, nil]
24
+ # @api public
25
+ def load(thread_id)
26
+ @mutex.synchronize do
27
+ snap = @data[thread_id]
28
+ snap ? deep_dup(snap) : nil
29
+ end
30
+ end
31
+
32
+ # @param thread_id [String]
33
+ # @param snapshot [Hash]
34
+ # @return [void]
35
+ # @api public
36
+ def save(thread_id, snapshot)
37
+ @mutex.synchronize { @data[thread_id] = deep_dup(snapshot) }
38
+ nil
39
+ end
40
+
41
+ # @param thread_id [String]
42
+ # @return [void]
43
+ # @api public
44
+ def delete(thread_id)
45
+ @mutex.synchronize { @data.delete(thread_id) }
46
+ nil
47
+ end
48
+
49
+ private
50
+
51
+ # Recursively deep-duplicates a plain-data value (Hash, Array, or scalar).
52
+ # Sufficient for snapshot data which consists of JSON-compatible types.
53
+ def deep_dup(val)
54
+ case val
55
+ when Hash then val.each_with_object({}) { |(k, v), h| h[k] = deep_dup(v) }
56
+ when Array then val.map { |v| deep_dup(v) }
57
+ else val.frozen? ? val : (val.dup rescue val) # rubocop:disable Style/RescueModifier
58
+ end
59
+ end
60
+ end
61
+ end
62
+ 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