phronomy 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +3 -4
  3. data/CHANGELOG.md +215 -10
  4. data/CONTRIBUTING.md +81 -9
  5. data/README.md +15 -6
  6. data/VERIFY.sh +587 -0
  7. data/benchmark/bench_agent_invoke.rb +2 -2
  8. data/benchmark/bench_context_assembler.rb +39 -68
  9. data/benchmark/bench_regression.rb +2 -2
  10. data/docs/architecture/agent-context.md +174 -0
  11. data/docs/architecture/before-llm-input.md +78 -0
  12. data/docs/architecture/context-management.md +232 -0
  13. data/docs/architecture/knowledge-and-rag.md +130 -0
  14. data/docs/architecture/multi-agent-handoff.md +147 -0
  15. data/docs/architecture/persistence.md +186 -0
  16. data/docs/architecture/removed/agent-context.md +72 -0
  17. data/docs/architecture/security-boundaries.md +173 -0
  18. data/docs/architecture/tracing.md +194 -0
  19. data/docs/architecture.md +89 -0
  20. data/docs/archive/design/archived/04_api_design.md +507 -0
  21. data/docs/archive/design/archived/09_guardrails.md +186 -0
  22. data/docs/archive/design/archived/17_rails_integration.md +175 -0
  23. data/docs/archive/design/historical/00_design_philosophy.md +122 -0
  24. data/docs/archive/design/historical/01_rubyllm_evaluation.md +178 -0
  25. data/docs/archive/design/historical/06_design_decisions.md +143 -0
  26. data/docs/changelog/0.14-and-earlier.md +1 -1
  27. data/docs/decisions/001-rubyllm-as-provider-layer.md +6 -1
  28. data/docs/decisions/002-workflow-context-immutability.md +26 -1
  29. data/docs/decisions/006-no-built-in-guardrails.md +2 -1
  30. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +120 -38
  31. data/docs/decisions/014-unified-persistence-durable-state.md +9 -2
  32. data/docs/decisions/016-semantic-multi-agent-handoff.md +114 -0
  33. data/docs/decisions/017-design-authority-and-adr-governance.md +200 -0
  34. data/docs/decisions/018-durability-guarantees-and-failure-model.md +488 -0
  35. data/docs/decisions/019-filter-contract-and-security-boundaries.md +229 -0
  36. data/docs/decisions/020-canonical-workflow-instance-identity.md +177 -0
  37. data/docs/decisions/021-generic-agent-invocation-identity-removal.md +119 -0
  38. data/docs/decisions/022-agent-execution-parent-identity-and-runtime-routing-boundary.md +193 -0
  39. data/docs/decisions/023-fsm-session-incarnation-identity-and-routing.md +139 -0
  40. data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +188 -0
  41. data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +249 -0
  42. data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +257 -0
  43. data/docs/decisions/027-llm-adapter-provider-boundary.md +93 -0
  44. data/docs/decisions/028-preparing-recovery-replay-contract.md +106 -0
  45. data/docs/decisions/029-semantic-completion-and-application-effect-boundary.md +220 -0
  46. data/docs/decisions/030-agent-handoff-domain-and-durable-responsibility.md +235 -0
  47. data/docs/decisions/031-durable-multi-agent-coordination.md +301 -0
  48. data/docs/decisions/README.md +176 -0
  49. data/docs/design/durable-semantic-coordination/CHANGELOG_V2_REVISION_2.md +33 -0
  50. data/docs/design/durable-semantic-coordination/CONTINUATION_DECISION_REFACTOR.md +191 -0
  51. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_DESIGN_V2.md +862 -0
  52. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +106 -0
  53. data/docs/design/durable-semantic-coordination/RECOVERY_CONTRACT_CLARIFICATIONS.md +179 -0
  54. data/docs/design/durable-semantic-coordination/RESPONSIBILITY_BOUNDARY_REVIEW.md +302 -0
  55. data/docs/features.md +65 -11
  56. data/docs/getting-started.md +77 -45
  57. data/docs/migrations/0.19.md +14 -7
  58. data/docs/migrations/0.22.md +390 -0
  59. data/docs/migrations/durable-semantic-coordination-v2.md +65 -0
  60. data/docs/persistence-backends.md +129 -40
  61. data/docs/runtime-and-concurrency.md +227 -33
  62. data/examples/README.md +13 -0
  63. data/lib/phronomy/agent/agent_execution.rb +21 -17
  64. data/lib/phronomy/agent/agent_invocation.rb +288 -93
  65. data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
  66. data/lib/phronomy/agent/agent_root.rb +3 -3
  67. data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
  68. data/lib/phronomy/agent/async_event_api.rb +162 -72
  69. data/lib/phronomy/agent/base.rb +416 -181
  70. data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
  71. data/lib/phronomy/agent/context_assembler.rb +437 -178
  72. data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
  73. data/lib/phronomy/agent/context_plan.rb +18 -13
  74. data/lib/phronomy/agent/context_plan_validator.rb +246 -88
  75. data/lib/phronomy/agent/context_policies/default.rb +123 -34
  76. data/lib/phronomy/agent/context_policy.rb +109 -3
  77. data/lib/phronomy/agent/context_policy_input.rb +244 -0
  78. data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
  79. data/lib/phronomy/agent/exact_execution.rb +153 -0
  80. data/lib/phronomy/agent/execution_cancellation.rb +25 -0
  81. data/lib/phronomy/agent/execution_coordinator.rb +2417 -572
  82. data/lib/phronomy/agent/handoff.rb +43 -0
  83. data/lib/phronomy/agent/handoff_capability_factory.rb +45 -0
  84. data/lib/phronomy/agent/handoff_context.rb +120 -0
  85. data/lib/phronomy/agent/handoff_execution_coordinator.rb +143 -0
  86. data/lib/phronomy/agent/handoff_policy.rb +143 -0
  87. data/lib/phronomy/agent/handoff_projection.rb +207 -0
  88. data/lib/phronomy/agent/handoff_request.rb +45 -0
  89. data/lib/phronomy/agent/handoff_runner.rb +178 -0
  90. data/lib/phronomy/agent/handoff_state.rb +43 -0
  91. data/lib/phronomy/agent/journal_record.rb +17 -4
  92. data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
  93. data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
  94. data/lib/phronomy/agent/llm_operation_result.rb +12 -7
  95. data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
  96. data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
  97. data/lib/phronomy/agent/recovery_coordinator/continuation.rb +174 -0
  98. data/lib/phronomy/agent/recovery_coordinator/installation.rb +381 -0
  99. data/lib/phronomy/agent/recovery_coordinator/resolution.rb +511 -0
  100. data/lib/phronomy/agent/recovery_coordinator.rb +217 -0
  101. data/lib/phronomy/agent/recovery_support.rb +504 -0
  102. data/lib/phronomy/agent/ruby_llm_materializer.rb +16 -13
  103. data/lib/phronomy/agent/selection/candidate.rb +53 -0
  104. data/lib/phronomy/agent/selection/constraint.rb +49 -0
  105. data/lib/phronomy/agent/shared_state.rb +38 -1
  106. data/lib/phronomy/agent/tool_approval_request.rb +33 -5
  107. data/lib/phronomy/agent/tool_definition_set.rb +49 -3
  108. data/lib/phronomy/agent/tool_invocation.rb +339 -103
  109. data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
  110. data/lib/phronomy/agent.rb +20 -2
  111. data/lib/phronomy/agent_already_exists_error.rb +5 -0
  112. data/lib/phronomy/agent_purged_error.rb +5 -0
  113. data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
  114. data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
  115. data/lib/phronomy/engine/event_loop.rb +622 -63
  116. data/lib/phronomy/engine/fsm_session.rb +194 -21
  117. data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
  118. data/lib/phronomy/engine/runtime/team_ownership_registry.rb +77 -0
  119. data/lib/phronomy/engine/runtime.rb +92 -20
  120. data/lib/phronomy/generator_verifier.rb +12 -14
  121. data/lib/phronomy/invocation_context.rb +9 -29
  122. data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
  123. data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +134 -0
  124. data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
  125. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
  126. data/lib/phronomy/multi_agent/orchestrator.rb +71 -26
  127. data/lib/phronomy/multi_agent/team_coordinator.rb +473 -125
  128. data/lib/phronomy/multi_agent/team_execution.rb +44 -0
  129. data/lib/phronomy/multi_agent/team_root.rb +41 -0
  130. data/lib/phronomy/persistence/durable_codec.rb +706 -0
  131. data/lib/phronomy/persistence/durable_record.rb +117 -0
  132. data/lib/phronomy/persistence/in_memory.rb +471 -133
  133. data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
  134. data/lib/phronomy/persistence/repository_facades.rb +535 -0
  135. data/lib/phronomy/persistence.rb +174 -40
  136. data/lib/phronomy/recovery.rb +186 -0
  137. data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
  138. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +2 -1
  139. data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
  140. data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
  141. data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
  142. data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +137 -0
  143. data/lib/phronomy/testing/persistence_contract.rb +5 -0
  144. data/lib/phronomy/tools/agent.rb +1 -1
  145. data/lib/phronomy/tracing/automatic.rb +176 -0
  146. data/lib/phronomy/tracing/base.rb +11 -2
  147. data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
  148. data/lib/phronomy/version.rb +1 -1
  149. data/lib/phronomy/workflow.rb +3 -6
  150. data/lib/phronomy/workflow_context.rb +14 -5
  151. data/lib/phronomy/workflow_recovery.rb +123 -0
  152. data/lib/phronomy/workflow_runner.rb +468 -256
  153. data/lib/phronomy.rb +6 -0
  154. data/scripts/api_snapshot.rb +12 -0
  155. data/sig/phronomy/agent.rbs +209 -7
  156. data/sig/phronomy/handoff.rbs +41 -0
  157. data/sig/phronomy/multi_agent.rbs +35 -0
  158. data/sig/phronomy/persistence.rbs +123 -4
  159. data/sig/phronomy/runtime.rbs +1 -4
  160. data/sig/phronomy/workflow.rbs +2 -2
  161. data/sig/phronomy.rbs +10 -0
  162. metadata +84 -18
  163. data/examples/workflows/agent_event_mapping.rb +0 -101
  164. data/examples/workflows/generic_task_event_mapping.rb +0 -66
  165. data/lib/phronomy/agent/activation_registry.rb +0 -28
  166. data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
  167. data/lib/phronomy/agent/context_candidate.rb +0 -47
  168. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
  169. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
  170. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
  171. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
  172. data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
  173. data/lib/phronomy/agent/context_policy_registry.rb +0 -46
  174. data/lib/phronomy/agent/context_request.rb +0 -35
  175. data/lib/phronomy/agent/context_selection_unit.rb +0 -38
  176. data/lib/phronomy/agent/derived_content_spec.rb +0 -34
  177. data/lib/phronomy/agent/runner.rb +0 -97
  178. data/lib/phronomy/multi_agent/handoff.rb +0 -64
