ruby_reactor 0.7.0 → 0.8.0

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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/demo-app-e2e-verify/SKILL.md +226 -0
  3. data/.claude/skills/speckit-demo-tests/SKILL.md +144 -0
  4. data/.release-please-manifest.json +1 -1
  5. data/.specify/feature.json +1 -1
  6. data/.specify/memory/constitution.md +92 -15
  7. data/.specify/templates/plan-template.md +4 -0
  8. data/.specify/templates/tasks-template.md +8 -1
  9. data/CHANGELOG.md +151 -0
  10. data/CLAUDE.md +2 -2
  11. data/README.md +149 -40
  12. data/lib/ruby_reactor/context.rb +9 -2
  13. data/lib/ruby_reactor/context_serializer.rb +13 -0
  14. data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
  15. data/lib/ruby_reactor/dsl/lockable.rb +2 -2
  16. data/lib/ruby_reactor/dsl/reactor.rb +36 -18
  17. data/lib/ruby_reactor/dsl/step_builder.rb +95 -2
  18. data/lib/ruby_reactor/dsl/template_helpers.rb +13 -5
  19. data/lib/ruby_reactor/dsl/validation_helpers.rb +17 -0
  20. data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
  21. data/lib/ruby_reactor/error/step_failure_error.rb +10 -3
  22. data/lib/ruby_reactor/executor/compensation_manager.rb +58 -45
  23. data/lib/ruby_reactor/executor/ordered_lock_support.rb +9 -9
  24. data/lib/ruby_reactor/executor/result_handler.rb +44 -13
  25. data/lib/ruby_reactor/executor/retry_manager.rb +6 -2
  26. data/lib/ruby_reactor/executor/step_executor.rb +27 -14
  27. data/lib/ruby_reactor/executor.rb +20 -15
  28. data/lib/ruby_reactor/map/element_executor.rb +7 -1
  29. data/lib/ruby_reactor/map/helpers.rb +9 -7
  30. data/lib/ruby_reactor/map/result_enumerator.rb +2 -0
  31. data/lib/ruby_reactor/map/sweeper.rb +1 -1
  32. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
  33. data/lib/ruby_reactor/open_telemetry.rb +7 -4
  34. data/lib/ruby_reactor/ordered_lock.rb +3 -3
  35. data/lib/ruby_reactor/reactor.rb +9 -12
  36. data/lib/ruby_reactor/rspec/matchers.rb +64 -17
  37. data/lib/ruby_reactor/rspec/test_subject.rb +8 -8
  38. data/lib/ruby_reactor/step/async_reactor_step.rb +159 -162
  39. data/lib/ruby_reactor/step/compose_step.rb +56 -75
  40. data/lib/ruby_reactor/step/input_contract.rb +128 -0
  41. data/lib/ruby_reactor/step/map_step.rb +178 -215
  42. data/lib/ruby_reactor/step.rb +119 -18
  43. data/lib/ruby_reactor/step_signals.rb +37 -0
  44. data/lib/ruby_reactor/step_worker.rb +25 -10
  45. data/lib/ruby_reactor/storage/adapter.rb +4 -0
  46. data/lib/ruby_reactor/storage/redis_adapter.rb +1 -72
  47. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
  48. data/lib/ruby_reactor/template/result.rb +9 -2
  49. data/lib/ruby_reactor/utils/fetch_indifferent.rb +13 -0
  50. data/lib/ruby_reactor/version.rb +1 -1
  51. data/lib/ruby_reactor/web/api.rb +23 -6
  52. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +1 -0
  53. data/lib/ruby_reactor/web/public/assets/index-Dw4KV4QY.js +22 -0
  54. data/lib/ruby_reactor/web/public/index.html +2 -2
  55. data/lib/ruby_reactor.rb +61 -9
  56. data/specs/002-step-input-contracts/checklists/requirements.md +49 -0
  57. data/specs/002-step-input-contracts/contracts/dsl-surface.md +193 -0
  58. data/specs/002-step-input-contracts/data-model.md +115 -0
  59. data/specs/002-step-input-contracts/plan.md +165 -0
  60. data/specs/002-step-input-contracts/quickstart.md +170 -0
  61. data/specs/002-step-input-contracts/research.md +233 -0
  62. data/specs/002-step-input-contracts/spec.md +359 -0
  63. data/specs/002-step-input-contracts/tasks.md +367 -0
  64. data/specs/004-inheritable-step-class/checklists/requirements.md +40 -0
  65. data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +85 -0
  66. data/specs/004-inheritable-step-class/data-model.md +116 -0
  67. data/specs/004-inheritable-step-class/plan.md +174 -0
  68. data/specs/004-inheritable-step-class/quickstart.md +112 -0
  69. data/specs/004-inheritable-step-class/research.md +308 -0
  70. data/specs/004-inheritable-step-class/spec.md +316 -0
  71. data/specs/004-inheritable-step-class/tasks.md +258 -0
  72. data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +51 -0
  73. data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +154 -0
  74. data/specs/deferred-003-step-lock-declarations/data-model.md +131 -0
  75. data/specs/deferred-003-step-lock-declarations/plan.md +166 -0
  76. data/specs/deferred-003-step-lock-declarations/quickstart.md +169 -0
  77. data/specs/deferred-003-step-lock-declarations/research.md +196 -0
  78. data/specs/deferred-003-step-lock-declarations/spec.md +447 -0
  79. data/specs/deferred-003-step-lock-declarations/tasks.md +572 -0
  80. data/specs/possible_feature.md +22 -0
  81. metadata +34 -11
  82. data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +0 -1
  83. data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +0 -22
  84. data/specs/001-background-async-steps/checklists/requirements.md +0 -39
  85. data/specs/001-background-async-steps/contracts/public-dsl.md +0 -154
  86. data/specs/001-background-async-steps/data-model.md +0 -117
  87. data/specs/001-background-async-steps/plan.md +0 -168
  88. data/specs/001-background-async-steps/quickstart.md +0 -102
  89. data/specs/001-background-async-steps/research.md +0 -150
  90. data/specs/001-background-async-steps/spec.md +0 -146
  91. data/specs/001-background-async-steps/tasks.md +0 -271
