ruby_reactor 0.7.0 → 0.7.1

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/demo-app-e2e-verify/SKILL.md +226 -0
  3. data/.claude/skills/speckit-demo-tests/SKILL.md +144 -0
  4. data/.release-please-manifest.json +1 -1
  5. data/.specify/feature.json +1 -1
  6. data/.specify/memory/constitution.md +79 -12
  7. data/.specify/templates/tasks-template.md +7 -0
  8. data/CHANGELOG.md +11 -0
  9. data/CLAUDE.md +2 -2
  10. data/README.md +24 -9
  11. data/lib/ruby_reactor/context.rb +7 -0
  12. data/lib/ruby_reactor/context_serializer.rb +13 -0
  13. data/lib/ruby_reactor/dsl/lockable.rb +2 -2
  14. data/lib/ruby_reactor/dsl/template_helpers.rb +11 -3
  15. data/lib/ruby_reactor/executor/compensation_manager.rb +58 -45
  16. data/lib/ruby_reactor/executor/ordered_lock_support.rb +9 -9
  17. data/lib/ruby_reactor/executor/result_handler.rb +35 -10
  18. data/lib/ruby_reactor/executor/retry_manager.rb +4 -1
  19. data/lib/ruby_reactor/executor/step_executor.rb +19 -13
  20. data/lib/ruby_reactor/executor.rb +17 -15
  21. data/lib/ruby_reactor/map/element_executor.rb +7 -1
  22. data/lib/ruby_reactor/map/helpers.rb +9 -7
  23. data/lib/ruby_reactor/map/result_enumerator.rb +2 -0
  24. data/lib/ruby_reactor/map/sweeper.rb +1 -1
  25. data/lib/ruby_reactor/open_telemetry.rb +7 -4
  26. data/lib/ruby_reactor/ordered_lock.rb +3 -3
  27. data/lib/ruby_reactor/rspec/matchers.rb +61 -11
  28. data/lib/ruby_reactor/rspec/test_subject.rb +8 -8
  29. data/lib/ruby_reactor/step/map_step.rb +5 -1
  30. data/lib/ruby_reactor/step.rb +10 -4
  31. data/lib/ruby_reactor/step_signals.rb +33 -0
  32. data/lib/ruby_reactor/storage/adapter.rb +4 -0
  33. data/lib/ruby_reactor/storage/redis_adapter.rb +1 -72
  34. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
  35. data/lib/ruby_reactor/version.rb +1 -1
  36. data/lib/ruby_reactor/web/api.rb +23 -6
  37. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +1 -0
  38. data/lib/ruby_reactor/web/public/assets/index-Dw4KV4QY.js +22 -0
  39. data/lib/ruby_reactor/web/public/index.html +2 -2
  40. data/lib/ruby_reactor.rb +56 -7
  41. metadata +7 -11
  42. data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +0 -1
  43. data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +0 -22
  44. data/specs/001-background-async-steps/checklists/requirements.md +0 -39
  45. data/specs/001-background-async-steps/contracts/public-dsl.md +0 -154
  46. data/specs/001-background-async-steps/data-model.md +0 -117
  47. data/specs/001-background-async-steps/plan.md +0 -168
  48. data/specs/001-background-async-steps/quickstart.md +0 -102
  49. data/specs/001-background-async-steps/research.md +0 -150
  50. data/specs/001-background-async-steps/spec.md +0 -146
  51. data/specs/001-background-async-steps/tasks.md +0 -271
@@ -103,6 +103,13 @@ module RubyReactor
103
103
  @current_step = old_step
104
104
  end
105
105
 
106
+ # Appends an execution-trace event stamped with whether this process is the
107
+ # worker (`inline_async_execution`). Traces written before this field
108
+ # existed stay unlabeled on deserialize.
109
+ def append_execution_trace(entry)
110
+ @execution_trace << entry.merge(background: !!@inline_async_execution)
111
+ end
112
+
106
113
  def to_h
