switchroom 0.19.26 → 0.19.28

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 (92) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +60 -2
  3. package/dist/auth-broker/index.js +244 -13
  4. package/dist/cli/autoaccept-poll.js +225 -17
  5. package/dist/cli/notion-write-pretool.mjs +60 -2
  6. package/dist/cli/switchroom.js +2843 -1220
  7. package/dist/host-control/main.js +245 -14
  8. package/dist/vault/approvals/kernel-server.js +242 -13
  9. package/dist/vault/broker/server.js +242 -13
  10. package/package.json +7 -2
  11. package/profiles/_base/cron-session.sh.hbs +8 -0
  12. package/profiles/_base/start.sh.hbs +175 -15
  13. package/telegram-plugin/card-layout.ts +328 -0
  14. package/telegram-plugin/dist/bridge/bridge.js +94 -1
  15. package/telegram-plugin/dist/gateway/gateway.js +2544 -1182
  16. package/telegram-plugin/dist/server.js +97 -1
  17. package/telegram-plugin/edit-flood-fuse.ts +841 -57
  18. package/telegram-plugin/flood-429-ledger.ts +526 -0
  19. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  20. package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
  21. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  22. package/telegram-plugin/gateway/gateway.ts +67 -70
  23. package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
  24. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  25. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  26. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  27. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  28. package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
  29. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  30. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  31. package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
  32. package/telegram-plugin/llm-error-present.ts +61 -2
  33. package/telegram-plugin/mcp-credential-failure.ts +459 -0
  34. package/telegram-plugin/model-unavailable.ts +8 -0
  35. package/telegram-plugin/operator-events.ts +110 -5
  36. package/telegram-plugin/outbound-class.ts +81 -0
  37. package/telegram-plugin/provider-credit.ts +237 -0
  38. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  39. package/telegram-plugin/send-gate.ts +24 -2
  40. package/telegram-plugin/status-no-truncate.ts +10 -48
  41. package/telegram-plugin/status-pin-driver.ts +33 -45
  42. package/telegram-plugin/status-pin.ts +18 -1
  43. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  44. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  45. package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
  46. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  47. package/telegram-plugin/tests/card-variants.ts +366 -0
  48. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +373 -0
  49. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  50. package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
  51. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  52. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  53. package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
  54. package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
  55. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  56. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  57. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  58. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  59. package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
  60. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  61. package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
  62. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  63. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  64. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  65. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  66. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  67. package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
  68. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  69. package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
  70. package/telegram-plugin/tests/status-pin.test.ts +56 -5
  71. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  72. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  73. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
  74. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  75. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  76. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  77. package/telegram-plugin/tool-activity-summary.ts +239 -365
  78. package/telegram-plugin/uat/assertions.ts +22 -11
  79. package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
  80. package/telegram-plugin/worker-activity-feed.ts +105 -47
  81. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  82. package/vendor/hindsight-memory/scripts/drain_pending.py +433 -11
  83. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  84. package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
  85. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  86. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  87. package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
  88. package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
  89. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
  90. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  91. package/vendor/hindsight-memory/settings.json +1 -1
  92. package/vendor/hindsight-memory/tests/test_hooks.py +11 -2
@@ -70,6 +70,27 @@ export interface PersistedStatusPin {
70
70
  * unpin fails (flood-wait exhausted / transient 5xx); the row is retained
71
71
  * for retry until BOOT_UNPIN_MAX_ATTEMPTS, then forfeited. Absent = 0. */
72
72
  attempts?: number
73
+ /**
74
+ * Wall-clock ms the pin was FIRST claimed for this key+message (#3810).
75
+ *
76
+ * Why the store needs it: the mid-session `wk:` reaper folds in rows that
77
+ * exist on disk but have NO in-memory claim (`storeOnlyWorkerPinCandidates`).
78
+ * The store used to persist no timestamp, so those candidates were stamped
79
+ * `pinnedAt = now` on every pass — deliberately, to avoid a spurious unpin of
80
+ * a pin whose real age was unknown, but with the side effect that the TTL
81
+ * gate could NEVER fire for a store orphan. Combined with a `wk:<agentId>`
82
+ * whose turnsDb row is gone (verdict `'unknown'`, never `'terminal'`), such a
83
+ * row was never mid-session reaped AT ALL — it waited for the next boot, the
84
+ * exact "stale pin glued to the top of the chat" failure the reaper exists to
85
+ * prevent.
86
+ *
87
+ * Recording the real claim time removes the guess: a store orphan now ages
88
+ * honestly and the ordinary TTL applies. Optional so a v1/v2 snapshot still
89
+ * loads; a row without it keeps the old conservative `now` stamp (terminal-
90
+ * only reaping), which self-clears within one restart since every write from
91
+ * this version onward carries the field.
92
+ */
93
+ pinnedAt?: number
73
94
  }
74
95
 
