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.
- package/dist/agent-scheduler/index.js +5 -2
- package/dist/auth-broker/index.js +129 -8
- package/dist/cli/autoaccept-poll.js +225 -17
- package/dist/cli/notion-write-pretool.mjs +5 -2
- package/dist/cli/switchroom.js +796 -35
- package/dist/host-control/main.js +130 -9
- package/dist/vault/approvals/kernel-server.js +129 -8
- package/dist/vault/broker/server.js +129 -8
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +70 -15
- package/telegram-plugin/dist/bridge/bridge.js +1 -0
- package/telegram-plugin/dist/gateway/gateway.js +568 -49
- package/telegram-plugin/dist/server.js +1 -0
- package/telegram-plugin/edit-flood-fuse.ts +230 -27
- package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
- package/telegram-plugin/gateway/gateway.ts +9 -2
- package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
- package/telegram-plugin/mcp-credential-failure.ts +459 -0
- package/telegram-plugin/operator-events.ts +38 -0
- package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +58 -1
- package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
- package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
- package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
- package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +433 -11
- package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
- package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
- package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
- package/vendor/hindsight-memory/settings.json +1 -1
- 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
|
|
561
|
-
* Telegram API doesn't delay the visible
|
|
562
|
-
* is awaited so `synthInbound` doesn't race
|
|
563
|
-
* visual confirmation. Each step's error is
|
|
564
|
-
* partial success is preferred to "tap looked dead
|
|
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
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
|
595
|
-
//
|
|
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 {
|