switchroom 0.18.11 → 0.18.13

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 (150) hide show
  1. package/dist/agent-scheduler/index.js +37 -5
  2. package/dist/auth-broker/index.js +116 -78
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  5. package/dist/cli/notion-write-pretool.mjs +38 -6
  6. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  7. package/dist/cli/switchroom.js +3529 -4543
  8. package/dist/cli/ui/index.html +163 -17
  9. package/dist/host-control/main.js +605 -2847
  10. package/dist/vault/approvals/kernel-server.js +120 -13
  11. package/dist/vault/broker/server.js +259 -157
  12. package/package.json +3 -4
  13. package/profiles/_base/start.sh.hbs +65 -0
  14. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  15. package/profiles/coding/CLAUDE.md.hbs +1 -1
  16. package/profiles/default/CLAUDE.md.hbs +2 -2
  17. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  18. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  19. package/telegram-plugin/bridge/bridge.ts +37 -0
  20. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +122 -4
  22. package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
  23. package/telegram-plugin/dist/server.js +139 -5
  24. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  25. package/telegram-plugin/format.ts +19 -0
  26. package/telegram-plugin/gateway/approval-hold.ts +602 -0
  27. package/telegram-plugin/gateway/auth-command.ts +92 -2
  28. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  29. package/telegram-plugin/gateway/boot-card.ts +12 -5
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
  31. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  32. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  33. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  34. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  35. package/telegram-plugin/gateway/gateway.ts +1695 -230
  36. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  37. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  38. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  39. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  40. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  41. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  42. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  43. package/telegram-plugin/gateway/model-command.ts +9 -3
  44. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  45. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  46. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  47. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  48. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  49. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  50. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  51. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  52. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  53. package/telegram-plugin/history.ts +51 -0
  54. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  55. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  56. package/telegram-plugin/hooks/hooks.json +9 -0
  57. package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
  58. package/telegram-plugin/model-unavailable.ts +41 -11
  59. package/telegram-plugin/operator-events.ts +23 -0
  60. package/telegram-plugin/outbound-field-redact.ts +69 -0
  61. package/telegram-plugin/package.json +0 -1
  62. package/telegram-plugin/permission-rule.ts +1 -0
  63. package/telegram-plugin/permission-title.ts +1 -0
  64. package/telegram-plugin/render/render.ts +32 -14
  65. package/telegram-plugin/retry-api-call.ts +212 -2
  66. package/telegram-plugin/scoped-approval.ts +11 -2
  67. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  68. package/telegram-plugin/secret-detect/index.ts +12 -56
  69. package/telegram-plugin/send-gate-degraded.test.ts +574 -0
  70. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  71. package/telegram-plugin/send-gate-observability.ts +355 -0
  72. package/telegram-plugin/send-gate.test.ts +717 -0
  73. package/telegram-plugin/send-gate.ts +1056 -0
  74. package/telegram-plugin/session-tail.ts +82 -7
  75. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  76. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  77. package/telegram-plugin/status-pin-driver.ts +52 -7
  78. package/telegram-plugin/status-pin.ts +81 -0
  79. package/telegram-plugin/subagent-watcher.ts +173 -18
  80. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  81. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  82. package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
  83. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  84. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  85. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  86. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  87. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  88. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  89. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  90. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  91. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  92. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  93. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  94. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  95. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  96. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  97. package/telegram-plugin/tests/history.test.ts +115 -0
  98. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  99. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  100. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  101. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
  102. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  103. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  104. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  105. package/telegram-plugin/tests/model-command.test.ts +14 -0
  106. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  107. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  108. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  109. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  111. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  112. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  113. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  114. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  115. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  116. package/telegram-plugin/tests/render/render.test.ts +88 -0
  117. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  118. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  119. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  120. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  121. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  122. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  123. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  124. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  125. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  126. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  127. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  128. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  129. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  130. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  131. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  132. package/telegram-plugin/typing-emitter.ts +224 -0
  133. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  134. package/telegram-plugin/welcome-text.ts +42 -0
  135. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  136. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  137. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  138. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  139. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  140. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  141. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  142. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  143. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  144. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  145. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  146. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  147. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  148. package/telegram-plugin/channel-envelope-safety.ts +0 -56
  149. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  150. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -0,0 +1,1056 @@
