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.
Files changed (54) hide show
  1. package/dist/cli/switchroom.js +905 -758
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +1 -1
  4. package/profiles/_base/start.sh.hbs +111 -34
  5. package/skills/switchroom-runtime/SKILL.md +2 -0
  6. package/telegram-plugin/dist/gateway/gateway.js +1403 -657
  7. package/telegram-plugin/flood-circuit-breaker.ts +123 -0
  8. package/telegram-plugin/gateway/activity-card-store.ts +63 -18
  9. package/telegram-plugin/gateway/boot-card.ts +27 -0
  10. package/telegram-plugin/gateway/busy-ack.ts +106 -0
  11. package/telegram-plugin/gateway/gateway.ts +564 -85
  12. package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
  13. package/telegram-plugin/gateway/model-command.ts +23 -11
  14. package/telegram-plugin/gateway/session-model-file.ts +198 -0
  15. package/telegram-plugin/gateway/status-pin-store.ts +82 -22
  16. package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
  17. package/telegram-plugin/hooks/hooks.json +10 -10
  18. package/telegram-plugin/hooks/run-hook.sh +84 -0
  19. package/telegram-plugin/model-unavailable.ts +26 -0
  20. package/telegram-plugin/pty-partial-handler.ts +39 -0
  21. package/telegram-plugin/render/rich-render.ts +79 -1
  22. package/telegram-plugin/retry-api-call.ts +62 -0
  23. package/telegram-plugin/shared/bot-runtime.ts +8 -1
  24. package/telegram-plugin/silence-poke.ts +14 -0
  25. package/telegram-plugin/stream-controller.ts +156 -38
  26. package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
  27. package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
  28. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
  29. package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
  30. package/telegram-plugin/tests/busy-ack.test.ts +121 -0
  31. package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
  32. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
  33. package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
  34. package/telegram-plugin/tests/model-command.test.ts +2 -2
  35. package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
  36. package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
  37. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
  38. package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
  39. package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
  40. package/telegram-plugin/tests/session-model-file.test.ts +132 -0
  41. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
  42. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  43. package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
  44. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
  45. package/telegram-plugin/tests/voice-send.test.ts +308 -0
  46. package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
  47. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
  48. package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
  49. package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
  50. package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
  51. package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
  52. package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
  53. package/telegram-plugin/voice-ondemand.ts +25 -1
  54. 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
+ );