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.
Files changed (72) hide show
  1. package/bundle/server/dist/adapters/agent-deck-bind.js +33 -3
  2. package/bundle/server/dist/adapters/agent-deck-bind.test.js +60 -2
  3. package/bundle/server/dist/adapters/agent-health.js +28 -6
  4. package/bundle/server/dist/adapters/agent-health.test.js +66 -11
  5. package/bundle/server/dist/adapters/git-worktree.js +187 -18
  6. package/bundle/server/dist/adapters/git-worktree.test.js +293 -0
  7. package/bundle/server/dist/adapters/github.js +110 -12
  8. package/bundle/server/dist/adapters/github.test.js +274 -3
  9. package/bundle/server/dist/adapters/muse-capability.js +356 -0
  10. package/bundle/server/dist/adapters/muse-capability.test.js +464 -0
  11. package/bundle/server/dist/capacity/claude-local-cache.js +266 -81
  12. package/bundle/server/dist/capacity/claude-local-cache.test.js +335 -36
  13. package/bundle/server/dist/capacity/muse-host.js +92 -32
  14. package/bundle/server/dist/capacity/muse-host.test.js +130 -4
  15. package/bundle/server/dist/coordinator/admission.test.js +85 -0
  16. package/bundle/server/dist/coordinator/args.js +4 -3
  17. package/bundle/server/dist/coordinator/checkpoint.js +5 -3
  18. package/bundle/server/dist/coordinator/checkpoint.test.js +23 -0
  19. package/bundle/server/dist/coordinator/commands.js +100 -12
  20. package/bundle/server/dist/coordinator/developer-effect.js +128 -16
  21. package/bundle/server/dist/coordinator/developer-effect.test.js +291 -12
  22. package/bundle/server/dist/coordinator/human-resolution.js +33 -1
  23. package/bundle/server/dist/coordinator/muse-developer.integration.test.js +291 -55
  24. package/bundle/server/dist/coordinator/muse-spawn.js +72 -164
  25. package/bundle/server/dist/coordinator/projection.js +1 -0
  26. package/bundle/server/dist/coordinator/prompts.js +25 -6
  27. package/bundle/server/dist/coordinator/prompts.test.js +36 -8
  28. package/bundle/server/dist/coordinator/routing.js +1 -0
  29. package/bundle/server/dist/coordinator/routing.test.js +20 -0
  30. package/bundle/server/dist/coordinator/spawn.js +9 -1
  31. package/bundle/server/dist/coordinator/worktree-cwd-guard.js +48 -0
  32. package/bundle/server/dist/coordinator/worktree-cwd-guard.test.js +83 -0
  33. package/bundle/server/dist/coordinator/worktree-owner-liveness.js +6 -1
  34. package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +2 -1
  35. package/bundle/server/dist/repository/human-actions.js +13 -0
  36. package/bundle/server/dist/routes/human-actions.js +10 -1
  37. package/bundle/server/dist/routes/human-actions.test.js +117 -1
  38. package/bundle/server/dist/routes/index.js +12 -5
  39. package/bundle/server/dist/routes/runtime-capacity.test.js +3 -2
  40. package/bundle/server/dist/routes/version.js +30 -0
  41. package/bundle/server/dist/routes/version.test.js +21 -0
  42. package/bundle/server/dist/runners/muse-config-core.js +63 -16
  43. package/bundle/server/dist/runners/muse-config.js +1 -1
  44. package/bundle/server/dist/runners/muse-config.test.js +135 -29
  45. package/bundle/server/dist/runners/spawn-cli.js +17 -3
  46. package/bundle/server/dist/runners/spawn-cli.test.js +59 -0
  47. package/bundle/server/package.json +2 -2
  48. package/bundle/server/static-ui/assets/{index-CYqRh_-S.css → index-BII-LgB8.css} +1 -1
  49. package/bundle/server/static-ui/assets/index-CoDzZcsz.js +60 -0
  50. package/bundle/server/static-ui/index.html +2 -2
  51. package/bundle/shared/dist/agents.d.ts +15 -15
  52. package/bundle/shared/dist/agents.js +6 -0
  53. package/bundle/shared/dist/index.d.ts +7 -7
  54. package/bundle/shared/package.json +1 -1
  55. package/dist/doctor.d.ts +7 -3
  56. package/dist/doctor.js +21 -13
  57. package/dist/doctor.test.js +9 -8
  58. package/dist/managed/index.d.ts +1 -1
  59. package/dist/managed/index.js +1 -1
  60. package/dist/managed/updater.d.ts +16 -6
  61. package/dist/managed/updater.js +28 -11
  62. package/dist/managed-update-restart.test.d.ts +1 -0
  63. package/dist/managed-update-restart.test.js +227 -0
  64. package/dist/ports.d.ts +6 -0
  65. package/dist/ports.js +10 -0
  66. package/dist/runtime-state.d.ts +10 -0
  67. package/dist/runtime-state.js +26 -0
  68. package/dist/start.js +67 -2
  69. package/dist/status.js +30 -2
  70. package/dist/update-check.js +3 -6
  71. package/package.json +1 -1
  72. 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 same worker posture as the exec lane's
