switchroom 0.18.15 → 0.18.17

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 (40) hide show
  1. package/dist/agent-scheduler/index.js +3 -0
  2. package/dist/auth-broker/index.js +432 -10
  3. package/dist/cli/notion-write-pretool.mjs +3 -0
  4. package/dist/cli/switchroom.js +50 -1
  5. package/dist/host-control/main.js +4 -1
  6. package/dist/vault/approvals/kernel-server.js +3 -0
  7. package/dist/vault/broker/server.js +3 -0
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +81 -139
  10. package/telegram-plugin/dist/gateway/gateway.js +386 -259
  11. package/telegram-plugin/draft-stream.ts +78 -3
  12. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +3 -4
  13. package/telegram-plugin/gateway/effort-command.ts +9 -7
  14. package/telegram-plugin/gateway/gateway.ts +265 -220
  15. package/telegram-plugin/gateway/litellm-local-notice-wiring.ts +200 -0
  16. package/telegram-plugin/gateway/model-command.ts +96 -18
  17. package/telegram-plugin/gateway/pending-session-command.ts +10 -8
  18. package/telegram-plugin/gateway/session-model-file.ts +38 -172
  19. package/telegram-plugin/litellm-local-notice.ts +189 -0
  20. package/telegram-plugin/quota-watch.ts +16 -4
  21. package/telegram-plugin/runtime-metrics.ts +16 -0
  22. package/telegram-plugin/send-gate-degraded.test.ts +9 -7
  23. package/telegram-plugin/send-gate.ts +34 -4
  24. package/telegram-plugin/stream-controller.ts +143 -20
  25. package/telegram-plugin/stream-reply-handler.ts +12 -2
  26. package/telegram-plugin/tests/bot-api.harness.ts +7 -2
  27. package/telegram-plugin/tests/draft-stream.test.ts +110 -1
  28. package/telegram-plugin/tests/effort-command.test.ts +4 -4
  29. package/telegram-plugin/tests/flood-windows-persistence.test.ts +2 -2
  30. package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +33 -19
  31. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +47 -127
  32. package/telegram-plugin/tests/litellm-local-notice.test.ts +417 -0
  33. package/telegram-plugin/tests/model-command.test.ts +84 -1
  34. package/telegram-plugin/tests/quota-watch.test.ts +21 -0
  35. package/telegram-plugin/tests/reaction-gate-routing.test.ts +2 -2
  36. package/telegram-plugin/tests/session-model-file.test.ts +7 -155
  37. package/telegram-plugin/tests/stream-controller-send-gate.test.ts +521 -0
  38. package/telegram-plugin/tests/stream-reply-handler.test.ts +44 -0
  39. package/telegram-plugin/tests/worker-activity-feed.test.ts +207 -0
  40. package/telegram-plugin/worker-activity-feed.ts +83 -8
@@ -1,10 +1,12 @@
1
1
  /**
2
- * Durable session-model file helpers (session-model-file.ts) — the gateway
3
- * side of the stickiness contract (reference/rfcs/session-model-stickiness.md).
2
+ * Session-model file helpers (session-model-file.ts) — the gateway side of the
3
+ * session-scoped /model contract (reference/rfcs/session-model-stickiness.md
4
+ * §0.1, rev 4 — consume-once). The `.relaunch-model-intent` subsystem and the
5
+ * crashloop counter were retired with rev 4; their tests are gone.
4
6
  */
5
7
 
6
8
  import { describe, it, expect, beforeEach, afterEach } from 'vitest'
7
- import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
9
+ import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'
8
10
  import { join } from 'node:path'
9
11
  import { tmpdir } from 'node:os'
10
12
  import {
@@ -15,17 +17,8 @@ import {
15
17
  readSessionModelFileRaw,
16
18
  restoreSessionModelFileRaw,
17
19
  clearSessionModelFile,
18
- clearSessionModelBootAttempts,
19
- SESSION_MODEL_BOOT_ATTEMPTS_FILE,
20
- writeRelaunchModelIntent,
21
- clearRelaunchModelIntent,
22
20
  readConfiguredDefaultModel,
23
- intentForRestartReason,
24
- readRelaunchModelIntent,
25
- clearStaleGatewayShutdownIntent,
26
- GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX,
27
21
  SESSION_MODEL_FILE,
28
- RELAUNCH_MODEL_INTENT_FILE,
29
22
  CONFIGURED_DEFAULT_MODEL_FILE,
30
23
  parseSessionEffort,
31
24
  writeSessionEffortFile,
@@ -87,86 +80,6 @@ describe('rollback snapshot (scheduleModelRelaunch dispatch failure)', () => {
87
80
  })
88
81
  })
