switchroom 0.18.10 → 0.18.12
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 +29 -5
- package/dist/auth-broker/index.js +53 -13
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/notion-write-pretool.mjs +29 -5
- package/dist/cli/switchroom.js +2636 -1369
- package/dist/cli/ui/apple-touch-icon.png +0 -0
- package/dist/cli/ui/favicon-32.png +0 -0
- package/dist/cli/ui/favicon.ico +0 -0
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +1248 -342
- package/dist/vault/approvals/kernel-server.js +54 -13
- package/dist/vault/broker/server.js +163 -114
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +73 -1
- package/telegram-plugin/dist/gateway/gateway.js +3603 -1007
- package/telegram-plugin/dist/server.js +74 -2
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/gateway/approval-hold.ts +583 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +76 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1482 -165
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +209 -2
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/send-gate-degraded.test.ts +443 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +698 -0
- package/telegram-plugin/send-gate.ts +982 -0
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +102 -2
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +296 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +284 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -0
- package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
|
@@ -15,8 +15,14 @@ import {
|
|
|
15
15
|
createSwallowingRetryApiCall,
|
|
16
16
|
retryWithThreadFallback,
|
|
17
17
|
isHtmlParseRejectError,
|
|
18
|
+
isMessageTooLongError,
|
|
19
|
+
isPhotoDimensionRejectError,
|
|
18
20
|
isLocalResourceError,
|
|
21
|
+
isFloodWaitActiveError,
|
|
19
22
|
LOCAL_RESOURCE_EXHAUSTED,
|
|
23
|
+
FLOOD_WAIT_ACTIVE,
|
|
24
|
+
DEFAULT_MAX_FLOOD_SLEEP_MS,
|
|
25
|
+
type FloodWaitActiveError,
|
|
20
26
|
type RetryObserver,
|
|
21
27
|
} from '../retry-api-call.js'
|
|
22
28
|
import { errors, makeGrammyError } from './fake-bot-api.js'
|
|
@@ -571,3 +577,395 @@ describe('#2923 — LOCAL resource exhaustion is NOT retried (avoids flood ban)'
|
|
|
571
577
|
expect(seen).toEqual([42])
|
|
572
578
|
})
|
|
573
579
|
})
|
|
580
|
+
|
|
581
|
+
describe('#3084 — a long flood ban fails FAST instead of sleeping for hours', () => {
|
|
582
|
+
/**
|
|
583
|
+
* Deterministic fake clock: `sleep` never waits, it just advances a counter.
|
|
584
|
+
* Nothing here touches a real timer, so "did we try to sleep 4.6 hours?" is
|
|
585
|
+
* an assertion on the fake clock, not a stopwatch.
|
|
586
|
+
*/
|
|
587
|
+
function fakeClock() {
|
|
588
|
+
let elapsedMs = 0
|
|
589
|
+
const slept: number[] = []
|
|
590
|
+
return {
|
|
591
|
+
get elapsedMs() {
|
|
592
|
+
return elapsedMs
|
|
593
|
+
},
|
|
594
|
+
slept,
|
|
595
|
+
sleep: async (ms: number) => {
|
|
596
|
+
slept.push(ms)
|
|
597
|
+
elapsedMs += ms
|
|
598
|
+
},
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// The real value overlord's gateway logged on 2026-07-11.
|
|
603
|
+
const REAL_BAN_SEC = 16_739
|
|
604
|
+
|
|
605
|
+
it('does NOT sleep a 4.6-hour retry_after (clock never advances past the ceiling)', async () => {
|
|
606
|
+
const clock = fakeClock()
|
|
607
|
+
let calls = 0
|
|
608
|
+
const retry = createRetryApiCall({ maxRetries: 3, sleep: clock.sleep })
|
|
609
|
+
|
|
610
|
+
await expect(
|
|
611
|
+
retry(async () => {
|
|
612
|
+
calls++
|
|
613
|
+
throw errors.floodWait(REAL_BAN_SEC)
|
|
614
|
+
}),
|
|
615
|
+
).rejects.toThrow(FLOOD_WAIT_ACTIVE)
|
|
616
|
+
|
|
617
|
+
expect(calls).toBe(1) // failed fast on the first 429 — no retry storm
|
|
618
|
+
expect(clock.slept).toEqual([]) // never even attempted the sleep
|
|
619
|
+
expect(clock.elapsedMs).toBe(0)
|
|
620
|
+
expect(clock.elapsedMs).toBeLessThanOrEqual(DEFAULT_MAX_FLOOD_SLEEP_MS)
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
it('carries retry_after / untilTs / original on the thrown marker', async () => {
|
|
624
|
+
const clock = fakeClock()
|
|
625
|
+
const before = Date.now()
|
|
626
|
+
const retry = createRetryApiCall({ maxRetries: 3, sleep: clock.sleep })
|
|
627
|
+
|
|
628
|
+
const err = await retry(async () => {
|
|
629
|
+
throw errors.floodWait(REAL_BAN_SEC)
|
|
630
|
+
}).catch((e: unknown) => e)
|
|
631
|
+
|
|
632
|
+
expect(isFloodWaitActiveError(err)).toBe(true)
|
|
633
|
+
const flood = err as FloodWaitActiveError
|
|
634
|
+
expect(flood.retryAfterSec).toBe(REAL_BAN_SEC)
|
|
635
|
+
expect(flood.untilTs).toBeGreaterThanOrEqual(before + REAL_BAN_SEC * 1000)
|
|
636
|
+
expect(flood.original).toBeInstanceOf(GrammyError)
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
it('STILL records the flood window via onFloodWait before throwing (#2923 breaker)', async () => {
|
|
640
|
+
const clock = fakeClock()
|
|
641
|
+
const seen: number[] = []
|
|
642
|
+
const order: string[] = []
|
|
643
|
+
const retry = createRetryApiCall({
|
|
644
|
+
maxRetries: 3,
|
|
645
|
+
sleep: clock.sleep,
|
|
646
|
+
onFloodWait: (s) => {
|
|
647
|
+
seen.push(s)
|
|
648
|
+
order.push('recorded')
|
|
649
|
+
},
|
|
650
|
+
observer: {
|
|
651
|
+
onGiveUp: () => {
|
|
652
|
+
order.push('gave_up')
|
|
653
|
+
},
|
|
654
|
+
},
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
await expect(
|
|
658
|
+
retry(async () => {
|
|
659
|
+
throw errors.floodWait(REAL_BAN_SEC)
|
|
660
|
+
}),
|
|
661
|
+
).rejects.toThrow(FLOOD_WAIT_ACTIVE)
|
|
662
|
+
|
|
663
|
+
// The breaker MUST learn about the window — that is what suppresses the
|
|
664
|
+
// restart boot-card that would otherwise extend the ban.
|
|
665
|
+
expect(seen).toEqual([REAL_BAN_SEC])
|
|
666
|
+
expect(order).toEqual(['recorded', 'gave_up'])
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
it('a throwing onFloodWait hook still yields the marker, not the hook error', async () => {
|
|
670
|
+
const clock = fakeClock()
|
|
671
|
+
const retry = createRetryApiCall({
|
|
672
|
+
maxRetries: 3,
|
|
673
|
+
sleep: clock.sleep,
|
|
674
|
+
onFloodWait: () => {
|
|
675
|
+
throw new Error('disk full while persisting flood state')
|
|
676
|
+
},
|
|
677
|
+
})
|
|
678
|
+
await expect(
|
|
679
|
+
retry(async () => {
|
|
680
|
+
throw errors.floodWait(REAL_BAN_SEC)
|
|
681
|
+
}),
|
|
682
|
+
).rejects.toThrow(FLOOD_WAIT_ACTIVE)
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
it('a SHORT retry_after under the ceiling still sleeps-and-retries and succeeds', async () => {
|
|
686
|
+
const clock = fakeClock()
|
|
687
|
+
let n = 0
|
|
688
|
+
const retry = createRetryApiCall({ maxRetries: 3, sleep: clock.sleep })
|
|
689
|
+
|
|
690
|
+
// 30s — the everyday rate-limit blip. Behaviour must be unchanged.
|
|
691
|
+
const out = await retry(async () => {
|
|
692
|
+
if (n++ === 0) throw errors.floodWait(30)
|
|
693
|
+
return 'ok'
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
expect(out).toBe('ok')
|
|
697
|
+
expect(n).toBe(2)
|
|
698
|
+
expect(clock.slept).toEqual([30_000])
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
it('the historic 28s and 75s waits are still slept through (no regression)', async () => {
|
|
702
|
+
for (const sec of [28, 75]) {
|
|
703
|
+
const clock = fakeClock()
|
|
704
|
+
let n = 0
|
|
705
|
+
const retry = createRetryApiCall({ maxRetries: 3, sleep: clock.sleep })
|
|
706
|
+
const out = await retry(async () => {
|
|
707
|
+
if (n++ === 0) throw errors.floodWait(sec)
|
|
708
|
+
return 'ok'
|
|
709
|
+
})
|
|
710
|
+
expect(out).toBe('ok')
|
|
711
|
+
expect(clock.slept).toEqual([sec * 1000])
|
|
712
|
+
}
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
it('sleeps exactly AT the ceiling, throws just above it', async () => {
|
|
716
|
+
const atCeiling = DEFAULT_MAX_FLOOD_SLEEP_MS / 1000
|
|
717
|
+
|
|
718
|
+
const clockAt = fakeClock()
|
|
719
|
+
let n = 0
|
|
720
|
+
const retryAt = createRetryApiCall({ maxRetries: 3, sleep: clockAt.sleep })
|
|
721
|
+
expect(
|
|
722
|
+
await retryAt(async () => {
|
|
723
|
+
if (n++ === 0) throw errors.floodWait(atCeiling)
|
|
724
|
+
return 'ok'
|
|
725
|
+
}),
|
|
726
|
+
).toBe('ok')
|
|
727
|
+
expect(clockAt.slept).toEqual([DEFAULT_MAX_FLOOD_SLEEP_MS])
|
|
728
|
+
|
|
729
|
+
const clockOver = fakeClock()
|
|
730
|
+
const retryOver = createRetryApiCall({ maxRetries: 3, sleep: clockOver.sleep })
|
|
731
|
+
await expect(
|
|
732
|
+
retryOver(async () => {
|
|
733
|
+
throw errors.floodWait(atCeiling + 1)
|
|
734
|
+
}),
|
|
735
|
+
).rejects.toThrow(FLOOD_WAIT_ACTIVE)
|
|
736
|
+
expect(clockOver.slept).toEqual([])
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
it('honours a caller-supplied maxFloodSleepMs ceiling', async () => {
|
|
740
|
+
const clock = fakeClock()
|
|
741
|
+
const retry = createRetryApiCall({
|
|
742
|
+
maxRetries: 3,
|
|
743
|
+
sleep: clock.sleep,
|
|
744
|
+
maxFloodSleepMs: 5_000,
|
|
745
|
+
})
|
|
746
|
+
// 30s would sleep under the DEFAULT ceiling; with a 5s ceiling it throws.
|
|
747
|
+
await expect(
|
|
748
|
+
retry(async () => {
|
|
749
|
+
throw errors.floodWait(30)
|
|
750
|
+
}),
|
|
751
|
+
).rejects.toThrow(FLOOD_WAIT_ACTIVE)
|
|
752
|
+
expect(clock.slept).toEqual([])
|
|
753
|
+
})
|
|
754
|
+
|
|
755
|
+
it('maxRetries semantics unchanged for sustained SHORT floods', async () => {
|
|
756
|
+
const clock = fakeClock()
|
|
757
|
+
let calls = 0
|
|
758
|
+
const retry = createRetryApiCall({ maxRetries: 3, sleep: clock.sleep })
|
|
759
|
+
|
|
760
|
+
await expect(
|
|
761
|
+
retry(async () => {
|
|
762
|
+
calls++
|
|
763
|
+
throw errors.floodWait(10)
|
|
764
|
+
}),
|
|
765
|
+
).rejects.toThrow('retryApiCall: max retries exceeded')
|
|
766
|
+
|
|
767
|
+
expect(calls).toBe(3)
|
|
768
|
+
expect(clock.slept).toEqual([10_000, 10_000, 10_000])
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
it('the swallowing wrapper absorbs the marker (no unhandled rejection)', async () => {
|
|
772
|
+
const clock = fakeClock()
|
|
773
|
+
const lines: string[] = []
|
|
774
|
+
const retry = createRetryApiCall({ maxRetries: 3, sleep: clock.sleep })
|
|
775
|
+
const swallow = createSwallowingRetryApiCall(retry, (l) => lines.push(l))
|
|
776
|
+
|
|
777
|
+
const out = await swallow(
|
|
778
|
+
async () => {
|
|
779
|
+
throw errors.floodWait(REAL_BAN_SEC)
|
|
780
|
+
},
|
|
781
|
+
{ verb: 'sendMessage' },
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
expect(out).toBeUndefined()
|
|
785
|
+
expect(lines.join('')).toContain(FLOOD_WAIT_ACTIVE)
|
|
786
|
+
})
|
|
787
|
+
})
|
|
788
|
+
|
|
789
|
+
describe('#3084 — the fast throw must not become a re-drive amplifier', () => {
|
|
790
|
+
const REAL_BAN_SEC = 16_739
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* EXACT duck-type predicate used by the card surfaces to arm their 429
|
|
794
|
+
* cooldown — copied verbatim from `worker-activity-feed.ts`
|
|
795
|
+
* (`extractRetryAfterSecs`) and `issues-card.ts`. They do NOT use
|
|
796
|
+
* `instanceof GrammyError`. If the marker doesn't satisfy this, they
|
|
797
|
+
* classify it 'transient', arm no cooldown, and their 5-6s heartbeats
|
|
798
|
+
* re-drive the send into the open ban.
|
|
799
|
+
*/
|
|
800
|
+
function extractRetryAfterSecs(err: unknown): number | null {
|
|
801
|
+
if (err == null || typeof err !== 'object') return null
|
|
802
|
+
const e = err as { error_code?: unknown; parameters?: { retry_after?: unknown } }
|
|
803
|
+
if (e.error_code !== 429) return null
|
|
804
|
+
const ra = e.parameters?.retry_after
|
|
805
|
+
if (typeof ra === 'number' && Number.isFinite(ra) && ra > 0) return ra
|
|
806
|
+
return null
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
it('the marker satisfies the card surfaces\' 429 cooldown duck-type', async () => {
|
|
810
|
+
const retry = createRetryApiCall({ maxRetries: 3, sleep: async () => {} })
|
|
811
|
+
const err = await retry(async () => {
|
|
812
|
+
throw errors.floodWait(REAL_BAN_SEC)
|
|
813
|
+
}).catch((e: unknown) => e)
|
|
814
|
+
|
|
815
|
+
// worker-activity-feed / issues-card would now arm a FULL-window cooldown
|
|
816
|
+
// instead of treating this as a generic 'transient' error.
|
|
817
|
+
expect(extractRetryAfterSecs(err)).toBe(REAL_BAN_SEC)
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
it('the marker is still NOT a GrammyError (resend-fallback probes must not match)', async () => {
|
|
821
|
+
const retry = createRetryApiCall({ maxRetries: 3, sleep: async () => {} })
|
|
822
|
+
const err = await retry(async () => {
|
|
823
|
+
throw errors.floodWait(REAL_BAN_SEC)
|
|
824
|
+
}).catch((e: unknown) => e)
|
|
825
|
+
|
|
826
|
+
expect(err).not.toBeInstanceOf(GrammyError)
|
|
827
|
+
expect(isHtmlParseRejectError(err)).toBe(false)
|
|
828
|
+
expect(isMessageTooLongError(err)).toBe(false)
|
|
829
|
+
expect(isPhotoDimensionRejectError(err)).toBe(false)
|
|
830
|
+
})
|
|
831
|
+
|
|
832
|
+
it('a card heartbeat ticking across the whole 4.6h ban issues ONE Bot API call', async () => {
|
|
833
|
+
// The regression that must be impossible. Before the pre-call gate: the
|
|
834
|
+
// marker throws in ~1ms, the 6s heartbeat re-drives, and we fire ~2,760
|
|
835
|
+
// fresh requests into the still-open per-bot ban — feeding the exact flood
|
|
836
|
+
// counter #2923 exists to starve. Strictly worse than the original hang.
|
|
837
|
+
let now = 1_000_000
|
|
838
|
+
let state: { untilTs: number } | null = null
|
|
839
|
+
let apiCalls = 0
|
|
840
|
+
|
|
841
|
+
const retry = createRetryApiCall({
|
|
842
|
+
maxRetries: 3,
|
|
843
|
+
sleep: async () => {},
|
|
844
|
+
// The #2923 breaker: record the window...
|
|
845
|
+
onFloodWait: (sec) => {
|
|
846
|
+
state = { untilTs: now + sec * 1000 }
|
|
847
|
+
},
|
|
848
|
+
// ...and the #3084 gate: refuse to send into it.
|
|
849
|
+
floodWaitRemainingMs: () => (state ? Math.max(0, state.untilTs - now) : 0),
|
|
850
|
+
})
|
|
851
|
+
|
|
852
|
+
const HEARTBEAT_MS = 6_000
|
|
853
|
+
const ticks = Math.ceil((REAL_BAN_SEC * 1000) / HEARTBEAT_MS) // ~2,790
|
|
854
|
+
let markerThrows = 0
|
|
855
|
+
|
|
856
|
+
for (let i = 0; i < ticks; i++) {
|
|
857
|
+
// A worker-feed style heartbeat: best-effort edit, swallow failures,
|
|
858
|
+
// and (deliberately, to model the WORST case) arm no cooldown of its own.
|
|
859
|
+
await retry(async () => {
|
|
860
|
+
apiCalls++
|
|
861
|
+
throw errors.floodWait(REAL_BAN_SEC)
|
|
862
|
+
}).catch((e: unknown) => {
|
|
863
|
+
if (isFloodWaitActiveError(e)) markerThrows++
|
|
864
|
+
})
|
|
865
|
+
now += HEARTBEAT_MS
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
expect(ticks).toBeGreaterThan(2_700) // we really did simulate the full ban
|
|
869
|
+
expect(markerThrows).toBe(ticks) // every tick got a clean degraded state
|
|
870
|
+
// …and the Bot API was touched a HANDFUL of times, not once per tick.
|
|
871
|
+
// Without the pre-call gate this is ~2,790 requests fired into an open
|
|
872
|
+
// per-bot ban. With it: the first tick (which discovers the ban) plus the
|
|
873
|
+
// few tail ticks that run after the simulated window has elapsed and
|
|
874
|
+
// legitimately re-probe. Bounded and small is the whole contract.
|
|
875
|
+
expect(apiCalls).toBeLessThanOrEqual(5)
|
|
876
|
+
expect(apiCalls).toBeGreaterThanOrEqual(1)
|
|
877
|
+
expect(apiCalls / ticks).toBeLessThan(0.01) // <1% of ticks reach the API
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
it('resumes issuing calls once the window expires (the gate is not a latch)', async () => {
|
|
881
|
+
let now = 1_000_000
|
|
882
|
+
let state: { untilTs: number } | null = null
|
|
883
|
+
let apiCalls = 0
|
|
884
|
+
|
|
885
|
+
const retry = createRetryApiCall({
|
|
886
|
+
maxRetries: 3,
|
|
887
|
+
sleep: async () => {},
|
|
888
|
+
onFloodWait: (sec) => {
|
|
889
|
+
state = { untilTs: now + sec * 1000 }
|
|
890
|
+
},
|
|
891
|
+
floodWaitRemainingMs: () => (state ? Math.max(0, state.untilTs - now) : 0),
|
|
892
|
+
})
|
|
893
|
+
|
|
894
|
+
await expect(
|
|
895
|
+
retry(async () => {
|
|
896
|
+
apiCalls++
|
|
897
|
+
throw errors.floodWait(300)
|
|
898
|
+
}),
|
|
899
|
+
).rejects.toThrow(FLOOD_WAIT_ACTIVE)
|
|
900
|
+
expect(apiCalls).toBe(1)
|
|
901
|
+
|
|
902
|
+
// Mid-window: gated, no call.
|
|
903
|
+
now += 100_000
|
|
904
|
+
await expect(retry(async () => 'ok')).rejects.toThrow(FLOOD_WAIT_ACTIVE)
|
|
905
|
+
expect(apiCalls).toBe(1)
|
|
906
|
+
|
|
907
|
+
// Window expired: the very next call goes through and succeeds.
|
|
908
|
+
now += 300_000
|
|
909
|
+
expect(
|
|
910
|
+
await retry(async () => {
|
|
911
|
+
apiCalls++
|
|
912
|
+
return 'ok'
|
|
913
|
+
}),
|
|
914
|
+
).toBe('ok')
|
|
915
|
+
expect(apiCalls).toBe(2)
|
|
916
|
+
})
|
|
917
|
+
|
|
918
|
+
it('a SHORT open window is not short-circuited (sleep-and-retry still rides it out)', async () => {
|
|
919
|
+
// A 30s window is under the ceiling: the normal path may still succeed
|
|
920
|
+
// (the server may have cleared it), so we must NOT gate it.
|
|
921
|
+
let apiCalls = 0
|
|
922
|
+
let n = 0
|
|
923
|
+
const retry = createRetryApiCall({
|
|
924
|
+
maxRetries: 3,
|
|
925
|
+
sleep: async () => {},
|
|
926
|
+
floodWaitRemainingMs: () => 30_000,
|
|
927
|
+
})
|
|
928
|
+
const out = await retry(async () => {
|
|
929
|
+
apiCalls++
|
|
930
|
+
if (n++ === 0) throw errors.floodWait(30)
|
|
931
|
+
return 'ok'
|
|
932
|
+
})
|
|
933
|
+
expect(out).toBe('ok')
|
|
934
|
+
expect(apiCalls).toBe(2)
|
|
935
|
+
})
|
|
936
|
+
|
|
937
|
+
it('FAILS OPEN — a throwing/garbage flood probe never silences the bot', async () => {
|
|
938
|
+
const throwing = createRetryApiCall({
|
|
939
|
+
maxRetries: 3,
|
|
940
|
+
sleep: async () => {},
|
|
941
|
+
floodWaitRemainingMs: () => {
|
|
942
|
+
throw new Error('EACCES: unreadable flood-wait.json')
|
|
943
|
+
},
|
|
944
|
+
})
|
|
945
|
+
expect(await throwing(async () => 'ok')).toBe('ok')
|
|
946
|
+
|
|
947
|
+
// Garbage from a corrupt marker file must be treated as "no window".
|
|
948
|
+
// Infinity in particular would otherwise mute the bot forever.
|
|
949
|
+
for (const garbage of [NaN, -1, 0, Infinity]) {
|
|
950
|
+
const retry = createRetryApiCall({
|
|
951
|
+
maxRetries: 3,
|
|
952
|
+
sleep: async () => {},
|
|
953
|
+
floodWaitRemainingMs: () => garbage,
|
|
954
|
+
})
|
|
955
|
+
expect(await retry(async () => 'ok')).toBe('ok')
|
|
956
|
+
}
|
|
957
|
+
})
|
|
958
|
+
|
|
959
|
+
it('with no flood probe wired at all, behaviour is exactly as before', async () => {
|
|
960
|
+
let apiCalls = 0
|
|
961
|
+
let n = 0
|
|
962
|
+
const retry = createRetryApiCall({ maxRetries: 3, sleep: async () => {} })
|
|
963
|
+
const out = await retry(async () => {
|
|
964
|
+
apiCalls++
|
|
965
|
+
if (n++ === 0) throw errors.floodWait(30)
|
|
966
|
+
return 'ok'
|
|
967
|
+
})
|
|
968
|
+
expect(out).toBe('ok')
|
|
969
|
+
expect(apiCalls).toBe(2)
|
|
970
|
+
})
|
|
971
|
+
})
|
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
readSessionModelFileRaw,
|
|
16
16
|
restoreSessionModelFileRaw,
|
|
17
17
|
clearSessionModelFile,
|
|
18
|
+
clearSessionModelBootAttempts,
|
|
19
|
+
SESSION_MODEL_BOOT_ATTEMPTS_FILE,
|
|
18
20
|
writeRelaunchModelIntent,
|
|
19
21
|
clearRelaunchModelIntent,
|
|
20
22
|
readConfiguredDefaultModel,
|
|
@@ -229,3 +231,51 @@ describe('intentForRestartReason is keep-for-everything (#3039)', () => {
|
|
|
229
231
|
}
|
|
230
232
|
})
|
|
231
233
|
})
|
|
234
|
+
|
|
235
|
+
describe('clearSessionModelBootAttempts (#3043 item 2: bridge-register clears the crashloop counter)', () => {
|
|
236
|
+
const bootAttemptsPath = () => join(dir, SESSION_MODEL_BOOT_ATTEMPTS_FILE)
|
|
237
|
+
|
|
238
|
+
// Faithful model of start.sh.hbs "Override crashloop self-heal": each boot
|
|
239
|
+
// within 150s of the previous stamp bumps the count; a stale stamp resets to
|
|
240
|
+
// 1. Reproduced here so the accumulate-vs-reset OUTCOME is asserted, not just
|
|
241
|
+
// the delete call.
|
|
242
|
+
function simulateBootStamp(nowSec: number): number {
|
|
243
|
+
let count = 0
|
|
244
|
+
let prev = 0
|
|
245
|
+
if (existsSync(bootAttemptsPath())) {
|
|
246
|
+
const [c, p] = readFileSync(bootAttemptsPath(), 'utf8').trim().split(/\s+/)
|
|
247
|
+
count = Number(c) || 0
|
|
248
|
+
prev = Number(p) || 0
|
|
249
|
+
}
|
|
250
|
+
count = nowSec - prev < 150 ? count + 1 : 1
|
|
251
|
+
writeFileSync(bootAttemptsPath(), `${count} ${nowSec}\n`)
|
|
252
|
+
return count
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
it('WITHOUT a bridge register, three fast boots accumulate toward the 3-strike clear', () => {
|
|
256
|
+
// Three operator hand-bounces, each <150s apart, with no register between.
|
|
257
|
+
expect(simulateBootStamp(1000)).toBe(1)
|
|
258
|
+
expect(simulateBootStamp(1010)).toBe(2)
|
|
259
|
+
expect(simulateBootStamp(1020)).toBe(3) // start.sh would now clear a HEALTHY override — the false positive
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('a bridge register between boots resets the counter, so a healthy agent never reaches 3', () => {
|
|
263
|
+
expect(simulateBootStamp(1000)).toBe(1)
|
|
264
|
+
// Boot 1 came all the way up and the bridge registered → gateway clears it.
|
|
265
|
+
clearSessionModelBootAttempts(dir)
|
|
266
|
+
expect(existsSync(bootAttemptsPath())).toBe(false)
|
|
267
|
+
|
|
268
|
+
// Next fast boot starts fresh at 1 (no accumulation), and register clears again.
|
|
269
|
+
expect(simulateBootStamp(1010)).toBe(1)
|
|
270
|
+
clearSessionModelBootAttempts(dir)
|
|
271
|
+
expect(simulateBootStamp(1020)).toBe(1)
|
|
272
|
+
// The healthy agent's counter never climbs to the 3-strike clear.
|
|
273
|
+
const [count] = readFileSync(bootAttemptsPath(), 'utf8').trim().split(/\s+/)
|
|
274
|
+
expect(Number(count)).toBeLessThan(3)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('is best-effort — no throw when the counter file is absent', () => {
|
|
278
|
+
expect(existsSync(bootAttemptsPath())).toBe(false)
|
|
279
|
+
expect(() => clearSessionModelBootAttempts(dir)).not.toThrow()
|
|
280
|
+
})
|
|
281
|
+
})
|
|
@@ -232,18 +232,43 @@ describe("status-pin boot cleanup is mutex-gated (structural)", () => {
|
|
|
232
232
|
);
|
|
233
233
|
});
|
|
234
234
|
|
|
235
|
-
it("every
|
|
236
|
-
//
|
|
235
|
+
it("every boot pin-cleanup call site follows the won-lock check", () => {
|
|
236
|
+
// #3026: boot cleanup is now invoked via the mutex-gated orchestrator
|
|
237
|
+
// runBootPinCleanupAndDmSweep(), which awaits statusPinBootCleanup()
|
|
238
|
+
// (+ the other reapers) and then runs the DM stale-pin sweep. The
|
|
239
|
+
// invariant is unchanged: cleanup must only run after the lock is won.
|
|
237
240
|
const lines = gatewaySrc.split("\n");
|
|
238
241
|
const lockIdx = lines.findIndex((l) => l.includes("acquireStartupLock({"));
|
|
239
242
|
expect(lockIdx).toBeGreaterThan(-1);
|
|
240
243
|
|
|
244
|
+
// Every `void runBootPinCleanupAndDmSweep()` invocation must come AFTER
|
|
245
|
+
// the acquireStartupLock outcome is available (the boot block the winner
|
|
246
|
+
// — or the link()-unsupported fallback — reaches).
|
|
247
|
+
const callIdxs = lines
|
|
248
|
+
.map((l, i) => ({ l, i }))
|
|
249
|
+
.filter(
|
|
250
|
+
({ l }) =>
|
|
251
|
+
/void runBootPinCleanupAndDmSweep\(\)/.test(l) &&
|
|
252
|
+
!l.trimStart().startsWith("//"),
|
|
253
|
+
)
|
|
254
|
+
.map(({ i }) => i);
|
|
255
|
+
expect(callIdxs.length).toBeGreaterThan(0);
|
|
256
|
+
for (const idx of callIdxs) {
|
|
257
|
+
expect(idx).toBeGreaterThan(lockIdx);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// statusPinBootCleanup() itself must only ever be invoked from INSIDE
|
|
261
|
+
// that orchestrator — never at a bare post-import call site that a
|
|
262
|
+
// losing double-boot could reach.
|
|
241
263
|
const declIdx = lines.findIndex((l) =>
|
|
242
264
|
/async function statusPinBootCleanup/.test(l),
|
|
243
265
|
);
|
|
244
266
|
expect(declIdx).toBeGreaterThan(-1);
|
|
245
|
-
|
|
246
|
-
|
|
267
|
+
const orchestratorIdx = lines.findIndex((l) =>
|
|
268
|
+
/async function runBootPinCleanupAndDmSweep/.test(l),
|
|
269
|
+
);
|
|
270
|
+
expect(orchestratorIdx).toBeGreaterThan(-1);
|
|
271
|
+
const invocationIdxs = lines
|
|
247
272
|
.map((l, i) => ({ l, i }))
|
|
248
273
|
.filter(
|
|
249
274
|
({ l, i }) =>
|
|
@@ -253,25 +278,21 @@ describe("status-pin boot cleanup is mutex-gated (structural)", () => {
|
|
|
253
278
|
!l.includes("statusPinBootCleanup() is deliberately NOT"),
|
|
254
279
|
)
|
|
255
280
|
.map(({ i }) => i);
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
// block that only the winner reaches).
|
|
260
|
-
expect(callIdxs.length).toBeGreaterThan(0);
|
|
261
|
-
for (const idx of callIdxs) {
|
|
262
|
-
expect(idx).toBeGreaterThan(lockIdx);
|
|
281
|
+
expect(invocationIdxs.length).toBeGreaterThan(0);
|
|
282
|
+
for (const idx of invocationIdxs) {
|
|
283
|
+
expect(idx).toBeGreaterThan(orchestratorIdx);
|
|
263
284
|
}
|
|
264
285
|
});
|
|
265
286
|
|
|
266
287
|
it("the blocked (losing) boot exits before reaching cleanup", () => {
|
|
267
288
|
// In the mutex block, `outcome.status === 'blocked'` must lead to
|
|
268
|
-
// process.exit(1) BEFORE
|
|
269
|
-
// so a loser never touches the shared store.
|
|
289
|
+
// process.exit(1) BEFORE the winner's runBootPinCleanupAndDmSweep() call
|
|
290
|
+
// in that block — so a loser never touches the shared store.
|
|
270
291
|
const blockedIdx = gatewaySrc.indexOf("outcome.status === 'blocked'");
|
|
271
292
|
expect(blockedIdx).toBeGreaterThan(-1);
|
|
272
293
|
const afterBlocked = gatewaySrc.slice(blockedIdx);
|
|
273
294
|
const exitIdx = afterBlocked.indexOf("process.exit(1)");
|
|
274
|
-
const cleanupIdx = afterBlocked.indexOf("void
|
|
295
|
+
const cleanupIdx = afterBlocked.indexOf("void runBootPinCleanupAndDmSweep()");
|
|
275
296
|
expect(exitIdx).toBeGreaterThan(-1);
|
|
276
297
|
expect(cleanupIdx).toBeGreaterThan(-1);
|
|
277
298
|
// The exit for the blocked branch appears before the winner's cleanup call.
|