agent-dealer 1.2.8 → 1.2.10

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 (60) hide show
  1. package/bundle/server/dist/adapters/github.js +93 -4
  2. package/bundle/server/dist/adapters/github.test.js +96 -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 +341 -18
  8. package/bundle/server/dist/coordinator/commands.test.js +47 -0
  9. package/bundle/server/dist/coordinator/developer-effect.js +237 -3
  10. package/bundle/server/dist/coordinator/developer-effect.test.js +460 -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/merge-conflict-sync.js +100 -12
  14. package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +190 -0
  15. package/bundle/server/dist/coordinator/muse-spawn.js +22 -4
  16. package/bundle/server/dist/coordinator/muse-spawn.test.js +166 -10
  17. package/bundle/server/dist/coordinator/operator-criteria.js +168 -0
  18. package/bundle/server/dist/coordinator/operator-criteria.test.js +78 -0
  19. package/bundle/server/dist/coordinator/operator-verification.integration.test.js +347 -0
  20. package/bundle/server/dist/coordinator/playbook-feedback.js +3 -0
  21. package/bundle/server/dist/coordinator/projection.js +10 -4
  22. package/bundle/server/dist/coordinator/projection.test.js +13 -0
  23. package/bundle/server/dist/coordinator/prompts.js +134 -1
  24. package/bundle/server/dist/coordinator/prompts.test.js +148 -0
  25. package/bundle/server/dist/coordinator/recovery.js +57 -3
  26. package/bundle/server/dist/coordinator/recovery.test.js +67 -8
  27. package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +1 -0
  28. package/bundle/server/dist/coordinator/reviewer-effect.js +6 -0
  29. package/bundle/server/dist/coordinator/routing.js +55 -6
  30. package/bundle/server/dist/coordinator/routing.test.js +62 -15
  31. package/bundle/server/dist/coordinator/session-timeouts.js +22 -0
  32. package/bundle/server/dist/coordinator/usage-cap-defer.js +61 -0
  33. package/bundle/server/dist/db/ci-attempts-migration.test.js +70 -0
  34. package/bundle/server/dist/db/index.js +13 -0
  35. package/bundle/server/dist/db/schema.sql +2 -0
  36. package/bundle/server/dist/repository/issues.js +39 -3
  37. package/bundle/server/dist/repository/work-items.js +7 -0
  38. package/bundle/server/dist/runners/muse-code-args.js +25 -0
  39. package/bundle/server/dist/runners/muse-code-args.test.js +30 -0
  40. package/bundle/server/dist/runners/muse-code-jsonl.js +61 -0
  41. package/bundle/server/dist/runners/muse-code.test.js +13 -1
  42. package/bundle/server/dist/runners/spawn-cli.js +93 -20
  43. package/bundle/server/dist/runners/spawn-cli.test.js +38 -0
  44. package/bundle/server/package.json +2 -2
  45. package/bundle/server/static-ui/assets/index-CU6fNntg.js +64 -0
  46. package/bundle/server/static-ui/index.html +1 -1
  47. package/bundle/shared/dist/human-actions.d.ts +4 -4
  48. package/bundle/shared/dist/human-actions.js +8 -0
  49. package/bundle/shared/dist/issues.d.ts +30 -0
  50. package/bundle/shared/dist/issues.js +14 -0
  51. package/bundle/shared/dist/issues.test.js +2 -0
  52. package/bundle/shared/dist/workflow.d.ts +4 -4
  53. package/bundle/shared/dist/workflow.js +6 -0
  54. package/bundle/shared/dist/workflow.test.js +1 -0
  55. package/bundle/shared/package.json +1 -1
  56. package/dist/action.js +4 -1
  57. package/dist/action.test.js +42 -0
  58. package/dist/index.js +1 -1
  59. package/package.json +1 -1
  60. package/bundle/server/static-ui/assets/index-B6SVCzMR.js +0 -64
@@ -25,6 +25,10 @@ export function parsePrView(json) {
25
25
  };
26
26
  }
27
27
  export const PR_VIEW_FIELDS = "number,url,baseRefName,headRefName,headRefOid,isDraft";
