smith-agents 0.4.5 → 0.6.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 (186) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +264 -0
  3. data/README.md +191 -4
  4. data/docs/CONFIGURATION.md +4 -1
  5. data/docs/PATTERNS.md +7 -0
  6. data/docs/PERSISTENCE.md +347 -25
  7. data/lib/smith/agent/lifecycle.rb +19 -10
  8. data/lib/smith/agent/registry/execution_binding_capture.rb +26 -0
  9. data/lib/smith/agent/registry/mutation_boundary.rb +29 -0
  10. data/lib/smith/agent/registry.rb +8 -0
  11. data/lib/smith/agent.rb +37 -2
  12. data/lib/smith/budget/amount_contract.rb +67 -0
  13. data/lib/smith/budget/amount_representation.rb +43 -0
  14. data/lib/smith/budget/decimal_context.rb +18 -0
  15. data/lib/smith/budget/ledger.rb +110 -18
  16. data/lib/smith/budget/ledger_publication.rb +60 -0
  17. data/lib/smith/budget/ledger_state.rb +21 -0
  18. data/lib/smith/budget/ledger_state_transition.rb +72 -0
  19. data/lib/smith/budget/public_state_validator.rb +25 -0
  20. data/lib/smith/budget/reservation.rb +20 -0
  21. data/lib/smith/budget/reservation_contract.rb +34 -0
  22. data/lib/smith/context/observation_masking.rb +3 -1
  23. data/lib/smith/context/state_injection.rb +1 -1
  24. data/lib/smith/doctor/checks/persistence_capabilities.rb +18 -4
  25. data/lib/smith/error.rb +5 -0
  26. data/lib/smith/errors.rb +10 -0
  27. data/lib/smith/exponential_backoff.rb +128 -0
  28. data/lib/smith/persistence_adapters/active_record_exact_predicate.rb +43 -0
  29. data/lib/smith/persistence_adapters/active_record_exact_store.rb +62 -0
  30. data/lib/smith/persistence_adapters/active_record_exact_write.rb +40 -0
  31. data/lib/smith/persistence_adapters/active_record_store.rb +7 -4
  32. data/lib/smith/persistence_adapters/cache_store.rb +1 -0
  33. data/lib/smith/persistence_adapters/memory.rb +16 -1
  34. data/lib/smith/persistence_adapters/redis_client_access.rb +38 -0
  35. data/lib/smith/persistence_adapters/redis_exact_write.rb +44 -0
  36. data/lib/smith/persistence_adapters/redis_store.rb +28 -11
  37. data/lib/smith/persistence_adapters/retry.rb +22 -10
  38. data/lib/smith/persistence_adapters.rb +21 -18
  39. data/lib/smith/persistence_payload_conflict.rb +12 -0
  40. data/lib/smith/tool/budget_enforcement.rb +2 -2
  41. data/lib/smith/types.rb +1 -0
  42. data/lib/smith/version.rb +2 -1
  43. data/lib/smith/workflow/branch_env.rb +1 -1
  44. data/lib/smith/workflow/budget_integration.rb +21 -11
  45. data/lib/smith/workflow/composite/branch.rb +93 -0
  46. data/lib/smith/workflow/composite/branch_budget_contract.rb +41 -0
  47. data/lib/smith/workflow/composite/branch_contract.rb +102 -0
  48. data/lib/smith/workflow/composite/branch_execution.rb +124 -0
  49. data/lib/smith/workflow/composite/branch_failure.rb +91 -0
  50. data/lib/smith/workflow/composite/branch_outcome.rb +108 -0
  51. data/lib/smith/workflow/composite/budget_allocator.rb +78 -0
  52. data/lib/smith/workflow/composite/budget_math.rb +51 -0
  53. data/lib/smith/workflow/composite/contract.rb +100 -0
  54. data/lib/smith/workflow/composite/effects.rb +140 -0
  55. data/lib/smith/workflow/composite/effects_application.rb +36 -0
  56. data/lib/smith/workflow/composite/effects_baseline.rb +36 -0
  57. data/lib/smith/workflow/composite/effects_preflight.rb +102 -0
  58. data/lib/smith/workflow/composite/encoded_value_budget.rb +36 -0
  59. data/lib/smith/workflow/composite/enums.rb +27 -0
  60. data/lib/smith/workflow/composite/error.rb +54 -0
  61. data/lib/smith/workflow/composite/error_evidence.rb +61 -0
  62. data/lib/smith/workflow/composite/execution_contract.rb +73 -0
  63. data/lib/smith/workflow/composite/fanout_branch_contract.rb +61 -0
  64. data/lib/smith/workflow/composite/input.rb +53 -0
  65. data/lib/smith/workflow/composite/outcome_accumulator.rb +131 -0
  66. data/lib/smith/workflow/composite/outcome_set.rb +23 -0
  67. data/lib/smith/workflow/composite/payload.rb +128 -0
  68. data/lib/smith/workflow/composite/payload_digest.rb +28 -0
  69. data/lib/smith/workflow/composite/plan.rb +130 -0
  70. data/lib/smith/workflow/composite/plan_integrity.rb +52 -0
  71. data/lib/smith/workflow/composite/planner.rb +53 -0
  72. data/lib/smith/workflow/composite/preparation.rb +27 -0
  73. data/lib/smith/workflow/composite/reducer.rb +133 -0
  74. data/lib/smith/workflow/composite/reduction.rb +31 -0
  75. data/lib/smith/workflow/composite/value_budget.rb +90 -0
  76. data/lib/smith/workflow/composite_branch_execution_authorization.rb +52 -0
  77. data/lib/smith/workflow/deadline_enforcement.rb +22 -5
  78. data/lib/smith/workflow/definition_identity/class_methods.rb +48 -0
  79. data/lib/smith/workflow/definition_identity.rb +25 -0
  80. data/lib/smith/workflow/deterministic_step.rb +2 -2
  81. data/lib/smith/workflow/dsl.rb +73 -5
  82. data/lib/smith/workflow/durability.rb +1 -1
  83. data/lib/smith/workflow/evaluator_optimizer.rb +7 -11
  84. data/lib/smith/workflow/execution.rb +28 -30
  85. data/lib/smith/workflow/execution_binding_resolution.rb +16 -0
  86. data/lib/smith/workflow/execution_result_snapshot.rb +118 -0
  87. data/lib/smith/workflow/fanout_execution.rb +20 -19
  88. data/lib/smith/workflow/graph/diagnostic.rb +27 -1
  89. data/lib/smith/workflow/graph/diagnostic_path.rb +24 -0
  90. data/lib/smith/workflow/graph/execution_successors.rb +65 -0
  91. data/lib/smith/workflow/graph/identifier_projection.rb +13 -0
  92. data/lib/smith/workflow/graph/metrics.rb +1 -3
  93. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +15 -16
  94. data/lib/smith/workflow/graph/optimization_contract.rb +2 -2
  95. data/lib/smith/workflow/graph/orchestration_contract.rb +3 -3
  96. data/lib/smith/workflow/graph/reachability.rb +19 -31
  97. data/lib/smith/workflow/graph/reachability_diagnostics.rb +17 -1
  98. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +36 -0
  99. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +2 -2
  100. data/lib/smith/workflow/graph/runtime_readiness.rb +3 -44
  101. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +2 -2
  102. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +47 -0
  103. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +108 -0
  104. data/lib/smith/workflow/graph/state_diagnostics.rb +2 -2
  105. data/lib/smith/workflow/graph/transition_contract.rb +47 -0
  106. data/lib/smith/workflow/graph/transition_contract_attributes.rb +73 -0
  107. data/lib/smith/workflow/graph/transition_contract_configurations.rb +100 -0
  108. data/lib/smith/workflow/graph/transition_diagnostics.rb +54 -12
  109. data/lib/smith/workflow/graph/transition_optimization_configuration.rb +62 -0
  110. data/lib/smith/workflow/graph/transition_snapshot.rb +4 -1
  111. data/lib/smith/workflow/graph/validator.rb +1 -1
  112. data/lib/smith/workflow/graph.rb +62 -4
  113. data/lib/smith/workflow/guardrail_integration.rb +23 -9
  114. data/lib/smith/workflow/identifier.rb +24 -0
  115. data/lib/smith/workflow/message_admission.rb +40 -0
  116. data/lib/smith/workflow/message_admission_boundary.rb +22 -0
  117. data/lib/smith/workflow/message_batch.rb +60 -0
  118. data/lib/smith/workflow/message_value_normalizer.rb +134 -0
  119. data/lib/smith/workflow/nested_execution.rb +10 -0
  120. data/lib/smith/workflow/orchestrator_worker.rb +3 -7
  121. data/lib/smith/workflow/parallel/cancellation_signal.rb +14 -5
  122. data/lib/smith/workflow/parallel/execution_context.rb +113 -0
  123. data/lib/smith/workflow/parallel/nested_execution.rb +124 -0
  124. data/lib/smith/workflow/parallel/root_execution.rb +91 -0
  125. data/lib/smith/workflow/parallel.rb +19 -22
  126. data/lib/smith/workflow/parallel_agent_binding.rb +24 -0
  127. data/lib/smith/workflow/parallel_execution.rb +46 -14
  128. data/lib/smith/workflow/persistence.rb +37 -8
  129. data/lib/smith/workflow/prepared_branch_execution.rb +31 -0
  130. data/lib/smith/workflow/prepared_step.rb +122 -0
  131. data/lib/smith/workflow/prepared_step_dispatch.rb +88 -0
  132. data/lib/smith/workflow/prepared_step_execution_authorization.rb +101 -0
  133. data/lib/smith/workflow/prepared_step_execution_result.rb +49 -0
  134. data/lib/smith/workflow/prepared_step_execution_scope.rb +111 -0
  135. data/lib/smith/workflow/prepared_step_recovery.rb +38 -0
  136. data/lib/smith/workflow/process_local.rb +33 -0
  137. data/lib/smith/workflow/retry_execution.rb +20 -11
  138. data/lib/smith/workflow/split_step_persistence/boundary.rb +35 -22
  139. data/lib/smith/workflow/split_step_persistence/boundary_reset.rb +50 -0
  140. data/lib/smith/workflow/split_step_persistence/canonical_payload_digest.rb +55 -0
  141. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -5
  142. data/lib/smith/workflow/split_step_persistence/composite_branch_authorization.rb +99 -0
  143. data/lib/smith/workflow/split_step_persistence/composite_branch_effects.rb +41 -0
  144. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +95 -0
  145. data/lib/smith/workflow/split_step_persistence/composite_branch_outcome.rb +36 -0
  146. data/lib/smith/workflow/split_step_persistence/composite_execution.rb +39 -0
  147. data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +57 -0
  148. data/lib/smith/workflow/split_step_persistence/composite_reduction_execution.rb +122 -0
  149. data/lib/smith/workflow/split_step_persistence/definition_boundary.rb +19 -0
  150. data/lib/smith/workflow/split_step_persistence/dispatch_boundary.rb +16 -0
  151. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +118 -0
  152. data/lib/smith/workflow/split_step_persistence/dispatch_confirmation.rb +70 -0
  153. data/lib/smith/workflow/split_step_persistence/dispatch_verification.rb +35 -0
  154. data/lib/smith/workflow/split_step_persistence/execution.rb +20 -64
  155. data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +109 -0
  156. data/lib/smith/workflow/split_step_persistence/execution_authorization_issuance.rb +57 -0
  157. data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +95 -0
  158. data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +109 -0
  159. data/lib/smith/workflow/split_step_persistence/execution_lifecycle.rb +96 -0
  160. data/lib/smith/workflow/split_step_persistence/execution_result_capture.rb +48 -0
  161. data/lib/smith/workflow/split_step_persistence/execution_verification.rb +77 -0
  162. data/lib/smith/workflow/split_step_persistence/execution_workflow_snapshot.rb +77 -0
  163. data/lib/smith/workflow/split_step_persistence/inheritance.rb +3 -1
  164. data/lib/smith/workflow/split_step_persistence/payloads.rb +7 -3
  165. data/lib/smith/workflow/split_step_persistence/preparation.rb +30 -2
  166. data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +38 -12
  167. data/lib/smith/workflow/split_step_persistence/preparation_payload.rb +25 -0
  168. data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +10 -2
  169. data/lib/smith/workflow/split_step_persistence/recovery.rb +125 -0
  170. data/lib/smith/workflow/split_step_persistence/recovery_boundary.rb +56 -0
  171. data/lib/smith/workflow/split_step_persistence/recovery_class_methods.rb +14 -0
  172. data/lib/smith/workflow/split_step_persistence/replace_exact_signature.rb +46 -0
  173. data/lib/smith/workflow/split_step_persistence/restart_safe_adapter.rb +42 -0
  174. data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +66 -0
  175. data/lib/smith/workflow/split_step_persistence/transaction_identity.rb +45 -0
  176. data/lib/smith/workflow/split_step_persistence.rb +50 -0
  177. data/lib/smith/workflow/step_completion.rb +31 -0
  178. data/lib/smith/workflow/step_context.rb +46 -0
  179. data/lib/smith/workflow/string_snapshot.rb +25 -0
  180. data/lib/smith/workflow/thread_context_snapshot.rb +103 -0
  181. data/lib/smith/workflow/transition.rb +137 -133
  182. data/lib/smith/workflow/transition_actionability.rb +22 -0
  183. data/lib/smith/workflow.rb +64 -18
  184. data/lib/smith.rb +44 -2
  185. data/sig/smith.rbs +1 -0
  186. metadata +155 -19
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class ExponentialBackoff
5
+ MAX_SLEEP_INTERVAL_SECONDS = 2_147_483_647.0
6
+ MAX_RANDOM_FACTOR = 1.0.prev_float
7
+
8
+ attr_reader :attempts, :base_delay, :max_delay, :jitter
9
+
10
+ def initialize(attempts:, base_delay:, max_delay:, jitter:, delay_label: "base_delay")
11
+ @attempt_limit = Smith.config.retry_attempt_limit
12
+ @attempts = validate_attempts!(attempts)
13
+ @base_delay = normalize_delay!(base_delay, delay_label)
14
+ @max_delay = normalize_optional_delay!(max_delay, "max_delay")
15
+ @jitter = normalize_delay!(jitter, "jitter")
16
+ validate_finite_schedule!
17
+ freeze
18
+ end
19
+
20
+ def delay(failed_attempt, random: Kernel.method(:rand))
21
+ validate_failed_attempt!(failed_attempt)
22
+
23
+ base = exponential_delay(failed_attempt - 1)
24
+ return base if jitter.zero? || max_delay == base
25
+
26
+ add_jitter(base, random.call)
27
+ end
28
+
29
+ private
30
+
31
+ def validate_attempts!(value)
32
+ raise ArgumentError, "attempts must be a positive integer" unless value.is_a?(Integer) && value.positive?
33
+ raise ArgumentError, "attempts must not exceed #{@attempt_limit}" if value > @attempt_limit
34
+
35
+ value
36
+ end
37
+
38
+ def normalize_optional_delay!(value, label)
39
+ return if value.nil?
40
+
41
+ normalize_delay!(value, label)
42
+ end
43
+
44
+ def normalize_delay!(value, label)
45
+ numeric = Float(value)
46
+ return numeric if numeric.finite? && numeric >= 0.0
47
+
48
+ raise ArgumentError, "#{label} must be finite and non-negative"
49
+ rescue TypeError, ArgumentError, RangeError
50
+ raise ArgumentError, "#{label} must be finite and non-negative"
51
+ end
52
+
53
+ def validate_finite_schedule!
54
+ return if attempts == 1
55
+
56
+ largest_delay = maximum_delay(attempts - 2)
57
+ return if largest_delay <= MAX_SLEEP_INTERVAL_SECONDS
58
+
59
+ raise ArgumentError, "retry delay exceeds supported sleep interval #{MAX_SLEEP_INTERVAL_SECONDS.to_i} seconds"
60
+ end
61
+
62
+ def validate_failed_attempt!(failed_attempt)
63
+ return if failed_attempt.is_a?(Integer) && failed_attempt.positive? && failed_attempt < attempts
64
+
65
+ raise ArgumentError, "failed_attempt must identify a retryable attempt"
66
+ end
67
+
68
+ def exponential_delay(exponent)
69
+ return 0.0 if base_delay.zero? || max_delay&.zero?
70
+ return uncapped_exponential_delay(exponent) unless max_delay
71
+
72
+ capped_exponential_delay(exponent)
73
+ end
74
+
75
+ def capped_exponential_delay(exponent)
76
+ base_fraction, base_exponent = Math.frexp(base_delay)
77
+ cap_fraction, cap_exponent = Math.frexp(max_delay)
78
+ scaled_exponent = base_exponent + exponent
79
+
80
+ return max_delay if scaled_exponent > cap_exponent
81
+ return max_delay if scaled_exponent == cap_exponent && base_fraction >= cap_fraction
82
+
83
+ [Math.ldexp(base_delay, exponent), max_delay].min
84
+ end
85
+
86
+ def uncapped_exponential_delay(exponent)
87
+ _fraction, base_exponent = Math.frexp(base_delay)
88
+ if base_exponent + exponent > Float::MAX_EXP
89
+ raise ArgumentError, "retry schedule exceeds the finite numeric range"
90
+ end
91
+
92
+ Math.ldexp(base_delay, exponent).tap do |value|
93
+ raise ArgumentError, "retry schedule exceeds the finite numeric range" unless value.finite?
94
+ end
95
+ end
96
+
97
+ def add_jitter(base, random_value)
98
+ factor = normalize_random_factor!(random_value)
99
+ sampled = jitter * factor
100
+ return max_delay if max_delay && sampled >= max_delay - base
101
+
102
+ base + sampled
103
+ end
104
+
105
+ def normalize_random_factor!(value)
106
+ factor = Float(value)
107
+ unless factor.finite? && factor >= 0.0 && factor < 1.0
108
+ raise ArgumentError, "random value must be finite and within 0.0...1.0"
109
+ end
110
+
111
+ factor
112
+ rescue TypeError, ArgumentError, RangeError
113
+ raise ArgumentError, "random value must be finite and within 0.0...1.0"
114
+ end
115
+
116
+ def maximum_delay(exponent)
117
+ base = exponential_delay(exponent)
118
+ return base if jitter.zero? || max_delay == base
119
+
120
+ sampled = jitter * MAX_RANDOM_FACTOR
121
+ return max_delay if max_delay && sampled >= max_delay - base
122
+
123
+ (base + sampled).tap do |value|
124
+ raise ArgumentError, "retry schedule exceeds the finite numeric range" unless value.finite?
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ module PersistenceAdapters
7
+ class ActiveRecordExactPredicate
8
+ extend Dry::Initializer
9
+
10
+ option :model
11
+ option :column
12
+
13
+ def call(scope, expected_payload)
14
+ scope.where(predicate, expected_payload)
15
+ end
16
+
17
+ private
18
+
19
+ def predicate
20
+ case connection.adapter_name.downcase
21
+ when /postgres/
22
+ "convert_to(#{qualified_column}, 'UTF8') = convert_to(?, 'UTF8')"
23
+ when /sqlite/
24
+ "CAST(#{qualified_column} AS BLOB) = CAST(? AS BLOB)"
25
+ when /mysql|trilogy/
26
+ "BINARY #{qualified_column} = BINARY ?"
27
+ else
28
+ raise ArgumentError,
29
+ "ActiveRecordStore#replace_exact does not support byte-exact payload comparison for " \
30
+ "#{connection.adapter_name.inspect}"
31
+ end
32
+ end
33
+
34
+ def qualified_column
35
+ table = connection.quote_table_name(model.table_name)
36
+ attribute = connection.quote_column_name(column)
37
+ "#{table}.#{attribute}"
38
+ end
39
+
40
+ def connection = model.connection
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module PersistenceAdapters
5
+ module ActiveRecordExactStore
6
+ attr_reader :persistence_identity
7
+
8
+ def replace_exact(key, payload, expected_payload:, ttl: nil) # rubocop:disable Lint/UnusedMethodArgument
9
+ ensure_version_column!
10
+ ensure_locking_configuration!
11
+ model = model_class
12
+ ensure_exact_schema!(model)
13
+ ActiveRecordExactWrite.new(
14
+ model: model,
15
+ key_column: @key_column,
16
+ payload_column: @payload_column,
17
+ version_column: @version_column,
18
+ key: key,
19
+ payload: payload,
20
+ expected_payload: expected_payload
21
+ ).call
22
+ rescue *ActiveRecordConnectionErrors.classes => e
23
+ raise Smith::PersistenceIOError.new(operation: :replace_exact, cause: e)
24
+ end
25
+
26
+ private
27
+
28
+ def ensure_exact_schema!(model)
29
+ ensure_exact_payload_column!(model)
30
+ ensure_exact_key_uniqueness!(model)
31
+ end
32
+
33
+ def ensure_exact_payload_column!(model)
34
+ column = model.columns_hash.fetch(@payload_column.to_s)
35
+ return if %i[string text].include?(column.type)
36
+
37
+ raise ArgumentError,
38
+ "ActiveRecordStore#replace_exact requires a text or string payload column on " \
39
+ "#{model.table_name}.#{@payload_column}"
40
+ end
41
+
42
+ def ensure_exact_key_uniqueness!(model)
43
+ connection = model.connection
44
+ schema_cache = connection.schema_cache
45
+ table_name = model.table_name
46
+ key = @key_column.to_s
47
+ return if schema_cache.primary_keys(table_name).to_s == key
48
+ return if unconditional_unique_key_index?(schema_cache, table_name, key)
49
+
50
+ raise ArgumentError,
51
+ "ActiveRecordStore#replace_exact requires a unique database index on " \
52
+ "#{table_name}.#{@key_column}"
53
+ end
54
+
55
+ def unconditional_unique_key_index?(schema_cache, table_name, key)
56
+ schema_cache.indexes(table_name).any? do |index|
57
+ index.unique && Array(index.columns).map(&:to_s) == [key] && index.where.to_s.empty?
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ require_relative "active_record_exact_predicate"
6
+
7
+ module Smith
8
+ module PersistenceAdapters
9
+ class ActiveRecordExactWrite
10
+ extend Dry::Initializer
11
+
12
+ option :model
13
+ option :key_column
14
+ option :payload_column
15
+ option :version_column
16
+ option :key
17
+ option :payload
18
+ option :expected_payload
19
+
20
+ def call
21
+ updated = exact_scope.update_all(update_attributes)
22
+ return payload if updated == 1
23
+
24
+ raise PersistencePayloadConflict.new(key:)
25
+ end
26
+
27
+ private
28
+
29
+ def exact_scope
30
+ scope = model.where(key_column => key)
31
+ ActiveRecordExactPredicate.new(model:, column: payload_column).call(scope, expected_payload)
32
+ end
33
+
34
+ def update_attributes
35
+ lock = model.arel_table[version_column]
36
+ { payload_column => payload, version_column => lock + 1 }
37
+ end
38
+ end
39
+ end
40
+ end
@@ -3,14 +3,17 @@
3
3
  module Smith
