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.
- package/bin/git-agent-attribution-hook.sh +144 -0
- package/dist/agent-scheduler/index.js +55 -0
- package/dist/auth-broker/index.js +116 -6
- package/dist/cli/notion-write-pretool.mjs +55 -0
- package/dist/cli/switchroom.js +2055 -1193
- package/dist/host-control/main.js +117 -7
- package/dist/vault/approvals/kernel-server.js +114 -6
- package/dist/vault/broker/server.js +114 -6
- package/package.json +6 -2
- package/profiles/_base/cron-session.sh.hbs +8 -0
- package/profiles/_base/start.sh.hbs +105 -0
- package/telegram-plugin/card-layout.ts +328 -0
- package/telegram-plugin/dist/bridge/bridge.js +93 -1
- package/telegram-plugin/dist/gateway/gateway.js +2009 -1166
- package/telegram-plugin/dist/server.js +96 -1
- package/telegram-plugin/edit-flood-fuse.ts +637 -56
- package/telegram-plugin/flood-429-ledger.ts +526 -0
- package/telegram-plugin/flood-circuit-breaker.ts +18 -0
- package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
- package/telegram-plugin/gateway/gateway.ts +58 -68
- package/telegram-plugin/gateway/narrative-lane.ts +14 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
- package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
- package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
- package/telegram-plugin/gateway/status-pin-store.ts +58 -9
- package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
- package/telegram-plugin/llm-error-present.ts +61 -2
- package/telegram-plugin/model-unavailable.ts +8 -0
- package/telegram-plugin/operator-events.ts +72 -5
- package/telegram-plugin/outbound-class.ts +81 -0
- package/telegram-plugin/provider-credit.ts +237 -0
- package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
- package/telegram-plugin/send-gate.ts +24 -2
- package/telegram-plugin/status-no-truncate.ts +10 -48
- package/telegram-plugin/status-pin-driver.ts +33 -45
- package/telegram-plugin/status-pin.ts +18 -1
- package/telegram-plugin/tests/card-golden.test.ts +69 -0
- package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
- package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
- package/telegram-plugin/tests/card-variants.golden.txt +211 -0
- package/telegram-plugin/tests/card-variants.ts +366 -0
- package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
- package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
- package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
- package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
- package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
- package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
- package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
- package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
- package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
- package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
- package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
- package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
- package/telegram-plugin/tests/status-pin.test.ts +56 -5
- package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
- package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
- package/telegram-plugin/tool-activity-summary.ts +239 -365
- package/telegram-plugin/uat/assertions.ts +22 -11
- package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
- package/telegram-plugin/worker-activity-feed.ts +105 -47
- package/vendor/hindsight-memory/CLAUDE.md +45 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
- package/vendor/hindsight-memory/scripts/recall.py +176 -7
- package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
- 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
|
|