mandrel 1.76.0 → 1.78.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/configuration.md +2 -2
- package/.agents/docs/workflows.md +19 -0
- package/.agents/schemas/agentrc.schema.json +1 -1
- package/.agents/schemas/dispatch-manifest.json +1 -1
- package/.agents/schemas/lifecycle/loop.tick.schema.json +20 -0
- package/.agents/schemas/loop-unit.schema.json +70 -0
- package/.agents/schemas/validation-evidence.schema.json +2 -1
- package/.agents/scripts/audit-to-stories.js +43 -1
- package/.agents/scripts/check-doc-links.js +24 -1
- package/.agents/scripts/check-loop-units.js +204 -0
- package/.agents/scripts/epic-deliver-prepare.js +31 -0
- package/.agents/scripts/evidence-gate.js +48 -12
- package/.agents/scripts/generate-workflows-doc.js +37 -4
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +141 -34
- package/.agents/scripts/lib/cli-args.js +6 -0
- package/.agents/scripts/lib/close-validation/process.js +61 -5
- package/.agents/scripts/lib/close-validation/runner.js +42 -9
- package/.agents/scripts/lib/config/temp-paths.js +1 -1
- package/.agents/scripts/lib/config/worktree-isolation.js +18 -3
- package/.agents/scripts/lib/config-resolver.js +4 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +1 -1
- package/.agents/scripts/lib/git-branch-lifecycle.js +90 -0
- package/.agents/scripts/lib/loop-units/validate-loop-unit.js +197 -0
- package/.agents/scripts/lib/mandrel-catalog.js +36 -0
- package/.agents/scripts/lib/orchestration/auto-merge-cwd.js +128 -0
- package/.agents/scripts/lib/orchestration/column-sync.js +88 -9
- package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +183 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +20 -2
- package/.agents/scripts/lib/orchestration/project-meta-cache.js +238 -0
- package/.agents/scripts/lib/orchestration/reassert-status-column.js +3 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +25 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +80 -14
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +74 -25
- package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +10 -1
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +48 -1
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +148 -4
- package/.agents/scripts/lib/orchestration/ticketing/transition.js +8 -1
- package/.agents/scripts/lib/story-body/story-body.js +76 -7
- package/.agents/scripts/lib/story-init/branch-initializer.js +29 -43
- package/.agents/scripts/lib/story-init/hierarchy-tracer.js +25 -4
- package/.agents/scripts/lib/story-init/task-graph-builder.js +22 -12
- package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -0
- package/.agents/scripts/lib/validation-evidence.js +63 -25
- package/.agents/scripts/lib/worktree/node-modules-strategy.js +239 -31
- package/.agents/scripts/providers/github/branch-protection.js +1 -1
- package/.agents/scripts/providers/github/errors.js +53 -2
- package/.agents/scripts/providers/github/labels.js +1 -1
- package/.agents/scripts/providers/github/projects-v2-graphql.js +1 -1
- package/.agents/scripts/resync-status-column.js +5 -0
- package/.agents/scripts/run-coverage.js +85 -45
- package/.agents/scripts/run-lint.js +11 -0
- package/.agents/scripts/single-story-init.js +22 -29
- package/.agents/scripts/story-init.js +38 -63
- package/.agents/scripts/story-phase.js +46 -4
- package/.agents/scripts/sync-claude-commands.js +112 -29
- package/.agents/scripts/update-maintainability-baseline.js +19 -76
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
- package/.agents/workflows/helpers/acceptance-self-eval.md +27 -0
- package/.agents/workflows/helpers/deliver-epic.md +19 -2
- package/.agents/workflows/helpers/epic-deliver-story.md +50 -14
- package/.agents/workflows/helpers/single-story-deliver.md +12 -0
- package/.agents/workflows/loops/README.md +65 -0
- package/.agents/workflows/loops/fix-failing-tests.md +74 -0
- package/.agents/workflows/loops/nightly-audit.md +71 -0
- package/.agents/workflows/loops/watch-ci.md +68 -0
- package/docs/CHANGELOG.md +51 -0
- package/package.json +1 -1
- package/.agents/scripts/providers/github/transient-retry.js +0 -62
|
@@ -7,6 +7,15 @@
|
|
|
7
7
|
*
|
|
8
8
|
* - `per-worktree` — run the project's package-manager install inside the
|
|
9
9
|
* worktree (lock-file aware).
|
|
10
|
+
* - `clone` — copy-on-write (reflink/clonefile) clone of the donor's
|
|
11
|
+
* `node_modules` into the worktree (`cp -c` on darwin,
|
|
12
|
+
* `cp --reflink=auto` on linux). Effectively free in time
|
|
13
|
+
* and disk on APFS / reflink-capable filesystems. Falls
|
|
14
|
+
* back to `per-worktree` cleanly on any failure
|
|
15
|
+
* (unsupported filesystem, cross-volume clone, Windows).
|
|
16
|
+
* When the worktree's lockfile byte-matches the donor's
|
|
17
|
+
* and the donor's install markers are present, the
|
|
18
|
+
* per-tree install is skipped.
|
|
10
19
|
* - `symlink` — symlink (or junction on Windows) the worktree's
|
|
11
20
|
* `node_modules` to a donor worktree's copy. Refuses on
|
|
12
21
|
* Windows unless `allowSymlinkOnWindows=true`.
|
|
@@ -14,10 +23,11 @@
|
|
|
14
23
|
* shared content-addressable store.
|
|
15
24
|
*
|
|
16
25
|
* The context passed to each helper carries the minimum state the strategy
|
|
17
|
-
* needs: config, platform, logger, and repoRoot (for `symlink`).
|
|
26
|
+
* needs: config, platform, logger, and repoRoot (for `symlink` / `clone`).
|
|
18
27
|
*/
|
|
19
28
|
|
|
20
29
|
import { spawnSync } from 'node:child_process';
|
|
30
|
+
import crypto from 'node:crypto';
|
|
21
31
|
import fs from 'node:fs';
|
|
22
32
|
import path from 'node:path';
|
|
23
33
|
import { detectPackageManager } from '../detect-package-manager.js';
|
|
@@ -28,6 +38,21 @@ function sleepSync(ms) {
|
|
|
28
38
|
Atomics.wait(signal, 0, 0, ms);
|
|
29
39
|
}
|
|
30
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the copy-on-write clone donor directory. Defaults to the repo root
|
|
43
|
+
* (the main checkout, which is primed by the host's own install) unless the
|
|
44
|
+
* operator pinned a `primeFromPath`.
|
|
45
|
+
*
|
|
46
|
+
* @param {{ config: object, repoRoot: string }} ctx
|
|
47
|
+
* @returns {string} Absolute donor path.
|
|
48
|
+
*/
|
|
49
|
+
function resolveCloneDonor(ctx) {
|
|
50
|
+
const primeFromPath = ctx.config?.primeFromPath;
|
|
51
|
+
return primeFromPath
|
|
52
|
+
? path.resolve(ctx.repoRoot, primeFromPath)
|
|
53
|
+
: path.resolve(ctx.repoRoot);
|
|
54
|
+
}
|
|
55
|
+
|
|
31
56
|
/**
|
|
32
57
|
* Apply the configured `nodeModulesStrategy` after a fresh worktree is added.
|
|
33
58
|
* Called only during creation.
|
|
@@ -43,6 +68,11 @@ export function applyNodeModulesStrategy(ctx, wtPath) {
|
|
|
43
68
|
case 'pnpm-store':
|
|
44
69
|
return;
|
|
45
70
|
|
|
71
|
+
case 'clone': {
|
|
72
|
+
cloneNodeModules(ctx, wtPath);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
46
76
|
case 'symlink': {
|
|
47
77
|
const primeFromPath = ctx.config.primeFromPath;
|
|
48
78
|
if (!primeFromPath) {
|
|
@@ -89,17 +119,106 @@ export function applyNodeModulesStrategy(ctx, wtPath) {
|
|
|
89
119
|
default:
|
|
90
120
|
throw new Error(
|
|
91
121
|
`WorktreeManager: unknown nodeModulesStrategy '${strategy}'. ` +
|
|
92
|
-
'Expected per-worktree | symlink | pnpm-store.',
|
|
122
|
+
'Expected per-worktree | clone | symlink | pnpm-store.',
|
|
93
123
|
);
|
|
94
124
|
}
|
|
95
125
|
}
|
|
96
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Copy-on-write clone the donor's `node_modules` into the worktree. On a
|
|
129
|
+
* reflink-capable filesystem (APFS on darwin, btrfs/XFS/etc. on linux) the
|
|
130
|
+
* clone is effectively free in both time and disk; the subsequent
|
|
131
|
+
* lockfile-match probe in `installDependencies` then skips the per-tree
|
|
132
|
+
* install entirely.
|
|
133
|
+
*
|
|
134
|
+
* Non-fatal by design: any failure (unsupported filesystem, cross-volume
|
|
135
|
+
* clone, Windows, missing donor) falls back to `per-worktree` cleanly — the
|
|
136
|
+
* worktree is simply left without a cloned `node_modules`, and the regular
|
|
137
|
+
* per-tree install runs. Failure NEVER throws; the clone is a fast-path
|
|
138
|
+
* optimization, not a correctness requirement.
|
|
139
|
+
*
|
|
140
|
+
* @param {{ config: object, platform: NodeJS.Platform, logger: object, repoRoot: string }} ctx
|
|
141
|
+
* @param {string} wtPath Absolute worktree path.
|
|
142
|
+
* @param {{ spawnFn?: typeof spawnSync, fsLike?: typeof fs }} [io] Injectable for tests.
|
|
143
|
+
* @returns {{ cloned: boolean, reason?: string }}
|
|
144
|
+
*/
|
|
145
|
+
export function cloneNodeModules(ctx, wtPath, io = {}) {
|
|
146
|
+
const spawnFn = io.spawnFn ?? spawnSync;
|
|
147
|
+
const fsLike = io.fsLike ?? fs;
|
|
148
|
+
// Key off the real host OS, not the test-injection `ctx.platform`, for the
|
|
149
|
+
// capability decision: Windows has no `cp -c`/`--reflink` equivalent here.
|
|
150
|
+
if (process.platform === 'win32') {
|
|
151
|
+
ctx.logger.info(
|
|
152
|
+
'worktree.node_modules strategy=clone fallback=per-worktree reason=windows-unsupported',
|
|
153
|
+
);
|
|
154
|
+
return { cloned: false, reason: 'windows-unsupported' };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const donor = resolveCloneDonor(ctx);
|
|
158
|
+
const donorNodeModules = path.join(donor, 'node_modules');
|
|
159
|
+
if (!fsLike.existsSync(donorNodeModules)) {
|
|
160
|
+
ctx.logger.info(
|
|
161
|
+
`worktree.node_modules strategy=clone fallback=per-worktree reason=donor-node-modules-missing donor=${donorNodeModules}`,
|
|
162
|
+
);
|
|
163
|
+
return { cloned: false, reason: 'donor-node-modules-missing' };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const target = path.join(wtPath, 'node_modules');
|
|
167
|
+
if (fsLike.existsSync(target)) {
|
|
168
|
+
// A worktree that already carries node_modules must not be clobbered by a
|
|
169
|
+
// clone-on-top (cp would nest into the existing dir). Treat as already
|
|
170
|
+
// populated and let the install-skip probe decide freshness.
|
|
171
|
+
ctx.logger.info(
|
|
172
|
+
`worktree.node_modules strategy=clone skip reason=target-exists target=${target}`,
|
|
173
|
+
);
|
|
174
|
+
return { cloned: false, reason: 'target-exists' };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// darwin: `cp -c` requests a clonefile (APFS). linux: `cp --reflink=auto`
|
|
178
|
+
// requests a reflink, silently degrading to a full copy on non-reflink
|
|
179
|
+
// filesystems — which we deliberately reject below so a slow full-tree copy
|
|
180
|
+
// never masquerades as a free clone.
|
|
181
|
+
const cloneArgs =
|
|
182
|
+
process.platform === 'darwin'
|
|
183
|
+
? ['-c', '-R', donorNodeModules, target]
|
|
184
|
+
: ['--reflink=always', '-R', donorNodeModules, target];
|
|
185
|
+
const result = spawnFn('cp', cloneArgs, {
|
|
186
|
+
cwd: wtPath,
|
|
187
|
+
stdio: 'pipe',
|
|
188
|
+
encoding: 'utf-8',
|
|
189
|
+
timeout: 120_000,
|
|
190
|
+
});
|
|
191
|
+
if (result.status !== 0) {
|
|
192
|
+
// Clean up a partial copy so the per-worktree fallback install starts from
|
|
193
|
+
// a known-empty target.
|
|
194
|
+
try {
|
|
195
|
+
fsLike.rmSync(target, { recursive: true, force: true });
|
|
196
|
+
} catch {
|
|
197
|
+
// best-effort
|
|
198
|
+
}
|
|
199
|
+
ctx.logger.warn(
|
|
200
|
+
`worktree.node_modules strategy=clone fallback=per-worktree reason=clone-command-failed ` +
|
|
201
|
+
`(${describeAttemptFailure(result, 120_000)}) stderr=${(result.stderr ?? '').slice(0, 300)}`,
|
|
202
|
+
);
|
|
203
|
+
return { cloned: false, reason: 'clone-command-failed' };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
ctx.logger.info(
|
|
207
|
+
`worktree.node_modules strategy=clone target=${target} source=${donorNodeModules}`,
|
|
208
|
+
);
|
|
209
|
+
return { cloned: true };
|
|
210
|
+
}
|
|
211
|
+
|
|
97
212
|
/**
|
|
98
213
|
* Pure: pick the package-manager command + args for a given strategy and
|
|
99
214
|
* worktree path. Returns `null` when the strategy is `symlink` (handled
|
|
100
215
|
* elsewhere) or the worktree has no `package.json`.
|
|
101
216
|
*
|
|
102
|
-
*
|
|
217
|
+
* `clone` shares `per-worktree`'s PM-detection: when the install is *not*
|
|
218
|
+
* skipped (lockfile mismatch), the worktree still installs with the project's
|
|
219
|
+
* detected package manager.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} strategy One of `per-worktree | clone | pnpm-store | symlink`.
|
|
103
222
|
* @param {string} wtPath Absolute worktree path.
|
|
104
223
|
* @param {{ existsSync: (p: string) => boolean }} [fsLike] Injectable for tests.
|
|
105
224
|
* @returns {{ cmd: string, args: string[] } | null}
|
|
@@ -112,6 +231,7 @@ export function selectInstallCommand(strategy, wtPath, fsLike = fs) {
|
|
|
112
231
|
return { cmd: 'pnpm', args: ['install', '--frozen-lockfile'] };
|
|
113
232
|
}
|
|
114
233
|
// Shared lockfile probe (Story #4048 B3 — one implementation per concept).
|
|
234
|
+
// `per-worktree` and `clone` both fall through to PM detection here.
|
|
115
235
|
const pm = detectPackageManager(wtPath, (p) => fsLike.existsSync(p)) ?? 'npm';
|
|
116
236
|
if (pm === 'pnpm') {
|
|
117
237
|
return { cmd: 'pnpm', args: ['install', '--frozen-lockfile'] };
|
|
@@ -138,14 +258,78 @@ const INSTALL_MARKERS = [
|
|
|
138
258
|
|
|
139
259
|
const LOCKFILES = ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'];
|
|
140
260
|
|
|
141
|
-
|
|
261
|
+
/**
|
|
262
|
+
* Pure: locate the first present lockfile in `dir` and return its byte-exact
|
|
263
|
+
* SHA-256 hash, or `null` when no lockfile exists / cannot be read.
|
|
264
|
+
*
|
|
265
|
+
* The hash is the load-bearing freshness key for the install-skip decision —
|
|
266
|
+
* never an mtime, which clone (clonefile/reflink) does not preserve reliably
|
|
267
|
+
* and which is trivially perturbed by a no-op `git checkout`.
|
|
268
|
+
*
|
|
269
|
+
* @param {string} dir Absolute directory containing the lockfile.
|
|
270
|
+
* @param {{ existsSync: Function, readFileSync: Function }} [fsLike]
|
|
271
|
+
* @returns {string | null}
|
|
272
|
+
*/
|
|
273
|
+
export function lockfileHash(dir, fsLike = fs) {
|
|
274
|
+
const lockfile = LOCKFILES.map((l) => path.join(dir, l)).find((p) =>
|
|
275
|
+
fsLike.existsSync(p),
|
|
276
|
+
);
|
|
277
|
+
if (!lockfile) return null;
|
|
142
278
|
try {
|
|
143
|
-
|
|
279
|
+
const bytes = fsLike.readFileSync(lockfile);
|
|
280
|
+
return crypto.createHash('sha256').update(bytes).digest('hex');
|
|
144
281
|
} catch {
|
|
145
282
|
return null;
|
|
146
283
|
}
|
|
147
284
|
}
|
|
148
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Pure: the single shared "is the per-tree install skippable?" predicate
|
|
288
|
+
* (generalized from `probeReusedInstall`). The install is safe to skip iff:
|
|
289
|
+
*
|
|
290
|
+
* 1. the worktree carries a completed-install marker
|
|
291
|
+
* (`node_modules/<INSTALL_MARKERS>`), AND
|
|
292
|
+
* 2. the worktree's lockfile byte-exactly matches the donor's lockfile.
|
|
293
|
+
*
|
|
294
|
+
* The lockfile match is keyed on a byte-exact SHA-256 hash, NEVER mtime: a
|
|
295
|
+
* reflink/clonefile clone does not preserve mtime relationships, so an mtime
|
|
296
|
+
* comparison would either mis-skip a real change or force a redundant install
|
|
297
|
+
* on every clone. When the donor is omitted (reuse path, no clone donor), the
|
|
298
|
+
* worktree's own lockfile is hashed against itself — i.e. the lockfile-match
|
|
299
|
+
* leg is vacuously satisfied and only the marker presence gates the skip.
|
|
300
|
+
*
|
|
301
|
+
* @param {object} opts
|
|
302
|
+
* @param {string} opts.wtPath Absolute worktree path.
|
|
303
|
+
* @param {string} [opts.donorPath] Absolute donor path (the clone source).
|
|
304
|
+
* @param {{ existsSync: Function, readFileSync: Function }} [opts.fsLike]
|
|
305
|
+
* @returns {{ skippable: boolean, reason: string }}
|
|
306
|
+
*/
|
|
307
|
+
export function isInstallSkippable({ wtPath, donorPath, fsLike = fs }) {
|
|
308
|
+
const nmPath = path.join(wtPath, 'node_modules');
|
|
309
|
+
if (!fsLike.existsSync(nmPath)) {
|
|
310
|
+
return { skippable: false, reason: 'node-modules-missing' };
|
|
311
|
+
}
|
|
312
|
+
const marker = INSTALL_MARKERS.map((m) => path.join(nmPath, m)).find((p) =>
|
|
313
|
+
fsLike.existsSync(p),
|
|
314
|
+
);
|
|
315
|
+
if (!marker) {
|
|
316
|
+
return { skippable: false, reason: 'install-incomplete' };
|
|
317
|
+
}
|
|
318
|
+
const wtHash = lockfileHash(wtPath, fsLike);
|
|
319
|
+
if (wtHash === null) {
|
|
320
|
+
// No lockfile in the worktree — nothing to pin freshness against. The
|
|
321
|
+
// marker alone is the signal (matches a lockfile-less project).
|
|
322
|
+
return { skippable: true, reason: 'marker-present-no-lockfile' };
|
|
323
|
+
}
|
|
324
|
+
if (donorPath) {
|
|
325
|
+
const donorHash = lockfileHash(donorPath, fsLike);
|
|
326
|
+
if (donorHash !== null && donorHash !== wtHash) {
|
|
327
|
+
return { skippable: false, reason: 'lockfile-mismatch' };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return { skippable: true, reason: 'lockfile-match' };
|
|
331
|
+
}
|
|
332
|
+
|
|
149
333
|
/**
|
|
150
334
|
* Pure: probe whether a **reused** worktree already carries a completed,
|
|
151
335
|
* up-to-date install. Worktree reuse must not blindly report
|
|
@@ -157,12 +341,17 @@ function safeMtimeMs(fsLike, p) {
|
|
|
157
341
|
* - `{ status: 'skipped', reason: 'worktree-reused' }` — a completed
|
|
158
342
|
* install was detected (or the strategy never installs per-tree);
|
|
159
343
|
* safe to skip.
|
|
160
|
-
* - `{ status: 'failed', reason }` — missing/incomplete
|
|
161
|
-
*
|
|
344
|
+
* - `{ status: 'failed', reason }` — missing/incomplete install detected;
|
|
345
|
+
* callers should retry the install.
|
|
162
346
|
*
|
|
163
|
-
*
|
|
347
|
+
* Thin wrapper over the shared `isInstallSkippable` predicate (Story #4249 —
|
|
348
|
+
* one freshness predicate, keyed on a byte-exact lockfile hash, never mtime).
|
|
349
|
+
* The reuse path has no separate donor, so freshness reduces to marker
|
|
350
|
+
* presence (the worktree's own lockfile is its own baseline).
|
|
351
|
+
*
|
|
352
|
+
* @param {string} strategy One of `per-worktree | clone | pnpm-store | symlink`.
|
|
164
353
|
* @param {string} wtPath Absolute worktree path.
|
|
165
|
-
* @param {{ existsSync: Function,
|
|
354
|
+
* @param {{ existsSync: Function, readFileSync: Function }} [fsLike] Injectable for tests.
|
|
166
355
|
* @returns {{ status: 'skipped' | 'failed', reason: string }}
|
|
167
356
|
*/
|
|
168
357
|
export function probeReusedInstall(strategy, wtPath, fsLike = fs) {
|
|
@@ -173,34 +362,33 @@ export function probeReusedInstall(strategy, wtPath, fsLike = fs) {
|
|
|
173
362
|
if (!fsLike.existsSync(path.join(wtPath, 'package.json'))) {
|
|
174
363
|
return { status: 'skipped', reason: 'no-package-json' };
|
|
175
364
|
}
|
|
176
|
-
const
|
|
177
|
-
if (
|
|
178
|
-
return { status: '
|
|
179
|
-
}
|
|
180
|
-
const marker = INSTALL_MARKERS.map((m) => path.join(nmPath, m)).find((p) =>
|
|
181
|
-
fsLike.existsSync(p),
|
|
182
|
-
);
|
|
183
|
-
if (!marker) {
|
|
184
|
-
return { status: 'failed', reason: 'reuse-install-incomplete' };
|
|
185
|
-
}
|
|
186
|
-
const markerMtime = safeMtimeMs(fsLike, marker);
|
|
187
|
-
const lockfile = LOCKFILES.map((l) => path.join(wtPath, l)).find((p) =>
|
|
188
|
-
fsLike.existsSync(p),
|
|
189
|
-
);
|
|
190
|
-
if (lockfile && markerMtime !== null) {
|
|
191
|
-
const lockMtime = safeMtimeMs(fsLike, lockfile);
|
|
192
|
-
if (lockMtime !== null && lockMtime > markerMtime) {
|
|
193
|
-
return { status: 'failed', reason: 'reuse-node-modules-stale' };
|
|
194
|
-
}
|
|
365
|
+
const probe = isInstallSkippable({ wtPath, fsLike });
|
|
366
|
+
if (probe.skippable) {
|
|
367
|
+
return { status: 'skipped', reason: 'worktree-reused' };
|
|
195
368
|
}
|
|
196
|
-
return {
|
|
369
|
+
return {
|
|
370
|
+
status: 'failed',
|
|
371
|
+
reason:
|
|
372
|
+
probe.reason === 'node-modules-missing'
|
|
373
|
+
? 'reuse-node-modules-missing'
|
|
374
|
+
: 'reuse-install-incomplete',
|
|
375
|
+
};
|
|
197
376
|
}
|
|
198
377
|
|
|
199
|
-
/**
|
|
378
|
+
/**
|
|
379
|
+
* Pure: retry policy keyed off the chosen command. pnpm gets 3× + 5min.
|
|
380
|
+
*
|
|
381
|
+
* Story #4249: npm/yarn now get a real `maxAttempts` of 2 (was 1). The
|
|
382
|
+
* formerly hardcoded `npm ci` retry inside `runStoryInitPrepare` was deleted
|
|
383
|
+
* in the same change; this in-`ensure` retry budget is what compensates so
|
|
384
|
+
* npm install reliability does not regress — a transient first-attempt
|
|
385
|
+
* failure now retries here, with the correct (PM-detected) command, instead
|
|
386
|
+
* of an unconditional `npm ci` re-run after init.
|
|
387
|
+
*/
|
|
200
388
|
export function installRetryPolicy(cmd) {
|
|
201
389
|
const isPnpm = cmd === 'pnpm';
|
|
202
390
|
return {
|
|
203
|
-
maxAttempts: isPnpm ? 3 :
|
|
391
|
+
maxAttempts: isPnpm ? 3 : 2,
|
|
204
392
|
timeoutMs: isPnpm ? 300_000 : 120_000,
|
|
205
393
|
backoffMs: [0, 2_000, 5_000],
|
|
206
394
|
};
|
|
@@ -387,6 +575,26 @@ export function installDependencies(ctx, wtPath) {
|
|
|
387
575
|
if (selection === null) {
|
|
388
576
|
return { status: 'skipped', reason: 'no-package-json' };
|
|
389
577
|
}
|
|
578
|
+
// `clone` reflink-clones the donor's node_modules in `applyNodeModulesStrategy`.
|
|
579
|
+
// When the cloned tree carries a completed-install marker AND the worktree's
|
|
580
|
+
// lockfile byte-matches the donor's, the per-tree install is pure waste —
|
|
581
|
+
// skip it. The freshness predicate is the single shared `isInstallSkippable`
|
|
582
|
+
// (byte-exact lockfile hash, never mtime). On a lockfile mismatch (donor
|
|
583
|
+
// drifted, or no clone happened) this returns false and the install below
|
|
584
|
+
// runs normally.
|
|
585
|
+
if (strategy === 'clone') {
|
|
586
|
+
const donor = resolveCloneDonor(ctx);
|
|
587
|
+
const probe = isInstallSkippable({ wtPath, donorPath: donor });
|
|
588
|
+
if (probe.skippable) {
|
|
589
|
+
ctx.logger.info(
|
|
590
|
+
`worktree.install strategy=clone skip reason=${probe.reason} path=${wtPath}`,
|
|
591
|
+
);
|
|
592
|
+
return { status: 'skipped', reason: `clone-${probe.reason}` };
|
|
593
|
+
}
|
|
594
|
+
ctx.logger.info(
|
|
595
|
+
`worktree.install strategy=clone install reason=${probe.reason} path=${wtPath}`,
|
|
596
|
+
);
|
|
597
|
+
}
|
|
390
598
|
// Prime the pnpm content-addressable store once per machine before the
|
|
391
599
|
// worktree's own install runs. No-op for non-pnpm-store strategies. Prime
|
|
392
600
|
// failures are surfaced as warnings but do not short-circuit the install —
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* @see Story #2462 — Split GitHubProvider god class into seven composed gateways.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { withTransientRetry } from './errors.js';
|
|
16
17
|
import { parseApiJson } from './request-helpers.js';
|
|
17
|
-
import { withTransientRetry } from './transient-retry.js';
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Detect a 404 across both error surfaces:
|
|
@@ -48,6 +48,40 @@ const TRANSIENT_MESSAGES = [
|
|
|
48
48
|
|
|
49
49
|
const PERMISSION_MESSAGES = ['unauthorized', 'forbidden', 'permission'];
|
|
50
50
|
|
|
51
|
+
// Network/connectivity blips that the gh-CLI path surfaces on `err.stderr`
|
|
52
|
+
// (Go HTTP errors, e.g. `dial tcp ...: i/o timeout`) and the direct `fetch`
|
|
53
|
+
// path surfaces as `TypeError: fetch failed` with the real reason on
|
|
54
|
+
// `err.cause` (e.g. `ETIMEDOUT`, `ENOTFOUND`). Folded in from the former
|
|
55
|
+
// `transient-retry.js` predicate (Story #4298) so the single canonical
|
|
56
|
+
// classifier retries the **union** of transient HTTP statuses/codes AND
|
|
57
|
+
// transient network errors. The `\b50[234]\b` alternative also catches a
|
|
58
|
+
// bare 502/503/504 surfaced only in an error message string (no `.status`).
|
|
59
|
+
const TRANSIENT_NETWORK_RE =
|
|
60
|
+
/i\/o timeout|dial tcp|TLS handshake timeout|connection reset|connection refused|temporary failure|could not resolve host|no such host|network is unreachable|socket hang up|fetch failed|ConnectTimeoutError|UND_ERR_CONNECT_TIMEOUT|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|\b50[234]\b/i;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* True when an error looks like a retryable network/connectivity blip.
|
|
64
|
+
* Scans the union of fields both transport paths populate (`stderr`,
|
|
65
|
+
* `message`, `code`, and the nested `cause.message` / `cause.code` the
|
|
66
|
+
* `fetch` path uses). Module-private — folded in from the former
|
|
67
|
+
* `transient-retry.js` predicate (Story #4298) and consumed only by
|
|
68
|
+
* `classifyGithubError` below; its behavior is exercised through that public
|
|
69
|
+
* classifier rather than as a standalone export (keeps the dead-export gate
|
|
70
|
+
* green — nothing outside this module imports it).
|
|
71
|
+
*/
|
|
72
|
+
function isTransientNetworkError(err) {
|
|
73
|
+
const hay = [
|
|
74
|
+
err?.stderr,
|
|
75
|
+
err?.message,
|
|
76
|
+
err?.code,
|
|
77
|
+
err?.cause?.message,
|
|
78
|
+
err?.cause?.code,
|
|
79
|
+
]
|
|
80
|
+
.filter(Boolean)
|
|
81
|
+
.join(' ');
|
|
82
|
+
return TRANSIENT_NETWORK_RE.test(hay);
|
|
83
|
+
}
|
|
84
|
+
|
|
51
85
|
function matchesAny(haystack, needles) {
|
|
52
86
|
for (const n of needles) if (haystack.includes(n)) return true;
|
|
53
87
|
return false;
|
|
@@ -97,19 +131,36 @@ export function classifyGithubError(err) {
|
|
|
97
131
|
if (matchesAny(lower, FEATURE_DISABLED_MESSAGES)) return 'feature-disabled';
|
|
98
132
|
if (isTransientStatus(status)) return 'transient';
|
|
99
133
|
if (isTransientByCodeOrMessage(code, lower)) return 'transient';
|
|
134
|
+
// Union with the former `transient-retry.js` predicate (Story #4298):
|
|
135
|
+
// retry on network/connectivity blips the status/code checks above miss
|
|
136
|
+
// (e.g. a `dial tcp ... i/o timeout` on `err.stderr` from the gh-CLI path,
|
|
137
|
+
// or `ECONNREFUSED` / `ENETUNREACH`). Checked before the permission rule so
|
|
138
|
+
// a transient network failure never masquerades as a permanent denial.
|
|
139
|
+
if (isTransientNetworkError(err)) return 'transient';
|
|
100
140
|
if (isPermissionSignal(status, lower)) return 'permission';
|
|
101
141
|
return 'permanent';
|
|
102
142
|
}
|
|
103
143
|
|
|
104
144
|
// ---------------------------------------------------------------------------
|
|
105
|
-
// Transient-retry helper (Story #2852)
|
|
145
|
+
// Transient-retry helper (Story #2852; unified in Story #4298)
|
|
106
146
|
// ---------------------------------------------------------------------------
|
|
107
147
|
//
|
|
148
|
+
// The single canonical `withTransientRetry` for the GitHub provider. Story
|
|
149
|
+
// #4298 collapsed the former two divergent same-named implementations (this
|
|
150
|
+
// one + the network-only one in the deleted `transient-retry.js`) into this
|
|
151
|
+
// one primitive. Its default classifier (`classifyGithubError`) is the
|
|
152
|
+
// **union** predicate — it retries on transient HTTP statuses/codes AND on
|
|
153
|
+
// transient network/connectivity errors — so every former consumer of either
|
|
154
|
+
// module keeps (or gains) its prior retry coverage with no shim.
|
|
155
|
+
//
|
|
108
156
|
// Mirrors the addSubIssue retry contract in `sub-issues.js` so read-path
|
|
109
157
|
// callers (paginateRest, getTicket, getNativeSubIssues, …) absorb the same
|
|
110
158
|
// jittered exponential backoff on transient GitHub errors instead of
|
|
111
159
|
// bubbling a one-shot 502/429/ECONNRESET that kills a longer pipeline
|
|
112
|
-
// (e.g. the /deliver Phase E retro).
|
|
160
|
+
// (e.g. the /deliver Phase E retro). The network consumers repointed here
|
|
161
|
+
// (branch-protection, labels, projects-v2-graphql) call with no opts, so
|
|
162
|
+
// they adopt these defaults; their retry *classes* (the network blips) are
|
|
163
|
+
// preserved via the unified classifier above.
|
|
113
164
|
|
|
114
165
|
export const TRANSIENT_RETRY_DEFAULTS = Object.freeze({
|
|
115
166
|
maxAttempts: 6,
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* @see Story #2462 — Split GitHubProvider god class into seven composed gateways.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { withTransientRetry } from './
|
|
19
|
+
import { withTransientRetry } from './errors.js';
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Detect the "label already exists" signal across the surfaces `gh label
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* `unavailable:true` envelopes. Wave 3 deletes the old submodules.
|
|
8
8
|
*/
|
|
9
9
|
import { execSync } from 'node:child_process';
|
|
10
|
-
import { withTransientRetry } from './
|
|
10
|
+
import { withTransientRetry } from './errors.js';
|
|
11
11
|
|
|
12
12
|
// Resolve an owner node id per-scope. Querying `user` and `organization`
|
|
13
13
|
// together in one request makes GitHub return a NOT_FOUND error for whichever
|
|
@@ -127,6 +127,7 @@ export function buildReassertOptions({
|
|
|
127
127
|
logger,
|
|
128
128
|
pollAttempts,
|
|
129
129
|
pollDelayMs,
|
|
130
|
+
config,
|
|
130
131
|
}) {
|
|
131
132
|
const opts = {
|
|
132
133
|
provider,
|
|
@@ -135,6 +136,9 @@ export function buildReassertOptions({
|
|
|
135
136
|
};
|
|
136
137
|
if (pollAttempts !== undefined) opts.pollAttempts = pollAttempts;
|
|
137
138
|
if (pollDelayMs !== undefined) opts.pollDelayMs = pollDelayMs;
|
|
139
|
+
// Story #4252 — forward the resolved config so ColumnSync's on-disk
|
|
140
|
+
// board-metadata cache lands under the project's configured tempRoot.
|
|
141
|
+
if (config !== undefined) opts.config = config;
|
|
138
142
|
return opts;
|
|
139
143
|
}
|
|
140
144
|
|
|
@@ -168,6 +172,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
168
172
|
logger: Logger,
|
|
169
173
|
pollAttempts,
|
|
170
174
|
pollDelayMs,
|
|
175
|
+
config: effectiveConfig,
|
|
171
176
|
}),
|
|
172
177
|
);
|
|
173
178
|
process.stdout.write(`${JSON.stringify({ ticketId, ...result })}\n`);
|
|
@@ -27,6 +27,14 @@
|
|
|
27
27
|
* `c8 report` path is ~19% faster end-to-end on a Windows dev host
|
|
28
28
|
* while producing an identical `coverage-final.json` artifact for the
|
|
29
29
|
* CRAP gate.
|
|
30
|
+
*
|
|
31
|
+
* Test-runner concurrency: the suite spawn reuses `TEST_RUNNER_FLAGS`
|
|
32
|
+
* from `run-tests.js` — the single source of truth for the
|
|
33
|
+
* `--test-concurrency` value, derived at startup from the host's
|
|
34
|
+
* available parallelism and clamped to `[TEST_CONCURRENCY_MIN,
|
|
35
|
+
* TEST_CONCURRENCY_MAX]`. This keeps the coverage gate (which runs the
|
|
36
|
+
* suite at every story close on both delivery paths) host-aware instead
|
|
37
|
+
* of pinned to the historical literal of 8.
|
|
30
38
|
*/
|
|
31
39
|
|
|
32
40
|
import { spawnSync } from 'node:child_process';
|
|
@@ -37,6 +45,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
37
45
|
|
|
38
46
|
import { cleanupRepoTestTempArtifacts } from './cleanup-repo-test-temp.js';
|
|
39
47
|
import { C8_CLI } from './lib/c8-cli-path.js';
|
|
48
|
+
import { TEST_RUNNER_FLAGS } from './run-tests.js';
|
|
40
49
|
|
|
41
50
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
42
51
|
const ROOT = path.resolve(__dirname, '..', '..');
|
|
@@ -45,59 +54,90 @@ const require = createRequire(import.meta.url);
|
|
|
45
54
|
const C8_CONFIG = require('../../.c8rc.cjs');
|
|
46
55
|
const V8_TMP = path.join(COVERAGE_DIR, 'tmp');
|
|
47
56
|
|
|
48
|
-
|
|
49
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Build the `node --test` argv for the coverage suite spawn.
|
|
59
|
+
*
|
|
60
|
+
* Reuses the shared `TEST_RUNNER_FLAGS` (the single source of truth for
|
|
61
|
+
* the host-aware, clamped `--test-concurrency` value) so the coverage
|
|
62
|
+
* path never drifts from `run-tests.js`. The `runnerFlags` parameter is
|
|
63
|
+
* injected in tests so the argv can be asserted without touching the OS.
|
|
64
|
+
*
|
|
65
|
+
* @param {object} [opts]
|
|
66
|
+
* @param {readonly string[]} [opts.runnerFlags]
|
|
67
|
+
* @param {string} [opts.testGlob]
|
|
68
|
+
* @returns {string[]}
|
|
69
|
+
*/
|
|
70
|
+
export function buildCoverageTestArgs({
|
|
71
|
+
runnerFlags = TEST_RUNNER_FLAGS,
|
|
72
|
+
testGlob = 'tests/**/*.test.js',
|
|
73
|
+
} = {}) {
|
|
74
|
+
return [...runnerFlags, testGlob];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Execute the coverage pipeline: run the suite under `NODE_V8_COVERAGE`,
|
|
79
|
+
* post-process the dumps with `c8 report`, then gate on the coverage
|
|
80
|
+
* baseline. Returns the first non-zero exit code across the three stages
|
|
81
|
+
* (or the baseline check's status when both prior stages pass).
|
|
82
|
+
*
|
|
83
|
+
* @returns {number}
|
|
84
|
+
*/
|
|
85
|
+
function runCoveragePipeline() {
|
|
86
|
+
rmSync(COVERAGE_DIR, { recursive: true, force: true });
|
|
87
|
+
mkdirSync(V8_TMP, { recursive: true });
|
|
50
88
|
|
|
51
|
-
const testRun = spawnSync(
|
|
52
|
-
process.execPath,
|
|
53
|
-
[
|
|
54
|
-
'--experimental-test-module-mocks',
|
|
55
|
-
'--test',
|
|
56
|
-
'--test-concurrency=8',
|
|
57
|
-
'tests/**/*.test.js',
|
|
58
|
-
],
|
|
59
|
-
{
|
|
89
|
+
const testRun = spawnSync(process.execPath, buildCoverageTestArgs(), {
|
|
60
90
|
cwd: ROOT,
|
|
61
91
|
stdio: 'inherit',
|
|
62
92
|
env: { ...process.env, NODE_V8_COVERAGE: V8_TMP },
|
|
63
|
-
}
|
|
64
|
-
);
|
|
93
|
+
});
|
|
65
94
|
|
|
66
|
-
cleanupRepoTestTempArtifacts({ repoRoot: ROOT });
|
|
95
|
+
cleanupRepoTestTempArtifacts({ repoRoot: ROOT });
|
|
67
96
|
|
|
68
|
-
const includeArgs = (C8_CONFIG.include ?? []).flatMap((p) => [
|
|
69
|
-
|
|
97
|
+
const includeArgs = (C8_CONFIG.include ?? []).flatMap((p) => [
|
|
98
|
+
'--include',
|
|
99
|
+
p,
|
|
100
|
+
]);
|
|
101
|
+
const excludeArgs = (C8_CONFIG.exclude ?? []).flatMap((p) => [
|
|
102
|
+
'--exclude',
|
|
103
|
+
p,
|
|
104
|
+
]);
|
|
70
105
|
|
|
71
|
-
const reportRun = spawnSync(
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
);
|
|
106
|
+
const reportRun = spawnSync(
|
|
107
|
+
process.execPath,
|
|
108
|
+
[
|
|
109
|
+
C8_CLI,
|
|
110
|
+
'report',
|
|
111
|
+
'--reporter=json',
|
|
112
|
+
'--reporter=text',
|
|
113
|
+
'--temp-directory',
|
|
114
|
+
V8_TMP,
|
|
115
|
+
...includeArgs,
|
|
116
|
+
...excludeArgs,
|
|
117
|
+
],
|
|
118
|
+
{ cwd: ROOT, stdio: 'inherit', shell: false },
|
|
119
|
+
);
|
|
85
120
|
|
|
86
|
-
const checkRun = spawnSync(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
);
|
|
121
|
+
const checkRun = spawnSync(
|
|
122
|
+
process.execPath,
|
|
123
|
+
[
|
|
124
|
+
path.join(ROOT, '.agents', 'scripts', 'check-baselines.js'),
|
|
125
|
+
'--gate',
|
|
126
|
+
'coverage',
|
|
127
|
+
],
|
|
128
|
+
{ cwd: ROOT, stdio: 'inherit' },
|
|
129
|
+
);
|
|
95
130
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
? testRun.status
|
|
131
|
+
return testRun.status !== 0
|
|
132
|
+
? (testRun.status ?? 1)
|
|
99
133
|
: reportRun.status !== 0
|
|
100
|
-
? reportRun.status
|
|
101
|
-
: checkRun.status;
|
|
134
|
+
? (reportRun.status ?? 1)
|
|
135
|
+
: (checkRun.status ?? 1);
|
|
136
|
+
}
|
|
102
137
|
|
|
103
|
-
|
|
138
|
+
// Run the pipeline only when invoked directly as a CLI; importing the
|
|
139
|
+
// module (e.g. from a test asserting the spawn argv) must not spawn the
|
|
140
|
+
// real suite.
|
|
141
|
+
if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] ?? '')) {
|
|
142
|
+
process.exit(runCoveragePipeline());
|
|
143
|
+
}
|
|
@@ -78,6 +78,17 @@ const tasks = [
|
|
|
78
78
|
cmd: 'node',
|
|
79
79
|
args: ['.agents/scripts/check-arch-cycles.js'],
|
|
80
80
|
},
|
|
81
|
+
{
|
|
82
|
+
// Loop-unit frontmatter gate (Story #4288, Epic #4284). Validates
|
|
83
|
+
// every `.agents/workflows/loops/*.md` loop unit against
|
|
84
|
+
// `.agents/schemas/loop-unit.schema.json`. An absent/empty loops
|
|
85
|
+
// directory is a clean pass; a malformed unit (e.g. a self-paced
|
|
86
|
+
// cadence missing its required `verify`) fails the lint gate with a
|
|
87
|
+
// message naming the offending file + field.
|
|
88
|
+
name: 'loop-units',
|
|
89
|
+
cmd: 'node',
|
|
90
|
+
args: ['.agents/scripts/check-loop-units.js'],
|
|
91
|
+
},
|
|
81
92
|
];
|
|
82
93
|
|
|
83
94
|
function runTask({ name, cmd, args }) {
|