chrono_forge 0.11.0 → 0.12.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c2b3299dbce8cb34289b708af6e0d22d19d764de09978ae257cb87fd37322b5b
4
- data.tar.gz: f8ca2ae0af221d262d8109b06bd4c319a679d14ea0b440404ef96997134f8b17
3
+ metadata.gz: fd4d0c02357d2d185a6d82034d21a6e09c034c0ae04a9b2cbd5cfcb5bb29b8f0
4
+ data.tar.gz: 981e5d7208c8a80dacb9a13a439e28a896037980364b9a810f976daa93597d65
5
5
  SHA512:
6
- metadata.gz: c0e81af8116529cbcb1b13fc2f23c952c3622b13f615281f7f97c82ce41cda244316bbb77a99666c4709e8211747d931bdf31d7b02cad011119db8b49b72714d
7
- data.tar.gz: 427469cfe3d72bd2891d156227be9a32a1879cfa1dc8f52c8c54f87bf99feeabc1b8d9dfca46fb37e58bae6cb84f560f77fca94888dcfcbd32fededfc70bd1f1
6
+ metadata.gz: 7a4ccac2097e6c808103327201f00c1e79341bbd433f1564c5a2ffc306c3740509e195825e613781d62d27844e230ce12b3a048d116a2d44ba3b61ea11f16f7c
7
+ data.tar.gz: e974b5cfd5aaf41045b84947993cb1052a19503bab85e2309b8b5ea13563340fac5136800c30d1a63ee34408c47262ed67922dc21166a6f2361d3742d021be18
data/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## [0.12.0] - 2026-07-09
2
+
3
+ ### Documentation
4
+
5
+ - Document the stalled-workflow reaper and fix broken README anchors
6
+
7
+ ### Features
8
+
9
+ - Reap workflows stranded in running by hard-killed workers
10
+
1
11
  ## [0.11.0] - 2026-07-04
2
12
 
3
13
  ### Bug Fixes
data/README.md CHANGED
@@ -90,7 +90,7 @@ A few deliberate choices behind that table:
90
90
  - **ActiveJob Integration**: Compatible with all ActiveJob backends, though database-backed processors (like Solid Queue) provide the most reliable experience for long-running workflows
