chrono_forge 0.9.1 → 0.11.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/README.md +390 -46
  4. data/Rakefile +4 -0
  5. data/cliff.toml +62 -0
  6. data/docs/design/per-child-commit-overhead.md +213 -0
  7. data/docs/fanout-scale-test.md +247 -0
  8. data/docs/superpowers/plans/2026-06-25-chrono_forge-dashboard.md +1748 -0
  9. data/docs/superpowers/plans/2026-06-25-chrono_forge-dashboard.md.tasks.json +17 -0
  10. data/docs/superpowers/plans/2026-06-25-composite-retry-policies.md +930 -0
  11. data/docs/superpowers/plans/2026-06-25-composite-retry-policies.md.tasks.json +54 -0
  12. data/docs/superpowers/plans/2026-06-25-reserved-kwarg-guard.md +241 -0
  13. data/docs/superpowers/plans/2026-06-25-reserved-kwarg-guard.md.tasks.json +12 -0
  14. data/docs/superpowers/plans/2026-06-26-branches-spawn-merge.md +1378 -0
  15. data/docs/superpowers/plans/2026-06-26-branches-spawn-merge.md.tasks.json +67 -0
  16. data/docs/superpowers/plans/2026-06-26-deferral-continuation-race-and-catchup.md +709 -0
  17. data/docs/superpowers/plans/2026-06-26-deferral-continuation-race-and-catchup.md.tasks.json +19 -0
  18. data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md +205 -0
  19. data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md.tasks.json +33 -0
  20. data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md +1373 -0
  21. data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md.tasks.json +68 -0
  22. data/docs/superpowers/specs/2026-06-03-unified-retry-policy-design.md +226 -0
  23. data/docs/superpowers/specs/2026-06-25-chrono_forge-dashboard-design.md +190 -0
  24. data/docs/superpowers/specs/2026-06-25-composite-retry-policies-design.md +228 -0
  25. data/docs/superpowers/specs/2026-06-25-reserved-kwarg-guard-design.md +169 -0
  26. data/docs/superpowers/specs/2026-06-25-spawn-merge-branches-design.md +468 -0
  27. data/docs/superpowers/specs/2026-06-26-dashboard-branch-view-design.md +142 -0
  28. data/docs/superpowers/specs/2026-06-26-deferral-continuation-race-and-catchup-design.md +265 -0
  29. data/docs/superpowers/specs/2026-07-01-workflow-definition-dag-design.md +203 -0
  30. data/lib/chrono_forge/branch_merge_job.rb +275 -0
  31. data/lib/chrono_forge/branch_probe.rb +70 -0
  32. data/lib/chrono_forge/cleanup.rb +6 -0
  33. data/lib/chrono_forge/configuration.rb +25 -0
  34. data/lib/chrono_forge/definition.rb +37 -0
  35. data/lib/chrono_forge/definition_analyzer.rb +501 -0
  36. data/lib/chrono_forge/execution_log.rb +6 -0
  37. data/lib/chrono_forge/executor/composite_retry_policy.rb +47 -0
  38. data/lib/chrono_forge/executor/context.rb +23 -0
  39. data/lib/chrono_forge/executor/lock_strategy.rb +10 -3
  40. data/lib/chrono_forge/executor/methods/branch.rb +185 -0
  41. data/lib/chrono_forge/executor/methods/continue_if.rb +15 -6
  42. data/lib/chrono_forge/executor/methods/durably_execute.rb +36 -26
  43. data/lib/chrono_forge/executor/methods/durably_repeat.rb +148 -39
  44. data/lib/chrono_forge/executor/methods/merge_branches.rb +84 -0
  45. data/lib/chrono_forge/executor/methods/wait.rb +2 -4
  46. data/lib/chrono_forge/executor/methods/wait_until.rb +25 -25
  47. data/lib/chrono_forge/executor/methods/workflow_states.rb +50 -46
  48. data/lib/chrono_forge/executor/methods.rb +2 -0
  49. data/lib/chrono_forge/executor/retry_policy.rb +111 -0
  50. data/lib/chrono_forge/executor.rb +241 -28
  51. data/lib/chrono_forge/version.rb +1 -1
  52. data/lib/chrono_forge/workflow.rb +10 -1
  53. data/lib/chrono_forge.rb +8 -0
  54. data/lib/generators/chrono_forge/migration_actions.rb +1 -0
  55. data/lib/generators/chrono_forge/templates/add_chrono_forge_parent_execution_log.rb +38 -0
  56. data/lib/tasks/release.rake +212 -0
  57. metadata +67 -4
  58. data/lib/chrono_forge/executor/retry_strategy.rb +0 -29
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChronoForge
4
+ # Lightweight poller that joins one or more branches. NOT a workflow — it holds
5
+ # no lock, does no replay, and carries no context. It exists so the heavy parent
6
+ # workflow is replayed only twice per merge (kick off + completion wake).
7
+ #
8
+ # DEPLOY NOTE — queue placement matters. merge_branches enqueues this poller
9
+ # AFTER dispatching the branch's children, so if it runs on the SAME queue as a
10
+ # large fan-out's children it is starved behind the whole backlog and only gets a
11
+ # worker slot near the end. It then polls once, at pending≈0, with no prior sample
12
+ # (rate 0) and backs off to max_interval — so the parent's convergence lags by up
13
+ # to max_interval and no mid-drain throughput sample is ever recorded. Set
14
+ # ChronoForge.config.branch_merge_queue to a queue NOT saturated by the fan-out's
15
+ # own children so it polls throughout the drain (ETA cadence then converges
16
+ # tightly). See ChronoForge::Configuration and docs/fanout-scale-test.md.
17
+ class BranchMergeJob < ActiveJob::Base
18
+ # Resolved per-enqueue from config (a block, so changing the config takes effect
19
+ # without redefining the job — and it can't be silently reset by a code reload,
20
+ # unlike a queue_as monkey-patch in a to_prepare block).
21
+ queue_as { ChronoForge.config.branch_merge_queue }
22
+
23
+ # The poller is the parent's only wake mechanism, so survive TRANSIENT
24
+ # infrastructure errors (DB connection/timeout/deadlock) with backoff. Any
25
+ # other error — a programming bug, a bad guard — is NOT retried: it propagates
26
+ # to the backend's failed-job queue where it's visible, rather than being
27
+ # silently retried-then-discarded (which would orphan the parent in :idle).
28
+ retry_on ActiveRecord::ConnectionNotEstablished,
29
+ ActiveRecord::ConnectionTimeoutError,
30
+ ActiveRecord::Deadlocked,
31
+ ActiveRecord::LockWaitTimeout,
32
+ wait: :polynomially_longer, attempts: 25
33
+
34
+ ETA_FRACTION = 0.5 # poll at this fraction of the projected time-to-drain
35
+ REKICK_AFTER = 5.minutes
36
+ REKICK_BATCH = 200 # bound per-run rekicks; later polls handle the rest
37
+
38
+ def perform(parent_key, parent_job_class, branch_log_ids, min_interval, max_interval, token = nil)
39
+ raise ArgumentError, "branch_log_ids must not be empty" if branch_log_ids.empty?
40
+
41
+ # Fencing: every merge_branches pass mints a fresh token and writes it onto
42
+ # the branch logs, so a poller from a superseded chain (parent replay /
43
+ # re-enqueue) holds a stale token. It stops quietly — no poll, no wake, no
44
+ # reschedule — leaving only the newest chain to drive the merge. (A nil token
45
+ # is a pre-upgrade job enqueued before fencing existed; it runs unfenced.)
46
+ logs = ExecutionLog.where(id: branch_log_ids).to_a
47
+ return if superseded?(logs, token)
48
+
49
+ # Per-branch probe (kept as maps so we can persist each branch's own state,
50
+ # not just the merge aggregate). Same query count as a plain sum/all?.
51
+ # The pending count is UNCAPPED: it feeds the drain signal below (a change in
52
+ # pending since the prior poll), which a CAP would flatten into a false
53
+ # "not draining" for large branches.
54
+ prev_pending_by_branch = logs.to_h { |l| [l.id, l.metadata&.dig("poll", "pending")] }
55
+ pending_by_branch = branch_log_ids.to_h { |id| [id, BranchProbe.incomplete(id).count] }
56
+ sealed_by_branch = branch_log_ids.to_h { |id| [id, BranchProbe.sealed?(id)] }
57
+ pending = pending_by_branch.values.sum
58
+ sealed = sealed_by_branch.values.all?
59
+
60
+ # Total children spawned per branch. Immutable once the branch is SEALED
61
+ # (dispatch done), so we count it exactly ONCE and cache it on the metadata;
62
+ # every later poll (and the dashboard) reuses the cached value, never recounting.
63
+ # Unsealed (mid-spawn, count still climbing) => nil, and the dashboard falls back
64
+ # to its capped live count until the seal freezes the total.
65
+ logs_by_id = logs.index_by(&:id)
66
+ spawned_by_branch = branch_log_ids.to_h do |id|
67
+ cached = logs_by_id[id]&.metadata&.dig("poll", "spawned")
68
+ [id, cached || (sealed_by_branch[id] ? BranchProbe.spawned(id).count : nil)]
69
+ end
70
+
71
+ if sealed && pending.zero?
72
+ record_poll!(pending_by_branch, sealed_by_branch, token, next_poll_at: nil, interval: nil,
73
+ rate_by_branch: {}, never_started_by_branch: {}, spawned_by_branch: spawned_by_branch, rekicked_by_branch: {})
74
+ parent_job_class.constantize.perform_later(parent_key)
75
+ return
76
+ end
77
+
78
+ # DISPATCHED (never-started) count per branch — the rekick drain signal. A
79
+ # drop since the prior poll means workers are consuming this branch's queue,
80
+ # so a still-queued child is in line; a flat count with stale never-started
81
+ # children is a dropped job to recover. Keyed off this, NOT total pending,
82
+ # which a wait/wait_until child completing would drop without any never-started
83
+ # child moving (masking a genuinely-dropped one behind staggered waits).
84
+ prev_never_started_by_branch = logs.to_h { |l| [l.id, l.metadata&.dig("poll", "never_started")] }
85
+ never_started_by_branch = branch_log_ids.to_h { |id| [id, BranchProbe.never_started(id).count] }
86
+
87
+ rekicked_by_branch = rekick_dropped_jobs(branch_log_ids, never_started_by_branch, prev_never_started_by_branch)
88
+
89
+ # Cadence is driven by ESTIMATED TIME-TO-DRAIN, measured from the prior
90
+ # poll's persisted pending. `motion` (EXISTS probes) is the fallback signal
91
+ # when nothing completed this interval: :running => a live worker is
92
+ # executing a child (hold the floor, it'll finish); :never_started => the only
93
+ # motion is a queued/rekicked-but-unpicked child (back off exponentially,
94
+ # it may never be picked up); :none => blocked/waiting (max backstop).
95
+ # See reschedule_delay. Computed lazily below, only off the drain path.
96
+ prior = logs.map { |l| l.metadata&.dig("poll") }
97
+ # Only trust the AGGREGATE prev_pending when every requested branch log is
98
+ # loaded AND carries a prior sample — otherwise `pending` (over all
99
+ # branch_log_ids) and prev_pending (over loaded logs) would cover different
100
+ # sets and yield a bogus aggregate rate. Missing/partial => no sample =>
101
+ # bootstrap. Per-branch rate below is independently safe (missing => nil => 0).
102
+ complete_prior = logs.size == branch_log_ids.size && prior.all?
103
+ prev_pending = (prior.sum { |p| p["pending"].to_i } if complete_prior)
104
+ prev_polled_at = prior.filter_map { |p| p && p["last_polled_at"] }.map { |s| Time.zone.parse(s) }.min
105
+ elapsed = prev_polled_at && (Time.current - prev_polled_at)
106
+ prev_delay = prior.filter_map { |p| p && p["interval"] }.max
107
+
108
+ # Drain rate = children completed / second since the prior poll — THIS is the
109
+ # throughput surfaced on the dashboard. Per branch for display; aggregated for
110
+ # the ETA. Zero unless the branch actually drained (a no-headway / cold poll).
111
+ # NOTE: the aggregate ETA blurs a heterogeneous multi-branch merge; acceptable
112
+ # (the common case is single-branch; clamp + per-poll re-estimate bound any
113
+ # skew, and only poll timing is affected — the parent is still woken).
114
+ drained = ->(pend, prev) { prev && elapsed && elapsed > 0 && pend < prev }
115
+ rate_by_branch = pending_by_branch.to_h do |id, pend|
116
+ prev = prev_pending_by_branch[id]
117
+ [id, drained.call(pend, prev) ? (prev - pend) / elapsed.to_f : 0.0]
118
+ end
119
+ rate = drained.call(pending, prev_pending) ? (prev_pending - pending) / elapsed.to_f : 0.0
120
+
121
+ # Only needed when the ETA branch won't be taken (rate == 0); computing the
122
+ # EXISTS probes lazily keeps them off the hot drain path. See reschedule_delay.
123
+ motion = if rate > 0 then nil
124
+ elsif branch_log_ids.any? { |id| BranchProbe.running?(id) } then :running
125
+ elsif never_started_by_branch.values.any?(&:positive?) then :never_started
126
+ else :none
127
+ end
128
+
129
+ delay = reschedule_delay(pending, rate, motion, prev_delay, min_interval, max_interval)
130
+ record_poll!(pending_by_branch, sealed_by_branch, token, next_poll_at: delay.seconds.from_now,
131
+ interval: delay, rate_by_branch: rate_by_branch, never_started_by_branch: never_started_by_branch,
132
+ spawned_by_branch: spawned_by_branch, rekicked_by_branch: rekicked_by_branch)
133
+ self.class.set(wait: delay.seconds)
134
+ .perform_later(parent_key, parent_job_class, branch_log_ids, min_interval, max_interval, token)
135
+ end
136
+
137
+ private
138
+
139
+ # Adaptive poll cadence driven by ESTIMATED TIME-TO-DRAIN, not backlog size.
140
+ # When the branch-set drained since the last poll we project completion from
141
+ # the measured rate and poll at ETA_FRACTION of it, clamped [min, max]. Because
142
+ # each poll re-estimates against the shrinking remainder, cadence converges
143
+ # geometrically and detects the merge within ~min_interval of the last child
144
+ # finishing — where the old count-based cadence polled SLOWEST (max_interval)
145
+ # exactly when a fast-draining backlog was about to complete.
146
+ #
147
+ # No completion observed this interval — fall back on `motion`:
148
+ # :running => a live worker is executing a child; it will finish, so hold
149
+ # the responsive floor (matches prior behaviour and avoids
150
+ # waking the parent late for a slow/low-fan-out child).
151
+ # :never_started => the only motion is a queued/rekicked-but-unpicked child that
152
+ # may never be picked up => exponential backoff from the floor
153
+ # (double prev_delay, capped at max), catching a quick recovery
154
+ # within seconds without spinning on a dead dispatch.
155
+ # :none => nothing can progress (blocked/failed or parked on a wait) =>
156
+ # straight to max_interval, the cheap recovery backstop.
157
+ # min_interval <= max_interval is enforced in merge_branches, so clamp is safe.
158
+ # `rate` is children/s measured by the caller (0 => nothing completed since the
159
+ # prior poll / cold poll).
160
+ def reschedule_delay(pending, rate, motion, prev_delay, min_interval, max_interval)
161
+ return (pending / rate * ETA_FRACTION).clamp(min_interval, max_interval) if rate > 0
162
+
163
+ case motion
164
+ when :running then min_interval
165
+ when :never_started then prev_delay ? (prev_delay * 2).clamp(min_interval, max_interval) : min_interval
166
+ else max_interval
167
+ end
168
+ end
169
+
170
+ # A poller is superseded when its token no longer matches what's stored on the
171
+ # branch logs (a newer merge_branches pass rotated it). A plain read is enough
172
+ # for the early-out; the persisting write in record_poll! re-checks the token
173
+ # under a row lock so it can never clobber the newer chain.
174
+ def superseded?(logs, token)
175
+ logs.empty? || logs.any? { |log| log.metadata&.dig("poll_token") != token }
176
+ end
177
+
178
+ # ActiveJob exposes no portable API to enumerate enqueued/scheduled jobs, so a
179
+ # poller in the backend's scheduled set is invisible to a backend-agnostic
180
+ # dashboard. We make the durable log the source of truth instead: each poll
181
+ # stamps its observable state onto every target branch log's metadata, so the
182
+ # dashboard can list in-flight merges (and a next_poll_at long in the past with
183
+ # work still pending is the signal that the poller was dropped). This is purely
184
+ # observational — replay and correctness never read it. It writes a "poll"
185
+ # sub-key, leaving spawn_each's "cursors" metadata untouched.
186
+ def record_poll!(pending_by_branch, sealed_by_branch, token, next_poll_at:, interval:, rate_by_branch:, never_started_by_branch:, spawned_by_branch:, rekicked_by_branch:)
187
+ now = Time.current
188
+ ExecutionLog.where(id: pending_by_branch.keys).find_each do |log|
189
+ # Lock the row so this read-modify-write can't clobber a concurrent token
190
+ # rotation (merge_branches) or another poller's metadata write — both touch
191
+ # the same JSON column. Re-check the token under the lock and skip if we've
192
+ # been superseded mid-run, so a stale poller never overwrites the fence.
193
+ log.with_lock do
194
+ meta = log.metadata || {}
195
+ next unless meta["poll_token"] == token
196
+ prev = meta["poll"] || {}
197
+ n = rekicked_by_branch[log.id].to_i
198
+ pend = pending_by_branch[log.id]
199
+ rate = rate_by_branch[log.id].to_f
200
+ meta["poll"] = {
201
+ "last_polled_at" => now.iso8601,
202
+ "next_poll_at" => next_poll_at&.iso8601,
203
+ "interval" => interval,
204
+ "pending" => pend,
205
+ "never_started" => never_started_by_branch[log.id], # never-started count (rekick drain signal)
206
+ "spawned" => prev["spawned"] || spawned_by_branch[log.id], # total spawned; immutable once sealed, so sticky
207
+ "sealed" => sealed_by_branch[log.id],
208
+ "rate" => rate.round(3), # children/s (round(3), not (2), so a
209
+ # very slow but real drain still reads > 0)
210
+ "eta_seconds" => (rate > 0 ? (pend / rate).round : nil),
211
+ "polls" => prev["polls"].to_i + 1,
212
+ "rekicked" => n,
213
+ "rekick_total" => prev["rekick_total"].to_i + n,
214
+ "last_rekick_at" => (n.positive? ? now.iso8601 : prev["last_rekick_at"])
215
+ }
216
+ log.update!(metadata: meta)
217
+ end
218
+ end
219
+ end
220
+
221
+ # A child that was dispatched but never picked up (its job was dropped by the
222
+ # backend) sits :idle with started_at nil. setup_workflow! stamps started_at
223
+ # on a child's first execution, so a nil started_at precisely means "never
224
+ # ran" — that's what we rekick on. It correctly excludes a child that ran and
225
+ # is now parked on a wait/wait_until (also :idle, but started_at is set):
226
+ # rekicking that would re-evaluate the wait condition prematurely and pile up
227
+ # duplicate scheduled jobs. We also require the row to be stale past
228
+ # REKICK_AFTER (a freshly dispatched child just hasn't been grabbed yet) and
229
+ # keep the :idle guard (a running/failed/stalled child must never be
230
+ # re-dispatched). Re-enqueue of an :idle child a worker just grabbed is still
231
+ # safe — the lock guard rejects the duplicate. Capped per run.
232
+ def rekick_dropped_jobs(branch_log_ids, never_started_by_branch, prev_never_started_by_branch)
233
+ cutoff = REKICK_AFTER.ago
234
+ branch_log_ids.to_h do |id|
235
+ # Skip a branch whose NEVER-STARTED count dropped since the last poll:
236
+ # workers are pulling its dispatched children off the queue, so a still-
237
+ # queued child is in line, not dropped. Deliberately NOT total pending —
238
+ # a wait/wait_until child completing would drop pending without any
239
+ # never-started child moving, masking a genuinely-dropped child behind
240
+ # staggered waits. With no prior sample (cold poll) we don't gate — the
241
+ # per-child staleness filter below still spares freshly-dispatched rows.
242
+ prev = prev_never_started_by_branch[id]
243
+ next [id, 0] if prev && never_started_by_branch[id] < prev
244
+
245
+ count = 0
246
+ Workflow.where(parent_execution_log_id: id, state: Workflow.states[:idle], started_at: nil)
247
+ .where("updated_at < ?", cutoff)
248
+ .limit(REKICK_BATCH)
249
+ .find_each do |child|
250
+ # Intentionally uses the GUARDED perform_later (single-child path),
251
+ # unlike the bulk perform_all_later bypass in dispatch_children.
252
+ #
253
+ # Rekick is best-effort recovery, so one bad child must never sink the
254
+ # poll: a raise here (e.g. cross-version kwarg drift failing the enqueue
255
+ # guard) would abort the whole run and — since it isn't a transient AR
256
+ # error — dead-letter the poller, orphaning every healthy sibling. Catch
257
+ # per child, log, and let the next poll retry it (it's still idle+stale).
258
+ child.job_klass.perform_later(child.key, **child.kwargs.symbolize_keys)
259
+ # Debounce: bump updated_at so this child isn't re-rekicked until it's
260
+ # been unstarted for another REKICK_AFTER — one redelivery window for a
261
+ # worker to pick it up. Only on a SUCCESSFUL enqueue; a rescued failure
262
+ # leaves it stale so the next poll retries.
263
+ child.touch
264
+ count += 1
265
+ rescue => e
266
+ Rails.logger.error do
267
+ "ChronoForge:BranchMergeJob rekick failed for child #{child.key}: " \
268
+ "#{e.class}: #{e.message}"
269
+ end
270
+ end
271
+ [id, count]
272
+ end
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChronoForge
4
+ # Single source of truth for "is this branch done?" — used by both merge_branches
5
+ # (boolean) and BranchMergeJob (which needs the sealed flag and pending count
6
+ # separately for its adaptive poll cadence). Option A: only :completed counts as
7
+ # done, so a failed/stalled child keeps the branch pending until recovered.
8
+ module BranchProbe
9
+ module_function
10
+
11
+ # The branch's coordination log is sealed (fully dispatched).
12
+ def sealed?(branch_log_id)
13
+ ExecutionLog.where(id: branch_log_id, state: ExecutionLog.states[:completed]).exists?
14
+ end
15
+
16
+ # Relation of this branch's children that are not yet completed.
17
+ def incomplete(branch_log_id)
18
+ Workflow.where(parent_execution_log_id: branch_log_id)
19
+ .where.not(state: Workflow.states[:completed])
20
+ end
21
+
22
+ # Relation of children that can advance on their own — actively running, or
23
+ # dispatched-but-not-yet-started (started_at nil). This drives the adaptive
24
+ # poll cadence. Deliberately EXCLUDES waiting children (idle with started_at
25
+ # SET — parked on a wait/wait_until) and blocked children (failed/stalled —
26
+ # awaiting operator recovery): polling can't make either progress, so they
27
+ # must not pin the cadence at the responsive floor. They still count as
28
+ # +incomplete+ (the branch stays open), they just don't accelerate polling.
29
+ def progressing(branch_log_id)
30
+ base = Workflow.where(parent_execution_log_id: branch_log_id)
31
+ base.where(state: Workflow.states[:running])
32
+ .or(base.where(state: Workflow.states[:idle], started_at: nil))
33
+ end
34
+
35
+ # A child of this branch is actively executing — a live worker will complete
36
+ # it, so the poller can hold its responsive floor rather than backing off.
37
+ def running?(branch_log_id)
38
+ Workflow.where(parent_execution_log_id: branch_log_id, state: Workflow.states[:running]).exists?
39
+ end
40
+
41
+ # Children dispatched but not yet started (idle, started_at nil) — the queue of
42
+ # never-started work for this branch. A DROP in this count between polls means
43
+ # workers are actively pulling it off the queue (so a still-queued child is in
44
+ # line, not dropped); the rekick gate keys off that. Distinct from total pending,
45
+ # which a wait/wait_until child completing would drop without any never-started
46
+ # child moving. (Not to be confused with the dashboard's "Dispatched" column,
47
+ # which is the TOTAL children spawned.)
48
+ def never_started(branch_log_id)
49
+ Workflow.where(parent_execution_log_id: branch_log_id,
50
+ state: Workflow.states[:idle], started_at: nil)
51
+ end
52
+
53
+ # A child was dispatched but no worker has started it yet. If this is the only
54
+ # motion left, it's a queued/rekicked-but-unpicked straggler (which may never be
55
+ # picked up), NOT active work — so the poller backs off.
56
+ def never_started?(branch_log_id) = never_started(branch_log_id).exists?
57
+
58
+ # All children spawned into this branch (every state) — the dispatch total. Fixed
59
+ # once the branch is sealed, so the poller counts it exactly once and caches it on
60
+ # the branch-log metadata. This is the dashboard's "Spawned" column. Distinct from
61
+ # #never_started, which is only the idle-and-unstarted subset.
62
+ def spawned(branch_log_id)
63
+ Workflow.where(parent_execution_log_id: branch_log_id)
64
+ end
65
+
66
+ def done?(branch_log_id)
67
+ sealed?(branch_log_id) && !incomplete(branch_log_id).exists?
68
+ end
69
+ end
70
+ end
@@ -93,6 +93,12 @@ module ChronoForge
93
93
  ids = batch.ids
