switchroom 0.20.9 → 0.20.11
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/bin/handoff-briefing.sh +57 -5
- package/bin/working-state-reload-hook.sh +262 -0
- package/dist/agent-scheduler/index.js +65 -2
- package/dist/auth-broker/index.js +204 -24
- package/dist/cli/notion-write-pretool.mjs +65 -2
- package/dist/cli/self-improve-apply-guard-pretool.mjs +357 -92
- package/dist/cli/self-improve-stop.mjs +889 -7
- package/dist/cli/skill-validate-pretool.mjs +82 -3
- package/dist/cli/switchroom.js +3699 -2110
- package/dist/host-control/main.js +67 -4
- package/dist/vault/approvals/kernel-server.js +66 -3
- package/dist/vault/broker/server.js +66 -3
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +49 -0
- package/profiles/_shared/agent-self-service.md.hbs +15 -22
- package/profiles/_shared/delegation-golden-rule.md.hbs +1 -1
- package/profiles/_shared/dev-protocol.md.hbs +1 -1
- package/profiles/_shared/execution-discipline.md.hbs +4 -4
- package/profiles/_shared/vault-protocol.md.hbs +2 -18
- package/profiles/default/CLAUDE.md.hbs +3 -5
- package/telegram-plugin/auto-fallback-fleet.ts +37 -2
- package/telegram-plugin/dist/gateway/gateway.js +1414 -918
- package/telegram-plugin/fallback-card-collapse.ts +1 -0
- package/telegram-plugin/gateway/auth-command.ts +11 -1
- package/telegram-plugin/gateway/callback-query-handlers.ts +100 -0
- package/telegram-plugin/gateway/eval-case-proposal-card.ts +86 -0
- package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.test.ts +74 -0
- package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.ts +71 -0
- package/telegram-plugin/gateway/gateway.ts +85 -90
- package/telegram-plugin/gateway/ipc-protocol.ts +43 -0
- package/telegram-plugin/gateway/ipc-server.ts +28 -0
- package/telegram-plugin/gateway/narrative-lane.ts +33 -2
- package/telegram-plugin/gateway/privacy-reset.test.ts +216 -0
- package/telegram-plugin/gateway/privacy-reset.ts +87 -0
- package/telegram-plugin/gateway/privacy-state.test.ts +165 -0
- package/telegram-plugin/gateway/privacy-state.ts +206 -0
- package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +176 -0
- package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +24 -14
- package/telegram-plugin/gateway/stale-pin-sweep.test.ts +123 -26
- package/telegram-plugin/gateway/stale-pin-sweep.ts +48 -32
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +15 -4
- package/telegram-plugin/slot-banner-driver.ts +42 -5
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +24 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +2 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +97 -0
- package/telegram-plugin/tests/privacy-reset-call-sites.test.ts +120 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +25 -0
- package/telegram-plugin/tests/throttle-tier.test.ts +16 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +67 -0
- package/telegram-plugin/throttle-tier.ts +12 -3
- package/telegram-plugin/turn-flush-safety.ts +97 -0
- package/vendor/hindsight-memory/CHANGELOG.md +31 -0
- package/vendor/hindsight-memory/hooks/hooks.json +2 -1
- package/vendor/hindsight-memory/scripts/retain.py +306 -0
- package/vendor/hindsight-memory/scripts/session_start.py +35 -8
- package/vendor/hindsight-memory/scripts/subagent_retain.py +29 -1
- package/vendor/hindsight-memory/scripts/tests/test_private_mode.py +415 -0
- package/vendor/hindsight-memory/scripts/tests/test_self_improve_correction_tag.py +167 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_durability.py +107 -0
|
@@ -22,7 +22,6 @@ import {
|
|
|
22
22
|
createStalePinSweeper,
|
|
23
23
|
isNothingToUnpinError,
|
|
24
24
|
isPeerFloodError,
|
|
25
|
-
isPinRightsError,
|
|
26
25
|
mayUnpinAllForumTopic,
|
|
27
26
|
retryAfterSeconds,
|
|
28
27
|
unexpiredStoreRepinIds,
|
|
@@ -38,6 +37,10 @@ import {
|
|
|
38
37
|
type SweepCursor,
|
|
39
38
|
type SweepStoreFsSeam,
|
|
40
39
|
} from './stale-pin-sweep-store.js'
|
|
40
|
+
// The sweep now imports its rights detector from the single source of truth
|
|
41
|
+
// (status-pin.ts) — no second copy. The negative cache it shares with the live
|
|
42
|
+
// pin path is that module's `PinRightsCache` (D3).
|
|
43
|
+
import { isPinRightsError, PinRightsCache } from '../status-pin.js'
|
|
41
44
|
|
|
42
45
|
// Synthetic ids (check-no-pii-secrets): DMs positive, groups negative.
|
|
43
46
|
const DM = '900000001'
|
|
@@ -84,7 +87,10 @@ interface FakeOpts {
|
|
|
84
87
|
* call-counted `popped` and an observation-counted `popped` disagree.
|
|
85
88
|
*/
|
|
86
89
|
unpinAllTopicRemoves?: number[]
|
|
87
|
-
|
|
90
|
+
/** Errors to throw from `unpinChatMessage`, consumed in order (null = normal).
|
|
91
|
+
* The sweep classifies pin rights REACTIVELY from a real Telegram 400 here —
|
|
92
|
+
* there is no proactive precheck to model. */
|
|
93
|
+
unpinErrors?: (unknown | null)[]
|
|
88
94
|
/** Throws from `getChat`, consumed in order (null = normal read). */
|
|
89
95
|
getChatErrors?: (unknown | null)[]
|
|
90
96
|
}
|
|
@@ -93,6 +99,7 @@ function fakeChat(o: FakeOpts) {
|
|
|
93
99
|
const stack = [...o.stack]
|
|
94
100
|
const calls: string[] = []
|
|
95
101
|
const pinErrors = [...(o.pinErrors ?? [])]
|
|
102
|
+
const unpinErrors = [...(o.unpinErrors ?? [])]
|
|
96
103
|
const unpinAllErrors = [...(o.unpinAllErrors ?? [])]
|
|
97
104
|
const getChatErrors = [...(o.getChatErrors ?? [])]
|
|
98
105
|
return {
|
|
@@ -115,6 +122,10 @@ function fakeChat(o: FakeOpts) {
|
|
|
115
122
|
},
|
|
116
123
|
unpin: async (_chatId: string, messageId: number) => {
|
|
117
124
|
calls.push(`unpin:${messageId}`)
|
|
125
|
+
// A real Telegram rejection (e.g. `400 not enough rights`) is injected
|
|
126
|
+
// here so the sweep's REACTIVE rights classifier is what gets exercised.
|
|
127
|
+
const err = unpinErrors.shift()
|
|
128
|
+
if (err != null) throw err
|
|
118
129
|
// ALWAYS resolves ok:true — the whole point.
|
|
119
130
|
if (o.unpinIsSilentNoop === true) return { ok: true }
|
|
120
131
|
const at = stack.indexOf(messageId)
|
|
@@ -135,10 +146,6 @@ function fakeChat(o: FakeOpts) {
|
|
|
135
146
|
}
|
|
136
147
|
return { ok: true }
|
|
137
148
|
},
|
|
138
|
-
canPinInChat: async () => {
|
|
139
|
-
calls.push('getChatMember')
|
|
140
|
-
return o.canPin !== false
|
|
141
|
-
},
|
|
142
149
|
}
|
|
143
150
|
}
|
|
144
151
|
|
|
@@ -165,6 +172,9 @@ function harness(
|
|
|
165
172
|
allowUnpinAllForumTopic?: boolean
|
|
166
173
|
/** Ids the gateway is on record as having pinned (the group drain's list). */
|
|
167
174
|
recordedPinIds?: number[]
|
|
175
|
+
/** D3: shared per-process pin-rights negative cache seam. */
|
|
176
|
+
rightsBlocked?: (chatId: string) => boolean
|
|
177
|
+
recordRightsBlock?: (chatId: string) => void
|
|
168
178
|
},
|
|
169
179
|
): Harness {
|
|
170
180
|
const fake = fakeChat(o)
|
|
@@ -183,10 +193,11 @@ function harness(
|
|
|
183
193
|
pinSilent: costed(fake.pinSilent),
|
|
184
194
|
unpin: costed(fake.unpin),
|
|
185
195
|
unpinAllForumTopicMessages: costed(fake.unpinAllForumTopicMessages),
|
|
186
|
-
canPinInChat: costed(fake.canPinInChat),
|
|
187
196
|
protectedMessageIds: () => o.protectedMessageIds ?? [],
|
|
188
197
|
recordedPinIds: () => o.recordedPinIds ?? [],
|
|
189
198
|
eligible: () => o.eligible !== false,
|
|
199
|
+
rightsBlocked: o.rightsBlocked,
|
|
200
|
+
recordRightsBlock: o.recordRightsBlock,
|
|
190
201
|
sleep: async (ms) => {
|
|
191
202
|
sleeps.push(ms)
|
|
192
203
|
if (o.clockAdvances !== false) clock += ms
|
|
@@ -552,8 +563,22 @@ describe('stale-pin sweep — forum topics', () => {
|
|
|
552
563
|
// ─── group safety: rights + service-message spam ─────────────────────────────
|
|
553
564
|
|
|
554
565
|
describe('stale-pin sweep — group safety', () => {
|
|
555
|
-
|
|
556
|
-
|
|
566
|
+
// The honest Telegram rejection a rights-less bot gets from EVERY pin verb.
|
|
567
|
+
const RIGHTS_400 = {
|
|
568
|
+
error_code: 400,
|
|
569
|
+
description: 'Bad Request: not enough rights to manage pinned messages in the chat',
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
it('ATTEMPTS the drain (no proactive precheck) and classifies a real 400 as rights', async () => {
|
|
573
|
+
// The pre-fix bug: a proactive getChatMember precheck against the wrapped
|
|
574
|
+
// (botInfo-less) bot always returned false, so a group forfeited WITHOUT a
|
|
575
|
+
// single unpin ever going out. The sweep must instead ATTEMPT the unpin and
|
|
576
|
+
// read Telegram's honest 400 — so `unpin:151` MUST appear in the call log.
|
|
577
|
+
const h = harness({
|
|
578
|
+
stack: [151, 152],
|
|
579
|
+
recordedPinIds: [151],
|
|
580
|
+
unpinErrors: [RIGHTS_400],
|
|
581
|
+
})
|
|
557
582
|
const res = await createStalePinSweeper(h.deps).sweepTarget({
|
|
558
583
|
chatId: GROUP,
|
|
559
584
|
threadId: 5,
|
|
@@ -561,22 +586,31 @@ describe('stale-pin sweep — group safety', () => {
|
|
|
561
586
|
})
|
|
562
587
|
|
|
563
588
|
expect(res.status).toBe('skipped-no-rights')
|
|
564
|
-
expect(h.fake.calls).
|
|
565
|
-
expect(h.fake.stack).toEqual([151, 152])
|
|
589
|
+
expect(h.fake.calls).toContain('unpin:151') // the drain was ATTEMPTED
|
|
590
|
+
expect(h.fake.stack).toEqual([151, 152]) // the rejected unpin popped nothing
|
|
566
591
|
})
|
|
567
592
|
|
|
568
|
-
it('
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
|
|
593
|
+
it('increments attempts and eventually FORFEITS a rights-less group via the reactive path (no infinite retry)', async () => {
|
|
594
|
+
const fs = memFs()
|
|
595
|
+
const path = '/state/stale-pin-sweep.json'
|
|
596
|
+
// Every boot: a fresh process attempts the recorded unpin, Telegram answers
|
|
597
|
+
// 400 not enough rights, the sweep records skipped-no-rights and bumps
|
|
598
|
+
// attempts. After SWEEP_MAX_ATTEMPTS boots the cursor forfeits and stops
|
|
599
|
+
// re-burning the pin-op budget — the bounded reactive path, no precheck.
|
|
600
|
+
let last = ''
|
|
601
|
+
for (let boot = 0; boot < SWEEP_MAX_ATTEMPTS + 1; boot++) {
|
|
602
|
+
const chat = fakeChat({ stack: [161], unpinErrors: [RIGHTS_400] })
|
|
603
|
+
const res = await sweeperOver(fs, path, chat, { recordedPinIds: [161] }).sweepTarget({
|
|
604
|
+
chatId: GROUP,
|
|
605
|
+
})
|
|
606
|
+
last = res.status
|
|
572
607
|
}
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
expect(
|
|
579
|
-
expect(h.fake.calls).toEqual([])
|
|
608
|
+
// The final boot is past the attempt budget: it forfeits instead of
|
|
609
|
+
// attempting yet another doomed unpin.
|
|
610
|
+
expect(last).toBe('forfeited')
|
|
611
|
+
const cursor = loadSweepCursors(path, fs).find((c) => c.chatId === GROUP)
|
|
612
|
+
expect(cursor?.attempts).toBe(SWEEP_MAX_ATTEMPTS)
|
|
613
|
+
expect(cursor?.done).toBe(false)
|
|
580
614
|
})
|
|
581
615
|
|
|
582
616
|
it('NEVER pins in a group — a pin is the only op that emits a service message', async () => {
|
|
@@ -623,10 +657,74 @@ describe('stale-pin sweep — group safety', () => {
|
|
|
623
657
|
expect(h.cursors().find((c) => c.chatId === GROUP)?.done).toBe(false)
|
|
624
658
|
})
|
|
625
659
|
|
|
626
|
-
it('runs a DM sweep
|
|
660
|
+
it('runs a DM sweep straight into a drain (no rights gating whatsoever)', async () => {
|
|
627
661
|
const h = harness({ stack: [181] })
|
|
628
|
-
await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
|
|
662
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
|
|
663
|
+
// No proactive precheck anywhere: the DM goes straight to the repin+unpin
|
|
664
|
+
// drain and clears the orphan.
|
|
629
665
|
expect(h.fake.calls).not.toContain('getChatMember')
|
|
666
|
+
expect(res.status).toBe('drained')
|
|
667
|
+
expect(h.fake.stack).toEqual([])
|
|
668
|
+
})
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
// ─── D3: shared per-process pin-rights negative cache ─────────────────────────
|
|
672
|
+
|
|
673
|
+
describe('stale-pin sweep — shared PinRightsCache (D3)', () => {
|
|
674
|
+
const RIGHTS_400 = {
|
|
675
|
+
error_code: 400,
|
|
676
|
+
description: 'Bad Request: not enough rights to manage pinned messages in the chat',
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
it('SKIPS a group the live path already blocked, without issuing any unpin', async () => {
|
|
680
|
+
const cache = new PinRightsCache()
|
|
681
|
+
cache.block(GROUP) // the live status-pin path already proved this chat rights-less
|
|
682
|
+
const h = harness({
|
|
683
|
+
stack: [201],
|
|
684
|
+
recordedPinIds: [201],
|
|
685
|
+
rightsBlocked: (c) => cache.isBlocked(c),
|
|
686
|
+
recordRightsBlock: (c) => {
|
|
687
|
+
cache.block(c)
|
|
688
|
+
},
|
|
689
|
+
})
|
|
690
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: GROUP })
|
|
691
|
+
|
|
692
|
+
expect(res.status).toBe('skipped-no-rights')
|
|
693
|
+
// No doomed traffic: the shared cache short-circuited BEFORE any unpin.
|
|
694
|
+
expect(h.fake.calls.filter((c) => c.startsWith('unpin'))).toEqual([])
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
it('FEEDS the cache from its own reactive rights discovery so the live path skips too', async () => {
|
|
698
|
+
const cache = new PinRightsCache()
|
|
699
|
+
const h = harness({
|
|
700
|
+
stack: [202],
|
|
701
|
+
recordedPinIds: [202],
|
|
702
|
+
unpinErrors: [RIGHTS_400],
|
|
703
|
+
rightsBlocked: (c) => cache.isBlocked(c),
|
|
704
|
+
recordRightsBlock: (c) => {
|
|
705
|
+
cache.block(c)
|
|
706
|
+
},
|
|
707
|
+
})
|
|
708
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: GROUP })
|
|
709
|
+
|
|
710
|
+
expect(res.status).toBe('skipped-no-rights')
|
|
711
|
+
expect(h.fake.calls).toContain('unpin:202') // it ATTEMPTED before classifying
|
|
712
|
+
expect(cache.isBlocked(GROUP)).toBe(true) // and recorded the block for the live path
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
it('does NOT skip a DM even when its chat id is in the cache (DMs are never rights-gated)', async () => {
|
|
716
|
+
const cache = new PinRightsCache()
|
|
717
|
+
cache.block(DM)
|
|
718
|
+
const h = harness({
|
|
719
|
+
stack: [203],
|
|
720
|
+
rightsBlocked: (c) => cache.isBlocked(c),
|
|
721
|
+
recordRightsBlock: (c) => {
|
|
722
|
+
cache.block(c)
|
|
723
|
+
},
|
|
724
|
+
})
|
|
725
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
|
|
726
|
+
expect(res.status).toBe('drained')
|
|
727
|
+
expect(h.fake.stack).toEqual([])
|
|
630
728
|
})
|
|
631
729
|
})
|
|
632
730
|
|
|
@@ -945,7 +1043,6 @@ function sweeperOver(
|
|
|
945
1043
|
pinSilent: fake.pinSilent,
|
|
946
1044
|
unpin: fake.unpin,
|
|
947
1045
|
unpinAllForumTopicMessages: fake.unpinAllForumTopicMessages,
|
|
948
|
-
canPinInChat: fake.canPinInChat,
|
|
949
1046
|
protectedMessageIds: () => [],
|
|
950
1047
|
recordedPinIds: () => opts.recordedPinIds ?? [],
|
|
951
1048
|
eligible: () => true,
|
|
@@ -1045,7 +1142,7 @@ describe('stale-pin sweep — re-drain after the boot-seed prune (#3953)', () =>
|
|
|
1045
1142
|
const path = '/state/stale-pin-sweep.json'
|
|
1046
1143
|
|
|
1047
1144
|
// Session 1: the one recorded orphan is reaped, obligation discharged.
|
|
1048
|
-
const s1 = await sweeperOver(fs, path, fakeChat({ stack: [77]
|
|
1145
|
+
const s1 = await sweeperOver(fs, path, fakeChat({ stack: [77] }), {
|
|
1049
1146
|
recordedPinIds: [77],
|
|
1050
1147
|
}).sweepTarget({ chatId: GROUP })
|
|
1051
1148
|
expect(s1.status).toBe('drained')
|
|
@@ -132,6 +132,12 @@ import {
|
|
|
132
132
|
upsertSweepCursor,
|
|
133
133
|
SWEEP_MAX_ATTEMPTS,
|
|
134
134
|
} from './stale-pin-sweep-store.js'
|
|
135
|
+
// The single source of truth for the pin-rights concept. The sweep classifies
|
|
136
|
+
// a rights failure REACTIVELY — it attempts the unpin and reads Telegram's
|
|
137
|
+
// honest `400 "not enough rights"` — using the same detector the live status-pin
|
|
138
|
+
// path uses, so the two paths can never disagree on what "no pin rights" means
|
|
139
|
+
// (this file must stay Telegram-import-free; status-pin.ts is dependency-free).
|
|
140
|
+
import { isPinRightsError } from '../status-pin.js'
|
|
135
141
|
|
|
136
142
|
// ─── Rate gates (operator-mandated; do not inline these numbers) ─────────────
|
|
137
143
|
|
|
@@ -400,17 +406,6 @@ export function isPeerFloodError(err: unknown): boolean {
|
|
|
400
406
|
return description(err).includes('peer_flood')
|
|
401
407
|
}
|
|
402
408
|
|
|
403
|
-
/**
|
|
404
|
-
* The bot is not allowed to manage pins here. Verified live: a bot without
|
|
405
|
-
* `can_pin_messages` gets `400 "not enough rights to manage pinned messages in
|
|
406
|
-
* the chat"` from EVERY pin method — an honest, stable rejection, unlike the
|
|
407
|
-
* silent-success unpin. So a rights failure is terminal for the chat, not a
|
|
408
|
-
* retry.
|
|
409
|
-
*/
|
|
410
|
-
export function isPinRightsError(err: unknown): boolean {
|
|
411
|
-
return description(err).includes('not enough rights')
|
|
412
|
-
}
|
|
413
|
-
|
|
414
409
|
/**
|
|
415
410
|
* `unpinChatMessage` with NO `message_id` on an EMPTY stack answers `400
|
|
416
411
|
* "message to unpin not found"`. That is a real, positive termination signal —
|
|
@@ -566,10 +561,18 @@ export interface StalePinSweepDeps {
|
|
|
566
561
|
/** `unpinAllForumTopicMessages(chat, thread)` — the one topic-scoped verb. */
|
|
567
562
|
unpinAllForumTopicMessages: (chatId: string, threadId: number) => Promise<unknown>
|
|
568
563
|
/**
|
|
569
|
-
*
|
|
570
|
-
*
|
|
564
|
+
* The SHARED per-process pin-rights negative cache (status-pin.ts
|
|
565
|
+
* `PinRightsCache`), so the sweep and the live status-pin path agree on which
|
|
566
|
+
* chats are rights-less. `rightsBlocked` is TRUE only when a REAL Telegram
|
|
567
|
+
* `400 "not enough rights"` was already observed (by the live pin path or an
|
|
568
|
+
* earlier sweep) — never a proactive network probe — so consulting it cannot
|
|
569
|
+
* resurrect the deleted `getChatMember` precheck. When it returns true the
|
|
570
|
+
* sweep skips the doomed group drain; `recordRightsBlock` feeds the cache from
|
|
571
|
+
* the sweep's OWN reactive rights discoveries. Both optional: undefined ⇒ the
|
|
572
|
+
* sweep relies purely on its reactive classifier.
|
|
571
573
|
*/
|
|
572
|
-
|
|
574
|
+
rightsBlocked?: (chatId: string) => boolean
|
|
575
|
+
recordRightsBlock?: (chatId: string) => void
|
|
573
576
|
/**
|
|
574
577
|
* Message ids in this chat that must be RE-PINNED once the drain finishes:
|
|
575
578
|
* live in-memory claims plus the deliberately-retained store rows (unexpired
|
|
@@ -1126,24 +1129,32 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
|
|
|
1126
1129
|
}
|
|
1127
1130
|
}
|
|
1128
1131
|
|
|
1129
|
-
//
|
|
1130
|
-
//
|
|
1131
|
-
//
|
|
1132
|
-
// precheck
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1132
|
+
// NO proactive rights precheck. A `getChatMember`-based precheck was
|
|
1133
|
+
// DELETED (see below): the sweep runs against the chat-lock-wrapped bot,
|
|
1134
|
+
// which carries only `.api` and no `.botInfo`, so `self` was always null,
|
|
1135
|
+
// the precheck always returned false, and every group target forfeited at
|
|
1136
|
+
// SWEEP_MAX_ATTEMPTS without a single unpin ever going out
|
|
1137
|
+
// (`getChatMember` appeared 0× in a 35MB live gateway log). Rights are now
|
|
1138
|
+
// classified REACTIVELY and only from a real Telegram `400 "not enough
|
|
1139
|
+
// rights"`: each drain primitive attempts the unpin and folds that error to
|
|
1140
|
+
// `{ kind: 'rights' }` via `classify`, returning `skipped-no-rights`. The
|
|
1141
|
+
// attempt is counted below BEFORE the drain, so a genuinely rights-less
|
|
1142
|
+
// chat still increments `attempts` on every boot and forfeits at
|
|
1143
|
+
// SWEEP_MAX_ATTEMPTS — no infinite retry — while a chat the bot CAN pin in
|
|
1144
|
+
// is no longer wrongly skipped.
|
|
1145
|
+
//
|
|
1146
|
+
// Same-process negative cache (D3): if the LIVE status-pin path (or an
|
|
1147
|
+
// earlier sweep) already observed a REAL `400 not enough rights` for this
|
|
1148
|
+
// chat, skip the doomed group drain rather than re-attempting it. This is
|
|
1149
|
+
// NOT a network probe — it is fed only by observed rights failures — so it
|
|
1150
|
+
// does not resurrect the deleted precheck. DMs are never rights-gated. The
|
|
1151
|
+
// attempt is still counted so the cursor forfeits at SWEEP_MAX_ATTEMPTS.
|
|
1152
|
+
if (kind !== 'dm' && deps.rightsBlocked?.(target.chatId) === true) {
|
|
1153
|
+
cursor.attempts++
|
|
1154
|
+
cursor.lastStatus = 'skipped-no-rights'
|
|
1155
|
+
cursor.updatedAt = deps.now()
|
|
1156
|
+
commit(cursor)
|
|
1157
|
+
return { status: 'skipped-no-rights', popped: 0, issued: 0 }
|
|
1147
1158
|
}
|
|
1148
1159
|
|
|
1149
1160
|
cursor.attempts++
|
|
@@ -1196,6 +1207,11 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
|
|
|
1196
1207
|
cursor.updatedAt = deps.now()
|
|
1197
1208
|
commit(cursor)
|
|
1198
1209
|
|
|
1210
|
+
// Feed the shared negative cache (D3): a reactive `skipped-no-rights` is a
|
|
1211
|
+
// real observed `400 not enough rights`, so record it so the live pin path
|
|
1212
|
+
// and later sweeps of this chat skip the doomed attempt too.
|
|
1213
|
+
if (result.status === 'skipped-no-rights') deps.recordRightsBlock?.(target.chatId)
|
|
1214
|
+
|
|
1199
1215
|
// Restore the deliberately-retained pins the blind DM drain cleared.
|
|
1200
1216
|
// Rate-gated like any other write; a failure is logged and never fatal.
|
|
1201
1217
|
//
|
|
@@ -73,6 +73,10 @@ export interface ThrottleBrokerClient {
|
|
|
73
73
|
throttled_until: number
|
|
74
74
|
escalated: boolean
|
|
75
75
|
rolledTo?: string | null
|
|
76
|
+
/** True when the caller has a strict pin (`auth.strict`) — its null
|
|
77
|
+
* `rolledTo` means "riding out the wall", not fleet all-blocked.
|
|
78
|
+
* Absent on pre-flag brokers. */
|
|
79
|
+
caller_pinned_strict?: boolean
|
|
76
80
|
}>
|
|
77
81
|
claimNotification(key: string, windowMs: number): Promise<{ granted: boolean }>
|
|
78
82
|
}
|
|
@@ -214,19 +218,22 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
|
|
|
214
218
|
client: ThrottleBrokerClient | null,
|
|
215
219
|
account: string | null,
|
|
216
220
|
rolledTo: string | null,
|
|
221
|
+
callerPinnedStrict: boolean,
|
|
217
222
|
triggerAgent: string,
|
|
218
223
|
armedAtMs: number,
|
|
219
224
|
): Promise<void> {
|
|
220
225
|
deps.log(
|
|
221
226
|
`[throttle-tier] escalated to wall account=${account ?? '?'} ` +
|
|
222
|
-
`rolledTo=${rolledTo ?? 'none (all blocked)'}`,
|
|
227
|
+
`rolledTo=${rolledTo ?? (callerPinnedStrict ? 'none (strict pin, riding it out)' : 'none (all blocked)')}`,
|
|
223
228
|
)
|
|
224
229
|
await broadcastDeduped(
|
|
225
230
|
client,
|
|
226
231
|
'throttle-escalation',
|
|
227
232
|
account,
|
|
228
|
-
renderThrottleEscalationNotice({ account, agent: triggerAgent, rolledTo }),
|
|
233
|
+
renderThrottleEscalationNotice({ account, agent: triggerAgent, rolledTo, callerPinnedStrict }),
|
|
229
234
|
)
|
|
235
|
+
// No nudge on a null rolledTo — for a strict pin a resume would replay
|
|
236
|
+
// the turn straight back into the wall it just hit.
|
|
230
237
|
if (rolledTo) nudgeResume('throttle-escalation-resume', armedAtMs)
|
|
231
238
|
}
|
|
232
239
|
|
|
@@ -240,6 +247,7 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
|
|
|
240
247
|
let account: string | null = null
|
|
241
248
|
let escalated = false
|
|
242
249
|
let rolledTo: string | null = null
|
|
250
|
+
let callerPinnedStrict = false
|
|
243
251
|
try {
|
|
244
252
|
client = await deps.getBrokerClient()
|
|
245
253
|
if (client) {
|
|
@@ -247,6 +255,7 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
|
|
|
247
255
|
account = r.account
|
|
248
256
|
escalated = r.escalated
|
|
249
257
|
rolledTo = r.rolledTo ?? null
|
|
258
|
+
callerPinnedStrict = r.caller_pinned_strict ?? false
|
|
250
259
|
} else {
|
|
251
260
|
deps.log(
|
|
252
261
|
`[throttle-tier] broker unreachable — notice only, no ledger record agent=${triggerAgent}`,
|
|
@@ -259,7 +268,7 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
|
|
|
259
268
|
}
|
|
260
269
|
|
|
261
270
|
if (escalated) {
|
|
262
|
-
await announceEscalation(client, account, rolledTo, triggerAgent, armedAtMs)
|
|
271
|
+
await announceEscalation(client, account, rolledTo, callerPinnedStrict, triggerAgent, armedAtMs)
|
|
263
272
|
return
|
|
264
273
|
}
|
|
265
274
|
|
|
@@ -303,6 +312,7 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
|
|
|
303
312
|
let account: string | null = null
|
|
304
313
|
let escalated = false
|
|
305
314
|
let rolledTo: string | null = null
|
|
315
|
+
let callerPinnedStrict = false
|
|
306
316
|
try {
|
|
307
317
|
client = await deps.getBrokerClient()
|
|
308
318
|
if (client) {
|
|
@@ -312,6 +322,7 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
|
|
|
312
322
|
account = r.account
|
|
313
323
|
escalated = r.escalated
|
|
314
324
|
rolledTo = r.rolledTo ?? null
|
|
325
|
+
callerPinnedStrict = r.caller_pinned_strict ?? false
|
|
315
326
|
} else {
|
|
316
327
|
deps.log(
|
|
317
328
|
`[throttle-tier] broker unreachable — probe-only skipped agent=${triggerAgent}`,
|
|
@@ -324,7 +335,7 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
|
|
|
324
335
|
}
|
|
325
336
|
|
|
326
337
|
if (escalated) {
|
|
327
|
-
await announceEscalation(client, account, rolledTo, triggerAgent, armedAtMs)
|
|
338
|
+
await announceEscalation(client, account, rolledTo, callerPinnedStrict, triggerAgent, armedAtMs)
|
|
328
339
|
return
|
|
329
340
|
}
|
|
330
341
|
|
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
|
|
23
23
|
import type { BannerState } from './slot-banner.js';
|
|
24
24
|
import { decideBannerAction } from './slot-banner.js';
|
|
25
|
+
// Shared rights model (D5): the banner adopts the status-pin path's rights
|
|
26
|
+
// detector, terminal-unpin classifier and per-process negative cache, so the two
|
|
27
|
+
// pin owners agree on which chats are rights-less instead of the banner's old
|
|
28
|
+
// drop-on-ANY-unpin. status-pin.ts is dependency-free.
|
|
29
|
+
import { isPinRightsError, isUnpinTerminalError, type PinRightsCache } from './status-pin.js';
|
|
25
30
|
|
|
26
31
|
/** Minimal subset of grammy's `bot.api` we depend on. Letting tests
|
|
27
32
|
* swap in `fake-bot-api.ts` without dragging in the full Bot type. */
|
|
@@ -72,6 +77,15 @@ export interface RefreshBannerArgs {
|
|
|
72
77
|
/** Optional API-failure observer. Phase identifies which Bot API
|
|
73
78
|
* call failed so the caller can log meaningfully. Default: silent. */
|
|
74
79
|
onError?: (phase: 'pin' | 'edit' | 'unpin', err: unknown) => void;
|
|
80
|
+
/** Shared per-process pin-rights negative cache (status-pin.ts
|
|
81
|
+
* `PinRightsCache`), unifying the banner with the status-pin path on
|
|
82
|
+
* rights-less chats (D5). When the chat is already known rights-less the pin
|
|
83
|
+
* action is skipped so we never emit an un-pinnable notice; a real pin-rights
|
|
84
|
+
* 400 from the pin/unpin verb records the block and a confirmed pin clears
|
|
85
|
+
* it. Fed ONLY from the pin/unpin verbs — a `sendMessage` failure is never
|
|
86
|
+
* cached, because a send failure is not a pin-rights signal. Optional;
|
|
87
|
+
* omitted in unit tests. */
|
|
88
|
+
rightsCache?: Pick<PinRightsCache, 'isBlocked' | 'block' | 'clear'>;
|
|
75
89
|
/** Optional durable-persistence hooks so an orphaned banner pin is
|
|
76
90
|
* recoverable across a gateway crash. The gateway wires these to the
|
|
77
91
|
* shared status-pin store (a distinct `banner:` pinKey), mirroring the
|
|
@@ -127,20 +141,34 @@ export async function refreshBanner(
|
|
|
127
141
|
|
|
128
142
|
if (action.kind === 'unpin') {
|
|
129
143
|
try {
|
|
144
|
+
// allow-raw-pin: the slot banner is a sanctioned separate pin owner (see
|
|
145
|
+
// scripts/check-status-pin-single-path-allowlist.txt); its unpin is not
|
|
146
|
+
// routed through reconcileStatusPin.
|
|
130
147
|
await args.bot.api.unpinChatMessage(args.ownerChatId, action.messageId);
|
|
131
148
|
} catch (err) {
|
|
132
149
|
args.onError?.('unpin', err);
|
|
150
|
+
// Feed the shared negative cache ONLY from a real pin-rights 400 on the
|
|
151
|
+
// unpin verb — never from any other failure class (D5).
|
|
152
|
+
if (isPinRightsError(err)) args.rightsCache?.block(String(args.ownerChatId));
|
|
153
|
+
// Adopt the status-pin terminal-vs-never-confirmed contract (#3664 Defect
|
|
154
|
+
// B) instead of drop-on-ANY-unpin: retain the claim when the unpin did
|
|
155
|
+
// NOT terminally land (transient flood/5xx/network), so the next refresh
|
|
156
|
+
// retries rather than orphaning a still-pinned banner with no record. A
|
|
157
|
+
// terminal 4xx (rights, message gone, bot kicked) — and a success — fall
|
|
158
|
+
// through to the drop below, because re-issuing cannot help.
|
|
159
|
+
if (!isUnpinTerminalError(err)) return args.prevState;
|
|
133
160
|
}
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
// would be more confusing than surfacing it again later. Drop the
|
|
137
|
-
// persisted record too (unpin-then-clear mirrors the status-pin store:
|
|
138
|
-
// a crash between unpin and clear just re-unpins next boot, idempotent).
|
|
161
|
+
// Terminal outcome: drop our claim and the persisted record. A crash
|
|
162
|
+
// between unpin and clear just re-unpins next boot (idempotent).
|
|
139
163
|
safePersist(args.persist?.clear);
|
|
140
164
|
return null;
|
|
141
165
|
}
|
|
142
166
|
|
|
143
167
|
if (action.kind === 'pin') {
|
|
168
|
+
// The banner is a PINNED notice: if this chat is already known rights-less
|
|
169
|
+
// (proved by the status-pin path or an earlier banner pin), skip the whole
|
|
170
|
+
// send+pin — an un-pinnable send is pure noise (D5).
|
|
171
|
+
if (args.rightsCache?.isBlocked(String(args.ownerChatId))) return args.prevState;
|
|
144
172
|
let sent: { message_id: number };
|
|
145
173
|
try {
|
|
146
174
|
// sendRichMessage doesn't accept link_preview_options — omit it.
|
|
@@ -150,6 +178,8 @@ export async function refreshBanner(
|
|
|
150
178
|
disable_notification: true,
|
|
151
179
|
});
|
|
152
180
|
} catch (err) {
|
|
181
|
+
// SEND failure — deliberately NOT a pin-rights signal, so the cache is
|
|
182
|
+
// NEVER written here even though the phase tag below is 'pin' (D5).
|
|
153
183
|
args.onError?.('pin', err);
|
|
154
184
|
return args.prevState;
|
|
155
185
|
}
|
|
@@ -158,17 +188,24 @@ export async function refreshBanner(
|
|
|
158
188
|
// unpins next boot — closing the persist-after-pin leak.
|
|
159
189
|
safePersist(() => args.persist?.pending?.(String(args.ownerChatId), sent.message_id));
|
|
160
190
|
try {
|
|
191
|
+
// allow-raw-pin: sanctioned banner pin owner (see the allowlist).
|
|
161
192
|
await args.bot.api.pinChatMessage(args.ownerChatId, sent.message_id, {
|
|
162
193
|
disable_notification: true,
|
|
163
194
|
});
|
|
164
195
|
} catch (err) {
|
|
165
196
|
args.onError?.('pin', err);
|
|
197
|
+
// The PIN verb failing with a real pin-rights 400 IS the rights signal —
|
|
198
|
+
// record it so later refreshes skip the doomed send+pin (D5).
|
|
199
|
+
if (isPinRightsError(err)) args.rightsCache?.block(String(args.ownerChatId));
|
|
166
200
|
// sendMessage succeeded but pin failed — don't claim the message, and
|
|
167
201
|
// drop the pending record so we don't leave a phantom claim for a pin
|
|
168
202
|
// that never landed.
|
|
169
203
|
safePersist(args.persist?.clear);
|
|
170
204
|
return args.prevState;
|
|
171
205
|
}
|
|
206
|
+
// Pin confirmed — rights are present, so clear any stale block for this chat
|
|
207
|
+
// (mirrors the status-pin path clearing on a successful pin).
|
|
208
|
+
args.rightsCache?.clear(String(args.ownerChatId));
|
|
172
209
|
// Pin confirmed — rewrite the record without the pending flag.
|
|
173
210
|
safePersist(() => args.persist?.confirm?.(String(args.ownerChatId), sent.message_id));
|
|
174
211
|
return { messageId: sent.message_id, slot: action.slot };
|
|
@@ -190,6 +190,30 @@ describe('runFleetAutoFallback', () => {
|
|
|
190
190
|
expect(failover).toHaveBeenCalledTimes(1);
|
|
191
191
|
});
|
|
192
192
|
|
|
193
|
+
it('strict-pinned caller: null rolledTo yields the strict-pinned outcome, NOT all-blocked', async () => {
|
|
194
|
+
// agents.<name>.auth.strict — the broker marked the account but
|
|
195
|
+
// deliberately did not roll the caller. The all-blocked card here would
|
|
196
|
+
// claim fleet-wide exhaustion while its own snapshots show healthy
|
|
197
|
+
// accounts.
|
|
198
|
+
const failover = vi.fn(async () => ({
|
|
199
|
+
rolledTo: null, rolled: [], callerPinnedStrict: true,
|
|
200
|
+
}));
|
|
201
|
+
const out = await runFleetAutoFallback({
|
|
202
|
+
state: state('work@x', ['work@x']),
|
|
203
|
+
quotas: [qOk({ fiveHourUtilizationPct: 12, sevenDayUtilizationPct: 30 })],
|
|
204
|
+
failover,
|
|
205
|
+
triggerAgent: 'workbot',
|
|
206
|
+
now: NOW,
|
|
207
|
+
tz: 'UTC',
|
|
208
|
+
rateLimitTrigger: true,
|
|
209
|
+
});
|
|
210
|
+
expect(out.kind).toBe('strict-pinned');
|
|
211
|
+
expect(failover).toHaveBeenCalledTimes(1);
|
|
212
|
+
expect(out.announcement).toContain('strictly pinned');
|
|
213
|
+
expect(out.announcement).toContain('fleet is unaffected');
|
|
214
|
+
expect(out.announcement).not.toContain('blocked');
|
|
215
|
+
});
|
|
216
|
+
|
|
193
217
|
it('idempotency: skips the swap WITHOUT calling failover when active probes healthy', async () => {
|
|
194
218
|
const failover = vi.fn();
|
|
195
219
|
const out = await runFleetAutoFallback({
|