smith-agents 0.4.5 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +264 -0
  3. data/README.md +191 -4
  4. data/docs/CONFIGURATION.md +4 -1
  5. data/docs/PATTERNS.md +7 -0
  6. data/docs/PERSISTENCE.md +347 -25
  7. data/lib/smith/agent/lifecycle.rb +19 -10
  8. data/lib/smith/agent/registry/execution_binding_capture.rb +26 -0
  9. data/lib/smith/agent/registry/mutation_boundary.rb +29 -0
  10. data/lib/smith/agent/registry.rb +8 -0
  11. data/lib/smith/agent.rb +37 -2
  12. data/lib/smith/budget/amount_contract.rb +67 -0
  13. data/lib/smith/budget/amount_representation.rb +43 -0
  14. data/lib/smith/budget/decimal_context.rb +18 -0
  15. data/lib/smith/budget/ledger.rb +110 -18
  16. data/lib/smith/budget/ledger_publication.rb +60 -0
  17. data/lib/smith/budget/ledger_state.rb +21 -0
  18. data/lib/smith/budget/ledger_state_transition.rb +72 -0
  19. data/lib/smith/budget/public_state_validator.rb +25 -0
  20. data/lib/smith/budget/reservation.rb +20 -0
  21. data/lib/smith/budget/reservation_contract.rb +34 -0
  22. data/lib/smith/context/observation_masking.rb +3 -1
  23. data/lib/smith/context/state_injection.rb +1 -1
  24. data/lib/smith/doctor/checks/persistence_capabilities.rb +18 -4
  25. data/lib/smith/error.rb +5 -0
  26. data/lib/smith/errors.rb +10 -0
  27. data/lib/smith/exponential_backoff.rb +128 -0
  28. data/lib/smith/persistence_adapters/active_record_exact_predicate.rb +43 -0
  29. data/lib/smith/persistence_adapters/active_record_exact_store.rb +62 -0
  30. data/lib/smith/persistence_adapters/active_record_exact_write.rb +40 -0
  31. data/lib/smith/persistence_adapters/active_record_store.rb +7 -4
  32. data/lib/smith/persistence_adapters/cache_store.rb +1 -0
  33. data/lib/smith/persistence_adapters/memory.rb +16 -1
  34. data/lib/smith/persistence_adapters/redis_client_access.rb +38 -0
  35. data/lib/smith/persistence_adapters/redis_exact_write.rb +44 -0
  36. data/lib/smith/persistence_adapters/redis_store.rb +28 -11
  37. data/lib/smith/persistence_adapters/retry.rb +22 -10
  38. data/lib/smith/persistence_adapters.rb +21 -18
  39. data/lib/smith/persistence_payload_conflict.rb +12 -0
  40. data/lib/smith/tool/budget_enforcement.rb +2 -2
  41. data/lib/smith/types.rb +1 -0
  42. data/lib/smith/version.rb +2 -1
  43. data/lib/smith/workflow/branch_env.rb +1 -1
  44. data/lib/smith/workflow/budget_integration.rb +21 -11
  45. data/lib/smith/workflow/composite/branch.rb +93 -0
  46. data/lib/smith/workflow/composite/branch_budget_contract.rb +41 -0
  47. data/lib/smith/workflow/composite/branch_contract.rb +102 -0
  48. data/lib/smith/workflow/composite/branch_execution.rb +124 -0
  49. data/lib/smith/workflow/composite/branch_failure.rb +91 -0
  50. data/lib/smith/workflow/composite/branch_outcome.rb +108 -0
  51. data/lib/smith/workflow/composite/budget_allocator.rb +78 -0
  52. data/lib/smith/workflow/composite/budget_math.rb +51 -0
  53. data/lib/smith/workflow/composite/contract.rb +100 -0
  54. data/lib/smith/workflow/composite/effects.rb +140 -0
  55. data/lib/smith/workflow/composite/effects_application.rb +36 -0
  56. data/lib/smith/workflow/composite/effects_baseline.rb +36 -0
  57. data/lib/smith/workflow/composite/effects_preflight.rb +102 -0
  58. data/lib/smith/workflow/composite/encoded_value_budget.rb +36 -0
  59. data/lib/smith/workflow/composite/enums.rb +27 -0
  60. data/lib/smith/workflow/composite/error.rb +54 -0
  61. data/lib/smith/workflow/composite/error_evidence.rb +61 -0
  62. data/lib/smith/workflow/composite/execution_contract.rb +73 -0
  63. data/lib/smith/workflow/composite/fanout_branch_contract.rb +61 -0
  64. data/lib/smith/workflow/composite/input.rb +53 -0
  65. data/lib/smith/workflow/composite/outcome_accumulator.rb +131 -0
  66. data/lib/smith/workflow/composite/outcome_set.rb +23 -0
  67. data/lib/smith/workflow/composite/payload.rb +128 -0
  68. data/lib/smith/workflow/composite/payload_digest.rb +28 -0
  69. data/lib/smith/workflow/composite/plan.rb +130 -0
  70. data/lib/smith/workflow/composite/plan_integrity.rb +52 -0
  71. data/lib/smith/workflow/composite/planner.rb +53 -0
  72. data/lib/smith/workflow/composite/preparation.rb +27 -0
  73. data/lib/smith/workflow/composite/reducer.rb +133 -0
  74. data/lib/smith/workflow/composite/reduction.rb +31 -0
  75. data/lib/smith/workflow/composite/value_budget.rb +90 -0
  76. data/lib/smith/workflow/composite_branch_execution_authorization.rb +52 -0
  77. data/lib/smith/workflow/deadline_enforcement.rb +22 -5
  78. data/lib/smith/workflow/definition_identity/class_methods.rb +48 -0
  79. data/lib/smith/workflow/definition_identity.rb +25 -0
  80. data/lib/smith/workflow/deterministic_step.rb +2 -2
  81. data/lib/smith/workflow/dsl.rb +73 -5
  82. data/lib/smith/workflow/durability.rb +1 -1
  83. data/lib/smith/workflow/evaluator_optimizer.rb +7 -11
  84. data/lib/smith/workflow/execution.rb +28 -30
  85. data/lib/smith/workflow/execution_binding_resolution.rb +16 -0
  86. data/lib/smith/workflow/execution_result_snapshot.rb +118 -0
  87. data/lib/smith/workflow/fanout_execution.rb +20 -19
  88. data/lib/smith/workflow/graph/diagnostic.rb +27 -1
  89. data/lib/smith/workflow/graph/diagnostic_path.rb +24 -0
  90. data/lib/smith/workflow/graph/execution_successors.rb +65 -0
  91. data/lib/smith/workflow/graph/identifier_projection.rb +13 -0
  92. data/lib/smith/workflow/graph/metrics.rb +1 -3
  93. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +15 -16
  94. data/lib/smith/workflow/graph/optimization_contract.rb +2 -2
  95. data/lib/smith/workflow/graph/orchestration_contract.rb +3 -3
  96. data/lib/smith/workflow/graph/reachability.rb +19 -31
  97. data/lib/smith/workflow/graph/reachability_diagnostics.rb +17 -1
  98. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +36 -0
  99. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +2 -2
  100. data/lib/smith/workflow/graph/runtime_readiness.rb +3 -44
  101. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +2 -2
  102. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +47 -0
  103. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +108 -0
  104. data/lib/smith/workflow/graph/state_diagnostics.rb +2 -2
  105. data/lib/smith/workflow/graph/transition_contract.rb +47 -0
  106. data/lib/smith/workflow/graph/transition_contract_attributes.rb +73 -0
  107. data/lib/smith/workflow/graph/transition_contract_configurations.rb +100 -0
  108. data/lib/smith/workflow/graph/transition_diagnostics.rb +54 -12
  109. data/lib/smith/workflow/graph/transition_optimization_configuration.rb +62 -0
  110. data/lib/smith/workflow/graph/transition_snapshot.rb +4 -1
  111. data/lib/smith/workflow/graph/validator.rb +1 -1
  112. data/lib/smith/workflow/graph.rb +62 -4
  113. data/lib/smith/workflow/guardrail_integration.rb +23 -9
  114. data/lib/smith/workflow/identifier.rb +24 -0
  115. data/lib/smith/workflow/message_admission.rb +40 -0
  116. data/lib/smith/workflow/message_admission_boundary.rb +22 -0
  117. data/lib/smith/workflow/message_batch.rb +60 -0
  118. data/lib/smith/workflow/message_value_normalizer.rb +134 -0
  119. data/lib/smith/workflow/nested_execution.rb +10 -0
  120. data/lib/smith/workflow/orchestrator_worker.rb +3 -7
  121. data/lib/smith/workflow/parallel/cancellation_signal.rb +14 -5
  122. data/lib/smith/workflow/parallel/execution_context.rb +113 -0
  123. data/lib/smith/workflow/parallel/nested_execution.rb +124 -0
  124. data/lib/smith/workflow/parallel/root_execution.rb +91 -0
  125. data/lib/smith/workflow/parallel.rb +19 -22
  126. data/lib/smith/workflow/parallel_agent_binding.rb +24 -0
  127. data/lib/smith/workflow/parallel_execution.rb +46 -14
  128. data/lib/smith/workflow/persistence.rb +37 -8
  129. data/lib/smith/workflow/prepared_branch_execution.rb +31 -0
  130. data/lib/smith/workflow/prepared_step.rb +122 -0
  131. data/lib/smith/workflow/prepared_step_dispatch.rb +88 -0
  132. data/lib/smith/workflow/prepared_step_execution_authorization.rb +101 -0
  133. data/lib/smith/workflow/prepared_step_execution_result.rb +49 -0
  134. data/lib/smith/workflow/prepared_step_execution_scope.rb +111 -0
  135. data/lib/smith/workflow/prepared_step_recovery.rb +38 -0
  136. data/lib/smith/workflow/process_local.rb +33 -0
  137. data/lib/smith/workflow/retry_execution.rb +20 -11
  138. data/lib/smith/workflow/split_step_persistence/boundary.rb +35 -22
  139. data/lib/smith/workflow/split_step_persistence/boundary_reset.rb +50 -0
  140. data/lib/smith/workflow/split_step_persistence/canonical_payload_digest.rb +55 -0
  141. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -5
  142. data/lib/smith/workflow/split_step_persistence/composite_branch_authorization.rb +99 -0
  143. data/lib/smith/workflow/split_step_persistence/composite_branch_effects.rb +41 -0
  144. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +95 -0
  145. data/lib/smith/workflow/split_step_persistence/composite_branch_outcome.rb +36 -0
  146. data/lib/smith/workflow/split_step_persistence/composite_execution.rb +39 -0
  147. data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +57 -0
  148. data/lib/smith/workflow/split_step_persistence/composite_reduction_execution.rb +122 -0
  149. data/lib/smith/workflow/split_step_persistence/definition_boundary.rb +19 -0
  150. data/lib/smith/workflow/split_step_persistence/dispatch_boundary.rb +16 -0
  151. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +118 -0
  152. data/lib/smith/workflow/split_step_persistence/dispatch_confirmation.rb +70 -0
  153. data/lib/smith/workflow/split_step_persistence/dispatch_verification.rb +35 -0
  154. data/lib/smith/workflow/split_step_persistence/execution.rb +20 -64
  155. data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +109 -0
  156. data/lib/smith/workflow/split_step_persistence/execution_authorization_issuance.rb +57 -0
  157. data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +95 -0
  158. data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +109 -0
  159. data/lib/smith/workflow/split_step_persistence/execution_lifecycle.rb +96 -0
  160. data/lib/smith/workflow/split_step_persistence/execution_result_capture.rb +48 -0
  161. data/lib/smith/workflow/split_step_persistence/execution_verification.rb +77 -0
  162. data/lib/smith/workflow/split_step_persistence/execution_workflow_snapshot.rb +77 -0
  163. data/lib/smith/workflow/split_step_persistence/inheritance.rb +3 -1
  164. data/lib/smith/workflow/split_step_persistence/payloads.rb +7 -3
  165. data/lib/smith/workflow/split_step_persistence/preparation.rb +30 -2
  166. data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +38 -12
  167. data/lib/smith/workflow/split_step_persistence/preparation_payload.rb +25 -0
  168. data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +10 -2
  169. data/lib/smith/workflow/split_step_persistence/recovery.rb +125 -0
  170. data/lib/smith/workflow/split_step_persistence/recovery_boundary.rb +56 -0
  171. data/lib/smith/workflow/split_step_persistence/recovery_class_methods.rb +14 -0
  172. data/lib/smith/workflow/split_step_persistence/replace_exact_signature.rb +46 -0
  173. data/lib/smith/workflow/split_step_persistence/restart_safe_adapter.rb +42 -0
  174. data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +66 -0
  175. data/lib/smith/workflow/split_step_persistence/transaction_identity.rb +45 -0
  176. data/lib/smith/workflow/split_step_persistence.rb +50 -0
  177. data/lib/smith/workflow/step_completion.rb +31 -0
  178. data/lib/smith/workflow/step_context.rb +46 -0
  179. data/lib/smith/workflow/string_snapshot.rb +25 -0
  180. data/lib/smith/workflow/thread_context_snapshot.rb +103 -0
  181. data/lib/smith/workflow/transition.rb +137 -133
  182. data/lib/smith/workflow/transition_actionability.rb +22 -0
  183. data/lib/smith/workflow.rb +64 -18
  184. data/lib/smith.rb +44 -2
  185. data/sig/smith.rbs +1 -0
  186. metadata +155 -19
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ require_relative "../errors"
6
+ require_relative "string_snapshot"
7
+
8
+ module Smith
9
+ class Workflow
10
+ class MessageValueNormalizer
11
+ MAX_DEPTH = 64
12
+ MAX_NODES = 100_000
13
+ MAX_BYTES = 1 * 1024 * 1024
14
+ INTEGER_RANGE = (-(2**63)..((2**63) - 1))
15
+ ARRAY_EACH = Array.instance_method(:each)
16
+ HASH_EACH_PAIR = Hash.instance_method(:each_pair)
17
+ private_constant :ARRAY_EACH, :HASH_EACH_PAIR
18
+
19
+ extend Dry::Initializer
20
+
21
+ param :value
22
+ option :label, default: proc { "session message" }
23
+
24
+ def call
25
+ @active = {}.compare_by_identity
26
+ @nodes = 0
27
+ @bytes = 0
28
+ copy_value(value, depth: 0)
29
+ end
30
+
31
+ private
32
+
33
+ def copy_value(item, depth:)
34
+ visit!(depth)
35
+ return copy_container_value(item, depth:) if item.is_a?(Hash) || item.is_a?(Array)
36
+
37
+ copy_scalar(item)
38
+ end
39
+
40
+ def copy_container_value(item, depth:)
41
+ copy_container(item) do
42
+ item.is_a?(Hash) ? copy_hash(item, depth:) : copy_array(item, depth:)
43
+ end
44
+ end
45
+
46
+ def copy_scalar(item)
47
+ case item
48
+ when String then copy_string(item)
49
+ when Symbol then copy_string(item.to_s)
50
+ when Integer then copy_integer(item)
51
+ when Float then copy_float(item)
52
+ when true, false, nil then item
53
+ else
54
+ reject!("#{label} contains unsupported value #{item.class}")
55
+ end
56
+ end
57
+
58
+ def copy_container(source)
59
+ reject!("#{label} contains a cyclic value") if @active.key?(source)
60
+
61
+ @active[source] = true
62
+ yield
63
+ ensure
64
+ @active.delete(source)
65
+ end
66
+
67
+ def copy_hash(source, depth:)
68
+ pairs = canonical_pairs(source)
69
+ pairs.sort_by!(&:first)
70
+ validate_unique_keys!(pairs)
71
+
72
+ pairs.each_with_object({}) do |(key, nested), copy|
73
+ copy[key] = copy_value(nested, depth: depth + 1)
74
+ end.freeze
75
+ end
76
+
77
+ def canonical_pairs(source)
78
+ pairs = []
79
+ remaining_nodes = MAX_NODES - @nodes
80
+ HASH_EACH_PAIR.bind_call(source) do |key, nested|
81
+ reject!("#{label} exceeds maximum size #{MAX_NODES}") if pairs.length == remaining_nodes
82
+ reject!("#{label} Hash keys must be strings or symbols") unless key.is_a?(String) || key.is_a?(Symbol)
83
+
84
+ pairs << [copy_string(key.to_s), nested]
85
+ end
86
+ pairs
87
+ end
88
+
89
+ def validate_unique_keys!(pairs)
90
+ return unless pairs.each_cons(2).any? { |left, right| left.first == right.first }
91
+
92
+ reject!("#{label} contains duplicate canonical Hash keys")
93
+ end
94
+
95
+ def copy_array(source, depth:)
96
+ copy = []
97
+ ARRAY_EACH.bind_call(source) { |nested| copy << copy_value(nested, depth: depth + 1) }
98
+ copy.freeze
99
+ end
100
+
101
+ def copy_string(string)
102
+ @bytes += StringSnapshot.bytesize(string)
103
+ reject!("#{label} exceeds maximum bytes #{MAX_BYTES}") if @bytes > MAX_BYTES
104
+
105
+ StringSnapshot.copy(string, freeze: true)
106
+ end
107
+
108
+ def copy_integer(integer)
109
+ return integer if INTEGER_RANGE.cover?(integer)
110
+
111
+ reject!("#{label} integers must fit a signed 64-bit value")
112
+ end
113
+
114
+ def copy_float(float)
115
+ return float if float.finite?
116
+
117
+ reject!("#{label} contains a non-finite Float")
118
+ end
119
+
120
+ def visit!(depth)
121
+ reject!("#{label} exceeds maximum depth #{MAX_DEPTH}") if depth > MAX_DEPTH
122
+
123
+ @nodes += 1
124
+ return if @nodes <= MAX_NODES
125
+
126
+ reject!("#{label} exceeds maximum size #{MAX_NODES}")
127
+ end
128
+
129
+ def reject!(message)
130
+ raise WorkflowError, message
131
+ end
132
+ end
133
+ end
134
+ end
@@ -21,10 +21,20 @@ module Smith
21
21
  end
