ruby_reactor 0.5.4 → 0.7.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 (128) 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 +121 -1
  41. data/CLAUDE.md +5 -0
  42. data/README.md +181 -39
  43. data/lib/ruby_reactor/adapters/active_job/compat.rb +24 -0
  44. data/lib/ruby_reactor/adapters/active_job/map_collector_worker.rb +19 -0
  45. data/lib/ruby_reactor/adapters/active_job/map_element_worker.rb +19 -0
  46. data/lib/ruby_reactor/adapters/active_job/router.rb +106 -0
  47. data/lib/ruby_reactor/adapters/active_job/step_worker.rb +20 -0
  48. data/lib/ruby_reactor/adapters/active_job/sweeper_worker.rb +16 -0
  49. data/lib/ruby_reactor/adapters/active_job/worker.rb +29 -0
  50. data/lib/ruby_reactor/adapters/sidekiq/map_collector_worker.rb +15 -0
  51. data/lib/ruby_reactor/adapters/sidekiq/map_element_worker.rb +15 -0
  52. data/lib/ruby_reactor/adapters/sidekiq/router.rb +106 -0
  53. data/lib/ruby_reactor/adapters/sidekiq/step_worker.rb +17 -0
  54. data/lib/ruby_reactor/adapters/sidekiq/sweeper_worker.rb +19 -0
  55. data/lib/ruby_reactor/adapters/sidekiq/worker.rb +28 -0
  56. data/lib/ruby_reactor/async_waiter.rb +113 -0
  57. data/lib/ruby_reactor/configuration.rb +54 -5
  58. data/lib/ruby_reactor/context.rb +21 -0
  59. data/lib/ruby_reactor/context_serializer.rb +2 -0
  60. data/lib/ruby_reactor/dsl/async_macros.rb +176 -0
  61. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +66 -0
  62. data/lib/ruby_reactor/dsl/compose_builder.rb +23 -4
  63. data/lib/ruby_reactor/dsl/interrupt_builder.rb +3 -2
  64. data/lib/ruby_reactor/dsl/interrupt_step_config.rb +7 -1
  65. data/lib/ruby_reactor/dsl/lockable.rb +1 -1
  66. data/lib/ruby_reactor/dsl/map_builder.rb +1 -2
  67. data/lib/ruby_reactor/dsl/reactor.rb +29 -10
  68. data/lib/ruby_reactor/dsl/step_builder.rb +30 -11
  69. data/lib/ruby_reactor/error/async_result_pending.rb +21 -0
  70. data/lib/ruby_reactor/error/async_wait_timeout_error.rb +10 -0
  71. data/lib/ruby_reactor/error/deprecated_dsl_error.rb +11 -0
  72. data/lib/ruby_reactor/executor/async_step_dispatch.rb +110 -0
  73. data/lib/ruby_reactor/executor/ordered_lock_support.rb +2 -2
  74. data/lib/ruby_reactor/executor/result_handler.rb +12 -1
  75. data/lib/ruby_reactor/executor/retry_manager.rb +9 -5
  76. data/lib/ruby_reactor/executor/step_executor.rb +58 -11
  77. data/lib/ruby_reactor/executor.rb +162 -8
  78. data/lib/ruby_reactor/lock.rb +19 -0
  79. data/lib/ruby_reactor/map/element_executor.rb +7 -2
  80. data/lib/ruby_reactor/map/helpers.rb +1 -1
  81. data/lib/ruby_reactor/map/result_enumerator.rb +5 -1
  82. data/lib/ruby_reactor/map/result_summary.rb +63 -0
  83. data/lib/ruby_reactor/open_telemetry.rb +1 -1
  84. data/lib/ruby_reactor/reactor.rb +25 -2
  85. data/lib/ruby_reactor/rspec/active_job_helpers.rb +52 -0
  86. data/lib/ruby_reactor/rspec/async_test_helpers.rb +41 -0
  87. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +4 -3
  88. data/lib/ruby_reactor/rspec/step_executor_patch.rb +2 -2
  89. data/lib/ruby_reactor/rspec/test_subject.rb +77 -18
  90. data/lib/ruby_reactor/rspec.rb +4 -0
  91. data/lib/ruby_reactor/semaphore.rb +10 -0
  92. data/lib/ruby_reactor/step/async_reactor_step.rb +207 -0
  93. data/lib/ruby_reactor/step/compose_step.rb +1 -1
  94. data/lib/ruby_reactor/step/map_step.rb +1 -1
  95. data/lib/ruby_reactor/step_sweeper.rb +72 -0
  96. data/lib/ruby_reactor/step_worker.rb +260 -0
  97. data/lib/ruby_reactor/storage/adapter.rb +22 -1
  98. data/lib/ruby_reactor/storage/redis_adapter.rb +17 -13
  99. data/lib/ruby_reactor/storage/redis_locking.rb +7 -0
  100. data/lib/ruby_reactor/storage/redis_pub_sub.rb +31 -0
  101. data/lib/ruby_reactor/storage/redis_step_results.rb +49 -0
  102. data/lib/ruby_reactor/sweeper.rb +7 -1
  103. data/lib/ruby_reactor/sweeper_job.rb +71 -0
  104. data/lib/ruby_reactor/template/result.rb +151 -5
  105. data/lib/ruby_reactor/version.rb +1 -1
  106. data/lib/ruby_reactor/web/api.rb +94 -16
  107. data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +1 -0
  108. data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +22 -0
  109. data/lib/ruby_reactor/web/public/index.html +2 -2
  110. data/lib/ruby_reactor/worker.rb +276 -0
  111. data/lib/ruby_reactor.rb +64 -4
  112. data/specs/001-background-async-steps/checklists/requirements.md +39 -0
  113. data/specs/001-background-async-steps/contracts/public-dsl.md +154 -0
  114. data/specs/001-background-async-steps/data-model.md +117 -0
  115. data/specs/001-background-async-steps/plan.md +168 -0
  116. data/specs/001-background-async-steps/quickstart.md +102 -0
  117. data/specs/001-background-async-steps/research.md +150 -0
  118. data/specs/001-background-async-steps/spec.md +146 -0
  119. data/specs/001-background-async-steps/tasks.md +271 -0
  120. data/specs/active_job.md +259 -0
  121. metadata +80 -8
  122. data/lib/ruby_reactor/sidekiq_adapter.rb +0 -87
  123. data/lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb +0 -13
  124. data/lib/ruby_reactor/sidekiq_workers/map_element_worker.rb +0 -13
  125. data/lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb +0 -73
  126. data/lib/ruby_reactor/sidekiq_workers/worker.rb +0 -222
  127. data/lib/ruby_reactor/web/public/assets/index-CCnNVQy5.css +0 -1
  128. data/lib/ruby_reactor/web/public/assets/index-D7IBZvos.js +0 -21
