ruby_reactor 0.7.0 → 0.8.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 (91) 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 +92 -15
  7. data/.specify/templates/plan-template.md +4 -0
  8. data/.specify/templates/tasks-template.md +8 -1
  9. data/CHANGELOG.md +151 -0
  10. data/CLAUDE.md +2 -2
  11. data/README.md +149 -40
  12. data/lib/ruby_reactor/context.rb +9 -2
  13. data/lib/ruby_reactor/context_serializer.rb +13 -0
  14. data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
  15. data/lib/ruby_reactor/dsl/lockable.rb +2 -2
  16. data/lib/ruby_reactor/dsl/reactor.rb +36 -18
  17. data/lib/ruby_reactor/dsl/step_builder.rb +95 -2
  18. data/lib/ruby_reactor/dsl/template_helpers.rb +13 -5
  19. data/lib/ruby_reactor/dsl/validation_helpers.rb +17 -0
  20. data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
  21. data/lib/ruby_reactor/error/step_failure_error.rb +10 -3
  22. data/lib/ruby_reactor/executor/compensation_manager.rb +58 -45
  23. data/lib/ruby_reactor/executor/ordered_lock_support.rb +9 -9
  24. data/lib/ruby_reactor/executor/result_handler.rb +44 -13
  25. data/lib/ruby_reactor/executor/retry_manager.rb +6 -2
  26. data/lib/ruby_reactor/executor/step_executor.rb +27 -14
  27. data/lib/ruby_reactor/executor.rb +20 -15
  28. data/lib/ruby_reactor/map/element_executor.rb +7 -1
  29. data/lib/ruby_reactor/map/helpers.rb +9 -7
  30. data/lib/ruby_reactor/map/result_enumerator.rb +2 -0
  31. data/lib/ruby_reactor/map/sweeper.rb +1 -1
  32. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
  33. data/lib/ruby_reactor/open_telemetry.rb +7 -4
  34. data/lib/ruby_reactor/ordered_lock.rb +3 -3
  35. data/lib/ruby_reactor/reactor.rb +9 -12
  36. data/lib/ruby_reactor/rspec/matchers.rb +64 -17
  37. data/lib/ruby_reactor/rspec/test_subject.rb +8 -8
  38. data/lib/ruby_reactor/step/async_reactor_step.rb +159 -162
  39. data/lib/ruby_reactor/step/compose_step.rb +56 -75
  40. data/lib/ruby_reactor/step/input_contract.rb +128 -0
  41. data/lib/ruby_reactor/step/map_step.rb +178 -215
  42. data/lib/ruby_reactor/step.rb +119 -18
  43. data/lib/ruby_reactor/step_signals.rb +37 -0
  44. data/lib/ruby_reactor/step_worker.rb +25 -10
  45. data/lib/ruby_reactor/storage/adapter.rb +4 -0
  46. data/lib/ruby_reactor/storage/redis_adapter.rb +1 -72
  47. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
  48. data/lib/ruby_reactor/template/result.rb +9 -2
  49. data/lib/ruby_reactor/utils/fetch_indifferent.rb +13 -0
  50. data/lib/ruby_reactor/version.rb +1 -1
  51. data/lib/ruby_reactor/web/api.rb +23 -6
  52. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +1 -0
  53. data/lib/ruby_reactor/web/public/assets/index-Dw4KV4QY.js +22 -0
  54. data/lib/ruby_reactor/web/public/index.html +2 -2
  55. data/lib/ruby_reactor.rb +61 -9
  56. data/specs/002-step-input-contracts/checklists/requirements.md +49 -0
  57. data/specs/002-step-input-contracts/contracts/dsl-surface.md +193 -0
  58. data/specs/002-step-input-contracts/data-model.md +115 -0
  59. data/specs/002-step-input-contracts/plan.md +165 -0
  60. data/specs/002-step-input-contracts/quickstart.md +170 -0
  61. data/specs/002-step-input-contracts/research.md +233 -0
  62. data/specs/002-step-input-contracts/spec.md +359 -0
  63. data/specs/002-step-input-contracts/tasks.md +367 -0
  64. data/specs/004-inheritable-step-class/checklists/requirements.md +40 -0
  65. data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +85 -0
  66. data/specs/004-inheritable-step-class/data-model.md +116 -0
  67. data/specs/004-inheritable-step-class/plan.md +174 -0
  68. data/specs/004-inheritable-step-class/quickstart.md +112 -0
  69. data/specs/004-inheritable-step-class/research.md +308 -0
  70. data/specs/004-inheritable-step-class/spec.md +316 -0
  71. data/specs/004-inheritable-step-class/tasks.md +258 -0
  72. data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +51 -0
  73. data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +154 -0
  74. data/specs/deferred-003-step-lock-declarations/data-model.md +131 -0
  75. data/specs/deferred-003-step-lock-declarations/plan.md +166 -0
  76. data/specs/deferred-003-step-lock-declarations/quickstart.md +169 -0
  77. data/specs/deferred-003-step-lock-declarations/research.md +196 -0
  78. data/specs/deferred-003-step-lock-declarations/spec.md +447 -0
  79. data/specs/deferred-003-step-lock-declarations/tasks.md +572 -0
  80. data/specs/possible_feature.md +22 -0
  81. metadata +34 -11
  82. data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +0 -1
  83. data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +0 -22
  84. data/specs/001-background-async-steps/checklists/requirements.md +0 -39
  85. data/specs/001-background-async-steps/contracts/public-dsl.md +0 -154
  86. data/specs/001-background-async-steps/data-model.md +0 -117
  87. data/specs/001-background-async-steps/plan.md +0 -168
  88. data/specs/001-background-async-steps/quickstart.md +0 -102
  89. data/specs/001-background-async-steps/research.md +0 -150
  90. data/specs/001-background-async-steps/spec.md +0 -146
  91. data/specs/001-background-async-steps/tasks.md +0 -271
