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.
Files changed (76) hide show
  1. package/dist/agent-scheduler/index.js +57 -9
  2. package/dist/auth-broker/index.js +174 -72
  3. package/dist/cli/autoaccept-poll.js +23 -0
  4. package/dist/cli/drive-write-pretool.mjs +24 -1
  5. package/dist/cli/foreground-hog-pretool.mjs +264 -0
  6. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  7. package/dist/cli/notion-write-pretool.mjs +9 -2
  8. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  9. package/dist/cli/switchroom.js +986 -3131
  10. package/dist/host-control/main.js +216 -2863
  11. package/dist/vault/approvals/kernel-server.js +67 -1
  12. package/dist/vault/broker/server.js +98 -45
  13. package/package.json +1 -1
  14. package/profiles/coding/CLAUDE.md.hbs +2 -0
  15. package/profiles/default/CLAUDE.md.hbs +2 -0
  16. package/skills/switchroom-architecture/telegram.md +0 -1
  17. package/telegram-plugin/auth-snapshot-format.ts +37 -5
  18. package/telegram-plugin/auto-fallback-fleet.ts +29 -1
  19. package/telegram-plugin/bridge/bridge.ts +2 -0
  20. package/telegram-plugin/dist/bridge/bridge.js +51 -3
  21. package/telegram-plugin/dist/gateway/gateway.js +1251 -2368
  22. package/telegram-plugin/dist/server.js +67 -3
  23. package/telegram-plugin/format.ts +19 -0
  24. package/telegram-plugin/gateway/approval-hold.ts +21 -2
  25. package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
  26. package/telegram-plugin/gateway/auth-command.ts +14 -0
  27. package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
  28. package/telegram-plugin/gateway/forward-origin.ts +235 -0
  29. package/telegram-plugin/gateway/gateway.ts +445 -83
  30. package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
  31. package/telegram-plugin/history.ts +106 -6
  32. package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
  33. package/telegram-plugin/model-unavailable.ts +61 -13
  34. package/telegram-plugin/outbound-field-redact.ts +69 -0
  35. package/telegram-plugin/render/render.ts +32 -14
  36. package/telegram-plugin/render/rich-render.ts +40 -32
  37. package/telegram-plugin/scoped-approval.ts +11 -2
  38. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  39. package/telegram-plugin/secret-detect/index.ts +12 -56
  40. package/telegram-plugin/send-gate-degraded.test.ts +131 -0
  41. package/telegram-plugin/send-gate.test.ts +25 -6
  42. package/telegram-plugin/send-gate.ts +82 -8
  43. package/telegram-plugin/session-tail.ts +82 -7
  44. package/telegram-plugin/stream-controller.ts +3 -2
  45. package/telegram-plugin/subagent-watcher.ts +71 -16
  46. package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
  47. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
  48. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  49. package/telegram-plugin/tests/forward-origin.test.ts +309 -0
  50. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  51. package/telegram-plugin/tests/history.test.ts +272 -0
  52. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  53. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +164 -0
  54. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  55. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  56. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  57. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
  58. package/telegram-plugin/tests/render/render.test.ts +88 -0
  59. package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
  60. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  61. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  62. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  63. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  64. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
  65. package/telegram-plugin/tests/status-accent.test.ts +5 -3
  66. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
  67. package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
  68. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  69. package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
  70. package/telegram-plugin/tests/throttle-tier.test.ts +278 -0
  71. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  72. package/telegram-plugin/throttle-tier.ts +226 -0
  73. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
  74. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  75. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  76. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -2,7 +2,8 @@
2
2
  * Wire-level regression test for the chunk-boundary cap bug
3
3
  * (fix/rich-render-chunk-boundary-cap).
4
4
  *
5
- * With `SWITCHROOM_RICH_RENDER` on, a near-cap body full of escapable chars
5
+ * With the rich renderer enabled (the default — escape hatch, not opt-in), a
6
+ * near-cap body full of escapable chars
6
7
  * (`_ * |`) makes `renderSafe` degrade the whole document to plain (its escaped
7
8
  * rich form exceeds RICH_MESSAGE_MAX_CHARS). BEFORE the fix, the stream
