switchroom 0.19.32 → 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.
- package/dist/auth-broker/index.js +112 -6
- package/dist/cli/switchroom.js +177 -34
- package/dist/host-control/main.js +289 -27
- package/dist/vault/approvals/kernel-server.js +112 -6
- package/dist/vault/broker/server.js +112 -6
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +209 -61
- package/telegram-plugin/gateway/stale-pin-sweep-store.ts +14 -8
- package/telegram-plugin/gateway/stale-pin-sweep.test.ts +106 -1
- package/telegram-plugin/gateway/stale-pin-sweep.ts +156 -47
- package/telegram-plugin/gateway/status-pin-store.ts +44 -11
- package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
- package/vendor/hindsight-memory/scripts/subagent_retain.py +30 -7
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +100 -2
- package/vendor/hindsight-memory/settings.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
501
|
-
*
|
|
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
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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())
|
|
1138
|
-
|
|
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)
|
|
@@ -117,19 +117,43 @@ export interface PersistedStatusPin {
|
|
|
117
117
|
* re-fail on every boot forever. */
|
|
118
118
|
export const BOOT_UNPIN_MAX_ATTEMPTS = 5
|
|
119
119
|
|
|
120
|
-
/**
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
120
|
+
/**
|
|
121
|
+
* Envelope version. v1 had no `pending` field; a v1 row loads as a confirmed
|
|
122
|
+
* pin (pending undefined). v2 adds the optional `pending` flag. v3 adds the
|
|
123
|
+
* optional `pinnedAt` claim timestamp (#3810). v4 adds the optional `threadId`
|
|
124
|
+
* so the claim is keyed by (chat, thread) and a forum topic can be drained
|
|
125
|
+
* after a crash.
|
|
126
|
+
*
|
|
127
|
+
* ── The bump rule, which the reader depends on (#3957) ───────────────────────
|
|
128
|
+
*
|
|
129
|
+
* EVERY field added since v1 is OPTIONAL, and every future bump must keep that
|
|
130
|
+
* property. That is what makes the versions mutually readable in both
|
|
131
|
+
* directions, and {@link loadStatusPins} now cashes it in: an envelope whose
|
|
132
|
+
* version this build does not know is still READ, row by row, through the same
|
|
133
|
+
* structural validator. Rows it cannot validate are dropped; rows it can are
|
|
134
|
+
* kept.
|
|
135
|
+
*
|
|
136
|
+
* Why that matters more than it looks: this file is the boot cleanup's only
|
|
137
|
+
* record of which messages the gateway pinned. A reader that discarded the
|
|
138
|
+
* whole file on an unfamiliar version turned every pin taken by the newer build
|
|
139
|
+
* into a permanent orphan the moment an operator rolled back — manufacturing
|
|
140
|
+
* exactly the bug this subsystem exists to prevent. Degrading (keep what
|
|
141
|
+
* validates) is strictly safer than failing open to `[]` here, because the
|
|
142
|
+
* fail-open outcome is not "do nothing", it is "forget an obligation".
|
|
143
|
+
*
|
|
144
|
+
* A bump that ever needs to be NON-additive — a field whose absence changes the
|
|
145
|
+
* meaning of a row — must therefore change the FILENAME, not just `v`, so old
|
|
146
|
+
* readers see "no file" rather than misreading rows. Do not quietly break the
|
|
147
|
+
* additive rule and leave `v` to carry it.
|
|
148
|
+
*/
|
|
127
149
|
interface SnapshotEnvelope {
|
|
128
|
-
v:
|
|
150
|
+
v: number
|
|
129
151
|
pins: PersistedStatusPin[]
|
|
130
152
|
}
|
|
131
153
|
|
|
132
|
-
|
|
154
|
+
/** The version THIS build writes. Reading is deliberately version-tolerant
|
|
155
|
+
* (see above); only the writer is pinned. */
|
|
156
|
+
const SNAPSHOT_VERSION = 4
|
|
133
157
|
|
|
134
158
|
function isPinRow(x: unknown): x is PersistedStatusPin {
|
|
135
159
|
if (x == null || typeof x !== 'object') return false
|
|
@@ -153,6 +177,13 @@ function isPinRow(x: unknown): x is PersistedStatusPin {
|
|
|
153
177
|
* file (fail-open to empty: a corrupt snapshot must never crash boot — worst
|
|
154
178
|
* case an orphaned pin isn't cleaned up this boot, strictly no worse than the
|
|
155
179
|
* pre-persistence behaviour).
|
|
180
|
+
*
|
|
181
|
+
* An UNRECOGNISED envelope version is NOT malformed and does not fail open
|
|
182
|
+
* (#3957). Any positive-integer `v` with an array of `pins` is read, and each
|
|
183
|
+
* row is kept iff {@link isPinRow} can structurally validate it. A row written
|
|
184
|
+
* by a newer build therefore survives a downgrade instead of being discarded
|
|
185
|
+
* into a permanent orphan — see the {@link SnapshotEnvelope} bump rule for the
|
|
186
|
+
* additive-fields property this relies on.
|
|
156
187
|
*/
|
|
157
188
|
export function loadStatusPins(
|
|
158
189
|
path: string,
|
|
@@ -173,7 +204,9 @@ export function loadStatusPins(
|
|
|
173
204
|
}
|
|
174
205
|
if (parsed == null || typeof parsed !== 'object') return []
|
|
175
206
|
const env = parsed as Record<string, unknown>
|
|
176
|
-
|
|
207
|
+
// Version-TOLERANT, structurally strict: an unknown `v` still yields the rows
|
|
208
|
+
// that validate. Only a shape that is not an envelope at all fails open.
|
|
209
|
+
if (!Number.isInteger(env.v) || (env.v as number) < 1 || !Array.isArray(env.pins)) return []
|
|
177
210
|
return env.pins.filter(isPinRow)
|
|
178
211
|
}
|
|
179
212
|
|
|
@@ -189,7 +222,7 @@ export function persistStatusPins(
|
|
|
189
222
|
snapshot: readonly PersistedStatusPin[],
|
|
190
223
|
log: (line: string) => void = (l) => process.stderr.write(l),
|
|
191
224
|
): void {
|
|
192
|
-
const env: SnapshotEnvelope = { v:
|
|
225
|
+
const env: SnapshotEnvelope = { v: SNAPSHOT_VERSION, pins: [...snapshot] }
|
|
193
226
|
const tmp = path + '.tmp'
|
|
194
227
|
try {
|
|
195
228
|
fs.writeFileSync(tmp, JSON.stringify(env))
|