4
4
  module PersistenceAdapters
5
5
  class ActiveRecordStore
6
+ include ActiveRecordExactStore
7
+
6
8
  # AR transient errors resolved via class-name guard so Smith
7
9
  # doesn't require activerecord at load time. Hosts that use this
8
10
  # adapter already have activerecord in their dep tree.
9
- def initialize(model:, key_column: :key, payload_column: :payload, version_column: :lock_version)
11
+ def initialize(model:, key_column: :key, payload_column: :payload, version_column: :lock_version, identity: nil)
10
12
  @model_source = model.is_a?(String) ? model.dup.freeze : model
11
13
  @key_column = normalize_column(key_column)
12
14
  @payload_column = normalize_column(payload_column)
13
15
  @version_column = normalize_column(version_column)
16
+ @persistence_identity = identity.to_s.dup.freeze if identity
14
17
  end
15
18
 
16
19
  def store(key, payload, ttl: nil) # rubocop:disable Lint/UnusedMethodArgument
@@ -36,9 +39,9 @@ module Smith
36
39
  end
37
40
  end
38
41
 
39
- def transaction_open?
40
- model_class.connection.transaction_open?
41
- end
42
+ def transaction_open? = model_class.connection.transaction_open?
43
+
44
+ def transaction_identity = model_class.respond_to?(:current_transaction) && model_class.current_transaction.uuid
42
45
 
