ruby_reactor 0.6.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 (81) 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 +125 -1
  9. data/CLAUDE.md +5 -0
  10. data/README.md +155 -25
  11. data/lib/ruby_reactor/adapters/active_job/router.rb +21 -6
  12. data/lib/ruby_reactor/adapters/active_job/step_worker.rb +20 -0
  13. data/lib/ruby_reactor/adapters/active_job/worker.rb +6 -1
  14. data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -6
  15. data/lib/ruby_reactor/adapters/sidekiq/step_worker.rb +17 -0
  16. data/lib/ruby_reactor/adapters/sidekiq/worker.rb +5 -2
  17. data/lib/ruby_reactor/async_waiter.rb +113 -0
  18. data/lib/ruby_reactor/configuration.rb +30 -1
  19. data/lib/ruby_reactor/context.rb +28 -0
  20. data/lib/ruby_reactor/context_serializer.rb +15 -0
  21. data/lib/ruby_reactor/dsl/async_macros.rb +176 -0
  22. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +66 -0
  23. data/lib/ruby_reactor/dsl/compose_builder.rb +23 -4
  24. data/lib/ruby_reactor/dsl/interrupt_builder.rb +3 -2
  25. data/lib/ruby_reactor/dsl/interrupt_step_config.rb +7 -1
  26. data/lib/ruby_reactor/dsl/lockable.rb +3 -3
  27. data/lib/ruby_reactor/dsl/map_builder.rb +1 -2
  28. data/lib/ruby_reactor/dsl/reactor.rb +29 -10
  29. data/lib/ruby_reactor/dsl/step_builder.rb +30 -11
  30. data/lib/ruby_reactor/dsl/template_helpers.rb +11 -3
  31. data/lib/ruby_reactor/error/async_result_pending.rb +21 -0
  32. data/lib/ruby_reactor/error/async_wait_timeout_error.rb +10 -0
  33. data/lib/ruby_reactor/error/deprecated_dsl_error.rb +11 -0
  34. data/lib/ruby_reactor/executor/async_step_dispatch.rb +110 -0
  35. data/lib/ruby_reactor/executor/compensation_manager.rb +58 -45
  36. data/lib/ruby_reactor/executor/ordered_lock_support.rb +11 -11
  37. data/lib/ruby_reactor/executor/result_handler.rb +47 -11
  38. data/lib/ruby_reactor/executor/retry_manager.rb +13 -6
  39. data/lib/ruby_reactor/executor/step_executor.rb +77 -24
  40. data/lib/ruby_reactor/executor.rb +179 -23
  41. data/lib/ruby_reactor/lock.rb +19 -0
  42. data/lib/ruby_reactor/map/element_executor.rb +13 -2
  43. data/lib/ruby_reactor/map/helpers.rb +10 -8
  44. data/lib/ruby_reactor/map/result_enumerator.rb +7 -1
  45. data/lib/ruby_reactor/map/result_summary.rb +63 -0
  46. data/lib/ruby_reactor/map/sweeper.rb +1 -1
  47. data/lib/ruby_reactor/open_telemetry.rb +8 -5
  48. data/lib/ruby_reactor/ordered_lock.rb +3 -3
  49. data/lib/ruby_reactor/reactor.rb +25 -2
  50. data/lib/ruby_reactor/rspec/matchers.rb +61 -11
  51. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +2 -1
  52. data/lib/ruby_reactor/rspec/step_executor_patch.rb +2 -2
  53. data/lib/ruby_reactor/rspec/test_subject.rb +74 -19
  54. data/lib/ruby_reactor/semaphore.rb +10 -0
  55. data/lib/ruby_reactor/step/async_reactor_step.rb +207 -0
  56. data/lib/ruby_reactor/step/compose_step.rb +1 -1
  57. data/lib/ruby_reactor/step/map_step.rb +6 -2
  58. data/lib/ruby_reactor/step.rb +10 -4
  59. data/lib/ruby_reactor/step_signals.rb +33 -0
  60. data/lib/ruby_reactor/step_sweeper.rb +72 -0
  61. data/lib/ruby_reactor/step_worker.rb +260 -0
  62. data/lib/ruby_reactor/storage/adapter.rb +26 -1
  63. data/lib/ruby_reactor/storage/redis_adapter.rb +4 -71
  64. data/lib/ruby_reactor/storage/redis_locking.rb +7 -0
  65. data/lib/ruby_reactor/storage/redis_pub_sub.rb +31 -0
  66. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
  67. data/lib/ruby_reactor/storage/redis_step_results.rb +49 -0
  68. data/lib/ruby_reactor/sweeper.rb +7 -1
  69. data/lib/ruby_reactor/sweeper_job.rb +1 -0
  70. data/lib/ruby_reactor/template/result.rb +151 -5
  71. data/lib/ruby_reactor/version.rb +1 -1
  72. data/lib/ruby_reactor/web/api.rb +117 -22
  73. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +1 -0
  74. data/lib/ruby_reactor/web/public/assets/index-Dw4KV4QY.js +22 -0
  75. data/lib/ruby_reactor/web/public/index.html +2 -2
  76. data/lib/ruby_reactor/worker.rb +53 -3
  77. data/lib/ruby_reactor.rb +80 -10
  78. data/specs/active_job.md +1 -1
  79. metadata +23 -3
  80. data/lib/ruby_reactor/web/public/assets/index-CCnNVQy5.css +0 -1
  81. data/lib/ruby_reactor/web/public/assets/index-D7IBZvos.js +0 -21
