switchroom 0.20.5 → 0.20.6

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.
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
2120
2120
  });
2121
2121
 
2122
2122
  // src/build-info.ts
2123
- var VERSION = "0.20.5", COMMIT_SHA = "fc40cc8e";
2123
+ var VERSION = "0.20.6", COMMIT_SHA = "4b1ad9b1";
2124
2124
 
2125
2125
  // src/cli/resolve-version.ts
2126
2126
  import { existsSync, readFileSync } from "node:fs";
@@ -21432,7 +21432,7 @@ function allocateAgentUid(name) {
21432
21432
  }
21433
21433
 
21434
21434
  // src/build-info.ts
21435
- var VERSION = "0.20.5";
21435
+ var VERSION = "0.20.6";
21436
21436
 
21437
21437
  // src/setup/hindsight-recall-tunables.ts
21438
21438
  var RECALL_DEADLINE_HEADROOM_SECONDS = 2;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
3
  "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
- "version": "0.20.5",
4
+ "version": "0.20.6",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -89491,6 +89491,7 @@ async function runBootPinSweepSteps(deps) {
89491
89491
  await step("status-pin-cleanup", deps.statusPinCleanup, log);
89492
89492
  await step("activity-card-reaper", deps.activityCardReaper, log);
89493
89493
  await step("queued-card-reaper", deps.queuedCardReaper, log);
89494
+ await step("seed-prune-sweep-cursors", async () => deps.seedPruneSweepCursors(), log);
89494
89495
  await step("enable-sweep", async () => deps.enableSweep(), log);
89495
89496
  for (const t of targets) {
89496
89497
  await step(`stale-pin-sweep:${sweepTargetKey(t.chatId, t.threadId)}`, () => deps.sweepTarget(t), log);
@@ -90303,6 +90304,56 @@ function createGatewayStalePinSweeper(w) {
90303
90304
  });
90304
90305
  }
90305
90306
 
90307
+ // gateway/stale-pin-sweep-store.ts
90308
+ var SWEEP_ENVELOPE_VERSION2 = 1;
90309
+ function isCursorRow2(x) {
90310
+ if (x == null || typeof x !== "object")
90311
+ return false;
90312
+ const o = x;
90313
+ return typeof o.chatId === "string" && o.chatId.length > 0 && (o.threadId === undefined || typeof o.threadId === "number") && (o.kind === "dm" || o.kind === "forum-topic" || o.kind === "supergroup") && typeof o.popped === "number" && typeof o.done === "boolean" && typeof o.attempts === "number" && (o.doneIds === undefined || Array.isArray(o.doneIds) && o.doneIds.every((n) => typeof n === "number")) && (o.lastStatus === undefined || typeof o.lastStatus === "string") && typeof o.updatedAt === "number";
90314
+ }
90315
+ function loadSweepCursors2(path2, fs2) {
90316
+ if (!fs2.existsSync(path2))
90317
+ return [];
90318
+ let raw = "";
90319
+ try {
90320
+ raw = fs2.readFileSync(path2);
90321
+ } catch {
90322
+ return [];
90323
+ }
90324
+ let parsed;
90325
+ try {
90326
+ parsed = JSON.parse(raw);
90327
+ } catch {
90328
+ return [];
90329
+ }
90330
+ if (parsed == null || typeof parsed !== "object")
90331
+ return [];
90332
+ const env = parsed;
90333
+ if (!Number.isInteger(env.v) || env.v < 1 || !Array.isArray(env.cursors)) {
90334
+ return [];
90335
+ }
90336
+ return env.cursors.filter(isCursorRow2);
90337
+ }
90338
+ function persistSweepCursors2(path2, fs2, cursors, log = (l) => process.stderr.write(l)) {
90339
+ const env = { v: SWEEP_ENVELOPE_VERSION2, cursors: [...cursors] };
90340
+ try {
90341
+ fs2.writeFileSync(path2, JSON.stringify(env));
90342
+ } catch (err) {
90343
+ log(`stale-pin-sweep-store: persist FAILED path=${path2}: ${err.message} \u2014 ` + `durability degraded; a restart mid-sweep will re-seed instead of resume
90344
+ `);
90345
+ }
90346
+ }
90347
+ function pruneSweepCursors(cursors) {
90348
+ return cursors.filter((c) => !c.done);
90349
+ }
90350
+ function reseedSweepLedger(path2, fs2, log = (l) => process.stderr.write(l)) {
90351
+ const rows = loadSweepCursors2(path2, fs2);
90352
+ const pruned = pruneSweepCursors(rows);
90353
+ if (pruned.length !== rows.length)
90354
+ persistSweepCursors2(path2, fs2, pruned, log);
90355
+ }
90356
+
90306
90357
  // gateway/webhook-ingest-server.ts
