switchroom 0.20.5 → 0.20.7
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 +1 -1
- package/dist/auth-broker/index.js +3 -2
- package/dist/cli/notion-write-pretool.mjs +1 -1
- package/dist/cli/switchroom.js +4 -3
- package/dist/host-control/main.js +4 -3
- package/dist/vault/approvals/kernel-server.js +3 -2
- package/dist/vault/broker/server.js +3 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +188 -31
- package/telegram-plugin/gateway/boot-sweep-gate.ts +24 -0
- package/telegram-plugin/gateway/gateway.ts +31 -33
- package/telegram-plugin/gateway/obligation-ledger.ts +9 -0
- package/telegram-plugin/gateway/obligation-wiring.ts +19 -4
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +43 -3
- package/telegram-plugin/gateway/represent-delivery-guard.ts +157 -0
- package/telegram-plugin/gateway/stale-pin-sweep-store.ts +39 -2
- package/telegram-plugin/gateway/stale-pin-sweep.test.ts +162 -0
- package/telegram-plugin/gateway/stream-render.ts +13 -0
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +15 -2
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +145 -9
- package/telegram-plugin/tests/represent-guard.test.ts +250 -0
|
@@ -49,6 +49,16 @@ export interface PendingInboundBuffer {
|
|
|
49
49
|
depth: (agent: string) => number
|
|
50
50
|
/** Test-only: total depth across all agents. */
|
|
51
51
|
totalDepth: () => number
|
|
52
|
+
/**
|
|
53
|
+
* Delivery-time gate consulted by `redeliverBufferedInbound` for EVERY message
|
|
54
|
+
* it is about to hand to the bridge. Returns TRUE to proceed, FALSE to RETRACT
|
|
55
|
+
* (drop) the message (the spool entry is acked so it is not boot-replayed, and
|
|
56
|
+
* it is NOT re-buffered). The gateway wires this to the represent delivery
|
|
57
|
+
* re-check (represent-delivery-guard.ts): a represent buffered while its
|
|
58
|
+
* obligation was open must be re-evaluated at drain time, since a reply may have
|
|
59
|
+
* landed between the sweep's decision and this drain. Undefined ⇒ never retract.
|
|
60
|
+
*/
|
|
61
|
+
beforeRedeliver?: (msg: InboundMessage) => boolean
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
export interface PendingInboundBufferOptions {
|
|
@@ -94,6 +104,13 @@ export interface PendingInboundBufferOptions {
|
|
|
94
104
|
* breaks the push hot path.
|
|
95
105
|
*/
|
|
96
106
|
onHandbackEnqueue?: (chatId: string, threadId: number | undefined, ts: number) => void
|
|
107
|
+
/**
|
|
108
|
+
* Delivery-time retract gate — see `PendingInboundBuffer.beforeRedeliver`. The
|
|
109
|
+
* gateway supplies the represent delivery re-check here so EVERY drain path
|
|
110
|
+
* (idle-drain, bridge re-register, silence-poke fallback, turn-end) inherits it
|
|
111
|
+
* by construction rather than by per-call-site discipline.
|
|
112
|
+
*/
|
|
113
|
+
beforeRedeliver?: (msg: InboundMessage) => boolean
|
|
97
114
|
}
|
|
98
115
|
|
|
99
116
|
/**
|
|
@@ -123,16 +140,38 @@ export function redeliverBufferedInbound(
|
|
|
123
140
|
// silently dropped (clerk 2026-06-03). `send` returning true only means
|
|
124
141
|
// the bytes reached the bridge, NOT that claude consumed them.
|
|
125
142
|
onDelivered?: (merged: InboundMessage, originals: InboundMessage[]) => void,
|
|
126
|
-
): { drained: number; redelivered: number; rebuffered: number } {
|
|
143
|
+
): { drained: number; redelivered: number; rebuffered: number; retracted: number } {
|
|
127
144
|
const pending = buffer.drain(agent)
|
|
128
145
|
let redelivered = 0
|
|
129
146
|
let rebuffered = 0
|
|
147
|
+
let retracted = 0
|
|
130
148
|
// Collapse consecutive same-sender Telegram user messages into one turn
|
|
131
149
|
// (see planBufferedRedelivery) so a forwarded burst that spanned a turn
|
|
132
150
|
// boundary doesn't fan out into N sequential replies. System inbounds
|
|
133
151
|
// (vault grants, approvals, cron, handbacks — anything with meta.source)
|
|
134
152
|
// are never merged and are delivered individually exactly as before.
|
|
135
153
|
for (const { merged, originals } of planBufferedRedelivery(pending)) {
|
|
154
|
+
// Delivery-time retract (F1): a buffered `obligation_represent` whose reply
|
|
155
|
+
// has landed since the sweep decided it is stale — drop it rather than hand
|
|
156
|
+
// a duplicate to the CLI queue. Ack the spool so it is not boot-replayed and
|
|
157
|
+
// do NOT re-buffer. The gateway closure closes the ledger + logs the retract.
|
|
158
|
+
// FAIL-OPEN: a throwing predicate must never silence or lose a real message,
|
|
159
|
+
// nor abort the drain loop (which would strand every following message and
|
|
160
|
+
// crash the setInterval tick). On throw we treat it as "deliver".
|
|
161
|
+
let proceed = true
|
|
162
|
+
if (buffer.beforeRedeliver != null) {
|
|
163
|
+
try {
|
|
164
|
+
proceed = buffer.beforeRedeliver(merged)
|
|
165
|
+
} catch (e) {
|
|
166
|
+
proceed = true
|
|
167
|
+
process.stderr.write(`redeliver beforeRedeliver threw — failing open (deliver): ${String(e)}\n`)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (!proceed) {
|
|
171
|
+
for (const o of originals) spool?.ack(o)
|
|
172
|
+
retracted += originals.length
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
136
175
|
let delivered = false
|
|
137
176
|
try {
|
|
138
177
|
delivered = send(merged)
|
|
@@ -157,7 +196,7 @@ export function redeliverBufferedInbound(
|
|
|
157
196
|
rebuffered += originals.length
|
|
158
197
|
}
|
|
159
198
|
}
|
|
160
|
-
return { drained: pending.length, redelivered, rebuffered }
|
|
199
|
+
return { drained: pending.length, redelivered, rebuffered, retracted }
|
|
161
200
|
}
|
|
162
201
|
|
|
163
202
|
/** True when `msg` is an ordinary Telegram user message eligible to be
|
|
@@ -312,7 +351,7 @@ export function idleDrainTick(
|
|
|
312
351
|
// enrols redelivered inbounds in the deliver-until-acked queue (parity with
|
|
313
352
|
// the bridgeUp drain — clerk lost-message incident, 2026-06-03).
|
|
314
353
|
onDelivered?: (merged: InboundMessage, originals: InboundMessage[]) => void,
|
|
315
|
-
): { drained: number; redelivered: number; rebuffered: number } | null {
|
|
354
|
+
): { drained: number; redelivered: number; rebuffered: number; retracted: number } | null {
|
|
316
355
|
if (!agent) return null
|
|
317
356
|
if (buffer.depth(agent) === 0) return null
|
|
318
357
|
if (!isBridgeAlive()) return null
|
|
@@ -412,5 +451,6 @@ export function createPendingInboundBuffer(
|
|
|
412
451
|
for (const q of queues.values()) n += q.length
|
|
413
452
|
return n
|
|
414
453
|
},
|
|
454
|
+
beforeRedeliver: opts.beforeRedeliver,
|
|
415
455
|
}
|
|
416
456
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* represent-delivery-guard.ts — the DELIVERY-time half of the duplicate-represent
|
|
3
|
+
* defence (fix/represent-double-send-delivery-recheck).
|
|
4
|
+
*
|
|
5
|
+
* Background — the race this closes (verified from history.db, 2026):
|
|
6
|
+
* 1. A ~5-min silence poke fires the framework fallback, which clears the
|
|
7
|
+
* machine turn (`turnInFlightForGate()` → false) while the CLI session is
|
|
8
|
+
* STILL producing the answer (it lands ~17s later). stream-render's live-turn
|
|
9
|
+
* mirror (`currentTurn`/`parkedTurnStarts`) still knows the session is busy.
|
|
10
|
+
* 2. With the machine turn cleared, `obligationSweep` runs; `shouldSuppressRepresent`
|
|
11
|
+
* correctly returns false (nothing has been sent YET) and the represent is
|
|
12
|
+
* buffered; the idle-drain flushes it into the live CLI queue.
|
|
13
|
+
* 3. ~13s later the real answer is delivered + recorded (ledger closed by the
|
|
14
|
+
* normal path). Then the model consumes the already-queued represent and
|
|
15
|
+
* answers a SECOND time → the duplicate.
|
|
16
|
+
*
|
|
17
|
+
* The represent guard never FAILED — it was consulted BEFORE the reply existed,
|
|
18
|
+
* and there was no re-check between decision → delivery → model consumption. This
|
|
19
|
+
* module adds that missing re-check (F1) plus the bounded busy-defer for the
|
|
20
|
+
* idle-drain gate (F2). Layer F2 (obligation-wiring's sweep + this drain defer)
|
|
21
|
+
* keeps the represent BUFFERED while the session is busy so that, by the time a
|
|
22
|
+
* drain actually hands it to the bridge, the real answer has landed and been
|
|
23
|
+
* recorded — at which point F1 retracts the now-stale represent. `recordOutbound`
|
|
24
|
+
* is a synchronous SQLite write that completes before the reply tool result
|
|
25
|
+
* returns, so a post-reply drain observes the row.
|
|
26
|
+
*
|
|
27
|
+
* Both halves are PURE-ish factories (no Telegram, no SQLite) so the whole
|
|
28
|
+
* decision is executable in a unit test; the gateway injects the ledger accessor,
|
|
29
|
+
* the outbound-history predicate, and the logger.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { InboundMessage } from './ipc-protocol.js'
|
|
33
|
+
import { shouldSuppressRepresent, type RepresentGuardObligation } from './represent-guard.js'
|
|
34
|
+
|
|
35
|
+
/** The minimal ledger surface the delivery re-check needs. */
|
|
36
|
+
export interface RepresentDeliveryLedger {
|
|
37
|
+
/** The CURRENTLY-open obligation for `originTurnId`, or undefined if none is
|
|
38
|
+
* open (already closed — answered or cancelled — since the represent was
|
|
39
|
+
* buffered). */
|
|
40
|
+
get(originTurnId: string): RepresentGuardObligation | undefined
|
|
41
|
+
/** Close the obligation. Idempotent; returns whether an entry was removed. */
|
|
42
|
+
close(originTurnId: string | null | undefined): boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface RepresentRedeliveryGuardDeps {
|
|
46
|
+
/** OBLIGATION_LEDGER_ENABLED — when off, never retract (nothing is tracked). */
|
|
47
|
+
enabled: boolean
|
|
48
|
+
/** HISTORY_ENABLED — forwarded to the pure guard (no history ⇒ never suppress). */
|
|
49
|
+
historyEnabled: boolean
|
|
50
|
+
ledger: RepresentDeliveryLedger
|
|
51
|
+
/** history.hasOutboundDeliveredSince, already curried with the chat query. */
|
|
52
|
+
hasOutboundDeliveredSince: (
|
|
53
|
+
chatId: string,
|
|
54
|
+
sinceMs: number,
|
|
55
|
+
threadId?: number,
|
|
56
|
+
minChars?: number,
|
|
57
|
+
) => boolean
|
|
58
|
+
/** The represent guard's OWN low reply-length threshold (a terse-but-real reply
|
|
59
|
+
* must still suppress the duplicate — #2472/#2474). */
|
|
60
|
+
minReplyChars: number
|
|
61
|
+
log: (line: string) => void
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build the `beforeRedeliver` predicate the pending-inbound buffer consults for
|
|
66
|
+
* EVERY message it is about to hand to the CLI bridge. Returns TRUE to proceed
|
|
67
|
+
* with delivery, FALSE to RETRACT (drop) the message.
|
|
68
|
+
*
|
|
69
|
+
* Only `obligation_represent` inbounds are ever retracted; everything else always
|
|
70
|
+
* proceeds. A represent is retracted when, AT DELIVERY TIME:
|
|
71
|
+
* - the obligation is no longer open (a reply landed and the normal close path
|
|
72
|
+
* fired, or it was cancelled) — re-delivering would duplicate / re-ask a
|
|
73
|
+
* resolved question; OR
|
|
74
|
+
* - it IS still open but `shouldSuppressRepresent` now returns true against the
|
|
75
|
+
* obligation's CURRENT cutoff (a reply landed since decision, but the normal
|
|
76
|
+
* close path missed it because its routing did not resolve back to the origin
|
|
77
|
+
* — the exact #2472 gap, now caught at delivery instead of only at decision).
|
|
78
|
+
*
|
|
79
|
+
* The cutoff is the obligation's own (`openedAt` for the first represent,
|
|
80
|
+
* `lastRepresentedAt` thereafter) — encoded inside `shouldSuppressRepresent`, so
|
|
81
|
+
* an OLD reply to a DIFFERENT earlier question does not suppress a legitimate new
|
|
82
|
+
* represent (#2472 second-represent semantics). The genuine plain-text-no-reply
|
|
83
|
+
* case (#2788) records NO outbound row, so the predicate reports false at BOTH
|
|
84
|
+
* decision and here ⇒ the represent still fires exactly once.
|
|
85
|
+
*/
|
|
86
|
+
export function makeRepresentRedeliveryGuard(
|
|
87
|
+
deps: RepresentRedeliveryGuardDeps,
|
|
88
|
+
): (msg: InboundMessage) => boolean {
|
|
89
|
+
return (msg) => {
|
|
90
|
+
if (!deps.enabled) return true
|
|
91
|
+
if (msg.meta?.source !== 'obligation_represent') return true
|
|
92
|
+
const originTurnId = msg.meta?.origin_turn_id
|
|
93
|
+
if (originTurnId == null) return true
|
|
94
|
+
|
|
95
|
+
const o = deps.ledger.get(originTurnId)
|
|
96
|
+
if (o == null) {
|
|
97
|
+
// Closed since this represent was buffered — a reply landed (normal close)
|
|
98
|
+
// or the turn was cancelled/interrupted. Either way the buffered represent
|
|
99
|
+
// is stale: drop it rather than re-ask an already-resolved obligation.
|
|
100
|
+
deps.log(
|
|
101
|
+
`telegram gateway: represent retracted at delivery — obligation already ` +
|
|
102
|
+
`closed since decision (no re-fire) origin=${originTurnId}\n`,
|
|
103
|
+
)
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const suppress = shouldSuppressRepresent(o, {
|
|
108
|
+
historyEnabled: deps.historyEnabled,
|
|
109
|
+
hasOutboundDeliveredSince: (chatId, sinceMs, threadId) =>
|
|
110
|
+
deps.hasOutboundDeliveredSince(chatId, sinceMs, threadId, deps.minReplyChars),
|
|
111
|
+
})
|
|
112
|
+
if (suppress) {
|
|
113
|
+
// A reply landed since this obligation's cutoff but the normal close path
|
|
114
|
+
// missed it. Close the ledger entry ourselves and drop the represent.
|
|
115
|
+
deps.ledger.close(originTurnId)
|
|
116
|
+
deps.log(
|
|
117
|
+
`telegram gateway: represent retracted at delivery — reply landed since ` +
|
|
118
|
+
`decision (no re-fire) origin=${originTurnId}\n`,
|
|
119
|
+
)
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
return true
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Build the BOUNDED busy-defer predicate for the idle-drain gate (F2, drain
|
|
128
|
+
* half). Returns TRUE while the drain should be DEFERRED because the session is
|
|
129
|
+
* busy (stream-render's live-turn mirror), FALSE once it is safe to drain.
|
|
130
|
+
*
|
|
131
|
+
* Why bounded: the drain must not hand a buffered represent to a session that is
|
|
132
|
+
* mid-answer (that re-queues it BEHIND the real answer → the duplicate). But a
|
|
133
|
+
* session that is WEDGED busy forever must not silence a buffered represent
|
|
134
|
+
* forever (red-team #2). So the deferral is capped: once the session has been
|
|
135
|
+
* continuously busy for longer than `boundMs`, the drain proceeds regardless. The
|
|
136
|
+
* clock resets to "not deferring" the moment the session reads idle, so an
|
|
137
|
+
* ordinary busy turn (which ends well within the bound) defers cleanly and never
|
|
138
|
+
* consumes the wedge budget.
|
|
139
|
+
*
|
|
140
|
+
* `boundMs <= 0` disables the busy-defer entirely (kill switch / parity with the
|
|
141
|
+
* background-work grace being disabled).
|
|
142
|
+
*/
|
|
143
|
+
export function makeSessionBusyDrainDeferral(
|
|
144
|
+
boundMs: number,
|
|
145
|
+
): (busy: boolean, now: number) => boolean {
|
|
146
|
+
let deferringSince: number | null = null
|
|
147
|
+
return (busy, now) => {
|
|
148
|
+
if (!busy || boundMs <= 0) {
|
|
149
|
+
deferringSince = null
|
|
150
|
+
return false
|
|
151
|
+
}
|
|
152
|
+
if (deferringSince == null) deferringSince = now
|
|
153
|
+
// Bounded: stop deferring once we have been busy past the ceiling, so a
|
|
154
|
+
// wedged/hung session still eventually drains the buffered represent.
|
|
155
|
+
return now - deferringSince < boundMs
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -221,7 +221,44 @@ export function pendingSweepCursors(cursors: readonly SweepCursor[]): SweepCurso
|
|
|
221
221
|
return cursors.filter((c) => !c.done && c.attempts < SWEEP_MAX_ATTEMPTS)
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
-
/**
|
|
224
|
+
/**
|
|
225
|
+
* Boot seed pass: drop DISCHARGED (`done: true`) rows ONLY, so a chat that
|
|
226
|
+
* drained in a prior session is re-evaluated LIVE on the next sweep instead of
|
|
227
|
+
* short-circuiting forever on a stale `done` (#3953 regression — the seed pass
|
|
228
|
+
* was never wired, so `done` was effectively permanent).
|
|
229
|
+
*
|
|
230
|
+
* FORFEITED rows (`attempts >= SWEEP_MAX_ATTEMPTS`, still `!done`) are
|
|
231
|
+
* deliberately RETAINED. Their whole purpose is to remember that a chat's
|
|
232
|
+
* attempt budget is spent (bot kicked, pin rights revoked, a chat that
|
|
233
|
+
* flood-waits forever). Pruning one would let the next boot re-seed it from the
|
|
234
|
+
* pin stores and re-burn the full 8-attempt budget on every boot for the rest
|
|
235
|
+
* of time — precisely the Telegram flood the attempt cap exists to prevent. So
|
|
236
|
+
* this filters on `done` ALONE; it must NOT also gate on `attempts`.
|
|
237
|
+
*/
|
|
225
238
|
export function pruneSweepCursors(cursors: readonly SweepCursor[]): SweepCursor[] {
|
|
226
|
-
return cursors.filter((c) => !c.done
|
|
239
|
+
return cursors.filter((c) => !c.done)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* BOOT-SEED reseed of the durable ledger (#3953): drop discharged (`done:true`)
|
|
244
|
+
* rows so a `(chat, thread)` that drained in a prior session is re-evaluated
|
|
245
|
+
* LIVE on the next sweep instead of short-circuiting forever on a stale `done`.
|
|
246
|
+
* Forfeited rows are retained (see {@link pruneSweepCursors}).
|
|
247
|
+
*
|
|
248
|
+
* Writes ONLY when the prune changed the ledger, so a no-op boot never churns
|
|
249
|
+
* the durable file. Never throws — a reseed failure degrades to the pre-fix
|
|
250
|
+
* behaviour (the stale `done` survives one more boot), it must not break boot.
|
|
251
|
+
*
|
|
252
|
+
* BOOT-SCOPED BY CONTRACT: the caller must invoke this once at boot, never on
|
|
253
|
+
* the per-inbound path (which fires on every message — re-arming a full re-drain
|
|
254
|
+
* per message is a Telegram flood).
|
|
255
|
+
*/
|
|
256
|
+
export function reseedSweepLedger(
|
|
257
|
+
path: string,
|
|
258
|
+
fs: SweepStoreFsSeam,
|
|
259
|
+
log: (line: string) => void = (l) => process.stderr.write(l),
|
|
260
|
+
): void {
|
|
261
|
+
const rows = loadSweepCursors(path, fs)
|
|
262
|
+
const pruned = pruneSweepCursors(rows)
|
|
263
|
+
if (pruned.length !== rows.length) persistSweepCursors(path, fs, pruned, log)
|
|
227
264
|
}
|
|
@@ -32,6 +32,8 @@ import {
|
|
|
32
32
|
import {
|
|
33
33
|
SWEEP_MAX_ATTEMPTS,
|
|
34
34
|
loadSweepCursors,
|
|
35
|
+
pruneSweepCursors,
|
|
36
|
+
reseedSweepLedger,
|
|
35
37
|
upsertSweepCursor,
|
|
36
38
|
type SweepCursor,
|
|
37
39
|
type SweepStoreFsSeam,
|
|
@@ -907,3 +909,163 @@ describe('stale-pin sweep — classification and seeding', () => {
|
|
|
907
909
|
expect(isNothingToUnpinError(new Error('something else'))).toBe(false)
|
|
908
910
|
})
|
|
909
911
|
})
|
|
912
|
+
|
|
913
|
+
// ─── #3953: the boot-seed prune reseeds a discharged obligation ───────────────
|
|
914
|
+
//
|
|
915
|
+
// Regression #3953 replaced the per-boot DM self-heal with a cursor-gated stack
|
|
916
|
+
// drain, but the boot "seed pass" that clears discharged cursors was never
|
|
917
|
+
// wired — so `done:true` was effectively PERMANENT and every later boot /
|
|
918
|
+
// first-inbound sweep short-circuited on `already-drained`, orphaning any pin
|
|
919
|
+
// that leaked AFTER the first drain. These tests pin the reseed contract:
|
|
920
|
+
// 1. `pruneSweepCursors` drops `done` rows ONLY and RETAINS forfeited
|
|
921
|
+
// (attempts-exhausted) rows — a store-level test that FAILS on the pre-fix
|
|
922
|
+
// filter (which also dropped forfeited rows, re-burning the attempt budget
|
|
923
|
+
// every boot = flood risk).
|
|
924
|
+
// 2. Running that prune between two fresh-process sweeps re-arms the drain, so
|
|
925
|
+
// a pin leaked after a prior discharge is reaped — for a DM AND a
|
|
926
|
+
// supergroup (channel-class) target alike.
|
|
927
|
+
|
|
928
|
+
/** The boot-seed reseed exactly as the gateway wires it (`gateway.ts`
|
|
929
|
+
* `seedPruneSweepCursors` → `reseedSweepLedger`). */
|
|
930
|
+
function bootSeedPrune(fs: SweepStoreFsSeam, path: string): void {
|
|
931
|
+
reseedSweepLedger(path, fs, () => {})
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/** A sweeper over a SHARED durable store — a distinct instance models a fresh
|
|
935
|
+
* process (reboot), so the only state carried across is the on-disk ledger. */
|
|
936
|
+
function sweeperOver(
|
|
937
|
+
fs: SweepStoreFsSeam,
|
|
938
|
+
path: string,
|
|
939
|
+
fake: ReturnType<typeof fakeChat>,
|
|
940
|
+
opts: { recordedPinIds?: number[] } = {},
|
|
941
|
+
) {
|
|
942
|
+
let clock = 1_000_000
|
|
943
|
+
const deps: StalePinSweepDeps = {
|
|
944
|
+
getTopPinnedMessageId: fake.getTopPinnedMessageId,
|
|
945
|
+
pinSilent: fake.pinSilent,
|
|
946
|
+
unpin: fake.unpin,
|
|
947
|
+
unpinAllForumTopicMessages: fake.unpinAllForumTopicMessages,
|
|
948
|
+
canPinInChat: fake.canPinInChat,
|
|
949
|
+
protectedMessageIds: () => [],
|
|
950
|
+
recordedPinIds: () => opts.recordedPinIds ?? [],
|
|
951
|
+
eligible: () => true,
|
|
952
|
+
sleep: async (ms) => {
|
|
953
|
+
clock += ms
|
|
954
|
+
},
|
|
955
|
+
now: () => clock,
|
|
956
|
+
store: { path, fs },
|
|
957
|
+
log: () => {},
|
|
958
|
+
}
|
|
959
|
+
return createStalePinSweeper(deps)
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
describe('pruneSweepCursors — boot-seed reseed (#3953)', () => {
|
|
963
|
+
const now = 1_000_000
|
|
964
|
+
|
|
965
|
+
it('drops discharged rows but RETAINS forfeited (attempts-exhausted) rows', () => {
|
|
966
|
+
const discharged: SweepCursor = {
|
|
967
|
+
chatId: DM,
|
|
968
|
+
kind: 'dm',
|
|
969
|
+
popped: 3,
|
|
970
|
+
done: true,
|
|
971
|
+
attempts: 1,
|
|
972
|
+
updatedAt: now,
|
|
973
|
+
}
|
|
974
|
+
// A no-rights group that spent its whole attempt budget: `done` is false,
|
|
975
|
+
// but re-seeding it would re-burn all 8 attempts on the NEXT boot, and every
|
|
976
|
+
// boot after — the exact Telegram flood the attempt cap exists to stop.
|
|
977
|
+
const forfeited: SweepCursor = {
|
|
978
|
+
chatId: GROUP,
|
|
979
|
+
kind: 'supergroup',
|
|
980
|
+
popped: 0,
|
|
981
|
+
done: false,
|
|
982
|
+
attempts: SWEEP_MAX_ATTEMPTS,
|
|
983
|
+
lastStatus: 'skipped-no-rights',
|
|
984
|
+
updatedAt: now,
|
|
985
|
+
}
|
|
986
|
+
const stillOwed: SweepCursor = {
|
|
987
|
+
chatId: '900000002',
|
|
988
|
+
kind: 'dm',
|
|
989
|
+
popped: 0,
|
|
990
|
+
done: false,
|
|
991
|
+
attempts: 2,
|
|
992
|
+
updatedAt: now,
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const keys = pruneSweepCursors([discharged, forfeited, stillOwed]).map((c) => c.chatId)
|
|
996
|
+
|
|
997
|
+
expect(keys).not.toContain(DM) // discharged → reseeded (dropped)
|
|
998
|
+
expect(keys).toContain(GROUP) // forfeited no-rights → RETAINED (no re-burn)
|
|
999
|
+
expect(keys).toContain('900000002') // still owed → retained untouched
|
|
1000
|
+
})
|
|
1001
|
+
|
|
1002
|
+
it('clears discharged rows for EVERY surface — dm, forum-topic, supergroup', () => {
|
|
1003
|
+
// The prune must be kind-agnostic: a stuck `done` on a topic or a channel is
|
|
1004
|
+
// the same regression as on a DM, so none may be left short-circuiting.
|
|
1005
|
+
const rows: SweepCursor[] = [
|
|
1006
|
+
{ chatId: '1', kind: 'dm', popped: 1, done: true, attempts: 1, updatedAt: now },
|
|
1007
|
+
{ chatId: '2', kind: 'forum-topic', threadId: 5, popped: 1, done: true, attempts: 1, updatedAt: now },
|
|
1008
|
+
{ chatId: '3', kind: 'supergroup', popped: 1, done: true, attempts: 1, updatedAt: now },
|
|
1009
|
+
]
|
|
1010
|
+
expect(pruneSweepCursors(rows)).toEqual([])
|
|
1011
|
+
})
|
|
1012
|
+
})
|
|
1013
|
+
|
|
1014
|
+
describe('stale-pin sweep — re-drain after the boot-seed prune (#3953)', () => {
|
|
1015
|
+
it('re-drains a DM whose obligation was discharged in a prior session', async () => {
|
|
1016
|
+
const fs = memFs()
|
|
1017
|
+
const path = '/state/stale-pin-sweep.json'
|
|
1018
|
+
|
|
1019
|
+
// Session 1: an orphan bot pin is drained and the obligation discharged.
|
|
1020
|
+
const s1 = await sweeperOver(fs, path, fakeChat({ stack: [11] })).sweepTarget({ chatId: DM })
|
|
1021
|
+
expect(s1.status).toBe('drained')
|
|
1022
|
+
expect(loadSweepCursors(path, fs).find((c) => c.chatId === DM)?.done).toBe(true)
|
|
1023
|
+
|
|
1024
|
+
// A NEW orphan pin leaks in after that drain.
|
|
1025
|
+
// Fresh process, no prune: the sweep short-circuits on the stale `done` —
|
|
1026
|
+
// the #3953 regression, verbatim. The orphan is left pinned.
|
|
1027
|
+
const stuckChat = fakeChat({ stack: [99] })
|
|
1028
|
+
const stuck = await sweeperOver(fs, path, stuckChat).sweepTarget({ chatId: DM })
|
|
1029
|
+
expect(stuck.status).toBe('already-drained')
|
|
1030
|
+
expect(stuck.popped).toBe(0)
|
|
1031
|
+
expect(stuckChat.stack).toEqual([99])
|
|
1032
|
+
|
|
1033
|
+
// Reboot WITH the boot-seed prune: the discharged row is reseeded, so the
|
|
1034
|
+
// next sweep re-evaluates the chat LIVE and reaps the orphan.
|
|
1035
|
+
bootSeedPrune(fs, path)
|
|
1036
|
+
const rebootChat = fakeChat({ stack: [99] })
|
|
1037
|
+
const redrain = await sweeperOver(fs, path, rebootChat).sweepTarget({ chatId: DM })
|
|
1038
|
+
expect(redrain.status).toBe('drained')
|
|
1039
|
+
expect(redrain.popped).toBe(1)
|
|
1040
|
+
expect(rebootChat.stack).toEqual([])
|
|
1041
|
+
})
|
|
1042
|
+
|
|
1043
|
+
it('re-drains a supergroup (channel-class) discharged in a prior session', async () => {
|
|
1044
|
+
const fs = memFs()
|
|
1045
|
+
const path = '/state/stale-pin-sweep.json'
|
|
1046
|
+
|
|
1047
|
+
// Session 1: the one recorded orphan is reaped, obligation discharged.
|
|
1048
|
+
const s1 = await sweeperOver(fs, path, fakeChat({ stack: [77], canPin: true }), {
|
|
1049
|
+
recordedPinIds: [77],
|
|
1050
|
+
}).sweepTarget({ chatId: GROUP })
|
|
1051
|
+
expect(s1.status).toBe('drained')
|
|
1052
|
+
expect(loadSweepCursors(path, fs).find((c) => c.chatId === GROUP)?.done).toBe(true)
|
|
1053
|
+
|
|
1054
|
+
// A fresh recorded orphan leaks in; a fresh process short-circuits on `done`.
|
|
1055
|
+
const stuckChat = fakeChat({ stack: [88], canPin: true })
|
|
1056
|
+
const stuck = await sweeperOver(fs, path, stuckChat, { recordedPinIds: [88] }).sweepTarget({
|
|
1057
|
+
chatId: GROUP,
|
|
1058
|
+
})
|
|
1059
|
+
expect(stuck.status).toBe('already-drained')
|
|
1060
|
+
expect(stuckChat.stack).toEqual([88])
|
|
1061
|
+
|
|
1062
|
+
// Reboot + prune: the supergroup row is reseeded and re-swept.
|
|
1063
|
+
bootSeedPrune(fs, path)
|
|
1064
|
+
const rebootChat = fakeChat({ stack: [88], canPin: true })
|
|
1065
|
+
const redrain = await sweeperOver(fs, path, rebootChat, { recordedPinIds: [88] }).sweepTarget({
|
|
1066
|
+
chatId: GROUP,
|
|
1067
|
+
})
|
|
1068
|
+
expect(redrain.status).toBe('drained')
|
|
1069
|
+
expect(rebootChat.stack).toEqual([])
|
|
1070
|
+
})
|
|
1071
|
+
})
|
|
@@ -279,6 +279,19 @@ export function __resetParkedTurnStartsForTest(): void {
|
|
|
279
279
|
export function __parkedTurnStartCountForTest(): number {
|
|
280
280
|
return parkedTurnStarts.length
|
|
281
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* Production accessor for the parked-turn-start count — the second half of the
|
|
284
|
+
* stream-render "session busy" signal (the first being a live `currentTurn`
|
|
285
|
+
* whose `endedAt == null`). A parked turn-start means the CLI has an enqueued
|
|
286
|
+
* message it is about to turn on, so the session is NOT idle even when the
|
|
287
|
+
* gateway's machine-turn gate (`turnInFlightForGate`) reads clear — the exact
|
|
288
|
+
* disagreement a ~5-min silence poke opens (it clears the machine turn while the
|
|
289
|
+
* CLI is still producing the answer). The obligation sweep and the idle-drain
|
|
290
|
+
* consult this so they do not treat a poke-cleared-but-busy session as idle.
|
|
291
|
+
*/
|
|
292
|
+
export function parkedTurnStartCount(): number {
|
|
293
|
+
return parkedTurnStarts.length
|
|
294
|
+
}
|
|
282
295
|
|
|
283
296
|
/**
|
|
284
297
|
* Silence-fallback unwedge for the parked store (the two-state desync fix).
|
|
@@ -161,6 +161,7 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
|
|
|
161
161
|
order.push('queued-cards')
|
|
162
162
|
},
|
|
163
163
|
enableSweep: () => order.push('enable-sweep'),
|
|
164
|
+
seedPruneSweepCursors: () => order.push('seed-prune'),
|
|
164
165
|
sweepTarget: async (t) => {
|
|
165
166
|
order.push(`sweep:${t.chatId}:${t.threadId ?? '-'}`)
|
|
166
167
|
},
|
|
@@ -177,6 +178,7 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
|
|
|
177
178
|
'status-pins',
|
|
178
179
|
'activity-cards',
|
|
179
180
|
'queued-cards',
|
|
181
|
+
'seed-prune',
|
|
180
182
|
'enable-sweep',
|
|
181
183
|
'sweep:900000001:-',
|
|
182
184
|
])
|
|
@@ -194,6 +196,7 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
|
|
|
194
196
|
'scan',
|
|
195
197
|
'status-pins',
|
|
196
198
|
'queued-cards',
|
|
199
|
+
'seed-prune',
|
|
197
200
|
'enable-sweep',
|
|
198
201
|
'sweep:900000001:-',
|
|
199
202
|
])
|
|
@@ -210,7 +213,7 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
|
|
|
210
213
|
queuedCardReaper: boom,
|
|
211
214
|
})
|
|
212
215
|
await runBootPinSweepSteps(d.deps)
|
|
213
|
-
expect(d.order).toEqual(['scan', 'enable-sweep', 'sweep:900000001:-'])
|
|
216
|
+
expect(d.order).toEqual(['scan', 'seed-prune', 'enable-sweep', 'sweep:900000001:-'])
|
|
214
217
|
})
|
|
215
218
|
|
|
216
219
|
it('a failing store scan still lets the reapers and the DM enable run', async () => {
|
|
@@ -221,7 +224,13 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
|
|
|
221
224
|
})
|
|
222
225
|
await runBootPinSweepSteps(d.deps)
|
|
223
226
|
// No targets to sweep, but nothing behind the scan is stranded.
|
|
224
|
-
expect(d.order).toEqual([
|
|
227
|
+
expect(d.order).toEqual([
|
|
228
|
+
'status-pins',
|
|
229
|
+
'activity-cards',
|
|
230
|
+
'queued-cards',
|
|
231
|
+
'seed-prune',
|
|
232
|
+
'enable-sweep',
|
|
233
|
+
])
|
|
225
234
|
expect(d.logs.join('')).toContain("step 'sweep-target-scan' failed: ENOENT")
|
|
226
235
|
})
|
|
227
236
|
|
|
@@ -272,6 +281,7 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
|
|
|
272
281
|
order.push('queued-cards')
|
|
273
282
|
},
|
|
274
283
|
enableSweep: () => order.push('enable-sweep'),
|
|
284
|
+
seedPruneSweepCursors: () => {},
|
|
275
285
|
sweepTarget: async () => {},
|
|
276
286
|
log: () => {
|
|
277
287
|
throw new Error('EPIPE')
|
|
@@ -296,6 +306,9 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
|
|
|
296
306
|
enableSweep: () => {
|
|
297
307
|
throw new Error('boom')
|
|
298
308
|
},
|
|
309
|
+
seedPruneSweepCursors: () => {
|
|
310
|
+
throw new Error('boom')
|
|
311
|
+
},
|
|
299
312
|
sweepTarget: boom,
|
|
300
313
|
log: () => {},
|
|
301
314
|
}),
|