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.
- package/dist/agent-scheduler/index.js +3 -0
- package/dist/auth-broker/index.js +432 -10
- package/dist/cli/notion-write-pretool.mjs +3 -0
- package/dist/cli/switchroom.js +50 -1
- package/dist/host-control/main.js +4 -1
- package/dist/vault/approvals/kernel-server.js +3 -0
- package/dist/vault/broker/server.js +3 -0
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +81 -139
- package/telegram-plugin/dist/gateway/gateway.js +386 -259
- package/telegram-plugin/draft-stream.ts +78 -3
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +3 -4
- package/telegram-plugin/gateway/effort-command.ts +9 -7
- package/telegram-plugin/gateway/gateway.ts +265 -220
- package/telegram-plugin/gateway/litellm-local-notice-wiring.ts +200 -0
- package/telegram-plugin/gateway/model-command.ts +96 -18
- package/telegram-plugin/gateway/pending-session-command.ts +10 -8
- package/telegram-plugin/gateway/session-model-file.ts +38 -172
- package/telegram-plugin/litellm-local-notice.ts +189 -0
- package/telegram-plugin/quota-watch.ts +16 -4
- package/telegram-plugin/runtime-metrics.ts +16 -0
- package/telegram-plugin/send-gate-degraded.test.ts +9 -7
- package/telegram-plugin/send-gate.ts +34 -4
- package/telegram-plugin/stream-controller.ts +143 -20
- package/telegram-plugin/stream-reply-handler.ts +12 -2
- package/telegram-plugin/tests/bot-api.harness.ts +7 -2
- package/telegram-plugin/tests/draft-stream.test.ts +110 -1
- package/telegram-plugin/tests/effort-command.test.ts +4 -4
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +2 -2
- package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +33 -19
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +47 -127
- package/telegram-plugin/tests/litellm-local-notice.test.ts +417 -0
- package/telegram-plugin/tests/model-command.test.ts +84 -1
- package/telegram-plugin/tests/quota-watch.test.ts +21 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +2 -2
- package/telegram-plugin/tests/session-model-file.test.ts +7 -155
- package/telegram-plugin/tests/stream-controller-send-gate.test.ts +521 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +44 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +207 -0
- package/telegram-plugin/worker-activity-feed.ts +83 -8
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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,
|
|
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
|
-
// ─── #
|
|
97
|
+
// ─── #3186: consume-once session-effort carrier (queued-command boot apply) ──
|
|
185
98
|
|
|
186
|
-
describe('session-effort file helpers (#
|
|
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
|
-
})
|