@qwen-code/channel-qqbot 0.26.0 → 0.26.1
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/QQChannel.d.ts +573 -3
- package/dist/QQChannel.d.ts.map +1 -1
- package/dist/QQChannel.js +2861 -182
- package/dist/QQChannel.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +14 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/QQChannel.d.ts
CHANGED
|
@@ -65,6 +65,14 @@ export declare class QQChannel extends ChannelBase {
|
|
|
65
65
|
private inboundReplyContext;
|
|
66
66
|
/** msg_seq counter per user messageId, for multi-block streaming. */
|
|
67
67
|
private msgSeqMap;
|
|
68
|
+
/**
|
|
69
|
+
* Anchored sends currently in flight, keyed by the msgId whose msg_seq
|
|
70
|
+
* counter they hold (refcounted: two sessions may send under one msgId).
|
|
71
|
+
* Covers sends that own no streamState entry — the terminal send in
|
|
72
|
+
* onResponseComplete and the stale-entry branch — which flushingSessions
|
|
73
|
+
* (session-keyed) cannot see.
|
|
74
|
+
*/
|
|
75
|
+
private inFlightMsgSeqSends;
|
|
68
76
|
/** Periodic cleanup timer for expired replyMsgId entries. */
|
|
69
77
|
private replyMsgIdCleanupTimer;
|
|
70
78
|
/** 5-minute TTL for replyMsgId entries and seenMessages dedup. */
|
|
@@ -75,8 +83,18 @@ export declare class QQChannel extends ChannelBase {
|
|
|
75
83
|
private maxFlushRetries;
|
|
76
84
|
/** Retry delay for subsequent attempts (backoff beyond first retry). */
|
|
77
85
|
private static readonly IDLE_FLUSH_BACKOFF_MS;
|
|
86
|
+
/**
|
|
87
|
+
* Foreground ceiling for the completion-path final segment when
|
|
88
|
+
* maxFlushRetries is the documented "unlimited" (<= 0). The fire-and-forget
|
|
89
|
+
* flush paths may retry forever because no caller awaits them, but a
|
|
90
|
+
* completion send is awaited inside the turn's try, so its readiness to give
|
|
91
|
+
* up decides when that turn's finally runs and the session lock is released.
|
|
92
|
+
*/
|
|
93
|
+
private static readonly FINAL_SEGMENT_ROUTE_ATTEMPTS;
|
|
78
94
|
/** Max buffer length before forcing an immediate flush. */
|
|
79
95
|
private static readonly MAX_BUFFER_LENGTH;
|
|
96
|
+
/** Purge records kept in the rescue file, oldest dropped first. */
|
|
97
|
+
private static readonly MAX_PURGE_RECORDS;
|
|
80
98
|
/** Per-group bot OPENID map for multi-group support. */
|
|
81
99
|
private botOpenIdByGroup;
|
|
82
100
|
/** Dedup set for unexpected senderOpenId format warnings (key: `${chatId}:${senderOpenId}`). */
|
|
@@ -122,6 +140,29 @@ export declare class QQChannel extends ChannelBase {
|
|
|
122
140
|
* - flushedSessions tracks already-sent sessions to skip final fullText
|
|
123
141
|
*/
|
|
124
142
|
private streamState;
|
|
143
|
+
/**
|
|
144
|
+
* Per-session reply msgId anchor, kept across idle-flush buffer windows.
|
|
145
|
+
* Set deterministically in onPromptStart from the triggering message's
|
|
146
|
+
* id (the same event.id setReplyMsgId stores) and released when the
|
|
147
|
+
* response completes, fails permanently, or the session dies. Entries
|
|
148
|
+
* carry a timestamp so readers (onResponseChunk / onResponseComplete)
|
|
149
|
+
* can drop anchors past the 5-minute TTL and fall back to the active
|
|
150
|
+
* send path — a slow turn must never keep sending chunks with an
|
|
151
|
+
* expired msg_id. Because it outlives individual streamState entries, a
|
|
152
|
+
* concurrent message in the same chat that overwrites the chat-level
|
|
153
|
+
* replyMsgId entry mid-stream cannot re-parent this session's later
|
|
154
|
+
* chunks onto its own msg_id (see PR #6457 review).
|
|
155
|
+
*/
|
|
156
|
+
private sessionReplyMsgId;
|
|
157
|
+
/**
|
|
158
|
+
* Monotonic turn counter per session, bumped on every onPromptStart.
|
|
159
|
+
* streamState entries carry the turn they were created in; when a new
|
|
160
|
+
* prompt starts on a session whose previous turn left a streamState entry
|
|
161
|
+
* behind (deferred send still settling), onResponseChunk uses this counter
|
|
162
|
+
* to detect and drop the stale entry so the new turn's chunks cannot be
|
|
163
|
+
* appended to the old turn's buffer or delivered under its msgId.
|
|
164
|
+
*/
|
|
165
|
+
private turnCounter;
|
|
125
166
|
private flushingSessions;
|
|
126
167
|
private pendingStreamDelete;
|
|
127
168
|
private _reconnectId;
|
|
@@ -139,6 +180,56 @@ export declare class QQChannel extends ChannelBase {
|
|
|
139
180
|
* finally, even on error/cancel), independent of streaming config.
|
|
140
181
|
*/
|
|
141
182
|
private activePromptSessions;
|
|
183
|
+
/**
|
|
184
|
+
* Side buffer for chunks arriving while a previous turn's deferred flush
|
|
185
|
+
* chain still owns the session's streamState entry (the stale parked
|
|
186
|
+
* early-return in onResponseChunk). Each entry records the turn that owns
|
|
187
|
+
* its text, so a teardown can tell a live turn's stashed HEAD — which must
|
|
188
|
+
* still be delivered — from a superseded turn's leftovers, which are
|
|
189
|
+
* discarded with a log. The chunks are prepended to the owning turn's next
|
|
190
|
+
* state entry once the chain settles and frees the entry, so the HEAD of a
|
|
191
|
+
* reply is never silently dropped during the chain's settle window (up to
|
|
192
|
+
* ~10s under rate-limit backoff). A teardown may only drop an entry whose
|
|
193
|
+
* turn is not the live turn. The consumption sites are onResponseChunk
|
|
194
|
+
* (the next turn's first chunk folds the stash in), onResponseComplete (the
|
|
195
|
+
* owning turn's completion prepends the sealed `pre`), and onPromptEnd's two
|
|
196
|
+
* branches: a cancelled turn merges the stash into its residual buffer, or —
|
|
197
|
+
* when a parked chain can still deliver it — hands it to
|
|
198
|
+
* deliverCancelledStash instead.
|
|
199
|
+
* `pre` seals the portion accumulated before the most recent
|
|
200
|
+
* responseBoundary: the bridge accumulates every textChunk and only a
|
|
201
|
+
* boundary clears that collection, so at completion only that sealed
|
|
202
|
+
* portion is missing from fullText — the post-boundary remainder is already
|
|
203
|
+
* there and must not be prepended a second time. It is absent until a
|
|
204
|
+
* boundary fires for the entry. Entries are cleared when the session dies
|
|
205
|
+
* or the channel disconnects.
|
|
206
|
+
*/
|
|
207
|
+
private streamOrphanBuffer;
|
|
208
|
+
/**
|
|
209
|
+
* The turn whose onResponseComplete has already run, per session. The
|
|
210
|
+
* completion that prepends a turn's stash cannot run a second time, so a
|
|
211
|
+
* stash tagged with that turn has no guaranteed consumer, and
|
|
212
|
+
* handOffSealedPre delivers its text directly instead of writing it. Dropped
|
|
213
|
+
* when a new turn starts, on session death, and on disconnect; onPromptEnd's
|
|
214
|
+
* terminal teardown drops it unless a chain still holds the session's flush
|
|
215
|
+
* marker, and a flush chain's terminal settle drops it when it still owns the
|
|
216
|
+
* generation (deleteTurnGenerationIfOwned). A teardown that finds a live
|
|
217
|
+
* marker keeps the record so that chain's handOffSealedPre can still read it;
|
|
218
|
+
* because that teardown also clears the marker, the chain's own .finally
|
|
219
|
+
* returns on its ownership check and the record is instead dropped by the
|
|
220
|
+
* next onPromptStart, onSessionDied, or disconnect.
|
|
221
|
+
*/
|
|
222
|
+
private completedTurns;
|
|
223
|
+
/**
|
|
224
|
+
* Bridge-side `responseBoundary` observer. ChannelBase's own listener returns
|
|
225
|
+
* early while a cancel is pending, so onResponseBoundary can miss a boundary
|
|
226
|
+
* even though the bridge still cleared its chunk collection — the stash's
|
|
227
|
+
* `pre` would then never be sealed and the diverted head would be absent from
|
|
228
|
+
* both state.buffer and fullText. Observing the bridge event directly (the
|
|
229
|
+
* same emit the bridge's clearChunks listens to) is ungated.
|
|
230
|
+
*/
|
|
231
|
+
private _bridgeBoundarySeal;
|
|
232
|
+
private bridgeBoundarySealAttached;
|
|
142
233
|
private readonly qqStatePath;
|
|
143
234
|
/**
|
|
144
235
|
* Path to the global sessions.json managed by start.ts.
|
|
@@ -147,8 +238,42 @@ export declare class QQChannel extends ChannelBase {
|
|
|
147
238
|
private readonly globalSessionsPath;
|
|
148
239
|
/** Backup of sessions.json so conversations survive daemon restarts. */
|
|
149
240
|
private readonly sessionsBackupPath;
|
|
241
|
+
/**
|
|
242
|
+
* Append-only rescue log of the routes the orphan purge deletes, written
|
|
243
|
+
* immediately before the first deletion when `purgeLegacySessions` is on, so
|
|
244
|
+
* an operator can restore a legacy conversation by hand.
|
|
245
|
+
*/
|
|
246
|
+
private readonly sessionsPurgedPath;
|
|
150
247
|
constructor(name: string, config: ChannelConfig & Record<string, unknown>, bridge: ChannelAgentBridge, options?: ChannelBaseOptions);
|
|
151
248
|
private handleCronTextChunk;
|
|
249
|
+
/**
|
|
250
|
+
* Flush `entry`'s accumulated cron text through the delivery ladder: one
|
|
251
|
+
* send plus its two bounded retries. A successful attempt deletes the
|
|
252
|
+
* entry when its buffer is empty, or re-arms the normal flush when a chunk
|
|
253
|
+
* arrived while the send was in flight.
|
|
254
|
+
*/
|
|
255
|
+
private flushCronEntry;
|
|
256
|
+
/**
|
|
257
|
+
* Terminal failure of a cron flush. Text that arrived while the failed send
|
|
258
|
+
* was in flight was never attempted, so it must not be dropped with the
|
|
259
|
+
* entry: re-arm the normal flush for that residual, exactly like the success
|
|
260
|
+
* tail. An entry that no longer owns the session was already replaced, so its
|
|
261
|
+
* successor's text is left alone, and an entry with an empty residual has
|
|
262
|
+
* nothing left to deliver — both go.
|
|
263
|
+
*/
|
|
264
|
+
private dropCronEntryOrRearm;
|
|
265
|
+
/**
|
|
266
|
+
* Tail of a successful cron send. If the entry still owns the session and
|
|
267
|
+
* a chunk arrived while the send was in flight, arm the normal flush for
|
|
268
|
+
* that residual; otherwise drop the entry once its buffer is empty.
|
|
269
|
+
*/
|
|
270
|
+
private settleCronFlushSuccess;
|
|
271
|
+
/**
|
|
272
|
+
* Arm the normal 2s idle flush (immediate at the buffer length limit) for
|
|
273
|
+
* `entry`, unless one is already armed. The callback re-checks that the
|
|
274
|
+
* entry still owns the session before flushing.
|
|
275
|
+
*/
|
|
276
|
+
private armCronFlush;
|
|
152
277
|
/**
|
|
153
278
|
* Public gate for external cron/scheduler integration.
|
|
154
279
|
* Wraps a cron message flow to activate `_inCronFlow` so that
|
|
@@ -167,13 +292,24 @@ export declare class QQChannel extends ChannelBase {
|
|
|
167
292
|
handleInbound(envelope: Envelope): Promise<void>;
|
|
168
293
|
sendMessage(chatId: string, text: string): Promise<void>;
|
|
169
294
|
protected sendThreadMessage(chatId: string, _threadId: string | undefined, text: string, sourceLabel?: string): Promise<void>;
|
|
295
|
+
/**
|
|
296
|
+
* The reply context a session's next response would carry: the inbound
|
|
297
|
+
* message the turn is answering, resolved through the turn-scoped
|
|
298
|
+
* activePrompts entry. Read by sendResponseMessage and captured once by
|
|
299
|
+
* deliverCancelledStash, which re-attempts a delivery after that entry may
|
|
300
|
+
* already belong to a successor turn.
|
|
301
|
+
*/
|
|
302
|
+
private resolveResponseReplyContext;
|
|
170
303
|
protected sendResponseMessage(chatId: string, text: string, sessionId: string, sourceLabel?: string): Promise<void>;
|
|
171
304
|
private sendMessageWithReplyContext;
|
|
172
305
|
/**
|
|
173
306
|
* Resolve API routing: handles disposed check, token refresh, chatId validation,
|
|
174
|
-
* sandbox detection, and C2C/group path selection.
|
|
307
|
+
* sandbox detection, and C2C/group path selection. When a guard fails it
|
|
308
|
+
* returns `{ block }` naming whether a retry could help, instead of a route.
|
|
175
309
|
*/
|
|
176
310
|
private resolveRoute;
|
|
311
|
+
/** The chat type recorded for a chat, if any (see resolveRoute). */
|
|
312
|
+
private chatTypeFor;
|
|
177
313
|
disconnect(): void;
|
|
178
314
|
/**
|
|
179
315
|
* QQ Bot API V2 does not provide a typing indicator endpoint, but these
|
|
@@ -181,9 +317,68 @@ export declare class QQChannel extends ChannelBase {
|
|
|
181
317
|
* discriminator (see activePromptSessions). ChannelBase always pairs the
|
|
182
318
|
* two calls per prompt turn (onPromptEnd runs in the prompt path's
|
|
183
319
|
* finally, even on error/cancel).
|
|
320
|
+
*
|
|
321
|
+
* Set the per-session reply anchor deterministically from the triggering
|
|
322
|
+
* message's id (ChannelBase passes envelope.messageId, which for QQ is
|
|
323
|
+
* event.id — the same value setReplyMsgId stores in the chat-level entry).
|
|
324
|
+
* This replaces the previous opportunistic capture on the first chunk,
|
|
325
|
+
* which had a blind spot: a slow model turn could pass the chat entry's
|
|
326
|
+
* 5-minute TTL before its first chunk, and the capture would then pick up
|
|
327
|
+
* the msgId of a NEWER message in the same chat (another user's turn).
|
|
328
|
+
* Anchoring at prompt start is immune — the anchor always refers to the
|
|
329
|
+
* message that actually triggered this session's turn.
|
|
330
|
+
*
|
|
331
|
+
* Proactive turns (loop/webhook/cron) have no triggering message; their
|
|
332
|
+
* replies must go out as active messages, so clear any stale anchor.
|
|
184
333
|
*/
|
|
185
|
-
protected onPromptStart(_chatId: string, sessionId: string,
|
|
186
|
-
|
|
334
|
+
protected onPromptStart(_chatId: string, sessionId: string, messageId?: string): void;
|
|
335
|
+
/**
|
|
336
|
+
* ChannelBase invokes this unconditionally in a finally block — including
|
|
337
|
+
* when the prompt was cancelled, in which case onResponseComplete is
|
|
338
|
+
* skipped. That makes it the one reliable release point for per-session
|
|
339
|
+
* streaming state: without it a cancelled turn would leave its reply
|
|
340
|
+
* anchor in sessionReplyMsgId (and the next prompt on the same session
|
|
341
|
+
* would overwrite it only at its own start, leaking the stale entry's
|
|
342
|
+
* msg_seq orphan meanwhile).
|
|
343
|
+
*
|
|
344
|
+
* Deferred completions are exempt: when onResponseComplete parked the
|
|
345
|
+
* session in pendingStreamDelete, a final flush is still in flight and
|
|
346
|
+
* its promise chain owns the streamState lifecycle (it re-flushes the
|
|
347
|
+
* residual buffer, then releases the anchor). Clearing state here would
|
|
348
|
+
* trip the .then() identity guard and silently drop the residual buffer.
|
|
349
|
+
*/
|
|
350
|
+
protected onPromptEnd(chatId: string, sessionId: string, _messageId?: string): void;
|
|
351
|
+
/**
|
|
352
|
+
* Deliver a cancelled turn's stashed HEAD on its OWN anchor. The session is
|
|
353
|
+
* parked in pendingStreamDelete while a predecessor turn's deferred chain
|
|
354
|
+
* settles, so ChannelBase skips onResponseComplete and onPromptEnd's park
|
|
355
|
+
* early-return would otherwise leave this turn's stash for the next turn to
|
|
356
|
+
* discard as superseded. Fire-and-forget — onPromptEnd is sync — and never
|
|
357
|
+
* rejects, so a failed delivery cannot become an unhandled rejection. The
|
|
358
|
+
* park flag and the predecessor's streamState entry/buffer are deliberately
|
|
359
|
+
* untouched: that chain still owns them and must settle and release its own
|
|
360
|
+
* anchor, and this text must not go out under the predecessor's msgId.
|
|
361
|
+
*
|
|
362
|
+
* This is the only delivery for a stash that onPromptEnd already deleted from
|
|
363
|
+
* streamOrphanBuffer (or a sealed head handOffSealedPre cleared from
|
|
364
|
+
* sealedPre), so a transient failure must not drop the text. RATE_LIMITED is
|
|
365
|
+
* the one code the flush path classifies as transient, and no copy survives
|
|
366
|
+
* for a later turn, so the send is re-attempted here under the same
|
|
367
|
+
* maxFlushRetries bound flushAndTrack uses (<= 0 means unlimited). A
|
|
368
|
+
* re-attempt is not routed through streamState/idleFlush: the turn is over,
|
|
369
|
+
* so a state entry for it would be dropped as stale by the next prompt, and
|
|
370
|
+
* the entry-less in-flight guard below would be lost. Every attempt
|
|
371
|
+
* reuses the anchor, the attribution label and the reply context captured
|
|
372
|
+
* before the first one.
|
|
373
|
+
*
|
|
374
|
+
* A send also fails to reach the wire without throwing when its route cannot
|
|
375
|
+
* be resolved; sendMessageWithReplyContext reports that as transient (token
|
|
376
|
+
* refresh failure) or permanent (disposed / unusable chatId / no chat type),
|
|
377
|
+
* and the second class is dropped with a log immediately rather than spun on
|
|
378
|
+
* the backoff forever. Permanent DeliveryErrors and exhaustion drop with a
|
|
379
|
+
* log, as the other paths do.
|
|
380
|
+
*/
|
|
381
|
+
private deliverCancelledStash;
|
|
187
382
|
protected onResponseChunk(chatId: string, chunk: string, sessionId: string, segment?: ChannelOutputSegmentContext): void;
|
|
188
383
|
private idleFlush;
|
|
189
384
|
/**
|
|
@@ -192,10 +387,233 @@ export declare class QQChannel extends ChannelBase {
|
|
|
192
387
|
* on failure) logic to eliminate duplication.
|
|
193
388
|
*/
|
|
194
389
|
private flushAndTrack;
|
|
390
|
+
/**
|
|
391
|
+
* Drop the session's turn-generation records — the turn counter and the
|
|
392
|
+
* completion record completedTurns — only when they still belong to the
|
|
393
|
+
* settling turn. A successor turn that started while this chain was in flight
|
|
394
|
+
* bumped the counter and cleared the completion record already (see
|
|
395
|
+
* onPromptStart); deleting them here would reset the successor's stale-state
|
|
396
|
+
* detection to 0 and let a stale completion record alias onto its turn.
|
|
397
|
+
*
|
|
398
|
+
* The counter is kept while this session still has a prompt in flight. A
|
|
399
|
+
* settle can run in the microtask window between onResponseComplete parking
|
|
400
|
+
* the session and ChannelBase's onPromptEnd (which clears the active marker
|
|
401
|
+
* first), and onPromptEnd then recognises the turn's own re-stashed sealed
|
|
402
|
+
* head by comparing the stash's turn against this counter — as does the
|
|
403
|
+
* parked-stash service before its early return. Deleting the counter here
|
|
404
|
+
* makes that gate read 0 and discard the head as superseded: the reply's
|
|
405
|
+
* opening reaches no wire, no buffer and no stash while the turn reports
|
|
406
|
+
* completed. The completion record still goes: it is turn-scoped, and a
|
|
407
|
+
* later prompt reusing the number must not alias it. onPromptEnd,
|
|
408
|
+
* onPromptStart and onSessionDied all still delete the counter once the
|
|
409
|
+
* prompt is over, so it stays bounded by the live sessions and can never be
|
|
410
|
+
* inherited by a successor turn.
|
|
411
|
+
*/
|
|
412
|
+
private deleteTurnGenerationIfOwned;
|
|
195
413
|
onToolCall(_chatId: string, event: ToolCallEvent): void;
|
|
196
414
|
protected onResponseBoundary(_chatId: string, sessionId: string): void;
|
|
197
415
|
protected onResponseComplete(chatId: string, fullText: string, sessionId: string, segment?: ChannelOutputSegmentContext): Promise<void>;
|
|
416
|
+
/**
|
|
417
|
+
* A stash's sealed head has no other copy — the bridge cleared it from its
|
|
418
|
+
* collection at the boundary — so a delivery that fails after the entry was
|
|
419
|
+
* consumed must report the loss (length and error) instead of dropping it
|
|
420
|
+
* silently. Delete-before-send is deliberate: it stops a chunk arriving
|
|
421
|
+
* mid-send from draining the same stash twice.
|
|
422
|
+
*/
|
|
423
|
+
private logLostSealedHead;
|
|
424
|
+
/**
|
|
425
|
+
* Act on the route class a completion-path send reports. A blocked send never
|
|
426
|
+
* reached the wire but throws nothing, so without this the final segment is
|
|
427
|
+
* indistinguishable from a delivered one: the sealed head's loss log is
|
|
428
|
+
* bypassed and the turn is reported delivered. A transient block (token
|
|
429
|
+
* refresh failure) can clear on a later attempt, so it is re-attempted; a
|
|
430
|
+
* permanent block, or exhaustion, reports the sealed head this path consumed
|
|
431
|
+
* instead of dropping it silently. Exhaustion is bounded by maxFlushRetries
|
|
432
|
+
* when that is positive, and by FINAL_SEGMENT_ROUTE_ATTEMPTS when
|
|
433
|
+
* maxFlushRetries is the documented "unlimited" (<= 0): this send is awaited
|
|
434
|
+
* inside the turn, so unlike the fire-and-forget flush paths it cannot be
|
|
435
|
+
* allowed to retry forever — the turn's finally (and with it the session's
|
|
436
|
+
* turn lock) would never run. The caller holds the msg_seq counter across the
|
|
437
|
+
* whole call and releases the anchor only after it returns, so a re-attempt
|
|
438
|
+
* cannot restart at seq 1 (QQ dedupes on msg_id + msg_seq).
|
|
439
|
+
*/
|
|
440
|
+
private sendFinalSegmentChecked;
|
|
198
441
|
private streamBufferLimit;
|
|
442
|
+
/**
|
|
443
|
+
* Build a fresh streamState entry for a session's first chunk, resolving
|
|
444
|
+
* the session's reply anchor (TTL-checked) and the segment's reply context.
|
|
445
|
+
* Shared by onResponseChunk's fresh-entry path and onPromptEnd's delivery
|
|
446
|
+
* of a cancelled turn's stashed head.
|
|
447
|
+
*/
|
|
448
|
+
private createStreamState;
|
|
449
|
+
/**
|
|
450
|
+
* Report the tail a capped divert episode accumulated after its first
|
|
451
|
+
* overflow line. See the cap branch in onResponseChunk: the first drop is
|
|
452
|
+
* logged immediately so the loss is visible, later chunks only count, and
|
|
453
|
+
* this emits the cumulative total once per episode when the stash leaves
|
|
454
|
+
* the side buffer. `capDropped` counts characters, not fields: the sealed
|
|
455
|
+
* pre's own trim is a subset of the text trim and is not charged again.
|
|
456
|
+
*/
|
|
457
|
+
private reportOrphanStashCap;
|
|
458
|
+
/**
|
|
459
|
+
* Drop a side-buffer entry that cannot be delivered (it belongs to a
|
|
460
|
+
* superseded turn, or a fresh turn is starting and can never own it) and
|
|
461
|
+
* log the discarded text so the loss is observable.
|
|
462
|
+
*/
|
|
463
|
+
private dropOrphanStash;
|
|
464
|
+
/**
|
|
465
|
+
* The seal a failed payload needs when a boundary cleared the bridge's
|
|
466
|
+
* collection while the send was in flight: the payload's whole text is now
|
|
467
|
+
* absent from fullText, so it is sealed outright rather than only by the seal
|
|
468
|
+
* the send carried, which may cover just a prefix. `residual` is the text the
|
|
469
|
+
* boundary stripped from the live turn, captured by the boundary hooks ('' =
|
|
470
|
+
* nothing), and is appended after the payload — no string comparison against
|
|
471
|
+
* the carried seal, so a residual whose text repeats it still counts.
|
|
472
|
+
*/
|
|
473
|
+
private sealClearedPayload;
|
|
474
|
+
/**
|
|
475
|
+
* Put a transiently-failed payload back on `current` in the order the seal
|
|
476
|
+
* states it in.
|
|
477
|
+
*
|
|
478
|
+
* With no stash merged during this flight the payload predates whatever
|
|
479
|
+
* accumulated while it was in flight — chunks sent to state.buffer by the
|
|
480
|
+
* bridge's fallback path, or a residual that arrived mid-send — so the plain
|
|
481
|
+
* prepend (`payload + buffer`) is correct and is what the non-widened case
|
|
482
|
+
* has always done.
|
|
483
|
+
*
|
|
484
|
+
* After onPromptEnd merged a stash while this send was flight, the buffer is
|
|
485
|
+
* `[stashed head][chunks that arrived since]`: the merge prepends the head,
|
|
486
|
+
* and every later writer appends. That head is OLDER than the failed payload
|
|
487
|
+
* (it is the seal's leading segment — see handOffSealedPre, "the older
|
|
488
|
+
* first"), so the payload belongs BETWEEN the two portions and the
|
|
489
|
+
* reconstruction is `head + payload + newer`. Reconstructing it is the whole
|
|
490
|
+
* reason `staleStashPrefix` exists: the failed payload's text may repeat
|
|
491
|
+
* either portion of the buffer, the newer text may itself begin with the
|
|
492
|
+
* payload's text, and the payload may even be empty, so no boundary can be
|
|
493
|
+
* found by comparing text. A `startsWith`/dedup test against the buffer would
|
|
494
|
+
* strip the newer text's own leading characters — dropping real reply text —
|
|
495
|
+
* so the split uses the recorded length alone and never compares strings.
|
|
496
|
+
* The marker is consumed here — the payload is folded in and the next flight
|
|
497
|
+
* must not reorder against it again (flushAndTrack also resets it).
|
|
498
|
+
*
|
|
499
|
+
* A boundary that sealed a residual of its own while the send was in flight
|
|
500
|
+
* does not change that order: captureBoundaryClear records the residual and
|
|
501
|
+
* marks the re-seal, but the residual is only the text that accumulated
|
|
502
|
+
* since the boundary — it never contains the in-flight payload, and it does
|
|
503
|
+
* not know that the head prepended in front of it is older. A plain prepend
|
|
504
|
+
* would therefore read `head + payload + newer` as `payload + head + newer`.
|
|
505
|
+
* The reconstruction above stays the same; resealOnRebuffer's boundary arm
|
|
506
|
+
* extends the widened seal to match it.
|
|
507
|
+
*/
|
|
508
|
+
private rebufferAfterStashMerge;
|
|
509
|
+
/**
|
|
510
|
+
* The seal owed for `payload` when a boundary cleared the bridge's collection
|
|
511
|
+
* while its send was in flight. onPromptEnd's stash merge may have widened the
|
|
512
|
+
* seal during this flight; the undelivered window is then the reconstruction
|
|
513
|
+
* rebufferAfterStashMerge builds (the OLDER stashed head, then this payload,
|
|
514
|
+
* then whatever accumulated since), read lazily so the non-widened path never
|
|
515
|
+
* consumes the stash marker. Otherwise the plain payload-plus-residual seal
|
|
516
|
+
* stands. Shared by the transient re-buffer arms (resealOnRebuffer) and the
|
|
517
|
+
* permanent handoff: all describe the same window, so they must not drift.
|
|
518
|
+
*/
|
|
519
|
+
private sealForClearedWindow;
|
|
520
|
+
/**
|
|
521
|
+
* Re-seal a failed payload that a transient re-buffer puts back on `current`:
|
|
522
|
+
* a boundary that cleared the bridge's collection while the send was in
|
|
523
|
+
* flight removed the payload's whole text from fullText, so the entire
|
|
524
|
+
* payload is sealed, plus the residual the boundary captured (flagged
|
|
525
|
+
* deterministically, not by comparing seal text). Only a chain that still
|
|
526
|
+
* owns the live turn may write the seal — a superseded entry's payload is
|
|
527
|
+
* abandoned with its failure and must not be injected into a successor's
|
|
528
|
+
* reply. `carriedSeal` is the backstop for a re-seal whose boundary marker
|
|
529
|
+
* was missed, but it is skipped when the seal was widened during this flight
|
|
530
|
+
* (sealWidenedInFlight — onPromptEnd's stash merge already folded the carried
|
|
531
|
+
* head in as the widened seal's suffix, so prepending it again would seal it
|
|
532
|
+
* twice). Shared by the parked and non-parked re-buffer arms, and by the
|
|
533
|
+
* permanent handoff through sealForClearedWindow; they must stay identical
|
|
534
|
+
* because they encode the same rule.
|
|
535
|
+
*/
|
|
536
|
+
private resealOnRebuffer;
|
|
537
|
+
/**
|
|
538
|
+
* Carry a widened, boundary-cleared re-buffer into the seal the NEXT flight
|
|
539
|
+
* captures, so the knowledge survives the per-flight resets and a permanent
|
|
540
|
+
* failure on a retry re-stashes the whole window instead of the stale
|
|
541
|
+
* residual.
|
|
542
|
+
*
|
|
543
|
+
* resealOnRebuffer writes exactly this for a live turn, but its live-turn
|
|
544
|
+
* gate skips a superseded entry — whose re-buffer would otherwise keep the
|
|
545
|
+
* residual the boundary overwrote ('AC') while `current.buffer` already holds
|
|
546
|
+
* the reconstruction ('APC'). The retry flight then starts with `sealedPre`
|
|
547
|
+
* 'AC' and, because flushAndTrack resets sealWidenedInFlight /
|
|
548
|
+
* boundaryClearedInFlight at every flight start, its permanent arm can no
|
|
549
|
+
* longer rebuild the window: the payload 'P' is on no wire and in no seal and
|
|
550
|
+
* the entry is deleted. Recording the reconstruction on the entry hands the
|
|
551
|
+
* retry flight the one value it needs: its success path still clears it
|
|
552
|
+
* without delivering, and only a give-up re-stashes it (or delivers it), the
|
|
553
|
+
* same handoff the widened permanent arm performs.
|
|
554
|
+
*
|
|
555
|
+
* Gated on the boundary marker AND the widening, not on the buffer alone: a
|
|
556
|
+
* widened re-buffer with NO boundary leaves the newer text in the bridge's
|
|
557
|
+
* collection, so welding it into the seal would deliver it twice. The value
|
|
558
|
+
* is the reconstruction `rebufferAfterStashMerge` just wrote to
|
|
559
|
+
* `current.buffer` — no seal/buffer text comparison anywhere.
|
|
560
|
+
*/
|
|
561
|
+
private carryWidenedWindowIntoSeal;
|
|
562
|
+
/**
|
|
563
|
+
* Whether a settling flush chain still owns the session: its entry is the
|
|
564
|
+
* live streamState entry, or the chain still holds the flush marker. The
|
|
565
|
+
* marker is cleared only by `.finally()` after the settle arms, so this stays
|
|
566
|
+
* true while those arms hand state off; a session torn down by onSessionDied
|
|
567
|
+
* (or replaced outright) owns neither, and its chain must not write shared
|
|
568
|
+
* per-session state.
|
|
569
|
+
*/
|
|
570
|
+
private ownsSession;
|
|
571
|
+
/**
|
|
572
|
+
* Whether this entry is still the turn that owns the session's counter. A
|
|
573
|
+
* superseded entry's text is abandoned with its failure and must not be
|
|
574
|
+
* sealed into a successor's reply.
|
|
575
|
+
*/
|
|
576
|
+
private ownsLiveTurn;
|
|
577
|
+
/**
|
|
578
|
+
* Hand a doomed entry's sealed pre-boundary head to whatever can still
|
|
579
|
+
* deliver it, before the entry carrying the only copy is dropped. The
|
|
580
|
+
* boundary cleared the bridge's chunk collection, so this text is absent
|
|
581
|
+
* from fullText: dropping it truncates the reply's opening with no log.
|
|
582
|
+
*
|
|
583
|
+
* A later settle of this turn (onResponseComplete has not run yet), or a
|
|
584
|
+
* successor turn that owns the counter now, can still prepend it — re-stash,
|
|
585
|
+
* merging into an existing stash. A fresh entry is tagged with the turn that
|
|
586
|
+
* owns the counter, never the doomed one: every consumer gates on the entry
|
|
587
|
+
* turn matching the live turn, so a doomed tag is dropped as superseded by
|
|
588
|
+
* the successor's first chunk and the head is lost anyway. The merge branch
|
|
589
|
+
* keeps the existing entry's turn, which a stash always carries as the turn
|
|
590
|
+
* that wrote it. `pre` carries the sealed head alone and is never widened to
|
|
591
|
+
* the whole dropped buffer: the post-boundary text is still in fullText and
|
|
592
|
+
* prepending it again would deliver it twice.
|
|
593
|
+
*
|
|
594
|
+
* Otherwise the turn is already over — completion returned and parked the
|
|
595
|
+
* session, or the caller is the park-consuming exhaustion branch, which
|
|
596
|
+
* clears the flag before reaching here — and this turn still owns the
|
|
597
|
+
* counter, so the block that drops the entry also drops the counter and a
|
|
598
|
+
* stash would be discarded as superseded. Deliver the sealed head on this
|
|
599
|
+
* turn's own anchor instead — the same path onPromptEnd already uses for a
|
|
600
|
+
* parked cancelled turn's HEAD. The same delivery is taken when the turn
|
|
601
|
+
* being tagged has already run its completion (see completedTurns): its
|
|
602
|
+
* completion is the reader that would prepend the stash, it will not run
|
|
603
|
+
* again, and a stash it cannot consume is dropped by the next onPromptStart.
|
|
604
|
+
*
|
|
605
|
+
* `turnIsOver` defaults to the park flag, which is still armed at the four
|
|
606
|
+
* sites that reach here while it is; the pending-exhaustion branch has
|
|
607
|
+
* already consumed it, so it passes the fact explicitly.
|
|
608
|
+
*
|
|
609
|
+
* `carriedSeal` is the seal a permanently-failed send captured when it
|
|
610
|
+
* started (flushAndTrack). A later boundary can re-seal the same state's
|
|
611
|
+
* residual while that send is in flight, overwriting `sealedPre`; the failed
|
|
612
|
+
* send carried the older head, which then has no other copy. Both are
|
|
613
|
+
* recovered here — the older first, since the seal always covers a later
|
|
614
|
+
* buffer window.
|
|
615
|
+
*/
|
|
616
|
+
private handOffSealedPre;
|
|
199
617
|
onSessionDied(sessionId: string): void;
|
|
200
618
|
private serializeQQState;
|
|
201
619
|
/** Debounced state persistence with atomic write. */
|
|
@@ -211,6 +629,41 @@ export declare class QQChannel extends ChannelBase {
|
|
|
211
629
|
* No-op if not attached or if cron is disabled.
|
|
212
630
|
*/
|
|
213
631
|
private detachCronHandler;
|
|
632
|
+
/**
|
|
633
|
+
* Attach the ungated `responseBoundary` observer to the current bridge.
|
|
634
|
+
* No-op if already attached.
|
|
635
|
+
*/
|
|
636
|
+
private attachBridgeBoundarySeal;
|
|
637
|
+
/** Detach the boundary observer. No-op if not attached. */
|
|
638
|
+
private detachBridgeBoundarySeal;
|
|
639
|
+
/**
|
|
640
|
+
* Seal the pre-boundary portion of a stashed head. Called from both the
|
|
641
|
+
* adapter hook and the bridge's own boundary event: ChannelBase suppresses
|
|
642
|
+
* the hook while a cancel is pending, but the bridge clears its chunk
|
|
643
|
+
* collection on every boundary it emits, so only the ungated observer can
|
|
644
|
+
* guarantee `pre` is set before the turn completes.
|
|
645
|
+
*/
|
|
646
|
+
private sealOrphanStash;
|
|
647
|
+
/**
|
|
648
|
+
* A boundary clears the bridge's chunk collection. Seal the live turn's
|
|
649
|
+
* buffer-resident prefix for the completion path, and record on the
|
|
650
|
+
* entry whose flush is in flight what its re-buffer must re-seal: the payload
|
|
651
|
+
* the boundary stripped from fullText, always, plus that residual when the
|
|
652
|
+
* flushed entry is the live turn's own. Called by the adapter hook and by the
|
|
653
|
+
* ungated bridge observer — the observer is the only signal when ChannelBase
|
|
654
|
+
* suppresses the hook (cancel pending) — and both run on a normal boundary,
|
|
655
|
+
* where the assignments are idempotent. Keyed off the in-flight marker rather
|
|
656
|
+
* than streamState, so a successor turn replacing the entry cannot hide the
|
|
657
|
+
* chain whose payload is still in the air.
|
|
658
|
+
*/
|
|
659
|
+
private captureBoundaryClear;
|
|
660
|
+
/**
|
|
661
|
+
* The residual text a boundary captured for this entry, or '' when it
|
|
662
|
+
* recorded none. The text lives in `sealedPre`, written by
|
|
663
|
+
* captureBoundaryClear in the same call that sets the marker, so it is always
|
|
664
|
+
* a real residual and never the seal the failed payload carried.
|
|
665
|
+
*/
|
|
666
|
+
private capturedResidual;
|
|
214
667
|
/** Flush pending state writes immediately (called on disconnect). */
|
|
215
668
|
private flushQQState;
|
|
216
669
|
/**
|
|
@@ -240,6 +693,123 @@ export declare class QQChannel extends ChannelBase {
|
|
|
240
693
|
* Keep this while old persisted files may still exist.
|
|
241
694
|
*/
|
|
242
695
|
private fixRestoredSessions;
|
|
696
|
+
/**
|
|
697
|
+
* Purge orphaned session mappings left over from older scope eras:
|
|
698
|
+
* `<channel>:__single__` keys (single-scope era, see PR #6457) and this
|
|
699
|
+
* channel's own three-part user-scope keys.
|
|
700
|
+
*
|
|
701
|
+
* `<channel>:__single__` is the live routing key under an explicit 'single'
|
|
702
|
+
* sessionScope, so it is an orphan only under any other scope. There it can
|
|
703
|
+
* never be routed to again and is dead weight in the router maps and the
|
|
704
|
+
* persisted sessions file — and worse, restore re-attaches it via
|
|
705
|
+
* bridge.loadSession on every restart, silently resetting continuity.
|
|
706
|
+
*
|
|
707
|
+
* User-scope keys (`<channel>:<sender>:<chat>`) are unroutable under every
|
|
708
|
+
* scope other than 'user' (including 'single'): SessionRouter's
|
|
709
|
+
* thread/chat_thread routing key is `<channel>:<chatId>` and its 'single'
|
|
710
|
+
* key is `<channel>:__single__`, while resolve() is a bare map lookup with
|
|
711
|
+
* no shape fallback. This channel's own user-scope entries are therefore
|
|
712
|
+
* purged under every recognized non-'user' scope; an unrecognized scope
|
|
713
|
+
* fails closed (see knownScope below). A sibling channel's are left alone,
|
|
714
|
+
* decided by target ownership, never by key prefix. The purge runs after
|
|
715
|
+
* restore, so on the first boot those keys are still re-attached once by
|
|
716
|
+
* bridge.loadSession and then released here; afterwards they are gone from
|
|
717
|
+
* the persisted file.
|
|
718
|
+
*
|
|
719
|
+
* The destructive half is opt-in via `purgeLegacySessions` (default false):
|
|
720
|
+
* the default scope changing is not the operator asking to delete persisted
|
|
721
|
+
* conversations. With it off, the doomed routes are only counted and
|
|
722
|
+
* reported, and nothing is released. With it on, before the first deletion
|
|
723
|
+
* the doomed routes are appended to `<name>-sessions-purged.json` in the
|
|
724
|
+
* channel state directory so an operator can restore a legacy conversation
|
|
725
|
+
* by hand; if that write fails, nothing is deleted. Each record carries the
|
|
726
|
+
* route's `cwd` when the router's persisted route store still has it, so a
|
|
727
|
+
* restored route resolves to the same workspace. The file accumulates purge
|
|
728
|
+
* records and is never read back automatically.
|
|
729
|
+
*
|
|
730
|
+
* Runs AFTER restoreSessions(): SessionRouter exposes no public API to drop
|
|
731
|
+
* persisted entries before restore (readPersistedEntries/deleteByKey are
|
|
732
|
+
* private), and rewriting the persist file from here would be fragile and
|
|
733
|
+
* race-prone. The orphan is therefore re-attached by bridge.loadSession
|
|
734
|
+
* during restore and then released here — both the router mapping and the
|
|
735
|
+
* daemon-side session (see bridge.discardSession below) are torn down.
|
|
736
|
+
*/
|
|
737
|
+
private purgeSingleScopeOrphans;
|
|
738
|
+
/**
|
|
739
|
+
* Resolve a session's reply anchor, returning both the raw entry and the
|
|
740
|
+
* msgId only while the entry is still inside REPLY_MSG_ID_TTL_MS. Callers
|
|
741
|
+
* that just need a usable anchor take `.msgId`; callers that must tell an
|
|
742
|
+
* absent anchor from an expired one (the final-segment fallback logs the
|
|
743
|
+
* expired case) branch on `.entry`. One site, so the freshness rule cannot
|
|
744
|
+
* drift between the flush, cancelled-stash, completion and create paths.
|
|
745
|
+
*/
|
|
746
|
+
private resolveSessionReplyAnchor;
|
|
747
|
+
/**
|
|
748
|
+
* Release a session's reply anchor, cascading to msgSeqMap when the
|
|
749
|
+
* anchored msgId is no longer referenced by any live session or by the
|
|
750
|
+
* chat's current replyMsgId entry. Idempotent — safe to call on sessions
|
|
751
|
+
* that never held an anchor. This is the single release path for
|
|
752
|
+
* sessionReplyMsgId so every release point (flush completion, permanent
|
|
753
|
+
* delivery failure, retry exhaustion, onResponseComplete, onSessionDied,
|
|
754
|
+
* onPromptEnd, group removal) also cleans up msg_seq counters whose msgId
|
|
755
|
+
* can never be used again — otherwise they accumulate forever in memory
|
|
756
|
+
* and in the persisted QQ state.
|
|
757
|
+
*
|
|
758
|
+
* When expectedMsgId is given, the map entry is only removed if it still
|
|
759
|
+
* points at that msgId. A deferred send chain may settle after the next
|
|
760
|
+
* prompt on the same session has already overwritten the anchor with a new
|
|
761
|
+
* msgId; releasing by sessionId alone would delete the *successor's* anchor
|
|
762
|
+
* (the exact defect this parameter guards). The msgSeqMap cascade still
|
|
763
|
+
* runs for expectedMsgId so the superseded turn's counter cannot orphan.
|
|
764
|
+
*/
|
|
765
|
+
private releaseSessionReplyAnchor;
|
|
766
|
+
/**
|
|
767
|
+
* Whether a msgId's msg_seq counter is still needed: another session is
|
|
768
|
+
* anchored to it, the chat-level replyMsgId entry still points at it, a
|
|
769
|
+
* streamState entry holds it with a pending residual or an in-flight flush,
|
|
770
|
+
* an anchored send under it is in flight (including the entry-less sends
|
|
771
|
+
* flushingSessions cannot see), or the replyContextByMessageId routing map
|
|
772
|
+
* still names it. The single predicate for every msgSeqMap reclamation site,
|
|
773
|
+
* so the release veto and the TTL/teardown reclaimers cannot disagree and
|
|
774
|
+
* drop a counter under a live send. It errs toward retaining: a counter that
|
|
775
|
+
* survives too long only makes a later send under the same msgId start at a
|
|
776
|
+
* higher msg_seq, which QQ accepts, while reclaiming it early loses the tail
|
|
777
|
+
* (QQ dedupes on msg_id + msg_seq).
|
|
778
|
+
*/
|
|
779
|
+
private isMsgSeqStillInUse;
|
|
780
|
+
/**
|
|
781
|
+
* Drop a msgId's msg_seq counter once no holder remains. Derived,
|
|
782
|
+
* not decided per call site: a release site that vetoes reclamation while a
|
|
783
|
+
* holder still exists never gets a second chance once that holder later
|
|
784
|
+
* disappears, so every site routes through isMsgSeqStillInUse and the sweep
|
|
785
|
+
* re-checks the counter map itself. Returns whether the counter was dropped;
|
|
786
|
+
* the drop is persisted so a cold restart cannot resurrect it.
|
|
787
|
+
*/
|
|
788
|
+
private reclaimMsgSeq;
|
|
789
|
+
/**
|
|
790
|
+
* Safety net that reclaims a msg_seq counter once its last holder is gone:
|
|
791
|
+
* the iteration domain is the counter map itself, so a
|
|
792
|
+
* counter whose only *naming* entry (session anchor, chat anchor,
|
|
793
|
+
* replyContextByMessageId routing entry, stream entry) was already deleted
|
|
794
|
+
* is still visited. It cannot make orphaning impossible — a counter can
|
|
795
|
+
* outlive its last holder between two 60s ticks — but it guarantees the
|
|
796
|
+
* counter is dropped on the next tick instead of leaking forever. Deleting
|
|
797
|
+
* from a Map while iterating its keys is safe. Returns whether anything was
|
|
798
|
+
* reclaimed.
|
|
799
|
+
*/
|
|
800
|
+
private reclaimOrphanMsgSeqCounters;
|
|
801
|
+
/** Mark an anchored send under msgId in flight for the release guard. */
|
|
802
|
+
private beginMsgSeqSend;
|
|
803
|
+
/** Clear the matching beginMsgSeqSend once the send has settled. */
|
|
804
|
+
private endMsgSeqSend;
|
|
805
|
+
/** Whether any live session is still anchored to this msgId. */
|
|
806
|
+
private isMsgIdAnchoredBySession;
|
|
807
|
+
/**
|
|
808
|
+
* Set replyMsgId for a chat, replacing any previous entry. The previous
|
|
809
|
+
* msgId's msg_seq counter is deliberately NOT dropped here (see the NOTE
|
|
810
|
+
* below): it is left to the TTL sweep, which reclaims it only once no
|
|
811
|
+
* holder remains.
|
|
812
|
+
*/
|
|
243
813
|
private setReplyMsgId;
|
|
244
814
|
private deleteReplyContext;
|
|
245
815
|
/**
|