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.
- package/bundle/server/dist/adapters/agent-health.test.js +52 -0
- package/bundle/server/dist/adapters/git-worktree.js +38 -1
- package/bundle/server/dist/adapters/github.js +50 -0
- package/bundle/server/dist/adapters/github.test.js +89 -1
- package/bundle/server/dist/capacity/service.js +66 -6
- package/bundle/server/dist/capacity/service.test.js +220 -0
- package/bundle/server/dist/coordinator/auto-merge.js +46 -9
- package/bundle/server/dist/coordinator/commands.js +89 -9
- package/bundle/server/dist/coordinator/commands.test.js +228 -2
- package/bundle/server/dist/coordinator/developer-effect.js +36 -0
- package/bundle/server/dist/coordinator/operator-verification.integration.test.js +102 -1
- package/bundle/server/dist/coordinator/product-scope-contract.test.js +249 -0
- package/bundle/server/dist/coordinator/prompts.js +57 -2
- package/bundle/server/dist/coordinator/prompts.test.js +236 -2
- package/bundle/server/dist/coordinator/reviewer-browser-probe.js +89 -10
- package/bundle/server/dist/coordinator/reviewer-browser-probe.test.js +100 -0
- package/bundle/server/dist/coordinator/reviewer-effect.js +30 -1
- package/bundle/server/dist/coordinator/reviewer-effect.test.js +54 -0
- package/bundle/server/dist/coordinator/reviewer-visual-evidence.js +594 -0
- package/bundle/server/dist/coordinator/reviewer-visual-evidence.test.js +496 -0
- package/bundle/server/dist/coordinator/source-reload.js +44 -9
- package/bundle/server/dist/coordinator/visual-qa-collection.test.js +619 -0
- package/bundle/server/dist/coordinator/visual-qa.js +630 -0
- package/bundle/server/dist/coordinator/visual-qa.test.js +493 -0
- package/bundle/server/dist/db/index.js +8 -0
- package/bundle/server/dist/db/schema.sql +5 -1
- package/bundle/server/dist/paths.js +24 -0
- package/bundle/server/dist/repository/issues.js +8 -1
- package/bundle/server/dist/repository/issues.test.js +34 -0
- package/bundle/server/dist/repository/runtime-availability.js +29 -11
- package/bundle/server/dist/routes/issues-execution-contract.test.js +293 -0
- package/bundle/server/dist/routes/issues-source-attachments.test.js +277 -0
- package/bundle/server/dist/routes/issues.js +23 -8
- package/bundle/server/dist/routes/issues.test.js +80 -0
- package/bundle/server/dist/runners/usage-cap.js +66 -15
- package/bundle/server/dist/runners/usage-cap.test.js +96 -2
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-B_pNUDRY.js +64 -0
- package/bundle/server/static-ui/index.html +1 -1
- package/bundle/shared/dist/runtime-capacity.d.ts +85 -0
- package/bundle/shared/dist/runtime-capacity.js +19 -0
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
- 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
|
-
|
|
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
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
|
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
|
+
});
|