mandrel 1.67.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-delivery.js +17 -0
- package/.agents/scripts/lib/config-settings-schema.js +12 -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-plan-spec/phases/run-spec-phase.js +32 -1
- 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/lifecycle/listeners/watcher.js +13 -8
- package/.agents/scripts/lib/orchestration/manifest-builder.js +6 -0
- package/.agents/scripts/lib/orchestration/planning-risk.js +45 -6
- 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 +26 -0
- package/package.json +1 -1
- package/.agents/scripts/lib/wave-runner/wave-checkpoint.js +0 -91
|
@@ -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
|
}
|
|
@@ -1,37 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* wave-record-projection.js — pure
|
|
2
|
+
* wave-record-projection.js — pure helpers for the per-Story status
|
|
3
|
+
* recorder CLI (`epic-execute-record-wave.js`).
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* Story #4155 (Epic #4151) — the Epic `/deliver` runtime cut over from the
|
|
6
|
+
* wave-batch scheduler to the continuous ready-set core. The post-dispatch
|
|
7
|
+
* persistence shrank with it: there is no longer a wave-level aggregation
|
|
8
|
+
* (`aggregateWaveStatus`), a `currentWave` advance (`nextCurrentWave`), a
|
|
9
|
+
* next-action classifier (`classifyWaveOutcome`), or a `projectWaveRecord`
|
|
10
|
+
* splice into a `waves[]` history. The recorder now writes each returned
|
|
11
|
+
* Story's **terminal status** into the checkpoint's flat per-Story `stories`
|
|
12
|
+
* map and re-renders the operator rollup from that map.
|
|
13
|
+
*
|
|
14
|
+
* Every export here is pure: no network I/O, no filesystem reads, no
|
|
15
|
+
* spawning. The parent CLI handles the impure work (provider calls,
|
|
16
|
+
* checkpoint reads/writes, webhook emits) and threads the resolved inputs
|
|
17
|
+
* through these helpers.
|
|
11
18
|
*
|
|
12
19
|
* Group the exports by responsibility:
|
|
13
20
|
*
|
|
14
21
|
* - Input validation: `validateResults`, `validateReturnsEntry`,
|
|
15
|
-
* `classifyParsedReturn`, `
|
|
16
|
-
*
|
|
17
|
-
* -
|
|
18
|
-
* `classifyWaveOutcome`.
|
|
19
|
-
* - Projection: `toRollupRow`, `projectWaveRecord`.
|
|
20
|
-
*
|
|
21
|
-
* The aggregator `projectWaveRecord` is the entry point: given the verified
|
|
22
|
-
* per-Story rows, the prior checkpoint, the wave index, the resolved
|
|
23
|
-
* concurrency cap, and a `titleById` map, it returns every derived shape the
|
|
24
|
-
* CLI needs to (a) write the next checkpoint, (b) render `epic-run-progress`,
|
|
25
|
-
* (c) classify the next slash-command action, and (d) assemble the stdout
|
|
26
|
-
* envelope. It is the single source of truth for "what does the wave look
|
|
27
|
-
* like after these results land?"
|
|
22
|
+
* `classifyParsedReturn`, `validateEpic`, `selectInputFlag`.
|
|
23
|
+
* - Normalization: `normalizeReturnsPure`.
|
|
24
|
+
* - Projection: `toRollupRow`.
|
|
28
25
|
*/
|
|
29
26
|
|
|
30
27
|
import { parseStoryAgentReturn } from './epic-runner/sub-agent-return.js';
|
|
31
28
|
|
|
32
|
-
/** Valid wave-level rollup statuses. */
|
|
33
|
-
export const VALID_RESULT_STATUSES = new Set(['complete', 'blocked', 'failed']);
|
|
34
|
-
|
|
35
29
|
/** Per-Story return statuses we accept off `/deliver` sub-agents. */
|
|
36
30
|
export const VALID_STORY_STATUSES = new Set(['done', 'blocked', 'failed']);
|
|
37
31
|
|
|
@@ -130,57 +124,6 @@ export function classifyParsedReturn(parsed, storyId) {
|
|
|
130
124
|
return { ok: false, error };
|
|
131
125
|
}
|
|
132
126
|
|
|
133
|
-
/**
|
|
134
|
-
* Aggregate validated per-Story rows into the wave-level outcome. Pure.
|
|
135
|
-
*
|
|
136
|
-
* @param {Array<{ storyId: number, status: string }>} results
|
|
137
|
-
*/
|
|
138
|
-
export function aggregateWaveStatus(results) {
|
|
139
|
-
const rows = Array.isArray(results) ? results : [];
|
|
140
|
-
const failed = rows.filter((r) => r.status === 'failed');
|
|
141
|
-
const blocked = rows.filter((r) => r.status === 'blocked');
|
|
142
|
-
let status;
|
|
143
|
-
if (failed.length > 0) {
|
|
144
|
-
status = 'failed';
|
|
145
|
-
} else if (blocked.length > 0) {
|
|
146
|
-
status = 'blocked';
|
|
147
|
-
} else {
|
|
148
|
-
status = 'complete';
|
|
149
|
-
}
|
|
150
|
-
return {
|
|
151
|
-
status,
|
|
152
|
-
blockedStoryIds: blocked.map((r) => r.storyId),
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Classify the wave outcome into the next operator action. Pure helper —
|
|
158
|
-
* exported so tests can pin each branch without touching the provider.
|
|
159
|
-
*
|
|
160
|
-
* @param {{ resultStatus: string, currentWave: number, totalWaves: number }} args
|
|
161
|
-
*/
|
|
162
|
-
export function classifyWaveOutcome({ resultStatus, currentWave, totalWaves }) {
|
|
163
|
-
const remainingWaves = Math.max(
|
|
164
|
-
0,
|
|
165
|
-
Number(totalWaves) - (Number(currentWave) + 1),
|
|
166
|
-
);
|
|
167
|
-
if (resultStatus === 'blocked') {
|
|
168
|
-
return { nextAction: 'halt-blocked', remainingWaves };
|
|
169
|
-
}
|
|
170
|
-
if (resultStatus === 'failed') {
|
|
171
|
-
return { nextAction: 'halt-failed', remainingWaves };
|
|
172
|
-
}
|
|
173
|
-
if (resultStatus === 'complete') {
|
|
174
|
-
return {
|
|
175
|
-
nextAction: remainingWaves > 0 ? 'dispatch-next' : 'finalize',
|
|
176
|
-
remainingWaves,
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
throw new RangeError(
|
|
180
|
-
`classifyWaveOutcome: resultStatus "${resultStatus}" must be one of: ${[...VALID_RESULT_STATUSES].join(', ')}`,
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
127
|
/**
|
|
185
128
|
* Build the rollup-row shape the unified `epic-run-progress` writer
|
|
186
129
|
* consumes. Returns `{ id, title, state, blockerCommentId? }`.
|
|
@@ -197,64 +140,13 @@ export function toRollupRow(verified, titleById) {
|
|
|
197
140
|
return row;
|
|
198
141
|
}
|
|
199
142
|
|
|
200
|
-
/**
|
|
201
|
-
export function
|
|
202
|
-
return waves.reduce(
|
|
203
|
-
(acc, w) =>
|
|
204
|
-
acc +
|
|
205
|
-
(Array.isArray(w.stories)
|
|
206
|
-
? w.stories.filter((s) => s?.state === 'done').length
|
|
207
|
-
: 0),
|
|
208
|
-
0,
|
|
209
|
-
);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/** Validate the core `{ epicId, wave }` invariants. Throws on bad input. */
|
|
213
|
-
export function validateEpicWave(epicId, wave) {
|
|
143
|
+
/** Validate the core `{ epicId }` invariant. Throws on bad input. */
|
|
144
|
+
export function validateEpic(epicId) {
|
|
214
145
|
if (!Number.isInteger(epicId) || epicId <= 0) {
|
|
215
146
|
throw new TypeError(
|
|
216
147
|
'runEpicExecuteRecordWave: --epic must be a positive integer',
|
|
217
148
|
);
|
|
218
149
|
}
|
|
219
|
-
if (!Number.isInteger(wave) || wave < 0) {
|
|
220
|
-
throw new TypeError(
|
|
221
|
-
'runEpicExecuteRecordWave: --wave must be a non-negative integer',
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/** Validate the `results`/`returns` XOR. Throws on bad input. */
|
|
227
|
-
export function validateResultsReturnsXor(results, returns) {
|
|
228
|
-
if (results == null && returns == null) {
|
|
229
|
-
throw new TypeError(
|
|
230
|
-
'runEpicExecuteRecordWave: either `results` or `returns` is required',
|
|
231
|
-
);
|
|
232
|
-
}
|
|
233
|
-
if (results != null && returns != null) {
|
|
234
|
-
throw new TypeError(
|
|
235
|
-
'runEpicExecuteRecordWave: pass `results` OR `returns`, not both',
|
|
236
|
-
);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/** Resolve the effective concurrency cap, honouring CLI > checkpoint > config. */
|
|
241
|
-
export function resolveConcurrencyCap(
|
|
242
|
-
concurrencyCapOverride,
|
|
243
|
-
existing,
|
|
244
|
-
deliverRunner,
|
|
245
|
-
) {
|
|
246
|
-
const cap =
|
|
247
|
-
concurrencyCapOverride ??
|
|
248
|
-
Number(existing.concurrencyCap) ??
|
|
249
|
-
Number(deliverRunner.concurrencyCap) ??
|
|
250
|
-
1;
|
|
251
|
-
if (!Number.isInteger(cap) || cap < 1) {
|
|
252
|
-
throw new RangeError(
|
|
253
|
-
`runEpicExecuteRecordWave: resolved concurrencyCap "${cap}" must be a positive integer; ` +
|
|
254
|
-
'pass --concurrency-cap or set `orchestration.runners.deliverRunner.concurrencyCap`.',
|
|
255
|
-
);
|
|
256
|
-
}
|
|
257
|
-
return cap;
|
|
258
150
|
}
|
|
259
151
|
|
|
260
152
|
/**
|
|
@@ -292,7 +184,7 @@ export function selectInputFlag(hasResults, hasReturns) {
|
|
|
292
184
|
* @param {(args: { storyId: number }) => Promise<object> | object} [args.reconcile]
|
|
293
185
|
* Optional async hook used to fetch a fallback row when parsing fails. If
|
|
294
186
|
* omitted, parse failures push a placeholder `{ storyId, status: 'failed' }`
|
|
295
|
-
* row so the caller can still
|
|
187
|
+
* row so the caller can still record without I/O.
|
|
296
188
|
*/
|
|
297
189
|
export async function normalizeReturnsPure({ returns, reconcile } = {}) {
|
|
298
190
|
if (!Array.isArray(returns)) {
|
|
@@ -318,100 +210,3 @@ export async function normalizeReturnsPure({ returns, reconcile } = {}) {
|
|
|
318
210
|
}
|
|
319
211
|
return { results, parseFailures };
|
|
320
212
|
}
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* Project the post-wave-record state from verified per-Story rows. Pure
|
|
324
|
-
* end-to-end: every derived field the CLI needs after `verifyWaveResults`
|
|
325
|
-
* lands here. The CLI hands us the verified rows, the existing checkpoint
|
|
326
|
-
* (so we can splice this wave's record), the wave index, the resolved cap,
|
|
327
|
-
* the manifest title map, and a `now` clock.
|
|
328
|
-
*
|
|
329
|
-
* Returns the union of:
|
|
330
|
-
* - the new `epic-run-state` shape (`nextWaves`, `nextCurrentWave`,
|
|
331
|
-
* `totalWaves`),
|
|
332
|
-
* - the rollup payload (`rollupRows`, `rollupWaves`),
|
|
333
|
-
* - the wave-level outcome (`status`, `blockedStoryIds`),
|
|
334
|
-
* - the slash-command next action (`nextAction`, `remainingWaves`).
|
|
335
|
-
*
|
|
336
|
-
* The CLI handles I/O around this projection: writing the checkpoint,
|
|
337
|
-
* upserting `epic-run-progress`, emitting webhooks, refreshing the local
|
|
338
|
-
* manifest. None of those impure side-effects live here.
|
|
339
|
-
*
|
|
340
|
-
* @param {object} args
|
|
341
|
-
* @param {number} args.wave
|
|
342
|
-
* @param {Array<object>} args.verified
|
|
343
|
-
* @param {object} args.existing
|
|
344
|
-
* @param {number} args.concurrencyCap
|
|
345
|
-
* @param {Map<number, string>} args.titleById
|
|
346
|
-
* @param {() => Date} [args.now]
|
|
347
|
-
*/
|
|
348
|
-
export function projectWaveRecord({
|
|
349
|
-
wave,
|
|
350
|
-
verified,
|
|
351
|
-
existing,
|
|
352
|
-
concurrencyCap,
|
|
353
|
-
titleById,
|
|
354
|
-
now = () => new Date(),
|
|
355
|
-
} = {}) {
|
|
356
|
-
if (!Number.isInteger(wave) || wave < 0) {
|
|
357
|
-
throw new TypeError(
|
|
358
|
-
'projectWaveRecord: wave must be a non-negative integer',
|
|
359
|
-
);
|
|
360
|
-
}
|
|
361
|
-
if (!existing || typeof existing !== 'object') {
|
|
362
|
-
throw new TypeError('projectWaveRecord: existing checkpoint is required');
|
|
363
|
-
}
|
|
364
|
-
if (!(titleById instanceof Map)) {
|
|
365
|
-
throw new TypeError('projectWaveRecord: titleById must be a Map');
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
const totalWaves = Number(existing.totalWaves ?? 0);
|
|
369
|
-
const verifiedRows = Array.isArray(verified) ? verified : [];
|
|
370
|
-
|
|
371
|
-
const { status, blockedStoryIds } = aggregateWaveStatus(verifiedRows);
|
|
372
|
-
const rollupRows = verifiedRows.map((r) => toRollupRow(r, titleById));
|
|
373
|
-
|
|
374
|
-
const priorWaves = Array.isArray(existing.waves) ? existing.waves : [];
|
|
375
|
-
const newRecord = {
|
|
376
|
-
index: wave,
|
|
377
|
-
status,
|
|
378
|
-
concurrencyCap,
|
|
379
|
-
stories: rollupRows,
|
|
380
|
-
completedAt: now().toISOString(),
|
|
381
|
-
};
|
|
382
|
-
const filtered = priorWaves.filter((w) => Number(w?.index) !== Number(wave));
|
|
383
|
-
const nextWaves = [...filtered, newRecord].sort(
|
|
384
|
-
(a, b) => Number(a.index) - Number(b.index),
|
|
385
|
-
);
|
|
386
|
-
|
|
387
|
-
const nextCurrentWave =
|
|
388
|
-
status === 'complete'
|
|
389
|
-
? Math.min(totalWaves, wave + 1)
|
|
390
|
-
: Number(existing.currentWave ?? wave);
|
|
391
|
-
|
|
392
|
-
const rollupWaves = nextWaves.map((w) => ({
|
|
393
|
-
wave: Number(w.index),
|
|
394
|
-
concurrencyCap: Number(w.concurrencyCap) || concurrencyCap,
|
|
395
|
-
stories: Array.isArray(w.stories) ? w.stories : [],
|
|
396
|
-
}));
|
|
397
|
-
|
|
398
|
-
const { nextAction, remainingWaves } = classifyWaveOutcome({
|
|
399
|
-
resultStatus: status,
|
|
400
|
-
currentWave: wave,
|
|
401
|
-
totalWaves,
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
return {
|
|
405
|
-
status,
|
|
406
|
-
blockedStoryIds,
|
|
407
|
-
rollupRows,
|
|
408
|
-
newRecord,
|
|
409
|
-
priorWaves,
|
|
410
|
-
nextWaves,
|
|
411
|
-
nextCurrentWave,
|
|
412
|
-
totalWaves,
|
|
413
|
-
rollupWaves,
|
|
414
|
-
nextAction,
|
|
415
|
-
remainingWaves,
|
|
416
|
-
};
|
|
417
|
-
}
|