@@ -13,51 +13,159 @@ module Phronomy
13
13
  optimistic_revision: true
14
14
  }.freeze
15
15
 
16
- # Durable repository accessors supplied by a Persistence backend.
16
+ # Content-addressed immutable content repository. ContentStore has its own
17
+ # codec/canonicalization boundary and is not wrapped in DurableRecord.
17
18
  #
18
- # The repository objects are part of the Backend SPI. They may be private
19
- # implementation classes owned by the backend; they do not need to inherit
20
- # from Phronomy repository base classes.
21
- #
22
- # @return [Object] content-addressed immutable content repository
19
+ # @return [Object]
23
20
  # @api public
24
21
  attr_reader :contents
25
22
 
26
- # @return [Object] AgentRoot repository
23
+ # Runtime/domain-facing AgentRoot repository. The backend repository supplied
24
+ # to #initialize is record-oriented; this facade owns encode/decode.
25
+ #
26
+ # @return [Object]
27
27
  # @api public
28
28
  attr_reader :agents
29
29
 
30
- # @return [Object] append-only Agent Journal repository
30
+ # Runtime/domain-facing append-only Agent Journal repository.
31
+ #
32
+ # @return [Object]
31
33
  # @api public
32
34
  attr_reader :journals
33
35
 
34
- # @return [Object] AgentExecution repository
36
+ # Runtime/domain-facing AgentExecution repository.
37
+ #
38
+ # @return [Object]
35
39
  # @api public
