ruby_reactor 0.5.3 → 0.6.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/speckit-agent-context-update/SKILL.md +32 -0
  3. data/.claude/skills/speckit-analyze/SKILL.md +262 -0
  4. data/.claude/skills/speckit-checklist/SKILL.md +374 -0
  5. data/.claude/skills/speckit-clarify/SKILL.md +286 -0
  6. data/.claude/skills/speckit-constitution/SKILL.md +157 -0
  7. data/.claude/skills/speckit-converge/SKILL.md +277 -0
  8. data/.claude/skills/speckit-implement/SKILL.md +224 -0
  9. data/.claude/skills/speckit-plan/SKILL.md +171 -0
  10. data/.claude/skills/speckit-specify/SKILL.md +346 -0
  11. data/.claude/skills/speckit-tasks/SKILL.md +215 -0
  12. data/.claude/skills/speckit-taskstoissues/SKILL.md +110 -0
  13. data/.release-please-manifest.json +1 -1
  14. data/.specify/extensions/.registry +19 -0
  15. data/.specify/extensions/agent-context/README.md +66 -0
  16. data/.specify/extensions/agent-context/agent-context-config.yml +5 -0
  17. data/.specify/extensions/agent-context/commands/speckit.agent-context.update.md +27 -0
  18. data/.specify/extensions/agent-context/extension.yml +34 -0
  19. data/.specify/extensions/agent-context/scripts/bash/update-agent-context.sh +282 -0
  20. data/.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +353 -0
  21. data/.specify/extensions.yml +23 -0
  22. data/.specify/feature.json +3 -0
  23. data/.specify/init-options.json +9 -0
  24. data/.specify/integration.json +15 -0
  25. data/.specify/integrations/claude.manifest.json +17 -0
  26. data/.specify/integrations/speckit.manifest.json +17 -0
  27. data/.specify/memory/constitution.md +134 -0
  28. data/.specify/scripts/bash/check-prerequisites.sh +189 -0
  29. data/.specify/scripts/bash/common.sh +619 -0
  30. data/.specify/scripts/bash/create-new-feature.sh +299 -0
  31. data/.specify/scripts/bash/setup-plan.sh +84 -0
  32. data/.specify/scripts/bash/setup-tasks.sh +91 -0
  33. data/.specify/templates/checklist-template.md +40 -0
  34. data/.specify/templates/constitution-template.md +50 -0
  35. data/.specify/templates/plan-template.md +113 -0
  36. data/.specify/templates/spec-template.md +131 -0
  37. data/.specify/templates/tasks-template.md +252 -0
  38. data/.specify/workflows/speckit/workflow.yml +77 -0
  39. data/.specify/workflows/workflow-registry.json +13 -0
  40. data/CHANGELOG.md +14 -0
  41. data/README.md +146 -65
  42. data/lib/ruby_reactor/adapters/active_job/compat.rb +24 -0
  43. data/lib/ruby_reactor/adapters/active_job/map_collector_worker.rb +19 -0
  44. data/lib/ruby_reactor/adapters/active_job/map_element_worker.rb +19 -0
  45. data/lib/ruby_reactor/adapters/active_job/router.rb +91 -0
  46. data/lib/ruby_reactor/adapters/active_job/sweeper_worker.rb +16 -0
  47. data/lib/ruby_reactor/adapters/active_job/worker.rb +24 -0
  48. data/lib/ruby_reactor/adapters/sidekiq/map_collector_worker.rb +15 -0
  49. data/lib/ruby_reactor/adapters/sidekiq/map_element_worker.rb +15 -0
  50. data/lib/ruby_reactor/adapters/sidekiq/router.rb +91 -0
  51. data/lib/ruby_reactor/adapters/sidekiq/sweeper_worker.rb +19 -0
  52. data/lib/ruby_reactor/adapters/sidekiq/worker.rb +25 -0
  53. data/lib/ruby_reactor/configuration.rb +24 -4
  54. data/lib/ruby_reactor/dsl/template_helpers.rb +7 -1
  55. data/lib/ruby_reactor/map/element_executor.rb +1 -1
  56. data/lib/ruby_reactor/rspec/active_job_helpers.rb +52 -0
  57. data/lib/ruby_reactor/rspec/async_test_helpers.rb +41 -0
  58. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +3 -3
  59. data/lib/ruby_reactor/rspec/test_subject.rb +11 -7
  60. data/lib/ruby_reactor/rspec.rb +4 -0
  61. data/lib/ruby_reactor/step.rb +4 -0
  62. data/lib/ruby_reactor/sweeper_job.rb +70 -0
  63. data/lib/ruby_reactor/version.rb +1 -1
  64. data/lib/ruby_reactor/worker.rb +226 -0
  65. data/lib/ruby_reactor.rb +40 -1
  66. data/specs/active_job.md +259 -0
  67. metadata +54 -6
  68. data/lib/ruby_reactor/sidekiq_adapter.rb +0 -87
  69. data/lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb +0 -13
  70. data/lib/ruby_reactor/sidekiq_workers/map_element_worker.rb +0 -13
  71. data/lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb +0 -73
  72. data/lib/ruby_reactor/sidekiq_workers/worker.rb +0 -222
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ # Framework-agnostic resume/snooze/escalate logic shared by every queueing
5
+ # backend's worker class. Each backend (`Adapters::Sidekiq::Worker`,
6
+ # `Adapters::ActiveJob::Worker`, ...) includes this and supplies its own
7
+ # `self.class.perform_in` (native on Sidekiq::Worker, via
8
+ # `Adapters::ActiveJob::Compat` on ActiveJob::Base) — nothing here references
9
+ # a specific backend.
10
+ module Worker
11
+ # Identity-only payload: storage is the source of truth. Rehydrate the live
12
+ # context from storage by id, then resume. A nil read means the context was
13
+ # swept, expired, or already terminal-and-collected — nothing to resume.
14
+ def perform(context_id, reactor_class_name = nil, snooze_count = 0)
15
+ # Normalize so a nil/omitted name resolves to the same storage key the
16
+ # enqueue path wrote (always via reactor_storage_name). Without this a
17
+ # nil here builds "reactor::context:<id>" and misses the stored
18
+ # "reactor:AnonymousReactor:context:<id>", silently no-op'ing.
19
+ reactor_class_name ||= RubyReactor.reactor_storage_name(nil)
20
+ data = RubyReactor.configuration.storage_adapter.retrieve_context(context_id, reactor_class_name)
21
+ return if data.nil?
22
+
23
+ begin
24
+ context = ContextSerializer.deserialize_hash(data)
25
+ rescue RubyReactor::Error::DeserializationError,
26
+ RubyReactor::Error::SchemaVersionError => e
27
+ # Permanent failures — re-reading the same stored blob will keep
28
+ # failing. Mark the context as failed (best-effort) and return so
29
+ # the job does not burn its retry budget.
30
+ handle_deserialization_failure(context_id, reactor_class_name, e)
31
+ return
32
+ end
33
+
34
+ resolve_reactor_class!(context, reactor_class_name)
35
+ unless context.reactor_class
36
+ # Still unresolved (class not loaded, or an anonymous reactor with no
37
+ # storage name to look up) — Executor.new below would blow up on a nil
38
+ # class and burn the job's retry budget forever. Fail the context now.
39
+ error = RubyReactor::Error::DeserializationError.new(
40
+ "reactor class '#{reactor_class_name}' could not be resolved"
41
+ )
42
+ handle_deserialization_failure(context_id, reactor_class_name, error)
43
+ return
44
+ end
45
+
46
+ # Mark that we're executing inline to prevent nested async calls
47
+ context.inline_async_execution = true
48
+
49
+ begin
50
+ # Resume execution from the failed step
51
+ executor = Executor.new(context.reactor_class, {}, context)
52
+ executor.resume_execution
53
+ # No explicit save here: resume_execution's ensure block already persists
54
+ # the final root state (`save_context unless skip_context_persist?`), and
55
+ # in the worker the executor's context IS the root, so an extra checkpoint!
56
+ # would just re-write the identical blob to the identical key. The
57
+ # skip_context_persist? guard (stale-batch redelivery of an already-terminal
58
+ # context) is likewise honored there.
59
+
60
+ # Return the executor (which now has the result stored in it)
61
+ executor
62
+ rescue RubyReactor::Lock::AcquisitionError,
63
+ RubyReactor::Semaphore::AcquisitionError,
64
+ RubyReactor::RateLimit::ExceededError,
65
+ RubyReactor::OrderedLock::WaitError => e
66
+ # Snooze on expected concurrency, rate, or ordering contention.
67
+ # OrderedLock::WaitError carries a poison-pill-derived retry hint,
68
+ # consumed by compute_snooze_delay below. We avoid the framework's native
69
+ # retry path so this doesn't burn the job's retry budget or appear
70
+ # as an error in dashboards. After the configured cap is reached we
71
+ # escalate by marking the reactor as failed.
72
+ handle_snooze(context_id, reactor_class_name, context, snooze_count, e)
73
+ rescue RubyReactor::RateLimitRegistry::UnknownLimitError => e
74
+ # Permanent configuration error — snoozing or retrying the same job
75
+ # will keep failing. Mark the context failed immediately.
76
+ escalate_snooze(context, snooze_count, e)
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ # If reactor_class_name is provided, use it to get the reactor class.
83
+ # This handles cases where the class can't be found via const_get.
84
+ def resolve_reactor_class!(context, reactor_class_name)
85
+ return unless reactor_class_name && context.reactor_class.nil?
86
+
87
+ begin
88
+ context.reactor_class = Object.const_get(reactor_class_name)
89
+ rescue NameError
90
+ # If not found, try to find it in the current namespace
91
+ # This is a fallback for test environments
92
+ context.reactor_class = reactor_class_name.constantize if reactor_class_name.respond_to?(:constantize)
93
+ end
94
+ end
95
+
96
+ def handle_snooze(context_id, reactor_class_name, context, snooze_count, error)
97
+ config = RubyReactor.configuration
98
+ max = config.lock_snooze_max_attempts
99
+
100
+ # OrderedLock::WaitError bypasses the snooze cap. The gate's
101
+ # poison_pill_timeout is the only meaningful upper bound on how long a
102
+ # nonce can legitimately wait; capping snoozes would either fail jobs
103
+ # prematurely or strand the nonce in `assigned_at` until poison_pill
104
+ # eventually advances past it. Snooze until the gate passes (or poison
105
+ # auto-advance moves the cursor past us).
106
+ # The per-context liveness lock (`async:<id>`) is also uncapped: a
107
+ # duplicate of the *same* execution may wait arbitrarily long for the
108
+ # live original to finish (e.g. a sweeper re-enqueue racing a slow but
109
+ # alive worker). Capping it would fail a legitimately-waiting duplicate.
110
+ capped = !(error.is_a?(RubyReactor::OrderedLock::WaitError) ||
111
+ error.is_a?(RubyReactor::Lock::ContextLockContention))
112
+
113
+ if capped && max != :infinity && snooze_count >= max
114
+ escalate_snooze(context, snooze_count, error)
115
+ return
116
+ end
117
+
118
+ delay = compute_snooze_delay(config, error)
119
+ # Re-enqueue by id: the context is already persisted in storage, so the
120
+ # rescheduled job rehydrates fresh state (no stale blob).
121
+ self.class.perform_in(delay, context_id, reactor_class_name, snooze_count + 1)
122
+ end
123
+
124
+ # Use the error's `retry_after_seconds` hint when available
125
+ # (RateLimit::ExceededError carries the time until the bucket rolls);
126
+ # otherwise fall back to the configured base + jitter for lock/semaphore
127
+ # contention which has no precise hint.
128
+ #
129
+ # OrderedLock::WaitError is deliberately excluded from the hint path: its
130
+ # `retry_after_seconds` is the poison-pill window (the upper bound before
131
+ # a *dead* blocker is force-advanced), NOT how long the *live* blocker
132
+ # will take — which is usually milliseconds. Snoozing for the full window
133
+ # would make every out-of-order nonce sleep up to poison_pill_timeout even
134
+ # though its blocker finishes immediately, collapsing throughput. Re-poll
135
+ # at the base delay instead; poison auto-advance still clears a genuinely
136
+ # dead blocker on a later gate.
137
+ def compute_snooze_delay(config, error)
138
+ jitter = config.lock_snooze_jitter.to_f
139
+ jitter_amount = jitter.positive? ? rand(0.0..jitter) : 0.0
140
+
141
+ if hinted_retry?(error)
142
+ [error.retry_after_seconds.to_f, 0.1].max + jitter_amount
143
+ else
144
+ config.lock_snooze_base_delay.to_f + jitter_amount
145
+ end
146
+ end
147
+
148
+ def hinted_retry?(error)
149
+ return false if error.is_a?(RubyReactor::OrderedLock::WaitError)
150
+
151
+ error.respond_to?(:retry_after_seconds) && error.retry_after_seconds
152
+ end
153
+
154
+ def escalate_snooze(context, snooze_count, error)
155
+ RubyReactor.configuration.logger.warn(
156
+ "RubyReactor snooze limit reached after #{snooze_count} attempts " \
157
+ "for context #{context.context_id}: #{error.message}"
158
+ )
159
+
160
+ context.status = :failed
161
+ context.failure_reason = {
162
+ message: error.message,
163
+ exception_class: error.class.name,
164
+ snooze_attempts: snooze_count
165
+ }
166
+
167
+ serialized = ContextSerializer.serialize(context)
168
+ reactor_class_name = RubyReactor.reactor_storage_name(context.reactor_class)
169
+ RubyReactor.configuration.storage_adapter.store_context(
170
+ context.context_id,
171
+ serialized,
172
+ reactor_class_name
173
+ )
174
+
175
+ # Escalation is a terminal Failure that never reaches the Executor's
176
+ # ensure path, so advance the ordered-lock cursor here. Without this
177
+ # the nonce stays stranded in assigned_at (successors stall for the
178
+ # full poison_pill_timeout) and, worse, the strict-mode chain marker
179
+ # is never recorded — successors would RUN instead of being skipped.
180
+ info = Executor::OrderedLockSupport.info_from(context)
181
+ Executor::OrderedLockSupport.advance_with_retry(info, failed: true) if info
182
+ end
183
+
184
+ def log_infrastructure_failure(msg, exception)
185
+ RubyReactor.configuration.logger.error("RubyReactor infrastructure failure: #{exception.message}")
186
+ RubyReactor.configuration.logger.error("Job details: #{msg.inspect}")
187
+ end
188
+
189
+ # The id-only payload already carries context_id and reactor_class_name, so
190
+ # there is no blob to parse for metadata — just mark the stored context
191
+ # failed (best-effort) so the job stops retrying a permanently-broken blob.
192
+ def handle_deserialization_failure(context_id, reactor_class_name, error)
193
+ RubyReactor.configuration.logger.error(
194
+ "RubyReactor deserialization failure for context " \
195
+ "#{context_id || "unknown"}: #{error.class.name}: #{error.message}"
196
+ )
197
+
198
+ return unless context_id && reactor_class_name
199
+
200
+ payload = build_failed_context_payload(context_id, reactor_class_name, error)
201
+ RubyReactor.configuration.storage_adapter.store_context(
202
+ context_id,
203
+ payload,
204
+ reactor_class_name
205
+ )
206
+ rescue StandardError => e
207
+ # Don't let a persistence failure mask the original deserialization error.
208
+ RubyReactor.configuration.logger.error(
209
+ "RubyReactor failed to persist deserialization failure: #{e.class.name}: #{e.message}"
210
+ )
211
+ end
212
+
213
+ def build_failed_context_payload(context_id, reactor_class_name, error)
214
+ JSON.generate(
215
+ "schema_version" => ContextSerializer::SCHEMA_VERSION,
216
+ "context_id" => context_id,
217
+ "reactor_class" => reactor_class_name,
218
+ "status" => "failed",
219
+ "failure_reason" => {
220
+ "message" => error.message,
221
+ "exception_class" => error.class.name
222
+ }
223
+ )
224
+ end
225
+ end
226
+ end
data/lib/ruby_reactor.rb CHANGED
@@ -26,6 +26,13 @@ rescue LoadError
26
26
  # sidekiq is optional, async features won't be available
