switchroom 0.18.6 → 0.18.7

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 (71) hide show
  1. package/dist/agent-scheduler/index.js +1 -0
  2. package/dist/auth-broker/index.js +1 -0
  3. package/dist/cli/autoaccept-poll.js +140 -33
  4. package/dist/cli/notion-write-pretool.mjs +1 -0
  5. package/dist/cli/switchroom.js +269 -56
  6. package/dist/host-control/main.js +2 -1
  7. package/dist/vault/approvals/kernel-server.js +1 -0
  8. package/dist/vault/broker/server.js +1 -0
  9. package/package.json +3 -3
  10. package/profiles/_base/cron-session.sh.hbs +55 -16
  11. package/profiles/_base/start.sh.hbs +35 -16
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/telegram-plugin/dist/bridge/bridge.js +22 -0
  14. package/telegram-plugin/dist/gateway/gateway.js +1937 -580
  15. package/telegram-plugin/dist/server.js +24 -0
  16. package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
  17. package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
  18. package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
  19. package/telegram-plugin/gateway/gateway.ts +1071 -130
  20. package/telegram-plugin/gateway/inbound-spool.ts +2 -1
  21. package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
  22. package/telegram-plugin/gateway/inject-handler.ts +17 -0
  23. package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
  24. package/telegram-plugin/gateway/ipc-server.ts +40 -0
  25. package/telegram-plugin/gateway/model-command.ts +212 -51
  26. package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
  27. package/telegram-plugin/gateway/pending-card-store.ts +173 -0
  28. package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
  29. package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
  30. package/telegram-plugin/gateway/session-model-source.ts +73 -0
  31. package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
  32. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
  33. package/telegram-plugin/model-label.ts +69 -0
  34. package/telegram-plugin/operator-events.ts +24 -0
  35. package/telegram-plugin/permission-diff.ts +128 -0
  36. package/telegram-plugin/registry/subagents-schema.ts +80 -1
  37. package/telegram-plugin/registry/subagents.test.ts +90 -0
  38. package/telegram-plugin/session-tail.ts +28 -0
  39. package/telegram-plugin/silent-end.ts +49 -4
  40. package/telegram-plugin/subagent-watcher.ts +222 -37
  41. package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
  42. package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
  43. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
  44. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
  45. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
  46. package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
  47. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
  48. package/telegram-plugin/tests/model-command.test.ts +202 -42
  49. package/telegram-plugin/tests/model-label.test.ts +64 -0
  50. package/telegram-plugin/tests/operator-events.test.ts +1 -0
  51. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
  52. package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
  53. package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
  54. package/telegram-plugin/tests/permission-diff.test.ts +111 -0
  55. package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
  56. package/telegram-plugin/tests/session-model-source.test.ts +67 -0
  57. package/telegram-plugin/tests/session-tail.test.ts +64 -0
  58. package/telegram-plugin/tests/silent-end.test.ts +46 -1
  59. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
  60. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
  61. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
  62. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
  63. package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
  64. package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
  65. package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
  66. package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
  67. package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
  68. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
  69. package/telegram-plugin/tool-activity-summary.ts +22 -2
  70. package/telegram-plugin/typing-wrap.ts +72 -25
  71. package/telegram-plugin/worker-activity-feed.ts +9 -0