@@ -5,8 +5,8 @@
5
5
  <link rel="icon" type="image/svg+xml" href="./vite.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>ui</title>
8
- <script type="module" crossorigin src="./assets/index-D7IBZvos.js"></script>
9
- <link rel="stylesheet" crossorigin href="./assets/index-CCnNVQy5.css">
8
+ <script type="module" crossorigin src="./assets/index-DPmP4yXT.js"></script>
9
+ <link rel="stylesheet" crossorigin href="./assets/index-B46p-M6K.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
@@ -0,0 +1,276 @@
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
+ TERMINAL_STATUSES = %w[completed failed cancelled skipped].freeze
12
+
13
+ # Last line of observability when a job burns its whole retry budget on an
14
+ # infrastructure failure and the backend then discards it (Sidekiq runs
15
+ # with `dead: false`): without this, the context would stay "running"
16
+ # forever with zero surface anywhere, and every reader would wait out its
17
+ # full timeout. Called from the backends' retries-exhausted hooks with the
18
+ # job's own args. Best-effort — never raises back into the backend.
19
+ def self.record_retries_exhausted(args, exception)
20
+ context_id, reactor_class_name = args
21
+ return unless context_id
22
+
23
+ reactor_class_name ||= RubyReactor.reactor_storage_name(nil)
24
+ storage = RubyReactor.configuration.storage_adapter
25
+ data = storage.retrieve_context(context_id, reactor_class_name)
26
+ return if data.nil? || TERMINAL_STATUSES.include?((data["status"] || data[:status]).to_s)
27
+
28
+ data["status"] = "failed"
29
+ data["failure_reason"] = {
30
+ "message" => "job retries exhausted: #{exception.class.name}: #{exception.message}",
31
+ "exception_class" => exception.class.name
32
+ }
33
+ storage.store_context(context_id, JSON.generate(data), reactor_class_name)
34
+ storage.publish(RubyReactor.async_reactor_channel(context_id), "failed")
35
+ rescue StandardError => e
36
+ RubyReactor.configuration.logger.error(
37
+ "RubyReactor: could not record retries-exhausted failure for #{context_id}: #{e.class.name}: #{e.message}"
38
+ )
39
+ end
40
+
41
+ # Identity-only payload: storage is the source of truth. Rehydrate the live
42
+ # context from storage by id, then resume. A nil read means the context was
43
+ # swept, expired, or already terminal-and-collected — nothing to resume.
44
+ def perform(context_id, reactor_class_name = nil, snooze_count = 0)
45
+ # Normalize so a nil/omitted name resolves to the same storage key the
46
+ # enqueue path wrote (always via reactor_storage_name). Without this a
47
+ # nil here builds "reactor::context:<id>" and misses the stored
48
+ # "reactor:AnonymousReactor:context:<id>", silently no-op'ing.
49
+ reactor_class_name ||= RubyReactor.reactor_storage_name(nil)
50
+ data = RubyReactor.configuration.storage_adapter.retrieve_context(context_id, reactor_class_name)
51
+ return if data.nil?
52
+
53
+ begin
54
+ context = ContextSerializer.deserialize_hash(data)
55
+ rescue RubyReactor::Error::DeserializationError,
56
+ RubyReactor::Error::SchemaVersionError => e
57
+ # Permanent failures — re-reading the same stored blob will keep
58
+ # failing. Mark the context as failed (best-effort) and return so
59
+ # the job does not burn its retry budget.
60
+ handle_deserialization_failure(context_id, reactor_class_name, e)
61
+ return
62
+ end
63
+
64
+ resolve_reactor_class!(context, reactor_class_name)
65
+ unless context.reactor_class
66
+ # Still unresolved (class not loaded, or an anonymous reactor with no
67
+ # storage name to look up) — Executor.new below would blow up on a nil
68
+ # class and burn the job's retry budget forever. Fail the context now.
69
+ error = RubyReactor::Error::DeserializationError.new(
70
+ "reactor class '#{reactor_class_name}' could not be resolved"
71
+ )
72
+ handle_deserialization_failure(context_id, reactor_class_name, error)
73
+ return
74
+ end
75
+
76
+ # Mark that we're executing inline to prevent nested async calls
77
+ context.inline_async_execution = true
78
+
79
+ begin
80
+ # Resume execution from the failed step
81
+ executor = Executor.new(context.reactor_class, {}, context)
82
+ executor.resume_execution
83
+ # No explicit save here: resume_execution's ensure block already persists
84
+ # the final root state (`save_context unless skip_context_persist?`), and
85
+ # in the worker the executor's context IS the root, so an extra checkpoint!
86
+ # would just re-write the identical blob to the identical key. The
87
+ # skip_context_persist? guard (stale-batch redelivery of an already-terminal
88
+ # context) is likewise honored there.
89
+
90
+ # Return the executor (which now has the result stored in it)
91
+ executor
92
+ rescue RubyReactor::Lock::AcquisitionError,
93
+ RubyReactor::Semaphore::AcquisitionError,
94
+ RubyReactor::RateLimit::ExceededError,
95
+ RubyReactor::OrderedLock::WaitError,
96
+ RubyReactor::Error::AsyncResultPending => e
97
+ # Snooze on expected concurrency, rate, or ordering contention.
98
+ # OrderedLock::WaitError carries a poison-pill-derived retry hint,
99
+ # consumed by compute_snooze_delay below. We avoid the framework's native
100
+ # retry path so this doesn't burn the job's retry budget or appear
101
+ # as an error in dashboards. After the configured cap is reached we
102
+ # escalate by marking the reactor as failed.
103
+ handle_snooze(context_id, reactor_class_name, context, snooze_count, e)
104
+ rescue RubyReactor::RateLimitRegistry::UnknownLimitError => e
105
+ # Permanent configuration error — snoozing or retrying the same job
106
+ # will keep failing. Mark the context failed immediately.
107
+ escalate_snooze(context, snooze_count, e)
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ # If reactor_class_name is provided, use it to get the reactor class.
114
+ # This handles cases where the class can't be found via const_get.
115
+ def resolve_reactor_class!(context, reactor_class_name)
116
+ return unless reactor_class_name && context.reactor_class.nil?
117
+
118
+ begin
119
+ context.reactor_class = Object.const_get(reactor_class_name)
120
+ rescue NameError
121
+ # If not found, try to find it in the current namespace
122
+ # This is a fallback for test environments
123
+ begin
124
+ context.reactor_class = reactor_class_name.constantize if reactor_class_name.respond_to?(:constantize)
125
+ rescue NameError
126
+ # Leave reactor_class nil: the caller's guard fails the context with
127
+ # a durable record. Letting this second NameError escape would burn
128
+ # the job's whole retry budget on an error retries can never fix,
129
+ # then vanish — leaving the context "running" forever and any reader
130
+ # waiting out its full timeout.
131
+ nil
132
+ end
133
+ end
134
+ end
135
+
136
+ def handle_snooze(context_id, reactor_class_name, context, snooze_count, error)
137
+ config = RubyReactor.configuration
138
+ max = config.lock_snooze_max_attempts
139
+
140
+ # OrderedLock::WaitError bypasses the snooze cap. The gate's
141
+ # poison_pill_timeout is the only meaningful upper bound on how long a
142
+ # nonce can legitimately wait; capping snoozes would either fail jobs
143
+ # prematurely or strand the nonce in `assigned_at` until poison_pill
144
+ # eventually advances past it. Snooze until the gate passes (or poison
145
+ # auto-advance moves the cursor past us).
146
+ # The per-context liveness lock (`async:<id>`) is also uncapped: a
147
+ # duplicate of the *same* execution may wait arbitrarily long for the
148
+ # live original to finish (e.g. a sweeper re-enqueue racing a slow but
149
+ # alive worker). Capping it would fail a legitimately-waiting duplicate.
150
+ # A parked async wait is likewise uncapped HERE: its bound is
151
+ # `async_park_timeout`, enforced against `dispatched_at` at the wait
152
+ # site — counting snoozes would double-bound it with the wrong unit.
153
+ capped = !(error.is_a?(RubyReactor::OrderedLock::WaitError) ||
154
+ error.is_a?(RubyReactor::Lock::ContextLockContention) ||
155
+ error.is_a?(RubyReactor::Error::AsyncResultPending))
156
+
157
+ if capped && max != :infinity && snooze_count >= max
158
+ escalate_snooze(context, snooze_count, error)
159
+ return
160
+ end
161
+
162
+ delay = compute_snooze_delay(config, error)
163
+ # Re-enqueue by id: the context is already persisted in storage, so the
164
+ # rescheduled job rehydrates fresh state (no stale blob).
165
+ self.class.perform_in(delay, context_id, reactor_class_name, snooze_count + 1)
166
+ end
167
+
168
+ # Use the error's `retry_after_seconds` hint when available
169
+ # (RateLimit::ExceededError carries the time until the bucket rolls);
170
+ # otherwise fall back to the configured base + jitter for lock/semaphore
171
+ # contention which has no precise hint.
172
+ #
173
+ # OrderedLock::WaitError is deliberately excluded from the hint path: its
174
+ # `retry_after_seconds` is the poison-pill window (the upper bound before
175
+ # a *dead* blocker is force-advanced), NOT how long the *live* blocker
176
+ # will take — which is usually milliseconds. Snoozing for the full window
177
+ # would make every out-of-order nonce sleep up to poison_pill_timeout even
178
+ # though its blocker finishes immediately, collapsing throughput. Re-poll
179
+ # at the base delay instead; poison auto-advance still clears a genuinely
180
+ # dead blocker on a later gate.
181
+ def compute_snooze_delay(config, error)
182
+ jitter = config.lock_snooze_jitter.to_f
183
+ jitter_amount = jitter.positive? ? rand(0.0..jitter) : 0.0
184
+
185
+ if hinted_retry?(error)
186
+ [error.retry_after_seconds.to_f, 0.1].max + jitter_amount
187
+ else
188
+ config.lock_snooze_base_delay.to_f + jitter_amount
189
+ end
190
+ end
191
+
192
+ def hinted_retry?(error)
193
+ return false if error.is_a?(RubyReactor::OrderedLock::WaitError)
194
+
195
+ error.respond_to?(:retry_after_seconds) && error.retry_after_seconds
196
+ end
197
+
198
+ def escalate_snooze(context, snooze_count, error)
199
+ RubyReactor.configuration.logger.warn(
200
+ "RubyReactor snooze limit reached after #{snooze_count} attempts " \
201
+ "for context #{context.context_id}: #{error.message}"
202
+ )
203
+
204
+ context.status = :failed
205
+ context.failure_reason = {
206
+ message: error.message,
207
+ exception_class: error.class.name,
208
+ snooze_attempts: snooze_count
209
+ }
210
+
211
+ serialized = ContextSerializer.serialize(context)
212
+ reactor_class_name = RubyReactor.reactor_storage_name(context.reactor_class)
213
+ RubyReactor.configuration.storage_adapter.store_context(
214
+ context.context_id,
215
+ serialized,
216
+ reactor_class_name
217
+ )
218
+
219
+ # Escalation is a terminal Failure that never reaches the Executor's
220
+ # ensure path, so advance the ordered-lock cursor here. Without this
221
+ # the nonce stays stranded in assigned_at (successors stall for the
222
+ # full poison_pill_timeout) and, worse, the strict-mode chain marker
223
+ # is never recorded — successors would RUN instead of being skipped.
224
+ info = Executor::OrderedLockSupport.info_from(context)
225
+ Executor::OrderedLockSupport.advance_with_retry(info, failed: true) if info
226
+ end
227
+
228
+ def log_infrastructure_failure(msg, exception)
229
+ RubyReactor.configuration.logger.error("RubyReactor infrastructure failure: #{exception.message}")
230
+ RubyReactor.configuration.logger.error("Job details: #{msg.inspect}")
231
+ end
232
+
233
+ # The id-only payload already carries context_id and reactor_class_name, so
234
+ # there is no blob to parse for metadata — just mark the stored context
235
+ # failed (best-effort) so the job stops retrying a permanently-broken blob.
236
+ def handle_deserialization_failure(context_id, reactor_class_name, error)
237
+ RubyReactor.configuration.logger.error(
238
+ "RubyReactor deserialization failure for context " \
239
+ "#{context_id || "unknown"}: #{error.class.name}: #{error.message}"
240
+ )
241
+
242
+ return unless context_id && reactor_class_name
243
+
244
+ payload = build_failed_context_payload(context_id, reactor_class_name, error)
245
+ RubyReactor.configuration.storage_adapter.store_context(
246
+ context_id,
247
+ payload,
248
+ reactor_class_name
249
+ )
250
+ # Written first, signalled second — wake any reader blocked (or parked)
251
+ # on this execution so it fails fast with the real cause instead of
252
+ # waiting out its timeout.
253
+ RubyReactor.configuration.storage_adapter.publish(
254
+ RubyReactor.async_reactor_channel(context_id), "failed"
255
+ )
256
+ rescue StandardError => e
257
+ # Don't let a persistence failure mask the original deserialization error.
258
+ RubyReactor.configuration.logger.error(
259
+ "RubyReactor failed to persist deserialization failure: #{e.class.name}: #{e.message}"
260
+ )
261
+ end
262
+
263
+ def build_failed_context_payload(context_id, reactor_class_name, error)
264
+ JSON.generate(
265
+ "schema_version" => ContextSerializer::SCHEMA_VERSION,
266
+ "context_id" => context_id,
267
+ "reactor_class" => reactor_class_name,
268
+ "status" => "failed",
269
+ "failure_reason" => {
270
+ "message" => error.message,
271
+ "exception_class" => error.class.name
272
+ }
273
+ )
274
+ end
275
+ end
276
+ end
data/lib/ruby_reactor.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require "zeitwerk"
4
4
  require "pathname"
