phronomy 0.11.0 → 0.11.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +17 -8
  3. data/benchmark/bench_agent_invoke.rb +1 -0
  4. data/lib/phronomy/agent/base.rb +192 -78
  5. data/lib/phronomy/agent/concerns/error_translation.rb +1 -1
  6. data/lib/phronomy/agent/concerns/retryable.rb +6 -2
  7. data/lib/phronomy/agent/invocation_context.rb +171 -0
  8. data/lib/phronomy/agent/invocation_session.rb +337 -0
  9. data/lib/phronomy/agent/phase_machine_builder.rb +189 -0
  10. data/lib/phronomy/agent/suspended_session_registry.rb +54 -0
  11. data/lib/phronomy/agent/tool_call_intercepted.rb +26 -0
  12. data/lib/phronomy/configuration.rb +0 -6
  13. data/lib/phronomy/{concurrency → engine/concurrency}/async_queue.rb +68 -5
  14. data/lib/phronomy/{concurrency → engine/concurrency}/blocking_adapter_pool.rb +9 -3
  15. data/lib/phronomy/{event_loop.rb → engine/event_loop.rb} +71 -37
  16. data/lib/phronomy/engine/fsm_session.rb +248 -0
  17. data/lib/phronomy/{runtime → engine/runtime}/deterministic_scheduler.rb +28 -1
  18. data/lib/phronomy/{runtime → engine/runtime}/fake_scheduler.rb +1 -1
  19. data/lib/phronomy/{runtime.rb → engine/runtime.rb} +2 -4
  20. data/lib/phronomy/{task → engine/task}/backend.rb +2 -2
  21. data/lib/phronomy/engine/task/deferred_backend.rb +73 -0
  22. data/lib/phronomy/{task → engine/task}/fiber_backend.rb +1 -1
  23. data/lib/phronomy/{task → engine/task}/immediate_backend.rb +1 -1
  24. data/lib/phronomy/{task → engine/task}/mapped_backend.rb +15 -3
  25. data/lib/phronomy/{task → engine/task}/thread_backend.rb +1 -1
  26. data/lib/phronomy/{task.rb → engine/task.rb} +19 -7
  27. data/lib/phronomy/eval/scorer/llm_judge.rb +1 -1
  28. data/lib/phronomy/generator_verifier.rb +20 -11
  29. data/lib/phronomy/multi_agent/orchestrator.rb +4 -4
  30. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +3 -3
  31. data/lib/phronomy/tools/mcp.rb +1 -1
  32. data/lib/phronomy/version.rb +1 -1
  33. data/lib/phronomy/workflow/phase_machine_builder.rb +24 -13
  34. data/lib/phronomy/workflow.rb +6 -3
  35. data/lib/phronomy/workflow_context.rb +7 -5
  36. data/lib/phronomy/workflow_runner.rb +77 -27
  37. data/lib/phronomy.rb +17 -9
  38. metadata +35 -35
  39. data/lib/phronomy/agent/checkpoint.rb +0 -208
  40. data/lib/phronomy/agent/checkpoint_store.rb +0 -97
  41. data/lib/phronomy/agent/concerns/suspendable.rb +0 -190
  42. data/lib/phronomy/agent/suspend_signal.rb +0 -37
  43. data/lib/phronomy/context.rb +0 -12
  44. data/lib/phronomy/tool.rb +0 -9
  45. data/lib/phronomy/workflow/fsm_session.rb +0 -249
  46. /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_scope.rb +0 -0
  47. /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_token.rb +0 -0
  48. /data/lib/phronomy/{concurrency → engine/concurrency}/concurrency_gate.rb +0 -0
  49. /data/lib/phronomy/{concurrency → engine/concurrency}/deadline.rb +0 -0
  50. /data/lib/phronomy/{concurrency → engine/concurrency}/gate_registry.rb +0 -0
  51. /data/lib/phronomy/{concurrency → engine/concurrency}/pool_registry.rb +0 -0
  52. /data/lib/phronomy/{runtime → engine/runtime}/runtime_metrics.rb +0 -0
  53. /data/lib/phronomy/{runtime → engine/runtime}/scheduler.rb +0 -0
  54. /data/lib/phronomy/{runtime → engine/runtime}/scheduler_timer_adapter.rb +0 -0
  55. /data/lib/phronomy/{runtime → engine/runtime}/task_registry.rb +0 -0
  56. /data/lib/phronomy/{runtime → engine/runtime}/thread_scheduler.rb +0 -0
  57. /data/lib/phronomy/{runtime → engine/runtime}/timer_queue.rb +0 -0
  58. /data/lib/phronomy/{runtime → engine/runtime}/timer_service.rb +0 -0
  59. /data/lib/phronomy/{task_group.rb → engine/task_group.rb} +0 -0
