smith-agents 0.8.0 → 0.11.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +433 -0
  3. data/README.md +16 -6
  4. data/docs/CONFIGURATION.md +26 -6
  5. data/docs/PATTERNS.md +59 -0
  6. data/docs/PERSISTENCE.md +5 -1
  7. data/lib/smith/agent/completion_usage_recording.rb +21 -3
  8. data/lib/smith/agent/fallback_configuration.rb +26 -3
  9. data/lib/smith/agent/invocation_preparation.rb +24 -0
  10. data/lib/smith/agent/lifecycle.rb +4 -2
  11. data/lib/smith/agent/provider_attempt.rb +17 -4
  12. data/lib/smith/agent/provider_call_timing.rb +129 -0
  13. data/lib/smith/agent/provider_completion.rb +26 -8
  14. data/lib/smith/agent/provider_failure_handling.rb +2 -2
  15. data/lib/smith/agent/provider_usage.rb +6 -0
  16. data/lib/smith/agent/usage_entry_recording.rb +27 -4
  17. data/lib/smith/agent/usage_traces.rb +49 -0
  18. data/lib/smith/agent/usage_tracking.rb +26 -20
  19. data/lib/smith/agent.rb +9 -1
  20. data/lib/smith/attribution.rb +115 -0
  21. data/lib/smith/budget/amount_representation.rb +20 -0
  22. data/lib/smith/budget/ledger.rb +6 -2
  23. data/lib/smith/context/state_injection.rb +12 -2
  24. data/lib/smith/diagnostic_text.rb +10 -1
  25. data/lib/smith/doctor/checks/durability.rb +16 -0
  26. data/lib/smith/doctor/checks/persistence_capabilities.rb +13 -7
  27. data/lib/smith/errors.rb +55 -4
  28. data/lib/smith/event.rb +10 -2
  29. data/lib/smith/events/bus.rb +63 -5
  30. data/lib/smith/events/step_completed.rb +3 -0
  31. data/lib/smith/events/step_failed.rb +25 -0
  32. data/lib/smith/events/subscription.rb +8 -0
  33. data/lib/smith/persistence_adapters/active_record_exact_store.rb +8 -3
  34. data/lib/smith/persistence_adapters/active_record_store.rb +4 -7
  35. data/lib/smith/persistence_adapters.rb +10 -0
  36. data/lib/smith/provider_permanent_failure.rb +44 -0
  37. data/lib/smith/tool/capture.rb +9 -1
  38. data/lib/smith/tool.rb +10 -1
  39. data/lib/smith/trace/logger.rb +1 -0
  40. data/lib/smith/trace/memory.rb +40 -4
  41. data/lib/smith/trace/open_telemetry.rb +47 -3
  42. data/lib/smith/trace.rb +24 -5
  43. data/lib/smith/version.rb +1 -1
  44. data/lib/smith/workflow/budget_integration.rb +3 -3
  45. data/lib/smith/workflow/composite/effects.rb +64 -33
  46. data/lib/smith/workflow/deterministic_execution.rb +3 -2
  47. data/lib/smith/workflow/deterministic_step.rb +15 -2
  48. data/lib/smith/workflow/dsl.rb +40 -6
  49. data/lib/smith/workflow/durability.rb +1 -1
  50. data/lib/smith/workflow/evaluator_optimizer.rb +111 -11
  51. data/lib/smith/workflow/event_integration.rb +48 -1
  52. data/lib/smith/workflow/execution.rb +10 -0
  53. data/lib/smith/workflow/failure_reconstructor.rb +18 -2
  54. data/lib/smith/workflow/failure_record.rb +6 -11
  55. data/lib/smith/workflow/failure_record_validator.rb +23 -3
  56. data/lib/smith/workflow/fanout_execution.rb +1 -1
  57. data/lib/smith/workflow/guardrail_integration.rb +27 -0
  58. data/lib/smith/workflow/optimization_state.rb +2 -2
  59. data/lib/smith/workflow/parallel_execution.rb +6 -0
  60. data/lib/smith/workflow/persistence.rb +55 -6
  61. data/lib/smith/workflow/prepared_branch_execution.rb +13 -4
  62. data/lib/smith/workflow/retry_execution.rb +4 -0
  63. data/lib/smith/workflow/run_result.rb +10 -0
  64. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +9 -1
  65. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
  66. data/lib/smith/workflow/step_completion.rb +13 -0
  67. data/lib/smith/workflow/step_context.rb +46 -0
  68. data/lib/smith/workflow/thread_context_snapshot.rb +1 -0
  69. data/lib/smith/workflow/usage_entry.rb +32 -4
  70. data/lib/smith/workflow.rb +30 -3
  71. data/lib/smith.rb +9 -4
  72. metadata +5 -1
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ # Ambient execution attribution: an immutable, thread-local description of
5
+ # where execution currently is (run identity, transition, fan-out branch,
6
+ # optimizer round). Workflow execution scopes install it; observability
7
+ # consumers (traces, events, usage recording) read it, so emitted facts
8
+ # carry correlation without any consumer knowing about the workflow.
9
+ #
10
+ # Attribution values are opaque identifiers, never content: the trace
11
+ # content policy does not treat them as payload. `execution_key` defaults
12
+ # to the workflow's persistence key during persisted runs; hosts running
13
+ # non-persisted workflows can seed an outer scope explicitly:
14
+ #
15
+ # Smith::Attribution.with(execution_key: "host-run-42") { workflow.run! }
16
+ #
17
+ # Installation inside workflow internals is a plain assignment
18
+ # (Attribution.install) because restoration there is owned by the
19
+ # surrounding ThreadContextSnapshot, which tracks the attribution thread
20
+ # key alongside the other per-step thread state.
21
+ module Attribution
22
+ THREAD_KEY = :smith_attribution
23
+
24
+ Context = Data.define(:execution_key, :transition, :from, :to, :branch_key, :round, :workflow) do
25
+ # Overlay semantics: nil overrides are ignored so an inner scope can
26
+ # only add or replace attribution, never blank an outer value.
27
+ def merge(**overrides)
28
+ filtered = overrides.compact
29
+ return self if filtered.empty?
30
+
31
+ override(**filtered)
32
+ end
33
+
34
+ # Replacement semantics: sets the given fields verbatim, nil included,
35
+ # so a scope that owns a field can reset it (a step whose transition
36
+ # declares no `from` must not inherit an enclosing step's `from`).
37
+ # Built on to_h, not Data#with, which requires Ruby 3.3 while the gem
38
+ # supports 3.2.
39
+ def override(**fields)
40
+ self.class.new(**to_h, **fields)
41
+ end
42
+
43
+ def to_fields
44
+ {
45
+ execution_key: execution_key,
46
+ transition: transition,
47
+ from: from,
48
+ to: to,
49
+ branch_key: branch_key,
50
+ round: round,
51
+ workflow: workflow
52
+ }.compact
53
+ end
54
+ end
55
+
56
+ EMPTY = Context.new(
57
+ execution_key: nil, transition: nil, from: nil, to: nil, branch_key: nil, round: nil, workflow: nil
58
+ )
59
+
60
+ class << self
61
+ def current
62
+ Thread.current[THREAD_KEY]
63
+ end
64
+
65
+ def ambient
66
+ current || EMPTY
67
+ end
68
+
69
+ # The compacted attribution fields, for merging into emitted payloads.
70
+ def current_fields
71
+ context = current
72
+ context ? context.to_fields : {}
73
+ end
74
+
75
+ # Plain installation with no restoration: callers own restoration,
76
+ # either through ThreadContextSnapshot (workflow internals) or an
77
+ # enclosing #with / #carrying block.
78
+ def install(context)
79
+ Thread.current[THREAD_KEY] = context
80
+ end
81
+
82
+ # Host-facing scope: overlays the ambient attribution for the block.
83
+ def with(**overrides, &block)
84
+ raise ArgumentError, "block required" unless block
85
+
86
+ swap(ambient.merge(**overrides), &block)
87
+ end
88
+
89
+ # Cross-thread propagation: installs a context captured on another
90
+ # thread (or nil, clearing any stale value on a pooled thread) for the
91
+ # duration of the block.
92
+ def carrying(context, &block)
93
+ raise ArgumentError, "block required" unless block
94
+
95
+ swap(context, &block)
96
+ end
97
+
98
+ private
99
+
100
+ # Matches the gem's scoped thread-state idiom: install and restore run
101
+ # interrupt-deferred, the block itself runs interruptible.
102
+ def swap(context, &block)
103
+ previous = Thread.current[THREAD_KEY]
104
+ Thread.handle_interrupt(Object => :never) do
105
+ Thread.current[THREAD_KEY] = context
106
+ begin
107
+ Thread.handle_interrupt(Object => :immediate, &block)
108
+ ensure
109
+ Thread.current[THREAD_KEY] = previous
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "bigdecimal"
4
4
  require "dry-initializer"
