agent-dealer 1.2.5 → 1.2.7

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 (75) hide show
  1. package/bundle/server/dist/adapters/agent-deck-bind.js +33 -3
  2. package/bundle/server/dist/adapters/agent-deck-bind.test.js +60 -2
  3. package/bundle/server/dist/adapters/agent-health.js +3 -4
  4. package/bundle/server/dist/adapters/agent-health.test.js +11 -9
  5. package/bundle/server/dist/adapters/git-worktree.js +187 -18
  6. package/bundle/server/dist/adapters/git-worktree.test.js +293 -0
  7. package/bundle/server/dist/adapters/muse-capability.js +51 -25
  8. package/bundle/server/dist/adapters/muse-capability.test.js +138 -52
  9. package/bundle/server/dist/capacity/claude-local-cache.js +59 -27
  10. package/bundle/server/dist/capacity/claude-local-cache.test.js +163 -9
  11. package/bundle/server/dist/capacity/muse-host.js +34 -32
  12. package/bundle/server/dist/capacity/muse-host.test.js +5 -3
  13. package/bundle/server/dist/coordinator/args.js +4 -3
  14. package/bundle/server/dist/coordinator/checkpoint.js +5 -3
  15. package/bundle/server/dist/coordinator/checkpoint.test.js +23 -0
  16. package/bundle/server/dist/coordinator/commands.js +66 -5
  17. package/bundle/server/dist/coordinator/developer-effect.js +88 -10
  18. package/bundle/server/dist/coordinator/developer-effect.test.js +170 -0
  19. package/bundle/server/dist/coordinator/human-resolution.js +2 -0
  20. package/bundle/server/dist/coordinator/muse-developer.integration.test.js +291 -55
  21. package/bundle/server/dist/coordinator/muse-spawn.js +72 -174
  22. package/bundle/server/dist/coordinator/projection.js +1 -0
  23. package/bundle/server/dist/coordinator/prompts.js +10 -6
  24. package/bundle/server/dist/coordinator/prompts.test.js +16 -8
  25. package/bundle/server/dist/coordinator/routing.js +1 -0
  26. package/bundle/server/dist/coordinator/routing.test.js +20 -0
  27. package/bundle/server/dist/coordinator/spawn.js +2 -1
  28. package/bundle/server/dist/coordinator/worktree-owner-liveness.js +6 -1
  29. package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +2 -1
  30. package/bundle/server/dist/repository/human-actions.js +13 -0
  31. package/bundle/server/dist/routes/index.js +11 -8
  32. package/bundle/server/dist/routes/version.js +30 -0
  33. package/bundle/server/dist/routes/version.test.js +21 -0
  34. package/bundle/server/dist/runners/muse-config-core.js +63 -16
  35. package/bundle/server/dist/runners/muse-config.js +1 -1
  36. package/bundle/server/dist/runners/muse-config.test.js +135 -29
  37. package/bundle/server/dist/runners/spawn-cli.js +17 -3
  38. package/bundle/server/dist/runners/spawn-cli.test.js +59 -0
  39. package/bundle/server/package.json +2 -2
  40. package/bundle/server/static-ui/assets/index-DyAJNyfV.css +1 -0
  41. package/bundle/server/static-ui/assets/index-yLyxRd-7.js +64 -0
  42. package/bundle/server/static-ui/index.html +2 -2
  43. package/bundle/shared/dist/action-presentation.d.ts +51 -0
  44. package/bundle/shared/dist/action-presentation.js +280 -0
  45. package/bundle/shared/dist/action-presentation.test.d.ts +1 -0
  46. package/bundle/shared/dist/action-presentation.test.js +180 -0
  47. package/bundle/shared/dist/attempt-waste.d.ts +2 -2
  48. package/bundle/shared/dist/execution-report.d.ts +2 -2
  49. package/bundle/shared/dist/human-actions.d.ts +12 -12
  50. package/bundle/shared/dist/index.d.ts +41 -40
  51. package/bundle/shared/dist/index.js +1 -0
  52. package/bundle/shared/dist/issues.d.ts +12 -12
  53. package/bundle/shared/dist/outbound-draft.d.ts +6 -6
  54. package/bundle/shared/dist/profile-snapshot.d.ts +2 -2
  55. package/bundle/shared/dist/usage-events.d.ts +2 -2
  56. package/bundle/shared/dist/worker-sessions.d.ts +6 -6
  57. package/bundle/shared/dist/workflow.d.ts +2 -2
  58. package/bundle/shared/package.json +1 -1
  59. package/dist/doctor.js +4 -1
  60. package/dist/managed/index.d.ts +1 -1
  61. package/dist/managed/index.js +1 -1
  62. package/dist/managed/updater.d.ts +16 -6
  63. package/dist/managed/updater.js +28 -11
  64. package/dist/managed-update-restart.test.d.ts +1 -0
  65. package/dist/managed-update-restart.test.js +227 -0
  66. package/dist/ports.d.ts +6 -0
  67. package/dist/ports.js +10 -0
  68. package/dist/runtime-state.d.ts +10 -0
  69. package/dist/runtime-state.js +26 -0
  70. package/dist/start.js +67 -2
  71. package/dist/status.js +30 -2
  72. package/dist/update-check.js +3 -6
  73. package/package.json +1 -1
  74. package/bundle/server/static-ui/assets/index-BII-LgB8.css +0 -1
  75. package/bundle/server/static-ui/assets/index-Bq8wWpZm.js +0 -60
@@ -3,13 +3,27 @@
3
3
  // NOT-277: Muse Code developer shell/write capability is re-validated once per newly reported
4
4
  // version — cached per version, auto-confirmed on success, blocked by name on loss, fail-closed
5
5
  // when the check cannot complete. The real probe runs against fixtures/fake-muse.mjs (never Meta).
