agent-dealer 1.2.2 → 1.2.3

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 (32) hide show
  1. package/bundle/server/dist/capacity/claude-events.js +27 -10
  2. package/bundle/server/dist/capacity/claude-local-cache.js +788 -0
  3. package/bundle/server/dist/capacity/claude-local-cache.test.js +520 -0
  4. package/bundle/server/dist/capacity/cursor-individual-credentials.js +203 -16
  5. package/bundle/server/dist/capacity/cursor-individual-credentials.test.js +186 -2
  6. package/bundle/server/dist/capacity/cursor-individual.js +69 -2
  7. package/bundle/server/dist/capacity/cursor-individual.test.js +268 -2
  8. package/bundle/server/dist/capacity/muse-host.js +748 -0
  9. package/bundle/server/dist/capacity/muse-host.test.js +480 -0
  10. package/bundle/server/dist/capacity/muse-lifecycle.test.js +97 -0
  11. package/bundle/server/dist/capacity/muse.js +307 -60
  12. package/bundle/server/dist/capacity/muse.test.js +8 -51
  13. package/bundle/server/dist/coordinator/muse-spawn.js +143 -1
  14. package/bundle/server/dist/index.js +15 -2
  15. package/bundle/server/dist/routes/index.js +26 -9
  16. package/bundle/server/dist/routes/runtime-capacity.test.js +12 -6
  17. package/bundle/server/dist/runners/muse-code-jsonl.js +7 -1
  18. package/bundle/server/dist/runners/muse-serve-session.js +313 -0
  19. package/bundle/server/dist/runners/muse-serve-session.test.js +325 -0
  20. package/bundle/server/package.json +2 -2
  21. package/bundle/server/static-ui/assets/codex-YZSpL-SB.png +0 -0
  22. package/bundle/server/static-ui/assets/{index-DUYzboWh.css → index-CYqRh_-S.css} +1 -1
  23. package/bundle/server/static-ui/assets/index-UO4lHZw4.js +60 -0
  24. package/bundle/server/static-ui/assets/muse-CtdcC6ve.svg +33 -0
  25. package/bundle/server/static-ui/index.html +2 -2
  26. package/bundle/shared/package.json +1 -1
  27. package/dist/doctor.d.ts +57 -0
  28. package/dist/doctor.js +206 -1
  29. package/dist/doctor.test.d.ts +1 -0
  30. package/dist/doctor.test.js +246 -0
  31. package/package.json +1 -1
  32. package/bundle/server/static-ui/assets/index-0GDYfbaM.js +0 -60
