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
@@ -45,75 +45,104 @@ module Phronomy
45
45
  end
46
46
  end
47
47
 
48
+ # Backend-side Agent record repository. DurableRecord is opaque here;
49
+ # identity/revision metadata is supplied explicitly by RepositoryFacades.
48
50
  class Agents
49
51
  def initialize(owner) = @owner = owner
50
52
 
51
- def create(root)
53
+ def create(agent_id:, agent_revision:, record:)
54
+ record = @owner.require_durable_record!(record)
55
+ key = agent_id.to_s
56
+ revision = Integer(agent_revision)
57
+ raise ConflictError, "agent_id must not be empty" if key.empty?
58
+ raise ConflictError, "agent_revision must be non-negative" if revision.negative?
59
+
52
60
  @owner.synchronize do
53
- key = root.agent_id.to_s
54
- raise ConflictError, "agent_id must not be empty" if key.empty?
55
61
  raise ConflictError, "agent already exists: #{key}" if @owner.state[:agents].key?(key)
56
- @owner.state[:agents][key] = root
62
+ @owner.state[:agents][key] = record.copy
63
+ @owner.state[:agent_revisions][key] = revision
57
64
  end
58
- root
65
+ record.copy
59
66
  end
60
67
 
61
68
  def load(agent_id)
62
69
  @owner.synchronize do
63
- @owner.state[:agents].fetch(agent_id.to_s) { raise NotFoundError, "agent not found: #{agent_id}" }
70
+ @owner.state[:agents].fetch(agent_id.to_s) do
71
+ raise NotFoundError, "agent not found: #{agent_id}"
72
+ end.copy
64
73
  end
65
74
  end
66
75
 
67
- def save(agent_id, expected_revision:, root:)
76
+ def save(agent_id, expected_revision:, next_revision:, record:)
77
+ record = @owner.require_durable_record!(record)
78
+ key = agent_id.to_s
79
+ expected = Integer(expected_revision)
80
+ next_value = Integer(next_revision)
68
81
  @owner.synchronize do
69
- current = @owner.state[:agents].fetch(agent_id.to_s) { raise NotFoundError, "agent not found: #{agent_id}" }
70
- unless current.agent_revision == expected_revision
71
- raise ConflictError,
72
- "agent revision conflict: expected #{expected_revision}, actual #{current.agent_revision}"
82
+ unless @owner.state[:agents].key?(key)
83
+ raise NotFoundError, "agent not found: #{agent_id}"
73
84
  end
74
- unless root.agent_id.to_s == agent_id.to_s
75
- raise ConflictError, "Agent root identity mismatch: #{root.agent_id} != #{agent_id}"
85
+ actual_revision = @owner.state[:agent_revisions].fetch(key)
86
+ unless actual_revision == expected
87
+ raise ConflictError,
88
+ "agent revision conflict: expected #{expected}, actual #{actual_revision}"
76
89
  end
77
- unless root.agent_revision == expected_revision + 1
90
+ unless next_value == expected + 1
78
91
  raise ConflictError,
79
92
  "agent save must advance revision exactly once: " \
80
- "expected #{expected_revision + 1}, got #{root.agent_revision}"
93
+ "expected #{expected + 1}, got #{next_value}"
81
94
  end
82
- @owner.state[:agents][agent_id.to_s] = root
95
+ @owner.state[:agents][key] = record.copy
96
+ @owner.state[:agent_revisions][key] = next_value
83
97
  end
84
- root
98
+ record.copy
85
99
  end
86
100
 
87
101
  def delete(agent_id)
88
- @owner.synchronize { @owner.state[:agents].delete(agent_id.to_s) }
102
+ @owner.synchronize do
103
+ key = agent_id.to_s
104
+ @owner.state[:agent_revisions].delete(key)
105
+ @owner.state[:agents].delete(key)
106
+ end
89
107
  end
90
108
  end
91
109
 
92
110
  class Journals
93
111
  def initialize(owner) = @owner = owner
94
112
 
