switchroom 0.18.11 → 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.
Files changed (122) hide show
  1. package/dist/agent-scheduler/index.js +29 -5
  2. package/dist/auth-broker/index.js +53 -13
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/notion-write-pretool.mjs +29 -5
  5. package/dist/cli/switchroom.js +2453 -1293
  6. package/dist/cli/ui/index.html +163 -17
  7. package/dist/host-control/main.js +504 -100
  8. package/dist/vault/approvals/kernel-server.js +53 -13
  9. package/dist/vault/broker/server.js +162 -114
  10. package/package.json +3 -4
  11. package/profiles/_base/start.sh.hbs +65 -0
  12. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  13. package/profiles/coding/CLAUDE.md.hbs +1 -1
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/telegram-plugin/bridge/bridge.ts +37 -0
  18. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  19. package/telegram-plugin/dist/bridge/bridge.js +73 -1
  20. package/telegram-plugin/dist/gateway/gateway.js +3602 -1007
  21. package/telegram-plugin/dist/server.js +74 -2
  22. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  23. package/telegram-plugin/gateway/approval-hold.ts +583 -0
  24. package/telegram-plugin/gateway/auth-command.ts +92 -2
  25. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  26. package/telegram-plugin/gateway/boot-card.ts +12 -5
  27. package/telegram-plugin/gateway/callback-query-handlers.ts +76 -1
  28. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  29. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  30. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  31. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  32. package/telegram-plugin/gateway/gateway.ts +1482 -165
  33. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  34. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  35. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  36. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  37. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  38. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  39. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  40. package/telegram-plugin/gateway/model-command.ts +9 -3
  41. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  42. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  43. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  44. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  45. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  46. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  47. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  48. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  49. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  50. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  51. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  52. package/telegram-plugin/hooks/hooks.json +9 -0
  53. package/telegram-plugin/inline-keyboard-callbacks.ts +209 -2
  54. package/telegram-plugin/operator-events.ts +23 -0
  55. package/telegram-plugin/package.json +0 -1
  56. package/telegram-plugin/permission-rule.ts +1 -0
  57. package/telegram-plugin/permission-title.ts +1 -0
  58. package/telegram-plugin/retry-api-call.ts +212 -2
  59. package/telegram-plugin/send-gate-degraded.test.ts +443 -0
  60. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  61. package/telegram-plugin/send-gate-observability.ts +355 -0
  62. package/telegram-plugin/send-gate.test.ts +698 -0
  63. package/telegram-plugin/send-gate.ts +982 -0
  64. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  65. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  66. package/telegram-plugin/status-pin-driver.ts +52 -7
  67. package/telegram-plugin/status-pin.ts +81 -0
  68. package/telegram-plugin/subagent-watcher.ts +102 -2
  69. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  70. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  71. package/telegram-plugin/tests/approval-hold-outcome.test.ts +296 -0
  72. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  73. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  74. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  75. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  76. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  77. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  78. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  79. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  80. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  81. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  82. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  83. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  84. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  85. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  86. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +284 -0
  87. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  88. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  89. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  90. package/telegram-plugin/tests/model-command.test.ts +14 -0
  91. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  92. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  93. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  94. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  95. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  96. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  97. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  98. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  99. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  100. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  101. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  102. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  103. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  104. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  105. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  106. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  107. package/telegram-plugin/typing-emitter.ts +224 -0
  108. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  109. package/telegram-plugin/welcome-text.ts +42 -0
  110. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  111. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  112. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  113. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  114. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  115. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  116. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  117. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  118. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  119. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  120. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  121. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  122. 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 statusPinBootCleanup() call site follows the won-lock check", () => {
236
- // Find each *invocation* (with parens), excluding the declaration.
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
- const callIdxs = lines
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
- // There must be at least one real call, and every real call must come
258
- // AFTER the acquireStartupLock outcome is available (i.e. inside the boot
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 any statusPinBootCleanup() call in that block —
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 statusPinBootCleanup()");
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.