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.
@@ -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.
@@ -0,0 +1,315 @@
1
+ ---
2
+ description: "Task list for 010 Interrupt Inside a Composed Child"
3
+ ---
4
+
5
+ # Tasks: Interrupt Inside a Composed Child
6
+
7
+ **Input**: Design documents from `specs/010-interrupt-in-composed-child/`
8
+
9
+ **Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/api-surface.md,
10
+ quickstart.md
11
+
12
+ **Tests**: Required. Constitution III makes tests test-first on real Redis. Each story's tests are
13
+ written and seen failing before its implementation tasks.
14
+
15
+ **Organization**: Grouped by user story (spec.md). R-xx refers to research.md, FR-xxx to spec.md.
16
+
17
+ ## Format: `[ID] [P?] [Story] Description`
18
+
19
+ - **[P]**: can run in parallel (different files, no dependency on an incomplete task)
20
+ - **[Story]**: US1–US4 from spec.md
21
+
22
+ ---
23
+
24
+ ## Phase 1: Setup (Shared Fixtures)
25
+
26
+ **Purpose**: The reactors every story's specs use.
27
+
28
+ - [X] T001 Create `spec/ruby_reactor/interrupt_in_compose_spec.rb`, with its fixtures in `spec/support/interrupt_in_compose_fixtures.rb` (`module InterruptInComposeFixtures`, shared with the helpers spec in T033). The fixtures are built on `RollbackRecorder::Reactor` (`spec/support/rollback_recorder.rb`).
29
+ - `Child` (tag `"child"`): `input :seed, optional: true`; `recording_step :c1`; `interrupt :approve` (`wait_for :c1`, `correlation_id { |ctx| "approve-#{ctx.inputs[:seed]}" }`); `recording_step :c2, after: :approve`. `c2` records the approve payload: `argument :decision, result(:approve)` through the `&extra` block.
30
+ - `Root`: `recording_step :r1`; `compose :fulfil, Child` (`argument :seed, result(:r1)`); `recording_step :r2, after: :fulfil`.
31
+ - `Middle`: like `Root`, composing `Child` at `:fulfil`. `Top`: composes `Middle` at `:order`, then `recording_step :t2`.
32
+ - `TwoInterruptsChild`: `c1`, `interrupt :approve`, `interrupt :sign` (`wait_for :approve`), `c2`; and `TwoInterruptsRoot` composing it.
33
+ - `RootWithOwnInterrupt`: `r1`, `interrupt :audit` (`wait_for :r1`) and `compose :fulfil, Child` both ready after `r1`, then `r2` after both.
34
+ - `BackgroundChild`: `Child` with `interrupt :approve, resume: :background`; `BackgroundRoot` composing it.
35
+ - `ValidatedChild`: `interrupt :approve` with `validate_payload { required(:ok).filled(:bool) }` and `max_attempts: 1`; `ValidatedRetryChild` the same with `max_attempts: 3`; a root composing each.
36
+ - `before { RollbackRecorder.reset! }`, and a `drain` helper as in `spec/map/map_compose_fan_out_spec.rb`.
37
+ - [X] T002 [P] Add to `MapComposeFanOutSpec` in `spec/map/map_compose_fan_out_spec.rb`: a child fixture `InterruptChild` (same as `Child`, plus `interrupt :approve` after `:m`, with `c2` waiting for `:approve`) and `root :InterruptRoot, InterruptChild`.
38
+
39
+ ---
40
+
41
+ ## Phase 2: Foundational
42
+
43
+ None. US1's pass-through is the base the other stories build on, and it is small enough to live in
44
+ US1.
45
+
46
+ ---
47
+
48
+ ## Phase 3: User Story 1 - Pause the root at an interrupt inside a composed child (Priority: P1) 🎯 MVP
49
+
50
+ **Goal**: A root whose composed child reaches an `interrupt` is stored `paused` and returns an
51
+ `InterruptResult` naming the root (FR-001–FR-004).
52
+
53
+ **Independent Test**: `InterruptInComposeSpec::Root.run({})`:
54
+
55
+ - the result is `paused?`, with `execution_id` equal to the root's id;
56
+ - `Root.find(id).context.status == "paused"`;
57
+ - the log is exactly `["run:r1", "run:child.c1"]`, with no undo events.
58
+
59
+ ### Tests for User Story 1 (write first, confirm failing)
60
+
61
+ - [X] T003 [US1] In `spec/ruby_reactor/interrupt_in_compose_spec.rb`, describe "pausing" (US1-AS1). Running `Root`:
62
+ - returns an `InterruptResult`, with `paused?` true and `execution_id == Root.find(result.execution_id).context.context_id`;
63
+ - leaves the stored status `"paused"`, with `current_step` `:fulfil`;
64
+ - leaves the log at `%w[run:r1 run:child.c1]`.
65
+ - [X] T004 [US1] In the same file, assert that the paused result's `correlation_id` is `"approve-r1-value"` (the seed is `r1`'s value), and that `Root.find_by_correlation_id("approve-r1-value").context.context_id` is the root's id (US1-AS2, FR-004).
66
+ - [X] T005 [US1] In the same file, assert that running `Top` stores `Top` as `"paused"`, returns `Top`'s id, and that the log stops after `run:child.c1` (US1-AS4, FR-002).
67
+ - [X] T006 [P] [US1] In `spec/map/map_compose_fan_out_spec.rb`, replace the `pending` example "resumes the root once the child's interrupt after the map is continued" with a real one. Under `for_each_async_backend`:
68
+ - `run(MapComposeFanOutSpec::InterruptRoot)` leaves the root `"paused"`;
69
+ - the root's `composed_contexts[:c][:context].current_step` is `:approve`;
70
+ - no `run:child.c2` was logged.
71
+
72
+ The resume half is added in T024 (US1-AS3, FR-015).
73
+
74
+ ### Implementation for User Story 1
75
+
76
+ - [X] T007 [P] [US1] In `lib/ruby_reactor/step/compose_step.rb`, `handle_execution_result` returns `RubyReactor::InterruptResult.new(execution_id: context.context_id, correlation_id: result.correlation_id, intermediate_results: context.intermediate_results)` when `result.is_a?(RubyReactor::InterruptResult)`. Put it before the `success?` line, with a comment: each compose level re-stamps its own id, so the top-level result names the root (R-02).
77
+ - [X] T008 [P] [US1] In `lib/ruby_reactor/executor/retry_manager.rb`, `handle_retry_result` passes `RubyReactor::InterruptResult` through unchanged. Add it to the `when RetryQueuedResult, RubyReactor::DispatchResult` arm (R-01).
78
+ - [X] T009 [P] [US1] In `lib/ruby_reactor/executor/result_handler.rb`, give `handle_step_result` a `when RubyReactor::InterruptResult` arm, before `else`, calling a new private `handle_interrupted(step_config)`. That method sets `@context.current_step = step_config.name`. Comment: `with_step` cleared it, and it is the root's resume cursor (R-03). Nothing is recorded as a result and the graph node stays incomplete.
79
+ - [X] T010 [US1] Run `bundle exec rspec spec/ruby_reactor/interrupt_in_compose_spec.rb spec/map/map_compose_fan_out_spec.rb spec/compose_spec.rb spec/ruby_reactor/interrupt_spec.rb`. T003–T006 must pass and nothing else may regress.
80
+
81
+ **Checkpoint**: Roots pause at any depth, sync and after a fan-out map.
82
+
83
+ ---
84
+
85
+ ## Phase 4: User Story 2 - Resume the root by naming the nested interrupt (Priority: P1)
86
+
87
+ **Goal**: `continue` and `continue_by_correlation_id` on the root accept an Array path and finish
88
+ the run. Wrong names and direct child resumes are refused (FR-005–FR-011).
89
+
90
+ **Independent Test**: Pause `Root`, then `Root.continue(id:, payload: { ok: true }, step_name:
91
+ [:fulfil, :approve])`. Expect:
92
+
93
+ - status `"completed"`;
94
+ - log `%w[run:r1 run:child.c1 run:child.c2 run:r2]`;
95
+ - `c2` received the payload.
96
+
97
+ ### Tests for User Story 2 (write first, confirm failing)
98
+
99
+ - [X] T011 [US2] In `spec/ruby_reactor/interrupt_in_compose_spec.rb`, describe "resuming" (US2-AS1, FR-008/FR-009):
100
+ - continuing with `[:fulfil, :approve]` completes the root;
101
+ - each step logs `run:` exactly once across pause and resume;
102
+ - `c2`'s stored result reflects the payload;
103
+ - string paths (`["fulfil", "approve"]`) work the same.
104
+ - [X] T012 [US2] In the same file, assert that `Root.continue_by_correlation_id(correlation_id: "approve-r1-value", payload:, step_name: [:fulfil, :approve])` completes the root (US2-AS2).
105
+ - [X] T013 [US2] In the same file, assert each of these raises `RubyReactor::Error::ValidationError` whose message includes `[:fulfil, :approve]`, leaving the status `"paused"` and the log unchanged (US2-AS4, FR-007, FR-011):
106
+ - `step_name: :approve`;
107
+ - `step_name: [:fulfil, :nope]`;
108
+ - `step_name: [:r2, :approve]`;
109
+ - `step_name: :r2`.
110
+ - [X] T014 [US2] In the same file, assert that a direct child resume raises `ValidationError` matching `/composed child/` and runs nothing (FR-005). Cover both:
111
+ - `Child.continue(id: <root>.context.composed_contexts[:fulfil][:context].context_id, …)`;
112
+ - `Child.continue_by_correlation_id(correlation_id: "approve-r1-value", …)`.
113
+ - [X] T015 [US2] In the same file, under `for_each_async_backend`, assert that continuing `BackgroundRoot` (US2-AS3, FR-010):
114
+ - returns a `DispatchResult`;
115
+ - completes after `drain`;
116
+ - calls the async router's `perform_async` with the root's id, and never with the child's id (pattern as in `spec/map/map_compose_fan_out_spec.rb` "signals the root's Worker").
117
+ - [X] T016 [US2] In the same file, assert that continuing the `ValidatedRetryChild` root with `{ ok: "x" }` (FR-010):
118
+ - raises `InputValidationError` from the class method;
119
+ - keeps the run `"paused"`;
120
+ - stores `private_data[:interrupt_attempts]["fulfil.approve"] == 1`;
121
+ - lets a valid payload then complete it.
122
+ - [X] T017 [US2] In the same file, cover multi-interrupt flows:
123
+ - `TwoInterruptsRoot`: continue `[:fulfil, :approve]`, which re-pauses with `ready_interrupt_steps == [[:fulfil, :sign]]`; continue `[:fulfil, :sign]`, which completes (US2-AS6).
124
+ - `RootWithOwnInterrupt`: `ready_interrupt_steps` contains both `:audit` and `[:fulfil, :approve]`; continuing each in turn completes the run (edge case).
125
+ - [X] T018 [US2] In the same file, assert that `Top`'s `ready_interrupt_steps == [[:order, :fulfil, :approve]]`, and that continuing with that path completes `Top` with `run:t2` logged once (FR-006).
126
+ - [X] T019 [US2] In the same file, assert that a `continue` while the resume is executing is refused with the existing "not paused" message (edge case, 008 FR-032). Stub `c2`'s run, via an `&extra` hook, to call `Root.continue` again with the same path, and expect `ValidationError` matching `/running, not paused/`.
127
+
128
+ ### Implementation for User Story 2
129
+
130
+ - [X] T020 [P] [US2] In `lib/ruby_reactor/step/compose_step.rb`, `prepare_child_context` sets `child_context.private_data[:composed] = true` when it builds a new child context (R-07).
131
+ - [X] T021 [US2] In `lib/ruby_reactor/reactor.rb`, add public `ready_interrupt_steps` (R-05):
132
+ - Return `[]` unless `@context.status.to_s == "paused"`.
133
+ - Otherwise return `pending_interrupts(self.class, @context, [])`, a private recursive helper. It builds a `GraphManager` (as `validate_continue_step!` does), takes the ready steps and collects:
134
+ - for each `interrupt?` step, `prefix.empty? ? name : prefix + [name]`;
135
+ - for each ready step whose `composed_contexts[name]` is `type: :composed` with a `paused` `:context`, the recursion into `(child.reactor_class, child, prefix + [name])`.
136
+ - Read hash keys indifferently (symbol or string), since stored `composed_contexts` round-trip through the serializer.
137
+ - [X] T022 [US2] In `lib/ruby_reactor/reactor.rb`, `#continue` (R-06, R-07):
138
+ - **Child guard**: right after the `current_step` guard, raise `Error::ValidationError, "Cannot resume: #{self.class.name} is a composed child; continue its root run"` when `@context.private_data[:composed] || @context.private_data["composed"]`.
139
+ - **Path normalization**: `path = Array(step_name).map(&:to_sym)`. A one-element path is a bare name.
140
+ - **Bare names** keep today's `validate_continue_step!`.
141
+ - **Paths**:
142
+ - raise `Error::ValidationError, "Cannot resume: #{path.inspect} is not a pending interrupt; pending: #{ready_interrupt_steps.inspect}"` unless `ready_interrupt_steps.include?(path)`;
143
+ - then walk `composed_contexts[...][:context]` down `path[0..-2]` to get `target_context`, and take `step_config = target_context.reactor_class.steps[path.last]`.
144
+ - **Callers to update**:
145
+ - `validate_continue_payload` takes `step_config` and an attempts key: `path.join(".")` for a path, the Symbol for a bare name;
146
+ - `set_result` goes to `target_context` with `path.last`;
147
+ - `background_resume?` reads `step_config`.
148
+ - Root status, save and `resume_execution` stay as they are.
149
+ - [X] T023 [US2] Run `bundle exec rspec spec/ruby_reactor/interrupt_in_compose_spec.rb spec/ruby_reactor/interrupt_spec.rb spec/ruby_reactor/multiple_interrupts_spec.rb spec/ruby_reactor/interrupt_background_resume_spec.rb spec/integration/interrupt_validation_spec.rb spec/integration/interrupt_max_attempts_spec.rb`. T011–T019 must pass with no regression.
150
+ - [X] T024 [US2] In `spec/map/map_compose_fan_out_spec.rb`, extend the T006 example: `InterruptRoot.continue(id:, payload: {}, step_name: [:c, :approve])`, `drain`, then expect `"completed"` and `r2` == `"r2-value"` (FR-015).
151
+
152
+ **Checkpoint**: The MVP (US1 + US2) is usable: pause and resume through a compose.
153
+
154
+ ---
155
+
156
+ ## Phase 5: User Story 3 - Undo or cancel a run paused inside a child (Priority: P2)
157
+
158
+ **Goal**: Undo reaches the child's completed steps, cancel blocks resumes, and `max_attempts`
159
+ exhaustion rolls back from the root (FR-012, FR-013, US2-AS5).
160
+
161
+ **Independent Test**: Pause `Root`, then `Root.undo(id)`. The log tail is `%w[undo:child.c1
162
+ undo:r1]`, each once, and the status is `"cancelled"`.
163
+
164
+ ### Tests for User Story 3 (write first, confirm failing)
165
+
166
+ - [X] T025 [US3] In `spec/ruby_reactor/interrupt_in_compose_spec.rb`, describe "undo and cancel":
167
+ - Undoing a paused `Root` logs `undo:child.c1` before `undo:r1`, each exactly once, and ends `"cancelled"` (US3-AS1).
168
+ - Undoing a paused `Top` reaches `undo:child.c1` too.
169
+ - [X] T026 [US3] In the same file, assert that a `Root` paused, resumed to completion, then undone logs each of `undo:child.c2`, `undo:child.c1`, `undo:r2` and `undo:r1` exactly once. This proves the partial-run entry was replaced, not duplicated (R-04).
170
+ - [X] T027 [US3] In the same file, assert that `Root.cancel(id:, reason: "no")` on a paused root makes a later `continue` with `[:fulfil, :approve]` raise `ValidationError` matching `/cancelled/`, with no `run:` logged (US3-AS3, FR-013).
171
+ - [X] T028 [US3] In the same file, assert that continuing the `ValidatedChild` root (`max_attempts: 1`) with `{ ok: "x" }` (US2-AS5, FR-010):
172
+ - returns a `Failure`;
173
+ - logs `undo:child.c1` before `undo:r1`;
174
+ - leaves the stored status `"failed"`.
175
+ - [X] T029 [P] [US3] In `spec/map/map_compose_fan_out_spec.rb`, under "rollback through the root", add an example (US3-AS2). Pause `InterruptRoot`, `RollbackRecorder.reset!`, `InterruptRoot.undo(id)`, `drain`, then expect:
176
+ - `"cancelled"`;
177
+ - `element_undos.size == 8`, each once;
178
+ - `undo:child.c1` then `undo:r1` last.
179
+
180
+ ### Implementation for User Story 3
181
+
182
+ - [X] T030 [US3] In `lib/ruby_reactor/executor/result_handler.rb`, `handle_interrupted` also pushes `{ step: step_config, arguments: step_config.rollback_arguments(resolved_arguments), result: RubyReactor.Success(nil) }` with `@compensation_manager.add_to_undo_stack` when `step_config.undoes_partial_run?`. This is the same entry as `StepExecutor#track_interrupted_construct`. Pass `resolved_arguments` through from `handle_step_result` (R-04).
183
+ - [X] T031 [US3] In `lib/ruby_reactor/executor/compensation_manager.rb`, `add_to_undo_stack` pops the top entry first when it belongs to the same step (`undo_stack.last && undo_stack.last[:step].name == step_info[:step].name`). Add a `ponytail:` comment naming the assumption: a step's entry is on top when it re-runs, because a run stops at a pause (R-04).
184
+ - [X] T032 [US3] Run `bundle exec rspec spec/ruby_reactor/interrupt_in_compose_spec.rb spec/map/map_compose_fan_out_spec.rb spec/ruby_reactor/interrupt_undo_spec.rb spec/ruby_reactor/rollback`. T025–T029 must pass with no regression.
185
+
186
+ **Checkpoint**: Saga integrity holds for nested pauses.
187
+
188
+ ---
189
+
190
+ ## Phase 6: User Story 4 - Test a nested pause with the RSpec helpers (Priority: P3)
191
+
192
+ **Goal**: `be_paused_at`, `have_ready_interrupts`, `ready_interrupt_steps` and `resume(step:)`
193
+ accept paths (FR-014).
194
+
195
+ **Independent Test**: `test_reactor(InterruptInComposeSpec::Root)`. Expect:
196
+
197
+ - `be_paused_at([:fulfil, :approve])` passes;
198
+ - `be_paused_at(:approve)` fails, with a message containing `[:fulfil, :approve]`;
199
+ - `resume(payload: { ok: true })` reaches `be_success`.
200
+
201
+ ### Tests for User Story 4 (write first, confirm failing)
202
+
203
+ - [X] T033 [US4] In `spec/ruby_reactor/rspec/helpers_spec.rb`, add a "nested interrupts" context using `InterruptInComposeSpec::Root` (US4-AS1–AS3):
204
+ - `subject.ready_interrupt_steps == [[:fulfil, :approve]]`;
205
+ - `be_paused_at([:fulfil, :approve])` passes, and `have_ready_interrupts([:fulfil, :approve])` passes;
206
+ - `expect { expect(subject).to be_paused_at(:approve) }.to fail_with(/\[:fulfil, :approve\]/)`;
207
+ - `resume(payload: { ok: true })` with no step, and `resume(step: [:fulfil, :approve], …)`, both end `be_success`.
208
+
209
+ Require the fixture file, or move the `InterruptInComposeSpec` module to `spec/support/interrupt_in_compose_fixtures.rb` if it is needed from two files.
210
+
211
+ ### Implementation for User Story 4
212
+
213
+ - [X] T034 [P] [US4] In `lib/ruby_reactor/rspec/test_subject.rb`:
214
+ - `ready_interrupt_steps` delegates to `@reactor_instance.ready_interrupt_steps`, and the duplicated graph code goes;
215
+ - `determine_resume_step` normalizes `step` (`step.is_a?(Array) ? step.map(&:to_sym) : step.to_sym`) before the `include?` check, and prints `step.inspect` in its error.
216
+ - [X] T035 [P] [US4] In `lib/ruby_reactor/rspec/matchers.rb`:
217
+ - `be_paused_at` and `have_ready_interrupts` normalize each expected name the same way;
218
+ - sorting in `have_ready_interrupts` uses `sort_by(&:inspect)`, because Symbols and Arrays do not compare;
219
+ - failure messages print names with `inspect` instead of `":#{s}"`.
220
+ - [X] T036 [US4] Run `bundle exec rspec spec/ruby_reactor/rspec` and the demo interrupt specs (`demo_app/spec/reactors/form_interrupt_reactor_spec.rb`, `webhook_interrupt_reactor_spec.rb`) per quickstart.md §2. T033 must pass with no regression.
221
+
222
+ **Checkpoint**: All four stories work independently.
223
+
224
+ ---
225
+
226
+ ## Phase 7: Polish & Cross-Cutting Concerns
227
+
228
+ ### Map element guard (FR-017, R-08)
229
+
230
+ - [X] T037 Add specs that an interrupt inside a map element fails that element with a message matching `/not supported inside a map element/`, never `NoMethodError`:
231
+ - in `spec/map/map_compose_fan_out_spec.rb`, an element reactor that composes a child with an `interrupt`, run fan-out under `for_each_async_backend`, with the root ending `"failed"`;
232
+ - in `spec/ruby_reactor/interrupt_in_compose_spec.rb`, the same element run as an inline map.
233
+ - [X] T038 In `lib/ruby_reactor/executor/step_executor.rb`, `handle_interrupt_step` returns `RubyReactor::Failure("interrupt :#{step_config.name} is not supported inside a map element", step_name: step_config.name, reactor_name: @reactor_class.name, retryable: false)` when `@context` or any `parent_context` up the chain has `map_metadata`. Use a small private `inside_map_element?` loop. T037 must pass.
234
+
235
+ ### Demo app (Constitution VI)
236
+
237
+ - [X] T039 [P] Create `demo_app/app/reactors/manager_approval_reactor.rb`: `ManagerApprovalReactor`, with class-based steps.
238
+ - `input :order_id`;
239
+ - `step :reserve_stock`, with an `undo` that prints/records the release;
240
+ - `interrupt :wait_for_manager` (`wait_for :reserve_stock`, `correlation_id { |ctx| "approval-#{ctx.inputs[:order_id]}" }`, `validate_payload { required(:approved).filled(:bool) }`);
241
+ - `step :confirm_reservation`, which fails when `approved` is false.
242
+ - [X] T040 [P] Create `demo_app/app/reactors/composed_approval_reactor.rb`: `ComposedApprovalReactor`, with class-based steps.
243
+ - `input :order_id`;
244
+ - `step :charge_card`, with an `undo` refund;
245
+ - `compose :approval, ManagerApprovalReactor` (`argument :order_id, input(:order_id)`);
246
+ - `step :ship`, after `:approval`.
247
+ - [X] T041 Add `demo:composed_interrupt` to `demo_app/lib/tasks/demo_reactors.rake`, with `desc` and `[:environment, :flush_redis]`. It prints:
248
+ - the run's status, root id and `ready_interrupt_steps`;
249
+ - an approved `continue` by correlation id with `step_name: [:approval, :wait_for_manager]`, and its final status;
250
+ - a second run undone while paused, its status, and that `reserve_stock` and `charge_card` were undone.
251
+ - [X] T042 Create `demo_app/spec/reactors/composed_approval_reactor_spec.rb` (`type: :reactor`), using only the shipped surface:
252
+ - `test_reactor`, `be_paused_at([:approval, :wait_for_manager])`, `have_ready_interrupts`;
253
+ - `resume(step: [:approval, :wait_for_manager], payload: { approved: true })` then `be_success` and `have_run_step(:ship).after(:approval)`;
254
+ - a rejected resume ending `be_failure`.
255
+
256
+ Run it locally per quickstart.md §2.
257
+ - [X] T043 Run the Docker acceptance `docker compose … run --rm --no-deps demo-app bash -c "bin/rails db:prepare && bin/rails demo:composed_interrupt"` with an isolated compose project (quickstart.md §2). Record the output in the PR description.
258
+
259
+ ### Documentation (Constitution Development Workflow)
260
+
261
+ - [X] T044 [P] Update `documentation/composition.md` rule 6: replace "An `interrupt` inside a composed child is not supported yet, with or without a map." with the nested pause behavior, linking to interrupts.md: the root pauses, is resumed by path, and undo reaches the child.
262
+ - [X] T045 [P] Add "Interrupts inside composed reactors" to `documentation/interrupts.md`, after "Resuming Execution". Use class-based examples. Cover:
263
+ - the path form, why it is an Array, and `ready_interrupt_steps`;
264
+ - correlation id on the root class;
265
+ - background resume running in the root's worker;
266
+ - undo and cancel;
267
+ - the composed-child guard;
268
+ - interrupts inside map elements being unsupported.
269
+ - [X] T046 [P] Add one line in `README.md` § *Interrupts (Pause & Resume)* noting that interrupts work inside `compose`d children, resumed by path, with a link to the new interrupts.md section.
270
+ - [X] T047 [P] Add a *Features* entry under *Unreleased* in `CHANGELOG.md`: interrupts inside composed children; Array `step_name` paths; `Reactor#ready_interrupt_steps`; matcher path support; and the map-element interrupt now failing clearly.
271
+ - [X] T048 [P] Remove the "Interrupt inside a composed child" section from `specs/future_improvements.md`.
272
+
273
+ ### Gate
274
+
275
+ - [X] T049 Run `bundle exec rspec` and `bundle exec rubocop` at the repo root, and the full `demo_app` spec suite per quickstart.md §2. All must be green. If an async example flakes, rerun it alone first (shared test Redis).
276
+
277
+ ---
278
+
279
+ ## Dependencies & Execution Order
280
+
281
+ - **Setup (T001–T002)** comes first.
282
+ - **US1 (T003–T010)** depends on Setup, and blocks US2, US3 and US4: nothing can be resumed, undone or matched until the root pauses.
283
+ - **US2 (T011–T024)** depends on US1.
284
+ - **US3 (T025–T032)** depends on US1. T028 (`max_attempts` exhaustion) also needs US2's T022 to resolve the nested step config, so implement it after T022.
285
+ - **US4 (T033–T036)** depends on US2's T021 (`Reactor#ready_interrupt_steps`).
286
+ - **Polish**:
287
+ - the map guard (T037–T038) depends on US1 only;
288
+ - the demo (T039–T043) depends on US1–US4;
289
+ - the docs (T044–T048) can be drafted after US2 and finalized last;
290
+ - T049 runs last.
291
+
292
+ Within each story, the tests are written and seen failing before the implementation tasks.
293
+
294
+ ## Parallel Opportunities
295
+
296
+ - T002 can run alongside T001.
297
+ - In US1, T007, T008 and T009 touch three different files.
298
+ - In US2, T020 can run alongside T021/T022 (different file). T021 and T022 share `reactor.rb`, so they run one after the other.
299
+ - In US3, T029 is in a different spec file from T025–T028.
300
+ - In US4, T034 and T035 touch different files.
301
+ - In Polish, T039 and T040 run in parallel, as do T044–T048.
302
+
303
+ ```text
304
+ # US1 implementation, after T003–T006 fail:
305
+ T007 compose_step.rb | T008 retry_manager.rb | T009 result_handler.rb
306
+ ```
307
+
308
+ ## Implementation Strategy
309
+
310
+ 1. **MVP = US1 + US2** (both P1). Pausing alone is a dead end: ship them together. Stop after T024
311
+ and validate with quickstart.md §1, rows 1–6.
312
+ 2. **US3** next. Undo while paused is a saga guarantee (Constitution II), so it must land before
313
+ release, not as a follow-up.
314
+ 3. **US4** after that: test-surface convenience.
315
+ 4. **Polish**: map guard, demo with the Docker run, docs, then the full gate.
@@ -222,18 +222,6 @@ All against real Redis, per the constitution.
222
222
  - Docs: `locks_and_semaphores.md`, `background_and_async.md`, and the README's "Durability &
