switchroom 0.18.31 → 0.18.33

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 (159) hide show
  1. package/dist/agent-scheduler/index.js +4 -2
  2. package/dist/auth-broker/index.js +21 -3
  3. package/dist/cli/notion-write-pretool.mjs +4 -2
  4. package/dist/cli/switchroom.js +1410 -852
  5. package/dist/host-control/main.js +22 -4
  6. package/dist/vault/approvals/kernel-server.js +21 -3
  7. package/dist/vault/broker/server.js +48 -4
  8. package/package.json +4 -3
  9. package/profiles/_base/start.sh.hbs +148 -23
  10. package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
  11. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  12. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  13. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  14. package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
  15. package/telegram-plugin/gateway/boot-card.ts +169 -1
  16. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  17. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  18. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  19. package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
  20. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  21. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  22. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  23. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  24. package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
  25. package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
  26. package/telegram-plugin/gateway/gateway.ts +6403 -13456
  27. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
  28. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
  29. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  30. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  31. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  32. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  33. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  34. package/telegram-plugin/gateway/model-command.ts +23 -0
  35. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  36. package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
  37. package/telegram-plugin/gateway/obligation-store.ts +37 -1
  38. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  39. package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
  40. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  41. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  42. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  43. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  44. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  45. package/telegram-plugin/gateway/turn-end.ts +606 -0
  46. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  47. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  48. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  49. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  50. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  51. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  52. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  53. package/telegram-plugin/narrative-dedup.ts +24 -1
  54. package/telegram-plugin/narrative-flush.ts +2 -2
  55. package/telegram-plugin/pending-user-notice.ts +59 -13
  56. package/telegram-plugin/render/render.ts +25 -1
  57. package/telegram-plugin/status-no-truncate.ts +13 -0
  58. package/telegram-plugin/subagent-watcher.ts +297 -31
  59. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  60. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  61. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  62. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  63. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  64. package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
  65. package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
  66. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  67. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  68. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  69. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
  70. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  71. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
  72. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  73. package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
  74. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  75. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  76. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  77. package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
  78. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
  79. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
  80. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  81. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
  82. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
  83. package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
  84. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
  85. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
  86. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  87. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
  88. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  89. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  90. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  91. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
  92. package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
  93. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
  94. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
  95. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
  96. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  97. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
  98. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  99. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  100. package/telegram-plugin/tests/model-command.test.ts +30 -0
  101. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
  102. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  103. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  104. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  105. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  106. package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
  107. package/telegram-plugin/tests/obligation-store.test.ts +43 -0
  108. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
  109. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
  111. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  112. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
  113. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  114. package/telegram-plugin/tests/render/render.test.ts +42 -0
  115. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
  116. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  117. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  118. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
  119. package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
  120. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  121. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  122. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  123. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  124. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  125. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
  126. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
  127. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  128. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
  129. package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
  130. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
  131. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  132. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
  133. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  134. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  135. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  136. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  137. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  138. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  139. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  140. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  141. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
  142. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  143. package/telegram-plugin/tool-activity-summary.ts +110 -38
  144. package/telegram-plugin/turn-flush-safety.ts +80 -14
  145. package/telegram-plugin/uat/restart-capability.ts +76 -0
  146. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  147. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  148. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  149. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  150. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  151. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  152. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  153. package/telegram-plugin/worker-activity-feed.ts +34 -4
  154. package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
  155. package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
  156. package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
  157. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
  158. package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
  159. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Boot-reconcile perf regression: at boot the subagent-watcher must NOT do the