107
114
  {
108
115
  inputs: @inputs,
@@ -36,6 +36,11 @@ module RubyReactor
36
36
  # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
37
37
  def serialize_value(value)
38
38
  case value
39
+ when RubyReactor::Halt
40
+ { "_type" => "Halt", "reason" => value.reason, "period_key" => value.period_key,
41
+ "step_name" => value.step_name }
42
+ when RubyReactor::Skipped
43
+ { "_type" => "Skipped", "value" => serialize_value(value.value), "reason" => value.reason }
39
44
  when RubyReactor::Success
40
45
  { "_type" => "Success", "value" => serialize_value(value.value) }
41
46
  when RubyReactor::Failure
@@ -112,6 +117,14 @@ module RubyReactor
112
117
  if value.key?("_type")
113
118
  # Special serialized types (Time, BigDecimal, etc.)
114
119
  case value["_type"]
120
+ when "Halt"
121
+ RubyReactor::Halt.new(
122
+ reason: value["reason"],
123
+ period_key: value["period_key"],
124
+ step_name: value["step_name"]
125
+ )
126
+ when "Skipped"
127
+ RubyReactor::Skipped.new(deserialize_value(value["value"]), reason: value["reason"])
115
128
  when "Success"
116
129
  RubyReactor::Success(deserialize_value(value["value"]))
117
130
  when "Failure"
@@ -53,7 +53,7 @@ module RubyReactor
53
53
 
54
54
  # Configure a calendar-aligned dedup window for this reactor. The
55
55
  # reactor will run at most once per bucket per key; subsequent calls
56
- # in the same bucket return `RubyReactor::Skipped` without executing
56
+ # in the same bucket return `RubyReactor::Halt` without executing
57
57
  # any steps.
58
58
  #
59
59
  # Note: `with_period` is *dedup*, not *concurrency*. Two concurrent
@@ -93,7 +93,7 @@ module RubyReactor
93
93
  # assign. Only fully-drained sequences GC themselves.
94
94
  # @param strict [Boolean] When true (default), if any nonce in the
95
95
  # sequence terminates with a `Failure`, all subsequent nonces are
96
- # short-circuited with `Skipped(reason: :ordered_lock_chain_failed)`
96
+ # short-circuited with `Halt(reason: :ordered_lock_chain_failed)`
97
97
  # instead of executing. This models "stop the line on the first
98
98
  # problem" pipelines (e.g. ledger transactions). When false, the
99
99
  # sequence keeps executing every nonce in order regardless of prior
@@ -3,6 +3,8 @@
3
3
  module RubyReactor
4
4
  module Dsl
5
5
  module TemplateHelpers
6
+ include RubyReactor::StepSignals
7
+
6
8
  def input(name, path = nil)
7
9
  RubyReactor::Template::Input.new(name, path)
8
10
  end
@@ -19,7 +21,7 @@ module RubyReactor
19
21
  RubyReactor::Template::Element.new(map_name, path)
20
22
  end
21
23
 
22
- # Make Success, Failure, and Skipped available in DSL contexts
24
+ # Make Success, Failure, Halt, and Skipped available in DSL contexts
23
25
  # rubocop:disable Naming/MethodName
24
26
  def Success(value = nil)
25
27
  # rubocop:enable Naming/MethodName
@@ -33,9 +35,15 @@ module RubyReactor
33
35
  end
34
36
 
35
37
  # rubocop:disable Naming/MethodName
36
- def Skipped(reason: nil, **kwargs)
38
+ def Halt(reason: nil, **kwargs)
39
+ # rubocop:enable Naming/MethodName
40
+ RubyReactor.Halt(reason: reason, **kwargs)
41
+ end
42
+
43
+ # rubocop:disable Naming/MethodName
44
+ def Skipped(...)
37
45
  # rubocop:enable Naming/MethodName
38
- RubyReactor.Skipped(reason: reason, **kwargs)
46
+ RubyReactor.Skipped(...)
39
47
  end
40
48
  end
41
49
  end
@@ -55,33 +55,44 @@ module RubyReactor
55
55
  @context.middlewares || RubyReactor::MiddlewareRunner.new([])
56
56
  end
57
57
 
58
+ # Ensure we have a value to log (if it's a Success/Failure object, get the value or error)
59
+ def loggable_value(result)
60
+ if result.respond_to?(:value)
61
+ result.value
62
+ elsif result.respond_to?(:error)
63
+ result.error
64
+ else
65
+ result
66
+ end
67
+ end
68
+
69
+ def skipped_result?(result)
70
+ result.respond_to?(:skipped?) && result.skipped?
71
+ end
72
+
58
73
  def compensate_step(step_config, error, arguments)
59
74
  middlewares.on(:start_compensation, step_config.name, error, arguments, @context)
60
75
  begin
61
- compensate_result = if step_config.compensate_block
62
- step_config.compensate_block.call(error, arguments, @context)
63
- elsif step_config.has_impl?
64
- step_config.impl.compensate(error, arguments, @context)
65
- else
66
- RubyReactor.Success() # Default compensation
67
- end
68
-
69
- # Ensure we have a value to log
70
- logged_result = if compensate_result.respond_to?(:value)
71
- compensate_result.value
72
- elsif compensate_result.respond_to?(:error)
73
- compensate_result.error
74
- else
75
- compensate_result
76
- end
77
-
78
- @context.execution_trace << {
79
- type: :compensate,
80
- step: step_config.name,
81
- timestamp: Time.now,
82
- result: logged_result,
83
- arguments: arguments
84
- }
76
+ compensate_result = catch(StepSignals::TAG) do
77
+ if step_config.compensate_block
78
+ step_config.compensate_block.call(error, arguments, @context)
79
+ elsif step_config.has_impl?
80
+ step_config.impl.compensate(error, arguments, @context)
81
+ else
82
+ RubyReactor.Skipped() # Default: nothing defined, rollback continues
83
+ end
84
+ end
85
+
86
+ @context.append_execution_trace(
87
+ {
88
+ type: :compensate,
89
+ step: step_config.name,
90
+ timestamp: Time.now,
91
+ result: loggable_value(compensate_result),
92
+ arguments: arguments,
93
+ skipped: skipped_result?(compensate_result)
94
+ }
95
+ )
85
96
  @undo_trace << { type: :compensation, step: step_config.name, error: error, arguments: arguments }
86
97
 
87
98
  if compensate_result.is_a?(RubyReactor::Failure)
@@ -100,25 +111,26 @@ module RubyReactor
100
111
  def undo_step(step_config, result, arguments)
101
112
  middlewares.on(:start_undo, step_config.name, result, arguments, @context)
102
113
  begin
103
- undo_result = if step_config.undo_block
104
- step_config.undo_block.call(result.value, arguments, @context)
105
- elsif step_config.has_impl?
106
- step_config.impl.undo(result.value, arguments, @context)
107
- else
108
- RubyReactor.Success()
109
- end
110
-
111
- # Ensure we have a value to log (if it's a Success/Failure object, get the value or error)
112
- logged_result = if undo_result.respond_to?(:value)
113
- undo_result.value
114
- elsif undo_result.respond_to?(:error)
115
- undo_result.error
116
- else
117
- undo_result
118
- end
119
-
120
- @context.execution_trace << { type: :undo, step: step_config.name, timestamp: Time.now, result: logged_result,
121
- arguments: arguments }
114
+ undo_result = catch(StepSignals::TAG) do
115
+ if step_config.undo_block
116
+ step_config.undo_block.call(result.value, arguments, @context)
117
+ elsif step_config.has_impl?
118
+ step_config.impl.undo(result.value, arguments, @context)
119
+ else
120
+ RubyReactor.Skipped() # Default: nothing defined, rollback continues
121
+ end
122
+ end
123
+
124
+ @context.append_execution_trace(
125
+ {
126
+ type: :undo,
127
+ step: step_config.name,
128
+ timestamp: Time.now,
129
+ result: loggable_value(undo_result),
130
+ arguments: arguments,
131
+ skipped: skipped_result?(undo_result)
132
+ }
133
+ )
122
134
 
123
135
  if undo_result.is_a?(RubyReactor::Failure)
124
136
  middlewares.on(:failed_undo, step_config.name, undo_result, @context)
@@ -130,8 +142,9 @@ module RubyReactor
130
142
  rescue StandardError => e
131
143
  middlewares.on(:failed_undo, step_config.name, e, @context)
132
144
  # Log undo failure but don't halt the rollback process
133
- @context.execution_trace << { type: :undo_failure, step: step_config.name, timestamp: Time.now,
134
- error: e.message }
145
+ @context.append_execution_trace(
146
+ { type: :undo_failure, step: step_config.name, timestamp: Time.now, error: e.message }
147
+ )
135
148
  RubyReactor.Failure(e)
136
149
  end
137
150
  end
@@ -117,14 +117,14 @@ module RubyReactor
117
117
  @ordered_lock_drained_replay == true
118
118
  end
119
119
 
120
- # Terminal Skipped result when the ordered-lock gate short-circuits this
120
+ # Terminal Halt result when the ordered-lock gate short-circuits this
121
121
  # run (stale batch, strict chain failure, or a drained-batch redelivery of
122
122
  # an already-terminal context), or nil to continue. Shared by `execute`
123
123
  # and `resume_execution`.
124
124
  def ordered_lock_short_circuit
125
- return RubyReactor::Skipped.new(reason: :ordered_lock_stale_batch) if ordered_lock_stale_batch?
126
- return RubyReactor::Skipped.new(reason: :ordered_lock_drained_replay) if ordered_lock_drained_replay?
127
- return RubyReactor::Skipped.new(reason: :ordered_lock_chain_failed) if ordered_lock_chain_skip?
125
+ return RubyReactor::Halt.new(reason: :ordered_lock_stale_batch) if ordered_lock_stale_batch?
126
+ return RubyReactor::Halt.new(reason: :ordered_lock_drained_replay) if ordered_lock_drained_replay?
127
+ return RubyReactor::Halt.new(reason: :ordered_lock_chain_failed) if ordered_lock_chain_skip?
128
128
 
129
129
  nil
130
130
  end
@@ -138,12 +138,12 @@ module RubyReactor
138
138
  def short_circuit!(result)
139
139
  @result = result
140
140
 
141
- # A stale-batch or drained-batch-redelivery skip means this run's epoch
141
+ # A stale-batch or drained-batch-redelivery halt means this run's epoch
142
142
  # belongs to a drained generation — typically a slow straggler or a
143
143
  # Sidekiq at-least-once redelivery. If the redelivery is of a job that
144
144
  # ALREADY reached a terminal status, its stored context is the source of
145
- # truth; writing :skipped over a :completed/:failed record would silently
146
- # corrupt the outcome. Return the skip to the worker (so it stops)
145
+ # truth; writing :halted over a :completed/:failed record would silently
146
+ # corrupt the outcome. Return the halt to the worker (so it stops)
147
147
  # without saving. The `@skip_context_persist` flag also suppresses the
148
148
  # ensure-block save in execute / resume_execution, which would otherwise
149
149
  # clobber the stored terminal record with this run's stale in-memory
@@ -169,14 +169,14 @@ module RubyReactor
169
169
  # set when the status was terminal, but re-checking keeps both paths
170
170
  # uniform and self-guarding.
171
171
  def redelivery_of_terminal?(result)
172
- return false unless result.is_a?(RubyReactor::Skipped)
172
+ return false unless result.is_a?(RubyReactor::Halt)
173
173
  return false unless %i[ordered_lock_stale_batch ordered_lock_drained_replay].include?(result.reason)
174
174
 
175
175
  stored_status_terminal?
176
176
  end
177
177
 
178
178
  def stored_status_terminal?
179
- %w[completed failed skipped].include?(stored_context_status)
179
+ %w[completed failed halted skipped].include?(stored_context_status)
180
180
  end
181
181
 
182
182
  def stored_context_status
@@ -14,9 +14,13 @@ module RubyReactor
14
14
 
15
15
  def handle_step_result(step_config, result, resolved_arguments)
16
16
  case result
17
+ when RubyReactor::Halt
18
+ # Important: must come before Skipped and Success — both are Halt's
19
+ # siblings under Success, and Halt takes precedence over either.
20
+ handle_halt(step_config, result)
17
21
  when RubyReactor::Skipped
18
- # Important: must come before the Success branch — Skipped < Success.
19
- handle_skipped(step_config, result)
22
+ # Must come before the Success branch — Skipped < Success.
23
+ handle_skipped(step_config, result, resolved_arguments)
20
24
  when RubyReactor::Success
21
25
  handle_success(step_config, result, resolved_arguments)
22
26
  when RubyReactor::MaxRetriesExhaustedFailure
@@ -78,19 +82,40 @@ module RubyReactor
78
82
  )
