mandrel 2.32.0 → 2.34.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/docs/SDLC.md +8 -5
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +3 -2
- package/.agents/runtime-deps.json +2 -1
- package/.agents/schemas/agentrc.schema.json +8 -2
- package/.agents/scripts/README.md +9 -0
- package/.agents/scripts/audit-to-stories.js +160 -41
- package/.agents/scripts/check-knip-entries.js +47 -24
- package/.agents/scripts/check-lifecycle-lint.js +72 -12
- package/.agents/scripts/coverage-capture.js +7 -1
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +81 -34
- package/.agents/scripts/lib/audit-to-stories/wire-dependencies.js +185 -0
- package/.agents/scripts/lib/baselines/kernel.js +20 -7
- package/.agents/scripts/lib/baselines/kinds/mutation.js +144 -14
- package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +10 -7
- package/.agents/scripts/lib/config/quality.js +7 -0
- package/.agents/scripts/lib/config/runners.js +38 -16
- package/.agents/scripts/lib/config-settings-schema-delivery.js +10 -2
- package/.agents/scripts/lib/coverage-capture-incremental.js +9 -2
- package/.agents/scripts/lib/coverage-capture-usage.js +55 -0
- package/.agents/scripts/lib/coverage-capture.js +10 -15
- package/.agents/scripts/lib/dependency-parser.js +20 -7
- package/.agents/scripts/lib/findings/provenance-field.js +135 -0
- package/.agents/scripts/lib/findings/route-finding.js +57 -8
- package/.agents/scripts/lib/knip-config-resolver.js +181 -0
- package/.agents/scripts/lib/knip-entry-sync.js +78 -39
- package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +1 -26
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +69 -5
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +93 -19
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +49 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +72 -35
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +116 -1
- package/.agents/scripts/lib/orchestration/ticket-validator.js +38 -0
- package/.agents/scripts/lib/story-body/footer-block.js +97 -0
- package/.agents/scripts/lib/story-body/story-body.js +6 -22
- package/.agents/scripts/lib/wave-runner/footprint.js +306 -0
- package/.agents/scripts/lib/wave-runner/ready-set.js +198 -181
- package/.agents/scripts/providers/github/blocked-by-add.js +25 -10
- package/.agents/scripts/resolve-stories.js +21 -5
- package/.agents/scripts/stories-wave-tick.js +192 -9
- package/.agents/workflows/audit-to-stories.md +26 -0
- package/.agents/workflows/helpers/deliver-light.md +5 -2
- package/.agents/workflows/helpers/deliver-reference.md +28 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +80 -1
- package/.agents/workflows/helpers/deliver-story.md +4 -2
- package/.agents/workflows/helpers/plan-reference.md +76 -0
- package/docs/CHANGELOG.md +26 -0
- package/package.json +3 -3
|
@@ -61,6 +61,35 @@
|
|
|
61
61
|
|
|
62
62
|
import { AGENT_LABELS } from '../label-constants.js';
|
|
63
63
|
import { buildStoryAdjacency } from '../story-adjacency.js';
|
|
64
|
+
import { detectCollision } from './footprint.js';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* How a footprint collision affects dispatch — the `footprintGuard` config
|
|
68
|
+
* knob (`delivery.deliverRunner.footprintGuard`, Story #5044).
|
|
69
|
+
*
|
|
70
|
+
* `enforce` is the default and encodes delivery-time-only knowledge the
|
|
71
|
+
* planner cannot have: which implementation windows are open right now, which
|
|
72
|
+
* Stories a foreign lease holds, how far the ground has moved since the plan
|
|
73
|
+
* was authored. It is never demoted automatically.
|
|
74
|
+
*
|
|
75
|
+
* `advisory` is an explicit operator trade for throughput on a run whose
|
|
76
|
+
* `depends_on` edges are known to be complete. Detection still runs; only the
|
|
77
|
+
* withholding stops.
|
|
78
|
+
*/
|
|
79
|
+
export const GUARD_MODES = Object.freeze({
|
|
80
|
+
ENFORCE: 'enforce',
|
|
81
|
+
ADVISORY: 'advisory',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Which guard produced a withhold: a peer admitted **this beat**, or a Story
|
|
86
|
+
* still in flight from an **earlier** one. The two clear on different events,
|
|
87
|
+
* so an operator reading the report needs them apart.
|
|
88
|
+
*/
|
|
89
|
+
export const WITHHOLD_SCOPES = Object.freeze({
|
|
90
|
+
BEAT: 'beat',
|
|
91
|
+
IN_FLIGHT: 'in-flight',
|
|
92
|
+
});
|
|
64
93
|
|
|
65
94
|
/**
|
|
66
95
|
* @typedef {object} StoryRecord
|
|
@@ -135,128 +164,6 @@ export function classifyStory(story) {
|
|
|
135
164
|
return 'ready';
|
|
136
165
|
}
|
|
137
166
|
|
|
138
|
-
/**
|
|
139
|
-
* Extract a Story's declared file footprint as a normalized set of path
|
|
140
|
-
* strings. Accepts the three footprint shapes a Story record can carry:
|
|
141
|
-
*
|
|
142
|
-
* - `files: string[]` — explicit footprint.
|
|
143
|
-
* - `changes: string[]` — string-array sketch.
|
|
144
|
-
* - `changeset: Array<{ path }>` / — object-array sketch (the
|
|
145
|
-
* `changes: Array<{ path }>` `{ path, assumption }`
|
|
146
|
-
* shape from a Story body).
|
|
147
|
-
*
|
|
148
|
-
* Paths are trimmed; empty / non-string entries are dropped. A Story with
|
|
149
|
-
* no declared footprint yields an empty set, which (by `storiesOverlap`'s
|
|
150
|
-
* contract) means it overlaps with nothing and is never withheld by the
|
|
151
|
-
* co-dispatch guard.
|
|
152
|
-
*
|
|
153
|
-
* @param {StoryRecord} story
|
|
154
|
-
* @returns {Set<string>}
|
|
155
|
-
*/
|
|
156
|
-
export function storyFootprint(story) {
|
|
157
|
-
const out = new Set();
|
|
158
|
-
const push = (entry) => {
|
|
159
|
-
const path =
|
|
160
|
-
typeof entry === 'string'
|
|
161
|
-
? entry
|
|
162
|
-
: typeof entry?.path === 'string'
|
|
163
|
-
? entry.path
|
|
164
|
-
: null;
|
|
165
|
-
if (!path) return;
|
|
166
|
-
const trimmed = path.trim();
|
|
167
|
-
if (trimmed) out.add(trimmed);
|
|
168
|
-
};
|
|
169
|
-
if (Array.isArray(story?.files)) for (const e of story.files) push(e);
|
|
170
|
-
if (Array.isArray(story?.changes)) for (const e of story.changes) push(e);
|
|
171
|
-
if (Array.isArray(story?.changeset)) for (const e of story.changeset) push(e);
|
|
172
|
-
return out;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Does a declared path contain a glob metacharacter? Mirrors the detection
|
|
177
|
-
* in `story-body.js#extractChangePaths`, whose `isGlob` flag documents an
|
|
178
|
-
* "unknown-width footprint" policy that was never implemented downstream.
|
|
179
|
-
*
|
|
180
|
-
* @param {string} path
|
|
181
|
-
* @returns {boolean}
|
|
182
|
-
*/
|
|
183
|
-
function isGlobPath(path) {
|
|
184
|
-
return path.includes('*') || path.includes('?') || path.includes('{');
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Repo-relative file paths as they appear in Story prose: at least one `/`
|
|
189
|
-
* separator and a short file extension. Deliberately narrow — a token has to
|
|
190
|
-
* look like a real path before it can widen a footprint and withhold a Story.
|
|
191
|
-
*/
|
|
192
|
-
const PROSE_PATH_RE = /(?:[\w.@~-]+\/)+[\w.@-]+\.[A-Za-z0-9]{1,6}/g;
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Scrape file paths a Story's **text** mentions but its `changes[]` never
|
|
196
|
-
* declared (Story #4875).
|
|
197
|
-
*
|
|
198
|
-
* The declared footprint is a planner's *prediction*, and it is systematically
|
|
199
|
-
* a lower bound: a Story's `## Spec` names the module it must also touch, its
|
|
200
|
-
* acceptance criteria name the caller that must be updated, and none of that
|
|
201
|
-
* reaches `changes[]`. The overlap guard exists to stop two Stories racing the
|
|
202
|
-
* same file, so trusting the declaration outright means the guard is blind to
|
|
203
|
-
* precisely the collisions nobody predicted.
|
|
204
|
-
*
|
|
205
|
-
* Evidence is only ever **added** — nothing here can shrink a declared
|
|
206
|
-
* footprint, so widening can withhold a Story for a beat but can never
|
|
207
|
-
* co-dispatch one the declared comparison would have caught.
|
|
208
|
-
*
|
|
209
|
-
* @param {StoryRecord} story
|
|
210
|
-
* @returns {Set<string>}
|
|
211
|
-
*/
|
|
212
|
-
function storyEvidencePaths(story) {
|
|
213
|
-
const out = new Set();
|
|
214
|
-
for (const field of [story?.title, story?.body, story?.spec]) {
|
|
215
|
-
if (typeof field !== 'string' || field === '') continue;
|
|
216
|
-
for (const match of field.matchAll(PROSE_PATH_RE)) {
|
|
217
|
-
const trimmed = match[0].trim();
|
|
218
|
-
if (trimmed) out.add(trimmed);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
return out;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* A Story's footprint **widened from observable evidence** — the set the
|
|
226
|
-
* co-dispatch guard actually compares (Story #4875).
|
|
227
|
-
*
|
|
228
|
-
* `declared ∪ scraped-from-prose`. See {@link storyEvidencePaths} for why the
|
|
229
|
-
* declaration is treated as a lower bound rather than the answer.
|
|
230
|
-
*
|
|
231
|
-
* @param {StoryRecord} story
|
|
232
|
-
* @returns {Set<string>}
|
|
233
|
-
*/
|
|
234
|
-
function storyWidenedFootprint(story) {
|
|
235
|
-
const out = storyFootprint(story);
|
|
236
|
-
for (const path of storyEvidencePaths(story)) out.add(path);
|
|
237
|
-
return out;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Both Stories' widened footprints, or `null` when either is empty.
|
|
242
|
-
*
|
|
243
|
-
* **An empty footprint means "no known overlap"**, so both guards below
|
|
244
|
-
* short-circuit to `false` on one. This is permissive by necessity: a Story
|
|
245
|
-
* with no declared footprint and no path evidence in its text carries no
|
|
246
|
-
* information, and withholding on absence would serialize every run.
|
|
247
|
-
*
|
|
248
|
-
* @param {StoryRecord} a
|
|
249
|
-
* @param {StoryRecord} b
|
|
250
|
-
* @returns {[Set<string>, Set<string>]|null}
|
|
251
|
-
*/
|
|
252
|
-
function widenedFootprintPair(a, b) {
|
|
253
|
-
const fa = storyWidenedFootprint(a);
|
|
254
|
-
if (fa.size === 0) return null;
|
|
255
|
-
const fb = storyWidenedFootprint(b);
|
|
256
|
-
if (fb.size === 0) return null;
|
|
257
|
-
return [fa, fb];
|
|
258
|
-
}
|
|
259
|
-
|
|
260
167
|
/**
|
|
261
168
|
* **Beat-local** file-overlap co-dispatch guard. Returns `true` when two
|
|
262
169
|
* Stories' file footprints intersect — meaning they would race the same file
|
|
@@ -282,17 +189,8 @@ function widenedFootprintPair(a, b) {
|
|
|
282
189
|
* @param {StoryRecord} b
|
|
283
190
|
* @returns {boolean}
|
|
284
191
|
*/
|
|
285
|
-
export function storiesOverlap(a, b) {
|
|
286
|
-
|
|
287
|
-
if (pair === null) return false;
|
|
288
|
-
const [fa, fb] = pair;
|
|
289
|
-
for (const path of fa) {
|
|
290
|
-
if (isGlobPath(path) || fb.has(path)) return true;
|
|
291
|
-
}
|
|
292
|
-
for (const path of fb) {
|
|
293
|
-
if (isGlobPath(path)) return true;
|
|
294
|
-
}
|
|
295
|
-
return false;
|
|
192
|
+
export function storiesOverlap(a, b, options = {}) {
|
|
193
|
+
return detectCollision(a, b, options) !== null;
|
|
296
194
|
}
|
|
297
195
|
|
|
298
196
|
/**
|
|
@@ -319,16 +217,11 @@ export function storiesOverlap(a, b) {
|
|
|
319
217
|
*
|
|
320
218
|
* @param {StoryRecord} held The in-flight Story holding the reservation.
|
|
321
219
|
* @param {StoryRecord} candidate The Story being considered for admission.
|
|
322
|
-
* @
|
|
220
|
+
* @param {object} [options] Evidence-scrape options (see {@link storyEvidencePaths}).
|
|
221
|
+
* @returns {{ paths: string[], source: string }|null}
|
|
323
222
|
*/
|
|
324
|
-
function reservesConcretePath(held, candidate) {
|
|
325
|
-
|
|
326
|
-
if (pair === null) return false;
|
|
327
|
-
const [fa, fb] = pair;
|
|
328
|
-
for (const path of fa) {
|
|
329
|
-
if (!isGlobPath(path) && fb.has(path)) return true;
|
|
330
|
-
}
|
|
331
|
-
return false;
|
|
223
|
+
function reservesConcretePath(held, candidate, options = {}) {
|
|
224
|
+
return detectCollision(held, candidate, { ...options, concreteOnly: true });
|
|
332
225
|
}
|
|
333
226
|
|
|
334
227
|
/**
|
|
@@ -396,11 +289,28 @@ function reservesConcretePath(held, candidate) {
|
|
|
396
289
|
* footprint reserves nothing (Story #4960). Callers that hold only ids (the
|
|
397
290
|
* `--dag`/`--in-flight` flag mode) pass nothing and get the pre-#4950
|
|
398
291
|
* same-beat-only behaviour.
|
|
399
|
-
* @
|
|
292
|
+
* @param {'enforce'|'advisory'} [args.footprintGuard='enforce'] Whether a
|
|
293
|
+
* footprint collision **withholds** a Story (`enforce`, the default and
|
|
294
|
+
* today's behaviour) or merely **reports** one while dispatch follows the
|
|
295
|
+
* declared `depends_on` edges alone (`advisory`). Advisory never changes what
|
|
296
|
+
* the guard *detects* — every would-be withhold is still computed and
|
|
297
|
+
* returned in `footprintWithholds` with `enforced: false` — so turning it on
|
|
298
|
+
* trades serialization for throughput without going blind (Story #5044).
|
|
299
|
+
* @param {string} [args.tempRoot] Resolved `project.paths.tempRoot`, threaded
|
|
300
|
+
* so the evidence scrape can ignore gitignored scratch paths.
|
|
301
|
+
* @returns {{
|
|
302
|
+
* selected: StoryRecord[],
|
|
303
|
+
* withheldByInFlight: Array<{id: number, blockedBy: number}>,
|
|
304
|
+
* footprintWithholds: Array<{id: number, blockedBy: number, scope: string, source: string, paths: string[], enforced: boolean}>,
|
|
305
|
+
* guardMode: 'enforce'|'advisory'
|
|
306
|
+
* }}
|
|
400
307
|
* `selected` is the dispatch set: a subset of `stories`, ascending by id,
|
|
401
308
|
* overlap-free, length ≤ `globalCap − inFlight`. `withheldByInFlight`
|
|
402
309
|
* names each eligible Story a reservation held back and the in-flight
|
|
403
|
-
* Story that holds it.
|
|
310
|
+
* Story that holds it. `footprintWithholds` is the **complete** ledger —
|
|
311
|
+
* beat-local skips as well as cross-beat reservations, each with the
|
|
312
|
+
* colliding paths and its `declared-overlap` / `scraped-overlap` source — so
|
|
313
|
+
* no withheld dispatch is unexplained (Story #5044).
|
|
404
314
|
*/
|
|
405
315
|
export function planReadySet({
|
|
406
316
|
stories,
|
|
@@ -409,14 +319,25 @@ export function planReadySet({
|
|
|
409
319
|
globalCap,
|
|
410
320
|
dropForeign = false,
|
|
411
321
|
inFlightRecords = [],
|
|
322
|
+
footprintGuard = GUARD_MODES.ENFORCE,
|
|
323
|
+
tempRoot,
|
|
412
324
|
} = {}) {
|
|
413
325
|
const records = Array.isArray(stories) ? stories : [];
|
|
326
|
+
const guardMode =
|
|
327
|
+
footprintGuard === GUARD_MODES.ADVISORY
|
|
328
|
+
? GUARD_MODES.ADVISORY
|
|
329
|
+
: GUARD_MODES.ENFORCE;
|
|
414
330
|
const cap = Number.isInteger(globalCap) ? globalCap : 0;
|
|
415
331
|
const inFlightCount =
|
|
416
332
|
Number.isInteger(inFlight) && inFlight > 0 ? inFlight : 0;
|
|
417
333
|
const slots = Math.max(0, cap - inFlightCount);
|
|
418
334
|
if (slots <= 0 || records.length === 0) {
|
|
419
|
-
return {
|
|
335
|
+
return {
|
|
336
|
+
selected: [],
|
|
337
|
+
withheldByInFlight: [],
|
|
338
|
+
footprintWithholds: [],
|
|
339
|
+
guardMode,
|
|
340
|
+
};
|
|
420
341
|
}
|
|
421
342
|
|
|
422
343
|
// Step 1 — adjacency keyed by id. The `dropForeign` policy decides whether
|
|
@@ -425,7 +346,47 @@ export function planReadySet({
|
|
|
425
346
|
// rationale.
|
|
426
347
|
const adjacency = buildStoryAdjacency(records, { dropForeign });
|
|
427
348
|
|
|
428
|
-
//
|
|
349
|
+
// Steps 2 + 3 — who is eligible at all, before any footprint reasoning.
|
|
350
|
+
const { eligibleIds, byId } = resolveEligibility({
|
|
351
|
+
records,
|
|
352
|
+
doneIds,
|
|
353
|
+
adjacency,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Steps 4 + 5 — greedily admit up to `slots`, skipping file-overlap
|
|
357
|
+
// collisions against the already-admitted set AND against the footprints
|
|
358
|
+
// reserved by Stories still in flight from an earlier beat.
|
|
359
|
+
return admitStories({
|
|
360
|
+
eligibleIds,
|
|
361
|
+
byId,
|
|
362
|
+
slots,
|
|
363
|
+
reserved: Array.isArray(inFlightRecords) ? inFlightRecords : [],
|
|
364
|
+
guardMode,
|
|
365
|
+
evidence: { tempRoot },
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Resolve which Stories are eligible to dispatch on dependency grounds alone —
|
|
371
|
+
* `agent::ready` with every declared blocker done — plus the id→record index
|
|
372
|
+
* the admission loop reads.
|
|
373
|
+
*
|
|
374
|
+
* Separated from {@link planReadySet} because it answers a different question:
|
|
375
|
+
* this is the *declared graph* half of the decision (edges and lifecycle
|
|
376
|
+
* state), while everything after it reasons about footprints. Keeping the two
|
|
377
|
+
* apart is also what holds `planReadySet` under the cyclomatic ceiling ratchet.
|
|
378
|
+
*
|
|
379
|
+
* The done set is the union of two sources: ids the caller resolved from live
|
|
380
|
+
* state, and records in this batch that classify done — a Story can be both,
|
|
381
|
+
* and neither alone is complete.
|
|
382
|
+
*
|
|
383
|
+
* @param {object} args
|
|
384
|
+
* @param {StoryRecord[]} args.records
|
|
385
|
+
* @param {number[]|Set<number>} args.doneIds
|
|
386
|
+
* @param {Map<number, number[]>} args.adjacency
|
|
387
|
+
* @returns {{ eligibleIds: number[], byId: Map<number, StoryRecord> }}
|
|
388
|
+
*/
|
|
389
|
+
function resolveEligibility({ records, doneIds, adjacency }) {
|
|
429
390
|
const done = new Set();
|
|
430
391
|
for (const raw of doneIds instanceof Set ? doneIds : (doneIds ?? [])) {
|
|
431
392
|
const id = Number(raw);
|
|
@@ -439,25 +400,14 @@ export function planReadySet({
|
|
|
439
400
|
if (classifyStory(rec) === 'done') done.add(id);
|
|
440
401
|
}
|
|
441
402
|
|
|
442
|
-
//
|
|
443
|
-
// deterministic admission order.
|
|
403
|
+
// Ascending id for deterministic admission order.
|
|
444
404
|
const eligibleIds = [];
|
|
445
405
|
for (const id of [...byId.keys()].sort((a, b) => a - b)) {
|
|
446
|
-
|
|
447
|
-
if (classifyStory(rec) !== 'ready') continue;
|
|
406
|
+
if (classifyStory(byId.get(id)) !== 'ready') continue;
|
|
448
407
|
const deps = adjacency.get(id) ?? [];
|
|
449
408
|
if (deps.every((dep) => done.has(dep))) eligibleIds.push(id);
|
|
450
409
|
}
|
|
451
|
-
|
|
452
|
-
// Steps 4 + 5 — greedily admit up to `slots`, skipping file-overlap
|
|
453
|
-
// collisions against the already-admitted set AND against the footprints
|
|
454
|
-
// reserved by Stories still in flight from an earlier beat.
|
|
455
|
-
return admitStories({
|
|
456
|
-
eligibleIds,
|
|
457
|
-
byId,
|
|
458
|
-
slots,
|
|
459
|
-
reserved: Array.isArray(inFlightRecords) ? inFlightRecords : [],
|
|
460
|
-
});
|
|
410
|
+
return { eligibleIds, byId };
|
|
461
411
|
}
|
|
462
412
|
|
|
463
413
|
/**
|
|
@@ -469,37 +419,102 @@ export function planReadySet({
|
|
|
469
419
|
* ({@link reservesConcretePath}), while the same-beat guard also serializes
|
|
470
420
|
* unknown-width footprints ({@link storiesOverlap}). See both for why.
|
|
471
421
|
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
*
|
|
477
|
-
*
|
|
478
|
-
*
|
|
479
|
-
*
|
|
422
|
+
* Which of the two a candidate is reported against is {@link blockingCollision}'s
|
|
423
|
+
* decision, not this loop's.
|
|
424
|
+
*
|
|
425
|
+
* Under `footprintGuard: 'advisory'` neither rule withholds: dispatch follows
|
|
426
|
+
* the declared `depends_on` edges alone. Detection is unchanged — every hit is
|
|
427
|
+
* still computed and recorded with `enforced: false` — so advisory mode is a
|
|
428
|
+
* deliberate throughput trade an operator can read the cost of, not a blind
|
|
429
|
+
* spot (Story #5044).
|
|
480
430
|
*
|
|
481
431
|
* @param {object} args
|
|
482
432
|
* @param {number[]} args.eligibleIds Ascending eligible Story ids.
|
|
483
433
|
* @param {Map<number, StoryRecord>} args.byId
|
|
484
434
|
* @param {number} args.slots Remaining dispatch capacity.
|
|
485
435
|
* @param {StoryRecord[]} args.reserved In-flight Story records.
|
|
486
|
-
* @
|
|
436
|
+
* @param {'enforce'|'advisory'} args.guardMode
|
|
437
|
+
* @param {object} args.evidence Evidence-scrape options.
|
|
438
|
+
* @returns {{ selected: StoryRecord[], withheldByInFlight: Array<{id: number, blockedBy: number}>, footprintWithholds: object[], guardMode: string }}
|
|
487
439
|
*/
|
|
488
|
-
function admitStories({
|
|
440
|
+
function admitStories({
|
|
441
|
+
eligibleIds,
|
|
442
|
+
byId,
|
|
443
|
+
slots,
|
|
444
|
+
reserved,
|
|
445
|
+
guardMode,
|
|
446
|
+
evidence,
|
|
447
|
+
}) {
|
|
448
|
+
const enforced = guardMode !== GUARD_MODES.ADVISORY;
|
|
489
449
|
const selected = [];
|
|
490
|
-
const
|
|
450
|
+
const footprintWithholds = [];
|
|
491
451
|
for (const id of eligibleIds) {
|
|
492
452
|
if (selected.length >= slots) break;
|
|
493
453
|
const rec = byId.get(id);
|
|
494
|
-
const
|
|
495
|
-
if (
|
|
496
|
-
|
|
497
|
-
continue;
|
|
498
|
-
}
|
|
499
|
-
if (selected.some((picked) => storiesOverlap(picked, rec))) continue;
|
|
454
|
+
const hit = blockingCollision({ rec, id, selected, reserved, evidence });
|
|
455
|
+
if (hit) footprintWithholds.push({ id, ...hit, enforced });
|
|
456
|
+
if (hit && enforced) continue;
|
|
500
457
|
selected.push(rec);
|
|
501
458
|
}
|
|
502
|
-
return {
|
|
459
|
+
return {
|
|
460
|
+
selected,
|
|
461
|
+
// The legacy cross-beat projection, kept at its original `{ id, blockedBy }`
|
|
462
|
+
// shape: it is `stories-wave-tick.js`'s long-standing reservation input and
|
|
463
|
+
// narrowing the scrape must not reshape it.
|
|
464
|
+
withheldByInFlight: footprintWithholds
|
|
465
|
+
.filter((w) => w.enforced && w.scope === WITHHOLD_SCOPES.IN_FLIGHT)
|
|
466
|
+
.map(({ id, blockedBy }) => ({ id, blockedBy })),
|
|
467
|
+
footprintWithholds,
|
|
468
|
+
guardMode,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* The one footprint collision withholding this candidate, or `null`.
|
|
474
|
+
*
|
|
475
|
+
* The in-flight reservation is checked **first**, so a Story racing both an
|
|
476
|
+
* in-flight Story and a same-beat peer is reported against the in-flight one:
|
|
477
|
+
* that is the longer-lived and more informative blocker (a Story that has been
|
|
478
|
+
* implementing for beats, not one merely admitted a moment ago), and checking it
|
|
479
|
+
* first is what makes the reservation report complete. Order cannot change
|
|
480
|
+
* `selected` — a candidate either rule rejects is skipped whichever runs first.
|
|
481
|
+
*
|
|
482
|
+
* @param {object} args
|
|
483
|
+
* @param {StoryRecord} args.rec
|
|
484
|
+
* @param {number} args.id
|
|
485
|
+
* @param {StoryRecord[]} args.selected Peers already admitted this beat.
|
|
486
|
+
* @param {StoryRecord[]} args.reserved In-flight Story records.
|
|
487
|
+
* @param {object} args.evidence
|
|
488
|
+
* @returns {{ blockedBy: number, scope: string, paths: string[], source: string }|null}
|
|
489
|
+
*/
|
|
490
|
+
function blockingCollision({ rec, id, selected, reserved, evidence }) {
|
|
491
|
+
const held = findInFlightBlocker(rec, id, reserved, evidence);
|
|
492
|
+
if (held) return { ...held, scope: WITHHOLD_SCOPES.IN_FLIGHT };
|
|
493
|
+
const peer = findBeatBlocker(rec, selected, evidence);
|
|
494
|
+
return peer ? { ...peer, scope: WITHHOLD_SCOPES.BEAT } : null;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* The **already-admitted peer** whose footprint this candidate would race on
|
|
499
|
+
* this beat, with the colliding paths — or `null` when none does.
|
|
500
|
+
*
|
|
501
|
+
* Until Story #5044 this was an anonymous `continue`: the candidate was
|
|
502
|
+
* silently dropped from the beat and nothing in any envelope said why. An
|
|
503
|
+
* unfilled slot with no explanation is indistinguishable from a cap that was
|
|
504
|
+
* simply not reached, which is what let a footprint-widening artifact
|
|
505
|
+
* serialize a whole audit-derived plan without leaving a trace to notice.
|
|
506
|
+
*
|
|
507
|
+
* @param {StoryRecord} candidate
|
|
508
|
+
* @param {StoryRecord[]} selected Stories already admitted this beat.
|
|
509
|
+
* @param {object} [options]
|
|
510
|
+
* @returns {{ blockedBy: number, paths: string[], source: string }|null}
|
|
511
|
+
*/
|
|
512
|
+
function findBeatBlocker(candidate, selected, options = {}) {
|
|
513
|
+
for (const picked of selected) {
|
|
514
|
+
const collision = detectCollision(picked, candidate, options);
|
|
515
|
+
if (collision) return { blockedBy: storyIdOf(picked), ...collision };
|
|
516
|
+
}
|
|
517
|
+
return null;
|
|
503
518
|
}
|
|
504
519
|
|
|
505
520
|
/**
|
|
@@ -520,13 +535,15 @@ function admitStories({ eligibleIds, byId, slots, reserved }) {
|
|
|
520
535
|
* @param {StoryRecord} candidate
|
|
521
536
|
* @param {number} candidateId
|
|
522
537
|
* @param {StoryRecord[]} reserved
|
|
523
|
-
* @
|
|
538
|
+
* @param {object} [options]
|
|
539
|
+
* @returns {{ blockedBy: number, paths: string[], source: string }|null}
|
|
524
540
|
*/
|
|
525
|
-
function findInFlightBlocker(candidate, candidateId, reserved) {
|
|
541
|
+
function findInFlightBlocker(candidate, candidateId, reserved, options = {}) {
|
|
526
542
|
for (const held of reserved) {
|
|
527
543
|
const heldId = storyIdOf(held);
|
|
528
544
|
if (heldId === null || heldId === candidateId) continue;
|
|
529
|
-
|
|
545
|
+
const collision = reservesConcretePath(held, candidate, options);
|
|
546
|
+
if (collision) return { blockedBy: heldId, ...collision };
|
|
530
547
|
}
|
|
531
548
|
return null;
|
|
532
549
|
}
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import { Logger } from '../../lib/Logger.js';
|
|
24
24
|
import { concurrentMap } from '../../lib/util/concurrent-map.js';
|
|
25
|
-
import {
|
|
25
|
+
import { paginateRest } from './request-helpers.js';
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* Bounded concurrency for the GitHub dependency-edge round-trips. Kept modest
|
|
@@ -32,22 +32,37 @@ import { parseApiJson } from './request-helpers.js';
|
|
|
32
32
|
const EDGE_CONCURRENCY = 5;
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
-
* Fetch the existing blocked-by
|
|
35
|
+
* Fetch the existing blocked-by database ids for a given issue, **paginated
|
|
36
|
+
* to exhaustion**.
|
|
37
|
+
*
|
|
38
|
+
* This read is the idempotency check: an edge it fails to see is re-POSTed.
|
|
39
|
+
* Reading only the first page therefore made the writer non-idempotent past
|
|
40
|
+
* the page boundary — every edge beyond it looked missing on every run
|
|
41
|
+
* (Story #5046). `paginateRest` walks the pages and carries the shared
|
|
42
|
+
* transient-retry and page-cap guards.
|
|
36
43
|
*
|
|
37
44
|
* Returns `[]` on any error so the caller falls back to posting the full
|
|
38
45
|
* set of missing edges (worst case: a duplicate POST, which GitHub
|
|
39
|
-
* handles idempotently).
|
|
46
|
+
* handles idempotently). That non-fatal contract is deliberate and is the
|
|
47
|
+
* inverse of the READ path in `lib/orchestration/resolve-stories.js`: a lost
|
|
48
|
+
* write-side edge is cosmetic, a lost read-side edge removes a dispatch gate.
|
|
40
49
|
*
|
|
41
|
-
* @param {{ gh: object, owner: string, repo: string, issueNumber: number }} opts
|
|
50
|
+
* @param {{ gh: object, owner: string, repo: string, issueNumber: number, paginate?: Function }} opts
|
|
42
51
|
* @returns {Promise<number[]>} Database ids of the issues that currently block `issueNumber`.
|
|
43
52
|
*/
|
|
44
|
-
async function fetchExistingBlockedBy({
|
|
53
|
+
async function fetchExistingBlockedBy({
|
|
54
|
+
gh,
|
|
55
|
+
owner,
|
|
56
|
+
repo,
|
|
57
|
+
issueNumber,
|
|
58
|
+
paginate = paginateRest,
|
|
59
|
+
}) {
|
|
45
60
|
try {
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
61
|
+
const data = await paginate(
|
|
62
|
+
gh,
|
|
63
|
+
`/repos/${owner}/${repo}/issues/${issueNumber}/dependencies/blocked_by`,
|
|
64
|
+
{ label: `[blocked-by-add] blocked_by #${issueNumber}` },
|
|
65
|
+
);
|
|
51
66
|
if (!Array.isArray(data)) return [];
|
|
52
67
|
return data.map((item) => item?.id).filter((id) => typeof id === 'number');
|
|
53
68
|
} catch (err) {
|
|
@@ -11,8 +11,11 @@
|
|
|
11
11
|
* What it resolves, per Story:
|
|
12
12
|
* - the issue itself, fetched with **state=all** so an already-landed
|
|
13
13
|
* sibling is present rather than silently dropped;
|
|
14
|
-
* - its dependency edges: the union of body
|
|
15
|
-
* `
|
|
14
|
+
* - its dependency edges: the union of the body's `---` footer
|
|
15
|
+
* (`blocked by #N`, footer-scoped and strict — prose mentioning a blocker
|
|
16
|
+
* elsewhere in the body declares nothing) and native GitHub `blocked_by`
|
|
17
|
+
* edges, read to exhaustion and failing loud rather than degrading to
|
|
18
|
+
* "no edges";
|
|
16
19
|
* - its declared file footprint, as plain path strings, so the scheduler's
|
|
17
20
|
* co-dispatch overlap guard has something to work with.
|
|
18
21
|
*
|
|
@@ -45,7 +48,7 @@ import {
|
|
|
45
48
|
} from './lib/orchestration/resolve-stories.js';
|
|
46
49
|
import { createProvider } from './lib/provider-factory.js';
|
|
47
50
|
import { concurrentMap } from './lib/util/concurrent-map.js';
|
|
48
|
-
import {
|
|
51
|
+
import { paginateRest } from './providers/github/request-helpers.js';
|
|
49
52
|
|
|
50
53
|
export { buildStoriesEnvelope, parseIds, readNativeBlockedBy, toStoryRecord };
|
|
51
54
|
|
|
@@ -111,9 +114,21 @@ export async function fetchStories(provider, ids) {
|
|
|
111
114
|
/**
|
|
112
115
|
* Read native blocked_by edges for every Story in the set.
|
|
113
116
|
*
|
|
117
|
+
* `paginate` is injected rather than imported inside the lib layer so
|
|
118
|
+
* `readNativeBlockedBy` stays provider-agnostic and unit-testable; production
|
|
119
|
+
* passes `paginateRest`, which walks every page (the read used to stop at the
|
|
120
|
+
* first, silently truncating a Story's gates — Story #5046).
|
|
121
|
+
*
|
|
114
122
|
* @returns {Promise<Map<number, number[]>>}
|
|
115
123
|
*/
|
|
116
|
-
export async function readNativeEdges({
|
|
124
|
+
export async function readNativeEdges({
|
|
125
|
+
provider,
|
|
126
|
+
stories,
|
|
127
|
+
owner,
|
|
128
|
+
repo,
|
|
129
|
+
paginate = paginateRest,
|
|
130
|
+
warn = (m) => Logger.warn(m),
|
|
131
|
+
}) {
|
|
117
132
|
const entries = await concurrentMap(
|
|
118
133
|
stories,
|
|
119
134
|
async (story) => [
|
|
@@ -123,7 +138,8 @@ export async function readNativeEdges({ provider, stories, owner, repo }) {
|
|
|
123
138
|
owner,
|
|
124
139
|
repo,
|
|
125
140
|
issueNumber: story.id,
|
|
126
|
-
|
|
141
|
+
paginate,
|
|
142
|
+
warn,
|
|
127
143
|
}),
|
|
128
144
|
],
|
|
129
145
|
{ concurrency: FETCH_CONCURRENCY },
|