switchroom 0.18.13 → 0.18.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +49 -9
- package/dist/auth-broker/index.js +111 -7
- package/dist/cli/autoaccept-poll.js +23 -0
- package/dist/cli/drive-write-pretool.mjs +24 -1
- package/dist/cli/foreground-hog-pretool.mjs +264 -0
- package/dist/cli/notion-write-pretool.mjs +0 -1
- package/dist/cli/switchroom.js +35 -6
- package/dist/host-control/main.js +1 -2
- package/dist/vault/approvals/kernel-server.js +0 -1
- package/dist/vault/broker/server.js +0 -1
- package/package.json +1 -1
- package/profiles/coding/CLAUDE.md.hbs +2 -0
- package/profiles/default/CLAUDE.md.hbs +2 -0
- package/skills/switchroom-architecture/telegram.md +0 -1
- package/telegram-plugin/auth-snapshot-format.ts +37 -5
- package/telegram-plugin/auto-fallback-fleet.ts +29 -1
- package/telegram-plugin/bridge/bridge.ts +2 -0
- package/telegram-plugin/dist/bridge/bridge.js +2 -0
- package/telegram-plugin/dist/gateway/gateway.js +620 -67
- package/telegram-plugin/dist/server.js +2 -0
- package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
- package/telegram-plugin/gateway/auth-command.ts +14 -0
- package/telegram-plugin/gateway/forward-origin.ts +235 -0
- package/telegram-plugin/gateway/gateway.ts +224 -10
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
- package/telegram-plugin/history.ts +55 -6
- package/telegram-plugin/model-unavailable.ts +20 -2
- package/telegram-plugin/render/rich-render.ts +40 -32
- package/telegram-plugin/stream-controller.ts +3 -2
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
- package/telegram-plugin/tests/forward-origin.test.ts +309 -0
- package/telegram-plugin/tests/history.test.ts +157 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
- package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
- package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
- package/telegram-plugin/tests/status-accent.test.ts +5 -3
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
- package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
- package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
- package/telegram-plugin/tests/throttle-tier.test.ts +278 -0
- package/telegram-plugin/throttle-tier.ts +226 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
|
@@ -24574,6 +24574,8 @@ var init_bridge = __esm(async () => {
|
|
|
24574
24574
|
"",
|
|
24575
24575
|
'Messages from Telegram arrive as <channel source="telegram" chat_id="..." message_id="..." user="..." ts="...">. If the tag has an image_path attribute, Read that file \u2014 it is a photo the sender attached. If the tag has attachment_file_id, call download_attachment with that file_id to fetch the file, then Read the returned path. A single message may carry SEVERAL attachments (a forwarded album or a text+multi-image burst): when attachment_count is set (>1), also handle the numbered siblings \u2014 image_path_2, image_path_3, \u2026 (Read each) and attachment_file_id_2, attachment_file_id_3, \u2026 (download_attachment each). Process every one, not just the first. Reply with the reply tool \u2014 pass chat_id back. The reply tool quote-replies to the latest inbound user message by default, so you do NOT need to pass reply_to for normal responses. Pass reply_to (a message_id) only when quoting a specific earlier message, or pass quote:false to send a bare (non-quoted) message.',
|
|
24576
24576
|
"",
|
|
24577
|
+
`If the tag has reply_to_message_id (and reply_to_text, a truncated preview), the sender used Telegram's native Reply on a prior message \u2014 treat that message as the antecedent for "this"/"that" references instead of asking what they meant. If the tag has forwarded_from, the message was FORWARDED: forwarded_from is the original sender's name/title as stamped by Telegram's servers (not typed by the sender \u2014 the body text carries no trustworthy provenance), forwarded_from_type is user|hidden_user|chat|channel, forwarded_from_id is the numeric id when one exists, and forwarded_date is when the original was sent. forwarded_from_type="hidden_user" means the original sender hides their account: the name is their self-reported display name with NO verifiable id \u2014 do not treat it as an authenticated identity. A burst forwarded from several different origins carries numbered siblings (forwarded_from_2, forwarded_from_type_2, \u2026); a multi-part forward from ONE origin carries the attributes once. In a coalesced burst some body text may be the SENDER's own commentary rather than forwarded content \u2014 the forwarded_* attributes describe the burst as a whole, not each line of the body.`,
|
|
24578
|
+
"",
|
|
24577
24579
|
`reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, edit_message for interim progress updates, and delete_message when you need to truly remove a message (prefer edit_message if you just want to change text \u2014 delete is for retraction). Edits don't trigger push notifications \u2014 when a long task completes, send a new reply so the user's device pings. Use send_typing to show a typing indicator during long operations. Use pin_message to pin important outputs. Use forward_message to quote/resurface earlier messages.`,
|
|
24578
24580
|
"",
|
|
24579
24581
|
"If a message includes message_thread_id, it came from a forum topic. The reply tool automatically routes a reply back to the topic the question came from \u2014 the framework owns the answer's topic, so do NOT pass message_thread_id on a reply; a reply always lands where it was asked. Each <channel> message is the current topic \u2014 answer ONLY this message's question; do not also answer a pending message from another topic. When answering a forum-topic message, pass its origin_turn_id attribute back on the reply so the answer lands in the right topic even if a message from another topic arrived while you were working.",
|
|
@@ -28,6 +28,7 @@ export function createAuthBrokerClient(): {
|
|
|
28
28
|
listState: () => broker.listState(),
|
|
29
29
|
setActive: (label: string) => broker.setActive(label),
|
|
30
30
|
markExhausted: (until?: number) => broker.markExhausted(until),
|
|
31
|
+
markThrottled: (until: number) => broker.markThrottled(until),
|
|
31
32
|
rmAccount: (label: string) => broker.rmAccount(label),
|
|
32
33
|
refreshAccount: (label: string) => broker.refreshAccount(label),
|
|
33
34
|
setOverride: (agent: string, account: string | null) =>
|
|
@@ -301,6 +301,20 @@ export interface AuthBrokerClient {
|
|
|
301
301
|
* identity — so auto-fallback works from any agent.
|
|
302
302
|
*/
|
|
303
303
|
markExhausted(until?: number): Promise<{ account: string; rolled: string[]; rolledTo?: string | null }>
|
|
304
|
+
/**
|
|
305
|
+
* 429 throttle tier (broker `mark-throttled`). Records a transient
|
|
306
|
+
* per-account rate limit on the CALLER's own account — `throttled_until`
|
|
307
|
+
* in the quota ledger — WITHOUT rolling the fleet and WITHOUT touching
|
|
308
|
+
* eligibility. `escalated` is true when the broker's escalation guard
|
|
309
|
+
* (repeated hits corroborated by a live probe) converted it into the
|
|
310
|
+
* standard mark-exhausted + roll; `rolledTo` names the roll target then.
|
|
311
|
+
*/
|
|
312
|
+
markThrottled(until: number): Promise<{
|
|
313
|
+
account: string
|
|
314
|
+
throttled_until: number
|
|
315
|
+
escalated: boolean
|
|
316
|
+
rolledTo?: string | null
|
|
317
|
+
}>
|
|
304
318
|
rmAccount(label: string): Promise<{ label: string }>
|
|
305
319
|
refreshAccount(label: string): Promise<{ account: string; expiresAt?: number }>
|
|
306
320
|
setOverride(
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for forwarded-message origin metadata — kept out of
|
|
3
|
+
* `gateway.ts` (same pattern as `coalesce-attachments.ts`) so the parsing,
|
|
4
|
+
* escaping, and numbered-sibling logic can be unit-tested without the
|
|
5
|
+
* gateway's `loadAccess()` / IPC machinery.
|
|
6
|
+
*
|
|
7
|
+
* When a user forwards a message to the bot, Telegram stamps
|
|
8
|
+
* `message.forward_origin` SERVER-side (Bot API 7.0+). Unlike the message
|
|
9
|
+
* body — which the forwarding user fully controls — the origin record
|
|
10
|
+
* cannot be forged by typing, so it is the trusted lane for "who
|
|
11
|
+
* originally sent this". The gateway surfaces it to the agent as
|
|
12
|
+
* `forwarded_*` channel-meta ATTRIBUTES only; origin info is never
|
|
13
|
+
* injected into the body text, keeping the attacker-influenceable body
|
|
14
|
+
* and the server-stamped provenance on separate lanes.
|
|
15
|
+
*
|
|
16
|
+
* Caveat surfaced via `forwarded_from_type="hidden_user"`: a sender who
|
|
17
|
+
* enabled forward privacy yields only a self-reported display name with
|
|
18
|
+
* NO verifiable id — agents can see the name but must not treat it as an
|
|
19
|
+
* authenticated identity.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { MessageOrigin } from 'grammy/types'
|
|
23
|
+
import { escapeXmlAttribute } from '../steering.js'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Cap on the human-readable origin name/title. Names and titles are
|
|
27
|
+
* attacker-controlled (a hostile account can set a 4KB display name);
|
|
28
|
+
* mirror the REPLY_TO_TEXT_MAX truncate-then-escape pattern with a cap
|
|
29
|
+
* sized for names rather than message previews.
|
|
30
|
+
*/
|
|
31
|
+
export const FORWARDED_FROM_NAME_MAX = 100
|
|
32
|
+
|
|
33
|
+
export type ForwardOriginType = 'user' | 'hidden_user' | 'chat' | 'channel'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Normalized origin record. `name` is RAW (truncated but unescaped) so it
|
|
37
|
+
* can be persisted verbatim to the SQLite history buffer — the XML
|
|
38
|
+
* escaping happens at the channel-meta boundary in
|
|
39
|
+
* `buildForwardOriginMeta`, same split as `replyToText` /
|
|
40
|
+
* `replyToTextEscaped` in the gateway inbound handler.
|
|
41
|
+
*/
|
|
42
|
+
export interface ForwardOriginInfo {
|
|
43
|
+
/** Human-readable name/title of the original sender (raw, truncated). */
|
|
44
|
+
name: string
|
|
45
|
+
type: ForwardOriginType
|
|
46
|
+
/** Numeric id when the origin shape exposes one (user id / chat id). */
|
|
47
|
+
id?: number
|
|
48
|
+
/** Unix seconds of the original message (`forward_origin.date`). */
|
|
49
|
+
date?: number
|
|
50
|
+
/** Channel origins only: the message id inside the origin channel. */
|
|
51
|
+
messageId?: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function truncateName(name: string): string {
|
|
55
|
+
return name.length > FORWARDED_FROM_NAME_MAX
|
|
56
|
+
? name.slice(0, FORWARDED_FROM_NAME_MAX - 1) + '…'
|
|
57
|
+
: name
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Join first/last name plus a trailing `(@username)` when present. */
|
|
61
|
+
function personName(parts: {
|
|
62
|
+
first_name?: string
|
|
63
|
+
last_name?: string
|
|
64
|
+
username?: string
|
|
65
|
+
}): string {
|
|
66
|
+
const name = [parts.first_name, parts.last_name]
|
|
67
|
+
.filter((p): p is string => typeof p === 'string' && p.length > 0)
|
|
68
|
+
.join(' ')
|
|
69
|
+
const handle = parts.username ? `(@${parts.username})` : ''
|
|
70
|
+
return [name, handle].filter((p) => p.length > 0).join(' ')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Chat/channel title plus a trailing `(@username)` when present. */
|
|
74
|
+
function chatTitle(chat: {
|
|
75
|
+
title?: string
|
|
76
|
+
username?: string
|
|
77
|
+
first_name?: string
|
|
78
|
+
last_name?: string
|
|
79
|
+
}): string {
|
|
80
|
+
// Defensive: `Chat` is a union — groups/channels carry `title`, a
|
|
81
|
+
// private chat carries first/last name instead. Fall through so a
|
|
82
|
+
// malformed/unexpected shape still yields whatever name exists.
|
|
83
|
+
if (chat.title && chat.title.length > 0) {
|
|
84
|
+
const handle = chat.username ? `(@${chat.username})` : ''
|
|
85
|
+
return [chat.title, handle].filter((p) => p.length > 0).join(' ')
|
|
86
|
+
}
|
|
87
|
+
return personName(chat)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Parse Telegram's `message.forward_origin` into a normalized record.
|
|
92
|
+
* Returns `undefined` for non-forwarded messages, unknown origin types,
|
|
93
|
+
* and records so malformed that no human-readable name can be recovered
|
|
94
|
+
* (the attrs are context, not a gate — degrading to "no origin metadata"
|
|
95
|
+
* is safe; inventing a name is not).
|
|
96
|
+
*
|
|
97
|
+
* Accepts `unknown`-ish input defensively: the origin arrives from the
|
|
98
|
+
* Telegram wire and future Bot API versions may add origin types this
|
|
99
|
+
* build doesn't know.
|
|
100
|
+
*/
|
|
101
|
+
export function parseForwardOrigin(
|
|
102
|
+
origin: MessageOrigin | undefined,
|
|
103
|
+
): ForwardOriginInfo | undefined {
|
|
104
|
+
if (origin == null || typeof origin !== 'object') return undefined
|
|
105
|
+
const date = typeof origin.date === 'number' ? origin.date : undefined
|
|
106
|
+
switch (origin.type) {
|
|
107
|
+
case 'user': {
|
|
108
|
+
const u = origin.sender_user
|
|
109
|
+
if (u == null || typeof u !== 'object') return undefined
|
|
110
|
+
const name = personName(u)
|
|
111
|
+
if (name.length === 0) {
|
|
112
|
+
// No printable name at all — fall back to the (unforgeable) id.
|
|
113
|
+
if (typeof u.id !== 'number') return undefined
|
|
114
|
+
return { name: String(u.id), type: 'user', id: u.id, date }
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
name: truncateName(name),
|
|
118
|
+
type: 'user',
|
|
119
|
+
...(typeof u.id === 'number' ? { id: u.id } : {}),
|
|
120
|
+
date,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
case 'hidden_user': {
|
|
124
|
+
// Forward-privacy senders: `sender_user_name` is a SELF-REPORTED
|
|
125
|
+
// display name with no verifiable id. The `hidden_user` type marker
|
|
126
|
+
// is the agent's signal to treat the name as unauthenticated.
|
|
127
|
+
const name = typeof origin.sender_user_name === 'string'
|
|
128
|
+
? origin.sender_user_name
|
|
129
|
+
: ''
|
|
130
|
+
if (name.length === 0) return undefined
|
|
131
|
+
return { name: truncateName(name), type: 'hidden_user', date }
|
|
132
|
+
}
|
|
133
|
+
case 'chat': {
|
|
134
|
+
const c = origin.sender_chat
|
|
135
|
+
if (c == null || typeof c !== 'object') return undefined
|
|
136
|
+
const name = chatTitle(c)
|
|
137
|
+
if (name.length === 0) {
|
|
138
|
+
if (typeof c.id !== 'number') return undefined
|
|
139
|
+
return { name: String(c.id), type: 'chat', id: c.id, date }
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
name: truncateName(name),
|
|
143
|
+
type: 'chat',
|
|
144
|
+
...(typeof c.id === 'number' ? { id: c.id } : {}),
|
|
145
|
+
date,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
case 'channel': {
|
|
149
|
+
const c = origin.chat
|
|
150
|
+
if (c == null || typeof c !== 'object') return undefined
|
|
151
|
+
const name = chatTitle(c)
|
|
152
|
+
const messageId = typeof origin.message_id === 'number' ? origin.message_id : undefined
|
|
153
|
+
if (name.length === 0) {
|
|
154
|
+
if (typeof c.id !== 'number') return undefined
|
|
155
|
+
return { name: String(c.id), type: 'channel', id: c.id, date, messageId }
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
name: truncateName(name),
|
|
159
|
+
type: 'channel',
|
|
160
|
+
...(typeof c.id === 'number' ? { id: c.id } : {}),
|
|
161
|
+
date,
|
|
162
|
+
messageId,
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
default:
|
|
166
|
+
// Unknown future origin type — no metadata beats wrong metadata.
|
|
167
|
+
return undefined
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Identity key for burst dedup: same-typed origins with the same id are
|
|
173
|
+
* one origin; id-less shapes (hidden_user) fall back to the name.
|
|
174
|
+
*/
|
|
175
|
+
export function forwardOriginKey(o: ForwardOriginInfo): string {
|
|
176
|
+
return o.id != null ? `${o.type}:${o.id}` : `${o.type}:${o.name}`
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Collapse the per-message origins of a coalesced burst into the DISTINCT
|
|
181
|
+
* origins in arrival order. A 10-part album forwarded from one channel
|
|
182
|
+
* yields ONE origin (attrs emitted once); a burst mixing forwards from two
|
|
183
|
+
* different senders yields two (the second gets `forwarded_from_2` etc.).
|
|
184
|
+
* Non-forwarded entries (`undefined`) are skipped. First occurrence wins,
|
|
185
|
+
* so the emitted `forwarded_date` is the first message's origin date.
|
|
186
|
+
*/
|
|
187
|
+
export function dedupeForwardOrigins(
|
|
188
|
+
origins: Array<ForwardOriginInfo | undefined>,
|
|
189
|
+
): ForwardOriginInfo[] {
|
|
190
|
+
const seen = new Set<string>()
|
|
191
|
+
const out: ForwardOriginInfo[] = []
|
|
192
|
+
for (const o of origins) {
|
|
193
|
+
if (o == null) continue
|
|
194
|
+
const key = forwardOriginKey(o)
|
|
195
|
+
if (seen.has(key)) continue
|
|
196
|
+
seen.add(key)
|
|
197
|
+
out.push(o)
|
|
198
|
+
}
|
|
199
|
+
return out
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Build the `forwarded_*` channel-meta fields. Fixed per-origin attribute
|
|
204
|
+
* order (documented here, tested in forward-origin.test.ts):
|
|
205
|
+
* forwarded_from, forwarded_from_type, forwarded_from_id, forwarded_date
|
|
206
|
+
* The primary field is the human-readable NAME; the numeric id is
|
|
207
|
+
* supplementary and follows it. The first origin gets the bare keys;
|
|
208
|
+
* subsequent distinct origins get `_2`, `_3`, … suffixes — the same
|
|
209
|
+
* numbered-sibling convention as `image_path_2` / `attachment_file_id_2`.
|
|
210
|
+
*
|
|
211
|
+
* Names/titles are attacker-controlled, so every value that can carry
|
|
212
|
+
* user text goes through `escapeXmlAttribute` (the same escaper
|
|
213
|
+
* `formatReplyToText` uses) before landing in the channel tag.
|
|
214
|
+
*/
|
|
215
|
+
export function buildForwardOriginMeta(
|
|
216
|
+
origins: ForwardOriginInfo[],
|
|
217
|
+
): Record<string, string> {
|
|
218
|
+
const out: Record<string, string> = {}
|
|
219
|
+
origins.forEach((o, i) => {
|
|
220
|
+
const suffix = i === 0 ? '' : `_${i + 1}`
|
|
221
|
+
out[`forwarded_from${suffix}`] = escapeXmlAttribute(truncateName(o.name))
|
|
222
|
+
out[`forwarded_from_type${suffix}`] = o.type
|
|
223
|
+
if (o.id != null) out[`forwarded_from_id${suffix}`] = String(o.id)
|
|
224
|
+
if (o.date != null) {
|
|
225
|
+
out[`forwarded_date${suffix}`] = new Date(o.date * 1000).toISOString()
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
return out
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** ISO form of an origin date for the SQLite history record. */
|
|
232
|
+
export function forwardOriginDateIso(o: ForwardOriginInfo | undefined): string | null {
|
|
233
|
+
if (o?.date == null) return null
|
|
234
|
+
return new Date(o.date * 1000).toISOString()
|
|
235
|
+
}
|
|
@@ -94,6 +94,13 @@ import {
|
|
|
94
94
|
buildExtraAttachmentMeta,
|
|
95
95
|
resolveCoalesceMaxAttachments,
|
|
96
96
|
} from './coalesce-attachments.js'
|
|
97
|
+
import {
|
|
98
|
+
parseForwardOrigin,
|
|
99
|
+
dedupeForwardOrigins,
|
|
100
|
+
buildForwardOriginMeta,
|
|
101
|
+
forwardOriginDateIso,
|
|
102
|
+
type ForwardOriginInfo,
|
|
103
|
+
} from './forward-origin.js'
|
|
97
104
|
import { StatusReactionController } from '../status-reactions.js'
|
|
98
105
|
import { DeferredDoneReactions } from '../reaction-defer.js'
|
|
99
106
|
import { createWorkerActivityFeed, isWorkerActivityFeedEnabled } from '../worker-activity-feed.js'
|
|
@@ -310,7 +317,14 @@ import { recordOperatorEvent } from '../operator-events-history.js'
|
|
|
310
317
|
import {
|
|
311
318
|
formatModelUnavailableCard,
|
|
312
319
|
resolveModelUnavailableFromOperatorEvent,
|
|
320
|
+
type ModelUnavailableDetection,
|
|
313
321
|
} from '../model-unavailable.js'
|
|
322
|
+
import {
|
|
323
|
+
decideThrottleTier,
|
|
324
|
+
isAccountScopedThrottle,
|
|
325
|
+
throttleRetryInPlaceMaxMs,
|
|
326
|
+
} from '../throttle-tier.js'
|
|
327
|
+
import { createThrottleTierRunner } from './throttle-tier-wiring.js'
|
|
314
328
|
import { runFleetAutoFallback, renderFallbackFailureNotice, evaluateFallbackFailureNotice, evaluateAllBlockedNotice, type FallbackFailureNoticeState, type FallbackAllBlockedNoticeState } from '../auto-fallback-fleet.js'
|
|
315
329
|
import { startRestartWatchdog } from './restart-watchdog.js'
|
|
316
330
|
import { validateStringArray } from './access-validator.js'
|
|
@@ -7587,6 +7601,14 @@ type CoalescePayload = {
|
|
|
7587
7601
|
attachment?: AttachmentMeta
|
|
7588
7602
|
// Set only by `merge`: the 2nd..Nth attachments folded into this turn.
|
|
7589
7603
|
extraAttachments?: CoalesceAttachment[]
|
|
7604
|
+
// Forwarded-message origin of THIS entry (parsed at enqueue time from
|
|
7605
|
+
// message.forward_origin). merged.ctx is the LAST entry's ctx, so a
|
|
7606
|
+
// coalesced burst would otherwise lose the earlier entries' origins —
|
|
7607
|
+
// each entry carries its own.
|
|
7608
|
+
forwardOrigin?: ForwardOriginInfo
|
|
7609
|
+
// Set only by `merge`: the distinct origins of the whole burst, arrival
|
|
7610
|
+
// order, deduped (a single-origin album collapses to one entry).
|
|
7611
|
+
forwardOrigins?: ForwardOriginInfo[]
|
|
7590
7612
|
}
|
|
7591
7613
|
|
|
7592
7614
|
// Count of attachment-bearing entries currently buffered per coalesce key.
|
|
@@ -7619,6 +7641,11 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
|
|
|
7619
7641
|
(e) => e.downloadImage != null || e.attachment != null,
|
|
7620
7642
|
coalesceMaxAttachments(),
|
|
7621
7643
|
)
|
|
7644
|
+
// Distinct forwarded-message origins across the burst, arrival order.
|
|
7645
|
+
// A forwarded album (one origin, N parts) collapses to a single entry
|
|
7646
|
+
// so the attrs are emitted once; a burst forwarded from two different
|
|
7647
|
+
// senders keeps both (numbered forwarded_from_2 siblings downstream).
|
|
7648
|
+
const forwardOrigins = dedupeForwardOrigins(entries.map((e) => e.forwardOrigin))
|
|
7622
7649
|
return {
|
|
7623
7650
|
// Drop empty texts (e.g. caption-less album parts) so the join doesn't
|
|
7624
7651
|
// emit blank lines between attachments.
|
|
@@ -7629,6 +7656,7 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
|
|
|
7629
7656
|
extraAttachments: extras.length > 0
|
|
7630
7657
|
? extras.map((e) => ({ downloadImage: e.downloadImage, attachment: e.attachment }))
|
|
7631
7658
|
: undefined,
|
|
7659
|
+
forwardOrigins: forwardOrigins.length > 0 ? forwardOrigins : undefined,
|
|
7632
7660
|
}
|
|
7633
7661
|
},
|
|
7634
7662
|
onFlush: (key, merged) => {
|
|
@@ -7639,6 +7667,7 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
|
|
|
7639
7667
|
merged.downloadImage,
|
|
7640
7668
|
merged.attachment,
|
|
7641
7669
|
merged.extraAttachments,
|
|
7670
|
+
merged.forwardOrigins,
|
|
7642
7671
|
)
|
|
7643
7672
|
},
|
|
7644
7673
|
})
|
|
@@ -7659,6 +7688,72 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
|
|
|
7659
7688
|
function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
7660
7689
|
const { agent, kind } = event
|
|
7661
7690
|
|
|
7691
|
+
// ── 429 throttle tier (operator spec: "retry in place under 5 min, else
|
|
7692
|
+
// mark + failover, honest reset messaging") ────────────────────────────
|
|
7693
|
+
// A terminal TRANSIENT ACCOUNT-scoped 429 — kind `rate-limited` carrying
|
|
7694
|
+
// wording that affirms the account's own rate limit ("would exceed your
|
|
7695
|
+
// account's rate limit") — is decided BEFORE the per-kind cooldown gate:
|
|
7696
|
+
// every hit must reach the broker's mark-throttled escalation counter, and
|
|
7697
|
+
// the >threshold failover ACTION must fire, even when the user-facing card
|
|
7698
|
+
// is cooldown-suppressed (a calm 529 card two minutes earlier arms that
|
|
7699
|
+
// cooldown). Server-side transients (529 / "server is temporarily…") fall
|
|
7700
|
+
// through to the existing calm rate-limited card unchanged — an
|
|
7701
|
+
// account-scoped throttle would be the wrong action for a server-wide
|
|
7702
|
+
// condition.
|
|
7703
|
+
let throttleEscalation: ModelUnavailableDetection | null = null
|
|
7704
|
+
let escalationFired = false
|
|
7705
|
+
if (kind === 'rate-limited' && isAccountScopedThrottle(event.detail)) {
|
|
7706
|
+
const throttleDecision = decideThrottleTier({
|
|
7707
|
+
detail: event.detail,
|
|
7708
|
+
now: Date.now(),
|
|
7709
|
+
thresholdMs: throttleRetryInPlaceMaxMs(),
|
|
7710
|
+
})
|
|
7711
|
+
if (throttleDecision.action === 'throttle') {
|
|
7712
|
+
// Reset is near (≤ threshold) or unparseable (60s default): DO NOT
|
|
7713
|
+
// fail over. Record throttled_until broker-side, post ONE lightweight
|
|
7714
|
+
// notice (per-account + fleet-wide dedup), nudge a retry after the
|
|
7715
|
+
// reset. The runner owns the sequencing (throttle-tier-wiring.ts).
|
|
7716
|
+
process.stderr.write(
|
|
7717
|
+
`telegram gateway: throttle-tier staying-put agent=${agent} ` +
|
|
7718
|
+
`until=${new Date(throttleDecision.throttledUntilMs).toISOString()} ` +
|
|
7719
|
+
`parsedReset=${throttleDecision.resetParsed}\n`,
|
|
7720
|
+
)
|
|
7721
|
+
try {
|
|
7722
|
+
recordOperatorEvent(event)
|
|
7723
|
+
} catch { /* history is best-effort */ }
|
|
7724
|
+
void throttleTierRunner.fire(
|
|
7725
|
+
agent,
|
|
7726
|
+
throttleDecision.throttledUntilMs,
|
|
7727
|
+
throttleDecision.resetParsed,
|
|
7728
|
+
)
|
|
7729
|
+
return
|
|
7730
|
+
}
|
|
7731
|
+
if (throttleDecision.action === 'failover') {
|
|
7732
|
+
// Reset beyond the retry-in-place threshold: the account is benched
|
|
7733
|
+
// until then anyway, so escalate to the standard mark-exhausted +
|
|
7734
|
+
// fleet-failover machinery — honest 'rate_limited' wording, parsed
|
|
7735
|
+
// reset as the mark expiry. The ACTION fires HERE, before the
|
|
7736
|
+
// per-kind card cooldown below can swallow it (the fleetFallbackGate
|
|
7737
|
+
// provides the action-level dedup); only the card stays
|
|
7738
|
+
// cooldown-bounded. The 'rate-limit' trigger tells the dispatcher to
|
|
7739
|
+
// trust this terminal parsed-reset signal over the utilization probe
|
|
7740
|
+
// (a transient 429 negates the usage-limit reading, so utilization
|
|
7741
|
+
// typically looks healthy — the idempotency guard would self-cancel
|
|
7742
|
+
// the swap).
|
|
7743
|
+
const resetAt = new Date(throttleDecision.resetAtMs)
|
|
7744
|
+
throttleEscalation = { kind: 'rate_limited', resetAt, raw: event.detail }
|
|
7745
|
+
if (wouldFireFleetAutoFallback()) {
|
|
7746
|
+
escalationFired = true
|
|
7747
|
+
void fireFleetAutoFallback(
|
|
7748
|
+
agent,
|
|
7749
|
+
resolveExhaustUntil(resetAt.getTime()),
|
|
7750
|
+
resetAt,
|
|
7751
|
+
'rate-limit',
|
|
7752
|
+
)
|
|
7753
|
+
}
|
|
7754
|
+
}
|
|
7755
|
+
}
|
|
7756
|
+
|
|
7662
7757
|
if (!shouldEmitOperatorEvent(agent, kind)) {
|
|
7663
7758
|
process.stderr.write(
|
|
7664
7759
|
`telegram gateway: operator-event suppressed (cooldown) agent=${agent} kind=${kind}\n`,
|
|
@@ -7687,7 +7782,10 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
7687
7782
|
// by the upstream classifier
|
|
7688
7783
|
// - detail string contains one of the model-unavailable text patterns
|
|
7689
7784
|
// (covers raw stderr that slipped past structured classification)
|
|
7690
|
-
|
|
7785
|
+
// Throttle-tier escalation (long-reset transient 429) takes precedence:
|
|
7786
|
+
// resolveModelUnavailableFromOperatorEvent deliberately returns null for a
|
|
7787
|
+
// bare rate-limited event, but this one is auto-fallback-eligible.
|
|
7788
|
+
const modelUnavailable = throttleEscalation ?? resolveModelUnavailableFromOperatorEvent(event)
|
|
7691
7789
|
let renderedText: string
|
|
7692
7790
|
let renderedKeyboard: ReturnType<typeof renderOperatorEvent>['keyboard'] | undefined
|
|
7693
7791
|
// #3031 PR 3 — when the card promises an in-flight auto-failover, record
|
|
@@ -7709,8 +7807,18 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
7709
7807
|
// it just produces a self-cancelling "probed healthy / Stale event?"
|
|
7710
7808
|
// loop on every 529. Overload is handled by Claude Code's own
|
|
7711
7809
|
// internal retry, not by switching accounts.
|
|
7712
|
-
|
|
7713
|
-
|
|
7810
|
+
// 'rate_limited' is the throttle-tier escalation (transient 429 whose
|
|
7811
|
+
// reset exceeds the retry-in-place threshold) — same failover mechanics
|
|
7812
|
+
// as a quota wall, honest wording on the card. Its fire already happened
|
|
7813
|
+
// ABOVE the cooldown gate (escalationFired) so the action can't be
|
|
7814
|
+
// swallowed by the per-kind card cooldown; the card here only reports it.
|
|
7815
|
+
const isAutoKind =
|
|
7816
|
+
modelUnavailable.kind === 'quota_exhausted' ||
|
|
7817
|
+
modelUnavailable.kind === 'rate_limited'
|
|
7818
|
+
const willActuallyFire =
|
|
7819
|
+
throttleEscalation != null
|
|
7820
|
+
? escalationFired
|
|
7821
|
+
: isAutoKind && wouldFireFleetAutoFallback()
|
|
7714
7822
|
process.stderr.write(
|
|
7715
7823
|
`telegram gateway: operator-event suppressing-raw-stderr-for-model-unavailable agent=${agent} kind=${kind} detected=${modelUnavailable.kind} autoKind=${isAutoKind} willFire=${willActuallyFire}\n`,
|
|
7716
7824
|
)
|
|
@@ -7734,9 +7842,14 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
7734
7842
|
// Pre-fix this called fireFleetAutoFallback(agent) with no until, so a
|
|
7735
7843
|
// weekly wall that surfaced as a 429 got markExhausted's ~5h default and
|
|
7736
7844
|
// the broker re-mirrored the still-walled account onto the fleet after 5h.
|
|
7737
|
-
|
|
7845
|
+
// Throttle-tier escalations (throttleEscalation != null) already fired
|
|
7846
|
+
// above the cooldown gate — don't double-fire here.
|
|
7847
|
+
if (willActuallyFire && throttleEscalation == null) {
|
|
7738
7848
|
const untilMs = resolveExhaustUntil(modelUnavailable.resetAt?.getTime())
|
|
7739
|
-
|
|
7849
|
+
// Thread the RAW parsed reset (distinct from the floor-applied untilMs)
|
|
7850
|
+
// so the fallback announcement can name it even when the live probe of
|
|
7851
|
+
// the old account fails — honest reset messaging on the enriched card.
|
|
7852
|
+
void fireFleetAutoFallback(agent, untilMs, modelUnavailable.resetAt)
|
|
7740
7853
|
}
|
|
7741
7854
|
} else {
|
|
7742
7855
|
try {
|
|
@@ -11374,7 +11487,13 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
11374
11487
|
(msg.resetAt == null ? ' (reset unparsed → +7d default)' : '') +
|
|
11375
11488
|
' — triggering fleet auto-fallback\n',
|
|
11376
11489
|
)
|
|
11377
|
-
void fireFleetAutoFallback(
|
|
11490
|
+
void fireFleetAutoFallback(
|
|
11491
|
+
msg.agentName,
|
|
11492
|
+
untilMs,
|
|
11493
|
+
// Enriched announcement: the sidecar-parsed reset (when present) keeps
|
|
11494
|
+
// the recovery line honest even when the old account's probe fails.
|
|
11495
|
+
msg.resetAt != null ? new Date(msg.resetAt) : undefined,
|
|
11496
|
+
)
|
|
11378
11497
|
},
|
|
11379
11498
|
|
|
11380
11499
|
// Issue #2971 — read-only wedge-watchdog probe: is there a live pending
|
|
@@ -18070,7 +18189,15 @@ async function handleInboundCoalesced(
|
|
|
18070
18189
|
ctx.message?.message_thread_id,
|
|
18071
18190
|
String(from.id),
|
|
18072
18191
|
)
|
|
18073
|
-
const result = inboundCoalescer.enqueue(key, {
|
|
18192
|
+
const result = inboundCoalescer.enqueue(key, {
|
|
18193
|
+
text,
|
|
18194
|
+
ctx,
|
|
18195
|
+
downloadImage,
|
|
18196
|
+
attachment,
|
|
18197
|
+
// Parsed HERE (not in merge) because merge only sees the last ctx —
|
|
18198
|
+
// each buffered entry keeps its own forwarded-message origin.
|
|
18199
|
+
forwardOrigin: parseForwardOrigin(ctx.message?.forward_origin),
|
|
18200
|
+
})
|
|
18074
18201
|
// Coalescing disabled (window <= 0): flush immediately, preserving any
|
|
18075
18202
|
// media this message carried.
|
|
18076
18203
|
if (result.bypass) return handleInbound(ctx, text, downloadImage, attachment)
|
|
@@ -18149,6 +18276,11 @@ async function handleInbound(
|
|
|
18149
18276
|
// resolved (photos downloaded) and surfaced as numbered meta fields
|
|
18150
18277
|
// (image_path_2, attachment_file_id_2, …) alongside the primary.
|
|
18151
18278
|
extraAttachments?: CoalesceAttachment[],
|
|
18279
|
+
// Forwarded-message origins for a coalesced burst (deduped, arrival
|
|
18280
|
+
// order — see the coalescer's merge). Left undefined by the direct /
|
|
18281
|
+
// bypass call sites; handleInbound then parses THIS ctx's
|
|
18282
|
+
// forward_origin itself, so single-message forwards work on every path.
|
|
18283
|
+
coalescedForwardOrigins?: ForwardOriginInfo[],
|
|
18152
18284
|
): Promise<void> {
|
|
18153
18285
|
markIdleActivity() // any inbound resets the idle auto-clear timer + re-arms
|
|
18154
18286
|
const isTopicMessage = ctx.message?.is_topic_message ?? false
|
|
@@ -19367,6 +19499,19 @@ async function handleInbound(
|
|
|
19367
19499
|
: undefined
|
|
19368
19500
|
const replyToTextEscaped = formatReplyToText(replyToTextRaw, REPLY_TO_TEXT_MAX)
|
|
19369
19501
|
|
|
19502
|
+
// Forwarded-message origin context. `forward_origin` is stamped by
|
|
19503
|
+
// Telegram's servers (Bot API 7.0+) — the forwarding user cannot forge
|
|
19504
|
+
// it via the message body, so it rides the trusted attrs lane and is
|
|
19505
|
+
// NEVER folded into the body text. Coalesced bursts pass their deduped
|
|
19506
|
+
// per-entry origins; direct/bypass paths parse this ctx's own origin.
|
|
19507
|
+
// Same raw-vs-escaped split as reply_to_text: `forwardOrigins` carries
|
|
19508
|
+
// raw (truncated) names for SQLite, `buildForwardOriginMeta` escapes at
|
|
19509
|
+
// the channel-meta boundary.
|
|
19510
|
+
const forwardOrigins = coalescedForwardOrigins
|
|
19511
|
+
?? dedupeForwardOrigins([parseForwardOrigin(ctx.message?.forward_origin)])
|
|
19512
|
+
const forwardOriginMeta = buildForwardOriginMeta(forwardOrigins)
|
|
19513
|
+
const primaryForwardOrigin = forwardOrigins[0]
|
|
19514
|
+
|
|
19370
19515
|
if (HISTORY_ENABLED) {
|
|
19371
19516
|
try {
|
|
19372
19517
|
recordInbound({
|
|
@@ -19380,6 +19525,11 @@ async function handleInbound(
|
|
|
19380
19525
|
attachment_kind: attachment?.kind,
|
|
19381
19526
|
reply_to_message_id: replyToMessageId ?? null,
|
|
19382
19527
|
reply_to_text: replyToText ?? null,
|
|
19528
|
+
forwarded_from: primaryForwardOrigin?.name ?? null,
|
|
19529
|
+
forwarded_from_type: primaryForwardOrigin?.type ?? null,
|
|
19530
|
+
forwarded_from_id: primaryForwardOrigin?.id != null ? String(primaryForwardOrigin.id) : null,
|
|
19531
|
+
forwarded_date: forwardOriginDateIso(primaryForwardOrigin),
|
|
19532
|
+
forwarded_message_id: primaryForwardOrigin?.messageId ?? null,
|
|
19383
19533
|
})
|
|
19384
19534
|
} catch (err) {
|
|
19385
19535
|
process.stderr.write(`telegram gateway: history recordInbound failed: ${err}\n`)
|
|
@@ -19485,6 +19635,11 @@ async function handleInbound(
|
|
|
19485
19635
|
// Use the XML-escaped form for the meta — the raw form is in the
|
|
19486
19636
|
// SQLite buffer for verbatim retrieval via get_recent_messages.
|
|
19487
19637
|
...(replyToTextEscaped != null && replyToTextEscaped.length > 0 ? { reply_to_text: replyToTextEscaped } : {}),
|
|
19638
|
+
// Forwarded-message origin (server-stamped, attrs-only — see above).
|
|
19639
|
+
// forwarded_from / forwarded_from_type / forwarded_from_id /
|
|
19640
|
+
// forwarded_date, plus numbered _2.. siblings for a multi-origin
|
|
19641
|
+
// burst. Names are XML-escaped inside buildForwardOriginMeta.
|
|
19642
|
+
...forwardOriginMeta,
|
|
19488
19643
|
// queued="true" when mid-turn with no steer prefix (new default), or
|
|
19489
19644
|
// with explicit /queue or /q prefix (legacy alias).
|
|
19490
19645
|
...((isQueuedMidTurn || isQueuedPrefix) ? { queued: 'true' } : {}),
|
|
@@ -23203,9 +23358,14 @@ function wouldFireFleetAutoFallback(): boolean {
|
|
|
23203
23358
|
* so the user sees the outcome inline with the original "Model
|
|
23204
23359
|
* unavailable" card.
|
|
23205
23360
|
*/
|
|
23206
|
-
async function fireFleetAutoFallback(
|
|
23361
|
+
async function fireFleetAutoFallback(
|
|
23362
|
+
triggerAgent: string,
|
|
23363
|
+
untilMs?: number,
|
|
23364
|
+
parsedResetAt?: Date,
|
|
23365
|
+
trigger?: 'rate-limit',
|
|
23366
|
+
): Promise<void> {
|
|
23207
23367
|
return fleetFallbackGate.fire(
|
|
23208
|
-
() => doFireFleetAutoFallback(triggerAgent, untilMs),
|
|
23368
|
+
() => doFireFleetAutoFallback(triggerAgent, untilMs, parsedResetAt, trigger),
|
|
23209
23369
|
(err) => {
|
|
23210
23370
|
process.stderr.write(
|
|
23211
23371
|
`telegram gateway: [fleet-fallback] error agent=${triggerAgent}: ${(err as Error)?.message ?? err}\n`,
|
|
@@ -23214,6 +23374,45 @@ async function fireFleetAutoFallback(triggerAgent: string, untilMs?: number): Pr
|
|
|
23214
23374
|
)
|
|
23215
23375
|
}
|
|
23216
23376
|
|
|
23377
|
+
// ─── 429 throttle tier — side-effect wiring ─────────────────────────────────
|
|
23378
|
+
// Decision + notice text live in throttle-tier.ts (pure); the SEQUENCING
|
|
23379
|
+
// (broker mark → fleet-deduped notice → jittered retry nudge with the
|
|
23380
|
+
// live-turn safety guards) lives in throttle-tier-wiring.ts so it is
|
|
23381
|
+
// unit-testable with injected deps. This block only binds the real gateway
|
|
23382
|
+
// dependencies.
|
|
23383
|
+
const throttleTierRunner = createThrottleTierRunner({
|
|
23384
|
+
agentName: process.env.SWITCHROOM_AGENT_NAME ?? '',
|
|
23385
|
+
getBrokerClient: () =>
|
|
23386
|
+
getAuthBrokerClient(process.env.SWITCHROOM_AGENT_NAME ?? ''),
|
|
23387
|
+
listNoticeChats: () => loadAccess().allowFrom,
|
|
23388
|
+
sendNotice: (chat_id, markdown) => {
|
|
23389
|
+
// Status notice, not the user's answer — silence the ping (same posture
|
|
23390
|
+
// as the fleet-fallback announcement).
|
|
23391
|
+
void swallowingApiCall(
|
|
23392
|
+
// allow-raw-bot-api: wrapped in swallowingApiCall (retry policy)
|
|
23393
|
+
() => bot.api.sendRichMessage(chat_id, richMessage(markdown), { disable_notification: true }),
|
|
23394
|
+
{ chat_id: String(chat_id), verb: 'throttle-tier:notify' },
|
|
23395
|
+
)
|
|
23396
|
+
},
|
|
23397
|
+
resumeDecide: (ts) => fleetFallbackResumeGate.decide(ts),
|
|
23398
|
+
newestActiveTurnStartedAtMs,
|
|
23399
|
+
turnInFlight: () => turnInFlightForGate(),
|
|
23400
|
+
deferRestartToTurnComplete: (agentName, reason) => {
|
|
23401
|
+
// Same lever the schedule_restart IPC uses: the restart drains at the
|
|
23402
|
+
// turn-complete gate (never SIGTERM-now under a live turn). Note the
|
|
23403
|
+
// pending-restart drain cap (PENDING_RESTART_DRAIN_CAP_MS) still bounds
|
|
23404
|
+
// a never-idling session — deliberate, matching scheduled restarts.
|
|
23405
|
+
process.stderr.write(
|
|
23406
|
+
`telegram gateway: [throttle-tier] restart deferred to turn-complete agent=${agentName} reason=${reason}\n`,
|
|
23407
|
+
)
|
|
23408
|
+
pendingRestarts.set(agentName, Date.now())
|
|
23409
|
+
},
|
|
23410
|
+
restartNow: (agentName, reason) => {
|
|
23411
|
+
triggerSelfRestart(agentName, reason)
|
|
23412
|
+
},
|
|
23413
|
+
log: (m) => process.stderr.write(`telegram gateway: ${m}\n`),
|
|
23414
|
+
})
|
|
23415
|
+
|
|
23217
23416
|
/**
|
|
23218
23417
|
* Broadcast a fleet-fallback FAILURE notice to every authorized chat.
|
|
23219
23418
|
*
|
|
@@ -23275,7 +23474,12 @@ function broadcastFleetFallbackFailure(triggerAgent: string, reason: string): vo
|
|
|
23275
23474
|
* user-visible announcement was broadcast). False on no-op /
|
|
23276
23475
|
* error / idempotent-skip — caller uses this to decide whether to
|
|
23277
23476
|
* arm the post-fire suppression window. */
|
|
23278
|
-
async function doFireFleetAutoFallback(
|
|
23477
|
+
async function doFireFleetAutoFallback(
|
|
23478
|
+
triggerAgent: string,
|
|
23479
|
+
untilMs?: number,
|
|
23480
|
+
parsedResetAt?: Date,
|
|
23481
|
+
trigger?: 'rate-limit',
|
|
23482
|
+
): Promise<boolean> {
|
|
23279
23483
|
try {
|
|
23280
23484
|
const client = await getAuthBrokerClient(triggerAgent)
|
|
23281
23485
|
if (!client) {
|
|
@@ -23324,6 +23528,16 @@ async function doFireFleetAutoFallback(triggerAgent: string, untilMs?: number):
|
|
|
23324
23528
|
},
|
|
23325
23529
|
triggerAgent,
|
|
23326
23530
|
tz,
|
|
23531
|
+
// Enriched card (429 throttle tier): the RAW reset parsed from the
|
|
23532
|
+
// error prose, so the announcement names when the old account frees
|
|
23533
|
+
// even when its live probe returned nothing.
|
|
23534
|
+
parsedResetAt,
|
|
23535
|
+
// Throttle-tier escalation: a terminal transient 429 with a long
|
|
23536
|
+
// parsed reset NEGATES the usage-limit reading, so the old account's
|
|
23537
|
+
// utilization probe typically classifies healthy — the idempotency
|
|
23538
|
+
// guard would self-cancel the swap ("probed healthy / Stale event?").
|
|
23539
|
+
// Trust the terminal parsed-reset signal over the utilization probe.
|
|
23540
|
+
rateLimitTrigger: trigger === 'rate-limit',
|
|
23327
23541
|
})
|
|
23328
23542
|
process.stderr.write(
|
|
23329
23543
|
`telegram gateway: [fleet-fallback] outcome=${outcome.kind} agent=${triggerAgent}` +
|