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,22 @@
|
|
|
23
23
|
* The lock is never load-bearing: the caller (`single-story-init.js`)
|
|
24
24
|
* skips the sweep when the lock is contended and continues with init.
|
|
25
25
|
* That matches the existing "sweep never blocks init" contract.
|
|
26
|
+
*
|
|
27
|
+
* **Story #5112 — a live holder is never mistaken for a crashed one.**
|
|
28
|
+
* The critical section this guards (per-candidate `gh pr view`, `getTicket`,
|
|
29
|
+
* `push --delete`) is unbounded, so a healthy sweep can easily outlive the
|
|
30
|
+
* 60 s staleness threshold. Three changes close that:
|
|
31
|
+
*
|
|
32
|
+
* 1. **Heartbeat.** The holder refreshes the lockfile mtime on an
|
|
33
|
+
* unref'd interval below `timeoutMs`, so an alive holder never reads
|
|
34
|
+
* stale no matter how long its critical section runs.
|
|
35
|
+
* 2. **Identity-checked steal.** A stale-breaker re-stats before it
|
|
36
|
+
* unlinks and only removes the *exact* file it observed (same
|
|
37
|
+
* dev/ino/mtime). Two concurrent breakers therefore yield exactly one
|
|
38
|
+
* acquisition — the loser cannot unlink the winner's fresh lockfile.
|
|
39
|
+
* 3. **Owner-checked release.** `release()` unlinks only a lockfile whose
|
|
40
|
+
* owner line still matches this holder, so a late release never drops
|
|
41
|
+
* someone else's lock.
|
|
26
42
|
*/
|
|
27
43
|
|
|
28
44
|
import fs from 'node:fs';
|
|
@@ -31,18 +47,135 @@ import path from 'node:path';
|
|
|
31
47
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
32
48
|
|
|
33
49
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
50
|
+
* Floor for the heartbeat interval. Below this the refresh cost starts to
|
|
51
|
+
* matter for a short critical section, and no `timeoutMs` this framework
|
|
52
|
+
* configures is small enough to need it.
|
|
36
53
|
*/
|
|
37
|
-
|
|
54
|
+
const MIN_HEARTBEAT_MS = 1_000;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Divisor applied to `timeoutMs` to derive the default heartbeat interval.
|
|
58
|
+
* Three refreshes per staleness window means two consecutive missed refreshes
|
|
59
|
+
* (a stalled event loop, a slow disk) still do not make a live holder look
|
|
60
|
+
* dead.
|
|
61
|
+
*/
|
|
62
|
+
const HEARTBEAT_DIVISOR = 3;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Canonical filename for the merged-branch sweep lock. One critical section
|
|
66
|
+
* (`sweepMergedBranches` over `story-*`) reached by two entry points —
|
|
67
|
+
* `single-story-init.js` and `boot-sweep.js` — so it gets one lockfile.
|
|
68
|
+
* Before Story #5112 they used `single-story-sweep.lock` and
|
|
69
|
+
* `boot-sweep.lock` respectively and could therefore run the same reap
|
|
70
|
+
* concurrently, each acting on branches the other was mid-delete on. Callers
|
|
71
|
+
* resolve it through {@link resolveSweepLockPath} rather than by name.
|
|
72
|
+
*/
|
|
73
|
+
const MERGED_BRANCH_SWEEP_LOCK_FILENAME = 'merged-branch-sweep.lock';
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve the one merged-branch sweep lock path. Both sweep entry points
|
|
77
|
+
* MUST route through this helper — that is what makes "one critical section,
|
|
78
|
+
* one lock" checkable rather than a convention two files can silently drift
|
|
79
|
+
* apart on.
|
|
80
|
+
*
|
|
81
|
+
* @param {{ cwd: string, tempRoot?: string }} args
|
|
82
|
+
* @returns {string} absolute path to the shared lockfile.
|
|
83
|
+
*/
|
|
84
|
+
export function resolveSweepLockPath({ cwd, tempRoot = 'temp' } = {}) {
|
|
85
|
+
return path.resolve(cwd, tempRoot, MERGED_BRANCH_SWEEP_LOCK_FILENAME);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Derive the heartbeat interval for a given staleness threshold. Module-
|
|
90
|
+
* private: the contract that matters ("strictly below `timeoutMs`") is
|
|
91
|
+
* observable at the `setIntervalFn` seam {@link acquireSweepLock} accepts, so
|
|
92
|
+
* a test pins it there rather than reaching past the public surface.
|
|
93
|
+
*
|
|
94
|
+
* @param {number} timeoutMs
|
|
95
|
+
* @returns {number}
|
|
96
|
+
*/
|
|
97
|
+
function heartbeatIntervalFor(timeoutMs) {
|
|
98
|
+
const derived = Math.floor(
|
|
99
|
+
(Number.isFinite(timeoutMs) ? timeoutMs : DEFAULT_TIMEOUT_MS) /
|
|
100
|
+
HEARTBEAT_DIVISOR,
|
|
101
|
+
);
|
|
102
|
+
return Math.max(MIN_HEARTBEAT_MS, derived);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Read the lockfile's *identity* — the tuple that distinguishes "the file I
|
|
107
|
+
* observed" from "a different file that now sits at the same path". `dev` +
|
|
108
|
+
* `ino` change when a lockfile is unlinked and re-created, and `mtimeMs`
|
|
109
|
+
* changes on every heartbeat, so a steal that re-checks all three cannot
|
|
110
|
+
* remove a lock some other process created (or refreshed) in the interim.
|
|
111
|
+
*
|
|
112
|
+
* Returns `null` when the file is absent or stat fails ("no holder").
|
|
113
|
+
*
|
|
114
|
+
* @param {string} lockPath
|
|
115
|
+
* @param {object} [fsImpl]
|
|
116
|
+
* Module-private: the two readers that need it (the stale takeover and the
|
|
117
|
+
* `readLockMtime` projection below) both live here, and nothing outside this
|
|
118
|
+
* primitive should be reasoning about a lockfile's inode.
|
|
119
|
+
*
|
|
120
|
+
* @returns {{ mtimeMs: number, ino: number|null, dev: number|null }|null}
|
|
121
|
+
*/
|
|
122
|
+
function readLockIdentity(lockPath, fsImpl = fs) {
|
|
38
123
|
try {
|
|
39
124
|
const stat = fsImpl.statSync(lockPath);
|
|
40
|
-
return
|
|
125
|
+
return {
|
|
126
|
+
mtimeMs: stat.mtimeMs,
|
|
127
|
+
ino: stat.ino ?? null,
|
|
128
|
+
dev: stat.dev ?? null,
|
|
129
|
+
};
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Pure: read the lockfile mtime. Returns `null` when the file is absent or
|
|
137
|
+
* stat fails (treat as "no holder"). The mtime projection of
|
|
138
|
+
* {@link readLockIdentity}. Exported for tests.
|
|
139
|
+
*/
|
|
140
|
+
export function readLockMtime(lockPath, fsImpl = fs) {
|
|
141
|
+
return readLockIdentity(lockPath, fsImpl)?.mtimeMs ?? null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Read the owner id a lockfile was created with (its first line). Returns
|
|
146
|
+
* `null` when the file is absent, unreadable, or empty. Module-private — the
|
|
147
|
+
* owner line is an implementation detail of this primitive; callers observe
|
|
148
|
+
* ownership through which acquire wins and which `release()` is a no-op.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} lockPath
|
|
151
|
+
* @param {object} [fsImpl]
|
|
152
|
+
* @returns {string|null}
|
|
153
|
+
*/
|
|
154
|
+
function readLockOwner(lockPath, fsImpl = fs) {
|
|
155
|
+
try {
|
|
156
|
+
const raw = fsImpl.readFileSync(lockPath, 'utf8');
|
|
157
|
+
const first = String(raw).split('\n', 1)[0];
|
|
158
|
+
return first.length > 0 ? first : null;
|
|
41
159
|
} catch {
|
|
42
160
|
return null;
|
|
43
161
|
}
|
|
44
162
|
}
|
|
45
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Pure: do two identity tuples describe the same lockfile instance? A `null`
|
|
166
|
+
* on either side is "not the same" — an absent file is never the file we
|
|
167
|
+
* observed. Module-private, like {@link readLockIdentity} it compares:
|
|
168
|
+
* nothing outside this primitive should reason about a lockfile's inode.
|
|
169
|
+
*
|
|
170
|
+
* @param {ReturnType<typeof readLockIdentity>} a
|
|
171
|
+
* @param {ReturnType<typeof readLockIdentity>} b
|
|
172
|
+
* @returns {boolean}
|
|
173
|
+
*/
|
|
174
|
+
function sameLockIdentity(a, b) {
|
|
175
|
+
if (!a || !b) return false;
|
|
176
|
+
return a.mtimeMs === b.mtimeMs && a.ino === b.ino && a.dev === b.dev;
|
|
177
|
+
}
|
|
178
|
+
|
|
46
179
|
/**
|
|
47
180
|
* Pure: is the lockfile mtime older than `timeoutMs`? A `null` mtime
|
|
48
181
|
* (no file) returns `false` — the lock isn't held, there's nothing to
|
|
@@ -103,6 +236,11 @@ function tryCreateLock(lockPath, ownerId, fsImpl = fs) {
|
|
|
103
236
|
* @param {object} [opts.nowFn] `() => number` (ms epoch);
|
|
104
237
|
* injection seam for tests.
|
|
105
238
|
* @param {object} [opts.fsImpl] Node `fs` shim for tests.
|
|
239
|
+
* @param {number} [opts.heartbeatMs] Mtime-refresh interval for a live
|
|
240
|
+
* holder; defaults to a third of
|
|
241
|
+
* `timeoutMs`. `0` disables it.
|
|
242
|
+
* @param {Function} [opts.setIntervalFn] Timer seam for tests.
|
|
243
|
+
* @param {Function} [opts.clearIntervalFn] Timer seam for tests.
|
|
106
244
|
* @returns {{ acquired: true, release: () => void, ownerId: string }
|
|
107
245
|
* | { acquired: false, reason: 'contended' | 'error', detail?: string }}
|
|
108
246
|
*/
|
|
@@ -112,6 +250,9 @@ export function acquireSweepLock({
|
|
|
112
250
|
ownerId,
|
|
113
251
|
nowFn = Date.now,
|
|
114
252
|
fsImpl = fs,
|
|
253
|
+
heartbeatMs,
|
|
254
|
+
setIntervalFn = setInterval,
|
|
255
|
+
clearIntervalFn = clearInterval,
|
|
115
256
|
} = {}) {
|
|
116
257
|
if (typeof lockPath !== 'string' || lockPath.length === 0) {
|
|
117
258
|
return {
|
|
@@ -121,22 +262,21 @@ export function acquireSweepLock({
|
|
|
121
262
|
};
|
|
122
263
|
}
|
|
123
264
|
const id = ownerId ?? `pid-${process.pid}-${nowFn()}`;
|
|
265
|
+
const holder = {
|
|
266
|
+
lockPath,
|
|
267
|
+
ownerId: id,
|
|
268
|
+
fsImpl,
|
|
269
|
+
nowFn,
|
|
270
|
+
heartbeatMs: heartbeatMs ?? heartbeatIntervalFor(timeoutMs),
|
|
271
|
+
setIntervalFn,
|
|
272
|
+
clearIntervalFn,
|
|
273
|
+
};
|
|
124
274
|
try {
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (isLockStale(mtime, nowFn(), timeoutMs)) {
|
|
131
|
-
try {
|
|
132
|
-
fsImpl.unlinkSync(lockPath);
|
|
133
|
-
} catch {
|
|
134
|
-
// Race: another holder may have refreshed the lock between
|
|
135
|
-
// our stat and our unlink. Fall through and report contended.
|
|
136
|
-
}
|
|
137
|
-
if (tryCreateLock(lockPath, id, fsImpl)) {
|
|
138
|
-
return buildAcquired(lockPath, id, fsImpl);
|
|
139
|
-
}
|
|
275
|
+
if (
|
|
276
|
+
tryCreateLock(lockPath, id, fsImpl) ||
|
|
277
|
+
tryStaleTakeover(holder, timeoutMs)
|
|
278
|
+
) {
|
|
279
|
+
return buildAcquired(holder);
|
|
140
280
|
}
|
|
141
281
|
return { acquired: false, reason: 'contended' };
|
|
142
282
|
} catch (err) {
|
|
@@ -148,16 +288,126 @@ export function acquireSweepLock({
|
|
|
148
288
|
}
|
|
149
289
|
}
|
|
150
290
|
|
|
151
|
-
|
|
291
|
+
/**
|
|
292
|
+
* Take over a lockfile whose holder looks dead. Returns `true` only when this
|
|
293
|
+
* call both removed the exact stale file it observed *and* won the re-create,
|
|
294
|
+
* so two concurrent breakers yield exactly one acquisition.
|
|
295
|
+
*
|
|
296
|
+
* @param {{ lockPath: string, ownerId: string, fsImpl: object, nowFn: () => number }} holder
|
|
297
|
+
* @param {number} timeoutMs
|
|
298
|
+
* @returns {boolean}
|
|
299
|
+
*/
|
|
300
|
+
function tryStaleTakeover({ lockPath, ownerId, fsImpl, nowFn }, timeoutMs) {
|
|
301
|
+
const observed = readLockIdentity(lockPath, fsImpl);
|
|
302
|
+
if (observed === null) return false;
|
|
303
|
+
if (!isLockStale(observed.mtimeMs, nowFn(), timeoutMs)) return false;
|
|
304
|
+
return (
|
|
305
|
+
breakStaleLock(lockPath, observed, fsImpl) &&
|
|
306
|
+
tryCreateLock(lockPath, ownerId, fsImpl)
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Unlink a stale lockfile — but only when it is still byte-for-byte the
|
|
312
|
+
* instance the caller observed. Returns `true` when this call removed that
|
|
313
|
+
* exact file, `false` when the file changed underneath us (a heartbeat, or
|
|
314
|
+
* another breaker's replacement) or the unlink failed. A `false` return means
|
|
315
|
+
* "someone else owns this now": the caller reports contended rather than
|
|
316
|
+
* racing on.
|
|
317
|
+
*
|
|
318
|
+
* @param {string} lockPath
|
|
319
|
+
* @param {ReturnType<typeof readLockIdentity>} observed
|
|
320
|
+
* @param {object} fsImpl
|
|
321
|
+
* @returns {boolean}
|
|
322
|
+
*/
|
|
323
|
+
function breakStaleLock(lockPath, observed, fsImpl) {
|
|
324
|
+
if (!sameLockIdentity(observed, readLockIdentity(lockPath, fsImpl))) {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
try {
|
|
328
|
+
fsImpl.unlinkSync(lockPath);
|
|
329
|
+
return true;
|
|
330
|
+
} catch {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Refresh a held lockfile's mtime so a long critical section never reads
|
|
337
|
+
* stale to a concurrent acquirer. Refuses to touch a lockfile whose owner
|
|
338
|
+
* line is no longer ours — after a steal the file belongs to someone else and
|
|
339
|
+
* bumping its mtime would keep *their* lock alive on our behalf.
|
|
340
|
+
*
|
|
341
|
+
* @returns {boolean} `true` when the refresh landed; `false` when the lock is
|
|
342
|
+
* no longer ours (the caller stops heartbeating).
|
|
343
|
+
*/
|
|
344
|
+
function refreshLockMtime({ lockPath, ownerId, fsImpl, nowFn }) {
|
|
345
|
+
if (readLockOwner(lockPath, fsImpl) !== ownerId) return false;
|
|
346
|
+
try {
|
|
347
|
+
const stamp = new Date(nowFn());
|
|
348
|
+
fsImpl.utimesSync(lockPath, stamp, stamp);
|
|
349
|
+
return true;
|
|
350
|
+
} catch {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Start the holder's mtime heartbeat. Returns a `stop()` that is safe to call
|
|
357
|
+
* repeatedly. The timer is unref'd where the platform supports it, so a
|
|
358
|
+
* forgotten release can never hold the process open.
|
|
359
|
+
*/
|
|
360
|
+
function startHeartbeat(holder) {
|
|
361
|
+
const { heartbeatMs, setIntervalFn, clearIntervalFn } = holder;
|
|
362
|
+
if (!(heartbeatMs > 0) || typeof setIntervalFn !== 'function') {
|
|
363
|
+
return () => {};
|
|
364
|
+
}
|
|
365
|
+
let timer = null;
|
|
366
|
+
const stop = () => {
|
|
367
|
+
if (timer === null) return;
|
|
368
|
+
const handle = timer;
|
|
369
|
+
timer = null;
|
|
370
|
+
try {
|
|
371
|
+
clearIntervalFn(handle);
|
|
372
|
+
} catch {
|
|
373
|
+
// Best-effort: a fake timer seam may not implement clear.
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
timer = setIntervalFn(() => {
|
|
377
|
+
if (!refreshLockMtime(holder)) stop();
|
|
378
|
+
}, heartbeatMs);
|
|
379
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
380
|
+
return stop;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Drop a lockfile, but only when it is still stamped with `ownerId`. A
|
|
385
|
+
* lockfile another holder created after ours was stolen (or stale-broken) is
|
|
386
|
+
* theirs — dropping it would hand a third caller a lock the current holder
|
|
387
|
+
* still believes it owns.
|
|
388
|
+
*
|
|
389
|
+
* @param {string} lockPath
|
|
390
|
+
* @param {string} ownerId
|
|
391
|
+
* @param {object} fsImpl
|
|
392
|
+
*/
|
|
393
|
+
function unlinkIfOwned(lockPath, ownerId, fsImpl) {
|
|
394
|
+
if (readLockOwner(lockPath, fsImpl) !== ownerId) return;
|
|
395
|
+
try {
|
|
396
|
+
fsImpl.unlinkSync(lockPath);
|
|
397
|
+
} catch {
|
|
398
|
+
// Already gone — nothing to do.
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function buildAcquired(holder) {
|
|
403
|
+
const { lockPath, ownerId, fsImpl } = holder;
|
|
404
|
+
const stopHeartbeat = startHeartbeat(holder);
|
|
152
405
|
let released = false;
|
|
153
406
|
const release = () => {
|
|
154
407
|
if (released) return;
|
|
155
408
|
released = true;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
} catch {
|
|
159
|
-
// Already gone — nothing to do.
|
|
160
|
-
}
|
|
409
|
+
stopHeartbeat();
|
|
410
|
+
unlinkIfOwned(lockPath, ownerId, fsImpl);
|
|
161
411
|
};
|
|
162
412
|
// Belt-and-braces: process exit also clears the lockfile so a
|
|
163
413
|
// crashed run doesn't leave a stale-but-not-yet-old artifact behind.
|
|
@@ -209,6 +459,9 @@ function defaultSleep(ms) {
|
|
|
209
459
|
* @param {() => number} [opts.nowFn]
|
|
210
460
|
* @param {(ms: number) => Promise<void>} [opts.sleepFn]
|
|
211
461
|
* @param {object} [opts.fsImpl]
|
|
462
|
+
* @param {number} [opts.heartbeatMs] Forwarded to the underlying acquire.
|
|
463
|
+
* @param {Function} [opts.setIntervalFn] Forwarded to the underlying acquire.
|
|
464
|
+
* @param {Function} [opts.clearIntervalFn] Forwarded to the underlying acquire.
|
|
212
465
|
* @returns {Promise<{ acquired: true, release: () => void, ownerId: string }
|
|
213
466
|
* | { acquired: false, reason: 'contended-after-wait' | 'error', detail?: string }>}
|
|
214
467
|
*/
|
|
@@ -221,6 +474,9 @@ export async function acquireLockWithWait({
|
|
|
221
474
|
nowFn = Date.now,
|
|
222
475
|
sleepFn = defaultSleep,
|
|
223
476
|
fsImpl = fs,
|
|
477
|
+
heartbeatMs,
|
|
478
|
+
setIntervalFn = setInterval,
|
|
479
|
+
clearIntervalFn = clearInterval,
|
|
224
480
|
} = {}) {
|
|
225
481
|
const deadline = nowFn() + Math.max(0, waitMs);
|
|
226
482
|
for (;;) {
|
|
@@ -230,6 +486,9 @@ export async function acquireLockWithWait({
|
|
|
230
486
|
ownerId,
|
|
231
487
|
nowFn,
|
|
232
488
|
fsImpl,
|
|
489
|
+
heartbeatMs,
|
|
490
|
+
setIntervalFn,
|
|
491
|
+
clearIntervalFn,
|
|
233
492
|
});
|
|
234
493
|
if (res.acquired) return res;
|
|
235
494
|
// A hard error will not resolve by retrying — surface it immediately.
|
|
@@ -13,7 +13,7 @@ import path from 'node:path';
|
|
|
13
13
|
* @param {string} rootDir
|
|
14
14
|
* @returns {string[]} absolute paths
|
|
15
15
|
*/
|
|
16
|
-
|
|
16
|
+
function walkSkillFiles(rootDir) {
|
|
17
17
|
const out = [];
|
|
18
18
|
if (!fs.existsSync(rootDir)) return out;
|
|
19
19
|
const stack = [rootDir];
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// .agents/scripts/lib/source-text/strip-js-comments.js
|
|
2
|
+
/**
|
|
3
|
+
* The one string-literal-aware JavaScript comment stripper.
|
|
4
|
+
*
|
|
5
|
+
* Several guards in this repository grep source text for a pattern that must
|
|
6
|
+
* appear in real code rather than in a docblock — a CLI citation, an import
|
|
7
|
+
* specifier, a schema path. Each grew its own stripper, and the four
|
|
8
|
+
* implementations disagreed on what a stripped block comment leaves behind:
|
|
9
|
+
* one preserved newlines only, one deleted the comment outright (losing line
|
|
10
|
+
* numbers), one blanked it to whitespace, and one used a regex that did not
|
|
11
|
+
* honour string literals at all. A fix to any of them silently missed the
|
|
12
|
+
* others, and a guard whose own rationale mentions the pattern it greps for
|
|
13
|
+
* can satisfy itself if the stripping is wrong.
|
|
14
|
+
*
|
|
15
|
+
* **Semantics: comment bodies become equivalent whitespace.** Every character
|
|
16
|
+
* of a comment is replaced by a space except newlines, which survive. That is
|
|
17
|
+
* the most information-preserving of the four behaviours and the only one that
|
|
18
|
+
* is safe for all of them:
|
|
19
|
+
*
|
|
20
|
+
* - line and column positions are unchanged, so a guard can report a
|
|
21
|
+
* `file:line` that matches the original source;
|
|
22
|
+
* - a regex looking for code cannot match comment text, because none
|
|
23
|
+
* survives;
|
|
24
|
+
* - the output is the same length as the input, so byte offsets hold.
|
|
25
|
+
*
|
|
26
|
+
* String and template literals are copied through verbatim, so a `//` inside a
|
|
27
|
+
* URL or a `/*` inside a message is not mistaken for a comment opener. Escape
|
|
28
|
+
* sequences are honoured, so an escaped quote does not end the literal early.
|
|
29
|
+
*
|
|
30
|
+
* An unterminated comment or literal runs to end of input rather than
|
|
31
|
+
* throwing: this is a lint helper reading files that may be mid-edit, and a
|
|
32
|
+
* crash there would be a worse failure than a slightly over-stripped tail.
|
|
33
|
+
*
|
|
34
|
+
* Builtins only — no dependency, so the guards that run before a consumer's
|
|
35
|
+
* install can use it.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Replace every non-newline character of `text` with a space.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} text
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
function blank(text) {
|
|
45
|
+
return text.replace(/[^\n]/g, ' ');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Index just past the string or template literal opening at `start`.
|
|
50
|
+
* Runs to end of input when the literal is never closed.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} text
|
|
53
|
+
* @param {number} start - index of the opening quote
|
|
54
|
+
* @returns {number}
|
|
55
|
+
*/
|
|
56
|
+
function endOfLiteral(text, start) {
|
|
57
|
+
const quote = text[start];
|
|
58
|
+
let i = start + 1;
|
|
59
|
+
while (i < text.length) {
|
|
60
|
+
const ch = text[i];
|
|
61
|
+
if (ch === '\\') {
|
|
62
|
+
i += 2;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (ch === quote) return i + 1;
|
|
66
|
+
i += 1;
|
|
67
|
+
}
|
|
68
|
+
return text.length;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Strip JavaScript comments, replacing their bodies with equivalent
|
|
73
|
+
* whitespace so line numbers, columns and byte offsets all survive.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} source - JavaScript source text. Nullish is treated as empty.
|
|
76
|
+
* @returns {string} the source with every comment body blanked to whitespace
|
|
77
|
+
*/
|
|
78
|
+
export function stripJsComments(source) {
|
|
79
|
+
const text = String(source ?? '');
|
|
80
|
+
let out = '';
|
|
81
|
+
let i = 0;
|
|
82
|
+
|
|
83
|
+
while (i < text.length) {
|
|
84
|
+
const two = text.slice(i, i + 2);
|
|
85
|
+
|
|
86
|
+
if (two === '//') {
|
|
87
|
+
const newline = text.indexOf('\n', i);
|
|
88
|
+
const stop = newline === -1 ? text.length : newline;
|
|
89
|
+
out += blank(text.slice(i, stop));
|
|
90
|
+
i = stop;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (two === '/*') {
|
|
95
|
+
const close = text.indexOf('*/', i + 2);
|
|
96
|
+
const stop = close === -1 ? text.length : close + 2;
|
|
97
|
+
out += blank(text.slice(i, stop));
|
|
98
|
+
i = stop;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const ch = text[i];
|
|
103
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
104
|
+
const stop = endOfLiteral(text, i);
|
|
105
|
+
out += text.slice(i, stop);
|
|
106
|
+
i = stop;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
out += ch;
|
|
111
|
+
i += 1;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
@@ -58,7 +58,7 @@ const NODE_TEST_BASE = [
|
|
|
58
58
|
* @param {typeof spawn} [opts.spawnFn]
|
|
59
59
|
* @returns {Promise<IsolatedResult>}
|
|
60
60
|
*/
|
|
61
|
-
|
|
61
|
+
function runFileIsolated({
|
|
62
62
|
repoRoot,
|
|
63
63
|
file,
|
|
64
64
|
envOutDir,
|
|
@@ -195,7 +195,7 @@ function sanitizeEnv(source) {
|
|
|
195
195
|
* @param {typeof spawn} [opts.spawnFn]
|
|
196
196
|
* @returns {Promise<{ results: SuiteResult[], exitCode: number, stdout: string, stderr: string }>}
|
|
197
197
|
*/
|
|
198
|
-
|
|
198
|
+
function runSuite({
|
|
199
199
|
repoRoot,
|
|
200
200
|
files,
|
|
201
201
|
concurrency = 8,
|
|
@@ -266,7 +266,7 @@ export function runSuite({
|
|
|
266
266
|
* @param {(r: IsolatedResult) => void} [opts.onResult]
|
|
267
267
|
* @returns {Promise<IsolatedResult[]>}
|
|
268
268
|
*/
|
|
269
|
-
|
|
269
|
+
async function runIsolatedPool({
|
|
270
270
|
repoRoot,
|
|
271
271
|
files,
|
|
272
272
|
envOutDir,
|
|
@@ -90,13 +90,20 @@ export const TEST_RUNNER_FLAGS = Object.freeze([
|
|
|
90
90
|
* the spawns `runTierPreflight` actually issues — which is the thing that
|
|
91
91
|
* has to be true.
|
|
92
92
|
*/
|
|
93
|
+
const STATE_PROBE_ONLY = Object.freeze(['.agents/scripts/test-wrapper.js']);
|
|
94
|
+
|
|
93
95
|
const TIER_PREFLIGHT_SCRIPTS = Object.freeze({
|
|
94
96
|
full: Object.freeze([
|
|
95
|
-
|
|
97
|
+
...STATE_PROBE_ONLY,
|
|
96
98
|
'.agents/scripts/validate-skills.js',
|
|
97
99
|
]),
|
|
98
|
-
quick:
|
|
99
|
-
integration:
|
|
100
|
+
quick: STATE_PROBE_ONLY,
|
|
101
|
+
integration: STATE_PROBE_ONLY,
|
|
102
|
+
// Story #5111: the `e2e` tier gets the state probe like every other
|
|
103
|
+
// non-full tier. An absent entry is not a neutral default — the lookup
|
|
104
|
+
// below falls through to `[]`, so a tier missing from this map runs *no*
|
|
105
|
+
// preflight at all and the omission is invisible in a green run.
|
|
106
|
+
e2e: STATE_PROBE_ONLY,
|
|
100
107
|
});
|
|
101
108
|
|
|
102
109
|
/**
|
|
@@ -108,7 +115,7 @@ const TIER_PREFLIGHT_SCRIPTS = Object.freeze({
|
|
|
108
115
|
* executes, which the npm hook does not under `ignore-scripts=true`.
|
|
109
116
|
*
|
|
110
117
|
* @param {object} [opts]
|
|
111
|
-
* @param {'full' | 'quick' | 'integration'} [opts.tier]
|
|
118
|
+
* @param {'full' | 'quick' | 'integration' | 'e2e'} [opts.tier]
|
|
112
119
|
* @param {string} [opts.repoRoot] Absolute repository root.
|
|
113
120
|
* @param {typeof spawnSync} [opts.spawn] Injected in tests.
|
|
114
121
|
* @param {string} [opts.execPath] Node binary to spawn; injected in tests.
|
|
@@ -127,8 +134,9 @@ export function runTierPreflight({
|
|
|
127
134
|
stdio: 'inherit',
|
|
128
135
|
});
|
|
129
136
|
if (run.error) throw run.error;
|
|
130
|
-
|
|
131
|
-
|
|
137
|
+
// A signal-killed child reports `status: null`; treat that as the
|
|
138
|
+
// reserved failure code rather than letting it read as "not zero, no code".
|
|
139
|
+
if (run.status !== 0) return run.status ?? 1;
|
|
132
140
|
}
|
|
133
141
|
return 0;
|
|
134
142
|
}
|