switchroom 0.20.10 → 0.20.12
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/agent-scheduler/index.js +68 -3
- package/dist/auth-broker/index.js +211 -46
- package/dist/cli/notion-write-pretool.mjs +68 -3
- 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 +5063 -2961
- package/dist/host-control/main.js +93 -27
- package/dist/vault/approvals/kernel-server.js +92 -26
- package/dist/vault/broker/server.js +92 -26
- package/examples/personal-google-workspace-mcp/compose.yaml +1 -1
- package/package.json +1 -1
- 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/skills/switchroom-architecture/telegram.md +0 -1
- package/skills/switchroom-cli/SKILL.md +0 -1
- package/telegram-plugin/README.md +2 -11
- package/telegram-plugin/auto-fallback-fleet.ts +37 -2
- package/telegram-plugin/bridge/bridge.ts +0 -12
- package/telegram-plugin/chat-lock.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +0 -12
- package/telegram-plugin/dist/gateway/gateway.js +1454 -976
- package/telegram-plugin/dist/server.js +0 -12
- 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 +109 -149
- package/telegram-plugin/gateway/ipc-protocol.ts +43 -0
- package/telegram-plugin/gateway/ipc-server.ts +28 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +6 -1
- 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 +52 -35
- package/telegram-plugin/gateway/status-pin-store.ts +10 -9
- package/telegram-plugin/gateway/stream-render.ts +4 -4
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +15 -4
- package/telegram-plugin/gateway/turn-record-status.ts +32 -1
- package/telegram-plugin/hooks/hooks.json +13 -12
- package/telegram-plugin/hooks/narration-classify.mjs +1 -2
- package/telegram-plugin/hooks/silent-end-scan.mjs +1 -1
- package/telegram-plugin/slot-banner-driver.ts +42 -5
- package/telegram-plugin/status-pin.ts +2 -5
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +24 -0
- package/telegram-plugin/tests/backstop-exactly-once.test.ts +8 -2
- package/telegram-plugin/tests/framework-fallback-duration-guard.test.ts +125 -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/pin-message-tool-retired.test.ts +64 -0
- package/telegram-plugin/tests/privacy-reset-call-sites.test.ts +120 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +38 -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/tests/worker-activity-feed.test.ts +40 -1
- package/telegram-plugin/throttle-tier.ts +12 -3
- package/telegram-plugin/turn-flush-safety.ts +97 -0
- package/telegram-plugin/worker-activity-feed.ts +1 -1
- package/vendor/hindsight-memory/scripts/recall.py +140 -0
- package/vendor/hindsight-memory/scripts/retain.py +306 -0
- 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_recall_latency_instrumentation.py +277 -0
- package/vendor/hindsight-memory/scripts/tests/test_self_improve_correction_tag.py +167 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-improvement proposal IPC handlers, lifted out of gateway.ts.
|
|
3
|
+
*
|
|
4
|
+
* Both `post_skill_proposal` (#2670 one-tap self-improvement) and
|
|
5
|
+
* `post_eval_case_proposal` (RFC amendment §"corrections as eval cases")
|
|
6
|
+
* arrive over the per-agent gateway socket, are chat-fenced via
|
|
7
|
+
* `assertAllowedChat`, persisted to their respective store, and rendered as
|
|
8
|
+
* an Approve/Dismiss card. The store transition + apply on Approve are owned
|
|
9
|
+
* by the callback handlers (so a gateway restart between post and tap still
|
|
10
|
+
* resolves), NOT here.
|
|
11
|
+
*
|
|
12
|
+
* These live in a module (not inline in gateway.ts) so the gateway anti-
|
|
13
|
+
* inflation line ratchet stays flat and the handler bodies are unit-testable
|
|
14
|
+
* against a stubbed `bot` / `swallowingApiCall`. gateway.ts keeps only a thin
|
|
15
|
+
* delegate per handler.
|
|
16
|
+
*
|
|
17
|
+
* The `bot.api.sendMessage` calls are wrapped in the injected
|
|
18
|
+
* `swallowingApiCall` exactly as they were in gateway.ts — the bot-api-wrapping
|
|
19
|
+
* lint recognises the wrapper by name in the surrounding context.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Bot, Context } from 'grammy'
|
|
23
|
+
import type { RetryCallOpts } from '../retry-api-call.js'
|
|
24
|
+
import type { PostSkillProposalMessage, PostEvalCaseProposalMessage } from './ipc-protocol.js'
|
|
25
|
+
import { renderSkillProposalCard, skillProposalKeyboard } from './skill-proposal-card.js'
|
|
26
|
+
import { renderEvalCaseProposalCard, evalCaseProposalKeyboard } from './eval-case-proposal-card.js'
|
|
27
|
+
import {
|
|
28
|
+
enqueueProposal as enqueueSkillProposal,
|
|
29
|
+
isSuppressed as isSkillProposalSuppressed,
|
|
30
|
+
} from '../../src/self-improve/skill-proposals.js'
|
|
31
|
+
import { enqueueEvalCaseProposal } from '../../src/self-improve/eval-case-proposals.js'
|
|
32
|
+
|
|
33
|
+
/** Collaborators the gateway injects into each handler. */
|
|
34
|
+
export interface ProposalWiringDeps {
|
|
35
|
+
bot: Bot<Context>
|
|
36
|
+
assertAllowedChat: (chatId: string) => void
|
|
37
|
+
swallowingApiCall: <T>(fn: () => Promise<T>, opts?: RetryCallOpts) => Promise<T | undefined>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* #2670 one-tap self-improvement — persist a skill-improvement proposal and
|
|
42
|
+
* post its Approve/Dismiss card. Dedups against still-live rejection
|
|
43
|
+
* fingerprints so a dismissed proposal never re-surfaces.
|
|
44
|
+
*/
|
|
45
|
+
export function handlePostSkillProposal(
|
|
46
|
+
msg: PostSkillProposalMessage,
|
|
47
|
+
deps: ProposalWiringDeps,
|
|
48
|
+
): void {
|
|
49
|
+
const { bot, assertAllowedChat, swallowingApiCall } = deps
|
|
50
|
+
const self = process.env.SWITCHROOM_AGENT_NAME
|
|
51
|
+
if (self && msg.agentName !== self) {
|
|
52
|
+
process.stderr.write(
|
|
53
|
+
`telegram gateway: post_skill_proposal rejected — agent mismatch (${msg.agentName} != ${self})\n`,
|
|
54
|
+
)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
assertAllowedChat(msg.chatId)
|
|
59
|
+
} catch (err) {
|
|
60
|
+
process.stderr.write(
|
|
61
|
+
`telegram gateway: post_skill_proposal rejected — ${(err as Error).message}\n`,
|
|
62
|
+
)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
const stateDir = process.env.TELEGRAM_STATE_DIR
|
|
66
|
+
if (stateDir == null || stateDir.length === 0) {
|
|
67
|
+
process.stderr.write(`telegram gateway: post_skill_proposal: TELEGRAM_STATE_DIR unset, skipping\n`)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
// Dedup against still-live rejection fingerprints — never re-surface a
|
|
71
|
+
// proposal the operator already dismissed.
|
|
72
|
+
if (isSkillProposalSuppressed(stateDir, {
|
|
73
|
+
lesson: msg.lesson,
|
|
74
|
+
draft: msg.draft,
|
|
75
|
+
skill_slug: msg.skillSlug,
|
|
76
|
+
})) {
|
|
77
|
+
process.stderr.write(
|
|
78
|
+
`telegram gateway: post_skill_proposal suppressed (rejected before) slug=${msg.skillSlug}\n`,
|
|
79
|
+
)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
const proposal = enqueueSkillProposal(stateDir, {
|
|
83
|
+
skill_slug: msg.skillSlug,
|
|
84
|
+
is_new: msg.isNew,
|
|
85
|
+
lesson: msg.lesson,
|
|
86
|
+
draft: msg.draft,
|
|
87
|
+
evidence: msg.evidence,
|
|
88
|
+
chat_id: Number(msg.chatId),
|
|
89
|
+
// Provenance — absent ⇒ the store's back-compat default (skill-synthesis).
|
|
90
|
+
...(msg.origin != null ? { origin: msg.origin } : {}),
|
|
91
|
+
})
|
|
92
|
+
const cardText = renderSkillProposalCard({
|
|
93
|
+
id: proposal.id,
|
|
94
|
+
skill_slug: proposal.skill_slug,
|
|
95
|
+
is_new: proposal.is_new,
|
|
96
|
+
lesson: proposal.lesson,
|
|
97
|
+
evidence: proposal.evidence,
|
|
98
|
+
skill_md: proposal.draft['SKILL.md'],
|
|
99
|
+
})
|
|
100
|
+
const threadId = msg.threadId
|
|
101
|
+
void swallowingApiCall(
|
|
102
|
+
() =>
|
|
103
|
+
bot.api.sendMessage(msg.chatId, cardText, {
|
|
104
|
+
parse_mode: 'HTML',
|
|
105
|
+
reply_markup: skillProposalKeyboard(proposal.id),
|
|
106
|
+
...(threadId != null && threadId !== 1 ? { message_thread_id: threadId } : {}),
|
|
107
|
+
}),
|
|
108
|
+
{ chat_id: msg.chatId, verb: 'skill-proposal-card', ...(threadId != null ? { threadId } : {}) },
|
|
109
|
+
)
|
|
110
|
+
process.stderr.write(
|
|
111
|
+
`telegram gateway: post_skill_proposal agent=${msg.agentName} chat=${msg.chatId} ` +
|
|
112
|
+
`proposal=${proposal.id} slug=${proposal.skill_slug} new=${proposal.is_new}\n`,
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* RFC amendment §"corrections as eval cases" — persist an eval-case proposal
|
|
118
|
+
* and post its Approve/Dismiss card. On Approve the callback runs the
|
|
119
|
+
* DETERMINISTIC applier (handleEvalCaseProposalCallback), NOT a model turn, so
|
|
120
|
+
* the case lands byte-exact. Same per-agent-socket / chat-fenced trust model
|
|
121
|
+
* as handlePostSkillProposal.
|
|
122
|
+
*/
|
|
123
|
+
export function handlePostEvalCaseProposal(
|
|
124
|
+
msg: PostEvalCaseProposalMessage,
|
|
125
|
+
deps: ProposalWiringDeps,
|
|
126
|
+
): void {
|
|
127
|
+
const { bot, assertAllowedChat, swallowingApiCall } = deps
|
|
128
|
+
const self = process.env.SWITCHROOM_AGENT_NAME
|
|
129
|
+
if (self && msg.agentName !== self) {
|
|
130
|
+
process.stderr.write(
|
|
131
|
+
`telegram gateway: post_eval_case_proposal rejected — agent mismatch (${msg.agentName} != ${self})\n`,
|
|
132
|
+
)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
assertAllowedChat(msg.chatId)
|
|
137
|
+
} catch (err) {
|
|
138
|
+
process.stderr.write(
|
|
139
|
+
`telegram gateway: post_eval_case_proposal rejected — ${(err as Error).message}\n`,
|
|
140
|
+
)
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
const stateDir = process.env.TELEGRAM_STATE_DIR
|
|
144
|
+
if (stateDir == null || stateDir.length === 0) {
|
|
145
|
+
process.stderr.write(`telegram gateway: post_eval_case_proposal: TELEGRAM_STATE_DIR unset, skipping\n`)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
const proposal = enqueueEvalCaseProposal(stateDir, {
|
|
149
|
+
skill_slug: msg.skillSlug,
|
|
150
|
+
skill_dir: msg.skillDir,
|
|
151
|
+
case: msg.case,
|
|
152
|
+
fingerprint: msg.fingerprint,
|
|
153
|
+
held_out: msg.heldOut === true,
|
|
154
|
+
chat_id: Number(msg.chatId),
|
|
155
|
+
})
|
|
156
|
+
const cardText = renderEvalCaseProposalCard({
|
|
157
|
+
id: proposal.id,
|
|
158
|
+
skill_slug: proposal.skill_slug,
|
|
159
|
+
held_out: proposal.held_out,
|
|
160
|
+
case: proposal.case,
|
|
161
|
+
})
|
|
162
|
+
const threadId = msg.threadId
|
|
163
|
+
void swallowingApiCall(
|
|
164
|
+
() =>
|
|
165
|
+
bot.api.sendMessage(msg.chatId, cardText, {
|
|
166
|
+
parse_mode: 'HTML',
|
|
167
|
+
reply_markup: evalCaseProposalKeyboard(proposal.id),
|
|
168
|
+
...(threadId != null && threadId !== 1 ? { message_thread_id: threadId } : {}),
|
|
169
|
+
}),
|
|
170
|
+
{ chat_id: msg.chatId, verb: 'eval-case-proposal-card', ...(threadId != null ? { threadId } : {}) },
|
|
171
|
+
)
|
|
172
|
+
process.stderr.write(
|
|
173
|
+
`telegram gateway: post_eval_case_proposal agent=${msg.agentName} chat=${msg.chatId} ` +
|
|
174
|
+
`proposal=${proposal.id} slug=${proposal.skill_slug} held_out=${proposal.held_out}\n`,
|
|
175
|
+
)
|
|
176
|
+
}
|
|
@@ -51,9 +51,7 @@ export interface StalePinSweepBotSeam {
|
|
|
51
51
|
) => Promise<unknown>
|
|
52
52
|
unpinChatMessage: (chatId: string, messageId: number) => Promise<unknown>
|
|
53
53
|
unpinAllForumTopicMessages: (chatId: string, threadId: number) => Promise<unknown>
|
|
54
|
-
getChatMember: (chatId: string, userId: number) => Promise<unknown>
|
|
55
54
|
}
|
|
56
|
-
botInfo?: { id?: number }
|
|
57
55
|
}
|
|
58
56
|
/** The gateway's retry/telemetry envelope (`robustApiCall`). */
|
|
59
57
|
call: <T>(fn: () => Promise<T>, meta: { chat_id: string; verb: string }) => Promise<T>
|
|
@@ -68,6 +66,14 @@ export interface StalePinSweepWiring {
|
|
|
68
66
|
loadPinRows: () => PersistedStatusPin[]
|
|
69
67
|
/** Mutex-won AND bot-constructed. False ⇒ the drain must not write. */
|
|
70
68
|
eligible: () => boolean
|
|
69
|
+
/**
|
|
70
|
+
* The SHARED per-process pin-rights negative cache (status-pin.ts
|
|
71
|
+
* `PinRightsCache`), unifying the sweep and the live status-pin path on which
|
|
72
|
+
* chats are rights-less. Only `isBlocked`/`block` are used here; the live
|
|
73
|
+
* path owns `clear` (on a successful explicit pin) and boot owns the reset.
|
|
74
|
+
* Optional — undefined ⇒ the sweep relies purely on its reactive classifier.
|
|
75
|
+
*/
|
|
76
|
+
rightsCache?: { isBlocked: (chatId: string) => boolean; block: (chatId: string) => boolean }
|
|
71
77
|
store: { path: string; fs: SweepStoreFsSeam }
|
|
72
78
|
/** Per-deployment override of `UNPIN_ALL_FORUM_TOPIC_ENABLED`; undefined =
|
|
73
79
|
* take the standing policy (the wholesale topic drain stays OFF). */
|
|
@@ -168,18 +174,16 @@ export function createGatewayStalePinSweeper(w: StalePinSweepWiring): StalePinSw
|
|
|
168
174
|
chat_id: chatId,
|
|
169
175
|
verb: 'stale-pin-sweep.unpin-all-topic',
|
|
170
176
|
}),
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
return member?.status === 'administrator' && member.can_pin_messages === true
|
|
182
|
-
},
|
|
177
|
+
// No proactive rights precheck. The old `getChatMember` precheck read
|
|
178
|
+
// `bot().botInfo?.id`, but the sweep runs against the chat-lock-wrapped bot
|
|
179
|
+
// (`wrapBot({ api: bot.api })`), which carries only `.api` — `.botInfo` was
|
|
180
|
+
// always undefined, so `self` was always null and every group precheck
|
|
181
|
+
// returned false, forfeiting every group cursor without a single unpin. The
|
|
182
|
+
// sweep now relies SOLELY on its reactive classifier: it attempts the unpin
|
|
183
|
+
// and classifies Telegram's honest `400 "not enough rights"` via
|
|
184
|
+
// `isPinRightsError`. Telegram is authoritative about pin rights; a precheck
|
|
185
|
+
// could only ever be redundant with (and, wired against the wrong bot,
|
|
186
|
+
// wrong about) that answer.
|
|
183
187
|
recordedPinIds: (chatId, threadId) => {
|
|
184
188
|
try {
|
|
185
189
|
return recordedPinIdsFor(w.loadPinRows(), chatId, threadId)
|
|
@@ -200,6 +204,12 @@ export function createGatewayStalePinSweeper(w: StalePinSweepWiring): StalePinSw
|
|
|
200
204
|
log,
|
|
201
205
|
}),
|
|
202
206
|
eligible: w.eligible,
|
|
207
|
+
rightsBlocked: w.rightsCache ? (chatId) => w.rightsCache!.isBlocked(chatId) : undefined,
|
|
208
|
+
recordRightsBlock: w.rightsCache
|
|
209
|
+
? (chatId) => {
|
|
210
|
+
w.rightsCache!.block(chatId)
|
|
211
|
+
}
|
|
212
|
+
: undefined,
|
|
203
213
|
sleep: w.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))),
|
|
204
214
|
now,
|
|
205
215
|
store: w.store,
|
|
@@ -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
|
|
|
@@ -346,9 +352,10 @@ export function collectSweepTargets(input: {
|
|
|
346
352
|
|
|
347
353
|
/**
|
|
348
354
|
* The messageIds in `chatId` that must SURVIVE a drain because they belong to
|
|
349
|
-
* deliberately-retained store rows: unexpired time-scoped `tool:` pins (
|
|
350
|
-
* `pin_message` MCP tool, #3001
|
|
351
|
-
*
|
|
355
|
+
* deliberately-retained store rows: unexpired time-scoped `tool:` pins (legacy
|
|
356
|
+
* rows from the retired `pin_message` MCP tool, #3001 / #4452 — no new ones are
|
|
357
|
+
* written), which `runStatusPinBootCleanup` intentionally KEEPS across restarts
|
|
358
|
+
* until they expire. Work-scoped rows (no `expiresAt`) are stale by
|
|
352
359
|
* definition after a restart and are NOT re-pin candidates; expired
|
|
353
360
|
* time-scoped rows are due for sweeping.
|
|
354
361
|
*/
|
|
@@ -400,17 +407,6 @@ export function isPeerFloodError(err: unknown): boolean {
|
|
|
400
407
|
return description(err).includes('peer_flood')
|
|
401
408
|
}
|
|
402
409
|
|
|
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
410
|
/**
|
|
415
411
|
* `unpinChatMessage` with NO `message_id` on an EMPTY stack answers `400
|
|
416
412
|
* "message to unpin not found"`. That is a real, positive termination signal —
|
|
@@ -566,10 +562,18 @@ export interface StalePinSweepDeps {
|
|
|
566
562
|
/** `unpinAllForumTopicMessages(chat, thread)` — the one topic-scoped verb. */
|
|
567
563
|
unpinAllForumTopicMessages: (chatId: string, threadId: number) => Promise<unknown>
|
|
568
564
|
/**
|
|
569
|
-
*
|
|
570
|
-
*
|
|
565
|
+
* The SHARED per-process pin-rights negative cache (status-pin.ts
|
|
566
|
+
* `PinRightsCache`), so the sweep and the live status-pin path agree on which
|
|
567
|
+
* chats are rights-less. `rightsBlocked` is TRUE only when a REAL Telegram
|
|
568
|
+
* `400 "not enough rights"` was already observed (by the live pin path or an
|
|
569
|
+
* earlier sweep) — never a proactive network probe — so consulting it cannot
|
|
570
|
+
* resurrect the deleted `getChatMember` precheck. When it returns true the
|
|
571
|
+
* sweep skips the doomed group drain; `recordRightsBlock` feeds the cache from
|
|
572
|
+
* the sweep's OWN reactive rights discoveries. Both optional: undefined ⇒ the
|
|
573
|
+
* sweep relies purely on its reactive classifier.
|
|
571
574
|
*/
|
|
572
|
-
|
|
575
|
+
rightsBlocked?: (chatId: string) => boolean
|
|
576
|
+
recordRightsBlock?: (chatId: string) => void
|
|
573
577
|
/**
|
|
574
578
|
* Message ids in this chat that must be RE-PINNED once the drain finishes:
|
|
575
579
|
* live in-memory claims plus the deliberately-retained store rows (unexpired
|
|
@@ -1126,24 +1130,32 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
|
|
|
1126
1130
|
}
|
|
1127
1131
|
}
|
|
1128
1132
|
|
|
1129
|
-
//
|
|
1130
|
-
//
|
|
1131
|
-
//
|
|
1132
|
-
// precheck
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1133
|
+
// NO proactive rights precheck. A `getChatMember`-based precheck was
|
|
1134
|
+
// DELETED (see below): the sweep runs against the chat-lock-wrapped bot,
|
|
1135
|
+
// which carries only `.api` and no `.botInfo`, so `self` was always null,
|
|
1136
|
+
// the precheck always returned false, and every group target forfeited at
|
|
1137
|
+
// SWEEP_MAX_ATTEMPTS without a single unpin ever going out
|
|
1138
|
+
// (`getChatMember` appeared 0× in a 35MB live gateway log). Rights are now
|
|
1139
|
+
// classified REACTIVELY and only from a real Telegram `400 "not enough
|
|
1140
|
+
// rights"`: each drain primitive attempts the unpin and folds that error to
|
|
1141
|
+
// `{ kind: 'rights' }` via `classify`, returning `skipped-no-rights`. The
|
|
1142
|
+
// attempt is counted below BEFORE the drain, so a genuinely rights-less
|
|
1143
|
+
// chat still increments `attempts` on every boot and forfeits at
|
|
1144
|
+
// SWEEP_MAX_ATTEMPTS — no infinite retry — while a chat the bot CAN pin in
|
|
1145
|
+
// is no longer wrongly skipped.
|
|
1146
|
+
//
|
|
1147
|
+
// Same-process negative cache (D3): if the LIVE status-pin path (or an
|
|
1148
|
+
// earlier sweep) already observed a REAL `400 not enough rights` for this
|
|
1149
|
+
// chat, skip the doomed group drain rather than re-attempting it. This is
|
|
1150
|
+
// NOT a network probe — it is fed only by observed rights failures — so it
|
|
1151
|
+
// does not resurrect the deleted precheck. DMs are never rights-gated. The
|
|
1152
|
+
// attempt is still counted so the cursor forfeits at SWEEP_MAX_ATTEMPTS.
|
|
1153
|
+
if (kind !== 'dm' && deps.rightsBlocked?.(target.chatId) === true) {
|
|
1154
|
+
cursor.attempts++
|
|
1155
|
+
cursor.lastStatus = 'skipped-no-rights'
|
|
1156
|
+
cursor.updatedAt = deps.now()
|
|
1157
|
+
commit(cursor)
|
|
1158
|
+
return { status: 'skipped-no-rights', popped: 0, issued: 0 }
|
|
1147
1159
|
}
|
|
1148
1160
|
|
|
1149
1161
|
cursor.attempts++
|
|
@@ -1196,6 +1208,11 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
|
|
|
1196
1208
|
cursor.updatedAt = deps.now()
|
|
1197
1209
|
commit(cursor)
|
|
1198
1210
|
|
|
1211
|
+
// Feed the shared negative cache (D3): a reactive `skipped-no-rights` is a
|
|
1212
|
+
// real observed `400 not enough rights`, so record it so the live pin path
|
|
1213
|
+
// and later sweeps of this chat skip the doomed attempt too.
|
|
1214
|
+
if (result.status === 'skipped-no-rights') deps.recordRightsBlock?.(target.chatId)
|
|
1215
|
+
|
|
1199
1216
|
// Restore the deliberately-retained pins the blind DM drain cleared.
|
|
1200
1217
|
// Rate-gated like any other write; a failure is logged and never fatal.
|
|
1201
1218
|
//
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
* — the turn it represented is over or crashed), dropping rows only after a
|
|
18
18
|
* successful unpin (failed ones are retained with an attempt counter for a
|
|
19
19
|
* next-boot retry — see runStatusPinBootCleanup). Time-scoped `tool:` rows
|
|
20
|
-
* (the `pin_message` MCP tool, #3001
|
|
20
|
+
* (legacy pins from the retired `pin_message` MCP tool, #3001 / #4452 — no new
|
|
21
|
+
* ones are written) survive boots until their `expiresAt`.
|
|
21
22
|
* It does NOT re-adopt or re-pin — it only cleans up.
|
|
22
23
|
*
|
|
23
24
|
* Shape choice — SNAPSHOT, not append-log, mirroring obligation-store.ts. The
|
|
@@ -78,10 +79,10 @@ export interface PersistedStatusPin {
|
|
|
78
79
|
/** Wall-clock ms after which this pin is stale and boot cleanup unpins it.
|
|
79
80
|
* Rows WITHOUT this field are work-scoped (fg:/wk:/banner:) — stale the
|
|
80
81
|
* moment their owning session dies, so boot cleanup unpins them
|
|
81
|
-
* unconditionally. Rows WITH it (
|
|
82
|
-
* `pin_message` MCP tool, #3001
|
|
83
|
-
* no "work finished" event: they
|
|
84
|
-
* expired. */
|
|
82
|
+
* unconditionally. Rows WITH it (legacy `tool:` pins from the retired
|
|
83
|
+
* `pin_message` MCP tool, #3001 / #4452 — no new ones are written)
|
|
84
|
+
* represented deliberate agent pins with no "work finished" event: they
|
|
85
|
+
* SURVIVE restarts and are only swept once expired. */
|
|
85
86
|
expiresAt?: number
|
|
86
87
|
/** Boot-cleanup unpin retry counter (#3001). Incremented each boot the
|
|
87
88
|
* unpin fails (flood-wait exhausted / transient 5xx); the row is retained
|
|
@@ -282,10 +283,10 @@ export function pinnedMessageIsOurs(
|
|
|
282
283
|
* record whose pin MAY have landed in Telegram, so we must treat it exactly
|
|
283
284
|
* like a confirmed one and unpin it.
|
|
284
285
|
*
|
|
285
|
-
* TIME-SCOPED rows (`tool:` pins from the `pin_message` MCP
|
|
286
|
-
* `expiresAt`) have no "work finished" event, so a
|
|
287
|
-
* them: an unexpired row is RETAINED untouched across
|
|
288
|
-
* once `now >= expiresAt`.
|
|
286
|
+
* TIME-SCOPED rows (legacy `tool:` pins from the retired `pin_message` MCP
|
|
287
|
+
* tool, #4452, carrying `expiresAt`) have no "work finished" event, so a
|
|
288
|
+
* restart does NOT reset them: an unexpired row is RETAINED untouched across
|
|
289
|
+
* boots and only unpinned once `now >= expiresAt`.
|
|
289
290
|
*
|
|
290
291
|
* RETRY-SAFETY (#3001): a row is dropped only AFTER its unpin resolves. A
|
|
291
292
|
* failing unpin (flood-wait exhausted / transient 5xx) retains the row with an
|