ruby_reactor 0.8.5 → 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/.specify/memory/constitution.md +17 -14
- data/CHANGELOG.md +82 -0
- data/CLAUDE.md +1 -1
- data/README.md +27 -8
- data/lib/ruby_reactor/adapters/active_job/map_element_rollback_worker.rb +20 -0
- data/lib/ruby_reactor/adapters/active_job/router.rb +32 -4
- data/lib/ruby_reactor/adapters/sidekiq/map_element_rollback_worker.rb +16 -0
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +32 -4
- data/lib/ruby_reactor/context.rb +14 -1
- data/lib/ruby_reactor/dsl/definition_warnings.rb +30 -0
- data/lib/ruby_reactor/dsl/map_builder.rb +27 -7
- data/lib/ruby_reactor/dsl/step_builder.rb +8 -21
- data/lib/ruby_reactor/error/rescuable.rb +6 -1
- data/lib/ruby_reactor/error/rollback_handed_off.rb +27 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +39 -9
- data/lib/ruby_reactor/executor/result_handler.rb +88 -52
- data/lib/ruby_reactor/executor/retry_manager.rb +6 -5
- data/lib/ruby_reactor/executor/step_executor.rb +23 -1
- data/lib/ruby_reactor/executor.rb +186 -6
- data/lib/ruby_reactor/map/collector.rb +25 -93
- data/lib/ruby_reactor/map/dispatcher.rb +38 -7
- data/lib/ruby_reactor/map/element_executor.rb +14 -10
- data/lib/ruby_reactor/map/element_rollback.rb +158 -0
- data/lib/ruby_reactor/map/helpers.rb +11 -155
- data/lib/ruby_reactor/map/step_rollback.rb +136 -0
- data/lib/ruby_reactor/map/sweeper.rb +66 -8
- data/lib/ruby_reactor/map.rb +17 -0
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +4 -2
- data/lib/ruby_reactor/reactor.rb +113 -24
- data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
- data/lib/ruby_reactor/rspec/matchers.rb +55 -37
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
- data/lib/ruby_reactor/rspec/test_subject.rb +21 -21
- data/lib/ruby_reactor/rspec.rb +3 -6
- data/lib/ruby_reactor/step/compose_step.rb +13 -0
- data/lib/ruby_reactor/step/map_step.rb +117 -114
- data/lib/ruby_reactor/storage/adapter.rb +14 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +18 -2
- data/lib/ruby_reactor/storage/redis_map_rollback.rb +153 -0
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
- data/lib/ruby_reactor/sweeper.rb +3 -1
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +11 -1
- data/lib/ruby_reactor/web/public/assets/{index-CQbgHtd0.js → index-C6ejszCQ.js} +12 -12
- data/lib/ruby_reactor/web/public/assets/index-DM5darhy.css +1 -0
- data/lib/ruby_reactor/web/public/index.html +2 -2
- data/lib/ruby_reactor/worker.rb +4 -2
- data/specs/007-execution-flow-analysis/evidence/harness.rb +2 -0
- data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +4 -1
- data/specs/009-distributed-map-undo/checklists/requirements.md +53 -0
- data/specs/009-distributed-map-undo/contracts/api-surface.md +125 -0
- data/specs/009-distributed-map-undo/contracts/rollback-protocol.md +157 -0
- data/specs/009-distributed-map-undo/data-model.md +151 -0
- data/specs/009-distributed-map-undo/plan.md +323 -0
- data/specs/009-distributed-map-undo/pr-description.md +78 -0
- data/specs/009-distributed-map-undo/quickstart.md +154 -0
- data/specs/009-distributed-map-undo/research.md +585 -0
- data/specs/009-distributed-map-undo/spec.md +360 -0
- data/specs/009-distributed-map-undo/tasks.md +1060 -0
- 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 +9 -33
- metadata +29 -3
- data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +0 -1
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Specification Quality Checklist: Interrupt Inside a Composed Child
|
|
2
|
+
|
|
3
|
+
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
|
4
|
+
**Created**: 2026-10-08
|
|
5
|
+
**Feature**: [spec.md](../spec.md)
|
|
6
|
+
|
|
7
|
+
## Content Quality
|
|
8
|
+
|
|
9
|
+
- [x] No implementation details (languages, frameworks, APIs)
|
|
10
|
+
- [x] Focused on user value and business needs
|
|
11
|
+
- [x] Written for non-technical stakeholders
|
|
12
|
+
- [x] All mandatory sections completed
|
|
13
|
+
|
|
14
|
+
## Requirement Completeness
|
|
15
|
+
|
|
16
|
+
- [x] No [NEEDS CLARIFICATION] markers remain
|
|
17
|
+
- [x] Requirements are testable and unambiguous
|
|
18
|
+
- [x] Success criteria are measurable
|
|
19
|
+
- [x] Success criteria are technology-agnostic (no implementation details)
|
|
20
|
+
- [x] All acceptance scenarios are defined
|
|
21
|
+
- [x] Edge cases are identified
|
|
22
|
+
- [x] Scope is clearly bounded
|
|
23
|
+
- [x] Dependencies and assumptions identified
|
|
24
|
+
|
|
25
|
+
## Feature Readiness
|
|
26
|
+
|
|
27
|
+
- [x] All functional requirements have clear acceptance criteria
|
|
28
|
+
- [x] User scenarios cover primary flows
|
|
29
|
+
- [x] Feature meets measurable outcomes defined in Success Criteria
|
|
30
|
+
- [x] No implementation details leak into specification
|
|
31
|
+
|
|
32
|
+
## Notes
|
|
33
|
+
|
|
34
|
+
- The product is a Ruby library, so its public DSL and API (`compose`, `interrupt`, `continue`,
|
|
35
|
+
`be_paused_at`, `ValidationError`) are the user-facing surface and are named, as in specs
|
|
36
|
+
006–009. Internal classes from the input (`ComposeStep#run`, `handle_execution_result`) stay in
|
|
37
|
+
the Input line and Context table only, as the observed defect; the requirements do not prescribe
|
|
38
|
+
them.
|
|
39
|
+
- Defaults chosen instead of clarification markers (see Assumptions): path given as an array, bare
|
|
40
|
+
leaf name not matched to a nested interrupt, web dashboard display and map-element interrupts out
|
|
41
|
+
of scope.
|
|
42
|
+
- FR-017 is a small adjacent guard: once a compose propagates the pause, a compose inside a map
|
|
43
|
+
element would hand the map an unhandled pause result, so it must fail clearly.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Public API Contract: Interrupt Inside a Composed Child
|
|
2
|
+
|
|
3
|
+
Every addition is backward compatible. Calls that do not use a path behave as before.
|
|
4
|
+
|
|
5
|
+
## `Reactor.run` / `Reactor#run`
|
|
6
|
+
|
|
7
|
+
When a composed child reaches an interrupt, the run returns an `InterruptResult` with:
|
|
8
|
+
|
|
9
|
+
- `paused?` → `true`, `status` → `:paused`;
|
|
10
|
+
- `execution_id` → **the root's** id;
|
|
11
|
+
- `correlation_id` → the child interrupt's correlation id, or `nil`.
|
|
12
|
+
|
|
13
|
+
`Reactor.find(id).result` rebuilds an `InterruptResult` with the root's id, and without
|
|
14
|
+
`correlation_id` (unchanged).
|
|
15
|
+
|
|
16
|
+
## `Reactor.continue(id:, payload:, step_name:)`, `Reactor#continue(payload:, step_name:)`
|
|
17
|
+
|
|
18
|
+
`step_name` takes either form:
|
|
19
|
+
|
|
20
|
+
| `step_name` | Meaning |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `:approve` / `"approve"` | A step of the root (unchanged). |
|
|
23
|
+
| `[:fulfil, :approve]` / `["fulfil", "approve"]` | The interrupt `:approve` inside compose `:fulfil`. |
|
|
24
|
+
| `[:order, :fulfil, :approve]` | Two composes deep. |
|
|
25
|
+
| `[:approve]` | Same as `:approve`. |
|
|
26
|
+
|
|
27
|
+
Errors:
|
|
28
|
+
|
|
29
|
+
- `RubyReactor::Error::ValidationError` when the name or path is not a pending interrupt
|
|
30
|
+
(`"Cannot resume: expected step '<current>' or ready steps <ready_interrupt_steps> but got
|
|
31
|
+
'<name>'"`). Nothing changes and the run stays `paused`. A bare name must now be a pending
|
|
32
|
+
interrupt: naming any other ready step is refused, where it used to be accepted.
|
|
33
|
+
- `RubyReactor::Error::ValidationError` when the reactor loaded is a composed child: "Cannot
|
|
34
|
+
resume: <Class> is a composed child; continue its root run".
|
|
35
|
+
- Every existing error keeps its meaning and is driven by the nested interrupt's own declaration:
|
|
36
|
+
not paused, cancelled, invalid payload (`InputValidationError` from the class method),
|
|
37
|
+
`max_attempts` exhaustion (`Failure`, run rolled back from the root and `failed`), and
|
|
38
|
+
lock/semaphore `AcquisitionError` (run stays paused).
|
|
39
|
+
|
|
40
|
+
`resume: :background` on the nested interrupt enqueues **the root's** worker.
|
|
41
|
+
|
|
42
|
+
## `Reactor.continue_by_correlation_id(correlation_id:, payload:, step_name:)`
|
|
43
|
+
|
|
44
|
+
Called on the **root** class with the path. Called on the child class, it raises the
|
|
45
|
+
composed-child `ValidationError` above.
|
|
46
|
+
|
|
47
|
+
## `Reactor.interrupt_key(step_name)` (new, public)
|
|
48
|
+
|
|
49
|
+
Normalizes a step name the way `continue` reads it. `:a`, `"a"` and `[:a]` all give `:a`;
|
|
50
|
+
`["f", "a"]` gives `[:f, :a]`.
|
|
51
|
+
|
|
52
|
+
## `Reactor#ready_interrupt_steps` (new, public)
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
Order.find(id).ready_interrupt_steps # => [[:fulfil, :approve]]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Returns `[]` unless the run is `paused`. Root-level interrupts come back as Symbols, nested ones as
|
|
59
|
+
Arrays. A root can be paused at a nested interrupt and have one of its own ready at the same
|
|
60
|
+
time, so the list can mix both: `[:audit, [:fulfil, :approve]]`.
|
|
61
|
+
|
|
62
|
+
## RSpec surface (`require "ruby_reactor/rspec"`)
|
|
63
|
+
|
|
64
|
+
| API | Change |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| `subject.ready_interrupt_steps` | Delegates to `Reactor#ready_interrupt_steps`, so it includes paths. |
|
|
67
|
+
| `be_paused_at([:fulfil, :approve])` | Matches a path. `be_paused_at(:a, :b)` still means two root interrupts. Failure messages print entries with `inspect`. |
|
|
68
|
+
| `have_ready_interrupts(:audit, [:fulfil, :approve])` | Exact set, paths included. |
|
|
69
|
+
| `subject.resume(payload:, step: [:fulfil, :approve])` | Resumes a nested interrupt. With one pending interrupt, `step` may be omitted. |
|
|
70
|
+
|
|
71
|
+
## Map elements
|
|
72
|
+
|
|
73
|
+
An `interrupt` reached inside a map element, directly or through a compose, fails that element
|
|
74
|
+
with `Failure("interrupt :<name> is not supported inside a map element")`. Before this feature it
|
|
75
|
+
crashed with `NoMethodError`.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Data Model: Interrupt Inside a Composed Child
|
|
2
|
+
|
|
3
|
+
No new storage keys or records. One new `private_data` flag; everything else is derived from state
|
|
4
|
+
that is already stored.
|
|
5
|
+
|
|
6
|
+
## Root context (stored row, unchanged shape)
|
|
7
|
+
|
|
8
|
+
| Field | While paused inside a child | Notes |
|
|
9
|
+
| --- | --- | --- |
|
|
10
|
+
| `status` | `paused` | Set by `update_context_status` on the root's `InterruptResult`. |
|
|
11
|
+
| `current_step` | the compose step name (e.g. `:fulfil`) | Set by the `ResultHandler` interrupt arm (R-03). |
|
|
12
|
+
| `composed_contexts[:fulfil]` | `{ name:, type: :composed, context: <child> }` | Child embedded as today. |
|
|
13
|
+
| `undo_stack` | ends with the compose step, `result: Success(nil)` | Partial-run entry (R-04). It is replaced, not duplicated, when the compose completes. |
|
|
14
|
+
| `private_data[:interrupt_attempts]` | keyed by `:"fulfil.approve"` (a Symbol) for a nested interrupt | Root-level interrupts keep their own name as the key (R-06). |
|
|
15
|
+
|
|
16
|
+
## Child context (embedded in the root, and its own observability row)
|
|
17
|
+
|
|
18
|
+
| Field | While paused | Notes |
|
|
19
|
+
| --- | --- | --- |
|
|
20
|
+
| `status` | `paused` | As today, set by the child's executor. |
|
|
21
|
+
| `current_step` | the interrupt (e.g. `:approve`), or the next compose one level down | |
|
|
22
|
+
| `intermediate_results[:approve]` | the payload, once `continue` accepted it | Written to the embedded child, never the observability row. |
|
|
23
|
+
| `private_data[:composed]` | `true` | **New.** Set when the compose step creates the child; refuses a direct `continue` (R-07). |
|
|
24
|
+
|
|
25
|
+
## Step path (value object, not stored)
|
|
26
|
+
|
|
27
|
+
`Array` of two or more step names: the compose steps from the root, then the interrupt. It is
|
|
28
|
+
normalized to Symbols on input. A one-element array is the same as the bare name.
|
|
29
|
+
|
|
30
|
+
## State transitions (root)
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
running ──child reaches interrupt──▶ paused
|
|
34
|
+
paused ──continue(path, valid payload)──▶ running ──▶ completed | failed | paused (next interrupt)
|
|
35
|
+
paused ──continue(path, payload, resume: :background)──▶ running (worker) ──▶ …
|
|
36
|
+
paused ──continue(path, invalid payload, attempts exhausted)──▶ undo ──▶ failed
|
|
37
|
+
paused ──undo──▶ cancelled paused ──cancel──▶ cancelled
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The child mirrors `paused → running → completed` inside the root and is never resumed on its own.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# Implementation Plan: Interrupt Inside a Composed Child
|
|
2
|
+
|
|
3
|
+
**Branch**: `interrupt_inside_composed_child` | **Date**: 2026-10-08 | **Spec**: [spec.md](spec.md)
|
|
4
|
+
|
|
5
|
+
**Input**: Feature specification from `specs/010-interrupt-in-composed-child/spec.md`
|
|
6
|
+
|
|
7
|
+
## Summary
|
|
8
|
+
|
|
9
|
+
A composed child already pauses itself correctly. Its parent then crashes on the
|
|
10
|
+
`InterruptResult`. The fix is mostly letting that result through, plus three gaps it exposes:
|
|
11
|
+
|
|
12
|
+
1. **Pass the pause up (US1)**: `InterruptResult` arms in `ComposeStep#handle_execution_result`,
|
|
13
|
+
`RetryManager#handle_retry_result` and `ResultHandler#handle_step_result` (R-01).
|
|
14
|
+
- The compose re-stamps the result with its own context id, so the top level reports the
|
|
15
|
+
root's id (R-02).
|
|
16
|
+
- The result-handler arm sets the root's `current_step` to the compose step, which is the
|
|
17
|
+
resume cursor that `with_step` would otherwise clear (R-03).
|
|
18
|
+
2. **Name and resume (US2)**: a nested interrupt is an Array path. `Reactor#ready_interrupt_steps`
|
|
19
|
+
(new, public) derives the pending paths from stored state (R-05).
|
|
20
|
+
- `Reactor#continue` resolves `(target context, step config, leaf)` once and uses them for
|
|
21
|
+
validation, attempts, background resume and `set_result` (R-06).
|
|
22
|
+
- The resume itself is unchanged: the root re-runs the compose step, and `ComposeStep#run`
|
|
23
|
+
re-enters the admitted child.
|
|
24
|
+
3. **Undo while paused (US3)**: the pausing compose joins the undo stack as a partial-run entry,
|
|
25
|
+
the same entry an aborted compose gets. `add_to_undo_stack` replaces it when the compose later
|
|
26
|
+
completes (R-04).
|
|
27
|
+
4. **Test surface (US4)**: `TestSubject#ready_interrupt_steps` delegates to the reactor, and
|
|
28
|
+
`be_paused_at`/`have_ready_interrupts`/`resume` accept paths.
|
|
29
|
+
5. **Guards**:
|
|
30
|
+
- a composed child refuses a direct `continue` (`private_data[:composed]`, R-07);
|
|
31
|
+
- an interrupt inside a map element fails cleanly instead of `NoMethodError` (R-08).
|
|
32
|
+
|
|
33
|
+
## Technical Context
|
|
34
|
+
|
|
35
|
+
**Language/Version**: Ruby >= 3.0
|
|
36
|
+
|
|
37
|
+
**Primary Dependencies**: Sidekiq and ActiveJob adapters, Redis, dry-validation. No new
|
|
38
|
+
dependencies.
|
|
39
|
+
|
|
40
|
+
**Storage**: Redis through `Storage::RedisAdapter`. No new keys. One new `private_data[:composed]`
|
|
41
|
+
flag on composed child contexts (see [data-model.md](data-model.md)).
|
|
42
|
+
|
|
43
|
+
**Testing**: RSpec against real Redis (`redis://localhost:6780`), `for_each_async_backend` and
|
|
44
|
+
`drain_async_jobs` for the worker paths. Demo specs use the shipped matchers only.
|
|
45
|
+
|
|
46
|
+
**Target Platform**: Any Ruby process with Redis; workers via Sidekiq or ActiveJob.
|
|
47
|
+
|
|
48
|
+
**Project Type**: Library (gem), with `demo_app/` as the integration example.
|
|
49
|
+
|
|
50
|
+
**Performance Goals**: None new. `ready_interrupt_steps` builds one dependency graph per paused
|
|
51
|
+
level, and only on `continue` or in tests.
|
|
52
|
+
|
|
53
|
+
**Constraints**: Single-writer context rule: only the root run is stored and resumed as the
|
|
54
|
+
owner, and a pause is written to the embedded child, never to its observability row.
|
|
55
|
+
|
|
56
|
+
**Scale/Scope**: About 8 lib files touched, each change a few lines, except the `Reactor#continue`
|
|
57
|
+
target resolution. 1 new gem spec file, 1 un-pended example, 2 demo reactors, 1 rake task, 1 demo
|
|
58
|
+
spec, 4 doc files.
|
|
59
|
+
|
|
60
|
+
## Constitution Check
|
|
61
|
+
|
|
62
|
+
*Gate re-checked after Phase 1. No violations.*
|
|
63
|
+
|
|
64
|
+
| Principle | Status | Notes |
|
|
65
|
+
| --- | --- | --- |
|
|
66
|
+
| I. Gem-First | PASS | All in `lib/`; no host coupling. |
|
|
67
|
+
| II. Saga Integrity | PASS | Closes a gap: the "interrupts first-class" clause now holds inside a compose, and undo reaches a paused child's completed steps (R-04). |
|
|
68
|
+
| III. Test-First, Real Infra | PASS | Specs written failing first, on real Redis, across async backends. No `inline!` for orchestration. |
|
|
69
|
+
| IV. Observability | PASS | Paused root status and the embedded child are visible in the dashboard as today. A refused `continue` names the pending path. |
|
|
70
|
+
| V. Simplicity & SemVer | PASS | Pass-through arms; the path is derived, not stored; one public method added. MINOR (R-10), with a CHANGELOG entry. |
|
|
71
|
+
| VI. Demo-App Proof | PASS (planned) | `ManagerApprovalReactor` (child) and `ComposedApprovalReactor` (root) in `demo_app/app/reactors/`; `demo:composed_interrupt`; `demo_app/spec/reactors/composed_approval_reactor_spec.rb` using `be_paused_at([...])`, `resume(step: [...])`, `be_success`, `have_run_step`. Docker acceptance run. |
|
|
72
|
+
|
|
73
|
+
- [x] Documentation impact identified (carried into tasks.md as a required task):
|
|
74
|
+
- **`documentation/composition.md`**: replace "An `interrupt` inside a composed child is not
|
|
75
|
+
supported yet…" (rule 6) with the nested-pause behavior and a pointer to interrupts.md.
|
|
76
|
+
- **`documentation/interrupts.md`**: new section "Interrupts inside composed reactors" covering
|
|
77
|
+
the path form, correlation id on the root class, undo/cancel, a child that refuses a direct
|
|
78
|
+
`continue`, and map elements unsupported.
|
|
79
|
+
- **`README.md`**: one line in the *Interrupts (Pause & Resume)* section linking to it.
|
|
80
|
+
- **`CHANGELOG.md`**: *Unreleased → Features*.
|
|
81
|
+
- **`specs/future_improvements.md`**: drop the "Interrupt inside a composed child" entry.
|
|
82
|
+
|
|
83
|
+
## Project Structure
|
|
84
|
+
|
|
85
|
+
### Documentation (this feature)
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
specs/010-interrupt-in-composed-child/
|
|
89
|
+
├── plan.md
|
|
90
|
+
├── research.md
|
|
91
|
+
├── data-model.md
|
|
92
|
+
├── quickstart.md
|
|
93
|
+
├── contracts/api-surface.md
|
|
94
|
+
├── checklists/requirements.md
|
|
95
|
+
└── tasks.md # /speckit-tasks
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Source Code (repository root)
|
|
99
|
+
|
|
100
|
+
```text
|
|
101
|
+
lib/ruby_reactor/
|
|
102
|
+
├── step/compose_step.rb # R-01/R-02 pass-through + re-stamp; R-07 mark child composed
|
|
103
|
+
├── executor/retry_manager.rb # R-01 InterruptResult arm
|
|
104
|
+
├── executor/result_handler.rb # R-01/R-03/R-04 arm: cursor + partial-run undo entry
|
|
105
|
+
├── executor/compensation_manager.rb # R-04 replace top entry for the same step
|
|
106
|
+
├── executor/step_executor.rb # R-08 interrupt inside a map element → Failure
|
|
107
|
+
├── reactor.rb # R-05 ready_interrupt_steps; R-06 path in continue; R-07 guard
|
|
108
|
+
└── rspec/
|
|
109
|
+
├── test_subject.rb # delegate ready_interrupt_steps; resume(step: path)
|
|
110
|
+
└── matchers.rb # be_paused_at / have_ready_interrupts accept paths
|
|
111
|
+
|
|
112
|
+
spec/
|
|
113
|
+
├── ruby_reactor/interrupt_in_compose_spec.rb # new
|
|
114
|
+
└── map/map_compose_fan_out_spec.rb # un-pend; add a child fixture with an interrupt after the map
|
|
115
|
+
|
|
116
|
+
demo_app/
|
|
117
|
+
├── app/reactors/manager_approval_reactor.rb # child: reserve → interrupt :wait_for_manager → confirm
|
|
118
|
+
├── app/reactors/composed_approval_reactor.rb # root: charge → compose :approval → ship
|
|
119
|
+
├── lib/tasks/demo_reactors.rake # demo:composed_interrupt
|
|
120
|
+
└── spec/reactors/composed_approval_reactor_spec.rb
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**Structure Decision**: Existing gem layout. No new lib files: each change sits where the
|
|
124
|
+
`InterruptResult` is already handled for root-level pauses.
|
|
125
|
+
|
|
126
|
+
## Implementation Order
|
|
127
|
+
|
|
128
|
+
Test-first within each step:
|
|
129
|
+
|
|
130
|
+
1. **US1**: pass-through arms, re-stamp, cursor. Spec: root pauses at depth 1 and 2, sync and
|
|
131
|
+
after a fan-out map (un-pend FR-015).
|
|
132
|
+
2. **US2**: `ready_interrupt_steps`, target resolution in `continue`, composed-child guard. Specs:
|
|
133
|
+
- resume by id and by correlation id;
|
|
134
|
+
- wrong path;
|
|
135
|
+
- background resume;
|
|
136
|
+
- `max_attempts`;
|
|
137
|
+
- two interrupts in sequence;
|
|
138
|
+
- a root interrupt ready alongside.
|
|
139
|
+
3. **US3**: partial-run undo entry and its replacement. Specs: undo while paused (plain child, and
|
|
140
|
+
a child with a fan-out map), cancel then continue, and an undo after completion that undoes the
|
|
141
|
+
compose once.
|
|
142
|
+
4. **US4**: test subject and matchers.
|
|
143
|
+
5. **Guard R-08**: map element interrupt, inline and fan-out.
|
|
144
|
+
6. **Demo** reactors, rake task, demo spec, Docker acceptance run.
|
|
145
|
+
7. **Docs, CHANGELOG, `future_improvements.md`**, then full `rspec` and `rubocop`.
|
|
146
|
+
|
|
147
|
+
## Complexity Tracking
|
|
148
|
+
|
|
149
|
+
No violations to justify.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Quickstart: Validating Interrupt Inside a Composed Child
|
|
2
|
+
|
|
3
|
+
## Prerequisites
|
|
4
|
+
|
|
5
|
+
- Test Redis on `localhost:6780` (`docker start ruby_reactor_redis_test` if the container exists).
|
|
6
|
+
- `bundle install` at the repo root and in `demo_app/`.
|
|
7
|
+
|
|
8
|
+
## 1. Gem specs
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
bundle exec rspec spec/ruby_reactor/interrupt_in_compose_spec.rb # new: US1–US4, edge cases
|
|
12
|
+
bundle exec rspec spec/map/map_compose_fan_out_spec.rb # FR-015: the former `pending` example
|
|
13
|
+
bundle exec rspec spec/ruby_reactor/interrupt_spec.rb spec/ruby_reactor/multiple_interrupts_spec.rb \
|
|
14
|
+
spec/ruby_reactor/interrupt_undo_spec.rb spec/ruby_reactor/interrupt_background_resume_spec.rb \
|
|
15
|
+
spec/integration/interrupt_validation_spec.rb spec/integration/interrupt_max_attempts_spec.rb \
|
|
16
|
+
spec/compose_spec.rb # no regression
|
|
17
|
+
bundle exec rspec && bundle exec rubocop # full gate
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Expected:
|
|
21
|
+
|
|
22
|
+
| Scenario | Outcome |
|
|
23
|
+
| --- | --- |
|
|
24
|
+
| Root composing a child with `c1`, `interrupt :approve`, `c2` | `paused`; `execution_id` is the root's; `c2` and later root steps not run |
|
|
25
|
+
| `Root.continue(id:, payload:, step_name: [:c, :approve])` | `completed`; each step ran once; same result as an uninterrupted run |
|
|
26
|
+
| Two composes deep | `ready_interrupt_steps == [[:c, :c, :approve]]` (path through both composes) |
|
|
27
|
+
| `continue` with `:approve` or a wrong path | `ValidationError` listing `[[:c, :approve]]`; still `paused` |
|
|
28
|
+
| `Child.continue(id: <child id>, …)` | `ValidationError` "is a composed child" |
|
|
29
|
+
| `Root.undo(id)` while paused | `undo:child.c1` before `undo:r1`, each once; `cancelled` |
|
|
30
|
+
| Invalid payload, `max_attempts: 1` | rolled back from the root; `failed` |
|
|
31
|
+
| `resume: :background` on the child interrupt | root's worker runs the remainder; the child is never enqueued |
|
|
32
|
+
| `interrupt` inside a map element (direct or via compose) | element fails with "not supported inside a map element" |
|
|
33
|
+
|
|
34
|
+
## 2. Demo app (Constitution VI)
|
|
35
|
+
|
|
36
|
+
Fast local loop (test Redis DB 5, no Docker):
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
cd demo_app
|
|
40
|
+
REDIS_URL=redis://localhost:6780/5 RAILS_ENV=test bundle exec rspec spec/reactors/composed_approval_reactor_spec.rb
|
|
41
|
+
REDIS_URL=redis://localhost:6780/5 bin/rails demo:composed_interrupt
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The rake task prints the pause (status, root id, pending path `[:approval, :wait_for_manager]`),
|
|
45
|
+
then the outcome of an approved resume (`completed`) and of a rejected one (`undo` → `cancelled`,
|
|
46
|
+
with the child's step undone).
|
|
47
|
+
|
|
48
|
+
Acceptance run (isolated compose project; see the per-worktree note in the repo memory):
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
docker compose -p rr_interrupt_compose -f docker-compose.yml -f <override.yml> up -d --build demo-redis demo-sidekiq
|
|
52
|
+
docker compose -p rr_interrupt_compose -f docker-compose.yml -f <override.yml> \
|
|
53
|
+
run --rm --no-deps demo-app bash -c "bin/rails db:prepare && bin/rails demo:composed_interrupt"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 3. Docs check
|
|
57
|
+
|
|
58
|
+
- `documentation/composition.md`: the "not supported yet" sentence is gone, and the nested pause
|
|
59
|
+
is described.
|
|
60
|
+
- `documentation/interrupts.md`: has a "Interrupts inside composed reactors" section with the path
|
|
61
|
+
form, undo and correlation behavior.
|
|
62
|
+
- `README.md` Interrupts section: one line pointing to it.
|
|
63
|
+
- `CHANGELOG.md` *Unreleased → Features*: the entry is there.
|
|
64
|
+
- `specs/future_improvements.md`: the "Interrupt inside a composed child" entry is removed.
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Research: Interrupt Inside a Composed Child
|
|
2
|
+
|
|
3
|
+
Every decision below was taken by reading the code on `main` (5ca7d1d1). Line references are to
|
|
4
|
+
that commit.
|
|
5
|
+
|
|
6
|
+
## R-01: Where the pause breaks today
|
|
7
|
+
|
|
8
|
+
**Finding**: The child's executor handles its own pause correctly: `handle_interrupt_step` sets the
|
|
9
|
+
child's `current_step`, `update_context_status` marks it `paused`, `handle_interrupt` saves it.
|
|
10
|
+
The crash comes later, in the parent, at three places that do not know `InterruptResult`:
|
|
11
|
+
|
|
12
|
+
1. `ComposeStep#handle_execution_result` calls `result.success?` (`compose_step.rb`, last method).
|
|
13
|
+
2. `RetryManager#handle_retry_result` falls to `else` and turns it into a `Failure("returned
|
|
14
|
+
unexpected result")`.
|
|
15
|
+
3. `ResultHandler#handle_step_result` falls to `handle_unknown_result`, which would record it as a
|
|
16
|
+
completed step.
|
|
17
|
+
|
|
18
|
+
**Decision**: Pass `InterruptResult` through all three. Each gets one explicit arm.
|
|
19
|
+
|
|
20
|
+
**Alternatives rejected**: A parent-side exception to unwind the stack (like
|
|
21
|
+
`Error::RollbackHandedOff`). An interrupt is a normal result, not an error; `execute_all_steps`
|
|
22
|
+
already stops on it (`step_executor.rb:50`), and both `update_context_status` and
|
|
23
|
+
`execute_current_step_and_continue` already list it as terminal.
|
|
24
|
+
|
|
25
|
+
## R-02: The paused result names the root run
|
|
26
|
+
|
|
27
|
+
**Finding**: The child's `InterruptResult` carries the child's `context_id`. A caller would get an
|
|
28
|
+
id that `Root.find` cannot load as the root.
|
|
29
|
+
|
|
30
|
+
**Decision**: `ComposeStep#handle_execution_result` rebuilds the result with `execution_id:
|
|
31
|
+
context.context_id`, the step's own context, keeping `correlation_id`, and passing
|
|
32
|
+
`context.intermediate_results`. Each compose level re-stamps its own id, so at any depth the
|
|
33
|
+
top-level result carries the root's id.
|
|
34
|
+
|
|
35
|
+
The root executor's `handle_interrupt` then stores the correlation mapping against the root id
|
|
36
|
+
and the root class (`executor.rb:1016`) with no change.
|
|
37
|
+
|
|
38
|
+
## R-03: The root's resume cursor
|
|
39
|
+
|
|
40
|
+
**Finding**: `execute_step_sync_without_result_handling` runs the compose body inside
|
|
41
|
+
`Context#with_step`, whose `ensure` restores the previous `current_step` (nil) when the body
|
|
42
|
+
returns. A root-level interrupt avoids this: `handle_interrupt_step` runs outside `with_step` and
|
|
43
|
+
sets `current_step` itself. After a nested pause, the root would have no cursor, and
|
|
44
|
+
`Reactor#continue` refuses a context without `current_step`.
|
|
45
|
+
|
|
46
|
+
**Decision**: The `ResultHandler` arm for `InterruptResult` sets `@context.current_step =
|
|
47
|
+
step_config.name`. On resume, `execute_current_step_and_continue` finds the compose step missing
|
|
48
|
+
from `intermediate_results`, re-runs it, and `ComposeStep#run` → `execute_child_reactor` sees an
|
|
49
|
+
admitted child and calls `resume_execution` on it. The child's own `current_step` is the
|
|
50
|
+
interrupt, whose result is now set, so it carries on (`executor.rb:897`).
|
|
51
|
+
|
|
52
|
+
## R-04: Undo of a run paused inside a child
|
|
53
|
+
|
|
54
|
+
**Finding**: Rollback replays the undo stack (`CompensationManager#rollback_completed_steps`).
|
|
55
|
+
A step joins it only on success (`ResultHandler#handle_success`). The paused compose step never
|
|
56
|
+
succeeded, so `Root.undo` would undo `r1` and leave the child's `c1` done. A root-level interrupt
|
|
57
|
+
has no such gap, because the interrupt step has no side effects.
|
|
58
|
+
|
|
59
|
+
The same gap was already closed for an *interrupted* (aborted) compose:
|
|
60
|
+
`StepExecutor#track_interrupted_construct` pushes the step with `result: Success(nil)` because
|
|
61
|
+
`ComposeStep.undoes_partial_run?` is true, and `ComposeStep#compensate` undoes whatever the child
|
|
62
|
+
completed.
|
|
63
|
+
|
|
64
|
+
**Decision**: The `InterruptResult` arm in `ResultHandler` pushes the same partial-run entry for a
|
|
65
|
+
step whose class `undoes_partial_run?`. When that step later completes,
|
|
66
|
+
`CompensationManager#add_to_undo_stack` replaces a top entry for the same step instead of
|
|
67
|
+
pushing a second one.
|
|
68
|
+
|
|
69
|
+
- The entry is always the top: the root stops at the pause, so nothing runs after it, and on
|
|
70
|
+
resume the compose step is the first to run.
|
|
71
|
+
- Replacing it keeps the undo trace to one `undo:<compose>` per run. Pushing twice would undo the
|
|
72
|
+
compose twice. The second undo would find the child's stack empty, so it would be harmless, but
|
|
73
|
+
still wrong.
|
|
74
|
+
- `max_attempts` exhaustion calls `undo` (`reactor.rb:478`), so it is covered by the same entry.
|
|
75
|
+
|
|
76
|
+
**Alternatives rejected**: Special-casing `Reactor#undo` for a paused run, to compensate the
|
|
77
|
+
current compose step first. That is a second rollback path beside the undo stack, and the
|
|
78
|
+
max-attempts path would need the same code again.
|
|
79
|
+
|
|
80
|
+
## R-05: Naming a nested interrupt
|
|
81
|
+
|
|
82
|
+
**Decision**: A path is an `Array` of two or more names: the compose step names from the root
|
|
83
|
+
down, then the interrupt. Symbols and strings are both accepted, so a JSON body works through the
|
|
84
|
+
web API's `continue` endpoint. A one-element array is the same as the bare name. A bare name
|
|
85
|
+
keeps its current meaning, a step of the root.
|
|
86
|
+
|
|
87
|
+
**Pending paths**: Derived from the stored state; no new field is stored. For a context and its
|
|
88
|
+
reactor class:
|
|
89
|
+
|
|
90
|
+
1. Take the ready steps from the dependency graph. This is the computation
|
|
91
|
+
`validate_continue_step!` and `TestSubject#ready_interrupt_steps` both do today.
|
|
92
|
+
2. Keep the ready interrupt steps, as names.
|
|
93
|
+
3. For a ready compose step whose stored child context (`composed_contexts[name][:context]`) is
|
|
94
|
+
`paused`, recurse into the child with its `reactor_class`, prefixing the compose name.
|
|
95
|
+
|
|
96
|
+
**Decision**: Put this in one public method, `Reactor#ready_interrupt_steps`. It returns Symbols
|
|
97
|
+
for root-level interrupts (unchanged for existing callers) and Arrays for nested ones.
|
|
98
|
+
`TestSubject#ready_interrupt_steps` delegates to it, which removes the duplicated graph code
|
|
99
|
+
there.
|
|
100
|
+
|
|
101
|
+
**Alternatives rejected**:
|
|
102
|
+
- Splat arguments (`be_paused_at(:fulfil, :approve)`): that already means two concurrent root
|
|
103
|
+
interrupts.
|
|
104
|
+
- A dotted string `"fulfil.approve"`: step names may contain dots, and Symbol-vs-String parsing
|
|
105
|
+
would leak into every caller.
|
|
106
|
+
- Matching a unique bare leaf: a root that later adds a step of that name silently changes what an
|
|
107
|
+
old `continue` call resumes.
|
|
108
|
+
|
|
109
|
+
**Implementation notes** (added during `/speckit-implement`):
|
|
110
|
+
|
|
111
|
+
- **Stricter bare names.** `Reactor#continue` now resolves bare names through
|
|
112
|
+
`ready_interrupt_steps` too. The old fast path accepted any `current_step`, and a compose step
|
|
113
|
+
is now a valid `current_step`: `continue(step_name: :fulfil)` would have stored the payload as
|
|
114
|
+
the compose's result and skipped the child. The old wording of the refusal ("expected step 'x'
|
|
115
|
+
or ready steps [...] but got 'y'") is kept, and it lists the pending interrupts.
|
|
116
|
+
- **The paused-at interrupt stays pending.** A resume contended on the root's lock leaves its
|
|
117
|
+
payload stored and the run `paused` (`reopen_paused`). That stored payload marks the interrupt
|
|
118
|
+
complete in the graph. So the interrupt a context's `current_step` names stays pending anyway,
|
|
119
|
+
and the retry is accepted, at any depth (`resume_guard_spec.rb` and the nested contention
|
|
120
|
+
example).
|
|
121
|
+
- **`Reactor.interrupt_key(step_name)`** (public class method) is the single normalizer: a
|
|
122
|
+
one-name path becomes its Symbol, otherwise an Array of Symbols. `continue`, the test subject
|
|
123
|
+
and the matchers use it.
|
|
124
|
+
|
|
125
|
+
## R-06: `Reactor#continue` with a path
|
|
126
|
+
|
|
127
|
+
**Decision**: Resolve the target once, at the top of `continue`: `target_context, step_config,
|
|
128
|
+
leaf = resolve_interrupt_target(step_name)`.
|
|
129
|
+
|
|
130
|
+
- For a bare name it is `@context`, `self.class.steps[name]`, `name`, which is today's behavior.
|
|
131
|
+
- For a path it first checks membership in `ready_interrupt_steps`, raising `ValidationError` that
|
|
132
|
+
lists them. It then walks `composed_contexts` down the path and returns the child context, its
|
|
133
|
+
class's step config and the leaf.
|
|
134
|
+
|
|
135
|
+
The rest of `continue` uses these three instead of `self.class.steps[step_name]` and
|
|
136
|
+
`@context.set_result`:
|
|
137
|
+
|
|
138
|
+
- payload validation;
|
|
139
|
+
- the `max_attempts` counter, keyed by the joined path as a Symbol (`:"fulfil.approve"`) for a
|
|
140
|
+
nested interrupt, so it does not share a counter with a root step of the same name. It has to be
|
|
141
|
+
a Symbol: the serializer symbolizes `private_data` keys, so a String key would reset after every
|
|
142
|
+
reload;
|
|
143
|
+
- `background_resume?`;
|
|
144
|
+
- `set_result`.
|
|
145
|
+
|
|
146
|
+
Status guards, the `running` claim, `save_context` and `resume_execution` stay on the root
|
|
147
|
+
unchanged. So the paused-only guard (008 FR-032), cancel, lock contention (`reopen_paused`) and
|
|
148
|
+
background resume (`enqueue_background_resume` enqueues the root) all apply to a nested resume for
|
|
149
|
+
free.
|
|
150
|
+
|
|
151
|
+
## R-07: A child never resumes on its own
|
|
152
|
+
|
|
153
|
+
**Finding**: The child executor's `save_context` also stores the child under its own id and class
|
|
154
|
+
(the observability row), and its `handle_interrupt` maps the correlation id to that row. So
|
|
155
|
+
`Child.find(child_id)` loads a `paused` child, and `Child.continue(...)` or
|
|
156
|
+
`Child.continue_by_correlation_id(...)` would run it as a standalone run. That breaks the
|
|
157
|
+
single-writer rule: the root's embedded copy and the standalone row would diverge.
|
|
158
|
+
|
|
159
|
+
**Decision**: `ComposeStep#prepare_child_context` marks a new child `private_data[:composed] =
|
|
160
|
+
true`; `private_data` is already serialized. `Reactor#continue` raises `ValidationError` on a
|
|
161
|
+
context carrying that mark (`"Cannot resume: <class> is a composed child; continue its root
|
|
162
|
+
run"`).
|
|
163
|
+
|
|
164
|
+
- This covers resume by id and by correlation id in one check.
|
|
165
|
+
- The child's correlation row stays: the guard makes it harmless, and dropping it would need a
|
|
166
|
+
second check in `handle_interrupt`.
|
|
167
|
+
- No stored child predates the mark and can be paused, since a nested pause crashed before this
|
|
168
|
+
feature.
|
|
169
|
+
|
|
170
|
+
**Alternative rejected**: Guarding on `parent_context_id`. An `async_reactor` child
|
|
171
|
+
(`async_reactor_step.rb:175`) sets it too, and that child *is* an addressable run that pauses and
|
|
172
|
+
resumes on its own.
|
|
173
|
+
|
|
174
|
+
## R-08: Interrupt inside a map element
|
|
175
|
+
|
|
176
|
+
**Finding**: A map element that receives an `InterruptResult` crashes:
|
|
177
|
+
|
|
178
|
+
- inline: `MapStep#execute_inline_map` calls `result.failure?`;
|
|
179
|
+
- fan-out: `ElementExecutor.handle_result` calls `result.halted?`.
|
|
180
|
+
|
|
181
|
+
This happens today for an `interrupt` directly in an element. After this feature, the same crash
|
|
182
|
+
also happens for one reached through a compose inside the element.
|
|
183
|
+
|
|
184
|
+
**Decision**: `StepExecutor#handle_interrupt_step` fails the step ("interrupt :name is not
|
|
185
|
+
supported inside a map element") when the context or any `parent_context` up the chain has
|
|
186
|
+
`map_metadata`. The `Failure` goes through `ResultHandler#handle_step_result`, as an
|
|
187
|
+
argument-resolution failure does, so the element rolls back what it completed before the
|
|
188
|
+
interrupt. The element then fails like any element: an atomic map rolls back, and a
|
|
189
|
+
non-atomic one records the error. One check, at the source, covers both map modes and any compose
|
|
190
|
+
depth inside the element.
|
|
191
|
+
|
|
192
|
+
## R-09: Out of scope
|
|
193
|
+
|
|
194
|
+
- `async_reactor` children are addressable runs of their own and already pause on their own. They
|
|
195
|
+
are unchanged.
|
|
196
|
+
- `reconstruct_paused_result` keeps omitting `correlation_id`, for root-level and nested pauses
|
|
197
|
+
alike (spec FR-003).
|
|
198
|
+
- The dashboard does not render the nested path. It reads the root's status (`paused`) and
|
|
199
|
+
drills into `composed_contexts` as today.
|
|
200
|
+
- Compose steps cannot declare `retries` (008 R-14), so a pause never consumes a retry attempt
|
|
201
|
+
that matters. No decrement is needed.
|
|
202
|
+
|
|
203
|
+
## R-10: SemVer
|
|
204
|
+
|
|
205
|
+
The change is MINOR: a previously crashing case now works, plus one additive public method
|
|
206
|
+
(`Reactor#ready_interrupt_steps`) and an additive argument form (Array `step_name`). Nothing that
|
|
207
|
+
worked changes behavior.
|