switchroom 0.18.12 → 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 +8 -0
- package/dist/auth-broker/index.js +63 -65
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +9 -1
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +952 -3126
- package/dist/host-control/main.js +216 -2862
- package/dist/vault/approvals/kernel-server.js +67 -0
- package/dist/vault/broker/server.js +98 -44
- package/package.json +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +49 -3
- package/telegram-plugin/dist/gateway/gateway.js +656 -2326
- package/telegram-plugin/dist/server.js +65 -3
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +21 -2
- package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
- package/telegram-plugin/gateway/gateway.ts +221 -73
- package/telegram-plugin/history.ts +51 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/render/render.ts +32 -14
- 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 +131 -0
- package/telegram-plugin/send-gate.test.ts +25 -6
- package/telegram-plugin/send-gate.ts +82 -8
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/subagent-watcher.ts +71 -16
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/history.test.ts +115 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +164 -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/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render.test.ts +88 -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-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
|
@@ -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
|
+
})
|
|
@@ -9,12 +9,26 @@
|
|
|
9
9
|
* Run with:
|
|
10
10
|
* bun test telegram-plugin/tests/worktree-watch-cwds.test.ts
|
|
11
11
|
*/
|
|
12
|
-
import { describe, it, expect, beforeEach } from "vitest";
|
|
12
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
13
|
+
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
|
|
14
|
+
import { join, dirname } from "node:path";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
13
17
|
import {
|
|
14
18
|
ownedWorktreeCwds,
|
|
19
|
+
refreshOwnedWorktreeHeartbeats,
|
|
20
|
+
makeWorktreeWatchProvider,
|
|
15
21
|
__resetIdentityEscalationForTests,
|
|
16
22
|
type WorktreeOwnershipRecord,
|
|
23
|
+
type WorktreeHeartbeatRecord,
|
|
17
24
|
} from "../worktree-watch-cwds.js";
|
|
25
|
+
import {
|
|
26
|
+
writeRecord,
|
|
27
|
+
readRecord,
|
|
28
|
+
listRecords as registryListRecords,
|
|
29
|
+
touchHeartbeat as registryTouchHeartbeat,
|
|
30
|
+
} from "../../src/worktree/registry.js";
|
|
31
|
+
import type { WorktreeRecord } from "../../src/worktree/types.js";
|
|
18
32
|
|
|
19
33
|
const idPath = (p: string) => p; // identity realpath for deterministic tests
|
|
20
34
|
|
|
@@ -196,3 +210,203 @@ describe("ownedWorktreeCwds", () => {
|
|
|
196
210
|
expect(provider()).toEqual(["/wt/b"]);
|
|
197
211
|
});
|
|
198
212
|
});
|
|
213
|
+
|
|
214
|
+
describe("refreshOwnedWorktreeHeartbeats", () => {
|
|
215
|
+
const records: WorktreeHeartbeatRecord[] = [
|
|
216
|
+
{ id: "mine-1", ownerAgent: "klanker" },
|
|
217
|
+
{ id: "mine-2", ownerAgent: "klanker" },
|
|
218
|
+
{ id: "theirs", ownerAgent: "reggie" },
|
|
219
|
+
{ id: "ownerless" }, // ownerAgent undefined
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
it("touches ONLY this agent's records, never ownerless or foreign ones", () => {
|
|
223
|
+
const touched: string[] = [];
|
|
224
|
+
const n = refreshOwnedWorktreeHeartbeats({
|
|
225
|
+
self: "klanker",
|
|
226
|
+
listRecords: () => records,
|
|
227
|
+
touchHeartbeat: (id) => touched.push(id),
|
|
228
|
+
minRefreshIntervalMs: 0, // always touch
|
|
229
|
+
});
|
|
230
|
+
expect(touched.sort()).toEqual(["mine-1", "mine-2"]);
|
|
231
|
+
expect(touched).not.toContain("theirs");
|
|
232
|
+
expect(touched).not.toContain("ownerless");
|
|
233
|
+
expect(n).toBe(2);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("fail-closed: unresolved identity touches NOTHING (no #1116 leak)", () => {
|
|
237
|
+
const touched: string[] = [];
|
|
238
|
+
const n = refreshOwnedWorktreeHeartbeats({
|
|
239
|
+
self: undefined,
|
|
240
|
+
listRecords: () => records,
|
|
241
|
+
touchHeartbeat: (id) => touched.push(id),
|
|
242
|
+
minRefreshIntervalMs: 0,
|
|
243
|
+
});
|
|
244
|
+
expect(touched).toEqual([]);
|
|
245
|
+
expect(n).toBe(0);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("resolves identity from agentDir when env is unset (durable fallback)", () => {
|
|
249
|
+
const touched: string[] = [];
|
|
250
|
+
refreshOwnedWorktreeHeartbeats({
|
|
251
|
+
self: undefined,
|
|
252
|
+
agentDir: "/home/x/.switchroom/agents/klanker",
|
|
253
|
+
listRecords: () => records,
|
|
254
|
+
touchHeartbeat: (id) => touched.push(id),
|
|
255
|
+
minRefreshIntervalMs: 0,
|
|
256
|
+
});
|
|
257
|
+
expect(touched.sort()).toEqual(["mine-1", "mine-2"]);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("throttles: skips a record whose heartbeat is younger than the interval", () => {
|
|
261
|
+
const now = 1_000_000;
|
|
262
|
+
const fresh = new Date(now - 30_000).toISOString(); // 30s ago
|
|
263
|
+
const aged = new Date(now - 5 * 60_000).toISOString(); // 5min ago
|
|
264
|
+
const touched: string[] = [];
|
|
265
|
+
refreshOwnedWorktreeHeartbeats({
|
|
266
|
+
self: "klanker",
|
|
267
|
+
listRecords: () => [
|
|
268
|
+
{ id: "fresh", ownerAgent: "klanker", heartbeatAt: fresh },
|
|
269
|
+
{ id: "aged", ownerAgent: "klanker", heartbeatAt: aged },
|
|
270
|
+
],
|
|
271
|
+
touchHeartbeat: (id) => touched.push(id),
|
|
272
|
+
minRefreshIntervalMs: 2 * 60_000, // 2 min
|
|
273
|
+
now: () => now,
|
|
274
|
+
});
|
|
275
|
+
// Only the aged one crosses the throttle window.
|
|
276
|
+
expect(touched).toEqual(["aged"]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("a registry read failure is swallowed (never throws on the hot loop)", () => {
|
|
280
|
+
expect(() =>
|
|
281
|
+
refreshOwnedWorktreeHeartbeats({
|
|
282
|
+
self: "klanker",
|
|
283
|
+
listRecords: () => {
|
|
284
|
+
throw new Error("registry gone");
|
|
285
|
+
},
|
|
286
|
+
touchHeartbeat: () => {},
|
|
287
|
+
}),
|
|
288
|
+
).not.toThrow();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("a per-record touch failure does not abort the remaining records", () => {
|
|
292
|
+
const touched: string[] = [];
|
|
293
|
+
const n = refreshOwnedWorktreeHeartbeats({
|
|
294
|
+
self: "klanker",
|
|
295
|
+
listRecords: () => records,
|
|
296
|
+
touchHeartbeat: (id) => {
|
|
297
|
+
if (id === "mine-1") throw new Error("write failed");
|
|
298
|
+
touched.push(id);
|
|
299
|
+
},
|
|
300
|
+
minRefreshIntervalMs: 0,
|
|
301
|
+
});
|
|
302
|
+
// mine-2 still touched despite mine-1 throwing.
|
|
303
|
+
expect(touched).toEqual(["mine-2"]);
|
|
304
|
+
expect(n).toBe(1);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ─── M1: the gateway's extraWatchCwds provider ──────────────────────────────
|
|
309
|
+
//
|
|
310
|
+
// The gateway wires a single closure (`makeWorktreeWatchProvider`) as the
|
|
311
|
+
// subagent-watcher's extraWatchCwdsProvider. The whole point of this PR is
|
|
312
|
+
// that that closure ALSO advances heartbeats on every tick — deleting the
|
|
313
|
+
// refresh call would leave the cwd behaviour (and every ownership test above)
|
|
314
|
+
// green while silently reintroducing the zero-caller `touchHeartbeat` bug.
|
|
315
|
+
// These tests are the deterministic guard: they assert the provider returns
|
|
316
|
+
// owned cwds AND advances a real registry record's heartbeatAt on the same
|
|
317
|
+
// call, and that the gateway actually installs it.
|
|
318
|
+
describe("makeWorktreeWatchProvider (gateway wiring)", () => {
|
|
319
|
+
const idPathLocal = (p: string) => p; // deterministic realpath
|
|
320
|
+
|
|
321
|
+
it("both returns owned cwds AND advances the claim's heartbeat (in-memory)", () => {
|
|
322
|
+
const now = 10_000_000;
|
|
323
|
+
const before = new Date(now - 60 * 60_000).toISOString(); // 1h ago (stale)
|
|
324
|
+
const store: (WorktreeOwnershipRecord & WorktreeHeartbeatRecord)[] = [
|
|
325
|
+
{ id: "mine", path: "/wt/mine", ownerAgent: "klanker", heartbeatAt: before },
|
|
326
|
+
{ id: "theirs", path: "/wt/theirs", ownerAgent: "reggie", heartbeatAt: before },
|
|
327
|
+
];
|
|
328
|
+
const touched: string[] = [];
|
|
329
|
+
const provider = makeWorktreeWatchProvider({
|
|
330
|
+
self: "klanker",
|
|
331
|
+
listRecords: () => store,
|
|
332
|
+
touchHeartbeat: (id) => touched.push(id),
|
|
333
|
+
realpath: idPathLocal,
|
|
334
|
+
minRefreshIntervalMs: 0,
|
|
335
|
+
now: () => now,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const cwds = provider();
|
|
339
|
+
|
|
340
|
+
// (1) returns exactly this agent's owned cwds
|
|
341
|
+
expect(cwds).toEqual(["/wt/mine"]);
|
|
342
|
+
// (2) AND advanced the heartbeat of the owned record only — never foreign
|
|
343
|
+
expect(touched).toEqual(["mine"]);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
describe("against a real temp registry", () => {
|
|
347
|
+
let tmpDir: string;
|
|
348
|
+
const origEnv = process.env.SWITCHROOM_WORKTREE_DIR;
|
|
349
|
+
|
|
350
|
+
beforeEach(() => {
|
|
351
|
+
tmpDir = mkdtempSync(join(tmpdir(), "sw-provider-test-"));
|
|
352
|
+
process.env.SWITCHROOM_WORKTREE_DIR = tmpDir;
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
afterEach(() => {
|
|
356
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
357
|
+
if (origEnv === undefined) delete process.env.SWITCHROOM_WORKTREE_DIR;
|
|
358
|
+
else process.env.SWITCHROOM_WORKTREE_DIR = origEnv;
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
function makeRecord(overrides: Partial<WorktreeRecord> = {}): WorktreeRecord {
|
|
362
|
+
const iso = new Date().toISOString();
|
|
363
|
+
return {
|
|
364
|
+
id: "prov001",
|
|
365
|
+
repo: "/fake/repo",
|
|
366
|
+
repoName: "fake",
|
|
367
|
+
branch: "task/prov001",
|
|
368
|
+
path: "/wt/prov001",
|
|
369
|
+
createdAt: iso,
|
|
370
|
+
heartbeatAt: iso,
|
|
371
|
+
ownerAgent: "klanker",
|
|
372
|
+
...overrides,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
it("provider drives the REAL registry: returns cwd AND advances heartbeatAt on disk", async () => {
|
|
377
|
+
const past = new Date(Date.now() - 5 * 60_000).toISOString(); // 5min ago
|
|
378
|
+
writeRecord(makeRecord({ heartbeatAt: past }));
|
|
379
|
+
const before = new Date(readRecord("prov001")!.heartbeatAt).getTime();
|
|
380
|
+
|
|
381
|
+
const provider = makeWorktreeWatchProvider({
|
|
382
|
+
self: "klanker",
|
|
383
|
+
listRecords: registryListRecords,
|
|
384
|
+
touchHeartbeat: registryTouchHeartbeat,
|
|
385
|
+
realpath: idPathLocal,
|
|
386
|
+
minRefreshIntervalMs: 0, // always touch
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
await new Promise((r) => setTimeout(r, 5)); // ensure a strictly newer ts
|
|
390
|
+
const cwds = provider();
|
|
391
|
+
|
|
392
|
+
// (1) returns the owned cwd from the real registry
|
|
393
|
+
expect(cwds).toEqual(["/wt/prov001"]);
|
|
394
|
+
// (2) AND the on-disk heartbeat actually advanced (the zero-caller guard)
|
|
395
|
+
const after = new Date(readRecord("prov001")!.heartbeatAt).getTime();
|
|
396
|
+
expect(after).toBeGreaterThan(before);
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("gateway.ts installs makeWorktreeWatchProvider as extraWatchCwdsProvider", () => {
|
|
401
|
+
// Grep-pin (anchor MUST resolve so it can never pass vacuously — repo test
|
|
402
|
+
// convention, PR #3126): proves the gateway actually wires the extracted
|
|
403
|
+
// provider, so a behaviour test on the provider is not testing dead code.
|
|
404
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
405
|
+
const gatewaySrc = readFileSync(
|
|
406
|
+
join(here, "..", "gateway", "gateway.ts"),
|
|
407
|
+
"utf8",
|
|
408
|
+
);
|
|
409
|
+
const anchor = "extraWatchCwdsProvider: makeWorktreeWatchProvider(";
|
|
410
|
+
expect(gatewaySrc.indexOf(anchor)).toBeGreaterThan(-1);
|
|
411
|
+
});
|
|
412
|
+
});
|