switchroom 0.18.11 → 0.18.13

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 (150) hide show
  1. package/dist/agent-scheduler/index.js +37 -5
  2. package/dist/auth-broker/index.js +116 -78
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  5. package/dist/cli/notion-write-pretool.mjs +38 -6
  6. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  7. package/dist/cli/switchroom.js +3529 -4543
  8. package/dist/cli/ui/index.html +163 -17
  9. package/dist/host-control/main.js +605 -2847
  10. package/dist/vault/approvals/kernel-server.js +120 -13
  11. package/dist/vault/broker/server.js +259 -157
  12. package/package.json +3 -4
  13. package/profiles/_base/start.sh.hbs +65 -0
  14. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  15. package/profiles/coding/CLAUDE.md.hbs +1 -1
  16. package/profiles/default/CLAUDE.md.hbs +2 -2
  17. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  18. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  19. package/telegram-plugin/bridge/bridge.ts +37 -0
  20. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +122 -4
  22. package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
  23. package/telegram-plugin/dist/server.js +139 -5
  24. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  25. package/telegram-plugin/format.ts +19 -0
  26. package/telegram-plugin/gateway/approval-hold.ts +602 -0
  27. package/telegram-plugin/gateway/auth-command.ts +92 -2
  28. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  29. package/telegram-plugin/gateway/boot-card.ts +12 -5
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
  31. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  32. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  33. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  34. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  35. package/telegram-plugin/gateway/gateway.ts +1695 -230
  36. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  37. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  38. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  39. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  40. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  41. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  42. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  43. package/telegram-plugin/gateway/model-command.ts +9 -3
  44. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  45. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  46. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  47. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  48. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  49. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  50. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  51. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  52. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  53. package/telegram-plugin/history.ts +51 -0
  54. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  55. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  56. package/telegram-plugin/hooks/hooks.json +9 -0
  57. package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
  58. package/telegram-plugin/model-unavailable.ts +41 -11
  59. package/telegram-plugin/operator-events.ts +23 -0
  60. package/telegram-plugin/outbound-field-redact.ts +69 -0
  61. package/telegram-plugin/package.json +0 -1
  62. package/telegram-plugin/permission-rule.ts +1 -0
  63. package/telegram-plugin/permission-title.ts +1 -0
  64. package/telegram-plugin/render/render.ts +32 -14
  65. package/telegram-plugin/retry-api-call.ts +212 -2
  66. package/telegram-plugin/scoped-approval.ts +11 -2
  67. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  68. package/telegram-plugin/secret-detect/index.ts +12 -56
  69. package/telegram-plugin/send-gate-degraded.test.ts +574 -0
  70. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  71. package/telegram-plugin/send-gate-observability.ts +355 -0
  72. package/telegram-plugin/send-gate.test.ts +717 -0
  73. package/telegram-plugin/send-gate.ts +1056 -0
  74. package/telegram-plugin/session-tail.ts +82 -7
  75. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  76. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  77. package/telegram-plugin/status-pin-driver.ts +52 -7
  78. package/telegram-plugin/status-pin.ts +81 -0
  79. package/telegram-plugin/subagent-watcher.ts +173 -18
  80. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  81. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  82. package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
  83. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  84. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  85. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  86. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  87. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  88. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  89. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  90. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  91. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  92. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  93. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  94. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  95. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  96. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  97. package/telegram-plugin/tests/history.test.ts +115 -0
  98. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  99. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  100. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  101. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
  102. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  103. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  104. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  105. package/telegram-plugin/tests/model-command.test.ts +14 -0
  106. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  107. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  108. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  109. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  111. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  112. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  113. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  114. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  115. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  116. package/telegram-plugin/tests/render/render.test.ts +88 -0
  117. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  118. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  119. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  120. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  121. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  122. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  123. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  124. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  125. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  126. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  127. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  128. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  129. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  130. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  131. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  132. package/telegram-plugin/typing-emitter.ts +224 -0
  133. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  134. package/telegram-plugin/welcome-text.ts +42 -0
  135. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  136. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  137. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  138. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  139. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  140. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  141. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  142. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  143. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  144. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  145. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  146. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  147. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  148. package/telegram-plugin/channel-envelope-safety.ts +0 -56
  149. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  150. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -0,0 +1,602 @@