91
91
  - **Retention & Cleanup**: A schedulable job to prune finished workflows and the unbounded logs that periodic tasks accumulate (see [Cleanup & Retention](#-cleanup--retention))
92
92
 
93
- ## 🖥️ Dashboard
93
+ ## 🖥 Dashboard
94
94
 
95
95
  ChronoForge has a free, mountable dashboard for visibility and recovery: workflow list, step replay timeline, a per-run **definition graph** (the durable steps a workflow will run, statically parsed from `perform`, with live run status overlaid), context inspector, periodic-task health, wait-state age, and retry/unlock actions. It ships as a separate gem, `chrono_forge-dashboard`, so the core stays lean.
96
96
 
@@ -344,7 +344,15 @@ end
344
344
  - **Idempotent**: Same operation won't be executed twice during replays
345
345
  - **Automatic Retries**: Failed executions retry per a unified `RetryPolicy` (exponential backoff with jitter; the step default caps at 30s over 3 attempts)
346
346
  - **Error Tracking**: All failures are logged with detailed error information
347
- - **Configurable**: Pass a `retry_policy:` per call, or set a class-wide default with the `retry_policy` DSL (see [Retry Policies](#retry-policies))
347
+ - **Configurable**: Pass a `retry_policy:` per call, or set a class-wide default with the `retry_policy` DSL (see [Retry Policies](#-retry-policies))
348
+
349
+ > **Write your side effects to be idempotent.** A step short-circuits on replay only
350
+ > once its log row reaches `completed`. If a worker is hard-killed (SIGKILL/OOM/eviction)
351
+ > *after* a step's side effect commits but *before* its log is marked `completed`, the
352
+ > step re-runs when the workflow is resumed (including by the [reaper](#recovering-stranded-workflows-hard-killed-workers)).
353
+ > Make external side effects safe to repeat — use a natural/unique key with
354
+ > `create_or_find_by`, an upsert, or a rescue on the uniqueness violation — rather than
355
+ > assuming exactly-once execution.
348
356
 
349
357
  #### 🔁 Retry Policies
350
358
 
@@ -918,6 +926,7 @@ stateDiagram-v2
918
926
  - **Identifiers**: Has locked_at and locked_by values set
919
927
  - **Behavior**: Protected against concurrent execution
920
928
  - **Duration**: Should be brief unless performing long operations
929
+ - **Note**: A workflow whose worker was hard-killed mid-pass stays stuck here with a stale lock. See [Recovering stranded workflows](#recovering-stranded-workflows-hard-killed-workers).
921
930
 
922
931
  #### Completed
923
932
  - **Description**: The workflow has successfully executed all steps
@@ -966,25 +975,60 @@ ChronoForge::Workflow.failed.find_each(&:retry_later)
966
975
  The class-level form (`MyWorkflow.retry_now(key)` / `retry_later(key)`) still
967
976
  works if you have the class and key rather than the record.
968
977
 
969
- #### Monitoring Running Workflows
978
+ #### Recovering stranded workflows (hard-killed workers)
979
+
980
+ When a worker is **hard-killed** mid-pass — SIGKILL from a deploy/rollout, an OOM kill,
981
+ a node eviction, or a SolidQueue heartbeat prune — Ruby's `ensure` block does not run.
982
+ The executor releases the workflow's lock and publishes the resume continuation in that
983
+ `ensure`, so a hard kill leaves the workflow stuck in `running` with a stale lock **and**
984
+ nothing scheduled to wake it. It is fully resumable (a resuming pass steals the stale
985
+ lock and replays completed steps as no-ops), but nothing re-enqueues it on its own:
986
+ `retry_now`/`retry_later` refuse a `running` workflow, and a dashboard "force unlock"
987
+ clears the lock but enqueues no job.
970
988
 
971
- Long-running workflows might indicate issues:
989
+ `ChronoForge::Workflow.reap_stalled` reconciles these. It finds every workflow in
990
+ `running` whose lock is older than `reap_stale_after` (top-level workflows **and** branch
991
+ children) and re-enqueues it, returning the number reaped:
972
992
 
973
993
  ```ruby
974
- # Find workflows running for too long
975
- long_running = ChronoForge::Workflow.where(state: :running)
976
- .where('locked_at < ?', 30.minutes.ago)
994
+ ChronoForge::Workflow.reap_stalled
995
+ # => 3 (also logs "ChronoForge reaped 3 stalled workflow(s)")
977
996
 
978
- long_running.each do |workflow|
979
- # Log potential issues for investigation
980
- Rails.logger.warn "Workflow #{workflow.key} has been running for >30 minutes"
981
-
982
- # Optionally force unlock if you suspect deadlock
983
- # CAUTION: Only do this if you're certain the job is stuck
984
- # workflow.update!(locked_at: nil, locked_by: nil, state: :idle)
997
+ # Override the threshold for a one-off sweep:
998
+ ChronoForge::Workflow.reap_stalled(stale_after: 15.minutes)
999
+ ```
1000
+
1001
+ It is **not** run automatically schedule it from your own scheduler, the same way you
1002
+ schedule `ChronoForge::Cleanup`. For example, a SolidQueue recurring task:
1003
+
1004
+ ```yaml
1005
+ # config/recurring.yml
1006
+ reap_stalled_workflows:
1007
+ command: "ChronoForge::Workflow.reap_stalled"
1008
+ schedule: "every 5 minutes"
1009
+ ```
1010
+
1011
+ Re-enqueue is safe under concurrency: overlapping sweeps (or a re-enqueue landing while
1012
+ the old stale lock still shows) at worst enqueue a duplicate, which loses the
1013
+ lock-acquisition race and no-ops. Because reaping replays the interrupted pass, steps
1014
+ with external side effects must be idempotent — see the note under
1015
+ [Durable Execution](#-durable-execution).
1016
+
1017
+ `reap_stale_after` defaults to **3× `max_duration`** (30 minutes out of the box). Both are
1018
+ configurable, and because the reap threshold derives from `max_duration` it always stays
1019
+ safely above the lock-steal threshold — raise one and the other follows:
1020
+
1021
+ ```ruby
1022
+ ChronoForge.configure do |c|
1023
+ c.max_duration = 10.minutes # how long one pass may hold its lock before it's stealable
1024
+ c.reap_stale_after = 45.minutes # optional: pin the reap threshold explicitly (else 3x max_duration)
985
1025
  end
986
1026
  ```
987
1027
 
1028
+ > **Not covered:** a workflow parked on a branch merge whose `BranchMergeJob` poller was
1029
+ > itself hard-killed sits `idle` (not `running`), so the reaper does not sweep it. That is
1030
+ > a distinct failure mode.
1031
+
988
1032
  ## 🧹 Cleanup & Retention
989
1033
 
990
1034
  ChronoForge keeps every workflow and execution-log row indefinitely so that
@@ -64,8 +64,7 @@ degradation as the tables grew. The path has a **stable steady-state ceiling**.
64
64
 
65
65
  ## Part 2 — Benchmark: baseline vs commit-consolidation
66
66
 
67
- **Baseline** is the current released engine (v0.10). **Consolidation** is the
68
- unreleased patch targeted for **v0.12** that cuts per-child write cost without
67
+ **Baseline** is (v0.10). **Consolidation** is the patch released in **v0.11** that cuts per-child write cost without
69
68
  changing behaviour. It (a) folds `started_at` into the lock-acquire
70
69
  transaction, (b) collapses `complete_workflow!`'s three separate commits — marker
71
70
  INSERT + `workflow.completed!` UPDATE + marker UPDATE — into one transaction, and
@@ -0,0 +1,175 @@
1
+ # Design: Stalled-Workflow Reaper for ChronoForge
2
+
3
+ **Date:** 2026-07-09
4
+ **Gem:** `chrono_forge` (with `chrono_forge-dashboard`)
5
+ **Status:** Approved for planning
6
+
7
+ ## Problem
8
+
9
+ A workflow that is mid-pass when its worker is **hard-killed** (SIGKILL: deploy/rollout,
10
+ OOM, node eviction, SolidQueue heartbeat prune) is stranded permanently in `state: running`
11
+ with a stale lock, and nothing ever resumes it.
12
+
13
+ The mechanism, confirmed against the code:
14
+
15
+ - `Executor#perform` releases the lock and publishes the resume continuation in its `ensure`
16
+ block (`lib/chrono_forge/executor.rb:178-197`): `release_lock` first, then
17
+ `flush_continuation!`.
18
+ - `enqueue_continuation` (`executor.rb:369-371`) only records `@continuation` in memory;
19
+ `flush_continuation!` (`executor.rb:376-382`) is what actually enqueues the ActiveJob.
20
+ - On SIGKILL, Ruby's `ensure` does not run. So (1) `release_lock` never runs — the row stays
21
+ `state: running` with a past `locked_at`; and (2) `flush_continuation!` never runs — no job
22
+ is scheduled to resume the workflow.
23
+
24
+ The workflow is fully *resumable* — `Workflow#executable?` is `idle? || running?`
25
+ (`workflow.rb:48-50`) and `LockStrategy.acquire_lock` steals any lock older than
26
+ `max_duration` (10 min) (`lock_strategy.rb:17-21`) — but nothing re-enqueues it. Existing
27
+ recovery mechanisms do not cover this:
28
+
29
+ - **`BranchMergeJob` rekick** (`branch_merge_job.rb:232-273`) only re-enqueues children that
30
+ are `state: idle, started_at: nil` (never-started dropped dispatches). A child hard-killed
31
+ **mid-pass** is `state: running` with `started_at` set — explicitly excluded (see the
32
+ comment at `branch_merge_job.rb:226-231`). So branch children have the identical gap.
33
+ - **Workflow-level retry** rides on the same process that must survive to its `ensure`; a
34
+ killed process never reaches it.
35
+ - **`retry_now`/`retry_later`** require `retryable?` (`stalled? || failed?`,
36
+ `workflow.rb:53-55`); a stranded workflow is `running`, so they refuse it.
37
+ - **Dashboard "Force unlock"** clears the lock but enqueues nothing.
38
+
39
+ Real-world impact (reported): 735 workflows stranded over ~8.5 months in one production app,
40
+ still accruing ~8/day.
41
+
42
+ ## Solution Overview
43
+
44
+ Add a reconciliation entry point, `ChronoForge::Workflow.reap_stalled`, that finds workflows
45
+ stuck in `running` past a safety threshold and re-enqueues them. Re-enqueue is safe:
46
+ `acquire_lock` steals the stale lock and completed durable steps replay as no-ops.
47
+
48
+ ### Decisions
49
+
50
+ | Decision | Choice | Rationale |
51
+ |---|---|---|
52
+ | Shipping | **Class method only** | No Railtie/rake task/self-scheduling job. Host apps call it from their own cron/recurring job. Smallest surface; no new gem infrastructure. |
53
+ | Scope | **All stranded `running` workflows** (top-level AND branch children) | Strictly more correct — rekick misses mid-pass-killed children. Re-enqueue of a child directly is the proven-safe pattern rekick already uses. |
54
+ | Threshold | **Config + per-call override** | `ChronoForge.config.reap_stale_after` (default `30.minutes`), overridable via `stale_after:` arg. Parity with the existing `branch_merge_queue` config. |
55
+ | Deliverable | **Code + tests + docs** | Implementation, tests (incl. simulated hard-kill strand), and docs covering the reaper + step-idempotency expectations. |
56
+
57
+ ## Component
58
+
59
+ `ChronoForge::Workflow.reap_stalled` — a class method on the AR model. It only queries and
60
+ re-enqueues; it holds no lock and performs no replay itself (the re-enqueued job does the
61
+ replay, under the normal lock protocol).
62
+
63
+ ```ruby
64
+ # ChronoForge::Workflow
65
+ def self.reap_stalled(stale_after: ChronoForge.config.reap_stale_after)
66
+ reaped = 0
67
+ where(state: states[:running])
68
+ .where("locked_at < ?", stale_after.ago)
69
+ .find_each do |wf|
70
+ wf.job_klass.perform_later(wf.key, **wf.kwargs.symbolize_keys)
71
+ reaped += 1
72
+ rescue => e
73
+ Rails.logger.error { "ChronoForge reap failed for workflow(#{wf.key}): #{e.class}: #{e.message}" }
74
+ end
75
+ Rails.logger.info { "ChronoForge reaped #{reaped} stalled workflow(s)" } if reaped.positive?
76
+ reaped
77
+ end
78
+ ```
79
+
80
+ Design points:
81
+
82
+ - **Target = `running` + stale `locked_at`.** The strand happens precisely because the
83
+ `ensure` never ran, so `release_lock` never flipped the row off `running`. A `NULL locked_at`
84
+ cannot match `locked_at < ?` and is naturally excluded (an anomalous running-without-lock row
85
+ is not our target).
86
+ - **Sweeps children too.** No `parent_execution_log_id` filter. A re-enqueued child runs,
87
+ replays, completes, and updates its `BranchProbe` state so the parent's merge poller observes
88
+ completion.
89
+ - **Re-enqueue via the public guarded `perform_later`.** `wf.kwargs` is a JSON column (string
90
+ keys); `.symbolize_keys` for `**`, matching `setup_workflow!` and `rekick_dropped_jobs`. The
91
+ guard rejects `RESERVED_KWARGS`, which stored user kwargs never contain. Fresh `attempt: 0`
92
+ — a full workflow-retry budget, exactly like a normal resume.
93
+ - **Per-row rescue** so one bad row (e.g. cross-version kwarg drift tripping the enqueue guard)
94
+ never aborts the sweep — mirrors `rekick_dropped_jobs` (`branch_merge_job.rb:265-270`).
95
+ - **Returns the reaped count** for cron visibility; logs via `Rails.logger` block syntax.
96
+
97
+ ## Configuration
98
+
99
+ `max_duration` (previously a hardcoded `10.minutes` in the executor) becomes a config value,
100
+ and `reap_stale_after` **derives from it** so the "reap threshold must exceed the lock-steal
101
+ threshold" invariant is automatic rather than documented-and-hoped.
102
+
103
+ Add to `ChronoForge::Configuration`:
104
+
105
+ ```ruby
106
+ # How long a single pass may hold its lock before it's stealable (feeds
107
+ # LockStrategy.acquire_lock via the executor's #max_duration). Default 10 minutes.
108
+ attr_accessor :max_duration
109
+
110
+ # Age past which a :running workflow is considered stranded and re-enqueued by
111
+ # Workflow.reap_stalled. Defaults to 3x max_duration (30 min out of the box) so it always
112
+ # clears the lock-steal threshold; an explicit value overrides the derived default.
113
+ def reap_stale_after
114
+ @reap_stale_after || max_duration * 3
115
+ end
116
+ attr_writer :reap_stale_after
117
+
118
+ # in #initialize:
119
+ @max_duration = 10.minutes
120
+ @reap_stale_after = nil
121
+ ```
122
+
123
+ The executor's `#max_duration` now reads `ChronoForge.config.max_duration` instead of
124
+ returning a literal. Deriving `reap_stale_after` from `max_duration` means raising one raises
125
+ the other, so the reaper can never be configured below the lock-steal threshold by accident.
126
+
127
+ ## Idempotency & Safety (documented behavior)
128
+
129
+ - **Concurrency-safe.** Overlapping cron runs, or a re-enqueue landing while the old stale
130
+ lock still shows, at worst enqueue a duplicate job — the second loses the `acquire_lock`
131
+ race and no-ops via `ConcurrentExecutionError`. Mild, harmless churn. Recommend a cron
132
+ interval comfortably longer than a typical resume pass.
133
+ - **Step idempotency.** Reaping replays the interrupted pass. A `durably_execute` step only
134
+ short-circuits when `completed`, so a step whose side effect committed but whose log never
135
+ reached `completed` will re-run on replay. Steps with external side effects must be
136
+ idempotent (natural/unique key + `create_or_find_by`/rescue). Documented alongside
137
+ `wait`/`durably_execute`.
138
+
139
+ ## Non-Goals (conscious boundaries)
140
+
141
+ - **Dropped `BranchMergeJob` poller → parent stranded `idle`.** A parent parked on a merge
142
+ whose poller was hard-killed sits `idle`, not `running`, so this reaper won't catch it. A
143
+ distinct failure mode; the code already hints (`branch_merge_job.rb:181-184`) that a
144
+ "`next_poll_at` far in the past with work still pending" detector is the right fix. Out of
145
+ scope here.
146
+ - **Micro-window between `release_lock` and `flush_continuation!`.** A process dying between
147
+ those two statements yields `idle` + no continuation. Theoretically possible, not seen in the
148
+ reported evidence (all strands were `running`), not covered.
149
+
150
+ ## Testing
151
+
152
+ - **Query selection (unit):** selects only `running` + stale-lock rows; excludes
153
+ fresh-lock `running`, `idle`, `completed`, `failed`, `stalled`, and `NULL locked_at`.
154
+ - **Strand-and-recover (behavioral):** create a workflow, force it into `running` with an old
155
+ `locked_at` and no scheduled resume job (simulating the hard-kill), run `reap_stalled`,
156
+ assert a job is enqueued and that draining the queue resumes and completes the workflow.
157
+ Cover both a top-level workflow and a branch child.
158
+ - **Per-row rescue:** a row that raises on re-enqueue does not abort the sweep; the returned
159
+ count reflects only successful re-enqueues.
160
+ - **Config:** default `reap_stale_after` honored; per-call `stale_after:` override honored.
161
+
162
+ ## Documentation
163
+
164
+ - Document `Workflow.reap_stalled` and the recommended cron wiring (host-owned; no shipped
165
+ task), including the `reap_stale_after > max_duration` constraint.
166
+ - Document the step-idempotency expectation alongside `wait`/`durably_execute`.
167
+
168
+ ## Alternatives Considered
169
+
170
+ - **Railtie + `chrono_forge:reap` rake task** (angarium `angarium:reap` parity) — rejected;
171
+ heavier, would add the gem's first Railtie.
172
+ - **Self-scheduling reaper ActiveJob** — rejected; adds an always-on job and scheduling
173
+ assumptions the host may not want.
174
+ - **Top-level-only sweep** (`parent_execution_log_id IS NULL`) — rejected; leaves
175
+ mid-pass-killed branch children stranded, which rekick does not recover.
@@ -18,8 +18,34 @@ module ChronoForge
18
18
  # runs promptly throughout the drain.
19
19
  attr_accessor :branch_merge_queue
20
20
 
21
+ # How long a single workflow pass may hold its lock before another job is
22
+ # allowed to steal it (LockStrategy.acquire_lock treats a lock older than this
23
+ # as stale). It bounds the assumed maximum duration of one execution pass.
24
+ # Defaults to 10 minutes.
25
+ attr_accessor :max_duration
26
+
21
27
  def initialize
22
28
  @branch_merge_queue = :default
29
+ @max_duration = 10.minutes
30
+ @reap_stale_after = nil
23
31
  end
32
+
33
+ # Age past which a workflow still in :running is treated as stranded and
34
+ # re-enqueued by ChronoForge::Workflow.reap_stalled. A workflow reaches this
35
+ # state when its worker is hard-killed (SIGKILL/OOM/eviction) mid-pass, before
36
+ # the executor's `ensure` block could release the lock and publish the resume
37
+ # continuation — so it stays locked in :running with nothing scheduled to wake it.
38
+ #
39
+ # Defaults to 3x max_duration (30 min out of the box), so it always comfortably
40
+ # exceeds the lock-steal threshold: acquire_lock only steals locks older than
41
+ # max_duration, so a shorter reap threshold would just enqueue resume jobs that
42
+ # immediately no-op via ConcurrentExecutionError. Deriving from max_duration keeps
43
+ # that invariant automatic — raise max_duration and the reaper backs off with it.
44
+ # An explicit value (set via the writer) overrides the derived default.
45
+ def reap_stale_after
46
+ @reap_stale_after || max_duration * 3
47
+ end
48
+
49
+ attr_writer :reap_stale_after
24
50
  end
25
51
  end
@@ -394,7 +394,7 @@ module ChronoForge
394
394
  end
395
395
 
396
396
  def max_duration
397
- 10.minutes
397
+ ChronoForge.config.max_duration
398
398
  end
399
399
  end
400
400
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ChronoForge
4
- VERSION = "0.11.0"
4
+ VERSION = "0.12.0"
5
5
  end
@@ -45,6 +45,49 @@ module ChronoForge
45
45
  stalled
46
46
  ]
47
47
 
48
+ # Reconcile workflows stranded in :running by a hard-killed worker. When a
49
+ # worker is SIGKILLed (deploy/rollout, OOM, node eviction, SolidQueue heartbeat
50
+ # prune) mid-pass, the executor's `ensure` block never runs, so the lock is
51
+ # never released (the row stays :running with a stale locked_at) and the resume
52
+ # continuation is never published — nothing is left to wake the workflow. No
53
+ # other mechanism recovers this: workflow-level retry rides on the same process
54
+ # that must reach its `ensure`; retry_now/retry_later require stalled?/failed?;
55
+ # and BranchMergeJob rekick only re-drives never-started idle children.
56
+ #
57
+ # This sweeps every workflow in :running whose lock is older than `stale_after`
58
+ # (top-level AND branch children) and re-enqueues it. Re-enqueue is safe:
59
+ # acquire_lock steals the stale lock and completed durable steps replay as
60
+ # no-ops. Overlapping sweeps (or a re-enqueue landing while the old stale lock
61
+ # still shows) at worst enqueue a duplicate, which loses the acquire_lock race
62
+ # and no-ops via ConcurrentExecutionError.
63
+ #
64
+ # Intended to be run periodically by the host app (e.g. a SolidQueue recurring
65
+ # task or cron). Returns the number of workflows re-enqueued.
66
+ #
67
+ # NOTE: replaying an interrupted pass re-runs any durably_execute step whose
68
+ # side effect committed but whose log never reached :completed. Steps with
69
+ # external side effects must be idempotent (natural/unique key +
70
+ # create_or_find_by/rescue).
71
+ def self.reap_stalled(stale_after: ChronoForge.config.reap_stale_after)
72
+ reaped = 0
73
+ where(state: states[:running])
74
+ .where("locked_at < ?", stale_after.ago)
75
+ .find_each do |workflow|
76
+ # Guarded per row: one bad workflow (e.g. a since-deleted job class that
77
+ # no longer constantizes, or cross-version kwarg drift tripping the
78
+ # enqueue guard) must never abort the sweep and strand every healthy
79
+ # sibling. Mirrors BranchMergeJob#rekick_dropped_jobs.
80
+ workflow.job_klass.perform_later(workflow.key, **workflow.kwargs.symbolize_keys)
81
+ reaped += 1
82
+ rescue => e
83
+ Rails.logger.error do
84
+ "ChronoForge reap failed for workflow(#{workflow.key}): #{e.class}: #{e.message}"
85
+ end
86
+ end
87
+ Rails.logger.info { "ChronoForge reaped #{reaped} stalled workflow(s)" } if reaped.positive?
88
+ reaped
89
+ end
90
+
48
91
  def executable?
49
92
  idle? || running?
50
93
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chrono_forge
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Froelich
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-04 00:00:00.000000000 Z
11
+ date: 2026-07-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -234,6 +234,7 @@ files:
234
234
  - docs/superpowers/specs/2026-06-26-dashboard-branch-view-design.md
235
235
  - docs/superpowers/specs/2026-06-26-deferral-continuation-race-and-catchup-design.md
236
236
  - docs/superpowers/specs/2026-07-01-workflow-definition-dag-design.md
237
+ - docs/superpowers/specs/2026-07-09-chrono-forge-reaper-design.md
237
238
  - examples/continue_if_webhook_example.rb
238
239
  - gemfiles/rails_7.1.gemfile
239
240
  - gemfiles/rails_7.1.gemfile.lock