27
27
  end
28
28
 
29
+ # Load active_job if available (for the ActiveJob async adapter)
30
+ begin
31
+ require "active_job"
32
+ rescue LoadError
33
+ # active_job is optional, only needed when using the ActiveJob adapter
34
+ end
35
+
29
36
  loader = Zeitwerk::Loader.for_gem
30
37
  loader.inflector.inflect("api" => "API", "rspec" => "RSpec")
31
38
  loader.setup
@@ -356,7 +363,39 @@ module RubyReactor
356
363
  def self.start_sweeper!
357
364
  return unless configuration.sweeper_enabled
358
365
 
359
- SidekiqWorkers::SweeperWorker.schedule_next
366
+ sweeper_job_class.schedule_next
367
+ end
368
+
369
+ # The sweeper job class living alongside the configured `async_router`
370
+ # (e.g. `Adapters::Sidekiq::Router` -> `Adapters::Sidekiq::SweeperWorker`),
371
+ # so the chain is kicked through whichever backend is configured instead of
372
+ # a hardcoded Sidekiq class. Known built-in routers are mapped explicitly;
373
+ # a custom `configuration.async_router` falls back to a sibling-namespace
374
+ # lookup, with a clear error (instead of a bare NameError) when that
375
+ # convention doesn't hold.
376
+ def self.sweeper_job_class
377
+ router = configuration.async_router
378
+ case router.name
379
+ when "RubyReactor::Adapters::Sidekiq::Router" then Adapters::Sidekiq::SweeperWorker
380
+ when "RubyReactor::Adapters::ActiveJob::Router" then Adapters::ActiveJob::SweeperWorker
381
+ else
382
+ inferred_sweeper_job_class(router)
383
+ end
384
+ end
385
+
386
+ def self.inferred_sweeper_job_class(router)
387
+ namespace_name = router.name.to_s.rpartition("::").first
388
+ raise sweeper_job_class_error(router) if namespace_name.empty?
389
+
390
+ Object.const_get(namespace_name).const_get(:SweeperWorker)
391
+ rescue NameError
392
+ raise sweeper_job_class_error(router)
393
+ end
394
+
395
+ def self.sweeper_job_class_error(router)
396
+ "RubyReactor: cannot infer a sweeper job class for custom async_router " \
397
+ "#{router.inspect}. Define a `SweeperWorker` class alongside it " \
398
+ "(same namespace), or override `RubyReactor.sweeper_job_class`."
360
399
  end