@@ -19,6 +19,9 @@ module RubyReactor
19
19
  :step_executor, :result, :middlewares
20
20
 
21
21
  def initialize(reactor_class, inputs = {}, context = nil)
22
+ # Resume, map, compose and background workers build an Executor without
23
+ # going through Reactor#run; the inferred wiring must exist there too.
24
+ reactor_class.validate_definition! if reactor_class.respond_to?(:validate_definition!)
22
25
  @reactor_class = reactor_class
23
26
  @context = context || Context.new(inputs, reactor_class)
24
27
  @middlewares = Executor.middlewares_for(reactor_class)
@@ -110,9 +113,9 @@ module RubyReactor
110
113
  # is a fast path; this one closes the race where two callers both passed
111
114
  # it and then serialized on the lock — without it the second caller would
112
115
  # re-run work the first already marked. (No-op when no lock is configured.)
113
- if (skipped = check_period_gate)
116
+ if (halted = check_period_gate)
114
117
  completed = true
115
- return finalize_skipped(skipped)
118
+ return finalize_halt(halted)
116
119
  end
117
120
 
118
121
  @context.status = :running
@@ -218,9 +221,9 @@ module RubyReactor
218
221
 
219
222
  # Post-lock re-check (see execute) — closes the period race for the
220
223
  # first run of a locked async reactor.
221
- if first_run && (skipped = check_period_gate)
224
+ if first_run && (halted = check_period_gate)
222
225
  completed = true
223
- return finalize_skipped(skipped)
226
+ return finalize_halt(halted)
224
227
  end
225
228
 
226
229
  prepare_for_resume
@@ -393,16 +396,16 @@ module RubyReactor
393
396
  @context.current_step.nil? && @context.intermediate_results.empty?
394
397
  end
395
398
 
396
- # Record and persist a Skipped result, then return it. Shared by the
399
+ # Record and persist a Halt result, then return it. Shared by the
397
400
  # pre-lock and post-lock period gates in both execute and resume.
398
- def finalize_skipped(skipped)
399
- @result = skipped
401
+ def finalize_halt(halted)
402
+ @result = halted
400
403
  update_context_status(@result)
401
404
  save_context
402
405
  @result
403
406
  end
404
407
 
405
- # Returns a Skipped result if the period bucket is already marked, else nil.
408
+ # Returns a Halt result if the period bucket is already marked, else nil.
406
409
  # Consulted before AND after lock acquisition on a first execution; genuine