95
- def append(agent_id, expected_position:, records:)
113
+ def append(agent_id, expected_position:, records:, record_ids:)
114
+ encoded = Array(records).map { |record| @owner.require_durable_record!(record) }
115
+ ids = Array(record_ids).map(&:to_s)
116
+ unless encoded.length == ids.length
117
+ raise ConflictError,
118
+ "Journal records/record_ids length mismatch: #{encoded.length} != #{ids.length}"
119
+ end
120
+ if ids.any?(&:empty?)
121
+ raise ConflictError, "Journal record_id must not be empty"
122
+ end
123
+
96
124
  @owner.synchronize do
97
- target = (@owner.state[:journals][agent_id.to_s] ||= [])
98
- unless target.length == expected_position
125
+ key = agent_id.to_s
126
+ target = (@owner.state[:journals][key] ||= [])
127
+ known_ids = (@owner.state[:journal_record_ids][key] ||= {})
128
+ expected = Integer(expected_position)
129
+ unless target.length == expected
99
130
  raise ConflictError,
100
- "journal position conflict: expected #{expected_position}, actual #{target.length}"
131
+ "journal position conflict: expected #{expected}, actual #{target.length}"
101
132
  end
102
- existing_ids = target.to_h { |record| [record.record_id, true] }
133
+
103
134
  incoming_ids = {}
104
- appended = Array(records).each_with_index.map do |record, index|
105
- unless record.agent_id.to_s == agent_id.to_s
106
- raise ConflictError,
107
- "Journal record Agent mismatch: #{record.agent_id} != #{agent_id}"
135
+ ids.each do |record_id|
136
+ if known_ids[record_id] || incoming_ids[record_id]
137
+ raise ConflictError, "duplicate Journal record_id: #{record_id}"
108
138
  end
109
- if existing_ids[record.record_id] || incoming_ids[record.record_id]
110
- raise ConflictError, "duplicate Journal record_id: #{record.record_id}"
111
- end
112
- incoming_ids[record.record_id] = true
113
- record.with_sequence(expected_position + index + 1)
139
+ incoming_ids[record_id] = true
114
140
  end
115
- target.concat(appended)
116
- appended.freeze
141
+
142
+ stored = encoded.map(&:copy)
143
+ target.concat(stored)
144
+ incoming_ids.each_key { |record_id| known_ids[record_id] = true }
145
+ stored.map(&:copy).freeze
117
146
  end
118
147
  end
119
148
 
@@ -121,8 +150,8 @@ module Phronomy
121
150
  @owner.synchronize do
122
151
  result = Array(@owner.state[:journals][agent_id.to_s])
123
152
  result = result.drop(Integer(after)) if after
124
- result = result.first(limit) if limit
125
- result.dup.freeze
153
+ result = result.first(Integer(limit)) if limit
154
+ result.map(&:copy).freeze
126
155
  end
127
156
  end
128
157
 
@@ -131,152 +160,478 @@ module Phronomy
131
160
  end
132
161
 
133
162
  def delete(agent_id)
134
- @owner.synchronize { @owner.state[:journals].delete(agent_id.to_s) }
163
+ @owner.synchronize do
164
+ key = agent_id.to_s
165
+ @owner.state[:journal_record_ids].delete(key)
166
+ @owner.state[:journals].delete(key)
167
+ end
135
168
  end
136
169
  end
137
170
 
138
171
  class Executions
139
172
  def initialize(owner) = @owner = owner
140
173
 
141
- def create_active(execution)
174
+ def create_active(execution_id:, agent_id:, execution_revision:, record:)
175
+ record = @owner.require_durable_record!(record)
176
+ execution_key = execution_id.to_s
177
+ agent_key = agent_id.to_s
178
+ revision = Integer(execution_revision)
179
+ raise ConflictError, "execution_id must not be empty" if execution_key.empty?
180
+ raise ConflictError, "agent_id must not be empty" if agent_key.empty?
181
+ raise ConflictError, "execution_revision must be non-negative" if revision.negative?
182
+
142
183
  @owner.synchronize do
143
- if @owner.state[:executions].key?(execution.execution_id.to_s)
144
- raise ConflictError, "execution already exists: #{execution.execution_id}"
184
+ if @owner.state[:executions].key?(execution_key)
185
+ raise ConflictError, "execution already exists: #{execution_key}"
145
186
  end