28
+ /** Normalizes `gh pr view --json mergeable` output; anything unrecognized is UNKNOWN. */
29
+ export function parsePrMergeableState(raw) {
30
+ return raw === "CONFLICTING" || raw === "MERGEABLE" ? raw : "UNKNOWN";
31
+ }
28
32
  const FAILURE_STATES = new Set([
29
33
  "failure",
30
34
  "error",
@@ -386,6 +390,23 @@ export function createGithubAdapter(exec = defaultExec) {
386
390
  return null;
387
391
  }
388
392
  },
393
+ async hasPullRequestWorkflow({ cwd, baseRef }) {
394
+ try {
395
+ return await baseRefHasPullRequestWorkflow(cwd, baseRef);
396
+ }
397
+ catch {
398
+ return null;
399
+ }
400
+ },
401
+ async prMergeableState({ cwd, number }) {
402
+ try {
403
+ const raw = await ghPrView(exec, cwd, "mergeable", String(number));
404
+ return parsePrMergeableState(raw?.mergeable);
405
+ }
406
+ catch {
407
+ return "UNKNOWN";
408
+ }
409
+ },
389
410
  async publishReview({ cwd, number, event, bodyFilePath }) {
390
411
  const result = await runReview(exec, cwd, number, event, bodyFilePath);
391
412
  if (result.ok || event === "COMMENT" || !OWN_PR_REVIEW_PATTERN.test(result.reason)) {
@@ -491,6 +512,64 @@ export async function fetchChecksFailureEvidence(exec, opts) {
491
512
  details,
492
513
  };
493
514
  }
515
+ /**
516
+ * NOT-311: does `origin/<baseRef>` contain a workflow triggered by `pull_request`?
517
+ * False when the tree has no workflow files at all; null when the tree itself cannot
518
+ * be read (unknown base ref, unparsable git output) so the caller waits fail-closed.
519
+ */
520
+ export async function baseRefHasPullRequestWorkflow(cwd, baseRef) {
521
+ let listing;
522
+ try {
523
+ // `-r`: without it ls-tree prints the workflows *directory* entry itself rather
524
+ // than the files under it, so the scan would never see a workflow (NOT-311).
525
+ listing = (await run("git", ["ls-tree", "-r", `origin/${baseRef}`, "--name-only", ".github/workflows"], { cwd })).stdout;
526
+ }
527
+ catch {
528
+ return null;
529
+ }
530
+ const files = listing
531
+ .split("\n")
532
+ .map((s) => s.trim())
533
+ .filter((f) => f && /\.(ya?ml)$/i.test(f));
534
+ if (files.length === 0)
535
+ return false;
536
+ for (const file of files) {
537
+ let content;
538
+ try {
539
+ content = (await run("git", ["show", `origin/${baseRef}:${file}`], { cwd })).stdout;
540
+ }
541
+ catch {
542
+ return null;
543
+ }
544
+ if (workflowHasPullRequestTrigger(content))
545
+ return true;
546
+ }
547
+ return false;
548
+ }
549
+ /**
550
+ * Heuristic `on:` trigger scan: matches inline (`on: pull_request`,
551
+ * `on: [push, pull_request]`) and block forms. Stops at the next top-level key so a
552
+ * later `pull_request` mention outside `on:` (a job name, a comment) cannot qualify.
553
+ * Leans toward waiting — a false positive only costs CI-wait backoff, while a false
554
+ * negative would send an unverified PR to review.
555
+ */
556
+ export function workflowHasPullRequestTrigger(content) {
557
+ const lines = content.split("\n");
558
+ const onIdx = lines.findIndex((l) => /^\s*on\s*:/.test(l));
559
+ if (onIdx < 0)
560
+ return false;
561
+ const onLine = lines[onIdx];
562
+ if (/\bpull_request\b/.test(onLine.slice(onLine.indexOf("on") + 2)))
563
+ return true;
564
+ for (let i = onIdx + 1; i < lines.length; i++) {
565
+ const line = lines[i];
566
+ if (/^\S/.test(line))
567
+ break;
568
+ if (/\bpull_request\b/.test(line.replace(/#.*$/, "")))
569
+ return true;
570
+ }
571
+ return false;
572
+ }
494
573
  export const realGithubAdapter = createGithubAdapter();
495
574
  const REVIEW_FLAG = {
496
575
  APPROVE: "--approve",
@@ -531,24 +610,34 @@ export async function pollPrChecks(
531
610
  // NOT-310: narrowed to what the poll actually calls — the merge-conflict sync
532
611
  // passes only a checks reader, and full adapters still satisfy this shape.
533
612
  adapter, opts) {
613
+ return (await pollPrChecksDetailed(adapter, opts)).result;
614
+ }
615
+ /**
616
+ * NOT-311: same bounded poll as {@link pollPrChecks}, but reporting what was last
617
+ * seen and whether an abort cut the wait short. `pollPrChecks` keeps the collapsed
618
+ * contract for callers that only branch on the result.
619
+ */
620
+ export async function pollPrChecksDetailed(adapter, opts) {
534
621
  const deadline = Date.now() + opts.timeoutMs;
535
622
  let noneStreak = 0;
623
+ let lastSnapshot = null;
536
624
  for (;;) {
537
625
  if (opts.signal?.aborted)
538
- return "timeout";
626
+ return { result: "timeout", lastSnapshot, aborted: true };
539
627
  const snapshot = await adapter.checksSnapshot({ cwd: opts.cwd, number: opts.number, branch: opts.branch });
628
+ lastSnapshot = snapshot;
540
629
  if (snapshot === "failure" || snapshot === "success")
541
- return snapshot;
630
+ return { result: snapshot, lastSnapshot, aborted: false };
542
631
  if (snapshot === "none") {
543
632
  noneStreak++;
544
633
  if (noneStreak >= NONE_STREAK_REQUIRED)
545
- return "none";
634
+ return { result: "none", lastSnapshot, aborted: false };
546
635
  }
547
636
  else {
548
637
  noneStreak = 0;
549
638
  }
550
639
  if (Date.now() >= deadline)
551
- return "timeout";
640
+ return { result: "timeout", lastSnapshot, aborted: false };
552
641
  await new Promise((resolve) => setTimeout(resolve, Math.min(opts.intervalMs, deadline - Date.now())));
553
642
  }
554
643
  }
@@ -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,
@@ -114,6 +114,19 @@ test("viewPr: a retry can find the already-created PR by explicit branch even wi
114
114
  const view = await createGithubAdapter(exec).viewPr({ cwd: "/repo", branch: "issue-4e5eb611" });
115
115
  assert.equal(view?.number, 42);
116
116
  });
117
+ test("NOT-354: prMergeableState reads `mergeable` by PR number; unknown values and failures are UNKNOWN", async () => {
118
+ const conflicting = queuedExec([{ stdout: JSON.stringify({ mergeable: "CONFLICTING" }) }]);
119
+ assert.equal(await createGithubAdapter(conflicting.exec).prMergeableState({ cwd: "/repo", number: 42 }), "CONFLICTING");
120
+ assert.deepEqual(conflicting.calls[0], ["pr", "view", "42", "--json", "mergeable"]);
121
+ const mergeable = queuedExec([{ stdout: JSON.stringify({ mergeable: "MERGEABLE" }) }]);
122
+ assert.equal(await createGithubAdapter(mergeable.exec).prMergeableState({ cwd: "/repo", number: 42 }), "MERGEABLE");
123
+ const unknown = queuedExec([{ stdout: JSON.stringify({ mergeable: "UNKNOWN" }) }]);
124
+ assert.equal(await createGithubAdapter(unknown.exec).prMergeableState({ cwd: "/repo", number: 42 }), "UNKNOWN");
125
+ const odd = queuedExec([{ stdout: JSON.stringify({ mergeable: "something-new" }) }]);
126
+ assert.equal(await createGithubAdapter(odd.exec).prMergeableState({ cwd: "/repo", number: 42 }), "UNKNOWN");
127
+ const failing = queuedExec([{ error: "HTTP 502" }]);
128
+ assert.equal(await createGithubAdapter(failing.exec).prMergeableState({ cwd: "/repo", number: 42 }), "UNKNOWN");
129
+ });
117
130
  test("checksSnapshot looks up the PR's check rollup by explicit selector (number preferred), never a bare `gh pr view`", async () => {
118
131
  const success = { stdout: JSON.stringify({ statusCheckRollup: [{ conclusion: "success" }] }) };
119
132
  const byNumber = queuedExec([success]);
@@ -205,6 +218,88 @@ test("pollPrChecks bails out as timeout immediately when the lease signal is alr
205
218
  });
206
219
  assert.equal(result, "timeout");
207
220
  });
221
+ test("NOT-311: pollPrChecksDetailed reports the last snapshot so a timeout can tell pending from abort", async () => {
222
+ const pending = await pollPrChecksDetailed(fakeAdapter(["pending", "pending", "pending", "pending"]), {
223
+ cwd: "/x",
224
+ timeoutMs: 20,
225
+ intervalMs: 10,
226
+ });
227
+ assert.deepEqual(pending, { result: "timeout", lastSnapshot: "pending", aborted: false });
228
+ const controller = new AbortController();
229
+ controller.abort();
230
+ const aborted = await pollPrChecksDetailed(fakeAdapter(["pending"]), {
231
+ cwd: "/x",
232
+ timeoutMs: 1000,
233
+ intervalMs: 5,
234
+ signal: controller.signal,
235
+ });
236
+ assert.deepEqual(aborted, { result: "timeout", lastSnapshot: null, aborted: true });
237
+ const none = await pollPrChecksDetailed(fakeAdapter(["none", "none"]), { cwd: "/x", timeoutMs: 1000, intervalMs: 5 });
238
+ assert.deepEqual(none, { result: "none", lastSnapshot: "none", aborted: false });
239
+ const incompleteNoneStreak = await pollPrChecksDetailed(fakeAdapter(["none"]), {
240
+ cwd: "/x",
241
+ timeoutMs: 0,
242
+ intervalMs: 10,
243
+ });
244
+ assert.deepEqual(incompleteNoneStreak, { result: "timeout", lastSnapshot: "none", aborted: false });
245
+ });
246
+ test("NOT-311: workflowHasPullRequestTrigger matches inline, list, and block on: forms", () => {
247
+ assert.equal(workflowHasPullRequestTrigger("on: pull_request\njobs:\n x:\n runs-on: ubuntu-latest\n"), true);
248
+ assert.equal(workflowHasPullRequestTrigger("on: [push, pull_request]\n"), true);
249
+ assert.equal(workflowHasPullRequestTrigger("on:\n push:\n branches: [main]\n pull_request:\n branches: [main]\n"), true);
250
+ assert.equal(workflowHasPullRequestTrigger("on:\n push:\n branches: [main]\n"), false);
251
+ assert.equal(workflowHasPullRequestTrigger("on: push\n"), false);
252
+ assert.equal(workflowHasPullRequestTrigger("on:\n schedule:\n - cron: '0 0 * * *'\n"), false);
253
+ assert.equal(workflowHasPullRequestTrigger("jobs:\n x:\n runs-on: ubuntu-latest\n"), false);
254
+ // A pull_request mention outside `on:` (job name) must not qualify.
255
+ assert.equal(workflowHasPullRequestTrigger("on: push\njobs:\n pull_request_preview:\n runs-on: ubuntu-latest\n"), false);
256
+ });
257
+ test("NOT-311: baseRefHasPullRequestWorkflow reads the base ref tree, false without workflows, null on unknown ref", async () => {
258
+ const { execFileSync } = await import("node:child_process");
259
+ const fs = await import("node:fs");
260
+ const os = await import("node:os");
261
+ const path = await import("node:path");
262
+ const setup = (files) => {
263
+ const repo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-gh-wf-repo-"));
264
+ const git = (...args) => execFileSync("git", args, { cwd: repo, encoding: "utf8" });
265
+ git("init", "-q", "-b", "main");
266
+ git("config", "user.email", "test@example.com");
267
+ git("config", "user.name", "Test");
268
+ for (const [rel, content] of Object.entries(files)) {
269
+ fs.mkdirSync(path.join(repo, path.dirname(rel)), { recursive: true });
270
+ fs.writeFileSync(path.join(repo, rel), content);
271
+ }
272
+ git("add", ".");
273
+ git("commit", "-q", "-m", "init");
274
+ const remote = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-gh-wf-remote-"));
275
+ execFileSync("git", ["init", "-q", "--bare", "-b", "main", remote]);
276
+ git("remote", "add", "origin", remote);
277
+ git("push", "-q", "origin", "main");
278
+ git("fetch", "-q", "origin");
279
+ return { repo, remote };
280
+ };
281
+ const withCi = setup({
282
+ "README.md": "hi\n",
283
+ ".github/workflows/ci.yml": "name: ci\non:\n pull_request:\n branches: [main]\njobs:\n x:\n runs-on: ubuntu-latest\n",
284
+ });
285
+ const pushOnly = setup({
286
+ "README.md": "hi\n",
287
+ ".github/workflows/nightly.yml": "name: nightly\non:\n schedule:\n - cron: '0 0 * * *'\njobs:\n x:\n runs-on: ubuntu-latest\n",
288
+ });
289
+ const bare = setup({ "README.md": "hi\n" });
290
+ try {
291
+ assert.equal(await baseRefHasPullRequestWorkflow(withCi.repo, "main"), true);
292
+ assert.equal(await baseRefHasPullRequestWorkflow(pushOnly.repo, "main"), false);
293
+ assert.equal(await baseRefHasPullRequestWorkflow(bare.repo, "main"), false);
294
+ assert.equal(await baseRefHasPullRequestWorkflow(bare.repo, "no-such-branch"), null);
295
+ }
296
+ finally {
297
+ for (const { repo, remote } of [withCi, pushOnly, bare]) {
298
+ fs.rmSync(repo, { recursive: true, force: true });
299
+ fs.rmSync(remote, { recursive: true, force: true });
300
+ }
301
+ }
302
+ });
208
303
  // --- NOT-252: terminal checks_failed enrichment ---
209
304
  const HEAD_SHA = "abc123def456abc123def456abc123def456abcd";
210
305
  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
+ });