@@ -1,271 +0,0 @@
1
- ---
2
-
3
- description: "Task list for Background Execution & Real Async Steps"
4
- ---
5
-
6
- # Tasks: Background Execution & Real Async Steps
7
-
8
- **Input**: Design documents from `/specs/001-background-async-steps/`
9
-
10
- **Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/public-dsl.md, quickstart.md
11
-
12
- **Tests**: Test tasks ARE included and are written first. Two reasons: the project constitution makes RSpec Red-Green-Refactor non-negotiable (Principle III, against real Redis — no mocked Redis/Sidekiq for integration paths), and the feature request explicitly asked for tests plus `demo_app` examples driven only by the gem's built-in spec helpers.
13
-
14
- **Organization**: Tasks are grouped by user story so each can be implemented, tested, and shipped independently.
15
-
16
- ## Format: `[ID] [P?] [Story] Description`
17
-
18
- - **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks)
19
- - **[Story]**: US1 / US2 / US3 (maps to spec.md user stories)
20
- - Exact file paths included in every task
21
-
22
- ## Path Conventions
23
-
24
- Single-project Ruby gem: `lib/ruby_reactor/**` (implementation), `spec/**` (gem specs), plus the bundled `demo_app/` Rails example, `documentation/`, and `gui/` (dashboard frontend).
25
-
26
- ---
27
-
28
- ## Phase 1: Setup (Shared Infrastructure)
29
-
30
- **Purpose**: Small additions every later phase leans on. No behavior change yet.
31
-
32
- - [X] T001 [P] Add `RubyReactor::Error::AsyncWaitTimeoutError` in `lib/ruby_reactor/error/async_wait_timeout_error.rb` and require it from `lib/ruby_reactor.rb` (raised when an FR-005 wait exceeds its bound)
33
- - [X] T002 [P] Add `RubyReactor::Error::DeprecatedDslError` in `lib/ruby_reactor/error/deprecated_dsl_error.rb` (subclass of `Error::ValidationError` so existing rescues still catch it) and require it from `lib/ruby_reactor.rb`
34
- - [X] T003 [P] Add `Configuration#async_wait_timeout` (memoized reader + `attr_writer`, matching the `context_ttl`/`context_lock_ttl` idiom) with a documented default in `lib/ruby_reactor/configuration.rb`
35
- - [X] T004 [P] Add a shared RSpec context that runs an example group against both backends (Sidekiq fake mode and ActiveJob `:test` adapter) in `spec/support/async_backends.rb`, so every new async spec asserts backend-agnosticism per spec.md Assumptions
36
-
37
- ---
38
-
39
- ## Phase 2: Foundational (Blocking Prerequisites)
40
-
41
- **Purpose**: Remove the old per-step `async` flag and repair everything that reads it, plus build the notified-wait core shared by US2 and US3.
42
-
43
- **⚠️ CRITICAL**: Removing `StepConfig#async?` breaks `Web::API`, `TestSubject`, and existing fixtures at once. This phase must land as one coherent unit before any user story work begins.
44
-
45
- ### Tests (write first, confirm failing)
46
-
47
- - [X] T005 [P] Spec: `async true` inside a `step` block raises `DeprecatedDslError` at class-definition time (not run time), with a message naming `background after:`/`before:`, `async_step`, `async_reactor` — `spec/ruby_reactor/dsl/deprecated_async_flag_spec.rb`
48
- - [X] T006 [P] Spec: `async true` inside a `compose` block raises the same definition-time error, and the `map`-internal `async` option still works untouched — `spec/ruby_reactor/dsl/deprecated_async_flag_spec.rb`
49
- - [X] T007 [P] Spec: `AsyncWaiter` returns immediately when the durable target is already terminal, wakes on a published signal, still resolves when no signal is ever published (fallback re-check), and raises `AsyncWaitTimeoutError` at the bound — `spec/ruby_reactor/async_waiter_spec.rb`
50
-
51
- ### Implementation
52
-
53
- - [X] T008 Remove the `async` DSL method and `@async`/`async?` from `StepBuilder` and `StepConfig`, raising `DeprecatedDslError` from a retained `async` method stub, in `lib/ruby_reactor/dsl/step_builder.rb` (FR-003)
54
- - [X] T009 Remove the `async` DSL method from `ComposeBuilder` (raise `DeprecatedDslError`) and drop the `async:` key from its built step config in `lib/ruby_reactor/dsl/compose_builder.rb` (FR-003; it set the same `StepConfig` flag — see research.md decision 1)
55
- - [X] T010 Add `:async_step_ref` / `:async_reactor_ref` to the documented `composed_contexts` `type:` union (alongside `:composed` and `:map_ref`) in `lib/ruby_reactor/context.rb` — comment-level convention plus any shared constants; no serialization change
56
- - [X] T011 Implement `RubyReactor::AsyncWaiter` in `lib/ruby_reactor/async_waiter.rb`: subscribe-first, then check the durable target, then block on the signal with a coarse fallback re-check, bounded by `Configuration#async_wait_timeout`; takes a channel and a terminal-check callable so US2 and US3 share one core (research.md decision 4)
57
- - [X] T012 Add dedicated-connection subscribe support to `lib/ruby_reactor/storage/redis_adapter.rb` so `subscribe` never blocks the shared client (`SUBSCRIBE` puts a connection into subscriber mode); keep the existing `Storage::Adapter#subscribe`/`#publish` interface signatures intact
58
- - [X] T013 Update `Web::API.determine_step_type` (drop the `config.async?` branch) and `build_structure` (drop the per-step `async:` field) in `lib/ruby_reactor/web/api.rb` so the dashboard survives the flag removal
59
- - [X] T014 Update `TestSubject#prepare_execution_class` (`force_sync` branch) and `#apply_mock_interceptor` to stop mutating the removed `@async` step flag in `lib/ruby_reactor/rspec/test_subject.rb` (research.md decision 6)
60
- - [X] T015 Migrate every existing gem fixture and spec that uses the removed per-step flag to the new DSL across `spec/support/**` and `spec/ruby_reactor/**`, and run `bundle exec rspec` to confirm no other call sites remain
61
-
62
- **Checkpoint**: Old flag is gone, suite is green, notified-wait core exists. User stories can begin.
63
-
64
- ---
65
-
66
- ## Phase 3: User Story 1 - Unambiguous background hand-off (Priority: P1) 🎯 MVP
67
-
68
- **Goal**: Replace the silently-ambiguous per-step `async` flag with a single reactor-level `background` declaration that hands off all remaining steps to a worker, with the cut point nameable from either side (`after:` = named step is last in-process; `before:` = named step is first in the worker).
69
-
70
- **Independent Test**: Define a reactor with `step :first`, `step :second`, `background after: :second`, `step :third`; confirm `:first`/`:second` run in the calling process, `.run` returns an `DispatchResult`, and `:third` runs in the dispatched job. Redeclare the same reactor as `background before: :third` and confirm identical behavior in this linear case. Confirm the deprecated flag raises at definition time.
71
-
72
- ### Tests for User Story 1 (write first, confirm failing)
73
-
74
- - [X] T016 [P] [US1] Fixture reactors for background hand-off in `spec/support/reactors/background_reactors.rb`: linear `after:` and `before:` pairs, a **branching** reactor where the two forms pin different steps, plus the invalid variants (duplicate declaration, unknown step, both keys, neither key, whole-reactor-async conflict, `returns` conflict)
75
- - [X] T017 [P] [US1] Spec: `after:` hand-off boundary — steps up to and including the named step run in the calling process, `.run` returns an `DispatchResult`, remaining steps run in the drained job, and compensation for a worker-side failure behaves exactly as for a same-process failure (US1 scenario 1, SC-001) — `spec/ruby_reactor/dsl/reactor_background_spec.rb`
76
- - [X] T018 [P] [US1] Spec: `before:` hand-off boundary — the named step never executes in the calling process and runs in the worker; the linear `after:`/`before:` pair from T016 produces identical outcomes; the branching fixture shows each form pinning its own named step (US1 scenario 2) — `spec/ruby_reactor/dsl/reactor_background_spec.rb`
77
- - [X] T019 [P] [US1] Spec: definition-time guards — duplicate `background`, an unknown step name via either key, both `after:` and `before:` supplied, neither supplied, and `background` combined with whole-reactor `async true` each raise (US1 scenarios 4-5, FR-002, SC-004) — `spec/ruby_reactor/dsl/reactor_background_spec.rb`
78
- - [X] T020 [P] [US1] Spec: trigger edge behavior — hand-off is keyed to *reaching* the named step, not to lexical position (declaration may sit anywhere in the class body); in a DAG with a parallel branch, steps already ready-and-executed before the trigger ran in the calling process; a named step skipped by a `where`/guard never triggers hand-off and the run completes in-process; and the hand-off never re-triggers inside the worker — `spec/ruby_reactor/dsl/reactor_background_spec.rb`
79
-
80
- ### Implementation for User Story 1
81
-
82
- - [X] T021 [US1] Add the `background(after: nil, before: nil)` class macro in `lib/ruby_reactor/dsl/reactor.rb`, storing a normalized `{ mode: :after|:before, step: }` hand-off point behind one reader, plus its definition-time guards (single declaration, known step, exactly one of the two keys, no whole-reactor-`async` combination) (FR-001, FR-002)
83
- - [X] T022 [US1] Re-key the hand-off trigger in `lib/ruby_reactor/executor/step_executor.rb` from the removed per-step `async?` to the reactor's hand-off point — a **post-execution** check when `mode == :after` (fire once the named step's result is recorded) and a **pre-execution** check when `mode == :before` (fire instead of running the named step, leaving its graph node incomplete for the worker) — reusing the existing `handle_async_step` body (checkpoint-before-enqueue → `async_router.perform_async` → `DispatchResult`) unchanged for both, inside the existing `inline_async_execution` guard (FR-001, research.md decision 1)
84
- - [X] T023 [US1] Expose the hand-off point once per reactor as the normalized `{ mode:, step: }` pair in `Web::API.build_structure` in `lib/ruby_reactor/web/api.rb`, replacing the per-step `async:` field dropped in T013
85
- - [X] T024 [US1] Redefine `TestSubject`'s `async: false` / `run_async(false)` to suppress the `background` hand-off (running the reactor fully in-process) in `lib/ruby_reactor/rspec/test_subject.rb`, preserving the option's existing purpose under the new DSL
86
-
87
- **Checkpoint**: `background after:` and `background before:` both fully work and are independently testable. This is a shippable MVP — the rename/bugfix half of the feature.
88
-
89
- ---
90
-
91
- ## Phase 4: User Story 2 - Async steps that truly run independently (Priority: P2)
92
-
93
- **Goal**: `async_step` dispatches one step's work to its own worker job while the calling process keeps running other ready steps; dependent steps consume the result through `result(:name)`.
94
-
95
- **Independent Test**: Reactor with `async_step :send_email`, then an unrelated `step :do_something_same_thread`, then `step :check_email` reading `result(:send_email)`. Confirm the unrelated step is not blocked and the reader receives the correct deserialized value.
96
-
97
- ### Tests for User Story 2 (write first, confirm failing)
98
-
99
- - [X] T025 [P] [US2] Fixture reactors for async_step (independent sibling, awaited reader, failing async step with and without a reader, never-completing step for the timeout case) in `spec/support/reactors/async_step_reactors.rb`
100
- - [X] T026 [P] [US2] Spec: `store_step_result`/`retrieve_step_result` round-trip against real Redis, including the `dispatched` → `completed` status transition and `context_ttl` expiry — `spec/ruby_reactor/storage/step_result_spec.rb`
101
- - [X] T027 [P] [US2] Spec: dispatch does not block — a sibling step with no dependency on the async step completes while the async job is still queued (US2 scenario 1, SC-002) — `spec/ruby_reactor/dsl/async_step_spec.rb`
102
- - [X] T028 [P] [US2] Spec: read semantics — on `Success` the reader receives the raw deserialized value (same shape a same-process step yields); on `Failure` the reader receives the `Failure` object itself for inspection (US2 scenario 2, FR-006) — `spec/ruby_reactor/dsl/async_step_spec.rb`
103
- - [X] T029 [P] [US2] Spec: compensation is opt-in — a failing async step with no reader leaves the parent uncompensated, while a reader that inspects the failure and returns `Failure` does trigger compensation (US2 scenario 3, FR-011, SC-003) — `spec/ruby_reactor/dsl/async_step_spec.rb`
104
- - [X] T030 [P] [US2] Spec: wait bound and race-freedom — a never-completing async step fails the reader with a timeout rather than hanging (SC-005); a step that completes *before* the reader subscribes is still resolved; a completion with no signal published is still caught by the fallback re-check — `spec/ruby_reactor/dsl/async_step_wait_spec.rb`
105
- - [X] T031 [P] [US2] Spec: an `async_step` declared after a `background` hand-off point still dispatches to its own job rather than degrading to inline execution inside the worker — `spec/ruby_reactor/dsl/async_step_spec.rb`
106
- - [X] T032 [P] [US2] Spec: `returns :async_step_name` raises at class-definition time — `spec/ruby_reactor/dsl/async_step_spec.rb`
107
-
108
- ### Implementation for User Story 2
109
-
110
- - [X] T033 [P] [US2] Add `store_step_result` / `retrieve_step_result` to the `Storage::Adapter` interface in `lib/ruby_reactor/storage/adapter.rb` and implement them in `lib/ruby_reactor/storage/redis_adapter.rb`, modeled on the existing `store_map_result`/`retrieve_map_results` pair and stamping `context_ttl` (FR-006, data-model.md "Step Result Record")
111
- - [X] T034 [P] [US2] Add the `async_step(name, impl = nil, &block)` class macro in `lib/ruby_reactor/dsl/reactor.rb`, building a normal `StepConfig` plus a dispatch-mode marker argument key so every existing step option (`argument`, `run`, `compensate`, `undo`, `retries`, validators) keeps working (FR-004)
112
- - [X] T035 [P] [US2] Implement the framework-agnostic single-step worker body in `lib/ruby_reactor/step_worker.rb`: load the parent context by id, resolve just that step's arguments, run it, write the Step Result Record, then publish the completion signal (write-before-publish ordering)
113
- - [X] T036 [P] [US2] Add the Sidekiq worker binding in `lib/ruby_reactor/adapters/sidekiq/step_worker.rb`, mirroring `adapters/sidekiq/map_element_worker.rb`
114
- - [X] T037 [P] [US2] Add the ActiveJob worker binding in `lib/ruby_reactor/adapters/active_job/step_worker.rb`, mirroring `adapters/active_job/map_element_worker.rb`
115
- - [X] T038 [US2] Add a `perform_step_async` dispatch entry point to both routers (`lib/ruby_reactor/adapters/sidekiq/router.rb`, `lib/ruby_reactor/adapters/active_job/router.rb`) so async-step dispatch stays backend-agnostic
116
- - [X] T039 [US2] Implement async-step dispatch in `lib/ruby_reactor/executor/step_executor.rb` in strict order: write the Step Result Record (`dispatched`) and the `composed_contexts[:name] = { type: :async_step_ref, ... }` reference, then enqueue, then `dependency_graph.complete_step` so unrelated siblings proceed — and deliberately do *not* gate dispatch on `inline_async_execution` (FR-004, FR-008, research.md decision 2)
117
- - [X] T040 [US2] Add the `:async_step_ref` branch to `Template::Result#resolve` in `lib/ruby_reactor/template/result.rb`: when the step has no in-context result but carries an async-step ref, delegate to `AsyncWaiter` against the Step Result Record, injecting the raw value on Success and the `Failure` object on failure (FR-005, FR-010)
118
- - [X] T041 [US2] Add the `returns` × `async_step` definition-time guard in `lib/ruby_reactor/dsl/reactor.rb` (spec Edge Cases)
119
- - [X] T042 [US2] Add the `async_step` step type to `Web::API.determine_step_type` and an `:async_step_ref` resolution branch to `hydrate_composed_contexts` (mirroring `hydrate_map_ref`) in `lib/ruby_reactor/web/api.rb` (FR-014)
120
- - [X] T043 [US2] Add an `#async_step` traversal helper to `lib/ruby_reactor/rspec/test_subject.rb` mirroring `#composed`/`#map`, and make `async: false` run async steps inline
121
-
122
- **Checkpoint**: US1 and US2 both work independently.
123
-
124
- ---
125
-
126
- ## Phase 5: User Story 3 - Fire-and-forget async reactors (Priority: P3)
127
-
128
- **Goal**: `async_reactor` dispatches a whole nested reactor to run independently — linked to the parent for traceability, excluded from its compensation graph, readable on demand via `result(:name)`.
129
-
130
- **Independent Test**: `async_reactor :create_profile` with no reader — forcing the child to fail leaves the parent uncompensated. Separately, `async_reactor :create_account` plus a `step :verify_all` reading `result(:create_account)` — the block sees the child's real outcome and can return `Success` or `Failure`.
131
-
132
- ### Tests for User Story 3 (write first, confirm failing)
133
-
134
- - [X] T044 [P] [US3] Fixture parent/child reactors for async_reactor (fire-and-forget, awaited, same-lock-key collision, invalid-child-inputs, interrupt-paused child) in `spec/support/reactors/async_reactor_reactors.rb`
135
- - [X] T045 [P] [US3] Spec: fire-and-forget isolation — a child failure with no downstream reader never compensates the parent (US3 scenario 1, FR-009, SC-003) — `spec/ruby_reactor/dsl/async_reactor_spec.rb`
136
- - [X] T046 [P] [US3] Spec: awaited outcome — the reader blocks until the child is terminal, receives the child's real `Success`/`Failure` (not the enqueue-time `DispatchResult`), and its explicit `Failure` triggers parent compensation (US3 scenarios 2-3, FR-010) — `spec/ruby_reactor/dsl/async_reactor_spec.rb`
137
- - [X] T047 [P] [US3] Spec: FR-015 deadlock guard — a child declaring the parent's currently-held lock key fails at dispatch with an error naming the key, both reactors, and the three remediations; a single-slot semaphore collides the same way; a *different* key dispatches normally — `spec/ruby_reactor/dsl/async_reactor_locks_spec.rb`
138
- - [X] T048 [P] [US3] Spec: FR-016 pre-enqueue safeguards — invalid child inputs fail the dispatching step in the parent (normal saga handling), and a child declaring `with_ordered_lock` receives its nonce at enqueue — `spec/ruby_reactor/dsl/async_reactor_dispatch_spec.rb`
139
- - [X] T049 [P] [US3] Spec: a child paused at an interrupt is not terminal — the reader waits and times out per FR-005, then resolves after the child is resumed — `spec/ruby_reactor/dsl/async_reactor_spec.rb`
140
- - [X] T050 [P] [US3] Spec: the parent's context carries an `:async_reactor_ref` with the child's `execution_id`, and `returns :async_reactor_name` raises at definition time (FR-008) — `spec/ruby_reactor/dsl/async_reactor_spec.rb`
141
-
142
- ### Implementation for User Story 3
143
-
144
- - [X] T051 [P] [US3] Add the `async_reactor(name, child_reactor_class, &block)` class macro (with `compose`-shaped `argument` mappings) in `lib/ruby_reactor/dsl/reactor.rb`, plus its `returns` guard (FR-007)
145
- - [X] T052 [US3] Implement the dispatch step in `lib/ruby_reactor/step/async_reactor_step.rb`, reusing the full pre-enqueue sequence extracted from `Reactor#run` — child input validation → ordered-lock nonce assignment → persist child context → enqueue — never raw `perform_async`, and registering no `compensate`/`undo` block (FR-007, FR-009, FR-016, research.md decision 3)
146
- - [X] T053 [US3] Implement the FR-015 deadlock guard in the dispatch path: resolve the child's `lock_config[:key_proc]` (and single-slot `semaphore_config`) against the mapped child inputs and fail the dispatch step immediately on collision with a lock the dispatching execution holds, with an error enumerating the three ranked remediations (research.md decision 9)
147
- - [X] T054 [US3] Write the `composed_contexts[:name] = { type: :async_reactor_ref, execution_id:, reactor_class_name:, dispatched_at: }` reference synchronously in the dispatching step (FR-008, data-model.md)
148
- - [X] T055 [US3] Publish the completion signal after the terminal context save in `lib/ruby_reactor/executor.rb`, so an awaited child wakes its parent's waiter (research.md decision 4, data-model.md "Completion Signal")
149
- - [X] T056 [US3] Add the `:async_reactor_ref` branch to `Template::Result#resolve` in `lib/ruby_reactor/template/result.rb`: wait via `AsyncWaiter` against the linked execution's own context row, treating `paused` as non-terminal, and inject the child's result object (FR-005, FR-010)
150
- - [X] T057 [US3] Add the `async_reactor` step type to `Web::API.determine_step_type`, an `:async_reactor_ref` resolution branch to `hydrate_composed_contexts`, and child-graph recursion via `extract_inner_class`/`nested_structure` as `compose`/`map` already do, in `lib/ruby_reactor/web/api.rb` (FR-014)
151
- - [X] T058 [US3] Add an `#async_reactor` traversal helper to `lib/ruby_reactor/rspec/test_subject.rb` mirroring `#composed`, and make `async: false` run async reactors inline
152
-
153
- **Checkpoint**: All three user stories are independently functional.
154
-
155
- ---
156
-
157
- ## Phase 6: Polish & Cross-Cutting Concerns
158
-
159
- **Purpose**: Observability, the dashboard frontend, the demo app, documentation, and release hygiene.
160
-
161
- - [X] T059 Emit structured log entries (reactor name, step name, execution id) for background hand-off, `async_step` dispatch/completion, and `async_reactor` dispatch/completion, following the existing middleware event pattern, across `lib/ruby_reactor/executor/step_executor.rb` and `lib/ruby_reactor/step_worker.rb` (FR-012)
162
- - [X] T060 [P] Render the `async_step` and `async_reactor` step types (icon, color, and for async_reactor a drill-down link to the linked execution) in `gui/src/components/DagVisualizer.tsx` (FR-014)
163
- - [X] T061 [P] Render the new step types and their status/result panels in `gui/src/components/StepInspector.tsx` (FR-014)
164
- - [X] T062 [P] Spec: `Web::API` returns the new step types and hydrates both new ref types, including the async_reactor child's nested structure — `spec/ruby_reactor/web/api_spec.rb`
165
- - [X] T063 Replace `demo_app/app/reactors/partial_async_reactor.rb` with a `background after:`-based example (the old per-step `async true` syntax no longer parses) and rename it to `demo_app/app/reactors/background_demo_reactor.rb`
166
- - [X] T064 [P] Add `demo_app/app/reactors/async_step_demo_reactor.rb` demonstrating `async_step` plus a `result()` reader (the `send_email` example from the spec)
167
- - [X] T065 [P] Add `demo_app/app/reactors/async_reactor_demo_reactor.rb` demonstrating fire-and-forget alongside an awaited child whose outcome the parent inspects
168
- - [X] T066 [P] Add `demo_app/spec/reactors/background_demo_reactor_spec.rb` using only the built-in `test_reactor` helper and matchers
169
- - [X] T067 [P] Add `demo_app/spec/reactors/async_step_demo_reactor_spec.rb` using only the built-in spec helpers
170
- - [X] T068 [P] Add `demo_app/spec/reactors/async_reactor_demo_reactor_spec.rb` using only the built-in spec helpers
171
- - [X] T069 Rewrite the "Step-Level Async" section as `background after:`/`before:` (documenting which step each form pins, and the DAG caveat) and add new `async_step` / `async_reactor` sections (covering the notified wait, the opt-in compensation model, and the FR-015 lock guidance) in `documentation/async_reactors.md`
172
- - [X] T070 [P] Add the `async_reactor` vs `compose` cross-reference (fire-and-forget/uncompensated vs synchronous/compensation-linked, and when a lock collision means you wanted `compose`) in `documentation/composition.md`
173
- - [X] T071 Mirror the T069/T070 edits into the duplicate copies under `demo_app/documentation/` so the two trees do not drift (research.md decision 7)
174
- - [X] T072 Rewrite the "Step-Level Async" subsection and add `async_step`/`async_reactor` coverage in `README.md`
175
- - [X] T073 Add the breaking-change entry with a migration note (per-step and compose `async` → `background after:` / `async_step` / `async_reactor`) under the correct semantic heading in `CHANGELOG.md` (FR-013)
176
- - [X] T074 Run `bundle exec rubocop` (no `--disable-pending-cops`) and fix all offenses across the changed files
177
- - [X] T075 Execute the full `quickstart.md` validation: both backends green, `demo_app` specs green, and the dashboard verified in a browser (do not close FR-014 on passing specs alone)
178
-
179
- ---
180
-
181
- ## Dependencies & Execution Order
182
-
183
- ### Phase Dependencies
184
-
185
- - **Setup (Phase 1)**: No dependencies — start immediately
186
- - **Foundational (Phase 2)**: Needs T001-T004 — **BLOCKS all user stories**
187
- - **User Story 1 (Phase 3)**: Needs Phase 2
188
- - **User Story 2 (Phase 4)**: Needs Phase 2 (specifically `AsyncWaiter` from T011)
189
- - **User Story 3 (Phase 5)**: Needs Phase 2 (specifically `AsyncWaiter` from T011)
190
- - **Polish (Phase 6)**: Needs the user stories whose surface it documents/renders
191
-
192
- ### User Story Dependencies
193
-
194
- - **US1 (P1)**: Independent after Phase 2. Ships alone as the MVP.
195
- - **US2 (P2)**: Independent after Phase 2 — does not require US1. (T031 asserts the US1 interaction only if US1 is present.)
196
- - **US3 (P3)**: Independent after Phase 2 — does not require US1 or US2. Shares only the `AsyncWaiter` core built in Phase 2.
197
-
198
- ### Within Each User Story
199
-
200
- - Tests first, confirmed failing (constitution Principle III)
201
- - Fixtures → storage/adapters → DSL macro → executor dispatch → `Template::Result` wait branch → dashboard/test-helper surface
202
- - Story complete and independently green before moving to the next priority
203
-
204
- ### Parallel Opportunities
205
-
206
- - All of Phase 1 (T001-T004) runs in parallel
207
- - Phase 2 tests T005-T007 run in parallel; then T008/T009 and T010-T012 are separate files
208
- - Every story's test tasks are `[P]` — write the whole story's spec set at once
209
- - In US2: T033 (storage), T034 (DSL), T035-T037 (workers) are independent files
210
- - In US3: T051 (DSL) is independent of the dispatch-step work
211
- - Across teams: after Phase 2, US1, US2, and US3 can be developed simultaneously
212
- - Polish: T060/T061 (GUI), T064/T065 (demo reactors), T066-T068 (demo specs), T070 (docs) all parallelize
213
-
214
- ---
215
-
216
- ## Parallel Example: User Story 2
217
-
218
- ```bash
219
- # Write the whole US2 spec set together (all fail initially):
220
- Task: "Fixture reactors for async_step in spec/support/reactors/async_step_reactors.rb"
221
- Task: "Storage round-trip spec in spec/ruby_reactor/storage/step_result_spec.rb"
222
- Task: "Non-blocking dispatch spec in spec/ruby_reactor/dsl/async_step_spec.rb"
223
- Task: "Wait bound and race-freedom spec in spec/ruby_reactor/dsl/async_step_wait_spec.rb"
224
-
225
- # Then build the independent implementation pieces together:
226
- Task: "Storage primitives in lib/ruby_reactor/storage/adapter.rb + redis_adapter.rb"
227
- Task: "async_step macro in lib/ruby_reactor/dsl/reactor.rb"
228
- Task: "Worker body in lib/ruby_reactor/step_worker.rb"
229
- Task: "Sidekiq binding in lib/ruby_reactor/adapters/sidekiq/step_worker.rb"
230
- Task: "ActiveJob binding in lib/ruby_reactor/adapters/active_job/step_worker.rb"
231
- ```
232
-
233
- ---
234
-
235
- ## Implementation Strategy
236
-
237
- ### MVP First (User Story 1 only)
238
-
239
- 1. Phase 1: Setup (T001-T004)
240
- 2. Phase 2: Foundational (T005-T015) — the breaking removal plus the shared waiter
241
- 3. Phase 3: User Story 1 (T016-T024)
242
- 4. **STOP and VALIDATE**: `background after:` works end to end; deprecated syntax fails loudly at definition time
243
- 5. This alone is a shippable MAJOR release — it fixes the reported footgun without adding new surface
244
-
245
- ### Incremental Delivery
246
-
247
- 1. Setup + Foundational → old flag gone, suite green, waiter available
248
- 2. + US1 → hand-off is unambiguous → ship (MVP)
249
- 3. + US2 → real per-step async work → ship
250
- 4. + US3 → independent nested reactors → ship
251
- 5. + Polish → dashboard, demo app, docs, CHANGELOG → release
252
-
253
- ### Parallel Team Strategy
254
-
255
- With three developers, after Phase 2 lands as one unit:
256
-
257
- - Developer A: US1 (T016-T024), then T063/T066 and the README/docs hand-off sections
258
- - Developer B: US2 (T025-T043), then T064/T067
259
- - Developer C: US3 (T044-T058), then T065/T068
260
- - Whoever finishes first picks up the GUI pair (T060/T061)
261
-
262
- ---
263
-
264
- ## Notes
265
-
266
- - **Phase 2 is a single atomic landing.** Removing `StepConfig#async?` breaks `Web::API`, `TestSubject`, and existing fixtures simultaneously — T008 through T015 should merge together, not incrementally.
267
- - **Real Redis, always.** Constitution Principle III forbids mocked Redis/Sidekiq state for integration and contract tests; `Sidekiq::Testing.inline!` is unit-level only.
268
- - **Both backends, every async spec.** Use the shared context from T004 rather than asserting against Sidekiq alone.
269
- - **Two documentation trees.** `documentation/` and `demo_app/documentation/` are duplicates — T071 exists specifically to prevent drift.
270
- - **The dispatch-time ordering in T039 is load-bearing**: durable record and context ref *before* enqueue (the F2 rule), or a crash between the two leaves a job with no record.
271
- - `[P]` tasks touch different files with no incomplete dependencies; commit after each task or logical group.