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.
Files changed (38) hide show
  1. package/bundle/server/dist/adapters/agent-health.test.js +52 -0
  2. package/bundle/server/dist/capacity/service.js +66 -6
  3. package/bundle/server/dist/capacity/service.test.js +220 -0
  4. package/bundle/server/dist/coordinator/commands.js +79 -1
  5. package/bundle/server/dist/coordinator/commands.test.js +2 -0
  6. package/bundle/server/dist/coordinator/developer-effect.test.js +2 -0
  7. package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +2 -0
  8. package/bundle/server/dist/coordinator/reviewer-effect.test.js +2 -0
  9. package/bundle/server/dist/coordinator/worker-loop.test.js +2 -0
  10. package/bundle/server/dist/db/auto-merge-default-migration.test.js +52 -0
  11. package/bundle/server/dist/db/index.js +12 -2
  12. package/bundle/server/dist/db/schema.sql +6 -2
  13. package/bundle/server/dist/dev-review-cli-happy-path.integration.test.js +2 -0
  14. package/bundle/server/dist/repository/runtime-availability.js +29 -11
  15. package/bundle/server/dist/routes/auto-merge-policy.test.js +378 -0
  16. package/bundle/server/dist/routes/human-actions.test.js +4 -4
  17. package/bundle/server/dist/routes/issues-execution-contract.test.js +4 -3
  18. package/bundle/server/dist/routes/issues.js +32 -2
  19. package/bundle/server/dist/routes/issues.test.js +25 -2
  20. package/bundle/server/dist/runners/usage-cap.js +66 -15
  21. package/bundle/server/dist/runners/usage-cap.test.js +96 -2
  22. package/bundle/server/package.json +2 -2
  23. package/bundle/server/static-ui/assets/index-CHCiztRI.css +1 -0
  24. package/bundle/server/static-ui/assets/index-CrLQFFpw.js +64 -0
  25. package/bundle/server/static-ui/index.html +2 -2
  26. package/bundle/shared/dist/issues.d.ts +8 -5
  27. package/bundle/shared/dist/issues.js +7 -4
  28. package/bundle/shared/dist/issues.test.js +11 -1
  29. package/bundle/shared/dist/runtime-capacity.d.ts +85 -0
  30. package/bundle/shared/dist/runtime-capacity.js +19 -0
  31. package/bundle/shared/package.json +1 -1
  32. package/dist/index.js +1 -1
  33. package/dist/issue.d.ts +1 -0
  34. package/dist/issue.js +3 -2
  35. package/dist/issue.test.js +37 -0
  36. package/package.json +1 -1
  37. package/bundle/server/static-ui/assets/index-DL8mEXzN.css +0 -1
  38. 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, hard-cap availability, and remaining capacity stay
9
- // separate states: this service never reads `runtime_availability` and its
10
- // output never implies health. Only normalized snapshots leave this module —
11
- // evidence refs and raw provider payloads stay server-side.
8
+ // Connection health and remaining capacity stay separate states: output never
9
+ // implies health. Only normalized snapshots leave this module — evidence refs
10
+ // and raw provider payloads stay server-side.
11
+ //
12
+ // NOT-440: an active hard cap (the same `runtime_availability` row that blocks
13
+ // admission) rides alongside the windows as `activeCap`; stored snapshots are
14
+ // never rewritten, and the overlay disappears with the row's lazy cleanup.
12
15
  import { isWindowKnown, Runtime as RuntimeSchema } from "@agent-dealer/shared";
13
16
  import { getDb } from "../db/index.js";
17
+ import { activeRuntimeCap } from "../repository/runtime-availability.js";
14
18
  import { listAllCapacitySnapshots, recordCapacitySnapshots, } from "../repository/runtime-capacity.js";
15
19
  import { normalizeAdapterWindow, normalizeUnavailableWindow, } from "./adapter.js";
16
20
  /** Distinct runtimes across Agent profiles — the configured runtime accounts. */
@@ -83,6 +87,61 @@ function classifyWindow(w, nowMs) {
83
87
  known: false,
84
88
  };
85
89
  }