3
+ * full initial `readSubTail` (a whole-transcript parse + per-record sqlite
4
+ * liveness/backfill round-trips) for provably-dead prior-session workers.
5
+ *
6
+ * Production incident (2026-07-19): on a busy 24/7 agent ~1177 dead `running`
7
+ * JSONLs (last written days-to-weeks ago) each got the full register → whole-
8
+ * file read → liveness/backfill → staleness → FSWatcher-decision treatment on
9
+ * every boot. On a slow disk that O(filesize) work delayed the gateway
10
+ * answering Telegram by ~7 minutes after each restart, so the agent looked
11
+ * dead. The staleness verdict (`last write > inflightPromoteMaxAgeMs`) was
12
+ * already reached, but only AFTER the expensive read.
13
+ *
14
+ * The fix replaces the full read for a stale-by-mtime historical entry with a
15
+ * BOUNDED TAIL PROBE. Outcomes asserted here (not code paths):
16
+ * 1. running-at-boot-stale (no turn_end): registered historical, NO watcher,
17
+ * NOT swept (nothing terminal to sweep), and the disk read is bounded to
18
+ * the tail window even for a huge file (never a byte-0 whole-transcript
19
+ * read).
20
+ * 2. done-at-boot-stale (turn_end present): still reaches terminal cleanup —
21
+ * `onTerminalCleanup` fires after the grace window so the worker-feed row
22
+ * is swept (the regression the coordinator caught: a done-at-boot orphan
23
+ * must NOT be silently left as a ghost feed row).
24
+ * 3. fresh in-flight-at-boot: takes the full path — its JSONL IS read from
25
+ * byte 0 and it DOES get a per-file FSWatcher (no over-pruning).
26
+ */
27
+
28
+ import { describe, it, expect, vi } from 'vitest'
29
+ import type * as fs from 'fs'
30
+ import { startSubagentWatcher } from '../subagent-watcher.js'
31
+
32
+ interface FakeWatcher { path: string; close: ReturnType<typeof vi.fn> }
33
+ interface ReadCall { position: number; length: number }
34
+
35
+ const AGENT_DIR = '/home/user/.switchroom/agents/myagent'
36
+ const PROJECTS = `${AGENT_DIR}/.claude/projects`
37
+ const PROJECT = `${PROJECTS}/proj`
38
+ const SESSION = `${PROJECT}/sess`
39
+ const SUBAGENTS = `${SESSION}/subagents`
40
+ const NOW = 100_000_000
41
+
42
+ const turnEndLine = () => JSON.stringify({ type: 'system', subtype: 'turn_duration', duration_ms: 100 })
43
+ const toolUseLine = () =>
44
+ JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', id: 't1', name: 'Read', input: {} }] } })
45
+
46
+ /**
47
+ * Harness with per-path read tracking (position + length of every readSync, the
48
+ * proxy for "how much of the transcript did boot read") and FSWatcher tracking.
49
+ * `reportedSize` can far exceed the synthetic tail content, so a test can assert
50
+ * that only the tail window — not the whole (huge) file — was read.
51
+ */
52
+ function makeHarness(opts: {
53
+ fileName: string
54
+ ageMs: number
55
+ reportedSize: number
56
+ /** Synthetic bytes returned when the file is read (represents its tail). */
57
+ tail: Buffer
58
+ }) {
59
+ const jsonl = `${SUBAGENTS}/${opts.fileName}`
60
+ const reads: ReadCall[] = []
61
+ const watchers: FakeWatcher[] = []
62
+ const timeouts: Array<{ fn: () => void; fireAt: number; ref: number }> = []
63
+ const terminalCleanups: string[] = []
64
+ let currentTime = NOW
65
+ let nextRef = 1
66
+
67
+ const mockFs = {
68
+ existsSync: ((p: fs.PathLike) => {
69
+ const ps = String(p)
70
+ return ps === PROJECTS || ps === PROJECT || ps === SESSION || ps === SUBAGENTS || ps === jsonl
71
+ }) as typeof fs.existsSync,
72
+ readdirSync: ((p: fs.PathLike) => {
73
+ const ps = String(p)
74
+ if (ps === PROJECTS) return ['proj']
75
+ if (ps === PROJECT) return ['sess']
76
+ if (ps === SESSION) return ['subagents']
77
+ if (ps === SUBAGENTS) return [opts.fileName]
78
+ return []
79
+ }) as unknown as typeof fs.readdirSync,
80
+ statSync: ((p: fs.PathLike) => {
81
+ if (String(p) !== jsonl) return { size: 0, mtimeMs: currentTime } as fs.Stats
82
+ return { size: opts.reportedSize, mtimeMs: currentTime - opts.ageMs } as fs.Stats
83
+ }) as typeof fs.statSync,
84
+ openSync: (() => 7) as unknown as typeof fs.openSync,
85
+ closeSync: (() => undefined) as typeof fs.closeSync,
86
+ readSync: ((
87
+ _fd: number, buf: NodeJS.ArrayBufferView, offset: number, length: number, position: number | null,
88
+ ): number => {
89
+ reads.push({ position: position ?? 0, length })
90
+ const src = opts.tail.subarray(0, Math.min(length, opts.tail.length))
91
+ src.copy(buf as Buffer, offset)
92
+ return src.length
93
+ }) as unknown as typeof fs.readSync,
94
+ watch: ((p: fs.PathLike) => {
95
+ const w: FakeWatcher = { path: String(p), close: vi.fn() }
96
+ watchers.push(w)
97
+ return w as unknown as fs.FSWatcher
98
+ }) as unknown as typeof fs.watch,
99
+ }
100
+
101
+ const watcher = startSubagentWatcher({
102
+ agentDir: AGENT_DIR,
103
+ fs: mockFs,
104
+ now: () => currentTime,
105
+ setInterval: () => ({ ref: 0 }),
106
+ clearInterval: () => {},
107
+ setTimeout: (fn: () => void, ms: number) => {
108
+ const ref = nextRef++
109
+ timeouts.push({ fn, fireAt: currentTime + ms, ref })
110
+ return { ref }
111
+ },
112
+ clearTimeout: (handle) => {
113
+ const { ref } = handle as { ref: number }
114
+ const idx = timeouts.findIndex((t) => t.ref === ref)
115
+ if (idx !== -1) timeouts.splice(idx, 1)
116
+ },
117
+ onTerminalCleanup: (agentId: string) => { terminalCleanups.push(agentId) },
118
+ })
119
+
120
+ const advance = (ms: number): void => {
121
+ currentTime += ms
122
+ for (;;) {
123
+ timeouts.sort((a, b) => a.fireAt - b.fireAt)
124
+ const next = timeouts[0]
125
+ if (!next || next.fireAt > currentTime) break
126
+ timeouts.shift()
127
+ next.fn()
128
+ }
129
+ }
130
+
131
+ return {
132
+ watcher,
133
+ reads,
134
+ advance,
135
+ terminalCleanups,
136
+ jsonl,
137
+ fileWatchers: () => watchers.filter((w) => w.path === jsonl),
138
+ }
139
+ }
140
+
141
+ describe('subagent-watcher boot reconcile: skip full read for dead prior-session workers', () => {
142
+ it('running-at-boot-stale: historical, no watcher, bounded tail read (not whole transcript), not swept', () => {
143
+ const agentId = 'deadrunning'
144
+ // A huge (50 MB) JSONL last written 3 days ago whose tail has NO turn_end.
145
+ const tail = Buffer.from(`${toolUseLine()}\n${toolUseLine()}\n${toolUseLine()}\n`, 'utf-8')
146
+ const h = makeHarness({
147
+ fileName: `agent-${agentId}.jsonl`,
148
+ ageMs: 3 * 24 * 3600_000,
149
+ reportedSize: 50_000_000,
150
+ tail,
151
+ })
152
+
153
+ const entry = h.watcher.getRegistry().get(agentId)
154
+ expect(entry?.historical).toBe(true)
155
+ expect(entry?.state).toBe('running') // never promoted, never done
156
+ expect(h.fileWatchers()).toHaveLength(0) // no FD leak for a dead worker
157
+ expect(h.terminalCleanups).not.toContain(agentId) // nothing terminal to sweep
158
+
159
+ // The disk read was BOUNDED to the tail window — the 50 MB file was NEVER
160
+ // read from byte 0 (that whole-transcript read is the ~7-min boot cost).
161
+ expect(h.reads.length).toBeGreaterThan(0)
162
+ for (const r of h.reads) {
163
+ expect(r.position).toBeGreaterThan(0) // tail offset, not byte 0
164
+ expect(r.length).toBeLessThanOrEqual(512 * 1024)
165
+ }
166
+
167
+ h.watcher.stop()
168
+ })
169
+
170
+ it('done-at-boot-stale: still reaches terminal cleanup (onTerminalCleanup fires → feed row swept)', () => {
171
+ const agentId = 'deaddone'
172
+ // A stale JSONL whose tail ends in a turn_duration line → done at boot.
173
+ const tail = Buffer.from(`${toolUseLine()}\n${turnEndLine()}\n`, 'utf-8')
174
+ const h = makeHarness({
175
+ fileName: `agent-${agentId}.jsonl`,
176
+ ageMs: 3 * 24 * 3600_000,
177
+ reportedSize: 50_000_000,
178
+ tail,
179
+ })
180
+
181
+ const entry = h.watcher.getRegistry().get(agentId)
182
+ expect(entry?.historical).toBe(true)
183
+ expect(entry?.state).toBe('done') // detected terminal via the probe
184
+ expect(h.fileWatchers()).toHaveLength(0) // no watcher for a done-at-boot worker
185
+ expect(h.terminalCleanups).toHaveLength(0) // not yet — waits out the grace window
186
+
187
+ // Fire the scheduled terminal cleanup (30s grace) → onTerminalCleanup so the
188
+ // gateway sweeps the ghost feed row. THIS is the regression guard.
189
+ h.advance(30_000)
190
+ expect(h.terminalCleanups).toContain(agentId)
191
+
192
+ // Still bounded — the probe read only the tail, never the whole 50 MB file.
193
+ for (const r of h.reads) {
194
+ expect(r.position).toBeGreaterThan(0)
195
+ expect(r.length).toBeLessThanOrEqual(512 * 1024)
196
+ }
197
+
198
+ h.watcher.stop()
199
+ })
200
+
201
+ it('fresh in-flight-at-boot: full path — read from byte 0 and given an FSWatcher (no over-pruning)', () => {
202
+ const agentId = 'liveone'
203
+ const tail = Buffer.from(`${toolUseLine()}\n`, 'utf-8')
204
+ const h = makeHarness({
205
+ fileName: `agent-${agentId}.jsonl`,
206
+ ageMs: 30_000, // 30s old — inside the freshness window
207
+ reportedSize: tail.length,
208
+ tail,
209
+ })
210
+
211
+ // Full path: the JSONL is read from byte 0 (the whole transcript) and a
212
+ // per-file FSWatcher is opened so live transitions are observed.
213
+ expect(h.reads.some((r) => r.position === 0)).toBe(true)
214
+ expect(h.fileWatchers()).toHaveLength(1)
215
+
216
+ h.watcher.stop()
217
+ })
218
+ })
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Tests for SubagentWatcher re-registration after a SendMessage resume
3
+ * (issue #3315).
4
+ *
5
+ * A background worker that completes normally (a REAL `sub_agent_turn_end`)
6
+ * is swept out of the registry by `cleanupTerminalAgent`, which records the
7
+ * agent id in `terminatedAgentIds` so a rescan of the still-present JSONL
8
+ * doesn't re-register and re-fire a duplicate handback (issue #1116 Bug B).
9
+ *
10
+ * But a SendMessage-resume reuses the SAME `agent-<id>.jsonl`, appending a
11
+ * NEW turn to it. Pre-fix the `terminatedAgentIds` guard suppressed that file
12
+ * forever regardless of growth, so the resumed worker was never tracked again:
13
+ * no FSWatcher, no `onProgress`, no progress card — the exact UX failure the
14
+ * card exists to prevent (operator: "I don't see any progress cards, anything
15
+ * happening??").
16
+ *
17
+ * This suite pins BOTH directions of the deterministic size-based mechanism:
18
+ *
19
+ * (a) terminal cleanup → resume (JSONL grows past the terminal size) ⇒ the
20
+ * watcher re-registers the agent as a LIVE worker and emits progress
21
+ * cues again. The re-registration reads only the NEW turn — it does NOT
22
+ * replay the already-handed-back completed turn (no duplicate onFinish
23
+ * until the resumed turn genuinely ends).
24
+ * (b) terminal cleanup with NO resume (JSONL static) ⇒ the agent stays
25
+ * cleaned up: no re-registration, no duplicate onFinish, no leaked card.
26
+ */
27
+
28
+ import { describe, it, expect, vi } from 'vitest'
29
+ import { startSubagentWatcher } from '../subagent-watcher.js'
30
+ import * as fs from 'fs'
31
+
32
+ function buildJSONL(...lines: object[]): string {
33
+ return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'
34
+ }
35
+ function subAgentUserMsg(promptText: string) {
36
+ return { type: 'user', message: { content: [{ type: 'text', text: promptText }] } }
37
+ }
38
+ function subAgentToolUse(name: string, id: string, input: Record<string, unknown> = {}) {
39
+ return { type: 'assistant', message: { content: [{ type: 'tool_use', name, id, input }] } }
40
+ }
41
+ function subAgentTurnEnd() {
42
+ return { type: 'system', subtype: 'turn_duration', duration_ms: 1234 }
43
+ }
44
+
45
+ const RESCAN_MS = 500
46
+ const CLEANUP_GRACE_MS = 30_000 // TERMINAL_CLEANUP_GRACE_MS in subagent-watcher.ts
47
+
48
+ interface ProgressCall {
49
+ agentId: string
50
+ skeleton: boolean
51
+ progressLine?: string
52
+ toolCount: number
53
+ }
54
+
55
+ function makeHarness(opts: { agentId?: string } = {}) {
56
+ const { agentId = 'resume-agent' } = opts
57
+
58
+ let currentTime = 1000
59
+ const progressCalls: ProgressCall[] = []
60
+ const finishCalls: Array<{ agentId: string; outcome: string }> = []
61
+ const terminalCleanupCalls: string[] = []
62
+ const resumeCalls: Array<{ agentId: string; description: string }> = []
63
+ const logs: string[] = []
64
+
65
+ const agentDir = '/home/user/.switchroom/agents/myagent'
66
+ const sessionId = 'mock-session'
67
+ const projectsRoot = `${agentDir}/.claude/projects`
68
+ const projectDir = `${projectsRoot}/mock-cwd`
69
+ const sessionDir = `${projectDir}/${sessionId}`
70
+ const subagentsDir = `${sessionDir}/subagents`
71
+ const jsonlPath = `${subagentsDir}/agent-${agentId}.jsonl`
72
+
73
+ // The agent JSONL is NOT present at construction, so the boot scan registers
74
+ // nothing and the file — when it later appears — registers post-boot as a
75
+ // LIVE (non-historical) worker, exactly like a freshly-dispatched one.
76
+ const fileContents = new Map<string, Buffer>()
77
+
78
+ let lastOpenedPath: string | null = null
79
+ const mockFs = {
80
+ existsSync: ((p: fs.PathLike) => {
81
+ const ps = String(p)
82
+ if (ps === projectsRoot || ps === projectDir || ps === sessionDir || ps === subagentsDir) return true
83
+ return fileContents.has(ps)
84
+ }) as typeof fs.existsSync,
85
+ readdirSync: ((p: fs.PathLike) => {
86
+ const ps = String(p)
87
+ if (ps === projectsRoot) return ['mock-cwd']
88
+ if (ps === projectDir) return [sessionId]
89
+ if (ps === sessionDir) return ['subagents']
90
+ if (ps === subagentsDir) {
91
+ // Dynamic: list the agent-*.jsonl files currently present on "disk".
92
+ const names: string[] = []
93
+ for (const key of fileContents.keys()) {
94
+ if (key.startsWith(subagentsDir + '/agent-') && key.endsWith('.jsonl')) {
95
+ names.push(key.slice(subagentsDir.length + 1))
96
+ }
97
+ }
98
+ return names
99
+ }
100
+ return []
101
+ }) as unknown as typeof fs.readdirSync,
102
+ statSync: ((p: fs.PathLike) =>
103
+ ({ size: fileContents.get(String(p))?.length ?? 0, mtimeMs: currentTime }) as fs.Stats) as typeof fs.statSync,
104
+ openSync: ((p: fs.PathLike) => {
105
+ lastOpenedPath = String(p)
106
+ return 42
107
+ }) as unknown as typeof fs.openSync,
108
+ closeSync: (() => { lastOpenedPath = null }) as typeof fs.closeSync,
109
+ readSync: ((
110
+ _fd: number,
111
+ buf: NodeJS.ArrayBufferView,
112
+ offset: number,
113
+ length: number,
114
+ position: number | null,
115
+ ): number => {
116
+ const content = lastOpenedPath != null ? fileContents.get(lastOpenedPath) : undefined
117
+ if (!content) return 0
118
+ const pos = position ?? 0
119
+ const src = content.slice(pos, pos + length)
120
+ ;(src as Buffer).copy(buf as Buffer, offset)
121
+ return src.length
122
+ }) as unknown as typeof fs.readSync,
123
+ watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
124
+ }
125
+
126
+ const intervals: Array<{ fn: () => void; ms: number; ref: number; fireAt: number }> = []
127
+ const timeouts: Array<{ fn: () => void; ref: number; fireAt: number }> = []
128
+ let nextRef = 1
129
+
130
+ const watcher = startSubagentWatcher({
131
+ agentDir,
132
+ rescanMs: RESCAN_MS,
133
+ onProgress: ({ agentId: id, skeleton, progressLine, toolCount }) =>
134
+ progressCalls.push({ agentId: id, skeleton: skeleton === true, progressLine, toolCount }),
135
+ onFinish: ({ agentId: id, outcome }) => finishCalls.push({ agentId: id, outcome }),
136
+ onTerminalCleanup: (id) => terminalCleanupCalls.push(id),
137
+ onResume: (id, description) => resumeCalls.push({ agentId: id, description }),
138
+ now: () => currentTime,
139
+ setInterval: (fn, ms) => {
140
+ const ref = nextRef++
141
+ intervals.push({ fn, ms, ref, fireAt: currentTime + ms })
142
+ return { ref }
143
+ },
144
+ clearInterval: (handle) => {
145
+ const { ref } = handle as { ref: number }
146
+ const idx = intervals.findIndex((i) => i.ref === ref)
147
+ if (idx !== -1) intervals.splice(idx, 1)
148
+ },
149
+ setTimeout: (fn, ms) => {
150
+ const ref = nextRef++
151
+ timeouts.push({ fn, ref, fireAt: currentTime + ms })
152
+ return { ref }
153
+ },
154
+ clearTimeout: (handle) => {
155
+ const { ref } = handle as { ref: number }
156
+ const idx = timeouts.findIndex((t) => t.ref === ref)
157
+ if (idx !== -1) timeouts.splice(idx, 1)
158
+ },
159
+ fs: mockFs,
160
+ log: (msg) => logs.push(msg),
161
+ })
162
+
163
+ const advance = (ms: number): void => {
164
+ currentTime += ms
165
+ for (;;) {
166
+ intervals.sort((a, b) => a.fireAt - b.fireAt)
167
+ timeouts.sort((a, b) => a.fireAt - b.fireAt)
168
+ const nextI = intervals[0]
169
+ const nextT = timeouts[0]
170
+ const iReady = nextI && nextI.fireAt <= currentTime
171
+ const tReady = nextT && nextT.fireAt <= currentTime
172
+ if (!iReady && !tReady) break
173
+ if (tReady && (!iReady || nextT!.fireAt <= nextI!.fireAt)) {
174
+ timeouts.shift()
175
+ nextT!.fn()
176
+ } else {
177
+ nextI!.fireAt += nextI!.ms
178
+ nextI!.fn()
179
+ }
180
+ }
181
+ }
182
+
183
+ const setFile = (content: string): void => {
184
+ fileContents.set(jsonlPath, Buffer.from(content, 'utf-8'))
185
+ }
186
+ const append = (content: string): void => {
187
+ const cur = fileContents.get(jsonlPath) ?? Buffer.alloc(0)
188
+ fileContents.set(jsonlPath, Buffer.concat([cur, Buffer.from(content, 'utf-8')]))
189
+ }
190
+
191
+ return {
192
+ agentId,
193
+ progressCalls,
194
+ finishCalls,
195
+ terminalCleanupCalls,
196
+ resumeCalls,
197
+ logs,
198
+ advance,
199
+ watcher,
200
+ setFile,
201
+ append,
202
+ }
203
+ }
204
+
205
+ describe('subagent-watcher resume re-registration (issue #3315)', () => {
206
+ it('(a) re-registers a terminal-then-resumed worker so its progress card resumes', () => {
207
+ const agentId = 'resume-live'
208
+ const h = makeHarness({ agentId })
209
+
210
+ // A worker is dispatched post-boot: its JSONL appears with a running turn.
211
+ h.setFile(buildJSONL(subAgentUserMsg('bg task'), subAgentToolUse('Bash', 't1')))
212
+ h.advance(RESCAN_MS) // rescan discovers the file → LIVE registration
213
+
214
+ const registered = h.watcher.getRegistry().get(agentId)
215
+ expect(registered).toBeDefined()
216
+ expect(registered!.state).toBe('running')
217
+ expect(registered!.historical).toBe(false)
218
+
219
+ // It completes normally with a REAL turn_end.
220
+ h.append(buildJSONL(subAgentTurnEnd()))
221
+ h.advance(RESCAN_MS) // poll reads turn_end → done → onFinish(completed)
222
+ expect(h.finishCalls).toHaveLength(1)
223
+ expect(h.finishCalls[0].outcome).toBe('completed')
224
+
225
+ // Grace elapses → the entry is swept out of the registry.
226
+ h.advance(CLEANUP_GRACE_MS)
227
+ expect(h.watcher.getRegistry().has(agentId)).toBe(false)
228
+ expect(h.terminalCleanupCalls).toContain(agentId)
229
+
230
+ const progressBeforeResume = h.progressCalls.length
231
+
232
+ // SendMessage resume: Claude Code appends a NEW turn to the SAME JSONL.
233
+ h.append(buildJSONL(
234
+ subAgentUserMsg('follow-up question'),
235
+ subAgentToolUse('Read', 't2', { file_path: '/tmp/x' }),
236
+ ))
237
+ h.advance(RESCAN_MS) // rescan sees growth past terminal size → re-register
238
+
239
+ // The worker is tracked again — live, running, non-historical.
240
+ const revived = h.watcher.getRegistry().get(agentId)
241
+ expect(revived).toBeDefined()
242
+ expect(revived!.state).toBe('running')
243
+ expect(revived!.historical).toBe(false)
244
+ expect(h.logs.some((l) => l.includes('resumed after terminal cleanup') && l.includes(agentId))).toBe(true)
245
+
246
+ // Issue #3373: onResume fired exactly once for the resumed worker, BEFORE
247
+ // registration re-fires onProgress — this is what clears the feed's terminal
248
+ // `finalized` latch so the resumed cues re-surface a card instead of being
249
+ // swallowed. (The feed-side re-surface behaviour is covered in
250
+ // worker-activity-feed.test.ts.)
251
+ expect(h.resumeCalls.filter((c) => c.agentId === agentId)).toHaveLength(1)
252
+
253
+ // Progress cues resume (the card updates again) — the user-visible fix.
254
+ expect(h.progressCalls.length).toBeGreaterThan(progressBeforeResume)
255
+ expect(h.progressCalls.slice(progressBeforeResume).some((c) => c.agentId === agentId)).toBe(true)
256
+
257
+ // The completed turn was NOT replayed — no duplicate onFinish fired on
258
+ // re-registration (the old turn_end sits before the resume cursor).
259
+ expect(h.finishCalls).toHaveLength(1)
260
+
261
+ // When the resumed turn genuinely ends, onFinish fires again for the new
262
+ // turn — the resumed work gets its own handback.
263
+ h.append(buildJSONL(subAgentTurnEnd()))
264
+ h.advance(RESCAN_MS)
265
+ expect(h.finishCalls).toHaveLength(2)
266
+ expect(h.finishCalls[1].outcome).toBe('completed')
267
+
268
+ h.watcher.stop()
269
+ })
270
+
271
+ it('(b) a terminal worker that is NOT resumed stays cleaned up (no re-register, no duplicate handback)', () => {
272
+ const agentId = 'stay-clean'
273
+ const h = makeHarness({ agentId })
274
+
275
+ h.setFile(buildJSONL(subAgentUserMsg('bg task'), subAgentToolUse('Bash', 't1')))
276
+ h.advance(RESCAN_MS)
277
+ expect(h.watcher.getRegistry().has(agentId)).toBe(true)
278
+
279
+ h.append(buildJSONL(subAgentTurnEnd()))
280
+ h.advance(RESCAN_MS)
281
+ expect(h.finishCalls).toHaveLength(1)
282
+
283
+ h.advance(CLEANUP_GRACE_MS) // sweep
284
+ expect(h.watcher.getRegistry().has(agentId)).toBe(false)
285
+ const finishAfterCleanup = h.finishCalls.length
286
+ const progressAfterCleanup = h.progressCalls.length
287
+
288
+ // The JSONL stays on disk, static (Claude Code leaves it), and the worker
289
+ // is never resumed. Many rescans must NOT re-register it or re-fire the
290
+ // handback (issue #1116 Bug B guard preserved) — the reverse-bug direction.
291
+ for (let i = 0; i < 20; i++) h.advance(RESCAN_MS)
292
+
293
+ expect(h.watcher.getRegistry().has(agentId)).toBe(false)
294
+ expect(h.finishCalls).toHaveLength(finishAfterCleanup) // no duplicate
295
+ expect(h.progressCalls).toHaveLength(progressAfterCleanup) // no card churn
296
+ // Cleanup fired exactly once — no leaked/duplicated terminal sweep.
297
+ expect(h.terminalCleanupCalls.filter((id) => id === agentId)).toHaveLength(1)
298
+ // Issue #3373 anti-zombie: a genuinely-finished worker that never grew past
299
+ // its terminal boundary must NEVER fire onResume — otherwise the feed's
300
+ // finalized latch would be cleared and a dead worker could zombie-resurface.
301
+ expect(h.resumeCalls.filter((c) => c.agentId === agentId)).toHaveLength(0)
302
+
303
+ h.watcher.stop()
304
+ })
305
+ })
@@ -395,4 +395,36 @@ describe('subagent-watcher card resurrection (issue #3023)', () => {
395
395
  h.advance(500)
396
396
  expect(h.resurrectCalls).toHaveLength(1)
397
397
  })
398
+
399
+ it('(f) a false finish resumed after cleanup grace is owned by resurrection, not the #3315 resume path', () => {
400
+ // Non-interference guard (issue #3315 × #3023): once the terminal-cleanup
401
+ // grace has elapsed, a falsely-finalised worker's id sits in BOTH
402
+ // `terminatedAgentIds` (the #3315 resume-detection map) AND
403
+ // `falseFinishTracker`. Its JSONL resuming must be handled by the
404
+ // resurrection path (bounded-chain + onResurrect semantics), NOT
405
+ // double-handled by the scanSubagentsDir resume branch. The branch defers
406
+ // when a false-finish record exists.
407
+ const agentId = 'false-finish-post-grace'
408
+ const h = makeHarness({ agentId })
409
+
410
+ h.advance(500)
411
+ unmarkHistorical(h, agentId)
412
+
413
+ driveToFalseFinish(h, agentId)
414
+ // Let the 30s terminal-cleanup grace elapse so the entry is swept — its id
415
+ // is now in BOTH terminatedAgentIds and falseFinishTracker.
416
+ h.advance(30_000)
417
+ expect(h.watcher.getRegistry().has(agentId)).toBe(false)
418
+
419
+ // JSONL resumes growing.
420
+ h.appendActivity()
421
+ h.advance(500)
422
+
423
+ // Resurrected exactly once via the #3023 path...
424
+ expect(h.resurrectCalls).toHaveLength(1)
425
+ expect(h.watcher.getRegistry().get(agentId)?.state).toBe('running')
426
+ // ...and NOT re-registered by the #3315 resume branch (its log marker is
427
+ // absent — the branch deferred because a false-finish record existed).
428
+ expect(h.logs.some((l) => l.includes('resumed after terminal cleanup'))).toBe(false)
429
+ })
398
430
  })
