agent-dealer 1.2.8 → 1.2.9

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.
Files changed (51) hide show
  1. package/bundle/server/dist/adapters/github.js +80 -4
  2. package/bundle/server/dist/adapters/github.test.js +83 -1
  3. package/bundle/server/dist/adapters/worktree-deps.js +96 -0
  4. package/bundle/server/dist/adapters/worktree-deps.test.js +144 -0
  5. package/bundle/server/dist/coordinator/auto-merge.js +205 -1
  6. package/bundle/server/dist/coordinator/checks-wait-config.js +19 -0
  7. package/bundle/server/dist/coordinator/commands.js +194 -18
  8. package/bundle/server/dist/coordinator/commands.test.js +47 -0
  9. package/bundle/server/dist/coordinator/developer-effect.js +145 -3
  10. package/bundle/server/dist/coordinator/developer-effect.test.js +288 -3
  11. package/bundle/server/dist/coordinator/human-resolution.js +19 -0
  12. package/bundle/server/dist/coordinator/human-resolution.test.js +33 -0
  13. package/bundle/server/dist/coordinator/operator-criteria.js +168 -0
  14. package/bundle/server/dist/coordinator/operator-criteria.test.js +78 -0
  15. package/bundle/server/dist/coordinator/operator-verification.integration.test.js +347 -0
  16. package/bundle/server/dist/coordinator/playbook-feedback.js +3 -0
  17. package/bundle/server/dist/coordinator/projection.js +10 -4
  18. package/bundle/server/dist/coordinator/projection.test.js +13 -0
  19. package/bundle/server/dist/coordinator/prompts.js +134 -1
  20. package/bundle/server/dist/coordinator/prompts.test.js +148 -0
  21. package/bundle/server/dist/coordinator/recovery.js +57 -3
  22. package/bundle/server/dist/coordinator/recovery.test.js +67 -8
  23. package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +1 -0
  24. package/bundle/server/dist/coordinator/reviewer-effect.js +6 -0
  25. package/bundle/server/dist/coordinator/routing.js +45 -6
  26. package/bundle/server/dist/coordinator/routing.test.js +62 -15
  27. package/bundle/server/dist/coordinator/usage-cap-defer.js +61 -0
  28. package/bundle/server/dist/db/ci-attempts-migration.test.js +70 -0
  29. package/bundle/server/dist/db/index.js +13 -0
  30. package/bundle/server/dist/db/schema.sql +2 -0
  31. package/bundle/server/dist/repository/issues.js +39 -3
  32. package/bundle/server/dist/repository/work-items.js +7 -0
  33. package/bundle/server/dist/runners/muse-code-args.js +25 -0
  34. package/bundle/server/dist/runners/muse-code-args.test.js +30 -0
  35. package/bundle/server/package.json +2 -2
  36. package/bundle/server/static-ui/assets/index-CU6fNntg.js +64 -0
  37. package/bundle/server/static-ui/index.html +1 -1
  38. package/bundle/shared/dist/human-actions.d.ts +4 -4
  39. package/bundle/shared/dist/human-actions.js +8 -0
  40. package/bundle/shared/dist/issues.d.ts +30 -0
  41. package/bundle/shared/dist/issues.js +14 -0
  42. package/bundle/shared/dist/issues.test.js +2 -0
  43. package/bundle/shared/dist/workflow.d.ts +4 -4
  44. package/bundle/shared/dist/workflow.js +6 -0
  45. package/bundle/shared/dist/workflow.test.js +1 -0
  46. package/bundle/shared/package.json +1 -1
  47. package/dist/action.js +4 -1
  48. package/dist/action.test.js +42 -0
  49. package/dist/index.js +1 -1
  50. package/package.json +1 -1
  51. package/bundle/server/static-ui/assets/index-B6SVCzMR.js +0 -64
