switchroom 0.18.11 → 0.18.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/dist/agent-scheduler/index.js +37 -5
  2. package/dist/auth-broker/index.js +116 -78
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  5. package/dist/cli/notion-write-pretool.mjs +38 -6
  6. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  7. package/dist/cli/switchroom.js +3529 -4543
  8. package/dist/cli/ui/index.html +163 -17
  9. package/dist/host-control/main.js +605 -2847
  10. package/dist/vault/approvals/kernel-server.js +120 -13
  11. package/dist/vault/broker/server.js +259 -157
  12. package/package.json +3 -4
  13. package/profiles/_base/start.sh.hbs +65 -0
  14. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  15. package/profiles/coding/CLAUDE.md.hbs +1 -1
  16. package/profiles/default/CLAUDE.md.hbs +2 -2
  17. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  18. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  19. package/telegram-plugin/bridge/bridge.ts +37 -0
  20. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +122 -4
  22. package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
  23. package/telegram-plugin/dist/server.js +139 -5
  24. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  25. package/telegram-plugin/format.ts +19 -0
  26. package/telegram-plugin/gateway/approval-hold.ts +602 -0
  27. package/telegram-plugin/gateway/auth-command.ts +92 -2
  28. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  29. package/telegram-plugin/gateway/boot-card.ts +12 -5
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
  31. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  32. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  33. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  34. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  35. package/telegram-plugin/gateway/gateway.ts +1695 -230
  36. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  37. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  38. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  39. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  40. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  41. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  42. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  43. package/telegram-plugin/gateway/model-command.ts +9 -3
  44. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  45. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  46. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  47. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  48. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  49. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  50. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  51. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  52. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  53. package/telegram-plugin/history.ts +51 -0
  54. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  55. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  56. package/telegram-plugin/hooks/hooks.json +9 -0
  57. package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
  58. package/telegram-plugin/model-unavailable.ts +41 -11
  59. package/telegram-plugin/operator-events.ts +23 -0
  60. package/telegram-plugin/outbound-field-redact.ts +69 -0
  61. package/telegram-plugin/package.json +0 -1
  62. package/telegram-plugin/permission-rule.ts +1 -0
  63. package/telegram-plugin/permission-title.ts +1 -0
  64. package/telegram-plugin/render/render.ts +32 -14
  65. package/telegram-plugin/retry-api-call.ts +212 -2
  66. package/telegram-plugin/scoped-approval.ts +11 -2
  67. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  68. package/telegram-plugin/secret-detect/index.ts +12 -56
  69. package/telegram-plugin/send-gate-degraded.test.ts +574 -0
  70. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  71. package/telegram-plugin/send-gate-observability.ts +355 -0
  72. package/telegram-plugin/send-gate.test.ts +717 -0
  73. package/telegram-plugin/send-gate.ts +1056 -0
  74. package/telegram-plugin/session-tail.ts +82 -7
  75. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  76. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  77. package/telegram-plugin/status-pin-driver.ts +52 -7
  78. package/telegram-plugin/status-pin.ts +81 -0
  79. package/telegram-plugin/subagent-watcher.ts +173 -18
  80. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  81. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  82. package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
  83. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  84. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  85. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  86. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  87. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  88. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  89. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  90. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  91. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  92. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  93. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  94. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  95. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  96. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  97. package/telegram-plugin/tests/history.test.ts +115 -0
  98. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  99. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  100. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  101. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
  102. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  103. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  104. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  105. package/telegram-plugin/tests/model-command.test.ts +14 -0
  106. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  107. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  108. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  109. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  111. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  112. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  113. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  114. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  115. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  116. package/telegram-plugin/tests/render/render.test.ts +88 -0
  117. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  118. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  119. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  120. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  121. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  122. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  123. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  124. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  125. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  126. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  127. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  128. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  129. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  130. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  131. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  132. package/telegram-plugin/typing-emitter.ts +224 -0
  133. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  134. package/telegram-plugin/welcome-text.ts +42 -0
  135. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  136. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  137. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  138. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  139. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  140. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  141. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  142. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  143. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  144. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  145. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  146. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  147. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  148. package/telegram-plugin/channel-envelope-safety.ts +0 -56
  149. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  150. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -0,0 +1,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,275 @@
