switchroom 0.19.31 → 0.19.33

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.
@@ -17551,6 +17551,9 @@ function projectTranscriptLine(line) {
17551
17551
  if (op === "dequeue") {
17552
17552
  return [{ kind: "dequeue" }];
17553
17553
  }
17554
+ if (op === "remove") {
17555
+ return [{ kind: "queue_remove", rawContent: obj.content ?? "" }];
17556
+ }
17554
17557
  return [];
17555
17558
  }
17556
17559
  if (type === "assistant") {
@@ -102,12 +102,20 @@ export interface SweepStoreFsSeam {
102
102
  existsSync: (path: string) => boolean
103
103
  }
104
104
 
105
+ /**
106
+ * Envelope. Same bump rule as `status-pin-store.ts` (#3957): every field added
107
+ * after v1 is OPTIONAL, and the reader is version-TOLERANT so a row written by
108
+ * a newer build survives a downgrade rather than being discarded. Discarding is
109
+ * not "do nothing" for an obligation ledger — it forgets an obligation, which
110
+ * is precisely the orphaned-pin failure this module exists to close.
111
+ */
105
112
  interface SweepEnvelope {
106
- v: 1
113
+ v: number
107
114
  cursors: SweepCursor[]
108
115
  }
109
116
 
110
- const SWEEP_ENVELOPE_VERSIONS = new Set([1])
117
+ /** The version THIS build writes. Only the writer is pinned; see above. */
118
+ const SWEEP_ENVELOPE_VERSION = 1
111
119
 
112
120
  /** Stable identity of a sweep target: chat plus topic (topic-less = `-`). */
113
121
  export function sweepTargetKey(chatId: string, threadId?: number): string {
@@ -153,11 +161,9 @@ export function loadSweepCursors(path: string, fs: SweepStoreFsSeam): SweepCurso
153
161
  }
154
162
  if (parsed == null || typeof parsed !== 'object') return []
155
163
  const env = parsed as Record<string, unknown>
156
- if (
157
- typeof env.v !== 'number' ||
158
- !SWEEP_ENVELOPE_VERSIONS.has(env.v) ||
159
- !Array.isArray(env.cursors)
160
- ) {
164
+ // Version-TOLERANT, structurally strict — see {@link SweepEnvelope}. An
165
+ // unknown `v` still yields the cursor rows that validate.
166
+ if (!Number.isInteger(env.v) || (env.v as number) < 1 || !Array.isArray(env.cursors)) {
161
167
  return []
162
168
  }
163
169
  return env.cursors.filter(isCursorRow)
@@ -174,7 +180,7 @@ export function persistSweepCursors(
174
180
  cursors: readonly SweepCursor[],
175
181
  log: (line: string) => void = (l) => process.stderr.write(l),
176
182
  ): void {
177
- const env: SweepEnvelope = { v: 1, cursors: [...cursors] }
183
+ const env: SweepEnvelope = { v: SWEEP_ENVELOPE_VERSION, cursors: [...cursors] }
178
184
  try {
179
185
  fs.writeFileSync(path, JSON.stringify(env))
180
186
  } catch (err) {
@@ -73,6 +73,15 @@ interface FakeOpts {
73
73
  pinErrors?: (unknown | null)[]
74
74
  /** Errors to throw from `unpinAllForumTopicMessages`, consumed in order. */
75
75
  unpinAllErrors?: (unknown | null)[]
76
+ /**
77
+ * Ids `unpinAllForumTopicMessages` removes. Undefined = the whole stack.
78
+ *
79
+ * The verb is TOPIC-scoped while `getChat` exposes the CHAT-wide top, so a
80
+ * successful topic drain routinely leaves pins belonging to other topics —
81
+ * including the one on top — untouched. That is exactly the case where a
82
+ * call-counted `popped` and an observation-counted `popped` disagree.
83
+ */
84
+ unpinAllTopicRemoves?: number[]
76
85
  canPin?: boolean
77
86
  /** Throws from `getChat`, consumed in order (null = normal read). */
78
87
  getChatErrors?: (unknown | null)[]
@@ -114,7 +123,14 @@ function fakeChat(o: FakeOpts) {
114
123
  calls.push(`unpinAllTopic:${threadId}`)
115
124
  const err = unpinAllErrors.shift()
116
125
  if (err != null) throw err
117
- stack.length = 0
126
+ if (o.unpinAllTopicRemoves == null) {
127
+ stack.length = 0
128
+ } else {
129
+ for (const id of o.unpinAllTopicRemoves) {
130
+ const at = stack.indexOf(id)
131
+ if (at >= 0) stack.splice(at, 1)
132
+ }
133
+ }
118
134
  return { ok: true }
119
135
  },
120
136
  canPinInChat: async () => {
@@ -270,6 +286,32 @@ describe('stale-pin sweep — draining a stacked chat', () => {
270
286
  expect(h.fake.stack).toEqual([41])
271
287
  })
272
288
 
289
+ it('does not lose an unpin that landed after the verifying read went dark (#3956)', async () => {
290
+ // Sequence: read/read agree on 61 → repin 61 → unpin 61 (it really pops) →
291
+ // the read that WOULD have verified it throws, so the sweep exits early.
292
+ //
293
+ // `popped` must stay 0 — the module never credits an unobserved pop — but
294
+ // the removal must not vanish from the accounting either: it is reported as
295
+ // one `issued`, which is what makes the ledger's "went out, unverified"
296
+ // figure honest instead of silently short by one.
297
+ const h = harness({ stack: [61, 62], getChatErrors: [null, null, new Error('ETIMEDOUT')] })
298
+ const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
299
+
300
+ expect(res.status).toBe('incomplete')
301
+ // The pop DID happen in the chat — this is not a hypothetical loss.
302
+ expect(h.fake.stack).toEqual([62])
303
+ expect(res.popped).toBe(0)
304
+ expect(res.issued).toBe(1)
305
+ })
306
+
307
+ it('reports issued alongside popped on a clean DM drain', async () => {
308
+ const h = harness({ stack: [71, 72] })
309
+ const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
310
+ expect(res.status).toBe('drained')
311
+ expect(res.popped).toBe(2)
312
+ expect(res.issued).toBe(2)
313
+ })
314
+
273
315
  it('accepts Telegram\'s honest empty-stack signal from unpin', async () => {
274
316
  const h = harness({ stack: [51] })
275
317
  h.deps.unpin = async () => {
@@ -448,6 +490,38 @@ describe('stale-pin sweep — forum topics', () => {
448
490
  expect(h.sleeps).not.toContain(3000) // it did not even back off — it gave up
449
491
  })
450
492
 
493
+ it('credits the wholesale drain a POP only when the observed top moved (#3955)', async () => {
494
+ // A real, ordinary shape: the topic drain clears THIS topic's pins (202)
495
+ // while the chat-wide top (201) belongs to another topic and survives. The
496
+ // obligation IS discharged, but nothing observable moved — so this is one
497
+ // `issued`, zero `popped`. Counting the call as a pop would give the forum
498
+ // branch a different meaning for `popped` than every other branch has.
499
+ const h = harness({
500
+ stack: [201, 202],
501
+ allowUnpinAllForumTopic: true,
502
+ unpinAllTopicRemoves: [202],
503
+ })
504
+ const res = await createStalePinSweeper(h.deps).sweepTarget(topic)
505
+
506
+ expect(h.fake.calls.filter((c) => c === 'unpinAllTopic:77')).toHaveLength(1)
507
+ expect(res.status).toBe('drained')
508
+ expect(res.issued).toBe(1)
509
+ expect(res.popped).toBe(0)
510
+ // …and the durable counter did not inherit the call count either.
511
+ expect(h.cursors()[0].popped).toBe(0)
512
+ })
513
+
514
+ it('credits the wholesale drain a pop when the top DID move', async () => {
515
+ const h = harness({ stack: [211, 212], allowUnpinAllForumTopic: true })
516
+ const res = await createStalePinSweeper(h.deps).sweepTarget(topic)
517
+
518
+ expect(h.fake.stack).toEqual([])
519
+ expect(res.status).toBe('drained')
520
+ expect(res.issued).toBe(1)
521
+ expect(res.popped).toBe(1)
522
+ expect(h.cursors()[0].popped).toBe(1)
523
+ })
524
+
451
525
  it('routes the wholesale-drain policy through ONE predicate', () => {
452
526
  // Deliberately pinned: the destructive remedy is opt-in in exactly one
453
527
  // place, and no chat class reaches it implicitly.
@@ -704,6 +778,37 @@ describe('stale-pin sweep — eligibility and durable resume', () => {
704
778
  expect(res.status).toBe('drained')
705
779
  })
706
780
 
781
+ it('still honours an obligation written by a NEWER build (#3957)', async () => {
782
+ // Downgrade case. A ledger the running build does not recognise must not be
783
+ // silently thrown away: for an obligation ledger, "discard" means "forget
784
+ // an obligation", which re-creates the orphan the sweep exists to clear.
785
+ // Here the newer build already discharged this target — a version-
786
+ // intolerant reader would re-drain a chat it had no business touching.
787
+ const h = harness({ stack: [241] })
788
+ h.fs.files.set(
789
+ h.path,
790
+ JSON.stringify({
791
+ v: 99,
792
+ cursors: [
793
+ {
794
+ chatId: DM,
795
+ kind: 'dm',
796
+ popped: 3,
797
+ done: true,
798
+ attempts: 1,
799
+ updatedAt: 1,
800
+ futureFieldFromV99: 'x',
801
+ },
802
+ ],
803
+ }),
804
+ )
805
+ const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
806
+
807
+ expect(res.status).toBe('already-drained')
808
+ expect(h.fake.calls.filter((c) => c.startsWith('unpin:'))).toHaveLength(0)
809
+ expect(h.fake.stack).toEqual([241])
810
+ })
811
+
707
812
  it('never lets a failing ledger write break the sweep', async () => {
708
813
  const h = harness({ stack: [231, 232] })
709
814
  h.fs.writeFileSync = () => {
@@ -20,6 +20,22 @@
20
20
  * Drain progress is only ever concluded from OBSERVED STATE, never from an
21
21
  * API result.
22
22
  *
23
+ * ── Two counters, one meaning each, on EVERY branch ──────────────────────────
24
+ *
25
+ * That invariant is only enforceable if the accounting keeps the two apart, so
26
+ * `SweepResult` carries both and every drain primitive populates both:
27
+ *
28
+ * • `issued` — removal calls that went out unrejected. A claim. Credited
29
+ * immediately, before any observation.
30
+ * • `popped` — a `getChat` top that DEMONSTRABLY MOVED. Evidence. A LOWER
31
+ * BOUND, credited only from an observation, never from a call.
32
+ *
33
+ * Neither branch is allowed its own dialect: a call-counted `popped` on one
34
+ * path and an observation-counted `popped` on another is how the next reader
35
+ * ends up trusting a number that was never evidence (#3955, #3956). `issued -
36
+ * popped` is the honest "went out, could not be verified" figure everywhere,
37
+ * and undercounting `popped` can only make the sweeper do less work.
38
+ *
23
39
  * ── The drain primitives, per chat class ─────────────────────────────────────
24
40
  *
25
41
  * ONE sweep path (`sweepTarget`) that owns eligibility, the rights precheck,
@@ -497,15 +513,38 @@ export type SweepStatus =
497
513
 
498
514
  export interface SweepResult {
499
515
  status: SweepStatus
500
- /** Pops OBSERVED to have landed in THIS sweep (never counted from an API
501
- * result — see the module docblock). */
516
+ /**
517
+ * Pops OBSERVED to have landed in THIS sweep — never counted from an API
518
+ * result (see the module docblock).
519
+ *
520
+ * It is a LOWER BOUND, deliberately, and it means the same thing on EVERY
521
+ * drain branch: "a `getChat` top that demonstrably moved". Two structural
522
+ * reasons it can undercount, both safe:
523
+ *
524
+ * • a removal aimed at a MID-STACK id changes nothing a bot can read, so it
525
+ * is never creditable however well it worked (#3959);
526
+ * • a sweep that exits between a removal and the read that would have
527
+ * verified it (an unverifiable `getChat`) leaves that removal uncredited
528
+ * rather than guessing (#3956).
529
+ *
530
+ * Undercounting can only make the sweeper do LESS work — `popped` drives the
531
+ * per-chat pop cap and telemetry, nothing else — whereas overcounting would
532
+ * launder the `ok:true` silent no-op into a success. Use {@link issued} for
533
+ * "how many removals went out"; the two are never the same number by design.
534
+ */
502
535
  popped: number
503
536
  /**
504
- * Targeted unpins ISSUED in this sweep. Deliberately separate from `popped`:
505
- * an unpin aimed at a mid-stack id has no observable effect on the only thing
506
- * a bot can read (`getChat().pinned_message`, the top), so issuing one is not
507
- * evidence it landed. Keeping the two apart is what stops the `ok:true`
508
- * silent no-op from being laundered back into a success count.
537
+ * Pin-removal calls that WENT OUT and were not rejected — the DM repin+unpin
538
+ * loop's unpins, the group drain's targeted unpins, and the opt-in wholesale
539
+ * topic drain's single call. (Telegram's honest `message to unpin not found`
540
+ * counts too: it settles the id, it just does not move the top.)
541
+ * Deliberately separate from `popped`: a resolved call is a claim, not
542
+ * evidence — `unpinChatMessage` answers `ok:true` on a silent no-op, and a
543
+ * mid-stack removal has no read-back at all.
544
+ *
545
+ * Every branch populates it, so `issued - popped` is the honest "removals we
546
+ * could not verify" figure on any branch, and the two counters can never be
547
+ * conflated by a future reader.
509
548
  */
510
549
  issued?: number
511
550
  /** Detail for the log line. */
@@ -693,7 +732,10 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
693
732
  /**
694
733
  * The DM / opted-in-supergroup drain: RE-PIN the top then UNPIN it, which is
695
734
  * the only sequence that actually pops an entry, and re-read the observed top
696
- * to decide whether anything moved. A resolved unpin is never counted.
735
+ * to decide whether anything moved. A resolved unpin is never counted as a
736
+ * pop — but it IS counted as `issued` the instant it resolves, so an exit
737
+ * taken before the verifying read reports "one removal, none verified"
738
+ * instead of silently reporting nothing at all (#3956).
697
739
  */
698
740
  const repinUnpinLoop = async (
699
741
  target: SweepTarget,
@@ -702,6 +744,7 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
702
744
  ): Promise<SweepResult> => {
703
745
  const gate = gateFor(kind)
704
746
  let popped = 0
747
+ let issued = 0
705
748
  let consecutiveFloods = 0
706
749
  let lastRetryAfter: number | null = null
707
750
  let noProgressAtSameRetryAfter = 0
@@ -711,6 +754,16 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
711
754
  // never a pop.
712
755
  let unverifiedPop = false
713
756
 
757
+ /**
758
+ * EVERY exit from this loop goes through here, so the issued-vs-observed
759
+ * accounting cannot be dropped on any of its many early-return paths (#3956).
760
+ * A resolved unpin is credited to `issued` the moment it resolves; whether
761
+ * it also becomes a `popped` depends on a later observation that an
762
+ * interrupted sweep may never get to make. Reporting both is what keeps the
763
+ * unverified one visible instead of vanishing.
764
+ */
765
+ const out = (r: Omit<SweepResult, 'issued'>): SweepResult => ({ ...r, issued })
766
+
714
767
  /** Count a pop ONLY once the observed top has moved. */
715
768
  const creditPop = (): void => {
716
769
  popped++
@@ -721,35 +774,35 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
721
774
 
722
775
  for (let i = 0; i < gate.maxPopsPerChatPerSweep; i++) {
723
776
  if (circuitOpen) {
724
- return { status: 'aborted-circuit-breaker', popped, detail: 'breaker open' }
777
+ return out({ status: 'aborted-circuit-breaker', popped, detail: 'breaker open' })
725
778
  }
726
779
  const observed = await readTopVerified(target.chatId)
727
780
  if (observed == null) {
728
781
  // Ambiguous read (disagreeing reads or a throw). Not a drain, and NOT
729
782
  // an empty stack. Defer rather than assert either way.
730
- return { status: 'incomplete', popped, detail: 'pin-stack read unverifiable' }
783
+ return out({ status: 'incomplete', popped, detail: 'pin-stack read unverifiable' })
731
784
  }
732
785
  if (unverifiedPop) {
733
786
  // THE bug this module exists for: `unpinChatMessage` resolves ok:true on
734
787
  // a stale entry and pops NOTHING. The same id still on top after a full
735
788
  // repin+unpin is proof the pop did not land, whatever the API answered.
736
789
  if (observed.top != null && observed.top === lastObservedTop) {
737
- return {
790
+ return out({
738
791
  status: 'incomplete',
739
792
  popped,
740
793
  detail: `no progress: message ${observed.top} still on top after a repin+unpin`,
741
- }
794
+ })
742
795
  }
743
796
  creditPop()
744
797
  unverifiedPop = false
745
798
  }
746
799
  if (observed.top == null) {
747
- return { status: 'drained', popped }
800
+ return out({ status: 'drained', popped })
748
801
  }
749
802
  lastObservedTop = observed.top
750
803
 
751
804
  if (!(await awaitWriteSlot(kind))) {
752
- return { status: 'deferred-budget', popped, detail: 'per-minute pin-op budget spent' }
805
+ return out({ status: 'deferred-budget', popped, detail: 'per-minute pin-op budget spent' })
753
806
  }
754
807
  try {
755
808
  await deps.pinSilent(target.chatId, observed.top)
@@ -757,9 +810,9 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
757
810
  const d = classify(err)
758
811
  if (d.kind === 'breaker') {
759
812
  circuitOpen = true
760
- return { status: 'aborted-circuit-breaker', popped, detail: d.detail }
813
+ return out({ status: 'aborted-circuit-breaker', popped, detail: d.detail })
761
814
  }
762
- if (d.kind === 'rights') return { status: 'skipped-no-rights', popped }
815
+ if (d.kind === 'rights') return out({ status: 'skipped-no-rights', popped })
763
816
  if (d.kind === 'flood') {
764
817
  consecutiveFloods++
765
818
  if (lastRetryAfter === d.seconds) noProgressAtSameRetryAfter++
@@ -769,22 +822,22 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
769
822
  consecutiveFloods >= MAX_CONSECUTIVE_FLOOD_WAITS ||
770
823
  noProgressAtSameRetryAfter >= MAX_IDENTICAL_RETRY_AFTER_NO_PROGRESS
771
824
  ) {
772
- return {
825
+ return out({
773
826
  status: 'deferred-flood',
774
827
  popped,
775
828
  detail: `flood: ${consecutiveFloods} consecutive 429s, retry_after=${d.seconds}s`,
776
- }
829
+ })
777
830
  }
778
831
  // Honour retry_after, then double it for the next one (3→6→12→24).
779
832
  await deps.sleep(d.seconds * 1000 * Math.pow(FLOOD_BACKOFF_FACTOR, consecutiveFloods - 1))
780
833
  continue
781
834
  }
782
- return { status: 'error', popped, detail: d.detail }
835
+ return out({ status: 'error', popped, detail: d.detail })
783
836
  }
784
837
  // The unpin's RESULT is deliberately ignored — it resolves ok:true on a
785
838
  // silent no-op. Only the next observed top decides whether this popped.
786
839
  if (!(await awaitWriteSlot(kind))) {
787
- return { status: 'deferred-budget', popped, detail: 'per-minute pin-op budget spent' }
840
+ return out({ status: 'deferred-budget', popped, detail: 'per-minute pin-op budget spent' })
788
841
  }
789
842
  try {
790
843
  await deps.unpin(target.chatId, observed.top)
@@ -792,10 +845,10 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
792
845
  const d = classify(err)
793
846
  if (d.kind === 'breaker') {
794
847
  circuitOpen = true
795
- return { status: 'aborted-circuit-breaker', popped, detail: d.detail }
848
+ return out({ status: 'aborted-circuit-breaker', popped, detail: d.detail })
796
849
  }
797
- if (d.kind === 'rights') return { status: 'skipped-no-rights', popped }
798
- if (isNothingToUnpinError(err)) return { status: 'drained', popped }
850
+ if (d.kind === 'rights') return out({ status: 'skipped-no-rights', popped })
851
+ if (isNothingToUnpinError(err)) return out({ status: 'drained', popped })
799
852
  if (d.kind === 'flood') {
800
853
  consecutiveFloods++
801
854
  if (lastRetryAfter === d.seconds) noProgressAtSameRetryAfter++
@@ -805,18 +858,23 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
805
858
  consecutiveFloods >= MAX_CONSECUTIVE_FLOOD_WAITS ||
806
859
  noProgressAtSameRetryAfter >= MAX_IDENTICAL_RETRY_AFTER_NO_PROGRESS
807
860
  ) {
808
- return {
861
+ return out({
809
862
  status: 'deferred-flood',
810
863
  popped,
811
864
  detail: `flood: ${consecutiveFloods} consecutive 429s, retry_after=${d.seconds}s`,
812
- }
865
+ })
813
866
  }
814
867
  await deps.sleep(d.seconds * 1000 * Math.pow(FLOOD_BACKOFF_FACTOR, consecutiveFloods - 1))
815
868
  continue
816
869
  }
817
- return { status: 'error', popped, detail: d.detail }
870
+ return out({ status: 'error', popped, detail: d.detail })
818
871
  }
819
872
  consecutiveFloods = 0
873
+ // The removal went out. It is `issued` NOW — unconditionally, before the
874
+ // observation that may or may not turn it into a `popped`. That ordering
875
+ // is the fix for #3956: an exit taken before the next verified read can
876
+ // no longer make this call disappear from the accounting.
877
+ issued++
820
878
  unverifiedPop = true
821
879
  }
822
880
 
@@ -824,12 +882,12 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
824
882
  // is the ONLY thing allowed to report `drained`.
825
883
  const finalRead = await readTopVerified(target.chatId)
826
884
  if (unverifiedPop && finalRead != null && finalRead.top !== lastObservedTop) creditPop()
827
- if (finalRead != null && finalRead.top == null) return { status: 'drained', popped }
828
- return {
885
+ if (finalRead != null && finalRead.top == null) return out({ status: 'drained', popped })
886
+ return out({
829
887
  status: 'deferred-budget',
830
888
  popped,
831
889
  detail: `hit the ${gate.maxPopsPerChatPerSweep}-pop cap for this chat; yielding to next boot`,
832
- }
890
+ })
833
891
  }
834
892
 
835
893
  /**
@@ -962,6 +1020,16 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
962
1020
  * place, which is why it is gated on {@link mayUnpinAllForumTopic}. A 429 here
963
1021
  * is the pegged TDLib re-issue loop, so it is a give-up, not a backoff — see
964
1022
  * the module docblock.
1023
+ *
1024
+ * Counting is the SAME contract as every other branch (#3955): the resolved
1025
+ * call is one `issued`, and `popped` is credited only from a `getChat` top
1026
+ * that demonstrably moved between the before- and after-reads. It is not a
1027
+ * distinction without a difference here — the verb is topic-scoped while the
1028
+ * only readable state is the CHAT-wide top, so a drained topic in a chat whose
1029
+ * top belongs to another topic legitimately moves nothing. That is still a
1030
+ * discharged obligation (`drained`), it is just not an observed pop, and
1031
+ * crediting one would make this branch's `popped` a call counter wearing a
1032
+ * pop counter's name.
965
1033
  */
966
1034
  const forumTopicDrain = async (
967
1035
  target: SweepTarget,
@@ -969,10 +1037,22 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
969
1037
  ): Promise<SweepResult> => {
970
1038
  const threadId = target.threadId
971
1039
  if (threadId == null) {
972
- return { status: 'skipped-nothing-recorded', popped: 0, detail: 'no message_thread_id' }
1040
+ return {
1041
+ status: 'skipped-nothing-recorded',
1042
+ popped: 0,
1043
+ issued: 0,
1044
+ detail: 'no message_thread_id',
1045
+ }
973
1046
  }
1047
+ // Read BEFORE the write, so "did the top move?" is answerable afterwards.
1048
+ const before = await readTopVerified(target.chatId)
974
1049
  if (!(await awaitWriteSlot('forum-topic'))) {
975
- return { status: 'deferred-budget', popped: 0, detail: 'per-minute pin-op budget spent' }
1050
+ return {
1051
+ status: 'deferred-budget',
1052
+ popped: 0,
1053
+ issued: 0,
1054
+ detail: 'per-minute pin-op budget spent',
1055
+ }
976
1056
  }
977
1057
  try {
978
1058
  await deps.unpinAllForumTopicMessages(target.chatId, threadId)
@@ -980,9 +1060,9 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
980
1060
  const d = classify(err)
981
1061
  if (d.kind === 'breaker') {
982
1062
  circuitOpen = true
983
- return { status: 'aborted-circuit-breaker', popped: 0, detail: d.detail }
1063
+ return { status: 'aborted-circuit-breaker', popped: 0, issued: 0, detail: d.detail }
984
1064
  }
985
- if (d.kind === 'rights') return { status: 'skipped-no-rights', popped: 0 }
1065
+ if (d.kind === 'rights') return { status: 'skipped-no-rights', popped: 0, issued: 0 }
986
1066
  if (d.kind === 'flood') {
987
1067
  // DO NOT retry. `run_affected_history_query_until_complete` re-issues
988
1068
  // the identical query while `!is_final_`, re-tripping the same server
@@ -990,35 +1070,60 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
990
1070
  return {
991
1071
  status: 'deferred-flood',
992
1072
  popped: 0,
1073
+ issued: 0,
993
1074
  detail: `unpinAllForumTopicMessages pegged at retry_after=${d.seconds}s; not looping`,
994
1075
  }
995
1076
  }
996
- return { status: 'error', popped: 0, detail: d.detail }
1077
+ return { status: 'error', popped: 0, issued: 0, detail: d.detail }
997
1078
  }
998
- cursor.popped++
999
- cursor.updatedAt = deps.now()
1000
- commit(cursor)
1079
+ // The call resolved: that is exactly ONE issued removal, and — per the
1080
+ // module invariant — no evidence of anything at all. Only the read below
1081
+ // may credit a pop, and only the durable counter it credits is `popped`.
1001
1082
  const observed = await readTopVerified(target.chatId)
1002
- if (observed != null && observed.top == null) return { status: 'drained', popped: 1 }
1083
+ const topMoved =
1084
+ before != null && observed != null && observed.top !== before.top ? 1 : 0
1085
+ if (topMoved > 0) {
1086
+ cursor.popped += topMoved
1087
+ cursor.updatedAt = deps.now()
1088
+ commit(cursor)
1089
+ }
1090
+ if (observed != null && observed.top == null) {
1091
+ return { status: 'drained', popped: topMoved, issued: 1 }
1092
+ }
1003
1093
  // The chat-wide stack may legitimately still hold pins belonging to OTHER
1004
1094
  // topics — the topic-scoped verb only drains this one. That is a success
1005
1095
  // for this obligation, not an incomplete drain.
1006
- return { status: 'drained', popped: 1, detail: 'topic drained; chat-wide stack may hold other topics' }
1096
+ return {
1097
+ status: 'drained',
1098
+ popped: topMoved,
1099
+ issued: 1,
1100
+ detail: 'topic drained; chat-wide stack may hold other topics',
1101
+ }
1007
1102
  }
1008
1103
 
1009
1104
  const sweepOnce = async (target: SweepTarget): Promise<SweepResult> => {
1010
- if (!deps.eligible()) return { status: 'skipped-not-eligible', popped: 0 }
1105
+ if (!deps.eligible()) return { status: 'skipped-not-eligible', popped: 0, issued: 0 }
1011
1106
  if (circuitOpen) {
1012
- return { status: 'aborted-circuit-breaker', popped: 0, detail: 'breaker already open' }
1107
+ return {
1108
+ status: 'aborted-circuit-breaker',
1109
+ popped: 0,
1110
+ issued: 0,
1111
+ detail: 'breaker already open',
1112
+ }
1013
1113
  }
1014
1114
  const kind = classifyChatForSweep(target)
1015
1115
  const cursor = loadCursor(target, kind)
1016
- if (cursor.done) return { status: 'already-drained', popped: 0 }
1116
+ if (cursor.done) return { status: 'already-drained', popped: 0, issued: 0 }
1017
1117
  // Attempt budget exhausted: a permanently-undrainable chat (bot kicked,
1018
1118
  // rights revoked, a chat that flood-waits forever) must not re-burn the pop
1019
1119
  // budget on every boot for the rest of time.
1020
1120
  if (cursor.attempts >= SWEEP_MAX_ATTEMPTS) {
1021
- return { status: 'forfeited', popped: 0, detail: `${cursor.attempts} attempts exhausted` }
1121
+ return {
1122
+ status: 'forfeited',
1123
+ popped: 0,
1124
+ issued: 0,
1125
+ detail: `${cursor.attempts} attempts exhausted`,
1126
+ }
1022
1127
  }
1023
1128
 
1024
1129
  // RIGHTS PRECHECK, before ANY write, in every group. A bot without
@@ -1037,7 +1142,7 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
1037
1142
  cursor.lastStatus = 'skipped-no-rights'
1038
1143
  cursor.updatedAt = deps.now()
1039
1144
  commit(cursor)
1040
- return { status: 'skipped-no-rights', popped: 0 }
1145
+ return { status: 'skipped-no-rights', popped: 0, issued: 0 }
1041
1146
  }
1042
1147
  }
1043
1148
 
@@ -1078,7 +1183,7 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
1078
1183
  : await targetedUnpinDrain(target, kind, cursor, ids)
1079
1184
  }
1080
1185
  } catch (err) {
1081
- result = { status: 'error', popped: 0, detail: (err as Error).message }
1186
+ result = { status: 'error', popped: 0, issued: 0, detail: (err as Error).message }
1082
1187
  }
1083
1188
 
1084
1189
  // `skipped-nothing-recorded` discharges the obligation too: there is no id
@@ -1134,8 +1239,12 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
1134
1239
  // Not eligible yet ⇒ nothing was attempted, so do NOT consume the one
1135
1240
  // allowed attempt: the boot sweep must still get its turn once the mutex
1136
1241
  // is won.
1137
- if (!deps.eligible()) return Promise.resolve({ status: 'skipped-not-eligible', popped: 0 })
1138
- if (attempted.has(key)) return Promise.resolve({ status: 'already-attempted', popped: 0 })
1242
+ if (!deps.eligible()) {
1243
+ return Promise.resolve({ status: 'skipped-not-eligible', popped: 0, issued: 0 })
1244
+ }
1245
+ if (attempted.has(key)) {
1246
+ return Promise.resolve({ status: 'already-attempted', popped: 0, issued: 0 })
1247
+ }
1139
1248
  attempted.add(key)
1140
1249
  const p = sweepOnce(target).finally(() => inFlight.delete(key))
1141
1250
  inFlight.set(key, p)