1
+ /**
2
+ * #3084 follow-up, PR 2 — a held permission card must COME BACK when the flood
3
+ * window closes.
4
+ *
5
+ * Holding instead of auto-denying is only safe if the ask actually returns.
6
+ * This is the half that returns it. The risk it introduces is a BURST: a
7
+ * backlog of held cards all firing the instant the window closes is the one
8
+ * realistic way this design could re-earn the very ban it exists to survive.
9
+ * Hence the per-tick cap, and hence these tests.
10
+ *
11
+ * No fake timers (bun-compatible) — the harness carries a virtual clock.
12
+ */
13
+
14
+ import { describe, it, expect, afterEach } from 'vitest'
15
+ import { rmSync, readFileSync } from 'node:fs'
16
+ import { createHarness, type FaultKind } from './approval-hold-harness.js'
17
+ import {
18
+ selectHeldForRedelivery,
19
+ holdReasonFor,
20
+ HELD_CARD_REDELIVERY_CAP,
21
+ } from '../gateway/approval-hold.js'
22
+
23
+ const FOUR_HOURS = 4 * 60 * 60_000
24
+ const MINUTE = 60_000
25
+
26
+ const dirs: string[] = []
27
+ afterEach(() => {
28
+ for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
29
+ })
30
+
31
+ function harness(opts?: { cap?: number }) {
32
+ const h = createHarness(opts)
33
+ dirs.push(h.stateDir)
34
+ return h
35
+ }
36
+
37
+ describe('held permission cards re-deliver when the window closes', () => {
38
+ it('does not send while the window is open, then sends EXACTLY once when it closes', async () => {
39
+ const h = harness()
40
+ h.banFor(FOUR_HOURS)
41
+
42
+ await h.raise('req-1', 'Bash', '{"command":"npm test"}')
43
+
44
+ // (a) nothing landed during the ban
45
+ expect(h.sends.length).toBe(0)
46
+ expect(h.pending.get('req-1')?.undeliverable?.reason).toBe('flood_wait')
47
+
48
+ // Ticks throughout the ban must not re-drive the send.
49
+ for (let i = 0; i < 20; i++) {
50
+ h.clock.advance(10 * MINUTE)
51
+ await h.tickSettled()
52
+ }
53
+ expect(h.sends.length).toBe(0)
54
+ expect(h.floodRemainingMs()).toBeGreaterThan(0)
55
+
56
+ // Window closes.
57
+ h.clock.advance(FOUR_HOURS)
58
+ await h.tickSettled()
59
+
60
+ // (d) exactly one card for the whole run — no loss, no duplicate.
61
+ expect(h.sends.length).toBe(1)
62
+ expect(h.sends[0]!.requestId).toBe('req-1')
63
+ // …and we never fired a request into a window we already knew was open.
64
+ expect(h.sentIntoOpenWindow).toBe(false)
65
+ })
66
+
67
+ it('clears the hold mark and the blocked record once the card lands', async () => {
68
+ const h = harness()
69
+ h.banFor(FOUR_HOURS)
70
+ await h.raise('req-1', 'Bash', '{"command":"npm test"}')
71
+ expect(h.blockedStore.read()?.requestId).toBe('req-1')
72
+
73
+ h.clock.advance(FOUR_HOURS + MINUTE)
74
+ await h.tickSettled()
75
+
76
+ expect(h.pending.get('req-1')?.undeliverable).toBeNull()
77
+ expect(h.pending.get('req-1')?.cards.length).toBe(1)
78
+ // The surface must go quiet — the operator can see the card in Telegram now.
79
+ expect(h.blockedStore.read()).toBeNull()
80
+ })
81
+
82
+ it('further ticks after a successful re-delivery never post a second card', async () => {
83
+ const h = harness()
84
+ h.banFor(FOUR_HOURS)
85
+ await h.raise('req-1', 'Bash', '{"command":"npm test"}')
86
+
87
+ h.clock.advance(FOUR_HOURS + MINUTE)
88
+ await h.tickSettled()
89
+ expect(h.sends.length).toBe(1)
90
+
91
+ // The `cards.length === 0` precondition is the double-delivery guard.
92
+ for (let i = 0; i < 5; i++) {
93
+ h.clock.advance(MINUTE)
94
+ await h.tickSettled()
95
+ }
96
+ expect(h.sends.length).toBe(1)
97
+ })
98
+
99
+ it('a ban EXTENSION re-holds with the longer window rather than bursting', async () => {
100
+ const h = harness()
101
+ h.banFor(FOUR_HOURS)
102
+ await h.raise('req-1', 'Bash', '{"command":"npm test"}')
103
+
104
+ // The window "closes"… but Telegram re-bans on the retry (computeFloodWait
105
+ // only ever GROWS untilTs, so this is self-correcting).
106
+ h.clock.advance(FOUR_HOURS + MINUTE)
107
+ h.banFor(FOUR_HOURS)
108
+ await h.tickSettled()
109
+
110
+ expect(h.sends.length).toBe(0)
111
+ expect(h.pending.get('req-1')?.undeliverable?.reason).toBe('flood_wait')
112
+
113
+ // The longer window eventually closes and the card lands — once.
114
+ h.clock.advance(FOUR_HOURS + MINUTE)
115
+ await h.tickSettled()
116
+ expect(h.sends.length).toBe(1)
117
+ })
118
+
119
+ it('the operator tap resolves the ORIGINAL requestId and the agent continues', async () => {
120
+ const h = harness()
121
+ h.banFor(FOUR_HOURS)
122
+ await h.raise('req-original', 'Bash', '{"command":"npm test"}')
123
+
124
+ h.clock.advance(FOUR_HOURS + MINUTE)
125
+ await h.tickSettled()
126
+
127
+ h.tap('req-original', 'allow')
128
+
129
+ expect(h.verdicts).toEqual([{ requestId: 'req-original', behavior: 'allow' }])
130
+ expect(h.pending.has('req-original')).toBe(false)
131
+ expect(h.blockedStore.read()).toBeNull()
132
+ })
133
+ })
134
+
135
+ describe('the per-tick cap — a backlog must not burst the moment the ban lifts', () => {
136
+ it('sends at most the cap per tick, and drains the rest on later ticks', async () => {
137
+ const h = harness()
138
+ h.banFor(FOUR_HOURS)
139
+ for (let i = 1; i <= 7; i++) {
140
+ await h.raise(`req-${i}`, 'Bash', `{"command":"cmd-${i}"}`)
141
+ }
142
+ expect(h.sends.length).toBe(0)
143
+
144
+ h.clock.advance(FOUR_HOURS + MINUTE)
145
+
146
+ await h.tickSettled()
147
+ expect(h.sends.length).toBe(HELD_CARD_REDELIVERY_CAP) // 3
148
+
149
+ await h.tickSettled()
150
+ expect(h.sends.length).toBe(2 * HELD_CARD_REDELIVERY_CAP) // 6
151
+
152
+ await h.tickSettled()
153
+ expect(h.sends.length).toBe(7) // drained — nothing lost
154
+
155
+ // And it settles: no eighth send, no duplicates.
156
+ await h.tickSettled()
157
+ expect(h.sends.length).toBe(7)
158
+ expect(new Set(h.sends.map(s => s.requestId)).size).toBe(7)
159
+ expect(h.sentIntoOpenWindow).toBe(false)
160
+ })
161
+ })
162
+
163
+ describe('selectHeldForRedelivery — the guards, directly', () => {
164
+ const held = (cards = 0) => ({
165
+ undeliverable: { since: 1, retryableAt: 2, reason: 'flood_wait' as const },
166
+ cards: new Array(cards).fill({}),
167
+ })
168
+
169
+ it('(i) sends NOTHING while the window is still open', () => {
170
+ const sel = selectHeldForRedelivery([['a', held()]], {
171
+ floodRemainingMs: 1,
172
+ inFlight: new Set(),
173
+ })
174
+ expect(sel).toEqual({ send: [], deferred: [] })
175
+ })
176
+
177
+ it('(ii) skips entries that already have a landed card (double-delivery guard)', () => {
178
+ const sel = selectHeldForRedelivery([['a', held(1)]], {
179
+ floodRemainingMs: 0,
180
+ inFlight: new Set(),
181
+ })
182
+ expect(sel.send).toEqual([])
183
+ })
184
+
185
+ it('(ii) skips entries that are not held at all', () => {
186
+ const sel = selectHeldForRedelivery([['a', { cards: [] }]], {
187
+ floodRemainingMs: 0,
188
+ inFlight: new Set(),
189
+ })
190
+ expect(sel.send).toEqual([])
191
+ })
192
+
193
+ it('(iii) skips entries already in flight from a previous tick', () => {
194
+ const sel = selectHeldForRedelivery([['a', held()]], {
195
+ floodRemainingMs: 0,
196
+ inFlight: new Set(['a']),
197
+ })
198
+ expect(sel.send).toEqual([])
199
+ })
200
+
201
+ it('(iv) caps the tick and DEFERS the overflow — never silently truncates', () => {
202
+ const entries: Array<[string, ReturnType<typeof held>]> = ['a', 'b', 'c', 'd', 'e'].map(
203
+ id => [id, held()],
204
+ )
205
+ const sel = selectHeldForRedelivery(entries, {
206
+ floodRemainingMs: 0,
207
+ inFlight: new Set(),
208
+ cap: 2,
209
+ })
210
+ expect(sel.send).toEqual(['a', 'b'])
211
+ // The overflow is reported, so the caller can log it. Nothing vanishes.
212
+ expect(sel.deferred).toEqual(['c', 'd', 'e'])
213
+ expect(sel.send.length + sel.deferred.length).toBe(entries.length)
214
+ })
215
+ })
216
+
217
+ /** Source-text pins — gateway.ts is not importable, so pin the wiring. */
218
+ describe('gateway wiring — the reaper re-drives held cards', () => {
219
+ const GATEWAY_SRC = readFileSync(
220
+ new URL('../gateway/gateway.ts', import.meta.url),
221
+ 'utf8',
222
+ )
223
+
224
+ it('the send is extracted into postPermissionCard, with two callers', () => {
225
+ expect(GATEWAY_SRC).toContain('function postPermissionCard(')
226
+ // caller 1: the initial delivery in onPermissionRequest
227
+ expect(GATEWAY_SRC).toContain('postPermissionCard(requestId, pendEntry)')
228
+ // caller 2: the reaper's held-card sweep
229
+ expect(GATEWAY_SRC).toContain('postPermissionCard(requestId, pend)')
230
+ })
231
+
232
+ it('the reaper runs the held-card sweep', () => {
233
+ expect(GATEWAY_SRC).toContain('function sweepHeldPermissionCards()')
234
+ expect(GATEWAY_SRC).toContain('sweepHeldPermissionCards()')
235
+ })
236
+
237
+ it('the sweep gates on the SAME flood probe robustApiCall uses', () => {
238
+ // One source of truth for "is the channel open" — the sweep's check and the
239
+ // retry policy's pre-call short-circuit are two reads of one on-disk window.
240
+ expect(GATEWAY_SRC).toContain('const probeFloodWaitRemainingMs = makeFloodWaitProbe(FLOOD_STATE_PATH)')
241
+ expect(GATEWAY_SRC).toContain('floodWaitRemainingMs: probeFloodWaitRemainingMs')
242
+ expect(GATEWAY_SRC).toContain('const remaining = probeFloodWaitRemainingMs()')
243
+ })
244
+
245
+ it('the sweep carries the in-flight guard', () => {
246
+ expect(GATEWAY_SRC).toContain('const heldCardsInFlight = new Set<string>()')
247
+ expect(GATEWAY_SRC).toContain('inFlight: heldCardsInFlight')
248
+ expect(GATEWAY_SRC).toContain('heldCardsInFlight.delete(requestId)')
249
+ })
250
+
251
+ it('deferred cards are logged, never silently dropped', () => {
252
+ expect(GATEWAY_SRC).toContain('deferred, NOT dropped')
253
+ })
254
+
255
+ // The production code MUST have the same shape the harness models, or the
256
+ // outcome tests above are testing a fiction. These pin the three fan-out fixes.
257
+ it('F1 — the in-flight flag clears ONCE per outcome, after Promise.allSettled over every target', () => {
258
+ const at = GATEWAY_SRC.indexOf('function postPermissionCard(')
259
+ const fn = GATEWAY_SRC.slice(at, GATEWAY_SRC.indexOf('\n/**', at))
260
+ expect(fn).toContain('Promise.allSettled(sends)')
261
+ // Exactly two releases: the allSettled settle (the async path) and the
262
+ // sync-throw catch (so a throw in the prologue can't leak the flag).
263
+ // NOT a per-target .finally inside the fan-out loop.
264
+ expect(fn.match(/heldCardsInFlight\.delete\(requestId\)/g)?.length).toBe(2)
265
+ expect(fn.indexOf('Promise.allSettled(sends)'))
266
+ .toBeLessThan(fn.indexOf('heldCardsInFlight.delete(requestId)'))
267
+ })
268
+
269
+ it('the flag is registered at the TOP of postPermissionCard — both callers covered', () => {
270
+ const at = GATEWAY_SRC.indexOf('function postPermissionCard(')
271
+ const fn = GATEWAY_SRC.slice(at, GATEWAY_SRC.indexOf('\n/**', at))
272
+ // Registered inside postPermissionCard itself, before any send fires, so the
273
+ // ipc INITIAL-delivery caller is guarded too — not only the sweep.
274
+ expect(fn).toContain('heldCardsInFlight.add(requestId)')
275
+ expect(fn.indexOf('heldCardsInFlight.add(requestId)'))
276
+ .toBeLessThan(fn.indexOf('Promise.allSettled(sends)'))
277
+ // …and the sweep no longer registers it at the call site.
278
+ const sweepAt = GATEWAY_SRC.indexOf('function sweepHeldPermissionCards(')
279
+ const sweep = GATEWAY_SRC.slice(sweepAt, GATEWAY_SRC.indexOf('\n// 60-second sweep', sweepAt))
280
+ expect(sweep).not.toContain('heldCardsInFlight.add(')
281
+ })
282
+
283
+ it('a SYNC throw in the sweep loop is caught — the reaper tick survives, the flag is freed', () => {
284
+ const sweepAt = GATEWAY_SRC.indexOf('function sweepHeldPermissionCards(')
285
+ const sweep = GATEWAY_SRC.slice(sweepAt, GATEWAY_SRC.indexOf('\n// 60-second sweep', sweepAt))
286
+ expect(sweep).toContain('try {')
287
+ expect(sweep).toContain('postPermissionCard(requestId, pend)')
288
+ expect(sweep).toContain('heldCardsInFlight.delete(requestId)')
289
+ })
290
+
291
+ it('the backoff counts once per ATTEMPT, not once per failing target', () => {
292
+ const at = GATEWAY_SRC.indexOf('function postPermissionCard(')
293
+ const fn = GATEWAY_SRC.slice(at, GATEWAY_SRC.indexOf('\n/**', at))
294
+ // The attempt number is computed ONCE, before the fan-out; every failing
295
+ // target stamps the same value. A per-target `(live.redeliveryFailures ?? 0) + 1`
296
+ // inside the catch would ratchet the 1m/2m/4m ladder N× faster.
297
+ expect(fn).toContain('const attemptFailures = (pend.redeliveryFailures ?? 0) + 1')
298
+ expect(fn).toContain('const failures = attemptFailures')
299
+ expect(fn).not.toContain('const failures = (live.redeliveryFailures ?? 0) + 1')
300
+ })
301
+
302
+ it('F2 — the failure handler never re-marks an entry whose card already landed', () => {
303
+ const at = GATEWAY_SRC.indexOf('function postPermissionCard(')
304
+ const fn = GATEWAY_SRC.slice(at, GATEWAY_SRC.indexOf('\n/**', at))
305
+ expect(fn).toContain('if (live.cards.length > 0) return')
306
+ })
307
+
308
+ it('F3/F6 — holdReasonFor decides, and re-delivery is bounded but the hold is not', () => {
309
+ const at = GATEWAY_SRC.indexOf('function postPermissionCard(')
310
+ const fn = GATEWAY_SRC.slice(at, GATEWAY_SRC.indexOf('\n/**', at))
311
+ expect(fn).toContain('const reason = holdReasonFor(e)')
312
+ expect(fn).toContain('live.redeliveryFailures = failures')
313
+ // The cap stops RE-SENDING, never the HOLD. No verdict is ever dispatched here.
314
+ expect(fn).not.toContain("behavior: 'deny'")
315
+ expect(fn).not.toContain("behavior: 'allow'")
316
+ })
317
+ })
318
+
319
+ /**
320
+ * The multi-target bugs. `resolvePermissionCardTargets()` ends in
321
+ * `allowFrom.map(...)` — it returns N targets, and on the RE-delivery path N>1 is
322
+ * the COMMON case (re-delivery after a multi-hour ban is by construction past the
323
+ * 30-min origin-recovery window, so it falls through to the operator-DM fan-out).
324
+ *
325
+ * A harness pinned at one target cannot see any of these. They were found in
326
+ * review, not by the tests — so they get tests.
327
+ */
328
+ describe('N targets — the fan-out cases a single-target harness cannot see', () => {
329
+ const TWO = ['-100200', '-100999']
330
+
331
+ // F1. The reaper is fire-and-forget (`void postPermissionCard(...)`), so a send
332
+ // can still be in flight when the NEXT 60s tick fires. If the in-flight flag is
333
+ // cleared per-target, the first target to settle releases it while the second is
334
+ // still sending — and the next tick re-posts to BOTH.
335
+ it('the in-flight flag is NOT released until EVERY target settles', async () => {
336
+ // F1, stated as the invariant it protects. `resolvePermissionCardTargets()`
337
+ // returns N targets. If the flag is cleared per-target, the FIRST target to
338
+ // settle releases it while others are still sending — and the next 60s tick
339
+ // (the reaper is fire-and-forget, so ticks DO overlap in-flight sends)
340
+ // re-selects the request and posts the card all over again.
341
+ const h = createHarness({ targets: TWO })
342
+ dirs.push(h.stateDir)
343
+ h.banFor(FOUR_HOURS)
344
+ await h.raise('req-1', 'Bash', '{"command":"npm test"}')
345
+ await h.settle()
346
+ expect(h.sends.length).toBe(0)
347
+
348
+ // Window closes. Target A fails FAST (a transient give-up — note this does
349
+ // not re-open the flood window, so the sweep stays armed); target B HANGS.
350
+ h.clock.advance(FOUR_HOURS + MINUTE)
351
+ h.breakTarget('-100200', 'econnreset')
352
+ h.hangTarget('-100999')
353
+
354
+ await h.tick() // fire-and-forget: A settles, B is still in flight
355
+ await h.flush() // let A's failure chain run to completion
356
+
357
+ // A has settled and B has NOT. The request must STILL be flagged in-flight —
358
+ // this is the whole guard. A per-target clear releases it here.
359
+ expect(h.isInFlight('req-1')).toBe(true)
360
+
361
+ // …so an overlapping tick cannot re-select it.
362
+ await h.tick()
363
+ await h.flush()
364
+ await h.tick()
365
+ await h.flush()
366
+
367
+ h.releaseTarget('-100999')
368
+ await h.settle()
369
+
370
+ // B received exactly one card, and the flag is finally released.
371
+ expect(h.sends.filter(x => x.chatId === '-100999').length).toBe(1)
372
+ expect(h.isInFlight('req-1')).toBe(false)
373
+ })
374
+
375
+ // F2. Target A succeeds, target B flood-fails in the SAME fan-out. If the catch
376
+ // re-marks the entry without checking cards.length, we get
377
+ // `cards.length > 0 && undeliverable != null` — which selectHeldForRedelivery
378
+ // skips forever (it has cards), so the mark can never be cleared. Stacked with
379
+ // PR 3's TTL freeze that is a PERMANENT wedge: the agent never unblocks and the
380
+ // dashboard shows "blocked" forever.
381
+ it('a card landing on ONE target while another floods does NOT wedge the entry', async () => {
382
+ const h = createHarness({ targets: TWO })
383
+ dirs.push(h.stateDir)
384
+ h.banTarget('-100999', FOUR_HOURS) // only this chat is banned
385
+ await h.raise('req-1', 'Bash', '{"command":"npm test"}')
386
+ await h.settle()
387
+
388
+ // The card LANDED on the good target — the operator can see and tap it.
389
+ expect(h.sends.length).toBe(1)
390
+ expect(h.sends[0]!.chatId).toBe('-100200')
391
+
392
+ // So the ask is NOT undeliverable, and there must be NO blocked record.
393
+ expect(h.pending.get('req-1')?.undeliverable ?? null).toBeNull()
394
+ expect(h.blockedStore.read()).toBeNull()
395
+
396
+ // …and it is not re-delivered forever, nor wedged: a tap resolves it.
397
+ for (let i = 0; i < 3; i++) { h.clock.advance(MINUTE); await h.tickSettled() }
398
+ expect(h.sends.length).toBe(1)
399
+ h.tap('req-1', 'allow')
400
+ expect(h.verdicts).toEqual([{ requestId: 'req-1', behavior: 'allow' }])
401
+ })
402
+
403
+ it('a PERMANENT failure (400) on every target is NOT held — it falls through', async () => {
404
+ const h = createHarness({ targets: ['-100999'] })
405
+ dirs.push(h.stateDir)
406
+ h.breakTarget('-100999', 'tg_400')
407
+ await h.raise('req-1', 'Bash', '{"command":"npm test"}')
408
+ await h.settle()
409
+
410
+ // A 400 will never format, whatever we do. Holding would park the agent
411
+ // forever on a card that can never render.
412
+ expect(h.sends.length).toBe(0)
413
+ expect(h.pending.get('req-1')?.undeliverable ?? null).toBeNull()
414
+ expect(h.blockedStore.read()).toBeNull()
415
+ })
416
+
417
+ // F3. A held entry is re-selected every tick. A target that keeps failing would
418
+ // otherwise be re-sent every 60s forever — the amplifier this series exists to
419
+ // avoid. But it must NEVER be abandoned either: a terminal give-up strands the
420
+ // card even after the channel recovers, and turnInFlightForGate() would buffer
421
+ // the operator's normal messages forever with no signal. So: bounded RATE,
422
+ // unbounded RETRY.
423
+ it('re-delivery BACKS OFF but is never abandoned — and the approval stays HELD', async () => {
424
+ const h = createHarness({ targets: ['-100999'] })
425
+ dirs.push(h.stateDir)
426
+ h.breakTarget('-100999', 'econnreset') // a network partition
427
+ await h.raise('req-1', 'Bash', '{"command":"npm test"}')
428
+ await h.settle()
429
+
430
+ expect(h.pending.get('req-1')?.undeliverable?.reason).toBe('transient')
431
+
432
+ // Hammer it for two hours of ticks. The backoff must throttle the re-sends…
433
+ for (let i = 0; i < 120; i++) { h.clock.advance(MINUTE); await h.tickSettled() }
434
+ const attempts = h.pending.get('req-1')!.redeliveryFailures!
435
+ expect(attempts).toBeLessThan(20) // NOT ~120 — the backoff is working
436
+
437
+ // …and no verdict was ever fabricated.
438
+ expect(h.verdicts).toEqual([])
439
+ expect(h.blockedStore.read()?.requestId).toBe('req-1')
440
+
441
+ // THE CHANNEL RECOVERS. The card must come back — never abandoned.
442
+ h.healTarget('-100999')
443
+ for (let i = 0; i < 40; i++) { h.clock.advance(MINUTE); await h.tickSettled() }
444
+ expect(h.sends.length).toBe(1)
445
+ expect(h.pending.get('req-1')?.undeliverable ?? null).toBeNull()
446
+ expect(h.blockedStore.read()).toBeNull()
447
+ })
448
+
449
+ // Review finding: only the sweep registered the in-flight flag — the ipc
450
+ // INITIAL-delivery call site did not. So: target A fails transiently (marks
451
+ // the entry held), target B is still slowly sending, a tick lands past the
452
+ // backoff — and, unguarded, re-posts the card while the FIRST post is still
453
+ // in flight. The flag now lives at the top of postPermissionCard itself.
454
+ it('a tick landing while the INITIAL delivery is still in flight does not duplicate', async () => {
455
+ const h = createHarness({ targets: TWO })
456
+ dirs.push(h.stateDir)
457
+ h.breakTarget('-100200', 'econnreset')
458
+ h.hangTarget('-100999')
459
+
460
+ const first = h.raise('req-1', 'Bash', '{"command":"npm test"}') // NOT awaited
461
+ await h.flush() // let A's failure chain mark the entry held
462
+
463
+ expect(h.pending.get('req-1')?.undeliverable?.reason).toBe('transient')
464
+ // The INITIAL delivery registered the flag — that is the fix under test.
465
+ expect(h.isInFlight('req-1')).toBe(true)
466
+
467
+ // Past the backoff, with B STILL in flight: the sweep must not re-post.
468
+ h.clock.advance(2 * MINUTE)
469
+ await h.tick()
470
+ await h.flush()
471
+ await h.tick()
472
+ await h.flush()
473
+
474
+ h.releaseTarget('-100999')
475
+ await first
476
+ await h.settle()
477
+
478
+ // Exactly ONE card reached B — no duplicate from the overlapping ticks.
479
+ expect(h.sends.filter(x => x.chatId === '-100999').length).toBe(1)
480
+ expect(h.isInFlight('req-1')).toBe(false)
481
+ })
482
+
483
+ // Review finding: a SYNC throw in postPermissionCard's prologue leaked the
484
+ // in-flight flag (never reaching the settle `.finally`) AND escaped the
485
+ // setInterval callback. The entry would then never be re-selected — a wedge.
486
+ it('a SYNC throw during re-delivery does not wedge the entry — the next tick retries', async () => {
487
+ const h = harness()
488
+ h.banFor(FOUR_HOURS)
489
+ await h.raise('req-1', 'Bash', '{"command":"npm test"}')
490
+ expect(h.pending.get('req-1')?.undeliverable?.reason).toBe('flood_wait')
491
+
492
+ // Window closes, but the card build throws synchronously on this tick.
493
+ h.clock.advance(FOUR_HOURS + MINUTE)
494
+ h.breakSync('req-1')
495
+ await h.tickSettled()
496
+ expect(h.sends.length).toBe(0)
497
+ // The flag did NOT leak — the entry is still re-selectable.
498
+ expect(h.isInFlight('req-1')).toBe(false)
499
+
500
+ // The very next tick re-delivers. No wedge, no operator intervention.
501
+ await h.tickSettled()
502
+ expect(h.sends.length).toBe(1)
503
+ expect(h.pending.get('req-1')?.undeliverable ?? null).toBeNull()
504
+ })
505
+
506
+ // Review finding: `redeliveryFailures` incremented once per failing TARGET per
507
+ // attempt, so two failing targets walked the documented 1m/2m/4m ladder twice
508
+ // as fast (rung 2 after one attempt, rung 4 after two…).
509
+ it('N failing targets count as ONE attempt — the ladder stays 1m/2m/4m', async () => {
510
+ const h = createHarness({ targets: TWO })
511
+ dirs.push(h.stateDir)
512
+ h.breakTarget('-100200', 'econnreset')
513
+ h.breakTarget('-100999', 'econnreset')
514
+
515
+ await h.raise('req-1', 'Bash', '{"command":"npm test"}')
516
+ await h.settle()
517
+
518
+ // BOTH targets failed in one fan-out. That is attempt 1, not attempt 2 —
519
+ // and the backoff is the ladder's FIRST rung: 1 minute.
520
+ expect(h.pending.get('req-1')!.redeliveryFailures).toBe(1)
521
+ expect(h.pending.get('req-1')!.undeliverable!.retryableAt - h.clock.now()).toBe(MINUTE)
522
+
523
+ // Attempt 2 (both targets fail again): rung 2 — 2 minutes, not 8.
524
+ h.clock.advance(MINUTE)
525
+ await h.tickSettled()
526
+ expect(h.pending.get('req-1')!.redeliveryFailures).toBe(2)
527
+ expect(h.pending.get('req-1')!.undeliverable!.retryableAt - h.clock.now()).toBe(2 * MINUTE)
528
+
529
+ // Attempt 3: rung 3 — 4 minutes.
530
+ h.clock.advance(2 * MINUTE)
531
+ await h.tickSettled()
532
+ expect(h.pending.get('req-1')!.redeliveryFailures).toBe(3)
533
+ expect(h.pending.get('req-1')!.undeliverable!.retryableAt - h.clock.now()).toBe(4 * MINUTE)
534
+ })
535
+ })
536
+
537
+ /**
538
+ * THE CLASSIFIER, driven end-to-end through the REAL retry policy.
539
+ *
540
+ * The first version of holdReasonFor() was an allowlist of marker strings on the
541
+ * premise that a network partition or a Telegram 5xx would surface as a give-up.
542
+ * The premise was FALSE — retryApiCall re-throws a 5xx GrammyError raw (its
543
+ * network-retry branch is guarded by `!isGrammyErr`), and re-throws a raw network
544
+ * error on the last attempt. So the allowlist FAILED OPEN: a single routine 502 at
545
+ * send time reproduced 2026-07-11 in full — no card, no mark, TTL fires, agent told
546
+ * the operator declined.
547
+ *
548
+ * These drive the real errors through the real policy. This is the test that would
549
+ * have caught it, and the reason the harness must throw real faults rather than
550
+ * the conclusion it expects.
551
+ */
552
+ describe('holdReasonFor — every real failure class, through the real retry policy', () => {
553
+ const TRANSIENT: FaultKind[] = ['tg_502', 'tg_500', 'econnreset', 'fetch_failed']
554
+ const PERMANENT: FaultKind[] = ['tg_400', 'tg_403']
555
+
556
+ for (const kind of TRANSIENT) {
557
+ it(`${kind} is HELD — the operator never got to answer`, async () => {
558
+ const h = createHarness({ targets: ['-100999'] })
559
+ dirs.push(h.stateDir)
560
+ h.breakTarget('-100999', kind)
561
+ await h.raise('req-1', 'Bash', '{"command":"npm test"}')
562
+ await h.settle()
563
+
564
+ expect(h.sends.length).toBe(0)
565
+ expect(h.pending.get('req-1')?.undeliverable?.reason).toBe('transient')
566
+ expect(h.blockedStore.read()?.requestId).toBe('req-1')
567
+
568
+ // …and past the 60-minute TTL, still no fabricated verdict.
569
+ for (let i = 0; i <= 61; i++) { await h.tickSettled(); h.clock.advance(MINUTE) }
570
+ expect(h.verdicts).toEqual([])
571
+ expect(h.pending.has('req-1')).toBe(true)
572
+ })
573
+ }
574
+
575
+ for (const kind of PERMANENT) {
576
+ it(`${kind} is NOT held — it will never render, so it falls through`, async () => {
577
+ const h = createHarness({ targets: ['-100999'] })
578
+ dirs.push(h.stateDir)
579
+ h.breakTarget('-100999', kind)
580
+ await h.raise('req-1', 'Bash', '{"command":"npm test"}')
581
+ await h.settle()
582
+
583
+ expect(h.sends.length).toBe(0)
584
+ expect(h.pending.get('req-1')?.undeliverable ?? null).toBeNull()
585
+ expect(h.blockedStore.read()).toBeNull()
586
+ })
587
+ }
588
+
589
+ it('an UNKNOWN error class fails CLOSED — held, not auto-denied', () => {
590
+ // The whole polarity of the fix. Anything we do not positively recognise as
591
+ // permanent is held. A held approval is visible and recoverable; a fabricated
592
+ // denial is neither.
593
+ expect(holdReasonFor(new Error('something nobody has seen before'))).toBe('transient')
594
+ expect(holdReasonFor({ weird: true })).toBe('transient')
595
+ expect(holdReasonFor(undefined)).toBe('transient')
596
+ // …and a rate-limit is the most transient failure there is — never permanent.
597
+ expect(holdReasonFor(Object.assign(new Error('x'), { error_code: 429 }))).toBe('transient')
598
+ // Only these two fall through.
599
+ expect(holdReasonFor(Object.assign(new Error('x'), { error_code: 400 }))).toBeNull()
600
+ expect(holdReasonFor(Object.assign(new Error('x'), { error_code: 403 }))).toBeNull()
601
+ })
602
+ })