agent-dealer 1.3.1 → 1.3.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 (44) hide show
  1. package/bundle/server/dist/adapters/agent-health.test.js +52 -0
  2. package/bundle/server/dist/adapters/git-worktree.js +38 -1
  3. package/bundle/server/dist/adapters/github.js +50 -0
  4. package/bundle/server/dist/adapters/github.test.js +89 -1
  5. package/bundle/server/dist/capacity/service.js +66 -6
  6. package/bundle/server/dist/capacity/service.test.js +220 -0
  7. package/bundle/server/dist/coordinator/auto-merge.js +46 -9
  8. package/bundle/server/dist/coordinator/commands.js +89 -9
  9. package/bundle/server/dist/coordinator/commands.test.js +228 -2
  10. package/bundle/server/dist/coordinator/developer-effect.js +36 -0
  11. package/bundle/server/dist/coordinator/operator-verification.integration.test.js +102 -1
  12. package/bundle/server/dist/coordinator/product-scope-contract.test.js +249 -0
  13. package/bundle/server/dist/coordinator/prompts.js +57 -2
  14. package/bundle/server/dist/coordinator/prompts.test.js +236 -2
  15. package/bundle/server/dist/coordinator/reviewer-browser-probe.js +89 -10
  16. package/bundle/server/dist/coordinator/reviewer-browser-probe.test.js +100 -0
  17. package/bundle/server/dist/coordinator/reviewer-effect.js +30 -1
  18. package/bundle/server/dist/coordinator/reviewer-effect.test.js +54 -0
  19. package/bundle/server/dist/coordinator/reviewer-visual-evidence.js +594 -0
  20. package/bundle/server/dist/coordinator/reviewer-visual-evidence.test.js +496 -0
  21. package/bundle/server/dist/coordinator/source-reload.js +44 -9
  22. package/bundle/server/dist/coordinator/visual-qa-collection.test.js +619 -0
  23. package/bundle/server/dist/coordinator/visual-qa.js +630 -0
  24. package/bundle/server/dist/coordinator/visual-qa.test.js +493 -0
  25. package/bundle/server/dist/db/index.js +8 -0
  26. package/bundle/server/dist/db/schema.sql +5 -1
  27. package/bundle/server/dist/paths.js +24 -0
  28. package/bundle/server/dist/repository/issues.js +8 -1
  29. package/bundle/server/dist/repository/issues.test.js +34 -0
  30. package/bundle/server/dist/repository/runtime-availability.js +29 -11
  31. package/bundle/server/dist/routes/issues-execution-contract.test.js +293 -0
  32. package/bundle/server/dist/routes/issues-source-attachments.test.js +277 -0
  33. package/bundle/server/dist/routes/issues.js +23 -8
  34. package/bundle/server/dist/routes/issues.test.js +80 -0
  35. package/bundle/server/dist/runners/usage-cap.js +66 -15
  36. package/bundle/server/dist/runners/usage-cap.test.js +96 -2
  37. package/bundle/server/package.json +2 -2
  38. package/bundle/server/static-ui/assets/index-B_pNUDRY.js +64 -0
  39. package/bundle/server/static-ui/index.html +1 -1
  40. package/bundle/shared/dist/runtime-capacity.d.ts +85 -0
  41. package/bundle/shared/dist/runtime-capacity.js +19 -0
  42. package/bundle/shared/package.json +1 -1
  43. package/package.json +1 -1
  44. package/bundle/server/static-ui/assets/index-Bz_c-UbO.js +0 -64
@@ -17,6 +17,8 @@ const { migrate, getDb } = await import("../db/index.js");
17
17
  const { createAgent, getAgent } = await import("../repository/agents.js");
18
18
  const { healthForAgent, runtimeIssuesUncached, githubIssuesUncached, githubIssuesSync, classifyGithubAuthStatus, clearAgentHealthCaches, setCursorProbeTimingForTests, setRunCommandForTests, } = await import("./agent-health.js");
19
19
  const { setMuseCapabilityProbeForTests, settleMuseCapabilityCheckForTests } = await import("./muse-capability.js");
20
+ const { recordMuseUsageCap } = await import("../runners/usage-cap.js");
21
+ const { runtimeAvailability, clearAllRuntimeAvailability, } = await import("../repository/runtime-availability.js");
20
22
  migrate();
21
23
  // NOT-277: health never runs the real (billed) Muse capability probe in tests.
22
24
  setMuseCapabilityProbeForTests(async () => ({ status: "capable" }));
@@ -441,6 +443,56 @@ The keychain item is stuck. Delete it and sign in again:
441
443
  assert.match(issues[0].message, /panic: something else broke/);
442
444
  assert.doesNotMatch(issues[0].message, /muse login/);
443
445
  });
