phronomy 0.13.0 → 0.15.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +155 -0
  3. data/README.md +266 -38
  4. data/benchmark/bench_agent_invoke.rb +2 -3
  5. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +14 -67
  6. data/docs/decisions/011-delegate-transport-policy-to-adapters.md +82 -0
  7. data/docs/mcp-client.md +75 -0
  8. data/examples/workflows/agent_event_mapping.rb +104 -0
  9. data/examples/workflows/generic_task_event_mapping.rb +58 -0
  10. data/gemfiles/mcp_1_0.gemfile +9 -0
  11. data/lib/phronomy/agent/agent_invocation.rb +385 -0
  12. data/lib/phronomy/agent/agent_invocation_registry.rb +75 -0
  13. data/lib/phronomy/agent/agent_invocation_session_builder.rb +448 -0
  14. data/lib/phronomy/agent/approval_evaluation_request.rb +102 -0
  15. data/lib/phronomy/agent/async_event_api.rb +471 -0
  16. data/lib/phronomy/agent/base.rb +509 -420
  17. data/lib/phronomy/agent/context/capability/base.rb +57 -119
  18. data/lib/phronomy/agent/llm_operation_result.rb +23 -0
  19. data/lib/phronomy/agent/phase_machine_builder.rb +75 -136
  20. data/lib/phronomy/agent/tool_approval_request.rb +121 -0
  21. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -15
  22. data/lib/phronomy/agent/tool_executor.rb +47 -69
  23. data/lib/phronomy/agent/tool_invocation.rb +634 -0
  24. data/lib/phronomy/agent/tool_invocation_session_builder.rb +378 -0
  25. data/lib/phronomy/agent.rb +21 -9
  26. data/lib/phronomy/configuration.rb +58 -53
  27. data/lib/phronomy/diagnostics.rb +1 -1
  28. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +230 -118
  29. data/lib/phronomy/engine/concurrency/cancellation_token.rb +5 -1
  30. data/lib/phronomy/engine/concurrency/pool_registry.rb +8 -3
  31. data/lib/phronomy/engine/event_loop.rb +507 -303
  32. data/lib/phronomy/engine/fsm_session.rb +181 -140
  33. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +1 -1
  34. data/lib/phronomy/engine/runtime/shutdown_result.rb +62 -0
  35. data/lib/phronomy/engine/runtime/task_registry.rb +62 -15
  36. data/lib/phronomy/engine/runtime.rb +247 -57
  37. data/lib/phronomy/engine/task.rb +5 -10
  38. data/lib/phronomy/event.rb +8 -8
  39. data/lib/phronomy/generator_verifier.rb +253 -142
  40. data/lib/phronomy/invalid_async_entry_action_error.rb +9 -0
  41. data/lib/phronomy/invalid_async_transition_action_error.rb +11 -0
  42. data/lib/phronomy/invalid_async_workflow_action_error.rb +9 -0
  43. data/lib/phronomy/invocation_context.rb +5 -19
  44. data/lib/phronomy/llm_adapter/base.rb +25 -34
  45. data/lib/phronomy/metrics.rb +6 -3
  46. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
  47. data/lib/phronomy/stream_callback_error.rb +35 -0
  48. data/lib/phronomy/testing/scheduler_helpers.rb +12 -3
  49. data/lib/phronomy/tools/mcp.rb +410 -81
  50. data/lib/phronomy/version.rb +1 -1
  51. data/lib/phronomy/workflow/phase_machine_builder.rb +129 -182
  52. data/lib/phronomy/workflow.rb +122 -261
  53. data/lib/phronomy/workflow_context.rb +55 -104
  54. data/lib/phronomy/workflow_runner.rb +239 -291
  55. data/lib/phronomy.rb +30 -23
  56. data/scripts/check_readme_runnable.rb +4 -1
  57. metadata +63 -11
  58. data/lib/phronomy/agent/concerns/retryable.rb +0 -103
  59. data/lib/phronomy/agent/context/capability/scope_policy.rb +0 -54
  60. data/lib/phronomy/agent/invocation_context.rb +0 -171
  61. data/lib/phronomy/agent/invocation_session.rb +0 -346
  62. data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
  63. data/lib/phronomy/engine/concurrency/concurrency_gate.rb +0 -157
  64. data/lib/phronomy/engine/concurrency/gate_registry.rb +0 -51
