smith-agents 0.10.0 → 0.12.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 +901 -0
- data/README.md +636 -20
- data/UPSTREAM_PROPOSAL.md +1 -0
- data/docs/CONFIGURATION.md +100 -14
- data/docs/PATTERNS.md +88 -12
- data/docs/PERSISTENCE.md +738 -6
- data/docs/TOOLS_AND_GUARDRAILS.md +24 -3
- data/lib/generators/smith/install/install_generator.rb +63 -2
- data/lib/generators/smith/install/templates/create_workflow_states.rb.tt +31 -0
- data/lib/generators/smith/install/templates/smith.rb.tt +39 -8
- data/lib/generators/smith/install/templates/workflow_state.rb.tt +19 -0
- data/lib/smith/agent/completion.rb +32 -6
- data/lib/smith/agent/completion_usage_recording.rb +23 -14
- data/lib/smith/agent/content_bytes.rb +98 -0
- data/lib/smith/agent/content_parts.rb +149 -0
- data/lib/smith/agent/fallback_configuration.rb +26 -3
- data/lib/smith/agent/invocation_preparation.rb +37 -14
- data/lib/smith/agent/lifecycle.rb +16 -5
- data/lib/smith/agent/observed_model.rb +29 -0
- data/lib/smith/agent/output_contract_check.rb +53 -0
- data/lib/smith/agent/output_integrity.rb +137 -0
- data/lib/smith/agent/output_policy.rb +92 -0
- data/lib/smith/agent/output_recovery.rb +129 -0
- data/lib/smith/agent/provider_attempt.rb +17 -4
- data/lib/smith/agent/provider_call_timing.rb +71 -7
- data/lib/smith/agent/provider_completion.rb +41 -38
- data/lib/smith/agent/provider_failure_handling.rb +5 -1
- data/lib/smith/agent/provider_request.rb +36 -0
- data/lib/smith/agent/provider_streaming.rb +155 -0
- data/lib/smith/agent/provider_usage.rb +75 -2
- data/lib/smith/agent/usage_entry_recording.rb +4 -2
- data/lib/smith/agent/usage_traces.rb +9 -2
- data/lib/smith/agent/usage_tracking.rb +64 -48
- data/lib/smith/agent.rb +14 -1
- data/lib/smith/artifacts/active_record_store.rb +79 -0
- data/lib/smith/artifacts/file.rb +45 -18
- data/lib/smith/artifacts/memory.rb +47 -36
- data/lib/smith/artifacts/s3.rb +126 -0
- data/lib/smith/artifacts/scoped_store.rb +55 -7
- data/lib/smith/artifacts/store_rules.rb +49 -0
- data/lib/smith/artifacts.rb +98 -1
- data/lib/smith/batch/message_shape.rb +36 -0
- data/lib/smith/batch.rb +166 -0
- data/lib/smith/budget/amount.rb +25 -0
- data/lib/smith/budget/amount_representation.rb +20 -0
- data/lib/smith/budget/continuation.rb +153 -0
- data/lib/smith/budget/host_ledger.rb +137 -0
- data/lib/smith/budget/ledger.rb +10 -4
- data/lib/smith/budget/ledger_state_transition.rb +7 -5
- data/lib/smith/budget/request_hold.rb +211 -0
- data/lib/smith/budget/spent_reservation.rb +18 -0
- data/lib/smith/child_run.rb +41 -0
- data/lib/smith/child_runs_failed.rb +56 -0
- data/lib/smith/content.rb +96 -0
- data/lib/smith/context/session.rb +14 -2
- data/lib/smith/context/state_injection.rb +21 -6
- data/lib/smith/context.rb +28 -0
- data/lib/smith/diagnostic_text.rb +10 -1
- data/lib/smith/doctor/checks/artifacts.rb +103 -0
- data/lib/smith/doctor/checks/configuration.rb +0 -11
- data/lib/smith/doctor/checks/durability.rb +35 -17
- data/lib/smith/doctor/checks/persistence_capabilities.rb +13 -7
- data/lib/smith/doctor/checks/state_model.rb +63 -0
- data/lib/smith/doctor/installer.rb +9 -2
- data/lib/smith/doctor.rb +4 -0
- data/lib/smith/errors.rb +304 -6
- data/lib/smith/idempotency_key/canonical.rb +74 -0
- data/lib/smith/idempotency_key.rb +145 -0
- data/lib/smith/launchers/inline.rb +46 -0
- data/lib/smith/message.rb +76 -0
- data/lib/smith/models/tool_routing.rb +47 -44
- data/lib/smith/output_failure.rb +130 -0
- data/lib/smith/persistence_adapters/active_record_exact_store.rb +51 -3
- data/lib/smith/persistence_adapters/active_record_store.rb +4 -7
- data/lib/smith/persistence_adapters.rb +10 -0
- data/lib/smith/pricing/catalog_validation.rb +91 -0
- data/lib/smith/pricing.rb +95 -64
- data/lib/smith/provider_permanent_failure.rb +44 -0
- data/lib/smith/providers/anthropic/batches.rb +116 -0
- data/lib/smith/providers/batch_exchange.rb +77 -0
- data/lib/smith/providers/openai/responses.rb +45 -20
- data/lib/smith/providers/output_limit.rb +68 -0
- data/lib/smith/providers/request_holds.rb +139 -0
- data/lib/smith/providers/response_facts.rb +108 -0
- data/lib/smith/providers/schema_constraint_check.rb +141 -0
- data/lib/smith/providers/schema_constraints.rb +87 -0
- data/lib/smith/providers/schema_rules.rb +66 -0
- data/lib/smith/providers/stop_reason.rb +39 -0
- data/lib/smith/providers/stream_facts.rb +192 -0
- data/lib/smith/response_memo.rb +152 -0
- data/lib/smith/setting_constructors.rb +47 -0
- data/lib/smith/streaming/json_field.rb +136 -0
- data/lib/smith/streaming/json_string.rb +91 -0
- data/lib/smith/streaming/session.rb +77 -0
- data/lib/smith/streaming/think_tags.rb +67 -0
- data/lib/smith/streaming.rb +64 -0
- data/lib/smith/testing/checks.rb +58 -0
- data/lib/smith/testing/hooks.rb +85 -0
- data/lib/smith/testing/minitest.rb +37 -0
- data/lib/smith/testing/rspec.rb +52 -0
- data/lib/smith/testing/stub.rb +110 -0
- data/lib/smith/testing.rb +158 -0
- data/lib/smith/tool/scoped_context.rb +9 -12
- data/lib/smith/tool.rb +14 -0
- data/lib/smith/trace/logger.rb +1 -1
- data/lib/smith/trace/memory.rb +1 -1
- data/lib/smith/trace/open_telemetry.rb +1 -1
- data/lib/smith/trace.rb +11 -4
- data/lib/smith/untrusted.rb +74 -0
- data/lib/smith/version.rb +2 -2
- data/lib/smith/workflow/active_clock.rb +61 -0
- data/lib/smith/workflow/agent_result.rb +37 -0
- data/lib/smith/workflow/artifact_integration.rb +57 -7
- data/lib/smith/workflow/await_step.rb +42 -0
- data/lib/smith/workflow/batch_charges.rb +74 -0
- data/lib/smith/workflow/batch_execution.rb +149 -0
- data/lib/smith/workflow/batch_holds.rb +73 -0
- data/lib/smith/workflow/batch_wait.rb +134 -0
- data/lib/smith/workflow/budget_integration.rb +14 -8
- data/lib/smith/workflow/budget_pause.rb +51 -0
- data/lib/smith/workflow/child_run_declarations.rb +127 -0
- data/lib/smith/workflow/child_run_execution.rb +150 -0
- data/lib/smith/workflow/child_run_results.rb +55 -0
- data/lib/smith/workflow/composite/effects.rb +37 -6
- data/lib/smith/workflow/composite/error.rb +1 -1
- data/lib/smith/workflow/composite/error_evidence.rb +9 -9
- data/lib/smith/workflow/composite/input.rb +11 -4
- data/lib/smith/workflow/context_keys.rb +77 -0
- data/lib/smith/workflow/deadline_enforcement.rb +1 -1
- data/lib/smith/workflow/deterministic_execution.rb +62 -11
- data/lib/smith/workflow/deterministic_step.rb +56 -7
- data/lib/smith/workflow/dsl.rb +74 -5
- data/lib/smith/workflow/durability.rb +140 -31
- data/lib/smith/workflow/evaluator_optimizer.rb +116 -12
- data/lib/smith/workflow/event_integration.rb +9 -0
- data/lib/smith/workflow/execution.rb +32 -23
- data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
- data/lib/smith/workflow/failure_reconstructor.rb +41 -2
- data/lib/smith/workflow/failure_record.rb +12 -12
- data/lib/smith/workflow/failure_record_validator.rb +32 -5
- data/lib/smith/workflow/fanout_execution.rb +5 -8
- data/lib/smith/workflow/graph/batch_diagnostic.rb +83 -0
- data/lib/smith/workflow/graph/branch_limit_diagnostic.rb +41 -0
- data/lib/smith/workflow/graph/budget_ledger_diagnostic.rb +126 -0
- data/lib/smith/workflow/graph/durable_retry_diagnostic.rb +50 -0
- data/lib/smith/workflow/graph/execution_successors.rb +16 -2
- data/lib/smith/workflow/graph/inherited_untrusted_diagnostics.rb +77 -0
- data/lib/smith/workflow/graph/isolated_input_diagnostic.rb +50 -0
- data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +43 -4
- data/lib/smith/workflow/graph/output_key_diagnostic.rb +53 -0
- data/lib/smith/workflow/graph/output_schema_diagnostic.rb +49 -0
- data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +1 -0
- data/lib/smith/workflow/graph/run_launcher_diagnostic.rb +30 -0
- data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +9 -3
- data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +7 -3
- data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +8 -1
- data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +9 -2
- data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +7 -4
- data/lib/smith/workflow/graph/stream_diagnostic.rb +95 -0
- data/lib/smith/workflow/graph/transition_contract.rb +19 -0
- data/lib/smith/workflow/graph/transition_contract_attributes.rb +25 -2
- data/lib/smith/workflow/graph/transition_contract_configurations.rb +24 -3
- data/lib/smith/workflow/graph/transition_diagnostics.rb +11 -24
- data/lib/smith/workflow/graph/transition_snapshot.rb +40 -39
- data/lib/smith/workflow/graph/untrusted_input_diagnostic.rb +57 -0
- data/lib/smith/workflow/graph/waiting_child_diagnostic.rb +24 -0
- data/lib/smith/workflow/graph.rb +12 -0
- data/lib/smith/workflow/guarded_step_execution.rb +48 -3
- data/lib/smith/workflow/guardrail_integration.rb +2 -0
- data/lib/smith/workflow/message_batch.rb +6 -0
- data/lib/smith/workflow/message_roles.rb +25 -0
- data/lib/smith/workflow/message_value_normalizer.rb +2 -1
- data/lib/smith/workflow/nested_execution.rb +47 -4
- data/lib/smith/workflow/optimization_state.rb +2 -2
- data/lib/smith/workflow/orchestrator_worker.rb +9 -3
- data/lib/smith/workflow/parallel_execution.rb +10 -0
- data/lib/smith/workflow/persistence.rb +162 -22
- data/lib/smith/workflow/remembered_calls.rb +92 -0
- data/lib/smith/workflow/retry_execution.rb +71 -8
- data/lib/smith/workflow/retry_wait.rb +134 -0
- data/lib/smith/workflow/run_result.rb +10 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +7 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +4 -1
- data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +3 -1
- data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +1 -0
- data/lib/smith/workflow/split_step_persistence/preparation.rb +5 -0
- data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
- data/lib/smith/workflow/step_completion.rb +40 -7
- data/lib/smith/workflow/step_context.rb +21 -0
- data/lib/smith/workflow/step_io.rb +132 -0
- data/lib/smith/workflow/step_stream.rb +52 -0
- data/lib/smith/workflow/thread_context_snapshot.rb +2 -0
- data/lib/smith/workflow/transition.rb +267 -13
- data/lib/smith/workflow/transition_actionability.rb +2 -0
- data/lib/smith/workflow/usage_entry.rb +24 -3
- data/lib/smith/workflow/wait_completion.rb +131 -0
- data/lib/smith/workflow/wait_execution.rb +180 -0
- data/lib/smith/workflow/wait_record.rb +157 -0
- data/lib/smith/workflow.rb +117 -19
- data/lib/smith.rb +103 -47
- metadata +88 -1
|
@@ -13,7 +13,19 @@ module Smith
|
|
|
13
13
|
context: snapshot_value(persisted_context),
|
|
14
14
|
budget_consumed: ledger_consumed,
|
|
15
15
|
step_count: @step_count,
|
|
16
|
+
awaiting: @awaiting&.dup,
|
|
17
|
+
# The attempt a durable retry makes next, and its key generation.
|
|
18
|
+
pending_retry: @pending_retry&.dup,
|
|
19
|
+
pending_batch: @pending_batch&.dup,
|
|
20
|
+
# The scope the run's budget_ledger is built from.
|
|
21
|
+
budget_ledger_scope: @budget_ledger_scope&.transform_keys(&:to_s),
|
|
22
|
+
active_seconds: @active_seconds || 0.0,
|
|
23
|
+
active_step_started_at: @active_step_started_at,
|
|
16
24
|
execution_namespace: @execution_namespace,
|
|
25
|
+
# Untrusted keys inherited from a parent run (a durable child's).
|
|
26
|
+
inherited_untrusted_keys: (@inherited_untrusted_keys || []).map(&:to_s),
|
|
27
|
+
# Artifact namespaces a child run may read (its ancestors').
|
|
28
|
+
inherited_artifact_namespaces: (@inherited_artifact_namespaces || []).dup,
|
|
17
29
|
created_at: @created_at,
|
|
18
30
|
updated_at: @updated_at,
|
|
19
31
|
next_transition_name: @next_transition_name,
|
|
@@ -85,6 +97,7 @@ module Smith
|
|
|
85
97
|
@next_transition_name = normalized[:next_transition_name]
|
|
86
98
|
@session_messages = normalized[:session_messages] || []
|
|
87
99
|
@seed_message_count = validated_seed_message_count(normalized)
|
|
100
|
+
validate_session_parts!
|
|
88
101
|
@total_cost = normalized[:total_cost] || 0.0
|
|
89
102
|
@total_tokens = normalized[:total_tokens] || 0
|
|
90
103
|
@outcome = normalized[:outcome]
|
|
@@ -99,12 +112,14 @@ module Smith
|
|
|
99
112
|
@usage_mutex = Mutex.new
|
|
100
113
|
@usage_entries = restore_usage_entries(normalized)
|
|
101
114
|
@last_output = restore_last_output(normalized)
|
|
115
|
+
@last_transcript_output = restored_transcript_output
|
|
102
116
|
# Backward-compat: pre-patch states have no last_agent_execution key and
|
|
103
|
-
# restore to nil. @pending_agent_execution
|
|
104
|
-
# steps), never persisted, but must be
|
|
105
|
-
# allocates and bypasses #initialize.
|
|
117
|
+
# restore to nil. @pending_agent_execution and @pending_evaluations are
|
|
118
|
+
# transient (nil between steps), never persisted, but must be
|
|
119
|
+
# initialized because from_state allocates and bypasses #initialize.
|
|
106
120
|
@last_agent_execution = restore_last_agent_execution(normalized)
|
|
107
121
|
@pending_agent_execution = nil
|
|
122
|
+
@pending_evaluations = nil
|
|
108
123
|
@last_failed_step = restore_last_failed_step(normalized)
|
|
109
124
|
# Restore the optimistic-locking version from the persisted payload.
|
|
110
125
|
# Backward-compat: pre-versioning payloads have no key, restore to 0
|
|
@@ -112,6 +127,16 @@ module Smith
|
|
|
112
127
|
# the original store from the legacy adapter contract).
|
|
113
128
|
@persistence_version = persistence_version
|
|
114
129
|
validate_definition_digest!(normalized)
|
|
130
|
+
validate_restored_position!(normalized)
|
|
131
|
+
@awaiting = WaitRecord.restore(normalized[:awaiting], workflow_class: self.class,
|
|
132
|
+
persistence_key: normalized[:persistence_key],
|
|
133
|
+
state: @state, next_transition: @next_transition_name)
|
|
134
|
+
@pending_retry = restore_pending_retry(normalized[:pending_retry], normalized[:persistence_key])
|
|
135
|
+
@pending_batch = restore_pending_batch(normalized[:pending_batch])
|
|
136
|
+
@active_seconds = restored_active_seconds(normalized[:active_seconds])
|
|
137
|
+
charge_unfinished_step!(normalized[:active_step_started_at])
|
|
138
|
+
@wait_mutex = Mutex.new
|
|
139
|
+
@wait_claimed = false
|
|
115
140
|
# Preserve the seed digest from the persisted payload so it
|
|
116
141
|
# round-trips on subsequent persists. validate_seed_digest!
|
|
117
142
|
# compares this against a fresh evaluation of the seed builder
|
|
@@ -125,6 +150,18 @@ module Smith
|
|
|
125
150
|
@split_step_mutex = Mutex.new
|
|
126
151
|
validate_step_in_progress!(normalized) if
|
|
127
152
|
self.class.idempotency_mode == :strict && !allow_step_in_progress
|
|
153
|
+
initialize_budget_ledger
|
|
154
|
+
@memo_attempts = []
|
|
155
|
+
@budget_ledger_scope = restored_budget_ledger_scope(normalized[:budget_ledger_scope])
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The stored scope, or, for a run stored before its class declared
|
|
159
|
+
# these scope keys, the one its context gives now.
|
|
160
|
+
def restored_budget_ledger_scope(stored)
|
|
161
|
+
return unless self.class.budget_ledger
|
|
162
|
+
|
|
163
|
+
Budget::HostLedger.stored_scope(self.class.budget_ledger_scope_keys, stored) ||
|
|
164
|
+
capture_budget_ledger_scope
|
|
128
165
|
end
|
|
129
166
|
|
|
130
167
|
def validated_persistence_version(normalized)
|
|
@@ -135,6 +172,24 @@ module Smith
|
|
|
135
172
|
"persisted workflow persistence_version must be a non-negative integer, got #{version.inspect}"
|
|
136
173
|
end
|
|
137
174
|
|
|
175
|
+
# Content parts come only from seed messages: anywhere else in a
|
|
176
|
+
# stored session they are refused, and a seed's are checked and kept
|
|
177
|
+
# as checked (String-keyed parts), however the payload was parsed.
|
|
178
|
+
def validate_session_parts!
|
|
179
|
+
@session_messages = @session_messages.each_with_index.map do |message, index|
|
|
180
|
+
next message unless Message.parts?(message)
|
|
181
|
+
|
|
182
|
+
checked = index < @seed_message_count && begin
|
|
183
|
+
Message.check_seed!(message)
|
|
184
|
+
rescue WorkflowError
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
raise Smith::SerializationError, "persisted session content parts must be valid seed messages" unless checked
|
|
188
|
+
|
|
189
|
+
{ role: checked["role"], content: checked["content"], parts: true }
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
138
193
|
def validated_seed_message_count(normalized)
|
|
139
194
|
count = normalized.fetch(:seed_message_count, 0)
|
|
140
195
|
return count if count.is_a?(Integer) && count >= 0 && count <= @session_messages.length
|
|
@@ -160,16 +215,17 @@ module Smith
|
|
|
160
215
|
|
|
161
216
|
raise Smith::StepInProgressOnRestore.new(
|
|
162
217
|
workflow: self.class.name,
|
|
163
|
-
persistence_key: normalized[:persistence_key]
|
|
218
|
+
persistence_key: normalized[:persistence_key],
|
|
219
|
+
state: normalized[:state],
|
|
220
|
+
transition: normalized[:next_transition_name]
|
|
164
221
|
)
|
|
165
222
|
end
|
|
166
223
|
|
|
167
224
|
def validate_raw_step_in_progress!(state)
|
|
168
225
|
return unless state.is_a?(Hash)
|
|
169
226
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
validate_step_in_progress!(step_in_progress: marker, persistence_key:)
|
|
227
|
+
fields = %i[step_in_progress persistence_key state next_transition_name]
|
|
228
|
+
validate_step_in_progress!(fields.to_h { |field| [field, state[field] || state[field.to_s]] })
|
|
173
229
|
end
|
|
174
230
|
|
|
175
231
|
def validate_seed_digest!(normalized)
|
|
@@ -225,6 +281,14 @@ module Smith
|
|
|
225
281
|
end
|
|
226
282
|
end
|
|
227
283
|
|
|
284
|
+
# The last assistant message after the seeds: the output the run last
|
|
285
|
+
# added to its transcript, unless the host admitted one after it.
|
|
286
|
+
def restored_transcript_output
|
|
287
|
+
seeds = @seed_message_count || 0
|
|
288
|
+
message = @session_messages.drop(seeds).reverse_each.find { |item| MessageRoles.role(item) == "assistant" }
|
|
289
|
+
message && message[:content]
|
|
290
|
+
end
|
|
291
|
+
|
|
228
292
|
def restore_last_agent_execution(normalized)
|
|
229
293
|
value = if normalized.key?(:last_agent_execution)
|
|
230
294
|
normalized[:last_agent_execution]
|
|
@@ -233,7 +297,9 @@ module Smith
|
|
|
233
297
|
end
|
|
234
298
|
return unless value.is_a?(Hash)
|
|
235
299
|
|
|
236
|
-
symbolize_keys(value)
|
|
300
|
+
execution = symbolize_keys(value)
|
|
301
|
+
execution[:provider] = execution[:provider].to_sym if execution[:provider].is_a?(String)
|
|
302
|
+
execution
|
|
237
303
|
end
|
|
238
304
|
|
|
239
305
|
# Symbolize ONLY the top-level keys of last_failed_step + the
|
|
@@ -252,15 +318,73 @@ module Smith
|
|
|
252
318
|
).call
|
|
253
319
|
end
|
|
254
320
|
|
|
321
|
+
# A state or pending transition the class does not know (a deploy
|
|
322
|
+
# renamed or removed it, or the payload has none) fails the restore.
|
|
323
|
+
# Restoring it would leave the run in a state with no outgoing
|
|
324
|
+
# transitions, which reads as terminal and ends the run silently. Runs
|
|
325
|
+
# after `migrate_from` and after the definition digest check, so a
|
|
326
|
+
# migration can map an old name and a digest mismatch is reported as
|
|
327
|
+
# one.
|
|
328
|
+
def validate_restored_position!(normalized)
|
|
329
|
+
validate_restored_state!(normalized[:state], normalized[:persistence_key])
|
|
330
|
+
validate_restored_transition!(normalized[:next_transition_name], normalized[:persistence_key])
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def validate_restored_state!(state, persistence_key)
|
|
334
|
+
return if state.nil? && self.class.initial_state.nil?
|
|
335
|
+
return if !state.nil? && self.class.known_state?(state)
|
|
336
|
+
|
|
337
|
+
raise Smith::UnknownStateOnRestore.new(workflow: self.class.name, persistence_key:, state:)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def validate_restored_transition!(transition, persistence_key)
|
|
341
|
+
return if transition.nil? || self.class.find_transition(transition)
|
|
342
|
+
|
|
343
|
+
raise Smith::UnknownTransitionOnRestore.new(workflow: self.class.name, persistence_key:, transition:)
|
|
344
|
+
end
|
|
345
|
+
|
|
255
346
|
def restore_core_fields(normalized)
|
|
256
347
|
@state = normalized[:state]
|
|
257
348
|
@context = filter_persisted_context(normalized[:context] || {})
|
|
258
349
|
@step_count = normalized[:step_count] || 0
|
|
259
|
-
@execution_namespace = normalized
|
|
350
|
+
@execution_namespace = restored_execution_namespace(normalized)
|
|
351
|
+
@inherited_untrusted_keys = restored_inherited_untrusted_keys(normalized)
|
|
352
|
+
@inherited_artifact_namespaces = restored_artifact_namespaces(normalized)
|
|
260
353
|
@created_at = normalized[:created_at]
|
|
261
354
|
@updated_at = normalized[:updated_at]
|
|
262
355
|
end
|
|
263
356
|
|
|
357
|
+
def restored_inherited_untrusted_keys(normalized)
|
|
358
|
+
keys = normalized.fetch(:inherited_untrusted_keys, [])
|
|
359
|
+
unless keys.is_a?(Array) && keys.length <= 1_000 && keys.all? { untrusted_key_name?(_1) }
|
|
360
|
+
raise Smith::SerializationError, "persisted workflow inherited_untrusted_keys are invalid"
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
keys.map(&:to_sym).uniq.freeze
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def untrusted_key_name?(key) = (key.is_a?(String) || key.is_a?(Symbol)) && key.length.between?(1, 256)
|
|
367
|
+
|
|
368
|
+
def restored_artifact_namespaces(normalized)
|
|
369
|
+
ArtifactIntegration.artifact_namespaces!(normalized.fetch(:inherited_artifact_namespaces, []))
|
|
370
|
+
rescue WorkflowError
|
|
371
|
+
raise Smith::SerializationError, "persisted workflow inherited_artifact_namespaces are invalid"
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# A payload written before namespaces were assigned at construction
|
|
375
|
+
# gets one derived from its own durable facts, the same on every
|
|
376
|
+
# restore of it, until a checkpoint stores it.
|
|
377
|
+
def restored_execution_namespace(normalized)
|
|
378
|
+
namespace = normalized[:execution_namespace]
|
|
379
|
+
if namespace.nil?
|
|
380
|
+
return IdempotencyKey.__send__(:legacy_namespace, normalized[:class] || self.class.name,
|
|
381
|
+
normalized[:persistence_key], normalized[:created_at])
|
|
382
|
+
end
|
|
383
|
+
return namespace if namespace.is_a?(String) && PreparedStep::UUID_PATTERN.match?(namespace)
|
|
384
|
+
|
|
385
|
+
raise Smith::SerializationError, "persisted workflow execution_namespace must be a UUID"
|
|
386
|
+
end
|
|
387
|
+
|
|
264
388
|
def restore_persisted_keys(normalized)
|
|
265
389
|
@persisted_keys_mutex = Mutex.new
|
|
266
390
|
raw = normalized[:persisted_keys]
|
|
@@ -357,24 +481,22 @@ module Smith
|
|
|
357
481
|
symbolized
|
|
358
482
|
end
|
|
359
483
|
|
|
484
|
+
# The identifier the class itself uses for a payload's state name, so a
|
|
485
|
+
# String-named state stays a String; an unknown name is symbolized.
|
|
360
486
|
def normalize_state_name(value)
|
|
361
487
|
return if value.nil?
|
|
362
|
-
return value if declared_state?(value)
|
|
363
|
-
return value unless value.is_a?(String)
|
|
364
488
|
|
|
365
|
-
|
|
366
|
-
return
|
|
489
|
+
resolved = self.class.resolve_state_name(value)
|
|
490
|
+
return resolved unless resolved.nil?
|
|
367
491
|
|
|
368
|
-
|
|
369
|
-
end
|
|
370
|
-
|
|
371
|
-
def declared_state?(value)
|
|
372
|
-
states = self.class.instance_variable_get(:@states) || []
|
|
373
|
-
states.include?(value)
|
|
492
|
+
value.is_a?(String) ? value.to_sym : value
|
|
374
493
|
end
|
|
375
494
|
|
|
495
|
+
# Context keys are Symbols at every depth, matching what construction
|
|
496
|
+
# and deterministic writes produce, so a nested read behaves the same
|
|
497
|
+
# before and after a restore.
|
|
376
498
|
def normalize_nested_hashes!(normalized)
|
|
377
|
-
normalized[:context] =
|
|
499
|
+
normalized[:context] = ContextKeys.normalize(normalized[:context]) if normalized[:context].is_a?(Hash)
|
|
378
500
|
return unless normalized[:budget_consumed].is_a?(Hash)
|
|
379
501
|
|
|
380
502
|
normalized[:budget_consumed] = symbolize_keys(normalized[:budget_consumed])
|
|
@@ -384,7 +506,12 @@ module Smith
|
|
|
384
506
|
return unless normalized[:session_messages].is_a?(Array)
|
|
385
507
|
|
|
386
508
|
normalized[:session_messages] = normalized[:session_messages].map do |msg|
|
|
387
|
-
|
|
509
|
+
unless MessageRoles.role(msg)
|
|
510
|
+
raise Smith::SerializationError,
|
|
511
|
+
"persisted session message must have the role system, user, assistant or tool"
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
symbolize_keys(snapshot_value(msg))
|
|
388
515
|
end
|
|
389
516
|
end
|
|
390
517
|
|
|
@@ -430,7 +557,20 @@ module Smith
|
|
|
430
557
|
config = self.class.budget
|
|
431
558
|
return nil unless config
|
|
432
559
|
|
|
433
|
-
Budget::Ledger.new(limits: config, consumed: consumed)
|
|
560
|
+
Budget::Ledger.new(limits: config, consumed: declared_budget_consumed(consumed, config))
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# A dimension the workflow no longer declares has no limit, so its
|
|
564
|
+
# consumed entry (a key an earlier version accepted and ignored) is
|
|
565
|
+
# dropped rather than refused.
|
|
566
|
+
def declared_budget_consumed(consumed, config)
|
|
567
|
+
return consumed unless consumed.is_a?(Hash)
|
|
568
|
+
|
|
569
|
+
declared = config.each_key.to_h { |dimension| [dimension.to_s, dimension] }
|
|
570
|
+
consumed.each_with_object({}) do |(key, amount), kept|
|
|
571
|
+
dimension = declared[key.to_s]
|
|
572
|
+
kept[dimension] = amount if dimension
|
|
573
|
+
end
|
|
434
574
|
end
|
|
435
575
|
|
|
436
576
|
def persisted_context
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
# A step's provider calls through a host's response memo
|
|
6
|
+
# (Smith::ResponseMemo): each call takes its kept messages, if any, and
|
|
7
|
+
# sends nothing when they end it (a streamed step's sink gets the reply's
|
|
8
|
+
# text at once), else goes on from them, and is recorded as it goes.
|
|
9
|
+
# Kept tool results count as tools the step ran, and the live part goes
|
|
10
|
+
# on from the kept rounds (Resumed): its first request is a later round,
|
|
11
|
+
# and a pause for budget keeps the kept rounds too.
|
|
12
|
+
module RememberedCalls
|
|
13
|
+
CALLS_LOCK = Mutex.new
|
|
14
|
+
private_constant :CALLS_LOCK
|
|
15
|
+
|
|
16
|
+
# Where a call's live part starts: the call's first message, and the
|
|
17
|
+
# rounds its kept messages made.
|
|
18
|
+
Resumed = Data.define(:start, :rounds)
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def remembered_completion(chat, model_reference, facts, &)
|
|
23
|
+
memo = Smith.config.response_memo
|
|
24
|
+
key = memo && memo_key(chat, facts)
|
|
25
|
+
return yield(nil) unless key
|
|
26
|
+
|
|
27
|
+
start = chat.messages.length
|
|
28
|
+
kept = ResponseMemo.fetch(memo, key)
|
|
29
|
+
keep_messages(chat, kept.fetch(:messages)) if kept
|
|
30
|
+
return replayed_completion(chat, model_reference) if kept&.fetch(:complete)
|
|
31
|
+
|
|
32
|
+
recorded_completion(chat, memo, key, Resumed.new(start:, rounds: kept_rounds(kept)), &)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def kept_rounds(kept) = kept ? kept.fetch(:messages).count { _1.role == :assistant } : 0
|
|
36
|
+
|
|
37
|
+
def memo_key(chat, facts)
|
|
38
|
+
ResponseMemo.key([*@memo_attempts, @step_attempt.to_i], facts[:attempt_index], next_memo_call, chat)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The kept messages, after the call's own; their tool results are
|
|
42
|
+
# tools the step ran.
|
|
43
|
+
def keep_messages(chat, messages)
|
|
44
|
+
messages.each { chat.add_message(_1) }
|
|
45
|
+
Tool.current_tool_execution_tracker&.mark_started! if messages.any? { _1.role == :tool }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def recorded_completion(chat, memo, key, resumed)
|
|
49
|
+
live = chat.messages.length
|
|
50
|
+
result = ResponseMemo.recording(chat_provider(chat), memo, key, resumed.start) { yield(resumed) }
|
|
51
|
+
ResponseMemo.store(memo, key, chat.messages.drop(resumed.start), complete: true,
|
|
52
|
+
facts: streamed_facts(chat, live, result))
|
|
53
|
+
result
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A streamed call's replies' facts, read from its stream.
|
|
57
|
+
def streamed_facts(chat, live, result)
|
|
58
|
+
stream = result[1]
|
|
59
|
+
return {} unless stream.is_a?(Hash) && stream[:rounds]
|
|
60
|
+
|
|
61
|
+
replies = chat.messages.drop(live).select { _1.role == :assistant }
|
|
62
|
+
replies.zip(Providers::StreamFacts.per_message(replies, stream[:rounds], stream[:output_limit]))
|
|
63
|
+
.to_h.compare_by_identity
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def replayed_completion(chat, model_reference)
|
|
67
|
+
response = chat.messages.last
|
|
68
|
+
if (stream = @step_stream)
|
|
69
|
+
stream.start(attempt: @step_attempt, model: model_reference.model_id,
|
|
70
|
+
provider: model_reference.provider&.to_s)
|
|
71
|
+
content = response.content
|
|
72
|
+
stream.delta(content.is_a?(String) ? content : JSON.generate(content))
|
|
73
|
+
end
|
|
74
|
+
[response, nil, nil, { memo: true }]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The call's place among its attempt's calls at its position (a
|
|
78
|
+
# fan-out's branches count their own).
|
|
79
|
+
def next_memo_call
|
|
80
|
+
slot = [IdempotencyKey.__send__(:current_scope)&.key, @step_attempt]
|
|
81
|
+
memo_calls_lock.synchronize do
|
|
82
|
+
@memo_calls ||= {}
|
|
83
|
+
@memo_calls[slot] = @memo_calls.fetch(slot, 0) + 1
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def memo_calls_lock = @memo_calls_lock || CALLS_LOCK.synchronize { @memo_calls_lock ||= Mutex.new }
|
|
88
|
+
|
|
89
|
+
def start_memo_calls = memo_calls_lock.synchronize { @memo_calls = {} }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -5,27 +5,88 @@ module Smith
|
|
|
5
5
|
module RetryExecution
|
|
6
6
|
private
|
|
7
7
|
|
|
8
|
+
# `@step_attempt` is the attempt under way, from 1, which the optimizer
|
|
9
|
+
# names its verdicts by; transient, set here before every attempt.
|
|
10
|
+
#
|
|
11
|
+
# Every attempt sends its requests under the step's idempotency keys,
|
|
12
|
+
# so a retry after an uncertain failure (a timeout, a lost connection)
|
|
13
|
+
# cannot repeat a request that took effect. Only an attempt that
|
|
14
|
+
# failed in a way the step declares settled moves later attempts to
|
|
15
|
+
# new keys. Attempts replay in the same order after a restore, so a
|
|
16
|
+
# replay reaches the same keys as long as the receivers answer the
|
|
17
|
+
# replayed requests as before. A durable policy waits out the backoff
|
|
18
|
+
# as a wait of the run (RetryWait) and resumes at the attempt and key
|
|
19
|
+
# generation it recorded.
|
|
8
20
|
def run_with_retry_policy(transition)
|
|
9
21
|
config = transition.retry_config
|
|
22
|
+
position = pending_batch_position
|
|
23
|
+
@step_attempt = position ? position.first + 1 : 1
|
|
24
|
+
@step_key_generation = position ? position.last : 0
|
|
25
|
+
start_budget_pause(transition)
|
|
26
|
+
start_memo_calls
|
|
10
27
|
return run_guarded_step(transition) unless config
|
|
11
28
|
|
|
29
|
+
run_attempts(transition, config, *(position || pending_retry_position(transition)))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Attempts after the `attempt` already made, under `key_generation`.
|
|
33
|
+
def run_attempts(transition, config, attempt, key_generation)
|
|
12
34
|
schedule = retry_schedule(config)
|
|
13
|
-
attempt = 0
|
|
14
35
|
begin
|
|
15
36
|
attempt += 1
|
|
16
|
-
|
|
37
|
+
@step_attempt = attempt
|
|
38
|
+
@step_key_generation = key_generation
|
|
39
|
+
attempt_scope = attempt_idempotency_scope(key_generation)
|
|
40
|
+
run_attempt_under_keys(transition, attempt_scope)
|
|
17
41
|
rescue StandardError => e
|
|
18
42
|
raise unless retry_transition_error?(config, e, attempt)
|
|
19
43
|
|
|
20
|
-
|
|
44
|
+
key_generation += 1 if new_idempotency_key_after?(config, e, attempt_scope)
|
|
45
|
+
keep_budget_continuation(e)
|
|
46
|
+
delay = retry_delay(schedule, attempt)
|
|
47
|
+
return wait_to_retry(transition, delay, attempt + 1, key_generation, e) if config[:durable]
|
|
48
|
+
|
|
49
|
+
sleep(delay) if delay.positive?
|
|
21
50
|
retry
|
|
22
51
|
end
|
|
23
52
|
end
|
|
24
53
|
|
|
54
|
+
def attempt_idempotency_scope(key_generation)
|
|
55
|
+
step_scope = IdempotencyKey.__send__(:current_scope)
|
|
56
|
+
return step_scope if step_scope.nil? || key_generation.zero?
|
|
57
|
+
|
|
58
|
+
step_scope.within(:key_generation, key_generation)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def run_attempt_under_keys(transition, attempt_scope)
|
|
62
|
+
return run_guarded_step(transition) if attempt_scope.equal?(IdempotencyKey.__send__(:current_scope))
|
|
63
|
+
|
|
64
|
+
IdempotencyKey.__send__(:swap, attempt_scope) { run_guarded_step(transition) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# A `fail!(new_idempotency_key: true)` counts only when this step's own
|
|
68
|
+
# attempt raised it, never a nested workflow's step, whose flag speaks
|
|
69
|
+
# for its own requests and not for the earlier steps of the attempt.
|
|
70
|
+
def new_idempotency_key_after?(config, error, attempt_scope)
|
|
71
|
+
return true if own_settled_failure?(error, attempt_scope)
|
|
72
|
+
|
|
73
|
+
config.fetch(:new_idempotency_key_on, EMPTY_ERROR_CLASSES).any? { |error_class| error.is_a?(error_class) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def own_settled_failure?(error, attempt_scope)
|
|
77
|
+
error.is_a?(DeterministicStepFailure) && error.new_idempotency_key &&
|
|
78
|
+
!attempt_scope.nil? && error.idempotency_scope == attempt_scope
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
EMPTY_ERROR_CLASSES = [].freeze
|
|
82
|
+
private_constant :EMPTY_ERROR_CLASSES
|
|
83
|
+
|
|
25
84
|
def retry_transition_error?(config, error, attempt)
|
|
26
85
|
return false if attempt >= config.fetch(:attempts)
|
|
27
86
|
return false if terminal_retry_error?(error)
|
|
28
87
|
|
|
88
|
+
return false if retry_deadline_passed?
|
|
89
|
+
|
|
29
90
|
classes = config.fetch(:error_classes)
|
|
30
91
|
if classes.any?
|
|
31
92
|
classes.any? { |error_class| error.is_a?(error_class) }
|
|
@@ -34,13 +95,15 @@ module Smith
|
|
|
34
95
|
end
|
|
35
96
|
end
|
|
36
97
|
|
|
37
|
-
|
|
38
|
-
|
|
98
|
+
# No attempt starts once the wall-clock deadline has passed: it could
|
|
99
|
+
# only fail again, after sleeping through its backoff.
|
|
100
|
+
def retry_deadline_passed?
|
|
101
|
+
deadline = effective_deadline
|
|
102
|
+
!deadline.nil? && Time.now.utc >= deadline
|
|
39
103
|
end
|
|
40
104
|
|
|
41
|
-
def
|
|
42
|
-
|
|
43
|
-
sleep(delay) if delay.positive?
|
|
105
|
+
def terminal_retry_error?(error)
|
|
106
|
+
Smith::Errors.retry_forbidden?(error)
|
|
44
107
|
end
|
|
45
108
|
|
|
46
109
|
def retry_delay(config_or_schedule, failed_attempt)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
# The waits of durable retries. An attempt that fails in a way a
|
|
8
|
+
# `retry_on ..., durable: true` policy retries does not sleep: the run
|
|
9
|
+
# enters a wait of kind `retry`, due when the backoff ends, and records
|
|
10
|
+
# its pending retry (the transition, the next attempt, its key
|
|
11
|
+
# generation, and the class of the error that failed the last attempt).
|
|
12
|
+
# `expire_wait!` past the deadline ends the wait without a step and
|
|
13
|
+
# leaves the transition as the run's next; the host continues the run as
|
|
14
|
+
# after any wait, and the step makes the recorded attempt under the
|
|
15
|
+
# recorded keys. A step that ends without entering a wait clears the
|
|
16
|
+
# pending retry (Workflow#advance!).
|
|
17
|
+
module RetryWait
|
|
18
|
+
PENDING_RETRY_FIELDS = %w[attempt error key_generation transition].freeze
|
|
19
|
+
private_constant :PENDING_RETRY_FIELDS
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# Enters the wait, due after the backoff, under a token of the attempt
|
|
24
|
+
# it waits to make. Like any wait it is not a step. The pending retry
|
|
25
|
+
# is recorded before the wait, so an interrupt between the two leaves
|
|
26
|
+
# a step that ended without a wait, whose pending retry is cleared.
|
|
27
|
+
def wait_to_retry(transition, seconds, attempt, key_generation, error)
|
|
28
|
+
if @split_step_phase == :executing
|
|
29
|
+
raise WorkflowError, "a durable retry cannot wait inside split-step execution"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
check_wait_ttl!(seconds)
|
|
33
|
+
error_name = DiagnosticText.error_class_name(error)
|
|
34
|
+
@pending_retry = { "transition" => transition.name.to_s, "attempt" => attempt,
|
|
35
|
+
"key_generation" => key_generation, "error" => error_name,
|
|
36
|
+
"continuation" => @budget_continuation }.compact.freeze
|
|
37
|
+
@retry_wait_error = error
|
|
38
|
+
@awaiting = WaitRecord.build_retry(transition, wait_token_for(transition, ["retry", attempt]), seconds,
|
|
39
|
+
Time.now.utc)
|
|
40
|
+
record_retry_trace(transition, :retrying, attempt:, error: error_name, deadline_at: @awaiting["deadline_at"])
|
|
41
|
+
WAIT_PENDING
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A step that ran and did not enter a wait has made its pending retry,
|
|
45
|
+
# whether it completed, failed or raised.
|
|
46
|
+
def clear_settled_pending_retry
|
|
47
|
+
@pending_retry = nil if step_work_started? && !waiting?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# [attempts already made, key generation] where the step starts: the
|
|
51
|
+
# pending retry's for its transition, else [0, 0].
|
|
52
|
+
def pending_retry_position(transition)
|
|
53
|
+
pending = @pending_retry
|
|
54
|
+
return [0, 0] unless pending && pending["transition"] == transition.name.to_s
|
|
55
|
+
|
|
56
|
+
[pending["attempt"] - 1, pending["key_generation"]]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The backoff is over: the transition becomes the run's next step, then
|
|
60
|
+
# the wait ends; nothing counts. Returns :expired.
|
|
61
|
+
def end_retry_wait(transition)
|
|
62
|
+
@next_transition_name = transition.name
|
|
63
|
+
@updated_at = Time.now.utc.iso8601(6)
|
|
64
|
+
@awaiting = nil
|
|
65
|
+
Attribution.with(execution_key: @persistence_key, workflow: self.class.name || "anonymous") do
|
|
66
|
+
record_retry_trace(transition, :retry_due, attempt: @pending_retry&.fetch("attempt"))
|
|
67
|
+
end
|
|
68
|
+
:expired
|
|
69
|
+
ensure
|
|
70
|
+
@wait_claimed = false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def record_retry_trace(transition, outcome, **details)
|
|
74
|
+
Smith::Trace.record(
|
|
75
|
+
type: :transition,
|
|
76
|
+
data: { transition: transition.name, from: transition.from, to: transition.to, outcome:, **details }
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The error that failed the attempt a retry wait follows, while this
|
|
81
|
+
# instance holds it (for a nested child's parent).
|
|
82
|
+
def retry_wait_error = (@retry_wait_error if retry_waiting?)
|
|
83
|
+
|
|
84
|
+
# A persisted pending retry names the transition of the stored retry
|
|
85
|
+
# wait, or else the run's next one, with an attempt after the first, a
|
|
86
|
+
# key generation an earlier attempt could reach, and an error class
|
|
87
|
+
# name; a stored retry wait needs one. A malformed one raises
|
|
88
|
+
# SerializationError; one the class no longer allows (the transition
|
|
89
|
+
# declares no durable retry, or fewer attempts) is refused like an
|
|
90
|
+
# unknown position.
|
|
91
|
+
def restore_pending_retry(value, persistence_key)
|
|
92
|
+
return if value.nil? && !retry_waiting?
|
|
93
|
+
|
|
94
|
+
record = value.is_a?(Hash) ? value.transform_keys(&:to_s) : {}
|
|
95
|
+
transition = pending_retry_transition(record)
|
|
96
|
+
raise Smith::SerializationError, "persisted workflow pending_retry is invalid" unless
|
|
97
|
+
well_formed_pending_retry?(transition, record)
|
|
98
|
+
|
|
99
|
+
refuse_disallowed_pending_retry!(transition, record, persistence_key)
|
|
100
|
+
record.transform_values(&:freeze).freeze
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def retry_waiting? = @awaiting&.fetch("kind") == "retry"
|
|
104
|
+
|
|
105
|
+
# The transition the record names, when it is the stored retry wait's,
|
|
106
|
+
# or the run's next while the run does not wait.
|
|
107
|
+
def pending_retry_transition(record)
|
|
108
|
+
return if @awaiting && !retry_waiting?
|
|
109
|
+
|
|
110
|
+
name = @awaiting ? @awaiting["transition"] : @next_transition_name&.to_s
|
|
111
|
+
self.class.transition_named(name) if name && record["transition"] == name
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def well_formed_pending_retry?(transition, record)
|
|
115
|
+
!transition.nil? && (record.keys - ["continuation"]).sort == PENDING_RETRY_FIELDS &&
|
|
116
|
+
pending_retry_fields?(record) &&
|
|
117
|
+
(!record.key?("continuation") || Budget::Continuation.valid?(record["continuation"]))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def pending_retry_fields?(record)
|
|
121
|
+
attempt, generation, error = record.values_at("attempt", "key_generation", "error")
|
|
122
|
+
attempt.is_a?(Integer) && attempt >= 2 && generation.is_a?(Integer) && generation.between?(0, attempt - 1) &&
|
|
123
|
+
error.is_a?(String) && error.bytesize.between?(1, 256)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def refuse_disallowed_pending_retry!(transition, record, persistence_key)
|
|
127
|
+
return if transition.durable_retry? && record["attempt"] <= transition.retry_config.fetch(:attempts)
|
|
128
|
+
|
|
129
|
+
raise Smith::UnknownTransitionOnRestore.new(workflow: self.class.name, persistence_key:,
|
|
130
|
+
transition: transition.name)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -33,6 +33,16 @@ module Smith
|
|
|
33
33
|
steps.reverse.map { |step| step[:error] }.compact.first
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
# Every verdict an optimize step's loops recorded, in step, attempt and
|
|
37
|
+
# round order, each a frozen record naming its step's transition. Like
|
|
38
|
+
# `steps`, they belong to the run that executed the step: a terminal
|
|
39
|
+
# restore has none.
|
|
40
|
+
def evaluations
|
|
41
|
+
steps.flat_map do |step|
|
|
42
|
+
(step[:evaluations] || []).map { |evaluation| { transition: step[:transition], **evaluation }.freeze }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
36
46
|
def failed_transition
|
|
37
47
|
failure_detail&.fetch(:transition)
|
|
38
48
|
end
|
|
@@ -35,6 +35,9 @@ module Smith
|
|
|
35
35
|
end
|
|
36
36
|
@split_step_mutex.synchronize { @split_step_phase = :checkpointed } if checkpoint_claimed
|
|
37
37
|
result
|
|
38
|
+
rescue PersistedStateTooLarge
|
|
39
|
+
release_split_step_checkpoint! if checkpoint_claimed
|
|
40
|
+
raise
|
|
38
41
|
rescue StandardError
|
|
39
42
|
mark_split_step_checkpoint_unknown! if checkpoint_claimed
|
|
40
43
|
raise
|
|
@@ -40,6 +40,13 @@ module Smith
|
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
# A checkpoint refused before anything was written may be made again.
|
|
44
|
+
def release_split_step_checkpoint!
|
|
45
|
+
@split_step_mutex.synchronize do
|
|
46
|
+
@split_step_phase = :checkpoint_retryable if @split_step_phase == :checkpointing
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
43
50
|
def mark_split_step_checkpoint_unknown!
|
|
44
51
|
@split_step_mutex.synchronize do
|
|
45
52
|
@split_step_phase = :checkpoint_unknown if @split_step_phase == :checkpointing
|