75
96
  /** How many boots may retry a failing boot-cleanup unpin before the row is
@@ -80,13 +101,18 @@ export interface PersistedStatusPin {
80
101
  export const BOOT_UNPIN_MAX_ATTEMPTS = 5
81
102
 
82
103
  /** Envelope version. v1 had no `pending` field; a v1 row loads as a confirmed
83
- * pin (pending undefined). v2 adds the optional `pending` flag. Both load
84
- * fail-open — an unknown/newer version yields []. */
104
+ * 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. */
85
109
  interface SnapshotEnvelope {
86
- v: 1 | 2
110
+ v: 1 | 2 | 3
87
111
  pins: PersistedStatusPin[]
88
112
  }
89
113
 
114
+ const SNAPSHOT_VERSIONS = new Set([1, 2, 3])
115
+
90
116
  function isPinRow(x: unknown): x is PersistedStatusPin {
91
117
  if (x == null || typeof x !== 'object') return false
92
118
  const o = x as Record<string, unknown>
@@ -98,7 +124,8 @@ function isPinRow(x: unknown): x is PersistedStatusPin {
98
124
  typeof o.messageId === 'number' &&
99
125
  (o.pending === undefined || typeof o.pending === 'boolean') &&
100
126
  (o.expiresAt === undefined || typeof o.expiresAt === 'number') &&
101
- (o.attempts === undefined || typeof o.attempts === 'number')
127
+ (o.attempts === undefined || typeof o.attempts === 'number') &&
128
+ (o.pinnedAt === undefined || typeof o.pinnedAt === 'number')
102
129
  )
103
130
  }
104
131
 
@@ -127,7 +154,7 @@ export function loadStatusPins(
127
154
  }
128
155
  if (parsed == null || typeof parsed !== 'object') return []
129
156
  const env = parsed as Record<string, unknown>
130
- if ((env.v !== 1 && env.v !== 2) || !Array.isArray(env.pins)) return []
157
+ if (typeof env.v !== 'number' || !SNAPSHOT_VERSIONS.has(env.v) || !Array.isArray(env.pins)) return []
131
158
  return env.pins.filter(isPinRow)
132
159
  }
133
160
 
