agent-dealer 1.2.3 → 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 +222 -63
- package/bundle/server/dist/capacity/claude-local-cache.test.js +179 -30
- package/bundle/server/dist/capacity/muse-host.js +111 -23
- package/bundle/server/dist/capacity/muse-host.test.js +125 -1
- package/bundle/server/dist/capacity/muse-lifecycle.test.js +2 -3
- package/bundle/server/dist/capacity/muse-probe.js +250 -0
- package/bundle/server/dist/capacity/muse-probe.test.js +183 -0
- package/bundle/server/dist/capacity/muse.js +16 -6
- 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 +17 -14
- package/bundle/server/dist/routes/runtime-capacity.test.js +11 -3
- package/bundle/server/dist/runners/muse-serve-session.js +7 -6
- 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 +22 -3
- package/dist/doctor.js +46 -12
- package/dist/doctor.test.js +41 -10
- 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,21 +285,40 @@ 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("
|
|
267
|
-
for (const value of [undefined, "", "
|
|
288
|
+
test("refresh defaults on; explicit off and unrecognized values never spawn", async () => {
|
|
289
|
+
for (const value of [undefined, "", "paid-after-1h"]) {
|
|
268
290
|
if (value === undefined)
|
|
269
291
|
delete process.env[CLAUDE_CAPACITY_REFRESH_ENV];
|
|
270
292
|
else
|
|
271
293
|
process.env[CLAUDE_CAPACITY_REFRESH_ENV] = value;
|
|
294
|
+
assert.equal(isClaudePaidFallbackEnabled(), true);
|
|
295
|
+
}
|
|
296
|
+
for (const value of ["off", "auto", "paid-after-1h "]) {
|
|
297
|
+
process.env[CLAUDE_CAPACITY_REFRESH_ENV] = value;
|
|
272
298
|
assert.equal(isClaudePaidFallbackEnabled(), false);
|
|
273
299
|
const outcome = await maybeProbeClaudeCapacity(NOW_MS, { runner: throwingRunner });
|
|
274
300
|
assert.deepEqual(outcome, { probed: false, reason: "disabled" });
|
|
275
301
|
}
|
|
276
|
-
process.env[CLAUDE_CAPACITY_REFRESH_ENV]
|
|
302
|
+
delete process.env[CLAUDE_CAPACITY_REFRESH_ENV];
|
|
277
303
|
assert.equal(isClaudePaidFallbackEnabled(), true);
|
|
278
304
|
});
|
|
279
|
-
test("
|
|
280
|
-
|
|
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 () => {
|
|
281
322
|
fs.writeFileSync(cacheFile, fullCacheFixture(NOW_MS - 10 * 60_000));
|
|
282
323
|
assert.equal(ingestClaudeLocalCache(NOW_MS), 2);
|
|
283
324
|
assert.ok((newestValidClaudeObservationMs(NOW_MS) ?? 0) > NOW_MS - 60 * 60_000);
|
|
@@ -300,8 +341,7 @@ test("fresh sample suppresses the probe; stale data triggers exactly one", async
|
|
|
300
341
|
assert.equal(outcomes.filter((o) => o.reason === "shared").length, 4);
|
|
301
342
|
assert.ok(outcomes.every((o) => o.probed && o.ok));
|
|
302
343
|
});
|
|
303
|
-
test("probe argv pins the fixed
|
|
304
|
-
process.env[CLAUDE_CAPACITY_REFRESH_ENV] = "paid-after-1h";
|
|
344
|
+
test("probe argv pins the fixed /usage command plus structural model-turn safeguards", async () => {
|
|
305
345
|
fs.writeFileSync(cacheFile, fullCacheFixture(NOW_MS - 61 * 60_000));
|
|
306
346
|
assert.equal(ingestClaudeLocalCache(NOW_MS), 2);
|
|
307
347
|
let seenBin = "";
|
|
@@ -318,7 +358,7 @@ test("probe argv pins the fixed prompt, cheapest model, no tools/MCP, one turn,
|
|
|
318
358
|
assert.equal(seenBin, "/fake/claude");
|
|
319
359
|
assert.deepEqual(seenArgv, [
|
|
320
360
|
"-p",
|
|
321
|
-
"
|
|
361
|
+
"/usage",
|
|
322
362
|
"--model",
|
|
323
363
|
"haiku",
|
|
324
364
|
"--max-turns",
|
|
@@ -333,13 +373,14 @@ test("probe argv pins the fixed prompt, cheapest model, no tools/MCP, one turn,
|
|
|
333
373
|
"--max-budget-usd",
|
|
334
374
|
"0.01",
|
|
335
375
|
]);
|
|
336
|
-
// Semantic pins behind the exact match:
|
|
337
|
-
//
|
|
338
|
-
//
|
|
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"));
|
|
339
381
|
assert.equal(seenArgv[seenArgv.indexOf("--model") + 1], "haiku");
|
|
340
382
|
assert.equal(seenArgv[seenArgv.indexOf("--max-turns") + 1], "1");
|
|
341
383
|
assert.equal(seenArgv[seenArgv.indexOf("--tools") + 1], "");
|
|
342
|
-
assert.ok(!seenArgv.includes("--mcp-config"));
|
|
343
384
|
const budget = Number(seenArgv[seenArgv.indexOf("--max-budget-usd") + 1]);
|
|
344
385
|
assert.ok(Number.isFinite(budget) && budget <= 0.01);
|
|
345
386
|
assert.equal(seenArgv.filter((a) => a === "-p").length, 1);
|
|
@@ -353,7 +394,7 @@ test("probe success updates both 5H and 1W and records cost without prompt/outpu
|
|
|
353
394
|
assert.equal(ingestClaudeLocalCache(NOW_MS), 2);
|
|
354
395
|
const marker = "probe-stream-marker-must-never-reach-log";
|
|
355
396
|
const runner = async () => ({
|
|
356
|
-
stdout: `${probeStreamFixture(new Date(NOW_MS).toISOString()
|
|
397
|
+
stdout: `${probeStreamFixture(new Date(NOW_MS).toISOString())}\n${JSON.stringify({ type: "assistant", text: marker })}`,
|
|
357
398
|
exitCode: 0,
|
|
358
399
|
timedOut: false,
|
|
359
400
|
spawnError: null,
|
|
@@ -361,28 +402,134 @@ test("probe success updates both 5H and 1W and records cost without prompt/outpu
|
|
|
361
402
|
const result = await runClaudeCapacityProbe(NOW_MS, { runner });
|
|
362
403
|
assert.equal(result.ok, true);
|
|
363
404
|
assert.equal(result.failureKind, null);
|
|
364
|
-
assert.equal(result.costUsd, 0
|
|
405
|
+
assert.equal(result.costUsd, 0);
|
|
365
406
|
assert.deepEqual(result.windowsUpdated, ["claude_unified_five_hour", "claude_unified_seven_day"]);
|
|
366
407
|
const rows = listCapacitySnapshots("claude_code");
|
|
367
408
|
assert.equal(rows.find((r) => r.providerBucket === "five_hour").remainingPercent, 80);
|
|
368
409
|
assert.equal(rows.find((r) => r.providerBucket === "seven_day").remainingPercent, 60);
|
|
369
410
|
assert.equal(rows.find((r) => r.providerBucket === "five_hour").observedAt, new Date(NOW_MS).toISOString());
|
|
370
411
|
const logText = fs.readFileSync(probeDiagnosticLogPath(), "utf8");
|
|
371
|
-
assert.ok(logText.includes('"costUsd":0
|
|
412
|
+
assert.ok(logText.includes('"costUsd":0'));
|
|
372
413
|
assert.ok(!logText.includes("Reply with exactly"));
|
|
373
414
|
assert.ok(!logText.includes(marker));
|
|
374
415
|
});
|
|
375
|
-
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 () => {
|
|
376
509
|
process.env[CLAUDE_CAPACITY_REFRESH_ENV] = "paid-after-1h";
|
|
377
510
|
fs.writeFileSync(cacheFile, fullCacheFixture(NOW_MS - 61 * 60_000));
|
|
378
|
-
// The
|
|
379
|
-
//
|
|
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.
|
|
380
515
|
const runner = async () => {
|
|
381
516
|
// A real probe refreshes Claude's own cache file while it runs, so the
|
|
382
517
|
// new `fetchedAtMs` is later than the pre-spawn `NOW_MS` stamp — the
|
|
383
518
|
// post-spawn re-read must accept it, not reject it as future.
|
|
384
519
|
fs.writeFileSync(cacheFile, fullCacheFixture(NOW_MS + 5_000));
|
|
385
|
-
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
|
+
};
|
|
386
533
|
};
|
|
387
534
|
const result = await runClaudeCapacityProbe(NOW_MS, { runner });
|
|
388
535
|
assert.equal(result.ok, true);
|
|
@@ -404,7 +551,9 @@ test("probe failure preserves last-good rows and enters bounded backoff", async
|
|
|
404
551
|
const first = await maybeProbeClaudeCapacity(NOW_MS, { runner: failing });
|
|
405
552
|
assert.equal(first.probed, true);
|
|
406
553
|
assert.equal(first.ok, false);
|
|
407
|
-
|
|
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");
|
|
408
557
|
// Last-good rows are untouched by the failed probe (0.17 used → 83 left).
|
|
409
558
|
const rows = listCapacitySnapshots("claude_code");
|
|
410
559
|
assert.equal(rows.find((r) => r.providerBucket === "five_hour").remainingPercent, 83);
|
|
@@ -44,9 +44,11 @@
|
|
|
44
44
|
// clears the sentinel.
|
|
45
45
|
// - Capacity stays independent from `runtime_availability` hard-cap
|
|
46
46
|
// admission: this module never reads or writes health rows.
|
|
47
|
-
// - The
|
|
48
|
-
// `initialized`, `usage/read`)
|
|
49
|
-
//
|
|
47
|
+
// - The free read path enforces the read-only allowlist (`initialize`,
|
|
48
|
+
// `initialized`, `usage/read`) and never starts billable work. The separate
|
|
49
|
+
// default-on fallback in `muse-probe.ts` may create a dedicated restricted
|
|
50
|
+
// host for one fixed, bounded turn after the complete pair has been absent
|
|
51
|
+
// for an hour.
|
|
50
52
|
//
|
|
51
53
|
// Execution precondition (NOT-269): only a host that observes the account's
|
|
52
54
|
// provider traffic can answer `usage/read`. Real Dealer Muse turns run
|
|
@@ -54,12 +56,12 @@
|
|
|
54
56
|
// below, driven by runners/muse-serve-session.ts) — that traffic is the
|
|
55
57
|
// observation, so the session-boundary refresh hook
|
|
56
58
|
// (`refreshMuseCapacityAfterSession` in coordinator/muse-spawn.ts) is the
|
|
57
|
-
// final `usage/read` that populates 5H/1W.
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
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.
|
|
63
65
|
//
|
|
64
66
|
// PRODUCT DECISION (2026-09-26, resolved via human_action on this ticket):
|
|
65
67
|
// the runner migration is in scope for NOT-270, in this same PR, not a
|
|
@@ -85,13 +87,32 @@
|
|
|
85
87
|
// shell restriction) is unaffected.
|
|
86
88
|
// This question was escalated twice on this ticket (product_scope_decision,
|
|
87
89
|
// resolved both times); the decision above is final for NOT-270's scope.
|
|
88
|
-
import { spawn } from "node:child_process";
|
|
90
|
+
import { execFile, spawn } from "node:child_process";
|
|
89
91
|
import fs from "node:fs";
|
|
90
92
|
import os from "node:os";
|
|
91
93
|
import path from "node:path";
|
|
92
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";
|
|
93
97
|
import { buildMuseDeveloperSettings } from "../runners/muse-code-settings.js";
|
|
94
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
|
+
}
|
|
95
116
|
/**
|
|
96
117
|
* NOT-270 serve lane: the owned host must run workers under the same
|
|
97
118
|
* posture as the exec lane's per-attempt settings — no MCP servers, no
|
|
@@ -128,6 +149,8 @@ export function prepareMuseServeHome(ambientAuthFile = resolveMuseAuthFile()) {
|
|
|
128
149
|
}
|
|
129
150
|
return { configHome, dataHome, authLinked };
|
|
130
151
|
}
|
|
152
|
+
const KEYCHAIN_UNREADABLE_RE = /keychain item .* unreadable|OSStatus\s+-?\d+/i;
|
|
153
|
+
const AUTH_FAILURE_RE = /credential|auth|login|sign[ -]?in|\b401\b|\b403\b/i;
|
|
131
154
|
function readOnlyRequest(id, method, params) {
|
|
132
155
|
assertMuseReadOnlyMethod(method);
|
|
133
156
|
return `${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`;
|
|
@@ -167,6 +190,14 @@ export class MuseCapacityHost {
|
|
|
167
190
|
observedOnConnection = false;
|
|
168
191
|
/** Increments on every spawn — tests use it to prove host reuse/restart. */
|
|
169
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;
|
|
170
201
|
/**
|
|
171
202
|
* Server-owned XDG home for the host (worker settings + auth link).
|
|
172
203
|
* Built lazily on first spawn, reused across restarts, removed on
|
|
@@ -237,6 +268,7 @@ export class MuseCapacityHost {
|
|
|
237
268
|
const child = this.child;
|
|
238
269
|
this.child = null;
|
|
239
270
|
this.dead = true;
|
|
271
|
+
this.runningVersion = null;
|
|
240
272
|
for (const [, p] of this.pending) {
|
|
241
273
|
try {
|
|
242
274
|
p.resolve({});
|
|
@@ -453,24 +485,51 @@ export class MuseCapacityHost {
|
|
|
453
485
|
this.starting = null;
|
|
454
486
|
}
|
|
455
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
|
+
}
|
|
456
506
|
async start() {
|
|
457
507
|
this.killChild();
|
|
508
|
+
const readVersion = this.opts.readVersion ?? (this.opts.command ? async () => null : readResolvedMuseVersion);
|
|
509
|
+
const versionBefore = await readVersion();
|
|
458
510
|
const timeoutMs = this.opts.timeoutMs ?? museCapacityTimeoutMs();
|
|
459
511
|
const mergedEnv = this.mergedEnv();
|
|
460
512
|
if (!hasMuseCredential(mergedEnv, this.opts.authFilePath ?? resolveMuseAuthFile())) {
|
|
513
|
+
this.startFailure = "credential_missing";
|
|
461
514
|
return false;
|
|
462
515
|
}
|
|
463
516
|
const command = this.opts.command ?? resolveMuseBin();
|
|
464
517
|
const args = this.opts.args ?? [...MUSE_SERVE_ARGV];
|
|
465
518
|
let child;
|
|
466
519
|
try {
|
|
467
|
-
|
|
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,
|
|
468
526
|
stdio: ["pipe", "pipe", "pipe"],
|
|
469
527
|
env: { ...mergedEnv, ...MUSE_CLI_ENV },
|
|
470
528
|
});
|
|
471
529
|
}
|
|
472
530
|
catch (err) {
|
|
473
|
-
this.startFailure =
|
|
531
|
+
this.startFailure =
|
|
532
|
+
err?.code === "ENOENT" ? "binary_missing" : "spawn_failed";
|
|
474
533
|
return false;
|
|
475
534
|
}
|
|
476
535
|
this.child = child;
|
|
@@ -505,14 +564,17 @@ export class MuseCapacityHost {
|
|
|
505
564
|
return;
|
|
506
565
|
}
|
|
507
566
|
});
|
|
508
|
-
child.on("error", () => {
|
|
567
|
+
child.on("error", (err) => {
|
|
509
568
|
if (!this.isCurrentChild(child))
|
|
510
569
|
return;
|
|
570
|
+
this.startFailure =
|
|
571
|
+
err?.code === "ENOENT" ? "binary_missing" : "spawn_failed";
|
|
511
572
|
this.killChild();
|
|
512
573
|
});
|
|
513
574
|
child.stdin?.on("error", () => {
|
|
514
575
|
if (!this.isCurrentChild(child))
|
|
515
576
|
return;
|
|
577
|
+
this.startFailure = "transport_error";
|
|
516
578
|
this.killChild();
|
|
517
579
|
});
|
|
518
580
|
child.on("close", () => {
|
|
@@ -521,6 +583,15 @@ export class MuseCapacityHost {
|
|
|
521
583
|
// its replacement must not kill the new host.
|
|
522
584
|
if (!this.isCurrentChild(child))
|
|
523
585
|
return;
|
|
586
|
+
if (KEYCHAIN_UNREADABLE_RE.test(this.stderrTail)) {
|
|
587
|
+
this.startFailure = "keychain_unreadable";
|
|
588
|
+
}
|
|
589
|
+
else if (AUTH_FAILURE_RE.test(this.stderrTail)) {
|
|
590
|
+
this.startFailure = "auth_rejected";
|
|
591
|
+
}
|
|
592
|
+
else if (this.startFailure === null) {
|
|
593
|
+
this.startFailure = "host_exited";
|
|
594
|
+
}
|
|
524
595
|
this.killChild();
|
|
525
596
|
});
|
|
526
597
|
const initId = `muse-capacity-init-${this.connectionEpoch}`;
|
|
@@ -529,7 +600,14 @@ export class MuseCapacityHost {
|
|
|
529
600
|
if (init?.error) {
|
|
530
601
|
const kind = museClassifyRpcError(init.error);
|
|
531
602
|
this.startFailure =
|
|
532
|
-
kind === "auth"
|
|
603
|
+
kind === "auth"
|
|
604
|
+
? "auth_rejected"
|
|
605
|
+
: kind === "unsupported"
|
|
606
|
+
? "unsupported"
|
|
607
|
+
: "handshake_rejected";
|
|
608
|
+
}
|
|
609
|
+
else if (this.startFailure === null) {
|
|
610
|
+
this.startFailure = this.dead ? "host_exited" : "handshake_timeout";
|
|
533
611
|
}
|
|
534
612
|
this.killChild();
|
|
535
613
|
return false;
|
|
@@ -541,6 +619,11 @@ export class MuseCapacityHost {
|
|
|
541
619
|
this.killChild();
|
|
542
620
|
return false;
|
|
543
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
|
+
}
|
|
544
627
|
return true;
|
|
545
628
|
}
|
|
546
629
|
startFailure = null;
|
|
@@ -579,15 +662,20 @@ export class MuseCapacityHost {
|
|
|
579
662
|
this.startFailure = null;
|
|
580
663
|
const started = await this.ensureStarted();
|
|
581
664
|
if (!started) {
|
|
582
|
-
|
|
665
|
+
// `ensureStarted` records the failure asynchronously. TypeScript does
|
|
666
|
+
// not model that mutation across the awaited call, so widen the field
|
|
667
|
+
// back to its declared type before selecting the fallback category.
|
|
668
|
+
const diagnostic = this.startFailure ?? "spawn_failed";
|
|
669
|
+
if (diagnostic === "unsupported" || diagnostic === "binary_missing") {
|
|
670
|
+
console.error(`[muse-capacity] host start failed: ${diagnostic}`);
|
|
583
671
|
await noteMuseCapacityFailure("unsupported");
|
|
584
|
-
return { status: "failure", reason: "unsupported" };
|
|
672
|
+
return { status: "failure", reason: "unsupported", diagnostic };
|
|
585
673
|
}
|
|
586
674
|
// No credential, spawn failure, timeout, or bad exit before the
|
|
587
675
|
// handshake: honest `missing`, last-good rows preserved.
|
|
588
|
-
console.error(
|
|
676
|
+
console.error(`[muse-capacity] host start failed: ${diagnostic}`);
|
|
589
677
|
await noteMuseCapacityFailure("missing");
|
|
590
|
-
return { status: "missing" };
|
|
678
|
+
return { status: "missing", diagnostic };
|
|
591
679
|
}
|
|
592
680
|
this.readSeq += 1;
|
|
593
681
|
const id = `muse-capacity-${this.connectionEpoch}-${this.readSeq}`;
|
|
@@ -601,23 +689,23 @@ export class MuseCapacityHost {
|
|
|
601
689
|
if (!this.hasInflightExecution())
|
|
602
690
|
this.killChild();
|
|
603
691
|
await noteMuseCapacityFailure("missing");
|
|
604
|
-
return { status: "missing" };
|
|
692
|
+
return { status: "missing", diagnostic: "read_timeout" };
|
|
605
693
|
}
|
|
606
694
|
if (res.error) {
|
|
607
695
|
const kind = museClassifyRpcError(res.error);
|
|
608
696
|
if (kind === "auth") {
|
|
609
697
|
console.error("[muse-capacity] host read failed: unauthenticated");
|
|
610
698
|
await noteMuseCapacityFailure("missing");
|
|
611
|
-
return { status: "missing" };
|
|
699
|
+
return { status: "missing", diagnostic: "auth_rejected" };
|
|
612
700
|
}
|
|
613
701
|
if (kind === "unsupported") {
|
|
614
702
|
console.error("[muse-capacity] host read failed: unsupported");
|
|
615
703
|
await noteMuseCapacityFailure("unsupported");
|
|
616
|
-
return { status: "failure", reason: "unsupported" };
|
|
704
|
+
return { status: "failure", reason: "unsupported", diagnostic: "unsupported" };
|
|
617
705
|
}
|
|
618
706
|
console.error("[muse-capacity] host read failed: malformed");
|
|
619
707
|
await noteMuseCapacityFailure("unparsable");
|
|
620
|
-
return { status: "failure", reason: "unparsable" };
|
|
708
|
+
return { status: "failure", reason: "unparsable", diagnostic: "read_rejected" };
|
|
621
709
|
}
|
|
622
710
|
let payload = res.result;
|
|
623
711
|
if (payload &&
|
|
@@ -633,7 +721,7 @@ export class MuseCapacityHost {
|
|
|
633
721
|
// Fresh/unobserved host: `usage` omitted — honest `missing`, never a
|
|
634
722
|
// write, so a newer known pair is never overwritten or deleted.
|
|
635
723
|
await noteMuseCapacityFailure("missing");
|
|
636
|
-
return { status: "missing" };
|
|
724
|
+
return { status: "missing", diagnostic: "unobserved" };
|
|
637
725
|
}
|
|
638
726
|
return { status: "observed", written: outcome.written, skippedStale: outcome.skippedStale };
|
|
639
727
|
}
|