36
40
  attr_reader :executions
37
41
 
38
- # @return [Object] durable Workflow snapshot repository
42
+ # Runtime/domain-facing durable Workflow snapshot repository.
43
+ #
44
+ # @return [Object]
39
45
  # @api public
40
46
  attr_reader :workflow_states
41
47
 
42
- # Initializes a Persistence backend with its durable repositories.
48
+ # Purpose-specific durable Handoff and Team repositories in atomic_all.
49
+ # @api public
50
+ attr_reader :handoff_states, :teams, :team_executions
51
+
52
+ # Read-only exact Agent execution result; never creates a live Agent owner.
53
+ # Result is nil until a result_ref exists; errors remain canonical data.
54
+ # @api public
55
+ def execution_result(execution_id)
56
+ assert_observation_thread!
57
+ execution = executions.load(execution_id)
58
+ {
59
+ execution_id: execution.execution_id, agent_id: execution.agent_id,
60
+ status: execution.status, phase: execution.phase,
61
+ result_ref: execution.result_ref, error_ref: execution.error_ref,
62
+ result: execution.result_ref && contents.fetch_text(execution.result_ref),
63
+ error: execution.error_ref && contents.fetch_json(execution.error_ref)
64
+ }.then { |value| Phronomy::Agent::Immutable.copy(value) }
65
+ end
66
+
67
+ # Follows one exact Handoff turn without loading any Agent or graph.
68
+ # A committed absent Target reservation remains active; failed reads raise.
69
+ # @api public
70
+ def handoff_result(execution_id, main_agent_id: nil)
71
+ assert_observation_thread!
72
+ anchor = main_agent_id&.to_s
73
+ seen = {}
74
+ current = execution_id.to_s
75
+ reserved_agent_id = nil
76
+ loop do
77
+ raise Phronomy::Persistence::SerializationError, "Cyclic durable Handoff chain" if seen[current]
78
+ seen[current] = true
79
+ begin
80
+ execution = executions.load(current)
81
+ rescue Phronomy::Persistence::NotFoundError
82
+ routing = handoff_states.load(anchor)
83
+ if routing && Array(routing.metadata["cancelled_execution_ids"]).include?(current)
84
+ return {execution_id: current, agent_id: reserved_agent_id, status: :cancelled, result: nil, error: nil}.freeze
85
+ end
86
+ if reserved_agent_id && routing && routing.phase != "stable" && routing.pending_target_execution_id == current
87
+ return {execution_id: current, agent_id: reserved_agent_id, status: :active,
88
+ phase: :target_pending, reserved: true, result: nil, error: nil}.freeze
89
+ end
90
+ raise
91
+ end
92
+ anchor ||= execution.metadata.dig("coordination", "main_agent_id")
93
+ unless anchor && execution.metadata.dig("coordination", "main_agent_id") == anchor
94
+ raise Phronomy::Persistence::ConflictError, "Execution does not belong to this Handoff anchor"
95
+ end
96
+ return execution_result(current) unless execution.status == :handed_off
97
+ reserved_agent_id = execution.metadata.fetch("handoff_target_agent_id")
98
+ current = execution.metadata.fetch("handoff_target_execution_id")
99
+ end
100
+ end
101
+
102
+ # Lists retained active and terminal executions in lexical ID order.
103
+ # after is an exclusive ID cursor; discovery does not imply request dedup.
104
+ # @api public
105
+ def list_executions(agent_id, after: nil, limit: 100)
106
+ assert_observation_thread!
107
+ executions.list(agent_id, after: after, limit: limit)
108
+ end
109
+
110
+ # Read-only exact Team result, independent of current Team class/Proc wiring.
111
+ # @api public
112
+ def team_execution_result(team_execution_id)
113
+ assert_observation_thread!
114
+ execution = team_executions.load(team_execution_id)
115
+ {
116
+ team_execution_id: execution.team_execution_id, team_id: execution.team_id,
117
+ status: execution.status, phase: execution.phase,
118
+ result_ref: execution.result_ref, error_ref: execution.error_ref,
119
+ result: execution.result_ref && contents.fetch_json(execution.result_ref),
120
+ error: execution.error_ref && contents.fetch_json(execution.error_ref)
121
+ }.then { |value| Phronomy::Agent::Immutable.copy(value) }
122
+ end
123
+
124
+ # Lists retained Team runs; no continuation or callback delivery occurs.
125
+ # @api public
126
+ def list_team_executions(team_id, after: nil, limit: 100)
127
+ assert_observation_thread!
128
+ team_executions.list(team_id, after: after, limit: limit)
129
+ end
130
+
131
+ # Initializes a Persistence backend from record-oriented storage
132
+ # repositories.
133
+ #
134
+ # Except for +contents+, backend repositories exchange
135
+ # {Phronomy::Persistence::DurableRecord} values. Phronomy's repository
136
+ # facades own current-format validation and domain-object encode/decode.
137
+ #
138
+ # Identity, revision, admission, and index metadata needed by a Backend is
139
+ # passed explicitly as repository arguments. A Backend must not inspect
140
+ # DurableRecord#payload to rediscover Phronomy domain semantics.
43
141
  #