223
223
  Recovery" section.
224
224
 
225
- ## Interrupt inside a composed child
226
-
227
- **Status:** unsupported on main, found while implementing 009 (US2-AS2). An `interrupt` in a
228
- `compose`d child does not pause the root: `ComposeStep#handle_execution_result` calls `success?` on
229
- the child's `InterruptResult`, and the compose step fails with `NoMethodError`. It fails the same
230
- way with or without a fan-out map in the child.
231
-
232
- Direction: propagate the child's `InterruptResult` as the compose step's result, so the root
233
- pauses; let `Reactor.continue` and the `be_paused_at` matcher name the nested interrupt (a step
234
- path such as `:fulfil, :approve`); and resume through `ComposeStep#run`, which already re-enters
235
- an admitted child. Spec: `spec/map/map_compose_fan_out_spec.rb` keeps a `pending` example for it.
236
-
237
225
  ## Rollback follow-ups (008)
238
226
 
239
227
  Raised while implementing specs/008-rollback-reliability. None blocks it.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_reactor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.6
4
+ version: 0.8.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Artur
@@ -330,6 +330,14 @@ files:
330
330
  - specs/009-distributed-map-undo/research.md
331
331
  - specs/009-distributed-map-undo/spec.md
332
332
  - specs/009-distributed-map-undo/tasks.md
333
+ - specs/010-interrupt-in-composed-child/checklists/requirements.md
334
+ - specs/010-interrupt-in-composed-child/contracts/api-surface.md
335
+ - specs/010-interrupt-in-composed-child/data-model.md
336
+ - specs/010-interrupt-in-composed-child/plan.md
337
+ - specs/010-interrupt-in-composed-child/quickstart.md
338
+ - specs/010-interrupt-in-composed-child/research.md
339
+ - specs/010-interrupt-in-composed-child/spec.md
340
+ - specs/010-interrupt-in-composed-child/tasks.md
333
341
  - specs/future_improvements.md
334
342
  - specs/scoped_rspec_matchers.md
335
343
  - specs/specs-inputs-by-method-md-piped-wigderson.md