phronomy 0.26.0 → 0.27.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 (304) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/README.md +4 -0
  4. data/VERIFY.sh +27 -27
  5. data/benchmark/bench_agent_invoke.rb +26 -22
  6. data/benchmark/bench_context_assembler.rb +4 -5
  7. data/docs/architecture/agent-chat-and-state-ownership.md +147 -0
  8. data/docs/architecture/agent-configuration-and-tool-binding.md +139 -0
  9. data/docs/architecture/agent-context.md +4 -2
  10. data/docs/architecture/agent-transition-ownership.md +89 -0
  11. data/docs/architecture/before-llm-input.md +6 -0
  12. data/docs/architecture/context-management.md +30 -0
  13. data/docs/architecture/context-preparation-steps.md +90 -0
  14. data/docs/architecture/entry-action-and-team-wording.md +72 -0
  15. data/docs/architecture/execution-metadata-and-values.md +80 -0
  16. data/docs/architecture/generator-verifier-ownership.md +111 -0
  17. data/docs/architecture/multi-agent-handoff.md +8 -2
  18. data/docs/architecture/persistence-refactoring-plan.md +50 -0
  19. data/docs/architecture/persistence.md +27 -4
  20. data/docs/architecture/refactoring-closure.md +334 -0
  21. data/docs/architecture/remaining-refactoring-plan.md +374 -0
  22. data/docs/architecture/rubyllm-2-token-ownership.md +82 -0
  23. data/docs/architecture/tool-schema-recording-gap.md +50 -0
  24. data/docs/architecture/tracing.md +1 -1
  25. data/docs/architecture/workflow-terminal-ownership-design.md +104 -0
  26. data/docs/architecture.md +209 -0
  27. data/docs/decisions/014-unified-persistence-durable-state.md +11 -0
  28. data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +5 -1
  29. data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +13 -3
  30. data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +5 -1
  31. data/docs/decisions/030-agent-handoff-domain-and-durable-responsibility.md +5 -0
  32. data/docs/decisions/031-durable-multi-agent-coordination.md +5 -0
  33. data/docs/decisions/032-storage-backend-composition.md +80 -0
  34. data/docs/decisions/033-domain-persistence-ownership.md +79 -0
  35. data/docs/decisions/034-handoff-runner-coordination-ownership.md +76 -0
  36. data/docs/decisions/035-tool-executor-capability-ownership.md +68 -0
  37. data/docs/decisions/036-context-contract-ownership.md +86 -0
  38. data/docs/decisions/037-common-definition-ownership.md +62 -0
  39. data/docs/decisions/038-responsibility-based-source-layout.md +131 -0
  40. data/docs/decisions/039-runtime-configuration-lifecycle-ownership.md +74 -0
  41. data/docs/decisions/040-configuration-default-composition.md +94 -0
  42. data/docs/decisions/041-feature-owned-identity-registries.md +100 -0
  43. data/docs/decisions/042-feature-owned-execution-state.md +106 -0
  44. data/docs/decisions/043-storage-execution-constraint-notifications.md +75 -0
  45. data/docs/decisions/044-agent-default-and-one-shot-composition.md +106 -0
  46. data/docs/decisions/045-worker-input-restriction-ownership.md +100 -0
  47. data/docs/decisions/046-agent-responsibility-layout-and-shared-records.md +110 -0
  48. data/docs/decisions/047-recovered-execution-continuation-contract.md +116 -0
  49. data/docs/decisions/048-dispatch-preparation-worker-ownership.md +120 -0
  50. data/docs/decisions/049-initial-preparation-worker-ownership.md +111 -0
  51. data/docs/decisions/050-approval-resume-snapshot-and-commit-ownership.md +98 -0
  52. data/docs/decisions/051-execution-outcome-worker-ownership.md +151 -0
  53. data/docs/decisions/052-tool-invocation-restoration-ownership.md +92 -0
  54. data/docs/decisions/053-shared-state-coordination-ownership.md +71 -0
  55. data/docs/decisions/054-workflow-terminal-save-single-owner.md +76 -0
  56. data/docs/decisions/055-terminal-observer-failure-settlement.md +60 -0
  57. data/docs/decisions/056-workflow-terminal-policy-ownership.md +87 -0
  58. data/docs/decisions/057-storage-transaction-boundaries.md +73 -0
  59. data/docs/decisions/058-neutral-storage-primitives.md +78 -0
  60. data/docs/decisions/README.md +35 -6
  61. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_DESIGN_V2.md +6 -0
  62. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +6 -0
  63. data/docs/features.md +9 -3
  64. data/docs/getting-started.md +4 -4
  65. data/docs/migrations/0.15.md +5 -0
  66. data/docs/migrations/durable-semantic-coordination-v2.md +6 -0
  67. data/docs/migrations/handoff-runner-multi-agent.md +35 -0
  68. data/docs/migrations/neutral-storage-spi.md +43 -0
  69. data/docs/migrations/parallel-tool-chat-removal.md +49 -0
  70. data/docs/migrations/shared-state-multi-agent.md +44 -0
  71. data/docs/migrations/storage-backend-composition.md +117 -0
  72. data/docs/migrations/storage-transaction-boundaries.md +74 -0
  73. data/docs/persistence-backends.md +162 -204
  74. data/docs/runtime-and-concurrency.md +117 -32
  75. data/lib/phronomy/agent/api/agent.rb +17 -0
  76. data/lib/phronomy/agent/async_event_api.rb +2 -2
  77. data/lib/phronomy/agent/base.rb +51 -241
  78. data/lib/phronomy/{agent.rb → agent/composition/run_once.rb} +4 -13
  79. data/lib/phronomy/agent/context/capability/base.rb +67 -26
  80. data/lib/phronomy/agent/context/capability/tool_executor.rb +62 -0
  81. data/lib/phronomy/agent/{context_assembler.rb → context_assembly/context_assembler.rb} +143 -88
  82. data/lib/phronomy/agent/{context_importer.rb → context_assembly/context_importer.rb} +2 -2
  83. data/lib/phronomy/agent/{ruby_llm_materializer.rb → context_assembly/ruby_llm_materializer.rb} +4 -7
  84. data/lib/phronomy/agent/context_assembly/runtime_chat_builder.rb +36 -0
  85. data/lib/phronomy/agent/context_assembly/saved_context_reader.rb +53 -0
  86. data/lib/phronomy/agent/context_assembly/state_writer.rb +165 -0
  87. data/lib/phronomy/agent/context_assembly/token_budget_resolver.rb +22 -0
  88. data/lib/phronomy/agent/{context_plan.rb → context_contract/context_plan.rb} +1 -1
  89. data/lib/phronomy/agent/{context_policy_input.rb → context_contract/context_policy_input.rb} +6 -6
  90. data/lib/phronomy/agent/{llm_input_build_context.rb → context_contract/llm_input_build_context.rb} +1 -1
  91. data/lib/phronomy/agent/{llm_input_manifest.rb → context_contract/llm_input_manifest.rb} +20 -20
  92. data/lib/phronomy/agent/{llm_input_patch.rb → context_contract/llm_input_patch.rb} +2 -2
  93. data/lib/phronomy/agent/{agent_execution.rb → execution/agent_execution.rb} +8 -4
  94. data/lib/phronomy/agent/{agent_invocation.rb → execution/agent_invocation.rb} +8 -13
  95. data/lib/phronomy/agent/{agent_invocation_session_builder.rb → execution/agent_invocation_session_builder.rb} +19 -95
  96. data/lib/phronomy/agent/execution/approval_resume_commit.rb +108 -0
  97. data/lib/phronomy/agent/execution/dispatch_preparation.rb +305 -0
  98. data/lib/phronomy/agent/{exact_execution.rb → execution/exact_execution.rb} +9 -10
  99. data/lib/phronomy/agent/{execution_cancellation.rb → execution/execution_cancellation.rb} +2 -3
  100. data/lib/phronomy/agent/execution/execution_coordinator.rb +1925 -0
  101. data/lib/phronomy/agent/execution/execution_failure.rb +30 -0
  102. data/lib/phronomy/agent/execution/execution_metadata.rb +53 -0
  103. data/lib/phronomy/agent/execution/execution_outcome_committer.rb +344 -0
  104. data/lib/phronomy/agent/execution/execution_registry.rb +459 -0
  105. data/lib/phronomy/agent/execution/execution_session_runner.rb +118 -0
  106. data/lib/phronomy/agent/execution/initial_preparation.rb +421 -0
  107. data/lib/phronomy/agent/execution/invocation_transitions.rb +86 -0
  108. data/lib/phronomy/agent/{phase_machine_builder.rb → execution/phase_machine_builder.rb} +20 -71
  109. data/lib/phronomy/agent/{provider_call_outcome.rb → execution/provider_call_outcome.rb} +9 -9
  110. data/lib/phronomy/agent/execution/runtime_record_encoder.rb +210 -0
  111. data/lib/phronomy/agent/{handoff_context.rb → handoff/handoff_context.rb} +2 -2
  112. data/lib/phronomy/agent/handoff/handoff_execution_coordinator.rb +15 -0
  113. data/lib/phronomy/agent/handoff/handoff_outcome_committer.rb +131 -0
  114. data/lib/phronomy/agent/{handoff_state.rb → handoff/handoff_state.rb} +1 -1
  115. data/lib/phronomy/agent/{journal_projection.rb → journal/journal_projection.rb} +4 -0
  116. data/lib/phronomy/agent/{journal_record.rb → journal/journal_record.rb} +3 -3
  117. data/lib/phronomy/agent/{llm_call_record.rb → journal/llm_call_record.rb} +2 -2
  118. data/lib/phronomy/agent/{agent_root.rb → lifecycle/agent_root.rb} +6 -2
  119. data/lib/phronomy/agent/lifecycle/default_persistence.rb +29 -0
  120. data/lib/phronomy/{engine/runtime/agent_ownership_registry.rb → agent/lifecycle/ownership_registry.rb} +24 -10
  121. data/lib/phronomy/{agent_already_exists_error.rb → agent/lifecycle_contract/agent_already_exists_error.rb} +2 -0
  122. data/lib/phronomy/{agent_busy_error.rb → agent/lifecycle_contract/agent_busy_error.rb} +2 -0
  123. data/lib/phronomy/{agent_purged_error.rb → agent/lifecycle_contract/agent_purged_error.rb} +2 -0
  124. data/lib/phronomy/agent/lifecycle_contract/handoff_error.rb +7 -0
  125. data/lib/phronomy/{stream_callback_error.rb → agent/lifecycle_contract/stream_callback_error.rb} +2 -0
  126. data/lib/phronomy/agent/persistence/agent_repository.rb +61 -0
  127. data/lib/phronomy/agent/persistence/codec.rb +358 -0
  128. data/lib/phronomy/agent/persistence/execution_repository.rb +108 -0
  129. data/lib/phronomy/agent/persistence/handoff_state_repository.rb +58 -0
  130. data/lib/phronomy/agent/persistence/journal_repository.rb +54 -0
  131. data/lib/phronomy/agent/persistence/queries.rb +61 -0
  132. data/lib/phronomy/agent/persistence/storage_schema.rb +24 -0
  133. data/lib/phronomy/agent/persistence/watermark.rb +27 -0
  134. data/lib/phronomy/agent/recovery/invocation_restorer.rb +132 -0
  135. data/lib/phronomy/agent/{recovery_coordinator → recovery/recovery_coordinator}/continuation.rb +21 -45
  136. data/lib/phronomy/agent/{recovery_coordinator → recovery/recovery_coordinator}/installation.rb +32 -37
  137. data/lib/phronomy/agent/{recovery_coordinator → recovery/recovery_coordinator}/resolution.rb +37 -40
  138. data/lib/phronomy/agent/{recovery_coordinator.rb → recovery/recovery_coordinator.rb} +6 -10
  139. data/lib/phronomy/agent/recovery/recovery_support.rb +227 -0
  140. data/lib/phronomy/agent/selection/candidate.rb +1 -1
  141. data/lib/phronomy/agent/{approval_evaluation_request.rb → tool_execution/approval_evaluation_request.rb} +1 -12
  142. data/lib/phronomy/agent/{tool_approval_request.rb → tool_execution/tool_approval_request.rb} +1 -10
  143. data/lib/phronomy/agent/tool_execution/tool_binding.rb +90 -0
  144. data/lib/phronomy/agent/{tool_call_intercepted.rb → tool_execution/tool_call_intercepted.rb} +2 -2
  145. data/lib/phronomy/agent/{tool_definition_set.rb → tool_execution/tool_definition_set.rb} +9 -4
  146. data/lib/phronomy/agent/{tool_invocation.rb → tool_execution/tool_invocation.rb} +69 -34
  147. data/lib/phronomy/common/configuration_error.rb +7 -0
  148. data/lib/phronomy/common/error.rb +5 -0
  149. data/lib/phronomy/{agent → common/values}/immutable.rb +9 -1
  150. data/lib/phronomy/common/values/serializable.rb +32 -0
  151. data/lib/phronomy/{configuration.rb → configuration/configuration.rb} +14 -5
  152. data/lib/phronomy/configuration/global_configuration.rb +26 -0
  153. data/lib/phronomy/content_store/storage_schema.rb +11 -0
  154. data/lib/phronomy/content_store/stored_contents.rb +43 -0
  155. data/lib/phronomy/engine/backpressure_error.rb +7 -0
  156. data/lib/phronomy/{blocking.rb → engine/blocking.rb} +1 -1
  157. data/lib/phronomy/engine/cancellation_error.rb +7 -0
  158. data/lib/phronomy/engine/concurrency/cancellation_token.rb +4 -0
  159. data/lib/phronomy/engine/concurrency/offload_pool.rb +4 -0
  160. data/lib/phronomy/engine/concurrency/operation_binding.rb +1 -1
  161. data/lib/phronomy/engine/concurrency/worker_input_restricted.rb +14 -0
  162. data/lib/phronomy/engine/event_loop.rb +158 -624
  163. data/lib/phronomy/engine/event_loop_reentrancy_error.rb +8 -0
  164. data/lib/phronomy/{execution.rb → engine/execution.rb} +0 -7
  165. data/lib/phronomy/{execution_cancellation_error.rb → engine/execution_cancellation_error.rb} +2 -0
  166. data/lib/phronomy/engine/execution_receiver.rb +65 -0
  167. data/lib/phronomy/{execution_timeout_error.rb → engine/execution_timeout_error.rb} +2 -0
  168. data/lib/phronomy/engine/fsm_protocol.rb +14 -0
  169. data/lib/phronomy/engine/fsm_session.rb +35 -28
  170. data/lib/phronomy/{invalid_async_entry_action_error.rb → engine/invalid_async_entry_action_error.rb} +2 -0
  171. data/lib/phronomy/{invalid_async_transition_action_error.rb → engine/invalid_async_transition_action_error.rb} +2 -0
  172. data/lib/phronomy/{invalid_async_workflow_action_error.rb → engine/invalid_async_workflow_action_error.rb} +2 -0
  173. data/lib/phronomy/engine/pool_shutdown_error.rb +7 -0
  174. data/lib/phronomy/engine/recursion_limit_error.rb +7 -0
  175. data/lib/phronomy/engine/runtime.rb +80 -84
  176. data/lib/phronomy/engine/runtime_shutdown_error.rb +7 -0
  177. data/lib/phronomy/engine/runtime_shutdown_reentrancy_error.rb +7 -0
  178. data/lib/phronomy/engine/scheduler_reentrancy_error.rb +9 -0
  179. data/lib/phronomy/engine/task_result.rb +4 -0
  180. data/lib/phronomy/engine/timeout_error.rb +7 -0
  181. data/lib/phronomy/filter/contract/filter_block_error.rb +14 -0
  182. data/lib/phronomy/generation/generator_verifier/agent_result_receiver.rb +89 -0
  183. data/lib/phronomy/generation/generator_verifier/pipeline_state.rb +57 -0
  184. data/lib/phronomy/generation/generator_verifier/workflow_builder.rb +112 -0
  185. data/lib/phronomy/generation/generator_verifier.rb +118 -0
  186. data/lib/phronomy/generation/low_confidence_error.rb +14 -0
  187. data/lib/phronomy/llm_context_window/token_budget.rb +6 -7
  188. data/lib/phronomy/llm_contract/authentication_error.rb +7 -0
  189. data/lib/phronomy/{context_budget_exceeded_error.rb → llm_contract/context_budget_exceeded_error.rb} +2 -0
  190. data/lib/phronomy/llm_contract/context_length_error.rb +7 -0
  191. data/lib/phronomy/llm_contract/rate_limit_error.rb +7 -0
  192. data/lib/phronomy/{token_usage.rb → llm_contract/token_usage.rb} +2 -2
  193. data/lib/phronomy/llm_contract/transport_error.rb +7 -0
  194. data/lib/phronomy/multi_agent/admission_registry.rb +22 -2
  195. data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +5 -5
  196. data/lib/phronomy/{agent → multi_agent}/handoff_runner.rb +18 -17
  197. data/lib/phronomy/multi_agent/orchestrator.rb +2 -2
  198. data/lib/phronomy/multi_agent/persistence/codec.rb +55 -0
  199. data/lib/phronomy/multi_agent/persistence/queries.rb +30 -0
  200. data/lib/phronomy/multi_agent/persistence/team_execution_repository.rb +108 -0
  201. data/lib/phronomy/multi_agent/persistence/team_repository.rb +61 -0
  202. data/lib/phronomy/{agent → multi_agent}/shared_state.rb +56 -39
  203. data/lib/phronomy/multi_agent/storage_contract/team_storage_schema.rb +15 -0
  204. data/lib/phronomy/multi_agent/team_coordinator.rb +21 -18
  205. data/lib/phronomy/multi_agent/team_execution.rb +1 -1
  206. data/lib/phronomy/{engine/runtime → multi_agent}/team_ownership_registry.rb +14 -4
  207. data/lib/phronomy/multi_agent/team_root.rb +1 -1
  208. data/lib/phronomy/output_parser/contract/parse_error.rb +7 -0
  209. data/lib/phronomy/persistence/api/persistence.rb +140 -0
  210. data/lib/phronomy/persistence/migration/initial_format_migration.rb +19 -19
  211. data/lib/phronomy/persistence_composition/repositories.rb +77 -0
  212. data/lib/phronomy/persistence_composition/storage_schema.rb +24 -0
  213. data/lib/phronomy/{execution_rehydration_required_error.rb → recovery/execution_rehydration_required_error.rb} +2 -0
  214. data/lib/phronomy/{recovery.rb → recovery/recovery.rb} +1 -1
  215. data/lib/phronomy/runtime_composition/agent_defaults.rb +7 -0
  216. data/lib/phronomy/runtime_composition/configuration_defaults.rb +9 -0
  217. data/lib/phronomy/runtime_composition/global_runtime.rb +19 -0
  218. data/lib/phronomy/storage/backend.rb +101 -0
  219. data/lib/phronomy/storage/backends/in_memory.rb +157 -0
  220. data/lib/phronomy/storage/blob_conflict_error.rb +10 -0
  221. data/lib/phronomy/storage/blobs.rb +31 -0
  222. data/lib/phronomy/storage/condition.rb +25 -0
  223. data/lib/phronomy/storage/condition_failed_error.rb +16 -0
  224. data/lib/phronomy/storage/conflict_error.rb +9 -0
  225. data/lib/phronomy/{persistence → storage}/durable_record.rb +12 -12
  226. data/lib/phronomy/storage/entry.rb +33 -0
  227. data/lib/phronomy/storage/guard_ref.rb +13 -0
  228. data/lib/phronomy/storage/not_found_error.rb +9 -0
  229. data/lib/phronomy/storage/record_codec.rb +177 -0
  230. data/lib/phronomy/storage/records.rb +61 -0
  231. data/lib/phronomy/storage/resource.rb +126 -0
  232. data/lib/phronomy/storage/scope.rb +25 -0
  233. data/lib/phronomy/storage/serialization_error.rb +9 -0
  234. data/lib/phronomy/storage/streams.rb +44 -0
  235. data/lib/phronomy/storage/transaction_error.rb +10 -0
  236. data/lib/phronomy/storage/unique_constraint_error.rb +17 -0
  237. data/lib/phronomy/storage/unsupported_backend_error.rb +9 -0
  238. data/lib/phronomy/storage/validation.rb +53 -0
  239. data/lib/phronomy/storage/view.rb +131 -0
  240. data/lib/phronomy/testing/persistence_contract/a_content_store.rb +1 -1
  241. data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +4 -4
  242. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +10 -7
  243. data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +2 -2
  244. data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +6 -6
  245. data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +6 -6
  246. data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +8 -8
  247. data/lib/phronomy/testing/persistence_contract/neutral_storage_primitives.rb +263 -0
  248. data/lib/phronomy/testing/persistence_contract/storage_transaction_boundaries.rb +123 -0
  249. data/lib/phronomy/testing/persistence_contract.rb +4 -0
  250. data/lib/phronomy/tool/contract/tool_error.rb +7 -0
  251. data/lib/phronomy/tools/agent.rb +1 -1
  252. data/lib/phronomy/version.rb +1 -1
  253. data/lib/phronomy/{workflow.rb → workflow/execution/workflow.rb} +8 -6
  254. data/lib/phronomy/{workflow_context.rb → workflow/execution/workflow_context.rb} +4 -0
  255. data/lib/phronomy/workflow/execution/workflow_context_ownership_error.rb +7 -0
  256. data/lib/phronomy/workflow/execution/workflow_execution_registry.rb +188 -0
  257. data/lib/phronomy/{workflow_runner.rb → workflow/execution/workflow_runner.rb} +122 -71
  258. data/lib/phronomy/workflow/execution/workflow_terminal_policy.rb +40 -0
  259. data/lib/phronomy/workflow/persistence/codec.rb +153 -0
  260. data/lib/phronomy/workflow/persistence/state_repository.rb +57 -0
  261. data/lib/phronomy/workflow/phase_machine_builder.rb +2 -2
  262. data/lib/phronomy/workflow/storage_contract/workflow_storage_schema.rb +9 -0
  263. data/lib/phronomy.rb +52 -94
  264. data/scripts/api_snapshot.rb +1 -1
  265. data/scripts/storage_spi_snapshot.rb +36 -0
  266. data/sig/phronomy/agent.rbs +0 -1
  267. data/sig/phronomy/execution_receiver.rbs +34 -0
  268. data/sig/phronomy/handoff.rbs +4 -2
  269. data/sig/phronomy/multi_agent.rbs +18 -0
  270. data/sig/phronomy/persistence.rbs +7 -91
  271. data/sig/phronomy/storage.rbs +174 -0
  272. data/sig/phronomy/tool.rbs +10 -1
  273. metadata +230 -99
  274. data/lib/phronomy/agent/execution_coordinator.rb +0 -3151
  275. data/lib/phronomy/agent/handoff_execution_coordinator.rb +0 -143
  276. data/lib/phronomy/agent/recovery_support.rb +0 -504
  277. data/lib/phronomy/agent/token_budget_resolver.rb +0 -70
  278. data/lib/phronomy/agent/tool_executor.rb +0 -55
  279. data/lib/phronomy/generator_verifier.rb +0 -369
  280. data/lib/phronomy/invalid_context_budget_configuration_error.rb +0 -8
  281. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +0 -116
  282. data/lib/phronomy/persistence/durable_codec.rb +0 -706
  283. data/lib/phronomy/persistence/in_memory.rb +0 -690
  284. data/lib/phronomy/persistence/repository_facades.rb +0 -535
  285. data/lib/phronomy/persistence.rb +0 -276
  286. data/lib/phronomy/ruby_llm_patches.rb +0 -24
  287. data/lib/phronomy/workflow_recovery.rb +0 -123
  288. /data/lib/phronomy/agent/{context_candidate_resolver.rb → context_assembly/context_candidate_resolver.rb} +0 -0
  289. /data/lib/phronomy/agent/{context_policy_input_builder.rb → context_assembly/context_policy_input_builder.rb} +0 -0
  290. /data/lib/phronomy/agent/{context_plan_validator.rb → context_contract/context_plan_validator.rb} +0 -0
  291. /data/lib/phronomy/agent/{context_policy.rb → context_contract/context_policy.rb} +0 -0
  292. /data/lib/phronomy/agent/{llm_operation_result.rb → execution/llm_operation_result.rb} +0 -0
  293. /data/lib/phronomy/agent/{handoff.rb → handoff/handoff.rb} +0 -0
  294. /data/lib/phronomy/agent/{handoff_capability_factory.rb → handoff/handoff_capability_factory.rb} +0 -0
  295. /data/lib/phronomy/agent/{handoff_policy.rb → handoff/handoff_policy.rb} +0 -0
  296. /data/lib/phronomy/agent/{handoff_projection.rb → handoff/handoff_projection.rb} +0 -0
  297. /data/lib/phronomy/agent/{handoff_request.rb → handoff/handoff_request.rb} +0 -0
  298. /data/lib/phronomy/agent/{tool_invocation_session_builder.rb → tool_execution/tool_invocation_session_builder.rb} +0 -0
  299. /data/lib/phronomy/{canonical_json.rb → common/canonical_json.rb} +0 -0
  300. /data/lib/phronomy/{diagnostics.rb → engine/diagnostics.rb} +0 -0
  301. /data/lib/phronomy/{event.rb → engine/event.rb} +0 -0
  302. /data/lib/phronomy/{invocation_context.rb → engine/invocation_context.rb} +0 -0
  303. /data/lib/phronomy/{metrics.rb → engine/metrics.rb} +0 -0
  304. /data/lib/phronomy/{runnable.rb → engine/runnable.rb} +0 -0