@@ -20,6 +20,9 @@ module Phronomy
20
20
  def initialize(max_size: nil)
21
21
  @queue = max_size ? SizedQueue.new(max_size) : Thread::Queue.new
22
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
23
26
  end
24
27
 
25
28
  # Enqueues +item+.
@@ -37,6 +40,24 @@ module Phronomy
37
40
  else
38
41
  @queue.push(item)
39
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
40
61
  end
41
62
  self
42
63
  end
@@ -103,14 +124,37 @@ module Phronomy
103
124
  self
104
125
  end
105
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
+
106
140
  private
107
141
 
108
142
  # Cooperative pop for DeterministicScheduler context.
109
143
  # Suspends the current Fiber via the scheduler's signal mechanism rather than
110
- # blocking the OS thread. Because cooperative mode is single-threaded, the
111
- # empty?/pop pair is race-free (no other Fiber can run between the two calls).
112
- # After dequeuing, notifies any push-waiter so that a backpressure-suspended
113
- # producer can be unblocked.
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.
114
158
  # @api private
115
159
  # @param scheduler [Runtime::Scheduler]
116
160
  # @param timeout [Numeric, nil]
@@ -127,7 +171,26 @@ module Phronomy
127
171
  return item
128
172
  end
129
173
  return nil if deadline && scheduler.virtual_time >= deadline
130
- scheduler.wait_for_signal(@coop_signal)
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
+
131
194
  return nil if deadline && scheduler.virtual_time >= deadline
132
195
  end
133
196
  end
@@ -26,12 +26,12 @@ module Phronomy
26
26
  #
27
27
  # @example Submitting a blocking LLM call
28
28
  # op = runtime.blocking_io.submit(timeout: 30) { chat.ask(message) }
29
- # result = op.await # blocks the calling thread until done
29
+ # result = op.blocking_wait # blocks the calling thread until done
30
30
  #
31
31
  # @example With cancellation
32
32
  # token = Phronomy::Concurrency::CancellationToken.timeout_after(60)
33
33
  # op = pool.submit(timeout: 30, cancellation_token: token) { expensive_call }
34
- # result = op.await
34
+ # result = op.blocking_wait
35
35
  class BlockingAdapterPool
36
36
  # Represents the pending result of a submitted blocking operation.
37
37
  # Returned immediately by {BlockingAdapterPool#submit}; call {#await} to
@@ -103,7 +103,7 @@ module Phronomy
103
103
  # @raise [Phronomy::CancellationError]
104
104
  # @raise [Exception] error raised inside the submitted block
105
105
  # @api private
106
- def await(timeout: nil, cancellation_token: nil)
106
+ def blocking_wait(timeout: nil, cancellation_token: nil)
107
107
  effective_timeout = [timeout, @timeout].compact.min
108
108
  effective_token = cancellation_token || @cancellation_token
109
109
 
@@ -174,6 +174,12 @@ module Phronomy
174
174
  @value
175
175
  end
176
176
 
177
+ # Unified wait interface compatible with {Phronomy::Task#wait_result}.
178
+ # Delegates to {#blocking_wait} so that callers treating the return value
179
+ # of {ToolExecutor.call_async} uniformly as +#wait_result+ work correctly
180
+ # regardless of whether a Task or PendingOperation is returned.
181
+ alias_method :wait_result, :blocking_wait
182
+
177
183
  # Registers a callback to be called when the operation finishes.