@@ -1,208 +1,275 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
- require "state_machines"
5
4
 
6
5
  module Phronomy
7
- # Execution engine for compiled workflows.
8
- # Manages state entry/exit action execution, phase transitions, halt/resume, and wait states.
9
- # Instantiated by Phronomy::Workflow and used internally.
6
+ # Execution boundary for compiled Workflows.
10
7
  #
11
- # == Design principle
8
+ # WorkflowRunner prepares WorkflowContext instances, registers FSMSession
9
+ # objects with the Runtime-owned EventLoop, observes completion, and persists
10
+ # serializable Workflow snapshots. All Workflow execution APIs share this
11
+ # path; their only differences are blocking and observation semantics.
12
12
  #
13
- # State transitions are driven entirely by state_machines. The PhaseTracker
14
- # holds a reference to the current WorkflowContext via +attr_accessor :context+,
15
- # and guard lambdas evaluate +m.context+ (the WorkflowContext) rather than
16
- # the PhaseTracker itself. This ensures that "what happens next" is always
17
- # determined by the declared state machine topology, never by Phronomy internals.
18
- #
19
- # Entry and exit actions are registered as state_machines +after_transition to:+
20
- # and +before_transition from:+ callbacks respectively. Entry actions may either
21
- # mutate the context in place or return a new context (e.g. via +s.merge(...)+).
22
- # When an entry action returns a Phronomy::WorkflowContext, that value replaces
23
- # the current context; otherwise the return value is ignored.
24
- # Exit actions are always mutation-in-place; their return value is ignored.
25
- #
26
- # The sole exception is the initial state: state_machines does not fire transition
27
- # callbacks on initialization, so the entry action for the entry point is invoked
28
- # directly by WorkflowRunner before the main execution loop begins.
29
- #
30
- # == Two transition categories registered in PhaseTracker
31
- #
32
- # 1. state_completed — all auto-fire transitions (with or without guards).
33
- # Fired when an action state's action completes.
34
- # Guards are evaluated in declaration order; first match wins.
35
- # (declared with +transition from: :foo, to: :bar+ or
36
- # +transition from: :foo, guard: ..., to: :bar+)
37
- #
38
- # 2. <event_name> — external events triggered by human input, originating
39
- # from wait states
40
- # (declared with +transition from: :awaiting, on: :approve, to: :run+)
41
13
  # @api private
42
14
  class WorkflowRunner
43
15
  include Phronomy::Runnable
44
16
 
45
- # Sentinel value for the terminal state of a workflow.
46
17
  FINISH = :__end__
47
18
 
48
- def initialize(state_class:, entry_actions:, declared_states:, auto_transitions:, external_events:, entry_point:, exit_actions: {}, wait_state_names: [], state_store: nil, action_timeouts: {})
19
+ Execution = Data.define(
20
+ :context,
21
+ :thread_id,
22
+ :recursion_limit,
23
+ :store,
24
+ :persist
25
+ )
26
+
27
+ def initialize(
28
+ state_class:,
29
+ entry_actions:,
30
+ declared_states:,
31
+ auto_transitions:,
32
+ external_events:,
33
+ entry_point:,
34
+ exit_actions: {},
35
+ wait_state_names: [],
36
+ state_store: nil
37
+ )
49
38
  @state_class = state_class
50
- @entry_actions = entry_actions # { state_name => [callable, ...] }
39
+ @entry_actions = entry_actions
51
40
  @declared_states = declared_states
52
- # Lookup set: states with at least one auto-fire transition declared.
53
- @auto_state_set = auto_transitions.each_with_object({}) { |t, h| h[t[:from]] = true }
54
- @external_events = external_events # { name => [{from:, to:, guard:}, ...] }
41
+ @auto_state_set = auto_transitions.each_with_object({}) do |transition, set|
42
+ set[transition[:from]] = true
43
+ end
44
+ @external_events = external_events
55
45
  @entry_point = entry_point
56
46
  @wait_state_names = wait_state_names
57
47
  @state_store = state_store
58
- @action_timeouts = action_timeouts # { state_name => seconds }
59
48
  @phase_machine_class = Workflow::PhaseMachineBuilder.new(
60
49
  entry_point: @entry_point,
61
50
  declared_states: @declared_states,
62
51
  wait_state_names: @wait_state_names,
63
52
  external_events: @external_events,
64
53
  entry_actions: @entry_actions,
65
- action_timeouts: @action_timeouts,
66
54
  auto_transitions: auto_transitions,
67
55
  exit_actions: exit_actions
68
56
  ).build
