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,19 @@
1
+ {
2
+ "planPath": "docs/superpowers/plans/2026-06-26-deferral-continuation-race-and-catchup.md",
3
+ "tasks": [
4
+ {
5
+ "id": 1,
6
+ "subject": "Task 1: Defer all continuation enqueues until after lock release",
7
+ "status": "completed",
8
+ "description": "**Goal:** No continuation job is published while the enqueuing job still holds the workflow lock; all 8 enqueue sites route through one recorded slot flushed in `ensure` after `release_lock`.\n\n**Files:** lib/chrono_forge/executor.rb; lib/chrono_forge/executor/methods/{wait,wait_until,durably_execute,durably_repeat}.rb; test/continuation_flush_test.rb (create)\n\n**Verify:** `bundle exec ruby -Itest test/continuation_flush_test.rb && bundle exec rake test`\n\n```json:metadata\n{\"files\": [\"lib/chrono_forge/executor.rb\", \"lib/chrono_forge/executor/methods/wait.rb\", \"lib/chrono_forge/executor/methods/wait_until.rb\", \"lib/chrono_forge/executor/methods/durably_execute.rb\", \"lib/chrono_forge/executor/methods/durably_repeat.rb\", \"test/continuation_flush_test.rb\"], \"verifyCommand\": \"bundle exec ruby -Itest test/continuation_flush_test.rb && bundle exec rake test\", \"acceptanceCriteria\": [\"continuations enqueued only after lock release\", \"per-site kwargs preserved\", \"flush no-ops without recorded continuation and is skipped when release_lock raises\", \"full suite green\"], \"requiresUserVerification\": false}\n```"
9
+ },
10
+ {
11
+ "id": 2,
12
+ "subject": "Task 2: Closed-form fast-forward of the expired prefix in durably_repeat",
13
+ "status": "completed",
14
+ "blockedBy": [1],
15
+ "description": "**Goal:** When `durably_repeat` resumes behind schedule, jump past the expired prefix in O(1), advance the coordination log's `last_execution_at`, and write one summary `ExecutionLog` for the skip — instead of one timed-out row + one zero-delay job per missed tick.\n\n**Files:** lib/chrono_forge/executor/methods/durably_repeat.rb; test/durably_repeat_test.rb (add tests; update 2 timeout tests)\n\n**Verify:** `bundle exec ruby -Itest test/durably_repeat_test.rb && bundle exec rake test`\n\n```json:metadata\n{\"files\": [\"lib/chrono_forge/executor/methods/durably_repeat.rb\", \"test/durably_repeat_test.rb\"], \"verifyCommand\": \"bundle exec ruby -Itest test/durably_repeat_test.rb && bundle exec rake test\", \"acceptanceCriteria\": [\"fast_forward returns input when nothing expired\", \"lands on first non-expired grid tick (no drift)\", \"zero per-tick timeout rows + exactly one summary row\", \"coordination last_execution_at advanced for stable replay\", \"first in-window tick still executes\", \"full suite green\"], \"requiresUserVerification\": false}\n```"
16
+ }
17
+ ],
18
+ "lastUpdated": "2026-06-26"
19
+ }
@@ -0,0 +1,205 @@
1
+ # BranchMergeJob: ETA poll cadence, drain-aware rekick, configurable poller queue
2
+
3
+ _Implementation record. Started from the two defects below; the final shape was
4
+ driven by three review rounds and a live 20k/100k/500k drive, so this documents
5
+ what shipped, not a pre-implementation plan._
6
+
7
+ ## Problem
8
+
9
+ `ChronoForge::BranchMergeJob` is the lightweight poller that joins a fan-out's
10
+ branches: each pass it counts a branch's incomplete children, wakes the parent
11
+ when all are sealed and complete, otherwise re-arms itself. Two defects, plus a
12
+ deployment trap surfaced by the live drive:
13
+
14
+ 1. **Rekick re-enqueued healthy children.** `rekick_dropped_jobs` re-dispatched any
15
+ child that was `idle`, `started_at: nil`, and `updated_at < REKICK_AFTER.ago` —
16
+ which also matches a healthy child merely waiting deep in a draining backlog
17
+ (queue wait exceeds `REKICK_AFTER` at N ≥ 100k). And because `perform_later`
18
+ never touched the row, a rekicked-but-unpicked child stayed stale and was
19
+ **re-rekicked every poll**, piling up duplicates.
20
+
21
+ 2. **Cadence overshoot.** The delay was `(progressing × FACTOR).clamp(min, max)`
22
+ with `progressing` a count capped at `CAP = 5000`, so it saturated to
23
+ `max_interval` while any large backlog existed — polling *slowest* exactly when
24
+ a fast-draining backlog was about to finish. A 20k fan-out drains in ~88s but
25
+ the parent wasn't woken until ~310s; 500k sealed up to 5 min late.
26
+
27
+ 3. **Poller starvation (deploy trap).** `merge_branches` enqueues the poller *after*
28
+ dispatching the branch's children, so on a queue those children saturate it is
29
+ starved behind the whole backlog — it polls once, at pending≈0, and backs off,
30
+ so the parent converges up to `max_interval` late and no throughput is recorded.
31
+ The only lever was monkey-patching `BranchMergeJob.queue_as` in the host app,
32
+ which a dev code-reload can silently reset.
33
+
34
+ ## Solution overview
35
+
36
+ - **Cadence** is driven by **estimated time-to-drain**, measured from the branch's
37
+ own completion rate — so the parent is woken within ~`min_interval` of the last
38
+ child finishing. When an interval sees no completion, the fallback is
39
+ **motion-aware** so a slow-but-healthy child isn't woken late.
40
+ - **Rekick** is gated on the **never-started count delta** — the true
41
+ "workers are pulling this branch's queue" signal — and **debounced** with
42
+ `child.touch`, so healthy deep-queued children are never rekicked and a dropped
43
+ child is redelivered at most once per `REKICK_AFTER`.
44
+ - The poller's **queue is a first-class config** (`branch_merge_queue`), since its
45
+ placement is our concern, not the user's.
46
+ - The measured **rate + ETA are persisted** each poll and surfaced **live on the
47
+ dashboard**, which now **auto-refreshes on every page**.
48
+
49
+ ---
50
+
51
+ ## Cadence — `reschedule_delay(pending, rate, motion, prev_delay, min, max)`
52
+
53
+ `lib/chrono_forge/branch_merge_job.rb`
54
+
55
+ ```ruby
56
+ def reschedule_delay(pending, rate, motion, prev_delay, min_interval, max_interval)
57
+ return (pending / rate * ETA_FRACTION).clamp(min_interval, max_interval) if rate > 0
58
+
59
+ case motion
60
+ when :running then min_interval
61
+ when :never_started then prev_delay ? (prev_delay * 2).clamp(min_interval, max_interval) : min_interval
62
+ else max_interval
63
+ end
64
+ end
65
+ ```
66
+
67
+ - **Draining (`rate > 0`):** poll at `ETA_FRACTION` (0.5) of the projected time-to-
68
+ drain. Because each poll re-estimates against the shrinking remainder, the cadence
69
+ converges geometrically and tightens to `min_interval` at the tail.
70
+ - **No completion this interval → `motion`:**
71
+ - `:running` — a live worker is executing a child; it will finish, so **hold the
72
+ floor** (`min_interval`). This is the anti-regression case: backing off would
73
+ wake the parent late for a slow / single-child branch.
74
+ - `:never_started` — the only motion is a queued/rekicked-but-unpicked child that may
75
+ never be picked up → **exponential backoff** from the floor (double `prev_delay`,
76
+ capped at `max`). Catches a quick recovery in seconds; decays instead of spinning.
77
+ - `:none` — nothing can progress (blocked/failed or parked on a wait) →
78
+ `max_interval` backstop.
79
+
80
+ Inputs, computed in `perform`:
81
+
82
+ - **`pending`** is the **uncapped** incomplete count (`BranchProbe.incomplete(id)
83
+ .count`), served by the existing `[parent_execution_log_id, state]` index — one
84
+ branch-scoped count per poll (~7 for 20k, ~15 for 500k; a background cost). The
85
+ old `CAP` flattened this to a constant `5000`, which is why the ETA couldn't reuse
86
+ it. `CAP` and `FACTOR` are removed; `ETA_FRACTION` added.
87
+ - **`rate`** = `(prev_pending − pending) / elapsed` when the branch drained since its
88
+ prior poll, else `0.0`. Measured per branch (`rate_by_branch`, for the dashboard)
89
+ and aggregated for the ETA. Aggregate `prev_pending` is only trusted when every
90
+ requested branch log is loaded *and* carries a prior sample
91
+ (`logs.size == branch_log_ids.size && prior.all?`), so a partial set can't yield a
92
+ bogus rate.
93
+ - **`motion`** is computed **lazily** — only when `rate == 0`, keeping the EXISTS
94
+ probes off the hot drain path: `:running` if any `BranchProbe.running?`, else
95
+ `:never_started` if any branch has a positive never-started count, else `:none`.
96
+ - **`prev_delay`** comes from the prior poll's persisted `interval`, driving the
97
+ exponential backoff.
98
+
99
+ ## Rekick — `rekick_dropped_jobs(branch_log_ids, never_started_by_branch, prev_never_started_by_branch)`
100
+
101
+ ```ruby
102
+ prev = prev_never_started_by_branch[id]
103
+ next [id, 0] if prev && never_started_by_branch[id] < prev # never-started count fell → workers consuming → in line
104
+ # else: scan idle & started_at IS NULL & updated_at < REKICK_AFTER.ago, limit REKICK_BATCH,
105
+ # guarded perform_later, then child.touch on success (debounce), rescue per child.
106
+ ```
107
+
108
+ - **Gate on the never-started count delta, not total pending.** A
109
+ `wait_until` child resuming drops total `pending` without any never-started child
110
+ being consumed, so a pending-delta gate would mistake that for "draining" and
111
+ defer recovery of a genuinely-dropped child behind staggered waits. The
112
+ `idle & started_at IS NULL` count falling is the real signal that workers are
113
+ pulling this branch's queue (added `BranchProbe.dispatched`, a countable relation).
114
+ - **Cold poll (no prior sample) doesn't gate** — it falls through to the per-child
115
+ staleness filter, which already spares freshly-dispatched children, so a dropped
116
+ child is still recovered on the first poll.
117
+ - **`child.touch` on a successful rekick** bumps `updated_at`, so the child leaves
118
+ the staleness window for one `REKICK_AFTER` — redelivered at most once per window,
119
+ killing the re-rekick pile-up. Only on success; a rescued enqueue failure leaves it
120
+ stale to retry next poll.
121
+ - Best-effort: a per-child rescue keeps one bad child from sinking the whole poll.
122
+
123
+ ## Persisted poll state — `record_poll!`
124
+
125
+ Each pass stamps the branch log's `metadata["poll"]` (under `with_lock` + a token
126
+ recheck, leaving `spawn_each`'s cursors untouched):
127
+
128
+ `last_polled_at`, `next_poll_at`, `interval`, `pending`, `dispatched`, `sealed`,
129
+ `rate` (children/s, `round(3)` so a very slow but real drain still reads > 0),
130
+ `eta_seconds`, `polls`, `rekicked`, `rekick_total`, `last_rekick_at`.
131
+
132
+ `rate`/`eta_seconds` are **free** — already computed for the cadence, no extra query
133
+ — which is what lets the dashboard show live throughput without the aggregate scans
134
+ the scale-aware design avoids.
135
+
136
+ ## Configurable poller queue
137
+
138
+ `lib/chrono_forge/configuration.rb` (the engine's first config object):
139
+
140
+ ```ruby
141
+ ChronoForge.configure { |c| c.branch_merge_queue = :chrono_forge_pollers } # default: :default
142
+ ```
143
+
144
+ `BranchMergeJob` reads it via `queue_as { ChronoForge.config.branch_merge_queue }`
145
+ — resolved **per-enqueue**, so a change takes effect without redefining the job and
146
+ can't be silently reset by a code reload (the fragility the live drive exposed). Keep
147
+ the poller off a queue saturated by a fan-out's own children.
148
+
149
+ ## Dashboard (`chrono_forge-dashboard`)
150
+
151
+ - **Live throughput / ETA on in-flight merges.** `BranchesPresenter::Merge` gains
152
+ `:rate` / `:eta_seconds` and `throughput? = merging? && rate.to_f > 0`; the merges
153
+ list renders `<rate>/s` and `ETA <cf_secs>`, both guarded. **Multi-branch merges
154
+ aggregate** — `merge_throughput` sums per-branch `rate` and recomputes the combined
155
+ ETA (`Σpending / Σrate`), rather than showing one branch's figure.
156
+ - **Auto-refresh on every page.** The poll region is marked once on the layout's
157
+ `<main>` (the nav + refresh/time controls sit in `<header>`, outside the swap), so
158
+ every page — workflow list *and detail*, analytics, waiting, repetitions —
159
+ refreshes in place, preserving filter text, focus, and scroll. It was previously
160
+ per-page opt-in, which had silently left the detail page (where the gauge lives)
161
+ and several others un-refreshing.
162
+
163
+ ## Files changed
164
+
165
+ **`chrono_forge`**
166
+ - `lib/chrono_forge.rb` — `config` / `configure` / `reset_configuration!`.
167
+ - `lib/chrono_forge/configuration.rb` — new; `branch_merge_queue`.
168
+ - `lib/chrono_forge/branch_merge_job.rb` — `queue_as` from config; ETA + motion
169
+ cadence; dispatched-delta rekick + `touch`; uncapped `pending`; `record_poll!`
170
+ fields; `superseded?(logs, …)`; removed `CAP`/`FACTOR`, added `ETA_FRACTION`.
171
+ - `lib/chrono_forge/branch_probe.rb` — `running?`, `dispatched`/`dispatched?`;
172
+ `incomplete` used uncapped (`progressing` retained, unused by the poller).
173
+ - `lib/chrono_forge/executor/methods/merge_branches.rb` — stale cadence comment.
174
+ - `test/branch_merge_job_test.rb`, `test/branch_probe_test.rb` — cadence (motion),
175
+ dispatched-delta rekick incl. the waits-drain-pending regression, debounce,
176
+ throughput persistence, configurable queue.
177
+ - `docs/fanout-scale-test.md`, `README.md` — cadence, rekick, queue config, the
178
+ poller-queue-placement trap.
179
+
180
+ **`chrono_forge-dashboard`**
181
+ - `branches_presenter.rb` — `Merge` rate/eta + `throughput?` + `merge_throughput`
182
+ aggregate.
183
+ - `_branches.html.erb` — throughput/ETA spans (sub-1/s shown to one decimal).
184
+ - `layouts/.../application.html.erb` — `data-poll-region` on `<main>`; removed the
185
+ per-page markers.
186
+ - `test/branches_test.rb`, `README.md` — aggregation test; docs.
187
+
188
+ ## Validation
189
+
190
+ Full engine suite **263** tests green; dashboard **106**. Live-driven on Solid Queue
191
+ + Postgres (poller on a dedicated `:scale_poller` queue):
192
+
193
+ - **20k / 100k** — parents converged; the dashboard showed live `~226/s` + ETA that
194
+ ramped down and vanished on completion.
195
+ - **500k** — **500,000 / 500,000** children completed, parent converged, **11**
196
+ poller passes (ETA cadence held throughout, not a single starved poll), and **200**
197
+ children rekicked + recovered after a mid-drain worker restart — the
198
+ rekick/debounce path exercised at scale.
199
+
200
+ ## Review findings resolved
201
+
202
+ - **#1** — rekick gate moved from total-pending delta to the never-started count
203
+ delta (dropped-child recovery no longer deferred behind resuming waits).
204
+ - **#2** — multi-branch merges aggregate rate/ETA in the presenter.
205
+ - **#3** — `rate` stored `round(3)` so a sub-1/s drain still renders throughput/ETA.
@@ -0,0 +1,33 @@
1
+ {
2
+ "planPath": "docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md",
3
+ "tasks": [
4
+ {
5
+ "id": 1,
6
+ "subject": "Task 1: Debounced drain-aware rekick + observability (Fix 1)",
7
+ "status": "completed",
8
+ "description": "**Goal:** Rekick a never-started child only when its branch is NOT draining (pending didn't drop since prior poll), at most once per REKICK_AFTER (touch debounce), and record rekick activity on branch-log metadata. Lands the shared foundation: load logs once, uncapped pending, superseded?(logs).\n\nDrain signal = pending-delta (NO query, NO started_at index). First poll (no prev_pending) doesn't gate; per-child staleness filter spares fresh children.\n\n**Files:**\n- Modify: lib/chrono_forge/branch_merge_job.rb\n- Test: test/branch_merge_job_test.rb\n\n```json:metadata\n{\"files\": [\"lib/chrono_forge/branch_merge_job.rb\", \"test/branch_merge_job_test.rb\"], \"verifyCommand\": \"bundle exec ruby -I test test/branch_merge_job_test.rb\", \"acceptanceCriteria\": [\"draining (pending dropped) suppresses rekick\", \"quiet branch still rekicks dropped child\", \"touch debounces re-rekick within REKICK_AFTER\", \"metadata records rekick stats\", \"all pre-existing tests pass\"], \"requiresUserVerification\": false}\n```"
9
+ },
10
+ {
11
+ "id": 2,
12
+ "subject": "Task 2: ETA + exponential-backoff poll cadence (Fix 2)",
13
+ "status": "completed",
14
+ "blockedBy": [1],
15
+ "description": "**Goal:** Drive reschedule delay from estimated time-to-drain (measured from prior poll's UNCAPPED pending), clamped [min,max]. When nothing completed this interval, use motion: :running holds floor (no regression), :dispatched backs off exponentially, :none -> max. Persist rate + eta_seconds per branch for the dashboard.\n\n**Files:**\n- Modify: lib/chrono_forge/branch_merge_job.rb (remove FACTOR+CAP, add ETA_FRACTION=0.5; motion from BranchProbe.running?/dispatched?; prev_pending/elapsed/prev_delay from metadata w/ size guard; new reschedule_delay(pending, rate, motion, prev_delay, min, max); record_poll! interval + rate + eta_seconds)\n- Modify: lib/chrono_forge/branch_probe.rb (add running?/dispatched?)\n- Modify: lib/chrono_forge/executor/methods/merge_branches.rb (stale FACTOR comment)\n- Test: test/branch_merge_job_test.rb\n\n```json:metadata\n{\"files\": [\"lib/chrono_forge/branch_merge_job.rb\", \"lib/chrono_forge/branch_probe.rb\", \"lib/chrono_forge/executor/methods/merge_branches.rb\", \"test/branch_merge_job_test.rb\"], \"verifyCommand\": \"bundle exec ruby -I test test/branch_merge_job_test.rb\", \"acceptanceCriteria\": [\"ETA scales with measured drain rate\", \"clamps to min and max\", \"running child holds floor (no regression)\", \"dispatched straggler backs off exponentially\", \"max when nothing progresses\", \"second poll records+uses throughput\", \"pre-existing cadence tests pass\"], \"requiresUserVerification\": false}\n```"
16
+ },
17
+ {
18
+ "id": 3,
19
+ "subject": "Task 3: Update the scale-test doc",
20
+ "status": "completed",
21
+ "blockedBy": [2],
22
+ "description": "**Goal:** Replace the 'needs a caveat' framing in docs/fanout-scale-test.md with a 'Poller behavior' note (pending-delta drain-aware rekick + touch debounce; ETA cadence with corrected 500k profile; no new indexes).\n\n**Files:**\n- Modify: docs/fanout-scale-test.md\n\n```json:metadata\n{\"files\": [\"docs/fanout-scale-test.md\"], \"verifyCommand\": \"\", \"acceptanceCriteria\": [\"doc reflects debounced drain-aware rekick and ETA cadence\"], \"requiresUserVerification\": false}\n```"
23
+ },
24
+ {
25
+ "id": 4,
26
+ "subject": "Task 4: Dashboard — live throughput / ETA for in-flight merges",
27
+ "status": "completed",
28
+ "blockedBy": [2],
29
+ "description": "**Goal:** Surface the rate (throughput) + eta_seconds persisted by the poller (Task 2) on the merges list of a parent workflow's detail page. Separate package (chrono_forge-dashboard).\n\n**Files (chrono_forge-dashboard/):**\n- Modify: app/presenters/chrono_forge/dashboard/branches_presenter.rb (Merge struct + builder: add :rate, :eta_seconds; throughput? helper)\n- Modify: app/views/chrono_forge/dashboard/workflows/_branches.html.erb (render throughput/ETA)\n- Test: test/presenters_test.rb\n\n```json:metadata\n{\"files\": [\"chrono_forge-dashboard/app/presenters/chrono_forge/dashboard/branches_presenter.rb\", \"chrono_forge-dashboard/app/views/chrono_forge/dashboard/workflows/_branches.html.erb\", \"chrono_forge-dashboard/test/presenters_test.rb\"], \"verifyCommand\": \"cd chrono_forge-dashboard && bundle exec rake test TEST=test/presenters_test.rb\", \"acceptanceCriteria\": [\"merging branch shows throughput + ETA from poll metadata\", \"zero/absent rate renders nothing\"], \"requiresUserVerification\": false}\n```"
30
+ }
31
+ ],
32
+ "lastUpdated": "2026-06-30"
33
+ }