361
400
 
362
401
  # Run both recovery sweepers exactly once and return their counts. The
@@ -0,0 +1,259 @@
1
+ # Pluggable Background Adapters (Sidekiq → ActiveJob)
2
+
3
+ ## Goal
4
+
5
+ Today RubyReactor is hardwired to Sidekiq for everything async: enqueuing,
6
+ resuming, map-element fan-out/collection, and the recovery sweeper. We want
7
+ to support other background processors — starting with ActiveJob — without
8
+ duplicating the reactor-resume/snooze/escalate logic per adapter.
9
+
10
+ ## What's already abstracted (good news)
11
+
12
+ The **enqueue side** is already behind a seam:
13
+
14
+ - `RubyReactor.configuration.async_router` (default `RubyReactor::Adapters::Sidekiq::Router`,
15
+ [configuration.rb:126-128](../lib/ruby_reactor/configuration.rb#L126-L128)) is the
16
+ only thing the core engine calls to go async. Call sites:
17
+ [reactor.rb:117](../lib/ruby_reactor/reactor.rb#L117),
18
+ [reactor.rb:319](../lib/ruby_reactor/reactor.rb#L319),
19
+ [step/map_step.rb:274](../lib/ruby_reactor/step/map_step.rb#L274),
20
+ [step/map_step.rb:285](../lib/ruby_reactor/step/map_step.rb#L285),
21
+ [map/dispatcher.rb:177](../lib/ruby_reactor/map/dispatcher.rb#L177),
22
+ [map/element_executor.rb:155](../lib/ruby_reactor/map/element_executor.rb#L155),
23
+ [map/element_executor.rb:176](../lib/ruby_reactor/map/element_executor.rb#L176),
24
+ [executor/retry_manager.rb:59,80](../lib/ruby_reactor/executor/retry_manager.rb#L59),
25
+ [executor/step_executor.rb:219](../lib/ruby_reactor/executor/step_executor.rb#L219),
26
+ [sweeper.rb:48](../lib/ruby_reactor/sweeper.rb#L48),
27
+ [map/sweeper.rb:99](../lib/ruby_reactor/map/sweeper.rb#L99).
28
+ - The router contract is 5 class methods on `SidekiqAdapter`
29
+ ([sidekiq_adapter.rb](../lib/ruby_reactor/sidekiq_adapter.rb)):
30
+ `perform_async`, `perform_in`, `perform_map_element_async`,
31
+ `perform_map_element_in`, `perform_map_collection_async`. All return
32
+ `RubyReactor::AsyncResult`.
33
+ - An adapter for any other queueing backend just needs to implement that
34
+ same 5-method contract and assign it to `config.async_router`. **This part
35
+ needs no rework.**
36
+
37
+ ## What's NOT abstracted (the actual gap)
38
+
39
+ The **worker/job side** — the classes the queue invokes — bakes Sidekiq in
40
+ directly. **Decision: these move into `RubyReactor::Adapters::Sidekiq::*`**
41
+ (renamed from `RubyReactor::SidekiqWorkers::*`), with a sibling
42
+ `RubyReactor::Adapters::ActiveJob::*` for the new adapter — see
43
+ [Namespace](#namespace) below.
44
+
45
+ | Class (today) | File | Sidekiq coupling |
46
+ |---|---|---|
47
+ | `SidekiqWorkers::Worker` | [worker.rb](../lib/ruby_reactor/sidekiq_workers/worker.rb) | `include ::Sidekiq::Worker`, `sidekiq_options`, `sidekiq_retries_exhausted`, and internally calls `self.class.perform_in(...)` to reschedule snoozes (lines 116, 91-117) |
48
+ | `SidekiqWorkers::MapElementWorker` | [map_element_worker.rb](../lib/ruby_reactor/sidekiq_workers/map_element_worker.rb) | `include ::Sidekiq::Worker`; body is a 1-line delegate to `Map::ElementExecutor.perform` |
49
+ | `SidekiqWorkers::MapCollectorWorker` | [map_collector_worker.rb](../lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb) | same — 1-line delegate to `Map::Collector.perform` |
50
+ | `SidekiqWorkers::SweeperWorker` | [sweeper_worker.rb](../lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb) | `include ::Sidekiq::Worker`, `sidekiq_options retry: false`, self-reschedules via `perform_in` / class-level `perform_in` in `schedule_next` |
51
+
52
+ Plus test-side coupling:
53
+
54
+ - [rspec/sidekiq_helpers.rb](../lib/ruby_reactor/rspec/sidekiq_helpers.rb) hardcodes
55
+ the 3 Sidekiq worker classes and `Sidekiq::Testing` fake-mode draining.
56
+ - [rspec/test_subject.rb:194-203,433](../lib/ruby_reactor/rspec/test_subject.rb#L194-L203)
57
+ gates job-processing on `defined?(Sidekiq::Testing)` and forces
58
+ `async_router` back to `SidekiqAdapter`.
59
+ - [ruby_reactor.rb:22-27](../lib/ruby_reactor.rb#L22-L27) optionally requires
60
+ `sidekiq`; [ruby_reactor.rb:359](../lib/ruby_reactor.rb#L359) calls
61
+ `SidekiqWorkers::SweeperWorker.schedule_next` directly from
62
+ `RubyReactor.start_sweeper!`.
63
+
64
+ The real logic worth extracting lives almost entirely in
65
+ `SidekiqWorkers::Worker#perform` (~65 lines): rehydrate context from
66
+ storage, deserialize, resolve `reactor_class`, mark
67
+ `inline_async_execution`, run `Executor#resume_execution`, and on
68
+ lock/semaphore/rate-limit/ordered-lock contention either snooze (re-enqueue
69
+ with a computed delay) or escalate to `failed`. None of that is
70
+ Sidekiq-specific — it only *touches* Sidekiq via `self.class.perform_in` to
71
+ reschedule.
72
+
73
+ ## Proposal
74
+
75
+ ### 1. Normalize the enqueue API at the job-class boundary, not in the shared logic
76
+
77
+ `Sidekiq::Worker` gives every job class `.perform_async` / `.perform_in` for
78
+ free. ActiveJob doesn't — it has `.perform_later` and
79
+ `.set(wait: delay).perform_later`. Rather than teach the shared logic two
80
+ different reschedule calls, give every framework-specific job class the
81
+ same two class methods, so the shared mixin can keep calling
82
+ `self.class.perform_in(...)` unchanged:
83
+
84
+ ```ruby
85
+ module RubyReactor
86
+ module Adapters
87
+ module ActiveJob
88
+ module Compat
89
+ def perform_async(*args) = perform_later(*args)
90
+ def perform_in(delay, *args) = set(wait: delay).perform_later(*args)
91
+ end
92
+ end
93
+ end
94
+ end
95
+ ```
96
+
97
+ ### 2. Extract `RubyReactor::Worker` — the framework-agnostic mixin
98
+
99
+ Move the body of `Adapters::Sidekiq::Worker#perform` (and its private snooze/
100
+ escalate/deserialization-failure helpers) into a plain module with no
101
+ `Sidekiq` reference:
102
+
103
+ ```ruby
104
+ module RubyReactor
105
+ module Worker
106
+ def perform(context_id, reactor_class_name = nil, snooze_count = 0)
107
+ # ...exact same logic as today's SidekiqWorkers::Worker#perform...
108
+ end
109
+
110
+ private
111
+ # handle_snooze, compute_snooze_delay, hinted_retry?, escalate_snooze,
112
+ # log_infrastructure_failure, handle_deserialization_failure,
113
+ # build_failed_context_payload — unchanged, moved verbatim.
114
+ end
115
+ end
116
+ ```
117
+
118
+ `Adapters::Sidekiq::Worker` then becomes:
119
+
120
+ ```ruby
121
+ module RubyReactor
122
+ module Adapters
123
+ module Sidekiq
124
+ class Worker
125
+ include ::Sidekiq::Worker
126
+ include RubyReactor::Worker
127
+
128
+ sidekiq_options retry: RubyReactor.configuration.sidekiq_retry_count,
129
+ dead: false, queue: RubyReactor.configuration.sidekiq_queue
130
+ end
131
+ end
132
+ end
133
+ end
134
+ ```
135
+
136
+ And a new `Adapters::ActiveJob::Worker`:
137
+
138
+ ```ruby
139
+ module RubyReactor
140
+ module Adapters
141
+ module ActiveJob
142
+ class Worker < ::ActiveJob::Base
143
+ extend Compat
144
+ include RubyReactor::Worker
145
+
146
+ queue_as { RubyReactor.configuration.sidekiq_queue } # or a renamed generic config
147
+ end
148
+ end
149
+ end
150
+ end
151
+ ```
152
+
153
+ Same pattern applies to `MapElementWorker` / `MapCollectorWorker` — they're
154
+ already a 1-line delegate, so genericizing is just swapping the include; no
155
+ logic to extract.
156
+
157
+ ### 3. Sweeper
158
+
159
+ `SweeperWorker`'s window-claim-lock + self-reschedule logic
160
+ ([sweeper_worker.rb:30-70](../lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb#L30-L70))
161
+ is also framework-agnostic except for the `perform_in` call in
162
+ `schedule_next`. Extract the same way into `RubyReactor::SweeperJob`,
163
+ included into both `Adapters::Sidekiq::SweeperWorker` (`sidekiq_options retry: false`)
164
+ and an `Adapters::ActiveJob::SweeperWorker`. `RubyReactor.start_sweeper!`
165
+ ([ruby_reactor.rb:356-360](../lib/ruby_reactor.rb#L356-L360)) needs to call
166
+ through whichever sweeper job class matches the configured adapter instead
167
+ of hardcoding `Adapters::Sidekiq::SweeperWorker`.
168
+
169
+ ### 4. New `RubyReactor::Adapters::ActiveJob::Router`
170
+
171
+ Mirrors today's `RubyReactor::Adapters::Sidekiq::Router` (renamed from
172
+ `SidekiqAdapter`) exactly — same 5 methods, just pointing at the
173
+ `Adapters::ActiveJob::*` job classes instead of `Adapters::Sidekiq::*`. No
174
+ changes needed to any core call site; swap is purely
175
+ `config.async_router = RubyReactor::Adapters::ActiveJob::Router`.
176
+
177
+ ### 5. Optional config sugar
178
+
179
+ `configuration.rb` already does this pattern for storage
180
+ ([configuration.rb:114-121](../lib/ruby_reactor/configuration.rb#L114-L121)):
181
+ a single `storage.adapter` symbol resolves to a concrete adapter instance.
182
+ Could mirror it — `config.queue_adapter = :sidekiq | :active_job` resolving
183
+ both `async_router` and the sweeper job class — but this is sugar, not
184
+ required for the feature to work. Confirm whether you want it.
185
+
186
+ ### 6. Test helpers
187
+
188
+ `rspec/sidekiq_helpers.rb` and the `Sidekiq::Testing` branch in
189
+ `rspec/test_subject.rb` only fire for Sidekiq today. For ActiveJob, Rails
190
+ already ships `ActiveJob::TestHelper` (`perform_enqueued_jobs`,
191
+ `have_enqueued_job`) which covers most of this generically. We'd still want
192
+ something equivalent to `drain_async_jobs` (loops until self-rescheduling
193
+ jobs — e.g. ordered-lock snoozes — stop producing new ones), so
194
+ `test_subject.rb`'s job-processing gate needs to branch on which
195
+ testing framework is active (or be driven by `configuration.async_router`)
196
+ rather than hardcoding `defined?(Sidekiq::Testing)`.
197
+
198
+ ### 7. Loading
199
+
200
+ `ruby_reactor.rb:22-27` optionally `require "sidekiq"`. Add the same
201
+ optional-require pattern for `active_job`, so neither dependency is forced
202
+ on users who only need one.
203
+
204
+ ## Namespace
205
+
206
+ **Decided: `RubyReactor::Adapters::Sidekiq::*` / `RubyReactor::Adapters::ActiveJob::*`.**
207
+
208
+ Renames/moves required (mechanical, but touches every reference):
209
+
210
+ | Today | Becomes |
211
+ |---|---|
212
+ | `RubyReactor::SidekiqAdapter` | `RubyReactor::Adapters::Sidekiq::Router` |
213
+ | `RubyReactor::SidekiqWorkers::Worker` | `RubyReactor::Adapters::Sidekiq::Worker` |
214
+ | `RubyReactor::SidekiqWorkers::MapElementWorker` | `RubyReactor::Adapters::Sidekiq::MapElementWorker` |
215
+ | `RubyReactor::SidekiqWorkers::MapCollectorWorker` | `RubyReactor::Adapters::Sidekiq::MapCollectorWorker` |
216
+ | `RubyReactor::SidekiqWorkers::SweeperWorker` | `RubyReactor::Adapters::Sidekiq::SweeperWorker` |
217
+ | (new) | `RubyReactor::Adapters::ActiveJob::Router` |
218
+ | (new) | `RubyReactor::Adapters::ActiveJob::{Worker,MapElementWorker,MapCollectorWorker,SweeperWorker}` |
219
+
220
+ Files move from `lib/ruby_reactor/sidekiq_workers/*.rb` +
221
+ `lib/ruby_reactor/sidekiq_adapter.rb` to
222
+ `lib/ruby_reactor/adapters/sidekiq/*.rb` (Zeitwerk-driven, so the directory
223
+ move IS the rename — no manual `module` boilerplate beyond nesting). New
224
+ ActiveJob side lives in `lib/ruby_reactor/adapters/active_job/*.rb`.
225
+
226
+ References that need updating for the rename:
227
+ - [configuration.rb:107](../lib/ruby_reactor/configuration.rb#L107) — `@async_router ||= RubyReactor::SidekiqAdapter`
228
+ - [ruby_reactor.rb:359](../lib/ruby_reactor.rb#L359) — `SidekiqWorkers::SweeperWorker.schedule_next`
229
+ - [rspec/sidekiq_helpers.rb](../lib/ruby_reactor/rspec/sidekiq_helpers.rb) — `worker_classes` list
230
+ - [rspec/test_subject.rb:196](../lib/ruby_reactor/rspec/test_subject.rb#L196) — stub target
231
+ - [spec/ruby_reactor/sidekiq_workers/worker_spec.rb](../spec/ruby_reactor/sidekiq_workers/worker_spec.rb),
232
+ [spec/ruby_reactor/sidekiq_workers/sweeper_worker_spec.rb](../spec/ruby_reactor/sidekiq_workers/sweeper_worker_spec.rb) —
233
+ `described_class` references, move to `spec/ruby_reactor/adapters/sidekiq/`
234
+
235
+ ## Retry config — decided: generic
236
+
237
+ `sidekiq_retry_count` / `sidekiq_queue` rename to `config.job_retry_count` /
238
+ `config.queue_name`, used by both adapters:
239
+
240
+ - `Adapters::Sidekiq::Worker` → `sidekiq_options retry: config.job_retry_count, dead: false, queue: config.queue_name`
241
+ - `Adapters::ActiveJob::Worker` → maps to `retry_on StandardError, attempts: config.job_retry_count` (infra
242
+ failures only — reactor-specific errors are already caught by the shared
243
+ snooze/escalate logic in `RubyReactor::Worker` before they'd ever reach
244
+ the framework's retry layer) and `queue_as { config.queue_name }`.
245
+ - `sidekiq_retries_exhausted` (currently an empty hook) → ActiveJob
246
+ equivalent is `retry_on ... do |job, error| ... end` / `discard_on`.
247
+
248
+ `configuration.rb` changes: add `job_retry_count`/`queue_name` as the
249
+ canonical attrs; keep `sidekiq_retry_count`/`sidekiq_queue` as deprecated
250
+ aliases delegating to the new names so existing configs don't break.
251
+
252
+ None of this requires touching the enqueue-side call sites in
253
+ `reactor.rb`, `executor/*`, `map/*` — that seam already works (it just calls
254
+ through `config.async_router`, whose value changes, not its call sites).
255
+ The work is isolated to: `lib/ruby_reactor/worker.rb` (new),
256
+ `lib/ruby_reactor/sweeper_job.rb` (new), the `sidekiq_workers/` →
257
+ `adapters/sidekiq/` move + rename, `adapters/active_job/*.rb` (new, 5
258
+ classes: `Router` + 4 job classes), and the test-helper /
259
+ `start_sweeper!` branching described above.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_reactor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.3
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Artur
@@ -88,16 +88,64 @@ executables: []
88
88
  extensions: []
89
89
  extra_rdoc_files: []
90
90
  files:
91
+ - ".claude/skills/speckit-agent-context-update/SKILL.md"
92
+ - ".claude/skills/speckit-analyze/SKILL.md"
93
+ - ".claude/skills/speckit-checklist/SKILL.md"
94
+ - ".claude/skills/speckit-clarify/SKILL.md"
95
+ - ".claude/skills/speckit-constitution/SKILL.md"
96
+ - ".claude/skills/speckit-converge/SKILL.md"
97
+ - ".claude/skills/speckit-implement/SKILL.md"
98
+ - ".claude/skills/speckit-plan/SKILL.md"
99
+ - ".claude/skills/speckit-specify/SKILL.md"
100
+ - ".claude/skills/speckit-tasks/SKILL.md"
101
+ - ".claude/skills/speckit-taskstoissues/SKILL.md"
91
102
  - ".release-please-config.json"
92
103
  - ".release-please-manifest.json"
93
104
  - ".rspec"
94
105
  - ".rubocop.yml"
106
+ - ".specify/extensions.yml"
107
+ - ".specify/extensions/.registry"
108
+ - ".specify/extensions/agent-context/README.md"
109
+ - ".specify/extensions/agent-context/agent-context-config.yml"
110
+ - ".specify/extensions/agent-context/commands/speckit.agent-context.update.md"
111
+ - ".specify/extensions/agent-context/extension.yml"
112
+ - ".specify/extensions/agent-context/scripts/bash/update-agent-context.sh"
113
+ - ".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1"
114
+ - ".specify/feature.json"
115
+ - ".specify/init-options.json"
116
+ - ".specify/integration.json"
117
+ - ".specify/integrations/claude.manifest.json"
118
+ - ".specify/integrations/speckit.manifest.json"
119
+ - ".specify/memory/constitution.md"
120
+ - ".specify/scripts/bash/check-prerequisites.sh"
121
+ - ".specify/scripts/bash/common.sh"
122
+ - ".specify/scripts/bash/create-new-feature.sh"
123
+ - ".specify/scripts/bash/setup-plan.sh"
124
+ - ".specify/scripts/bash/setup-tasks.sh"
125
+ - ".specify/templates/checklist-template.md"
126
+ - ".specify/templates/constitution-template.md"
127
+ - ".specify/templates/plan-template.md"
128
+ - ".specify/templates/spec-template.md"
129
+ - ".specify/templates/tasks-template.md"
130
+ - ".specify/workflows/speckit/workflow.yml"
131
+ - ".specify/workflows/workflow-registry.json"
95
132
  - ".tool-versions"
96
133
  - CHANGELOG.md
97
134
  - CODE_OF_CONDUCT.md
98
135
  - README.md
99
136
  - Rakefile
100
137
  - lib/ruby_reactor.rb
138
+ - lib/ruby_reactor/adapters/active_job/compat.rb
139
+ - lib/ruby_reactor/adapters/active_job/map_collector_worker.rb
140
+ - lib/ruby_reactor/adapters/active_job/map_element_worker.rb
141
+ - lib/ruby_reactor/adapters/active_job/router.rb
142
+ - lib/ruby_reactor/adapters/active_job/sweeper_worker.rb
143
+ - lib/ruby_reactor/adapters/active_job/worker.rb
144
+ - lib/ruby_reactor/adapters/sidekiq/map_collector_worker.rb
145
+ - lib/ruby_reactor/adapters/sidekiq/map_element_worker.rb
146
+ - lib/ruby_reactor/adapters/sidekiq/router.rb
147
+ - lib/ruby_reactor/adapters/sidekiq/sweeper_worker.rb
148
+ - lib/ruby_reactor/adapters/sidekiq/worker.rb
101
149
  - lib/ruby_reactor/configuration.rb
102
150
  - lib/ruby_reactor/context.rb
103
151
  - lib/ruby_reactor/context_serializer.rb
@@ -150,6 +198,8 @@ files:
150
198
  - lib/ruby_reactor/retry_context.rb
151
199
  - lib/ruby_reactor/retry_queued_result.rb
152
200
  - lib/ruby_reactor/rspec.rb
201
+ - lib/ruby_reactor/rspec/active_job_helpers.rb
202
+ - lib/ruby_reactor/rspec/async_test_helpers.rb
153
203
  - lib/ruby_reactor/rspec/helpers.rb
154
204
  - lib/ruby_reactor/rspec/matchers.rb
155
205
  - lib/ruby_reactor/rspec/sidekiq_helpers.rb
@@ -157,11 +207,6 @@ files:
157
207
  - lib/ruby_reactor/rspec/storage_reset.rb
158
208
  - lib/ruby_reactor/rspec/test_subject.rb
159
209
  - lib/ruby_reactor/semaphore.rb
160
- - lib/ruby_reactor/sidekiq_adapter.rb
161
- - lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb
162
- - lib/ruby_reactor/sidekiq_workers/map_element_worker.rb
163
- - lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb
164
- - lib/ruby_reactor/sidekiq_workers/worker.rb
165
210
  - lib/ruby_reactor/step.rb
166
211
  - lib/ruby_reactor/step/compose_step.rb
167
212
  - lib/ruby_reactor/step/map_step.rb
@@ -171,6 +216,7 @@ files:
171
216
  - lib/ruby_reactor/storage/redis_locking.rb
172
217
  - lib/ruby_reactor/storage/redis_ordered_locking.rb
173
218
  - lib/ruby_reactor/sweeper.rb
219
+ - lib/ruby_reactor/sweeper_job.rb
174
220
  - lib/ruby_reactor/template/base.rb
175
221
  - lib/ruby_reactor/template/dynamic_source.rb
176
222
  - lib/ruby_reactor/template/element.rb
@@ -191,9 +237,11 @@ files:
191
237
  - lib/ruby_reactor/web/public/assets/index-D7IBZvos.js
192
238
  - lib/ruby_reactor/web/public/index.html
193
239
  - lib/ruby_reactor/web/public/vite.svg
240
+ - lib/ruby_reactor/worker.rb
194
241
  - llms-full.txt
195
242
  - llms.txt
196
243
  - sig/ruby_reactor.rbs
244
+ - specs/active_job.md
197
245
  - teley/Dockerfile
198
246
  homepage: https://github.com/arturictus/ruby_reactor
199
247
  licenses: []