switchroom 0.19.27 → 0.19.29
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 +16 -2
- package/dist/auth-broker/index.js +141 -8
- package/dist/cli/autoaccept-poll.js +225 -17
- package/dist/cli/notion-write-pretool.mjs +18 -2
- package/dist/cli/switchroom.js +864 -43
- package/dist/host-control/main.js +144 -9
- package/dist/vault/approvals/kernel-server.js +146 -9
- package/dist/vault/broker/server.js +146 -9
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +79 -15
- package/telegram-plugin/dist/bridge/bridge.js +1 -0
- package/telegram-plugin/dist/gateway/gateway.js +585 -50
- package/telegram-plugin/dist/server.js +1 -0
- package/telegram-plugin/edit-flood-fuse.ts +230 -27
- package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
- package/telegram-plugin/gateway/gateway.ts +9 -2
- package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
- package/telegram-plugin/mcp-credential-failure.ts +459 -0
- package/telegram-plugin/operator-events.ts +38 -0
- package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +58 -1
- package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
- package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
- package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
- package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
- package/vendor/hindsight-memory/CHANGELOG.md +50 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +1 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +437 -11
- package/vendor/hindsight-memory/scripts/lib/client.py +14 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +91 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +199 -28
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +1 -0
- package/vendor/hindsight-memory/scripts/retain.py +41 -1
- package/vendor/hindsight-memory/scripts/subagent_retain.py +1 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill.py +23 -1
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +5 -1
- package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
- package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
- package/vendor/hindsight-memory/scripts/tests/test_observation_scopes.py +325 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +160 -1
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +40 -2
- package/vendor/hindsight-memory/settings.json +1 -1
- package/vendor/hindsight-memory/tests/test_hooks.py +11 -2
|
@@ -17267,6 +17267,7 @@ var init_operator_events = __esm(() => {
|
|
|
17267
17267
|
"credentials-invalid",
|
|
17268
17268
|
"credit-exhausted",
|
|
17269
17269
|
"provider-credit-exhausted",
|
|
17270
|
+
"mcp-dependency-blocked",
|
|
17270
17271
|
"proxy-misconfig"
|
|
17271
17272
|
]);
|
|
17272
17273
|
});
|
|
@@ -165,8 +165,46 @@
|
|
|
165
165
|
* `maxTightenLevel`) and decays one level at a time, so sustained pressure
|
|
166
166
|
* ratchets the cosmetic rate down towards the floor of 1/window.
|
|
167
167
|
*
|
|
168
|
+
* ── 2026-07-28: the reserve could not fire, and the reply paid (#3885) ────
|
|
169
|
+
* Points 3 and 5 above, and the escalating backoff, combined into a defect
|
|
170
|
+
* that hit the operator directly: during a 12-agent staggered restart that drew
|
|
171
|
+
* **three** genuine 429s, one chat logged 792 fuse events in an hour against a
|
|
172
|
+
* 35-50/hr baseline, shed 221 typing indicators, deferred 18 reactions, and
|
|
173
|
+
* deferred real replies long enough that the MCP `reply` tool's 60s timeout
|
|
174
|
+
* fired — so the agent retried and the operator got the same answer twice. One
|
|
175
|
+
* ~1900-character reply was lost outright.
|
|
176
|
+
*
|
|
177
|
+
* Three 3-second 429s ratchet `tightenLevel` to 3, and each one also writes
|
|
178
|
+
* `flood-wait.json`, which pinned `levelAt` at `maxTightenLevel` outright. At
|
|
179
|
+
* level 4 the shared per-chat budget is `max(1, floor(20 * 0.5^4))` = **1 call
|
|
180
|
+
* per minute for the entire chat**. The reserve that exists for precisely this
|
|
181
|
+
* case could not help, because it was a fixed count subtracted from the BASE:
|
|
182
|
+
* `max(1, 20 - 8)` = 12, tightened to 1 — the same 1 the reply had. Reply and
|
|
183
|
+
* repaint ended up with an identical budget, which is the opposite of a
|
|
184
|
+
* reservation. Three changes:
|
|
185
|
+
*
|
|
186
|
+
* 6. **The reserve is a PROPORTION, re-derived per window.** `cosmeticTotalMax`
|
|
187
|
+
* computes it against the EFFECTIVE ceiling, so "8 of 20" means "40% of
|
|
188
|
+
* whatever the budget currently is", and at least one slot is always
|
|
189
|
+
* reserved. When the effective budget shrinks to the reserve, cosmetic
|
|
190
|
+
* traffic gets 0 and every remaining slot belongs to a reply.
|
|
191
|
+
* 7. **A hard floor under `critical`.** `perChatCriticalMinPerWindow`
|
|
192
|
+
* (default 3) is a rate no tightening may cross, on every per-chat tier.
|
|
193
|
+
* Shedding a typing indicator under pressure is correct; holding the
|
|
194
|
+
* answer past the caller's own timeout is not — it does not save a call,
|
|
195
|
+
* it doubles it.
|
|
196
|
+
* 8. **A persisted flood window tightens IN PROPORTION to what remains**,
|
|
197
|
+
* through the same severity ladder `noteFlood` uses, instead of jumping
|
|
198
|
+
* straight to `maxTightenLevel`. `makeFloodWaitRecorder` writes the marker
|
|
199
|
+
* for every 429 including a 3-second nudge, so the flat jump reintroduced
|
|
200
|
+
* on the persisted path exactly the "a nudge and a 4.4h ban are the same
|
|
201
|
+
* signal" defect that #3856 had just fixed on the in-memory path.
|
|
202
|
+
*
|
|
168
203
|
* Every ceiling is operator-overridable via env — see
|
|
169
|
-
* {@link editFloodFuseConfigFromEnv}. Previously none of them were
|
|
204
|
+
* {@link editFloodFuseConfigFromEnv}. Previously none of them were, and until
|
|
205
|
+
* #3885 the tightening CURVE (`maxTightenLevel`, `tightenFactor`) still was
|
|
206
|
+
* not, which is why the only available workaround for the above was to inflate
|
|
207
|
+
* the base ceiling fleet-wide.
|
|
170
208
|
*/
|
|
171
209
|
|
|
172
210
|
import { createHash } from 'node:crypto'
|
|
@@ -302,8 +340,29 @@ export interface EditFloodFuseConfig {
|
|
|
302
340
|
* consume. Default 8. This is the reply-starvation guarantee: whatever the
|
|
303
341
|
* repaint surfaces do, at least this many calls per window remain available
|
|
304
342
|
* to an actual answer.
|
|
343
|
+
*
|
|
344
|
+
* Read as a PROPORTION of the base, not an absolute count (#3885). The
|
|
345
|
+
* reserve used to be a fixed number subtracted from the base ceiling, which
|
|
346
|
+
* made it arithmetically dead under tightening: at `maxTightenLevel` the base
|
|
347
|
+
* 20 collapses to an effective 1, and `1 - 8` clamps to the same floor of 1
|
|
348
|
+
* that cosmetic traffic already had — so reply and repaint ended up with an
|
|
349
|
+
* identical budget in exactly the situation the reserve exists for. It is now
|
|
350
|
+
* re-derived against the EFFECTIVE ceiling every window, and always leaves at
|
|
351
|
+
* least one slot reserved.
|
|
305
352
|
*/
|
|
306
353
|
perChatReplyReserve?: number
|
|
354
|
+
/**
|
|
355
|
+
* Absolute floor, per `perChatWindowMs`, on `critical`-class traffic in one
|
|
356
|
+
* chat — the operator's actual answer, an approval card, a reaction. Default
|
|
357
|
+
* 3. No amount of tightening may take a chat below this: a reply held past
|
|
358
|
+
* the caller's own timeout is not "shedding under pressure", it is a lost
|
|
359
|
+
* answer plus a retry that sends it twice (#3885). Cosmetic traffic has no
|
|
360
|
+
* floor and is still shed to zero, which is the correct trade.
|
|
361
|
+
*
|
|
362
|
+
* Capped by the tier's own base ceiling, so an operator who deliberately
|
|
363
|
+
* configures a base below this floor still gets what they configured.
|
|
364
|
+
*/
|
|
365
|
+
perChatCriticalMinPerWindow?: number
|
|
307
366
|
/**
|
|
308
367
|
* Ceiling on EVERY outbound call made with this bot token, across all chats
|
|
309
368
|
* and including the chat-less ones. Default 25 per `perTokenWindowMs`
|
|
@@ -340,7 +399,11 @@ export interface EditFloodFuseConfig {
|
|
|
340
399
|
tightenFactor?: number
|
|
341
400
|
/** How long ONE level of tightening stays in force before decaying. Default 10 minutes. */
|
|
342
401
|
tightenMs?: number
|
|
343
|
-
/**
|
|
402
|
+
/**
|
|
403
|
+
* Cap on compounding 429 tightening levels. Default 4 (⇒ 0.5^4 = 1/16).
|
|
404
|
+
* Operator-overridable via `SWITCHROOM_EDIT_FUSE_MAX_TIGHTEN_LEVEL` (#3885);
|
|
405
|
+
* `0` disables tightening entirely.
|
|
406
|
+
*/
|
|
344
407
|
maxTightenLevel?: number
|
|
345
408
|
/**
|
|
346
409
|
* Remaining ms of a KNOWN-OPEN Telegram flood window, or 0 when none — the
|
|
@@ -389,6 +452,18 @@ export interface EditFloodFuseStats {
|
|
|
389
452
|
cosmeticPerMessageCeiling: number
|
|
390
453
|
/** Live COSMETIC per-chat ceiling (post-AIMD). */
|
|
391
454
|
cosmeticPerChatCeiling: number
|
|
455
|
+
/**
|
|
456
|
+
* Live shared per-chat budget available to COSMETIC traffic (post-AIMD, after
|
|
457
|
+
* the reply reserve). Goes to 0 under heavy tightening — that is the reserve
|
|
458
|
+
* working, not a fault.
|
|
459
|
+
*/
|
|
460
|
+
cosmeticPerChatTotalCeiling: number
|
|
461
|
+
/**
|
|
462
|
+
* Live shared per-chat budget available to CRITICAL traffic (post-AIMD, after
|
|
463
|
+
* the critical floor). The direct measure of #3885: this must never drop
|
|
464
|
+
* below `perChatCriticalMinPerWindow`, at any tighten level.
|
|
465
|
+
*/
|
|
466
|
+
criticalPerChatTotalCeiling: number
|
|
392
467
|
/**
|
|
393
468
|
* Chat-targeted calls charged by the DEFAULT-DENY rule — every one of these
|
|
394
469
|
* was completely unmetered before #3855. A non-zero value here is the direct
|
|
@@ -430,8 +505,18 @@ export const EDIT_FLOOD_FUSE_DEFAULTS = {
|
|
|
430
505
|
* edits together — so this is the only window that reflects the real budget.
|
|
431
506
|
*/
|
|
432
507
|
perChatTotalMaxPerWindow: 20,
|
|
433
|
-
/**
|
|
508
|
+
/**
|
|
509
|
+
* 8 of those 20 slots/min are unreachable by cosmetic traffic — i.e. 40% of
|
|
510
|
+
* whatever the EFFECTIVE ceiling is, re-derived per window (#3885).
|
|
511
|
+
*/
|
|
434
512
|
perChatReplyReserve: 8,
|
|
513
|
+
/**
|
|
514
|
+
* 3/60s. The hard floor on `critical` traffic in a chat. Sized against the
|
|
515
|
+
* thing that broke: the MCP `reply` tool times out at 60s, so a chat that can
|
|
516
|
+
* pass fewer than a couple of criticals a minute turns one answer into a
|
|
517
|
+
* timeout, a retry, and a duplicate message.
|
|
518
|
+
*/
|
|
519
|
+
perChatCriticalMinPerWindow: 3,
|
|
435
520
|
/**
|
|
436
521
|
* 25 per second across the whole bot token. Telegram enforces ~30/s at the
|
|
437
522
|
* token level and the ban it issues is token-scoped; 25 leaves headroom for
|
|
@@ -460,6 +545,20 @@ function envInt(raw: string | undefined): number | undefined {
|
|
|
460
545
|
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : undefined
|
|
461
546
|
}
|
|
462
547
|
|
|
548
|
+
/**
|
|
549
|
+
* Parse a multiplicative-decrease factor from env: a real in (0, 1].
|
|
550
|
+
*
|
|
551
|
+
* Rejects 0 and negatives (which would zero every ceiling) and anything above
|
|
552
|
+
* 1 (which would make a 429 LOOSEN the fuse). An out-of-range value is treated
|
|
553
|
+
* as unset rather than clamped — silently reinterpreting an operator's number
|
|
554
|
+
* is how a fuse ends up at a rate nobody chose.
|
|
555
|
+
*/
|
|
556
|
+
function envFactor(raw: string | undefined): number | undefined {
|
|
557
|
+
if (raw == null || raw.trim() === '') return undefined
|
|
558
|
+
const n = Number(raw)
|
|
559
|
+
return Number.isFinite(n) && n > 0 && n <= 1 ? n : undefined
|
|
560
|
+
}
|
|
561
|
+
|
|
463
562
|
/**
|
|
464
563
|
* Operator config surface. Before the 2026-07-27 incident the fuse had exactly
|
|
465
564
|
* one knob — `SWITCHROOM_EDIT_FUSE=0`, which turns the whole failsafe OFF —
|
|
@@ -478,8 +577,16 @@ export function editFloodFuseConfigFromEnv(
|
|
|
478
577
|
assign('cosmeticPerChatMaxPerWindow', envInt(env.SWITCHROOM_FEED_EDIT_MAX_PER_CHAT_PER_MIN))
|
|
479
578
|
assign('perChatTotalMaxPerWindow', envInt(env.SWITCHROOM_CHAT_TOTAL_MAX_PER_MIN))
|
|
480
579
|
assign('perChatReplyReserve', envInt(env.SWITCHROOM_CHAT_REPLY_RESERVE))
|
|
580
|
+
assign('perChatCriticalMinPerWindow', envInt(env.SWITCHROOM_CHAT_CRITICAL_MIN_PER_MIN))
|
|
481
581
|
assign('perTokenMaxPerWindow', envInt(env.SWITCHROOM_TOKEN_MAX_PER_SEC))
|
|
482
582
|
assign('maxDeferMs', envInt(env.SWITCHROOM_EDIT_FUSE_MAX_DEFER_MS))
|
|
583
|
+
// #3885 — the tightening curve itself is now operator-tunable. Before this,
|
|
584
|
+
// `SWITCHROOM_CHAT_TOTAL_MAX_PER_MIN` was the ONLY lever on it, so an
|
|
585
|
+
// operator whose chats were collapsing under `factor^maxLevel` had to inflate
|
|
586
|
+
// the BASE ceiling fleet-wide to compensate — raising the untightened rate
|
|
587
|
+
// (the one that earns bans) to fix the tightened one.
|
|
588
|
+
assign('maxTightenLevel', envInt(env.SWITCHROOM_EDIT_FUSE_MAX_TIGHTEN_LEVEL))
|
|
589
|
+
assign('tightenFactor', envFactor(env.SWITCHROOM_EDIT_FUSE_TIGHTEN_FACTOR))
|
|
483
590
|
// #3856 — durable ban awareness. The fuse reads the SAME persisted marker
|
|
484
591
|
// (`flood-wait.json`) that `robustApiCall` and the outbox sweep consult, so a
|
|
485
592
|
// restart mid-ban comes back tightened instead of at full rate. Wired here
|
|
@@ -529,12 +636,21 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
529
636
|
perChatEditMax, config.cosmeticPerChatMaxPerWindow ?? D.cosmeticPerChatMaxPerWindow)
|
|
530
637
|
const perChatTotalMax = config.perChatTotalMaxPerWindow ?? D.perChatTotalMaxPerWindow
|
|
531
638
|
const perChatWindowMs = config.perChatWindowMs ?? D.perChatWindowMs
|
|
532
|
-
// Clamped so a misconfigured reserve can never
|
|
533
|
-
//
|
|
639
|
+
// Clamped so a misconfigured reserve can never eat more than the whole
|
|
640
|
+
// budget. Note this clamp is on the BASE only — the reserve that actually
|
|
641
|
+
// binds is re-derived per window by `cosmeticTotalMax` (#3885).
|
|
534
642
|
const perChatReplyReserve = Math.min(
|
|
535
643
|
Math.max(0, config.perChatReplyReserve ?? D.perChatReplyReserve),
|
|
536
644
|
Math.max(0, perChatTotalMax - 1),
|
|
537
645
|
)
|
|
646
|
+
/**
|
|
647
|
+
* The reserve as a FRACTION of the base. This is the whole #3885 fix: the
|
|
648
|
+
* reserve has to mean "40% of the chat's budget belongs to replies", not "8
|
|
649
|
+
* calls", because 8 is meaningless once the effective budget is 1.
|
|
650
|
+
*/
|
|
651
|
+
const replyReserveFraction = perChatTotalMax > 0 ? perChatReplyReserve / perChatTotalMax : 0
|
|
652
|
+
const perChatCriticalMin = Math.max(
|
|
653
|
+
0, config.perChatCriticalMinPerWindow ?? D.perChatCriticalMinPerWindow)
|
|
538
654
|
const perTokenMax = Math.max(1, config.perTokenMaxPerWindow ?? D.perTokenMaxPerWindow)
|
|
539
655
|
const perTokenWindowMs = Math.max(1, config.perTokenWindowMs ?? D.perTokenWindowMs)
|
|
540
656
|
// Only ever the PUBLIC bot id (or an irreversible hash) reaches this key.
|
|
@@ -602,12 +718,29 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
602
718
|
tightenLevel--
|
|
603
719
|
tightenedUntil = tightenLevel > 0 ? tightenedUntil + tightenMs : 0
|
|
604
720
|
}
|
|
605
|
-
// #3856 — a KNOWN-OPEN persisted window pins the fuse
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
|
|
721
|
+
// #3856 — a KNOWN-OPEN persisted window pins the fuse regardless of
|
|
722
|
+
// in-memory state. This is what survives a restart: the process forgets,
|
|
723
|
+
// the marker on disk does not. It does not MUTATE `tightenLevel`, so when
|
|
724
|
+
// the window closes the fuse returns to whatever this process actually
|
|
725
|
+
// earned rather than staying pinned.
|
|
726
|
+
//
|
|
727
|
+
// #3885 — but pinned at a level PROPORTIONAL to the window still open, not
|
|
728
|
+
// unconditionally at `maxTightenLevel`. `flood-wait.json` is written for
|
|
729
|
+
// EVERY 429 including a routine 3-second nudge (`makeFloodWaitRecorder` →
|
|
730
|
+
// `computeFloodWait`), so the flat jump meant a 3s burst nudge and a 4.4h
|
|
731
|
+
// ban produced the identical maximal response — the exact asymmetry #3856
|
|
732
|
+
// fixed for the in-memory path and left in place here. The remaining window
|
|
733
|
+
// is graded through the SAME severity ladder `noteFlood` uses, so a big ban
|
|
734
|
+
// still pins at maximum and a nudge costs one level.
|
|
735
|
+
//
|
|
736
|
+
// Clamped by `maxTightenLevel` like every other path: `tightenStepFor`
|
|
737
|
+
// returns a fixed 1..3 for the smaller bands, so an operator who sets
|
|
738
|
+
// `SWITCHROOM_EDIT_FUSE_MAX_TIGHTEN_LEVEL=0` to disable tightening entirely
|
|
739
|
+
// must not find the fuse tightening anyway the moment a marker exists.
|
|
740
|
+
const remainingMs = persistedFloodRemainingMs(now)
|
|
741
|
+
if (remainingMs > 0) {
|
|
742
|
+
return Math.max(tightenLevel, Math.min(maxTightenLevel, tightenStepFor(remainingMs / 1000)))
|
|
743
|
+
}
|
|
611
744
|
return tightenLevel
|
|
612
745
|
}
|
|
613
746
|
|
|
@@ -626,6 +759,45 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
626
759
|
return Math.max(1, Math.floor(base * Math.pow(tightenFactor, level)))
|
|
627
760
|
}
|
|
628
761
|
|
|
762
|
+
/**
|
|
763
|
+
* The ceiling a given CLASS sees on a tier whose base is `base` (#3885).
|
|
764
|
+
*
|
|
765
|
+
* `critical` traffic — the operator's answer, an approval card, a reaction —
|
|
766
|
+
* gets an absolute floor that tightening cannot cross. Everything else takes
|
|
767
|
+
* the tightened ceiling as-is, so shedding still happens; it just happens to
|
|
768
|
+
* the traffic that can afford it.
|
|
769
|
+
*
|
|
770
|
+
* The floor is capped by `base` so it can only ever RAISE a tightened ceiling
|
|
771
|
+
* back towards what the operator configured, never above it.
|
|
772
|
+
*/
|
|
773
|
+
function classCeiling(base: number, cls: OutboundClass, now: number): number {
|
|
774
|
+
const eff = ceiling(base, now)
|
|
775
|
+
if (cls !== 'critical') return eff
|
|
776
|
+
return Math.max(eff, Math.min(base, perChatCriticalMin))
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* The shared per-chat budget COSMETIC traffic may reach, re-derived against
|
|
781
|
+
* the EFFECTIVE ceiling (#3885).
|
|
782
|
+
*
|
|
783
|
+
* Returns 0 when the effective budget has shrunk to the reserve — at that
|
|
784
|
+
* point every remaining slot belongs to replies, and a repaint waits for the
|
|
785
|
+
* window or is shed. That is the intended behaviour under real flood
|
|
786
|
+
* pressure: the operator loses refresh frequency, never the answer.
|
|
787
|
+
*/
|
|
788
|
+
function cosmeticTotalMax(now: number): number {
|
|
789
|
+
const eff = ceiling(perChatTotalMax, now)
|
|
790
|
+
if (replyReserveFraction <= 0) return eff
|
|
791
|
+
const reserve = Math.min(eff, Math.max(1, Math.round(eff * replyReserveFraction)))
|
|
792
|
+
return Math.max(0, eff - reserve)
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** Shared per-chat budget resolver, per class, evaluated at admission time. */
|
|
796
|
+
function totalMaxFor(cls: OutboundClass): (now: number) => number {
|
|
797
|
+
if (cls === 'cosmetic') return cosmeticTotalMax
|
|
798
|
+
return (now: number) => classCeiling(perChatTotalMax, cls, now)
|
|
799
|
+
}
|
|
800
|
+
|
|
629
801
|
function win(key: string): Window {
|
|
630
802
|
let w = windows.get(key)
|
|
631
803
|
if (w === undefined) {
|
|
@@ -668,6 +840,14 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
668
840
|
/** ms until `w` has room, or 0 if it has room now. */
|
|
669
841
|
function waitFor(w: Window, now: number, windowMs: number, max: number): number {
|
|
670
842
|
prune(w, now, windowMs)
|
|
843
|
+
// A ceiling of 0 is reachable now that the reply reserve is derived against
|
|
844
|
+
// the EFFECTIVE budget (#3885): under heavy tightening the whole remaining
|
|
845
|
+
// budget belongs to replies, so cosmetic traffic has none. There is no
|
|
846
|
+
// "oldest slot" to wait behind in that case, so wait out the window rather
|
|
847
|
+
// than indexing an empty array (which would produce NaN and admit).
|
|
848
|
+
if (max <= 0) {
|
|
849
|
+
return w.ts.length > 0 ? Math.max(1, w.ts[0]! + windowMs - now) : Math.max(1, windowMs)
|
|
850
|
+
}
|
|
671
851
|
if (w.ts.length < max) return 0
|
|
672
852
|
return Math.max(1, w.ts[0]! + windowMs - now)
|
|
673
853
|
}
|
|
@@ -770,7 +950,16 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
770
950
|
* Returns the reserved timestamp, or null when the call must be dropped.
|
|
771
951
|
*/
|
|
772
952
|
async function awaitRoom(
|
|
773
|
-
key: string, windowMs: number,
|
|
953
|
+
key: string, windowMs: number,
|
|
954
|
+
/**
|
|
955
|
+
* The ceiling, resolved at EVERY loop iteration rather than once at entry.
|
|
956
|
+
* It has to be a function: the effective ceiling depends on the tighten
|
|
957
|
+
* level, the tighten level decays with time, and a call can sit in this
|
|
958
|
+
* loop for `maxDeferMs`. Passing a number would pin a waiter to the ceiling
|
|
959
|
+
* that was in force when it arrived.
|
|
960
|
+
*/
|
|
961
|
+
maxFor: (now: number) => number,
|
|
962
|
+
method: string, mode: WaitMode, cls: OutboundClass,
|
|
774
963
|
/**
|
|
775
964
|
* Consulted ONLY at the defer deadline for `mode: 'drop'`. Returning false
|
|
776
965
|
* converts the drop into a late release: this call is the last thing that
|
|
@@ -805,7 +994,7 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
805
994
|
let counted = false
|
|
806
995
|
for (;;) {
|
|
807
996
|
const now = clock.now()
|
|
808
|
-
const wait = waitFor(w, now, windowMs,
|
|
997
|
+
const wait = waitFor(w, now, windowMs, maxFor(now))
|
|
809
998
|
if (wait === 0) {
|
|
810
999
|
w.ts.push(now)
|
|
811
1000
|
return now
|
|
@@ -897,22 +1086,29 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
897
1086
|
// It cannot take a per-chat slot (there is no chat to charge), but it does
|
|
898
1087
|
// consume the bot token's global allowance, which is the thing Telegram
|
|
899
1088
|
// actually bans. Charge it there and pass.
|
|
1089
|
+
// The token tier is per-SECOND and global; a class floor there would be
|
|
1090
|
+
// meaningless (even at maximum tightening it admits 60/min), so it takes
|
|
1091
|
+
// the plain tightened ceiling.
|
|
1092
|
+
const tokenMaxFor = (t: number): number => ceiling(perTokenMax, t)
|
|
1093
|
+
|
|
900
1094
|
if (chat == null) {
|
|
901
1095
|
counters.chatless++
|
|
902
|
-
await awaitRoom(tokenKey, perTokenWindowMs,
|
|
1096
|
+
await awaitRoom(tokenKey, perTokenWindowMs, tokenMaxFor, method, 'release', cls, undefined, deadline)
|
|
903
1097
|
return runObserved(next)
|
|
904
1098
|
}
|
|
905
1099
|
|
|
906
1100
|
const totalKey = `t:${chat}`
|
|
907
|
-
// Cosmetic traffic may only reach
|
|
908
|
-
// the remaining slots stay available to a real reply no matter how
|
|
909
|
-
// repaint surfaces are
|
|
910
|
-
|
|
911
|
-
|
|
1101
|
+
// Cosmetic traffic may only reach the effective ceiling MINUS the reply
|
|
1102
|
+
// reserve; the remaining slots stay available to a real reply no matter how
|
|
1103
|
+
// hot the repaint surfaces are, and `critical` additionally cannot be
|
|
1104
|
+
// tightened below `perChatCriticalMinPerWindow`. Together these are the
|
|
1105
|
+
// reply-starvation guarantee, and unlike the pre-#3885 shape they hold at
|
|
1106
|
+
// every tighten level rather than only at level 0.
|
|
1107
|
+
const chatTotalMax = totalMaxFor(cls)
|
|
912
1108
|
|
|
913
1109
|
/** Final tier for every chat-targeted path: the token-scoped ceiling. */
|
|
914
1110
|
const passToken = async (): Promise<R> => {
|
|
915
|
-
await awaitRoom(tokenKey, perTokenWindowMs,
|
|
1111
|
+
await awaitRoom(tokenKey, perTokenWindowMs, tokenMaxFor, method, 'release', cls, undefined, deadline)
|
|
916
1112
|
return runObserved(next)
|
|
917
1113
|
}
|
|
918
1114
|
|
|
@@ -925,7 +1121,9 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
925
1121
|
try {
|
|
926
1122
|
const msgSlot = await awaitRoom(
|
|
927
1123
|
msgKey, perMessageWindowMs,
|
|
928
|
-
cls === 'cosmetic'
|
|
1124
|
+
cls === 'cosmetic'
|
|
1125
|
+
? (t) => ceiling(cosmeticPerMessageMax, t)
|
|
1126
|
+
: (t) => classCeiling(perMessageMax, cls, t),
|
|
929
1127
|
method, 'supersede', cls, undefined, deadline,
|
|
930
1128
|
)
|
|
931
1129
|
if (msgSlot === null) return DROPPED_RESULT as unknown as R
|
|
@@ -941,7 +1139,9 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
941
1139
|
const chatKey = `ce:${chat}`
|
|
942
1140
|
const chatSlot = await awaitRoom(
|
|
943
1141
|
chatKey, perChatWindowMs,
|
|
944
|
-
cls === 'cosmetic'
|
|
1142
|
+
cls === 'cosmetic'
|
|
1143
|
+
? (t) => ceiling(cosmeticPerChatMax, t)
|
|
1144
|
+
: (t) => classCeiling(perChatEditMax, cls, t),
|
|
945
1145
|
method, 'drop', cls, dropGuard, deadline, lateKey,
|
|
946
1146
|
)
|
|
947
1147
|
if (chatSlot === null) { giveBack(); return DROPPED_RESULT as unknown as R }
|
|
@@ -951,7 +1151,7 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
951
1151
|
// metering. A non-cosmetic edit is RELEASED late rather than dropped:
|
|
952
1152
|
// an approval card or answer finalisation has no newer frame coming.
|
|
953
1153
|
const totalSlot = await awaitRoom(
|
|
954
|
-
totalKey, perChatWindowMs,
|
|
1154
|
+
totalKey, perChatWindowMs, chatTotalMax, method,
|
|
955
1155
|
cls === 'cosmetic' ? 'drop' : 'release', cls, dropGuard, deadline, lateKey,
|
|
956
1156
|
)
|
|
957
1157
|
if (totalSlot === null) { giveBack(); return DROPPED_RESULT as unknown as R }
|
|
@@ -965,8 +1165,9 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
965
1165
|
// Sends create user-visible output and are NEVER dropped — only paced.
|
|
966
1166
|
// The shared budget's reserve is what guarantees they still have room
|
|
967
1167
|
// when the repaint surfaces are saturated.
|
|
968
|
-
await awaitRoom(`cs:${chat}`, perChatWindowMs,
|
|
969
|
-
|
|
1168
|
+
await awaitRoom(`cs:${chat}`, perChatWindowMs,
|
|
1169
|
+
(t) => classCeiling(perChatSendMax, cls, t), method, 'release', cls, undefined, deadline)
|
|
1170
|
+
await awaitRoom(totalKey, perChatWindowMs, chatTotalMax, method, 'release', cls, undefined, deadline)
|
|
970
1171
|
return passToken()
|
|
971
1172
|
}
|
|
972
1173
|
|
|
@@ -982,14 +1183,14 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
982
1183
|
// short deadline also keeps a typing ping from occupying a 30s hold.
|
|
983
1184
|
const actionDeadline = Math.min(deadline, now + chatActionMaxDeferMs)
|
|
984
1185
|
const slot = await awaitRoom(
|
|
985
|
-
totalKey, perChatWindowMs,
|
|
1186
|
+
totalKey, perChatWindowMs, chatTotalMax, method, 'drop', cls, undefined, actionDeadline,
|
|
986
1187
|
)
|
|
987
1188
|
if (slot === null) return DROPPED_RESULT as unknown as R
|
|
988
1189
|
return passToken()
|
|
989
1190
|
}
|
|
990
1191
|
// Durable, user-visible effect (a deletion, a pin, a reaction): paced,
|
|
991
1192
|
// never dropped — the same contract sends get.
|
|
992
|
-
await awaitRoom(totalKey, perChatWindowMs,
|
|
1193
|
+
await awaitRoom(totalKey, perChatWindowMs, chatTotalMax, method, 'release', cls, undefined, deadline)
|
|
993
1194
|
return passToken()
|
|
994
1195
|
}
|
|
995
1196
|
|
|
@@ -1026,6 +1227,8 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
1026
1227
|
perMessageCeiling: ceiling(perMessageMax, now),
|
|
1027
1228
|
cosmeticPerMessageCeiling: ceiling(cosmeticPerMessageMax, now),
|
|
1028
1229
|
cosmeticPerChatCeiling: ceiling(cosmeticPerChatMax, now),
|
|
1230
|
+
cosmeticPerChatTotalCeiling: cosmeticTotalMax(now),
|
|
1231
|
+
criticalPerChatTotalCeiling: classCeiling(perChatTotalMax, 'critical', now),
|
|
1029
1232
|
meteredByDefault: counters.meteredByDefault,
|
|
1030
1233
|
chatless: counters.chatless,
|
|
1031
1234
|
perTokenCeiling: ceiling(perTokenMax, now),
|
|
@@ -766,6 +766,7 @@ async function handleVaultRecentDenialCallback(ctx: Context, data: string): Prom
|
|
|
766
766
|
`(grant \`${id}\`). ` +
|
|
767
767
|
`Re-run /vault audit to act on remaining denials.`
|
|
768
768
|
await finalizeCallback(ctx, {
|
|
769
|
+
apiCall: robustApiCall,
|
|
769
770
|
ackText: '✅ Grant minted',
|
|
770
771
|
newText: baseText ? `${baseText}${statusLine}` : statusLine,
|
|
771
772
|
// No synthInbound — operator-only flow. The granted agent picks
|
|
@@ -1909,6 +1910,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
1909
1910
|
`\n\n✏️ **Rename mode** — send the new key name as your next message. ` +
|
|
1910
1911
|
`The current proposed key is \`${pending.key}\`.`
|
|
1911
1912
|
await finalizeCallback(ctx, {
|
|
1913
|
+
apiCall: robustApiCall,
|
|
1912
1914
|
ackText: 'Send the new key name as your next message.',
|
|
1913
1915
|
newText: baseText ? `${baseText}${statusLine}` : statusLine,
|
|
1914
1916
|
})
|
|
@@ -2512,6 +2514,7 @@ async function executeGrantWizard(ctx: Context, chatId: string, state: Extract<P
|
|
|
2512
2514
|
const successText = `✅ Grant \`${id}\` created. Written to \`~/.switchroom/agents/${escapeHtmlForTg(state.agent!)}/.vault-token\``
|
|
2513
2515
|
if (msgId != null) {
|
|
2514
2516
|
await finalizeCallback(ctx, {
|
|
2517
|
+
apiCall: robustApiCall,
|
|
2515
2518
|
ackText: '✅ Grant created',
|
|
2516
2519
|
newText: successText,
|
|
2517
2520
|
// No synthInbound — operator-only flow.
|
|
@@ -2933,6 +2936,7 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
|
|
|
2933
2936
|
// scrollback shows the dismissal.
|
|
2934
2937
|
const status = `\n\n✗ _Dismissed by operator._`
|
|
2935
2938
|
await finalizeCallback(ctx, {
|
|
2939
|
+
apiCall: robustApiCall,
|
|
2936
2940
|
ackText: 'Dismissed',
|
|
2937
2941
|
newText: sourceMsgText ? `${sourceMsgText}${status}` : status,
|
|
2938
2942
|
// No synthInbound — dismiss is operator-only, no model in loop.
|
|
@@ -2947,6 +2951,7 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
|
|
|
2947
2951
|
// status line is the announcement, no separate reply needed.
|
|
2948
2952
|
const status = `\n\n🔄 _**${escapeHtmlForTg(agent)}** restart requested by operator._`
|
|
2949
2953
|
await finalizeCallback(ctx, {
|
|
2954
|
+
apiCall: robustApiCall,
|
|
2950
2955
|
ackText: `Restarting ${agent}…`,
|
|
2951
2956
|
newText: sourceMsgText ? `${sourceMsgText}${status}` : status,
|
|
2952
2957
|
})
|
|
@@ -2973,6 +2978,7 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
|
|
|
2973
2978
|
// collapsed card.
|
|
2974
2979
|
const status = `\n\n🔐 _Reauth started for **${escapeHtmlForTg(agent)}** — follow the login URL below._`
|
|
2975
2980
|
await finalizeCallback(ctx, {
|
|
2981
|
+
apiCall: robustApiCall,
|
|
2976
2982
|
ackText: `Starting reauth for ${agent}…`,
|
|
2977
2983
|
newText: sourceMsgText ? `${sourceMsgText}${status}` : status,
|
|
2978
2984
|
synthInbound: async () => {
|
|
@@ -404,6 +404,7 @@ import {
|
|
|
404
404
|
type OperatorEvent,
|
|
405
405
|
type OperatorEventKind,
|
|
406
406
|
} from '../operator-events.js'
|
|
407
|
+
import { createMcpFailureHook } from './mcp-failure-hook.js'
|
|
407
408
|
import { pendingUserNoticeGate } from '../pending-user-notice.js'
|
|
408
409
|
import { recordOperatorEvent } from '../operator-events-history.js'
|
|
409
410
|
import {
|
|
@@ -14007,7 +14008,13 @@ function surfaceConsolidationLegibility(
|
|
|
14007
14008
|
})
|
|
14008
14009
|
}
|
|
14009
14010
|
|
|
14011
|
+
/** Blocked-paid-dependency alerting — policy in `mcp-credential-failure.ts`,
|
|
14012
|
+
* seam in `gateway/mcp-failure-hook.ts`. Tees the SAME
|
|
14013
|
+
* `emitGatewayOperatorEvent` path so there is ONE operator-alert mechanism. */
|
|
14014
|
+
const noteMcpDependencyFailure = createMcpFailureHook({ agent: AGENT_NAME, emit: emitGatewayOperatorEvent })
|
|
14015
|
+
|
|
14010
14016
|
function handleSessionEvent(ev: SessionEvent): void {
|
|
14017
|
+
noteMcpDependencyFailure(ev)
|
|
14011
14018
|
handleSessionEventCore(gatewayStreamRenderDeps(), ev)
|
|
14012
14019
|
}
|
|
14013
14020
|
|
|
@@ -21948,7 +21955,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
21948
21955
|
// ALREADY dispatched above (independently of this host round-trip)
|
|
21949
21956
|
// so the turn never blocked — finalizeCallback here only edits the
|
|
21950
21957
|
// card; no synthInbound (would double-fire the verdict).
|
|
21951
|
-
await finalizeCallback(ctx, {
|
|
21958
|
+
await finalizeCallback(ctx, { apiCall: robustApiCall,
|
|
21952
21959
|
ackText: 'Rule applied for this session; saving durably in background…'.slice(0, 200),
|
|
21953
21960
|
newText: baseText ? `${baseText}\n\n${interimLabel}` : interimLabel,
|
|
21954
21961
|
})
|
|
@@ -22260,7 +22267,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22260
22267
|
// decision. Operator could re-tap and flip Deny → Allow after the
|
|
22261
22268
|
// permission was already broadcast. Routing through finalizeCallback
|
|
22262
22269
|
// strips the keyboard atomically with the status-line edit.
|
|
22263
|
-
await finalizeCallback(ctx, {
|
|
22270
|
+
await finalizeCallback(ctx, { apiCall: robustApiCall,
|
|
22264
22271
|
ackText: ackText.slice(0, 200),
|
|
22265
22272
|
newText: baseText ? `${baseText}\n\n${labelWithResume}` : labelWithResume,
|
|
22266
22273
|
synthInbound: () => {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-failure-hook.ts — the gateway-side wiring that turns a blocked PAID MCP
|
|
3
|
+
* DEPENDENCY into an operator alert.
|
|
4
|
+
*
|
|
5
|
+
* Ken: "If perplexity fails for any agent we should have hard warning via
|
|
6
|
+
* message here per other failures."
|
|
7
|
+
*
|
|
8
|
+
* MCP providers (Perplexity, Eraser, Brevo, Postiz, Meta/Google Ads,
|
|
9
|
+
* Cloudflare) never touch LiteLLM, so the operator-event path that catches an
|
|
10
|
+
* OpenRouter 402 never sees them — an expired Perplexity key would otherwise
|
|
11
|
+
* die as a transient red step in the live feed and nobody would learn the fleet
|
|
12
|
+
* had lost a paid capability.
|
|
13
|
+
*
|
|
14
|
+
* This tees the SAME `emitGatewayOperatorEvent` path (redact → per-kind
|
|
15
|
+
* cooldown → operator-only audience via `OPERATOR_ACTIONABLE_KINDS` → brief
|
|
16
|
+
* user notice), so there is ONE operator-alert mechanism in switchroom, not
|
|
17
|
+
* two. All the policy — what counts as a key problem, what stays silent, the
|
|
18
|
+
* 6-hour re-notify cadence — lives in `../mcp-credential-failure.ts`; this file
|
|
19
|
+
* is only the seam.
|
|
20
|
+
*
|
|
21
|
+
* Extracted from `gateway.ts` rather than inlined there, per the
|
|
22
|
+
* switchroom#2996 anti-inflation ratchet (`scripts/check-gateway-line-ratchet.mjs`).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { SessionEvent } from '../session-tail.js'
|
|
26
|
+
import type { OperatorEvent } from '../operator-events.js'
|
|
27
|
+
import { McpFailureWatcher, renderMcpFailureDetail } from '../mcp-credential-failure.js'
|
|
28
|
+
|
|
29
|
+
export interface McpFailureHookDeps {
|
|
30
|
+
agent: string
|
|
31
|
+
emit: (event: OperatorEvent) => void
|
|
32
|
+
now?: () => number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build the `handleSessionEvent` side-hook. One watcher per gateway process,
|
|
37
|
+
* captured in the closure.
|
|
38
|
+
*
|
|
39
|
+
* Silent by default: only a credit/credential/quota class raises anything, and
|
|
40
|
+
* only once per (server, class) per re-notify window. A bad query, a 404, a
|
|
41
|
+
* timeout, a transient 429 or a successful call all return without emitting.
|
|
42
|
+
*
|
|
43
|
+
* Never throws — alerting is a side channel and must not break the live feed.
|
|
44
|
+
*/
|
|
45
|
+
export function createMcpFailureHook(deps: McpFailureHookDeps): (ev: SessionEvent) => void {
|
|
46
|
+
const watcher = new McpFailureWatcher()
|
|
47
|
+
const clock = deps.now ?? (() => Date.now())
|
|
48
|
+
return function noteMcpDependencyFailure(ev: SessionEvent): void {
|
|
49
|
+
try {
|
|
50
|
+
if (ev.kind === 'tool_use') {
|
|
51
|
+
watcher.onToolUse(ev.toolUseId, ev.toolName)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
if (ev.kind !== 'tool_result') return
|
|
55
|
+
const alert = watcher.onToolResult({
|
|
56
|
+
toolUseId: ev.toolUseId,
|
|
57
|
+
isError: ev.isError,
|
|
58
|
+
errorText: ev.errorText,
|
|
59
|
+
agent: deps.agent,
|
|
60
|
+
now: clock(),
|
|
61
|
+
})
|
|
62
|
+
if (alert == null) return
|
|
63
|
+
deps.emit({
|
|
64
|
+
kind: 'mcp-dependency-blocked',
|
|
65
|
+
agent: deps.agent,
|
|
66
|
+
detail: renderMcpFailureDetail(alert),
|
|
67
|
+
suggestedActions: [],
|
|
68
|
+
firstSeenAt: new Date(clock()),
|
|
69
|
+
})
|
|
70
|
+
} catch {
|
|
71
|
+
// Side channel — never let alerting break the live feed.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|