29
- // per-attempt settings (no MCP servers, no subagents, no workflows, no
30
- // reminders) plus a symlink to the ambient login, so serve-lane turns
31
- // never inherit the operator's ambient config.
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`. Real Dealer Muse turns run
55
- // through THIS host (`session/start` + `turn/start` via the execution lane
56
- // below, driven by runners/muse-serve-session.ts) — that traffic is the
57
- // observation, so the session-boundary refresh hook
58
- // (`refreshMuseCapacityAfterSession` in coordinator/muse-spawn.ts) is the
59
- // final `usage/read` that populates 5H/1W. If no genuine turn has populated
60
- // a complete current pair for an hour, `muse-probe.ts` may use its own host
61
- // for the bounded paid fallback. When the normal serve execution lane cannot
62
- // admit a developer turn, that session still falls back to the legacy
63
- // `muse exec` subprocess before any model work starts; last-good capacity
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 { buildMuseDeveloperSettings } from "../runners/muse-code-settings.js";
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-270 serve lane: the owned host must run workers under the same
99
- * posture as the exec lane's per-attempt settings — no MCP servers, no
100
- * subagent delegation, no workflows, no reminder child runs — so serve-lane
101
- * turns never inherit the operator's ambient MCP/subagent config. Builds a
102
- * server-owned config home carrying `buildMuseDeveloperSettings()` plus a
103
- * symlink to the ambient login (linked, never read or copied; absent when
104
- * the operator authenticates with META_API_KEY), and an isolated data home
105
- * for the host's own session/state. Mirrors the exec lane's per-attempt
106
- * dirs (`coordinator/muse-spawn.ts`), but process-scoped: one home per host
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(buildMuseDeveloperSettings(), null, 2)}\n`, { mode: 0o600 });
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 serve-lane workers never inherit ambient MCP
203
- * servers, subagents, or workflows. Only an explicit XDG override in
204
- * `opts.env` (tests) still wins.
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
- child = spawn(command, args, {
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
- // Same worker posture as the exec lane's per-attempt settings: no MCP
417
- // servers, no subagent delegation, no workflows, no reminder child runs.
418
- assert.ok(!("mcpServers" in settings), "workers get no MCP servers");
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-code-args.ts inside coordinator/muse-spawn.ts
37
- // (NOT-181); it needs a per-attempt session id and XDG dirs this pure builder has no place
38
- // for. Falling through to the Claude arm below would spawn `muse` with Claude flags.
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.kind === "commit" && ev.origin === "salvage"
30
- ? `checkpoint:${ev.workerSessionId}:commit:salvage`
31
- : `checkpoint:${ev.workerSessionId}:${ev.kind}`,
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
+ });