5
+ require_relative "decimal_context"
5
6
 
6
7
  module Smith
7
8
  module Budget
@@ -26,6 +27,25 @@ module Smith
26
27
  finite_float!(amount)
27
28
  end
28
29
 
30
+ # Remaining capacity, or one of parts equal shares of it, is reserved as
31
+ # read, so parts reservations of the external value must never exceed the
32
+ # exact amount. An Integer keeps floor division; a Float steps down until
33
+ # its decimal form fits, which takes O(1) steps because Float division
34
+ # lands within one ulp of the exact share.
35
+ def externalize_remaining(key, amount, parts = 1)
36
+ unless parts.is_a?(Integer) && parts.positive?
37
+ raise ArgumentError, "budget share parts must be a positive Integer"
38
+ end
39
+
40
+ share = externalize_amount(key, amount) / parts
41
+ return share unless share.is_a?(Float)
42
+
43
+ DecimalContext.call do
44
+ share = share.prev_float while internalize_amount(share) * parts > amount
45
+ end
46
+ share
47
+ end
48
+
29
49
  private
30
50
 
31
51
  def internalize_amount(amount)
@@ -58,12 +58,16 @@ module Smith
58
58
  @mutex.synchronize { release_reservation(token) }
59
59
  end
60
60
 
61
- def remaining(key)
61
+ def remaining(key) = remaining_share(key, 1)
62
+
63
+ # One of parts equal shares of the remaining amount, taken from the exact
64
+ # internal value so that parts reservations of it always fit.
65
+ def remaining_share(key, parts)
62
66
  dimension = @amount_contract.dimension!(key)