178
184
  # If the operation has already finished the callback is invoked immediately
179
185
  # on the calling thread. Otherwise it is invoked on the worker thread that
@@ -9,7 +9,7 @@ module Phronomy
9
9
  # +BlockingAdapterPool+, then post results back to the loop via
10
10
  # {EventLoop#post}.
11
11
  #
12
- # Activated with: +Phronomy.configure { |c| c.event_loop = true }+
12
+ # Always active all Workflow and Agent invocations use the EventLoop.
13
13
  #
14
14
  # == Threading exception (see ADR-010 Rule 2)
15
15
  #
@@ -49,6 +49,11 @@ module Phronomy
49
49
  # +Runtime.instance.spawn+ or +BlockingAdapterPool+, then post events back
50
50
  # via +Phronomy::EventLoop.instance.post(...)+.
51
51
  class EventLoop
52
+ # Sentinel target_id for EventLoop management events (:start, :finished, :halted, :error).
53
+ # Events with this target_id are processed directly by the EventLoop and never
54
+ # routed to any FSMSession via handle(event).
55
+ SYSTEM_CHANNEL_ID = "__event_loop__"
56
+
52
57
  # Returns the singleton instance, creating and starting it on first call.
53
58
  def self.instance
54
59
  @instance ||= new.tap(&:start)
@@ -129,11 +134,11 @@ module Phronomy
129
134
  # (WorkflowContext) once the workflow finishes or halts. If an error occurred,
130
135
  # the popped value will be an Exception — callers are responsible for re-raising it.
131
136
  #
132
- # @param fsm_session [Phronomy::Workflow::FSMSession]
137
+ # @param fsm_session [Phronomy::FSMSession]
133
138
  # @return [Phronomy::Concurrency::AsyncQueue] resolves to final/halted context, or an Exception
134
139
  # @api private
135
- def register(fsm_session)
136
- if Phronomy::EventLoop.current?
140
+ def register(fsm_session, completion: nil)
141
+ if Phronomy::EventLoop.current? && !completion.is_a?(Phronomy::Task)
137
142
  raise Phronomy::Error,
138
143
  "Cannot call Workflow#invoke (EventLoop mode) from within an EventLoop " \
139
144
  "entry action. Schedule work via Runtime.instance.spawn or " \
@@ -141,10 +146,17 @@ module Phronomy
141
146
  "Phronomy::EventLoop.instance.post(...) instead."
142
147
  end
143
148
 
144
- completion_queue = Phronomy::Concurrency::AsyncQueue.new
149
+ completion_queue = completion || Phronomy::Concurrency::AsyncQueue.new
150
+ # When called from a DeterministicScheduler Fiber (e.g. :fiber backend),
151
+ # mark the queue so that _pop_cooperative uses track_blocking_await.
152
+ # This prevents run_until_idle from exiting before the EventLoop thread
153
+ # (a different OS thread where Scheduler.current is nil) pushes the result.
154
+ scheduler = Phronomy::Runtime::Scheduler.current
155
+ completion_queue.expect_cross_thread_push(scheduler) if scheduler && completion_queue.respond_to?(:expect_cross_thread_push)
145
156
  # Pass both session and completion_queue in the event payload so that the
146
157
  # EventLoop thread is the sole writer of @fsms and @waiting.
