switchroom 0.19.26 → 0.19.27

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 (77) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +55 -0
  3. package/dist/auth-broker/index.js +116 -6
  4. package/dist/cli/notion-write-pretool.mjs +55 -0
  5. package/dist/cli/switchroom.js +2055 -1193
  6. package/dist/host-control/main.js +117 -7
  7. package/dist/vault/approvals/kernel-server.js +114 -6
  8. package/dist/vault/broker/server.js +114 -6
  9. package/package.json +6 -2
  10. package/profiles/_base/cron-session.sh.hbs +8 -0
  11. package/profiles/_base/start.sh.hbs +105 -0
  12. package/telegram-plugin/card-layout.ts +328 -0
  13. package/telegram-plugin/dist/bridge/bridge.js +93 -1
  14. package/telegram-plugin/dist/gateway/gateway.js +2009 -1166
  15. package/telegram-plugin/dist/server.js +96 -1
  16. package/telegram-plugin/edit-flood-fuse.ts +637 -56
  17. package/telegram-plugin/flood-429-ledger.ts +526 -0
  18. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  20. package/telegram-plugin/gateway/gateway.ts +58 -68
  21. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  22. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  23. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  24. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  25. package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
  26. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  27. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  28. package/telegram-plugin/llm-error-present.ts +61 -2
  29. package/telegram-plugin/model-unavailable.ts +8 -0
  30. package/telegram-plugin/operator-events.ts +72 -5
  31. package/telegram-plugin/outbound-class.ts +81 -0
  32. package/telegram-plugin/provider-credit.ts +237 -0
  33. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  34. package/telegram-plugin/send-gate.ts +24 -2
  35. package/telegram-plugin/status-no-truncate.ts +10 -48
  36. package/telegram-plugin/status-pin-driver.ts +33 -45
  37. package/telegram-plugin/status-pin.ts +18 -1
  38. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  39. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  40. package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
  41. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  42. package/telegram-plugin/tests/card-variants.ts +366 -0
  43. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
  44. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  45. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  46. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  47. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  48. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  49. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  50. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  51. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  52. package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
  53. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  54. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  55. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  56. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  57. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  58. package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
  59. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  60. package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
  61. package/telegram-plugin/tests/status-pin.test.ts +56 -5
  62. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  63. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  64. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
  65. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  66. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  67. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  68. package/telegram-plugin/tool-activity-summary.ts +239 -365
  69. package/telegram-plugin/uat/assertions.ts +22 -11
  70. package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
  71. package/telegram-plugin/worker-activity-feed.ts +105 -47
  72. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  73. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  74. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  75. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  76. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  77. package/vendor/hindsight-memory/settings.json +1 -1