5
5
  require "securerandom"
6
+ require "time"
6
7
  require_relative "ruby_reactor/registry"
7
8
  require_relative "ruby_reactor/utils/code_extractor"
8
9
  require_relative "ruby_reactor/dsl/lockable" # Add this
@@ -26,6 +27,13 @@ rescue LoadError
26
27
  # sidekiq is optional, async features won't be available
27
28
  end
28
29
 
30
+ # Load active_job if available (for the ActiveJob async adapter)
31
+ begin
32
+ require "active_job"
33
+ rescue LoadError
34
+ # active_job is optional, only needed when using the ActiveJob adapter
35
+ end
36
+
29
37
  loader = Zeitwerk::Loader.for_gem
30
38
  loader.inflector.inflect("api" => "API", "rspec" => "RSpec")
31
39
  loader.setup
@@ -285,8 +293,10 @@ module RubyReactor
285
293
  end
286
294
  end
287
295
 
288
- # Async result for background job execution
289
- class AsyncResult
296
+ # Sentinel returned when a step's work is handed off to a worker job and is
297
+ # not yet resolved — produced by `background`, `async_step`, `async_reactor`,
298
+ # and map's async element dispatch alike.
299
+ class DispatchResult
290
300
  attr_reader :job_id, :intermediate_results, :execution_id