90307
90358
  import net4 from "node:net";
90308
90359
  import { chmodSync as chmodSync10, existsSync as existsSync39, unlinkSync as unlinkSync18 } from "node:fs";
@@ -102428,10 +102479,10 @@ function startOutboxSweep(deps) {
102428
102479
  }
102429
102480
 
102430
102481
  // ../src/build-info.ts
102431
- var VERSION2 = "0.20.5";
102432
- var COMMIT_SHA = "fc40cc8e";
102433
- var COMMIT_DATE = "2026-08-04T04:03:52Z";
102434
- var LATEST_PR = 4338;
102482
+ var VERSION2 = "0.20.6";
102483
+ var COMMIT_SHA = "4b1ad9b1";
102484
+ var COMMIT_DATE = "2026-08-04T07:34:19Z";
102485
+ var LATEST_PR = 4340;
102435
102486
  var COMMITS_AHEAD_OF_TAG = 0;
102436
102487
 
102437
102488
  // gateway/boot-version.ts
@@ -108140,19 +108191,17 @@ async function unpinAllStatusPins() {
108140
108191
  }
108141
108192
  var stalePinSweepEligible = false;
108142
108193
  var STALE_PIN_SWEEP_STORE_PATH = join71(STATE_DIR, "stale-pin-sweep.json");
108194
+ var sweepStoreFs = {
108195
+ readFileSync: (p) => readFileSync64(p, "utf-8"),
108196
+ writeFileSync: (p, data) => atomicWriteFileSync(p, data, 384),
108197
+ existsSync: (p) => existsSync61(p)
108198
+ };
108143
108199
  var stalePinSweeper = createGatewayStalePinSweeper({
108144
108200
  telegram: { handle: () => lockedBot, call: robustApiCall },
108145
108201
  claims: () => statusPinClaims.values(),
108146
108202
  loadPinRows: () => statusPinPersistEnabled || bannerPinPersistEnabled || toolPinPersistEnabled ? loadStatusPins2(STATUS_PIN_STORE_PATH, statusPinStoreFs) : [],
108147
108203
  eligible: () => stalePinSweepEligible,
108148
- store: {
108149
- path: STALE_PIN_SWEEP_STORE_PATH,
108150
- fs: {
108151
- readFileSync: (p) => readFileSync64(p, "utf-8"),
108152
- writeFileSync: (p, data) => atomicWriteFileSync(p, data, 384),
108153
- existsSync: (p) => existsSync61(p)
108154
- }
108155
- },
108204
+ store: { path: STALE_PIN_SWEEP_STORE_PATH, fs: sweepStoreFs },
108156
108205
  allowUnpinAllForumTopic: process.env.SWITCHROOM_PIN_SWEEP_UNPIN_ALL_TOPIC == null ? undefined : process.env.SWITCHROOM_PIN_SWEEP_UNPIN_ALL_TOPIC === "1"
108157
108206
  });
108158
108207
  function runBootPinCleanupAndStalePinSweep() {
@@ -108168,6 +108217,7 @@ function runBootPinCleanupAndStalePinSweep() {
108168
108217
  enableSweep: () => {
108169
108218
  stalePinSweepEligible = true;
108170
108219
  },
108220
+ seedPruneSweepCursors: () => reseedSweepLedger(STALE_PIN_SWEEP_STORE_PATH, sweepStoreFs),
108171
108221
  sweepTarget: (t) => stalePinSweeper.sweepTarget(t),
108172
108222
  log: (line) => process.stderr.write(line)
108173
108223
  });