146
- active = @owner.state[:executions].values.find do |candidate|
147
- candidate.agent_id == execution.agent_id && candidate.active?
187
+ active = @owner.state[:execution_metadata].values.find do |metadata|
188
+ metadata.fetch(:agent_id) == agent_key && metadata.fetch(:active)
148
189
  end
149
- raise Phronomy::AgentBusyError, "agent is busy: #{execution.agent_id}" if active
150
- @owner.state[:executions][execution.execution_id] = execution
190
+ raise Phronomy::AgentBusyError, "agent is busy: #{agent_key}" if active
191
+
192
+ @owner.state[:executions][execution_key] = record.copy
193
+ @owner.state[:execution_metadata][execution_key] = {
194
+ agent_id: agent_key,
195
+ revision: revision,
196
+ active: true
197
+ }.freeze
151
198
  end
152
- execution
199
+ record.copy
153
200
  end
154
201
 
155
202
  def load(execution_id)
156
203
  @owner.synchronize do
157
204
  @owner.state[:executions].fetch(execution_id.to_s) do
158
205
  raise NotFoundError, "execution not found: #{execution_id}"
159
- end
206
+ end.copy
160
207
  end
161
208
  end
162
209
 
163
- def save(execution_id, expected_revision:, execution:)
210
+ def save(execution_id, expected_revision:, next_revision:, agent_id:, active:, record:)
211
+ record = @owner.require_durable_record!(record)
212
+ execution_key = execution_id.to_s
213
+ agent_key = agent_id.to_s
214
+ expected = Integer(expected_revision)
215
+ next_value = Integer(next_revision)
216
+ unless active.equal?(true) || active.equal?(false)
217
+ raise ConflictError, "execution active metadata must be true or false"
218
+ end
219
+
164
220
  @owner.synchronize do
165
- current = @owner.state[:executions].fetch(execution_id.to_s) { raise NotFoundError, "execution not found: #{execution_id}" }
166
- unless current.execution_revision == expected_revision
221
+ unless @owner.state[:executions].key?(execution_key)
222
+ raise NotFoundError, "execution not found: #{execution_id}"
223
+ end
224
+ current = @owner.state[:execution_metadata].fetch(execution_key)
225
+ actual_revision = current.fetch(:revision)
226
+ unless actual_revision == expected
167
227
  raise ConflictError,
168
- "execution revision conflict: expected #{expected_revision}, actual #{current.execution_revision}"
228
+ "execution revision conflict: expected #{expected}, actual #{actual_revision}"
169
229
  end
170
- unless execution.execution_id.to_s == execution_id.to_s
230
+ unless current.fetch(:agent_id) == agent_key
171
231
  raise ConflictError,
172
- "Execution identity mismatch: #{execution.execution_id} != #{execution_id}"
232
+ "Execution Agent identity mismatch: #{agent_key} != #{current.fetch(:agent_id)}"
173
233
  end
174
- unless execution.execution_revision == expected_revision + 1
234
+ unless next_value == expected + 1
175
235
  raise ConflictError,
176
236
  "execution save must advance revision exactly once: " \
177
- "expected #{expected_revision + 1}, got #{execution.execution_revision}"
237
+ "expected #{expected + 1}, got #{next_value}"
178
238
  end
179
- @owner.state[:executions][execution_id.to_s] = execution
239
+
240
+ @owner.state[:executions][execution_key] = record.copy
241
+ @owner.state[:execution_metadata][execution_key] = {
242
+ agent_id: agent_key,
243
+ revision: next_value,
244
+ active: active
245
+ }.freeze
180
246
  end
181
- execution
247
+ record.copy
182
248
  end
183
249
 
184
250
  def list_active(agent_id)
185
251
  @owner.synchronize do
186
- @owner.state[:executions].values.select do |execution|
187
- execution.agent_id == agent_id.to_s && execution.active?
188
- end.freeze
252
+ agent_key = agent_id.to_s
253
+ ids = @owner.state[:execution_metadata].filter_map do |execution_id, metadata|
254
+ execution_id if metadata.fetch(:agent_id) == agent_key && metadata.fetch(:active)
255
+ end
256
+ ids.map { |execution_id| @owner.state[:executions].fetch(execution_id).copy }.freeze
257
+ end
258
+ end
259
+
260
+ def list(agent_id, after: nil, limit: 100)
261
+ @owner.synchronize do
262
+ ids = @owner.state[:execution_metadata].filter_map do |id, metadata|
263
+ id if metadata.fetch(:agent_id) == agent_id.to_s && (!after || id > after.to_s)
264
+ end.sort.first(limit)
265
+ ids.map { |id| @owner.state[:executions].fetch(id).copy }.freeze
189
266
  end
