ruby_reactor 0.8.2 → 0.8.3

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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/speckit-review/SKILL.md +324 -0
  3. data/.release-please-manifest.json +1 -1
  4. data/.specify/extensions.yml +10 -0
  5. data/.specify/feature.json +1 -1
  6. data/.specify/workflows/speckit/workflow.yml +13 -1
  7. data/.specify/workflows/workflow-registry.json +2 -2
  8. data/CHANGELOG.md +82 -0
  9. data/CLAUDE.md +2 -2
  10. data/README.md +35 -2
  11. data/lib/ruby_reactor/adapters/active_job/router.rb +19 -0
  12. data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -0
  13. data/lib/ruby_reactor/context.rb +26 -0
  14. data/lib/ruby_reactor/context_serializer.rb +4 -2
  15. data/lib/ruby_reactor/dsl/interrupt_builder.rb +14 -0
  16. data/lib/ruby_reactor/dsl/lockable.rb +76 -21
  17. data/lib/ruby_reactor/dsl/step_builder.rb +112 -1
  18. data/lib/ruby_reactor/error/async_result_pending.rb +1 -1
  19. data/lib/ruby_reactor/error/execution_parked.rb +16 -0
  20. data/lib/ruby_reactor/error/reactor_contention_park.rb +26 -0
  21. data/lib/ruby_reactor/error/step_contention_park.rb +26 -0
  22. data/lib/ruby_reactor/executor/async_step_dispatch.rb +109 -3
  23. data/lib/ruby_reactor/executor/compensation_manager.rb +99 -17
  24. data/lib/ruby_reactor/executor/ordered_lock_support.rb +76 -44
  25. data/lib/ruby_reactor/executor/result_handler.rb +31 -11
  26. data/lib/ruby_reactor/executor/retry_manager.rb +9 -1
  27. data/lib/ruby_reactor/executor/step_coordination.rb +788 -0
  28. data/lib/ruby_reactor/executor/step_executor.rb +115 -11
  29. data/lib/ruby_reactor/executor.rb +90 -20
  30. data/lib/ruby_reactor/map/element_executor.rb +24 -2
  31. data/lib/ruby_reactor/map/helpers.rb +35 -11
  32. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +2 -2
  33. data/lib/ruby_reactor/open_telemetry.rb +61 -24
  34. data/lib/ruby_reactor/retry_context.rb +31 -2
  35. data/lib/ruby_reactor/rspec/helpers.rb +15 -0
  36. data/lib/ruby_reactor/rspec/matchers.rb +92 -0
  37. data/lib/ruby_reactor/rspec/test_subject.rb +7 -1
  38. data/lib/ruby_reactor/step/async_reactor_step.rb +40 -24
  39. data/lib/ruby_reactor/step/compose_step.rb +14 -3
  40. data/lib/ruby_reactor/step.rb +49 -7
  41. data/lib/ruby_reactor/step_sweeper.rb +29 -1
  42. data/lib/ruby_reactor/step_worker.rb +260 -37
  43. data/lib/ruby_reactor/version.rb +1 -1
  44. data/lib/ruby_reactor/web/api.rb +72 -7
  45. data/lib/ruby_reactor/web/coordination_serializer.rb +120 -2
  46. data/lib/ruby_reactor/web/public/assets/{index-Dw4KV4QY.js → index-CeZU-ESu.js} +9 -9
  47. data/lib/ruby_reactor/web/public/index.html +1 -1
  48. data/lib/ruby_reactor/worker.rb +56 -30
  49. data/lib/ruby_reactor.rb +27 -5
  50. data/specs/future_improvements.md +250 -0
  51. metadata +8 -28
  52. data/specs/002-step-input-contracts/checklists/requirements.md +0 -49
  53. data/specs/002-step-input-contracts/contracts/dsl-surface.md +0 -193
  54. data/specs/002-step-input-contracts/data-model.md +0 -115
  55. data/specs/002-step-input-contracts/plan.md +0 -165
  56. data/specs/002-step-input-contracts/quickstart.md +0 -170
  57. data/specs/002-step-input-contracts/research.md +0 -233
  58. data/specs/002-step-input-contracts/spec.md +0 -359
  59. data/specs/002-step-input-contracts/tasks.md +0 -367
  60. data/specs/004-inheritable-step-class/checklists/requirements.md +0 -40
  61. data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +0 -85
  62. data/specs/004-inheritable-step-class/data-model.md +0 -116
  63. data/specs/004-inheritable-step-class/plan.md +0 -174
  64. data/specs/004-inheritable-step-class/quickstart.md +0 -112
  65. data/specs/004-inheritable-step-class/research.md +0 -308
  66. data/specs/004-inheritable-step-class/spec.md +0 -316
  67. data/specs/004-inheritable-step-class/tasks.md +0 -258
  68. data/specs/active_job.md +0 -259
  69. data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +0 -51
  70. data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +0 -154
  71. data/specs/deferred-003-step-lock-declarations/data-model.md +0 -131
  72. data/specs/deferred-003-step-lock-declarations/plan.md +0 -166
  73. data/specs/deferred-003-step-lock-declarations/quickstart.md +0 -169
  74. data/specs/deferred-003-step-lock-declarations/research.md +0 -196
  75. data/specs/deferred-003-step-lock-declarations/spec.md +0 -447
  76. data/specs/deferred-003-step-lock-declarations/tasks.md +0 -572
  77. data/specs/possible_feature.md +0 -22
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 31b3ac286396e1d29f1bad40e87f4f55b8ecc3e6c715e6cfd2ca0496d9c8dd0c
4
- data.tar.gz: d518e5aceb0e5fa6dd43007e1f324612c84a53528e73a76463db7ecb652f0a95
3
+ metadata.gz: ac772fc307621dcf2fba69d726aeb670a34201a0c06e17e1e9213b4779f4cd03
4
+ data.tar.gz: 5ed6d5e4251643fc16fafaf48ecde75aa31f95cc09f9b1fe9d0b879b190fa61b
5
5
  SHA512:
