agent-dealer 1.2.4 → 1.2.6
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 +33 -3
- package/bundle/server/dist/adapters/agent-deck-bind.test.js +60 -2
- package/bundle/server/dist/adapters/agent-health.js +28 -6
- package/bundle/server/dist/adapters/agent-health.test.js +66 -11
- package/bundle/server/dist/adapters/git-worktree.js +187 -18
- package/bundle/server/dist/adapters/git-worktree.test.js +293 -0
- package/bundle/server/dist/adapters/github.js +110 -12
- package/bundle/server/dist/adapters/github.test.js +274 -3
- package/bundle/server/dist/adapters/muse-capability.js +356 -0
- package/bundle/server/dist/adapters/muse-capability.test.js +464 -0
- package/bundle/server/dist/capacity/claude-local-cache.js +266 -81
- package/bundle/server/dist/capacity/claude-local-cache.test.js +335 -36
- package/bundle/server/dist/capacity/muse-host.js +92 -32
- package/bundle/server/dist/capacity/muse-host.test.js +130 -4
- package/bundle/server/dist/coordinator/admission.test.js +85 -0
- package/bundle/server/dist/coordinator/args.js +4 -3
- package/bundle/server/dist/coordinator/checkpoint.js +5 -3
- package/bundle/server/dist/coordinator/checkpoint.test.js +23 -0
- package/bundle/server/dist/coordinator/commands.js +100 -12
- package/bundle/server/dist/coordinator/developer-effect.js +128 -16
- package/bundle/server/dist/coordinator/developer-effect.test.js +291 -12
- package/bundle/server/dist/coordinator/human-resolution.js +33 -1
- package/bundle/server/dist/coordinator/muse-developer.integration.test.js +291 -55
- package/bundle/server/dist/coordinator/muse-spawn.js +72 -164
- package/bundle/server/dist/coordinator/projection.js +1 -0
- package/bundle/server/dist/coordinator/prompts.js +25 -6
- package/bundle/server/dist/coordinator/prompts.test.js +36 -8
- package/bundle/server/dist/coordinator/routing.js +1 -0
- package/bundle/server/dist/coordinator/routing.test.js +20 -0
- package/bundle/server/dist/coordinator/spawn.js +9 -1
- package/bundle/server/dist/coordinator/worktree-cwd-guard.js +48 -0
- package/bundle/server/dist/coordinator/worktree-cwd-guard.test.js +83 -0
- package/bundle/server/dist/coordinator/worktree-owner-liveness.js +6 -1
- package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +2 -1
- package/bundle/server/dist/repository/human-actions.js +13 -0
- package/bundle/server/dist/routes/human-actions.js +10 -1
- package/bundle/server/dist/routes/human-actions.test.js +117 -1
- package/bundle/server/dist/routes/index.js +12 -5
- package/bundle/server/dist/routes/runtime-capacity.test.js +3 -2
- package/bundle/server/dist/routes/version.js +30 -0
- package/bundle/server/dist/routes/version.test.js +21 -0
- package/bundle/server/dist/runners/muse-config-core.js +63 -16
- package/bundle/server/dist/runners/muse-config.js +1 -1
- package/bundle/server/dist/runners/muse-config.test.js +135 -29
- package/bundle/server/dist/runners/spawn-cli.js +17 -3
- package/bundle/server/dist/runners/spawn-cli.test.js +59 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/{index-CYqRh_-S.css → index-BII-LgB8.css} +1 -1
- package/bundle/server/static-ui/assets/index-CoDzZcsz.js +60 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/agents.d.ts +15 -15
- package/bundle/shared/dist/agents.js +6 -0
- package/bundle/shared/dist/index.d.ts +7 -7
- package/bundle/shared/package.json +1 -1
- package/dist/doctor.d.ts +7 -3
- package/dist/doctor.js +21 -13
- package/dist/doctor.test.js +9 -8
- package/dist/managed/index.d.ts +1 -1
- package/dist/managed/index.js +1 -1
- package/dist/managed/updater.d.ts +16 -6
- package/dist/managed/updater.js +28 -11
- package/dist/managed-update-restart.test.d.ts +1 -0
- package/dist/managed-update-restart.test.js +227 -0
- package/dist/ports.d.ts +6 -0
- package/dist/ports.js +10 -0
- package/dist/runtime-state.d.ts +10 -0
- package/dist/runtime-state.js +26 -0
- package/dist/start.js +67 -2
- package/dist/status.js +30 -2
- package/dist/update-check.js +3 -6
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-UO4lHZw4.js +0 -60
|
@@ -25,10 +25,12 @@
|
|
|
25
25
|
// connection (`hasInflightExecution`): a capacity read must not SIGTERM
|
|
26
26
|
// an admitted turn.
|
|
27
27
|
// - The host launches under a server-owned XDG home
|
|
28
|
-
// (`prepareMuseServeHome`): the
|
|
29
|
-
//
|
|
30
|
-
// reminders) plus a symlink to the ambient login, so
|
|
31
|
-
//
|
|
28
|
+
// (`prepareMuseServeHome`): the deckless worker base posture
|
|
29
|
+
// (`buildMuseBaseSettings` — no MCP servers, no subagents, no workflows,
|
|
30
|
+
// no reminders) plus a symlink to the ambient login, so the host never
|
|
31
|
+
// inherits the operator's ambient config. It runs no sessions; deck-enabled
|
|
32
|
+
// developer turns each carry their own required `agent-deck` server on the
|
|
33
|
+
// isolated exec lane.
|
|
32
34
|
// - `usage/changed` is ingested as soon as received; the throttled
|
|
33
35
|
// on-demand `usage/read` on the same host is the final read.
|
|
34
36
|
// - Restart/crash: usage state is process-local to the host, so a dead host
|
|
@@ -51,17 +53,16 @@
|
|
|
51
53
|
// for an hour.
|
|
52
54
|
//
|
|
53
55
|
// Execution precondition (NOT-269): only a host that observes the account's
|
|
54
|
-
// provider traffic can answer `usage/read`.
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
// (`refreshMuseCapacityAfterSession` in coordinator/muse-spawn.ts)
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// for
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
// rows remain preserved.
|
|
56
|
+
// provider traffic can answer `usage/read`. NOT-278 removed developer turns
|
|
57
|
+
// from this host: every deck-enabled Muse developer turn runs the isolated
|
|
58
|
+
// `muse exec` lane (coordinator/muse-spawn.ts), which cannot observe the
|
|
59
|
+
// host, so the session-boundary refresh hook
|
|
60
|
+
// (`refreshMuseCapacityAfterSession` in coordinator/muse-spawn.ts) stays
|
|
61
|
+
// honest N/A with last-good rows preserved until a genuine turn observes
|
|
62
|
+
// the host again. If no genuine turn has populated a complete current pair
|
|
63
|
+
// for an hour, `muse-probe.ts` may use its own host for the bounded paid
|
|
64
|
+
// fallback. Per-session Agent Deck support for the serve protocol is a
|
|
65
|
+
// non-goal until the protocol can represent isolated session MCP identity.
|
|
65
66
|
//
|
|
66
67
|
// PRODUCT DECISION (2026-09-26, resolved via human_action on this ticket):
|
|
67
68
|
// the runner migration is in scope for NOT-270, in this same PR, not a
|
|
@@ -87,24 +88,43 @@
|
|
|
87
88
|
// shell restriction) is unaffected.
|
|
88
89
|
// This question was escalated twice on this ticket (product_scope_decision,
|
|
89
90
|
// resolved both times); the decision above is final for NOT-270's scope.
|
|
90
|
-
import { spawn } from "node:child_process";
|
|
91
|
+
import { execFile, spawn } from "node:child_process";
|
|
91
92
|
import fs from "node:fs";
|
|
92
93
|
import os from "node:os";
|
|
93
94
|
import path from "node:path";
|
|
94
95
|
import { MUSE_CLI_ENV, resolveMuseAuthFile, resolveMuseBin } from "../cli-env.js";
|
|
95
|
-
import {
|
|
96
|
+
import { getDataDir } from "../db/index.js";
|
|
97
|
+
import { parseMuseVersion } from "../adapters/muse-capability.js";
|
|
98
|
+
import { buildMuseBaseSettings } from "../runners/muse-config.js";
|
|
96
99
|
import { MSP_USAGE_CHANGED, MUSE_CLIENT_INFO, MUSE_SERVE_ARGV, assertMuseExecMethod, assertMuseReadOnlyMethod, hasMuseCredential, ingestMuseUsagePayload, museCapacityTimeoutMs, museClassifyRpcError, museEvidenceRef, museRefreshThrottleMs, museUuidv7, noteMuseCapacityFailure, parseMuseRpcLine, } from "./muse.js";
|
|
100
|
+
function readResolvedMuseVersion() {
|
|
101
|
+
return new Promise((resolve) => {
|
|
102
|
+
execFile(resolveMuseBin(), ["--version"], { encoding: "utf8", timeout: 30_000, env: { ...process.env, ...MUSE_CLI_ENV } }, (err, stdout) => resolve(err ? null : parseMuseVersion(stdout)));
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* NOT-273: dedicated cwd for the coordinator-owned `muse serve` child.
|
|
107
|
+
* The spawn must never inherit the server process's ambient working
|
|
108
|
+
* directory (in a maintainer's `npm run dev` checkout that is the repo
|
|
109
|
+
* itself, where a stray `git add . && git commit` in the child would land
|
|
110
|
+
* real commits on the checkout). Created if missing.
|
|
111
|
+
*/
|
|
112
|
+
export function resolveMuseHostCwd() {
|
|
113
|
+
const dir = path.join(getDataDir(), "capacity", "muse-host-home");
|
|
114
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
115
|
+
return dir;
|
|
116
|
+
}
|
|
97
117
|
/**
|
|
98
|
-
* NOT-
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* server-owned config home carrying
|
|
103
|
-
* symlink to the ambient login (linked, never read or copied; absent
|
|
104
|
-
* the operator authenticates with META_API_KEY), and an isolated data home
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* instance, reused across restarts, removed on shutdown.
|
|
118
|
+
* NOT-278: the owned host never runs a session — it only serves the read-only
|
|
119
|
+
* handshake/`usage/read` allowlist for capacity, and it is shared across decks, so it
|
|
120
|
+
* carries the deckless worker base posture (`buildMuseBaseSettings()`: no MCP servers, no
|
|
121
|
+
* subagent delegation, no workflows, no reminder child runs) and never inherits the
|
|
122
|
+
* operator's ambient MCP/subagent config. Builds a server-owned config home carrying those
|
|
123
|
+
* base settings plus a symlink to the ambient login (linked, never read or copied; absent
|
|
124
|
+
* when the operator authenticates with META_API_KEY), and an isolated data home for the
|
|
125
|
+
* host's own session/state. Deck-enabled developer turns run on the isolated exec lane
|
|
126
|
+
* (`coordinator/muse-spawn.ts`), each with its own required `agent-deck` server. Process-
|
|
127
|
+
* scoped: one home per host instance, reused across restarts, removed on shutdown.
|
|
108
128
|
*/
|
|
109
129
|
export function prepareMuseServeHome(ambientAuthFile = resolveMuseAuthFile()) {
|
|
110
130
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-serve-"));
|
|
@@ -116,7 +136,7 @@ export function prepareMuseServeHome(ambientAuthFile = resolveMuseAuthFile()) {
|
|
|
116
136
|
fs.chmodSync(root, 0o700);
|
|
117
137
|
fs.chmodSync(configHome, 0o700);
|
|
118
138
|
fs.chmodSync(dataHome, 0o700);
|
|
119
|
-
fs.writeFileSync(path.join(configHome, "muse", "settings.json"), `${JSON.stringify(
|
|
139
|
+
fs.writeFileSync(path.join(configHome, "muse", "settings.json"), `${JSON.stringify(buildMuseBaseSettings(), null, 2)}\n`, { mode: 0o600 });
|
|
120
140
|
let authLinked = false;
|
|
121
141
|
try {
|
|
122
142
|
if (fs.existsSync(ambientAuthFile)) {
|
|
@@ -171,6 +191,14 @@ export class MuseCapacityHost {
|
|
|
171
191
|
observedOnConnection = false;
|
|
172
192
|
/** Increments on every spawn — tests use it to prove host reuse/restart. */
|
|
173
193
|
connectionEpoch = 0;
|
|
194
|
+
/**
|
|
195
|
+
* NOT-277: the Muse version the live child runs — the on-disk binary's reported version, read
|
|
196
|
+
* immediately before the spawn and again after the handshake; null when unknown or when the
|
|
197
|
+
* binary changed across the spawn. A long-lived host keeps running the build it started on
|
|
198
|
+
* after an auto-update, so developer work only uses it when this is the capability-checked
|
|
199
|
+
* version (see `ensureStartedOnVersion`).
|
|
200
|
+
*/
|
|
201
|
+
runningVersion = null;
|
|
174
202
|
/**
|
|
175
203
|
* Server-owned XDG home for the host (worker settings + auth link).
|
|
176
204
|
* Built lazily on first spawn, reused across restarts, removed on
|
|
@@ -199,9 +227,9 @@ export class MuseCapacityHost {
|
|
|
199
227
|
/**
|
|
200
228
|
* Env for the host subprocess: the server-owned XDG home always wins over
|
|
201
229
|
* inherited ambient config (the server process itself may run under an
|
|
202
|
-
* operator XDG home), so
|
|
203
|
-
*
|
|
204
|
-
*
|
|
230
|
+
* operator XDG home), so the host never inherits ambient MCP servers,
|
|
231
|
+
* subagents, or workflows. Only an explicit XDG override in `opts.env`
|
|
232
|
+
* (tests) still wins.
|
|
205
233
|
*/
|
|
206
234
|
mergedEnv() {
|
|
207
235
|
const home = this.ensureServeHome();
|
|
@@ -241,6 +269,7 @@ export class MuseCapacityHost {
|
|
|
241
269
|
const child = this.child;
|
|
242
270
|
this.child = null;
|
|
243
271
|
this.dead = true;
|
|
272
|
+
this.runningVersion = null;
|
|
244
273
|
for (const [, p] of this.pending) {
|
|
245
274
|
try {
|
|
246
275
|
p.resolve({});
|
|
@@ -457,8 +486,29 @@ export class MuseCapacityHost {
|
|
|
457
486
|
this.starting = null;
|
|
458
487
|
}
|
|
459
488
|
}
|
|
489
|
+
/**
|
|
490
|
+
* NOT-277: start (or reuse) the host, and report whether it runs `version`. A live host on any
|
|
491
|
+
* other (or an unknown) version is restarted onto the current on-disk binary when it is idle;
|
|
492
|
+
* while the connection is in use it is left alone and this returns false. Never true for a
|
|
493
|
+
* host not known to run `version`. (NOT-278: no developer turn runs on the host any more —
|
|
494
|
+
* this only gates capacity reads, which are version-independent.)
|
|
495
|
+
*/
|
|
496
|
+
async ensureStartedOnVersion(version) {
|
|
497
|
+
if (!(await this.ensureStarted()))
|
|
498
|
+
return false;
|
|
499
|
+
if (this.runningVersion === version)
|
|
500
|
+
return true;
|
|
501
|
+
if (this.hasInflightExecution() || this.inflightRead)
|
|
502
|
+
return false;
|
|
503
|
+
this.killChild();
|
|
504
|
+
if (!(await this.ensureStarted()))
|
|
505
|
+
return false;
|
|
506
|
+
return this.runningVersion === version;
|
|
507
|
+
}
|
|
460
508
|
async start() {
|
|
461
509
|
this.killChild();
|
|
510
|
+
const readVersion = this.opts.readVersion ?? (this.opts.command ? async () => null : readResolvedMuseVersion);
|
|
511
|
+
const versionBefore = await readVersion();
|
|
462
512
|
const timeoutMs = this.opts.timeoutMs ?? museCapacityTimeoutMs();
|
|
463
513
|
const mergedEnv = this.mergedEnv();
|
|
464
514
|
if (!hasMuseCredential(mergedEnv, this.opts.authFilePath ?? resolveMuseAuthFile())) {
|
|
@@ -469,7 +519,12 @@ export class MuseCapacityHost {
|
|
|
469
519
|
const args = this.opts.args ?? [...MUSE_SERVE_ARGV];
|
|
470
520
|
let child;
|
|
471
521
|
try {
|
|
472
|
-
|
|
522
|
+
// NOT-273: explicit cwd — never inherit the server process's ambient
|
|
523
|
+
// working directory (which in dev is the repo checkout itself).
|
|
524
|
+
const cwd = this.opts.cwd ?? resolveMuseHostCwd();
|
|
525
|
+
const spawnFn = this.opts.spawnImpl ?? spawn;
|
|
526
|
+
child = spawnFn(command, args, {
|
|
527
|
+
cwd,
|
|
473
528
|
stdio: ["pipe", "pipe", "pipe"],
|
|
474
529
|
env: { ...mergedEnv, ...MUSE_CLI_ENV },
|
|
475
530
|
});
|
|
@@ -566,6 +621,11 @@ export class MuseCapacityHost {
|
|
|
566
621
|
this.killChild();
|
|
567
622
|
return false;
|
|
568
623
|
}
|
|
624
|
+
// The spawned build is the on-disk one only if the binary did not change across the spawn.
|
|
625
|
+
const versionAfter = await readVersion();
|
|
626
|
+
if (this.isCurrentChild(child)) {
|
|
627
|
+
this.runningVersion = versionBefore !== null && versionBefore === versionAfter ? versionBefore : null;
|
|
628
|
+
}
|
|
569
629
|
return true;
|
|
570
630
|
}
|
|
571
631
|
startFailure = null;
|
|
@@ -21,7 +21,7 @@ import fs from "node:fs";
|
|
|
21
21
|
import os from "node:os";
|
|
22
22
|
import path from "node:path";
|
|
23
23
|
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-host-"));
|
|
24
|
-
import { getMuseCapacityHost, maybeRefreshMuseCapacityFromHost, MuseCapacityHost, prepareMuseServeHome, refreshMuseCapacityFromHost, resetMuseCapacityHostForTests, resetMuseCapacityRefreshState, shutdownMuseCapacityHost, } from "./muse-host.js";
|
|
24
|
+
import { getMuseCapacityHost, maybeRefreshMuseCapacityFromHost, MuseCapacityHost, prepareMuseServeHome, refreshMuseCapacityFromHost, resetMuseCapacityHostForTests, resetMuseCapacityRefreshState, resolveMuseHostCwd, shutdownMuseCapacityHost, } from "./muse-host.js";
|
|
25
25
|
import { ingestMuseUsagePayload, museRefreshThrottleMs, noteMuseCapacityFailure, } from "./muse.js";
|
|
26
26
|
const { migrate } = await import("../db/index.js");
|
|
27
27
|
const { clearAllCapacitySnapshots, listCapacitySnapshots } = await import("../repository/runtime-capacity.js");
|
|
@@ -342,6 +342,66 @@ test("a stale child's late close cannot kill its replacement", async () => {
|
|
|
342
342
|
await host.shutdown();
|
|
343
343
|
}
|
|
344
344
|
});
|
|
345
|
+
// NOT-277: developer work is only served on a host known to run the capability-checked build.
|
|
346
|
+
function versionedHost(versions) {
|
|
347
|
+
let i = 0;
|
|
348
|
+
const readVersion = async () => versions[Math.min(i++, versions.length - 1)] ?? null;
|
|
349
|
+
return new MuseCapacityHost({ ...hostOpts("persistent-full"), readVersion });
|
|
350
|
+
}
|
|
351
|
+
test("the host records the version it was spawned on; a matching host is reused", async () => {
|
|
352
|
+
const host = versionedHost(["1.4.0-R4161.1"]);
|
|
353
|
+
try {
|
|
354
|
+
assert.equal(await host.ensureStartedOnVersion("1.4.0-R4161.1"), true);
|
|
355
|
+
assert.equal(host.runningVersion, "1.4.0-R4161.1");
|
|
356
|
+
assert.equal(await host.ensureStartedOnVersion("1.4.0-R4161.1"), true);
|
|
357
|
+
assert.equal(host.connectionEpoch, 1, "no restart when the host already runs the version");
|
|
358
|
+
}
|
|
359
|
+
finally {
|
|
360
|
+
await host.shutdown();
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
test("an idle host on an older build is restarted onto the confirmed version", async () => {
|
|
364
|
+
// Spawned on B (before + after the handshake), then the binary is C for the restart.
|
|
365
|
+
const host = versionedHost(["1.4.0-R4161.1", "1.4.0-R4161.1", "1.4.0-R4302.1"]);
|
|
366
|
+
try {
|
|
367
|
+
assert.ok(await host.ensureStarted());
|
|
368
|
+
assert.equal(host.runningVersion, "1.4.0-R4161.1");
|
|
369
|
+
assert.equal(await host.ensureStartedOnVersion("1.4.0-R4302.1"), true);
|
|
370
|
+
assert.equal(host.connectionEpoch, 2, "restarted onto the checked build");
|
|
371
|
+
assert.equal(host.runningVersion, "1.4.0-R4302.1");
|
|
372
|
+
}
|
|
373
|
+
finally {
|
|
374
|
+
await host.shutdown();
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
test("a busy host on an older build is left alone and not used", async () => {
|
|
378
|
+
const host = versionedHost(["1.4.0-R4161.1", "1.4.0-R4161.1", "1.4.0-R4302.1"]);
|
|
379
|
+
try {
|
|
380
|
+
assert.ok(await host.ensureStarted());
|
|
381
|
+
// An admitted turn on the shared connection: never torn down under it.
|
|
382
|
+
const waiting = host.waitForHostNotification(() => false, 5_000);
|
|
383
|
+
assert.equal(await host.ensureStartedOnVersion("1.4.0-R4302.1"), false);
|
|
384
|
+
assert.equal(host.connectionEpoch, 1);
|
|
385
|
+
assert.ok(host.isConnected());
|
|
386
|
+
await host.shutdown();
|
|
387
|
+
await waiting;
|
|
388
|
+
}
|
|
389
|
+
finally {
|
|
390
|
+
await host.shutdown();
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
test("a binary that changed across the spawn leaves the host's version unknown (never served)", async () => {
|
|
394
|
+
// Changes across the first spawn and again across the restart.
|
|
395
|
+
const host = versionedHost(["1.4.0-R4161.1", "1.4.0-R4302.1", "1.4.0-R4302.1", "1.5.0-R5000.1"]);
|
|
396
|
+
try {
|
|
397
|
+
assert.equal(await host.ensureStartedOnVersion("1.4.0-R4302.1"), false);
|
|
398
|
+
assert.equal(host.connectionEpoch, 2);
|
|
399
|
+
assert.equal(host.runningVersion, null);
|
|
400
|
+
}
|
|
401
|
+
finally {
|
|
402
|
+
await host.shutdown();
|
|
403
|
+
}
|
|
404
|
+
});
|
|
345
405
|
test("clean shutdown releases the child process", async () => {
|
|
346
406
|
const host = new MuseCapacityHost(hostOpts("persistent-full"));
|
|
347
407
|
assert.equal((await host.readUsage()).status, "observed");
|
|
@@ -413,9 +473,11 @@ test("serve host launches under a server-owned config home with worker settings"
|
|
|
413
473
|
assert.ok(!String(rec.dataHome).includes(".config"), "data home is not the operator's");
|
|
414
474
|
assert.equal(rec.settingsError, null, "settings.json parses on the launched host");
|
|
415
475
|
const settings = rec.settings;
|
|
416
|
-
//
|
|
417
|
-
// servers, no subagent delegation, no workflows, no
|
|
418
|
-
|
|
476
|
+
// NOT-278: the host runs no sessions, so it carries the deckless worker base posture
|
|
477
|
+
// (`buildMuseBaseSettings`): no MCP servers, no subagent delegation, no workflows, no
|
|
478
|
+
// reminder child runs. Deck-enabled developer turns each carry their own required
|
|
479
|
+
// `agent-deck` server on the isolated exec lane.
|
|
480
|
+
assert.ok(!("mcpServers" in settings), "the host carries no MCP servers");
|
|
419
481
|
const run = settings.run;
|
|
420
482
|
assert.equal(run.workflow_trigger_mode, "off");
|
|
421
483
|
assert.equal(run.subagent_delegation_mode, "off");
|
|
@@ -458,6 +520,70 @@ test("prepareMuseServeHome links the ambient login, or records API-key auth", as
|
|
|
458
520
|
}
|
|
459
521
|
fs.rmSync(ambientRoot, { recursive: true, force: true });
|
|
460
522
|
});
|
|
523
|
+
test("NOT-273: the serve child spawns with an explicit cwd under the managed data dir", async () => {
|
|
524
|
+
const { EventEmitter } = await import("node:events");
|
|
525
|
+
const { getDataDir } = await import("../db/index.js");
|
|
526
|
+
const seen = [];
|
|
527
|
+
const hangingChild = () => {
|
|
528
|
+
const events = new EventEmitter();
|
|
529
|
+
const fake = {
|
|
530
|
+
stdin: { write: () => true, on: () => ({}) },
|
|
531
|
+
stdout: { on: () => ({}) },
|
|
532
|
+
stderr: { on: () => ({}) },
|
|
533
|
+
on: events.on.bind(events),
|
|
534
|
+
// The handshake never answers, but a kill() must still settle the
|
|
535
|
+
// child like a real process would — otherwise shutdown()'s teardown
|
|
536
|
+
// has nothing but its 2s unref'd SIGKILL-escalation timer to resolve
|
|
537
|
+
// on, which races the test runner's own event-loop bookkeeping and
|
|
538
|
+
// can cancel the test outright (see the child-ticket playbook gotcha:
|
|
539
|
+
// never await an infinite fake handler).
|
|
540
|
+
kill: () => {
|
|
541
|
+
fake.exitCode = 0;
|
|
542
|
+
queueMicrotask(() => events.emit("close"));
|
|
543
|
+
return true;
|
|
544
|
+
},
|
|
545
|
+
exitCode: null,
|
|
546
|
+
};
|
|
547
|
+
return fake;
|
|
548
|
+
};
|
|
549
|
+
const host = new MuseCapacityHost({
|
|
550
|
+
command: process.execPath,
|
|
551
|
+
args: [FAKE],
|
|
552
|
+
env: { META_API_KEY: "test-fake-key" },
|
|
553
|
+
// Handshake never answers — start() still records the exact spawn options.
|
|
554
|
+
timeoutMs: 100,
|
|
555
|
+
spawnImpl: ((command, args, options) => {
|
|
556
|
+
seen.push({ command, args, options });
|
|
557
|
+
return hangingChild();
|
|
558
|
+
}),
|
|
559
|
+
});
|
|
560
|
+
// This test's fake spawn has no real child process, so nothing else keeps
|
|
561
|
+
// the event loop busy while the production code's handshake/kill timers
|
|
562
|
+
// (deliberately unref'd — a real server must never block on them) are
|
|
563
|
+
// pending. With zero other ref'd handles, some Node versions' test runner
|
|
564
|
+
// treats the loop as "drained" before those timers fire and cancels the
|
|
565
|
+
// test outright (cancelledByParent). A trivial ref'd keepalive avoids the
|
|
566
|
+
// race without touching the production unref'd-timer behavior being tested.
|
|
567
|
+
const keepalive = setInterval(() => { }, 10);
|
|
568
|
+
try {
|
|
569
|
+
// Missing (handshake timeout), not a spawn failure — the spawn happened.
|
|
570
|
+
assert.equal((await host.readUsage()).status, "missing");
|
|
571
|
+
assert.equal(seen.length, 1, "exactly one serve spawn");
|
|
572
|
+
const cwd = seen[0].options.cwd;
|
|
573
|
+
assert.equal(typeof cwd, "string", "spawn options include an explicit cwd");
|
|
574
|
+
const dir = cwd;
|
|
575
|
+
assert.ok(path.isAbsolute(dir), `cwd is absolute, got ${dir}`);
|
|
576
|
+
assert.notEqual(path.resolve(dir), path.resolve(process.cwd()), "cwd is not the ambient process cwd");
|
|
577
|
+
assert.ok(dir.startsWith(`${getDataDir()}${path.sep}`), `cwd lives under the managed data dir, got ${dir}`);
|
|
578
|
+
assert.equal(path.basename(dir), "muse-host-home");
|
|
579
|
+
assert.ok(fs.existsSync(dir), "the dedicated cwd is created if missing");
|
|
580
|
+
assert.equal(resolveMuseHostCwd(), dir, "default cwd is the dedicated data-dir home");
|
|
581
|
+
}
|
|
582
|
+
finally {
|
|
583
|
+
await host.shutdown();
|
|
584
|
+
clearInterval(keepalive);
|
|
585
|
+
}
|
|
586
|
+
});
|
|
461
587
|
test("no credential short-circuits to missing without spawning", async () => {
|
|
462
588
|
const noAuthDir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-host-noauth-"));
|
|
463
589
|
const env = { ...process.env, FAKE_MSP_MODE: "persistent-full" };
|
|
@@ -401,6 +401,91 @@ test("NOT-178: a Muse Code developer without credentials is refused at admission
|
|
|
401
401
|
setAdmissionHealthCheckerForTests(async () => ({ ok: true }));
|
|
402
402
|
}
|
|
403
403
|
});
|
|
404
|
+
// NOT-277: a Muse Code auto-update is re-validated automatically. A version whose developer
|
|
405
|
+
// shell/write check passes is admitted with no manual step; one that lost it is refused with the
|
|
406
|
+
// versions and capability named; one whose check could not complete is refused, never assumed ok.
|
|
407
|
+
test("NOT-277: a Muse Code update is admitted when capable, refused by name when not", async () => {
|
|
408
|
+
const { clearAgentHealthCaches, runtimeIssuesUncached } = await import("../adapters/agent-health.js");
|
|
409
|
+
const { setMuseCapabilityProbeForTests, settleMuseCapabilityCheckForTests } = await import("../adapters/muse-capability.js");
|
|
410
|
+
const OLD = "1.3.0-R3401.1";
|
|
411
|
+
const NEW = "1.4.0-R4161.1";
|
|
412
|
+
const BROKEN = "1.4.0-R4302.1";
|
|
413
|
+
const stubDir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-stub-"));
|
|
414
|
+
const versionFile = path.join(stubDir, "version");
|
|
415
|
+
const stub = path.join(stubDir, "muse");
|
|
416
|
+
fs.writeFileSync(stub, `#!/bin/sh\ncat ${JSON.stringify(versionFile)}\n`);
|
|
417
|
+
fs.chmodSync(stub, 0o755);
|
|
418
|
+
// The real checker's async path runs `gh auth status` too; CI runners have no gh login,
|
|
419
|
+
// so a logged-in stub keeps the `ok: true` assertions about Muse alone.
|
|
420
|
+
const ghStub = path.join(stubDir, "gh");
|
|
421
|
+
fs.writeFileSync(ghStub, "#!/bin/sh\necho 'Logged in to github.com account dealer-test'\nexit 0\n");
|
|
422
|
+
fs.chmodSync(ghStub, 0o755);
|
|
423
|
+
const probed = [];
|
|
424
|
+
setMuseCapabilityProbeForTests(async (version) => {
|
|
425
|
+
probed.push(version);
|
|
426
|
+
if (version === BROKEN)
|
|
427
|
+
return { status: "missing", detail: "probe session completed without running its shell command" };
|
|
428
|
+
if (version === "9.9.9-R1")
|
|
429
|
+
throw new Error("probe session timed out");
|
|
430
|
+
return { status: "capable" };
|
|
431
|
+
});
|
|
432
|
+
const saved = {
|
|
433
|
+
MUSE_CLI: process.env.MUSE_CLI,
|
|
434
|
+
META_API_KEY: process.env.META_API_KEY,
|
|
435
|
+
SKIP: process.env.AGENT_DEALER_SKIP_AGENT_HEALTH,
|
|
436
|
+
PATH: process.env.PATH,
|
|
437
|
+
};
|
|
438
|
+
process.env.MUSE_CLI = stub;
|
|
439
|
+
process.env.PATH = `${stubDir}${path.delimiter}${process.env.PATH ?? ""}`;
|
|
440
|
+
process.env.META_API_KEY = "k";
|
|
441
|
+
delete process.env.AGENT_DEALER_SKIP_AGENT_HEALTH;
|
|
442
|
+
setAdmissionHealthCheckerForTests(null);
|
|
443
|
+
clearAgentHealthCaches();
|
|
444
|
+
/** Muse reports `version`; the next health read sees it and its one-time check settles. */
|
|
445
|
+
const museReports = async (version) => {
|
|
446
|
+
fs.writeFileSync(versionFile, `Muse Code ${version.split("-")[0]} (${version})\n`);
|
|
447
|
+
await runtimeIssuesUncached("muse_code");
|
|
448
|
+
await settleMuseCapabilityCheckForTests();
|
|
449
|
+
};
|
|
450
|
+
try {
|
|
451
|
+
const issue = readyIssue("muse-updated", { runtimes: { dev: "muse_code", rev: "codex_local" } });
|
|
452
|
+
const developerHealth = () => checkRoleAgentHealthy(issue, "developer", { deckOnline: true });
|
|
453
|
+
await museReports(OLD);
|
|
454
|
+
assert.deepEqual(await developerHealth(), { ok: true });
|
|
455
|
+
await museReports(NEW);
|
|
456
|
+
assert.deepEqual(await developerHealth(), { ok: true });
|
|
457
|
+
assert.deepEqual(probed, [OLD, NEW]);
|
|
458
|
+
await museReports(BROKEN);
|
|
459
|
+
enqueueIssue(issue.id);
|
|
460
|
+
assert.equal(await admitNext(), null);
|
|
461
|
+
assert.equal(getIssue(issue.id).status, "ready");
|
|
462
|
+
assert.equal(getActiveWorkflowInstance(issue.id), null);
|
|
463
|
+
const reason = getQueuedEntryForIssue(issue.id).waitReason;
|
|
464
|
+
assert.match(reason, /developer unhealthy/);
|
|
465
|
+
assert.match(reason, new RegExp(`Muse Code updated ${NEW} → ${BROKEN}: developer sessions no longer get shell/write access`));
|
|
466
|
+
await museReports("9.9.9-R1");
|
|
467
|
+
const unverified = await developerHealth();
|
|
468
|
+
assert.equal(unverified.ok, false);
|
|
469
|
+
assert.match(unverified.reason, new RegExp(`Could not verify Muse Code developer shell/write access after version change \\(${BROKEN} → 9\\.9\\.9-R1\\)`));
|
|
470
|
+
assert.deepEqual(probed, [OLD, NEW, BROKEN, "9.9.9-R1"]);
|
|
471
|
+
}
|
|
472
|
+
finally {
|
|
473
|
+
for (const [key, value] of Object.entries({
|
|
474
|
+
MUSE_CLI: saved.MUSE_CLI,
|
|
475
|
+
META_API_KEY: saved.META_API_KEY,
|
|
476
|
+
AGENT_DEALER_SKIP_AGENT_HEALTH: saved.SKIP,
|
|
477
|
+
PATH: saved.PATH,
|
|
478
|
+
})) {
|
|
479
|
+
if (value === undefined)
|
|
480
|
+
delete process.env[key];
|
|
481
|
+
else
|
|
482
|
+
process.env[key] = value;
|
|
483
|
+
}
|
|
484
|
+
setMuseCapabilityProbeForTests(null);
|
|
485
|
+
clearAgentHealthCaches();
|
|
486
|
+
setAdmissionHealthCheckerForTests(async () => ({ ok: true }));
|
|
487
|
+
}
|
|
488
|
+
});
|
|
404
489
|
// NOT-178: Muse Code is developer-only, so it is never a healthy reviewer — even with the
|
|
405
490
|
// unit-test health skip on, and without probing the CLI.
|
|
406
491
|
test("NOT-178: a Muse Code reviewer is refused regardless of CLI health", async () => {
|
|
@@ -33,9 +33,10 @@ function claudeDisallowedTools(policy) {
|
|
|
33
33
|
}
|
|
34
34
|
function buildArgs(runtime, policy, prompt, model, mcpConfigPath, effort) {
|
|
35
35
|
if (runtime === "muse_code") {
|
|
36
|
-
// Muse's developer argv is built by runners/muse-
|
|
37
|
-
//
|
|
38
|
-
// for. Falling through to the Claude arm below
|
|
36
|
+
// Muse's developer argv is built by runners/muse-config.ts (`prepareMuseAttempt`,
|
|
37
|
+
// NOT-278) inside coordinator/muse-spawn.ts; it needs a per-attempt deck, session id, and
|
|
38
|
+
// XDG dirs this pure builder has no place for. Falling through to the Claude arm below
|
|
39
|
+
// would spawn `muse` with Claude flags.
|
|
39
40
|
throw new Error("muse_code is not wired through the shared arg builder — its developer argv is built in coordinator/muse-spawn.ts");
|
|
40
41
|
}
|
|
41
42
|
if (runtime === "codex_local") {
|
|
@@ -26,9 +26,11 @@ export function emitCheckpointObserved(ev) {
|
|
|
26
26
|
samplingPrecisionMs: ev.samplingPrecisionMs ?? null,
|
|
27
27
|
branch: ev.branch ?? null,
|
|
28
28
|
},
|
|
29
|
-
idempotencyKey: ev.
|
|
30
|
-
? `checkpoint:${ev.
|
|
31
|
-
:
|
|
29
|
+
idempotencyKey: ev.resumeSalvage && ev.observedSha
|
|
30
|
+
? `checkpoint:resume-salvage:${ev.issueId}:${ev.observedSha}`
|
|
31
|
+
: ev.kind === "commit" && ev.origin === "salvage"
|
|
32
|
+
? `checkpoint:${ev.workerSessionId}:commit:salvage`
|
|
33
|
+
: `checkpoint:${ev.workerSessionId}:${ev.kind}`,
|
|
32
34
|
});
|
|
33
35
|
}
|
|
34
36
|
/**
|
|
@@ -167,3 +167,26 @@ test("checkpoints attribute per session across attempts of one issue", async ()
|
|
|
167
167
|
assert.deepEqual(records.map((r) => r.sessionId).sort(), [sessionId, second.id].sort());
|
|
168
168
|
assert.deepEqual(records.map((r) => r.observedSha).sort(), [COMMIT_SHA, "c".repeat(40)].sort());
|
|
169
169
|
});
|
|
170
|
+
test("NOT-280: resume-salvage checkpoints dedupe per issue on restart but never collide across issues with an identical commit SHA", async () => {
|
|
171
|
+
const a = await setup();
|
|
172
|
+
const b = await setup();
|
|
173
|
+
const emit = (ctx) => emitCheckpointObserved({
|
|
174
|
+
issueId: ctx.issueId,
|
|
175
|
+
workflowInstanceId: ctx.instanceId,
|
|
176
|
+
workerSessionId: ctx.sessionId,
|
|
177
|
+
role: "developer",
|
|
178
|
+
stage: "developing",
|
|
179
|
+
round: 1,
|
|
180
|
+
kind: "commit",
|
|
181
|
+
observedSha: COMMIT_SHA,
|
|
182
|
+
origin: "salvage",
|
|
183
|
+
resumeSalvage: true,
|
|
184
|
+
});
|
|
185
|
+
emit(a);
|
|
186
|
+
emit(a); // restart re-detecting the same saved tip
|
|
187
|
+
emit(b); // another issue whose salvage produced the identical commit object
|
|
188
|
+
assert.equal(checkpointsOf(a.issueId, "commit").length, 1, "restart never duplicates the row");
|
|
189
|
+
assert.equal(checkpointsOf(b.issueId, "commit").length, 1, "the second issue still gets its own checkpoint");
|
|
190
|
+
const [row] = listWorkflowEventsForIssue(b.issueId).filter((e) => e.type === "checkpoint.observed");
|
|
191
|
+
assert.equal(row.workerSessionId, b.sessionId, "tied to its own predecessor session");
|
|
192
|
+
});
|