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
|
@@ -22,9 +22,9 @@ import { applyBudget } from '../../planning-context-budget.js';
|
|
|
22
22
|
|
|
23
23
|
export function buildDecomposerSystemPrompt(
|
|
24
24
|
heuristics = [],
|
|
25
|
-
{ maxTickets } = {},
|
|
25
|
+
{ maxTickets, maxTokenBudget } = {},
|
|
26
26
|
) {
|
|
27
|
-
const base = renderDecomposerSystemPrompt({ maxTickets });
|
|
27
|
+
const base = renderDecomposerSystemPrompt({ maxTickets, maxTokenBudget });
|
|
28
28
|
const heuristicsStr =
|
|
29
29
|
heuristics.length > 0
|
|
30
30
|
? `### RISK HEURISTICS (planning metadata if any apply):\n- ${heuristics.join('\n- ')}`
|
|
@@ -115,9 +115,13 @@ export async function buildDecompositionContext(
|
|
|
115
115
|
const heuristics = resolveHeuristics(config);
|
|
116
116
|
const limits = getLimits(config);
|
|
117
117
|
const maxTickets = limits.maxTickets;
|
|
118
|
+
const maxTokenBudget = limits.maxTokenBudget;
|
|
118
119
|
const planningLimits = limits.planningContext;
|
|
119
120
|
const { fullContext = false } = opts;
|
|
120
|
-
const systemPrompt = buildDecomposerSystemPrompt(heuristics, {
|
|
121
|
+
const systemPrompt = buildDecomposerSystemPrompt(heuristics, {
|
|
122
|
+
maxTickets,
|
|
123
|
+
maxTokenBudget,
|
|
124
|
+
});
|
|
121
125
|
|
|
122
126
|
const budgeted = applyBudget(
|
|
123
127
|
[
|
|
@@ -137,8 +141,10 @@ export async function buildDecompositionContext(
|
|
|
137
141
|
maxTickets,
|
|
138
142
|
// Story #3875 — surface the real delivery envelope to the decomposer
|
|
139
143
|
// so Stories are sized against the hydration budget and the
|
|
140
|
-
// configured preflight ceilings rather than guessed.
|
|
141
|
-
|
|
144
|
+
// configured preflight ceilings rather than guessed. Story #4162 also
|
|
145
|
+
// threads this value into the rendered systemPrompt above as a sizing
|
|
146
|
+
// input so the prompt itself names the budget.
|
|
147
|
+
maxTokenBudget,
|
|
142
148
|
preflightCeilings: resolvePreflightCeilings(config),
|
|
143
149
|
contextMode: budgeted.mode,
|
|
144
150
|
// Story #2801 — surface the Phase 7 planning decision so the
|
|
@@ -2,17 +2,28 @@
|
|
|
2
2
|
* epic-run-state-store — stateless functions for reading and writing the
|
|
3
3
|
* `epic-run-state` structured comment used by `/deliver`.
|
|
4
4
|
*
|
|
5
|
+
* Story #4155 (Epic #4151) — the Epic `/deliver` runtime cut over from
|
|
6
|
+
* the wave-batch scheduler to the continuous ready-set core
|
|
7
|
+
* (`lib/wave-runner/ready-set.js`). The checkpoint shrank with it: it no
|
|
8
|
+
* longer carries `currentWave`, `plan[][]`, `totalWaves`, or the
|
|
9
|
+
* per-wave `waves[]` aggregation. The durable run state is now a flat
|
|
10
|
+
* **per-Story status map** (`stories: { [storyId]: { status, title?,
|
|
11
|
+
* blockerCommentId? } }`) plus the run-level `concurrencyCap` (the
|
|
12
|
+
* GLOBAL in-flight cap the ready-set selector honours), `phase`,
|
|
13
|
+
* `startedAt`, and `manualInterventions[]`. There is no resume-pointer
|
|
14
|
+
* to reconcile — the ready-set core re-derives adjacency and readiness
|
|
15
|
+
* from live Story bodies/labels on every tick, so the checkpoint only
|
|
16
|
+
* records terminal Story outcomes (for the auto-merge predicate, branch
|
|
17
|
+
* cleanup, and the operator rollup) and the run-level knobs.
|
|
18
|
+
*
|
|
5
19
|
* This module is the function-based replacement for the legacy
|
|
6
20
|
* `Checkpointer` class that previously lived at
|
|
7
|
-
* `./epic-runner/checkpointer.js`.
|
|
8
|
-
*
|
|
9
|
-
* preserved byte-for-byte. Story #2423 (Epic #2307) deleted the class
|
|
10
|
-
* file; the class API survives as a tests-only fixture at
|
|
21
|
+
* `./epic-runner/checkpointer.js`. Story #2423 (Epic #2307) deleted the
|
|
22
|
+
* class file; the class API survives as a tests-only fixture at
|
|
11
23
|
* `tests/fixtures/epic-run-state-store.js`.
|
|
12
24
|
*
|
|
13
25
|
* The comment is identified by a stable HTML marker so it can be overwritten
|
|
14
|
-
* idempotently across orchestrator restarts. The body is a fenced JSON block
|
|
15
|
-
* following the schema in tech spec #323.
|
|
26
|
+
* idempotently across orchestrator restarts. The body is a fenced JSON block.
|
|
16
27
|
*/
|
|
17
28
|
|
|
18
29
|
import { assertValidDeliverPhase } from './epic-runner/deliver-phases.js';
|
|
@@ -20,7 +31,15 @@ import { parseFencedJsonComment } from './structured-comment-parser.js';
|
|
|
20
31
|
import { findStructuredComment, upsertStructuredComment } from './ticketing.js';
|
|
21
32
|
|
|
22
33
|
export const EPIC_RUN_STATE_TYPE = 'epic-run-state';
|
|
23
|
-
export const CHECKPOINT_SCHEMA_VERSION =
|
|
34
|
+
export const CHECKPOINT_SCHEMA_VERSION = 2;
|
|
35
|
+
|
|
36
|
+
/** Terminal / in-progress per-Story statuses persisted on the checkpoint. */
|
|
37
|
+
export const STORY_STATUSES = Object.freeze([
|
|
38
|
+
'pending',
|
|
39
|
+
'done',
|
|
40
|
+
'blocked',
|
|
41
|
+
'failed',
|
|
42
|
+
]);
|
|
24
43
|
|
|
25
44
|
// Re-export the phase enum + index helper so downstream importers continue
|
|
26
45
|
// to use this module as a single import target.
|
|
@@ -40,6 +59,56 @@ function assertEpicId(epicId) {
|
|
|
40
59
|
}
|
|
41
60
|
}
|
|
42
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Normalize an inbound Story id (accepts the ticket `id` shape, the raw
|
|
64
|
+
* GitHub `number` shape, and a bare integer) to a positive integer, or
|
|
65
|
+
* `null` when it is absent / non-positive / non-integer.
|
|
66
|
+
*
|
|
67
|
+
* @param {object|number|string} entry
|
|
68
|
+
* @returns {number|null}
|
|
69
|
+
*/
|
|
70
|
+
function storyIdOf(entry) {
|
|
71
|
+
if (typeof entry === 'number') {
|
|
72
|
+
return Number.isInteger(entry) && entry > 0 ? entry : null;
|
|
73
|
+
}
|
|
74
|
+
if (!entry || typeof entry !== 'object') {
|
|
75
|
+
const n = Number(entry);
|
|
76
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
77
|
+
}
|
|
78
|
+
const raw = entry.id ?? entry.storyId ?? entry.number;
|
|
79
|
+
const id = Number(raw);
|
|
80
|
+
return Number.isInteger(id) && id > 0 ? id : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build the initial per-Story status map from a list of Story records (or
|
|
85
|
+
* ids). Every Story seeds at `status: 'pending'`; an optional `title` is
|
|
86
|
+
* carried through when the record supplies one so the operator rollup and
|
|
87
|
+
* branch-cleanup surfaces have a label without a second fetch. Keys are the
|
|
88
|
+
* positive-integer Story ids as strings (JSON object keys are strings);
|
|
89
|
+
* shapeless / non-positive entries are dropped.
|
|
90
|
+
*
|
|
91
|
+
* Pure helper — exported for unit tests.
|
|
92
|
+
*
|
|
93
|
+
* @param {Array<object|number>} stories
|
|
94
|
+
* @returns {Record<string, { status: string, title?: string }>}
|
|
95
|
+
*/
|
|
96
|
+
export function buildStoryStatusMap(stories) {
|
|
97
|
+
const out = {};
|
|
98
|
+
for (const entry of Array.isArray(stories) ? stories : []) {
|
|
99
|
+
const id = storyIdOf(entry);
|
|
100
|
+
if (id === null) continue;
|
|
101
|
+
const record = { status: 'pending' };
|
|
102
|
+
const title =
|
|
103
|
+
entry && typeof entry === 'object' && typeof entry.title === 'string'
|
|
104
|
+
? entry.title
|
|
105
|
+
: undefined;
|
|
106
|
+
if (title) record.title = title;
|
|
107
|
+
out[String(id)] = record;
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
43
112
|
/**
|
|
44
113
|
* Read and parse the checkpoint. Returns null if the comment is missing or
|
|
45
114
|
* unparseable (callers treat null as "start fresh").
|
|
@@ -60,7 +129,7 @@ export async function read({ provider, epicId } = {}) {
|
|
|
60
129
|
|
|
61
130
|
/**
|
|
62
131
|
* Overwrite the checkpoint with `state`. Idempotent — callers may invoke
|
|
63
|
-
* freely per
|
|
132
|
+
* freely per tick; the marker-scoped upsert deletes the prior comment.
|
|
64
133
|
*
|
|
65
134
|
* @param {{ provider: import('../ITicketingProvider.js').ITicketingProvider, epicId: number, state: object }} opts
|
|
66
135
|
*/
|
|
@@ -78,38 +147,48 @@ export async function write({ provider, epicId, state } = {}) {
|
|
|
78
147
|
}
|
|
79
148
|
|
|
80
149
|
/**
|
|
81
|
-
* Initial checkpoint for a brand-new run. Idempotent against re-dispatch
|
|
82
|
-
* when
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* `
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
150
|
+
* Initial checkpoint for a brand-new run. Idempotent against re-dispatch:
|
|
151
|
+
* when an existing checkpoint is found and the persisted `concurrencyCap`
|
|
152
|
+
* matches the incoming value, the existing state is returned verbatim (no
|
|
153
|
+
* rewrite) so a re-prepare preserves `startedAt`, prior Story statuses, and
|
|
154
|
+
* `manualInterventions`. When the cap differs (an operator re-tuned the
|
|
155
|
+
* global in-flight cap) it is refreshed in place; the Story status map is
|
|
156
|
+
* **merged** so any Story that already reached a terminal status keeps it
|
|
157
|
+
* while newly-discovered Stories are seeded at `pending`. Prepare owns the
|
|
158
|
+
* Story set (it overwrites it on every run) but never clobbers recorded
|
|
159
|
+
* progress.
|
|
90
160
|
*
|
|
91
|
-
* @param {{
|
|
161
|
+
* @param {{
|
|
162
|
+
* provider: import('../ITicketingProvider.js').ITicketingProvider,
|
|
163
|
+
* epicId: number,
|
|
164
|
+
* storyIds: Array<object|number>,
|
|
165
|
+
* concurrencyCap: number,
|
|
166
|
+
* }} opts
|
|
167
|
+
* `concurrencyCap` is the GLOBAL in-flight cap the ready-set selector
|
|
168
|
+
* honours (`selectReadySet({ globalCap })`).
|
|
92
169
|
*/
|
|
93
170
|
export async function initialize({
|
|
94
171
|
provider,
|
|
95
172
|
epicId,
|
|
96
|
-
|
|
173
|
+
storyIds,
|
|
97
174
|
concurrencyCap,
|
|
98
175
|
} = {}) {
|
|
99
176
|
assertProvider(provider);
|
|
100
177
|
assertEpicId(epicId);
|
|
178
|
+
const seededStories = buildStoryStatusMap(storyIds);
|
|
101
179
|
const existing = await read({ provider, epicId });
|
|
102
180
|
if (existing) {
|
|
181
|
+
const mergedStories = mergeStoryStatuses(existing.stories, seededStories);
|
|
103
182
|
if (
|
|
104
|
-
existing.
|
|
105
|
-
existing.
|
|
183
|
+
existing.concurrencyCap === concurrencyCap &&
|
|
184
|
+
storyMapsEqual(existing.stories, mergedStories)
|
|
106
185
|
) {
|
|
107
186
|
return existing;
|
|
108
187
|
}
|
|
109
188
|
return write({
|
|
110
189
|
provider,
|
|
111
190
|
epicId,
|
|
112
|
-
state: { ...existing,
|
|
191
|
+
state: { ...existing, concurrencyCap, stories: mergedStories },
|
|
113
192
|
});
|
|
114
193
|
}
|
|
115
194
|
return write({
|
|
@@ -118,119 +197,133 @@ export async function initialize({
|
|
|
118
197
|
state: {
|
|
119
198
|
epicId,
|
|
120
199
|
startedAt: new Date().toISOString(),
|
|
121
|
-
currentWave: 0,
|
|
122
|
-
totalWaves,
|
|
123
200
|
concurrencyCap,
|
|
124
201
|
phase: 'prepare',
|
|
125
|
-
|
|
126
|
-
blockerHistory: [],
|
|
202
|
+
stories: seededStories,
|
|
127
203
|
manualInterventions: [],
|
|
128
204
|
},
|
|
129
205
|
});
|
|
130
206
|
}
|
|
131
207
|
|
|
132
208
|
/**
|
|
133
|
-
*
|
|
134
|
-
* a
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
* closed/merged Stories). The recomputed plan is therefore *shorter* and
|
|
140
|
-
* **re-indexed from 0** — `plan[0]` is the next ready wave. The preserved
|
|
141
|
-
* checkpoint, however, still carries the prior `currentWave` (e.g. `2`)
|
|
142
|
-
* and a `waves[]` history keyed to the *old* index space. `wave-tick.js`
|
|
143
|
-
* then indexes `plan[currentWave]` into the new plan and dispatches the
|
|
144
|
-
* wrong wave — silently skipping the Stories that are actually ready.
|
|
145
|
-
*
|
|
146
|
-
* Prepare already owns the `plan` field (it overwrites it on every run),
|
|
147
|
-
* so it must equally own the pointer that indexes into that plan. This
|
|
148
|
-
* helper is the single point of reconciliation:
|
|
149
|
-
*
|
|
150
|
-
* - When the recomputed `nextPlan` is **structurally identical** to the
|
|
151
|
-
* persisted `priorPlan` (an idempotent re-prepare with no Story
|
|
152
|
-
* completed since the last run), the pointer is preserved verbatim so
|
|
153
|
-
* in-flight wave progress is not lost.
|
|
154
|
-
* - When the recomputed `nextPlan` **differs** (a Story merged → the
|
|
155
|
-
* plan got shorter / re-indexed), the pointer is reset: `currentWave`
|
|
156
|
-
* to `0` (the new plan's index space starts at the first not-done
|
|
157
|
-
* wave) and `waves[]` to `[]` (the prior history references the old
|
|
158
|
-
* index space and would mis-key `readGateFailures`).
|
|
159
|
-
*
|
|
160
|
-
* Plan equality is compared on the Story-id matrix only — `title` /
|
|
161
|
-
* `worktree` churn on an otherwise-identical plan must not trip a reset.
|
|
209
|
+
* Merge a freshly-seeded Story status map onto a persisted one. Every Story
|
|
210
|
+
* present in either map appears in the result; when a Story exists in the
|
|
211
|
+
* prior map its recorded status / blockerCommentId win (recorded progress is
|
|
212
|
+
* never lost), while its `title` is refreshed from the incoming seed when the
|
|
213
|
+
* seed supplies one. Stories present only in the incoming seed are added at
|
|
214
|
+
* their seeded (`pending`) status. Pure — exported for unit tests.
|
|
162
215
|
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
* @
|
|
166
|
-
* currentWave?: number,
|
|
167
|
-
* waves?: Array<unknown>,
|
|
168
|
-
* }} checkpoint The persisted checkpoint fields to reconcile.
|
|
169
|
-
* @param {Array<Array<{ id?: number, storyId?: number, number?: number }>>} priorPlan
|
|
170
|
-
* The plan currently persisted on the checkpoint (may be undefined on a
|
|
171
|
-
* first run).
|
|
172
|
-
* @param {Array<Array<{ id?: number, storyId?: number, number?: number }>>} nextPlan
|
|
173
|
-
* The freshly-recomputed plan prepare is about to persist.
|
|
174
|
-
* @returns {{ currentWave: number, waves: Array<unknown> }} The reconciled
|
|
175
|
-
* pointer fields. Always returns concrete values so the caller can spread
|
|
176
|
-
* them onto the checkpoint payload unconditionally.
|
|
216
|
+
* @param {Record<string, object>|undefined} prior
|
|
217
|
+
* @param {Record<string, object>} incoming
|
|
218
|
+
* @returns {Record<string, object>}
|
|
177
219
|
*/
|
|
178
|
-
export function
|
|
179
|
-
const
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
220
|
+
export function mergeStoryStatuses(prior, incoming) {
|
|
221
|
+
const priorMap = prior && typeof prior === 'object' ? prior : {};
|
|
222
|
+
const seedMap = incoming && typeof incoming === 'object' ? incoming : {};
|
|
223
|
+
const out = {};
|
|
224
|
+
for (const key of new Set([
|
|
225
|
+
...Object.keys(priorMap),
|
|
226
|
+
...Object.keys(seedMap),
|
|
227
|
+
])) {
|
|
228
|
+
const priorEntry = priorMap[key];
|
|
229
|
+
const seedEntry = seedMap[key];
|
|
230
|
+
if (priorEntry && typeof priorEntry === 'object') {
|
|
231
|
+
const merged = { ...priorEntry };
|
|
232
|
+
if (seedEntry && typeof seedEntry.title === 'string') {
|
|
233
|
+
merged.title = seedEntry.title;
|
|
234
|
+
}
|
|
235
|
+
out[key] = merged;
|
|
236
|
+
} else {
|
|
237
|
+
out[key] = seedEntry;
|
|
238
|
+
}
|
|
185
239
|
}
|
|
186
|
-
|
|
187
|
-
// 0-indexed over the remaining not-done waves. Reset the pointer and
|
|
188
|
-
// drop the stale history so `wave-tick.js` reads `plan[0]`.
|
|
189
|
-
return { currentWave: 0, waves: [] };
|
|
240
|
+
return out;
|
|
190
241
|
}
|
|
191
242
|
|
|
192
243
|
/**
|
|
193
|
-
*
|
|
194
|
-
* `
|
|
195
|
-
*
|
|
196
|
-
* positions. `title` / `worktree` fields are ignored so cosmetic churn on
|
|
197
|
-
* an otherwise-identical plan does not register as a change.
|
|
244
|
+
* Structural equality on two Story status maps — same key set and, per key,
|
|
245
|
+
* the same `status`, `title`, and `blockerCommentId`. Used by `initialize`
|
|
246
|
+
* to decide whether an idempotent re-prepare needs a rewrite. Pure.
|
|
198
247
|
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
* @param {Array<Array<object>>|undefined} a
|
|
202
|
-
* @param {Array<Array<object>>|undefined} b
|
|
248
|
+
* @param {Record<string, object>|undefined} a
|
|
249
|
+
* @param {Record<string, object>|undefined} b
|
|
203
250
|
* @returns {boolean}
|
|
204
251
|
*/
|
|
205
|
-
function
|
|
206
|
-
const left =
|
|
207
|
-
const right =
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
252
|
+
function storyMapsEqual(a, b) {
|
|
253
|
+
const left = a && typeof a === 'object' ? a : {};
|
|
254
|
+
const right = b && typeof b === 'object' ? b : {};
|
|
255
|
+
const keys = Object.keys(left);
|
|
256
|
+
if (keys.length !== Object.keys(right).length) return false;
|
|
257
|
+
for (const key of keys) {
|
|
258
|
+
const l = left[key] ?? {};
|
|
259
|
+
const r = right[key];
|
|
260
|
+
if (!r) return false;
|
|
261
|
+
if (
|
|
262
|
+
l.status !== r.status ||
|
|
263
|
+
l.title !== r.title ||
|
|
264
|
+
l.blockerCommentId !== r.blockerCommentId
|
|
265
|
+
) {
|
|
266
|
+
return false;
|
|
215
267
|
}
|
|
216
268
|
}
|
|
217
269
|
return true;
|
|
218
270
|
}
|
|
219
271
|
|
|
220
272
|
/**
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
273
|
+
* Record a per-Story terminal (or in-progress) status on the checkpoint.
|
|
274
|
+
* Reads the current state first, splices the single Story's record into the
|
|
275
|
+
* `stories` map, and re-writes. Other Stories and all run-level fields are
|
|
276
|
+
* preserved verbatim. Tolerant of a legacy/absent `stories` map (treated as
|
|
277
|
+
* empty) so a checkpoint that predates a field is upgraded in place.
|
|
225
278
|
*
|
|
226
|
-
* @param {
|
|
227
|
-
*
|
|
279
|
+
* @param {{
|
|
280
|
+
* provider: import('../ITicketingProvider.js').ITicketingProvider,
|
|
281
|
+
* epicId: number,
|
|
282
|
+
* storyId: number,
|
|
283
|
+
* status: string,
|
|
284
|
+
* title?: string,
|
|
285
|
+
* blockerCommentId?: string|number|null,
|
|
286
|
+
* }} opts
|
|
287
|
+
* @returns {Promise<object>} the persisted state
|
|
228
288
|
*/
|
|
229
|
-
function
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
289
|
+
export async function recordStoryStatus({
|
|
290
|
+
provider,
|
|
291
|
+
epicId,
|
|
292
|
+
storyId,
|
|
293
|
+
status,
|
|
294
|
+
title,
|
|
295
|
+
blockerCommentId,
|
|
296
|
+
} = {}) {
|
|
297
|
+
assertProvider(provider);
|
|
298
|
+
assertEpicId(epicId);
|
|
299
|
+
const id = storyIdOf(storyId);
|
|
300
|
+
if (id === null) {
|
|
301
|
+
throw new TypeError(
|
|
302
|
+
'recordStoryStatus: storyId must be a positive integer',
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
if (!STORY_STATUSES.includes(status)) {
|
|
306
|
+
throw new RangeError(
|
|
307
|
+
`recordStoryStatus: status "${status}" must be one of: ${STORY_STATUSES.join(', ')}`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
const existing = (await read({ provider, epicId })) ?? {};
|
|
311
|
+
const stories =
|
|
312
|
+
existing.stories && typeof existing.stories === 'object'
|
|
313
|
+
? { ...existing.stories }
|
|
314
|
+
: {};
|
|
315
|
+
const prior = stories[String(id)] ?? {};
|
|
316
|
+
const record = { ...prior, status };
|
|
317
|
+
if (typeof title === 'string' && title) record.title = title;
|
|
318
|
+
if (status === 'blocked' && blockerCommentId != null) {
|
|
319
|
+
record.blockerCommentId = String(blockerCommentId);
|
|
320
|
+
}
|
|
321
|
+
stories[String(id)] = record;
|
|
322
|
+
return write({
|
|
323
|
+
provider,
|
|
324
|
+
epicId,
|
|
325
|
+
state: { ...existing, stories },
|
|
326
|
+
});
|
|
234
327
|
}
|
|
235
328
|
|
|
236
329
|
/**
|
|
@@ -240,42 +240,33 @@ export async function renderProgressBody({
|
|
|
240
240
|
/**
|
|
241
241
|
* Render and upsert the rolled-up `epic-run-progress` comment on the Epic.
|
|
242
242
|
*
|
|
243
|
-
* Called by `/deliver`
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
243
|
+
* Called by `/deliver`'s per-Story status recorder
|
|
244
|
+
* (`epic-execute-record-wave.js`) after each recorder beat. Story #4155
|
|
245
|
+
* (Epic #4151) — the Epic `/deliver` runtime cut over from the wave-batch
|
|
246
|
+
* scheduler to the continuous ready-set core, so the rollup is a **flat
|
|
247
|
+
* per-Story table** keyed by the checkpoint's `stories` status map, not a
|
|
248
|
+
* wave-grouped table. There is no `currentWave` / `totalWaves` / `waves[]`
|
|
249
|
+
* in the payload any more.
|
|
250
250
|
*
|
|
251
|
-
* The payload schema
|
|
252
|
-
* #902:
|
|
251
|
+
* The payload schema:
|
|
253
252
|
*
|
|
254
253
|
* {
|
|
255
254
|
* "kind": "epic-run-progress",
|
|
256
255
|
* "epicId": <number>,
|
|
257
|
-
* "
|
|
258
|
-
* "totalWaves": <number>,
|
|
259
|
-
* "waves": [ { wave, concurrencyCap?, stories[] } ],
|
|
256
|
+
* "stories": [ { id, title?, state, blockerCommentId? } ],
|
|
260
257
|
* "startedAt"?: "<iso8601>",
|
|
261
258
|
* "updatedAt": "<iso8601>"
|
|
262
259
|
* }
|
|
263
260
|
*
|
|
264
261
|
* The function does not re-derive Story state from labels — it trusts the
|
|
265
|
-
* `
|
|
266
|
-
*
|
|
262
|
+
* `stories` map supplied by the caller (the checkpoint's recorded per-Story
|
|
263
|
+
* statuses).
|
|
267
264
|
*
|
|
268
265
|
* @param {{
|
|
269
266
|
* provider: import('../../../ITicketingProvider.js').ITicketingProvider,
|
|
270
267
|
* epicId: number,
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
* concurrencyCap?: number,
|
|
274
|
-
* stories?: Array<{ id: number, title?: string, state?: string,
|
|
275
|
-
* blockerCommentId?: string }>,
|
|
276
|
-
* }>,
|
|
277
|
-
* currentWave: number,
|
|
278
|
-
* totalWaves: number,
|
|
268
|
+
* stories: Record<string, { status?: string, title?: string,
|
|
269
|
+
* blockerCommentId?: string }>,
|
|
279
270
|
* startedAt?: string,
|
|
280
271
|
* now?: () => Date,
|
|
281
272
|
* }} args
|
|
@@ -285,9 +276,7 @@ export async function renderProgressBody({
|
|
|
285
276
|
export async function upsertEpicRunProgress({
|
|
286
277
|
provider,
|
|
287
278
|
epicId,
|
|
288
|
-
|
|
289
|
-
currentWave,
|
|
290
|
-
totalWaves,
|
|
279
|
+
stories,
|
|
291
280
|
startedAt,
|
|
292
281
|
now = () => new Date(),
|
|
293
282
|
} = {}) {
|
|
@@ -300,73 +289,44 @@ export async function upsertEpicRunProgress({
|
|
|
300
289
|
if (!Number.isInteger(epicIdNum) || epicIdNum <= 0) {
|
|
301
290
|
throw new TypeError('upsertEpicRunProgress requires a numeric epicId');
|
|
302
291
|
}
|
|
303
|
-
const
|
|
304
|
-
if (!Number.isInteger(totalWavesNum) || totalWavesNum < 0) {
|
|
305
|
-
throw new TypeError(
|
|
306
|
-
'upsertEpicRunProgress requires a non-negative integer totalWaves',
|
|
307
|
-
);
|
|
308
|
-
}
|
|
309
|
-
const currentWaveNum = Number(currentWave);
|
|
310
|
-
if (!Number.isInteger(currentWaveNum) || currentWaveNum < 0) {
|
|
311
|
-
throw new TypeError(
|
|
312
|
-
'upsertEpicRunProgress requires a non-negative integer currentWave',
|
|
313
|
-
);
|
|
314
|
-
}
|
|
315
|
-
const wavesArr = Array.isArray(waves) ? waves : [];
|
|
292
|
+
const statusMap = stories && typeof stories === 'object' ? stories : {};
|
|
316
293
|
|
|
317
294
|
const updatedAt = now().toISOString();
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
295
|
+
const rows = Object.entries(statusMap)
|
|
296
|
+
.map(([key, rec]) => {
|
|
297
|
+
const id = Number(key);
|
|
298
|
+
const state = String(rec?.status ?? 'pending');
|
|
299
|
+
const row = { id, title: String(rec?.title ?? ''), state };
|
|
300
|
+
if (rec?.blockerCommentId != null) {
|
|
301
|
+
row.blockerCommentId = String(rec.blockerCommentId);
|
|
302
|
+
}
|
|
303
|
+
return row;
|
|
304
|
+
})
|
|
305
|
+
.filter((r) => Number.isInteger(r.id) && r.id > 0)
|
|
306
|
+
.sort((a, b) => a.id - b.id);
|
|
329
307
|
|
|
330
308
|
const payload = {
|
|
331
309
|
kind: EPIC_RUN_PROGRESS_TYPE,
|
|
332
310
|
epicId: epicIdNum,
|
|
333
|
-
|
|
334
|
-
totalWaves: totalWavesNum,
|
|
335
|
-
waves: normalizedWaves,
|
|
311
|
+
stories: rows,
|
|
336
312
|
updatedAt,
|
|
337
313
|
};
|
|
338
314
|
if (typeof startedAt === 'string' && startedAt) {
|
|
339
315
|
payload.startedAt = startedAt;
|
|
340
316
|
}
|
|
341
317
|
|
|
342
|
-
const totalStories =
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
);
|
|
346
|
-
const doneStories = normalizedWaves.reduce(
|
|
347
|
-
(acc, w) => acc + w.stories.filter((s) => s?.state === 'done').length,
|
|
348
|
-
0,
|
|
349
|
-
);
|
|
350
|
-
const header = `### 📊 Epic Progress — Wave ${Math.min(currentWaveNum + 1, Math.max(totalWavesNum, 1))}/${totalWavesNum || '?'} · ${doneStories}/${totalStories} stories done`;
|
|
318
|
+
const totalStories = rows.length;
|
|
319
|
+
const doneStories = rows.filter((s) => s.state === 'done').length;
|
|
320
|
+
const header = `### 📊 Epic Progress — ${doneStories}/${totalStories} stories done`;
|
|
351
321
|
|
|
352
|
-
const tableLines = ['|
|
|
353
|
-
if (
|
|
354
|
-
tableLines.push('| — |
|
|
322
|
+
const tableLines = ['| ID | State | Title |', '|---|---|---|'];
|
|
323
|
+
if (rows.length === 0) {
|
|
324
|
+
tableLines.push('| — | _(no stories yet)_ | — |');
|
|
355
325
|
} else {
|
|
356
|
-
for (const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
}
|
|
361
|
-
for (const s of w.stories) {
|
|
362
|
-
const state = String(s?.state ?? 'unknown');
|
|
363
|
-
const emoji = STATE_EMOJI[state] ?? '';
|
|
364
|
-
const id = Number(s?.id ?? 0);
|
|
365
|
-
const title = escapePipes(truncate(String(s?.title ?? ''), 60));
|
|
366
|
-
tableLines.push(
|
|
367
|
-
`| ${w.wave + 1} | #${id} | ${emoji} ${state} | ${title} |`,
|
|
368
|
-
);
|
|
369
|
-
}
|
|
326
|
+
for (const s of rows) {
|
|
327
|
+
const emoji = STATE_EMOJI[s.state] ?? '';
|
|
328
|
+
const title = escapePipes(truncate(s.title, 60));
|
|
329
|
+
tableLines.push(`| #${s.id} | ${emoji} ${s.state} | ${title} |`);
|
|
370
330
|
}
|
|
371
331
|
}
|
|
372
332
|
|