6
- metadata.gz: 2e91db7e2c548b834f285fe514747bc407b29fbb4cdb10e4aef44b05ff556d518ccf77ad4d02fc3e097907d7298843bc32b7e5a57d4f4a8ae687795dc702fa71
7
- data.tar.gz: 6a2252e7fdf4740f1d297f2855bb60f07c4b66e0a551c8d90e4394fd1acafd829098323d40afb4f9bc650c76a53818d76566c70f65c89c4b60ed11f9018a3d48
6
+ metadata.gz: c61a1ec89ce310154e5bd9d2b09c3dc997e682a83d1bb5b18a87d47128f792133e27e7c8648db699cbfa5de8830522dad9bdfbb7b953addf89273fa3eb3042e0
7
+ data.tar.gz: b78511087cfccec3eb6912000c3d744fa0f1cabca1f87eed9a500d223eb2aa4e6d1e4219a85eb2c6774f54e14493179d3c86ba836e4012cf9a86ec3a553955c1
@@ -0,0 +1,324 @@
1
+ ---
2
+ name: "speckit-review"
3
+ description: "Review the code changes produced by /speckit-implement against RubyReactor's runtime invariants: saga unwind/compensation/undo, retry semantics, async isolation, lock safety without deadlock, validation ordering, and the behavioral claims made in README/documentation."
4
+ argument-hint: "Optional: review scope (e.g. 'locks only', a path, or a git ref to diff against)"
5
+ compatibility: "Requires spec-kit project structure with .specify/ directory and a git repository"
6
+ metadata:
7
+ author: "project"
8
+ user-invocable: true
9
+ disable-model-invocation: false
10
+ ---
11
+
12
+ ## User Input
13
+
14
+ ```text
15
+ $ARGUMENTS
16
+ ```
17
+
18
+ You **MUST** consider the user input before proceeding (if not empty). Treat it as a scope
19
+ narrowing hint only — it MAY reduce which files are inspected, it MUST NOT disable any gate
20
+ below for files that remain in scope.
21
+
22
+ ## Pre-Execution Checks
23
+
24
+ **Check for extension hooks (before review)**:
25
+
26
+ - Check if `.specify/extensions.yml` exists in the project root.
27
+ - If it exists, read it and look for entries under the `hooks.before_review` key
28
+ - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
29
+ - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
30
+ - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
31
+ - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
32
+ - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
33
+ - When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
34
+ - For each executable hook, output the following based on its `optional` flag:
35
+ - **Optional hook** (`optional: true`):
36
+
37
+ ```text
38
+ ## Extension Hooks
39
+
40
+ **Optional Pre-Hook**: {extension}
41
+ Command: `/{command}`
42
+ Description: {description}
43
+
44
+ Prompt: {prompt}
45
+ To execute: `/{command}`
46
+ ```
47
+
48
+ - **Mandatory hook** (`optional: false`):
49
+
50
+ ```text
51
+ ## Extension Hooks
52
+
53
+ **Automatic Pre-Hook**: {extension}
54
+ Executing: `/{command}`
55
+ EXECUTE_COMMAND: {command}
56
+
57
+ Wait for the result of the hook command before proceeding to the Goal.
58
+ ```
59
+
60
+ - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
61
+
62
+ ## Goal
63
+
64
+ Review the **code changes** made for the current feature (normally right after
65
+ `/speckit-implement`) and decide whether they are safe to merge. The review answers one
66
+ question per gate, with file/line evidence, and ends in a single verdict: `PASS`,
67
+ `PASS WITH FINDINGS`, or `BLOCKED`.
68
+
69
+ This command is **read-only**. It MUST NOT edit source, specs, docs, `tasks.md`, or git
70
+ state. Remediation is handed off to `/speckit-converge` (append tasks) or
71
+ `/speckit-implement` (fix), not performed here.
72
+
73
+ It differs from its neighbours:
74
+
75
+ - `/speckit-analyze` → consistency **between artifacts** (spec/plan/tasks).
76
+ - `/speckit-converge` → what the artifacts demand that the code does **not yet do**.
77
+ - `/speckit-review` (this one) → whether the code that now exists is **correct, safe, and
78
+ honest** at runtime — saga unwind, retries, async isolation, locks, validation, and the
79
+ claims the docs make about all of it. Constitution/process compliance is out of scope
80
+ here; `/speckit-plan` and `/speckit-converge` already cover it.
81
+
82
+ ## Execution Steps
83
+
84
+ ### 1. Establish Review Scope
85
+
86
+ Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks`
87
+ from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive:
88
+
89
+ - SPEC = FEATURE_DIR/spec.md, PLAN = FEATURE_DIR/plan.md, TASKS = FEATURE_DIR/tasks.md
90
+
91
+ These are context for *what the change was meant to do* — the gates below judge the code,
92
+ not the artifacts.
93
+
94
+ If the prerequisites script fails (no active feature), do **not** stop: fall back to a
95
+ pure diff review and say so in the report header — none of the gates below require spec
96
+ artifacts.
97
+
98
+ Compute the change set:
99
+
100
+ ```bash
101
+ git rev-parse --abbrev-ref HEAD
102
+ git merge-base HEAD main
103
+ git diff --stat $(git merge-base HEAD main)...HEAD
104
+ git diff $(git merge-base HEAD main)...HEAD
105
+ git status --porcelain # uncommitted work is in scope too
106
+ ```
107
+
108
+ If the user supplied a git ref in `$ARGUMENTS`, diff against that instead of `main`.
109
+ Read the **full current content** of every non-trivially changed file under `lib/` —
110
+ a diff hunk alone is not enough to reason about unwind order or lock release paths.
111
+
112
+ Load `README.md` and the files under `./documentation/` that cover the touched areas
113
+ (`DAG.md`, `background_and_async.md`, `locks_and_semaphores.md`, `retry_configuration.md`,
114
+ `interrupts.md`, `composition.md`, `core_concepts.md`, `testing.md`).
115
+
116
+ ### 2. Run the Gates
117
+
118
+ Every gate produces: `PASS`, `FINDING`, or `BLOCKER`, each with concrete evidence
119
+ (`path/to/file.rb:LINE`). A gate with no changed code in its area is `N/A` — say so
120
+ explicitly rather than silently passing it.
121
+
122
+ #### G1 — Distributed Rules and Documented Claims Hold
123
+
124
+ The gem is consumed as a distributed coordinator; the docs are a contract.
125
+
126
+ - Every behavioral claim in `README.md` and `./documentation/` that touches changed code is
127
+ still literally true. Quote the claim, then cite the code that satisfies or breaks it.
128
+ A claim the code no longer honors is a **BLOCKER** (fix the code or fix the claim —
129
+ the report says which, it does not do either).
130
+ - Multi-process correctness: no state that must be shared lives in a process-local ivar,
131
+ class variable, or memoized constant where a second worker process would miss it;
132
+ Redis keys carry the discriminators (reactor/step/run/lock identity) needed to avoid
133
+ cross-run or cross-worker collision; TTLs exist wherever a crashed process would
134
+ otherwise leak a key.
135
+ - Crash safety: for each new Redis write, ask what happens if the process dies
136
+ immediately before and immediately after it. A window that strands a run with no
137
+ sweeper/recovery path (`step_sweeper.rb`, `sweeper.rb`) is a BLOCKER.
138
+ - Serialization: anything crossing a process boundary round-trips through
139
+ `context_serializer.rb` without silent loss (watch `false`/`nil` handling, symbol vs
140
+ string keys, and context size limits).
141
+
142
+ #### G2 — Saga Robustness: Predictable Unwind, Compensation, Undo
143
+
144
+ The core promise of the library: a run either completes or unwinds cleanly. For every step
145
+ touched or added:
146
+
147
+ - A step producing a side effect declares rollback (`compensate`/`undo`) — and the
148
+ reviewed code actually routes failures into it.
149
+ - **Unwind order is deterministic**: compensation runs in reverse completion order per the
150
+ DAG, and is reproducible across runs of the same failure. Any dependence on hash order,
151
+ thread completion order, or wall-clock ordering is a BLOCKER.
152
+ - **Compensation is idempotent and re-entrant**: running it twice (crash mid-unwind, then
153
+ sweeper retry) leaves the same state.
154
+ - A failure *inside* compensation/undo is handled explicitly (`compensation_error.rb`,
155
+ `undo_error.rb`) — never swallowed, never allowed to abort the remaining unwind silently.
156
+ - No orphaned steps: a step that started cannot end with neither a result nor a
157
+ compensation record. Partial execution with no recovery path is forbidden.
158
+ - Interrupts/pause/resume remain first-class: a paused run resumes to the same DAG position
159
+ with the same context, and unwinding a paused or resumed run compensates exactly the
160
+ steps that actually ran — not more, not fewer.
161
+ - Async steps that fail after dispatch still reach compensation of their upstream steps.
162
+
163
+ #### G3 — Retries Do What They Claim
164
+
165
+ - Retry counting is per-step and survives process boundaries (`retry_context.rb`,
166
+ `retry_queued_result.rb`); a retry that re-enters through a worker does not reset the
167
+ counter or double-count it.
168
+ - `max_retries` exhaustion produces `max_retries_exhausted_failure.rb` and then flows into
169
+ the same compensation path as any other failure.
170
+ - Backoff/delay semantics match `documentation/retry_configuration.md` exactly (units,
171
+ first-attempt-vs-retry counting, jitter, caps).
172
+ - A retried step re-acquires whatever locks/semaphores/rate-limit tokens it needs, and
173
+ releases those from the failed attempt first — no token or lock leak per retry.
174
+ - Retries do not re-run already-successful upstream steps and do not skip validation.
175
+ - Retry of a step with side effects either compensates the failed attempt first or is
176
+ documented as idempotent — whichever the docs claim, the code must do.
177
+
178
+ #### G4 — Async Reactors and Steps Are Isolated
179
+
180
+ - No shared mutable state between concurrently executing steps: context writes are
181
+ per-step and merged, not mutated in place across threads/processes.
182
+ - An async step's failure is contained — it fails its own step and the run's declared
183
+ policy, and cannot corrupt or cross-talk into a sibling async step or another reactor
184
+ run (`executor/async_step_dispatch.rb`, `async_waiter.rb`, `step_worker.rb`).
185
+ - Job payloads carry full identity (run id, step name, attempt) so a worker cannot act on
186
+ the wrong run; late/duplicate job delivery is a no-op, not a second execution.
187
+ - Waiting is bounded: every wait has a timeout path (`async_wait_timeout_error.rb`) and
188
+ `async_result_pending.rb` handling that cannot park forever.
189
+ - Adapter isolation holds: `adapters/sidekiq` and `adapters/active_job` stay
190
+ interchangeable; no adapter-specific behavior leaks into the executor.
191
+
192
+ #### G5 — Locks Protect Resources Without Deadlocking
193
+
194
+ Covers `lock.rb`, `ordered_lock.rb`, `semaphore.rb`, `rate_limit*.rb`, `period.rb`,
195
+ `dsl/lockable.rb`, and this feature's independent step-lock work.
196
+
197
+ - Every acquire has a release on **all** paths: success, failure, compensation, undo,
198
+ exception, pause, and process crash (TTL). Show the release path for each new acquire.
199
+ - **Deadlock freedom**: multi-lock acquisition follows one global deterministic order
200
+ (that is what `ordered_lock.rb` is for) — no step acquires A-then-B while another can
201
+ acquire B-then-A. Any new acquisition site must be shown to respect the order.
202
+ - No lock is held across an async dispatch or a blocking wait unless that is explicitly
203
+ designed, documented, and TTL-bounded.
204
+ - Ownership tokens: release only releases a lock this run still owns (fencing/ownership
205
+ check), so a TTL-expired holder cannot release a lock now held by someone else.
206
+ - Semaphore/rate-limit accounting cannot drift: crashed holders are reclaimed, and
207
+ double-release cannot inflate available tokens.
208
+ - Behavior matches `documentation/locks_and_semaphores.md` (blocking vs non-blocking,
209
+ timeout, what a contended run returns).
210
+
211
+ #### G6 — Validations Predictable and Run Before `run`
212
+
213
+ - Input validation executes **before** the step's `run` body — always, including on retry,
214
+ resume-from-pause, and async re-entry paths. A path that reaches `run` with unvalidated
215
+ input is a BLOCKER.
216
+ - Validation failures produce `input_validation_error.rb` / `validation_error.rb` as
217
+ failures with the documented shape — never exceptions escaping the executor, never a
218
+ silent skip.
219
+ - A validation failure produces **no side effects** and unwinds any already-completed steps
220
+ per G2.
221
+ - dry-validation schemas stay inside the reactor/step DSL (`dsl/validation_helpers.rb`,
222
+ `validation/`), not scattered into application code.
223
+ - Validation is deterministic: same input → same outcome, no I/O, no clock, no network
224
+ inside a schema.
225
+ - Defaults/coercions applied by validation are visible to `run` and to serialization
226
+ consistently (this is where `false`/`nil` bugs hide).
227
+
228
+ ### 3. Verify Dynamically Where Cheap
229
+
230
+ Evidence beats reasoning. When the change touches runtime behavior, run what already
231
+ exists rather than asserting:
232
+
233
+ ```bash
234
+ bundle exec rubocop
235
+ bundle exec rspec <specs covering the changed area>
236
+ ```
237
+
238
+ For user-facing behavior, run it end to end against real Redis:
239
+ `docker compose run --rm demo-app bin/rails demo:<task>` (or `/demo-app-e2e-verify`).
240
+ Report command output honestly — a failing or skipped check is a finding with its output
241
+ quoted, never "should pass". If a check is not run, say which and why.
242
+
243
+ ### 4. Report
244
+
245
+ Output in-session (no file writes):
246
+
247
+ ```markdown
248
+ ## Review — <feature> (<base>...<head>)
249
+
250
+ **Verdict**: BLOCKED | PASS WITH FINDINGS | PASS
251
+
252
+ | Gate | Result | Evidence |
253
+ |------|--------|----------|
254
+ | G1 Distributed + claims | BLOCKER | documentation/locks_and_semaphores.md:88 claims X; lib/ruby_reactor/lock.rb:142 does Y |
255
+ | G2 Saga unwind/compensation | ... | ... |
256
+ | G3 Retries | ... | ... |
257
+ | G4 Async isolation | ... | ... |
258
+ | G5 Locks / deadlock | ... | ... |
259
+ | G6 Validations | ... | ... |
260
+
261
+ ### Findings
262
+
263
+ **[BLOCKER] F1 — <one-line defect>** (G5, `lib/ruby_reactor/lock.rb:142`)
264
+ Failure scenario: <concrete interleaving/inputs → wrong state or hang>
265
+ Fix: <smallest change that closes it>
266
+
267
+ ### Checks Run
268
+ - `bundle exec rubocop` → <result>
269
+ - `bundle exec rspec …` → <result>
270
+ - not run: <what, why>
271
+ ```
272
+
273
+ Rules for the report:
274
+
275
+ - Order findings BLOCKER → FINDING, most severe first.
276
+ - Every finding names a **concrete failure scenario** (interleaving, crash point, or input
277
+ that produces the wrong result). A finding that cannot state one is speculation — drop it.
278
+ - No finding without a file:line. No gate row without either evidence or `N/A`.
279
+ - Verdict is `BLOCKED` if any gate is a BLOCKER, `PASS WITH FINDINGS` if only findings
280
+ remain, `PASS` only when every in-scope gate passed **and** the checks in Step 3 ran.
281
+
282
+ ### 5. Handoff
283
+
284
+ - `BLOCKED` / `PASS WITH FINDINGS`: recommend `/speckit-converge` to append the remediation
285
+ work as traceable tasks, then `/speckit-implement`. Do not fix anything here.
286
+ - `PASS`: recommend proceeding to PR, carrying the gate table into the PR description as
287
+ the reviewer-facing evidence.
288
+
289
+ ### 6. Check for extension hooks
290
+
291
+ After producing the report, check if `.specify/extensions.yml` exists in the project root.
292
+
293
+ - If it exists, read it and look for entries under the `hooks.after_review` key
294
+ - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
295
+ - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
296
+ - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
297
+ - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
298
+ - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
299
+ - When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
300
+ - For each executable hook, output the following based on its `optional` flag:
301
+ - **Optional hook** (`optional: true`):
302
+
303
+ ```text
304
+ ## Extension Hooks
305
+
306
+ **Optional Hook**: {extension}
307
+ Command: `/{command}`
308
+ Description: {description}
309
+
310
+ Prompt: {prompt}
311
+ To execute: `/{command}`
312
+ ```
313
+
314
+ - **Mandatory hook** (`optional: false`):
315
+
316
+ ```text
317
+ ## Extension Hooks
318
+
319
+ **Automatic Hook**: {extension}
320
+ Executing: `/{command}`
321
+ EXECUTE_COMMAND: {command}
322
+ ```
323
+
324
+ - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.8.2"
2
+ ".": "0.8.3"
3
3
  }
@@ -21,3 +21,13 @@ hooks:
21
21
  prompt: Execute speckit.agent-context.update?
22
22
  description: Refresh agent context after planning
23
23
  condition: null
24
+ after_implement:
25
+ - extension: project
26
+ command: speckit.review
27
+ enabled: true
28
+ optional: false
29
+ priority: 10
30
+ prompt: Execute speckit.review?
31
+ description: Review the implemented changes against RubyReactor
32
+ runtime invariants (saga, retries, async isolation, locks, validation, documented claims)
33
+ condition: null
@@ -1,3 +1,3 @@
1
1
  {
2
- "feature_directory": "specs/004-inheritable-step-class"
2
+ "feature_directory": "specs/005-step-coordination-remediation"
3
3
  }
@@ -4,7 +4,7 @@ workflow:
4
4
  name: "Full SDD Cycle"
5
5
  version: "1.0.0"
6
6
  author: "GitHub"
7
- description: "Runs specify → plan → tasks → implement with review gates"
7
+ description: "Runs specify → plan → tasks → implement → review with review gates"
8
8
 
9
9
  requires:
10
10
  # 0.8.5 is the first release with engine-side resolution of the
@@ -75,3 +75,15 @@ steps:
75
75
  integration: "{{ inputs.integration }}"
76
76
  input:
77
77
  args: "{{ inputs.spec }}"
78
+
79
+ - id: review
80
+ command: speckit.review
81
+ integration: "{{ inputs.integration }}"
82
+ input:
83
+ args: "{{ inputs.spec }}"
84
+
85
+ - id: review-gate
86
+ type: gate
87
+ message: "Review findings above (constitution + saga/retry/async/lock/validation gates). Approve to finish, reject to send remaining work back through converge/implement."
88
+ options: [approve, reject]
89
+ on_reject: abort
@@ -4,10 +4,10 @@
4
4
  "speckit": {
5
5
  "name": "Full SDD Cycle",
6
6
  "version": "1.0.0",
7
- "description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates",
7
+ "description": "Runs specify → plan → tasks → implement → review with review gates",
8
8
  "source": "bundled",
9
9
  "installed_at": "2026-06-23T23:29:12.184843+00:00",
10
10
  "updated_at": "2026-06-23T23:29:12.184848+00:00"
11
11
  }
12
12
  }
13
- }
13
+ }
data/CHANGELOG.md CHANGED
@@ -80,6 +80,19 @@
80
80
 
81
81
  ### Features
82
82
 
83
+ * **Step-scoped coordination.** Steps can declare `with_lock`, `with_semaphore`, `with_rate_limit`,
84
+ `with_period`, and `with_ordered_lock` — the same macros as the reactor form, keyed on the step's
85
+ own resolved arguments instead of the reactor's inputs — so one step of a workflow can be
86
+ serialized (or rate-limited, deduped, or strictly ordered) without serializing the whole
87
+ workflow. Works on class steps and inline `step :x do ... end` blocks (or both on one step —
88
+ taken once, in one fixed order); a direct `MyStep.run(args)` call is protected too, as its own
89
+ unit of work that waits then fails. Contention parks the execution in a worker (bounded by
90
+ `lock_snooze_max_attempts`, never consuming the step's own `retries` budget; the parked step
91
+ releases what it took and never spends a rate-limit slot on a park) and waits-then-fails
92
+ synchronously. Re-entrancy, the async dispatch deadlock guard, and rollback
93
+ (`compensate`/`undo` re-take lock/semaphore only) all follow the same rules as reactor-level
94
+ coordination. See
95
+ [Step-Scoped Coordination](documentation/locks_and_semaphores.md#step-scoped-coordination).
83
96
  * **Step input contracts.** A step class declares its own inputs with `input :name, :type, **predicates`
84
97
  (plus `optional:`, `default:`, `redact:`, the `do |i| ... end` macro block and `validate:`) and
85
98
  cross-field rules with `validate_inputs`. The contract is enforced before `run` on every path
@@ -103,6 +116,20 @@
103
116
  * A step that returns another unit's validation failure (e.g. an `async_step` reader propagating
104
117
  the worker's `Failure`) keeps its `validation_errors` on the reactor's final failure.
105
118
 
119
+ * **`rollback_wait:` on step `with_lock` / `with_semaphore`.** How long a step's `undo` /
120
+ `compensate` waits to re-take its key. Defaults to the lock's `ttl` (60 s for a semaphore);
121
+ rollback still never parks, so in a worker the wait blocks the thread.
122
+ * **`Failure#rollback_failures`.** Every undo or compensation that did not complete —
123
+ `{ step:, kind: :undo | :compensate, key:, reason: :coordination_unavailable | :returned_failure | :raised, message: }`,
124
+ including composed children's (flattened). Always an Array; part of `Failure#to_h` and the
125
+ stored failure of a background run.
126
+ * **`:snooze_step` middleware event** (`on_snooze_step(step_name, error, context)`): a step's
127
+ attempt ended in a park — it lost its own contention in a worker, or it is a `compose` step whose
128
+ child parked — at any nesting depth. Never `:failed_step`. A step whose own arguments wait on a
129
+ background result parks before it starts, so it fires neither `:start_step` nor `:snooze_step`.
130
+ The OpenTelemetry middleware closes the span as `step.status = "parked"`.
131
+ * **`have_rollback_failure(step)` matcher**, with `.for_key(key)` and `.because(reason)`.
132
+
106
133
  ### Deprecations