89
82
 
90
- describe('relaunch intent', () => {
91
- it('writes one-line JSON with intent, reason, and embedded ts (the freshness clock)', () => {
92
- writeRelaunchModelIntent(dir, 'keep', 'user: /new from chat')
93
- const raw = readFileSync(join(dir, RELAUNCH_MODEL_INTENT_FILE), 'utf8')
94
- const parsed = JSON.parse(raw)
95
- expect(parsed.intent).toBe('keep')
96
- expect(parsed.reason).toBe('user: /new from chat')
97
- expect(Math.abs(Date.now() - parsed.ts)).toBeLessThan(5000)
98
- })
99
-
100
- it('last-writer-wins and clearable', () => {
101
- writeRelaunchModelIntent(dir, 'keep', 'a')
102
- writeRelaunchModelIntent(dir, 'revert', 'b')
103
- expect(JSON.parse(readFileSync(join(dir, RELAUNCH_MODEL_INTENT_FILE), 'utf8')).intent).toBe('revert')
104
- clearRelaunchModelIntent(dir)
105
- expect(existsSync(join(dir, RELAUNCH_MODEL_INTENT_FILE))).toBe(false)
106
- })
107
- })
108
-
109
- describe('intentForRestartReason — the triggerSelfRestart per-reason table (RFC §3)', () => {
110
- it.each([
111
- 'schedule-restart-immediate',
112
- 'restart-drain-cap-forced',
113
- 'turn-complete-pending-restart',
114
- 'fleet-fallback-resume',
115
- 'sr-to-claude-model-switch',
116
- ])('switchroom-managed relaunch %s → keep', (reason) => {
117
- expect(intentForRestartReason(reason)).toBe('keep')
118
- })
119
-
120
- it('inline-button-restart → keep (#3039: a restart is not "clear my model")', () => {
121
- expect(intentForRestartReason('inline-button-restart')).toBe('keep')
122
- })
123
-
124
- it('unknown gateway reasons default to keep (only gateway code calls triggerSelfRestart; crashes never do)', () => {
125
- expect(intentForRestartReason('some-future-recovery-path')).toBe('keep')
126
- })
127
- })
128
-
129
- describe('readRelaunchModelIntent', () => {
130
- it('round-trips a stamped intent; null when absent / corrupt / malformed', () => {
131
- expect(readRelaunchModelIntent(dir)).toBeNull()
132
- writeRelaunchModelIntent(dir, 'keep', 'watchdog recovery')
133
- const rec = readRelaunchModelIntent(dir)!
134
- expect(rec.intent).toBe('keep')
135
- expect(rec.reason).toBe('watchdog recovery')
136
- expect(Math.abs(Date.now() - rec.ts)).toBeLessThan(5000)
137
- writeFileSync(join(dir, RELAUNCH_MODEL_INTENT_FILE), '{broken')
138
- expect(readRelaunchModelIntent(dir)).toBeNull()
139
- writeFileSync(join(dir, RELAUNCH_MODEL_INTENT_FILE), '{"intent":"maybe","reason":"x","ts":1}\n')
140
- expect(readRelaunchModelIntent(dir)).toBeNull()
141
- })
142
- })
143
-
144
- describe('clearStaleGatewayShutdownIntent (#3018 finding 4 — a gateway-only bounce must not leave a keep stamp)', () => {
145
- it('clears ONLY a gateway-shutdown-stamped intent and reports it', () => {
146
- writeRelaunchModelIntent(
147
- dir,
148
- 'keep',
149
- `${GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX} graceful SIGTERM shutdown (deploy/rolling restart) — preserving user-chosen session model`,
150
- )
151
- expect(clearStaleGatewayShutdownIntent(dir)).toBe(true)
152
- expect(existsSync(join(dir, RELAUNCH_MODEL_INTENT_FILE))).toBe(false)
153
- // Idempotent: a second call finds nothing.
154
- expect(clearStaleGatewayShutdownIntent(dir)).toBe(false)
155
- })
156
-
157
- it('never touches a triggerSelfRestart / user-slash stamp (un-prefixed reason)', () => {
158
- writeRelaunchModelIntent(dir, 'keep', 'sr-to-claude-model-switch')
159
- expect(clearStaleGatewayShutdownIntent(dir)).toBe(false)
160
- expect(readRelaunchModelIntent(dir)!.reason).toBe('sr-to-claude-model-switch')
161
- })
162
-
163
- it('is a safe no-op on an absent or corrupt intent file', () => {
164
- expect(clearStaleGatewayShutdownIntent(dir)).toBe(false)
165
- writeFileSync(join(dir, RELAUNCH_MODEL_INTENT_FILE), 'not json')
166
- expect(clearStaleGatewayShutdownIntent(dir)).toBe(false)
167
- })
168
- })
169
-
170
83
  describe('readConfiguredDefaultModel', () => {
171
84
  it('reads the trimmed value; null when absent or empty', () => {
172
85
  expect(readConfiguredDefaultModel(dir)).toBeNull()
@@ -181,9 +94,9 @@ describe('readConfiguredDefaultModel', () => {
181
94
  })
182
95
  })
183
96
 
184
- // ─── #3039: durable session-effort carrier ───────────────────────────────────
97
+ // ─── #3186: consume-once session-effort carrier (queued-command boot apply) ──
185
98
 
186
- describe('session-effort file helpers (#3039)', () => {
99
+ describe('session-effort file helpers (#3186)', () => {
187
100
  let dir: string
188
101
  beforeEach(() => {
189
102
  dir = mkdtempSync(join(tmpdir(), 'sr-session-effort-'))
@@ -218,64 +131,3 @@ describe('session-effort file helpers (#3039)', () => {
218
131
  expect(readSessionEffortFile(dir)).toBeNull()
219
132
  })
220
133
  })
221
-
222
- describe('intentForRestartReason is keep-for-everything (#3039)', () => {
223
- it('every reason keeps — restarts never clear a user model choice', () => {
224
- for (const reason of [
225
- 'inline-button-restart',
226
- 'user: /restart from chat',
227
- 'schedule-restart-immediate',
228
- 'anything-else',
229
- ]) {
230
- expect(intentForRestartReason(reason)).toBe('keep')
231
- }
232
- })
233
- })
234
-
235
- describe('clearSessionModelBootAttempts (#3043 item 2: bridge-register clears the crashloop counter)', () => {
236
- const bootAttemptsPath = () => join(dir, SESSION_MODEL_BOOT_ATTEMPTS_FILE)
237
-
238
- // Faithful model of start.sh.hbs "Override crashloop self-heal": each boot
239
- // within 150s of the previous stamp bumps the count; a stale stamp resets to
240
- // 1. Reproduced here so the accumulate-vs-reset OUTCOME is asserted, not just
241
- // the delete call.
242
- function simulateBootStamp(nowSec: number): number {
243
- let count = 0
244
- let prev = 0
245
- if (existsSync(bootAttemptsPath())) {
246
- const [c, p] = readFileSync(bootAttemptsPath(), 'utf8').trim().split(/\s+/)
247
- count = Number(c) || 0
248
- prev = Number(p) || 0
249
- }
250
- count = nowSec - prev < 150 ? count + 1 : 1
251
- writeFileSync(bootAttemptsPath(), `${count} ${nowSec}\n`)
252
- return count
253
- }
254
-
255
- it('WITHOUT a bridge register, three fast boots accumulate toward the 3-strike clear', () => {
256
- // Three operator hand-bounces, each <150s apart, with no register between.
257
- expect(simulateBootStamp(1000)).toBe(1)
258
- expect(simulateBootStamp(1010)).toBe(2)
259
- expect(simulateBootStamp(1020)).toBe(3) // start.sh would now clear a HEALTHY override — the false positive
260
- })
261
-
262
- it('a bridge register between boots resets the counter, so a healthy agent never reaches 3', () => {
263
- expect(simulateBootStamp(1000)).toBe(1)
264
- // Boot 1 came all the way up and the bridge registered → gateway clears it.
265
- clearSessionModelBootAttempts(dir)
266
- expect(existsSync(bootAttemptsPath())).toBe(false)
267
-
268
- // Next fast boot starts fresh at 1 (no accumulation), and register clears again.
269
- expect(simulateBootStamp(1010)).toBe(1)
270
- clearSessionModelBootAttempts(dir)
271
- expect(simulateBootStamp(1020)).toBe(1)
272
- // The healthy agent's counter never climbs to the 3-strike clear.
273
- const [count] = readFileSync(bootAttemptsPath(), 'utf8').trim().split(/\s+/)
274
- expect(Number(count)).toBeLessThan(3)
275
- })
276
-
277
- it('is best-effort — no throw when the counter file is absent', () => {
278
- expect(existsSync(bootAttemptsPath())).toBe(false)
279
- expect(() => clearSessionModelBootAttempts(dir)).not.toThrow()
280
- })
281
- })