phronomy 0.9.0 → 0.10.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 +96 -4
- data/README.md +12 -12
- data/lib/phronomy/agent/base.rb +106 -96
- data/lib/phronomy/agent/checkpoint.rb +30 -1
- data/lib/phronomy/agent/checkpoint_store.rb +97 -0
- data/lib/phronomy/agent/concerns/retryable.rb +1 -1
- data/lib/phronomy/agent/concerns/suspendable.rb +57 -2
- data/lib/phronomy/configuration.rb +13 -0
- data/lib/phronomy/event_loop.rb +1 -18
- data/lib/phronomy/task/mapped_backend.rb +78 -0
- data/lib/phronomy/task.rb +53 -0
- data/lib/phronomy/tools/agent.rb +2 -3
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/fsm_session.rb +249 -0
- data/lib/phronomy/workflow/phase_machine_builder.rb +247 -0
- data/lib/phronomy/workflow_runner.rb +2 -2
- data/lib/phronomy.rb +10 -3
- data/scripts/api_snapshot.rb +0 -1
- metadata +6 -7
- data/lib/phronomy/agent/fsm.rb +0 -157
- data/lib/phronomy/agent/invocation_pipeline.rb +0 -108
- data/lib/phronomy/agent/lifecycle/fsm_session.rb +0 -251
- data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +0 -249
- data/lib/phronomy/agent/react_agent.rb +0 -205
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
3
5
|
module Phronomy
|
|
4
6
|
module Agent
|
|
5
7
|
module Concerns
|
|
@@ -47,6 +49,23 @@ module Phronomy
|
|
|
47
49
|
@scope_policy = policy
|
|
48
50
|
end
|
|
49
51
|
|
|
52
|
+
# Sets the idempotency store used to guard against duplicate resumes.
|
|
53
|
+
#
|
|
54
|
+
# The store must respond to:
|
|
55
|
+
# - +consumed?(checkpoint_id)+ ⇒ Boolean
|
|
56
|
+
# - +consume!(checkpoint_id)+ ⇒ void; raises {Phronomy::CheckpointAlreadyResumedError} on duplicate
|
|
57
|
+
#
|
|
58
|
+
# Defaults to a per-instance {Phronomy::Agent::CheckpointStore} (in-memory, not thread-safe).
|
|
59
|
+
# Assign a shared persistent store when resuming across processes (e.g. Redis-backed).
|
|
60
|
+
# Custom stores are responsible for ensuring thread-safety if shared across threads.
|
|
61
|
+
#
|
|
62
|
+
# @param store [#consumed?, #consume!]
|
|
63
|
+
# @return [void]
|
|
64
|
+
# @api public
|
|
65
|
+
def checkpoint_store=(store)
|
|
66
|
+
@checkpoint_store = store
|
|
67
|
+
end
|
|
68
|
+
|
|
50
69
|
# Resumes a previously suspended invocation from a {Phronomy::Agent::Checkpoint}.
|
|
51
70
|
#
|
|
52
71
|
# This method reconstructs the conversation state captured at suspension
|
|
@@ -59,9 +78,14 @@ module Phronomy
|
|
|
59
78
|
# to inject a denial message and let the LLM handle it gracefully
|
|
60
79
|
# @param config [Hash] same runtime options as #invoke
|
|
61
80
|
# @return [Hash] +{ output: String, suspended: false, messages: Array, usage: Phronomy::TokenUsage }+
|
|
81
|
+
# or +{ output: nil, suspended: true, checkpoint: Phronomy::Agent::Checkpoint, messages: Array }+
|
|
82
|
+
# when a second approval-required tool is encountered during continuation
|
|
62
83
|
# @raise [Phronomy::GuardrailError] when an output guardrail rejects the value
|
|
84
|
+
# @raise [Phronomy::CheckpointAlreadyResumedError] when the checkpoint has already been consumed
|
|
63
85
|
# @api private
|
|
64
86
|
def resume(checkpoint, approved:, config: {})
|
|
87
|
+
# Guard against duplicate resumes using the idempotency store.
|
|
88
|
+
_checkpoint_store.consume!(checkpoint.checkpoint_id)
|
|
65
89
|
# Build a fresh chat with all tools registered.
|
|
66
90
|
chat = build_chat
|
|
67
91
|
|
|
@@ -91,8 +115,30 @@ module Phronomy
|
|
|
91
115
|
tool_call_id: checkpoint.pending_tool_call_id
|
|
92
116
|
)
|
|
93
117
|
|
|
94
|
-
#
|
|
95
|
-
|
|
118
|
+
# Re-register the suspension hook so that any further requires_approval
|
|
119
|
+
# tools encountered during continuation are intercepted rather than
|
|
120
|
+
# executed without approval (cascading / chained approval scenario).
|
|
121
|
+
_register_suspension_hook!(chat)
|
|
122
|
+
|
|
123
|
+
# Continue the LLM loop. Rescue SuspendSignal so that a second
|
|
124
|
+
# approval-required tool produces a new checkpoint instead of running
|
|
125
|
+
# without consent.
|
|
126
|
+
begin
|
|
127
|
+
response = chat.complete
|
|
128
|
+
rescue SuspendSignal => signal
|
|
129
|
+
new_checkpoint = Checkpoint.new(
|
|
130
|
+
checkpoint_id: SecureRandom.uuid,
|
|
131
|
+
agent_class: self.class.name,
|
|
132
|
+
requested_at: Time.now.utc,
|
|
133
|
+
thread_id: checkpoint.thread_id,
|
|
134
|
+
original_input: checkpoint.original_input,
|
|
135
|
+
messages: chat.messages.dup,
|
|
136
|
+
pending_tool_name: signal.tool_name,
|
|
137
|
+
pending_tool_args: signal.args,
|
|
138
|
+
pending_tool_call_id: signal.tool_call_id
|
|
139
|
+
)
|
|
140
|
+
return {output: nil, suspended: true, checkpoint: new_checkpoint, messages: chat.messages}
|
|
141
|
+
end
|
|
96
142
|
|
|
97
143
|
output = response.content
|
|
98
144
|
usage = Phronomy::TokenUsage.from_tokens(response.tokens)
|
|
@@ -129,6 +175,15 @@ module Phronomy
|
|
|
129
175
|
end
|
|
130
176
|
end
|
|
131
177
|
end
|
|
178
|
+
|
|
179
|
+
# Returns the checkpoint idempotency store for this instance, lazily
|
|
180
|
+
# initialising a default in-memory {Phronomy::Agent::CheckpointStore}.
|
|
181
|
+
#
|
|
182
|
+
# @return [#consumed?, #consume!]
|
|
183
|
+
# @api private
|
|
184
|
+
def _checkpoint_store
|
|
185
|
+
@checkpoint_store ||= CheckpointStore.new
|
|
186
|
+
end
|
|
132
187
|
end
|
|
133
188
|
end
|
|
134
189
|
end
|
|
@@ -33,6 +33,18 @@ module Phronomy
|
|
|
33
33
|
# @see Phronomy::EventLoop
|
|
34
34
|
attr_accessor :event_loop
|
|
35
35
|
|
|
36
|
+
# When true, agent LLM calls use {Phronomy::MultiAgent::ParallelToolChat}
|
|
37
|
+
# for concurrent tool dispatch within a single agent turn.
|
|
38
|
+
# Defaults to false.
|
|
39
|
+
#
|
|
40
|
+
# Previously, this was automatically enabled when +event_loop+ was true.
|
|
41
|
+
# As of Phase 3, +parallel_tool_execution+ is a separate setting that must
|
|
42
|
+
# be explicitly enabled.
|
|
43
|
+
# @example
|
|
44
|
+
# Phronomy.configure { |c| c.parallel_tool_execution = true }
|
|
45
|
+
# @return [Boolean]
|
|
46
|
+
attr_accessor :parallel_tool_execution
|
|
47
|
+
|
|
36
48
|
# When true, user input and LLM output are recorded in trace spans.
|
|
37
49
|
# Defaults to false; set to true only in environments where PII capture is acceptable.
|
|
38
50
|
# Set to false in privacy-sensitive environments to prevent PII from reaching
|
|
@@ -186,6 +198,7 @@ module Phronomy
|
|
|
186
198
|
@tracer = Phronomy::Tracing::NullTracer.new
|
|
187
199
|
@trace_pii = false
|
|
188
200
|
@event_loop = false
|
|
201
|
+
@parallel_tool_execution = false
|
|
189
202
|
@event_loop_stop_grace_seconds = 5
|
|
190
203
|
@llm_adapter = Phronomy::LLMAdapter::RubyLLM.new
|
|
191
204
|
@backpressure = :wait
|
data/lib/phronomy/event_loop.rb
CHANGED
|
@@ -129,7 +129,7 @@ module Phronomy
|
|
|
129
129
|
# (WorkflowContext) once the workflow finishes or halts. If an error occurred,
|
|
130
130
|
# the popped value will be an Exception — callers are responsible for re-raising it.
|
|
131
131
|
#
|
|
132
|
-
# @param fsm_session [Phronomy::
|
|
132
|
+
# @param fsm_session [Phronomy::Workflow::FSMSession]
|
|
133
133
|
# @return [Phronomy::Concurrency::AsyncQueue] resolves to final/halted context, or an Exception
|
|
134
134
|
# @api private
|
|
135
135
|
def register(fsm_session)
|
|
@@ -150,23 +150,6 @@ module Phronomy
|
|
|
150
150
|
completion_queue
|
|
151
151
|
end
|
|
152
152
|
|
|
153
|
-
# Enqueues an {AgentFSM} as a fire-and-forget child session.
|
|
154
|
-
#
|
|
155
|
-
# Unlike {#register}, this method:
|
|
156
|
-
# - Is safe to call from the EventLoop thread (entry actions).
|
|
157
|
-
# - Does NOT block — no completion queue is created.
|
|
158
|
-
# - Delegates `:finished`/`:error` cleanup to the EventLoop via posted events.
|
|
159
|
-
#
|
|
160
|
-
# @param agent_fsm [Phronomy::Agent::FSM]
|
|
161
|
-
# @return [nil]
|
|
162
|
-
# @api private
|
|
163
|
-
def enqueue_child(agent_fsm)
|
|
164
|
-
@queue.push([Event.new(type: :start, target_id: agent_fsm.id,
|
|
165
|
-
payload: {session: agent_fsm, completion: nil}),
|
|
166
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)])
|
|
167
|
-
nil
|
|
168
|
-
end
|
|
169
|
-
|
|
170
153
|
# Posts an event to the loop. Safe to call from any thread (including IO threads).
|
|
171
154
|
# The current monotonic clock time is recorded so that the EventLoop can
|
|
172
155
|
# measure the dispatch lag when it dequeues the event.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
class Task
|
|
7
|
+
# Backend for Tasks created by {Task#map}.
|
|
8
|
+
#
|
|
9
|
+
# A mapped task's lifecycle is driven entirely by the +on_complete+
|
|
10
|
+
# callback of its source task — it never spawns a thread of its own.
|
|
11
|
+
# +MappedBackend+ transitions the owning task to +:running+ immediately
|
|
12
|
+
# on initialization so that +FSMSession+ treats it as an in-progress
|
|
13
|
+
# async action. Completion (or failure) is triggered externally via
|
|
14
|
+
# {Task#transition!} from the +on_complete+ callback registered by
|
|
15
|
+
# {Task#map}.
|
|
16
|
+
#
|
|
17
|
+
# +await+ and +join+ block until {#unblock} is called, which {Task#map}
|
|
18
|
+
# arranges by registering a second +on_complete+ callback on the *mapped*
|
|
19
|
+
# task itself after the transform callback has been registered.
|
|
20
|
+
#
|
|
21
|
+
# @api private
|
|
22
|
+
class MappedBackend < Backend
|
|
23
|
+
def initialize(task:, &)
|
|
24
|
+
super
|
|
25
|
+
@done_queue = Queue.new
|
|
26
|
+
task.transition!(:running)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Unblocks +await+ / +join+. Called by {Task#map} after the mapped task
|
|
30
|
+
# reaches a terminal state.
|
|
31
|
+
# @api private
|
|
32
|
+
def unblock(value, error)
|
|
33
|
+
@done_queue.push([value, error])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Blocks until the mapped task reaches a terminal state.
|
|
37
|
+
# @return [Object] the mapped value
|
|
38
|
+
# @raise [Exception] if the source task or the map block raised an error
|
|
39
|
+
# @api private
|
|
40
|
+
def await
|
|
41
|
+
value, error = @done_queue.pop
|
|
42
|
+
raise error if error
|
|
43
|
+
|
|
44
|
+
value
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Returns +false+ — a mapped task has no independent thread to kill.
|
|
48
|
+
# @return [Boolean]
|
|
49
|
+
# @api private
|
|
50
|
+
def alive?
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# No-op — mapped tasks carry no independent thread to cancel.
|
|
55
|
+
# @return [self]
|
|
56
|
+
# @api private
|
|
57
|
+
def cancel!
|
|
58
|
+
self
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Blocks until the mapped task completes, with an optional timeout.
|
|
62
|
+
# @param limit [Numeric, nil]
|
|
63
|
+
# @return [Object, nil] +nil+ on timeout
|
|
64
|
+
# @api private
|
|
65
|
+
def join(limit = nil)
|
|
66
|
+
if limit.nil?
|
|
67
|
+
await
|
|
68
|
+
else
|
|
69
|
+
begin
|
|
70
|
+
Timeout.timeout(limit) { await }
|
|
71
|
+
rescue Timeout::Error
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
data/lib/phronomy/task.rb
CHANGED
|
@@ -4,6 +4,7 @@ require_relative "task/backend"
|
|
|
4
4
|
require_relative "task/thread_backend"
|
|
5
5
|
require_relative "task/immediate_backend"
|
|
6
6
|
require_relative "task/fiber_backend"
|
|
7
|
+
require_relative "task/mapped_backend"
|
|
7
8
|
|
|
8
9
|
module Phronomy
|
|
9
10
|
# A single unit of concurrent work.
|
|
@@ -195,6 +196,58 @@ module Phronomy
|
|
|
195
196
|
self
|
|
196
197
|
end
|
|
197
198
|
|
|
199
|
+
# Returns a new {Task} whose completed value is the result of applying
|
|
200
|
+
# +block+ to this task's completed value.
|
|
201
|
+
#
|
|
202
|
+
# If this task fails or is cancelled, the mapped task also fails/is
|
|
203
|
+
# cancelled with the same error. The block is never called in error cases.
|
|
204
|
+
#
|
|
205
|
+
# The primary use-case is transforming an agent result into a
|
|
206
|
+
# {WorkflowContext} so that a Workflow entry action can return a Task
|
|
207
|
+
# whose value is picked up by {Workflow::FSMSession} via the existing
|
|
208
|
+
# +:action_completed+ path:
|
|
209
|
+
#
|
|
210
|
+
# @example Returning agent output into a Workflow state field
|
|
211
|
+
# entry :translate, ->(ctx) {
|
|
212
|
+
# TranslationAgent.new.invoke_async(ctx.query).map do |result|
|
|
213
|
+
# ctx.merge(answer: result[:output]) # returns WorkflowContext
|
|
214
|
+
# end
|
|
215
|
+
# }
|
|
216
|
+
#
|
|
217
|
+
# @yield [value] the completed value of this task
|
|
218
|
+
# @yieldreturn [Object] the value for the mapped task
|
|
219
|
+
# @return [Task] a new task that completes when this task does
|
|
220
|
+
# @api public
|
|
221
|
+
def map(&block)
|
|
222
|
+
# MappedBackend drives the task lifecycle entirely via on_complete;
|
|
223
|
+
# it never spawns a thread of its own.
|
|
224
|
+
mapped = self.class.spawn(
|
|
225
|
+
name: "#{@name}-mapped",
|
|
226
|
+
parent: @parent,
|
|
227
|
+
backend_class: MappedBackend
|
|
228
|
+
) {}
|
|
229
|
+
|
|
230
|
+
on_complete do |value, error|
|
|
231
|
+
mapped_value = nil
|
|
232
|
+
mapped_error = error
|
|
233
|
+
unless error
|
|
234
|
+
begin
|
|
235
|
+
mapped_value = block.call(value)
|
|
236
|
+
rescue => e
|
|
237
|
+
mapped_error = e
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
if mapped_error
|
|
241
|
+
mapped.transition!(:failed, error: mapped_error)
|
|
242
|
+
else
|
|
243
|
+
mapped.transition!(:completed, value: mapped_value)
|
|
244
|
+
end
|
|
245
|
+
# Unblock mapped.await / mapped.join after the terminal transition.
|
|
246
|
+
mapped.backend.unblock(mapped_value, mapped_error)
|
|
247
|
+
end
|
|
248
|
+
mapped
|
|
249
|
+
end
|
|
250
|
+
|
|
198
251
|
# Returns +true+ once the task has finished (success, error, or cancellation).
|
|
199
252
|
# @return [Boolean]
|
|
200
253
|
# @api private
|
data/lib/phronomy/tools/agent.rb
CHANGED
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
module Tools
|
|
5
5
|
# Wraps a Phronomy::Agent::Base subclass as a callable tool so that a parent
|
|
6
|
-
#
|
|
7
|
-
# fully-capable agent.
|
|
6
|
+
# agent can delegate sub-tasks to a fully-capable sub-agent.
|
|
8
7
|
#
|
|
9
8
|
# Use Agent.from_agent to generate a concrete tool class. The generated
|
|
10
9
|
# class is anonymous; assign it to a constant when you need a stable name.
|
|
@@ -16,7 +15,7 @@ module Phronomy
|
|
|
16
15
|
# description: "Summarizes a long text and returns a brief summary"
|
|
17
16
|
# )
|
|
18
17
|
#
|
|
19
|
-
# class OrchestratorAgent < Phronomy::Agent::
|
|
18
|
+
# class OrchestratorAgent < Phronomy::Agent::Base
|
|
20
19
|
# model "openai/gpt-4o-mini"
|
|
21
20
|
# instructions "You are an orchestrator that delegates to specialist agents."
|
|
22
21
|
# tools SummarizerTool
|
data/lib/phronomy/version.rb
CHANGED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
class Workflow
|
|
5
|
+
# Event-driven execution wrapper for a single workflow run.
|
|
6
|
+
#
|
|
7
|
+
# Created by WorkflowRunner and registered with EventLoop. All public methods
|
|
8
|
+
# are called from the EventLoop thread — FSMSession is NOT thread-safe and must
|
|
9
|
+
# not be accessed concurrently from multiple threads.
|
|
10
|
+
#
|
|
11
|
+
# == Lifecycle
|
|
12
|
+
#
|
|
13
|
+
# register(session) → EventLoop posts :start → session.start
|
|
14
|
+
# ↓ (auto-transition present)
|
|
15
|
+
# EventLoop posts :state_completed → session.handle
|
|
16
|
+
# ↓ (repeat)
|
|
17
|
+
# session posts :finished or :halted
|
|
18
|
+
# ↓
|
|
19
|
+
# EventLoop pushes ctx to completion_queue → caller unblocks
|
|
20
|
+
#
|
|
21
|
+
# == Async IO pattern (EventLoop mode only)
|
|
22
|
+
#
|
|
23
|
+
# When a state has no auto-transition and is not a wait_state, but has an
|
|
24
|
+
# external event registered (e.g. +transition from: :fetching, on: :fetch_done+),
|
|
25
|
+
# the FSMSession stays registered in the EventLoop and waits for that event.
|
|
26
|
+
# The entry action is expected to spawn an IO thread that posts the event back:
|
|
27
|
+
#
|
|
28
|
+
# entry :fetching, ->(ctx) {
|
|
29
|
+
# Thread.new {
|
|
30
|
+
# ctx.result = http.get(ctx.url)
|
|
31
|
+
# Phronomy::EventLoop.instance.post(
|
|
32
|
+
# Phronomy::Event.new(type: :fetch_done, target_id: ctx.thread_id, payload: nil)
|
|
33
|
+
# )
|
|
34
|
+
# }
|
|
35
|
+
# }
|
|
36
|
+
# transition from: :fetching, on: :fetch_done, to: :process
|
|
37
|
+
class FSMSession
|
|
38
|
+
FINISH = WorkflowRunner::FINISH
|
|
39
|
+
|
|
40
|
+
# @return [String] workflow thread_id (matches WorkflowContext#thread_id)
|
|
41
|
+
attr_reader :id
|
|
42
|
+
|
|
43
|
+
# @param id [String]
|
|
44
|
+
# @param context [Object] includes Phronomy::WorkflowContext
|
|
45
|
+
# @param entry_point [Symbol] initial state name
|
|
46
|
+
# @param entry_actions [Hash] { state_name => [callable, ...] }
|
|
47
|
+
# @param auto_state_set [Hash] { state_name => true }
|
|
48
|
+
# @param declared_states [Array<Symbol>] all action state names
|
|
49
|
+
# @param wait_state_names [Array<Symbol>]
|
|
50
|
+
# @param external_events [Hash] { event_name => [{from:, to:, guard:}] }
|
|
51
|
+
# @param phase_machine_class [Class] state_machines-backed phase tracker class
|
|
52
|
+
# @param recursion_limit [Integer]
|
|
53
|
+
# @param action_timeouts [Hash] { state_name => seconds }
|
|
54
|
+
# @param resume_event [Symbol, nil] external event to fire when resuming
|
|
55
|
+
# @param resume_phase [Symbol, nil] wait state name to resume from
|
|
56
|
+
# @api private
|
|
57
|
+
def initialize(id:, context:, entry_point:, entry_actions:, auto_state_set:,
|
|
58
|
+
declared_states:, wait_state_names:, external_events:, phase_machine_class:,
|
|
59
|
+
recursion_limit:, action_timeouts: {}, resume_event: nil, resume_phase: nil)
|
|
60
|
+
@id = id
|
|
61
|
+
@ctx = context
|
|
62
|
+
@entry_point = entry_point
|
|
63
|
+
@entry_actions = entry_actions
|
|
64
|
+
@auto_state_set = auto_state_set
|
|
65
|
+
@declared_states = declared_states
|
|
66
|
+
@wait_state_names = wait_state_names
|
|
67
|
+
@external_events = external_events
|
|
68
|
+
@phase_machine_class = phase_machine_class
|
|
69
|
+
@recursion_limit = recursion_limit
|
|
70
|
+
@action_timeouts = action_timeouts
|
|
71
|
+
@resume_event = resume_event
|
|
72
|
+
@resume_phase = resume_phase
|
|
73
|
+
@step = 0
|
|
74
|
+
@done = false
|
|
75
|
+
@current_state = nil
|
|
76
|
+
@tracker = nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Begins workflow execution. Called by EventLoop on :start event.
|
|
80
|
+
def start
|
|
81
|
+
if @resume_event
|
|
82
|
+
# Resume from wait state: position tracker at the wait state, then fire the
|
|
83
|
+
# external event. state_machines fires before_transition (exit) and
|
|
84
|
+
# after_transition (entry) callbacks, so both actions execute here.
|
|
85
|
+
@current_state = @resume_phase
|
|
86
|
+
@tracker = build_tracker(@current_state)
|
|
87
|
+
@tracker.context = @ctx
|
|
88
|
+
fire_and_advance!(@resume_event)
|
|
89
|
+
else
|
|
90
|
+
# Fresh start: state_machines does not fire callbacks on initialization,
|
|
91
|
+
# so we invoke the entry action for the initial state manually.
|
|
92
|
+
@current_state = @entry_point
|
|
93
|
+
@tracker = build_tracker(@current_state)
|
|
94
|
+
@tracker.context = @ctx
|
|
95
|
+
(@entry_actions[@current_state] || []).each do |c|
|
|
96
|
+
result = c.call(@ctx)
|
|
97
|
+
if result.is_a?(Phronomy::Task)
|
|
98
|
+
# Awaitable action: spawn a task to await without blocking EventLoop.
|
|
99
|
+
@tracker.async_pending = true
|
|
100
|
+
session_id = @id
|
|
101
|
+
current_state_name = @current_state
|
|
102
|
+
timeout_secs = @action_timeouts[current_state_name]
|
|
103
|
+
Phronomy::Runtime.instance.spawn(name: "fsm-await-#{session_id}") do
|
|
104
|
+
if timeout_secs
|
|
105
|
+
if result.join(timeout_secs).nil?
|
|
106
|
+
result.cancel!
|
|
107
|
+
raise Phronomy::ActionTimeoutError,
|
|
108
|
+
"Action in state #{current_state_name.inspect} timed out after #{timeout_secs}s"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
task_result = result.await
|
|
112
|
+
if task_result.is_a?(Phronomy::WorkflowContext)
|
|
113
|
+
event_loop.post(Event.new(type: :action_completed, target_id: session_id, payload: task_result))
|
|
114
|
+
else
|
|
115
|
+
event_loop.post(Event.new(type: :state_completed, target_id: session_id, payload: nil))
|
|
116
|
+
end
|
|
117
|
+
rescue => e
|
|
118
|
+
event_loop.post(Event.new(type: :error, target_id: session_id, payload: e))
|
|
119
|
+
end
|
|
120
|
+
break # Only one async action at a time per state
|
|
121
|
+
elsif result.is_a?(Phronomy::WorkflowContext)
|
|
122
|
+
@ctx = result
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
@tracker.context = @ctx
|
|
126
|
+
advance_or_halt unless @tracker.async_pending
|
|
127
|
+
end
|
|
128
|
+
rescue => e
|
|
129
|
+
finish_with_error(e)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Processes an event dispatched from EventLoop.
|
|
133
|
+
# Called for :state_completed, :action_completed, and all user-defined external events.
|
|
134
|
+
#
|
|
135
|
+
# @param event [Phronomy::Event]
|
|
136
|
+
# @api private
|
|
137
|
+
def handle(event)
|
|
138
|
+
return if @done
|
|
139
|
+
|
|
140
|
+
if event.type == :action_completed
|
|
141
|
+
# An awaitable entry action completed: update context and advance.
|
|
142
|
+
@ctx = event.payload if event.payload.is_a?(Phronomy::WorkflowContext)
|
|
143
|
+
@tracker.context = @ctx
|
|
144
|
+
@tracker.async_pending = false # Reset flag set by start or fire_and_advance!
|
|
145
|
+
advance_or_halt
|
|
146
|
+
return
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
fire_and_advance!(event.type)
|
|
150
|
+
rescue => e
|
|
151
|
+
finish_with_error(e)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
# Fires event_name on the phase tracker, updates @current_state, then
|
|
157
|
+
# calls advance_or_halt to decide what to do next.
|
|
158
|
+
def fire_and_advance!(event_name)
|
|
159
|
+
if @step >= @recursion_limit
|
|
160
|
+
raise Phronomy::RecursionLimitError,
|
|
161
|
+
"Recursion limit (#{@recursion_limit}) exceeded"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
fire_event!(@tracker, event_name, @current_state)
|
|
165
|
+
@ctx = @tracker.context
|
|
166
|
+
next_phase = @tracker.phase.to_sym
|
|
167
|
+
# When next_phase == @current_state, no transition matched → treat as terminal.
|
|
168
|
+
@current_state = (next_phase == @current_state) ? FINISH : next_phase
|
|
169
|
+
@step += 1
|
|
170
|
+
|
|
171
|
+
# If an entry action returned a Task, the after_transition callback set
|
|
172
|
+
# async_pending = true and spawned a thread. Skip advance_or_halt — the
|
|
173
|
+
# background thread will post :action_completed or :state_completed.
|
|
174
|
+
if @tracker.async_pending
|
|
175
|
+
@tracker.async_pending = false
|
|
176
|
+
return
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
advance_or_halt
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Determines the next action after the FSM has entered @current_state.
|
|
183
|
+
def advance_or_halt
|
|
184
|
+
return finish! if @current_state == FINISH
|
|
185
|
+
|
|
186
|
+
if @wait_state_names.include?(@current_state)
|
|
187
|
+
return halt!
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
if @auto_state_set.key?(@current_state)
|
|
191
|
+
event_loop.post(Event.new(type: :state_completed, target_id: @id, payload: nil))
|
|
192
|
+
return
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
if has_external_event_from?(@current_state)
|
|
196
|
+
# Async IO pattern: the entry action spawned an IO thread that will post
|
|
197
|
+
# an external event back. Stay registered; do nothing here.
|
|
198
|
+
return
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# No transition declared — validate the state is known, then treat as terminal.
|
|
202
|
+
unless @declared_states.include?(@current_state)
|
|
203
|
+
raise ArgumentError, "State #{@current_state.inspect} is not defined"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
finish!
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def finish!
|
|
210
|
+
@done = true
|
|
211
|
+
@ctx.set_graph_metadata(thread_id: @id, phase: :__end__)
|
|
212
|
+
event_loop.post(Event.new(type: :finished, target_id: @id, payload: @ctx))
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def halt!
|
|
216
|
+
@done = true
|
|
217
|
+
@ctx.set_graph_metadata(thread_id: @id, phase: @current_state)
|
|
218
|
+
event_loop.post(Event.new(type: :halted, target_id: @id, payload: @ctx))
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def finish_with_error(err)
|
|
222
|
+
@done = true
|
|
223
|
+
event_loop.post(Event.new(type: :error, target_id: @id, payload: err))
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def fire_event!(tracker, event_name, from_state)
|
|
227
|
+
return if tracker.send(event_name)
|
|
228
|
+
|
|
229
|
+
raise ArgumentError,
|
|
230
|
+
"Transition from #{from_state.inspect} via event #{event_name.inspect} failed. " \
|
|
231
|
+
"Ensure at least one guard matches or add a fallback (no-guard) transition."
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def has_external_event_from?(state)
|
|
235
|
+
@external_events.any? { |_, transitions| transitions.any? { |t| t[:from] == state } }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def build_tracker(from_state)
|
|
239
|
+
machine = @phase_machine_class.new
|
|
240
|
+
machine.instance_variable_set(:@phase, from_state.to_s)
|
|
241
|
+
machine
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def event_loop
|
|
245
|
+
Phronomy::EventLoop.instance
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|