smith-agents 0.4.4 → 0.5.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/CHANGELOG.md +262 -0
- data/README.md +116 -6
- data/docs/CONFIGURATION.md +4 -1
- data/docs/PATTERNS.md +7 -0
- data/docs/PERSISTENCE.md +502 -1
- data/docs/workflow_claim.md +4 -1
- data/lib/smith/agent/lifecycle.rb +19 -10
- data/lib/smith/agent/registry/execution_binding_capture.rb +26 -0
- data/lib/smith/agent/registry/mutation_boundary.rb +29 -0
- data/lib/smith/agent/registry.rb +8 -0
- data/lib/smith/agent.rb +24 -2
- data/lib/smith/budget/amount_contract.rb +67 -0
- data/lib/smith/budget/amount_representation.rb +43 -0
- data/lib/smith/budget/decimal_context.rb +18 -0
- data/lib/smith/budget/ledger.rb +110 -18
- data/lib/smith/budget/ledger_publication.rb +60 -0
- data/lib/smith/budget/ledger_state.rb +21 -0
- data/lib/smith/budget/ledger_state_transition.rb +72 -0
- data/lib/smith/budget/public_state_validator.rb +25 -0
- data/lib/smith/budget/reservation.rb +20 -0
- data/lib/smith/budget/reservation_contract.rb +34 -0
- data/lib/smith/context/observation_masking.rb +3 -1
- data/lib/smith/context/state_injection.rb +1 -1
- data/lib/smith/doctor/checks/persistence_capabilities.rb +18 -3
- data/lib/smith/error.rb +5 -0
- data/lib/smith/errors.rb +6 -0
- data/lib/smith/exponential_backoff.rb +128 -0
- data/lib/smith/persistence_adapters/active_record_connection_errors.rb +23 -0
- data/lib/smith/persistence_adapters/active_record_exact_predicate.rb +43 -0
- data/lib/smith/persistence_adapters/active_record_exact_store.rb +62 -0
- data/lib/smith/persistence_adapters/active_record_exact_write.rb +40 -0
- data/lib/smith/persistence_adapters/active_record_initial_write.rb +42 -0
- data/lib/smith/persistence_adapters/active_record_store.rb +95 -52
- data/lib/smith/persistence_adapters/cache_store.rb +4 -1
- data/lib/smith/persistence_adapters/memory.rb +45 -14
- data/lib/smith/persistence_adapters/payload_version.rb +23 -0
- data/lib/smith/persistence_adapters/redis_client_access.rb +38 -0
- data/lib/smith/persistence_adapters/redis_exact_write.rb +44 -0
- data/lib/smith/persistence_adapters/redis_store.rb +38 -43
- data/lib/smith/persistence_adapters/redis_versioned_write.rb +56 -0
- data/lib/smith/persistence_adapters/retry.rb +22 -10
- data/lib/smith/persistence_adapters/version_expectation.rb +19 -0
- data/lib/smith/persistence_adapters.rb +26 -18
- data/lib/smith/persistence_payload_conflict.rb +12 -0
- data/lib/smith/tool/budget_enforcement.rb +2 -2
- data/lib/smith/types.rb +1 -0
- data/lib/smith/version.rb +2 -1
- data/lib/smith/workflow/branch_env.rb +1 -1
- data/lib/smith/workflow/budget_integration.rb +21 -11
- data/lib/smith/workflow/definition_identity/class_methods.rb +48 -0
- data/lib/smith/workflow/definition_identity.rb +25 -0
- data/lib/smith/workflow/deterministic_step.rb +2 -2
- data/lib/smith/workflow/dsl.rb +73 -5
- data/lib/smith/workflow/durability.rb +2 -1
- data/lib/smith/workflow/evaluator_optimizer.rb +7 -11
- data/lib/smith/workflow/execution.rb +16 -18
- data/lib/smith/workflow/execution_binding_resolution.rb +16 -0
- data/lib/smith/workflow/execution_result_snapshot.rb +118 -0
- data/lib/smith/workflow/fanout_execution.rb +1 -1
- data/lib/smith/workflow/graph/diagnostic.rb +27 -1
- data/lib/smith/workflow/graph/diagnostic_path.rb +24 -0
- data/lib/smith/workflow/graph/execution_successors.rb +65 -0
- data/lib/smith/workflow/graph/identifier_projection.rb +13 -0
- data/lib/smith/workflow/graph/metrics.rb +1 -3
- data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +15 -16
- data/lib/smith/workflow/graph/optimization_contract.rb +2 -2
- data/lib/smith/workflow/graph/orchestration_contract.rb +3 -3
- data/lib/smith/workflow/graph/reachability.rb +19 -31
- data/lib/smith/workflow/graph/reachability_diagnostics.rb +17 -1
- data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +36 -0
- data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +2 -2
- data/lib/smith/workflow/graph/runtime_readiness.rb +3 -44
- data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +2 -2
- data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +47 -0
- data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +108 -0
- data/lib/smith/workflow/graph/state_diagnostics.rb +2 -2
- data/lib/smith/workflow/graph/transition_contract.rb +47 -0
- data/lib/smith/workflow/graph/transition_contract_attributes.rb +73 -0
- data/lib/smith/workflow/graph/transition_contract_configurations.rb +100 -0
- data/lib/smith/workflow/graph/transition_diagnostics.rb +54 -12
- data/lib/smith/workflow/graph/transition_optimization_configuration.rb +62 -0
- data/lib/smith/workflow/graph/transition_snapshot.rb +4 -1
- data/lib/smith/workflow/graph/validator.rb +1 -1
- data/lib/smith/workflow/graph.rb +62 -4
- data/lib/smith/workflow/guardrail_integration.rb +23 -9
- data/lib/smith/workflow/identifier.rb +24 -0
- data/lib/smith/workflow/message_admission.rb +40 -0
- data/lib/smith/workflow/message_admission_boundary.rb +22 -0
- data/lib/smith/workflow/message_batch.rb +60 -0
- data/lib/smith/workflow/message_value_normalizer.rb +133 -0
- data/lib/smith/workflow/nested_execution.rb +10 -0
- data/lib/smith/workflow/orchestrator_worker.rb +3 -7
- data/lib/smith/workflow/parallel/cancellation_signal.rb +14 -5
- data/lib/smith/workflow/parallel/execution_context.rb +113 -0
- data/lib/smith/workflow/parallel/nested_execution.rb +124 -0
- data/lib/smith/workflow/parallel/root_execution.rb +91 -0
- data/lib/smith/workflow/parallel.rb +19 -22
- data/lib/smith/workflow/parallel_agent_binding.rb +24 -0
- data/lib/smith/workflow/parallel_execution.rb +7 -3
- data/lib/smith/workflow/persistence.rb +49 -10
- data/lib/smith/workflow/prepared_step.rb +122 -0
- data/lib/smith/workflow/prepared_step_dispatch.rb +88 -0
- data/lib/smith/workflow/prepared_step_execution_authorization.rb +116 -0
- data/lib/smith/workflow/prepared_step_execution_result.rb +49 -0
- data/lib/smith/workflow/prepared_step_execution_scope.rb +45 -0
- data/lib/smith/workflow/prepared_step_recovery.rb +38 -0
- data/lib/smith/workflow/retry_execution.rb +20 -11
- data/lib/smith/workflow/split_step_persistence/boundary.rb +128 -0
- data/lib/smith/workflow/split_step_persistence/boundary_reset.rb +50 -0
- data/lib/smith/workflow/split_step_persistence/canonical_payload_digest.rb +55 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint.rb +119 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +51 -0
- data/lib/smith/workflow/split_step_persistence/definition_boundary.rb +19 -0
- data/lib/smith/workflow/split_step_persistence/dispatch_boundary.rb +16 -0
- data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +118 -0
- data/lib/smith/workflow/split_step_persistence/dispatch_confirmation.rb +70 -0
- data/lib/smith/workflow/split_step_persistence/dispatch_verification.rb +35 -0
- data/lib/smith/workflow/split_step_persistence/execution.rb +128 -0
- data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +89 -0
- data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +90 -0
- data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +95 -0
- data/lib/smith/workflow/split_step_persistence/execution_result_capture.rb +48 -0
- data/lib/smith/workflow/split_step_persistence/execution_verification.rb +65 -0
- data/lib/smith/workflow/split_step_persistence/execution_workflow_snapshot.rb +77 -0
- data/lib/smith/workflow/split_step_persistence/inheritance.rb +22 -0
- data/lib/smith/workflow/split_step_persistence/payloads.rb +54 -0
- data/lib/smith/workflow/split_step_persistence/preparation.rb +125 -0
- data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +117 -0
- data/lib/smith/workflow/split_step_persistence/preparation_payload.rb +25 -0
- data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +54 -0
- data/lib/smith/workflow/split_step_persistence/recovery.rb +125 -0
- data/lib/smith/workflow/split_step_persistence/recovery_boundary.rb +56 -0
- data/lib/smith/workflow/split_step_persistence/recovery_class_methods.rb +14 -0
- data/lib/smith/workflow/split_step_persistence/replace_exact_signature.rb +46 -0
- data/lib/smith/workflow/split_step_persistence/restart_safe_adapter.rb +42 -0
- data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +84 -0
- data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +102 -0
- data/lib/smith/workflow/split_step_persistence/transaction_identity.rb +45 -0
- data/lib/smith/workflow/split_step_persistence/transition_contract.rb +48 -0
- data/lib/smith/workflow/split_step_persistence/transition_contract_freezer.rb +83 -0
- data/lib/smith/workflow/split_step_persistence/transition_contract_signature.rb +107 -0
- data/lib/smith/workflow/split_step_persistence/transition_contract_structured_values.rb +51 -0
- data/lib/smith/workflow/split_step_persistence.rb +79 -0
- data/lib/smith/workflow/step_completion.rb +31 -0
- data/lib/smith/workflow/string_snapshot.rb +25 -0
- data/lib/smith/workflow/transition.rb +122 -133
- data/lib/smith/workflow/transition_actionability.rb +22 -0
- data/lib/smith/workflow.rb +57 -19
- data/lib/smith.rb +42 -2
- data/sig/smith.rbs +1 -0
- metadata +131 -19
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "monitor"
|
|
4
|
+
require "securerandom"
|
|
4
5
|
|
|
5
6
|
module Smith
|
|
6
7
|
module PersistenceAdapters
|
|
@@ -15,7 +16,10 @@ module Smith
|
|
|
15
16
|
# Smith.config.persistence_adapter is nil AND Smith.config.test_mode
|
|
16
17
|
# is true (typically set in spec_helper.rb).
|
|
17
18
|
class Memory
|
|
18
|
-
|
|
19
|
+
attr_reader :persistence_identity
|
|
20
|
+
|
|
21
|
+
def initialize(identity: "memory:#{SecureRandom.uuid}")
|
|
22
|
+
@persistence_identity = identity.to_s.dup.freeze
|
|
19
23
|
@store = {}
|
|
20
24
|
@heartbeats = {}
|
|
21
25
|
@monitor = Monitor.new
|
|
@@ -23,21 +27,16 @@ module Smith
|
|
|
23
27
|
|
|
24
28
|
def store(key, payload, ttl: Smith.config.persistence_ttl)
|
|
25
29
|
@monitor.synchronize do
|
|
26
|
-
@store[key] =
|
|
30
|
+
@store[key] = entry_for(payload, ttl)
|
|
27
31
|
end
|
|
28
32
|
end
|
|
29
33
|
|
|
30
34
|
def fetch(key)
|
|
31
35
|
@monitor.synchronize do
|
|
32
|
-
entry =
|
|
36
|
+
entry = live_entry(key)
|
|
33
37
|
next nil if entry.nil?
|
|
34
38
|
|
|
35
|
-
|
|
36
|
-
@store.delete(key)
|
|
37
|
-
next nil
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
entry[:payload]
|
|
39
|
+
copy_payload(entry[:payload])
|
|
41
40
|
end
|
|
42
41
|
end
|
|
43
42
|
|
|
@@ -48,6 +47,9 @@ module Smith
|
|
|
48
47
|
end
|
|
49
48
|
end
|
|
50
49
|
|
|
50
|
+
def transaction_open? = false
|
|
51
|
+
def transaction_identity = nil
|
|
52
|
+
|
|
51
53
|
def record_heartbeat(key, ttl: Smith.config.persistence_ttl)
|
|
52
54
|
@monitor.synchronize do
|
|
53
55
|
@heartbeats[key] = { at: Time.now.utc, expires_at: ttl ? Time.now.utc + ttl : nil }
|
|
@@ -76,7 +78,7 @@ module Smith
|
|
|
76
78
|
# across all versioned adapters).
|
|
77
79
|
def store_versioned(key, payload, expected_version:, ttl: Smith.config.persistence_ttl)
|
|
78
80
|
@monitor.synchronize do
|
|
79
|
-
entry =
|
|
81
|
+
entry = live_entry(key)
|
|
80
82
|
if entry
|
|
81
83
|
current_version = parse_version(entry[:payload])
|
|
82
84
|
if current_version != expected_version
|
|
@@ -84,9 +86,21 @@ module Smith
|
|
|
84
86
|
key: key, expected: expected_version, actual: current_version
|
|
85
87
|
)
|
|
86
88
|
end
|
|
89
|
+
else
|
|
90
|
+
VersionExpectation.validate_missing!(key, expected_version)
|
|
87
91
|
end
|
|
88
|
-
@store[key] =
|
|
92
|
+
@store[key] = entry_for(payload, ttl)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def replace_exact(key, payload, expected_payload:, ttl: Smith.config.persistence_ttl)
|
|
97
|
+
@monitor.synchronize do
|
|
98
|
+
entry = live_entry(key)
|
|
99
|
+
raise PersistencePayloadConflict.new(key:) unless entry && entry[:payload] == expected_payload
|
|
100
|
+
|
|
101
|
+
@store[key] = entry_for(payload, ttl)
|
|
89
102
|
end
|
|
103
|
+
payload
|
|
90
104
|
end
|
|
91
105
|
|
|
92
106
|
def clear!
|
|
@@ -95,10 +109,27 @@ module Smith
|
|
|
95
109
|
|
|
96
110
|
private
|
|
97
111
|
|
|
112
|
+
def live_entry(key)
|
|
113
|
+
entry = @store[key]
|
|
114
|
+
return unless entry
|
|
115
|
+
return entry unless entry[:expires_at] && entry[:expires_at] < Time.now.utc
|
|
116
|
+
|
|
117
|
+
@store.delete(key)
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def entry_for(payload, ttl)
|
|
122
|
+
{ payload: copy_payload(payload), expires_at: ttl ? Time.now.utc + ttl : nil }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def copy_payload(payload)
|
|
126
|
+
payload.dup
|
|
127
|
+
rescue TypeError
|
|
128
|
+
payload
|
|
129
|
+
end
|
|
130
|
+
|
|
98
131
|
def parse_version(payload)
|
|
99
|
-
|
|
100
|
-
rescue JSON::ParserError
|
|
101
|
-
0
|
|
132
|
+
PayloadVersion.call(payload)
|
|
102
133
|
end
|
|
103
134
|
end
|
|
104
135
|
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module PersistenceAdapters
|
|
5
|
+
module PayloadVersion
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def call(payload)
|
|
9
|
+
document = payload.is_a?(String) ? JSON.parse(payload) : payload
|
|
10
|
+
raise TypeError, "persisted workflow payload must be a JSON object" unless document.is_a?(Hash)
|
|
11
|
+
|
|
12
|
+
version = document.fetch("persistence_version", 0)
|
|
13
|
+
unless version.is_a?(Integer) && version >= 0
|
|
14
|
+
raise TypeError, "persisted workflow persistence_version must be a non-negative integer"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
version
|
|
18
|
+
rescue JSON::ParserError
|
|
19
|
+
0
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module PersistenceAdapters
|
|
5
|
+
module RedisClientAccess
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def client
|
|
9
|
+
return @client if instance_variable_defined?(:@client)
|
|
10
|
+
|
|
11
|
+
@client_resolution_mutex.synchronize do
|
|
12
|
+
return @client if instance_variable_defined?(:@client)
|
|
13
|
+
|
|
14
|
+
resolved = redis_client?(@redis_source) ? @redis_source : resolve_redis_source
|
|
15
|
+
raise ArgumentError, "Redis client is required" unless resolved
|
|
16
|
+
|
|
17
|
+
@client = resolved
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def resolve_redis_source
|
|
22
|
+
@redis_source.respond_to?(:call) ? @redis_source.call : @redis_source
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def redis_client?(candidate)
|
|
26
|
+
candidate.respond_to?(:get) && candidate.respond_to?(:set) && candidate.respond_to?(:del)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def without_reconnection
|
|
30
|
+
redis = client
|
|
31
|
+
return redis.without_reconnect { yield redis } if redis.respond_to?(:without_reconnect)
|
|
32
|
+
return redis.disable_reconnection { yield redis } if redis.respond_to?(:disable_reconnection)
|
|
33
|
+
|
|
34
|
+
raise ArgumentError, "Redis client must expose without_reconnect or disable_reconnection for CAS writes"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module PersistenceAdapters
|
|
7
|
+
class RedisExactWrite
|
|
8
|
+
extend Dry::Initializer
|
|
9
|
+
|
|
10
|
+
option :client
|
|
11
|
+
option :key
|
|
12
|
+
option :storage_key
|
|
13
|
+
option :payload
|
|
14
|
+
option :expected_payload
|
|
15
|
+
option :ttl
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
result = client.watch(storage_key) do
|
|
19
|
+
validate_current!
|
|
20
|
+
enqueue_write
|
|
21
|
+
end
|
|
22
|
+
return payload unless result.nil?
|
|
23
|
+
|
|
24
|
+
raise PersistencePayloadConflict.new(key:)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def validate_current!
|
|
30
|
+
return if client.get(storage_key) == expected_payload
|
|
31
|
+
|
|
32
|
+
client.unwatch
|
|
33
|
+
raise PersistencePayloadConflict.new(key:)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def enqueue_write
|
|
37
|
+
client.multi do |transaction|
|
|
38
|
+
options = ttl ? { ex: ttl } : {}
|
|
39
|
+
transaction.set(storage_key, payload, **options)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
require "time"
|
|
4
4
|
|
|
5
|
+
require_relative "redis_client_access"
|
|
6
|
+
|
|
5
7
|
module Smith
|
|
6
8
|
module PersistenceAdapters
|
|
7
9
|
class RedisStore
|
|
10
|
+
include RedisClientAccess
|
|
11
|
+
|
|
8
12
|
# Redis transient errors — narrow list; non-transient errors
|
|
9
13
|
# (CommandError, etc.) propagate up immediately. Pattern matches
|
|
10
14
|
# Redis::BaseConnectionError if loaded (covers Connection/Timeout)
|
|
@@ -24,9 +28,13 @@ module Smith
|
|
|
24
28
|
end + [Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::EPIPE]
|
|
25
29
|
end
|
|
26
30
|
|
|
27
|
-
|
|
31
|
+
attr_reader :persistence_identity
|
|
32
|
+
|
|
33
|
+
def initialize(redis:, namespace: "smith", identity: nil)
|
|
28
34
|
@redis_source = redis
|
|
29
|
-
@namespace = namespace
|
|
35
|
+
@namespace = namespace.nil? ? nil : namespace.to_s.dup.freeze
|
|
36
|
+
@persistence_identity = identity.to_s.dup.freeze if identity
|
|
37
|
+
@client_resolution_mutex = Mutex.new
|
|
30
38
|
end
|
|
31
39
|
|
|
32
40
|
def store(key, payload, ttl: Smith.config.persistence_ttl)
|
|
@@ -51,6 +59,9 @@ module Smith
|
|
|
51
59
|
end
|
|
52
60
|
end
|
|
53
61
|
|
|
62
|
+
def transaction_open? = false
|
|
63
|
+
def transaction_identity = nil
|
|
64
|
+
|
|
54
65
|
def record_heartbeat(key, ttl: Smith.config.persistence_ttl)
|
|
55
66
|
Retry.with_retries(operation: :record_heartbeat, transient: self.class.transient_errors) do
|
|
56
67
|
iso = Time.now.utc.iso8601
|
|
@@ -77,47 +88,37 @@ module Smith
|
|
|
77
88
|
# Smith::PersistenceVersionConflict on a stale expected_version
|
|
78
89
|
# OR on EXEC failure (WATCH detected concurrent write).
|
|
79
90
|
def store_versioned(key, payload, expected_version:, ttl: Smith.config.persistence_ttl)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
client.multi do |tx|
|
|
92
|
-
if ttl
|
|
93
|
-
tx.set(namespaced_key, payload, ex: ttl)
|
|
94
|
-
else
|
|
95
|
-
tx.set(namespaced_key, payload)
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
if result.nil?
|
|
101
|
-
raise Smith::PersistenceVersionConflict.new(
|
|
102
|
-
key: key, expected: expected_version, actual: :concurrent
|
|
103
|
-
)
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
result
|
|
91
|
+
without_reconnection do |redis|
|
|
92
|
+
RedisVersionedWrite.new(
|
|
93
|
+
client: redis,
|
|
94
|
+
key: key,
|
|
95
|
+
storage_key: namespaced(key),
|
|
96
|
+
payload: payload,
|
|
97
|
+
expected_version: expected_version,
|
|
98
|
+
ttl: ttl
|
|
99
|
+
).call
|
|
107
100
|
end
|
|
101
|
+
rescue *self.class.transient_errors => e
|
|
102
|
+
raise Smith::PersistenceIOError.new(operation: :store_versioned, cause: e)
|
|
108
103
|
end
|
|
109
104
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
105
|
+
def replace_exact(key, payload, expected_payload:, ttl: Smith.config.persistence_ttl)
|
|
106
|
+
without_reconnection do |redis|
|
|
107
|
+
RedisExactWrite.new(
|
|
108
|
+
client: redis,
|
|
109
|
+
key: key,
|
|
110
|
+
storage_key: namespaced(key),
|
|
111
|
+
payload: payload,
|
|
112
|
+
expected_payload: expected_payload,
|
|
113
|
+
ttl: ttl
|
|
114
|
+
).call
|
|
118
115
|
end
|
|
116
|
+
rescue *self.class.transient_errors => e
|
|
117
|
+
raise Smith::PersistenceIOError.new(operation: :replace_exact, cause: e)
|
|
119
118
|
end
|
|
120
119
|
|
|
120
|
+
private
|
|
121
|
+
|
|
121
122
|
def namespaced(key)
|
|
122
123
|
[@namespace, key].compact.join(":")
|
|
123
124
|
end
|
|
@@ -125,12 +126,6 @@ module Smith
|
|
|
125
126
|
def namespaced_heartbeat(key)
|
|
126
127
|
[@namespace, "heartbeat", key].compact.join(":")
|
|
127
128
|
end
|
|
128
|
-
|
|
129
|
-
def parse_version(payload)
|
|
130
|
-
JSON.parse(payload).fetch("persistence_version", 0)
|
|
131
|
-
rescue JSON::ParserError
|
|
132
|
-
0
|
|
133
|
-
end
|
|
134
129
|
end
|
|
135
130
|
end
|
|
136
131
|
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module PersistenceAdapters
|
|
7
|
+
class RedisVersionedWrite
|
|
8
|
+
extend Dry::Initializer
|
|
9
|
+
|
|
10
|
+
option :client
|
|
11
|
+
option :key
|
|
12
|
+
option :storage_key
|
|
13
|
+
option :payload
|
|
14
|
+
option :expected_version
|
|
15
|
+
option :ttl
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
result = client.watch(storage_key) do
|
|
19
|
+
current = client.get(storage_key)
|
|
20
|
+
validate_current!(current)
|
|
21
|
+
enqueue_write
|
|
22
|
+
end
|
|
23
|
+
return result unless result.nil?
|
|
24
|
+
|
|
25
|
+
raise PersistenceVersionConflict.new(
|
|
26
|
+
key: key,
|
|
27
|
+
expected: expected_version,
|
|
28
|
+
actual: :concurrent
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def validate_current!(current)
|
|
35
|
+
return VersionExpectation.validate_missing!(key, expected_version) unless current
|
|
36
|
+
|
|
37
|
+
current_version = PayloadVersion.call(current)
|
|
38
|
+
return if current_version == expected_version
|
|
39
|
+
|
|
40
|
+
client.unwatch
|
|
41
|
+
raise PersistenceVersionConflict.new(
|
|
42
|
+
key: key,
|
|
43
|
+
expected: expected_version,
|
|
44
|
+
actual: current_version
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def enqueue_write
|
|
49
|
+
client.multi do |transaction|
|
|
50
|
+
options = ttl ? { ex: ttl } : {}
|
|
51
|
+
transaction.set(storage_key, payload, **options)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -16,27 +16,39 @@ module Smith
|
|
|
16
16
|
|
|
17
17
|
def with_retries(operation:, transient:, policy: Smith.config.persistence_retry_policy,
|
|
18
18
|
logger: Smith.config.logger)
|
|
19
|
-
|
|
20
|
-
base = policy.fetch(:base_delay, 0.1)
|
|
21
|
-
max_delay = policy.fetch(:max_delay, 1.0)
|
|
19
|
+
schedule = schedule_for(policy)
|
|
22
20
|
last_error = nil
|
|
23
21
|
|
|
24
|
-
attempts.times do |i|
|
|
22
|
+
schedule.attempts.times do |i|
|
|
25
23
|
return yield
|
|
26
24
|
rescue *transient => e
|
|
27
25
|
last_error = e
|
|
28
|
-
break if i == attempts - 1
|
|
26
|
+
break if i == schedule.attempts - 1
|
|
29
27
|
|
|
30
|
-
delay =
|
|
31
|
-
logger
|
|
32
|
-
"Smith::PersistenceAdapters::Retry #{operation} attempt #{i + 1}/#{attempts} failed: " \
|
|
33
|
-
"#{e.class}: #{e.message}; sleeping #{delay}s"
|
|
34
|
-
)
|
|
28
|
+
delay = schedule.delay(i + 1)
|
|
29
|
+
log_retry(logger, operation, e, { attempt: i + 1, attempts: schedule.attempts, delay: })
|
|
35
30
|
sleep(delay)
|
|
36
31
|
end
|
|
37
32
|
|
|
38
33
|
raise Smith::PersistenceIOError.new(operation: operation, cause: last_error)
|
|
39
34
|
end
|
|
35
|
+
|
|
36
|
+
def schedule_for(policy)
|
|
37
|
+
ExponentialBackoff.new(
|
|
38
|
+
attempts: policy.fetch(:attempts, 3),
|
|
39
|
+
base_delay: policy.fetch(:base_delay, 0.1),
|
|
40
|
+
max_delay: policy.fetch(:max_delay, 1.0),
|
|
41
|
+
jitter: 0
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def log_retry(logger, operation, error, retry_context)
|
|
46
|
+
logger&.warn(
|
|
47
|
+
"Smith::PersistenceAdapters::Retry #{operation} " \
|
|
48
|
+
"attempt #{retry_context.fetch(:attempt)}/#{retry_context.fetch(:attempts)} failed: " \
|
|
49
|
+
"#{error.class}: #{error.message}; sleeping #{retry_context.fetch(:delay)}s"
|
|
50
|
+
)
|
|
51
|
+
end
|
|
40
52
|
end
|
|
41
53
|
end
|
|
42
54
|
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module PersistenceAdapters
|
|
5
|
+
module VersionExpectation
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def validate_missing!(key, expected_version)
|
|
9
|
+
return if expected_version.is_a?(Integer) && expected_version.zero?
|
|
10
|
+
|
|
11
|
+
raise Smith::PersistenceVersionConflict.new(
|
|
12
|
+
key: key,
|
|
13
|
+
expected: expected_version,
|
|
14
|
+
actual: :missing
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "monitor"
|
|
4
|
+
require_relative "persistence_adapters/active_record_connection_errors"
|
|
5
|
+
require_relative "persistence_adapters/active_record_initial_write"
|
|
6
|
+
require_relative "persistence_adapters/active_record_exact_write"
|
|
7
|
+
require_relative "persistence_adapters/active_record_exact_store"
|
|
8
|
+
require_relative "persistence_adapters/payload_version"
|
|
9
|
+
require_relative "persistence_adapters/version_expectation"
|
|
4
10
|
require_relative "persistence_adapters/cache_store"
|
|
5
11
|
require_relative "persistence_adapters/rails_cache"
|
|
12
|
+
require_relative "persistence_adapters/redis_versioned_write"
|
|
13
|
+
require_relative "persistence_adapters/redis_exact_write"
|
|
6
14
|
require_relative "persistence_adapters/redis_store"
|
|
7
15
|
require_relative "persistence_adapters/active_record_store"
|
|
8
16
|
require_relative "persistence_adapters/memory"
|
|
@@ -23,7 +31,10 @@ module Smith
|
|
|
23
31
|
# check support via `supports?(adapter, capability)` and fall back
|
|
24
32
|
# gracefully (e.g., Workflow#persist! warns once and uses plain
|
|
25
33
|
# `store` when `store_versioned` is missing).
|
|
26
|
-
OPTIONAL_METHODS = %i[
|
|
34
|
+
OPTIONAL_METHODS = %i[
|
|
35
|
+
store_versioned replace_exact persistence_identity record_heartbeat last_heartbeat
|
|
36
|
+
transaction_open? transaction_identity
|
|
37
|
+
].freeze
|
|
27
38
|
|
|
28
39
|
def self.resolve(adapter, **options)
|
|
29
40
|
return nil if adapter.nil?
|
|
@@ -34,22 +45,7 @@ module Smith
|
|
|
34
45
|
return validate!(instance)
|
|
35
46
|
end
|
|
36
47
|
|
|
37
|
-
|
|
38
|
-
when :cache_store
|
|
39
|
-
CacheStore.new(**options)
|
|
40
|
-
when :rails_cache, :solid_cache
|
|
41
|
-
RailsCache.new(**options)
|
|
42
|
-
when :redis
|
|
43
|
-
RedisStore.new(**options)
|
|
44
|
-
when :active_record
|
|
45
|
-
ActiveRecordStore.new(**options)
|
|
46
|
-
when :memory
|
|
47
|
-
Memory.new
|
|
48
|
-
else
|
|
49
|
-
raise ArgumentError, "Unknown persistence adapter #{adapter.inspect}"
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
validate!(built_in)
|
|
48
|
+
validate!(build(adapter, options))
|
|
53
49
|
end
|
|
54
50
|
|
|
55
51
|
def self.adapter_like?(adapter)
|
|
@@ -60,9 +56,21 @@ module Smith
|
|
|
60
56
|
return adapter if adapter_like?(adapter)
|
|
61
57
|
|
|
62
58
|
missing = REQUIRED_METHODS.reject { |method_name| adapter.respond_to?(method_name) }
|
|
63
|
-
raise ArgumentError, "Persistence adapter must implement #{missing.join(
|
|
59
|
+
raise ArgumentError, "Persistence adapter must implement #{missing.join(", ")}"
|
|
64
60
|
end
|
|
65
61
|
|
|
62
|
+
def self.build(adapter, options)
|
|
63
|
+
case adapter.to_sym
|
|
64
|
+
when :cache_store then CacheStore.new(**options)
|
|
65
|
+
when :rails_cache, :solid_cache then RailsCache.new(**options)
|
|
66
|
+
when :redis then RedisStore.new(**options)
|
|
67
|
+
when :active_record then ActiveRecordStore.new(**options)
|
|
68
|
+
when :memory then Memory.new(**options)
|
|
69
|
+
else raise ArgumentError, "Unknown persistence adapter #{adapter.inspect}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
private_class_method :build
|
|
73
|
+
|
|
66
74
|
# Capability introspection used by Workflow#persist! to decide
|
|
67
75
|
# whether the adapter supports optimistic locking via store_versioned.
|
|
68
76
|
def self.supports?(adapter, capability)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class PersistencePayloadConflict < Error
|
|
5
|
+
attr_reader :key
|
|
6
|
+
|
|
7
|
+
def initialize(key:)
|
|
8
|
+
@key = key
|
|
9
|
+
super("persisted payload conflict for #{key.inspect}: the exact expected payload is no longer current")
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -22,8 +22,8 @@ module Smith
|
|
|
22
22
|
|
|
23
23
|
def commit_tool_call_charges!(ledger, allowance, workflow_active)
|
|
24
24
|
if workflow_active
|
|
25
|
-
ledger.reserve!(:tool_calls, 1)
|
|
26
|
-
ledger.reconcile!(
|
|
25
|
+
reservation = ledger.reserve!(:tool_calls, 1)
|
|
26
|
+
ledger.reconcile!(reservation, 1)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
allowance[:remaining] -= 1 if allowance
|
data/lib/smith/types.rb
CHANGED
data/lib/smith/version.rb
CHANGED
|
@@ -4,7 +4,7 @@ module Smith
|
|
|
4
4
|
class Workflow
|
|
5
5
|
# rubocop:disable Style/RedundantStructKeywordInit
|
|
6
6
|
BranchEnv = Struct.new(
|
|
7
|
-
:prepared_input, :guardrail_sources, :scoped_store, :branch_estimates, :deadline,
|
|
7
|
+
:prepared_input, :guardrail_sources, :scoped_store, :branch_estimates, :deadline, :agent_class,
|
|
8
8
|
keyword_init: true
|
|
9
9
|
) do
|
|
10
10
|
def setup_thread
|
|
@@ -15,8 +15,7 @@ module Smith
|
|
|
15
15
|
def reserve_branch_budget(ledger, branch_estimates:)
|
|
16
16
|
return nil unless ledger && branch_estimates
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
branch_estimates
|
|
18
|
+
reserve_estimates!(ledger, branch_estimates)
|
|
20
19
|
end
|
|
21
20
|
|
|
22
21
|
def compute_branch_estimates(ledger, branch_count:, agent_budget: nil)
|
|
@@ -29,13 +28,16 @@ module Smith
|
|
|
29
28
|
end
|
|
30
29
|
end
|
|
31
30
|
|
|
32
|
-
def reconcile_branch_budget(ledger,
|
|
33
|
-
return unless ledger &&
|
|
31
|
+
def reconcile_branch_budget(ledger, reservation, agent_result: nil)
|
|
32
|
+
return unless ledger && reservation
|
|
34
33
|
|
|
35
34
|
actuals = extract_actuals(agent_results_for_settlement(agent_result))
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
settlement = reservation.amounts.to_h do |dimension, _amount|
|
|
36
|
+
[dimension, actual_for_dimension(dimension, actuals[:tokens], actuals[:cost])]
|
|
38
37
|
end
|
|
38
|
+
return ledger.reconcile_many!(reservation, actual: settlement) if settlement.length > 1
|
|
39
|
+
|
|
40
|
+
ledger.reconcile!(reservation, settlement.values.first)
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
def extract_actuals(agent_results)
|
|
@@ -66,10 +68,12 @@ module Smith
|
|
|
66
68
|
0
|
|
67
69
|
end
|
|
68
70
|
|
|
69
|
-
def release_branch_budget(ledger,
|
|
70
|
-
return unless ledger &&
|
|
71
|
+
def release_branch_budget(ledger, reservation)
|
|
72
|
+
return unless ledger && reservation
|
|
73
|
+
|
|
74
|
+
return ledger.release_many!(reservation) if reservation.amounts.length > 1
|
|
71
75
|
|
|
72
|
-
|
|
76
|
+
ledger.release!(reservation)
|
|
73
77
|
end
|
|
74
78
|
|
|
75
79
|
def settle_budget_on_failure(ledger, estimates, agent_result)
|
|
@@ -91,8 +95,14 @@ module Smith
|
|
|
91
95
|
est[dim] = cap ? [remaining, cap].min : remaining
|
|
92
96
|
end
|
|
93
97
|
|
|
94
|
-
|
|
95
|
-
|
|
98
|
+
reserve_estimates!(ledger, estimates)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def reserve_estimates!(ledger, estimates)
|
|
102
|
+
return ledger.reserve_many!(estimates) if estimates.length > 1
|
|
103
|
+
|
|
104
|
+
dimension, amount = estimates.first
|
|
105
|
+
ledger.reserve!(dimension, amount)
|
|
96
106
|
end
|
|
97
107
|
|
|
98
108
|
def finalize_branch(transition, index, result, ledger, reserved)
|