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.
- checksums.yaml +4 -4
- data/.mutant.yml +3 -4
- data/CHANGELOG.md +200 -10
- data/CONTRIBUTING.md +81 -9
- data/README.md +15 -6
- data/VERIFY.sh +587 -0
- data/benchmark/bench_agent_invoke.rb +2 -2
- data/benchmark/bench_context_assembler.rb +39 -68
- data/benchmark/bench_regression.rb +2 -2
- data/docs/architecture/agent-context.md +174 -0
- data/docs/architecture/before-llm-input.md +78 -0
- data/docs/architecture/context-management.md +232 -0
- data/docs/architecture/knowledge-and-rag.md +130 -0
- data/docs/architecture/multi-agent-handoff.md +152 -0
- data/docs/architecture/persistence.md +175 -0
- data/docs/architecture/removed/agent-context.md +72 -0
- data/docs/architecture/security-boundaries.md +173 -0
- data/docs/architecture/tracing.md +194 -0
- data/docs/architecture.md +82 -0
- data/docs/archive/design/archived/04_api_design.md +507 -0
- data/docs/archive/design/archived/09_guardrails.md +186 -0
- data/docs/archive/design/archived/17_rails_integration.md +175 -0
- data/docs/archive/design/historical/00_design_philosophy.md +122 -0
- data/docs/archive/design/historical/01_rubyllm_evaluation.md +178 -0
- data/docs/archive/design/historical/06_design_decisions.md +143 -0
- data/docs/changelog/0.14-and-earlier.md +1 -1
- data/docs/decisions/001-rubyllm-as-provider-layer.md +6 -1
- data/docs/decisions/002-workflow-context-immutability.md +26 -1
- data/docs/decisions/006-no-built-in-guardrails.md +2 -1
- data/docs/decisions/012-canonical-execution-log-and-context-policy.md +120 -38
- data/docs/decisions/014-unified-persistence-durable-state.md +9 -2
- data/docs/decisions/016-semantic-multi-agent-handoff.md +112 -0
- data/docs/decisions/017-design-authority-and-adr-governance.md +200 -0
- data/docs/decisions/018-durability-guarantees-and-failure-model.md +488 -0
- data/docs/decisions/019-filter-contract-and-security-boundaries.md +229 -0
- data/docs/decisions/020-canonical-workflow-instance-identity.md +177 -0
- data/docs/decisions/021-generic-agent-invocation-identity-removal.md +119 -0
- data/docs/decisions/022-agent-execution-parent-identity-and-runtime-routing-boundary.md +193 -0
- data/docs/decisions/023-fsm-session-incarnation-identity-and-routing.md +139 -0
- data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +188 -0
- data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +249 -0
- data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +257 -0
- data/docs/decisions/027-llm-adapter-provider-boundary.md +93 -0
- data/docs/decisions/README.md +172 -0
- data/docs/features.md +31 -11
- data/docs/getting-started.md +77 -45
- data/docs/migrations/0.19.md +14 -7
- data/docs/migrations/0.22.md +390 -0
- data/docs/persistence-backends.md +88 -38
- data/docs/runtime-and-concurrency.md +227 -33
- data/examples/README.md +13 -0
- data/lib/phronomy/agent/agent_execution.rb +19 -15
- data/lib/phronomy/agent/agent_invocation.rb +288 -93
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
- data/lib/phronomy/agent/agent_root.rb +3 -3
- data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
- data/lib/phronomy/agent/async_event_api.rb +145 -72
- data/lib/phronomy/agent/base.rb +388 -181
- data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
- data/lib/phronomy/agent/context_assembler.rb +437 -178
- data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
- data/lib/phronomy/agent/context_plan.rb +18 -13
- data/lib/phronomy/agent/context_plan_validator.rb +246 -88
- data/lib/phronomy/agent/context_policies/default.rb +123 -34
- data/lib/phronomy/agent/context_policy.rb +109 -3
- data/lib/phronomy/agent/context_policy_input.rb +244 -0
- data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
- data/lib/phronomy/agent/execution_coordinator.rb +1975 -587
- data/lib/phronomy/agent/journal_record.rb +17 -4
- data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
- data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
- data/lib/phronomy/agent/llm_operation_result.rb +12 -7
- data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
- data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
- data/lib/phronomy/agent/recovery_coordinator/continuation.rb +271 -0
- data/lib/phronomy/agent/recovery_coordinator/installation.rb +427 -0
- data/lib/phronomy/agent/recovery_coordinator/resolution.rb +635 -0
- data/lib/phronomy/agent/recovery_coordinator.rb +211 -0
- data/lib/phronomy/agent/recovery_support.rb +512 -0
- data/lib/phronomy/agent/ruby_llm_materializer.rb +22 -13
- data/lib/phronomy/agent/selection/candidate.rb +53 -0
- data/lib/phronomy/agent/selection/constraint.rb +49 -0
- data/lib/phronomy/agent/shared_state.rb +38 -1
- data/lib/phronomy/agent/tool_approval_request.rb +33 -5
- data/lib/phronomy/agent/tool_definition_set.rb +49 -3
- data/lib/phronomy/agent/tool_invocation.rb +336 -102
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
- data/lib/phronomy/agent.rb +20 -2
- data/lib/phronomy/agent_already_exists_error.rb +5 -0
- data/lib/phronomy/agent_purged_error.rb +5 -0
- data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
- data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
- data/lib/phronomy/engine/event_loop.rb +622 -63
- data/lib/phronomy/engine/fsm_session.rb +194 -21
- data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
- data/lib/phronomy/engine/runtime.rb +77 -20
- data/lib/phronomy/generator_verifier.rb +12 -14
- data/lib/phronomy/invocation_context.rb +9 -29
- data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
- data/lib/phronomy/multi_agent/coordination_state.rb +18 -0
- data/lib/phronomy/multi_agent/coordinator.rb +154 -0
- data/lib/phronomy/multi_agent/execution_coordinator.rb +116 -0
- data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
- data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
- data/lib/phronomy/multi_agent/handoff.rb +24 -45
- data/lib/phronomy/multi_agent/handoff_capability_factory.rb +87 -0
- data/lib/phronomy/multi_agent/handoff_context.rb +95 -0
- data/lib/phronomy/multi_agent/handoff_policy.rb +137 -0
- data/lib/phronomy/multi_agent/handoff_projection.rb +191 -0
- data/lib/phronomy/multi_agent/handoff_request.rb +45 -0
- data/lib/phronomy/multi_agent/orchestrator.rb +12 -15
- data/lib/phronomy/multi_agent/runner.rb +98 -0
- data/lib/phronomy/persistence/durable_codec.rb +646 -0
- data/lib/phronomy/persistence/durable_record.rb +117 -0
- data/lib/phronomy/persistence/in_memory.rb +210 -134
- data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
- data/lib/phronomy/persistence/repository_facades.rb +316 -0
- data/lib/phronomy/persistence.rb +81 -41
- data/lib/phronomy/recovery.rb +186 -0
- data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
- data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +1 -1
- data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
- data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
- data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
- data/lib/phronomy/tracing/automatic.rb +176 -0
- data/lib/phronomy/tracing/base.rb +11 -2
- data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +3 -6
- data/lib/phronomy/workflow_context.rb +14 -5
- data/lib/phronomy/workflow_recovery.rb +123 -0
- data/lib/phronomy/workflow_runner.rb +468 -256
- data/lib/phronomy.rb +6 -0
- data/scripts/api_snapshot.rb +12 -0
- data/sig/phronomy/agent.rbs +209 -7
- data/sig/phronomy/multi_agent.rbs +39 -0
- data/sig/phronomy/persistence.rbs +62 -4
- data/sig/phronomy/runtime.rbs +1 -4
- data/sig/phronomy/workflow.rbs +2 -2
- data/sig/phronomy.rbs +10 -0
- metadata +65 -17
- data/examples/workflows/agent_event_mapping.rb +0 -101
- data/examples/workflows/generic_task_event_mapping.rb +0 -66
- data/lib/phronomy/agent/activation_registry.rb +0 -28
- data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
- data/lib/phronomy/agent/context_candidate.rb +0 -47
- data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
- data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
- data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
- data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
- data/lib/phronomy/agent/context_policy_registry.rb +0 -46
- data/lib/phronomy/agent/context_request.rb +0 -35
- data/lib/phronomy/agent/context_selection_unit.rb +0 -38
- data/lib/phronomy/agent/derived_content_spec.rb +0 -34
- 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(
|
|
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] =
|
|
62
|
+
@owner.state[:agents][key] = record.copy
|
|
63
|
+
@owner.state[:agent_revisions][key] = revision
|
|
57
64
|
end
|
|
58
|
-
|
|
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)
|
|
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:,
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
|
90
|
+
unless next_value == expected + 1
|
|
78
91
|
raise ConflictError,
|
|
79
92
|
"agent save must advance revision exactly once: " \
|
|
80
|
-
"expected #{
|
|
93
|
+
"expected #{expected + 1}, got #{next_value}"
|
|
81
94
|
end
|
|
82
|
-
@owner.state[:agents][
|
|
95
|
+
@owner.state[:agents][key] = record.copy
|
|
96
|
+
@owner.state[:agent_revisions][key] = next_value
|
|
83
97
|
end
|
|
84
|
-
|
|
98
|
+
record.copy
|
|
85
99
|
end
|
|
86
100
|
|
|
87
101
|
def delete(agent_id)
|
|
88
|
-
@owner.synchronize
|
|
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
|
-
|
|
98
|
-
|
|
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 #{
|
|
131
|
+
"journal position conflict: expected #{expected}, actual #{target.length}"
|
|
101
132
|
end
|
|
102
|
-
|
|
133
|
+
|
|
103
134
|
incoming_ids = {}
|
|
104
|
-
|
|
105
|
-
|
|
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[
|
|
113
|
-
record.with_sequence(expected_position + index + 1)
|
|
139
|
+
incoming_ids[record_id] = true
|
|
114
140
|
end
|
|
115
|
-
|
|
116
|
-
|
|
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.
|
|
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
|
|
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(
|
|
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?(
|
|
144
|
-
raise ConflictError, "execution already exists: #{
|
|
184
|
+
if @owner.state[:executions].key?(execution_key)
|
|
185
|
+
raise ConflictError, "execution already exists: #{execution_key}"
|
|
145
186
|
end
|
|
146
|
-
active = @owner.state[:
|
|
147
|
-
|
|
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: #{
|
|
150
|
-
|
|
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
|
-
|
|
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:,
|
|
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
|
-
|
|
166
|
-
|
|
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 #{
|
|
228
|
+
"execution revision conflict: expected #{expected}, actual #{actual_revision}"
|
|
169
229
|
end
|
|
170
|
-
unless
|
|
230
|
+
unless current.fetch(:agent_id) == agent_key
|
|
171
231
|
raise ConflictError,
|
|
172
|
-
"Execution identity mismatch: #{
|
|
232
|
+
"Execution Agent identity mismatch: #{agent_key} != #{current.fetch(:agent_id)}"
|
|
173
233
|
end
|
|
174
|
-
unless
|
|
234
|
+
unless next_value == expected + 1
|
|
175
235
|
raise ConflictError,
|
|
176
236
|
"execution save must advance revision exactly once: " \
|
|
177
|
-
"expected #{
|
|
237
|
+
"expected #{expected + 1}, got #{next_value}"
|
|
178
238
|
end
|
|
179
|
-
|
|
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
|
-
|
|
247
|
+
record.copy
|
|
182
248
|
end
|
|
183
249
|
|
|
184
250
|
def list_active(agent_id)
|
|
185
251
|
@owner.synchronize do
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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(
|
|
298
|
+
def load(workflow_instance_id)
|
|
216
299
|
@owner.synchronize do
|
|
217
|
-
record = @owner.
|
|
218
|
-
|
|
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(
|
|
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
|
-
|
|
230
|
-
|
|
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 #{
|
|
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
|
-
|
|
239
|
-
@owner.
|
|
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(
|
|
330
|
+
def delete(workflow_instance_id, expected_revision:)
|
|
248
331
|
@owner.synchronize do
|
|
249
|
-
key =
|
|
250
|
-
|
|
251
|
-
actual_revision =
|
|
252
|
-
unless actual_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 #{
|
|
338
|
+
"expected #{expected.inspect}, actual #{actual_revision.inspect}"
|
|
256
339
|
end
|
|
257
|
-
@owner.
|
|
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
|
|
347
|
+
attr_reader :state
|
|
264
348
|
|
|
265
349
|
def initialize
|
|
266
350
|
@monitor = Monitor.new
|
|
267
|
-
@state = {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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: @
|
|
276
|
-
agents: @
|
|
277
|
-
journals: @
|
|
278
|
-
executions: @
|
|
279
|
-
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
|
-
|
|
290
|
-
unless
|
|
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
|
-
|
|
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 #{
|
|
390
|
+
"agent revision conflict: expected #{agent_revision}, actual #{actual_revision}"
|
|
297
391
|
end
|
|
298
392
|
|
|
299
|
-
actual_position = Array(@state[:journals][
|
|
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
|
-
#
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
344
|
-
|
|
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
|