6
- import { describe, test, beforeEach } from "node:test";
6
+ import { describe, test, beforeEach, before, after } from "node:test";
7
7
  import assert from "node:assert/strict";
8
+ import { randomUUID } from "node:crypto";
8
9
  import fs from "node:fs";
10
+ import http from "node:http";
11
+ import net from "node:net";
9
12
  import os from "node:os";
10
13
  import path from "node:path";
11
14
  import { fileURLToPath } from "node:url";
12
- process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-capability-"));
15
+ // NOT-278: the worker MCP config root (the per-attempt base dir) refuses temp dirs, so the
16
+ // dealer home for this file is a home scratch root, never OS temp. Removed in `after`
17
+ // below so local runs do not clutter $HOME.
18
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.homedir(), ".dealer-muse-capability-"));
19
+ after(() => {
20
+ try {
21
+ fs.rmSync(process.env.AGENT_DEALER_HOME, { recursive: true, force: true });
22
+ }
23
+ catch {
24
+ // best-effort — a failed rm must not fail the suite
25
+ }
26
+ });
13
27
  const { museCapabilityIssues, museCapabilityCheckInFlight, parseMuseVersion, settleMuseCapabilityCheckForTests, setMuseCapabilityProbeForTests, resetMuseCapabilityStateForTests, ageMuseCapabilityCheckForTests, defaultMuseCapabilityProbe, } = await import("./muse-capability.js");
14
28
  const OLD = "1.3.0-R3401.1";
15
29
  const NEW = "1.4.0-R4161.1";
