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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +155 -0
- data/README.md +266 -38
- data/benchmark/bench_agent_invoke.rb +2 -3
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +14 -67
- data/docs/decisions/011-delegate-transport-policy-to-adapters.md +82 -0
- data/docs/mcp-client.md +75 -0
- data/examples/workflows/agent_event_mapping.rb +104 -0
- data/examples/workflows/generic_task_event_mapping.rb +58 -0
- data/gemfiles/mcp_1_0.gemfile +9 -0
- data/lib/phronomy/agent/agent_invocation.rb +385 -0
- data/lib/phronomy/agent/agent_invocation_registry.rb +75 -0
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +448 -0
- data/lib/phronomy/agent/approval_evaluation_request.rb +102 -0
- data/lib/phronomy/agent/async_event_api.rb +471 -0
- data/lib/phronomy/agent/base.rb +509 -420
- data/lib/phronomy/agent/context/capability/base.rb +57 -119
- data/lib/phronomy/agent/llm_operation_result.rb +23 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +75 -136
- data/lib/phronomy/agent/tool_approval_request.rb +121 -0
- data/lib/phronomy/agent/tool_call_intercepted.rb +11 -15
- data/lib/phronomy/agent/tool_executor.rb +47 -69
- data/lib/phronomy/agent/tool_invocation.rb +634 -0
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +378 -0
- data/lib/phronomy/agent.rb +21 -9
- data/lib/phronomy/configuration.rb +58 -53
- data/lib/phronomy/diagnostics.rb +1 -1
- data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +230 -118
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +5 -1
- data/lib/phronomy/engine/concurrency/pool_registry.rb +8 -3
- data/lib/phronomy/engine/event_loop.rb +507 -303
- data/lib/phronomy/engine/fsm_session.rb +181 -140
- data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +1 -1
- data/lib/phronomy/engine/runtime/shutdown_result.rb +62 -0
- data/lib/phronomy/engine/runtime/task_registry.rb +62 -15
- data/lib/phronomy/engine/runtime.rb +247 -57
- data/lib/phronomy/engine/task.rb +5 -10
- data/lib/phronomy/event.rb +8 -8
- data/lib/phronomy/generator_verifier.rb +253 -142
- data/lib/phronomy/invalid_async_entry_action_error.rb +9 -0
- data/lib/phronomy/invalid_async_transition_action_error.rb +11 -0
- data/lib/phronomy/invalid_async_workflow_action_error.rb +9 -0
- data/lib/phronomy/invocation_context.rb +5 -19
- data/lib/phronomy/llm_adapter/base.rb +25 -34
- data/lib/phronomy/metrics.rb +6 -3
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
- data/lib/phronomy/stream_callback_error.rb +35 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +12 -3
- data/lib/phronomy/tools/mcp.rb +410 -81
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/phase_machine_builder.rb +129 -182
- data/lib/phronomy/workflow.rb +122 -261
- data/lib/phronomy/workflow_context.rb +55 -104
- data/lib/phronomy/workflow_runner.rb +239 -291
- data/lib/phronomy.rb +30 -23
- data/scripts/check_readme_runnable.rb +4 -1
- metadata +63 -11
- data/lib/phronomy/agent/concerns/retryable.rb +0 -103
- data/lib/phronomy/agent/context/capability/scope_policy.rb +0 -54
- data/lib/phronomy/agent/invocation_context.rb +0 -171
- data/lib/phronomy/agent/invocation_session.rb +0 -346
- data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
- data/lib/phronomy/engine/concurrency/concurrency_gate.rb +0 -157
- 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
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
39
|
+
@entry_actions = entry_actions
|
|
51
40
|
@declared_states = declared_states
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
83
|
-
result =
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
current_phase =
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
104
|
+
execution = Execution.new(
|
|
105
|
+
context: context,
|
|
106
|
+
thread_id: thread_id.to_s,
|
|
167
107
|
recursion_limit: Phronomy.configuration.recursion_limit,
|
|
168
|
-
|
|
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
|
-
#
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
def
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
299
|
+
return event_name
|
|
291
300
|
end
|
|
292
301
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
349
|
-
|
|
309
|
+
raise ArgumentError,
|
|
310
|
+
"No external event registered for state #{current_phase.inspect}"
|
|
350
311
|
end
|
|
351
312
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
318
|
+
def fail_task(task, error)
|
|
319
|
+
task.backend.unblock(nil, error)
|
|
320
|
+
task.transition!(:failed, error: error)
|
|
361
321
|
end
|
|
362
322
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|