mandrel 2.46.0 → 2.47.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.
@@ -88,7 +88,8 @@
88
88
  "refCleanup",
89
89
  "baseFastForward",
90
90
  "tempPurge",
91
- "leaseRelease"
91
+ "leaseRelease",
92
+ "epicRollup"
92
93
  ],
93
94
  "properties": {
94
95
  "followUps": { "type": "boolean" },
@@ -103,6 +104,10 @@
103
104
  "type": "boolean",
104
105
  "description": "Story #4860 — the operator's assignee-lease on the Story was released now that the merge is confirmed. The close deliberately no longer releases it at PR creation, so the ticket stays assigned for the whole time its PR is open; every non-merged ending (merge.unlanded block, exhausted wait budget, --no-wait-merge, --no-auto-merge) retains the claim and never reaches this step. A no-op release — the operator is no longer the recorded owner, as on a re-run or a belated manual confirm — reports true: an already-unassigned ticket is the desired end state. Only a throw reports false, and like every tail step that degrades the report, never the land."
105
106
  },
107
+ "epicRollup": {
108
+ "type": "boolean",
109
+ "description": "Story #5205 — the container Epic listing this Story was rolled up from its children: Status column derived from their composition, the operator recorded as its owner while any child is in flight, and the Epic closed once every child landed. A Story under no container reports true — there was nothing to roll up, which IS the correct outcome. Only a real failure — an unreadable child, a refused board or issue mutation — reports false, and like every tail step that degrades the report, never the land."
110
+ },
106
111
  "details": {
107
112
  "type": "object",
108
113
  "description": "Per-step diagnostic detail — the reason a false step reported false.",
@@ -108,8 +108,9 @@ export class ColumnSync {
108
108
  }
109
109
 
110
110
  /**
111
- * Sync a single issue to its target column. Returns a result descriptor
112
- * (`synced | skipped | failed`) so callers can log without parsing errors.
111
+ * Sync a single issue to the column its `agent::*` labels imply. Returns a
112
+ * result descriptor (`synced | skipped | failed`) so callers can log
113
+ * without parsing errors.
113
114
  *
114
115
  * @param {number} issueId
115
116
  * @param {string[]} labels
@@ -117,6 +118,29 @@ export class ColumnSync {
117
118
  async sync(issueId, labels) {
118
119
  const column = columnForLabels(labels);
119
120
  if (!column) return { status: 'skipped', reason: 'no-matching-label' };
121
+ return this.setColumn(issueId, column);
122
+ }
123
+
124
+ /**
125
+ * Push one issue to a column named **directly**, skipping the label
126
+ * derivation {@link sync} performs.
127
+ *
128
+ * Split out for the one caller whose target column cannot come from labels:
129
+ * a container Epic carries no `agent::*` label by construction, so
130
+ * {@link columnForLabels} returns `null` for it and `sync` can never move
131
+ * it. The Epic's column is derived from its children instead
132
+ * (`epic-rollup.js`) and handed here — which keeps that derivation from
133
+ * having to fabricate a label on the container just to reach the board, the
134
+ * one thing the container invariant forbids.
135
+ *
136
+ * Every skip path, the metadata cache and the stale-cache self-heal are
137
+ * shared with `sync` because they live here rather than in it.
138
+ *
139
+ * @param {number} issueId
140
+ * @param {string} column Board column name (`Todo` | `In Progress` | `Done`).
141
+ */
142
+ async setColumn(issueId, column) {
143
+ if (!column) return { status: 'skipped', reason: 'no-column' };
120
144
  if (!this.projectNumber) {
121
145
  return { status: 'skipped', reason: 'no-project' };
122
146
  }
@@ -18,6 +18,14 @@
18
18
  * `resolve-stories` (which expands one) — import from here so the written
19
19
  * shape and the read shape cannot drift apart.
20
20
  *
21
+ * **The container's lifecycle is derived, never labelled.** It still carries
22
+ * no `agent::*` label — that absence is what keeps it out of the bare
23
+ * `/mandrel-deliver` ready list and outside `lint-issue-body.js`. What it does
24
+ * carry is a Projects v2 Status column, an owner while its children run, and
25
+ * eventually a closed state, all computed from the children by
26
+ * `epic-rollup.js` and written directly (Story #5205). Deriving rather than
27
+ * labelling is the whole reason those two facts can coexist.
28
+ *
21
29
  * @module lib/orchestration/epic-container
22
30
  * @see Story #5139
23
31
  */
@@ -0,0 +1,401 @@
1
+ /**
2
+ * epic-rollup.js — derive a container Epic's board state from its children.
3
+ *
4
+ * A container Epic is never delivered, so nothing in the delivery engine
5
+ * ever writes to it. That left it inert on the board for the whole run it
6
+ * was the subject of: `columnForLabels` reads `agent::*` labels and the
7
+ * container carries none by construction, so the Status sync always skipped
8
+ * it; and the only closure lived in the multi-Story run epilogue, which a
9
+ * single-Story delivery never reaches.
10
+ *
11
+ * This module is the one place that answers "what state is this Epic in?"
12
+ * — by asking its children — and the one place that writes the answer.
13
+ * {@link rollUpEpicForStory} is invoked from the two per-Story lifecycle
14
+ * edges (the `agent::executing` flip in `single-story-init.js` and the
15
+ * post-land tail), which is what makes the rollup hold at N=1, and the run
16
+ * epilogue delegates its Epic close here so one closure rule exists.
17
+ *
18
+ * Three invariants shape the writes:
19
+ *
20
+ * 1. **The Epic never gains an `agent::*` label.** That absence keeps the
21
+ * container out of the bare `/mandrel-deliver` ready list and outside
22
+ * `lint-issue-body.js`, so the derived column goes to the board
23
+ * directly via `ColumnSync.setColumn` rather than through a label.
24
+ * 2. **Status recomputes in both directions; closure is one-way.** A
25
+ * reopened child pulls a closed Epic's Status back to `In Progress`
26
+ * and MUST NOT reopen the issue — an operator who closed a container
27
+ * deliberately is not overruled by a reopened child.
28
+ * 3. **Never throws.** Every step degrades with a reason. A stale
29
+ * container costs tidiness; a delivery failed on a board mutation
30
+ * costs a landed Story its terminal envelope.
31
+ *
32
+ * @module lib/orchestration/epic-rollup
33
+ * @see Story #5205
34
+ */
35
+
36
+ import { Logger } from '../Logger.js';
37
+ import { AGENT_LABELS, TYPE_LABELS } from '../label-constants.js';
38
+ import { ColumnSync, LABEL_TO_COLUMN } from './column-sync.js';
39
+ import { isEpicTicket, readEpicChildIdsFrom } from './epic-container.js';
40
+ import { resolveOperatorFromCandidates } from './lease-guard-shared.js';
41
+ import { deriveParentState } from './ticketing/bulk.js';
42
+
43
+ /**
44
+ * Derived states that mean "children are moving" — the window in which the
45
+ * Epic carries an owner. `agent::blocked` counts: a blocked child is still
46
+ * this operator's problem, and dropping the assignee at the moment someone
47
+ * needs to be found would invert the signal.
48
+ */
49
+ const IN_FLIGHT_STATES = new Set([
50
+ AGENT_LABELS.EXECUTING,
51
+ AGENT_LABELS.BLOCKED,
52
+ ]);
53
+
54
+ /**
55
+ * Read an Epic's native sub-issue children as issue numbers.
56
+ *
57
+ * A local adapter rather than an import from `resolve-stories.js`: that
58
+ * module is a CLI entrypoint, and reaching up into one from the lib layer to
59
+ * borrow four lines would invert the dependency direction for no gain. What
60
+ * matters is that the *reader* handed to `readEpicChildIdsFrom` behaves the
61
+ * same on both paths, which is what keeps an Epic from being expandable but
62
+ * unclosable.
63
+ *
64
+ * @param {object} provider
65
+ * @returns {(epic: object) => Promise<number[]>}
66
+ */
67
+ function nativeChildReader(provider) {
68
+ return async (epic) => {
69
+ if (typeof provider?._getNativeSubIssues !== 'function') return [];
70
+ return provider._getNativeSubIssues(epic?.nodeId, epic?.number ?? epic?.id);
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Resolve the handle the Epic is assigned to while its children run.
76
+ *
77
+ * Deliberately the **non-throwing** resolution (`missingHandleBehavior:
78
+ * 'null'`), unlike the Story lease's: a container with no owner recorded is
79
+ * a cosmetic gap, and refusing the whole rollup over it would cost the
80
+ * Status write and the closure too.
81
+ *
82
+ * @param {object} config Resolved `.agentrc.json` config.
83
+ * @returns {string|null} Bare login, or null when none is configured.
84
+ */
85
+ function resolveEpicOwner(config) {
86
+ return resolveOperatorFromCandidates({
87
+ candidates: [config?.github?.operatorHandle],
88
+ missingHandleBehavior: 'null',
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Normalize an issue's assignee list to bare logins.
94
+ *
95
+ * @param {unknown} raw
96
+ * @returns {string[]}
97
+ */
98
+ function normalizeAssignees(raw) {
99
+ if (!Array.isArray(raw)) return [];
100
+ return raw
101
+ .map((a) => (typeof a === 'string' ? a : a?.login))
102
+ .filter((login) => typeof login === 'string' && login.length > 0);
103
+ }
104
+
105
+ /**
106
+ * Is this issue already closed?
107
+ *
108
+ * @param {{ state?: string }} issue
109
+ * @returns {boolean}
110
+ */
111
+ function isClosed(issue) {
112
+ return String(issue?.state ?? '').toLowerCase() === 'closed';
113
+ }
114
+
115
+ /**
116
+ * Read every child of one Epic, freshly.
117
+ *
118
+ * Returns `null` when any child is unreadable. That is the conservative
119
+ * answer, not a lazy one: `deriveParentState` reads "all children done" off
120
+ * the list it is handed, so a silently dropped child could close a container
121
+ * with work still open under it.
122
+ *
123
+ * @param {{ epicId: number, childIds: number[], provider: object }} opts
124
+ * @returns {Promise<object[]|null>}
125
+ */
126
+ async function readChildren({ epicId, childIds, provider }) {
127
+ const children = [];
128
+ for (const childId of childIds) {
129
+ let child;
130
+ try {
131
+ child = await provider.getTicket(childId);
132
+ } catch (err) {
133
+ Logger.warn(
134
+ `[epic-rollup] Epic #${epicId}: could not read child #${childId} ` +
135
+ `(${err?.message ?? err}) — leaving the Epic untouched.`,
136
+ );
137
+ return null;
138
+ }
139
+ if (!child) {
140
+ Logger.warn(
141
+ `[epic-rollup] Epic #${epicId}: child #${childId} was not found — ` +
142
+ 'leaving the Epic untouched.',
143
+ );
144
+ return null;
145
+ }
146
+ children.push(child);
147
+ }
148
+ return children;
149
+ }
150
+
151
+ /**
152
+ * Push the derived column onto the Epic's board item.
153
+ *
154
+ * @param {{ epicId: number, column: string|null, columnSync: object }} opts
155
+ * @returns {Promise<{ column: string|null, detail: string|null }>}
156
+ */
157
+ async function applyColumn({ epicId, column, columnSync }) {
158
+ if (!column || !columnSync) return { column: null, detail: null };
159
+ try {
160
+ const result = await columnSync.setColumn(epicId, column);
161
+ if (result?.status === 'synced') return { column, detail: null };
162
+ return { column: null, detail: result?.reason ?? 'column-not-synced' };
163
+ } catch (err) {
164
+ return { column: null, detail: String(err?.message ?? err) };
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Record the operator as the Epic's owner, additively.
170
+ *
171
+ * The additive assignees mutation is the only one that cannot evict a login
172
+ * another run wrote between our read and our write, so it is the only one
173
+ * used here — and the reason the assignee is never removed when the Epic
174
+ * closes. A closed container naming who delivered it is useful; a removal
175
+ * would need the replacing endpoint and would race every concurrent run.
176
+ *
177
+ * @param {{ epicId: number, epic: object, owner: string|null, provider: object }} opts
178
+ * @returns {Promise<{ assigned: boolean, detail: string|null }>}
179
+ */
180
+ async function applyOwner({ epicId, epic, owner, provider }) {
181
+ if (!owner) return { assigned: false, detail: 'no-operator-handle' };
182
+ if (normalizeAssignees(epic?.assignees).includes(owner)) {
183
+ return { assigned: false, detail: null };
184
+ }
185
+ try {
186
+ await provider.updateTicket(epicId, { addAssignees: [owner] });
187
+ return { assigned: true, detail: null };
188
+ } catch (err) {
189
+ return { assigned: false, detail: String(err?.message ?? err) };
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Close a container whose children have all landed.
195
+ *
196
+ * @param {{ epicId: number, provider: object }} opts
197
+ * @returns {Promise<{ closed: boolean, detail: string|null }>}
198
+ */
199
+ async function applyClosure({ epicId, provider }) {
200
+ try {
201
+ await provider.updateTicket(epicId, {
202
+ state: 'closed',
203
+ state_reason: 'completed',
204
+ });
205
+ Logger.info(
206
+ `[epic-rollup] Closed container Epic #${epicId} — every child Story landed.`,
207
+ );
208
+ return { closed: true, detail: null };
209
+ } catch (err) {
210
+ return { closed: false, detail: String(err?.message ?? err) };
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Roll one Epic up from the children it lists.
216
+ *
217
+ * @param {{ epic: object, childIds: number[], provider: object, columnSync: object, owner: string|null }} opts
218
+ * @returns {Promise<object>} Per-Epic outcome record.
219
+ */
220
+ async function rollUpOneEpic({ epic, childIds, provider, columnSync, owner }) {
221
+ const epicId = Number(epic?.number ?? epic?.id);
222
+ const outcome = {
223
+ epicId,
224
+ column: null,
225
+ assigned: false,
226
+ closed: false,
227
+ pending: false,
228
+ detail: null,
229
+ };
230
+
231
+ const children = await readChildren({ epicId, childIds, provider });
232
+ if (children === null) {
233
+ outcome.pending = true;
234
+ outcome.detail = 'child-read-failed';
235
+ return outcome;
236
+ }
237
+
238
+ const derived = deriveParentState(children);
239
+ const applied = await applyColumn({
240
+ epicId,
241
+ column: derived ? (LABEL_TO_COLUMN[derived] ?? null) : null,
242
+ columnSync,
243
+ });
244
+ outcome.column = applied.column;
245
+ if (applied.detail) outcome.detail = applied.detail;
246
+
247
+ if (IN_FLIGHT_STATES.has(derived)) {
248
+ const ownership = await applyOwner({ epicId, epic, owner, provider });
249
+ outcome.assigned = ownership.assigned;
250
+ if (ownership.detail) outcome.detail = ownership.detail;
251
+ }
252
+
253
+ if (derived !== AGENT_LABELS.DONE) {
254
+ // Not every child has landed. Reported pending only when the Epic is
255
+ // still open — a closed container with an outstanding child is the
256
+ // reopened-child case, whose Status we just corrected and whose issue
257
+ // state is deliberately left alone.
258
+ outcome.pending = !isClosed(epic);
259
+ return outcome;
260
+ }
261
+
262
+ if (isClosed(epic)) return outcome;
263
+
264
+ const closure = await applyClosure({ epicId, provider });
265
+ outcome.closed = closure.closed;
266
+ outcome.pending = !closure.closed;
267
+ if (closure.detail) outcome.detail = closure.detail;
268
+ return outcome;
269
+ }
270
+
271
+ /**
272
+ * Find the open container Epics that list a given Story, with their children.
273
+ *
274
+ * The lookup runs child→parent by scanning open Epics because linkage is
275
+ * parent→child only — a Story body carries no pointer back, and adding one
276
+ * would reverse ADR `20260726-v2-story-collapse`. It is cheap: open
277
+ * containers are few, and only one listing this Story is ever read further.
278
+ *
279
+ * @param {{ storyId: number, provider: object, skipEpicIds: Set<number> }} opts
280
+ * @returns {Promise<Array<{ epic: object, childIds: number[] }>>}
281
+ */
282
+ async function findEpicsForStory({ storyId, provider, skipEpicIds }) {
283
+ let epics;
284
+ try {
285
+ epics = await provider.listIssuesByLabel({
286
+ state: 'open',
287
+ labels: TYPE_LABELS.EPIC,
288
+ });
289
+ } catch (err) {
290
+ Logger.warn(
291
+ `[epic-rollup] Could not list open Epics (${err?.message ?? err}); ` +
292
+ 'skipping the rollup.',
293
+ );
294
+ return [];
295
+ }
296
+
297
+ const matches = [];
298
+ for (const epic of Array.isArray(epics) ? epics : []) {
299
+ if (!isEpicTicket(epic)) continue;
300
+ const epicId = Number(epic?.number ?? epic?.id);
301
+ if (!Number.isInteger(epicId)) continue;
302
+ if (skipEpicIds.has(epicId)) continue;
303
+ // Body checklist UNION native sub-issue edges — the same reader the
304
+ // delivery expansion uses. Reading the body alone here is what made an
305
+ // Epic whose children were linked in the GitHub UI expandable but
306
+ // permanently unclosable.
307
+ const childIds = await readEpicChildIdsFrom({
308
+ epic,
309
+ readNativeChildIds: nativeChildReader(provider),
310
+ onWarn: (message) => Logger.warn(message),
311
+ });
312
+ if (!childIds.includes(storyId)) continue;
313
+ matches.push({ epic, childIds });
314
+ }
315
+ return matches;
316
+ }
317
+
318
+ /**
319
+ * Roll every container Epic listing this Story up from its children.
320
+ *
321
+ * `skipEpicIds` exists for a caller that walks several Stories of one run:
322
+ * siblings share a container, so without it the second Story would re-derive
323
+ * — and re-close — an Epic the first already closed. Live listings filter to
324
+ * open Epics and would eventually hide it, but a caller must not have to rely
325
+ * on a remote read racing its own writes.
326
+ *
327
+ * @param {{
328
+ * storyId: number,
329
+ * provider: object,
330
+ * config?: object,
331
+ * columnSync?: object,
332
+ * owner?: string|null,
333
+ * skipEpicIds?: Iterable<number>,
334
+ * }} opts
335
+ * @returns {Promise<{ epics: object[], closed: number[], pending: number[], reason: string|null }>}
336
+ */
337
+ export async function rollUpEpicForStory({
338
+ storyId,
339
+ provider,
340
+ config,
341
+ columnSync,
342
+ owner,
343
+ skipEpicIds,
344
+ }) {
345
+ const empty = { epics: [], closed: [], pending: [], reason: null };
346
+ const id = Number(storyId);
347
+ if (!Number.isInteger(id) || id <= 0) {
348
+ return { ...empty, reason: 'invalid-story-id' };
349
+ }
350
+ if (
351
+ typeof provider?.listIssuesByLabel !== 'function' ||
352
+ typeof provider?.getTicket !== 'function' ||
353
+ typeof provider?.updateTicket !== 'function'
354
+ ) {
355
+ return { ...empty, reason: 'provider-unsupported' };
356
+ }
357
+
358
+ try {
359
+ const matches = await findEpicsForStory({
360
+ storyId: id,
361
+ provider,
362
+ skipEpicIds: new Set(skipEpicIds ?? []),
363
+ });
364
+ if (matches.length === 0) return { ...empty, reason: 'no-container-epic' };
365
+
366
+ // One ColumnSync across every Epic in the run: it caches the board
367
+ // metadata, so sharing it spends the resolve once instead of per Epic.
368
+ const sync =
369
+ columnSync ??
370
+ (typeof provider.graphql === 'function'
371
+ ? new ColumnSync({ provider, logger: Logger, config })
372
+ : null);
373
+ const resolvedOwner =
374
+ owner === undefined ? resolveEpicOwner(config) : owner;
375
+
376
+ const epics = [];
377
+ for (const { epic, childIds } of matches) {
378
+ epics.push(
379
+ await rollUpOneEpic({
380
+ epic,
381
+ childIds,
382
+ provider,
383
+ columnSync: sync,
384
+ owner: resolvedOwner,
385
+ }),
386
+ );
387
+ }
388
+ return {
389
+ epics,
390
+ closed: epics.filter((e) => e.closed).map((e) => e.epicId),
391
+ pending: epics.filter((e) => e.pending).map((e) => e.epicId),
392
+ reason: null,
393
+ };
394
+ } catch (err) {
395
+ // The module-level never-throws contract. Both call sites are lifecycle
396
+ // edges of a Story that is otherwise fine; neither may fail on this.
397
+ const detail = String(err?.message ?? err);
398
+ Logger.warn(`[epic-rollup] Rollup for Story #${id} failed: ${detail}`);
399
+ return { ...empty, reason: detail };
400
+ }
401
+ }
@@ -7,8 +7,8 @@
7
7
  * 2. Rolls up friction follow-ups across every Story in the run and
8
8
  * files/posts them on the primary Story.
9
9
  * 3. Checks sibling Spec/acceptance coherence across Story bodies.
10
- * 4. Closes any container Epic whose children all landed (Story #5139) —
11
- * the only completion cascade v2 reintroduces.
10
+ * 4. Reports the container Epics whose children all landed (Story #5139),
11
+ * delegating the derivation and the close to `epic-rollup.js`.
12
12
  *
13
13
  * There is no inert planner-only path: `planRunEpilogue` enumerates steps
14
14
  * and `runPlanRunEpilogue` executes them. Single-Story runs skip the
@@ -21,8 +21,7 @@ import { selectAudits } from '../audit-suite/index.js';
21
21
  import { graduateRetroProposals } from '../feedback-loop/retro-proposals-graduator.js';
22
22
  import { gitSpawn } from '../git-utils.js';
23
23
  import { Logger } from '../Logger.js';
24
- import { AGENT_LABELS, TYPE_LABELS } from '../label-constants.js';
25
- import { isEpicTicket, readEpicChildIds } from './epic-container.js';
24
+ import { rollUpEpicForStory } from './epic-rollup.js';
26
25
  import { composeRoutedProposals } from './retro-proposals.js';
27
26
  import {
28
27
  assessRollupOutcome,
@@ -45,118 +44,59 @@ export const RUN_EPILOGUE_STEP_KINDS = Object.freeze([
45
44
  ]);
46
45
 
47
46
  /**
48
- * Close a container Epic once every child Story has landed.
47
+ * Close every container Epic whose children all landed in this run.
49
48
  *
50
- * This is the **only** completion cascade v2 reintroduces (Story #5139), and
51
- * it is deliberately one-directional: closing the container, never touching a
52
- * child's state, never reopening.
49
+ * Delegates to `epic-rollup.js` (Story #5205) rather than deriving anything
50
+ * itself. That module owns the child→parent scan, the body-checklist-union-
51
+ * native-sub-issue child read, and the one-way closure rule, and it is also
52
+ * invoked from the per-Story land tail — which is what closes a container
53
+ * whose last open child was a single Story, a case this epilogue never
54
+ * reaches because a one-Story run reports `applicable: false`.
53
55
  *
54
- * The lookup runs child→parent by scanning open Epics, because linkage is
55
- * parent→child only — a Story body carries no pointer back. That is the
56
- * price of leaving Story bodies untouched, and it is cheap: open Epics are
57
- * few, and the scan is scoped to Epics that actually contain one of this
58
- * run's delivered Stories, so an unrelated Epic is never swept.
56
+ * The step survives for its report: this is the surface an operator reads to
57
+ * see which containers a multi-Story run closed and which are still pending.
59
58
  *
60
59
  * Non-fatal throughout: the epilogue is a reporting tail, and a container
61
60
  * left open costs tidiness, not correctness.
62
61
  *
63
- * @param {{ stories: string[], provider: object }} opts
62
+ * @param {{ stories: string[], provider: object, config?: object }} opts
64
63
  * @returns {Promise<{ kind: string, closed: number[], pending: number[] }>}
65
64
  */
66
- async function executeEpicClose({ stories, provider }) {
67
- const result = { kind: 'epic-close', closed: [], pending: [] };
68
- if (
69
- typeof provider?.listIssuesByLabel !== 'function' ||
70
- typeof provider?.updateTicket !== 'function'
71
- ) {
72
- return result;
73
- }
65
+ async function executeEpicClose({ stories, provider, config }) {
66
+ const closed = new Set();
67
+ const pending = new Set();
68
+ // Siblings share a container, so an Epic resolved by one Story's rollup is
69
+ // withheld from the next one's — otherwise the second Story would re-derive
70
+ // and re-close what the first already closed.
71
+ const seen = new Set();
74
72
 
75
- const delivered = new Set(stories.map((id) => Number(id)));
76
- let epics;
77
- try {
78
- epics = await provider.listIssuesByLabel({
79
- state: 'open',
80
- labels: TYPE_LABELS.EPIC,
73
+ // `rollUpEpicForStory` never throws and always returns the full envelope,
74
+ // so its three lists are read directly — a `?? []` guard here would be an
75
+ // unreachable branch asserting a contract the module already keeps.
76
+ for (const raw of stories) {
77
+ const storyId = Number(raw);
78
+ if (!Number.isInteger(storyId) || storyId <= 0) continue;
79
+ const outcome = await rollUpEpicForStory({
80
+ storyId,
81
+ provider,
82
+ config,
83
+ skipEpicIds: seen,
81
84
  });
82
- } catch (err) {
83
- Logger.warn(
84
- `[run-epilogue] Could not list open Epics (${err?.message ?? err}); skipping the Epic close.`,
85
- );
86
- return result;
87
- }
88
-
89
- for (const epic of Array.isArray(epics) ? epics : []) {
90
- if (!isEpicTicket(epic)) continue;
91
- const epicId = Number(epic?.number ?? epic?.id);
92
- if (!Number.isInteger(epicId)) continue;
93
-
94
- const childIds = readEpicChildIds(epic?.body);
95
- if (childIds.length === 0) continue;
96
- // Only Epics this run actually advanced. Sweeping every open Epic would
97
- // make a delivery close containers it had nothing to do with.
98
- if (!childIds.some((c) => delivered.has(c))) continue;
99
-
100
- let allLanded = true;
101
- for (const childId of childIds) {
102
- try {
103
- const child = await provider.getTicket(childId);
104
- if (!isSatisfiedChild(child)) {
105
- allLanded = false;
106
- break;
107
- }
108
- } catch (err) {
109
- Logger.warn(
110
- `[run-epilogue] Epic #${epicId}: could not read child #${childId} ` +
111
- `(${err?.message ?? err}) — leaving the Epic open.`,
112
- );
113
- allLanded = false;
114
- break;
115
- }
116
- }
117
-
118
- if (!allLanded) {
119
- result.pending.push(epicId);
120
- continue;
121
- }
122
-
123
- try {
124
- await provider.updateTicket(epicId, {
125
- state: 'closed',
126
- state_reason: 'completed',
127
- });
128
- Logger.info(
129
- `[run-epilogue] Closed container Epic #${epicId} — all ${childIds.length} child Story(ies) landed.`,
130
- );
131
- result.closed.push(epicId);
132
- } catch (err) {
133
- Logger.warn(
134
- `[run-epilogue] Could not close Epic #${epicId} (${err?.message ?? err}).`,
135
- );
136
- result.pending.push(epicId);
137
- }
85
+ for (const epic of outcome.epics) seen.add(epic.epicId);
86
+ for (const epicId of outcome.closed) closed.add(epicId);
87
+ for (const epicId of outcome.pending) pending.add(epicId);
138
88
  }
139
89
 
140
- return result;
141
- }
90
+ // An Epic this run closed can also have been reported pending by an
91
+ // earlier Story's rollup, when a sibling had not landed yet. The close is
92
+ // the later, truer answer.
93
+ for (const epicId of closed) pending.delete(epicId);
142
94
 
143
- /**
144
- * A child no longer holds its Epic open once it is closed or `agent::done`.
145
- *
146
- * Mirrors `isSatisfiedBlocker` in `lib/orchestration/resolve-stories.js`
147
- * rather than importing it: that module is the delivery-resolution path and
148
- * pulling it in here would drag the whole story-body parser into the
149
- * epilogue for a two-line predicate.
150
- *
151
- * @param {{ state?: string, labels?: unknown }} issue
152
- * @returns {boolean}
153
- */
154
- function isSatisfiedChild(issue) {
155
- if (String(issue?.state ?? '').toLowerCase() === 'closed') return true;
156
- const labels = Array.isArray(issue?.labels)
157
- ? issue.labels.map((l) => (typeof l === 'string' ? l : l?.name))
158
- : [];
159
- return labels.includes(AGENT_LABELS.DONE);
95
+ return {
96
+ kind: 'epic-close',
97
+ closed: [...closed],
98
+ pending: [...pending],
99
+ };
160
100
  }
161
101
 
162
102
  /**
@@ -952,7 +892,7 @@ export async function runPlanRunEpilogue({
952
892
  );
953
893
  } else if (step.kind === 'epic-close') {
954
894
  results.push(
955
- await executeEpicClose({ stories: plan.stories, provider }),
895
+ await executeEpicClose({ stories: plan.stories, provider, config }),
956
896
  );
957
897
  }
958
898
  } catch (err) {
@@ -40,6 +40,7 @@ import {
40
40
  } from '../../../observability/runtime-friction.js';
41
41
  import { acquireLockWithWait as defaultAcquireLockWithWait } from '../../../single-story-sweep/sweep-lock.js';
42
42
  import { purgeStoryTempArtifacts as defaultPurgeStoryTempArtifacts } from '../../../temp-retention.js';
43
+ import { rollUpEpicForStory as defaultRollUpEpicForStory } from '../../epic-rollup.js';
43
44
  import {
44
45
  executeFastForward as defaultExecuteFastForward,
45
46
  planFastForward as defaultPlanFastForward,
@@ -298,6 +299,45 @@ async function stepLeaseRelease({
298
299
  */
299
300
  const REAP_SWEEP_REMEDY = 'node .agents/scripts/prune-plan-run-labels.js';
300
301
 
302
+ /**
303
+ * Roll the closing Story's container Epic up from its children (Story #5205).
304
+ *
305
+ * Wired here for the same reason the cohort-label reap is: this is the only
306
+ * seam both a single- and a multi-Story run reach. The run epilogue that used
307
+ * to own the Epic close runs at N>1 only, so a container whose last open
308
+ * child was one Story stayed open forever.
309
+ *
310
+ * Unlike the reap, the outcome IS reported in the returned `tail`. The
311
+ * distinction is what an operator can act on: a stale cohort label is read by
312
+ * nothing, whereas an Epic left open or showing the wrong column is a visible
313
+ * board state someone will otherwise correct by hand, so a false here earns
314
+ * its line in the envelope.
315
+ *
316
+ * @returns {Promise<{ ok: boolean, detail: string|null }>}
317
+ */
318
+ async function stepEpicRollup({
319
+ storyId,
320
+ provider,
321
+ config,
322
+ progress,
323
+ rollUpEpicForStoryFn,
324
+ }) {
325
+ const outcome = await rollUpEpicForStoryFn({ storyId, provider, config });
326
+ for (const epicId of outcome?.closed ?? []) {
327
+ progress?.(
328
+ 'POST-LAND',
329
+ `🗃️ Closed container Epic #${epicId} — every child Story landed.`,
330
+ );
331
+ }
332
+ const failures = (outcome?.epics ?? []).filter((e) => e?.detail);
333
+ return {
334
+ ok: failures.length === 0,
335
+ detail: failures.length
336
+ ? failures.map((e) => `#${e.epicId}: ${e.detail}`).join('; ')
337
+ : null,
338
+ };
339
+ }
340
+
301
341
  /**
302
342
  * Reap the cohort labels the closing Story carried (Story #5189).
303
343
  *
@@ -396,7 +436,8 @@ async function stepPlanRunLabelReap({
396
436
  * @param {Function} [args.purgeStoryTempArtifactsFn] Test seam.
397
437
  * @param {Function} [args.releaseStoryLeaseFn] Test seam.
398
438
  * @param {Function} [args.reapPlanRunLabelsForStoryFn] Test seam.
399
- * @returns {Promise<{ followUps: boolean, statusResync: boolean, refCleanup: boolean, baseFastForward: boolean, tempPurge: boolean, leaseRelease: boolean, details: Record<string, string|null> }>}
439
+ * @param {Function} [args.rollUpEpicForStoryFn] Test seam.
440
+ * @returns {Promise<{ followUps: boolean, statusResync: boolean, refCleanup: boolean, baseFastForward: boolean, tempPurge: boolean, leaseRelease: boolean, epicRollup: boolean, details: Record<string, string|null> }>}
400
441
  */
401
442
  export async function runPostLandTail({
402
443
  storyId,
@@ -417,6 +458,7 @@ export async function runPostLandTail({
417
458
  purgeStoryTempArtifactsFn = defaultPurgeStoryTempArtifacts,
418
459
  releaseStoryLeaseFn = defaultReleaseStoryLease,
419
460
  reapPlanRunLabelsForStoryFn = defaultReapPlanRunLabelsForStory,
461
+ rollUpEpicForStoryFn = defaultRollUpEpicForStory,
420
462
  }) {
421
463
  progress?.('POST-LAND', `🧾 Running land tail for Story #${storyId}...`);
422
464
 
@@ -483,6 +525,21 @@ export async function runPostLandTail({
483
525
  { name: 'plan-run label reap', progress },
484
526
  );
485
527
 
528
+ // Story #5205 — the container Epic's state is derived from its children, so
529
+ // the child reaching `agent::done` is the edge that can close it. Runs with
530
+ // the other GitHub-touching steps, outside the checkout lock.
531
+ const epicRollup = await step(
532
+ () =>
533
+ stepEpicRollup({
534
+ storyId,
535
+ provider,
536
+ config,
537
+ progress,
538
+ rollUpEpicForStoryFn,
539
+ }),
540
+ { name: 'epic rollup', progress },
541
+ );
542
+
486
543
  // Local-checkout mutations: serialized behind a best-effort cross-process
487
544
  // lock (Story #4622). Acquire once, run both steps, release in `finally`.
488
545
  const lockCfg = config?.delivery?.postLandLock ?? {};
@@ -555,6 +612,7 @@ export async function runPostLandTail({
555
612
  baseFastForward: baseFastForward.ok,
556
613
  tempPurge: tempPurge.ok,
557
614
  leaseRelease: leaseRelease.ok,
615
+ epicRollup: epicRollup.ok,
558
616
  details: {
559
617
  followUps: followUps.detail,
560
618
  statusResync: statusResync.detail,
@@ -562,6 +620,7 @@ export async function runPostLandTail({
562
620
  baseFastForward: baseFastForward.detail,
563
621
  tempPurge: tempPurge.detail,
564
622
  leaseRelease: leaseRelease.detail,
623
+ epicRollup: epicRollup.detail,
565
624
  },
566
625
  };
567
626
  const degraded = Object.entries(tail)
@@ -55,6 +55,7 @@ import { getStoryBranch, gitSpawn, gitSync } from './lib/git-utils.js';
55
55
  import { Logger } from './lib/Logger.js';
56
56
  import { TYPE_LABELS } from './lib/label-constants.js';
57
57
  import { emitTerseResult } from './lib/observability/terse-result.js';
58
+ import { rollUpEpicForStory } from './lib/orchestration/epic-rollup.js';
58
59
  import {
59
60
  executeFastForward,
60
61
  planFastForward,
@@ -223,6 +224,34 @@ async function flipStoryToExecuting(provider, storyId, story) {
223
224
  }
224
225
  }
225
226
 
227
+ /**
228
+ * Roll any container Epic listing this Story up from its children.
229
+ *
230
+ * A container carries no `agent::*` label, so the Status sync that follows
231
+ * the flip above cannot reach it — its column is derived from its children
232
+ * instead. This is the edge where the first child of an Epic starts moving,
233
+ * which is what puts the Epic on the board as In Progress with an owner.
234
+ *
235
+ * Best-effort by construction: the rollup never throws, and a container left
236
+ * at a stale column must never cost the Story its init.
237
+ *
238
+ * @param {object} provider
239
+ * @param {number} storyId
240
+ * @param {object} config
241
+ * @returns {Promise<void>}
242
+ */
243
+ async function rollUpContainerEpic(provider, storyId, config) {
244
+ const outcome = await rollUpEpicForStory({ storyId, provider, config });
245
+ for (const epic of outcome.epics) {
246
+ if (!epic.column) continue;
247
+ progress(
248
+ 'EPIC',
249
+ `🗃️ Epic #${epic.epicId} → ${epic.column}` +
250
+ (epic.assigned ? ' (assigned)' : ''),
251
+ );
252
+ }
253
+ }
254
+
226
255
  /**
227
256
  * Undo this run's claim when provisioning fails after the early
228
257
  * `agent::executing` flip: revert the label to `agent::ready` and release the
@@ -675,6 +704,12 @@ export async function runSingleStoryInit({
675
704
  // install window instead of reading agent::ready and double-dispatching.
676
705
  await flipStoryToExecuting(provider, storyId, story);
677
706
 
707
+ // Story #5205 — the child is now in flight, so any container Epic listing
708
+ // it is too. Fired here rather than after provisioning so the board shows
709
+ // In Progress for the whole install window, and after the flip so the
710
+ // rollup reads the state it derives from.
711
+ await rollUpContainerEpic(provider, storyId, config);
712
+
678
713
  // Any failure from here on leaves a claimed, executing-labelled Story with
679
714
  // no live run behind it — revert the label and release the lease so the
680
715
  // Story is not stranded as phantom-executing.
@@ -292,18 +292,40 @@ This executes, in order:
292
292
  (files issues when auto-file is on; posts `follow-ups`).
293
293
  - `sibling-coherence` — Spec/Acceptance coherence check across sibling bodies
294
294
  (`plan-run-sibling-coherence`).
295
- - `epic-close` — closes a container Epic once **every** child Story is
296
- `agent::done`, as `completed`. This is the only completion cascade v2 has:
297
- it closes the container and nothing else — no child status roll-up, no label
298
- inheritance, no reopening. Because linkage is parent→child only, the parent
299
- is found by scanning open `type::epic` issues, and only an Epic containing
300
- one of *this run's* Stories is considered, so an unrelated container is
301
- never swept. An Epic with an outstanding child is reported `pending` and
302
- left open.
295
+ - `epic-close` — **reports** which container Epics this run closed and which
296
+ are still pending. It derives nothing itself: every step here and the
297
+ per-Story land tail alike delegate to `epic-rollup.js`, so one rule decides
298
+ a container's state.
303
299
 
304
300
  A single-Story run skips the epilogue — follow-ups are captured on merge
305
301
  confirm instead (`captureStoryFollowUps`).
306
302
 
303
+ ## Container-Epic rollup (every N)
304
+
305
+ A container Epic is never delivered, so nothing used to write to it during
306
+ the run it was the subject of. `epic-rollup.js` derives its state from its
307
+ children at both per-Story lifecycle edges — the `agent::executing` flip in
308
+ `single-story-init.js` and the post-land tail (reported as the tail's
309
+ `epicRollup` step) — which is why it holds at **N=1**, where no epilogue runs.
310
+
311
+ - **Status** follows the children's composition (`deriveParentState` mapped
312
+ onto the board's three options): any child executing or blocked → `In
313
+ Progress`, every child `agent::done` or closed → `Done`. It is written
314
+ **directly**, never via a label: the container carries no `agent::*` label
315
+ by construction, which is what keeps it out of the bare `/mandrel-deliver`
316
+ ready list.
317
+ - **Owner** — `github.operatorHandle` is added to the Epic while any child is
318
+ in flight, through the additive assignees endpoint, and is never removed.
319
+ - **Closure** is one-way: every child landed closes the container as
320
+ `completed`; a reopened child moves Status back to `In Progress` and does
321
+ **not** reopen it.
322
+ - The parent lookup scans open `type::epic` issues, because linkage is
323
+ parent→child only, and reads children as the body checklist **union** the
324
+ native sub-issue edges — the same reader `/mandrel-deliver`'s expansion
325
+ uses, so an Epic can never be expandable but unclosable.
326
+ - Every step is best-effort and never throws: a stale container costs
327
+ tidiness, not a landed Story's envelope.
328
+
307
329
  ## Ceremony (profiles + two scopes)
308
330
 
309
331
  Ceremony depth is selected by `delivery.routing.ceremonyProfile`
@@ -323,7 +345,7 @@ sensitive-path classes in `audit-rules.json`
323
345
  | **Per-Story (always)** | Gates, branch discipline, close-and-land | `deliver-story` / `single-story-close` |
324
346
  | **Per-Story (profile + derived level)** | Acceptance critic mode; review depth | `ceremony-routing.js` + `review-depth.js` + `code-review.js` |
325
347
  | **Per-run (N>1)** | Audit roster · follow-up roll-up · sibling coherence | `plan-run-epilogue.js` once at run end |
326
- | **Per-Story land tail** | Follow-up capture · status resync · ref cleanup · base fast-forward | `single-story-close/phases/post-land.js` (in-process, per-step reported) |
348
+ | **Per-Story land tail** | Follow-up capture · status resync · Epic rollup · ref cleanup · base fast-forward | `single-story-close/phases/post-land.js` (in-process, per-step reported) |
327
349
 
328
350
  ## Async merge-confirm mode (`delivery.mergeWatch.mode: "async"`)
329
351
 
@@ -102,9 +102,9 @@ to an attended run.
102
102
 
103
103
  4. **Close each hand-off** (§ Closing what the workers hand back), then, with
104
104
  every Story landed, run the **per-run epilogue (N>1)**:
105
- `node .agents/scripts/plan-run-epilogue.js --stories 101,102`, which also
106
- closes a container Epic whose children all landed. N=1 skips it
107
- ([reference](helpers/deliver-reference.md)).
105
+ `node .agents/scripts/plan-run-epilogue.js --stories 101,102`; N=1 has none
106
+ ([reference](helpers/deliver-reference.md)). Every close rolls its container
107
+ Epic up from its children.
108
108
 
109
109
  5. **Correct what the change invalidated.** If a memory you recalled this
110
110
  session is now wrong — a trap this landed, a budget it moved — fix that entry
package/docs/CHANGELOG.md CHANGED
@@ -15,6 +15,13 @@ All notable changes to this project will be documented in this file.
15
15
  -->
16
16
  <!-- markdownlint-disable-file MD004 MD012 MD037 -->
17
17
 
18
+ ## [2.47.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.46.0...mandrel-v2.47.0) (2026-09-07)
19
+
20
+
21
+ ### Added
22
+
23
+ * roll a container Epic's board status, assignee and closure up from its child Stories ([#5205](https://github.com/dsj1984/mandrel/issues/5205)) ([#5206](https://github.com/dsj1984/mandrel/issues/5206)) ([38aae8b](https://github.com/dsj1984/mandrel/commit/38aae8bb4c273b258b420b463781970df18f58e1))
24
+
18
25
  ## [2.46.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.45.0...mandrel-v2.46.0) (2026-09-07)
19
26
 
20
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.46.0",
3
+ "version": "2.47.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, skills, rules, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",