407
410
  # resumes never re-check (a paused run must not skip itself when its own
408
411
  # marker eventually appears).
@@ -413,13 +416,13 @@ module RubyReactor
413
416
  key = period_key(config)
414
417
  return nil unless RubyReactor.configuration.storage_adapter.period_seen?(key)
415
418
 
416
- RubyReactor::Skipped.new(reason: :period, period_key: key)
419
+ RubyReactor::Halt.new(reason: :period, period_key: key)
417
420
  end
418
421
 
419
422
  def mark_period_on_success(result)
420
423
  return unless @reactor_class.respond_to?(:period_config) && @reactor_class.period_config
421
424
  return unless result.is_a?(RubyReactor::Success)
422
- return if result.is_a?(RubyReactor::Skipped)
425
+ return if result.is_a?(RubyReactor::Halt)
423
426
 
424
427
  config = @reactor_class.period_config
425
428
  ttl = RubyReactor::Period.ttl_seconds(config[:every])
@@ -662,8 +665,8 @@ module RubyReactor
662
665
  case result
663
666
  when RubyReactor::DispatchResult
664
667
  @context.status = :running
665
- when RubyReactor::Skipped
666
- @context.status = :skipped
668
+ when RubyReactor::Halt
669
+ @context.status = :halted
667
670
  when RubyReactor::Success
668
671
  @context.status = :completed
669
672
  when RubyReactor::Failure
@@ -696,14 +699,16 @@ module RubyReactor
696
699
  @result = @step_executor.execute_all_steps
697
700
  else
698
701
  case result
699
- # Skipped must be listed before Success (Skipped < Success) so the
702
+ # Halt must be listed before Success (Halt < Success) so the
700
703
  # halt path wins over the "continue with remaining steps" path.
701
- when RubyReactor::Skipped,
704
+ # Skipped is NOT listed here — it is a Success subclass and must
705
+ # continue with the remaining steps, same as a plain Success.
706
+ when RubyReactor::Halt,
702
707
  RetryQueuedResult,
703
708
  RubyReactor::Failure,
704
709
  RubyReactor::DispatchResult,
705
710
  RubyReactor::InterruptResult
706
- # Terminal: step was skipped, requeued, failed, paused, or handed
711
+ # Terminal: step halted, requeued, failed, paused, or handed
707
712
  # off to async. Return the result as-is.
708
713
  @result = result
709
714
  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)
@@ -7,11 +7,12 @@ module RubyReactor
7
7
  # rubocop:disable Metrics/ParameterLists
8
8
  def initialize(message, step:, attempts:, original_error: nil,
9
9
  inputs: {}, backtrace: nil, redact_inputs: [],
10
- reactor_name: nil, step_arguments: {})
10
+ reactor_name: nil, step_arguments: {}, validation_errors: nil)
11
11
  # rubocop:enable Metrics/ParameterLists
12
12
  super(message,
13
13
  step_name: step, inputs: inputs, backtrace: backtrace,
14
- redact_inputs: redact_inputs, reactor_name: reactor_name, step_arguments: step_arguments)
14
+ redact_inputs: redact_inputs, reactor_name: reactor_name, step_arguments: step_arguments,
15
+ validation_errors: validation_errors)
15
16
  @attempts = attempts
16
17
  @original_error = original_error
17
18
  end
@@ -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
 
@@ -84,21 +84,17 @@ module RubyReactor
84
84
  end
85
85
  end
86
86
 
87
- # rubocop:disable Metrics/MethodLength
88
87
  def run(inputs = {})
88
+ # Before the context exists, so an incomplete definition never saves one.
89
+ self.class.validate_definition!
90
+
89
91
  # For all reactors, initialize context first to capture execution ID
90
92
  @context = @context.is_a?(Context) ? @context : Context.new(inputs, self.class)
91
93
 
92
94
  # Validate inputs
93
95
  validation_result = self.class.validate_inputs(inputs)
94
96
  if validation_result.failure?