63
67
  @amount_contract.known_dimension!(dimension)
64
68
  @mutex.synchronize do
65
69
  amount = @state_transition.remaining(@publication.state, dimension)
66
- @representation.externalize_amount(dimension, amount)
70
+ @representation.externalize_remaining(dimension, amount, parts)
67
71
  end
68
72
  end
69
73
 
@@ -6,19 +6,29 @@ module Smith
6
6
  MARKER = "[smith:injected-state]"
7
7
 
8
8
  def self.inject(messages, formatter:, persisted:)
9
- content = "#{MARKER}\n#{formatter.call(persisted)}"
9
+ text = formatter.call(persisted).to_s
10
+ content = "#{MARKER}\n#{text}"
10
11
 
11
12
  existing_index = messages.index do |message|
12
13
  message_content = message[:content] || message["content"]
13
14
  message_content.is_a?(String) && message_content.start_with?(MARKER)
14
15
  end
15
16
 
16
- if existing_index
17
+ if blank?(text)
18
+ without_marker(messages, existing_index)
19
+ elsif existing_index
17
20
  messages.dup.tap { |msgs| msgs[existing_index] = { role: :system, content: content } }
18
21
  else
19
22
  messages + [{ role: :system, content: content }]
20
23
  end
21
24
  end
25
+
26
+ def self.blank?(text) = text.valid_encoding? && text.strip.empty?
27
+
28
+ def self.without_marker(messages, index)
29
+ index ? messages.dup.tap { |msgs| msgs.delete_at(index) } : messages
30
+ end
31
+ private_class_method :blank?, :without_marker
22
32
  end
