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.
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +7 -2
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +144 -12
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +11 -1
- package/.agents/scripts/lib/observability/runtime-friction.js +204 -12
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +5 -19
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +35 -0
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +53 -13
- package/docs/CHANGELOG.md +15 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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
|
|
280
|
-
*
|
|
281
|
-
*
|
|
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.
|
|
425
|
-
*
|
|
426
|
-
* `alreadyFiled`
|
|
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
|
|
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
|
|
546
|
+
return { alreadyFiled: true };
|
|
459
547
|
}
|
|
460
548
|
}
|
|
461
|
-
return { alreadyFiled: false
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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.
|
|
208
|
-
*
|
|
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
|
|
380
|
+
export function isRecoveredSignal(signal) {
|
|
214
381
|
return (
|
|
215
382
|
signal !== null &&
|
|
216
383
|
typeof signal === 'object' &&
|
|
217
|
-
signal.category ===
|
|
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
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
* `
|
|
244
|
-
*
|
|
245
|
-
*
|
|
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: {
|
|
Binary file
|
|
@@ -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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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 {
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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