agent-dealer 1.2.7 → 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 (112) hide show
  1. package/bundle/server/dist/adapters/agent-deck-bind.js +16 -6
  2. package/bundle/server/dist/adapters/agent-deck-bind.test.js +74 -0
  3. package/bundle/server/dist/adapters/agent-health.js +14 -3
  4. package/bundle/server/dist/adapters/github.js +84 -5
  5. package/bundle/server/dist/adapters/github.test.js +83 -1
  6. package/bundle/server/dist/adapters/muse-capability.js +443 -24
  7. package/bundle/server/dist/adapters/muse-capability.test.js +469 -25
  8. package/bundle/server/dist/adapters/muse-visual-qa.js +114 -0
  9. package/bundle/server/dist/adapters/muse-visual-qa.test.js +68 -0
  10. package/bundle/server/dist/adapters/worktree-deps.js +96 -0
  11. package/bundle/server/dist/adapters/worktree-deps.test.js +144 -0
  12. package/bundle/server/dist/capacity/muse-probe.js +56 -9
  13. package/bundle/server/dist/capacity/muse-probe.test.js +188 -1
  14. package/bundle/server/dist/coordinator/admission.js +14 -1
  15. package/bundle/server/dist/coordinator/admission.test.js +197 -4
  16. package/bundle/server/dist/coordinator/auto-merge.integration.test.js +276 -0
  17. package/bundle/server/dist/coordinator/auto-merge.js +244 -4
  18. package/bundle/server/dist/coordinator/checks-wait-config.js +19 -0
  19. package/bundle/server/dist/coordinator/commands.js +284 -23
  20. package/bundle/server/dist/coordinator/commands.test.js +47 -0
  21. package/bundle/server/dist/coordinator/deck-outage.integration.test.js +4 -2
  22. package/bundle/server/dist/coordinator/developer-effect.js +224 -4
  23. package/bundle/server/dist/coordinator/developer-effect.test.js +288 -3
  24. package/bundle/server/dist/coordinator/execution-report.js +6 -0
  25. package/bundle/server/dist/coordinator/execution-report.test.js +7 -0
  26. package/bundle/server/dist/coordinator/failure-cause.js +64 -1
  27. package/bundle/server/dist/coordinator/failure-cause.test.js +92 -1
  28. package/bundle/server/dist/coordinator/failure-reason.js +7 -0
  29. package/bundle/server/dist/coordinator/failure-reason.test.js +14 -0
  30. package/bundle/server/dist/coordinator/human-resolution.js +36 -1
  31. package/bundle/server/dist/coordinator/human-resolution.test.js +33 -0
  32. package/bundle/server/dist/coordinator/merge-conflict-sync.js +647 -0
  33. package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +121 -0
  34. package/bundle/server/dist/coordinator/muse-developer.integration.test.js +9 -4
  35. package/bundle/server/dist/coordinator/muse-spawn.js +122 -29
  36. package/bundle/server/dist/coordinator/muse-spawn.test.js +210 -0
  37. package/bundle/server/dist/coordinator/operator-criteria.js +168 -0
  38. package/bundle/server/dist/coordinator/operator-criteria.test.js +78 -0
  39. package/bundle/server/dist/coordinator/operator-verification.integration.test.js +347 -0
  40. package/bundle/server/dist/coordinator/playbook-feedback.js +693 -0
  41. package/bundle/server/dist/coordinator/playbook-feedback.test.js +702 -0
  42. package/bundle/server/dist/coordinator/projection.js +10 -4
  43. package/bundle/server/dist/coordinator/projection.test.js +13 -0
  44. package/bundle/server/dist/coordinator/prompts-execution-contract.test.js +107 -0
  45. package/bundle/server/dist/coordinator/prompts.js +211 -2
  46. package/bundle/server/dist/coordinator/prompts.test.js +231 -0
  47. package/bundle/server/dist/coordinator/recovery.js +57 -3
  48. package/bundle/server/dist/coordinator/recovery.test.js +67 -8
  49. package/bundle/server/dist/coordinator/reflect-trigger.js +33 -169
  50. package/bundle/server/dist/coordinator/reflect-trigger.test.js +149 -200
  51. package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +1 -0
  52. package/bundle/server/dist/coordinator/reviewer-effect.js +16 -1
  53. package/bundle/server/dist/coordinator/reviewer-result.js +6 -0
  54. package/bundle/server/dist/coordinator/routing.js +45 -6
  55. package/bundle/server/dist/coordinator/routing.test.js +76 -15
  56. package/bundle/server/dist/coordinator/session-timeouts.js +30 -0
  57. package/bundle/server/dist/coordinator/usage-cap-defer.js +61 -0
  58. package/bundle/server/dist/coordinator/usage-cap.integration.test.js +1 -1
  59. package/bundle/server/dist/coordinator/worker-loop.js +18 -4
  60. package/bundle/server/dist/db/ci-attempts-migration.test.js +70 -0
  61. package/bundle/server/dist/db/index.js +18 -0
  62. package/bundle/server/dist/db/schema.sql +5 -0
  63. package/bundle/server/dist/docs-execution-analysis.test.js +1 -0
  64. package/bundle/server/dist/repository/artifacts-for-issue.js +3 -3
  65. package/bundle/server/dist/repository/human-actions.js +14 -0
  66. package/bundle/server/dist/repository/issues.js +67 -6
  67. package/bundle/server/dist/repository/work-items.js +7 -0
  68. package/bundle/server/dist/repository/worker-sessions.js +51 -2
  69. package/bundle/server/dist/routes/human-actions.js +10 -8
  70. package/bundle/server/dist/routes/issues-execution-contract.test.js +321 -0
  71. package/bundle/server/dist/routes/issues.js +19 -2
  72. package/bundle/server/dist/runners/muse-code-args.js +25 -0
  73. package/bundle/server/dist/runners/muse-code-args.test.js +30 -0
  74. package/bundle/server/dist/runners/muse-code-jsonl.js +106 -11
  75. package/bundle/server/dist/runners/muse-config-core.js +18 -1
  76. package/bundle/server/dist/runners/muse-config.test.js +37 -0
  77. package/bundle/server/dist/runners/muse-serve-session.js +5 -0
  78. package/bundle/server/dist/runners/spawn-cli.js +116 -0
  79. package/bundle/server/dist/runners/spawn-cli.test.js +124 -0
  80. package/bundle/server/package.json +2 -2
  81. package/bundle/server/static-ui/assets/index-CU6fNntg.js +64 -0
  82. package/bundle/server/static-ui/assets/{index-DyAJNyfV.css → index-K_YcYkQU.css} +1 -1
  83. package/bundle/server/static-ui/index.html +2 -2
  84. package/bundle/shared/dist/execution-analysis.d.ts +22 -22
  85. package/bundle/shared/dist/execution-contract.d.ts +117 -0
  86. package/bundle/shared/dist/execution-contract.js +307 -0
  87. package/bundle/shared/dist/execution-contract.test.d.ts +2 -0
  88. package/bundle/shared/dist/execution-contract.test.js +499 -0
  89. package/bundle/shared/dist/execution-report.d.ts +9 -9
  90. package/bundle/shared/dist/execution-report.js +2 -0
  91. package/bundle/shared/dist/failure-cause.d.ts +4 -4
  92. package/bundle/shared/dist/failure-cause.js +2 -0
  93. package/bundle/shared/dist/human-actions.d.ts +4 -4
  94. package/bundle/shared/dist/human-actions.js +17 -0
  95. package/bundle/shared/dist/index.d.ts +85 -84
  96. package/bundle/shared/dist/index.js +3 -0
  97. package/bundle/shared/dist/issues.d.ts +176 -16
  98. package/bundle/shared/dist/issues.js +22 -0
  99. package/bundle/shared/dist/issues.test.js +3 -0
  100. package/bundle/shared/dist/outbound-draft.d.ts +12 -12
  101. package/bundle/shared/dist/worker-sessions.d.ts +10 -0
  102. package/bundle/shared/dist/worker-sessions.js +8 -0
  103. package/bundle/shared/dist/worker-sessions.test.js +1 -0
  104. package/bundle/shared/dist/workflow.d.ts +4 -4
  105. package/bundle/shared/dist/workflow.js +16 -0
  106. package/bundle/shared/dist/workflow.test.js +3 -0
  107. package/bundle/shared/package.json +1 -1
  108. package/dist/action.js +4 -1
  109. package/dist/action.test.js +42 -0
  110. package/dist/index.js +1 -1
  111. package/package.json +1 -1
  112. package/bundle/server/static-ui/assets/index-yLyxRd-7.js +0 -64
