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