44
- # Subclasses normally construct backend-specific repository objects and then
45
- # call +super+. The backend must advertise every capability in
46
- # {REQUIRED_CAPABILITIES}; construction fails fast otherwise.
142
+ # Required raw repository shapes:
143
+ # - agents:
144
+ # create(agent_id:, agent_revision:, record:), load(id),
145
+ # save(id, expected_revision:, next_revision:, record:), delete(id)
146
+ # - journals:
147
+ # append(id, expected_position:, records:, record_ids:), read/head/delete
148
+ # - executions:
149
+ # create_active(execution_id:, agent_id:, execution_revision:, record:),
150
+ # load(id), save(id, expected_revision:, next_revision:, agent_id:,
151
+ # active:, record:), list_active/delete/delete_for_agent/assert_idle!
152
+ # - workflow_states:
153
+ # load(id), save(id, expected_revision:, next_revision:, record:), delete
154
+ #
155
+ # Subclasses normally construct backend-specific raw repository objects and
156
+ # call +super+. Construction fails fast when required capabilities are not
157
+ # advertised.
47
158
  #
48
- # @param contents [Object]
49
- # @param agents [Object]
50
- # @param journals [Object]
51
- # @param executions [Object]
52
- # @param workflow_states [Object]
53
- # @raise [UnsupportedBackendError] when a required capability is missing
54
159
  # @api public
55
- def initialize(contents:, agents:, journals:, executions:, workflow_states:)
160
+ def initialize(contents:, agents:, journals:, executions:, workflow_states:, handoff_states:, teams:, team_executions:)
56
161
  @contents = contents
57
- @agents = agents
58
- @journals = journals
59
- @executions = executions
60
- @workflow_states = workflow_states
162
+ @agents = RepositoryFacades::Agents.new(agents)
163
+ @journals = RepositoryFacades::Journals.new(journals)
164
+ @executions = RepositoryFacades::Executions.new(executions)
165
+ @workflow_states = RepositoryFacades::WorkflowStates.new(workflow_states)
166
+ @handoff_states = RepositoryFacades::HandoffStates.new(handoff_states)
167
+ @teams = RepositoryFacades::Teams.new(teams)
168
+ @team_executions = RepositoryFacades::TeamExecutions.new(team_executions)
61
169
  validate_capabilities!
62
170
  end
63
171
 
@@ -84,11 +192,9 @@ module Phronomy
84
192
 
85
193
  # Executes one atomic durable transaction.
86
194
  #
87
- # The object yielded to the block is a transaction-scoped Persistence view.
88
- # It must respond to +contents+, +agents+, +journals+, +executions+,
89
- # +workflow_states+, and +assert_agent_watermark!+. It may be +self+, but
90
- # backends are free to yield a separate transaction view backed by a checked
91
- # out connection/session.
195
+ # The yielded object is a transaction-scoped Persistence view exposing the
196
+ # same domain-facing repository facades. Its underlying backend repositories
197
+ # remain DurableRecord-oriented.
92
198
  #