446
+ // ---------------------------------------------------------------------------
447
+ // NOT-439: agent health renders the exact provider boundary from persisted
448
+ // availability — the reproduced Muse message resolves to 11:59 AM in
449
+ // America/Los_Angeles, not the 11:46 AM of the old 30-minute fallback.
450
+ // ---------------------------------------------------------------------------
451
+ test("NOT-439: persisted muse provider reset renders the exact boundary, not the fallback", async () => {
452
+ clearAllRuntimeAvailability();
453
+ clearAgentHealthCaches();
454
+ try {
455
+ // Pinned incident replay: the provider said resets-at 18:59:01Z while the
456
+ // old fallback stored 18:46:20.154Z. Timezone-safe assertions on the
457
+ // stored instant itself.
458
+ const incidentNow = Date.parse("2026-10-12T18:16:20.154Z");
459
+ const cap = recordMuseUsageCap({
460
+ kind: "usage_cap",
461
+ message: "Subscription quota exhausted. Your usage window resets at 2026-10-12T18:59:01Z",
462
+ }, incidentNow);
463
+ assert.equal(cap?.unavailableUntil, "2026-10-12T18:59:01.000Z");
464
+ const stored = runtimeAvailability("muse_code", incidentNow);
465
+ assert.equal(stored.available, false);
466
+ if (!stored.available)
467
+ assert.equal(stored.until, "2026-10-12T18:59:01.000Z");
468
+ const la = { hour: "2-digit", minute: "2-digit", timeZone: "America/Los_Angeles" };
469
+ assert.equal(new Date("2026-10-12T18:59:01.000Z").toLocaleTimeString("en-US", la), "11:59 AM");
470
+ // The superseded fallback instant renders differently, proving the
471
+ // assertion distinguishes the two.
472
+ assert.equal(new Date("2026-10-12T18:46:20.154Z").toLocaleTimeString("en-US", la), "11:46 AM");
473
+ // Live surface: a guaranteed-future provider reset flows verbatim from
474
+ // persisted availability into the usage_capped health message.
475
+ const future = new Date(Date.now() + 2 * 3600_000);
476
+ future.setMilliseconds(0);
477
+ const resetIso = future.toISOString();
478
+ const live = recordMuseUsageCap({ kind: "usage_cap", message: `Subscription quota exhausted. Resets at ${resetIso}` });
479
+ assert.equal(live?.unavailableUntil, resetIso);
480
+ const stub = stubMuse(`cat ${JSON.stringify(path.join(FIXTURES, "muse-version.txt"))}`);
481
+ const agent = createAgent({ name: "muse-capped", runtime: "muse_code", deckId: randomUUID() });
482
+ const result = await withMuseEnv({ MUSE_CLI: stub.bin, META_API_KEY: "k", configHome: emptyConfigHome() }, () => healthForAgent(agent, true, undefined, true, null, NO_GITHUB));
483
+ const issue = result.issues.find((i) => i.code === "usage_capped");
484
+ assert.ok(issue, "expected a usage_capped issue from persisted availability");
485
+ const expectedRendering = new Date(resetIso).toLocaleTimeString([], {
486
+ hour: "2-digit",
487
+ minute: "2-digit",
488
+ });
489
+ assert.ok(issue.message.includes(expectedRendering), `expected health message ${JSON.stringify(issue.message)} to render ${expectedRendering}`);
490
+ }
491
+ finally {
492
+ clearAllRuntimeAvailability();
493
+ clearAgentHealthCaches();
494
+ }
495
+ });
444
496
  test("a muse probe that hangs past the timeout is runtime_unknown", async () => {
445
497
  const stub = stubMuse("sleep 30");
446
498
  setCursorProbeTimingForTests({ timeoutMs: 150 });
@@ -18,6 +18,17 @@ import { withRepoLock } from "../runners/process-registry.js";
18
18
  * Kept inside the repo (not `$AGENT_DEALER_HOME`) so `agent-deck use` grants cover the
19
19
  * worker cwd and `bind_workspace` to that path can succeed. */
20
20
  export const WORKTREES_DIR_NAME = ".agent-dealer-worktrees";
21
+ /** Directory name for coordinator-staged CI visual artifacts inside a reviewer
22
+ * worktree (NOT-384). The reviewer has no shell, so it cannot list the worktree
23
+ * root itself — but its file tools read paths the prompt names, and only paths
24
+ * inside the worktree are readable under Claude Code's `--restricted` file
25
+ * confinement. The directory is untracked and ignored by `isWorktreeClean`
26
+ * below, so staged evidence never reads as reviewer dirt. It only ever exists
27
+ * in detached-HEAD reviewer checkouts (developer sessions never create it); a
28
+ * developer-side file of the same hidden name would be ignored by the clean
29
+ * check too, but salvage (`git add -A`) still sweeps it up, so nothing is lost —
30
+ * and the coordinator never stages it anywhere but a reviewer worktree. */
31
+ export const REVIEWER_VISUAL_STAGING_DIR_NAME = ".agent-dealer-visual";
21
32
  const execFileAsync = promisify(execFile);
22
33
  const gitCommandObservers = new Set();
23
34
  /** Registers `observer` for every adapter `git` invocation; returns the unsubscribe. */
@@ -214,8 +225,34 @@ export async function removeWorktree(opts) {
214
225
  async function readOnlyStatus(worktreePath) {
215
226
  return (await git(worktreePath, ["--no-optional-locks", "status", "--porcelain"])).stdout;
216
227
  }
228
+ /** True when a `git status --porcelain` line names only the coordinator-staged
229
+ * visual-evidence directory (NOT-384) — `?? .agent-dealer-visual/` for the
230
+ * untracked dir, or a path under it. Anything else on the line means real dirt. */
231
+ function isVisualStagingStatusLine(line) {
232
+ const entry = line.slice(0, 2);
233
+ if (entry !== "??")
234
+ return false;
235
+ const shown = line.slice(3).trim().replace(/^"|"$/g, "");
236
+ return shown === REVIEWER_VISUAL_STAGING_DIR_NAME || shown.startsWith(`${REVIEWER_VISUAL_STAGING_DIR_NAME}/`);
237
+ }
217
238
  export async function isWorktreeClean(worktreePath) {
218
- return (await readOnlyStatus(worktreePath)).trim().length === 0;
239
+ const out = (await readOnlyStatus(worktreePath)).trim();
240
+ if (!out)
241
+ return true;
242
+ return out
243
+ .split("\n")
244
+ .map((line) => line.trimEnd())
245
+ .filter((line) => line.length > 0)
246
+ .every(isVisualStagingStatusLine);
247
+ }
248
+ /** Repo-relative paths changed between `baseSha` and `headSha` (NOT-384: the
249
+ * UI-affecting classifier's input). Empty when the SHAs match. */
250
+ export async function listChangedFiles(opts) {
251
+ const { stdout } = await git(opts.worktreePath, ["diff", "--name-only", opts.baseSha, opts.headSha]);
252
+ return stdout
253
+ .split("\n")
254
+ .map((line) => line.trim())
255
+ .filter((line) => line.length > 0);
219
256
  }
220
257
  /** Commit message for a timeout salvage tip (NOT-145). */
221
258
  export const SALVAGE_TIMEOUT_MESSAGE = "wip: timeout salvage";
@@ -334,6 +334,17 @@ export function formatChecksFailureDetails(opts) {
334
334
  }
335
335
  return parts.join("\n");
336
336
  }
337
+ export function parseWorkflowRunInfo(raw) {
338
+ if (typeof raw !== "object" || raw === null)
339
+ return null;
340
+ const row = raw;
341
+ if (typeof row.databaseId !== "number" || typeof row.headSha !== "string" || !row.headSha) {
342
+ return null;
343
+ }
344
+ const conclusion = typeof row.conclusion === "string" && row.conclusion ? row.conclusion : null;
345
+ const status = typeof row.status === "string" && row.status ? row.status : null;
346
+ return { databaseId: row.databaseId, headSha: row.headSha, conclusion, status };
347
+ }
337
348
  const NO_COMMITS_PATTERN = /no commits between/i;
338
349
  const defaultExec = (args, opts) => run("gh", args, { cwd: opts.cwd, maxBuffer: opts.maxBuffer });
339
350
  async function ghPrView(exec, cwd, fields, selector) {
@@ -407,6 +418,45 @@ export function createGithubAdapter(exec = defaultExec) {
407
418
  return "UNKNOWN";
408
419
  }
409
420
  },
421
+ async listWorkflowRunsForCommit({ cwd, workflow, commitSha }) {
422
+ try {
423
+ const { stdout } = await exec([
424
+ "run",
425
+ "list",
426
+ "--workflow",
427
+ workflow,
428
+ "--commit",
429
+ commitSha,
430
+ "--limit",
431
+ "5",
432
+ "--json",
433
+ "databaseId,headSha,conclusion,status",
434
+ ], { cwd });
435
+ const rows = JSON.parse(stdout);
436
+ if (!Array.isArray(rows))
437
+ return [];
438
+ const runs = [];
439
+ for (const row of rows) {
440
+ const parsed = parseWorkflowRunInfo(row);
441
+ if (parsed)
442
+ runs.push(parsed);
443
+ }
444
+ return runs;
445
+ }
446
+ catch (err) {
447
+ const message = err.stderr ?? err.message;
448
+ throw new Error(`gh run list failed: ${message}`);
449
+ }
450
+ },
451
+ async downloadRunArtifact({ cwd, runId, name, destDir }) {
452
+ try {
453
+ await exec(["run", "download", String(runId), "--name", name, "--dir", destDir], { cwd });
454
+ }
455
+ catch (err) {
456
+ const message = err.stderr ?? err.message;
457
+ throw new Error(`gh run download failed: ${message}`);
458
+ }
459
+ },
410
460
  async publishReview({ cwd, number, event, bodyFilePath }) {
411
461
  const result = await runReview(exec, cwd, number, event, bodyFilePath);
412
462
  if (result.ok || event === "COMMENT" || !OWN_PR_REVIEW_PATTERN.test(result.reason)) {
@@ -1,7 +1,7 @@
1
1
  // packages/server/src/adapters/github.test.ts
2
2
  import { test } from "node:test";
3
3
  import assert from "node:assert/strict";
4
- import { parsePrView, summarizeChecks, pollPrChecks, pollPrChecksDetailed, baseRefHasPullRequestWorkflow, workflowHasPullRequestTrigger, createGithubAdapter, fetchChecksFailureEvidence, sanitizeCiText, sanitizeUrl, extractActionsRunId, buildFailureExcerpt, formatChecksFailureDetails, CHECKS_EVIDENCE_MAX_EXCERPT_CHARS, CHECKS_EVIDENCE_MAX_EXCERPT_LINES, CHECKS_EVIDENCE_MAX_RAW_LOG_CHARS_PER_RUN, CHECKS_EVIDENCE_GH_LOG_MAX_BUFFER, CHECKS_FAILURE_GENERIC_REASON, PR_VIEW_FIELDS, } from "./github.js";
4
+ import { parsePrView, summarizeChecks, pollPrChecks, pollPrChecksDetailed, baseRefHasPullRequestWorkflow, workflowHasPullRequestTrigger, createGithubAdapter, fetchChecksFailureEvidence, sanitizeCiText, sanitizeUrl, extractActionsRunId, buildFailureExcerpt, formatChecksFailureDetails, CHECKS_EVIDENCE_MAX_EXCERPT_CHARS, CHECKS_EVIDENCE_MAX_EXCERPT_LINES, CHECKS_EVIDENCE_MAX_RAW_LOG_CHARS_PER_RUN, CHECKS_EVIDENCE_GH_LOG_MAX_BUFFER, CHECKS_FAILURE_GENERIC_REASON, PR_VIEW_FIELDS, parseWorkflowRunInfo, } from "./github.js";
5
5
  test("parsePrView extracts the ground-truth handoff fields, including draft status", () => {
6
6
  const view = parsePrView(JSON.stringify({
7
7
  number: 7,
@@ -814,3 +814,91 @@ test("NOT-252: formatChecksFailureDetails labels the excerpt as untrusted, not i
814
814
  });
815
815
  assert.match(noExcerpt, /excerpt unavailable/);
816
816
  });
817
+ test("NOT-384: listWorkflowRunsForCommit filters by workflow and exact commit, parsing runs", async () => {
818
+ const { exec, calls } = queuedExec([
819
+ {
820
+ stdout: JSON.stringify([
821
+ { databaseId: 42, headSha: HEAD_SHA, conclusion: "success", status: "completed" },
822
+ { databaseId: 41, headSha: "other", conclusion: null, status: "in_progress" },
823
+ ]),
824
+ },
825
+ ]);
826
+ const runs = await createGithubAdapter(exec).listWorkflowRunsForCommit({
827
+ cwd: "/repo",
828
+ workflow: "visual.yml",
829
+ commitSha: HEAD_SHA,
830
+ });
831
+ assert.deepEqual(calls[0], [
832
+ "run",
833
+ "list",
834
+ "--workflow",
835
+ "visual.yml",
836
+ "--commit",
837
+ HEAD_SHA,
838
+ "--limit",
839
+ "5",
840
+ "--json",
841
+ "databaseId,headSha,conclusion,status",
842
+ ]);
843
+ assert.deepEqual(runs, [
844
+ { databaseId: 42, headSha: HEAD_SHA, conclusion: "success", status: "completed" },
845
+ { databaseId: 41, headSha: "other", conclusion: null, status: "in_progress" },
846
+ ]);
847
+ });
848
+ test("NOT-384: listWorkflowRunsForCommit returns [] when the workflow never ran, skips malformed rows, throws on gh failure", async () => {
849
+ const empty = queuedExec([{ stdout: "[]" }]);
850
+ assert.deepEqual(await createGithubAdapter(empty.exec).listWorkflowRunsForCommit({
851
+ cwd: "/repo",
852
+ workflow: "visual.yml",
853
+ commitSha: HEAD_SHA,
854
+ }), []);
855
+ const malformed = queuedExec([
856
+ {
857
+ stdout: JSON.stringify([
858
+ { databaseId: "nope", headSha: HEAD_SHA },
859
+ { databaseId: 42 },
860
+ null,
861
+ { databaseId: 43, headSha: HEAD_SHA, conclusion: "", status: "" },
862
+ ]),
863
+ },
864
+ ]);
865
+ assert.deepEqual(await createGithubAdapter(malformed.exec).listWorkflowRunsForCommit({
866
+ cwd: "/repo",
867
+ workflow: "visual.yml",
868
+ commitSha: HEAD_SHA,
869
+ }), [{ databaseId: 43, headSha: HEAD_SHA, conclusion: null, status: null }]);
870
+ const failing = queuedExec([{ error: "HTTP 401" }]);
871
+ await assert.rejects(createGithubAdapter(failing.exec).listWorkflowRunsForCommit({
872
+ cwd: "/repo",
873
+ workflow: "visual.yml",
874
+ commitSha: HEAD_SHA,
875
+ }), /gh run list failed/);
876
+ });
877
+ test("NOT-384: parseWorkflowRunInfo rejects rows without a numeric id and head SHA", () => {
878
+ assert.equal(parseWorkflowRunInfo(null), null);
879
+ assert.equal(parseWorkflowRunInfo({ databaseId: 1 }), null);
880
+ assert.equal(parseWorkflowRunInfo({ databaseId: 1, headSha: "" }), null);
881
+ assert.deepEqual(parseWorkflowRunInfo({ databaseId: 1, headSha: "h" }), {
882
+ databaseId: 1,
883
+ headSha: "h",
884
+ conclusion: null,
885
+ status: null,
886
+ });
887
+ });
888
+ test("NOT-384: downloadRunArtifact downloads one named artifact into the dest dir, throwing on gh failure", async () => {
889
+ const { exec, calls } = queuedExec([{ stdout: "" }]);
890
+ await createGithubAdapter(exec).downloadRunArtifact({
891
+ cwd: "/repo",
892
+ runId: 42,
893
+ name: "ui-diff",
894
+ destDir: "/store/ui-diff",
895
+ });
896
+ assert.deepEqual(calls[0], ["run", "download", "42", "--name", "ui-diff", "--dir", "/store/ui-diff"]);
897
+ const failing = queuedExec([{ error: "no such artifact" }]);
898
+ await assert.rejects(createGithubAdapter(failing.exec).downloadRunArtifact({
899
+ cwd: "/repo",
900
+ runId: 42,
901
+ name: "ui-diff",
902
+ destDir: "/store/ui-diff",
903
+ }), /gh run download failed/);
904
+ });
@@ -5,12 +5,16 @@
5
5
  // profiles sharing one account produce one entry — each carrying whatever
6
6
  // windows the provider actually reported.
7
7
  //
8
- // Connection health, hard-cap availability, and remaining capacity stay
9
- // separate states: this service never reads `runtime_availability` and its
10
- // output never implies health. Only normalized snapshots leave this module —
11
- // evidence refs and raw provider payloads stay server-side.
8
+ // Connection health and remaining capacity stay separate states: output never
9
+ // implies health. Only normalized snapshots leave this module — evidence refs
10
+ // and raw provider payloads stay server-side.
11
+ //
12
+ // NOT-440: an active hard cap (the same `runtime_availability` row that blocks
13
+ // admission) rides alongside the windows as `activeCap`; stored snapshots are
14
+ // never rewritten, and the overlay disappears with the row's lazy cleanup.
12
15
  import { isWindowKnown, Runtime as RuntimeSchema } from "@agent-dealer/shared";
13
16
  import { getDb } from "../db/index.js";
17
+ import { activeRuntimeCap } from "../repository/runtime-availability.js";
14
18
  import { listAllCapacitySnapshots, recordCapacitySnapshots, } from "../repository/runtime-capacity.js";
15
19
  import { normalizeAdapterWindow, normalizeUnavailableWindow, } from "./adapter.js";
16
20
  /** Distinct runtimes across Agent profiles — the configured runtime accounts. */
@@ -83,6 +87,61 @@ function classifyWindow(w, nowMs) {
83
87
  known: false,
84
88
  };
85
89
  }
90
+ const PAIR_RUNTIMES = new Set(["claude_code", "codex_local", "muse_code"]);
91
+ const CURSOR_BILLING_WINDOW_KEY = "billing_cycle";
92
+ /** Structured provider limit types that name a compact critical window. */
93
+ const LIMIT_TYPE_ROLE = {
94
+ five_hour: "five_hour",
95
+ seven_day: "weekly",
96
+ };
97
+ /** A cap reset and a snapshot reset within this distance name the same boundary. */
98
+ const RESET_MATCH_TOLERANCE_MS = 60_000;
99
+ /** The compact role a stored window presents under, or null when it never renders compactly. */
100
+ function compactRole(runtime, w) {
101
+ if (runtime === "cursor_local")
102
+ return w.windowKey === CURSOR_BILLING_WINDOW_KEY ? "billing_cycle" : null;
103
+ if (PAIR_RUNTIMES.has(runtime))
104
+ return w.criticalRole;
105
+ return null;
106
+ }
107
+ /**
108
+ * Which compact window an active cap exhausts: structured limit metadata
109
+ * first (a reported non-compact limit stays unmapped), then a tightly bounded
110
+ * match between the provider reset and exactly one tagged snapshot reset.
111
+ * A fallback-cooldown cap (no provider reset) is never matched.
112
+ */
113
+ export function resolveCapWindowRole(runtime, cap, windows) {
114
+ if (cap.limitType !== null) {
115
+ const role = LIMIT_TYPE_ROLE[cap.limitType] ?? null;
116
+ return role !== null && PAIR_RUNTIMES.has(runtime) ? role : null;
117
+ }
118
+ if (cap.providerResetAt === null)
119
+ return null;
120
+ const capResetMs = Date.parse(cap.providerResetAt);
121
+ if (!Number.isFinite(capResetMs))
122
+ return null;
123
+ const matched = new Set();
124
+ for (const w of windows) {
125
+ const role = compactRole(runtime, w);
126
+ if (role === null || w.resetAt === null)
127
+ continue;
128
+ const resetMs = Date.parse(w.resetAt);
129
+ if (Number.isFinite(resetMs) && Math.abs(resetMs - capResetMs) <= RESET_MATCH_TOLERANCE_MS) {
130
+ matched.add(role);
131
+ }
132
+ }
133
+ return matched.size === 1 ? [...matched][0] : null;
134
+ }
135
+ function activeCapFor(runtime, windows, nowMs) {
136
+ const cap = activeRuntimeCap(runtime, nowMs);
137
+ if (cap === null)
138
+ return null;
139
+ return {
140
+ unavailableUntil: cap.unavailableUntil,
141
+ reason: cap.reason,
142
+ windowRole: resolveCapWindowRole(runtime, cap, windows),
143
+ };
144
+ }
86
145
  /**
87
146
  * Read model for `GET /api/runtime-capacity`: every configured runtime, its
88
147
  * windows with freshness applied, and an explicit unavailable reason whenever
@@ -103,14 +162,15 @@ export function getRuntimeCapacitySnapshot(nowMs = Date.now()) {
103
162
  const entries = [...runtimes]
104
163
  .sort()
105
164
  .map((runtime) => {
106
- const windows = (byRuntime.get(runtime) ?? []).map((w) => classifyWindow(w, nowMs).snapshot);
165
+ const raw = byRuntime.get(runtime) ?? [];
166
+ const windows = raw.map((w) => classifyWindow(w, nowMs).snapshot);
107
167
  const knownCount = windows.filter((w) => w.unavailableReason === null).length;
108
168
  let unavailableReason = null;
109
169
  if (knownCount === 0) {
110
170
  const first = windows.find((w) => w.unavailableReason)?.unavailableReason;
111
171
  unavailableReason = first ?? "missing";
112
172
  }
113
- return { runtime, windows, unavailableReason };
173
+ return { runtime, windows, unavailableReason, activeCap: activeCapFor(runtime, raw, nowMs) };
114
174
  });
115
175
  return { runtimes: entries, generatedAt: new Date(nowMs).toISOString() };
116
176
  }
@@ -219,3 +219,223 @@ test("a past reset time is never presented as current capacity", async () => {
219
219
  assert.equal(muse.windows[0].unavailableReason, "expired");
220
220
  assert.equal(muse.unavailableReason, "expired");
221
221
  });
222
+ // NOT-440: active hard caps overlay the compact read model without rewriting
223
+ // stored provider observations.
224
+ const NOT440_NOW = Date.parse("2026-10-12T18:20:00.000Z");
225
+ const NOT440_RESET = "2026-10-12T18:59:01.000Z";
226
+ const NOT440_WEEKLY_RESET = "2026-10-16T02:00:00.000Z";
227
+ async function seedPair(runtime, windows) {
228
+ const { recordCapacitySnapshots } = await import("../repository/runtime-capacity.js");
229
+ recordCapacitySnapshots(runtime, windows.map((w) => ({
230
+ windowKey: w.windowKey,
231
+ providerBucket: "all_models",
232
+ durationMinutes: w.durationMinutes ?? (w.criticalRole === "weekly" ? 10080 : 300),
233
+ displayLabel: w.criticalRole === "weekly" ? "1W" : "5H",
234
+ usedValue: 100 - w.remainingPercent,
235
+ usedUnit: "percent",
236
+ remainingPercent: w.remainingPercent,
237
+ resetAt: w.resetAt,
238
+ observedAt: "2026-10-12T18:03:00.000Z",
239
+ freshUntil: "2026-10-12T19:03:00.000Z",
240
+ expiresAt: "2026-10-12T19:03:00.000Z",
241
+ source: "observed_event",
242
+ unavailableReason: null,
243
+ criticalRole: w.criticalRole,
244
+ })));
245
+ }
246
+ async function seedReproducedMuse() {
247
+ await seedPair("muse_code", [
248
+ { windowKey: "five_hour", criticalRole: "five_hour", remainingPercent: 50, resetAt: NOT440_RESET },
249
+ { windowKey: "weekly", criticalRole: "weekly", remainingPercent: 52, resetAt: NOT440_WEEKLY_RESET },
250
+ ]);
251
+ }
252
+ function storedMuseRemaining() {
253
+ const rows = getDb()
254
+ .prepare("SELECT window_key, remaining_percent FROM runtime_capacity_snapshots WHERE runtime = 'muse_code'")
255
+ .all();
256
+ return Object.fromEntries(rows.map((r) => [r.window_key, r.remaining_percent]));
257
+ }
258
+ const { getDb } = await import("../db/index.js");
259
+ const { clearAllRuntimeAvailability, recordRuntimeAvailability, runtimeAvailability } = await import("../repository/runtime-availability.js");
260
+ const { recordMuseUsageCap } = await import("../runners/usage-cap.js");
261
+ const { resolveCapWindowRole } = await import("./service.js");
262
+ test("NOT-440: reproduced Muse cap maps to 5H without modifying stored snapshots", async () => {
263
+ clearAllCapacitySnapshots();
264
+ clearAllRuntimeAvailability();
265
+ await seedReproducedMuse();
266
+ assert.deepEqual(storedMuseRemaining(), { five_hour: 50, weekly: 52 });
267
+ recordMuseUsageCap({ kind: "usage_cap", message: `Subscription quota exhausted. Usage resets at 2026-10-12T18:59:01Z.` }, Date.parse("2026-10-12T18:16:00.000Z"));
268
+ const snap = getRuntimeCapacitySnapshot(NOT440_NOW);
269
+ const muse = snap.runtimes.find((r) => r.runtime === "muse_code");
270
+ assert.deepEqual(muse.activeCap, {
271
+ unavailableUntil: NOT440_RESET,
272
+ reason: muse.activeCap.reason,
273
+ windowRole: "five_hour",
274
+ });
275
+ assert.match(muse.activeCap.reason, /muse_code usage capped/);
276
+ // Windows remain the raw provider observation; the overlay is activeCap.
277
+ assert.equal(muse.windows.find((w) => w.criticalRole === "five_hour").remainingPercent, 50);
278
+ assert.equal(muse.windows.find((w) => w.criticalRole === "weekly").remainingPercent, 52);
279
+ assert.deepEqual(storedMuseRemaining(), { five_hour: 50, weekly: 52 });
280
+ // Admission/health read the same row.
281
+ assert.deepEqual(runtimeAvailability("muse_code", NOT440_NOW), {
282
+ available: false,
283
+ until: NOT440_RESET,
284
+ reason: muse.activeCap.reason,
285
+ });
286
+ });
287
+ test("NOT-440: overlay is absent at and after unavailableUntil; history is kept", async () => {
288
+ for (const offset of [0, 1]) {
289
+ clearAllCapacitySnapshots();
290
+ clearAllRuntimeAvailability();
291
+ await seedReproducedMuse();
292
+ recordMuseUsageCap({ kind: "usage_cap", message: `Subscription quota exhausted. Usage resets at 2026-10-12T18:59:01Z.` }, Date.parse("2026-10-12T18:16:00.000Z"));
293
+ const atMs = Date.parse(NOT440_RESET) + offset;
294
+ const snap = getRuntimeCapacitySnapshot(atMs);
295
+ const muse = snap.runtimes.find((r) => r.runtime === "muse_code");
296
+ assert.equal(muse.activeCap, null, `offset ${offset}ms`);
297
+ // Normal classification resumes: the 5H reset has passed, 1W is still current.
298
+ const fiveHour = muse.windows.find((w) => w.criticalRole === "five_hour");
299
+ assert.equal(fiveHour.remainingPercent, null);
300
+ assert.equal(fiveHour.unavailableReason, "expired");
301
+ assert.equal(muse.windows.find((w) => w.criticalRole === "weekly").remainingPercent, 52);
302
+ assert.deepEqual(storedMuseRemaining(), { five_hour: 50, weekly: 52 });
303
+ const capRows = getDb()
304
+ .prepare("SELECT COUNT(*) AS n FROM runtime_availability WHERE runtime = 'muse_code'")
305
+ .get();
306
+ assert.equal(capRows.n, 0, "lazy cleanup cleared the expired cap row");
307
+ assert.deepEqual(runtimeAvailability("muse_code", atMs), { available: true });
308
+ }
309
+ });
310
+ test("NOT-440: a cap with no provider reset or role stays unmapped (explicit capped)", async () => {
311
+ clearAllCapacitySnapshots();
312
+ clearAllRuntimeAvailability();
313
+ await seedReproducedMuse();
314
+ // Fallback cooldown that happens to land on the 5H reset is still not a provider boundary.
315
+ recordRuntimeAvailability({
316
+ runtime: "muse_code",
317
+ unavailableUntil: NOT440_RESET,
318
+ reason: "muse_code usage capped — usage cap",
319
+ });
320
+ const muse = getRuntimeCapacitySnapshot(NOT440_NOW).runtimes.find((r) => r.runtime === "muse_code");
321
+ assert.equal(muse.activeCap.windowRole, null);
322
+ assert.equal(muse.activeCap.unavailableUntil, NOT440_RESET);
323
+ assert.deepEqual(storedMuseRemaining(), { five_hour: 50, weekly: 52 });
324
+ });
325
+ const tagged = (windowKey, criticalRole, resetAt) => ({
326
+ windowKey,
327
+ providerBucket: "all_models",
328
+ durationMinutes: 300,
329
+ displayLabel: "5H",
330
+ usedValue: 50,
331
+ usedUnit: "percent",
332
+ remainingPercent: 50,
333
+ resetAt,
334
+ observedAt: "2026-10-12T18:03:00.000Z",
335
+ freshUntil: "2026-10-12T19:03:00.000Z",
336
+ expiresAt: "2026-10-12T19:03:00.000Z",
337
+ source: "observed_event",
338
+ unavailableReason: null,
339
+ criticalRole,
340
+ });
341
+ test("NOT-440: cap window role resolution per runtime (metadata first, then reset match)", () => {
342
+ const pair = [tagged("five_hour", "five_hour", NOT440_RESET), tagged("weekly", "weekly", NOT440_WEEKLY_RESET)];
343
+ const cases = [
344
+ {
345
+ name: "Claude five_hour metadata wins even without a reset match",
346
+ runtime: "claude_code",
347
+ cap: { providerResetAt: "2026-10-12T20:00:00.000Z", limitType: "five_hour" },
348
+ windows: pair,
349
+ expected: "five_hour",
350
+ },
351
+ {
352
+ name: "Claude seven_day metadata maps to weekly",
353
+ runtime: "claude_code",
354
+ cap: { providerResetAt: null, limitType: "seven_day" },
355
+ windows: pair,
356
+ expected: "weekly",
357
+ },
358
+ {
359
+ name: "Claude model-specific limit stays unmapped despite a matching weekly reset",
360
+ runtime: "claude_code",
361
+ cap: { providerResetAt: NOT440_WEEKLY_RESET, limitType: "seven_day_sonnet" },
362
+ windows: pair,
363
+ expected: null,
364
+ },
365
+ {
366
+ name: "Codex reset match on the weekly window",
367
+ runtime: "codex_local",
368
+ cap: { providerResetAt: NOT440_WEEKLY_RESET, limitType: null },
369
+ windows: pair,
370
+ expected: "weekly",
371
+ },
372
+ {
373
+ name: "Muse reset match on the 5H window (within tolerance)",
374
+ runtime: "muse_code",
375
+ cap: { providerResetAt: "2026-10-12T18:59:30.000Z", limitType: null },
376
+ windows: pair,
377
+ expected: "five_hour",
378
+ },
379
+ {
380
+ name: "Muse reset outside tolerance stays unmapped",
381
+ runtime: "muse_code",
382
+ cap: { providerResetAt: "2026-10-12T19:05:00.000Z", limitType: null },
383
+ windows: pair,
384
+ expected: null,
385
+ },
386
+ {
387
+ name: "Cursor reset match on the billing cycle",
388
+ runtime: "cursor_local",
389
+ cap: { providerResetAt: "2026-11-01T00:00:00.000Z", limitType: null },
390
+ windows: [tagged("billing_cycle", null, "2026-11-01T00:00:00.000Z")],
391
+ expected: "billing_cycle",
392
+ },
393
+ {
394
+ name: "Untagged diagnostic window with a matching reset never maps",
395
+ runtime: "muse_code",
396
+ cap: { providerResetAt: "2026-10-13T00:00:00.000Z", limitType: null },
397
+ windows: [...pair, tagged("muse_account_usage", null, "2026-10-13T00:00:00.000Z")],
398
+ expected: null,
399
+ },
400
+ {
401
+ name: "Fallback cooldown (no provider reset) never maps",
402
+ runtime: "codex_local",
403
+ cap: { providerResetAt: null, limitType: null },
404
+ windows: pair,
405
+ expected: null,
406
+ },
407
+ {
408
+ name: "Ambiguous reset matching both pair windows stays unmapped",
409
+ runtime: "codex_local",
410
+ cap: { providerResetAt: NOT440_RESET, limitType: null },
411
+ windows: [tagged("five_hour", "five_hour", NOT440_RESET), tagged("weekly", "weekly", NOT440_RESET)],
412
+ expected: null,
413
+ },
414
+ ];
415
+ for (const c of cases) {
416
+ assert.equal(resolveCapWindowRole(c.runtime, c.cap, c.windows), c.expected, c.name);
417
+ }
418
+ });
419
+ test("NOT-440: Claude structured rate_limit_event persists limit type and maps through the read model", async () => {
420
+ clearAllCapacitySnapshots();
421
+ clearAllRuntimeAvailability();
422
+ await seedPair("claude_code", [
423
+ { windowKey: "five_hour", criticalRole: "five_hour", remainingPercent: 40, resetAt: NOT440_RESET },
424
+ { windowKey: "weekly", criticalRole: "weekly", remainingPercent: 70, resetAt: NOT440_WEEKLY_RESET },
425
+ ]);
426
+ const { recordUsageCapFromEvents } = await import("../runners/usage-cap.js");
427
+ recordUsageCapFromEvents([
428
+ {
429
+ type: "rate_limit_event",
430
+ rate_limit_info: {
431
+ status: "rejected",
432
+ rateLimitType: "seven_day",
433
+ resetsAt: Math.floor(Date.parse(NOT440_WEEKLY_RESET) / 1000),
434
+ },
435
+ },
436
+ ], "claude_code", NOT440_NOW);
437
+ const claude = getRuntimeCapacitySnapshot(NOT440_NOW).runtimes.find((r) => r.runtime === "claude_code");
438
+ assert.equal(claude.activeCap.windowRole, "weekly");
439
+ assert.equal(claude.activeCap.unavailableUntil, NOT440_WEEKLY_RESET);
440
+ clearAllRuntimeAvailability();
441
+ });