switchroom 0.19.42 → 0.19.44
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 +4 -1
- package/dist/auth-broker/index.js +284 -124
- package/dist/cli/notion-write-pretool.mjs +4 -1
- package/dist/cli/switchroom.js +4094 -2711
- package/dist/host-control/main.js +472 -155
- package/dist/vault/approvals/kernel-server.js +231 -71
- package/dist/vault/broker/server.js +293 -133
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +93 -8
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-layout.ts +63 -1
- package/telegram-plugin/dist/bridge/bridge.js +4 -1
- package/telegram-plugin/dist/gateway/gateway.js +1682 -936
- package/telegram-plugin/dist/server.js +5 -2
- package/telegram-plugin/flood-429-ledger.ts +5 -3
- package/telegram-plugin/flood-circuit-breaker.ts +11 -3
- package/telegram-plugin/format.ts +223 -13
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
- package/telegram-plugin/gateway/gateway.ts +49 -51
- package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
- package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
- package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
- package/telegram-plugin/gateway/ipc-server.ts +13 -4
- package/telegram-plugin/gateway/obligation-store.ts +55 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
- package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
- package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
- package/telegram-plugin/gateway/stream-render.ts +5 -0
- package/telegram-plugin/gateway/update-announce.ts +11 -4
- package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
- package/telegram-plugin/hooks/audience-classify.mjs +364 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
- package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
- package/telegram-plugin/outbox.ts +103 -2
- package/telegram-plugin/render/rich-render.ts +10 -2
- package/telegram-plugin/reply-quote.ts +150 -0
- package/telegram-plugin/retry-api-call.ts +254 -11
- package/telegram-plugin/shared/bot-runtime.ts +69 -34
- package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
- package/telegram-plugin/silent-end.ts +34 -2
- package/telegram-plugin/status-no-truncate.ts +31 -16
- package/telegram-plugin/stream-controller.ts +44 -9
- package/telegram-plugin/stream-reply-handler.ts +5 -3
- package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
- package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
- package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
- package/telegram-plugin/tests/card-variants.golden.txt +9 -9
- package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
- package/telegram-plugin/tests/format-consistency.test.ts +160 -14
- package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
- package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
- package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
- package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
- package/telegram-plugin/tests/obligation-store.test.ts +51 -0
- package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
- package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
- package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
- package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
- package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
- package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
- package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
- package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
- package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
- package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
- package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
- package/telegram-plugin/tool-activity-summary.ts +58 -30
- package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
- package/vendor/hindsight-memory/scripts/lib/config.py +15 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_provenance_tag.py +135 -0
- package/vendor/hindsight-memory/settings.json +1 -1
|
@@ -22,11 +22,206 @@
|
|
|
22
22
|
* | GrammyError 400 "message to edit not found" | swallow, return undefined |
|
|
23
23
|
* | GrammyError 400 "message to delete not found" | swallow, return undefined |
|
|
24
24
|
* | GrammyError 400 "thread not found" (w/ opts) | throw `THREAD_NOT_FOUND` wrapper |
|
|
25
|
-
* |
|
|
25
|
+
* | HttpError (grammy transport failure) | exponential backoff retry, 3 attempts |
|
|
26
|
+
* | Raw network error (fetch failed / ECONN…) | exponential backoff retry, 3 attempts |
|
|
27
|
+
* | ENOSPC/EDQUOT/EIO/ENOMEM (local exhaustion) | throw `LOCAL_RESOURCE_EXHAUSTED`, no retry|
|
|
26
28
|
* | Anything else | rethrow immediately |
|
|
29
|
+
*
|
|
30
|
+
* The three "swallow" rows are the DEFAULT, not the law: a call site whose
|
|
31
|
+
* contract is "did the target message survive?" sets `rethrowBenign400: true`
|
|
32
|
+
* and gets the original `GrammyError` instead, so it can classify the failure
|
|
33
|
+
* itself. See that option's docblock for the #4065 inertness bug it exists for.
|
|
34
|
+
*
|
|
35
|
+
* The network row is classified by TYPE (`isTransientTransportError`), not by a
|
|
36
|
+
* substring of the message: grammy rewrites every transport failure's message
|
|
37
|
+
* to `Network request for '<method>' failed!`, so the substring form of this
|
|
38
|
+
* row never matched in production and the retry was dead code (#4123).
|
|
27
39
|
*/
|
|
28
40
|
|
|
29
|
-
import { GrammyError } from 'grammy'
|
|
41
|
+
import { GrammyError, HttpError } from 'grammy'
|
|
42
|
+
import { AsyncLocalStorage } from 'async_hooks'
|
|
43
|
+
|
|
44
|
+
// ─── retry-attempt context (#3931) ────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* What the retry policy knows about the attempt currently on the wire.
|
|
48
|
+
*
|
|
49
|
+
* Published so the layer BELOW the policy — the `tg-post` observability
|
|
50
|
+
* transformer in `shared/bot-runtime.ts`, which runs inside `fn()` and
|
|
51
|
+
* therefore sees a failure *before* the policy decides what to do with it —
|
|
52
|
+
* can label a doomed-but-non-terminal attempt honestly instead of as a
|
|
53
|
+
* delivery failure. Without it, a 429 that the policy sleeps and retries
|
|
54
|
+
* SUCCESSFULLY still wrote `status=err` to the gateway log, and fleet-health's
|
|
55
|
+
* per-line `reply-delivery-failure` detector escalated a delivered reply
|
|
56
|
+
* (#3931).
|
|
57
|
+
*/
|
|
58
|
+
export interface TgAttemptContext {
|
|
59
|
+
/** 0-based index of the attempt currently executing. */
|
|
60
|
+
attempt: number
|
|
61
|
+
/** Total attempts this policy will make. */
|
|
62
|
+
maxRetries: number
|
|
63
|
+
/** Longest 429 `retry_after` the policy will sleep in-process. */
|
|
64
|
+
maxFloodSleepMs: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const attemptStore = new AsyncLocalStorage<TgAttemptContext>()
|
|
68
|
+
|
|
69
|
+
/** The attempt context of the enclosing `retryApiCall`, or undefined outside one. */
|
|
70
|
+
export function _getTgAttemptContext(): TgAttemptContext | undefined {
|
|
71
|
+
return attemptStore.getStore()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Run `fn` with `ctx` visible to `_getTgAttemptContext()` for its whole async chain. */
|
|
75
|
+
function withAttemptContext<T>(ctx: TgAttemptContext, fn: () => Promise<T>): Promise<T> {
|
|
76
|
+
return attemptStore.run(ctx, fn)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Substring classifier for a RAW transport error message (an undici/node error
|
|
81
|
+
* that has not been through grammy's wrapping).
|
|
82
|
+
*
|
|
83
|
+
* NOT sufficient on its own for anything that came out of `bot.api.*` — see
|
|
84
|
+
* `isTransientTransportError`, which is what the retry loop actually calls.
|
|
85
|
+
* grammy rewrites the message of every transport failure, so this predicate
|
|
86
|
+
* returns false for 100% of production send failures if used alone (#4123).
|
|
87
|
+
*/
|
|
88
|
+
export function isTransientNetworkMessage(msg: string): boolean {
|
|
89
|
+
return (
|
|
90
|
+
msg.includes('ECONNRESET') ||
|
|
91
|
+
msg.includes('ETIMEDOUT') ||
|
|
92
|
+
msg.includes('fetch failed') ||
|
|
93
|
+
msg.includes('ENOTFOUND')
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* True for a cancellation: `AbortController.abort()` surfaces as a
|
|
99
|
+
* `DOMException`/`Error` with `name === 'AbortError'` (node also sets
|
|
100
|
+
* `code === 'ABORT_ERR'`). Checked structurally rather than by `instanceof`
|
|
101
|
+
* because the shape differs across the fetch implementations grammy can be
|
|
102
|
+
* handed (undici, node-fetch, a test double).
|
|
103
|
+
*/
|
|
104
|
+
function isAbortError(err: unknown): boolean {
|
|
105
|
+
const e = err as { name?: unknown; code?: unknown } | null
|
|
106
|
+
return e?.name === 'AbortError' || e?.code === 'ABORT_ERR'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Is `err` a transient TRANSPORT failure the policy should back off and repeat?
|
|
111
|
+
*
|
|
112
|
+
* This is the single classifier the retry loop and `willRetryTelegramFailure`
|
|
113
|
+
* both call, so the policy and the logging tier cannot disagree about what
|
|
114
|
+
* counts as retryable (#3931).
|
|
115
|
+
*
|
|
116
|
+
* Why this is a TYPE test and not a message test (#4123): grammy funnels every
|
|
117
|
+
* fetch rejection through `toHttpError` (grammy 1.44.0, `out/core/client.js:58`
|
|
118
|
+
* → `out/core/error.js:76-83`), which throws away the original error text and
|
|
119
|
+
* renders `Network request for '<method>' failed!`. The underlying
|
|
120
|
+
* `fetch failed` / `ECONNRESET` / `ETIMEDOUT` / `ENOTFOUND` string is preserved
|
|
121
|
+
* only on `HttpError.error`, and is appended to the message only when
|
|
122
|
+
* `client.sensitiveLogs` is on — which we deliberately leave off, because it
|
|
123
|
+
* writes the bot-token URL into logs. So a substring match over the message
|
|
124
|
+
* matched NOTHING in production: the network-retry branch was dead code and a
|
|
125
|
+
* single connection blip terminally failed the send.
|
|
126
|
+
*
|
|
127
|
+
* The classes, and why each lands where it does:
|
|
128
|
+
*
|
|
129
|
+
* - `GrammyError` — an API-LEVEL rejection: Telegram answered, and said no.
|
|
130
|
+
* Never part of the network class. 429 has its own sleep-and-retry branch
|
|
131
|
+
* above; everything else (400/403/404/…) is terminal and must NOT be
|
|
132
|
+
* retried. This is the invariant the old `!isGrammyErr` guard at the
|
|
133
|
+
* callsite protected; it now lives here, in one place, next to the reason.
|
|
134
|
+
* - local resource exhaustion — ENOSPC/EDQUOT/EIO/ENOMEM is a LOCAL disk or
|
|
135
|
+
* memory failure. Retrying it in a tight loop is what tripped the per-bot
|
|
136
|
+
* flood ban (#2923). Excluded even when grammy wrapped it.
|
|
137
|
+
* - `HttpError` — transport by construction: it exists only on the
|
|
138
|
+
* "the fetch never produced a Telegram response" path (connection reset,
|
|
139
|
+
* DNS failure, request timeout, unparseable proxy 5xx). Retryable.
|
|
140
|
+
* - anything else — a raw error that never went through grammy (or a nested
|
|
141
|
+
* cause): fall back to the errno/message classifier.
|
|
142
|
+
*/
|
|
143
|
+
export function isTransientTransportError(err: unknown): boolean {
|
|
144
|
+
if (err instanceof GrammyError) return false
|
|
145
|
+
if (isLocalResourceError(err)) return false
|
|
146
|
+
if (err instanceof HttpError) {
|
|
147
|
+
// grammy stashes the original rejection on `.error`.
|
|
148
|
+
const inner = (err as HttpError).error
|
|
149
|
+
// A local disk failure during a multipart upload can surface here; it
|
|
150
|
+
// stays non-retryable (#2923).
|
|
151
|
+
if (isLocalResourceError(inner)) return false
|
|
152
|
+
// A CALLER-initiated cancellation is not a transient failure — retrying it
|
|
153
|
+
// only re-aborts three times and adds backoff sleeps to shutdown. grammy's
|
|
154
|
+
// own request TIMEOUT is NOT this case: `createTimeout` rejects the race
|
|
155
|
+
// with a plain `Error("Request to '<m>' timed out after Ns")` BEFORE it
|
|
156
|
+
// aborts the controller (grammy 1.44.0, out/core/client.js:168-178), so a
|
|
157
|
+
// timeout arrives as that Error and remains retryable. An `AbortError`
|
|
158
|
+
// here means something aborted the signal we were handed (`bot.stop()`).
|
|
159
|
+
if (isAbortError(inner)) return false
|
|
160
|
+
return true
|
|
161
|
+
}
|
|
162
|
+
const msg = err instanceof Error ? err.message : String(err ?? '')
|
|
163
|
+
if (isTransientNetworkMessage(msg)) return true
|
|
164
|
+
// One level of standard cause-chaining (`new Error(x, { cause })`), so a
|
|
165
|
+
// wrapper that preserves the transport error is still classified correctly.
|
|
166
|
+
const cause = (err as { cause?: unknown } | null)?.cause
|
|
167
|
+
if (cause == null || cause === err) return false
|
|
168
|
+
if (isLocalResourceError(cause)) return false
|
|
169
|
+
return isTransientNetworkMessage(cause instanceof Error ? cause.message : String(cause))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The parts of a Telegram failure that decide retryability. */
|
|
173
|
+
export interface TgFailureShape {
|
|
174
|
+
/** Telegram `error_code`, or null/undefined for a transport-level error. */
|
|
175
|
+
errorCode?: number | null
|
|
176
|
+
/** Telegram `parameters.retry_after`, seconds. */
|
|
177
|
+
retryAfterSec?: number | null
|
|
178
|
+
/** Transport-level error message (only consulted when `errorCode` is absent). */
|
|
179
|
+
message?: string | null
|
|
180
|
+
/**
|
|
181
|
+
* The thrown error itself, when the caller has it.
|
|
182
|
+
*
|
|
183
|
+
* STRONGLY PREFERRED over `message` for anything that came out of `bot.api.*`:
|
|
184
|
+
* grammy rewrites the message of every transport failure, so a message-only
|
|
185
|
+
* shape cannot tell a retryable `HttpError` from a terminal one (#4123). Pass
|
|
186
|
+
* the error and the predicate classifies by type, exactly as the loop does.
|
|
187
|
+
*/
|
|
188
|
+
cause?: unknown
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Will the enclosing retry policy issue ANOTHER attempt after this failure?
|
|
193
|
+
*
|
|
194
|
+
* `true` means the failure is non-terminal: a later attempt may still deliver,
|
|
195
|
+
* so it must not be logged (or detected) as a delivery failure. `false` means
|
|
196
|
+
* either there is no retry policy above this call, or this attempt is the last
|
|
197
|
+
* one, or the error class is not retryable — i.e. the failure IS the outcome.
|
|
198
|
+
*
|
|
199
|
+
* Mirrors `createRetryApiCall`'s loop exactly:
|
|
200
|
+
* - no enclosing policy ⇒ terminal
|
|
201
|
+
* - last attempt (`attempt >= maxRetries-1`) ⇒ terminal (loop falls through)
|
|
202
|
+
* - 429 with `retry_after` over the sleep ceiling ⇒ terminal (FLOOD_WAIT_ACTIVE)
|
|
203
|
+
* - 429 under the ceiling ⇒ retried
|
|
204
|
+
* - transient transport error ⇒ retried
|
|
205
|
+
* - anything else ⇒ terminal (rethrown)
|
|
206
|
+
*/
|
|
207
|
+
export function willRetryTelegramFailure(
|
|
208
|
+
failure: TgFailureShape,
|
|
209
|
+
ctx: TgAttemptContext | undefined = _getTgAttemptContext(),
|
|
210
|
+
): boolean {
|
|
211
|
+
if (ctx == null) return false
|
|
212
|
+
if (ctx.attempt >= ctx.maxRetries - 1) return false
|
|
213
|
+
if (failure.errorCode === 429) {
|
|
214
|
+
const retryAfter = Number(failure.retryAfterSec ?? 5)
|
|
215
|
+
const delayMs = (Number.isFinite(retryAfter) ? retryAfter : 5) * 1000
|
|
216
|
+
return delayMs <= ctx.maxFloodSleepMs
|
|
217
|
+
}
|
|
218
|
+
if (failure.errorCode != null) return false
|
|
219
|
+
// Classify by TYPE when the caller handed us the error (the seam that
|
|
220
|
+
// matters — see `isTransientTransportError`); fall back to the message only
|
|
221
|
+
// for shapes reconstructed from a log line, which have nothing else.
|
|
222
|
+
if (failure.cause != null) return isTransientTransportError(failure.cause)
|
|
223
|
+
return isTransientTransportError(new Error(failure.message ?? ''))
|
|
224
|
+
}
|
|
30
225
|
|
|
31
226
|
export interface RetryCallOpts {
|
|
32
227
|
/**
|
|
@@ -90,6 +285,42 @@ export interface RetryCallOpts {
|
|
|
90
285
|
* the authoritative statement.
|
|
91
286
|
*/
|
|
92
287
|
priorityClass?: 'critical' | 'useful' | 'cosmetic'
|
|
288
|
+
/**
|
|
289
|
+
* Opt OUT of the benign-400 swallow for this one call (#4065 follow-up).
|
|
290
|
+
*
|
|
291
|
+
* By default `retryApiCall` treats the benign 400s
|
|
292
|
+
* (`classifyBenignTelegram400`: not-modified / message-to-edit-not-found /
|
|
293
|
+
* message-to-delete-not-found) as non-events and RESOLVES `undefined`. That
|
|
294
|
+
* is right for fire-and-forget card repaints, and wrong — silently, and in
|
|
295
|
+
* the worst direction — for any caller whose contract is "tell me whether
|
|
296
|
+
* the target message still exists".
|
|
297
|
+
*
|
|
298
|
+
* The concrete failure this exists for: the rollout narration card's edit
|
|
299
|
+
* relay (`gateway/rollout-status-edit.ts`) infers edit success from the
|
|
300
|
+
* ABSENCE of a throw and reports it to hostd. A seeded-resume narrator
|
|
301
|
+
* holding a carried `narration_message_id` for a card the operator deleted
|
|
302
|
+
* gets `400 "message to edit not found"` — which the default swallow turns
|
|
303
|
+
* into a resolved promise, so the handler replies `{ok:true}` and hostd
|
|
304
|
+
* records a frozen card as live and never re-posts. The `gone` signal — the
|
|
305
|
+
* whole reason that feature exists — is unreachable on the production path
|
|
306
|
+
* for its primary trigger. Classification has to happen where the error
|
|
307
|
+
* still exists, so the call site asks for the error instead of a
|
|
308
|
+
* lie-shaped success.
|
|
309
|
+
*
|
|
310
|
+
* When true, a benign 400 is RETHROWN unchanged (still `GrammyError`, with
|
|
311
|
+
* its `description` intact) after `observer.onBenign` fires, so the caller
|
|
312
|
+
* classifies it itself. Everything else — 429 handling, the flood breaker,
|
|
313
|
+
* network retries, `THREAD_NOT_FOUND` — is untouched. Default `false`:
|
|
314
|
+
* every existing call site keeps today's exact behaviour.
|
|
315
|
+
*
|
|
316
|
+
* Note this covers BOTH benign kinds deliberately. `not modified` also
|
|
317
|
+
* resolves `undefined` today, which coincidentally matches the intended
|
|
318
|
+
* semantics for an edit relay (the live card already carries this body =
|
|
319
|
+
* success) — but "coincidentally right" is not a contract. Rethrowing both
|
|
320
|
+
* puts ONE classifier in charge (the caller's), so the two kinds can never
|
|
321
|
+
* drift apart on the swallow boundary.
|
|
322
|
+
*/
|
|
323
|
+
rethrowBenign400?: boolean
|
|
93
324
|
}
|
|
94
325
|
|
|
95
326
|
/**
|
|
@@ -133,7 +364,12 @@ export interface RetryObserver {
|
|
|
133
364
|
onRetry?(info: { attempt: number; reason: 'flood_wait' | 'network'; delayMs: number }): void
|
|
134
365
|
/** Fires when max retries is reached and the wrapper gives up. */
|
|
135
366
|
onGiveUp?(info: { attempts: number; error: unknown }): void
|
|
136
|
-
/**
|
|
367
|
+
/**
|
|
368
|
+
* Fires for each benign 400 we CLASSIFIED (not-modified, not-found) —
|
|
369
|
+
* whether it was then swallowed (the default) or rethrown because the call
|
|
370
|
+
* set `rethrowBenign400`. The Telegram-side event is the same either way, so
|
|
371
|
+
* counting it must not depend on the disposition.
|
|
372
|
+
*/
|
|
137
373
|
onBenign?(info: { kind: BenignTelegram400Kind }): void
|
|
138
374
|
}
|
|
139
375
|
|
|
@@ -379,7 +615,10 @@ export function createRetryApiCall(
|
|
|
379
615
|
throw gated
|
|
380
616
|
}
|
|
381
617
|
try {
|
|
382
|
-
|
|
618
|
+
// #3931 — publish the attempt context for the duration of the wire
|
|
619
|
+
// call so the `tg-post` transformer underneath can tell a retried
|
|
620
|
+
// failure from a terminal one.
|
|
621
|
+
return await withAttemptContext({ attempt, maxRetries, maxFloodSleepMs }, fn)
|
|
383
622
|
} catch (err) {
|
|
384
623
|
const isGrammyErr = err instanceof GrammyError
|
|
385
624
|
const msg = err instanceof Error ? err.message : String(err)
|
|
@@ -444,6 +683,11 @@ export function createRetryApiCall(
|
|
|
444
683
|
: null
|
|
445
684
|
if (benignKind !== null) {
|
|
446
685
|
observer?.onBenign?.({ kind: benignKind })
|
|
686
|
+
// #4065 follow-up: a caller whose contract is "does the target
|
|
687
|
+
// message still exist?" opts out of the swallow, because a resolved
|
|
688
|
+
// promise is indistinguishable from a landed edit. Rethrow the
|
|
689
|
+
// ORIGINAL error so the caller classifies off `description`.
|
|
690
|
+
if (opts?.rethrowBenign400) throw err
|
|
447
691
|
return undefined as unknown as T
|
|
448
692
|
}
|
|
449
693
|
|
|
@@ -459,13 +703,12 @@ export function createRetryApiCall(
|
|
|
459
703
|
}
|
|
460
704
|
|
|
461
705
|
// Network-level transient errors — exponential backoff, bounded.
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
) {
|
|
706
|
+
// Classified by TYPE, not by message substring: grammy rewrites the
|
|
707
|
+
// message of every transport failure, so the old substring test never
|
|
708
|
+
// fired in production and this whole branch was dead (#4123). The
|
|
709
|
+
// `!isGrammyErr` guard that used to sit here now lives inside
|
|
710
|
+
// `isTransientTransportError`, which excludes GrammyError explicitly.
|
|
711
|
+
if (isTransientTransportError(err)) {
|
|
469
712
|
if (attempt < maxRetries - 1) {
|
|
470
713
|
const delayMs = Math.pow(2, attempt) * 1000
|
|
471
714
|
log?.(
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* standalone foreman bot before its retirement.
|
|
6
6
|
*
|
|
7
7
|
* What lives here:
|
|
8
|
-
* - `
|
|
9
|
-
*
|
|
8
|
+
* - `installTgPostLogger` / `installRichMarkdownGuard` — the grammy API
|
|
9
|
+
* transformers every outbound call transits.
|
|
10
10
|
* - `makeSwitchroomExec` / `makeSwitchroomExecCombined` — factory fns for
|
|
11
11
|
* the switchroom CLI exec helpers (callers pass their own CLI path / config
|
|
12
12
|
* env so each process can be configured independently).
|
|
@@ -28,8 +28,7 @@ import { execFileSync, spawnSync } from 'child_process'
|
|
|
28
28
|
import { createHash } from 'crypto'
|
|
29
29
|
import { AsyncLocalStorage } from 'async_hooks'
|
|
30
30
|
import { clearStaleTelegramPollingState } from '../startup-reset.js'
|
|
31
|
-
import {
|
|
32
|
-
import { makeFloodWaitRecorder, makeFloodWaitProbe } from '../flood-circuit-breaker.js'
|
|
31
|
+
import { classifyBenignTelegram400, willRetryTelegramFailure } from '../retry-api-call.js'
|
|
33
32
|
import { RICH_MESSAGE_MAX_CHARS } from '../format.js'
|
|
34
33
|
import { shouldEmitTgPost, type TgPostStatus } from './gw-trace-gate.js'
|
|
35
34
|
import { guardAccidentalFormatting } from '../rich-send.js'
|
|
@@ -102,7 +101,7 @@ function shortDesc(raw: string | undefined): string {
|
|
|
102
101
|
*
|
|
103
102
|
* Log shape (one line per POST, on both success and failure):
|
|
104
103
|
*
|
|
105
|
-
* tg-post method=<m> chat=<id> thread=<id|-> parse_mode=<HTML|MarkdownV2|none> bytes=<n> hash=<sha1-12> status=<ok|benign|err> err=<class-or--> code=<http-or--> desc=<short|-->
|
|
104
|
+
* tg-post method=<m> chat=<id> thread=<id|-> parse_mode=<HTML|MarkdownV2|none> bytes=<n> hash=<sha1-12> status=<ok|benign|retry|err> err=<class-or--> code=<http-or--> desc=<short|-->
|
|
106
105
|
*
|
|
107
106
|
* TRUTHFULNESS (#3927): grammY resolves the transformer chain with the raw
|
|
108
107
|
* `ApiResponse`, and only converts `{ok:false}` into a thrown `GrammyError`
|
|
@@ -127,6 +126,17 @@ function shortDesc(raw: string | undefined): string {
|
|
|
127
126
|
* instead, so promoting real rejections out of `status=ok` doesn't bury
|
|
128
127
|
* `grep status=err` under high-volume card-repaint no-ops.
|
|
129
128
|
*
|
|
129
|
+
* ATTEMPT vs OUTCOME (#3931): this transformer runs INSIDE `fn()`, i.e. below
|
|
130
|
+
* the retry policy, so it sees each attempt separately. A 429 that
|
|
131
|
+
* `createRetryApiCall` sleeps and then retries successfully is not a delivery
|
|
132
|
+
* failure — but it used to write `status=err`, and fleet-health's
|
|
133
|
+
* `reply-delivery-failure` detector (`src/fleet-health/detect.ts`) matches per
|
|
134
|
+
* LINE, so every retried-and-DELIVERED reply raised a severity-3 alert. The
|
|
135
|
+
* retry policy now publishes its attempt context (`_getTgAttemptContext`), and
|
|
136
|
+
* a failure the policy is about to retry is logged `status=retry` instead.
|
|
137
|
+
* `status=err` is therefore an OUTCOME: the logical send is over and nothing
|
|
138
|
+
* landed. Nothing is hidden — the retry lines are still emitted verbatim.
|
|
139
|
+
*
|
|
130
140
|
* Body content is never logged — only its length and a 12-char sha1 prefix
|
|
131
141
|
* so we can recognise repeated identical sends without leaking PII. The
|
|
132
142
|
* `code` field carries the Telegram error_code (400/403/429/etc.) on
|
|
@@ -164,12 +174,27 @@ export function installTgPostLogger(bot: Bot): void {
|
|
|
164
174
|
// converts `{ok:false}` to a thrown GrammyError only after this chain
|
|
165
175
|
// returns (see the docblock). Same defensive shape as the edit fuse's
|
|
166
176
|
// `runObserved` (edit-flood-fuse.ts).
|
|
167
|
-
const r = res as unknown as {
|
|
177
|
+
const r = res as unknown as {
|
|
178
|
+
ok?: boolean
|
|
179
|
+
error_code?: number
|
|
180
|
+
description?: string
|
|
181
|
+
parameters?: { retry_after?: number }
|
|
182
|
+
}
|
|
168
183
|
if (r != null && typeof r === 'object' && r.ok === false) {
|
|
169
184
|
const code = r.error_code
|
|
170
185
|
const benign = classifyBenignTelegram400(code, r.description)
|
|
186
|
+
// #3931 — an attempt the enclosing retry policy is about to repeat is
|
|
187
|
+
// not an outcome; label it `retry` so it is never read as a delivery
|
|
188
|
+
// failure. Terminal rejections keep `status=err`.
|
|
189
|
+
const retrying =
|
|
190
|
+
benign === null &&
|
|
191
|
+
willRetryTelegramFailure({
|
|
192
|
+
errorCode: code ?? null,
|
|
193
|
+
retryAfterSec: r.parameters?.retry_after ?? null,
|
|
194
|
+
message: r.description ?? null,
|
|
195
|
+
})
|
|
171
196
|
emit(
|
|
172
|
-
benign !== null ? 'benign' : 'err',
|
|
197
|
+
benign !== null ? 'benign' : retrying ? 'retry' : 'err',
|
|
173
198
|
`telegram_${code ?? 'unknown'}`,
|
|
174
199
|
code != null ? String(code) : '-',
|
|
175
200
|
shortDesc(r.description),
|
|
@@ -186,7 +211,26 @@ export function installTgPostLogger(bot: Bot): void {
|
|
|
186
211
|
const rawDesc = err instanceof GrammyError
|
|
187
212
|
? (err as GrammyError).description
|
|
188
213
|
: (err instanceof Error ? err.message : '')
|
|
189
|
-
|
|
214
|
+
// #3931 — same attempt-vs-outcome rule as the resolved-rejection branch
|
|
215
|
+
// above. This branch carries the transport (`HttpError`) failures, whose
|
|
216
|
+
// transient members the retry policy backs off and repeats.
|
|
217
|
+
//
|
|
218
|
+
// #4123 — pass `cause`, not just `message`. grammy rewrites every
|
|
219
|
+
// transport failure's message to `Network request for '<method>' failed!`,
|
|
220
|
+
// so a message-only shape classified EVERY transport blip as terminal and
|
|
221
|
+
// wrote `status=err` — which fleet-health escalates as a
|
|
222
|
+
// `reply-delivery-failure` even when the next attempt delivers. The
|
|
223
|
+
// predicate needs the error itself to classify by type.
|
|
224
|
+
const retrying = willRetryTelegramFailure({
|
|
225
|
+
errorCode: err instanceof GrammyError ? (err as GrammyError).error_code : null,
|
|
226
|
+
retryAfterSec:
|
|
227
|
+
err instanceof GrammyError
|
|
228
|
+
? ((err as GrammyError).parameters?.retry_after ?? null)
|
|
229
|
+
: null,
|
|
230
|
+
message: err instanceof Error ? err.message : String(err ?? ''),
|
|
231
|
+
cause: err,
|
|
232
|
+
})
|
|
233
|
+
emit(retrying ? 'retry' : 'err', errClass, code, shortDesc(rawDesc))
|
|
190
234
|
throw err
|
|
191
235
|
}
|
|
192
236
|
})
|
|
@@ -248,32 +292,23 @@ export function installRichMarkdownGuard(bot: Bot): void {
|
|
|
248
292
|
})
|
|
249
293
|
}
|
|
250
294
|
|
|
251
|
-
// ─── robustApiCall factory
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
// few seconds once the policy stops sleeping long waits.
|
|
269
|
-
...(opts.floodStatePath
|
|
270
|
-
? {
|
|
271
|
-
onFloodWait: makeFloodWaitRecorder(opts.floodStatePath),
|
|
272
|
-
floodWaitRemainingMs: makeFloodWaitProbe(opts.floodStatePath),
|
|
273
|
-
}
|
|
274
|
-
: {}),
|
|
275
|
-
})
|
|
276
|
-
}
|
|
295
|
+
// ─── robustApiCall factory: REMOVED (#3863) ───────────────────────────────
|
|
296
|
+
//
|
|
297
|
+
// `createRobustApiCall` used to live here as a second `createRetryApiCall`
|
|
298
|
+
// wiring "pre-wired with stderr logging", kept for a standalone foreman bot
|
|
299
|
+
// that was retired. It had ZERO production callers, and its breaker hooks were
|
|
300
|
+
// OPTIONAL (`floodStatePath` defaulted to absent), so the one thing a second
|
|
301
|
+
// wiring must never be — a send path blind to the shared flood window — was
|
|
302
|
+
// its default. Docblocks in `flood-circuit-breaker.ts` and `flood-429-ledger.ts`
|
|
303
|
+
// cited it as a live wiring, which is how a dead function became load-bearing
|
|
304
|
+
// documentation for the breaker's completeness argument.
|
|
305
|
+
//
|
|
306
|
+
// There is one retry wiring per process, built at its own callsite with both
|
|
307
|
+
// breaker hooks (`gateway.ts`'s `robustApiCall`, `outbox-sweep.ts`'s sweep
|
|
308
|
+
// caller) and enforced by `scripts/check-retry-flood-hooks.mjs`. Do not
|
|
309
|
+
// reintroduce a convenience factory here: it re-creates the hook-optional
|
|
310
|
+
// default this deletion removed. `tests/no-robust-api-call-factory.test.ts`
|
|
311
|
+
// guards that.
|
|
277
312
|
|
|
278
313
|
// ─── Markdown escape helpers (#2669) ──────────────────────────────────────
|
|
279
314
|
|
|
@@ -68,8 +68,18 @@ const ZERO_SIGNAL_POLL_METHODS = new Set(['getUpdates', 'getMe'])
|
|
|
68
68
|
* `status=err` signal in high-volume card-repaint no-ops: `grep status=err`
|
|
69
69
|
* stays a list of real failures, and the benign lines are still there,
|
|
70
70
|
* honestly labelled, for anyone who wants them.
|
|
71
|
+
*
|
|
72
|
+
* `retry` (#3931) is a REJECTED call that the enclosing retry policy is about
|
|
73
|
+
* to attempt AGAIN — a 429 under the in-process sleep ceiling, or a transient
|
|
74
|
+
* transport error with attempts left. It is not an outcome: the logical send
|
|
75
|
+
* may still be delivered by the next attempt, and usually is. It exists
|
|
76
|
+
* because `status=err` is consumed as a delivery FAILURE by fleet-health's
|
|
77
|
+
* `reply-delivery-failure` detector (`src/fleet-health/detect.ts`), which
|
|
78
|
+
* matches per LINE — so labelling a survivable attempt `err` escalated
|
|
79
|
+
* successfully delivered replies. Post-#3931 a `status=err` line means the
|
|
80
|
+
* logical send is over and it did not land.
|
|
71
81
|
*/
|
|
72
|
-
export type TgPostStatus = 'ok' | 'benign' | 'err'
|
|
82
|
+
export type TgPostStatus = 'ok' | 'benign' | 'err' | 'retry'
|
|
73
83
|
|
|
74
84
|
/**
|
|
75
85
|
* Decide whether a `tg-post` line should be written.
|
|
@@ -79,6 +89,8 @@ export type TgPostStatus = 'ok' | 'benign' | 'err'
|
|
|
79
89
|
*
|
|
80
90
|
* Always emitted:
|
|
81
91
|
* - any `status=err` (real failures — the whole point of the log),
|
|
92
|
+
* - any `status=retry` (the survivable attempts behind a flood episode —
|
|
93
|
+
* suppressing them would hide the run-up to a ban, #3931),
|
|
82
94
|
* - every other method's `ok` line (sendMessage/editMessageText/... are
|
|
83
95
|
* genuine outbound observability, #656/#657),
|
|
84
96
|
* - `status=benign` on any non-poll method — same volume as the
|
|
@@ -90,7 +102,7 @@ export function shouldEmitTgPost(
|
|
|
90
102
|
verbose: boolean = gwTraceVerbose,
|
|
91
103
|
): boolean {
|
|
92
104
|
if (verbose) return true
|
|
93
|
-
if (status === 'err') return true
|
|
105
|
+
if (status === 'err' || status === 'retry') return true
|
|
94
106
|
return !ZERO_SIGNAL_POLL_METHODS.has(method)
|
|
95
107
|
}
|
|
96
108
|
|
|
@@ -66,6 +66,22 @@ export interface SilentEndState {
|
|
|
66
66
|
* absent for the zero-prose (genuinely empty) silent-end.
|
|
67
67
|
*/
|
|
68
68
|
pendingText?: string
|
|
69
|
+
/**
|
|
70
|
+
* W1-d follow-up (#4141) — one of THIS turn's reply-tool calls came back
|
|
71
|
+
* `is_error: true`, so `pendingText` is prose the model wrote *after* its
|
|
72
|
+
* delivery attempt failed, not text it chose to send as the answer.
|
|
73
|
+
*
|
|
74
|
+
* Stamped by the Stop hook's single-writer election
|
|
75
|
+
* (`silent-end-interrupt-stop.mjs` → `buildNextState`), the only component
|
|
76
|
+
* that can see the transcript; the gateway's own `writeSilentEndState` never
|
|
77
|
+
* sets it. Consumed by `deliverCapturedProse`, which prefixes a one-line
|
|
78
|
+
* provenance banner so the recipient is told the text was scraped off a
|
|
79
|
+
* failed turn rather than sent as an answer.
|
|
80
|
+
*
|
|
81
|
+
* Optional and only ever `true`: absent means "no positive evidence of a
|
|
82
|
+
* throw", which delivers exactly as before. It never suppresses.
|
|
83
|
+
*/
|
|
84
|
+
replyToolThrewThisTurn?: boolean
|
|
69
85
|
}
|
|
70
86
|
|
|
71
87
|
export interface SilentEndDeps {
|
|
@@ -320,8 +336,16 @@ export const CAPTURED_PROSE_MIN_CHARS = 200
|
|
|
320
336
|
export interface CapturedProseDecision {
|
|
321
337
|
/** True → the gateway should deliver `text` directly on this silent-end. */
|
|
322
338
|
deliver: boolean
|
|
323
|
-
/** The prose to deliver; present iff `deliver === true`.
|
|
339
|
+
/** The prose to deliver; present iff `deliver === true`. RAW — never framed
|
|
340
|
+
* here, so the caller's dedup and journal keys stay computed on the same
|
|
341
|
+
* bytes the sweep path uses (#4141). */
|
|
324
342
|
text?: string
|
|
343
|
+
/**
|
|
344
|
+
* #4141 — the persisted record says this turn's reply tool threw. Surfaced
|
|
345
|
+
* (not acted on) so `deliverCapturedProse` can state the prose's provenance.
|
|
346
|
+
* `true` only on positive evidence; never affects `deliver`.
|
|
347
|
+
*/
|
|
348
|
+
replyToolThrewThisTurn?: boolean
|
|
325
349
|
/** Machine-readable reason (for logs / tests). */
|
|
326
350
|
reason:
|
|
327
351
|
| 'captured-prose'
|
|
@@ -394,7 +418,15 @@ export function decideCapturedProseDelivery(
|
|
|
394
418
|
if (deps?.backstopDeliveredNonceHit?.(state.turnId) === true) {
|
|
395
419
|
return { deliver: false, reason: 'already-delivered' }
|
|
396
420
|
}
|
|
397
|
-
return {
|
|
421
|
+
return {
|
|
422
|
+
deliver: true,
|
|
423
|
+
text,
|
|
424
|
+
reason: 'captured-prose',
|
|
425
|
+
// #4141: pass the hook's raw signal through untouched. Deliberately placed
|
|
426
|
+
// AFTER every `deliver:false` gate above — the banner is a labelling
|
|
427
|
+
// concern, and must never become an input to whether we deliver at all.
|
|
428
|
+
...(state.replyToolThrewThisTurn === true ? { replyToolThrewThisTurn: true } : {}),
|
|
429
|
+
}
|
|
398
430
|
}
|
|
399
431
|
|
|
400
432
|
/**
|
|
@@ -60,23 +60,38 @@ export const STATUS_LINE_MAX = 200
|
|
|
60
60
|
export const STATUS_CARD_CHAR_BUDGET = RICH_MESSAGE_MAX_CHARS
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
* Indent marker for a nested (foreground sub-agent) step line
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
63
|
+
* Indent marker for a nested (foreground sub-agent) step line: three U+2800
|
|
64
|
+
* BRAILLE PATTERN BLANK, then the `↳` glyph.
|
|
65
|
+
*
|
|
66
|
+
* Until #3668 the indent was three ASCII spaces, which Telegram's server-side
|
|
67
|
+
* markdown parser LEFT-TRIMS off a content line — so the child block rendered
|
|
68
|
+
* FLAT against its parent steps and the only surviving nesting cue was the `↳`
|
|
69
|
+
* glyph. This constant now uses the SAME idiom as {@link WORKER_STEP_INDENT}
|
|
70
|
+
* for exactly the same reason, and there is only one indent idiom on the card
|
|
71
|
+
* surfaces again.
|
|
72
|
+
*
|
|
73
|
+
* The evidence is not an inference about Telegram's parser: U+2800 is general
|
|
74
|
+
* category So (Symbol, other), not Zs, so no whitespace-trimming rule — ASCII
|
|
75
|
+
* `\s`, Unicode `White_Space`, or `Zs` — can classify it as whitespace, yet it
|
|
76
|
+
* renders as blank width. It is ALSO live-verified: on 2026-07-26 four
|
|
77
|
+
* candidates were sent to a real phone in one message — three U+00A0 rendered
|
|
78
|
+
* FLAT (the #3662 failure), three U+2800 INDENTED CORRECTLY, a leading `↳ `
|
|
79
|
+
* rendered as visible ink, and a leading `· ` was promoted into a real list
|
|
80
|
+
* bullet. See {@link WORKER_STEP_INDENT}'s comment for the full record.
|
|
81
|
+
*
|
|
82
|
+
* The `↳ ` glyph is KEPT: it is what distinguishes a foreground sub-agent's
|
|
83
|
+
* step from the parent's own steps on a card where both appear, and the
|
|
84
|
+
* indent is what makes the block read as nested. The blanks lead so the
|
|
85
|
+
* glyph is not the thing being trimmed.
|
|
86
|
+
*
|
|
87
|
+
* Guard: `nested-prefix-indent.test.ts` asserts the PROPERTY (no character of
|
|
88
|
+
* the indent is whitespace under any of the three rules), not just the bytes —
|
|
89
|
+
* a byte-only assertion is what let #3662 ship green and inert.
|
|
90
|
+
*
|
|
91
|
+
* @see WORKER_STEP_INDENT — the same U+2800 idiom for the combined
|
|
92
|
+
* (2+ worker) card, and the full evidence for why ASCII and U+00A0 cannot work.
|
|
78
93
|
*/
|
|
79
|
-
export const NESTED_PREFIX = '
|
|
94
|
+
export const NESTED_PREFIX = '\u2800\u2800\u2800↳ '
|
|
80
95
|
|
|
81
96
|
/**
|
|
82
97
|
* Left indent for a step line rendered UNDER a worker header on the combined
|