107
134
 
108
135
  * Rules on `argument` (`argument :x, src, :type, **predicates`) and `validate_args` keep working
@@ -128,6 +155,61 @@
128
155
  * A class step that calls `halt!` under `async_step` is recorded as a halt rather than an
129
156
  ordinary `nil` success, and `result(:step)` hands the reader the `Halt` — the same way it
130
157
  already hands over a `Failure`.
158
+ * Step coordination (F1): a step's undo is no longer dropped because another execution holds its
159
+ key at that moment — it waits up to `rollback_wait`, and one that still cannot run is reported
160
+ on `Failure#rollback_failures` instead of only in the trace.
161
+ * Step coordination (F2): a park inside a composed child no longer releases the parent's reactor
162
+ lock or semaphore, and no longer charges the parent's rate limit again on redelivery — every
163
+ level keeps its own holds and is admitted once, at any depth.
164
+ * Step coordination (F3): a synchronous execution that reaches a strict step-level ordered lock
165
+ out of turn fails without poisoning the chain; later arrivals run instead of being skipped.
166
+ * Step coordination (F4): the docs name `context.coordinating_step` (not `current_step`) as the
167
+ attribution for coordination middleware events.
168
+ * Step coordination (F5): a parked `async_step` no longer overwrites its parent's saved context;
169
+ its park state (ordered-lock position, "waiting" marker) lives on its own step result record.
170
+ * Step coordination (F6): documented the cross-level key-ordering rule that avoids two workflows
171
+ waiting on each other's reactor and step locks.
172
+ * Step coordination (F7): a step whose ordered-lock batch expired before its retry is skipped
173
+ (`Skipped(reason: :ordered_lock_stale_batch)`) instead of running unordered.
174
+ * Step coordination (F8): a step-level ordered-lock heartbeat stops when the step body exits
175
+ abnormally (e.g. `Sidekiq::Shutdown`), so the poison pill can release the position.
176
+ * Step coordination (F9): a step class invoked directly from another step's body names itself in
177
+ contention errors and coordination events, not the calling step.
178
+ * An `async_step` no longer writes its parent's context when it finishes (it already stopped
179
+ doing so on a park). Its older snapshot overwrote whatever the parent saved while the unit ran —
180
+ a parent could revert to "running" and lose later steps' results. The unit's run (arguments,
181
+ attempts, start time) now lives on its Step Result Record, and the dashboard rebuilds it from the
182
+ parent's link, at any composition depth. `context.execution_trace` no longer has a `:run` entry
183
+ for an `async_step`, and changes an `async_step` body makes to `context` are not persisted.
184
+ * A park after a fan-out map (a later step's contention, or an awaited background result) now
185
+ requeues the parent on its own worker instead of escaping the map collector and leaving the run
186
+ "running" forever; the collector also no longer re-saves the parent after resuming it, which
187
+ could overwrite a newer save by the parent's next worker.
188
+ * An `async_step` refused at dispatch because it would deadlock on a key the reactor holds is no
189
+ longer compensated. It was never dispatched or run. The steps before it still roll back.
190
+ * A step of a composed child that reads a not-yet-finished background result in a worker (F10)
191
+ parks the execution, keeping the child's lock, instead of failing the parent with
192
+ "async result … still pending".
193
+ * A background reactor whose own `with_lock` / `with_semaphore` is busy when it starts no longer
194
+ charges its `with_rate_limit` again on every snoozed redelivery.
195
+ * A `compensate` that raises no longer stops the rollback: the completed steps are still undone,
196
+ and the reactor fails with `CompensationError` and a `reason: :raised` rollback failure.
197
+ * A composed child whose own `with_lock` / `with_semaphore` / `with_rate_limit` is busy inside a
198
+ worker now parks the execution and snoozes the job instead of failing the parent. After
199
+ `lock_snooze_max_attempts` parks it fails the `compose` step, which rolls the parent back.
200
+ * A park that comes up through a `compose` step (the child's contention, or its wait on a
201
+ background result) no longer uses up that step's `retries` budget.
202
+ * Docs: `on_snooze_step` is documented for what it covers — a step's own contention park and a
203
+ `compose` step whose child parked, not a step whose own arguments wait on a background result.
204
+ * Docs: a park keeps each level's lock without a second `:lock_acquired` only while the gap stays
205
+ within the lock's `ttl`; a lapsed lock is acquired again.
206
+
207
+ ## [0.8.3](https://github.com/arturictus/ruby_reactor/compare/v0.8.2...v0.8.3) (2026-09-25)
208
+
209
+
210
+ ### Features
211
+
212
+ * move locks declarations to step classes ([#56](https://github.com/arturictus/ruby_reactor/issues/56)) ([a7862ca](https://github.com/arturictus/ruby_reactor/commit/a7862ca765ae706aebb361d9eadcf4c0ead12caf))
131
213
 
132
214
  ## [0.8.2](https://github.com/arturictus/ruby_reactor/compare/v0.8.1...v0.8.2) (2026-09-22)
133
215
 
data/CLAUDE.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <!-- SPECKIT START -->
2
2
  For additional context about technologies to be used, project structure,
3
- shell commands, and other important information, read the current plan:
4
- `specs/004-inheritable-step-class/plan.md`
3
+ shell commands, and other important information, read the current plan
4
+ at specs/005-step-coordination-remediation/plan.md
5
5
  <!-- SPECKIT END -->
data/README.md CHANGED
@@ -720,6 +720,23 @@ class RefundOrderReactor < RubyReactor::Reactor
720
720
  end
721
721
  end
722
722
 
723
+ class ChargeStep < RubyReactor::Step
724
+ input :account_id
725
+
726
+ # All five macros also work declared on a STEP, not just the reactor —
727
+ # keying the critical section down to this one operation instead of the
728
+ # whole workflow. Surrounding steps (audit, notify, ...) keep overlapping
729
+ # across concurrent executions; only :charge serializes. Its undo re-takes
730
+ # the key, waiting up to `rollback_wait:` (default: `ttl`; 60 s for a
731
+ # step `with_semaphore`) — an undo that still can't run is reported on
732
+ # `Failure#rollback_failures`, never dropped.
733
+ with_lock(ttl: 60, rollback_wait: nil) { |args| "acct:#{args[:account_id]}" }
734
+
735
+ def run
736
+ Success(charge!(inputs))
737
+ end
738
+ end
739
+
723
740
  class GeocodeReactor < RubyReactor::Reactor
724
741
  input :address
725
742
 
@@ -843,7 +860,7 @@ step :maybe_sync do
843
860
  end
844
861
  ```
845
862
 
846
- See [Locks, Semaphores, Rate Limits, Periods & Ordered Locks](documentation/locks_and_semaphores.md) for re-entrancy, auto-extend, multi-window quotas, bucket semantics, owner identity, snooze tuning, ordered-lock assignment + poison-pill semantics, and operational notes.
863
+ See [Locks, Semaphores, Rate Limits, Periods & Ordered Locks](documentation/locks_and_semaphores.md) for re-entrancy, auto-extend, multi-window quotas, bucket semantics, owner identity, snooze tuning, ordered-lock assignment + poison-pill semantics, step-scoped coordination, and operational notes.
847
864
 
848
865
  ### Map & Parallel Execution
849
866
 
@@ -1376,6 +1393,22 @@ end
1376
1393
  # Result: Complete rollback of the transaction
1377
1394
  ```
1378
1395
 
1396
+ A rollback that did not complete is never silent. `Failure#rollback_failures`
1397
+ lists every undo or compensation that raised, returned a `Failure`, or could not
1398
+ re-take its step's lock/semaphore within `rollback_wait:` — including those of
1399
+ composed children (flattened). It is always an Array, empty when rollback
1400
+ completed, and is part of `Failure#to_h`:
1401
+
1402
+ ```ruby
1403
+ result = TransactionReactor.run(from_account: 1, to_account: 2, amount: 10)
1404
+ result.rollback_failures
1405
+ # => [{ step: :debit_account, kind: :undo, key: nil, reason: :raised, message: "gateway timeout" }]
1406
+ ```
1407
+
1408
+ `reason` is `:coordination_unavailable`, `:returned_failure`, or `:raised`; `kind`
1409
+ is `:undo` or `:compensate`. See
1410
+ [Step Rollback](documentation/locks_and_semaphores.md#step-rollback).
1411
+
1379
1412
  ### Using Pre-defined Schemas
1380
1413
 
1381
1414
  You can use existing dry-validation schemas:
@@ -1460,7 +1493,7 @@ Comprehensive guide to testing reactors with RubyReactor's testing utilities. Le
1460
1493
 
1461
1494
  ### [Locks, Semaphores, Rate Limits, Periods & Ordered Locks](documentation/locks_and_semaphores.md)
1462
1495
 
1463
- Coordinate access to shared resources across processes with Redis-backed primitives: exclusive locks (`with_lock`), concurrency-limiting semaphores (`with_semaphore`), fixed-window rate limits with multi-window quotas (`with_rate_limit`), calendar-bucketed dedup (`with_period`, returning `Halt` results), and strict sequential ordering via a monotonically increasing nonce assigned at enqueue (`with_ordered_lock`). Covers re-entrancy across composed reactors, TTL auto-extend, inline-vs-background contention behavior, smart `retry_after` snoozes for rate limits, snooze tuning, the token-based semaphore safety model, once-per-day/month/year scheduling patterns, ordered-lock counter reset on drain, poison-pill timeouts, and deadlock-safe composition rules.
1496
+ Coordinate access to shared resources across processes with Redis-backed primitives: exclusive locks (`with_lock`), concurrency-limiting semaphores (`with_semaphore`), fixed-window rate limits with multi-window quotas (`with_rate_limit`), calendar-bucketed dedup (`with_period`, returning `Halt` results), and strict sequential ordering via a monotonically increasing nonce assigned at enqueue (`with_ordered_lock`). Every primitive is also available [declared on a step](documentation/locks_and_semaphores.md#step-scoped-coordination) instead of the whole reactor, keying the critical section down to one operation. Covers re-entrancy across composed reactors, TTL auto-extend, inline-vs-async contention behavior, smart `retry_after` snoozes for rate limits, snooze tuning, the token-based semaphore safety model, once-per-day/month/year scheduling patterns, ordered-lock counter reset on drain, poison-pill timeouts, and deadlock-safe composition rules.
1464
1497
 
1465
1498
  ### [Middlewares & OpenTelemetry](documentation/middlewares.md)
1466
1499
 
@@ -34,6 +34,25 @@ module RubyReactor
34
34
  RubyReactor::DispatchResult.new(job_id: job_id)
35
35
  end
36
36
 
37
+ # Delayed re-enqueue of an `async_step`'s body — the park path
38
+ # (Finding 6). See Sidekiq::Router#perform_step_in.
39
+ # rubocop:disable Metrics/ParameterLists
40
+ def self.perform_step_in(delay, root_context_id:, reactor_class_name:, step_context_id:, step_name:,
41
+ contention_attempts: 0)
42
+ # rubocop:enable Metrics/ParameterLists
43
+ job_id = RubyReactor::Adapters::ActiveJob::StepWorker.perform_in(
44
+ delay,
45
+ {
46
+ "root_context_id" => root_context_id,
47
+ "reactor_class_name" => reactor_class_name,
48
+ "step_context_id" => step_context_id,
49
+ "step_name" => step_name.to_s,
50
+ "contention_attempts" => contention_attempts
51
+ }
52
+ )
53
+ RubyReactor::DispatchResult.new(job_id: job_id)
54
+ end
55
+
37
56
  # rubocop:disable Metrics/ParameterLists
38
57
  def self.perform_map_element_async(map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
39
58
  strict_ordering:, parent_context_id:, parent_reactor_class_name:,
@@ -34,6 +34,27 @@ module RubyReactor
34
34
  RubyReactor::DispatchResult.new(job_id: job_id)
35
35
  end
36
36
 
37
+ # Delayed re-enqueue of an `async_step`'s body — the park path
38
+ # (Finding 6): the worker rescues `StepCoordination::Contended` and
39
+ # reschedules itself instead of failing, carrying the contention
40
+ # attempt count forward so `lock_snooze_max_attempts` still bounds it.
41
+ # rubocop:disable Metrics/ParameterLists
42
+ def self.perform_step_in(delay, root_context_id:, reactor_class_name:, step_context_id:, step_name:,
43
+ contention_attempts: 0)
44
+ # rubocop:enable Metrics/ParameterLists
45
+ job_id = RubyReactor::Adapters::Sidekiq::StepWorker.perform_in(
46
+ delay,
47
+ {
48
+ "root_context_id" => root_context_id,
49
+ "reactor_class_name" => reactor_class_name,
50
+ "step_context_id" => step_context_id,
51
+ "step_name" => step_name.to_s,
52
+ "contention_attempts" => contention_attempts
53
+ }
54
+ )
55
+ RubyReactor::DispatchResult.new(job_id: job_id)
56
+ end
57
+
37
58
  # rubocop:disable Metrics/ParameterLists
38
59
  def self.perform_map_element_async(map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
39
60
  strict_ordering:, parent_context_id:, parent_reactor_class_name:,