@@ -386,6 +386,14 @@ export function createGithubAdapter(exec = defaultExec) {
386
386
  return null;
387
387
  }
388
388
  },
389
+ async hasPullRequestWorkflow({ cwd, baseRef }) {
390
+ try {
391
+ return await baseRefHasPullRequestWorkflow(cwd, baseRef);
392
+ }
393
+ catch {
394
+ return null;
395
+ }
396
+ },
389
397
  async publishReview({ cwd, number, event, bodyFilePath }) {
390
398
  const result = await runReview(exec, cwd, number, event, bodyFilePath);
391
399
  if (result.ok || event === "COMMENT" || !OWN_PR_REVIEW_PATTERN.test(result.reason)) {
@@ -491,6 +499,64 @@ export async function fetchChecksFailureEvidence(exec, opts) {
491
499
  details,
492
500
  };
493
501
  }
502
+ /**
503
+ * NOT-311: does `origin/<baseRef>` contain a workflow triggered by `pull_request`?
504
+ * False when the tree has no workflow files at all; null when the tree itself cannot
505
+ * be read (unknown base ref, unparsable git output) so the caller waits fail-closed.
506
+ */
507
+ export async function baseRefHasPullRequestWorkflow(cwd, baseRef) {
508
+ let listing;
509
+ try {
510
+ // `-r`: without it ls-tree prints the workflows *directory* entry itself rather
511
+ // than the files under it, so the scan would never see a workflow (NOT-311).
512
+ listing = (await run("git", ["ls-tree", "-r", `origin/${baseRef}`, "--name-only", ".github/workflows"], { cwd })).stdout;
513
+ }
514
+ catch {
515
+ return null;
516
+ }
517
+ const files = listing
518
+ .split("\n")
519
+ .map((s) => s.trim())
520
+ .filter((f) => f && /\.(ya?ml)$/i.test(f));
521
+ if (files.length === 0)
522
+ return false;
523
+ for (const file of files) {
524
+ let content;
525
+ try {
526
+ content = (await run("git", ["show", `origin/${baseRef}:${file}`], { cwd })).stdout;
527
+ }
528
+ catch {
529
+ return null;
530
+ }
531
+ if (workflowHasPullRequestTrigger(content))
532
+ return true;
533
+ }
534
+ return false;
535
+ }
536
+ /**
537
+ * Heuristic `on:` trigger scan: matches inline (`on: pull_request`,
538
+ * `on: [push, pull_request]`) and block forms. Stops at the next top-level key so a
539
+ * later `pull_request` mention outside `on:` (a job name, a comment) cannot qualify.
540
+ * Leans toward waiting — a false positive only costs CI-wait backoff, while a false
541
+ * negative would send an unverified PR to review.
542
+ */
543
+ export function workflowHasPullRequestTrigger(content) {
544
+ const lines = content.split("\n");
545
+ const onIdx = lines.findIndex((l) => /^\s*on\s*:/.test(l));
546
+ if (onIdx < 0)
547
+ return false;
548
+ const onLine = lines[onIdx];
549
+ if (/\bpull_request\b/.test(onLine.slice(onLine.indexOf("on") + 2)))
550
+ return true;
551
+ for (let i = onIdx + 1; i < lines.length; i++) {
552
+ const line = lines[i];
553
+ if (/^\S/.test(line))
554
+ break;
555
+ if (/\bpull_request\b/.test(line.replace(/#.*$/, "")))
556
+ return true;
557
+ }
558
+ return false;
559
+ }
494
560
  export const realGithubAdapter = createGithubAdapter();
495
561
  const REVIEW_FLAG = {
496
562
  APPROVE: "--approve",
@@ -531,24 +597,34 @@ export async function pollPrChecks(
531
597
  // NOT-310: narrowed to what the poll actually calls — the merge-conflict sync
532
598
  // passes only a checks reader, and full adapters still satisfy this shape.
533
599
  adapter, opts) {
600
+ return (await pollPrChecksDetailed(adapter, opts)).result;
601
+ }
602
+ /**
603
+ * NOT-311: same bounded poll as {@link pollPrChecks}, but reporting what was last
604
+ * seen and whether an abort cut the wait short. `pollPrChecks` keeps the collapsed
605
+ * contract for callers that only branch on the result.
606
+ */
607
+ export async function pollPrChecksDetailed(adapter, opts) {
534
608
  const deadline = Date.now() + opts.timeoutMs;
535
609
  let noneStreak = 0;
610
+ let lastSnapshot = null;
536
611
  for (;;) {
537
612
  if (opts.signal?.aborted)
538
- return "timeout";
613
+ return { result: "timeout", lastSnapshot, aborted: true };
539
614
  const snapshot = await adapter.checksSnapshot({ cwd: opts.cwd, number: opts.number, branch: opts.branch });
615
+ lastSnapshot = snapshot;
540
616
  if (snapshot === "failure" || snapshot === "success")
541
- return snapshot;
617
+ return { result: snapshot, lastSnapshot, aborted: false };
542
618
  if (snapshot === "none") {
543
619
  noneStreak++;
544
620
  if (noneStreak >= NONE_STREAK_REQUIRED)
545
- return "none";
621
+ return { result: "none", lastSnapshot, aborted: false };
546
622
  }
547
623
  else {
548
624
  noneStreak = 0;
549
625
  }
550
626
  if (Date.now() >= deadline)
551
- return "timeout";
627
+ return { result: "timeout", lastSnapshot, aborted: false };
552
628
  await new Promise((resolve) => setTimeout(resolve, Math.min(opts.intervalMs, deadline - Date.now())));
553
629
  }
554
630
  }
@@ -1,7 +1,7 @@
1
1
  // packages/server/src/adapters/github.test.ts
2
2
  import { test } from "node:test";
3
3
  import assert from "node:assert/strict";
4
- import { parsePrView, summarizeChecks, pollPrChecks, createGithubAdapter, fetchChecksFailureEvidence, sanitizeCiText, sanitizeUrl, extractActionsRunId, buildFailureExcerpt, formatChecksFailureDetails, CHECKS_EVIDENCE_MAX_EXCERPT_CHARS, CHECKS_EVIDENCE_MAX_EXCERPT_LINES, CHECKS_EVIDENCE_MAX_RAW_LOG_CHARS_PER_RUN, CHECKS_EVIDENCE_GH_LOG_MAX_BUFFER, CHECKS_FAILURE_GENERIC_REASON, PR_VIEW_FIELDS, } from "./github.js";
4
+ import { parsePrView, summarizeChecks, pollPrChecks, pollPrChecksDetailed, baseRefHasPullRequestWorkflow, workflowHasPullRequestTrigger, createGithubAdapter, fetchChecksFailureEvidence, sanitizeCiText, sanitizeUrl, extractActionsRunId, buildFailureExcerpt, formatChecksFailureDetails, CHECKS_EVIDENCE_MAX_EXCERPT_CHARS, CHECKS_EVIDENCE_MAX_EXCERPT_LINES, CHECKS_EVIDENCE_MAX_RAW_LOG_CHARS_PER_RUN, CHECKS_EVIDENCE_GH_LOG_MAX_BUFFER, CHECKS_FAILURE_GENERIC_REASON, PR_VIEW_FIELDS, } from "./github.js";
5
5
  test("parsePrView extracts the ground-truth handoff fields, including draft status", () => {
6
6
  const view = parsePrView(JSON.stringify({
7
7
  number: 7,
@@ -205,6 +205,88 @@ test("pollPrChecks bails out as timeout immediately when the lease signal is alr
205
205
  });
206
206
  assert.equal(result, "timeout");
207
207
  });
208
+ test("NOT-311: pollPrChecksDetailed reports the last snapshot so a timeout can tell pending from abort", async () => {
209
+ const pending = await pollPrChecksDetailed(fakeAdapter(["pending", "pending", "pending", "pending"]), {
210
+ cwd: "/x",
211
+ timeoutMs: 20,
212
+ intervalMs: 10,
213
+ });
214
+ assert.deepEqual(pending, { result: "timeout", lastSnapshot: "pending", aborted: false });
215
+ const controller = new AbortController();
216
+ controller.abort();
217
+ const aborted = await pollPrChecksDetailed(fakeAdapter(["pending"]), {
218
+ cwd: "/x",
219
+ timeoutMs: 1000,
220
+ intervalMs: 5,
221
+ signal: controller.signal,
222
+ });
223
+ assert.deepEqual(aborted, { result: "timeout", lastSnapshot: null, aborted: true });
224
+ const none = await pollPrChecksDetailed(fakeAdapter(["none", "none"]), { cwd: "/x", timeoutMs: 1000, intervalMs: 5 });
225
+ assert.deepEqual(none, { result: "none", lastSnapshot: "none", aborted: false });
226
+ const incompleteNoneStreak = await pollPrChecksDetailed(fakeAdapter(["none"]), {
227
+ cwd: "/x",
228
+ timeoutMs: 0,
229
+ intervalMs: 10,
230
+ });
231
+ assert.deepEqual(incompleteNoneStreak, { result: "timeout", lastSnapshot: "none", aborted: false });
232
+ });
233
+ test("NOT-311: workflowHasPullRequestTrigger matches inline, list, and block on: forms", () => {
234
+ assert.equal(workflowHasPullRequestTrigger("on: pull_request\njobs:\n x:\n runs-on: ubuntu-latest\n"), true);
235
+ assert.equal(workflowHasPullRequestTrigger("on: [push, pull_request]\n"), true);
236
+ assert.equal(workflowHasPullRequestTrigger("on:\n push:\n branches: [main]\n pull_request:\n branches: [main]\n"), true);
237
+ assert.equal(workflowHasPullRequestTrigger("on:\n push:\n branches: [main]\n"), false);
238
+ assert.equal(workflowHasPullRequestTrigger("on: push\n"), false);
239
+ assert.equal(workflowHasPullRequestTrigger("on:\n schedule:\n - cron: '0 0 * * *'\n"), false);
240
+ assert.equal(workflowHasPullRequestTrigger("jobs:\n x:\n runs-on: ubuntu-latest\n"), false);
241
+ // A pull_request mention outside `on:` (job name) must not qualify.
242
+ assert.equal(workflowHasPullRequestTrigger("on: push\njobs:\n pull_request_preview:\n runs-on: ubuntu-latest\n"), false);
243
+ });
244
+ test("NOT-311: baseRefHasPullRequestWorkflow reads the base ref tree, false without workflows, null on unknown ref", async () => {
245
+ const { execFileSync } = await import("node:child_process");
246
+ const fs = await import("node:fs");
247
+ const os = await import("node:os");
248
+ const path = await import("node:path");
249
+ const setup = (files) => {
250
+ const repo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-gh-wf-repo-"));
251
+ const git = (...args) => execFileSync("git", args, { cwd: repo, encoding: "utf8" });
252
+ git("init", "-q", "-b", "main");
253
+ git("config", "user.email", "test@example.com");
254
+ git("config", "user.name", "Test");
255
+ for (const [rel, content] of Object.entries(files)) {
256
+ fs.mkdirSync(path.join(repo, path.dirname(rel)), { recursive: true });
257
+ fs.writeFileSync(path.join(repo, rel), content);
258
+ }
259
+ git("add", ".");
260
+ git("commit", "-q", "-m", "init");
261
+ const remote = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-gh-wf-remote-"));
262
+ execFileSync("git", ["init", "-q", "--bare", "-b", "main", remote]);
263
+ git("remote", "add", "origin", remote);
264
+ git("push", "-q", "origin", "main");
265
+ git("fetch", "-q", "origin");
266
+ return { repo, remote };
267
+ };
268
+ const withCi = setup({
269
+ "README.md": "hi\n",
270
+ ".github/workflows/ci.yml": "name: ci\non:\n pull_request:\n branches: [main]\njobs:\n x:\n runs-on: ubuntu-latest\n",
271
+ });
272
+ const pushOnly = setup({
273
+ "README.md": "hi\n",
274
+ ".github/workflows/nightly.yml": "name: nightly\non:\n schedule:\n - cron: '0 0 * * *'\njobs:\n x:\n runs-on: ubuntu-latest\n",
275
+ });
276
+ const bare = setup({ "README.md": "hi\n" });
277
+ try {
278
+ assert.equal(await baseRefHasPullRequestWorkflow(withCi.repo, "main"), true);
279
+ assert.equal(await baseRefHasPullRequestWorkflow(pushOnly.repo, "main"), false);
280
+ assert.equal(await baseRefHasPullRequestWorkflow(bare.repo, "main"), false);
281
+ assert.equal(await baseRefHasPullRequestWorkflow(bare.repo, "no-such-branch"), null);
282
+ }
283
+ finally {
284
+ for (const { repo, remote } of [withCi, pushOnly, bare]) {
285
+ fs.rmSync(repo, { recursive: true, force: true });
286
+ fs.rmSync(remote, { recursive: true, force: true });
287
+ }
288
+ }
289
+ });
208
290
  // --- NOT-252: terminal checks_failed enrichment ---
209
291
  const HEAD_SHA = "abc123def456abc123def456abc123def456abcd";
210
292
  function prViewWithRollup(rollup, headSha = HEAD_SHA) {
@@ -0,0 +1,96 @@
1
+ // packages/server/src/adapters/worktree-deps.ts
2
+ //
3
+ // NOT-315: provision dependencies in a fresh developer worktree before the
4
+ // session spawns. The Muse builder sandbox has no network (restricted mode:
5
+ // DNS and loopback listen() both fail), so an in-session `npm install` can
6
+ // never work — the coordinator runs it here instead, outside the sandbox,
7
+ // with network.
8
+ //
9
+ // Only npm is in scope: a lockfile for another package manager (or no
10
+ // lockfile at all) skips the step with a logged reason, never a failure.
11
+ // A failed or timed-out install throws — the caller turns that into a
12
+ // visible `adapter_failure` worktree-setup failure (retryable on the existing
13
+ // infra-retry path), never a silent skip.
14
+ //
15
+ // The command runner is an injectable seam so tests prove the exact argv/cwd
16
+ // without spending network or touching a registry.
17
+ import { execFile } from "node:child_process";
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { promisify } from "node:util";
21
+ const run = promisify(execFile);
22
+ /** Default bound for `npm ci` in a fresh worktree (NOT-315). */
23
+ export const DEFAULT_WORKTREE_DEPS_TIMEOUT_MS = 5 * 60_000;
24
+ /** Bounded timeout for the install step; env override for tests/operators. */
25
+ export function worktreeDepsTimeoutMs() {
26
+ const raw = Number(process.env.WORKTREE_DEPS_TIMEOUT_MS);
27
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_WORKTREE_DEPS_TIMEOUT_MS;
28
+ }
29
+ /** Exact install invocation — asserted by tests, never drifted silently. */
30
+ export const NPM_CI_ARGS = ["ci", "--prefer-offline", "--no-audit", "--no-fund"];
31
+ /** Lockfiles that mean "another package manager owns this worktree" — npm is out of scope. */
32
+ const NON_NPM_LOCKFILES = ["pnpm-lock.yaml", "yarn.lock", "bun.lockb", "bun.lock"];
33
+ /** Last bytes of install output kept in the failure reason (a full log would drown routing). */
34
+ const STDERR_TAIL_CHARS = 2000;
35
+ const defaultRunner = async (cmd, args, opts) => run(cmd, [...args], { cwd: opts.cwd, timeout: opts.timeoutMs });
36
+ /** Keep the failure reason actionable: the tail of what npm actually printed. */
37
+ export function stderrTail(err) {
38
+ const e = err;
39
+ const raw = (typeof e?.stderr === "string" && e.stderr.trim() ? e.stderr : null) ??
40
+ (typeof e?.stdout === "string" && e.stdout.trim() ? e.stdout : null) ??
41
+ (typeof e?.message === "string" && e.message ? e.message : String(err));
42
+ const trimmed = raw.trim();
43
+ return trimmed.length > STDERR_TAIL_CHARS ? trimmed.slice(-STDERR_TAIL_CHARS) : trimmed;
44
+ }
45
+ /**
46
+ * Run `npm ci` in `worktreePath` when the checkout declares npm dependencies
47
+ * that are not installed yet. Returns whether the install ran (with its
48
+ * duration) or why it was skipped. Throws
49
+ * `dependency install failed: <stderr tail>` when the install itself fails or
50
+ * times out — the caller surfaces that as a worktree-setup `adapter_failure`.
51
+ */
52
+ export async function ensureWorktreeDeps(worktreePath, opts = {}) {
53
+ const startedAt = Date.now();
54
+ const skipped = (reason) => ({
55
+ ran: false,
56
+ reason,
57
+ durationMs: Date.now() - startedAt,
58
+ });
59
+ if (fs.existsSync(path.join(worktreePath, "node_modules"))) {
60
+ return skipped("node_modules present");
61
+ }
62
+ if (!fs.existsSync(path.join(worktreePath, "package.json"))) {
63
+ return skipped("no package.json");
64
+ }
65
+ for (const lock of NON_NPM_LOCKFILES) {
66
+ if (fs.existsSync(path.join(worktreePath, lock))) {
67
+ return skipped(`non-npm lockfile ${lock} present; only npm is in scope`);
68
+ }
69
+ }
70
+ if (!fs.existsSync(path.join(worktreePath, "package-lock.json"))) {
71
+ return skipped("no lockfile");
72
+ }
73
+ const timeoutMs = opts.timeoutMs ?? worktreeDepsTimeoutMs();
74
+ const runner = opts.runner ?? defaultRunner;
75
+ try {
76
+ await runner("npm", NPM_CI_ARGS, { cwd: worktreePath, timeoutMs });
77
+ }
78
+ catch (err) {
79
+ // A failed `npm ci` (or a timeout kill mid-reify) can leave a
80
+ // partial/empty node_modules behind. The setup failure retries on the
81
+ // infra path, which reuses the leftover worktree — and a leftover
82
+ // node_modules would make the retry skip the install and hand the builder
83
+ // broken deps. Remove it so the retry reinstalls from scratch; the removal
84
+ // itself is best-effort and must never mask the install error.
85
+ try {
86
+ fs.rmSync(path.join(worktreePath, "node_modules"), { recursive: true, force: true });
87
+ }
88
+ catch {
89
+ // keep the original install error below
90
+ }
91
+ const killed = err?.killed === true;
92
+ const prefix = killed ? `timed out after ${timeoutMs}ms: ` : "";
93
+ throw new Error(`dependency install failed: ${prefix}${stderrTail(err)}`);
94
+ }
95
+ return { ran: true, durationMs: Date.now() - startedAt };
96
+ }
@@ -0,0 +1,144 @@
1
+ // packages/server/src/adapters/worktree-deps.test.ts
2
+ //
3
+ // NOT-315: the pre-spawn dependency step runs `npm ci` exactly once when the
4
+ // worktree declares npm dependencies, skips otherwise, and surfaces install
5
+ // failures with the stderr tail — all against a fake runner, never a registry.
6
+ import { test, after } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { ensureWorktreeDeps, NPM_CI_ARGS } from "./worktree-deps.js";
12
+ let dirs = [];
13
+ function makeWorktree(files) {
14
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt-deps-"));
15
+ dirs.push(dir);
16
+ for (const [name, content] of Object.entries(files)) {
17
+ fs.writeFileSync(path.join(dir, name), content);
18
+ }
19
+ return dir;
20
+ }
21
+ after(() => {
22
+ for (const dir of dirs)
23
+ fs.rmSync(dir, { recursive: true, force: true });
24
+ dirs = [];
25
+ });
26
+ const PKG = JSON.stringify({ name: "wt", version: "1.0.0" });
27
+ const LOCK = JSON.stringify({ name: "wt", lockfileVersion: 3, packages: {} });
28
+ function recordingRunner(calls) {
29
+ return async (cmd, args, opts) => {
30
+ calls.push({ cmd, args: [...args], cwd: opts.cwd, timeoutMs: opts.timeoutMs });
31
+ return { stdout: "", stderr: "" };
32
+ };
33
+ }
34
+ test("lockfile present and node_modules absent -> install runs once with the expected args and cwd", async () => {
35
+ const dir = makeWorktree({ "package.json": PKG, "package-lock.json": LOCK });
36
+ const calls = [];
37
+ const result = await ensureWorktreeDeps(dir, { runner: recordingRunner(calls), timeoutMs: 1234 });
38
+ assert.equal(calls.length, 1);
39
+ assert.equal(calls[0].cmd, "npm");
40
+ assert.deepEqual(calls[0].args, [...NPM_CI_ARGS]);
41
+ assert.deepEqual(calls[0].args, ["ci", "--prefer-offline", "--no-audit", "--no-fund"]);
42
+ assert.equal(calls[0].cwd, dir);
43
+ assert.equal(calls[0].timeoutMs, 1234);
44
+ assert.equal(result.ran, true);
45
+ if (result.ran)
46
+ assert.ok(typeof result.durationMs === "number" && result.durationMs >= 0);
47
+ });
48
+ test("node_modules present -> skipped without running anything", async () => {
49
+ const dir = makeWorktree({ "package.json": PKG, "package-lock.json": LOCK });
50
+ fs.mkdirSync(path.join(dir, "node_modules"));
51
+ const calls = [];
52
+ const result = await ensureWorktreeDeps(dir, { runner: recordingRunner(calls) });
53
+ assert.equal(calls.length, 0);
54
+ assert.equal(result.ran, false);
55
+ if (!result.ran)
56
+ assert.match(result.reason, /node_modules/);
57
+ assert.ok(typeof result.durationMs === "number" && result.durationMs >= 0);
58
+ });
59
+ test("no lockfile -> skipped without running anything", async () => {
60
+ const dir = makeWorktree({ "package.json": PKG });
61
+ const calls = [];
62
+ const result = await ensureWorktreeDeps(dir, { runner: recordingRunner(calls) });
63
+ assert.equal(calls.length, 0);
64
+ assert.equal(result.ran, false);
65
+ if (!result.ran)
66
+ assert.match(result.reason, /no lockfile/);
67
+ });
68
+ test("no package.json -> skipped without running anything", async () => {
69
+ const dir = makeWorktree({ "README.md": "hello\n" });
70
+ const calls = [];
71
+ const result = await ensureWorktreeDeps(dir, { runner: recordingRunner(calls) });
72
+ assert.equal(calls.length, 0);
73
+ assert.equal(result.ran, false);
74
+ });
75
+ test("another package manager's lockfile -> skipped without running anything", async () => {
76
+ for (const lock of ["pnpm-lock.yaml", "yarn.lock", "bun.lockb"]) {
77
+ const dir = makeWorktree({ "package.json": PKG, [lock]: "lock\n" });
78
+ const calls = [];
79
+ const result = await ensureWorktreeDeps(dir, { runner: recordingRunner(calls) });
80
+ assert.equal(calls.length, 0, `${lock} must not trigger npm`);
81
+ assert.equal(result.ran, false);
82
+ if (!result.ran)
83
+ assert.match(result.reason, /non-npm lockfile/);
84
+ }
85
+ });
86
+ test("a failing install throws with the stderr tail", async () => {
87
+ const dir = makeWorktree({ "package.json": PKG, "package-lock.json": LOCK });
88
+ const failing = async () => {
89
+ const err = new Error("npm ci exited");
90
+ err.stderr = "npm error code E500\nnpm error registry exploded on line 42\n";
91
+ throw err;
92
+ };
93
+ await assert.rejects(() => ensureWorktreeDeps(dir, { runner: failing }), (err) => {
94
+ assert.match(err.message, /^dependency install failed: /);
95
+ assert.match(err.message, /registry exploded on line 42/);
96
+ return true;
97
+ });
98
+ });
99
+ test("a timed-out install throws with the same dependency-install prefix", async () => {
100
+ const dir = makeWorktree({ "package.json": PKG, "package-lock.json": LOCK });
101
+ const timingOut = async () => {
102
+ throw new Error("npm ci timed out after 300000ms — network or registry may be down");
103
+ };
104
+ await assert.rejects(() => ensureWorktreeDeps(dir, { runner: timingOut }), (err) => {
105
+ assert.match(err.message, /^dependency install failed: /);
106
+ assert.match(err.message, /timed out/);
107
+ return true;
108
+ });
109
+ });
110
+ test("a failing install removes a leftover node_modules so an infra retry reinstalls", async () => {
111
+ // npm ci that dies mid-reify (or is SIGTERM-killed by the execFile timeout)
112
+ // can leave a partial node_modules behind; the retry reuses the leftover
113
+ // worktree, so the husk must be gone or the retry would skip the install.
114
+ const dir = makeWorktree({ "package.json": PKG, "package-lock.json": LOCK });
115
+ const failing = async (_cmd, _args, opts) => {
116
+ fs.mkdirSync(path.join(opts.cwd, "node_modules", "half-written-pkg"), { recursive: true });
117
+ fs.writeFileSync(path.join(opts.cwd, "node_modules", "half-written-pkg", "index.js"), "partial\n");
118
+ const err = new Error("npm ci exited");
119
+ err.stderr = "npm error code E500\nnpm error registry exploded on line 42\n";
120
+ throw err;
121
+ };
122
+ await assert.rejects(() => ensureWorktreeDeps(dir, { runner: failing }), (err) => {
123
+ assert.match(err.message, /^dependency install failed: /);
124
+ assert.match(err.message, /registry exploded on line 42/);
125
+ return true;
126
+ });
127
+ assert.equal(fs.existsSync(path.join(dir, "node_modules")), false, "partial node_modules must be removed");
128
+ });
129
+ test("a killed (timed-out) install names the timeout in the reason", async () => {
130
+ const dir = makeWorktree({ "package.json": PKG, "package-lock.json": LOCK });
131
+ const killed = async () => {
132
+ // Shape of a real execFile timeout: killed by SIGTERM with little stderr.
133
+ const err = new Error("Command failed: npm ci --prefer-offline --no-audit --no-fund");
134
+ err.killed = true;
135
+ err.signal = "SIGTERM";
136
+ throw err;
137
+ };
138
+ await assert.rejects(() => ensureWorktreeDeps(dir, { runner: killed, timeoutMs: 4321 }), (err) => {
139
+ assert.match(err.message, /^dependency install failed: /);
140
+ assert.match(err.message, /timed out after 4321ms/);
141
+ return true;
142
+ });
143
+ assert.equal(fs.existsSync(path.join(dir, "node_modules")), false);
144
+ });