8
9
  * controller shipped that ~32k plain body through the plain `sendMessage`
@@ -13,7 +14,7 @@
13
14
  * emitted send fits its own wire cap: rich pieces <= 32768, plain pieces
14
15
  * <= 4096, and no fenced block is bisected.
15
16
  */
16
- import { describe, it, expect, afterEach } from "vitest";
17
+ import { describe, it, expect } from "vitest";
17
18
  import { createStreamController } from "../stream-controller.js";
18
19
  import { createFakeBotApi } from "./fake-bot-api.js";
19
20
  import { RICH_MESSAGE_MAX_CHARS } from "../format.js";
@@ -25,12 +26,7 @@ function fenceCount(s: string): number {
25
26
  }
26
27
 
27
28
  describe("stream-controller enforces the wire cap on the post-escape body", () => {
28
- afterEach(() => {
29
- delete process.env.SWITCHROOM_RICH_RENDER;
30
- });
31
-
32
29
  it("REGRESSION: near-cap escapable first send never exceeds the plain wire cap", async () => {
33
- process.env.SWITCHROOM_RICH_RENDER = "1";
34
30
  const bot = createFakeBotApi({ startMessageId: 1000 });
35
31
  const unit = "a_b*c|d ";
36
32
  const body = unit.repeat(Math.floor((RICH_MESSAGE_MAX_CHARS - 20) / unit.length));
@@ -62,7 +58,6 @@ describe("stream-controller enforces the wire cap on the post-escape body", () =
62
58
  // callback. The pre-fix code re-sent all (N-1) tails as brand-new messages
63
59
  // on each edit tick, so `sent.length` grew by (N-1) every update. After the
64
60
  // fix, tails are parked once and edited in place — `sent.length` is flat.
65
- process.env.SWITCHROOM_RICH_RENDER = "1";
66
61
  const bot = createFakeBotApi({ startMessageId: 3000 });
67
62
  const unit = "a_b*c|d ";
68
63
  // Near-cap body that degrades to plain and splits into several pieces.
@@ -106,17 +101,22 @@ describe("stream-controller enforces the wire cap on the post-escape body", () =
106
101
  }
107
102
  }, 30000);
108
103
 
109
- it("flag OFF leaves the single-send path untouched", async () => {
110
- const bot = createFakeBotApi({ startMessageId: 2000 });
111
- const stream = createStreamController({
112
- bot: bot as unknown as Parameters<typeof createStreamController>[0]["bot"],
113
- chatId: "c1",
114
- throttleMs: 0,
115
- });
116
- await stream.update("**hi** _there_");
117
- await stream.finalize();
118
- expect(bot.state.sent).toHaveLength(1);
119
- expect(bot.state.sent[0].rich).toBe(true);
120
- expect(bot.state.sent[0].text).toBe("**hi** _there_");
104
+ it("kill-switch (=0) leaves the single-send path byte-for-byte untouched", async () => {
105
+ process.env.SWITCHROOM_RICH_RENDER = "0";
106
+ try {
107
+ const bot = createFakeBotApi({ startMessageId: 2000 });
108
+ const stream = createStreamController({
109
+ bot: bot as unknown as Parameters<typeof createStreamController>[0]["bot"],
110
+ chatId: "c1",
111
+ throttleMs: 0,
112
+ });
113
+ await stream.update("**hi** _there_");
114
+ await stream.finalize();
115
+ expect(bot.state.sent).toHaveLength(1);
116
+ expect(bot.state.sent[0].rich).toBe(true);
117
+ expect(bot.state.sent[0].text).toBe("**hi** _there_");
118
+ } finally {
119
+ delete process.env.SWITCHROOM_RICH_RENDER;
120
+ }
121
121
  });
122
122
  });
@@ -81,7 +81,7 @@ describe('handleStreamReply', () => {
81
81
  expect(state.activeDraftStreams.size).toBe(1)
82
82
  })
83
83
 
84
- it('a non-text format ships raw GFM markdown unescaped (no parse_mode)', async () => {
84
+ it('a non-text format ships GFM markdown unescaped (no parse_mode)', async () => {
85
85
  const state = makeState()
86
86
  const deps = makeDeps(bot)
87
87
 
@@ -94,7 +94,10 @@ describe('handleStreamReply', () => {
94
94
  await pending
95
95
 
96
96
  expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
97
- expect(richSendMarkdown(bot)).toBe('**hi** _there_')
97
+ // The default-on rich renderer (parse -> renderSafe) normalises the
98
+ // italic marker (`_there_` -> `*there*`, same wire entity) but the
99
+ // payload stays unescaped GFM markdown — no HTML, no MarkdownV2 escaping.
100
+ expect(richSendMarkdown(bot)).toBe('**hi** *there*')
98
101
  // No parse_mode on rich opts.
99
102
  expect(bot.api.sendRichMessage.mock.calls[0][2]?.parse_mode).toBeUndefined()
100
103
  })
@@ -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,290 @@
1
+ /**
2
+ * Tests for gateway/throttle-tier-wiring.ts — the 429 throttle tier's
3
+ * side-effect runner, driven with fully injected deps (no gateway import).
4
+ *
5
+ * Pins the OUTCOMES the wiring promises:
6
+ * - EVERY fire reaches the broker's mark-throttled (the escalation counter),
7
+ * even when the user-facing notice is cooldown-suppressed;
8
+ * - ONE notice per account per window, deduped locally AND fleet-wide via
9
+ * the broker claim verb (denied claim → no send; claim error → fail open);
10
+ * - the retry nudge is armed at throttled_until + slack + jitter, replaced
11
+ * (not stacked) by a newer throttle, and at FIRE time:
12
+ * - restarts when idle and the resume gate says 'resume';
13
+ * - DEFERS to the turn-complete drain when the in-flight gate is held
14
+ * by the dead throttled turn (never SIGTERM-now under a live turn);
15
+ * - SKIPS entirely when a NEWER turn (started after the throttle was
16
+ * armed) is live — restarting would kill live work and boot-resume
17
+ * would replay the WRONG turn;
18
+ * - the escalated outcome posts the corroborated-wall announcement (fleet-
19
+ * deduped) and nudges the resume immediately through the same guards;
20
+ * - a broker-unreachable fire degrades to notice-only without throwing.
21
+ */
22
+
23
+ import { describe, it, expect } from 'vitest'
24
+ import {
25
+ createThrottleTierRunner,
26
+ THROTTLE_RETRY_NUDGE_SLACK_MS,
27
+ type ThrottleBrokerClient,
28
+ type ThrottleTierRunnerDeps,
29
+ } from '../gateway/throttle-tier-wiring.js'
30
+
31
+ const NOW = Date.UTC(2026, 6, 12, 8, 0, 0)
32
+
33
+ interface Harness {
34
+ deps: ThrottleTierRunnerDeps
35
+ calls: {
36
+ markThrottled: number[]
37
+ claims: string[]
38
+ notices: Array<{ chatId: string | number; markdown: string }>
39
+ deferrals: string[]
40
+ restarts: string[]
41
+ logs: string[]
42
+ timers: Array<{ ms: number; fn: () => void; cancelled: boolean }>
43
+ }
44
+ clock: { set(ms: number): void }
45
+ fireTimer(idx: number): void
46
+ }
47
+
48
+ function makeHarness(opts: {
49
+ markThrottledResult?:
50
+ | { account: string; throttled_until: number; escalated: boolean; rolledTo?: string | null }
51
+ | 'unreachable'
52
+ | 'throw'
53
+ claimGranted?: (key: string) => boolean | 'throw'
54
+ resumeVerdict?: 'resume' | 'skip-inflight' | 'skip-stale'
55
+ turnInFlight?: () => boolean
56
+ newestTurnStartedAt?: () => number | null
57
+ jitterMs?: number
58
+ } = {}): Harness {
59
+ let nowMs = NOW
60
+ const calls: Harness['calls'] = {
61
+ markThrottled: [],
62
+ claims: [],
63
+ notices: [],
64
+ deferrals: [],
65
+ restarts: [],
66
+ logs: [],
67
+ timers: [],
68
+ }
69
+ const client: ThrottleBrokerClient = {
70
+ async markThrottled(until: number) {
71
+ calls.markThrottled.push(until)
72
+ if (opts.markThrottledResult === 'throw') throw new Error('boom')
73
+ const r = opts.markThrottledResult
74
+ if (r && r !== 'unreachable') return r
75
+ return { account: 'alice', throttled_until: until, escalated: false, rolledTo: null }
76
+ },
77
+ async claimNotification(key: string) {
78
+ calls.claims.push(key)
79
+ const g = opts.claimGranted?.(key) ?? true
80
+ if (g === 'throw') throw new Error('claim boom')
81
+ return { granted: g }
82
+ },
83
+ }
84
+ const deps: ThrottleTierRunnerDeps = {
85
+ agentName: 'carrie',
86
+ getBrokerClient: async () =>
87
+ opts.markThrottledResult === 'unreachable' ? null : client,
88
+ listNoticeChats: () => ['111', '222'],
89
+ sendNotice: (chatId, markdown) => calls.notices.push({ chatId, markdown }),
90
+ resumeDecide: () => opts.resumeVerdict ?? 'resume',
91
+ newestActiveTurnStartedAtMs: opts.newestTurnStartedAt ?? (() => null),
92
+ turnInFlight: opts.turnInFlight ?? (() => false),
93
+ deferRestartToTurnComplete: (_agent, reason) => calls.deferrals.push(reason),
94
+ restartNow: (_agent, reason) => calls.restarts.push(reason),
95
+ log: (m) => calls.logs.push(m),
96
+ now: () => nowMs,
97
+ schedule: (fn, ms) => {
98
+ const t = { ms, fn, cancelled: false }
99
+ calls.timers.push(t)
100
+ return { cancel: () => { t.cancelled = true } }
101
+ },
102
+ jitterMs: () => opts.jitterMs ?? 0,
103
+ }
104
+ return {
105
+ deps,
106
+ calls,
107
+ clock: { set: (ms) => { nowMs = ms } },
108
+ fireTimer(idx: number) {
109
+ const t = calls.timers[idx]
110
+ if (!t || t.cancelled) throw new Error('no live timer at idx ' + idx)
111
+ t.fn()
112
+ },
113
+ }
114
+ }
115
+
116
+ describe('throttle-tier runner — broker mark + notice dedup', () => {
117
+ it('every fire reaches markThrottled; the notice is sent ONCE per cooldown window', async () => {
118
+ const h = makeHarness()
119
+ const runner = createThrottleTierRunner(h.deps)
120
+ await runner.fire('carrie', NOW + 60_000, true)
121
+ h.clock.set(NOW + 30_000) // same window
122
+ await runner.fire('carrie', NOW + 90_000, true)
123
+
124
+ // Both hits reached the broker (the escalation counter needs them)…
125
+ expect(h.calls.markThrottled).toEqual([NOW + 60_000, NOW + 90_000])
126
+ // …but only ONE notice per chat went out.
127
+ expect(h.calls.notices).toHaveLength(2) // 2 chats × 1 notice
128
+ expect(h.calls.notices.map((n) => n.chatId)).toEqual(['111', '222'])
129
+ expect(h.calls.notices[0].markdown).toContain('alice')
130
+ expect(h.calls.notices[0].markdown).toContain('Rate-limited, staying put')
131
+ })
132
+
133
+ it('fleet-wide claim dedup: a denied claim suppresses that chat, an error fails open', async () => {
134
+ const h = makeHarness({
135
+ claimGranted: (key) => (key.endsWith(':111') ? false : 'throw'),
136
+ })
137
+ const runner = createThrottleTierRunner(h.deps)
138
+ await runner.fire('carrie', NOW + 60_000, true)
139
+
140
+ // chat 111 denied (another gateway won the claim), chat 222 errored → open.
141
+ expect(h.calls.claims).toEqual([
142
+ `throttle-notice:alice:111`,
143
+ `throttle-notice:alice:222`,
144
+ ])
145
+ expect(h.calls.notices.map((n) => n.chatId)).toEqual(['222'])
146
+ })
147
+
148
+ it('degrades to notice-only when the broker is unreachable (no throw, no claim)', async () => {
149
+ const h = makeHarness({ markThrottledResult: 'unreachable' })
150
+ const runner = createThrottleTierRunner(h.deps)
151
+ await runner.fire('carrie', NOW + 60_000, false)
152
+ expect(h.calls.markThrottled).toHaveLength(0)
153
+ expect(h.calls.claims).toHaveLength(0) // no account → no claim key
154
+ expect(h.calls.notices).toHaveLength(2)
155
+ expect(h.calls.notices[0].markdown).toContain('the active account')
156
+ })
157
+
158
+ it('a markThrottled throw is swallowed and the notice still goes out', async () => {
159
+ const h = makeHarness({ markThrottledResult: 'throw' })
160
+ const runner = createThrottleTierRunner(h.deps)
161
+ await expect(runner.fire('carrie', NOW + 60_000, true)).resolves.toBeUndefined()
162
+ expect(h.calls.notices).toHaveLength(2)
163
+ })
164
+ })
165
+
166
+ describe('throttle-tier runner — retry nudge scheduling + turn safety', () => {
167
+ it('arms the nudge at reset + slack + jitter and restarts when idle', async () => {
168
+ const h = makeHarness({ jitterMs: 7_000 })
169
+ const runner = createThrottleTierRunner(h.deps)
170
+ await runner.fire('carrie', NOW + 60_000, true)
171
+
172
+ expect(h.calls.timers).toHaveLength(1)
173
+ expect(h.calls.timers[0].ms).toBe(60_000 + THROTTLE_RETRY_NUDGE_SLACK_MS + 7_000)
174
+ expect(runner.inspect().nudgePending).toBe(true)
175
+
176
+ h.fireTimer(0)
177
+ expect(h.calls.restarts).toEqual(['throttle-retry-resume'])
178
+ expect(h.calls.deferrals).toHaveLength(0)
179
+ expect(runner.inspect().nudgePending).toBe(false)
180
+ })
181
+
182
+ it('a newer throttle REPLACES the pending nudge instead of stacking restarts', async () => {
183
+ const h = makeHarness()
184
+ const runner = createThrottleTierRunner(h.deps)
185
+ await runner.fire('carrie', NOW + 60_000, true)
186
+ await runner.fire('carrie', NOW + 120_000, true)
187
+ expect(h.calls.timers).toHaveLength(2)
188
+ expect(h.calls.timers[0].cancelled).toBe(true)
189
+ expect(h.calls.timers[1].cancelled).toBe(false)
190
+ })
191
+
192
+ it('DEFERS to the turn-complete drain when the dead throttled turn still holds the gate', async () => {
193
+ const h = makeHarness({
194
+ turnInFlight: () => true,
195
+ // The in-flight turn started BEFORE the throttle was armed — it IS the
196
+ // dead turn (a 429-killed turn never writes its turn-end marker).
197
+ newestTurnStartedAt: () => NOW - 60_000,
198
+ })
199
+ const runner = createThrottleTierRunner(h.deps)
200
+ await runner.fire('carrie', NOW + 60_000, true)
201
+ h.fireTimer(0)
202
+ expect(h.calls.restarts).toHaveLength(0) // never SIGTERM-now under a live gate
203
+ expect(h.calls.deferrals).toEqual(['throttle-retry-resume'])
204
+ })
205
+
206
+ it('SKIPS entirely when a NEWER live turn supersedes the dead one', async () => {
207
+ let nowAtNudge = NOW
208
+ const h = makeHarness({
209
+ turnInFlight: () => true,
210
+ // Started AFTER the throttle was armed — live user work.
211
+ newestTurnStartedAt: () => nowAtNudge + 30_000,
212
+ })
213
+ const runner = createThrottleTierRunner(h.deps)
214
+ await runner.fire('carrie', NOW + 60_000, true)
215
+ nowAtNudge = NOW // armedAt == NOW; newest = NOW+30s > armedAt
216
+ h.fireTimer(0)
217
+ expect(h.calls.restarts).toHaveLength(0)
218
+ expect(h.calls.deferrals).toHaveLength(0)
219
+ expect(h.calls.logs.some((l) => l.includes('superseded'))).toBe(true)
220
+ })
221
+
222
+ it('honours the shared resume gate verdict (skip-inflight → no restart)', async () => {
223
+ const h = makeHarness({ resumeVerdict: 'skip-inflight' })
224
+ const runner = createThrottleTierRunner(h.deps)
225
+ await runner.fire('carrie', NOW + 60_000, true)
226
+ h.fireTimer(0)
227
+ expect(h.calls.restarts).toHaveLength(0)
228
+ expect(h.calls.logs.some((l) => l.includes('skip-inflight'))).toBe(true)
229
+ })
230
+ })
231
+
232
+ describe('throttle-tier runner — escalated outcome (corroborated wall)', () => {
233
+ it('posts the fleet-deduped escalation announcement and resumes immediately', async () => {
234
+ const h = makeHarness({
235
+ markThrottledResult: {
236
+ account: 'alice',
237
+ throttled_until: NOW + 60_000,
238
+ escalated: true,
239
+ rolledTo: 'bob',
240
+ },
241
+ })
242
+ const runner = createThrottleTierRunner(h.deps)
243
+ await runner.fire('carrie', NOW + 60_000, true)
244
+
245
+ // Announcement (not the staying-put notice), per chat, claim-deduped.
246
+ expect(h.calls.claims).toEqual([
247
+ `throttle-escalation:alice:111`,
248
+ `throttle-escalation:alice:222`,
249
+ ])
250
+ expect(h.calls.notices).toHaveLength(2)
251
+ expect(h.calls.notices[0].markdown).toContain('actually a wall')
252
+ expect(h.calls.notices[0].markdown).toContain('bob')
253
+
254
+ // Immediate resume (fleet just swapped accounts) — no delayed nudge armed.
255
+ expect(h.calls.restarts).toEqual(['throttle-escalation-resume'])
256
+ expect(h.calls.timers).toHaveLength(0)
257
+ })
258
+
259
+ it('escalated with rolledTo=null (all blocked) announces but does NOT restart', async () => {
260
+ const h = makeHarness({
261
+ markThrottledResult: {
262
+ account: 'alice',
263
+ throttled_until: NOW + 60_000,
264
+ escalated: true,
265
+ rolledTo: null,
266
+ },
267
+ })
268
+ const runner = createThrottleTierRunner(h.deps)
269
+ await runner.fire('carrie', NOW + 60_000, true)
270
+ expect(h.calls.notices[0].markdown).toContain('all blocked')
271
+ expect(h.calls.restarts).toHaveLength(0)
272
+ })
273
+
274
+ it('escalated resume respects the live-turn guards too', async () => {
275
+ const h = makeHarness({
276
+ markThrottledResult: {
277
+ account: 'alice',
278
+ throttled_until: NOW + 60_000,
279
+ escalated: true,
280
+ rolledTo: 'bob',
281
+ },
282
+ turnInFlight: () => true,
283
+ newestTurnStartedAt: () => NOW - 60_000, // the dead turn holds the gate
284
+ })
285
+ const runner = createThrottleTierRunner(h.deps)
286
+ await runner.fire('carrie', NOW + 60_000, true)
287
+ expect(h.calls.restarts).toHaveLength(0)
288
+ expect(h.calls.deferrals).toEqual(['throttle-escalation-resume'])
289
+ })
290
+ })