ruby_reactor 0.8.6 → 0.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c59b0af2a9d34b620e3400cff78e0df3b2e580d1138b37ed5aee1b9e5a78f548
4
- data.tar.gz: 4e3c2b92b6ae58797beae470b60c69015e15b2b8d6855ce710e71a999d0ae62f
3
+ metadata.gz: a0d1aa7711547d66d5a7f19acf5e0b13c7e208a9660d3f1714eb8df2e7eff004
4
+ data.tar.gz: a0b52b6139f80b5133f9585f39ff5fe227e46b34dfb9d1b8767bf4f068e5678f
5
5
  SHA512:
6
- metadata.gz: a3ac367241909b09a5f0451b9278489776e7bedc56b506043c786892b86247a646b297a7c6192be6e5935b5e9a861f36e66e2f370a7c3e0741820cbcaa1ee409
7
- data.tar.gz: 1fd3ff1f692008747021d11d5356613163a8c7777e33ead22260da4b3959bc5f6ec7fff3cb5928444815298011ff4b0e1e2e10e34c55a9781b98d9ab80893e40
6
+ metadata.gz: 5457e6cf85bb33a4bd12f00161d810a6dc491a590c3cefaaf2c5c967987d78e783e199c77bdff77d2b2745f569098c9492d07f4aebb0a7ad74f023bc5274f525
7
+ data.tar.gz: 58ad78fe9e8e1c1e6b8b7106c12c384534aae6db6747860202cfb38dd75e3922f35c88b65f91320cfaef9fb121b6b0cae63dce4f42c677a5ae7ee4c6b0ff91b6
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.8.6"
2
+ ".": "0.8.7"
3
3
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "feature_directory": "specs/009-distributed-map-undo"
2
+ "feature_directory": "specs/010-interrupt-in-composed-child"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -241,6 +241,23 @@ Every breaking or shape-changing item of the rollback work, with what to change.
241
241
 
242
242
  ### Features
243
243
 
