switchroom 0.19.30 → 0.19.32
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/cli/switchroom.js +1121 -584
- package/dist/host-control/main.js +151 -73
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +5 -1
- package/profiles/_base/start.sh.hbs +15 -1
- package/telegram-plugin/dist/bridge/bridge.js +3 -0
- package/telegram-plugin/dist/gateway/gateway.js +1660 -677
- package/telegram-plugin/dist/server.js +3 -0
- package/telegram-plugin/edit-flood-fuse.ts +70 -20
- package/telegram-plugin/gateway/boot-beacon.ts +364 -0
- package/telegram-plugin/gateway/boot-sweep-gate.ts +20 -15
- package/telegram-plugin/gateway/gateway.ts +87 -88
- package/telegram-plugin/gateway/inbound-spool.ts +39 -0
- package/telegram-plugin/gateway/narrative-lane.ts +12 -0
- package/telegram-plugin/gateway/obligation-store.ts +28 -0
- package/telegram-plugin/gateway/stale-pin-sweep-store.ts +221 -0
- package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +211 -0
- package/telegram-plugin/gateway/stale-pin-sweep.test.ts +804 -0
- package/telegram-plugin/gateway/stale-pin-sweep.ts +1146 -0
- package/telegram-plugin/gateway/status-pin-retarget.ts +15 -2
- package/telegram-plugin/gateway/status-pin-store.ts +33 -11
- package/telegram-plugin/gateway/stream-render.ts +578 -321
- package/telegram-plugin/registry/turns-schema.ts +21 -1
- package/telegram-plugin/retry-api-call.ts +46 -21
- package/telegram-plugin/session-tail.ts +13 -0
- package/telegram-plugin/shared/bot-runtime.ts +61 -17
- package/telegram-plugin/shared/gw-trace-gate.ts +18 -2
- package/telegram-plugin/tests/activity-card-wiring.test.ts +7 -7
- package/telegram-plugin/tests/activity-drain-fuse-drop-not-failure.test.ts +324 -0
- package/telegram-plugin/tests/agent-card-result-footer.test.ts +193 -0
- package/telegram-plugin/tests/boot-beacon.test.ts +462 -0
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +6 -6
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +42 -31
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +2 -0
- package/telegram-plugin/tests/inbound-spool-progress.test.ts +2 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +134 -5
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +28 -0
- package/telegram-plugin/tests/obligation-determinism.test.ts +2 -0
- package/telegram-plugin/tests/obligation-store.test.ts +67 -1
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +26 -5
- package/telegram-plugin/tests/stream-render-golden.test.ts +20 -5
- package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +161 -0
- 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/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +30 -0
- package/telegram-plugin/tool-activity-summary.ts +104 -38
- package/telegram-plugin/worker-activity-feed.ts +33 -16
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +0 -251
- package/telegram-plugin/gateway/dm-pin-sweep.ts +0 -178
|
@@ -1,251 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
-
import {
|
|
3
|
-
isDmChatId,
|
|
4
|
-
collectDmChatIdsFromStores,
|
|
5
|
-
createDmPinSweeper,
|
|
6
|
-
unexpiredStoreRepinIds,
|
|
7
|
-
type DmPinSweeperDeps,
|
|
8
|
-
} from './dm-pin-sweep'
|
|
9
|
-
|
|
10
|
-
// Synthetic chat IDs only (check-no-pii-secrets): positive = DM, negative =
|
|
11
|
-
// group/supergroup.
|
|
12
|
-
const DM_A = '5000001'
|
|
13
|
-
const DM_B = '5000002'
|
|
14
|
-
const GROUP = '-1002000000001'
|
|
15
|
-
|
|
16
|
-
describe('isDmChatId', () => {
|
|
17
|
-
it('true for positive integer DM ids', () => {
|
|
18
|
-
expect(isDmChatId(DM_A)).toBe(true)
|
|
19
|
-
expect(isDmChatId('1')).toBe(true)
|
|
20
|
-
})
|
|
21
|
-
it('false for group/supergroup (negative), zero, non-integer, non-numeric', () => {
|
|
22
|
-
expect(isDmChatId(GROUP)).toBe(false)
|
|
23
|
-
expect(isDmChatId('-1')).toBe(false)
|
|
24
|
-
expect(isDmChatId('0')).toBe(false)
|
|
25
|
-
expect(isDmChatId('1.5')).toBe(false)
|
|
26
|
-
expect(isDmChatId('abc')).toBe(false)
|
|
27
|
-
expect(isDmChatId('')).toBe(false)
|
|
28
|
-
expect(isDmChatId('Infinity')).toBe(false)
|
|
29
|
-
})
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
describe('collectDmChatIdsFromStores', () => {
|
|
33
|
-
it('collects distinct DM chat ids across all three stores and drops non-DMs', () => {
|
|
34
|
-
const ids = collectDmChatIdsFromStores({
|
|
35
|
-
statusPins: [{ chatId: DM_A }, { chatId: GROUP }, { chatId: DM_A }],
|
|
36
|
-
activityCards: [{ chatId: DM_B }, { chatId: '0' }],
|
|
37
|
-
queuedCards: [{ chatId: DM_A }, { chatId: '-77' }],
|
|
38
|
-
})
|
|
39
|
-
expect(new Set(ids)).toEqual(new Set([DM_A, DM_B]))
|
|
40
|
-
expect(ids).toHaveLength(2)
|
|
41
|
-
})
|
|
42
|
-
it('handles missing store arrays', () => {
|
|
43
|
-
expect(collectDmChatIdsFromStores({})).toEqual([])
|
|
44
|
-
expect(collectDmChatIdsFromStores({ statusPins: [{ chatId: GROUP }] })).toEqual([])
|
|
45
|
-
})
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
function makeDeps(overrides: Partial<DmPinSweeperDeps> = {}): {
|
|
49
|
-
deps: DmPinSweeperDeps
|
|
50
|
-
unpinAll: ReturnType<typeof vi.fn>
|
|
51
|
-
pinSilent: ReturnType<typeof vi.fn>
|
|
52
|
-
} {
|
|
53
|
-
const unpinAll = vi.fn(async () => undefined)
|
|
54
|
-
const pinSilent = vi.fn(async () => undefined)
|
|
55
|
-
const deps: DmPinSweeperDeps = {
|
|
56
|
-
unpinAll,
|
|
57
|
-
pinSilent,
|
|
58
|
-
liveTrackedMessageIds: () => [],
|
|
59
|
-
eligible: () => true,
|
|
60
|
-
log: () => {},
|
|
61
|
-
...overrides,
|
|
62
|
-
}
|
|
63
|
-
return { deps, unpinAll, pinSilent }
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
describe('createDmPinSweeper', () => {
|
|
67
|
-
it('DM with tracked pins: unpin-all exactly once, then re-pin exactly the live ids', async () => {
|
|
68
|
-
const { deps, unpinAll, pinSilent } = makeDeps({
|
|
69
|
-
liveTrackedMessageIds: (chatId) => (chatId === DM_A ? [111, 222] : []),
|
|
70
|
-
})
|
|
71
|
-
const sweeper = createDmPinSweeper(deps)
|
|
72
|
-
await sweeper.sweep(DM_A)
|
|
73
|
-
|
|
74
|
-
expect(unpinAll).toHaveBeenCalledTimes(1)
|
|
75
|
-
expect(unpinAll).toHaveBeenCalledWith(DM_A)
|
|
76
|
-
expect(pinSilent.mock.calls).toEqual([
|
|
77
|
-
[DM_A, 111],
|
|
78
|
-
[DM_A, 222],
|
|
79
|
-
])
|
|
80
|
-
expect(sweeper.hasSwept(DM_A)).toBe(true)
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
it('DM with no tracked pins: unpin-all once, no re-pin', async () => {
|
|
84
|
-
const { deps, unpinAll, pinSilent } = makeDeps()
|
|
85
|
-
const sweeper = createDmPinSweeper(deps)
|
|
86
|
-
await sweeper.sweep(DM_A)
|
|
87
|
-
expect(unpinAll).toHaveBeenCalledTimes(1)
|
|
88
|
-
expect(pinSilent).not.toHaveBeenCalled()
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
it('group/supergroup id: never unpin-all (keeps human pins)', async () => {
|
|
92
|
-
const { deps, unpinAll, pinSilent } = makeDeps()
|
|
93
|
-
const sweeper = createDmPinSweeper(deps)
|
|
94
|
-
await sweeper.sweep(GROUP)
|
|
95
|
-
await sweeper.sweep('-1')
|
|
96
|
-
expect(unpinAll).not.toHaveBeenCalled()
|
|
97
|
-
expect(pinSilent).not.toHaveBeenCalled()
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
it('not eligible (lost startup mutex): does nothing', async () => {
|
|
101
|
-
const { deps, unpinAll } = makeDeps({ eligible: () => false })
|
|
102
|
-
const sweeper = createDmPinSweeper(deps)
|
|
103
|
-
await sweeper.sweep(DM_A)
|
|
104
|
-
expect(unpinAll).not.toHaveBeenCalled()
|
|
105
|
-
expect(sweeper.hasSwept(DM_A)).toBe(false)
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
it('second sweep of same chat does not re-sweep', async () => {
|
|
109
|
-
const { deps, unpinAll } = makeDeps()
|
|
110
|
-
const sweeper = createDmPinSweeper(deps)
|
|
111
|
-
await sweeper.sweep(DM_A)
|
|
112
|
-
await sweeper.sweep(DM_A)
|
|
113
|
-
expect(unpinAll).toHaveBeenCalledTimes(1)
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
it('concurrent triggers on the same chat fire unpin-all only once', async () => {
|
|
117
|
-
let resolveUnpin: () => void = () => {}
|
|
118
|
-
const unpinAll = vi.fn(
|
|
119
|
-
() =>
|
|
120
|
-
new Promise<void>((res) => {
|
|
121
|
-
resolveUnpin = res
|
|
122
|
-
}),
|
|
123
|
-
)
|
|
124
|
-
const { deps } = makeDeps({ unpinAll })
|
|
125
|
-
const sweeper = createDmPinSweeper(deps)
|
|
126
|
-
const p1 = sweeper.sweep(DM_A)
|
|
127
|
-
const p2 = sweeper.sweep(DM_A)
|
|
128
|
-
resolveUnpin()
|
|
129
|
-
await Promise.all([p1, p2])
|
|
130
|
-
expect(unpinAll).toHaveBeenCalledTimes(1)
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
it('429 / unpin-all rejects: degrades without throwing, still re-pins live ids', async () => {
|
|
134
|
-
const unpinAll = vi.fn(async () => {
|
|
135
|
-
throw new Error('429: Too Many Requests: retry after 30')
|
|
136
|
-
})
|
|
137
|
-
const { deps, pinSilent } = makeDeps({
|
|
138
|
-
unpinAll,
|
|
139
|
-
liveTrackedMessageIds: () => [333],
|
|
140
|
-
})
|
|
141
|
-
const sweeper = createDmPinSweeper(deps)
|
|
142
|
-
// Must not reject.
|
|
143
|
-
await expect(sweeper.sweep(DM_A)).resolves.toBeUndefined()
|
|
144
|
-
expect(unpinAll).toHaveBeenCalledTimes(1)
|
|
145
|
-
expect(pinSilent).toHaveBeenCalledWith(DM_A, 333)
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
it('re-pin failure is absorbed and does not reject', async () => {
|
|
149
|
-
const pinSilent = vi.fn(async () => {
|
|
150
|
-
throw new Error('pin failed')
|
|
151
|
-
})
|
|
152
|
-
const { deps } = makeDeps({
|
|
153
|
-
pinSilent,
|
|
154
|
-
liveTrackedMessageIds: () => [444],
|
|
155
|
-
})
|
|
156
|
-
const sweeper = createDmPinSweeper(deps)
|
|
157
|
-
await expect(sweeper.sweep(DM_A)).resolves.toBeUndefined()
|
|
158
|
-
})
|
|
159
|
-
|
|
160
|
-
it('no unhandled rejection escapes a failing sweep (fire-and-forget safe)', async () => {
|
|
161
|
-
const unhandled: unknown[] = []
|
|
162
|
-
const onUnhandled = (r: unknown): void => {
|
|
163
|
-
unhandled.push(r)
|
|
164
|
-
}
|
|
165
|
-
process.on('unhandledRejection', onUnhandled)
|
|
166
|
-
try {
|
|
167
|
-
const { deps } = makeDeps({
|
|
168
|
-
unpinAll: async () => {
|
|
169
|
-
throw new Error('boom')
|
|
170
|
-
},
|
|
171
|
-
liveTrackedMessageIds: () => [555],
|
|
172
|
-
})
|
|
173
|
-
const sweeper = createDmPinSweeper(deps)
|
|
174
|
-
// Fire-and-forget, as the gateway does.
|
|
175
|
-
void sweeper.sweep(DM_A)
|
|
176
|
-
await new Promise((r) => setTimeout(r, 10))
|
|
177
|
-
} finally {
|
|
178
|
-
process.off('unhandledRejection', onUnhandled)
|
|
179
|
-
}
|
|
180
|
-
expect(unhandled).toEqual([])
|
|
181
|
-
})
|
|
182
|
-
})
|
|
183
|
-
|
|
184
|
-
describe('tool-pin survival (#3001 contract, review blocker on #3074)', () => {
|
|
185
|
-
const NOW = 1_752_000_000_000
|
|
186
|
-
|
|
187
|
-
it('unexpiredStoreRepinIds keeps unexpired tool rows for the chat, drops expired / work-scoped / other-chat rows', () => {
|
|
188
|
-
const rows = [
|
|
189
|
-
// Unexpired tool pin in this chat — MUST survive.
|
|
190
|
-
{ chatId: DM_A, messageId: 10, expiresAt: NOW + 60_000 },
|
|
191
|
-
// Expired tool pin — due for sweeping, NOT re-pinned.
|
|
192
|
-
{ chatId: DM_A, messageId: 11, expiresAt: NOW - 1 },
|
|
193
|
-
// Work-scoped row (no expiresAt) — stale after restart, NOT re-pinned.
|
|
194
|
-
{ chatId: DM_A, messageId: 12 },
|
|
195
|
-
// Unexpired tool pin in a DIFFERENT chat — not this chat's re-pin set.
|
|
196
|
-
{ chatId: DM_B, messageId: 13, expiresAt: NOW + 60_000 },
|
|
197
|
-
]
|
|
198
|
-
expect(unexpiredStoreRepinIds(rows, DM_A, NOW)).toEqual([10])
|
|
199
|
-
expect(unexpiredStoreRepinIds(rows, DM_B, NOW)).toEqual([13])
|
|
200
|
-
})
|
|
201
|
-
|
|
202
|
-
// Composition test mirroring the gateway wiring: liveTrackedMessageIds
|
|
203
|
-
// unions in-memory claims with unexpired store rows.
|
|
204
|
-
function gatewayStyleDeps(args: {
|
|
205
|
-
inMemory: number[]
|
|
206
|
-
storeRows: { chatId: string; messageId: number; expiresAt?: number }[]
|
|
207
|
-
}): ReturnType<typeof makeDeps> {
|
|
208
|
-
return makeDeps({
|
|
209
|
-
liveTrackedMessageIds: (chatId) => [
|
|
210
|
-
...args.inMemory,
|
|
211
|
-
...unexpiredStoreRepinIds(args.storeRows, chatId, NOW),
|
|
212
|
-
],
|
|
213
|
-
})
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
it('(a) boot sweep: DM with an unexpired tool: row → unpinAll once, then that messageId re-pinned', async () => {
|
|
217
|
-
const { deps, unpinAll, pinSilent } = gatewayStyleDeps({
|
|
218
|
-
inMemory: [], // fresh boot — in-memory claims empty
|
|
219
|
-
storeRows: [{ chatId: DM_A, messageId: 777, expiresAt: NOW + 60_000 }],
|
|
220
|
-
})
|
|
221
|
-
const sweeper = createDmPinSweeper(deps)
|
|
222
|
-
await sweeper.sweep(DM_A)
|
|
223
|
-
expect(unpinAll).toHaveBeenCalledTimes(1)
|
|
224
|
-
expect(pinSilent.mock.calls).toEqual([[DM_A, 777]])
|
|
225
|
-
})
|
|
226
|
-
|
|
227
|
-
it('(b) expired tool: row is NOT re-pinned', async () => {
|
|
228
|
-
const { deps, unpinAll, pinSilent } = gatewayStyleDeps({
|
|
229
|
-
inMemory: [],
|
|
230
|
-
storeRows: [{ chatId: DM_A, messageId: 778, expiresAt: NOW - 1 }],
|
|
231
|
-
})
|
|
232
|
-
const sweeper = createDmPinSweeper(deps)
|
|
233
|
-
await sweeper.sweep(DM_A)
|
|
234
|
-
expect(unpinAll).toHaveBeenCalledTimes(1)
|
|
235
|
-
expect(pinSilent).not.toHaveBeenCalled()
|
|
236
|
-
})
|
|
237
|
-
|
|
238
|
-
it('(c) first-inbound sweep preserves an unexpired tool pin alongside a live in-memory claim, deduped', async () => {
|
|
239
|
-
const { deps, pinSilent } = gatewayStyleDeps({
|
|
240
|
-
inMemory: [500, 777], // live fg: claim + the tool pin also claimed in-memory
|
|
241
|
-
storeRows: [{ chatId: DM_A, messageId: 777, expiresAt: NOW + 60_000 }],
|
|
242
|
-
})
|
|
243
|
-
const sweeper = createDmPinSweeper(deps)
|
|
244
|
-
await sweeper.sweep(DM_A)
|
|
245
|
-
// 777 appears in BOTH sources but is re-pinned exactly once.
|
|
246
|
-
expect(pinSilent.mock.calls).toEqual([
|
|
247
|
-
[DM_A, 500],
|
|
248
|
-
[DM_A, 777],
|
|
249
|
-
])
|
|
250
|
-
})
|
|
251
|
-
})
|
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DM pin sweep (#3026) — durable clearing of stale bot-authored pins in
|
|
3
|
-
* user DM chats.
|
|
4
|
-
*
|
|
5
|
-
* Why this exists (two independent gaps the probe-based boot sweep can't
|
|
6
|
-
* close for DMs):
|
|
7
|
-
*
|
|
8
|
-
* 1. The boot pin sweep (`shouldSweepChatAtBoot`) deliberately SKIPS
|
|
9
|
-
* positive (DM) chat IDs — the Bot API returns `400 chat not found`
|
|
10
|
-
* for a user who never messaged the bot. So DM chats were never
|
|
11
|
-
* boot-swept at all, and stale pins from a prior session lingered
|
|
12
|
-
* across restarts (observed fleet-wide 2026-07-11).
|
|
13
|
-
*
|
|
14
|
-
* 2. Even when a DM chat IS reachable, `getChat()` exposes ONLY the
|
|
15
|
-
* NEWEST pinned message. Telegram DMs STACK pins and the Bot API has
|
|
16
|
-
* no list-pins method, so older orphan pins are invisible to a
|
|
17
|
-
* probe-based (unpin-one-by-message-id) sweep forever.
|
|
18
|
-
*
|
|
19
|
-
* The durable fix, DM-only: call `unpinAllChatMessages` ONCE per DM chat
|
|
20
|
-
* per gateway boot (clearing the whole stack — safe in a DM, where
|
|
21
|
-
* virtually every pin is bot-authored), then immediately RE-PIN the live
|
|
22
|
-
* tracked cards from the in-memory pin claims (silent, `disable_notification`)
|
|
23
|
-
* so a genuinely in-flight status/worker pin survives the sweep.
|
|
24
|
-
*
|
|
25
|
-
* Groups/supergroups (negative IDs) are NOT touched here — an unpin-all
|
|
26
|
-
* there would nuke human pins. They keep the existing probe-based path.
|
|
27
|
-
*
|
|
28
|
-
* This module is the PURE half (chat-id classification, store→DM-chat-id
|
|
29
|
-
* collection, and a dependency-injected sweeper with the once-per-boot
|
|
30
|
-
* guard). The gateway binds the live Bot API + in-memory claim map.
|
|
31
|
-
*/
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* True iff `chatId` is a user DM chat: a positive integer. Group and
|
|
35
|
-
* supergroup IDs are negative; zero / non-numeric / non-integer are
|
|
36
|
-
* malformed and never DMs.
|
|
37
|
-
*/
|
|
38
|
-
export function isDmChatId(chatId: string): boolean {
|
|
39
|
-
const n = Number(chatId)
|
|
40
|
-
return Number.isFinite(n) && Number.isInteger(n) && n > 0
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Collect the DISTINCT DM chat IDs that appear in ANY of the persisted pin
|
|
45
|
-
* stores — the set of DM chats the gateway has a record of having pinned
|
|
46
|
-
* something in during a prior session. These are the DM chats worth
|
|
47
|
-
* sweeping at boot (an unpin-all is wasted on a DM we never pinned in).
|
|
48
|
-
*
|
|
49
|
-
* Pure over its input records; callers pass the loaded store snapshots
|
|
50
|
-
* (status pins, activity cards, queued/busy-ack cards). Non-DM chat IDs
|
|
51
|
-
* are filtered out.
|
|
52
|
-
*/
|
|
53
|
-
export function collectDmChatIdsFromStores(input: {
|
|
54
|
-
statusPins?: readonly { chatId: string }[]
|
|
55
|
-
activityCards?: readonly { chatId: string }[]
|
|
56
|
-
queuedCards?: readonly { chatId: string }[]
|
|
57
|
-
}): string[] {
|
|
58
|
-
const out = new Set<string>()
|
|
59
|
-
const add = (rows?: readonly { chatId: string }[]): void => {
|
|
60
|
-
if (rows == null) return
|
|
61
|
-
for (const r of rows) if (isDmChatId(r.chatId)) out.add(r.chatId)
|
|
62
|
-
}
|
|
63
|
-
add(input.statusPins)
|
|
64
|
-
add(input.activityCards)
|
|
65
|
-
add(input.queuedCards)
|
|
66
|
-
return [...out]
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* The messageIds in `chatId` that must SURVIVE a DM unpin-all because they
|
|
71
|
-
* belong to deliberately-retained store rows: unexpired time-scoped `tool:`
|
|
72
|
-
* pins (the `pin_message` MCP tool, #3001), which `runStatusPinBootCleanup`
|
|
73
|
-
* intentionally KEEPS across restarts. Work-scoped rows (no `expiresAt`) are
|
|
74
|
-
* stale by definition after a restart and are NOT re-pin candidates; expired
|
|
75
|
-
* time-scoped rows are due for sweeping.
|
|
76
|
-
*
|
|
77
|
-
* Pure over a loaded store snapshot; the gateway binds a LIVE store read into
|
|
78
|
-
* `liveTrackedMessageIds` so BOTH the boot sweep and a first-inbound sweep
|
|
79
|
-
* see the kept rows (the in-memory claim maps are empty at boot).
|
|
80
|
-
*/
|
|
81
|
-
export function unexpiredStoreRepinIds(
|
|
82
|
-
rows: readonly { chatId: string; messageId: number; expiresAt?: number }[],
|
|
83
|
-
chatId: string,
|
|
84
|
-
now: number,
|
|
85
|
-
): number[] {
|
|
86
|
-
const out: number[] = []
|
|
87
|
-
for (const r of rows) {
|
|
88
|
-
if (r.chatId !== chatId) continue
|
|
89
|
-
if (r.expiresAt != null && r.expiresAt > now) out.push(r.messageId)
|
|
90
|
-
}
|
|
91
|
-
return out
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export interface DmPinSweeperDeps {
|
|
95
|
-
/** Clear EVERY pinned message in a chat. Bound to the gateway's
|
|
96
|
-
* robust/retry API wrapper so a 429 flood-wait is retried, then
|
|
97
|
-
* degrades (rejects) rather than crashing — the sweeper absorbs it. */
|
|
98
|
-
unpinAll: (chatId: string) => Promise<unknown>
|
|
99
|
-
/** Silently re-pin an EXISTING message (disable_notification). Bound to
|
|
100
|
-
* the gateway's robust/retry API wrapper. */
|
|
101
|
-
pinSilent: (chatId: string, messageId: number) => Promise<unknown>
|
|
102
|
-
/** Pin message ids in this chat that must survive the unpin-all: the
|
|
103
|
-
* in-memory status-pin claims still owned by an in-flight turn/worker,
|
|
104
|
-
* UNIONED with the deliberately-retained store rows (unexpired `tool:`
|
|
105
|
-
* pins, #3001 — see unexpiredStoreRepinIds). The gateway binds both
|
|
106
|
-
* sources; duplicates are deduped by the sweeper before re-pinning. */
|
|
107
|
-
liveTrackedMessageIds: (chatId: string) => number[]
|
|
108
|
-
/** Gate: sweep ONLY when the gateway won the startup mutex. The stores
|
|
109
|
-
* and the chat's pins are shared per-agent state; a LOSING double-boot
|
|
110
|
-
* must never unpin the live holder's legitimate pins. */
|
|
111
|
-
eligible: () => boolean
|
|
112
|
-
/** Optional structured log sink. */
|
|
113
|
-
log?: (line: string) => void
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export interface DmPinSweeper {
|
|
117
|
-
/**
|
|
118
|
-
* Sweep a single DM chat: unpin-all then re-pin live tracked cards.
|
|
119
|
-
* At most ONCE per chat per sweeper lifetime (one gateway boot). No-op
|
|
120
|
-
* for non-DM chats, when not eligible, or when already swept. Never
|
|
121
|
-
* throws — a failed unpin-all/re-pin degrades and is logged.
|
|
122
|
-
*/
|
|
123
|
-
sweep(chatId: string): Promise<void>
|
|
124
|
-
/** True if this chat has already been swept this boot (test/observability). */
|
|
125
|
-
hasSwept(chatId: string): boolean
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export function createDmPinSweeper(deps: DmPinSweeperDeps): DmPinSweeper {
|
|
129
|
-
const swept = new Set<string>()
|
|
130
|
-
return {
|
|
131
|
-
hasSwept: (chatId) => swept.has(chatId),
|
|
132
|
-
async sweep(chatId: string): Promise<void> {
|
|
133
|
-
if (!isDmChatId(chatId)) return
|
|
134
|
-
if (!deps.eligible()) return
|
|
135
|
-
if (swept.has(chatId)) return
|
|
136
|
-
// Claim the once-guard BEFORE the first await so two concurrent
|
|
137
|
-
// triggers (boot + first-inbound landing in the same tick) can't
|
|
138
|
-
// both fire the unpin-all.
|
|
139
|
-
swept.add(chatId)
|
|
140
|
-
|
|
141
|
-
// Snapshot live claims BEFORE the unpin-all so we know exactly which
|
|
142
|
-
// messages to restore. Dedupe — the in-memory and store-row sources
|
|
143
|
-
// can both report the same messageId.
|
|
144
|
-
const liveIds = [...new Set(deps.liveTrackedMessageIds(chatId))]
|
|
145
|
-
|
|
146
|
-
try {
|
|
147
|
-
await deps.unpinAll(chatId)
|
|
148
|
-
} catch (err) {
|
|
149
|
-
const msg = err instanceof Error ? err.message : String(err)
|
|
150
|
-
deps.log?.(
|
|
151
|
-
`telegram gateway: dm-pin-sweep: unpin-all failed (chat=${chatId}): ${msg}\n`,
|
|
152
|
-
)
|
|
153
|
-
// Degrade: the stack may still hold stale pins, but we don't
|
|
154
|
-
// crash and we don't retry this boot. Fall through to re-pin the
|
|
155
|
-
// live claims (harmless if the unpin didn't land).
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
for (const messageId of liveIds) {
|
|
159
|
-
try {
|
|
160
|
-
await deps.pinSilent(chatId, messageId)
|
|
161
|
-
} catch (err) {
|
|
162
|
-
const msg = err instanceof Error ? err.message : String(err)
|
|
163
|
-
deps.log?.(
|
|
164
|
-
`telegram gateway: dm-pin-sweep: re-pin failed ` +
|
|
165
|
-
`(chat=${chatId} msg=${messageId}): ${msg}\n`,
|
|
166
|
-
)
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
if (liveIds.length > 0) {
|
|
171
|
-
deps.log?.(
|
|
172
|
-
`telegram gateway: dm-pin-sweep: cleared stacked pins in DM ${chatId}, ` +
|
|
173
|
-
`re-pinned ${liveIds.length} live card(s)\n`,
|
|
174
|
-
)
|
|
175
|
-
}
|
|
176
|
-
},
|
|
177
|
-
}
|
|
178
|
-
}
|