@@ -96,7 +96,13 @@ module RubyReactor
96
96
 
97
97
  def wrap_result(result)
98
98
  if result.is_a?(Hash) && result.key?("_error")
99
- RubyReactor::Failure.new(result["_error"])
99
+ # `backtrace: []` is load-bearing: without it Failure falls back to
100
+ # `caller`, so a bare-message element failure would carry the stack of
101
+ # whoever happened to materialize the enumerator (the JSON encoder, in
102
+ # the dashboard's case) instead of the stack of the step that failed.
103
+ RubyReactor::Failure.new(result["_error"], backtrace: [])
104
+ elsif result.is_a?(Hash) && result.key?("_halt")
105
+ RubyReactor::Halt.new(reason: result["reason"])
100
106
  else
101
107
  RubyReactor::Success.new(ContextSerializer.deserialize_value(result))
102
108
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Map
5
+ # A map's result set is unbounded, so the API describes it instead of
6
+ # inlining it. Inlining meant the JSON encoder materialized the lazy
7
+ # ResultEnumerator itself — one storage read per element — and rendered every
8
+ # element failure as an opaque blob with no count of how many there were.
9
+ class ResultSummary
10
+ # How many failed elements are described in full. The rest are only
11
+ # counted — a map failing 50k elements must not produce a 50k-entry
12
+ # response.
13
+ SAMPLE_LIMIT = 25
14
+
15
+ # Sampled failures are a list, not a detail view, and the same summary is
16
+ # echoed in intermediate_results, the execution trace and the undo stack —
17
+ # full traces on each would triple a large payload.
18
+ BACKTRACE_FRAMES = 10
19
+
20
+ # ponytail: one windowed read of the whole map; paginate if maps grow past
21
+ # what fits in a single response.
22
+ def self.build(enumerator)
23
+ total = enumerator.count
24
+ results = RubyReactor.configuration.storage_adapter.retrieve_map_results_batch(
25
+ enumerator.map_id, enumerator.reactor_class_name, offset: 0, limit: total
26
+ )
27
+ # Slots are index-keyed and the window starts at 0, so a full read means
28
+ # position == element index. A short read means the map is still filling
29
+ # in and gaps would shift positions — then indices are omitted.
30
+ failures = collect_failures(results, indexed: results.size == total)
31
+
32
+ {
33
+ "_type" => "map_results",
34
+ "total" => total,
35
+ "succeeded" => total - failures[:count],
36
+ "failed" => failures[:count],
37
+ "failures" => failures[:sample],
38
+ "failures_truncated" => failures[:count] > failures[:sample].size
39
+ }
40
+ end
41
+
42
+ def self.collect_failures(results, indexed:)
43
+ count = 0
44
+ sample = []
45
+
46
+ results.each_with_index do |raw, position|
47
+ next unless raw.is_a?(Hash) && raw.key?("_error")
48
+
49
+ count += 1
50
+ next if sample.size >= SAMPLE_LIMIT
51
+
52
+ entry = ContextSerializer.simplify_for_api(Failure.new(raw["_error"], backtrace: []))
53
+ entry["backtrace"] = Array(entry["backtrace"]).first(BACKTRACE_FRAMES)
54
+ entry["index"] = position if indexed
55
+ sample << entry
56
+ end
57
+
58
+ { count: count, sample: sample }
59
+ end
60
+ private_class_method :collect_failures
61
+ end
62
+ end
63
+ 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)
@@ -147,7 +147,7 @@ module RubyReactor
147
147
  span = @step_spans.delete(step_name)
