mandrel 1.68.0 → 1.70.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.
- package/.agents/README.md +1 -1
- package/.agents/docs/agentrc-reference.json +1 -2
- package/.agents/docs/configuration.md +2 -4
- package/.agents/docs/workflows.md +1 -1
- package/.agents/schemas/agentrc.schema.json +1 -5
- package/.agents/schemas/lifecycle/epic.automerge.end.schema.json +2 -1
- package/.agents/scripts/agents-update-preflight.js +235 -0
- package/.agents/scripts/apply-quality-bootstrap.js +79 -0
- package/.agents/scripts/audit-labels-bootstrap.js +52 -30
- package/.agents/scripts/audit-to-stories.js +54 -0
- package/.agents/scripts/bootstrap.js +13 -3
- package/.agents/scripts/epic-deliver-preflight.js +30 -13
- package/.agents/scripts/epic-deliver-prepare.js +40 -53
- package/.agents/scripts/epic-execute-record-wave.js +119 -133
- package/.agents/scripts/generate-config-docs.js +189 -94
- package/.agents/scripts/lib/audit-suite/findings.js +0 -4
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +99 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +13 -5
- package/.agents/scripts/lib/baseline-snapshot.js +163 -4
- package/.agents/scripts/lib/baselines/refresh-service.js +13 -5
- package/.agents/scripts/lib/config/baselines.js +0 -20
- package/.agents/scripts/lib/config/explain.js +0 -2
- package/.agents/scripts/lib/config/limits.js +19 -8
- package/.agents/scripts/lib/config/temp-paths.js +0 -31
- package/.agents/scripts/lib/config-settings-schema.js +1 -2
- package/.agents/scripts/lib/crap-utils.js +281 -0
- package/.agents/scripts/lib/maintainability-utils.js +32 -9
- package/.agents/scripts/lib/orchestration/dispatch-engine.js +0 -2
- package/.agents/scripts/lib/orchestration/epic-cleanup.js +11 -7
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +6 -6
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +11 -5
- package/.agents/scripts/lib/orchestration/epic-run-state-store.js +203 -110
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +38 -162
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +3 -4
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/transport.js +16 -13
- package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +10 -7
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +37 -24
- package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -4
- package/.agents/scripts/lib/orchestration/manifest-builder.js +6 -0
- package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +101 -70
- package/.agents/scripts/lib/orchestration/spec-renderer.js +42 -14
- package/.agents/scripts/lib/orchestration/ticket-lease.js +3 -0
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +6 -2
- package/.agents/scripts/lib/orchestration/wave-record-io.js +18 -77
- package/.agents/scripts/lib/orchestration/wave-record-notifications.js +78 -122
- package/.agents/scripts/lib/orchestration/wave-record-projection.js +21 -226
- package/.agents/scripts/lib/presentation/dispatch-manifest-render.js +18 -1
- package/.agents/scripts/lib/presentation/manifest-render-waves.js +77 -4
- package/.agents/scripts/lib/story-adjacency.js +14 -10
- package/.agents/scripts/lib/story-body/story-body.js +142 -65
- package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -3
- package/.agents/scripts/lib/test-tiers.js +13 -7
- package/.agents/scripts/lib/wave-runner/ready-set.js +295 -0
- package/.agents/scripts/lib/wave-runner/tick.js +446 -216
- package/.agents/scripts/lib/wave-runner/wave-runner-error.js +2 -1
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +226 -0
- package/.agents/scripts/lint-label-vocabulary.js +1 -1
- package/.agents/scripts/providers/github/issues.js +48 -0
- package/.agents/scripts/providers/github.js +1 -0
- package/.agents/scripts/stories-wave-tick.js +262 -161
- package/.agents/skills/core/epic-plan-consolidate/SKILL.md +6 -0
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +108 -101
- package/.agents/skills/skills.index.json +2 -2
- package/.agents/workflows/agents-update.md +205 -28
- package/.agents/workflows/deliver.md +12 -9
- package/.agents/workflows/helpers/deliver-epic.md +126 -90
- package/.agents/workflows/helpers/deliver-stories.md +131 -85
- package/.agents/workflows/helpers/plan-epic.md +13 -10
- package/.agents/workflows/plan.md +1 -1
- package/README.md +20 -0
- package/docs/CHANGELOG.md +46 -0
- package/lib/cli/registry.js +49 -6
- package/lib/cli/update.js +335 -332
- package/package.json +16 -11
- package/.agents/scripts/lib/wave-runner/wave-checkpoint.js +0 -91
|
@@ -1,13 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `tick({ epic, collaborators })` — single callable entry point for
|
|
3
|
-
* "advance this
|
|
4
|
-
*
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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`.
|
|
31
|
+
*
|
|
32
|
+
* Story #4183 — the `tick(args)` orchestrator was a 252-line SRP /
|
|
33
|
+
* cognitive-load hotspot carrying six distinct responsibilities in one
|
|
34
|
+
* body. It is now a thin coordinator (Coordinator-plus-Phases pattern,
|
|
35
|
+
* `docs/patterns.md`) that wires four extracted stages:
|
|
36
|
+
* `resolveTickCollaborators` (collaborator/fallback resolution),
|
|
37
|
+
* `readAndValidateCheckpoint` (checkpoint read + shape validation, folding
|
|
38
|
+
* in `assertNotOldShape`), `refetchStoryRecords` (force-fresh re-fetch),
|
|
39
|
+
* and the **pure** `planTick` (classification → cycle detection → ready-set
|
|
40
|
+
* selection → dispatch decision, returning the signals to emit rather than
|
|
41
|
+
* emitting them, so it carries no I/O). The exported `tick(args)`
|
|
42
|
+
* signature, the `tickResult` / `withInFlight` envelope shapes, and every
|
|
43
|
+
* `WaveRunnerError` code are preserved verbatim — callers and tests are
|
|
44
|
+
* unchanged.
|
|
11
45
|
*
|
|
12
46
|
* @module lib/wave-runner/tick
|
|
13
47
|
*/
|
|
@@ -15,29 +49,57 @@
|
|
|
15
49
|
import { existsSync, readFileSync } from 'node:fs';
|
|
16
50
|
|
|
17
51
|
import { epicLedgerPath } from '../config/temp-paths.js';
|
|
52
|
+
import { detectCycle } from '../Graph.js';
|
|
18
53
|
import { AGENT_LABELS } from '../label-constants.js';
|
|
19
54
|
import { appendEpicSignal } from '../observability/signals-writer.js';
|
|
20
55
|
import * as epicRunStateStoreModule from '../orchestration/epic-run-state-store.js';
|
|
21
56
|
import { detectRecurringFailures } from '../orchestration/recurring-failure-detector.js';
|
|
22
57
|
import { upsertStructuredComment as defaultUpsertStructuredComment } from '../orchestration/ticketing.js';
|
|
58
|
+
import { buildStoryAdjacency } from '../story-adjacency.js';
|
|
23
59
|
|
|
24
|
-
import {
|
|
60
|
+
import { classifyStory, selectReadySet, storyIdOf } from './ready-set.js';
|
|
25
61
|
import { WaveRunnerError } from './wave-runner-error.js';
|
|
26
62
|
|
|
27
63
|
/**
|
|
28
|
-
*
|
|
64
|
+
* The checkpoint fields whose presence marks an **old-shape** (wave-batch)
|
|
65
|
+
* `epic-run-state` comment. The ready-set runtime cannot mis-schedule
|
|
66
|
+
* against a wave-indexed plan — indexing the old wave grouping would silently
|
|
67
|
+
* dispatch the wrong stories — so the tick fails closed on any of these
|
|
68
|
+
* fields with an explicit operator message rather than guessing.
|
|
69
|
+
*/
|
|
70
|
+
const OLD_SHAPE_FIELDS = Object.freeze(['plan', 'currentWave', 'totalWaves']);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Advance the Epic one beat. Returns a `WaveTickResult`:
|
|
29
74
|
*
|
|
30
|
-
* nextAction: { kind: 'dispatch', stories: [{ id, title
|
|
31
|
-
* | { kind: 'observe',
|
|
32
|
-
* | { kind: '
|
|
75
|
+
* nextAction: { kind: 'dispatch', stories: [{ id, title? }, ...] }
|
|
76
|
+
* | { kind: 'observe', waitingOn: number[] }
|
|
77
|
+
* | { kind: 'halt', reason: string, stuckStories: number[],
|
|
78
|
+
* cycle?: number[] }
|
|
33
79
|
* | { kind: 'epic-complete' }
|
|
34
80
|
* blockedStories: [{ storyId, reason, detail? }, ...]
|
|
35
81
|
* gateFailures: [{ storyId, gate, detail? }, ...]
|
|
36
|
-
*
|
|
37
|
-
*
|
|
82
|
+
* readyCount: number // size of the ready set this beat
|
|
83
|
+
* inFlight: number[] // ledger-derived dispatched-not-yet-ended ids
|
|
84
|
+
*
|
|
85
|
+
* Readiness comes entirely from the **live** Story bodies + labels — the
|
|
86
|
+
* checkpoint contributes only the Story set in scope and the global cap.
|
|
38
87
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
88
|
+
* `epic-complete` is returned **only** when every in-scope Story is done.
|
|
89
|
+
* If the ready set is empty and nothing is in flight but at least one Story
|
|
90
|
+
* is still not done — a Story gated on an unsatisfiable dependency
|
|
91
|
+
* (a dependency cycle, or a `blocked by #N` that survived adjacency closure)
|
|
92
|
+
* — the tick returns a non-terminal `halt` naming the stuck Story ids rather
|
|
93
|
+
* than silently reporting the Epic complete and stranding the Story. A
|
|
94
|
+
* dependency cycle among the in-scope Stories is likewise surfaced as a
|
|
95
|
+
* `halt` (with the offending `cycle`), never collapsed to `epic-complete`.
|
|
96
|
+
*
|
|
97
|
+
* Coordinator (Story #4183): this function is a thin dispatcher. It resolves
|
|
98
|
+
* collaborators, reads + validates the checkpoint, re-fetches the live Story
|
|
99
|
+
* records, runs the best-effort recurring-failure scan, delegates the pure
|
|
100
|
+
* dispatch decision to `planTick`, then drains the `signals` `planTick`
|
|
101
|
+
* returned through the configured emitter. Each stage is an independently
|
|
102
|
+
* testable helper below.
|
|
41
103
|
*
|
|
42
104
|
* @typedef {object} WaveTickArgs
|
|
43
105
|
* @property {number | { id: number }} epic
|
|
@@ -53,33 +115,123 @@ import { WaveRunnerError } from './wave-runner-error.js';
|
|
|
53
115
|
* @param {WaveTickArgs} args
|
|
54
116
|
*/
|
|
55
117
|
export async function tick(args = {}) {
|
|
118
|
+
const { epicId, provider, epicRunStateStore, emit, inFlightReader, ctx } =
|
|
119
|
+
resolveTickCollaborators(args);
|
|
120
|
+
|
|
121
|
+
const state = await readAndValidateCheckpoint(epicRunStateStore, epicId);
|
|
122
|
+
|
|
123
|
+
const storyIds = checkpointStoryIds(state);
|
|
124
|
+
|
|
125
|
+
if (storyIds.length === 0) {
|
|
126
|
+
// No Stories in scope — the Epic has nothing to dispatch.
|
|
127
|
+
return tickResult({
|
|
128
|
+
nextAction: withInFlight({ kind: 'epic-complete' }, []),
|
|
129
|
+
readyCount: 0,
|
|
130
|
+
inFlight: [],
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Re-fetch the live Story records (body + labels + issue state) for every
|
|
135
|
+
// Story in scope. In-flight Stories are force-fresh-fetched so a label that
|
|
136
|
+
// flipped since the last tick is observed; every other Story serves from
|
|
137
|
+
// the provider's in-process cache.
|
|
138
|
+
const inFlight = await safeReadInFlight(inFlightReader);
|
|
139
|
+
const inFlightSet = new Set(inFlight);
|
|
140
|
+
const records = await refetchStoryRecords(provider, storyIds, inFlightSet);
|
|
141
|
+
|
|
142
|
+
// Best-effort recurring-failure scan (≥2 distinct Stories sharing the same
|
|
143
|
+
// `close-validate.end` failedGate). Idempotent across re-ticks; a reporter
|
|
144
|
+
// throw must not crash the planner.
|
|
145
|
+
const recurringFailureReporter =
|
|
146
|
+
args.collaborators?.recurringFailureReporter ??
|
|
147
|
+
defaultRecurringFailureReporter({ provider, epicId, config: ctx?.config });
|
|
148
|
+
await safeReportRecurringFailures(recurringFailureReporter);
|
|
149
|
+
|
|
150
|
+
// Decide the next action from the live records + ledger in-flight set. The
|
|
151
|
+
// decision is pure (no I/O); the signals it wants emitted come back in
|
|
152
|
+
// `plan.signals` and are drained by the coordinator below.
|
|
153
|
+
const plan = planTick(state, records, inFlight);
|
|
154
|
+
for (const signal of plan.signals) {
|
|
155
|
+
await emit(signal);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return tickResult({
|
|
159
|
+
nextAction: withInFlight(plan.nextAction, inFlight),
|
|
160
|
+
blockedStories: plan.blockedStories,
|
|
161
|
+
gateFailures: plan.gateFailures,
|
|
162
|
+
readyCount: plan.readyCount,
|
|
163
|
+
inFlight,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Resolve the Epic id and the five injectable collaborators (with their
|
|
169
|
+
* production-default fallbacks) from the `tick` args. The single home for the
|
|
170
|
+
* collaborator/fallback wiring so the coordinator stays declarative.
|
|
171
|
+
*
|
|
172
|
+
* Throws `WaveRunnerError('invalid-input')` when the epic id is not a
|
|
173
|
+
* positive integer (or `{ id: positiveInt }`) or when no provider is supplied
|
|
174
|
+
* via either `collaborators.provider` or `ctx.provider`.
|
|
175
|
+
*
|
|
176
|
+
* @param {WaveTickArgs} args
|
|
177
|
+
* @returns {{
|
|
178
|
+
* epicId: number,
|
|
179
|
+
* provider: object,
|
|
180
|
+
* epicRunStateStore: { read: () => Promise<object|null> },
|
|
181
|
+
* emit: (signal: object) => Promise<unknown>,
|
|
182
|
+
* inFlightReader: () => Promise<number[]>,
|
|
183
|
+
* ctx: object,
|
|
184
|
+
* }}
|
|
185
|
+
*/
|
|
186
|
+
function resolveTickCollaborators(args) {
|
|
56
187
|
const epicId = resolveEpicId(args.epic);
|
|
57
188
|
const {
|
|
58
189
|
provider: collabProvider,
|
|
59
190
|
epicRunStateStore: collabStore,
|
|
60
191
|
signalEmit,
|
|
61
192
|
inFlightReader: collabInFlightReader,
|
|
62
|
-
recurringFailureReporter: collabRecurringFailureReporter,
|
|
63
193
|
} = args.collaborators ?? {};
|
|
64
194
|
const ctx = args.ctx ?? {};
|
|
65
195
|
const provider = collabProvider ?? ctx.provider;
|
|
66
196
|
if (!provider) {
|
|
67
197
|
throw new WaveRunnerError('invalid-input', 'provider is required');
|
|
68
198
|
}
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
// the pre-migration `.read()` shape exactly.
|
|
199
|
+
// The ready-set tick is stateless. When the caller does not supply a
|
|
200
|
+
// collaborator shim, read the `epic-run-state` structured comment directly
|
|
201
|
+
// via the function-based store.
|
|
73
202
|
const epicRunStateStore = collabStore ?? {
|
|
74
203
|
read: () => epicRunStateStoreModule.read({ provider, epicId }),
|
|
75
204
|
};
|
|
76
205
|
const emit = signalEmit ?? defaultSignalEmit(epicId, ctx);
|
|
77
206
|
const inFlightReader =
|
|
78
207
|
collabInFlightReader ?? (() => defaultInFlightReader(epicId, ctx?.config));
|
|
208
|
+
return { epicId, provider, epicRunStateStore, emit, inFlightReader, ctx };
|
|
209
|
+
}
|
|
79
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Read the `epic-run-state` checkpoint via the store, validate its shape, and
|
|
213
|
+
* fail closed on a pre-ready-set (wave-batch) checkpoint.
|
|
214
|
+
*
|
|
215
|
+
* Throws:
|
|
216
|
+
* - `WaveRunnerError('checkpoint-read')` when the store read rejects,
|
|
217
|
+
* - `WaveRunnerError('checkpoint-missing')` when the read resolves to a
|
|
218
|
+
* non-object (no comment),
|
|
219
|
+
* - `WaveRunnerError('old-shape-checkpoint')` when the checkpoint still
|
|
220
|
+
* carries a `plan` / `currentWave` / `totalWaves` field (via
|
|
221
|
+
* `assertNotOldShape`). A `plan` / `currentWave` / `totalWaves` comment
|
|
222
|
+
* predates the ready-set cutover (Story #4155); the ready-set runtime
|
|
223
|
+
* would otherwise ignore those fields and re-derive readiness from live
|
|
224
|
+
* labels — silently discarding an in-progress wave-batch run's resume
|
|
225
|
+
* pointer. Refuse with an explicit operator remediation instead.
|
|
226
|
+
*
|
|
227
|
+
* @param {{ read: () => Promise<object|null> }} store
|
|
228
|
+
* @param {number} epicId
|
|
229
|
+
* @returns {Promise<object>} the validated checkpoint state.
|
|
230
|
+
*/
|
|
231
|
+
async function readAndValidateCheckpoint(store, epicId) {
|
|
80
232
|
let state;
|
|
81
233
|
try {
|
|
82
|
-
state = await
|
|
234
|
+
state = await store.read();
|
|
83
235
|
} catch (err) {
|
|
84
236
|
throw new WaveRunnerError('checkpoint-read', err);
|
|
85
237
|
}
|
|
@@ -89,189 +241,292 @@ export async function tick(args = {}) {
|
|
|
89
241
|
`no epic-run-state comment on Epic #${epicId}`,
|
|
90
242
|
);
|
|
91
243
|
}
|
|
244
|
+
assertNotOldShape(state, epicId);
|
|
245
|
+
return state;
|
|
246
|
+
}
|
|
92
247
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
totalWaves,
|
|
112
|
-
empty: true,
|
|
113
|
-
});
|
|
114
|
-
return tickResult({
|
|
115
|
-
nextAction: { kind: 'wave-complete', index: currentWave },
|
|
116
|
-
currentWave,
|
|
117
|
-
totalWaves,
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
|
|
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;
|
|
248
|
+
/**
|
|
249
|
+
* Re-fetch the live Story records (body + labels + issue state) for every
|
|
250
|
+
* Story in scope. The body feeds `buildStoryAdjacency` (inside
|
|
251
|
+
* `selectReadySet`) so the dependency edges are always read from the current
|
|
252
|
+
* ticket text, never a stale checkpoint snapshot. Stories in `inFlightSet`
|
|
253
|
+
* are force-fresh-fetched (`{ fresh: true }`) so a label that flipped since
|
|
254
|
+
* the last tick is observed; every other Story serves from the provider's
|
|
255
|
+
* in-process cache.
|
|
256
|
+
*
|
|
257
|
+
* Throws `WaveRunnerError('story-fetch')` when any `provider.getTicket`
|
|
258
|
+
* rejects.
|
|
259
|
+
*
|
|
260
|
+
* @param {{ getTicket: (id: number, opts?: object) => Promise<object> }} provider
|
|
261
|
+
* @param {number[]} storyIds Ascending, deduped in-scope Story ids.
|
|
262
|
+
* @param {Set<number>} inFlightSet Ledger-derived in-flight Story ids.
|
|
263
|
+
* @returns {Promise<Array<object>>} normalized Story records.
|
|
264
|
+
*/
|
|
265
|
+
async function refetchStoryRecords(provider, storyIds, inFlightSet) {
|
|
128
266
|
try {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
const opts = haltedStoryIds.has(id) ? { fresh: true } : {};
|
|
267
|
+
return await Promise.all(
|
|
268
|
+
storyIds.map(async (id) => {
|
|
269
|
+
const opts = inFlightSet.has(id) ? { fresh: true } : {};
|
|
133
270
|
const ticket = await provider.getTicket(id, opts);
|
|
134
271
|
return {
|
|
135
272
|
id,
|
|
136
|
-
title:
|
|
137
|
-
|
|
273
|
+
title: ticket?.title,
|
|
274
|
+
body: ticket?.body ?? '',
|
|
138
275
|
labels: Array.isArray(ticket?.labels) ? ticket.labels : [],
|
|
139
276
|
state: ticket?.state,
|
|
277
|
+
// Forward every declared file-footprint shape so the selector's
|
|
278
|
+
// overlap co-dispatch guard (`storiesOverlap`) can withhold two
|
|
279
|
+
// Stories that would race the same path on parallel branches.
|
|
280
|
+
files: Array.isArray(ticket?.files) ? ticket.files : undefined,
|
|
281
|
+
changes: Array.isArray(ticket?.changes) ? ticket.changes : undefined,
|
|
282
|
+
changeset: Array.isArray(ticket?.changeset)
|
|
283
|
+
? ticket.changeset
|
|
284
|
+
: undefined,
|
|
140
285
|
};
|
|
141
286
|
}),
|
|
142
287
|
);
|
|
143
288
|
} catch (err) {
|
|
144
289
|
throw new WaveRunnerError('story-fetch', err);
|
|
145
290
|
}
|
|
291
|
+
}
|
|
146
292
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
293
|
+
/**
|
|
294
|
+
* Pure dispatch planner — the scheduler tick's decision core with **no I/O**.
|
|
295
|
+
* Given the parsed checkpoint, the live Story records, and the ledger-derived
|
|
296
|
+
* in-flight id list, it classifies every Story, detects a sibling dependency
|
|
297
|
+
* cycle, selects the ready set under the global in-flight cap, and decides the
|
|
298
|
+
* `nextAction`. It performs no fetching, no signal emission, and no ledger
|
|
299
|
+
* read: the two wave-window forensics signals are returned in the `signals`
|
|
300
|
+
* array for the coordinator to drain, so this function stays independently
|
|
301
|
+
* unit-testable against fixture records without a provider stub or an emitter.
|
|
302
|
+
*
|
|
303
|
+
* @param {object} state Parsed `epic-run-state` checkpoint (for the global
|
|
304
|
+
* cap and the per-Story `failed` rows surfaced as gate failures).
|
|
305
|
+
* @param {Array<object>} records Live Story records (id, title, body, labels,
|
|
306
|
+
* state, file-footprint shapes).
|
|
307
|
+
* @param {number[]} inFlight Ledger-derived dispatched-not-yet-ended ids.
|
|
308
|
+
* @returns {{
|
|
309
|
+
* nextAction: object,
|
|
310
|
+
* blockedStories: Array<{ storyId: number, reason: string, detail?: string }>,
|
|
311
|
+
* gateFailures: Array<{ storyId: number, gate: string, detail?: string }>,
|
|
312
|
+
* readyCount: number,
|
|
313
|
+
* signals: Array<object>,
|
|
314
|
+
* }}
|
|
315
|
+
*/
|
|
316
|
+
export function planTick(state, records, inFlight) {
|
|
317
|
+
const globalCap = positiveIntOrZero(state.concurrencyCap);
|
|
157
318
|
const inFlightSet = new Set(inFlight);
|
|
158
319
|
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
)
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
320
|
+
// 1. Classify by live label. `done` / `blocked` / `executing` / `ready`.
|
|
321
|
+
const byClass = { done: [], blocked: [], executing: [], ready: [] };
|
|
322
|
+
for (const rec of records) {
|
|
323
|
+
byClass[classifyStory(rec)].push(rec);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 1a. Detect a dependency cycle among the in-scope Stories BEFORE selecting.
|
|
327
|
+
// A cycle makes every Story on it permanently un-eligible (no member's
|
|
328
|
+
// deps can all be done), so `selectReadySet` would return an empty set
|
|
329
|
+
// and the terminal decision could otherwise mistake the stall for
|
|
330
|
+
// completion. Surface it as a `halt` so the workflow parks the Epic on
|
|
331
|
+
// a diagnosable condition instead of silently dropping the cycle. Build
|
|
332
|
+
// adjacency with `dropForeign: true` to match the Epic-scoped semantics
|
|
333
|
+
// (a cycle is only meaningful over the scheduled sibling set). Mirrors
|
|
334
|
+
// the cycle handling in `stories-wave-tick.js`.
|
|
335
|
+
const epicAdjacency = buildStoryAdjacency(records, { dropForeign: true });
|
|
336
|
+
const cycle = detectCycle(epicAdjacency);
|
|
337
|
+
|
|
338
|
+
// 2. Select the ready set under the GLOBAL in-flight cap. The selector
|
|
339
|
+
// re-derives adjacency from the live bodies (with `dropForeign: true` so
|
|
340
|
+
// a `blocked by #N` whose target is outside this Epic's Story set — a
|
|
341
|
+
// foreign id or a typo — is pruned rather than treated as a permanent
|
|
342
|
+
// unsatisfiable gate that strands the dependent), and applies the
|
|
343
|
+
// file-overlap co-dispatch guard, returning the deterministic,
|
|
344
|
+
// overlap-free, dependency-satisfied subset capped at the remaining
|
|
345
|
+
// slots.
|
|
346
|
+
//
|
|
347
|
+
// A Story recorded in-flight on the ledger (`story.dispatch.start`
|
|
348
|
+
// without a matching `.end`) but whose label has not yet flipped to
|
|
349
|
+
// `agent::executing` (the child is mid-`story-init`, or the host crashed
|
|
350
|
+
// after the dispatch-ledger write but before the label flip) still reads
|
|
351
|
+
// as `ready` by label alone. Re-dispatching it would put a second agent
|
|
352
|
+
// on the same `story-<id>` branch — the worst failure mode in the
|
|
353
|
+
// system. So the candidate set passed to the selector marks those
|
|
354
|
+
// Stories `executing`: they keep occupying a slot (and gate any
|
|
355
|
+
// dependent, since they are not done) but are never re-selected.
|
|
356
|
+
//
|
|
357
|
+
// The slot denominator is the size of the UNION of (a) ledger-in-flight
|
|
358
|
+
// ids and (b) Stories carrying `agent::executing` by label. A Story that
|
|
359
|
+
// flipped to `agent::executing` but whose `story.dispatch.start` never
|
|
360
|
+
// landed in the ledger (e.g. the label flip raced ahead of the ledger
|
|
361
|
+
// write) occupies a real slot the ledger count alone misses; counting
|
|
362
|
+
// only the ledger would let the global cap be exceeded. The union is the
|
|
363
|
+
// authoritative occupied-slot count.
|
|
364
|
+
const candidates = records.map((rec) =>
|
|
365
|
+
inFlightSet.has(rec.id) && classifyStory(rec) === 'ready'
|
|
366
|
+
? { ...rec, labels: [...rec.labels, AGENT_LABELS.EXECUTING] }
|
|
367
|
+
: rec,
|
|
185
368
|
);
|
|
369
|
+
const doneIds = byClass.done.map((s) => s.id);
|
|
370
|
+
const occupiedSlotIds = new Set([
|
|
371
|
+
...inFlight,
|
|
372
|
+
...byClass.executing.map((s) => s.id),
|
|
373
|
+
]);
|
|
374
|
+
const readySet = selectReadySet({
|
|
375
|
+
stories: candidates,
|
|
376
|
+
doneIds,
|
|
377
|
+
inFlight: occupiedSlotIds.size,
|
|
378
|
+
globalCap,
|
|
379
|
+
dropForeign: true,
|
|
380
|
+
});
|
|
186
381
|
|
|
187
|
-
const blockedStories = blocked.map((s) => ({
|
|
382
|
+
const blockedStories = byClass.blocked.map((s) => ({
|
|
188
383
|
storyId: s.id,
|
|
189
384
|
reason: 'agent::blocked',
|
|
190
385
|
detail: s.title,
|
|
191
386
|
}));
|
|
192
|
-
const gateFailures = readGateFailures(
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
// the
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
//
|
|
387
|
+
const gateFailures = readGateFailures(state);
|
|
388
|
+
|
|
389
|
+
// 3. Decide nextAction.
|
|
390
|
+
// - A blocked Story halts the Epic → observe (the workflow flips the
|
|
391
|
+
// Epic to agent::blocked and parks).
|
|
392
|
+
// - A dependency cycle among the in-scope Stories halts the Epic → halt
|
|
393
|
+
// (the cycle is an unsatisfiable gate; never collapse it to complete).
|
|
394
|
+
// - A non-empty ready set → dispatch it. Fire `wave-start` on the very
|
|
395
|
+
// first dispatch of the run (nothing executing / in-flight / done
|
|
396
|
+
// yet) so the perf-aggregator can bracket the run's wall-clock.
|
|
397
|
+
// - Otherwise, if any Story is still executing or in-flight → observe.
|
|
398
|
+
// - Otherwise, if EVERY in-scope Story is done → epic-complete.
|
|
399
|
+
// - Otherwise the ready set is empty, nothing is in flight, yet not all
|
|
400
|
+
// Stories are done: at least one Story is permanently gated (an
|
|
401
|
+
// unsatisfiable dependency that survived adjacency closure). Halt and
|
|
402
|
+
// name the stuck Story ids — never silently report the Epic complete.
|
|
403
|
+
const allDone = byClass.done.length === records.length;
|
|
404
|
+
const signals = [];
|
|
208
405
|
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
406
|
if (blockedStories.length) {
|
|
218
|
-
nextAction = {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
407
|
+
nextAction = {
|
|
408
|
+
kind: 'observe',
|
|
409
|
+
waitingOn: byClass.blocked.map((s) => s.id).sort((a, b) => a - b),
|
|
410
|
+
};
|
|
411
|
+
} else if (cycle) {
|
|
412
|
+
const cycleIds = cycle
|
|
413
|
+
.filter((id) => Number.isInteger(id))
|
|
414
|
+
.sort((a, b) => a - b);
|
|
415
|
+
nextAction = {
|
|
416
|
+
kind: 'halt',
|
|
417
|
+
reason: 'dependency-cycle',
|
|
418
|
+
stuckStories: cycleIds,
|
|
419
|
+
cycle,
|
|
420
|
+
};
|
|
421
|
+
} else if (readySet.length) {
|
|
225
422
|
if (
|
|
226
|
-
executing.length === 0 &&
|
|
227
|
-
done.length === 0 &&
|
|
228
|
-
|
|
423
|
+
byClass.executing.length === 0 &&
|
|
424
|
+
byClass.done.length === 0 &&
|
|
425
|
+
inFlight.length === 0
|
|
229
426
|
) {
|
|
230
|
-
|
|
427
|
+
signals.push({
|
|
231
428
|
kind: 'wave-start',
|
|
232
|
-
|
|
233
|
-
totalWaves,
|
|
234
|
-
stories: wavePlan.map((s) => ({ id: storyIdOf(s), title: s.title })),
|
|
429
|
+
stories: records.map((s) => ({ id: s.id, title: s.title })),
|
|
235
430
|
});
|
|
236
431
|
}
|
|
237
432
|
nextAction = {
|
|
238
433
|
kind: 'dispatch',
|
|
239
|
-
stories:
|
|
240
|
-
id: s
|
|
434
|
+
stories: readySet.map((s) => ({
|
|
435
|
+
id: storyIdOf(s),
|
|
241
436
|
title: s.title,
|
|
242
|
-
worktree: s.worktree,
|
|
243
437
|
})),
|
|
244
438
|
};
|
|
245
|
-
} else if (executing.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.
|
|
439
|
+
} else if (byClass.executing.length || inFlight.length) {
|
|
249
440
|
const waitingOn = [
|
|
250
|
-
...executing.map((s) => s.id),
|
|
251
|
-
...inFlightUndispatched.map((s) => s.id),
|
|
441
|
+
...new Set([...byClass.executing.map((s) => s.id), ...inFlight]),
|
|
252
442
|
].sort((a, b) => a - b);
|
|
253
443
|
nextAction = { kind: 'observe', waitingOn };
|
|
254
|
-
} else if (
|
|
444
|
+
} else if (allDone) {
|
|
445
|
+
// Every Story is done and nothing is in flight: the run is complete.
|
|
446
|
+
signals.push({ kind: 'wave-complete' });
|
|
255
447
|
nextAction = { kind: 'epic-complete' };
|
|
256
448
|
} else {
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
449
|
+
// Ready set empty, nothing in flight, but not all Stories are done — a
|
|
450
|
+
// Story is gated on an unsatisfiable dependency. Halt with the stuck ids
|
|
451
|
+
// (every not-done, not-in-flight Story) so the operator can see exactly
|
|
452
|
+
// which Story stranded the run instead of a false epic-complete.
|
|
453
|
+
const stuckStories = records
|
|
454
|
+
.filter((rec) => classifyStory(rec) !== 'done')
|
|
455
|
+
.map((rec) => rec.id)
|
|
456
|
+
.filter((id) => Number.isInteger(id))
|
|
457
|
+
.sort((a, b) => a - b);
|
|
458
|
+
nextAction = {
|
|
459
|
+
kind: 'halt',
|
|
460
|
+
reason: 'unsatisfiable-dependency',
|
|
461
|
+
stuckStories,
|
|
462
|
+
};
|
|
260
463
|
}
|
|
261
464
|
|
|
262
|
-
|
|
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
|
-
return tickResult({
|
|
465
|
+
return {
|
|
269
466
|
nextAction,
|
|
270
467
|
blockedStories,
|
|
271
468
|
gateFailures,
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
469
|
+
readyCount: readySet.length,
|
|
470
|
+
signals,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Throw `WaveRunnerError('old-shape-checkpoint')` when the checkpoint still
|
|
476
|
+
* carries any wave-batch field. The message names the offending field(s) and
|
|
477
|
+
* the operator remediation so a stuck delivery is diagnosable from the
|
|
478
|
+
* thrown error alone.
|
|
479
|
+
*
|
|
480
|
+
* @param {object} state Parsed checkpoint.
|
|
481
|
+
* @param {number} epicId
|
|
482
|
+
*/
|
|
483
|
+
function assertNotOldShape(state, epicId) {
|
|
484
|
+
const present = OLD_SHAPE_FIELDS.filter((f) => Object.hasOwn(state, f));
|
|
485
|
+
if (present.length === 0) return;
|
|
486
|
+
throw new WaveRunnerError(
|
|
487
|
+
'old-shape-checkpoint',
|
|
488
|
+
`Epic #${epicId} carries a pre-ready-set (wave-batch) epic-run-state ` +
|
|
489
|
+
`checkpoint (fields: ${present.join(', ')}). The ready-set /deliver ` +
|
|
490
|
+
`runtime cannot resume a wave-batch run. Re-run ` +
|
|
491
|
+
`\`node .agents/scripts/epic-deliver-prepare.js --epic ${epicId}\` to ` +
|
|
492
|
+
`re-seed the checkpoint in the per-Story-status shape, then re-run ` +
|
|
493
|
+
`/deliver.`,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Extract the in-scope Story ids from the shrunk checkpoint's per-Story
|
|
499
|
+
* `stories` status map (`{ [storyId]: { status, ... } }`). Returns an
|
|
500
|
+
* ascending-sorted, deduped array of positive integers; tolerates an absent
|
|
501
|
+
* / malformed map by returning `[]`.
|
|
502
|
+
*
|
|
503
|
+
* @param {object} state
|
|
504
|
+
* @returns {number[]}
|
|
505
|
+
*/
|
|
506
|
+
function checkpointStoryIds(state) {
|
|
507
|
+
const stories = state?.stories;
|
|
508
|
+
if (!stories || typeof stories !== 'object') return [];
|
|
509
|
+
const ids = new Set();
|
|
510
|
+
for (const key of Object.keys(stories)) {
|
|
511
|
+
const id = Number(key);
|
|
512
|
+
if (Number.isInteger(id) && id > 0) ids.add(id);
|
|
513
|
+
}
|
|
514
|
+
return [...ids].sort((a, b) => a - b);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Attach the ledger-derived in-flight Story-id list to a `nextAction`
|
|
519
|
+
* envelope under the `in-flight` key. Always present (empty array when the
|
|
520
|
+
* ledger is silent) so downstream consumers pattern-match on presence
|
|
521
|
+
* without an existence check.
|
|
522
|
+
*
|
|
523
|
+
* @param {object} nextAction
|
|
524
|
+
* @param {number[]} inFlight
|
|
525
|
+
* @returns {object} the same nextAction (mutated) for call-site convenience
|
|
526
|
+
*/
|
|
527
|
+
function withInFlight(nextAction, inFlight) {
|
|
528
|
+
nextAction['in-flight'] = inFlight;
|
|
529
|
+
return nextAction;
|
|
275
530
|
}
|
|
276
531
|
|
|
277
532
|
/**
|
|
@@ -415,17 +670,17 @@ async function defaultInFlightReader(epicId, config) {
|
|
|
415
670
|
for (const id of started) {
|
|
416
671
|
if (!ended.has(id)) inFlight.push(id);
|
|
417
672
|
}
|
|
418
|
-
return inFlight;
|
|
673
|
+
return inFlight.sort((a, b) => a - b);
|
|
419
674
|
}
|
|
420
675
|
|
|
421
676
|
function tickResult({
|
|
422
677
|
nextAction,
|
|
423
678
|
blockedStories = [],
|
|
424
679
|
gateFailures = [],
|
|
425
|
-
|
|
426
|
-
|
|
680
|
+
readyCount = 0,
|
|
681
|
+
inFlight = [],
|
|
427
682
|
}) {
|
|
428
|
-
return { nextAction, blockedStories, gateFailures,
|
|
683
|
+
return { nextAction, blockedStories, gateFailures, readyCount, inFlight };
|
|
429
684
|
}
|
|
430
685
|
|
|
431
686
|
function resolveEpicId(epic) {
|
|
@@ -443,66 +698,41 @@ function positiveIntOrZero(v) {
|
|
|
443
698
|
return Number.isInteger(v) && v >= 0 ? v : 0;
|
|
444
699
|
}
|
|
445
700
|
|
|
446
|
-
function storyIdOf(s) {
|
|
447
|
-
if (typeof s === 'number') return s;
|
|
448
|
-
return s.id ?? s.storyId ?? s.number;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
/**
|
|
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.
|
|
458
|
-
*
|
|
459
|
-
* @param {{ labels: string[], state?: string }} s
|
|
460
|
-
* @returns {boolean}
|
|
461
|
-
*/
|
|
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
701
|
/**
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
702
|
+
* Derive gate-failure rows from the checkpoint's per-Story `stories` status
|
|
703
|
+
* map: every Story recorded as `failed` surfaces as a gate failure so the
|
|
704
|
+
* operator workflow can act on it. The shrunk checkpoint no longer carries a
|
|
705
|
+
* per-wave history with explicit gate names, so the gate is reported as
|
|
706
|
+
* `unspecified` and the recorded `title` (when present) is the detail.
|
|
472
707
|
*
|
|
473
|
-
* @param {
|
|
474
|
-
* @returns {
|
|
708
|
+
* @param {object} state Parsed checkpoint.
|
|
709
|
+
* @returns {Array<{ storyId: number, gate: string, detail?: string }>}
|
|
475
710
|
*/
|
|
476
|
-
function
|
|
477
|
-
const
|
|
478
|
-
return
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
return
|
|
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
|
-
}));
|
|
711
|
+
function readGateFailures(state) {
|
|
712
|
+
const stories = state?.stories;
|
|
713
|
+
if (!stories || typeof stories !== 'object') return [];
|
|
714
|
+
const out = [];
|
|
715
|
+
for (const [key, rec] of Object.entries(stories)) {
|
|
716
|
+
const id = Number(key);
|
|
717
|
+
if (!Number.isInteger(id) || id <= 0) continue;
|
|
718
|
+
if (rec?.status !== 'failed') continue;
|
|
719
|
+
const row = { storyId: id, gate: 'unspecified' };
|
|
720
|
+
if (typeof rec.title === 'string' && rec.title) row.detail = rec.title;
|
|
721
|
+
out.push(row);
|
|
722
|
+
}
|
|
723
|
+
return out.sort((a, b) => a.storyId - b.storyId);
|
|
495
724
|
}
|
|
496
725
|
|
|
497
726
|
/**
|
|
498
727
|
* Default emitter — appends to per-Epic `signals.ndjson`. Best-effort;
|
|
499
728
|
* never throws. Tests override via `collaborators.signalEmit`.
|
|
500
729
|
*
|
|
501
|
-
* Story #3909 — the planner
|
|
502
|
-
* live consumer: `wave-start`
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
730
|
+
* Story #3909 / #4155 — the planner emits only the two wave-window
|
|
731
|
+
* forensics events with a live consumer: `wave-start` (fired on the run's
|
|
732
|
+
* first dispatch) and `wave-complete` (fired when the run finishes), which
|
|
733
|
+
* the perf-aggregator (`waveParallelism` report) brackets into the run's
|
|
734
|
+
* wall-clock. The write-only per-call telemetry and `epic-complete` emits
|
|
735
|
+
* were dropped — they duplicated the `epic-run-state` checkpoint and the
|
|
506
736
|
* `epic-run-progress` rollup and nothing consumed them.
|
|
507
737
|
*/
|
|
508
738
|
function defaultSignalEmit(epicId, ctx) {
|