ruby_reactor 0.8.6 → 0.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/CHANGELOG.md +81 -0
  5. data/CLAUDE.md +1 -1
  6. data/README.md +42 -3
  7. data/lib/ruby_reactor/dsl/map_builder.rb +20 -1
  8. data/lib/ruby_reactor/error/recorded_failure.rb +18 -0
  9. data/lib/ruby_reactor/executor/compensation_manager.rb +27 -1
  10. data/lib/ruby_reactor/executor/result_handler.rb +16 -0
  11. data/lib/ruby_reactor/executor/retry_manager.rb +2 -2
  12. data/lib/ruby_reactor/executor/step_executor.rb +20 -0
  13. data/lib/ruby_reactor/executor.rb +62 -10
  14. data/lib/ruby_reactor/interrupt_claims.rb +81 -0
  15. data/lib/ruby_reactor/map/step_rollback.rb +121 -0
  16. data/lib/ruby_reactor/reactor.rb +270 -105
  17. data/lib/ruby_reactor/rspec/matchers.rb +64 -16
  18. data/lib/ruby_reactor/rspec/test_subject.rb +25 -24
  19. data/lib/ruby_reactor/step/compose_step.rb +10 -0
  20. data/lib/ruby_reactor/step/map_step.rb +12 -0
  21. data/lib/ruby_reactor/storage/adapter.rb +17 -0
  22. data/lib/ruby_reactor/storage/redis_adapter.rb +42 -0
  23. data/lib/ruby_reactor/version.rb +1 -1
  24. data/lib/ruby_reactor/web/api.rb +17 -0
  25. data/lib/ruby_reactor/web/public/assets/{index-C6ejszCQ.js → index-DxjQlW7Q.js} +8 -8
  26. data/lib/ruby_reactor/web/public/index.html +1 -1
  27. data/lib/ruby_reactor/worker.rb +73 -6
  28. data/specs/010-interrupt-in-composed-child/checklists/requirements.md +43 -0
  29. data/specs/010-interrupt-in-composed-child/contracts/api-surface.md +75 -0
  30. data/specs/010-interrupt-in-composed-child/data-model.md +40 -0
  31. data/specs/010-interrupt-in-composed-child/plan.md +149 -0
  32. data/specs/010-interrupt-in-composed-child/quickstart.md +64 -0
  33. data/specs/010-interrupt-in-composed-child/research.md +207 -0
  34. data/specs/010-interrupt-in-composed-child/spec.md +267 -0
  35. data/specs/010-interrupt-in-composed-child/tasks.md +315 -0
  36. data/specs/010-rollback-follow-ups/checklists/requirements.md +47 -0
  37. data/specs/010-rollback-follow-ups/contracts/api-surface.md +123 -0
  38. data/specs/010-rollback-follow-ups/contracts/resume-protocol.md +132 -0
  39. data/specs/010-rollback-follow-ups/data-model.md +139 -0
  40. data/specs/010-rollback-follow-ups/plan.md +273 -0
  41. data/specs/010-rollback-follow-ups/pr-description.md +110 -0
  42. data/specs/010-rollback-follow-ups/quickstart.md +157 -0
  43. data/specs/010-rollback-follow-ups/research.md +440 -0
  44. data/specs/010-rollback-follow-ups/spec.md +489 -0
  45. data/specs/010-rollback-follow-ups/tasks.md +1022 -0
  46. data/specs/future_improvements.md +17 -14
  47. metadata +22 -2
@@ -5,7 +5,7 @@
5
5
  <link rel="icon" type="image/svg+xml" href="./vite.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>ui</title>
8
- <script type="module" crossorigin src="./assets/index-C6ejszCQ.js"></script>
8
+ <script type="module" crossorigin src="./assets/index-DxjQlW7Q.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="./assets/index-DM5darhy.css">
10
10
  </head>
11
11
  <body>
@@ -8,7 +8,13 @@ module RubyReactor
8
8
  # `Adapters::ActiveJob::Compat` on ActiveJob::Base) — nothing here references
9
9
  # a specific backend.
10
10
  module Worker
11
- TERMINAL_STATUSES = %w[completed failed cancelled skipped aborted].freeze
11
+ TERMINAL_STATUSES = %w[completed failed cancelled skipped halted aborted].freeze
12
+
13
+ # Seconds a Worker waits for the run's lock before it reads the run (010
14
+ # R-02). The usual holder is the caller that enqueued this job, a few
15
+ # milliseconds from its final save and release; a snooze would cost a whole
16
+ # scheduled-set poll instead. Same bound as `Map::Collector::COLLECT_LOCK_WAIT`.
17
+ CONTEXT_LOCK_WAIT = 2
12
18
 
13
19
  # Use the error's `retry_after_seconds` hint when available
14
20
  # (RateLimit::ExceededError carries the time until the bucket rolls);
@@ -81,16 +87,58 @@ module RubyReactor
81
87
  # Identity-only payload: storage is the source of truth. Rehydrate the live
82
88
  # context from storage by id, then resume. A nil read means the context was
83
89
  # swept, expired, or already terminal-and-collected — nothing to resume.
90
+ #
91
+ # Lock, then load (010 R-02, J-2): the run's `async:` lock is taken BEFORE
92
+ # the read, so this job sees the last save of whoever held it — a caller
93
+ # that just handed off, a manual undo, another worker — and its own later
94
+ # save can never replace newer progress. The executor re-enters the lock.
84
95
  def perform(context_id, reactor_class_name = nil, snooze_count = 0)
85
96
  # Normalize so a nil/omitted name resolves to the same storage key the
86
97
  # enqueue path wrote (always via reactor_storage_name). Without this a
87
98
  # nil here builds "reactor::context:<id>" and misses the stored