79
83
  end
80
84
 
81
- # A step returned `RubyReactor.Skipped(...)`. Halt cleanly: record the
85
+ # A step returned `RubyReactor.Halt(...)`. Halt cleanly: record the
82
86
  # event in the trace, do NOT push to the undo stack (so existing
83
87
  # completed steps stay as-is — no compensation), and stamp the step
84
88
  # name on the result so the caller can see who halted.
85
- def handle_skipped(step_config, result)
89
+ def handle_halt(step_config, result)
86
90
  @step_results[step_config.name] = result
87
91
  result.instance_variable_set(:@step_name, step_config.name) if result.step_name.nil?
88
- @context.execution_trace << {
89
- type: :skipped,
90
- step: step_config.name,
91
- timestamp: Time.now,
92
- reason: result.reason
93
- }
92
+ @context.append_execution_trace(
93
+ {
94
+ type: :halt,
95
+ step: step_config.name,
96
+ timestamp: Time.now,
97
+ reason: result.reason
98
+ }
99
+ )
100
+ result
101
+ end
102
+
103
+ # A step returned `RubyReactor.Skipped(...)`. The reactor continues:
104
+ # validate and record the value exactly like a Success, but do NOT push
105
+ # to the undo stack (nothing happened, so there is nothing to undo).
106
+ def handle_skipped(step_config, result, resolved_arguments)
107
+ validate_step_output(step_config, result.value, resolved_arguments)
108
+ @step_results[step_config.name] = result
109
+ @context.set_result(step_config.name, result.value)
110
+ @dependency_graph.complete_step(step_config.name)
111
+ @context.append_execution_trace(
112
+ {
113
+ type: :skipped,
114
+ step: step_config.name,
115
+ timestamp: Time.now,
116
+ reason: result.reason
117
+ }
118
+ )
94
119
  result
