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.
- package/bundle/server/dist/adapters/agent-deck-bind.js +33 -3
- package/bundle/server/dist/adapters/agent-deck-bind.test.js +60 -2
- package/bundle/server/dist/adapters/agent-health.js +3 -4
- package/bundle/server/dist/adapters/agent-health.test.js +11 -9
- package/bundle/server/dist/adapters/git-worktree.js +187 -18
- package/bundle/server/dist/adapters/git-worktree.test.js +293 -0
- package/bundle/server/dist/adapters/muse-capability.js +51 -25
- package/bundle/server/dist/adapters/muse-capability.test.js +138 -52
- package/bundle/server/dist/capacity/claude-local-cache.js +59 -27
- package/bundle/server/dist/capacity/claude-local-cache.test.js +163 -9
- package/bundle/server/dist/capacity/muse-host.js +34 -32
- package/bundle/server/dist/capacity/muse-host.test.js +5 -3
- package/bundle/server/dist/coordinator/args.js +4 -3
- package/bundle/server/dist/coordinator/checkpoint.js +5 -3
- package/bundle/server/dist/coordinator/checkpoint.test.js +23 -0
- package/bundle/server/dist/coordinator/commands.js +66 -5
- package/bundle/server/dist/coordinator/developer-effect.js +88 -10
- package/bundle/server/dist/coordinator/developer-effect.test.js +170 -0
- package/bundle/server/dist/coordinator/human-resolution.js +2 -0
- package/bundle/server/dist/coordinator/muse-developer.integration.test.js +291 -55
- package/bundle/server/dist/coordinator/muse-spawn.js +72 -174
- package/bundle/server/dist/coordinator/projection.js +1 -0
- package/bundle/server/dist/coordinator/prompts.js +10 -6
- package/bundle/server/dist/coordinator/prompts.test.js +16 -8
- package/bundle/server/dist/coordinator/routing.js +1 -0
- package/bundle/server/dist/coordinator/routing.test.js +20 -0
- package/bundle/server/dist/coordinator/spawn.js +2 -1
- package/bundle/server/dist/coordinator/worktree-owner-liveness.js +6 -1
- package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +2 -1
- package/bundle/server/dist/repository/human-actions.js +13 -0
- package/bundle/server/dist/routes/index.js +11 -8
- package/bundle/server/dist/routes/version.js +30 -0
- package/bundle/server/dist/routes/version.test.js +21 -0
- package/bundle/server/dist/runners/muse-config-core.js +63 -16
- package/bundle/server/dist/runners/muse-config.js +1 -1
- package/bundle/server/dist/runners/muse-config.test.js +135 -29
- package/bundle/server/dist/runners/spawn-cli.js +17 -3
- package/bundle/server/dist/runners/spawn-cli.test.js +59 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-DyAJNyfV.css +1 -0
- package/bundle/server/static-ui/assets/index-yLyxRd-7.js +64 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/action-presentation.d.ts +51 -0
- package/bundle/shared/dist/action-presentation.js +280 -0
- package/bundle/shared/dist/action-presentation.test.d.ts +1 -0
- package/bundle/shared/dist/action-presentation.test.js +180 -0
- package/bundle/shared/dist/attempt-waste.d.ts +2 -2
- package/bundle/shared/dist/execution-report.d.ts +2 -2
- package/bundle/shared/dist/human-actions.d.ts +12 -12
- package/bundle/shared/dist/index.d.ts +41 -40
- package/bundle/shared/dist/index.js +1 -0
- package/bundle/shared/dist/issues.d.ts +12 -12
- package/bundle/shared/dist/outbound-draft.d.ts +6 -6
- package/bundle/shared/dist/profile-snapshot.d.ts +2 -2
- package/bundle/shared/dist/usage-events.d.ts +2 -2
- package/bundle/shared/dist/worker-sessions.d.ts +6 -6
- package/bundle/shared/dist/workflow.d.ts +2 -2
- package/bundle/shared/package.json +1 -1
- package/dist/doctor.js +4 -1
- package/dist/managed/index.d.ts +1 -1
- package/dist/managed/index.js +1 -1
- package/dist/managed/updater.d.ts +16 -6
- package/dist/managed/updater.js +28 -11
- package/dist/managed-update-restart.test.d.ts +1 -0
- package/dist/managed-update-restart.test.js +227 -0
- package/dist/ports.d.ts +6 -0
- package/dist/ports.js +10 -0
- package/dist/runtime-state.d.ts +10 -0
- package/dist/runtime-state.js +26 -0
- package/dist/start.js +67 -2
- package/dist/status.js +30 -2
- package/dist/update-check.js +3 -6
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-BII-LgB8.css +0 -1
- 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
|
-
|
|
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)
|
|
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,
|
|
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 (
|
|
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
|
-
//
|
|
303
|
-
//
|
|
304
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
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, {
|
|
329
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
|
17
|
-
//
|
|
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
|
-
/**
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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: "
|
|
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: "
|
|
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
|
|
826
|
-
* window keys and critical roles). A row counts when it
|
|
827
|
-
* %, its reset is absent or future, and its observed
|
|
828
|
-
* future.
|
|
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
|
|
831
|
-
|
|
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
|
-
|
|
860
|
+
const prev = out[row.criticalRole];
|
|
861
|
+
out[row.criticalRole] = prev === null ? observedMs : Math.max(prev, observedMs);
|
|
846
862
|
}
|
|
847
|
-
return
|
|
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
|
|
872
|
-
*
|
|
873
|
-
* probe (single-flight across concurrent readers;
|
|
874
|
-
* account per
|
|
875
|
-
*
|
|
876
|
-
*
|
|
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
|
|
886
|
-
|
|
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
|
|
5
|
-
//
|
|
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 -
|
|
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
|
|
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
|
|
565
|
-
const during = await maybeProbeClaudeCapacity(NOW_MS +
|
|
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 +
|
|
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
|
});
|