phronomy 0.22.0 → 0.23.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 (156) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +3 -4
  3. data/CHANGELOG.md +200 -10
  4. data/CONTRIBUTING.md +81 -9
  5. data/README.md +15 -6
  6. data/VERIFY.sh +587 -0
  7. data/benchmark/bench_agent_invoke.rb +2 -2
  8. data/benchmark/bench_context_assembler.rb +39 -68
  9. data/benchmark/bench_regression.rb +2 -2
  10. data/docs/architecture/agent-context.md +174 -0
  11. data/docs/architecture/before-llm-input.md +78 -0
  12. data/docs/architecture/context-management.md +232 -0
  13. data/docs/architecture/knowledge-and-rag.md +130 -0
  14. data/docs/architecture/multi-agent-handoff.md +152 -0
  15. data/docs/architecture/persistence.md +175 -0
  16. data/docs/architecture/removed/agent-context.md +72 -0
  17. data/docs/architecture/security-boundaries.md +173 -0
  18. data/docs/architecture/tracing.md +194 -0
  19. data/docs/architecture.md +82 -0
  20. data/docs/archive/design/archived/04_api_design.md +507 -0
  21. data/docs/archive/design/archived/09_guardrails.md +186 -0
  22. data/docs/archive/design/archived/17_rails_integration.md +175 -0
  23. data/docs/archive/design/historical/00_design_philosophy.md +122 -0
  24. data/docs/archive/design/historical/01_rubyllm_evaluation.md +178 -0
  25. data/docs/archive/design/historical/06_design_decisions.md +143 -0
  26. data/docs/changelog/0.14-and-earlier.md +1 -1
  27. data/docs/decisions/001-rubyllm-as-provider-layer.md +6 -1
  28. data/docs/decisions/002-workflow-context-immutability.md +26 -1
  29. data/docs/decisions/006-no-built-in-guardrails.md +2 -1
  30. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +120 -38
  31. data/docs/decisions/014-unified-persistence-durable-state.md +9 -2
  32. data/docs/decisions/016-semantic-multi-agent-handoff.md +112 -0
  33. data/docs/decisions/017-design-authority-and-adr-governance.md +200 -0
  34. data/docs/decisions/018-durability-guarantees-and-failure-model.md +488 -0
  35. data/docs/decisions/019-filter-contract-and-security-boundaries.md +229 -0
  36. data/docs/decisions/020-canonical-workflow-instance-identity.md +177 -0
  37. data/docs/decisions/021-generic-agent-invocation-identity-removal.md +119 -0
  38. data/docs/decisions/022-agent-execution-parent-identity-and-runtime-routing-boundary.md +193 -0
  39. data/docs/decisions/023-fsm-session-incarnation-identity-and-routing.md +139 -0
  40. data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +188 -0
  41. data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +249 -0
  42. data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +257 -0
  43. data/docs/decisions/027-llm-adapter-provider-boundary.md +93 -0
  44. data/docs/decisions/README.md +172 -0
  45. data/docs/features.md +31 -11
  46. data/docs/getting-started.md +77 -45
  47. data/docs/migrations/0.19.md +14 -7
  48. data/docs/migrations/0.22.md +390 -0
  49. data/docs/persistence-backends.md +88 -38
  50. data/docs/runtime-and-concurrency.md +227 -33
  51. data/examples/README.md +13 -0
  52. data/lib/phronomy/agent/agent_execution.rb +19 -15
  53. data/lib/phronomy/agent/agent_invocation.rb +288 -93
  54. data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
  55. data/lib/phronomy/agent/agent_root.rb +3 -3
  56. data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
  57. data/lib/phronomy/agent/async_event_api.rb +145 -72
  58. data/lib/phronomy/agent/base.rb +388 -181
  59. data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
  60. data/lib/phronomy/agent/context_assembler.rb +437 -178
  61. data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
  62. data/lib/phronomy/agent/context_plan.rb +18 -13
  63. data/lib/phronomy/agent/context_plan_validator.rb +246 -88
  64. data/lib/phronomy/agent/context_policies/default.rb +123 -34
  65. data/lib/phronomy/agent/context_policy.rb +109 -3
  66. data/lib/phronomy/agent/context_policy_input.rb +244 -0
  67. data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
  68. data/lib/phronomy/agent/execution_coordinator.rb +1975 -587
  69. data/lib/phronomy/agent/journal_record.rb +17 -4
  70. data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
  71. data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
  72. data/lib/phronomy/agent/llm_operation_result.rb +12 -7
  73. data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
  74. data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
  75. data/lib/phronomy/agent/recovery_coordinator/continuation.rb +271 -0
  76. data/lib/phronomy/agent/recovery_coordinator/installation.rb +427 -0
  77. data/lib/phronomy/agent/recovery_coordinator/resolution.rb +635 -0
  78. data/lib/phronomy/agent/recovery_coordinator.rb +211 -0
  79. data/lib/phronomy/agent/recovery_support.rb +512 -0
  80. data/lib/phronomy/agent/ruby_llm_materializer.rb +22 -13
  81. data/lib/phronomy/agent/selection/candidate.rb +53 -0
  82. data/lib/phronomy/agent/selection/constraint.rb +49 -0
  83. data/lib/phronomy/agent/shared_state.rb +38 -1
  84. data/lib/phronomy/agent/tool_approval_request.rb +33 -5
  85. data/lib/phronomy/agent/tool_definition_set.rb +49 -3
  86. data/lib/phronomy/agent/tool_invocation.rb +336 -102
  87. data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
  88. data/lib/phronomy/agent.rb +20 -2
  89. data/lib/phronomy/agent_already_exists_error.rb +5 -0
  90. data/lib/phronomy/agent_purged_error.rb +5 -0
  91. data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
  92. data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
  93. data/lib/phronomy/engine/event_loop.rb +622 -63
  94. data/lib/phronomy/engine/fsm_session.rb +194 -21
  95. data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
  96. data/lib/phronomy/engine/runtime.rb +77 -20
  97. data/lib/phronomy/generator_verifier.rb +12 -14
  98. data/lib/phronomy/invocation_context.rb +9 -29
  99. data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
  100. data/lib/phronomy/multi_agent/coordination_state.rb +18 -0
  101. data/lib/phronomy/multi_agent/coordinator.rb +154 -0
  102. data/lib/phronomy/multi_agent/execution_coordinator.rb +116 -0
  103. data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
  104. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
  105. data/lib/phronomy/multi_agent/handoff.rb +24 -45
  106. data/lib/phronomy/multi_agent/handoff_capability_factory.rb +87 -0
  107. data/lib/phronomy/multi_agent/handoff_context.rb +95 -0
  108. data/lib/phronomy/multi_agent/handoff_policy.rb +137 -0
  109. data/lib/phronomy/multi_agent/handoff_projection.rb +191 -0
  110. data/lib/phronomy/multi_agent/handoff_request.rb +45 -0
  111. data/lib/phronomy/multi_agent/orchestrator.rb +12 -15
  112. data/lib/phronomy/multi_agent/runner.rb +98 -0
  113. data/lib/phronomy/persistence/durable_codec.rb +646 -0
  114. data/lib/phronomy/persistence/durable_record.rb +117 -0
  115. data/lib/phronomy/persistence/in_memory.rb +210 -134
  116. data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
  117. data/lib/phronomy/persistence/repository_facades.rb +316 -0
  118. data/lib/phronomy/persistence.rb +81 -41
  119. data/lib/phronomy/recovery.rb +186 -0
  120. data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
  121. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +1 -1
  122. data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
  123. data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
  124. data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
  125. data/lib/phronomy/tracing/automatic.rb +176 -0
  126. data/lib/phronomy/tracing/base.rb +11 -2
  127. data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
  128. data/lib/phronomy/version.rb +1 -1
  129. data/lib/phronomy/workflow.rb +3 -6
  130. data/lib/phronomy/workflow_context.rb +14 -5
  131. data/lib/phronomy/workflow_recovery.rb +123 -0
  132. data/lib/phronomy/workflow_runner.rb +468 -256
  133. data/lib/phronomy.rb +6 -0
  134. data/scripts/api_snapshot.rb +12 -0
  135. data/sig/phronomy/agent.rbs +209 -7
  136. data/sig/phronomy/multi_agent.rbs +39 -0
  137. data/sig/phronomy/persistence.rbs +62 -4
  138. data/sig/phronomy/runtime.rbs +1 -4
  139. data/sig/phronomy/workflow.rbs +2 -2
  140. data/sig/phronomy.rbs +10 -0
  141. metadata +65 -17
  142. data/examples/workflows/agent_event_mapping.rb +0 -101
  143. data/examples/workflows/generic_task_event_mapping.rb +0 -66
  144. data/lib/phronomy/agent/activation_registry.rb +0 -28
  145. data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
  146. data/lib/phronomy/agent/context_candidate.rb +0 -47
  147. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
  148. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
  149. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
  150. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
  151. data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
  152. data/lib/phronomy/agent/context_policy_registry.rb +0 -46
  153. data/lib/phronomy/agent/context_request.rb +0 -35
  154. data/lib/phronomy/agent/context_selection_unit.rb +0 -38
  155. data/lib/phronomy/agent/derived_content_spec.rb +0 -34
  156. data/lib/phronomy/agent/runner.rb +0 -97
