switchroom 0.15.0 → 0.15.1
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/dist/agent-scheduler/index.js +22 -1
- package/dist/auth-broker/index.js +36 -1
- package/dist/cli/drive-write-pretool.mjs +23 -2
- package/dist/cli/switchroom.js +39 -6
- package/package.json +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +9 -0
- package/telegram-plugin/auto-fallback-fleet.ts +59 -0
- package/telegram-plugin/dist/gateway/gateway.js +224 -19
- package/telegram-plugin/gateway/auth-broker-client.ts +2 -0
- package/telegram-plugin/gateway/auth-command.ts +35 -2
- package/telegram-plugin/gateway/gateway.ts +203 -18
- package/telegram-plugin/quota-watch.ts +141 -3
- package/telegram-plugin/tests/auth-quota-util-cell.test.ts +23 -0
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +71 -0
- package/telegram-plugin/tests/quota-watch.test.ts +266 -0
|
@@ -436,3 +436,269 @@ describe("evaluateFleetAllExhausted", () => {
|
|
|
436
436
|
if (d.kind === "notify") expect(d.message).toContain("Reset time unknown");
|
|
437
437
|
});
|
|
438
438
|
});
|
|
439
|
+
|
|
440
|
+
// ── hardening: tuning resolver + claim keys (2026-06-09 incident) ───────────
|
|
441
|
+
|
|
442
|
+
import {
|
|
443
|
+
resolveQuotaWatchTuning,
|
|
444
|
+
buildQuotaClaimKey,
|
|
445
|
+
DEFAULT_QUOTA_WATCH_MAX_STALE_MS,
|
|
446
|
+
DEFAULT_QUOTA_WATCH_LATE_RECOVERY_MS,
|
|
447
|
+
QUOTA_WATCH_CLAIM_WINDOW_MS,
|
|
448
|
+
type QuotaWatchDecision,
|
|
449
|
+
} from "../quota-watch.js";
|
|
450
|
+
|
|
451
|
+
describe("resolveQuotaWatchTuning", () => {
|
|
452
|
+
it("defaults: stale gate 60min, late-recovery 6h, dedup on, probe-fail send off", () => {
|
|
453
|
+
const t = resolveQuotaWatchTuning({});
|
|
454
|
+
expect(t.maxStaleMs).toBe(DEFAULT_QUOTA_WATCH_MAX_STALE_MS);
|
|
455
|
+
expect(t.lateRecoveryMs).toBe(DEFAULT_QUOTA_WATCH_LATE_RECOVERY_MS);
|
|
456
|
+
expect(t.fleetDedup).toBe(true);
|
|
457
|
+
expect(t.sendOnProbeFail).toBe(false);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("each knob is individually kill-switchable", () => {
|
|
461
|
+
const t = resolveQuotaWatchTuning({
|
|
462
|
+
SWITCHROOM_QUOTA_WATCH_MAX_STALE_MS: "0",
|
|
463
|
+
SWITCHROOM_QUOTA_WATCH_LATE_RECOVERY_MS: "0",
|
|
464
|
+
SWITCHROOM_QUOTA_WATCH_FLEET_DEDUP: "0",
|
|
465
|
+
SWITCHROOM_QUOTA_WATCH_SEND_ON_PROBE_FAIL: "1",
|
|
466
|
+
});
|
|
467
|
+
expect(t.maxStaleMs).toBe(0);
|
|
468
|
+
expect(t.lateRecoveryMs).toBe(0);
|
|
469
|
+
expect(t.fleetDedup).toBe(false);
|
|
470
|
+
expect(t.sendOnProbeFail).toBe(true);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("garbage numeric values fall back to defaults", () => {
|
|
474
|
+
const t = resolveQuotaWatchTuning({
|
|
475
|
+
SWITCHROOM_QUOTA_WATCH_MAX_STALE_MS: "banana",
|
|
476
|
+
SWITCHROOM_QUOTA_WATCH_LATE_RECOVERY_MS: "-5",
|
|
477
|
+
});
|
|
478
|
+
expect(t.maxStaleMs).toBe(DEFAULT_QUOTA_WATCH_MAX_STALE_MS);
|
|
479
|
+
expect(t.lateRecoveryMs).toBe(DEFAULT_QUOTA_WATCH_LATE_RECOVERY_MS);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("claim window exceeds one poll cycle (15 min) so all agents' observations of one edge dedup", () => {
|
|
483
|
+
expect(QUOTA_WATCH_CLAIM_WINDOW_MS).toBeGreaterThan(15 * 60_000);
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
describe("buildQuotaClaimKey", () => {
|
|
488
|
+
it("is namespaced and distinct per account, transition, and chat", () => {
|
|
489
|
+
const k1 = buildQuotaClaimKey("a@x.com", "entered-throttling", 111);
|
|
490
|
+
const k2 = buildQuotaClaimKey("a@x.com", "entered-throttling", 222);
|
|
491
|
+
const k3 = buildQuotaClaimKey("a@x.com", "recovered-to-healthy", 111);
|
|
492
|
+
const k4 = buildQuotaClaimKey("b@x.com", "entered-throttling", 111);
|
|
493
|
+
expect(new Set([k1, k2, k3, k4]).size).toBe(4);
|
|
494
|
+
expect(k1).toBe("quota-watch:a@x.com:entered-throttling:111");
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// ── hardening: staleness gate ────────────────────────────────────────────────
|
|
499
|
+
|
|
500
|
+
const TUNING = {
|
|
501
|
+
maxStaleMs: DEFAULT_QUOTA_WATCH_MAX_STALE_MS,
|
|
502
|
+
lateRecoveryMs: DEFAULT_QUOTA_WATCH_LATE_RECOVERY_MS,
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
describe("evaluateQuotaWatchAccount — staleness gate", () => {
|
|
506
|
+
it("a cached snapshot past maxStaleMs carries no opinion (skip, even on a real edge)", () => {
|
|
507
|
+
const staleHealthy: AccountSnapshot = {
|
|
508
|
+
...HEALTHY_SNAP,
|
|
509
|
+
capturedAtMs: NOW - DEFAULT_QUOTA_WATCH_MAX_STALE_MS - 1,
|
|
510
|
+
};
|
|
511
|
+
const d = evaluateQuotaWatchAccount({
|
|
512
|
+
agentName: "test", snap: staleHealthy, prev: PREV_WAS_THROTTLING, now: NOW, tuning: TUNING,
|
|
513
|
+
});
|
|
514
|
+
expect(d.kind).toBe("skip");
|
|
515
|
+
expect((d as { reason: string }).reason).toBe("stale-snapshot");
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("a cached snapshot inside the shelf life classifies normally", () => {
|
|
519
|
+
const freshEnough: AccountSnapshot = {
|
|
520
|
+
...THROTTLING_5H,
|
|
521
|
+
capturedAtMs: NOW - 5 * 60_000,
|
|
522
|
+
};
|
|
523
|
+
const d = evaluateQuotaWatchAccount({
|
|
524
|
+
agentName: "test", snap: freshEnough, prev: PREV_WAS_HEALTHY, now: NOW, tuning: TUNING,
|
|
525
|
+
});
|
|
526
|
+
expect(d.kind).toBe("notify");
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it("live-probe snapshots (no capturedAtMs) are never stale-gated", () => {
|
|
530
|
+
const d = evaluateQuotaWatchAccount({
|
|
531
|
+
agentName: "test", snap: THROTTLING_5H, prev: PREV_WAS_HEALTHY, now: NOW, tuning: TUNING,
|
|
532
|
+
});
|
|
533
|
+
expect(d.kind).toBe("notify");
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it("maxStaleMs=0 disables the gate (legacy behaviour)", () => {
|
|
537
|
+
const ancient: AccountSnapshot = { ...THROTTLING_5H, capturedAtMs: NOW - 86_400_000 };
|
|
538
|
+
const d = evaluateQuotaWatchAccount({
|
|
539
|
+
agentName: "test", snap: ancient, prev: PREV_WAS_HEALTHY, now: NOW,
|
|
540
|
+
tuning: { maxStaleMs: 0, lateRecoveryMs: 0 },
|
|
541
|
+
});
|
|
542
|
+
expect(d.kind).toBe("notify");
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
// ── hardening: boot-tick + late-recovery reconciliation ─────────────────────
|
|
547
|
+
|
|
548
|
+
describe("evaluateQuotaWatchAccount — recovery reconciliation", () => {
|
|
549
|
+
it("recovery on a boot tick reconciles silently (the fleet-bounce flood case)", () => {
|
|
550
|
+
const d = evaluateQuotaWatchAccount({
|
|
551
|
+
agentName: "test", snap: HEALTHY_SNAP, prev: PREV_WAS_THROTTLING, now: NOW,
|
|
552
|
+
bootTick: true, tuning: TUNING,
|
|
553
|
+
});
|
|
554
|
+
expect(d.kind).toBe("reconcile");
|
|
555
|
+
const r = d as Extract<QuotaWatchDecision, { kind: "reconcile" }>;
|
|
556
|
+
expect(r.reason).toBe("boot-tick-recovery");
|
|
557
|
+
expect(r.transition).toBe("recovered-to-healthy");
|
|
558
|
+
// The latch must clear so the edge never re-fires.
|
|
559
|
+
expect(r.newAccountState.lastNotifiedHealth).toBe("healthy");
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("recovery whose 🟡 warning is older than lateRecoveryMs reconciles silently", () => {
|
|
563
|
+
const prev = {
|
|
564
|
+
lastNotifiedHealth: "throttling" as const,
|
|
565
|
+
lastNotifiedAt: NOW - DEFAULT_QUOTA_WATCH_LATE_RECOVERY_MS - 1,
|
|
566
|
+
};
|
|
567
|
+
const d = evaluateQuotaWatchAccount({
|
|
568
|
+
agentName: "test", snap: HEALTHY_SNAP, prev, now: NOW, tuning: TUNING,
|
|
569
|
+
});
|
|
570
|
+
expect(d.kind).toBe("reconcile");
|
|
571
|
+
expect((d as Extract<QuotaWatchDecision, { kind: "reconcile" }>).reason).toBe("late-recovery");
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("a prompt recovery on a normal tick still notifies", () => {
|
|
575
|
+
const prev = { lastNotifiedHealth: "throttling" as const, lastNotifiedAt: NOW - 30 * 60_000 };
|
|
576
|
+
const d = evaluateQuotaWatchAccount({
|
|
577
|
+
agentName: "test", snap: HEALTHY_SNAP, prev, now: NOW, tuning: TUNING,
|
|
578
|
+
});
|
|
579
|
+
expect(d.kind).toBe("notify");
|
|
580
|
+
expect((d as Extract<QuotaWatchDecision, { kind: "notify" }>).transition).toBe("recovered-to-healthy");
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it("a 🟡 warning still notifies on a boot tick (warnings are level-state news)", () => {
|
|
584
|
+
const d = evaluateQuotaWatchAccount({
|
|
585
|
+
agentName: "test", snap: THROTTLING_5H, prev: PREV_WAS_HEALTHY, now: NOW,
|
|
586
|
+
bootTick: true, tuning: TUNING,
|
|
587
|
+
});
|
|
588
|
+
expect(d.kind).toBe("notify");
|
|
589
|
+
expect((d as Extract<QuotaWatchDecision, { kind: "notify" }>).transition).toBe("entered-throttling");
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it("lateRecoveryMs=0 disables reconciliation (legacy always-send)", () => {
|
|
593
|
+
const prev = { lastNotifiedHealth: "throttling" as const, lastNotifiedAt: NOW - 86_400_000 };
|
|
594
|
+
const d = evaluateQuotaWatchAccount({
|
|
595
|
+
agentName: "test", snap: HEALTHY_SNAP, prev, now: NOW,
|
|
596
|
+
tuning: { maxStaleMs: 0, lateRecoveryMs: 0 },
|
|
597
|
+
});
|
|
598
|
+
expect(d.kind).toBe("notify");
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it("omitting bootTick/tuning entirely preserves the pre-hardening table", () => {
|
|
602
|
+
const prev = { lastNotifiedHealth: "throttling" as const, lastNotifiedAt: NOW - 86_400_000 };
|
|
603
|
+
const d = evaluateQuotaWatchAccount({
|
|
604
|
+
agentName: "test", snap: HEALTHY_SNAP, prev, now: NOW,
|
|
605
|
+
});
|
|
606
|
+
expect(d.kind).toBe("notify");
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
// ── total enumeration: every (state × input) cell of the decision FSM ────────
|
|
611
|
+
//
|
|
612
|
+
// Operator standard (feedback_prove_finite_fsm_not_sample): the decision
|
|
613
|
+
// function is a finite table — prove it by enumerating EVERY cell, not by
|
|
614
|
+
// sampling. Dimensions:
|
|
615
|
+
// prevHealth ∈ {null, healthy, throttling}
|
|
616
|
+
// currentHealth ∈ {healthy, throttling, blocked, unknown}
|
|
617
|
+
// staleness ∈ {live (no capturedAtMs), fresh-cache, stale-cache}
|
|
618
|
+
// bootTick ∈ {false, true}
|
|
619
|
+
// warningAge ∈ {recent (< lateRecoveryMs), late (> lateRecoveryMs)}
|
|
620
|
+
// = 3 × 4 × 3 × 2 × 2 = 144 cells. The expected kind for every cell is
|
|
621
|
+
// computed from the documented table independently and asserted.
|
|
622
|
+
|
|
623
|
+
describe("evaluateQuotaWatchAccount — total enumeration (144 cells)", () => {
|
|
624
|
+
const PREVS = [null, "healthy", "throttling"] as const;
|
|
625
|
+
const CURRENTS = ["healthy", "throttling", "blocked", "unknown"] as const;
|
|
626
|
+
const STALENESS = ["live", "fresh-cache", "stale-cache"] as const;
|
|
627
|
+
const BOOLS = [false, true] as const;
|
|
628
|
+
const AGES = ["recent", "late"] as const;
|
|
629
|
+
|
|
630
|
+
const SNAPS: Record<(typeof CURRENTS)[number], QuotaUtilization | null> = {
|
|
631
|
+
healthy: makeQuota(30, 40),
|
|
632
|
+
throttling: makeQuota(85, 40),
|
|
633
|
+
blocked: makeQuota(99.9, 99.9),
|
|
634
|
+
unknown: null,
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
it("every cell matches the documented table", () => {
|
|
638
|
+
let cells = 0;
|
|
639
|
+
for (const prevHealth of PREVS) {
|
|
640
|
+
for (const currentHealth of CURRENTS) {
|
|
641
|
+
for (const staleness of STALENESS) {
|
|
642
|
+
for (const bootTick of BOOLS) {
|
|
643
|
+
for (const age of AGES) {
|
|
644
|
+
cells++;
|
|
645
|
+
const lastNotifiedAt =
|
|
646
|
+
prevHealth === null
|
|
647
|
+
? 0
|
|
648
|
+
: age === "late"
|
|
649
|
+
? NOW - DEFAULT_QUOTA_WATCH_LATE_RECOVERY_MS - 1
|
|
650
|
+
: NOW - 30 * 60_000;
|
|
651
|
+
const prev = { lastNotifiedHealth: prevHealth, lastNotifiedAt };
|
|
652
|
+
const snap: AccountSnapshot = {
|
|
653
|
+
label: "enum@example.com",
|
|
654
|
+
isActive: false,
|
|
655
|
+
quota: SNAPS[currentHealth],
|
|
656
|
+
capturedAtMs:
|
|
657
|
+
staleness === "live"
|
|
658
|
+
? undefined
|
|
659
|
+
: staleness === "fresh-cache"
|
|
660
|
+
? NOW - 60_000
|
|
661
|
+
: NOW - DEFAULT_QUOTA_WATCH_MAX_STALE_MS - 1,
|
|
662
|
+
};
|
|
663
|
+
const d = evaluateQuotaWatchAccount({
|
|
664
|
+
agentName: "enum", snap, prev, now: NOW, bootTick, tuning: TUNING,
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
// Independent expectation, straight from the documented table.
|
|
668
|
+
let expected: "notify" | "reconcile" | "skip";
|
|
669
|
+
const effPrev = prevHealth ?? "healthy";
|
|
670
|
+
if (staleness === "stale-cache") {
|
|
671
|
+
expected = "skip"; // gate fires before everything else
|
|
672
|
+
} else if (currentHealth === "blocked" || currentHealth === "unknown") {
|
|
673
|
+
expected = "skip"; // credits-watch domain / no data
|
|
674
|
+
} else if (currentHealth === effPrev) {
|
|
675
|
+
expected = "skip"; // steady-state
|
|
676
|
+
} else if (currentHealth === "throttling") {
|
|
677
|
+
expected = "notify"; // warnings always notify
|
|
678
|
+
} else {
|
|
679
|
+
// recovery: throttling → healthy
|
|
680
|
+
expected = bootTick || age === "late" ? "reconcile" : "notify";
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
expect(
|
|
684
|
+
d.kind,
|
|
685
|
+
`cell prev=${prevHealth} cur=${currentHealth} stale=${staleness} boot=${bootTick} age=${age}`,
|
|
686
|
+
).toBe(expected);
|
|
687
|
+
|
|
688
|
+
// Terminal-state invariant: any notify/reconcile lands the
|
|
689
|
+
// latch on currentHealth, so re-running the SAME cell with the
|
|
690
|
+
// updated state is always a skip (the FSM cannot loop).
|
|
691
|
+
if (d.kind === "notify" || d.kind === "reconcile") {
|
|
692
|
+
const again = evaluateQuotaWatchAccount({
|
|
693
|
+
agentName: "enum", snap, prev: d.newAccountState, now: NOW, bootTick, tuning: TUNING,
|
|
694
|
+
});
|
|
695
|
+
expect(again.kind, `re-run of cell prev=${prevHealth} cur=${currentHealth}`).toBe("skip");
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
expect(cells).toBe(144);
|
|
703
|
+
});
|
|
704
|
+
});
|