phronomy 0.17.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +97 -1134
  3. data/README.md +68 -1280
  4. data/benchmark/bench_regression.rb +25 -5
  5. data/docs/changelog/0.14-and-earlier.md +1137 -0
  6. data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
  7. data/docs/decisions/010-cooperative-first-concurrency.md +155 -235
  8. data/docs/features.md +87 -0
  9. data/docs/getting-started.md +351 -0
  10. data/docs/migrations/0.15.md +35 -0
  11. data/docs/migrations/0.16.md +43 -0
  12. data/docs/runtime-and-concurrency.md +258 -0
  13. data/examples/workflows/generic_task_event_mapping.rb +14 -6
  14. data/lib/phronomy/agent/agent_invocation_session_builder.rb +2 -2
  15. data/lib/phronomy/agent/async_event_api.rb +3 -3
  16. data/lib/phronomy/agent/base.rb +35 -19
  17. data/lib/phronomy/agent/context/capability/base.rb +13 -3
  18. data/lib/phronomy/agent/execution_coordinator.rb +6 -6
  19. data/lib/phronomy/agent/shared_state.rb +2 -0
  20. data/lib/phronomy/agent/tool_executor.rb +29 -71
  21. data/lib/phronomy/agent/tool_invocation.rb +97 -47
  22. data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
  23. data/lib/phronomy/configuration.rb +5 -29
  24. data/lib/phronomy/diagnostics.rb +12 -41
  25. data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
  26. data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
  27. data/lib/phronomy/engine/concurrency/cancellation_token.rb +48 -3
  28. data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
  29. data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
  30. data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
  31. data/lib/phronomy/engine/event_loop.rb +89 -190
  32. data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
  33. data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
  34. data/lib/phronomy/engine/runtime.rb +45 -158
  35. data/lib/phronomy/engine/task.rb +136 -277
  36. data/lib/phronomy/llm_adapter/base.rb +14 -14
  37. data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
  38. data/lib/phronomy/llm_adapter.rb +2 -2
  39. data/lib/phronomy/metrics.rb +15 -30
  40. data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
  41. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
  42. data/lib/phronomy/multi_agent/handoff.rb +1 -0
  43. data/lib/phronomy/multi_agent/orchestrator.rb +147 -99
  44. data/lib/phronomy/multi_agent/team_coordinator.rb +2 -0
  45. data/lib/phronomy/testing/eval/comparison.rb +23 -0
  46. data/lib/phronomy/testing/eval/dataset.rb +27 -0
  47. data/lib/phronomy/testing/eval/eval_case.rb +13 -0
  48. data/lib/phronomy/testing/eval/eval_result.rb +16 -0
  49. data/lib/phronomy/testing/eval/metrics.rb +43 -0
  50. data/lib/phronomy/testing/eval/runner.rb +52 -0
  51. data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
  52. data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
  53. data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
  54. data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
  55. data/lib/phronomy/testing/eval/scorer.rb +10 -0
  56. data/lib/phronomy/testing/eval.rb +9 -0
  57. data/lib/phronomy/testing/fake_clock.rb +6 -53
  58. data/lib/phronomy/testing.rb +2 -6
  59. data/lib/phronomy/tools/agent.rb +141 -6
  60. data/lib/phronomy/vector_store/async_backend.rb +21 -17
  61. data/lib/phronomy/vector_store/base.rb +2 -2
  62. data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
  63. data/lib/phronomy/version.rb +1 -1
  64. data/lib/phronomy/workflow_runner.rb +2 -4
  65. data/lib/phronomy.rb +7 -4
  66. data/scripts/api_snapshot.rb +4 -4
  67. metadata +23 -31
  68. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
  69. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
  70. data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
  71. data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
  72. data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
  73. data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
  74. data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
  75. data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
  76. data/lib/phronomy/engine/task/backend.rb +0 -80
  77. data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
  78. data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
  79. data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
  80. data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
  81. data/lib/phronomy/engine/task/thread_backend.rb +0 -84
  82. data/lib/phronomy/engine/task_group.rb +0 -193
  83. data/lib/phronomy/eval/comparison.rb +0 -47
  84. data/lib/phronomy/eval/dataset.rb +0 -45
  85. data/lib/phronomy/eval/eval_case.rb +0 -17
  86. data/lib/phronomy/eval/eval_result.rb +0 -29
  87. data/lib/phronomy/eval/metrics.rb +0 -66
  88. data/lib/phronomy/eval/runner.rb +0 -94
  89. data/lib/phronomy/eval/scorer/base.rb +0 -22
  90. data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
  91. data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
  92. data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
  93. data/lib/phronomy/eval/scorer.rb +0 -9
  94. data/lib/phronomy/eval.rb +0 -7
  95. data/lib/phronomy/testing/fake_scheduler.rb +0 -104
  96. data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