95
120
  end
96
121
 
@@ -94,7 +94,10 @@ module RubyReactor
94
94
 
95
95
  def handle_retry_result(step_config, reactor_class, result)
96
96
  case result
97
- when RubyReactor::Success
97
+ when RubyReactor::Halt, RubyReactor::Skipped, RubyReactor::Success
98
+ # Halt and Skipped are Success subclasses, so they already take this
99
+ # path via inheritance; the explicit arms are readability plus a
100
+ # guard against a future hierarchy change (R5).
98
101
  clear_retry_state
99
102
  result
100
103
  when RubyReactor::Failure
@@ -37,10 +37,10 @@ module RubyReactor
37
37
  # If a step returns RetryQueuedResult, we need to stop and return it
38
38
  return result if result.is_a?(RetryQueuedResult)
39
39
 
40
- # If a step returns Skipped, halt the reactor cleanly (no
40
+ # If a step returns Halt, stop the reactor cleanly (no
41
41
  # compensation). Must be checked BEFORE Failure / Success because
42
- # Skipped is a Success subclass.
43
- return result if result.is_a?(RubyReactor::Skipped)
42
+ # Halt is a Success subclass.
43
+ return result if result.is_a?(RubyReactor::Halt)
44
44
 
45
45
  # If a step returns Failure, we need to stop execution and return it