22
22
 
23
23
  def build_child_workflow(child_class)
24
+ @split_step_active_execution_authorization&.verify_workflow!(child_class)
24
25
  child = child_class.new(context: @context.dup, ledger: @ledger, created_at: @created_at)
26
+ unless child.instance_of?(child_class)
27
+ raise WorkflowError, "nested workflow constructor returned #{child.class} instead of #{child_class}"
28
+ end
29
+
30
+ @split_step_active_execution_authorization&.verify_workflow!(child_class)
25
31
  child.instance_variable_set(:@execution_namespace, @execution_namespace)
26
32
  child.instance_variable_set(:@inherited_deadline, wall_clock_deadline)
27
33
  child.instance_variable_set(:@inherited_scoped_artifacts, Smith.scoped_artifacts)
34
+ child.instance_variable_set(
35
+ :@split_step_active_execution_authorization,
36
+ @split_step_active_execution_authorization
37
+ )
28
38
  child
29
39
  end
30
40
 
@@ -23,13 +23,13 @@ module Smith
23
23
 
24
24
  def execute_orchestration_step(transition, prepared_input: nil)
25
25
  state = OrchestrationState.new(transition.orchestrator_config, prepared_input)
26
- state.orchestrator_class = Agent::Registry.fetch!(
26
+ state.orchestrator_class = resolve_registered_agent!(
27
27
  state.config[:orchestrator],
28
28
  workflow_class: self.class,
29
29
  transition_name: transition.name,
30
30
  role: :orchestrator
31
31
  )
32
- state.worker_class = Agent::Registry.fetch!(
32
+ state.worker_class = resolve_registered_agent!(
33
33
  state.config[:worker],
34
34
  workflow_class: self.class,
35
35
  transition_name: transition.name,
@@ -77,11 +77,7 @@ module Smith
77
77
 
78
78
  def run_worker_with_schema(worker_class, task, worker_output_schema)
79
79
  input = [{ role: :user, content: task.to_json }]
80
- original_schema = worker_class.output_schema
81
- worker_class.output_schema(worker_output_schema)
82
- invoke_agent_with_budget(worker_class, input)
83
- ensure
84
- worker_class.output_schema(original_schema)
80
+ invoke_agent_with_budget(worker_class, input, output_schema: worker_output_schema)
85
81
  end
86
82
 
87
83
  def prepare_orchestrator_input(prepared_input, round, worker_results)
@@ -3,17 +3,26 @@
3
3
  module Smith
4
4
  class Workflow
5
5
  class Parallel
6
- CancellationSignal = Struct.new(:cancelled, :mutex) do
6
+ class CancellationSignal
7
7
  def initialize
8
- super(false, Mutex.new)
8
+ @cancelled = false
9
+ @reason = nil
10
+ @mutex = Mutex.new
9
11
  end
10
12
 
11
- def cancel!
12
- mutex.synchronize { self.cancelled = true }
13
+ def cancel!(error = nil)
14
+ @mutex.synchronize do
15
+ @reason ||= error
16
+ @cancelled = true
17
+ end
13
18
  end
14
19
 
15
20
  def cancelled?
16
- mutex.synchronize { cancelled }
21
+ @mutex.synchronize { @cancelled }
22
+ end
23
+
24
+ def reason
25
+ @mutex.synchronize { @reason }
17
26
  end
18
27
  end
19
28
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Workflow
7
+ class Parallel
8
+ class ExecutionContext
9
+ extend Dry::Initializer
10
+
11
+ THREAD_KEY = :smith_parallel_execution_context
12
+ DEPTH_KEY = :smith_parallel_execution_depth
13
+
14
+ option :executor
15
+ option :signal
16
+ option :concurrency
17
+ option :nesting_limit
18
+ option :top_level_branch_count
19
+
20
+ def initialize(...)
21
+ super
22
+ @pending_top_level = top_level_branch_count
23
+ @active_top_level = 0
24
+ @reserved_nested_workers = 0
25
+ @mutex = Mutex.new
26
+ end
27
+
28
+ def self.current
29
+ Fiber[THREAD_KEY]
30
+ end
31
+
32
+ def self.current_depth
33
+ Fiber[DEPTH_KEY] || 0
34
+ end
35
+
36
+ def within(depth: self.class.current_depth)
37
+ previous = self.class.current
38
+ previous_depth = Fiber[DEPTH_KEY]
39
+ Fiber[THREAD_KEY] = self
40
+ Fiber[DEPTH_KEY] = depth
41
+ yield
42
+ ensure
43
+ Fiber[THREAD_KEY] = previous
44
+ Fiber[DEPTH_KEY] = previous_depth
45
+ end
46
+
47
+ def raise_if_cancelled!
48
+ raise(signal.reason || Cancellation.new("cancelled")) if signal.cancelled?
49
+ end
50
+
51
+ def next_nesting_depth!
52
+ depth = self.class.current_depth + 1
53
+ return depth if depth <= nesting_limit
54
+
55
+ raise WorkflowError, "parallel nesting exceeds configured limit #{nesting_limit}"
56
+ end
57
+
58
+ def top_level_started!
59
+ @mutex.synchronize do
60
+ @pending_top_level -= 1
61
+ @active_top_level += 1
62
+ validate_worker_count!
63
+ end
64
+ end
65
+
66
+ def top_level_finished!
67
+ @mutex.synchronize do
68
+ @active_top_level -= 1
69
+ validate_worker_count!
70
+ end
71
+ end
72
+
73
+ def reserve_workers(requested)
74
+ @mutex.synchronize do
75
+ reserved = [requested, available_workers].min
76
+ @reserved_nested_workers += reserved
77
+ validate_worker_count!
78
+ reserved
79
+ end
80
+ end
81
+
82
+ def release_workers(count)
83
+ @mutex.synchronize do
84
+ @reserved_nested_workers -= count
85
+ validate_worker_count!
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ def validate_worker_count!
92
+ valid = @pending_top_level >= 0 && @active_top_level >= 0 && @reserved_nested_workers >= 0
93
+ return if valid && occupied_workers <= concurrency
94
+
95
+ raise WorkflowError, "parallel worker accounting exceeded its concurrency bound"
96
+ end
97
+
98
+ def available_workers
99
+ concurrency - occupied_workers
100
+ end
101
+
102
+ def occupied_workers
103
+ @active_top_level + reserved_top_level_workers + @reserved_nested_workers
104
+ end
105
+
106
+ def reserved_top_level_workers
107
+ capacity = [concurrency - @active_top_level - @reserved_nested_workers, 0].max
108
+ [@pending_top_level, capacity].min
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+ require "dry-initializer"
5
+
6
+ module Smith
7
+ class Workflow
8
+ class Parallel
9
+ class NestedExecution
10
+ extend Dry::Initializer
11
+
12
+ option :branches
13
+ option :context
14
+
15
+ def initialize(...)
16
+ super
17
+ @depth = context.next_nesting_depth!
18
+ end
19
+
20
+ def call
21
+ Thread.handle_interrupt(Object => :never) do
22
+ prepare
23
+ Thread.handle_interrupt(Object => :immediate) { execute }
24
+ rescue Exception => e # rubocop:disable Lint/RescueException
25
+ context.signal.cancel!(e)
26
+ raise
27
+ ensure
28
+ drain_futures
29
+ release_workers
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def prepare
36
+ context.raise_if_cancelled!
37
+ @reserved_workers = context.reserve_workers(branches.length - 1)
38
+ return if @reserved_workers.zero?
39
+
40
+ @values = Array.new(branches.length)
41
+ @cursor = Concurrent::AtomicFixnum.new(0)
42
+ @futures = []
43
+ @reserved_workers.times { @futures << build_future }
44
+ end
45
+
46
+ def execute
47
+ return execute_inline if @reserved_workers.zero?
48
+
49
+ execute_concurrently
50
+ end
51
+
52
+ def execute_concurrently
53
+ fulfilled, reasons = execute_workers
54
+ raise_worker_failure!(fulfilled, reasons)
55
+ @values
56
+ end
57
+
58
+ def execute_workers
59
+ current_error = capture_error { work }
60
+ fulfilled, _values, reasons = Concurrent::Promises.zip(*@futures).result
61
+ [fulfilled, [context.signal.reason, current_error, *reasons]]
62
+ end
63
+
64
+ def raise_worker_failure!(fulfilled, reasons)
65
+ error = Parallel.preferred_error(reasons)
66
+ raise(error || Cancellation.new("parallel execution failed")) unless fulfilled && !error
67
+ end
68
+
69
+ def build_future
70
+ Concurrent::Promises.future_on(context.executor, context, @depth) do |execution_context, depth|
71
+ execution_context.within(depth:) { work }
72
+ end
73
+ end
74
+
75
+ def work
76
+ while (index = next_index)
77
+ @values[index] = execute_branch(index)
78
+ end
79
+ rescue StandardError => e
80
+ context.signal.cancel!(e)
81
+ raise
82
+ end
83
+
84
+ def next_index
85
+ context.raise_if_cancelled!
86
+ index = @cursor.increment - 1
87
+ return if index >= branches.length
88
+
89
+ context.raise_if_cancelled!
90
+ index
91
+ end
92
+
93
+ def execute_branch(index)
94
+ context.within(depth: @depth) { branches.fetch(index).call(context.signal) }
95
+ end
96
+
97
+ def execute_inline
98
+ branches.map do |branch|
99
+ context.raise_if_cancelled!
100
+ context.within(depth: @depth) { branch.call(context.signal) }
101
+ end
102
+ rescue StandardError => e
103
+ context.signal.cancel!(e)
104
+ raise
105
+ end
106
+
107
+ def capture_error
108
+ yield
109
+ nil
110
+ rescue StandardError => e
111
+ e
112
+ end
113
+
114
+ def drain_futures
115
+ Concurrent::Promises.zip(*@futures).result if @futures&.any?
116
+ end
117
+
118
+ def release_workers
119
+ context.release_workers(@reserved_workers) if @reserved_workers&.positive?
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+ require "dry-initializer"
5
+
6
+ module Smith
7
+ class Workflow
8
+ class Parallel
9
+ class RootExecution
10
+ extend Dry::Initializer
11
+
12
+ option :branches
13
+
14
+ def call
15
+ Thread.handle_interrupt(Object => :never) do
16
+ prepare
17
+ values = Thread.handle_interrupt(Object => :immediate) { resolve(@futures) }
18
+ @completed = true
19
+ values
20
+ rescue Exception => e # rubocop:disable Lint/RescueException
21
+ @signal&.cancel!(e)
22
+ raise
23
+ ensure
24
+ cleanup
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def prepare
31
+ @completed = false
32
+ @signal = CancellationSignal.new
33
+ @concurrency = Smith.config.parallel_concurrency
34
+ @nesting_limit = Smith.config.parallel_nesting_limit
35
+ @executor = build_executor
36
+ @futures = build_futures(build_context)
37
+ end
38
+
39
+ def build_executor
40
+ Concurrent::FixedThreadPool.new(
41
+ @concurrency,
42
+ max_queue: [@concurrency, branches.length].max,
43
+ fallback_policy: :abort
44
+ )
45
+ end
46
+
47
+ def build_context
48
+ ExecutionContext.new(
49
+ executor: @executor,
50
+ signal: @signal,
51
+ concurrency: @concurrency,
52
+ nesting_limit: @nesting_limit,
53
+ top_level_branch_count: branches.length
54
+ )
55
+ end
56
+
57
+ def build_futures(context)
58
+ branches.map { future_for(_1, context) }
59
+ end
60
+
61
+ def future_for(branch, context)
62
+ Concurrent::Promises.future_on(context.executor, branch, context) do |callable, execution_context|
63
+ execution_context.top_level_started!
64
+ execution_context.raise_if_cancelled!
65
+ execution_context.within(depth: 0) { callable.call(execution_context.signal) }
66
+ rescue StandardError => e
67
+ execution_context.signal.cancel!(e)
68
+ raise
69
+ ensure
70
+ execution_context.top_level_finished!
71
+ end
72
+ end
73
+
74
+ def resolve(futures)
75
+ fulfilled, values, reasons = Concurrent::Promises.zip(*futures).result
76
+ raise(@signal.reason || Parallel.preferred_error(reasons)) unless fulfilled
77
+
78
+ values
79
+ end
80
+
81
+ def cleanup
82
+ @signal&.cancel!(Cancellation.new("parallel execution interrupted")) unless @completed
83
+ return unless @executor
84
+
85
+ @executor.shutdown
86
+ @executor.wait_for_termination
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "concurrent"
4
-
3
+ require_relative "parallel/cancellation"
5
4
  require_relative "parallel/cancellation_signal"
5
+ require_relative "parallel/execution_context"
6
+ require_relative "parallel/nested_execution"
7
+ require_relative "parallel/root_execution"
6
8
 
7
9
  module Smith
8
10
  class Workflow
@@ -10,35 +12,30 @@ module Smith
10
12
  def self.resolve_branch_count(transition, context)
11
13
  count = transition.agent_opts[:count]
12
14
  resolved = count.respond_to?(:call) ? count.call(context) : (count || 1)
13
- return resolved if resolved.is_a?(Integer) && resolved.positive?
14
-
15
- raise WorkflowError, "parallel branch count must be a positive integer"
15
+ validate_branch_count!(resolved)
16
16
  end
17
17
 
18
- def self.execute(branches:)
19
- signal = CancellationSignal.new
20
-
21
- futures = branches.map do |branch|
22
- Concurrent::Promises.future(branch, signal) do |b, s|
23
- b.call(s)
24
- rescue StandardError
25
- s.cancel!
26
- raise
27
- end
18
+ def self.validate_branch_count!(count)
19
+ unless count.is_a?(Integer) && count.positive?
20
+ raise WorkflowError, "parallel branch count must be a positive integer"
28
21
  end
29
22
 
30
- fulfilled, values, reasons = Concurrent::Promises.zip(*futures).result
23
+ limit = Smith.config.parallel_branch_limit
24
+ raise WorkflowError, "parallel branch count exceeds configured limit #{limit}" if count > limit
31
25
 
32
- unless fulfilled
33
- error = preferred_error(reasons)
34
- raise error
35
- end
26
+ count
27
+ end
28
+
29
+ def self.execute(branches:)
30
+ validate_branch_count!(branches.length)
31
+ context = ExecutionContext.current
32
+ return NestedExecution.new(branches:, context:).call if context
36
33
 
37
- values
34
+ RootExecution.new(branches:).call
38
35
  end
39
36
 
40
37
  def self.preferred_error(reasons)
41
- errors = reasons.compact
38
+ errors = Array(reasons).compact
42
39
  errors.find { |error| !error.is_a?(Cancellation) } || errors.first
43
40
  end
44
41
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Workflow
7
+ class ParallelAgentBinding
8
+ extend Dry::Initializer
9
+
10
+ param :workflow
11
+ param :transition
12
+ param :agent_class
13
+
14
+ def initialize(...)
15
+ super
16
+ freeze
17
+ end
18
+
19
+ def resolve(workflow:, transition:)
20
+ agent_class if @workflow.equal?(workflow) && @transition.equal?(transition)
21
+ end
22
+ end
23
+ end
24
+ end