switchroom 0.18.12 → 0.18.14
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 +57 -9
- package/dist/auth-broker/index.js +174 -72
- package/dist/cli/autoaccept-poll.js +23 -0
- package/dist/cli/drive-write-pretool.mjs +24 -1
- package/dist/cli/foreground-hog-pretool.mjs +264 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +9 -2
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +986 -3131
- package/dist/host-control/main.js +216 -2863
- package/dist/vault/approvals/kernel-server.js +67 -1
- package/dist/vault/broker/server.js +98 -45
- package/package.json +1 -1
- package/profiles/coding/CLAUDE.md.hbs +2 -0
- package/profiles/default/CLAUDE.md.hbs +2 -0
- package/skills/switchroom-architecture/telegram.md +0 -1
- package/telegram-plugin/auth-snapshot-format.ts +37 -5
- package/telegram-plugin/auto-fallback-fleet.ts +29 -1
- package/telegram-plugin/bridge/bridge.ts +2 -0
- package/telegram-plugin/dist/bridge/bridge.js +51 -3
- package/telegram-plugin/dist/gateway/gateway.js +1251 -2368
- package/telegram-plugin/dist/server.js +67 -3
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +21 -2
- package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
- package/telegram-plugin/gateway/auth-command.ts +14 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
- package/telegram-plugin/gateway/forward-origin.ts +235 -0
- package/telegram-plugin/gateway/gateway.ts +445 -83
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
- package/telegram-plugin/history.ts +106 -6
- package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
- package/telegram-plugin/model-unavailable.ts +61 -13
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/render/rich-render.ts +40 -32
- 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/stream-controller.ts +3 -2
- package/telegram-plugin/subagent-watcher.ts +71 -16
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/forward-origin.test.ts +309 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/history.test.ts +272 -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-outbound-chunks.test.ts +6 -4
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
- 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/single-mode-stream-reply.test.ts +5 -3
- package/telegram-plugin/tests/status-accent.test.ts +5 -3
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
- package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
- package/telegram-plugin/tests/throttle-tier.test.ts +278 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/throttle-tier.ts +226 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
- 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,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for telegram-plugin/throttle-tier.ts — the 429 throttle tier.
|
|
3
|
+
*
|
|
4
|
+
* Pins the operator-approved decision matrix ("retry in place under 5 min,
|
|
5
|
+
* else mark + failover, honest reset messaging"):
|
|
6
|
+
* - transient wording + reset ≤ threshold → throttle (stay put)
|
|
7
|
+
* - transient wording + reset > threshold → failover (escalate)
|
|
8
|
+
* - transient wording + unparseable reset → throttle, now+60s default
|
|
9
|
+
* - non-transient (wall) wording → none (caller's existing path)
|
|
10
|
+
* plus the per-account notice cooldown and the notice text itself.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from 'vitest'
|
|
14
|
+
import {
|
|
15
|
+
decideThrottleTier,
|
|
16
|
+
evaluateThrottleNotice,
|
|
17
|
+
isAccountScopedThrottle,
|
|
18
|
+
renderThrottleEscalationNotice,
|
|
19
|
+
renderThrottleNotice,
|
|
20
|
+
throttleRetryInPlaceMaxMs,
|
|
21
|
+
THROTTLE_DEFAULT_WAIT_MS,
|
|
22
|
+
THROTTLE_NOTICE_COOLDOWN_MS,
|
|
23
|
+
THROTTLE_RETRY_IN_PLACE_MAX_MS_DEFAULT,
|
|
24
|
+
type ThrottleNoticeState,
|
|
25
|
+
} from '../throttle-tier.js'
|
|
26
|
+
import { formatModelUnavailableCard, parseResetTime } from '../model-unavailable.js'
|
|
27
|
+
|
|
28
|
+
const NOW = Date.UTC(2026, 6, 12, 8, 0, 0) // 2026-07-12T08:00:00Z
|
|
29
|
+
const THRESHOLD = THROTTLE_RETRY_IN_PLACE_MAX_MS_DEFAULT // 5 min
|
|
30
|
+
|
|
31
|
+
// The canonical transient-negation wording (Anthropic burst-429 shape).
|
|
32
|
+
const TRANSIENT = (suffix: string): string =>
|
|
33
|
+
`This request would exceed your account's rate limit (not your usage limit). ${suffix}`
|
|
34
|
+
|
|
35
|
+
describe('decideThrottleTier — decision matrix', () => {
|
|
36
|
+
it('transient wording + reset within threshold → throttle at the parsed reset', () => {
|
|
37
|
+
const d = decideThrottleTier({
|
|
38
|
+
detail: TRANSIENT('Please retry after 90 seconds.'),
|
|
39
|
+
now: NOW,
|
|
40
|
+
thresholdMs: THRESHOLD,
|
|
41
|
+
})
|
|
42
|
+
expect(d).toEqual({
|
|
43
|
+
action: 'throttle',
|
|
44
|
+
throttledUntilMs: NOW + 90_000,
|
|
45
|
+
resetParsed: true,
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('transient wording + "resets in 3m" → throttle (≤ 5 min)', () => {
|
|
50
|
+
const d = decideThrottleTier({
|
|
51
|
+
detail: TRANSIENT('resets in 3m'),
|
|
52
|
+
now: NOW,
|
|
53
|
+
thresholdMs: THRESHOLD,
|
|
54
|
+
})
|
|
55
|
+
expect(d).toEqual({
|
|
56
|
+
action: 'throttle',
|
|
57
|
+
throttledUntilMs: NOW + 3 * 60_000,
|
|
58
|
+
resetParsed: true,
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('transient wording + reset beyond threshold → failover with the parsed reset', () => {
|
|
63
|
+
const d = decideThrottleTier({
|
|
64
|
+
detail: TRANSIENT('resets in 2h 15m'),
|
|
65
|
+
now: NOW,
|
|
66
|
+
thresholdMs: THRESHOLD,
|
|
67
|
+
})
|
|
68
|
+
expect(d).toEqual({
|
|
69
|
+
action: 'failover',
|
|
70
|
+
resetAtMs: NOW + (2 * 60 + 15) * 60_000,
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('transient wording + NO parseable reset → throttle for the 60s default', () => {
|
|
75
|
+
const d = decideThrottleTier({
|
|
76
|
+
detail: TRANSIENT('try again later.'),
|
|
77
|
+
now: NOW,
|
|
78
|
+
thresholdMs: THRESHOLD,
|
|
79
|
+
})
|
|
80
|
+
expect(d).toEqual({
|
|
81
|
+
action: 'throttle',
|
|
82
|
+
throttledUntilMs: NOW + THROTTLE_DEFAULT_WAIT_MS,
|
|
83
|
+
resetParsed: false,
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('transient wording + reset in the PAST → throttle for the 60s default', () => {
|
|
88
|
+
const past = new Date(NOW - 60_000).toISOString()
|
|
89
|
+
const d = decideThrottleTier({
|
|
90
|
+
detail: TRANSIENT(`resets ${past}`),
|
|
91
|
+
now: NOW,
|
|
92
|
+
thresholdMs: THRESHOLD,
|
|
93
|
+
})
|
|
94
|
+
expect(d).toEqual({
|
|
95
|
+
action: 'throttle',
|
|
96
|
+
throttledUntilMs: NOW + THROTTLE_DEFAULT_WAIT_MS,
|
|
97
|
+
resetParsed: false,
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('genuine wall wording (no transient negation) → none (existing quota path owns it)', () => {
|
|
102
|
+
const d = decideThrottleTier({
|
|
103
|
+
detail: "You've hit your limit · resets 8:50am (Australia/Melbourne)",
|
|
104
|
+
now: NOW,
|
|
105
|
+
thresholdMs: THRESHOLD,
|
|
106
|
+
})
|
|
107
|
+
expect(d).toEqual({ action: 'none' })
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('SERVER-side transient wording (529 shape) → none — never account-throttled', () => {
|
|
111
|
+
// This carries the transient NEGATION ("not your usage limit") but does
|
|
112
|
+
// NOT affirm the account's own rate limit — it is a server-wide
|
|
113
|
+
// condition; an account-scoped throttle + restart nudge would be the
|
|
114
|
+
// wrong action. It stays on the existing calm rate-limited path.
|
|
115
|
+
const d = decideThrottleTier({
|
|
116
|
+
detail: 'Server is temporarily limiting requests (not your usage limit). retry after 60 seconds',
|
|
117
|
+
now: NOW,
|
|
118
|
+
thresholdMs: THRESHOLD,
|
|
119
|
+
})
|
|
120
|
+
expect(d).toEqual({ action: 'none' })
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('honours a custom threshold (boundary: exactly at threshold stays in place)', () => {
|
|
124
|
+
const atThreshold = decideThrottleTier({
|
|
125
|
+
detail: TRANSIENT('retry after 300 seconds'),
|
|
126
|
+
now: NOW,
|
|
127
|
+
thresholdMs: 5 * 60_000,
|
|
128
|
+
})
|
|
129
|
+
expect(atThreshold.action).toBe('throttle')
|
|
130
|
+
const beyond = decideThrottleTier({
|
|
131
|
+
detail: TRANSIENT('retry after 301 seconds'),
|
|
132
|
+
now: NOW,
|
|
133
|
+
thresholdMs: 5 * 60_000,
|
|
134
|
+
})
|
|
135
|
+
expect(beyond.action).toBe('failover')
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('isAccountScopedThrottle — the tier gate', () => {
|
|
140
|
+
it("matches account-affirming wording (both apostrophe variants + 'not your account')", () => {
|
|
141
|
+
expect(isAccountScopedThrottle("would exceed your account's rate limit")).toBe(true)
|
|
142
|
+
expect(isAccountScopedThrottle('would exceed your account’s rate limit')).toBe(true)
|
|
143
|
+
expect(isAccountScopedThrottle("this is not your account's limit")).toBe(true)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('rejects server-side transient / 529 wordings and walls', () => {
|
|
147
|
+
expect(isAccountScopedThrottle('Server is temporarily limiting requests (not your usage limit)')).toBe(false)
|
|
148
|
+
expect(isAccountScopedThrottle('temporarily rate limited, overloaded_error 529')).toBe(false)
|
|
149
|
+
expect(isAccountScopedThrottle("You've hit your limit · resets 8:50am")).toBe(false)
|
|
150
|
+
expect(isAccountScopedThrottle('')).toBe(false)
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('throttleRetryInPlaceMaxMs — env resolution', () => {
|
|
155
|
+
it('defaults to 5 minutes', () => {
|
|
156
|
+
expect(throttleRetryInPlaceMaxMs({})).toBe(5 * 60_000)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('reads SWITCHROOM_THROTTLE_RETRY_IN_PLACE_MAX_MS', () => {
|
|
160
|
+
expect(
|
|
161
|
+
throttleRetryInPlaceMaxMs({ SWITCHROOM_THROTTLE_RETRY_IN_PLACE_MAX_MS: '120000' }),
|
|
162
|
+
).toBe(120_000)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('falls back to the default on junk / non-positive values', () => {
|
|
166
|
+
expect(
|
|
167
|
+
throttleRetryInPlaceMaxMs({ SWITCHROOM_THROTTLE_RETRY_IN_PLACE_MAX_MS: 'soon' }),
|
|
168
|
+
).toBe(THROTTLE_RETRY_IN_PLACE_MAX_MS_DEFAULT)
|
|
169
|
+
expect(
|
|
170
|
+
throttleRetryInPlaceMaxMs({ SWITCHROOM_THROTTLE_RETRY_IN_PLACE_MAX_MS: '-5' }),
|
|
171
|
+
).toBe(THROTTLE_RETRY_IN_PLACE_MAX_MS_DEFAULT)
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('evaluateThrottleNotice — per-account cooldown', () => {
|
|
176
|
+
it('sends the first notice and suppresses a repeat inside the window', () => {
|
|
177
|
+
let state: ThrottleNoticeState = { lastSentAtMsByAccount: {} }
|
|
178
|
+
const first = evaluateThrottleNotice(state, 'acct-a', NOW)
|
|
179
|
+
expect(first.send).toBe(true)
|
|
180
|
+
state = first.next
|
|
181
|
+
const repeat = evaluateThrottleNotice(state, 'acct-a', NOW + 60_000)
|
|
182
|
+
expect(repeat.send).toBe(false)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('a DIFFERENT account is not suppressed by the first account\'s window', () => {
|
|
186
|
+
const first = evaluateThrottleNotice({ lastSentAtMsByAccount: {} }, 'acct-a', NOW)
|
|
187
|
+
const other = evaluateThrottleNotice(first.next, 'acct-b', NOW + 1)
|
|
188
|
+
expect(other.send).toBe(true)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('sends again once the cooldown window has elapsed', () => {
|
|
192
|
+
const first = evaluateThrottleNotice({ lastSentAtMsByAccount: {} }, 'acct-a', NOW)
|
|
193
|
+
const later = evaluateThrottleNotice(
|
|
194
|
+
first.next,
|
|
195
|
+
'acct-a',
|
|
196
|
+
NOW + THROTTLE_NOTICE_COOLDOWN_MS,
|
|
197
|
+
)
|
|
198
|
+
expect(later.send).toBe(true)
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
describe('renderThrottleNotice — honest reset messaging', () => {
|
|
203
|
+
it('names the account, the agent, the reset, and that it is NOT a quota wall', () => {
|
|
204
|
+
const text = renderThrottleNotice({
|
|
205
|
+
account: 'alice',
|
|
206
|
+
agent: 'carrie',
|
|
207
|
+
throttledUntilMs: NOW + 3 * 60_000,
|
|
208
|
+
resetParsed: true,
|
|
209
|
+
now: new Date(NOW),
|
|
210
|
+
})
|
|
211
|
+
expect(text).toContain('alice')
|
|
212
|
+
expect(text).toContain('carrie')
|
|
213
|
+
expect(text).toContain('resets in 3m')
|
|
214
|
+
expect(text).toContain('not a quota wall')
|
|
215
|
+
expect(text).toContain('Staying on')
|
|
216
|
+
expect(text).toContain('no failover')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('degrades honestly when the account is unknown (broker unreachable)', () => {
|
|
220
|
+
const text = renderThrottleNotice({
|
|
221
|
+
account: null,
|
|
222
|
+
agent: 'carrie',
|
|
223
|
+
throttledUntilMs: NOW + THROTTLE_DEFAULT_WAIT_MS,
|
|
224
|
+
resetParsed: false,
|
|
225
|
+
now: new Date(NOW),
|
|
226
|
+
})
|
|
227
|
+
expect(text).toContain('the active account')
|
|
228
|
+
expect(text).toContain('retrying in ~60s')
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
describe('renderThrottleEscalationNotice — corroborated-wall announcement', () => {
|
|
233
|
+
it('names the account, the trigger agent, and the roll target', () => {
|
|
234
|
+
const text = renderThrottleEscalationNotice({
|
|
235
|
+
account: 'alice',
|
|
236
|
+
agent: 'carrie',
|
|
237
|
+
rolledTo: 'bob',
|
|
238
|
+
})
|
|
239
|
+
expect(text).toContain('alice')
|
|
240
|
+
expect(text).toContain('carrie')
|
|
241
|
+
expect(text).toContain('bob')
|
|
242
|
+
expect(text).toContain('actually a wall')
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('renders the all-blocked variant when no fallback had quota', () => {
|
|
246
|
+
const text = renderThrottleEscalationNotice({
|
|
247
|
+
account: 'alice',
|
|
248
|
+
agent: 'carrie',
|
|
249
|
+
rolledTo: null,
|
|
250
|
+
})
|
|
251
|
+
expect(text).toContain('all blocked')
|
|
252
|
+
expect(text).toContain('/auth add')
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
describe('rate_limited escalation card wording (model-unavailable integration)', () => {
|
|
257
|
+
it('formatModelUnavailableCard names the rate limit, not quota exhaustion', () => {
|
|
258
|
+
const card = formatModelUnavailableCard(
|
|
259
|
+
{ kind: 'rate_limited', resetAt: new Date(NOW + 30 * 60_000), raw: 'x' },
|
|
260
|
+
'carrie',
|
|
261
|
+
{ now: new Date(NOW), autoFallbackInFlight: true },
|
|
262
|
+
)
|
|
263
|
+
expect(card).toContain('account rate-limited')
|
|
264
|
+
expect(card).toContain('resets in 30m')
|
|
265
|
+
expect(card).not.toContain('quota exhausted')
|
|
266
|
+
})
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
describe('parseResetTime (exported for the throttle tier)', () => {
|
|
270
|
+
it('parses "retry after N seconds" relative to the injected clock', () => {
|
|
271
|
+
const d = parseResetTime('retry after 45 seconds', new Date(NOW))
|
|
272
|
+
expect(d?.getTime()).toBe(NOW + 45_000)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('returns undefined for prose with no reset hint', () => {
|
|
276
|
+
expect(parseResetTime('temporarily limiting requests', new Date(NOW))).toBeUndefined()
|
|
277
|
+
})
|
|
278
|
+
})
|
|
@@ -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
|
+
});
|