switchroom 0.19.19 → 0.19.22
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/auth-broker/index.js +53 -0
- package/dist/cli/switchroom.js +2444 -1264
- package/dist/host-control/main.js +54 -1
- package/dist/vault/approvals/kernel-server.js +53 -0
- package/dist/vault/broker/server.js +53 -0
- package/package.json +4 -2
- package/skills/switchroom-release/SKILL.md +103 -20
- package/telegram-plugin/card-format.ts +92 -3
- package/telegram-plugin/dist/gateway/gateway.js +769 -172
- package/telegram-plugin/edit-flood-fuse.ts +477 -0
- package/telegram-plugin/format.ts +19 -7
- package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
- package/telegram-plugin/gateway/gateway.ts +66 -56
- package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
- package/telegram-plugin/gateway/narrative-lane.ts +49 -3
- package/telegram-plugin/gateway/status-pin-api.ts +145 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
- package/telegram-plugin/retry-api-call.ts +15 -2
- package/telegram-plugin/send-gate.ts +1 -1
- package/telegram-plugin/status-no-truncate.ts +64 -1
- package/telegram-plugin/status-pin-driver.ts +50 -27
- package/telegram-plugin/status-pin.ts +43 -5
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
- package/telegram-plugin/tests/status-pin.test.ts +106 -5
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +19 -10
- package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
- package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
- package/telegram-plugin/tool-activity-summary.ts +85 -13
- package/telegram-plugin/worker-activity-feed.ts +5 -1
- package/vendor/hindsight-memory/scripts/drain_pending.py +193 -25
- package/vendor/hindsight-memory/scripts/lib/pending.py +84 -5
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +21 -10
- package/vendor/hindsight-memory/scripts/recall.py +74 -5
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +158 -4
- package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +19 -11
- package/vendor/hindsight-memory/tests/test_drain_pending.py +28 -2
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* boot-sweep-gate — the deterministic ordering fence for #3664 Defect A.
|
|
3
|
+
*
|
|
4
|
+
* The regression these assertions exist to catch: the boot orphan sweep was
|
|
5
|
+
* dispatched the moment the gateway won the startup mutex, which happens at
|
|
6
|
+
* module-eval time — thousands of lines before `initGatewayBot()` assigns
|
|
7
|
+
* `lockedBot`. Every boot-cleanup unpin therefore threw
|
|
8
|
+
* `undefined is not an object (evaluating 'lockedBot.api')` before reaching
|
|
9
|
+
* Telegram (14/14 fleet-wide failures since #3310, zero clean successes).
|
|
10
|
+
*
|
|
11
|
+
* The behavioural assertion is "arming ALONE must not dispatch the sweep" —
|
|
12
|
+
* a test that would have failed on the original bug shape.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect } from 'vitest'
|
|
15
|
+
import {
|
|
16
|
+
createBootSweepGate,
|
|
17
|
+
runBootPinSweepSteps,
|
|
18
|
+
type BootPinSweepSteps,
|
|
19
|
+
} from '../gateway/boot-sweep-gate.js'
|
|
20
|
+
|
|
21
|
+
/** A fake sweep that records whether it ran and what the bot handle looked
|
|
22
|
+
* like at that moment — the thing the original bug got wrong. */
|
|
23
|
+
function fakeSweep() {
|
|
24
|
+
const observed: (string | undefined)[] = []
|
|
25
|
+
let bot: string | undefined
|
|
26
|
+
return {
|
|
27
|
+
run: async () => {
|
|
28
|
+
observed.push(bot)
|
|
29
|
+
},
|
|
30
|
+
setBot: (b: string) => {
|
|
31
|
+
bot = b
|
|
32
|
+
},
|
|
33
|
+
get observed() {
|
|
34
|
+
return observed
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('createBootSweepGate (#3664 Defect A)', () => {
|
|
40
|
+
it('does NOT dispatch on arm() alone — the bot handle does not exist yet', async () => {
|
|
41
|
+
const sweep = fakeSweep()
|
|
42
|
+
const gate = createBootSweepGate({ run: sweep.run })
|
|
43
|
+
|
|
44
|
+
gate.arm()
|
|
45
|
+
await Promise.resolve()
|
|
46
|
+
|
|
47
|
+
expect(gate.hasRun()).toBe(false)
|
|
48
|
+
expect(sweep.observed).toEqual([])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('does NOT dispatch on botReady() alone — this boot may not own the pin state', async () => {
|
|
52
|
+
const sweep = fakeSweep()
|
|
53
|
+
const gate = createBootSweepGate({ run: sweep.run })
|
|
54
|
+
|
|
55
|
+
gate.botReady()
|
|
56
|
+
await Promise.resolve()
|
|
57
|
+
|
|
58
|
+
expect(gate.hasRun()).toBe(false)
|
|
59
|
+
expect(sweep.observed).toEqual([])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('dispatches once BOTH signals arrive, and the bot handle is live when it does', async () => {
|
|
63
|
+
const sweep = fakeSweep()
|
|
64
|
+
const gate = createBootSweepGate({ run: sweep.run })
|
|
65
|
+
|
|
66
|
+
gate.arm() // startup mutex won (module-eval time)
|
|
67
|
+
await Promise.resolve()
|
|
68
|
+
expect(sweep.observed).toEqual([])
|
|
69
|
+
|
|
70
|
+
sweep.setBot('lockedBot') // initGatewayBot() assigns lockedBot …
|
|
71
|
+
gate.botReady() // … and signals the gate
|
|
72
|
+
await Promise.resolve()
|
|
73
|
+
|
|
74
|
+
// The sweep ran, and it saw a live bot handle — never `undefined`.
|
|
75
|
+
expect(sweep.observed).toEqual(['lockedBot'])
|
|
76
|
+
expect(gate.hasRun()).toBe(true)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('is order-independent: botReady() before arm() still dispatches exactly once', async () => {
|
|
80
|
+
const sweep = fakeSweep()
|
|
81
|
+
const gate = createBootSweepGate({ run: sweep.run })
|
|
82
|
+
|
|
83
|
+
sweep.setBot('lockedBot')
|
|
84
|
+
gate.botReady()
|
|
85
|
+
await Promise.resolve()
|
|
86
|
+
expect(sweep.observed).toEqual([])
|
|
87
|
+
|
|
88
|
+
gate.arm()
|
|
89
|
+
await Promise.resolve()
|
|
90
|
+
expect(sweep.observed).toEqual(['lockedBot'])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('dispatches at most once however many times the signals repeat', async () => {
|
|
94
|
+
const sweep = fakeSweep()
|
|
95
|
+
const gate = createBootSweepGate({ run: sweep.run })
|
|
96
|
+
|
|
97
|
+
sweep.setBot('lockedBot')
|
|
98
|
+
gate.arm()
|
|
99
|
+
gate.botReady()
|
|
100
|
+
gate.arm() // e.g. the mutex path AND the mutex-fallback path
|
|
101
|
+
gate.botReady()
|
|
102
|
+
await Promise.resolve()
|
|
103
|
+
|
|
104
|
+
expect(sweep.observed).toEqual(['lockedBot'])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('routes a rejecting sweep to onError instead of leaking an unhandled rejection', async () => {
|
|
108
|
+
const rejections: unknown[] = []
|
|
109
|
+
const onUnhandled = (err: unknown) => rejections.push(err)
|
|
110
|
+
process.on('unhandledRejection', onUnhandled)
|
|
111
|
+
try {
|
|
112
|
+
const errs: unknown[] = []
|
|
113
|
+
const gate = createBootSweepGate({
|
|
114
|
+
run: async () => {
|
|
115
|
+
throw new Error('boom')
|
|
116
|
+
},
|
|
117
|
+
onError: (err) => errs.push(err),
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
gate.arm()
|
|
121
|
+
gate.botReady()
|
|
122
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
123
|
+
|
|
124
|
+
expect((errs[0] as Error).message).toBe('boom')
|
|
125
|
+
expect(rejections).toEqual([])
|
|
126
|
+
} finally {
|
|
127
|
+
process.off('unhandledRejection', onUnhandled)
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Per-step isolation (#3664 salvage S2).
|
|
134
|
+
*
|
|
135
|
+
* The steps used to be a bare sequential `await` chain in gateway.ts. A throw
|
|
136
|
+
* from either card reaper skipped `dmPinSweepEligible = true`, which authorises
|
|
137
|
+
* the DM unpin-all path for the WHOLE SESSION — so one throwing reaper silently
|
|
138
|
+
* disabled DM stale-pin cleanup, including every later lazy first-inbound
|
|
139
|
+
* sweep. That became newly reachable the moment #3664 made the sweep actually
|
|
140
|
+
* run for the first time.
|
|
141
|
+
*
|
|
142
|
+
* The outcome asserted is "was the DM path enabled and were the DM chats
|
|
143
|
+
* swept", not "was a wrapper called".
|
|
144
|
+
*/
|
|
145
|
+
describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
|
|
146
|
+
function deps(over: Partial<BootPinSweepSteps> = {}) {
|
|
147
|
+
const order: string[] = []
|
|
148
|
+
const logs: string[] = []
|
|
149
|
+
const base: BootPinSweepSteps = {
|
|
150
|
+
scanDmChatIds: () => {
|
|
151
|
+
order.push('scan')
|
|
152
|
+
return ['12345']
|
|
153
|
+
},
|
|
154
|
+
statusPinCleanup: async () => {
|
|
155
|
+
order.push('status-pins')
|
|
156
|
+
},
|
|
157
|
+
activityCardReaper: async () => {
|
|
158
|
+
order.push('activity-cards')
|
|
159
|
+
},
|
|
160
|
+
queuedCardReaper: async () => {
|
|
161
|
+
order.push('queued-cards')
|
|
162
|
+
},
|
|
163
|
+
enableDmSweep: () => order.push('enable-dm'),
|
|
164
|
+
sweepDm: async (id) => {
|
|
165
|
+
order.push(`dm:${id}`)
|
|
166
|
+
},
|
|
167
|
+
log: (l) => logs.push(l),
|
|
168
|
+
}
|
|
169
|
+
return { order, logs, deps: { ...base, ...over } }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
it('runs the store scan first, then the reapers, then enables + runs the DM sweep', async () => {
|
|
173
|
+
const d = deps()
|
|
174
|
+
await runBootPinSweepSteps(d.deps)
|
|
175
|
+
expect(d.order).toEqual([
|
|
176
|
+
'scan',
|
|
177
|
+
'status-pins',
|
|
178
|
+
'activity-cards',
|
|
179
|
+
'queued-cards',
|
|
180
|
+
'enable-dm',
|
|
181
|
+
'dm:12345',
|
|
182
|
+
])
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('a throwing reaper does not strand the steps behind it', async () => {
|
|
186
|
+
const d = deps({
|
|
187
|
+
activityCardReaper: async () => {
|
|
188
|
+
throw new Error('boom')
|
|
189
|
+
},
|
|
190
|
+
})
|
|
191
|
+
await runBootPinSweepSteps(d.deps)
|
|
192
|
+
// The DM path is still enabled and the recorded DM chat is still swept.
|
|
193
|
+
expect(d.order).toEqual(['scan', 'status-pins', 'queued-cards', 'enable-dm', 'dm:12345'])
|
|
194
|
+
expect(d.logs.join('')).toContain("step 'activity-card-reaper' failed: boom")
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('enables the DM sweep even when EVERY earlier step throws', async () => {
|
|
198
|
+
const boom = async () => {
|
|
199
|
+
throw new Error('boom')
|
|
200
|
+
}
|
|
201
|
+
const d = deps({
|
|
202
|
+
statusPinCleanup: boom,
|
|
203
|
+
activityCardReaper: boom,
|
|
204
|
+
queuedCardReaper: boom,
|
|
205
|
+
})
|
|
206
|
+
await runBootPinSweepSteps(d.deps)
|
|
207
|
+
expect(d.order).toEqual(['scan', 'enable-dm', 'dm:12345'])
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('a failing store scan still lets the reapers and the DM enable run', async () => {
|
|
211
|
+
const d = deps({
|
|
212
|
+
scanDmChatIds: () => {
|
|
213
|
+
throw new Error('ENOENT')
|
|
214
|
+
},
|
|
215
|
+
})
|
|
216
|
+
await runBootPinSweepSteps(d.deps)
|
|
217
|
+
// No DM ids to sweep, but nothing behind the scan is stranded.
|
|
218
|
+
expect(d.order).toEqual(['status-pins', 'activity-cards', 'queued-cards', 'enable-dm'])
|
|
219
|
+
expect(d.logs.join('')).toContain("step 'dm-chat-scan' failed: ENOENT")
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('one failing DM chat does not block the next one', async () => {
|
|
223
|
+
const swept: string[] = []
|
|
224
|
+
const d = deps({
|
|
225
|
+
scanDmChatIds: () => ['1', '2'],
|
|
226
|
+
sweepDm: async (id) => {
|
|
227
|
+
if (id === '1') throw new Error('kicked')
|
|
228
|
+
swept.push(id)
|
|
229
|
+
},
|
|
230
|
+
})
|
|
231
|
+
await runBootPinSweepSteps(d.deps)
|
|
232
|
+
expect(swept).toEqual(['2'])
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('logs a non-Error throw honestly instead of "undefined"', async () => {
|
|
236
|
+
const d = deps({
|
|
237
|
+
queuedCardReaper: async () => {
|
|
238
|
+
// A non-Error throw: `(err as Error).message` would log "undefined".
|
|
239
|
+
throw 'not an Error'
|
|
240
|
+
},
|
|
241
|
+
})
|
|
242
|
+
await runBootPinSweepSteps(d.deps)
|
|
243
|
+
expect(d.logs.join('')).toContain("step 'queued-card-reaper' failed: not an Error")
|
|
244
|
+
expect(d.logs.join('')).not.toContain('failed: undefined')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('survives a log sink that itself throws', async () => {
|
|
248
|
+
// stderr can be gone on a dying process; the one function whose job is
|
|
249
|
+
// "a step must not take the sweep down" must not take the sweep down.
|
|
250
|
+
const order: string[] = []
|
|
251
|
+
await expect(
|
|
252
|
+
runBootPinSweepSteps({
|
|
253
|
+
scanDmChatIds: () => [],
|
|
254
|
+
statusPinCleanup: async () => {
|
|
255
|
+
throw new Error('boom')
|
|
256
|
+
},
|
|
257
|
+
activityCardReaper: async () => {
|
|
258
|
+
order.push('activity-cards')
|
|
259
|
+
},
|
|
260
|
+
queuedCardReaper: async () => {
|
|
261
|
+
order.push('queued-cards')
|
|
262
|
+
},
|
|
263
|
+
enableDmSweep: () => order.push('enable-dm'),
|
|
264
|
+
sweepDm: async () => {},
|
|
265
|
+
log: () => {
|
|
266
|
+
throw new Error('EPIPE')
|
|
267
|
+
},
|
|
268
|
+
}),
|
|
269
|
+
).resolves.toBeUndefined()
|
|
270
|
+
expect(order).toEqual(['activity-cards', 'queued-cards', 'enable-dm'])
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('never rejects — the caller fire-and-forgets it', async () => {
|
|
274
|
+
const boom = async () => {
|
|
275
|
+
throw new Error('boom')
|
|
276
|
+
}
|
|
277
|
+
await expect(
|
|
278
|
+
runBootPinSweepSteps({
|
|
279
|
+
scanDmChatIds: () => {
|
|
280
|
+
throw new Error('boom')
|
|
281
|
+
},
|
|
282
|
+
statusPinCleanup: boom,
|
|
283
|
+
activityCardReaper: boom,
|
|
284
|
+
queuedCardReaper: boom,
|
|
285
|
+
enableDmSweep: () => {
|
|
286
|
+
throw new Error('boom')
|
|
287
|
+
},
|
|
288
|
+
sweepDm: boom,
|
|
289
|
+
log: () => {},
|
|
290
|
+
}),
|
|
291
|
+
).resolves.toBeUndefined()
|
|
292
|
+
})
|
|
293
|
+
})
|
|
Binary file
|