mandrel 1.68.0 → 1.69.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 (46) hide show
  1. package/.agents/docs/agentrc-reference.json +1 -2
  2. package/.agents/docs/configuration.md +2 -4
  3. package/.agents/schemas/agentrc.schema.json +1 -5
  4. package/.agents/schemas/lifecycle/epic.automerge.end.schema.json +2 -1
  5. package/.agents/scripts/epic-deliver-preflight.js +30 -13
  6. package/.agents/scripts/epic-deliver-prepare.js +40 -53
  7. package/.agents/scripts/epic-execute-record-wave.js +119 -133
  8. package/.agents/scripts/lib/baselines/refresh-service.js +13 -1
  9. package/.agents/scripts/lib/config/explain.js +0 -2
  10. package/.agents/scripts/lib/config/limits.js +19 -8
  11. package/.agents/scripts/lib/config-settings-schema.js +1 -2
  12. package/.agents/scripts/lib/maintainability-utils.js +32 -9
  13. package/.agents/scripts/lib/orchestration/epic-cleanup.js +11 -7
  14. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +6 -6
  15. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +11 -5
  16. package/.agents/scripts/lib/orchestration/epic-run-state-store.js +203 -110
  17. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +38 -78
  18. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/transport.js +16 -13
  19. package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +10 -7
  20. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +37 -24
  21. package/.agents/scripts/lib/orchestration/manifest-builder.js +6 -0
  22. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +6 -2
  23. package/.agents/scripts/lib/orchestration/wave-record-io.js +18 -77
  24. package/.agents/scripts/lib/orchestration/wave-record-notifications.js +78 -122
  25. package/.agents/scripts/lib/orchestration/wave-record-projection.js +21 -226
  26. package/.agents/scripts/lib/presentation/dispatch-manifest-render.js +18 -1
  27. package/.agents/scripts/lib/presentation/manifest-render-waves.js +77 -4
  28. package/.agents/scripts/lib/story-adjacency.js +14 -10
  29. package/.agents/scripts/lib/story-body/story-body.js +36 -4
  30. package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -3
  31. package/.agents/scripts/lib/wave-runner/ready-set.js +295 -0
  32. package/.agents/scripts/lib/wave-runner/tick.js +312 -206
  33. package/.agents/scripts/lib/wave-runner/wave-runner-error.js +2 -1
  34. package/.agents/scripts/lint-label-vocabulary.js +1 -1
  35. package/.agents/scripts/stories-wave-tick.js +262 -161
  36. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +6 -0
  37. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +108 -101
  38. package/.agents/skills/skills.index.json +2 -2
  39. package/.agents/workflows/deliver.md +12 -9
  40. package/.agents/workflows/helpers/deliver-epic.md +126 -90
  41. package/.agents/workflows/helpers/deliver-stories.md +131 -85
  42. package/.agents/workflows/helpers/plan-epic.md +13 -10
  43. package/.agents/workflows/plan.md +1 -1
  44. package/docs/CHANGELOG.md +14 -0
  45. package/package.json +1 -1
  46. package/.agents/scripts/lib/wave-runner/wave-checkpoint.js +0 -91
@@ -1,13 +1,33 @@
1
1
  /**
2
2
  * `tick({ epic, collaborators })` — single callable entry point for
3
- * "advance this wave one step." Stateless planner: rebuilds wave state
4
- * from the `epic-run-state` checkpoint plus fresh Story labels on every
5
- * call, then returns a `WaveTickResult` describing the next action.
3
+ * "advance this Epic one beat." Stateless adapter over the continuous
4
+ * ready-set scheduling core (`lib/wave-runner/ready-set.js`).
6
5
  *
7
- * Contract (Story #1430): stateless; caller owns concurrency,
8
- * worktrees, and checkpointing. Expected failures (blocked stories,
9
- * gate failures) flow back through result fields; unexpected failures
10
- * (GH 5xx, malformed checkpoint) throw `WaveRunnerError`.
6
+ * Story #4155 (Epic #4151) the Epic `/deliver` runtime cut over from
7
+ * the wave-batch scheduler to the ready-set core. Each tick:
8
+ *
9
+ * 1. reads the shrunk `epic-run-state` checkpoint (per-Story status map
10
+ * + the GLOBAL in-flight `concurrencyCap`),
11
+ * 2. re-fetches the **live** Story records (body + labels + issue
12
+ * state) for every Story in scope,
13
+ * 3. classifies each by live label (`classifyStory`), re-derives
14
+ * adjacency from the live bodies (`buildStoryAdjacency`, inside
15
+ * `selectReadySet`), and selects the ready set under a global
16
+ * in-flight cap with the file-overlap co-dispatch guard
17
+ * (`storiesOverlap`),
18
+ * 4. returns a `WaveTickResult` describing the next action.
19
+ *
20
+ * There is **no wave barrier**: a Story whose own dependencies are all
21
+ * done is dispatched the instant a slot is free, even while an unrelated
22
+ * sibling Story is still `agent::executing`. The selector neither reads
23
+ * GitHub nor a checkpoint nor the ledger — this adapter supplies the live
24
+ * records, the `inFlight` count (from the lifecycle ledger), and the
25
+ * `globalCap`, then maps its return into the `WaveTickResult` envelope.
26
+ *
27
+ * Contract (Story #1430, refined by #4155): stateless; caller owns
28
+ * concurrency, worktrees, and the checkpoint. Expected failures (blocked
29
+ * stories) flow back through result fields; unexpected failures (GH 5xx,
30
+ * malformed / old-shape checkpoint) throw `WaveRunnerError`.
11
31
  *
12
32
  * @module lib/wave-runner/tick
13
33
  */