244
+ * **An `interrupt` inside a composed child pauses the top-level run.** Before, the compose step
245
+ failed with `NoMethodError` and the run rolled back. Now the top-level run is stored `paused`, at
246
+ any compose depth and after a `fan_out` map in the child, and the paused result carries its id.
247
+ Resume it on the top-level class by the interrupt's step path, an Array of the compose step names
248
+ then the interrupt (`continue(id:, payload:, step_name: [:approval, :wait_for_manager])`), by id
249
+ or by the child interrupt's correlation id. Payload validation, `max_attempts`,
250
+ `resume: :background` and the resume guards apply as for a top-level interrupt. A resume
251
+ contended on the child's own `with_lock`/`with_semaphore` fails and rolls back the run. Only
252
+ contention on the top-level reactor's lock leaves it paused. `Reactor.undo`
253
+ of the paused run rolls back the child's completed steps, then the parent's. A composed child
254
+ refuses a `continue` of its own. New `Reactor#ready_interrupt_steps` lists the pending
255
+ interrupts (Symbols, and paths for nested ones). `be_paused_at`, `have_ready_interrupts` and
256
+ `TestSubject#resume(step:)` accept paths. A `continue` must now name a pending interrupt: naming
257
+ another ready step, which used to store the payload as that step's result, raises
258
+ `ValidationError`. An `interrupt` inside a `map` element now fails that element with "not
259
+ supported inside a map element" instead of `NoMethodError`.
260
+ See [Interrupts inside composed reactors](documentation/interrupts.md#interrupts-inside-composed-reactors).
244
261
  * **`fan_out` without `batch_size` is back-pressured: at most 50 element jobs per throw**
245
262
  (`RubyReactor::Map::DEFAULT_BATCH_SIZE`), forward and rollback. Before, every element was
246
263
  enqueued at once. A map of more than 50 elements with no declared `batch_size` now runs in
@@ -444,6 +461,13 @@ Every breaking or shape-changing item of the rollback work, with what to change.
444
461
  * Docs: a park keeps each level's lock without a second `:lock_acquired` only while the gap stays
445
462
  within the lock's `ttl`; a lapsed lock is acquired again.
446
463
 
464
+ ## [0.8.7](https://github.com/arturictus/ruby_reactor/compare/v0.8.6...v0.8.7) (2026-10-08)
465
+
466
+
467
+ ### Features
468
+
469
+ * add support for interrupts inside composed children ([#71](https://github.com/arturictus/ruby_reactor/issues/71)) ([7be968e](https://github.com/arturictus/ruby_reactor/commit/7be968eb5dbfc6458e9419d8c6fddbf9d89cc80e))
470
+
447
471
  ## [0.8.6](https://github.com/arturictus/ruby_reactor/compare/v0.8.5...v0.8.6) (2026-10-08)
448
472
 
449
473
 
data/CLAUDE.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <!-- SPECKIT START -->
2
2
  For additional context about technologies to be used, project structure,
3
3
  shell commands, and other important information, read the current plan
4
- at specs/009-distributed-map-undo/plan.md
4
+ at specs/010-interrupt-in-composed-child/plan.md
5
5
  <!-- SPECKIT END -->
data/README.md CHANGED
@@ -704,6 +704,10 @@ ApprovalReactor.continue_by_correlation_id(
704
704
  )
705
705
  ```
706
706
 
707
+ An interrupt inside a `compose`d child pauses the top-level run; resume it there by the step path,
708
+ e.g. `step_name: [:approval, :wait_for_manager]`. See
709
+ [Interrupts inside composed reactors](documentation/interrupts.md#interrupts-inside-composed-reactors).
710
+
707
711
  ### Locks, Semaphores & Ordered Locks
708
712
 
709
713
  Coordinate across processes with Redis-backed primitives:
@@ -41,7 +41,13 @@ module RubyReactor
41
41
  @rollback_failures.replace(Array(ContextSerializer.deserialize_value(list)))
42
42
  end
43
43
 
44
+ # A step that paused (a compose whose child hit an interrupt) is already
45
+ # on top as a partial run; its re-run replaces that entry, so the step is
46
+ # undone once (010 R-04).
47
+ # ponytail: assumes the paused step's entry is on top when it re-runs,
48
+ # which holds because a run stops at a pause and resumes at that step.
44
49
  def add_to_undo_stack(step_info)
50
+ undo_stack.pop if undo_stack.last && undo_stack.last[:step].name == step_info[:step].name
45
51
  @context.undo_stack << step_info
46
52
  end
47
53
 
@@ -27,6 +27,8 @@ module RubyReactor
27
27
  handle_retries_exhausted(step_config, result, resolved_arguments)
28
28
  when RubyReactor::Failure
29
29
  handle_failure(step_config, result, resolved_arguments)
30
+ when RubyReactor::InterruptResult
31
+ handle_interrupted(step_config, resolved_arguments)
30
32
  else
31
33
  handle_unknown_result(step_config, result, resolved_arguments)
32
34
  end
@@ -148,6 +150,20 @@ module RubyReactor
148
150
  result
149
151
  end
150
152
 
153
+ # A composed child paused at an interrupt (010): the step stays
154
+ # incomplete, and this run pauses on it. `with_step` cleared
155
+ # `current_step` on the way out; it is the run's resume cursor. The
156
+ # child's completed steps are only undone through this step, so it joins
157
+ # the undo stack as a partial run, as an interrupted compose does (R-04).
158
+ def handle_interrupted(step_config, resolved_arguments)
159
+ @context.current_step = step_config.name
160
+ return unless step_config.undoes_partial_run?
161
+
162
+ @compensation_manager.add_to_undo_stack({ step: step_config,
163
+ arguments: step_config.rollback_arguments(resolved_arguments),
164
+ result: RubyReactor.Success(nil) })
165
+ end
166
+
151
167
  # A step returned `RubyReactor.Skipped(...)`: the run continues exactly as
152
168
  # for a Success (value, hand-off, period mark), but the step is NOT pushed
153
169
  # for undo — it had nothing to do, so there is nothing to revert (008 R-19).
@@ -108,8 +108,8 @@ module RubyReactor
108
108
  result
109
109
  when RubyReactor::Failure
110
110
  handle_failure_result(step_config, reactor_class, result)
111
- when RetryQueuedResult, RubyReactor::DispatchResult
112
- # Pass through async results
111
+ when RetryQueuedResult, RubyReactor::DispatchResult, RubyReactor::InterruptResult
112
+ # Pass through async results, and a composed child's pause
113
113
  result
114
114
  else
115
115
  clear_retry_state
@@ -431,6 +431,15 @@ module RubyReactor
431
431
  return RubyReactor.Success(result)
432
432
  end
433
433
 
434
+ # A map element cannot pause: nothing would resume it. The step fails
435
+ # like any other, rolling back what the element completed (010 R-08).
436
+ if inside_map_element?
437
+ failure = RubyReactor::Failure("interrupt :#{step_config.name} is not supported inside a map element",
438
+ step_name: step_config.name, reactor_name: @reactor_class.name,
439
+ retryable: false)
440
+ return @result_handler.handle_step_result(step_config, failure, {})
441
+ end
442
+
434
443
  # We are pausing
435
444
  correlation_id = nil
436
445
  correlation_id = step_config.correlation_id_block.call(@context) if step_config.correlation_id_block
@@ -445,6 +454,17 @@ module RubyReactor
445
454
  )
446
455
  end
447
456
 
457
+ # This run, or one it is composed into, is a map element.
458
+ def inside_map_element?
459
+ context = @context
460
+ while context
461
+ return true if context.map_metadata
462
+
463
+ context = context.parent_context
464
+ end
465
+ false
466
+ end
467
+
448
468
  def configuration
449
469
  RubyReactor::Configuration.instance
450
470
  end
@@ -38,6 +38,14 @@ module RubyReactor
38
38
  find(context_id)
39
39
  end
40
40
 
41
+ # How a resume names an interrupt: a Symbol for one of the reactor's own,
42
+ # or its step path from the root (an Array: the compose step names, then
43
+ # the interrupt) for one inside a composed child (010).
44
+ def self.interrupt_key(step_name)
45
+ path = Array(step_name).map(&:to_sym)
46
+ path.one? ? path.first : path
47
+ end
48
+
41
49
  def self.continue(id:, payload:, step_name:, idempotency_key: nil)
42
50
  reactor = find(id)
43
51
  result = reactor.continue(payload: payload, step_name: step_name, idempotency_key: idempotency_key)
@@ -154,6 +162,11 @@ module RubyReactor
154
162
  raise Error::ValidationError, "Cannot resume: context does not have a current step (was it interrupted?)"
155
163
  end
156
164
 
165
+ # A composed child pauses only inside its root, which owns the run (010 R-07).
166
+ if @context.private_data[:composed] || @context.private_data["composed"]
167
+ raise Error::ValidationError, "Cannot resume: #{self.class.name} is a composed child; continue its root run"
168
+ end
169
+
157
170
  if @context.cancelled
158
171
  raise Error::ValidationError,
159
172
  "Cannot resume: reactor has been cancelled (Reason: #{@context.cancellation_reason})"
@@ -167,19 +180,17 @@ module RubyReactor
167
180
  "Cannot resume: the reactor is #{@context.status}, not paused at an interrupt"
168
181
  end
169
182
 
170
- validate_continue_step!(step_name)
183
+ path = Array(self.class.interrupt_key(step_name))
184
+ target_context, step_config = resolve_interrupt_target!(path)
171
185
 
172
- if (failure = validate_continue_payload(payload, step_name))
173
- return failure
174
- end
186
+ failure = validate_continue_payload(payload, step_config, path)
187
+ return failure if failure
175
188
 
176
- target_step = step_name
177
- @context.set_result(target_step, payload)
189
+ target_context.set_result(path.last, payload) # the context that paused (010 R-06)
178
190
 
179
191
  # `interrupt :x, resume: :background` — payload is validated and stored
180
192
  # (above, in this process); the remaining work goes to a worker instead
181
193
  # of running inline in the delivering process.
182
- step_config = self.class.steps[step_name.to_sym]
183
194
  if step_config.respond_to?(:background_resume?) && step_config.background_resume?
184
195
  return @result = enqueue_background_resume
185
196
  end
@@ -244,6 +255,15 @@ module RubyReactor
244
255
  save_context
245
256
  end
246
257
 
258
+ # The interrupts this paused run can be resumed at (010 R-05): a Symbol
259
+ # for one of this reactor's own, and the step path from here (the compose
260
+ # step names, then the interrupt) for one inside a composed child.
261
+ def ready_interrupt_steps
262
+ return [] unless @context.status.to_s == "paused"
263
+
264
+ pending_interrupts(self.class, @context, [])
265
+ end
266
+
247
267
  def validate!
248
268
  # Validate reactor configuration
249
269
  validate_steps!
@@ -434,39 +454,69 @@ module RubyReactor
434
454
  @undo_trace = executor.undo_trace
435
455
  end
436
456
 
437
- def validate_continue_step!(step_name)
438
- return if step_name.to_s == @context.current_step.to_s
457
+ # The context that paused at the interrupt `path` names, and that
458
+ # interrupt's config. A one-name path is a step of this reactor; a longer
459
+ # one names compose steps from here down, then the interrupt (010 R-05).
460
+ def resolve_interrupt_target!(path)
461
+ pending = ready_interrupt_steps
462
+ key = path.one? ? path.first : path
463
+ unless pending.include?(key)
464
+ raise Error::ValidationError,
465
+ "Cannot resume: expected step '#{@context.current_step}' " \
466
+ "or ready steps #{pending.inspect} but got '#{key}'"
467
+ end
468
+
469
+ target = path[0...-1].reduce(@context) { |context, name| paused_child(context, name) }
470
+ [target, target.reactor_class.steps[path.last]]
471
+ end
439
472
 
440
- # Build graph to check if step is ready
441
- graph_manager = Executor::GraphManager.new(self.class, DependencyGraph.new, @context)
473
+ def pending_interrupts(reactor_class, context, prefix)
474
+ graph_manager = Executor::GraphManager.new(reactor_class, DependencyGraph.new, context)
442
475
  graph_manager.build_and_validate!
443
476
  graph_manager.mark_completed_steps_from_context
444
- ready_steps = graph_manager.dependency_graph.ready_steps.map(&:name).map(&:to_s)
445
477
 
446
- return if ready_steps.include?(step_name.to_s)
478
+ # The interrupt a context paused at stays pending even once a resume
479
+ # stored its payload: that resume was contended and never ran.
480
+ ready = graph_manager.dependency_graph.ready_steps
481
+ paused_at = reactor_class.steps[context.current_step&.to_sym]
482
+ ready += [paused_at] if paused_at&.interrupt? && ready.none? { |step| step.name == paused_at.name }
483
+
484
+ ready.flat_map do |step_config|
485
+ name = step_config.name.to_sym
486
+ if step_config.respond_to?(:interrupt?) && step_config.interrupt?
487
+ [prefix.empty? ? name : prefix + [name]]
488
+ elsif (child = paused_child(context, name))
489
+ pending_interrupts(child.reactor_class, child, prefix + [name])
490
+ else
491
+ []
492
+ end
493
+ end
494
+ end
447
495
 
448
- raise Error::ValidationError,
449
- "Cannot resume: expected step '#{@context.current_step}' " \
450
- "or ready steps #{ready_steps} but got '#{step_name}'"
496
+ def paused_child(context, step_name)
497
+ entry = context.composed_contexts[step_name] || context.composed_contexts[step_name.to_s]
498
+ child = entry.is_a?(Hash) && (entry[:context] || entry["context"])
499
+ child if child.is_a?(Context) && child.status.to_s == "paused"
451
500
  end
452
501
 
453
- def validate_continue_payload(payload, step_name)
454
- step_config = self.class.steps[step_name.to_sym]
502
+ def validate_continue_payload(payload, step_config, path)
455
503
  return unless step_config&.validation_schema
456
504
 
505
+ # A nested interrupt counts apart from a root step of the same name.
506
+ step_name = path.one? ? path.first : path.join(".").to_sym
507
+
457
508
  validation = step_config.validation_schema.call(payload)
458
509
 
459
510
  return unless validation.failure?
460
511
 
461
512
  # Track attempts
462
- step_key = step_name.to_sym
463
513
  @context.private_data[:interrupt_attempts] ||= {}
464
- @context.private_data[:interrupt_attempts][step_key] ||= 0
465
- @context.private_data[:interrupt_attempts][step_key] += 1
514
+ @context.private_data[:interrupt_attempts][step_name] ||= 0
515
+ @context.private_data[:interrupt_attempts][step_name] += 1
466
516
 
467
517
  save_context # Persist the attempt count
468
518
 
469
- current_attempts = @context.private_data[:interrupt_attempts][step_key]
519
+ current_attempts = @context.private_data[:interrupt_attempts][step_name]
470
520
  max_attempts = step_config.max_attempts
471
521
 
472
522
  if max_attempts != :infinity && current_attempts >= max_attempts
@@ -210,50 +210,49 @@ module RubyReactor
210
210
  # Matcher to check if reactor is paused at a specific interrupt step
211
211
  # Works with both single and multiple concurrent interrupts
212
212
  matcher :be_paused_at do |*step_names|
213
+ # A step inside a composed child is named by its path, e.g. `[:fulfil, :approve]`.
214
+ expected = -> { step_names.map { |name| RubyReactor::Reactor.interrupt_key(name).inspect }.join(", ") }
215
+
213
216
  match do |subject|
214
217
  subject.ensure_executed!
215
218
  return false unless subject.paused?
216
219
 
217
220
  ready_steps = subject.ready_interrupt_steps
218
- step_names.all? { |name| ready_steps.include?(name.to_sym) }
221
+ step_names.all? { |name| ready_steps.include?(RubyReactor::Reactor.interrupt_key(name)) }
219
222
  end
220
223
 
221
224
  failure_message do |subject|
222
225
  if subject.paused?
223
- ready_steps = subject.ready_interrupt_steps
224
- if step_names.size == 1
225
- "expected reactor to be paused at :#{step_names.first}, " \
226
- "but ready interrupt steps are: #{ready_steps.inspect}"
227
- else
228
- "expected reactor to be paused at #{step_names.map { |s| ":#{s}" }.join(", ")}, " \
229
- "but ready interrupt steps are: #{ready_steps.inspect}"
230
- end
226
+ "expected reactor to be paused at #{expected.call}, " \
227
+ "but ready interrupt steps are: #{subject.ready_interrupt_steps.inspect}"
231
228
  else
232
- "expected reactor to be paused at #{step_names.map { |s| ":#{s}" }.join(", ")}, " \
229
+ "expected reactor to be paused at #{expected.call}, " \
233
230
  "but status was #{subject.reactor_instance.context.status}"
234
231
  end
235
232
  end
236
233
 
237
234
  failure_message_when_negated do |_subject|
238
- "expected reactor not to be paused at #{step_names.map { |s| ":#{s}" }.join(", ")}, but it is"
235
+ "expected reactor not to be paused at #{expected.call}, but it is"
239
236
  end
240
237
  end
241
238
 
242
239
  # Matcher to check the exact set of ready interrupt steps
243
240
  matcher :have_ready_interrupts do |*expected_steps|
241
+ # A step inside a composed child is named by its path, e.g. `[:fulfil, :approve]`.
242
+ expected = -> { expected_steps.map { |name| RubyReactor::Reactor.interrupt_key(name) } }
243
+
244
244
  match do |subject|
245
245
  subject.ensure_executed!
246
246
  return false unless subject.paused?
247
247
 
248
- actual_steps = subject.ready_interrupt_steps.sort
249
- expected = expected_steps.map(&:to_sym).sort
250
- actual_steps == expected
248
+ # Symbols and paths (Arrays) do not compare: sort by their inspect.
249
+ subject.ready_interrupt_steps.sort_by(&:inspect) == expected.call.sort_by(&:inspect)
251
250
  end
252
251
 
253
252
  failure_message do |subject|
254
253
  if subject.paused?
255
254
  actual_steps = subject.ready_interrupt_steps
256
- "expected ready interrupt steps to be #{expected_steps.map { |s| ":#{s}" }}, " \
255
+ "expected ready interrupt steps to be #{expected.call}, " \
257
256
  "but got #{actual_steps.inspect}"
258
257
  else
259
258
  "expected reactor to be paused with ready interrupt steps, " \
@@ -263,7 +262,7 @@ module RubyReactor
263
262
 
264
263
  failure_message_when_negated do |subject|
265
264
  actual_steps = subject.ready_interrupt_steps
266
- "expected ready interrupt steps not to be #{expected_steps.map { |s| ":#{s}" }}, " \
265
+ "expected ready interrupt steps not to be #{expected.call}, " \
267
266
  "but it was #{actual_steps.inspect}"
268
267
  end
269
268
  end
@@ -426,29 +426,19 @@ module RubyReactor
426
426
  # Get all ready interrupt steps (steps that can be resumed)
427
427
  # This is useful when multiple interrupts are waiting concurrently.
428
428
  #
429
- # @return [Array<Symbol>] list of ready interrupt step names
429
+ # @return [Array<Symbol, Array<Symbol>>] the reactor's own interrupts by
430
+ # name, and one inside a composed child by its step path
431
+ # (e.g. `[:fulfil, :approve]`)
430
432
  def ready_interrupt_steps
431
433
  ensure_executed!
432
- return [] unless paused?
433
-
434
- # Build the dependency graph and get ready steps
435
- graph = RubyReactor::DependencyGraph.new
436
- graph_manager = RubyReactor::Executor::GraphManager.new(
437
- @reactor_class, graph, @reactor_instance.context
438
- )
439
- graph_manager.build_and_validate!
440
- graph_manager.mark_completed_steps_from_context
441
-
442
- # Filter to only interrupt steps (using interrupt? predicate method)
443
- ready = graph_manager.dependency_graph.ready_steps
444
- ready.select { |step_config| step_config.respond_to?(:interrupt?) && step_config.interrupt? }
445
- .map { |step_config| step_config.name.to_sym }
434
+ @reactor_instance.ready_interrupt_steps
446
435
  end
447
436
 
448
437
  # Resume a paused reactor with the given payload
449
438
  #
450
439
  # @param payload [Hash] The data to provide to the interrupt step
451
- # @param step [Symbol, String, nil] The specific interrupt step to resume.
440
+ # @param step [Symbol, String, Array, nil] The specific interrupt step to resume,
441
+ # or the step path of one inside a composed child (e.g. `[:fulfil, :approve]`).
452
442
  # Required when multiple interrupts are ready. If not provided and only
453
443
  # one interrupt is ready, that step will be used.
454
444
  # @return [TestSubject] self for chaining and introspection
@@ -483,10 +473,10 @@ module RubyReactor
483
473
 
484
474
  if step
485
475
  # User explicitly specified a step
486
- step_sym = step.to_sym
476
+ step_sym = RubyReactor::Reactor.interrupt_key(step)
487
477
  unless ready_steps.include?(step_sym)
488
478
  raise RubyReactor::Error::ValidationError,
489
- "Cannot resume: step :#{step} is not ready. Ready steps: #{ready_steps.inspect}"
479
+ "Cannot resume: step #{step_sym.inspect} is not ready. Ready steps: #{ready_steps.inspect}"
490
480
  end
491
481
  step_sym
492
482
  elsif ready_steps.size == 1
@@ -74,6 +74,8 @@ module RubyReactor
74
74
  unless child_context
75
75
  composed_inputs = build_composed_inputs(inputs.argument_mappings || {})
76
76
  child_context = RubyReactor::Context.new(composed_inputs, inputs.composed_reactor_class)
77
+ # Resumed only through this run, never on its own (010 R-07).
78
+ child_context.private_data[:composed] = true
77
79
  end
78
80
 
79
81
  link_contexts(child_context, context)
@@ -120,6 +122,14 @@ module RubyReactor
120
122
  # the metadata the direct and async paths do propagate.
121
123
  return result if result.is_a?(RubyReactor::Failure)
122
124
 
125
+ # The child paused at an interrupt: so does this run. Each compose
126
+ # level re-stamps its own id, so the top-level result names the root.
127
+ if result.is_a?(RubyReactor::InterruptResult)
128
+ return RubyReactor::InterruptResult.new(execution_id: context.context_id,
129
+ correlation_id: result.correlation_id,
130
+ intermediate_results: context.intermediate_results)
131
+ end
132
+
123
133
  result.success? ? RubyReactor.Success(result.value) : RubyReactor.Failure(result.error)
124
134
  end
125
135
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyReactor
4
- VERSION = "0.8.6"
4
+ VERSION = "0.8.7"
5
5
  end
@@ -0,0 +1,43 @@
1
+ # Specification Quality Checklist: Interrupt Inside a Composed Child
2
+
3
+ **Purpose**: Validate specification completeness and quality before proceeding to planning
4
+ **Created**: 2026-10-08
5
+ **Feature**: [spec.md](../spec.md)
6
+
7
+ ## Content Quality
8
+
9
+ - [x] No implementation details (languages, frameworks, APIs)
10
+ - [x] Focused on user value and business needs
11
+ - [x] Written for non-technical stakeholders
12
+ - [x] All mandatory sections completed
13
+
14
+ ## Requirement Completeness
15
+
16
+ - [x] No [NEEDS CLARIFICATION] markers remain
17
+ - [x] Requirements are testable and unambiguous
18
+ - [x] Success criteria are measurable
19
+ - [x] Success criteria are technology-agnostic (no implementation details)
20
+ - [x] All acceptance scenarios are defined
21
+ - [x] Edge cases are identified
22
+ - [x] Scope is clearly bounded
23
+ - [x] Dependencies and assumptions identified
24
+
25
+ ## Feature Readiness
26
+
27
+ - [x] All functional requirements have clear acceptance criteria
28
+ - [x] User scenarios cover primary flows
29
+ - [x] Feature meets measurable outcomes defined in Success Criteria
30
+ - [x] No implementation details leak into specification
31
+
32
+ ## Notes
33
+
34
+ - The product is a Ruby library, so its public DSL and API (`compose`, `interrupt`, `continue`,
35
+ `be_paused_at`, `ValidationError`) are the user-facing surface and are named, as in specs
36
+ 006–009. Internal classes from the input (`ComposeStep#run`, `handle_execution_result`) stay in
37
+ the Input line and Context table only, as the observed defect; the requirements do not prescribe
38
+ them.
39
+ - Defaults chosen instead of clarification markers (see Assumptions): path given as an array, bare
40
+ leaf name not matched to a nested interrupt, web dashboard display and map-element interrupts out
41
+ of scope.
42
+ - FR-017 is a small adjacent guard: once a compose propagates the pause, a compose inside a map
43
+ element would hand the map an unhandled pause result, so it must fail clearly.
@@ -0,0 +1,75 @@
1
+ # Public API Contract: Interrupt Inside a Composed Child
2
+
3
+ Every addition is backward compatible. Calls that do not use a path behave as before.
4
+
5
+ ## `Reactor.run` / `Reactor#run`
6
+
7
+ When a composed child reaches an interrupt, the run returns an `InterruptResult` with:
8
+
9
+ - `paused?` → `true`, `status` → `:paused`;
10
+ - `execution_id` → **the root's** id;
11
+ - `correlation_id` → the child interrupt's correlation id, or `nil`.
12
+
13
+ `Reactor.find(id).result` rebuilds an `InterruptResult` with the root's id, and without
14
+ `correlation_id` (unchanged).
15
+
16
+ ## `Reactor.continue(id:, payload:, step_name:)`, `Reactor#continue(payload:, step_name:)`
17
+
18
+ `step_name` takes either form:
19
+
20
+ | `step_name` | Meaning |
21
+ | --- | --- |
22
+ | `:approve` / `"approve"` | A step of the root (unchanged). |
23
+ | `[:fulfil, :approve]` / `["fulfil", "approve"]` | The interrupt `:approve` inside compose `:fulfil`. |
24
+ | `[:order, :fulfil, :approve]` | Two composes deep. |
25
+ | `[:approve]` | Same as `:approve`. |
26
+
27
+ Errors:
28
+
29
+ - `RubyReactor::Error::ValidationError` when the name or path is not a pending interrupt
30
+ (`"Cannot resume: expected step '<current>' or ready steps <ready_interrupt_steps> but got
31
+ '<name>'"`). Nothing changes and the run stays `paused`. A bare name must now be a pending
32
+ interrupt: naming any other ready step is refused, where it used to be accepted.
33
+ - `RubyReactor::Error::ValidationError` when the reactor loaded is a composed child: "Cannot
34
+ resume: <Class> is a composed child; continue its root run".
35
+ - Every existing error keeps its meaning and is driven by the nested interrupt's own declaration:
36
+ not paused, cancelled, invalid payload (`InputValidationError` from the class method),
37
+ `max_attempts` exhaustion (`Failure`, run rolled back from the root and `failed`), and
38
+ lock/semaphore `AcquisitionError` (run stays paused).
39
+
40
+ `resume: :background` on the nested interrupt enqueues **the root's** worker.
41
+
42
+ ## `Reactor.continue_by_correlation_id(correlation_id:, payload:, step_name:)`
43
+
44
+ Called on the **root** class with the path. Called on the child class, it raises the
45
+ composed-child `ValidationError` above.
46
+
47
+ ## `Reactor.interrupt_key(step_name)` (new, public)
48
+
49
+ Normalizes a step name the way `continue` reads it. `:a`, `"a"` and `[:a]` all give `:a`;
50
+ `["f", "a"]` gives `[:f, :a]`.
51
+
52
+ ## `Reactor#ready_interrupt_steps` (new, public)
53
+
54
+ ```ruby
55
+ Order.find(id).ready_interrupt_steps # => [[:fulfil, :approve]]
56
+ ```
57
+
58
+ Returns `[]` unless the run is `paused`. Root-level interrupts come back as Symbols, nested ones as
59
+ Arrays. A root can be paused at a nested interrupt and have one of its own ready at the same
60
+ time, so the list can mix both: `[:audit, [:fulfil, :approve]]`.
61
+
62
+ ## RSpec surface (`require "ruby_reactor/rspec"`)
63
+
64
+ | API | Change |
65
+ | --- | --- |
66
+ | `subject.ready_interrupt_steps` | Delegates to `Reactor#ready_interrupt_steps`, so it includes paths. |
67
+ | `be_paused_at([:fulfil, :approve])` | Matches a path. `be_paused_at(:a, :b)` still means two root interrupts. Failure messages print entries with `inspect`. |
68
+ | `have_ready_interrupts(:audit, [:fulfil, :approve])` | Exact set, paths included. |
69
+ | `subject.resume(payload:, step: [:fulfil, :approve])` | Resumes a nested interrupt. With one pending interrupt, `step` may be omitted. |
70
+
71
+ ## Map elements
72
+
73
+ An `interrupt` reached inside a map element, directly or through a compose, fails that element
74
+ with `Failure("interrupt :<name> is not supported inside a map element")`. Before this feature it
75
+ crashed with `NoMethodError`.
@@ -0,0 +1,40 @@
1
+ # Data Model: Interrupt Inside a Composed Child
2
+
3
+ No new storage keys or records. One new `private_data` flag; everything else is derived from state
4
+ that is already stored.
5
+
6
+ ## Root context (stored row, unchanged shape)
7
+
8
+ | Field | While paused inside a child | Notes |
9
+ | --- | --- | --- |
10
+ | `status` | `paused` | Set by `update_context_status` on the root's `InterruptResult`. |
11
+ | `current_step` | the compose step name (e.g. `:fulfil`) | Set by the `ResultHandler` interrupt arm (R-03). |
12
+ | `composed_contexts[:fulfil]` | `{ name:, type: :composed, context: <child> }` | Child embedded as today. |
13
+ | `undo_stack` | ends with the compose step, `result: Success(nil)` | Partial-run entry (R-04). It is replaced, not duplicated, when the compose completes. |
14
+ | `private_data[:interrupt_attempts]` | keyed by `:"fulfil.approve"` (a Symbol) for a nested interrupt | Root-level interrupts keep their own name as the key (R-06). |
15
+
16
+ ## Child context (embedded in the root, and its own observability row)
17
+
18
+ | Field | While paused | Notes |
19
+ | --- | --- | --- |
20
+ | `status` | `paused` | As today, set by the child's executor. |
21
+ | `current_step` | the interrupt (e.g. `:approve`), or the next compose one level down | |
22
+ | `intermediate_results[:approve]` | the payload, once `continue` accepted it | Written to the embedded child, never the observability row. |
23
+ | `private_data[:composed]` | `true` | **New.** Set when the compose step creates the child; refuses a direct `continue` (R-07). |
24
+
25
+ ## Step path (value object, not stored)
26
+
27
+ `Array` of two or more step names: the compose steps from the root, then the interrupt. It is
28
+ normalized to Symbols on input. A one-element array is the same as the bare name.
29
+
30
+ ## State transitions (root)
31
+
32
+ ```text
33
+ running ──child reaches interrupt──▶ paused
34
+ paused ──continue(path, valid payload)──▶ running ──▶ completed | failed | paused (next interrupt)
35
+ paused ──continue(path, payload, resume: :background)──▶ running (worker) ──▶ …
36
+ paused ──continue(path, invalid payload, attempts exhausted)──▶ undo ──▶ failed
37
+ paused ──undo──▶ cancelled paused ──cancel──▶ cancelled
38
+ ```
39
+
40
+ The child mirrors `paused → running → completed` inside the root and is never resumed on its own.