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.
- 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/bridge/bridge.js +3 -0
- package/telegram-plugin/dist/gateway/gateway.js +434 -187
- package/telegram-plugin/dist/server.js +3 -0
- 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/gateway/stream-render.ts +578 -321
- package/telegram-plugin/session-tail.ts +13 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
- package/telegram-plugin/tests/stream-render-golden.test.ts +20 -5
- package/telegram-plugin/tests/turn-mint-defers-until-dequeue.test.ts +196 -0
- package/telegram-plugin/tests/turn-mint-harness.ts +155 -0
- package/telegram-plugin/tests/turn-supersede-finalizes-prior-card.test.ts +124 -0
- 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
|
@@ -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:
|
|
113
|
+
v: number
|
|
107
114
|
cursors: SweepCursor[]
|
|
108
115
|
}
|
|
109
116
|
|
|
110
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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:
|
|
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
|
-
|
|
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)
|