phronomy 0.11.0 → 0.12.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/README.md +17 -8
- data/benchmark/bench_agent_invoke.rb +1 -0
- data/lib/phronomy/agent/base.rb +197 -83
- data/lib/phronomy/agent/concerns/error_translation.rb +1 -1
- data/lib/phronomy/agent/concerns/retryable.rb +6 -2
- data/lib/phronomy/agent/invocation_context.rb +171 -0
- data/lib/phronomy/agent/invocation_session.rb +346 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +189 -0
- data/lib/phronomy/agent/suspended_session_registry.rb +54 -0
- data/lib/phronomy/agent/tool_call_intercepted.rb +26 -0
- data/lib/phronomy/configuration.rb +0 -6
- data/lib/phronomy/{concurrency → engine/concurrency}/async_queue.rb +68 -5
- data/lib/phronomy/{concurrency → engine/concurrency}/blocking_adapter_pool.rb +9 -3
- data/lib/phronomy/{event_loop.rb → engine/event_loop.rb} +71 -37
- data/lib/phronomy/engine/fsm_session.rb +248 -0
- data/lib/phronomy/{runtime → engine/runtime}/deterministic_scheduler.rb +28 -1
- data/lib/phronomy/{runtime → engine/runtime}/fake_scheduler.rb +1 -1
- data/lib/phronomy/{runtime.rb → engine/runtime.rb} +2 -4
- data/lib/phronomy/{task → engine/task}/backend.rb +2 -2
- data/lib/phronomy/engine/task/deferred_backend.rb +73 -0
- data/lib/phronomy/{task → engine/task}/fiber_backend.rb +1 -1
- data/lib/phronomy/{task → engine/task}/immediate_backend.rb +1 -1
- data/lib/phronomy/{task → engine/task}/mapped_backend.rb +15 -3
- data/lib/phronomy/{task → engine/task}/thread_backend.rb +1 -1
- data/lib/phronomy/{task.rb → engine/task.rb} +19 -7
- data/lib/phronomy/eval/scorer/llm_judge.rb +1 -1
- data/lib/phronomy/generator_verifier.rb +20 -11
- data/lib/phronomy/multi_agent/orchestrator.rb +6 -6
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +3 -3
- data/lib/phronomy/tools/mcp.rb +17 -10
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/phase_machine_builder.rb +24 -13
- data/lib/phronomy/workflow.rb +6 -3
- data/lib/phronomy/workflow_context.rb +7 -5
- data/lib/phronomy/workflow_runner.rb +77 -27
- data/lib/phronomy.rb +17 -9
- metadata +35 -35
- data/lib/phronomy/agent/checkpoint.rb +0 -208
- data/lib/phronomy/agent/checkpoint_store.rb +0 -97
- data/lib/phronomy/agent/concerns/suspendable.rb +0 -190
- data/lib/phronomy/agent/suspend_signal.rb +0 -37
- data/lib/phronomy/context.rb +0 -12
- data/lib/phronomy/tool.rb +0 -9
- data/lib/phronomy/workflow/fsm_session.rb +0 -249
- /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_scope.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_token.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/concurrency_gate.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/deadline.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/gate_registry.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/pool_registry.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/runtime_metrics.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/scheduler.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/scheduler_timer_adapter.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/task_registry.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/thread_scheduler.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/timer_queue.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/timer_service.rb +0 -0
- /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.
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
# producer
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
#
|
|
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::
|
|
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
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
#
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
|
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 &&
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
28
|
-
raise NotImplementedError, "#{self.class}#
|
|
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.
|