phronomy 0.22.0 → 0.24.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 (178) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +3 -4
  3. data/CHANGELOG.md +215 -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 +147 -0
  15. data/docs/architecture/persistence.md +186 -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 +89 -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 +114 -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/028-preparing-recovery-replay-contract.md +106 -0
  45. data/docs/decisions/029-semantic-completion-and-application-effect-boundary.md +220 -0
  46. data/docs/decisions/030-agent-handoff-domain-and-durable-responsibility.md +235 -0
  47. data/docs/decisions/031-durable-multi-agent-coordination.md +301 -0
  48. data/docs/decisions/README.md +176 -0
  49. data/docs/design/durable-semantic-coordination/CHANGELOG_V2_REVISION_2.md +33 -0
  50. data/docs/design/durable-semantic-coordination/CONTINUATION_DECISION_REFACTOR.md +191 -0
  51. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_DESIGN_V2.md +862 -0
  52. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +106 -0
  53. data/docs/design/durable-semantic-coordination/RECOVERY_CONTRACT_CLARIFICATIONS.md +179 -0
  54. data/docs/design/durable-semantic-coordination/RESPONSIBILITY_BOUNDARY_REVIEW.md +302 -0
  55. data/docs/features.md +65 -11
  56. data/docs/getting-started.md +77 -45
  57. data/docs/migrations/0.19.md +14 -7
  58. data/docs/migrations/0.22.md +390 -0
  59. data/docs/migrations/durable-semantic-coordination-v2.md +65 -0
  60. data/docs/persistence-backends.md +129 -40
  61. data/docs/runtime-and-concurrency.md +227 -33
  62. data/examples/README.md +13 -0
  63. data/lib/phronomy/agent/agent_execution.rb +21 -17
  64. data/lib/phronomy/agent/agent_invocation.rb +288 -93
  65. data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
  66. data/lib/phronomy/agent/agent_root.rb +3 -3
  67. data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
  68. data/lib/phronomy/agent/async_event_api.rb +162 -72
  69. data/lib/phronomy/agent/base.rb +416 -181
  70. data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
  71. data/lib/phronomy/agent/context_assembler.rb +437 -178
  72. data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
  73. data/lib/phronomy/agent/context_plan.rb +18 -13
  74. data/lib/phronomy/agent/context_plan_validator.rb +246 -88
  75. data/lib/phronomy/agent/context_policies/default.rb +123 -34
  76. data/lib/phronomy/agent/context_policy.rb +109 -3
  77. data/lib/phronomy/agent/context_policy_input.rb +244 -0
  78. data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
  79. data/lib/phronomy/agent/exact_execution.rb +153 -0
  80. data/lib/phronomy/agent/execution_cancellation.rb +25 -0
  81. data/lib/phronomy/agent/execution_coordinator.rb +2417 -572
  82. data/lib/phronomy/agent/handoff.rb +43 -0
  83. data/lib/phronomy/agent/handoff_capability_factory.rb +45 -0
  84. data/lib/phronomy/agent/handoff_context.rb +120 -0
  85. data/lib/phronomy/agent/handoff_execution_coordinator.rb +143 -0
  86. data/lib/phronomy/agent/handoff_policy.rb +143 -0
  87. data/lib/phronomy/agent/handoff_projection.rb +207 -0
  88. data/lib/phronomy/agent/handoff_request.rb +45 -0
  89. data/lib/phronomy/agent/handoff_runner.rb +178 -0
  90. data/lib/phronomy/agent/handoff_state.rb +43 -0
  91. data/lib/phronomy/agent/journal_record.rb +17 -4
  92. data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
  93. data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
  94. data/lib/phronomy/agent/llm_operation_result.rb +12 -7
  95. data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
  96. data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
  97. data/lib/phronomy/agent/recovery_coordinator/continuation.rb +174 -0
  98. data/lib/phronomy/agent/recovery_coordinator/installation.rb +381 -0
  99. data/lib/phronomy/agent/recovery_coordinator/resolution.rb +511 -0
  100. data/lib/phronomy/agent/recovery_coordinator.rb +217 -0
  101. data/lib/phronomy/agent/recovery_support.rb +504 -0
  102. data/lib/phronomy/agent/ruby_llm_materializer.rb +16 -13
  103. data/lib/phronomy/agent/selection/candidate.rb +53 -0
  104. data/lib/phronomy/agent/selection/constraint.rb +49 -0
  105. data/lib/phronomy/agent/shared_state.rb +38 -1
  106. data/lib/phronomy/agent/tool_approval_request.rb +33 -5
  107. data/lib/phronomy/agent/tool_definition_set.rb +49 -3
  108. data/lib/phronomy/agent/tool_invocation.rb +339 -103
  109. data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
  110. data/lib/phronomy/agent.rb +20 -2
  111. data/lib/phronomy/agent_already_exists_error.rb +5 -0
  112. data/lib/phronomy/agent_purged_error.rb +5 -0
  113. data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
  114. data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
  115. data/lib/phronomy/engine/event_loop.rb +622 -63
  116. data/lib/phronomy/engine/fsm_session.rb +194 -21
  117. data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
  118. data/lib/phronomy/engine/runtime/team_ownership_registry.rb +77 -0
  119. data/lib/phronomy/engine/runtime.rb +92 -20
  120. data/lib/phronomy/generator_verifier.rb +12 -14
  121. data/lib/phronomy/invocation_context.rb +9 -29
  122. data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
  123. data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +134 -0
  124. data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
  125. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
  126. data/lib/phronomy/multi_agent/orchestrator.rb +71 -26
  127. data/lib/phronomy/multi_agent/team_coordinator.rb +473 -125
  128. data/lib/phronomy/multi_agent/team_execution.rb +44 -0
  129. data/lib/phronomy/multi_agent/team_root.rb +41 -0
  130. data/lib/phronomy/persistence/durable_codec.rb +706 -0
  131. data/lib/phronomy/persistence/durable_record.rb +117 -0
  132. data/lib/phronomy/persistence/in_memory.rb +471 -133
  133. data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
  134. data/lib/phronomy/persistence/repository_facades.rb +535 -0
  135. data/lib/phronomy/persistence.rb +174 -40
  136. data/lib/phronomy/recovery.rb +186 -0
  137. data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
  138. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +2 -1
  139. data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
  140. data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
  141. data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
  142. data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +137 -0
  143. data/lib/phronomy/testing/persistence_contract.rb +5 -0
  144. data/lib/phronomy/tools/agent.rb +1 -1
  145. data/lib/phronomy/tracing/automatic.rb +176 -0
  146. data/lib/phronomy/tracing/base.rb +11 -2
  147. data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
  148. data/lib/phronomy/version.rb +1 -1
  149. data/lib/phronomy/workflow.rb +3 -6
  150. data/lib/phronomy/workflow_context.rb +14 -5
  151. data/lib/phronomy/workflow_recovery.rb +123 -0
  152. data/lib/phronomy/workflow_runner.rb +468 -256
  153. data/lib/phronomy.rb +6 -0
  154. data/scripts/api_snapshot.rb +12 -0
  155. data/sig/phronomy/agent.rbs +209 -7
  156. data/sig/phronomy/handoff.rbs +41 -0
  157. data/sig/phronomy/multi_agent.rbs +35 -0
  158. data/sig/phronomy/persistence.rbs +123 -4
  159. data/sig/phronomy/runtime.rbs +1 -4
  160. data/sig/phronomy/workflow.rbs +2 -2
  161. data/sig/phronomy.rbs +10 -0
  162. metadata +84 -18
  163. data/examples/workflows/agent_event_mapping.rb +0 -101
  164. data/examples/workflows/generic_task_event_mapping.rb +0 -66
  165. data/lib/phronomy/agent/activation_registry.rb +0 -28
  166. data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
  167. data/lib/phronomy/agent/context_candidate.rb +0 -47
  168. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
  169. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
  170. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
  171. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
  172. data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
  173. data/lib/phronomy/agent/context_policy_registry.rb +0 -46
  174. data/lib/phronomy/agent/context_request.rb +0 -35
  175. data/lib/phronomy/agent/context_selection_unit.rb +0 -38
  176. data/lib/phronomy/agent/derived_content_spec.rb +0 -34
  177. data/lib/phronomy/agent/runner.rb +0 -97
  178. data/lib/phronomy/multi_agent/handoff.rb +0 -64