@@ -15,29 +35,50 @@
15
35
  import { existsSync, readFileSync } from 'node:fs';
16
36
 
17
37
  import { epicLedgerPath } from '../config/temp-paths.js';
38
+ import { detectCycle } from '../Graph.js';
18
39
  import { AGENT_LABELS } from '../label-constants.js';
19
40
  import { appendEpicSignal } from '../observability/signals-writer.js';
20
41
  import * as epicRunStateStoreModule from '../orchestration/epic-run-state-store.js';
21
42
  import { detectRecurringFailures } from '../orchestration/recurring-failure-detector.js';
22
43
  import { upsertStructuredComment as defaultUpsertStructuredComment } from '../orchestration/ticketing.js';
44
+ import { buildStoryAdjacency } from '../story-adjacency.js';
23
45
 
24
- import { collectHaltedStoryIds } from './wave-checkpoint.js';
46
+ import { classifyStory, selectReadySet, storyIdOf } from './ready-set.js';
25
47
  import { WaveRunnerError } from './wave-runner-error.js';
26
48
 
27
49
  /**
28
- * Advance the wave loop one step. Returns a `WaveTickResult`:
50
+ * The checkpoint fields whose presence marks an **old-shape** (wave-batch)
51
+ * `epic-run-state` comment. The ready-set runtime cannot mis-schedule
52
+ * against a wave-indexed plan — indexing the old wave grouping would silently
53
+ * dispatch the wrong stories — so the tick fails closed on any of these
54
+ * fields with an explicit operator message rather than guessing.
55
+ */
56
+ const OLD_SHAPE_FIELDS = Object.freeze(['plan', 'currentWave', 'totalWaves']);
57
+
58
+ /**
59
+ * Advance the Epic one beat. Returns a `WaveTickResult`:
29
60
  *
30
- * nextAction: { kind: 'dispatch', stories: [{ id, title?, worktree? }, ...] }
31
- * | { kind: 'observe', waitingOn: number[] }
32
- * | { kind: 'wave-complete', index: number }
61
+ * nextAction: { kind: 'dispatch', stories: [{ id, title? }, ...] }
62
+ * | { kind: 'observe', waitingOn: number[] }
63
+ * | { kind: 'halt', reason: string, stuckStories: number[],
64
+ * cycle?: number[] }
33
65
  * | { kind: 'epic-complete' }
34
66
  * blockedStories: [{ storyId, reason, detail? }, ...]
35
67
  * gateFailures: [{ storyId, gate, detail? }, ...]
36
- * currentWave: number
37
- * totalWaves: number
68
+ * readyCount: number // size of the ready set this beat
69
+ * inFlight: number[] // ledger-derived dispatched-not-yet-ended ids
70
+ *
71
+ * Readiness comes entirely from the **live** Story bodies + labels — the
72
+ * checkpoint contributes only the Story set in scope and the global cap.
38
73
  *
39
- * Wave grouping comes from the checkpoint's `state.plan` (the GH-derived
40
- * dependency-DAG grouping originally seeded by /plan).
74
+ * `epic-complete` is returned **only** when every in-scope Story is done.
75
+ * If the ready set is empty and nothing is in flight but at least one Story
76
+ * is still not done — a Story gated on an unsatisfiable dependency
77
+ * (a dependency cycle, or a `blocked by #N` that survived adjacency closure)
78
+ * — the tick returns a non-terminal `halt` naming the stuck Story ids rather
79
+ * than silently reporting the Epic complete and stranding the Story. A
80
+ * dependency cycle among the in-scope Stories is likewise surfaced as a
81
+ * `halt` (with the offending `cycle`), never collapsed to `epic-complete`.
41
82
  *
42
83
  * @typedef {object} WaveTickArgs
43
84
  * @property {number | { id: number }} epic
@@ -66,10 +107,9 @@ export async function tick(args = {}) {
66
107
  if (!provider) {
67
108
  throw new WaveRunnerError('invalid-input', 'provider is required');
68
109
  }
69
- // Story #2409 — the wave-runner tick is stateless. When the caller
70
- // does not supply a collaborator shim, we read the `epic-run-state`
71
- // structured comment directly via the function-based store, mirroring
72
- // the pre-migration `.read()` shape exactly.
110
+ // The ready-set tick is stateless. When the caller does not supply a
111
+ // collaborator shim, read the `epic-run-state` structured comment
112
+ // directly via the function-based store.
73
113
  const epicRunStateStore = collabStore ?? {
74
114
  read: () => epicRunStateStoreModule.read({ provider, epicId }),
75
115
  };
@@ -90,53 +130,55 @@ export async function tick(args = {}) {
90
130
  );
91
131
  }
92
132
 
93
- const currentWave = positiveIntOrZero(state.currentWave);
94
- const plan = Array.isArray(state.plan) ? state.plan : [];
95
- const totalWaves = positiveIntOrZero(state.totalWaves);
96
- const history = Array.isArray(state.waves) ? state.waves : [];
133
+ // Fail closed on an old-shape (wave-batch) checkpoint. A `plan` /
134
+ // `currentWave` / `totalWaves` comment predates the ready-set cutover
135
+ // (Story #4155); the ready-set runtime would otherwise ignore those
136
+ // fields and re-derive readiness from live labels — silently discarding
137
+ // an in-progress wave-batch run's resume pointer. Refuse with an explicit
138
+ // operator remediation instead.
139
+ assertNotOldShape(state, epicId);
97
140
 
98
- if (totalWaves === 0 || currentWave >= totalWaves) {
99
- return tickResult({
100
- nextAction: { kind: 'epic-complete' },
101
- currentWave,
102
- totalWaves,
103
- });
104
- }
141
+ const globalCap = positiveIntOrZero(state.concurrencyCap);
142
+ const storyIds = checkpointStoryIds(state);
105
143
 
106
- const wavePlan = Array.isArray(plan[currentWave]) ? plan[currentWave] : [];
107
- if (wavePlan.length === 0) {
108
- await emit({
109
- kind: 'wave-complete',
110
- index: currentWave,
111
- totalWaves,
112
- empty: true,
113
- });
144
+ if (storyIds.length === 0) {
145
+ // No Stories in scope — the Epic has nothing to dispatch.
114
146
  return tickResult({
115
- nextAction: { kind: 'wave-complete', index: currentWave },
116
- currentWave,
117
- totalWaves,
147
+ nextAction: withInFlight({ kind: 'epic-complete' }, []),
148
+ readyCount: 0,
149
+ inFlight: [],
118
150
  });
119
151
  }
120
152
 
121
- // Story #3026 match the iterate-waves resume-check cache strategy:
122
- // only Stories that the checkpoint marks as halted on a prior wave
123
- // are force-refreshed. Every other Story serves the tick fetch from
124
- // the provider's in-process cache, eliminating the per-wave
125
- // `fresh: true` round-trip we historically issued for every Story.
126
- const haltedStoryIds = collectHaltedStoryIds(state);
127
- let waveStates;
153
+ // 1. Re-fetch the live Story records (body + labels + issue state) for
154
+ // every Story in scope. The body feeds `buildStoryAdjacency` (inside
155
+ // `selectReadySet`) so the dependency edges are always read from the
156
+ // current ticket text, never a stale checkpoint snapshot. In-flight
157
+ // Stories are force-fresh-fetched so a label that flipped since the
158
+ // last tick is observed; every other Story serves from the provider's
159
+ // in-process cache.
160
+ const inFlight = await safeReadInFlight(inFlightReader);
161
+ const inFlightSet = new Set(inFlight);
162
+ let records;
128
163
  try {
129
- waveStates = await Promise.all(
130
- wavePlan.map(async (s) => {
131
- const id = storyIdOf(s);
132
- const opts = haltedStoryIds.has(id) ? { fresh: true } : {};
164
+ records = await Promise.all(
165
+ storyIds.map(async (id) => {
166
+ const opts = inFlightSet.has(id) ? { fresh: true } : {};
133
167
  const ticket = await provider.getTicket(id, opts);
134
168
  return {
135
169
  id,
136
- title: s.title ?? ticket?.title,
137
- worktree: s.worktree,
170
+ title: ticket?.title,
171
+ body: ticket?.body ?? '',
138
172
  labels: Array.isArray(ticket?.labels) ? ticket.labels : [],
139
173
  state: ticket?.state,
174
+ // Forward every declared file-footprint shape so the selector's
175
+ // overlap co-dispatch guard (`storiesOverlap`) can withhold two
176
+ // Stories that would race the same path on parallel branches.
177
+ files: Array.isArray(ticket?.files) ? ticket.files : undefined,
178
+ changes: Array.isArray(ticket?.changes) ? ticket.changes : undefined,
179
+ changeset: Array.isArray(ticket?.changeset)
180
+ ? ticket.changeset
181
+ : undefined,
140
182
  };
141
183
  }),
142
184
  );
@@ -144,136 +186,225 @@ export async function tick(args = {}) {
144
186
  throw new WaveRunnerError('story-fetch', err);
145
187
  }
146
188
 
147
- // Story #2891 compute in-flight Stories from the lifecycle ledger.
148
- // A Story is "in-flight" when the ledger carries a
149
- // `story.dispatch.start` record for it without a matching
150
- // `story.dispatch.end`. The reconciliation is purely additive on the
151
- // result envelope so callers can surface dispatched-but-uncompleted
152
- // Stories that the per-Wave label state alone cannot reveal.
153
- //
154
- // Story #3907 — the in-flight set is read **before** the dispatch
155
- // classification so it can be subtracted from the dispatchable set below.
156
- const inFlight = await safeReadInFlight(inFlightReader);
157
- const inFlightSet = new Set(inFlight);
158
-
159
- // Story #3907 — a Story is "done" when it carries `agent::done` OR its
160
- // GitHub issue is `state === 'closed'`. Reading the closed state (not just
161
- // the label) means a Story closed manually through the GitHub UI — which
162
- // closes the issue but does not flip the `agent::*` label — is recognised
163
- // as done and is never re-dispatched.
164
- const done = waveStates.filter(isStoryDone);
165
- const blocked = waveStates.filter((s) =>
166
- s.labels.includes(AGENT_LABELS.BLOCKED),
167
- );
168
- const executing = waveStates.filter((s) =>
169
- s.labels.includes(AGENT_LABELS.EXECUTING),
170
- );
171
- // Undispatched = no terminal/in-progress label AND not closed. The closed
172
- // check rides on `isStoryDone` via the negation in `isUndispatched`.
173
- const undispatchedByLabel = waveStates.filter(isUndispatched);
189
+ // 2. Classify by live label. `done` / `blocked` / `executing` / `ready`.
190
+ const byClass = { done: [], blocked: [], executing: [], ready: [] };
191
+ for (const rec of records) {
192
+ byClass[classifyStory(rec)].push(rec);
193
+ }
174
194
 
175
- // Story #3907 subtract ledger in-flight Stories from the dispatch set.
176
- // A Story whose `story.dispatch.start` has been recorded but whose label
177
- // has not yet flipped to `agent::executing` (the child is mid-`story-init`,
178
- // or the host crashed after the dispatch-ledger write but before the label
179
- // flip) still looks "undispatched" by label alone. Re-dispatching it would
180
- // put a second agent on the same `story-<id>` branch — the worst failure
181
- // mode in the system. The ledger in-flight signal is the authoritative
182
- // "already dispatched" record, so it overrides the label view here.
183
- const dispatchable = undispatchedByLabel.filter(
184
- (s) => !inFlightSet.has(s.id),
195
+ // 2a. Detect a dependency cycle among the in-scope Stories BEFORE selecting.
196
+ // A cycle makes every Story on it permanently un-eligible (no member's
197
+ // deps can all be done), so `selectReadySet` would return an empty set
198
+ // and the terminal decision could otherwise mistake the stall for
199
+ // completion. Surface it as a `halt` so the workflow parks the Epic on
200
+ // a diagnosable condition instead of silently dropping the cycle. Build
201
+ // adjacency with `dropForeign: true` to match the Epic-scoped semantics
202
+ // (a cycle is only meaningful over the scheduled sibling set). Mirrors
203
+ // the cycle handling in `stories-wave-tick.js`.
204
+ const epicAdjacency = buildStoryAdjacency(records, { dropForeign: true });
205
+ const cycle = detectCycle(epicAdjacency);
206
+
207
+ // 3. Select the ready set under the GLOBAL in-flight cap. The selector
208
+ // re-derives adjacency from the live bodies (with `dropForeign: true` so
209
+ // a `blocked by #N` whose target is outside this Epic's Story set — a
210
+ // foreign id or a typo — is pruned rather than treated as a permanent
211
+ // unsatisfiable gate that strands the dependent), and applies the
212
+ // file-overlap co-dispatch guard, returning the deterministic,
213
+ // overlap-free, dependency-satisfied subset capped at the remaining
214
+ // slots.
215
+ //
216
+ // A Story recorded in-flight on the ledger (`story.dispatch.start`
217
+ // without a matching `.end`) but whose label has not yet flipped to
218
+ // `agent::executing` (the child is mid-`story-init`, or the host crashed
219
+ // after the dispatch-ledger write but before the label flip) still reads
220
+ // as `ready` by label alone. Re-dispatching it would put a second agent
221
+ // on the same `story-<id>` branch — the worst failure mode in the
222
+ // system. So the candidate set passed to the selector marks those
223
+ // Stories `executing`: they keep occupying a slot (and gate any
224
+ // dependent, since they are not done) but are never re-selected.
225
+ //
226
+ // The slot denominator is the size of the UNION of (a) ledger-in-flight
227
+ // ids and (b) Stories carrying `agent::executing` by label. A Story that
228
+ // flipped to `agent::executing` but whose `story.dispatch.start` never
229
+ // landed in the ledger (e.g. the label flip raced ahead of the ledger
230
+ // write) occupies a real slot the ledger count alone misses; counting
231
+ // only the ledger would let the global cap be exceeded. The union is the
232
+ // authoritative occupied-slot count.
233
+ const candidates = records.map((rec) =>
234
+ inFlightSet.has(rec.id) && classifyStory(rec) === 'ready'
235
+ ? { ...rec, labels: [...rec.labels, AGENT_LABELS.EXECUTING] }
236
+ : rec,
185
237
  );
238
+ const doneIds = byClass.done.map((s) => s.id);
239
+ const occupiedSlotIds = new Set([
240
+ ...inFlight,
241
+ ...byClass.executing.map((s) => s.id),
242
+ ]);
243
+ const readySet = selectReadySet({
244
+ stories: candidates,
245
+ doneIds,
246
+ inFlight: occupiedSlotIds.size,
247
+ globalCap,
248
+ dropForeign: true,
249
+ });
186
250
 
187
- const blockedStories = blocked.map((s) => ({
188
- storyId: s.id,
189
- reason: 'agent::blocked',
190
- detail: s.title,
191
- }));
192
- const gateFailures = readGateFailures(history, currentWave);
193
-
194
- // Story #3062 — scan the per-Epic lifecycle ledger for recurring
195
- // failure classes (≥2 distinct Stories sharing the same
196
- // `close-validate.end` failedGate) and upsert a
197
- // `recurring-failure-class` structured comment on the Epic when
198
- // findings are returned. Idempotent across re-ticks: the upsert path
199
- // diffs body bytes, so a tick that produces the same findings does not
200
- // duplicate the comment. Best-effort — a reporter throw must not crash
201
- // the planner.
251
+ // 4. Best-effort recurring-failure scan (≥2 distinct Stories sharing the
252
+ // same `close-validate.end` failedGate). Idempotent across re-ticks; a
253
+ // reporter throw must not crash the planner.
202
254
  const recurringFailureReporter =
203
255
  collabRecurringFailureReporter ??
204
256
  defaultRecurringFailureReporter({ provider, epicId, config: ctx?.config });
205
257
  await safeReportRecurringFailures(recurringFailureReporter);
206
258
 
207
- // 6. Decide nextAction.
259
+ const blockedStories = byClass.blocked.map((s) => ({
260
+ storyId: s.id,
261
+ reason: 'agent::blocked',
262
+ detail: s.title,
263
+ }));
264
+ const gateFailures = readGateFailures(state);
265
+
266
+ // 5. Decide nextAction.
267
+ // - A blocked Story halts the Epic → observe (the workflow flips the
268
+ // Epic to agent::blocked and parks).
269
+ // - A dependency cycle among the in-scope Stories halts the Epic → halt
270
+ // (the cycle is an unsatisfiable gate; never collapse it to complete).
271
+ // - A non-empty ready set → dispatch it. Fire `wave-start` on the very
272
+ // first dispatch of the run (nothing executing / in-flight / done
273
+ // yet) so the perf-aggregator can bracket the run's wall-clock.
274
+ // - Otherwise, if any Story is still executing or in-flight → observe.
275
+ // - Otherwise, if EVERY in-scope Story is done → epic-complete.
276
+ // - Otherwise the ready set is empty, nothing is in flight, yet not all
277
+ // Stories are done: at least one Story is permanently gated (an
278
+ // unsatisfiable dependency that survived adjacency closure). Halt and
279
+ // name the stuck Story ids — never silently report the Epic complete.
280
+ const allDone = byClass.done.length === records.length;
208
281
  let nextAction;
209
-
210
- // Stories that are label-undispatched but recorded in-flight on the ledger
211
- // (subtracted out of `dispatchable`) must be observed, not re-dispatched —
212
- // see the in-flight subtraction above.
213
- const inFlightUndispatched = undispatchedByLabel.filter((s) =>
214
- inFlightSet.has(s.id),
215
- );
216
-
217
282
  if (blockedStories.length) {
218
- nextAction = { kind: 'observe', waitingOn: blocked.map((s) => s.id) };
219
- } else if (dispatchable.length) {
220
- // First dispatch of this wave fires `wave-start` exactly once — the
221
- // perf-aggregator (`waveParallelism` report) brackets each wave's
222
- // wall-clock from `wave-start` → `wave-complete`. The ledger in-flight
223
- // set is consulted alongside the label view so a dispatched-but-not-yet-
224
- // executing Story does not re-fire `wave-start`.
283
+ nextAction = {
284
+ kind: 'observe',
285
+ waitingOn: byClass.blocked.map((s) => s.id).sort((a, b) => a - b),
286
+ };
287
+ } else if (cycle) {
288
+ const cycleIds = cycle
289
+ .filter((id) => Number.isInteger(id))
290
+ .sort((a, b) => a - b);
291
+ nextAction = {
292
+ kind: 'halt',
293
+ reason: 'dependency-cycle',
294
+ stuckStories: cycleIds,
295
+ cycle,
296
+ };
297
+ } else if (readySet.length) {
225
298
  if (
226
- executing.length === 0 &&
227
- done.length === 0 &&
228
- inFlightUndispatched.length === 0
299
+ byClass.executing.length === 0 &&
300
+ byClass.done.length === 0 &&
301
+ inFlight.length === 0
229
302
  ) {
230
303
  await emit({
231
304
  kind: 'wave-start',
232
- index: currentWave,
233
- totalWaves,
234
- stories: wavePlan.map((s) => ({ id: storyIdOf(s), title: s.title })),
305
+ stories: records.map((s) => ({ id: s.id, title: s.title })),
235
306
  });
236
307
  }
237
308
  nextAction = {
238
309
  kind: 'dispatch',
239
- stories: dispatchable.map((s) => ({
240
- id: s.id,
310
+ stories: readySet.map((s) => ({
311
+ id: storyIdOf(s),
241
312
  title: s.title,
242
- worktree: s.worktree,
243
313
  })),
244
314
  };
245
- } else if (executing.length || inFlightUndispatched.length) {
246
- // Either a Story is `agent::executing`, or the ledger shows a
247
- // dispatched-but-unflipped Story we just declined to re-dispatch. Both
248
- // are in-flight — observe rather than collapse the wave.
315
+ } else if (byClass.executing.length || inFlight.length) {
249
316
  const waitingOn = [
250
- ...executing.map((s) => s.id),
251
- ...inFlightUndispatched.map((s) => s.id),
317
+ ...new Set([...byClass.executing.map((s) => s.id), ...inFlight]),
252
318
  ].sort((a, b) => a - b);
253
319
  nextAction = { kind: 'observe', waitingOn };
254
- } else if (currentWave + 1 >= totalWaves) {
320
+ } else if (allDone) {
321
+ // Every Story is done and nothing is in flight: the run is complete.
322
+ await emit({ kind: 'wave-complete' });
255
323
  nextAction = { kind: 'epic-complete' };
256
324
  } else {
257
- // Closes the wave window for the perf-aggregator's wall-clock bracket.
258
- await emit({ kind: 'wave-complete', index: currentWave, totalWaves });
259
- nextAction = { kind: 'wave-complete', index: currentWave };
325
+ // Ready set empty, nothing in flight, but not all Stories are done — a
326
+ // Story is gated on an unsatisfiable dependency. Halt with the stuck ids
327
+ // (every not-done, not-in-flight Story) so the operator can see exactly
328
+ // which Story stranded the run instead of a false epic-complete.
329
+ const stuckStories = records
330
+ .filter((rec) => classifyStory(rec) !== 'done')
331
+ .map((rec) => rec.id)
332
+ .filter((id) => Number.isInteger(id))
333
+ .sort((a, b) => a - b);
334
+ nextAction = {
335
+ kind: 'halt',
336
+ reason: 'unsatisfiable-dependency',
337
+ stuckStories,
338
+ };
260
339
  }
261
340
 
262
- // Story #2891 — attach the in-flight ledger reconciliation to the
263
- // nextAction envelope. Always emit the field (empty array when the
264
- // ledger is silent) so downstream consumers can pattern-match on
265
- // presence without an existence check.
266
- nextAction['in-flight'] = inFlight;
267
-
268
341
  return tickResult({
269
- nextAction,
342
+ nextAction: withInFlight(nextAction, inFlight),
270
343
  blockedStories,
271
344
  gateFailures,
272
- currentWave,
273
- totalWaves,
345
+ readyCount: readySet.length,
346
+ inFlight,
274
347
  });
275
348
  }
276
349
 
350
+ /**
351
+ * Throw `WaveRunnerError('old-shape-checkpoint')` when the checkpoint still
352
+ * carries any wave-batch field. The message names the offending field(s) and
353
+ * the operator remediation so a stuck delivery is diagnosable from the
354
+ * thrown error alone.
355
+ *
356
+ * @param {object} state Parsed checkpoint.
357
+ * @param {number} epicId
358
+ */
359
+ function assertNotOldShape(state, epicId) {
360
+ const present = OLD_SHAPE_FIELDS.filter((f) => Object.hasOwn(state, f));
361
+ if (present.length === 0) return;
362
+ throw new WaveRunnerError(
363
+ 'old-shape-checkpoint',
364
+ `Epic #${epicId} carries a pre-ready-set (wave-batch) epic-run-state ` +
365
+ `checkpoint (fields: ${present.join(', ')}). The ready-set /deliver ` +
366
+ `runtime cannot resume a wave-batch run. Re-run ` +
367
+ `\`node .agents/scripts/epic-deliver-prepare.js --epic ${epicId}\` to ` +
368
+ `re-seed the checkpoint in the per-Story-status shape, then re-run ` +
369
+ `/deliver.`,
370
+ );
371
+ }
372
+
373
+ /**
374
+ * Extract the in-scope Story ids from the shrunk checkpoint's per-Story
375
+ * `stories` status map (`{ [storyId]: { status, ... } }`). Returns an
376
+ * ascending-sorted, deduped array of positive integers; tolerates an absent
377
+ * / malformed map by returning `[]`.
378
+ *
379
+ * @param {object} state
380
+ * @returns {number[]}
381
+ */
382
+ function checkpointStoryIds(state) {
383
+ const stories = state?.stories;
384
+ if (!stories || typeof stories !== 'object') return [];
385
+ const ids = new Set();
386
+ for (const key of Object.keys(stories)) {
387
+ const id = Number(key);
388
+ if (Number.isInteger(id) && id > 0) ids.add(id);
389
+ }
390
+ return [...ids].sort((a, b) => a - b);
391
+ }
392
+
393
+ /**
394
+ * Attach the ledger-derived in-flight Story-id list to a `nextAction`
395
+ * envelope under the `in-flight` key. Always present (empty array when the
396
+ * ledger is silent) so downstream consumers pattern-match on presence
397
+ * without an existence check.
398
+ *
399
+ * @param {object} nextAction
400
+ * @param {number[]} inFlight
401
+ * @returns {object} the same nextAction (mutated) for call-site convenience
402
+ */
403
+ function withInFlight(nextAction, inFlight) {
404
+ nextAction['in-flight'] = inFlight;
405
+ return nextAction;
406
+ }
407
+
277
408
  /**
278
409
  * Wrap the configured `inFlightReader` with a defensive guard so an
279
410
  * unreadable ledger never crashes the tick. The default reader already
@@ -415,17 +546,17 @@ async function defaultInFlightReader(epicId, config) {
415
546
  for (const id of started) {
416
547
  if (!ended.has(id)) inFlight.push(id);
417
548
  }
418
- return inFlight;
549
+ return inFlight.sort((a, b) => a - b);
419
550
  }
420
551
 
421
552
  function tickResult({
422
553
  nextAction,
423
554
  blockedStories = [],
424
555
  gateFailures = [],
425
- currentWave,
426
- totalWaves,
556
+ readyCount = 0,
557
+ inFlight = [],
427
558
  }) {
428
- return { nextAction, blockedStories, gateFailures, currentWave, totalWaves };
559
+ return { nextAction, blockedStories, gateFailures, readyCount, inFlight };
429
560
  }
430
561
 
431
562
  function resolveEpicId(epic) {
@@ -443,66 +574,41 @@ function positiveIntOrZero(v) {
443
574
  return Number.isInteger(v) && v >= 0 ? v : 0;
444
575
  }
445
576
 
446
- function storyIdOf(s) {
447
- if (typeof s === 'number') return s;
448
- return s.id ?? s.storyId ?? s.number;
449
- }
450
-
451
577
  /**
452
- * A Story is "done" when it carries `agent::done` OR its GitHub issue is
453
- * `state === 'closed'`. The closed-state arm (Story #3907) is what aligns the
454
- * wave planner with the other done-predicates in the codebase
455
- * (`reconciler.isDone`, `verifySingleResult`) so a Story closed manually
456
- * through the GitHub UI which closes the issue without flipping the
457
- * `agent::*` label — is recognised as done and never re-dispatched.
578
+ * Derive gate-failure rows from the checkpoint's per-Story `stories` status
579
+ * map: every Story recorded as `failed` surfaces as a gate failure so the
580
+ * operator workflow can act on it. The shrunk checkpoint no longer carries a
581
+ * per-wave history with explicit gate names, so the gate is reported as
582
+ * `unspecified` and the recorded `title` (when present) is the detail.
458
583
  *
459
- * @param {{ labels: string[], state?: string }} s
460
- * @returns {boolean}
584
+ * @param {object} state Parsed checkpoint.
585
+ * @returns {Array<{ storyId: number, gate: string, detail?: string }>}
461
586
  */