43
46
  # Optimistic locking via Rails' built-in optimistic locking on the
44
47
  # `lock_version` column. Requires the AR model to have a
@@ -43,6 +43,7 @@ module Smith
43
43
  end
44
44
 
45
45
  def transaction_open? = false
46
+ def transaction_identity = nil
46
47
 
47
48
  def backend_name
48
49
  backend.class.name || backend.class.to_s
@@ -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
@@ -44,6 +48,7 @@ module Smith
44
48
  end
45
49
 
46
50
  def transaction_open? = false
51
+ def transaction_identity = nil
47
52
 
48
53
  def record_heartbeat(key, ttl: Smith.config.persistence_ttl)
49
54
  @monitor.synchronize do
@@ -88,6 +93,16 @@ module Smith
88
93
  end
89
94
  end
90
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)
102
+ end
103
+ payload
104
+ end
105
+
91
106
  def clear!
92
107
  @monitor.synchronize { @store.clear }
93
108
  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
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)
@@ -52,6 +60,7 @@ module Smith
52
60
  end
53
61
 
54
62
  def transaction_open? = false
63
+ def transaction_identity = nil
55
64
 
56
65
  def record_heartbeat(key, ttl: Smith.config.persistence_ttl)
57
66
  Retry.with_retries(operation: :record_heartbeat, transient: self.class.transient_errors) do
