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.
Files changed (53) hide show
  1. package/dist/auth-broker/index.js +53 -0
  2. package/dist/cli/switchroom.js +2444 -1264
  3. package/dist/host-control/main.js +54 -1
  4. package/dist/vault/approvals/kernel-server.js +53 -0
  5. package/dist/vault/broker/server.js +53 -0
  6. package/package.json +4 -2
  7. package/skills/switchroom-release/SKILL.md +103 -20
  8. package/telegram-plugin/card-format.ts +92 -3
  9. package/telegram-plugin/dist/gateway/gateway.js +769 -172
  10. package/telegram-plugin/edit-flood-fuse.ts +477 -0
  11. package/telegram-plugin/format.ts +19 -7
  12. package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
  13. package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
  14. package/telegram-plugin/gateway/gateway.ts +66 -56
  15. package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
  16. package/telegram-plugin/gateway/narrative-lane.ts +49 -3
  17. package/telegram-plugin/gateway/status-pin-api.ts +145 -0
  18. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
  19. package/telegram-plugin/retry-api-call.ts +15 -2
  20. package/telegram-plugin/send-gate.ts +1 -1
  21. package/telegram-plugin/status-no-truncate.ts +64 -1
  22. package/telegram-plugin/status-pin-driver.ts +50 -27
  23. package/telegram-plugin/status-pin.ts +43 -5
  24. package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
  25. package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
  26. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
  27. package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
  28. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  29. package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
  30. package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
  31. package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
  32. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
  33. package/telegram-plugin/tests/status-pin.test.ts +106 -5
  34. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
  35. package/telegram-plugin/tests/tool-activity-summary.test.ts +19 -10
  36. package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
  37. package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
  38. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
  39. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
  40. package/telegram-plugin/tool-activity-summary.ts +85 -13
  41. package/telegram-plugin/worker-activity-feed.ts +5 -1
  42. package/vendor/hindsight-memory/scripts/drain_pending.py +193 -25
  43. package/vendor/hindsight-memory/scripts/lib/pending.py +84 -5
  44. package/vendor/hindsight-memory/scripts/lib/retain_split.py +21 -10
  45. package/vendor/hindsight-memory/scripts/recall.py +74 -5
  46. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +158 -4
  47. package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
  48. package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
  49. package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
  50. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
  51. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
  52. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +19 -11
  53. 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
- // Every `void runBootPinCleanupAndDmSweep()` invocation must come AFTER
245
- // the acquireStartupLock outcome is available (the boot block the winner
246
- // or the link()-unsupported fallbackreaches).
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
- /void runBootPinCleanupAndDmSweep\(\)/.test(l) &&
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() itself must only ever be invoked from INSIDE
261
- // that orchestrator — never at a bare post-import call site that a
262
- // losing double-boot could reach.
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
- /async function runBootPinCleanupAndDmSweep/.test(l),
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\(\)/.test(l) &&
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
- const cleanupIdx = afterBlocked.indexOf("void runBootPinCleanupAndDmSweep()");
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 even when unpinChatMessage throws', async () => {
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 { api, calls } = fakeApi({ unpinThrows: true })
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 regardless (drop-on-unpin contract)
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
+ })