291
301
 
292
302
  def initialize(job_id:, intermediate_results: {}, execution_id: nil)
@@ -341,6 +351,23 @@ module RubyReactor
341
351
  # (A truly anonymous class can't be reconstituted by name in another process,
342
352
  # so cross-process resume of one is inherently unsupported; this only keeps
343
353
  # the keys self-consistent within a process — e.g. inline tests.)
354
+ # Completion-signal channels for the notified wait. One place, because
355
+ # the publishing side and the waiting side must agree exactly and they live in
356
+ # different files (StepWorker / Executor vs Template::Result).
357
+ def self.async_step_channel(context_id, step_name)
358
+ "rr:done:#{context_id}:#{step_name}"
359
+ end
360
+
361
+ def self.async_reactor_channel(execution_id)
362
+ "rr:done:#{execution_id}"
363
+ end
364
+
365
+ # Liveness lock for one dispatched `async_step`. Held by StepWorker for the
366
+ # life of the unit, so StepSweeper can tell a slow unit from a lost job.
367
+ def self.async_step_lock_key(context_id, step_name)
368
+ "async_step:#{context_id}:#{step_name}"
369
+ end
370
+
344
371
  def self.reactor_storage_name(reactor_class)
345
372
  return "AnonymousReactor" if reactor_class.nil?
