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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -1
- data/README.md +390 -46
- data/Rakefile +4 -0
- data/cliff.toml +62 -0
- data/docs/design/per-child-commit-overhead.md +213 -0
- data/docs/fanout-scale-test.md +247 -0
- data/docs/superpowers/plans/2026-06-25-chrono_forge-dashboard.md +1748 -0
- data/docs/superpowers/plans/2026-06-25-chrono_forge-dashboard.md.tasks.json +17 -0
- data/docs/superpowers/plans/2026-06-25-composite-retry-policies.md +930 -0
- data/docs/superpowers/plans/2026-06-25-composite-retry-policies.md.tasks.json +54 -0
- data/docs/superpowers/plans/2026-06-25-reserved-kwarg-guard.md +241 -0
- data/docs/superpowers/plans/2026-06-25-reserved-kwarg-guard.md.tasks.json +12 -0
- data/docs/superpowers/plans/2026-06-26-branches-spawn-merge.md +1378 -0
- data/docs/superpowers/plans/2026-06-26-branches-spawn-merge.md.tasks.json +67 -0
- data/docs/superpowers/plans/2026-06-26-deferral-continuation-race-and-catchup.md +709 -0
- data/docs/superpowers/plans/2026-06-26-deferral-continuation-race-and-catchup.md.tasks.json +19 -0
- data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md +205 -0
- data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md.tasks.json +33 -0
- data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md +1373 -0
- data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md.tasks.json +68 -0
- data/docs/superpowers/specs/2026-06-03-unified-retry-policy-design.md +226 -0
- data/docs/superpowers/specs/2026-06-25-chrono_forge-dashboard-design.md +190 -0
- data/docs/superpowers/specs/2026-06-25-composite-retry-policies-design.md +228 -0
- data/docs/superpowers/specs/2026-06-25-reserved-kwarg-guard-design.md +169 -0
- data/docs/superpowers/specs/2026-06-25-spawn-merge-branches-design.md +468 -0
- data/docs/superpowers/specs/2026-06-26-dashboard-branch-view-design.md +142 -0
- data/docs/superpowers/specs/2026-06-26-deferral-continuation-race-and-catchup-design.md +265 -0
- data/docs/superpowers/specs/2026-07-01-workflow-definition-dag-design.md +203 -0
- data/lib/chrono_forge/branch_merge_job.rb +275 -0
- data/lib/chrono_forge/branch_probe.rb +70 -0
- data/lib/chrono_forge/cleanup.rb +6 -0
- data/lib/chrono_forge/configuration.rb +25 -0
- data/lib/chrono_forge/definition.rb +37 -0
- data/lib/chrono_forge/definition_analyzer.rb +501 -0
- data/lib/chrono_forge/execution_log.rb +6 -0
- data/lib/chrono_forge/executor/composite_retry_policy.rb +47 -0
- data/lib/chrono_forge/executor/context.rb +23 -0
- data/lib/chrono_forge/executor/lock_strategy.rb +10 -3
- data/lib/chrono_forge/executor/methods/branch.rb +185 -0
- data/lib/chrono_forge/executor/methods/continue_if.rb +15 -6
- data/lib/chrono_forge/executor/methods/durably_execute.rb +36 -26
- data/lib/chrono_forge/executor/methods/durably_repeat.rb +148 -39
- data/lib/chrono_forge/executor/methods/merge_branches.rb +84 -0
- data/lib/chrono_forge/executor/methods/wait.rb +2 -4
- data/lib/chrono_forge/executor/methods/wait_until.rb +25 -25
- data/lib/chrono_forge/executor/methods/workflow_states.rb +50 -46
- data/lib/chrono_forge/executor/methods.rb +2 -0
- data/lib/chrono_forge/executor/retry_policy.rb +111 -0
- data/lib/chrono_forge/executor.rb +241 -28
- data/lib/chrono_forge/version.rb +1 -1
- data/lib/chrono_forge/workflow.rb +10 -1
- data/lib/chrono_forge.rb +8 -0
- data/lib/generators/chrono_forge/migration_actions.rb +1 -0
- data/lib/generators/chrono_forge/templates/add_chrono_forge_parent_execution_log.rb +38 -0
- data/lib/tasks/release.rake +212 -0
- metadata +67 -4
- 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
|
+
}
|