phronomy 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +3 -4
  3. data/CHANGELOG.md +200 -10
  4. data/CONTRIBUTING.md +81 -9
  5. data/README.md +15 -6
  6. data/VERIFY.sh +587 -0
  7. data/benchmark/bench_agent_invoke.rb +2 -2
  8. data/benchmark/bench_context_assembler.rb +39 -68
  9. data/benchmark/bench_regression.rb +2 -2
  10. data/docs/architecture/agent-context.md +174 -0
  11. data/docs/architecture/before-llm-input.md +78 -0
  12. data/docs/architecture/context-management.md +232 -0
  13. data/docs/architecture/knowledge-and-rag.md +130 -0
  14. data/docs/architecture/multi-agent-handoff.md +152 -0
  15. data/docs/architecture/persistence.md +175 -0
  16. data/docs/architecture/removed/agent-context.md +72 -0
  17. data/docs/architecture/security-boundaries.md +173 -0
  18. data/docs/architecture/tracing.md +194 -0
  19. data/docs/architecture.md +82 -0
  20. data/docs/archive/design/archived/04_api_design.md +507 -0
  21. data/docs/archive/design/archived/09_guardrails.md +186 -0
  22. data/docs/archive/design/archived/17_rails_integration.md +175 -0
  23. data/docs/archive/design/historical/00_design_philosophy.md +122 -0
  24. data/docs/archive/design/historical/01_rubyllm_evaluation.md +178 -0
  25. data/docs/archive/design/historical/06_design_decisions.md +143 -0
  26. data/docs/changelog/0.14-and-earlier.md +1 -1
  27. data/docs/decisions/001-rubyllm-as-provider-layer.md +6 -1
  28. data/docs/decisions/002-workflow-context-immutability.md +26 -1
  29. data/docs/decisions/006-no-built-in-guardrails.md +2 -1
  30. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +120 -38
  31. data/docs/decisions/014-unified-persistence-durable-state.md +9 -2
  32. data/docs/decisions/016-semantic-multi-agent-handoff.md +112 -0
  33. data/docs/decisions/017-design-authority-and-adr-governance.md +200 -0
  34. data/docs/decisions/018-durability-guarantees-and-failure-model.md +488 -0
  35. data/docs/decisions/019-filter-contract-and-security-boundaries.md +229 -0
  36. data/docs/decisions/020-canonical-workflow-instance-identity.md +177 -0
  37. data/docs/decisions/021-generic-agent-invocation-identity-removal.md +119 -0
  38. data/docs/decisions/022-agent-execution-parent-identity-and-runtime-routing-boundary.md +193 -0
  39. data/docs/decisions/023-fsm-session-incarnation-identity-and-routing.md +139 -0
  40. data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +188 -0
  41. data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +249 -0
  42. data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +257 -0
  43. data/docs/decisions/027-llm-adapter-provider-boundary.md +93 -0
  44. data/docs/decisions/README.md +172 -0
  45. data/docs/features.md +31 -11
  46. data/docs/getting-started.md +77 -45
  47. data/docs/migrations/0.19.md +14 -7
  48. data/docs/migrations/0.22.md +390 -0
  49. data/docs/persistence-backends.md +88 -38
  50. data/docs/runtime-and-concurrency.md +227 -33
  51. data/examples/README.md +13 -0
  52. data/lib/phronomy/agent/agent_execution.rb +19 -15
  53. data/lib/phronomy/agent/agent_invocation.rb +288 -93
  54. data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
  55. data/lib/phronomy/agent/agent_root.rb +3 -3
  56. data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
  57. data/lib/phronomy/agent/async_event_api.rb +145 -72
  58. data/lib/phronomy/agent/base.rb +388 -181
  59. data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
  60. data/lib/phronomy/agent/context_assembler.rb +437 -178
  61. data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
  62. data/lib/phronomy/agent/context_plan.rb +18 -13
  63. data/lib/phronomy/agent/context_plan_validator.rb +246 -88
  64. data/lib/phronomy/agent/context_policies/default.rb +123 -34
  65. data/lib/phronomy/agent/context_policy.rb +109 -3
  66. data/lib/phronomy/agent/context_policy_input.rb +244 -0
  67. data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
  68. data/lib/phronomy/agent/execution_coordinator.rb +1975 -587
  69. data/lib/phronomy/agent/journal_record.rb +17 -4
  70. data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
  71. data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
  72. data/lib/phronomy/agent/llm_operation_result.rb +12 -7
  73. data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
  74. data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
  75. data/lib/phronomy/agent/recovery_coordinator/continuation.rb +271 -0
  76. data/lib/phronomy/agent/recovery_coordinator/installation.rb +427 -0
  77. data/lib/phronomy/agent/recovery_coordinator/resolution.rb +635 -0
  78. data/lib/phronomy/agent/recovery_coordinator.rb +211 -0
  79. data/lib/phronomy/agent/recovery_support.rb +512 -0
  80. data/lib/phronomy/agent/ruby_llm_materializer.rb +22 -13
  81. data/lib/phronomy/agent/selection/candidate.rb +53 -0
  82. data/lib/phronomy/agent/selection/constraint.rb +49 -0
  83. data/lib/phronomy/agent/shared_state.rb +38 -1
  84. data/lib/phronomy/agent/tool_approval_request.rb +33 -5
  85. data/lib/phronomy/agent/tool_definition_set.rb +49 -3
  86. data/lib/phronomy/agent/tool_invocation.rb +336 -102
  87. data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
  88. data/lib/phronomy/agent.rb +20 -2
  89. data/lib/phronomy/agent_already_exists_error.rb +5 -0
  90. data/lib/phronomy/agent_purged_error.rb +5 -0
  91. data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
  92. data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
  93. data/lib/phronomy/engine/event_loop.rb +622 -63
  94. data/lib/phronomy/engine/fsm_session.rb +194 -21
  95. data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
  96. data/lib/phronomy/engine/runtime.rb +77 -20
  97. data/lib/phronomy/generator_verifier.rb +12 -14
  98. data/lib/phronomy/invocation_context.rb +9 -29
  99. data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
  100. data/lib/phronomy/multi_agent/coordination_state.rb +18 -0
  101. data/lib/phronomy/multi_agent/coordinator.rb +154 -0
  102. data/lib/phronomy/multi_agent/execution_coordinator.rb +116 -0
  103. data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
  104. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
  105. data/lib/phronomy/multi_agent/handoff.rb +24 -45
  106. data/lib/phronomy/multi_agent/handoff_capability_factory.rb +87 -0
  107. data/lib/phronomy/multi_agent/handoff_context.rb +95 -0
  108. data/lib/phronomy/multi_agent/handoff_policy.rb +137 -0
  109. data/lib/phronomy/multi_agent/handoff_projection.rb +191 -0
  110. data/lib/phronomy/multi_agent/handoff_request.rb +45 -0
  111. data/lib/phronomy/multi_agent/orchestrator.rb +12 -15
  112. data/lib/phronomy/multi_agent/runner.rb +98 -0
  113. data/lib/phronomy/persistence/durable_codec.rb +646 -0
  114. data/lib/phronomy/persistence/durable_record.rb +117 -0
  115. data/lib/phronomy/persistence/in_memory.rb +210 -134
  116. data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
  117. data/lib/phronomy/persistence/repository_facades.rb +316 -0
  118. data/lib/phronomy/persistence.rb +81 -41
  119. data/lib/phronomy/recovery.rb +186 -0
  120. data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
  121. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +1 -1
  122. data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
  123. data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
  124. data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
  125. data/lib/phronomy/tracing/automatic.rb +176 -0
  126. data/lib/phronomy/tracing/base.rb +11 -2
  127. data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
  128. data/lib/phronomy/version.rb +1 -1
  129. data/lib/phronomy/workflow.rb +3 -6
  130. data/lib/phronomy/workflow_context.rb +14 -5
  131. data/lib/phronomy/workflow_recovery.rb +123 -0
  132. data/lib/phronomy/workflow_runner.rb +468 -256
  133. data/lib/phronomy.rb +6 -0
  134. data/scripts/api_snapshot.rb +12 -0
  135. data/sig/phronomy/agent.rbs +209 -7
  136. data/sig/phronomy/multi_agent.rbs +39 -0
  137. data/sig/phronomy/persistence.rbs +62 -4
  138. data/sig/phronomy/runtime.rbs +1 -4
  139. data/sig/phronomy/workflow.rbs +2 -2
  140. data/sig/phronomy.rbs +10 -0
  141. metadata +65 -17
  142. data/examples/workflows/agent_event_mapping.rb +0 -101
  143. data/examples/workflows/generic_task_event_mapping.rb +0 -66
  144. data/lib/phronomy/agent/activation_registry.rb +0 -28
  145. data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
  146. data/lib/phronomy/agent/context_candidate.rb +0 -47
  147. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
  148. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
  149. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
  150. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
  151. data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
  152. data/lib/phronomy/agent/context_policy_registry.rb +0 -46
  153. data/lib/phronomy/agent/context_request.rb +0 -35
  154. data/lib/phronomy/agent/context_selection_unit.rb +0 -38
  155. data/lib/phronomy/agent/derived_content_spec.rb +0 -34
  156. data/lib/phronomy/agent/runner.rb +0 -97
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class Persistence
5
+ # Immutable logical record exchanged across the Persistence Backend SPI.
6
+ #
7
+ # Phronomy owns +record_type+, +format_version+, and the semantic meaning of
8
+ # +payload+. A backend owns only the physical representation used to store
9
+ # the record and must return an equivalent DurableRecord on read.
10
+ #
11
+ # @api public
12
+ class DurableRecord
13
+ FORMAT_VERSION_PATTERN = /\A(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\z/
14
+ MISSING = Object.new.freeze
15
+
16
+ # @return [String]
17
+ # @api public
18
+ attr_reader :record_type
19
+
20
+ # @return [String]
21
+ # @api public
22
+ attr_reader :format_version
23
+
24
+ # @return [Hash{String => Object}]
25
+ # @api public
26
+ attr_reader :payload
27
+
28
+ # @api public
29
+ def initialize(record_type: MISSING, format_version: MISSING, payload: MISSING)
30
+ if record_type.equal?(MISSING)
31
+ raise Phronomy::Persistence::SerializationError, "durable record_type is missing"
32
+ end
33
+ if format_version.equal?(MISSING)
34
+ raise Phronomy::Persistence::SerializationError, "durable format_version is missing"
35
+ end
36
+ if payload.equal?(MISSING)
37
+ raise Phronomy::Persistence::SerializationError, "durable payload is missing"
38
+ end
39
+ unless record_type.is_a?(String)
40
+ raise Phronomy::Persistence::SerializationError,
41
+ "durable record_type must be a String"
42
+ end
43
+ unless format_version.is_a?(String)
44
+ raise Phronomy::Persistence::SerializationError,
45
+ "durable format_version must be a String"
46
+ end
47
+
48
+ @record_type = record_type.dup.freeze
49
+ @format_version = format_version.dup.freeze
50
+ validate_header!
51
+ validate_payload!(payload)
52
+ @payload = immutable_copy(payload)
53
+ freeze
54
+ rescue Phronomy::Persistence::SerializationError
55
+ raise
56
+ rescue => error
57
+ raise Phronomy::Persistence::SerializationError, error.message
58
+ end
59
+
60
+ # Returns an independent immutable record with the same logical value.
61
+ # Backends may use this when they need a defensive copy at a storage
62
+ # boundary without knowing anything about the domain object codec.
63
+ #
64
+ # @return [DurableRecord]
65
+ # @api public
66
+ def copy
67
+ self.class.new(
68
+ record_type: record_type,
69
+ format_version: format_version,
70
+ payload: payload
71
+ )
72
+ end
73
+
74
+ private
75
+
76
+ def validate_header!
77
+ if record_type.empty?
78
+ raise Phronomy::Persistence::SerializationError,
79
+ "durable record_type must not be empty"
80
+ end
81
+
82
+ return if FORMAT_VERSION_PATTERN.match?(format_version)
83
+
84
+ raise Phronomy::Persistence::SerializationError,
85
+ "invalid durable format_version: #{format_version.inspect}"
86
+ end
87
+
88
+ def validate_payload!(payload)
89
+ unless payload.is_a?(Hash)
90
+ raise Phronomy::Persistence::SerializationError,
91
+ "durable payload must be a Hash"
92
+ end
93
+
94
+ Phronomy::CanonicalJSON.dump(payload)
95
+ true
96
+ rescue ArgumentError => error
97
+ raise Phronomy::Persistence::SerializationError,
98
+ "durable payload is not canonical JSON compatible: #{error.message}"
99
+ end
100
+
101
+ def immutable_copy(value)
102
+ case value
103
+ when Hash
104
+ value.each_with_object({}) do |(key, child), result|
105
+ result[key.dup.freeze] = immutable_copy(child)
106
+ end.freeze
107
+ when Array
108
+ value.map { |child| immutable_copy(child) }.freeze
109
+ when String
110
+ value.dup.freeze
111
+ else
112
+ value
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -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}"
108
- end
109
- if existing_ids[record.record_id] || incoming_ids[record.record_id]
110
- raise ConflictError, "duplicate Journal record_id: #{record.record_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}"
111
138
  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,216 @@ 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
