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.
- package/dist/agent-scheduler/index.js +1 -0
- package/dist/auth-broker/index.js +1 -0
- package/dist/cli/autoaccept-poll.js +140 -33
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +269 -56
- package/dist/host-control/main.js +2 -1
- package/dist/vault/approvals/kernel-server.js +1 -0
- package/dist/vault/broker/server.js +1 -0
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +55 -16
- package/profiles/_base/start.sh.hbs +35 -16
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +1937 -580
- package/telegram-plugin/dist/server.js +24 -0
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
- package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1071 -130
- package/telegram-plugin/gateway/inbound-spool.ts +2 -1
- package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
- package/telegram-plugin/gateway/inject-handler.ts +17 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
- package/telegram-plugin/gateway/ipc-server.ts +40 -0
- package/telegram-plugin/gateway/model-command.ts +212 -51
- package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
- package/telegram-plugin/gateway/pending-card-store.ts +173 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
- package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/operator-events.ts +24 -0
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/subagent-watcher.ts +222 -37
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +202 -42
- package/telegram-plugin/tests/model-label.test.ts +64 -0
- package/telegram-plugin/tests/operator-events.test.ts +1 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
- package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
- package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
- package/telegram-plugin/tests/permission-diff.test.ts +111 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
- package/telegram-plugin/tests/session-model-source.test.ts +67 -0
- package/telegram-plugin/tests/session-tail.test.ts +64 -0
- package/telegram-plugin/tests/silent-end.test.ts +46 -1
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
- package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
- package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- 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
|
+
})
|