@@ -0,0 +1,1925 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../engine/concurrency/worker_input_restricted"
4
+
5
+ require "time"
6
+ require "securerandom"
7
+
8
+ module Phronomy
9
+ module Agent
10
+ class ExecutionCoordinator
11
+ include Phronomy::Concurrency::WorkerInputRestricted
12
+
13
+ # External/API -> EventLoop control messages.
14
+ StartCommand = Data.define(
15
+ :coordinator, :input, :config, :mode,
16
+ :approval_policy, :approval_listener, :on_event, :result_task,
17
+ :admission_token
18
+ )
19
+ ResumeCommand = Data.define(
20
+ :coordinator, :execution_id, :approval_request_id,
21
+ :approved, :config, :result_task
22
+ )
23
+
24
+ # Recovery -> execution-owner commands. These stay on EventLoop and do
25
+ # not carry persistence readers or arbitrary private method names.
26
+ RecoverPreparationCommand = Data.define(
27
+ :coordinator, :execution_id, :expected_execution_revision,
28
+ :result_task, :load_completion
29
+ )
30
+ ContinueRecoveredCommand = Data.define(
31
+ :coordinator, :execution_id, :expected_execution_revision,
32
+ :continuation, :invocation, :runtime_projection, :result_task, :error
33
+ )
34
+ SessionFinishedCommand = Data.define(
35
+ :coordinator, :execution_id, :result_task, :invocation, :error, :fsm_session_id
36
+ )
37
+
38
+ # EventLoop -> Offload operation-specific immutable snapshots.
39
+ InitialPreparationCommand = InitialPreparation::Command
40
+ ProviderDispatchPreparationCommand = DispatchPreparation::ProviderCommand
41
+ ToolDispatchPreparationCommand = DispatchPreparation::ToolCommand
42
+ ProviderDispatchPreparationReconciliationCommand = DispatchPreparation::ProviderReconciliationCommand
43
+ ToolDispatchPreparationReconciliationCommand = DispatchPreparation::ToolReconciliationCommand
44
+ ResumeCommitCommand = ApprovalResumeCommit::Command
45
+ HandoffTerminalView = ExecutionOutcomeCommitter::HandoffTerminalView
46
+ TerminalView = ExecutionOutcomeCommitter::TerminalView
47
+ TerminalCommitCommand = ExecutionOutcomeCommitter::Command
48
+ TerminalDelivery = Data.define(
49
+ :result_task, :application_listener, :approval_listener,
50
+ :handoff_request, :handoff_manifest
51
+ )
52
+
53
+ # Offload -> EventLoop operation results. No live Agent/Invocation object is
54
+ # mutated by a worker; these values are validated and applied on EventLoop.
55
+ InitialPreparationResult = InitialPreparation::Result
56
+ ProviderDispatchPreparationResult = DispatchPreparation::ProviderResult
57
+ ToolDispatchPreparationResult = DispatchPreparation::ToolResult
58
+ ProviderDispatchPreparationReconciliationResult = DispatchPreparation::ProviderReconciliationResult
59
+ ToolDispatchPreparationReconciliationResult = DispatchPreparation::ToolReconciliationResult
60
+ ResumeCommitResult = ApprovalResumeCommit::Result
61
+ TerminalOutcome = ExecutionOutcomeCommitter::Outcome
62
+
63
+ InitialPreparationReady = Data.define(
64
+ :coordinator, :request, :result, :error
65
+ )
66
+ InitialPreparationRecoveryReady = Data.define(
67
+ :coordinator, :execution_id, :expected_execution_revision,
68
+ :result_task, :load_completion, :result, :error
69
+ )
70
+ ProviderDispatchPreparationReady = Data.define(
71
+ :coordinator, :operation, :result, :error
72
+ )
73
+ ToolDispatchPreparationReady = Data.define(
74
+ :coordinator, :operation, :result, :error
75
+ )
76
+ ProviderDispatchPreparationReconciliationReady = Data.define(
77
+ :coordinator, :command, :result, :error
78
+ )
79
+ ToolDispatchPreparationReconciliationReady = Data.define(
80
+ :coordinator, :command, :result, :error
81
+ )
82
+ ResumeCommitReady = Data.define(
83
+ :coordinator, :request, :operation, :result, :error
84
+ )
85
+ TerminalCommitReady = Data.define(
86
+ :coordinator, :operation, :delivery, :outcome, :error
87
+ )
88
+ DeferredTerminalCommand = Data.define(
89
+ :coordinator, :execution_id, :result_task, :invocation,
90
+ :source_error, :fsm_session_id
91
+ )
92
+
93
+ PreparationOutcomeUnknownError = DispatchPreparation::OutcomeUnknownError
94
+ private_constant :PreparationOutcomeUnknownError
95
+
96
+ def initialize(agent)
97
+ @agent = agent
98
+ @initial_preparation = InitialPreparation.new(agent: agent, persistence: agent.persistence)
99
+ @dispatch_preparation = DispatchPreparation.new(agent: agent, persistence: agent.persistence)
100
+ @approval_resume_commit = ApprovalResumeCommit.new(agent_id: agent.agent_id, persistence: agent.persistence)
101
+ @outcome_committer = build_outcome_committer
102
+ end
103
+
104
+ def start(
105
+ input, config: {}, mode: :invoke,
106
+ approval_policy: nil, approval_listener: nil, on_event: nil
107
+ )
108
+ @agent.send(:__assert_live_agent!)
109
+ @agent.send(:_reject_removed_generic_identity_keys!, config)
110
+ result_task = Phronomy::TaskResult.deferred(name: "agent-#{@agent.agent_id}-#{mode}")
111
+ if config[:invocation_context]
112
+ binding = Phronomy::Concurrency::OperationBinding.new(
113
+ invocation_context: config[:invocation_context],
114
+ cancellation_token: config[:cancellation_token]
115
+ )
116
+ binding.bind(result_task)
117
+ config = config.merge(cancellation_token: binding.token)
118
+ end
119
+ command = StartCommand.new(
120
+ coordinator: self,
121
+ input: input,
122
+ config: @agent.__invocation_config(config).dup.freeze,
123
+ mode: mode.to_sym,
124
+ approval_policy: approval_policy,
125
+ approval_listener: approval_listener,
126
+ on_event: on_event,
127
+ result_task: result_task,
128
+ admission_token: Object.new.freeze
129
+ )
130
+ unless post_control(Phronomy::Runtime.instance, command)
131
+ fail_task(result_task, runtime_rejected_error(:start))
132
+ end
133
+ result_task
134
+ rescue => error
135
+ fail_task(result_task, translated(error)) if defined?(result_task) && result_task
136
+ result_task
137
+ end
138
+
139
+ def resume(
140
+ execution_id,
141
+ approval_request_id:,
142
+ approved:,
143
+ config: {}
144
+ )
145
+ @agent.send(:__assert_live_agent!)
146
+ @agent.send(:_reject_removed_generic_identity_keys!, config)
147
+ result_task = Phronomy::TaskResult.deferred(
148
+ name: "agent-approval-resume:#{execution_id}"
149
+ )
150
+ command = ResumeCommand.new(
151
+ coordinator: self,
152
+ execution_id: execution_id.to_s.freeze,
153
+ approval_request_id: approval_request_id.to_s.freeze,
154
+ approved: !!approved,
155
+ config: config.dup.freeze,
156
+ result_task: result_task
157
+ )
158
+ unless post_control(Phronomy::Runtime.instance, command)
159
+ fail_task(result_task, runtime_rejected_error(:resume))
160
+ end
161
+ result_task
162
+ rescue => error
163
+ fail_task(result_task, translated(error)) if defined?(result_task) && result_task
164
+ result_task
165
+ end
166
+
167
+ # Called only from an Agent FSMSession external-operation dispatch state on
168
+ # EventLoop. Each method captures one operation-specific immutable snapshot
169
+ # and sends only that snapshot to OffloadPool.
170
+ # @api private
171
+ def prepare_provider_dispatch(invocation, event_sink:, streaming:)
172
+ state = dispatch_preparation_state!(
173
+ invocation, event_sink, phase: :calling_llm, kind: "Provider"
174
+ )
175
+ operation = capture_provider_preparation(
176
+ state, invocation, fsm_session_id: event_sink.fsm_session_id, streaming: streaming
177
+ )
178
+ submit_provider_dispatch_preparation(operation)
179
+ nil
180
+ rescue => error
181
+ event_sink.post(:llm_setup_failed, translated(error))
182
+ nil
183
+ end
184
+
185
+ # @api private
186
+ def prepare_tool_dispatch(invocation, event_sink:)
187
+ state = dispatch_preparation_state!(
188
+ invocation, event_sink, phase: :dispatching_tools, kind: "Tool"
189
+ )
190
+ operation = capture_tool_preparation(
191
+ state, invocation, fsm_session_id: event_sink.fsm_session_id
192
+ )
193
+ submit_tool_dispatch_preparation(operation)
194
+ nil
195
+ rescue => error
196
+ event_sink.post(:tool_setup_failed, translated(error))
197
+ nil
198
+ end
199
+
200
+ def submit_provider_dispatch_preparation(operation)
201
+ runtime = Phronomy::Runtime.instance
202
+ task = runtime.offload.submit(on_full: :raise) do
203
+ @dispatch_preparation.prepare_provider(operation)
204
+ end
205
+ task.on_complete do |result, error|
206
+ ready = ProviderDispatchPreparationReady.new(
207
+ coordinator: self,
208
+ operation: operation,
209
+ result: result,
210
+ error: error
211
+ )
212
+ unless post_control(runtime, ready)
213
+ Phronomy.configuration.logger&.warn(
214
+ "[Phronomy] EventLoop rejected Provider dispatch preparation result for " \
215
+ "#{operation.execution_id}"
216
+ )
217
+ end
218
+ end
219
+ end
220
+
221
+ def submit_tool_dispatch_preparation(operation)
222
+ runtime = Phronomy::Runtime.instance
223
+ task = runtime.offload.submit(on_full: :raise) do
224
+ @dispatch_preparation.prepare_tools(operation)
225
+ end
226
+ task.on_complete do |result, error|
227
+ ready = ToolDispatchPreparationReady.new(
228
+ coordinator: self,
229
+ operation: operation,
230
+ result: result,
231
+ error: error
232
+ )
233
+ unless post_control(runtime, ready)
234
+ Phronomy.configuration.logger&.warn(
235
+ "[Phronomy] EventLoop rejected Tool dispatch preparation result for " \
236
+ "#{operation.execution_id}"
237
+ )
238
+ end
239
+ end
240
+ end
241
+
242
+ def submit_provider_dispatch_preparation_reconciliation(operation, uncertainty)
243
+ # simplecov:disable
244
+ runtime = Phronomy::Runtime.instance
245
+ command = ProviderDispatchPreparationReconciliationCommand.new(
246
+ operation: operation,
247
+ intended_result: uncertainty.intended_result,
248
+ original_error: uncertainty.original_error
249
+ )
250
+ task = runtime.offload.submit(on_full: :raise) do
251
+ @dispatch_preparation.reconcile_provider(command)
252
+ end
253
+ task.on_complete do |result, error|
254
+ ready = ProviderDispatchPreparationReconciliationReady.new(
255
+ coordinator: self,
256
+ command: command,
257
+ result: result,
258
+ error: error
259
+ )
260
+ unless post_control(runtime, ready)
261
+ Phronomy.configuration.logger&.warn(
262
+ "[Phronomy] EventLoop rejected Provider dispatch reconciliation result for #{operation.execution_id}"
263
+ )
264
+ end
265
+ end
266
+ rescue => error
267
+ mark_barrier_recovery_required(operation, error)
268
+ # simplecov:enable
269
+ end
270
+
271
+ def submit_tool_dispatch_preparation_reconciliation(operation, uncertainty)
272
+ # simplecov:disable
273
+ runtime = Phronomy::Runtime.instance
274
+ command = ToolDispatchPreparationReconciliationCommand.new(
275
+ operation: operation,
276
+ intended_result: uncertainty.intended_result,
277
+ original_error: uncertainty.original_error
278
+ )
279
+ task = runtime.offload.submit(on_full: :raise) do
280
+ @dispatch_preparation.reconcile_tools(command)
281
+ end
282
+ task.on_complete do |result, error|
283
+ ready = ToolDispatchPreparationReconciliationReady.new(
284
+ coordinator: self,
285
+ command: command,
286
+ result: result,
287
+ error: error
288
+ )
289
+ unless post_control(runtime, ready)
290
+ Phronomy.configuration.logger&.warn(
291
+ "[Phronomy] EventLoop rejected Tool dispatch reconciliation result for #{operation.execution_id}"
292
+ )
293
+ end
294
+ end
295
+ rescue => error
296
+ mark_barrier_recovery_required(operation, error)
297
+ # simplecov:enable
298
+ end
299
+
300
+ # Every control message is delivered by EventLoop. This method is the only
301
+ # coordinator entry point allowed to advance Phronomy-managed live state.
302
+ # @api private
303
+ def deliver_on_event_loop(command)
304
+ event_loop = Phronomy::Runtime.instance.event_loop
305
+ assert_event_loop!(event_loop)
306
+
307
+ case command
308
+ when StartCommand
309
+ begin_start_on_event_loop(command)
310
+ when RecoverPreparationCommand
311
+ recover_preparation_on_event_loop(command)
312
+ when ContinueRecoveredCommand
313
+ continue_recovered_on_event_loop(command)
314
+ when SessionFinishedCommand
315
+ finish_on_event_loop(command.execution_id, command.result_task,
316
+ command.invocation, command.error, fsm_session_id: command.fsm_session_id)
317
+ when InitialPreparationReady
318
+ apply_initial_preparation_on_event_loop(command)
319
+ when InitialPreparationRecoveryReady
320
+ apply_initial_preparation_recovery_on_event_loop(command)
321
+ when ProviderDispatchPreparationReady
322
+ apply_provider_dispatch_preparation_on_event_loop(command)
323
+ when ToolDispatchPreparationReady
324
+ apply_tool_dispatch_preparation_on_event_loop(command)
325
+ when ProviderDispatchPreparationReconciliationReady
326
+ apply_provider_dispatch_preparation_reconciliation_on_event_loop(command)
327
+ when ToolDispatchPreparationReconciliationReady
328
+ apply_tool_dispatch_preparation_reconciliation_on_event_loop(command)
329
+ when ResumeCommand
330
+ begin_resume_on_event_loop(command)
331
+ when ResumeCommitReady
332
+ apply_resume_commit_on_event_loop(command)
333
+ when TerminalCommitReady
334
+ apply_terminal_commit_on_event_loop(command)
335
+ when DeferredTerminalCommand
336
+ resume_deferred_terminal_on_event_loop(command)
337
+ else
338
+ raise Phronomy::Error, "unknown Agent control command: #{command.class}"
339
+ end
340
+ end
341
+
342
+ private
343
+
344
+ # ----------------------------------------------------------------------
345
+ # Initial preparation
346
+ # ----------------------------------------------------------------------
347
+
348
+ def begin_start_on_event_loop(request)
349
+ runtime = Phronomy::Runtime.instance
350
+ event_loop = runtime.event_loop
351
+ scope = request.config[:invocation_context]&.__execution_scope
352
+ if scope && (!scope.__open? || request.config[:cancellation_token]&.cancelled?)
353
+ deliver_start_failure_on_event_loop(request, scope.__cancellation_error)
354
+ return
355
+ end
356
+ root = @agent.agent_root
357
+ if root.lifecycle_status == :closed
358
+ deliver_start_failure_on_event_loop(
359
+ request,
360
+ Phronomy::Error.new("agent is closed: #{@agent.agent_id}")
361
+ )
362
+ return
363
+ end
364
+
365
+ @agent.send(:__assert_live_agent!)
366
+ admitted = false
367
+ submitted = false
368
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).admit_agent_execution(
369
+ @agent.agent_id,
370
+ owner_token: request.admission_token
371
+ )
372
+ admitted = true
373
+
374
+ # Ownership purge may begin on an application thread between the first
375
+ # live-owner check and EventLoop admission. Re-check after the slot is
376
+ # installed: once this check succeeds, purge observes the admission and
377
+ # must abort instead of deleting the Agent underneath this start.
378
+ @agent.send(:__assert_live_agent!)
379
+
380
+ operation = capture_initial_preparation(request, root)
381
+ task = runtime.offload.submit(on_full: :raise) do
382
+ @initial_preparation.prepare(operation)
383
+ end
384
+ submitted = true
385
+ task.on_complete do |result, error|
386
+ ready = InitialPreparationReady.new(
387
+ coordinator: self,
388
+ request: request,
389
+ result: result,
390
+ error: error
391
+ )
392
+ fail_task(request.result_task, runtime_rejected_error(:initial_preparation)) unless
393
+ post_control(runtime, ready)
394
+ end
395
+ rescue => error
396
+ if admitted
397
+ if submitted
398
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).mark_agent_admission_recovery_required(
399
+ @agent.agent_id,
400
+ owner_token: request.admission_token
401
+ )
402
+ else
403
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).release_agent_execution_admission(
404
+ @agent.agent_id,
405
+ owner_token: request.admission_token
406
+ )
407
+ end
408
+ end
409
+ deliver_start_failure_on_event_loop(request, translated(error))
410
+ end
411
+
412
+ def capture_initial_preparation(request, root)
413
+ InitialPreparationCommand.new(
414
+ root: root,
415
+ journal_records: @agent.send(:_journal_records_snapshot),
416
+ input: request.input,
417
+ config: request.config,
418
+ preparation_replayable: initial_preparation_replayable?(
419
+ request.input,
420
+ request.config,
421
+ request.approval_policy
422
+ )
423
+ )
424
+ end
425
+
426
+ def initial_preparation_replayable?(input, config, approval_policy)
427
+ return false unless input.is_a?(String)
428
+ return false unless approval_policy.nil?
429
+ if config.key?(:phronomy_handoff_bindings) ||
430
+ config.key?(:phronomy_handoff_context)
431
+ return false unless config[:phronomy_coordination]&.fetch("kind", nil) == "handoff"
432
+ end
433
+
434
+ invocation_context = config[:invocation_context]
435
+ return true unless invocation_context
436
+
437
+ %i[approval_policy redaction_policy token_budget].none? do |name|
438
+ invocation_context.respond_to?(name) &&
439
+ !invocation_context.public_send(name).nil?
440
+ end
441
+ end
442
+
443
+ def apply_initial_preparation_on_event_loop(ready)
444
+ request = ready.request
445
+ event_loop = Phronomy::Runtime.instance.event_loop
446
+ if ready.error
447
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).mark_agent_admission_recovery_required(
448
+ @agent.agent_id,
449
+ owner_token: request.admission_token
450
+ )
451
+ deliver_start_failure_on_event_loop(request, translated(ready.error))
452
+ return
453
+ end
454
+
455
+ result = ready.result
456
+ case result.admission_outcome
457
+ when :not_established
458
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).release_agent_execution_admission(
459
+ @agent.agent_id,
460
+ owner_token: request.admission_token
461
+ )
462
+ deliver_start_failure_on_event_loop(request, result.error)
463
+ return
464
+ when :outcome_unknown, :recovery_required
465
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).mark_agent_admission_recovery_required(
466
+ @agent.agent_id,
467
+ owner_token: request.admission_token
468
+ )
469
+ deliver_start_failure_on_event_loop(request, result.error)
470
+ return
471
+ when :active, :terminal
472
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).bind_agent_execution_admission(
473
+ @agent.agent_id,
474
+ owner_token: request.admission_token,
475
+ execution_id: result.execution.execution_id
476
+ )
477
+ else
478
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).mark_agent_admission_recovery_required(
479
+ @agent.agent_id,
480
+ owner_token: request.admission_token
481
+ )
482
+ raise Phronomy::Error,
483
+ "unknown initial admission outcome: #{result.admission_outcome.inspect}"
484
+ end
485
+
486
+ apply_agent_live_state(
487
+ root: result.root,
488
+ appended_records: result.appended_records
489
+ )
490
+ if result.admission_outcome == :terminal
491
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).release_agent_execution_admission(
492
+ @agent.agent_id,
493
+ execution_id: result.execution.execution_id
494
+ )
495
+ deliver_start_failure_on_event_loop(request, result.error)
496
+ return
497
+ end
498
+
499
+ register_initial_session_on_event_loop(request, result)
500
+ rescue => error
501
+ # Preparation may already be durably active. Terminalize through another
502
+ # offloaded durable operation rather than releasing the admission blindly.
503
+ begin_terminal_without_session_on_event_loop(
504
+ request: request,
505
+ prepared: ready.result,
506
+ error: error
507
+ )
508
+ end
509
+
510
+ def register_initial_session_on_event_loop(request, prepared)
511
+ runtime = Phronomy::Runtime.instance
512
+ event_loop = runtime.event_loop
513
+ Phronomy::Tracing::Automatic.observe_task(
514
+ request.result_task,
515
+ "agent.execution",
516
+ input: request.input,
517
+ agent_id: @agent.agent_id,
518
+ execution_id: prepared.execution.execution_id,
519
+ mode: request.mode,
520
+ **@agent.send(:_build_caller_meta, prepared.config)
521
+ )
522
+ effective_config = prepared.config.merge(
523
+ phronomy_execution_coordinator: self,
524
+ phronomy_runtime_projection: prepared.runtime_projection,
525
+ phronomy_filtered_input: prepared.filtered_input,
526
+ execution_id: prepared.execution.execution_id
527
+ )
528
+ session = Agent::AgentInvocationSessionBuilder.build(
529
+ agent: @agent,
530
+ input: prepared.filtered_input,
531
+ config: effective_config,
532
+ approval_policy: request.approval_policy,
533
+ approval_listener: request.approval_listener,
534
+ mode: request.mode,
535
+ on_event: request.on_event,
536
+ runtime: runtime
537
+ )
538
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).install_agent_execution(
539
+ execution_id: prepared.execution.execution_id,
540
+ agent: @agent,
541
+ coordinator: self,
542
+ execution: prepared.execution,
543
+ runtime_projection: prepared.runtime_projection,
544
+ base_manifest: prepared.runtime_projection.manifest,
545
+ invocation: session.context,
546
+ fsm_session_id: session.id
547
+ )
548
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).register_agent_completion_waiter(
549
+ prepared.execution.execution_id,
550
+ request.result_task
551
+ )
552
+ execution_session_runner(runtime).register(
553
+ session, request.result_task
554
+ )
555
+ # simplecov:disable
556
+ rescue => _error
557
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).release_agent_execution(prepared.execution.execution_id) if
558
+ event_loop&.current? && Phronomy::Agent::ExecutionRegistry.for(event_loop).agent_execution_state(prepared.execution.execution_id)
559
+ raise
560
+ end
561
+ # simplecov:enable
562
+
563
+ def deliver_start_failure_on_event_loop(request, error)
564
+ callback_error = @agent.send(
565
+ :_deliver_stream_event,
566
+ request.on_event,
567
+ StreamEvent.new(
568
+ type: terminal_event_type(error),
569
+ payload: {error: error}
570
+ )
571
+ )
572
+ settle_after_terminal(
573
+ request.result_task,
574
+ callback_error,
575
+ terminal_event_type(error),
576
+ nil,
577
+ error
578
+ )
579
+ end
580
+
581
+ # Restarts only the replay-safe initial preparation region of an already
582
+ # durably admitted logical Execution. Runtime-only caller state is not
583
+ # reconstructed here; the durable preparation inputs are authoritative.
584
+ # @api private
585
+ def start_initial_preparation_recovery_on_event_loop(
586
+ execution,
587
+ result_task,
588
+ load_completion:
589
+ )
590
+ runtime = Phronomy::Runtime.instance
591
+ event_loop = runtime.event_loop
592
+ assert_event_loop!(event_loop)
593
+ state = Phronomy::Agent::ExecutionRegistry.for(event_loop).agent_execution_state(execution.execution_id)
594
+ unless state && state.agent.equal?(@agent) &&
595
+ state.execution.execution_revision == execution.execution_revision &&
596
+ state.execution.status == :preparing &&
597
+ state.execution.phase.to_sym == :preparing
598
+ error = Phronomy::ExecutionRehydrationRequiredError.new(
599
+ "stale :preparing Recovery installation for #{execution.execution_id}"
600
+ )
601
+ fail_task(result_task, error)
602
+ load_completion.fail(error)
603
+ return
604
+ end
605
+
606
+ operation = InitialPreparation::RecoveryCommand.new(
607
+ execution: execution,
608
+ root: @agent.agent_root,
609
+ journal_records: @agent.send(:_journal_records_snapshot)
610
+ )
611
+ task = runtime.offload.submit(on_full: :raise) do
612
+ @initial_preparation.recover(operation)
613
+ end
614
+ task.on_complete do |result, error|
615
+ ready = InitialPreparationRecoveryReady.new(
616
+ coordinator: self,
617
+ execution_id: execution.execution_id.to_s.freeze,
618
+ expected_execution_revision: execution.execution_revision,
619
+ result_task: result_task,
620
+ load_completion: load_completion,
621
+ result: result,
622
+ error: error
623
+ )
624
+ unless post_control(runtime, ready)
625
+ rejected = runtime_rejected_error(:initial_preparation_recovery)
626
+ fail_task(result_task, rejected)
627
+ load_completion.fail(rejected)
628
+ end
629
+ end
630
+ rescue => error
631
+ # simplecov:disable
632
+ begin
633
+ if defined?(event_loop) && event_loop.current?
634
+ release_initial_preparation_recovery_runtime_state(
635
+ event_loop,
636
+ execution.execution_id
637
+ )
638
+ end
639
+ rescue
640
+ nil
641
+ end
642
+ translated_error = translated(error)
643
+ fail_task(result_task, translated_error)
644
+ load_completion.fail(translated_error)
645
+ # simplecov:enable
646
+ end
647
+
648
+ def apply_initial_preparation_recovery_on_event_loop(ready)
649
+ event_loop = Phronomy::Runtime.instance.event_loop
650
+ state = Phronomy::Agent::ExecutionRegistry.for(event_loop).agent_execution_state(ready.execution_id)
651
+ unless state && state.agent.equal?(@agent) &&
652
+ state.execution.execution_revision == ready.expected_execution_revision &&
653
+ state.execution.status == :preparing &&
654
+ state.execution.phase.to_sym == :preparing
655
+ error = Phronomy::ExecutionRehydrationRequiredError.new(
656
+ "stale initial preparation Recovery result for #{ready.execution_id}"
657
+ )
658
+ fail_task(ready.result_task, error)
659
+ ready.load_completion.fail(error)
660
+ return
661
+ end
662
+
663
+ if ready.error
664
+ release_initial_preparation_recovery_runtime_state(
665
+ event_loop,
666
+ ready.execution_id
667
+ )
668
+ error = translated(ready.error)
669
+ fail_task(ready.result_task, error)
670
+ ready.load_completion.fail(error)
671
+ return
672
+ end
673
+
674
+ result = ready.result
675
+ case result.admission_outcome
676
+ when :terminal
677
+ settle_failed_preparation_recovery_on_event_loop(ready, event_loop)
678
+ ready.load_completion.complete(@agent)
679
+ when :active
680
+ restart_prepared_execution_on_event_loop(ready, event_loop)
681
+ ready.load_completion.complete(@agent)
682
+ else
683
+ error = Phronomy::ExecutionRehydrationRequiredError.new(
684
+ "unexpected initial preparation Recovery outcome: #{result.admission_outcome.inspect}"
685
+ )
686
+ release_initial_preparation_recovery_runtime_state(
687
+ event_loop,
688
+ ready.execution_id
689
+ )
690
+ fail_task(ready.result_task, error)
691
+ ready.load_completion.fail(error)
692
+ end
693
+ rescue => error
694
+ # simplecov:disable
695
+ begin
696
+ release_initial_preparation_recovery_runtime_state(
697
+ event_loop,
698
+ ready.execution_id
699
+ )
700
+ rescue
701
+ nil
702
+ end
703
+ translated_error = translated(error)
704
+ fail_task(ready.result_task, translated_error)
705
+ ready.load_completion.fail(translated_error)
706
+ # simplecov:enable
707
+ end
708
+
709
+ def settle_failed_preparation_recovery_on_event_loop(ready, event_loop)
710
+ result = ready.result
711
+ apply_agent_live_state(
712
+ root: result.root,
713
+ appended_records: result.appended_records
714
+ )
715
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).replace_agent_execution(
716
+ ready.execution_id,
717
+ execution: result.execution
718
+ )
719
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).release_agent_execution(ready.execution_id)
720
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).release_agent_execution_admission(
721
+ @agent.agent_id,
722
+ execution_id: ready.execution_id
723
+ )
724
+ event_type = terminal_event_type(result.error)
725
+ callback_error = deliver_terminal(
726
+ @agent.send(:_phronomy_event_listener),
727
+ event_type,
728
+ error: result.error
729
+ )
730
+ settle_after_terminal(
731
+ ready.result_task,
732
+ callback_error,
733
+ event_type,
734
+ nil,
735
+ result.error
736
+ )
737
+ end
738
+
739
+ def restart_prepared_execution_on_event_loop(ready, event_loop)
740
+ result = ready.result
741
+ apply_agent_live_state(
742
+ root: result.root,
743
+ appended_records: result.appended_records
744
+ )
745
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).release_agent_execution(ready.execution_id)
746
+ mode = (
747
+ result.execution.metadata[ExecutionMetadata::INVOCATION_MODE_KEY] ||
748
+ "invoke"
749
+ ).to_sym
750
+ request = StartCommand.new(
751
+ coordinator: self,
752
+ input: result.filtered_input,
753
+ config: result.config,
754
+ mode: mode,
755
+ approval_policy: nil,
756
+ approval_listener: nil,
757
+ on_event: @agent.send(:_phronomy_event_listener),
758
+ result_task: ready.result_task,
759
+ admission_token: Object.new.freeze
760
+ )
761
+ begin
762
+ register_initial_session_on_event_loop(request, result)
763
+ rescue => error
764
+ begin_terminal_without_session_on_event_loop(
765
+ request: request,
766
+ prepared: result,
767
+ error: error
768
+ )
769
+ end
770
+ end
771
+
772
+ def release_initial_preparation_recovery_runtime_state(
773
+ event_loop,
774
+ execution_id
775
+ )
776
+ state = Phronomy::Agent::ExecutionRegistry.for(event_loop).agent_execution_state(execution_id)
777
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).release_agent_execution(execution_id) if state
778
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).release_agent_execution_admission(
779
+ @agent.agent_id,
780
+ execution_id: execution_id
781
+ )
782
+ end
783
+
784
+ # ----------------------------------------------------------------------
785
+ # External-operation causal durable barriers
786
+ # ----------------------------------------------------------------------
787
+
788
+ def dispatch_preparation_state!(invocation, event_sink, phase:, kind:)
789
+ event_loop = Phronomy::Runtime.instance.event_loop
790
+ assert_event_loop!(event_loop)
791
+ state = ExecutionRegistry.for(event_loop).agent_execution_state(invocation.execution_id)
792
+ validate_live_session!(state, invocation, event_sink.fsm_session_id)
793
+ unless event_loop.fsm_session_state(event_sink.fsm_session_id) == phase
794
+ raise Phronomy::Error,
795
+ "#{kind} dispatch preparation requires the owning FSMSession to be in #{phase.inspect}"
796
+ end
797
+ state
798
+ end
799
+
800
+ def capture_provider_preparation(state, invocation, fsm_session_id:, streaming:)
801
+ ProviderDispatchPreparationCommand.new(
802
+ execution_id: state.execution_id,
803
+ fsm_session_id: fsm_session_id.to_s.freeze,
804
+ expected_execution_revision: state.execution.execution_revision,
805
+ root: @agent.agent_root,
806
+ journal_records: @agent.send(:_journal_records_snapshot),
807
+ execution: state.execution,
808
+ base_manifest: state.base_manifest,
809
+ invocation_config: invocation.config.dup.freeze,
810
+ runtime_snapshot: invocation.runtime_snapshot,
811
+ streaming: !!streaming,
812
+ pending_llm_call_id: SecureRandom.uuid.to_s.freeze,
813
+ pending_llm_started_at: Time.now.utc.iso8601(6).freeze
814
+ )
815
+ end
816
+
817
+ def capture_tool_preparation(state, invocation, fsm_session_id:)
818
+ tool_batch_snapshot = ExecutionMetadata.build_tool_batch_snapshot(invocation)
819
+ if tool_batch_snapshot.empty?
820
+ raise Phronomy::Error, "Tool dispatch preparation requires a non-empty Tool batch"
821
+ end
822
+
823
+ ToolDispatchPreparationCommand.new(
824
+ execution_id: state.execution_id,
825
+ fsm_session_id: fsm_session_id.to_s.freeze,
826
+ expected_execution_revision: state.execution.execution_revision,
827
+ root: @agent.agent_root,
828
+ execution: state.execution,
829
+ runtime_snapshot: invocation.runtime_snapshot,
830
+ tool_batch_snapshot: tool_batch_snapshot
831
+ )
832
+ end
833
+
834
+ def apply_provider_dispatch_preparation_on_event_loop(ready)
835
+ operation = ready.operation
836
+ state = authoritative_state_for_operation(
837
+ execution_id: operation.execution_id,
838
+ fsm_session_id: operation.fsm_session_id,
839
+ expected_execution_revision: operation.expected_execution_revision,
840
+ expected_fsm_state: :calling_llm
841
+ )
842
+ return unless state
843
+
844
+ if ready.error
845
+ if ready.error.is_a?(PreparationOutcomeUnknownError)
846
+ submit_provider_dispatch_preparation_reconciliation(
847
+ operation,
848
+ ready.error
849
+ )
850
+ else
851
+ state.invocation.event_sink.post(:llm_setup_failed, translated(ready.error))
852
+ end
853
+ return
854
+ end
855
+
856
+ apply_confirmed_provider_dispatch_preparation_on_event_loop(
857
+ operation,
858
+ ready.result,
859
+ state
860
+ )
861
+ rescue => error
862
+ state&.invocation&.event_sink&.post(:llm_setup_failed, translated(error))
863
+ end
864
+
865
+ def apply_tool_dispatch_preparation_on_event_loop(ready)
866
+ operation = ready.operation
867
+ state = authoritative_state_for_operation(
868
+ execution_id: operation.execution_id,
869
+ fsm_session_id: operation.fsm_session_id,
870
+ expected_execution_revision: operation.expected_execution_revision,
871
+ expected_fsm_state: :dispatching_tools
872
+ )
873
+ return unless state
874
+
875
+ if ready.error
876
+ if ready.error.is_a?(PreparationOutcomeUnknownError)
877
+ submit_tool_dispatch_preparation_reconciliation(
878
+ operation,
879
+ ready.error
880
+ )
881
+ else
882
+ state.invocation.event_sink.post(:tool_setup_failed, translated(ready.error))
883
+ end
884
+ return
885
+ end
886
+
887
+ apply_confirmed_tool_dispatch_preparation_on_event_loop(
888
+ operation,
889
+ ready.result,
890
+ state
891
+ )
892
+ rescue => error
893
+ state&.invocation&.event_sink&.post(:tool_setup_failed, translated(error))
894
+ end
895
+
896
+ def apply_confirmed_provider_dispatch_preparation_on_event_loop(
897
+ operation,
898
+ result,
899
+ state
900
+ )
901
+ event_loop = Phronomy::Runtime.instance.event_loop
902
+ if result.runtime_projection
903
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).replace_agent_execution(
904
+ operation.execution_id,
905
+ execution: result.execution,
906
+ runtime_projection: result.runtime_projection
907
+ )
908
+ else
909
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).replace_agent_execution(
910
+ operation.execution_id,
911
+ execution: result.execution
912
+ )
913
+ end
914
+ state.invocation.acknowledge_runtime_snapshot(operation.runtime_snapshot)
915
+
916
+ if result.error
917
+ state.invocation.event_sink.post(:llm_setup_failed, translated(result.error))
918
+ return
919
+ end
920
+
921
+ Agent::AgentInvocationSessionBuilder.start_prepared_provider_call(
922
+ agent: @agent,
923
+ runtime: Phronomy::Runtime.instance,
924
+ event_sink: state.invocation.event_sink,
925
+ invocation: state.invocation,
926
+ projection: result.runtime_projection,
927
+ streaming: operation.streaming
928
+ )
929
+ end
930
+
931
+ def apply_confirmed_tool_dispatch_preparation_on_event_loop(
932
+ operation,
933
+ result,
934
+ state
935
+ )
936
+ event_loop = Phronomy::Runtime.instance.event_loop
937
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).replace_agent_execution(
938
+ operation.execution_id,
939
+ execution: result.execution
940
+ )
941
+ state.invocation.acknowledge_runtime_snapshot(operation.runtime_snapshot)
942
+ Agent::AgentInvocationSessionBuilder.start_prepared_tool_dispatch(
943
+ runtime: Phronomy::Runtime.instance,
944
+ event_sink: state.invocation.event_sink,
945
+ invocation: state.invocation
946
+ )
947
+ end
948
+
949
+ def apply_provider_dispatch_preparation_reconciliation_on_event_loop(ready)
950
+ command = ready.command
951
+ operation = command.operation
952
+ state = authoritative_state_for_operation(
953
+ execution_id: operation.execution_id,
954
+ fsm_session_id: operation.fsm_session_id,
955
+ expected_execution_revision: operation.expected_execution_revision,
956
+ expected_fsm_state: :calling_llm
957
+ )
958
+ return unless state
959
+
960
+ if ready.error
961
+ mark_barrier_recovery_required(operation, ready.error)
962
+ return
963
+ end
964
+
965
+ case ready.result.disposition
966
+ when :committed
967
+ apply_confirmed_provider_dispatch_preparation_on_event_loop(
968
+ operation,
969
+ ready.result.preparation_result,
970
+ state
971
+ )
972
+ when :not_committed
973
+ state.invocation.event_sink.post(
974
+ :llm_setup_failed,
975
+ translated(command.original_error)
976
+ )
977
+ when :conflict
978
+ mark_barrier_recovery_required(operation, command.original_error)
979
+ else
980
+ raise Phronomy::Error,
981
+ "unknown Provider dispatch reconciliation disposition: " \
982
+ "#{ready.result.disposition.inspect}"
983
+ end
984
+ rescue => error
985
+ mark_barrier_recovery_required(operation, error)
986
+ end
987
+
988
+ def apply_tool_dispatch_preparation_reconciliation_on_event_loop(ready)
989
+ command = ready.command
990
+ operation = command.operation
991
+ state = authoritative_state_for_operation(
992
+ execution_id: operation.execution_id,
993
+ fsm_session_id: operation.fsm_session_id,
994
+ expected_execution_revision: operation.expected_execution_revision,
995
+ expected_fsm_state: :dispatching_tools
996
+ )
997
+ return unless state
998
+
999
+ if ready.error
1000
+ mark_barrier_recovery_required(operation, ready.error)
1001
+ return
1002
+ end
1003
+
1004
+ case ready.result.disposition
1005
+ when :committed
1006
+ apply_confirmed_tool_dispatch_preparation_on_event_loop(
1007
+ operation,
1008
+ ready.result.preparation_result,
1009
+ state
1010
+ )
1011
+ when :not_committed
1012
+ state.invocation.event_sink.post(
1013
+ :tool_setup_failed,
1014
+ translated(command.original_error)
1015
+ )
1016
+ when :conflict
1017
+ mark_barrier_recovery_required(operation, command.original_error)
1018
+ else
1019
+ raise Phronomy::Error,
1020
+ "unknown Tool dispatch reconciliation disposition: " \
1021
+ "#{ready.result.disposition.inspect}"
1022
+ end
1023
+ rescue => error
1024
+ mark_barrier_recovery_required(operation, error)
1025
+ # simplecov:enable
1026
+ end
1027
+
1028
+ def mark_barrier_recovery_required(operation, error)
1029
+ event_loop = Phronomy::Runtime.instance.event_loop
1030
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).mark_agent_execution_admission(
1031
+ @agent.agent_id,
1032
+ execution_id: operation.execution_id,
1033
+ state: :recovery_required
1034
+ )
1035
+ Phronomy.configuration.logger&.warn(
1036
+ "[Phronomy] causal durable barrier requires recovery for " \
1037
+ "#{operation.execution_id}: #{error.class}: #{error.message}"
1038
+ )
1039
+ end
1040
+
1041
+ # ----------------------------------------------------------------------
1042
+ # Approval resume
1043
+ # ----------------------------------------------------------------------
1044
+
1045
+ def begin_resume_on_event_loop(request)
1046
+ runtime = Phronomy::Runtime.instance
1047
+ event_loop = runtime.event_loop
1048
+ state = Phronomy::Agent::ExecutionRegistry.for(event_loop).agent_execution_state(request.execution_id)
1049
+ unless state
1050
+ fail_task(
1051
+ request.result_task,
1052
+ Phronomy::ExecutionRehydrationRequiredError.new(
1053
+ "no live execution owner for #{request.execution_id}; durable rehydration is required"
1054
+ )
1055
+ )
1056
+ return
1057
+ end
1058
+
1059
+ unless state.agent.equal?(@agent) && state.execution.status == :suspended
1060
+ fail_task(
1061
+ request.result_task,
1062
+ ArgumentError.new(
1063
+ "execution is not a suspended execution of this agent: #{request.execution_id}"
1064
+ )
1065
+ )
1066
+ return
1067
+ end
1068
+
1069
+ approval = state.execution.approval_request || {}
1070
+ current_request_id = approval["id"] || approval[:id]
1071
+ unless current_request_id.to_s == request.approval_request_id
1072
+ fail_task(
1073
+ request.result_task,
1074
+ ArgumentError.new(
1075
+ "approval request does not match execution #{request.execution_id}"
1076
+ )
1077
+ )
1078
+ return
1079
+ end
1080
+
1081
+ operation = capture_approval_resume(state, request)
1082
+ resume_transition_started = false
1083
+ submitted = false
1084
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).mark_agent_execution_admission(
1085
+ @agent.agent_id,
1086
+ execution_id: state.execution_id,
1087
+ state: :resuming
1088
+ )
1089
+ resume_transition_started = true
1090
+ task = runtime.offload.submit(on_full: :raise) do
1091
+ @approval_resume_commit.commit(operation)
1092
+ end
1093
+ submitted = true
1094
+ task.on_complete do |result, error|
1095
+ ready = ResumeCommitReady.new(
1096
+ coordinator: self,
1097
+ request: request,
1098
+ operation: operation,
1099
+ result: result,
1100
+ error: error
1101
+ )
1102
+ fail_task(request.result_task, runtime_rejected_error(:resume_commit)) unless
1103
+ post_control(runtime, ready)
1104
+ end
1105
+ rescue => error
1106
+ if resume_transition_started
1107
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).mark_agent_execution_admission(
1108
+ @agent.agent_id,
1109
+ execution_id: state.execution_id,
1110
+ state: submitted ? :recovery_required : :suspended
1111
+ )
1112
+ end
1113
+ fail_task(request.result_task, translated(error))
1114
+ raise unless resume_transition_started
1115
+ end
1116
+
1117
+ def capture_approval_resume(state, request)
1118
+ snapshot = if state.invocation
1119
+ Phronomy::Values::Immutable.copy(
1120
+ ExecutionMetadata.build_tool_batch_snapshot(state.invocation)
1121
+ )
1122
+ end
1123
+ ResumeCommitCommand.new(
1124
+ execution_id: state.execution_id,
1125
+ expected_execution_revision: state.execution.execution_revision,
1126
+ root: @agent.agent_root,
1127
+ execution: state.execution,
1128
+ approval_request_id: request.approval_request_id,
1129
+ approved: request.approved,
1130
+ tool_batch_snapshot: snapshot
1131
+ )
1132
+ end
1133
+
1134
+ def apply_resume_commit_on_event_loop(ready)
1135
+ request = ready.request
1136
+ operation = ready.operation
1137
+ event_loop = Phronomy::Runtime.instance.event_loop
1138
+ state = Phronomy::Agent::ExecutionRegistry.for(event_loop).agent_execution_state(operation.execution_id)
1139
+ unless state && state.agent.equal?(@agent) &&
1140
+ state.execution.execution_revision == operation.expected_execution_revision &&
1141
+ state.execution.status == :suspended
1142
+ fail_task(
1143
+ request.result_task,
1144
+ Phronomy::Error.new(
1145
+ "stale approval resume result for #{operation.execution_id}"
1146
+ )
1147
+ )
1148
+ return
1149
+ end
1150
+
1151
+ if ready.error
1152
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).mark_agent_execution_admission(
1153
+ @agent.agent_id,
1154
+ execution_id: operation.execution_id,
1155
+ state: :recovery_required
1156
+ )
1157
+ fail_task(request.result_task, translated(ready.error))
1158
+ return
1159
+ end
1160
+
1161
+ install_resumed_execution_on_event_loop(ready, event_loop)
1162
+ observe_resumed_execution(ready, state)
1163
+ execution_session_runner(Phronomy::Runtime.instance).resume_approval(
1164
+ state.invocation, request.result_task,
1165
+ approved: request.approved, config: request.config
1166
+ )
1167
+ rescue => error
1168
+ state = Phronomy::Agent::ExecutionRegistry.for(event_loop).agent_execution_state(operation.execution_id) if event_loop&.current?
1169
+ if state
1170
+ begin_terminal_commit_on_event_loop(
1171
+ state,
1172
+ request.result_task,
1173
+ state.invocation,
1174
+ error,
1175
+ fsm_session_id: state.fsm_session_id
1176
+ )
1177
+ else
1178
+ fail_task(request.result_task, translated(error))
1179
+ end
1180
+ end
1181
+
1182
+ def install_resumed_execution_on_event_loop(ready, event_loop)
1183
+ request = ready.request
1184
+ operation = ready.operation
1185
+ apply_agent_live_state(root: ready.result.root, appended_records: [])
1186
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).mark_agent_execution_admission(
1187
+ @agent.agent_id,
1188
+ execution_id: operation.execution_id,
1189
+ state: :executing
1190
+ )
1191
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).replace_agent_execution(
1192
+ operation.execution_id,
1193
+ execution: ready.result.execution
1194
+ )
1195
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).register_agent_completion_waiter(
1196
+ operation.execution_id,
1197
+ request.result_task
1198
+ )
1199
+ end
1200
+
1201
+ def observe_resumed_execution(ready, state)
1202
+ request = ready.request
1203
+ operation = ready.operation
1204
+ trace_config = state.invocation ?
1205
+ state.invocation.config.merge(request.config) : request.config
1206
+ trace_mode = state.invocation&.mode ||
1207
+ (
1208
+ ready.result.execution.metadata[
1209
+ ExecutionMetadata::INVOCATION_MODE_KEY
1210
+ ] || "invoke"
1211
+ ).to_sym
1212
+ Phronomy::Tracing::Automatic.observe_task(
1213
+ request.result_task,
1214
+ "agent.execution",
1215
+ agent_id: @agent.agent_id,
1216
+ execution_id: operation.execution_id,
1217
+ mode: trace_mode,
1218
+ **@agent.send(:_build_caller_meta, trace_config)
1219
+ )
1220
+ end
1221
+
1222
+ # Recovery chooses a semantic continuation after resolving saved facts.
1223
+ # Execution owns validation, admission, live installation and FSM entry.
1224
+ def recover_preparation_on_event_loop(command)
1225
+ state = recovered_execution_state!(command)
1226
+ unless state.execution.status == :preparing && state.execution.phase.to_sym == :preparing
1227
+ raise Phronomy::ExecutionRehydrationRequiredError,
1228
+ "initial preparation continuation requires a :preparing execution"
1229
+ end
1230
+ ExecutionRegistry.for(Phronomy::Runtime.instance.event_loop).mark_agent_execution_admission(
1231
+ @agent.agent_id, execution_id: state.execution_id, state: :executing
1232
+ )
1233
+ start_initial_preparation_recovery_on_event_loop(
1234
+ state.execution, command.result_task, load_completion: command.load_completion
1235
+ )
1236
+ rescue => error
1237
+ fail_task(command.result_task, translated(error))
1238
+ command.load_completion.fail(translated(error))
1239
+ end
1240
+
1241
+ def continue_recovered_on_event_loop(command)
1242
+ runtime = Phronomy::Runtime.instance
1243
+ registry = ExecutionRegistry.for(runtime.event_loop)
1244
+ state = recovered_execution_state!(command)
1245
+ validate_recovered_continuation!(state.execution, command.continuation)
1246
+ invocation = command.invocation
1247
+ unless invocation && invocation.agent.equal?(@agent) &&
1248
+ invocation.execution_id.to_s == state.execution_id.to_s
1249
+ raise Phronomy::ExecutionRehydrationRequiredError,
1250
+ "recovered invocation does not belong to execution #{state.execution_id}"
1251
+ end
1252
+ if command.continuation == :failed_terminal && !command.error.is_a?(Exception)
1253
+ raise Phronomy::ExecutionRehydrationRequiredError,
1254
+ "failed recovery continuation requires a failure"
1255
+ end
1256
+
1257
+ state = registry.replace_agent_execution(state.execution_id,
1258
+ runtime_projection: command.runtime_projection, invocation: invocation, fsm_session_id: nil)
1259
+ if command.continuation == :failed_terminal
1260
+ begin_terminal_commit_on_event_loop(state, command.result_task, invocation,
1261
+ command.error, fsm_session_id: nil)
1262
+ return
1263
+ end
1264
+
1265
+ registry.mark_agent_execution_admission(@agent.agent_id,
1266
+ execution_id: state.execution_id, state: :executing)
1267
+ registry.register_agent_completion_waiter(state.execution_id, command.result_task)
1268
+ runner = execution_session_runner(runtime)
1269
+ case command.continuation
1270
+ when :approval_rejection
1271
+ runner.resume_approval(invocation, command.result_task, approved: false, config: {})
1272
+ when :framework_tools
1273
+ runner.resume_framework_tools(invocation, command.result_task)
1274
+ when :framework_calls, :output
1275
+ runner.resume(invocation, command.result_task,
1276
+ resume_event: :llm_completed, resume_phase: :calling_llm)
1277
+ when :followup
1278
+ runner.resume(invocation, command.result_task,
1279
+ resume_event: :state_completed, resume_phase: :recording_tool_results)
1280
+ end
1281
+ end
1282
+
1283
+ def recovered_execution_state!(command)
1284
+ state = ExecutionRegistry.for(Phronomy::Runtime.instance.event_loop).agent_execution_state(command.execution_id)
1285
+ unless command.coordinator.equal?(self) && state && state.agent.equal?(@agent) &&
1286
+ state.coordinator.equal?(self) && state.execution.active? &&
1287
+ state.execution.execution_revision == command.expected_execution_revision &&
1288
+ state.fsm_session_id.nil?
1289
+ raise Phronomy::ExecutionRehydrationRequiredError,
1290
+ "stale recovered execution continuation for #{command.execution_id}"
1291
+ end
1292
+ state
1293
+ end
1294
+
1295
+ def execution_session_runner(runtime)
1296
+ ExecutionSessionRunner.new(runtime: runtime, on_complete: ->(**result) {
1297
+ deliver_on_event_loop(SessionFinishedCommand.new(coordinator: self, **result))
1298
+ })
1299
+ end
1300
+
1301
+ def validate_recovered_continuation!(execution, continuation)
1302
+ phase = execution.phase.to_sym
1303
+ valid = case continuation
1304
+ when :framework_tools
1305
+ %i[dispatching_tools resuming recovery_tools].include?(phase) && execution.status != :suspended
1306
+ when :framework_calls
1307
+ %i[recovery_provider_completed recovery_tools_completed].include?(phase) &&
1308
+ execution.metadata["framework_calls_pending"]
1309
+ when :output
1310
+ phase == :recovery_provider_completed && !execution.metadata["framework_calls_pending"]
1311
+ when :followup
1312
+ phase == :recovery_tools_completed && !execution.metadata["framework_calls_pending"]
1313
+ when :failed_terminal
1314
+ phase == :recovery_resolved_failed
1315
+ when :approval_rejection
1316
+ approval = execution.approval_request
1317
+ phase == :resuming && !(approval && (approval["approved"] || approval[:approved]))
1318
+ end
1319
+ return if valid
1320
+
1321
+ raise Phronomy::ExecutionRehydrationRequiredError,
1322
+ "invalid recovered continuation #{continuation.inspect} for #{phase.inspect}"
1323
+ end
1324
+
1325
+ # ----------------------------------------------------------------------
1326
+ # Terminal durable barrier
1327
+ # ----------------------------------------------------------------------
1328
+
1329
+ def finish_on_event_loop(execution_id, result_task, invocation, error, fsm_session_id:)
1330
+ event_loop = Phronomy::Runtime.instance.event_loop
1331
+ assert_event_loop!(event_loop)
1332
+ state = Phronomy::Agent::ExecutionRegistry.for(event_loop).agent_execution_state(execution_id)
1333
+ return fail_task(result_task, runtime_rejected_error(:terminal)) unless state
1334
+
1335
+ unless state.agent.equal?(@agent) && state.coordinator.equal?(self) &&
1336
+ state.fsm_session_id.to_s == fsm_session_id.to_s &&
1337
+ state.invocation.equal?(invocation)
1338
+ return fail_task(
1339
+ result_task,
1340
+ Phronomy::Error.new("stale Agent terminal callback for #{execution_id}")
1341
+ )
1342
+ end
1343
+
1344
+ terminal_error = error || invocation&.error
1345
+ unless Phronomy::Agent::ExecutionRegistry.for(event_loop).agent_execution_quiescent?(execution_id)
1346
+ wait_state = quiescence_sensitive_terminal_error?(terminal_error) ?
1347
+ :cancelling : :terminalizing
1348
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).mark_agent_execution_admission(
1349
+ @agent.agent_id,
1350
+ execution_id: execution_id,
1351
+ state: wait_state
1352
+ )
1353
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).defer_agent_terminal_until_quiescent(
1354
+ execution_id,
1355
+ DeferredTerminalCommand.new(
1356
+ coordinator: self,
1357
+ execution_id: execution_id.to_s.freeze,
1358
+ result_task: result_task,
1359
+ invocation: invocation,
1360
+ source_error: error,
1361
+ fsm_session_id: fsm_session_id.to_s.freeze
1362
+ )
1363
+ )
1364
+ return
1365
+ end
1366
+
1367
+ begin_terminal_commit_on_event_loop(
1368
+ state,
1369
+ result_task,
1370
+ invocation,
1371
+ error,
1372
+ fsm_session_id: fsm_session_id
1373
+ )
1374
+ end
1375
+
1376
+ def resume_deferred_terminal_on_event_loop(command)
1377
+ event_loop = Phronomy::Runtime.instance.event_loop
1378
+ state = Phronomy::Agent::ExecutionRegistry.for(event_loop).agent_execution_state(command.execution_id)
1379
+ unless state && state.agent.equal?(@agent) &&
1380
+ state.fsm_session_id.to_s == command.fsm_session_id.to_s &&
1381
+ state.invocation.equal?(command.invocation)
1382
+ Phronomy.configuration.logger&.warn(
1383
+ "[Phronomy] Dropped stale deferred terminal for #{command.execution_id}"
1384
+ )
1385
+ return
1386
+ end
1387
+
1388
+ unless Phronomy::Agent::ExecutionRegistry.for(event_loop).agent_execution_quiescent?(command.execution_id)
1389
+ raise Phronomy::Error,
1390
+ "deferred terminal resumed before quiescence for #{command.execution_id}"
1391
+ end
1392
+
1393
+ begin_terminal_commit_on_event_loop(
1394
+ state,
1395
+ command.result_task,
1396
+ command.invocation,
1397
+ command.source_error,
1398
+ fsm_session_id: command.fsm_session_id
1399
+ )
1400
+ end
1401
+
1402
+ def quiescence_sensitive_terminal_error?(error)
1403
+ error.is_a?(Phronomy::CancellationError) ||
1404
+ error.is_a?(Phronomy::TimeoutError)
1405
+ end
1406
+
1407
+ def begin_terminal_commit_on_event_loop(
1408
+ state,
1409
+ result_task,
1410
+ invocation,
1411
+ source_error,
1412
+ fsm_session_id:
1413
+ )
1414
+ if invocation&.phase == :suspended
1415
+ snapshot = ExecutionMetadata.build_tool_batch_snapshot(invocation)
1416
+ staged_execution = ExecutionMetadata.with_values(
1417
+ state.execution,
1418
+ ExecutionMetadata::TOOL_BATCH_METADATA_KEY => snapshot
1419
+ )
1420
+ state = state.class.new(
1421
+ **state.to_h.merge(execution: staged_execution)
1422
+ )
1423
+ end
1424
+
1425
+ operation = build_terminal_operation(
1426
+ execution: state.execution,
1427
+ root: @agent.agent_root,
1428
+ journal_records: @agent.send(:_journal_records_snapshot),
1429
+ invocation: invocation,
1430
+ source_error: source_error,
1431
+ fsm_session_id: fsm_session_id,
1432
+ state_required: true
1433
+ )
1434
+ delivery = TerminalDelivery.new(
1435
+ result_task: result_task,
1436
+ application_listener: invocation&.event_listener,
1437
+ approval_listener: invocation&.approval_listener,
1438
+ handoff_request: invocation&.handoff_request,
1439
+ handoff_manifest: state.runtime_projection&.manifest
1440
+ )
1441
+ submit_terminal_operation(operation, delivery)
1442
+ end
1443
+
1444
+ def begin_terminal_without_session_on_event_loop(request:, prepared:, error:)
1445
+ return deliver_start_failure_on_event_loop(request, translated(error)) unless prepared&.execution
1446
+
1447
+ operation = build_terminal_operation(
1448
+ execution: prepared.execution,
1449
+ root: prepared.root,
1450
+ journal_records: @agent.send(:_journal_records_snapshot),
1451
+ invocation: nil,
1452
+ source_error: error,
1453
+ fsm_session_id: nil,
1454
+ state_required: false
1455
+ )
1456
+ delivery = TerminalDelivery.new(
1457
+ result_task: request.result_task,
1458
+ application_listener: request.on_event,
1459
+ approval_listener: request.approval_listener,
1460
+ handoff_request: nil,
1461
+ handoff_manifest: nil
1462
+ )
1463
+ submit_terminal_operation(operation, delivery)
1464
+ end
1465
+
1466
+ def build_terminal_operation(
1467
+ execution:,
1468
+ root:,
1469
+ journal_records:,
1470
+ invocation:,
1471
+ source_error:,
1472
+ fsm_session_id:,
1473
+ state_required:
1474
+ )
1475
+ TerminalCommitCommand.new(
1476
+ execution_id: execution.execution_id.to_s.freeze,
1477
+ fsm_session_id: fsm_session_id&.to_s&.freeze,
1478
+ expected_execution_revision: execution.execution_revision,
1479
+ root: root,
1480
+ journal_records: journal_records,
1481
+ execution: execution,
1482
+ runtime_snapshot: invocation ? invocation.runtime_snapshot : empty_runtime_snapshot,
1483
+ terminal_view: terminal_view(invocation, source_error),
1484
+ state_required: state_required
1485
+ )
1486
+ end
1487
+
1488
+ def build_outcome_committer
1489
+ ExecutionOutcomeCommitter.new(agent: @agent, persistence: @agent.persistence)
1490
+ end
1491
+
1492
+ def submit_terminal_operation(operation, delivery)
1493
+ runtime = Phronomy::Runtime.instance
1494
+ event_loop = runtime.event_loop
1495
+ terminal_transition_started = false
1496
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).mark_agent_execution_admission(
1497
+ @agent.agent_id,
1498
+ execution_id: operation.execution_id,
1499
+ state: :terminalizing
1500
+ )
1501
+ terminal_transition_started = true
1502
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).register_agent_completion_waiter(
1503
+ operation.execution_id,
1504
+ delivery.result_task
1505
+ )
1506
+ task = runtime.offload.submit(on_full: :raise) do
1507
+ # Only the operation-specific immutable durable snapshot crosses the
1508
+ # worker boundary. TaskResult/listener delivery state stays outside it.
1509
+ @outcome_committer.commit_outcome(operation)
1510
+ end
1511
+ task.on_complete do |outcome, error|
1512
+ ready = TerminalCommitReady.new(
1513
+ coordinator: self,
1514
+ operation: operation,
1515
+ delivery: delivery,
1516
+ outcome: outcome,
1517
+ error: error
1518
+ )
1519
+ fail_task(delivery.result_task, runtime_rejected_error(:terminal_apply)) unless
1520
+ post_control(runtime, ready)
1521
+ end
1522
+ rescue => error
1523
+ if terminal_transition_started
1524
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).mark_agent_execution_admission(
1525
+ @agent.agent_id,
1526
+ execution_id: operation.execution_id,
1527
+ state: :recovery_required
1528
+ )
1529
+ Phronomy.configuration.logger&.warn(
1530
+ "[Phronomy] Agent terminal transition requires recovery for " \
1531
+ "#{operation.execution_id}: #{error.class}: #{error.message}"
1532
+ )
1533
+ return
1534
+ end
1535
+
1536
+ fail_task(delivery.result_task, translated(error))
1537
+ raise
1538
+ end
1539
+
1540
+ def terminal_view(invocation, source_error)
1541
+ TerminalView.new(
1542
+ phase: invocation&.phase,
1543
+ output: invocation&.output,
1544
+ usage: invocation&.usage,
1545
+ approval_request: invocation&.approval_request,
1546
+ rejected: invocation ? !!invocation.rejected : false,
1547
+ input_blocked: invocation ? invocation.input_blocked? : false,
1548
+ output_blocked: invocation ? invocation.output_blocked? : false,
1549
+ block_error: invocation&.block_error,
1550
+ invocation_error: invocation&.error,
1551
+ handoff: handoff_terminal_view(invocation&.handoff_request),
1552
+ callback_failure: invocation&.callback_failure,
1553
+ source_error: source_error,
1554
+ cancel_requested: invocation&.config&.fetch(:cancellation_token, nil)&.cancelled? || false
1555
+ )
1556
+ end
1557
+
1558
+ def handoff_terminal_view(request)
1559
+ return unless request
1560
+
1561
+ HandoffTerminalView.new(
1562
+ target_agent_id: request.handoff.target_agent.agent_id.to_s.freeze,
1563
+ responsibility: request.responsibility.to_s.freeze,
1564
+ selection_intent: request.selection_intent.to_h do |category, included|
1565
+ [category.to_s.freeze, !!included]
1566
+ end.freeze,
1567
+ llm_call_id: request.llm_call_id&.to_s&.freeze,
1568
+ tool_call_id: request.tool_call_id&.to_s&.freeze,
1569
+ policy: Phronomy::Values::Immutable.copy(request.handoff.policy.to_h)
1570
+ )
1571
+ end
1572
+
1573
+ def empty_runtime_snapshot
1574
+ {llm_results: [].freeze, runtime_events: [].freeze, active_call: nil}.freeze
1575
+ end
1576
+
1577
+ def apply_terminal_commit_on_event_loop(ready)
1578
+ operation = ready.operation
1579
+ delivery = ready.delivery
1580
+ event_loop = Phronomy::Runtime.instance.event_loop
1581
+ state = Phronomy::Agent::ExecutionRegistry.for(event_loop).agent_execution_state(operation.execution_id)
1582
+
1583
+ if operation.state_required
1584
+ unless state && state.agent.equal?(@agent) &&
1585
+ state.execution.execution_revision == operation.expected_execution_revision &&
1586
+ state.fsm_session_id.to_s == operation.fsm_session_id.to_s
1587
+ Phronomy.configuration.logger&.warn(
1588
+ "[Phronomy] Dropped stale Agent terminal result for #{operation.execution_id}"
1589
+ )
1590
+ return
1591
+ end
1592
+ end
1593
+
1594
+ if ready.error
1595
+ handle_terminal_commit_error_on_event_loop(ready, state, event_loop)
1596
+ return
1597
+ end
1598
+
1599
+ apply_terminal_outcome_state_on_event_loop(ready, state, event_loop)
1600
+ outcome = ready.outcome
1601
+ case outcome.type
1602
+ when :coordination_wait
1603
+ release_coordination_wait_on_event_loop(ready, state, event_loop)
1604
+ when :suspended
1605
+ suspend_execution_on_event_loop(ready, state, event_loop)
1606
+ when :completed
1607
+ deliver_completed_execution_on_event_loop(ready, state, event_loop)
1608
+ when :handed_off
1609
+ deliver_handoff_on_event_loop(ready, state, event_loop)
1610
+ when :failed
1611
+ deliver_failed_execution_on_event_loop(ready, state, event_loop)
1612
+ else
1613
+ fail_task(
1614
+ delivery.result_task,
1615
+ Phronomy::Error.new("unknown terminal outcome: #{outcome.type.inspect}")
1616
+ )
1617
+ end
1618
+ end
1619
+
1620
+ def handle_terminal_commit_error_on_event_loop(ready, state, event_loop)
1621
+ operation = ready.operation
1622
+ delivery = ready.delivery
1623
+ if operation.execution.metadata["coordination"] || operation.execution.metadata["multi_agent_coordination_ref"]
1624
+ release_terminal_ownership(event_loop, operation.execution_id, state)
1625
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).take_agent_completion_waiters(operation.execution_id, fallback: delivery.result_task).each do |task|
1626
+ task.fail(ready.error)
1627
+ end
1628
+ return
1629
+ end
1630
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).mark_agent_execution_admission(
1631
+ @agent.agent_id,
1632
+ execution_id: operation.execution_id,
1633
+ state: :recovery_required
1634
+ )
1635
+ Phronomy.configuration.logger&.warn(
1636
+ "[Phronomy] Agent terminal durable outcome requires recovery for " \
1637
+ "#{operation.execution_id}: #{ready.error.class}: #{ready.error.message}"
1638
+ )
1639
+ end
1640
+
1641
+ def apply_terminal_outcome_state_on_event_loop(ready, state, event_loop)
1642
+ operation = ready.operation
1643
+ outcome = ready.outcome
1644
+ apply_agent_live_state(
1645
+ root: outcome.root,
1646
+ appended_records: outcome.appended_records
1647
+ )
1648
+ if state
1649
+ state.invocation&.acknowledge_runtime_snapshot(operation.runtime_snapshot)
1650
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).replace_agent_execution(
1651
+ operation.execution_id,
1652
+ execution: outcome.execution,
1653
+ fsm_session_id: (outcome.type == :suspended) ? nil : state.fsm_session_id
1654
+ )
1655
+ end
1656
+ end
1657
+
1658
+ def release_coordination_wait_on_event_loop(ready, state, event_loop)
1659
+ operation = ready.operation
1660
+ delivery = ready.delivery
1661
+ outcome = ready.outcome
1662
+ release_terminal_ownership(event_loop, operation.execution_id, state)
1663
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).take_agent_completion_waiters(operation.execution_id, fallback: delivery.result_task).each { |task| task.fail(outcome.error) }
1664
+ end
1665
+
1666
+ def suspend_execution_on_event_loop(ready, state, event_loop)
1667
+ operation = ready.operation
1668
+ delivery = ready.delivery
1669
+ outcome = ready.outcome
1670
+ Phronomy::Agent::ExecutionRegistry.for(event_loop).mark_agent_execution_admission(
1671
+ @agent.agent_id,
1672
+ execution_id: operation.execution_id,
1673
+ state: :suspended
1674
+ )
1675
+ dispatch_approval_listener(delivery.approval_listener, outcome.approval_request)
1676
+ callback_error = deliver_terminal(
1677
+ delivery.application_listener,
1678
+ :approval_required,
1679
+ request: outcome.approval_request
1680
+ )
1681
+ report_nonterminal_callback_error(
1682
+ callback_error,
1683
+ :approval_required,
1684
+ outcome.result
1685
+ )
1686
+ Array(state&.invocation&.config&.fetch(:phronomy_exact_observers, [])).each do |task|
1687
+ task.fail(Phronomy::ExecutionRehydrationRequiredError.new("Execution #{operation.execution_id} requires approval"))
1688
+ end
1689
+ end
1690
+
1691
+ def deliver_completed_execution_on_event_loop(ready, state, event_loop)
1692
+ operation = ready.operation
1693
+ delivery = ready.delivery
1694
+ outcome = ready.outcome
1695
+ release_terminal_ownership(event_loop, operation.execution_id, state)
1696
+ callback_error = deliver_terminal(delivery.application_listener, :done, outcome.result)
1697
+ settle_execution_waiters(
1698
+ event_loop, operation.execution_id, delivery.result_task,
1699
+ callback_error, :done, outcome.result
1700
+ )
1701
+ end
1702
+
1703
+ def deliver_handoff_on_event_loop(ready, state, event_loop)
1704
+ operation = ready.operation
1705
+ delivery = ready.delivery
1706
+ outcome = ready.outcome
1707
+ release_terminal_ownership(event_loop, operation.execution_id, state)
1708
+ result = outcome.result.merge(
1709
+ handoff_request: delivery.handoff_request,
1710
+ _phronomy_handoff_manifest: delivery.handoff_manifest
1711
+ ).freeze
1712
+ callback_error = deliver_terminal(delivery.application_listener, :handoff, result)
1713
+ settle_execution_waiters(
1714
+ event_loop, operation.execution_id, delivery.result_task,
1715
+ callback_error, :handoff, result
1716
+ )
1717
+ end
1718
+
1719
+ def deliver_failed_execution_on_event_loop(ready, state, event_loop)
1720
+ operation = ready.operation
1721
+ delivery = ready.delivery
1722
+ outcome = ready.outcome
1723
+ release_terminal_ownership(event_loop, operation.execution_id, state)
1724
+ type = terminal_event_type(outcome.error)
1725
+ callback_error = deliver_terminal(
1726
+ delivery.application_listener,
1727
+ type,
1728
+ error: outcome.error
1729
+ )
1730
+ settle_execution_waiters(
1731
+ event_loop, operation.execution_id, delivery.result_task,
1732
+ callback_error, type, nil, outcome.error
1733
+ )
1734
+ end
1735
+
1736
+ def release_terminal_ownership(event_loop, execution_id, state)
1737
+ registry = Phronomy::Agent::ExecutionRegistry.for(event_loop)
1738
+ registry.release_agent_execution(execution_id) if state
1739
+ registry.release_agent_execution_admission(@agent.agent_id, execution_id: execution_id)
1740
+ end
1741
+
1742
+ # ----------------------------------------------------------------------
1743
+ # EventLoop validation/apply helpers
1744
+ # ----------------------------------------------------------------------
1745
+
1746
+ def authoritative_state_for_operation(
1747
+ execution_id:,
1748
+ fsm_session_id:,
1749
+ expected_execution_revision:,
1750
+ expected_fsm_state:
1751
+ )
1752
+ event_loop = Phronomy::Runtime.instance.event_loop
1753
+ state = Phronomy::Agent::ExecutionRegistry.for(event_loop).agent_execution_state(execution_id)
1754
+ authoritative = state &&
1755
+ state.agent.equal?(@agent) &&
1756
+ state.execution.execution_revision == expected_execution_revision &&
1757
+ state.fsm_session_id.to_s == fsm_session_id.to_s &&
1758
+ event_loop.fsm_session_state(fsm_session_id) == expected_fsm_state
1759
+ return state if authoritative
1760
+
1761
+ Phronomy.configuration.logger&.warn(
1762
+ "[Phronomy] Dropped stale Agent operation result: " \
1763
+ "execution_id=#{execution_id} fsm_session_id=#{fsm_session_id}"
1764
+ )
1765
+ nil
1766
+ rescue
1767
+ nil
1768
+ end
1769
+
1770
+ def validate_live_session!(state, invocation, fsm_session_id)
1771
+ unless state && state.agent.equal?(@agent) &&
1772
+ state.invocation.equal?(invocation) &&
1773
+ state.fsm_session_id.to_s == fsm_session_id.to_s
1774
+ raise Phronomy::Error,
1775
+ "Agent operation does not belong to the current live FSMSession"
1776
+ end
1777
+ end
1778
+
1779
+ def apply_agent_live_state(root:, appended_records:)
1780
+ event_loop = Phronomy::Runtime.instance.event_loop
1781
+ assert_event_loop!(event_loop)
1782
+ @agent.send(:_append_journal_records, appended_records)
1783
+ @agent.__replace_root(root)
1784
+ end
1785
+
1786
+ def assert_event_loop!(event_loop)
1787
+ return if event_loop.current?
1788
+
1789
+ raise Phronomy::Error,
1790
+ "ExecutionCoordinator live-state apply must run on EventLoop"
1791
+ end
1792
+
1793
+ def deliver_terminal(listener, type, payload)
1794
+ return nil unless listener
1795
+
1796
+ @agent.send(
1797
+ :_deliver_stream_event,
1798
+ listener,
1799
+ StreamEvent.new(type: type, payload: payload)
1800
+ )
1801
+ end
1802
+
1803
+ def dispatch_approval_listener(listener, request)
1804
+ return unless listener
1805
+
1806
+ Phronomy::Runtime.instance.offload.submit(on_full: :raise) do
1807
+ listener.call(request)
1808
+ end
1809
+ rescue => error
1810
+ Phronomy.configuration.logger&.warn(
1811
+ "[Phronomy] approval listener dispatch failed: #{error.class}: #{error.message}"
1812
+ )
1813
+ end
1814
+
1815
+ def settle_execution_waiters(
1816
+ event_loop, execution_id, fallback_task, callback_error, event_type,
1817
+ result, execution_error = nil
1818
+ )
1819
+ waiters = Phronomy::Agent::ExecutionRegistry.for(event_loop).take_agent_completion_waiters(
1820
+ execution_id,
1821
+ fallback: fallback_task
1822
+ )
1823
+ waiters.each do |task|
1824
+ settle_after_terminal(
1825
+ task, callback_error, event_type, result, execution_error
1826
+ )
1827
+ end
1828
+ end
1829
+
1830
+ def report_nonterminal_callback_error(callback_error, event_type, result)
1831
+ return unless callback_error
1832
+
1833
+ @agent.send(
1834
+ :_report_stream_callback_error,
1835
+ callback_error,
1836
+ event: StreamEvent.new(type: event_type, payload: result),
1837
+ execution_id: result&.fetch(:execution_id, nil),
1838
+ callback_error_policy: Phronomy.configuration.stream_callback_error_policy
1839
+ )
1840
+ end
1841
+
1842
+ def settle_after_terminal(
1843
+ result_task,
1844
+ callback_error,
1845
+ event_type,
1846
+ result,
1847
+ execution_error = nil
1848
+ )
1849
+ if callback_error
1850
+ policy = Phronomy.configuration.stream_callback_error_policy
1851
+ @agent.send(
1852
+ :_report_stream_callback_error,
1853
+ callback_error,
1854
+ event: StreamEvent.new(
1855
+ type: event_type,
1856
+ payload: result || {error: execution_error}
1857
+ ),
1858
+ execution_id: result&.fetch(:execution_id, nil),
1859
+ callback_error_policy: policy
1860
+ )
1861
+ if policy == :fail_task && execution_error.nil?
1862
+ return fail_task(
1863
+ result_task,
1864
+ @agent.send(
1865
+ :_build_stream_callback_error,
1866
+ event_type: event_type,
1867
+ callback_error: callback_error,
1868
+ result: result
1869
+ )
1870
+ )
1871
+ end
1872
+ end
1873
+ if execution_error
1874
+ if execution_error.is_a?(Phronomy::CancellationError)
1875
+ result_task.cancel!(execution_error)
1876
+ else
1877
+ fail_task(result_task, execution_error)
1878
+ end
1879
+ else
1880
+ complete_task(result_task, result)
1881
+ end
1882
+ end
1883
+
1884
+ def terminal_event_type(error)
1885
+ return :timeout if error.is_a?(Phronomy::TimeoutError)
1886
+ return :cancelled if error.is_a?(Phronomy::CancellationError)
1887
+
1888
+ :error
1889
+ end
1890
+
1891
+ def post_control(runtime, command)
1892
+ admission = command.is_a?(StartCommand) || command.is_a?(ResumeCommand)
1893
+ completion = case command
1894
+ when StartCommand, ResumeCommand then command.result_task
1895
+ when InitialPreparationReady, ResumeCommitReady then command.request.result_task
1896
+ when InitialPreparationRecoveryReady then command.load_completion
1897
+ when TerminalCommitReady then command.delivery.result_task
1898
+ when DeferredTerminalCommand then command.result_task
1899
+ end
1900
+ ExecutionRegistry.for(runtime.event_loop).post(command,
1901
+ admission: admission, completion: completion)
1902
+ rescue Phronomy::RuntimeShutdownError
1903
+ false
1904
+ end
1905
+
1906
+ def runtime_rejected_error(operation)
1907
+ Phronomy::RuntimeShutdownError.new(
1908
+ "EventLoop is not accepting Agent #{operation} control delivery"
1909
+ )
1910
+ end
1911
+
1912
+ def translated(error)
1913
+ @agent.send(:_translated_error, error)
1914
+ end
1915
+
1916
+ def complete_task(task, result)
1917
+ @agent.send(:_complete_result_task, task, result)
1918
+ end
1919
+
1920
+ def fail_task(task, error)
1921
+ @agent.send(:_fail_result_task, task, error)
1922
+ end
1923
+ end
1924
+ end
1925
+ end