69
57
  end
70
58
 
71
- # Executes the workflow from the initial state.
72
- # @param input [Hash] initial context field values
73
- # @param config [Hash] { thread_id:, recursion_limit:, user_id:, session_id:, state_store: }
74
- # @return [Object] final context (includes Phronomy::WorkflowContext)
75
- # @api private
76
59
  def invoke(input, config: {})
60
+ ensure_blocking_call_allowed!(:invoke, :invoke_async)
77
61
  caller_meta = {}
78
62
  caller_meta[:user_id] = config[:user_id] if config[:user_id]
79
63
  caller_meta[:session_id] = config[:session_id] if config[:session_id]
80
64
 
81
65
  trace("workflow.invoke", input: input.inspect, **caller_meta) do |_span|
82
- state, thread_id, recursion_limit, store = _build_initial_context(input, config)
83
- result = run_via_event_loop(state, recursion_limit: recursion_limit)
84
- store&.save(thread_id, {fields: result.to_h, phase: result.phase.to_s}) if config[:thread_id]
66
+ execution = prepare_new_execution(input, config)
67
+ result = start_execution(execution).wait_result
85
68
  [result, nil]
86
69
  end
87
70
  end
88
71
 
89
- # Registers the workflow with the EventLoop and returns a {Phronomy::Task}
90
- # immediately without blocking the caller. The task resolves with the final
91
- # context when the workflow finishes.
92
- #
93
- # This is the EventLoop-driven equivalent of spawning a thread around
94
- # {#invoke}. No extra OS thread is created; the EventLoop's existing thread
95
- # drives the execution.
96
- #
97
- # @param input [Hash] initial context field values
98
- # @param config [Hash]
99
- # @return [Phronomy::Task]
100
- # @api private
101
72
  def invoke_deferred(input, config: {})
102
- state, thread_id, recursion_limit, store = _build_initial_context(input, config)
103
- result_task = Phronomy::Task.deferred(name: "workflow-async:#{thread_id}")
104
- Phronomy::EventLoop.instance.start
105
- session = build_session_for(context: state, recursion_limit: recursion_limit)
106
- if store && config[:thread_id]
107
- # Wrap so that state is persisted when the task resolves.
108
- persist_task = Phronomy::Task.deferred(name: "workflow-async-persist:#{thread_id}")
109
- Phronomy::EventLoop.instance.register(session, completion: persist_task)
110
- persist_task.on_complete do |result, error|
111
- store.save(thread_id, {fields: result.to_h, phase: result.phase.to_s}) unless error
112
- if error
113
- result_task.backend.unblock(nil, error)
114
- result_task.transition!(:failed, error: error)
115
- else
116
- result_task.backend.unblock(result, nil)
117
- result_task.transition!(:completed, value: result)
118
- end
119
- end
120
- else
121
- Phronomy::EventLoop.instance.register(session, completion: result_task)
122
- end
123
- result_task
73
+ execution = prepare_new_execution(input, config)
74
+ start_execution(execution)
75
+ rescue => error
76
+ failed_task("workflow-async:preparation", error)
77
+ end
78
+
79
+ def stream(input, config: {}, &observer)
80
+ ensure_blocking_call_allowed!(:stream, :invoke_async)
81
+ raise ArgumentError, "stream requires a block" unless observer
82
+
83
+ execution = prepare_new_execution(input, config)
84
+ start_execution(
85
+ execution,
86
+ stable_observer: observer
87
+ ).wait_result
124
88
  end
125
89
 
126
- # Generic resume. Equivalent to +send_event(state:, event: :resume, input:)+.
127
- # @param state [Object] halted context
128
- # @param input [Hash, nil] optional field updates to merge before resuming
129
- # @return [Object] final context
130
- # @api private
131
90
  def resume(state:, input: nil)
132
91
  send_event(state: state, event: :resume, input: input)
133
92
  end
134
93
 
135
- # Fires a named event to advance a halted workflow.
136
- #
137
- # The special event +:resume+ selects the first external event registered
138
- # for the current wait state and fires it.
139
- #
140
- # @param state [Object] halted context
141
- # @param event [Symbol] named event or +:resume+ for generic resumption
142
- # @param input [Hash, nil] optional field updates to merge before resuming
143
- # @return [Object] final context
144
- # @api private
145
94
  def send_event(state:, event:, input: nil)