@@ -647,6 +647,36 @@ describe('startSubagentWatcher', () => {
647
647
  h.poll()
648
648
  // The draft is staged, not yet resolved — no cue yet.
649
649
  // The very next event is a reply tool with matching text → SUPPRESS.
650
+ // #3231: feed the PREFIXED prod wire shape (mcp__…__stream_reply), the
651
+ // form real jsonl actually carries. Before the isReplyTool fix a bare
652
+ // REPLY_TOOLS.has() missed this and the draft was WRONGLY surfaced.
653
+ appendFileSync(jsonlPath, buildJSONL({
654
+ type: 'assistant',
655
+ message: { content: [{ type: 'tool_use', name: 'mcp__switchroom-telegram__stream_reply', id: 'r1', input: { text: answer } }] },
656
+ }))
657
+ h.poll()
658
+ expect(narrativeCues.length).toBe(0)
659
+ })
660
+
661
+ it('narrative gate: a draft-then-reply sub_agent_text is SUPPRESSED with a BARE reply tool name too (#3231)', () => {
662
+ // Symmetric with the prefixed case above: bare 'stream_reply' can still
663
+ // arrive from non-MCP sources, so isReplyTool must match it as well.
664
+ const narrativeCues: string[] = []
665
+ const agentDir = join(tmpRoot, 'agent')
666
+ const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
667
+ mkdirSync(subagentsDir, { recursive: true })
668
+ const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
669
+ const h = startWatcherSync({
670
+ agentDir,
671
+ onProgress: ({ progressLine, latestSummary, skeleton }) => {
672
+ if (!skeleton && progressLine == null) narrativeCues.push(latestSummary)
673
+ },
674
+ })
675
+ writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Find the repo path')))
676
+ h.poll()
677
+ const answer = 'The repo is at /home/user/code/switchroom.'
678
+ appendFileSync(jsonlPath, buildJSONL(subAgentAssistantText(answer)))
679
+ h.poll()
650
680
  appendFileSync(jsonlPath, buildJSONL({
651
681
  type: 'assistant',
652
682
  message: { content: [{ type: 'tool_use', name: 'stream_reply', id: 'r1', input: { text: answer } }] },
@@ -786,10 +816,11 @@ describe('startSubagentWatcher', () => {
786
816
  writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Summarise the diff')))
787
817
  h.poll()
788
818
  const answer = 'The fix touches three files and adds a unit test for the double-Done case.'
789
- // Final tool of the turn is stream_reply carrying the answer.
819
+ // Final tool of the turn is stream_reply carrying the answer. #3231: use
820
+ // the PREFIXED prod wire shape so lastReplyText capture (isReplyTool) fires.
790
821
  appendFileSync(jsonlPath, buildJSONL({
791
822
  type: 'assistant',
792
- message: { content: [{ type: 'tool_use', name: 'stream_reply', id: 'r1', input: { text: answer } }] },
823
+ message: { content: [{ type: 'tool_use', name: 'mcp__switchroom-telegram__stream_reply', id: 'r1', input: { text: answer } }] },
793
824
  }))
794
825
  h.poll()
795
826
  // Trailing text block (separate message) that drafts the delivered answer.
@@ -819,9 +850,10 @@ describe('startSubagentWatcher', () => {
819
850
  writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Summarise the diff')))
820
851
  h.poll()
821
852
  const answer = 'The fix touches three files and adds a unit test for the double-Done case.'
853
+ // #3231: prefixed prod wire shape.
822
854
  appendFileSync(jsonlPath, buildJSONL({
823
855
  type: 'assistant',
824
- message: { content: [{ type: 'tool_use', name: 'stream_reply', id: 'r1', input: { text: answer } }] },
856
+ message: { content: [{ type: 'tool_use', name: 'mcp__switchroom-telegram__stream_reply', id: 'r1', input: { text: answer } }] },
825
857
  }))
826
858
  h.poll()
827
859
  appendFileSync(jsonlPath, buildJSONL(subAgentAssistantText('Done — cleaning up the worktree now.')))
@@ -4,10 +4,10 @@
4
4
  * Outcome contract: if a pre-purge op in the turn_end handler body THROWS
5
5
  * before the canonical purge (endCurrentTurnAtomic → purgeReactionTracking)
6
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.
7
+ * (activeTurnStartedAt; the purge also emits the machine turnEnd) so the
8
+ * #1556 inbound gate re-opens for the next inbound. On the happy path —
9
+ * where the body clears the key itself — the backstop must be a no-op (no
10
+ * double purge), and the original error must always propagate.
11
11
  */
12
12
  import { describe, it, expect } from 'vitest'
13
13
  import { withTurnEndGateBackstop } from '../gateway/turn-end-gate-backstop.js'
@@ -22,19 +22,16 @@ function statusKey(chatId: string, threadId?: number): string {
22
22
  }
23
23
 
24
24
  /** Mirror of the production gate state + a purge that clears it, as
25
- * purgeReactionTracking does (activeTurnStartedAt.delete + claudeBusyKeys
26
- * drain for the key). */
25
+ * purgeReactionTracking does (activeTurnStartedAt.delete for the key). */
27
26
  function freshGate(turn: Turn) {
28
27
  const key = statusKey(turn.sessionChatId, turn.sessionThreadId)
29
28
  const activeTurnStartedAt = new Map<string, number>([[key, Date.now()]])
30
- const claudeBusyKeys = new Set<string>([key])
31
29
  const purgeCalls: Array<{ key: string; endingTurn: Turn | undefined }> = []
32
30
  const purge = (k: string, endingTurn: Turn | undefined) => {
33
31
  purgeCalls.push({ key: k, endingTurn })
34
32
  activeTurnStartedAt.delete(k)
35
- claudeBusyKeys.delete(k)
36
33
  }
37
- return { key, activeTurnStartedAt, claudeBusyKeys, purge, purgeCalls }
34
+ return { key, activeTurnStartedAt, purge, purgeCalls }
38
35
  }
39
36
 
40
37
  function deps(gate: ReturnType<typeof freshGate>) {
@@ -58,10 +55,9 @@ describe('withTurnEndGateBackstop (#2094 finding 1)', () => {
58
55
  }, deps(gate)),
59
56
  ).toThrow('redactOutboundText blew up')
60
57
 
61
- // Outcome: gate is OPEN again — both maps cleared, so the #1556 inbound
62
- // gate no longer wedges the next inbound.
58
+ // Outcome: gate is OPEN again — the turn map is cleared, so the #1556
59
+ // inbound gate no longer wedges the next inbound.
63
60
  expect(gate.activeTurnStartedAt.has(gate.key)).toBe(false)
64
- expect(gate.claudeBusyKeys.has(gate.key)).toBe(false)
65
61
  // The backstop fired exactly once, forwarding the ending turn.
66
62
  expect(gate.purgeCalls).toEqual([{ key: gate.key, endingTurn: turn }])
67
63
  })