189
257
  end
190
258
  end
191
259
 
192
260
  def delete(execution_id)
193
- @owner.synchronize { @owner.state[:executions].delete(execution_id.to_s) }
261
+ @owner.synchronize do
262
+ key = execution_id.to_s
263
+ @owner.state[:execution_metadata].delete(key)
264
+ @owner.state[:executions].delete(key)
265
+ end
194
266
  end
195
267
 
196
268
  def delete_for_agent(agent_id)
197
269
  @owner.synchronize do
198
- @owner.state[:executions].delete_if { |_id, execution| execution.agent_id == agent_id.to_s }
270
+ agent_key = agent_id.to_s
271
+ ids = @owner.state[:execution_metadata].filter_map do |execution_id, metadata|
272
+ execution_id if metadata.fetch(:agent_id) == agent_key
273
+ end
274
+ ids.each do |execution_id|
275
+ @owner.state[:execution_metadata].delete(execution_id)
276
+ @owner.state[:executions].delete(execution_id)
277
+ end
199
278
  end
200
279
  end
201
280
 
202
- # Raises AgentBusyError if there is an active execution for agent_id.
203
- # Must be called from within a transaction (monitor already held).
204
281
  def assert_idle!(agent_id)
205
- active = @owner.state[:executions].values.find do |candidate|
206
- candidate.agent_id == agent_id.to_s && candidate.active?
282
+ @owner.synchronize do
283
+ active = @owner.state[:execution_metadata].values.find do |metadata|
284
+ metadata.fetch(:agent_id) == agent_id.to_s && metadata.fetch(:active)
285
+ end
286
+ if active
287
+ raise Phronomy::AgentBusyError,
288
+ "agent has an active or suspended execution: #{agent_id}"
289
+ end
207
290
  end
