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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/.specify/memory/constitution.md +17 -14
  5. data/CHANGELOG.md +82 -0
  6. data/CLAUDE.md +1 -1
  7. data/README.md +27 -8
  8. data/lib/ruby_reactor/adapters/active_job/map_element_rollback_worker.rb +20 -0
  9. data/lib/ruby_reactor/adapters/active_job/router.rb +32 -4
  10. data/lib/ruby_reactor/adapters/sidekiq/map_element_rollback_worker.rb +16 -0
  11. data/lib/ruby_reactor/adapters/sidekiq/router.rb +32 -4
  12. data/lib/ruby_reactor/context.rb +14 -1
  13. data/lib/ruby_reactor/dsl/definition_warnings.rb +30 -0
  14. data/lib/ruby_reactor/dsl/map_builder.rb +27 -7
  15. data/lib/ruby_reactor/dsl/step_builder.rb +8 -21
  16. data/lib/ruby_reactor/error/rescuable.rb +6 -1
  17. data/lib/ruby_reactor/error/rollback_handed_off.rb +27 -0
  18. data/lib/ruby_reactor/executor/compensation_manager.rb +39 -9
  19. data/lib/ruby_reactor/executor/result_handler.rb +88 -52
  20. data/lib/ruby_reactor/executor/retry_manager.rb +6 -5
  21. data/lib/ruby_reactor/executor/step_executor.rb +23 -1
  22. data/lib/ruby_reactor/executor.rb +186 -6
  23. data/lib/ruby_reactor/map/collector.rb +25 -93
  24. data/lib/ruby_reactor/map/dispatcher.rb +38 -7
  25. data/lib/ruby_reactor/map/element_executor.rb +14 -10
  26. data/lib/ruby_reactor/map/element_rollback.rb +158 -0
  27. data/lib/ruby_reactor/map/helpers.rb +11 -155
  28. data/lib/ruby_reactor/map/step_rollback.rb +136 -0
  29. data/lib/ruby_reactor/map/sweeper.rb +66 -8
  30. data/lib/ruby_reactor/map.rb +17 -0
  31. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +4 -2
  32. data/lib/ruby_reactor/reactor.rb +113 -24
  33. data/lib/ruby_reactor/rspec/active_job_helpers.rb +13 -2
  34. data/lib/ruby_reactor/rspec/matchers.rb +55 -37
  35. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +7 -3
  36. data/lib/ruby_reactor/rspec/test_subject.rb +21 -21
  37. data/lib/ruby_reactor/rspec.rb +3 -6
  38. data/lib/ruby_reactor/step/compose_step.rb +13 -0
  39. data/lib/ruby_reactor/step/map_step.rb +117 -114
  40. data/lib/ruby_reactor/storage/adapter.rb +14 -0
  41. data/lib/ruby_reactor/storage/redis_adapter.rb +18 -2
  42. data/lib/ruby_reactor/storage/redis_map_rollback.rb +153 -0
  43. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
  44. data/lib/ruby_reactor/sweeper.rb +3 -1
  45. data/lib/ruby_reactor/version.rb +1 -1
  46. data/lib/ruby_reactor/web/api.rb +11 -1
  47. data/lib/ruby_reactor/web/public/assets/{index-CQbgHtd0.js → index-C6ejszCQ.js} +12 -12
  48. data/lib/ruby_reactor/web/public/assets/index-DM5darhy.css +1 -0
  49. data/lib/ruby_reactor/web/public/index.html +2 -2
  50. data/lib/ruby_reactor/worker.rb +4 -2
  51. data/specs/007-execution-flow-analysis/evidence/harness.rb +2 -0
  52. data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +4 -1
  53. data/specs/009-distributed-map-undo/checklists/requirements.md +53 -0
  54. data/specs/009-distributed-map-undo/contracts/api-surface.md +125 -0
  55. data/specs/009-distributed-map-undo/contracts/rollback-protocol.md +157 -0
  56. data/specs/009-distributed-map-undo/data-model.md +151 -0
  57. data/specs/009-distributed-map-undo/plan.md +323 -0
  58. data/specs/009-distributed-map-undo/pr-description.md +78 -0
  59. data/specs/009-distributed-map-undo/quickstart.md +154 -0
  60. data/specs/009-distributed-map-undo/research.md +585 -0
  61. data/specs/009-distributed-map-undo/spec.md +360 -0
  62. data/specs/009-distributed-map-undo/tasks.md +1060 -0
  63. data/specs/010-interrupt-in-composed-child/checklists/requirements.md +43 -0
  64. data/specs/010-interrupt-in-composed-child/contracts/api-surface.md +75 -0
  65. data/specs/010-interrupt-in-composed-child/data-model.md +40 -0
  66. data/specs/010-interrupt-in-composed-child/plan.md +149 -0
  67. data/specs/010-interrupt-in-composed-child/quickstart.md +64 -0
  68. data/specs/010-interrupt-in-composed-child/research.md +207 -0
  69. data/specs/010-interrupt-in-composed-child/spec.md +267 -0
  70. data/specs/010-interrupt-in-composed-child/tasks.md +315 -0
  71. data/specs/future_improvements.md +9 -33
  72. metadata +29 -3
  73. 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.