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.
Files changed (125) hide show
  1. package/dist/agent-scheduler/index.js +29 -5
  2. package/dist/auth-broker/index.js +53 -13
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/notion-write-pretool.mjs +29 -5
  5. package/dist/cli/switchroom.js +2636 -1369
  6. package/dist/cli/ui/apple-touch-icon.png +0 -0
  7. package/dist/cli/ui/favicon-32.png +0 -0
  8. package/dist/cli/ui/favicon.ico +0 -0
  9. package/dist/cli/ui/index.html +163 -17
  10. package/dist/host-control/main.js +1248 -342
  11. package/dist/vault/approvals/kernel-server.js +54 -13
  12. package/dist/vault/broker/server.js +163 -114
  13. package/package.json +3 -4
  14. package/profiles/_base/start.sh.hbs +65 -0
  15. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  16. package/profiles/coding/CLAUDE.md.hbs +1 -1
  17. package/profiles/default/CLAUDE.md.hbs +2 -2
  18. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  19. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  20. package/telegram-plugin/bridge/bridge.ts +37 -0
  21. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  22. package/telegram-plugin/dist/bridge/bridge.js +73 -1
  23. package/telegram-plugin/dist/gateway/gateway.js +3603 -1007
  24. package/telegram-plugin/dist/server.js +74 -2
  25. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  26. package/telegram-plugin/gateway/approval-hold.ts +583 -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 +76 -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 +1482 -165
  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/hooks/dispatch-claim-scan.mjs +259 -0
  54. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  55. package/telegram-plugin/hooks/hooks.json +9 -0
  56. package/telegram-plugin/inline-keyboard-callbacks.ts +209 -2
  57. package/telegram-plugin/operator-events.ts +23 -0
  58. package/telegram-plugin/package.json +0 -1
  59. package/telegram-plugin/permission-rule.ts +1 -0
  60. package/telegram-plugin/permission-title.ts +1 -0
  61. package/telegram-plugin/retry-api-call.ts +212 -2
  62. package/telegram-plugin/send-gate-degraded.test.ts +443 -0
  63. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  64. package/telegram-plugin/send-gate-observability.ts +355 -0
  65. package/telegram-plugin/send-gate.test.ts +698 -0
  66. package/telegram-plugin/send-gate.ts +982 -0
  67. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  68. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  69. package/telegram-plugin/status-pin-driver.ts +52 -7
  70. package/telegram-plugin/status-pin.ts +81 -0
  71. package/telegram-plugin/subagent-watcher.ts +102 -2
  72. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  73. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  74. package/telegram-plugin/tests/approval-hold-outcome.test.ts +296 -0
  75. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  76. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  77. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  78. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  79. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  80. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  81. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  82. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  83. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  84. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  85. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  86. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  87. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  88. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  89. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +284 -0
  90. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  91. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  92. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  93. package/telegram-plugin/tests/model-command.test.ts +14 -0
  94. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  95. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  96. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  97. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  98. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  99. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  100. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  101. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  102. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  103. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  104. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  105. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  106. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  107. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  108. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  109. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  110. package/telegram-plugin/typing-emitter.ts +224 -0
  111. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  112. package/telegram-plugin/welcome-text.ts +42 -0
  113. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  114. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  115. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  116. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  117. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  118. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  119. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  120. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  121. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  122. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  123. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  124. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  125. 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
+ })