switchroom 0.19.42 → 0.19.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/agent-scheduler/index.js +1 -1
  2. package/dist/auth-broker/index.js +32 -7
  3. package/dist/cli/notion-write-pretool.mjs +1 -1
  4. package/dist/cli/switchroom.js +1187 -669
  5. package/dist/host-control/main.js +205 -28
  6. package/dist/vault/approvals/kernel-server.js +32 -7
  7. package/dist/vault/broker/server.js +32 -7
  8. package/package.json +3 -3
  9. package/profiles/_base/start.sh.hbs +82 -4
  10. package/telegram-plugin/bridge/bridge.ts +1 -1
  11. package/telegram-plugin/card-layout.ts +63 -1
  12. package/telegram-plugin/dist/bridge/bridge.js +4 -1
  13. package/telegram-plugin/dist/gateway/gateway.js +1225 -614
  14. package/telegram-plugin/dist/server.js +5 -2
  15. package/telegram-plugin/flood-429-ledger.ts +5 -3
  16. package/telegram-plugin/flood-circuit-breaker.ts +11 -3
  17. package/telegram-plugin/format.ts +223 -13
  18. package/telegram-plugin/gateway/boot-card.ts +7 -2
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
  20. package/telegram-plugin/gateway/gateway.ts +49 -51
  21. package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
  22. package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
  23. package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
  24. package/telegram-plugin/gateway/ipc-server.ts +13 -4
  25. package/telegram-plugin/gateway/obligation-store.ts +55 -0
  26. package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
  27. package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
  28. package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
  29. package/telegram-plugin/gateway/stream-render.ts +5 -0
  30. package/telegram-plugin/gateway/update-announce.ts +11 -4
  31. package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
  32. package/telegram-plugin/hooks/audience-classify.mjs +364 -0
  33. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
  34. package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
  35. package/telegram-plugin/outbox.ts +103 -2
  36. package/telegram-plugin/render/rich-render.ts +10 -2
  37. package/telegram-plugin/reply-quote.ts +150 -0
  38. package/telegram-plugin/retry-api-call.ts +254 -11
  39. package/telegram-plugin/shared/bot-runtime.ts +69 -34
  40. package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
  41. package/telegram-plugin/silent-end.ts +34 -2
  42. package/telegram-plugin/status-no-truncate.ts +31 -16
  43. package/telegram-plugin/stream-controller.ts +44 -9
  44. package/telegram-plugin/stream-reply-handler.ts +5 -3
  45. package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
  46. package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
  47. package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
  48. package/telegram-plugin/tests/card-variants.golden.txt +9 -9
  49. package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
  50. package/telegram-plugin/tests/format-consistency.test.ts +160 -14
  51. package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
  52. package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
  53. package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
  54. package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
  55. package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
  56. package/telegram-plugin/tests/obligation-store.test.ts +51 -0
  57. package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
  58. package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
  59. package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
  60. package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
  61. package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
  62. package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
  63. package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
  64. package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
  65. package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
  66. package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
  67. package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
  68. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
  69. package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
  70. package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
  71. package/telegram-plugin/tool-activity-summary.ts +58 -30
  72. package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
@@ -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
- * | Network error (fetch failed / ECONN…) | exponential backoff retry, 3 attempts |
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
- /** Fires for each benign error we swallowed (not-modified, not-found). */
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
- return await fn()
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
- if (
463
- !isGrammyErr &&
464
- (msg.includes('ECONNRESET') ||
465
- msg.includes('ETIMEDOUT') ||
466
- msg.includes('fetch failed') ||
467
- msg.includes('ENOTFOUND'))
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
- * - `createRobustApiCall` thin re-export of createRetryApiCall pre-wired
9
- * with stderr logging (mirrors how gateway.ts constructs `robustApiCall`).
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 { createRetryApiCall, classifyBenignTelegram400 } from '../retry-api-call.js'
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 { ok?: boolean; error_code?: number; description?: string }
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
- emit('err', errClass, code, shortDesc(rawDesc))
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
- * Creates a robust API call wrapper pre-wired with stderr logging.
255
- * This is exactly how gateway.ts constructs its `robustApiCall`.
256
- *
257
- * Usage:
258
- * const robustApiCall = createRobustApiCall()
259
- */
260
- export function createRobustApiCall(opts: { floodStatePath?: string } = {}) {
261
- return createRetryApiCall({
262
- log: (line) => process.stderr.write(line),
263
- // #2923: persist every observed 429 flood-wait window so a restart during
264
- // the ban can suppress non-essential sends (boot cards) instead of feeding
265
- // the per-bot flood counter and prolonging the ban.
266
- // #3084: and refuse to issue a call while that window is still open —
267
- // otherwise the card heartbeats re-drive a request into the ban every
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 { deliver: true, text, reason: 'captured-prose' }
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
- * NOTE: the three leading spaces here are ASCII, which Telegram's server-side
66
- * markdown parser DROPS — the visible nesting cue on this surface is the `↳`
67
- * glyph, not the indent. That is a known latent wart, deliberately left alone
68
- * it needs its own live render check on the single-worker / agent card,
69
- * tracked in #3668. It is NOT broken by the #3662 mechanism (nothing here
70
- * depends on the ASCII run being visible), so it is not fixed here. If #3668
71
- * ever does make it a real indent, use U+2800 — NOT U+00A0, which was tried in
72
- * #3662 and renders flat. Do NOT copy this string as the idiom for a real
73
- * indent.
74
- *
75
- * @see WORKER_STEP_INDENTthe U+2800 indent used for actual left-nesting on
76
- * the combined (2+ worker) card, and the evidence for why ASCII (and U+00A0)
77
- * cannot work.
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