147
- @queue.push([Event.new(type: :start, target_id: fsm_session.id,
158
+ # Use SYSTEM_CHANNEL_ID so the management event is never routed to an FSM.
159
+ @queue.push([Event.new(type: :start, target_id: SYSTEM_CHANNEL_ID,
148
160
  payload: {session: fsm_session, completion: completion_queue}),
149
161
  Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)])
150
162
  completion_queue
@@ -282,40 +294,46 @@ module Phronomy
282
294
  check_starvation_lag(lag_ns, event)
283
295
 
284
296
  dispatch_start_ns = dequeued_at_ns
285
- case event.type
286
- when :finished, :halted, :error
287
- # All three terminal events share the same cleanup path.
288
- # Both @fsms and @waiting are exclusively owned by this thread.
289
- @fsms.delete(event.target_id)
290
- cq = @waiting.delete(event.target_id)
291
- cq&.push(event.payload)
292
- # Decrement active FSM count and signal drain waiters.
293
- @fsm_count_mutex.synchronize do
294
- @fsm_count -= 1
295
- @fsm_count_cond.signal if @fsm_count <= 0
296
- end
297
+ if event.target_id == SYSTEM_CHANNEL_ID
298
+ # Management channel: lifecycle events processed directly by EventLoop.
299
+ # Never routed to any FSMSession.
300
+ case event.type
301
+ when :finished, :halted, :error
302
+ # session_id is carried in the payload so the FSM can use its own ID
303
+ # as the FSM-facing target_id for other events.
304
+ session_id = event.payload[:session_id]
305
+ @fsms.delete(session_id)
306
+ cq = @waiting.delete(session_id)
307
+ complete_waiter(cq, event.payload[:result])
308
+ # Decrement active FSM count and signal drain waiters.
309
+ @fsm_count_mutex.synchronize do
310
+ @fsm_count -= 1
311
+ @fsm_count_cond.signal if @fsm_count <= 0
312
+ end
297
313
 
298
- when :start
299
- # session and completion_queue arrive together in the payload so that
300
- # this thread is the sole writer of @fsms and @waiting.
301
- # completion may be nil for fire-and-forget child sessions (AgentFSM).
302
- session = event.payload[:session]
303
- cq = event.payload[:completion]
304
-
305
- # When shutdown has been requested, reject new sessions with a
306
- # CancellationError rather than starting new LLM calls that would
307
- # be interrupted by force-kill.
308
- if @shutdown_token.cancelled? && cq
309
- cq.push(Phronomy::CancellationError.new("EventLoop is shutting down"))
310
- next
311
- end
314
+ when :start
315
+ # session and completion_queue arrive together in the payload so that
316
+ # this thread is the sole writer of @fsms and @waiting.
317
+ # completion may be nil for fire-and-forget child sessions (AgentFSM).
318
+ session = event.payload[:session]
319
+ cq = event.payload[:completion]
312
320
 
313
- @fsms[event.target_id] = session
314
- @waiting[event.target_id] = cq if cq
315
- @fsm_count_mutex.synchronize { @fsm_count += 1 }
316
- session.start
321
+ # When shutdown has been requested, reject new sessions with a
322
+ # CancellationError rather than starting new LLM calls that would
323
+ # be interrupted by force-kill.
324
+ if @shutdown_token.cancelled? && cq
325
+ complete_waiter(cq, Phronomy::CancellationError.new("EventLoop is shutting down"))
326
+ next
327
+ end
328
+
329
+ @fsms[session.id] = session
330
+ @waiting[session.id] = cq if cq
331
+ @fsm_count_mutex.synchronize { @fsm_count += 1 }
332
+ session.start
333
+ end
317
334
 
318
335
  else
336
+ # FSM channel: route to the target FSMSession by target_id.
319
337
  fsm = @fsms[event.target_id]
320
338
  if fsm
321
339
  fsm.handle(event)
@@ -332,10 +350,26 @@ module Phronomy
332
350
  end
333
351
  rescue => e
334
352
  # Unblock all waiting callers if the loop dies unexpectedly.
335
- @waiting.values.each { |cq| cq.push(e) }
353
+ @waiting.values.each { |cq| complete_waiter(cq, e) }
336
354
  raise
337
355
  end
338
356
 
357
+ def complete_waiter(waiter, payload)
358
+ return unless waiter
359
+
360
+ if waiter.is_a?(Phronomy::Task)
361
+ if payload.is_a?(Exception)
362
+ waiter.backend.unblock(nil, payload)
363
+ waiter.transition!(:failed, error: payload)
364
+ else
365
+ waiter.backend.unblock(payload, nil)
366
+ waiter.transition!(:completed, value: payload)
367
+ end
368
+ else
369
+ waiter.push(payload)
370
+ end
371
+ end
372
+
339
373
  def update_lag_metrics(lag_ns)
340
374
  @lag_mutex.synchronize do
341
375
  @last_lag_ns = lag_ns
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Event-driven execution wrapper for a single FSM session.
5
+ #
6
+ # Used by both WorkflowRunner (for Workflow) and Agent::InvocationSession
7
+ # (for Agent invoke). Not Workflow-specific.
8
+ #
9
+ # Created by a runner and registered with EventLoop. All public methods
10
+ # are called from the EventLoop thread — FSMSession is NOT thread-safe and must
11
+ # not be accessed concurrently from multiple threads.
12
+ #
13
+ class FSMSession
14
+ FINISH = WorkflowRunner::FINISH
15
+
16
+ # @return [String] workflow thread_id (matches WorkflowContext#thread_id)
17
+ attr_reader :id
18
+
19
+ # @param id [String]
20
+ # @param context [Object] includes Phronomy::WorkflowContext
21
+ # @param entry_point [Symbol] initial state name
22
+ # @param entry_actions [Hash] { state_name => [callable, ...] }
23
+ # @param auto_state_set [Hash] { state_name => true }
24
+ # @param declared_states [Array<Symbol>] all action state names
25
+ # @param wait_state_names [Array<Symbol>]
26
+ # @param external_events [Hash] { event_name => [{from:, to:, guard:}] }
27
+ # @param phase_machine_class [Class] state_machines-backed phase tracker class
28
+ # @param recursion_limit [Integer]
29
+ # @param action_timeouts [Hash] { state_name => seconds }
30
+ # @param resume_event [Symbol, nil] external event to fire when resuming
31
+ # @param resume_phase [Symbol, nil] wait state name to resume from
32
+ # @api private
33
+ def initialize(id:, context:, entry_point:, entry_actions:, auto_state_set:,
34
+ declared_states:, wait_state_names:, external_events:, phase_machine_class:,
35
+ recursion_limit:, action_timeouts: {}, resume_event: nil, resume_phase: nil)
36
+ @id = id
37
+ @ctx = context
38
+ @entry_point = entry_point
39
+ @entry_actions = entry_actions
40
+ @auto_state_set = auto_state_set
41
+ @declared_states = declared_states
42
+ @wait_state_names = wait_state_names
43
+ @external_events = external_events
44
+ @phase_machine_class = phase_machine_class
45
+ @recursion_limit = recursion_limit
46
+ @action_timeouts = action_timeouts
47
+ @resume_event = resume_event
48
+ @resume_phase = resume_phase
49
+ @step = 0
50
+ @done = false
51
+ @current_state = nil
52
+ @tracker = nil
53
+ end
54
+
55
+ # Begins workflow execution. Called by EventLoop on :start event.
56
+ def start
57
+ if @resume_event
58
+ # Resume from wait state: position tracker at the wait state, then fire the
59
+ # external event. state_machines fires before_transition (exit) and
60
+ # after_transition (entry) callbacks, so both actions execute here.
61
+ @current_state = @resume_phase
62
+ @tracker = build_tracker(@current_state)
63
+ @tracker.context = @ctx
64
+ @tracker.session_id = @id if @tracker.respond_to?(:session_id=)
65
+ fire_and_advance!(@resume_event)
66
+ else
67
+ # Fresh start: state_machines does not fire callbacks on initialization,
68
+ # so we invoke the entry action for the initial state manually.
69
+ @current_state = @entry_point
70
+ @tracker = build_tracker(@current_state)
71
+ @tracker.context = @ctx
72
+ @tracker.session_id = @id if @tracker.respond_to?(:session_id=)
73
+ (@entry_actions[@current_state] || []).each do |c|
74
+ result = c.call(@ctx)
75
+ if result.is_a?(Phronomy::Task)
76
+ # Awaitable action: resume via on_complete without blocking EventLoop.
77
+ @tracker.async_pending = true
78
+ session_id = @id
79
+ current_state_name = @current_state
80
+ timeout_secs = @action_timeouts[current_state_name]
81
+ if timeout_secs
82
+ Phronomy::Runtime.instance.timer_queue.schedule(seconds: timeout_secs) do
83
+ next if result.done?
84
+
85
+ event_loop.post(
86
+ Event.new(
87
+ type: :error,
88
+ target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID,
89
+ payload: {session_id: session_id, result: Phronomy::ActionTimeoutError.new(
90
+ "Action in state #{current_state_name.inspect} timed out after #{timeout_secs}s"
91
+ )}
92
+ )
93
+ )
94
+ end
95
+ end
96
+ result.on_complete do |task_result, error|
97
+ if error
98
+ event_loop.post(Event.new(type: :error, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: session_id, result: error}))
99
+ next
100
+ end
101
+ if _fsm_context?(task_result)
102
+ event_loop.post(Event.new(type: :action_completed, target_id: session_id, payload: task_result))
103
+ else
104
+ event_loop.post(Event.new(type: :state_completed, target_id: session_id, payload: nil))
105
+ end
106
+ end
107
+ break # Only one async action at a time per state
108
+ elsif _fsm_context?(result)
109
+ @ctx = result
110
+ end
111
+ end
112
+ @tracker.context = @ctx
113
+ advance_or_halt unless @tracker.async_pending
114
+ end
115
+ rescue => e
116
+ finish_with_error(e)
117
+ end
118
+
119
+ # Processes an event dispatched from EventLoop.
120
+ # Called for :state_completed, :action_completed, and all user-defined external events.
121
+ #
122
+ # @param event [Phronomy::Event]
123
+ # @api private
124
+ def handle(event)
125
+ return if @done
126
+
127
+ if event.type == :action_completed
128
+ # An awaitable entry action completed: update context and advance.
129
+ @ctx = event.payload if _fsm_context?(event.payload)
130
+ @tracker.context = @ctx
131
+ @tracker.async_pending = false # Reset flag set by start or fire_and_advance!
132
+ advance_or_halt
133
+ return
134
+ end
135
+
136
+ # When :state_completed arrives from an async Task (non-WorkflowContext result),
137
+ # async_pending may still be true from the spawn. Clear it before advancing.
138
+ @tracker.async_pending = false if event.type == :state_completed && @tracker.async_pending
139
+
140
+ fire_and_advance!(event.type)
141
+ rescue => e
142
+ finish_with_error(e)
143
+ end
144
+
145
+ private
146
+
147
+ # Fires event_name on the phase tracker, updates @current_state, then
148
+ # calls advance_or_halt to decide what to do next.
149
+ def fire_and_advance!(event_name)
150
+ if @step >= @recursion_limit
151
+ raise Phronomy::RecursionLimitError,
152
+ "Recursion limit (#{@recursion_limit}) exceeded"
153
+ end
154
+
155
+ fire_event!(@tracker, event_name, @current_state)
156
+ @ctx = @tracker.context
157
+ next_phase = @tracker.phase.to_sym
158
+ # When next_phase == @current_state, no transition matched → treat as terminal.
159
+ @current_state = (next_phase == @current_state) ? FINISH : next_phase
160
+ @step += 1
161
+
162
+ # If an entry action returned a Task, the after_transition callback set
163
+ # async_pending = true and spawned a thread. Skip advance_or_halt — the
164
+ # background thread will post :action_completed or :state_completed.
165
+ if @tracker.async_pending
166
+ @tracker.async_pending = false
167
+ return
168
+ end
169
+
170
+ advance_or_halt
171
+ end
172
+
173
+ # Determines the next action after the FSM has entered @current_state.
174
+ def advance_or_halt
175
+ return finish! if @current_state == FINISH
176
+
177
+ if @wait_state_names.include?(@current_state)
178
+ return halt!
179
+ end
180
+
181
+ if @auto_state_set.key?(@current_state)
182
+ event_loop.post(Event.new(type: :state_completed, target_id: @id, payload: nil))
183
+ return
184
+ end
185
+
186
+ if has_external_event_from?(@current_state)
187
+ # Async IO pattern: the entry action spawned an IO thread that will post
188
+ # an external event back. Stay registered; do nothing here.
189
+ return
190
+ end
191
+
192
+ # No transition declared — validate the state is known, then treat as terminal.
193
+ unless @declared_states.include?(@current_state)
194
+ raise ArgumentError, "State #{@current_state.inspect} is not defined"
195
+ end
196
+
197
+ finish!
198
+ end
199
+
200
+ def finish!
201
+ @done = true
202
+ @ctx.set_graph_metadata(thread_id: @id, phase: :__end__)
203
+ event_loop.post(Event.new(type: :finished, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: @id, result: @ctx}))
204
+ end
205
+
206
+ def halt!
207
+ @done = true
208
+ @ctx.set_graph_metadata(thread_id: @id, phase: @current_state)
209
+ event_loop.post(Event.new(type: :halted, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: @id, result: @ctx}))
210
+ end
211
+
212
+ def finish_with_error(err)
213
+ @done = true
214
+ event_loop.post(Event.new(type: :error, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: @id, result: err}))
215
+ end
216
+
217
+ def fire_event!(tracker, event_name, from_state)
218
+ return if tracker.send(event_name)
219
+
220
+ raise ArgumentError,
221
+ "Transition from #{from_state.inspect} via event #{event_name.inspect} failed. " \
222
+ "Ensure at least one guard matches or add a fallback (no-guard) transition."
223
+ end
224
+
225
+ def has_external_event_from?(state)
226
+ @external_events.any? { |_, transitions| transitions.any? { |t| t[:from] == state } }
227
+ end
228
+
229
+ def build_tracker(from_state)
230
+ machine = @phase_machine_class.new
231
+ machine.instance_variable_set(:@phase, from_state.to_s)
232
+ machine
233
+ end
234
+
235
+ def event_loop
236
+ Phronomy::EventLoop.instance
237
+ end
238
+
239
+ # Returns true when +obj+ is an FSM execution context (responds to
240
+ # +set_graph_metadata+). Used to distinguish context objects from other
241
+ # Task return values (strings, hashes, etc.) without hard-coding a specific
242
+ # class. Both WorkflowContext and Agent::InvocationContext qualify.
243
+ # @api private
244
+ def _fsm_context?(obj)
245
+ obj.respond_to?(:set_graph_metadata)
246
+ end
247
+ end
248
+ end
@@ -99,6 +99,13 @@ module Phronomy
99
99
  @pending_awaits = 0