462
- export function isStoryDone(s) {
463
- const labels = Array.isArray(s?.labels) ? s.labels : [];
464
- return labels.includes(AGENT_LABELS.DONE) || s?.state === 'closed';
465
- }
466
-
467
- /**
468
- * A wave member is "undispatched" when it carries none of the terminal /
469
- * in-progress labels AND is not a closed issue. The closed check (Story
470
- * #3907) prevents a manually-closed Story (issue closed, label not flipped)
471
- * from being re-dispatched.
472
- *
473
- * @param {{ labels: string[], state?: string }} s
474
- * @returns {boolean}
475
- */
476
- function isUndispatched(s) {
477
- const labels = Array.isArray(s?.labels) ? s.labels : [];
478
- return (
479
- !isStoryDone(s) &&
480
- !labels.includes(AGENT_LABELS.BLOCKED) &&
481
- !labels.includes(AGENT_LABELS.EXECUTING)
482
- );
483
- }
484
-
485
- function readGateFailures(history, currentWave) {
486
- const prior = history[currentWave - 1];
487
- if (!prior || !Array.isArray(prior.stories)) return [];
488
- return prior.stories
489
- .filter((s) => s.status === 'failed' && typeof s.detail === 'string')
490
- .map((s) => ({
491
- storyId: s.storyId,
492
- gate: s.gate ?? 'unspecified',
493
- detail: s.detail,
494
- }));
587
+ function readGateFailures(state) {
588
+ const stories = state?.stories;
589
+ if (!stories || typeof stories !== 'object') return [];
590
+ const out = [];
591
+ for (const [key, rec] of Object.entries(stories)) {
592
+ const id = Number(key);
593
+ if (!Number.isInteger(id) || id <= 0) continue;
594
+ if (rec?.status !== 'failed') continue;
595
+ const row = { storyId: id, gate: 'unspecified' };
596
+ if (typeof rec.title === 'string' && rec.title) row.detail = rec.title;
597
+ out.push(row);
598
+ }
599
+ return out.sort((a, b) => a.storyId - b.storyId);
495
600
  }
496
601
 
497
602
  /**
498
603
  * Default emitter — appends to per-Epic `signals.ndjson`. Best-effort;
499
604
  * never throws. Tests override via `collaborators.signalEmit`.
500
605
  *
501
- * Story #3909 — the planner now emits only the two wave events that have a
502
- * live consumer: `wave-start` and `wave-complete`, which the perf-aggregator
503
- * (`waveParallelism` report) brackets into per-wave wall-clock. The
504
- * write-only `wave-tick` (per-call telemetry) and `epic-complete` (no reader)
505
- * emits were dropped they duplicated the `epic-run-state` checkpoint and the
606
+ * Story #3909 / #4155 — the planner emits only the two wave-window
607
+ * forensics events with a live consumer: `wave-start` (fired on the run's
608
+ * first dispatch) and `wave-complete` (fired when the run finishes), which
609
+ * the perf-aggregator (`waveParallelism` report) brackets into the run's
610
+ * wall-clock. The write-only per-call telemetry and `epic-complete` emits
611
+ * were dropped — they duplicated the `epic-run-state` checkpoint and the
506
612
  * `epic-run-progress` rollup and nothing consumed them.
507
613
  */
508
614
  function defaultSignalEmit(epicId, ctx) {