208
- raise Phronomy::AgentBusyError, "agent has an active or suspended execution: #{agent_id}" if active
291
+ true
209
292
  end
210
293
  end
211
294
 
212
295
  class WorkflowStates
213
296
  def initialize(owner) = @owner = owner
214
297
 
215
- def load(thread_id)
298
+ def load(workflow_instance_id)
216
299
  @owner.synchronize do
217
- record = @owner.workflow_state_data[thread_id.to_s]
218
- next nil unless record
219
-
220
- {
221
- snapshot: @owner.deep_dup_workflow_value(record.fetch(:snapshot)),
222
- revision: record.fetch(:revision)
223
- }.freeze
300
+ record = @owner.state[:workflow_states][workflow_instance_id.to_s]
301
+ record&.copy
224
302
  end
225
303
  end
226
304
 
227
- def save(thread_id, expected_revision:, snapshot:)
305
+ def save(workflow_instance_id, expected_revision:, next_revision:, record:)
306
+ record = @owner.require_durable_record!(record)
307
+ key = workflow_instance_id.to_s
308
+ expected = expected_revision.nil? ? nil : Integer(expected_revision)
309
+ next_value = Integer(next_revision)
228
310
  @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
311
+ actual_revision = @owner.state[:workflow_revisions][key]
312
+ unless actual_revision == expected
233
313
  raise ConflictError,