190
267
  end
191
268
 
192
269
  def delete(execution_id)
193
- @owner.synchronize { @owner.state[:executions].delete(execution_id.to_s) }
270
+ @owner.synchronize do
271
+ key = execution_id.to_s
272
+ @owner.state[:execution_metadata].delete(key)
273
+ @owner.state[:executions].delete(key)
274
+ end
194
275
  end
195
276
 
196
277
  def delete_for_agent(agent_id)
197
278
  @owner.synchronize do
198
- @owner.state[:executions].delete_if { |_id, execution| execution.agent_id == agent_id.to_s }
279
+ agent_key = agent_id.to_s
280
+ ids = @owner.state[:execution_metadata].filter_map do |execution_id, metadata|
281
+ execution_id if metadata.fetch(:agent_id) == agent_key
282
+ end
283
+ ids.each do |execution_id|
284
+ @owner.state[:execution_metadata].delete(execution_id)
285
+ @owner.state[:executions].delete(execution_id)
286
+ end
199
287
  end
200
288
  end
201
289
 
202
- # Raises AgentBusyError if there is an active execution for agent_id.
203
- # Must be called from within a transaction (monitor already held).
204
290
  def assert_idle!(agent_id)
205
- active = @owner.state[:executions].values.find do |candidate|
206
- candidate.agent_id == agent_id.to_s && candidate.active?
291
+ @owner.synchronize do
292
+ active = @owner.state[:execution_metadata].values.find do |metadata|
293
+ metadata.fetch(:agent_id) == agent_id.to_s && metadata.fetch(:active)
294
+ end
295
+ if active
296
+ raise Phronomy::AgentBusyError,
297
+ "agent has an active or suspended execution: #{agent_id}"
298
+ end
207
299
  end
208
- raise Phronomy::AgentBusyError, "agent has an active or suspended execution: #{agent_id}" if active
300
+ true
209
301
  end
210
302
  end
211
303
 
212
- class WorkflowStates
304
+ class Teams
213
305
  def initialize(owner) = @owner = owner
214
306
 
215
- def load(thread_id)
307
+ def create(team_id:, team_revision:, record:)
308
+ record = @owner.require_durable_record!(record)
309
+ key = team_id.to_s
310
+ revision = Integer(team_revision)
311
+ raise ConflictError, "team_id must not be empty" if key.empty?
312
+ raise ConflictError, "team_revision must be non-negative" if revision.negative?
313
+
216
314
  @owner.synchronize do
