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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/CHANGELOG.md +24 -0
- data/CLAUDE.md +1 -1
- data/README.md +4 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +6 -0
- data/lib/ruby_reactor/executor/result_handler.rb +16 -0
- data/lib/ruby_reactor/executor/retry_manager.rb +2 -2
- data/lib/ruby_reactor/executor/step_executor.rb +20 -0
- data/lib/ruby_reactor/reactor.rb +72 -22
- data/lib/ruby_reactor/rspec/matchers.rb +15 -16
- data/lib/ruby_reactor/rspec/test_subject.rb +8 -18
- data/lib/ruby_reactor/step/compose_step.rb +10 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/specs/010-interrupt-in-composed-child/checklists/requirements.md +43 -0
- data/specs/010-interrupt-in-composed-child/contracts/api-surface.md +75 -0
- data/specs/010-interrupt-in-composed-child/data-model.md +40 -0
- data/specs/010-interrupt-in-composed-child/plan.md +149 -0
- data/specs/010-interrupt-in-composed-child/quickstart.md +64 -0
- data/specs/010-interrupt-in-composed-child/research.md +207 -0
- data/specs/010-interrupt-in-composed-child/spec.md +267 -0
- data/specs/010-interrupt-in-composed-child/tasks.md +315 -0
- data/specs/future_improvements.md +0 -12
- metadata +9 -1
|
@@ -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.
|
|
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
|