switchroom 0.19.30 → 0.19.31

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 (43) hide show
  1. package/dist/cli/switchroom.js +1121 -584
  2. package/dist/host-control/main.js +151 -73
  3. package/package.json +1 -1
  4. package/profiles/_base/cron-session.sh.hbs +5 -1
  5. package/profiles/_base/start.sh.hbs +15 -1
  6. package/telegram-plugin/dist/gateway/gateway.js +1435 -551
  7. package/telegram-plugin/edit-flood-fuse.ts +70 -20
  8. package/telegram-plugin/gateway/boot-beacon.ts +364 -0
  9. package/telegram-plugin/gateway/boot-sweep-gate.ts +20 -15
  10. package/telegram-plugin/gateway/gateway.ts +87 -88
  11. package/telegram-plugin/gateway/inbound-spool.ts +39 -0
  12. package/telegram-plugin/gateway/narrative-lane.ts +12 -0
  13. package/telegram-plugin/gateway/obligation-store.ts +28 -0
  14. package/telegram-plugin/gateway/stale-pin-sweep-store.ts +221 -0
  15. package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +211 -0
  16. package/telegram-plugin/gateway/stale-pin-sweep.test.ts +804 -0
  17. package/telegram-plugin/gateway/stale-pin-sweep.ts +1146 -0
  18. package/telegram-plugin/gateway/status-pin-retarget.ts +15 -2
  19. package/telegram-plugin/gateway/status-pin-store.ts +33 -11
  20. package/telegram-plugin/registry/turns-schema.ts +21 -1
  21. package/telegram-plugin/retry-api-call.ts +46 -21
  22. package/telegram-plugin/shared/bot-runtime.ts +61 -17
  23. package/telegram-plugin/shared/gw-trace-gate.ts +18 -2
  24. package/telegram-plugin/tests/activity-card-wiring.test.ts +7 -7
  25. package/telegram-plugin/tests/activity-drain-fuse-drop-not-failure.test.ts +324 -0
  26. package/telegram-plugin/tests/agent-card-result-footer.test.ts +193 -0
  27. package/telegram-plugin/tests/boot-beacon.test.ts +462 -0
  28. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +6 -6
  29. package/telegram-plugin/tests/boot-sweep-gate.test.ts +42 -31
  30. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +2 -0
  31. package/telegram-plugin/tests/inbound-spool-progress.test.ts +2 -0
  32. package/telegram-plugin/tests/inbound-spool.test.ts +134 -5
  33. package/telegram-plugin/tests/narrative-lane-golden.test.ts +28 -0
  34. package/telegram-plugin/tests/obligation-determinism.test.ts +2 -0
  35. package/telegram-plugin/tests/obligation-store.test.ts +67 -1
  36. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  37. package/telegram-plugin/tests/status-pin-store.test.ts +26 -5
  38. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +161 -0
  39. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +30 -0
  40. package/telegram-plugin/tool-activity-summary.ts +104 -38
  41. package/telegram-plugin/worker-activity-feed.ts +33 -16
  42. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +0 -251
  43. package/telegram-plugin/gateway/dm-pin-sweep.ts +0 -178
@@ -79,6 +79,15 @@ export interface StatusPinClaim {
79
79
  messageId: number
80
80
  /** Chat the pin lives in. Never empty — the reconcile refuses an empty chat. */
81
81
  chatId: string
82
+ /**
83
+ * Forum topic the pinned message lives in, when known.
84
+ *
85
+ * The claim is keyed by `(chat, thread)`. It is NOT needed to unpin this one
86
+ * message (`unpinChatMessage` takes no `message_thread_id`), but it is the
87
+ * only thing that lets a sweep aim `unpinAllForumTopicMessages` at the right
88
+ * topic when a crash leaves a chat-wide stack of orphans behind.
89
+ */
90
+ threadId?: number
82
91
  /** Wall-clock ms the claim was FIRST taken (survives a same-id re-pin). */
83
92
  pinnedAt: number
84
93
  }
