ruby_reactor 0.5.4 → 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 (70) 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 +7 -0
  41. data/README.md +51 -24
  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/map/element_executor.rb +1 -1
  55. data/lib/ruby_reactor/rspec/active_job_helpers.rb +52 -0
  56. data/lib/ruby_reactor/rspec/async_test_helpers.rb +41 -0
  57. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +3 -3
  58. data/lib/ruby_reactor/rspec/test_subject.rb +11 -7
  59. data/lib/ruby_reactor/rspec.rb +4 -0
  60. data/lib/ruby_reactor/sweeper_job.rb +70 -0
  61. data/lib/ruby_reactor/version.rb +1 -1
  62. data/lib/ruby_reactor/worker.rb +226 -0
  63. data/lib/ruby_reactor.rb +40 -1
  64. data/specs/active_job.md +259 -0
  65. metadata +54 -6
  66. data/lib/ruby_reactor/sidekiq_adapter.rb +0 -87
  67. data/lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb +0 -13
  68. data/lib/ruby_reactor/sidekiq_workers/map_element_worker.rb +0 -13
  69. data/lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb +0 -73
  70. data/lib/ruby_reactor/sidekiq_workers/worker.rb +0 -222
@@ -1,87 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RubyReactor
4
- class SidekiqAdapter
5
- # Identity-only payload: the worker rehydrates the live context from storage
6
- # by (context_id, reactor_class_name). The caller already holds context_id, so
7
- # there is no blob to deserialize here.
8
- def self.perform_async(context_id, reactor_class_name = nil, intermediate_results: {})
9
- job_id = SidekiqWorkers::Worker.perform_async(context_id, reactor_class_name)
10
- RubyReactor::AsyncResult.new(job_id: job_id, intermediate_results: intermediate_results,
11
- execution_id: context_id)
12
- end
13
-
14
- def self.perform_in(delay, context_id, reactor_class_name = nil, intermediate_results: {})
15
- job_id = SidekiqWorkers::Worker.perform_in(delay, context_id, reactor_class_name)
16
- RubyReactor::AsyncResult.new(job_id: job_id, intermediate_results: intermediate_results,
17
- execution_id: context_id)
18
- end
19
-
20
- # rubocop:disable Metrics/ParameterLists
21
- def self.perform_map_element_async(map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
22
- strict_ordering:, parent_context_id:, parent_reactor_class_name:, step_name:,
23
- batch_size: nil, serialized_context: nil, fail_fast: nil)
24
- job_id = RubyReactor::SidekiqWorkers::MapElementWorker.perform_async(
25
- {
26
- "map_id" => map_id,
27
- "element_id" => element_id,
28
- "index" => index,
29
- "serialized_inputs" => serialized_inputs,
30
- "reactor_class_info" => reactor_class_info,
31
- "strict_ordering" => strict_ordering,
32
- "parent_context_id" => parent_context_id,
33
- "parent_reactor_class_name" => parent_reactor_class_name,
34
- "step_name" => step_name,
35
- "batch_size" => batch_size,
36
- "serialized_context" => serialized_context,
37
- "fail_fast" => fail_fast
38
- }
39
- )
40
- RubyReactor::AsyncResult.new(job_id: job_id)
41
- end
42
-
43
- def self.perform_map_element_in(delay, map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
44
- strict_ordering:, parent_context_id:, parent_reactor_class_name:, step_name:,
45
- batch_size: nil, serialized_context: nil, fail_fast: nil)
46
- job_id = RubyReactor::SidekiqWorkers::MapElementWorker.perform_in(
47
- delay,
48
- {
49
- "map_id" => map_id,
50
- "element_id" => element_id,
51
- "index" => index,
52
- "serialized_inputs" => serialized_inputs,
53
- "reactor_class_info" => reactor_class_info,
54
- "strict_ordering" => strict_ordering,
55
- "parent_context_id" => parent_context_id,
56
- "parent_reactor_class_name" => parent_reactor_class_name,
57
- "step_name" => step_name,
58
- "batch_size" => batch_size,
59
- "serialized_context" => serialized_context,
60
- "fail_fast" => fail_fast
61
- }
62
- )
63
- # Return an AsyncResult so RetryManager#handle_async_retry recognises the
64
- # element was successfully requeued and yields a RetryQueuedResult.
65
- RubyReactor::AsyncResult.new(job_id: job_id)
66
- end
67
- # rubocop:enable Metrics/ParameterLists
68
-
69
- # rubocop:disable Metrics/ParameterLists
70
- def self.perform_map_collection_async(parent_context_id:, map_id:, parent_reactor_class_name:, step_name:,
71
- strict_ordering:, timeout:)
72
- job_id = RubyReactor::SidekiqWorkers::MapCollectorWorker.perform_async(
73
- {
74
- "parent_context_id" => parent_context_id,
75
- "map_id" => map_id,
76
- "parent_reactor_class_name" => parent_reactor_class_name,
77
- "step_name" => step_name,
78
- "strict_ordering" => strict_ordering,
79
- "timeout" => timeout
80
- }
81
- )
82
- RubyReactor::AsyncResult.new(job_id: job_id)
83
- end
84
-
85
- # rubocop:enable Metrics/ParameterLists
86
- end
87
- end
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RubyReactor
4
- module SidekiqWorkers
5
- class MapCollectorWorker
6
- include ::Sidekiq::Worker
7
-
8
- def perform(arguments)
9
- RubyReactor::Map::Collector.perform(arguments)
10
- end
11
- end
12
- end
13
- end
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RubyReactor
4
- module SidekiqWorkers
5
- class MapElementWorker
6
- include ::Sidekiq::Worker
7
-
8
- def perform(arguments)
9
- RubyReactor::Map::ElementExecutor.perform(arguments)
10
- end
11
- end
12
- end
13
- end
@@ -1,73 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "sidekiq"
4
- require "securerandom"
5
-
6
- module RubyReactor
7
- module SidekiqWorkers
8
- # Self-rescheduling recovery tick. Each run sweeps both the top-level reactor
9
- # sweeper and the map sweeper, then schedules the next tick — a perpetual
10
- # chain the host kicks once via `RubyReactor.start_sweeper!`.
11
- #
12
- # super_fetch safety. Sidekiq Enterprise `super_fetch` reliably re-runs a job
13
- # whose worker died mid-execution. For a self-rescheduling chain that is a
14
- # hazard: a tick can crash AFTER enqueuing its successor but BEFORE acking, so
15
- # super_fetch recovers the crashed tick *alongside* the successor it already
16
- # scheduled — the chain forks and then doubles every interval. We therefore do
17
- # NOT rely on "exactly one job exists". The next tick is claimed by a
18
- # per-time-window lock: every duplicate computes the SAME target window and
19
- # only one wins the claim, so recovered/duplicated ticks collapse back to a
20
- # single chain. The claim lock is never released — it simply expires — so no
21
- # delete can race two duplicates into both winning.
22
- class SweeperWorker
23
- include ::Sidekiq::Worker
24
-
25
- # retry: false — the sweep is idempotent and self-rescheduling, so a failed
26
- # tick must not pile up Sidekiq retries; the next tick (or a super_fetch
27
- # recovery) re-runs it anyway.
28
- sidekiq_options retry: false, queue: RubyReactor.configuration.sidekiq_queue
29
-
30
- def perform
31
- config = RubyReactor.configuration
32
- return unless config.sweeper_enabled
33
-
34
- run_sweeps(config)
35
- ensure
36
- # Always chain forward (unless disabled), even after an error above, so a
37
- # single bad sweep can't kill recovery. The window lock keeps this from
38
- # forking under super_fetch.
39
- self.class.schedule_next if RubyReactor.configuration.sweeper_enabled
40
- end
41
-
42
- def run_sweeps(config)
43
- RubyReactor::Sweeper.run_once(limit: config.sweeper_limit)
44
- RubyReactor::Map::Sweeper.run_once(limit: config.sweeper_limit)
45
- rescue StandardError => e
46
- config.logger.error("RubyReactor::SweeperWorker sweep failed: #{e.class}: #{e.message}")
47
- end
48
-
49
- # Enqueue the next tick for the upcoming time window, claiming that window
50
- # so concurrent/duplicate/recovered ticks produce exactly one successor.
51
- # Idempotent: also safe to call from `start_sweeper!` on every process boot.
52
- def self.schedule_next
53
- interval = RubyReactor.configuration.sweeper_interval
54
- window = (Time.now.to_i / interval) + 1
55
-
56
- lock = RubyReactor::Lock.new(
57
- "sweeper:window:#{window}",
58
- owner: SecureRandom.uuid,
59
- ttl: interval * 2, # outlive the window; expires on its own (never released)
60
- wait: 0,
61
- auto_extend: false
62
- )
63
- lock.acquire # raises AcquisitionError if this window is already claimed
64
-
65
- delay = (window * interval) - Time.now.to_i
66
- perform_in([delay, 1].max)
67
- rescue RubyReactor::Lock::AcquisitionError
68
- # Another tick already scheduled this window — collapse the duplicate.
69
- nil
70
- end
71
- end
72
- end
73
- end
@@ -1,222 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "sidekiq"
4
-
5
- module RubyReactor
6
- module SidekiqWorkers
7
- # Sidekiq worker for executing RubyReactor reactors asynchronously
8
- # with non-blocking retry capabilities
9
- class Worker
10
- include ::Sidekiq::Worker
11
-
12
- # Enable Sidekiq retries for infrastructure failures only
13
- sidekiq_options retry: RubyReactor.configuration.sidekiq_retry_count, dead: false,
14
- queue: RubyReactor.configuration.sidekiq_queue
15
-
16
- sidekiq_retries_exhausted do |_, exception|
17
- # Handle infrastructure failures (network, Redis, etc.)
18
- end
19
-
20
- # Identity-only payload: storage is the source of truth. Rehydrate the live
21
- # context from storage by id, then resume. A nil read means the context was
22
- # swept, expired, or already terminal-and-collected — nothing to resume.
23
- def perform(context_id, reactor_class_name = nil, snooze_count = 0)
24
- # Normalize so a nil/omitted name resolves to the same storage key the
25
- # enqueue path wrote (always via reactor_storage_name). Without this a
26
- # nil here builds "reactor::context:<id>" and misses the stored
27
- # "reactor:AnonymousReactor:context:<id>", silently no-op'ing.
28
- reactor_class_name ||= RubyReactor.reactor_storage_name(nil)
29
- data = RubyReactor.configuration.storage_adapter.retrieve_context(context_id, reactor_class_name)
30
- return if data.nil?
31
-
32
- begin
33
- context = ContextSerializer.deserialize_hash(data)
34
- rescue RubyReactor::Error::DeserializationError,
35
- RubyReactor::Error::SchemaVersionError => e
36
- # Permanent failures — re-reading the same stored blob will keep
37
- # failing. Mark the context as failed (best-effort) and return so
38
- # Sidekiq does not burn its retry budget.
39
- handle_deserialization_failure(context_id, reactor_class_name, e)
40
- return
41
- end
42
-
43
- # If reactor_class_name is provided, use it to get the reactor class
44
- # This handles cases where the class can't be found via const_get
45
- if reactor_class_name && context.reactor_class.nil?
46
- begin
47
- context.reactor_class = Object.const_get(reactor_class_name)
48
- rescue NameError
49
- # If not found, try to find it in the current namespace
50
- # This is a fallback for test environments
51
- context.reactor_class = reactor_class_name.constantize if reactor_class_name.respond_to?(:constantize)
52
- end
53
- end
54
-
55
- # Mark that we're executing inline to prevent nested async calls
56
- context.inline_async_execution = true
57
-
58
- begin
59
- # Resume execution from the failed step
60
- executor = Executor.new(context.reactor_class, {}, context)
61
- executor.resume_execution
62
- # No explicit save here: resume_execution's ensure block already persists
63
- # the final root state (`save_context unless skip_context_persist?`), and
64
- # in the worker the executor's context IS the root, so an extra checkpoint!
65
- # would just re-write the identical blob to the identical key. The
66
- # skip_context_persist? guard (stale-batch redelivery of an already-terminal
67
- # context) is likewise honored there.
68
-
69
- # Return the executor (which now has the result stored in it)
70
- executor
71
- rescue RubyReactor::Lock::AcquisitionError,
72
- RubyReactor::Semaphore::AcquisitionError,
73
- RubyReactor::RateLimit::ExceededError,
74
- RubyReactor::OrderedLock::WaitError => e
75
- # Snooze on expected concurrency, rate, or ordering contention.
76
- # OrderedLock::WaitError carries a poison-pill-derived retry hint,
77
- # consumed by compute_snooze_delay below. We avoid Sidekiq's native
78
- # retry path so this doesn't burn the job's retry budget or appear
79
- # as an error in dashboards. After the configured cap is reached we
80
- # escalate by marking the reactor as failed.
81
- handle_snooze(context_id, reactor_class_name, context, snooze_count, e)
82
- rescue RubyReactor::RateLimitRegistry::UnknownLimitError => e
83
- # Permanent configuration error — snoozing or retrying the same job
84
- # will keep failing. Mark the context failed immediately.
85
- escalate_snooze(context, snooze_count, e)
86
- end
87
- end
88
-
89
- private
90
-
91
- def handle_snooze(context_id, reactor_class_name, context, snooze_count, error)
92
- config = RubyReactor.configuration
93
- max = config.lock_snooze_max_attempts
94
-
95
- # OrderedLock::WaitError bypasses the snooze cap. The gate's
96
- # poison_pill_timeout is the only meaningful upper bound on how long a
97
- # nonce can legitimately wait; capping snoozes would either fail jobs
98
- # prematurely or strand the nonce in `assigned_at` until poison_pill
99
- # eventually advances past it. Snooze until the gate passes (or poison
100
- # auto-advance moves the cursor past us).
101
- # The per-context liveness lock (`async:<id>`) is also uncapped: a
102
- # duplicate of the *same* execution may wait arbitrarily long for the
103
- # live original to finish (e.g. a sweeper re-enqueue racing a slow but
104
- # alive worker). Capping it would fail a legitimately-waiting duplicate.
105
- capped = !(error.is_a?(RubyReactor::OrderedLock::WaitError) ||
106
- error.is_a?(RubyReactor::Lock::ContextLockContention))
107
-
108
- if capped && max != :infinity && snooze_count >= max
109
- escalate_snooze(context, snooze_count, error)
110
- return
111
- end
112
-
113
- delay = compute_snooze_delay(config, error)
114
- # Re-enqueue by id: the context is already persisted in storage, so the
115
- # rescheduled job rehydrates fresh state (no stale blob).
116
- self.class.perform_in(delay, context_id, reactor_class_name, snooze_count + 1)
117
- end
118
-
119
- # Use the error's `retry_after_seconds` hint when available
120
- # (RateLimit::ExceededError carries the time until the bucket rolls);
121
- # otherwise fall back to the configured base + jitter for lock/semaphore
122
- # contention which has no precise hint.
123
- #
124
- # OrderedLock::WaitError is deliberately excluded from the hint path: its
125
- # `retry_after_seconds` is the poison-pill window (the upper bound before
126
- # a *dead* blocker is force-advanced), NOT how long the *live* blocker
127
- # will take — which is usually milliseconds. Snoozing for the full window
128
- # would make every out-of-order nonce sleep up to poison_pill_timeout even
129
- # though its blocker finishes immediately, collapsing throughput. Re-poll
130
- # at the base delay instead; poison auto-advance still clears a genuinely
131
- # dead blocker on a later gate.
132
- def compute_snooze_delay(config, error)
133
- jitter = config.lock_snooze_jitter.to_f
134
- jitter_amount = jitter.positive? ? rand(0.0..jitter) : 0.0
135
-
136
- if hinted_retry?(error)
137
- [error.retry_after_seconds.to_f, 0.1].max + jitter_amount
138
- else
139
- config.lock_snooze_base_delay.to_f + jitter_amount
140
- end
141
- end
142
-
143
- def hinted_retry?(error)
144
- return false if error.is_a?(RubyReactor::OrderedLock::WaitError)
145
-
146
- error.respond_to?(:retry_after_seconds) && error.retry_after_seconds
147
- end
148
-
149
- def escalate_snooze(context, snooze_count, error)
150
- RubyReactor.configuration.logger.warn(
151
- "RubyReactor snooze limit reached after #{snooze_count} attempts " \
152
- "for context #{context.context_id}: #{error.message}"
153
- )
154
-
155
- context.status = :failed
156
- context.failure_reason = {
157
- message: error.message,
158
- exception_class: error.class.name,
159
- snooze_attempts: snooze_count
160
- }
161
-
162
- serialized = ContextSerializer.serialize(context)
163
- reactor_class_name = RubyReactor.reactor_storage_name(context.reactor_class)
164
- RubyReactor.configuration.storage_adapter.store_context(
165
- context.context_id,
166
- serialized,
167
- reactor_class_name
168
- )
169
-
170
- # Escalation is a terminal Failure that never reaches the Executor's
171
- # ensure path, so advance the ordered-lock cursor here. Without this
172
- # the nonce stays stranded in assigned_at (successors stall for the
173
- # full poison_pill_timeout) and, worse, the strict-mode chain marker
174
- # is never recorded — successors would RUN instead of being skipped.
175
- info = Executor::OrderedLockSupport.info_from(context)
176
- Executor::OrderedLockSupport.advance_with_retry(info, failed: true) if info
177
- end
178
-
179
- def log_infrastructure_failure(msg, exception)
180
- RubyReactor.configuration.logger.error("RubyReactor infrastructure failure: #{exception.message}")
181
- RubyReactor.configuration.logger.error("Job details: #{msg.inspect}")
182
- end
183
-
184
- # The id-only payload already carries context_id and reactor_class_name, so
185
- # there is no blob to parse for metadata — just mark the stored context
186
- # failed (best-effort) so the job stops retrying a permanently-broken blob.
187
- def handle_deserialization_failure(context_id, reactor_class_name, error)
188
- RubyReactor.configuration.logger.error(
189
- "RubyReactor deserialization failure for context " \
190
- "#{context_id || "unknown"}: #{error.class.name}: #{error.message}"
191
- )
192
-
193
- return unless context_id && reactor_class_name
194
-
195
- payload = build_failed_context_payload(context_id, reactor_class_name, error)
196
- RubyReactor.configuration.storage_adapter.store_context(
197
- context_id,
198
- payload,
199
- reactor_class_name
200
- )
201
- rescue StandardError => e
202
- # Don't let a persistence failure mask the original deserialization error.
203
- RubyReactor.configuration.logger.error(
204
- "RubyReactor failed to persist deserialization failure: #{e.class.name}: #{e.message}"
205
- )
206
- end
207
-
208
- def build_failed_context_payload(context_id, reactor_class_name, error)
209
- JSON.generate(
210
- "schema_version" => ContextSerializer::SCHEMA_VERSION,
211
- "context_id" => context_id,
212
- "reactor_class" => reactor_class_name,
213
- "status" => "failed",
214
- "failure_reason" => {
215
- "message" => error.message,
216
- "exception_class" => error.class.name
217
- }
218
- )
219
- end
220
- end
221
- end
222
- end