mandrel 2.36.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/configuration.md +25 -0
- package/.agents/rules/testing-standards.md +56 -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-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/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/sync-agentrc.js +1 -1
- 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 -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/options.js +52 -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/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/docs/CHANGELOG.md +32 -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 +7 -3
- 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
|
@@ -29,8 +29,9 @@
|
|
|
29
29
|
* a claim back from whoever took over).
|
|
30
30
|
*
|
|
31
31
|
* Provider contract (a subset of `ITicketingProvider`):
|
|
32
|
-
* - `getTicket(id)`
|
|
33
|
-
* - `updateTicket(id, { assignees })`
|
|
32
|
+
* - `getTicket(id)` → `{ assignees: string[], ... }`
|
|
33
|
+
* - `updateTicket(id, { assignees })` replaces the assignee list.
|
|
34
|
+
* - `updateTicket(id, { addAssignees })` appends to it (Story #5112).
|
|
34
35
|
*/
|
|
35
36
|
|
|
36
37
|
/**
|
|
@@ -128,11 +129,13 @@ function normaliseOpts(op, opts) {
|
|
|
128
129
|
* operator out, `acquired: false`,
|
|
129
130
|
* `owner: <foreign>`, `reason: 'lost-race'`.
|
|
130
131
|
*
|
|
131
|
-
* Every claiming write is verified: GitHub's assignee
|
|
132
|
+
* Every claiming write is verified: GitHub's assignee write is not a
|
|
132
133
|
* compare-and-set, so two runs that both read the ticket unassigned will both
|
|
133
134
|
* write themselves. {@link claimAndVerify} re-reads after the write and refuses
|
|
134
135
|
* (fail-closed) when a foreign login is present, so the loser of a simultaneous
|
|
135
|
-
* claim never proceeds as though it holds the lease.
|
|
136
|
+
* claim never proceeds as though it holds the lease. Story #5112 made the
|
|
137
|
+
* first claim **additive** so that verify can actually see the collision —
|
|
138
|
+
* see {@link claimAndVerify}.
|
|
136
139
|
*
|
|
137
140
|
* @param {object} opts
|
|
138
141
|
* @param {object} opts.provider Ticketing provider.
|
|
@@ -199,7 +202,7 @@ export async function acquireLease(opts) {
|
|
|
199
202
|
*
|
|
200
203
|
* The assignee write is not atomic — GitHub offers no compare-and-set on the
|
|
201
204
|
* assignees surface — so two runs that both observed the ticket unassigned (or
|
|
202
|
-
* a stale foreign claim) will both
|
|
205
|
+
* a stale foreign claim) will both write themselves in. Without a check the
|
|
203
206
|
* loser of that race returns `acquired: true` and marches into the worktree
|
|
204
207
|
* the winner is already building. The verify closes that window: it re-reads
|
|
205
208
|
* with `fresh: true` (bypassing any provider cache so it sees the other run's
|
|
@@ -208,9 +211,16 @@ export async function acquireLease(opts) {
|
|
|
208
211
|
* returns `acquired: false` / `reason: 'lost-race'` so the fail-closed caller
|
|
209
212
|
* refuses. A clean read (assignees exactly `[operator]`) confirms the claim.
|
|
210
213
|
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
+
* **The write must be additive for the verify to work (Story #5112).** With
|
|
215
|
+
* the replacing PATCH this used unconditionally, a simultaneous claim
|
|
216
|
+
* *evicted* the other operator rather than joining it, so the co-assignment
|
|
217
|
+
* the `lost-race` branch keys on was a state the PATCH could never produce:
|
|
218
|
+
* each run read a clean `[self]` on verify and both proceeded. Claiming an
|
|
219
|
+
* unowned ticket therefore goes through the additive assignees endpoint
|
|
220
|
+
* (`addAssignees`), which makes the collision observable and lets exactly one
|
|
221
|
+
* claimer survive. The replacing form stays for the two cases that genuinely
|
|
222
|
+
* mean "replace": the `--steal` transfer of a foreign claim, and the loser's
|
|
223
|
+
* own back-out below.
|
|
214
224
|
*
|
|
215
225
|
* @param {object} args
|
|
216
226
|
* @param {object} args.provider Ticketing provider.
|
|
@@ -220,6 +230,21 @@ export async function acquireLease(opts) {
|
|
|
220
230
|
* @param {string} args.reason Success reason when the claim holds.
|
|
221
231
|
* @returns {Promise<{ acquired: boolean, owner: string, previousOwner: string|null, reason: string }>}
|
|
222
232
|
*/
|
|
233
|
+
/**
|
|
234
|
+
* The assignee mutation a claim writes. Additive when the ticket has no
|
|
235
|
+
* previous owner — that is what makes a simultaneous claim show up as a
|
|
236
|
+
* co-assignment {@link claimAndVerify} can detect. Replacing only for a
|
|
237
|
+
* steal, where evicting the previous owner *is* the intent.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} operator
|
|
240
|
+
* @param {string|null} previousOwner
|
|
241
|
+
* @returns {{ addAssignees: string[] }|{ assignees: string[] }}
|
|
242
|
+
*/
|
|
243
|
+
function claimMutation(operator, previousOwner) {
|
|
244
|
+
if (previousOwner === null) return { addAssignees: [operator] };
|
|
245
|
+
return { assignees: [operator] };
|
|
246
|
+
}
|
|
247
|
+
|
|
223
248
|
async function claimAndVerify({
|
|
224
249
|
provider,
|
|
225
250
|
ticketId,
|
|
@@ -227,7 +252,7 @@ async function claimAndVerify({
|
|
|
227
252
|
previousOwner,
|
|
228
253
|
reason,
|
|
229
254
|
}) {
|
|
230
|
-
await provider.updateTicket(ticketId,
|
|
255
|
+
await provider.updateTicket(ticketId, claimMutation(operator, previousOwner));
|
|
231
256
|
|
|
232
257
|
const after = await provider.getTicket(ticketId, { fresh: true });
|
|
233
258
|
const assignees = Array.isArray(after?.assignees) ? after.assignees : [];
|
|
@@ -81,7 +81,9 @@ export const STRUCTURED_COMMENT_TYPES = Object.freeze([
|
|
|
81
81
|
'epic-run-state',
|
|
82
82
|
'epic-run-progress',
|
|
83
83
|
'epic-plan-state',
|
|
84
|
-
|
|
84
|
+
// `parked-follow-ons` retired in Story #5114 with the module that was its
|
|
85
|
+
// only writer. A kind the reader still recognises but nothing emits is the
|
|
86
|
+
// same dead wiring in a new place.
|
|
85
87
|
// Story #566 — per-phase wall-clock summary posted by single-story-close.js.
|
|
86
88
|
'phase-timings',
|
|
87
89
|
// Story #831 — story-init upserts a `story-init` comment that
|
|
@@ -124,7 +124,7 @@ function pick(logger, level) {
|
|
|
124
124
|
* @param {Array<object>} fixed
|
|
125
125
|
* @param {object} [logger]
|
|
126
126
|
*/
|
|
127
|
-
|
|
127
|
+
function logFixes(fixed, logger = DEFAULT_LOGGER) {
|
|
128
128
|
const info = pick(logger, 'info');
|
|
129
129
|
info(`[preflight] auto-fixed ${fixed.length} finding(s):`);
|
|
130
130
|
for (const f of fixed) {
|
|
@@ -141,7 +141,7 @@ export function logFixes(fixed, logger = DEFAULT_LOGGER) {
|
|
|
141
141
|
* @param {Array<object>} blockers
|
|
142
142
|
* @param {object} [logger]
|
|
143
143
|
*/
|
|
144
|
-
|
|
144
|
+
function logBlockers(scope, blockers, logger = DEFAULT_LOGGER) {
|
|
145
145
|
const error = pick(logger, 'error');
|
|
146
146
|
error(
|
|
147
147
|
`[preflight] ${scope}: ${blockers.length} blocker finding(s) — refusing to proceed.`,
|
|
@@ -180,7 +180,7 @@ export function logBlockers(scope, blockers, logger = DEFAULT_LOGGER) {
|
|
|
180
180
|
* @param {Array<object>} findings
|
|
181
181
|
* @param {object} [logger]
|
|
182
182
|
*/
|
|
183
|
-
|
|
183
|
+
function logNonBlockers(findings, logger = DEFAULT_LOGGER) {
|
|
184
184
|
const warn = pick(logger, 'warn');
|
|
185
185
|
for (const f of findings) {
|
|
186
186
|
warn(`[preflight] ${f.severity}: ${f.id} — ${f.summary ?? ''}`);
|
|
@@ -36,7 +36,7 @@ import path from 'node:path';
|
|
|
36
36
|
import { tempRootFrom } from '../config/temp-paths.js';
|
|
37
37
|
|
|
38
38
|
/** Directory segment (under `tempRoot`) that holds every QA session ledger. */
|
|
39
|
-
|
|
39
|
+
const QA_LEDGER_DIRNAME = 'qa';
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* Triaged dispositions, mirrored from the `disposition` enum in
|
|
@@ -26,13 +26,7 @@ const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
|
26
26
|
* `.agents/scripts/lib/runtime-deps/`; the manifest sits at the `.agents/`
|
|
27
27
|
* root, three directories up.
|
|
28
28
|
*/
|
|
29
|
-
|
|
30
|
-
HERE,
|
|
31
|
-
'..',
|
|
32
|
-
'..',
|
|
33
|
-
'..',
|
|
34
|
-
'runtime-deps.json',
|
|
35
|
-
);
|
|
29
|
+
const MANIFEST_PATH = path.resolve(HERE, '..', '..', '..', 'runtime-deps.json');
|
|
36
30
|
|
|
37
31
|
/**
|
|
38
32
|
* @typedef {object} RuntimeDepsManifest
|
|
@@ -26,9 +26,10 @@
|
|
|
26
26
|
import fs from 'node:fs';
|
|
27
27
|
import { builtinModules } from 'node:module';
|
|
28
28
|
import path from 'node:path';
|
|
29
|
+
import { stripJsComments } from '../source-text/strip-js-comments.js';
|
|
29
30
|
|
|
30
31
|
/** Node builtins, with and without the `node:` prefix. */
|
|
31
|
-
|
|
32
|
+
const BUILTIN_MODULES = new Set([
|
|
32
33
|
...builtinModules,
|
|
33
34
|
...builtinModules.map((m) => `node:${m}`),
|
|
34
35
|
]);
|
|
@@ -71,62 +72,6 @@ const SIDE_EFFECT = /^\s*import\s*['"]([^'"]+)['"]/gm;
|
|
|
71
72
|
// `require(...)` and dynamic `import(...)` may appear mid-expression.
|
|
72
73
|
const CALL_FORM = /\b(?:require|import)\s*\(\s*['"]([^'"]+)['"]/g;
|
|
73
74
|
|
|
74
|
-
/**
|
|
75
|
-
* Remove `//` line comments and block comments while preserving string and
|
|
76
|
-
* template literals (which carry the import specifiers we extract). A
|
|
77
|
-
* char-by-char state machine is used rather than a regex so that comment
|
|
78
|
-
* markers inside string literals (e.g. a `https://` URL) are not mistaken
|
|
79
|
-
* for comments, and example import syntax inside *comments* (e.g. a
|
|
80
|
-
* `// require('x')` doc line) does not register as a phantom dependency.
|
|
81
|
-
*
|
|
82
|
-
* @param {string} source
|
|
83
|
-
* @returns {string}
|
|
84
|
-
*/
|
|
85
|
-
export function stripComments(source) {
|
|
86
|
-
let out = '';
|
|
87
|
-
let i = 0;
|
|
88
|
-
const n = source.length;
|
|
89
|
-
while (i < n) {
|
|
90
|
-
const ch = source[i];
|
|
91
|
-
const next = source[i + 1];
|
|
92
|
-
// Enter a string / template literal — copy verbatim until it closes.
|
|
93
|
-
if (ch === '"' || ch === "'" || ch === '`') {
|
|
94
|
-
const quote = ch;
|
|
95
|
-
out += ch;
|
|
96
|
-
i += 1;
|
|
97
|
-
while (i < n) {
|
|
98
|
-
const c = source[i];
|
|
99
|
-
out += c;
|
|
100
|
-
if (c === '\\') {
|
|
101
|
-
// Copy the escaped char too, then continue.
|
|
102
|
-
if (i + 1 < n) out += source[i + 1];
|
|
103
|
-
i += 2;
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
|
-
i += 1;
|
|
107
|
-
if (c === quote) break;
|
|
108
|
-
}
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
// Line comment — drop to end of line (keep the newline).
|
|
112
|
-
if (ch === '/' && next === '/') {
|
|
113
|
-
i += 2;
|
|
114
|
-
while (i < n && source[i] !== '\n') i += 1;
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
|
-
// Block comment — drop until the closing `*/`.
|
|
118
|
-
if (ch === '/' && next === '*') {
|
|
119
|
-
i += 2;
|
|
120
|
-
while (i < n && !(source[i] === '*' && source[i + 1] === '/')) i += 1;
|
|
121
|
-
i += 2;
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
|
-
out += ch;
|
|
125
|
-
i += 1;
|
|
126
|
-
}
|
|
127
|
-
return out;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
75
|
/**
|
|
131
76
|
* Extract the set of third-party top-level package names imported by a
|
|
132
77
|
* single source string.
|
|
@@ -136,7 +81,7 @@ export function stripComments(source) {
|
|
|
136
81
|
*/
|
|
137
82
|
export function extractThirdPartyImports(source) {
|
|
138
83
|
const found = new Set();
|
|
139
|
-
const cleaned =
|
|
84
|
+
const cleaned = stripJsComments(source);
|
|
140
85
|
for (const re of [STATIC_FROM, SIDE_EFFECT, CALL_FORM]) {
|
|
141
86
|
re.lastIndex = 0;
|
|
142
87
|
let match = re.exec(cleaned);
|
|
@@ -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];
|