phronomy 0.28.0 → 0.29.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 +20 -5
- data/docs/application-recipes.md +229 -0
- data/docs/architecture/dx05-agent-scope.md +119 -0
- data/docs/architecture/dx05-workflow-task-state.md +134 -0
- data/docs/architecture/error-api-review.md +245 -0
- data/docs/failure-reason-codes.md +45 -0
- data/docs/features.md +4 -1
- data/docs/getting-started.md +44 -43
- data/docs/ordered-event-delivery.md +54 -0
- data/docs/workflow-completion.md +92 -0
- data/gemfiles/http_minimum.gemfile +5 -0
- data/lib/phronomy/agent/agent_not_found_error.rb +16 -0
- data/lib/phronomy/agent/agent_unloaded_error.rb +14 -0
- data/lib/phronomy/agent/api/agent.rb +13 -0
- data/lib/phronomy/agent/async_event_api.rb +111 -87
- data/lib/phronomy/agent/base.rb +74 -43
- data/lib/phronomy/agent/before_llm_input.rb +7 -1
- data/lib/phronomy/agent/execution/exact_execution.rb +11 -9
- data/lib/phronomy/agent/execution/execution_coordinator.rb +16 -13
- data/lib/phronomy/agent/execution/execution_outcome_committer.rb +1 -1
- data/lib/phronomy/agent/execution/initial_preparation.rb +1 -4
- data/lib/phronomy/agent/execution/runtime_record_encoder.rb +1 -4
- data/lib/phronomy/agent/filterable.rb +21 -15
- data/lib/phronomy/agent/lifecycle/lifetime_api.rb +76 -0
- data/lib/phronomy/agent/lifecycle/operation_lease.rb +33 -0
- data/lib/phronomy/agent/lifecycle/operation_result.rb +53 -0
- data/lib/phronomy/agent/lifecycle/ownership_registry.rb +99 -8
- data/lib/phronomy/agent/lifecycle/scope.rb +198 -0
- data/lib/phronomy/agent/lifecycle/scope_cleanup_error.rb +19 -0
- data/lib/phronomy/agent/lifecycle/unload_transition.rb +78 -0
- data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +3 -3
- data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +7 -7
- data/lib/phronomy/agent/recovery/recovery_coordinator/resolution.rb +14 -18
- data/lib/phronomy/agent/recovery/recovery_coordinator.rb +5 -9
- data/lib/phronomy/agent/recovery/recovery_support.rb +2 -8
- data/lib/phronomy/agent/stream_callback_error.rb +2 -2
- data/lib/phronomy/common/error.rb +65 -1
- data/lib/phronomy/content_store/base.rb +7 -2
- data/lib/phronomy/context/assembly.rb +11 -18
- data/lib/phronomy/embeddings/authentication_error.rb +11 -0
- data/lib/phronomy/embeddings/backends/ruby_llm_embeddings.rb +5 -1
- data/lib/phronomy/embeddings/rate_limit_error.rb +11 -0
- data/lib/phronomy/execution/execution_cancellation_error.rb +2 -2
- data/lib/phronomy/execution/execution_timeout_error.rb +2 -2
- data/lib/phronomy/execution/task_result.rb +7 -2
- data/lib/phronomy/filter/filter_block_error.rb +2 -2
- data/lib/phronomy/generation/low_confidence_error.rb +2 -2
- data/lib/phronomy/integrations/ordered_event_delivery.rb +208 -0
- data/lib/phronomy/multi_agent/persistence/team_execution_repository.rb +5 -5
- data/lib/phronomy/multi_agent/team_busy_error.rb +18 -0
- data/lib/phronomy/multi_agent/team_coordinator.rb +20 -7
- data/lib/phronomy/multi_agent/team_not_found_error.rb +18 -0
- data/lib/phronomy/storage/condition_failed_error.rb +2 -2
- data/lib/phronomy/storage/unique_constraint_error.rb +2 -2
- data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +1 -1
- data/lib/phronomy/tool/base.rb +25 -23
- data/lib/phronomy/tracing/base.rb +35 -6
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +4 -29
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/execution/workflow.rb +30 -2
- data/lib/phronomy/workflow/execution/workflow_execution_environment.rb +1 -0
- data/lib/phronomy/workflow/execution/workflow_execution_registry.rb +74 -3
- data/lib/phronomy/workflow/execution/workflow_runner.rb +48 -14
- data/lib/phronomy/workflow/execution/workflow_task_execution.rb +153 -0
- data/lib/phronomy/workflow/execution/workflow_task_reception.rb +72 -0
- data/lib/phronomy/workflow/execution/workflow_task_resume_error.rb +14 -0
- data/lib/phronomy/workflow/runtime_binding/workflow_engine_environment.rb +26 -3
- data/lib/phronomy/workflow/runtime_binding/workflow_phase_machine_builder.rb +3 -1
- data/lib/phronomy/workflow/runtime_binding/workflow_task_session.rb +76 -0
- data/lib/phronomy.rb +6 -0
- data/scripts/api_snapshot.rb +12 -0
- data/scripts/check_readme_ruby.rb +24 -29
- data/scripts/check_readme_runnable.rb +92 -81
- data/sig/phronomy/agent/agent_not_found_error.rbs +6 -0
- data/sig/phronomy/agent/agent_unloaded_error.rbs +6 -0
- data/sig/phronomy/agent/stream_callback_error.rbs +8 -0
- data/sig/phronomy/agent.rbs +18 -0
- data/sig/phronomy/embeddings/base.rbs +6 -0
- data/sig/phronomy/execution/execution.rbs +2 -2
- data/sig/phronomy/extensions.rbs +1 -0
- data/sig/phronomy/filter/filter_block_error.rbs +6 -0
- data/sig/phronomy/generation/low_confidence_error.rbs +6 -0
- data/sig/phronomy/integrations.rbs +14 -0
- data/sig/phronomy/multi_agent/team_busy_error.rbs +8 -0
- data/sig/phronomy/multi_agent/team_not_found_error.rbs +8 -0
- data/sig/phronomy/storage/contracts.rbs +2 -2
- data/sig/phronomy/workflow/execution/workflow_execution_environment.rbs +1 -0
- data/sig/phronomy/workflow.rbs +4 -0
- data/sig/phronomy.rbs +4 -0
- metadata +49 -2
|
@@ -53,38 +53,13 @@ module Phronomy
|
|
|
53
53
|
span.finish
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
# automatically inherit the current span as their parent, establishing the
|
|
59
|
-
# correct parent/child hierarchy in the trace tree.
|
|
60
|
-
#
|
|
61
|
-
# @param name [String] span name
|
|
62
|
-
# @param input [Object, nil] input to record as a span attribute
|
|
63
|
-
# @param meta [Hash] additional metadata (e.g. task_id, user_id)
|
|
64
|
-
# @yield [span] the active OTel span
|
|
65
|
-
# @return [Object] the block's return value
|
|
56
|
+
# Preserve the backend context while sharing C15's exactly-once finish
|
|
57
|
+
# and primary-error protection. Context affinity remains backend-owned.
|
|
66
58
|
# @api private
|
|
67
59
|
def trace(name, input: nil, **meta)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
meta.each { |k, v| attrs["phronomy.#{k}"] = v.to_s unless v.nil? }
|
|
71
|
-
|
|
72
|
-
result = nil
|
|
73
|
-
@otel_tracer.in_span(name, attributes: attrs) do |span|
|
|
74
|
-
result, usage = yield span
|
|
75
|
-
span.set_attribute("phronomy.output", result.to_s) if result
|
|
76
|
-
if usage
|
|
77
|
-
span.set_attribute("llm.usage.input_tokens", usage.input)
|
|
78
|
-
span.set_attribute("llm.usage.output_tokens", usage.output)
|
|
79
|
-
total = (usage.input || 0) + (usage.output || 0)
|
|
80
|
-
span.set_attribute("llm.usage.total_tokens", total)
|
|
81
|
-
end
|
|
82
|
-
rescue => e
|
|
83
|
-
span.record_exception(e)
|
|
84
|
-
span.status = OpenTelemetry::Trace::Status.error(e.message)
|
|
85
|
-
raise
|
|
60
|
+
Base.trace_with(self, name, attributes: {input: input, **meta.compact}) do |span|
|
|
61
|
+
OpenTelemetry::Trace.with_span(span) { yield span }
|
|
86
62
|
end
|
|
87
|
-
result
|
|
88
63
|
end
|
|
89
64
|
end
|
|
90
65
|
end
|
data/lib/phronomy/version.rb
CHANGED
|
@@ -85,6 +85,7 @@ module Phronomy
|
|
|
85
85
|
@exit_actions = {}
|
|
86
86
|
@transitions = []
|
|
87
87
|
@wait_state_names = []
|
|
88
|
+
@task_states = {}
|
|
88
89
|
end
|
|
89
90
|
|
|
90
91
|
def initial(state_name) # rubocop:disable Style/TrivialAccessors
|
|
@@ -94,9 +95,10 @@ module Phronomy
|
|
|
94
95
|
# Declares a Workflow state.
|
|
95
96
|
#
|
|
96
97
|
# Entry actions are synchronous Run-to-Completion callbacks. To start
|
|
97
|
-
# asynchronous work
|
|
98
|
-
# return the context or nil
|
|
98
|
+
# asynchronous work owned by this state, use task_state. Ordinary actions
|
|
99
|
+
# return the context or nil; returning Phronomy::TaskResult is an error.
|
|
99
100
|
def state(name, action: nil)
|
|
101
|
+
reject_task_state_overlap!(name)
|
|
100
102
|
@declared_states << name
|
|
101
103
|
entry(name, action) if action
|
|
102
104
|
end
|
|
@@ -110,9 +112,30 @@ module Phronomy
|
|
|
110
112
|
end
|
|
111
113
|
|
|
112
114
|
def wait_state(name)
|
|
115
|
+
reject_task_state_overlap!(name)
|
|
113
116
|
@wait_state_names << name
|
|
114
117
|
end
|
|
115
118
|
|
|
119
|
+
# Starts one asynchronous operation on state entry and advances only
|
|
120
|
+
# after its successful result has been applied on the execution owner.
|
|
121
|
+
# Task completion is correlated to this state entry, not a durable ID.
|
|
122
|
+
# @param name [Symbol] state name
|
|
123
|
+
# @param start [#call] short callback accepting a context snapshot and returning TaskResult
|
|
124
|
+
# @param apply [#call] short callback accepting current context and value; returns context or nil
|
|
125
|
+
# @return [void]
|
|
126
|
+
# @api public
|
|
127
|
+
def task_state(name, start:, apply:)
|
|
128
|
+
unless name.is_a?(Symbol) && start.respond_to?(:call) && apply.respond_to?(:call)
|
|
129
|
+
raise ArgumentError, "task_state requires a Symbol name and callable start/apply"
|
|
130
|
+
end
|
|
131
|
+
if @declared_states.include?(name) || @wait_state_names.include?(name)
|
|
132
|
+
raise ArgumentError, "Workflow state #{name.inspect} is already declared"
|
|
133
|
+
end
|
|
134
|
+
@declared_states << name
|
|
135
|
+
@task_states[name] = {start: start, apply: apply}.freeze
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
116
139
|
# Declares a transition.
|
|
117
140
|
#
|
|
118
141
|
# Guards and actions may accept either (context) or (context, event).
|
|
@@ -167,6 +190,7 @@ module Phronomy
|
|
|
167
190
|
external_events: external_events,
|
|
168
191
|
entry_point: @initial || @declared_states.first,
|
|
169
192
|
wait_state_names: @wait_state_names.dup,
|
|
193
|
+
task_states: @task_states.dup.freeze,
|
|
170
194
|
persistence: @persistence
|
|
171
195
|
)
|
|
172
196
|
Workflow.new(runner)
|
|
@@ -174,6 +198,10 @@ module Phronomy
|
|
|
174
198
|
|
|
175
199
|
private
|
|
176
200
|
|
|
201
|
+
def reject_task_state_overlap!(name)
|
|
202
|
+
raise ArgumentError, "Workflow task_state #{name.inspect} is already declared" if @task_states.key?(name)
|
|
203
|
+
end
|
|
204
|
+
|
|
177
205
|
def validate_graph!
|
|
178
206
|
all_states = (@declared_states + @wait_state_names).uniq
|
|
179
207
|
entry_point = @initial || @declared_states.first
|
|
@@ -23,5 +23,6 @@ module Phronomy
|
|
|
23
23
|
def compile_transitions(**definition) = raise(NotImplementedError)
|
|
24
24
|
def build_session(**options) = raise(NotImplementedError)
|
|
25
25
|
def register_session(session, completion:) = raise(NotImplementedError)
|
|
26
|
+
def subscribe_cancellation(token, &callback) = raise(NotImplementedError)
|
|
26
27
|
end
|
|
27
28
|
end
|
|
@@ -12,10 +12,14 @@ module Phronomy
|
|
|
12
12
|
:workflow_instance_id, :owner_token, :fsm_session_id, :state
|
|
13
13
|
)
|
|
14
14
|
private_constant :WorkflowAdmission
|
|
15
|
+
StopTaskExecutions = Data.define(:error)
|
|
16
|
+
private_constant :StopTaskExecutions
|
|
15
17
|
|
|
16
18
|
def initialize(channel:)
|
|
17
19
|
super
|
|
18
20
|
@workflow_admissions = {}
|
|
21
|
+
@task_executions = {}
|
|
22
|
+
@draining = false
|
|
19
23
|
end
|
|
20
24
|
|
|
21
25
|
def post(command, admission: false, completion: nil)
|
|
@@ -24,7 +28,18 @@ module Phronomy
|
|
|
24
28
|
|
|
25
29
|
def deliver(command)
|
|
26
30
|
assert_event_loop_thread!
|
|
27
|
-
command
|
|
31
|
+
case command
|
|
32
|
+
when WorkflowTaskReception::Notification
|
|
33
|
+
task_execution_for(command.reception)&.receive(command)
|
|
34
|
+
when WorkflowTaskReception::DeliveryFailure
|
|
35
|
+
task_execution_for(command.reception)&.delivery_failed(command.reception, command.error)
|
|
36
|
+
when WorkflowTaskReception::Cancellation
|
|
37
|
+
task_execution_for(command.reception)&.receive_cancellation(command.reception)
|
|
38
|
+
when StopTaskExecutions
|
|
39
|
+
synchronize { @task_executions.values.dup }.each { |execution| execution.stop(command.error) }
|
|
40
|
+
else
|
|
41
|
+
command.runner.deliver_on_event_loop(command)
|
|
42
|
+
end
|
|
28
43
|
end
|
|
29
44
|
|
|
30
45
|
def idle?
|
|
@@ -37,7 +52,49 @@ module Phronomy
|
|
|
37
52
|
end
|
|
38
53
|
|
|
39
54
|
def shutdown(error:)
|
|
40
|
-
|
|
55
|
+
executions = synchronize do
|
|
56
|
+
@workflow_admissions.clear
|
|
57
|
+
values = @task_executions.values
|
|
58
|
+
@task_executions.clear
|
|
59
|
+
values
|
|
60
|
+
end
|
|
61
|
+
failure = error || Phronomy::RuntimeShutdownError.new("Runtime stopped Workflow task execution")
|
|
62
|
+
executions.each { |execution| execution.retire(failure) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Called under Runtime's lifecycle lock. Only queue owner-side decisions;
|
|
66
|
+
# do not call application code or acquire Runtime again from here.
|
|
67
|
+
def begin_draining
|
|
68
|
+
first = synchronize do
|
|
69
|
+
next false if @draining
|
|
70
|
+
@draining = true
|
|
71
|
+
end
|
|
72
|
+
post(StopTaskExecutions.new(error: Phronomy::RuntimeShutdownError.new("Runtime stopped Workflow task execution"))) if first
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def wait_until_idle(_deadline)
|
|
76
|
+
# EventLoop's existing admission/delivery accounting owns the drain wait.
|
|
77
|
+
true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def draining?
|
|
81
|
+
synchronize { @draining }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def bind_task_execution(execution)
|
|
85
|
+
assert_event_loop_thread!
|
|
86
|
+
synchronize { @task_executions[execution.owner_token] = execution }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def task_delivery_failed(reception, error)
|
|
90
|
+
return unless reception.active? && task_execution_for(reception)
|
|
91
|
+
|
|
92
|
+
failure = WorkflowTaskReception::DeliveryFailure.new(reception: reception, error: error)
|
|
93
|
+
return if post(failure)
|
|
94
|
+
|
|
95
|
+
fail_task_observer(reception, error)
|
|
96
|
+
rescue => delivery_error
|
|
97
|
+
fail_task_observer(reception, error || delivery_error)
|
|
41
98
|
end
|
|
42
99
|
|
|
43
100
|
# Reserves one logical Workflow execution segment before durable load or
|
|
@@ -121,13 +178,17 @@ module Phronomy
|
|
|
121
178
|
def release_workflow(workflow_instance_id, owner_token:)
|
|
122
179
|
assert_event_loop_thread!
|
|
123
180
|
key = workflow_instance_id.to_s
|
|
124
|
-
|
|
181
|
+
execution = nil
|
|
182
|
+
released = synchronize do
|
|
125
183
|
current = @workflow_admissions[key]
|
|
126
184
|
next false unless current&.owner_token&.equal?(owner_token)
|
|
127
185
|
|
|
128
186
|
@workflow_admissions.delete(key)
|
|
187
|
+
execution = @task_executions.delete(owner_token)
|
|
129
188
|
true
|
|
130
189
|
end
|
|
190
|
+
execution&.close
|
|
191
|
+
released
|
|
131
192
|
end
|
|
132
193
|
|
|
133
194
|
# Read-only diagnostics used by internal tests and routing assertions.
|
|
@@ -160,6 +221,16 @@ module Phronomy
|
|
|
160
221
|
|
|
161
222
|
private
|
|
162
223
|
|
|
224
|
+
def task_execution_for(reception)
|
|
225
|
+
synchronize { @task_executions[reception.owner_token] }
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def fail_task_observer(reception, error)
|
|
229
|
+
# A fully unavailable owner cannot safely release admission or claim a
|
|
230
|
+
# durable terminal result. Settle observation only; shutdown owns cleanup.
|
|
231
|
+
task_execution_for(reception)&.result_task&.fail(error) if reception.active?
|
|
232
|
+
end
|
|
233
|
+
|
|
163
234
|
def mark_workflow_recovery_required_for_session(fsm_session_id)
|
|
164
235
|
synchronize do
|
|
165
236
|
key, admission = @workflow_admissions.find do |_workflow_instance_id, candidate|
|
|
@@ -29,8 +29,13 @@ module Phronomy
|
|
|
29
29
|
:recursion_limit,
|
|
30
30
|
:repository,
|
|
31
31
|
:persist,
|
|
32
|
-
:expected_revision
|
|
33
|
-
|
|
32
|
+
:expected_revision,
|
|
33
|
+
:cancellation_token
|
|
34
|
+
) do
|
|
35
|
+
def initialize(cancellation_token: nil, **fields)
|
|
36
|
+
super(**fields, cancellation_token: cancellation_token)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
34
39
|
|
|
35
40
|
# API/callback -> EventLoop control messages.
|
|
36
41
|
StartCommand = Data.define(
|
|
@@ -79,6 +84,7 @@ module Phronomy
|
|
|
79
84
|
entry_point:,
|
|
80
85
|
exit_actions: {},
|
|
81
86
|
wait_state_names: [],
|
|
87
|
+
task_states: {},
|
|
82
88
|
persistence: nil
|
|
83
89
|
)
|
|
84
90
|
@state_class = state_class
|
|
@@ -90,6 +96,7 @@ module Phronomy
|
|
|
90
96
|
@external_events = external_events
|
|
91
97
|
@entry_point = entry_point
|
|
92
98
|
@wait_state_names = wait_state_names
|
|
99
|
+
@task_states = task_states
|
|
93
100
|
@persistence = persistence
|
|
94
101
|
@environment = WorkflowExecutionEnvironment.current
|
|
95
102
|
@phase_machine_class = @environment.compile_transitions(
|
|
@@ -126,6 +133,7 @@ module Phronomy
|
|
|
126
133
|
def send_event(state:, event:, input: nil)
|
|
127
134
|
ensure_blocking_call_allowed!(:send_event, :signal)
|
|
128
135
|
current_phase = state.phase.to_sym
|
|
136
|
+
reject_task_resume!(current_phase)
|
|
129
137
|
event_name = resolve_resume_event(current_phase, event)
|
|
130
138
|
workflow_instance_id = state.workflow_instance_id
|
|
131
139
|
unless workflow_instance_id
|
|
@@ -300,7 +308,8 @@ module Phronomy
|
|
|
300
308
|
repository: repository,
|
|
301
309
|
persist: false,
|
|
302
310
|
loaded_snapshot: nil,
|
|
303
|
-
expected_revision: nil
|
|
311
|
+
expected_revision: nil,
|
|
312
|
+
cancellation_token: request.config[:cancellation_token]
|
|
304
313
|
)
|
|
305
314
|
register_execution(
|
|
306
315
|
execution,
|
|
@@ -344,7 +353,8 @@ module Phronomy
|
|
|
344
353
|
recursion_limit: Phronomy::WorkflowSettings.current.recursion_limit,
|
|
345
354
|
repository: nil,
|
|
346
355
|
persist: false,
|
|
347
|
-
expected_revision: nil
|
|
356
|
+
expected_revision: nil,
|
|
357
|
+
cancellation_token: nil
|
|
348
358
|
)
|
|
349
359
|
register_execution(
|
|
350
360
|
execution,
|
|
@@ -416,7 +426,8 @@ module Phronomy
|
|
|
416
426
|
repository: ready.result.repository,
|
|
417
427
|
persist: true,
|
|
418
428
|
loaded_snapshot: ready.result.snapshot,
|
|
419
|
-
expected_revision: ready.result.revision
|
|
429
|
+
expected_revision: ready.result.revision,
|
|
430
|
+
cancellation_token: request.config[:cancellation_token]
|
|
420
431
|
)
|
|
421
432
|
register_execution(
|
|
422
433
|
execution,
|
|
@@ -447,7 +458,8 @@ module Phronomy
|
|
|
447
458
|
recursion_limit: Phronomy::WorkflowSettings.current.recursion_limit,
|
|
448
459
|
repository: ready.result.repository,
|
|
449
460
|
persist: true,
|
|
450
|
-
expected_revision: expected_revision
|
|
461
|
+
expected_revision: expected_revision,
|
|
462
|
+
cancellation_token: nil
|
|
451
463
|
)
|
|
452
464
|
register_execution(
|
|
453
465
|
execution,
|
|
@@ -467,8 +479,11 @@ module Phronomy
|
|
|
467
479
|
repository:,
|
|
468
480
|
persist:,
|
|
469
481
|
loaded_snapshot:,
|
|
470
|
-
expected_revision
|
|
482
|
+
expected_revision:,
|
|
483
|
+
cancellation_token: nil
|
|
471
484
|
)
|
|
485
|
+
stored_phase = loaded_snapshot && (loaded_snapshot[:phase] || loaded_snapshot["phase"])
|
|
486
|
+
reject_task_resume!(stored_phase&.to_sym)
|
|
472
487
|
stored_fields = loaded_snapshot &&
|
|
473
488
|
(loaded_snapshot[:fields] || loaded_snapshot["fields"])
|
|
474
489
|
initial_fields = if stored_fields
|
|
@@ -489,7 +504,8 @@ module Phronomy
|
|
|
489
504
|
recursion_limit: recursion_limit,
|
|
490
505
|
repository: repository,
|
|
491
506
|
persist: persist,
|
|
492
|
-
expected_revision: expected_revision
|
|
507
|
+
expected_revision: expected_revision,
|
|
508
|
+
cancellation_token: cancellation_token
|
|
493
509
|
)
|
|
494
510
|
end
|
|
495
511
|
|
|
@@ -563,12 +579,19 @@ module Phronomy
|
|
|
563
579
|
stable_observer: nil
|
|
564
580
|
)
|
|
565
581
|
assert_execution_owner!
|
|
582
|
+
task_execution = unless @task_states.empty?
|
|
583
|
+
WorkflowTaskExecution.new(definitions: @task_states, registry: @environment.registry,
|
|
584
|
+
environment: @environment, owner_token: execution.owner_token, result_task: result_task,
|
|
585
|
+
cancellation_token: execution.cancellation_token)
|
|
586
|
+
end
|
|
566
587
|
session = build_session_for(
|
|
567
588
|
execution: execution,
|
|
568
589
|
resume_event: resume_event,
|
|
569
590
|
resume_phase: resume_phase,
|
|
570
|
-
stable_observer: stable_observer
|
|
591
|
+
stable_observer: stable_observer,
|
|
592
|
+
task_execution: task_execution
|
|
571
593
|
)
|
|
594
|
+
@environment.registry.bind_task_execution(task_execution) if task_execution
|
|
572
595
|
@environment.registry.bind_workflow_session(
|
|
573
596
|
execution.workflow_instance_id,
|
|
574
597
|
owner_token: execution.owner_token,
|
|
@@ -581,7 +604,8 @@ module Phronomy
|
|
|
581
604
|
execution: execution,
|
|
582
605
|
result_task: result_task,
|
|
583
606
|
result: result,
|
|
584
|
-
error: error
|
|
607
|
+
error: error,
|
|
608
|
+
task_execution: task_execution
|
|
585
609
|
)
|
|
586
610
|
end
|
|
587
611
|
@environment.register_session(session, completion: source_task)
|
|
@@ -598,13 +622,17 @@ module Phronomy
|
|
|
598
622
|
|
|
599
623
|
# Called from source_task completion on EventLoop, after the FSMSession has
|
|
600
624
|
# accepted the durable terminal result (when a durable barrier is required).
|
|
601
|
-
def finalize_execution(execution:, result_task:, result:, error:)
|
|
625
|
+
def finalize_execution(execution:, result_task:, result:, error:, task_execution: nil)
|
|
602
626
|
assert_execution_owner!
|
|
603
627
|
@environment.registry.release_workflow(
|
|
604
628
|
execution.workflow_instance_id,
|
|
605
629
|
owner_token: execution.owner_token
|
|
606
630
|
)
|
|
607
|
-
error ?
|
|
631
|
+
if error && task_execution&.cancelled_error&.equal?(error)
|
|
632
|
+
result_task.cancel!(error)
|
|
633
|
+
else
|
|
634
|
+
error ? fail_task(result_task, error) : complete_task(result_task, result)
|
|
635
|
+
end
|
|
608
636
|
end
|
|
609
637
|
|
|
610
638
|
def begin_terminal_persistence_on_event_loop(
|
|
@@ -741,7 +769,8 @@ module Phronomy
|
|
|
741
769
|
execution:,
|
|
742
770
|
resume_event: nil,
|
|
743
771
|
resume_phase: nil,
|
|
744
|
-
stable_observer: nil
|
|
772
|
+
stable_observer: nil,
|
|
773
|
+
task_execution: nil
|
|
745
774
|
)
|
|
746
775
|
@environment.build_session(
|
|
747
776
|
context: execution.context,
|
|
@@ -759,7 +788,8 @@ module Phronomy
|
|
|
759
788
|
resume_event: resume_event,
|
|
760
789
|
resume_phase: resume_phase,
|
|
761
790
|
stable_observer: stable_observer,
|
|
762
|
-
persist: terminal_persistence_callback(execution)
|
|
791
|
+
persist: terminal_persistence_callback(execution),
|
|
792
|
+
task_execution: task_execution
|
|
763
793
|
)
|
|
764
794
|
end
|
|
765
795
|
|
|
@@ -798,6 +828,10 @@ module Phronomy
|
|
|
798
828
|
"No external event registered for state #{current_phase.inspect}"
|
|
799
829
|
end
|
|
800
830
|
|
|
831
|
+
def reject_task_resume!(phase)
|
|
832
|
+
raise Phronomy::WorkflowTaskResumeError if phase && @task_states.key?(phase)
|
|
833
|
+
end
|
|
834
|
+
|
|
801
835
|
def post_control(command)
|
|
802
836
|
admission = command.is_a?(StartCommand) || command.is_a?(ResumeCommand)
|
|
803
837
|
completion = admission ? command.result_task : command.request.result_task
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# C02 owns which state admission may consume a TaskResult. The injected
|
|
5
|
+
# connection applies context/terminal decisions to the concrete state machine.
|
|
6
|
+
# @api private
|
|
7
|
+
class WorkflowTaskExecution
|
|
8
|
+
attr_reader :result_task, :owner_token, :cancelled_error
|
|
9
|
+
|
|
10
|
+
def initialize(definitions:, registry:, environment:, owner_token:, result_task:, cancellation_token: nil)
|
|
11
|
+
unless cancellation_token.nil? || cancellation_token.is_a?(Phronomy::Concurrency::CancellationToken)
|
|
12
|
+
raise TypeError, "cancellation_token must be a Phronomy::Concurrency::CancellationToken or nil"
|
|
13
|
+
end
|
|
14
|
+
@definitions = definitions
|
|
15
|
+
@registry = registry
|
|
16
|
+
@environment = environment
|
|
17
|
+
@owner_token = owner_token
|
|
18
|
+
@result_task = result_task
|
|
19
|
+
@cancellation_token = cancellation_token
|
|
20
|
+
@connection = nil
|
|
21
|
+
@reception = nil
|
|
22
|
+
@state_name = nil
|
|
23
|
+
@applied = false
|
|
24
|
+
@closed = false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def bind(connection)
|
|
28
|
+
@connection = connection
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def activate
|
|
32
|
+
if @registry.draining?
|
|
33
|
+
stop(Phronomy::RuntimeShutdownError.new("Runtime is stopping Workflow task execution"))
|
|
34
|
+
elsif @cancellation_token&.cancelled?
|
|
35
|
+
cancel
|
|
36
|
+
elsif @cancellation_token
|
|
37
|
+
@cancel_reception = WorkflowTaskReception.new(registry: @registry, owner_token: @owner_token)
|
|
38
|
+
@cancel_subscription = @environment.subscribe_cancellation(
|
|
39
|
+
@cancellation_token, &@cancel_reception.method(:cancelled)
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Called after synchronous entry. true suppresses automatic completion.
|
|
45
|
+
def enter(state_name, context)
|
|
46
|
+
if @state_name != state_name
|
|
47
|
+
leave
|
|
48
|
+
@state_name = state_name
|
|
49
|
+
@entry_key = Object.new.freeze
|
|
50
|
+
end
|
|
51
|
+
return false unless @definitions.key?(state_name)
|
|
52
|
+
return !@applied if @reception || @applied
|
|
53
|
+
|
|
54
|
+
@reception = WorkflowTaskReception.new(registry: @registry, owner_token: @owner_token)
|
|
55
|
+
snapshot = context.merge({})
|
|
56
|
+
snapshot.set_graph_metadata(phase: state_name)
|
|
57
|
+
source = @definitions.fetch(state_name).fetch(:start).call(snapshot)
|
|
58
|
+
unless source.is_a?(Phronomy::TaskResult)
|
|
59
|
+
raise TypeError, "task_state #{state_name.inspect} start must return Phronomy::TaskResult"
|
|
60
|
+
end
|
|
61
|
+
@reception.watch(source)
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Queued automatic progression must belong to the same entry as the
|
|
66
|
+
# applied result, even if an external transition wins before dispatch.
|
|
67
|
+
def completion_key
|
|
68
|
+
return if @closed || (@definitions.key?(@state_name) && !@applied)
|
|
69
|
+
|
|
70
|
+
@entry_key
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def permits_completion?(key)
|
|
74
|
+
key&.equal?(completion_key)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def receive(notification)
|
|
78
|
+
return unless current?(notification.reception)
|
|
79
|
+
|
|
80
|
+
definition = @definitions.fetch(@state_name)
|
|
81
|
+
@reception.close
|
|
82
|
+
@reception = nil
|
|
83
|
+
case notification.status
|
|
84
|
+
when :completed
|
|
85
|
+
context = @connection.context
|
|
86
|
+
updated = definition.fetch(:apply).call(context, notification.value)
|
|
87
|
+
unless updated.nil? || updated.is_a?(context.class)
|
|
88
|
+
raise TypeError, "task_state #{@state_name.inspect} apply must return its WorkflowContext or nil"
|
|
89
|
+
end
|
|
90
|
+
@applied = true
|
|
91
|
+
@connection.apply_task_context(updated || context)
|
|
92
|
+
when :cancelled
|
|
93
|
+
cancel(notification.error)
|
|
94
|
+
when :failed
|
|
95
|
+
stop(notification.error)
|
|
96
|
+
else
|
|
97
|
+
raise TypeError, "task_state received nonterminal TaskResult status #{notification.status.inspect}"
|
|
98
|
+
end
|
|
99
|
+
rescue => error
|
|
100
|
+
stop(error)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def delivery_failed(reception, error)
|
|
104
|
+
stop(error) if current?(reception) || (!@closed && reception.equal?(@cancel_reception))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def receive_cancellation(reception)
|
|
108
|
+
cancel if !@closed && reception.equal?(@cancel_reception)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def cancel(error = nil)
|
|
112
|
+
return if @closed
|
|
113
|
+
|
|
114
|
+
@cancelled_error = error || Phronomy::CancellationError.new("Workflow task execution cancelled")
|
|
115
|
+
stop(@cancelled_error)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def stop(error)
|
|
119
|
+
@connection&.fail_task_execution(error) unless @closed
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def leave
|
|
123
|
+
@reception&.close
|
|
124
|
+
@reception = nil
|
|
125
|
+
@state_name = nil
|
|
126
|
+
@entry_key = nil
|
|
127
|
+
@applied = false
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def close
|
|
131
|
+
return if @closed
|
|
132
|
+
|
|
133
|
+
@closed = true
|
|
134
|
+
leave
|
|
135
|
+
@cancel_reception&.close
|
|
136
|
+
@cancel_subscription&.close
|
|
137
|
+
@cancel_reception = @cancel_subscription = @cancellation_token = nil
|
|
138
|
+
@connection = nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Unknown durable outcomes retain admission, but are still observable.
|
|
142
|
+
def retire(error)
|
|
143
|
+
close
|
|
144
|
+
@result_task.fail(error)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
def current?(reception)
|
|
150
|
+
!@closed && reception.equal?(@reception) && @connection.current_state == @state_name
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# A revocable, process-local completion address. The callback retains only
|
|
5
|
+
# this address; closing it drops its source and receiver, not the source work.
|
|
6
|
+
# @api private
|
|
7
|
+
class WorkflowTaskReception
|
|
8
|
+
Notification = Data.define(:reception, :status, :value, :error)
|
|
9
|
+
DeliveryFailure = Data.define(:reception, :error)
|
|
10
|
+
Cancellation = Data.define(:reception)
|
|
11
|
+
|
|
12
|
+
attr_reader :owner_token
|
|
13
|
+
|
|
14
|
+
def initialize(registry:, owner_token:)
|
|
15
|
+
@registry = registry
|
|
16
|
+
@owner_token = owner_token
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
@source = nil
|
|
19
|
+
@notified = false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def watch(source)
|
|
23
|
+
@mutex.synchronize { @source = source if @registry }
|
|
24
|
+
source.on_complete(&method(:completed))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def completed(value, error)
|
|
28
|
+
registry, source = claim
|
|
29
|
+
return unless registry
|
|
30
|
+
|
|
31
|
+
deliver(registry, Notification.new(reception: self,
|
|
32
|
+
status: source.status, value: value, error: error))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def cancelled
|
|
36
|
+
registry, = claim
|
|
37
|
+
deliver(registry, Cancellation.new(reception: self)) if registry
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def close
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
@registry = nil
|
|
43
|
+
@source = nil
|
|
44
|
+
end
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def active?
|
|
49
|
+
@mutex.synchronize { !@registry.nil? }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def claim
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
return [nil, nil] if @notified || !@registry
|
|
57
|
+
|
|
58
|
+
@notified = true
|
|
59
|
+
[@registry, @source]
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def deliver(registry, command)
|
|
64
|
+
return if registry.post(command)
|
|
65
|
+
|
|
66
|
+
registry.task_delivery_failed(self,
|
|
67
|
+
Phronomy::RuntimeShutdownError.new("Runtime rejected Workflow task completion"))
|
|
68
|
+
rescue => error
|
|
69
|
+
registry.task_delivery_failed(self, error)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../common/error"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
# A live task wait cannot be reconstructed from a Workflow state snapshot.
|
|
7
|
+
# @api public
|
|
8
|
+
class WorkflowTaskResumeError < Error
|
|
9
|
+
# @api public
|
|
10
|
+
def initialize(message = "Cannot resume a pending Workflow task_state; explicit reconciliation is required", code: "workflow.task_resume_unsupported")
|
|
11
|
+
super
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|