@@ -5,13 +5,15 @@ require "securerandom"
5
5
  module Phronomy
6
6
  # Execution boundary for compiled Workflows.
7
7
  #
8
- # WorkflowRunner separates three identities:
8
+ # WorkflowRunner separates three identities/responsibilities:
9
9
  # - application session_id remains caller/tracing metadata;
10
- # - thread_id identifies durable Workflow state;
11
- # - fsm_session_id identifies one Runtime FSMSession execution.
10
+ # - workflow_instance_id identifies durable Workflow state;
11
+ # - each concrete FSMSession owns a runtime-only fsm_session_id;
12
+ # - Runtime admission uses a separate opaque owner token.
12
13
  #
13
14
  # Workflow persistence is synchronous at the repository contract but is always
14
- # invoked through Runtime's OffloadPool from EventLoop-driven lifecycle paths.
15
+ # invoked through Runtime's OffloadPool. Durable terminal save results return
16
+ # to the same FSMSession before that session becomes halted/completed.
15
17
  # @api private
16
18
  class WorkflowRunner
17
19
  include Phronomy::Runnable
@@ -20,23 +22,52 @@ module Phronomy
20
22
 
21
23
  Execution = Data.define(
22
24
  :context,
23
- :thread_id,
24
- :fsm_session_id,
25
+ :workflow_instance_id,
26
+ :owner_token,
25
27
  :recursion_limit,
26
28
  :repository,
27
29
  :persist,
28
30
  :expected_revision
29
31
  )
30
32
 
