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.
- package/bundle/server/dist/adapters/agent-health.js +25 -2
- package/bundle/server/dist/adapters/agent-health.test.js +55 -2
- 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 +330 -0
- package/bundle/server/dist/adapters/muse-capability.test.js +378 -0
- package/bundle/server/dist/capacity/claude-local-cache.js +217 -64
- package/bundle/server/dist/capacity/claude-local-cache.test.js +174 -29
- package/bundle/server/dist/capacity/muse-host.js +60 -2
- package/bundle/server/dist/capacity/muse-host.test.js +125 -1
- package/bundle/server/dist/coordinator/admission.test.js +85 -0
- package/bundle/server/dist/coordinator/commands.js +35 -8
- package/bundle/server/dist/coordinator/developer-effect.js +40 -6
- package/bundle/server/dist/coordinator/developer-effect.test.js +121 -12
- package/bundle/server/dist/coordinator/human-resolution.js +31 -1
- package/bundle/server/dist/coordinator/muse-spawn.js +12 -2
- package/bundle/server/dist/coordinator/prompts.js +15 -0
- package/bundle/server/dist/coordinator/prompts.test.js +20 -0
- package/bundle/server/dist/coordinator/spawn.js +7 -0
- 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/routes/human-actions.js +10 -1
- package/bundle/server/dist/routes/human-actions.test.js +117 -1
- package/bundle/server/dist/routes/index.js +9 -5
- package/bundle/server/dist/routes/runtime-capacity.test.js +3 -2
- 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-Bq8wWpZm.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 +17 -12
- package/dist/doctor.test.js +9 -8
- package/package.json +1 -1
- 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
|
|
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
|
-
|
|
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: "
|
|
53
|
+
type: "assistant",
|
|
49
54
|
timestamp: observedIso,
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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("
|
|
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]
|
|
302
|
+
delete process.env[CLAUDE_CAPACITY_REFRESH_ENV];
|
|
281
303
|
assert.equal(isClaudePaidFallbackEnabled(), true);
|
|
282
304
|
});
|
|
283
|
-
test("
|
|
284
|
-
|
|
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
|
|
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
|
-
"
|
|
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:
|
|
341
|
-
//
|
|
342
|
-
//
|
|
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()
|
|
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
|
|
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
|
|
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
|
|
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
|
|
383
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 () => {
|