346
373
 
@@ -356,7 +383,39 @@ module RubyReactor
356
383
  def self.start_sweeper!
357
384
  return unless configuration.sweeper_enabled
358
385
 
359
- SidekiqWorkers::SweeperWorker.schedule_next
386
+ sweeper_job_class.schedule_next
387
+ end
388
+
389
+ # The sweeper job class living alongside the configured `async_router`
390
+ # (e.g. `Adapters::Sidekiq::Router` -> `Adapters::Sidekiq::SweeperWorker`),
391
+ # so the chain is kicked through whichever backend is configured instead of
392
+ # a hardcoded Sidekiq class. Known built-in routers are mapped explicitly;
393
+ # a custom `configuration.async_router` falls back to a sibling-namespace
394
+ # lookup, with a clear error (instead of a bare NameError) when that
395
+ # convention doesn't hold.
396
+ def self.sweeper_job_class
397
+ router = configuration.async_router
398
+ case router.name
399
+ when "RubyReactor::Adapters::Sidekiq::Router" then Adapters::Sidekiq::SweeperWorker
400
+ when "RubyReactor::Adapters::ActiveJob::Router" then Adapters::ActiveJob::SweeperWorker
401
+ else
402
+ inferred_sweeper_job_class(router)
403
+ end
404
+ end
405
+
406
+ def self.inferred_sweeper_job_class(router)
407
+ namespace_name = router.name.to_s.rpartition("::").first
408
+ raise sweeper_job_class_error(router) if namespace_name.empty?
409
+
410
+ Object.const_get(namespace_name).const_get(:SweeperWorker)
411
+ rescue NameError
412
+ raise sweeper_job_class_error(router)
413
+ end
414
+
415
+ def self.sweeper_job_class_error(router)
416
+ "RubyReactor: cannot infer a sweeper job class for custom async_router " \
417
+ "#{router.inspect}. Define a `SweeperWorker` class alongside it " \
418
+ "(same namespace), or override `RubyReactor.sweeper_job_class`."
360
419
  end
361
420
 
362
421
  # Run both recovery sweepers exactly once and return their counts. The
@@ -368,7 +427,8 @@ module RubyReactor
368
427
  limit ||= configuration.sweeper_limit
369
428
  {
370
429
  reactors: Sweeper.run_once(limit: limit),
371
- maps: Map::Sweeper.run_once(limit: limit)
430
+ maps: Map::Sweeper.run_once(limit: limit),
431
+ async_steps: StepSweeper.run_once(limit: limit)
372
432
  }
373
433
  end
374
434
 