@@ -0,0 +1,511 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ class RecoveryCoordinator
6
+ # Resolution portion of durable Agent Recovery.
7
+ # @api private
8
+ module Resolution
9
+ private
10
+
11
+ def begin_resolve_on_event_loop(request)
12
+ event_loop = @runtime.event_loop
13
+ state = event_loop.agent_execution_state(
14
+ request.execution_id
15
+ )
16
+ unless state && state.agent.equal?(agent)
17
+ request.completion.fail(
18
+ Phronomy::ExecutionRehydrationRequiredError.new(
19
+ "no live recovered execution #{request.execution_id}"
20
+ )
21
+ )
22
+ return
23
+ end
24
+
25
+ current = state.execution
26
+ unless current.execution_revision ==
27
+ request.expected_execution_revision
28
+ request.completion.fail(
29
+ Phronomy::Persistence::ConflictError.new(
30
+ "Recovery resolution revision conflict: expected " \
31
+ "#{request.expected_execution_revision}, actual " \
32
+ "#{current.execution_revision}"
33
+ )
34
+ )
35
+ return
36
+ end
37
+
38
+ descriptor =
39
+ coordination_recovery_descriptor(current)
40
+ unless descriptor &&
41
+ Phronomy::Recovery.subject_equal?(
42
+ descriptor.fetch(:subject),
43
+ request.subject
44
+ )
45
+ request.completion.fail(
46
+ ArgumentError.new(
47
+ "Recovery subject is not current for execution #{request.execution_id}"
48
+ )
49
+ )
50
+ return
51
+ end
52
+
53
+ unless Array(
54
+ descriptor.fetch(:allowed_outcomes)
55
+ ).map(&:to_sym).include?(request.outcome)
56
+ request.completion.fail(
57
+ ArgumentError.new(
58
+ "Recovery outcome #{request.outcome.inspect} is not allowed for the current subject"
59
+ )
60
+ )
61
+ return
62
+ end
63
+
64
+ operation = ResolutionOperation.new(
65
+ execution: current,
66
+ root: agent.agent_root,
67
+ subject: request.subject,
68
+ outcome: request.outcome,
69
+ result: request.result,
70
+ failure: request.failure
71
+ )
72
+ event_loop.mark_agent_execution_admission(
73
+ agent.agent_id,
74
+ execution_id: current.execution_id,
75
+ state: :recovery_required
76
+ )
77
+ task = @runtime.offload.submit(
78
+ on_full: :raise
79
+ ) do
80
+ prepare_resolution(operation)
81
+ end
82
+ task.on_complete do |result, error|
83
+ ready = ResolveReady.new(
84
+ coordinator: self,
85
+ request: request,
86
+ operation: operation,
87
+ result: result,
88
+ error: error
89
+ )
90
+ unless post_control(ready)
91
+ request.completion.fail(
92
+ Phronomy::RuntimeShutdownError.new(
93
+ "EventLoop rejected Recovery resolution apply"
94
+ )
95
+ )
96
+ end
97
+ end
98
+ rescue => caught
99
+ request.completion.fail(caught)
100
+ end
101
+
102
+ def perform_resolution(operation)
103
+ case operation.subject.fetch(:type)
104
+ when :llm_call
105
+ resolve_llm(operation)
106
+ when :tool_invocation
107
+ resolve_tool(operation)
108
+ else
109
+ raise ArgumentError,
110
+ "unsupported Recovery subject: #{operation.subject.inspect}"
111
+ end
112
+ end
113
+
114
+ def resolve_llm(operation)
115
+ current = operation.execution
116
+ llm_call_id =
117
+ operation.subject.fetch(:llm_call_id).to_s
118
+ unless current.metadata[
119
+ RecoverySupport::PENDING_LLM_ID_KEY
120
+ ].to_s == llm_call_id
121
+ raise Phronomy::Persistence::ConflictError,
122
+ "LLM Recovery subject is no longer pending"
123
+ end
124
+
125
+ case operation.outcome
126
+ when :not_performed
127
+ # The factual ambiguity is resolved, but Recovery deliberately does not
128
+ # invent a generic semantic-retry contract. Without an operation-
129
+ # specific replay contract, fail the logical execution explicitly
130
+ # rather than asking the Application to provide a second, non-factual
131
+ # "resolution" or blindly redispatching the Provider call.
132
+ failure = {
133
+ "class" => "Phronomy::Error",
134
+ "message" =>
135
+ "Recovery confirmed LLM call #{llm_call_id} was not performed; " \
136
+ "automatic semantic redispatch is unavailable without a replay contract"
137
+ }.freeze
138
+ recovery = {
139
+ "version" => RecoverySupport::CONTRACT_VERSION,
140
+ "resolution_outcome" => "not_performed",
141
+ "failure" => failure
142
+ }.freeze
143
+ updated = current.with(
144
+ phase: :recovery_resolved_failed,
145
+ metadata: current.metadata.merge(
146
+ RecoverySupport::RECOVERY_METADATA_KEY => recovery
147
+ )
148
+ )
149
+ intended = ResolutionResult.new(execution: updated)
150
+ save_resolution_result(current, intended)
151
+ when :failed
152
+ recovery = {
153
+ "version" => RecoverySupport::CONTRACT_VERSION,
154
+ "failure" => operation.failure
155
+ }
156
+ updated = current.with(
157
+ phase: :recovery_resolved_failed,
158
+ metadata: current.metadata.merge(
159
+ RecoverySupport::RECOVERY_METADATA_KEY =>
160
+ recovery
161
+ )
162
+ )
163
+ intended = ResolutionResult.new(execution: updated)
164
+ save_resolution_result(current, intended)
165
+ when :succeeded
166
+ # simplecov:disable
167
+ outcome =
168
+ Phronomy::Agent::ProviderCallOutcome.from_h(
169
+ operation.result
170
+ )
171
+ updated = nil
172
+ with_resolution_f1_capture do |tx|
173
+ main = agent.send(:execution_coordinator_for, agent.__coordination_config)
174
+ snapshot = {
175
+ llm_results: [{
176
+ llm_call_id: llm_call_id,
177
+ response: outcome,
178
+ error: nil,
179
+ streaming: (
180
+ current.metadata[
181
+ RecoverySupport::INVOCATION_MODE_KEY
182
+ ].to_s == "stream"
183
+ ),
184
+ manifest_ref: current.metadata.fetch(
185
+ "manifest_ref"
186
+ ),
187
+ started_at: current.metadata[
188
+ RecoverySupport::PENDING_LLM_STARTED_AT_KEY
189
+ ] || current.updated_at
190
+ }.freeze].freeze,
191
+ runtime_events: [].freeze,
192
+ active_call: nil
193
+ }.freeze
194
+ records, calls = main.send(
195
+ :encode_runtime_records,
196
+ current,
197
+ tx: tx,
198
+ snapshot: snapshot,
199
+ context_candidate: true,
200
+ agent_root: operation.root
201
+ )
202
+ subjects =
203
+ RecoverySupport.build_tool_subjects(
204
+ current,
205
+ llm_call_id,
206
+ outcome
207
+ )
208
+ framework_subjects, external_subjects = subjects.partition { |entry| agent.__framework_call?(entry.fetch("tool_name")) }
209
+ next_phase = external_subjects.empty? ?
210
+ :recovery_provider_completed : :recovery_tools
211
+ metadata = current.metadata.dup
212
+ metadata.delete(
213
+ RecoverySupport::PENDING_LLM_ID_KEY
214
+ )
215
+ metadata.delete(
216
+ RecoverySupport::PENDING_LLM_STARTED_AT_KEY
217
+ )
218
+ metadata.delete("framework_calls_pending")
219
+ metadata["framework_calls_pending"] = true unless framework_subjects.empty?
220
+ if subjects.any? && Array(metadata[RecoverySupport::TOOL_BATCH_METADATA_KEY]).empty?
221
+ metadata[RecoverySupport::TOOL_BATCH_METADATA_KEY] = subjects.map do |entry|
222
+ {
223
+ "tool_invocation_id" => entry.fetch("tool_invocation_id"),
224
+ "llm_call_id" => entry.fetch("llm_call_id"),
225
+ "tool_call_id" => entry.fetch("tool_call_id"),
226
+ "tool_name" => entry.fetch("tool_name"),
227
+ "arguments" => RecoverySupport.canonical_copy(entry.fetch("arguments", {})),
228
+ "status" => "authorized"
229
+ }.freeze
230
+ end.freeze
231
+ end
232
+ if external_subjects.empty?
233
+ metadata.delete(
234
+ RecoverySupport::RECOVERY_METADATA_KEY
235
+ )
236
+ else
237
+ metadata[
238
+ RecoverySupport::RECOVERY_METADATA_KEY
239
+ ] = RecoverySupport.build_recovery_hash(
240
+ external_subjects
241
+ )
242
+ end
243
+ updated = current.with(
244
+ phase: next_phase,
245
+ working_records:
246
+ current.working_records + records,
247
+ llm_calls: current.llm_calls + calls,
248
+ metadata: metadata
249
+ )
250
+ tx.executions.save(
251
+ current.execution_id,
252
+ expected_revision: current.execution_revision,
253
+ execution: updated
254
+ )
255
+ ResolutionResult.new(execution: updated)
256
+ end
257
+ # simplecov:enable
258
+ end
259
+ end
260
+
261
+ def resolve_tool(operation)
262
+ # simplecov:disable
263
+ current = operation.execution
264
+ recovery = RecoverySupport.recovery_hash(current)
265
+ if recovery.nil? &&
266
+ %i[resuming dispatching_tools].include?(current.phase.to_sym)
267
+ subjects = RecoverySupport.pending_tool_subjects(current)
268
+ recovery = RecoverySupport.build_recovery_hash(subjects)
269
+ end
270
+ unless recovery
271
+ raise Phronomy::Persistence::ConflictError,
272
+ "Tool Recovery state is missing"
273
+ end
274
+
275
+ subject_entry = Array(
276
+ recovery["subjects"] || recovery[:subjects]
277
+ ).find do |entry|
278
+ hash = entry.to_h { |key, value| [key.to_s, value] }
279
+ hash.fetch("tool_invocation_id").to_s ==
280
+ operation.subject.fetch(:tool_invocation_id).to_s &&
281
+ hash.fetch("state", "unresolved") == "unresolved"
282
+ end
283
+ unless subject_entry
284
+ raise Phronomy::Persistence::ConflictError,
285
+ "Tool Recovery subject is no longer unresolved"
286
+ end
287
+ subject_entry =
288
+ subject_entry.to_h { |key, value| [key.to_s, value] }
289
+
290
+ case operation.outcome
291
+ when :not_performed
292
+ tool_id = subject_entry.fetch("tool_invocation_id").to_s
293
+ failure = {
294
+ "class" => "Phronomy::Error",
295
+ "message" =>
296
+ "Recovery confirmed Tool invocation #{tool_id} was not performed; " \
297
+ "automatic semantic redispatch is unavailable without a replay contract"
298
+ }.freeze
299
+ resolved_recovery =
300
+ RecoverySupport.update_recovery_subject(
301
+ recovery,
302
+ tool_invocation_id: tool_id,
303
+ state: :resolved,
304
+ outcome: :not_performed
305
+ ).merge(
306
+ "resolution_outcome" => "not_performed",
307
+ "failure" => failure
308
+ ).freeze
309
+ updated = current.with(
310
+ phase: :recovery_resolved_failed,
311
+ metadata: current.metadata.merge(
312
+ RecoverySupport::RECOVERY_METADATA_KEY =>
313
+ resolved_recovery
314
+ )
315
+ )
316
+ intended = ResolutionResult.new(execution: updated)
317
+ save_resolution_result(current, intended)
318
+ when :failed
319
+ recovery_with_failure =
320
+ RecoverySupport.update_recovery_subject(
321
+ recovery,
322
+ tool_invocation_id:
323
+ subject_entry.fetch("tool_invocation_id"),
324
+ state: :resolved,
325
+ outcome: :failed
326
+ ).merge(
327
+ "failure" => operation.failure
328
+ )
329
+ updated = current.with(
330
+ phase: :recovery_resolved_failed,
331
+ metadata: current.metadata.merge(
332
+ RecoverySupport::RECOVERY_METADATA_KEY =>
333
+ recovery_with_failure
334
+ )
335
+ )
336
+ intended = ResolutionResult.new(execution: updated)
337
+ save_resolution_result(current, intended)
338
+ when :succeeded
339
+ updated = nil
340
+ with_resolution_f1_capture do |tx|
341
+ main = agent.send(:execution_coordinator_for, agent.__coordination_config)
342
+ result_ref = main.send(
343
+ :put_runtime_content,
344
+ tx,
345
+ operation.result
346
+ )
347
+ message = {
348
+ "role" => "tool",
349
+ "content" => operation.result.to_s,
350
+ "tool_call_id" =>
351
+ subject_entry.fetch("tool_call_id").to_s
352
+ }
353
+ runtime_event = StreamEvent.new(
354
+ type: :tool_result,
355
+ payload: {
356
+ tool_call_id:
357
+ subject_entry.fetch("tool_call_id").to_s,
358
+ tool_name:
359
+ subject_entry.fetch("tool_name").to_s,
360
+ tool_result: operation.result,
361
+ tool_message: message,
362
+ llm_call_id:
363
+ subject_entry.fetch("llm_call_id").to_s
364
+ }.freeze
365
+ )
366
+ records, _calls = main.send(
367
+ :encode_runtime_records,
368
+ current,
369
+ tx: tx,
370
+ snapshot: {
371
+ llm_results: [].freeze,
372
+ runtime_events: [runtime_event].freeze,
373
+ active_call: nil
374
+ }.freeze,
375
+ context_candidate: true,
376
+ agent_root: operation.root
377
+ )
378
+ next_recovery =
379
+ RecoverySupport.update_recovery_subject(
380
+ recovery,
381
+ tool_invocation_id:
382
+ subject_entry.fetch("tool_invocation_id"),
383
+ state: :resolved,
384
+ outcome: :succeeded,
385
+ result_ref: result_ref
386
+ )
387
+ unresolved =
388
+ RecoverySupport.unresolved_subjects(
389
+ next_recovery
390
+ )
391
+ tool_batch = Array(current.metadata[RecoverySupport::TOOL_BATCH_METADATA_KEY]).map do |entry|
392
+ if entry.fetch("tool_invocation_id") == subject_entry.fetch("tool_invocation_id")
393
+ entry.merge("status" => "completed", "result" => operation.result)
394
+ else
395
+ entry
396
+ end
397
+ end
398
+ next_phase = unresolved.empty? ?
399
+ :recovery_tools_completed : :recovery_tools
400
+ updated = current.with(
401
+ phase: next_phase,
402
+ working_records:
403
+ current.working_records + records,
404
+ metadata: current.metadata.merge(
405
+ RecoverySupport::RECOVERY_METADATA_KEY => next_recovery,
406
+ RecoverySupport::TOOL_BATCH_METADATA_KEY => tool_batch
407
+ )
408
+ )
409
+ tx.executions.save(
410
+ current.execution_id,
411
+ expected_revision: current.execution_revision,
412
+ execution: updated
413
+ )
414
+ ResolutionResult.new(execution: updated)
415
+ end
416
+ end
417
+ end
418
+ # simplecov:enable
419
+
420
+ def known_non_f1_error?(error)
421
+ error.is_a?(Phronomy::Persistence::ConflictError) ||
422
+ error.is_a?(Phronomy::Persistence::NotFoundError) ||
423
+ error.is_a?(Phronomy::Persistence::SerializationError) ||
424
+ error.is_a?(Phronomy::Persistence::UnsupportedBackendError) ||
425
+ error.is_a?(ArgumentError) ||
426
+ error.is_a?(Phronomy::ConfigurationError)
427
+ end
428
+
429
+ def with_resolution_f1_capture
430
+ intended = nil
431
+ begin
432
+ agent.persistence.transaction do |tx|
433
+ intended = yield(tx)
434
+ intended
435
+ end
436
+ intended
437
+ rescue => caught
438
+ raise if known_non_f1_error?(caught)
439
+ raise unless intended
440
+
441
+ raise ResolutionOutcomeUnknownError.new(
442
+ caught,
443
+ intended
444
+ )
445
+ end
446
+ end
447
+
448
+ def save_resolution_result(current, result)
449
+ with_resolution_f1_capture do |tx|
450
+ tx.executions.save(
451
+ current.execution_id,
452
+ expected_revision: current.execution_revision,
453
+ execution: result.execution
454
+ )
455
+ result
456
+ end
457
+ end
458
+
459
+ # Commit/readback and materialization share one worker operation. A read
460
+ # failure after a confirmed commit must retain that committed execution;
461
+ # it must not be mistaken for another uncertain resolution write.
462
+ def prepare_resolution(operation)
463
+ result = resolve_with_readback(operation)
464
+ material = prepare_recovery_material(result.execution)
465
+ ResolutionPreparation.new(execution: result.execution, material: material, error: nil)
466
+ rescue => error
467
+ ResolutionPreparation.new(execution: result&.execution, material: nil, error: error)
468
+ end
469
+
470
+ def resolve_with_readback(operation)
471
+ perform_resolution(operation)
472
+ rescue => error
473
+ intended = error.intended_result.execution if error.is_a?(ResolutionOutcomeUnknownError)
474
+ current = agent.persistence.executions.load(operation.execution.execution_id)
475
+ return ResolutionResult.new(execution: current) if intended && current.to_h == intended.to_h
476
+
477
+ if current.to_h == operation.execution.to_h
478
+ raise(error.is_a?(ResolutionOutcomeUnknownError) ? error.original_error : error)
479
+ end
480
+ raise Phronomy::Persistence::ConflictError,
481
+ "Recovery resolution durable outcome conflicts with both expected pre-state and intended post-state"
482
+ end
483
+
484
+ def apply_resolve_on_event_loop(ready)
485
+ request = ready.request
486
+ event_loop = @runtime.event_loop
487
+ state = event_loop.agent_execution_state(request.execution_id)
488
+ unless state && state.agent.equal?(agent)
489
+ raise Phronomy::ExecutionRehydrationRequiredError,
490
+ "recovered execution disappeared before resolution apply"
491
+ end
492
+ unless state.execution.equal?(ready.operation.execution) && state.fsm_session_id.nil?
493
+ raise Phronomy::Persistence::ConflictError,
494
+ "Recovery execution changed before resolution apply"
495
+ end
496
+ raise ready.error if ready.error
497
+
498
+ result = ready.result
499
+ if result.execution
500
+ event_loop.replace_agent_execution(request.execution_id, execution: result.execution)
501
+ end
502
+ raise result.error if result.error
503
+
504
+ continue_recovery_on_event_loop(result.execution, request.completion, material: result.material)
505
+ rescue => caught
506
+ request.completion.fail(caught)
507
+ end
508
+ end
509
+ end
510
+ end
511
+ end