@@ -143,7 +170,7 @@ export function persistStatusPins(
143
170
  snapshot: readonly PersistedStatusPin[],
144
171
  log: (line: string) => void = (l) => process.stderr.write(l),
145
172
  ): void {
146
- const env: SnapshotEnvelope = { v: 2, pins: [...snapshot] }
173
+ const env: SnapshotEnvelope = { v: 3, pins: [...snapshot] }
147
174
  const tmp = path + '.tmp'
148
175
  try {
149
176
  fs.writeFileSync(tmp, JSON.stringify(env))
@@ -427,16 +454,34 @@ export function reconcileAndPersistStatusPin(args: {
427
454
  /** Execute the real pin/unpin; returns the confirmed message id (pin) or
428
455
  * null (cleared). Must never throw — API errors are swallowed inside. */
429
456
  applyPin: () => Promise<{ messageId: number } | null>
457
+ /** Clock for the `pinnedAt` stamp (#3810). Defaults to wall clock. */
458
+ now?: number
430
459
  log?: (line: string) => void
431
460
  }): Promise<{ messageId: number } | null> {
432
461
  const { path, fs, pinKey, chatId, op } = args
433
462
  const log = args.log ?? ((l: string) => process.stderr.write(l))
463
+ const now = args.now ?? Date.now()
464
+
465
+ /**
466
+ * The claim age to persist for `messageId`. Carried forward from the row
467
+ * already on disk when it names the SAME message (a steady-state re-write
468
+ * must not reset the age and hand a stale pin a fresh TTL lease); stamped
469
+ * `now` for a genuinely new pin. Mirrors the in-memory `pinnedAt` rule in
470
+ * `status-pin-retarget.ts` so disk and memory can't disagree about age.
471
+ */
472
+ const claimAge = (messageId: number): number => {
473
+ const existing = loadStatusPins(path, fs).find((p) => p.pinKey === pinKey)
474
+ return existing?.messageId === messageId && existing.pinnedAt != null
475
+ ? existing.pinnedAt
476
+ : now
477
+ }
434
478
 
435
479
  // Hold the per-path lock across the WHOLE op so no other writer (a banner
436
480
  // persist, or another key's reconcile) can rebuild/overwrite the file during
437
481
  // the applyPin await window and drop this key's pending/confirmed row.
438
482
  return withStoreLock(path, async () => {
439
483
  if (op.kind === 'pin') {
484
+ const pinnedAt = claimAge(op.messageId)
440
485
  // Persist INTENT first, marked pending — BEFORE the pin API call. If we
441
486
  // crash after the pin lands but before the confirm rewrite, this pending
442
487
  // record is what boot cleanup uses to unpin the orphan.
@@ -444,7 +489,7 @@ export function reconcileAndPersistStatusPin(args: {
444
489
  path,
445
490
  fs,
446
491
  pinKey,
447
- { pinKey, chatId, messageId: op.messageId, pending: true },
492
+ { pinKey, chatId, messageId: op.messageId, pending: true, pinnedAt },
448
493
  log,
449
494
  )
450
495
  const next = await args.applyPin()
@@ -459,7 +504,7 @@ export function reconcileAndPersistStatusPin(args: {
459
504
  path,
460
505
  fs,
461
506
  pinKey,
462
- { pinKey, chatId, messageId: next.messageId },
507
+ { pinKey, chatId, messageId: next.messageId, pinnedAt: claimAge(next.messageId) },
463
508
  log,
464
509
  )
465
510
  return next
@@ -488,11 +533,15 @@ export function reconcileAndPersistStatusPin(args: {
488
533
  if (next == null) {
489
534
  applyStatusPinRow(path, fs, pinKey, null, log)
490
535
  } else {
536
+ // Steady-state noop-clear (the worker feed calls syncPin on every edit):
537
+ // the pin is still up, so the row is preserved AND so is its original
538
+ // claim age — re-stamping it here would hand a genuinely old pin a fresh
539
+ // TTL lease on every feed edit and make it immortal (#3810).
491
540
  applyStatusPinRow(
492
541
  path,
493
542
  fs,
494
543
  pinKey,
495
- { pinKey, chatId, messageId: next.messageId },
544
+ { pinKey, chatId, messageId: next.messageId, pinnedAt: claimAge(next.messageId) },
496
545
  log,
497
546
  )
498
547
  }
@@ -99,6 +99,47 @@ export interface StoreOrphanRow {
99
99
  pending?: boolean
100
100
  /** Time-scoped `tool:` pin. Never a worker orphan; excluded. */
101
101
  expiresAt?: number
102
+ /** Wall-clock ms the claim was first taken (#3810). Absent on a pre-v3 row. */
103
+ pinnedAt?: number
104
+ }
105
+
106
+ /**
107
+ * The live worker-activity feed's liveness probe, as the reaper needs it.
108
+ * Nullable ON PURPOSE — see `groupPinStatus`.
109
+ */
110
+ export interface WorkerFeedLivenessProbe {
111
+ hasRunningInFeed(feedKey: string): boolean
112
+ }
113
+
114
+ /**
115
+ * Verdict for a GROUP-level pin key (`wk:group:<feedKey>`, the shape the
116
+ * coalesced feed produces since #3207). Vouched off the live feed rather than
117
+ * the sub-agent registry: `group:<feedKey>` is not a jsonl agent id.
118
+ *
119
+ * #3811 — the gateway used to write this as
120
+ * `feed?.hasRunningInFeed(key) ? 'running' : 'terminal'`, which collapses two
121
+ * very different facts into one verdict:
122
+ *
123
+ * - "a live feed exists and affirmatively says this group is done" → terminal
124
+ * - "there is NO feed object to ask" → nothing known
125
+ *
126
+ * A `'terminal'` verdict reaps at ANY age — it bypasses the TTL exemption
127
+ * entirely — so the second case could unpin a GENUINELY LIVE group pin out from
128
+ * under running workers during a window where `workerActivityFeed` is null
129
+ * (teardown/rebuild on a bridge flap, and every tick before the first bridge
130
+ * connect). That is the inverse of the stale-pin bug and much harder to notice:
131
+ * the user loses the pin for work that is still in flight.
132
+ *
133
+ * "No feed to ask" is `'unknown'`: the TTL still applies, so a truly stale pin
134
+ * is still bounded, but nothing is reaped on the strength of a question we
135
+ * never got to ask.
136
+ */
137
+ export function groupPinStatus(
138
+ feed: WorkerFeedLivenessProbe | null | undefined,
139
+ feedKey: string,
140
+ ): WorkerRegistryStatus {
141
+ if (feed == null) return 'unknown'
142
+ return feed.hasRunningInFeed(feedKey) ? 'running' : 'terminal'
102
143
  }
103
144
 
104
145
  /**
@@ -110,13 +151,21 @@ export interface StoreOrphanRow {
110
151
  * it too, group-safely (per-message unpin of a bot-tracked row — never an
111
152
  * unpin-all, never a human pin).
112
153
  *
113
- * The store persists NO timestamp, so a store-only candidate carries
114
- * `pinnedAt = now`: only a TERMINAL registry verdict can reap it. It is never
115
- * TTL-reaped (no trustworthy age to age it out), never touched while its worker
116
- * is `running`, and it is always a `wk:` row (the documented leak class).
154
+ * AGE (#3810). A v3+ row carries the real `pinnedAt` the claim was taken at, so
155
+ * a store orphan ages honestly and the ordinary TTL gate applies to it exactly
156
+ * as it does to an in-memory claim. A pre-v3 row has no timestamp and keeps the
157
+ * old conservative stamp (`pinnedAt = now`, i.e. TERMINAL-verdict reaping only)
158
+ * rather than guessing an age — but that case now self-clears within one
159
+ * restart, because every write from v3 onward records the field.
160
+ *
161
+ * Why the honest age matters: `storeOnlyWorkerPinCandidates` was the ONLY net
162
+ * for a `wk:` row with no in-memory claim, and with the TTL permanently
163
+ * disabled a `wk:<agentId>` row whose turnsDb row had been pruned (verdict
164
+ * `'unknown'`, never `'terminal'`) was never mid-session reaped at all — its
165
+ * stale `🛠 Worker` pin sat at the top of the chat until the next gateway boot.
166
+ *
117
167
  * `pending` rows (pin API in-flight) and time-scoped `tool:` rows are excluded,
118
- * as are rows already tracked in memory (the in-memory reaper owns those, with
119
- * a real `pinnedAt` that can drive the TTL).
168
+ * as are rows already tracked in memory (the in-memory reaper owns those).
120
169
  */
121
170
  export function storeOnlyWorkerPinCandidates(args: {
122
171
  rows: Iterable<StoreOrphanRow>
@@ -133,7 +182,7 @@ export function storeOnlyWorkerPinCandidates(args: {
133
182
  out.push({
134
183
  pinKey: r.pinKey,
135
184
  chatId: r.chatId,
136
- pinnedAt: args.now,
185
+ pinnedAt: r.pinnedAt ?? args.now,
137
186
  messageId: r.messageId,
138
187
  })
139
188
  }
@@ -34,6 +34,7 @@ import {
34
34
  type AnyButton,
35
35
  type ButtonValidationError,
36
36
  } from './telegram-button-constraints.js'
37
+ import type { RetryCallOpts } from './retry-api-call.js'
37
38
 
38
39
  /** Prefix used to namespace agent-emitted callback_data on the wire. */
39
40
  export const AGENT_CALLBACK_PREFIX = 'agent:'
@@ -496,6 +497,11 @@ export function validateAndWrapAgentKeyboard(
496
497
  * Minimal callback-context shape the helper needs. Real grammy
497
498
  * `Context` satisfies this; tests can implement a lightweight fake
498
499
  * without dragging the grammy types in.
500
+ *
501
+ * `editMessageReplyMarkup` / `reply` are REQUIRED (not optional) because
502
+ * they are the two rungs of the repaint-failure ladder (#3891). A context
503
+ * that cannot disarm its own keyboard is exactly the context that leaves
504
+ * the operator tapping a corpse, so the type refuses to construct one.
499
505
  */
500
506
  export interface FinalizeCallbackContext {
501
507
  answerCallbackQuery: (
@@ -505,8 +511,68 @@ export interface FinalizeCallbackContext {
505
511
  text: string | { markdown: string },
506
512
  opts?: Record<string, unknown>,
507
513
  ) => Promise<unknown>
514
+ /** Rung 2 of the ladder — strip `reply_markup` without touching the body. */
515
+ editMessageReplyMarkup: (
516
+ opts?: Record<string, unknown>,
517
+ ) => Promise<unknown>
518
+ /** Rung 3 of the ladder — a fresh in-channel message when the card is unfixable. */
519
+ reply: (
520
+ text: string,
521
+ opts?: Record<string, unknown>,
522
+ ) => Promise<unknown>
523
+ /**
524
+ * The raw `callback_query` update, typed `unknown` on purpose: this
525
+ * helper only reads `message.chat.id` off it (defensively, at runtime)
526
+ * to scope the retry policy's flood window, and a structural type here
527
+ * would have to track grammy's `MaybeInaccessibleMessage` union for no
528
+ * benefit. See {@link extractCallbackChatId}.
529
+ */
530
+ callbackQuery?: unknown
531
+ }
532
+
533
+ /**
534
+ * Best-effort `chat_id` for the retry policy's scope-precise flood window.
535
+ *
536
+ * Returns `undefined` rather than throwing on ANY unexpected shape — a
537
+ * missing scope degrades the 429 to a `global` window (still recorded, still
538
+ * respected), whereas a throw here would break the tap path outright.
539
+ */
540
+ export function extractCallbackChatId(callbackQuery: unknown): string | undefined {
541
+ const msg = (callbackQuery as { message?: unknown } | null | undefined)?.message
542
+ const chat = (msg as { chat?: unknown } | null | undefined)?.chat
543
+ const id = (chat as { id?: unknown } | null | undefined)?.id
544
+ if (typeof id === 'number' && Number.isFinite(id)) return String(id)
545
+ if (typeof id === 'string' && id !== '') return id
546
+ return undefined
508
547
  }
509
548
 
549
+ /**
550
+ * The retry/flood seam every leg of {@link finalizeCallback} transits.
551
+ *
552
+ * Structurally the gateway's `robustApiCall` (chat-lock → send gate →
553
+ * `createRetryApiCall`). Declared here rather than imported as
554
+ * `typeof robustApiCall` so this module keeps its zero-dependency-on-gateway
555
+ * shape and tests can hand in a counting fake.
556
+ */
557
+ export type FinalizeApiCall = <T>(
558
+ fn: () => Promise<T>,
559
+ opts?: RetryCallOpts,
560
+ ) => Promise<T>
561
+
562
+ /**
563
+ * Default notice posted when BOTH the body repaint and the keyboard strip
564
+ * fail — the card is still standing with live-looking buttons and the only
565
+ * honest thing left is to say so out loud, in the same chat.
566
+ *
567
+ * Deliberately a LITERAL plain string (no markdown, no entities): the most
568
+ * common cause of a repaint failure is Telegram rejecting the body's
569
+ * entities, so a rich fallback would be likely to fail the same way.
570
+ */
571
+ export const DEAD_CARD_NOTICE =
572
+ '⚠️ Your tap was applied, but this card could not be updated. ' +
573
+ 'The buttons on it are STALE — tapping them again will not change anything. ' +
574
+ 'Scroll down for the outcome, or ask the agent to re-send the card.'
575
+
510
576
  export interface FinalizeCallbackOptions {
511
577
  /**
512
578
  * Toast text shown to the operator via `answerCallbackQuery`. Telegram
@@ -549,52 +615,167 @@ export interface FinalizeCallbackOptions {
549
615
  * actions that shell to the host CLI, operator-event dismiss, etc).
550
616
  */
551
617
  synthInbound?: () => void | Promise<void>
618
+ /**
619
+ * The retry/flood policy every Telegram call in this helper transits
620
+ * (#3891). REQUIRED — not optional-with-a-passthrough-default, because a
621
+ * passthrough default is exactly the bug: the tap path silently opted out
622
+ * of the one policy that records 429s, and nothing failed. Making it a
623
+ * mandatory field turns "did this call site wire the policy?" into a
624
+ * `tsc` error at all 8 call sites instead of a review checklist item.
625
+ *
626
+ * Pass the gateway's `robustApiCall`.
627
+ */
628
+ apiCall: FinalizeApiCall
552
629
  /** Logger seam for tests. Defaults to stderr. */
553
630
  log?: (line: string) => void
554
631
  }
555
632
 
633
+ /**
634
+ * Rungs 2 and 3 of the repaint-failure ladder (#3891).
635
+ *
636
+ * Precondition: the body repaint already failed AFTER the retry policy had
637
+ * its go, so the card is standing with a live `reply_markup` over a decision
638
+ * that is already resolved. Getting the keyboard OFF is what matters here —
639
+ * the stale body text is cosmetic by comparison, an un-disarmed keyboard is
640
+ * an invitation to re-tap.
641
+ *
642
+ * Never throws. A throw would land back in `finalizeCallback` and could skip
643
+ * invariant 3 (the model wake-up), trading a confusing card for a wedged turn.
644
+ */
645
+ async function disarmDeadCard(
646
+ ctx: FinalizeCallbackContext,
647
+ apiCall: FinalizeApiCall,
648
+ scope: RetryCallOpts,
649
+ log: (line: string) => void,
650
+ ): Promise<void> {
651
+ try {
652
+ await apiCall(
653
+ () => ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }),
654
+ { ...scope, verb: 'editMessageReplyMarkup' },
655
+ )
656
+ log(
657
+ 'finalizeCallback: repaint failed but the keyboard was stripped — ' +
658
+ 'the card shows stale text and is no longer tappable\n',
659
+ )
660
+ return
661
+ } catch (err) {
662
+ log(`finalizeCallback: editMessageReplyMarkup fallback failed: ${(err as Error).message}\n`)
663
+ }
664
+ // Rung 3 — the card cannot be changed at all. Say so out loud rather than
665
+ // leave a live-looking keyboard with no explanation behind it.
666
+ try {
667
+ await apiCall(
668
+ () => ctx.reply(DEAD_CARD_NOTICE, { link_preview_options: { is_disabled: true } }),
669
+ { ...scope, verb: 'sendMessage' },
670
+ )
671
+ } catch (err) {
672
+ log(`finalizeCallback: dead-card notice failed: ${(err as Error).message}\n`)
673
+ }
674
+ }
675
+
556
676
  /**
557
677
  * Apply the three-invariant finalize pattern. See module docstring
558
678
  * above for design rationale.
559
679
  *
560
- * Order: ack → edit → synth. The ack is fired-and-forgotten (so a slow
561
- * Telegram API doesn't delay the visible state change), but the edit
562
- * is awaited so `synthInbound` doesn't race ahead of the operator's
563
- * visual confirmation. Each step's error is logged + swallowed —
564
- * partial success is preferred to "tap looked dead AND the model
565
- * stayed stuck" full failure.
680
+ * Order: ack → edit (disarm ladder on failure) → synth. The ack is
681
+ * fired-and-forgotten (so a slow Telegram API doesn't delay the visible
682
+ * state change), but the edit is awaited so `synthInbound` doesn't race
683
+ * ahead of the operator's visual confirmation. Each step's error is
684
+ * logged + swallowed — partial success is preferred to "tap looked dead
685
+ * AND the model stayed stuck" full failure.
686
+ *
687
+ * ── #3891: every leg goes through `opts.apiCall` ──────────────────────
688
+ * Both legs used to call grammy's CONTEXT methods raw. That bypassed
689
+ * `robustApiCall` / `createRetryApiCall` entirely, with two consequences
690
+ * observed together in one live incident:
691
+ *
692
+ * 1. A 429 earned on the tap path never reached `onFloodWait`, so it was
693
+ * never folded into `429-ledger.json` and never opened a window in
694
+ * `flood-windows.json`. Every consumer of that state — the send gate,
695
+ * `switchroom doctor`'s flood-pressure classifier, the wedge-watchdog's
696
+ * Esc suppression — was reasoning about the bot's 429 pressure from a
697
+ * picture with the operator's own taps cut out of it.
698
+ * 2. With no retry, ONE transient failure at the edit left the card
699
+ * holding its `reply_markup`: still live, still tappable, decision
700
+ * already resolved. The operator taps a corpse and gets a bare
701
+ * "doesn't want to proceed" with no explanation.
702
+ *
703
+ * Both legs are tagged `priorityClass: 'critical'` and carry no
704
+ * `messageId`/`editPayload`. That is deliberate: `critical` is the one
705
+ * class the send gate never SHEDS, and omitting the edit-coalescing keys
706
+ * keeps the gate from treating a finalize repaint as a droppable
707
+ * last-write-wins card edit. A finalize repaint is the operator's only
708
+ * visual confirmation that a human-in-the-loop decision resolved — shedding
709
+ * or coalescing it away IS the incident this fix exists to stop.
710
+ *
711
+ * ── The repaint-failure ladder ────────────────────────────────────────
712
+ * `robustApiCall` already swallows the two BENIGN edit failures
713
+ * (MESSAGE_NOT_MODIFIED, MESSAGE_TO_EDIT_NOT_FOUND → `undefined`) and
714
+ * retries transient ones. So anything reaching the catch below is a real,
715
+ * post-retry failure — and the card is still standing with live buttons.
716
+ * Three rungs, cheapest and most-likely-to-work first:
717
+ *
718
+ * 1. body repaint (`editMessageText`) — the good outcome.
719
+ * 2. keyboard strip (`editMessageReplyMarkup`) — the body is what usually
720
+ * breaks (entity/markdown parse, length); an empty-keyboard edit sends
721
+ * no body at all, so it survives the failure mode that killed rung 1.
722
+ * Card keeps stale TEXT but is no longer tappable.
723
+ * 3. plain-text notice (`reply`) — the card is unfixable, so
724
+ * say so in-channel rather than let it keep presenting as live.
725
+ *
726
+ * Every rung is best-effort and logged; none of them may block invariant 3.
727
+ * NOTE the ladder changes no approval semantics — it only repaints and
728
+ * narrates. The decision was resolved by the caller before we were called.
566
729
  */
567
730
  export async function finalizeCallback(
568
731
  ctx: FinalizeCallbackContext,
569
732
  opts: FinalizeCallbackOptions,
570
733
  ): Promise<void> {
571
734
  const log = opts.log ?? ((line: string) => process.stderr.write(line))
735
+ const apiCall = opts.apiCall
736
+ const chatId = extractCallbackChatId(ctx.callbackQuery)
737
+ const scope: RetryCallOpts = {
738
+ ...(chatId != null ? { chat_id: chatId } : {}),
739
+ priorityClass: 'critical',
740
+ }
572
741
  // Invariant 1 — toast. Fire-and-forget; we don't want a slow
573
742
  // answerCallbackQuery round-trip to delay the message edit.
574
- void ctx.answerCallbackQuery({
575
- text: opts.ackText,
576
- ...(opts.alert ? { show_alert: true } : {}),
577
- }).catch((err: unknown) => {
743
+ void apiCall(
744
+ () =>
745
+ ctx.answerCallbackQuery({
746
+ text: opts.ackText,
747
+ ...(opts.alert ? { show_alert: true } : {}),
748
+ }),
749
+ { ...scope, verb: 'answerCallbackQuery' },
750
+ ).catch((err: unknown) => {
578
751
  log(`finalizeCallback: answerCallbackQuery failed: ${(err as Error).message}\n`)
579
752
  })
580
753
  // Invariant 2 — strip keyboard + append status line, atomic edit.
754
+ let repainted = true
581
755
  try {
582
- await ctx.editMessageText(
583
- opts.literalText ? opts.newText : { markdown: opts.newText },
584
- {
585
- reply_markup: { inline_keyboard: [] },
586
- // Default link_preview_options off — most finalized cards don't
587
- // benefit from preview cards, and a stale preview survives the
588
- // edit otherwise.
589
- link_preview_options: { is_disabled: true },
590
- },
756
+ await apiCall(
757
+ () =>
758
+ ctx.editMessageText(
759
+ opts.literalText ? opts.newText : { markdown: opts.newText },
760
+ {
761
+ reply_markup: { inline_keyboard: [] },
762
+ // Default link_preview_options off — most finalized cards don't
763
+ // benefit from preview cards, and a stale preview survives the
764
+ // edit otherwise.
765
+ link_preview_options: { is_disabled: true },
766
+ },
767
+ ),
768
+ { ...scope, verb: 'editMessageText' },
591
769
  )
592
770
  } catch (err) {
593
771
  // MESSAGE_NOT_MODIFIED (text didn't change) and MESSAGE_TO_EDIT_NOT_FOUND
594
- // (operator already deleted the card) are both benign. Other failures
595
- // log + continue we still want synthInbound to run.
772
+ // (operator already deleted the card) are both benign AND are already
773
+ // swallowed inside the retry policy, so they never land here. Anything
774
+ // that does is a real failure that left the keyboard standing.
775
+ repainted = false
596
776
  log(`finalizeCallback: editMessageText failed: ${(err as Error).message}\n`)
597
777
  }
778
+ if (!repainted) await disarmDeadCard(ctx, apiCall, scope, log)
598
779
  // Invariant 3 — model wake-up (when applicable).
599
780
  if (opts.synthInbound != null) {
600
781
  try {
@@ -34,6 +34,12 @@ import {
34
34
  parseResetTime,
35
35
  } from './model-unavailable.js'
36
36
  import { classify429Detail } from './throttle-tier.js'
37
+ import {
38
+ PROVIDER_CREDIT_REGISTRY,
39
+ attributeProvider,
40
+ describeProviderCreditRemedy,
41
+ type ProviderCreditEntry,
42
+ } from './provider-credit.js'
37
43
  import { classifyClaudeError } from './operator-events.js'
38
44
  import { stripRawErrorBytes, extractRequestId } from './raw-error-scrub.js'
39
45
  import { fmtLocalClock, tzAbbrev } from './shared/local-time.js'
@@ -48,10 +54,24 @@ export type LlmErrorKind =
48
54
  | 'quota_wall'
49
55
  | 'auth'
50
56
  | 'infra_misconfig'
57
+ /**
58
+ * A THIRD-PARTY model provider (OpenRouter / OpenAI / Perplexity) is out of
59
+ * credit. OPERATOR-actionable, never user-actionable — its `coreText` is a
60
+ * deliberately diagnosis-free one-liner and its recommendation names the
61
+ * vendor console, never `/auth`. Kept distinct from `quota_wall` (the
62
+ * Anthropic subscription window, which resets on a clock and is addressed by
63
+ * switching account slots); a vendor balance resets only when someone pays.
64
+ */
65
+ | 'provider_credit'
51
66
  | 'transient'
52
67
  | 'unknown'
53
68
 
54
- export type LlmErrorSource = 'anthropic' | 'litellm-local' | 'network'
69
+ export type LlmErrorSource =
70
+ | 'anthropic'
71
+ | 'litellm-local'
72
+ /** A paid third-party model provider reached THROUGH the proxy (OpenRouter, OpenAI). */
73
+ | 'external-provider'
74
+ | 'network'
55
75
 
56
76
  export interface ParsedLlmError {
57
77
  kind: LlmErrorKind
@@ -65,6 +85,14 @@ export interface ParsedLlmError {
65
85
  model?: string
66
86
  /** Anthropic `request_id`, when present — the strongest dedup key. */
67
87
  requestId?: string
88
+ /**
89
+ * Registry id of the third-party provider the error was attributed to
90
+ * (`openrouter` / `openai` / `perplexity`), when `kind === 'provider_credit'`
91
+ * and the raw text named one. An ID only — never raw error text, never a
92
+ * credential — so the recommendation line can point at the right console
93
+ * without this struct ever carrying the raw string it was parsed from.
94
+ */
95
+ providerId?: string
68
96
  source: LlmErrorSource
69
97
  /** True when the harness is still retrying this error internally (mid-retry). */
70
98
  autoRetrying: boolean
@@ -96,7 +124,10 @@ const TRANSIENT_KINDS: ReadonlySet<LlmErrorKind> = new Set<LlmErrorKind>([
96
124
 
97
125
  /** The always-actionable kinds — NEVER silenced, even inside a collapse window. */
98
126
  export function isActionableKind(kind: LlmErrorKind): boolean {
99
- return kind === 'auth' || kind === 'quota_wall'
127
+ // `provider_credit` joins the never-silenced set: an unpaid vendor balance
128
+ // does not clear on its own, so collapsing it inside a dedup window would
129
+ // hide the one signal that needs a human.
130
+ return kind === 'auth' || kind === 'quota_wall' || kind === 'provider_credit'
100
131
  }
101
132
 
102
133
  /**
@@ -118,6 +149,8 @@ export function parseLlmError(
118
149
  resetAt != null ? Math.max(0, resetAt.getTime() - Date.now()) : undefined
119
150
 
120
151
  const { kind, source } = classifyKindAndSource(text)
152
+ const providerId =
153
+ kind === 'provider_credit' ? (attributeProvider(text)?.id ?? undefined) : undefined
121
154
 
122
155
  // Retry / terminal semantics. Only the transient family can be mid-retry;
123
156
  // auth / quota_wall / model_unavailable are terminal by construction.
@@ -143,6 +176,7 @@ export function parseLlmError(
143
176
  ...(retryAfterMs != null ? { retryAfterMs } : {}),
144
177
  ...(model != null ? { model } : {}),
145
178
  ...(requestId != null ? { requestId } : {}),
179
+ ...(providerId != null ? { providerId } : {}),
146
180
  source,
147
181
  autoRetrying,
148
182
  terminal,
@@ -165,6 +199,15 @@ function classifyKindAndSource(text: string): { kind: LlmErrorKind; source: LlmE
165
199
 
166
200
  // 1. Auth — always terminal, always actionable.
167
201
  const claudeKind = classifyClaudeError({ message: text, type: text })
202
+ // 1b. THIRD-PARTY provider credit wall. Placed immediately after the auth
203
+ // resolution and BEFORE isLitellmProxyLocal429 / detectModelUnavailable:
204
+ // OpenAI reports an exhausted balance as HTTP 429 `insufficient_quota`,
205
+ // and the 429 matchers below would otherwise swallow it as a transient
206
+ // "retrying automatically" — the single most misleading thing to tell
207
+ // someone whose provider has no money left.
208
+ if (claudeKind === 'provider-credit-exhausted') {
209
+ return { kind: 'provider_credit', source: 'external-provider' }
210
+ }
168
211
  if (claudeKind === 'proxy-misconfig') {
169
212
  return { kind: 'infra_misconfig', source: 'litellm-local' }
170
213
  }
@@ -216,6 +259,12 @@ function classifyKindAndSource(text: string): { kind: LlmErrorKind; source: LlmE
216
259
  return { kind: 'unknown', source: 'anthropic' }
217
260
  }
218
261
 
262
+ /** Resolve a registry entry from a persisted `providerId`, or null. */
263
+ function providerById(id: string | undefined): ProviderCreditEntry | null {
264
+ if (id == null) return null
265
+ return PROVIDER_CREDIT_REGISTRY.find(p => p.id === id) ?? null
266
+ }
267
+
219
268
  function buildCoreText(kind: LlmErrorKind, source: LlmErrorSource): string {
220
269
  switch (kind) {
221
270
  case 'rate_limit':
@@ -230,6 +279,10 @@ function buildCoreText(kind: LlmErrorKind, source: LlmErrorSource): string {
230
279
  return 'Claude login needs re-authentication.'
231
280
  case 'infra_misconfig':
232
281
  return 'Local model-gateway auth misconfig (proxy fallback dropped the OAuth header).'
282
+ case 'provider_credit':
283
+ // Diagnosis-free by design — this string is the ceiling on what a
284
+ // non-operator may ever be told about a vendor billing wall.
285
+ return 'An upstream model provider is out of credit — the operator has been notified.'
233
286
  case 'transient':
234
287
  return source === 'network'
235
288
  ? "Couldn't reach Anthropic (network) — retrying automatically."
@@ -302,6 +355,10 @@ function buildRecommendation(parsed: ParsedLlmError, tz: string): string | undef
302
355
  // Operator-facing — NO re-auth wording (the login is fine). Points at the
303
356
  // real remedy: the local LiteLLM proxy fallback config.
304
357
  return '→ Fix the LiteLLM proxy fallback config (deployment missing OAuth passthrough).'
358
+ case 'provider_credit':
359
+ // Operator-facing. NO `/auth` wording: the credential is valid, the
360
+ // balance is empty, and rotating an Anthropic slot changes nothing.
361
+ return `→ ${describeProviderCreditRemedy(providerById(parsed.providerId))}`
305
362
  case 'quota_wall': {
306
363
  const reset = formatResetClock(parsed.resetAt, tz)
307
364
  return reset
@@ -377,6 +434,8 @@ function kindEmoji(kind: LlmErrorKind): string {
377
434
  return '🔑'
378
435
  case 'infra_misconfig':
379
436
  return '🛠️'
437
+ case 'provider_credit':
438
+ return '💳'
380
439
  case 'transient':
381
440
  return '🌐'
382
441
  case 'unknown':