46
46
  return result if result.is_a?(RubyReactor::Failure)
@@ -48,13 +48,17 @@ module RubyReactor
48
48
  # If a step returns InterruptResult, we need to stop execution and return it
49
49
  return result if result.is_a?(RubyReactor::InterruptResult)
50
50
 
51
- # Only a continue-Success reaches here (Async/Retry/Skipped/Failure/
52
- # Interrupt all returned above; nil is inline-async test mode). It is
53
- # the one outcome where the loop proceeds to more steps with no other
54
- # save in between — every terminal/handoff result persists via its own
55
- # path. Write a durable checkpoint so a crash re-runs at most this one
56
- # step. Ordering: side-effect -> record result (inside execute_step) ->
57
- # checkpoint here.
51
+ # A Skipped step (or a plain Success) continues the loop — Skipped
52
+ # is a Success subclass, so this also fires the durable checkpoint
53
+ # for it, same as a plain success.
54
+ #
55
+ # Only a continue-Success/Skipped reaches here (Async/Retry/Halt/
56
+ # Failure/Interrupt all returned above; nil is inline-async test
57
+ # mode). It is the one outcome where the loop proceeds to more
58
+ # steps with no other save in between — every terminal/handoff
59
+ # result persists via its own path. Write a durable checkpoint so
60
+ # a crash re-runs at most this one step. Ordering: side-effect ->
61
+ # record result (inside execute_step) -> checkpoint here.
58
62
  @on_step_complete&.call if result.is_a?(RubyReactor::Success)
