smith-agents 0.4.3 → 0.4.5

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +87 -0
  3. data/README.md +8 -2
  4. data/docs/PERSISTENCE.md +180 -1
  5. data/docs/workflow_claim.md +4 -1
  6. data/lib/smith/agent/lifecycle.rb +30 -10
  7. data/lib/smith/doctor/checks/persistence_capabilities.rb +1 -0
  8. data/lib/smith/persistence_adapters/active_record_connection_errors.rb +23 -0
  9. data/lib/smith/persistence_adapters/active_record_initial_write.rb +42 -0
  10. data/lib/smith/persistence_adapters/active_record_store.rb +91 -51
  11. data/lib/smith/persistence_adapters/cache_store.rb +3 -1
  12. data/lib/smith/persistence_adapters/memory.rb +29 -13
  13. data/lib/smith/persistence_adapters/payload_version.rb +23 -0
  14. data/lib/smith/persistence_adapters/redis_store.rb +11 -33
  15. data/lib/smith/persistence_adapters/redis_versioned_write.rb +56 -0
  16. data/lib/smith/persistence_adapters/version_expectation.rb +19 -0
  17. data/lib/smith/persistence_adapters.rb +6 -1
  18. data/lib/smith/version.rb +1 -1
  19. data/lib/smith/workflow/durability.rb +1 -0
  20. data/lib/smith/workflow/persistence.rb +12 -2
  21. data/lib/smith/workflow/split_step_persistence/boundary.rb +115 -0
  22. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +121 -0
  23. data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +51 -0
  24. data/lib/smith/workflow/split_step_persistence/execution.rb +126 -0
  25. data/lib/smith/workflow/split_step_persistence/inheritance.rb +20 -0
  26. data/lib/smith/workflow/split_step_persistence/payloads.rb +50 -0
  27. data/lib/smith/workflow/split_step_persistence/preparation.rb +97 -0
  28. data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +91 -0
  29. data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +46 -0
  30. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +84 -0
  31. data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +52 -0
  32. data/lib/smith/workflow/split_step_persistence/transition_contract.rb +48 -0
  33. data/lib/smith/workflow/split_step_persistence/transition_contract_freezer.rb +83 -0
  34. data/lib/smith/workflow/split_step_persistence/transition_contract_signature.rb +107 -0
  35. data/lib/smith/workflow/split_step_persistence/transition_contract_structured_values.rb +51 -0
  36. data/lib/smith/workflow/split_step_persistence.rb +43 -0
  37. data/lib/smith/workflow.rb +13 -1
  38. data/lib/smith.rb +1 -0
  39. metadata +22 -1
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module Execution
7
+ def execute_prepared_step!
8
+ execution_started = false
9
+ step = nil
10
+ claim_split_step_execution_verification!
11
+ begin
12
+ verify_split_step_preparation_available!
13
+ activate_split_step_execution!
14
+ execution_started = true
15
+ step = execute_claimed_split_step_transition!
16
+ ensure
17
+ execution_started ? finish_split_step_execution!(step) : restore_unverified_execution!
18
+ end
19
+ step
20
+ end
21
+
22
+ def prepared_persisted_step? = @split_step_mutex.synchronize { @split_step_phase == :prepared }
23
+
24
+ private
25
+
26
+ def claim_split_step_execution_verification!
27
+ @split_step_mutex.synchronize do
28
+ raise WorkflowError, "no persisted step is prepared" unless @split_step_phase == :prepared
29
+ raise WorkflowError, "the prepared transition no longer matches the workflow" unless
30
+ prepared_split_step_transition_matches?
31
+
32
+ @split_step_phase = :verifying_execution
33
+ end
34
+ end
35
+
36
+ def prepared_split_step_transition_matches?
37
+ transition = pending_split_step_transition
38
+ transition_name = @next_transition_name || transition&.name
39
+ transition_name == @split_step_transition_name &&
40
+ transition.equal?(@split_step_transition) &&
41
+ split_step_transition_signature(transition) == @split_step_transition_signature
42
+ end
43
+
44
+ def activate_split_step_execution!
45
+ @split_step_mutex.synchronize do
46
+ unless @split_step_phase == :verifying_execution
47
+ raise WorkflowError, "the prepared execution claim is no longer active"
48
+ end
49
+
50
+ @split_step_phase = :executing
51
+ @split_step_execution_thread = Thread.current
52
+ @split_step_advance_permit = true
53
+ end
54
+ end
55
+
56
+ def restore_unverified_execution!
57
+ @split_step_mutex.synchronize do
58
+ @split_step_phase = :prepared if @split_step_phase == :verifying_execution
59
+ end
60
+ end
61
+
62
+ def verify_split_step_preparation_available!
63
+ payload = @split_step_adapter.fetch(@split_step_persistence_key)
64
+ durable = persisted_split_step_payload?(payload, @split_step_preparation_payload)
65
+ live = persisted_split_step_payload?(current_split_step_preparation_payload, @split_step_preparation_payload)
66
+ stable = split_step_transition_signature(@split_step_transition) == @split_step_transition_signature
67
+ return if durable && live && stable
68
+
69
+ raise WorkflowError, "the persisted split-step preparation is no longer available"
70
+ end
71
+
72
+ def current_split_step_preparation_payload
73
+ split_step_preparation_payload(
74
+ JSON.generate(to_state.merge(persistence_version: @persistence_version))
75
+ )
76
+ end
77
+
78
+ def execute_claimed_split_step_transition!
79
+ step = Workflow.instance_method(:advance!).bind_call(self)
80
+ return step if accepted_split_step_result?(step)
81
+
82
+ raise WorkflowError, "prepared execution did not return the claimed transition"
83
+ end
84
+
85
+ def finish_split_step_execution!(step)
86
+ @split_step_mutex.synchronize do
87
+ @split_step_execution_thread = nil
88
+ @split_step_advance_permit = false
89
+ @split_step_phase = step ? :executed : :attempted
90
+ end
91
+ end
92
+
93
+ def resolve_split_step_advance_transition
94
+ @split_step_mutex.synchronize do
95
+ unless @split_step_phase == :executing && @split_step_execution_thread.equal?(Thread.current)
96
+ return NO_SPLIT_TRANSITION
97
+ end
98
+
99
+ name = @next_transition_name
100
+ @next_transition_name = nil if name
101
+ raise UnresolvedTransitionError.new(name, self.class, @state) if name && @split_step_transition.nil?
102
+
103
+ @split_step_transition
104
+ end
105
+ end
106
+
107
+ def accepted_split_step_result?(step)
108
+ return false unless step.is_a?(Hash)
109
+ return false unless step[:transition] == @split_step_transition_name
110
+ return false unless step[:from] == @split_step_origin_state
111
+
112
+ step[:error] ? accepted_split_step_failure? : step.key?(:to) && @state == step[:to]
113
+ end
114
+
115
+ def accepted_split_step_failure?
116
+ failure_name = @split_step_transition&.failure_transition || :fail
117
+ failure_transition = self.class.find_transition(failure_name)
118
+ return false unless failure_transition
119
+
120
+ @state == failure_transition.to ||
121
+ (@state == @split_step_origin_state && @next_transition_name == failure_name)
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module Inheritance
7
+ def inherited(subclass)
8
+ super
9
+ subclass.prepend(SubclassBoundary)
10
+ end
11
+
12
+ def prepend(*features)
13
+ result = super
14
+ Module.instance_method(:prepend).bind_call(self, SubclassBoundary.guard)
15
+ result
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module Payloads
7
+ private
8
+
9
+ def persisted_split_step_payload?(payload, expected)
10
+ return false unless payload
11
+
12
+ JSON.parse(payload) == JSON.parse(expected)
13
+ rescue JSON::ParserError, TypeError
14
+ false
15
+ end
16
+
17
+ def persisted_split_step_checkpoint?(payload)
18
+ return false unless payload && @split_step_checkpoint_digest
19
+
20
+ @split_step_checkpoint_digest == Digest::SHA256.hexdigest(payload)
21
+ end
22
+
23
+ def validate_split_step_marker!(payload, expected:)
24
+ marker = JSON.parse(payload).fetch("step_in_progress")
25
+ return if marker == expected
26
+
27
+ raise WorkflowError, "split-step persistence serialized an invalid step_in_progress marker"
28
+ rescue JSON::ParserError, KeyError, TypeError
29
+ raise WorkflowError, "split-step persistence requires an explicit step_in_progress marker"
30
+ end
31
+
32
+ def split_step_checkpoint_payload(payload)
33
+ document = JSON.parse(payload)
34
+ document["step_in_progress"] = false
35
+ JSON.generate(document)
36
+ end
37
+
38
+ def split_step_preparation_payload(payload)
39
+ document = JSON.parse(payload)
40
+ document["split_step_token"] = @split_step_token
41
+ JSON.generate(document)
42
+ end
43
+
44
+ def split_step_transition_signature(transition)
45
+ transition && TransitionContract.signature(transition)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module Preparation
7
+ def prepare_persisted_step!(key = nil, adapter: Smith.persistence_adapter)
8
+ ensure_strict_split_step_persistence!
9
+ intent_claimed = claim_split_step_preparation_intent!
10
+ return unless intent_claimed
11
+
12
+ preparation_finalized = false
13
+ prepare_claimed_persisted_step!(key, adapter)
14
+ preparation_finalized = true
15
+ persist_claimed_split_step_preparation!(@split_step_persistence_key, @split_step_adapter)
16
+ @split_step_transition_name
17
+ ensure
18
+ release_split_step_preparation_intent! if intent_claimed && !preparation_finalized
19
+ end
20
+
21
+ def confirm_prepared_step!
22
+ confirmation_claimed = false
23
+ claim_split_step_confirmation!
24
+ confirmation_claimed = true
25
+ confirmed = false
26
+ payload = @split_step_adapter.fetch(@split_step_persistence_key)
27
+ raise WorkflowError, "the persisted split-step preparation is not committed" unless
28
+ persisted_split_step_payload?(payload, @split_step_preparation_payload)
29
+
30
+ @split_step_mutex.synchronize { @split_step_phase = :prepared }
31
+ confirmed = true
32
+ self
33
+ ensure
34
+ restore_unconfirmed_preparation! if confirmation_claimed && !confirmed
35
+ end
36
+
37
+ private
38
+
39
+ def ensure_strict_split_step_persistence!
40
+ return if strict_idempotency?
41
+
42
+ raise WorkflowError, "split-step persistence requires idempotency_mode :strict"
43
+ end
44
+
45
+ def prepare_claimed_persisted_step!(key, adapter)
46
+ transition = pending_split_step_transition
47
+ transition_name = @next_transition_name || transition&.name
48
+ raise WorkflowError, "workflow has no pending transition" unless transition_name
49
+
50
+ ensure_transition_budget!
51
+ resolved_key = normalize_split_step_persistence_key(candidate_split_step_persistence_key(key))
52
+ store = persistence_adapter!(adapter)
53
+ persistence_ttl = validate_split_step_adapter!(store)
54
+ finalize_split_step_preparation!(transition, transition_name, resolved_key, store, persistence_ttl)
55
+ end
56
+
57
+ def pending_split_step_transition
58
+ transition = if @next_transition_name
59
+ self.class.find_transition(@next_transition_name)
60
+ else
61
+ self.class.transitions_from(@state).first
62
+ end
63
+ validate_transition_origin!(transition) if transition
64
+ transition
65
+ end
66
+
67
+ def candidate_split_step_persistence_key(key)
68
+ return key unless key.nil? || blank_key?(key)
69
+ return @persistence_key unless blank_key?(@persistence_key)
70
+
71
+ self.class.send(:resolve_persistence_key!, key:, context: @context)
72
+ end
73
+
74
+ def normalize_split_step_persistence_key(key) = key.to_s.dup.freeze
75
+
76
+ def validate_split_step_adapter!(adapter)
77
+ unless Smith::PersistenceAdapters.supports?(adapter, :store_versioned)
78
+ raise WorkflowError, "split-step persistence requires an adapter with store_versioned"
79
+ end
80
+
81
+ unless split_step_adapter_accepts_explicit_ttl?(adapter)
82
+ raise WorkflowError, "split-step persistence requires store_versioned to accept ttl:"
83
+ end
84
+ return unless effective_persistence_ttl
85
+
86
+ raise WorkflowError, "split-step persistence requires non-expiring workflow persistence"
87
+ end
88
+
89
+ def split_step_adapter_accepts_explicit_ttl?(adapter)
90
+ adapter.method(:store_versioned).parameters.any? do |kind, name|
91
+ kind == :keyrest || (%i[key keyreq].include?(kind) && name == :ttl)
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module PreparationClaim
7
+ private
8
+
9
+ def claim_split_step_preparation_intent!
10
+ @split_step_mutex.synchronize do
11
+ ensure_no_split_step_boundary!
12
+ return false if terminal?
13
+
14
+ @split_step_phase = :claiming_preparation
15
+ @split_step_preparation_thread = Thread.current
16
+ true
17
+ end
18
+ end
19
+
20
+ def release_split_step_preparation_intent!
21
+ @split_step_mutex.synchronize do
22
+ next unless @split_step_phase == :claiming_preparation
23
+ next unless @split_step_preparation_thread.equal?(Thread.current)
24
+
25
+ @split_step_phase = nil
26
+ @split_step_preparation_thread = nil
27
+ end
28
+ end
29
+
30
+ def finalize_split_step_preparation!(transition, transition_name, key, adapter, persistence_ttl)
31
+ transition_signature = TransitionContract.capture(transition)
32
+ @split_step_mutex.synchronize do
33
+ unless active_split_step_preparation_claim?
34
+ raise WorkflowError, "the split-step preparation claim is no longer active"
35
+ end
36
+
37
+ @split_step_phase = :preparing
38
+ @split_step_transition_name = transition_name
39
+ @split_step_transition = transition
40
+ @split_step_transition_signature = transition_signature
41
+ @split_step_origin_state = @state
42
+ @split_step_token = SecureRandom.uuid.freeze
43
+ detach_split_step_execution_state!
44
+ @split_step_persistence_key = key
45
+ @split_step_adapter = adapter
46
+ @split_step_persistence_ttl = persistence_ttl
47
+ @persistence_key = key
48
+ @split_step_persist_permit = true
49
+ end
50
+ end
51
+
52
+ def active_split_step_preparation_claim?
53
+ @split_step_phase == :claiming_preparation &&
54
+ @split_step_preparation_thread.equal?(Thread.current)
55
+ end
56
+
57
+ def mark_split_step_prepared!(adapter)
58
+ @split_step_mutex.synchronize do
59
+ @split_step_phase = if Smith::PersistenceAdapters.supports?(adapter, :transaction_open?) &&
60
+ adapter.transaction_open?
61
+ :prepared_uncommitted
62
+ else
63
+ :prepared
64
+ end
65
+ @split_step_preparation_thread = nil
66
+ @split_step_persist_permit = false
67
+ end
68
+ end
69
+
70
+ def claim_split_step_confirmation!
71
+ @split_step_mutex.synchronize do
72
+ unless @split_step_phase == :prepared_uncommitted
73
+ raise WorkflowError, "no uncommitted split-step preparation is awaiting confirmation"
74
+ end
75
+ if @split_step_adapter.transaction_open?
76
+ raise WorkflowError, "the split-step preparation transaction is still open"
77
+ end
78
+
79
+ @split_step_phase = :confirming_preparation
80
+ end
81
+ end
82
+
83
+ def restore_unconfirmed_preparation!
84
+ @split_step_mutex.synchronize do
85
+ @split_step_phase = :prepared_uncommitted if @split_step_phase == :confirming_preparation
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module PreparationRecovery
7
+ private
8
+
9
+ def persist_claimed_split_step_preparation!(resolved_key, store)
10
+ persist!(resolved_key, adapter: store)
11
+ mark_split_step_prepared!(store)
12
+ rescue PersistenceVersionConflict
13
+ handle_split_step_preparation_conflict!
14
+ raise
15
+ rescue StandardError
16
+ mark_split_step_preparation_unknown!
17
+ raise
18
+ end
19
+
20
+ def reset_failed_split_step_preparation!
21
+ return unless @split_step_phase == :preparing
22
+
23
+ clear_step_in_progress!
24
+ clear_split_step_boundary!
25
+ end
26
+
27
+ def handle_split_step_preparation_conflict!
28
+ payload = @split_step_adapter.fetch(@split_step_persistence_key)
29
+ if persisted_split_step_payload?(payload, @split_step_preparation_payload)
30
+ mark_split_step_preparation_unknown!
31
+ else
32
+ reset_failed_split_step_preparation!
33
+ end
34
+ rescue StandardError
35
+ mark_split_step_preparation_unknown!
36
+ end
37
+
38
+ def mark_split_step_preparation_unknown!
39
+ @split_step_mutex.synchronize do
40
+ @split_step_phase = :preparation_unknown if @split_step_phase == :preparing
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module StateSnapshot
7
+ def to_state
8
+ state = super
9
+ return state unless split_step_boundary_active?
10
+
11
+ snapshot_value(state)
12
+ end
13
+
14
+ def session_messages
15
+ return super unless split_step_boundary_active?
16
+
17
+ snapshot_session_messages
18
+ end
19
+
20
+ def ledger
21
+ return super unless split_step_boundary_active?
22
+ return unless @ledger
23
+
24
+ snapshot_split_step_ledger
25
+ end
26
+
27
+ private
28
+
29
+ def effective_persistence_ttl
30
+ return super unless split_step_boundary_active?
31
+ return super unless instance_variable_defined?(:@split_step_persistence_ttl)
32
+
33
+ @split_step_persistence_ttl
34
+ end
35
+
36
+ def ttl_kwarg(ttl)
37
+ return super unless split_step_boundary_active?
38
+ return super unless instance_variable_defined?(:@split_step_persistence_ttl)
39
+
40
+ { ttl: ttl }
41
+ end
42
+
43
+ def detach_split_step_execution_state!
44
+ detach_split_step_collections!
45
+ detach_split_step_results!
46
+ detach_split_step_metadata!
47
+ @ledger = snapshot_split_step_ledger if @ledger
48
+ end
49
+
50
+ def detach_split_step_collections!
51
+ @context = snapshot_context
52
+ @session_messages = snapshot_session_messages
53
+ @tool_results = snapshot_tool_results
54
+ @outcome = snapshot_outcome
55
+ @usage_entries = snapshot_usage_entries
56
+ @persisted_keys = Set.new(snapshot_value((@persisted_keys || Set.new).to_a))
57
+ end
58
+
59
+ def detach_split_step_results!
60
+ @last_output = snapshot_value(@last_output)
61
+ @last_failed_step = snapshot_value(@last_failed_step)
62
+ @last_prepared_input = snapshot_value(@last_prepared_input)
63
+ end
64
+
65
+ def detach_split_step_metadata!
66
+ @created_at = snapshot_value(@created_at)
67
+ @updated_at = snapshot_value(@updated_at)
68
+ @execution_namespace = snapshot_value(@execution_namespace)
69
+ @seed_digest = snapshot_value(@seed_digest)
70
+ @router_next_transition = snapshot_value(@router_next_transition)
71
+ end
72
+
73
+ def snapshot_split_step_ledger
74
+ Budget::Ledger.new(
75
+ limits: snapshot_value(@ledger.limits),
76
+ consumed: snapshot_value(@ledger.consumed)
77
+ )
78
+ end
79
+
80
+ def split_step_boundary_active? = !@split_step_phase.nil?
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module SubclassBoundary
7
+ def self.guard
8
+ Module.new.tap do |guard|
9
+ instance_methods(false).each do |method_name|
10
+ guard.define_method(method_name, instance_method(method_name))
11
+ end
12
+ private_instance_methods(false).each do |method_name|
13
+ guard.define_method(method_name, instance_method(method_name))
14
+ guard.send(:private, method_name)
15
+ end
16
+ end
17
+ end
18
+
19
+ def to_state
20
+ state = super
21
+ split_step_boundary_active? ? snapshot_value(state) : state
22
+ end
23
+
24
+ def advance!
25
+ SplitStepPersistence.instance_method(:guard_split_step_subclass_execution!).bind_call(self)
26
+ super
27
+ end
28
+
29
+ def run!
30
+ SplitStepPersistence.instance_method(:guard_split_step_subclass_execution!).bind_call(self)
31
+ super
32
+ end
33
+
34
+ private
35
+
36
+ def effective_persistence_ttl
37
+ return super unless split_step_boundary_active?
38
+ return super unless instance_variable_defined?(:@split_step_persistence_ttl)
39
+
40
+ @split_step_persistence_ttl
41
+ end
42
+
43
+ def ttl_kwarg(ttl)
44
+ return super unless split_step_boundary_active?
45
+ return super unless instance_variable_defined?(:@split_step_persistence_ttl)
46
+
47
+ { ttl: ttl }
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Workflow
7
+ module SplitStepPersistence
8
+ class TransitionContract
9
+ extend Dry::Initializer
10
+
11
+ MAX_DEPTH = 128
12
+ MAX_NODES = 10_000
13
+ MAX_BYTES = 4 * 1024 * 1024
14
+
15
+ param :transition
16
+
17
+ def self.capture(transition) = new(transition).capture
18
+ def self.signature(transition) = new(transition).signature
19
+
20
+ def capture
21
+ captured = signature
22
+ freeze_value(transition)
23
+ freeze_value(captured)
24
+ captured
25
+ end
26
+
27
+ def signature
28
+ TransitionContractSignature.new(
29
+ value: transition,
30
+ max_depth: MAX_DEPTH,
31
+ max_nodes: MAX_NODES,
32
+ max_bytes: MAX_BYTES
33
+ ).call
34
+ end
35
+
36
+ private
37
+
38
+ def freeze_value(value)
39
+ TransitionContractFreezer.new(
40
+ value: value,
41
+ max_depth: MAX_DEPTH,
42
+ max_nodes: MAX_NODES
43
+ ).call
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end