217
- record = @owner.workflow_state_data[thread_id.to_s]
218
- next nil unless record
315
+ raise ConflictError, "agent already exists: #{key}" if @owner.state[:teams].key?(key)
316
+ @owner.state[:teams][key] = record.copy
317
+ @owner.state[:team_revisions][key] = revision
318
+ end
319
+ record.copy
320
+ end
321
+
322
+ def load(team_id)
323
+ @owner.synchronize do
324
+ @owner.state[:teams].fetch(team_id.to_s) do
325
+ raise NotFoundError, "agent not found: #{team_id}"
326
+ end.copy
327
+ end
328
+ end
329
+
330
+ def save(team_id, expected_revision:, next_revision:, record:)
331
+ record = @owner.require_durable_record!(record)
332
+ key = team_id.to_s
333
+ expected = Integer(expected_revision)
334
+ next_value = Integer(next_revision)
335
+ @owner.synchronize do
336
+ unless @owner.state[:teams].key?(key)
337
+ raise NotFoundError, "agent not found: #{team_id}"
338
+ end
339
+ actual_revision = @owner.state[:team_revisions].fetch(key)
340
+ unless actual_revision == expected
341
+ raise ConflictError,
342
+ "agent revision conflict: expected #{expected}, actual #{actual_revision}"
343
+ end
344
+ unless next_value == expected + 1
345
+ raise ConflictError,
346
+ "agent save must advance revision exactly once: " \
347
+ "expected #{expected + 1}, got #{next_value}"
348
+ end
349
+ @owner.state[:teams][key] = record.copy
350
+ @owner.state[:team_revisions][key] = next_value
351
+ end
352
+ record.copy
353
+ end
354
+
355
+ def delete(team_id)
356
+ @owner.synchronize do
357
+ key = team_id.to_s
358
+ @owner.state[:team_revisions].delete(key)
359
+ @owner.state[:teams].delete(key)
360
+ end
361
+ end
362
+ end
363
+
364
+ class TeamExecutions
365
+ def initialize(owner) = @owner = owner
366
+
367
+ def create_active(team_execution_id:, team_id:, execution_revision:, record:)
368
+ record = @owner.require_durable_record!(record)
369
+ execution_key = team_execution_id.to_s
370
+ agent_key = team_id.to_s
371
+ revision = Integer(execution_revision)
372
+ raise ConflictError, "team_execution_id must not be empty" if execution_key.empty?
373
+ raise ConflictError, "team_id must not be empty" if agent_key.empty?
374
+ raise ConflictError, "execution_revision must be non-negative" if revision.negative?
219
375
 
