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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +433 -0
- data/README.md +16 -6
- data/docs/CONFIGURATION.md +26 -6
- data/docs/PATTERNS.md +59 -0
- data/docs/PERSISTENCE.md +5 -1
- data/lib/smith/agent/completion_usage_recording.rb +21 -3
- data/lib/smith/agent/fallback_configuration.rb +26 -3
- data/lib/smith/agent/invocation_preparation.rb +24 -0
- data/lib/smith/agent/lifecycle.rb +4 -2
- data/lib/smith/agent/provider_attempt.rb +17 -4
- data/lib/smith/agent/provider_call_timing.rb +129 -0
- data/lib/smith/agent/provider_completion.rb +26 -8
- data/lib/smith/agent/provider_failure_handling.rb +2 -2
- data/lib/smith/agent/provider_usage.rb +6 -0
- data/lib/smith/agent/usage_entry_recording.rb +27 -4
- data/lib/smith/agent/usage_traces.rb +49 -0
- data/lib/smith/agent/usage_tracking.rb +26 -20
- data/lib/smith/agent.rb +9 -1
- data/lib/smith/attribution.rb +115 -0
- data/lib/smith/budget/amount_representation.rb +20 -0
- data/lib/smith/budget/ledger.rb +6 -2
- data/lib/smith/context/state_injection.rb +12 -2
- data/lib/smith/diagnostic_text.rb +10 -1
- data/lib/smith/doctor/checks/durability.rb +16 -0
- data/lib/smith/doctor/checks/persistence_capabilities.rb +13 -7
- data/lib/smith/errors.rb +55 -4
- data/lib/smith/event.rb +10 -2
- data/lib/smith/events/bus.rb +63 -5
- data/lib/smith/events/step_completed.rb +3 -0
- data/lib/smith/events/step_failed.rb +25 -0
- data/lib/smith/events/subscription.rb +8 -0
- data/lib/smith/persistence_adapters/active_record_exact_store.rb +8 -3
- data/lib/smith/persistence_adapters/active_record_store.rb +4 -7
- data/lib/smith/persistence_adapters.rb +10 -0
- data/lib/smith/provider_permanent_failure.rb +44 -0
- data/lib/smith/tool/capture.rb +9 -1
- data/lib/smith/tool.rb +10 -1
- data/lib/smith/trace/logger.rb +1 -0
- data/lib/smith/trace/memory.rb +40 -4
- data/lib/smith/trace/open_telemetry.rb +47 -3
- data/lib/smith/trace.rb +24 -5
- data/lib/smith/version.rb +1 -1
- data/lib/smith/workflow/budget_integration.rb +3 -3
- data/lib/smith/workflow/composite/effects.rb +64 -33
- data/lib/smith/workflow/deterministic_execution.rb +3 -2
- data/lib/smith/workflow/deterministic_step.rb +15 -2
- data/lib/smith/workflow/dsl.rb +40 -6
- data/lib/smith/workflow/durability.rb +1 -1
- data/lib/smith/workflow/evaluator_optimizer.rb +111 -11
- data/lib/smith/workflow/event_integration.rb +48 -1
- data/lib/smith/workflow/execution.rb +10 -0
- data/lib/smith/workflow/failure_reconstructor.rb +18 -2
- data/lib/smith/workflow/failure_record.rb +6 -11
- data/lib/smith/workflow/failure_record_validator.rb +23 -3
- data/lib/smith/workflow/fanout_execution.rb +1 -1
- data/lib/smith/workflow/guardrail_integration.rb +27 -0
- data/lib/smith/workflow/optimization_state.rb +2 -2
- data/lib/smith/workflow/parallel_execution.rb +6 -0
- data/lib/smith/workflow/persistence.rb +55 -6
- data/lib/smith/workflow/prepared_branch_execution.rb +13 -4
- data/lib/smith/workflow/retry_execution.rb +4 -0
- data/lib/smith/workflow/run_result.rb +10 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +9 -1
- data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
- data/lib/smith/workflow/step_completion.rb +13 -0
- data/lib/smith/workflow/step_context.rb +46 -0
- data/lib/smith/workflow/thread_context_snapshot.rb +1 -0
- data/lib/smith/workflow/usage_entry.rb +32 -4
- data/lib/smith/workflow.rb +30 -3
- data/lib/smith.rb +9 -4
- 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)
|
data/lib/smith/budget/ledger.rb
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
9
|
-
|
|
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
|
data/lib/smith/events/bus.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
9
|
-
|
|
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)
|
|
20
|
-
|
|
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)
|
|
51
|
-
|
|
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)
|