@@ -0,0 +1,114 @@
1
+ // packages/server/src/adapters/muse-visual-qa.ts
2
+ //
3
+ // NOT-303: Muse Dev visual-QA screenshot-path preflight.
4
+ //
5
+ // `Google Chrome.app` headless cannot run inside a Muse developer session: every
6
+ // launch aborts at macOS process registration
7
+ // (`TransformProcessType -> _RegisterApplication`, SIGABRT/exit 134, no output, no
8
+ // file) under the Muse sandbox, and `--no-sandbox` only disables Chrome's own
9
+ // sandbox so it cannot help. Evidence:
10
+ // `docs/evaluations/muse-code/chrome-headless-screenshot.md`.
11
+ //
12
+ // This module resolves, coordinator-side and before the session starts, which
13
+ // screenshot path a Muse developer worker gets. It never launches a browser and
14
+ // never touches the sandbox: the default verdict is unusable-with-reason (fail
15
+ // loud, so the worker reads it in its prompt instead of discovering the abort
16
+ // mid-session). Only an operator pre-installed non-AppKit headless shell,
17
+ // pointed at by `MUSE_HEADLESS_SHELL_BIN`, flips the verdict — the binary cannot
18
+ // be fetched in-session because the developer sandbox network is `restricted`.
19
+ //
20
+ // Deliberately NOT covered: widening the Muse sandbox, a coordinator-side
21
+ // screenshot step, and how Cursor/Claude/Codex workers run Chrome.
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ /** Operator override: absolute path to a pre-installed headless shell binary. */
25
+ export const MUSE_HEADLESS_SHELL_BIN_ENV = "MUSE_HEADLESS_SHELL_BIN";
26
+ /** Phase 1 evidence behind the default verdict. */
27
+ export const MUSE_VISUAL_QA_DOC = "docs/evaluations/muse-code/chrome-headless-screenshot.md";
28
+ /** The abort every `Google Chrome.app` headless launch dies with in-sandbox. */
29
+ export const MUSE_CHROME_APP_ABORT = "TransformProcessType -> _RegisterApplication (SIGABRT, exit 134)";
30
+ /**
31
+ * Resolve the screenshot path for a Muse developer worker. Pure apart from the
32
+ * injected `exists` probe; never spawns anything (probing `Google Chrome.app`
33
+ * would only mint another crash report).
34
+ */
35
+ export function checkMuseVisualQa(opts = {}) {
36
+ const env = opts.env ?? process.env;
37
+ const exists = opts.exists ?? fs.existsSync;
38
+ const configured = env[MUSE_HEADLESS_SHELL_BIN_ENV]?.trim() || null;
39
+ if (configured) {
40
+ if (!path.isAbsolute(configured)) {
41
+ return {
42
+ usable: false,
43
+ binary: null,
44
+ args: [],
45
+ reason: `${MUSE_HEADLESS_SHELL_BIN_ENV} must be an absolute path (got a non-absolute value) — ` +
46
+ `no headless shell configured, and Google Chrome.app headless aborts in-session ` +
47
+ `(${MUSE_CHROME_APP_ABORT}); see ${MUSE_VISUAL_QA_DOC}`,
48
+ };
49
+ }
50
+ if (!exists(configured)) {
51
+ return {
52
+ usable: false,
53
+ binary: null,
54
+ args: [],
55
+ reason: `headless shell not found at ${MUSE_HEADLESS_SHELL_BIN_ENV} (${configured}) — ` +
56
+ `no screenshot path is installed, and Google Chrome.app headless aborts in-session ` +
57
+ `(${MUSE_CHROME_APP_ABORT}); see ${MUSE_VISUAL_QA_DOC}`,
58
+ };
59
+ }
60
+ return {
61
+ usable: true,
62
+ binary: configured,
63
+ args: ["--screenshot=<png>", "--window-size=1280,800"],
64
+ reason: `pre-installed headless shell at ${configured}`,
65
+ };
66
+ }
67
+ return {
68
+ usable: false,
69
+ binary: null,
70
+ args: [],
71
+ reason: `no headless shell installed (${MUSE_HEADLESS_SHELL_BIN_ENV} is unset — it must be ` +
72
+ `pre-installed on the host because the developer sandbox network is restricted), and ` +
73
+ `Google Chrome.app headless aborts in-session (${MUSE_CHROME_APP_ABORT}); ` +
74
+ `see ${MUSE_VISUAL_QA_DOC}`,
75
+ };
76
+ }
77
+ function chromeAppWarningLines() {
78
+ return [
79
+ `- Never launch \`Google Chrome.app\` headless here: every launch aborts at startup`,
80
+ ` (${MUSE_CHROME_APP_ABORT}, no output, no file) under the Muse sandbox.`,
81
+ ` \`--no-sandbox\` only disables Chrome's own sandbox and cannot help.`,
82
+ ];
83
+ }
84
+ /**
85
+ * Worker-prompt section for a Muse developer session. The unusable variant tells
86
+ * the worker not to burn steps probing and to close with an explicit
87
+ * `Visual QA: not run` conclusion line, so review evidence states it instead of
88
+ * reading a missing screenshot as a pass.
89
+ */
90
+ export function museVisualQaPromptSection(status) {
91
+ if (status.usable && status.binary) {
92
+ return [
93
+ `## Visual QA (screenshots)`,
94
+ `A pre-installed headless shell is available: \`${status.binary}\`. For UI-visible`,
95
+ `changes, capture a screenshot with`,
96
+ `\`${status.binary} ${[...status.args, "<page-url>"].join(" ")}\``,
97
+ `saving the PNG into the worktree, and commit it with your change.`,
98
+ ...chromeAppWarningLines(),
99
+ `End your implementation conclusion with a \`Visual QA:\` line —`,
100
+ `\`Visual QA: verified (<png path>, exit <code>)\` or \`Visual QA: not run (<reason>)\`.`,
101
+ ``,
102
+ ];
103
+ }
104
+ return [
105
+ `## Visual QA (screenshots)`,
106
+ `Headless visual QA does not work inside this session — do not spend steps probing it:`,
107
+ ...chromeAppWarningLines(),
108
+ `- ${status.reason}`,
109
+ `Visual QA is out of scope for this session: never read the absence of a screenshot as`,
110
+ `a pass. End your implementation conclusion with a \`Visual QA:\` line —`,
111
+ `\`Visual QA: not run (<short reason>)\` — so the review evidence states it explicitly.`,
112
+ ``,
113
+ ];
114
+ }
@@ -0,0 +1,68 @@
1
+ // packages/server/src/adapters/muse-visual-qa.test.ts
2
+ //
3
+ // NOT-303: the Muse Dev screenshot-path preflight. The unusable verdict must fail
4
+ // loudly — naming the RegisterApplication abort and the missing binary — instead
5
+ // of letting the worker discover it mid-session.
6
+ import { test } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { checkMuseVisualQa, MUSE_HEADLESS_SHELL_BIN_ENV, museVisualQaPromptSection, } from "./muse-visual-qa.js";
9
+ test("unset override: unusable, naming the Chrome.app abort and the missing binary", () => {
10
+ const status = checkMuseVisualQa({ env: {}, exists: () => false });
11
+ assert.equal(status.usable, false);
12
+ assert.equal(status.binary, null);
13
+ assert.match(status.reason, /RegisterApplication/);
14
+ assert.match(status.reason, /MUSE_HEADLESS_SHELL_BIN/);
15
+ });
16
+ test("configured but absent binary: unusable, naming the path", () => {
17
+ const status = checkMuseVisualQa({
18
+ env: { [MUSE_HEADLESS_SHELL_BIN_ENV]: "/opt/headless/chrome-headless-shell" },
19
+ exists: () => false,
20
+ });
21
+ assert.equal(status.usable, false);
22
+ assert.equal(status.binary, null);
23
+ assert.match(status.reason, /\/opt\/headless\/chrome-headless-shell/);
24
+ assert.match(status.reason, /RegisterApplication/);
25
+ });
26
+ test("configured but non-absolute value: unusable, not treated as a binary", () => {
27
+ const status = checkMuseVisualQa({
28
+ env: { [MUSE_HEADLESS_SHELL_BIN_ENV]: "relative/bin" },
29
+ exists: () => true,
30
+ });
31
+ assert.equal(status.usable, false);
32
+ assert.equal(status.binary, null);
33
+ assert.match(status.reason, /absolute path/);
34
+ });
35
+ test("pre-installed binary present: usable with binary and screenshot flags", () => {
36
+ const status = checkMuseVisualQa({
37
+ env: { [MUSE_HEADLESS_SHELL_BIN_ENV]: "/opt/headless/chrome-headless-shell" },
38
+ exists: (p) => p === "/opt/headless/chrome-headless-shell",
39
+ });
40
+ assert.equal(status.usable, true);
41
+ assert.equal(status.binary, "/opt/headless/chrome-headless-shell");
42
+ assert.ok(status.args.some((a) => a.startsWith("--screenshot=")));
43
+ });
44
+ test("unusable prompt section: forbids Chrome.app probing, declares out of scope, requires the conclusion line", () => {
45
+ const lines = museVisualQaPromptSection(checkMuseVisualQa({ env: {}, exists: () => false }));
46
+ const text = lines.join("\n");
47
+ assert.match(text, /## Visual QA/);
48
+ assert.match(text, /Google Chrome\.app/);
49
+ assert.match(text, /RegisterApplication/);
50
+ assert.match(text, /do not spend steps probing/i);
51
+ assert.match(text, /out of scope/i);
52
+ assert.match(text, /Visual QA: not run/);
53
+ assert.doesNotMatch(text, /--disable-sandbox/);
54
+ assert.doesNotMatch(text, /--yolo/);
55
+ });
56
+ test("usable prompt section: names the binary and still forbids Google Chrome.app", () => {
57
+ const lines = museVisualQaPromptSection({
58
+ usable: true,
59
+ binary: "/opt/headless/chrome-headless-shell",
60
+ args: ["--screenshot=<png>", "--window-size=1280,800"],
61
+ reason: "pre-installed headless shell",
62
+ });
63
+ const text = lines.join("\n");
64
+ assert.match(text, /\/opt\/headless\/chrome-headless-shell/);
65
+ assert.match(text, /--screenshot=<png>/);
66
+ assert.match(text, /Google Chrome\.app/);
67
+ assert.match(text, /Visual QA:/);
68
+ });
@@ -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
+ });
@@ -91,6 +91,44 @@ export function hasFreshMuseCapacityPair(nowMs = Date.now()) {
91
91
  function museProbeDiagnosticLogPath() {
92
92
  return path.join(getDataDir(), "capacity", "muse-probe.log");
93
93
  }
94
+ const MUSE_PROBE_FAILURE_MESSAGE_MAX = 300;
95
+ const MUSE_PROBE_FAILURE_KIND_MAX = 100;
96
+ /** Keep the diagnostic log one JSON object per line and free of anything
97
+ * beyond kind + message: strip control characters, then truncate. */
98
+ function sanitizeFailureText(value, max) {
99
+ return value.replace(/[\x00-\x1f\x7f]/g, "").slice(0, max);
100
+ }
101
+ function failureDetailFromTurn(failure) {
102
+ if (!failure)
103
+ return null;
104
+ return {
105
+ kind: sanitizeFailureText(failure.kind, MUSE_PROBE_FAILURE_KIND_MAX),
106
+ message: sanitizeFailureText(failure.message, MUSE_PROBE_FAILURE_MESSAGE_MAX),
107
+ };
108
+ }
109
+ function failureDetailFromError(err) {
110
+ let message;
111
+ if (err instanceof Error) {
112
+ message = err.message;
113
+ }
114
+ else if (typeof err === "string") {
115
+ message = err;
116
+ }
117
+ else {
118
+ try {
119
+ message = JSON.stringify(err) ?? "unknown error";
120
+ }
121
+ catch {
122
+ message = "unknown error";
123
+ }
124
+ }
125
+ if (!message)
126
+ message = "unknown error";
127
+ return {
128
+ kind: "exception",
129
+ message: sanitizeFailureText(message, MUSE_PROBE_FAILURE_MESSAGE_MAX),
130
+ };
131
+ }
94
132
  function appendMuseProbeDiagnostic(nowMs, result) {
95
133
  try {
96
134
  const file = museProbeDiagnosticLogPath();
@@ -134,6 +172,7 @@ export async function runMuseCapacityProbe(nowMs = Date.now(), opts = {}) {
134
172
  admitted: false,
135
173
  terminal: null,
136
174
  failureKind: "unadmitted",
175
+ failureDetail: null,
137
176
  windowsUpdated: [],
138
177
  durationMs: Date.now() - startedRealMs,
139
178
  };
@@ -142,28 +181,33 @@ export async function runMuseCapacityProbe(nowMs = Date.now(), opts = {}) {
142
181
  await host.readUsage();
143
182
  const checkNowMs = Math.max(nowMs, Date.now());
144
183
  const ok = hasFreshMuseCapacityPair(checkNowMs);
184
+ const failureKind = ok
185
+ ? null
186
+ : turn.timedOut
187
+ ? "timeout"
188
+ : turn.failure !== null
189
+ ? "turn_failed"
190
+ : "no_windows";
145
191
  result = {
146
192
  ok,
147
193
  admitted: true,
148
194
  terminal: turn.terminal,
149
- failureKind: ok
150
- ? null
151
- : turn.timedOut
152
- ? "timeout"
153
- : turn.failure !== null
154
- ? "turn_failed"
155
- : "no_windows",
195
+ failureKind,
196
+ failureDetail: failureKind === "turn_failed" || failureKind === "timeout"
197
+ ? failureDetailFromTurn(turn.failure)
198
+ : null,
156
199
  windowsUpdated: ok ? ["rolling_all_models", "weekly_all_models"] : [],
157
200
  durationMs: Date.now() - startedRealMs,
158
201
  };
159
202
  }
160
203
  }
161
- catch {
204
+ catch (err) {
162
205
  result = {
163
206
  ok: false,
164
207
  admitted: false,
165
208
  terminal: null,
166
209
  failureKind: "exception",
210
+ failureDetail: failureDetailFromError(err),
167
211
  windowsUpdated: [],
168
212
  durationMs: Date.now() - startedRealMs,
169
213
  };
@@ -173,7 +217,10 @@ export async function runMuseCapacityProbe(nowMs = Date.now(), opts = {}) {
173
217
  await host.shutdown().catch(() => undefined);
174
218
  }
175
219
  appendMuseProbeDiagnostic(nowMs, result);
176
- const outcome = result.ok ? "ok" : `failed: ${result.failureKind ?? "unknown"}`;
220
+ const detail = result.failureDetail !== null
221
+ ? ` (${result.failureDetail.kind}: ${result.failureDetail.message})`
222
+ : "";
223
+ const outcome = result.ok ? "ok" : `failed: ${result.failureKind ?? "unknown"}${detail}`;
177
224
  console.error(`[muse-capacity] fallback ${outcome}`);
178
225
  return result;
179
226
  }