93
199
  # If the block raises, mutations made through the transaction view must not
94
200
  # be committed. Storage failures whose commit outcome is fundamentally
@@ -103,6 +209,38 @@ module Phronomy
103
209
  raise UnsupportedBackendError, "#{self.class} does not provide atomic_all"
104
210
  end
105
211
 
212
+ # Backend SPI helper for transaction implementations whose transaction-scoped
213
+ # raw repositories differ from the root repository objects.
214
+ #
215
+ # +watermark+ is a transaction-scoped object responding to
216
+ # +assert_agent_watermark!+. The returned view owns the same Phronomy codec
217
+ # facades as the root Persistence instance, so backend authors never need to
218
+ # instantiate RepositoryFacades directly.
219
+ #
220
+ # Backends whose repository objects are already transaction-scoped may yield
221
+ # +self+ and need not call this helper.
222
+ #
223
+ # @api public
224
+ def build_transaction_view(
225
+ contents:,
226
+ agents:,
227
+ journals:,
228
+ executions:,
229
+ workflow_states:,
230
+ handoff_states:, teams:, team_executions:,
231
+ watermark:
232
+ )
233
+ RepositoryFacades::View.new(
234
+ contents: contents,
235
+ agents: agents,
236
+ journals: journals,
237
+ executions: executions,
238
+ workflow_states: workflow_states,
239
+ handoff_states: handoff_states, teams: teams, team_executions: team_executions,
240
+ watermark: watermark
241
+ )
242
+ end
243
+
106
244
  # Verifies that a live Agent still owns the durable base it hydrated.
107
245
  #
108
246
  # This is a Backend SPI operation invoked by Phronomy at durable barriers.
@@ -111,16 +249,6 @@ module Phronomy
111
249
  # supplied watermark in the same storage consistency view used by subsequent
112
250
  # writes in the surrounding transaction.
113
251
  #
114
- # The method is a precondition check only. It must not reload or return
115
- # replacement mutable Agent state; the live Agent remains the logical owner.
116
- #
117
- # @param agent_id [String]
118
- # @param agent_revision [Integer]
119
- # @param journal_position [Integer]
120
- # @return [true]
121
- # @raise [NotFoundError] when the Agent does not exist
122
- # @raise [ConflictError] when either durable watermark component differs
123
- # @raise [UnsupportedBackendError] when the backend does not implement the check
124
252
  # @api public
125
253
  def assert_agent_watermark!(agent_id:, agent_revision:, journal_position:)
126
254
  raise UnsupportedBackendError,
@@ -129,6 +257,12 @@ module Phronomy
129
257
 
130
258
  private
131
259
 
260
+ def assert_observation_thread!
261
+ if Phronomy::Runtime.in_event_loop_context?
262
+ raise Phronomy::EventLoopReentrancyError, "Persistence observation cannot block EventLoop"
263
+ end
264
+ end
265
+
132
266
  def validate_capabilities!
133
267
  missing = REQUIRED_CAPABILITIES.reject do |key, value|
