switchroom 0.19.27 → 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 (32) hide show
  1. package/dist/agent-scheduler/index.js +5 -2
  2. package/dist/auth-broker/index.js +129 -8
  3. package/dist/cli/autoaccept-poll.js +225 -17
  4. package/dist/cli/notion-write-pretool.mjs +5 -2
  5. package/dist/cli/switchroom.js +796 -35
  6. package/dist/host-control/main.js +130 -9
  7. package/dist/vault/approvals/kernel-server.js +129 -8
  8. package/dist/vault/broker/server.js +129 -8
  9. package/package.json +3 -2
  10. package/profiles/_base/start.sh.hbs +70 -15
  11. package/telegram-plugin/dist/bridge/bridge.js +1 -0
  12. package/telegram-plugin/dist/gateway/gateway.js +568 -49
  13. package/telegram-plugin/dist/server.js +1 -0
  14. package/telegram-plugin/edit-flood-fuse.ts +230 -27
  15. package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
  16. package/telegram-plugin/gateway/gateway.ts +9 -2
  17. package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
  18. package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
  19. package/telegram-plugin/mcp-credential-failure.ts +459 -0
  20. package/telegram-plugin/operator-events.ts +38 -0
  21. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +58 -1
  22. package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
  23. package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
  24. package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
  25. package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
  26. package/vendor/hindsight-memory/scripts/drain_pending.py +433 -11
  27. package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
  28. package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
  29. package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
  30. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
  31. package/vendor/hindsight-memory/settings.json +1 -1
  32. package/vendor/hindsight-memory/tests/test_hooks.py +11 -2
@@ -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 {