146
- state = state.merge(input) if input
147
- event = event.to_sym
148
- current_phase = state.phase
149
-
150
- ev_to_fire = if event == :resume
151
- # Find the first external event that can originate from the current wait state.
152
- name, = @external_events.find { |_, ts| ts.any? { |t| t[:from] == current_phase } }
153
- unless name
154
- raise ArgumentError,
155
- "No external event registered for wait state #{current_phase.inspect}"
156
- end
157
- name
158
- else
159
- unless @external_events.key?(event)
160
- raise ArgumentError,
161
- "Unknown event #{event.inspect}. Valid events: #{@external_events.keys.inspect}"
162
- end
163
- event
95
+ ensure_blocking_call_allowed!(:send_event, :signal)
96
+ context = input ? state.merge(input) : state
97
+ current_phase = context.phase.to_sym
98
+ event_name = resolve_resume_event(current_phase, event)
99
+ thread_id = context.thread_id
100
+ unless thread_id
101
+ raise ArgumentError, "Halted WorkflowContext has no thread_id"
164
102
  end
165
103
 
166
- run_via_event_loop(state,
104
+ execution = Execution.new(
105
+ context: context,
106
+ thread_id: thread_id.to_s,
167
107
  recursion_limit: Phronomy.configuration.recursion_limit,
168
- resume_event: ev_to_fire, resume_phase: current_phase)
108
+ store: configured_store,
109
+ persist: true
110
+ )
111
+ start_execution(
112
+ execution,
113
+ resume_event: event_name,
114
+ resume_phase: current_phase
115
+ ).wait_result
169
116
  end
170
117
 
171
- # Streaming execution. Yields { state: Symbol, context: Object } after each state action completes.
172
- # @param input [Hash]
173
- # @param config [Hash]
174
- # @yield [Hash]
175
- # @return [Object] final context
176
- # @api private
177
- def stream(input, config: {}, &block)
178
- thread_id = config[:thread_id] || SecureRandom.uuid
179
- recursion_limit = config.fetch(:recursion_limit, Phronomy.configuration.recursion_limit)
180
- state = @state_class.new(**input)
181
- state.set_graph_metadata(thread_id: thread_id)
182
- run_workflow(state, recursion_limit: recursion_limit, &block)
118
+ # Posts an application-defined event to a currently live Workflow session.
119
+ #
120
+ # Admission is asynchronous. A true result means the EventLoop accepted the
121
+ # event for an admitted session; it does not mean that a transition matched.
122
+ # A false result means that the Runtime is stopping or the session is no
123
+ # longer admitted.
124
+ def signal(thread_id:, event:, payload: nil)
125
+ if thread_id.nil?
126
+ raise ArgumentError, "thread_id is required"
127
+ end
128
+
129
+ event_name = event.to_sym
130
+ unless @external_events.key?(event_name)
131
+ raise ArgumentError,
132
+ "Unknown event #{event_name.inspect}. " \
133
+ "Valid events: #{@external_events.keys.inspect}"
134
+ end
135
+
136
+ Phronomy::Runtime.instance.event_loop.post_to_session(
137
+ Phronomy::Event.new(
138
+ type: event_name,
139
+ target_id: thread_id.to_s,
140
+ payload: payload
141
+ )
142
+ )
183
143
  end
184
144
 
185
145
  private
186
146
 
187
- # Builds the initial WorkflowContext from input and config.
188
- # Returns [state, thread_id, recursion_limit, store].
189
- def _build_initial_context(input, config)
190
- thread_id = config[:thread_id] || SecureRandom.uuid
191
- recursion_limit = config.fetch(:recursion_limit, Phronomy.configuration.recursion_limit)
192
- store = config.fetch(:state_store, @state_store) || Phronomy.configuration.state_store
193
- snapshot = (store && config[:thread_id]) ? store.load(thread_id) : nil
194
- initial_fields = if snapshot && snapshot[:fields]
195
- snapshot[:fields].transform_keys(&:to_sym).merge(input.transform_keys(&:to_sym))
147
+ def ensure_blocking_call_allowed!(method_name, async_alternative)
148
+ return unless Phronomy::Runtime.instance.event_loop.current?
149
+
150
+ raise Phronomy::Error,
151
+ "Cannot call Workflow##{method_name} from the EventLoop thread. " \
152
+ "Use #{async_alternative} instead."
153
+ end
154
+
155
+ def prepare_new_execution(input, config)
156
+ thread_id = (config[:thread_id] || SecureRandom.uuid).to_s
157
+ recursion_limit = config.fetch(
158
+ :recursion_limit,
159
+ Phronomy.configuration.recursion_limit
160
+ )
161
+ store = configured_store(config)
162
+ snapshot = store&.load(thread_id) if config[:thread_id]
163
+
164
+ stored_fields = snapshot && snapshot[:fields]
165
+ initial_fields = if stored_fields
166
+ stored_fields
167
+ .transform_keys(&:to_sym)
168
+ .merge(input.transform_keys(&:to_sym))
196
169
  else
