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.
- package/dist/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +62 -12
- package/telegram-plugin/gateway/boot-sweep-gate.ts +24 -0
- package/telegram-plugin/gateway/gateway.ts +8 -8
- package/telegram-plugin/gateway/stale-pin-sweep-store.ts +39 -2
- package/telegram-plugin/gateway/stale-pin-sweep.test.ts +162 -0
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +15 -2
package/dist/cli/switchroom.js
CHANGED
|
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
|
|
|
2120
2120
|
});
|
|
2121
2121
|
|
|
2122
2122
|
// src/build-info.ts
|
|
2123
|
-
var VERSION = "0.20.
|
|
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.
|
|
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.
|
|
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.
|
|
102432
|
-
var COMMIT_SHA = "
|
|
102433
|
-
var COMMIT_DATE = "2026-08-
|
|
102434
|
-
var LATEST_PR =
|
|
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
|
-
/**
|
|
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
|
|
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([
|
|
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
|
}),
|