148
148
  return unless span
149
149
 
150
- if result.is_a?(RubyReactor::AsyncResult)
150
+ if result.is_a?(RubyReactor::DispatchResult)
151
151
  # The step was handed off to a background worker; the run block did not
152
152
  # execute here. Rename the span so it is not confused with the real
153
153
  # execution span emitted later under the resumed reactor span.
@@ -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
 
@@ -118,7 +118,7 @@ module RubyReactor
118
118
  RubyReactor.reactor_storage_name(self.class),
119
119
  intermediate_results: @context.intermediate_results)
120
120
 
121
- # Even if it's an AsyncResult, it might have finished inline (e.g. Sidekiq::Testing.inline!)
121
+ # Even if it's an DispatchResult, it might have finished inline (e.g. Sidekiq::Testing.inline!)
122
122
  # Check storage to see if it's already finished or paused (interrupted).
123
123
  begin
124
124
  reloaded = self.class.find(@context.context_id)
@@ -167,6 +167,14 @@ module RubyReactor
167
167
  target_step = step_name
168
168
  @context.set_result(target_step, payload)
169
169
 
170
+ # `interrupt :x, resume: :background` — payload is validated and stored
171
+ # (above, in this process); the remaining work goes to a worker instead
172
+ # of running inline in the delivering process.
173
+ step_config = self.class.steps[step_name.to_sym]
174
+ if step_config.respond_to?(:background_resume?) && step_config.background_resume?
175
+ return @result = enqueue_background_resume
176
+ end
177
+
170
178
  # Resume execution
171
179
  executor = Executor.new(self.class, {}, @context)
172
180
  @result = executor.resume_execution
@@ -311,6 +319,21 @@ module RubyReactor
311
319
  save_context
312
320
  end
313
321
 
322
+ # Mirror of perform_async_run for the interrupt-resume path: persist the
323
+ # context (now carrying the validated payload) BEFORE enqueue — the job
324
+ # payload is identity-only (F2) — then hand the remainder to a worker.
325
+ def enqueue_background_resume
326
+ @context.status = :running
327
+ Executor.middlewares_for(self.class).on(:before_async_enqueue, @context)
328
+ save_context
329
+
330
+ @result = configuration.async_router.perform_async(@context.context_id,
331
+ RubyReactor.reactor_storage_name(self.class),
332
+ intermediate_results: @context.intermediate_results)
333
+
334
+ check_for_inline_completion || @result
335
+ end
336
+
314
337
  def perform_async_run
315
338
  @context.status = :running
316
339
  # Persist BEFORE enqueue — the job payload is identity-only (F2).
@@ -324,7 +347,7 @@ module RubyReactor
324
347
  end
325
348
 
326
349
  def check_for_inline_completion
327
- # Even if it's an AsyncResult, it might have finished inline (e.g. Sidekiq::Testing.inline!)
350
+ # Even if it's an DispatchResult, it might have finished inline (e.g. Sidekiq::Testing.inline!)
328
351
  # Check storage to see if it's already finished or paused (interrupted).
329
352
  reloaded = self.class.find(@context.context_id)
330
353
  if reloaded.finished? || reloaded.context.status.to_s == "paused"
@@ -264,18 +264,18 @@ module RubyReactor
264
264
  RubyReactor.configuration.storage_adapter
265
265
  end
266
266
 
267
- # Distinguishes `RubyReactor::Skipped` from a plain `Success`. Works on
268
- # any object with a `skipped?` predicate.
267
+ # Distinguishes `RubyReactor::Halt` (a clean halt) from a plain
268
+ # `Success`. Works on any object with a `halted?` predicate.
269
269
  #
