agent-dealer 1.2.0 → 1.2.2
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/linear-inbox.js +9 -5
- package/bundle/server/dist/capacity/adapter.js +2 -0
- package/bundle/server/dist/capacity/claude-events.js +18 -0
- package/bundle/server/dist/capacity/claude-events.test.js +7 -0
- package/bundle/server/dist/capacity/codex-app-server.js +100 -10
- package/bundle/server/dist/capacity/codex-app-server.test.js +195 -2
- package/bundle/server/dist/capacity/cursor-individual-credentials.js +238 -0
- package/bundle/server/dist/capacity/cursor-individual-credentials.test.js +185 -0
- package/bundle/server/dist/capacity/cursor-individual.js +970 -0
- package/bundle/server/dist/capacity/cursor-individual.test.js +815 -0
- package/bundle/server/dist/capacity/muse.js +94 -43
- package/bundle/server/dist/capacity/muse.test.js +95 -13
- package/bundle/server/dist/capacity/service.js +1 -0
- package/bundle/server/dist/coordinator/developer-effect.js +12 -7
- package/bundle/server/dist/coordinator/developer-effect.test.js +142 -5
- package/bundle/server/dist/db/index.js +30 -0
- package/bundle/server/dist/db/schema.sql +27 -0
- package/bundle/server/dist/index.js +2 -0
- package/bundle/server/dist/repository/cursor-individual-billing.js +84 -0
- package/bundle/server/dist/repository/repository-mappings.js +46 -0
- package/bundle/server/dist/repository/repository-mappings.test.js +83 -0
- package/bundle/server/dist/repository/runtime-capacity.js +8 -3
- package/bundle/server/dist/repository/runtime-capacity.test.js +4 -0
- package/bundle/server/dist/routes/cursor-individual-billing.test.js +80 -0
- package/bundle/server/dist/routes/index.js +32 -0
- package/bundle/server/dist/routes/intake-linear.test.js +55 -1
- package/bundle/server/dist/routes/repository-mappings.js +22 -0
- package/bundle/server/dist/routes/repository-mappings.test.js +110 -0
- package/bundle/server/dist/routes/runtime-capacity.test.js +72 -2
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-0GDYfbaM.js +60 -0
- package/bundle/server/static-ui/assets/index-DUYzboWh.css +1 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/index.d.ts +6 -6
- package/bundle/shared/dist/linear-intake.d.ts +71 -0
- package/bundle/shared/dist/linear-intake.js +90 -0
- package/bundle/shared/dist/linear-intake.test.js +80 -1
- package/bundle/shared/dist/runtime-capacity.d.ts +89 -0
- package/bundle/shared/dist/runtime-capacity.js +60 -0
- package/bundle/shared/dist/runtime-capacity.test.js +1 -0
- package/bundle/shared/package.json +1 -1
- package/dist/doctor.js +6 -0
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-J1u7JDns.css +0 -1
- package/bundle/server/static-ui/assets/index-obJbiSaU.js +0 -60
|
@@ -1,29 +1,31 @@
|
|
|
1
1
|
// packages/server/src/capacity/muse.ts
|
|
2
2
|
//
|
|
3
3
|
// NOT-247: Muse Code capacity adapter — read stable MSP usage windows.
|
|
4
|
+
// NOT-263: handshake repaired against the stable schema embedded in the
|
|
5
|
+
// shipped binary (`muse schema generate-json-schema`).
|
|
4
6
|
//
|
|
5
|
-
// Reads observed capacity through the
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// allowlist (`usage/read`) — any other method
|
|
11
|
-
// polling can never start a session, send a
|
|
12
|
-
// never touches the Keychain or undocumented
|
|
7
|
+
// Reads observed capacity through the stable Muse Session Protocol without
|
|
8
|
+
// sending a prompt and without consuming model tokens: `muse serve` over a
|
|
9
|
+
// one-shot managed stdio connection, then `initialize` → `initialized` → one
|
|
10
|
+
// `usage/read`, then shutdown. `usage/changed` notifications received while
|
|
11
|
+
// the connection is alive are recorded. The client enforces a read-only
|
|
12
|
+
// allowlist (`initialize`, `initialized`, `usage/read`) — any other method
|
|
13
|
+
// throws before it is written, so polling can never start a session, send a
|
|
14
|
+
// prompt, or run model work. It never touches the Keychain or undocumented
|
|
15
|
+
// endpoints.
|
|
13
16
|
//
|
|
14
|
-
//
|
|
17
|
+
// Stable `usage/read` shape:
|
|
15
18
|
// result: {
|
|
16
|
-
// protocol: "msp/1.3",
|
|
17
19
|
// usage?: {
|
|
18
20
|
// observedAtMs: number,
|
|
19
21
|
// tier: string, // dropped: never persisted, never served
|
|
20
|
-
//
|
|
22
|
+
// window?: { usedPercent, resetsAtMs, windowDurationMins },
|
|
21
23
|
// weekly?: { usedPercent, resetsAtMs }
|
|
22
24
|
// }
|
|
23
25
|
// }
|
|
24
26
|
// `usage` may be omitted when Muse has no observation — preserved as N/A
|
|
25
|
-
// (`missing`). The rolling label is derived from
|
|
26
|
-
// shared normalization, never hardcoded.
|
|
27
|
+
// (`missing`). The rolling (`window`) label is derived from
|
|
28
|
+
// `windowDurationMins` by the shared normalization, never hardcoded.
|
|
27
29
|
//
|
|
28
30
|
// Failure semantics (shared CapacityUnavailableReason enum only):
|
|
29
31
|
// - No credential (no META_API_KEY and no login file) / `usage` omitted /
|
|
@@ -42,7 +44,16 @@ import { MUSE_CLI_ENV, resolveMuseAuthFile, resolveMuseBin } from "../cli-env.js
|
|
|
42
44
|
import { normalizeAdapterWindow, normalizeUnavailableWindow, } from "./adapter.js";
|
|
43
45
|
export const MUSE_RUNTIME = "muse_code";
|
|
44
46
|
/** Methods this client may ever send. Anything else throws before write. */
|
|
45
|
-
export const MSP_READ_ONLY_METHODS = ["usage/read"];
|
|
47
|
+
export const MSP_READ_ONLY_METHODS = ["initialize", "initialized", "usage/read"];
|
|
48
|
+
/**
|
|
49
|
+
* Client identity sent in the `initialize` handshake. The stable schema
|
|
50
|
+
* requires `clientInfo.name` to match `^[a-z0-9_]+$` plus a version string.
|
|
51
|
+
* Version is kept in sync with `packages/server/package.json`.
|
|
52
|
+
*/
|
|
53
|
+
export const MUSE_CLIENT_INFO = {
|
|
54
|
+
name: "agent_dealer",
|
|
55
|
+
version: "1.2.1",
|
|
56
|
+
};
|
|
46
57
|
/** Notification the client records (never sends) while connected. */
|
|
47
58
|
export const MSP_USAGE_CHANGED = "usage/changed";
|
|
48
59
|
/** Methods that would start billable model work — never sent, asserted in tests. */
|
|
@@ -61,21 +72,18 @@ function readOnlyRequest(id, method, params) {
|
|
|
61
72
|
assertMuseReadOnlyMethod(method);
|
|
62
73
|
return `${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`;
|
|
63
74
|
}
|
|
75
|
+
function readOnlyNotification(method, params) {
|
|
76
|
+
assertMuseReadOnlyMethod(method);
|
|
77
|
+
return `${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`;
|
|
78
|
+
}
|
|
64
79
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* built from the ticket contract brief — the official Muse Code docs were
|
|
69
|
-
* unreachable at implementation time, so they are unverified against the
|
|
70
|
-
* published MSP surface. The committed fake (`fixtures/fake-muse-serve.mjs`)
|
|
71
|
-
* likewise enforces no handshake, so tests cannot catch a handshake
|
|
72
|
-
* requirement either. If a live `muse serve` demands an `initialize` step,
|
|
73
|
-
* the read-only allowlist will reject it and every live read will fail
|
|
74
|
-
* closed as `missing`/`unparsable` (never billed); re-check the argv against
|
|
75
|
-
* the docs before debugging any live failure.
|
|
80
|
+
* Stable Session Protocol argv. Fixed: capacity reads never `exec`, and the
|
|
81
|
+
* host takes no `--protocol` flag — `muse serve` with that flag exits 2
|
|
82
|
+
* against the shipped binary, so this stays exactly `["serve"]`.
|
|
76
83
|
*/
|
|
77
|
-
export const MUSE_SERVE_ARGV = ["serve"
|
|
78
|
-
/**
|
|
84
|
+
export const MUSE_SERVE_ARGV = ["serve"];
|
|
85
|
+
/** JSON-RPC request ids for the one-shot read (init handshake, then usage). */
|
|
86
|
+
export const MSP_INITIALIZE_ID = "muse-capacity-init";
|
|
79
87
|
export const MSP_USAGE_READ_ID = "muse-capacity-1";
|
|
80
88
|
/** Weekly windows carry no duration in MSP 1.3; a week in minutes. */
|
|
81
89
|
export const MUSE_WEEKLY_DURATION_MINUTES = 10080;
|
|
@@ -133,6 +141,9 @@ function windowReadingFromEntry(kind, entry, observedAt) {
|
|
|
133
141
|
resetAt,
|
|
134
142
|
observedAt,
|
|
135
143
|
source: "supported_protocol",
|
|
144
|
+
// Muse reports exactly this one rolling/weekly pair — it is always the
|
|
145
|
+
// account-wide critical window, never a model-specific extra.
|
|
146
|
+
criticalRole: kind === "rolling" ? "five_hour" : "weekly",
|
|
136
147
|
};
|
|
137
148
|
}
|
|
138
149
|
/**
|
|
@@ -153,7 +164,7 @@ function extractUsageObject(payload) {
|
|
|
153
164
|
return { error: "usage is not an object" };
|
|
154
165
|
return { usage: usage };
|
|
155
166
|
}
|
|
156
|
-
if ("observedAtMs" in result || "rolling" in result || "weekly" in result) {
|
|
167
|
+
if ("observedAtMs" in result || "window" in result || "rolling" in result || "weekly" in result) {
|
|
157
168
|
return { usage: result };
|
|
158
169
|
}
|
|
159
170
|
const keys = Object.keys(result);
|
|
@@ -162,25 +173,27 @@ function extractUsageObject(payload) {
|
|
|
162
173
|
return { error: "result carries no usage observation" };
|
|
163
174
|
}
|
|
164
175
|
/**
|
|
165
|
-
* Normalize
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* window
|
|
176
|
+
* Normalize stable `usage` into adapter readings. The rolling window arrives
|
|
177
|
+
* as `usage.window` (a legacy `usage.rolling` spelling is tolerated and maps
|
|
178
|
+
* to the same `rolling_all_models` snapshot). Returns null when the payload
|
|
179
|
+
* carries no observation or is malformed (callers needing the distinction
|
|
180
|
+
* check the payload shape first). Entries that are individually malformed
|
|
181
|
+
* are skipped — the caller re-reports present-but-bad keys as per-window
|
|
182
|
+
* `unparsable` readings so a bad sibling never sinks a good window.
|
|
171
183
|
*/
|
|
172
184
|
export function museUsageToReadings(payload, observedAt = new Date().toISOString()) {
|
|
173
185
|
const { usage, error } = extractUsageObject(payload);
|
|
174
186
|
if (error !== undefined || usage === undefined)
|
|
175
187
|
return null;
|
|
176
188
|
const u = usage;
|
|
177
|
-
const
|
|
189
|
+
const rollingEntry = u.window !== undefined ? u.window : u.rolling;
|
|
190
|
+
const hasRolling = rollingEntry !== undefined;
|
|
178
191
|
const hasWeekly = u.weekly !== undefined;
|
|
179
192
|
const observedAtMs = typeof u.observedAtMs === "number" && Number.isFinite(u.observedAtMs) ? u.observedAtMs : null;
|
|
180
193
|
const observed = observedAtMs !== null ? new Date(observedAtMs).toISOString() : observedAt;
|
|
181
194
|
const out = [];
|
|
182
195
|
if (hasRolling) {
|
|
183
|
-
const rolling = windowReadingFromEntry("rolling",
|
|
196
|
+
const rolling = windowReadingFromEntry("rolling", rollingEntry, observed);
|
|
184
197
|
if (rolling)
|
|
185
198
|
out.push(rolling);
|
|
186
199
|
}
|
|
@@ -200,11 +213,15 @@ export function museBadWindowKinds(payload) {
|
|
|
200
213
|
if (error !== undefined || usage === undefined)
|
|
201
214
|
return [];
|
|
202
215
|
const u = usage;
|
|
216
|
+
const entries = {
|
|
217
|
+
rolling: u.window !== undefined ? u.window : u.rolling,
|
|
218
|
+
weekly: u.weekly,
|
|
219
|
+
};
|
|
203
220
|
const bad = [];
|
|
204
221
|
for (const kind of ["rolling", "weekly"]) {
|
|
205
|
-
if (
|
|
222
|
+
if (entries[kind] === undefined)
|
|
206
223
|
continue;
|
|
207
|
-
const probe = windowReadingFromEntry(kind,
|
|
224
|
+
const probe = windowReadingFromEntry(kind, entries[kind], new Date().toISOString());
|
|
208
225
|
if (!probe)
|
|
209
226
|
bad.push(kind);
|
|
210
227
|
}
|
|
@@ -253,10 +270,11 @@ function hasMuseCredential(env, authFilePath) {
|
|
|
253
270
|
}
|
|
254
271
|
}
|
|
255
272
|
/**
|
|
256
|
-
* Spawn `muse serve`,
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
273
|
+
* Spawn `muse serve`, run the stable handshake (`initialize` with a
|
|
274
|
+
* `clientInfo` identity, then the `initialized` notification), send one
|
|
275
|
+
* `usage/read`, record any `usage/changed` notifications, then shut the
|
|
276
|
+
* connection down. Never throws for provider-side failures — those come back
|
|
277
|
+
* as `failure.kind`. Never sends anything outside MSP_READ_ONLY_METHODS.
|
|
260
278
|
*/
|
|
261
279
|
export function requestMuseUsage(opts = {}) {
|
|
262
280
|
const command = opts.command ?? resolveMuseBin();
|
|
@@ -272,6 +290,7 @@ export function requestMuseUsage(opts = {}) {
|
|
|
272
290
|
let settled = false;
|
|
273
291
|
let child;
|
|
274
292
|
let buffer = "";
|
|
293
|
+
let initResolve = null;
|
|
275
294
|
let readResolve = null;
|
|
276
295
|
const finish = (out) => {
|
|
277
296
|
if (settled)
|
|
@@ -298,7 +317,12 @@ export function requestMuseUsage(opts = {}) {
|
|
|
298
317
|
updates.push(msg.params ?? null);
|
|
299
318
|
return;
|
|
300
319
|
}
|
|
301
|
-
if (msg.id ===
|
|
320
|
+
if (msg.id === MSP_INITIALIZE_ID && initResolve) {
|
|
321
|
+
const cb = initResolve;
|
|
322
|
+
initResolve = null;
|
|
323
|
+
cb(msg);
|
|
324
|
+
}
|
|
325
|
+
else if (msg.id === MSP_USAGE_READ_ID && readResolve) {
|
|
302
326
|
const cb = readResolve;
|
|
303
327
|
readResolve = null;
|
|
304
328
|
cb(msg);
|
|
@@ -361,6 +385,28 @@ export function requestMuseUsage(opts = {}) {
|
|
|
361
385
|
}
|
|
362
386
|
};
|
|
363
387
|
void (async () => {
|
|
388
|
+
if (!write(readOnlyRequest(MSP_INITIALIZE_ID, "initialize", { clientInfo: { ...MUSE_CLIENT_INFO } }))) {
|
|
389
|
+
fail("unavailable");
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const init = await new Promise((res) => {
|
|
393
|
+
initResolve = res;
|
|
394
|
+
});
|
|
395
|
+
if (settled)
|
|
396
|
+
return;
|
|
397
|
+
if (!init || init.error) {
|
|
398
|
+
if (init?.error && errorLooksAuth(init.error))
|
|
399
|
+
fail("unauthenticated");
|
|
400
|
+
else if (init?.error && init.error.code === -32601)
|
|
401
|
+
fail("unsupported");
|
|
402
|
+
else
|
|
403
|
+
fail("unavailable");
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (!write(readOnlyNotification("initialized", {}))) {
|
|
407
|
+
fail("unavailable");
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
364
410
|
if (!write(readOnlyRequest(MSP_USAGE_READ_ID, "usage/read", {}))) {
|
|
365
411
|
fail("unavailable");
|
|
366
412
|
return;
|
|
@@ -504,6 +550,11 @@ export async function readMuseCapacity(opts = {}) {
|
|
|
504
550
|
durationMinutes: kind === "weekly" ? MUSE_WEEKLY_DURATION_MINUTES : null,
|
|
505
551
|
reason: "unparsable",
|
|
506
552
|
observedAt,
|
|
553
|
+
// This unavailable reading still stands in for the account-wide
|
|
554
|
+
// critical half it replaces — untagging it would make the client
|
|
555
|
+
// synthesize a second, duplicate N/A row for the same half (NOT-264
|
|
556
|
+
// review finding).
|
|
557
|
+
criticalRole: kind === "rolling" ? "five_hour" : "weekly",
|
|
507
558
|
});
|
|
508
559
|
}
|
|
509
560
|
if (windows.length === 0 && unavailable.length === 0) {
|
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
// packages/server/src/capacity/muse.test.ts
|
|
2
2
|
//
|
|
3
3
|
// NOT-247: Muse serve adapter — fake-subprocess coverage only, never a live
|
|
4
|
-
// Muse request. A fake MSP
|
|
4
|
+
// Muse request. A fake stable MSP server returns window + weekly usage (two
|
|
5
5
|
// independent snapshots with correct remaining percents and reset times), the
|
|
6
6
|
// rolling label derives from `windowDurationMins` (never hardcoded),
|
|
7
7
|
// failure modes map to explicit N/A reasons without touching runtime health,
|
|
8
|
-
// and the recorded protocol traffic proves the client
|
|
8
|
+
// and the recorded protocol traffic proves the client runs the
|
|
9
|
+
// initialize → initialized → usage/read handshake and never sends a prompt.
|
|
10
|
+
// NOT-263: the fake enforces the stable contract (no `--protocol` argv,
|
|
11
|
+
// handshake order, `usage.window` field names).
|
|
9
12
|
import { test, before } from "node:test";
|
|
10
13
|
import assert from "node:assert/strict";
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
11
15
|
import fs from "node:fs";
|
|
12
16
|
import os from "node:os";
|
|
13
17
|
import path from "node:path";
|
|
14
18
|
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-cap-"));
|
|
15
|
-
import { assertMuseReadOnlyMethod, createMuseCapacityAdapter, MSP_FORBIDDEN_MODEL_METHODS, museRefreshThrottleMs, museUsageToReadings, maybeRefreshMuseCapacityFromServe, normalizeMuseResetsAt, normalizeMuseUnavailable, readMuseCapacity, refreshMuseCapacityFromServe, requestMuseUsage, resetMuseCapacityRefreshState, } from "./muse.js";
|
|
16
|
-
import { normalizeAdapterWindow } from "./adapter.js";
|
|
19
|
+
import { assertMuseReadOnlyMethod, createMuseCapacityAdapter, MSP_FORBIDDEN_MODEL_METHODS, MUSE_CLIENT_INFO, MUSE_SERVE_ARGV, museRefreshThrottleMs, museUsageToReadings, maybeRefreshMuseCapacityFromServe, normalizeMuseResetsAt, normalizeMuseUnavailable, readMuseCapacity, refreshMuseCapacityFromServe, requestMuseUsage, resetMuseCapacityRefreshState, } from "./muse.js";
|
|
20
|
+
import { normalizeAdapterWindow, normalizeUnavailableWindow } from "./adapter.js";
|
|
17
21
|
const { migrate } = await import("../db/index.js");
|
|
18
22
|
const { clearAllCapacitySnapshots, listCapacitySnapshots } = await import("../repository/runtime-capacity.js");
|
|
19
23
|
const { clearAllRuntimeAvailability, runtimeAvailability, } = await import("../repository/runtime-availability.js");
|
|
@@ -40,6 +44,14 @@ function fakeOpts(mode, extra = {}) {
|
|
|
40
44
|
},
|
|
41
45
|
};
|
|
42
46
|
}
|
|
47
|
+
test("serve argv is the shipping contract: no --protocol flag", () => {
|
|
48
|
+
assert.deepEqual([...MUSE_SERVE_ARGV], ["serve"]);
|
|
49
|
+
assert.ok(!MUSE_SERVE_ARGV.includes("--protocol"), "muse serve takes no --protocol flag");
|
|
50
|
+
});
|
|
51
|
+
test("handshake identity matches the stable clientInfo shape", () => {
|
|
52
|
+
assert.match(MUSE_CLIENT_INFO.name, /^[a-z0-9_]+$/);
|
|
53
|
+
assert.ok(MUSE_CLIENT_INFO.version.length > 0, "versioned client identity required");
|
|
54
|
+
});
|
|
43
55
|
test("usage windows keep identity, percent, duration, and reset", () => {
|
|
44
56
|
const now = Date.now();
|
|
45
57
|
const parsed = museUsageToReadings({
|
|
@@ -47,7 +59,7 @@ test("usage windows keep identity, percent, duration, and reset", () => {
|
|
|
47
59
|
usage: {
|
|
48
60
|
observedAtMs: now,
|
|
49
61
|
tier: "contributor",
|
|
50
|
-
|
|
62
|
+
window: { usedPercent: 60, resetsAtMs: now + 2 * 3600_000, windowDurationMins: 300 },
|
|
51
63
|
weekly: { usedPercent: 25, resetsAtMs: now + 3 * 24 * 3600_000 },
|
|
52
64
|
},
|
|
53
65
|
}, new Date(now).toISOString());
|
|
@@ -62,16 +74,30 @@ test("usage windows keep identity, percent, duration, and reset", () => {
|
|
|
62
74
|
const normalized = normalizeAdapterWindow("muse_code", rolling, now);
|
|
63
75
|
assert.equal(normalized.displayLabel, "5H");
|
|
64
76
|
assert.equal(normalized.remainingPercent, 40);
|
|
77
|
+
assert.equal(normalized.criticalRole, "five_hour");
|
|
65
78
|
const weekly = parsed.windows.find((w) => w.windowKey === "weekly_all_models");
|
|
66
79
|
assert.equal(normalizeAdapterWindow("muse_code", weekly, now).displayLabel, "1W");
|
|
67
80
|
assert.equal(normalizeAdapterWindow("muse_code", weekly, now).remainingPercent, 75);
|
|
81
|
+
assert.equal(normalizeAdapterWindow("muse_code", weekly, now).criticalRole, "weekly");
|
|
82
|
+
});
|
|
83
|
+
test("legacy rolling spelling maps to the same snapshot", () => {
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
const parsed = museUsageToReadings({
|
|
86
|
+
usage: {
|
|
87
|
+
observedAtMs: now,
|
|
88
|
+
rolling: { usedPercent: 60, resetsAtMs: now + 2 * 3600_000, windowDurationMins: 300 },
|
|
89
|
+
},
|
|
90
|
+
}, new Date(now).toISOString());
|
|
91
|
+
assert.ok(parsed);
|
|
92
|
+
assert.equal(parsed.windows.length, 1);
|
|
93
|
+
assert.equal(parsed.windows[0].windowKey, "rolling_all_models");
|
|
68
94
|
});
|
|
69
95
|
test("rolling label derives from windowDurationMins, never a hardcoded 5H", () => {
|
|
70
96
|
const now = Date.now();
|
|
71
97
|
const parsed = museUsageToReadings({
|
|
72
98
|
usage: {
|
|
73
99
|
observedAtMs: now,
|
|
74
|
-
|
|
100
|
+
window: { usedPercent: 10, resetsAtMs: now + 6 * 3600_000, windowDurationMins: 720 },
|
|
75
101
|
},
|
|
76
102
|
}, new Date(now).toISOString());
|
|
77
103
|
assert.ok(parsed);
|
|
@@ -86,7 +112,7 @@ test("payloads without an observation read null, not empty", () => {
|
|
|
86
112
|
assert.equal(museUsageToReadings({ protocol: "msp/1.3" }), null);
|
|
87
113
|
assert.equal(museUsageToReadings({ usage: null }), null);
|
|
88
114
|
assert.equal(museUsageToReadings({ usage: { observedAtMs: Date.now() } }).windows.length, 0);
|
|
89
|
-
assert.equal(museUsageToReadings({ usage: { observedAtMs: Date.now(),
|
|
115
|
+
assert.equal(museUsageToReadings({ usage: { observedAtMs: Date.now(), window: { windowDurationMins: 300 } } })
|
|
90
116
|
.windows.length, 0);
|
|
91
117
|
});
|
|
92
118
|
test("resetsAtMs accepts ms, seconds, and ISO; rejects garbage", () => {
|
|
@@ -99,7 +125,9 @@ test("resetsAtMs accepts ms, seconds, and ISO; rejects garbage", () => {
|
|
|
99
125
|
assert.equal(normalizeMuseResetsAt(null), null);
|
|
100
126
|
assert.equal(normalizeMuseResetsAt(-5), null);
|
|
101
127
|
});
|
|
102
|
-
test("only usage/read can be sent; session prompts throw before write", () => {
|
|
128
|
+
test("only the handshake and usage/read can be sent; session prompts throw before write", () => {
|
|
129
|
+
assertMuseReadOnlyMethod("initialize");
|
|
130
|
+
assertMuseReadOnlyMethod("initialized");
|
|
103
131
|
assertMuseReadOnlyMethod("usage/read");
|
|
104
132
|
for (const m of ["session/start", "session/prompt", "session/resume", "exec", "usage/write"]) {
|
|
105
133
|
assert.throws(() => assertMuseReadOnlyMethod(m), /refusing non-read method/);
|
|
@@ -144,31 +172,85 @@ test("usage/changed notifications are consumed while the connection is alive", a
|
|
|
144
172
|
assert.equal(read.failure, null);
|
|
145
173
|
assert.equal(read.updates.length, 1);
|
|
146
174
|
});
|
|
147
|
-
test("protocol traffic is
|
|
175
|
+
test("protocol traffic is the stable handshake then exactly one usage/read", async () => {
|
|
148
176
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-record-"));
|
|
149
177
|
const recordPath = path.join(dir, "methods.jsonl");
|
|
150
178
|
const { opts } = fakeOpts("full", { env: { FAKE_MSP_RECORD: recordPath } });
|
|
151
179
|
await readMuseCapacity(opts);
|
|
152
|
-
const
|
|
180
|
+
const lines = fs
|
|
153
181
|
.readFileSync(recordPath, "utf8")
|
|
154
182
|
.split("\n")
|
|
155
183
|
.filter(Boolean)
|
|
156
|
-
.map((l) =>
|
|
157
|
-
|
|
184
|
+
.map((l) => JSON.parse(l));
|
|
185
|
+
const methods = lines.map((l) => String(l.method));
|
|
186
|
+
assert.deepEqual(methods, ["initialize", "initialized", "usage/read"]);
|
|
187
|
+
const init = lines[0];
|
|
188
|
+
assert.equal(init.params?.clientInfo?.name, MUSE_CLIENT_INFO.name);
|
|
189
|
+
assert.match(String(init.params?.clientInfo?.name ?? ""), /^[a-z0-9_]+$/);
|
|
190
|
+
assert.equal(init.params?.clientInfo?.version, MUSE_CLIENT_INFO.version);
|
|
158
191
|
for (const m of methods) {
|
|
159
|
-
assert.equal(m, "usage/read");
|
|
160
192
|
assert.ok(!/session|prompt|exec|message|turn|thread/i.test(m), `billable method ${m}`);
|
|
161
193
|
}
|
|
162
194
|
});
|
|
195
|
+
test("fake rejects the removed --protocol argv like the shipped binary", async () => {
|
|
196
|
+
clearAllCapacitySnapshots();
|
|
197
|
+
const now = Date.now();
|
|
198
|
+
const result = await readMuseCapacity({
|
|
199
|
+
command: process.execPath,
|
|
200
|
+
args: [FAKE, "--protocol", "msp/1.3"],
|
|
201
|
+
env: { META_API_KEY: "test-fake-key", FAKE_MSP_MODE: "full" },
|
|
202
|
+
timeoutMs: 10_000,
|
|
203
|
+
nowMs: now,
|
|
204
|
+
});
|
|
205
|
+
assert.equal(result.windows.length, 0);
|
|
206
|
+
assert.equal(result.unavailable.length, 1);
|
|
207
|
+
assert.equal(result.unavailable[0].windowKey, "muse_account_usage");
|
|
208
|
+
assert.equal(result.unavailable[0].reason, "missing");
|
|
209
|
+
});
|
|
210
|
+
test("fake requires initialize before usage/read", async () => {
|
|
211
|
+
const child = spawn(process.execPath, [FAKE], {
|
|
212
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
213
|
+
env: { ...process.env, FAKE_MSP_MODE: "full", META_API_KEY: "test-fake-key" },
|
|
214
|
+
});
|
|
215
|
+
try {
|
|
216
|
+
const first = await new Promise((resolve, reject) => {
|
|
217
|
+
const timer = setTimeout(() => reject(new Error("timed out waiting for fake response")), 5000);
|
|
218
|
+
timer.unref?.();
|
|
219
|
+
let buf = "";
|
|
220
|
+
child.stdout.on("data", (chunk) => {
|
|
221
|
+
buf += chunk.toString();
|
|
222
|
+
const idx = buf.indexOf("\n");
|
|
223
|
+
if (idx >= 0) {
|
|
224
|
+
clearTimeout(timer);
|
|
225
|
+
resolve(buf.slice(0, idx));
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: "bare-read", method: "usage/read", params: {} })}\n`);
|
|
229
|
+
});
|
|
230
|
+
const msg = JSON.parse(first);
|
|
231
|
+
assert.ok(msg.error, "bare usage/read is rejected before the handshake");
|
|
232
|
+
assert.match(String(msg.error?.message ?? ""), /initialize/i);
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
child.kill();
|
|
236
|
+
}
|
|
237
|
+
});
|
|
163
238
|
test("a malformed sibling window does not sink the good one", async () => {
|
|
164
239
|
clearAllCapacitySnapshots();
|
|
165
240
|
const { opts } = fakeOpts("partial-bad-weekly");
|
|
166
241
|
const result = await readMuseCapacity(opts);
|
|
167
242
|
assert.equal(result.windows.length, 1);
|
|
168
243
|
assert.equal(result.windows[0].windowKey, "rolling_all_models");
|
|
244
|
+
assert.equal(result.windows[0].criticalRole, "five_hour");
|
|
169
245
|
assert.equal(result.unavailable.length, 1);
|
|
170
246
|
assert.equal(result.unavailable[0].windowKey, "weekly_all_models");
|
|
171
247
|
assert.equal(result.unavailable[0].reason, "unparsable");
|
|
248
|
+
// NOT-264 review finding: the unavailable half must still carry its
|
|
249
|
+
// criticalRole, or the client can't find it and synthesizes a second,
|
|
250
|
+
// duplicate N/A row for the same half instead of one.
|
|
251
|
+
assert.equal(result.unavailable[0].criticalRole, "weekly");
|
|
252
|
+
const normalizedUnavailable = normalizeUnavailableWindow("muse_code", result.unavailable[0]);
|
|
253
|
+
assert.equal(normalizedUnavailable.criticalRole, "weekly");
|
|
172
254
|
});
|
|
173
255
|
async function expectUnavailable(mode, reason, extra = {}) {
|
|
174
256
|
clearAllCapacitySnapshots();
|
|
@@ -47,6 +47,7 @@ export async function ingestAdapterResult(result) {
|
|
|
47
47
|
source: w.source,
|
|
48
48
|
unavailableReason: w.unavailableReason,
|
|
49
49
|
evidenceRef: w.evidenceRef,
|
|
50
|
+
criticalRole: w.criticalRole,
|
|
50
51
|
})));
|
|
51
52
|
}
|
|
52
53
|
/** Classify a stored window at read time (freshness/expiry/reset, not just flags). */
|
|
@@ -176,15 +176,20 @@ function loadPriorVerificationReceipt(issueId, currentHeadSha) {
|
|
|
176
176
|
/**
|
|
177
177
|
* The ticket requires verifying "draft PR identity, base SHA, and current head SHA" —
|
|
178
178
|
* not just accepting whatever `gh pr view` returns. A review round found the original
|
|
179
|
-
* code took `prView` on faith: wrong base, a
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
179
|
+
* code took `prView` on faith: wrong base, a PR number that silently changed between
|
|
180
|
+
* rounds, or a stale `headRefOid` would all have been accepted as a clean handoff.
|
|
181
|
+
* `localHead` is this process's own `git rev-parse HEAD` right after the push it just
|
|
182
|
+
* performed — the actual ground truth `gh`'s view is checked against.
|
|
183
|
+
*
|
|
184
|
+
* NOT-255: draft-ness is deliberately NOT identity. A failed `retry_merge` undrafts
|
|
185
|
+
* the PR before attempting the squash-merge (`auto-merge.ts` runs `gh pr ready`
|
|
186
|
+
* first), so a merge-failure repair/resume round legitimately starts with a non-draft
|
|
187
|
+
* PR whose branch, base, number, and head still verify. Requiring `isDraft` here
|
|
188
|
+
* turned that legitimate state into an `adapter_failure` loop no repair round could
|
|
189
|
+
* exit. A non-draft PR whose branch/base/number/head all verify proceeds; the
|
|
190
|
+
* identity-drift cases above still fail exactly as before.
|
|
183
191
|
*/
|
|
184
192
|
async function validatePrIdentity(prView, opts) {
|
|
185
|
-
if (!prView.isDraft) {
|
|
186
|
-
return { ok: false, reason: `PR #${prView.number} is not a draft PR` };
|
|
187
|
-
}
|
|
188
193
|
if (prView.headRefName !== opts.branchName) {
|
|
189
194
|
return { ok: false, reason: `PR head branch ${prView.headRefName} does not match the issue branch ${opts.branchName}` };
|
|
190
195
|
}
|
|
@@ -457,8 +457,12 @@ test("timed_out (checks poll): checks stay pending past the poll deadline", asyn
|
|
|
457
457
|
await pump(1);
|
|
458
458
|
assert.equal(getIssue(issueId).status, "developing");
|
|
459
459
|
});
|
|
460
|
-
test("
|
|
461
|
-
|
|
460
|
+
test("NOT-255: a non-draft PR with otherwise-verified identity hands off cleanly (just-failed retry_merge)", async () => {
|
|
461
|
+
// A merge-failure escalation is reachable from `retry_merge`, which undrafts the PR
|
|
462
|
+
// before attempting the squash-merge. When that merge fails the PR is left non-draft
|
|
463
|
+
// — legitimately — so a repair/resume developer round must not fail identity on
|
|
464
|
+
// draft-ness alone when branch/base/number/head all verify.
|
|
465
|
+
const issueId = await makeIssue();
|
|
462
466
|
const github = fakeGithub();
|
|
463
467
|
const realViewPr = github.viewPr.bind(github);
|
|
464
468
|
github.viewPr = async (opts) => {
|
|
@@ -469,9 +473,61 @@ test("adapter_failure: a non-draft PR is rejected rather than accepted as a clea
|
|
|
469
473
|
startWorkflow(issueId);
|
|
470
474
|
await pump(1);
|
|
471
475
|
const issue = getIssue(issueId);
|
|
472
|
-
assert.equal(issue.status, "
|
|
473
|
-
assert.
|
|
474
|
-
|
|
476
|
+
assert.equal(issue.status, "reviewing");
|
|
477
|
+
assert.ok(issue.prNumber, "a verified non-draft identity is still recorded as the handoff");
|
|
478
|
+
});
|
|
479
|
+
test("NOT-255: repair from a merge-failure escalation with a non-draft PR runs a round that hands off, not re-escalates", async () => {
|
|
480
|
+
// commands.ts routing: an open policy_escalation with mergeFailure evidence resolved
|
|
481
|
+
// with `repair` queues a developer round. That round starts with the non-draft PR
|
|
482
|
+
// the failed retry_merge left behind and must reach the reviewer instead of
|
|
483
|
+
// immediately re-escalating on "is not a draft PR".
|
|
484
|
+
const { transitionIssue } = await import("../repository/issues.js");
|
|
485
|
+
const { createHumanAction } = await import("../repository/human-actions.js");
|
|
486
|
+
const { responseOptionsFor } = await import("./commands.js");
|
|
487
|
+
const { MERGE_FAILURE_EVIDENCE_KEY } = await import("./human-resolution.js");
|
|
488
|
+
const issueId = await makeIssue();
|
|
489
|
+
const github = fakeGithub();
|
|
490
|
+
const realViewPr = github.viewPr.bind(github);
|
|
491
|
+
github.viewPr = async (opts) => {
|
|
492
|
+
const view = await realViewPr(opts);
|
|
493
|
+
return view ? { ...view, isDraft: false } : view;
|
|
494
|
+
};
|
|
495
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github }));
|
|
496
|
+
startWorkflow(issueId);
|
|
497
|
+
// A real merge failure happens after all work finished: clear the initial pending
|
|
498
|
+
// round (the coordinator enforces one pending/leased item per instance) to reach
|
|
499
|
+
// the parked state a failed retry_merge leaves behind.
|
|
500
|
+
for (const item of listWorkItemsForIssue(issueId)) {
|
|
501
|
+
if (item.status === "pending" || item.status === "leased")
|
|
502
|
+
cancelWorkItem(item.id);
|
|
503
|
+
}
|
|
504
|
+
transitionIssue(issueId, "needs_human", {
|
|
505
|
+
currentOwner: "human",
|
|
506
|
+
currentIntent: "Auto-merge failed: merge conflict",
|
|
507
|
+
});
|
|
508
|
+
const action = createHumanAction({
|
|
509
|
+
issueId,
|
|
510
|
+
workflowInstanceId: getActiveWorkflowInstance(issueId).id,
|
|
511
|
+
actionType: "policy_escalation",
|
|
512
|
+
reason: "Auto-merge failed: merge conflict",
|
|
513
|
+
question: "Auto-merge failed: merge conflict Retry the merge, queue another repair round, or close the issue?",
|
|
514
|
+
responseOptions: responseOptionsFor("policy_escalation", false, { mergeFailure: true }),
|
|
515
|
+
evidence: { [MERGE_FAILURE_EVIDENCE_KEY]: true },
|
|
516
|
+
});
|
|
517
|
+
const resolved = resolveHumanActionAndAdvance(action.id, "op", "repair");
|
|
518
|
+
assert.equal(resolved.ok, true);
|
|
519
|
+
if (resolved.ok) {
|
|
520
|
+
assert.equal(resolved.issueStatus, "repairing");
|
|
521
|
+
assert.ok(resolved.nextWorkItemId, "repair must queue a developer round");
|
|
522
|
+
}
|
|
523
|
+
// Only pump the developer item — the file's placeholder reviewer handler (always
|
|
524
|
+
// session_failed) would otherwise fail the just-queued reviewer round and mask the
|
|
525
|
+
// handoff this test actually checks.
|
|
526
|
+
await pump(1);
|
|
527
|
+
const issue = getIssue(issueId);
|
|
528
|
+
assert.equal(issue.status, "reviewing", "the repair round must hand off, not immediately re-escalate");
|
|
529
|
+
assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer" && i.status === "pending").length, 1, "the handoff must queue the reviewer round");
|
|
530
|
+
assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 0, "no fresh escalation may be waiting");
|
|
475
531
|
});
|
|
476
532
|
test("adapter_failure: a PR based against the wrong branch is rejected rather than accepted", async () => {
|
|
477
533
|
const issueId = await makeIssue({ maxInfraAttempts: 0 });
|
|
@@ -486,6 +542,87 @@ test("adapter_failure: a PR based against the wrong branch is rejected rather th
|
|
|
486
542
|
await pump(1);
|
|
487
543
|
assert.equal(getIssue(issueId).status, "needs_human");
|
|
488
544
|
});
|
|
545
|
+
test("adapter_failure: a PR on the wrong head branch is rejected rather than accepted", async () => {
|
|
546
|
+
// NOT-255 regression guard: tolerating non-draft must not weaken the branch check.
|
|
547
|
+
const issueId = await makeIssue({ maxInfraAttempts: 0 });
|
|
548
|
+
const github = fakeGithub();
|
|
549
|
+
const realViewPr = github.viewPr.bind(github);
|
|
550
|
+
github.viewPr = async (opts) => {
|
|
551
|
+
const view = await realViewPr(opts);
|
|
552
|
+
return view ? { ...view, headRefName: "someone-elses-branch" } : view;
|
|
553
|
+
};
|
|
554
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github }));
|
|
555
|
+
startWorkflow(issueId);
|
|
556
|
+
await pump(1);
|
|
557
|
+
const issue = getIssue(issueId);
|
|
558
|
+
assert.equal(issue.status, "needs_human");
|
|
559
|
+
assert.equal(issue.prNumber, null, "a rejected identity must never be recorded as the verified handoff");
|
|
560
|
+
});
|
|
561
|
+
test("adapter_failure: a PR number that changed between rounds is rejected rather than accepted", async () => {
|
|
562
|
+
// NOT-255 regression guard: tolerating non-draft must not weaken the number check.
|
|
563
|
+
// Round 1 hands off cleanly (recording the PR number on the issue); the reviewer
|
|
564
|
+
// requests changes; round 2 reports a different PR number for the same branch and
|
|
565
|
+
// must be rejected.
|
|
566
|
+
const { runReviewerEffect } = await import("./reviewer-effect.js");
|
|
567
|
+
const issueId = await makeIssue({ maxInfraAttempts: 0 });
|
|
568
|
+
// Round 2 must push a NEW commit — re-running the identical write+commit against
|
|
569
|
+
// an unchanged file would fail with "nothing to commit" in the fake, not the fix.
|
|
570
|
+
let devCalls = 0;
|
|
571
|
+
const twoRoundSpawn = async (input) => {
|
|
572
|
+
devCalls++;
|
|
573
|
+
if (devCalls === 1)
|
|
574
|
+
return commitingSpawn(input);
|
|
575
|
+
fs.writeFileSync(path.join(input.cwd, "followup.txt"), "follow-up\n");
|
|
576
|
+
git(input.cwd, "add", ".");
|
|
577
|
+
git(input.cwd, "-c", "user.email=agent@test", "-c", "user.name=Agent", "commit", "-q", "-m", "follow-up");
|
|
578
|
+
return { exitCode: 0, transcript: "Implementation conclusion: follow-up work.", logPath: "/dev/null", timedOut: false };
|
|
579
|
+
};
|
|
580
|
+
const github = fakeGithub();
|
|
581
|
+
// The shared fake's publishReview throws (developer-only use) — the reviewer
|
|
582
|
+
// round needs the success stub NOT-220's shared github carries.
|
|
583
|
+
github.publishReview = async ({ event }) => ({ ok: true, event, usedCommentFallback: false });
|
|
584
|
+
const realViewPr = github.viewPr.bind(github);
|
|
585
|
+
github.viewPr = async (opts) => {
|
|
586
|
+
// The reviewer selects by PR number alone, but the shared fake requires an
|
|
587
|
+
// explicit branch (NOT-82) — pin the number-only lookup to this issue's branch.
|
|
588
|
+
const view = await realViewPr(opts.branch == null && opts.number != null ? { ...opts, branch: issueBranchName(issueId) } : opts);
|
|
589
|
+
if (!view)
|
|
590
|
+
return view;
|
|
591
|
+
// Once round 1's handoff recorded the PR number, report a different one — but
|
|
592
|
+
// only on the developer's branch-pinned lookups (no `number` selector). The
|
|
593
|
+
// reviewer selects by PR number and must keep seeing the recorded one, or round
|
|
594
|
+
// 1 would never reach the repair round this test needs.
|
|
595
|
+
if (getIssue(issueId)?.prNumber != null && opts.number == null) {
|
|
596
|
+
return { ...view, number: view.number + 1000 };
|
|
597
|
+
}
|
|
598
|
+
return view;
|
|
599
|
+
};
|
|
600
|
+
const reviewerSpawn = async (input) => {
|
|
601
|
+
const baseSha = input.prompt.match(/"baseSha" to exactly "([0-9a-f]+)"/)?.[1];
|
|
602
|
+
const headSha = input.prompt.match(/"headSha" to exactly "([0-9a-f]+)"/)?.[1];
|
|
603
|
+
if (!baseSha || !headSha)
|
|
604
|
+
throw new Error("could not extract SHAs from reviewer prompt");
|
|
605
|
+
const body = {
|
|
606
|
+
verdict: "changes_requested",
|
|
607
|
+
baseSha,
|
|
608
|
+
headSha,
|
|
609
|
+
acceptanceCriteriaAssessment: "Not yet.",
|
|
610
|
+
evidenceAssessment: "Evidence checked.",
|
|
611
|
+
findings: [{ fingerprint: "missing-test", severity: "blocking", title: "No test", rationale: "Add a test." }],
|
|
612
|
+
risks: [],
|
|
613
|
+
};
|
|
614
|
+
return { exitCode: 0, transcript: "```json\n" + JSON.stringify(body) + "\n```\n", logPath: "/dev/null", timedOut: false };
|
|
615
|
+
};
|
|
616
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: twoRoundSpawn, github }));
|
|
617
|
+
registerEffectHandler("reviewer", (ctx) => runReviewerEffect(ctx, { deckCallTool: okDeckCallTool, spawn: reviewerSpawn, github: github }));
|
|
618
|
+
startWorkflow(issueId);
|
|
619
|
+
await pump(20);
|
|
620
|
+
const issue = getIssue(issueId);
|
|
621
|
+
assert.equal(devCalls, 2, "the repair round must actually run against the changed PR number");
|
|
622
|
+
assert.equal(issue.status, "needs_human", "a changed PR number must escalate, not hand off");
|
|
623
|
+
assert.ok(listHumanActionsForIssue(issueId).find((a) => a.actionType === "policy_escalation"), "the number drift must surface as a policy escalation");
|
|
624
|
+
resetEffectHandlers();
|
|
625
|
+
});
|
|
489
626
|
test("clean handoff: a briefly stale headRefOid (gh's view lags the push) catches up within the reconcile window", async () => {
|
|
490
627
|
// NOT-110: the first couple of `gh pr view` reads still show the pre-push head — exactly
|
|
491
628
|
// the GraphQL-lag race observed in production — then GitHub catches up. This must be
|