197
170
  input
198
171
  end
199
- state = @state_class.new(**initial_fields)
200
- state.set_graph_metadata(thread_id: thread_id)
201
- [state, thread_id, recursion_limit, store]
172
+
173
+ context = @state_class.new(**initial_fields)
174
+ context.set_graph_metadata(thread_id: thread_id)
175
+
176
+ Execution.new(
177
+ context: context,
178
+ thread_id: thread_id,
179
+ recursion_limit: recursion_limit,
180
+ store: store,
181
+ persist: !config[:thread_id].nil?
182
+ )
202
183
  end
203
184
 
204
- # Builds an FSMSession for the given context. Used in EventLoop mode.
205
- def build_session_for(context:, recursion_limit:, resume_event: nil, resume_phase: nil)
185
+ def configured_store(config = {})
186
+ config.fetch(:state_store, @state_store) ||
187
+ Phronomy.configuration.state_store
188
+ end
189
+
190
+ def start_execution(
191
+ execution,
192
+ resume_event: nil,
193
+ resume_phase: nil,
194
+ stable_observer: nil
195
+ )
196
+ runtime = Phronomy::Runtime.instance
197
+ result_task = Phronomy::Task.deferred(
198
+ name: "workflow:#{execution.thread_id}"
199
+ )
200
+ source_task = Phronomy::Task.deferred(
201
+ name: "workflow-source:#{execution.thread_id}"
202
+ )
203
+
204
+ source_task.on_complete do |result, error|
205
+ finalize_execution(
206
+ result_task: result_task,
207
+ result: result,
208
+ error: error,
209
+ store: execution.store,
210
+ thread_id: execution.thread_id,
211
+ persist: execution.persist
212
+ )
213
+ end
214
+
215
+ session = build_session_for(
216
+ context: execution.context,
217
+ recursion_limit: execution.recursion_limit,
218
+ runtime: runtime,
219
+ resume_event: resume_event,
220
+ resume_phase: resume_phase,
221
+ stable_observer: stable_observer
222
+ )
223
+ runtime.event_loop.register(session, completion: source_task)
224
+ result_task
225
+ rescue => error
226
+ fail_task(result_task, error) if result_task
227
+ result_task || failed_task("workflow:registration", error)
228
+ end
229
+
230
+ def finalize_execution(
231
+ result_task:,
232
+ result:,
233
+ error:,
234
+ store:,
235
+ thread_id:,
236
+ persist:
237
+ )
238
+ if error
239
+ fail_task(result_task, error)
240
+ return
241
+ end
242
+
243
+ begin
244
+ persist_snapshot(store, thread_id, result, persist: persist)
245
+ rescue => persistence_error
246
+ fail_task(result_task, persistence_error)
247
+ return
248
+ end
249
+
250
+ complete_task(result_task, result)
251
+ end
252
+
253
+ def persist_snapshot(store, thread_id, context, persist:)
254
+ return unless store && persist
255
+
256
+ store.save(
257
+ thread_id,
258
+ {
259
+ fields: context.to_h,
260
+ phase: context.phase.to_s
261
+ }
262
+ )
263
+ end
264
+
265
+ def build_session_for(
266
+ context:,
267
+ recursion_limit:,
268
+ runtime:,
269
+ resume_event: nil,
270
+ resume_phase: nil,
271
+ stable_observer: nil
272
+ )
206
273
  Phronomy::FSMSession.new(
207
274
  id: context.thread_id,
208
275
  context: context,
@@ -214,168 +281,49 @@ module Phronomy
214
281
  external_events: @external_events,
215
282
  phase_machine_class: @phase_machine_class,
216
283
  recursion_limit: recursion_limit,
217
- action_timeouts: @action_timeouts,
284
+ event_loop: runtime.event_loop,
218
285
  resume_event: resume_event,
219
- resume_phase: resume_phase
220
- )
221
- end
222
-
223
- # Executes the workflow via the singleton EventLoop.
224
- # Blocks the calling thread on a completion queue until the workflow
225
- # finishes, halts at a wait state, or raises an error.
226
- def run_via_event_loop(context, recursion_limit:, resume_event: nil, resume_phase: nil)
227
- # Ensure EventLoop is running. In tests, reset_runtime! resets the
228
- # singleton without restarting it; start is idempotent when already alive.
229
- Phronomy::EventLoop.instance.start
230
- session = build_session_for(
231
- context: context, recursion_limit: recursion_limit,
232
- resume_event: resume_event, resume_phase: resume_phase
286
+ resume_phase: resume_phase,
287
+ stable_observer: stable_observer
233
288
  )