134
268
  capabilities[key] == value
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Small private Recovery semantic/primitives layer shared by Agent and
5
+ # Workflow recovery. Entity-specific orchestration remains outside this module.
6
+ # @api private
7
+ module Recovery
8
+ RESUMABLE = :resumable
9
+ RECONCILABLE = :reconcilable
10
+ RESOLUTION_REQUIRED = :resolution_required
11
+ DISPOSITIONS = [RESUMABLE, RECONCILABLE, RESOLUTION_REQUIRED].freeze
12
+
13
+ ALLOWED_OUTCOMES = %i[succeeded failed not_performed].freeze
14
+ # Internal shorthand used by the Agent Recovery implementation. This is not
15
+ # a public API constant; both names refer to the same frozen value.
16
+ OUTCOMES = ALLOWED_OUTCOMES
17
+
18
+ MISSING = Object.new.freeze
19
+
20
+ Classification = Data.define(
21
+ :disposition, :reason, :subject, :allowed_outcomes, :facts
22
+ ) do
23
+ def initialize(
24
+ disposition:,
25
+ reason:,
26
+ subject: nil,
27
+ allowed_outcomes: [],
28
+ facts: {}
29
+ )
30
+ normalized = disposition.to_sym
31
+ unless Phronomy::Recovery::DISPOSITIONS.include?(normalized)
32
+ raise ArgumentError, "unknown Recovery disposition: #{disposition.inspect}"
33
+ end
34
+ super(
35
+ disposition: normalized,
36
+ reason: reason.to_sym,
37
+ subject: subject && Phronomy::Recovery.normalize_subject(subject),
38
+ allowed_outcomes: Array(allowed_outcomes).map(&:to_sym).freeze,
39
+ facts: Phronomy::Agent::Immutable.copy(facts || {})
40
+ )
41
+ end
42
+ end
43
+
44
+ module_function
45
+
46
+ def normalize_outcome(outcome)
47
+ normalized = outcome.to_sym
48
+ unless OUTCOMES.include?(normalized)
49
+ raise ArgumentError, "unsupported Recovery outcome: #{outcome.inspect}"
50
+ end
51
+ normalized
52
+ end
53
+
54
+ def validate_resolution_material!(outcome:, result_present:, error_present:)
55
+ normalized = normalize_outcome(outcome)
56
+ case normalized
57
+ when :succeeded
58
+ raise ArgumentError, "Recovery :succeeded requires result:" unless result_present
59
+ raise ArgumentError, "Recovery :succeeded forbids error:" if error_present
60
+ when :failed
61
+ raise ArgumentError, "Recovery :failed requires error:" unless error_present
62
+ raise ArgumentError, "Recovery :failed forbids result:" if result_present
63
+ when :not_performed
64
+ if result_present || error_present
65
+ raise ArgumentError, "Recovery :not_performed accepts neither result: nor error:"
66
+ end
67
+ end
68
+ true
69
+ end
70
+
71
+ def normalize_subject(subject)
72
+ hash = subject.to_h.transform_keys(&:to_sym)
73
+ type = hash.fetch(:type).to_sym
74
+ normalized = {type: type}
75
+ case type
76
+ when :llm_call
77
+ normalized[:llm_call_id] = required_id(hash, :llm_call_id)
78
+ when :tool_invocation
79
+ normalized[:tool_invocation_id] = required_id(hash, :tool_invocation_id)
80
+ when :persistence_operation
81
+ # Persistence operation references are structural only. Do not introduce
82
+ # a generic durable operation/recovery identity here.
83
+ normalized[:entity] = hash.fetch(:entity).to_sym
84
+ else
85
+ raise ArgumentError, "unsupported Recovery subject type: #{type.inspect}"
86
+ end
87
+ normalized.freeze
88
+ end
89
+
90
+ def subject_key(subject)
91
+ normalized = normalize_subject(subject)
92
+ case normalized.fetch(:type)
93
+ when :llm_call
94
+ "llm_call:#{normalized.fetch(:llm_call_id)}"
95
+ when :tool_invocation
96
+ "tool_invocation:#{normalized.fetch(:tool_invocation_id)}"
97
+ when :persistence_operation
98
+ "persistence_operation:#{normalized.fetch(:entity)}"
99
+ end
100
+ end
101
+
102
+ def subject_equal?(left, right)
103
+ normalize_subject(left) == normalize_subject(right)
104
+ rescue ArgumentError, KeyError
105
+ false
106
+ end
107
+
108
+ # Compare authoritative current state with a known pre/post revision pair.
109
+ # This is deliberately small: callers still validate entity-specific content.
110
+ def compare_revisions(current_revision:, expected_pre_revision:, intended_post_revision:)
111
+ current = current_revision
112
+ return :post_state if current == intended_post_revision
113
+ return :pre_state if current == expected_pre_revision
114
+
115
+ :conflict
116
+ end
117
+
118
+ # Generic F1 helper for revisioned snapshot repositories such as Workflow.
119
+ # Some repositories return a newly allocated revision only after save, so
120
+ # callers may not know the intended post revision in advance. In that case
121
+ # the intended snapshot plus a revision different from the expected pre
122
+ # revision identifies the post-state.
123
+ def compare_revisioned_snapshot(
124
+ record:,
125
+ expected_pre_revision:,
126
+ intended_snapshot:,
127
+ intended_post_revision: nil
128
+ )
129
+ return :pre_state if record.nil? && expected_pre_revision.nil?
130
+ return :conflict if record.nil?
131
+
132
+ revision = fetch_value(record, :revision)
133
+ snapshot = fetch_value(record, :snapshot)
134
+ normalized_snapshot = normalize_value(snapshot)
135
+ normalized_intended = normalize_value(intended_snapshot)
136
+
137
+ if intended_post_revision
138
+ if revision == intended_post_revision &&
139
+ normalized_snapshot == normalized_intended
140
+ return :post_state
141
+ end
142
+ elsif normalized_snapshot == normalized_intended &&
143
+ revision != expected_pre_revision
144
+ return :post_state
145
+ end
146
+
147
+ return :pre_state if revision == expected_pre_revision
148
+
149
+ :conflict
150
+ end
151
+
152
+ def normalize_value(value)
153
+ case value
154
+ when Hash
155
+ value.each_with_object({}) do |(key, child), result|
156
+ result[key.to_s] = normalize_value(child)
157
+ end
158
+ when Array
159
+ value.map { |child| normalize_value(child) }
160
+ when Symbol
161
+ value.to_s
162
+ else
163
+ value
164
+ end
165
+ end
166
+
167
+ def fetch_value(record, key)
168
+ return nil unless record
169
+ return record.public_send(key) if record.respond_to?(key)
170
+ return record[key] if record.respond_to?(:key?) && record.key?(key)
171
+ string_key = key.to_s
172
+ return record[string_key] if record.respond_to?(:key?) && record.key?(string_key)
173
+
174
+ nil
175
+ end
176
+
177
+ def required_id(hash, key)
178
+ value = hash.fetch(key)
179
+ text = value.to_s
180
+ raise ArgumentError, "#{key} must not be empty" if text.empty?
181
+
182
+ text.freeze
183
+ end
184
+ private_class_method :required_id
185
+ end
186
+ end
@@ -8,7 +8,7 @@ RSpec.shared_examples "a Journal repository" do
8
8
  Phronomy::Agent::AgentRoot.create(
9
9
  agent_id: "journal-agent-#{SecureRandom.uuid}",
10
10
  agent_definition_id: "contract-agent",
11
- definition_version: 1
11
+ agent_definition_version: 1
12
12
  )
