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.
- package/.agents/docs/agentrc-reference.json +1 -2
- package/.agents/docs/configuration.md +2 -4
- package/.agents/schemas/agentrc.schema.json +1 -5
- package/.agents/schemas/lifecycle/epic.automerge.end.schema.json +2 -1
- 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/lib/baselines/refresh-service.js +13 -1
- package/.agents/scripts/lib/config/explain.js +0 -2
- package/.agents/scripts/lib/config/limits.js +19 -8
- package/.agents/scripts/lib/config-settings-schema.js +1 -2
- package/.agents/scripts/lib/maintainability-utils.js +32 -9
- 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 -78
- 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/manifest-builder.js +6 -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 +36 -4
- package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -3
- package/.agents/scripts/lib/wave-runner/ready-set.js +295 -0
- package/.agents/scripts/lib/wave-runner/tick.js +312 -206
- package/.agents/scripts/lib/wave-runner/wave-runner-error.js +2 -1
- package/.agents/scripts/lint-label-vocabulary.js +1 -1
- 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/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/docs/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/.agents/scripts/lib/wave-runner/wave-checkpoint.js +0 -91
|
@@ -38,10 +38,10 @@ export const EPIC_PROGRESS_EVENT = 'epic-progress';
|
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Fire a curated `epic-progress` webhook event. Event-driven only — called
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
41
|
+
* per recorder beat and after blocker raise/clear transitions. Carries the
|
|
42
|
+
* rollup payload `{ pct, done, total, phase, openBlockers }`, which Slack
|
|
43
|
+
* consumers and downstream subscribers use to track epic progress without
|
|
44
|
+
* subscribing to per-story chatter.
|
|
45
45
|
*
|
|
46
46
|
* The dispatch passes `skipComment: true` — the operator-facing GitHub
|
|
47
47
|
* comment is owned by `ProgressReporter.fire()` and `upsertEpicRunProgress`,
|
|
@@ -50,13 +50,17 @@ export const EPIC_PROGRESS_EVENT = 'epic-progress';
|
|
|
50
50
|
* Failures are swallowed by design: the runner must keep moving even if
|
|
51
51
|
* the webhook URL is misconfigured or the network is flaky.
|
|
52
52
|
*
|
|
53
|
+
* Story #4155 — the Epic `/deliver` runtime cut over to the continuous
|
|
54
|
+
* ready-set scheduler, which has **no wave index**. The wave segment was
|
|
55
|
+
* dropped from the message and the rollup payload entirely (rather than
|
|
56
|
+
* rendered as `Wave undefined/undefined`); the sole live caller
|
|
57
|
+
* (`wave-record-notifications.js`) never supplied wave coordinates.
|
|
58
|
+
*
|
|
53
59
|
* @param {{
|
|
54
60
|
* notify: Function|null,
|
|
55
61
|
* epicId: number,
|
|
56
62
|
* done: number,
|
|
57
63
|
* total: number,
|
|
58
|
-
* currentWave: number,
|
|
59
|
-
* totalWaves: number,
|
|
60
64
|
* phase?: string,
|
|
61
65
|
* openBlockers?: Array<{ reason: string, storyId?: number }>,
|
|
62
66
|
* logger?: { warn?: Function },
|
|
@@ -68,8 +72,6 @@ export async function emitEpicProgress({
|
|
|
68
72
|
epicId,
|
|
69
73
|
done,
|
|
70
74
|
total,
|
|
71
|
-
currentWave,
|
|
72
|
-
totalWaves,
|
|
73
75
|
phase,
|
|
74
76
|
openBlockers = [],
|
|
75
77
|
logger,
|
|
@@ -85,7 +87,7 @@ export async function emitEpicProgress({
|
|
|
85
87
|
blockerCount > 0
|
|
86
88
|
? ` · 🚧 ${blockerCount} blocker${blockerCount === 1 ? '' : 's'}`
|
|
87
89
|
: '';
|
|
88
|
-
const message = `Epic #${epicIdNum} progress ·
|
|
90
|
+
const message = `Epic #${epicIdNum} progress · ${doneN}/${totalN} stories done (${pct}%)${blockerSuffix}`;
|
|
89
91
|
|
|
90
92
|
const payload = {
|
|
91
93
|
severity: blockerCount > 0 ? 'high' : 'medium',
|
|
@@ -109,8 +111,6 @@ export async function emitEpicProgress({
|
|
|
109
111
|
pct,
|
|
110
112
|
done: doneN,
|
|
111
113
|
total: totalN,
|
|
112
|
-
currentWave,
|
|
113
|
-
totalWaves,
|
|
114
114
|
phase,
|
|
115
115
|
openBlockers: openBlockers ?? [],
|
|
116
116
|
},
|
|
@@ -121,11 +121,14 @@ export async function emitEpicProgress({
|
|
|
121
121
|
* Fire a curated `epic-started` webhook event at /deliver kickoff.
|
|
122
122
|
* The Slack consumer anchors the rest of the epic narrative to this fire.
|
|
123
123
|
* Failures are swallowed.
|
|
124
|
+
*
|
|
125
|
+
* Story #4155 — the ready-set runtime has no wave count, so the wave
|
|
126
|
+
* segment was dropped from the message (rather than rendered as
|
|
127
|
+
* `undefined wave(s)`); the sole live caller never supplied one.
|
|
124
128
|
*/
|
|
125
129
|
export async function emitEpicStarted({
|
|
126
130
|
notify,
|
|
127
131
|
epicId,
|
|
128
|
-
totalWaves,
|
|
129
132
|
totalStories,
|
|
130
133
|
title,
|
|
131
134
|
logger,
|
|
@@ -133,7 +136,7 @@ export async function emitEpicStarted({
|
|
|
133
136
|
if (typeof notify !== 'function') return null;
|
|
134
137
|
const epicIdNum = Number(epicId);
|
|
135
138
|
if (!Number.isInteger(epicIdNum) || epicIdNum <= 0) return null;
|
|
136
|
-
const message = `Epic #${epicIdNum} started · ${
|
|
139
|
+
const message = `Epic #${epicIdNum} started · ${totalStories} stor${totalStories === 1 ? 'y' : 'ies'}${title ? ` — ${title}` : ''}`;
|
|
137
140
|
try {
|
|
138
141
|
await notify(
|
|
139
142
|
epicIdNum,
|
|
@@ -241,26 +241,29 @@ export async function reconcileStoryFromGitHub({ provider, storyId } = {}) {
|
|
|
241
241
|
|
|
242
242
|
/**
|
|
243
243
|
* Render a single friction-comment body listing every malformed sub-agent
|
|
244
|
-
* return for a
|
|
245
|
-
* can pin the body shape.
|
|
244
|
+
* return for a recorder beat. Pure helper — no provider call. Exposed so
|
|
245
|
+
* tests can pin the body shape.
|
|
246
|
+
*
|
|
247
|
+
* Story #4155 — under the ready-set runtime there is no wave index; the
|
|
248
|
+
* recorder records the Stories it was handed, so the body is keyed by Epic
|
|
249
|
+
* only.
|
|
246
250
|
*
|
|
247
251
|
* @param {{
|
|
248
252
|
* epicId: number,
|
|
249
|
-
* wave: number,
|
|
250
253
|
* failures: Array<{ storyId: number, error: string, returnText: string }>,
|
|
251
254
|
* }} args
|
|
252
255
|
* @returns {string}
|
|
253
256
|
*/
|
|
254
|
-
export function renderMalformedReturnsFriction({ epicId,
|
|
257
|
+
export function renderMalformedReturnsFriction({ epicId, failures }) {
|
|
255
258
|
const lines = [
|
|
256
|
-
`### 🚧 epic-execute friction — Epic #${epicId}
|
|
259
|
+
`### 🚧 epic-execute friction — Epic #${epicId}`,
|
|
257
260
|
'',
|
|
258
261
|
`**Reason:** \`malformed-subagent-return\``,
|
|
259
262
|
'',
|
|
260
263
|
`${failures.length} sub-agent return(s) did not match the /deliver return contract.`,
|
|
261
264
|
'Each Story below was reconciled from GitHub (labels + `story-run-progress`)',
|
|
262
|
-
'and its
|
|
263
|
-
'`agent::done`.',
|
|
265
|
+
'and its recorded status downgraded to `failed` unless the live ticket',
|
|
266
|
+
'already carried `agent::done`.',
|
|
264
267
|
'',
|
|
265
268
|
];
|
|
266
269
|
for (const f of failures) {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* is a hard block evaluated BEFORE the structured-signal evaluator.
|
|
22
22
|
*
|
|
23
23
|
* Either trigger evaluates the same verdict: if `evaluateAutoMergePredicate`
|
|
24
|
-
* reports `clean: true` (no manual interventions,
|
|
24
|
+
* reports `clean: true` (no manual interventions, every Story done,
|
|
25
25
|
* no story blockers, no critical/high review findings, machine-readable
|
|
26
26
|
* "clean sprint" retro trailer), emit `epic.merge.ready`. Otherwise emit
|
|
27
27
|
* `epic.merge.blocked` with a non-empty reason.
|
|
@@ -172,38 +172,51 @@ function evaluateStateSignals(state, reasons) {
|
|
|
172
172
|
.join('; ')}${interventionCount > 3 ? '; …' : ''}`,
|
|
173
173
|
);
|
|
174
174
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
175
|
+
// Story #4155 — the ready-set runtime records a flat per-Story status map
|
|
176
|
+
// on the checkpoint (`stories: { [id]: { status, blockerCommentId? } }`)
|
|
177
|
+
// instead of a per-wave `waves[]` history. The clean-run certification
|
|
178
|
+
// reads it directly: a run is clean only when every Story reached `done`
|
|
179
|
+
// and none carries a recorded blocker comment.
|
|
180
|
+
const stories =
|
|
181
|
+
state?.stories && typeof state.stories === 'object' ? state.stories : {};
|
|
182
|
+
const storyStatuses = Object.values(stories).map(
|
|
183
|
+
(s) => s?.status ?? 'pending',
|
|
184
|
+
);
|
|
185
|
+
const nonDoneStatuses = storyStatuses.filter((s) => s !== 'done');
|
|
186
|
+
if (nonDoneStatuses.length > 0) {
|
|
179
187
|
reasons.push(
|
|
180
|
-
`${
|
|
188
|
+
`${nonDoneStatuses.length} story(ies) not done (statuses: ${nonDoneStatuses.join(', ')})`,
|
|
181
189
|
);
|
|
182
190
|
}
|
|
183
|
-
const storyBlockers = countStoryBlockers(
|
|
191
|
+
const storyBlockers = countStoryBlockers(stories);
|
|
184
192
|
if (storyBlockers > 0) {
|
|
185
193
|
reasons.push(
|
|
186
194
|
`${storyBlockers} story-level blocker(s) recorded in run-state`,
|
|
187
195
|
);
|
|
188
196
|
}
|
|
189
|
-
return { interventionCount,
|
|
197
|
+
return { interventionCount, storyStatuses, storyBlockers };
|
|
190
198
|
}
|
|
191
199
|
|
|
192
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Count blockers in the flat per-Story `stories` status map: each Story with
|
|
202
|
+
* a recorded `blockerCommentId` and each Story whose status is not `done`
|
|
203
|
+
* contributes one blocker (matching the prior per-wave count semantics).
|
|
204
|
+
*
|
|
205
|
+
* @param {Record<string, { status?: string, blockerCommentId?: string }>} stories
|
|
206
|
+
* @returns {number}
|
|
207
|
+
*/
|
|
208
|
+
function countStoryBlockers(stories) {
|
|
193
209
|
let blockers = 0;
|
|
194
|
-
for (const
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
if (s?.status && s.status !== 'done') {
|
|
205
|
-
blockers += 1;
|
|
206
|
-
}
|
|
210
|
+
for (const s of Object.values(stories ?? {})) {
|
|
211
|
+
if (
|
|
212
|
+
s &&
|
|
213
|
+
typeof s.blockerCommentId === 'string' &&
|
|
214
|
+
s.blockerCommentId.length > 0
|
|
215
|
+
) {
|
|
216
|
+
blockers += 1;
|
|
217
|
+
}
|
|
218
|
+
if (s?.status && s.status !== 'done') {
|
|
219
|
+
blockers += 1;
|
|
207
220
|
}
|
|
208
221
|
}
|
|
209
222
|
return blockers;
|
|
@@ -271,7 +284,7 @@ function evaluateRetroSignals(retro, reasons) {
|
|
|
271
284
|
* reasons: string[],
|
|
272
285
|
* signals: {
|
|
273
286
|
* manualInterventions: number,
|
|
274
|
-
*
|
|
287
|
+
* storyStatuses: string[],
|
|
275
288
|
* storyBlockers: number,
|
|
276
289
|
* severity: { critical: number|null, high: number|null, medium: number|null, suggestion: number|null },
|
|
277
290
|
* retroCompact: boolean,
|
|
@@ -292,7 +305,7 @@ export function deriveAutoMergeVerdict({ state, codeReview, retro }) {
|
|
|
292
305
|
reasons,
|
|
293
306
|
signals: {
|
|
294
307
|
manualInterventions: stateSig.interventionCount,
|
|
295
|
-
|
|
308
|
+
storyStatuses: stateSig.storyStatuses,
|
|
296
309
|
storyBlockers: stateSig.storyBlockers,
|
|
297
310
|
severity: reviewSig.severity,
|
|
298
311
|
retroCompact: retroSig.retroCompact,
|
|
@@ -139,6 +139,12 @@ function buildStoryOnlyManifest(stories, epicId) {
|
|
|
139
139
|
type: 'story',
|
|
140
140
|
branchName: getStoryBranch(epicId, story.id),
|
|
141
141
|
earliestWave,
|
|
142
|
+
// Carry the resolved cross-Story dependency edges on the entry so the
|
|
143
|
+
// presentation layer can derive grouping depth at render time via
|
|
144
|
+
// `assignLayers` (Story #4157) instead of trusting the persisted
|
|
145
|
+
// `earliestWave`. This is the same `explicitStoryDeps` set the wave
|
|
146
|
+
// computation consumed, already closed over the scheduled Story set.
|
|
147
|
+
dependsOn: explicitStoryDeps.get(story.id) ?? [],
|
|
142
148
|
tasks: [],
|
|
143
149
|
};
|
|
144
150
|
});
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*
|
|
19
19
|
* Sizing model (Story #3760 — profile-matrix collapse; Story #3874 — one
|
|
20
20
|
* uniform relaxed profile):
|
|
21
|
-
* - Flat knobs: `softFiles` (~
|
|
21
|
+
* - Flat knobs: `softFiles` (~15), `hardFiles` (~30), `maxAcceptance` (~14),
|
|
22
22
|
* `softAcceptanceCount` (~10). No per-profile ceiling map, no parallel
|
|
23
23
|
* `testSurface` axis, no selector and no second profile.
|
|
24
24
|
* - The four-profile `sizingProfile` enum is replaced by a single optional
|
|
@@ -32,7 +32,11 @@
|
|
|
32
32
|
|
|
33
33
|
export const DEFAULT_TASK_SIZING = Object.freeze({
|
|
34
34
|
// Typical-Story warning thresholds (soft — emit advisory findings).
|
|
35
|
-
softFiles:
|
|
35
|
+
// Story #4162 raised `softFiles` 8 → 15: a capability-sized Story routinely
|
|
36
|
+
// touches a dozen-plus files for one cohesive reason, so the advisory width
|
|
37
|
+
// nudge fired far too eagerly and biased the decomposer toward over-slicing.
|
|
38
|
+
// The hard `hardFiles` rejection (30) is unchanged.
|
|
39
|
+
softFiles: 15,
|
|
36
40
|
softAcceptanceCount: 10,
|
|
37
41
|
// Hard ceilings (rejection unless lifted).
|
|
38
42
|
hardFiles: 30,
|
|
@@ -1,25 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* wave-record-io.js — impure helpers for the
|
|
3
|
-
* verification, manifest title
|
|
2
|
+
* wave-record-io.js — impure helpers for the per-Story status recorder CLI
|
|
3
|
+
* (`epic-execute-record-wave.js`): ticket verification, manifest title
|
|
4
|
+
* lookup, and returns reconciliation.
|
|
4
5
|
*
|
|
5
6
|
* These functions all hit the provider and are intentionally kept out of
|
|
6
|
-
* `wave-record-projection.js`, which is the pure
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* `wave-record-projection.js`, which is the pure helper layer. The parent
|
|
8
|
+
* CLI imports both modules and threads the I/O results through the
|
|
9
|
+
* projection.
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
* (
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Story #4155 (Epic #4151) — the wave-batch livelock-recovery path
|
|
12
|
+
* (`planStoryIdsForWave` + the empty-mode-B reconcile) was deleted along
|
|
13
|
+
* with the wave checkpoint it keyed off. Under the ready-set runtime a
|
|
14
|
+
* Story's terminal state is always re-derivable from its live label on the
|
|
15
|
+
* next `tick`, so there is no falsely-`complete` empty wave to recover from
|
|
16
|
+
* — the recorder records exactly the Stories it was handed.
|
|
14
17
|
*
|
|
15
|
-
* Story #3909 — the per-wave dispatch-manifest refresh hop
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* only to re-render the `dispatch-manifest` comment, which nothing reads
|
|
19
|
-
* for control flow — `loadManifestTitleMap` reads it for rollup-row titles,
|
|
20
|
-
* and those are fixed at plan time. The manifest is now written once at
|
|
21
|
-
* `epic-deliver-prepare` time and left frozen; the surviving operator-facing
|
|
22
|
-
* surface is the `epic-run-progress` rollup the record-wave CLI re-renders.
|
|
18
|
+
* Story #3909 — the per-wave dispatch-manifest refresh hop was deleted; the
|
|
19
|
+
* manifest is written once at `epic-deliver-prepare` time and left frozen.
|
|
20
|
+
* `loadManifestTitleMap` reads it for rollup-row titles.
|
|
23
21
|
*/
|
|
24
22
|
|
|
25
23
|
import { Logger } from '../Logger.js';
|
|
@@ -112,14 +110,14 @@ async function verifySingleResult(r, provider) {
|
|
|
112
110
|
* stale cache cannot mask the discrepancy. A network failure during
|
|
113
111
|
* verification cannot prove the claim either way, so the row is
|
|
114
112
|
* downgraded to `failed` and a `verify-error` discrepancy is recorded —
|
|
115
|
-
* an unverifiable `done` must not
|
|
116
|
-
*
|
|
113
|
+
* an unverifiable `done` must not be recorded as `done`, which is what the
|
|
114
|
+
* auto-merge predicate reads as "GitHub agrees everything is done."
|
|
117
115
|
*
|
|
118
116
|
* Story #3024 — verification runs through {@link concurrentMap} under a
|
|
119
117
|
* bounded cap (default 4, override via
|
|
120
118
|
* `delivery.deliverRunner.verifyConcurrencyCap`). Per-row failures are
|
|
121
119
|
* captured inside the mapper so one Story's `getTicket` throw cannot
|
|
122
|
-
* abort the whole
|
|
120
|
+
* abort the whole batch — the per-row try/catch lives in
|
|
123
121
|
* {@link verifySingleResult} and turns into a `verify-error`
|
|
124
122
|
* discrepancy rather than a rejected mapper. Input order is preserved
|
|
125
123
|
* (mapper-index → output-index), matching the previous serial behaviour.
|
|
@@ -183,84 +181,27 @@ export async function loadManifestTitleMap({ provider, epicId }) {
|
|
|
183
181
|
}
|
|
184
182
|
}
|
|
185
183
|
|
|
186
|
-
/**
|
|
187
|
-
* Extract the Story IDs planned for `wave` from the checkpoint `plan`
|
|
188
|
-
* (`Story[][]` indexed by wave). Returns `[]` when the plan is missing or
|
|
189
|
-
* the wave index is out of range. Pure helper — exported for unit tests.
|
|
190
|
-
*
|
|
191
|
-
* Story #3907 — the wave-complete livelock recovery (below) keys off this:
|
|
192
|
-
* when mode B records a wave with **no** child returns (the host crashed
|
|
193
|
-
* after the children finished but before `record-wave` ran), every Story in
|
|
194
|
-
* `plan[wave]` is reconciled from GitHub so the wave can record and
|
|
195
|
-
* `currentWave` can advance instead of returning `wave-complete` for the same
|
|
196
|
-
* index forever.
|
|
197
|
-
*
|
|
198
|
-
* @param {object} existing Checkpoint state.
|
|
199
|
-
* @param {number} wave
|
|
200
|
-
* @returns {number[]}
|
|
201
|
-
*/
|
|
202
|
-
export function planStoryIdsForWave(existing, wave) {
|
|
203
|
-
const plan = Array.isArray(existing?.plan) ? existing.plan : [];
|
|
204
|
-
const entries = Array.isArray(plan[wave]) ? plan[wave] : [];
|
|
205
|
-
const ids = [];
|
|
206
|
-
for (const entry of entries) {
|
|
207
|
-
const id =
|
|
208
|
-
typeof entry === 'number'
|
|
209
|
-
? entry
|
|
210
|
-
: Number(entry?.id ?? entry?.storyId ?? entry?.number);
|
|
211
|
-
if (Number.isInteger(id) && id > 0) ids.push(id);
|
|
212
|
-
}
|
|
213
|
-
return ids;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
184
|
/**
|
|
217
185
|
* Parse / reconcile the per-Story returns (or pass `results` through). Posts
|
|
218
186
|
* a single rolled-up friction comment listing every malformed return on
|
|
219
187
|
* failure — non-fatal if the post itself fails.
|
|
220
188
|
*
|
|
221
|
-
* Story #3907 — mode B with an **empty** `returns` array is the
|
|
222
|
-
* wave-complete-livelock recovery path: the host crashed after the wave's
|
|
223
|
-
* children finished but before `record-wave` ran, so no return text survives.
|
|
224
|
-
* Rather than recording an empty (falsely-`complete`) wave, every Story in
|
|
225
|
-
* `plan[wave]` is reconciled from GitHub via {@link reconcileStoryFromGitHub}
|
|
226
|
-
* so the recorded wave reflects the live ticket state. This requires the
|
|
227
|
-
* caller to thread the checkpoint `existing` so the wave's planned Story set
|
|
228
|
-
* is known; without it the empty array degrades to the previous behaviour.
|
|
229
|
-
*
|
|
230
189
|
* @returns {Promise<{ resolvedResults: Array, parseFailures: Array }>}
|
|
231
190
|
*/
|
|
232
191
|
export async function resolveResolvedResults({
|
|
233
192
|
provider,
|
|
234
193
|
epicId,
|
|
235
|
-
wave,
|
|
236
194
|
results,
|
|
237
195
|
returns,
|
|
238
|
-
existing,
|
|
239
196
|
}) {
|
|
240
197
|
if (returns == null) {
|
|
241
198
|
return { resolvedResults: results, parseFailures: [] };
|
|
242
199
|
}
|
|
243
|
-
if (Array.isArray(returns) && returns.length === 0 && existing) {
|
|
244
|
-
const ids = planStoryIdsForWave(existing, wave);
|
|
245
|
-
if (ids.length > 0) {
|
|
246
|
-
const resolvedResults = await concurrentMap(
|
|
247
|
-
ids,
|
|
248
|
-
(storyId) => reconcileStoryFromGitHub({ provider, storyId }),
|
|
249
|
-
{ concurrency: DEFAULT_VERIFY_CONCURRENCY_CAP },
|
|
250
|
-
);
|
|
251
|
-
Logger.warn(
|
|
252
|
-
`[wave-record-io] Wave ${wave} recorded with no child returns; ` +
|
|
253
|
-
`reconciled ${ids.length} Story(ies) from GitHub (livelock recovery).`,
|
|
254
|
-
);
|
|
255
|
-
return { resolvedResults, parseFailures: [] };
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
200
|
const normalized = await normalizeReturns({ provider, returns });
|
|
259
201
|
if (normalized.parseFailures.length > 0) {
|
|
260
202
|
try {
|
|
261
203
|
const body = renderMalformedReturnsFriction({
|
|
262
204
|
epicId,
|
|
263
|
-
wave,
|
|
264
205
|
failures: normalized.parseFailures,
|
|
265
206
|
});
|
|
266
207
|
await postStructuredComment(provider, epicId, 'friction', body);
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* wave-record-notifications.js — webhook-emit helpers
|
|
3
|
-
* `epic-execute-record-wave.js
|
|
2
|
+
* wave-record-notifications.js — webhook-emit helpers for the per-Story
|
|
3
|
+
* status recorder CLI (`epic-execute-record-wave.js`).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
5
|
+
* Story #4155 (Epic #4151) — the Epic `/deliver` runtime cut over from the
|
|
6
|
+
* wave-batch scheduler to the continuous ready-set core, so these emits are
|
|
7
|
+
* no longer keyed to a wave boundary. The recorder fires curated webhook
|
|
8
|
+
* events per recorder beat: `epic-started` once (on the first recorded
|
|
9
|
+
* Story), `epic-progress` with the run's done/total counts (re-derived from
|
|
10
|
+
* the checkpoint's flat per-Story `stories` map), and `epic-blocked` when a
|
|
11
|
+
* Story in this beat blocked or failed. Each helper is fire-and-forget —
|
|
12
|
+
* webhook misconfig or a transient Slack outage must not block the loop.
|
|
12
13
|
*
|
|
13
14
|
* These helpers stay in their own module to keep the parent CLI a thin
|
|
14
15
|
* runner shell. They are not part of the pure projection layer; they
|
|
@@ -20,9 +21,7 @@ import {
|
|
|
20
21
|
emitEpicBlocked,
|
|
21
22
|
emitEpicProgress,
|
|
22
23
|
emitEpicStarted,
|
|
23
|
-
emitEpicUnblocked,
|
|
24
24
|
} from './epic-runner/progress-reporter/transport.js';
|
|
25
|
-
import { countDoneStories } from './wave-record-projection.js';
|
|
26
25
|
|
|
27
26
|
/**
|
|
28
27
|
* Build the notify-bound closure used by the curated webhook emitters. When
|
|
@@ -41,25 +40,53 @@ export function buildNotifyFn(injectedNotify, config, provider, defaultNotify) {
|
|
|
41
40
|
}
|
|
42
41
|
|
|
43
42
|
/**
|
|
44
|
-
*
|
|
43
|
+
* Count Stories in a terminal `done` state across the checkpoint's flat
|
|
44
|
+
* per-Story `stories` status map. Pure helper.
|
|
45
|
+
*
|
|
46
|
+
* @param {Record<string, { status?: string }>|undefined} stories
|
|
47
|
+
* @returns {number}
|
|
48
|
+
*/
|
|
49
|
+
export function countDoneStories(stories) {
|
|
50
|
+
const map = stories && typeof stories === 'object' ? stories : {};
|
|
51
|
+
let done = 0;
|
|
52
|
+
for (const rec of Object.values(map)) {
|
|
53
|
+
if (rec?.status === 'done') done += 1;
|
|
54
|
+
}
|
|
55
|
+
return done;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Fire the curated webhook events for a recorder beat. Each emit is
|
|
45
60
|
* fire-and-forget (the emit helpers swallow webhook misconfiguration), but
|
|
46
|
-
* we still serialise them so the order
|
|
47
|
-
*
|
|
48
|
-
*
|
|
61
|
+
* we still serialise them so the order is deterministic.
|
|
62
|
+
*
|
|
63
|
+
* - `epic-started` fires exactly once: on the very first recorded Story
|
|
64
|
+
* (signalled by `firstRecord === true`), before any Story has been
|
|
65
|
+
* recorded on a prior beat.
|
|
66
|
+
* - `epic-progress` always fires with the run's done/total counts.
|
|
67
|
+
* - `epic-blocked` fires when this beat recorded at least one blocked or
|
|
68
|
+
* failed Story.
|
|
69
|
+
*
|
|
70
|
+
* @param {{
|
|
71
|
+
* injectedNotify?: Function,
|
|
72
|
+
* defaultNotify: Function,
|
|
73
|
+
* config: object,
|
|
74
|
+
* provider: object,
|
|
75
|
+
* epicId: number,
|
|
76
|
+
* firstRecord: boolean,
|
|
77
|
+
* stories: Record<string, { status?: string }>,
|
|
78
|
+
* verified: Array<{ storyId: number, status: string }>,
|
|
79
|
+
* blockedStoryIds: number[],
|
|
80
|
+
* }} args
|
|
49
81
|
*/
|
|
50
|
-
export async function
|
|
82
|
+
export async function emitRecordNotifications({
|
|
51
83
|
injectedNotify,
|
|
52
84
|
defaultNotify,
|
|
53
85
|
config,
|
|
54
86
|
provider,
|
|
55
87
|
epicId,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
priorWaves,
|
|
59
|
-
nextWaves,
|
|
60
|
-
titleById,
|
|
61
|
-
totalWaves,
|
|
62
|
-
nextCurrentWave,
|
|
88
|
+
firstRecord,
|
|
89
|
+
stories,
|
|
63
90
|
verified,
|
|
64
91
|
blockedStoryIds,
|
|
65
92
|
}) {
|
|
@@ -69,121 +96,50 @@ export async function emitWaveBoundaryNotifications({
|
|
|
69
96
|
provider,
|
|
70
97
|
defaultNotify,
|
|
71
98
|
);
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
)
|
|
77
|
-
if (priorWaves.length === 0 && wave === 0) {
|
|
99
|
+
const map = stories && typeof stories === 'object' ? stories : {};
|
|
100
|
+
const totalStories = Object.keys(map).length;
|
|
101
|
+
const doneStories = countDoneStories(map);
|
|
102
|
+
|
|
103
|
+
if (firstRecord) {
|
|
78
104
|
await emitEpicStarted({
|
|
79
105
|
notify: notifyFn,
|
|
80
106
|
epicId,
|
|
81
|
-
|
|
82
|
-
totalStories: totalStoriesEstimate,
|
|
107
|
+
totalStories,
|
|
83
108
|
logger: Logger,
|
|
84
109
|
});
|
|
85
110
|
}
|
|
86
|
-
if (status === 'complete') {
|
|
87
|
-
await emitCompleteWaveNotifications({
|
|
88
|
-
notifyFn,
|
|
89
|
-
epicId,
|
|
90
|
-
priorWaveRecord,
|
|
91
|
-
doneStoriesSoFar,
|
|
92
|
-
totalStoriesEstimate,
|
|
93
|
-
nextCurrentWave,
|
|
94
|
-
totalWaves,
|
|
95
|
-
});
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
await emitFailingWaveNotifications({
|
|
99
|
-
notifyFn,
|
|
100
|
-
epicId,
|
|
101
|
-
status,
|
|
102
|
-
blockedStoryIds,
|
|
103
|
-
verified,
|
|
104
|
-
doneStoriesSoFar,
|
|
105
|
-
totalStoriesEstimate,
|
|
106
|
-
nextCurrentWave,
|
|
107
|
-
totalWaves,
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
111
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}) {
|
|
121
|
-
const resumedFromHalt =
|
|
122
|
-
priorWaveRecord &&
|
|
123
|
-
(priorWaveRecord.status === 'blocked' ||
|
|
124
|
-
priorWaveRecord.status === 'failed');
|
|
125
|
-
if (resumedFromHalt) {
|
|
126
|
-
await emitEpicUnblocked({
|
|
112
|
+
const blockedIds = Array.isArray(blockedStoryIds) ? blockedStoryIds : [];
|
|
113
|
+
const failedStoryId = (verified ?? []).find(
|
|
114
|
+
(r) => r.status === 'failed',
|
|
115
|
+
)?.storyId;
|
|
116
|
+
const failingStoryId = blockedIds[0] ?? failedStoryId;
|
|
117
|
+
const hasFailure = blockedIds.length > 0 || failedStoryId != null;
|
|
118
|
+
|
|
119
|
+
if (hasFailure) {
|
|
120
|
+
await emitEpicBlocked({
|
|
127
121
|
notify: notifyFn,
|
|
128
122
|
epicId,
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
priorWaveRecord.status === 'blocked'
|
|
132
|
-
? 'story_blocked'
|
|
133
|
-
: 'story_failed',
|
|
134
|
-
},
|
|
123
|
+
reason: blockedIds.length > 0 ? 'story_blocked' : 'story_failed',
|
|
124
|
+
storyId: failingStoryId,
|
|
135
125
|
logger: Logger,
|
|
136
126
|
});
|
|
137
127
|
}
|
|
138
|
-
await emitEpicProgress({
|
|
139
|
-
notify: notifyFn,
|
|
140
|
-
epicId,
|
|
141
|
-
done: doneStoriesSoFar,
|
|
142
|
-
total: totalStoriesEstimate,
|
|
143
|
-
currentWave: nextCurrentWave,
|
|
144
|
-
totalWaves,
|
|
145
|
-
phase: 'iterate-waves',
|
|
146
|
-
openBlockers: [],
|
|
147
|
-
logger: Logger,
|
|
148
|
-
});
|
|
149
|
-
// The `epic-complete` webhook used to fire here, at the post-final-wave
|
|
150
|
-
// / pre-finalize boundary. That preceded `gh pr create` by minutes — the
|
|
151
|
-
// operator got an "Epic complete" ping with no PR to click. The fire
|
|
152
|
-
// moved to `epic-deliver-finalize.js`, which emits it after the PR URL
|
|
153
|
-
// is captured. See that script for the new emit point.
|
|
154
|
-
}
|
|
155
128
|
|
|
156
|
-
/** Emit blocked + progress (with open-blocker context) for a non-complete wave. */
|
|
157
|
-
async function emitFailingWaveNotifications({
|
|
158
|
-
notifyFn,
|
|
159
|
-
epicId,
|
|
160
|
-
status,
|
|
161
|
-
blockedStoryIds,
|
|
162
|
-
verified,
|
|
163
|
-
doneStoriesSoFar,
|
|
164
|
-
totalStoriesEstimate,
|
|
165
|
-
nextCurrentWave,
|
|
166
|
-
totalWaves,
|
|
167
|
-
}) {
|
|
168
|
-
const reason = status === 'blocked' ? 'story_blocked' : 'story_failed';
|
|
169
|
-
const failingStoryId =
|
|
170
|
-
blockedStoryIds[0] ?? verified.find((r) => r.status === 'failed')?.storyId;
|
|
171
|
-
await emitEpicBlocked({
|
|
172
|
-
notify: notifyFn,
|
|
173
|
-
epicId,
|
|
174
|
-
reason,
|
|
175
|
-
storyId: failingStoryId,
|
|
176
|
-
logger: Logger,
|
|
177
|
-
});
|
|
178
129
|
await emitEpicProgress({
|
|
179
130
|
notify: notifyFn,
|
|
180
131
|
epicId,
|
|
181
|
-
done:
|
|
182
|
-
total:
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
132
|
+
done: doneStories,
|
|
133
|
+
total: totalStories,
|
|
134
|
+
phase: 'wave-loop',
|
|
135
|
+
openBlockers: hasFailure
|
|
136
|
+
? [
|
|
137
|
+
{
|
|
138
|
+
reason: blockedIds.length > 0 ? 'story_blocked' : 'story_failed',
|
|
139
|
+
storyId: failingStoryId,
|
|
140
|
+
},
|
|
141
|
+
]
|
|
142
|
+
: [],
|
|
187
143
|
logger: Logger,
|
|
188
144
|
});
|
|
189
145
|
}
|