@vitest-agent/plugin 2.2.1 → 2.2.2

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/README.md CHANGED
@@ -9,7 +9,7 @@ Vitest plugin that turns your test suite into a live data source for LLM coding
9
9
  ## Features
10
10
 
11
11
  - **`AgentPlugin`** — drop into `vitest.config.ts`; auto-detects human, agent and CI executors and adapts console output accordingly
12
- - **Project discovery** — `AgentPlugin.discover()` scans workspace packages, returns `{ projects, tags }` ready for `test.projects` and `test.tags`; classification tags apply at collection time, so every test declaration form inherits them, including wrapper testers like `@effect/vitest`'s `it.effect`
12
+ - **Project discovery** — `AgentPlugin.discover()` scans workspace packages, returns `{ projects, tags }` ready for `test.projects` and `test.tags`; classification tags apply at collection time, so every test declaration form inherits them, including wrapper testers like `@effect/vitest`'s `it.effect`; a package that looks test-shaped but ends up with no project warns on stderr instead of silently running nothing
13
13
  - **Coverage presets** — `COVERAGE_LEVELS` and `COVERAGE_LEVELS_PER_FILE` return dual-output `{ thresholds, coverageTargets }` objects; `COVERAGE_AUTOUPDATE` tolerance functions plug into Vitest's native `autoUpdate`
14
14
  - **Failure classification** — persists per-test errors, computes failure signatures, classifies tests as stable, new-failure, persistent, flaky or recovered across runs
15
15
  - **Custom reporters** — pass any `VitestAgentReporterFactory` as the `reporter` option; the default wires `DefaultVitestAgentReporter` from `@vitest-agent/reporter`
