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.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +20 -5
  3. data/docs/application-recipes.md +229 -0
  4. data/docs/architecture/dx05-agent-scope.md +119 -0
  5. data/docs/architecture/dx05-workflow-task-state.md +134 -0
  6. data/docs/architecture/error-api-review.md +245 -0
  7. data/docs/failure-reason-codes.md +45 -0
  8. data/docs/features.md +4 -1
  9. data/docs/getting-started.md +44 -43
  10. data/docs/ordered-event-delivery.md +54 -0
  11. data/docs/workflow-completion.md +92 -0
  12. data/gemfiles/http_minimum.gemfile +5 -0
  13. data/lib/phronomy/agent/agent_not_found_error.rb +16 -0
  14. data/lib/phronomy/agent/agent_unloaded_error.rb +14 -0
  15. data/lib/phronomy/agent/api/agent.rb +13 -0
  16. data/lib/phronomy/agent/async_event_api.rb +111 -87
  17. data/lib/phronomy/agent/base.rb +74 -43
  18. data/lib/phronomy/agent/before_llm_input.rb +7 -1
  19. data/lib/phronomy/agent/execution/exact_execution.rb +11 -9
  20. data/lib/phronomy/agent/execution/execution_coordinator.rb +16 -13
  21. data/lib/phronomy/agent/execution/execution_outcome_committer.rb +1 -1
  22. data/lib/phronomy/agent/execution/initial_preparation.rb +1 -4
  23. data/lib/phronomy/agent/execution/runtime_record_encoder.rb +1 -4
  24. data/lib/phronomy/agent/filterable.rb +21 -15
  25. data/lib/phronomy/agent/lifecycle/lifetime_api.rb +76 -0
  26. data/lib/phronomy/agent/lifecycle/operation_lease.rb +33 -0
  27. data/lib/phronomy/agent/lifecycle/operation_result.rb +53 -0
  28. data/lib/phronomy/agent/lifecycle/ownership_registry.rb +99 -8
  29. data/lib/phronomy/agent/lifecycle/scope.rb +198 -0
  30. data/lib/phronomy/agent/lifecycle/scope_cleanup_error.rb +19 -0
  31. data/lib/phronomy/agent/lifecycle/unload_transition.rb +78 -0
  32. data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +3 -3
  33. data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +7 -7
  34. data/lib/phronomy/agent/recovery/recovery_coordinator/resolution.rb +14 -18
  35. data/lib/phronomy/agent/recovery/recovery_coordinator.rb +5 -9
  36. data/lib/phronomy/agent/recovery/recovery_support.rb +2 -8
  37. data/lib/phronomy/agent/stream_callback_error.rb +2 -2
  38. data/lib/phronomy/common/error.rb +65 -1
  39. data/lib/phronomy/content_store/base.rb +7 -2
  40. data/lib/phronomy/context/assembly.rb +11 -18
  41. data/lib/phronomy/embeddings/authentication_error.rb +11 -0
  42. data/lib/phronomy/embeddings/backends/ruby_llm_embeddings.rb +5 -1
  43. data/lib/phronomy/embeddings/rate_limit_error.rb +11 -0
  44. data/lib/phronomy/execution/execution_cancellation_error.rb +2 -2
  45. data/lib/phronomy/execution/execution_timeout_error.rb +2 -2
  46. data/lib/phronomy/execution/task_result.rb +7 -2
  47. data/lib/phronomy/filter/filter_block_error.rb +2 -2
  48. data/lib/phronomy/generation/low_confidence_error.rb +2 -2
  49. data/lib/phronomy/integrations/ordered_event_delivery.rb +208 -0
  50. data/lib/phronomy/multi_agent/persistence/team_execution_repository.rb +5 -5
  51. data/lib/phronomy/multi_agent/team_busy_error.rb +18 -0
  52. data/lib/phronomy/multi_agent/team_coordinator.rb +20 -7
  53. data/lib/phronomy/multi_agent/team_not_found_error.rb +18 -0
  54. data/lib/phronomy/storage/condition_failed_error.rb +2 -2
  55. data/lib/phronomy/storage/unique_constraint_error.rb +2 -2
  56. data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +1 -1
  57. data/lib/phronomy/tool/base.rb +25 -23
  58. data/lib/phronomy/tracing/base.rb +35 -6
  59. data/lib/phronomy/tracing/open_telemetry_tracer.rb +4 -29
  60. data/lib/phronomy/version.rb +1 -1
  61. data/lib/phronomy/workflow/execution/workflow.rb +30 -2
  62. data/lib/phronomy/workflow/execution/workflow_execution_environment.rb +1 -0
  63. data/lib/phronomy/workflow/execution/workflow_execution_registry.rb +74 -3
  64. data/lib/phronomy/workflow/execution/workflow_runner.rb +48 -14
  65. data/lib/phronomy/workflow/execution/workflow_task_execution.rb +153 -0
  66. data/lib/phronomy/workflow/execution/workflow_task_reception.rb +72 -0
  67. data/lib/phronomy/workflow/execution/workflow_task_resume_error.rb +14 -0
  68. data/lib/phronomy/workflow/runtime_binding/workflow_engine_environment.rb +26 -3
  69. data/lib/phronomy/workflow/runtime_binding/workflow_phase_machine_builder.rb +3 -1
  70. data/lib/phronomy/workflow/runtime_binding/workflow_task_session.rb +76 -0
  71. data/lib/phronomy.rb +6 -0
  72. data/scripts/api_snapshot.rb +12 -0
  73. data/scripts/check_readme_ruby.rb +24 -29
  74. data/scripts/check_readme_runnable.rb +92 -81
  75. data/sig/phronomy/agent/agent_not_found_error.rbs +6 -0
  76. data/sig/phronomy/agent/agent_unloaded_error.rbs +6 -0
  77. data/sig/phronomy/agent/stream_callback_error.rbs +8 -0
  78. data/sig/phronomy/agent.rbs +18 -0
  79. data/sig/phronomy/embeddings/base.rbs +6 -0
  80. data/sig/phronomy/execution/execution.rbs +2 -2
  81. data/sig/phronomy/extensions.rbs +1 -0
  82. data/sig/phronomy/filter/filter_block_error.rbs +6 -0
  83. data/sig/phronomy/generation/low_confidence_error.rbs +6 -0
  84. data/sig/phronomy/integrations.rbs +14 -0
  85. data/sig/phronomy/multi_agent/team_busy_error.rbs +8 -0
  86. data/sig/phronomy/multi_agent/team_not_found_error.rbs +8 -0
  87. data/sig/phronomy/storage/contracts.rbs +2 -2
  88. data/sig/phronomy/workflow/execution/workflow_execution_environment.rbs +1 -0
  89. data/sig/phronomy/workflow.rbs +4 -0
  90. data/sig/phronomy.rbs +4 -0
  91. metadata +49 -2
@@ -53,38 +53,13 @@ module Phronomy
53
53
  span.finish
54
54
  end
55
55
 
56
- # Overrides Base#trace to use OTel +in_span+, which pushes the span onto
57
- # the OTel Context stack while the block runs. Nested +trace+ calls
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
- attrs = {}
69
- attrs["phronomy.input"] = input.to_s if input
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.28.0"
4
+ VERSION = "0.29.0"
5
5
  end
@@ -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, register its listener/callback inside the action and
98
- # return the context or nil. Returning Phronomy::TaskResult is an error.
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.runner.deliver_on_event_loop(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
- synchronize { @workflow_admissions.clear }
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
- synchronize do
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 ? fail_task(result_task, error) : complete_task(result_task, result)
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