94
94
  next if ids.empty?
95
95
 
96
+ # Branch children point at their parent's branch$ execution log via
97
+ # parent_execution_log_id. Bulk delete bypasses the dependent: :nullify callback,
98
+ # so nullify explicitly to avoid dangling references when a parent is reclaimed.
99
+ Workflow.where(parent_execution_log_id: ExecutionLog.where(workflow_id: ids).select(:id))
100
+ .update_all(parent_execution_log_id: nil)
101
+
96
102
  # Delete dependent rows in bulk rather than relying on row-by-row
97
103
  # dependent: :destroy callbacks.
98
104
  result[:execution_logs] += ExecutionLog.where(workflow_id: ids).delete_all
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChronoForge
4
+ # Engine-wide configuration. Set via ChronoForge.configure in an initializer.
5
+ class Configuration
6
+ # The queue the branch-merge poller (BranchMergeJob) runs on.
7
+ #
8
+ # This MUST NOT be a queue that a fan-out's own children saturate: merge_branches
9
+ # enqueues the poller AFTER dispatching the branch's children, so on a shared
10
+ # queue it is starved behind the whole backlog and only gets a worker slot near
11
+ # the end — it then polls once, at pending≈0, and backs off, so the parent's
12
+ # convergence lags by up to max_interval and no mid-drain throughput is recorded.
13
+ # Because the poller is OUR code (not the user's job), its placement is a
14
+ # first-class setting rather than something to monkey-patch onto BranchMergeJob.
15
+ #
16
+ # Defaults to :default (fine when fan-outs run on their own queues). For large
17
+ # fan-outs, point this at a dedicated queue with its own worker so the poller
18
+ # runs promptly throughout the drain.
19
+ attr_accessor :branch_merge_queue
20
+
21
+ def initialize
22
+ @branch_merge_queue = :default
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChronoForge
4
+ # Rendering-agnostic graph model produced by DefinitionAnalyzer. Plain value
5
+ # objects so a Definition can be cached/serialized (to_h -> JSON) and consumed
6
+ # by any renderer. No DB, no Prism, no dashboard dependency here.
7
+ class Definition
8
+ # kind: :execute :wait :wait_until :continue_if :branch :merge :repeat :dynamic
9
+ # A node binds to runtime logs by EXACT step_name when known, else by
10
+ # step_name_pattern (a prefix for fan-out/repeat/dynamic).
11
+ Node = Struct.new(
12
+ :id, :kind, :label, :step_name, :step_name_pattern, :guard, :warnings,
13
+ keyword_init: true
14
+ ) do
15
+ def dynamic? = kind == :dynamic || step_name.nil?
16
+
17
+ # Default a missing warnings member to [] here (rather than overriding the
18
+ # struct's generated reader, which triggers a method-redefined warning).
19
+ def to_h = super.merge(warnings: self[:warnings] || [])
20
+ end
21
+
22
+ # kind: :seq :conditional :fanout :join :terminal
23
+ Edge = Struct.new(:from, :to, :kind, :guard, keyword_init: true)
24
+
25
+ attr_reader :nodes, :edges, :warnings
26
+
27
+ def initialize(nodes: [], edges: [], warnings: [])
28
+ @nodes = nodes
29
+ @edges = edges
30
+ @warnings = warnings
31
+ end
32
+
33
+ def to_h
34
+ {nodes: nodes.map(&:to_h), edges: edges.map(&:to_h), warnings: warnings}
35
+ end
36
+ end
37
+ end