agent-dealer 1.3.2 → 1.3.4
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/capacity/service.js +66 -6
- package/bundle/server/dist/capacity/service.test.js +220 -0
- package/bundle/server/dist/coordinator/commands.js +79 -1
- package/bundle/server/dist/coordinator/commands.test.js +2 -0
- package/bundle/server/dist/coordinator/developer-effect.test.js +2 -0
- package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +2 -0
- package/bundle/server/dist/coordinator/reviewer-effect.test.js +2 -0
- package/bundle/server/dist/coordinator/worker-loop.test.js +2 -0
- package/bundle/server/dist/db/auto-merge-default-migration.test.js +52 -0
- package/bundle/server/dist/db/index.js +12 -2
- package/bundle/server/dist/db/schema.sql +6 -2
- package/bundle/server/dist/dev-review-cli-happy-path.integration.test.js +2 -0
- package/bundle/server/dist/repository/runtime-availability.js +29 -11
- package/bundle/server/dist/routes/auto-merge-policy.test.js +378 -0
- package/bundle/server/dist/routes/human-actions.test.js +4 -4
- package/bundle/server/dist/routes/issues-execution-contract.test.js +4 -3
- package/bundle/server/dist/routes/issues.js +32 -2
- package/bundle/server/dist/routes/issues.test.js +25 -2
- 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-CHCiztRI.css +1 -0
- package/bundle/server/static-ui/assets/index-CrLQFFpw.js +64 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/issues.d.ts +8 -5
- package/bundle/shared/dist/issues.js +7 -4
- package/bundle/shared/dist/issues.test.js +11 -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/dist/index.js +1 -1
- package/dist/issue.d.ts +1 -0
- package/dist/issue.js +3 -2
- package/dist/issue.test.js +37 -0
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-DL8mEXzN.css +0 -1
- package/bundle/server/static-ui/assets/index-DQ5ivcyg.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 });
|
|
@@ -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
|
+
});
|
|
@@ -32,7 +32,7 @@ import { routeDeveloperOutcome, routeReviewerOutcome, } from "./routing.js";
|
|
|
32
32
|
import { projectDeveloperRoute, projectReviewerRoute } from "./projection.js";
|
|
33
33
|
import { MERGE_CONFLICT_FILES_EVIDENCE_KEY, MERGE_FAILURE_EVIDENCE_KEY, MERGE_FAILURE_RESPONSE_OPTIONS, PUSH_DIVERGENCE_EVIDENCE_KEY, PUSH_DIVERGENCE_RESPONSE_OPTIONS, WORKTREE_BLOCKER_EVIDENCE_KEY, normalizeResolutionNote, parseHumanResolution, resolveHumanActionOutcome, } from "./human-resolution.js";
|
|
34
34
|
import { AUTH_TRANSIENT_RETRY_PAYLOAD_KEY, RUNTIME_AUTH_PARK_EVIDENCE_KEY, authProbeConfirmsFailure, defaultRemediationForRuntime, gateRuntimeAuthParkResume, parseRuntimeAuthParkEvidence, remediationFromProbe, } from "./runtime-auth-park.js";
|
|
35
|
-
import { AUTO_MERGE_INTENT, finalizeAutoMerge } from "./auto-merge.js";
|
|
35
|
+
import { AUTO_MERGE_INTENT, OPERATOR_VERIFICATION_INTENT, finalizeAutoMerge } from "./auto-merge.js";
|
|
36
36
|
import { triggerLinearPostMerge } from "./linear-merge-verify.js";
|
|
37
37
|
import { conflictRepairSpent, queueConflictRepairRound } from "./merge-conflict-sync.js";
|
|
38
38
|
import { OPERATOR_VERIFICATION_ARTIFACT_KIND, OPERATOR_VERIFICATION_RESPONSE_OPTIONS, } from "./operator-criteria.js";
|
|
@@ -261,6 +261,84 @@ export function applyParkedEdit(issueId, patch) {
|
|
|
261
261
|
throw err;
|
|
262
262
|
}
|
|
263
263
|
}
|
|
264
|
+
export function autoMergePolicyEditability(issue) {
|
|
265
|
+
switch (issue.status) {
|
|
266
|
+
case "ready":
|
|
267
|
+
return { editable: true, reason: null };
|
|
268
|
+
case "developing":
|
|
269
|
+
case "reviewing":
|
|
270
|
+
case "repairing":
|
|
271
|
+
return getActiveWorkflowInstance(issue.id)
|
|
272
|
+
? { editable: true, reason: null }
|
|
273
|
+
: { editable: false, reason: "No active workflow is running for this issue." };
|
|
274
|
+
case "needs_human": {
|
|
275
|
+
const open = listHumanActionsForIssue(issue.id).filter((a) => a.status === "open");
|
|
276
|
+
// The operator-verification gate runs after approval routing: resolving it continues
|
|
277
|
+
// the already-committed merge path, so the policy is locked while it is open.
|
|
278
|
+
if (issue.currentIntent === OPERATOR_VERIFICATION_INTENT ||
|
|
279
|
+
open.some((a) => a.actionType === "operator_verification")) {
|
|
280
|
+
return {
|
|
281
|
+
editable: false,
|
|
282
|
+
reason: "The reviewer approved and the merge decision is committed — it waits only on operator verification.",
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
// Merge-failure parks, including the no-head operator-verification escalation.
|
|
286
|
+
if (open.some((a) => a.actionType === "policy_escalation" && isMergeFailureAction(a))) {
|
|
287
|
+
return {
|
|
288
|
+
editable: false,
|
|
289
|
+
reason: "The reviewer approved and the merge was already attempted — use the merge-failure action instead.",
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
return { editable: true, reason: null };
|
|
293
|
+
}
|
|
294
|
+
case "final_review":
|
|
295
|
+
return {
|
|
296
|
+
editable: false,
|
|
297
|
+
reason: issue.currentOwner === "system" && issue.currentIntent === AUTO_MERGE_INTENT
|
|
298
|
+
? "The reviewer approved and auto-merge is already in flight."
|
|
299
|
+
: "The reviewer approved and the merge decision is committed — use Merge on the final review action.",
|
|
300
|
+
};
|
|
301
|
+
case "done":
|
|
302
|
+
case "closed":
|
|
303
|
+
return { editable: false, reason: `The issue is ${issue.status}.` };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* NOT-490: an `autoMerge`-only update — the one input that may change during a live
|
|
308
|
+
* round, because nothing reads it until approved-review routing. Guard re-check, row
|
|
309
|
+
* write and audit run in one SQLite transaction, which serializes against the
|
|
310
|
+
* reviewer-completion transaction: an update committed first is the value
|
|
311
|
+
* `applyReviewer` routes on; a reviewer decision committed first moves the issue to
|
|
312
|
+
* `final_review` and this answers 409, so one approval can never yield both an
|
|
313
|
+
* auto-merge and a `final_review` action. Queue entry, frozen snapshot, agents,
|
|
314
|
+
* budgets, branch and PR are untouched (`updateIssue` writes only the policy column
|
|
315
|
+
* here). A real change appends one `issue.reassigned` event with before/after values
|
|
316
|
+
* and the actor; a no-op appends nothing.
|
|
317
|
+
*/
|
|
318
|
+
export function applyAutoMergePolicyUpdate(issueId, autoMerge, actor) {
|
|
319
|
+
return getDb().transaction(() => {
|
|
320
|
+
const fresh = getIssue(issueId);
|
|
321
|
+
if (!fresh)
|
|
322
|
+
return { ok: false, code: 404, error: "Not found" };
|
|
323
|
+
const editability = autoMergePolicyEditability(fresh);
|
|
324
|
+
if (!editability.editable) {
|
|
325
|
+
return { ok: false, code: 409, error: `Auto-merge can no longer be changed: ${editability.reason}` };
|
|
326
|
+
}
|
|
327
|
+
if (fresh.autoMerge === autoMerge)
|
|
328
|
+
return { ok: true, issue: fresh, changed: false };
|
|
329
|
+
const next = updateIssue(issueId, { autoMerge });
|
|
330
|
+
appendWorkflowEvent({
|
|
331
|
+
issueId,
|
|
332
|
+
workflowInstanceId: getActiveWorkflowInstance(issueId)?.id ?? null,
|
|
333
|
+
type: "issue.reassigned",
|
|
334
|
+
actorType: "human",
|
|
335
|
+
stage: next.status,
|
|
336
|
+
round: next.currentRound,
|
|
337
|
+
payload: { fromAutoMerge: fresh.autoMerge, toAutoMerge: next.autoMerge, actor },
|
|
338
|
+
});
|
|
339
|
+
return { ok: true, issue: next, changed: true };
|
|
340
|
+
})();
|
|
341
|
+
}
|
|
264
342
|
/**
|
|
265
343
|
* Stable identity-plus-bytes key for one attachment manifest entry. blobPath is
|
|
266
344
|
* excluded: it is content-addressed from the compared fields under an
|
|
@@ -42,6 +42,8 @@ function newIssue(opts = {}) {
|
|
|
42
42
|
baseBranch: "main",
|
|
43
43
|
maxReviewRounds: opts.maxReviewRounds ?? 3,
|
|
44
44
|
maxInfraAttempts: opts.maxInfraAttempts ?? 3,
|
|
45
|
+
// NOT-489: omitted now means auto-merge; this suite exercises final human review.
|
|
46
|
+
autoMerge: false,
|
|
45
47
|
source: "manual"
|
|
46
48
|
}).id;
|
|
47
49
|
}
|
|
@@ -89,6 +89,8 @@ async function makeIssue(opts = {}) {
|
|
|
89
89
|
reviewerAgentId: rev.id,
|
|
90
90
|
maxReviewRounds: 3,
|
|
91
91
|
maxInfraAttempts: opts.maxInfraAttempts ?? 3,
|
|
92
|
+
// NOT-489: omitted now means auto-merge; this suite exercises final human review.
|
|
93
|
+
autoMerge: false,
|
|
92
94
|
source: "manual"
|
|
93
95
|
}).id;
|
|
94
96
|
}
|
|
@@ -104,6 +104,8 @@ async function makeIssue(opts = {}) {
|
|
|
104
104
|
reviewerAgentId: rev.id,
|
|
105
105
|
maxReviewRounds: 3,
|
|
106
106
|
maxInfraAttempts: opts.maxInfraAttempts ?? 3,
|
|
107
|
+
// NOT-489: omitted now means auto-merge; this suite exercises final human review.
|
|
108
|
+
autoMerge: false,
|
|
107
109
|
source: "manual"
|
|
108
110
|
}).id;
|
|
109
111
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// NOT-489: a clean install's `issues.auto_merge` column defaults on, while an upgrade
|
|
2
|
+
// never rewrites an existing issue's stored choice — and new creates on the upgraded
|
|
3
|
+
// database still take the shared `CreateIssueInput` default (true), not the column's.
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import Database from "better-sqlite3";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-auto-merge-migrate-"));
|
|
12
|
+
const { migrate, getDb } = await import("./index.js");
|
|
13
|
+
const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
|
|
14
|
+
const { createIssue, getIssue } = await import("../repository/issues.js");
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const autoMergeDefault = (db) => db.prepare("PRAGMA table_info(issues)").all().find((c) => c.name === "auto_merge").dflt_value;
|
|
17
|
+
const storedAutoMerge = (id) => getDb().prepare("SELECT auto_merge FROM issues WHERE id = ?").get(id).auto_merge;
|
|
18
|
+
function newIssueId(autoMerge) {
|
|
19
|
+
return createIssue({
|
|
20
|
+
title: "T",
|
|
21
|
+
repo: "github.com/acme/app",
|
|
22
|
+
baseBranch: "main",
|
|
23
|
+
developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
24
|
+
reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
|
|
25
|
+
maxReviewRounds: 3,
|
|
26
|
+
maxInfraAttempts: 3,
|
|
27
|
+
source: "manual",
|
|
28
|
+
...(autoMerge === undefined ? {} : { autoMerge }),
|
|
29
|
+
}).id;
|
|
30
|
+
}
|
|
31
|
+
test("a clean schema defaults auto_merge on", () => {
|
|
32
|
+
const db = new Database(":memory:");
|
|
33
|
+
db.exec(fs.readFileSync(path.join(__dirname, "schema.sql"), "utf8"));
|
|
34
|
+
assert.equal(autoMergeDefault(db), "1");
|
|
35
|
+
});
|
|
36
|
+
test("upgrading preserves every stored auto_merge value; post-upgrade omitted-field creates still get true", () => {
|
|
37
|
+
migrate();
|
|
38
|
+
const optedOut = newIssueId(false);
|
|
39
|
+
const optedIn = newIssueId(true);
|
|
40
|
+
// An upgraded database whose column predates NOT-489 (still DEFAULT 0).
|
|
41
|
+
getDb().exec("ALTER TABLE issues DROP COLUMN auto_merge");
|
|
42
|
+
getDb().exec("ALTER TABLE issues ADD COLUMN auto_merge INTEGER NOT NULL DEFAULT 0");
|
|
43
|
+
getDb().prepare("UPDATE issues SET auto_merge = 1 WHERE id = ?").run(optedIn);
|
|
44
|
+
assert.equal(autoMergeDefault(getDb()), "0");
|
|
45
|
+
migrate();
|
|
46
|
+
assert.equal(storedAutoMerge(optedOut), 0, "a pre-existing false row is not rewritten");
|
|
47
|
+
assert.equal(getIssue(optedOut).autoMerge, false);
|
|
48
|
+
assert.equal(storedAutoMerge(optedIn), 1);
|
|
49
|
+
const fresh = newIssueId();
|
|
50
|
+
assert.equal(storedAutoMerge(fresh), 1, "an omitted-field create on the upgraded DB stores true");
|
|
51
|
+
assert.equal(getIssue(fresh).autoMerge, true);
|
|
52
|
+
});
|
|
@@ -264,8 +264,10 @@ export function migrate() {
|
|
|
264
264
|
if (!issueColsForCi.some((c) => c.name === "ci_attempts")) {
|
|
265
265
|
db.exec("ALTER TABLE issues ADD COLUMN ci_attempts INTEGER NOT NULL DEFAULT 0");
|
|
266
266
|
}
|
|
267
|
-
// NOT-102: per-issue auto-merge after reviewer approve
|
|
268
|
-
//
|
|
267
|
+
// NOT-102: per-issue auto-merge after reviewer approve. Rows that predate the column
|
|
268
|
+
// backfill off so in-flight workflows keep the human final_review path. NOT-489: new
|
|
269
|
+
// creates write the shared `CreateIssueInput` default (true) explicitly, so this
|
|
270
|
+
// backfill DEFAULT never decides a new issue's policy and stays 0 to preserve old rows.
|
|
269
271
|
const issueColsForAutoMerge = db.prepare("PRAGMA table_info(issues)").all();
|
|
270
272
|
if (!issueColsForAutoMerge.some((c) => c.name === "auto_merge")) {
|
|
271
273
|
db.exec("ALTER TABLE issues ADD COLUMN auto_merge INTEGER NOT NULL DEFAULT 0");
|
|
@@ -378,6 +380,14 @@ export function migrate() {
|
|
|
378
380
|
);
|
|
379
381
|
`);
|
|
380
382
|
}
|
|
383
|
+
// NOT-440: normalized cap metadata for capacity reconciliation.
|
|
384
|
+
const runtimeAvailCols = db.prepare("PRAGMA table_info(runtime_availability)").all();
|
|
385
|
+
if (!runtimeAvailCols.some((c) => c.name === "provider_reset_at")) {
|
|
386
|
+
db.exec("ALTER TABLE runtime_availability ADD COLUMN provider_reset_at TEXT");
|
|
387
|
+
}
|
|
388
|
+
if (!runtimeAvailCols.some((c) => c.name === "limit_type")) {
|
|
389
|
+
db.exec("ALTER TABLE runtime_availability ADD COLUMN limit_type TEXT");
|
|
390
|
+
}
|
|
381
391
|
// NOT-245: normalized capacity snapshots for databases created before
|
|
382
392
|
// schema.sql declared the table (same upgrade-path pattern as queue_entries).
|
|
383
393
|
const capacitySnapshots = db
|
|
@@ -121,7 +121,7 @@ CREATE TABLE IF NOT EXISTS issues (
|
|
|
121
121
|
head_sha TEXT,
|
|
122
122
|
pr_number INTEGER,
|
|
123
123
|
pr_url TEXT,
|
|
124
|
-
auto_merge INTEGER NOT NULL DEFAULT
|
|
124
|
+
auto_merge INTEGER NOT NULL DEFAULT 1,
|
|
125
125
|
created_at TEXT NOT NULL,
|
|
126
126
|
updated_at TEXT NOT NULL
|
|
127
127
|
);
|
|
@@ -473,7 +473,11 @@ CREATE TABLE IF NOT EXISTS runtime_availability (
|
|
|
473
473
|
unavailable_until TEXT NOT NULL,
|
|
474
474
|
reason TEXT NOT NULL,
|
|
475
475
|
evidence_json TEXT,
|
|
476
|
-
observed_at TEXT NOT NULL
|
|
476
|
+
observed_at TEXT NOT NULL,
|
|
477
|
+
-- NOT-440: provider-reported reset (null when unavailable_until is the
|
|
478
|
+
-- fallback cooldown) and structured provider limit type, if any.
|
|
479
|
+
provider_reset_at TEXT,
|
|
480
|
+
limit_type TEXT
|
|
477
481
|
);
|
|
478
482
|
|
|
479
483
|
-- NOT-171: append-only normalized failure-cause evidence. A derived view over
|
|
@@ -280,6 +280,8 @@ test("agent-operated CLI: discover profiles, create + start a Dev-review issue,
|
|
|
280
280
|
"Widget renders.",
|
|
281
281
|
"--base-branch",
|
|
282
282
|
"main",
|
|
283
|
+
// NOT-489: auto-merge is the default; this walk drives the final-review action.
|
|
284
|
+
"--no-auto-merge",
|
|
283
285
|
]));
|
|
284
286
|
const issueId = created.id;
|
|
285
287
|
assert.equal(created.status, "ready");
|