ruby_reactor 0.8.6 → 0.8.8

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/CHANGELOG.md +81 -0
  5. data/CLAUDE.md +1 -1
  6. data/README.md +42 -3
  7. data/lib/ruby_reactor/dsl/map_builder.rb +20 -1
  8. data/lib/ruby_reactor/error/recorded_failure.rb +18 -0
  9. data/lib/ruby_reactor/executor/compensation_manager.rb +27 -1
  10. data/lib/ruby_reactor/executor/result_handler.rb +16 -0
  11. data/lib/ruby_reactor/executor/retry_manager.rb +2 -2
  12. data/lib/ruby_reactor/executor/step_executor.rb +20 -0
  13. data/lib/ruby_reactor/executor.rb +62 -10
  14. data/lib/ruby_reactor/interrupt_claims.rb +81 -0
  15. data/lib/ruby_reactor/map/step_rollback.rb +121 -0
  16. data/lib/ruby_reactor/reactor.rb +270 -105
  17. data/lib/ruby_reactor/rspec/matchers.rb +64 -16
  18. data/lib/ruby_reactor/rspec/test_subject.rb +25 -24
  19. data/lib/ruby_reactor/step/compose_step.rb +10 -0
  20. data/lib/ruby_reactor/step/map_step.rb +12 -0
  21. data/lib/ruby_reactor/storage/adapter.rb +17 -0
  22. data/lib/ruby_reactor/storage/redis_adapter.rb +42 -0
  23. data/lib/ruby_reactor/version.rb +1 -1
  24. data/lib/ruby_reactor/web/api.rb +17 -0
  25. data/lib/ruby_reactor/web/public/assets/{index-C6ejszCQ.js → index-DxjQlW7Q.js} +8 -8
  26. data/lib/ruby_reactor/web/public/index.html +1 -1
  27. data/lib/ruby_reactor/worker.rb +73 -6
  28. data/specs/010-interrupt-in-composed-child/checklists/requirements.md +43 -0
  29. data/specs/010-interrupt-in-composed-child/contracts/api-surface.md +75 -0
  30. data/specs/010-interrupt-in-composed-child/data-model.md +40 -0
  31. data/specs/010-interrupt-in-composed-child/plan.md +149 -0
  32. data/specs/010-interrupt-in-composed-child/quickstart.md +64 -0
  33. data/specs/010-interrupt-in-composed-child/research.md +207 -0
  34. data/specs/010-interrupt-in-composed-child/spec.md +267 -0
  35. data/specs/010-interrupt-in-composed-child/tasks.md +315 -0
  36. data/specs/010-rollback-follow-ups/checklists/requirements.md +47 -0
  37. data/specs/010-rollback-follow-ups/contracts/api-surface.md +123 -0
  38. data/specs/010-rollback-follow-ups/contracts/resume-protocol.md +132 -0
  39. data/specs/010-rollback-follow-ups/data-model.md +139 -0
  40. data/specs/010-rollback-follow-ups/plan.md +273 -0
  41. data/specs/010-rollback-follow-ups/pr-description.md +110 -0
  42. data/specs/010-rollback-follow-ups/quickstart.md +157 -0
  43. data/specs/010-rollback-follow-ups/research.md +440 -0
  44. data/specs/010-rollback-follow-ups/spec.md +489 -0
  45. data/specs/010-rollback-follow-ups/tasks.md +1022 -0
  46. data/specs/future_improvements.md +17 -14
  47. metadata +22 -2
