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.
- package/bundle/server/dist/adapters/agent-deck-bind.js +16 -6
- package/bundle/server/dist/adapters/agent-deck-bind.test.js +74 -0
- package/bundle/server/dist/adapters/agent-health.js +14 -3
- package/bundle/server/dist/adapters/github.js +84 -5
- package/bundle/server/dist/adapters/github.test.js +83 -1
- package/bundle/server/dist/adapters/muse-capability.js +443 -24
- package/bundle/server/dist/adapters/muse-capability.test.js +469 -25
- package/bundle/server/dist/adapters/muse-visual-qa.js +114 -0
- package/bundle/server/dist/adapters/muse-visual-qa.test.js +68 -0
- package/bundle/server/dist/adapters/worktree-deps.js +96 -0
- package/bundle/server/dist/adapters/worktree-deps.test.js +144 -0
- package/bundle/server/dist/capacity/muse-probe.js +56 -9
- package/bundle/server/dist/capacity/muse-probe.test.js +188 -1
- package/bundle/server/dist/coordinator/admission.js +14 -1
- package/bundle/server/dist/coordinator/admission.test.js +197 -4
- package/bundle/server/dist/coordinator/auto-merge.integration.test.js +276 -0
- package/bundle/server/dist/coordinator/auto-merge.js +244 -4
- package/bundle/server/dist/coordinator/checks-wait-config.js +19 -0
- package/bundle/server/dist/coordinator/commands.js +284 -23
- package/bundle/server/dist/coordinator/commands.test.js +47 -0
- package/bundle/server/dist/coordinator/deck-outage.integration.test.js +4 -2
- package/bundle/server/dist/coordinator/developer-effect.js +224 -4
- package/bundle/server/dist/coordinator/developer-effect.test.js +288 -3
- package/bundle/server/dist/coordinator/execution-report.js +6 -0
- package/bundle/server/dist/coordinator/execution-report.test.js +7 -0
- package/bundle/server/dist/coordinator/failure-cause.js +64 -1
- package/bundle/server/dist/coordinator/failure-cause.test.js +92 -1
- package/bundle/server/dist/coordinator/failure-reason.js +7 -0
- package/bundle/server/dist/coordinator/failure-reason.test.js +14 -0
- package/bundle/server/dist/coordinator/human-resolution.js +36 -1
- package/bundle/server/dist/coordinator/human-resolution.test.js +33 -0
- package/bundle/server/dist/coordinator/merge-conflict-sync.js +647 -0
- package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +121 -0
- package/bundle/server/dist/coordinator/muse-developer.integration.test.js +9 -4
- package/bundle/server/dist/coordinator/muse-spawn.js +122 -29
- package/bundle/server/dist/coordinator/muse-spawn.test.js +210 -0
- package/bundle/server/dist/coordinator/operator-criteria.js +168 -0
- package/bundle/server/dist/coordinator/operator-criteria.test.js +78 -0
- package/bundle/server/dist/coordinator/operator-verification.integration.test.js +347 -0
- package/bundle/server/dist/coordinator/playbook-feedback.js +693 -0
- package/bundle/server/dist/coordinator/playbook-feedback.test.js +702 -0
- package/bundle/server/dist/coordinator/projection.js +10 -4
- package/bundle/server/dist/coordinator/projection.test.js +13 -0
- package/bundle/server/dist/coordinator/prompts-execution-contract.test.js +107 -0
- package/bundle/server/dist/coordinator/prompts.js +211 -2
- package/bundle/server/dist/coordinator/prompts.test.js +231 -0
- package/bundle/server/dist/coordinator/recovery.js +57 -3
- package/bundle/server/dist/coordinator/recovery.test.js +67 -8
- package/bundle/server/dist/coordinator/reflect-trigger.js +33 -169
- package/bundle/server/dist/coordinator/reflect-trigger.test.js +149 -200
- package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +1 -0
- package/bundle/server/dist/coordinator/reviewer-effect.js +16 -1
- package/bundle/server/dist/coordinator/reviewer-result.js +6 -0
- package/bundle/server/dist/coordinator/routing.js +45 -6
- package/bundle/server/dist/coordinator/routing.test.js +76 -15
- package/bundle/server/dist/coordinator/session-timeouts.js +30 -0
- package/bundle/server/dist/coordinator/usage-cap-defer.js +61 -0
- package/bundle/server/dist/coordinator/usage-cap.integration.test.js +1 -1
- package/bundle/server/dist/coordinator/worker-loop.js +18 -4
- package/bundle/server/dist/db/ci-attempts-migration.test.js +70 -0
- package/bundle/server/dist/db/index.js +18 -0
- package/bundle/server/dist/db/schema.sql +5 -0
- package/bundle/server/dist/docs-execution-analysis.test.js +1 -0
- package/bundle/server/dist/repository/artifacts-for-issue.js +3 -3
- package/bundle/server/dist/repository/human-actions.js +14 -0
- package/bundle/server/dist/repository/issues.js +67 -6
- package/bundle/server/dist/repository/work-items.js +7 -0
- package/bundle/server/dist/repository/worker-sessions.js +51 -2
- package/bundle/server/dist/routes/human-actions.js +10 -8
- package/bundle/server/dist/routes/issues-execution-contract.test.js +321 -0
- package/bundle/server/dist/routes/issues.js +19 -2
- package/bundle/server/dist/runners/muse-code-args.js +25 -0
- package/bundle/server/dist/runners/muse-code-args.test.js +30 -0
- package/bundle/server/dist/runners/muse-code-jsonl.js +106 -11
- package/bundle/server/dist/runners/muse-config-core.js +18 -1
- package/bundle/server/dist/runners/muse-config.test.js +37 -0
- package/bundle/server/dist/runners/muse-serve-session.js +5 -0
- package/bundle/server/dist/runners/spawn-cli.js +116 -0
- package/bundle/server/dist/runners/spawn-cli.test.js +124 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-CU6fNntg.js +64 -0
- package/bundle/server/static-ui/assets/{index-DyAJNyfV.css → index-K_YcYkQU.css} +1 -1
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/execution-analysis.d.ts +22 -22
- package/bundle/shared/dist/execution-contract.d.ts +117 -0
- package/bundle/shared/dist/execution-contract.js +307 -0
- package/bundle/shared/dist/execution-contract.test.d.ts +2 -0
- package/bundle/shared/dist/execution-contract.test.js +499 -0
- package/bundle/shared/dist/execution-report.d.ts +9 -9
- package/bundle/shared/dist/execution-report.js +2 -0
- package/bundle/shared/dist/failure-cause.d.ts +4 -4
- package/bundle/shared/dist/failure-cause.js +2 -0
- package/bundle/shared/dist/human-actions.d.ts +4 -4
- package/bundle/shared/dist/human-actions.js +17 -0
- package/bundle/shared/dist/index.d.ts +85 -84
- package/bundle/shared/dist/index.js +3 -0
- package/bundle/shared/dist/issues.d.ts +176 -16
- package/bundle/shared/dist/issues.js +22 -0
- package/bundle/shared/dist/issues.test.js +3 -0
- package/bundle/shared/dist/outbound-draft.d.ts +12 -12
- package/bundle/shared/dist/worker-sessions.d.ts +10 -0
- package/bundle/shared/dist/worker-sessions.js +8 -0
- package/bundle/shared/dist/worker-sessions.test.js +1 -0
- package/bundle/shared/dist/workflow.d.ts +4 -4
- package/bundle/shared/dist/workflow.js +16 -0
- package/bundle/shared/dist/workflow.test.js +3 -0
- package/bundle/shared/package.json +1 -1
- package/dist/action.js +4 -1
- package/dist/action.test.js +42 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
- 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
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
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
|
}
|