31
- WorkflowPersistenceCommand = Struct.new(
33
+ # API/callback -> EventLoop control messages.
34
+ StartCommand = Data.define(
32
35
  :runner,
36
+ :input,
37
+ :config,
38
+ :stable_observer,
33
39
  :result_task,
34
- :result,
35
- :error,
36
- :thread_id,
37
- :fsm_session_id
40
+ :workflow_instance_id,
41
+ :owner_token,
42
+ :explicit_workflow_instance_id
43
+ )
44
+ ResumeCommand = Data.define(
45
+ :runner,
46
+ :state,
47
+ :input,
48
+ :event_name,
49
+ :current_phase,
50
+ :result_task,
51
+ :workflow_instance_id,
52
+ :owner_token
53
+ )
54
+
55
+ # EventLoop -> Offload operation-specific immutable/value snapshots.
56
+ WorkflowLoadCommand = Data.define(:repository, :workflow_instance_id)
57
+ WorkflowTerminalPersistenceCommand = Data.define(
58
+ :repository,
59
+ :workflow_instance_id,
60
+ :expected_revision,
61
+ :snapshot
38
62
  )
39
63
 
64
+ # Offload -> EventLoop/FSMSession operation results.
65
+ WorkflowLoadResult = Data.define(:repository, :snapshot, :revision)
66
+ WorkflowTerminalPersistenceResult = Data.define(:outcome, :revision, :error)
67
+
68
+ StartLoadReady = Data.define(:runner, :request, :result, :error)
69
+ ResumeLoadReady = Data.define(:runner, :request, :result, :error)
70
+
40
71
  def initialize(
41
72
  state_class:,
42
73
  entry_actions:,
@@ -71,14 +102,7 @@ module Phronomy
71
102
 
72
103
  def invoke(input, config: {})
73
104
  ensure_blocking_call_allowed!(:invoke, :invoke_async)
74
- caller_meta = {}
75
- caller_meta[:user_id] = config[:user_id] if config[:user_id]
76
- caller_meta[:session_id] = config[:session_id] if config[:session_id]
77
-
78
- trace("workflow.invoke", input: input.inspect, **caller_meta) do |_span|
79
- result = start_new_execution(input, config).wait_result
80
- [result, nil]
81
- end
105
+ start_new_execution(input, config).wait_result
82
106
  end
83
107
 
84
108
  def invoke_deferred(input, config: {})
@@ -100,9 +124,9 @@ module Phronomy
100
124
  ensure_blocking_call_allowed!(:send_event, :signal)
101
125
  current_phase = state.phase.to_sym
102
126
  event_name = resolve_resume_event(current_phase, event)
103
- thread_id = state.thread_id
104
- unless thread_id
105
- raise ArgumentError, "Halted WorkflowContext has no thread_id"
127
+ workflow_instance_id = state.workflow_instance_id
128
+ unless workflow_instance_id
129
+ raise ArgumentError, "Halted WorkflowContext has no workflow_instance_id"
106
130
  end
107
131
 
108
132
  start_resume_execution(
@@ -114,10 +138,11 @@ module Phronomy
114
138
  end
115
139
 
116
140
  # Posts an application-defined event to the currently live Workflow owner.
117
- # thread_id is resolved to its Runtime-only fsm_session_id by EventLoop; the
118
- # application session_id is deliberately unrelated to this routing.
119
- def signal(thread_id:, event:, payload: nil)
120
- raise ArgumentError, "thread_id is required" if thread_id.nil?
141
+ # EventLoop resolves workflow_instance_id to the current concrete
142
+ # fsm_session_id. The opaque admission owner token is deliberately not a
143
+ # routing identity and is not exposed here.
144
+ def signal(workflow_instance_id:, event:, payload: nil)
145
+ raise ArgumentError, "workflow_instance_id is required" if workflow_instance_id.nil?
121
146
 
122
147
  event_name = event.to_sym
123
148
  unless @external_events.key?(event_name)
@@ -127,28 +152,59 @@ module Phronomy
127
152
  end
128
153
 
129
154
  Phronomy::Runtime.instance.event_loop.post_to_workflow(
130
- thread_id: thread_id,
155
+ workflow_instance_id: workflow_instance_id,
131
156
  event: event_name,
132
157
  payload: payload
133
158
  )
134
159
  end
135
160
 
136
- # Called only by EventLoop for terminal Workflow persistence completion.
137
- def deliver_persistence_on_event_loop(command)
161
+ # Every Workflow control message is delivered by EventLoop. Admission,
162
+ # hydration apply, FSMSession creation/binding, and terminal live-state
163
+ # progression therefore remain EventLoop-owned.
164
+ # @api private
165
+ def deliver_on_event_loop(command)
138
166
  event_loop = Phronomy::Runtime.instance.event_loop
139
- event_loop.release_workflow(
140
- command.thread_id,
141
- owner_fsm_session_id: command.fsm_session_id
142
- )
143
- if command.error
144
- fail_task(command.result_task, command.error)
167
+ assert_event_loop!(event_loop)
168
+
169
+ case command
170
+ when StartCommand
171
+ begin_start_on_event_loop(command)
172
+ when ResumeCommand
173
+ begin_resume_on_event_loop(command)
174
+ when StartLoadReady
175
+ apply_start_load_on_event_loop(command)
176
+ when ResumeLoadReady
177
+ apply_resume_load_on_event_loop(command)
145
178
  else
146
- complete_task(command.result_task, command.result)
179
+ raise Phronomy::Error, "unknown Workflow control command: #{command.class}"
147
180
  end
148
181
  end
149
182
 
150
183
  private
151
184
 
185
+ def workflow_trace_metadata(config)
186
+ metadata = {}
187
+ metadata[:user_id] = config[:user_id] if config[:user_id]
188
+ metadata[:session_id] = config[:session_id] if config[:session_id]
189
+ if (invocation_context = config[:invocation_context])
190
+ metadata[:task_id] = invocation_context.task_id if invocation_context.task_id
191
+ if invocation_context.parent_task_id
192
+ metadata[:parent_task_id] = invocation_context.parent_task_id
193
+ end
194
+ end
195
+ metadata
196
+ end
197
+
198
+ # CG-01 is a clean break. Reject the removed Workflow config key instead of
199
+ # silently generating a new Workflow identity and branching durable history.
200
+ # This is a migration error path, not a compatibility alias.
201
+ def reject_legacy_workflow_identity_key!(config)
202
+ return unless config.key?(:thread_id) || config.key?("thread_id")
203
+
204
+ raise ArgumentError,
205
+ "Workflow config key :thread_id was removed; use :workflow_instance_id"
206
+ end
207
+
152
208
  def ensure_blocking_call_allowed!(method_name, async_alternative)
153
209
  return unless Phronomy::Runtime.instance.event_loop.current?
154
210
 
@@ -158,149 +214,135 @@ module Phronomy
158
214
  end
159
215
 
160
216
  def start_new_execution(input, config, stable_observer: nil)
217
+ reject_legacy_workflow_identity_key!(config)
161
218
  runtime = Phronomy::Runtime.instance
162
- event_loop = runtime.event_loop
163
219
  result_task = Phronomy::Task.deferred(name: "workflow:preparing")
164
- explicit_thread_id = !config[:thread_id].nil?
165
- thread_id = (config[:thread_id] || SecureRandom.uuid).to_s
166
- fsm_session_id = SecureRandom.uuid
167
- recursion_limit = config.fetch(
168
- :recursion_limit,
169
- Phronomy.configuration.recursion_limit
220
+ explicit_workflow_instance_id = !config[:workflow_instance_id].nil?
221
+ workflow_instance_id = (config[:workflow_instance_id] || SecureRandom.uuid).to_s.freeze
222
+ Phronomy::Tracing::Automatic.observe_task(
223
+ result_task,
224
+ "workflow.execution",
225
+ input: input,
226
+ workflow_instance_id: workflow_instance_id,
227
+ mode: stable_observer ? :stream : :invoke,
228
+ **workflow_trace_metadata(config)
170
229
  )
171
- repository = configured_repository
172
- persist = explicit_thread_id && !repository.nil?
230
+ command = StartCommand.new(
231
+ runner: self,
232
+ input: input,
233
+ config: config.dup.freeze,
234
+ stable_observer: stable_observer,
235
+ result_task: result_task,
236
+ workflow_instance_id: workflow_instance_id,
237
+ owner_token: Object.new.freeze,
238
+ explicit_workflow_instance_id: explicit_workflow_instance_id
239
+ )
240
+ fail_task(result_task, runtime_rejected_error(:start)) unless post_control(runtime, command)
241
+ result_task
242
+ rescue => error
243
+ fail_task(result_task, error) if defined?(result_task) && result_task
244
+ result_task || failed_task("workflow:preparation", error)
245
+ end
173
246
 
247
+ def start_resume_execution(state, input:, event_name:, current_phase:)
248
+ runtime = Phronomy::Runtime.instance
249
+ workflow_instance_id = state.workflow_instance_id.to_s.freeze
250
+ result_task = Phronomy::Task.deferred(name: "workflow-resume:#{workflow_instance_id}")
251
+ Phronomy::Tracing::Automatic.observe_task(
252
+ result_task,
253
+ "workflow.execution",
254
+ input: input,
255
+ workflow_instance_id: workflow_instance_id,
256
+ mode: :resume,
257
+ event: event_name
258
+ )
259
+ command = ResumeCommand.new(
260
+ runner: self,
261
+ state: state,
262
+ input: input,
263
+ event_name: event_name.to_sym,
264
+ current_phase: current_phase.to_sym,
265
+ result_task: result_task,
266
+ workflow_instance_id: workflow_instance_id,
267
+ owner_token: Object.new.freeze
268
+ )
269
+ fail_task(result_task, runtime_rejected_error(:resume)) unless post_control(runtime, command)
270
+ result_task
271
+ rescue => error
272
+ fail_task(result_task, error) if defined?(result_task) && result_task
273
+ result_task || failed_task("workflow:resume-preparation", error)
274
+ end
275
+
276
+ def begin_start_on_event_loop(request)
277
+ runtime = Phronomy::Runtime.instance
278
+ event_loop = runtime.event_loop
279
+ admitted = false
174
280
  event_loop.admit_workflow(
175
- thread_id,
176
- owner_fsm_session_id: fsm_session_id
281
+ request.workflow_instance_id,
282
+ owner_token: request.owner_token
177
283
  )
284
+ admitted = true
178
285
 
179
- if persist
180
- load_operation = runtime.offload.submit(on_full: :raise) do
181
- repository.load(thread_id)
182
- end
183
- load_operation.on_complete do |record, error|
184
- if error
185
- release_and_fail(
186
- event_loop, result_task, thread_id, fsm_session_id, error
187
- )
188
- next
189
- end
190
-
191
- begin
192
- execution = build_new_execution(
193
- input,
194
- thread_id: thread_id,
195
- fsm_session_id: fsm_session_id,
196
- recursion_limit: recursion_limit,
197
- repository: repository,
198
- persist: true,
199
- record: record
200
- )
201
- register_execution(
202
- execution,
203
- result_task,
204
- stable_observer: stable_observer
205
- )
206
- rescue => preparation_error
207
- release_and_fail(
208
- event_loop,
209
- result_task,
210
- thread_id,
211
- fsm_session_id,
212
- preparation_error
213
- )
214
- end
215
- end
216
- else
286
+ recursion_limit = request.config.fetch(
287
+ :recursion_limit,
288
+ Phronomy.configuration.recursion_limit
289
+ )
290
+ repository = configured_repository
291
+ persist = request.explicit_workflow_instance_id && !repository.nil?
292
+
293
+ unless persist
217
294
  execution = build_new_execution(
218
- input,
219
- thread_id: thread_id,
220
- fsm_session_id: fsm_session_id,
295
+ request.input,
296
+ workflow_instance_id: request.workflow_instance_id,
297
+ owner_token: request.owner_token,
221
298
  recursion_limit: recursion_limit,
222
299
  repository: repository,
223
300
  persist: false,
224
- record: nil
301
+ loaded_snapshot: nil,
302
+ expected_revision: nil
225
303
  )
226
304
  register_execution(
227
305
  execution,
228
- result_task,
229
- stable_observer: stable_observer
306
+ request.result_task,
307
+ stable_observer: request.stable_observer
230
308
  )
309
+ return
231
310
  end
232
- result_task
311
+
312
+ submit_workflow_load(
313
+ runtime,
314
+ repository,
315
+ request.workflow_instance_id,
316
+ StartLoadReady,
317
+ request
318
+ )
233
319
  rescue => error
234
- if defined?(event_loop) && event_loop &&
235
- defined?(thread_id) && defined?(fsm_session_id)
320
+ if admitted
236
321
  event_loop.release_workflow(
237
- thread_id,
238
- owner_fsm_session_id: fsm_session_id
322
+ request.workflow_instance_id,
323
+ owner_token: request.owner_token
239
324
  )
240
325
  end
241
- fail_task(result_task, error) if defined?(result_task) && result_task
242
- result_task || failed_task("workflow:preparation", error)
326
+ fail_task(request.result_task, error)
243
327
  end
244
328
 
245
- def start_resume_execution(state, input:, event_name:, current_phase:)
329
+ def begin_resume_on_event_loop(request)
246
330
  runtime = Phronomy::Runtime.instance
247
331
  event_loop = runtime.event_loop
248
- thread_id = state.thread_id.to_s
249
- fsm_session_id = SecureRandom.uuid
250
- repository = configured_repository
251
- result_task = Phronomy::Task.deferred(name: "workflow-resume:#{thread_id}")
252
-
332
+ admitted = false
253
333
  event_loop.admit_workflow(
254
- thread_id,
255
- owner_fsm_session_id: fsm_session_id
334
+ request.workflow_instance_id,
335
+ owner_token: request.owner_token
256
336
  )
337
+ admitted = true
257
338
 
258
- if repository
259
- load_operation = runtime.offload.submit(on_full: :raise) do
260
- repository.load(thread_id)
261
- end
262
- load_operation.on_complete do |record, error|
263
- if error
264
- release_and_fail(
265
- event_loop, result_task, thread_id, fsm_session_id, error
266
- )
267
- next
268
- end
269
-
270
- begin
271
- expected_revision = validate_resume_snapshot!(state, record)
272
- context = input ? state.merge(input) : state
273
- execution = Execution.new(
274
- context: context,
275
- thread_id: thread_id,
276
- fsm_session_id: fsm_session_id,
277
- recursion_limit: Phronomy.configuration.recursion_limit,
278
- repository: repository,
279
- persist: true,
280
- expected_revision: expected_revision
281
- )
282
- register_execution(
283
- execution,
284
- result_task,
285
- resume_event: event_name,
286
- resume_phase: current_phase
287
- )
288
- rescue => preparation_error
289
- release_and_fail(
290
- event_loop,
291
- result_task,
292
- thread_id,
293
- fsm_session_id,
294
- preparation_error
295
- )
296
- end
297
- end
298
- else
299
- context = input ? state.merge(input) : state
339
+ repository = configured_repository
340
+ unless repository
341
+ context = request.input ? request.state.merge(request.input) : request.state
300
342
  execution = Execution.new(
301
343
  context: context,
302
- thread_id: thread_id,
303
- fsm_session_id: fsm_session_id,
344
+ workflow_instance_id: request.workflow_instance_id,
345
+ owner_token: request.owner_token,
304
346
  recursion_limit: Phronomy.configuration.recursion_limit,
305
347
  repository: nil,
306
348
  persist: false,
@@ -308,35 +350,130 @@ module Phronomy
308
350
  )
309
351
  register_execution(
310
352
  execution,
311
- result_task,
312
- resume_event: event_name,
313
- resume_phase: current_phase
353
+ request.result_task,
354
+ resume_event: request.event_name,
355
+ resume_phase: request.current_phase
314
356
  )
357
+ return
315
358
  end
316
- result_task
359
+
360
+ submit_workflow_load(
361
+ runtime,
362
+ repository,
363
+ request.workflow_instance_id,
364
+ ResumeLoadReady,
365
+ request
366
+ )
317
367
  rescue => error
318
- if defined?(event_loop) && event_loop &&
319
- defined?(thread_id) && defined?(fsm_session_id)
368
+ if admitted
320
369
  event_loop.release_workflow(
321
- thread_id,
322
- owner_fsm_session_id: fsm_session_id
370
+ request.workflow_instance_id,
371
+ owner_token: request.owner_token
323
372
  )
324
373
  end
325
- fail_task(result_task, error) if defined?(result_task) && result_task
326
- result_task || failed_task("workflow:resume-preparation", error)
374
+ fail_task(request.result_task, error)
375
+ end
376
+
377
+ def submit_workflow_load(runtime, repository, workflow_instance_id, ready_class, request)
378
+ operation = WorkflowLoadCommand.new(
379
+ repository: repository,
380
+ workflow_instance_id: workflow_instance_id.to_s.freeze
381
+ )
382
+ task = runtime.offload.submit(on_full: :raise) do
383
+ record = operation.repository.load(operation.workflow_instance_id)
384
+ WorkflowLoadResult.new(
385
+ repository: operation.repository,
386
+ snapshot: deep_immutable_copy(record_value(record, :snapshot)),
387
+ revision: record_value(record, :revision)
388
+ )
389
+ end
390
+ task.on_complete do |result, error|
391
+ ready = ready_class.new(
392
+ runner: self,
393
+ request: request,
394
+ result: result,
395
+ error: error
396
+ )
397
+ fail_task(request.result_task, runtime_rejected_error(:load_result)) unless
398
+ post_control(runtime, ready)
399
+ end
400
+ nil
401
+ end
402
+
403
+ def apply_start_load_on_event_loop(ready)
404
+ request = ready.request
405
+ if ready.error
406
+ release_and_fail(request, ready.error)
407
+ return
408
+ end
409
+
410
+ recursion_limit = request.config.fetch(
411
+ :recursion_limit,
412
+ Phronomy.configuration.recursion_limit
413
+ )
414
+ execution = build_new_execution(
415
+ request.input,
416
+ workflow_instance_id: request.workflow_instance_id,
417
+ owner_token: request.owner_token,
418
+ recursion_limit: recursion_limit,
419
+ repository: ready.result.repository,
420
+ persist: true,
421
+ loaded_snapshot: ready.result.snapshot,
422
+ expected_revision: ready.result.revision
423
+ )
424
+ register_execution(
425
+ execution,
426
+ request.result_task,
427
+ stable_observer: request.stable_observer
428
+ )
429
+ rescue => error
430
+ release_and_fail(request, error)
431
+ end
432
+
433
+ def apply_resume_load_on_event_loop(ready)
434
+ request = ready.request
435
+ if ready.error
436
+ release_and_fail(request, ready.error)
437
+ return
438
+ end
439
+
440
+ expected_revision = validate_resume_snapshot!(
441
+ request.state,
442
+ ready.result.snapshot,
443
+ ready.result.revision
444
+ )
445
+ context = request.input ? request.state.merge(request.input) : request.state
446
+ execution = Execution.new(
447
+ context: context,
448
+ workflow_instance_id: request.workflow_instance_id,
449
+ owner_token: request.owner_token,
450
+ recursion_limit: Phronomy.configuration.recursion_limit,
451
+ repository: ready.result.repository,
452
+ persist: true,
453
+ expected_revision: expected_revision
454
+ )
455
+ register_execution(
456
+ execution,
457
+ request.result_task,
458
+ resume_event: request.event_name,
459
+ resume_phase: request.current_phase
460
+ )
461
+ rescue => error
462
+ release_and_fail(request, error)
327
463
  end
328
464
 
329
465
  def build_new_execution(
330
466
  input,
331
- thread_id:,
332
- fsm_session_id:,
467
+ workflow_instance_id:,
468
+ owner_token:,
333
469
  recursion_limit:,
334
470
  repository:,
335
471
  persist:,
336
- record:
472
+ loaded_snapshot:,
473
+ expected_revision:
337
474
  )
338
- snapshot = record_value(record, :snapshot)
339
- stored_fields = snapshot && (snapshot[:fields] || snapshot["fields"])
475
+ stored_fields = loaded_snapshot &&
476
+ (loaded_snapshot[:fields] || loaded_snapshot["fields"])
340
477
  initial_fields = if stored_fields
341
478
  stored_fields
342
479
  .transform_keys(&:to_sym)
@@ -346,29 +483,29 @@ module Phronomy
346
483
  end
347
484
 
348
485
  context = @state_class.new(**initial_fields)
349
- context.set_graph_metadata(thread_id: thread_id)
486
+ context.set_graph_metadata(workflow_instance_id: workflow_instance_id)
350
487
 
351
488
  Execution.new(
352
489
  context: context,
353
- thread_id: thread_id,
354
- fsm_session_id: fsm_session_id,
490
+ workflow_instance_id: workflow_instance_id,
491
+ owner_token: owner_token,
355
492
  recursion_limit: recursion_limit,
356
493
  repository: repository,
357
494
  persist: persist,
358
- expected_revision: record_value(record, :revision)
495
+ expected_revision: expected_revision
359
496
  )
360
497
  end
361
498
 
362
- def validate_resume_snapshot!(state, record)
363
- return nil unless record
499
+ def validate_resume_snapshot!(state, durable_snapshot, durable_revision)
500
+ return nil unless durable_snapshot
364
501
 
365
- durable_snapshot = normalize_snapshot(record_value(record, :snapshot))
502
+ normalized_durable = normalize_snapshot(durable_snapshot)
366
503
  local_snapshot = normalize_snapshot(snapshot_for(state))
367
- return record_value(record, :revision) if durable_snapshot == local_snapshot
504
+ return durable_revision if normalized_durable == local_snapshot
368
505
 
369
506
  raise Phronomy::Persistence::ConflictError,
370
507
  "Workflow state changed since the supplied halted context for " \
371
- "thread_id #{state.thread_id.inspect}; explicit reload/reconciliation is required"
508
+ "workflow_instance_id #{state.workflow_instance_id.inspect}; explicit reload/reconciliation is required"
372
509
  end
373
510
 
374
511
  def normalize_snapshot(snapshot)
@@ -396,6 +533,22 @@ module Phronomy
396
533
  end
397
534
  end
398
535
 
536
+ def deep_immutable_copy(value)
537
+ case value
538
+ when Hash
539
+ value.each_with_object({}) do |(key, child), result|
540
+ copied_key = key.is_a?(String) ? key.dup.freeze : key
541
+ result[copied_key] = deep_immutable_copy(child)
542
+ end.freeze
543
+ when Array
544
+ value.map { |child| deep_immutable_copy(child) }.freeze
545
+ when String
546
+ value.dup.freeze
547
+ else
548
+ value
549
+ end
550
+ end
551
+
399
552
  def record_value(record, key)
400
553
  return nil unless record
401
554
  record.key?(key) ? record[key] : record[key.to_s]
@@ -414,10 +567,22 @@ module Phronomy
414
567
  stable_observer: nil
415
568
  )
416
569
  runtime = Phronomy::Runtime.instance
417
- source_task = Phronomy::Task.deferred(
418
- name: "workflow-source:#{execution.fsm_session_id}"
570
+ event_loop = runtime.event_loop
571
+ assert_event_loop!(event_loop)
572
+ session = build_session_for(
573
+ execution: execution,
574
+ runtime: runtime,
575
+ resume_event: resume_event,
576
+ resume_phase: resume_phase,
577
+ stable_observer: stable_observer
578
+ )
579
+ event_loop.bind_workflow_session(
580
+ execution.workflow_instance_id,
581
+ owner_token: execution.owner_token,
582
+ fsm_session_id: session.id
419
583
  )
420
584
 
585
+ source_task = Phronomy::Task.deferred(name: "workflow-source:#{session.id}")
421
586
  source_task.on_complete do |result, error|
422
587
  finalize_execution(
423
588
  execution: execution,
@@ -426,99 +591,110 @@ module Phronomy
426
591
  error: error
427
592
  )
428
593
  end
429
-
430
- session = build_session_for(
431
- execution: execution,
432
- runtime: runtime,
433
- resume_event: resume_event,
434
- resume_phase: resume_phase,
435
- stable_observer: stable_observer
436
- )
437
- runtime.event_loop.register(session, completion: source_task)
594
+ event_loop.register(session, completion: source_task)
438
595
  result_task
439
596
  rescue => error
440
- Phronomy::Runtime.instance.event_loop.release_workflow(
441
- execution.thread_id,
442
- owner_fsm_session_id: execution.fsm_session_id
443
- )
444
- fail_task(result_task, error)
445
- result_task
597
+ if event_loop&.current?
598
+ event_loop&.release_workflow(
599
+ execution.workflow_instance_id,
600
+ owner_token: execution.owner_token
601
+ )
602
+ end
603
+ raise error
446
604
  end
447
605
 
448
- # Called from source_task completion on EventLoop.
606
+ # Called from source_task completion on EventLoop, after the FSMSession has
607
+ # accepted the durable terminal result (when a durable barrier is required).
449
608
  def finalize_execution(execution:, result_task:, result:, error:)
450
609
  event_loop = Phronomy::Runtime.instance.event_loop
451
- if error
452
- event_loop.release_workflow(
453
- execution.thread_id,
454
- owner_fsm_session_id: execution.fsm_session_id
455
- )
456
- fail_task(result_task, error)
457
- return
458
- end
459
-
460
- unless execution.repository && execution.persist
461
- event_loop.release_workflow(
462
- execution.thread_id,
463
- owner_fsm_session_id: execution.fsm_session_id
464
- )
465
- complete_task(result_task, result)
466
- return
467
- end
610
+ assert_event_loop!(event_loop)
611
+ event_loop.release_workflow(
612
+ execution.workflow_instance_id,
613
+ owner_token: execution.owner_token
614
+ )
615
+ error ? fail_task(result_task, error) : complete_task(result_task, result)
616
+ end
468
617
 
469
- snapshot = snapshot_for(result)
618
+ def begin_terminal_persistence_on_event_loop(
619
+ execution,
620
+ terminal_type:,
621
+ context:,
622
+ event_sink:
623
+ )
470
624
  runtime = Phronomy::Runtime.instance
471
- operation = runtime.offload.submit(on_full: :raise) do
472
- execution.repository.save(
473
- execution.thread_id,
474
- expected_revision: execution.expected_revision,
475
- snapshot: snapshot
625
+ event_loop = runtime.event_loop
626
+ assert_event_loop!(event_loop)
627
+ event_loop.mark_workflow_admission(
628
+ execution.workflow_instance_id,
629
+ owner_token: execution.owner_token,
630
+ state: :persisting_terminal
631
+ )
632
+
633
+ operation = WorkflowTerminalPersistenceCommand.new(
634
+ repository: execution.repository,
635
+ workflow_instance_id: execution.workflow_instance_id.to_s.freeze,
636
+ expected_revision: execution.expected_revision,
637
+ snapshot: deep_immutable_copy(snapshot_for(context))
638
+ )
639
+ task = runtime.offload.submit(on_full: :raise) do
640
+ revision = operation.repository.save(
641
+ operation.workflow_instance_id,
642
+ expected_revision: operation.expected_revision,
643
+ snapshot: operation.snapshot
476
644
  )
477
- end
478
- operation.on_complete do |_revision, persistence_error|
479
- command = WorkflowPersistenceCommand.new(
480
- self,
481
- result_task,
482
- result,
483
- persistence_error,
484
- execution.thread_id,
485
- execution.fsm_session_id
645
+ WorkflowTerminalPersistenceResult.new(
646
+ outcome: :success,
647
+ revision: revision,
648
+ error: nil
486
649
  )
487
- posted = event_loop.post(
488
- Phronomy::Event.new(
489
- type: :workflow_persistence_ready,
490
- target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID,
491
- payload: {command: command}
492
- )
650
+ rescue Phronomy::Persistence::ConflictError,
651
+ Phronomy::Persistence::NotFoundError,
652
+ Phronomy::Persistence::SerializationError,
653
+ Phronomy::Persistence::UnsupportedBackendError => error
654
+ WorkflowTerminalPersistenceResult.new(
655
+ outcome: :known_failure,
656
+ revision: nil,
657
+ error: error
658
+ )
659
+ rescue => error
660
+ # The Backend SPI does not currently provide a portable commit-outcome
661
+ # classifier for arbitrary storage/transport failures. If non-commit is
662
+ # not guaranteed by the Phronomy error contract, fail closed as F1.
663
+ WorkflowTerminalPersistenceResult.new(
664
+ outcome: :outcome_unknown,
665
+ revision: nil,
666
+ error: error
493
667
  )
494
- settle_persistence_without_event_loop(command, event_loop) unless posted
495
668
  end
496
- rescue => persistence_start_error
497
- event_loop.release_workflow(
498
- execution.thread_id,
499
- owner_fsm_session_id: execution.fsm_session_id
500
- )
501
- fail_task(result_task, persistence_start_error)
502
- end
503
-
504
- def settle_persistence_without_event_loop(command, event_loop)
505
- event_loop.release_workflow(
506
- command.thread_id,
507
- owner_fsm_session_id: command.fsm_session_id
508
- )
509
- command.error ?
510
- fail_task(command.result_task, command.error) :
511
- complete_task(command.result_task, command.result)
512
- rescue => error
513
- fail_task(command.result_task, error)
669
+ task.on_complete do |result, operation_error|
670
+ delivery = if operation_error
671
+ WorkflowTerminalPersistenceResult.new(
672
+ outcome: :outcome_unknown,
673
+ revision: nil,
674
+ error: operation_error
675
+ )
676
+ else
677
+ result
678
+ end
679
+ accepted = event_sink.post(:workflow_terminal_persistence_result, delivery)
680
+ unless accepted
681
+ Phronomy.configuration.logger&.warn(
682
+ "[Phronomy] EventLoop rejected Workflow terminal persistence result " \
683
+ "for #{execution.workflow_instance_id.inspect}"
684
+ )
685
+ end
686
+ end
687
+ terminal_type
514
688
  end
515
689
 
516
- def release_and_fail(event_loop, result_task, thread_id, fsm_session_id, error)
690
+ def release_and_fail(request, error)
691
+ event_loop = Phronomy::Runtime.instance.event_loop
692
+ assert_event_loop!(event_loop)
517
693
  event_loop.release_workflow(
518
- thread_id,
519
- owner_fsm_session_id: fsm_session_id
694
+ request.workflow_instance_id,
695
+ owner_token: request.owner_token
520
696
  )
521
- fail_task(result_task, error)
697
+ fail_task(request.result_task, error)
522
698
  end
523
699
 
524
700
  def snapshot_for(context)
@@ -535,10 +711,22 @@ module Phronomy
535
711
  resume_phase: nil,
536
712
  stable_observer: nil
537
713
  )
714
+ terminal_barrier = if execution.repository && execution.persist
715
+ ->(terminal_type:, context:, event_sink:) {
716
+ begin_terminal_persistence_on_event_loop(
717
+ execution,
718
+ terminal_type: terminal_type,
719
+ context: context,
720
+ event_sink: event_sink
721
+ )
722
+ }
723
+ end
724
+
538
725
  Phronomy::FSMSession.new(
539
- id: execution.fsm_session_id,
540
- graph_thread_id: execution.thread_id,
541
726
  context: execution.context,
727
+ context_metadata: {
728
+ workflow_instance_id: execution.workflow_instance_id
729
+ },
542
730
  entry_point: @entry_point,
543
731
  entry_actions: @entry_actions,
544
732
  auto_state_set: @auto_state_set,
@@ -550,7 +738,8 @@ module Phronomy
550
738
  event_loop: runtime.event_loop,
551
739
  resume_event: resume_event,
552
740
  resume_phase: resume_phase,
553
- stable_observer: stable_observer
741
+ stable_observer: stable_observer,
742
+ terminal_barrier: terminal_barrier
554
743
  )
555
744
  end
556
745
 
@@ -576,6 +765,29 @@ module Phronomy
576
765
  "No external event registered for state #{current_phase.inspect}"
577
766
  end
578
767
 
768
+ def post_control(runtime, command)
769
+ runtime.event_loop.post(
770
+ Phronomy::Event.new(
771
+ type: :workflow_control,
772
+ target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID,
773
+ payload: {command: command}
774
+ )
775
+ )
776
+ end
777
+
778
+ def runtime_rejected_error(action)
779
+ Phronomy::RuntimeShutdownError.new(
780
+ "Runtime rejected Workflow #{action} while shutting down"
781
+ )
782
+ end
783
+
784
+ def assert_event_loop!(event_loop)
785
+ return if event_loop.current?
786
+
787
+ raise Phronomy::Error,
788
+ "Workflow live-state progression must run on EventLoop"
789
+ end
790
+
579
791
  def complete_task(task, value)
580
792
  task.complete(value)
581
793
  end