100
100
  @await_mutex = Mutex.new
101
101
  @await_cond = ConditionVariable.new
102
+ # Tracks which OS thread is currently executing run_until_idle.
103
+ # When a spawn is called from a DIFFERENT thread while run_until_idle is
104
+ # active, creating a FiberBackend on that thread would produce a Fiber
105
+ # that the run_until_idle owner thread cannot resume (Ruby 3+ restriction).
106
+ # The mutex is separate from @mutex/@await_mutex to avoid lock-ordering issues.
107
+ @rui_mutex = Mutex.new
108
+ @run_until_idle_thread = nil
102
109
  end
103
110
 
104
111
  # Returns +true+ when this scheduler is in autorun mode.
@@ -116,6 +123,23 @@ module Phronomy
116
123
  # @return [Task]
117
124
  # @api private
118
125
  def spawn(name:, parent:, &block)
126
+ inside_tick = Thread.current.thread_variable_get(SCHEDULER_KEY)
127
+ rui_thread = @rui_mutex.synchronize { @run_until_idle_thread }
128
+
129
+ # Cross-thread guard: if run_until_idle is active on a DIFFERENT OS thread
130
+ # (e.g. the EventLoop calling Runtime.instance.spawn while the test thread
131
+ # is blocked in run_until_idle waiting for a cross-thread push), creating a
132
+ # FiberBackend here would produce a Fiber owned by THIS thread. The
133
+ # run_until_idle owner thread cannot resume it, which raises:
134
+ # FiberError: fiber called across threads
135
+ # Fall back to ThreadBackend so the block runs on its own OS thread instead,
136
+ # and broadcast @await_cond so the owner thread is notified of new work.
137
+ if !inside_tick && rui_thread && rui_thread != Thread.current
138
+ task = Task.spawn(name: name, parent: parent, backend_class: Task::ThreadBackend, &block)
139
+ @await_mutex.synchronize { @await_cond.broadcast }
140
+ return task
141
+ end
142
+
119
143
  task = Task.spawn(name: name, parent: parent, backend_class: Task::FiberBackend, &block)