@@ -86,6 +95,9 @@ export interface StatusPinClaim {
86
95
  export interface StatusPinReconcileArgs {
87
96
  pinKey: string
88
97
  chatId: string
98
+ /** Forum topic the pinned message lives in, when the caller knows it. Carried
99
+ * into both the in-memory claim and the durable row (see StatusPinClaim). */
100
+ threadId?: number
89
101
  /** The claim held for this key when the reconcile started. */
90
102
  prev: PinState | null
91
103
  /** What the caller wants pinned for this key now. */
@@ -116,7 +128,7 @@ export interface StatusPinReconcileArgs {
116
128
  * is read once by the caller and both legs run inside that critical section.
117
129
  */
118
130
  export async function runStatusPinReconcile(args: StatusPinReconcileArgs): Promise<void> {
119
- const { pinKey, chatId, prev, desired, persist, runPin, claims } = args
131
+ const { pinKey, chatId, threadId, prev, desired, persist, runPin, claims } = args
120
132
  const now = args.now ?? Date.now
121
133
 
122
134
  // Publish a leg's outcome into the single claim registry. `pinnedAt` is
@@ -128,7 +140,7 @@ export async function runStatusPinReconcile(args: StatusPinReconcileArgs): Promi
128
140
  return
129
141
  }
130
142
  const pinnedAt = claims.get(pinKey)?.pinnedAt ?? now()
131
- claims.set(pinKey, { messageId: next.messageId, chatId, pinnedAt })
143
+ claims.set(pinKey, { messageId: next.messageId, chatId, threadId, pinnedAt })
132
144
  }
133
145
 
134
146
  // One leg, with the persist ordering its action requires. Only a fresh `pin`
@@ -152,6 +164,7 @@ export async function runStatusPinReconcile(args: StatusPinReconcileArgs): Promi
152
164
  fs: persist.fs,
153
165
  pinKey,
154
166
  chatId,
167
+ threadId,
155
168
  op,
156
169
  applyPin: () => runPin(legAction, from),
157
170
  now: now(),
@@ -55,6 +55,23 @@ export interface StatusPinStoreFsSeam {
55
55
  export interface PersistedStatusPin {
56
56
  pinKey: string
57
57
  chatId: string
58
+ /**
59
+ * Forum topic the pinned message lives in, when the caller knows it.
60
+ *
61
+ * The durable claim is keyed by `(chat, thread)`, not by chat alone. Telegram's
62
+ * pin stack is CHAT-WIDE — a "topic pin" is a chat-level pin whose message
63
+ * happens to sit in that thread, and `pinChatMessage`/`unpinChatMessage` take
64
+ * no `message_thread_id` at all. So the thread is NOT needed to unpin one
65
+ * known message; it is needed to DRAIN a topic, because the only topic-scoped
66
+ * pin verb a bot has is `unpinAllForumTopicMessages(chat_id, message_thread_id)`.
67
+ *
68
+ * Without this field a row records "there were orphan pins in forum chat X"
69
+ * and nothing more, so a boot after a crash cannot aim the one verb that would
70
+ * clear them — which is how a chat-wide stack grows on every restart. Optional
71
+ * so a v1–v3 snapshot still loads; a row without it degrades to the
72
+ * chat-level path.
73
+ */
74
+ threadId?: number
58
75
  messageId: number
59
76
  /** True while the pin API call is in-flight / unconfirmed (see above). */
60
77
  pending?: boolean
@@ -102,16 +119,17 @@ export const BOOT_UNPIN_MAX_ATTEMPTS = 5
102
119
 
103
120
  /** Envelope version. v1 had no `pending` field; a v1 row loads as a confirmed
104
121
  * pin (pending undefined). v2 adds the optional `pending` flag. v3 adds the
105
- * optional `pinnedAt` claim timestamp (#3810). All load fail-open an
106
- * unknown/newer version yields []. Every field added since v1 is optional, so
107
- * the versions are mutually readable and a downgrade degrades rather than
108
- * breaks. */
122
+ * optional `pinnedAt` claim timestamp (#3810). v4 adds the optional `threadId`
123
+ * so the claim is keyed by (chat, thread) and a forum topic can be drained
124
+ * after a crash. All load fail-open an unknown/newer version yields [].
125
+ * Every field added since v1 is optional, so the versions are mutually
126
+ * readable and a downgrade degrades rather than breaks. */
109
127
  interface SnapshotEnvelope {
110
- v: 1 | 2 | 3
128
+ v: 1 | 2 | 3 | 4
111
129
  pins: PersistedStatusPin[]
112
130
  }
113
131
 
114
- const SNAPSHOT_VERSIONS = new Set([1, 2, 3])
132
+ const SNAPSHOT_VERSIONS = new Set([1, 2, 3, 4])
115
133
 
116
134
  function isPinRow(x: unknown): x is PersistedStatusPin {
117
135
  if (x == null || typeof x !== 'object') return false
@@ -121,6 +139,7 @@ function isPinRow(x: unknown): x is PersistedStatusPin {
121
139
  o.pinKey.length > 0 &&
122
140
  typeof o.chatId === 'string' &&
123
141
  o.chatId.length > 0 &&
142
+ (o.threadId === undefined || typeof o.threadId === 'number') &&
124
143
  typeof o.messageId === 'number' &&
125
144
  (o.pending === undefined || typeof o.pending === 'boolean') &&
126
145
  (o.expiresAt === undefined || typeof o.expiresAt === 'number') &&
@@ -170,7 +189,7 @@ export function persistStatusPins(
170
189
  snapshot: readonly PersistedStatusPin[],
171
190
  log: (line: string) => void = (l) => process.stderr.write(l),
172
191
  ): void {
173
- const env: SnapshotEnvelope = { v: 3, pins: [...snapshot] }
192
+ const env: SnapshotEnvelope = { v: 4, pins: [...snapshot] }
174
193
  const tmp = path + '.tmp'
175
194
  try {
176
195
  fs.writeFileSync(tmp, JSON.stringify(env))
@@ -450,6 +469,9 @@ export function reconcileAndPersistStatusPin(args: {
450
469
  fs: StatusPinStoreFsSeam
451
470
  pinKey: string
452
471
  chatId: string
472
+ /** Forum topic the pinned message lives in, when known — persisted so a boot
473
+ * after a crash can aim `unpinAllForumTopicMessages` at the right topic. */
474
+ threadId?: number
453
475
  op: StatusPinPersistOp
454
476
  /** Execute the real pin/unpin; returns the confirmed message id (pin) or
455
477
  * null (cleared). Must never throw — API errors are swallowed inside. */
@@ -458,7 +480,7 @@ export function reconcileAndPersistStatusPin(args: {
458
480
  now?: number
459
481
  log?: (line: string) => void
460
482
  }): Promise<{ messageId: number } | null> {
461
- const { path, fs, pinKey, chatId, op } = args
483
+ const { path, fs, pinKey, chatId, threadId, op } = args
462
484
  const log = args.log ?? ((l: string) => process.stderr.write(l))
463
485
  const now = args.now ?? Date.now()
464
486
 
@@ -489,7 +511,7 @@ export function reconcileAndPersistStatusPin(args: {
489
511
  path,
490
512
  fs,
491
513
  pinKey,
492
- { pinKey, chatId, messageId: op.messageId, pending: true, pinnedAt },
514
+ { pinKey, chatId, threadId, messageId: op.messageId, pending: true, pinnedAt },
493
515
  log,
494
516
  )
495
517
  const next = await args.applyPin()
@@ -504,7 +526,7 @@ export function reconcileAndPersistStatusPin(args: {
504
526
  path,
505
527
  fs,
506
528
  pinKey,
507
- { pinKey, chatId, messageId: next.messageId, pinnedAt: claimAge(next.messageId) },
529
+ { pinKey, chatId, threadId, messageId: next.messageId, pinnedAt: claimAge(next.messageId) },
508
530
  log,
509
531
  )
510
532
  return next
@@ -541,7 +563,7 @@ export function reconcileAndPersistStatusPin(args: {
541
563
  path,
542
564
  fs,
543
565
  pinKey,
544
- { pinKey, chatId, messageId: next.messageId, pinnedAt: claimAge(next.messageId) },
566
+ { pinKey, chatId, threadId, messageId: next.messageId, pinnedAt: claimAge(next.messageId) },
545
567
  log,
546
568
  )
547
569
  }
@@ -240,7 +240,27 @@ const PHASE4_MIGRATIONS = [
240
240
 
241
241
  function applySchema(db: SqliteDatabase): void {
242
242
  db.exec('PRAGMA journal_mode = WAL')
243
- db.exec('PRAGMA synchronous = NORMAL')
243
+ // FULL, not NORMAL. This registry is the crash-recovery ledger: the turn row
244
+ // is what tells the boot-time resume protocol that a turn was in flight when
245
+ // the process died. Under WAL, `synchronous = NORMAL` deliberately does NOT
246
+ // fsync the WAL on commit — it syncs only at checkpoint — so a container kill
247
+ // is survived but a HOST POWER CUT can lose the last commits, including the
248
+ // whole turn row. The interrupted turn then becomes invisible to resume:
249
+ // silently unrecoverable, which is exactly the case this ledger exists for.
250
+ //
251
+ // Cost is bounded and measured, not assumed: on this fleet's ext4/NVMe volume
252
+ // a FULL commit costs ~0.87ms mean (p99 1.7ms) vs ~0.011ms at NORMAL, i.e. a
253
+ // ~1150 commits/s ceiling. Steady state is ~330 commits/day, and the hot path
254
+ // is `bumpSubagentActivity` (subagents-schema.ts) at ~1Hz per running worker
255
+ // — ~4 commits/s during fan-out, a ~0.35% duty cycle.
256
+ //
257
+ // `synchronous` is per-connection and NOT persisted in the DB file (unlike
258
+ // `journal_mode`), so it binds only connections that set it. The PreToolUse
259
+ // subagent tracker (`hooks/subagent-tracker-pretool.mjs`) opens this same
260
+ // registry.db without the pragma and has therefore been running at SQLite's
261
+ // default FULL on the tool-call hot path since it shipped; the gateway's
262
+ // NORMAL was the outlier.
263
+ db.exec('PRAGMA synchronous = FULL')
244
264
  // Concurrency: multiple writers contend on this registry (the PreToolUse
245
265
  // subagent-tracker hook, the gateway's subagent-watcher backfill, the turns
246
266
  // writer) — especially when several sub-agents dispatch at once. Without a
@@ -92,13 +92,49 @@ export interface RetryCallOpts {
92
92
  priorityClass?: 'critical' | 'useful' | 'cosmetic'
93
93
  }
94
94
 
95
+ /**
96
+ * The three benign Telegram 400s this policy swallows (contract table above).
97
+ */
98
+ export type BenignTelegram400Kind = 'not_modified' | 'message_not_found' | 'delete_not_found'
99
+
100
+ /**
101
+ * Single source of truth for "is this Telegram 400 a benign no-op?".
102
+ *
103
+ * Benign here means exactly what the retry policy already treats as a
104
+ * non-event: the edit was a no-op because the content is identical, or the
105
+ * target message vanished before the edit/delete landed. Both are
106
+ * high-volume on a healthy agent (every coalesced card repaint can produce
107
+ * one) and neither means anything is wrong — so any surface that reports
108
+ * Telegram failures needs to tell them apart from a real rejection WITHOUT
109
+ * maintaining a second copy of the description list (#3927). The retry
110
+ * policy's own swallow branches call this, so there is one list.
111
+ *
112
+ * Returns the benign kind, or `null` for everything else — including 429,
113
+ * 403, and any 400 not on this deliberately narrow list. A new benign
114
+ * description belongs here, not in a caller.
115
+ */
116
+ export function classifyBenignTelegram400(
117
+ errorCode: number | undefined,
118
+ description: string | undefined,
119
+ ): BenignTelegram400Kind | null {
120
+ if (errorCode !== 400) return null
121
+ const desc = (description ?? '').toLowerCase()
122
+ // "message is not modified" / "message was not modified" — Telegram's
123
+ // no-op-on-equal-text.
124
+ if (desc.includes('not modified')) return 'not_modified'
125
+ // "message to edit/delete not found" — the target vanished.
126
+ if (desc.includes('message to edit not found')) return 'message_not_found'
127
+ if (desc.includes('message to delete not found')) return 'delete_not_found'
128
+ return null
129
+ }
130
+
95
131
  export interface RetryObserver {
96
132
  /** Fires just before sleeping for a retry. */
97
133
  onRetry?(info: { attempt: number; reason: 'flood_wait' | 'network'; delayMs: number }): void
98
134
  /** Fires when max retries is reached and the wrapper gives up. */
99
135
  onGiveUp?(info: { attempts: number; error: unknown }): void
100
136
  /** Fires for each benign error we swallowed (not-modified, not-found). */
101
- onBenign?(info: { kind: 'not_modified' | 'message_not_found' | 'delete_not_found' }): void
137
+ onBenign?(info: { kind: BenignTelegram400Kind }): void
102
138
  }
103
139
 
104
140
  export interface RetryApiCallConfig {
@@ -399,26 +435,15 @@ export function createRetryApiCall(
399
435
  continue
400
436
  }
401
437
 
402
- // Swallow "message is not modified" Telegram's no-op-on-equal-text.
403
- if (
404
- isGrammyErr &&
405
- (err as GrammyError).error_code === 400 &&
406
- desc.includes('not modified')
407
- ) {
408
- observer?.onBenign?.({ kind: 'not_modified' })
409
- return undefined as unknown as T
410
- }
411
-
412
- // Swallow "message to edit/delete not found" — target vanished.
413
- if (
414
- isGrammyErr &&
415
- (err as GrammyError).error_code === 400 &&
416
- (desc.includes('message to edit not found') ||
417
- desc.includes('message to delete not found'))
418
- ) {
419
- observer?.onBenign?.({
420
- kind: desc.includes('edit') ? 'message_not_found' : 'delete_not_found',
421
- })
438
+ // Swallow the benign 400s — "message is not modified" (Telegram's
439
+ // no-op-on-equal-text) and "message to edit/delete not found" (the
440
+ // target vanished). Classification lives in `classifyBenignTelegram400`
441
+ // so the tg-post logger reports the same family identically (#3927).
442
+ const benignKind = isGrammyErr
443
+ ? classifyBenignTelegram400((err as GrammyError).error_code, desc)
444
+ : null
445
+ if (benignKind !== null) {
446
+ observer?.onBenign?.({ kind: benignKind })
422
447
  return undefined as unknown as T
423
448
  }
424
449
 
@@ -28,10 +28,10 @@ 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 } from '../retry-api-call.js'
31
+ import { createRetryApiCall, classifyBenignTelegram400 } from '../retry-api-call.js'
32
32
  import { makeFloodWaitRecorder, makeFloodWaitProbe } from '../flood-circuit-breaker.js'
33
33
  import { RICH_MESSAGE_MAX_CHARS } from '../format.js'
34
- import { shouldEmitTgPost } from './gw-trace-gate.js'
34
+ import { shouldEmitTgPost, type TgPostStatus } from './gw-trace-gate.js'
35
35
  import { guardAccidentalFormatting } from '../rich-send.js'
36
36
 
37
37
  // ─── tg-post tag plumbing ─────────────────────────────────────────────────
@@ -80,6 +80,17 @@ function formatTgPostTags(tags: TgPostTags | undefined): string {
80
80
 
81
81
  // ─── tg-post observability transformer ────────────────────────────────────
82
82
 
83
+ /**
84
+ * Sanitise a Telegram error description for single-line log output —
85
+ * collapse whitespace, strip newlines, cap at 80 chars, `-` when absent.
86
+ * PII-safe: Telegram error descriptions are server-generated and don't
87
+ * echo the request body.
88
+ */
89
+ function shortDesc(raw: string | undefined): string {
90
+ if (!raw) return '-'
91
+ return raw.replace(/\s+/g, ' ').slice(0, 80).replace(/[\r\n]/g, ' ') || '-'
92
+ }
93
+
83
94
  /**
84
95
  * Installs an API transformer on the bot that emits one stderr line per
85
96
  * outbound Telegram Bot API POST. This is the single catchment point for
@@ -91,7 +102,30 @@ function formatTgPostTags(tags: TgPostTags | undefined): string {
91
102
  *
92
103
  * Log shape (one line per POST, on both success and failure):
93
104
  *
94
- * tg-post method=<m> chat=<id> thread=<id|-> parse_mode=<HTML|MarkdownV2|none> bytes=<n> hash=<sha1-12> status=<ok|err> err=<class-or--> code=<http-or--> desc=<short|-->
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|-->
106
+ *
107
+ * TRUTHFULNESS (#3927): grammY resolves the transformer chain with the raw
108
+ * `ApiResponse`, and only converts `{ok:false}` into a thrown `GrammyError`
109
+ * AFTER the chain returns — verified in the pinned grammy 1.44.0,
110
+ * `out/core/client.js:95-100`:
111
+ *
112
+ * const data = await this.call(method, payload, signal) // chain
113
+ * if (data.ok) return data.result
114
+ * else throw toGrammyError(data, method, payload) // OUTSIDE
115
+ *
116
+ * So EVERY Telegram-level rejection (429 flood-wait, 400, 403) arrives here
117
+ * as a RESOLVED response, and the `catch` below only ever sees transport
118
+ * (`HttpError`) failures. Before this was fixed the logger reported every
119
+ * one of them as `status=ok err=- code=- desc=-` — a rate-limited
120
+ * `unpinAllChatMessages` logged as SUCCESS 3ms before the retry policy
121
+ * logged `429 rate limited, waiting 3s`. We therefore inspect the resolved
122
+ * body and report `status=err err=telegram_<code>` from it.
123
+ *
124
+ * The narrow set of benign 400s the retry policy already swallows
125
+ * ("message is not modified", "message to edit/delete not found" —
126
+ * classified by the shared `classifyBenignTelegram400`) gets `status=benign`
127
+ * instead, so promoting real rejections out of `status=ok` doesn't bury
128
+ * `grep status=err` under high-volume card-repaint no-ops.
95
129
  *
96
130
  * Body content is never logged — only its length and a 12-char sha1 prefix
97
131
  * so we can recognise repeated identical sends without leaking PII. The
@@ -115,16 +149,34 @@ export function installTgPostLogger(bot: Bot): void {
115
149
  ? createHash('sha1').update(text).digest('hex').slice(0, 12)
116
150
  : '-'
117
151
  const tagSuffix = formatTgPostTags(_getTgPostTags())
118
- try {
119
- const res = await prev(method, payload, signal)
152
+ const emit = (status: TgPostStatus, errClass: string, code: string, desc: string) => {
120
153
  // #3025: suppress zero-signal per-poll heartbeats (getUpdates/getMe
121
154
  // status=ok, one line per ~30s long-poll tick) unless the operator
122
155
  // set SWITCHROOM_GW_TRACE. Errors and all other methods still log.
123
- if (shouldEmitTgPost(method, 'ok')) {
124
- process.stderr.write(
125
- `tg-post method=${method} chat=${chat} thread=${thread} parse_mode=${parseMode} bytes=${bytes} hash=${hash} status=ok err=- code=- desc=-${tagSuffix}\n`,
156
+ if (!shouldEmitTgPost(method, status)) return
157
+ process.stderr.write(
158
+ `tg-post method=${method} chat=${chat} thread=${thread} parse_mode=${parseMode} bytes=${bytes} hash=${hash} status=${status} err=${errClass} code=${code} desc=${desc}${tagSuffix}\n`,
159
+ )
160
+ }
161
+ try {
162
+ const res = await prev(method, payload, signal)
163
+ // A RESOLVED response can still be a Telegram-level rejection — grammy
164
+ // converts `{ok:false}` to a thrown GrammyError only after this chain
165
+ // returns (see the docblock). Same defensive shape as the edit fuse's
166
+ // `runObserved` (edit-flood-fuse.ts).
167
+ const r = res as unknown as { ok?: boolean; error_code?: number; description?: string }
168
+ if (r != null && typeof r === 'object' && r.ok === false) {
169
+ const code = r.error_code
170
+ const benign = classifyBenignTelegram400(code, r.description)
171
+ emit(
172
+ benign !== null ? 'benign' : 'err',
173
+ `telegram_${code ?? 'unknown'}`,
174
+ code != null ? String(code) : '-',
175
+ shortDesc(r.description),
126
176
  )
177
+ return res
127
178
  }
179
+ emit('ok', '-', '-', '-')
128
180
  return res
129
181
  } catch (err) {
130
182
  const errClass = err instanceof GrammyError
@@ -134,15 +186,7 @@ export function installTgPostLogger(bot: Bot): void {
134
186
  const rawDesc = err instanceof GrammyError
135
187
  ? (err as GrammyError).description
136
188
  : (err instanceof Error ? err.message : '')
137
- // Sanitise the description for single-line log output — collapse
138
- // whitespace, strip newlines, cap at 80 chars. PII-safe: Telegram
139
- // error descriptions are server-generated and don't echo body.
140
- const desc = rawDesc
141
- ? rawDesc.replace(/\s+/g, ' ').slice(0, 80).replace(/[\r\n]/g, ' ') || '-'
142
- : '-'
143
- process.stderr.write(
144
- `tg-post method=${method} chat=${chat} thread=${thread} parse_mode=${parseMode} bytes=${bytes} hash=${hash} status=err err=${errClass} code=${code} desc=${desc}${tagSuffix}\n`,
145
- )
189
+ emit('err', errClass, code, shortDesc(rawDesc))
146
190
  throw err
147
191
  }
148
192
  })
@@ -57,6 +57,20 @@ export const gwTraceVerbose: boolean = computeGwTraceVerbose(
57
57
  */
58
58
  const ZERO_SIGNAL_POLL_METHODS = new Set(['getUpdates', 'getMe'])
59
59
 
60
+ /**
61
+ * The status tiers a `tg-post` line can carry.
62
+ *
63
+ * `benign` (#3927) is a REJECTED call that is nonetheless a non-event —
64
+ * the narrow set of 400s the retry policy already swallows ("message is
65
+ * not modified", "message to edit/delete not found"), classified by
66
+ * `classifyBenignTelegram400`. It exists so that promoting resolved
67
+ * `ok:false` responses out of `status=ok` doesn't drown the newly-truthful
68
+ * `status=err` signal in high-volume card-repaint no-ops: `grep status=err`
69
+ * stays a list of real failures, and the benign lines are still there,
70
+ * honestly labelled, for anyone who wants them.
71
+ */
72
+ export type TgPostStatus = 'ok' | 'benign' | 'err'
73
+
60
74
  /**
61
75
  * Decide whether a `tg-post` line should be written.
62
76
  *
@@ -66,11 +80,13 @@ const ZERO_SIGNAL_POLL_METHODS = new Set(['getUpdates', 'getMe'])
66
80
  * Always emitted:
67
81
  * - any `status=err` (real failures — the whole point of the log),
68
82
  * - every other method's `ok` line (sendMessage/editMessageText/... are
69
- * genuine outbound observability, #656/#657).
83
+ * genuine outbound observability, #656/#657),
84
+ * - `status=benign` on any non-poll method — same volume as the
85
+ * `status=ok` line it replaces, just no longer mislabelled as success.
70
86
  */
71
87
  export function shouldEmitTgPost(
72
88
  method: string,
73
- status: 'ok' | 'err',
89
+ status: TgPostStatus,
74
90
  verbose: boolean = gwTraceVerbose,
75
91
  ): boolean {
76
92
  if (verbose) return true
@@ -119,17 +119,17 @@ describe('activity-card durability wiring', () => {
119
119
 
120
120
  it('the boot reaper runs ONLY after the startup mutex is won (never at import time)', () => {
121
121
  // #3026: the reaper is now invoked via the mutex-gated orchestrator
122
- // runBootPinCleanupAndDmSweep() (which runs it alongside
123
- // statusPinBootCleanup + queuedCardBootReaper, then runs the DM
124
- // stale-pin sweep). Invariant unchanged: reachable only post-lock.
122
+ // runBootPinCleanupAndStalePinSweep() (which runs it alongside
123
+ // statusPinBootCleanup + queuedCardBootReaper, then runs the stale-pin
124
+ // stack sweep). Invariant unchanged: reachable only post-lock.
125
125
  // (1) The orchestrator runs the reaper. Since the #3664 S2 salvage the
126
126
  // steps are INJECTED into runBootPinSweepSteps (each individually
127
- // absorbed, so a throwing reaper cannot strand the DM sweep behind it)
127
+ // absorbed, so a throwing reaper cannot strand the sweep behind it)
128
128
  // instead of being awaited inline — so the marker is the step binding.
129
129
  const orchestrator = between(
130
130
  gatewaySrc,
131
- 'function runBootPinCleanupAndDmSweep()',
132
- 'dmPinSweepEligible = true',
131
+ 'function runBootPinCleanupAndStalePinSweep()',
132
+ 'stalePinSweepEligible = true',
133
133
  )
134
134
  expect(orchestrator).toMatch(/activityCardReaper: activityCardBootReaper,/)
135
135
  // (2) Both orchestrator invocation sites (mutex-won + mutex-fallback)
@@ -146,7 +146,7 @@ describe('activity-card durability wiring', () => {
146
146
  // statusPinBootCleanup documents.
147
147
  const beforeMain = gatewaySrc.split('await acquireStartupLock({')[0] ?? ''
148
148
  expect(beforeMain).not.toMatch(/^\s*void activityCardBootReaper\(\)/m)
149
- expect(beforeMain).not.toMatch(/^\s*void runBootPinCleanupAndDmSweep\(\)/m)
149
+ expect(beforeMain).not.toMatch(/^\s*void runBootPinCleanupAndStalePinSweep\(\)/m)
150
150
  expect(beforeMain).not.toMatch(/^\s*bootPinSweepGate\.(arm|botReady)\(\)/m)
151
151
  })
152
152