mandrel 2.6.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
  });
@@ -202,16 +202,20 @@ export async function emitBlockRecoveredFriction({
202
202
  }
203
203
 
204
204
  /**
205
- * Emit the recovery counterpart of a `close-failed` record when a Story's
206
- * close ultimately lands (Story #4649).
207
- *
208
- * A close that fails once — CI/lease contention, a transient GitHub fault —
209
- * and succeeds on a later attempt still fired a `close-failed` record at the
210
- * failed terminal, which the composer counts exactly like a close that never
211
- * recovered. This is the `close-failed` analogue of
212
- * {@link emitBlockRecoveredFriction}: same `recovered: true` discriminator,
213
- * same category (a distinct bucket would itself aggregate into a routable
214
- * proposal, re-introducing the noise).
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.
215
219
  *
216
220
  * **Why this is not emitted from `frictionForTerminal`.** A `landed` terminal
217
221
  * envelope is emitted at the very END of close — *after* the post-land tail
@@ -220,39 +224,54 @@ export async function emitBlockRecoveredFriction({
220
224
  * produced it. So the emit hangs off `runPostLandTail`, which is the single
221
225
  * shared land point (reached from both the in-close land and the standalone
222
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.
223
229
  *
224
- * **Conditional on an actual failure.** The marker is appended only when the
225
- * Story's stream already carries an un-recovered `close-failed`. Emitting
226
- * unconditionally on every land would write a `close-failed` row for Stories
227
- * whose close never failed a category-mislabelled record — and, because the
228
- * netting is per `(category, storyId)` over the cumulative stream, that
229
- * spurious marker would suppress the Story's `close-failed` bucket outright,
230
- * making the category un-routable at story scope for a Story that never had
231
- * a close failure at all.
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.
232
237
  *
233
238
  * What this guard does NOT do is bound the netting once a *legitimate* marker
234
239
  * exists. The netting inherits the Story #4622 coarsening — per
235
240
  * `(category, storyId)` across the whole stream, not 1:1 pairing — so a
236
- * later, genuinely un-landed `close-failed` for a Story that already
241
+ * later, genuinely un-landed record in `category` for a Story that already
237
242
  * recovered once is still netted away, and does not even reach `discarded`.
238
243
  * Reaching that needs a re-close after a land (a confirm-merge resume, or a
239
244
  * close after a revert). Deliberate, inherited, and called out here rather
240
245
  * than papered over: an aggregate is a routing heuristic, not an incident
241
246
  * ledger.
242
247
  *
243
- * Best-effort; never throws. A read failure yields no marker (the failure
248
+ * Best-effort; never throws. A read failure yields no marker (the incident
244
249
  * stays counted) rather than a speculative write.
245
250
  *
246
251
  * @param {object} args
247
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`).
248
255
  * @param {object} [args.config]
249
256
  * @returns {Promise<boolean>} true when a record was appended.
250
257
  */
251
- export async function emitCloseRecoveredFriction({ storyId, config } = {}) {
258
+ export async function emitRecoveredFrictionMarker({
259
+ storyId,
260
+ category,
261
+ tool,
262
+ config,
263
+ } = {}) {
252
264
  const sid = positiveIntOrNull(storyId);
253
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();
254
273
 
255
- let failed = false;
274
+ let incident = false;
256
275
  let recovered = false;
257
276
  try {
258
277
  await forEachLine(
@@ -260,34 +279,52 @@ export async function emitCloseRecoveredFriction({ storyId, config } = {}) {
260
279
  sid,
261
280
  (parsed) => {
262
281
  if (!parsed || typeof parsed !== 'object') return;
263
- if (parsed.category !== RUNTIME_FRICTION_CATEGORIES.CLOSE_FAILED) {
264
- return;
265
- }
282
+ if (parsed.category !== cat) return;
266
283
  if (isRecoveredSignal(parsed)) recovered = true;
267
- else failed = true;
284
+ else incident = true;
268
285
  },
269
286
  config,
270
287
  );
271
288
  } catch (err) {
272
289
  Logger.warn(
273
- `[runtime-friction] close-recovery probe failed for Story #${sid}: ${
290
+ `[runtime-friction] ${cat} recovery probe failed for Story #${sid}: ${
274
291
  err instanceof Error ? err.message : String(err)
275
292
  }`,
276
293
  );
277
294
  return false;
278
295
  }
279
296
  // Nothing to cancel, or already cancelled — a second marker would be noise.
280
- if (!failed || recovered) return false;
297
+ if (!incident || recovered) return false;
281
298
 
282
299
  return emitRuntimeFriction({
283
300
  storyId: sid,
284
- category: RUNTIME_FRICTION_CATEGORIES.CLOSE_FAILED,
285
- tool: 'runPostLandTail',
301
+ category: cat,
302
+ tool: tool || 'runPostLandTail',
286
303
  details: { recovered: true },
287
304
  config,
288
305
  });
289
306
  }
290
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
+
291
328
  /**
292
329
  * Normalize one raw signals-stream row into the shape the retro composer
293
330
  * consumes, or `null` when the row carries no usable category.
@@ -368,12 +405,21 @@ export function isRecoveredSignal(signal) {
368
405
  * would count the same block twice.
369
406
  * - `landed` → null. Nothing happened worth a retro.
370
407
  * - `failed` → friction. A close that ended non-zero.
371
- * - `pending` → friction **only when a `waitBudget` was exhausted**. That
372
- * is the parked worker from the report: a bounded wait expired with the
373
- * PR in flight and a human must resume it. A `pending` with **no**
374
- * `waitBudget` is the `--no-wait-merge` / operator-merge path, where the
375
- * human deliberately owns the land and nothing is broken — flagging it
376
- * 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.
377
423
  *
378
424
  * Deliberately **not exported**: it is this module's internal policy, and
379
425
  * `emitTerminalFriction` is the contract callers (and tests) exercise. An
@@ -398,6 +444,21 @@ function frictionForTerminal(envelope) {
398
444
  }
399
445
 
400
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
+ }
401
462
  return {
402
463
  category: RUNTIME_FRICTION_CATEGORIES.MERGE_WAIT_EXHAUSTED,
403
464
  details: {
@@ -33,7 +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 { emitCloseRecoveredFriction as defaultEmitCloseRecoveredFriction } from '../../../observability/runtime-friction.js';
36
+ import {
37
+ emitCloseRecoveredFriction as defaultEmitCloseRecoveredFriction,
38
+ emitRecoveredFrictionMarker as defaultEmitRecoveredFrictionMarker,
39
+ RUNTIME_FRICTION_CATEGORIES,
40
+ } from '../../../observability/runtime-friction.js';
37
41
  import { acquireLockWithWait as defaultAcquireLockWithWait } from '../../../single-story-sweep/sweep-lock.js';
38
42
  import {
39
43
  executeFastForward as defaultExecuteFastForward,
@@ -252,6 +256,7 @@ async function stepBaseFastForward({
252
256
  * @param {(tag: string, msg: string) => void} [args.progress]
253
257
  * @param {Function} [args.captureStoryFollowUpsFn] Test seam.
254
258
  * @param {Function} [args.emitCloseRecoveredFrictionFn] Test seam.
259
+ * @param {Function} [args.emitRecoveredFrictionMarkerFn] Test seam.
255
260
  * @param {Function} [args.reassertStatusColumnFn] Test seam.
256
261
  * @param {Function} [args.gitSpawnFn] Test seam.
257
262
  * @param {Function} [args.planFastForwardFn] Test seam.
@@ -269,6 +274,7 @@ export async function runPostLandTail({
269
274
  progress,
270
275
  captureStoryFollowUpsFn = defaultCaptureStoryFollowUps,
271
276
  emitCloseRecoveredFrictionFn = defaultEmitCloseRecoveredFriction,
277
+ emitRecoveredFrictionMarkerFn = defaultEmitRecoveredFrictionMarker,
272
278
  reassertStatusColumnFn = defaultReassertStatusColumn,
273
279
  gitSpawnFn = defaultGitSpawn,
274
280
  planFastForwardFn = defaultPlanFastForward,
@@ -277,13 +283,31 @@ export async function runPostLandTail({
277
283
  }) {
278
284
  progress?.('POST-LAND', `🧾 Running land tail for Story #${storyId}...`);
279
285
 
280
- // Story #4649 — the close landed, so any earlier `close-failed` for this
281
- // Story was transient. Emit the recovery marker BEFORE follow-up capture
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
282
288
  // reads the stream: the `landed` terminal envelope is emitted after this
283
289
  // whole tail, so a marker written there would arrive too late to net
284
- // anything out of the very run that produced the failure. Best-effort and
285
- // never throws, exactly like every other tail step.
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.
286
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
+ });
287
311
 
288
312
  const followUps = await step(
289
313
  () =>
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
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
+
5
13
  ## [2.6.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.5.0...mandrel-v2.6.0) (2026-07-20)
6
14
 
7
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.6.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/",