59
63
  end
60
64
  end
@@ -336,15 +340,17 @@ module RubyReactor
336
340
  end
337
341
 
338
342
  def run_step_implementation(step_config, arguments)
339
- @context.execution_trace << { type: :run, step: step_config.name, timestamp: Time.now, arguments: arguments }
343
+ @context.append_execution_trace(
344
+ { type: :run, step: step_config.name, timestamp: Time.now, arguments: arguments }
345
+ )
340
346
  if step_config.has_run_block?
341
347
  # Execute inline block
342
348
  # If no arguments are defined for the step, pass the reactor inputs as arguments
343
349
  args_to_pass = arguments.empty? ? @context.inputs : arguments
344
- step_config.run_block.call(args_to_pass, @context)
350
+ catch(StepSignals::TAG) { step_config.run_block.call(args_to_pass, @context) }
345
351
  elsif step_config.has_impl?
346
352
  # Execute step class
347
- step_config.impl.run(arguments, @context)
353
+ catch(StepSignals::TAG) { step_config.impl.run(arguments, @context) }
348
354
  else
349
355
  raise Error::ValidationError.new(
350
356
  "Step '#{step_config.name}' has no implementation",
@@ -110,9 +110,9 @@ module RubyReactor
110
110
  # is a fast path; this one closes the race where two callers both passed
111
111
  # it and then serialized on the lock — without it the second caller would
112
112
  # re-run work the first already marked. (No-op when no lock is configured.)
113
- if (skipped = check_period_gate)
113
+ if (halted = check_period_gate)
114
114
  completed = true
115
- return finalize_skipped(skipped)
115
+ return finalize_halt(halted)
116
116
  end
117
117
 
118
118
  @context.status = :running
@@ -218,9 +218,9 @@ module RubyReactor
218
218
 
219
219
  # Post-lock re-check (see execute) — closes the period race for the
220
220
  # first run of a locked async reactor.
221
- if first_run && (skipped = check_period_gate)
221
+ if first_run && (halted = check_period_gate)
222
222
  completed = true
223
- return finalize_skipped(skipped)
223
+ return finalize_halt(halted)
224
224
  end
225
225
 
226
226
  prepare_for_resume
@@ -393,16 +393,16 @@ module RubyReactor
393
393
  @context.current_step.nil? && @context.intermediate_results.empty?
394
394
  end
395
395
 
396
- # Record and persist a Skipped result, then return it. Shared by the
396
+ # Record and persist a Halt result, then return it. Shared by the
397
397
  # pre-lock and post-lock period gates in both execute and resume.
398
- def finalize_skipped(skipped)
399
- @result = skipped
398
+ def finalize_halt(halted)
399
+ @result = halted
400
400
  update_context_status(@result)
401
401
  save_context
402
402
  @result
403
403
  end
404
404
 
405
- # Returns a Skipped result if the period bucket is already marked, else nil.
405
+ # Returns a Halt result if the period bucket is already marked, else nil.
406
406
  # Consulted before AND after lock acquisition on a first execution; genuine
407
407
  # resumes never re-check (a paused run must not skip itself when its own
408
408
  # marker eventually appears).
@@ -413,13 +413,13 @@ module RubyReactor
413
413
  key = period_key(config)
414
414
  return nil unless RubyReactor.configuration.storage_adapter.period_seen?(key)
415
415
 
416
- RubyReactor::Skipped.new(reason: :period, period_key: key)
416
+ RubyReactor::Halt.new(reason: :period, period_key: key)
417
417
  end
418
418
 
419
419
  def mark_period_on_success(result)
420
420
  return unless @reactor_class.respond_to?(:period_config) && @reactor_class.period_config
421
421
  return unless result.is_a?(RubyReactor::Success)
422
- return if result.is_a?(RubyReactor::Skipped)
422
+ return if result.is_a?(RubyReactor::Halt)
423
423
 
424
424
  config = @reactor_class.period_config
425
425
  ttl = RubyReactor::Period.ttl_seconds(config[:every])
@@ -662,8 +662,8 @@ module RubyReactor
662
662
  case result
663
663
  when RubyReactor::DispatchResult
664
664
  @context.status = :running
665
- when RubyReactor::Skipped
666
- @context.status = :skipped
665
+ when RubyReactor::Halt
666
+ @context.status = :halted
667
667
  when RubyReactor::Success
668
668
  @context.status = :completed
669
669
  when RubyReactor::Failure
@@ -696,14 +696,16 @@ module RubyReactor
696
696
  @result = @step_executor.execute_all_steps
697
697
  else
698
698
  case result
699
- # Skipped must be listed before Success (Skipped < Success) so the
699
+ # Halt must be listed before Success (Halt < Success) so the
700
700
  # halt path wins over the "continue with remaining steps" path.
701
- when RubyReactor::Skipped,
701
+ # Skipped is NOT listed here — it is a Success subclass and must
702
+ # continue with the remaining steps, same as a plain Success.
703
+ when RubyReactor::Halt,
702
704
  RetryQueuedResult,
703
705
  RubyReactor::Failure,
704
706
  RubyReactor::DispatchResult,
705
707
  RubyReactor::InterruptResult
706
- # Terminal: step was skipped, requeued, failed, paused, or handed
708
+ # Terminal: step halted, requeued, failed, paused, or handed
707
709
  # off to async. Return the result as-is.
708
710
  @result = result
709
711
  when RubyReactor::Success
@@ -141,7 +141,13 @@ module RubyReactor
141
141
  index = arguments[:index]
142
142
  parent_class = arguments[:parent_reactor_class_name] # Using short name for variable
143
143
 
144
- if result.success?
144
+ if result.halted?
145
+ # A Halt must not be collected as a (nil) value indistinguishable
146
+ # from an ordinary success — mark it so the enumerator reconstructs
147
+ # a real Halt for the consumer.
148
+ storage.store_map_result(map_id, index, { _halt: true, reason: result.reason },
149
+ parent_class, strict_ordering: arguments[:strict_ordering])
150
+ elsif result.success?
145
151
  storage.store_map_result(map_id, index, ContextSerializer.serialize_value(result.value),
146
152
  parent_class, strict_ordering: arguments[:strict_ordering])
147
153
  else
@@ -96,13 +96,15 @@ module RubyReactor
96
96
  # Manually update execution trace to reflect completion
97
97
  # This is necessary because resume_execution continues from the NEXT step
98
98
  # and the async step (which returned DispatchResult) needs to be marked as done with actual value
99
- parent_context.execution_trace << {
100
- type: :result,
101
- step: step_name_sym,
102
- timestamp: Time.now,
103
- value: final_result.value,
104
- status: :success
105
- }
99
+ parent_context.append_execution_trace(
100
+ {
101
+ type: :result,
102
+ step: step_name_sym,
103
+ timestamp: Time.now,
104
+ value: final_result.value,
105
+ status: :success
106
+ }
107
+ )
106
108
 
107
109
  parent_context.current_step = nil
108
110
  executor.resume_execution
@@ -101,6 +101,8 @@ module RubyReactor
101
101
  # whoever happened to materialize the enumerator (the JSON encoder, in
102
102
  # the dashboard's case) instead of the stack of the step that failed.
103
103
  RubyReactor::Failure.new(result["_error"], backtrace: [])
104
+ elsif result.is_a?(Hash) && result.key?("_halt")
105
+ RubyReactor::Halt.new(reason: result["reason"])
104
106
  else
105
107
  RubyReactor::Success.new(ContextSerializer.deserialize_value(result))
106
108
  end
@@ -92,7 +92,7 @@ module RubyReactor
92
92
 
93
93
  results = data["intermediate_results"] || {}
94
94
  status = data["status"].to_s
95
- results.key?(meta["step_name"].to_s) || %w[completed failed skipped].include?(status)
95
+ results.key?(meta["step_name"].to_s) || %w[completed failed halted skipped].include?(status)
96
96
  end
97
97
 
98
98
  def retrigger_collector(meta)
@@ -502,9 +502,9 @@ module RubyReactor
502
502
 
503
503
  case result
504
504
  when RubyReactor::Success
505
- if result.skipped?
506
- span.set_attribute("reactor.status", "skipped")
507
- span.set_attribute("reactor.skipped_reason", result.reason.to_s)
505
+ if result.halted?
506
+ span.set_attribute("reactor.status", "halted")
507
+ span.set_attribute("reactor.halt_reason", result.reason.to_s)
508
508
  span.status = ::OpenTelemetry::Trace::Status.ok
509
509
  else
510
510
  span.set_attribute("reactor.status", "completed")
@@ -578,7 +578,10 @@ module RubyReactor
578
578
 
579
579
  case result
580
580
  when RubyReactor::Success
581
- if result.skipped?
581
+ if result.halted?
582
+ span.set_attribute("step.status", "halted")
583
+ span.set_attribute("step.halt_reason", result.reason.to_s)
584
+ elsif result.skipped?
582
585
  span.set_attribute("step.status", "skipped")
583
586
  span.set_attribute("step.skipped_reason", result.reason.to_s)
584
587
  else
@@ -62,11 +62,11 @@ module RubyReactor
62
62
  # via the stored context status.
63
63
  # - `:skip_chain_failed` — only in strict mode: an earlier nonce in this
64
64
  # sequence terminated with a Failure, so this run is short-circuited
65
- # with `Skipped(reason: :ordered_lock_chain_failed)` without executing.
65
+ # with `Halt(reason: :ordered_lock_chain_failed)` without executing.
66
66
  # - `:stale_batch` — this run's epoch no longer matches the key's current
67
67
  # generation: its batch fully drained and the numbering was reused by a
68
68
  # newer batch. The run is short-circuited with
69
- # `Skipped(reason: :ordered_lock_stale_batch)` and must not participate.
69
+ # `Halt(reason: :ordered_lock_stale_batch)` and must not participate.
70
70
  # - `:poison_advance` is collapsed to `:go` from the caller's perspective.
71
71
  def check!
72
72
  raise ArgumentError, "OrderedLock#check! requires a nonce" unless @nonce
@@ -111,7 +111,7 @@ module RubyReactor
111
111
  #
112
112
  # `failed:` records this nonce as the chain-failure marker (only the FIRST
113
113
  # failure sticks). In strict mode the marker causes subsequent nonces to
114
- # short-circuit with Skipped.
114
+ # short-circuit with Halt.
115
115
  def advance!(failed: false)
116
116
  raise ArgumentError, "OrderedLock#advance! requires a nonce" unless @nonce
117
117