mandrel 2.53.0 → 2.54.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.
@@ -40,6 +40,7 @@
40
40
  * @module lib/orchestration/epic-rollup
41
41
  * @see Story #5205
42
42
  * @see Story #5210 — fail closed on a degraded child read.
43
+ * @see Story #5255 — a closed child contributes no `agent::*` state.
43
44
  */
44
45
 
45
46
  import { Logger } from '../Logger.js';
@@ -58,6 +59,13 @@ import { deriveParentState } from './ticketing/bulk.js';
58
59
  * Epic carries an owner. `agent::blocked` counts: a blocked child is still
59
60
  * this operator's problem, and dropping the assignee at the moment someone
60
61
  * needs to be found would invert the signal.
62
+ *
63
+ * "Blocked" here means an **open** blocked child. `deriveParentState` stopped
64
+ * reading closed children's `agent::*` labels in Story #5255 — a superseded
65
+ * Story closed while still wearing `agent::blocked` is not someone's problem
66
+ * to pick up, and the stale label used to derive `agent::blocked` forever,
67
+ * which both held an owner on the container and pinned it open past the
68
+ * `derived !== DONE` bail below.
61
69
  */
62
70
  const IN_FLIGHT_STATES = new Set([
63
71
  AGENT_LABELS.EXECUTING,
@@ -28,6 +28,15 @@
28
28
  * partial failure reports which tickets were and were not closed so the
29
29
  * operator can finish by hand.
30
30
  *
31
+ * The close also strips the source ticket's `agent::*` label (Story #5255).
32
+ * A retired ticket has no agent state, and the one it kept was read as live
33
+ * work: `agent::blocked` is the state a Story must be in to be re-planned, so
34
+ * this path closes exactly the tickets carrying it, and a closed-but-blocked
35
+ * child pinned its container Epic open on every rollup thereafter. The
36
+ * derivation in `ticketing/bulk.js` now ignores closed children's labels too —
37
+ * that half covers the tickets already closed and the ones closed by hand;
38
+ * this one stops new ones being written.
39
+ *
31
40
  * Idempotency is keyed off the `superseded-by` structured-comment marker
32
41
  * (`upsertStructuredComment`), not a bare `postComment`, so a re-run cannot
33
42
  * double-comment.
@@ -36,6 +45,7 @@
36
45
  */
37
46
 
38
47
  import { Logger } from '../../Logger.js';
48
+ import { AGENT_LABELS } from '../../label-constants.js';
39
49
  import {
40
50
  concurrentMap,
41
51
  FANOUT_CONCURRENCY,
@@ -55,6 +65,40 @@ const SUPERSEDED_BY_COMMENT_TYPE = 'superseded-by';
55
65
  */
56
66
  export const SUPERSEDE_CLOSE_REASON = 'not_planned';
57
67
 
68
+ /**
69
+ * Every `agent::*` label, as the set the supersede close strips.
70
+ *
71
+ * A retired ticket has no agent state. `agent::done` would be the wrong
72
+ * substitute — the work was re-planned, never delivered — so the label is
73
+ * removed rather than rewritten, and the ticket ends carrying only its
74
+ * `type::`/domain labels and the supersede comment that explains it.
75
+ */
76
+ const AGENT_STATE_LABELS = Object.freeze(Object.values(AGENT_LABELS));
77
+
78
+ /**
79
+ * The single `updateTicket` mutation that retires a source ticket.
80
+ *
81
+ * Closing and clearing the state ride one write: two calls could leave the
82
+ * ticket closed but still wearing `agent::blocked`, which is the shape that
83
+ * pinned a container Epic open forever (Story #5255).
84
+ *
85
+ * A ticket with no `agent::*` label gets the bare close, unchanged from before
86
+ * that Story — `updateTicket` merges a `labels` mutation by reading the issue
87
+ * back, so an unconditional empty `remove` would buy a wasted round-trip per
88
+ * superseded ticket. `_ticketSnapshot` feeds that merge the copy
89
+ * `probeSourceTicket` already fetched.
90
+ *
91
+ * @param {{ labels?: unknown }} ticket The probe's fresh copy.
92
+ * @returns {object} Mutations for `provider.updateTicket`.
93
+ */
94
+ function supersedeCloseMutations(ticket) {
95
+ const close = { state: 'closed', state_reason: SUPERSEDE_CLOSE_REASON };
96
+ const labels = Array.isArray(ticket?.labels) ? ticket.labels : [];
97
+ const remove = AGENT_STATE_LABELS.filter((label) => labels.includes(label));
98
+ if (remove.length === 0) return close;
99
+ return { ...close, labels: { remove }, _ticketSnapshot: ticket };
100
+ }
101
+
58
102
  /**
59
103
  * Coerce one `supersedes[]` entry into `{ id, note }`.
60
104
  *
@@ -329,13 +373,22 @@ export function buildSupersedeCommentBody({
329
373
  /**
330
374
  * Resolve the live state of a source ticket.
331
375
  *
332
- * @returns {Promise<{ ok: true, state: string } | { ok: false, reason: string }>}
376
+ * The ticket itself rides along so the close can strip the `agent::*` label
377
+ * without a second read: `updateTicket`'s label merge takes a
378
+ * `_ticketSnapshot` for exactly this, and this probe has already paid for the
379
+ * fresh copy.
380
+ *
381
+ * @returns {Promise<{ ok: true, state: string, ticket: object } | { ok: false, reason: string }>}
333
382
  */
334
383
  async function probeSourceTicket(provider, id) {
335
384
  try {
336
385
  const ticket = await provider.getTicket(id, { fresh: true });
337
386
  if (!ticket) return { ok: false, reason: 'not-found' };
338
- return { ok: true, state: String(ticket.state ?? 'open').toLowerCase() };
387
+ return {
388
+ ok: true,
389
+ state: String(ticket.state ?? 'open').toLowerCase(),
390
+ ticket,
391
+ };
339
392
  } catch (err) {
340
393
  return { ok: false, reason: `inaccessible: ${err.message}` };
341
394
  }
@@ -370,10 +423,7 @@ async function closeOneSupersededTicket({
370
423
  sourceTicketIds,
371
424
  }),
372
425
  );
373
- await provider.updateTicket(id, {
374
- state: 'closed',
375
- state_reason: SUPERSEDE_CLOSE_REASON,
376
- });
426
+ await provider.updateTicket(id, supersedeCloseMutations(probe.ticket));
377
427
  return { outcome: 'closed' };
378
428
  } catch (err) {
379
429
  return { outcome: 'failed', reason: err.message };
@@ -401,25 +401,59 @@ async function cascadeCompletion(provider, ticketId, opts = {}) {
401
401
  * Derive the parent `agent::*` state from the composition of its children.
402
402
  *
403
403
  * Rules (Story #2676):
404
- * - Any child carrying `agent::blocked` → parent should be `agent::blocked`.
404
+ * - Any **open** child carrying `agent::blocked` → parent should be
405
+ * `agent::blocked`.
405
406
  * - Otherwise, every child is `agent::done` (or closed) → parent should be
406
407
  * `agent::done`.
407
- * - Otherwise, any child carrying `agent::executing` or `agent::closing` →
408
- * parent should be `agent::executing`.
408
+ * - Otherwise, any **open** child carrying `agent::executing` or
409
+ * `agent::closing` → parent should be `agent::executing`.
409
410
  * - Otherwise (e.g. all children still `agent::ready`) → return `null` to
410
411
  * signal "leave the parent unchanged". A parent already partway through
411
412
  * the lifecycle MUST NOT be downgraded just because one child reverted.
412
413
  *
414
+ * **A closed child contributes no `agent::*` state** (Story #5255). Its label
415
+ * records the state it was in when it stopped, not outstanding work, and
416
+ * nothing clears it on the way out: a Story re-planned out of `agent::blocked`
417
+ * is closed as superseded still wearing that label, and the blocked rule then
418
+ * pinned its container Epic open forever — `epic-rollup.js` bails before its
419
+ * close path on any derived state other than `agent::done`, so the Epic
420
+ * reported `pending` every run with every child long since closed. Filtering
421
+ * here rather than at that one call site is what also covers a child closed by
422
+ * hand with a stale state label attached. The all-done branch already counted
423
+ * `state === 'closed'` as done, so a closed child keeps exactly that meaning
424
+ * and loses only its vote on the other two.
425
+ *
413
426
  * The function is pure and exported so the rule can be exercised in
414
427
  * isolation by unit tests without dragging the cascade I/O surface in.
415
428
  *
416
429
  * @param {Array<{ labels?: string[], state?: string }>} siblings
417
430
  * @returns {string|null} A `STATE_LABELS.*` value, or `null` for no-op.
418
431
  */
432
+ /**
433
+ * The labels that still describe **live** work on this child.
434
+ *
435
+ * Empty for a closed child: its `agent::*` label records the state it stopped
436
+ * in, not outstanding work, and the two live-state rules in
437
+ * {@link deriveParentState} must not read it. The all-done rule reads the
438
+ * child's labels directly, so a closed child keeps counting as done.
439
+ *
440
+ * Module-level rather than another local arrow inside `deriveParentState`:
441
+ * the CRAP baseline keys anonymous functions positionally within their
442
+ * enclosing scope, so adding or removing one there renumbers every later
443
+ * arrow and reports the shift as drift on code that did not change.
444
+ *
445
+ * @param {{ labels?: string[], state?: string }} sibling
446
+ * @returns {string[]}
447
+ */
448
+ function liveChildLabels(sibling) {
449
+ if (sibling?.state === 'closed') return [];
450
+ return Array.isArray(sibling?.labels) ? sibling.labels : [];
451
+ }
452
+
419
453
  export function deriveParentState(siblings) {
420
454
  if (!Array.isArray(siblings) || siblings.length === 0) return null;
421
455
  const labelsOf = (s) => (Array.isArray(s?.labels) ? s.labels : []);
422
- if (siblings.some((s) => labelsOf(s).includes(STATE_LABELS.BLOCKED))) {
456
+ if (siblings.some((s) => liveChildLabels(s).includes(STATE_LABELS.BLOCKED))) {
423
457
  return STATE_LABELS.BLOCKED;
424
458
  }
425
459
  const allDone = siblings.every(
@@ -428,8 +462,8 @@ export function deriveParentState(siblings) {
428
462
  if (allDone) return STATE_LABELS.DONE;
429
463
  const anyActive = siblings.some(
430
464
  (s) =>
431
- labelsOf(s).includes(STATE_LABELS.EXECUTING) ||
432
- labelsOf(s).includes(STATE_LABELS.CLOSING),
465
+ liveChildLabels(s).includes(STATE_LABELS.EXECUTING) ||
466
+ liveChildLabels(s).includes(STATE_LABELS.CLOSING),
433
467
  );
434
468
  if (anyActive) return STATE_LABELS.EXECUTING;
435
469
  return null;
@@ -309,8 +309,11 @@ children at both per-Story lifecycle edges — the `agent::executing` flip in
309
309
  `epicRollup` step) — which is why it holds at **N=1**, where no epilogue runs.
310
310
 
311
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
312
+ onto the board's three options): any **open** child executing or blocked →
313
+ `In Progress`, every child `agent::done` or closed → `Done`. A **closed**
314
+ child contributes no `agent::*` state at all — its label records where it
315
+ stopped, and a superseded Story closed still wearing `agent::blocked` used
316
+ to pin its container open forever. It is written
314
317
  **directly**, never via a label: the container carries no `agent::*` label
315
318
  by construction, which is what keeps it out of the bare `/mandrel-deliver`
316
319
  ready list.
@@ -598,7 +598,7 @@ link that makes the history readable.
598
598
 
599
599
  | Behaviour | Contract |
600
600
  | --- | --- |
601
- | Default | Comment + close every source ticket as `not_planned`. |
601
+ | Default | Comment + close every source ticket as `not_planned`, **clearing its `agent::*` label** in the same write — a retired ticket has no agent state, and `agent::done` would claim a delivery that never happened. |
602
602
  | `--no-close-superseded` | Skips all commenting and closing. Story creation is unchanged. Use it for a genuinely partial supersede — when the plan folded in only *part* of an issue and the remainder must stay open. |
603
603
  | `--dry-run` | Posts no comment and closes nothing; reports what it would have done. |
604
604
  | Re-run | Idempotent — the comment is keyed off a `superseded-by` structured-comment marker, and an already-closed source is skipped. |
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.54.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.53.0...mandrel-v2.54.0) (2026-09-09)
19
+
20
+
21
+ ### Fixed
22
+
23
+ * a closed child pins its container Epic open forever: the rollup reads its stale agent::* label, and the supersede close is what writes one ([#5255](https://github.com/dsj1984/mandrel/issues/5255)) ([#5256](https://github.com/dsj1984/mandrel/issues/5256)) ([672428f](https://github.com/dsj1984/mandrel/commit/672428f1e0fba1388a1a001d2840e7eb827b9373))
24
+
18
25
  ## [2.53.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.52.0...mandrel-v2.53.0) (2026-09-09)
19
26
 
20
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.53.0",
3
+ "version": "2.54.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/",