234
- completion_queue = Phronomy::EventLoop.instance.register(session)
235
- result = completion_queue.pop
236
- raise result if result.is_a?(Exception)
237
- result
238
289
  end
239
290
 
240
- def run_workflow(ctx, resume_event: nil, resume_phase: nil, recursion_limit: 25, &event_block)
241
- # Mark the current thread as a synchronous execution context.
242
- # This allows WorkflowContext field mutations via setters without raising
243
- # WorkflowContextOwnershipError (which would otherwise fire since EventLoop
244
- # is always active and run_workflow runs on the caller's thread).
245
- Thread.current[:phronomy_sync_execution] = Thread.current[:phronomy_sync_execution].to_i + 1
246
- _run_workflow_body(ctx, resume_event: resume_event, resume_phase: resume_phase,
247
- recursion_limit: recursion_limit, &event_block)
248
- ensure
249
- depth = Thread.current[:phronomy_sync_execution].to_i - 1
250
- Thread.current[:phronomy_sync_execution] = (depth > 0) ? depth : nil
251
- end
252
-
253
- def _run_workflow_body(ctx, resume_event: nil, resume_phase: nil, recursion_limit: 25, &event_block)
254
- if resume_event
255
- # -- Resume from a wait state -------------------------------------------
256
- # Fire the external event on a tracker positioned at the wait state.
257
- # state_machines will invoke before_transition (exit) and after_transition
258
- # (entry) callbacks as part of the transition, so both actions fire here.
259
- current_state = resume_phase
260
- tracker = new_phase_machine(current_state)
261
- tracker.context = ctx
262
- fire_event!(tracker, resume_event, current_state)
263
- ctx = tracker.context
264
- next_phase = tracker.phase.to_sym
265
- current_state = (next_phase == current_state) ? FINISH : next_phase
266
- else
267
- # -- Fresh start --------------------------------------------------------
268
- current_state = @entry_point
269
- tracker = new_phase_machine(current_state)
270
- tracker.context = ctx
271
- # state_machines only fires after_transition callbacks on transitions.
272
- # The entry point has no prior transition, so we invoke its entry actions directly.
273
- @entry_actions[current_state]&.each do |c|
274
- result = c.call(ctx)
275
- if result.is_a?(Phronomy::Task)
276
- timeout_secs = @action_timeouts[current_state]
277
- if timeout_secs
278
- if result.join(timeout_secs).nil?
279
- result.cancel!
280
- raise Phronomy::ActionTimeoutError,
281
- "Action in state #{current_state.inspect} timed out after #{timeout_secs}s"
282
- end
283
- end
284
- task_result = result.wait_result
285
- ctx = task_result if task_result.is_a?(Phronomy::WorkflowContext)
286
- elsif result.is_a?(Phronomy::WorkflowContext)
287
- ctx = result
288
- end
291
+ def resolve_resume_event(current_phase, event)
292
+ event_name = event.to_sym
293
+ unless event_name == :resume
294
+ unless @external_events.key?(event_name)
295
+ raise ArgumentError,
296
+ "Unknown event #{event_name.inspect}. " \
297
+ "Valid events: #{@external_events.keys.inspect}"
289
298
  end
290
- tracker.context = ctx
299
+ return event_name
291
300
  end
292
301
 