@@ -0,0 +1,207 @@
1
+ # Research: Interrupt Inside a Composed Child
2
+
3
+ Every decision below was taken by reading the code on `main` (5ca7d1d1). Line references are to
4
+ that commit.
5
+
6
+ ## R-01: Where the pause breaks today
7
+
8
+ **Finding**: The child's executor handles its own pause correctly: `handle_interrupt_step` sets the
9
+ child's `current_step`, `update_context_status` marks it `paused`, `handle_interrupt` saves it.
10
+ The crash comes later, in the parent, at three places that do not know `InterruptResult`:
11
+
12
+ 1. `ComposeStep#handle_execution_result` calls `result.success?` (`compose_step.rb`, last method).
13
+ 2. `RetryManager#handle_retry_result` falls to `else` and turns it into a `Failure("returned
14
+ unexpected result")`.
15
+ 3. `ResultHandler#handle_step_result` falls to `handle_unknown_result`, which would record it as a
16
+ completed step.
17
+
18
+ **Decision**: Pass `InterruptResult` through all three. Each gets one explicit arm.
19
+
20
+ **Alternatives rejected**: A parent-side exception to unwind the stack (like
21
+ `Error::RollbackHandedOff`). An interrupt is a normal result, not an error; `execute_all_steps`
22
+ already stops on it (`step_executor.rb:50`), and both `update_context_status` and
23
+ `execute_current_step_and_continue` already list it as terminal.
24
+
25
+ ## R-02: The paused result names the root run
26
+
27
+ **Finding**: The child's `InterruptResult` carries the child's `context_id`. A caller would get an
28
+ id that `Root.find` cannot load as the root.
29
+
30
+ **Decision**: `ComposeStep#handle_execution_result` rebuilds the result with `execution_id:
31
+ context.context_id`, the step's own context, keeping `correlation_id`, and passing
32
+ `context.intermediate_results`. Each compose level re-stamps its own id, so at any depth the
33
+ top-level result carries the root's id.
34
+
35
+ The root executor's `handle_interrupt` then stores the correlation mapping against the root id
36
+ and the root class (`executor.rb:1016`) with no change.
37
+
38
+ ## R-03: The root's resume cursor
39
+
40
+ **Finding**: `execute_step_sync_without_result_handling` runs the compose body inside
41
+ `Context#with_step`, whose `ensure` restores the previous `current_step` (nil) when the body
42
+ returns. A root-level interrupt avoids this: `handle_interrupt_step` runs outside `with_step` and
43
+ sets `current_step` itself. After a nested pause, the root would have no cursor, and
44
+ `Reactor#continue` refuses a context without `current_step`.
45
+
46
+ **Decision**: The `ResultHandler` arm for `InterruptResult` sets `@context.current_step =
47
+ step_config.name`. On resume, `execute_current_step_and_continue` finds the compose step missing
48
+ from `intermediate_results`, re-runs it, and `ComposeStep#run` → `execute_child_reactor` sees an
49
+ admitted child and calls `resume_execution` on it. The child's own `current_step` is the
50
+ interrupt, whose result is now set, so it carries on (`executor.rb:897`).
51
+
52
+ ## R-04: Undo of a run paused inside a child
53
+
54
+ **Finding**: Rollback replays the undo stack (`CompensationManager#rollback_completed_steps`).
55
+ A step joins it only on success (`ResultHandler#handle_success`). The paused compose step never
56
+ succeeded, so `Root.undo` would undo `r1` and leave the child's `c1` done. A root-level interrupt
57
+ has no such gap, because the interrupt step has no side effects.
58
+
59
+ The same gap was already closed for an *interrupted* (aborted) compose:
60
+ `StepExecutor#track_interrupted_construct` pushes the step with `result: Success(nil)` because
61
+ `ComposeStep.undoes_partial_run?` is true, and `ComposeStep#compensate` undoes whatever the child
62
+ completed.
63
+
64
+ **Decision**: The `InterruptResult` arm in `ResultHandler` pushes the same partial-run entry for a
65
+ step whose class `undoes_partial_run?`. When that step later completes,
66
+ `CompensationManager#add_to_undo_stack` replaces a top entry for the same step instead of
67
+ pushing a second one.
68
+
69
+ - The entry is always the top: the root stops at the pause, so nothing runs after it, and on
70
+ resume the compose step is the first to run.
71
+ - Replacing it keeps the undo trace to one `undo:<compose>` per run. Pushing twice would undo the
72
+ compose twice. The second undo would find the child's stack empty, so it would be harmless, but
73
+ still wrong.
74
+ - `max_attempts` exhaustion calls `undo` (`reactor.rb:478`), so it is covered by the same entry.
75
+
76
+ **Alternatives rejected**: Special-casing `Reactor#undo` for a paused run, to compensate the
77
+ current compose step first. That is a second rollback path beside the undo stack, and the
78
+ max-attempts path would need the same code again.
79
+
80
+ ## R-05: Naming a nested interrupt
81
+
82
+ **Decision**: A path is an `Array` of two or more names: the compose step names from the root
83
+ down, then the interrupt. Symbols and strings are both accepted, so a JSON body works through the
84
+ web API's `continue` endpoint. A one-element array is the same as the bare name. A bare name
85
+ keeps its current meaning, a step of the root.
86
+
87
+ **Pending paths**: Derived from the stored state; no new field is stored. For a context and its
88
+ reactor class:
89
+
90
+ 1. Take the ready steps from the dependency graph. This is the computation
91
+ `validate_continue_step!` and `TestSubject#ready_interrupt_steps` both do today.
92
+ 2. Keep the ready interrupt steps, as names.
93
+ 3. For a ready compose step whose stored child context (`composed_contexts[name][:context]`) is
94
+ `paused`, recurse into the child with its `reactor_class`, prefixing the compose name.
95
+
96
+ **Decision**: Put this in one public method, `Reactor#ready_interrupt_steps`. It returns Symbols
97
+ for root-level interrupts (unchanged for existing callers) and Arrays for nested ones.
98
+ `TestSubject#ready_interrupt_steps` delegates to it, which removes the duplicated graph code
99
+ there.
100
+
101
+ **Alternatives rejected**:
102
+ - Splat arguments (`be_paused_at(:fulfil, :approve)`): that already means two concurrent root
103
+ interrupts.
104
+ - A dotted string `"fulfil.approve"`: step names may contain dots, and Symbol-vs-String parsing
105
+ would leak into every caller.
106
+ - Matching a unique bare leaf: a root that later adds a step of that name silently changes what an
107
+ old `continue` call resumes.
108
+
109
+ **Implementation notes** (added during `/speckit-implement`):
110
+
111
+ - **Stricter bare names.** `Reactor#continue` now resolves bare names through
112
+ `ready_interrupt_steps` too. The old fast path accepted any `current_step`, and a compose step
113
+ is now a valid `current_step`: `continue(step_name: :fulfil)` would have stored the payload as
114
+ the compose's result and skipped the child. The old wording of the refusal ("expected step 'x'
115
+ or ready steps [...] but got 'y'") is kept, and it lists the pending interrupts.
116
+ - **The paused-at interrupt stays pending.** A resume contended on the root's lock leaves its
117
+ payload stored and the run `paused` (`reopen_paused`). That stored payload marks the interrupt
118
+ complete in the graph. So the interrupt a context's `current_step` names stays pending anyway,
119
+ and the retry is accepted, at any depth (`resume_guard_spec.rb` and the nested contention
120
+ example).
121
+ - **`Reactor.interrupt_key(step_name)`** (public class method) is the single normalizer: a
122
+ one-name path becomes its Symbol, otherwise an Array of Symbols. `continue`, the test subject
123
+ and the matchers use it.
124
+
125
+ ## R-06: `Reactor#continue` with a path
126
+
127
+ **Decision**: Resolve the target once, at the top of `continue`: `target_context, step_config,
128
+ leaf = resolve_interrupt_target(step_name)`.
129
+
130
+ - For a bare name it is `@context`, `self.class.steps[name]`, `name`, which is today's behavior.
131
+ - For a path it first checks membership in `ready_interrupt_steps`, raising `ValidationError` that
132
+ lists them. It then walks `composed_contexts` down the path and returns the child context, its
133
+ class's step config and the leaf.
134
+
135
+ The rest of `continue` uses these three instead of `self.class.steps[step_name]` and
136
+ `@context.set_result`:
137
+
138
+ - payload validation;
139
+ - the `max_attempts` counter, keyed by the joined path as a Symbol (`:"fulfil.approve"`) for a
140
+ nested interrupt, so it does not share a counter with a root step of the same name. It has to be
141
+ a Symbol: the serializer symbolizes `private_data` keys, so a String key would reset after every
142
+ reload;
143
+ - `background_resume?`;
144
+ - `set_result`.
145
+
146
+ Status guards, the `running` claim, `save_context` and `resume_execution` stay on the root
147
+ unchanged. So the paused-only guard (008 FR-032), cancel, lock contention (`reopen_paused`) and
148
+ background resume (`enqueue_background_resume` enqueues the root) all apply to a nested resume for
149
+ free.
150
+
151
+ ## R-07: A child never resumes on its own
152
+
153
+ **Finding**: The child executor's `save_context` also stores the child under its own id and class
154
+ (the observability row), and its `handle_interrupt` maps the correlation id to that row. So
155
+ `Child.find(child_id)` loads a `paused` child, and `Child.continue(...)` or
156
+ `Child.continue_by_correlation_id(...)` would run it as a standalone run. That breaks the
157
+ single-writer rule: the root's embedded copy and the standalone row would diverge.
158
+
159
+ **Decision**: `ComposeStep#prepare_child_context` marks a new child `private_data[:composed] =
160
+ true`; `private_data` is already serialized. `Reactor#continue` raises `ValidationError` on a
161
+ context carrying that mark (`"Cannot resume: <class> is a composed child; continue its root
162
+ run"`).
163
+
164
+ - This covers resume by id and by correlation id in one check.
165
+ - The child's correlation row stays: the guard makes it harmless, and dropping it would need a
166
+ second check in `handle_interrupt`.
167
+ - No stored child predates the mark and can be paused, since a nested pause crashed before this
168
+ feature.
169
+
170
+ **Alternative rejected**: Guarding on `parent_context_id`. An `async_reactor` child
171
+ (`async_reactor_step.rb:175`) sets it too, and that child *is* an addressable run that pauses and
172
+ resumes on its own.
173
+
174
+ ## R-08: Interrupt inside a map element
175
+
176
+ **Finding**: A map element that receives an `InterruptResult` crashes:
177
+
178
+ - inline: `MapStep#execute_inline_map` calls `result.failure?`;
179
+ - fan-out: `ElementExecutor.handle_result` calls `result.halted?`.
180
+
181
+ This happens today for an `interrupt` directly in an element. After this feature, the same crash
182
+ also happens for one reached through a compose inside the element.
183
+
184
+ **Decision**: `StepExecutor#handle_interrupt_step` fails the step ("interrupt :name is not
185
+ supported inside a map element") when the context or any `parent_context` up the chain has
186
+ `map_metadata`. The `Failure` goes through `ResultHandler#handle_step_result`, as an
187
+ argument-resolution failure does, so the element rolls back what it completed before the
188
+ interrupt. The element then fails like any element: an atomic map rolls back, and a
189
+ non-atomic one records the error. One check, at the source, covers both map modes and any compose
190
+ depth inside the element.
191
+
192
+ ## R-09: Out of scope
193
+
194
+ - `async_reactor` children are addressable runs of their own and already pause on their own. They
195
+ are unchanged.
196
+ - `reconstruct_paused_result` keeps omitting `correlation_id`, for root-level and nested pauses
197
+ alike (spec FR-003).
198
+ - The dashboard does not render the nested path. It reads the root's status (`paused`) and
199
+ drills into `composed_contexts` as today.
200
+ - Compose steps cannot declare `retries` (008 R-14), so a pause never consumes a retry attempt
201
+ that matters. No decrement is needed.
202
+
203
+ ## R-10: SemVer
204
+
205
+ The change is MINOR: a previously crashing case now works, plus one additive public method
206
+ (`Reactor#ready_interrupt_steps`) and an additive argument form (Array `step_name`). Nothing that
207
+ worked changes behavior.
@@ -0,0 +1,267 @@
1
+ # Feature Specification: Interrupt Inside a Composed Child
2
+
3
+ **Feature Branch**: `interrupt_inside_composed_child`
4
+
5
+ **Created**: 2026-10-08
6
+
7
+ **Status**: Draft
8
+
9
+ **Input**: User description: "Interrupt inside a composed child. Status: unsupported on main, found
10
+ while implementing 009 (US2-AS2). An `interrupt` in a `compose`d child does not pause the root:
11
+ `ComposeStep#handle_execution_result` calls `success?` on the child's `InterruptResult`, and the
12
+ compose step fails with `NoMethodError`. It fails the same way with or without a fan-out map in the
13
+ child. Direction: propagate the child's `InterruptResult` as the compose step's result, so the root
14
+ pauses; let `Reactor.continue` and the `be_paused_at` matcher name the nested interrupt (a step path
15
+ such as `:fulfil, :approve`); and resume through `ComposeStep#run`, which already re-enters an
16
+ admitted child. Spec: `spec/map/map_compose_fan_out_spec.rb` keeps a `pending` example for it."
17
+
18
+ ## Context
19
+
20
+ What happens today, and what this feature changes:
21
+
22
+ | Area | Today | After this feature |
23
+ | --- | --- | --- |
24
+ | Child reaches an `interrupt` | The compose step crashes (`NoMethodError` on the child's pause result); the root fails and rolls back. | The root pauses, stored as `paused`, with the child's completed steps kept. |
25
+ | The paused result | (never reached) | Identifies the **root** run (its `execution_id`) and carries the child interrupt's `correlation_id`. |
26
+ | Naming the pause | Only the root's own steps can be named. | A nested interrupt is named by its step path from the root, e.g. `[:fulfil, :approve]`. |
27
+ | Resuming | (never reached) | `continue` on the root, by id or correlation id, re-enters the child at its interrupt; the child finishes, then the root. |
28
+ | Undo / cancel while paused in a child | (never reached) | Work as for a root-level pause; undo also rolls back the steps the child completed. |
29
+ | Docs | `composition.md` says an `interrupt` inside a composed child is not supported yet. | Documented as supported, with the path form. |
30
+
31
+ ## User Scenarios & Testing *(mandatory)*
32
+
33
+ ### User Story 1 - Pause the root at an interrupt inside a composed child (Priority: P1)
34
+
35
+ A developer composes a `Fulfilment` reactor into an `Order` reactor (`compose :fulfil,
36
+ Fulfilment`). `Fulfilment` contains `interrupt :approve` waiting for a manager's approval. Running
37
+ `Order` must stop at the approval: the root run is stored as `paused`, the steps completed so far in
38
+ both the root and the child are kept, and the caller gets a paused result that identifies the
39
+ **root** run, so it can be resumed later.
40
+
41
+ **Why this priority**: Without this the feature is unusable: any reactor that reuses a child with a
42
+ human approval or webhook wait fails outright. Every other story depends on the root pausing
43
+ correctly.
44
+
45
+ **Independent Test**: Run a root whose composed child has an interrupt. Verify the root is stored as
46
+ `paused`, the result's `execution_id` is the root's id, the child's steps before the interrupt ran
47
+ exactly once, no step after the interrupt (in the child or the root) ran, and nothing was rolled
48
+ back.
49
+
50
+ **Acceptance Scenarios**:
51
+
52
+ 1. **Given** a root composing a child with steps `c1`, `interrupt :approve`, `c2`, **When** the root
53
+ runs, **Then** it is stored as `paused`, `c1` ran once, `c2` and every root step after the compose
54
+ did not run, and the returned result reports `paused?` with the root's `execution_id`.
55
+ 2. **Given** the child interrupt declares a `correlation_id`, **When** the root pauses, **Then** the
56
+ paused result carries that correlation id and it resolves to the root run.
57
+ 3. **Given** a child that runs a `fan_out` map before its interrupt, **When** the map completes and
58
+ the root's worker resumes the run, **Then** the root is stored as `paused` at the child's
59
+ interrupt (the `pending` example in `spec/map/map_compose_fan_out_spec.rb`).
60
+ 4. **Given** the interrupt sits two composes deep (root → middle → child), **When** the root runs,
61
+ **Then** the top-level root is stored as `paused`; no intermediate reactor is stored or resumable
62
+ as a run of its own.
63
+
64
+ ---
65
+
66
+ ### User Story 2 - Resume the root by naming the nested interrupt (Priority: P1)
67
+
68
+ The approval arrives. The developer calls `Order.continue(id:, payload:, step_name: [:fulfil,
69
+ :approve])`, or `Order.continue_by_correlation_id(...)` with the same path. The payload becomes the
70
+ interrupt's result inside the child, the child runs its remaining steps, and the root carries on
71
+ from the compose step to completion, exactly as a root-level interrupt resumes.
72
+
73
+ **Why this priority**: Pausing without resuming is a dead end. Together with US1 this is the
74
+ minimum viable feature.
75
+
76
+ **Independent Test**: Pause a root at a child interrupt, then continue it with the step path and a
77
+ payload. Verify the child step after the interrupt receives the payload, the root completes, and
78
+ every step ran exactly once across pause and resume.
79
+
80
+ **Acceptance Scenarios**:
81
+
82
+ 1. **Given** a root paused at `[:fulfil, :approve]`, **When** `continue` is called with that path
83
+ and a valid payload, **Then** the child's steps after the interrupt run, the root's steps after
84
+ the compose run, the root ends `completed`, and its result equals that of an uninterrupted run
85
+ given the same payload.
86
+ 2. **Given** a root paused at a child interrupt with a `correlation_id`, **When**
87
+ `continue_by_correlation_id` is called on the root class with the path, **Then** the run resumes
88
+ as in scenario 1.
89
+ 3. **Given** the child interrupt declares `resume: :background`, **When** `continue` is called,
90
+ **Then** the payload is validated in the calling process and the remainder runs in the root's
91
+ worker, never in a worker for the child.
92
+ 4. **Given** a root paused at a child interrupt, **When** `continue` names a step that is not the
93
+ pending interrupt (a bare `:approve`, a wrong path, or a root step), **Then** it raises
94
+ `ValidationError` naming the pending path(s) and the run stays `paused`, unchanged.
95
+ 5. **Given** the child interrupt validates its payload with `max_attempts: 1`, **When** `continue`
96
+ is called with an invalid payload, **Then** the whole run is rolled back from the root (the
97
+ child's completed steps, then the root's) and marked `failed`, as for a root-level interrupt.
98
+ 6. **Given** a child that pauses at two interrupts in turn, **When** each is continued in order,
99
+ **Then** the root pauses again at the second and completes after the second resume.
100
+
101
+ ---
102
+
103
+ ### User Story 3 - Undo or cancel a run paused inside a child (Priority: P2)
104
+
105
+ The approval is refused, so an operator undoes or cancels the paused order. `Order.undo(id)` must
106
+ roll back every step completed so far, including the child's, and mark the run `cancelled`;
107
+ `Order.cancel(id:, reason:)` must stop it so no later `continue` resumes it.
108
+
109
+ **Why this priority**: Required for saga correctness once pauses exist, but a paused run is
110
+ already safe to leave alone, so it ranks after pausing and resuming.
111
+
112
+ **Independent Test**: Pause a root at a child interrupt, call `undo`. Verify the child's completed
113
+ steps were undone once, before the root's, in reverse order, and the run is `cancelled`.
114
+
115
+ **Acceptance Scenarios**:
116
+
117
+ 1. **Given** a root paused at a child interrupt after `r1` (root) and `c1` (child) completed,
118
+ **When** the root is undone, **Then** `c1` is undone before `r1`, each once, and the root is
119
+ `cancelled`.
120
+ 2. **Given** a root paused at a child interrupt after a `fan_out` map in the child, **When** the
121
+ root is undone, **Then** the map's elements, the child's earlier steps, then the root's are
122
+ rolled back, as 009 specifies for a completed run.
123
+ 3. **Given** a root paused at a child interrupt, **When** it is cancelled, **Then** a later
124
+ `continue` with the nested path raises `ValidationError` and runs nothing.
125
+
126
+ ---
127
+
128
+ ### User Story 4 - Test a nested pause with the RSpec helpers (Priority: P3)
129
+
130
+ A developer testing `Order` writes `expect(subject).to be_paused_at([:fulfil, :approve])` and
131
+ resumes it from the test subject with the same path.
132
+
133
+ **Why this priority**: Developer convenience; the runtime behavior is verifiable without it.
134
+
135
+ **Independent Test**: In a spec using the library's test subject, run a root that pauses in a
136
+ child, assert the matcher passes for the path and fails with a message listing the actual pending
137
+ path for any other name, then resume through the test subject.
138
+
139
+ **Acceptance Scenarios**:
140
+
141
+ 1. **Given** a test subject paused at a child interrupt, **When** asserting `be_paused_at([:fulfil,
142
+ :approve])`, **Then** it passes; `be_paused_at(:approve)` fails with a message listing
143
+ `[:fulfil, :approve]` as the pending step.
144
+ 2. **Given** the same subject, **When** its ready interrupt steps are listed, **Then** the nested
145
+ interrupt appears as its path.
146
+ 3. **Given** the same subject, **When** it is resumed with the path and a payload, **Then** it
147
+ completes as in US2.
148
+
149
+ ---
150
+
151
+ ### Edge Cases
152
+
153
+ - **Root has its own interrupt ready alongside the compose.** The run pauses at whichever it reaches
154
+ first; resuming that one runs on and pauses at the other, as for two root-level interrupts.
155
+ - **A second `continue` while the first resume is executing.** Rejected with the existing "not
156
+ paused" `ValidationError` (008 FR-032), whatever path it names.
157
+ - **The child's reactor class used directly.** `Fulfilment.continue` or
158
+ `Fulfilment.continue_by_correlation_id` never resumes the child as a run of its own; the root is
159
+ the only run that owns the pause.
160
+ - **A `continue` that cannot take the root's `with_lock` or `with_semaphore`.** Raises the
161
+ `AcquisitionError` and leaves the run paused, as for a root-level resume.
162
+ - **A process interruption (signal, exit) during the nested resume.** Handled as today for an
163
+ interruption inside a composed child: the run is stored `aborted` with outstanding undo work.
164
+ - **An interrupt inside a composed child inside a `map` element.** Out of scope: an interrupt in a
165
+ map element is not supported either; it must fail with a clear error, not a `NoMethodError`.
166
+ - **Path names a compose step whose child is not paused, or a non-compose step.** Treated as a
167
+ wrong step name (US2-AS4).
168
+
169
+ ## Requirements *(mandatory)*
170
+
171
+ ### Functional Requirements
172
+
173
+ **Pausing**
174
+
175
+ - **FR-001**: When a composed child reaches an `interrupt`, the root run MUST pause: it is stored as
176
+ `paused`, no step after the interrupt in the child or after the compose in the root runs, and
177
+ nothing is rolled back.
178
+ - **FR-002**: The pause MUST work at any compose depth, and whether the run reached the interrupt
179
+ in the caller's process or in a worker (including after a `fan_out` map in the child).
180
+ - **FR-003**: The paused result returned to the caller MUST identify the root run by its
181
+ `execution_id` and carry the child interrupt's `correlation_id` when it declares one. A result
182
+ rebuilt by `find(id).result` MUST identify the root run, exactly as for a root-level pause (which
183
+ does not rebuild the correlation id either).
184
+ - **FR-004**: A child interrupt's `correlation_id` MUST resolve to the root run through the root
185
+ class's correlation lookup.
186
+ - **FR-005**: Only the root run owns the pause: an intermediate or child reactor MUST NOT be stored,
187
+ looked up or resumed as a run of its own (single-writer rule).
188
+
189
+ **Naming and resuming**
190
+
191
+ - **FR-006**: A nested interrupt MUST be named by its step path from the root: the compose step
192
+ names from the root down, then the interrupt name, as an ordered list (e.g. `[:fulfil,
193
+ :approve]`, or `[:order, :fulfil, :approve]` two levels deep). Symbols and strings MUST both be
194
+ accepted, so a path sent as JSON works.
195
+ - **FR-007**: A bare name MUST keep meaning a step of the root itself; it MUST NOT match a nested
196
+ interrupt.
197
+ - **FR-008**: `continue` and `continue_by_correlation_id` on the root MUST accept the path, store
198
+ the payload as the nested interrupt's result, re-enter the child at that interrupt, and run the
199
+ child's and then the root's remaining steps.
200
+ - **FR-009**: A resumed run MUST produce the same result, and run each step exactly once, as the
201
+ same tree run without the pause given the same payload.
202
+ - **FR-010**: Every resume rule that applies to a root-level interrupt MUST apply to a nested one,
203
+ driven by the nested interrupt's own declaration: payload validation and `max_attempts` (rollback
204
+ of the whole run from the root on exhaustion), `resume: :background` (remainder runs in the root's
205
+ worker), the paused-only guard, cancellation, and lock/semaphore contention leaving the run
206
+ paused.
207
+ - **FR-011**: A `continue` naming anything other than a currently pending interrupt MUST raise
208
+ `ValidationError` listing the pending step(s) in path form, and change nothing.
209
+
210
+ **Undo and cancel**
211
+
212
+ - **FR-012**: `undo` on a root paused inside a child MUST roll back the steps the child completed
213
+ (including any `fan_out` map elements, per 009) before the root's own, each once, and mark the run
214
+ `cancelled`.
215
+ - **FR-013**: `cancel` on a root paused inside a child MUST make every later `continue` fail.
216
+
217
+ **Testing helpers and docs**
218
+
219
+ - **FR-014**: The `be_paused_at` matcher, the ready-interrupt listing and the test subject's resume
220
+ MUST accept and report nested interrupts in path form; a failure message MUST list the pending
221
+ path(s).
222
+ - **FR-015**: The `pending` example in `spec/map/map_compose_fan_out_spec.rb` MUST become a real,
223
+ passing example that pauses at an interrupt after the child's map, then resumes the root to
224
+ completion.
225
+ - **FR-016**: `composition.md` and `interrupts.md` MUST describe the nested pause, the path form and
226
+ how undo reaches the child; the "not supported yet" sentence MUST go.
227
+ - **FR-017**: An interrupt reached inside a `map` element (directly or through a compose there) MUST
228
+ fail the step with an error that says it is unsupported, not a `NoMethodError`.
229
+
230
+ ### Key Entities
231
+
232
+ - **Root run**: The top-level execution a caller started. Sole owner of the stored state, the
233
+ `paused` status, the correlation mapping and every resume.
234
+ - **Nested pause point**: The interrupt a composed child stopped at, identified by its step path
235
+ from the root.
236
+ - **Step path**: Ordered list of compose step names from the root to the child, ending with the
237
+ interrupt name.
238
+
239
+ ## Success Criteria *(mandatory)*
240
+
241
+ ### Measurable Outcomes
242
+
243
+ - **SC-001**: 100% of runs whose composed child (at depth 1 or 2) reaches an interrupt end
244
+ `paused`, with zero failures or rollbacks caused by the pause itself.
245
+ - **SC-002**: A paused-then-resumed run ends with the same final result as the equivalent
246
+ uninterrupted run in 100% of tested cases, with every step run exactly once.
247
+ - **SC-003**: Undoing a run paused inside a child undoes each completed step exactly once, child
248
+ steps before root steps, in 100% of tested cases.
249
+ - **SC-004**: Every wrongly named resume is rejected with a message listing the pending path, and
250
+ leaves the run unchanged.
251
+ - **SC-005**: The previously `pending` example passes on every async backend the suite covers,
252
+ with no other example in the suite regressing.
253
+
254
+ ## Assumptions
255
+
256
+ - The path is an ordered list (array). Passing names as separate arguments is already taken: the
257
+ `be_paused_at` matcher reads `be_paused_at(:a, :b)` as two concurrent root-level interrupts.
258
+ - A bare leaf name is not accepted for a nested interrupt even when unique: explicit paths avoid a
259
+ silent change of meaning when a root later adds a step of the same name.
260
+ - The web dashboard's display of a nested pause is out of scope; its continue endpoint passes
261
+ `step_name` through, so a JSON array path works there without extra work.
262
+ - Interrupt `timeout` behavior is unchanged: whatever a root-level interrupt does with it applies
263
+ equally.
264
+ - Interrupts inside `map` elements stay unsupported; this feature only replaces their crash with a
265
+ clear error.
266
+ - Builds on 008 (paused-only resume guard, `aborted` runs) and 009 (fan-out map inside a composed
267
+ child, rollback through the root), both on main.