ruby_reactor 0.7.0 → 0.7.1

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 (51) 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 +79 -12
  7. data/.specify/templates/tasks-template.md +7 -0
  8. data/CHANGELOG.md +11 -0
  9. data/CLAUDE.md +2 -2
  10. data/README.md +24 -9
  11. data/lib/ruby_reactor/context.rb +7 -0
  12. data/lib/ruby_reactor/context_serializer.rb +13 -0
  13. data/lib/ruby_reactor/dsl/lockable.rb +2 -2
  14. data/lib/ruby_reactor/dsl/template_helpers.rb +11 -3
  15. data/lib/ruby_reactor/executor/compensation_manager.rb +58 -45
  16. data/lib/ruby_reactor/executor/ordered_lock_support.rb +9 -9
  17. data/lib/ruby_reactor/executor/result_handler.rb +35 -10
  18. data/lib/ruby_reactor/executor/retry_manager.rb +4 -1
  19. data/lib/ruby_reactor/executor/step_executor.rb +19 -13
  20. data/lib/ruby_reactor/executor.rb +17 -15
  21. data/lib/ruby_reactor/map/element_executor.rb +7 -1
  22. data/lib/ruby_reactor/map/helpers.rb +9 -7
  23. data/lib/ruby_reactor/map/result_enumerator.rb +2 -0
  24. data/lib/ruby_reactor/map/sweeper.rb +1 -1
  25. data/lib/ruby_reactor/open_telemetry.rb +7 -4
  26. data/lib/ruby_reactor/ordered_lock.rb +3 -3
  27. data/lib/ruby_reactor/rspec/matchers.rb +61 -11
  28. data/lib/ruby_reactor/rspec/test_subject.rb +8 -8
  29. data/lib/ruby_reactor/step/map_step.rb +5 -1
  30. data/lib/ruby_reactor/step.rb +10 -4
  31. data/lib/ruby_reactor/step_signals.rb +33 -0
  32. data/lib/ruby_reactor/storage/adapter.rb +4 -0
  33. data/lib/ruby_reactor/storage/redis_adapter.rb +1 -72
  34. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
  35. data/lib/ruby_reactor/version.rb +1 -1
  36. data/lib/ruby_reactor/web/api.rb +23 -6
  37. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +1 -0
  38. data/lib/ruby_reactor/web/public/assets/index-Dw4KV4QY.js +22 -0
  39. data/lib/ruby_reactor/web/public/index.html +2 -2
  40. data/lib/ruby_reactor.rb +56 -7
  41. metadata +7 -11
  42. data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +0 -1
  43. data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +0 -22
  44. data/specs/001-background-async-steps/checklists/requirements.md +0 -39
  45. data/specs/001-background-async-steps/contracts/public-dsl.md +0 -154
  46. data/specs/001-background-async-steps/data-model.md +0 -117
  47. data/specs/001-background-async-steps/plan.md +0 -168
  48. data/specs/001-background-async-steps/quickstart.md +0 -102
  49. data/specs/001-background-async-steps/research.md +0 -150
  50. data/specs/001-background-async-steps/spec.md +0 -146
  51. data/specs/001-background-async-steps/tasks.md +0 -271
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b08f95b4b201fa008ca5f98e8e221f7bb8da00becac3307f7f107406b4a3fd95
4
- data.tar.gz: d3884e832408aea3d1caee63342b71c16823474abba8b98536a76dbe55ca1fc2
3
+ metadata.gz: abde4c2dc313b2e5d4a74873f1fc337743e6847e8f47fcbd121fd1a72be1d557
4
+ data.tar.gz: defd25588b78022c339ffd105d4e304d80e676498272c887cafecb60f100dc7e
5
5
  SHA512:
