mandrel 2.25.0 → 2.26.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/agents/acceptance-critic.md +10 -6
- package/.agents/audit-checklists/baselines.md +21 -0
- package/.agents/docs/quality-gates.md +80 -18
- package/.agents/docs/workflows.md +3 -1
- package/.agents/instructions.md +1 -1
- package/.agents/schemas/audit-rules.json +15 -0
- package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +8 -0
- package/.agents/schemas/model-attribution.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +89 -6
- package/.agents/scripts/audit-baselines.js +136 -0
- package/.agents/scripts/check-arch-cycles.js +12 -93
- package/.agents/scripts/check-baseline-drift.js +16 -3
- package/.agents/scripts/check-baselines.js +19 -3
- package/.agents/scripts/check-cyclomatic.js +214 -0
- package/.agents/scripts/check-schema-references.js +392 -0
- package/.agents/scripts/check-test-temp-hygiene.js +38 -1
- package/.agents/scripts/check-workflow-timeouts.js +291 -0
- package/.agents/scripts/diagnose-friction.js +85 -19
- package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
- package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
- package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
- package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
- package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
- package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
- package/.agents/scripts/lib/audit-baselines/read.js +87 -0
- package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
- package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
- package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
- package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
- package/.agents/scripts/lib/audit-suite/index.js +0 -5
- package/.agents/scripts/lib/audit-suite/selector.js +9 -62
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
- package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
- package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
- package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
- package/.agents/scripts/lib/baselines/git-base.js +26 -4
- package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
- package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
- package/.agents/scripts/lib/baselines/scope.js +39 -90
- package/.agents/scripts/lib/baselines/writer.js +16 -11
- package/.agents/scripts/lib/changed-files.js +8 -1
- package/.agents/scripts/lib/cli-args.js +115 -1
- package/.agents/scripts/lib/close-validation/runner.js +70 -25
- package/.agents/scripts/lib/crap-engine.js +32 -13
- package/.agents/scripts/lib/crap-method-identity.js +153 -0
- package/.agents/scripts/lib/crap-utils.js +13 -0
- package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
- package/.agents/scripts/lib/git-utils.js +136 -80
- package/.agents/scripts/lib/import-graph.js +156 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
- package/.agents/scripts/lib/observability/source-classifier.js +175 -2
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
- package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
- package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
- package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
- package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
- package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
- package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
- package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
- package/.agents/scripts/lib/story-adjacency.js +3 -3
- package/.agents/scripts/lib/test-runner-contract.js +134 -0
- package/.agents/scripts/lib/test-tiers.js +11 -2
- package/.agents/scripts/lib/util/concurrent-map.js +17 -0
- package/.agents/scripts/lib/util/parse-id-list.js +103 -0
- package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
- package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
- package/.agents/scripts/lib/workers/crap-worker.js +2 -10
- package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
- package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
- package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
- package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
- package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
- package/.agents/scripts/lib/worktree-manager.js +14 -0
- package/.agents/scripts/plan-run-epilogue.js +17 -5
- package/.agents/scripts/providers/github/tickets.js +33 -10
- package/.agents/scripts/provision-git-hooks.js +85 -0
- package/.agents/scripts/quality-preview.js +112 -28
- package/.agents/scripts/resolve-stories.js +4 -1
- package/.agents/scripts/run-coverage.js +86 -35
- package/.agents/scripts/run-lint.js +20 -0
- package/.agents/scripts/run-tests.js +26 -36
- package/.agents/scripts/single-story-close.js +28 -2
- package/.agents/scripts/single-story-confirm-merge.js +22 -6
- package/.agents/scripts/stories-wave-tick.js +214 -38
- package/.agents/scripts/update-coverage-baseline.js +34 -4
- package/.agents/scripts/update-duplication-baseline.js +209 -83
- package/.agents/scripts/validate-docs-freshness.js +1 -0
- package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
- package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
- package/.agents/skills/skills.index.json +2 -2
- package/.agents/workflows/audit-baselines.md +289 -0
- package/.agents/workflows/audit-navigability.md +5 -4
- package/.agents/workflows/deliver.md +13 -4
- package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
- package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
- package/.agents/workflows/helpers/deliver-digest.md +41 -21
- package/.agents/workflows/helpers/deliver-reference.md +77 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
- package/.agents/workflows/helpers/plan-reference.md +15 -5
- package/.agents/workflows/memory-consolidate.md +116 -0
- package/.agents/workflows/plan.md +3 -0
- package/README.md +13 -6
- package/docs/CHANGELOG.md +64 -0
- package/package.json +9 -4
- package/.agents/schemas/friction-event.schema.json +0 -56
- package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parse-id-list — expand a Story-id list that may contain dash ranges.
|
|
3
|
+
*
|
|
4
|
+
* Operators name a contiguous span of Stories the way they read one — as a
|
|
5
|
+
* range: `/deliver 4922 - 4926`. Enumerating it by hand is the kind of
|
|
6
|
+
* transcription step that silently drops or invents an id, so the range is a
|
|
7
|
+
* first-class shape of every delivery id list rather than something the host
|
|
8
|
+
* expands from prose.
|
|
9
|
+
*
|
|
10
|
+
* Accepted tokens, comma-separated:
|
|
11
|
+
* - a single id, with an optional `#` — `4922`, `#4922`
|
|
12
|
+
* - an inclusive range — `4922-4926`, `4922 - 4926`, `#4922-#4926`
|
|
13
|
+
* (hyphen-minus, en dash, or em dash; whitespace around it is fine)
|
|
14
|
+
*
|
|
15
|
+
* Everything else is a hard error, never a silent drop: a wrong id list
|
|
16
|
+
* co-dispatches against the wrong graph, so it must fail where it is typed.
|
|
17
|
+
* Two range-specific guards exist for the same reason — a backwards range is
|
|
18
|
+
* refused rather than expanded to nothing, and a span above `MAX_RANGE_SPAN`
|
|
19
|
+
* is refused rather than resolving thousands of issues off a typo.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Inclusive-span ceiling for a single range token. Generous against any real
|
|
24
|
+
* plan run (a handful of Stories) and tight enough that `1-4926` is caught as
|
|
25
|
+
* the typo it is rather than fanning out into a live resolution sweep.
|
|
26
|
+
*
|
|
27
|
+
* Deliberately module-private: the cap is a published contract
|
|
28
|
+
* (`helpers/deliver-reference.md` § Ranges), so a test that imported it could
|
|
29
|
+
* not notice the number silently moving out from under the doc.
|
|
30
|
+
*/
|
|
31
|
+
const MAX_RANGE_SPAN = 50;
|
|
32
|
+
|
|
33
|
+
/** Hyphen-minus, en dash, em dash — whichever the operator's keyboard emits. */
|
|
34
|
+
const DASH = '[-–—]';
|
|
35
|
+
const SINGLE_RE = /^#?(\d+)$/;
|
|
36
|
+
const RANGE_RE = new RegExp(`^#?(\\d+)\\s*${DASH}\\s*#?(\\d+)$`);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse a comma-separated Story-id list, expanding any `A-B` range token.
|
|
40
|
+
*
|
|
41
|
+
* Absent or empty input is not an error here — it yields an empty list, and
|
|
42
|
+
* the caller decides whether that is a usage error (`--ids`) or a legitimate
|
|
43
|
+
* empty set (`--done`).
|
|
44
|
+
*
|
|
45
|
+
* @param {string|undefined|null} raw
|
|
46
|
+
* @param {object} [options]
|
|
47
|
+
* @param {string} [options.flag] Flag name, for the error message.
|
|
48
|
+
* @param {string} [options.prefix] Message prefix, for the caller's log tag.
|
|
49
|
+
* @param {number} [options.maxSpan] Inclusive-span ceiling per range token.
|
|
50
|
+
* @returns {{ ids: number[]|null, error: string|null }}
|
|
51
|
+
*/
|
|
52
|
+
export function expandIdList(raw, options = {}) {
|
|
53
|
+
const { flag = '--ids', prefix = '', maxSpan = MAX_RANGE_SPAN } = options;
|
|
54
|
+
const fail = (message) => ({ ids: null, error: `${prefix}${message}` });
|
|
55
|
+
|
|
56
|
+
const ids = [];
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
const push = (n) => {
|
|
59
|
+
if (seen.has(n)) return;
|
|
60
|
+
seen.add(n);
|
|
61
|
+
ids.push(n);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
for (const token of String(raw ?? '').split(',')) {
|
|
65
|
+
const trimmed = token.trim();
|
|
66
|
+
if (trimmed === '') continue;
|
|
67
|
+
|
|
68
|
+
const range = RANGE_RE.exec(trimmed);
|
|
69
|
+
if (range) {
|
|
70
|
+
const start = Number(range[1]);
|
|
71
|
+
const end = Number(range[2]);
|
|
72
|
+
if (start <= 0 || end <= 0) {
|
|
73
|
+
return fail(
|
|
74
|
+
`${flag} range "${trimmed}" must use positive issue numbers.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
if (end < start) {
|
|
78
|
+
return fail(
|
|
79
|
+
`${flag} range "${trimmed}" runs backwards — write it low-to-high (e.g. 4922-4926).`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
const span = end - start + 1;
|
|
83
|
+
if (span > maxSpan) {
|
|
84
|
+
return fail(
|
|
85
|
+
`${flag} range "${trimmed}" spans ${span} ids, above the ${maxSpan}-id cap. Narrow it, or list the ids.`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
for (let n = start; n <= end; n++) push(n);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const single = SINGLE_RE.exec(trimmed);
|
|
93
|
+
const n = single ? Number(single[1]) : Number.NaN;
|
|
94
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
95
|
+
return fail(
|
|
96
|
+
`${flag} must be a comma-separated list of positive issue numbers or A-B ranges (got "${trimmed}").`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
push(n);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { ids, error: null };
|
|
103
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* lib/wave-runner/live-probe.js — the state-probing adapter that feeds the
|
|
3
3
|
* ready-set kernel from live GitHub state.
|
|
4
4
|
*
|
|
5
|
-
* `
|
|
5
|
+
* `planReadySet` (`./ready-set.js`) is deliberately a pure, side-effect-free
|
|
6
6
|
* kernel: callers hand it the live Story records, the done set, and the
|
|
7
7
|
* in-flight count, and it decides. Until now the only adapter was the
|
|
8
8
|
* flag-driven one (`stories-wave-tick.js --dag/--done/--in-flight`), which
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
* "waiting" (Story #4601).
|
|
42
42
|
*
|
|
43
43
|
* It is an **adapter, not a kernel change**: it gathers inputs and hands them
|
|
44
|
-
* to `
|
|
44
|
+
* to `planReadySet` unchanged. The kernel stays pure and flag-driven, and
|
|
45
45
|
* the legacy flag mode stays byte-compatible.
|
|
46
46
|
*
|
|
47
47
|
* The graph resolution is **not** reimplemented here — it reuses
|
|
@@ -217,7 +217,7 @@ export function createProbeContext({
|
|
|
217
217
|
|
|
218
218
|
/**
|
|
219
219
|
* Probe live state for a set of Story ids and return the exact inputs
|
|
220
|
-
* `
|
|
220
|
+
* `planReadySet` consumes.
|
|
221
221
|
*
|
|
222
222
|
* Mirrors `resolve-stories.js`'s two-pass envelope build: a provisional pass
|
|
223
223
|
* yields the DAG whose foreign dependency ids are then resolved against live
|
|
@@ -239,7 +239,7 @@ export function createProbeContext({
|
|
|
239
239
|
* skipped (the probe never fails closed).
|
|
240
240
|
* @param {(msg: string) => void} [args.warn]
|
|
241
241
|
* Each returned node carries its **live labels**. That is load-bearing, not
|
|
242
|
-
* decoration: `
|
|
242
|
+
* decoration: `planReadySet` classifies from labels, so a node stripped of
|
|
243
243
|
* them reads as `ready` and an `agent::executing` Story gets re-dispatched
|
|
244
244
|
* onto a second branch while its first run is still going. The resolver's DAG
|
|
245
245
|
* projection (`{id, dependsOn, files}`) drops labels because flag mode's
|
|
@@ -247,6 +247,7 @@ export function createProbeContext({
|
|
|
247
247
|
*
|
|
248
248
|
* @returns {Promise<{
|
|
249
249
|
* nodes: Array<{id: number, dependsOn: number[], files: string[], body: string, labels: string[]}>,
|
|
250
|
+
* inFlightRecords: Array<{id: number, dependsOn: number[], files: string[], body: string, labels: string[]}>,
|
|
250
251
|
* doneIds: Set<number>,
|
|
251
252
|
* inFlight: number,
|
|
252
253
|
* blockedIds: number[],
|
|
@@ -284,7 +285,7 @@ export async function probeLiveState({
|
|
|
284
285
|
const labelsById = new Map(stories.map((s) => [s.id, s.labels ?? []]));
|
|
285
286
|
// Bodies ride along with the node so the co-dispatch guard can widen a
|
|
286
287
|
// declared footprint from the paths a Story's own text names (Story #4875).
|
|
287
|
-
// They are consumed in-process by `
|
|
288
|
+
// They are consumed in-process by `planReadySet` and never serialized into
|
|
288
289
|
// the beat envelope, which stays a list of ids.
|
|
289
290
|
const bodyById = new Map(stories.map((s) => [s.id, s.body ?? '']));
|
|
290
291
|
const inFlightIds = deriveInFlightIds(stories, dispatched);
|
|
@@ -294,15 +295,24 @@ export async function probeLiveState({
|
|
|
294
295
|
// never dispatched by this run.
|
|
295
296
|
const foreignHeld = deriveForeignHeld(stories, self, warn);
|
|
296
297
|
for (const id of foreignHeld.keys()) inFlightIds.add(id);
|
|
298
|
+
const nodes = envelope.dag.map((node) => ({
|
|
299
|
+
...node,
|
|
300
|
+
body: bodyById.get(node.id) ?? '',
|
|
301
|
+
labels: projectInFlightLabels(
|
|
302
|
+
labelsById.get(node.id) ?? [],
|
|
303
|
+
inFlightIds.has(node.id),
|
|
304
|
+
),
|
|
305
|
+
}));
|
|
297
306
|
return {
|
|
298
|
-
nodes
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
307
|
+
nodes,
|
|
308
|
+
// The same nodes, narrowed to the Stories occupying a slot. `inFlight` is
|
|
309
|
+
// only a count, so it can shrink capacity but can never stop a Story
|
|
310
|
+
// admitted now from sharing files with one dispatched on an earlier beat
|
|
311
|
+
// and still implementing. These records carry the `files[]` and `body` the
|
|
312
|
+
// co-dispatch guard needs, so the kernel can RESERVE those footprints
|
|
313
|
+
// rather than merely count them (Story #4950). No extra fetch: this is a
|
|
314
|
+
// projection of what the probe already read.
|
|
315
|
+
inFlightRecords: nodes.filter((node) => inFlightIds.has(node.id)),
|
|
306
316
|
doneIds: new Set(envelope.done),
|
|
307
317
|
inFlight: inFlightIds.size,
|
|
308
318
|
blockedIds: deriveBlockedIds(stories),
|
|
@@ -316,7 +326,7 @@ export async function probeLiveState({
|
|
|
316
326
|
*
|
|
317
327
|
* This is the load-bearing half of the dispatch-window fix, and it is why
|
|
318
328
|
* `inFlight` alone is not enough. The two inputs do **different** jobs inside
|
|
319
|
-
* `
|
|
329
|
+
* `planReadySet`:
|
|
320
330
|
*
|
|
321
331
|
* - `inFlight` is only a **count**. It reserves capacity (`slots = cap −
|
|
322
332
|
* inFlight`) and nothing more.
|
|
@@ -19,24 +19,36 @@
|
|
|
19
19
|
* `stories-wave-tick.js` adapter wires this core; this module does not
|
|
20
20
|
* modify that CLI surface.
|
|
21
21
|
*
|
|
22
|
-
*
|
|
22
|
+
* The scheduling surface:
|
|
23
23
|
* - `classifyStory(story)` — live-label classifier mapping a Story
|
|
24
24
|
* record's labels + issue state to one of `done | blocked | executing |
|
|
25
25
|
* ready`. Mirrors the done-predicate this module uses
|
|
26
26
|
* (`agent::done` OR closed issue) so a Story closed manually through
|
|
27
27
|
* the GitHub UI is recognised as done.
|
|
28
|
-
* - `storiesOverlap(a, b)` — the file-overlap co-dispatch
|
|
29
|
-
* when two Stories' file footprints intersect. Two Stories
|
|
30
|
-
* touch the same file MUST NOT be
|
|
31
|
-
* `story-<id>` branches
|
|
32
|
-
*
|
|
28
|
+
* - `storiesOverlap(a, b)` — the **beat-local** file-overlap co-dispatch
|
|
29
|
+
* guard: true when two Stories' file footprints intersect. Two Stories
|
|
30
|
+
* that would touch the same file MUST NOT be admitted onto parallel
|
|
31
|
+
* `story-<id>` branches on the same beat (they would race the same path
|
|
32
|
+
* and produce a merge conflict at close). The comparison runs over
|
|
33
33
|
* the **widened** footprint (`storyWidenedFootprint`) — a declared
|
|
34
34
|
* `changes[]` is treated as a lower bound and widened from the paths the
|
|
35
35
|
* Story's own text names, because a guard that trusts a prediction cannot
|
|
36
36
|
* prevent the collision nobody predicted (Story #4875).
|
|
37
|
-
* - `
|
|
38
|
-
* scheduler. Returns the deterministic,
|
|
39
|
-
*
|
|
37
|
+
* - `planReadySet({ stories, doneIds, inFlight, globalCap,
|
|
38
|
+
* inFlightRecords })` — the scheduler. Returns the deterministic,
|
|
39
|
+
* overlap-free dispatch set (capped at `globalCap − inFlight`) **and**
|
|
40
|
+
* the Stories it withheld because a **concrete** path in their footprint
|
|
41
|
+
* is reserved by a Story still in flight from an earlier beat.
|
|
42
|
+
*
|
|
43
|
+
* The two guards deliberately draw the glob/UNKNOWN class differently
|
|
44
|
+
* (Story #4960). Within a beat an unknown-width footprint overlaps
|
|
45
|
+
* everything, because admitting two Stories whose real widths are unknown is
|
|
46
|
+
* the collision the guard exists to prevent. Across beats it reserves
|
|
47
|
+
* nothing: an in-flight Story's window spans its whole implementation, so a
|
|
48
|
+
* glob that reserved cross-beat would withhold every other Story for
|
|
49
|
+
* minutes-to-hours — and `resolve-stories.js` substitutes the UNKNOWN
|
|
50
|
+
* sentinel for any body it cannot parse, so a single malformed Story body
|
|
51
|
+
* would collapse an N-Story run to fully serial.
|
|
40
52
|
*
|
|
41
53
|
* Adjacency is re-derived from the supplied records via the shared
|
|
42
54
|
* `buildStoryAdjacency` builder (`lib/story-adjacency.js`) — the same
|
|
@@ -226,11 +238,29 @@ function storyWidenedFootprint(story) {
|
|
|
226
238
|
}
|
|
227
239
|
|
|
228
240
|
/**
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
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
|
+
/**
|
|
261
|
+
* **Beat-local** file-overlap co-dispatch guard. Returns `true` when two
|
|
262
|
+
* Stories' file footprints intersect — meaning they would race the same file
|
|
263
|
+
* if both were admitted onto parallel `story-<id>` branches on this beat.
|
|
234
264
|
*
|
|
235
265
|
* Comparison runs over the **widened** footprint
|
|
236
266
|
* ({@link storyWidenedFootprint}), not the declaration: a declared `changes[]`
|
|
@@ -238,32 +268,26 @@ function storyWidenedFootprint(story) {
|
|
|
238
268
|
* collide were being co-dispatched whenever the collision was not predicted
|
|
239
269
|
* (Story #4875).
|
|
240
270
|
*
|
|
241
|
-
*
|
|
271
|
+
* **A glob footprint overlaps EVERYTHING** → `true` (Story #4539/#4540).
|
|
272
|
+
* Comparison is exact-string, so a Story declaring `.agents/scripts/lib/**`
|
|
273
|
+
* would not match another declaring `.agents/scripts/lib/story-adjacency.js` —
|
|
274
|
+
* the guard would silently pass two Stories that genuinely race. Unknown width
|
|
275
|
+
* is not the same as no width: fail safe by serializing the rest of the beat.
|
|
242
276
|
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
* carries no information, and withholding on absence would serialize every
|
|
247
|
-
* run.
|
|
248
|
-
* - **A glob footprint overlaps EVERYTHING** → `true` (Story #4539/#4540).
|
|
249
|
-
* Comparison is exact-string, so a Story declaring
|
|
250
|
-
* `.agents/scripts/lib/**` would not match another declaring
|
|
251
|
-
* `.agents/scripts/lib/story-adjacency.js` — the guard would silently
|
|
252
|
-
* pass two Stories that genuinely race. Unknown width is not the same as
|
|
253
|
-
* no width: fail safe by serializing.
|
|
277
|
+
* That fail-safe is **beat-local and stays that way**. The cross-beat
|
|
278
|
+
* reservation uses {@link reservesConcretePath} instead, because a beat is a
|
|
279
|
+
* moment and an in-flight window is an implementation (Story #4960).
|
|
254
280
|
*
|
|
255
281
|
* @param {StoryRecord} a
|
|
256
282
|
* @param {StoryRecord} b
|
|
257
283
|
* @returns {boolean}
|
|
258
284
|
*/
|
|
259
285
|
export function storiesOverlap(a, b) {
|
|
260
|
-
const
|
|
261
|
-
if (
|
|
262
|
-
const fb =
|
|
263
|
-
if (fb.size === 0) return false;
|
|
286
|
+
const pair = widenedFootprintPair(a, b);
|
|
287
|
+
if (pair === null) return false;
|
|
288
|
+
const [fa, fb] = pair;
|
|
264
289
|
for (const path of fa) {
|
|
265
|
-
if (isGlobPath(path)) return true;
|
|
266
|
-
if (fb.has(path)) return true;
|
|
290
|
+
if (isGlobPath(path) || fb.has(path)) return true;
|
|
267
291
|
}
|
|
268
292
|
for (const path of fb) {
|
|
269
293
|
if (isGlobPath(path)) return true;
|
|
@@ -271,6 +295,42 @@ export function storiesOverlap(a, b) {
|
|
|
271
295
|
return false;
|
|
272
296
|
}
|
|
273
297
|
|
|
298
|
+
/**
|
|
299
|
+
* **Cross-beat** reservation guard: `true` only when the two widened
|
|
300
|
+
* footprints share a **concrete** (non-glob) path.
|
|
301
|
+
*
|
|
302
|
+
* A Story dispatched on an earlier beat holds its footprint for its entire
|
|
303
|
+
* implementation window, not for a moment, so the two guards cannot share a
|
|
304
|
+
* predicate (Story #4960):
|
|
305
|
+
*
|
|
306
|
+
* - A **concrete** shared path is a real, named collision — two branches
|
|
307
|
+
* editing `lib/foo.js` conflict at close whether the peer was admitted
|
|
308
|
+
* alongside or hours ago. It reserves, exactly as Story #4950 shipped.
|
|
309
|
+
* - A **glob** — or the UNKNOWN sentinel `resolve-stories.js` substitutes
|
|
310
|
+
* for an unparseable body — names no file. Reserving on it withheld every
|
|
311
|
+
* eligible Story against a single unknown-width blocker for that blocker's
|
|
312
|
+
* whole window, which is strictly worse than the serial run the
|
|
313
|
+
* reservation was meant to speed up. Glob paths are therefore skipped on
|
|
314
|
+
* both sides here; the beat-local {@link storiesOverlap} fail-safe is
|
|
315
|
+
* unchanged and still serializes them within a beat.
|
|
316
|
+
*
|
|
317
|
+
* Set intersection is symmetric, so scanning the reserving side alone finds
|
|
318
|
+
* every shared concrete path.
|
|
319
|
+
*
|
|
320
|
+
* @param {StoryRecord} held The in-flight Story holding the reservation.
|
|
321
|
+
* @param {StoryRecord} candidate The Story being considered for admission.
|
|
322
|
+
* @returns {boolean}
|
|
323
|
+
*/
|
|
324
|
+
function reservesConcretePath(held, candidate) {
|
|
325
|
+
const pair = widenedFootprintPair(held, candidate);
|
|
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;
|
|
332
|
+
}
|
|
333
|
+
|
|
274
334
|
/**
|
|
275
335
|
* Select the set of Stories safe to dispatch on this beat.
|
|
276
336
|
*
|
|
@@ -304,10 +364,12 @@ export function storiesOverlap(a, b) {
|
|
|
304
364
|
* already occupying a slot (executing / closing / dispatched-not-yet-
|
|
305
365
|
* labelled). When `slots <= 0`, the result is empty.
|
|
306
366
|
* 5. **Overlap guard.** Greedily admit eligible Stories in ascending-id
|
|
307
|
-
* order, skipping any whose file footprint overlaps
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
367
|
+
* order, skipping any whose file footprint overlaps a Story **already
|
|
368
|
+
* admitted this beat** ({@link storiesOverlap}) or shares a **concrete**
|
|
369
|
+
* path with a Story **still in flight from an earlier beat**
|
|
370
|
+
* (`inFlightRecords`, {@link reservesConcretePath}). A withheld Story
|
|
371
|
+
* stays eligible and is naturally re-considered on the next beat once
|
|
372
|
+
* the Story reserving its files has cleared.
|
|
311
373
|
*
|
|
312
374
|
* The result is deterministic: eligible Stories are considered in
|
|
313
375
|
* ascending-id order, so the same inputs always yield the same set.
|
|
@@ -325,22 +387,37 @@ export function storiesOverlap(a, b) {
|
|
|
325
387
|
* step 1 above). `false` keeps a foreign dependency as a gate
|
|
326
388
|
* (standalone / operator-DAG semantics); `true` prunes foreign edges so
|
|
327
389
|
* the DAG stays closed over the scheduled set (Epic semantics).
|
|
328
|
-
* @
|
|
329
|
-
*
|
|
390
|
+
* @param {StoryRecord[]} [args.inFlightRecords=[]] Records for the Stories
|
|
391
|
+
* already in flight, whose **concrete** footprint paths this beat must
|
|
392
|
+
* **reserve** rather than merely count. `inFlight` is a number and can only
|
|
393
|
+
* shrink capacity; without the records a Story admitted now can share files
|
|
394
|
+
* with one dispatched on an earlier beat and still implementing — a
|
|
395
|
+
* guaranteed merge conflict at close (Story #4950). A glob / UNKNOWN
|
|
396
|
+
* footprint reserves nothing (Story #4960). Callers that hold only ids (the
|
|
397
|
+
* `--dag`/`--in-flight` flag mode) pass nothing and get the pre-#4950
|
|
398
|
+
* same-beat-only behaviour.
|
|
399
|
+
* @returns {{ selected: StoryRecord[], withheldByInFlight: Array<{id: number, blockedBy: number}> }}
|
|
400
|
+
* `selected` is the dispatch set: a subset of `stories`, ascending by id,
|
|
401
|
+
* overlap-free, length ≤ `globalCap − inFlight`. `withheldByInFlight`
|
|
402
|
+
* names each eligible Story a reservation held back and the in-flight
|
|
403
|
+
* Story that holds it.
|
|
330
404
|
*/
|
|
331
|
-
export function
|
|
405
|
+
export function planReadySet({
|
|
332
406
|
stories,
|
|
333
407
|
doneIds = [],
|
|
334
408
|
inFlight = 0,
|
|
335
409
|
globalCap,
|
|
336
410
|
dropForeign = false,
|
|
411
|
+
inFlightRecords = [],
|
|
337
412
|
} = {}) {
|
|
338
413
|
const records = Array.isArray(stories) ? stories : [];
|
|
339
414
|
const cap = Number.isInteger(globalCap) ? globalCap : 0;
|
|
340
415
|
const inFlightCount =
|
|
341
416
|
Number.isInteger(inFlight) && inFlight > 0 ? inFlight : 0;
|
|
342
417
|
const slots = Math.max(0, cap - inFlightCount);
|
|
343
|
-
if (slots <= 0 || records.length === 0)
|
|
418
|
+
if (slots <= 0 || records.length === 0) {
|
|
419
|
+
return { selected: [], withheldByInFlight: [] };
|
|
420
|
+
}
|
|
344
421
|
|
|
345
422
|
// Step 1 — adjacency keyed by id. The `dropForeign` policy decides whether
|
|
346
423
|
// a dependency on an id outside the supplied set gates the dependent
|
|
@@ -373,13 +450,83 @@ export function selectReadySet({
|
|
|
373
450
|
}
|
|
374
451
|
|
|
375
452
|
// Steps 4 + 5 — greedily admit up to `slots`, skipping file-overlap
|
|
376
|
-
// collisions against the already-admitted set
|
|
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
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Greedily admit eligible Stories in ascending-id order under two distinct
|
|
465
|
+
* withholding rules, and report which ones a reservation held back.
|
|
466
|
+
*
|
|
467
|
+
* The two rules are distinct predicates, not one applied twice: the
|
|
468
|
+
* cross-beat reservation matches only a shared **concrete** path
|
|
469
|
+
* ({@link reservesConcretePath}), while the same-beat guard also serializes
|
|
470
|
+
* unknown-width footprints ({@link storiesOverlap}). See both for why.
|
|
471
|
+
*
|
|
472
|
+
* The reservation check runs **first**, so a Story racing both an in-flight
|
|
473
|
+
* Story and a same-beat peer is reported against the in-flight one: that is
|
|
474
|
+
* the longer-lived and more informative blocker (a Story that has been
|
|
475
|
+
* implementing for beats, not one merely admitted a moment ago), and checking
|
|
476
|
+
* it first is what makes the report complete — every withheld-by-reservation
|
|
477
|
+
* Story appears in it. Ordering cannot change `selected`: a candidate either
|
|
478
|
+
* rule rejects is skipped whichever runs first; only which list it is
|
|
479
|
+
* reported in depends on the order.
|
|
480
|
+
*
|
|
481
|
+
* @param {object} args
|
|
482
|
+
* @param {number[]} args.eligibleIds Ascending eligible Story ids.
|
|
483
|
+
* @param {Map<number, StoryRecord>} args.byId
|
|
484
|
+
* @param {number} args.slots Remaining dispatch capacity.
|
|
485
|
+
* @param {StoryRecord[]} args.reserved In-flight Story records.
|
|
486
|
+
* @returns {{ selected: StoryRecord[], withheldByInFlight: Array<{id: number, blockedBy: number}> }}
|
|
487
|
+
*/
|
|
488
|
+
function admitStories({ eligibleIds, byId, slots, reserved }) {
|
|
377
489
|
const selected = [];
|
|
490
|
+
const withheldByInFlight = [];
|
|
378
491
|
for (const id of eligibleIds) {
|
|
379
492
|
if (selected.length >= slots) break;
|
|
380
493
|
const rec = byId.get(id);
|
|
494
|
+
const blockedBy = findInFlightBlocker(rec, id, reserved);
|
|
495
|
+
if (blockedBy !== null) {
|
|
496
|
+
withheldByInFlight.push({ id, blockedBy });
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
381
499
|
if (selected.some((picked) => storiesOverlap(picked, rec))) continue;
|
|
382
500
|
selected.push(rec);
|
|
383
501
|
}
|
|
384
|
-
return selected;
|
|
502
|
+
return { selected, withheldByInFlight };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* The id of the in-flight Story whose widened footprint reserves a concrete
|
|
507
|
+
* path the candidate would race, or `null` when none does.
|
|
508
|
+
*
|
|
509
|
+
* Two records are skipped rather than treated as blockers:
|
|
510
|
+
*
|
|
511
|
+
* - **The candidate itself.** Probe mode hands the whole record set to both
|
|
512
|
+
* arguments, and an in-flight Story classifies `executing` rather than
|
|
513
|
+
* `ready`, so a candidate can never legitimately appear here — but a
|
|
514
|
+
* caller that double-lists one Story must not have it withhold itself.
|
|
515
|
+
* - **An unidentifiable record.** A withholding this function cannot name
|
|
516
|
+
* is one the envelope cannot explain, and an unexplained unfilled slot is
|
|
517
|
+
* the exact operator-facing failure this reservation exists to remove.
|
|
518
|
+
* Probe-mode records always carry an integer id, so this is defensive.
|
|
519
|
+
*
|
|
520
|
+
* @param {StoryRecord} candidate
|
|
521
|
+
* @param {number} candidateId
|
|
522
|
+
* @param {StoryRecord[]} reserved
|
|
523
|
+
* @returns {number|null}
|
|
524
|
+
*/
|
|
525
|
+
function findInFlightBlocker(candidate, candidateId, reserved) {
|
|
526
|
+
for (const held of reserved) {
|
|
527
|
+
const heldId = storyIdOf(held);
|
|
528
|
+
if (heldId === null || heldId === candidateId) continue;
|
|
529
|
+
if (reservesConcretePath(held, candidate)) return heldId;
|
|
530
|
+
}
|
|
531
|
+
return null;
|
|
385
532
|
}
|
|
@@ -47,6 +47,7 @@ import { parentPort } from 'node:worker_threads';
|
|
|
47
47
|
import { finalizeMethodRows } from '../crap-engine.js';
|
|
48
48
|
import { analyzeOnce } from '../crap-utils.js';
|
|
49
49
|
import { prepareSourceForScoring } from '../transpile.js';
|
|
50
|
+
import { serveWorkerMessages } from './serve-worker-messages.js';
|
|
50
51
|
|
|
51
52
|
/**
|
|
52
53
|
* Pure handler for a single inbound worker message. Exported so unit tests
|
|
@@ -163,13 +164,6 @@ export function handleCombinedMiCrapWorkerMessage(msg, deps = {}) {
|
|
|
163
164
|
});
|
|
164
165
|
}
|
|
165
166
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if (out.kind === 'exit') {
|
|
170
|
-
parentPort.close();
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
parentPort.postMessage(out.message);
|
|
174
|
-
});
|
|
175
|
-
}
|
|
167
|
+
serveWorkerMessages(parentPort, (msg) =>
|
|
168
|
+
handleCombinedMiCrapWorkerMessage(msg),
|
|
169
|
+
);
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
import { parentPort } from 'node:worker_threads';
|
|
33
33
|
import { calculateCrapForSource, finalizeMethodRows } from '../crap-engine.js';
|
|
34
34
|
import { prepareSourceForScoring } from '../transpile.js';
|
|
35
|
+
import { serveWorkerMessages } from './serve-worker-messages.js';
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
38
|
* Pure handler for a single inbound worker message. Exported so unit
|
|
@@ -161,13 +162,4 @@ export function handleCrapWorkerMessage(msg, _coverage, deps = {}) {
|
|
|
161
162
|
};
|
|
162
163
|
}
|
|
163
164
|
|
|
164
|
-
|
|
165
|
-
parentPort.on('message', (msg) => {
|
|
166
|
-
const out = handleCrapWorkerMessage(msg, null);
|
|
167
|
-
if (out.kind === 'exit') {
|
|
168
|
-
parentPort.close();
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
parentPort.postMessage(out.message);
|
|
172
|
-
});
|
|
173
|
-
}
|
|
165
|
+
serveWorkerMessages(parentPort, (msg) => handleCrapWorkerMessage(msg, null));
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
calculateReportForFile,
|
|
41
41
|
} from '../maintainability-engine.js';
|
|
42
42
|
import { transpileIfNeeded } from '../transpile.js';
|
|
43
|
+
import { serveWorkerMessages } from './serve-worker-messages.js';
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
46
|
* Score a pre-sourced content string (Story #3696). Mirrors
|
|
@@ -125,13 +126,6 @@ export function handleMaintainabilityReportWorkerMessage(msg, deps = {}) {
|
|
|
125
126
|
}
|
|
126
127
|
}
|
|
127
128
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (out.kind === 'exit') {
|
|
132
|
-
parentPort.close();
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
parentPort.postMessage(out.message);
|
|
136
|
-
});
|
|
137
|
-
}
|
|
129
|
+
serveWorkerMessages(parentPort, (msg) =>
|
|
130
|
+
handleMaintainabilityReportWorkerMessage(msg),
|
|
131
|
+
);
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import { parentPort } from 'node:worker_threads';
|
|
23
23
|
import { scoreFile } from '../maintainability-engine.js';
|
|
24
|
+
import { serveWorkerMessages } from './serve-worker-messages.js';
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* Pure handler for a single inbound worker message. Exported so unit
|
|
@@ -72,13 +73,6 @@ export function handleMaintainabilityWorkerMessage(msg, deps = {}) {
|
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (out.kind === 'exit') {
|
|
79
|
-
parentPort.close();
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
parentPort.postMessage(out.message);
|
|
83
|
-
});
|
|
84
|
-
}
|
|
76
|
+
serveWorkerMessages(parentPort, (msg) =>
|
|
77
|
+
handleMaintainabilityWorkerMessage(msg),
|
|
78
|
+
);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/workers/serve-worker-messages.js — the shared CPU-pool worker skeleton.
|
|
3
|
+
*
|
|
4
|
+
* Every worker under `lib/workers/` is a pure message handler plus the same
|
|
5
|
+
* eight-line `parentPort` wiring: subscribe, run the handler, close on an
|
|
6
|
+
* `exit` verdict, post the reply otherwise. That wiring was copied into all
|
|
7
|
+
* four workers; it lives here once instead (Story #4926).
|
|
8
|
+
*
|
|
9
|
+
* The handler contract is unchanged — see `lib/cpu-pool.js`:
|
|
10
|
+
* IN : `{ item: … }` — one unit of work
|
|
11
|
+
* `{ exit: true }` — drain & terminate
|
|
12
|
+
* OUT : `{ kind: 'exit' }` | `{ kind: 'reply', message: object }`
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Wire a pure worker message handler onto a `worker_threads` port.
|
|
17
|
+
*
|
|
18
|
+
* A falsy `port` is a no-op, so a worker module imported directly by a unit
|
|
19
|
+
* test (where `parentPort` is `null`) installs no listener and stays pure.
|
|
20
|
+
*
|
|
21
|
+
* @param {import('node:worker_threads').MessagePort|null|undefined} port
|
|
22
|
+
* @param {(msg: unknown) => {kind: 'exit'} | {kind: 'reply', message: object}} handle
|
|
23
|
+
* @returns {void}
|
|
24
|
+
*/
|
|
25
|
+
export function serveWorkerMessages(port, handle) {
|
|
26
|
+
if (!port) return;
|
|
27
|
+
port.on('message', (msg) => {
|
|
28
|
+
const out = handle(msg);
|
|
29
|
+
if (out.kind === 'exit') {
|
|
30
|
+
port.close();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
port.postMessage(out.message);
|
|
34
|
+
});
|
|
35
|
+
}
|