270
270
  # 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
271
+ # expect(result).to be_halted
272
+ # expect(result).to be_halted.because(:period)
273
+ # expect(result).to be_halted.at_step(:second)
274
+ ::RSpec::Matchers.define :be_halted do
275
275
  match do |subject|
276
276
  subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
277
277
  actual = subject.respond_to?(:result) ? subject.result : subject
278
- next false unless actual.respond_to?(:skipped?) && actual.skipped?
278
+ next false unless actual.respond_to?(:halted?) && actual.halted?
279
279
  next false if @expected_reason && actual.reason != @expected_reason
280
280
  next false if @expected_step && actual.step_name != @expected_step
281
281
 
@@ -292,12 +292,62 @@ module RubyReactor
292
292
 
293
293
  failure_message do |subject|
294
294
  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}"
295
+ if !actual.respond_to?(:halted?) || !actual.halted?
296
+ "expected result to be Halt, got #{actual.class}"
297
297
  elsif @expected_reason && actual.reason != @expected_reason
298
- "expected Skipped reason #{@expected_reason.inspect}, got #{actual.reason.inspect}"
298
+ "expected Halt reason #{@expected_reason.inspect}, got #{actual.reason.inspect}"
299
299
  else
300
- "expected Skipped at_step #{@expected_step.inspect}, got #{actual.step_name.inspect}"
300
+ "expected Halt at_step #{@expected_step.inspect}, got #{actual.step_name.inspect}"
301
+ end
302
+ end
303
+
304
+ failure_message_when_negated do
305
+ "expected result not to be Halt"
306
+ end
307
+ end
308
+
309
+ # Asserts that a STEP was skipped — either the subject itself is a
310
+ # `RubyReactor::Skipped` result, or the execution trace records a
311
+ # `:skipped` entry (optionally for a specific step via `.at_step`). A
312
+ # halted run never satisfies this — halting and skipping are distinct.
313
+ #
314
+ # Examples:
315
+ # expect(result).to be_skipped
316
+ # expect(subject).to be_skipped.at_step(:maybe_sync)
317
+ ::RSpec::Matchers.define :be_skipped do
318
+ match do |subject|
319
+ subject.ensure_executed! if subject.respond_to?(:ensure_executed!)
320
+ actual = subject.respond_to?(:result) ? subject.result : subject
321
+ next false if actual.respond_to?(:halted?) && actual.halted?
322
+
323
+ entries = skipped_trace_entries(subject)
324
+ next entries.any? { |e| e[:step].to_s == @expected_step.to_s } if @expected_step
325
+ next true if entries.any?
326
+
327
+ actual.respond_to?(:skipped?) && actual.skipped?
328
+ end
329
+
330
+ chain :at_step do |step|
331
+ @expected_step = step
332
+ end
333
+
334
+ def skipped_trace_entries(subject)
335
+ trace = if subject.respond_to?(:reactor_instance)
336
+ subject.reactor_instance.context.execution_trace
337
+ elsif subject.respond_to?(:execution_trace)
338
+ subject.execution_trace
339
+ else
340
+ []
341
+ end
342
+ trace.select { |e| e[:type].to_s == "skipped" }
343
+ end
344
+
345
+ failure_message do |subject|
346
+ actual = subject.respond_to?(:result) ? subject.result : subject
347
+ if @expected_step
348
+ "expected a skipped step named #{@expected_step.inspect}, found none in the execution trace"
349
+ else
350
+ "expected result to be Skipped, got #{actual.class}"
301
351
  end
302
352
  end
303
353
 
@@ -38,7 +38,8 @@ module RubyReactor
38
38
  @worker_classes ||= [
39
39
  RubyReactor::Adapters::Sidekiq::Worker,
40
40
  RubyReactor::Adapters::Sidekiq::MapElementWorker,
41
- RubyReactor::Adapters::Sidekiq::MapCollectorWorker
41
+ RubyReactor::Adapters::Sidekiq::MapCollectorWorker,
42
+ RubyReactor::Adapters::Sidekiq::StepWorker
42
43
  ]
43
44
  end
44
45
 
@@ -14,7 +14,7 @@ module RubyReactor
14
14
  result = super
15
15
 
16
16
  # 2. Add test-specific logic for inline async execution