@@ -79,9 +88,9 @@ module Smith
79
88
  # Smith::PersistenceVersionConflict on a stale expected_version
80
89
  # OR on EXEC failure (WATCH detected concurrent write).
81
90
  def store_versioned(key, payload, expected_version:, ttl: Smith.config.persistence_ttl)
82
- Retry.with_retries(operation: :store_versioned, transient: self.class.transient_errors) do
91
+ without_reconnection do |redis|
83
92
  RedisVersionedWrite.new(
84
- client: client,
93
+ client: redis,
85
94
  key: key,
86
95
  storage_key: namespaced(key),
87
96
  payload: payload,
@@ -89,19 +98,27 @@ module Smith
89
98
  ttl: ttl
90
99
  ).call
91
100
  end
101
+ rescue *self.class.transient_errors => e
102
+ raise Smith::PersistenceIOError.new(operation: :store_versioned, cause: e)
92
103
  end
93
104
 
94
- private
95
-
96
- def client
97
- @client ||= begin
98
- resolved = @redis_source.respond_to?(:call) ? @redis_source.call : @redis_source
99
- raise ArgumentError, "Redis client is required" unless resolved
100
-
101
- 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
102
115
  end
116
+ rescue *self.class.transient_errors => e
117
+ raise Smith::PersistenceIOError.new(operation: :replace_exact, cause: e)
103
118
  end
