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.
Files changed (152) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +262 -0
  3. data/README.md +116 -6
  4. data/docs/CONFIGURATION.md +4 -1
  5. data/docs/PATTERNS.md +7 -0
  6. data/docs/PERSISTENCE.md +502 -1
  7. data/docs/workflow_claim.md +4 -1
  8. data/lib/smith/agent/lifecycle.rb +19 -10
  9. data/lib/smith/agent/registry/execution_binding_capture.rb +26 -0
  10. data/lib/smith/agent/registry/mutation_boundary.rb +29 -0
  11. data/lib/smith/agent/registry.rb +8 -0
  12. data/lib/smith/agent.rb +24 -2
  13. data/lib/smith/budget/amount_contract.rb +67 -0
  14. data/lib/smith/budget/amount_representation.rb +43 -0
  15. data/lib/smith/budget/decimal_context.rb +18 -0
  16. data/lib/smith/budget/ledger.rb +110 -18
  17. data/lib/smith/budget/ledger_publication.rb +60 -0
  18. data/lib/smith/budget/ledger_state.rb +21 -0
  19. data/lib/smith/budget/ledger_state_transition.rb +72 -0
  20. data/lib/smith/budget/public_state_validator.rb +25 -0
  21. data/lib/smith/budget/reservation.rb +20 -0
  22. data/lib/smith/budget/reservation_contract.rb +34 -0
  23. data/lib/smith/context/observation_masking.rb +3 -1
  24. data/lib/smith/context/state_injection.rb +1 -1
  25. data/lib/smith/doctor/checks/persistence_capabilities.rb +18 -3
  26. data/lib/smith/error.rb +5 -0
  27. data/lib/smith/errors.rb +6 -0
  28. data/lib/smith/exponential_backoff.rb +128 -0
  29. data/lib/smith/persistence_adapters/active_record_connection_errors.rb +23 -0
  30. data/lib/smith/persistence_adapters/active_record_exact_predicate.rb +43 -0
  31. data/lib/smith/persistence_adapters/active_record_exact_store.rb +62 -0
  32. data/lib/smith/persistence_adapters/active_record_exact_write.rb +40 -0
  33. data/lib/smith/persistence_adapters/active_record_initial_write.rb +42 -0
  34. data/lib/smith/persistence_adapters/active_record_store.rb +95 -52
  35. data/lib/smith/persistence_adapters/cache_store.rb +4 -1
  36. data/lib/smith/persistence_adapters/memory.rb +45 -14
  37. data/lib/smith/persistence_adapters/payload_version.rb +23 -0
  38. data/lib/smith/persistence_adapters/redis_client_access.rb +38 -0
  39. data/lib/smith/persistence_adapters/redis_exact_write.rb +44 -0
  40. data/lib/smith/persistence_adapters/redis_store.rb +38 -43
  41. data/lib/smith/persistence_adapters/redis_versioned_write.rb +56 -0
  42. data/lib/smith/persistence_adapters/retry.rb +22 -10
  43. data/lib/smith/persistence_adapters/version_expectation.rb +19 -0
  44. data/lib/smith/persistence_adapters.rb +26 -18
  45. data/lib/smith/persistence_payload_conflict.rb +12 -0
  46. data/lib/smith/tool/budget_enforcement.rb +2 -2
  47. data/lib/smith/types.rb +1 -0
  48. data/lib/smith/version.rb +2 -1
  49. data/lib/smith/workflow/branch_env.rb +1 -1
  50. data/lib/smith/workflow/budget_integration.rb +21 -11
  51. data/lib/smith/workflow/definition_identity/class_methods.rb +48 -0
  52. data/lib/smith/workflow/definition_identity.rb +25 -0
  53. data/lib/smith/workflow/deterministic_step.rb +2 -2
  54. data/lib/smith/workflow/dsl.rb +73 -5
  55. data/lib/smith/workflow/durability.rb +2 -1
  56. data/lib/smith/workflow/evaluator_optimizer.rb +7 -11
  57. data/lib/smith/workflow/execution.rb +16 -18
  58. data/lib/smith/workflow/execution_binding_resolution.rb +16 -0
  59. data/lib/smith/workflow/execution_result_snapshot.rb +118 -0
  60. data/lib/smith/workflow/fanout_execution.rb +1 -1
  61. data/lib/smith/workflow/graph/diagnostic.rb +27 -1
  62. data/lib/smith/workflow/graph/diagnostic_path.rb +24 -0
  63. data/lib/smith/workflow/graph/execution_successors.rb +65 -0
  64. data/lib/smith/workflow/graph/identifier_projection.rb +13 -0
  65. data/lib/smith/workflow/graph/metrics.rb +1 -3
  66. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +15 -16
  67. data/lib/smith/workflow/graph/optimization_contract.rb +2 -2
  68. data/lib/smith/workflow/graph/orchestration_contract.rb +3 -3
  69. data/lib/smith/workflow/graph/reachability.rb +19 -31
  70. data/lib/smith/workflow/graph/reachability_diagnostics.rb +17 -1
  71. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +36 -0
  72. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +2 -2
  73. data/lib/smith/workflow/graph/runtime_readiness.rb +3 -44
  74. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +2 -2
  75. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +47 -0
  76. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +108 -0
  77. data/lib/smith/workflow/graph/state_diagnostics.rb +2 -2
  78. data/lib/smith/workflow/graph/transition_contract.rb +47 -0
  79. data/lib/smith/workflow/graph/transition_contract_attributes.rb +73 -0
  80. data/lib/smith/workflow/graph/transition_contract_configurations.rb +100 -0
  81. data/lib/smith/workflow/graph/transition_diagnostics.rb +54 -12
  82. data/lib/smith/workflow/graph/transition_optimization_configuration.rb +62 -0
  83. data/lib/smith/workflow/graph/transition_snapshot.rb +4 -1
  84. data/lib/smith/workflow/graph/validator.rb +1 -1
  85. data/lib/smith/workflow/graph.rb +62 -4
  86. data/lib/smith/workflow/guardrail_integration.rb +23 -9
  87. data/lib/smith/workflow/identifier.rb +24 -0
  88. data/lib/smith/workflow/message_admission.rb +40 -0
  89. data/lib/smith/workflow/message_admission_boundary.rb +22 -0
  90. data/lib/smith/workflow/message_batch.rb +60 -0
  91. data/lib/smith/workflow/message_value_normalizer.rb +133 -0
  92. data/lib/smith/workflow/nested_execution.rb +10 -0
  93. data/lib/smith/workflow/orchestrator_worker.rb +3 -7
  94. data/lib/smith/workflow/parallel/cancellation_signal.rb +14 -5
  95. data/lib/smith/workflow/parallel/execution_context.rb +113 -0
  96. data/lib/smith/workflow/parallel/nested_execution.rb +124 -0
  97. data/lib/smith/workflow/parallel/root_execution.rb +91 -0
  98. data/lib/smith/workflow/parallel.rb +19 -22
  99. data/lib/smith/workflow/parallel_agent_binding.rb +24 -0
  100. data/lib/smith/workflow/parallel_execution.rb +7 -3
  101. data/lib/smith/workflow/persistence.rb +49 -10
  102. data/lib/smith/workflow/prepared_step.rb +122 -0
  103. data/lib/smith/workflow/prepared_step_dispatch.rb +88 -0
  104. data/lib/smith/workflow/prepared_step_execution_authorization.rb +116 -0
  105. data/lib/smith/workflow/prepared_step_execution_result.rb +49 -0
  106. data/lib/smith/workflow/prepared_step_execution_scope.rb +45 -0
  107. data/lib/smith/workflow/prepared_step_recovery.rb +38 -0
  108. data/lib/smith/workflow/retry_execution.rb +20 -11
  109. data/lib/smith/workflow/split_step_persistence/boundary.rb +128 -0
  110. data/lib/smith/workflow/split_step_persistence/boundary_reset.rb +50 -0
  111. data/lib/smith/workflow/split_step_persistence/canonical_payload_digest.rb +55 -0
  112. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +119 -0
  113. data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +51 -0
  114. data/lib/smith/workflow/split_step_persistence/definition_boundary.rb +19 -0
  115. data/lib/smith/workflow/split_step_persistence/dispatch_boundary.rb +16 -0
  116. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +118 -0
  117. data/lib/smith/workflow/split_step_persistence/dispatch_confirmation.rb +70 -0
  118. data/lib/smith/workflow/split_step_persistence/dispatch_verification.rb +35 -0
  119. data/lib/smith/workflow/split_step_persistence/execution.rb +128 -0
  120. data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +89 -0
  121. data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +90 -0
  122. data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +95 -0
  123. data/lib/smith/workflow/split_step_persistence/execution_result_capture.rb +48 -0
  124. data/lib/smith/workflow/split_step_persistence/execution_verification.rb +65 -0
  125. data/lib/smith/workflow/split_step_persistence/execution_workflow_snapshot.rb +77 -0
  126. data/lib/smith/workflow/split_step_persistence/inheritance.rb +22 -0
  127. data/lib/smith/workflow/split_step_persistence/payloads.rb +54 -0
  128. data/lib/smith/workflow/split_step_persistence/preparation.rb +125 -0
  129. data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +117 -0
  130. data/lib/smith/workflow/split_step_persistence/preparation_payload.rb +25 -0
  131. data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +54 -0
  132. data/lib/smith/workflow/split_step_persistence/recovery.rb +125 -0
  133. data/lib/smith/workflow/split_step_persistence/recovery_boundary.rb +56 -0
  134. data/lib/smith/workflow/split_step_persistence/recovery_class_methods.rb +14 -0
  135. data/lib/smith/workflow/split_step_persistence/replace_exact_signature.rb +46 -0
  136. data/lib/smith/workflow/split_step_persistence/restart_safe_adapter.rb +42 -0
  137. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +84 -0
  138. data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +102 -0
  139. data/lib/smith/workflow/split_step_persistence/transaction_identity.rb +45 -0
  140. data/lib/smith/workflow/split_step_persistence/transition_contract.rb +48 -0
  141. data/lib/smith/workflow/split_step_persistence/transition_contract_freezer.rb +83 -0
  142. data/lib/smith/workflow/split_step_persistence/transition_contract_signature.rb +107 -0
  143. data/lib/smith/workflow/split_step_persistence/transition_contract_structured_values.rb +51 -0
  144. data/lib/smith/workflow/split_step_persistence.rb +79 -0
  145. data/lib/smith/workflow/step_completion.rb +31 -0
  146. data/lib/smith/workflow/string_snapshot.rb +25 -0
  147. data/lib/smith/workflow/transition.rb +122 -133
  148. data/lib/smith/workflow/transition_actionability.rb +22 -0
  149. data/lib/smith/workflow.rb +57 -19
  150. data/lib/smith.rb +42 -2
  151. data/sig/smith.rbs +1 -0
  152. 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