293
- # Event queue: decouple action execution from transition firing.
294
- # Events are enqueued after visiting a state and processed at the top
295
- # of the next iteration so that guards always see the freshest context.
296
- event_queue = []
297
- step = 0
298
-
299
- loop do
300
- break if current_state == FINISH
301
-
302
- # -- Process next pending event -----------------------------------------
303
- # Dequeue one event and fire it against the state machine. Guards are
304
- # evaluated here (at fire time). Entry/exit callbacks fire inside fire_event!.
305
- if (event = event_queue.shift)
306
- if step >= recursion_limit
307
- raise Phronomy::RecursionLimitError,
308
- "Recursion limit (#{recursion_limit}) exceeded"
309
- end
310
-
311
- fire_event!(tracker, event, current_state)
312
- ctx = tracker.context
313
- next_phase = tracker.phase.to_sym
314
- # When next_phase == current_state no transition matched → terminal state.
315
- current_state = (next_phase == current_state) ? FINISH : next_phase
316
- step += 1
317
- next
318
- end
319
-
320
- # -- Queue empty: check for halt -----------------------------------------
321
- # Auto-halt at wait states: persist phase in context and return to caller.
322
- # The caller resumes via send_event.
323
- if @wait_state_names.include?(current_state)
324
- ctx.set_graph_metadata(thread_id: ctx.thread_id, phase: current_state)
325
- return ctx
326
- end
327
-
328
- # -- Validate state is known --------------------------------------------
329
- unless @declared_states.include?(current_state)
330
- raise ArgumentError, "State #{current_state.inspect} is not defined"
331
- end
332
-
333
- # -- Emit stream event and enqueue transition ---------------------------
334
- # Entry action for current_state has already been invoked (either by the
335
- # initial manual call above, or by the after_transition callback fired
336
- # inside fire_event! on the previous iteration).
337
- event_block&.call({state: current_state, context: ctx})
338
-
339
- # state_completed: unified event for all auto-fire transitions.
340
- # No enqueue: terminal state — next iteration exits via FINISH check.
341
- if @auto_state_set.key?(current_state)
342
- event_queue << :state_completed
343
- else
344
- current_state = FINISH
302
+ name, = @external_events.find do |_candidate, transitions|
303
+ Array(transitions).any? do |transition|
304
+ transition[:from] == current_phase
345
305
  end
346
306
  end
307
+ return name if name
347
308
 
348
- ctx.set_graph_metadata(thread_id: ctx.thread_id, phase: :__end__)
349
- ctx
309
+ raise ArgumentError,
310
+ "No external event registered for state #{current_phase.inspect}"
350
311
  end
351
312
 
352
- # Fires +event_name+ on +tracker+, raising a descriptive error if no
353
- # transition matches. state_machines event methods return false when no
354
- # transition can be taken (invalid state or all guards fail).
355
- def fire_event!(tracker, event_name, from_state)
356
- return if tracker.send(event_name)
313
+ def complete_task(task, value)
314
+ task.backend.unblock(value, nil)
315
+ task.transition!(:completed, value: value)
316
+ end
357
317
 
358
- raise ArgumentError,
359
- "Transition from #{from_state.inspect} via event #{event_name.inspect} failed. " \
360
- "Ensure at least one guard matches or add a fallback (no-guard) transition."
318
+ def fail_task(task, error)
319
+ task.backend.unblock(nil, error)
320
+ task.transition!(:failed, error: error)
361
321
  end
362
322
 
363
- # Builds the PhaseTracker class backed by state_machines.
364
- #
365
- # Four event/callback types are registered:
366
- # state_completed — all auto-fire transitions (guarded and unguarded)
367
- # <external_name> — external events originating from wait states
368
- # after_transition to — entry callbacks (invoked when entering a state)
369
- # before_transition from — exit callbacks (invoked when leaving a state)
370
- #
371
- # Guard lambdas bridge the PhaseTracker and WorkflowContext via +m.context+.
372
- # Creates a PhaseTracker instance initialized to +from_state+.
373
- def new_phase_machine(from_state)
374
- machine = @phase_machine_class.new
375
- # Override the initial state set by state_machine's initializer so we can
376
- # resume from an arbitrary state (e.g. after a wait state).
377
- machine.instance_variable_set(:@phase, from_state.to_s)
378
- machine
323
+ def failed_task(name, error)
324
+ task = Phronomy::Task.deferred(name: name)
325
+ fail_task(task, error)
326
+ task
379
327
  end
380
328
  end
381
329
  end