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.
- 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 +97 -36
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +29 -5
- package/docs/CHANGELOG.md +8 -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
|
});
|
|
@@ -202,16 +202,20 @@ export async function emitBlockRecoveredFriction({
|
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
/**
|
|
205
|
-
* Emit the recovery counterpart of
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
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
|
|
225
|
-
* Story's stream already carries an un-recovered `
|
|
226
|
-
* unconditionally on every land would write a
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
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 `
|
|
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
|
|
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
|
|
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
|
|
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 !==
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
282
|
+
if (parsed.category !== cat) return;
|
|
266
283
|
if (isRecoveredSignal(parsed)) recovered = true;
|
|
267
|
-
else
|
|
284
|
+
else incident = true;
|
|
268
285
|
},
|
|
269
286
|
config,
|
|
270
287
|
);
|
|
271
288
|
} catch (err) {
|
|
272
289
|
Logger.warn(
|
|
273
|
-
`[runtime-friction]
|
|
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 (!
|
|
297
|
+
if (!incident || recovered) return false;
|
|
281
298
|
|
|
282
299
|
return emitRuntimeFriction({
|
|
283
300
|
storyId: sid,
|
|
284
|
-
category:
|
|
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
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
* `
|
|
375
|
-
*
|
|
376
|
-
*
|
|
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 {
|
|
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
|
-
//
|
|
281
|
-
//
|
|
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
|
|
285
|
-
//
|
|
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