13
13
  end
14
14
 
@@ -106,7 +106,7 @@ RSpec.shared_examples "a Journal repository" do
106
106
  expected_position: 0,
107
107
  records: [record]
108
108
  )
109
- end.to raise_error(Phronomy::Persistence::ConflictError)
109
+ end.to raise_error(Phronomy::Persistence::SerializationError)
110
110
  end
111
111
 
112
112
  it "reads records in sequence order and supports after/limit" do
@@ -7,7 +7,7 @@ RSpec.shared_examples "a Persistence backend" do
7
7
  Phronomy::Agent::AgentRoot.create(
8
8
  agent_id: "backend-agent-#{SecureRandom.uuid}",
9
9
  agent_definition_id: "contract-agent",
10
- definition_version: 1
10
+ agent_definition_version: 1
11
11
  )
12
12
  end
13
13
 
@@ -36,6 +36,7 @@ RSpec.shared_examples "a Persistence backend" do
36
36
  :journals,
37
37
  :executions,
38
38
  :workflow_states,
39
+ :handoff_states, :teams, :team_executions,
39
40
  :assert_agent_watermark!
40
41
  )
41
42
  end
@@ -17,15 +17,15 @@ RSpec.shared_examples "a workflow state repository" do
17
17
  workflow_contract_value(record, :revision)
18
18
  end
19
19
 
20
- it "returns nil for an unknown thread_id" do
20
+ it "returns nil for an unknown workflow_instance_id" do
21
21
  expect(repository.load("missing-#{SecureRandom.uuid}")).to be_nil
22
22
  end
23
23
 
24
24
  it "uses optimistic revisions for save" do