234
314
  "workflow state revision conflict for #{key}: " \
235
- "expected #{expected_revision.inspect}, actual #{actual_revision.inspect}"
315
+ "expected #{expected.inspect}, actual #{actual_revision.inspect}"
316
+ end
317
+ expected_next = expected.nil? ? 1 : expected + 1
318
+ unless next_value == expected_next
319
+ raise ConflictError,
320
+ "workflow state save must advance revision exactly once: " \
321
+ "expected #{expected_next}, got #{next_value}"
236
322
  end
237
323
 
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
324
+ @owner.state[:workflow_states][key] = record.copy
325
+ @owner.state[:workflow_revisions][key] = next_value
244
326
  end
327
+ record.copy
245
328
  end
246
329
 
247
- def delete(thread_id, expected_revision:)
330
+ def delete(workflow_instance_id, expected_revision:)
248
331
  @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
332
+ key = workflow_instance_id.to_s
333
+ expected = Integer(expected_revision)
334
+ actual_revision = @owner.state[:workflow_revisions][key]
335
+ unless actual_revision == expected
253
336
  raise ConflictError,
254
337
  "workflow state revision conflict for #{key}: " \
255
- "expected #{expected_revision.inspect}, actual #{actual_revision.inspect}"
338
+ "expected #{expected.inspect}, actual #{actual_revision.inspect}"
256
339
  end