95
- @result = validation_result
96
- @context.status = "failed"
97
- @context.failure_reason = {
98
- message: validation_result.error.message,
99
- validation_errors: validation_result.error.field_errors
100
- }
101
- save_context
97
+ handle_validation_failure(validation_result)
102
98
  return validation_result
103
99
  end
104
100
 
@@ -144,7 +140,6 @@ module RubyReactor
144
140
  end
145
141
  @result
146
142
  end
147
- # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
148
143
 
149
144
  def continue(payload:, step_name:, idempotency_key: nil)
150
145
  _ = idempotency_key
@@ -270,8 +265,9 @@ module RubyReactor
270
265
  reason = @context.failure_reason || {}
271
266
  return reason if reason.is_a?(RubyReactor::Failure)
272
267
 
273
- # Use string keys preferred, fallback to symbol
274
- r = ->(k) { reason[k.to_s] || reason[k.to_sym] }
268
+ # Presence-aware: a stored `retryable: false` must not be swallowed by an
269
+ # `||` fallback and silently default back to retryable.
270
+ r = ->(k) { Utils::FetchIndifferent.call(reason, k) }
275
271
 
276
272
  Failure.new(
277
273
  r[:message],
@@ -314,7 +310,8 @@ module RubyReactor
314
310
  @context.status = "failed"
315
311
  @context.failure_reason = {
316
312
  message: result.error.message,
317
- validation_errors: result.error.field_errors
313
+ validation_errors: result.error.field_errors,
314
+ retryable: result.retryable?
318
315
  }
319
316
  save_context
320
317
  end
@@ -156,12 +156,9 @@ module RubyReactor
156
156
  subject.ensure_executed!
157
157
  return false unless subject.failure?
158
158
 
159
- # Try to get validation errors from failure reason
160
- reason = subject.reactor_instance.context.failure_reason || {}
161
-
162
- # If failure is InputValidationError, it might be serialized differently
163
- # Or stored in validation_errors key
164
- errors = reason["validation_errors"] || reason[:validation_errors]
159
+ # `result` normalizes both failure shapes a reactor-input failure
160
+ # stored as a hash, and a step failure stored as a Failure.
161
+ errors = subject.result.validation_errors
165
162
 
166
163
  if errors
167
164
  errors.key?(field.to_s) || errors.key?(field.to_sym)
@@ -264,18 +261,18 @@ module RubyReactor
264
261
  RubyReactor.configuration.storage_adapter
265
262
  end
266
263
 
267
- # Distinguishes `RubyReactor::Skipped` from a plain `Success`. Works on
268
- # any object with a `skipped?` predicate.
264
+ # Distinguishes `RubyReactor::Halt` (a clean halt) from a plain
265
+ # `Success`. Works on any object with a `halted?` predicate.
269
266
  #
270
267
  # Examples:
271
- # expect(result).to be_skipped
272
- # expect(result).to be_skipped.because(:period)
273
- # expect(result).to be_skipped.at_step(:second)
274
- ::RSpec::Matchers.define :be_skipped do
268
+ # expect(result).to be_halted
269
+ # expect(result).to be_halted.because(:period)
270
+ # expect(result).to be_halted.at_step(:second)
271
+ ::RSpec::Matchers.define :be_halted do
275
272
  match do |subject|
276
273
  subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
277
274
  actual = subject.respond_to?(:result) ? subject.result : subject
278
- next false unless actual.respond_to?(:skipped?) && actual.skipped?
275
+ next false unless actual.respond_to?(:halted?) && actual.halted?
279
276
  next false if @expected_reason && actual.reason != @expected_reason
280
277
  next false if @expected_step && actual.step_name != @expected_step
281
278
 
@@ -292,12 +289,62 @@ module RubyReactor
292
289
 
293
290
  failure_message do |subject|
294
291
  actual = subject.respond_to?(:result) ? subject.result : subject
295
- if !actual.respond_to?(:skipped?) || !actual.skipped?
296
- "expected result to be Skipped, got #{actual.class}"
292
+ if !actual.respond_to?(:halted?) || !actual.halted?
293
+ "expected result to be Halt, got #{actual.class}"
297
294
  elsif @expected_reason && actual.reason != @expected_reason
298
- "expected Skipped reason #{@expected_reason.inspect}, got #{actual.reason.inspect}"
295
+ "expected Halt reason #{@expected_reason.inspect}, got #{actual.reason.inspect}"
296
+ else
297
+ "expected Halt at_step #{@expected_step.inspect}, got #{actual.step_name.inspect}"
298
+ end
299
+ end
300
+
301
+ failure_message_when_negated do
302
+ "expected result not to be Halt"
303
+ end
304
+ end
305
+
306
+ # Asserts that a STEP was skipped — either the subject itself is a
307
+ # `RubyReactor::Skipped` result, or the execution trace records a
308
+ # `:skipped` entry (optionally for a specific step via `.at_step`). A
309
+ # halted run never satisfies this — halting and skipping are distinct.
310
+ #
311
+ # Examples:
312
+ # expect(result).to be_skipped
313
+ # expect(subject).to be_skipped.at_step(:maybe_sync)
314
+ ::RSpec::Matchers.define :be_skipped do
315
+ match do |subject|
316
+ subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
317
+ actual = subject.respond_to?(:result) ? subject.result : subject
318
+ next false if actual.respond_to?(:halted?) && actual.halted?
319
+
320
+ entries = skipped_trace_entries(subject)
321
+ next entries.any? { |e| e[:step].to_s == @expected_step.to_s } if @expected_step
322
+ next true if entries.any?
323
+
324
+ actual.respond_to?(:skipped?) && actual.skipped?
325
+ end
326
+
327
+ chain :at_step do |step|
328
+ @expected_step = step
329
+ end
330
+
331
+ def skipped_trace_entries(subject)
332
+ trace = if subject.respond_to?(:reactor_instance)
333
+ subject.reactor_instance.context.execution_trace
334
+ elsif subject.respond_to?(:execution_trace)
335
+ subject.execution_trace
336
+ else
337
+ []
338
+ end
339
+ trace.select { |e| e[:type].to_s == "skipped" }
340
+ end
341
+
342
+ failure_message do |subject|
343
+ actual = subject.respond_to?(:result) ? subject.result : subject
344
+ if @expected_step
345
+ "expected a skipped step named #{@expected_step.inspect}, found none in the execution trace"
299
346
  else
300
- "expected Skipped at_step #{@expected_step.inspect}, got #{actual.step_name.inspect}"
347
+ "expected result to be Skipped, got #{actual.class}"
301
348
  end
302
349
  end
303
350
 
@@ -300,8 +300,8 @@ module RubyReactor
300
300
  end
301
301
  end
302
302
  RubyReactor::Success.new(val)
303
- when "skipped"
304
- skipped_result(ctx)
303
+ when "halted", "skipped" # "skipped" is the legacy name for a halt
304
+ halted_result(ctx)
305
305
  when "running"
306
306
  # Try to determine if it is truly running or if we just missed the completion
307
307
  if @process_jobs && AsyncTestHelpers.active?
@@ -326,14 +326,14 @@ module RubyReactor
326
326
  end
327
327
 
328
328
  # A clean halt: either a `with_period` gate or a step returning
329
- # `RubyReactor.Skipped(...)`. The sync run already produced the exact
330
- # Skipped (reason/step intact) — surface it. For async runs the worker
329
+ # `RubyReactor.Halt(...)`. The sync run already produced the exact
330
+ # Halt (reason/step intact) — surface it. For async runs the worker
331
331
  # swallows the return value, so rebuild from the trace.
332
- def skipped_result(ctx)
333
- return @run_result if @run_result.is_a?(RubyReactor::Skipped)
332
+ def halted_result(ctx)
333
+ return @run_result if @run_result.is_a?(RubyReactor::Halt)
334
334
 
335
- entry = ctx.execution_trace.reverse.find { |t| t[:type].to_s == "skipped" }
336
- RubyReactor::Skipped.new(reason: entry&.dig(:reason), step_name: entry&.dig(:step))
335
+ entry = ctx.execution_trace.reverse.find { |t| t[:type].to_s == "halt" }
336
+ RubyReactor::Halt.new(reason: entry&.dig(:reason), step_name: entry&.dig(:step))
337
337
  end
338
338
 
339
339
  def success?