switchroom 0.18.10 → 0.18.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +29 -5
- package/dist/auth-broker/index.js +53 -13
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/notion-write-pretool.mjs +29 -5
- package/dist/cli/switchroom.js +2636 -1369
- package/dist/cli/ui/apple-touch-icon.png +0 -0
- package/dist/cli/ui/favicon-32.png +0 -0
- package/dist/cli/ui/favicon.ico +0 -0
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +1248 -342
- package/dist/vault/approvals/kernel-server.js +54 -13
- package/dist/vault/broker/server.js +163 -114
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +73 -1
- package/telegram-plugin/dist/gateway/gateway.js +3603 -1007
- package/telegram-plugin/dist/server.js +74 -2
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/gateway/approval-hold.ts +583 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +76 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1482 -165
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +209 -2
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/send-gate-degraded.test.ts +443 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +698 -0
- package/telegram-plugin/send-gate.ts +982 -0
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +102 -2
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +296 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +284 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -0
- package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
2
|
import { GrammyError } from 'grammy'
|
|
3
|
-
import { decidePinAction } from '../status-pin.js'
|
|
3
|
+
import { decidePinAction, isPinRightsError, PinRightsCache } from '../status-pin.js'
|
|
4
4
|
import { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
|
|
5
5
|
import type { PinState } from '../status-pin.js'
|
|
6
6
|
|
|
@@ -200,3 +200,277 @@ describe('reconcilePin — pin-rights 400 must never crash (marko 2026-07-01)',
|
|
|
200
200
|
}
|
|
201
201
|
})
|
|
202
202
|
})
|
|
203
|
+
|
|
204
|
+
// ── #3024: rights-aware negative cache ─────────────────────────────────────
|
|
205
|
+
// marko logged 41 identical `pinChatMessage` "not enough rights" rejections in
|
|
206
|
+
// 48h — one per auto status-pin attempt in a group where the bot isn't a pin
|
|
207
|
+
// admin. The permanent-rights class is now cached per-chat so the SECOND and
|
|
208
|
+
// later attempts skip the API call entirely and stop spamming the log. Synthetic
|
|
209
|
+
// chat IDs only (the real ones in the issue are operator PII).
|
|
210
|
+
describe('isPinRightsError (pure classifier)', () => {
|
|
211
|
+
it('matches the permanent pin-rights 400 (grammy .description)', () => {
|
|
212
|
+
expect(
|
|
213
|
+
isPinRightsError(
|
|
214
|
+
grammyError(400, 'Bad Request: not enough rights to manage pinned messages in the chat'),
|
|
215
|
+
),
|
|
216
|
+
).toBe(true)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('matches a plain Error carrying the rights text', () => {
|
|
220
|
+
expect(isPinRightsError(new Error('not enough rights to manage pinned messages'))).toBe(true)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('does NOT match transient classes (429 flood-wait, network)', () => {
|
|
224
|
+
expect(isPinRightsError(grammyError(429, 'Too Many Requests: retry after 5'))).toBe(false)
|
|
225
|
+
expect(isPinRightsError(new Error('fetch failed'))).toBe(false)
|
|
226
|
+
expect(isPinRightsError(grammyError(400, 'Bad Request: message to edit not found'))).toBe(false)
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
describe('PinRightsCache (pure)', () => {
|
|
231
|
+
it('block() returns true only the first time a chat is added (log-once)', () => {
|
|
232
|
+
const cache = new PinRightsCache()
|
|
233
|
+
expect(cache.isBlocked('-1009000000001')).toBe(false)
|
|
234
|
+
expect(cache.block('-1009000000001')).toBe(true)
|
|
235
|
+
expect(cache.block('-1009000000001')).toBe(false)
|
|
236
|
+
expect(cache.isBlocked('-1009000000001')).toBe(true)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('clear() forgets a chat (rights re-granted)', () => {
|
|
240
|
+
const cache = new PinRightsCache()
|
|
241
|
+
cache.block('-1009000000001')
|
|
242
|
+
expect(cache.clear('-1009000000001')).toBe(true)
|
|
243
|
+
expect(cache.isBlocked('-1009000000001')).toBe(false)
|
|
244
|
+
expect(cache.clear('-1009000000001')).toBe(false)
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
/** A fake Bot API counting pin calls, throwing a chosen error on pin. */
|
|
249
|
+
function countingApi(pinError?: unknown) {
|
|
250
|
+
let pinCalls = 0
|
|
251
|
+
let unpinCalls = 0
|
|
252
|
+
const api: PinBotApi = {
|
|
253
|
+
pinChatMessage: async () => {
|
|
254
|
+
pinCalls += 1
|
|
255
|
+
if (pinError) throw pinError
|
|
256
|
+
},
|
|
257
|
+
unpinChatMessage: async () => {
|
|
258
|
+
unpinCalls += 1
|
|
259
|
+
},
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
api,
|
|
263
|
+
get pinCalls() {
|
|
264
|
+
return pinCalls
|
|
265
|
+
},
|
|
266
|
+
get unpinCalls() {
|
|
267
|
+
return unpinCalls
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
describe('reconcilePin — rights-aware negative cache (#3024)', () => {
|
|
273
|
+
const rightsErr = () =>
|
|
274
|
+
grammyError(400, 'Bad Request: not enough rights to manage pinned messages in the chat')
|
|
275
|
+
|
|
276
|
+
it('first rights failure: attempts once, logs once, records the chat', async () => {
|
|
277
|
+
const cache = new PinRightsCache()
|
|
278
|
+
const disabled: string[] = []
|
|
279
|
+
const pinFails: string[] = []
|
|
280
|
+
const t = countingApi(rightsErr())
|
|
281
|
+
|
|
282
|
+
const next = await reconcilePin({
|
|
283
|
+
api: t.api,
|
|
284
|
+
chatId: '-1009000000001',
|
|
285
|
+
prevState: null,
|
|
286
|
+
desired: { pinned: true, messageId: 4 },
|
|
287
|
+
rightsCache: cache,
|
|
288
|
+
onPinRightsDisabled: (chat) => disabled.push(chat),
|
|
289
|
+
onError: (phase) => pinFails.push(phase),
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
expect(t.pinCalls).toBe(1) // it did try
|
|
293
|
+
expect(next).toBeNull() // no claim taken
|
|
294
|
+
expect(disabled).toEqual(['-1009000000001']) // logged once
|
|
295
|
+
expect(pinFails).toEqual([]) // NOT routed through the per-attempt onError
|
|
296
|
+
expect(cache.isBlocked('-1009000000001')).toBe(true)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('second attempt in the same chat makes NO api call and logs nothing', async () => {
|
|
300
|
+
const cache = new PinRightsCache()
|
|
301
|
+
const disabled: string[] = []
|
|
302
|
+
const pinFails: string[] = []
|
|
303
|
+
const t = countingApi(rightsErr())
|
|
304
|
+
|
|
305
|
+
const common = {
|
|
306
|
+
chatId: '-1009000000001',
|
|
307
|
+
prevState: null,
|
|
308
|
+
desired: { pinned: true, messageId: 4 } as const,
|
|
309
|
+
rightsCache: cache,
|
|
310
|
+
onPinRightsDisabled: (chat: string) => disabled.push(chat),
|
|
311
|
+
onError: (phase: 'pin' | 'unpin') => pinFails.push(phase),
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
await reconcilePin({ api: t.api, ...common })
|
|
315
|
+
await reconcilePin({ api: t.api, ...common })
|
|
316
|
+
|
|
317
|
+
expect(t.pinCalls).toBe(1) // second attempt short-circuited before the API
|
|
318
|
+
expect(disabled).toEqual(['-1009000000001']) // still logged exactly once
|
|
319
|
+
expect(pinFails).toEqual([])
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('a DIFFERENT chat is unaffected by another chat being blocked', async () => {
|
|
323
|
+
const cache = new PinRightsCache()
|
|
324
|
+
cache.block('-1009000000001')
|
|
325
|
+
const t = countingApi() // pin succeeds here
|
|
326
|
+
|
|
327
|
+
const next = await reconcilePin({
|
|
328
|
+
api: t.api,
|
|
329
|
+
chatId: '-1009000000002',
|
|
330
|
+
prevState: null,
|
|
331
|
+
desired: { pinned: true, messageId: 7 },
|
|
332
|
+
rightsCache: cache,
|
|
333
|
+
onPinRightsDisabled: () => {
|
|
334
|
+
throw new Error('should not disable a healthy chat')
|
|
335
|
+
},
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
expect(t.pinCalls).toBe(1)
|
|
339
|
+
expect(next).toEqual({ messageId: 7 })
|
|
340
|
+
expect(cache.isBlocked('-1009000000002')).toBe(false)
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('a 429 (transient) does NOT enter the cache — retry behaviour preserved', async () => {
|
|
344
|
+
const cache = new PinRightsCache()
|
|
345
|
+
const disabled: string[] = []
|
|
346
|
+
const pinFails: string[] = []
|
|
347
|
+
const t = countingApi(grammyError(429, 'Too Many Requests: retry after 5'))
|
|
348
|
+
|
|
349
|
+
await reconcilePin({
|
|
350
|
+
api: t.api,
|
|
351
|
+
chatId: '-1009000000003',
|
|
352
|
+
prevState: null,
|
|
353
|
+
desired: { pinned: true, messageId: 9 },
|
|
354
|
+
rightsCache: cache,
|
|
355
|
+
onPinRightsDisabled: (chat) => disabled.push(chat),
|
|
356
|
+
onError: (phase) => pinFails.push(phase),
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
expect(cache.isBlocked('-1009000000003')).toBe(false) // NOT cached
|
|
360
|
+
expect(disabled).toEqual([]) // no rights-disable log
|
|
361
|
+
expect(pinFails).toEqual(['pin']) // routed through onError → normal retry path
|
|
362
|
+
|
|
363
|
+
// A follow-up attempt still hits the API (no negative cache in the way).
|
|
364
|
+
await reconcilePin({
|
|
365
|
+
api: t.api,
|
|
366
|
+
chatId: '-1009000000003',
|
|
367
|
+
prevState: null,
|
|
368
|
+
desired: { pinned: true, messageId: 9 },
|
|
369
|
+
rightsCache: cache,
|
|
370
|
+
onError: (phase) => pinFails.push(phase),
|
|
371
|
+
})
|
|
372
|
+
expect(t.pinCalls).toBe(2)
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('a later successful pin clears a previously-blocked chat', async () => {
|
|
376
|
+
const cache = new PinRightsCache()
|
|
377
|
+
cache.block('-1009000000004')
|
|
378
|
+
const t = countingApi() // succeeds
|
|
379
|
+
|
|
380
|
+
// While blocked, the pin is skipped (no claim, no API call).
|
|
381
|
+
const skipped = await reconcilePin({
|
|
382
|
+
api: t.api,
|
|
383
|
+
chatId: '-1009000000004',
|
|
384
|
+
prevState: null,
|
|
385
|
+
desired: { pinned: true, messageId: 3 },
|
|
386
|
+
rightsCache: cache,
|
|
387
|
+
})
|
|
388
|
+
expect(skipped).toBeNull()
|
|
389
|
+
expect(t.pinCalls).toBe(0)
|
|
390
|
+
|
|
391
|
+
// Simulate rights granted: unblock, then a pin succeeds and stays clear.
|
|
392
|
+
cache.clear('-1009000000004')
|
|
393
|
+
const next = await reconcilePin({
|
|
394
|
+
api: t.api,
|
|
395
|
+
chatId: '-1009000000004',
|
|
396
|
+
prevState: null,
|
|
397
|
+
desired: { pinned: true, messageId: 3 },
|
|
398
|
+
rightsCache: cache,
|
|
399
|
+
})
|
|
400
|
+
expect(next).toEqual({ messageId: 3 })
|
|
401
|
+
expect(cache.isBlocked('-1009000000004')).toBe(false)
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('UNPIN rights-failure (rights revoked mid-session) blocks the chat, logs once, second unpin makes no API call', async () => {
|
|
405
|
+
const cache = new PinRightsCache()
|
|
406
|
+
const disabled: string[] = []
|
|
407
|
+
const unpinFails: string[] = []
|
|
408
|
+
let unpinCalls = 0
|
|
409
|
+
const api: PinBotApi = {
|
|
410
|
+
pinChatMessage: async () => {},
|
|
411
|
+
unpinChatMessage: async () => {
|
|
412
|
+
unpinCalls += 1
|
|
413
|
+
throw rightsErr()
|
|
414
|
+
},
|
|
415
|
+
}
|
|
416
|
+
const common = {
|
|
417
|
+
api,
|
|
418
|
+
chatId: '-1009000000007',
|
|
419
|
+
desired: { pinned: false } as const,
|
|
420
|
+
rightsCache: cache,
|
|
421
|
+
onPinRightsDisabled: (chat: string) => disabled.push(chat),
|
|
422
|
+
onError: (phase: 'pin' | 'unpin') => unpinFails.push(phase),
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const first = await reconcilePin({ ...common, prevState: { messageId: 21 } })
|
|
426
|
+
expect(first).toBeNull() // claim dropped regardless (drop-on-unpin contract)
|
|
427
|
+
expect(unpinCalls).toBe(1) // it did try once
|
|
428
|
+
expect(disabled).toEqual(['-1009000000007']) // logged exactly once
|
|
429
|
+
expect(unpinFails).toEqual([]) // NOT routed through per-attempt onError
|
|
430
|
+
expect(cache.isBlocked('-1009000000007')).toBe(true)
|
|
431
|
+
|
|
432
|
+
const second = await reconcilePin({ ...common, prevState: { messageId: 22 } })
|
|
433
|
+
expect(second).toBeNull() // claim still dropped
|
|
434
|
+
expect(unpinCalls).toBe(1) // second attempt short-circuited before the API
|
|
435
|
+
expect(disabled).toEqual(['-1009000000007']) // no second log
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
it('a blocked chat skips the UNPIN api call too (drops the claim silently)', async () => {
|
|
439
|
+
const cache = new PinRightsCache()
|
|
440
|
+
cache.block('-1009000000005')
|
|
441
|
+
const t = countingApi()
|
|
442
|
+
|
|
443
|
+
const next = await reconcilePin({
|
|
444
|
+
api: t.api,
|
|
445
|
+
chatId: '-1009000000005',
|
|
446
|
+
prevState: { messageId: 12 },
|
|
447
|
+
desired: { pinned: false },
|
|
448
|
+
rightsCache: cache,
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
expect(next).toBeNull() // claim dropped
|
|
452
|
+
expect(t.unpinCalls).toBe(0) // but no wasted API call
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
it('fire-and-forget with the cache leaks NO unhandledRejection', async () => {
|
|
456
|
+
const rejections: unknown[] = []
|
|
457
|
+
const onUnhandled = (err: unknown) => rejections.push(err)
|
|
458
|
+
process.on('unhandledRejection', onUnhandled)
|
|
459
|
+
try {
|
|
460
|
+
const cache = new PinRightsCache()
|
|
461
|
+
const t = countingApi(rightsErr())
|
|
462
|
+
void reconcilePin({
|
|
463
|
+
api: t.api,
|
|
464
|
+
chatId: '-1009000000006',
|
|
465
|
+
prevState: null,
|
|
466
|
+
desired: { pinned: true, messageId: 4 },
|
|
467
|
+
rightsCache: cache,
|
|
468
|
+
onPinRightsDisabled: () => {},
|
|
469
|
+
})
|
|
470
|
+
await new Promise((r) => setTimeout(r, 20))
|
|
471
|
+
expect(rejections).toEqual([])
|
|
472
|
+
} finally {
|
|
473
|
+
process.off('unhandledRejection', onUnhandled)
|
|
474
|
+
}
|
|
475
|
+
})
|
|
476
|
+
})
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3092 — the "silent-stall terminal synthesis deferred" line must not be
|
|
3
|
+
* re-emitted on every rescan tick.
|
|
4
|
+
*
|
|
5
|
+
* `checkStalls()` runs on the ~1s rescan interval and re-evaluates the
|
|
6
|
+
* in-flight deferral every time. The DECISION is correct (a worker inside a
|
|
7
|
+
* long `Bash` is alive, not dead, and must not have its card finalised) — but
|
|
8
|
+
* before this gate the decision was also RE-LOGGED every tick. Observed on the
|
|
9
|
+
* live overlord gateway 2026-07-11: 1,547 near-identical lines in ~35 minutes
|
|
10
|
+
* for a single worker id, differing only in the idle-seconds counter, while
|
|
11
|
+
* the gateway was concurrently hitting real Telegram 429 flood bans (#3084)
|
|
12
|
+
* whose lines were buried in the spam.
|
|
13
|
+
*
|
|
14
|
+
* Contract asserted here:
|
|
15
|
+
* - first entry into the deferred state logs immediately (diagnosability),
|
|
16
|
+
* - subsequent ticks are rate-limited to 1 line / `deferralLogIntervalMs`,
|
|
17
|
+
* - the cap-crossing STATE CHANGE (deferral → terminal synthesis) always
|
|
18
|
+
* logs,
|
|
19
|
+
* - the resume STATE CHANGE (worker resurrects) always logs,
|
|
20
|
+
* - the emitted lines still carry worker id / in-flight count / idle / cap.
|
|
21
|
+
*
|
|
22
|
+
* Deterministic fake clock + injected setInterval — no real timers.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
26
|
+
import { startSubagentWatcher } from '../subagent-watcher.js'
|
|
27
|
+
import * as fs from 'fs'
|
|
28
|
+
|
|
29
|
+
function buildJSONL(...lines: object[]): string {
|
|
30
|
+
return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'
|
|
31
|
+
}
|
|
32
|
+
function subAgentUserMsg(promptText: string) {
|
|
33
|
+
return { type: 'user', message: { content: [{ type: 'text', text: promptText }] } }
|
|
34
|
+
}
|
|
35
|
+
/** An assistant tool_use with no matching tool_result → the tool call is IN FLIGHT. */
|
|
36
|
+
function bashToolUse(id: string) {
|
|
37
|
+
return { type: 'assistant', message: { content: [{ type: 'tool_use', id, name: 'Bash', input: { command: 'npm test' } }] } }
|
|
38
|
+
}
|
|
39
|
+
/** The matching tool_result — drains the in-flight set and resurrects the worker. */
|
|
40
|
+
function bashToolResult(id: string) {
|
|
41
|
+
return { type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: id, content: 'ok' }] } }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DEFERRED_RE = /silent-stall terminal synthesis deferred/
|
|
45
|
+
const CAP_RE = /in-flight deferral cap reached/
|
|
46
|
+
const RESOLVED_RE = /in-flight deferral resolved/
|
|
47
|
+
|
|
48
|
+
function makeHarness(opts: {
|
|
49
|
+
agentId?: string
|
|
50
|
+
deferralLogIntervalMs?: number
|
|
51
|
+
inflightTerminalCapMs?: number
|
|
52
|
+
} = {}) {
|
|
53
|
+
const {
|
|
54
|
+
agentId = 'a732f61196738da7d', // the real (synthetic-safe) worker id shape from the incident
|
|
55
|
+
deferralLogIntervalMs,
|
|
56
|
+
inflightTerminalCapMs = 2_700_000, // 45 min — the cap in the incident log
|
|
57
|
+
} = opts
|
|
58
|
+
|
|
59
|
+
let currentTime = 1000
|
|
60
|
+
const logs: string[] = []
|
|
61
|
+
|
|
62
|
+
const agentDir = '/home/user/.switchroom/agents/myagent'
|
|
63
|
+
const sessionId = 'mock-session'
|
|
64
|
+
const projectsRoot = `${agentDir}/.claude/projects`
|
|
65
|
+
const projectDir = `${projectsRoot}/mock-cwd`
|
|
66
|
+
const sessionDir = `${projectDir}/${sessionId}`
|
|
67
|
+
const subagentsDir = `${sessionDir}/subagents`
|
|
68
|
+
const jsonlPath = `${subagentsDir}/agent-${agentId}.jsonl`
|
|
69
|
+
const fileContents = new Map<string, Buffer>()
|
|
70
|
+
fileContents.set(
|
|
71
|
+
jsonlPath,
|
|
72
|
+
Buffer.from(buildJSONL(subAgentUserMsg('bg task'), bashToolUse('tool-B')), 'utf-8'),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
let lastOpenedPath: string | null = null
|
|
76
|
+
const mockFs = {
|
|
77
|
+
existsSync: ((p: fs.PathLike) => {
|
|
78
|
+
const ps = String(p)
|
|
79
|
+
if (ps === projectsRoot || ps === projectDir || ps === sessionDir || ps === subagentsDir) return true
|
|
80
|
+
return fileContents.has(ps)
|
|
81
|
+
}) as typeof fs.existsSync,
|
|
82
|
+
readdirSync: ((p: fs.PathLike) => {
|
|
83
|
+
const ps = String(p)
|
|
84
|
+
if (ps === projectsRoot) return ['mock-cwd']
|
|
85
|
+
if (ps === projectDir) return [sessionId]
|
|
86
|
+
if (ps === sessionDir) return ['subagents']
|
|
87
|
+
if (ps === subagentsDir) return [`agent-${agentId}.jsonl`]
|
|
88
|
+
return []
|
|
89
|
+
}) as unknown as typeof fs.readdirSync,
|
|
90
|
+
statSync: ((p: fs.PathLike) => ({ size: fileContents.get(String(p))?.length ?? 0 }) as fs.Stats) as typeof fs.statSync,
|
|
91
|
+
openSync: ((p: fs.PathLike) => { lastOpenedPath = String(p); return 42 }) as unknown as typeof fs.openSync,
|
|
92
|
+
closeSync: (() => { lastOpenedPath = null }) as typeof fs.closeSync,
|
|
93
|
+
readSync: ((
|
|
94
|
+
_fd: number, buf: NodeJS.ArrayBufferView, offset: number, length: number, position: number | null,
|
|
95
|
+
): number => {
|
|
96
|
+
const content = lastOpenedPath != null ? fileContents.get(lastOpenedPath) : undefined
|
|
97
|
+
if (!content) return 0
|
|
98
|
+
const pos = position ?? 0
|
|
99
|
+
const src = content.slice(pos, pos + length)
|
|
100
|
+
;(src as Buffer).copy(buf as Buffer, offset)
|
|
101
|
+
return src.length
|
|
102
|
+
}) as unknown as typeof fs.readSync,
|
|
103
|
+
watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const intervals: Array<{ fn: () => void; ms: number; ref: number; fireAt: number }> = []
|
|
107
|
+
let nextRef = 1
|
|
108
|
+
const watcher = startSubagentWatcher({
|
|
109
|
+
agentDir,
|
|
110
|
+
// Tight windows so the deferral is reachable in a handful of ticks; the
|
|
111
|
+
// rate-limit under test is orthogonal to how we got into the state.
|
|
112
|
+
stallThresholdMs: 1000,
|
|
113
|
+
silentSynthesisStallThresholdMs: 5000,
|
|
114
|
+
silentStallTerminalMs: 5000,
|
|
115
|
+
inflightTerminalCapMs,
|
|
116
|
+
deferralLogIntervalMs,
|
|
117
|
+
rescanMs: 1000, // the production 1Hz tick — the cadence that produced the spam
|
|
118
|
+
log: (line) => logs.push(line),
|
|
119
|
+
now: () => currentTime,
|
|
120
|
+
setInterval: (fn, ms) => {
|
|
121
|
+
const ref = nextRef++
|
|
122
|
+
intervals.push({ fn, ms, ref, fireAt: currentTime + ms })
|
|
123
|
+
return { ref }
|
|
124
|
+
},
|
|
125
|
+
clearInterval: (h) => {
|
|
126
|
+
const { ref } = h as { ref: number }
|
|
127
|
+
const idx = intervals.findIndex((i) => i.ref === ref)
|
|
128
|
+
if (idx !== -1) intervals.splice(idx, 1)
|
|
129
|
+
},
|
|
130
|
+
fs: mockFs,
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const advance = (ms: number): void => {
|
|
134
|
+
currentTime += ms
|
|
135
|
+
for (;;) {
|
|
136
|
+
intervals.sort((a, b) => a.fireAt - b.fireAt)
|
|
137
|
+
const next = intervals[0]
|
|
138
|
+
if (!next || next.fireAt > currentTime) break
|
|
139
|
+
next.fireAt += next.ms
|
|
140
|
+
next.fn()
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Drive N discrete 1s ticks with the clock advancing between each — the
|
|
146
|
+
* faithful simulation of the production loop. (A single advance(N*1000)
|
|
147
|
+
* would fire N callbacks all observing the SAME `now`, which would mask a
|
|
148
|
+
* time-based gate.)
|
|
149
|
+
*/
|
|
150
|
+
const tick = (n: number): void => { for (let i = 0; i < n; i++) advance(1000) }
|
|
151
|
+
|
|
152
|
+
const unmarkHistorical = (): void => {
|
|
153
|
+
const e = watcher.getRegistry().get(agentId)
|
|
154
|
+
if (e) e.historical = false
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Land the matching tool_result — the worker resurrects. */
|
|
158
|
+
const resumeWorker = (): void => {
|
|
159
|
+
fileContents.set(
|
|
160
|
+
jsonlPath,
|
|
161
|
+
Buffer.from(
|
|
162
|
+
buildJSONL(subAgentUserMsg('bg task'), bashToolUse('tool-B'), bashToolResult('tool-B')),
|
|
163
|
+
'utf-8',
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The resumed worker starts a NEW long tool call (no tool_result) — it goes
|
|
170
|
+
* quiet again with something in flight, so the deferral path is re-entered.
|
|
171
|
+
*/
|
|
172
|
+
const startNewToolCall = (): void => {
|
|
173
|
+
fileContents.set(
|
|
174
|
+
jsonlPath,
|
|
175
|
+
Buffer.from(
|
|
176
|
+
buildJSONL(
|
|
177
|
+
subAgentUserMsg('bg task'),
|
|
178
|
+
bashToolUse('tool-B'),
|
|
179
|
+
bashToolResult('tool-B'),
|
|
180
|
+
bashToolUse('tool-C'),
|
|
181
|
+
),
|
|
182
|
+
'utf-8',
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { logs, advance, tick, unmarkHistorical, resumeWorker, startNewToolCall, agentId }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
describe('subagent-watcher: deferral log rate-limit (#3092)', () => {
|
|
191
|
+
it('logs the deferral ONCE on entry, then at most once per interval — not once per 1Hz tick', () => {
|
|
192
|
+
const h = makeHarness({ deferralLogIntervalMs: 60_000 })
|
|
193
|
+
h.advance(500)
|
|
194
|
+
h.unmarkHistorical()
|
|
195
|
+
|
|
196
|
+
// Get into the deferred state: cross the long-runner stall window, then
|
|
197
|
+
// the post-stall terminal window, with `tool-B` still in flight.
|
|
198
|
+
h.tick(12)
|
|
199
|
+
const afterEntry = h.logs.filter((l) => DEFERRED_RE.test(l))
|
|
200
|
+
expect(afterEntry.length).toBe(1) // first entry IS logged — diagnosability preserved
|
|
201
|
+
|
|
202
|
+
// The line must still carry the full diagnostic payload.
|
|
203
|
+
expect(afterEntry[0]).toContain(h.agentId)
|
|
204
|
+
expect(afterEntry[0]).toContain('1 tool call(s) still in flight')
|
|
205
|
+
expect(afterEntry[0]).toMatch(/\d+s idle < 2700s cap/)
|
|
206
|
+
|
|
207
|
+
// Now 600 more 1Hz ticks = 10 minutes of a persistently-stalled worker.
|
|
208
|
+
// Pre-fix this emitted ~600 lines. With a 60s interval it may emit at
|
|
209
|
+
// most ~10 more.
|
|
210
|
+
h.tick(600)
|
|
211
|
+
const deferred = h.logs.filter((l) => DEFERRED_RE.test(l))
|
|
212
|
+
expect(deferred.length).toBeLessThanOrEqual(11) // 1 entry + ≤10 periodic
|
|
213
|
+
expect(deferred.length).toBeGreaterThanOrEqual(2) // still periodically visible
|
|
214
|
+
// The regression this guards: NOT one line per tick.
|
|
215
|
+
expect(deferred.length).toBeLessThan(50)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('reproduces the incident scale: ~35min at 1Hz emits a handful of lines, not ~1500', () => {
|
|
219
|
+
// The real evidence: 1,547 lines in ~35 minutes for one worker id.
|
|
220
|
+
const h = makeHarness({ deferralLogIntervalMs: 60_000 })
|
|
221
|
+
h.advance(500)
|
|
222
|
+
h.unmarkHistorical()
|
|
223
|
+
h.tick(2100) // 35 minutes of 1Hz ticks
|
|
224
|
+
|
|
225
|
+
const deferred = h.logs.filter((l) => DEFERRED_RE.test(l))
|
|
226
|
+
// 35 min / 60s ≈ 35 periodic lines + the entry line. Wildly below 1,547.
|
|
227
|
+
expect(deferred.length).toBeLessThanOrEqual(40)
|
|
228
|
+
// Every emitted line still names the worker and its in-flight count.
|
|
229
|
+
for (const line of deferred) {
|
|
230
|
+
expect(line).toContain(h.agentId)
|
|
231
|
+
expect(line).toContain('tool call(s) still in flight')
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('STATE CHANGE — a resurrecting worker always logs, even mid-suppression', () => {
|
|
236
|
+
const h = makeHarness({ deferralLogIntervalMs: 60_000 })
|
|
237
|
+
h.advance(500)
|
|
238
|
+
h.unmarkHistorical()
|
|
239
|
+
h.tick(12) // enter the deferred state (1 line)
|
|
240
|
+
expect(h.logs.filter((l) => DEFERRED_RE.test(l)).length).toBe(1)
|
|
241
|
+
|
|
242
|
+
h.tick(20) // 20 suppressed ticks — well inside the 60s window
|
|
243
|
+
expect(h.logs.filter((l) => DEFERRED_RE.test(l)).length).toBe(1)
|
|
244
|
+
|
|
245
|
+
// The worker resurrects (the incident worker did exactly this).
|
|
246
|
+
h.resumeWorker()
|
|
247
|
+
h.tick(2)
|
|
248
|
+
|
|
249
|
+
const resolved = h.logs.filter((l) => RESOLVED_RE.test(l))
|
|
250
|
+
expect(resolved.length).toBe(1) // the transition is NOT suppressed
|
|
251
|
+
expect(resolved[0]).toContain(h.agentId)
|
|
252
|
+
expect(resolved[0]).toContain('deferral was correct')
|
|
253
|
+
// The suppressed volume is accounted for, not silently dropped.
|
|
254
|
+
expect(resolved[0]).toMatch(/\d+ deferral tick\(s\) suppressed/)
|
|
255
|
+
expect(h.logs.some((l) => /stall cleared/.test(l))).toBe(true)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('STATE CHANGE — crossing the cap always logs the terminal synthesis', () => {
|
|
259
|
+
// Compressed cap so the crossing is reachable in a bounded tick count.
|
|
260
|
+
const h = makeHarness({ deferralLogIntervalMs: 60_000, inflightTerminalCapMs: 120_000 })
|
|
261
|
+
h.advance(500)
|
|
262
|
+
h.unmarkHistorical()
|
|
263
|
+
h.tick(12)
|
|
264
|
+
expect(h.logs.filter((l) => DEFERRED_RE.test(l)).length).toBe(1)
|
|
265
|
+
|
|
266
|
+
h.tick(130) // cross the 120s cap
|
|
267
|
+
const cap = h.logs.filter((l) => CAP_RE.test(l))
|
|
268
|
+
expect(cap.length).toBe(1) // the cap-crossing is NOT suppressed
|
|
269
|
+
expect(cap[0]).toContain(h.agentId)
|
|
270
|
+
expect(cap[0]).toContain('proceeding with terminal synthesis')
|
|
271
|
+
expect(cap[0]).toMatch(/\d+ deferral tick\(s\) suppressed/)
|
|
272
|
+
|
|
273
|
+
// And the deferral lines over that whole window stayed bounded.
|
|
274
|
+
expect(h.logs.filter((l) => DEFERRED_RE.test(l)).length).toBeLessThanOrEqual(4)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('a re-stall after a resume logs its first deferral again (gate re-arms)', () => {
|
|
278
|
+
const h = makeHarness({ deferralLogIntervalMs: 60_000 })
|
|
279
|
+
h.advance(500)
|
|
280
|
+
h.unmarkHistorical()
|
|
281
|
+
h.tick(12)
|
|
282
|
+
expect(h.logs.filter((l) => DEFERRED_RE.test(l)).length).toBe(1)
|
|
283
|
+
|
|
284
|
+
h.resumeWorker()
|
|
285
|
+
h.tick(2)
|
|
286
|
+
expect(h.logs.filter((l) => RESOLVED_RE.test(l)).length).toBe(1)
|
|
287
|
+
|
|
288
|
+
// The resumed worker goes quiet again inside a NEW tool call. Its first
|
|
289
|
+
// deferral must log immediately rather than being swallowed by the
|
|
290
|
+
// still-open 60s window from the previous stall episode.
|
|
291
|
+
h.startNewToolCall()
|
|
292
|
+
h.tick(14)
|
|
293
|
+
expect(h.logs.filter((l) => DEFERRED_RE.test(l)).length).toBe(2)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('env override SWITCHROOM_SUBAGENT_DEFERRAL_LOG_INTERVAL_MS is honored', () => {
|
|
297
|
+
const saved = process.env.SWITCHROOM_SUBAGENT_DEFERRAL_LOG_INTERVAL_MS
|
|
298
|
+
process.env.SWITCHROOM_SUBAGENT_DEFERRAL_LOG_INTERVAL_MS = '10000'
|
|
299
|
+
try {
|
|
300
|
+
const h = makeHarness() // no config arg → env wins
|
|
301
|
+
h.advance(500)
|
|
302
|
+
h.unmarkHistorical()
|
|
303
|
+
h.tick(12)
|
|
304
|
+
expect(h.logs.filter((l) => DEFERRED_RE.test(l)).length).toBe(1)
|
|
305
|
+
|
|
306
|
+
// 60 ticks at a 10s interval → ~6 more lines, not 60.
|
|
307
|
+
h.tick(60)
|
|
308
|
+
const deferred = h.logs.filter((l) => DEFERRED_RE.test(l)).length
|
|
309
|
+
expect(deferred).toBeGreaterThanOrEqual(5)
|
|
310
|
+
expect(deferred).toBeLessThanOrEqual(9)
|
|
311
|
+
} finally {
|
|
312
|
+
if (saved === undefined) delete process.env.SWITCHROOM_SUBAGENT_DEFERRAL_LOG_INTERVAL_MS
|
|
313
|
+
else process.env.SWITCHROOM_SUBAGENT_DEFERRAL_LOG_INTERVAL_MS = saved
|
|
314
|
+
}
|
|
315
|
+
})
|
|
316
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2094 finding 1 — turn_end gate-wedge backstop.
|
|
3
|
+
*
|
|
4
|
+
* Outcome contract: if a pre-purge op in the turn_end handler body THROWS
|
|
5
|
+
* before the canonical purge (endCurrentTurnAtomic → purgeReactionTracking)
|
|
6
|
+
* runs, the guarded finally must still clear this turn's gate state
|
|
7
|
+
* (activeTurnStartedAt + the mirrored claudeBusyKeys) so the #1556 inbound
|
|
8
|
+
* gate re-opens for the next inbound. On the happy path — where the body
|
|
9
|
+
* clears the key itself — the backstop must be a no-op (no double purge), and
|
|
10
|
+
* the original error must always propagate.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect } from 'vitest'
|
|
13
|
+
import { withTurnEndGateBackstop } from '../gateway/turn-end-gate-backstop.js'
|
|
14
|
+
|
|
15
|
+
interface Turn {
|
|
16
|
+
sessionChatId: string
|
|
17
|
+
sessionThreadId?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function statusKey(chatId: string, threadId?: number): string {
|
|
21
|
+
return `${chatId}:${threadId ?? '_'}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Mirror of the production gate state + a purge that clears it, as
|
|
25
|
+
* purgeReactionTracking does (activeTurnStartedAt.delete + claudeBusyKeys
|
|
26
|
+
* drain for the key). */
|
|
27
|
+
function freshGate(turn: Turn) {
|
|
28
|
+
const key = statusKey(turn.sessionChatId, turn.sessionThreadId)
|
|
29
|
+
const activeTurnStartedAt = new Map<string, number>([[key, Date.now()]])
|
|
30
|
+
const claudeBusyKeys = new Set<string>([key])
|
|
31
|
+
const purgeCalls: Array<{ key: string; endingTurn: Turn | undefined }> = []
|
|
32
|
+
const purge = (k: string, endingTurn: Turn | undefined) => {
|
|
33
|
+
purgeCalls.push({ key: k, endingTurn })
|
|
34
|
+
activeTurnStartedAt.delete(k)
|
|
35
|
+
claudeBusyKeys.delete(k)
|
|
36
|
+
}
|
|
37
|
+
return { key, activeTurnStartedAt, claudeBusyKeys, purge, purgeCalls }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function deps(gate: ReturnType<typeof freshGate>) {
|
|
41
|
+
return {
|
|
42
|
+
hasActiveTurn: (k: string) => gate.activeTurnStartedAt.has(k),
|
|
43
|
+
purge: gate.purge,
|
|
44
|
+
log: () => {},
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('withTurnEndGateBackstop (#2094 finding 1)', () => {
|
|
49
|
+
it('a throw in a pre-purge op still clears the gate for the next inbound', () => {
|
|
50
|
+
const turn: Turn = { sessionChatId: '12345' }
|
|
51
|
+
const gate = freshGate(turn)
|
|
52
|
+
|
|
53
|
+
// The body throws BEFORE it reaches endCurrentTurnAtomic → purge (models
|
|
54
|
+
// a throw in redactOutboundText / progressDriver?.takeOverCard).
|
|
55
|
+
expect(() =>
|
|
56
|
+
withTurnEndGateBackstop(gate.key, turn, () => {
|
|
57
|
+
throw new Error('redactOutboundText blew up')
|
|
58
|
+
}, deps(gate)),
|
|
59
|
+
).toThrow('redactOutboundText blew up')
|
|
60
|
+
|
|
61
|
+
// Outcome: gate is OPEN again — both maps cleared, so the #1556 inbound
|
|
62
|
+
// gate no longer wedges the next inbound.
|
|
63
|
+
expect(gate.activeTurnStartedAt.has(gate.key)).toBe(false)
|
|
64
|
+
expect(gate.claudeBusyKeys.has(gate.key)).toBe(false)
|
|
65
|
+
// The backstop fired exactly once, forwarding the ending turn.
|
|
66
|
+
expect(gate.purgeCalls).toEqual([{ key: gate.key, endingTurn: turn }])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('is a no-op on the happy path (body already purged) — no double purge', () => {
|
|
70
|
+
const turn: Turn = { sessionChatId: '12345', sessionThreadId: 7 }
|
|
71
|
+
const gate = freshGate(turn)
|
|
72
|
+
|
|
73
|
+
withTurnEndGateBackstop(gate.key, turn, () => {
|
|
74
|
+
// Model the canonical clean branch: endCurrentTurnAtomic → purge ran.
|
|
75
|
+
gate.purge(gate.key, turn)
|
|
76
|
+
}, deps(gate))
|
|
77
|
+
|
|
78
|
+
// Purge happened exactly once (the canonical one); the finally saw the
|
|
79
|
+
// key already gone and did NOT re-fire the inconsistent shadow trace.
|
|
80
|
+
expect(gate.purgeCalls).toHaveLength(1)
|
|
81
|
+
expect(gate.activeTurnStartedAt.has(gate.key)).toBe(false)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('does nothing when there was no live turn (null key)', () => {
|
|
85
|
+
const gate = freshGate({ sessionChatId: '12345' })
|
|
86
|
+
// A different, unrelated gate state; null key means no turn to end.
|
|
87
|
+
let ran = false
|
|
88
|
+
withTurnEndGateBackstop(null, null, () => { ran = true }, deps(gate))
|
|
89
|
+
expect(ran).toBe(true)
|
|
90
|
+
expect(gate.purgeCalls).toHaveLength(0)
|
|
91
|
+
})
|
|
92
|
+
})
|