@@ -0,0 +1,529 @@
1
+ /**
2
+ * Unit tests for telegram-plugin/gateway/always-allow-persist-queue.ts
3
+ * (#2973 pt.2 — durable retry queue for the "🔁 Always allow" persist).
4
+ *
5
+ * Contract under test:
6
+ * - enqueue is idempotent per (agent, rule); survives reload (boot drain);
7
+ * - listDue only returns entries whose backoff has elapsed;
8
+ * - a retryable failure re-checks isRulePersisted FIRST via a fresh
9
+ * config read, then re-synthesizes and redispatches — success dequeues;
10
+ * - a persist that fails 5 times (or ages out) is dropped AND the
11
+ * terminal-failure notice fires exactly once;
12
+ * - a retry that finds the rule already persisted is a no-op dequeue
13
+ * (no duplicate-rule risk, no redispatch);
14
+ * - bounds are structural: MAX_ATTEMPTS, MAX_AGE_MS, MAX_QUEUE_SIZE,
15
+ * MAX_BACKOFF_MS all cap out — nothing retries indefinitely;
16
+ * - concurrent read-modify-write operations against the queue file
17
+ * (two overlapping enqueue()s, an enqueue() racing a drain's
18
+ * recordAttempt(), etc.) are serialized — no lost update, no
19
+ * duplicated/corrupted rule (adversarial review pt.1/pt.3);
20
+ * - a write failure (disk full, permissions, …) PROPAGATES to the
21
+ * caller of enqueue/recordAttempt/remove rather than being silently
22
+ * swallowed (adversarial review pt.2).
23
+ */
24
+
25
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
26
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
27
+ import { join } from 'node:path'
28
+ import { tmpdir } from 'node:os'
29
+
30
+ // Tests that need to force a `writeFileSync` failure (disk full /
31
+ // permissions / read-only fs) pass one in via `createAlwaysAllowPersistQueue`'s
32
+ // injectable `writeFileSyncFn` param (see `makeControllableWrite` below)
33
+ // rather than mocking `node:fs` — we run as root in CI/containers, so
34
+ // chmod-based permission tricks don't reliably fail, and bun's test runner
35
+ // doesn't support mocking node:fs built-ins.
36
+
37
+ import {
38
+ createAlwaysAllowPersistQueue,
39
+ drainAlwaysAllowPersistQueue,
40
+ computeBackoffMs,
41
+ isExhausted,
42
+ makeEntryId,
43
+ MAX_ATTEMPTS,
44
+ MAX_AGE_MS,
45
+ MAX_QUEUE_SIZE,
46
+ MAX_BACKOFF_MS,
47
+ BASE_BACKOFF_MS,
48
+ type AlwaysAllowDrainDeps,
49
+ type AlwaysAllowPersistEntry,
50
+ } from '../gateway/always-allow-persist-queue.js'
51
+
52
+ function makeTmpDir() {
53
+ return mkdtempSync(join(tmpdir(), 'always-allow-persist-queue-test-'))
54
+ }
55
+
56
+ function baseArgs(over: Partial<Parameters<ReturnType<typeof createAlwaysAllowPersistQueue>['enqueue']>[0]> = {}) {
57
+ return {
58
+ agentName: 'clerk',
59
+ rule: 'Skill(calendar)',
60
+ grantPhrase: 'use the calendar skill',
61
+ chatId: '-100123',
62
+ threadId: 7,
63
+ error: 'E_PATCH_APPLY_FAILED',
64
+ ...over,
65
+ }
66
+ }
67
+
68
+ describe('createAlwaysAllowPersistQueue', () => {
69
+ let dir: string
70
+ beforeEach(() => { dir = makeTmpDir() })
71
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
72
+
73
+ it('starts empty', () => {
74
+ const q = createAlwaysAllowPersistQueue(dir)
75
+ expect(q.listAll()).toEqual([])
76
+ expect(q.listDue()).toEqual([])
77
+ })
78
+
79
+ it('enqueue + listAll returns the entry with attempts=1', async () => {
80
+ const q = createAlwaysAllowPersistQueue(dir)
81
+ await q.enqueue(baseArgs())
82
+ const all = q.listAll()
83
+ expect(all).toHaveLength(1)
84
+ expect(all[0].attempts).toBe(1)
85
+ expect(all[0].id).toBe(makeEntryId('clerk', 'Skill(calendar)'))
86
+ })
87
+
88
+ it('is idempotent per (agent, rule) — a second failure updates, not duplicates', async () => {
89
+ const q = createAlwaysAllowPersistQueue(dir)
90
+ await q.enqueue(baseArgs({ error: 'first' }))
91
+ await q.enqueue(baseArgs({ error: 'second' }))
92
+ expect(q.listAll()).toHaveLength(1)
93
+ expect(q.listAll()[0].lastError).toBe('second')
94
+ })
95
+
96
+ it('listDue only returns entries whose backoff has elapsed', async () => {
97
+ const q = createAlwaysAllowPersistQueue(dir)
98
+ await q.enqueue(baseArgs())
99
+ const now = Date.now()
100
+ // Not due yet — backoff for attempt 1 is BASE_BACKOFF_MS.
101
+ expect(q.listDue(now)).toEqual([])
102
+ expect(q.listDue(now + BASE_BACKOFF_MS + 1)).toHaveLength(1)
103
+ })
104
+
105
+ it('survives reload (boot drain picks up a queued entry after restart)', async () => {
106
+ const q1 = createAlwaysAllowPersistQueue(dir)
107
+ await q1.enqueue(baseArgs())
108
+ const q2 = createAlwaysAllowPersistQueue(dir)
109
+ expect(q2.listAll()).toHaveLength(1)
110
+ })
111
+
112
+ it('recordAttempt(success) dequeues', async () => {
113
+ const q = createAlwaysAllowPersistQueue(dir)
114
+ await q.enqueue(baseArgs())
115
+ const id = q.listAll()[0].id
116
+ await q.recordAttempt(id, { success: true })
117
+ expect(q.listAll()).toEqual([])
118
+ })
119
+
120
+ it('recordAttempt(failure) increments attempts and pushes nextAttemptAt out', async () => {
121
+ const q = createAlwaysAllowPersistQueue(dir)
122
+ await q.enqueue(baseArgs())
123
+ const id = q.listAll()[0].id
124
+ const before = q.listAll()[0].nextAttemptAt
125
+ await q.recordAttempt(id, { success: false, error: 'boom' })
126
+ const after = q.listAll()[0]
127
+ expect(after.attempts).toBe(2)
128
+ expect(after.nextAttemptAt).toBeGreaterThan(before)
129
+ })
130
+
131
+ it('recordAttempt drops the entry once MAX_ATTEMPTS is reached', async () => {
132
+ // enqueue() itself counts as attempt 1 (the original failed dispatch
133
+ // that triggered the enqueue) — so MAX_ATTEMPTS total tries is reached
134
+ // after (MAX_ATTEMPTS - 1) further recordAttempt(failure) calls.
135
+ const q = createAlwaysAllowPersistQueue(dir)
136
+ await q.enqueue(baseArgs())
137
+ const id = q.listAll()[0].id
138
+ for (let i = 0; i < MAX_ATTEMPTS - 2; i++) {
139
+ await q.recordAttempt(id, { success: false, error: 'boom' })
140
+ }
141
+ // One more failure reaches attempts===MAX_ATTEMPTS and drops it.
142
+ expect(q.listAll()[0].attempts).toBe(MAX_ATTEMPTS - 1)
143
+ await q.recordAttempt(id, { success: false, error: 'boom' })
144
+ expect(q.listAll()).toEqual([])
145
+ })
146
+
147
+ it('honors a structured retryAfterMs floor on backoff', async () => {
148
+ const q = createAlwaysAllowPersistQueue(dir)
149
+ await q.enqueue(baseArgs())
150
+ const id = q.listAll()[0].id
151
+ const now = Date.now()
152
+ await q.recordAttempt(id, { success: false, error: 'rate limited', retryAfterMs: 20 * 60_000 })
153
+ const entry = q.listAll()[0]
154
+ // 20 minutes is bigger than the exponential value at attempt 2, so the
155
+ // hint should win (but never exceed MAX_BACKOFF_MS).
156
+ expect(entry.nextAttemptAt - now).toBeGreaterThanOrEqual(20 * 60_000 - 1000)
157
+ expect(entry.nextAttemptAt - now).toBeLessThanOrEqual(MAX_BACKOFF_MS + 1000)
158
+ })
159
+
160
+ it('bounds the queue at MAX_QUEUE_SIZE, dropping the oldest', async () => {
161
+ const q = createAlwaysAllowPersistQueue(dir)
162
+ for (let i = 0; i < MAX_QUEUE_SIZE + 5; i++) {
163
+ await q.enqueue(baseArgs({ agentName: `agent${i}`, rule: 'Bash' }))
164
+ }
165
+ expect(q.listAll().length).toBeLessThanOrEqual(MAX_QUEUE_SIZE)
166
+ })
167
+
168
+ it('remove() drops an entry outright', async () => {
169
+ const q = createAlwaysAllowPersistQueue(dir)
170
+ await q.enqueue(baseArgs())
171
+ const id = q.listAll()[0].id
172
+ await q.remove(id)
173
+ expect(q.listAll()).toEqual([])
174
+ })
175
+
176
+ it('clear() wipes the backing file', async () => {
177
+ const q = createAlwaysAllowPersistQueue(dir)
178
+ await q.enqueue(baseArgs())
179
+ q.clear()
180
+ expect(q.listAll()).toEqual([])
181
+ })
182
+ })
183
+
184
+ describe('createAlwaysAllowPersistQueue — concurrency (#2973 adversarial review pt.1/pt.3)', () => {
185
+ let dir: string
186
+ beforeEach(() => { dir = makeTmpDir() })
187
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
188
+
189
+ it('two concurrent enqueue() calls for the SAME (agent, rule) race but do not duplicate or corrupt the entry', async () => {
190
+ const q = createAlwaysAllowPersistQueue(dir)
191
+ // Fire both "concurrently" — no await between them — simulating two
192
+ // overlapping "Always allow" taps racing against each other, the
193
+ // exact scenario from the original bug report.
194
+ await Promise.all([
195
+ q.enqueue(baseArgs({ error: 'attempt-A' })),
196
+ q.enqueue(baseArgs({ error: 'attempt-B' })),
197
+ ])
198
+ const all = q.listAll()
199
+ // Exactly one entry — never duplicated, never lost.
200
+ expect(all).toHaveLength(1)
201
+ expect(all[0].id).toBe(makeEntryId('clerk', 'Skill(calendar)'))
202
+ // One of the two attempts' error message won — not corrupted/merged
203
+ // into something neither caller wrote.
204
+ expect(['attempt-A', 'attempt-B']).toContain(all[0].lastError)
205
+ })
206
+
207
+ it('two concurrent enqueue() calls for DIFFERENT rules both land — no lost update', async () => {
208
+ const q = createAlwaysAllowPersistQueue(dir)
209
+ await Promise.all([
210
+ q.enqueue(baseArgs({ rule: 'Skill(calendar)' })),
211
+ q.enqueue(baseArgs({ rule: 'Skill(email)' })),
212
+ q.enqueue(baseArgs({ rule: 'Bash(ls:*)' })),
213
+ ])
214
+ const all = q.listAll()
215
+ expect(all).toHaveLength(3)
216
+ const rules = all.map(e => e.rule).sort()
217
+ expect(rules).toEqual(['Bash(ls:*)', 'Skill(calendar)', 'Skill(email)'])
218
+ })
219
+
220
+ it('many concurrent enqueue() calls interleaved with recordAttempt on an unrelated id never lose an update', async () => {
221
+ const q = createAlwaysAllowPersistQueue(dir)
222
+ // Seed one entry to record against concurrently with fresh enqueues —
223
+ // simulates a drain's recordAttempt() racing new "Always allow" taps.
224
+ await q.enqueue(baseArgs({ agentName: 'seed', rule: 'Read' }))
225
+ const seedId = makeEntryId('seed', 'Read')
226
+
227
+ const ops: Promise<void>[] = []
228
+ for (let i = 0; i < 10; i++) {
229
+ ops.push(q.enqueue(baseArgs({ agentName: `agent${i}`, rule: 'Bash' })))
230
+ }
231
+ ops.push(q.recordAttempt(seedId, { success: false, error: 'racing' }))
232
+
233
+ await Promise.all(ops)
234
+
235
+ const all = q.listAll()
236
+ // 10 fresh entries + the seed entry (updated, not dropped/duplicated).
237
+ expect(all).toHaveLength(11)
238
+ const seed = all.find(e => e.id === seedId)
239
+ expect(seed).toBeDefined()
240
+ expect(seed!.attempts).toBe(2)
241
+ expect(seed!.lastError).toBe('racing')
242
+ for (let i = 0; i < 10; i++) {
243
+ expect(all.some(e => e.id === makeEntryId(`agent${i}`, 'Bash'))).toBe(true)
244
+ }
245
+ })
246
+
247
+ it('a concurrent enqueue() racing recordAttempt(success) on the SAME id does not resurrect a dequeued entry with corrupted state', async () => {
248
+ const q = createAlwaysAllowPersistQueue(dir)
249
+ await q.enqueue(baseArgs())
250
+ const id = q.listAll()[0].id
251
+
252
+ // One racer dequeues via success, the other refreshes via enqueue
253
+ // (e.g. a second failed tap landing right as the drain's redispatch
254
+ // for the SAME rule succeeds). Whichever wins, the on-disk file must
255
+ // stay well-formed and never contain two entries for the same id.
256
+ await Promise.all([
257
+ q.recordAttempt(id, { success: true }),
258
+ q.enqueue(baseArgs({ error: 'second-tap-failure' })),
259
+ ])
260
+
261
+ const all = q.listAll()
262
+ const matching = all.filter(e => e.id === id)
263
+ expect(matching.length).toBeLessThanOrEqual(1)
264
+ })
265
+ })
266
+
267
+ /** A `writeFileSync`-shaped fn that runs the real node:fs implementation
268
+ * unless armed via `failNext()` (throws once, then reverts to real writes)
269
+ * or `failAlways()` (throws on every call from here on) — lets a test force
270
+ * writes to fail while everything before/after actually lands on disk. */
271
+ function makeControllableWrite() {
272
+ let armedMessage: string | null = null
273
+ let alwaysFail = false
274
+ const fn = ((...args: Parameters<typeof writeFileSync>) => {
275
+ if (alwaysFail) throw new Error(armedMessage ?? 'forced write failure')
276
+ if (armedMessage !== null) {
277
+ const message = armedMessage
278
+ armedMessage = null
279
+ throw new Error(message)
280
+ }
281
+ return writeFileSync(...args)
282
+ }) as typeof writeFileSync
283
+ return {
284
+ fn,
285
+ failNext: (message: string) => { armedMessage = message },
286
+ failAlways: (message: string) => { armedMessage = message; alwaysFail = true },
287
+ }
288
+ }
289
+
290
+ describe('createAlwaysAllowPersistQueue — write failures propagate (#2973 adversarial review pt.2)', () => {
291
+ let dir: string
292
+ beforeEach(() => { dir = makeTmpDir() })
293
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
294
+
295
+ it('enqueue() rejects when the underlying writeFileSync throws (disk full / permissions)', async () => {
296
+ const write = makeControllableWrite()
297
+ const q = createAlwaysAllowPersistQueue(dir, write.fn)
298
+ write.failNext('ENOSPC: no space left on device')
299
+ await expect(q.enqueue(baseArgs())).rejects.toThrow(/ENOSPC/)
300
+ // The caller was told it failed — and indeed nothing was persisted.
301
+ expect(q.listAll()).toEqual([])
302
+ })
303
+
304
+ it('recordAttempt() rejects when the underlying writeFileSync throws, and does not pretend the state change landed', async () => {
305
+ const write = makeControllableWrite()
306
+ const q = createAlwaysAllowPersistQueue(dir, write.fn)
307
+ await q.enqueue(baseArgs())
308
+ const id = q.listAll()[0].id
309
+
310
+ write.failNext('EACCES: permission denied')
311
+ await expect(q.recordAttempt(id, { success: true })).rejects.toThrow(/EACCES/)
312
+
313
+ // The entry is still there — the (failed) dequeue never actually
314
+ // persisted, so a fresh read must still see it, not silently vanish.
315
+ expect(q.listAll()).toHaveLength(1)
316
+ expect(q.listAll()[0].id).toBe(id)
317
+ })
318
+
319
+ it('remove() rejects when the underlying writeFileSync throws', async () => {
320
+ const write = makeControllableWrite()
321
+ const q = createAlwaysAllowPersistQueue(dir, write.fn)
322
+ await q.enqueue(baseArgs())
323
+ const id = q.listAll()[0].id
324
+
325
+ write.failNext('EROFS: read-only file system')
326
+ await expect(q.remove(id)).rejects.toThrow(/EROFS/)
327
+
328
+ expect(q.listAll()).toHaveLength(1)
329
+ })
330
+
331
+ it('a write failure does not wedge the in-process lock — a later successful call still lands', async () => {
332
+ const write = makeControllableWrite()
333
+ const q = createAlwaysAllowPersistQueue(dir, write.fn)
334
+ write.failNext('ENOSPC: no space left on device')
335
+ await expect(q.enqueue(baseArgs({ error: 'first' }))).rejects.toThrow(/ENOSPC/)
336
+
337
+ // Disk recovered — the NEXT call (real writeFileSync) must succeed,
338
+ // proving the failed link didn't leave the mutex permanently locked.
339
+ await q.enqueue(baseArgs({ error: 'second' }))
340
+ const all = q.listAll()
341
+ expect(all).toHaveLength(1)
342
+ expect(all[0].lastError).toBe('second')
343
+ })
344
+ })
345
+
346
+ describe('computeBackoffMs / isExhausted — structural bounds', () => {
347
+ it('doubles each attempt, capped at MAX_BACKOFF_MS', () => {
348
+ expect(computeBackoffMs(1)).toBe(BASE_BACKOFF_MS)
349
+ expect(computeBackoffMs(2)).toBe(BASE_BACKOFF_MS * 2)
350
+ expect(computeBackoffMs(3)).toBe(BASE_BACKOFF_MS * 4)
351
+ // Large attempt counts never exceed the ceiling — no unbounded growth.
352
+ expect(computeBackoffMs(50)).toBe(MAX_BACKOFF_MS)
353
+ })
354
+
355
+ it('isExhausted trips on attempts>=MAX_ATTEMPTS regardless of age', () => {
356
+ expect(isExhausted({ attempts: MAX_ATTEMPTS, createdAt: Date.now() })).toBe(true)
357
+ expect(isExhausted({ attempts: MAX_ATTEMPTS - 1, createdAt: Date.now() })).toBe(false)
358
+ })
359
+
360
+ it('isExhausted trips on age>=MAX_AGE_MS regardless of attempts', () => {
361
+ expect(isExhausted({ attempts: 1, createdAt: Date.now() - MAX_AGE_MS - 1 })).toBe(true)
362
+ expect(isExhausted({ attempts: 1, createdAt: Date.now() })).toBe(false)
363
+ })
364
+ })
365
+
366
+ describe('drainAlwaysAllowPersistQueue', () => {
367
+ let dir: string
368
+ beforeEach(() => { dir = makeTmpDir() })
369
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
370
+
371
+ function makeDeps(over: Partial<AlwaysAllowDrainDeps> = {}): AlwaysAllowDrainDeps {
372
+ return {
373
+ readConfigText: () => 'agents:\n clerk:\n tools:\n allow: [Read]\n',
374
+ resolveAllowList: () => ['Read'],
375
+ isRulePersisted: (allow, rule) => allow.includes(rule),
376
+ synthesizeDiff: () => '--- a/switchroom.yaml\n+++ b/switchroom.yaml\n@@ -1 +1,2 @@\n+fake\n',
377
+ dispatchConfigEdit: async () => ({ ok: true }),
378
+ notifyTerminalFailure: vi.fn(),
379
+ ...over,
380
+ }
381
+ }
382
+
383
+ it('a retryable failure retries after backoff with a fresh config read and succeeds; entry removed', async () => {
384
+ const q = createAlwaysAllowPersistQueue(dir)
385
+ await q.enqueue(baseArgs())
386
+ const readConfigText = vi.fn(() => 'agents:\n clerk:\n tools:\n allow: [Read]\n')
387
+ const dispatchConfigEdit = vi.fn(async () => ({ ok: true as const }))
388
+ const deps = makeDeps({ readConfigText, dispatchConfigEdit })
389
+
390
+ // Not due yet.
391
+ await drainAlwaysAllowPersistQueue(q, deps)
392
+ expect(dispatchConfigEdit).not.toHaveBeenCalled()
393
+ expect(q.listAll()).toHaveLength(1)
394
+
395
+ // Force due by rewriting the entry directly via recordAttempt timing —
396
+ // simplest: enqueue fresh with an already-elapsed backoff by manipulating
397
+ // system time isn't available here, so instead verify due-gating via a
398
+ // second queue instance whose entry we mark due by fast-forwarding.
399
+ vi.useFakeTimers()
400
+ vi.advanceTimersByTime(BASE_BACKOFF_MS + 1000)
401
+ await drainAlwaysAllowPersistQueue(q, deps)
402
+ vi.useRealTimers()
403
+
404
+ expect(readConfigText).toHaveBeenCalled()
405
+ expect(dispatchConfigEdit).toHaveBeenCalledTimes(1)
406
+ expect(q.listAll()).toEqual([]) // dequeued on success
407
+ })
408
+
409
+ it('a retry that finds the rule already persisted is a no-op dequeue (no redispatch)', async () => {
410
+ const q = createAlwaysAllowPersistQueue(dir)
411
+ await q.enqueue(baseArgs({ rule: 'Read' })) // "Read" will already be in the fresh allow list
412
+ const dispatchConfigEdit = vi.fn(async () => ({ ok: true as const }))
413
+ const deps = makeDeps({
414
+ resolveAllowList: () => ['Read'],
415
+ isRulePersisted: (allow, rule) => allow.includes(rule),
416
+ dispatchConfigEdit,
417
+ })
418
+
419
+ vi.useFakeTimers()
420
+ vi.advanceTimersByTime(BASE_BACKOFF_MS + 1000)
421
+ await drainAlwaysAllowPersistQueue(q, deps)
422
+ vi.useRealTimers()
423
+
424
+ expect(dispatchConfigEdit).not.toHaveBeenCalled()
425
+ expect(q.listAll()).toEqual([])
426
+ })
427
+
428
+ it('a persist that fails MAX_ATTEMPTS times sends the terminal-failure notice exactly once and is dropped', async () => {
429
+ // enqueue() itself counts as attempt 1 (the original failed dispatch
430
+ // that triggered the enqueue) — so the drain loop's dispatchConfigEdit
431
+ // fires (MAX_ATTEMPTS - 1) times before the entry is exhausted/dropped.
432
+ const q = createAlwaysAllowPersistQueue(dir)
433
+ await q.enqueue(baseArgs())
434
+ const notifyTerminalFailure = vi.fn()
435
+ const dispatchConfigEdit = vi.fn(async () => ({ ok: false as const, error: 'E_PATCH_APPLY_FAILED' }))
436
+ const deps = makeDeps({
437
+ resolveAllowList: () => [], // rule never lands
438
+ dispatchConfigEdit,
439
+ notifyTerminalFailure,
440
+ })
441
+
442
+ vi.useFakeTimers()
443
+ // Drive it through every remaining attempt. Each drain pass needs the
444
+ // fake clock advanced past the current backoff before the entry
445
+ // becomes due again.
446
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
447
+ vi.advanceTimersByTime(MAX_BACKOFF_MS + 1000)
448
+ await drainAlwaysAllowPersistQueue(q, deps)
449
+ }
450
+ vi.useRealTimers()
451
+
452
+ expect(notifyTerminalFailure).toHaveBeenCalledTimes(1)
453
+ expect(q.listAll()).toEqual([]) // dropped after exhaustion
454
+ expect(dispatchConfigEdit).toHaveBeenCalledTimes(MAX_ATTEMPTS - 1)
455
+ })
456
+
457
+ it('never retries indefinitely: attempts and age are both hard bounds (no drain call exceeds MAX_ATTEMPTS-1 dispatches for one entry)', async () => {
458
+ const q = createAlwaysAllowPersistQueue(dir)
459
+ await q.enqueue(baseArgs())
460
+ const dispatchConfigEdit = vi.fn(async () => ({ ok: false as const, error: 'boom' }))
461
+ const deps = makeDeps({ resolveAllowList: () => [], dispatchConfigEdit, notifyTerminalFailure: vi.fn() })
462
+
463
+ vi.useFakeTimers()
464
+ // Drain far more times than MAX_ATTEMPTS would allow — dispatch count
465
+ // must plateau, never grow further once the entry is exhausted+dropped.
466
+ for (let i = 0; i < MAX_ATTEMPTS + 10; i++) {
467
+ vi.advanceTimersByTime(MAX_BACKOFF_MS + 1000)
468
+ await drainAlwaysAllowPersistQueue(q, deps)
469
+ }
470
+ vi.useRealTimers()
471
+
472
+ expect(dispatchConfigEdit.mock.calls.length).toBe(MAX_ATTEMPTS - 1)
473
+ })
474
+
475
+ it('a config read failure is treated as retryable, not a crash', async () => {
476
+ const q = createAlwaysAllowPersistQueue(dir)
477
+ await q.enqueue(baseArgs())
478
+ const deps = makeDeps({
479
+ readConfigText: () => { throw new Error('ENOENT') },
480
+ })
481
+
482
+ vi.useFakeTimers()
483
+ vi.advanceTimersByTime(BASE_BACKOFF_MS + 1000)
484
+ await expect(drainAlwaysAllowPersistQueue(q, deps)).resolves.toBeUndefined()
485
+ vi.useRealTimers()
486
+
487
+ expect(q.listAll()).toHaveLength(1)
488
+ expect(q.listAll()[0].attempts).toBe(2)
489
+ })
490
+
491
+ it('a null re-synthesized diff is treated as retryable, not a crash', async () => {
492
+ const q = createAlwaysAllowPersistQueue(dir)
493
+ await q.enqueue(baseArgs())
494
+ const deps = makeDeps({ synthesizeDiff: () => null })
495
+
496
+ vi.useFakeTimers()
497
+ vi.advanceTimersByTime(BASE_BACKOFF_MS + 1000)
498
+ await drainAlwaysAllowPersistQueue(q, deps)
499
+ vi.useRealTimers()
500
+
501
+ expect(q.listAll()).toHaveLength(1)
502
+ expect(q.listAll()[0].attempts).toBe(2)
503
+ })
504
+
505
+ it('a drain-time write failure on recordAttempt does not crash the pass and other due entries still get processed', async () => {
506
+ const write = makeControllableWrite()
507
+ const q = createAlwaysAllowPersistQueue(dir, write.fn)
508
+ await q.enqueue(baseArgs({ agentName: 'clerk', rule: 'Skill(calendar)' }))
509
+ await q.enqueue(baseArgs({ agentName: 'clerk', rule: 'Skill(email)' }))
510
+ const dispatchConfigEdit = vi.fn(async () => ({ ok: true as const }))
511
+ const deps = makeDeps({ resolveAllowList: () => [], dispatchConfigEdit })
512
+
513
+ // Both entries' recordAttempt writes fail — the drain pass must
514
+ // swallow that (log it) rather than throwing out of
515
+ // drainAlwaysAllowPersistQueue and abandoning remaining entries.
516
+ write.failAlways('EIO: i/o error')
517
+
518
+ vi.useFakeTimers()
519
+ vi.advanceTimersByTime(BASE_BACKOFF_MS + 1000)
520
+ await expect(drainAlwaysAllowPersistQueue(q, deps)).resolves.toBeUndefined()
521
+ vi.useRealTimers()
522
+
523
+ // Both entries were attempted despite the persistent write failure.
524
+ expect(dispatchConfigEdit).toHaveBeenCalledTimes(2)
525
+ // Since the "success" recordAttempt write failed, the entries are
526
+ // still present on next read (state change did NOT silently land).
527
+ expect(q.listAll()).toHaveLength(2)
528
+ })
529
+ })
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Pin the synthetic-inbound shapes the gateway injects when an agent-initiated
3
+ * approval card TTL-expires unanswered (the "wake the parked agent on timeout"
4
+ * half of the durability fix). A regression that drops/changes `meta.source`
5
+ * silently breaks the wake-up: the bridge wouldn't recognize the source and the
6
+ * model wouldn't know its card timed out.
7
+ *
8
+ * The load-bearing wording invariant: each message says TIMEOUT, not a denial,
9
+ * matching the permission-card philosophy so the model degrades gracefully
10
+ * instead of spam-re-requesting into an absent operator.
11
+ */
12
+
13
+ import { describe, it, expect } from 'vitest'
14
+ import {
15
+ buildVaultAccessTimeoutInbound,
16
+ buildVaultSaveTimeoutInbound,
17
+ buildSecretRequestTimeoutInbound,
18
+ buildMentalModelProposeTimeoutInbound,
19
+ } from '../gateway/approval-timeout-inbound-builders.js'
20
+
21
+ const FIXED_NOW = 1_700_000_000_000
22
+ const BASE = { agent: 'gymbro', chatId: '12345', stageId: 's1', timeoutMinutes: 60, nowMs: FIXED_NOW }
23
+
24
+ describe('timeout inbound builders — envelope', () => {
25
+ const cases = [
26
+ buildVaultAccessTimeoutInbound({ ...BASE, key: 'fatsecret/creds', scope: 'read' }),
27
+ buildVaultSaveTimeoutInbound({ ...BASE, key: 'brevo/api-key' }),
28
+ buildSecretRequestTimeoutInbound({ ...BASE, key: 'openai/token' }),
29
+ buildMentalModelProposeTimeoutInbound({ ...BASE, name: 'training-plan-state' }),
30
+ ]
31
+
32
+ it('all share the canonical vault-broker envelope', () => {
33
+ for (const m of cases) {
34
+ expect(m.type).toBe('inbound')
35
+ expect(m.chatId).toBe('12345')
36
+ expect(m.user).toBe('vault-broker')
37
+ expect(m.userId).toBe(0)
38
+ expect(m.ts).toBe(FIXED_NOW)
39
+ expect(m.messageId).toBe(FIXED_NOW)
40
+ expect(m.meta?.agent).toBe('gymbro')
41
+ expect(m.meta?.stage_id).toBe('s1')
42
+ }
43
+ })
44
+
45
+ it('every message says TIMEOUT, not a denial (degrade-gracefully wording)', () => {
46
+ for (const m of cases) {
47
+ expect(m.text).toMatch(/TIMEOUT, not a denial/)
48
+ expect(m.text).toMatch(/60 min/)
49
+ expect(m.text).toMatch(/Do NOT (re-request|loop|re-propose)/i)
50
+ }
51
+ })
52
+ })
53
+
54
+ describe('timeout inbound builders — distinct sources', () => {
55
+ it('pins each meta.source string (load-bearing for the bridge)', () => {
56
+ expect(buildVaultAccessTimeoutInbound({ ...BASE, key: 'k', scope: 'read' }).meta?.source).toBe('vault_grant_timeout')
57
+ expect(buildVaultSaveTimeoutInbound({ ...BASE, key: 'k' }).meta?.source).toBe('vault_save_timeout')
58
+ expect(buildSecretRequestTimeoutInbound({ ...BASE, key: 'k' }).meta?.source).toBe('secret_request_timeout')
59
+ expect(buildMentalModelProposeTimeoutInbound({ ...BASE, name: 'n' }).meta?.source).toBe('mental_model_propose_timeout')
60
+ })
61
+
62
+ it('sources are all disjoint', () => {
63
+ const sources = [
64
+ buildVaultAccessTimeoutInbound({ ...BASE, key: 'k', scope: 'read' }).meta?.source,
65
+ buildVaultSaveTimeoutInbound({ ...BASE, key: 'k' }).meta?.source,
66
+ buildSecretRequestTimeoutInbound({ ...BASE, key: 'k' }).meta?.source,
67
+ buildMentalModelProposeTimeoutInbound({ ...BASE, name: 'n' }).meta?.source,
68
+ ]
69
+ expect(new Set(sources).size).toBe(4)
70
+ })
71
+ })
72
+
73
+ describe('timeout inbound builders — topic routing', () => {
74
+ const THREAD = 4242
75
+ it('threadId set → top-level threadId + meta.message_thread_id (stringified)', () => {
76
+ const m = buildVaultAccessTimeoutInbound({ ...BASE, threadId: THREAD, key: 'k', scope: 'write' })
77
+ expect(m.threadId).toBe(THREAD)
78
+ expect(m.meta?.message_thread_id).toBe(String(THREAD))
79
+ expect(m.meta?.scope).toBe('write')
80
+ })
81
+ it('threadId absent → both omitted (DM stays thread-less)', () => {
82
+ const m = buildVaultSaveTimeoutInbound({ ...BASE, key: 'k' })
83
+ expect(m.threadId).toBeUndefined()
84
+ expect(m.meta?.message_thread_id).toBeUndefined()
85
+ })
86
+ it('defaults nowMs to Date.now() when omitted', () => {
87
+ const before = Date.now()
88
+ const m = buildSecretRequestTimeoutInbound({ agent: 'a', chatId: '1', stageId: 's', timeoutMinutes: 30, key: 'k' })
89
+ const after = Date.now()
90
+ expect(m.ts).toBeGreaterThanOrEqual(before)
91
+ expect(m.ts).toBeLessThanOrEqual(after)
92
+ expect(m.messageId).toBe(m.ts)
93
+ })
94
+ })