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
|
@@ -3,38 +3,33 @@
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
# Event-driven execution wrapper for a single FSM session.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
-
#
|
|
6
|
+
# All public methods are called from the Runtime-owned EventLoop thread.
|
|
7
|
+
# FSMSession owns FSM execution only; it does not own external Task handles,
|
|
8
|
+
# activity tokens, callback correlation, or domain-specific stale-event policy.
|
|
13
9
|
class FSMSession
|
|
14
10
|
FINISH = WorkflowRunner::FINISH
|
|
15
11
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
declared_states:, wait_state_names:, external_events:, phase_machine_class:,
|
|
35
|
-
recursion_limit:, action_timeouts: {}, resume_event: nil, resume_phase: nil)
|
|
12
|
+
attr_reader :id, :context
|
|
13
|
+
|
|
14
|
+
def initialize(
|
|
15
|
+
id:,
|
|
16
|
+
context:,
|
|
17
|
+
entry_point:,
|
|
18
|
+
entry_actions:,
|
|
19
|
+
auto_state_set:,
|
|
20
|
+
declared_states:,
|
|
21
|
+
wait_state_names:,
|
|
22
|
+
external_events:,
|
|
23
|
+
phase_machine_class:,
|
|
24
|
+
recursion_limit:,
|
|
25
|
+
event_loop:,
|
|
26
|
+
resume_event: nil,
|
|
27
|
+
resume_phase: nil,
|
|
28
|
+
stable_observer: nil
|
|
29
|
+
)
|
|
36
30
|
@id = id
|
|
37
31
|
@ctx = context
|
|
32
|
+
@context = context
|
|
38
33
|
@entry_point = entry_point
|
|
39
34
|
@entry_actions = entry_actions
|
|
40
35
|
@auto_state_set = auto_state_set
|
|
@@ -43,153 +38,145 @@ module Phronomy
|
|
|
43
38
|
@external_events = external_events
|
|
44
39
|
@phase_machine_class = phase_machine_class
|
|
45
40
|
@recursion_limit = recursion_limit
|
|
46
|
-
@
|
|
41
|
+
@event_loop = event_loop
|
|
47
42
|
@resume_event = resume_event
|
|
48
43
|
@resume_phase = resume_phase
|
|
44
|
+
@stable_observer = stable_observer
|
|
49
45
|
@step = 0
|
|
50
46
|
@done = false
|
|
51
47
|
@current_state = nil
|
|
52
48
|
@tracker = nil
|
|
53
49
|
end
|
|
54
50
|
|
|
55
|
-
# Begins workflow execution. Called by EventLoop on :start event.
|
|
56
51
|
def start
|
|
57
52
|
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
53
|
@current_state = @resume_phase
|
|
62
54
|
@tracker = build_tracker(@current_state)
|
|
63
55
|
@tracker.context = @ctx
|
|
64
|
-
|
|
65
|
-
|
|
56
|
+
fire_and_advance!(
|
|
57
|
+
Phronomy::Event.new(
|
|
58
|
+
type: @resume_event,
|
|
59
|
+
target_id: @id,
|
|
60
|
+
payload: nil
|
|
61
|
+
)
|
|
62
|
+
)
|
|
66
63
|
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
64
|
@current_state = @entry_point
|
|
70
65
|
@tracker = build_tracker(@current_state)
|
|
71
66
|
@tracker.context = @ctx
|
|
72
|
-
|
|
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
|
|
67
|
+
run_initial_entry_actions!
|
|
112
68
|
@tracker.context = @ctx
|
|
113
|
-
advance_or_halt
|
|
69
|
+
advance_or_halt
|
|
114
70
|
end
|
|
115
|
-
rescue =>
|
|
116
|
-
finish_with_error(
|
|
71
|
+
rescue => error
|
|
72
|
+
finish_with_error(error)
|
|
117
73
|
end
|
|
118
74
|
|
|
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
75
|
def handle(event)
|
|
125
76
|
return if @done
|
|
126
77
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
advance_or_halt
|
|
78
|
+
context_disposition = apply_context_event(event)
|
|
79
|
+
return if context_disposition == :consume
|
|
80
|
+
|
|
81
|
+
if context_disposition &&
|
|
82
|
+
!has_external_event_from?(@current_state, event.type)
|
|
133
83
|
return
|
|
134
84
|
end
|
|
135
85
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
fire_and_advance!(event.type)
|
|
141
|
-
rescue => e
|
|
142
|
-
finish_with_error(e)
|
|
86
|
+
fire_and_advance!(event)
|
|
87
|
+
rescue => error
|
|
88
|
+
finish_with_error(error)
|
|
143
89
|
end
|
|
144
90
|
|
|
145
91
|
private
|
|
146
92
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
93
|
+
def run_initial_entry_actions!
|
|
94
|
+
Array(@entry_actions[@current_state]).each do |callable|
|
|
95
|
+
result = callable.call(@ctx)
|
|
96
|
+
apply_synchronous_action_result!(result, @current_state)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def apply_synchronous_action_result!(result, state_name)
|
|
101
|
+
if result.is_a?(Phronomy::Task)
|
|
102
|
+
raise Phronomy::InvalidAsyncEntryActionError,
|
|
103
|
+
"Entry action for state #{state_name.inspect} returned Phronomy::Task. " \
|
|
104
|
+
"Start the asynchronous operation, register its callback/listener, " \
|
|
105
|
+
"and return the WorkflowContext or nil."
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
if _fsm_context?(result)
|
|
109
|
+
@ctx = result
|
|
110
|
+
@context = result
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def apply_context_event(event)
|
|
115
|
+
return false unless @ctx.respond_to?(:handle_fsm_event)
|
|
116
|
+
|
|
117
|
+
result = @ctx.handle_fsm_event(event)
|
|
118
|
+
return :consume if result == :consume
|
|
119
|
+
|
|
120
|
+
if _fsm_context?(result)
|
|
121
|
+
@ctx = result
|
|
122
|
+
@context = result
|
|
123
|
+
@tracker.context = @ctx
|
|
124
|
+
true
|
|
125
|
+
else
|
|
126
|
+
!!result
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def fire_and_advance!(event)
|
|
150
131
|
if @step >= @recursion_limit
|
|
151
132
|
raise Phronomy::RecursionLimitError,
|
|
152
133
|
"Recursion limit (#{@recursion_limit}) exceeded"
|
|
153
134
|
end
|
|
154
135
|
|
|
155
|
-
|
|
136
|
+
@tracker.context = @ctx
|
|
137
|
+
clear_selected_transition!
|
|
138
|
+
@tracker.current_event = event if @tracker.respond_to?(:current_event=)
|
|
139
|
+
transitioned = fire_event!(@tracker, event.type, @current_state)
|
|
140
|
+
return unless transitioned
|
|
141
|
+
|
|
156
142
|
@ctx = @tracker.context
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
@current_state = (next_phase == @current_state) ? FINISH : next_phase
|
|
143
|
+
@context = @ctx
|
|
144
|
+
@current_state = @tracker.phase.to_sym
|
|
160
145
|
@step += 1
|
|
146
|
+
advance_or_halt
|
|
147
|
+
ensure
|
|
148
|
+
@tracker.current_event = nil if @tracker&.respond_to?(:current_event=)
|
|
149
|
+
clear_selected_transition!
|
|
150
|
+
end
|
|
161
151
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
# background thread will post :action_completed or :state_completed.
|
|
165
|
-
if @tracker.async_pending
|
|
166
|
-
@tracker.async_pending = false
|
|
167
|
-
return
|
|
168
|
-
end
|
|
152
|
+
def clear_selected_transition!
|
|
153
|
+
return unless @tracker
|
|
169
154
|
|
|
170
|
-
|
|
155
|
+
if @tracker.respond_to?(:selected_transition_action=)
|
|
156
|
+
@tracker.selected_transition_action = nil
|
|
157
|
+
end
|
|
158
|
+
if @tracker.respond_to?(:selected_transition_metadata=)
|
|
159
|
+
@tracker.selected_transition_metadata = nil
|
|
160
|
+
end
|
|
171
161
|
end
|
|
172
162
|
|
|
173
|
-
# Determines the next action after the FSM has entered @current_state.
|
|
174
163
|
def advance_or_halt
|
|
175
164
|
return finish! if @current_state == FINISH
|
|
176
165
|
|
|
166
|
+
notify_stable_state!
|
|
167
|
+
|
|
177
168
|
if @wait_state_names.include?(@current_state)
|
|
178
|
-
|
|
169
|
+
halt!
|
|
170
|
+
return
|
|
179
171
|
end
|
|
180
172
|
|
|
181
173
|
if @auto_state_set.key?(@current_state)
|
|
182
|
-
|
|
174
|
+
post_session_event(:state_completed)
|
|
183
175
|
return
|
|
184
176
|
end
|
|
185
177
|
|
|
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
|
|
178
|
+
return if has_external_event_from?(@current_state)
|
|
191
179
|
|
|
192
|
-
# No transition declared — validate the state is known, then treat as terminal.
|
|
193
180
|
unless @declared_states.include?(@current_state)
|
|
194
181
|
raise ArgumentError, "State #{@current_state.inspect} is not defined"
|
|
195
182
|
end
|
|
@@ -197,33 +184,96 @@ module Phronomy
|
|
|
197
184
|
finish!
|
|
198
185
|
end
|
|
199
186
|
|
|
187
|
+
def notify_stable_state!
|
|
188
|
+
return unless @stable_observer
|
|
189
|
+
|
|
190
|
+
@stable_observer.call(
|
|
191
|
+
{
|
|
192
|
+
state: @current_state,
|
|
193
|
+
context: @ctx
|
|
194
|
+
}
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def post_session_event(type, payload = nil)
|
|
199
|
+
event = Phronomy::Event.new(
|
|
200
|
+
type: type,
|
|
201
|
+
target_id: @id,
|
|
202
|
+
payload: payload
|
|
203
|
+
)
|
|
204
|
+
accepted =
|
|
205
|
+
if @event_loop.respond_to?(:post_to_session)
|
|
206
|
+
@event_loop.post_to_session(event)
|
|
207
|
+
else
|
|
208
|
+
@event_loop.post(event)
|
|
209
|
+
end
|
|
210
|
+
return if accepted
|
|
211
|
+
|
|
212
|
+
raise Phronomy::RuntimeShutdownError,
|
|
213
|
+
"EventLoop rejected #{type.inspect} for FSMSession #{@id}"
|
|
214
|
+
end
|
|
215
|
+
|
|
200
216
|
def finish!
|
|
217
|
+
return if @done
|
|
218
|
+
|
|
201
219
|
@done = true
|
|
202
220
|
@ctx.set_graph_metadata(thread_id: @id, phase: :__end__)
|
|
203
|
-
|
|
221
|
+
post_terminal_event(:finished, @ctx)
|
|
204
222
|
end
|
|
205
223
|
|
|
206
224
|
def halt!
|
|
225
|
+
return if @done
|
|
226
|
+
|
|
207
227
|
@done = true
|
|
208
228
|
@ctx.set_graph_metadata(thread_id: @id, phase: @current_state)
|
|
209
|
-
|
|
229
|
+
post_terminal_event(:halted, @ctx)
|
|
210
230
|
end
|
|
211
231
|
|
|
212
|
-
def finish_with_error(
|
|
232
|
+
def finish_with_error(error)
|
|
233
|
+
return if @done
|
|
234
|
+
|
|
213
235
|
@done = true
|
|
214
|
-
|
|
236
|
+
post_terminal_event(:error, error)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def post_terminal_event(type, result)
|
|
240
|
+
accepted = @event_loop.post(
|
|
241
|
+
Phronomy::Event.new(
|
|
242
|
+
type: type,
|
|
243
|
+
target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID,
|
|
244
|
+
payload: {session_id: @id, result: result}
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
return if accepted
|
|
248
|
+
|
|
249
|
+
Phronomy.configuration.logger&.warn(
|
|
250
|
+
"[Phronomy::FSMSession] EventLoop rejected terminal event " \
|
|
251
|
+
"#{type.inspect} for #{@id}"
|
|
252
|
+
)
|
|
215
253
|
end
|
|
216
254
|
|
|
217
255
|
def fire_event!(tracker, event_name, from_state)
|
|
218
|
-
|
|
256
|
+
unless tracker.respond_to?(event_name)
|
|
257
|
+
raise ArgumentError,
|
|
258
|
+
"Unknown FSM event #{event_name.inspect} for state #{from_state.inspect}"
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
return true if tracker.public_send(event_name)
|
|
262
|
+
|
|
263
|
+
# A declared external event whose guards all reject is a valid no-op.
|
|
264
|
+
# Applications use this to reject stale or unrelated correlated events.
|
|
265
|
+
return false if has_external_event_from?(from_state, event_name)
|
|
219
266
|
|
|
220
267
|
raise ArgumentError,
|
|
221
268
|
"Transition from #{from_state.inspect} via event #{event_name.inspect} failed. " \
|
|
222
|
-
"
|
|
269
|
+
"The event is not declared for the current state."
|
|
223
270
|
end
|
|
224
271
|
|
|
225
|
-
def has_external_event_from?(state)
|
|
226
|
-
|
|
272
|
+
def has_external_event_from?(state, event_name = nil)
|
|
273
|
+
events = event_name ? {event_name => @external_events[event_name]} : @external_events
|
|
274
|
+
events.any? do |_name, transitions|
|
|
275
|
+
Array(transitions).any? { |transition| transition[:from] == state }
|
|
276
|
+
end
|
|
227
277
|
end
|
|
228
278
|
|
|
229
279
|
def build_tracker(from_state)
|
|
@@ -232,17 +282,8 @@ module Phronomy
|
|
|
232
282
|
machine
|
|
233
283
|
end
|
|
234
284
|
|
|
235
|
-
def
|
|
236
|
-
|
|
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)
|
|
285
|
+
def _fsm_context?(object)
|
|
286
|
+
object.respond_to?(:set_graph_metadata)
|
|
246
287
|
end
|
|
247
288
|
end
|
|
248
289
|
end
|
|
@@ -38,7 +38,7 @@ module Phronomy
|
|
|
38
38
|
class DeterministicScheduler < Scheduler
|
|
39
39
|
# Scheduler-aware signal for cooperative suspension.
|
|
40
40
|
#
|
|
41
|
-
# Used by {
|
|
41
|
+
# Used by {TaskGroup} to suspend a Fiber until a
|
|
42
42
|
# slot or condition becomes available, without blocking the OS thread.
|
|
43
43
|
# All methods must be called from within a {DeterministicScheduler} tick.
|
|
44
44
|
# @api private
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
class Runtime
|
|
5
|
+
# Immutable result returned by {Runtime#shutdown}.
|
|
6
|
+
#
|
|
7
|
+
# +runtime_outcome+ records whether execution remained healthy. It is
|
|
8
|
+
# intentionally independent from +cleanup_status+: a Runtime may fail
|
|
9
|
+
# during execution but still release every owned resource successfully.
|
|
10
|
+
# @api private
|
|
11
|
+
class ShutdownResult
|
|
12
|
+
attr_reader :runtime_outcome,
|
|
13
|
+
:cleanup_status,
|
|
14
|
+
:event_loop_status,
|
|
15
|
+
:task_registry_status,
|
|
16
|
+
:error
|
|
17
|
+
|
|
18
|
+
def self.not_started
|
|
19
|
+
new(
|
|
20
|
+
runtime_outcome: :terminated,
|
|
21
|
+
cleanup_status: :complete,
|
|
22
|
+
event_loop_status: :not_started,
|
|
23
|
+
task_registry_status: :empty
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(
|
|
28
|
+
runtime_outcome:,
|
|
29
|
+
cleanup_status:,
|
|
30
|
+
event_loop_status:,
|
|
31
|
+
task_registry_status:,
|
|
32
|
+
error: nil
|
|
33
|
+
)
|
|
34
|
+
@runtime_outcome = runtime_outcome
|
|
35
|
+
@cleanup_status = cleanup_status
|
|
36
|
+
@event_loop_status = event_loop_status
|
|
37
|
+
@task_registry_status = task_registry_status
|
|
38
|
+
@error = error
|
|
39
|
+
freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns true when every Runtime-owned resource is known to have stopped.
|
|
43
|
+
def cleanup_complete?
|
|
44
|
+
@cleanup_status == :complete
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Returns true only for a graceful, error-free shutdown.
|
|
48
|
+
# A cancellation may release every resource, but is not considered clean.
|
|
49
|
+
def clean?
|
|
50
|
+
@runtime_outcome == :terminated &&
|
|
51
|
+
@cleanup_status == :complete &&
|
|
52
|
+
%i[not_started terminated].include?(@event_loop_status) &&
|
|
53
|
+
@task_registry_status == :empty
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Runtime reset is safe when cleanup completed, even if execution failed.
|
|
57
|
+
def success?
|
|
58
|
+
cleanup_complete?
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -5,10 +5,8 @@ module Phronomy
|
|
|
5
5
|
# Internal registry of active {Task} instances for a {Runtime}.
|
|
6
6
|
#
|
|
7
7
|
# Tracks every task that has been spawned but not yet completed so that
|
|
8
|
-
# {Runtime#shutdown} can drain them.
|
|
9
|
-
#
|
|
10
|
-
# before the caller returns from {Runtime#spawn}, so they are never added
|
|
11
|
-
# to the registry in the first place.
|
|
8
|
+
# {Runtime#shutdown} can drain them. Tasks that complete synchronously
|
|
9
|
+
# deregister themselves before the caller returns from {Runtime#spawn}.
|
|
12
10
|
# @api private
|
|
13
11
|
class TaskRegistry
|
|
14
12
|
def initialize
|
|
@@ -16,33 +14,82 @@ module Phronomy
|
|
|
16
14
|
@tasks = []
|
|
17
15
|
end
|
|
18
16
|
|
|
19
|
-
# Adds +task+
|
|
20
|
-
# @param task [Task]
|
|
21
|
-
# @return [void]
|
|
17
|
+
# Adds +task+ unless it already completed synchronously.
|
|
22
18
|
# @api private
|
|
23
19
|
def register(task)
|
|
24
20
|
@mutex.synchronize { @tasks << task unless task.done? }
|
|
25
21
|
end
|
|
26
22
|
|
|
27
|
-
# Removes +task+ from the registry
|
|
28
|
-
# @param task [Task]
|
|
29
|
-
# @return [void]
|
|
23
|
+
# Removes +task+ from the registry.
|
|
30
24
|
# @api private
|
|
31
25
|
def deregister(task)
|
|
32
26
|
@mutex.synchronize { @tasks.delete(task) }
|
|
33
27
|
end
|
|
34
28
|
|
|
35
|
-
# Waits for
|
|
36
|
-
#
|
|
29
|
+
# Waits for registered tasks until the absolute monotonic +deadline+.
|
|
30
|
+
# The registry is re-snapshotted because tasks may create follow-up tasks
|
|
31
|
+
# while Runtime shutdown is draining accepted work.
|
|
32
|
+
#
|
|
33
|
+
# @param deadline [Numeric] absolute Process::CLOCK_MONOTONIC value
|
|
34
|
+
# @return [Symbol] +:empty+ or +:timeout+
|
|
35
|
+
# @api private
|
|
36
|
+
def drain_until(deadline)
|
|
37
|
+
loop do
|
|
38
|
+
tasks = snapshot
|
|
39
|
+
return :empty if tasks.empty?
|
|
40
|
+
|
|
41
|
+
tasks.each do |task|
|
|
42
|
+
remaining = deadline - monotonic_now
|
|
43
|
+
return :timeout if remaining <= 0
|
|
44
|
+
|
|
45
|
+
begin
|
|
46
|
+
task.join(remaining)
|
|
47
|
+
rescue
|
|
48
|
+
# Some backends re-raise the task error from join. The task's
|
|
49
|
+
# ensure block still deregisters it, so continue the drain.
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
return :timeout if task.alive? && monotonic_now >= deadline
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
return :empty if empty?
|
|
57
|
+
return :timeout if monotonic_now >= deadline
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Compatibility helper for callers that explicitly require an unbounded
|
|
62
|
+
# drain. Runtime#shutdown uses {#drain_until}.
|
|
37
63
|
# @api private
|
|
38
64
|
def drain
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
t.join
|
|
65
|
+
snapshot.each do |task|
|
|
66
|
+
task.join
|
|
42
67
|
rescue
|
|
43
68
|
nil
|
|
44
69
|
end
|
|
45
70
|
end
|
|
71
|
+
|
|
72
|
+
# @return [Boolean]
|
|
73
|
+
# @api private
|
|
74
|
+
def empty?
|
|
75
|
+
@mutex.synchronize { @tasks.empty? }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [Integer]
|
|
79
|
+
# @api private
|
|
80
|
+
def size
|
|
81
|
+
@mutex.synchronize { @tasks.size }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def snapshot
|
|
87
|
+
@mutex.synchronize { @tasks.dup }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def monotonic_now
|
|
91
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
92
|
+
end
|
|
46
93
|
end
|
|
47
94
|
end
|
|
48
95
|
end
|