@@ -0,0 +1,748 @@
1
+ // packages/server/src/capacity/muse-host.ts
2
+ //
3
+ // NOT-270: server-owned long-lived `muse serve` host for Muse 5H/1W capacity.
4
+ //
5
+ // NOT-269 proved the only supported acquisition path: a host that actually
6
+ // observed the account's provider traffic (a real session's `turn/start`
7
+ // flow on that same host), whose `usage/changed` notifications and
8
+ // `usage/read` answers then carry the `window` + `weekly` pair. A fresh
9
+ // host is structurally unobserved — the retired one-shot poll (spawn →
10
+ // `usage/read` → exit) could only ever read `missing` — and
11
+ // `session/resume` carries no usage, so neither is used here.
12
+ //
13
+ // Ownership (exactly the NOT-269 design):
14
+ // - One `muse serve` host per server process, started at first demand.
15
+ // Concurrent readers share it: connection startup and `usage/read` are
16
+ // both single-flight, so there is never a second Muse execution host
17
+ // per concurrent request.
18
+ // - The host is held open only once it has observed provider traffic
19
+ // (state worth preserving across restarts is process-local to the
20
+ // host). A host that has observed nothing is released right after the
21
+ // read — an unobserved host holds no state, so releasing it loses
22
+ // nothing, and production keeps no lifetime child that can only answer
23
+ // `missing`. The next throttled refresh transparently respawns it.
24
+ // The release never fires while an execution turn is using the
25
+ // connection (`hasInflightExecution`): a capacity read must not SIGTERM
26
+ // an admitted turn.
27
+ // - The host launches under a server-owned XDG home
28
+ // (`prepareMuseServeHome`): the same worker posture as the exec lane's
29
+ // per-attempt settings (no MCP servers, no subagents, no workflows, no
30
+ // reminders) plus a symlink to the ambient login, so serve-lane turns
31
+ // never inherit the operator's ambient config.
32
+ // - `usage/changed` is ingested as soon as received; the throttled
33
+ // on-demand `usage/read` on the same host is the final read.
34
+ // - Restart/crash: usage state is process-local to the host, so a dead host
35
+ // restarts empty — `missing` until fresh provider traffic is observed on
36
+ // it. Never backfill from another host. No leaked child processes:
37
+ // every failure path kills the child and the next read restarts it;
38
+ // `shutdownMuseCapacityHost()` releases the host for clean shutdown.
39
+ // - Ingest is newest-`observedAtMs`-wins (`ingestMuseUsagePayload`): an
40
+ // older finishing read cannot clobber newer rows, and a fresh-host
41
+ // `missing` never overwrites or deletes a newer known pair.
42
+ // - Failures preserve last-good rows (`noteMuseCapacityFailure`): a failure
43
+ // diagnostic never sits beside valid windows, and a successful recovery
44
+ // clears the sentinel.
45
+ // - Capacity stays independent from `runtime_availability` hard-cap
46
+ // admission: this module never reads or writes health rows.
47
+ // - The client enforces the read-only allowlist (`initialize`,
48
+ // `initialized`, `usage/read`) — a capacity read by itself never sends
49
+ // `session/start`, a prompt, a turn, a tool, or any other billable method.
50
+ //
51
+ // Execution precondition (NOT-269): only a host that observes the account's
52
+ // provider traffic can answer `usage/read`. Real Dealer Muse turns run
53
+ // through THIS host (`session/start` + `turn/start` via the execution lane
54
+ // below, driven by runners/muse-serve-session.ts) — that traffic is the
55
+ // observation, so the session-boundary refresh hook
56
+ // (`refreshMuseCapacityAfterSession` in coordinator/muse-spawn.ts) is the
57
+ // final `usage/read` that populates 5H/1W. No synthetic model prompt is
58
+ // ever issued to refresh capacity. When the serve execution lane cannot
59
+ // admit a turn (host unavailable, unimplemented method, auth), the session
60
+ // falls back to the legacy `muse exec` subprocess before any model work
61
+ // starts — the host stays unobserved and the read stays honest N/A with
62
+ // last-good rows preserved.
63
+ //
64
+ // PRODUCT DECISION (2026-09-26, resolved via human_action on this ticket):
65
+ // the runner migration is in scope for NOT-270, in this same PR, not a
66
+ // follow-up ticket. Facts supporting that decision:
67
+ // - Sandbox-network posture is a fixed constant across every existing
68
+ // developer/reviewer invocation today (`--sandbox-network restricted`,
69
+ // see runners/muse-code-args.ts) — there is no per-session variation to
70
+ // lose. `muse serve --sandbox-network restricted` at host startup
71
+ // reproduces it exactly; this is a one-line host-launch change, not a
72
+ // rewrite.
73
+ // - Approval mode is already wire-selectable per session
74
+ // (`SessionStartParams.approvalMode`), matching today's
75
+ // `--approval-mode never`.
76
+ // - Re-verify the NOT-177/179/181 sandbox contracts (shell/write/network
77
+ // restriction actually holds under `serve`) using a free
78
+ // `--provider echo` session/turn on the host — no live paid Meta turn is
79
+ // needed for this verification.
80
+ // - Accepted, explicit tradeoff (not an oversight): `muse serve` has no
81
+ // wire- or host-level equivalent to `--disable-web-tools` or
82
+ // `--no-foreign-personal-context` in this Muse version (1.4.0). Real
83
+ // turns routed through the owned host run without those two specific
84
+ // restrictions. Everything else (network sandbox, approval mode, write/
85
+ // shell restriction) is unaffected.
86
+ // This question was escalated twice on this ticket (product_scope_decision,
87
+ // resolved both times); the decision above is final for NOT-270's scope.
88
+ import { spawn } from "node:child_process";
89
+ import fs from "node:fs";
90
+ import os from "node:os";
91
+ import path from "node:path";
92
+ import { MUSE_CLI_ENV, resolveMuseAuthFile, resolveMuseBin } from "../cli-env.js";
93
+ import { buildMuseDeveloperSettings } from "../runners/muse-code-settings.js";
94
+ import { MSP_USAGE_CHANGED, MUSE_CLIENT_INFO, MUSE_SERVE_ARGV, assertMuseExecMethod, assertMuseReadOnlyMethod, hasMuseCredential, ingestMuseUsagePayload, museCapacityTimeoutMs, museClassifyRpcError, museEvidenceRef, museRefreshThrottleMs, museUuidv7, noteMuseCapacityFailure, parseMuseRpcLine, } from "./muse.js";
95
+ /**
96
+ * NOT-270 serve lane: the owned host must run workers under the same
97
+ * posture as the exec lane's per-attempt settings — no MCP servers, no
98
+ * subagent delegation, no workflows, no reminder child runs — so serve-lane
99
+ * turns never inherit the operator's ambient MCP/subagent config. Builds a
100
+ * server-owned config home carrying `buildMuseDeveloperSettings()` plus a
101
+ * symlink to the ambient login (linked, never read or copied; absent when
102
+ * the operator authenticates with META_API_KEY), and an isolated data home
103
+ * for the host's own session/state. Mirrors the exec lane's per-attempt
104
+ * dirs (`coordinator/muse-spawn.ts`), but process-scoped: one home per host
105
+ * instance, reused across restarts, removed on shutdown.
106
+ */
107
+ export function prepareMuseServeHome(ambientAuthFile = resolveMuseAuthFile()) {
108
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-serve-"));
109
+ const configHome = path.join(root, "config");
110
+ const dataHome = path.join(root, "data");
111
+ fs.mkdirSync(path.join(configHome, "muse"), { recursive: true, mode: 0o700 });
112
+ fs.mkdirSync(dataHome, { recursive: true, mode: 0o700 });
113
+ // mkdir's mode is masked by the umask; the home holds a login symlink.
114
+ fs.chmodSync(root, 0o700);
115
+ fs.chmodSync(configHome, 0o700);
116
+ fs.chmodSync(dataHome, 0o700);
117
+ fs.writeFileSync(path.join(configHome, "muse", "settings.json"), `${JSON.stringify(buildMuseDeveloperSettings(), null, 2)}\n`, { mode: 0o600 });
118
+ let authLinked = false;
119
+ try {
120
+ if (fs.existsSync(ambientAuthFile)) {
121
+ fs.symlinkSync(ambientAuthFile, path.join(configHome, "muse", "auth.json"));
122
+ authLinked = true;
123
+ }
124
+ }
125
+ catch {
126
+ // Best-effort: the credential check in start() decides admission, and
127
+ // API-key auth needs no link at all.
128
+ }
129
+ return { configHome, dataHome, authLinked };
130
+ }
131
+ function readOnlyRequest(id, method, params) {
132
+ assertMuseReadOnlyMethod(method);
133
+ return `${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`;
134
+ }
135
+ function readOnlyNotification(method, params) {
136
+ assertMuseReadOnlyMethod(method);
137
+ return `${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`;
138
+ }
139
+ function execRequestLine(id, method, params) {
140
+ assertMuseExecMethod(method);
141
+ return `${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`;
142
+ }
143
+ /**
144
+ * One long-lived connection. A new instance is created per process (plus
145
+ * per test reset); the module singleton below guarantees at most one live
146
+ * instance per process at a time.
147
+ */
148
+ export class MuseCapacityHost {
149
+ opts;
150
+ child = null;
151
+ dead = true;
152
+ starting = null;
153
+ inflightRead = null;
154
+ pending = new Map();
155
+ /** Live execution-turn subscribers (turn/completed, item/*, ...). */
156
+ notifWaiters = new Set();
157
+ buffer = "";
158
+ stderrTail = "";
159
+ readSeq = 0;
160
+ /**
161
+ * True once this connection has observed provider traffic (a `usage/read`
162
+ * or `usage/changed` ingest that wrote rows). Only an observed host is
163
+ * held open — an unobserved one is released after the read. Reset on
164
+ * every (re)spawn: usage state is process-local, so a restarted host
165
+ * starts unobserved.
166
+ */
167
+ observedOnConnection = false;
168
+ /** Increments on every spawn — tests use it to prove host reuse/restart. */
169
+ connectionEpoch = 0;
170
+ /**
171
+ * Server-owned XDG home for the host (worker settings + auth link).
172
+ * Built lazily on first spawn, reused across restarts, removed on
173
+ * shutdown.
174
+ */
175
+ serveHome = null;
176
+ constructor(opts = {}) {
177
+ this.opts = { ...opts };
178
+ }
179
+ /** True while a live child backs this host. */
180
+ isConnected() {
181
+ return !this.dead && this.child !== null && this.child.exitCode === null;
182
+ }
183
+ /**
184
+ * True while a real execution turn (or its admission) is using the shared
185
+ * connection: an unanswered execution request id, or a live
186
+ * `turn/completed` subscriber. A capacity read must never tear the host
187
+ * down while this is true — on a fresh host the first turn is still
188
+ * unobserved, so an unconditional unobserved-host release would SIGTERM
189
+ * the admitted billable turn and fail it with 'no turn completion
190
+ * observed'.
191
+ */
192
+ hasInflightExecution() {
193
+ return this.pending.size > 0 || this.notifWaiters.size > 0;
194
+ }
195
+ /**
196
+ * Env for the host subprocess: the server-owned XDG home always wins over
197
+ * inherited ambient config (the server process itself may run under an
198
+ * operator XDG home), so serve-lane workers never inherit ambient MCP
199
+ * servers, subagents, or workflows. Only an explicit XDG override in
200
+ * `opts.env` (tests) still wins.
201
+ */
202
+ mergedEnv() {
203
+ const home = this.ensureServeHome();
204
+ const env = { ...process.env, ...this.opts.env };
205
+ if (this.opts.env?.XDG_CONFIG_HOME === undefined)
206
+ env.XDG_CONFIG_HOME = home.configHome;
207
+ if (this.opts.env?.XDG_DATA_HOME === undefined)
208
+ env.XDG_DATA_HOME = home.dataHome;
209
+ return env;
210
+ }
211
+ /** Lazily built server-owned home; rebuilt if removed (e.g. after shutdown). */
212
+ ensureServeHome() {
213
+ const home = this.serveHome;
214
+ if (home) {
215
+ try {
216
+ if (fs.existsSync(path.join(home.configHome, "muse", "settings.json")))
217
+ return home;
218
+ }
219
+ catch {
220
+ // Fall through and rebuild.
221
+ }
222
+ }
223
+ this.serveHome = prepareMuseServeHome();
224
+ return this.serveHome;
225
+ }
226
+ /**
227
+ * SIGTERM the child and release it. A wedged host can ignore SIGTERM, so
228
+ * every kill arms a one-shot 2 s SIGKILL escalation against that exact
229
+ * child — the timer holds only the old handle, so it can never signal a
230
+ * replacement host spawned later. SIGKILL is the backstop for wedged
231
+ * hosts only: a host that exits on SIGTERM is never signalled again.
232
+ *
233
+ * Notification waiters resolve null here so execution turns fail fast on
234
+ * host death instead of hanging until their own timeout.
235
+ */
236
+ killChild() {
237
+ const child = this.child;
238
+ this.child = null;
239
+ this.dead = true;
240
+ for (const [, p] of this.pending) {
241
+ try {
242
+ p.resolve({});
243
+ }
244
+ catch {
245
+ // Never throw out of teardown.
246
+ }
247
+ }
248
+ this.pending.clear();
249
+ for (const w of this.notifWaiters) {
250
+ clearTimeout(w.timer);
251
+ try {
252
+ w.resolve(null);
253
+ }
254
+ catch {
255
+ // Never throw out of teardown.
256
+ }
257
+ }
258
+ this.notifWaiters.clear();
259
+ this.buffer = "";
260
+ try {
261
+ child?.kill();
262
+ }
263
+ catch {
264
+ // Already exited — nothing to signal.
265
+ }
266
+ if (child && child.exitCode === null) {
267
+ const killer = setTimeout(() => {
268
+ try {
269
+ if (child.exitCode === null)
270
+ child.kill("SIGKILL");
271
+ }
272
+ catch {
273
+ // Already exited — nothing to signal.
274
+ }
275
+ }, 2000);
276
+ killer.unref?.();
277
+ }
278
+ }
279
+ /** True when `child` is still the live backing process for this host. */
280
+ isCurrentChild(child) {
281
+ return this.child === child && !this.dead;
282
+ }
283
+ onLine(line) {
284
+ const msg = parseMuseRpcLine(line);
285
+ if (!msg)
286
+ return;
287
+ if (typeof msg.method === "string" && msg.id === undefined) {
288
+ if (msg.method === MSP_USAGE_CHANGED) {
289
+ // Passive observation from real provider traffic on this host —
290
+ // ingest immediately, newest-observedAtMs-wins.
291
+ void ingestMuseUsagePayload(msg.params ?? null, {
292
+ evidenceRef: museEvidenceRef("changed"),
293
+ })
294
+ .then((outcome) => {
295
+ // A changed-notification that wrote rows means this host has
296
+ // observed traffic: it is worth holding open.
297
+ if (outcome.written.length > 0)
298
+ this.observedOnConnection = true;
299
+ })
300
+ .catch(() => {
301
+ // Best-effort: a failed ingest never breaks the connection.
302
+ });
303
+ }
304
+ // Execution-turn subscribers (turn/completed, item/*, ...). A waiter
305
+ // that fires is removed; waiters never see each other's messages.
306
+ for (const w of [...this.notifWaiters]) {
307
+ let match = false;
308
+ try {
309
+ match = w.predicate(msg);
310
+ }
311
+ catch {
312
+ match = false;
313
+ }
314
+ if (match) {
315
+ this.notifWaiters.delete(w);
316
+ clearTimeout(w.timer);
317
+ try {
318
+ w.resolve(msg);
319
+ }
320
+ catch {
321
+ // Never throw out of the read loop.
322
+ }
323
+ }
324
+ }
325
+ return;
326
+ }
327
+ if (typeof msg.id === "string") {
328
+ const p = this.pending.get(msg.id);
329
+ if (p) {
330
+ this.pending.delete(msg.id);
331
+ p.resolve({ result: msg.result, error: msg.error });
332
+ }
333
+ }
334
+ }
335
+ /**
336
+ * NOT-270 execution lane: one JSON-RPC request for REAL Dealer work
337
+ * (`session/start`, `turn/start`, `turn/cancel`, `session/read` only —
338
+ * anything else throws before write). Multiplexed over the single shared
339
+ * connection by request id, so concurrent sessions share the one owned
340
+ * host and there is never a second Muse execution host per request.
341
+ * Resolves null on timeout, dead host, or unwritable stdin.
342
+ */
343
+ async execRequest(id, method, params, timeoutMs) {
344
+ assertMuseExecMethod(method);
345
+ if (this.dead || !this.child || this.child.exitCode !== null)
346
+ return null;
347
+ return new Promise((resolve) => {
348
+ const timer = setTimeout(() => {
349
+ this.pending.delete(id);
350
+ resolve(null);
351
+ }, timeoutMs);
352
+ timer.unref?.();
353
+ this.pending.set(id, {
354
+ resolve: (value) => {
355
+ clearTimeout(timer);
356
+ resolve(value);
357
+ },
358
+ });
359
+ try {
360
+ this.child?.stdin?.write(execRequestLine(id, method, params));
361
+ }
362
+ catch {
363
+ this.pending.delete(id);
364
+ clearTimeout(timer);
365
+ resolve(null);
366
+ }
367
+ });
368
+ }
369
+ /**
370
+ * Subscribe to one host notification (e.g. `turn/completed` for a session).
371
+ * Resolves with the first matching message, null on timeout or host death.
372
+ * Concurrent turns each hold their own waiter on the shared connection.
373
+ */
374
+ async waitForHostNotification(predicate, timeoutMs) {
375
+ if (this.dead)
376
+ return null;
377
+ return new Promise((resolve) => {
378
+ const waiter = {
379
+ predicate,
380
+ resolve: (msg) => {
381
+ clearTimeout(waiter.timer);
382
+ this.notifWaiters.delete(waiter);
383
+ resolve(msg);
384
+ },
385
+ timer: setTimeout(() => {
386
+ this.notifWaiters.delete(waiter);
387
+ resolve(null);
388
+ }, timeoutMs),
389
+ };
390
+ waiter.timer.unref?.();
391
+ this.notifWaiters.add(waiter);
392
+ });
393
+ }
394
+ /**
395
+ * Best-effort `turn/cancel` for a timed-out or aborted execution turn.
396
+ * Never throws; the caller owns the timeout/abort verdict regardless.
397
+ */
398
+ async cancelExecTurn(sessionId, turnId) {
399
+ try {
400
+ if (this.dead)
401
+ return;
402
+ this.readSeq += 1;
403
+ await this.execRequest(`muse-cancel-${this.connectionEpoch}-${this.readSeq}-${Date.now()}`, "turn/cancel", {
404
+ commandId: museUuidv7(),
405
+ sessionId,
406
+ ...(turnId ? { turnId } : {}),
407
+ }, 10_000);
408
+ }
409
+ catch {
410
+ // Best-effort: cancellation must never break the caller's verdict.
411
+ }
412
+ }
413
+ sendRequest(id, method, params, timeoutMs) {
414
+ return new Promise((resolve) => {
415
+ const timer = setTimeout(() => {
416
+ this.pending.delete(id);
417
+ resolve(null);
418
+ }, timeoutMs);
419
+ timer.unref?.();
420
+ this.pending.set(id, {
421
+ resolve: (value) => {
422
+ clearTimeout(timer);
423
+ resolve(value);
424
+ },
425
+ });
426
+ try {
427
+ this.child?.stdin?.write(readOnlyRequest(id, method, params));
428
+ }
429
+ catch {
430
+ this.pending.delete(id);
431
+ clearTimeout(timer);
432
+ resolve(null);
433
+ }
434
+ });
435
+ }
436
+ /**
437
+ * Start (or reuse) the connection. Single-flight across callers: a start
438
+ * already in flight is always joined first. (The child is assigned before
439
+ * the handshake completes, so checking `isConnected()` first would let a
440
+ * second caller send requests down a half-open connection — session/start
441
+ * arriving before `initialized` is rejected by the host.)
442
+ */
443
+ async ensureStarted() {
444
+ if (this.starting)
445
+ return this.starting;
446
+ if (this.isConnected())
447
+ return true;
448
+ this.starting = this.start();
449
+ try {
450
+ return await this.starting;
451
+ }
452
+ finally {
453
+ this.starting = null;
454
+ }
455
+ }
456
+ async start() {
457
+ this.killChild();
458
+ const timeoutMs = this.opts.timeoutMs ?? museCapacityTimeoutMs();
459
+ const mergedEnv = this.mergedEnv();
460
+ if (!hasMuseCredential(mergedEnv, this.opts.authFilePath ?? resolveMuseAuthFile())) {
461
+ return false;
462
+ }
463
+ const command = this.opts.command ?? resolveMuseBin();
464
+ const args = this.opts.args ?? [...MUSE_SERVE_ARGV];
465
+ let child;
466
+ try {
467
+ child = spawn(command, args, {
468
+ stdio: ["pipe", "pipe", "pipe"],
469
+ env: { ...mergedEnv, ...MUSE_CLI_ENV },
470
+ });
471
+ }
472
+ catch (err) {
473
+ this.startFailure = err?.code === "ENOENT" ? "unsupported" : null;
474
+ return false;
475
+ }
476
+ this.child = child;
477
+ this.dead = false;
478
+ this.connectionEpoch += 1;
479
+ // Usage state is process-local: a (re)spawned host starts unobserved.
480
+ this.observedOnConnection = false;
481
+ this.stderrTail = "";
482
+ child.stderr?.on("data", (buf) => {
483
+ // Stderr is diagnostic-only; attribute it only while this child is live.
484
+ if (!this.isCurrentChild(child))
485
+ return;
486
+ this.stderrTail = `${this.stderrTail}${buf.toString()}`.slice(-2000);
487
+ });
488
+ child.stdout?.on("data", (buf) => {
489
+ // A previous child killed for restart may still flush output after its
490
+ // replacement spawns — never let a stale child feed the live buffer
491
+ // or tear the new connection down.
492
+ if (!this.isCurrentChild(child))
493
+ return;
494
+ this.buffer += buf.toString();
495
+ if (this.buffer.length > 10 * 1024 * 1024) {
496
+ this.killChild();
497
+ return;
498
+ }
499
+ let idx;
500
+ while ((idx = this.buffer.indexOf("\n")) >= 0) {
501
+ const line = this.buffer.slice(0, idx);
502
+ this.buffer = this.buffer.slice(idx + 1);
503
+ this.onLine(line);
504
+ if (this.dead)
505
+ return;
506
+ }
507
+ });
508
+ child.on("error", () => {
509
+ if (!this.isCurrentChild(child))
510
+ return;
511
+ this.killChild();
512
+ });
513
+ child.stdin?.on("error", () => {
514
+ if (!this.isCurrentChild(child))
515
+ return;
516
+ this.killChild();
517
+ });
518
+ child.on("close", () => {
519
+ // Process-local usage state dies with the host (NOT-269 restart rule).
520
+ // Guarded: a SIGTERMed hung child that exits after a restart spawned
521
+ // its replacement must not kill the new host.
522
+ if (!this.isCurrentChild(child))
523
+ return;
524
+ this.killChild();
525
+ });
526
+ const initId = `muse-capacity-init-${this.connectionEpoch}`;
527
+ const init = await this.sendRequest(initId, "initialize", { clientInfo: { ...MUSE_CLIENT_INFO } }, timeoutMs);
528
+ if (this.dead || !init || init.error) {
529
+ if (init?.error) {
530
+ const kind = museClassifyRpcError(init.error);
531
+ this.startFailure =
532
+ kind === "auth" ? "auth" : kind === "unsupported" ? "unsupported" : null;
533
+ }
534
+ this.killChild();
535
+ return false;
536
+ }
537
+ try {
538
+ child.stdin?.write(readOnlyNotification("initialized", {}));
539
+ }
540
+ catch {
541
+ this.killChild();
542
+ return false;
543
+ }
544
+ return true;
545
+ }
546
+ startFailure = null;
547
+ /**
548
+ * Final `usage/read` on the owned host. Single-flight: concurrent
549
+ * callers share one read on the one connection. Ingests newest-wins;
550
+ * a fresh-host `missing` preserves stored rows.
551
+ */
552
+ async readUsage() {
553
+ if (this.inflightRead)
554
+ return this.inflightRead;
555
+ this.inflightRead = this.read();
556
+ try {
557
+ const outcome = await this.inflightRead;
558
+ if (outcome.status === "observed") {
559
+ this.observedOnConnection = true;
560
+ }
561
+ else if (!this.observedOnConnection && !this.hasInflightExecution()) {
562
+ // The host observed nothing and holds no state worth keeping:
563
+ // release the child instead of parking a lifetime process that
564
+ // can only answer `missing`. The next refresh transparently
565
+ // respawns it; last-good DB rows are untouched either way.
566
+ // Skipped while an execution turn is using the connection — the
567
+ // release must never SIGTERM an admitted turn (see
568
+ // hasInflightExecution).
569
+ this.killChild();
570
+ }
571
+ return outcome;
572
+ }
573
+ finally {
574
+ this.inflightRead = null;
575
+ }
576
+ }
577
+ async read() {
578
+ const timeoutMs = this.opts.timeoutMs ?? museCapacityTimeoutMs();
579
+ this.startFailure = null;
580
+ const started = await this.ensureStarted();
581
+ if (!started) {
582
+ if (this.startFailure === "unsupported") {
583
+ await noteMuseCapacityFailure("unsupported");
584
+ return { status: "failure", reason: "unsupported" };
585
+ }
586
+ // No credential, spawn failure, timeout, or bad exit before the
587
+ // handshake: honest `missing`, last-good rows preserved.
588
+ console.error("[muse-capacity] host start failed: missing");
589
+ await noteMuseCapacityFailure("missing");
590
+ return { status: "missing" };
591
+ }
592
+ this.readSeq += 1;
593
+ const id = `muse-capacity-${this.connectionEpoch}-${this.readSeq}`;
594
+ const res = await this.sendRequest(id, "usage/read", {}, timeoutMs);
595
+ if (this.dead || !res) {
596
+ console.error("[muse-capacity] host read failed: timeout");
597
+ // Never tear down under an admitted turn: a capacity timeout must not
598
+ // become a turn failure. The turn's own timeout/cancel owns that
599
+ // verdict; the hung child is reaped when the turn settles or when a
600
+ // later read finds the connection idle.
601
+ if (!this.hasInflightExecution())
602
+ this.killChild();
603
+ await noteMuseCapacityFailure("missing");
604
+ return { status: "missing" };
605
+ }
606
+ if (res.error) {
607
+ const kind = museClassifyRpcError(res.error);
608
+ if (kind === "auth") {
609
+ console.error("[muse-capacity] host read failed: unauthenticated");
610
+ await noteMuseCapacityFailure("missing");
611
+ return { status: "missing" };
612
+ }
613
+ if (kind === "unsupported") {
614
+ console.error("[muse-capacity] host read failed: unsupported");
615
+ await noteMuseCapacityFailure("unsupported");
616
+ return { status: "failure", reason: "unsupported" };
617
+ }
618
+ console.error("[muse-capacity] host read failed: malformed");
619
+ await noteMuseCapacityFailure("unparsable");
620
+ return { status: "failure", reason: "unparsable" };
621
+ }
622
+ let payload = res.result;
623
+ if (payload &&
624
+ typeof payload === "object" &&
625
+ !("usage" in payload) &&
626
+ payload.result !== undefined) {
627
+ payload = payload.result;
628
+ }
629
+ const outcome = await ingestMuseUsagePayload(payload, {
630
+ evidenceRef: museEvidenceRef("read"),
631
+ });
632
+ if (outcome.written.length === 0) {
633
+ // Fresh/unobserved host: `usage` omitted — honest `missing`, never a
634
+ // write, so a newer known pair is never overwritten or deleted.
635
+ await noteMuseCapacityFailure("missing");
636
+ return { status: "missing" };
637
+ }
638
+ return { status: "observed", written: outcome.written, skippedStale: outcome.skippedStale };
639
+ }
640
+ /**
641
+ * Clean shutdown: release the child so no host process leaks.
642
+ * Graceful-first — a host that exits on SIGTERM is never signalled again
643
+ * (SIGKILL is the backstop for wedged hosts only). The close is awaited
644
+ * for a bounded grace window and a still-alive host is SIGKILLed before
645
+ * returning, so teardown never leaks the child and never depends on a
646
+ * timer the exiting event loop may never run.
647
+ */
648
+ async shutdown() {
649
+ const child = this.child;
650
+ this.killChild();
651
+ this.starting = null;
652
+ this.inflightRead = null;
653
+ // The serve home is per-instance: remove it so restarts and test resets
654
+ // leave no litter. A later ensureStarted() transparently rebuilds it.
655
+ const home = this.serveHome;
656
+ this.serveHome = null;
657
+ if (home) {
658
+ try {
659
+ fs.rmSync(path.dirname(home.configHome), { recursive: true, force: true });
660
+ }
661
+ catch {
662
+ // Best-effort cleanup only.
663
+ }
664
+ }
665
+ if (!child || child.exitCode !== null)
666
+ return;
667
+ const closed = await new Promise((resolve) => {
668
+ if (child.exitCode !== null) {
669
+ resolve(true);
670
+ return;
671
+ }
672
+ const timer = setTimeout(() => resolve(false), 2000);
673
+ timer.unref?.();
674
+ child.once("close", () => {
675
+ clearTimeout(timer);
676
+ resolve(true);
677
+ });
678
+ });
679
+ if (!closed) {
680
+ try {
681
+ if (child.exitCode === null)
682
+ child.kill("SIGKILL");
683
+ }
684
+ catch {
685
+ // Already exited — nothing to signal.
686
+ }
687
+ }
688
+ }
689
+ }
690
+ // ---------------------------------------------------------------------------
691
+ // Process singleton + production refresh
692
+ // ---------------------------------------------------------------------------
693
+ let sharedHost = null;
694
+ let lastMuseHostRefreshMs = 0;
695
+ /** The server-owned host (created on first use). */
696
+ export function getMuseCapacityHost(opts = {}) {
697
+ if (!sharedHost)
698
+ sharedHost = new MuseCapacityHost(opts);
699
+ return sharedHost;
700
+ }
701
+ /** Test helper — shut the shared host down so the next use starts fresh. */
702
+ export async function resetMuseCapacityHostForTests() {
703
+ lastMuseHostRefreshMs = 0;
704
+ const host = sharedHost;
705
+ sharedHost = null;
706
+ await host?.shutdown();
707
+ }
708
+ /** Clean shutdown for server teardown. */
709
+ export async function shutdownMuseCapacityHost() {
710
+ const host = sharedHost;
711
+ sharedHost = null;
712
+ await host?.shutdown();
713
+ }
714
+ /** Test helper — reset the refresh throttle so the next refresh runs. */
715
+ export function resetMuseCapacityRefreshState() {
716
+ lastMuseHostRefreshMs = 0;
717
+ }
718
+ /**
719
+ * Bounded refresh through the owned host: one final `usage/read` on the
720
+ * connection that observed the account's provider traffic, ingested
721
+ * newest-`observedAtMs`-wins. Reuses the shared snapshot path; failures
722
+ * preserve last-good rows as N/A, never as health rows.
723
+ */
724
+ export async function refreshMuseCapacityFromHost(opts = {}) {
725
+ const { getRuntimeCapacitySnapshot } = await import("./service.js");
726
+ const { nowMs, ...hostOpts } = opts;
727
+ const host = getMuseCapacityHost(hostOpts);
728
+ await host.readUsage();
729
+ return getRuntimeCapacitySnapshot(nowMs ?? Date.now());
730
+ }
731
+ /**
732
+ * NOT-270 production trigger for Muse capacity: throttled, bounded,
733
+ * best-effort. Returns the refreshed snapshot, or null when
734
+ * throttled/disabled. Never throws — callers (routes) serve the
735
+ * last-known snapshot on null.
736
+ */
737
+ export async function maybeRefreshMuseCapacityFromHost(opts = {}, nowMs = Date.now()) {
738
+ const throttle = museRefreshThrottleMs();
739
+ if (!Number.isFinite(throttle) || nowMs - lastMuseHostRefreshMs < throttle)
740
+ return null;
741
+ lastMuseHostRefreshMs = nowMs;
742
+ try {
743
+ return await refreshMuseCapacityFromHost({ ...opts, nowMs });
744
+ }
745
+ catch {
746
+ return null;
747
+ }
748
+ }