257
- @owner.workflow_state_data.delete(key)
340
+ @owner.state[:workflow_revisions].delete(key)
341
+ @owner.state[:workflow_states].delete(key)
258
342
  end
259
343
  nil
260
344
  end
261
345
  end
262
346
 
263
- attr_reader :state, :workflow_state_data
347
+ attr_reader :state
264
348
 
265
349
  def initialize
266
350
  @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)
351
+ @state = {
352
+ contents: {},
353
+ agents: {},
354
+ agent_revisions: {},
355
+ journals: {},
356
+ journal_record_ids: {},
357
+ executions: {},
358
+ execution_metadata: {},
359
+ workflow_states: {},
360
+ workflow_revisions: {}
361
+ }
362
+ @contents_backend = Contents.new(self)
363
+ @agents_backend = Agents.new(self)
364
+ @journals_backend = Journals.new(self)
365
+ @executions_backend = Executions.new(self)
366
+ @workflow_states_backend = WorkflowStates.new(self)
274
367
  super(
275
- contents: @contents,
276
- agents: @agents,
277
- journals: @journals,
278
- executions: @executions,
279
- workflow_states: @workflow_states
368
+ contents: @contents_backend,
369
+ agents: @agents_backend,
370
+ journals: @journals_backend,
371
+ executions: @executions_backend,
372
+ workflow_states: @workflow_states_backend
280
373
  )
281
374
  end
282
375
 
@@ -286,17 +379,18 @@ module Phronomy
286
379
 
287
380
  def assert_agent_watermark!(agent_id:, agent_revision:, journal_position:)
288
381
  synchronize do
289
- stored = @state[:agents][agent_id.to_s]
290
- unless stored
382
+ key = agent_id.to_s
383
+ unless @state[:agents].key?(key)
291
384
  raise NotFoundError, "Agent not found: #{agent_id}"
292
385
  end
293
386
 
294
- if stored.agent_revision != agent_revision
387
+ actual_revision = @state[:agent_revisions].fetch(key)
388
+ if actual_revision != agent_revision
295
389
  raise ConflictError,
296
- "agent revision conflict: expected #{agent_revision}, actual #{stored.agent_revision}"
390
+ "agent revision conflict: expected #{agent_revision}, actual #{actual_revision}"
297
391
  end
298
392
 
299
- actual_position = Array(@state[:journals][agent_id.to_s]).length
393
+ actual_position = Array(@state[:journals][key]).length
300
394
  if actual_position != journal_position
301
395
  raise ConflictError,
302
396
  "journal position conflict: expected #{journal_position}, actual #{actual_position}"
@@ -309,12 +403,10 @@ module Phronomy
309
403
  def transaction
310
404
  synchronize do
311
405
  state_snapshot = Marshal.load(Marshal.dump(@state))
312
- workflow_snapshot = deep_dup_workflow_value(@workflow_state_data)
313
406
  begin
314
407
  yield self
315
408
  rescue
316
409
  @state.replace(state_snapshot)
317
- @workflow_state_data.replace(workflow_snapshot)
318
410
  raise
319
411
  end
320
412
  end
@@ -324,28 +416,12 @@ module Phronomy
324
416
  @monitor.synchronize(&block)
325
417
  end
326
418
 
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?
419
+ # @api private
420
+ def require_durable_record!(record)
421
+ return record if record.is_a?(Phronomy::Persistence::DurableRecord)
342
422
 
343
- begin
344
- value.dup
345
- rescue TypeError
346
- value
347
- end
348
- end
423
+ raise Phronomy::Persistence::SerializationError,
424
+ "backend repository expected Persistence::DurableRecord, got #{record.class}"
349
425
  end
350
426
  end
351
427
  end