88
99
  # "reactor:AnonymousReactor:context:<id>", silently no-op'ing.
89
100
  reactor_class_name ||= RubyReactor.reactor_storage_name(nil)
101
+ lock = lock_run(context_id, reactor_class_name, snooze_count)
102
+ return if lock == :snoozed
103
+
104
+ begin
105
+ perform_locked(context_id, reactor_class_name, snooze_count, lock&.owner)
106
+ ensure
107
+ lock&.release
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ # nil in inline job-testing mode, where a nested re-entry would contend
114
+ # with itself (as `Executor#acquire_context_lock`). Another live holder
115
+ # after the wait: snooze, uncapped, having read and written nothing.
116
+ def lock_run(context_id, reactor_class_name, snooze_count)
117
+ return nil if inline_testing_mode?
118
+
119
+ lock = RubyReactor::Lock.new("async:#{context_id}", owner: SecureRandom.uuid,
120
+ ttl: RubyReactor.configuration.context_lock_ttl,
121
+ wait: CONTEXT_LOCK_WAIT, auto_extend: true)
122
+ lock.acquire
123
+ lock
124
+ rescue RubyReactor::Lock::AcquisitionError => e
125
+ contention = RubyReactor::Lock::ContextLockContention.new(e.message, context_lock_key: "async:#{context_id}")
126
+ handle_snooze(context_id, reactor_class_name, nil, snooze_count, contention)
127
+ :snoozed
128
+ end
129
+
130
+ def inline_testing_mode?
131
+ defined?(::Sidekiq::Testing) && ::Sidekiq::Testing.respond_to?(:inline?) && ::Sidekiq::Testing.inline?
132
+ end
133
+
134
+ def perform_locked(context_id, reactor_class_name, snooze_count, lock_owner)
90
135
  data = RubyReactor.configuration.storage_adapter.retrieve_context(context_id, reactor_class_name)
91
136
  return if data.nil?
92
- # An aborted run is never resumed forward: only a manual undo applies (008 R-08).
93
- return if (data["status"] || data[:status]).to_s == "aborted"
137
+
138
+ status = (data["status"] || data[:status]).to_s
139
+ # Finished, cancelled or aborted: nothing to resume (an aborted run takes
140
+ # only a manual undo, 008 R-08). A stray or duplicate job does nothing.
141
+ return if TERMINAL_STATUSES.include?(status)
94
142
 
95
143
  begin
96
144
  context = ContextSerializer.deserialize_hash(data)
@@ -115,6 +163,10 @@ module RubyReactor
115
163
  return
116
164
  end
117
165
 
166
+ # A paused run advances only with a claimed payload (010 R-06): a stray
167
+ # job must never run the steps that follow the interrupt without one.
168
+ return if status == "paused" && InterruptClaims.unapplied(context).empty?
169
+
118
170
  # Mark that we're executing inline to prevent nested async calls
119
171
  context.inline_async_execution = true
120
172
 
@@ -122,7 +174,8 @@ module RubyReactor
122
174
  # Resume execution from the failed step — or, for a run whose rollback
123
175
  # handed off at a fan-out map, finish that rollback (009 R-04).
124
176
  executor = Executor.new(context.reactor_class, {}, context)
125
- rolling_back = (data["status"] || data[:status]).to_s == "rolling_back"
177
+ executor.context_lock_owner = lock_owner
178
+ rolling_back = status == "rolling_back"
126
179
  rolling_back ? executor.resume_rollback : executor.resume_execution
127
180
  # No explicit save here: resume_execution's ensure block already persists
128
181
  # the final root state (`save_context unless skip_context_persist?`), and
@@ -155,8 +208,6 @@ module RubyReactor
155
208
  end
156
209
  end
157
210
 
158
- private
159
-
160
211
  # If reactor_class_name is provided, use it to get the reactor class.
161
212
  # This handles cases where the class can't be found via const_get.
162
213
  def resolve_reactor_class!(context, reactor_class_name)
@@ -203,6 +254,16 @@ module RubyReactor
203
254
  error.is_a?(RubyReactor::Lock::ContextLockContention) ||
204
255
  error.is_a?(RubyReactor::Error::ExecutionParked))
205
256
 
257
+ # Escalation marks the run `failed` WITHOUT rolling back: harmless before
258
+ # admission, when nothing ran, but a run already admitted (a resume,
259
+ # deferred or `resume: :background`, or a re-entry after a hand-off) has
260
+ # completed steps whose effects would be stranded. It keeps waiting
261
+ # instead, and says so once (010 R-07, J-8).
262
+ if capped && context&.admitted?
263
+ warn_still_waiting(context, snooze_count, error) if max != :infinity && snooze_count == max
264
+ capped = false
265
+ end
266
+
206
267
  if capped && max != :infinity && snooze_count >= max
207
268
  escalate_snooze(context, snooze_count, error)
208
269
  return
@@ -214,6 +275,12 @@ module RubyReactor
214
275
  self.class.perform_in(delay, context_id, reactor_class_name, snooze_count + 1)
215
276
  end
216
277
 
278
+ def warn_still_waiting(context, snooze_count, error)
279
+ fields = { event: "ruby_reactor.resume.waiting", reactor: RubyReactor.reactor_storage_name(context.reactor_class),
280
+ context_id: context.context_id, error: error.message, snooze_count: snooze_count }
281
+ RubyReactor.configuration.logger.warn(fields.map { |k, v| "#{k}=#{v.inspect}" }.join(" "))
282
+ end
283
+
217
284
  # Instance methods delegate to the module functions above — worker
218
285
  # behavior is unchanged, just relocated so other callers (StepWorker,
219
286
  # T036) can reuse the same logic without a Worker instance.
@@ -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.