agent-dealer 1.2.4 → 1.2.5

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 (38) hide show
  1. package/bundle/server/dist/adapters/agent-health.js +25 -2
  2. package/bundle/server/dist/adapters/agent-health.test.js +55 -2
  3. package/bundle/server/dist/adapters/github.js +110 -12
  4. package/bundle/server/dist/adapters/github.test.js +274 -3
  5. package/bundle/server/dist/adapters/muse-capability.js +330 -0
  6. package/bundle/server/dist/adapters/muse-capability.test.js +378 -0
  7. package/bundle/server/dist/capacity/claude-local-cache.js +217 -64
  8. package/bundle/server/dist/capacity/claude-local-cache.test.js +174 -29
  9. package/bundle/server/dist/capacity/muse-host.js +60 -2
  10. package/bundle/server/dist/capacity/muse-host.test.js +125 -1
  11. package/bundle/server/dist/coordinator/admission.test.js +85 -0
  12. package/bundle/server/dist/coordinator/commands.js +35 -8
  13. package/bundle/server/dist/coordinator/developer-effect.js +40 -6
  14. package/bundle/server/dist/coordinator/developer-effect.test.js +121 -12
  15. package/bundle/server/dist/coordinator/human-resolution.js +31 -1
  16. package/bundle/server/dist/coordinator/muse-spawn.js +12 -2
  17. package/bundle/server/dist/coordinator/prompts.js +15 -0
  18. package/bundle/server/dist/coordinator/prompts.test.js +20 -0
  19. package/bundle/server/dist/coordinator/spawn.js +7 -0
  20. package/bundle/server/dist/coordinator/worktree-cwd-guard.js +48 -0
  21. package/bundle/server/dist/coordinator/worktree-cwd-guard.test.js +83 -0
  22. package/bundle/server/dist/routes/human-actions.js +10 -1
  23. package/bundle/server/dist/routes/human-actions.test.js +117 -1
  24. package/bundle/server/dist/routes/index.js +9 -5
  25. package/bundle/server/dist/routes/runtime-capacity.test.js +3 -2
  26. package/bundle/server/package.json +2 -2
  27. package/bundle/server/static-ui/assets/{index-CYqRh_-S.css → index-BII-LgB8.css} +1 -1
  28. package/bundle/server/static-ui/assets/index-Bq8wWpZm.js +60 -0
  29. package/bundle/server/static-ui/index.html +2 -2
  30. package/bundle/shared/dist/agents.d.ts +15 -15
  31. package/bundle/shared/dist/agents.js +6 -0
  32. package/bundle/shared/dist/index.d.ts +7 -7
  33. package/bundle/shared/package.json +1 -1
  34. package/dist/doctor.d.ts +7 -3
  35. package/dist/doctor.js +17 -12
  36. package/dist/doctor.test.js +9 -8
  37. package/package.json +1 -1
  38. package/bundle/server/static-ui/assets/index-UO4lHZw4.js +0 -60
@@ -1,8 +1,8 @@
1
1
  // packages/server/src/capacity/claude-local-cache.test.ts
2
2
  //
3
3
  // NOT-268: Claude capacity local-first ladder — local cache 5H/1W plus a
4
- // one-hour paid fallback. No test here performs a live provider request:
5
- // every probe spawn goes through an injected fake runner.
4
+ // free `/usage` refresh after one hour stale. No test here performs a live
5
+ // provider request: every probe spawn goes through an injected fake runner.
6
6
  import { test, before, beforeEach, afterEach } from "node:test";
7
7
  import assert from "node:assert/strict";
8
8
  import fs from "node:fs";
@@ -42,17 +42,39 @@ function fullCacheFixture(fetchedAtMs) {
42
42
  },
43
43
  });
44
44
  }