104
119
 
120
+ private
121
+
105
122
  def namespaced(key)
106
123
  [@namespace, key].compact.join(":")
107
124
  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
@@ -3,11 +3,14 @@
3
3
  require "monitor"
4
4
  require_relative "persistence_adapters/active_record_connection_errors"
5
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"
6
8
  require_relative "persistence_adapters/payload_version"
7
9
  require_relative "persistence_adapters/version_expectation"
8
10
  require_relative "persistence_adapters/cache_store"
9
11
  require_relative "persistence_adapters/rails_cache"
10
12
  require_relative "persistence_adapters/redis_versioned_write"
13
+ require_relative "persistence_adapters/redis_exact_write"
11
14
  require_relative "persistence_adapters/redis_store"
12
15
  require_relative "persistence_adapters/active_record_store"
13
16
  require_relative "persistence_adapters/memory"
@@ -28,7 +31,10 @@ module Smith
28
31
  # check support via `supports?(adapter, capability)` and fall back
29
32
  # gracefully (e.g., Workflow#persist! warns once and uses plain
30
33
  # `store` when `store_versioned` is missing).
31
- OPTIONAL_METHODS = %i[store_versioned record_heartbeat last_heartbeat transaction_open?].freeze
34
+ OPTIONAL_METHODS = %i[
35
+ store_versioned replace_exact persistence_identity record_heartbeat last_heartbeat
36
+ transaction_open? transaction_identity
37
+ ].freeze
32
38
 
