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
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
import { reconcilePin } from "../status-pin-driver.js";
|
|
34
34
|
import type { PinState, DesiredPin } from "../status-pin.js";
|
|
35
35
|
import { decidePinAction } from "../status-pin.js";
|
|
36
|
+
import { makeFloodWaitActiveError } from "../retry-api-call.js";
|
|
36
37
|
|
|
37
38
|
const PATH = "/state/agent/telegram/status-pins.json";
|
|
38
39
|
|
|
@@ -182,6 +183,83 @@ describe("status-pin boot recovery (gateway wiring)", () => {
|
|
|
182
183
|
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
183
184
|
});
|
|
184
185
|
|
|
186
|
+
it("(C) #3664: a never-confirmed unpin (FLOOD_WAIT_ACTIVE) KEEPS the claim + the durable row, and the next boot clears the pin", async () => {
|
|
187
|
+
// Defect B: the unpin was refused LOCALLY by the send gate, so the message
|
|
188
|
+
// is provably still pinned. The old driver returned null anyway, which made
|
|
189
|
+
// reconcileAndPersistStatusPin delete the row and the gateway delete the
|
|
190
|
+
// in-memory claim — erasing BOTH records of a live pin. Nothing (reaper or
|
|
191
|
+
// boot sweep) could ever find it again.
|
|
192
|
+
const { fs } = memFs();
|
|
193
|
+
const pinned = new Set<string>();
|
|
194
|
+
let floodOpen = true;
|
|
195
|
+
const tg = {
|
|
196
|
+
pinned,
|
|
197
|
+
api: {
|
|
198
|
+
pinChatMessage: async (chat_id: string | number, message_id: number) => {
|
|
199
|
+
pinned.add(`${chat_id}:${message_id}`);
|
|
200
|
+
},
|
|
201
|
+
unpinChatMessage: async (chat_id: string | number, message_id: number) => {
|
|
202
|
+
// The send gate fail-fast: NO request leaves the process.
|
|
203
|
+
if (floodOpen) throw makeFloodWaitActiveError(16739, Date.now() + 16739_000, null);
|
|
204
|
+
pinned.delete(`${chat_id}:${message_id}`);
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const gw1 = makeGateway(fs, tg);
|
|
210
|
+
await gw1.reconcileStatusPin("fg:c:3", "-100123", { pinned: true, messageId: 715 });
|
|
211
|
+
expect(pinned.has("-100123:715")).toBe(true);
|
|
212
|
+
|
|
213
|
+
// Turn ends → unpin requested → refused locally.
|
|
214
|
+
await gw1.reconcileStatusPin("fg:c:3", "-100123", { pinned: false });
|
|
215
|
+
|
|
216
|
+
// The pin is STILL UP …
|
|
217
|
+
expect(pinned.has("-100123:715")).toBe(true);
|
|
218
|
+
// … and BOTH records survive: the in-memory claim …
|
|
219
|
+
expect(gw1.statusPinState.get("fg:c:3")).toEqual({ messageId: 715 });
|
|
220
|
+
// … and the durable row (what boot cleanup reads).
|
|
221
|
+
expect(loadStatusPins(PATH, fs)).toEqual([
|
|
222
|
+
{ pinKey: "fg:c:3", chatId: "-100123", messageId: 715 },
|
|
223
|
+
]);
|
|
224
|
+
|
|
225
|
+
// Same session, flood window closed: the retained claim retries and clears.
|
|
226
|
+
floodOpen = false;
|
|
227
|
+
await gw1.reconcileStatusPin("fg:c:3", "-100123", { pinned: false });
|
|
228
|
+
expect(pinned.has("-100123:715")).toBe(false);
|
|
229
|
+
expect(gw1.statusPinState.has("fg:c:3")).toBe(false);
|
|
230
|
+
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("(D) #3664: if the process dies while the flood window is open, the retained row lets the NEXT boot clear the orphan", async () => {
|
|
234
|
+
const { fs } = memFs();
|
|
235
|
+
const pinned = new Set<string>();
|
|
236
|
+
let floodOpen = true;
|
|
237
|
+
const tg = {
|
|
238
|
+
pinned,
|
|
239
|
+
api: {
|
|
240
|
+
pinChatMessage: async (chat_id: string | number, message_id: number) => {
|
|
241
|
+
pinned.add(`${chat_id}:${message_id}`);
|
|
242
|
+
},
|
|
243
|
+
unpinChatMessage: async (chat_id: string | number, message_id: number) => {
|
|
244
|
+
if (floodOpen) throw makeFloodWaitActiveError(16739, Date.now() + 16739_000, null);
|
|
245
|
+
pinned.delete(`${chat_id}:${message_id}`);
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const gw1 = makeGateway(fs, tg);
|
|
251
|
+
await gw1.reconcileStatusPin("fg:c:3", "-100123", { pinned: true, messageId: 715 });
|
|
252
|
+
await gw1.reconcileStatusPin("fg:c:3", "-100123", { pinned: false }); // refused
|
|
253
|
+
// CRASH here — in-memory state is gone, only the durable row remains.
|
|
254
|
+
|
|
255
|
+
floodOpen = false;
|
|
256
|
+
const gw2 = makeGateway(fs, tg);
|
|
257
|
+
const res = await gw2.bootCleanup();
|
|
258
|
+
expect(res).toEqual({ cleared: 1, retained: 0, kept: 0, total: 1 });
|
|
259
|
+
expect(pinned.has("-100123:715")).toBe(false);
|
|
260
|
+
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
261
|
+
});
|
|
262
|
+
|
|
185
263
|
it("clean shutdown (sweep DID run) leaves nothing for boot cleanup to do", async () => {
|
|
186
264
|
// Contrast: when the SIGTERM sweep runs (unpin each key), the store is
|
|
187
265
|
// emptied and the pin removed — boot cleanup is a no-op. This guards the
|
|
@@ -241,15 +319,15 @@ describe("status-pin boot cleanup is mutex-gated (structural)", () => {
|
|
|
241
319
|
const lockIdx = lines.findIndex((l) => l.includes("acquireStartupLock({"));
|
|
242
320
|
expect(lockIdx).toBeGreaterThan(-1);
|
|
243
321
|
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
322
|
+
// #3664: the winner ARMS the bot-ready gate rather than dispatching the
|
|
323
|
+
// sweep inline. Every arm() site must still come AFTER the
|
|
324
|
+
// acquireStartupLock outcome is available (the boot block the winner — or
|
|
325
|
+
// the link()-unsupported fallback — reaches).
|
|
247
326
|
const callIdxs = lines
|
|
248
327
|
.map((l, i) => ({ l, i }))
|
|
249
328
|
.filter(
|
|
250
329
|
({ l }) =>
|
|
251
|
-
/
|
|
252
|
-
!l.trimStart().startsWith("//"),
|
|
330
|
+
/bootPinSweepGate\.arm\(\)/.test(l) && !l.trimStart().startsWith("//"),
|
|
253
331
|
)
|
|
254
332
|
.map(({ i }) => i);
|
|
255
333
|
expect(callIdxs.length).toBeGreaterThan(0);
|
|
@@ -257,15 +335,17 @@ describe("status-pin boot cleanup is mutex-gated (structural)", () => {
|
|
|
257
335
|
expect(idx).toBeGreaterThan(lockIdx);
|
|
258
336
|
}
|
|
259
337
|
|
|
260
|
-
// statusPinBootCleanup
|
|
261
|
-
//
|
|
262
|
-
//
|
|
338
|
+
// statusPinBootCleanup itself must only ever be REACHED from INSIDE that
|
|
339
|
+
// orchestrator — never at a bare post-import call site that a losing
|
|
340
|
+
// double-boot could reach. Since the #3664 S2 salvage the orchestrator
|
|
341
|
+
// hands it to runBootPinSweepSteps BY REFERENCE (per-step isolation), so
|
|
342
|
+
// the step binding counts as a reach, not just a `()` call.
|
|
263
343
|
const declIdx = lines.findIndex((l) =>
|
|
264
344
|
/async function statusPinBootCleanup/.test(l),
|
|
265
345
|
);
|
|
266
346
|
expect(declIdx).toBeGreaterThan(-1);
|
|
267
347
|
const orchestratorIdx = lines.findIndex((l) =>
|
|
268
|
-
/
|
|
348
|
+
/function runBootPinCleanupAndDmSweep/.test(l),
|
|
269
349
|
);
|
|
270
350
|
expect(orchestratorIdx).toBeGreaterThan(-1);
|
|
271
351
|
const invocationIdxs = lines
|
|
@@ -273,7 +353,7 @@ describe("status-pin boot cleanup is mutex-gated (structural)", () => {
|
|
|
273
353
|
.filter(
|
|
274
354
|
({ l, i }) =>
|
|
275
355
|
i !== declIdx &&
|
|
276
|
-
/statusPinBootCleanup\(\)
|
|
356
|
+
/statusPinBootCleanup\(\)|:\s*statusPinBootCleanup,/.test(l) &&
|
|
277
357
|
!l.trimStart().startsWith("//") &&
|
|
278
358
|
!l.includes("statusPinBootCleanup() is deliberately NOT"),
|
|
279
359
|
)
|
|
@@ -292,7 +372,10 @@ describe("status-pin boot cleanup is mutex-gated (structural)", () => {
|
|
|
292
372
|
expect(blockedIdx).toBeGreaterThan(-1);
|
|
293
373
|
const afterBlocked = gatewaySrc.slice(blockedIdx);
|
|
294
374
|
const exitIdx = afterBlocked.indexOf("process.exit(1)");
|
|
295
|
-
|
|
375
|
+
// #3664: the winner now ARMS a two-condition gate instead of dispatching
|
|
376
|
+
// the sweep inline (the sweep also needs `lockedBot`), so the marker for
|
|
377
|
+
// "the winner's cleanup path" is the arm() call.
|
|
378
|
+
const cleanupIdx = afterBlocked.indexOf("bootPinSweepGate.arm()");
|
|
296
379
|
expect(exitIdx).toBeGreaterThan(-1);
|
|
297
380
|
expect(cleanupIdx).toBeGreaterThan(-1);
|
|
298
381
|
// The exit for the blocked branch appears before the winner's cleanup call.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
2
|
import { GrammyError } from 'grammy'
|
|
3
|
-
import { decidePinAction, isPinRightsError, PinRightsCache } from '../status-pin.js'
|
|
3
|
+
import { decidePinAction, isPinRightsError, isUnpinTerminalError, PinRightsCache } from '../status-pin.js'
|
|
4
|
+
import { makeFloodWaitActiveError, GIVE_UP_MESSAGE } from '../retry-api-call.js'
|
|
4
5
|
import { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
|
|
5
6
|
import type { PinState } from '../status-pin.js'
|
|
6
7
|
|
|
@@ -88,9 +89,18 @@ describe('reconcilePin (driver)', () => {
|
|
|
88
89
|
expect(calls).toEqual([{ verb: 'unpin', messageId: 42 }])
|
|
89
90
|
})
|
|
90
91
|
|
|
91
|
-
it('CRITICAL: drops the claim
|
|
92
|
+
it('CRITICAL: drops the claim on a TERMINAL unpin failure — never stays stuck pinned', async () => {
|
|
93
|
+
// A 400 means the call REACHED Telegram and was rejected for a reason that
|
|
94
|
+
// cannot change on retry (message gone / rights). Dropping is correct.
|
|
92
95
|
const errors: string[] = []
|
|
93
|
-
const
|
|
96
|
+
const calls: { verb: string; messageId: number }[] = []
|
|
97
|
+
const api: PinBotApi = {
|
|
98
|
+
pinChatMessage: async () => {},
|
|
99
|
+
unpinChatMessage: async (_chat, message_id) => {
|
|
100
|
+
calls.push({ verb: 'unpin', messageId: message_id })
|
|
101
|
+
throw grammyError(400, 'Bad Request: message to unpin not found')
|
|
102
|
+
},
|
|
103
|
+
}
|
|
94
104
|
const next = await reconcilePin({
|
|
95
105
|
api,
|
|
96
106
|
chatId: '123',
|
|
@@ -98,12 +108,75 @@ describe('reconcilePin (driver)', () => {
|
|
|
98
108
|
desired: { pinned: false },
|
|
99
109
|
onError: (phase) => errors.push(phase),
|
|
100
110
|
})
|
|
101
|
-
// State MUST be cleared even though the API threw — never stay stuck pinned.
|
|
102
111
|
expect(next).toBeNull()
|
|
103
112
|
expect(calls).toEqual([{ verb: 'unpin', messageId: 42 }])
|
|
104
113
|
expect(errors).toEqual(['unpin'])
|
|
105
114
|
})
|
|
106
115
|
|
|
116
|
+
it('#3664 Defect B: a FLOOD_WAIT_ACTIVE unpin failure RETAINS the claim (no API call was made)', async () => {
|
|
117
|
+
// FLOOD_WAIT_ACTIVE is a purely LOCAL fail-fast: the send gate refused to
|
|
118
|
+
// issue the call, so the message is provably STILL PINNED. Dropping the
|
|
119
|
+
// claim here erased the in-memory claim AND the durable store row, leaving
|
|
120
|
+
// an orphan nothing could ever find again.
|
|
121
|
+
const errors: string[] = []
|
|
122
|
+
const prev: PinState = { messageId: 42 }
|
|
123
|
+
const next = await reconcilePin({
|
|
124
|
+
api: {
|
|
125
|
+
pinChatMessage: async () => {},
|
|
126
|
+
unpinChatMessage: async () => {
|
|
127
|
+
throw makeFloodWaitActiveError(16739, Date.now() + 16739_000, null)
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
chatId: '123',
|
|
131
|
+
prevState: prev,
|
|
132
|
+
desired: { pinned: false },
|
|
133
|
+
onError: (phase) => errors.push(phase),
|
|
134
|
+
})
|
|
135
|
+
// The claim SURVIVES so the next reconcile / reaper / boot sweep retries.
|
|
136
|
+
expect(next).toEqual({ messageId: 42 })
|
|
137
|
+
expect(errors).toEqual(['unpin'])
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('#3664 Defect B: retains the claim on a never-confirmed transport failure (retries exhausted / network / 5xx)', async () => {
|
|
141
|
+
for (const err of [
|
|
142
|
+
new Error(GIVE_UP_MESSAGE),
|
|
143
|
+
new Error('fetch failed'),
|
|
144
|
+
grammyError(500, 'Internal Server Error'),
|
|
145
|
+
]) {
|
|
146
|
+
const next = await reconcilePin({
|
|
147
|
+
api: {
|
|
148
|
+
pinChatMessage: async () => {},
|
|
149
|
+
unpinChatMessage: async () => {
|
|
150
|
+
throw err
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
chatId: '123',
|
|
154
|
+
prevState: { messageId: 42 },
|
|
155
|
+
desired: { pinned: false },
|
|
156
|
+
})
|
|
157
|
+
expect(next).toEqual({ messageId: 42 })
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('#3664 Defect B: a retained claim retries the unpin and clears once it lands', async () => {
|
|
162
|
+
let attempts = 0
|
|
163
|
+
const api: PinBotApi = {
|
|
164
|
+
pinChatMessage: async () => {},
|
|
165
|
+
unpinChatMessage: async () => {
|
|
166
|
+
attempts += 1
|
|
167
|
+
if (attempts === 1) throw makeFloodWaitActiveError(300, Date.now() + 300_000, null)
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
const common = { api, chatId: '123', desired: { pinned: false } as const }
|
|
171
|
+
|
|
172
|
+
const first = await reconcilePin({ ...common, prevState: { messageId: 42 } })
|
|
173
|
+
expect(first).toEqual({ messageId: 42 }) // retained
|
|
174
|
+
|
|
175
|
+
const second = await reconcilePin({ ...common, prevState: first })
|
|
176
|
+
expect(second).toBeNull() // flood window closed; unpin landed; claim cleared
|
|
177
|
+
expect(attempts).toBe(2)
|
|
178
|
+
})
|
|
179
|
+
|
|
107
180
|
it('does NOT claim a message whose pin failed (retries next reconcile)', async () => {
|
|
108
181
|
const errors: string[] = []
|
|
109
182
|
const { api } = fakeApi({ pinThrows: true })
|
|
@@ -423,7 +496,7 @@ describe('reconcilePin — rights-aware negative cache (#3024)', () => {
|
|
|
423
496
|
}
|
|
424
497
|
|
|
425
498
|
const first = await reconcilePin({ ...common, prevState: { messageId: 21 } })
|
|
426
|
-
expect(first).toBeNull() // claim dropped
|
|
499
|
+
expect(first).toBeNull() // rights 400 is TERMINAL — claim dropped (#3664)
|
|
427
500
|
expect(unpinCalls).toBe(1) // it did try once
|
|
428
501
|
expect(disabled).toEqual(['-1009000000007']) // logged exactly once
|
|
429
502
|
expect(unpinFails).toEqual([]) // NOT routed through per-attempt onError
|
|
@@ -474,3 +547,31 @@ describe('reconcilePin — rights-aware negative cache (#3024)', () => {
|
|
|
474
547
|
}
|
|
475
548
|
})
|
|
476
549
|
})
|
|
550
|
+
|
|
551
|
+
describe('isUnpinTerminalError (#3664 Defect B classifier)', () => {
|
|
552
|
+
it('treats a stable 4xx as terminal — Telegram answered, retrying cannot help', () => {
|
|
553
|
+
expect(isUnpinTerminalError(grammyError(400, 'Bad Request: message to unpin not found'))).toBe(true)
|
|
554
|
+
expect(isUnpinTerminalError(grammyError(400, 'Bad Request: chat not found'))).toBe(true)
|
|
555
|
+
expect(isUnpinTerminalError(grammyError(403, 'Forbidden: bot was kicked'))).toBe(true)
|
|
556
|
+
expect(
|
|
557
|
+
isUnpinTerminalError(
|
|
558
|
+
grammyError(400, 'Bad Request: not enough rights to manage pinned messages in the chat'),
|
|
559
|
+
),
|
|
560
|
+
).toBe(true)
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
it('treats FLOOD_WAIT_ACTIVE as NON-terminal even though it carries the 429 duck-type shape', () => {
|
|
564
|
+
const err = makeFloodWaitActiveError(16739, Date.now() + 16739_000, null)
|
|
565
|
+
expect(err.error_code).toBe(429) // the shape other cooldown gates duck-type on
|
|
566
|
+
expect(isUnpinTerminalError(err)).toBe(false)
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
it('treats transport failures as NON-terminal (the unpin may never have landed)', () => {
|
|
570
|
+
expect(isUnpinTerminalError(new Error(GIVE_UP_MESSAGE))).toBe(false)
|
|
571
|
+
expect(isUnpinTerminalError(new Error('fetch failed'))).toBe(false)
|
|
572
|
+
expect(isUnpinTerminalError(grammyError(500, 'Internal Server Error'))).toBe(false)
|
|
573
|
+
expect(isUnpinTerminalError(grammyError(429, 'Too Many Requests'))).toBe(false)
|
|
574
|
+
expect(isUnpinTerminalError(undefined)).toBe(false)
|
|
575
|
+
expect(isUnpinTerminalError('boom')).toBe(false)
|
|
576
|
+
})
|
|
577
|
+
})
|