@@ -0,0 +1,39 @@
1
+ # Specification Quality Checklist: Background Execution & Real Async Steps
2
+
3
+ **Purpose**: Validate specification completeness and quality before proceeding to planning
4
+ **Created**: 2026-08-16
5
+ **Feature**: [spec.md](../spec.md)
6
+
7
+ ## Content Quality
8
+
9
+ - [x] No implementation details (languages, frameworks, APIs)
10
+ - [x] Focused on user value and business needs
11
+ - [x] Written for non-technical stakeholders
12
+ - [x] All mandatory sections completed
13
+
14
+ ## Requirement Completeness
15
+
16
+ - [x] No [NEEDS CLARIFICATION] markers remain
17
+ - [x] Requirements are testable and unambiguous
18
+ - [x] Success criteria are measurable
19
+ - [x] Success criteria are technology-agnostic (no implementation details)
20
+ - [x] All acceptance scenarios are defined
21
+ - [x] Edge cases are identified
22
+ - [x] Scope is clearly bounded
23
+ - [x] Dependencies and assumptions identified
24
+
25
+ ## Feature Readiness
26
+
27
+ - [x] All functional requirements have clear acceptance criteria
28
+ - [x] User scenarios cover primary flows
29
+ - [x] Feature meets measurable outcomes defined in Success Criteria
30
+ - [x] No implementation details leak into specification
31
+
32
+ ## Notes
33
+
34
+ - Items marked incomplete require spec updates before `/speckit-clarify` or `/speckit-plan`.
35
+ - Note: the spec names concrete terms (`Sidekiq`, `ActiveJob`, `Redis`) only inside the **Assumptions** section, where they describe existing constraints already codified in the project constitution, not new implementation choices being introduced by this feature. All requirement- and success-criteria-level language remains technology-agnostic.
36
+ - 2026-08-16 (post-plan review): added FR-014/SC-006 covering dashboard visibility for `async_step`/`async_reactor`, a gap found while reviewing plan.md against the constitution's Observability principle. Re-validated against this checklist — all items still pass.
37
+ - 2026-08-21 (final consistency pass): fixed stale "blocking-poll" wording left from the superseded wait design (plan Summary/tree, contract, data-model); fixed the async_step dispatch ordering in data-model (record + ref written BEFORE enqueue, matching the F2 rule its own Step Result Record row states); fixed "record absent = pending" to "record still `dispatched` = pending"; reconciled plan Scale/Scope with the tree (dependency_graph.rb unchanged; executor.rb added for the completion-signal publish); aligned FR-015 with contract/research on single-slot semaphores. All items re-verified passing.
38
+ - 2026-08-20 (wait-mechanism & locks review): FR-005 revised from tight-poll to notified wait (record-first publish, subscribe-first check, fallback re-check — durable record stays the source of truth, timeout bound unchanged); added FR-015 (no lock-owner sharing across the async boundary + dispatch-time same-key deadlock guard; reentrancy stays compose-only) and FR-016 (async_reactor dispatch reuses the full pre-enqueue sequence: child input validation + ordered-lock nonce, a gap found during the locks review). Clarifications Session 2026-08-20 records both decisions. All checklist items re-verified passing.
39
+ - 2026-08-16 (full-artifact review): fixed FR-006 (claimed a nonexistent "existing step-result storage"; now: new per-step record for `async_step`, own execution record for `async_reactor`); clarified FR-003 scope (compose's `async` flag is removed too — it sets the same flag; map's `async` is a different mechanism and stays); added edge cases for `returns` × async unit, `background` × whole-reactor `async true`, and a paused awaited `async_reactor` child. Contract additionally pins failure-read semantics for `result()` on async units and lock-window behavior. All checklist items re-verified passing.
@@ -0,0 +1,154 @@
1
+ # Contract: Public DSL Surface
2
+
3
+ This gem's "interface" is its Ruby DSL, consumed by host applications that subclass `RubyReactor::Reactor`. This contract documents the exact public surface this feature adds, changes, or removes, so implementation and tests can be checked against it directly.
4
+
5
+ ## Removed
6
+
7
+ ```ruby
8
+ class MyReactor < RubyReactor::Reactor
9
+ step :do_thing do
10
+ async true # REMOVED — raises a definition-time error (FR-003)
11
+ end
12
+ end
13
+ ```
14
+
15
+ ```ruby
16
+ class MyReactor < RubyReactor::Reactor
17
+ compose :sub_flow, SubReactor do
18
+ async true # ALSO REMOVED — same StepConfig hand-off flag, same definition-time error
19
+ end
20
+ end
21
+ ```
22
+
23
+ **Error contract**: attempting to call `async` inside a `step` **or `compose`** block MUST raise a `RubyReactor::Error::ValidationError` (or a new dedicated error subclass — implementation's choice, but it MUST be raised at reactor **class-definition** time, not at `run` time) whose message names both the removed syntax and its replacement(s): `background after:`/`background before:`, `async_step`, `async_reactor`. (`ComposeBuilder#async` sets the very `StepConfig` flag this feature removes — `dsl/compose_builder.rb:62` — so it cannot survive; the exact migration for a compose that handed off is `background before: :<that compose step>`, which reproduces the old semantics precisely — the flagged step and everything after it moved to the worker — without the author having to identify a predecessor.)
24
+
25
+ **Not removed**: the `async` option inside a `map` block (`MapBuilder#async`, `dsl/map_builder.rb:43`) is a map-internal element-dispatch mode passed as a step *argument* — the map's own `StepConfig` is hardcoded `async: false` (`map_builder.rb:111`), so it does not touch the removed flag and keeps working unchanged.
26
+
27
+ ## Added: `background after:` / `background before:`
28
+
29
+ ```ruby
30
+ class MyReactor < RubyReactor::Reactor
31
+ step :first
32
+ step :second
33
+ background after: :second # :second is the LAST step in the calling process
34
+ step :third
35
+ end
36
+
37
+ class EquivalentInLinearFlow < RubyReactor::Reactor
38
+ step :first
39
+ step :second
40
+ background before: :third # :third is the FIRST step in the worker
41
+ step :third
42
+ end
43
+ ```
44
+
45
+ - **Signature**: `self.background(after: nil, before: nil)` — reactor class macro, exactly one keyword supplied.
46
+ - **The two forms** name one cut point from opposite sides, and each carries a *guarantee about the step it names*:
47
+ - `after: :x` — `:x` runs in the calling process, and is the last step to do so.
48
+ - `before: :x` — `:x` runs in the worker, and is the first step to do so; it never executes in the calling process.
49
+
50
+ In a linear reactor where `:third` immediately follows `:second`, `after: :second` and `before: :third` are equivalent. In a branching workflow they are not, and the author picks whichever step they actually need pinned.
51
+ - **Constraints** (all raise at class-definition time, FR-002):
52
+ - at most one `background` declaration per reactor class;
53
+ - the named step must be defined in the class (either keyword);
54
+ - the named step must not be an `interrupt` (either keyword) — an interrupt re-enters the reactor from a foreground process, so `after:` would silently never fire on resume and `before:` would enqueue a worker that instantly pauses and swallows the `InterruptResult`; the error points to `interrupt :name, resume: :background` as the correct spelling;
55
+ - exactly one of `after:`/`before:` — supplying both, or neither, raises;
56
+ - combining `background` with whole-reactor `async true` raises (the hand-off point would be silently meaningless inside a reactor that already runs entirely in a worker — see spec Edge Cases).
57
+ - **Runtime contract**: hand-off is triggered by *reaching the named step*, not by the declaration's lexical position — the declaration may sit anywhere in the class body. For `after: :x`, the trigger fires when `:x` completes; for `before: :x`, it fires when `:x` is selected to run, and `:x` is left unexecuted for the worker to run. Either way: checkpoint, enqueue the remainder via `configuration.async_router`, return an `DispatchResult` to the caller. In a DAG with parallel branches, any independent step that became ready and executed before the trigger fired has already run in the calling process; everything not yet executed at the trigger moment runs in the worker — this caveat is identical for both forms. Compensation for worker-side step failures works exactly as it does for any same-process step failure — `background` only changes *where* code runs, not the saga/compensation contract (US1 acceptance scenarios 1-2). Inside the worker the hand-off never re-triggers (the existing `inline_async_execution` guard).
58
+ - **Never-reached trigger**: if the named step is skipped by a `where`/guard condition, or the reactor fails before reaching it, the hand-off simply never fires and the run completes in the calling process. No step is stranded — the hand-off only ever relocates *remaining* work.
59
+ - **`before:` naming the first step** is legal, and is not the same as whole-reactor `async true`: every step body runs in the worker, but input validation still happens in the calling process, so invalid inputs fail the caller synchronously instead of failing inside a worker.
60
+
61
+ ## Added: `async_step`
62
+
63
+ ```ruby
64
+ class MyReactor < RubyReactor::Reactor
65
+ async_step :send_email do
66
+ argument :to, input(:email)
67
+ run { |args| Mailer.send(args[:to]) }
68
+ end
69
+
70
+ step :do_something_same_thread do
71
+ run { do_work }
72
+ end
73
+
74
+ step :check_email do
75
+ argument :email, result(:send_email) # blocks (notified wait, bounded by timeout) until :send_email is done
76
+ run { |args| ... }
77
+ end
78
+ end
79
+ ```
80
+
81
+ - **Signature**: `self.async_step(name, impl = nil, &block)` — same call shape as `step`, builds the same `StepConfig` fields (`argument`, `run`, `compensate`, `undo`, `validate_args`, `validate_output`, `retries`, etc. all still work identically inside the block).
82
+ - **Runtime contract**:
83
+ - Dispatches the step's work to an independent worker job; does **not** halt the calling reactor's execution of other ready steps that don't depend on it (US2 acceptance scenario 1).
84
+ - Any step that references `result(:async_step_name)` blocks — notified wait: woken by the completion signal the finishing worker publishes after its durable write, with a coarse fallback re-check of the durable record, bounded overall by `Configuration#async_wait_timeout` (spec Clarifications, Session 2026-08-20) — until the async step's terminal result is available. **Read semantics**: on `Success`, the reader receives the same deserialized raw value a same-process step's result would produce (US2 acceptance scenario 2, FR-006); on `Failure`, the reader receives the `Failure` object itself as the argument value — a same-process step's failure would have halted the reactor before any reader ran, so there is no sync-behavior to mirror here, and injecting the `Failure` is what lets the reader "see the failure and decide" per the spec's clarified compensation model (US2 acceptance scenario 3). This matches `async_reactor`'s wrapped-result-on-inspection pattern.
85
+ - `returns :async_step_name` raises at class-definition time — the reactor's return value must come from a same-process step (spec Edge Cases).
86
+ - Dispatch is **not** suppressed inside a worker: an `async_step` declared after a `background` hand-off point (or reached during a worker resume) still dispatches to its own independent job — the existing `inline_async_execution` guard suppresses only the *hand-off* re-trigger, never `async_step`/`async_reactor` dispatch (spec Edge Cases).
87
+ - Reactor-level `lock`/`semaphore`/`rate_limit` windows are held by the process executing the reactor's own steps — the async step's work runs *outside* those windows (in its own job, which acquires nothing). A step body that needs mutual exclusion must arrange it itself.
88
+ - If the async step fails and no later step reads its result, the parent reactor's compensation is **not** automatically triggered (US2 acceptance scenario 3, FR-011). A later step that does read the result and observes failure may itself return `Failure` to trigger compensation.
89
+ - `compensate`/`undo` blocks declared on an `async_step` still register normally — they only run if the step's own failure is surfaced into the parent's compensation path via the opt-in mechanism above, never automatically.
90
+ - A reference to the dispatched unit is recorded on the parent's own context (`composed_contexts[:send_email] = { type: :async_step_ref, ... }`) at dispatch time, and the web dashboard renders `send_email` as an `async_step`-typed node (FR-008, FR-014).
91
+
92
+ ## Added: `async_reactor`
93
+
94
+ ```ruby
95
+ class MyReactor < RubyReactor::Reactor
96
+ async_reactor :create_profile, CreateProfileReactor # fire-and-forget, no downstream reference
97
+
98
+ async_reactor :create_account, CreateAccountReactor do
99
+ argument :user_id, input(:user_id)
100
+ end
101
+
102
+ step :verify_all do
103
+ argument :account, result(:create_account) # blocks until create_account finishes
104
+ run do |args, context|
105
+ if args[:account].success?
106
+ Success(args[:account].value)
107
+ else
108
+ Failure(args[:account].error) # opt-in compensation trigger
109
+ end
110
+ end
111
+ end
112
+ end
113
+ ```
114
+
115
+ - **Signature**: `self.async_reactor(name, child_reactor_class, &block)` — `argument` inside the block maps parent-visible sources to the child reactor's inputs, same shape as `compose`.
116
+ - **Dispatch contract** (the part that runs in the parent's process, FR-015/FR-016):
117
+ - Dispatch applies the same pre-enqueue safeguards as a top-level async run: the child's inputs are validated and, if the child declares `with_ordered_lock`, its ordering nonce is assigned at enqueue. A child-input validation failure fails **the dispatching step** (normal saga handling in the parent) — this is a dispatch failure, not a child-execution failure, and is deliberately outside FR-009's no-auto-compensation rule.
118
+ - Deadlock guard: if the child declares an exclusive `lock` (or a `semaphore` with `limit: 1`) whose resolved key equals one the dispatching execution currently holds, the dispatch step fails immediately with an error naming the lock key and both reactor classes. The error message MUST enumerate the three remediations, ranked: (1) use `compose` if the child belongs in the parent's critical section and its result is needed — the wait means the work is sequential anyway; (2) narrow the lock keys if parent and child actually protect different resources; (3) restructure so the locked reactor never reads the child's result — fire-and-forget with verification in the child itself or in a successor reactor outside the lock window. Lock ownership is never shared across the async boundary (parent and child run concurrently — shared ownership would break mutual exclusion); owner-based reentrancy remains for `compose` only. Transitive cross-execution cycles are out of the guard's reach (undetectable at dispatch) — documentation advises consistent key-acquisition order, with the FR-005 timeout as backstop.
119
+ - **Runtime contract**:
120
+ - Dispatches `child_reactor_class` to run independently via `configuration.async_router`, linked to the parent by the child's `execution_id` for traceability/logging (FR-008, US3 acceptance scenario 4) — never added to the parent's compensation graph (FR-009).
121
+ - If nothing in the parent reads `result(:name)`, the child's eventual failure never affects the parent (US3 acceptance scenario 1).
122
+ - If a later step reads `result(:name)`, it blocks (same notified-wait policy as `async_step` — the child publishes its completion signal after its terminal save) until the child reactor reaches a terminal state, then receives the child's actual `Success`/`Failure` result object (not the enqueue-time `DispatchResult`), and may inspect `.success?`/`.value`/`.error` to decide whether to itself return `Failure` (US3 acceptance scenarios 2-3, FR-010).
123
+ - A reference is recorded on the parent's own context (`composed_contexts[:create_profile] = { type: :async_reactor_ref, execution_id:, reactor_class_name:, ... }`) at dispatch time — the web dashboard renders `create_profile`/`create_account` as `async_reactor`-typed nodes and lets an operator open the linked child execution, the same drill-down `compose`/`map` already offer (FR-008, FR-014, US3 acceptance scenario 4).
124
+ - `returns :async_reactor_name` raises at class-definition time, same as for `async_step`.
125
+ - A child that *pauses* at an interrupt step is not terminal: a reader keeps polling and hits the FR-005 timeout unless the child is resumed within the bound (spec Edge Cases). The child is an ordinary independently-recoverable execution — the existing sweeper/durability machinery covers its crash recovery with no new mechanism.
126
+
127
+ ## Added: `interrupt ..., resume: :background`
128
+
129
+ ```ruby
130
+ class MyReactor < RubyReactor::Reactor
131
+ interrupt :webhook, resume: :background do
132
+ validate_payload { required(:status).filled(:string) }
133
+ end
134
+
135
+ step :heavy_work do
136
+ argument :status, result(:webhook)
137
+ run { |args| ... } # runs in a worker, never in the process that delivered the payload
138
+ end
139
+ end
140
+ ```
141
+
142
+ - **Signature**: `self.interrupt(name, resume: :inline, &block)` — `:inline` (today's behavior, the default) or `:background`. Any other value raises `RubyReactor::Error::ValidationError` at class-definition time.
143
+ - **Why it exists**: `background after:/before:` is edge-triggered on reaching one named step, but an interrupt re-enters the reactor from a foreground process (`continue` runs in whatever process delivered the payload). `resume: :background` makes that re-entry itself the hand-off: the delivering process validates and stores the payload, then enqueues the remainder.
144
+ - **Runtime contract**:
145
+ - Payload validation (`validate_payload`), attempt counting, and `max_attempts` exhaustion (undo + failed) all run **synchronously in the calling process**, before anything is enqueued — validate in the caller, execute in the worker, the same split as `background before: <first step>` and FR-016. An invalid payload enqueues nothing and leaves the reactor paused.
146
+ - On a valid payload: result stored, context persisted (job payload is identity-only), `before_async_enqueue` middleware fires, remainder enqueued via `configuration.async_router`, and `continue` returns an `DispatchResult` (with `execution_id`) instead of the final result.
147
+ - The worker resumes through the existing path: rehydrate by id, `resume_execution`, interrupt's stored result found, remaining steps run under `inline_async_execution` (so a `background` hand-off point never re-triggers there).
148
+
149
+ ## Unchanged (explicitly out of scope, called out to prevent accidental regression)
150
+
151
+ - Reactor-level `async true` ("Full Reactor Async") — `self.class.async?`, `lib/ruby_reactor/dsl/reactor.rb:44-50`. (Its only new interaction: combining it with `background after:` is a definition-time error, see above.)
152
+ - `compose` itself — synchronous, fully compensation-linked nested execution, untouched. (Its `async` flag is removed — see the Removed section — but everything else about `compose` is unchanged.)
153
+ - `map`'s dispatch/collection machinery and its full DSL including the map-internal `async` element-dispatch option — reused as an architectural pattern (see research.md) but untouched.
154
+ - `result(:name)` for a **synchronous** step's result — resolves exactly as it does today (`Template::Result#resolve`), with zero added latency; the new notified-wait path only activates for `async_step`/`async_reactor` references.