33
39
  def self.resolve(adapter, **options)
34
40
  return nil if adapter.nil?
@@ -39,22 +45,7 @@ module Smith
39
45
  return validate!(instance)
40
46
  end
41
47
 
42
- built_in = case adapter.to_sym
43
- when :cache_store
44
- CacheStore.new(**options)
45
- when :rails_cache, :solid_cache
46
- RailsCache.new(**options)
47
- when :redis
48
- RedisStore.new(**options)
49
- when :active_record
50
- ActiveRecordStore.new(**options)
51
- when :memory
52
- Memory.new
53
- else
54
- raise ArgumentError, "Unknown persistence adapter #{adapter.inspect}"
55
- end
56
-
57
- validate!(built_in)
48
+ validate!(build(adapter, options))
58
49
  end
59
50
 
60
51
  def self.adapter_like?(adapter)
@@ -65,9 +56,21 @@ module Smith
65
56
  return adapter if adapter_like?(adapter)
66
57
 
67
58
  missing = REQUIRED_METHODS.reject { |method_name| adapter.respond_to?(method_name) }
68
- raise ArgumentError, "Persistence adapter must implement #{missing.join(', ')}"
59
+ raise ArgumentError, "Persistence adapter must implement #{missing.join(", ")}"
69
60
  end
70
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
+
71
74
  # Capability introspection used by Workflow#persist! to decide
72
75
  # whether the adapter supports optimistic locking via store_versioned.
73
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