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.
- package/dist/agent-scheduler/index.js +37 -5
- package/dist/auth-broker/index.js +116 -78
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +38 -6
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +3529 -4543
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +605 -2847
- package/dist/vault/approvals/kernel-server.js +120 -13
- package/dist/vault/broker/server.js +259 -157
- 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 +122 -4
- package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
- package/telegram-plugin/dist/server.js +139 -5
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +602 -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 +88 -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 +1695 -230
- 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/history.ts +51 -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 +303 -2
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/outbound-field-redact.ts +69 -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/render/render.ts +32 -14
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +574 -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 +717 -0
- package/telegram-plugin/send-gate.ts +1056 -0
- package/telegram-plugin/session-tail.ts +82 -7
- 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 +173 -18
- 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 +327 -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/callback-query-handlers.test.ts +65 -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/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/history.test.ts +115 -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/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -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/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -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/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -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/subagent-watcher-fd-leak.test.ts +275 -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/tests/worktree-watch-cwds.test.ts +215 -1
- 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/telegram-plugin/worktree-watch-cwds.ts +194 -5
- 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
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- 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
|
+
})
|