@@ -0,0 +1,526 @@
1
+ /**
2
+ * Telegram 429 pressure ledger — the "was anything watching?" half of the
3
+ * 2026-07-27 flood-ban incident.
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * On 2026-07-27T20:19:57Z an agent's bot token took a `retry_after=15908`
8
+ * (4.4 hour) flood ban. The *rate* cause was fixed separately (#3847,
9
+ * edit-flood-fuse ceilings + outbound class propagation). This module closes
10
+ * the second, independent gap: **nothing observed the run-up**. Telegram
11
+ * escalates its penalty as a bot keeps tripping the limit, so the size of
12
+ * `parameters.retry_after` over the preceding days is a genuine leading
13
+ * indicator of a multi-hour outage — and it was sitting unexamined in a log
14
+ * file the whole time.
15
+ *
16
+ * `flood-circuit-breaker.ts` already sees every single 429: `retryApiCall`
17
+ * calls `onFloodWait(retry_after, opts)` (telegram-plugin/retry-api-call.ts,
18
+ * in the `error_code === 429` branch) BEFORE either of its two log lines, and
19
+ * every wiring of that hook goes through `makeFloodWaitRecorder`. But the
20
+ * breaker keeps only the CURRENT window in `flood-wait.json` — each 429
21
+ * overwrites the last, so history is destroyed at exactly the moment it
22
+ * becomes evidence. This ledger is the sibling file that keeps it.
23
+ *
24
+ * ## What is recorded — episodes, not observations
25
+ *
26
+ * A single ban is re-observed on every subsequent send: the 2026-07-27 ban
27
+ * produced ~100 log lines in 8 minutes, each with a `retry_after` 5s smaller
28
+ * than the last (the same window, counting down). Storing raw observations
29
+ * would let one ban evict weeks of history from any bounded file, and would
30
+ * make any count-based signal explode during the outage it is supposed to
31
+ * predict.
32
+ *
33
+ * So the write path folds observations into EPISODES, keyed on the implied
34
+ * ban expiry (`ts + retry_after`). Two observations belong to the same
35
+ * episode when they imply the same expiry (within `EPISODE_MERGE_MS`) or when
36
+ * the later one lands inside the earlier one's still-open window. Validated
37
+ * against the real incident: `20:19:56.704 + 15908s` and
38
+ * `21:00:36.999 + 13468s` both resolve to `2026-07-28T00:45:04Z` — one
39
+ * episode, 101 observations.
40
+ *
41
+ * ## What is NOT here
42
+ *
43
+ * Interpretation is deliberately split from recording, but both live in this
44
+ * file so the plugin (writer) and `switchroom doctor` (reader) can never
45
+ * disagree about what a ledger means. `src/cli/doctor-flood-pressure.ts`
46
+ * imports the pure classifier below; it does not reimplement it.
47
+ */
48
+
49
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync, unlinkSync } from 'node:fs'
50
+ import { dirname, join } from 'node:path'
51
+
52
+ /** One observed 429, as handed to the `onFloodWait` hook. */
53
+ export interface Flood429Observation {
54
+ /** Epoch ms the 429 was seen. */
55
+ ts: number
56
+ /** Telegram's `parameters.retry_after`, in seconds. */
57
+ retryAfterSec: number
58
+ }
59
+
60
+ /**
61
+ * A collapsed run of observations that all describe the SAME Telegram ban
62
+ * window. `peakRetryAfterSec` is the headline number (the ban as Telegram
63
+ * first stated it); `count` preserves how hard we kept knocking.
64
+ */
65
+ export interface Flood429Episode {
66
+ /** Epoch ms of the first observation folded into this episode. */
67
+ firstTs: number
68
+ /** Epoch ms of the most recent observation folded into this episode. */
69
+ lastTs: number
70
+ /** Largest `retry_after` (seconds) seen for this window. */
71
+ peakRetryAfterSec: number
72
+ /** Latest implied ban expiry (epoch ms) — the episode's merge key. */
73
+ untilTs: number
74
+ /** How many 429s were folded in. */
75
+ count: number
76
+ }
77
+
78
+ /** Ledger filename inside the agent's telegram state dir. */
79
+ export const FLOOD_429_LEDGER_FILE = '429-ledger.json'
80
+
81
+ /**
82
+ * 0644 — deliberately world-READABLE, for the same reason
83
+ * `FLOOD_STATE_MODE` is (see flood-circuit-breaker.ts): the telegram state
84
+ * dir is shared by processes running under different uids (a `root:` agent's
85
+ * gateway is uid 0, a normal agent's is its allocated uid), and `switchroom
86
+ * doctor` reads this file from the host as yet another uid. The payload is
87
+ * integers; there is nothing here worth 0600.
88
+ */
89
+ export const FLOOD_429_LEDGER_MODE = 0o644
90
+
91
+ /**
92
+ * Two observations belong to the same episode when their implied expiries are
93
+ * within this much of each other. 60s absorbs both the countdown drift of a
94
+ * long ban re-observed every ~5s and the sub-second jitter of a short one
95
+ * retried twice.
96
+ */
97
+ export const EPISODE_MERGE_MS = 60_000
98
+
99
+ /** Hard cap on stored episodes — oldest-first eviction. */
100
+ export const FLOOD_429_LEDGER_MAX_EPISODES = 400
101
+
102
+ /** Episodes older than this are dropped on write. */
103
+ export const FLOOD_429_LEDGER_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
104
+
105
+ /**
106
+ * At or below this `retry_after`, Telegram is nudging a per-second send rate,
107
+ * not imposing a penalty: `retryApiCall` sleeps it and the send succeeds, and
108
+ * no human ever notices.
109
+ *
110
+ * Calibrated against 16 days of real gateway logs (2026-07-12 → 2026-07-27,
111
+ * 170 observations): the modal value is exactly `3`, the largest benign one is
112
+ * `5`, and the smallest value belonging to an actual outage is `282`. There is
113
+ * no observed traffic between 5 and 282, so 60 sits in a wide empty band —
114
+ * this threshold is not fitted to a boundary case.
115
+ */
116
+ export const TRIVIAL_RETRY_AFTER_SEC = 60
117
+
118
+ /**
119
+ * At or above this `retry_after`, the bot is silent for long enough that the
120
+ * operator experiences it as an outage rather than a hiccup. 600s = 10 minutes.
121
+ */
122
+ export const SEVERE_RETRY_AFTER_SEC = 600
123
+
124
+ /**
125
+ * How far back penalty episodes are considered. Telegram's escalation memory
126
+ * is multi-day: the 2026-07-25T23:43Z ban (3713s) and the 2026-07-27T20:19Z
127
+ * ban (15908s, a 4.3x escalation) are 44 hours apart. A 24h window would have
128
+ * treated the second as a first offence.
129
+ */
130
+ export const PENALTY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000
131
+
132
+ /** Window for the low-grade "constantly at the rate limit" pressure signal. */
133
+ export const TRIVIAL_PRESSURE_WINDOW_MS = 24 * 60 * 60 * 1000
134
+
135
+ /**
136
+ * Trivial 429s in 24h that earn a WARN on their own.
137
+ *
138
+ * The busiest benign day in the 16-day sample (2026-07-19) logged 12 trivial
139
+ * 429s, so 20 clears the observed background with headroom and never fires on
140
+ * a healthy agent. This is the only count-based rule in the classifier, and it
141
+ * is deliberately the WEAKEST tier — see `classifyFlood429Pressure`.
142
+ */
143
+ export const TRIVIAL_PRESSURE_WARN_COUNT = 20
144
+
145
+ /** Resolve the ledger path from a telegram state dir. */
146
+ export function flood429LedgerPath(stateDir: string): string {
147
+ return join(stateDir, FLOOD_429_LEDGER_FILE)
148
+ }
149
+
150
+ /**
151
+ * Resolve the ledger path from the flood-wait marker path, so the recorder
152
+ * can derive one from the other without a second env lookup.
153
+ */
154
+ export function flood429LedgerPathFromFloodState(floodStatePath: string): string {
155
+ return join(dirname(floodStatePath), FLOOD_429_LEDGER_FILE)
156
+ }
157
+
158
+ /** True when `obs` describes the same ban window as `ep`. */
159
+ function belongsToEpisode(ep: Flood429Episode, obs: Flood429Observation): boolean {
160
+ const impliedUntil = obs.ts + Math.max(0, obs.retryAfterSec) * 1000
161
+ if (Math.abs(impliedUntil - ep.untilTs) <= EPISODE_MERGE_MS) return true
162
+ // Observed while the episode's window was still open — by definition the
163
+ // same ban (a fresh, unrelated ban cannot start inside an open one).
164
+ return obs.ts >= ep.firstTs && obs.ts <= ep.untilTs
165
+ }
166
+
167
+ /**
168
+ * Pure: fold one observation into an episode list, then prune.
169
+ *
170
+ * Only the LAST episode is considered for merging — observations arrive in
171
+ * time order, so an out-of-order merge would mean a clock jump, and inventing
172
+ * a repair for that would hide it. Returns a new array; never mutates input.
173
+ */
174
+ export function foldFlood429Observation(
175
+ episodes: readonly Flood429Episode[],
176
+ obs: Flood429Observation,
177
+ now: number = obs.ts,
178
+ ): Flood429Episode[] {
179
+ const retryAfterSec = Math.max(0, Math.floor(obs.retryAfterSec))
180
+ const impliedUntil = obs.ts + retryAfterSec * 1000
181
+ const next = episodes.slice()
182
+ const last = next[next.length - 1]
183
+
184
+ if (last && belongsToEpisode(last, obs)) {
185
+ next[next.length - 1] = {
186
+ firstTs: Math.min(last.firstTs, obs.ts),
187
+ lastTs: Math.max(last.lastTs, obs.ts),
188
+ peakRetryAfterSec: Math.max(last.peakRetryAfterSec, retryAfterSec),
189
+ untilTs: Math.max(last.untilTs, impliedUntil),
190
+ count: last.count + 1,
191
+ }
192
+ } else {
193
+ next.push({
194
+ firstTs: obs.ts,
195
+ lastTs: obs.ts,
196
+ peakRetryAfterSec: retryAfterSec,
197
+ untilTs: impliedUntil,
198
+ count: 1,
199
+ })
200
+ }
201
+
202
+ return pruneFlood429Episodes(next, now)
203
+ }
204
+
205
+ /** Pure: drop episodes past the retention window, then cap the count. */
206
+ export function pruneFlood429Episodes(
207
+ episodes: readonly Flood429Episode[],
208
+ now: number,
209
+ ): Flood429Episode[] {
210
+ const floor = now - FLOOD_429_LEDGER_RETENTION_MS
211
+ const kept = episodes.filter((e) => e.lastTs >= floor)
212
+ return kept.length > FLOOD_429_LEDGER_MAX_EPISODES
213
+ ? kept.slice(kept.length - FLOOD_429_LEDGER_MAX_EPISODES)
214
+ : kept
215
+ }
216
+
217
+ /** Coerce one parsed JSON entry into an episode, or null if it is junk. */
218
+ function parseEpisode(raw: unknown): Flood429Episode | null {
219
+ if (typeof raw !== 'object' || raw === null) return null
220
+ const r = raw as Record<string, unknown>
221
+ const num = (v: unknown): number | null =>
222
+ typeof v === 'number' && Number.isFinite(v) ? v : null
223
+ const firstTs = num(r.firstTs)
224
+ const untilTs = num(r.untilTs)
225
+ const peak = num(r.peakRetryAfterSec)
226
+ if (firstTs === null || untilTs === null || peak === null) return null
227
+ return {
228
+ firstTs,
229
+ lastTs: num(r.lastTs) ?? firstTs,
230
+ peakRetryAfterSec: peak,
231
+ untilTs,
232
+ count: num(r.count) ?? 1,
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Why a read produced no episodes. `absent` is the only status that honestly
238
+ * means "this agent has never been 429'd"; the others mean the ledger exists
239
+ * and we could not use it, which is a different thing an operator must be
240
+ * told about. Conflating the two is the bug #3106 exists to fix, and the same
241
+ * trap applies here: a silently-unreadable ledger looks exactly like a
242
+ * perfectly healthy agent.
243
+ */
244
+ export type Flood429ReadStatus = 'ok' | 'absent' | 'corrupt' | 'unreadable'
245
+
246
+ export interface Flood429ReadResult {
247
+ status: Flood429ReadStatus
248
+ episodes: Flood429Episode[]
249
+ /** Populated for `corrupt` / `unreadable`. */
250
+ error?: string
251
+ }
252
+
253
+ /**
254
+ * Read the ledger, reporting WHY there are no episodes.
255
+ *
256
+ * No `existsSync` pre-check — it is a `stat`, not an `access`, so it cannot
257
+ * separate "missing" from "unreadable", and pre-checking would race. We read
258
+ * and classify the error instead.
259
+ */
260
+ export function readFlood429LedgerResult(
261
+ path: string,
262
+ readFile: (p: string) => string = (p) => readFileSync(p, 'utf-8'),
263
+ ): Flood429ReadResult {
264
+ let text: string
265
+ try {
266
+ text = readFile(path)
267
+ } catch (err) {
268
+ const code = (err as NodeJS.ErrnoException | undefined)?.code
269
+ if (code === 'ENOENT' || code === 'ENOTDIR') return { status: 'absent', episodes: [] }
270
+ return {
271
+ status: 'unreadable',
272
+ episodes: [],
273
+ error: `${code ?? 'EUNKNOWN'}: ${(err as Error)?.message ?? String(err)}`,
274
+ }
275
+ }
276
+ let parsed: unknown
277
+ try {
278
+ parsed = JSON.parse(text)
279
+ } catch (err) {
280
+ return { status: 'corrupt', episodes: [], error: (err as Error)?.message ?? String(err) }
281
+ }
282
+ if (!Array.isArray(parsed)) {
283
+ return { status: 'corrupt', episodes: [], error: 'ledger is not a JSON array' }
284
+ }
285
+ const out: Flood429Episode[] = []
286
+ let dropped = 0
287
+ for (const raw of parsed) {
288
+ const ep = parseEpisode(raw)
289
+ if (ep) out.push(ep)
290
+ else dropped += 1
291
+ }
292
+ if (dropped > 0 && out.length === 0) {
293
+ return { status: 'corrupt', episodes: [], error: `${dropped} unparseable entr(ies)` }
294
+ }
295
+ return { status: 'ok', episodes: out }
296
+ }
297
+
298
+ /**
299
+ * Read the ledger, yielding whatever episodes are parseable.
300
+ *
301
+ * The write path uses this: a diagnostic record that cannot be read must
302
+ * never break the send path, and starting a fresh ledger is strictly better
303
+ * than throwing out of `onFloodWait`. Anything making an operator-facing
304
+ * JUDGEMENT should use `readFlood429LedgerResult` so "no ban" and "cannot
305
+ * tell" stay distinguishable.
306
+ */
307
+ export function readFlood429Ledger(
308
+ path: string,
309
+ readFile: (p: string) => string = (p) => readFileSync(p, 'utf-8'),
310
+ ): Flood429Episode[] {
311
+ return readFlood429LedgerResult(path, readFile).episodes
312
+ }
313
+
314
+ /**
315
+ * Persist the ledger, self-healing a file left unowned by another uid.
316
+ *
317
+ * Mirrors `writeFloodState`'s two heals for the same reason (issue #3106):
318
+ * `mode` only applies at create time, and the state dir is shared across
319
+ * uids. Best-effort throughout — a failed diagnostic write must never
320
+ * propagate into the send path.
321
+ */
322
+ export function writeFlood429Ledger(
323
+ path: string,
324
+ episodes: readonly Flood429Episode[],
325
+ log: (line: string) => void = (l) => process.stderr.write(l),
326
+ ): void {
327
+ const payload = JSON.stringify(episodes)
328
+ try {
329
+ mkdirSync(dirname(path), { recursive: true })
330
+ } catch {
331
+ /* best-effort */
332
+ }
333
+ try {
334
+ writeFileSync(path, payload, { mode: FLOOD_429_LEDGER_MODE })
335
+ try {
336
+ chmodSync(path, FLOOD_429_LEDGER_MODE)
337
+ } catch {
338
+ /* not the owner — the read path degrades to [] */
339
+ }
340
+ return
341
+ } catch (err) {
342
+ const code = (err as NodeJS.ErrnoException | undefined)?.code
343
+ if (code !== 'EACCES' && code !== 'EPERM') {
344
+ log(`telegram gateway: 429-ledger: could not persist ${path} (${code ?? 'error'})\n`)
345
+ return
346
+ }
347
+ try {
348
+ unlinkSync(path)
349
+ writeFileSync(path, payload, { mode: FLOOD_429_LEDGER_MODE })
350
+ } catch (err2) {
351
+ log(
352
+ `telegram gateway: 429-ledger: cannot persist the 429 pressure ledger to ${path} ` +
353
+ `(${code}, recreate failed: ${(err2 as Error)?.message ?? String(err2)}). ` +
354
+ `Flood-pressure history will be missing from \`switchroom doctor\`\n`,
355
+ )
356
+ }
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Record one observed 429 into the ledger at `path`. Read-fold-write; safe to
362
+ * call at the ~5s cadence a long ban produces, because the fold collapses
363
+ * those into a single episode instead of growing the file.
364
+ *
365
+ * Read-fold-write is not atomic across processes: the gateway and the MCP
366
+ * server's `createRobustApiCall` share one state dir, so a simultaneous 429 in
367
+ * both could drop one episode. That is deliberate — the alternative is a lock
368
+ * on the send path's error handler, and the cost of losing an episode is
369
+ * nil: a penalty stays on the ledger for seven days and is re-recorded by the
370
+ * next 429, so no verdict tier turns on a single write landing.
371
+ */
372
+ export function recordFlood429(
373
+ path: string,
374
+ obs: Flood429Observation,
375
+ log: (line: string) => void = (l) => process.stderr.write(l),
376
+ ): void {
377
+ const folded = foldFlood429Observation(readFlood429Ledger(path), obs, obs.ts)
378
+ writeFlood429Ledger(path, folded, log)
379
+ }
380
+
381
+ // ─── Interpretation ─────────────────────────────────────────────────────────
382
+
383
+ /** Verdict tiers, matching `switchroom doctor`'s `CheckStatus`. */
384
+ export type Flood429Status = 'warn' | 'fail'
385
+
386
+ export interface Flood429Verdict {
387
+ status: Flood429Status
388
+ /** Stable machine-readable reason, so tests assert intent not prose. */
389
+ reason:
390
+ | 'ban_open'
391
+ | 'repeat_penalty'
392
+ | 'severe_penalty'
393
+ | 'single_penalty'
394
+ | 'trivial_pressure'
395
+ detail: string
396
+ /** Penalty episodes inside the window, oldest first. */
397
+ penalties: Flood429Episode[]
398
+ /** Trivial 429 observations inside the 24h pressure window. */
399
+ trivialCount24h: number
400
+ }
401
+
402
+ /** True when this episode is a real Telegram penalty, not a rate nudge. */
403
+ export function isPenaltyEpisode(ep: Flood429Episode): boolean {
404
+ return ep.peakRetryAfterSec > TRIVIAL_RETRY_AFTER_SEC
405
+ }
406
+
407
+ function fmtDuration(sec: number): string {
408
+ if (sec < 90) return `${sec}s`
409
+ if (sec < 5400) return `${Math.round(sec / 60)}min`
410
+ return `${(sec / 3600).toFixed(1)}h`
411
+ }
412
+
413
+ /**
414
+ * Pure: turn a ledger into an operator verdict, or `null` when nothing is
415
+ * worth saying.
416
+ *
417
+ * The tiers, strongest first — every one of them is about the SIZE of the
418
+ * penalty, not the number of 429s, because raw count is actively misleading:
419
+ * in the real 16-day sample the highest-count day (2026-07-19, 12 events) was
420
+ * entirely benign, while the day that preceded the 4.4h outage
421
+ * (2026-07-25, 4 events) contained a 62-minute ban.
422
+ *
423
+ * 1. `ban_open` — FAIL. A window recorded here has not expired: the
424
+ * bot is silenced RIGHT NOW.
425
+ * 2. `repeat_penalty` — FAIL. Two or more penalty episodes in 7 days.
426
+ * Telegram escalates on repeat, so a second penalty
427
+ * predicts a bigger third. (2026-07-12 21397s →
428
+ * 2026-07-13 27951s; 2026-07-25 3713s →
429
+ * 2026-07-27 15908s.)
430
+ * 3. `severe_penalty` — FAIL. One penalty ≥ 10 minutes. Already an outage,
431
+ * and the first rung of the escalation ladder.
432
+ * 4. `single_penalty` — WARN. One sub-10-minute penalty in 7 days. Not yet
433
+ * an outage; the bot has left the benign band.
434
+ * 5. `trivial_pressure`— WARN. ≥20 rate nudges in 24h with no penalty at
435
+ * all. The weakest signal, and the only count-based
436
+ * one: sustained pressure without a penalty yet.
437
+ */
438
+ export function classifyFlood429Pressure(
439
+ episodes: readonly Flood429Episode[],
440
+ now: number,
441
+ ): Flood429Verdict | null {
442
+ const inPenaltyWindow = episodes.filter((e) => e.lastTs >= now - PENALTY_WINDOW_MS)
443
+ const penalties = inPenaltyWindow.filter(isPenaltyEpisode)
444
+ const trivialCount24h = inPenaltyWindow
445
+ .filter((e) => !isPenaltyEpisode(e) && e.lastTs >= now - TRIVIAL_PRESSURE_WINDOW_MS)
446
+ .reduce((n, e) => n + e.count, 0)
447
+
448
+ const base = { penalties, trivialCount24h }
449
+
450
+ const open = episodes.filter((e) => e.untilTs > now)
451
+ if (open.length > 0) {
452
+ const worst = open.reduce((a, b) => (b.untilTs > a.untilTs ? b : a))
453
+ const remaining = Math.round((worst.untilTs - now) / 1000)
454
+ return {
455
+ ...base,
456
+ status: 'fail',
457
+ reason: 'ban_open',
458
+ detail:
459
+ `a Telegram flood ban is OPEN — ${fmtDuration(remaining)} remaining ` +
460
+ `(retry_after peaked at ${worst.peakRetryAfterSec}s, first seen ` +
461
+ `${new Date(worst.firstTs).toISOString()}). Outbound sends are being ` +
462
+ `rejected by Telegram; nothing client-side clears it early.`,
463
+ }
464
+ }
465
+
466
+ if (penalties.length >= 2) {
467
+ const latest = penalties[penalties.length - 1]
468
+ const prior = penalties[penalties.length - 2]
469
+ const escalating = latest.peakRetryAfterSec > prior.peakRetryAfterSec
470
+ return {
471
+ ...base,
472
+ status: 'fail',
473
+ reason: 'repeat_penalty',
474
+ detail:
475
+ `${penalties.length} Telegram flood bans in the last 7 days ` +
476
+ `(peak retry_after: ${penalties.map((p) => `${p.peakRetryAfterSec}s`).join(' → ')})` +
477
+ (escalating
478
+ ? ` — and the penalty is GROWING (${prior.peakRetryAfterSec}s → ` +
479
+ `${latest.peakRetryAfterSec}s). Telegram escalates on repeat offences, ` +
480
+ `so the next ban will be longer still.`
481
+ : `. Telegram escalates on repeat offences, so the next ban is likely longer.`),
482
+ }
483
+ }
484
+
485
+ if (penalties.length === 1 && penalties[0].peakRetryAfterSec >= SEVERE_RETRY_AFTER_SEC) {
486
+ const p = penalties[0]
487
+ return {
488
+ ...base,
489
+ status: 'fail',
490
+ reason: 'severe_penalty',
491
+ detail:
492
+ `a ${fmtDuration(p.peakRetryAfterSec)} Telegram flood ban on ` +
493
+ `${new Date(p.firstTs).toISOString()} (retry_after=${p.peakRetryAfterSec}s, ` +
494
+ `${p.count} rejected send(s)). The bot was silent for that whole window, and ` +
495
+ `Telegram escalates the next penalty from here.`,
496
+ }
497
+ }
498
+
499
+ if (penalties.length === 1) {
500
+ const p = penalties[0]
501
+ return {
502
+ ...base,
503
+ status: 'warn',
504
+ reason: 'single_penalty',
505
+ detail:
506
+ `a ${fmtDuration(p.peakRetryAfterSec)} Telegram flood ban on ` +
507
+ `${new Date(p.firstTs).toISOString()} (retry_after=${p.peakRetryAfterSec}s). ` +
508
+ `Short, but above the benign rate-nudge band — the outbound rate is ` +
509
+ `earning penalties, and the next one escalates.`,
510
+ }
511
+ }
512
+
513
+ if (trivialCount24h >= TRIVIAL_PRESSURE_WARN_COUNT) {
514
+ return {
515
+ ...base,
516
+ status: 'warn',
517
+ reason: 'trivial_pressure',
518
+ detail:
519
+ `${trivialCount24h} rate-limit 429s in the last 24h (all short, all ` +
520
+ `absorbed by the in-process retry). No penalty ban yet, but the outbound ` +
521
+ `rate is sitting on Telegram's limit — that is where escalating bans start.`,
522
+ }
523
+ }
524
+
525
+ return null
526
+ }
@@ -30,6 +30,7 @@ import {
30
30
  renameSync,
31
31
  } from 'node:fs'
32
32
  import { dirname, join } from 'node:path'
33
+ import { recordFlood429, flood429LedgerPathFromFloodState } from './flood-429-ledger.js'
33
34
 
34
35
  export interface FloodWaitState {
35
36
  /** Epoch ms at which the flood-wait window expires. */
@@ -225,6 +226,18 @@ export function writeFloodState(
225
226
  * Build the `onFloodWait` callback for `createRetryApiCall`, wired to persist
226
227
  * (and extend) the window at `path`. Reads current state, merges the new
227
228
  * retry_after, writes it back.
229
+ *
230
+ * It ALSO appends to the sibling 429 pressure ledger. `flood-wait.json` holds
231
+ * only the CURRENT window — every 429 overwrites the last — so the run-up to
232
+ * a ban is destroyed at exactly the moment it becomes evidence. That is why
233
+ * the 2026-07-27 4.4h ban had days of escalating penalties behind it that
234
+ * nothing could see. The ledger keeps that history and `switchroom doctor`
235
+ * classifies it (`src/cli/doctor-flood-pressure.ts`).
236
+ *
237
+ * Recording here rather than at the gateway callsites is deliberate: this is
238
+ * the one function EVERY `onFloodWait` wiring goes through (gateway.ts's two
239
+ * hooks and `shared/bot-runtime.ts`'s `createRobustApiCall`), so no future
240
+ * callsite can record a window without also recording its history.
228
241
  */
229
242
  export function makeFloodWaitRecorder(
230
243
  path: string,
@@ -235,6 +248,11 @@ export function makeFloodWaitRecorder(
235
248
  const t = now()
236
249
  const next = computeFloodWait(readFloodState(path), retryAfterSec, t)
237
250
  writeFloodState(path, next, log)
251
+ try {
252
+ recordFlood429(flood429LedgerPathFromFloodState(path), { ts: t, retryAfterSec }, log)
253
+ } catch {
254
+ /* best-effort — the pressure ledger must never break the breaker */
255
+ }
238
256
  }
239
257
  }
240
258