6
- metadata.gz: 2df63b3d9a9603d61baa4c51ea453399c2890b012fd74e11e01b7931081dfc9d3cc1eba358930166c2847c6cef964d9491b968e40c448d6a62fcb8ea0ae90e90
7
- data.tar.gz: 809eb63f486448fb5d0c0d8d15eec39c3e358e3de26dee6c1db8318c9a2f0449fbf32a7f91695e75d58b8eee2ac026e46ce0e063181b567f4b777c35b44d2b52
6
+ metadata.gz: 595c701f476086c166d0e157c062e0bea0e6fb243b1c5466017bf488461b987b44c70b56e081770c586a1d83519311c58da9769ae83e302fb7d17d1cdc8f37fb
7
+ data.tar.gz: 7dadad987df2ec6d9c1b44e93ed328f07d77660310726dceeb1ee8957cc9e63b73bd3c2338792280bfc780a95650bcac12b384af3d2aad83b3309aa4896f6d0d
@@ -0,0 +1,226 @@
1
+ ---
2
+ name: "demo-app-e2e-verify"
3
+ description: "Boot demo_app via docker-compose, run demo_reactors.rake, auto-derive acceptance criteria from the reactor/rake source, and cross-check actual behavior against both the RubyReactor API and dashboard at localhost:[port]/ruby_reactor. Writes findings + a fix plan to a tmp report."
4
+ argument-hint: "Optional: specific rake task(s)/reactor(s) to focus on (default: all)"
5
+ compatibility: "Requires docker compose and the demo_app/lib/tasks/demo_reactors.rake tasks"
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
+ If non-empty, scope the run to the named rake task(s)/reactor(s) instead of `demo:all`.
19
+ Otherwise run everything.
20
+
21
+ ## Purpose
22
+
23
+ End-to-end confidence check for `demo_app`: does what the rake demos claim to do actually
24
+ show up correctly in the RubyReactor API and dashboard? This is not a spec-kit workflow —
25
+ it derives its own expectations from the current code rather than from `specs/`.
26
+
27
+ ## Step 1: Bring the stack up
28
+
29
+ 1. `docker compose ps` to see what's already running; avoid tearing down containers the
30
+ user didn't ask to stop.
31
+ 2. `docker compose up -d demo-redis demo-sidekiq demo-app` (skip `redis-test`/`teley`
32
+ unless already running — they're not needed for this check). If it fails with a
33
+ container name conflict (`container_name` is static, and this repo runs from multiple
34
+ git worktrees sharing one docker daemon), check `docker ps -a --filter name=ruby_reactor`
35
+ — if the conflicting containers are `Exited` (not running), it's safe to `docker rm`
36
+ them and retry; never `rm` a container that's currently `Up` without asking, it may
37
+ belong to another worktree's active session.
38
+ 3. Poll `docker compose ps` / container healthchecks until `demo-redis` and `demo-sidekiq`
39
+ report healthy, and until the Rails server inside `demo-app` responds. Use a
40
+ short polling loop (not a fixed sleep) against `http://localhost:3789/ruby_reactor` —
41
+ treat a non-timeout HTTP response (even a 404/500) as "server is up", empty/refused as
42
+ "still booting". Cap the wait (e.g. ~90s) and report clearly if it never comes up.
43
+ 4. Read the exposed host port for `demo-app` from `docker-compose.yml` at runtime rather
44
+ than assuming `3789` — the file may have changed.
45
+
46
+ ## Step 2: Auto-learn the expected behavior (before running anything)
47
+
48
+ For each rake task in scope, build an acceptance-criteria list by reading the source, not
49
+ by assuming:
50
+
51
+ 1. Read `demo_app/lib/tasks/demo_reactors.rake` — for each in-scope task, note: which
52
+ reactor(s) it calls, what inputs/scenarios it loops over (e.g. `fail_at` values), and
53
+ what output markers it prints for success/failure/background/pause (`✅`, `❌`, `⏳`,
54
+ etc.).
55
+ 2. Read the corresponding reactor class(es) in `demo_app/app/reactors/`. For each step,
56
+ note: what triggers it to succeed/fail/halt/skip/retry-veto, what its rollback (undo)
57
+ does if it has side effects, and any locks/rate-limits/periods/interrupts it declares.
58
+ 3. From that, derive one acceptance criterion per scenario, e.g.:
59
+ - "`fail_at: nil` → task prints ✅, reactor status is `success`, `execution_trace`
60
+ contains every step in dependency order, `error` is nil."
61
+ - "`fail_at: :capture_payment` → task prints ❌, reactor status is `failed`/`halted`,
62
+ steps after `capture_payment` never ran, steps before it with side effects appear in
63
+ `undo_stack` (or show a compensation trace), `error` mentions the trigger."
64
+ - For background/async reactors: expect a `DispatchResult`/`⏳` print, then (after
65
+ `drain`/wait) a terminal status reachable via the API.
66
+ - For interrupt/pause reactors: expect `be_paused`-equivalent status and
67
+ `ready_interrupts`/`current_step` matching the interrupt step.
68
+ 4. Keep this list in memory (or a scratch note) — it is what Step 4 checks results against.
69
+ If a reactor's behavior can't be inferred confidently from its source, say so explicitly
70
+ rather than guessing an expectation.
71
+
72
+ ## Step 3: Run the demos
73
+
74
+ 1. Execute the in-scope rake task(s) inside the running container:
75
+ `docker compose exec -T demo-app bin/rails demo:<task>` (or `demo:all` by default).
76
+ 2. Capture full stdout. Extract, per scenario run: reactor class, inputs used (especially
77
+ any `fail_at`/scenario discriminator), the printed outcome marker, and any
78
+ `execution_id`/`context_id`/`order_id` printed — these are the join key to the API in
79
+ Step 4. If a task doesn't print an id, look up the id via the API list endpoint
80
+ (Step 4.1) by class name + recency instead of skipping verification.
81
+ 3. For async/background scenarios, allow Sidekiq (already running as `demo-sidekiq`) time
82
+ to process, then re-check rather than asserting immediately. `demo:all` runs heavy
83
+ background tasks back-to-back (`map`'s ~30 jobs, `ar`'s ~100 product jobs, the lock/
84
+ semaphore/coordination holds) — an async/await step later in the chain (e.g.
85
+ `async_step_demo`, `async_reactor_demo`) can hit `async_wait_timeout` purely from
86
+ Sidekiq queue backlog, not a real bug. Before reporting an async timeout as a finding,
87
+ re-run that one task in isolation (`bin/rails demo:<task>`) once the queue has drained
88
+ — if it passes clean alone, it's a demo-ordering/concurrency artifact (note it under
89
+ Suggestions, e.g. "raise Sidekiq concurrency" or "reorder demo:all"), not a code bug.
90
+
91
+ ### Splitting the run across agents (avoid context overload)
92
+
93
+ For `demo:all` (or any multi-task scope), don't run Steps 2-5 for every task in one
94
+ context — the full run is ~19 tasks / 80+ scenarios and will blow past a useful context
95
+ budget long before the report is written. Instead:
96
+
97
+ 1. Do Step 1 (stack up) and a first pass of Step 2 yourself: skim
98
+ `demo_reactors.rake` and `git status`/`git log` for the reactors involved to identify
99
+ which ones are new/actively-changing right now (untracked files, recent commits touching
100
+ `app/reactors/`) — those get priority full-depth verification.
101
+ 2. Group the remaining tasks (e.g. by rake task, or a few related tasks per group) and
102
+ fan them out to parallel background agents, each self-contained: give it the exact
103
+ `docker compose exec` command, the port, which reactors/tasks it owns, the acceptance-
104
+ criteria method from Step 2, and the Step 4/5 validation + fix instructions verbatim.
105
+ Tell each agent explicitly which scenarios warrant full API depth-check (`GET .../<id>`
106
+ against every field) versus which can be validated via stdout markers plus a 1-2
107
+ scenario spot-check — exhaustively hitting the API for every scenario in a large group
108
+ is what overloads context, not running the scenarios themselves.
109
+ 3. Have each agent write its own findings; collect and merge into the single report in
110
+ Step 6 yourself (or have one agent own the merge) rather than re-deriving everything in
111
+ your own context.
112
+
113
+ ## Step 4: Validate against both data sources
114
+
115
+ For every scenario captured in Step 3, check it against **both**:
116
+
117
+ **(a) The API** (`http://localhost:<port>/ruby_reactor/api/reactors`):
118
+ - `GET /ruby_reactor/api/reactors` — list; confirm the run's reactor/context appears.
119
+ Known limitation to watch for: the storage adapter's `scan_reactors` caps at `count: 50`
120
+ via Redis `SCAN` with no chronological ordering — on a run with 50+ total executions
121
+ (e.g. `demo:all`), some earlier ones can be genuinely absent from the list endpoint even
122
+ though `GET .../<id>` finds them directly by id. Don't assume "missing from the list" ==
123
+ "reactor didn't run" — try the direct id lookup before concluding it's a bug, but if it
124
+ IS a real gap, it's a known gem-level finding (no `limit`/`cursor` param threaded through
125
+ `lib/ruby_reactor/web/api.rb` → `lib/ruby_reactor/storage/redis_adapter.rb` `scan_reactors`).
126
+ - `GET /ruby_reactor/api/reactors/<id>` — confirm `status`, `current_step`,
127
+ `execution_trace` (`steps`), `undo_stack`, `retry_count`/`step_attempts`, `error`,
128
+ `coordination`, and `structure` match the acceptance criterion from Step 2. Compare
129
+ step ordering and dependency edges against `structure`/`depends_on`, not just final
130
+ status.
131
+ - **Cross-check the rake task's own printer branches against every outcome type the
132
+ reactor can actually produce**, not just its final API status. A common bug class here:
133
+ a `run_*` helper in the rake task branches on `DispatchResult` / `success?` / `failure?`
134
+ but is missing a `halted?` (or `skipped?`) branch, so a reactor that legitimately halts
135
+ (e.g. a dedup/period-lock halt, which is `Halt < Success`) falls through to the
136
+ `success?` branch and prints a misleading `✅ SUCCESS: nil` while the API correctly shows
137
+ `status: "halted"`. Read every `run_*`/`report_demo_result` helper the in-scope tasks use
138
+ and check its branch list covers everything the reactor's `signals`/return values allow.
139
+
140
+ **(b) The dashboard** (`http://localhost:<port>/ruby_reactor`):
141
+ - Confirm the root path serves the SPA (HTTP 200, HTML with its mount point) — a broken
142
+ dashboard build is itself a finding.
143
+ - Where practical, fetch the same reactor's dashboard state the UI would render (it is
144
+ backed by the same API, so this mainly confirms the mount + asset pipeline are healthy
145
+ end-to-end, not just the API module in isolation). Note any divergence between what the
146
+ rake stdout claimed and what the API/dashboard actually recorded — that divergence is
147
+ the primary class of bug this skill hunts for.
148
+
149
+ Also check for **coverage gaps**: any reactor in `demo_app/app/reactors/` with no
150
+ corresponding rake task (nothing exercises it), and any acceptance criterion from Step 2
151
+ that neither data source could confirm (e.g. no id was ever discoverable).
152
+
153
+ ## Step 5: For every bug found, propose a fix and a regression test
154
+
155
+ A finding is not done at "here's what's wrong" — for each confirmed bug/error:
156
+
157
+ 1. **Root-cause it**: trace the mismatch to the actual code (a step's outcome, the API
158
+ serializer, storage, the dashboard build) rather than stopping at "API returned X,
159
+ expected Y". Name the file(s)/line(s) responsible.
160
+ 2. **Propose a concrete fix**: a diff-level description (or, if the fix is small and
161
+ unambiguous, apply it — see below) of what changes and why, referencing the gem's
162
+ principles (`.specify/memory/constitution.md`) where relevant (e.g. don't silently
163
+ widen behavior beyond what the demo actually needs).
164
+ 3. **Propose the regression test** that would have caught it, and place it correctly:
165
+ - **Bug in gem behavior** (executor, signals, storage, adapters, DSL, the `Web::API`
166
+ serializer, etc.) → a unit/integration spec under `spec/` (mirror the existing
167
+ layout, e.g. `spec/ruby_reactor/web/` for API bugs, `spec/ruby_reactor/executor/`
168
+ for execution-order bugs) using the gem's normal spec helpers — **not** the
169
+ `RubyReactor::RSpec` demo helpers, those are for `demo_app`.
170
+ - **Bug only reachable through the demo reactor's own definition, the rake task, or
171
+ dashboard integration** → a spec under `demo_app/spec/reactors/`, `type: :reactor`,
172
+ using only the built-in surface from `lib/ruby_reactor/rspec.rb` (see
173
+ `speckit-demo-tests` skill for the exact matcher/helper list and the "no hand-rolled
174
+ scaffolding" rule — apply the same constraint here).
175
+ - If the bug spans both (a gem defect that happens to surface via a demo reactor),
176
+ propose **both**: a gem-level spec pinning the underlying behavior, and a demo spec
177
+ confirming the demo reactor now reports it correctly.
178
+ 4. Do not apply gem-behavior fixes automatically without asking — surface them in the
179
+ report. You MAY apply a fix that is confined to `demo_app/` (a demo reactor, the rake
180
+ task, or a demo spec) directly if it's unambiguous, then note that it was applied and
181
+ re-verify it in this same run. Never silently skip proposing the regression test even
182
+ when you don't apply the fix itself.
183
+
184
+ ## Step 6: Write the report
185
+
186
+ Write findings to `demo_app/tmp/demo_verification/<UTC timestamp>-report.md` (create the
187
+ directory if needed; it's already gitignored via `demo_app/.gitignore`). Structure:
188
+
189
+ ```markdown
190
+ # Demo App E2E Verification — <timestamp>
191
+
192
+ ## Scope
193
+ <tasks/reactors run, port used, docker services status>
194
+
195
+ ## Acceptance criteria checked
196
+ <the Step 2 list, one line per scenario>
197
+
198
+ ## Results
199
+ | Reactor | Scenario | Rake output | API result | Dashboard | Verdict |
200
+ |---|---|---|---|---|---|
201
+ ...
202
+
203
+ ## Errors found
204
+ <concrete mismatches: expected vs actual, with the reactor/context id and relevant
205
+ API JSON excerpt or rake stdout excerpt, plus root cause (file/line)>
206
+
207
+ ## Suggestions / improvements
208
+ <non-broken but worth-fixing observations: missing rake coverage, confusing output,
209
+ missing undo, etc.>
210
+
211
+ ## Fix plan
212
+ <per error: ordered, concrete steps to resolve it — file(s) to touch, what changes,
213
+ whether it was already applied — followed by the regression test proposed for it
214
+ (file path + what it asserts) and whether it was added/passing>
215
+ ```
216
+
217
+ Keep the table terse; put raw JSON/log excerpts in fenced code blocks under "Errors
218
+ found", not inline in the table.
219
+
220
+ ## Step 7: Summarize to the user
221
+
222
+ Report: report file path, pass/fail counts, and the top few errors (if any) inline —
223
+ each with its proposed fix and regression test location — so the user doesn't have to
224
+ open the file to know whether anything broke or what to do about it. If everything
225
+ passed, say so plainly — don't manufacture findings. Do not tear down the docker services
226
+ unless the user asks; mention they're still running.
@@ -0,0 +1,144 @@
1
+ ---
2
+ name: "speckit-demo-tests"
3
+ description: "Generate the demo_app example reactor, rake task, and RSpec spec required by Constitution Principle VI for the current spec-kit feature, or for a context the user describes when no feature is active."
4
+ argument-hint: "Optional: what to demo/test (required if no active speckit feature is detected)"
5
+ compatibility: "Requires spec-kit project structure with .specify/ directory; writes into demo_app/"
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).
19
+
20
+ ## Purpose
21
+
22
+ Constitution Principle VI ("Demo-App Proof of Feature", `.specify/memory/constitution.md`)
23
+ requires every feature to ship three artifacts together: an example reactor in
24
+ `demo_app/app/reactors/`, a task entry in `demo_app/lib/tasks/demo_reactors.rake`, and a
25
+ spec in `demo_app/spec/reactors/` written **only** with the built-in RSpec surface from
26
+ `lib/ruby_reactor/rspec.rb`. This skill produces all three in one pass.
27
+
28
+ ## Step 1: Determine the context
29
+
30
+ Try to detect the current spec-kit feature first; only fall back to the user for context
31
+ if detection fails.
32
+
33
+ 1. Run `.specify/scripts/bash/check-prerequisites.sh --json --paths-only` from the repo
34
+ root and parse `FEATURE_DIR` and `BRANCH`.
35
+ 2. **Speckit detected** if `FEATURE_DIR` resolves to an existing directory under `specs/`
36
+ containing a `spec.md`. In that case:
37
+ - Read `spec.md` (and `plan.md`/`data-model.md` if present) in `FEATURE_DIR`.
38
+ - Extract the functional requirements (`FR-###`) and Given/When/Then acceptance
39
+ scenarios that describe observable, runnable behavior — the ones a reactor run can
40
+ demonstrate (happy path, a failure/compensation path, and any distinctive semantics
41
+ like pause/resume, skip, halt, retry).
42
+ - This extracted behavior is the context for Step 2. Do not ask the user anything
43
+ unless the spec is ambiguous about what a runnable example should exercise.
44
+ 3. **No speckit detected** (script fails, no `FEATURE_DIR`, or no `spec.md`) — the
45
+ feature being tested must come from the user:
46
+ - If `$ARGUMENTS` is non-empty, treat it as the context describing what to build a
47
+ demo/test for.
48
+ - If `$ARGUMENTS` is empty, use `AskUserQuestion` (or a plain question if that tool is
49
+ unavailable) to ask what reactor behavior/feature the demo and spec should cover.
50
+ Do not invent a feature — block here until the user answers.
51
+
52
+ ## Step 2: Study existing conventions before writing anything
53
+
54
+ Read 1-2 existing examples so the new files match house style exactly:
55
+
56
+ - One existing reactor from `demo_app/app/reactors/` whose shape is closest to the new
57
+ one (e.g. a failure/compensation demo, a background demo, an interrupt demo — pick by
58
+ relevance to the context from Step 1).
59
+ - Its matching spec in `demo_app/spec/reactors/`.
60
+ - The full built-in matcher/helper surface: skim `lib/ruby_reactor/rspec.rb` and its
61
+ `lib/ruby_reactor/rspec/*.rb` files (`helpers.rb`, `test_subject.rb`, `matchers.rb`,
62
+ `sidekiq_helpers.rb`, `async_test_helpers.rb`). Confirm the exact set currently
63
+ available — do not rely on a memorized list, the gem may have grown matchers since.
64
+ - The tail of `demo_app/lib/tasks/demo_reactors.rake`, in particular the `desc "All demo
65
+ reactors"` / `task all: [...]` entry — the new task name must be appended to that
66
+ dependency list.
67
+
68
+ ## Step 3: Generate the example reactor
69
+
70
+ Create `demo_app/app/reactors/<name>_reactor.rb` (snake_case file matching the
71
+ `CamelCase` class name), where `<name>` is derived from the context (ask the user only
72
+ if the right name is genuinely ambiguous). Requirements:
73
+
74
+ - Class-based step definitions, matching Development Workflow conventions (no inline
75
+ lambdas as the primary authoring style).
76
+ - Exercises the feature end-to-end: the happy path, and — where the feature has one — a
77
+ failure/compensation path, typically toggled via a `fail_at:`-style input like other
78
+ demo reactors already do.
79
+ - If the feature under test is signal-specific (Halt, Skipped, retry veto, pause/resume,
80
+ locks, rate limits, periods), the example MUST produce that outcome observably (e.g. a
81
+ step that calls `halt!`/`skip!`/`fail!(..., retry: false)` under a controllable input),
82
+ not just something that happens to compile.
83
+ - Do not duplicate an existing demo reactor's purpose — if one already covers this
84
+ behavior, extend it or say so instead of creating a near-duplicate file.
85
+
86
+ ## Step 4: Register the rake task
87
+
88
+ Edit `demo_app/lib/tasks/demo_reactors.rake`:
89
+
90
+ - Add a `desc "<Reactor> — <one-line of what it demonstrates>"` /
91
+ `task <name>: [:environment, :flush_redis] do ... end` block, following the existing
92
+ print-the-outcome pattern (`✅ SUCCESS`, `❌ FAILED`, `⏳ BACKGROUND`, etc. — match
93
+ whatever branch the existing tasks use for `DispatchResult`/`success?`/`failure?`).
94
+ - Run through the same scenarios the spec will assert (e.g. loop over `fail_at`
95
+ values) so `rake demo:<name>` is a working live demonstration on its own.
96
+ - Append `:<name>` to the `task all: [...]` dependency list so it runs as part of
97
+ `rake demo:all`.
98
+
99
+ ## Step 5: Write the spec using only the built-in RSpec surface
100
+
101
+ Create `demo_app/spec/reactors/<name>_reactor_spec.rb`:
102
+
103
+ - `RSpec.describe <ReactorClass>, type: :reactor do ... end`.
104
+ - Build the subject with `test_reactor(described_class, inputs)` (see
105
+ `lib/ruby_reactor/rspec/helpers.rb`).
106
+ - Use only the `TestSubject` API (`lib/ruby_reactor/rspec/test_subject.rb`) — e.g.
107
+ `mock_step`, `failing_at`, `map`, `composed`, `async_step`, `async_reactor`, `resume`,
108
+ `step_result`, `ensure_executed!`, `process_pending_jobs` — and only the matchers
109
+ defined in `lib/ruby_reactor/rspec/matchers.rb` (`be_success`, `be_failure`,
110
+ `have_run_step(...).after(...)`, `have_retried_step`, `have_validation_error`,
111
+ `be_paused`, `be_paused_at`, `have_ready_interrupts`, `be_halted`, `be_skipped`,
112
+ `be_locked`, `have_available_tokens`, `have_held_tokens`, `have_rate_limit_count`,
113
+ `be_period_marked`, and the ordered-lock matchers) plus `drain_async_jobs` /
114
+ `pending_async_jobs` from `sidekiq_helpers.rb` for async paths.
115
+ - **Forbidden**: any direct `RubyReactor::Executor`/`Storage`/Redis call, manual Sidekiq
116
+ job draining, stubbing reactor internals, or any other hand-rolled scaffolding in this
117
+ spec file.
118
+ - **If an assertion needs a matcher or helper that does not exist yet**: add it to the
119
+ appropriate file under `lib/ruby_reactor/rspec/` in this same change (with its own
120
+ unit coverage if the gem's own spec suite conventions call for it), then use it from
121
+ the demo spec. Do not work around the gap with ad-hoc internals — that is what
122
+ Principle VI explicitly forbids.
123
+ - Cover at least: the happy path (`be_success`, step ordering via `have_run_step`), and
124
+ the failure/compensation or signal-specific path identified in Step 1.
125
+
126
+ ## Step 6: Validate
127
+
128
+ Run, from `demo_app/`:
129
+
130
+ - `bundle exec rspec spec/reactors/<name>_reactor_spec.rb` — must pass.
131
+ - `bundle exec rake demo:<name>` — must run cleanly and print observable outcomes.
132
+ - `bundle exec rubocop app/reactors/<name>_reactor.rb lib/tasks/demo_reactors.rake spec/reactors/<name>_reactor_spec.rb` (from the repo root, or wherever the project's rubocop config resolves) — fix violations before finishing.
133
+
134
+ If any command fails, fix the generated files and re-run — do not report success on
135
+ files that don't pass.
136
+
137
+ ## Step 7: Summarize
138
+
139
+ Report to the user:
140
+
141
+ - Files created/edited (reactor, rake task block + `all` list update, spec).
142
+ - How to run the demo (`rake demo:<name>`) and the spec.
143
+ - Whether any new matcher/helper was added to `lib/ruby_reactor/rspec/` and why.
144
+ - Confirmation that validation (Step 6) passed.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.7.0"
2
+ ".": "0.7.1"
3
3
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "feature_directory": "specs/001-background-async-steps"
2
+ "feature_directory": "specs/001-reactor-signal-semantics"
3
3
  }
@@ -1,20 +1,25 @@
1
1
  <!--
2
2
  SYNC IMPACT REPORT
3
3
  ==================
4
- Version change: [unset] → 1.0.0 (MINOR: initial constitution, all principles defined from scratch)
4
+ Version change: 1.1.0 → 1.2.0 (MINOR: Principle VI expanded with a 4th requirement —
5
+ docker-compose.yml currency + docker-run acceptance tests)
5
6
 
6
- Modified principles: N/A (first ratification)
7
+ Modified principles:
8
+ - Principle VI: Demo-App Proof of Feature — added requirement 4, "Docker acceptance
9
+ run" (docker-compose.yml MUST track demo_app's services; demo: rake tasks are the
10
+ project's acceptance suite, runnable via `docker compose run`)
7
11
 
8
- Added sections:
9
- - Core Principles (5 principles)
10
- - Technical Constraints
11
- - Development Workflow
12
- - Governance
12
+ Added sections: none (existing Principle VI section extended)
13
+
14
+ Removed sections: none
13
15
 
14
16
  Templates checked:
15
- - .specify/templates/plan-template.md ✅ Constitution Check gate present, no update needed
16
- - .specify/templates/spec-template.md ✅ Aligned with Saga/library constraints
17
- - .specify/templates/tasks-template.md Task phases align with Red-Green workflow
17
+ - .specify/templates/plan-template.md ✅ Constitution Check gate is generic ("Gates determined
18
+ based on constitution file") no edit required
19
+ - .specify/templates/spec-template.md No principle-specific content no edit required
20
+ - .specify/templates/tasks-template.md ✅ Already covers demo-app polish tasks — no edit required
21
+ - .specify/templates/checklist-template.md ✅ Generic — no edit required
22
+ - .specify/extensions.yml ✅ No before/after_constitution hooks registered
18
23
 
19
24
  Deferred TODOs: none
20
25
  -->
@@ -89,6 +94,58 @@ surprise breaking change in a MINOR bump costs downstream teams debugging time
89
94
  they did not budget for. Simplicity keeps the library auditable and the
90
95
  upgrade path predictable.
91
96
 
97
+ ### VI. Demo-App Proof of Feature (NON-NEGOTIABLE)
98
+
99
+ Every user-facing feature or public API change MUST ship with a runnable example
100
+ in `demo_app/`. Three artifacts are required together — a change is incomplete if
101
+ any one is missing:
102
+
103
+ 1. **Example reactor**: a reactor (or step) demonstrating the feature MUST live in
104
+ `demo_app/app/reactors/`, one file per reactor, named `<snake_case>_reactor.rb`
105
+ matching its class name. The example MUST exercise the feature end to end —
106
+ including its failure and compensation path where the feature has one — and MUST
107
+ use class-based step definitions per the Development Workflow rule.
108
+ 2. **Rake entry**: the example MUST be registered as a task in
109
+ `demo_app/lib/tasks/demo_reactors.rake` under the `demo:` namespace, with a `desc`
110
+ line describing what it demonstrates, and depending on `[:environment, :flush_redis]`
111
+ so each run starts from clean Redis state. The task MUST print observable outcomes
112
+ (success, failure, background dispatch, pause) so an operator can verify behavior
113
+ without a debugger.
114
+ 3. **Spec**: a matching spec MUST live at
115
+ `demo_app/spec/reactors/<reactor>_spec.rb`, declared `type: :reactor`, and MUST use
116
+ **only** the built-in test surface exported by `lib/ruby_reactor/rspec.rb` — the
117
+ helpers (`test_reactor`, `drain_async_jobs`), the `TestSubject` API
118
+ (`mock_step`, `failing_at`, `map`, `composed`, `async_step`, `resume`,
119
+ `step_result`, `ensure_executed!`), and the matchers (`be_success`, `be_failure`,
120
+ `have_run_step(...).after(...)`, `have_retried_step`, `have_validation_error`,
121
+ `be_paused`, `be_paused_at`, `have_ready_interrupts`, `be_halted`, `be_skipped`,
122
+ `be_locked`, `have_available_tokens`, `have_held_tokens`, `have_rate_limit_count`,
123
+ `be_period_marked`, and the ordered-lock matchers).
124
+
125
+ Hand-rolled test scaffolding is forbidden in `demo_app/spec/reactors/`: no direct
126
+ `RubyReactor::Executor`/`Storage` calls, no bespoke Sidekiq draining, no manual Redis
127
+ assertions, no stubbing of reactor internals. If an assertion cannot be expressed with
128
+ the built-in surface, the missing matcher or helper MUST be added to
129
+ `lib/ruby_reactor/rspec/` in the same change — extending the shared test API, not
130
+ bypassing it.
131
+
132
+ 4. **Docker acceptance run**: `docker-compose.yml` MUST stay current with `demo_app`'s
133
+ runtime dependencies (Redis, Sidekiq, the Rails service itself) so that
134
+ `docker compose run --rm demo-app bin/rails demo:<task>` runs the new rake task
135
+ end to end against real Redis, with no manual setup beyond `docker compose up`.
136
+ A new demo service or environment variable required by a feature MUST be added to
137
+ `docker-compose.yml` in the same change. The `demo:` rake tasks in
138
+ `demo_app/lib/tasks/demo_reactors.rake` constitute the project's acceptance test
139
+ suite for user-facing behavior — CI or a release checklist MAY invoke them via
140
+ `docker compose run` to confirm the demo still passes before a MINOR/MAJOR release.
141
+
142
+ **Rationale**: `demo_app/` is the only place the gem is consumed the way users consume
143
+ it. An example that is written but never listed is never run; a spec written with
144
+ private internals passes while the public API is broken. Forcing every feature through
145
+ the public reactor DSL, a runnable rake task, and the shipped matchers means the
146
+ documented API, the demo, and the test surface are validated by the same change — and
147
+ gaps in the matcher library surface as work instead of as workarounds.
148
+
92
149
  ## Technical Constraints
93
150
 
94
151
  - **Ruby**: >= 3.0.0 required. No polyfills for older Rubies.
@@ -113,7 +170,17 @@ upgrade path predictable.
113
170
  - Class-based step definitions are the preferred authoring style (not inline
114
171
  lambdas). Documentation and examples MUST reflect this.
115
172
  - The `demo_app/` directory serves as a living integration example. Changes to
116
- public API surface MUST be reflected there.
173
+ public API surface MUST be reflected there per Principle VI — example reactor in
174
+ `demo_app/app/reactors/`, rake task in `demo_app/lib/tasks/demo_reactors.rake`,
175
+ and a spec in `demo_app/spec/reactors/` using only the built-in RSpec matchers
176
+ and helpers from `lib/ruby_reactor/rspec.rb`.
177
+ - PR reviews MUST reject any feature change whose demo example is missing, unlisted
178
+ in the rake file, or tested with hand-rolled scaffolding instead of the shipped
179
+ matcher library.
180
+ - `docker-compose.yml` MUST be kept current with `demo_app`'s services (Redis,
181
+ Sidekiq, Rails) so `docker compose up` and `docker compose run --rm demo-app
182
+ bin/rails demo:<task>` are the supported way to run the `demo:` rake tasks as
183
+ acceptance tests, with no host-side Ruby/Redis setup required.
117
184
 
118
185
  ## Governance
119
186
 
@@ -131,4 +198,4 @@ justified in the `Complexity Tracking` table of the plan.
131
198
  Compliance review: at each MINOR or MAJOR gem release, confirm this constitution
132
199
  still accurately reflects the codebase and update as needed.
133
200
 
134
- **Version**: 1.0.0 | **Ratified**: 2025-10-02 | **Last Amended**: 2026-06-24
201
+ **Version**: 1.2.0 | **Ratified**: 2025-10-02 | **Last Amended**: 2026-09-09
@@ -158,6 +158,13 @@ Examples of foundational tasks (adjust based on your project):
158
158
  - [ ] TXXX Security hardening
159
159
  - [ ] TXXX Run quickstart.md validation
160
160
 
161
+ **Constitution Principle VI — Demo-App Proof of Feature (required for any public API change):**
162
+
163
+ - [ ] TXXX [P] Example reactor in `demo_app/app/reactors/<name>_reactor.rb`
164
+ - [ ] TXXX Register `demo:<name>` task in `demo_app/lib/tasks/demo_reactors.rake` (desc + `[:environment, :flush_redis]`)
165
+ - [ ] TXXX Spec in `demo_app/spec/reactors/<name>_reactor_spec.rb` (`type: :reactor`), using only built-in matchers/helpers from `lib/ruby_reactor/rspec.rb`
166
+ - [ ] TXXX If an assertion needs a matcher that does not exist, add it to `lib/ruby_reactor/rspec/` — never bypass the shared test API
167
+
161
168
  ---
162
169
 
163
170
  ## Dependencies & Execution Order
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.1](https://github.com/arturictus/ruby_reactor/compare/v0.7.0...v0.7.1) (2026-09-14)
4
+
5
+
6
+ ### Features
7
+
8
+ * reactor signal semantics ([#52](https://github.com/arturictus/ruby_reactor/issues/52)) ([9147d06](https://github.com/arturictus/ruby_reactor/commit/9147d066da603c0afc1af6536b6afac7e165a034))
9
+
3
10
  ## [0.7.0](https://github.com/arturictus/ruby_reactor/compare/v0.6.0...v0.7.0) (2026-09-08)
4
11
 
5
12
 
@@ -120,6 +127,10 @@ was dispatched precisely so the parent would not depend on it. A later step that
120
127
  reads the result and returns `Failure` triggers compensation normally, so no
121
128
  failure is unrecoverable, just not automatic.
122
129
 
130
+ * Reactor signal semantics: `Skipped` is renamed to `Halt` (the existing clean-stop behaviour, unchanged otherwise), and `Skipped` is reused with new meaning — marking a single step skipped while the reactor continues, with its value flowing to dependants exactly like `Success`. One-line outcome helpers `success!`, `fail!`, `skip!`, and `halt!` end a step immediately from any call depth. `Failure` (and `fail!`) accept a `retry:` spelling alongside the existing `retryable:`. `compensate`/`undo` now default to `Skipped` instead of `Success`, so the execution trace distinguishes rollback logic that ran from rollback logic that was never written.
131
+
132
+ **Migration**: `Skipped(reason: "...")` (the old halt) is now `Halt(reason: "...")`; `result.skipped?` for a clean halt is now `result.halted?`; the `be_skipped` matcher for a clean halt is now `be_halted`. The old call shape raises `ArgumentError` naming `Halt` — there is no silent compatibility path. Run status `:skipped` is renamed `:halted`; contexts persisted by a pre-upgrade version with status `"skipped"` are still read back correctly as halted.
133
+
123
134
  ## [0.5.4](https://github.com/arturictus/ruby_reactor/compare/v0.5.3...v0.5.4) (2026-06-18)
124
135
 
125
136
 
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
- at specs/001-background-async-steps/plan.md
3
+ shell commands, and other important information, read the current plan:
4
+ `specs/001-reactor-signal-semantics/plan.md`
5
5
  <!-- SPECKIT END -->
data/README.md CHANGED
@@ -230,11 +230,14 @@ RubyReactor supports two ways to define step logic:
230
230
  | **Class steps** (preferred) | Real business logic, compensation/undo, shared steps, testability |
231
231
  | **Inline blocks** | Quick prototypes, trivial one-liners, documentation examples |
232
232
 
233
- Whichever style you use, a step's `run` returns one of three signals — all exposed as bare helpers in both class steps and inline blocks:
233
+ Whichever style you use, a step's `run` returns one of four signals — all exposed as bare helpers in both class steps and inline blocks:
234
234
 
235
235
  - **`Success(value)`** — step succeeded; `value` flows to dependent steps.
236
236
  - **`Failure(error)`** — step failed; the reactor rolls back completed steps (compensate/undo).
237
- - **`Skipped(reason:)`** — clean halt: stop the reactor, keep partial progress, **no rollback**. See [Skipping a reactor cleanly](documentation/core_concepts.md#skipping-a-reactor-cleanly).
237
+ - **`Halt(reason:)`** — clean halt: stop the reactor, keep partial progress, **no rollback**. See [Halting a reactor cleanly](documentation/core_concepts.md#halting-a-reactor-cleanly).
238
+ - **`Skipped(value)`** — mark this one step skipped; the reactor continues and `value` flows to dependants exactly like `Success`. See [Skipping a single step](documentation/core_concepts.md#skipping-a-single-step).
239
+
240
+ One-line helpers end a step immediately from any call depth: `success!(value)`, `fail!(error, retry: true)`, `halt!(reason:)`, `skip!(value)` — equivalent to `return`ing the matching signal, usable in `run`, `compensate`, and `undo` bodies.
238
241
 
239
242
  **Class steps** are plain Ruby classes that include `RubyReactor::Step` and implement `run`, and optionally `compensate` and `undo`:
240
243
 
@@ -726,7 +729,7 @@ class MonthlyBillingReactor < RubyReactor::Reactor
726
729
  input :org_id
727
730
 
728
731
  # Run at most once per UTC month per org. Subsequent calls in the same month
729
- # return RubyReactor::Skipped without executing any step. Pair with
732
+ # return RubyReactor::Halt without executing any step. Pair with
730
733
  # with_lock for strict at-most-one even under concurrent racers.
731
734
  with_period(every: :month) { |inputs| "monthly_billing:#{inputs[:org_id]}" }
732
735
 
@@ -801,26 +804,38 @@ On contention:
801
804
  - **Inline** (`Reactor.run`) raises `RubyReactor::Lock::AcquisitionError` / `RubyReactor::Semaphore::AcquisitionError` / `RubyReactor::RateLimit::ExceededError` / `RubyReactor::OrderedLock::WaitError`.
802
805
  - **Async** (Sidekiq or ActiveJob) snoozes the job via `perform_in(delay, ...)`. For rate limits the delay uses the error's `retry_after_seconds` hint (precise wakeup — the bucket roll time is known exactly); for locks, semaphores, and ordered-lock waits it's `lock_snooze_base_delay + jitter` (a short re-poll, since a held lock or a live blocker nonce typically clears in milliseconds). Snoozes do not count against the backend's retry budget. After `lock_snooze_max_attempts` snoozes the context is marked failed (ordered-lock waits bypass the cap — see the ordered-lock docs).
803
806
 
804
- On dedup hits (period gate already marked), the reactor returns a `RubyReactor::Skipped` result instead — no steps run, no exception:
807
+ On dedup hits (period gate already marked), the reactor returns a `RubyReactor::Halt` result instead — no steps run, no exception:
805
808
 
806
809
  ```ruby
807
810
  result = MonthlyBillingReactor.run(org_id: 42)
808
- result.success? # true (Skipped is a Success subclass)
809
- result.skipped? # true on dedup hit, false otherwise
811
+ result.success? # true (Halt is a Success subclass)
812
+ result.halted? # true on dedup hit, false otherwise
810
813
  ```
811
814
 
812
- A step's `run` block can also return `Skipped(reason: "...")` to halt the reactor cleanly — remaining steps don't execute, **and already-completed steps are NOT compensated**. Use it when the rest of the workflow is unnecessary and partial progress should be kept (`Failure` is for "stop and roll back"). `Skipped` is a bare helper just like `Success`/`Failure` (or use the fully-qualified `RubyReactor.Skipped(...)`).
815
+ A step's `run` block can also return `Halt(reason: "...")` to stop the reactor cleanly — remaining steps don't execute, **and already-completed steps are NOT compensated**. Use it when the rest of the workflow is unnecessary and partial progress should be kept (`Failure` is for "stop and roll back"). `Halt` is a bare helper just like `Success`/`Failure` (or use the fully-qualified `RubyReactor.Halt(...)`).
813
816
 
814
817
  ```ruby
815
818
  step :ensure_active do
816
819
  argument :user, result(:fetch_user)
817
820
  run do |args, _ctx|
818
- next Skipped(reason: "user_opted_out") if args[:user].opted_out?
821
+ next Halt(reason: "user_opted_out") if args[:user].opted_out?
819
822
  Success(args[:user])
820
823
  end
821
824
  end
822
825
  ```
823
826
 
827
+ To skip a *single* step while the reactor continues — the step did nothing, but the rest of the workflow should still run — return `Skipped(value)` instead. The value flows to dependants exactly like a `Success` value, and the step is not enrolled for rollback:
828
+
829
+ ```ruby
830
+ step :maybe_sync do
831
+ argument :user, result(:fetch_user)
832
+ run do |args, _ctx|
833
+ next Skipped(args[:user]) if args[:user].already_synced?
834
+ Success(sync!(args[:user]))
835
+ end
836
+ end
837
+ ```
838
+
824
839
  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.
825
840
 
826
841
  ### Map & Parallel Execution
@@ -1337,7 +1352,7 @@ Comprehensive guide to testing reactors with RubyReactor's testing utilities. Le
1337
1352
 
1338
1353
  ### [Locks, Semaphores, Rate Limits, Periods & Ordered Locks](documentation/locks_and_semaphores.md)
1339
1354
 
1340
- 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 `Skipped` 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-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.
1355
+ 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-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.
1341
1356
 
1342
1357
  ### [Middlewares & OpenTelemetry](documentation/middlewares.md)
1343
1358