switchroom 0.18.11 → 0.18.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +37 -5
- package/dist/auth-broker/index.js +116 -78
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +38 -6
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +3529 -4543
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +605 -2847
- package/dist/vault/approvals/kernel-server.js +120 -13
- package/dist/vault/broker/server.js +259 -157
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +122 -4
- package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
- package/telegram-plugin/dist/server.js +139 -5
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +602 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1695 -230
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/history.ts +51 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +574 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +717 -0
- package/telegram-plugin/send-gate.ts +1056 -0
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +173 -18
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/history.test.ts +115 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
- package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
|
@@ -0,0 +1,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
|
+
})
|