1
+ /**
2
+ * FD-leak regression tests for the subagent-watcher (review findings H1 + H2).
3
+ *
4
+ * H1 — directory FSWatchers were only ever closed in the global stop(). When a
5
+ * Claude session's `subagents/` dir was reaped, the rescan loop skipped
6
+ * the vanished path without closing its watcher, so every session leaked
7
+ * one inotify FD for the gateway's lifetime.
8
+ *
9
+ * H2 — every boot-scanned file opened a per-file FSWatcher unconditionally,
10
+ * including stale historical `running` entries that `checkStalls` skips
11
+ * and that therefore never reach terminal cleanup — leaking their FD until
12
+ * the file happened to vanish.
13
+ *
14
+ * Each test FAILS on pre-fix code (an unclosed / never-opened-guarded watcher)
15
+ * and PASSES with the runtime-close / open-gating fix.
16
+ */
17
+
18
+ import { describe, it, expect, vi } from 'vitest'
19
+ import type * as fs from 'fs'
20
+ import { startSubagentWatcher } from '../subagent-watcher.js'
21
+
22
+ interface FakeWatcher {
23
+ path: string
24
+ close: ReturnType<typeof vi.fn>
25
+ closed: boolean
26
+ }
27
+
28
+ /**
29
+ * Minimal watcher harness with a MUTABLE fake filesystem (so a test can make a
30
+ * directory or file vanish mid-run) and per-watcher path tracking (so we can
31
+ * assert exactly which watchers were opened / closed).
32
+ */
33
+ function makeHarness(opts: {
34
+ agentDir?: string
35
+ dirs: Record<string, string[]>
36
+ fileSizes?: Record<string, number>
37
+ rescanMs?: number
38
+ }) {
39
+ const agentDir = opts.agentDir ?? '/home/user/.switchroom/agents/myagent'
40
+ const rescanMs = opts.rescanMs ?? 500
41
+ const dirs = new Map<string, string[]>(Object.entries(opts.dirs))
42
+ const fileSizes = new Map<string, number>(Object.entries(opts.fileSizes ?? {}))
43
+ const logs: string[] = []
44
+ const watchers: FakeWatcher[] = []
45
+ let currentTime = 1_000_000
46
+
47
+ const existsSync = ((p: fs.PathLike) => {
48
+ const ps = String(p)
49
+ return dirs.has(ps) || fileSizes.has(ps)
50
+ }) as typeof fs.existsSync
51
+
52
+ const mockFs = {
53
+ existsSync,
54
+ readdirSync: ((p: fs.PathLike) => dirs.get(String(p)) ?? []) as unknown as typeof fs.readdirSync,
55
+ // No mtimeMs → boot-promotion freshness gate treats a running file as
56
+ // stale (dead prior-session worker), so it stays historical + unpromoted.
57
+ statSync: ((p: fs.PathLike) => ({ size: fileSizes.get(String(p)) ?? 0 }) as fs.Stats) as typeof fs.statSync,
58
+ openSync: (() => 42) as unknown as typeof fs.openSync,
59
+ closeSync: (() => undefined) as typeof fs.closeSync,
60
+ readSync: (() => 0) as unknown as typeof fs.readSync,
61
+ watch: ((p: fs.PathLike) => {
62
+ const w: FakeWatcher = {
63
+ path: String(p),
64
+ closed: false,
65
+ close: vi.fn(() => { w.closed = true }),
66
+ }
67
+ watchers.push(w)
68
+ return w as unknown as fs.FSWatcher
69
+ }) as unknown as typeof fs.watch,
70
+ }
71
+
72
+ const intervals: Array<{ fn: () => void; ms: number; ref: number; fireAt: number }> = []
73
+ const timeouts: Array<{ fn: () => void; ref: number; fireAt: number }> = []
74
+ let nextRef = 1
75
+
76
+ const watcher = startSubagentWatcher({
77
+ agentDir,
78
+ onFinish: () => {},
79
+ stallThresholdMs: 60_000,
80
+ silentSynthesisStallThresholdMs: 60_000,
81
+ rescanMs,
82
+ now: () => currentTime,
83
+ setInterval: (fn, ms) => {
84
+ const ref = nextRef++
85
+ intervals.push({ fn, ms, ref, fireAt: currentTime + ms })
86
+ return { ref }
87
+ },
88
+ clearInterval: (handle) => {
89
+ const { ref } = handle as { ref: number }
90
+ const idx = intervals.findIndex((i) => i.ref === ref)
91
+ if (idx !== -1) intervals.splice(idx, 1)
92
+ },
93
+ setTimeout: (fn, ms) => {
94
+ const ref = nextRef++
95
+ timeouts.push({ fn, ref, fireAt: currentTime + ms })
96
+ return { ref }
97
+ },
98
+ clearTimeout: (handle) => {
99
+ const { ref } = handle as { ref: number }
100
+ const idx = timeouts.findIndex((t) => t.ref === ref)
101
+ if (idx !== -1) timeouts.splice(idx, 1)
102
+ },
103
+ fs: mockFs,
104
+ log: (msg: string) => { logs.push(msg) },
105
+ })
106
+
107
+ const poll = (): void => {
108
+ // intervals[0] is the poll loop (registered first — see startSubagentWatcher).
109
+ intervals[0]?.fn()
110
+ }
111
+
112
+ return {
113
+ watcher,
114
+ watchers,
115
+ logs,
116
+ dirs,
117
+ fileSizes,
118
+ poll,
119
+ fileWatchers: () => watchers.filter((w) => w.path.endsWith('.jsonl')),
120
+ dirWatchersFor: (p: string) => watchers.filter((w) => w.path === p),
121
+ }
122
+ }
123
+
124
+ const PROJECTS = '/home/user/.switchroom/agents/myagent/.claude/projects'
125
+
126
+ describe('subagent-watcher FD-leak (H1): dir watchers close when their session dir vanishes', () => {
127
+ it('closes and forgets the subagents-dir FSWatcher after the session dir is reaped', () => {
128
+ const projectDir = `${PROJECTS}/myproject`
129
+ const sessionDir = `${projectDir}/session-A`
130
+ const subagentsDir = `${sessionDir}/subagents`
131
+
132
+ const h = makeHarness({
133
+ dirs: {
134
+ [PROJECTS]: ['myproject'],
135
+ [projectDir]: ['session-A'],
136
+ [sessionDir]: ['subagents'],
137
+ [subagentsDir]: [], // empty subagents dir — still gets a dir watcher
138
+ },
139
+ })
140
+
141
+ // Boot scan already ran in the constructor; poll once to be certain the
142
+ // dir watcher for the subagents dir has been opened.
143
+ h.poll()
144
+ const dw = h.dirWatchersFor(subagentsDir)
145
+ expect(dw).toHaveLength(1)
146
+ expect(dw[0].closed).toBe(false)
147
+
148
+ // Claude Code reaps the whole session directory (session rotation).
149
+ h.dirs.delete(sessionDir)
150
+ h.dirs.delete(subagentsDir)
151
+ h.dirs.set(projectDir, []) // session-A gone from the project listing
152
+
153
+ // Next rescan tick must release the now-dangling dir watcher.
154
+ h.poll()
155
+
156
+ expect(dw[0].close).toHaveBeenCalledTimes(1)
157
+ expect(dw[0].closed).toBe(true)
158
+
159
+ h.watcher.stop()
160
+ })
161
+ })
162
+
163
+ describe('subagent-watcher FD-leak (H2): no per-file watcher for stale historical running entries', () => {
164
+ it('does not open an FSWatcher for a boot-discovered stale running JSONL', () => {
165
+ const projectDir = `${PROJECTS}/myproject`
166
+ const sessionDir = `${projectDir}/session-A`
167
+ const subagentsDir = `${sessionDir}/subagents`
168
+ const staleFile = `${subagentsDir}/agent-deadbeef.jsonl`
169
+
170
+ const h = makeHarness({
171
+ dirs: {
172
+ [PROJECTS]: ['myproject'],
173
+ [projectDir]: ['session-A'],
174
+ [sessionDir]: ['subagents'],
175
+ [subagentsDir]: ['agent-deadbeef.jsonl'],
176
+ },
177
+ // size 0 + no mtimeMs → registers as a stale historical `running` entry
178
+ // that will never be promoted and never reach terminal cleanup.
179
+ fileSizes: { [staleFile]: 0 },
180
+ })
181
+
182
+ h.poll()
183
+
184
+ // A dir watcher for the subagents dir is fine. What must NOT happen is a
185
+ // per-file (.jsonl) watcher for a stale historical running entry that
186
+ // would leak forever.
187
+ const fileWatchersForStale = h.fileWatchers().filter((w) => w.path === staleFile)
188
+ expect(fileWatchersForStale).toHaveLength(0)
189
+
190
+ h.watcher.stop()
191
+ })
192
+
193
+ // Positive direction of the H2 gate: the open-guard is `!entry.historical ||
194
+ // entry.bootPromotionPending != null`, so the negative test above (no watcher
195
+ // for a stale boot entry) must be balanced by proof the guard does NOT
196
+ // over-prune a genuinely LIVE worker. A file that first appears AFTER the boot
197
+ // scan is non-historical (`bootScanInProgress` is already false), so it is a
198
+ // live worker the user is awaiting and MUST get its own per-file FSWatcher —
199
+ // otherwise its tool-call / turn_end transitions would be invisible until the
200
+ // 1s defensive poll happened to catch them. Reviewer verified this by code-
201
+ // reading only; this test locks it in.
202
+ it('opens a per-file FSWatcher for a live (post-boot, non-historical) worker JSONL', () => {
203
+ const projectDir = `${PROJECTS}/myproject`
204
+ const sessionDir = `${projectDir}/session-A`
205
+ const subagentsDir = `${sessionDir}/subagents`
206
+ const liveFile = `${subagentsDir}/agent-live01.jsonl`
207
+
208
+ const h = makeHarness({
209
+ dirs: {
210
+ [PROJECTS]: ['myproject'],
211
+ [projectDir]: ['session-A'],
212
+ [sessionDir]: ['subagents'],
213
+ [subagentsDir]: [], // empty at boot → nothing marked historical
214
+ },
215
+ })
216
+
217
+ // Boot scan already ran (constructor) over the empty dir; poll once so the
218
+ // dir watcher for the subagents dir is definitely established.
219
+ h.poll()
220
+ expect(h.fileWatchers()).toHaveLength(0) // nothing to watch yet
221
+
222
+ // A brand-new worker dispatches AFTER boot: its JSONL appears now. Because
223
+ // bootScanInProgress is already false, scanSubagentsDir does NOT mark it
224
+ // historical → it registers as a live running entry.
225
+ h.dirs.set(subagentsDir, ['agent-live01.jsonl'])
226
+ h.fileSizes.set(liveFile, 24)
227
+
228
+ h.poll()
229
+
230
+ const fileWatchersForLive = h.fileWatchers().filter((w) => w.path === liveFile)
231
+ expect(fileWatchersForLive).toHaveLength(1)
232
+ expect(fileWatchersForLive[0].closed).toBe(false)
233
+
234
+ h.watcher.stop()
235
+ // stop() must close the live watcher it opened (no leak on shutdown).
236
+ expect(fileWatchersForLive[0].closed).toBe(true)
237
+ })
238
+ })
239
+
240
+ describe('subagent-watcher FD-leak (H1): a still-present session dir keeps its watcher across rescans', () => {
241
+ it('does NOT prune the dir watcher for a directory that is still present', () => {
242
+ const projectDir = `${PROJECTS}/myproject`
243
+ const sessionDir = `${projectDir}/session-A`
244
+ const subagentsDir = `${sessionDir}/subagents`
245
+
246
+ const h = makeHarness({
247
+ dirs: {
248
+ [PROJECTS]: ['myproject'],
249
+ [projectDir]: ['session-A'],
250
+ [sessionDir]: ['subagents'],
251
+ [subagentsDir]: [], // present + empty — gets a dir watcher, stays present
252
+ },
253
+ })
254
+
255
+ h.poll()
256
+ const dw = h.dirWatchersFor(subagentsDir)
257
+ expect(dw).toHaveLength(1)
258
+ expect(dw[0].closed).toBe(false)
259
+
260
+ // The session dir never vanishes. Several rescan ticks pass. The complement
261
+ // of the H1 close-on-vanish test: pruneVanishedDirWatchers must leave a
262
+ // still-existing dir's watcher untouched, and the `if (!dirWatchers.has(...))`
263
+ // guard must NOT open a duplicate watcher for the same dir on each rescan.
264
+ h.poll()
265
+ h.poll()
266
+ h.poll()
267
+
268
+ expect(dw[0].close).not.toHaveBeenCalled()
269
+ expect(dw[0].closed).toBe(false)
270
+ // Exactly one dir watcher for this path across all the rescans — no churn.
271
+ expect(h.dirWatchersFor(subagentsDir)).toHaveLength(1)
272
+
273
+ h.watcher.stop()
274
+ })
275
+ })
@@ -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
+ })