- def initialize
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] = { payload: payload, expires_at: ttl ? Time.now.utc + ttl : nil }
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 = @store[key]
36
+ entry = live_entry(key)
33
37
  next nil if entry.nil?
34
38
 
35
- if entry[:expires_at] && entry[:expires_at] < Time.now.utc
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 = @store[key]
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] = { payload: payload, expires_at: ttl ? Time.now.utc + ttl : nil }
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
- JSON.parse(payload).fetch("persistence_version", 0)
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
- def initialize(redis:, namespace: "smith")
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
- Retry.with_retries(operation: :store_versioned, transient: self.class.transient_errors) do
81
- namespaced_key = namespaced(key)
82
- result = client.watch(namespaced_key) do
83
- current = client.get(namespaced_key)
84
- if current && (current_version = parse_version(current)) != expected_version
85
- client.unwatch
86
- raise Smith::PersistenceVersionConflict.new(
87
- key: key, expected: expected_version, actual: current_version
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
- private
111
-
112
- def client
113
- @client ||= begin
114
- resolved = @redis_source.respond_to?(:call) ? @redis_source.call : @redis_source
115
- raise ArgumentError, "Redis client is required" unless resolved
116
-
117
- resolved
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
- attempts = policy.fetch(:attempts, 3)
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 = [base * (2**i), max_delay].min
31
- logger&.warn(
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[store_versioned record_heartbeat last_heartbeat].freeze
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
- built_in = case adapter.to_sym
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!(:tool_calls, 1, 1)
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
@@ -12,5 +12,6 @@ module Smith
12
12
  # the constants we need as direct module constants.
13
13
  const_set(:String, const_get(:String))
14
14
  const_set(:Integer, const_get(:Integer))
15
+ const_set(:Sha256Hex, const_get(:String).constrained(format: /\A[0-9a-f]{64}\z/))
15
16
  end
16
17
  end
data/lib/smith/version.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Smith
4
- VERSION = "0.4.4"
4
+ VERSION = "0.5.0"
5
+ EXECUTION_SEMANTICS_VERSION = "1"
5
6
  end
@@ -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
- branch_estimates.each { |dim, amount| ledger.reserve!(dim, amount) }
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, estimates, agent_result: nil)
33
- return unless ledger && estimates
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
- estimates.each do |dim, amt|
37
- ledger.reconcile!(dim, amt, actual_for_dimension(dim, actuals[:tokens], actuals[:cost]))
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, estimates)
70
- return unless ledger && estimates
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
- estimates.each { |dim, amount| ledger.release!(dim, amount) }
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
- estimates.each { |dim, amount| ledger.reserve!(dim, amount) }
95
- estimates
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)