@@ -225,20 +239,68 @@ describe("muse-capability", { concurrency: false }, () => {
225
239
  // The real probe: one developer-posture session against the fake Muse, always a fresh `muse exec`.
226
240
  describe("defaultMuseCapabilityProbe (fake muse)", { concurrency: false }, () => {
227
241
  const FAKE_MUSE = path.join(path.dirname(fileURLToPath(import.meta.url)), "../coordinator/fixtures/fake-muse.mjs");
242
+ // A stub Agent Deck API: only `/health` matters to the probe's cheap pre-spawn gate
243
+ // (the fake muse never dials the deck). Started once for this block on an ephemeral port.
244
+ let deckApiUrl = "";
245
+ let deckServer = null;
246
+ before(() => new Promise((resolve) => {
247
+ deckServer = http
248
+ .createServer((req, res) => {
249
+ if (req.url === "/health") {
250
+ res.writeHead(200, { "content-type": "text/plain" });
251
+ res.end("ok");
252
+ }
253
+ else {
254
+ res.writeHead(404);
255
+ res.end();
256
+ }
257
+ })
258
+ .listen(0, "127.0.0.1", () => {
259
+ deckApiUrl = `http://127.0.0.1:${deckServer.address().port}`;
260
+ resolve();
261
+ });
262
+ }));
263
+ // `closeAllConnections` first: fetch keep-alive sockets would otherwise hold the
264
+ // stub open and the runner would never exit.
265
+ after(() => new Promise((resolve) => {
266
+ if (!deckServer)
267
+ return resolve();
268
+ deckServer.closeAllConnections();
269
+ deckServer.close(() => resolve());
270
+ }));
271
+ /** A surely-closed loopback port: nothing answers, so the deck reads as unreachable. */
272
+ async function deadDeckApiUrl() {
273
+ const srv = net.createServer();
274
+ await new Promise((r) => srv.listen(0, "127.0.0.1", r));
275
+ const port = srv.address().port;
276
+ await new Promise((r) => srv.close(() => r()));
277
+ return `http://127.0.0.1:${port}`;
278
+ }
279
+ // The probe binds a real listed deck and preflights it before spawning; there is no
280
+ // live deck/MCP here, so tests substitute both steps (production defaults hit the live
281
+ // `fetchDecks` + `verifyWorkerDeckConnection`). Overrides exercise the fail-closed paths.
282
+ const PROBE_DECK_ID = "11111111-1111-4111-8111-111111111111";
228
283
  async function probeWith(scenario, extraEnv = {}, opts = {}) {
229
284
  const env = {
230
285
  MUSE_CLI: FAKE_MUSE,
231
286
  FAKE_MUSE_SCENARIO: scenario,
232
287
  FAKE_MUSE_VERSION: NEW,
288
+ // NOT-278: the probe runs the deck-required exec lane without a database — the endpoint
289
+ // comes from the env override and the credential from a fake API key on stdin. The
290
+ // stub above answers `/health`, so the cheap pre-spawn gate passes and the fake runs.
291
+ AGENT_DECK_API_URL: deckApiUrl,
292
+ META_API_KEY: "mk-test-fake-key-0123456789abcdef",
233
293
  ...extraEnv,
234
294
  };
235
- const keys = [...Object.keys(env), "AGENT_DEALER_MUSE_RUNNER"];
295
+ const keys = [...Object.keys(env)];
236
296
  const prev = Object.fromEntries(keys.map((k) => [k, process.env[k]]));
237
297
  Object.assign(process.env, env);
238
- // The serve lane is the production default; the probe must bypass it on its own.
239
- delete process.env.AGENT_DEALER_MUSE_RUNNER;
240
298
  try {
241
- return await defaultMuseCapabilityProbe(NEW, opts);
299
+ return await defaultMuseCapabilityProbe(NEW, {
300
+ timeoutMs: opts.timeoutMs,
301
+ listDecks: opts.listDecks ?? (async () => ({ ok: true, decks: [{ id: PROBE_DECK_ID, name: "probe" }] })),
302
+ verifyDeck: opts.verifyDeck ?? (async () => ({ ok: true })),
303
+ });
242
304
  }
243
305
  finally {
244
306
  for (const k of keys) {
@@ -260,6 +322,43 @@ describe("defaultMuseCapabilityProbe (fake muse)", { concurrency: false }, () =>
260
322
  const result = await probeWith("auth");
261
323
  assert.equal(result.status, "error");
262
324
  });
325
+ test("an unreachable deck fails closed before any model session is spent", async () => {
326
+ // The fixture would succeed and record under this scenario — an absent record proves no
327
+ // child ever spawned, so the deck outage cost a cheap health check, never a paid turn.
328
+ const record = path.join(process.env.AGENT_DEALER_HOME, `no-spawn-${randomUUID()}.json`);
329
+ const result = await probeWith("capability-shell", {
330
+ AGENT_DECK_API_URL: await deadDeckApiUrl(),
331
+ FAKE_MUSE_RECORD: record,
332
+ });
333
+ assert.equal(result.status, "error");
334
+ assert.match(result.detail, /Agent Deck is unreachable/);
335
+ assert.match(result.detail, /no model session spent/);
336
+ assert.equal(fs.existsSync(record), false, "no child spawned, so the fixture never recorded");
337
+ });
338
+ test("the probe binds the listed real deck id, never a synthetic one", async () => {
339
+ const record = path.join(process.env.AGENT_DEALER_HOME, `deck-id-${randomUUID()}.json`);
340
+ const result = await probeWith("capability-shell", { FAKE_MUSE_RECORD: record });
341
+ assert.deepEqual(result, { status: "capable" });
342
+ const seen = JSON.parse(fs.readFileSync(record, "utf8"));
343
+ assert.match(seen.settings, new RegExp(PROBE_DECK_ID));
344
+ assert.doesNotMatch(seen.settings, /00000000-0000-4000-a000-000000000000/);
345
+ });
346
+ test("a deck that responds but rejects the probe deck fails closed without a model session", async () => {
347
+ const record = path.join(process.env.AGENT_DEALER_HOME, `rejected-${randomUUID()}.json`);
348
+ const result = await probeWith("capability-shell", { FAKE_MUSE_RECORD: record }, { verifyDeck: async () => ({ ok: false, kind: "infra_failure", reason: "get_bound_deck returned deck other, expected probe" }) });
349
+ assert.equal(result.status, "error");
350
+ assert.match(result.detail, /rejected probe deck/);
351
+ assert.match(result.detail, /no model session spent/);
352
+ assert.equal(fs.existsSync(record), false, "no child spawned, so the fixture never recorded");
353
+ });
354
+ test("an empty deck list fails closed without a model session", async () => {
355
+ const record = path.join(process.env.AGENT_DEALER_HOME, `no-decks-${randomUUID()}.json`);
356
+ const result = await probeWith("capability-shell", { FAKE_MUSE_RECORD: record }, { listDecks: async () => ({ ok: true, decks: [] }) });
357
+ assert.equal(result.status, "error");
358
+ assert.match(result.detail, /no decks/);
359
+ assert.match(result.detail, /no model session spent/);
360
+ assert.equal(fs.existsSync(record), false, "no child spawned, so the fixture never recorded");
361
+ });
263
362
  test("a session that ran the shell but then timed out is could-not-verify, never capable", async () => {
264
363
  const result = await probeWith("capability-shell-then-hang", {}, { timeoutMs: 1500 });
265
364
  assert.equal(result.status, "error");
@@ -270,7 +369,7 @@ describe("defaultMuseCapabilityProbe (fake muse)", { concurrency: false }, () =>
270
369
  assert.equal(result.status, "error");
271
370
  assert.match(result.detail, /probe session (failed|exited)/);
272
371
  });
273
- test("the probe never runs on the shared serve host (it may still be the pre-update build)", async () => {
372
+ test("the probe never runs on the shared serve host (deck-enabled turns are always isolated exec)", async () => {
274
373
  const { getMuseCapacityHost, resetMuseCapacityHostForTests } = await import("../capacity/muse-host.js");
275
374
  await resetMuseCapacityHostForTests();
276
375
  let serveSpawns = 0;
@@ -299,49 +398,51 @@ describe("defaultMuseCapabilityProbe (fake muse)", { concurrency: false }, () =>
299
398
  assert.match(result.detail, /1\.5\.0-R5000\.1 after probing 1\.4\.0-R4161\.1/);
300
399
  });
301
400
  });
302
- // Admission checks the on-disk binary; developer work must not then run on a serve host that is
303
- // still an older (possibly broken) build. A capable → B broken → C capable, host restarted onto B.
304
- describe("developer sessions run only on the capability-checked build", { concurrency: false }, () => {
401
+ // NOT-278: deck-enabled developer turns always run the isolated `muse exec` lane — the shared
402
+ // serve host cannot carry per-session deck/workspace identity, so no version check can route a
403
+ // session onto it. The capability gate still admits (or blocks) the on-disk binary; the lane is
404
+ // always exec.
405
+ describe("deck-enabled developer sessions always use the isolated exec lane", { concurrency: false }, () => {
305
406
  const FAKE_MUSE = path.join(path.dirname(fileURLToPath(import.meta.url)), "../coordinator/fixtures/fake-muse.mjs");
306
- const B = NEW;
307
- const C = "1.4.0-R4302.1";
308
- /** One developer session with the shared host reporting `hostVersion`; returns the RPCs sent to it. */
309
- async function sessionWithHostOn(hostVersion) {
407
+ /** One deck-enabled developer session while a serve host exists; returns host spawn attempts. */
408
+ async function sessionBesideServeHost() {
310
409
  const { getMuseCapacityHost, resetMuseCapacityHostForTests } = await import("../capacity/muse-host.js");
311
410
  const { runMuseDeveloperSession } = await import("../coordinator/muse-spawn.js");
312
411
  await resetMuseCapacityHostForTests();
313
- const host = getMuseCapacityHost({
314
- command: process.execPath,
315
- args: [path.join(path.dirname(fileURLToPath(import.meta.url)), "../capacity/fixtures/fake-muse-serve.mjs")],
316
- env: { META_API_KEY: "test-fake-key", FAKE_MSP_MODE: "persistent-full" },
317
- readVersion: async () => hostVersion,
412
+ let serveSpawns = 0;
413
+ getMuseCapacityHost({
414
+ spawnImpl: (() => {
415
+ serveSpawns += 1;
416
+ throw new Error("serve host must not be used by a deck-enabled developer session");
417
+ }),
318
418
  });
319
- const sent = [];
320
- const execRequest = host.execRequest.bind(host);
321
- host.execRequest = (id, method, params, timeoutMs) => {
322
- sent.push(method);
323
- return execRequest(id, method, params, timeoutMs);
324
- };
325
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-bound-"));
326
- const keys = ["MUSE_CLI", "FAKE_MUSE_SCENARIO", "FAKE_MUSE_VERSION", "AGENT_DEALER_MUSE_RUNNER"];
419
+ // NOT-278: home scratch, never OS temp (the attempt rejects temp dirs).
420
+ const dir = fs.mkdtempSync(path.join(os.homedir(), ".dealer-muse-exec-"));
421
+ const configHome = fs.mkdtempSync(path.join(os.homedir(), ".dealer-muse-exec-cfg-"));
422
+ const keys = ["MUSE_CLI", "FAKE_MUSE_SCENARIO", "META_API_KEY", "XDG_CONFIG_HOME"];
327
423
  const prev = Object.fromEntries(keys.map((k) => [k, process.env[k]]));
328
- Object.assign(process.env, { MUSE_CLI: FAKE_MUSE, FAKE_MUSE_SCENARIO: "success", FAKE_MUSE_VERSION: C });
329
- delete process.env.AGENT_DEALER_MUSE_RUNNER;
424
+ Object.assign(process.env, {
425
+ MUSE_CLI: FAKE_MUSE,
426
+ FAKE_MUSE_SCENARIO: "success",
427
+ META_API_KEY: "mk-test-fake-key-0123456789abcdef",
428
+ XDG_CONFIG_HOME: configHome,
429
+ });
330
430
  try {
331
431
  const { execFileSync } = await import("node:child_process");
332
432
  execFileSync("git", ["init", "-q"], { cwd: dir });
333
433
  const run = await runMuseDeveloperSession({
334
- sessionId: "bound-session",
434
+ sessionId: randomUUID(),
335
435
  runtime: "muse_code",
336
436
  policy: {},
337
437
  model: null,
438
+ deckId: "00000000-0000-4000-a000-000000000099",
439
+ agentDeckUrl: "http://127.0.0.1:1110/mcp",
338
440
  prompt: "implement",
339
441
  cwd: dir,
340
442
  timeoutMs: 30_000,
341
443
  logPath: path.join(dir, "session.ndjson"),
342
444
  });
343
- assert.equal(run.exitCode, 0);
344
- return sent;
445
+ return { exitCode: run.exitCode, serveSpawns };
345
446
  }
346
447
  finally {
347
448
  for (const k of keys) {
@@ -352,27 +453,12 @@ describe("developer sessions run only on the capability-checked build", { concur
352
453
  }
353
454
  await resetMuseCapacityHostForTests();
354
455
  fs.rmSync(dir, { recursive: true, force: true });
456
+ fs.rmSync(configHome, { recursive: true, force: true });
355
457
  }
356
458
  }
357
- beforeEach(() => {
358
- resetMuseCapabilityStateForTests();
359
- stubProbe({ [OLD]: { status: "capable" }, [B]: { status: "missing", detail: "no shell" }, [C]: { status: "capable" } });
360
- });
361
- test("C confirmed but the host still runs broken B: the session never reaches the host", async () => {
362
- await check(OLD);
363
- await check(B);
364
- const { settled } = await check(C);
365
- assert.deepEqual(settled, []);
366
- assert.deepEqual(await sessionWithHostOn(B), [], "work ran on the checked exec lane, not the B host");
367
- });
368
- test("a host running the confirmed version serves the session", async () => {
369
- await check(C);
370
- const sent = await sessionWithHostOn(C);
371
- assert.ok(sent.includes("session/start"), "serve lane used when the host runs the checked build");
372
- });
373
- test("while the reported version is not confirmed, no session reaches the host", async () => {
374
- await check(OLD);
375
- await check(B);
376
- assert.deepEqual(await sessionWithHostOn(OLD), []);
459
+ test("the session runs on the exec lane and never touches the serve host", async () => {
460
+ const { exitCode, serveSpawns } = await sessionBesideServeHost();
461
+ assert.equal(exitCode, 0);
462
+ assert.equal(serveSpawns, 0, "no serve-host spawn for a deck-enabled developer turn");
377
463
  });
378
464
  });
@@ -13,8 +13,10 @@
13
13
  // `~/.claude.json` (this module — free, ingested on every capacity
14
14
  // read; only that subtree is ever parsed, the rest of the config —
15
15
  // accountUuid, email, credentials, projects — is never retained).
16
- // 3. One minimal FREE refresh when every valid 5H/1W observation is older
17
- // than 60 minutes: `claude -p "/usage"`. This is the default behavior.
16
+ // 3. One minimal FREE refresh when either valid 5H/1W observation is
17
+ // missing or at least 14 minutes old: `claude -p "/usage"` (NOT-281 —
18
+ // the 15-minute display freshness would otherwise lapse into N/A while
19
+ // the old 60-minute trigger waited). This is the default behavior.
18
20
  // Set `AGENT_DEALER_CLAUDE_CAPACITY_REFRESH=off` to disable it entirely
19
21
  // — reading capacity then never spawns Claude. Unrecognized values also
20
22
  // fail closed (stay disabled).
@@ -133,10 +135,20 @@ export const CLAUDE_PROBE_PROMPT = "/usage";
133
135
  export const CLAUDE_PROBE_MODEL = "haiku";
134
136
  /** Defensive spend cap (USD) — normal cost is $0; see module header. */
135
137
  export const CLAUDE_PROBE_MAX_BUDGET_USD = 0.01;
136
- /** A 5H/1W observation newer than this suppresses the refresh. */
137
- export const CLAUDE_PROBE_STALE_AFTER_MS = 60 * 60 * 1000;
138
- /** Minimum gap between probe attempts (per account); failures back off. */
139
- export const CLAUDE_PROBE_ATTEMPT_COOLDOWN_MS = 60 * 60 * 1000;
138
+ /**
139
+ * Either critical window (5H or 1W) at least this old triggers the refresh.
140
+ * 14 minutes — one minute inside the 15-minute display `freshUntil` (NOT-281)
141
+ * so the asynchronous single-flight `/usage` run normally completes before
142
+ * the UI would mark the reading stale. Checked per window, never as a
143
+ * newest-of-pair: one fresh sibling must not suppress its stale twin.
144
+ */
145
+ export const CLAUDE_PROBE_STALE_AFTER_MS = 14 * 60 * 1000;
146
+ /**
147
+ * Minimum gap between probe attempts (per account). Aligned with the trigger
148
+ * above: a healthy observation causes at most one refresh per 14 minutes;
149
+ * failures back off exponentially (14m → 28m → 56m → ~2h, capped at 8h).
150
+ */
151
+ export const CLAUDE_PROBE_ATTEMPT_COOLDOWN_MS = 14 * 60 * 1000;
140
152
  export const CLAUDE_PROBE_BACKOFF_CAP_MS = 8 * 60 * 60 * 1000;
141
153
  /** Account-wide window identities for the local cache (exact, lowercase). */
142
154
  const FIVE_HOUR_LIMIT_NAMES = new Set(["five_hour", "session"]);
@@ -704,7 +716,7 @@ export async function runClaudeCapacityProbe(nowMs = Date.now(), opts = {}) {
704
716
  appendProbeDiagnostic({
705
717
  ts: new Date(nowMs).toISOString(),
706
718
  event: "claude_capacity_probe",
707
- trigger: "stale_60m",
719
+ trigger: "stale_14m",
708
720
  probeCommand: CLAUDE_PROBE_PROMPT,
709
721
  budgetUsd: CLAUDE_PROBE_MAX_BUDGET_USD,
710
722
  ...out,
@@ -811,24 +823,27 @@ export async function runClaudeCapacityProbe(nowMs = Date.now(), opts = {}) {
811
823
  appendProbeDiagnostic({
812
824
  ts: new Date(nowMs).toISOString(),
813
825
  event: "claude_capacity_probe",
814
- trigger: "stale_60m",
826
+ trigger: "stale_14m",
815
827
  probeCommand: CLAUDE_PROBE_PROMPT,
816
828
  budgetUsd: CLAUDE_PROBE_MAX_BUDGET_USD,
817
829
  ...out,
818
830
  });
819
831
  return out;
820
832
  }
821
- // ---------------------------------------------------------------------------
822
- // Freshness gate, single-flight, backoff
823
- // ---------------------------------------------------------------------------
824
833
  /**
825
- * Newest valid account-wide 5H/1W observation (event or cache — both share
826
- * window keys and critical roles). A row counts when it carries a remaining
827
- * %, its reset is absent or future, and its observed time is not in the
828
- * future. Returns null when no such sample exists.
834
+ * Newest valid account-wide observation per critical window (event or cache
835
+ * — both share window keys and critical roles). A row counts when it
836
+ * carries a remaining %, its reset is absent or future, and its observed
837
+ * time is not in the future. A role maps to null when it has no such
838
+ * sample (missing, expired, or unparsable). Split per window on purpose
839
+ * (NOT-281): the refresh gate must see a stale twin hiding behind a fresh
840
+ * sibling, which a newest-of-pair maximum cannot show.
829
841
  */
830
- export function newestValidClaudeObservationMs(nowMs = Date.now()) {
831
- let max = null;
842
+ export function newestValidClaudeObservationMsByRole(nowMs = Date.now()) {
843
+ const out = {
844
+ five_hour: null,
845
+ weekly: null,
846
+ };
832
847
  for (const row of listCapacitySnapshots(CLAUDE_RUNTIME)) {
833
848
  if (row.criticalRole !== "five_hour" && row.criticalRole !== "weekly")
834
849
  continue;
@@ -842,9 +857,22 @@ export function newestValidClaudeObservationMs(nowMs = Date.now()) {
842
857
  const observedMs = Date.parse(row.observedAt);
843
858
  if (!Number.isFinite(observedMs) || observedMs > nowMs)
844
859
  continue;
845
- max = max === null ? observedMs : Math.max(max, observedMs);
860
+ const prev = out[row.criticalRole];
861
+ out[row.criticalRole] = prev === null ? observedMs : Math.max(prev, observedMs);
846
862
  }
847
- return max;
863
+ return out;
864
+ }
865
+ /**
866
+ * Newest valid account-wide 5H/1W observation across both critical windows.
867
+ * Kept for diagnostics; the refresh gate uses the per-role variant above.
868
+ */
869
+ export function newestValidClaudeObservationMs(nowMs = Date.now()) {
870
+ const byRole = newestValidClaudeObservationMsByRole(nowMs);
871
+ if (byRole.five_hour === null)
872
+ return byRole.weekly;
873
+ if (byRole.weekly === null)
874
+ return byRole.five_hour;
875
+ return Math.max(byRole.five_hour, byRole.weekly);
848
876
  }
849
877
  let claudeProbeInFlight = null;
850
878
  let lastClaudeProbeAttemptMs = 0;
@@ -868,12 +896,14 @@ function probeCooldownMs() {
868
896
  return Math.min(CLAUDE_PROBE_ATTEMPT_COOLDOWN_MS * 2 ** shift, CLAUDE_PROBE_BACKOFF_CAP_MS);
869
897
  }
870
898
  /**
871
- * On-demand free `/usage` refresh: when `claude_code` is configured and no
872
- * valid 5H/1W observation is newer than 60 minutes, run one minimal bounded
873
- * probe (single-flight across concurrent readers; at most one attempt per
874
- * account per 60 minutes, backing off exponentially on failure). Explicitly
875
- * disabled is a strict no-op — no spawn at all. Never throws, never touches
876
- * Dealer workflow/session state or `runtime_availability`.
899
+ * On-demand free `/usage` refresh: when `claude_code` is configured and
900
+ * either critical window (5H or 1W) is missing or at least 14 minutes old,
901
+ * run one minimal bounded probe (single-flight across concurrent readers;
902
+ * at most one attempt per account per 14 minutes while healthy, backing
903
+ * off exponentially on failure). Per-window on purpose (NOT-281): a fresh
904
+ * sibling must never suppress its stale twin. Explicitly disabled is a
905
+ * strict no-op — no spawn at all. Never throws, never touches Dealer
906
+ * workflow/session state or `runtime_availability`.
877
907
  */
878
908
  export async function maybeProbeClaudeCapacity(nowMs = Date.now(), opts = {}) {
879
909
  try {
@@ -882,8 +912,10 @@ export async function maybeProbeClaudeCapacity(nowMs = Date.now(), opts = {}) {
882
912
  if (!configuredCapacityRuntimes().includes(CLAUDE_RUNTIME)) {
883
913
  return { probed: false, reason: "unconfigured" };
884
914
  }
885
- const newest = newestValidClaudeObservationMs(nowMs);
886
- if (newest !== null && nowMs - newest < CLAUDE_PROBE_STALE_AFTER_MS) {
915
+ const byRole = newestValidClaudeObservationMsByRole(nowMs);
916
+ const fiveFresh = byRole.five_hour !== null && nowMs - byRole.five_hour < CLAUDE_PROBE_STALE_AFTER_MS;
917
+ const weeklyFresh = byRole.weekly !== null && nowMs - byRole.weekly < CLAUDE_PROBE_STALE_AFTER_MS;
918
+ if (fiveFresh && weeklyFresh) {
887
919
  return { probed: false, reason: "fresh" };
888
920
  }
889
921
  // Single-flight first: readers arriving while a probe runs share it
@@ -1,8 +1,10 @@
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
- // 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.
4
+ // free `/usage` refresh; NOT-281 retargeted the trigger to either window at
5
+ // least 14 minutes old (per-window, inside the 15-minute display freshness).
6
+ // No test here performs a live provider request: every probe spawn goes
7
+ // through an injected fake runner.
6
8
  import { test, before, beforeEach, afterEach } from "node:test";
7
9
  import assert from "node:assert/strict";
8
10
  import fs from "node:fs";
@@ -14,8 +16,8 @@ const { createAgent } = await import("../repository/agents.js");
14
16
  const { listCapacitySnapshots, clearAllCapacitySnapshots, } = await import("../repository/runtime-capacity.js");
15
17
  const { parseNdjson } = await import("../runners/stream-json.js");
16
18
  const { getRuntimeCapacitySnapshot } = await import("./service.js");
17
- const { recordClaudeCapacityFromEvents } = await import("./claude-events.js");
18
- const { CLAUDE_CACHE_FILE_ENV, CLAUDE_CAPACITY_REFRESH_ENV, buildClaudeProbeArgv, claudeCacheFilePath, extractClaudeCacheSubtree, ingestClaudeLocalCache, isClaudePaidFallbackEnabled, maybeProbeClaudeCapacity, newestValidClaudeObservationMs, parseClaudeCachedUtilization, probeDiagnosticLogPath, readClaudeLocalCache, refreshClaudeCapacityIfStale, resetClaudeCapacityRefreshState, runClaudeCapacityProbe, } = await import("./claude-local-cache.js");
19
+ const { recordClaudeCapacityFromEvents, recordClaudeWindowReadings } = await import("./claude-events.js");
20
+ const { CLAUDE_CACHE_FILE_ENV, CLAUDE_CAPACITY_REFRESH_ENV, CLAUDE_PROBE_STALE_AFTER_MS, buildClaudeProbeArgv, claudeCacheFilePath, extractClaudeCacheSubtree, ingestClaudeLocalCache, isClaudePaidFallbackEnabled, maybeProbeClaudeCapacity, newestValidClaudeObservationMs, newestValidClaudeObservationMsByRole, parseClaudeCachedUtilization, probeDiagnosticLogPath, readClaudeLocalCache, refreshClaudeCapacityIfStale, resetClaudeCapacityRefreshState, runClaudeCapacityProbe, } = await import("./claude-local-cache.js");
19
21
  const NOW_MS = Date.parse("2026-09-20T12:00:00.000Z");
20
22
  const FIVE_HOUR_RESET_SEC = Math.floor(NOW_MS / 1000) + 2 * 3600;
21
23
  const SEVEN_DAY_RESET_SEC = Math.floor(NOW_MS / 1000) + 3 * 24 * 3600;
@@ -88,6 +90,42 @@ function probeStreamFixture(observedIso, costUsd = 0) {
88
90
  const throwingRunner = () => {
89
91
  throw new Error("probe runner must not be called while disabled/fresh");
90
92
  };
93
+ /**
94
+ * Seed the snapshot store with per-window ages (NOT-281): the shared cache
95
+ * fixture always stamps both windows together, so split-freshness cases
96
+ * need direct per-role rows. Resets stay in the future so rows count as
97
+ * valid observations at NOW_MS.
98
+ */
99
+ function seedWindowAges(fiveHourAgeMs, weeklyAgeMs, nowMs = NOW_MS) {
100
+ const readings = [];
101
+ const role = (five, ageMs) => ({
102
+ windowKey: five ? "claude_unified_five_hour" : "claude_unified_seven_day",
103
+ providerBucket: five ? "five_hour" : "seven_day",
104
+ durationMinutes: five ? 300 : 10080,
105
+ providerLabel: five ? "five_hour" : "seven_day",
106
+ usedValue: five ? 0.17 : 0.42,
107
+ usedUnit: "fraction",
108
+ usedFraction: five ? 0.17 : 0.42,
109
+ resetAt: new Date((five ? FIVE_HOUR_RESET_SEC : SEVEN_DAY_RESET_SEC) * 1000).toISOString(),
110
+ observedAt: new Date(nowMs - ageMs).toISOString(),
111
+ source: "observed_event",
112
+ evidenceRef: "test:split-window-seed",
113
+ criticalRole: five ? "five_hour" : "weekly",
114
+ });
115
+ if (fiveHourAgeMs !== null)
116
+ readings.push(role(true, fiveHourAgeMs));
117
+ if (weeklyAgeMs !== null)
118
+ readings.push(role(false, weeklyAgeMs));
119
+ assert.equal(recordClaudeWindowReadings(readings, "claude_code", nowMs), readings.length);
120
+ }
121
+ function successRunner(atMs = NOW_MS) {
122
+ return async () => ({
123
+ stdout: probeStreamFixture(new Date(atMs).toISOString()),
124
+ exitCode: 0,
125
+ timedOut: false,
126
+ spawnError: null,
127
+ });
128
+ }
91
129
  before(() => {
92
130
  migrate();
93
131
  createAgent({
@@ -319,12 +357,13 @@ test("extractClaudeUsageReportLimits reads the /usage local-command result", asy
319
357
  assert.equal(extractClaudeUsageReportLimits([{ type: "result" }], NOW_MS), null);
320
358
  });
321
359
  test("default-on refresh suppresses a fresh sample; stale data triggers exactly one", async () => {
360
+ assert.equal(CLAUDE_PROBE_STALE_AFTER_MS, 14 * 60_000);
322
361
  fs.writeFileSync(cacheFile, fullCacheFixture(NOW_MS - 10 * 60_000));
323
362
  assert.equal(ingestClaudeLocalCache(NOW_MS), 2);
324
- assert.ok((newestValidClaudeObservationMs(NOW_MS) ?? 0) > NOW_MS - 60 * 60_000);
363
+ assert.ok((newestValidClaudeObservationMs(NOW_MS) ?? 0) > NOW_MS - CLAUDE_PROBE_STALE_AFTER_MS);
325
364
  const fresh = await maybeProbeClaudeCapacity(NOW_MS, { runner: throwingRunner });
326
365
  assert.deepEqual(fresh, { probed: false, reason: "fresh" });
327
- // All samples ≥60 minutes old: five concurrent readers share one probe.
366
+ // All samples past the 14-minute trigger: five concurrent readers share one probe.
328
367
  clearAllCapacitySnapshots();
329
368
  resetClaudeCapacityRefreshState();
330
369
  fs.writeFileSync(cacheFile, fullCacheFixture(NOW_MS - 61 * 60_000));
@@ -341,6 +380,121 @@ test("default-on refresh suppresses a fresh sample; stale data triggers exactly
341
380
  assert.equal(outcomes.filter((o) => o.reason === "shared").length, 4);
342
381
  assert.ok(outcomes.every((o) => o.probed && o.ok));
343
382
  });
383
+ test("a stale twin triggers one refresh even when its sibling is newer", async () => {
384
+ // NOT-281: the old newest-of-pair gate let one fresh window suppress the
385
+ // refresh while its sibling went stale. Freshness is per window now.
386
+ seedWindowAges(5 * 60_000, 20 * 60_000);
387
+ const byRole = newestValidClaudeObservationMsByRole(NOW_MS);
388
+ assert.equal(byRole.five_hour, NOW_MS - 5 * 60_000);
389
+ assert.equal(byRole.weekly, NOW_MS - 20 * 60_000);
390
+ let calls = 0;
391
+ const runner = async () => {
392
+ calls++;
393
+ return {
394
+ stdout: probeStreamFixture(new Date(NOW_MS).toISOString()),
395
+ exitCode: 0,
396
+ timedOut: false,
397
+ spawnError: null,
398
+ };
399
+ };
400
+ const outcome = await maybeProbeClaudeCapacity(NOW_MS, { runner });
401
+ assert.equal(calls, 1);
402
+ assert.equal(outcome.probed, true);
403
+ assert.equal(outcome.reason, "completed");
404
+ assert.equal(outcome.ok, true);
405
+ // On success both windows are fresh again …
406
+ const after = newestValidClaudeObservationMsByRole(NOW_MS);
407
+ assert.equal(after.five_hour, NOW_MS);
408
+ assert.equal(after.weekly, NOW_MS);
409
+ // … and the next API/UI poll (a minute later, well inside the ordinary
410
+ // 15-minute stale boundary) reports known values with no new spawn.
411
+ const poll = await maybeProbeClaudeCapacity(NOW_MS + 60_000, { runner: throwingRunner });
412
+ assert.deepEqual(poll, { probed: false, reason: "fresh" });
413
+ const snap = getRuntimeCapacitySnapshot(NOW_MS + 60_000);
414
+ const claude = snap.runtimes.find((r) => r.runtime === "claude_code");
415
+ assert.equal(claude.windows.find((w) => w.displayLabel === "5H").remainingPercent, 80);
416
+ assert.equal(claude.windows.find((w) => w.displayLabel === "1W").remainingPercent, 60);
417
+ });
418
+ test("a missing window triggers a refresh even when the sibling is newer", async () => {
419
+ seedWindowAges(5 * 60_000, null);
420
+ assert.deepEqual(newestValidClaudeObservationMsByRole(NOW_MS), {
421
+ five_hour: NOW_MS - 5 * 60_000,
422
+ weekly: null,
423
+ });
424
+ let calls = 0;
425
+ const runner = async () => {
426
+ calls++;
427
+ return {
428
+ stdout: probeStreamFixture(new Date(NOW_MS).toISOString()),
429
+ exitCode: 0,
430
+ timedOut: false,
431
+ spawnError: null,
432
+ };
433
+ };
434
+ const outcome = await maybeProbeClaudeCapacity(NOW_MS, { runner });
435
+ assert.equal(calls, 1);
436
+ assert.equal(outcome.ok, true);
437
+ assert.deepEqual(newestValidClaudeObservationMsByRole(NOW_MS), {
438
+ five_hour: NOW_MS,
439
+ weekly: NOW_MS,
440
+ });
441
+ });
442
+ test("the freshness boundary is 14 minutes: just under stays quiet, exactly at triggers", async () => {
443
+ seedWindowAges(13 * 60_000, 13 * 60_000);
444
+ const fresh = await maybeProbeClaudeCapacity(NOW_MS, { runner: throwingRunner });
445
+ assert.deepEqual(fresh, { probed: false, reason: "fresh" });
446
+ clearAllCapacitySnapshots();
447
+ resetClaudeCapacityRefreshState();
448
+ seedWindowAges(14 * 60_000, 0);
449
+ const stale = await maybeProbeClaudeCapacity(NOW_MS, { runner: successRunner() });
450
+ assert.equal(stale.probed, true);
451
+ assert.equal(stale.reason, "completed");
452
+ assert.equal(stale.ok, true);
453
+ });
454
+ test("a healthy refresh buys 14 minutes: no second attempt inside the interval", async () => {
455
+ seedWindowAges(20 * 60_000, 20 * 60_000);
456
+ let calls = 0;
457
+ const runner = async () => {
458
+ calls++;
459
+ return {
460
+ stdout: probeStreamFixture(new Date(NOW_MS).toISOString()),
461
+ exitCode: 0,
462
+ timedOut: false,
463
+ spawnError: null,
464
+ };
465
+ };
466
+ const first = await maybeProbeClaudeCapacity(NOW_MS, { runner });
467
+ assert.equal(first.ok, true);
468
+ assert.equal(calls, 1);
469
+ // A 5-second-poll cadence sees fresh rows and spawns nothing.
470
+ const poll = await maybeProbeClaudeCapacity(NOW_MS + 5_000, { runner: throwingRunner });
471
+ assert.deepEqual(poll, { probed: false, reason: "fresh" });
472
+ // Even if the rows go missing mid-interval (e.g. a reset passes), the
473
+ // attempt cooldown stamped by the healthy run holds — at most one
474
+ // refresh per 14 minutes, never a retry per poll.
475
+ clearAllCapacitySnapshots();
476
+ const missing = await maybeProbeClaudeCapacity(NOW_MS + 5 * 60_000, { runner: throwingRunner });
477
+ assert.deepEqual(missing, { probed: false, reason: "backoff" });
478
+ assert.equal(calls, 1);
479
+ // Past the interval the gate opens again.
480
+ const later = await maybeProbeClaudeCapacity(NOW_MS + 15 * 60_000, { runner });
481
+ assert.equal(later.probed, true);
482
+ assert.equal(calls, 2);
483
+ });
484
+ test("disabled refresh never spawns and stale readings stay honestly N/A", async () => {
485
+ process.env[CLAUDE_CAPACITY_REFRESH_ENV] = "off";
486
+ // 20 minutes old: past the 15-minute display freshness, inside expiry.
487
+ fs.writeFileSync(cacheFile, fullCacheFixture(NOW_MS - 20 * 60_000));
488
+ assert.equal(ingestClaudeLocalCache(NOW_MS), 2);
489
+ const outcome = await maybeProbeClaudeCapacity(NOW_MS, { runner: throwingRunner });
490
+ assert.deepEqual(outcome, { probed: false, reason: "disabled" });
491
+ const snap = getRuntimeCapacitySnapshot(NOW_MS);
492
+ const claude = snap.runtimes.find((r) => r.runtime === "claude_code");
493
+ for (const w of claude.windows) {
494
+ assert.equal(w.remainingPercent, null);
495
+ assert.equal(w.unavailableReason, "stale");
496
+ }
497
+ });
344
498
  test("probe argv pins the fixed /usage command plus structural model-turn safeguards", async () => {
345
499
  fs.writeFileSync(cacheFile, fullCacheFixture(NOW_MS - 61 * 60_000));
346
500
  assert.equal(ingestClaudeLocalCache(NOW_MS), 2);
@@ -561,10 +715,10 @@ test("probe failure preserves last-good rows and enters bounded backoff", async
561
715
  const second = await maybeProbeClaudeCapacity(NOW_MS + 60_000, { runner: failing });
562
716
  assert.deepEqual(second, { probed: false, reason: "backoff" });
563
717
  assert.equal(calls, 1);
564
- // Exponential backoff: one failure doubles the 60-minute cooldown.
565
- const during = await maybeProbeClaudeCapacity(NOW_MS + 61 * 60_000, { runner: failing });
718
+ // Exponential backoff: one failure doubles the 14-minute cooldown (28m).
719
+ const during = await maybeProbeClaudeCapacity(NOW_MS + 15 * 60_000, { runner: failing });
566
720
  assert.deepEqual(during, { probed: false, reason: "backoff" });
567
- const after = await maybeProbeClaudeCapacity(NOW_MS + 121 * 60_000, { runner: failing });
721
+ const after = await maybeProbeClaudeCapacity(NOW_MS + 29 * 60_000, { runner: failing });
568
722
  assert.equal(after.probed, true);
569
723
  assert.equal(calls, 2);
570
724
  });