package/index.d.ts CHANGED
@@ -351,6 +351,19 @@ declare namespace AgentPlugin {
351
351
  * Designed for use in Vitest `globalSetup` files to run build steps or
352
352
  * other preparatory scripts without polluting agent stdout.
353
353
  *
354
+ * Concurrent invocations of the same command from the same workspace
355
+ * (e.g. two `vitest` runs started in one checkout at once) serialize
356
+ * through a file-based advisory lock (issue #191): the first caller
357
+ * runs the command; a concurrent caller blocks until the lock frees,
358
+ * then skips its own run when the winner's build is still fresh
359
+ * (`DEFAULT_BUILT_RECENTLY_MS`) rather than repeating it.
360
+ *
361
+ * Serialization is best-effort by design: a waiter that is still
362
+ * blocked after `DEFAULT_LOCK_WAIT_TIMEOUT_MS` gives up and runs the
363
+ * command anyway, unserialized, rather than hanging the caller's test
364
+ * run forever. See `utils/run-script-lock.ts` for that escape valve
365
+ * and for the two-tier (pid probe, then age) stale-takeover rule.
366
+ *
354
367
  * On failure the captured stderr and stdout are written to their respective
355
368
  * streams before rethrowing, so the error is still visible to humans and
356
369
  * surfaced in CI logs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vitest-agent/plugin",
3
- "version": "2.2.1",
3
+ "version": "2.2.2",
4
4
  "private": false,
5
5
  "description": "Vitest plugin for the vitest-agent ecosystem: owns persistence, classification, baselines, trends, and dispatches rendering to a configurable reporter.",
6
6
  "keywords": [
@@ -41,13 +41,13 @@
41
41
  "dependencies": {
42
42
  "@effect/platform-node": "4.0.0-beta.107",
43
43
  "@effect/sql-sqlite-node": "4.0.0-beta.107",
44
- "@effected/workspaces": "^0.11.2",
45
- "@vitest-agent/cli": "2.1.1",
46
- "@vitest-agent/mcp": "2.1.4",
47
- "@vitest-agent/reporter": "2.0.19",
48
- "@vitest-agent/sdk": "2.2.1",
44
+ "@effected/workspaces": "^0.12.0",
45
+ "@vitest-agent/cli": "2.1.2",
46
+ "@vitest-agent/mcp": "2.2.0",
47
+ "@vitest-agent/reporter": "2.0.20",
48
+ "@vitest-agent/sdk": "2.3.0",
49
49
  "effect": "4.0.0-beta.107",
50
- "magic-string": "^1.1.0"
50
+ "magic-string": "^1.2.0"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "@vitest/coverage-istanbul": "^4.1.0",
package/plugin.js CHANGED
@@ -7,6 +7,7 @@ import { DefaultDiscoverStrategy } from "./utils/discover-strategy.js";
7
7
  import { discoverProjects } from "./utils/discover-projects.js";
8
8
  import { injectTags } from "./utils/inject-tags.js";
9
9
  import { isBenignViteSourceMapWarning } from "./utils/is-benign-vite-source-map-warning.js";
10
+ import { DEFAULT_BUILT_RECENTLY_MS, DEFAULT_LOCK_STALE_MS, DEFAULT_LOCK_WAIT_TIMEOUT_MS, acquireRunScriptLock, markRunScriptDone, parseLockTimingOverride, releaseRunScriptLock } from "./utils/run-script-lock.js";
10
11
  import { stripConsoleReporters } from "./utils/strip-console-reporters.js";
11
12
  import { execSync } from "node:child_process";
12
13
  import { AgentConsoleMode, CiConsoleMode, CoverageLevel, EnvironmentDetector, EnvironmentDetectorLive, HumanConsoleMode, SRC_DIR, TEST_DIR, formatFatalError, isTestFileName, resolveLogLevel } from "@vitest-agent/sdk";
@@ -37,7 +38,8 @@ function resolveConsoleMode(options, executor, _env) {
37
38
  if (executor === "human" && Schema.is(HumanConsoleMode)(override)) return override;
38
39
  if (executor === "agent" && Schema.is(AgentConsoleMode)(override)) return override;
39
40
  if (executor !== "human" && executor !== "agent" && Schema.is(CiConsoleMode)(override)) return override;
40
- process.stderr.write(`[vitest-agent:plugin] ignoring invalid VITEST_AGENT_CONSOLE="${override}" for ${executor} executor\n`);
41
+ const accepted = executor === "human" ? HumanConsoleMode.literals : executor === "agent" ? AgentConsoleMode.literals : CiConsoleMode.literals;
42
+ process.stderr.write(`[vitest-agent:plugin] ignoring invalid VITEST_AGENT_CONSOLE="${override}" for ${executor} executor; accepted for ${executor}: ${accepted.join(" | ")}\n`);
41
43
  }
42
44
  const console = options.console;
43
45
  if (executor === "human") return console?.human ?? "passthrough";
@@ -98,7 +100,7 @@ const aggregatedReporterByVitest = /* @__PURE__ */ new WeakSet();
98
100
  *
99
101
  * @public
100
102
  */
101
- const CURRENT_PLUGIN_VERSION = "2.2.1";
103
+ const CURRENT_PLUGIN_VERSION = "2.2.2";
102
104
  const TEST_FILE_DIR_RE = new RegExp(`/(?:${SRC_DIR}|${TEST_DIR})/`);
103
105
  const isTestFile = (id) => isTestFileName(id) && TEST_FILE_DIR_RE.test(id);
104
106
  /**
@@ -308,14 +310,37 @@ function makeDiscoverBuilder(options) {
308
310
  });
309
311
  }
310
312
  _AgentPlugin.discover = discover;
313
+ /**
314
+ * Parses a `VITEST_AGENT_RUNSCRIPT_*` override env var, falling back to
315
+ * `fallback` for anything that is not a whole positive integer at or above
316
+ * `minimum`. Test-support: lets the concurrency e2e suite use short timeouts
317
+ * instead of the production-sized defaults — but a typo there (`"200ms"`,
318
+ * `"-1"`, `"0"`) must degrade to the default rather than to an
319
+ * instantly-stale lock or a busy-spinning waiter.
320
+ */
321
+ function readRunScriptLockEnvOverride(name, fallback, minimum = 1) {
322
+ return parseLockTimingOverride(process.env[name], fallback, minimum);
323
+ }
311
324
  function runScript(command) {
325
+ const lock = acquireRunScriptLock({
326
+ cwd: process.cwd(),
327
+ command,
328
+ staleMs: readRunScriptLockEnvOverride("VITEST_AGENT_RUNSCRIPT_LOCK_STALE_MS", DEFAULT_LOCK_STALE_MS),
329
+ waitTimeoutMs: readRunScriptLockEnvOverride("VITEST_AGENT_RUNSCRIPT_LOCK_WAIT_TIMEOUT_MS", DEFAULT_LOCK_WAIT_TIMEOUT_MS),
330
+ pollMs: readRunScriptLockEnvOverride("VITEST_AGENT_RUNSCRIPT_LOCK_POLL_MS", 200, 10),
331
+ builtRecentlyMs: readRunScriptLockEnvOverride("VITEST_AGENT_RUNSCRIPT_BUILT_RECENTLY_MS", DEFAULT_BUILT_RECENTLY_MS)
332
+ });
333
+ if (lock.recentlyBuilt) return;
312
334
  try {
313
335
  execSync(command, { stdio: "pipe" });
336
+ markRunScriptDone(lock);
314
337
  } catch (error) {
315
338
  const execError = error;
316
339
  if (execError.stderr?.length) process.stderr.write(execError.stderr);
317
340
  if (execError.stdout?.length) process.stdout.write(execError.stdout);
318
341
  throw error;
342
+ } finally {
343
+ releaseRunScriptLock(lock);
319
344
  }
320
345
  }
321
346
  _AgentPlugin.runScript = runScript;
@@ -1,5 +1,6 @@
1
1
  import { toPosixPath } from "./to-posix-path.js";
2
2
  import { DefaultDiscoverStrategy } from "./discover-strategy.js";
3
+ import { isTestShapedPackage } from "./is-test-shaped-package.js";
3
4
  import { NON_DISCOVERABLE_DIRS, SRC_DIR, TEST_DIR } from "@vitest-agent/sdk";
4
5
  import { isAbsolute, join, normalize, relative } from "node:path";
5
6
  import { readdir, stat } from "node:fs/promises";
@@ -8,6 +9,24 @@ import { nodeSyncOps } from "@effected/workspaces/node-sync";
8
9
 
9
10
  //#region src/utils/discover-projects.ts
10
11
  const DISCOVERY_LAST_SCAN_SYMBOL = Symbol.for("vitest-agent:discovery:last-scan-at");
12
+ const warnedDeclinedPackagePaths = /* @__PURE__ */ new Set();
13
+ async function warnIfDeclinedPackageIsTestShaped(pkg) {
14
+ const normPath = normalize(pkg.path);
15
+ if (warnedDeclinedPackagePaths.has(normPath)) return;
16
+ warnedDeclinedPackagePaths.add(normPath);
17
+ let testShaped;
18
+ try {
19
+ testShaped = await isTestShapedPackage(pkg.path);
20
+ } catch (error) {
21
+ warnedDeclinedPackagePaths.delete(normPath);
22
+ throw error;
23
+ }
24
+ if (!testShaped) {
25
+ warnedDeclinedPackagePaths.delete(normPath);
26
+ return;
27
+ }
28
+ process.stderr.write(`[vitest-agent] warning: package "${pkg.name}" has a ${TEST_DIR}/ directory or ${SRC_DIR}/ test files, but its tests were not wired into a Vitest project. Run \`npx vitest-agent agent check-test-path <path>\` to diagnose why.\n`);
29
+ }
11
30
  function recordDiscoveryScanTimestamp() {
12
31
  globalThis[DISCOVERY_LAST_SCAN_SYMBOL] = (/* @__PURE__ */ new Date()).toISOString();
13
32
  }
@@ -107,6 +126,7 @@ async function discoverProjects(options) {
107
126
  workspaceRoot: root
108
127
  });
109
128
  if (config !== null) configs.push(config);
129
+ else await warnIfDeclinedPackageIsTestShaped(pkg);
110
130
  workspaceNames.add(pkg.name);
111
131
  workspacePaths.add(normalize(pkg.path));
112
132
  }
@@ -0,0 +1,40 @@
1
+ import { findTestFiles } from "./find-test-files.js";
2
+ import { SRC_DIR, TEST_DIR, TEST_FILE_GLOB_SUFFIX } from "@vitest-agent/sdk";
3
+ import { join } from "node:path";
4
+ import { stat } from "node:fs/promises";
5
+
6
+ //#region src/utils/is-test-shaped-package.ts
7
+ async function isDirectory(p) {
8
+ try {
9
+ return (await stat(p)).isDirectory();
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+ /**
15
+ * True when `pkgPath` looks like it was meant to hold tests: a `__test__/`
16
+ * directory exists at the package root (regardless of what's inside it), or
17
+ * `src/` contains at least one file matching the discoverable test-file
18
+ * naming convention (`TEST_FILE_GLOB_SUFFIX`).
19
+ *
20
+ * Reuses `SRC_DIR` / `TEST_DIR` / `TEST_FILE_GLOB_SUFFIX` from
21
+ * `@vitest-agent/sdk`'s `utils/test-location.ts` — the single source of
22
+ * truth for the test-layout rule — and the existing `findTestFiles` walker,
23
+ * rather than re-deriving the shape independently.
24
+ *
25
+ * A `__test__/` directory counts on existence alone, not on matching file
26
+ * content: the failure mode this predicate exists to catch (issue #229) IS a
27
+ * `__test__/` directory whose files don't match the naming convention (wrong
28
+ * suffix, wrong extension, typo) — `DefaultDiscoverStrategy.buildProject`
29
+ * already declined those packages by finding zero matching files, so
30
+ * requiring a match here would make the predicate blind to exactly the case
31
+ * it needs to catch.
32
+ * @internal
33
+ */
34
+ async function isTestShapedPackage(pkgPath) {
35
+ if (await isDirectory(join(pkgPath, TEST_DIR))) return true;
36
+ return (await findTestFiles(pkgPath, [`${SRC_DIR}/**/${TEST_FILE_GLOB_SUFFIX}`])).length > 0;
37
+ }
38
+
39
+ //#endregion
40
+ export { isTestShapedPackage };
@@ -0,0 +1,268 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { closeSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { homedir } from "node:os";
5
+
6
+ //#region src/utils/run-script-lock.ts
7
+ /**
8
+ * File-based advisory lock backing `AgentPlugin.runScript` (issue #191,
9
+ * sub-item B). Two concurrent `vitest` invocations in one checkout used
10
+ * to both run the `globalSetup` build and race — this lock serializes
11
+ * them: the first process to acquire the lock runs the command and
12
+ * marks a "done" timestamp on success; a concurrent process polls,
13
+ * and once the marker is fresh enough (`builtRecentlyMs`) it trusts
14
+ * the winner's build and skips its own.
15
+ *
16
+ * A stale-lock takeover exists because a process that crashed or was
17
+ * killed mid-build leaves the lock file behind forever otherwise —
18
+ * every future `vitest` invocation would hang. Takeover is a two-tier
19
+ * rule, never age alone:
20
+ *
21
+ * 1. The lock file records the owner's pid. Once the lock is older
22
+ * than `staleMs`, that pid is probed with `process.kill(pid, 0)`.
23
+ * A live owner (probe succeeds, or fails `EPERM` — the process
24
+ * exists but belongs to another user) is NEVER taken over, no
25
+ * matter how old the lock is: a slow-but-live build must not have
26
+ * its lock stolen and the command double-run.
27
+ * 2. Only a dead owner (`ESRCH`), or a lock file whose owner record is
28
+ * missing/corrupt (in which case age is all we have to go on),
29
+ * is taken over.
30
+ *
31
+ * Release is ownership-gated for the same reason: `acquireRunScriptLock`
32
+ * stamps a random per-acquisition nonce into the lock file, and
33
+ * `releaseRunScriptLock` deletes the file only when the nonce on disk is
34
+ * still its own. Without that, an original owner that lost its lock to a
35
+ * takeover would delete the *takeover* owner's lock in its `finally`,
36
+ * letting a third process in while the second is still building.
37
+ *
38
+ * A generous `waitTimeoutMs` safety valve exists so a genuinely slow
39
+ * (but still live) build doesn't hang the waiter forever either: past
40
+ * that timeout the waiter gives up, `acquireRunScriptLock` returns
41
+ * `{ acquired: false, recentlyBuilt: false }`, and the caller
42
+ * (`AgentPlugin.runScript`) goes ahead and runs its own command
43
+ * unserialized. That reproduces the original race for that one pair of
44
+ * processes rather than blocking indefinitely — a deliberate,
45
+ * documented escape valve, not an oversight.
46
+ *
47
+ * @packageDocumentation
48
+ */
49
+ /**
50
+ * A build whose owning process is *gone* and whose lock is older than
51
+ * this is presumed abandoned (crashed process, killed job) and taken
52
+ * over. Age alone never triggers a takeover — the recorded pid is
53
+ * probed first, and a live owner keeps its lock past this age (see the
54
+ * module doc comment). One minute comfortably covers the `globalSetup`
55
+ * build steps this lock is built for; the pid probe is what protects
56
+ * the longer ones.
57
+ */
58
+ const DEFAULT_LOCK_STALE_MS = 6e4;
59
+ /** How long a waiter sleeps between polls of an active lock. */
60
+ const DEFAULT_LOCK_POLL_MS = 200;
61
+ /** How long a waiter blocks before giving up and running its own build independently. */
62
+ const DEFAULT_LOCK_WAIT_TIMEOUT_MS = 6e5;
63
+ /** A done-marker fresher than this is trusted without re-running the command. */
64
+ const DEFAULT_BUILT_RECENTLY_MS = 3e4;
65
+ /**
66
+ * Strictly parses a millisecond timing supplied through one of the
67
+ * `VITEST_AGENT_RUNSCRIPT_*` env overrides, falling back to `fallback`
68
+ * for anything that is not a whole positive integer at or above
69
+ * `minimum`.
70
+ *
71
+ * `Number.parseInt` is deliberately not used on its own: it happily
72
+ * accepts `"200ms"` (→ 200) and `"-1"` (→ a negative stale window,
73
+ * which makes every lock instantly stale) and `"0"` for a poll interval
74
+ * (a tight spin loop). An unusable override falls back to the default
75
+ * rather than silently degrading the lock.
76
+ *
77
+ * @public
78
+ */
79
+ function parseLockTimingOverride(raw, fallback, minimum = 1) {
80
+ if (raw === void 0) return fallback;
81
+ const trimmed = raw.trim();
82
+ if (!/^\d+$/.test(trimmed)) return fallback;
83
+ const parsed = Number.parseInt(trimmed, 10);
84
+ if (!Number.isSafeInteger(parsed) || parsed < minimum) return fallback;
85
+ return parsed;
86
+ }
87
+ /**
88
+ * Resolves the directory `runScript` locks and done-markers live in:
89
+ * `XDG_DATA_HOME` when set (matching `@vitest-agent/sdk`'s `data.db`
90
+ * resolution convention), else the same `~/.local/share` fallback that
91
+ * convention documents.
92
+ *
93
+ * @public
94
+ */
95
+ function resolveRunScriptLockDir(env = process.env, home = homedir()) {
96
+ const xdg = env.XDG_DATA_HOME;
97
+ const base = xdg !== void 0 && xdg.length > 0 ? xdg : join(home, ".local", "share");
98
+ return join(base, "vitest-agent", "runscript-locks");
99
+ }
100
+ /**
101
+ * Deterministic, filesystem-safe key for one (workspace, command) pair
102
+ * so different `globalSetup` commands in the same checkout don't share
103
+ * a lock, while repeat invocations of the *same* command in the *same*
104
+ * workspace do.
105
+ */
106
+ function computeLockKey(cwd, command) {
107
+ return createHash("sha256").update(`${cwd}\n${command}`).digest("hex").slice(0, 16);
108
+ }
109
+ /** Reads the owner record from a lock file. Returns `null` when the file is missing, unreadable, or not a well-formed owner record (e.g. observed mid-write by its creator). */
110
+ function readLockOwner(lockPath) {
111
+ let raw;
112
+ try {
113
+ raw = readFileSync(lockPath, "utf8");
114
+ } catch {
115
+ return null;
116
+ }
117
+ try {
118
+ const parsed = JSON.parse(raw);
119
+ if (typeof parsed?.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid <= 0) return null;
120
+ if (typeof parsed.nonce !== "string" || parsed.nonce.length === 0) return null;
121
+ return {
122
+ pid: parsed.pid,
123
+ nonce: parsed.nonce,
124
+ startedAt: String(parsed.startedAt ?? "")
125
+ };
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+ /**
131
+ * Signal-0 liveness probe. `EPERM` means the process exists but is
132
+ * owned by another user — still alive, and emphatically not ours to
133
+ * take over. Anything else (`ESRCH`) means it is gone.
134
+ */
135
+ function isProcessAlive(pid) {
136
+ try {
137
+ process.kill(pid, 0);
138
+ return true;
139
+ } catch (err) {
140
+ return err.code === "EPERM";
141
+ }
142
+ }
143
+ function defaultSleep(ms) {
144
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
145
+ }
146
+ function isRecentlyBuilt(doneMarkerPath, builtRecentlyMs) {
147
+ try {
148
+ return Date.now() - statSync(doneMarkerPath).mtimeMs < builtRecentlyMs;
149
+ } catch {
150
+ return false;
151
+ }
152
+ }
153
+ function lockAgeMs(lockPath) {
154
+ try {
155
+ return Date.now() - statSync(lockPath).mtimeMs;
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+ /**
161
+ * Acquires (or observes) the advisory lock for one (cwd, command) pair.
162
+ * Blocks the calling thread (via `sleep`) while another process holds
163
+ * a non-stale lock, up to `waitTimeoutMs`.
164
+ *
165
+ * @public
166
+ */
167
+ function acquireRunScriptLock(options) {
168
+ const { cwd, command, lockDir = resolveRunScriptLockDir(), staleMs = DEFAULT_LOCK_STALE_MS, waitTimeoutMs = DEFAULT_LOCK_WAIT_TIMEOUT_MS, pollMs = 200, builtRecentlyMs = DEFAULT_BUILT_RECENTLY_MS, sleep = defaultSleep } = options;
169
+ mkdirSync(lockDir, { recursive: true });
170
+ const key = computeLockKey(cwd, command);
171
+ const lockPath = join(lockDir, `${key}.lock`);
172
+ const doneMarkerPath = join(lockDir, `${key}.done`);
173
+ const deadline = Date.now() + waitTimeoutMs;
174
+ for (;;) {
175
+ if (isRecentlyBuilt(doneMarkerPath, builtRecentlyMs)) return {
176
+ lockPath,
177
+ doneMarkerPath,
178
+ acquired: false,
179
+ recentlyBuilt: true,
180
+ ownerNonce: null
181
+ };
182
+ let fd = null;
183
+ try {
184
+ fd = openSync(lockPath, "wx");
185
+ } catch (err) {
186
+ if (err.code !== "EEXIST") throw err;
187
+ }
188
+ if (fd !== null) {
189
+ const ownerNonce = randomBytes(12).toString("hex");
190
+ const record = {
191
+ pid: process.pid,
192
+ nonce: ownerNonce,
193
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
194
+ };
195
+ try {
196
+ writeSync(fd, JSON.stringify(record));
197
+ } catch (err) {
198
+ try {
199
+ rmSync(lockPath, { force: true });
200
+ } catch {}
201
+ throw err;
202
+ } finally {
203
+ try {
204
+ closeSync(fd);
205
+ } catch {}
206
+ }
207
+ return {
208
+ lockPath,
209
+ doneMarkerPath,
210
+ acquired: true,
211
+ recentlyBuilt: false,
212
+ ownerNonce
213
+ };
214
+ }
215
+ const age = lockAgeMs(lockPath);
216
+ if (age !== null && age > staleMs) {
217
+ const owner = readLockOwner(lockPath);
218
+ if (owner === null || !isProcessAlive(owner.pid)) {
219
+ try {
220
+ rmSync(lockPath, { force: true });
221
+ } catch {}
222
+ continue;
223
+ }
224
+ }
225
+ if (Date.now() > deadline) return {
226
+ lockPath,
227
+ doneMarkerPath,
228
+ acquired: false,
229
+ recentlyBuilt: false,
230
+ ownerNonce: null
231
+ };
232
+ sleep(pollMs);
233
+ }
234
+ }
235
+ /**
236
+ * Releases a lock this process acquired. No-op when `lock.acquired` is
237
+ * `false` (this process never owned it), and — critically — also a
238
+ * no-op when the lock file on disk no longer carries this
239
+ * acquisition's nonce: that means the lock was taken over and now
240
+ * belongs to somebody else's in-flight build. Deleting it there would
241
+ * admit a third process while the second is still running the command.
242
+ *
243
+ * @public
244
+ */
245
+ function releaseRunScriptLock(lock) {
246
+ if (!lock.acquired || lock.ownerNonce === null) return;
247
+ const owner = readLockOwner(lock.lockPath);
248
+ if (owner === null || owner.nonce !== lock.ownerNonce) return;
249
+ try {
250
+ rmSync(lock.lockPath, { force: true });
251
+ } catch {}
252
+ }
253
+ /**
254
+ * Marks a successful build, so a waiter's next `isRecentlyBuilt` check
255
+ * can skip re-running the command. Best-effort: a write failure just
256
+ * means the next process won't short-circuit, not a correctness
257
+ * problem.
258
+ *
259
+ * @public
260
+ */
261
+ function markRunScriptDone(lock) {
262
+ try {
263
+ writeFileSync(lock.doneMarkerPath, String(Date.now()));
264
+ } catch {}
265
+ }
266
+
267
+ //#endregion
268
+ export { DEFAULT_BUILT_RECENTLY_MS, DEFAULT_LOCK_STALE_MS, DEFAULT_LOCK_WAIT_TIMEOUT_MS, acquireRunScriptLock, markRunScriptDone, parseLockTimingOverride, releaseRunScriptLock, resolveRunScriptLockDir };