mandrel 2.35.0 → 2.37.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/agentrc-reference.json +3 -1
- package/.agents/docs/configuration.md +27 -0
- package/.agents/rules/testing-standards.md +56 -0
- package/.agents/schemas/agentrc.schema.json +11 -0
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
- package/.agents/scripts/boot-sweep.js +7 -1
- package/.agents/scripts/check-context-budget.js +195 -103
- package/.agents/scripts/check-cyclomatic.js +112 -42
- package/.agents/scripts/check-doc-links.js +23 -2
- package/.agents/scripts/check-generated-validator.js +202 -0
- package/.agents/scripts/check-lifecycle-lint.js +2 -68
- package/.agents/scripts/check-schema-references.js +2 -28
- package/.agents/scripts/drain-pending-cleanup.js +18 -1
- package/.agents/scripts/git-cleanup.js +2 -0
- package/.agents/scripts/lib/baselines/components.js +32 -2
- package/.agents/scripts/lib/baselines/env-overrides.js +1 -1
- package/.agents/scripts/lib/bootstrap/branch-protection.js +1 -1
- package/.agents/scripts/lib/bootstrap/install-ledger.js +1 -1
- package/.agents/scripts/lib/bootstrap/prompt.js +1 -1
- package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +2 -2
- package/.agents/scripts/lib/checks/loop-health.js +1 -1
- package/.agents/scripts/lib/cli-args.js +0 -2
- package/.agents/scripts/lib/close-validation/gates.js +1 -1
- package/.agents/scripts/lib/close-validation/process.js +1 -1
- package/.agents/scripts/lib/config/ci.js +18 -0
- package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +13 -0
- package/.agents/scripts/lib/config-settings-schema.js +46 -6
- package/.agents/scripts/lib/coverage-baseline.js +2 -2
- package/.agents/scripts/lib/cpu-pool.js +90 -10
- package/.agents/scripts/lib/crap-utils.js +6 -2
- package/.agents/scripts/lib/cyclomatic-ceiling.js +28 -6
- package/.agents/scripts/lib/cyclomatic-scope.js +144 -0
- package/.agents/scripts/lib/dependency-version.js +86 -0
- package/.agents/scripts/lib/duplicate-search.js +1 -1
- package/.agents/scripts/lib/errors/index.js +1 -1
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +2 -2
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +1 -1
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +1 -1
- package/.agents/scripts/lib/generated/agentrc-validator.js +16 -0
- package/.agents/scripts/lib/install-cmd-parser.js +1 -1
- package/.agents/scripts/lib/knip-entry-sync.js +1 -57
- package/.agents/scripts/lib/maintainability-utils.js +108 -10
- package/.agents/scripts/lib/observability/source-classifier.js +1 -1
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +22 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -14
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/merged-tip.js +132 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +56 -11
- package/.agents/scripts/lib/orchestration/merge-block-class.js +10 -1
- package/.agents/scripts/lib/orchestration/merge-poll.js +164 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +70 -9
- package/.agents/scripts/lib/orchestration/pr-watch.js +1 -30
- package/.agents/scripts/lib/orchestration/reassert-status-column.js +2 -2
- package/.agents/scripts/lib/orchestration/review-providers/codex.js +3 -3
- package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +1 -1
- package/.agents/scripts/lib/orchestration/review-providers/native.js +9 -2
- package/.agents/scripts/lib/orchestration/review-providers/review-depth.js +2 -2
- package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +145 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -5
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +52 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +9 -1
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +2 -5
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +1 -1
- package/.agents/scripts/lib/orchestration/ticket-lease.js +34 -9
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +3 -1
- package/.agents/scripts/lib/preflight-runner.js +3 -3
- package/.agents/scripts/lib/qa/qa-session.js +1 -1
- package/.agents/scripts/lib/runtime-deps/manifest.js +1 -7
- package/.agents/scripts/lib/runtime-deps/scan-imports.js +3 -58
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +284 -25
- package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
- package/.agents/scripts/lib/source-text/strip-js-comments.js +115 -0
- package/.agents/scripts/lib/test-isolate/runner.js +3 -3
- package/.agents/scripts/lib/test-runner-contract.js +14 -6
- package/.agents/scripts/lib/test-tiers.js +135 -18
- package/.agents/scripts/lib/transpile.js +16 -3
- package/.agents/scripts/lib/worktree/lifecycle/pending-cleanup.js +169 -31
- package/.agents/scripts/lib/worktree/node-modules-strategy.js +2 -5
- package/.agents/scripts/notify.js +4 -10
- package/.agents/scripts/pr-watch-with-update.js +305 -137
- package/.agents/scripts/providers/github/tickets.js +109 -16
- package/.agents/scripts/quality-preview.js +162 -70
- package/.agents/scripts/run-test-profile.js +8 -5
- package/.agents/scripts/run-tests.js +79 -14
- package/.agents/scripts/single-story-close.js +0 -2
- package/.agents/scripts/single-story-init.js +5 -1
- package/.agents/workflows/audit-documentation.md +5 -6
- package/docs/CHANGELOG.md +50 -0
- package/lib/cli/migrate.js +100 -94
- package/lib/migrations/helpers/retire-agentrc-key.js +200 -0
- package/lib/migrations/index.js +32 -33
- package/lib/migrations/steps/2.1.0-retire-mi-drop-knobs.js +21 -80
- package/lib/migrations/steps/2.1.0-retire-verify-concurrency-cap.js +15 -87
- package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +13 -71
- package/lib/migrations/steps/2.20.0-retire-codebase-snapshot.js +13 -101
- package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +13 -100
- package/package.json +9 -5
- package/.agents/scripts/generate-lifecycle-docs.js +0 -237
- package/.agents/scripts/lib/audit-suite/frontmatter-lint.js +0 -32
- package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +0 -37
- package/.agents/scripts/lib/cli/parse-numeric.js +0 -60
- package/.agents/scripts/lib/close-validation/telemetry.js +0 -79
- package/.agents/scripts/lib/orchestration/label-transitions.js +0 -44
- package/.agents/scripts/lib/orchestration/parked-follow-ons.js +0 -147
- package/.agents/scripts/lib/orchestration/phase-runner.js +0 -88
- package/.agents/scripts/lib/orchestration/recut.js +0 -56
|
@@ -23,6 +23,46 @@ export const INTEGRATION_INCLUDE = [
|
|
|
23
23
|
|
|
24
24
|
const matchesIntegration = picomatch(INTEGRATION_INCLUDE, { dot: true });
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Globs for the `e2e` tier — real-binary suites under `tests/e2e/` that pack
|
|
28
|
+
* this repository, install it into a temp consumer and drive the shipped
|
|
29
|
+
* `mandrel` binary end to end.
|
|
30
|
+
*
|
|
31
|
+
* They are the most expensive files the repository owns by an order of
|
|
32
|
+
* magnitude: one `npm pack` plus real `npm install` spawns per file, measured
|
|
33
|
+
* (Story #5111) at ~7 s of system time and ~17 s of summed install wall clock
|
|
34
|
+
* for `update-chain.integration.test.js` alone. Every one of those seconds was
|
|
35
|
+
* charged to `npm test` — i.e. to every pre-push hook and every local
|
|
36
|
+
* iteration — for a signal that only changes when the release-shaped install
|
|
37
|
+
* path changes.
|
|
38
|
+
*
|
|
39
|
+
* So they get their own tier and leave every other one: `full` (`npm test`),
|
|
40
|
+
* `quick` and `integration` all exclude them, and `npm run test:e2e` is how
|
|
41
|
+
* they run. CI runs that tier as its own job on every PR, so the
|
|
42
|
+
* release-shaped path keeps its per-PR signal.
|
|
43
|
+
*
|
|
44
|
+
* Deliberately not exported: a second reader of this list is a second place
|
|
45
|
+
* for the tier definition to drift. `listTestFilesForTier('e2e', root)` is the
|
|
46
|
+
* public answer to "which files are e2e", and the tests assert through it.
|
|
47
|
+
*/
|
|
48
|
+
const E2E_INCLUDE = ['tests/e2e/**/*.test.js'];
|
|
49
|
+
|
|
50
|
+
const matchesE2E = picomatch(E2E_INCLUDE, { dot: true });
|
|
51
|
+
|
|
52
|
+
/** Tier names `parseTierArgv` accepts, in the order `--help` lists them. */
|
|
53
|
+
const TIERS = ['full', 'quick', 'integration', 'e2e'];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `node --test` flags the runner forwards verbatim to the child.
|
|
57
|
+
*
|
|
58
|
+
* Everything else that looks like a flag is a mistake — a typo, a retired
|
|
59
|
+
* option, or a flag meant for `npm` that landed after the `--` separator —
|
|
60
|
+
* and `parseTierArgv` rejects it rather than passing it to `node --test`,
|
|
61
|
+
* which reads an unknown `--flag` as a *file pattern* and silently runs a
|
|
62
|
+
* suite that matches nothing while exiting 0.
|
|
63
|
+
*/
|
|
64
|
+
const PASSTHROUGH_FLAGS = ['--test-name-pattern', '--test-only'];
|
|
65
|
+
|
|
26
66
|
/**
|
|
27
67
|
* Repo-relative roots the tier walker scans for test files (names ending in
|
|
28
68
|
* `.test.js`).
|
|
@@ -47,12 +87,22 @@ const TEST_WALK_ROOTS = ['tests', 'lib', '.agents/scripts'];
|
|
|
47
87
|
* globs are scoped to `__tests__` subtrees so they only match colocated
|
|
48
88
|
* tests, never the shipped source modules themselves.
|
|
49
89
|
*
|
|
50
|
-
* Exported because
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* `
|
|
54
|
-
*
|
|
55
|
-
*
|
|
90
|
+
* Exported because it is the **measured surface**: `run-coverage.js` — the
|
|
91
|
+
* required CI job, and the run every coverage / CRAP baseline is scored from —
|
|
92
|
+
* consumes it directly. Story #4922: the coverage runner used to restate
|
|
93
|
+
* `tests/**` on its own, so the 47 colocated `__tests__` files ran under
|
|
94
|
+
* `npm test` but were absent from the measured surface, leaving the coverage
|
|
95
|
+
* and CRAP numbers computed over code the measuring run never executed.
|
|
96
|
+
* Consume this constant; never restate a glob.
|
|
97
|
+
*
|
|
98
|
+
* Story #5111 made this a strict **superset** of the `full` runner tier: the
|
|
99
|
+
* measured surface still includes `tests/e2e/**`, while `npm test` no longer
|
|
100
|
+
* runs it. That asymmetry is deliberate and load-bearing. The e2e suites drive
|
|
101
|
+
* the shipped binary through `bin/mandrel.js` and `lib/cli/update.js` in real
|
|
102
|
+
* child processes, and c8's `NODE_V8_COVERAGE` is inherited by those children —
|
|
103
|
+
* so dropping them from the measured run would deflate exactly the CLI files
|
|
104
|
+
* they exist to exercise and red the coverage ratchet on code nobody touched.
|
|
105
|
+
* Cheapening the pre-push loop must not cost the measurement its subject.
|
|
56
106
|
*/
|
|
57
107
|
export const FULL_TIER_GLOBS = [
|
|
58
108
|
'tests/**/*.test.js',
|
|
@@ -81,10 +131,47 @@ function walkTestFiles(dir, prefix, fsLike) {
|
|
|
81
131
|
return out;
|
|
82
132
|
}
|
|
83
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Split the walked set into the `e2e` tier and the remainder every other tier
|
|
136
|
+
* is drawn from, so an e2e file belongs to exactly one tier and `npm test`
|
|
137
|
+
* never pays for it.
|
|
138
|
+
*
|
|
139
|
+
* @param {string[]} all
|
|
140
|
+
* @returns {{ e2e: string[], rest: string[] }}
|
|
141
|
+
*/
|
|
142
|
+
function partitionE2E(all) {
|
|
143
|
+
const e2e = all.filter((file) => matchesE2E(file));
|
|
144
|
+
const e2eSet = new Set(e2e);
|
|
145
|
+
return { e2e, rest: all.filter((file) => !e2eSet.has(file)) };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Split the non-e2e remainder into the slow `integration` tier and the `quick`
|
|
150
|
+
* complement — the historical partition, unchanged.
|
|
151
|
+
*
|
|
152
|
+
* @param {string[]} rest
|
|
153
|
+
* @param {'quick' | 'integration'} tier
|
|
154
|
+
* @returns {string[]}
|
|
155
|
+
*/
|
|
156
|
+
function splitBySpeed(rest, tier) {
|
|
157
|
+
const integration = rest.filter((file) => matchesIntegration(file));
|
|
158
|
+
if (tier === 'integration') {
|
|
159
|
+
return integration;
|
|
160
|
+
}
|
|
161
|
+
const integrationSet = new Set(integration);
|
|
162
|
+
return rest.filter((file) => !integrationSet.has(file));
|
|
163
|
+
}
|
|
164
|
+
|
|
84
165
|
/**
|
|
85
166
|
* List repo-relative test file paths for a tier.
|
|
86
167
|
*
|
|
87
|
-
*
|
|
168
|
+
* `full` used to return {@link FULL_TIER_GLOBS} verbatim and let `node --test`
|
|
169
|
+
* expand them. It enumerates files instead since Story #5111, because the one
|
|
170
|
+
* thing a glob list cannot express is an exclusion: `node --test` has no
|
|
171
|
+
* negative pattern, so "everything except `tests/e2e/**`" is only sayable as
|
|
172
|
+
* a file set. The measured surface keeps the globs (see `FULL_TIER_GLOBS`).
|
|
173
|
+
*
|
|
174
|
+
* @param {'full' | 'quick' | 'integration' | 'e2e'} tier
|
|
88
175
|
* @param {string} repoRoot
|
|
89
176
|
* @param {typeof fs} [fsLike]
|
|
90
177
|
* @returns {string[]}
|
|
@@ -93,34 +180,64 @@ export function listTestFilesForTier(tier, repoRoot, fsLike = fs) {
|
|
|
93
180
|
const all = TEST_WALK_ROOTS.flatMap((root) =>
|
|
94
181
|
walkTestFiles(path.join(repoRoot, root), root, fsLike),
|
|
95
182
|
).sort();
|
|
96
|
-
|
|
97
|
-
|
|
183
|
+
const { e2e, rest } = partitionE2E(all);
|
|
184
|
+
if (tier === 'e2e') {
|
|
185
|
+
return e2e;
|
|
98
186
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
return integration;
|
|
187
|
+
if (tier === 'full') {
|
|
188
|
+
return rest;
|
|
102
189
|
}
|
|
103
|
-
|
|
104
|
-
|
|
190
|
+
return splitBySpeed(rest, tier);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Reject argv tokens that look like flags but are neither `--tier` (already
|
|
195
|
+
* consumed) nor a sanctioned `node --test` pass-through.
|
|
196
|
+
*
|
|
197
|
+
* Silence was the old behaviour and the reason this exists: the runner
|
|
198
|
+
* forwarded every unrecognized token verbatim, and `node --test` treats an
|
|
199
|
+
* unknown `--flag` as another **file pattern**. A typo therefore produced a
|
|
200
|
+
* run that matched nothing, printed a plausible-looking summary and exited 0 —
|
|
201
|
+
* a green that proved nothing.
|
|
202
|
+
*
|
|
203
|
+
* @param {string[]} rest
|
|
204
|
+
* @throws {Error} naming both the accepted tiers and the accepted flags.
|
|
205
|
+
*/
|
|
206
|
+
function assertKnownFlags(rest) {
|
|
207
|
+
const unknown = rest.filter(
|
|
208
|
+
(arg) =>
|
|
209
|
+
arg.startsWith('--') &&
|
|
210
|
+
!PASSTHROUGH_FLAGS.includes(arg) &&
|
|
211
|
+
!PASSTHROUGH_FLAGS.some((flag) => arg.startsWith(`${flag}=`)),
|
|
212
|
+
);
|
|
213
|
+
if (unknown.length === 0) return;
|
|
214
|
+
throw new Error(
|
|
215
|
+
`[run-tests] unrecognized argument(s): ${unknown.join(', ')}. ` +
|
|
216
|
+
`Accepted: --tier <${TIERS.join('|')}>, ${PASSTHROUGH_FLAGS.join(', ')}, --help. ` +
|
|
217
|
+
'Unrecognized flags are not forwarded: `node --test` would read them as ' +
|
|
218
|
+
'file patterns and exit 0 having run nothing.',
|
|
219
|
+
);
|
|
105
220
|
}
|
|
106
221
|
|
|
107
222
|
/**
|
|
108
|
-
* Parse `--tier <name>` from argv. Unknown tiers throw.
|
|
223
|
+
* Parse `--tier <name>` from argv. Unknown tiers and unknown flags throw.
|
|
109
224
|
*
|
|
110
225
|
* @param {string[]} argv
|
|
111
|
-
* @returns {{ tier: 'full' | 'quick' | 'integration', rest: string[] }}
|
|
226
|
+
* @returns {{ tier: 'full' | 'quick' | 'integration' | 'e2e', rest: string[] }}
|
|
112
227
|
*/
|
|
113
228
|
export function parseTierArgv(argv) {
|
|
114
229
|
const tierIdx = argv.indexOf('--tier');
|
|
115
230
|
if (tierIdx === -1) {
|
|
231
|
+
assertKnownFlags(argv);
|
|
116
232
|
return { tier: 'full', rest: argv };
|
|
117
233
|
}
|
|
118
234
|
const tier = argv[tierIdx + 1];
|
|
119
|
-
if (!tier || !
|
|
235
|
+
if (!tier || !TIERS.includes(tier)) {
|
|
120
236
|
throw new Error(
|
|
121
|
-
`[run-tests] --tier requires one of:
|
|
237
|
+
`[run-tests] --tier requires one of: ${TIERS.join(', ')} (got ${JSON.stringify(tier)})`,
|
|
122
238
|
);
|
|
123
239
|
}
|
|
124
240
|
const rest = argv.filter((_, i) => i !== tierIdx && i !== tierIdx + 1);
|
|
241
|
+
assertKnownFlags(rest);
|
|
125
242
|
return { tier, rest };
|
|
126
243
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { createRequire, SourceMap } from 'node:module';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { resolveDependencyVersion } from './dependency-version.js';
|
|
4
5
|
import { Logger } from './Logger.js';
|
|
5
6
|
|
|
6
7
|
const require = createRequire(import.meta.url);
|
|
@@ -22,18 +23,30 @@ function loadTypeScript() {
|
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
let _tsVersion = null;
|
|
27
|
+
|
|
25
28
|
/**
|
|
26
29
|
* Resolve the `typescript` package version, used to stamp baselines so
|
|
27
30
|
* consumers can detect transpiler drift. Returns `'0.0.0'` when the
|
|
28
31
|
* dependency is unresolvable — callers treat that sentinel as "unknown
|
|
29
32
|
* environment" and may refuse to persist a baseline that includes TS rows.
|
|
30
33
|
*
|
|
34
|
+
* **The version is read from the package manifest, never by evaluating the
|
|
35
|
+
* compiler** (Story #5109) — see
|
|
36
|
+
* [`dependency-version.js`](dependency-version.js) for how, and for what it
|
|
37
|
+
* cost to do it the other way. The manifest returns the identical string, so
|
|
38
|
+
* the stamp (and therefore every committed baseline envelope) is
|
|
39
|
+
* byte-identical either way. The compiler itself is still loaded — lazily, by
|
|
40
|
+
* `transpileIfNeeded` — the first time a `.ts`/`.tsx` input is actually
|
|
41
|
+
* transpiled.
|
|
42
|
+
*
|
|
31
43
|
* @returns {string}
|
|
32
44
|
*/
|
|
33
45
|
export function resolveTsTranspilerVersion() {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
46
|
+
if (_tsVersion === null) {
|
|
47
|
+
_tsVersion = resolveDependencyVersion('typescript', require) ?? '0.0.0';
|
|
48
|
+
}
|
|
49
|
+
return _tsVersion;
|
|
37
50
|
}
|
|
38
51
|
|
|
39
52
|
function isTypeScriptPath(filePath) {
|
|
@@ -15,14 +15,39 @@
|
|
|
15
15
|
*
|
|
16
16
|
* The manifest itself lives under `.worktrees/`, which is already
|
|
17
17
|
* git-ignored; no tracked state is mutated.
|
|
18
|
+
*
|
|
19
|
+
* **Story #5112 — the manifest is shared mutable state.** `recordPendingCleanup`
|
|
20
|
+
* runs from the close reap and `drainPendingCleanup` runs from the `/plan`
|
|
21
|
+
* boot, so two concurrent delivery sessions can read-modify-write the same
|
|
22
|
+
* file. Three properties close that:
|
|
23
|
+
*
|
|
24
|
+
* 1. **Atomic write.** Every write lands in a pid-scoped temp file and is
|
|
25
|
+
* then `rename`d over the manifest, so a reader never observes a half-
|
|
26
|
+
* written array (`readManifest` treats a torn or absent file as empty,
|
|
27
|
+
* which used to silently *drop* the backlog).
|
|
28
|
+
* 2. **Mutual exclusion.** Read-modify-write runs under the shared
|
|
29
|
+
* `acquireSweepLock` primitive on a manifest-adjacent lockfile.
|
|
30
|
+
* 3. **Merge before write.** The drain holds its snapshot across awaited
|
|
31
|
+
* removals, so it re-reads and merges by `storyId` before its final
|
|
32
|
+
* write — an entry recorded mid-drain survives instead of being erased
|
|
33
|
+
* by a stale snapshot.
|
|
18
34
|
*/
|
|
19
35
|
|
|
20
36
|
import fs from 'node:fs';
|
|
21
37
|
import { rm as fsPromisesRm } from 'node:fs/promises';
|
|
22
38
|
import path from 'node:path';
|
|
23
39
|
import { NOOP_LOGGER } from '../../Logger.js';
|
|
40
|
+
import { acquireSweepLock } from '../../single-story-sweep/sweep-lock.js';
|
|
24
41
|
|
|
25
42
|
const MANIFEST_FILENAME = '.pending-cleanup.json';
|
|
43
|
+
const MANIFEST_LOCK_FILENAME = '.pending-cleanup.lock';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Staleness threshold for the manifest lock. Deliberately short: the guarded
|
|
47
|
+
* section is a read, an in-memory merge and a rename — milliseconds — so a
|
|
48
|
+
* lockfile older than this is a crashed holder, not a slow one.
|
|
49
|
+
*/
|
|
50
|
+
const MANIFEST_LOCK_TIMEOUT_MS = 5_000;
|
|
26
51
|
/**
|
|
27
52
|
* After a reap hands off to the manifest, `attempts` counts failed
|
|
28
53
|
* `drainPendingCleanup` passes (initial hand-off uses `attempts: 0`). The entry
|
|
@@ -35,6 +60,49 @@ export function manifestPath(worktreeRoot) {
|
|
|
35
60
|
return path.join(worktreeRoot, MANIFEST_FILENAME);
|
|
36
61
|
}
|
|
37
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Path of the lockfile guarding manifest read-modify-write. Manifest-adjacent
|
|
65
|
+
* (same gitignored `.worktrees/` directory) so it needs no extra config and
|
|
66
|
+
* shares the manifest's lifetime. Module-private: the lock is internal to
|
|
67
|
+
* this manifest's read-modify-write, and callers only ever observe that a
|
|
68
|
+
* mutation completed and left nothing behind.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} worktreeRoot
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
function manifestLockPath(worktreeRoot) {
|
|
74
|
+
return path.join(worktreeRoot, MANIFEST_LOCK_FILENAME);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Run `fn` under the manifest lock. Best-effort in the same sense as every
|
|
79
|
+
* other use of this primitive: on contention the work still runs (the
|
|
80
|
+
* merge-before-write below is what makes a lost race non-destructive), so a
|
|
81
|
+
* stuck lockfile can never wedge a reap. Always releases.
|
|
82
|
+
*
|
|
83
|
+
* @template T
|
|
84
|
+
* @param {string} worktreeRoot
|
|
85
|
+
* @param {() => T} fn
|
|
86
|
+
* @returns {T}
|
|
87
|
+
*/
|
|
88
|
+
function withManifestLock(worktreeRoot, fn) {
|
|
89
|
+
const lock = acquireSweepLock({
|
|
90
|
+
lockPath: manifestLockPath(worktreeRoot),
|
|
91
|
+
timeoutMs: MANIFEST_LOCK_TIMEOUT_MS,
|
|
92
|
+
});
|
|
93
|
+
try {
|
|
94
|
+
return fn();
|
|
95
|
+
} finally {
|
|
96
|
+
if (lock.acquired) {
|
|
97
|
+
try {
|
|
98
|
+
lock.release();
|
|
99
|
+
} catch {
|
|
100
|
+
// Release is best-effort; a stale lockfile expires on its own.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
38
106
|
export function readManifest(worktreeRoot) {
|
|
39
107
|
const p = manifestPath(worktreeRoot);
|
|
40
108
|
if (!fs.existsSync(p)) return [];
|
|
@@ -47,6 +115,13 @@ export function readManifest(worktreeRoot) {
|
|
|
47
115
|
}
|
|
48
116
|
}
|
|
49
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Write the manifest atomically: serialize into a pid-scoped temp file in the
|
|
120
|
+
* same directory (so `rename` stays within one filesystem and is therefore
|
|
121
|
+
* atomic), then rename it over the manifest. A concurrent reader sees either
|
|
122
|
+
* the whole previous manifest or the whole new one — never a truncated array
|
|
123
|
+
* that `readManifest`'s catch would silently turn into "no pending cleanups".
|
|
124
|
+
*/
|
|
50
125
|
function writeManifest(worktreeRoot, entries) {
|
|
51
126
|
const p = manifestPath(worktreeRoot);
|
|
52
127
|
if (!Array.isArray(entries) || entries.length === 0) {
|
|
@@ -58,7 +133,37 @@ function writeManifest(worktreeRoot, entries) {
|
|
|
58
133
|
return;
|
|
59
134
|
}
|
|
60
135
|
fs.mkdirSync(worktreeRoot, { recursive: true });
|
|
61
|
-
|
|
136
|
+
writeFileAtomic(p, `${JSON.stringify(entries, null, 2)}\n`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Write `contents` to `targetPath` atomically: serialize into a pid-scoped
|
|
141
|
+
* temp file beside it (same directory, so the rename stays within one
|
|
142
|
+
* filesystem and is therefore atomic), then rename it into place. On failure
|
|
143
|
+
* the temp file is reaped and the error propagates — a half-written file is
|
|
144
|
+
* never left where a reader could pick it up.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} targetPath
|
|
147
|
+
* @param {string} contents
|
|
148
|
+
*/
|
|
149
|
+
function writeFileAtomic(targetPath, contents) {
|
|
150
|
+
const tmp = `${targetPath}.${process.pid}.tmp`;
|
|
151
|
+
try {
|
|
152
|
+
fs.writeFileSync(tmp, contents, 'utf8');
|
|
153
|
+
fs.renameSync(tmp, targetPath);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
reapTempFile(tmp);
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Best-effort removal of an abandoned atomic-write temp file. */
|
|
161
|
+
function reapTempFile(tmp) {
|
|
162
|
+
try {
|
|
163
|
+
fs.unlinkSync(tmp);
|
|
164
|
+
} catch {
|
|
165
|
+
// Temp file already gone (or never created) — nothing to reap.
|
|
166
|
+
}
|
|
62
167
|
}
|
|
63
168
|
|
|
64
169
|
/**
|
|
@@ -71,38 +176,45 @@ export function recordPendingCleanup(
|
|
|
71
176
|
worktreeRoot,
|
|
72
177
|
{ storyId, branch, path: wtPath, push = false },
|
|
73
178
|
) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
push
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
179
|
+
// The read, the merge and the write are one critical section: without the
|
|
180
|
+
// lock a concurrent drain's write can land between our read and our write
|
|
181
|
+
// and erase this hand-off entirely.
|
|
182
|
+
return withManifestLock(worktreeRoot, () => {
|
|
183
|
+
const now = new Date().toISOString();
|
|
184
|
+
const entries = readManifest(worktreeRoot);
|
|
185
|
+
const idx = entries.findIndex((e) => e.storyId === storyId);
|
|
186
|
+
if (idx >= 0) {
|
|
187
|
+
entries[idx] = {
|
|
188
|
+
...entries[idx],
|
|
189
|
+
branch,
|
|
190
|
+
path: wtPath,
|
|
191
|
+
push,
|
|
192
|
+
lastFailedAt: now,
|
|
193
|
+
attempts: (entries[idx].attempts ?? 0) + 1,
|
|
194
|
+
};
|
|
195
|
+
} else {
|
|
196
|
+
entries.push({
|
|
197
|
+
storyId,
|
|
198
|
+
branch,
|
|
199
|
+
path: wtPath,
|
|
200
|
+
push,
|
|
201
|
+
firstFailedAt: now,
|
|
202
|
+
lastFailedAt: now,
|
|
203
|
+
attempts: 0,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
writeManifest(worktreeRoot, entries);
|
|
207
|
+
return entries.find((e) => e.storyId === storyId);
|
|
208
|
+
});
|
|
99
209
|
}
|
|
100
210
|
|
|
101
211
|
export function removePendingCleanup(worktreeRoot, storyId) {
|
|
102
|
-
|
|
103
|
-
(
|
|
104
|
-
|
|
105
|
-
|
|
212
|
+
withManifestLock(worktreeRoot, () => {
|
|
213
|
+
const entries = readManifest(worktreeRoot).filter(
|
|
214
|
+
(e) => e.storyId !== storyId,
|
|
215
|
+
);
|
|
216
|
+
writeManifest(worktreeRoot, entries);
|
|
217
|
+
});
|
|
106
218
|
}
|
|
107
219
|
|
|
108
220
|
async function removeStuckWorktreePath(wtPath, { git, repoRoot, fsRm }) {
|
|
@@ -176,6 +288,32 @@ async function retryStage1ForEntry(entry, ctx) {
|
|
|
176
288
|
return { success: true, ...cleanup };
|
|
177
289
|
}
|
|
178
290
|
|
|
291
|
+
/**
|
|
292
|
+
* Write the drain's result, merged against whatever the manifest holds *now*.
|
|
293
|
+
*
|
|
294
|
+
* The drain awaits a removal per entry, so its `entries` snapshot can be
|
|
295
|
+
* minutes old by the time it writes. Writing that snapshot back wholesale
|
|
296
|
+
* erased any entry a concurrent `recordPendingCleanup` added mid-drain — the
|
|
297
|
+
* exact hand-off that reap had just decided it could not complete. Merging by
|
|
298
|
+
* `storyId` keeps it: rows this pass re-computed win, rows it successfully
|
|
299
|
+
* drained are dropped, and everything else the manifest has gained survives.
|
|
300
|
+
*
|
|
301
|
+
* @param {string} worktreeRoot
|
|
302
|
+
* @param {object[]} next Rows this drain re-computed (still pending).
|
|
303
|
+
* @param {Set<number|string>} drainedIds Story ids this drain cleared.
|
|
304
|
+
*/
|
|
305
|
+
function commitDrainedManifest(worktreeRoot, next, drainedIds) {
|
|
306
|
+
withManifestLock(worktreeRoot, () => {
|
|
307
|
+
const byId = new Map(next.map((entry) => [entry.storyId, entry]));
|
|
308
|
+
for (const entry of readManifest(worktreeRoot)) {
|
|
309
|
+
if (byId.has(entry.storyId)) continue;
|
|
310
|
+
if (drainedIds.has(entry.storyId)) continue;
|
|
311
|
+
byId.set(entry.storyId, entry);
|
|
312
|
+
}
|
|
313
|
+
writeManifest(worktreeRoot, [...byId.values()]);
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
179
317
|
/**
|
|
180
318
|
* Drain the pending-cleanup manifest: for each entry, retry Stage 1
|
|
181
319
|
* cleanup. Successful entries are removed; failing entries have their
|
|
@@ -252,7 +390,7 @@ export async function drainPendingCleanup({
|
|
|
252
390
|
next.push(updated);
|
|
253
391
|
}
|
|
254
392
|
|
|
255
|
-
|
|
393
|
+
commitDrainedManifest(worktreeRoot, next, new Set(drained));
|
|
256
394
|
return {
|
|
257
395
|
drained,
|
|
258
396
|
drainedDetails,
|
|
@@ -401,10 +401,7 @@ export function describeAttemptFailure(result, timeoutMs) {
|
|
|
401
401
|
}
|
|
402
402
|
|
|
403
403
|
/** Relative path of the per-machine pnpm-store prime sentinel (under tempRoot). */
|
|
404
|
-
|
|
405
|
-
'temp',
|
|
406
|
-
'.pnpm-store-primed',
|
|
407
|
-
);
|
|
404
|
+
const PNPM_STORE_PRIME_SENTINEL = path.join('temp', '.pnpm-store-primed');
|
|
408
405
|
|
|
409
406
|
/**
|
|
410
407
|
* Pure: one-time per-machine pnpm content-addressable-store prime.
|
|
@@ -424,7 +421,7 @@ export const PNPM_STORE_PRIME_SENTINEL = path.join(
|
|
|
424
421
|
*
|
|
425
422
|
* @returns {{ primed: 'primed' | 'cached' | 'failed' | 'skipped', reason?: string }}
|
|
426
423
|
*/
|
|
427
|
-
|
|
424
|
+
function primePnpmStore({
|
|
428
425
|
strategy,
|
|
429
426
|
repoRoot,
|
|
430
427
|
logger,
|
|
@@ -7,16 +7,10 @@
|
|
|
7
7
|
* Single dispatch entry point for runtime notifications across two
|
|
8
8
|
* independent channels.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* `notify()` payload. Direct inline calls at phase boundaries are no
|
|
15
|
-
* longer the canonical path — listeners on the bus are. See
|
|
16
|
-
* [`docs/LIFECYCLE.md`](../docs/LIFECYCLE.md) for the bus contract,
|
|
17
|
-
* event taxonomy, and the dispatcher's wiring. Direct CLI / library
|
|
18
|
-
* invocations remain supported for one-shot operator commands and the
|
|
19
|
-
* structured-comment back-channel.
|
|
10
|
+
* Direct inline calls at phase boundaries are the only path: `notify()`
|
|
11
|
+
* is invoked from the caller that has something to say. Direct CLI /
|
|
12
|
+
* library invocations are equally supported, for one-shot operator
|
|
13
|
+
* commands and the structured-comment back-channel.
|
|
20
14
|
*
|
|
21
15
|
* Channels:
|
|
22
16
|
*
|