@@ -5,18 +5,9 @@ require "state_machines"
5
5
  module Phronomy
6
6
  module Agent
7
7
  # Builds an FSMSession for one ToolInvocation.
8
- #
9
- # Authorization and execution Tasks are observed by ToolInvocation-specific
10
- # callbacks that post explicit FSM events. The generated phase machine does
11
- # not await Tasks.
12
- #
13
- # @api private
8
+ # Async completion is represented only by explicit FSM events.
14
9
  class ToolInvocationSessionBuilder
15
- AUTO_STATE_SET = {
16
- idle: true,
17
- validating: true,
18
- queued: true
19
- }.freeze
10
+ AUTO_STATE_SET = {idle: true, validating: true, queued: true}.freeze
20
11
 
21
12
  DECLARED_STATES = %i[
22
13
  idle validating authorizing awaiting_approval authorized queued running
@@ -27,42 +18,20 @@ module Phronomy
27
18
 
28
19
  EXTERNAL_EVENTS = {
29
20
  authorization_completed: [
30
- {from: :authorizing, to: :cancelled, guard: ->(ctx) {
31
- ctx.cancelled?
32
- }},
33
- {from: :authorizing, to: :failed, guard: ->(ctx) {
34
- ctx.failed?
35
- }},
36
- {from: :authorizing, to: :rejected, guard: ->(ctx) {
37
- ctx.rejected?
38
- }},
39
- {from: :authorizing, to: :awaiting_approval, guard: ->(ctx) {
40
- ctx.awaiting_approval?
41
- }},
42
- {from: :authorizing, to: :authorized, guard: ->(ctx) {
43
- ctx.authorized?
44
- }}
21
+ {from: :authorizing, to: :cancelled, guard: ->(ctx) { ctx.cancelled? }},
22
+ {from: :authorizing, to: :failed, guard: ->(ctx) { ctx.failed? }},
23
+ {from: :authorizing, to: :rejected, guard: ->(ctx) { ctx.rejected? }},
24
+ {from: :authorizing, to: :awaiting_approval, guard: ->(ctx) { ctx.awaiting_approval? }},
25
+ {from: :authorizing, to: :authorized, guard: ->(ctx) { ctx.authorized? }}
45
26
  ],
46
27
  execution_completed: [
47
- {from: :running, to: :cancelled, guard: ->(ctx) {
48
- ctx.cancelled?
49
- }},
50
- {from: :running, to: :failed, guard: ->(ctx) {
51
- ctx.failed?
52
- }},
53
- {from: :running, to: :completed, guard: ->(ctx) {
54
- ctx.execution_completed?
55
- }}
56
- ],
57
- approve: [
58
- {from: :awaiting_approval, to: :authorized, guard: nil}
59
- ],
60
- reject: [
61
- {from: :awaiting_approval, to: :rejected, guard: nil}
62
- ],
63
- dispatch: [
64
- {from: :authorized, to: :queued, guard: nil}
28
+ {from: :running, to: :cancelled, guard: ->(ctx) { ctx.cancelled? }},
29
+ {from: :running, to: :failed, guard: ->(ctx) { ctx.failed? }},
30
+ {from: :running, to: :completed, guard: ->(ctx) { ctx.execution_completed? }}
65
31
  ],
32
+ approve: [{from: :awaiting_approval, to: :authorized, guard: nil}],
33
+ reject: [{from: :awaiting_approval, to: :rejected, guard: nil}],
34
+ dispatch: [{from: :authorized, to: :queued, guard: nil}],
66
35
  cancel: [
67
36
  {from: :awaiting_approval, to: :cancelled, guard: nil},
68
37
  {from: :authorized, to: :cancelled, guard: nil},
@@ -71,14 +40,8 @@ module Phronomy
71
40
  ]
72
41
  }.freeze
73
42
 
74
- def self.build(
75
- tool_invocation:,
76
- runtime: Phronomy::Runtime.instance
77
- )
78
- build_session(
79
- tool_invocation: tool_invocation,
80
- runtime: runtime
81
- )
43
+ def self.build(tool_invocation:, runtime: Phronomy::Runtime.instance)
44
+ build_session(tool_invocation: tool_invocation, runtime: runtime)
82
45
  end
83
46
 
84
47
  def self.build_for_resume(
@@ -125,31 +88,15 @@ module Phronomy
125
88
  def self.build_entry_actions(runtime)
126
89
  {
127
90
  validating: [method(:validating_action)],
128
- authorizing: [
129
- method(:authorizing_action).curry.call(runtime)
130
- ],
131
- awaiting_approval: [
132
- method(:awaiting_approval_action).curry.call(runtime)
133
- ],
134
- authorized: [
135
- method(:authorized_action).curry.call(runtime)
136
- ],
91
+ authorizing: [method(:authorizing_action).curry.call(runtime)],
92
+ awaiting_approval: [method(:awaiting_approval_action).curry.call(runtime)],
93
+ authorized: [method(:authorized_action).curry.call(runtime)],
137
94
  queued: [method(:queued_action)],
138
- running: [
139
- method(:running_action).curry.call(runtime)
140
- ],
141
- completed: [
142
- method(:completed_action).curry.call(runtime)
143
- ],
144
- failed: [
145
- method(:failed_action).curry.call(runtime)
146
- ],
147
- rejected: [
148
- method(:rejected_action).curry.call(runtime)
149
- ],
150
- cancelled: [
151
- method(:cancelled_action).curry.call(runtime)
152
- ]
95
+ running: [method(:running_action).curry.call(runtime)],
96
+ completed: [method(:completed_action).curry.call(runtime)],
97
+ failed: [method(:failed_action).curry.call(runtime)],
98
+ rejected: [method(:rejected_action).curry.call(runtime)],
99
+ cancelled: [method(:cancelled_action).curry.call(runtime)]
153
100
  }
154
101
  end
155
102
  private_class_method :build_entry_actions
@@ -176,59 +123,29 @@ module Phronomy
176
123
 
177
124
  event :state_completed do
178
125
  transition idle: :validating
179
-
180
- transition validating: :failed,
181
- if: ->(machine) { machine.context&.failed? }
182
- transition validating: :completed,
183
- if: ->(machine) {
184
- machine.context&.validation_completed?
185
- }
186
- transition validating: :authorizing,
187
- if: ->(machine) {
188
- machine.context&.validation_passed?
189
- }
190
-
126
+ transition validating: :failed, if: ->(m) { m.context&.failed? }
127
+ transition validating: :completed, if: ->(m) { m.context&.validation_completed? }
128
+ transition validating: :authorizing, if: ->(m) { m.context&.validation_passed? }
191
129
  transition queued: :running
192
130
  end
193
131
 
194
132
  event :authorization_completed do
195
- transition authorizing: :cancelled,
196
- if: ->(machine) { machine.context&.cancelled? }
197
- transition authorizing: :failed,
198
- if: ->(machine) { machine.context&.failed? }
199
- transition authorizing: :rejected,
200
- if: ->(machine) { machine.context&.rejected? }
201
- transition authorizing: :awaiting_approval,
202
- if: ->(machine) {
203
- machine.context&.awaiting_approval?
204
- }
205
- transition authorizing: :authorized,
206
- if: ->(machine) { machine.context&.authorized? }
133
+ transition authorizing: :cancelled, if: ->(m) { m.context&.cancelled? }
134
+ transition authorizing: :failed, if: ->(m) { m.context&.failed? }
135
+ transition authorizing: :rejected, if: ->(m) { m.context&.rejected? }
136
+ transition authorizing: :awaiting_approval, if: ->(m) { m.context&.awaiting_approval? }
137
+ transition authorizing: :authorized, if: ->(m) { m.context&.authorized? }
207
138
  end
208
139
 
209
140
  event :execution_completed do
210
- transition running: :cancelled,
211
- if: ->(machine) { machine.context&.cancelled? }
212
- transition running: :failed,
213
- if: ->(machine) { machine.context&.failed? }
214
- transition running: :completed,
215
- if: ->(machine) {
216
- machine.context&.execution_completed?
217
- }
218
- end
219
-
220
- event :approve do
221
- transition awaiting_approval: :authorized
222
- end
223
-
224
- event :reject do
225
- transition awaiting_approval: :rejected
226
- end
227
-
228
- event :dispatch do
229
- transition authorized: :queued
141
+ transition running: :cancelled, if: ->(m) { m.context&.cancelled? }
142
+ transition running: :failed, if: ->(m) { m.context&.failed? }
143
+ transition running: :completed, if: ->(m) { m.context&.execution_completed? }
230
144
  end
231
145
 
146
+ event(:approve) { transition awaiting_approval: :authorized }
147
+ event(:reject) { transition awaiting_approval: :rejected }
148
+ event(:dispatch) { transition authorized: :queued }
232
149
  event :cancel do
233
150
  transition awaiting_approval: :cancelled
234
151
  transition authorized: :cancelled
@@ -267,13 +184,9 @@ module Phronomy
267
184
  private_class_method :validating_action
268
185
 
269
186
  def self.authorizing_action(runtime, invocation)
270
- task = invocation.authorization_task(runtime: runtime)
271
- observe_task(
272
- runtime,
273
- invocation,
274
- task,
275
- event_type: :authorization_completed
276
- )
187
+ invocation.start_authorization(runtime: runtime) do |outcome|
188
+ post_to_invocation(runtime, invocation.id, :authorization_completed, outcome)
189
+ end
277
190
  invocation
278
191
  end
279
192
  private_class_method :authorizing_action
@@ -298,45 +211,14 @@ module Phronomy
298
211
  private_class_method :queued_action
299
212
 
300
213
  def self.running_action(runtime, invocation)
301
- # execution_task checks dispatchable? which requires :queued status;
302
- # mark_running! is deferred until after the task is started.
303
- task = invocation.execution_task(runtime: runtime)
214
+ invocation.start_execution(runtime: runtime) do |outcome|
215
+ post_to_invocation(runtime, invocation.id, :execution_completed, outcome)
216
+ end
304
217
  invocation.mark_running!
305
- observe_task(
306
- runtime,
307
- invocation,
308
- task,
309
- event_type: :execution_completed
310
- )
311
218
  invocation
312
219
  end
313
220
  private_class_method :running_action
314
221
 
315
- def self.observe_task(
316
- runtime,
317
- invocation,
318
- task,
319
- event_type:
320
- )
321
- task.on_complete do |outcome, error|
322
- payload = error || outcome
323
- accepted = runtime.event_loop.post_to_session(
324
- Phronomy::Event.new(
325
- type: event_type,
326
- target_id: invocation.id,
327
- payload: payload
328
- )
329
- )
330
- next if accepted
331
-
332
- Phronomy.configuration.logger&.warn(
333
- "[Phronomy] Dropped #{event_type.inspect} for " \
334
- "ToolInvocation #{invocation.id}"
335
- )
336
- end
337
- end
338
- private_class_method :observe_task
339
-
340
222
  def self.completed_action(runtime, invocation)
341
223
  notify_parent(runtime, invocation, :tool_completed)
342
224
  invocation
@@ -363,6 +245,18 @@ module Phronomy
363
245
  end
364
246
  private_class_method :cancelled_action
365
247
 
248
+ def self.post_to_invocation(runtime, id, event_type, payload)
249
+ accepted = runtime.event_loop.post_to_session(
250
+ Phronomy::Event.new(type: event_type, target_id: id, payload: payload)
251
+ )
252
+ return if accepted
253
+
254
+ Phronomy.configuration.logger&.warn(
255
+ "[Phronomy] Dropped #{event_type.inspect} for ToolInvocation #{id}"
256
+ )
257
+ end
258
+ private_class_method :post_to_invocation
259
+
366
260
  def self.notify_parent(runtime, invocation, event_type)
367
261
  runtime.event_loop.post_to_session(
368
262
  Phronomy::Event.new(
@@ -2,11 +2,9 @@
2
2
 
3
3
  module Phronomy
4
4
  # Holds global configuration for the entire framework.
5
- # Configure via the Phronomy.configure block.
6
5
  class Configuration
7
6
  STREAM_CALLBACK_ERROR_POLICIES = %i[report fail_task].freeze
8
- RUNTIME_BACKENDS = %i[thread immediate fiber].freeze
9
- private_constant :STREAM_CALLBACK_ERROR_POLICIES, :RUNTIME_BACKENDS
7
+ private_constant :STREAM_CALLBACK_ERROR_POLICIES
10
8
 
11
9
  attr_accessor :default_model
12
10
  attr_accessor :default_embedding_model
@@ -23,17 +21,12 @@ module Phronomy
23
21
  attr_accessor :llm_adapter
24
22
  attr_accessor :event_loop_starvation_threshold_seconds
25
23
  attr_accessor :event_loop_dispatch_threshold_seconds
26
- attr_accessor :scheduler_debug
27
- attr_accessor :blocking_detect_threshold_ms
28
24
  attr_reader :stream_callback_error_policy
29
- attr_accessor :blocking_io_pool_size
30
- attr_accessor :blocking_io_queue_size
25
+ attr_accessor :offload_pool_size
26
+ attr_accessor :offload_queue_size
31
27
  attr_accessor :authorization_pool_size
32
28
  attr_accessor :authorization_queue_size
33
29
  attr_accessor :authorization_timeout
34
- attr_accessor :starvation_threshold_ms
35
- attr_reader :runtime_backend
36
- attr_accessor :strict_runtime_guards
37
30
 
38
31
  def stream_callback_error_policy=(value)
39
32
  unless STREAM_CALLBACK_ERROR_POLICIES.include?(value)
@@ -45,18 +38,6 @@ module Phronomy
45
38
  @stream_callback_error_policy = value
46
39
  end
47
40
 
48
- # Scheduler backend used for newly-created Runtime instances.
49
- # Supported values are :thread, :immediate, and :fiber.
50
- def runtime_backend=(value)
51
- value = value.to_sym if value.respond_to?(:to_sym)
52
- unless RUNTIME_BACKENDS.include?(value)
53
- allowed = RUNTIME_BACKENDS.map(&:inspect).join(", ")
54
- raise Phronomy::ConfigurationError,
55
- "runtime_backend must be one of: #{allowed}"
56
- end
57
- @runtime_backend = value
58
- end
59
-
60
41
  def initialize
61
42
  @recursion_limit = 25
62
43
  @tracer = Phronomy::Tracing::NullTracer.new
@@ -66,17 +47,12 @@ module Phronomy
66
47
  @llm_adapter = Phronomy::LLMAdapter::RubyLLM.new
67
48
  @event_loop_starvation_threshold_seconds = nil
68
49
  @event_loop_dispatch_threshold_seconds = nil
69
- @scheduler_debug = false
70
- @blocking_detect_threshold_ms = nil
71
50
  @stream_callback_error_policy = :report
72
- @blocking_io_pool_size = 10
73
- @blocking_io_queue_size = 100
51
+ @offload_pool_size = 10
52
+ @offload_queue_size = 100
74
53
  @authorization_pool_size = 4
75
54
  @authorization_queue_size = 100
76
55
  @authorization_timeout = 5
77
- @starvation_threshold_ms = 50
78
- @runtime_backend = :thread
79
- @strict_runtime_guards = false
80
56
  end
81
57
  end
82
58
  end
@@ -1,62 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- # Developer-facing diagnostics for blocking operation detection (Issue #279).
5
- #
6
- # Provides debug dump utilities that can be called from an IRB / Rails console
7
- # or in test helpers to inspect the current state of the Runtime.
8
- #
9
- # @example Enable diagnostics and print a dump
10
- # Phronomy.configure { |c| c.scheduler_debug = true }
11
- # Phronomy::Diagnostics.dump
4
+ # Developer-facing diagnostics for EventLoop and offload execution boundaries.
12
5
  module Diagnostics
13
- # Prints a formatted summary of the current Runtime state to +$stderr+
14
- # (or the supplied IO).
15
- #
16
- # Includes:
17
- # - BlockingAdapterPool: active workers, queue depth, abandoned count
18
- # - EventLoop: last / max / average lag in milliseconds
19
- #
20
- # @param out [IO] output destination (default: $stderr)
21
- # @return [void]
22
- # @api public
23
6
  def self.dump(out: $stderr)
24
7
  snap = Phronomy::Metrics.snapshot
25
8
 
26
9
  out.puts "[Phronomy::Diagnostics] Runtime state dump"
27
- out.puts " BlockingAdapterPool:"
28
- out.puts " pool_size : #{snap[:blocking_pool_size]}"
29
- out.puts " active_count : #{snap[:blocking_pool_active]}"
30
- out.puts " queue_depth : #{snap[:blocking_pool_queue_length]}"
31
- out.puts " abandoned_total : #{snap[:blocking_pool_abandoned_total]}"
10
+ out.puts " OffloadPool:"
11
+ out.puts " pool_size : #{snap[:offload_pool_size]}"
12
+ out.puts " active_count : #{snap[:offload_pool_active]}"
13
+ out.puts " queue_depth : #{snap[:offload_pool_queue_length]}"
14
+ out.puts " abandoned_active : #{snap[:offload_pool_abandoned_active]}"
15
+ out.puts " abandoned_total : #{snap[:offload_pool_abandoned_total]}"
32
16
  out.puts " EventLoop:"
33
- out.puts " last_lag_ms : #{snap[:event_loop_lag_last_ms]}"
34
- out.puts " max_lag_ms : #{snap[:event_loop_lag_max_ms]}"
35
- out.puts " average_lag_ms : #{snap[:event_loop_lag_average_ms]}"
17
+ out.puts " last_lag_ms : #{snap[:event_loop_lag_last_ms]}"
18
+ out.puts " max_lag_ms : #{snap[:event_loop_lag_max_ms]}"
19
+ out.puts " average_lag_ms : #{snap[:event_loop_lag_average_ms]}"
36
20
  end
37
21
 
38
- # Returns the diagnostics state as a plain Hash (useful for JSON export).
39
- #
40
- # @return [Hash]
41
- # @api public
42
22
  def self.snapshot
43
23
  Phronomy::Metrics.snapshot
44
24
  end
45
25
 
46
- # Raises an error if +invoke+ (blocking) is called from inside an EventLoop
47
- # action, preventing accidental scheduler stalls.
48
- #
49
- # Called by Agent::Base#invoke and Workflow#invoke before executing.
50
- #
51
- # @raise [Phronomy::SchedulerReentrancyError] when called from EventLoop thread
52
- # @return [void]
53
- # @api private
54
26
  def self.assert_not_in_event_loop!
55
27
  return unless Phronomy::Runtime.in_event_loop_context?
56
28
 
57
- raise Phronomy::SchedulerReentrancyError,
58
- "Blocking invoke called from inside an EventLoop action. " \
59
- "Use invoke_async instead."
29
+ raise Phronomy::EventLoopReentrancyError,
30
+ "Blocking invoke called from inside an EventLoop action. Use invoke_async instead."
60
31
  end
61
32
  end
62
33
  end
@@ -2,219 +2,36 @@
2
2
 
3
3
  module Phronomy
4
4
  module Concurrency
5
- # A thread-safe FIFO queue for passing values between concurrent tasks.
5
+ # Thread-safe FIFO queue used at explicit thread boundaries.
6
6
  #
7
- # Wraps +Thread::Queue+ so that callers do not need to reference the Ruby
8
- # standard-library type directly. A future implementation may replace the
9
- # backing primitive without changing call sites.
10
- #
11
- # @example Producer / consumer
12
- # queue = Phronomy::Concurrency::AsyncQueue.new
13
- # Runtime.instance.spawn { queue.push(expensive_io()) }
14
- # value = queue.pop # blocks until the producer pushes
15
- # @api private
7
+ # It intentionally contains no scheduler/Fiber semantics. Cooperative
8
+ # application execution is represented by FSMSession state and EventLoop events.
16
9
  class AsyncQueue
17
- # @param max_size [Integer, nil] optional upper bound on queue depth.
18
- # When set, {#push} blocks the caller until a slot is available.
19
- # @api private
20
10
  def initialize(max_size: nil)
21
11
  @queue = max_size ? SizedQueue.new(max_size) : Thread::Queue.new
22
- @max_size = max_size
23
- @waiter_mutex = Mutex.new
24
- @cross_thread_waiter = nil # [fiber, scheduler] set by _pop_cooperative; consumed by push
25
- @cross_thread_scheduler = nil # set by expect_cross_thread_push
26
12
  end
27
13
 
28
- # Enqueues +item+.
29
- # In a cooperative scheduler context with a bounded queue (max_size:), suspends
30
- # the current Fiber via a scheduler signal when the queue is full rather than
31
- # blocking the OS thread. Without a scheduler, falls back to the standard
32
- # SizedQueue blocking behaviour.
33
- # @param item [Object] value to enqueue
34
- # @return [self]
35
- # @api private
36
14
  def push(item)
37
- scheduler = Phronomy::Runtime::Scheduler.current
38
- if scheduler && @max_size
39
- _push_cooperative(scheduler, item)
40
- else
41
- @queue.push(item)
42
- scheduler.raise_signal(@coop_signal) if scheduler && @coop_signal
43
- # Wake a cross-thread waiter if one is registered.
44
- # Handles the case where a DeterministicScheduler Fiber is suspended
45
- # in _pop_cooperative waiting for a push from a non-scheduler thread
46
- # (e.g. EventLoop thread where Scheduler.current is nil).
47
- # enqueue_fiber is thread-safe; complete_blocking_await decrements
48
- # @pending_awaits so run_until_idle can eventually exit.
49
- if @cross_thread_scheduler
50
- waiter = @waiter_mutex.synchronize do
51
- w = @cross_thread_waiter
52
- @cross_thread_waiter = nil
53
- w
54
- end
55
- if waiter
56
- fiber, sched = waiter
57
- sched.complete_blocking_await
58
- sched.enqueue_fiber(-> { fiber.resume })
59
- end
60
- end
61
- end
15
+ @queue.push(item)
62
16
  self
63
17
  end
64
18
 
65
- # Dequeues and returns the next item.
66
- # In a cooperative scheduler context, suspends the current Fiber (yielding
67
- # control back to the scheduler) rather than blocking the OS thread.
68
- #
69
- # When +timeout+ is given the semantics depend on the active backend:
70
- #
71
- # * **Thread backend** (`:thread`) — uses real wall-clock time via
72
- # +Thread::Queue#pop(timeout:)+. Requires Ruby 3.2+.
73
- # Returns +nil+ if no item arrives within the specified number of real seconds.
74
- # * **DeterministicScheduler / `:fiber` backend** — uses the scheduler's
75
- # *virtual time* (+scheduler.virtual_time+). The timeout elapses only when
76
- # the virtual clock is advanced (e.g. via {Phronomy::Testing::FakeClock#advance}).
77
- # In tests this means the timeout is fully deterministic and does not depend on
78
- # actual elapsed wall time. However, in production `:fiber` mode the timeout
79
- # may never expire unless the scheduler explicitly advances virtual time.
80
- #
81
- # @note The `:fiber` backend is **EXPERIMENTAL**. Real-time timeout behaviour
82
- # in production workloads is not guaranteed and may differ from wall-clock
83
- # expectations.
84
- # @note **Cooperative timeout limitation**: on the cooperative path, the
85
- # deadline is re-checked *after* a wake-up signal arrives. If virtual time
86
- # has already passed the deadline when the consumer is woken by a producer
87
- # push, the consumer returns +nil+ rather than the pushed item. Without any
88
- # wake-up signal the waiting Fiber remains suspended even after
89
- # +scheduler.advance+ — the timeout does not self-fire.
90
- # @param timeout [Numeric, nil] seconds to wait before returning +nil+.
91
- # Semantics are wall-clock on `:thread` and virtual-time on `:fiber`.
92
- # @return [Object, nil] the next item, or +nil+ when timeout expires
93
- # @api private
94
19
  def pop(timeout: nil)
95
- scheduler = Phronomy::Runtime::Scheduler.current
96
- if scheduler
97
- _pop_cooperative(scheduler, timeout: timeout)
98
- elsif timeout
99
- @queue.pop(timeout: timeout)
100
- else
101
- @queue.pop
102
- end
20
+ timeout ? @queue.pop(timeout: timeout) : @queue.pop
103
21
  end
104
22
 
105
- # Returns the current number of items in the queue.
106
- # @return [Integer]
107
- # @api private
108
23
  def size
109
24
  @queue.size
110
25
  end
111
26
 
112
- # Returns +true+ when the queue contains no items.
113
- # @return [Boolean]
114
- # @api private
115
27
  def empty?
116
28
  @queue.empty?
117
29
  end
118
30
 
119
- # Closes the queue. Subsequent {#pop} calls raise +ClosedQueueError+.
120
- # @return [self]
121
- # @api private
122
31
  def close
123
32
  @queue.close
124
33
  self
125
34
  end
126
-
127
- # Marks this queue as expecting pushes from a non-scheduler OS thread.
128
- # When set, {#pop} in cooperative mode uses +track_blocking_await+ so that
129
- # {Runtime::DeterministicScheduler#run_until_idle} does not exit while
130
- # waiting for the cross-thread push. Called by {EventLoop#register} when
131
- # a cooperative scheduler is active on the calling thread.
132
- # @param scheduler [Runtime::Scheduler]
133
- # @return [self]
134
- # @api private
135
- def expect_cross_thread_push(scheduler)
136
- @cross_thread_scheduler = scheduler
137
- self
138
- end
139
-
140
- private
141
-
142
- # Cooperative pop for DeterministicScheduler context.
143
- # Suspends the current Fiber via the scheduler's signal mechanism rather than
144
- # blocking the OS thread.
145
- #
146
- # Two suspension paths:
147
- # * **Same-scheduler** (default): uses {CoopSignal} — the producer is another
148
- # Fiber on the same DeterministicScheduler. run_until_idle is allowed to
149
- # exit; the producer's push will enqueue the consumer Fiber.
150
- # * **Cross-thread** ({#expect_cross_thread_push} was called): uses
151
- # +track_blocking_await+ so that run_until_idle does not exit while waiting
152
- # for a push from a non-scheduler OS thread (e.g. EventLoop). The push
153
- # side calls +complete_blocking_await+ + +enqueue_fiber+ to resume.
154
- #
155
- # The empty?/register pair for the cross-thread path is wrapped in
156
- # @waiter_mutex to eliminate the race between the empty check and the
157
- # registration of the waker.
158
- # @api private
159
- # @param scheduler [Runtime::Scheduler]
160
- # @param timeout [Numeric, nil]
161
- # @return [Object, nil]
162
- def _pop_cooperative(scheduler, timeout:)
163
- @coop_signal ||= scheduler.new_signal
164
- deadline = timeout ? (scheduler.virtual_time + timeout) : nil
165
-
166
- loop do
167
- unless @queue.empty?
168
- item = @queue.pop(timeout: 0)
169
- # Notify a push-waiter (bounded queue) that a slot opened up.
170
- scheduler.raise_signal(@push_signal) if @push_signal
171
- return item
172
- end
173
- return nil if deadline && scheduler.virtual_time >= deadline
174
-
175
- if @cross_thread_scheduler
176
- # Cross-thread path: atomically check the queue and register a waker
177
- # so that a concurrent push cannot slip between the empty? check above
178
- # and the registration below.
179
- will_yield = false
180
- @waiter_mutex.synchronize do
181
- if @queue.empty?
182
- @cross_thread_waiter = [Fiber.current, scheduler]
183
- scheduler.track_blocking_await
184
- will_yield = true
185
- end
186
- # else: push arrived between the loop's empty? check and here;
187
- # will_yield stays false and the next loop iteration dequeues it.
188
- end
189
- Fiber.yield(:cooperative_suspend) if will_yield
190
- else
191
- scheduler.wait_for_signal(@coop_signal)
192
- end
193
-
194
- return nil if deadline && scheduler.virtual_time >= deadline
195
- end
196
- end
197
-
198
- # Cooperative push for DeterministicScheduler context with a bounded queue.
199
- # Suspends the current Fiber via a scheduler signal when the queue is full,
200
- # rather than blocking the OS thread.
201
- # @api private
202
- # @param scheduler [Runtime::Scheduler]
203
- # @param item [Object]
204
- # @return [void]
205
- def _push_cooperative(scheduler, item)
206
- @push_signal ||= scheduler.new_signal
207
-
208
- loop do
209
- unless @queue.size >= @max_size
210
- @queue.push(item)
211
- # Notify any pop-waiter that an item is now available.
212
- scheduler.raise_signal(@coop_signal) if @coop_signal
213
- return
214
- end
215
- scheduler.wait_for_signal(@push_signal)
216
- end
217
- end
218
35
  end
219
36
  end
220
37
  end