45
- function probeStreamFixture(observedIso, costUsd = 0.0003) {
45
+ // Mirrors a real `claude -p "/usage"` stream at 2.1.283: a local-command
46
+ // result event carrying `usage_report.rate_limits.limits[]` (verified live
47
+ // 2026-09-27 — see claude-local-cache.ts module header), followed by the
48
+ // terminal `result` event. Real runs cost $0 (no model call); `costUsd`
49
+ // defaults to 0 but stays overridable for the cost-plumbing assertions.
50
+ function probeStreamFixture(observedIso, costUsd = 0) {
46
51
  return [
47
52
  JSON.stringify({
48
- type: "rate_limit_event",
53
+ type: "assistant",
49
54
  timestamp: observedIso,
50
- rate_limit_info: {
51
- status: "allowed",
52
- unifiedWindows: [
53
- { window: "five_hour", utilization: 0.2, resetsAt: FIVE_HOUR_RESET_SEC },
54
- { window: "seven_day", utilization: 0.4, resetsAt: SEVEN_DAY_RESET_SEC },
55
- ],
55
+ local_command_run: { command: "usage", args: "" },
56
+ usage_report: {
57
+ session: { total_cost_usd: 0 },
58
+ rate_limits: {
59
+ limits: [
60
+ {
61
+ kind: "session",
62
+ group: "session",
63
+ percent: 20,
64
+ resets_at: new Date(FIVE_HOUR_RESET_SEC * 1000).toISOString(),
65
+ severity: "normal",
66
+ is_active: false,
67
+ },
68
+ {
69
+ kind: "weekly_all",
70
+ group: "weekly",
71
+ percent: 40,
72
+ resets_at: new Date(SEVEN_DAY_RESET_SEC * 1000).toISOString(),
73
+ severity: "normal",
74
+ is_active: true,
75
+ },
76
+ ],
77
+ },
56
78
  },
57
79
  }),
58
80
  JSON.stringify({
@@ -263,7 +285,7 @@ test("a 2-hour-old cache ingests with its true age and reads expired, never live
263
285
  assert.equal(w.unavailableReason, "expired");
264
286
  }
265
287
  });
266
- test("paid fallback defaults on; explicit off and unrecognized values never spawn", async () => {
288
+ test("refresh defaults on; explicit off and unrecognized values never spawn", async () => {
267
289
  for (const value of [undefined, "", "paid-after-1h"]) {
268
290
  if (value === undefined)
269
291
  delete process.env[CLAUDE_CAPACITY_REFRESH_ENV];
@@ -277,11 +299,26 @@ test("paid fallback defaults on; explicit off and unrecognized values never spaw
277
299
  const outcome = await maybeProbeClaudeCapacity(NOW_MS, { runner: throwingRunner });
278
300
  assert.deepEqual(outcome, { probed: false, reason: "disabled" });
279
301
  }
280
- process.env[CLAUDE_CAPACITY_REFRESH_ENV] = "paid-after-1h";
302
+ delete process.env[CLAUDE_CAPACITY_REFRESH_ENV];
281
303
  assert.equal(isClaudePaidFallbackEnabled(), true);
282
304
  });
283
- test("default-on fallback suppresses a fresh sample; stale data triggers exactly one", async () => {
284
- delete process.env[CLAUDE_CAPACITY_REFRESH_ENV];
305
+ test("extractClaudeUsageReportLimits reads the /usage local-command result", async () => {
306
+ const { extractClaudeUsageReportLimits } = await import("./claude-local-cache.js");
307
+ const observedIso = new Date(NOW_MS).toISOString();
308
+ const events = probeStreamFixture(observedIso).split("\n").map((l) => JSON.parse(l));
309
+ const readings = extractClaudeUsageReportLimits(events, NOW_MS);
310
+ assert.ok(readings);
311
+ assert.equal(readings.length, 2);
312
+ const fiveHour = readings.find((w) => w.criticalRole === "five_hour");
313
+ const weekly = readings.find((w) => w.criticalRole === "weekly");
314
+ assert.equal(fiveHour.usedPercent, 20);
315
+ assert.equal(weekly.usedPercent, 40);
316
+ assert.equal(fiveHour.observedAt, observedIso);
317
+ assert.equal(fiveHour.evidenceRef, "claude-probe:usage-command");
318
+ // No usage_report anywhere in the stream — nothing fabricated.
319
+ assert.equal(extractClaudeUsageReportLimits([{ type: "result" }], NOW_MS), null);
320
+ });
321
+ test("default-on refresh suppresses a fresh sample; stale data triggers exactly one", async () => {
285
322
  fs.writeFileSync(cacheFile, fullCacheFixture(NOW_MS - 10 * 60_000));
286
323
  assert.equal(ingestClaudeLocalCache(NOW_MS), 2);
287
324
  assert.ok((newestValidClaudeObservationMs(NOW_MS) ?? 0) > NOW_MS - 60 * 60_000);
@@ -304,8 +341,7 @@ test("default-on fallback suppresses a fresh sample; stale data triggers exactly
304
341
  assert.equal(outcomes.filter((o) => o.reason === "shared").length, 4);
305
342
  assert.ok(outcomes.every((o) => o.probed && o.ok));
306
343
  });
307
- test("probe argv pins the fixed prompt, cheapest model, no tools/MCP, one turn, ≤$0.01", async () => {
308
- process.env[CLAUDE_CAPACITY_REFRESH_ENV] = "paid-after-1h";
344
+ test("probe argv pins the fixed /usage command plus structural model-turn safeguards", async () => {
309
345
  fs.writeFileSync(cacheFile, fullCacheFixture(NOW_MS - 61 * 60_000));
310
346
  assert.equal(ingestClaudeLocalCache(NOW_MS), 2);
311
347
  let seenBin = "";
@@ -322,7 +358,7 @@ test("probe argv pins the fixed prompt, cheapest model, no tools/MCP, one turn,
322
358
  assert.equal(seenBin, "/fake/claude");
323
359
  assert.deepEqual(seenArgv, [
324
360
  "-p",
325
- "Reply with exactly: ok",
361
+ "/usage",
326
362
  "--model",
327
363
  "haiku",
328
364
  "--max-turns",
@@ -337,13 +373,14 @@ test("probe argv pins the fixed prompt, cheapest model, no tools/MCP, one turn,
337
373
  "--max-budget-usd",
338
374
  "0.01",
339
375
  ]);
340
- // Semantic pins behind the exact match: cheapest model, hard one-turn
341
- // bound, tools fully off, MCP restricted to none passed, stream JSON,
342
- // hard budget cap.
376
+ // Semantic pins behind the exact match: no MCP config passed, cheapest
377
+ // model + hard one-turn bound + no tools kept as structural safeguards
378
+ // (2026-09-27 review hardening) even though `/usage` never reaches the
379
+ // model on 2.1.283, plus the defensive budget cap.
380
+ assert.ok(!seenArgv.includes("--mcp-config"));
343
381
  assert.equal(seenArgv[seenArgv.indexOf("--model") + 1], "haiku");
344
382
  assert.equal(seenArgv[seenArgv.indexOf("--max-turns") + 1], "1");
345
383
  assert.equal(seenArgv[seenArgv.indexOf("--tools") + 1], "");
346
- assert.ok(!seenArgv.includes("--mcp-config"));
347
384
  const budget = Number(seenArgv[seenArgv.indexOf("--max-budget-usd") + 1]);
348
385
  assert.ok(Number.isFinite(budget) && budget <= 0.01);
349
386
  assert.equal(seenArgv.filter((a) => a === "-p").length, 1);
@@ -357,7 +394,7 @@ test("probe success updates both 5H and 1W and records cost without prompt/outpu
357
394
  assert.equal(ingestClaudeLocalCache(NOW_MS), 2);
358
395
  const marker = "probe-stream-marker-must-never-reach-log";
359
396
  const runner = async () => ({
360
- stdout: `${probeStreamFixture(new Date(NOW_MS).toISOString(), 0.0007)}\n${JSON.stringify({ type: "assistant", text: marker })}`,
397
+ stdout: `${probeStreamFixture(new Date(NOW_MS).toISOString())}\n${JSON.stringify({ type: "assistant", text: marker })}`,
361
398
  exitCode: 0,
362
399
  timedOut: false,
363
400
  spawnError: null,
@@ -365,28 +402,134 @@ test("probe success updates both 5H and 1W and records cost without prompt/outpu
365
402
  const result = await runClaudeCapacityProbe(NOW_MS, { runner });
366
403
  assert.equal(result.ok, true);
367
404
  assert.equal(result.failureKind, null);
368
- assert.equal(result.costUsd, 0.0007);
405
+ assert.equal(result.costUsd, 0);
369
406
  assert.deepEqual(result.windowsUpdated, ["claude_unified_five_hour", "claude_unified_seven_day"]);
370
407
  const rows = listCapacitySnapshots("claude_code");
371
408
  assert.equal(rows.find((r) => r.providerBucket === "five_hour").remainingPercent, 80);
372
409
  assert.equal(rows.find((r) => r.providerBucket === "seven_day").remainingPercent, 60);
373
410
  assert.equal(rows.find((r) => r.providerBucket === "five_hour").observedAt, new Date(NOW_MS).toISOString());
374
411
  const logText = fs.readFileSync(probeDiagnosticLogPath(), "utf8");
375
- assert.ok(logText.includes('"costUsd":0.0007'));
412
+ assert.ok(logText.includes('"costUsd":0'));
376
413
  assert.ok(!logText.includes("Reply with exactly"));
377
414
  assert.ok(!logText.includes(marker));
378
415
  });
379
- test("probe success via the cache side effect when the stream carries no windows", async () => {
416
+ test("probe rejects and ingests nothing when the result reports nonzero cost", async () => {
417
+ // Reviewer-requested hardening (PR #165): any charge means /usage did not
418
+ // resolve locally as expected — reject the whole run rather than trusting
419
+ // windows a real (unexpected) model turn happened to produce.
420
+ process.env[CLAUDE_CAPACITY_REFRESH_ENV] = "paid-after-1h";
421
+ fs.writeFileSync(cacheFile, fullCacheFixture(NOW_MS - 61 * 60_000));
422
+ assert.equal(ingestClaudeLocalCache(NOW_MS), 2);
423
+ const runner = async () => ({
424
+ stdout: probeStreamFixture(new Date(NOW_MS).toISOString(), 0.0007),
425
+ exitCode: 0,
426
+ timedOut: false,
427
+ spawnError: null,
428
+ });
429
+ const result = await runClaudeCapacityProbe(NOW_MS, { runner });
430
+ assert.equal(result.ok, false);
431
+ assert.equal(result.failureKind, "unexpected_cost");
432
+ assert.equal(result.costUsd, 0.0007);
433
+ assert.deepEqual(result.windowsUpdated, []);
434
+ // Last-good rows from before the rejected run are untouched.
435
+ const rows = listCapacitySnapshots("claude_code");
436
+ assert.equal(rows.find((r) => r.providerBucket === "five_hour").remainingPercent, 83);
437
+ });
438
+ test("probe rejects and ingests nothing when cost cannot be verified as zero", async () => {
439
+ // Reviewer-requested hardening, round 2 (PR #165): a stream with the
440
+ // local-command marker and well-formed windows, but a `result` event that
441
+ // omits `total_cost_usd` (costUsd === null), must fail closed exactly like
442
+ // a confirmed positive charge — null is not evidence of zero cost.
443
+ process.env[CLAUDE_CAPACITY_REFRESH_ENV] = "paid-after-1h";
444
+ fs.writeFileSync(cacheFile, fullCacheFixture(NOW_MS - 61 * 60_000));
445
+ assert.equal(ingestClaudeLocalCache(NOW_MS), 2);
446
+ const runner = async () => ({
447
+ stdout: [
448
+ JSON.stringify({
449
+ type: "assistant",
450
+ timestamp: new Date(NOW_MS).toISOString(),
451
+ local_command_run: { command: "usage", args: "" },
452
+ usage_report: {
453
+ rate_limits: {
454
+ limits: [
455
+ { kind: "session", percent: 20, resets_at: new Date(FIVE_HOUR_RESET_SEC * 1000).toISOString() },
456
+ { kind: "weekly_all", percent: 40, resets_at: new Date(SEVEN_DAY_RESET_SEC * 1000).toISOString() },
457
+ ],
458
+ },
459
+ },
460
+ }),
461
+ // No `result` event at all — total_cost_usd is unknowable.
462
+ ].join("\n"),
463
+ exitCode: 0,
464
+ timedOut: false,
465
+ spawnError: null,
466
+ });
467
+ const result = await runClaudeCapacityProbe(NOW_MS, { runner });
468
+ assert.equal(result.ok, false);
469
+ assert.equal(result.failureKind, "unexpected_cost");
470
+ assert.equal(result.costUsd, null);
471
+ assert.deepEqual(result.windowsUpdated, []);
472
+ const rows = listCapacitySnapshots("claude_code");
473
+ assert.equal(rows.find((r) => r.providerBucket === "five_hour").remainingPercent, 83);
474
+ });
475
+ test("probe rejects and ingests nothing when the local-command marker is absent", async () => {
476
+ // Reviewer-requested hardening (PR #165): a stream that never shows
477
+ // `/usage` resolving locally must not be trusted, even if it happens to
478
+ // carry a well-formed usage_report (e.g. a different CLI build echoing it
479
+ // from a real turn) or a fresh cache-file side effect.
480
+ process.env[CLAUDE_CAPACITY_REFRESH_ENV] = "paid-after-1h";
481
+ fs.writeFileSync(cacheFile, fullCacheFixture(NOW_MS - 61 * 60_000));
482
+ assert.equal(ingestClaudeLocalCache(NOW_MS), 2);
483
+ const runner = async () => {
484
+ fs.writeFileSync(cacheFile, fullCacheFixture(NOW_MS + 5_000));
485
+ return {
486
+ stdout: [
487
+ JSON.stringify({
488
+ type: "assistant",
489
+ timestamp: new Date(NOW_MS).toISOString(),
490
+ usage_report: {
491
+ rate_limits: { limits: [{ kind: "session", percent: 20, resets_at: null }] },
492
+ },
493
+ }),
494
+ JSON.stringify({ type: "result", is_error: false, total_cost_usd: 0 }),
495
+ ].join("\n"),
496
+ exitCode: 0,
497
+ timedOut: false,
498
+ spawnError: null,
499
+ };
500
+ };
501
+ const result = await runClaudeCapacityProbe(NOW_MS, { runner });
502
+ assert.equal(result.ok, false);
503
+ assert.equal(result.failureKind, "not_local_command");
504
+ assert.deepEqual(result.windowsUpdated, []);
505
+ const rows = listCapacitySnapshots("claude_code");
506
+ assert.equal(rows.find((r) => r.providerBucket === "five_hour").remainingPercent, 83);
507
+ });
508
+ test("probe success via the cache side effect when usage_report carries no windows", async () => {
380
509
  process.env[CLAUDE_CAPACITY_REFRESH_ENV] = "paid-after-1h";
381
510
  fs.writeFileSync(cacheFile, fullCacheFixture(NOW_MS - 61 * 60_000));
382
- // The stream is empty but the probe run itself refreshes Claude's own
383
- // cache file — the re-read must count as success.
511
+ // The local-command marker is present (so the run is trusted) and cost is
512
+ // $0, but usage_report carries no usable limits[] — the cache re-read
513
+ // (the probe run itself refreshes Claude's own cache file) still counts
514
+ // as corroborating success.
384
515
  const runner = async () => {
385
516
  // A real probe refreshes Claude's own cache file while it runs, so the
386
517
  // new `fetchedAtMs` is later than the pre-spawn `NOW_MS` stamp — the
387
518
  // post-spawn re-read must accept it, not reject it as future.
388
519
  fs.writeFileSync(cacheFile, fullCacheFixture(NOW_MS + 5_000));
389
- return { stdout: "", exitCode: 0, timedOut: false, spawnError: null };
520
+ return {
521
+ stdout: [
522
+ JSON.stringify({
523
+ type: "assistant",
524
+ timestamp: new Date(NOW_MS).toISOString(),
525
+ local_command_run: { command: "usage", args: "" },
526
+ }),
527
+ JSON.stringify({ type: "result", is_error: false, total_cost_usd: 0 }),
528
+ ].join("\n"),
529
+ exitCode: 0,
530
+ timedOut: false,
531
+ spawnError: null,
532
+ };
390
533
  };
391
534
  const result = await runClaudeCapacityProbe(NOW_MS, { runner });
392
535
  assert.equal(result.ok, true);
@@ -408,7 +551,9 @@ test("probe failure preserves last-good rows and enters bounded backoff", async
408
551
  const first = await maybeProbeClaudeCapacity(NOW_MS, { runner: failing });
409
552
  assert.equal(first.probed, true);
410
553
  assert.equal(first.ok, false);
411
- assert.equal(first.failureKind, "nonzero_exit");
554
+ // Unparsable stdout ("not-json") means no local-command marker is found —
555
+ // that check runs before the nonzero-exit fallback.
556
+ assert.equal(first.failureKind, "not_local_command");
412
557
  // Last-good rows are untouched by the failed probe (0.17 used → 83 left).
413
558
  const rows = listCapacitySnapshots("claude_code");
414
559
  assert.equal(rows.find((r) => r.providerBucket === "five_hour").remainingPercent, 83);
@@ -87,13 +87,32 @@
87
87
  // shell restriction) is unaffected.
88
88
  // This question was escalated twice on this ticket (product_scope_decision,
89
89
  // resolved both times); the decision above is final for NOT-270's scope.
90
- import { spawn } from "node:child_process";
90
+ import { execFile, spawn } from "node:child_process";
91
91
  import fs from "node:fs";
92
92
  import os from "node:os";
93
93
  import path from "node:path";
94
94
  import { MUSE_CLI_ENV, resolveMuseAuthFile, resolveMuseBin } from "../cli-env.js";
95
+ import { getDataDir } from "../db/index.js";
96
+ import { parseMuseVersion } from "../adapters/muse-capability.js";
95
97
  import { buildMuseDeveloperSettings } from "../runners/muse-code-settings.js";
96
98
  import { MSP_USAGE_CHANGED, MUSE_CLIENT_INFO, MUSE_SERVE_ARGV, assertMuseExecMethod, assertMuseReadOnlyMethod, hasMuseCredential, ingestMuseUsagePayload, museCapacityTimeoutMs, museClassifyRpcError, museEvidenceRef, museRefreshThrottleMs, museUuidv7, noteMuseCapacityFailure, parseMuseRpcLine, } from "./muse.js";
99
+ function readResolvedMuseVersion() {
100
+ return new Promise((resolve) => {
101
+ execFile(resolveMuseBin(), ["--version"], { encoding: "utf8", timeout: 30_000, env: { ...process.env, ...MUSE_CLI_ENV } }, (err, stdout) => resolve(err ? null : parseMuseVersion(stdout)));
102
+ });
103
+ }
104
+ /**
105
+ * NOT-273: dedicated cwd for the coordinator-owned `muse serve` child.
106
+ * The spawn must never inherit the server process's ambient working
107
+ * directory (in a maintainer's `npm run dev` checkout that is the repo
108
+ * itself, where a stray `git add . && git commit` in the child would land
109
+ * real commits on the checkout). Created if missing.
110
+ */
111
+ export function resolveMuseHostCwd() {
112
+ const dir = path.join(getDataDir(), "capacity", "muse-host-home");
113
+ fs.mkdirSync(dir, { recursive: true });
114
+ return dir;
115
+ }
97
116
  /**
98
117
  * NOT-270 serve lane: the owned host must run workers under the same
99
118
  * posture as the exec lane's per-attempt settings — no MCP servers, no
@@ -171,6 +190,14 @@ export class MuseCapacityHost {
171
190
  observedOnConnection = false;
172
191
  /** Increments on every spawn — tests use it to prove host reuse/restart. */
173
192
  connectionEpoch = 0;
193
+ /**
194
+ * NOT-277: the Muse version the live child runs — the on-disk binary's reported version, read
195
+ * immediately before the spawn and again after the handshake; null when unknown or when the
196
+ * binary changed across the spawn. A long-lived host keeps running the build it started on
197
+ * after an auto-update, so developer work only uses it when this is the capability-checked
198
+ * version (see `ensureStartedOnVersion`).
199
+ */
200
+ runningVersion = null;
174
201
  /**
175
202
  * Server-owned XDG home for the host (worker settings + auth link).
176
203
  * Built lazily on first spawn, reused across restarts, removed on
@@ -241,6 +268,7 @@ export class MuseCapacityHost {
241
268
  const child = this.child;
242
269
  this.child = null;
243
270
  this.dead = true;
271
+ this.runningVersion = null;
244
272
  for (const [, p] of this.pending) {
245
273
  try {
246
274
  p.resolve({});
@@ -457,8 +485,28 @@ export class MuseCapacityHost {
457
485
  this.starting = null;
458
486
  }
459
487
  }
488
+ /**
489
+ * NOT-277: start (or reuse) the host, and report whether it runs `version`. A live host on any
490
+ * other (or an unknown) version is restarted onto the current on-disk binary when it is idle;
491
+ * while real turns are using it, it is left alone and this returns false (the caller runs its
492
+ * work on the exec lane instead). Never true for a host not known to run `version`.
493
+ */
494
+ async ensureStartedOnVersion(version) {
495
+ if (!(await this.ensureStarted()))
496
+ return false;
497
+ if (this.runningVersion === version)
498
+ return true;
499
+ if (this.hasInflightExecution() || this.inflightRead)
500
+ return false;
501
+ this.killChild();
502
+ if (!(await this.ensureStarted()))
503
+ return false;
504
+ return this.runningVersion === version;
505
+ }
460
506
  async start() {
461
507
  this.killChild();
508
+ const readVersion = this.opts.readVersion ?? (this.opts.command ? async () => null : readResolvedMuseVersion);
509
+ const versionBefore = await readVersion();
462
510
  const timeoutMs = this.opts.timeoutMs ?? museCapacityTimeoutMs();
463
511
  const mergedEnv = this.mergedEnv();
464
512
  if (!hasMuseCredential(mergedEnv, this.opts.authFilePath ?? resolveMuseAuthFile())) {
@@ -469,7 +517,12 @@ export class MuseCapacityHost {
469
517
  const args = this.opts.args ?? [...MUSE_SERVE_ARGV];
470
518
  let child;
471
519
  try {
472
- child = spawn(command, args, {
520
+ // NOT-273: explicit cwd — never inherit the server process's ambient
521
+ // working directory (which in dev is the repo checkout itself).
522
+ const cwd = this.opts.cwd ?? resolveMuseHostCwd();
523
+ const spawnFn = this.opts.spawnImpl ?? spawn;
524
+ child = spawnFn(command, args, {
525
+ cwd,
473
526
  stdio: ["pipe", "pipe", "pipe"],
474
527
  env: { ...mergedEnv, ...MUSE_CLI_ENV },
475
528
  });
@@ -566,6 +619,11 @@ export class MuseCapacityHost {
566
619
  this.killChild();
567
620
  return false;
568
621
  }
622
+ // The spawned build is the on-disk one only if the binary did not change across the spawn.
623
+ const versionAfter = await readVersion();
624
+ if (this.isCurrentChild(child)) {
625
+ this.runningVersion = versionBefore !== null && versionBefore === versionAfter ? versionBefore : null;
626
+ }
569
627
  return true;
570
628
  }
571
629
  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");
@@ -458,6 +518,70 @@ test("prepareMuseServeHome links the ambient login, or records API-key auth", as
458
518
  }
459
519
  fs.rmSync(ambientRoot, { recursive: true, force: true });
460
520
  });
521
+ test("NOT-273: the serve child spawns with an explicit cwd under the managed data dir", async () => {
522
+ const { EventEmitter } = await import("node:events");
523
+ const { getDataDir } = await import("../db/index.js");
524
+ const seen = [];
525
+ const hangingChild = () => {
526
+ const events = new EventEmitter();
527
+ const fake = {
528
+ stdin: { write: () => true, on: () => ({}) },
529
+ stdout: { on: () => ({}) },
530
+ stderr: { on: () => ({}) },
531
+ on: events.on.bind(events),
532
+ // The handshake never answers, but a kill() must still settle the
533
+ // child like a real process would — otherwise shutdown()'s teardown
534
+ // has nothing but its 2s unref'd SIGKILL-escalation timer to resolve
535
+ // on, which races the test runner's own event-loop bookkeeping and
536
+ // can cancel the test outright (see the child-ticket playbook gotcha:
537
+ // never await an infinite fake handler).
538
+ kill: () => {
539
+ fake.exitCode = 0;
540
+ queueMicrotask(() => events.emit("close"));
541
+ return true;
542
+ },
543
+ exitCode: null,
544
+ };
545
+ return fake;
546
+ };
547
+ const host = new MuseCapacityHost({
548
+ command: process.execPath,
549
+ args: [FAKE],
550
+ env: { META_API_KEY: "test-fake-key" },
551
+ // Handshake never answers — start() still records the exact spawn options.
552
+ timeoutMs: 100,
553
+ spawnImpl: ((command, args, options) => {
554
+ seen.push({ command, args, options });
555
+ return hangingChild();
556
+ }),
557
+ });
558
+ // This test's fake spawn has no real child process, so nothing else keeps
559
+ // the event loop busy while the production code's handshake/kill timers
560
+ // (deliberately unref'd — a real server must never block on them) are
561
+ // pending. With zero other ref'd handles, some Node versions' test runner
562
+ // treats the loop as "drained" before those timers fire and cancels the
563
+ // test outright (cancelledByParent). A trivial ref'd keepalive avoids the
564
+ // race without touching the production unref'd-timer behavior being tested.
565
+ const keepalive = setInterval(() => { }, 10);
566
+ try {
567
+ // Missing (handshake timeout), not a spawn failure — the spawn happened.
568
+ assert.equal((await host.readUsage()).status, "missing");
569
+ assert.equal(seen.length, 1, "exactly one serve spawn");
570
+ const cwd = seen[0].options.cwd;
571
+ assert.equal(typeof cwd, "string", "spawn options include an explicit cwd");
572
+ const dir = cwd;
573
+ assert.ok(path.isAbsolute(dir), `cwd is absolute, got ${dir}`);
574
+ assert.notEqual(path.resolve(dir), path.resolve(process.cwd()), "cwd is not the ambient process cwd");
575
+ assert.ok(dir.startsWith(`${getDataDir()}${path.sep}`), `cwd lives under the managed data dir, got ${dir}`);
576
+ assert.equal(path.basename(dir), "muse-host-home");
577
+ assert.ok(fs.existsSync(dir), "the dedicated cwd is created if missing");
578
+ assert.equal(resolveMuseHostCwd(), dir, "default cwd is the dedicated data-dir home");
579
+ }
580
+ finally {
581
+ await host.shutdown();
582
+ clearInterval(keepalive);
583
+ }
584
+ });
461
585
  test("no credential short-circuits to missing without spawning", async () => {
462
586
  const noAuthDir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-host-noauth-"));
463
587
  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 () => {