120
144
  backend = task.backend
121
145
  # Build a self-rescheduling step: after each step, re-enqueue if the
@@ -130,7 +154,7 @@ module Phronomy
130
154
  # When SCHEDULER_KEY is set, the calling code is already inside a managed
131
155
  # Fiber; the outer run_until_idle loop will pick up the new task on the
132
156
  # next iteration without a recursive re-entry.
133
- run_until_idle if @autorun && Thread.current.thread_variable_get(SCHEDULER_KEY).nil?
157
+ run_until_idle if @autorun && !inside_tick
134
158
  task
135
159
  end
136
160
 
@@ -202,6 +226,7 @@ module Phronomy
202
226
  # @return [self]
203
227
  # @api private
204
228
  def run_until_idle
229
+ @rui_mutex.synchronize { @run_until_idle_thread = Thread.current }
205
230
  if @autorun
206
231
  loop do
207
232
  fire_real_timers
@@ -230,6 +255,8 @@ module Phronomy
230
255
  tick until idle?
231
256
  end
232
257
  self
258
+ ensure
259
+ @rui_mutex.synchronize { @run_until_idle_thread = nil }
233
260
  end
234
261
 
235
262
  # Advances the virtual clock by +seconds+ and enqueues any timer
@@ -30,7 +30,7 @@ module Phronomy
30
30
  # @example