220
- {
221
- snapshot: @owner.deep_dup_workflow_value(record.fetch(:snapshot)),
222
- revision: record.fetch(:revision)
376
+ @owner.synchronize do
377
+ if @owner.state[:team_executions].key?(execution_key)
378
+ raise ConflictError, "execution already exists: #{execution_key}"
379
+ end
380
+ active = @owner.state[:team_execution_metadata].values.find do |metadata|
381
+ metadata.fetch(:team_id) == agent_key && metadata.fetch(:active)
382
+ end
383
+ raise Phronomy::AgentBusyError, "agent is busy: #{agent_key}" if active
384
+
385
+ @owner.state[:team_executions][execution_key] = record.copy
386
+ @owner.state[:team_execution_metadata][execution_key] = {
387
+ team_id: agent_key,
388
+ revision: revision,
389
+ active: true
223
390
  }.freeze
224
391
  end
392
+ record.copy
225
393
  end
226
394
 
227
- def save(thread_id, expected_revision:, snapshot:)
395
+ def load(team_execution_id)
228
396
  @owner.synchronize do
229
- key = thread_id.to_s
230
- current = @owner.workflow_state_data[key]
231
- actual_revision = current&.fetch(:revision)
232
- unless actual_revision == expected_revision
397
+ @owner.state[:team_executions].fetch(team_execution_id.to_s) do
398
+ raise NotFoundError, "execution not found: #{team_execution_id}"
399
+ end.copy
400
+ end
401
+ end
402
+
403
+ def save(team_execution_id, expected_revision:, next_revision:, team_id:, active:, record:)
404
+ record = @owner.require_durable_record!(record)
405
+ execution_key = team_execution_id.to_s
406
+ agent_key = team_id.to_s
407
+ expected = Integer(expected_revision)
408
+ next_value = Integer(next_revision)
409
+ unless active.equal?(true) || active.equal?(false)
410
+ raise ConflictError, "execution active metadata must be true or false"
411
+ end
412
+
413
+ @owner.synchronize do
414
+ unless @owner.state[:team_executions].key?(execution_key)
415
+ raise NotFoundError, "execution not found: #{team_execution_id}"
416
+ end
417
+ current = @owner.state[:team_execution_metadata].fetch(execution_key)
418
+ actual_revision = current.fetch(:revision)
419
+ unless actual_revision == expected
420
+ raise ConflictError,
421
+ "execution revision conflict: expected #{expected}, actual #{actual_revision}"
422
+ end
423
+ unless current.fetch(:team_id) == agent_key
424
+ raise ConflictError,
425
+ "Execution Team identity mismatch: #{agent_key} != #{current.fetch(:team_id)}"
426
+ end
427
+ if active && !current.fetch(:active)
428
+ raise ConflictError, "A terminal Team execution cannot become active"
429
+ end
430
+ unless next_value == expected + 1
431
+ raise ConflictError,
432
+ "execution save must advance revision exactly once: " \
433
+ "expected #{expected + 1}, got #{next_value}"
434
+ end
435
+
436
+ @owner.state[:team_executions][execution_key] = record.copy
437
+ @owner.state[:team_execution_metadata][execution_key] = {
438
+ team_id: agent_key,
439
+ revision: next_value,
440
+ active: active
441
+ }.freeze
442
+ end
443
+ record.copy
444
+ end
445
+
446
+ def list_active(team_id)
447
+ @owner.synchronize do
448
+ agent_key = team_id.to_s
449
+ ids = @owner.state[:team_execution_metadata].filter_map do |team_execution_id, metadata|
450
+ team_execution_id if metadata.fetch(:team_id) == agent_key && metadata.fetch(:active)
451
+ end
452
+ ids.map { |team_execution_id| @owner.state[:team_executions].fetch(team_execution_id).copy }.freeze
453
+ end
454
+ end
455
+
456
+ def list(team_id, after: nil, limit: 100)
457
+ @owner.synchronize do
458
+ ids = @owner.state[:team_execution_metadata].filter_map do |id, metadata|
459
+ id if metadata.fetch(:team_id) == team_id.to_s && (!after || id > after.to_s)
460
+ end.sort.first(limit)
461
+ ids.map { |id| @owner.state[:team_executions].fetch(id).copy }.freeze
462
+ end
463
+ end
464
+
465
+ def delete(team_execution_id)
466
+ @owner.synchronize do
467
+ key = team_execution_id.to_s
468
+ @owner.state[:team_execution_metadata].delete(key)
469
+ @owner.state[:team_executions].delete(key)
470
+ end
471
+ end
472
+
473
+ def delete_for_team(team_id)
474
+ @owner.synchronize do
475
+ agent_key = team_id.to_s
476
+ ids = @owner.state[:team_execution_metadata].filter_map do |team_execution_id, metadata|
477
+ team_execution_id if metadata.fetch(:team_id) == agent_key
478
+ end
479
+ ids.each do |team_execution_id|
480
+ @owner.state[:team_execution_metadata].delete(team_execution_id)
481
+ @owner.state[:team_executions].delete(team_execution_id)
482
+ end
483
+ end
484
+ end
485
+
486
+ def assert_idle!(team_id)
487
+ @owner.synchronize do
488
+ active = @owner.state[:team_execution_metadata].values.find do |metadata|
489
+ metadata.fetch(:team_id) == team_id.to_s && metadata.fetch(:active)
490
+ end
491
+ if active
492
+ raise Phronomy::AgentBusyError,
493
+ "agent has an active or suspended execution: #{team_id}"
494
+ end
495
+ end
496
+ true
497
+ end
498
+ end
499
+
500
+ class HandoffStates
501
+ def initialize(owner) = @owner = owner
502
+
503
+ def load(main_agent_id)
504
+ @owner.synchronize do
505
+ record = @owner.state[:handoff_states][main_agent_id.to_s]
506
+ record&.copy
507
+ end
508
+ end
509
+
510
+ def save(main_agent_id, expected_revision:, next_revision:, active_agent_id:, record:)
511
+ record = @owner.require_durable_record!(record)
512
+ raise ArgumentError, "active_agent_id is required" if active_agent_id.to_s.empty?
513
+ key = main_agent_id.to_s
514
+ expected = expected_revision.nil? ? nil : Integer(expected_revision)
515
+ next_value = Integer(next_revision)
516
+ @owner.synchronize do
517
+ actual_revision = @owner.state[:handoff_revisions][key]
518
+ unless actual_revision == expected
233
519
  raise ConflictError,
234
520
  "workflow state revision conflict for #{key}: " \
235
- "expected #{expected_revision.inspect}, actual #{actual_revision.inspect}"
521
+ "expected #{expected.inspect}, actual #{actual_revision.inspect}"
522
+ end
523
+ expected_next = expected.nil? ? 1 : expected + 1
524
+ unless next_value == expected_next
525
+ raise ConflictError,
526
+ "workflow state save must advance revision exactly once: " \
527
+ "expected #{expected_next}, got #{next_value}"
236
528
  end
237
529
 
238
- next_revision = actual_revision ? actual_revision + 1 : 1
239
- @owner.workflow_state_data[key] = {
240
- snapshot: @owner.deep_dup_workflow_value(snapshot),
241
- revision: next_revision
242
- }
243
- next_revision
530
+ @owner.state[:handoff_states][key] = record.copy
531
+ @owner.state[:handoff_revisions][key] = next_value
532
+ end
533
+ record.copy
534
+ end
535
+
536
+ def delete(main_agent_id, expected_revision:)
537
+ @owner.synchronize do
538
+ key = main_agent_id.to_s
539
+ expected = Integer(expected_revision)
540
+ actual_revision = @owner.state[:handoff_revisions][key]
541
+ unless actual_revision == expected
542
+ raise ConflictError,
543
+ "workflow state revision conflict for #{key}: " \
544
+ "expected #{expected.inspect}, actual #{actual_revision.inspect}"
545
+ end
546
+ @owner.state[:handoff_revisions].delete(key)
547
+ @owner.state[:handoff_states].delete(key)
548
+ end
549
+ nil
550
+ end
551
+ end
552
+
553
+ class WorkflowStates
554
+ def initialize(owner) = @owner = owner
555
+
556
+ def load(workflow_instance_id)
557
+ @owner.synchronize do
558
+ record = @owner.state[:workflow_states][workflow_instance_id.to_s]
559
+ record&.copy
244
560
  end
245
561
  end
246
562
 
247
- def delete(thread_id, expected_revision:)
563
+ def save(workflow_instance_id, expected_revision:, next_revision:, record:)
564
+ record = @owner.require_durable_record!(record)
565
+ key = workflow_instance_id.to_s
566
+ expected = expected_revision.nil? ? nil : Integer(expected_revision)
567
+ next_value = Integer(next_revision)
248
568
  @owner.synchronize do
249
- key = thread_id.to_s
250
- current = @owner.workflow_state_data[key]
251
- actual_revision = current&.fetch(:revision)
252
- unless actual_revision == expected_revision
569
+ actual_revision = @owner.state[:workflow_revisions][key]
570
+ unless actual_revision == expected
253
571
  raise ConflictError,
254
572
  "workflow state revision conflict for #{key}: " \
255
- "expected #{expected_revision.inspect}, actual #{actual_revision.inspect}"
573
+ "expected #{expected.inspect}, actual #{actual_revision.inspect}"
574
+ end
575
+ expected_next = expected.nil? ? 1 : expected + 1
576
+ unless next_value == expected_next
577
+ raise ConflictError,
578
+ "workflow state save must advance revision exactly once: " \
579
+ "expected #{expected_next}, got #{next_value}"
256
580
  end
257
- @owner.workflow_state_data.delete(key)
581
+
582
+ @owner.state[:workflow_states][key] = record.copy
583
+ @owner.state[:workflow_revisions][key] = next_value
584
+ end
585
+ record.copy
586
+ end
587
+
588
+ def delete(workflow_instance_id, expected_revision:)
589
+ @owner.synchronize do
590
+ key = workflow_instance_id.to_s
591
+ expected = Integer(expected_revision)
592
+ actual_revision = @owner.state[:workflow_revisions][key]
593
+ unless actual_revision == expected
594
+ raise ConflictError,
595
+ "workflow state revision conflict for #{key}: " \
596
+ "expected #{expected.inspect}, actual #{actual_revision.inspect}"
597
+ end
598
+ @owner.state[:workflow_revisions].delete(key)
599
+ @owner.state[:workflow_states].delete(key)
258
600
  end
259
601
  nil
260
602
  end
261
603
  end
262
604
 
263
- attr_reader :state, :workflow_state_data
605
+ attr_reader :state
264
606
 
265
607
  def initialize
266
608
  @monitor = Monitor.new
267
- @state = {contents: {}, agents: {}, journals: {}, executions: {}}
268
- @workflow_state_data = {}
269
- @contents = Contents.new(self)
270
- @agents = Agents.new(self)
271
- @journals = Journals.new(self)
272
- @executions = Executions.new(self)
273
- @workflow_states = WorkflowStates.new(self)
609
+ @state = {
610
+ contents: {},
611
+ agents: {},
612
+ agent_revisions: {},
613
+ journals: {},
614
+ journal_record_ids: {},
615
+ executions: {},
616
+ execution_metadata: {},
617
+ workflow_states: {},
618
+ workflow_revisions: {},
619
+ handoff_states: {}, handoff_revisions: {},
620
+ teams: {}, team_revisions: {}, team_executions: {}, team_execution_metadata: {}
621
+ }
622
+ @contents_backend = Contents.new(self)
623
+ @agents_backend = Agents.new(self)
624
+ @journals_backend = Journals.new(self)
625
+ @executions_backend = Executions.new(self)
626
+ @workflow_states_backend = WorkflowStates.new(self)
274
627
  super(
275
- contents: @contents,
276
- agents: @agents,
277
- journals: @journals,
278
- executions: @executions,
279
- workflow_states: @workflow_states
628
+ contents: @contents_backend,
629
+ agents: @agents_backend,
630
+ journals: @journals_backend,
631
+ executions: @executions_backend,
632
+ workflow_states: @workflow_states_backend,
633
+ handoff_states: HandoffStates.new(self),
634
+ teams: Teams.new(self), team_executions: TeamExecutions.new(self)
280
635
  )
281
636
  end
282
637
 
@@ -286,17 +641,18 @@ module Phronomy
286
641
 
287
642
  def assert_agent_watermark!(agent_id:, agent_revision:, journal_position:)
288
643
  synchronize do
289
- stored = @state[:agents][agent_id.to_s]
290
- unless stored
644
+ key = agent_id.to_s
645
+ unless @state[:agents].key?(key)
291
646
  raise NotFoundError, "Agent not found: #{agent_id}"
292
647
  end
293
648
 
294
- if stored.agent_revision != agent_revision
649
+ actual_revision = @state[:agent_revisions].fetch(key)
650
+ if actual_revision != agent_revision
295
651
  raise ConflictError,
296
- "agent revision conflict: expected #{agent_revision}, actual #{stored.agent_revision}"
652
+ "agent revision conflict: expected #{agent_revision}, actual #{actual_revision}"
297
653
  end
298
654
 
299
- actual_position = Array(@state[:journals][agent_id.to_s]).length
655
+ actual_position = Array(@state[:journals][key]).length
300
656
  if actual_position != journal_position
301
657
  raise ConflictError,
302
658
  "journal position conflict: expected #{journal_position}, actual #{actual_position}"
@@ -309,12 +665,10 @@ module Phronomy
309
665
  def transaction
310
666
  synchronize do
311
667
  state_snapshot = Marshal.load(Marshal.dump(@state))
312
- workflow_snapshot = deep_dup_workflow_value(@workflow_state_data)
313
668
  begin
314
669
  yield self
315
670
  rescue
316
671
  @state.replace(state_snapshot)
317
- @workflow_state_data.replace(workflow_snapshot)
318
672
  raise
319
673
  end
320
674
  end
@@ -324,28 +678,12 @@ module Phronomy
324
678
  @monitor.synchronize(&block)
325
679
  end
326
680
 
327
- # Workflow fields historically accepted ordinary Ruby values in the
328
- # in-memory store. Keep that contract without forcing the Agent durable
329
- # state Marshal snapshot to serialize arbitrary Workflow values.
330
- def deep_dup_workflow_value(value)
331
- case value
332
- when Hash
333
- value.each_with_object({}) do |(key, child), result|
334
- result[deep_dup_workflow_value(key)] = deep_dup_workflow_value(child)
335
- end
336
- when Array
337
- value.map { |child| deep_dup_workflow_value(child) }
338
- when NilClass, Symbol, Integer, Float, TrueClass, FalseClass
339
- value
340
- else
341
- return value if value.frozen?
681
+ # @api private
682
+ def require_durable_record!(record)
683
+ return record if record.is_a?(Phronomy::Persistence::DurableRecord)
342
684
 
343
- begin
344
- value.dup
345
- rescue TypeError
346
- value
347
- end
348
- end
685
+ raise Phronomy::Persistence::SerializationError,
686
+ "backend repository expected Persistence::DurableRecord, got #{record.class}"
349
687
  end
350
688
  end
351
689
  end