switchroom 0.18.7 โ 0.18.8
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/cli/switchroom.js +905 -758
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +111 -34
- package/skills/switchroom-runtime/SKILL.md +2 -0
- package/telegram-plugin/dist/gateway/gateway.js +1403 -657
- package/telegram-plugin/flood-circuit-breaker.ts +123 -0
- package/telegram-plugin/gateway/activity-card-store.ts +63 -18
- package/telegram-plugin/gateway/boot-card.ts +27 -0
- package/telegram-plugin/gateway/busy-ack.ts +106 -0
- package/telegram-plugin/gateway/gateway.ts +564 -85
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
- package/telegram-plugin/gateway/model-command.ts +23 -11
- package/telegram-plugin/gateway/session-model-file.ts +198 -0
- package/telegram-plugin/gateway/status-pin-store.ts +82 -22
- package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
- package/telegram-plugin/hooks/hooks.json +10 -10
- package/telegram-plugin/hooks/run-hook.sh +84 -0
- package/telegram-plugin/model-unavailable.ts +26 -0
- package/telegram-plugin/pty-partial-handler.ts +39 -0
- package/telegram-plugin/render/rich-render.ts +79 -1
- package/telegram-plugin/retry-api-call.ts +62 -0
- package/telegram-plugin/shared/bot-runtime.ts +8 -1
- package/telegram-plugin/silence-poke.ts +14 -0
- package/telegram-plugin/stream-controller.ts +156 -38
- package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
- package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
- package/telegram-plugin/tests/busy-ack.test.ts +121 -0
- package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
- package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
- package/telegram-plugin/tests/model-command.test.ts +2 -2
- package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
- package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
- package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
- package/telegram-plugin/tests/session-model-file.test.ts +132 -0
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
- package/telegram-plugin/tests/voice-send.test.ts +308 -0
- package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
- package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
- package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
- package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
- package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
- package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
- package/telegram-plugin/voice-ondemand.ts +25 -1
- package/telegram-plugin/voice-send.ts +154 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram file_id reuse for the on-demand '๐ Listen' send path.
|
|
3
|
+
*
|
|
4
|
+
* gateway.ts is a 25k-line module with import-time side effects, so the send
|
|
5
|
+
* decision core lives in ../voice-send.ts (imported by the gateway). These
|
|
6
|
+
* tests drive the real orchestrator + cache primitive with a mocked bot api,
|
|
7
|
+
* proving the observable contract the gateway wires up:
|
|
8
|
+
*
|
|
9
|
+
* (a) FIRST tap (no stored id) uploads via InputFile and captures the
|
|
10
|
+
* returned file_id onto the cache entry;
|
|
11
|
+
* (b) SECOND tap sends BY the file_id string โ no disk read, no InputFile;
|
|
12
|
+
* (c) a file_id send REJECTED as stale (400) falls back to a re-upload and
|
|
13
|
+
* refreshes the stored id, so a dead id never permanently breaks Listen.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect } from 'bun:test'
|
|
17
|
+
import {
|
|
18
|
+
sendVoiceReusingFileId,
|
|
19
|
+
extractVoiceFileId,
|
|
20
|
+
isInvalidTelegramFileIdError,
|
|
21
|
+
isHttp400Error,
|
|
22
|
+
type SentVoiceMessage,
|
|
23
|
+
} from '../voice-send.js'
|
|
24
|
+
import { VoiceOnDemandCache } from '../voice-ondemand.js'
|
|
25
|
+
|
|
26
|
+
/** A minimal grammy-shaped 400 error (duck-typed the way the gateway sees it). */
|
|
27
|
+
function grammy400(description: string): Error {
|
|
28
|
+
return Object.assign(new Error(description), { error_code: 400, description })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Sent-message stub echoing a file_id, as Telegram returns from sendVoice. */
|
|
32
|
+
function sentWithFileId(fileId: string): SentVoiceMessage {
|
|
33
|
+
return { voice: { file_id: fileId } }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const OGG = new Uint8Array([0x4f, 0x67, 0x67, 0x53]) // OggS magic
|
|
37
|
+
|
|
38
|
+
describe('sendVoiceReusingFileId โ file_id reuse contract', () => {
|
|
39
|
+
it('(a) first tap: no stored id โ uploads via InputFile, captures the id', async () => {
|
|
40
|
+
const calls: string[] = []
|
|
41
|
+
let byFileId = 0
|
|
42
|
+
let uploaded = 0
|
|
43
|
+
let captured: string | null = null
|
|
44
|
+
|
|
45
|
+
const res = await sendVoiceReusingFileId({
|
|
46
|
+
fileId: null,
|
|
47
|
+
sendByFileId: async () => {
|
|
48
|
+
byFileId++
|
|
49
|
+
return null
|
|
50
|
+
},
|
|
51
|
+
loadAudio: async () => {
|
|
52
|
+
calls.push('loadAudio')
|
|
53
|
+
return OGG
|
|
54
|
+
},
|
|
55
|
+
sendByUpload: async (audio) => {
|
|
56
|
+
uploaded++
|
|
57
|
+
expect(audio).toBe(OGG)
|
|
58
|
+
return sentWithFileId('FILE_ID_1')
|
|
59
|
+
},
|
|
60
|
+
onFileId: (id) => {
|
|
61
|
+
captured = id
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
expect(res).toEqual({ ok: true, path: 'upload', refreshed: false })
|
|
66
|
+
expect(byFileId).toBe(0) // never tried the id path โ none stored
|
|
67
|
+
expect(uploaded).toBe(1) // exactly one upload
|
|
68
|
+
expect(calls).toEqual(['loadAudio']) // audio WAS loaded on the first tap
|
|
69
|
+
expect(captured).toBe('FILE_ID_1') // id captured for reuse
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('(b) second tap: stored id โ sends by file_id string, no disk read, no InputFile', async () => {
|
|
73
|
+
let loadAudioCalls = 0
|
|
74
|
+
let uploadCalls = 0
|
|
75
|
+
let sentId: string | null = null
|
|
76
|
+
|
|
77
|
+
const res = await sendVoiceReusingFileId({
|
|
78
|
+
fileId: 'FILE_ID_1',
|
|
79
|
+
sendByFileId: async (fid) => {
|
|
80
|
+
sentId = fid
|
|
81
|
+
return sentWithFileId('FILE_ID_1') // Telegram echoes the same id
|
|
82
|
+
},
|
|
83
|
+
loadAudio: async () => {
|
|
84
|
+
loadAudioCalls++ // MUST NOT happen โ proves no disk read
|
|
85
|
+
return OGG
|
|
86
|
+
},
|
|
87
|
+
sendByUpload: async () => {
|
|
88
|
+
uploadCalls++ // MUST NOT happen โ proves no re-upload
|
|
89
|
+
return null
|
|
90
|
+
},
|
|
91
|
+
onFileId: () => {},
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
expect(res).toEqual({ ok: true, path: 'file_id', refreshed: false })
|
|
95
|
+
expect(sentId).toBe('FILE_ID_1') // sent BY the string id
|
|
96
|
+
expect(loadAudioCalls).toBe(0) // no disk read
|
|
97
|
+
expect(uploadCalls).toBe(0) // no InputFile upload
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('(c) stale id (400): falls back to re-upload and refreshes the stored id', async () => {
|
|
101
|
+
let byFileId = 0
|
|
102
|
+
let uploaded = 0
|
|
103
|
+
let refreshedTo: string | null = null
|
|
104
|
+
|
|
105
|
+
const res = await sendVoiceReusingFileId({
|
|
106
|
+
fileId: 'STALE_ID',
|
|
107
|
+
sendByFileId: async () => {
|
|
108
|
+
byFileId++
|
|
109
|
+
throw grammy400('Bad Request: wrong file identifier/HTTP URL specified')
|
|
110
|
+
},
|
|
111
|
+
loadAudio: async () => OGG,
|
|
112
|
+
sendByUpload: async () => {
|
|
113
|
+
uploaded++
|
|
114
|
+
return sentWithFileId('FRESH_ID')
|
|
115
|
+
},
|
|
116
|
+
onFileId: (id) => {
|
|
117
|
+
refreshedTo = id
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
expect(res).toEqual({ ok: true, path: 'upload', refreshed: true })
|
|
122
|
+
expect(byFileId).toBe(1) // tried the stale id once
|
|
123
|
+
expect(uploaded).toBe(1) // then re-uploaded
|
|
124
|
+
expect(refreshedTo).toBe('FRESH_ID') // stored id refreshed, not left stale
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('a NON-stale send error is a real failure โ no silent re-upload', async () => {
|
|
128
|
+
let uploaded = 0
|
|
129
|
+
const res = await sendVoiceReusingFileId({
|
|
130
|
+
fileId: 'FILE_ID_1',
|
|
131
|
+
sendByFileId: async () => {
|
|
132
|
+
throw Object.assign(new Error('forbidden'), { error_code: 403 })
|
|
133
|
+
},
|
|
134
|
+
loadAudio: async () => OGG,
|
|
135
|
+
sendByUpload: async () => {
|
|
136
|
+
uploaded++
|
|
137
|
+
return null
|
|
138
|
+
},
|
|
139
|
+
onFileId: () => {},
|
|
140
|
+
})
|
|
141
|
+
expect(res.ok).toBe(false)
|
|
142
|
+
expect(uploaded).toBe(0) // did NOT paper over a 403 with an upload
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('(C1) UNKNOWN-wording 400: re-uploads + refreshes id even outside the stale-id substrings', async () => {
|
|
146
|
+
// A 400 whose text is NOT in isInvalidTelegramFileIdError's substring set.
|
|
147
|
+
// Pre-tweak (substring-gated) this classified as send-failed and never
|
|
148
|
+
// refreshed the id; post-tweak ANY 400 recovers via re-upload.
|
|
149
|
+
let byFileId = 0
|
|
150
|
+
let uploaded = 0
|
|
151
|
+
let refreshedTo: string | null = null
|
|
152
|
+
|
|
153
|
+
const err400 = grammy400('Bad Request: some brand-new file rejection wording')
|
|
154
|
+
expect(isInvalidTelegramFileIdError(err400)).toBe(false) // guard: outside old substrings
|
|
155
|
+
expect(isHttp400Error(err400)).toBe(true) // but IS a 400 โ now re-uploadable
|
|
156
|
+
|
|
157
|
+
const res = await sendVoiceReusingFileId({
|
|
158
|
+
fileId: 'STALE_ID',
|
|
159
|
+
sendByFileId: async () => {
|
|
160
|
+
byFileId++
|
|
161
|
+
throw err400
|
|
162
|
+
},
|
|
163
|
+
loadAudio: async () => OGG,
|
|
164
|
+
sendByUpload: async () => {
|
|
165
|
+
uploaded++
|
|
166
|
+
return sentWithFileId('FRESH_ID')
|
|
167
|
+
},
|
|
168
|
+
onFileId: (id) => {
|
|
169
|
+
refreshedTo = id
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
expect(res).toEqual({ ok: true, path: 'upload', refreshed: true })
|
|
174
|
+
expect(byFileId).toBe(1) // tried the id once
|
|
175
|
+
expect(uploaded).toBe(1) // then re-uploaded on the unknown 400
|
|
176
|
+
expect(refreshedTo).toBe('FRESH_ID') // stored id refreshed, not left stale
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('(C1) a network error (no error_code) still does NOT re-upload', async () => {
|
|
180
|
+
let uploaded = 0
|
|
181
|
+
const res = await sendVoiceReusingFileId({
|
|
182
|
+
fileId: 'FILE_ID_1',
|
|
183
|
+
sendByFileId: async () => {
|
|
184
|
+
throw new Error('ECONNRESET') // no error_code โ not a 400
|
|
185
|
+
},
|
|
186
|
+
loadAudio: async () => OGG,
|
|
187
|
+
sendByUpload: async () => {
|
|
188
|
+
uploaded++
|
|
189
|
+
return null
|
|
190
|
+
},
|
|
191
|
+
onFileId: () => {},
|
|
192
|
+
})
|
|
193
|
+
expect(res.ok).toBe(false)
|
|
194
|
+
expect(uploaded).toBe(0) // transient network error is a real failure, not a re-upload
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('(C1) re-upload after a 400 ALSO fails โ returns failure, no second re-upload', async () => {
|
|
198
|
+
let byFileId = 0
|
|
199
|
+
let uploaded = 0
|
|
200
|
+
let refreshed: string | null = null
|
|
201
|
+
|
|
202
|
+
const res = await sendVoiceReusingFileId({
|
|
203
|
+
fileId: 'STALE_ID',
|
|
204
|
+
sendByFileId: async () => {
|
|
205
|
+
byFileId++
|
|
206
|
+
throw grammy400('Bad Request: totally unfamiliar 400 wording')
|
|
207
|
+
},
|
|
208
|
+
loadAudio: async () => OGG,
|
|
209
|
+
sendByUpload: async () => {
|
|
210
|
+
uploaded++
|
|
211
|
+
throw grammy400('Bad Request: upload also rejected') // recovery send fails too
|
|
212
|
+
},
|
|
213
|
+
onFileId: (id) => {
|
|
214
|
+
refreshed = id
|
|
215
|
+
},
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
expect(res.ok).toBe(false)
|
|
219
|
+
if (!res.ok) expect(res.reason).toBe('send-failed')
|
|
220
|
+
expect(byFileId).toBe(1) // id tried once
|
|
221
|
+
expect(uploaded).toBe(1) // re-uploaded exactly once โ NO second attempt / loop
|
|
222
|
+
expect(refreshed).toBeNull() // failed upload never refreshed the id
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('no-audio on the upload path returns { ok:false, reason:"no-audio" }', async () => {
|
|
226
|
+
const res = await sendVoiceReusingFileId({
|
|
227
|
+
fileId: null,
|
|
228
|
+
sendByFileId: async () => null,
|
|
229
|
+
loadAudio: async () => null, // swept + synth unavailable
|
|
230
|
+
sendByUpload: async () => {
|
|
231
|
+
throw new Error('should not be called')
|
|
232
|
+
},
|
|
233
|
+
onFileId: () => {},
|
|
234
|
+
})
|
|
235
|
+
expect(res).toEqual({ ok: false, reason: 'no-audio' })
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
describe('extractVoiceFileId / isInvalidTelegramFileIdError', () => {
|
|
240
|
+
it('extracts a present file_id, ignores missing/empty', () => {
|
|
241
|
+
expect(extractVoiceFileId({ voice: { file_id: 'abc' } })).toBe('abc')
|
|
242
|
+
expect(extractVoiceFileId({ voice: {} })).toBeUndefined()
|
|
243
|
+
expect(extractVoiceFileId({ voice: { file_id: '' } })).toBeUndefined()
|
|
244
|
+
expect(extractVoiceFileId(null)).toBeUndefined()
|
|
245
|
+
expect(extractVoiceFileId(undefined)).toBeUndefined()
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('classifies a 400 wrong-file-identifier as stale, others as not', () => {
|
|
249
|
+
expect(
|
|
250
|
+
isInvalidTelegramFileIdError(grammy400('Bad Request: wrong file identifier/HTTP URL specified')),
|
|
251
|
+
).toBe(true)
|
|
252
|
+
expect(isInvalidTelegramFileIdError(grammy400('Bad Request: wrong remote file identifier'))).toBe(
|
|
253
|
+
true,
|
|
254
|
+
)
|
|
255
|
+
// Not a file_id problem โ genuine failures the caller must surface.
|
|
256
|
+
expect(isInvalidTelegramFileIdError(grammy400('Bad Request: chat not found'))).toBe(false)
|
|
257
|
+
expect(
|
|
258
|
+
isInvalidTelegramFileIdError(Object.assign(new Error('forbidden'), { error_code: 403 })),
|
|
259
|
+
).toBe(false)
|
|
260
|
+
expect(isInvalidTelegramFileIdError(new Error('network'))).toBe(false)
|
|
261
|
+
expect(isInvalidTelegramFileIdError(null)).toBe(false)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('isHttp400Error: true only for error_code 400, wording-agnostic', () => {
|
|
265
|
+
expect(isHttp400Error(grammy400('any wording at all'))).toBe(true)
|
|
266
|
+
expect(isHttp400Error(grammy400('Bad Request: chat not found'))).toBe(true) // 400 regardless of text
|
|
267
|
+
expect(isHttp400Error(Object.assign(new Error('x'), { error_code: 403 }))).toBe(false)
|
|
268
|
+
expect(isHttp400Error(Object.assign(new Error('x'), { error_code: 500 }))).toBe(false)
|
|
269
|
+
expect(isHttp400Error(new Error('network'))).toBe(false) // no error_code
|
|
270
|
+
expect(isHttp400Error(null)).toBe(false)
|
|
271
|
+
expect(isHttp400Error(undefined)).toBe(false)
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
describe('VoiceOnDemandCache.setTelegramFileId โ capture + sweep drop', () => {
|
|
276
|
+
it('stores an id on a live entry and returns it via get()', () => {
|
|
277
|
+
const cache = new VoiceOnDemandCache()
|
|
278
|
+
cache.put('tok', { text: 'hi', speed: 1.1 })
|
|
279
|
+
cache.setTelegramFileId('tok', 'FILE_ID_1')
|
|
280
|
+
expect(cache.get('tok')?.telegramFileId).toBe('FILE_ID_1')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('ignores an empty id and unknown/evicted tokens', () => {
|
|
284
|
+
const cache = new VoiceOnDemandCache()
|
|
285
|
+
cache.put('tok', { text: 'hi', speed: 1.1 })
|
|
286
|
+
cache.setTelegramFileId('tok', '') // ignored
|
|
287
|
+
expect(cache.get('tok')?.telegramFileId).toBeUndefined()
|
|
288
|
+
cache.setTelegramFileId('missing', 'X') // no-op, no throw
|
|
289
|
+
expect(cache.get('missing')).toBeNull()
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('drops the stored id when the 7-day sweep prunes the entry โ no resurrection', () => {
|
|
293
|
+
const cache = new VoiceOnDemandCache()
|
|
294
|
+
cache.put('tok', { text: 'hi', speed: 1.1 })
|
|
295
|
+
cache.setTelegramFileId('tok', 'FILE_ID_1')
|
|
296
|
+
cache.prune(['tok']) // sweep deleted the on-disk file for this token
|
|
297
|
+
expect(cache.get('tok')).toBeNull() // entry AND its file_id are gone
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('does not store an id on an already-expired entry', () => {
|
|
301
|
+
let t = 1000
|
|
302
|
+
const cache = new VoiceOnDemandCache({ ttlMs: 100, now: () => t })
|
|
303
|
+
cache.put('tok', { text: 'hi', speed: 1.1 })
|
|
304
|
+
t = 2000 // past TTL
|
|
305
|
+
cache.setTelegramFileId('tok', 'FILE_ID_1') // no-op on expired entry
|
|
306
|
+
expect(cache.get('tok')).toBeNull()
|
|
307
|
+
})
|
|
308
|
+
})
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
decideWorkerPinReaps,
|
|
4
|
+
workerAgentIdOfPinKey,
|
|
5
|
+
WORKER_PIN_TTL_MS_DEFAULT,
|
|
6
|
+
type WorkerPinCandidate,
|
|
7
|
+
} from "../gateway/worker-pin-reaper.js";
|
|
8
|
+
|
|
9
|
+
const NOW = 1_750_000_000_000;
|
|
10
|
+
const TTL = WORKER_PIN_TTL_MS_DEFAULT;
|
|
11
|
+
|
|
12
|
+
function pin(over: Partial<WorkerPinCandidate> = {}): WorkerPinCandidate {
|
|
13
|
+
return { pinKey: "wk:agent-1", chatId: "123", pinnedAt: NOW - 60_000, ...over };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("workerAgentIdOfPinKey", () => {
|
|
17
|
+
it("extracts the agentId from a wk: key", () => {
|
|
18
|
+
expect(workerAgentIdOfPinKey("wk:abc123")).toBe("abc123");
|
|
19
|
+
});
|
|
20
|
+
it("returns null for non-worker keys (fg:/banner:/tool:) and empty ids", () => {
|
|
21
|
+
expect(workerAgentIdOfPinKey("fg:123:5")).toBeNull();
|
|
22
|
+
expect(workerAgentIdOfPinKey("banner:owner")).toBeNull();
|
|
23
|
+
expect(workerAgentIdOfPinKey("tool:123:9")).toBeNull();
|
|
24
|
+
expect(workerAgentIdOfPinKey("wk:")).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("decideWorkerPinReaps (#3001 mid-session wk: sweep)", () => {
|
|
29
|
+
it("reaps a pin whose worker is terminal, however young the pin is", () => {
|
|
30
|
+
const reaps = decideWorkerPinReaps({
|
|
31
|
+
pins: [pin({ pinnedAt: NOW - 1_000 })],
|
|
32
|
+
statusOf: () => 'terminal' as const,
|
|
33
|
+
ttlMs: TTL,
|
|
34
|
+
now: NOW,
|
|
35
|
+
});
|
|
36
|
+
expect(reaps).toHaveLength(1);
|
|
37
|
+
expect(reaps[0].reason).toBe("terminal");
|
|
38
|
+
expect(reaps[0].pinKey).toBe("wk:agent-1");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("reaps a non-terminal pin once it exceeds the TTL", () => {
|
|
42
|
+
const reaps = decideWorkerPinReaps({
|
|
43
|
+
pins: [pin({ pinnedAt: NOW - TTL })],
|
|
44
|
+
statusOf: () => 'unknown' as const,
|
|
45
|
+
ttlMs: TTL,
|
|
46
|
+
now: NOW,
|
|
47
|
+
});
|
|
48
|
+
expect(reaps).toHaveLength(1);
|
|
49
|
+
expect(reaps[0].reason).toBe("ttl");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("NEVER touches a pin younger than the TTL when the registry cannot vouch (unknown)", () => {
|
|
53
|
+
const reaps = decideWorkerPinReaps({
|
|
54
|
+
pins: [pin({ pinnedAt: NOW - TTL + 1 })],
|
|
55
|
+
statusOf: () => 'unknown' as const,
|
|
56
|
+
ttlMs: TTL,
|
|
57
|
+
now: NOW,
|
|
58
|
+
});
|
|
59
|
+
expect(reaps).toHaveLength(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("ignores non-worker keys entirely (fg:/banner:/tool: are not its job)", () => {
|
|
63
|
+
const reaps = decideWorkerPinReaps({
|
|
64
|
+
pins: [
|
|
65
|
+
pin({ pinKey: "fg:123:5", pinnedAt: NOW - 10 * TTL }),
|
|
66
|
+
pin({ pinKey: "banner:owner", pinnedAt: NOW - 10 * TTL }),
|
|
67
|
+
pin({ pinKey: "tool:123:9", pinnedAt: NOW - 10 * TTL }),
|
|
68
|
+
],
|
|
69
|
+
statusOf: () => 'terminal' as const,
|
|
70
|
+
ttlMs: TTL,
|
|
71
|
+
now: NOW,
|
|
72
|
+
});
|
|
73
|
+
expect(reaps).toHaveLength(0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("skips a candidate with no chat id (cannot unpin without one)", () => {
|
|
77
|
+
const reaps = decideWorkerPinReaps({
|
|
78
|
+
pins: [pin({ chatId: "", pinnedAt: NOW - 10 * TTL })],
|
|
79
|
+
statusOf: () => 'terminal' as const,
|
|
80
|
+
ttlMs: TTL,
|
|
81
|
+
now: NOW,
|
|
82
|
+
});
|
|
83
|
+
expect(reaps).toHaveLength(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("a registry-confirmed RUNNING worker keeps its pin PAST the TTL (no unpin/re-pin churn on healthy long workers)", () => {
|
|
87
|
+
const reaps = decideWorkerPinReaps({
|
|
88
|
+
pins: [pin({ pinnedAt: NOW - 10 * TTL })],
|
|
89
|
+
statusOf: () => 'running' as const,
|
|
90
|
+
ttlMs: TTL,
|
|
91
|
+
now: NOW,
|
|
92
|
+
});
|
|
93
|
+
expect(reaps).toHaveLength(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("status is consulted per-agent: terminal reaps young, running exempts old, unknown falls to the TTL gate", () => {
|
|
97
|
+
const seen: string[] = [];
|
|
98
|
+
const reaps = decideWorkerPinReaps({
|
|
99
|
+
pins: [
|
|
100
|
+
pin({ pinKey: "wk:done", pinnedAt: NOW - 1_000 }),
|
|
101
|
+
pin({ pinKey: "wk:running-old", pinnedAt: NOW - TTL - 1 }),
|
|
102
|
+
pin({ pinKey: "wk:unknown-young", pinnedAt: NOW - 1_000 }),
|
|
103
|
+
pin({ pinKey: "wk:unknown-old", pinnedAt: NOW - TTL - 1 }),
|
|
104
|
+
],
|
|
105
|
+
statusOf: (agentId) => {
|
|
106
|
+
seen.push(agentId);
|
|
107
|
+
if (agentId === "done") return "terminal";
|
|
108
|
+
if (agentId === "running-old") return "running";
|
|
109
|
+
return "unknown";
|
|
110
|
+
},
|
|
111
|
+
ttlMs: TTL,
|
|
112
|
+
now: NOW,
|
|
113
|
+
});
|
|
114
|
+
expect(seen).toEqual(["done", "running-old", "unknown-young", "unknown-old"]);
|
|
115
|
+
expect(reaps.map((r) => [r.pinKey, r.reason])).toEqual([
|
|
116
|
+
["wk:done", "terminal"],
|
|
117
|
+
["wk:unknown-old", "ttl"],
|
|
118
|
+
]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("registry hiccup ('unknown') never unpins a young pin โ the caller's contract on statusOf degrade", () => {
|
|
122
|
+
// The gateway wraps its DB lookup and degrades to 'unknown'. This pins the
|
|
123
|
+
// pure module's side of the contract: unknown + young pin = untouched.
|
|
124
|
+
const reaps = decideWorkerPinReaps({
|
|
125
|
+
pins: [pin()],
|
|
126
|
+
statusOf: () => 'unknown' as const,
|
|
127
|
+
ttlMs: TTL,
|
|
128
|
+
now: NOW,
|
|
129
|
+
});
|
|
130
|
+
expect(reaps).toHaveLength(0);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JTBD scenario named by `reference/jobs/survive-reboots-and-real-life.md`
|
|
3
|
+
* ยง Prove it โ "Deliberate restart resumes, bounded (DM)".
|
|
4
|
+
*
|
|
5
|
+
* Contract under test (#2988): a DELIBERATE restart (operator
|
|
6
|
+
* `switchroom agent restart`, i.e. a clean SIGTERM shutdown with a
|
|
7
|
+
* clean-shutdown marker) that lands MID-TURN resumes the interrupted turn
|
|
8
|
+
* like a crash-class interruption. The quota-saving suppression applies only
|
|
9
|
+
* when nothing was in flight โ it never swallows mid-turn work. And the
|
|
10
|
+
* resume is bounded: AT MOST ONCE per interruption (the loop-guard caps the
|
|
11
|
+
* resume chain), so we also assert no second resume-framed turn fires after
|
|
12
|
+
* the first completes.
|
|
13
|
+
*
|
|
14
|
+
* Sibling: `jtbd-interrupted-turn-resumes-dm.test.ts` (same interruption
|
|
15
|
+
* mechanics; this scenario adds the deliberate-restart class + the
|
|
16
|
+
* at-most-once bound). Decision-level twin: `decideBootResumeKind` cases in
|
|
17
|
+
* `tests/resume-inbound-builder.test.ts` (BOUNDED CHAIN) and
|
|
18
|
+
* `shouldSuppressBootResume` in `tests/gateway-clean-shutdown-marker.test.ts`.
|
|
19
|
+
*
|
|
20
|
+
* Self-skips green without NOPASSWD sudo.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, it, expect } from "vitest";
|
|
24
|
+
import { execSync, spawn } from "node:child_process";
|
|
25
|
+
import { spinUp } from "../harness.js";
|
|
26
|
+
|
|
27
|
+
const AGENT = "test-harness";
|
|
28
|
+
const MID_TURN_MS = 10_000; // let the turn get in-flight before the bounce
|
|
29
|
+
const RESUME_BUDGET_MS = 180_000; // boot + resume + reply
|
|
30
|
+
const QUIET_WINDOW_MS = 90_000; // after the resume completes, no second resume may fire
|
|
31
|
+
// Messages landing within this window of the FIRST resume-framed reply are
|
|
32
|
+
// treated as the same resumed turn (e.g. a progress line plus the final
|
|
33
|
+
// answer of one turn) โ only a resume-framed message AFTER it counts as a
|
|
34
|
+
// second resume for the at-most-once bound.
|
|
35
|
+
const SAME_TURN_GRACE_MS = 20_000;
|
|
36
|
+
|
|
37
|
+
const RESUME_FRAMING = /resum|picking .*back|interrupted|cut off|just restarted/i;
|
|
38
|
+
|
|
39
|
+
function canShellSudo(): boolean {
|
|
40
|
+
try {
|
|
41
|
+
execSync("sudo -n true", { stdio: "ignore", timeout: 2_000 });
|
|
42
|
+
return true;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function kickRestartDetached(name: string): void {
|
|
49
|
+
// A deliberate operator restart: clean SIGTERM path, clean-shutdown marker
|
|
50
|
+
// written โ the exact class #2988 makes resume in-flight work through.
|
|
51
|
+
const child = spawn(
|
|
52
|
+
"sudo",
|
|
53
|
+
["-n", "env", `PATH=${process.env.PATH}`, `HOME=${process.env.HOME}`,
|
|
54
|
+
"switchroom", "agent", "restart", name, "--force"],
|
|
55
|
+
{ detached: true, stdio: "ignore" },
|
|
56
|
+
);
|
|
57
|
+
child.unref();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const sudoOk = canShellSudo();
|
|
61
|
+
|
|
62
|
+
(sudoOk ? describe : describe.skip)(
|
|
63
|
+
"uat: deliberate restart mid-turn resumes exactly once (DM, #2988)",
|
|
64
|
+
() => {
|
|
65
|
+
it(
|
|
66
|
+
"an operator restart mid-turn resumes the work once, and never a second time",
|
|
67
|
+
async () => {
|
|
68
|
+
const sc = await spinUp({ agent: AGENT, settleMs: 0 });
|
|
69
|
+
try {
|
|
70
|
+
// A task long enough to still be in flight at MID_TURN_MS.
|
|
71
|
+
await sc.sendDM(
|
|
72
|
+
`Please write a thorough, detailed ~300-word explanation of how a ` +
|
|
73
|
+
`player piano's pneumatic action reads a music roll, step by step. ` +
|
|
74
|
+
`Take your time and be complete.`,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
await new Promise((r) => setTimeout(r, MID_TURN_MS));
|
|
78
|
+
kickRestartDetached(AGENT);
|
|
79
|
+
|
|
80
|
+
// 1. In-flight work resumes: quota-saving suppression must NOT
|
|
81
|
+
// swallow a turn that was mid-flight at the bounce. Edits are
|
|
82
|
+
// excluded โ a progress-card edit must not satisfy this; only
|
|
83
|
+
// a genuinely NEW message from the resumed turn counts.
|
|
84
|
+
const reply = await sc.expectMessage(
|
|
85
|
+
(m) => !m.edited && RESUME_FRAMING.test(m.text),
|
|
86
|
+
{ from: "bot", timeout: RESUME_BUDGET_MS },
|
|
87
|
+
);
|
|
88
|
+
expect(reply.text).toMatch(RESUME_FRAMING);
|
|
89
|
+
expect(reply.text.length).toBeGreaterThan(40);
|
|
90
|
+
|
|
91
|
+
// 2. Bounded chain: with the interruption resolved, no SECOND
|
|
92
|
+
// resume-framed turn may fire (at-most-once per interruption).
|
|
93
|
+
// Edits are excluded, and messages within SAME_TURN_GRACE_MS of
|
|
94
|
+
// the first reply are deduped as part of the SAME resumed turn
|
|
95
|
+
// (two resume-ish lines in one turn are not a second resume).
|
|
96
|
+
const firstReplyAt = reply.date.getTime();
|
|
97
|
+
let second: unknown = null;
|
|
98
|
+
try {
|
|
99
|
+
second = await sc.expectMessage(
|
|
100
|
+
(m) =>
|
|
101
|
+
!m.edited &&
|
|
102
|
+
m.messageId !== reply.messageId &&
|
|
103
|
+
m.date.getTime() > firstReplyAt + SAME_TURN_GRACE_MS &&
|
|
104
|
+
RESUME_FRAMING.test(m.text),
|
|
105
|
+
{ from: "bot", timeout: QUIET_WINDOW_MS },
|
|
106
|
+
);
|
|
107
|
+
} catch {
|
|
108
|
+
// timeout is the PASS: no repeat resume.
|
|
109
|
+
}
|
|
110
|
+
expect(second).toBeNull();
|
|
111
|
+
} finally {
|
|
112
|
+
await sc.tearDown();
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
RESUME_BUDGET_MS + QUIET_WINDOW_MS + 120_000,
|
|
116
|
+
);
|
|
117
|
+
},
|
|
118
|
+
);
|