31
31
  # runtime = Phronomy::Runtime.new(scheduler: Phronomy::Runtime::FakeScheduler.new)
32
32
  # task = runtime.spawn(name: "agent-test") { 42 }
33
- # expect(task.await).to eq(42)
33
+ # expect(task.wait_result).to eq(42)
34
34
  # expect(task.status).to eq(:completed)
35
35
  # @api private
36
36
  class FakeScheduler < Scheduler
@@ -32,7 +32,7 @@ module Phronomy
32
32
  # @example Test usage — no extra threads
33
33
  # runtime = Phronomy::Runtime.new(scheduler: Phronomy::Runtime::FakeScheduler.new)
34
34
  # task = runtime.spawn { 42 }
35
- # expect(task.await).to eq(42)
35
+ # expect(task.wait_result).to eq(42)
36
36
  class Runtime
37
37
  # Returns the process-wide default Runtime.
38
38
  #
@@ -366,9 +366,7 @@ module Phronomy
366
366
  @task_registry.drain
367
367
  # Drain EventLoop events before stopping pools so that in-flight
368
368
  # Workflow / Agent FSM sessions can complete their final LLM calls.
369
- if Phronomy.configuration.event_loop
370
- Phronomy::EventLoop.instance.stop(drain: true)
371
- end
369
+ Phronomy::EventLoop.instance.stop(drain: true)
372
370
  @pool_registry.shutdown
373
371
  @timer_service.shutdown
374
372
  end
@@ -24,8 +24,8 @@ module Phronomy
24
24
  # @return [Object]
25
25
  # @raise [Exception]
26
26
  # @api private
27
- def await
28
- raise NotImplementedError, "#{self.class}#await not implemented"
27
+ def wait_result
28
+ raise NotImplementedError, "#{self.class}#wait_result not implemented"
29
29
  end
30
30
 
31
31
  # Returns +true+ while execution is still ongoing.