@@ -103,6 +103,21 @@ export interface BootPinSweepSteps {
103
103
  /** Flips the flag authorising the stale-pin drain — for this sweep AND for
104
104
  * later lazy first-inbound sweeps. */
105
105
  enableSweep: () => void
106
+ /**
107
+ * BOOT-SEED PRUNE (#3953 regression fix). Drops discharged (`done: true`)
108
+ * cursor rows from the sweep ledger so a `(chat, thread)` that drained in a
109
+ * PRIOR session is re-evaluated live this boot, instead of short-circuiting
110
+ * forever on a stale `done`. Kind-agnostic — clears DM, forum-topic and
111
+ * supergroup rows alike. Forfeited rows (`attempts >= SWEEP_MAX_ATTEMPTS`)
112
+ * are RETAINED by the underlying `pruneSweepCursors`, so a chat the bot
113
+ * cannot pin does not re-burn its attempt budget every boot.
114
+ *
115
+ * MUST stay BOOT-scoped. The lazy first-inbound sweep fires on EVERY message;
116
+ * pruning there would re-arm a full re-drain per message = a Telegram flood.
117
+ * Runs once here, before the `sweepTarget` loop, so the loop re-evaluates the
118
+ * freshly-reseeded ledger.
119
+ */
120
+ seedPruneSweepCursors: () => void
106
121
  sweepTarget: (target: SweepTarget) => Promise<unknown>
107
122
  log?: (line: string) => void
108
123
  }
@@ -161,6 +176,15 @@ export async function runBootPinSweepSteps(deps: BootPinSweepSteps): Promise<voi
161
176
  await step('activity-card-reaper', deps.activityCardReaper, log)
162
177
  await step('queued-card-reaper', deps.queuedCardReaper, log)
163
178
 
179
+ // Boot-seed prune (#3953): reseed the cursor ledger so a chat that drained in
180
+ // a prior session is re-evaluated live below, rather than short-circuiting on
181
+ // a stale `done`. Boot-scoped ONLY — never on the per-inbound path. Runs
182
+ // BEFORE enableSweep so eligibility (which also gates the lazy first-inbound
183
+ // sweep) is granted only once the ledger is reseeded — no window where an
184
+ // inbound observes a stale `done`. Isolated: a throw here must not strand the
185
+ // drain, and enableSweep below still runs regardless.
186
+ await step('seed-prune-sweep-cursors', async () => deps.seedPruneSweepCursors(), log)
187
+
164
188
  // Unconditional: reached even when every step above threw. See the docblock.
165
189
  await step('enable-sweep', async () => deps.enableSweep(), log)