23
33
  end
24
34
  end
@@ -9,7 +9,10 @@ module Smith
9
9
  STRING_BYTESIZE = String.instance_method(:bytesize)
10
10
  STRING_BYTESLICE = String.instance_method(:byteslice)
11
11
  MODULE_MATCH = Module.instance_method(:===)
12
- private_constant :TRUNCATION_MARKER, :STRING_BYTESIZE, :STRING_BYTESLICE, :MODULE_MATCH
12
+ MODULE_NAME = Module.instance_method(:name)
13
+ OBJECT_CLASS = Object.instance_method(:class)
14
+ private_constant :TRUNCATION_MARKER, :STRING_BYTESIZE, :STRING_BYTESLICE, :MODULE_MATCH, :MODULE_NAME,
15
+ :OBJECT_CLASS
13
16
 
14
17
  extend Dry::Initializer
15
18
 
@@ -18,6 +21,12 @@ module Smith
18
21
 
19
22
  def self.capture(value, max_bytes: MAX_BYTES) = new(value, max_bytes:).call
20
23
 
24
+ # An error's class name, read through the native lookups so the error
25
+ # cannot misreport it, and bounded like any diagnostic identifier.
26
+ def self.error_class_name(error)
27
+ capture(MODULE_NAME.bind_call(OBJECT_CLASS.bind_call(error)) || "anonymous_error", max_bytes: 512)
28
+ end
29
+
21
30
  def call
22
31
  validate_limit!
23
32
  text = utf8_prefix
@@ -31,10 +31,26 @@ module Smith
31
31
  )
32
32
  return
33
33
  else
34
+ check_ttl_support(report, adapter)
34
35
  check_persist_and_restore(report, adapter)
35
36
  check_resume_after_restore(report, adapter)
36
37
  end
37
38
 
39
+ # ActiveRecordStore refuses every write that carries a TTL, so a
40
+ # configured global TTL would fail the first persisted run.
41
+ def self.check_ttl_support(report, adapter)
42
+ ttl = ::Smith.config.persistence_ttl
43
+ return if ttl.nil? || !adapter.is_a?(::Smith::PersistenceAdapters::ActiveRecordStore)
44
+
45
+ report.add(
46
+ name: "durability.ttl",
47
+ status: :fail,
48
+ message: "ActiveRecordStore cannot expire workflow state; persistence_ttl must be nil",
49
+ detail: "Smith.config.persistence_ttl is #{ttl.inspect}, so every persisted write refuses it. " \
50
+ "Set it to nil, or use an adapter that honours TTL."
51
+ )
52
+ end
53
+
38
54
  def self.check_persist_and_restore(report, adapter)
39
55
  workflow_class = build_probe_class
40
56
  payload = JSON.generate(workflow_class.new.to_state)
@@ -17,7 +17,10 @@ module Smith
17
17
  OPTIONAL_CAPABILITIES = Smith::PersistenceAdapters::OPTIONAL_METHODS
18
18
 
19
19
  def run(report)
20
- adapter = resolve_adapter
20
+ adapter = Smith.persistence_adapter
21
+ rescue StandardError => e
22
+ report_invalid_adapter(report, e)
23
+ else
21
24
  return report_missing_adapter(report) if adapter.nil?
22
25
 
23
26
  missing = OPTIONAL_CAPABILITIES.reject { |cap| capability_available?(adapter, cap) }
@@ -26,12 +29,6 @@ module Smith
26
29
  report_missing_capabilities(report, adapter, missing)
27
30
  end
28
31
 
29
- def resolve_adapter
30
- Smith.persistence_adapter
31
- rescue StandardError
32
- nil
33
- end
34
-
35
32
  def capability_available?(adapter, capability)
36
33
  return false unless Smith::PersistenceAdapters.supports?(adapter, capability)
37
34
  return true unless capability == :persistence_identity