25
- thread_id = "t1-#{SecureRandom.uuid}"
25
+ workflow_instance_id = "t1-#{SecureRandom.uuid}"
26
26
  expect(
27
27
  repository.save(
28
- thread_id,
28
+ workflow_instance_id,
29
29
  expected_revision: nil,
30
30
  snapshot: {fields: {value: 1}, phase: "pause"}
31
31
  )
@@ -33,7 +33,7 @@ RSpec.shared_examples "a workflow state repository" do
33
33
 
34
34
  expect(
35
35
  repository.save(
36
- thread_id,
36
+ workflow_instance_id,
37
37
  expected_revision: 1,
38
38
  snapshot: {fields: {value: 2}, phase: "__end__"}
39
39
  )
@@ -41,16 +41,16 @@ RSpec.shared_examples "a workflow state repository" do
41
41
  end
42
42
 
43
43
  it "rejects a stale expected_revision" do
44
- thread_id = "t1-#{SecureRandom.uuid}"
44
+ workflow_instance_id = "t1-#{SecureRandom.uuid}"
45
45
  repository.save(
46
- thread_id,
46
+ workflow_instance_id,
47
47
  expected_revision: nil,
48
48
  snapshot: {fields: {value: 1}, phase: "pause"}
49
49
  )
50
50
 
51
51
  expect do
52
52
  repository.save(
53
- thread_id,
53
+ workflow_instance_id,
54
54
  expected_revision: nil,
55
55
  snapshot: {fields: {value: 2}, phase: "__end__"}
56
56
  )
@@ -58,14 +58,14 @@ RSpec.shared_examples "a workflow state repository" do
58
58
  end
59
59
 
60
60
  it "returns a snapshot representation accepted by WorkflowRunner" do
61
- thread_id = "t1-#{SecureRandom.uuid}"
61
+ workflow_instance_id = "t1-#{SecureRandom.uuid}"
62
62
  repository.save(
63
- thread_id,
63
+ workflow_instance_id,
64
64
  expected_revision: nil,
65
65
  snapshot: {fields: {value: 1}, phase: "pause"}
66
66
  )
67
67
 
68
- record = repository.load(thread_id)
68
+ record = repository.load(workflow_instance_id)
69
69
  snapshot = workflow_contract_snapshot(record)
70
70
  fields = workflow_contract_value(snapshot, :fields)
71
71
 
@@ -75,14 +75,14 @@ RSpec.shared_examples "a workflow state repository" do
75
75
  end
76
76
 
77
77
  it "isolates stored snapshots from caller mutation" do
78
- thread_id = "t1-#{SecureRandom.uuid}"
78
+ workflow_instance_id = "t1-#{SecureRandom.uuid}"
79
79
  repository.save(
80
- thread_id,
80
+ workflow_instance_id,
81
81
  expected_revision: nil,
82
82
  snapshot: {fields: {values: [1]}, phase: "pause"}
83
83
  )
84
84
 
85
- loaded = repository.load(thread_id)
85
+ loaded = repository.load(workflow_instance_id)
86
86
  snapshot = workflow_contract_snapshot(loaded)
87
87
  fields = workflow_contract_value(snapshot, :fields)
88
88
  values = workflow_contract_value(fields, :values)
@@ -93,7 +93,7 @@ RSpec.shared_examples "a workflow state repository" do
93
93
  nil
94
94
  end
95
95
 
96
- reloaded = repository.load(thread_id)
96
+ reloaded = repository.load(workflow_instance_id)
97
97
  reloaded_fields = workflow_contract_value(
98
98
  workflow_contract_snapshot(reloaded),
99
99
  :fields
@@ -102,18 +102,18 @@ RSpec.shared_examples "a workflow state repository" do
102
102
  end
103
103
 
104
104
  it "deletes only at the expected revision" do
105
- thread_id = "t1-#{SecureRandom.uuid}"
105
+ workflow_instance_id = "t1-#{SecureRandom.uuid}"
106
106
  repository.save(
107
- thread_id,
107
+ workflow_instance_id,
108
108
  expected_revision: nil,
109
109
  snapshot: {fields: {}, phase: "pause"}
110
110
  )
111
111
 
112
112
  expect do
113
- repository.delete(thread_id, expected_revision: 99)
113
+ repository.delete(workflow_instance_id, expected_revision: 99)
114
114
  end.to raise_error(Phronomy::Persistence::ConflictError)
115
115
 
116
- repository.delete(thread_id, expected_revision: 1)
117
- expect(repository.load(thread_id)).to be_nil
116
+ repository.delete(workflow_instance_id, expected_revision: 1)
117
+ expect(repository.load(workflow_instance_id)).to be_nil
118
118
  end
119
119
  end
@@ -8,7 +8,7 @@ RSpec.shared_examples "an Agent repository" do
8
8
  Phronomy::Agent::AgentRoot.create(
9
9
  agent_id: "contract-agent-#{SecureRandom.uuid}",
10
10
  agent_definition_id: "contract-agent",
11
- definition_version: 1
11
+ agent_definition_version: 1
12
12
  )
13
13
  end
14
14
 
@@ -62,7 +62,7 @@ RSpec.shared_examples "an Agent repository" do
62
62
  other = Phronomy::Agent::AgentRoot.create(
63
63
  agent_id: "other-#{SecureRandom.uuid}",
64
64
  agent_definition_id: "contract-agent",
65
- definition_version: 1
65
+ agent_definition_version: 1
66
66
  ).with(agent_revision: 1)
67
67
 
68
68
  expect do
@@ -71,7 +71,7 @@ RSpec.shared_examples "an Agent repository" do
71
71
  expected_revision: 0,
72
72
  root: other
73
73
  )
74
- end.to raise_error(Phronomy::Persistence::ConflictError)
74
+ end.to raise_error(Phronomy::Persistence::SerializationError)
75
75
  end
76
76
 
77
77
  it "requires revision to advance exactly once" do
@@ -8,7 +8,7 @@ RSpec.shared_examples "an Execution repository" do
8
8
  Phronomy::Agent::AgentRoot.create(
9
9
  agent_id: "execution-agent-#{SecureRandom.uuid}",
10
10
  agent_definition_id: "contract-agent",
11
- definition_version: 1
11
+ agent_definition_version: 1
12
12
  )
13
13
  end
14
14
 
@@ -66,7 +66,7 @@ RSpec.shared_examples "an Execution repository" do
66
66
  other_root = Phronomy::Agent::AgentRoot.create(
67
67
  agent_id: "execution-agent-#{SecureRandom.uuid}",
68
68
  agent_definition_id: "contract-agent",
69
- definition_version: 1
69
+ agent_definition_version: 1
70
70
  )
71
71
  persistence.agents.create(other_root)
72
72
 
@@ -116,7 +116,7 @@ RSpec.shared_examples "an Execution repository" do
116
116
  other_root = Phronomy::Agent::AgentRoot.create(
117
117
  agent_id: "execution-agent-#{SecureRandom.uuid}",
118
118
  agent_definition_id: "contract-agent",
119
- definition_version: 1
119
+ agent_definition_version: 1
120
120
  )
121
121
  persistence.agents.create(other_root)
122
122
  other = build_contract_execution(other_root).with(
@@ -131,7 +131,7 @@ RSpec.shared_examples "an Execution repository" do
131
131
  expected_revision: 0,
132
132
  execution: other
133
133
  )
134
- end.to raise_error(Phronomy::Persistence::ConflictError)
134
+ end.to raise_error(Phronomy::Persistence::SerializationError)
135
135
  end
136
136
 
137
137
  it "requires execution revision to advance exactly once" do
@@ -186,7 +186,7 @@ RSpec.shared_examples "an Execution repository" do
186
186
  other_root = Phronomy::Agent::AgentRoot.create(
187
187
  agent_id: "execution-agent-#{SecureRandom.uuid}",
188
188
  agent_definition_id: "contract-agent",
189
- definition_version: 1
189
+ agent_definition_version: 1
190
190
  )
191
191
  persistence.agents.create(other_root)
192
192
  own = build_contract_execution(execution_agent_root)