166
190
  for (const t of targets) {
@@ -648,6 +648,7 @@ import { withDeadline } from './with-deadline.js'
648
648
  import { createStatusPinApi, type PinCapableBot, type RobustApiSeam } from './status-pin-api.js'
649
649
  import { collectSweepTargets, type StalePinSweeper, type SweepTarget } from './stale-pin-sweep.js'
650
650
  import { createGatewayStalePinSweeper } from './stale-pin-sweep-wiring.js'
651
+ import { reseedSweepLedger } from './stale-pin-sweep-store.js'
651
652
  import { atomicWriteFileSync } from '../../src/util/atomic.js'
652
653
  import { startWebhookIngestServer } from './webhook-ingest-server.js'
653
654
  import { recordWebhookEvent } from '../../src/web/webhook-gateway-record.js'
@@ -9041,6 +9042,11 @@ let stalePinSweepEligible = false
9041
9042
  // the previous complete ledger or the new one — never a truncated file that
9042
9043
  // forgets an in-flight drain.
9043
9044
  const STALE_PIN_SWEEP_STORE_PATH = join(STATE_DIR, 'stale-pin-sweep.json')
9045
+ const sweepStoreFs = {
9046
+ readFileSync: (p: string) => readFileSync(p, 'utf-8'),
9047
+ writeFileSync: (p: string, data: string) => atomicWriteFileSync(p, data, 0o600),
9048
+ existsSync: (p: string) => existsSync(p),
9049
+ }
9044
9050
  const stalePinSweeper: StalePinSweeper = createGatewayStalePinSweeper({
9045
9051
  telegram: { handle: () => lockedBot, call: robustApiCall },
9046
9052
  claims: () => statusPinClaims.values(),
@@ -9049,14 +9055,7 @@ const stalePinSweeper: StalePinSweeper = createGatewayStalePinSweeper({
9049
9055
  ? loadStatusPins(STATUS_PIN_STORE_PATH, statusPinStoreFs)
9050
9056
  : [],
9051
9057
  eligible: () => stalePinSweepEligible,
9052
- store: {
9053
- path: STALE_PIN_SWEEP_STORE_PATH,
9054
- fs: {
9055
- readFileSync: (p) => readFileSync(p, 'utf-8'),
9056
- writeFileSync: (p, data) => atomicWriteFileSync(p, data, 0o600),
9057
- existsSync: (p) => existsSync(p),
9058
- },
9059
- },
9058
+ store: { path: STALE_PIN_SWEEP_STORE_PATH, fs: sweepStoreFs },
9060
9059
  // Per-deployment override only. UNSET (the normal case) means "take the
9061
9060
  // standing policy" — UNPIN_ALL_FORUM_TOPIC_ENABLED in stale-pin-sweep.ts,
9062
9061
  // i.e. the WHOLESALE topic drain stays off because it also removes pins this
@@ -9099,6 +9098,7 @@ function runBootPinCleanupAndStalePinSweep(): Promise<void> {
9099
9098
  enableSweep: () => {
9100
9099
  stalePinSweepEligible = true
9101
9100
  },
9101
+ seedPruneSweepCursors: () => reseedSweepLedger(STALE_PIN_SWEEP_STORE_PATH, sweepStoreFs),
9102
9102
  sweepTarget: (t: SweepTarget) => stalePinSweeper.sweepTarget(t),
9103
9103
  log: (line) => process.stderr.write(line),
9104
9104
  })
@@ -221,7 +221,44 @@ export function pendingSweepCursors(cursors: readonly SweepCursor[]): SweepCurso
221
221
  return cursors.filter((c) => !c.done && c.attempts < SWEEP_MAX_ATTEMPTS)
222
222
  }
223
223
 
224
- /** Drop discharged and forfeited rows — called once the ledger is reseeded. */
224
+ /**
225
+ * Boot seed pass: drop DISCHARGED (`done: true`) rows ONLY, so a chat that
226
+ * drained in a prior session is re-evaluated LIVE on the next sweep instead of
227
+ * short-circuiting forever on a stale `done` (#3953 regression — the seed pass
228
+ * was never wired, so `done` was effectively permanent).
229
+ *
230
+ * FORFEITED rows (`attempts >= SWEEP_MAX_ATTEMPTS`, still `!done`) are
231
+ * deliberately RETAINED. Their whole purpose is to remember that a chat's
232
+ * attempt budget is spent (bot kicked, pin rights revoked, a chat that
233
+ * flood-waits forever). Pruning one would let the next boot re-seed it from the
234
+ * pin stores and re-burn the full 8-attempt budget on every boot for the rest
235
+ * of time — precisely the Telegram flood the attempt cap exists to prevent. So
236
+ * this filters on `done` ALONE; it must NOT also gate on `attempts`.
237
+ */
225
238
  export function pruneSweepCursors(cursors: readonly SweepCursor[]): SweepCursor[] {
226
- return cursors.filter((c) => !c.done && c.attempts < SWEEP_MAX_ATTEMPTS)
239
+ return cursors.filter((c) => !c.done)
240
+ }
241
+
242
+ /**
243
+ * BOOT-SEED reseed of the durable ledger (#3953): drop discharged (`done:true`)
244
+ * rows so a `(chat, thread)` that drained in a prior session is re-evaluated
245
+ * LIVE on the next sweep instead of short-circuiting forever on a stale `done`.
246
+ * Forfeited rows are retained (see {@link pruneSweepCursors}).
247
+ *
248
+ * Writes ONLY when the prune changed the ledger, so a no-op boot never churns
249
+ * the durable file. Never throws — a reseed failure degrades to the pre-fix
250
+ * behaviour (the stale `done` survives one more boot), it must not break boot.
251
+ *
252
+ * BOOT-SCOPED BY CONTRACT: the caller must invoke this once at boot, never on
253
+ * the per-inbound path (which fires on every message — re-arming a full re-drain
254
+ * per message is a Telegram flood).
255
+ */
256
+ export function reseedSweepLedger(
257
+ path: string,
258
+ fs: SweepStoreFsSeam,
259
+ log: (line: string) => void = (l) => process.stderr.write(l),
260
+ ): void {
261
+ const rows = loadSweepCursors(path, fs)
262
+ const pruned = pruneSweepCursors(rows)
263
+ if (pruned.length !== rows.length) persistSweepCursors(path, fs, pruned, log)
227
264
  }
@@ -32,6 +32,8 @@ import {
32
32
  import {
33
33
  SWEEP_MAX_ATTEMPTS,
34
34
  loadSweepCursors,
35
+ pruneSweepCursors,
36
+ reseedSweepLedger,
35
37
  upsertSweepCursor,
36
38
  type SweepCursor,
37
39
  type SweepStoreFsSeam,
@@ -907,3 +909,163 @@ describe('stale-pin sweep — classification and seeding', () => {
907
909
  expect(isNothingToUnpinError(new Error('something else'))).toBe(false)
908
910
  })
909
911
  })
912
+
913
+ // ─── #3953: the boot-seed prune reseeds a discharged obligation ───────────────
914
+ //
915
+ // Regression #3953 replaced the per-boot DM self-heal with a cursor-gated stack
916
+ // drain, but the boot "seed pass" that clears discharged cursors was never
917
+ // wired — so `done:true` was effectively PERMANENT and every later boot /
918
+ // first-inbound sweep short-circuited on `already-drained`, orphaning any pin
919
+ // that leaked AFTER the first drain. These tests pin the reseed contract:
920
+ // 1. `pruneSweepCursors` drops `done` rows ONLY and RETAINS forfeited
921
+ // (attempts-exhausted) rows — a store-level test that FAILS on the pre-fix
922
+ // filter (which also dropped forfeited rows, re-burning the attempt budget
923
+ // every boot = flood risk).
924
+ // 2. Running that prune between two fresh-process sweeps re-arms the drain, so
925
+ // a pin leaked after a prior discharge is reaped — for a DM AND a
926
+ // supergroup (channel-class) target alike.
927
+
928
+ /** The boot-seed reseed exactly as the gateway wires it (`gateway.ts`
929
+ * `seedPruneSweepCursors` → `reseedSweepLedger`). */
930
+ function bootSeedPrune(fs: SweepStoreFsSeam, path: string): void {
931
+ reseedSweepLedger(path, fs, () => {})
932
+ }
933
+
934
+ /** A sweeper over a SHARED durable store — a distinct instance models a fresh
935
+ * process (reboot), so the only state carried across is the on-disk ledger. */
936
+ function sweeperOver(
937
+ fs: SweepStoreFsSeam,
938
+ path: string,
939
+ fake: ReturnType<typeof fakeChat>,
940
+ opts: { recordedPinIds?: number[] } = {},
941
+ ) {
942
+ let clock = 1_000_000
943
+ const deps: StalePinSweepDeps = {
944
+ getTopPinnedMessageId: fake.getTopPinnedMessageId,
945
+ pinSilent: fake.pinSilent,
946
+ unpin: fake.unpin,
947
+ unpinAllForumTopicMessages: fake.unpinAllForumTopicMessages,
948
+ canPinInChat: fake.canPinInChat,
949
+ protectedMessageIds: () => [],
950
+ recordedPinIds: () => opts.recordedPinIds ?? [],
951
+ eligible: () => true,
952
+ sleep: async (ms) => {
953
+ clock += ms
954
+ },
955
+ now: () => clock,
956
+ store: { path, fs },
957
+ log: () => {},
958
+ }
959
+ return createStalePinSweeper(deps)
960
+ }
961
+
962
+ describe('pruneSweepCursors — boot-seed reseed (#3953)', () => {
963
+ const now = 1_000_000
964
+
965
+ it('drops discharged rows but RETAINS forfeited (attempts-exhausted) rows', () => {
966
+ const discharged: SweepCursor = {
967
+ chatId: DM,
968
+ kind: 'dm',
969
+ popped: 3,
970
+ done: true,
971
+ attempts: 1,
972
+ updatedAt: now,
973
+ }
974
+ // A no-rights group that spent its whole attempt budget: `done` is false,
975
+ // but re-seeding it would re-burn all 8 attempts on the NEXT boot, and every
976
+ // boot after — the exact Telegram flood the attempt cap exists to stop.
977
+ const forfeited: SweepCursor = {
978
+ chatId: GROUP,
979
+ kind: 'supergroup',
980
+ popped: 0,
981
+ done: false,
982
+ attempts: SWEEP_MAX_ATTEMPTS,
983
+ lastStatus: 'skipped-no-rights',
984
+ updatedAt: now,
985
+ }
986
+ const stillOwed: SweepCursor = {
987
+ chatId: '900000002',
988
+ kind: 'dm',
989
+ popped: 0,
990
+ done: false,
991
+ attempts: 2,
992
+ updatedAt: now,
993
+ }
994
+
995
+ const keys = pruneSweepCursors([discharged, forfeited, stillOwed]).map((c) => c.chatId)
996
+
997
+ expect(keys).not.toContain(DM) // discharged → reseeded (dropped)
998
+ expect(keys).toContain(GROUP) // forfeited no-rights → RETAINED (no re-burn)
999
+ expect(keys).toContain('900000002') // still owed → retained untouched
1000
+ })
1001
+
1002
+ it('clears discharged rows for EVERY surface — dm, forum-topic, supergroup', () => {
1003
+ // The prune must be kind-agnostic: a stuck `done` on a topic or a channel is
1004
+ // the same regression as on a DM, so none may be left short-circuiting.
1005
+ const rows: SweepCursor[] = [
1006
+ { chatId: '1', kind: 'dm', popped: 1, done: true, attempts: 1, updatedAt: now },
1007
+ { chatId: '2', kind: 'forum-topic', threadId: 5, popped: 1, done: true, attempts: 1, updatedAt: now },
1008
+ { chatId: '3', kind: 'supergroup', popped: 1, done: true, attempts: 1, updatedAt: now },
1009
+ ]
1010
+ expect(pruneSweepCursors(rows)).toEqual([])
1011
+ })
1012
+ })
1013
+
1014
+ describe('stale-pin sweep — re-drain after the boot-seed prune (#3953)', () => {
1015
+ it('re-drains a DM whose obligation was discharged in a prior session', async () => {
1016
+ const fs = memFs()
1017
+ const path = '/state/stale-pin-sweep.json'
1018
+
1019
+ // Session 1: an orphan bot pin is drained and the obligation discharged.
1020
+ const s1 = await sweeperOver(fs, path, fakeChat({ stack: [11] })).sweepTarget({ chatId: DM })
1021
+ expect(s1.status).toBe('drained')
1022
+ expect(loadSweepCursors(path, fs).find((c) => c.chatId === DM)?.done).toBe(true)
1023
+
1024
+ // A NEW orphan pin leaks in after that drain.
1025
+ // Fresh process, no prune: the sweep short-circuits on the stale `done` —
1026
+ // the #3953 regression, verbatim. The orphan is left pinned.
1027
+ const stuckChat = fakeChat({ stack: [99] })
1028
+ const stuck = await sweeperOver(fs, path, stuckChat).sweepTarget({ chatId: DM })
1029
+ expect(stuck.status).toBe('already-drained')
1030
+ expect(stuck.popped).toBe(0)
1031
+ expect(stuckChat.stack).toEqual([99])
1032
+
1033
+ // Reboot WITH the boot-seed prune: the discharged row is reseeded, so the
1034
+ // next sweep re-evaluates the chat LIVE and reaps the orphan.
1035
+ bootSeedPrune(fs, path)
1036
+ const rebootChat = fakeChat({ stack: [99] })
1037
+ const redrain = await sweeperOver(fs, path, rebootChat).sweepTarget({ chatId: DM })
1038
+ expect(redrain.status).toBe('drained')
1039
+ expect(redrain.popped).toBe(1)
1040
+ expect(rebootChat.stack).toEqual([])
1041
+ })
1042
+
1043
+ it('re-drains a supergroup (channel-class) discharged in a prior session', async () => {
1044
+ const fs = memFs()
1045
+ const path = '/state/stale-pin-sweep.json'
1046
+
1047
+ // Session 1: the one recorded orphan is reaped, obligation discharged.
1048
+ const s1 = await sweeperOver(fs, path, fakeChat({ stack: [77], canPin: true }), {
1049
+ recordedPinIds: [77],
1050
+ }).sweepTarget({ chatId: GROUP })
1051
+ expect(s1.status).toBe('drained')
1052
+ expect(loadSweepCursors(path, fs).find((c) => c.chatId === GROUP)?.done).toBe(true)
1053
+
1054
+ // A fresh recorded orphan leaks in; a fresh process short-circuits on `done`.
1055
+ const stuckChat = fakeChat({ stack: [88], canPin: true })
1056
+ const stuck = await sweeperOver(fs, path, stuckChat, { recordedPinIds: [88] }).sweepTarget({
1057
+ chatId: GROUP,
1058
+ })
1059
+ expect(stuck.status).toBe('already-drained')
1060
+ expect(stuckChat.stack).toEqual([88])
1061
+
1062
+ // Reboot + prune: the supergroup row is reseeded and re-swept.
1063
+ bootSeedPrune(fs, path)
1064
+ const rebootChat = fakeChat({ stack: [88], canPin: true })
1065
+ const redrain = await sweeperOver(fs, path, rebootChat, { recordedPinIds: [88] }).sweepTarget({
1066
+ chatId: GROUP,
1067
+ })
1068
+ expect(redrain.status).toBe('drained')
1069
+ expect(rebootChat.stack).toEqual([])
1070
+ })
1071
+ })
@@ -161,6 +161,7 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
161
161
  order.push('queued-cards')
162
162
  },
163
163
  enableSweep: () => order.push('enable-sweep'),
164
+ seedPruneSweepCursors: () => order.push('seed-prune'),
164
165
  sweepTarget: async (t) => {
165
166
  order.push(`sweep:${t.chatId}:${t.threadId ?? '-'}`)
166
167
  },
@@ -177,6 +178,7 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
177
178
  'status-pins',
178
179
  'activity-cards',
179
180
  'queued-cards',
181
+ 'seed-prune',
180
182
  'enable-sweep',
181
183
  'sweep:900000001:-',
182
184
  ])