17
- # Only interfere if we got an AsyncResult and we are in a testing environment that supports inline execution
17
+ # Only interfere if we got an DispatchResult and we are in a testing environment that supports inline execution
18
18
  if should_check_inline_completion?(result)
19
19
  # Check if it finished or paused inline (e.g. Sidekiq::Testing.inline!)
20
20
  refresh_context_from_storage
@@ -75,7 +75,7 @@ module RubyReactor
75
75
  end
76
76
 
77
77
  def should_check_inline_completion?(result)
78
- return false unless result.is_a?(RubyReactor::AsyncResult) || result.is_a?(RubyReactor::RetryQueuedResult)
78
+ return false unless result.is_a?(RubyReactor::DispatchResult) || result.is_a?(RubyReactor::RetryQueuedResult)
79
79
  return true if defined?(Sidekiq::Testing) && Sidekiq::Testing.inline?
80
80
 
81
81
  false
@@ -130,6 +130,56 @@ module RubyReactor
130
130
  elements[index]
131
131
  end
132
132
 
133
+ # The durable record for a dispatched `async_step`, or nil if the step was
134
+ # never dispatched (e.g. under `async: false`, where it ran inline and its
135
+ # value is an ordinary `step_result`). Mirrors `#composed` / `#map` in
136
+ # reading the reference off `composed_contexts`.
137
+ #
138
+ # subject.async_step(:send_email) # => the raw record hash
139
+ # subject.async_step(:send_email, :status) # => "dispatched" / "completed"
140
+ def async_step(step_name, key = nil)
141
+ ensure_executed!
142
+
143
+ entry = composed_entry(step_name)
144
+ return nil unless entry && entry[:type] == :async_step_ref
145
+
146
+ record = RubyReactor.configuration.storage_adapter.retrieve_step_result(
147
+ entry[:context_id] || @reactor_instance.context.context_id,
148
+ step_name,
149
+ RubyReactor.reactor_storage_name(@reactor_instance.class)
150
+ )
151
+ key && record ? record[key.to_s] : record
152
+ end
153
+
154
+ # The child execution a dispatched `async_reactor` created, as its own
155
+ # TestSubject — the same drill-down `#composed` gives for an inline child.
156
+ def async_reactor(step_name)
157
+ ensure_executed!
158
+
159
+ entry = composed_entry(step_name)
160
+ return nil unless entry && entry[:type] == :async_reactor_ref
161
+
162
+ child_class = RubyReactor::Context.resolve_reactor_class(entry[:reactor_class_name])
163
+ return nil unless child_class
164
+
165
+ child_instance = child_class.find(entry[:execution_id])
166
+ self.class.new(
167
+ reactor_class: child_instance.class,
168
+ inputs: child_instance.context.inputs,
169
+ context: child_instance.context,
170
+ async: @async,
171
+ process_jobs: @process_jobs
172
+ ).tap do |s|
173
+ s.instance_variable_set(:@executed, true)
174
+ s.instance_variable_set(:@reactor_instance, child_instance)
175
+ end
176
+ end
177
+
178
+ def composed_entry(step_name)
179
+ contexts = @reactor_instance.context.composed_contexts
180
+ contexts[step_name] || contexts[step_name.to_s] || contexts[step_name.to_sym]
181
+ end
182
+
133
183
  private
134
184
 
135
185
  def traverse_composed(step_name)
@@ -250,8 +300,8 @@ module RubyReactor
250
300
  end
251
301
  end
252
302
  RubyReactor::Success.new(val)
253
- when "skipped"
254
- skipped_result(ctx)
303
+ when "halted", "skipped" # "skipped" is the legacy name for a halt
304
+ halted_result(ctx)
255
305
  when "running"
256
306
  # Try to determine if it is truly running or if we just missed the completion
257
307
  if @process_jobs && AsyncTestHelpers.active?
@@ -276,14 +326,14 @@ module RubyReactor
276
326
  end
277
327
 
278
328
  # A clean halt: either a `with_period` gate or a step returning
279
- # `RubyReactor.Skipped(...)`. The sync run already produced the exact
280
- # 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
281
331
  # swallows the return value, so rebuild from the trace.
282
- def skipped_result(ctx)
283
- 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)
284
334
 
