mandrel 2.5.0 → 2.6.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/observability/runtime-friction.js +137 -6
- 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 +11 -0
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +53 -13
- package/docs/CHANGELOG.md +7 -0
- package/package.json +1 -1
|
@@ -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,150 @@ export async function emitBlockRecoveredFriction({
|
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
/**
|
|
205
|
-
*
|
|
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).
|
|
215
|
+
*
|
|
216
|
+
* **Why this is not emitted from `frictionForTerminal`.** A `landed` terminal
|
|
217
|
+
* envelope is emitted at the very END of close — *after* the post-land tail
|
|
218
|
+
* has already gathered the signal stream and filed its follow-ups. A marker
|
|
219
|
+
* written there would arrive too late to net anything out of the run that
|
|
220
|
+
* produced it. So the emit hangs off `runPostLandTail`, which is the single
|
|
221
|
+
* shared land point (reached from both the in-close land and the standalone
|
|
222
|
+
* `single-story-confirm-merge.js` resume) and runs BEFORE follow-up capture.
|
|
223
|
+
*
|
|
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.
|
|
232
|
+
*
|
|
233
|
+
* What this guard does NOT do is bound the netting once a *legitimate* marker
|
|
234
|
+
* exists. The netting inherits the Story #4622 coarsening — per
|
|
235
|
+
* `(category, storyId)` across the whole stream, not 1:1 pairing — so a
|
|
236
|
+
* later, genuinely un-landed `close-failed` for a Story that already
|
|
237
|
+
* recovered once is still netted away, and does not even reach `discarded`.
|
|
238
|
+
* Reaching that needs a re-close after a land (a confirm-merge resume, or a
|
|
239
|
+
* close after a revert). Deliberate, inherited, and called out here rather
|
|
240
|
+
* than papered over: an aggregate is a routing heuristic, not an incident
|
|
241
|
+
* ledger.
|
|
242
|
+
*
|
|
243
|
+
* Best-effort; never throws. A read failure yields no marker (the failure
|
|
244
|
+
* stays counted) rather than a speculative write.
|
|
245
|
+
*
|
|
246
|
+
* @param {object} args
|
|
247
|
+
* @param {number} args.storyId
|
|
248
|
+
* @param {object} [args.config]
|
|
249
|
+
* @returns {Promise<boolean>} true when a record was appended.
|
|
250
|
+
*/
|
|
251
|
+
export async function emitCloseRecoveredFriction({ storyId, config } = {}) {
|
|
252
|
+
const sid = positiveIntOrNull(storyId);
|
|
253
|
+
if (sid === null) return false;
|
|
254
|
+
|
|
255
|
+
let failed = false;
|
|
256
|
+
let recovered = false;
|
|
257
|
+
try {
|
|
258
|
+
await forEachLine(
|
|
259
|
+
null,
|
|
260
|
+
sid,
|
|
261
|
+
(parsed) => {
|
|
262
|
+
if (!parsed || typeof parsed !== 'object') return;
|
|
263
|
+
if (parsed.category !== RUNTIME_FRICTION_CATEGORIES.CLOSE_FAILED) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (isRecoveredSignal(parsed)) recovered = true;
|
|
267
|
+
else failed = true;
|
|
268
|
+
},
|
|
269
|
+
config,
|
|
270
|
+
);
|
|
271
|
+
} catch (err) {
|
|
272
|
+
Logger.warn(
|
|
273
|
+
`[runtime-friction] close-recovery probe failed for Story #${sid}: ${
|
|
274
|
+
err instanceof Error ? err.message : String(err)
|
|
275
|
+
}`,
|
|
276
|
+
);
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
// Nothing to cancel, or already cancelled — a second marker would be noise.
|
|
280
|
+
if (!failed || recovered) return false;
|
|
281
|
+
|
|
282
|
+
return emitRuntimeFriction({
|
|
283
|
+
storyId: sid,
|
|
284
|
+
category: RUNTIME_FRICTION_CATEGORIES.CLOSE_FAILED,
|
|
285
|
+
tool: 'runPostLandTail',
|
|
286
|
+
details: { recovered: true },
|
|
287
|
+
config,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Normalize one raw signals-stream row into the shape the retro composer
|
|
293
|
+
* consumes, or `null` when the row carries no usable category.
|
|
294
|
+
*
|
|
295
|
+
* Single-homed because BOTH production gathers need it identically —
|
|
296
|
+
* `gatherStoryFrictionSignals` (story scope) and `executeFollowUpRollup`
|
|
297
|
+
* (run scope) — and the bug this exists to prevent is precisely the two of
|
|
298
|
+
* them drifting: they each independently flattened rows to
|
|
299
|
+
* `{ category, source }`, dropping the `storyId` / `details` the composer's
|
|
300
|
+
* recovery-netting keys on, which left that netting unreachable on real data
|
|
301
|
+
* while its unit tests stayed green (Story #4649).
|
|
302
|
+
*
|
|
303
|
+
* The row's own `storyId` wins over `fallbackStoryId` so a stream carrying
|
|
304
|
+
* foreign rows attributes each one correctly; the fallback covers records
|
|
305
|
+
* written before the field existed.
|
|
306
|
+
*
|
|
307
|
+
* @param {unknown} parsed One parsed NDJSON row.
|
|
308
|
+
* @param {number} fallbackStoryId Stream owner, used when the row has none.
|
|
309
|
+
* @returns {{ category: string, source: 'framework'|'consumer', storyId: number, details: object }|null}
|
|
310
|
+
*/
|
|
311
|
+
export function normalizeGatheredSignal(parsed, fallbackStoryId) {
|
|
312
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
313
|
+
const category =
|
|
314
|
+
typeof parsed.category === 'string' ? parsed.category.trim() : '';
|
|
315
|
+
if (!category) return null;
|
|
316
|
+
const recordStoryId = Number(parsed.storyId);
|
|
317
|
+
return {
|
|
318
|
+
category,
|
|
319
|
+
source: parsed.source === 'framework' ? 'framework' : 'consumer',
|
|
320
|
+
storyId: Number.isInteger(recordStoryId) ? recordStoryId : fallbackStoryId,
|
|
321
|
+
details:
|
|
322
|
+
parsed.details && typeof parsed.details === 'object'
|
|
323
|
+
? parsed.details
|
|
324
|
+
: {},
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Pure predicate: is this signal a recovery marker for its own category?
|
|
206
330
|
* Shared with the retro composer so the "recovered" discriminator is read
|
|
207
|
-
* from one place.
|
|
208
|
-
*
|
|
331
|
+
* from one place.
|
|
332
|
+
*
|
|
333
|
+
* **Category-agnostic by design (Story #4649).** The predicate used to hard-
|
|
334
|
+
* code `story-blocked`, which meant every new category needing recovery
|
|
335
|
+
* semantics had to re-implement the netting. A record is a recovery marker
|
|
336
|
+
* when it carries a usable `category` and `details.recovered === true`; the
|
|
337
|
+
* composer nets per `(category, storyId)`, so a marker can only ever cancel
|
|
338
|
+
* records in its OWN bucket.
|
|
209
339
|
*
|
|
210
340
|
* @param {object} signal
|
|
211
341
|
* @returns {boolean}
|
|
212
342
|
*/
|
|
213
|
-
export function
|
|
343
|
+
export function isRecoveredSignal(signal) {
|
|
214
344
|
return (
|
|
215
345
|
signal !== null &&
|
|
216
346
|
typeof signal === 'object' &&
|
|
217
|
-
signal.category ===
|
|
347
|
+
typeof signal.category === 'string' &&
|
|
348
|
+
signal.category.trim() !== '' &&
|
|
218
349
|
signal.details !== null &&
|
|
219
350
|
typeof signal.details === 'object' &&
|
|
220
351
|
signal.details.recovered === true
|
|
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,7 @@ 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
37
|
import { acquireLockWithWait as defaultAcquireLockWithWait } from '../../../single-story-sweep/sweep-lock.js';
|
|
37
38
|
import {
|
|
38
39
|
executeFastForward as defaultExecuteFastForward,
|
|
@@ -250,6 +251,7 @@ async function stepBaseFastForward({
|
|
|
250
251
|
* @param {object} [args.config]
|
|
251
252
|
* @param {(tag: string, msg: string) => void} [args.progress]
|
|
252
253
|
* @param {Function} [args.captureStoryFollowUpsFn] Test seam.
|
|
254
|
+
* @param {Function} [args.emitCloseRecoveredFrictionFn] Test seam.
|
|
253
255
|
* @param {Function} [args.reassertStatusColumnFn] Test seam.
|
|
254
256
|
* @param {Function} [args.gitSpawnFn] Test seam.
|
|
255
257
|
* @param {Function} [args.planFastForwardFn] Test seam.
|
|
@@ -266,6 +268,7 @@ export async function runPostLandTail({
|
|
|
266
268
|
config,
|
|
267
269
|
progress,
|
|
268
270
|
captureStoryFollowUpsFn = defaultCaptureStoryFollowUps,
|
|
271
|
+
emitCloseRecoveredFrictionFn = defaultEmitCloseRecoveredFriction,
|
|
269
272
|
reassertStatusColumnFn = defaultReassertStatusColumn,
|
|
270
273
|
gitSpawnFn = defaultGitSpawn,
|
|
271
274
|
planFastForwardFn = defaultPlanFastForward,
|
|
@@ -274,6 +277,14 @@ export async function runPostLandTail({
|
|
|
274
277
|
}) {
|
|
275
278
|
progress?.('POST-LAND', `🧾 Running land tail for Story #${storyId}...`);
|
|
276
279
|
|
|
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
|
|
282
|
+
// reads the stream: the `landed` terminal envelope is emitted after this
|
|
283
|
+
// 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.
|
|
286
|
+
await emitCloseRecoveredFrictionFn({ storyId, config });
|
|
287
|
+
|
|
277
288
|
const followUps = await step(
|
|
278
289
|
() =>
|
|
279
290
|
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,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [2.6.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.5.0...mandrel-v2.6.0) (2026-07-20)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
* **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))
|
|
11
|
+
|
|
5
12
|
## [2.5.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.4.0...mandrel-v2.5.0) (2026-07-19)
|
|
6
13
|
|
|
7
14
|
|
package/package.json
CHANGED