@@ -194,6 +196,7 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
194
196
  'scan',
195
197
  'status-pins',
196
198
  'queued-cards',
199
+ 'seed-prune',
197
200
  'enable-sweep',
198
201
  'sweep:900000001:-',
199
202
  ])
@@ -210,7 +213,7 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
210
213
  queuedCardReaper: boom,
211
214
  })
212
215
  await runBootPinSweepSteps(d.deps)
213
- expect(d.order).toEqual(['scan', 'enable-sweep', 'sweep:900000001:-'])
216
+ expect(d.order).toEqual(['scan', 'seed-prune', 'enable-sweep', 'sweep:900000001:-'])
214
217
  })
215
218
 
216
219
  it('a failing store scan still lets the reapers and the DM enable run', async () => {
@@ -221,7 +224,13 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
221
224
  })
222
225
  await runBootPinSweepSteps(d.deps)
223
226
  // No targets to sweep, but nothing behind the scan is stranded.
224
- expect(d.order).toEqual(['status-pins', 'activity-cards', 'queued-cards', 'enable-sweep'])
227
+ expect(d.order).toEqual([
228
+ 'status-pins',
229
+ 'activity-cards',
230
+ 'queued-cards',
231
+ 'seed-prune',
232
+ 'enable-sweep',
233
+ ])
225
234
  expect(d.logs.join('')).toContain("step 'sweep-target-scan' failed: ENOENT")
226
235
  })
227
236
 
@@ -272,6 +281,7 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
272
281
  order.push('queued-cards')
273
282
  },
274
283
  enableSweep: () => order.push('enable-sweep'),
284
+ seedPruneSweepCursors: () => {},
275
285
  sweepTarget: async () => {},
276
286
  log: () => {
277
287
  throw new Error('EPIPE')
@@ -296,6 +306,9 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
296
306
  enableSweep: () => {
297
307
  throw new Error('boom')
298
308
  },
309
+ seedPruneSweepCursors: () => {
310
+ throw new Error('boom')
311
+ },
299
312
  sweepTarget: boom,
300
313
  log: () => {},
301
314
  }),