mandrel 2.5.0 → 2.7.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.
@@ -97,7 +97,9 @@ function metaSourceLabel(source) {
97
97
  * ({@link buildContentMarker}) at the Story #4415 cutover, but still
98
98
  * probed for so follow-ups filed before the cutover are recognized and
99
99
  * not re-filed. An HTML comment so it survives markdown rendering without
100
- * leaking into the visible body, but stays indexable via `gh search`.
100
+ * leaking into the visible body; the idempotency probe strips the comment
101
+ * delimiters before querying `gh search` (the raw `<!-- … -->` form never
102
+ * matches the index — Story #4657).
101
103
  *
102
104
  * @param {number} epicId
103
105
  * @param {number} index — zero-based finding ordinal within the Epic.
@@ -112,7 +114,10 @@ export function buildIdempotencyMarker(epicId, index) {
112
114
  * follow-up bodies. Derived from the finding's `lens|path|summary` triple
113
115
  * so the marker is stable across sibling insert/remove/reorder churn in
114
116
  * the source `audit-results` comment (Story #4415). An HTML comment so it
115
- * survives markdown rendering but stays indexable via `gh search`.
117
+ * survives markdown rendering without leaking into the visible body; the
118
+ * idempotency probe strips the comment delimiters before querying
119
+ * `gh search` (the raw `<!-- … -->` form never matches the index —
120
+ * Story #4657).
116
121
  *
117
122
  * @param {number} epicId
118
123
  * @param {{ lens?: string, path?: string, summary?: string }} finding
@@ -273,12 +273,34 @@ export async function probePathStatus({
273
273
  return { exists: res.code === 0, probeError: false };
274
274
  }
275
275
 
276
+ /**
277
+ * Normalize an idempotency marker into a `gh search issues` query. Markers
278
+ * are HTML comments (`<!-- … -->`) so they survive markdown rendering
279
+ * without leaking into the visible body, but the `<` / `>` delimiters are
280
+ * NOT index-safe as a query: GitHub full-text search DOES index the text
281
+ * inside an HTML comment, yet a query that carries the `<!--` / `-->`
282
+ * delimiters never matches that indexed text (measured against this repo,
283
+ * Story #4657). Stripping the delimiters and trimming yields the bare marker
284
+ * text — `retro-proposal-followup: epic-1-<fp>` — which the index matches.
285
+ * The caller-facing marker is left untouched; normalization is the probe's
286
+ * own concern.
287
+ *
288
+ * @param {string} marker
289
+ * @returns {string}
290
+ */
291
+ function normalizeMarkerQuery(marker) {
292
+ if (typeof marker !== 'string') return '';
293
+ return marker.replaceAll('<!--', '').replaceAll('-->', '').trim();
294
+ }
295
+
276
296
  /**
277
297
  * Probe whether a follow-up issue carrying the given idempotency marker
278
298
  * already exists in the routed repo. Uses `gh search issues` so we hit
279
- * the body field directly. Returns `true` when at least one match is
280
- * present; degrades to `false` on any spawn/parse error (better to risk
281
- * a duplicate than swallow the finding entirely).
299
+ * the body field directly, querying the delimiter-stripped marker text
300
+ * (see {@link normalizeMarkerQuery}) the raw `<!-- -->` form never
301
+ * matches the index. Returns `true` when at least one match is present;
302
+ * degrades to `false` on any spawn/parse error (better to risk a duplicate
303
+ * than swallow the finding entirely).
282
304
  */
283
305
  export async function probeMarkerExists({
284
306
  marker,
@@ -292,7 +314,7 @@ export async function probeMarkerExists({
292
314
  const args = [
293
315
  'search',
294
316
  'issues',
295
- marker,
317
+ normalizeMarkerQuery(marker),
296
318
  '--repo',
297
319
  `${owner}/${repo}`,
298
320
  '--json',
@@ -312,6 +334,71 @@ export async function probeMarkerExists({
312
334
  }
313
335
  }
314
336
 
337
+ /**
338
+ * Strongly-consistent confirmation that a follow-up carrying `marker`
339
+ * already exists, run ONLY on the would-file path as the last gate before
340
+ * creating. `gh search issues` reads an eventually-consistent index whose
341
+ * catch-up latency (measured under 20s against this repo, Story #4657) is
342
+ * exactly wide enough to miss a byte-identical duplicate filed seconds
343
+ * earlier in the same rollup. A label-scoped `gh issue list … --state all`
344
+ * is strongly consistent, so it closes that window. The list is narrowed by
345
+ * the follow-up's own labels (supplied by the same `spec.buildFollowUp` that
346
+ * writes the marker, so the two agree by construction) to keep the read
347
+ * bounded, and the marker is matched as a substring of each returned body.
348
+ *
349
+ * Degrades to `false` (i.e. proceed to file) on any spawn/parse error — the
350
+ * deliberate degrade-toward-filing posture: an undecidable probe risks a
351
+ * duplicate rather than swallowing the finding.
352
+ *
353
+ * @param {object} opts
354
+ * @param {string} opts.marker — the content-hash marker embedded in the body.
355
+ * @param {string} opts.owner
356
+ * @param {string} opts.repo
357
+ * @param {string[]} [opts.labels] — the follow-up's labels; scopes the list.
358
+ * @param {string} [opts.ghPath]
359
+ * @param {Function} [opts.spawnImpl]
360
+ * @param {string} [opts.cwd]
361
+ * @param {number} [opts.timeoutMs]
362
+ * @returns {Promise<boolean>}
363
+ */
364
+ async function confirmMarkerFiled({
365
+ marker,
366
+ owner,
367
+ repo,
368
+ labels,
369
+ ghPath,
370
+ spawnImpl,
371
+ cwd,
372
+ timeoutMs,
373
+ }) {
374
+ const args = [
375
+ 'issue',
376
+ 'list',
377
+ '--repo',
378
+ `${owner}/${repo}`,
379
+ '--state',
380
+ 'all',
381
+ '--json',
382
+ 'number,body',
383
+ ];
384
+ for (const label of Array.isArray(labels) ? labels : []) {
385
+ args.push('--label', label);
386
+ }
387
+ const res = await runChild({ cmd: ghPath, args, spawnImpl, cwd, timeoutMs });
388
+ if (res.spawnError || (typeof res.code === 'number' && res.code !== 0)) {
389
+ return false;
390
+ }
391
+ try {
392
+ const parsed = JSON.parse(res.stdout || '[]');
393
+ if (!Array.isArray(parsed)) return false;
394
+ return parsed.some(
395
+ (issue) => typeof issue?.body === 'string' && issue.body.includes(marker),
396
+ );
397
+ } catch {
398
+ return false;
399
+ }
400
+ }
401
+
315
402
  /**
316
403
  * File a new follow-up issue via `gh issue create` and resolve to
317
404
  * `{ url, error }`. On success `url` is the trimmed stdout and `error` is
@@ -421,21 +508,22 @@ async function loadGraduateFindings({ epicId, provider, spec }) {
421
508
  /**
422
509
  * Probe whether a finding was already filed, checking both the current
423
510
  * content-hash marker AND the legacy `(epicId, parse-index)` marker so
424
- * findings filed before the fingerprint cutover are not re-filed. Returns
425
- * the content-hash marker (embedded in a freshly filed body) alongside the
426
- * `alreadyFiled` decision.
511
+ * findings filed before the fingerprint cutover are not re-filed. The
512
+ * content-hash marker is passed in precomputed so a caller can consult an
513
+ * in-process memo before spending a spawn. Returns the `alreadyFiled`
514
+ * decision.
427
515
  */
428
516
  async function resolveAlreadyFiled({
429
517
  finding,
430
518
  epicId,
431
519
  routedRepo,
520
+ contentMarker,
432
521
  ghPath,
433
522
  spawnImpl,
434
523
  cwd,
435
524
  timeoutMs,
436
525
  spec,
437
526
  }) {
438
- const contentMarker = spec.buildContentMarker(epicId, finding);
439
527
  const probe = (marker) =>
440
528
  probeMarkerExists({
441
529
  marker,
@@ -448,17 +536,17 @@ async function resolveAlreadyFiled({
448
536
  });
449
537
 
450
538
  if (await probe(contentMarker)) {
451
- return { alreadyFiled: true, contentMarker };
539
+ return { alreadyFiled: true };
452
540
  }
453
541
  // Legacy recognition — a pre-cutover follow-up carries the ordinal
454
542
  // marker, not the content hash. Skip re-filing when it is present.
455
543
  if (typeof spec.buildLegacyMarker === 'function') {
456
544
  const legacyMarker = spec.buildLegacyMarker(epicId, finding.index);
457
545
  if (legacyMarker && (await probe(legacyMarker))) {
458
- return { alreadyFiled: true, contentMarker };
546
+ return { alreadyFiled: true };
459
547
  }
460
548
  }
461
- return { alreadyFiled: false, contentMarker };
549
+ return { alreadyFiled: false };
462
550
  }
463
551
 
464
552
  /**
@@ -483,6 +571,7 @@ async function processGraduateFinding({
483
571
  timeoutMs,
484
572
  maxFilingsPerRun,
485
573
  crossRepoDeferred,
574
+ filedMarkers,
486
575
  logger,
487
576
  spec,
488
577
  }) {
@@ -531,10 +620,20 @@ async function processGraduateFinding({
531
620
  return skip('cross-repo-deferred');
532
621
  }
533
622
 
534
- const { alreadyFiled, contentMarker } = await resolveAlreadyFiled({
623
+ const contentMarker = spec.buildContentMarker(epicId, finding);
624
+
625
+ // In-process memo (Story #4657): a marker already filed earlier in THIS
626
+ // invocation — e.g. the framework bucket of a retro rollup that also has
627
+ // the same category in the consumer bucket — short-circuits a repeat in a
628
+ // later bucket without spending a single spawn, and closes the same-rollup
629
+ // race the eventually-consistent search index cannot.
630
+ if (filedMarkers?.has(contentMarker)) return skip('already-filed');
631
+
632
+ const { alreadyFiled } = await resolveAlreadyFiled({
535
633
  finding,
536
634
  epicId,
537
635
  routedRepo,
636
+ contentMarker,
538
637
  ghPath,
539
638
  spawnImpl,
540
639
  cwd,
@@ -548,12 +647,37 @@ async function processGraduateFinding({
548
647
  // a re-run picks it up next time.
549
648
  if (envelope.filed.length >= maxFilingsPerRun) return skip('cap-reached');
550
649
 
650
+ // Resolve the follow-up (title/body/labels) BEFORE the dedup decision so
651
+ // the strong read can scope its `gh issue list` by the very labels this
652
+ // filing would carry (they agree with the marker by construction).
551
653
  const { title, body, labels } = spec.buildFollowUp({
552
654
  finding,
553
655
  source,
554
656
  epicId,
555
657
  idMarker: contentMarker,
556
658
  });
659
+
660
+ // Strong read (would-file path only, Story #4657): the search probe reads
661
+ // an eventually-consistent index that can miss a byte-identical duplicate
662
+ // filed seconds earlier. Confirm against a strongly-consistent,
663
+ // label-scoped `gh issue list` before creating. Skipped entirely on the
664
+ // already-filed path above, so it never fires when the search probe
665
+ // already matched.
666
+ const confirmed = await confirmMarkerFiled({
667
+ marker: contentMarker,
668
+ owner: routedRepo.owner,
669
+ repo: routedRepo.repo,
670
+ labels,
671
+ ghPath,
672
+ spawnImpl,
673
+ cwd,
674
+ timeoutMs,
675
+ });
676
+ if (confirmed) {
677
+ filedMarkers?.add(contentMarker);
678
+ return skip('already-filed');
679
+ }
680
+
557
681
  const created = await createFollowUpIssue({
558
682
  owner: routedRepo.owner,
559
683
  repo: routedRepo.repo,
@@ -571,6 +695,7 @@ async function processGraduateFinding({
571
695
  );
572
696
  return;
573
697
  }
698
+ filedMarkers?.add(contentMarker);
574
699
  envelope.filed.push(
575
700
  decorate(
576
701
  {
@@ -681,6 +806,11 @@ async function persistCrossRepoDeferred({
681
806
  * @param {Array<object>} [opts.findings] — pre-parsed findings; when
682
807
  * provided, the structured-comment read/parse is bypassed (the retro
683
808
  * auto-filer seam).
809
+ * @param {Set<string>} [opts.filedMarkers] — in-process memo of content
810
+ * markers filed so far. Pass a shared Set across multiple `graduate()`
811
+ * calls in one logical invocation (e.g. the retro graduator's two source
812
+ * buckets) so a marker filed in one call short-circuits a repeat in the
813
+ * next without a spawn. Defaults to a fresh per-call Set.
684
814
  * @param {{info?: Function, warn?: Function, debug?: Function}} [opts.logger]
685
815
  * @param {object} opts.spec — the per-graduator behaviour bundle
686
816
  * @returns {Promise<{ filed: object[], skipped: object[], errors: string[] }>}
@@ -699,6 +829,7 @@ export async function graduate({
699
829
  timeoutMs = DEFAULT_RUN_CHILD_TIMEOUT_MS,
700
830
  maxFilingsPerRun = DEFAULT_MAX_FILINGS_PER_RUN,
701
831
  findings: preParsedFindings,
832
+ filedMarkers = new Set(),
702
833
  logger,
703
834
  spec,
704
835
  }) {
@@ -745,6 +876,7 @@ export async function graduate({
745
876
  timeoutMs,
746
877
  maxFilingsPerRun,
747
878
  crossRepoDeferred,
879
+ filedMarkers,
748
880
  logger,
749
881
  spec,
750
882
  });
@@ -54,7 +54,9 @@ export const isAutoFileEnabled = makeIsAutoFileEnabled('retroProposals');
54
54
  * are path-less and the rendered title embeds a mutable recurrence count)
55
55
  * so the marker is stable across sibling insert/remove/reorder churn AND
56
56
  * across re-runs that change the count. An HTML comment so it survives
57
- * markdown rendering but stays indexable via `gh search`.
57
+ * markdown rendering without leaking into the visible body; the idempotency
58
+ * probe strips the comment delimiters before querying `gh search` (the raw
59
+ * `<!-- … -->` form never matches the index — Story #4657).
58
60
  *
59
61
  * @param {number} epicId
60
62
  * @param {{ category?: string, title?: string }} finding
@@ -223,6 +225,13 @@ export async function graduateRetroProposals({
223
225
  { source: 'consumer', items: consumer },
224
226
  ];
225
227
 
228
+ // One memo of content markers filed so far, SHARED across both buckets: the
229
+ // two scopes mint an identical marker for the same category by construction
230
+ // (Story #4657), so without it the framework and consumer buckets could each
231
+ // file the same category. The shared set makes the second bucket short-
232
+ // circuit the repeat with no spawn.
233
+ const filedMarkers = new Set();
234
+
226
235
  let remaining = maxFilingsPerRun;
227
236
  for (const { source, items } of buckets) {
228
237
  if (items.length === 0) continue;
@@ -242,6 +251,7 @@ export async function graduateRetroProposals({
242
251
  timeoutMs,
243
252
  maxFilingsPerRun: Math.max(0, remaining),
244
253
  findings,
254
+ filedMarkers,
245
255
  logger,
246
256
  spec: makeSpec(source),
247
257
  });
@@ -45,7 +45,7 @@
45
45
  import crypto from 'node:crypto';
46
46
 
47
47
  import { Logger } from '../Logger.js';
48
- import { appendSignal } from './signals-writer.js';
48
+ import { appendSignal, forEachLine } from './signals-writer.js';
49
49
 
50
50
  /**
51
51
  * The friction categories this module emits.
@@ -202,19 +202,187 @@ export async function emitBlockRecoveredFriction({
202
202
  }
203
203
 
204
204
  /**
205
- * Pure predicate: is this signal a recovery-marked `story-blocked` record?
205
+ * Emit the recovery counterpart of an earlier friction record in `category`
206
+ * when a Story ultimately lands, netting a transient incident out of the
207
+ * retro (generalized from the `close-failed`-only emitter of Story #4649 by
208
+ * Story #4654).
209
+ *
210
+ * An incident that fires once — a transient block, a CI/lease/GitHub fault at
211
+ * close, a merge wait that outran one window — but is provably resolved by the
212
+ * time the Story lands still left an un-netted record on the stream, which the
213
+ * composer counts exactly like an incident that never recovered. This appends
214
+ * the companion record carrying the `recovered: true` discriminator in the
215
+ * **same** `category` (a distinct bucket would itself aggregate into a routable
216
+ * proposal, re-introducing the noise), so `netOutRecoveredIncidents` /
217
+ * `deriveUnresolvedBlockedEvents` in `retro-proposals.js` can cancel the whole
218
+ * `(category, storyId)` incident out.
219
+ *
220
+ * **Why this is not emitted from `frictionForTerminal`.** A `landed` terminal
221
+ * envelope is emitted at the very END of close — *after* the post-land tail
222
+ * has already gathered the signal stream and filed its follow-ups. A marker
223
+ * written there would arrive too late to net anything out of the run that
224
+ * produced it. So the emit hangs off `runPostLandTail`, which is the single
225
+ * shared land point (reached from both the in-close land and the standalone
226
+ * `single-story-confirm-merge.js` resume) and runs BEFORE follow-up capture.
227
+ * The tail runs *because* the PR merged, so every incident on the stream is
228
+ * provably resolved at that point.
229
+ *
230
+ * **Conditional on an actual incident.** The marker is appended only when the
231
+ * Story's stream already carries an un-recovered record in `category`. Emitting
232
+ * unconditionally on every land would write a category-mislabelled row for
233
+ * Stories that never had the incident — and, because the netting is per
234
+ * `(category, storyId)` over the cumulative stream, that spurious marker would
235
+ * suppress the Story's whole bucket for `category`, making it un-routable at
236
+ * story scope for a Story that never hit the incident at all.
237
+ *
238
+ * What this guard does NOT do is bound the netting once a *legitimate* marker
239
+ * exists. The netting inherits the Story #4622 coarsening — per
240
+ * `(category, storyId)` across the whole stream, not 1:1 pairing — so a
241
+ * later, genuinely un-landed record in `category` for a Story that already
242
+ * recovered once is still netted away, and does not even reach `discarded`.
243
+ * Reaching that needs a re-close after a land (a confirm-merge resume, or a
244
+ * close after a revert). Deliberate, inherited, and called out here rather
245
+ * than papered over: an aggregate is a routing heuristic, not an incident
246
+ * ledger.
247
+ *
248
+ * Best-effort; never throws. A read failure yields no marker (the incident
249
+ * stays counted) rather than a speculative write.
250
+ *
251
+ * @param {object} args
252
+ * @param {number} args.storyId
253
+ * @param {string} args.category One of {@link RUNTIME_FRICTION_CATEGORIES}.
254
+ * @param {string} [args.tool] Emitting surface (default `runPostLandTail`).
255
+ * @param {object} [args.config]
256
+ * @returns {Promise<boolean>} true when a record was appended.
257
+ */
258
+ export async function emitRecoveredFrictionMarker({
259
+ storyId,
260
+ category,
261
+ tool,
262
+ config,
263
+ } = {}) {
264
+ const sid = positiveIntOrNull(storyId);
265
+ if (sid === null) return false;
266
+ if (typeof category !== 'string' || category.trim() === '') {
267
+ Logger.warn(
268
+ '[runtime-friction] refusing to emit a category-less recovery marker',
269
+ );
270
+ return false;
271
+ }
272
+ const cat = category.trim();
273
+
274
+ let incident = false;
275
+ let recovered = false;
276
+ try {
277
+ await forEachLine(
278
+ null,
279
+ sid,
280
+ (parsed) => {
281
+ if (!parsed || typeof parsed !== 'object') return;
282
+ if (parsed.category !== cat) return;
283
+ if (isRecoveredSignal(parsed)) recovered = true;
284
+ else incident = true;
285
+ },
286
+ config,
287
+ );
288
+ } catch (err) {
289
+ Logger.warn(
290
+ `[runtime-friction] ${cat} recovery probe failed for Story #${sid}: ${
291
+ err instanceof Error ? err.message : String(err)
292
+ }`,
293
+ );
294
+ return false;
295
+ }
296
+ // Nothing to cancel, or already cancelled — a second marker would be noise.
297
+ if (!incident || recovered) return false;
298
+
299
+ return emitRuntimeFriction({
300
+ storyId: sid,
301
+ category: cat,
302
+ tool: tool || 'runPostLandTail',
303
+ details: { recovered: true },
304
+ config,
305
+ });
306
+ }
307
+
308
+ /**
309
+ * Emit the recovery counterpart of a `close-failed` record when a Story's
310
+ * close ultimately lands (Story #4649). Thin wrapper over
311
+ * {@link emitRecoveredFrictionMarker} bound to the `close-failed` category —
312
+ * retained as its own export so the post-land seam that injects it stays
313
+ * stable.
314
+ *
315
+ * @param {object} args
316
+ * @param {number} args.storyId
317
+ * @param {object} [args.config]
318
+ * @returns {Promise<boolean>} true when a record was appended.
319
+ */
320
+ export async function emitCloseRecoveredFriction({ storyId, config } = {}) {
321
+ return emitRecoveredFrictionMarker({
322
+ storyId,
323
+ category: RUNTIME_FRICTION_CATEGORIES.CLOSE_FAILED,
324
+ config,
325
+ });
326
+ }
327
+
328
+ /**
329
+ * Normalize one raw signals-stream row into the shape the retro composer
330
+ * consumes, or `null` when the row carries no usable category.
331
+ *
332
+ * Single-homed because BOTH production gathers need it identically —
333
+ * `gatherStoryFrictionSignals` (story scope) and `executeFollowUpRollup`
334
+ * (run scope) — and the bug this exists to prevent is precisely the two of
335
+ * them drifting: they each independently flattened rows to
336
+ * `{ category, source }`, dropping the `storyId` / `details` the composer's
337
+ * recovery-netting keys on, which left that netting unreachable on real data
338
+ * while its unit tests stayed green (Story #4649).
339
+ *
340
+ * The row's own `storyId` wins over `fallbackStoryId` so a stream carrying
341
+ * foreign rows attributes each one correctly; the fallback covers records
342
+ * written before the field existed.
343
+ *
344
+ * @param {unknown} parsed One parsed NDJSON row.
345
+ * @param {number} fallbackStoryId Stream owner, used when the row has none.
346
+ * @returns {{ category: string, source: 'framework'|'consumer', storyId: number, details: object }|null}
347
+ */
348
+ export function normalizeGatheredSignal(parsed, fallbackStoryId) {
349
+ if (!parsed || typeof parsed !== 'object') return null;
350
+ const category =
351
+ typeof parsed.category === 'string' ? parsed.category.trim() : '';
352
+ if (!category) return null;
353
+ const recordStoryId = Number(parsed.storyId);
354
+ return {
355
+ category,
356
+ source: parsed.source === 'framework' ? 'framework' : 'consumer',
357
+ storyId: Number.isInteger(recordStoryId) ? recordStoryId : fallbackStoryId,
358
+ details:
359
+ parsed.details && typeof parsed.details === 'object'
360
+ ? parsed.details
361
+ : {},
362
+ };
363
+ }
364
+
365
+ /**
366
+ * Pure predicate: is this signal a recovery marker for its own category?
206
367
  * Shared with the retro composer so the "recovered" discriminator is read
207
- * from one place. A record is a recovery marker when its category is
208
- * `story-blocked` and `details.recovered === true`.
368
+ * from one place.
369
+ *
370
+ * **Category-agnostic by design (Story #4649).** The predicate used to hard-
371
+ * code `story-blocked`, which meant every new category needing recovery
372
+ * semantics had to re-implement the netting. A record is a recovery marker
373
+ * when it carries a usable `category` and `details.recovered === true`; the
374
+ * composer nets per `(category, storyId)`, so a marker can only ever cancel
375
+ * records in its OWN bucket.
209
376
  *
210
377
  * @param {object} signal
211
378
  * @returns {boolean}
212
379
  */
213
- export function isRecoveredBlockSignal(signal) {
380
+ export function isRecoveredSignal(signal) {
214
381
  return (
215
382
  signal !== null &&
216
383
  typeof signal === 'object' &&
217
- signal.category === RUNTIME_FRICTION_CATEGORIES.STORY_BLOCKED &&
384
+ typeof signal.category === 'string' &&
385
+ signal.category.trim() !== '' &&
218
386
  signal.details !== null &&
219
387
  typeof signal.details === 'object' &&
220
388
  signal.details.recovered === true
@@ -237,12 +405,21 @@ export function isRecoveredBlockSignal(signal) {
237
405
  * would count the same block twice.
238
406
  * - `landed` → null. Nothing happened worth a retro.
239
407
  * - `failed` → friction. A close that ended non-zero.
240
- * - `pending` → friction **only when a `waitBudget` was exhausted**. That
241
- * is the parked worker from the report: a bounded wait expired with the
242
- * PR in flight and a human must resume it. A `pending` with **no**
243
- * `waitBudget` is the `--no-wait-merge` / operator-merge path, where the
244
- * human deliberately owns the land and nothing is broken — flagging it
245
- * would train operators to ignore the channel.
408
+ * - `pending` → friction **only when the cumulative wait budget is provably
409
+ * exhausted** (`waitBudget.cumulativeSeconds >= waitBudget.maxBudgetSeconds`).
410
+ * A `pending` return is reached at the per-invocation `maxWaitSeconds`
411
+ * bound (`phases/confirm-merge.js`); genuine cumulative exhaustion returns
412
+ * earlier as a **blocked** terminal via `blockOnUnlanded` (Story #4654).
413
+ * So a routine long-CI window rollover under budget is NOT exhaustion and
414
+ * emits nothing — its category name would otherwise assert an exhaustion
415
+ * that did not occur. Only the residual case the merge-wait guard cannot
416
+ * suppress — a merge that genuinely spends its whole budget and lands on a
417
+ * later resume — reaches here. A missing or non-numeric `cumulativeSeconds`
418
+ * / `maxBudgetSeconds` means exhaustion cannot be proven → emit nothing. A
419
+ * `pending` with **no** `waitBudget` is the `--no-wait-merge` /
420
+ * operator-merge path, where the human deliberately owns the land and
421
+ * nothing is broken — flagging it would train operators to ignore the
422
+ * channel.
246
423
  *
247
424
  * Deliberately **not exported**: it is this module's internal policy, and
248
425
  * `emitTerminalFriction` is the contract callers (and tests) exercise. An
@@ -267,6 +444,21 @@ function frictionForTerminal(envelope) {
267
444
  }
268
445
 
269
446
  if (status === 'pending' && waitBudget) {
447
+ // Only a PROVEN cumulative-budget exhaustion is friction. The `pending`
448
+ // return is reached at the per-invocation `maxWaitSeconds` bound, not at
449
+ // the cumulative `maxBudgetSeconds` (that returns a blocked terminal
450
+ // earlier), so a routine window rollover carries `cumulativeSeconds`
451
+ // still under budget — emit nothing. A missing/non-numeric field means
452
+ // exhaustion cannot be proven, which is likewise not a record.
453
+ const cumulativeSeconds = Number(waitBudget.cumulativeSeconds);
454
+ const maxBudgetSeconds = Number(waitBudget.maxBudgetSeconds);
455
+ if (
456
+ !Number.isFinite(cumulativeSeconds) ||
457
+ !Number.isFinite(maxBudgetSeconds) ||
458
+ cumulativeSeconds < maxBudgetSeconds
459
+ ) {
460
+ return null;
461
+ }
270
462
  return {
271
463
  category: RUNTIME_FRICTION_CATEGORIES.MERGE_WAIT_EXHAUSTED,
272
464
  details: {
@@ -19,10 +19,10 @@ import { selectAudits } from '../audit-suite/index.js';
19
19
  import { graduateRetroProposals } from '../feedback-loop/retro-proposals-graduator.js';
20
20
  import { gitSpawn } from '../git-utils.js';
21
21
  import { Logger } from '../Logger.js';
22
- import { forEachLine } from '../observability/signals-writer.js';
23
22
  import { composeRoutedProposals } from './retro-proposals.js';
24
23
  import {
25
24
  buildFollowUpsCommentBody,
25
+ gatherRunFrictionSignals,
26
26
  resolveFollowUpRepos,
27
27
  } from './story-follow-ups.js';
28
28
  import { upsertStructuredComment } from './ticketing.js';
@@ -551,24 +551,10 @@ async function executeFollowUpRollup({
551
551
  config,
552
552
  cwd,
553
553
  }) {
554
- const signals = [];
555
- for (const raw of stories) {
556
- const sid = Number(raw);
557
- if (!Number.isInteger(sid) || sid <= 0) continue;
558
- await forEachLine(
559
- null,
560
- sid,
561
- (parsed) => {
562
- if (!parsed || typeof parsed !== 'object') return;
563
- const category =
564
- typeof parsed.category === 'string' ? parsed.category.trim() : '';
565
- if (!category) return;
566
- const source = parsed.source === 'framework' ? 'framework' : 'consumer';
567
- signals.push({ category, source });
568
- },
569
- config,
570
- );
571
- }
554
+ // Shared with the story-scoped gather (Story #4649): `storyId` + `details`
555
+ // are what the composer's recovery-netting keys on, and two hand-rolled
556
+ // copies of this loop are how they got dropped in the first place.
557
+ const signals = await gatherRunFrictionSignals(stories, config);
572
558
  const repos = resolveFollowUpRepos(config);
573
559
  const primaryId = Number(stories[0]);
574
560
  const proposals = composeRoutedProposals({
@@ -33,6 +33,11 @@ import path from 'node:path';
33
33
 
34
34
  import { gitSpawn as defaultGitSpawn } from '../../../git-utils.js';
35
35
  import { Logger } from '../../../Logger.js';
36
+ import {
37
+ emitCloseRecoveredFriction as defaultEmitCloseRecoveredFriction,
38
+ emitRecoveredFrictionMarker as defaultEmitRecoveredFrictionMarker,
39
+ RUNTIME_FRICTION_CATEGORIES,
40
+ } from '../../../observability/runtime-friction.js';
36
41
  import { acquireLockWithWait as defaultAcquireLockWithWait } from '../../../single-story-sweep/sweep-lock.js';
37
42
  import {
38
43
  executeFastForward as defaultExecuteFastForward,
@@ -250,6 +255,8 @@ async function stepBaseFastForward({
250
255
  * @param {object} [args.config]
251
256
  * @param {(tag: string, msg: string) => void} [args.progress]
252
257
  * @param {Function} [args.captureStoryFollowUpsFn] Test seam.
258
+ * @param {Function} [args.emitCloseRecoveredFrictionFn] Test seam.
259
+ * @param {Function} [args.emitRecoveredFrictionMarkerFn] Test seam.
253
260
  * @param {Function} [args.reassertStatusColumnFn] Test seam.
254
261
  * @param {Function} [args.gitSpawnFn] Test seam.
255
262
  * @param {Function} [args.planFastForwardFn] Test seam.
@@ -266,6 +273,8 @@ export async function runPostLandTail({
266
273
  config,
267
274
  progress,
268
275
  captureStoryFollowUpsFn = defaultCaptureStoryFollowUps,
276
+ emitCloseRecoveredFrictionFn = defaultEmitCloseRecoveredFriction,
277
+ emitRecoveredFrictionMarkerFn = defaultEmitRecoveredFrictionMarker,
269
278
  reassertStatusColumnFn = defaultReassertStatusColumn,
270
279
  gitSpawnFn = defaultGitSpawn,
271
280
  planFastForwardFn = defaultPlanFastForward,
@@ -274,6 +283,32 @@ export async function runPostLandTail({
274
283
  }) {
275
284
  progress?.('POST-LAND', `🧾 Running land tail for Story #${storyId}...`);
276
285
 
286
+ // The close landed, so every friction incident on this Story's stream is
287
+ // provably resolved. Emit the recovery markers BEFORE follow-up capture
288
+ // reads the stream: the `landed` terminal envelope is emitted after this
289
+ // whole tail, so a marker written there would arrive too late to net
290
+ // anything out of the very run that produced the incident. Each emit is
291
+ // conditional on an un-recovered record already present, so a Story that
292
+ // never hit the incident gets no spurious (and bucket-suppressing) row.
293
+ // - `close-failed` — Story #4649.
294
+ // - `story-blocked` — a Story that blocked then reached
295
+ // `agent::done` (Story #4654); resolves the occurrence-1 force-file.
296
+ // - `merge-wait-exhausted` — a merge that spent its whole budget and
297
+ // landed on a later resume (Story #4654); the residual case the
298
+ // `frictionForTerminal` budget guard cannot suppress at the source.
299
+ // Best-effort and never throws, exactly like every other tail step.
300
+ await emitCloseRecoveredFrictionFn({ storyId, config });
301
+ await emitRecoveredFrictionMarkerFn({
302
+ storyId,
303
+ category: RUNTIME_FRICTION_CATEGORIES.STORY_BLOCKED,
304
+ config,
305
+ });
306
+ await emitRecoveredFrictionMarkerFn({
307
+ storyId,
308
+ category: RUNTIME_FRICTION_CATEGORIES.MERGE_WAIT_EXHAUSTED,
309
+ config,
310
+ });
311
+
277
312
  const followUps = await step(
278
313
  () =>
279
314
  stepFollowUps({
@@ -12,8 +12,12 @@
12
12
  import { graduateRetroProposals } from '../feedback-loop/retro-proposals-graduator.js';
13
13
  import { DEFAULT_FRAMEWORK_REPO } from '../github/framework-repo.js';
14
14
  import { Logger } from '../Logger.js';
15
+ import { normalizeGatheredSignal } from '../observability/runtime-friction.js';
15
16
  import { forEachLine } from '../observability/signals-writer.js';
16
- import { composeRoutedProposals } from './retro-proposals.js';
17
+ import {
18
+ composeRoutedProposals,
19
+ deriveUnresolvedBlockedEvents,
20
+ } from './retro-proposals.js';
17
21
  import { upsertStructuredComment } from './ticketing.js';
18
22
 
19
23
  export const FOLLOW_UPS_COMMENT_TYPE = 'follow-ups';
@@ -46,9 +50,23 @@ export function resolveFollowUpRepos(config) {
46
50
  }
47
51
 
48
52
  /**
53
+ * Gather the Story's friction signals for the composer.
54
+ *
55
+ * **`storyId` and `details` are load-bearing (Story #4649).** This function
56
+ * used to flatten every record to `{ category, source }`, which silently
57
+ * dropped exactly the two fields `netOutRecoveredIncidents` keys on — so the
58
+ * Story #4622 recovery-netting could never fire on real data, and every
59
+ * transient friction event survived to be auto-filed. The composer's unit
60
+ * tests passed throughout, because they fed it synthetic signals carrying
61
+ * both fields that no production path ever produced. Preserve them.
62
+ *
63
+ * The record's own `storyId` is preferred over the argument so a stream that
64
+ * carries foreign rows attributes each one correctly; the argument is the
65
+ * fallback for records written before the field existed.
66
+ *
49
67
  * @param {number} storyId
50
68
  * @param {object} [config]
51
- * @returns {Promise<Array<{ category: string, source: 'framework'|'consumer' }>>}
69
+ * @returns {Promise<Array<{ category: string, source: 'framework'|'consumer', storyId: number, details: object }>>}
52
70
  */
53
71
  export async function gatherStoryFrictionSignals(storyId, config) {
54
72
  const signals = [];
@@ -56,22 +74,40 @@ export async function gatherStoryFrictionSignals(storyId, config) {
56
74
  null,
57
75
  storyId,
58
76
  (parsed) => {
59
- if (!parsed || typeof parsed !== 'object') return;
60
- const kind = parsed.kind;
61
- if (kind !== 'friction' && kind !== undefined) {
62
- // Prefer friction records; also accept category-bearing rows.
63
- }
64
- const category =
65
- typeof parsed.category === 'string' ? parsed.category.trim() : '';
66
- if (!category) return;
67
- const source = parsed.source === 'framework' ? 'framework' : 'consumer';
68
- signals.push({ category, source });
77
+ const signal = normalizeGatheredSignal(parsed, storyId);
78
+ if (signal) signals.push(signal);
69
79
  },
70
80
  config,
71
81
  );
72
82
  return signals;
73
83
  }
74
84
 
85
+ /**
86
+ * Gather friction signals across every Story in a run, for the run-scoped
87
+ * roll-up.
88
+ *
89
+ * Homed beside {@link gatherStoryFrictionSignals} on purpose: the two used to
90
+ * be independent copies of the same loop in two modules, and they drifted in
91
+ * exactly the way that made the recovery-netting unreachable (Story #4649).
92
+ * One reader, one normalizer, no second place to forget a field.
93
+ *
94
+ * Unusable ids are skipped rather than throwing — a roll-up must not fail the
95
+ * epilogue over one malformed entry.
96
+ *
97
+ * @param {Array<number|string>} storyIds
98
+ * @param {object} [config]
99
+ * @returns {Promise<Array<{ category: string, source: 'framework'|'consumer', storyId: number, details: object }>>}
100
+ */
101
+ export async function gatherRunFrictionSignals(storyIds, config) {
102
+ const signals = [];
103
+ for (const raw of Array.isArray(storyIds) ? storyIds : []) {
104
+ const sid = Number(raw);
105
+ if (!Number.isInteger(sid) || sid <= 0) continue;
106
+ signals.push(...(await gatherStoryFrictionSignals(sid, config)));
107
+ }
108
+ return signals;
109
+ }
110
+
75
111
  /**
76
112
  * Render the empty-roll-up line.
77
113
  *
@@ -250,7 +286,11 @@ export async function captureStoryFollowUps({
250
286
  frameworkRepo: repos.frameworkRepo,
251
287
  consumerRepo: repos.consumerRepo,
252
288
  signals,
253
- unresolvedBlockedEvents: [],
289
+ // Derived, not hardcoded `[]` (Story #4649). This is the escape hatch
290
+ // the retired story-scope threshold carve-out was standing in for: a
291
+ // Story still parked at `agent::blocked` files at a single occurrence,
292
+ // while one that blocked and self-resolved nets out entirely.
293
+ unresolvedBlockedEvents: deriveUnresolvedBlockedEvents(signals),
254
294
  });
255
295
  const graduated = await graduateRetroProposals({
256
296
  epicId: sid,
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [2.7.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.6.0...mandrel-v2.7.0) (2026-07-21)
6
+
7
+
8
+ ### Fixed
9
+
10
+ * **feedback-loop:** repair the graduator idempotency probe — an HTML-comment-wrapped search query never matches, so every rollup re-files ([#4657](https://github.com/dsj1984/mandrel/issues/4657)) ([#4661](https://github.com/dsj1984/mandrel/issues/4661)) ([9bb9d3a](https://github.com/dsj1984/mandrel/commit/9bb9d3ad674dafb11bcd2fbce771a81b79e8cd51))
11
+ * **retro:** generalize post-land recovery marking and gate merge-wait-exhausted on real budget exhaustion (refs [#4654](https://github.com/dsj1984/mandrel/issues/4654)) ([#4658](https://github.com/dsj1984/mandrel/issues/4658)) ([54c45ae](https://github.com/dsj1984/mandrel/commit/54c45ae4f522688e018c092da0137cc6562e0c73))
12
+
13
+ ## [2.6.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.5.0...mandrel-v2.6.0) (2026-07-20)
14
+
15
+
16
+ ### Fixed
17
+
18
+ * **retro:** generalize friction recovery-netting, preserve signal fields on gather, and retire the story-scope singleton auto-file ([#4649](https://github.com/dsj1984/mandrel/issues/4649)) ([#4650](https://github.com/dsj1984/mandrel/issues/4650)) ([bdb8250](https://github.com/dsj1984/mandrel/commit/bdb82507d188bfe53bbd47fea7b18b2c3160439c))
19
+
5
20
  ## [2.5.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.4.0...mandrel-v2.5.0) (2026-07-19)
6
21
 
7
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.5.0",
3
+ "version": "2.7.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/",