1
+ /**
2
+ * Deterministic outbound send gate for the Telegram Bot API (#3084, PR 1/3).
3
+ *
4
+ * WHY
5
+ * ---
6
+ * Each agent's gateway drives MANY outbound surfaces (reply chunks, answer /
7
+ * draft stream edits, typing, worker-feed edits, reactions, cards). Each has
8
+ * its own local throttle, but nothing composes them into a global or per-chat
9
+ * ceiling — so during a busy turn the per-surface throttles ADD UP with no cap
10
+ * and trip a per-bot-token flood ban (429 retry_after ~hours). See
11
+ * `part2-audit.md` §3 and issue #3084.
12
+ *
13
+ * This module is the core control from `part3-design.md` §1: ONE token-bucket
14
+ * scheduler that every Bot API call passes through (wired at the robustApiCall
15
+ * layer so no call site can bypass it). It enforces:
16
+ *
17
+ * - Global bucket: 25/sec sustained, small burst (headroom under ~30/s).
18
+ * - Per-chat bucket: 1/sec sustained, burst 3.
19
+ * - Per-group bucket: 18/min sustained, small burst (headroom under 20),
20
+ * keyed on chat type.
21
+ * - Per-message edit: >=1.5s between edits of the same message_id, with
22
+ * LAST-WRITE-WINS coalescing (an edit queued while one
23
+ * is pending REPLACES the pending payload; it never
24
+ * queues behind it — the next edit carries full state)
25
+ * AND in-flight serialization (only one send per
26
+ * message runs at a time; edits that arrive while a
27
+ * send is mid-flight — including one sleeping on a
28
+ * 429 retry_after — coalesce behind it rather than
29
+ * firing a second overlapping edit).
30
+ *
31
+ * Nested buckets like PTB's AIORateLimiter; grammY's Bottleneck numbers are the
32
+ * sanity reference. Sustained rate is the refill rate (= budget); the burst
33
+ * capacity is a small headroom under Telegram's hard window ceiling, NOT a full
34
+ * extra window's worth of budget (that would double the effective rate — see
35
+ * the M2 finding on #3092).
36
+ *
37
+ * It also skips no-op edits: identical rendered payload for the same
38
+ * message_id is dropped before hitting the API (Telegram 400s "message is not
39
+ * modified" today, which still costs flood budget).
40
+ *
41
+ * RESTART-PROOF FLOOD STATE (part3-design §7, PR 2 hook)
42
+ * -----------------------------------------------------
43
+ * `createSendGate` accepts `initialWindows` (flood windows loaded from
44
+ * `flood-wait.json` on boot) and `bootRamp` (start the global bucket at a
45
+ * fraction of capacity for the first N ms to absorb boot-card bursts).
46
+ * `openFloodWindow(scopeKey, untilTs)` lets PR 2 re-open a window at runtime
47
+ * on a 429. A bucket under an open window admits NOTHING until `untilTs`,
48
+ * regardless of token fill — a token bucket alone cannot express a
49
+ * "blocked until" suppression, so the buckets consult a per-scope window too.
50
+ *
51
+ * SAFETY / ROLLOUT
52
+ * ----------------
53
+ * ON BY DEFAULT in every install — an escape hatch, not an opt-in feature.
54
+ * `SWITCHROOM_TELEGRAM_SEND_GATE=0` (or `false`/`off`/`no`) is the safety valve
55
+ * that disables it without a rebuild, following the repo's default-on kill-
56
+ * switch convention (`midTurnFloorEnabled`, `SWITCHROOM_RATE_LIMIT_OVERAGE=0`).
57
+ * When disabled, `gate()` is a pure passthrough to the wrapped call — zero
58
+ * behaviour change. Priority-class shedding + degraded mode and observability +
59
+ * operator alert build on the counters exposed here.
60
+ *
61
+ * DETERMINISM / TESTABILITY
62
+ * -------------------------
63
+ * All time comes from an injectable `Clock` (`now()` + `sleep()`); there is no
64
+ * inline `Date.now()` / `setTimeout()` in the scheduling logic, so a fake clock
65
+ * makes the buckets fully deterministic under test. Token consumption happens
66
+ * in a synchronous critical section (no `await` between reading the clock and
67
+ * consuming), so concurrent admissions on a single-threaded runtime never
68
+ * double-spend a token.
69
+ */
70
+
71
+ import { createHash } from 'node:crypto'
72
+ import {
73
+ makeFloodWaitActiveError,
74
+ isFloodWaitActiveError,
75
+ } from './retry-api-call.js'
76
+
77
+ /** Injectable time source. Default binds to real wall clock + setTimeout. */
78
+ export interface Clock {
79
+ /** Milliseconds since epoch (monotonic-enough for bucket refill math). */
80
+ now(): number
81
+ /** Resolve after `ms` milliseconds. */
82
+ sleep(ms: number): Promise<void>
83
+ }
84
+
85
+ export const systemClock: Clock = {
86
+ now: () => Date.now(),
87
+ sleep: (ms: number) => new Promise<void>((r) => setTimeout(r, ms)),
88
+ }
89
+
90
+ /** Chat type as reported by Telegram, used to key the per-group bucket. */
91
+ export type ChatType = 'private' | 'group' | 'supergroup' | 'channel'
92
+
93
+ /**
94
+ * Priority class governing shedding + degraded mode (part3-design §2/§3):
95
+ * - `critical` — final reply chunks, approval / vault cards, error notices.
96
+ * Never shed; queued unbounded. Degraded mode: waits a short window,
97
+ * fails fast with a structured `FLOOD_WAIT_ACTIVE` for a long one.
98
+ * - `useful` — progress-card creation, worker handbacks, checklists,
99
+ * boot/config cards. Queued with a TTL; dropped (counted) when stale.
100
+ * - `cosmetic` — typing, reactions, all card EDITS, stream updates,
101
+ * heartbeats. Shed immediately (counted) when no token is free OR any
102
+ * flood window covering the scope is open.
103
+ *
104
+ * UNTAGGED default (L2, review PR #3106): an untagged NON-EDIT send defaults to
105
+ * `critical` — NON-droppable (queued unbounded, fail-fast only on a long
106
+ * window), NOT `useful`. Rationale: most call sites are untagged, and a `useful`
107
+ * default silently TTL-drops an untagged-but-important send under pressure
108
+ * (a resolved `undefined` a caller reads as "sent"). Conservative posture:
109
+ * nothing is droppable unless a call site OPTS IN by tagging `useful`/`cosmetic`.
110
+ * (Untagged EDITS keep coalescing — the latest payload always wins, never
111
+ * dropped — so their default is non-droppable already.)
112
+ */
113
+ export type PriorityClass = 'critical' | 'useful' | 'cosmetic'
114
+
115
+ /**
116
+ * Priority class an UNTAGGED non-edit send is admitted as. `critical` =
117
+ * non-droppable (L2). See the `PriorityClass` doc above.
118
+ */
119
+ export const UNTAGGED_SEND_CLASS: PriorityClass = 'critical'
120
+
121
+ /**
122
+ * Extra metadata a call site can attach so the gate can key the right buckets.
123
+ * All fields optional — a call with none still passes the global bucket. These
124
+ * mirror (a superset of) `RetryCallOpts` so the gate can wrap `robustApiCall`
125
+ * transparently.
126
+ */
127
+ export interface SendGateOpts {
128
+ /** Destination chat id — keys the per-chat and (for groups) per-group bucket. */
129
+ chat_id?: string
130
+ /** Chat type — a group/supergroup additionally passes the per-group bucket. */
131
+ chatType?: ChatType
132
+ /** For edits: the target message id. Enables the edit floor + coalescing. */
133
+ messageId?: number
134
+ /**
135
+ * For edits: the rendered payload (any stable-stringifiable value). Used for
136
+ * the no-op skip (hash equal to last sent → dropped) and last-write-wins
137
+ * coalescing. Only meaningful together with `messageId`.
138
+ */
139
+ editPayload?: unknown
140
+ /** Informational label (e.g. "editMessageText"); surfaced in stats/logs. */
141
+ verb?: string
142
+ /**
143
+ * Priority class for shedding + degraded mode (part3-design §2/§3). Untagged
144
+ * NON-EDIT calls default to `critical` (non-droppable — see
145
+ * `UNTAGGED_SEND_CLASS`); untagged edits coalesce (also non-droppable).
146
+ */
147
+ priorityClass?: PriorityClass
148
+ }
149
+
150
+ /** Per-bucket counters, snapshotted by `stats()`. */
151
+ export interface BucketCounters {
152
+ /** Calls that executed the wrapped fn. */
153
+ sent: number
154
+ /** Calls that had to wait on at least one bucket before executing. */
155
+ queued: number
156
+ /** Edits whose payload replaced a still-pending edit for the same message. */
157
+ coalesced: number
158
+ /** Calls dropped without hitting the API (no-op edit skip). */
159
+ dropped: number
160
+ /**
161
+ * Cosmetic calls shed under pressure — no token free OR a flood window open
162
+ * (part3-design §2). A shed resolves as `undefined`; the next send carries
163
+ * full state.
164
+ */
165
+ shed: number
166
+ /** Useful calls dropped because they exceeded their queue TTL (part3-design §2). */
167
+ expired: number
168
+ /**
169
+ * Critical calls that failed fast with a structured `FLOOD_WAIT_ACTIVE`
170
+ * because the open window exceeded the fail-fast ceiling (part3-design §3).
171
+ */
172
+ failedFast: number
173
+ }
174
+
175
+ export interface SendGateStats {
176
+ enabled: boolean
177
+ global: BucketCounters
178
+ /** Number of live per-message edit states (watch for the H2 leak). */
179
+ messageStates: number
180
+ /** Current token fill of each live bucket (for observability, PR 3). */
181
+ fill: {
182
+ global: number
183
+ perChat: Record<string, number>
184
+ perGroup: Record<string, number>
185
+ }
186
+ }
187
+
188
+ /** A flood window: a scope suppressed until `untilTs` (part3-design §7). */
189
+ export interface FloodWindow {
190
+ /** `global` | `chat:<id>` | `group:<id>` | `msg-edit:<id>`. */
191
+ scopeKey: string
192
+ /** Epoch ms until which the scope admits nothing. */
193
+ untilTs: number
194
+ }
195
+
196
+ /**
197
+ * Boot ramp for the global bucket (part3-design §7): start at a fraction of
198
+ * capacity for `durationMs` after boot so a burst of boot/config cards can't
199
+ * immediately saturate the freshly-full bucket.
200
+ */
201
+ export interface BootRamp {
202
+ /** Fraction of the global burst capacity during the ramp (0..1). Default 0.5. */
203
+ fraction?: number
204
+ /** Ramp duration in ms from gate construction. Default 10_000. */
205
+ durationMs?: number
206
+ }
207
+
208
+ export interface SendGateConfig {
209
+ /** Master switch. When false, `gate()` is a straight passthrough. */
210
+ enabled: boolean
211
+ /** Injected clock; defaults to the real system clock. */
212
+ clock?: Clock
213
+ /** Global bucket sustained rate (tokens/sec). Default 25. */
214
+ globalPerSec?: number
215
+ /**
216
+ * Global burst capacity (headroom under Telegram's ~30/s). Default 4.
217
+ * Worst-case sliding-window admissions = capacity + rate·T, so a full 1s
218
+ * window admits at most `globalBurst + globalPerSec` = 4 + 25 = 29 < 30 —
219
+ * a real margin under the ceiling (#3092 M2 residual: burst 5 hit exactly 30).
220
+ */
221
+ globalBurst?: number
222
+ /** Per-chat sustained rate (tokens/sec). Default 1. */
223
+ perChatPerSec?: number
224
+ /** Per-chat burst capacity. Default 3. */
225
+ perChatBurst?: number
226
+ /** Per-group sustained rate (tokens/min). Default 18. */
227
+ perGroupPerMin?: number
228
+ /** Per-group burst capacity (headroom under 20/min). Default 2. */
229
+ perGroupBurst?: number
230
+ /** Minimum ms between edits of the same message_id. Default 1500. */
231
+ editFloorMs?: number
232
+ /**
233
+ * Flood windows to re-open at construction (part3-design §7). PR 2 loads
234
+ * these from `flood-wait.json` BEFORE the first outbound call so a restart
235
+ * during an active ban does not immediately resend into the flood.
236
+ */
237
+ initialWindows?: FloodWindow[]
238
+ /** Global-bucket boot ramp (part3-design §7). Omit to start full. */
239
+ bootRamp?: BootRamp
240
+ /**
241
+ * TTL (ms) after which an idle per-message edit state is evicted. Default
242
+ * 60_000. Prevents unbounded growth of the perMessage map (#3092 H2).
243
+ */
244
+ messageStateTtlMs?: number
245
+ /**
246
+ * Hard cap on live per-message edit states; oldest-idle are evicted (LRU)
247
+ * once exceeded. Default 5_000.
248
+ */
249
+ maxMessageStates?: number
250
+ /**
251
+ * TTL (ms) a `useful` call may wait in the admission queue before it is
252
+ * dropped as stale (part3-design §2). Default 120_000 (~2 min).
253
+ */
254
+ usefulTtlMs?: number
255
+ /**
256
+ * Ceiling (ms) on an open flood window under which a `critical` send WAITS
257
+ * (single in-flight, jitter); above it, the send fails fast with a structured
258
+ * `FLOOD_WAIT_ACTIVE` carrying `untilTs` so the MCP reply path surfaces a real
259
+ * error instead of a long opaque block (part3-design §3). Default 60_000.
260
+ */
261
+ criticalFailFastMs?: number
262
+ /**
263
+ * Max jitter (ms) added before a `critical` send probes the API during an
264
+ * open (short) window, so serialized criticals don't thunder at the exact
265
+ * window edge. Default 250.
266
+ */
267
+ criticalJitterMaxMs?: number
268
+ /**
269
+ * Injectable 0..1 source for the critical jitter (tests pass `() => 0` for
270
+ * determinism). Default `Math.random`.
271
+ */
272
+ jitter?: () => number
273
+ /**
274
+ * Called whenever `openFloodWindow` opens / extends a window at RUNTIME (on a
275
+ * 429). Write-through persistence hook (part3-design §7) — the gateway wires
276
+ * this to `flood-windows.json` so a window survives a restart. NOT called for
277
+ * `initialWindows` applied at construction (those already came from disk).
278
+ */
279
+ onWindowOpen?: (scopeKey: string, untilTs: number) => void
280
+ }
281
+
282
+ /**
283
+ * Classic token bucket with an optional per-scope suppression window and an
284
+ * optional boot ramp. `tokens` refills continuously at `refillPerMs` up to
285
+ * `capacity` (or `rampCapacity` while inside the ramp). `consume` and
286
+ * `msUntilAvailable` both refill lazily against the supplied `now`, so there is
287
+ * no timer per bucket. A suppression window (`suppressUntil`) blocks all
288
+ * admission until its `untilTs`, regardless of token fill — a token bucket
289
+ * alone cannot represent "blocked until T" (part3-design §7).
290
+ */
291
+ class TokenBucket {
292
+ private tokens: number
293
+ private lastRefillMs: number
294
+ private suppressedUntilMs = 0
295
+ private readonly rampCapacity: number
296
+ private readonly rampUntilMs: number
297
+
298
+ constructor(
299
+ readonly capacity: number,
300
+ private readonly refillPerMs: number,
301
+ now: number,
302
+ ramp?: { capacity: number; untilMs: number },
303
+ ) {
304
+ this.rampCapacity = ramp?.capacity ?? capacity
305
+ this.rampUntilMs = ramp?.untilMs ?? 0
306
+ this.tokens = this.capAt(now)
307
+ this.lastRefillMs = now
308
+ }
309
+
310
+ /** Effective capacity ceiling at `now` (lower during the boot ramp). */
311
+ private capAt(now: number): number {
312
+ return now < this.rampUntilMs ? this.rampCapacity : this.capacity
313
+ }
314
+
315
+ private refill(now: number): void {
316
+ if (now <= this.lastRefillMs) return
317
+ const elapsed = now - this.lastRefillMs
318
+ this.tokens = Math.min(this.capAt(now), this.tokens + elapsed * this.refillPerMs)
319
+ this.lastRefillMs = now
320
+ }
321
+
322
+ /** Open/extend a suppression window (part3-design §7). */
323
+ suppressUntil(untilTs: number): void {
324
+ if (untilTs > this.suppressedUntilMs) this.suppressedUntilMs = untilTs
325
+ }
326
+
327
+ /** Remaining ms of an open suppression window (0 when none). */
328
+ windowRemainingMs(now: number): number {
329
+ return this.suppressedUntilMs > now ? this.suppressedUntilMs - now : 0
330
+ }
331
+
332
+ /**
333
+ * Milliseconds until at least one token is available AND no suppression
334
+ * window is open (0 if admissible now).
335
+ */
336
+ msUntilAvailable(now: number): number {
337
+ const windowWait = this.suppressedUntilMs > now ? this.suppressedUntilMs - now : 0
338
+ this.refill(now)
339
+ const tokenWait = this.tokens >= 1 ? 0 : Math.ceil((1 - this.tokens) / this.refillPerMs)
340
+ return Math.max(windowWait, tokenWait)
341
+ }
342
+
343
+ /** Consume one token. Caller must have checked availability in the SAME tick. */
344
+ consume(now: number): void {
345
+ this.refill(now)
346
+ this.tokens -= 1
347
+ }
348
+
349
+ fill(now: number): number {
350
+ this.refill(now)
351
+ return this.tokens
352
+ }
353
+ }
354
+
355
+ interface PendingEdit {
356
+ hash: string
357
+ fn: () => Promise<unknown>
358
+ promise: Promise<unknown>
359
+ resolve: (v: unknown) => void
360
+ reject: (e: unknown) => void
361
+ /**
362
+ * Effective priority of the CURRENTLY-queued edit for this message. Set when
363
+ * the pending edit is created and UPGRADED (never downgraded) on coalesce, so
364
+ * that a `critical` edit coalescing onto a non-critical driver still gets the
365
+ * critical fail-fast treatment. The driver reads THIS (not the driver-start
366
+ * opts) to decide fail-fast vs unbounded admit (F2, review 2026-07-12).
367
+ */
368
+ priorityClass: PriorityClass
369
+ }
370
+
371
+ /** Total order over priority classes: cosmetic < useful < critical. */
372
+ const PRIORITY_RANK: Record<PriorityClass, number> = {
373
+ cosmetic: 0,
374
+ useful: 1,
375
+ critical: 2,
376
+ }
377
+
378
+ /** Return the HIGHER-priority of two classes (upgrade-only; never downgrades). */
379
+ function maxPriority(a: PriorityClass, b: PriorityClass): PriorityClass {
380
+ return PRIORITY_RANK[b] > PRIORITY_RANK[a] ? b : a
381
+ }
382
+
383
+ interface MessageEditState {
384
+ /** Wall time of the last edit SEND START for this message. */
385
+ lastSentMs: number
386
+ /** Hash of the last payload SUCCESSFULLY sent (for the no-op skip). */
387
+ lastHash: string | undefined
388
+ /** At most one queued coalesced edit per message (last-write-wins). */
389
+ pending: PendingEdit | null
390
+ /** True while a driver is actively sending / waiting for this message. */
391
+ running: boolean
392
+ /** Per-message flood suppression window (part3-design §7). */
393
+ suppressedUntilMs: number
394
+ }
395
+
396
+ function hashPayload(payload: unknown): string {
397
+ let s: string
398
+ if (typeof payload === 'string') {
399
+ s = payload
400
+ } else {
401
+ const j = stableStringify(payload)
402
+ // `stableStringify(undefined)` (and any value that JSON.stringify drops)
403
+ // returns undefined; hash a fixed sentinel so createHash never throws
404
+ // (#3092 L2 — a caller may set editPayload: undefined alongside messageId).
405
+ s = j === undefined ? 'undefined' : j
406
+ }
407
+ return createHash('sha256').update(s).digest('hex')
408
+ }
409
+
410
+ /** Deterministic JSON stringify (sorted keys) so equal payloads hash equal. */
411
+ function stableStringify(value: unknown): string | undefined {
412
+ return JSON.stringify(value, (_k, v) => {
413
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
414
+ return Object.keys(v as Record<string, unknown>)
415
+ .sort()
416
+ .reduce<Record<string, unknown>>((acc, k) => {
417
+ acc[k] = (v as Record<string, unknown>)[k]
418
+ return acc
419
+ }, {})
420
+ }
421
+ return v
422
+ })
423
+ }
424
+
425
+ export interface SendGate {
426
+ /**
427
+ * Run `fn` (a single Bot API call) through the gate. Returns whatever `fn`
428
+ * resolves to. For a dropped no-op edit, resolves to `undefined` without
429
+ * calling `fn`. For a coalesced edit, resolves when the coalesced send
430
+ * completes (with that send's result).
431
+ */
432
+ gate<T>(fn: () => Promise<T>, opts?: SendGateOpts): Promise<T>
433
+ /**
434
+ * Open/extend a flood-suppression window on a scope (part3-design §7). Used
435
+ * by PR 2 on a 429 and at boot from the persisted `flood-wait.json`.
436
+ */
437
+ openFloodWindow(scopeKey: string, untilTs: number): void
438
+ /** Snapshot of counters + current bucket fill. */
439
+ stats(): SendGateStats
440
+ }
441
+
442
+ const GROUP_TYPES = new Set<ChatType>(['group', 'supergroup'])
443
+
444
+ export function createSendGate(config: SendGateConfig): SendGate {
445
+ const enabled = config.enabled
446
+ const clock = config.clock ?? systemClock
447
+ const globalPerSec = config.globalPerSec ?? 25
448
+ const globalBurst = config.globalBurst ?? 4
449
+ const perChatPerSec = config.perChatPerSec ?? 1
450
+ const perChatBurst = config.perChatBurst ?? 3
451
+ const perGroupPerMin = config.perGroupPerMin ?? 18
452
+ const perGroupBurst = config.perGroupBurst ?? 2
453
+ const editFloorMs = config.editFloorMs ?? 1500
454
+ const messageStateTtlMs = config.messageStateTtlMs ?? 60_000
455
+ const maxMessageStates = config.maxMessageStates ?? 5_000
456
+ const usefulTtlMs = config.usefulTtlMs ?? 120_000
457
+ const criticalFailFastMs = config.criticalFailFastMs ?? 60_000
458
+ const criticalJitterMaxMs = config.criticalJitterMaxMs ?? 250
459
+ const jitter = config.jitter ?? Math.random
460
+ const onWindowOpen = config.onWindowOpen
461
+
462
+ const counters: BucketCounters = {
463
+ sent: 0,
464
+ queued: 0,
465
+ coalesced: 0,
466
+ dropped: 0,
467
+ shed: 0,
468
+ expired: 0,
469
+ failedFast: 0,
470
+ }
471
+
472
+ const bootStart = clock.now()
473
+ const globalRamp = config.bootRamp
474
+ ? {
475
+ capacity: Math.max(1, Math.floor(globalBurst * (config.bootRamp.fraction ?? 0.5))),
476
+ untilMs: bootStart + (config.bootRamp.durationMs ?? 10_000),
477
+ }
478
+ : undefined
479
+
480
+ const globalBucket = new TokenBucket(globalBurst, globalPerSec / 1000, bootStart, globalRamp)
481
+ const perChat = new Map<string, TokenBucket>()
482
+ const perGroup = new Map<string, TokenBucket>()
483
+ // H1 (review PR #3106): keyed by `${chat_id}:${messageId}`, NOT messageId
484
+ // alone. Telegram `message_id` is per-chat, not globally unique — two chats
485
+ // routinely both hold a low-numbered card (id 3, 100, …). Keying on the id
486
+ // alone collides cross-chat: chat B's edit overwrites chat A's pending edit
487
+ // (A's real update silently lost), and the edit floor / msg-edit suppression
488
+ // window of one chat sheds an unrelated card in another.
489
+ const perMessage = new Map<string, MessageEditState>()
490
+ let lastSweepMs = bootStart
491
+
492
+ function chatBucket(chatId: string): TokenBucket {
493
+ let b = perChat.get(chatId)
494
+ if (!b) {
495
+ b = new TokenBucket(perChatBurst, perChatPerSec / 1000, clock.now())
496
+ perChat.set(chatId, b)
497
+ }
498
+ return b
499
+ }
500
+
501
+ function groupBucket(chatId: string): TokenBucket {
502
+ let b = perGroup.get(chatId)
503
+ if (!b) {
504
+ b = new TokenBucket(perGroupBurst, perGroupPerMin / 60000, clock.now())
505
+ perGroup.set(chatId, b)
506
+ }
507
+ return b
508
+ }
509
+
510
+ /**
511
+ * Per-message state key: `${chat_id}:${messageId}` (H1). A missing chat_id
512
+ * degrades to `:${messageId}` — still unique per call site, and edits always
513
+ * carry a chat_id in production. This is ALSO the `msg-edit:` scope suffix, so
514
+ * the boot-reloaded scoped windows and the runtime edit floor agree.
515
+ */
516
+ function messageKey(chatId: string | undefined, messageId: number): string {
517
+ return `${chatId ?? ''}:${messageId}`
518
+ }
519
+
520
+ function messageState(key: string): MessageEditState {
521
+ let state = perMessage.get(key)
522
+ if (!state) {
523
+ state = {
524
+ lastSentMs: Number.NEGATIVE_INFINITY,
525
+ lastHash: undefined,
526
+ pending: null,
527
+ running: false,
528
+ suppressedUntilMs: 0,
529
+ }
530
+ perMessage.set(key, state)
531
+ }
532
+ return state
533
+ }
534
+
535
+ /**
536
+ * Apply any flood windows injected at construction (§7 boot load) WITHOUT
537
+ * re-persisting them (they already came from disk). Applied before the first
538
+ * outbound call so a boot mid-ban never resends into an open window.
539
+ */
540
+ for (const w of config.initialWindows ?? []) applyWindow(w.scopeKey, w.untilTs, false)
541
+
542
+ function bucketsFor(opts?: SendGateOpts): TokenBucket[] {
543
+ const buckets: TokenBucket[] = [globalBucket]
544
+ if (opts?.chat_id) {
545
+ buckets.push(chatBucket(opts.chat_id))
546
+ if (opts.chatType && GROUP_TYPES.has(opts.chatType)) {
547
+ buckets.push(groupBucket(opts.chat_id))
548
+ }
549
+ }
550
+ return buckets
551
+ }
552
+
553
+ /**
554
+ * Open/extend a flood window on a scope. `global` suppresses the global
555
+ * bucket; `chat:<id>` / `group:<id>` the per-chat / per-group buckets;
556
+ * `msg-edit:<id>` the per-message edit floor. Idempotent + monotonic
557
+ * (only ever extends). Persists write-through via `onWindowOpen` (part3-design
558
+ * §7) so the window survives a restart.
559
+ */
560
+ function openFloodWindow(scopeKey: string, untilTs: number): void {
561
+ // M3 (review PR #3106): flag-OFF is a PURE no-op. The gateway wires
562
+ // `onFloodWait` → `openFloodWindow('global', …)` unconditionally, so without
563
+ // this guard a 429 would mutate in-memory suppression AND write a new
564
+ // `flood-windows.json` even while the feature is disabled — a real fs side
565
+ // effect and behaviour change before the flag is ever enabled. When
566
+ // disabled the gate must open no window and write no file (the separate
567
+ // #2923 `flood-wait.json` recorder is unaffected — it is not wired here).
568
+ if (!enabled) return
569
+ applyWindow(scopeKey, untilTs, true)
570
+ }
571
+
572
+ /**
573
+ * Open all scope windows implied by a call's `opts` at `untilTs` — global,
574
+ * plus per-chat / per-group / per-message where keyed. Called when a wrapped
575
+ * send surfaces a `FLOOD_WAIT_ACTIVE` (a real 429 or a pre-call
576
+ * short-circuit) so the ban is remembered at the finest scope we know
577
+ * (part3-design §7). Global is always opened as the conservative floor.
578
+ */
579
+ function openScopedWindowsForOpts(opts: SendGateOpts | undefined, untilTs: number): void {
580
+ applyWindow('global', untilTs, true)
581
+ if (opts?.chat_id) {
582
+ applyWindow(`chat:${opts.chat_id}`, untilTs, true)
583
+ if (opts.chatType && GROUP_TYPES.has(opts.chatType)) {
584
+ applyWindow(`group:${opts.chat_id}`, untilTs, true)
585
+ }
586
+ }
587
+ if (opts?.messageId != null) {
588
+ // H1: msg-edit scope keyed by chat_id+messageId so a 429 on one chat's
589
+ // card never sheds another chat's same-id card.
590
+ applyWindow(`msg-edit:${messageKey(opts.chat_id, opts.messageId)}`, untilTs, true)
591
+ }
592
+ }
593
+
594
+ /** Core window application; `persist` gates the write-through hook. */
595
+ function applyWindow(scopeKey: string, untilTs: number, persist: boolean): void {
596
+ if (persist && onWindowOpen) {
597
+ try {
598
+ onWindowOpen(scopeKey, untilTs)
599
+ } catch {
600
+ /* best-effort — persistence must not break the send path */
601
+ }
602
+ }
603
+ if (scopeKey === 'global') {
604
+ globalBucket.suppressUntil(untilTs)
605
+ } else if (scopeKey.startsWith('chat:')) {
606
+ chatBucket(scopeKey.slice('chat:'.length)).suppressUntil(untilTs)
607
+ } else if (scopeKey.startsWith('group:')) {
608
+ groupBucket(scopeKey.slice('group:'.length)).suppressUntil(untilTs)
609
+ } else if (scopeKey.startsWith('msg-edit:')) {
610
+ // H1: the suffix is the composite `${chat_id}:${messageId}` state key.
611
+ const key = scopeKey.slice('msg-edit:'.length)
612
+ if (key) {
613
+ // N2: creating a per-message edit state here must go through the same
614
+ // eviction accounting (maybeEvict / LRU cap) as normal state creation,
615
+ // so opening many per-message flood windows can't grow perMessage past
616
+ // the cap.
617
+ const now = clock.now()
618
+ const existing = perMessage.get(key)
619
+ maybeEvict(now, existing === undefined)
620
+ const st = existing ?? messageState(key)
621
+ if (untilTs > st.suppressedUntilMs) st.suppressedUntilMs = untilTs
622
+ }
623
+ }
624
+ }
625
+
626
+ function isEvictable(s: MessageEditState, now: number): boolean {
627
+ return !s.pending && !s.running && s.suppressedUntilMs <= now
628
+ }
629
+
630
+ /**
631
+ * Bound the perMessage map (#3092 H2). Two mechanisms: a TTL sweep of idle
632
+ * states older than `messageStateTtlMs`, and a hard LRU cap. An entry with a
633
+ * pending/running send or an open suppression window is never evicted.
634
+ */
635
+ function maybeEvict(now: number, reserving: boolean): void {
636
+ // Leave room for the entry about to be created so the map never settles
637
+ // ABOVE the cap (evicting AFTER the insert would perpetually sit at cap+1).
638
+ const target = Math.max(0, maxMessageStates - (reserving ? 1 : 0))
639
+ if (perMessage.size > target) {
640
+ const evictable = [...perMessage.entries()]
641
+ .filter(([, s]) => isEvictable(s, now))
642
+ .sort((a, b) => a[1].lastSentMs - b[1].lastSentMs)
643
+ let over = perMessage.size - target
644
+ for (const [k] of evictable) {
645
+ if (over <= 0) break
646
+ perMessage.delete(k)
647
+ over--
648
+ }
649
+ }
650
+ if (now - lastSweepMs >= messageStateTtlMs) {
651
+ lastSweepMs = now
652
+ for (const [k, s] of perMessage) {
653
+ if (isEvictable(s, now) && now - s.lastSentMs > messageStateTtlMs) {
654
+ perMessage.delete(k)
655
+ }
656
+ }
657
+ }
658
+ }
659
+
660
+ /**
661
+ * Wait until every bucket has a token (and no scope window is open), then
662
+ * consume one from each. The check-and-consume block runs synchronously (no
663
+ * `await` after reading the clock), so concurrent admissions never
664
+ * double-spend. Loops because a competing admission may drain a bucket during
665
+ * our sleep. When `deadline` is set (a `useful` call's TTL), returns `false`
666
+ * without consuming if admission cannot happen before it — the caller drops
667
+ * the call as stale (part3-design §2).
668
+ */
669
+ async function admitLoop(buckets: TokenBucket[], deadline?: number): Promise<boolean> {
670
+ let counted = false
671
+ for (;;) {
672
+ const now = clock.now()
673
+ let wait = 0
674
+ for (const b of buckets) wait = Math.max(wait, b.msUntilAvailable(now))
675
+ if (wait <= 0) {
676
+ for (const b of buckets) b.consume(now)
677
+ return true
678
+ }
679
+ if (deadline !== undefined && now + wait > deadline) return false
680
+ if (!counted) {
681
+ counters.queued++
682
+ counted = true
683
+ }
684
+ await clock.sleep(deadline !== undefined ? Math.min(wait, deadline - now) : wait)
685
+ }
686
+ }
687
+
688
+ /** Back-compat unbounded admission (used by the per-message edit driver). */
689
+ function admit(buckets: TokenBucket[]): Promise<boolean> {
690
+ return admitLoop(buckets)
691
+ }
692
+
693
+ // Serializes `critical` sends that must probe the API during an open (short)
694
+ // flood window: only ONE in-flight at a time so a burst of criticals doesn't
695
+ // thunder at the window edge (part3-design §3, "single in-flight, jitter").
696
+ let criticalTail: Promise<void> = Promise.resolve()
697
+ function criticalSerialize<R>(job: () => Promise<R>): Promise<R> {
698
+ const prev = criticalTail
699
+ let release!: () => void
700
+ criticalTail = new Promise<void>((r) => {
701
+ release = r
702
+ })
703
+ return (async () => {
704
+ await prev
705
+ try {
706
+ return await job()
707
+ } finally {
708
+ release()
709
+ }
710
+ })()
711
+ }
712
+
713
+ type AdmitOutcome =
714
+ | { result: 'ok' }
715
+ | { result: 'shed' }
716
+ | { result: 'expired' }
717
+ | { result: 'failfast'; untilTs: number }
718
+
719
+ /**
720
+ * Priority-aware admission (part3-design §2/§3):
721
+ * - cosmetic — shed immediately when any bucket has a wait (no token OR a
722
+ * window open); otherwise consume and go.
723
+ * - useful — queue with a TTL; drop as stale past the deadline.
724
+ * - critical — never shed. If a window covering the scope exceeds the
725
+ * fail-fast ceiling, fail fast with `untilTs` (caller throws a structured
726
+ * FLOOD_WAIT_ACTIVE). A short window → wait single-in-flight with jitter.
727
+ * No window → queue unbounded.
728
+ */
729
+ async function admitPriority(
730
+ buckets: TokenBucket[],
731
+ priority: PriorityClass,
732
+ ): Promise<AdmitOutcome> {
733
+ const now = clock.now()
734
+ if (priority === 'cosmetic') {
735
+ let wait = 0
736
+ for (const b of buckets) wait = Math.max(wait, b.msUntilAvailable(now))
737
+ if (wait > 0) return { result: 'shed' }
738
+ for (const b of buckets) b.consume(now)
739
+ return { result: 'ok' }
740
+ }
741
+ if (priority === 'critical') {
742
+ let remaining = 0
743
+ for (const b of buckets) remaining = Math.max(remaining, b.windowRemainingMs(now))
744
+ if (remaining > criticalFailFastMs) {
745
+ return { result: 'failfast', untilTs: now + remaining }
746
+ }
747
+ if (remaining > 0) {
748
+ // Degraded but short window: serialize + jitter, then wait it out —
749
+ // re-checking the ceiling EACH loop (admitCriticalLoop) so a window
750
+ // extended past `criticalFailFastMs` while we wait converts to fail-fast
751
+ // instead of blocking the reply path unbounded (M2, review PR #3106).
752
+ return await criticalSerialize(async () => {
753
+ const j = Math.floor(jitter() * criticalJitterMaxMs)
754
+ if (j > 0) await clock.sleep(j)
755
+ return await admitCriticalLoop(buckets)
756
+ })
757
+ }
758
+ return await admitCriticalLoop(buckets)
759
+ }
760
+ // useful (default): TTL-bounded queue.
761
+ const admitted = await admitLoop(buckets, now + usefulTtlMs)
762
+ return admitted ? { result: 'ok' } : { result: 'expired' }
763
+ }
764
+
765
+ /**
766
+ * Unbounded critical admission that RE-EVALUATES the covering window on every
767
+ * iteration (M2, review PR #3106). If a competing 429 extends the window past
768
+ * the fail-fast ceiling while a critical waits out a short window, this stops
769
+ * blocking and returns `failfast` (the caller throws a structured
770
+ * `FLOOD_WAIT_ACTIVE`) rather than blocking the MCP reply path for the whole
771
+ * extended ban — exactly the "opaque long block" part3-design §3 forbids.
772
+ */
773
+ async function admitCriticalLoop(buckets: TokenBucket[]): Promise<AdmitOutcome> {
774
+ let counted = false
775
+ for (;;) {
776
+ const now = clock.now()
777
+ let remaining = 0
778
+ let wait = 0
779
+ for (const b of buckets) {
780
+ remaining = Math.max(remaining, b.windowRemainingMs(now))
781
+ wait = Math.max(wait, b.msUntilAvailable(now))
782
+ }
783
+ if (remaining > criticalFailFastMs) return { result: 'failfast', untilTs: now + remaining }
784
+ if (wait <= 0) {
785
+ for (const b of buckets) b.consume(now)
786
+ return { result: 'ok' }
787
+ }
788
+ if (!counted) {
789
+ counters.queued++
790
+ counted = true
791
+ }
792
+ await clock.sleep(wait)
793
+ }
794
+ }
795
+
796
+ /**
797
+ * The single serialized driver for one message. Only ONE driver runs per
798
+ * message at a time (`state.running`); every edit that arrives while it runs
799
+ * — whether it is sleeping on the floor, waiting on a bucket, or mid-flight
800
+ * on the network (e.g. a 429 retry_after sleep INSIDE `fn`) — coalesces into
801
+ * `state.pending` rather than firing a second overlapping send. This closes
802
+ * the same-tick double-send (M3) and the in-flight parallelism (M4).
803
+ */
804
+ async function drive(state: MessageEditState, opts: SendGateOpts): Promise<void> {
805
+ state.running = true
806
+ try {
807
+ while (state.pending) {
808
+ const now = clock.now()
809
+ const readyAt = Math.max(state.lastSentMs + editFloorMs, state.suppressedUntilMs)
810
+ const waitMs = readyAt - now
811
+ if (waitMs > 0) {
812
+ // Still inside the floor / an open window — sleep, then re-read
813
+ // state.pending (a newer edit may have replaced it in the meantime).
814
+ await clock.sleep(waitMs)
815
+ continue
816
+ }
817
+
818
+ // Floor cleared: take the current pending edit. A newer edit arriving
819
+ // from here on lands in a FRESH state.pending and is handled next loop.
820
+ const p = state.pending
821
+ state.pending = null
822
+
823
+ // Re-check the no-op skip against the last SUCCESSFULLY-sent payload —
824
+ // the latest coalesced payload may have reverted to what's on screen.
825
+ if (p.hash === state.lastHash) {
826
+ counters.dropped++
827
+ p.resolve(undefined)
828
+ continue
829
+ }
830
+
831
+ // A `critical` edit must NEVER block unbounded (part3-design §3). Mirror
832
+ // the non-edit critical path in `gate`: admit via the priority-aware loop
833
+ // so a flood-wait ban longer than the fail-fast ceiling rejects with a
834
+ // structured FLOOD_WAIT_ACTIVE — and a window that EXTENDS past the
835
+ // ceiling while we wait out a short window converts to fail-fast too (M2,
836
+ // review PR #3106) — instead of the unbounded `admit` below, which would
837
+ // re-introduce the multi-hour reply wedge the send-gate exists to
838
+ // eliminate (F4, review 2026-07-11). Non-critical edits keep PR 1's
839
+ // unbounded coalescing admit unchanged.
840
+ //
841
+ // Read the CURRENT pending edit's class (`p.priorityClass`), NOT the
842
+ // driver-start `opts`: a `critical` edit that coalesced onto a driver
843
+ // started by a non-critical edit upgraded `p.priorityClass`, and MUST
844
+ // fail-fast here rather than ride the non-critical unbounded admit for
845
+ // the whole ban (F2, review 2026-07-12).
846
+ if (p.priorityClass === 'critical') {
847
+ const outcome = await admitPriority(bucketsFor(opts), 'critical')
848
+ if (outcome.result === 'failfast') {
849
+ counters.failedFast++
850
+ const retryAfterSec = Math.ceil((outcome.untilTs - clock.now()) / 1000)
851
+ openScopedWindowsForOpts(opts, outcome.untilTs)
852
+ // Reject THIS edit's own promise (fail fast) and loop: a distinct
853
+ // newer edit that arrived during the wait owns a fresh state.pending
854
+ // and is handled on the next iteration.
855
+ p.reject(makeFloodWaitActiveError(retryAfterSec, outcome.untilTs, null))
856
+ continue
857
+ }
858
+ // outcome.result === 'ok' → admitPriority already consumed the buckets.
859
+ } else {
860
+ await admit(bucketsFor(opts))
861
+ }
862
+ // Reserve the send-start time BEFORE awaiting the network so the floor
863
+ // is measured from send start (matches the per-message serialization).
864
+ state.lastSentMs = clock.now()
865
+ try {
866
+ // N3 (liveness): this awaits `p.fn()` with no watchdog. A `fn` that
867
+ // NEVER settles would keep `state.running` true forever, making the
868
+ // state unevictable and hanging every coalesced caller. We rely on
869
+ // the fact that the only production caller is `robustApiCall`, whose
870
+ // own request/retry timeouts bound every send — so `p.fn()` is
871
+ // guaranteed to settle. No separate watchdog is needed here.
872
+ const res = await p.fn()
873
+ // M1: only record the payload as on-screen AFTER a successful send,
874
+ // so a FAILED edit can be retried with the same payload (not dropped
875
+ // as a phantom no-op).
876
+ state.lastHash = p.hash
877
+ counters.sent++
878
+ p.resolve(res)
879
+ } catch (err) {
880
+ // A 429 surfaced from the edit send opens the flood windows at this
881
+ // scope so later cosmetic edits shed and the window persists (§3/§7).
882
+ if (isFloodWaitActiveError(err)) {
883
+ openScopedWindowsForOpts(opts, err.untilTs)
884
+ }
885
+ // H1: reject THIS edit's own promise (the closure-local `p`), never
886
+ // state.pending — a distinct newer edit that arrived during the send
887
+ // owns state.pending and must survive to be sent on the next loop.
888
+ p.reject(err)
889
+ }
890
+ }
891
+ } finally {
892
+ state.running = false
893
+ }
894
+ }
895
+
896
+ function handleEdit<T>(fn: () => Promise<T>, opts: SendGateOpts): Promise<T> {
897
+ const messageId = opts.messageId as number
898
+ const key = messageKey(opts.chat_id, messageId)
899
+ const hash = hashPayload(opts.editPayload)
900
+ const now = clock.now()
901
+ const existing = perMessage.get(key)
902
+ maybeEvict(now, existing === undefined)
903
+ const state = existing ?? messageState(key)
904
+
905
+ // Cosmetic edits (part3-design §2) shed under pressure — a flood window
906
+ // covering the scope is open, or a bucket has no token free right now. A
907
+ // dropped edit costs nothing: the next edit carries the full state. Only an
908
+ // EXPLICITLY-cosmetic edit sheds; untagged edits keep PR 1's coalescing
909
+ // behaviour (default = useful), so this never changes an untagged caller.
910
+ if ((opts.priorityClass ?? 'useful') === 'cosmetic') {
911
+ let wait = 0
912
+ for (const b of bucketsFor(opts)) wait = Math.max(wait, b.msUntilAvailable(now))
913
+ const msgWait = state.suppressedUntilMs > now ? state.suppressedUntilMs - now : 0
914
+ if (wait > 0 || msgWait > 0) {
915
+ counters.shed++
916
+ return Promise.resolve(undefined as unknown as T)
917
+ }
918
+ }
919
+
920
+ // N1: the coalesce (last-write-wins) check runs BEFORE the no-op skip. When
921
+ // a distinct edit is already queued, the newest payload always replaces it —
922
+ // even if that payload reverts to the on-screen one (hash === lastHash).
923
+ // Otherwise a revert-to-on-screen edit would be dropped as a no-op while the
924
+ // stale queued edit still rendered, violating last-write-wins. When the
925
+ // driver dequeues, its own no-op re-check (`p.hash === state.lastHash`) drops
926
+ // a coalesced revert so nothing needless hits the API. All callers share the
927
+ // single pending promise, which resolves with the coalesced send's result.
928
+ if (state.pending) {
929
+ // Upgrade (never downgrade) the queued edit's effective priority. A
930
+ // `critical` edit coalescing onto a `useful`/`cosmetic` pending edit must
931
+ // ride the driver's critical fail-fast path — otherwise the critical work
932
+ // rides the non-critical unbounded admit and blocks for a whole flood ban
933
+ // (F2, review 2026-07-12). We upgrade even when the hash is unchanged (a
934
+ // no-op payload from a critical caller still deserves fail-fast, not an
935
+ // unbounded block); a lower-priority coalesce leaves the class intact.
936
+ state.pending.priorityClass = maxPriority(
937
+ state.pending.priorityClass,
938
+ opts.priorityClass ?? 'useful',
939
+ )
940
+ if (state.pending.hash !== hash) {
941
+ counters.coalesced++
942
+ state.pending.hash = hash
943
+ state.pending.fn = fn as () => Promise<unknown>
944
+ }
945
+ return state.pending.promise as Promise<T>
946
+ }
947
+
948
+ // No edit queued → a repeat of the last payload we actually sent is a plain
949
+ // no-op skip: drop it before the API.
950
+ if (hash === state.lastHash) {
951
+ counters.dropped++
952
+ return Promise.resolve(undefined as unknown as T)
953
+ }
954
+
955
+ // Otherwise create a fresh pending edit. If no driver is currently running
956
+ // for this message, start one; if one IS running (mid-flight send, floor
957
+ // sleep, or bucket wait), it will pick this up on its next loop — this is
958
+ // what serializes sends per message (M3/M4).
959
+ let resolve!: (v: unknown) => void
960
+ let reject!: (e: unknown) => void
961
+ const promise = new Promise<unknown>((res, rej) => {
962
+ resolve = res
963
+ reject = rej
964
+ })
965
+ const pending: PendingEdit = {
966
+ hash,
967
+ fn: fn as () => Promise<unknown>,
968
+ promise,
969
+ resolve,
970
+ reject,
971
+ priorityClass: opts.priorityClass ?? 'useful',
972
+ }
973
+ state.pending = pending
974
+ if (!state.running) void drive(state, opts)
975
+ return promise as Promise<T>
976
+ }
977
+
978
+ async function gate<T>(fn: () => Promise<T>, opts?: SendGateOpts): Promise<T> {
979
+ // Flag OFF → pure passthrough, zero behaviour change.
980
+ if (!enabled) return fn()
981
+
982
+ // Edit path (floor + coalescing + no-op skip) only when we can key it.
983
+ if (opts && opts.messageId != null && 'editPayload' in opts) {
984
+ return handleEdit(fn, opts)
985
+ }
986
+
987
+ // Priority-aware admission (part3-design §2/§3). Untagged → critical
988
+ // (non-droppable — L2, review PR #3106); tag `useful`/`cosmetic` to opt into
989
+ // shedding.
990
+ const priority = opts?.priorityClass ?? UNTAGGED_SEND_CLASS
991
+ const outcome = await admitPriority(bucketsFor(opts), priority)
992
+ if (outcome.result === 'shed') {
993
+ counters.shed++
994
+ return undefined as unknown as T
995
+ }
996
+ if (outcome.result === 'expired') {
997
+ counters.expired++
998
+ return undefined as unknown as T
999
+ }
1000
+ if (outcome.result === 'failfast') {
1001
+ counters.failedFast++
1002
+ // Compose #3094's structured error so the MCP reply path surfaces a real
1003
+ // flood_wait (untilTs / retry_after) instead of an opaque long block.
1004
+ const retryAfterSec = Math.ceil((outcome.untilTs - clock.now()) / 1000)
1005
+ openScopedWindowsForOpts(opts, outcome.untilTs)
1006
+ throw makeFloodWaitActiveError(retryAfterSec, outcome.untilTs, null)
1007
+ }
1008
+
1009
+ try {
1010
+ // N4: count `sent` only AFTER a successful send, mirroring the edit path.
1011
+ const res = await fn()
1012
+ counters.sent++
1013
+ return res
1014
+ } catch (err) {
1015
+ // A 429 surfaced from a non-edit send opens the scope's flood windows so
1016
+ // subsequent cosmetic traffic sheds and the window persists (§3/§7).
1017
+ if (isFloodWaitActiveError(err)) openScopedWindowsForOpts(opts, err.untilTs)
1018
+ throw err
1019
+ }
1020
+ }
1021
+
1022
+ function stats(): SendGateStats {
1023
+ const now = clock.now()
1024
+ const perChatFill: Record<string, number> = {}
1025
+ for (const [k, b] of perChat) perChatFill[k] = b.fill(now)
1026
+ const perGroupFill: Record<string, number> = {}
1027
+ for (const [k, b] of perGroup) perGroupFill[k] = b.fill(now)
1028
+ return {
1029
+ enabled,
1030
+ global: { ...counters },
1031
+ messageStates: perMessage.size,
1032
+ fill: {
1033
+ global: globalBucket.fill(now),
1034
+ perChat: perChatFill,
1035
+ perGroup: perGroupFill,
1036
+ },
1037
+ }
1038
+ }
1039
+
1040
+ return { gate, openFloodWindow, stats }
1041
+ }
1042
+
1043
+ /**
1044
+ * The send gate is an ESCAPE HATCH, not an opt-in feature: it is ON BY DEFAULT
1045
+ * in every install and can be disabled as a safety valve. Mirrors the repo's
1046
+ * default-on kill-switch convention (`midTurnFloorEnabled`, `PIN_STATUS_WHILE_
1047
+ * WORKING`, `SWITCHROOM_RATE_LIMIT_OVERAGE`): enabled unless
1048
+ * `SWITCHROOM_TELEGRAM_SEND_GATE` is explicitly set to a falsey/off value
1049
+ * (`0`/`false`/`off`/`no`, case-insensitive, trimmed). Unset → enabled.
1050
+ */
1051
+ export function sendGateEnabledFromEnv(env: NodeJS.ProcessEnv = process.env): boolean {
1052
+ const v = env.SWITCHROOM_TELEGRAM_SEND_GATE
1053
+ if (v == null) return true
1054
+ const t = v.trim().toLowerCase()
1055
+ return !(t === '0' || t === 'false' || t === 'off' || t === 'no')
1056
+ }