@@ -42,6 +39,15 @@ module Smith
42
39
  false
43
40
  end
44
41
 
42
+ def report_invalid_adapter(report, error)
43
+ report.add(
44
+ name: "persistence.capabilities",
45
+ status: :fail,
46
+ message: "Persistence adapter configuration is invalid",
47
+ detail: error.message
48
+ )
49
+ end
50
+
45
51
  def report_missing_adapter(report)
46
52
  report.add(
47
53
  name: "persistence.capabilities",
data/lib/smith/errors.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "diagnostic_text"
3
4
  require_relative "error"
4
5
  require_relative "persisted_failure_invalid"
5
6
  require_relative "pricing_configuration_error"
@@ -88,6 +89,11 @@ module Smith
88
89
  class AgentError < Error; end
89
90
 
90
91
  class BlankAgentOutputError < AgentError
92
+ DETAIL_NAMES = %i[agent_name model_used].freeze
93
+ DETAIL_KEYS = DETAIL_NAMES.to_h { |name| [name.to_s.freeze, name] }.freeze
94
+ MAX_DETAIL_BYTES = 512
95
+ private_constant :DETAIL_NAMES, :DETAIL_KEYS, :MAX_DETAIL_BYTES
96
+
91
97
  attr_reader :agent_name, :model_used
92
98
 
93
99
  def initialize(agent_name:, model_used:)
@@ -101,6 +107,46 @@ module Smith
101
107
 
102
108
  super(detail)
103
109
  end
110
+
111
+ def details
112
+ {
113
+ agent_name: agent_name && DiagnosticText.capture(agent_name.to_s, max_bytes: MAX_DETAIL_BYTES),
114
+ model_used: model_used && DiagnosticText.capture(model_used.to_s, max_bytes: MAX_DETAIL_BYTES)
115
+ }.freeze
116
+ end
117
+
118
+ def self.from_details(details)
119
+ values = normalize_details(details)
120
+ new(agent_name: values.fetch(:agent_name)&.to_sym, model_used: values.fetch(:model_used))
121
+ end
122
+
123
+ def self.normalize_details(details)
124
+ raise ArgumentError, "blank agent output details must be a Hash" unless details.is_a?(Hash)
125
+
126
+ values = {}
127
+ Hash.instance_method(:each_pair).bind_call(details) do |key, value|
128
+ name = key.is_a?(Symbol) ? key : DETAIL_KEYS[key]
129
+ unless DETAIL_NAMES.include?(name)
130
+ raise ArgumentError, "blank agent output details contain an unknown attribute"
131
+ end
132
+ raise ArgumentError, "blank agent output details contain a duplicate attribute" if values.key?(name)
133
+
134
+ values[name] = bounded_detail(name, value)
135
+ end
136
+ raise ArgumentError, "blank agent output details are incomplete" unless values.length == DETAIL_NAMES.length
137
+
138
+ values
139
+ end
140
+
141
+ def self.bounded_detail(name, value)
142
+ return value if value.nil?
143
+
144
+ bounded = value.is_a?(String) && value.valid_encoding? && value.bytesize <= MAX_DETAIL_BYTES
145
+ raise ArgumentError, "blank agent output detail #{name} must be bounded text" unless bounded
146
+
147
+ value
148
+ end
149
+ private_class_method :normalize_details, :bounded_detail
104
150
  end
105
151
 
106
152
  class WorkflowError < Error; end
@@ -185,15 +231,20 @@ module Smith
185
231
  # `idempotency_mode :strict`. Signals that a previous worker crashed
186
232
  # between `persist!` (before advance) and `persist!` (after advance);
187
233
  # the step's effects are unknown, so blindly re-running could
188
- # double-execute non-idempotent agent calls or tools.
234
+ # double-execute non-idempotent agent calls or tools. `state` is the
235
+ # persisted state the interrupted step started from and `transition` the
236
+ # next transition the payload records; each is nil when unknown.
189
237
  class StepInProgressOnRestore < Error
190
- attr_reader :workflow, :persistence_key
238
+ attr_reader :workflow, :persistence_key, :state, :transition
191
239
 
192
- def initialize(workflow:, persistence_key:)
240
+ def initialize(workflow:, persistence_key:, state: nil, transition: nil)
193
241
  @workflow = workflow
194
242
  @persistence_key = persistence_key
243
+ @state = state.to_sym if state.is_a?(String) || state.is_a?(Symbol)
244
+ @transition = transition.to_sym if transition.is_a?(String) || transition.is_a?(Symbol)
195
245
  super(
196
- "step in progress on restore for #{workflow} key=#{persistence_key.inspect}: " \
246
+ "step in progress on restore for #{workflow} key=#{persistence_key.inspect}" \
247
+ "#{" state=#{@state.inspect}" if @state}: " \
197
248
  "a previous worker crashed mid-step. Hosts using idempotency_mode :strict must " \
198
249
  "decide whether to clear the persisted state (idempotent re-run unsafe) or " \
199
250
  "switch to :lax (assume re-run is safe)."
data/lib/smith/event.rb CHANGED
@@ -3,9 +3,17 @@
3
3
  require "dry-struct"
4
4
  require "securerandom"
5
5
 
6
+ require_relative "attribution"
7
+
6
8
  module Smith
7
9
  class Event < Dry::Struct
8
- attribute(:execution_id, Types::String.default { SecureRandom.uuid })
9
- attribute(:trace_id, Types::String.default { SecureRandom.uuid })
10
+ # Both ids default to the ambient Smith::Attribution execution key, so
11
+ # events emitted under one persisted run (or one host-seeded
12
+ # Attribution scope) share one identity a host can group by. Outside
13
+ # any scope, each event falls back to its own random UUID, so
14
+ # non-persisted, unseeded runs have no shared event identity. Callers
15
+ # may always pass explicit values.
16
+ attribute(:execution_id, Types::String.default { Smith::Attribution.ambient.execution_key || SecureRandom.uuid })
17
+ attribute(:trace_id, Types::String.default { Smith::Attribution.ambient.execution_key || SecureRandom.uuid })
10
18
  end
11
19
  end
@@ -18,19 +18,43 @@ module Smith
18
18
  end
19
19
  end
20
20
 
21
+ REGISTRY_MUTEX = Mutex.new
22
+ private_constant :REGISTRY_MUTEX
23
+
21
24
  class << self
25
+ # Registration-ordered snapshot of the live subscriptions. Cancelled
26
+ # subscriptions are detached from the registry, so this reflects only
27
+ # what will actually receive events.
22
28
  def subscriptions
23
- @subscriptions ||= []
29
+ snapshot = REGISTRY_MUTEX.synchronize { registry.values.flatten }
30
+ snapshot.sort_by!(&:sequence_number)
24
31
  end
25
32
 
26
33
  def on(event_class, **opts, &block)
27
34
  sub = Subscription.new(event_class, handler: block, predicate: opts[:if])
28
- subscriptions << sub
35
+ REGISTRY_MUTEX.synchronize do
36
+ @sequence = (@sequence || 0) + 1
37
+ sub.sequence_number = @sequence
38
+ (registry[event_class] ||= []) << sub
39
+ end
29
40
  sub
30
41
  end
31
42
 
43
+ # Removes a subscription from the registry. Called by
44
+ # Subscription#cancel; safe to call more than once.
45
+ def detach(subscription)
46
+ REGISTRY_MUTEX.synchronize do
47
+ bucket = registry[subscription.event_class]
48
+ next unless bucket
49
+
50
+ bucket.delete(subscription)
51
+ registry.delete(subscription.event_class) if bucket.empty?
52
+ end
53
+ nil
54
+ end
55
+
32
56
  def emit(event)
33
- subscriptions.each { |sub| dispatch_to(sub, event) }
57
+ matching_subscriptions(event).each { |sub| dispatch_to(sub, event) }
34
58
  end
35
59
 
36
60
  def within
@@ -41,14 +65,48 @@ module Smith
41
65
  end
42
66
 
43
67
  def reset!
44
- @subscriptions = []
68
+ REGISTRY_MUTEX.synchronize do
69
+ @registry = {}
70
+ @sequence = 0
71
+ end
45
72
  end
46
73
 
47
74
  private
48
75
 
76
+ def registry
77
+ @registry ||= {}
78
+ end
79
+
80
+ # Subscriptions live in per-class buckets so one emit touches only the
81
+ # buckets for the event's ancestors instead of scanning every
82
+ # subscription; `is_a?` dispatch semantics are preserved exactly
83
+ # because a subscription matches iff its registered class or module is
84
+ # among the event class's ancestors. The merged candidates are ordered
85
+ # by registration sequence, keeping subscription-order dispatch.
86
+ # Handlers run outside the registry lock so a handler may subscribe or
87
+ # cancel without deadlocking.
88
+ def matching_subscriptions(event)
89
+ # singleton_class.ancestors, not class.ancestors: it additionally
90
+ # covers modules mixed into the event instance via extend, which
91
+ # `is_a?` matched before the bucketed registry existed. Immediates
92
+ # (Integer, Symbol, Float) have no singleton class; they also cannot
93
+ # be extended, so their class ancestors are the complete `is_a?` set.
94
+ ancestors = begin
95
+ event.singleton_class.ancestors
96
+ rescue TypeError
97
+ event.class.ancestors
98
+ end
99
+ candidates = REGISTRY_MUTEX.synchronize do
100
+ ancestors.each_with_object([]) do |ancestor, found|
101
+ bucket = registry[ancestor]
102
+ found.concat(bucket) if bucket
103
+ end
104
+ end
105
+ candidates.sort_by!(&:sequence_number)
106
+ end
107
+
49
108
  def dispatch_to(sub, event)
50
109
  return if sub.cancelled?
51
- return unless event.is_a?(sub.event_class)
52
110
  return if sub.predicate && !sub.predicate.call(event)
53
111
 
54
112
  sub.handler.call(event)
@@ -6,6 +6,9 @@ module Smith
6
6
  attribute :transition, Types::Strict::Symbol
7
7
  attribute :from, Types::Strict::Symbol.optional
8
8
  attribute :to, Types::Strict::Symbol
9
+ # The emitting workflow's class name; distinguishes nested-child steps
10
+ # from parent steps under the shared root execution identity.
11
+ attribute :workflow, Types::Strict::String.optional.default(nil)
9
12
  end
10
13
  end
11
14
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Events
5
+ # Emitted from both step-failure paths (step-body failure and an
6
+ # unresolved transition routed to :fail), closing the success-only
7
+ # observation gap for failures that reach step handling. Terminal errors
8
+ # raised outside it (an unresolved transition with no :fail transition,
9
+ # transition-budget exhaustion, origin validation) still re-raise
10
+ # without a StepFailed. Carries bounded classification only, never raw
11
+ # error messages; `error_family` uses FailureRecord's taxonomy
12
+ # ("agent_error", "deadline_exceeded", ..., "other") and `retryable` is
13
+ # nil when the error does not declare retryability.
14
+ class StepFailed < Smith::Event
15
+ attribute :transition, Types::Strict::Symbol
16
+ attribute :from, Types::Strict::Symbol.optional
17
+ attribute :to, Types::Strict::Symbol.optional
18
+ attribute :error_class, Types::Strict::String
19
+ attribute :error_family, Types::Strict::String
20
+ attribute :retryable, Types::Strict::Bool.optional
21
+ # See StepCompleted#workflow.
22
+ attribute :workflow, Types::Strict::String.optional.default(nil)
23
+ end
24
+ end
25
+ end
@@ -4,6 +4,9 @@ module Smith
4
4
  module Events
5
5
  class Subscription
6
6
  attr_reader :event_class, :handler, :predicate
7
+ # Internal registration order, assigned by Events.on; dispatch and the
8
+ # subscriptions snapshot sort by it.
9
+ attr_accessor :sequence_number
7
10
 
8
11
  def initialize(event_class, handler:, predicate: nil)
9
12
  @event_class = event_class
@@ -12,8 +15,13 @@ module Smith
12
15
  @cancelled = false
13
16
  end
14
17
 
18
+ # Cancelling flags the subscription and detaches it from the registry
19
+ # (so it cannot leak). The flag check against an in-flight emit
20
+ # snapshot is best-effort: a dispatch already past the check may still
21
+ # deliver once after cancel returns.
15
22
  def cancel
16
23
  @cancelled = true
24
+ Events.detach(self)
17
25
  end
18
26
 
19
27
  def cancelled?
@@ -5,9 +5,8 @@ module Smith
5
5
  module ActiveRecordExactStore
6
6
  attr_reader :persistence_identity
7
7
 
8
- def replace_exact(key, payload, expected_payload:, ttl: nil) # rubocop:disable Lint/UnusedMethodArgument
9
- ensure_version_column!
10
- ensure_locking_configuration!
8
+ def replace_exact(key, payload, expected_payload:, ttl: nil)
9
+ ensure_versioned_write!(ttl)
11
10
  model = model_class
12
11
  ensure_exact_schema!(model)
13
12
  ActiveRecordExactWrite.new(
@@ -25,6 +24,12 @@ module Smith
25
24
 
26
25
  private
27
26
 
27
+ def ensure_versioned_write!(ttl)
28
+ PersistenceAdapters.refuse_active_record_ttl!(ttl)
29
+ ensure_version_column!
30
+ ensure_locking_configuration!
31
+ end
32
+
28
33
  def ensure_exact_schema!(model)
29
34
  ensure_exact_payload_column!(model)
30
35
  ensure_exact_key_uniqueness!(model)
@@ -16,10 +16,8 @@ module Smith
16
16
  @persistence_identity = identity.to_s.dup.freeze if identity
17
17
  end
18
18
 
19
- def store(key, payload, ttl: nil) # rubocop:disable Lint/UnusedMethodArgument
20
- # TTL is deferred for ActiveRecordStore — would require an
21
- # `expires_at` column + a periodic sweeper job. Ignored here;
22
- # documented as a known limitation.
19
+ def store(key, payload, ttl: nil)
20
+ PersistenceAdapters.refuse_active_record_ttl!(ttl)
23
21
  Retry.with_retries(operation: :store, transient: ActiveRecordConnectionErrors.classes) do
24
22
  record = model_class.find_or_initialize_by(@key_column => key)
25
23
  record.public_send(:"#{@payload_column}=", payload)
@@ -47,9 +45,8 @@ module Smith
47
45
  # `lock_version` column. Requires the AR model to have a
48
46
  # `lock_version` (or configured) integer column with default 0.
49
47
  # If absent, raises ArgumentError directing the host to migrate.
50
- def store_versioned(key, payload, expected_version:, ttl: nil) # rubocop:disable Lint/UnusedMethodArgument
51
- ensure_version_column!
52
- ensure_locking_configuration!
48
+ def store_versioned(key, payload, expected_version:, ttl: nil)
49
+ ensure_versioned_write!(ttl)
53
50
 
54
51
  write_versioned(key, payload, expected_version)
55
52
  rescue *ActiveRecordConnectionErrors.classes => e
@@ -71,6 +71,16 @@ module Smith
71
71
  end
72
72
  private_class_method :build
73
73
 
74
+ # ActiveRecordStore's host table has no expiry column, so a TTL could
75
+ # only be ignored. Its writes fail closed instead of silently keeping
76
+ # state forever; reads and deletes never take a TTL and keep working.
77
+ def self.refuse_active_record_ttl!(ttl)
78
+ return if ttl.nil?
79
+
80
+ raise ArgumentError,
81
+ "ActiveRecordStore cannot expire workflow state; persistence_ttl must be nil, got #{ttl.inspect}"
82
+ end
83
+
74
84
  # Capability introspection used by Workflow#persist! to decide
75
85
  # whether the adapter supports optimistic locking via store_versioned.
76
86
  def self.supports?(adapter, capability)