90
+ const PAIR_RUNTIMES = new Set(["claude_code", "codex_local", "muse_code"]);
91
+ const CURSOR_BILLING_WINDOW_KEY = "billing_cycle";
92
+ /** Structured provider limit types that name a compact critical window. */
93
+ const LIMIT_TYPE_ROLE = {
94
+ five_hour: "five_hour",
95
+ seven_day: "weekly",
96
+ };
97
+ /** A cap reset and a snapshot reset within this distance name the same boundary. */
98
+ const RESET_MATCH_TOLERANCE_MS = 60_000;
99
+ /** The compact role a stored window presents under, or null when it never renders compactly. */
100
+ function compactRole(runtime, w) {
101
+ if (runtime === "cursor_local")
102
+ return w.windowKey === CURSOR_BILLING_WINDOW_KEY ? "billing_cycle" : null;
103
+ if (PAIR_RUNTIMES.has(runtime))
104
+ return w.criticalRole;
105
+ return null;
106
+ }
107
+ /**
108
+ * Which compact window an active cap exhausts: structured limit metadata
109
+ * first (a reported non-compact limit stays unmapped), then a tightly bounded
110
+ * match between the provider reset and exactly one tagged snapshot reset.
111
+ * A fallback-cooldown cap (no provider reset) is never matched.
112
+ */
113
+ export function resolveCapWindowRole(runtime, cap, windows) {
114
+ if (cap.limitType !== null) {
115
+ const role = LIMIT_TYPE_ROLE[cap.limitType] ?? null;
116
+ return role !== null && PAIR_RUNTIMES.has(runtime) ? role : null;
117
+ }
118
+ if (cap.providerResetAt === null)
119
+ return null;
120
+ const capResetMs = Date.parse(cap.providerResetAt);
121
+ if (!Number.isFinite(capResetMs))
122
+ return null;
123
+ const matched = new Set();
124
+ for (const w of windows) {
125
+ const role = compactRole(runtime, w);
126
+ if (role === null || w.resetAt === null)
127
+ continue;
128
+ const resetMs = Date.parse(w.resetAt);
129
+ if (Number.isFinite(resetMs) && Math.abs(resetMs - capResetMs) <= RESET_MATCH_TOLERANCE_MS) {
130
+ matched.add(role);
131
+ }
132
+ }
133
+ return matched.size === 1 ? [...matched][0] : null;
134
+ }
135
+ function activeCapFor(runtime, windows, nowMs) {
136
+ const cap = activeRuntimeCap(runtime, nowMs);
137
+ if (cap === null)
138
+ return null;
139
+ return {
140
+ unavailableUntil: cap.unavailableUntil,
141
+ reason: cap.reason,
142
+ windowRole: resolveCapWindowRole(runtime, cap, windows),
143
+ };
144
+ }
86
145
  /**
87
146
  * Read model for `GET /api/runtime-capacity`: every configured runtime, its
88
147
  * windows with freshness applied, and an explicit unavailable reason whenever
@@ -103,14 +162,15 @@ export function getRuntimeCapacitySnapshot(nowMs = Date.now()) {
103
162
  const entries = [...runtimes]
104
163
  .sort()
105
164
  .map((runtime) => {
106
- const windows = (byRuntime.get(runtime) ?? []).map((w) => classifyWindow(w, nowMs).snapshot);
165
+ const raw = byRuntime.get(runtime) ?? [];
166
+ const windows = raw.map((w) => classifyWindow(w, nowMs).snapshot);
107
167
  const knownCount = windows.filter((w) => w.unavailableReason === null).length;
108
168
  let unavailableReason = null;
109
169
  if (knownCount === 0) {
110
170
  const first = windows.find((w) => w.unavailableReason)?.unavailableReason;
111
171
  unavailableReason = first ?? "missing";
112
172
  }
113
- return { runtime, windows, unavailableReason };
173
+ return { runtime, windows, unavailableReason, activeCap: activeCapFor(runtime, raw, nowMs) };
114
174
  });
115
175
  return { runtimes: entries, generatedAt: new Date(nowMs).toISOString() };
116
176
  }
@@ -219,3 +219,223 @@ test("a past reset time is never presented as current capacity", async () => {
219
219
  assert.equal(muse.windows[0].unavailableReason, "expired");
220
220
  assert.equal(muse.unavailableReason, "expired");
221
221
  });
222
+ // NOT-440: active hard caps overlay the compact read model without rewriting
223
+ // stored provider observations.
224
+ const NOT440_NOW = Date.parse("2026-10-12T18:20:00.000Z");
225
+ const NOT440_RESET = "2026-10-12T18:59:01.000Z";
226
+ const NOT440_WEEKLY_RESET = "2026-10-16T02:00:00.000Z";
227
+ async function seedPair(runtime, windows) {
228
+ const { recordCapacitySnapshots } = await import("../repository/runtime-capacity.js");
229
+ recordCapacitySnapshots(runtime, windows.map((w) => ({
230
+ windowKey: w.windowKey,
231
+ providerBucket: "all_models",
232
+ durationMinutes: w.durationMinutes ?? (w.criticalRole === "weekly" ? 10080 : 300),
233
+ displayLabel: w.criticalRole === "weekly" ? "1W" : "5H",
234
+ usedValue: 100 - w.remainingPercent,
235
+ usedUnit: "percent",
236
+ remainingPercent: w.remainingPercent,
237
+ resetAt: w.resetAt,
238
+ observedAt: "2026-10-12T18:03:00.000Z",
239
+ freshUntil: "2026-10-12T19:03:00.000Z",
240
+ expiresAt: "2026-10-12T19:03:00.000Z",
241
+ source: "observed_event",
242
+ unavailableReason: null,
243
+ criticalRole: w.criticalRole,
244
+ })));
245
+ }
246
+ async function seedReproducedMuse() {
247
+ await seedPair("muse_code", [
248
+ { windowKey: "five_hour", criticalRole: "five_hour", remainingPercent: 50, resetAt: NOT440_RESET },
249
+ { windowKey: "weekly", criticalRole: "weekly", remainingPercent: 52, resetAt: NOT440_WEEKLY_RESET },
250
+ ]);
251
+ }
252
+ function storedMuseRemaining() {
253
+ const rows = getDb()
254
+ .prepare("SELECT window_key, remaining_percent FROM runtime_capacity_snapshots WHERE runtime = 'muse_code'")
255
+ .all();
256
+ return Object.fromEntries(rows.map((r) => [r.window_key, r.remaining_percent]));
257
+ }
258
+ const { getDb } = await import("../db/index.js");
259
+ const { clearAllRuntimeAvailability, recordRuntimeAvailability, runtimeAvailability } = await import("../repository/runtime-availability.js");
260
+ const { recordMuseUsageCap } = await import("../runners/usage-cap.js");
261
+ const { resolveCapWindowRole } = await import("./service.js");
262
+ test("NOT-440: reproduced Muse cap maps to 5H without modifying stored snapshots", async () => {
263
+ clearAllCapacitySnapshots();
264
+ clearAllRuntimeAvailability();
265
+ await seedReproducedMuse();
266
+ assert.deepEqual(storedMuseRemaining(), { five_hour: 50, weekly: 52 });
267
+ recordMuseUsageCap({ kind: "usage_cap", message: `Subscription quota exhausted. Usage resets at 2026-10-12T18:59:01Z.` }, Date.parse("2026-10-12T18:16:00.000Z"));
268
+ const snap = getRuntimeCapacitySnapshot(NOT440_NOW);
269
+ const muse = snap.runtimes.find((r) => r.runtime === "muse_code");
270
+ assert.deepEqual(muse.activeCap, {
271
+ unavailableUntil: NOT440_RESET,
272
+ reason: muse.activeCap.reason,
273
+ windowRole: "five_hour",
274
+ });
275
+ assert.match(muse.activeCap.reason, /muse_code usage capped/);
276
+ // Windows remain the raw provider observation; the overlay is activeCap.
277
+ assert.equal(muse.windows.find((w) => w.criticalRole === "five_hour").remainingPercent, 50);
278
+ assert.equal(muse.windows.find((w) => w.criticalRole === "weekly").remainingPercent, 52);
279
+ assert.deepEqual(storedMuseRemaining(), { five_hour: 50, weekly: 52 });
280
+ // Admission/health read the same row.
281
+ assert.deepEqual(runtimeAvailability("muse_code", NOT440_NOW), {
282
+ available: false,
283
+ until: NOT440_RESET,
284
+ reason: muse.activeCap.reason,
285
+ });
286
+ });
287
+ test("NOT-440: overlay is absent at and after unavailableUntil; history is kept", async () => {
288
+ for (const offset of [0, 1]) {
289
+ clearAllCapacitySnapshots();
290
+ clearAllRuntimeAvailability();
291
+ await seedReproducedMuse();
292
+ recordMuseUsageCap({ kind: "usage_cap", message: `Subscription quota exhausted. Usage resets at 2026-10-12T18:59:01Z.` }, Date.parse("2026-10-12T18:16:00.000Z"));
293
+ const atMs = Date.parse(NOT440_RESET) + offset;
294
+ const snap = getRuntimeCapacitySnapshot(atMs);
295
+ const muse = snap.runtimes.find((r) => r.runtime === "muse_code");
296
+ assert.equal(muse.activeCap, null, `offset ${offset}ms`);
297
+ // Normal classification resumes: the 5H reset has passed, 1W is still current.
298
+ const fiveHour = muse.windows.find((w) => w.criticalRole === "five_hour");
299
+ assert.equal(fiveHour.remainingPercent, null);
300
+ assert.equal(fiveHour.unavailableReason, "expired");
301
+ assert.equal(muse.windows.find((w) => w.criticalRole === "weekly").remainingPercent, 52);
302
+ assert.deepEqual(storedMuseRemaining(), { five_hour: 50, weekly: 52 });
303
+ const capRows = getDb()
304
+ .prepare("SELECT COUNT(*) AS n FROM runtime_availability WHERE runtime = 'muse_code'")
305
+ .get();
306
+ assert.equal(capRows.n, 0, "lazy cleanup cleared the expired cap row");
307
+ assert.deepEqual(runtimeAvailability("muse_code", atMs), { available: true });
308
+ }
309
+ });
310
+ test("NOT-440: a cap with no provider reset or role stays unmapped (explicit capped)", async () => {
311
+ clearAllCapacitySnapshots();
312
+ clearAllRuntimeAvailability();
313
+ await seedReproducedMuse();
314
+ // Fallback cooldown that happens to land on the 5H reset is still not a provider boundary.
315
+ recordRuntimeAvailability({
316
+ runtime: "muse_code",
317
+ unavailableUntil: NOT440_RESET,
318
+ reason: "muse_code usage capped — usage cap",
319
+ });
320
+ const muse = getRuntimeCapacitySnapshot(NOT440_NOW).runtimes.find((r) => r.runtime === "muse_code");
321
+ assert.equal(muse.activeCap.windowRole, null);
322
+ assert.equal(muse.activeCap.unavailableUntil, NOT440_RESET);
323
+ assert.deepEqual(storedMuseRemaining(), { five_hour: 50, weekly: 52 });
324
+ });
325
+ const tagged = (windowKey, criticalRole, resetAt) => ({
326
+ windowKey,
327
+ providerBucket: "all_models",
328
+ durationMinutes: 300,
329
+ displayLabel: "5H",
330
+ usedValue: 50,
331
+ usedUnit: "percent",
332
+ remainingPercent: 50,
333
+ resetAt,
334
+ observedAt: "2026-10-12T18:03:00.000Z",
335
+ freshUntil: "2026-10-12T19:03:00.000Z",
336
+ expiresAt: "2026-10-12T19:03:00.000Z",
337
+ source: "observed_event",
338
+ unavailableReason: null,
339
+ criticalRole,
340
+ });
341
+ test("NOT-440: cap window role resolution per runtime (metadata first, then reset match)", () => {
342
+ const pair = [tagged("five_hour", "five_hour", NOT440_RESET), tagged("weekly", "weekly", NOT440_WEEKLY_RESET)];
343
+ const cases = [
344
+ {
345
+ name: "Claude five_hour metadata wins even without a reset match",
346
+ runtime: "claude_code",
347
+ cap: { providerResetAt: "2026-10-12T20:00:00.000Z", limitType: "five_hour" },
348
+ windows: pair,
349
+ expected: "five_hour",
350
+ },
351
+ {
352
+ name: "Claude seven_day metadata maps to weekly",
353
+ runtime: "claude_code",
354
+ cap: { providerResetAt: null, limitType: "seven_day" },
355
+ windows: pair,
356
+ expected: "weekly",
357
+ },
358
+ {
359
+ name: "Claude model-specific limit stays unmapped despite a matching weekly reset",
360
+ runtime: "claude_code",
361
+ cap: { providerResetAt: NOT440_WEEKLY_RESET, limitType: "seven_day_sonnet" },
362
+ windows: pair,
363
+ expected: null,
364
+ },
365
+ {
366
+ name: "Codex reset match on the weekly window",
367
+ runtime: "codex_local",
368
+ cap: { providerResetAt: NOT440_WEEKLY_RESET, limitType: null },
369
+ windows: pair,
370
+ expected: "weekly",
371
+ },
372
+ {
373
+ name: "Muse reset match on the 5H window (within tolerance)",
374
+ runtime: "muse_code",
375
+ cap: { providerResetAt: "2026-10-12T18:59:30.000Z", limitType: null },
376
+ windows: pair,
377
+ expected: "five_hour",
378
+ },
379
+ {
380
+ name: "Muse reset outside tolerance stays unmapped",
381
+ runtime: "muse_code",
382
+ cap: { providerResetAt: "2026-10-12T19:05:00.000Z", limitType: null },
383
+ windows: pair,
384
+ expected: null,
385
+ },
386
+ {
387
+ name: "Cursor reset match on the billing cycle",
388
+ runtime: "cursor_local",
389
+ cap: { providerResetAt: "2026-11-01T00:00:00.000Z", limitType: null },
390
+ windows: [tagged("billing_cycle", null, "2026-11-01T00:00:00.000Z")],
391
+ expected: "billing_cycle",
392
+ },
393
+ {
394
+ name: "Untagged diagnostic window with a matching reset never maps",
395
+ runtime: "muse_code",
396
+ cap: { providerResetAt: "2026-10-13T00:00:00.000Z", limitType: null },
397
+ windows: [...pair, tagged("muse_account_usage", null, "2026-10-13T00:00:00.000Z")],
398
+ expected: null,
399
+ },
400
+ {
401
+ name: "Fallback cooldown (no provider reset) never maps",
402
+ runtime: "codex_local",
403
+ cap: { providerResetAt: null, limitType: null },
404
+ windows: pair,
405
+ expected: null,
406
+ },
407
+ {
408
+ name: "Ambiguous reset matching both pair windows stays unmapped",
409
+ runtime: "codex_local",
410
+ cap: { providerResetAt: NOT440_RESET, limitType: null },
411
+ windows: [tagged("five_hour", "five_hour", NOT440_RESET), tagged("weekly", "weekly", NOT440_RESET)],
412
+ expected: null,
413
+ },
414
+ ];
415
+ for (const c of cases) {
416
+ assert.equal(resolveCapWindowRole(c.runtime, c.cap, c.windows), c.expected, c.name);
417
+ }
418
+ });
419
+ test("NOT-440: Claude structured rate_limit_event persists limit type and maps through the read model", async () => {
420
+ clearAllCapacitySnapshots();
421
+ clearAllRuntimeAvailability();
422
+ await seedPair("claude_code", [
423
+ { windowKey: "five_hour", criticalRole: "five_hour", remainingPercent: 40, resetAt: NOT440_RESET },
424
+ { windowKey: "weekly", criticalRole: "weekly", remainingPercent: 70, resetAt: NOT440_WEEKLY_RESET },
425
+ ]);
426
+ const { recordUsageCapFromEvents } = await import("../runners/usage-cap.js");
427
+ recordUsageCapFromEvents([
428
+ {
429
+ type: "rate_limit_event",
430
+ rate_limit_info: {
431
+ status: "rejected",
432
+ rateLimitType: "seven_day",
433
+ resetsAt: Math.floor(Date.parse(NOT440_WEEKLY_RESET) / 1000),
434
+ },
435
+ },
436
+ ], "claude_code", NOT440_NOW);
437
+ const claude = getRuntimeCapacitySnapshot(NOT440_NOW).runtimes.find((r) => r.runtime === "claude_code");
438
+ assert.equal(claude.activeCap.windowRole, "weekly");
439
+ assert.equal(claude.activeCap.unavailableUntil, NOT440_WEEKLY_RESET);
440
+ clearAllRuntimeAvailability();
441
+ });
@@ -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
  }
@@ -84,6 +84,8 @@ async function makeIssue() {
84
84
  reviewerAgentId: rev.id,
85
85
  maxReviewRounds: 3,
86
86
  maxInfraAttempts: 3,
87
+ // NOT-489: omitted now means auto-merge; this suite exercises final human review.
88
+ autoMerge: false,
87
89
  source: "manual"
88
90
  }).id;
89
91
  }
@@ -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
  }
@@ -51,6 +51,8 @@ function newIssue(maxReviewRounds = 3) {
51
51
  baseBranch: "main",
52
52
  maxReviewRounds,
53
53
  maxInfraAttempts: 3,
54
+ // NOT-489: omitted now means auto-merge; this suite exercises final human review.
55
+ autoMerge: false,
54
56
  source: "manual"
55
57
  }).id;
56
58
  }
@@ -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 (kick UI defaults on; existing
268
- // rows stay off so in-flight workflows keep the human final_review path).
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 0,
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");