285
- entry = ctx.execution_trace.reverse.find { |t| t[:type].to_s == "skipped" }
286
- 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))
287
337
  end
288
338
 
289
339
  def success?
@@ -441,7 +491,8 @@ module RubyReactor
441
491
  end
442
492
 
443
493
  def prepare_execution_class
444
- # Even if no interceptors, we might need to subclass to override async steps
494
+ # Even if no interceptors, we might need to subclass to force the whole
495
+ # reactor to run in-process.
445
496
  return @reactor_class if @interceptors.empty? && @async != false
446
497
 
447
498
  interceptors = @interceptors
@@ -454,7 +505,7 @@ module RubyReactor
454
505
  @input_validations = superclass.input_validations.dup
455
506
  @middlewares = superclass.middlewares.dup
456
507
  @return_step = superclass.return_step
457
- @async = superclass.async?
508
+ @background_handoff = superclass.background_handoff
458
509
  @retry_defaults = superclass.instance_variable_get(:@retry_defaults)
459
510
 
460
511
  # 2. Add Name Handling with Unique Registry Entry
@@ -464,15 +515,16 @@ module RubyReactor
464
515
  define_singleton_method(:name) { unique_name }
465
516
  RubyReactor::Registry.register(unique_name, self)
466
517
 
467
- # 3. Apply Force Sync (Disable async on all steps)
518
+ # 3. `async: false` / `run_async(false)` means "run this reactor's full
519
+ # logic here, in one process". Under the new DSL that is three things:
520
+ # suppress the `background` hand-off, and run `async_step` /
521
+ # `async_reactor` units inline instead of dispatching them.
468
522
  if force_sync
523
+ @background_handoff = nil
469
524
  @steps.each do |name, config|
470
- next unless config.async?
525
+ next unless config.respond_to?(:async_dispatch?) && config.async_dispatch?
471
526
 
472
- # Clone and modify
473
- new_config = config.clone
474
- new_config.instance_variable_set(:@async, false)
475
- @steps[name] = new_config
527
+ @steps[name] = config.clone.tap { |c| c.instance_variable_set(:@async_dispatch, nil) }
476
528
  end
477
529
  end
478
530
  end
@@ -503,7 +555,8 @@ module RubyReactor
503
555
 
504
556
  if nested_interceptors.any?
505
557
  apply_nested_interceptors(step_config, nested_interceptors)
506
- step_config.instance_variable_set(:@async, false)
558
+ # A mocked inner step only takes effect if the child runs here.
559
+ step_config.instance_variable_set(:@async_dispatch, nil)
507
560
  end
508
561
 
509
562
  # Apply direct interceptors (mocks/failures on this step)
@@ -551,7 +604,7 @@ module RubyReactor
551
604
  @input_validations = superclass.input_validations.dup
552
605
  @middlewares = superclass.middlewares.dup
553
606
  @return_step = superclass.return_step
554
- @async = superclass.async?
607
+ @background_handoff = superclass.background_handoff
555
608
  @retry_defaults = superclass.instance_variable_get(:@retry_defaults)
556
609
  end
557
610
 
@@ -606,7 +659,9 @@ module RubyReactor
606
659
  end
607
660
 
608
661
  step_config.instance_variable_set(:@run_block, wrapper_impl)
609
- step_config.instance_variable_set(:@async, false)
662
+ # The mock replaces the step's body, so it must run where the spec can
663
+ # observe it rather than being dispatched to a worker.
664
+ step_config.instance_variable_set(:@async_dispatch, nil)
610
665
  end
611
666
  end
612
667
  # rubocop:enable Metrics/ClassLength
@@ -40,6 +40,16 @@ module RubyReactor
40
40
  release
41
41
  end
42
42
 
43
+ # Park support: re-adopt a token that stayed checked out across a parked
44
+ # gap. Verifies the token is still in the held set — a reset/expiry in
45
+ # between means the slot was lost and the caller must acquire fresh.
46
+ def reattach(token) # rubocop:disable Naming/PredicateMethod
47
+ return false unless token && adapter.semaphore_held?(@key, token)
48
+
49
+ @token = token
50
+ true
51
+ end
52
+
43
53
  private
44
54
 
45
55
  def ensure_initialized