@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.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* @see https://bot.q.qq.com/wiki/develop/api-v2/
|
|
13
13
|
*/
|
|
14
|
-
import { ChannelBase, SessionRouter, getGlobalQwenDir, sanitizeSenderName, sanitizePromptText, sanitizeLogText, truncateCodePoints, } from '@qwen-code/channel-base';
|
|
14
|
+
import { ChannelBase, SessionRouter, getGlobalQwenDir, sanitizeSenderName, sanitizePromptText, sanitizeLogText, singleScopeRoutingKey, truncateCodePoints, truncateUtf16Units, unwrapMessageRoutingKey, } from '@qwen-code/channel-base';
|
|
15
15
|
import WebSocket from 'ws';
|
|
16
16
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
17
17
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync, } from 'node:fs';
|
|
@@ -155,6 +155,14 @@ export class QQChannel extends ChannelBase {
|
|
|
155
155
|
inboundReplyContext = new AsyncLocalStorage();
|
|
156
156
|
/** msg_seq counter per user messageId, for multi-block streaming. */
|
|
157
157
|
msgSeqMap = new Map();
|
|
158
|
+
/**
|
|
159
|
+
* Anchored sends currently in flight, keyed by the msgId whose msg_seq
|
|
160
|
+
* counter they hold (refcounted: two sessions may send under one msgId).
|
|
161
|
+
* Covers sends that own no streamState entry — the terminal send in
|
|
162
|
+
* onResponseComplete and the stale-entry branch — which flushingSessions
|
|
163
|
+
* (session-keyed) cannot see.
|
|
164
|
+
*/
|
|
165
|
+
inFlightMsgSeqSends = new Map();
|
|
158
166
|
/** Periodic cleanup timer for expired replyMsgId entries. */
|
|
159
167
|
replyMsgIdCleanupTimer = null;
|
|
160
168
|
/** 5-minute TTL for replyMsgId entries and seenMessages dedup. */
|
|
@@ -165,8 +173,18 @@ export class QQChannel extends ChannelBase {
|
|
|
165
173
|
maxFlushRetries;
|
|
166
174
|
/** Retry delay for subsequent attempts (backoff beyond first retry). */
|
|
167
175
|
static IDLE_FLUSH_BACKOFF_MS = 4000;
|
|
176
|
+
/**
|
|
177
|
+
* Foreground ceiling for the completion-path final segment when
|
|
178
|
+
* maxFlushRetries is the documented "unlimited" (<= 0). The fire-and-forget
|
|
179
|
+
* flush paths may retry forever because no caller awaits them, but a
|
|
180
|
+
* completion send is awaited inside the turn's try, so its readiness to give
|
|
181
|
+
* up decides when that turn's finally runs and the session lock is released.
|
|
182
|
+
*/
|
|
183
|
+
static FINAL_SEGMENT_ROUTE_ATTEMPTS = 3;
|
|
168
184
|
/** Max buffer length before forcing an immediate flush. */
|
|
169
185
|
static MAX_BUFFER_LENGTH = 4096;
|
|
186
|
+
/** Purge records kept in the rescue file, oldest dropped first. */
|
|
187
|
+
static MAX_PURGE_RECORDS = 20;
|
|
170
188
|
// ── Group / cron fields ────────────────────────────────────────
|
|
171
189
|
/** Per-group bot OPENID map for multi-group support. */
|
|
172
190
|
botOpenIdByGroup = new Map();
|
|
@@ -214,7 +232,30 @@ export class QQChannel extends ChannelBase {
|
|
|
214
232
|
*/
|
|
215
233
|
// ── Streaming state ───────────────────────────────────────────
|
|
216
234
|
streamState = new Map();
|
|
217
|
-
|
|
235
|
+
/**
|
|
236
|
+
* Per-session reply msgId anchor, kept across idle-flush buffer windows.
|
|
237
|
+
* Set deterministically in onPromptStart from the triggering message's
|
|
238
|
+
* id (the same event.id setReplyMsgId stores) and released when the
|
|
239
|
+
* response completes, fails permanently, or the session dies. Entries
|
|
240
|
+
* carry a timestamp so readers (onResponseChunk / onResponseComplete)
|
|
241
|
+
* can drop anchors past the 5-minute TTL and fall back to the active
|
|
242
|
+
* send path — a slow turn must never keep sending chunks with an
|
|
243
|
+
* expired msg_id. Because it outlives individual streamState entries, a
|
|
244
|
+
* concurrent message in the same chat that overwrites the chat-level
|
|
245
|
+
* replyMsgId entry mid-stream cannot re-parent this session's later
|
|
246
|
+
* chunks onto its own msg_id (see PR #6457 review).
|
|
247
|
+
*/
|
|
248
|
+
sessionReplyMsgId = new Map();
|
|
249
|
+
/**
|
|
250
|
+
* Monotonic turn counter per session, bumped on every onPromptStart.
|
|
251
|
+
* streamState entries carry the turn they were created in; when a new
|
|
252
|
+
* prompt starts on a session whose previous turn left a streamState entry
|
|
253
|
+
* behind (deferred send still settling), onResponseChunk uses this counter
|
|
254
|
+
* to detect and drop the stale entry so the new turn's chunks cannot be
|
|
255
|
+
* appended to the old turn's buffer or delivered under its msgId.
|
|
256
|
+
*/
|
|
257
|
+
turnCounter = new Map();
|
|
258
|
+
flushingSessions = new Map();
|
|
218
259
|
pendingStreamDelete = new Set();
|
|
219
260
|
_reconnectId = 0;
|
|
220
261
|
flushedSessions = new Set();
|
|
@@ -231,6 +272,62 @@ export class QQChannel extends ChannelBase {
|
|
|
231
272
|
* finally, even on error/cancel), independent of streaming config.
|
|
232
273
|
*/
|
|
233
274
|
activePromptSessions = new Set();
|
|
275
|
+
/**
|
|
276
|
+
* Side buffer for chunks arriving while a previous turn's deferred flush
|
|
277
|
+
* chain still owns the session's streamState entry (the stale parked
|
|
278
|
+
* early-return in onResponseChunk). Each entry records the turn that owns
|
|
279
|
+
* its text, so a teardown can tell a live turn's stashed HEAD — which must
|
|
280
|
+
* still be delivered — from a superseded turn's leftovers, which are
|
|
281
|
+
* discarded with a log. The chunks are prepended to the owning turn's next
|
|
282
|
+
* state entry once the chain settles and frees the entry, so the HEAD of a
|
|
283
|
+
* reply is never silently dropped during the chain's settle window (up to
|
|
284
|
+
* ~10s under rate-limit backoff). A teardown may only drop an entry whose
|
|
285
|
+
* turn is not the live turn. The consumption sites are onResponseChunk
|
|
286
|
+
* (the next turn's first chunk folds the stash in), onResponseComplete (the
|
|
287
|
+
* owning turn's completion prepends the sealed `pre`), and onPromptEnd's two
|
|
288
|
+
* branches: a cancelled turn merges the stash into its residual buffer, or —
|
|
289
|
+
* when a parked chain can still deliver it — hands it to
|
|
290
|
+
* deliverCancelledStash instead.
|
|
291
|
+
* `pre` seals the portion accumulated before the most recent
|
|
292
|
+
* responseBoundary: the bridge accumulates every textChunk and only a
|
|
293
|
+
* boundary clears that collection, so at completion only that sealed
|
|
294
|
+
* portion is missing from fullText — the post-boundary remainder is already
|
|
295
|
+
* there and must not be prepended a second time. It is absent until a
|
|
296
|
+
* boundary fires for the entry. Entries are cleared when the session dies
|
|
297
|
+
* or the channel disconnects.
|
|
298
|
+
*/
|
|
299
|
+
streamOrphanBuffer = new Map();
|
|
300
|
+
/**
|
|
301
|
+
* The turn whose onResponseComplete has already run, per session. The
|
|
302
|
+
* completion that prepends a turn's stash cannot run a second time, so a
|
|
303
|
+
* stash tagged with that turn has no guaranteed consumer, and
|
|
304
|
+
* handOffSealedPre delivers its text directly instead of writing it. Dropped
|
|
305
|
+
* when a new turn starts, on session death, and on disconnect; onPromptEnd's
|
|
306
|
+
* terminal teardown drops it unless a chain still holds the session's flush
|
|
307
|
+
* marker, and a flush chain's terminal settle drops it when it still owns the
|
|
308
|
+
* generation (deleteTurnGenerationIfOwned). A teardown that finds a live
|
|
309
|
+
* marker keeps the record so that chain's handOffSealedPre can still read it;
|
|
310
|
+
* because that teardown also clears the marker, the chain's own .finally
|
|
311
|
+
* returns on its ownership check and the record is instead dropped by the
|
|
312
|
+
* next onPromptStart, onSessionDied, or disconnect.
|
|
313
|
+
*/
|
|
314
|
+
completedTurns = new Map();
|
|
315
|
+
/**
|
|
316
|
+
* Bridge-side `responseBoundary` observer. ChannelBase's own listener returns
|
|
317
|
+
* early while a cancel is pending, so onResponseBoundary can miss a boundary
|
|
318
|
+
* even though the bridge still cleared its chunk collection — the stash's
|
|
319
|
+
* `pre` would then never be sealed and the diverted head would be absent from
|
|
320
|
+
* both state.buffer and fullText. Observing the bridge event directly (the
|
|
321
|
+
* same emit the bridge's clearChunks listens to) is ungated.
|
|
322
|
+
*/
|
|
323
|
+
_bridgeBoundarySeal = (sessionId) => {
|
|
324
|
+
this.sealOrphanStash(sessionId);
|
|
325
|
+
// Ungated: ChannelBase suppresses the adapter hook while a cancel is
|
|
326
|
+
// pending, but the bridge still clears its collection, so this path must
|
|
327
|
+
// reach the same seal the hook would.
|
|
328
|
+
this.captureBoundaryClear(sessionId);
|
|
329
|
+
};
|
|
330
|
+
bridgeBoundarySealAttached = false;
|
|
234
331
|
qqStatePath;
|
|
235
332
|
/**
|
|
236
333
|
* Path to the global sessions.json managed by start.ts.
|
|
@@ -239,19 +336,62 @@ export class QQChannel extends ChannelBase {
|
|
|
239
336
|
globalSessionsPath;
|
|
240
337
|
/** Backup of sessions.json so conversations survive daemon restarts. */
|
|
241
338
|
sessionsBackupPath;
|
|
339
|
+
/**
|
|
340
|
+
* Append-only rescue log of the routes the orphan purge deletes, written
|
|
341
|
+
* immediately before the first deletion when `purgeLegacySessions` is on, so
|
|
342
|
+
* an operator can restore a legacy conversation by hand.
|
|
343
|
+
*/
|
|
344
|
+
sessionsPurgedPath;
|
|
242
345
|
constructor(name, config, bridge, options) {
|
|
243
346
|
const safeName = name.replace(/[^A-Za-z0-9_-]/g, '_');
|
|
244
347
|
const stateDir = join(getGlobalQwenDir(), 'channels');
|
|
245
348
|
mkdirSync(stateDir, { recursive: true });
|
|
246
349
|
const sessionsPath = join(stateDir, `${safeName}-sessions.json`);
|
|
247
|
-
// groupAllPolicy 'keyword' or 'all' requires
|
|
248
|
-
//
|
|
350
|
+
// groupAllPolicy 'keyword' or 'all' requires per-group shared context.
|
|
351
|
+
// 'thread' is the only scope that gives it without collateral: it shares
|
|
352
|
+
// each group's session (routing key = channel:chatId) while keeping every
|
|
353
|
+
// direct message per-user. 'chat_thread' routes groups identically but
|
|
354
|
+
// ChannelBase treats it as unconditionally shared, so every DM becomes a
|
|
355
|
+
// shared session too (no operator can /clear it, and a DM tool call has no
|
|
356
|
+
// answerer). 'single' is shared as well but collapses every group and every
|
|
357
|
+
// DM into one `channel:__single__` context — the cross-group leakage issue
|
|
358
|
+
// #8238 describes. 'user' fragments group messages per sender. We warn but
|
|
359
|
+
// do NOT force the scope: forcibly flattening the user's multi-level
|
|
360
|
+
// session isolation into a global single session was incorrect (see
|
|
361
|
+
// PR #6457 review). The user's sessionScope choice wins.
|
|
249
362
|
const qqCfg = config;
|
|
250
363
|
if ((qqCfg.groupAllPolicy === 'keyword' || qqCfg.groupAllPolicy === 'all') &&
|
|
251
|
-
config.sessionScope !== '
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
364
|
+
config.sessionScope !== 'thread') {
|
|
365
|
+
// multiSession pins sessionScope to 'user' (enforced by the channel
|
|
366
|
+
// parser), so the scope advice below would tell the operator to set a
|
|
367
|
+
// combination the parser rejects on the next start. Name the conflict
|
|
368
|
+
// instead and leave the scope alone.
|
|
369
|
+
process.stderr.write(config.multiSession
|
|
370
|
+
? `[QQ:${name}] WARNING: groupAllPolicy is '${qqCfg.groupAllPolicy}' but sessionScope is '${config.sessionScope}'. multiSession requires sessionScope 'user', so groupAllPolicy keyword/all and multiSession are mutually exclusive — groupAllPolicy keyword/all is not supported with multiSession enabled; change groupAllPolicy or turn multiSession off.\n`
|
|
371
|
+
: `[QQ:${name}] WARNING: groupAllPolicy is '${qqCfg.groupAllPolicy}' but sessionScope is '${config.sessionScope}' (not 'thread'). groupAllPolicy keyword/all needs sessionScope: 'thread' for per-group shared context with each direct message kept private; 'chat_thread' routes groups the same but makes every direct message a shared session, and 'single' merges every group and direct message into one context. With 'user', group messages fragment per sender.\n`);
|
|
372
|
+
}
|
|
373
|
+
// A shared-scope session is operated only by config.operators (see
|
|
374
|
+
// ChannelBase.isSharedSessionOperator) — membership grants no
|
|
375
|
+
// session-control rights. Warn whenever such a session exists and no
|
|
376
|
+
// operator is configured: permission requests, the session-control
|
|
377
|
+
// commands (/clear, /cancel, /who, /status, /loop, /btw, /approve*, /deny)
|
|
378
|
+
// and steering an in-flight turn are then unreachable, with no other
|
|
379
|
+
// signal. Group access is not required for the lockout: 'single' and
|
|
380
|
+
// 'chat_thread' share direct-message sessions too, so a DM-only channel on
|
|
381
|
+
// those scopes has the same dead end ('thread' shares only group sessions,
|
|
382
|
+
// because a direct message is one chat with one sender).
|
|
383
|
+
const sharedScope = config.sessionScope === 'thread' ||
|
|
384
|
+
config.sessionScope === 'chat_thread' ||
|
|
385
|
+
config.sessionScope === 'single';
|
|
386
|
+
const dmSharedScope = config.sessionScope === 'single' || config.sessionScope === 'chat_thread';
|
|
387
|
+
const groupAccess = config.groupPolicy !== undefined && config.groupPolicy !== 'disabled';
|
|
388
|
+
if (sharedScope &&
|
|
389
|
+
(groupAccess || dmSharedScope) &&
|
|
390
|
+
(config.operators ?? []).length === 0) {
|
|
391
|
+
const where = groupAccess
|
|
392
|
+
? `group access is enabled with shared sessionScope '${config.sessionScope}'`
|
|
393
|
+
: `sessionScope '${config.sessionScope}' makes every session shared, direct messages included`;
|
|
394
|
+
process.stderr.write(`[QQ:${name}] WARNING: ${where}, but no operators are configured — no one can answer permission requests, run session-control commands, or steer an in-flight turn until 'operators' is set.\n`);
|
|
255
395
|
}
|
|
256
396
|
const router = options?.router ??
|
|
257
397
|
new SessionRouter(bridge, config.cwd, config.sessionScope, sessionsPath);
|
|
@@ -276,6 +416,7 @@ export class QQChannel extends ChannelBase {
|
|
|
276
416
|
? join(stateDir, 'sessions.json')
|
|
277
417
|
: sessionsPath;
|
|
278
418
|
this.sessionsBackupPath = join(stateDir, `${safeName}-sessions-backup.json`);
|
|
419
|
+
this.sessionsPurgedPath = join(stateDir, `${safeName}-sessions-purged.json`);
|
|
279
420
|
// Permanent textChunk listener for cron/non-prompt messages.
|
|
280
421
|
// ChannelBase's prompt-path textChunk listener is only alive during
|
|
281
422
|
// bridge.prompt(). Cron messages bypass prompt() so their textChunk
|
|
@@ -284,6 +425,9 @@ export class QQChannel extends ChannelBase {
|
|
|
284
425
|
this._cronTextHandler = (sid, t) => this.handleCronTextChunk(sid, t);
|
|
285
426
|
this.attachCronHandler();
|
|
286
427
|
}
|
|
428
|
+
// Seal a stashed head on every bridge boundary, including the ones
|
|
429
|
+
// ChannelBase suppresses while a cancel is pending.
|
|
430
|
+
this.attachBridgeBoundarySeal();
|
|
287
431
|
}
|
|
288
432
|
handleCronTextChunk(sessionId, text) {
|
|
289
433
|
const wasInCronFlow = this._inCronFlow > 0;
|
|
@@ -318,124 +462,211 @@ export class QQChannel extends ChannelBase {
|
|
|
318
462
|
const limit = this.qqConfig.bufferFlushLength ?? QQChannel.MAX_BUFFER_LENGTH;
|
|
319
463
|
const delay = entry.buffer.length >= limit ? 0 : 2000;
|
|
320
464
|
entry.timer = setTimeout(() => {
|
|
321
|
-
const toFlush = entry.buffer;
|
|
322
|
-
entry.buffer = '';
|
|
323
465
|
entry.timer = null;
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
466
|
+
this.flushCronEntry(sessionId, entry);
|
|
467
|
+
}, delay).unref();
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Flush `entry`'s accumulated cron text through the delivery ladder: one
|
|
472
|
+
* send plus its two bounded retries. A successful attempt deletes the
|
|
473
|
+
* entry when its buffer is empty, or re-arms the normal flush when a chunk
|
|
474
|
+
* arrived while the send was in flight.
|
|
475
|
+
*/
|
|
476
|
+
flushCronEntry(sessionId, entry) {
|
|
477
|
+
const toFlush = entry.buffer;
|
|
478
|
+
entry.buffer = '';
|
|
479
|
+
if (toFlush) {
|
|
480
|
+
const target = this.router.getTarget(sessionId);
|
|
481
|
+
if (target) {
|
|
482
|
+
this.sendMessageWithReplyContext(target.chatId, toFlush)
|
|
483
|
+
.then((blocked) => {
|
|
484
|
+
// A route-blocked send resolves without reaching the wire (see
|
|
485
|
+
// resolveRoute), so the success body below would delete the
|
|
486
|
+
// entry and silently lose toFlush. Route the block into the
|
|
487
|
+
// .catch() below, which already retries a transient block and
|
|
488
|
+
// drops a permanent one with a log.
|
|
489
|
+
if (blocked !== undefined) {
|
|
490
|
+
throw blocked === 'permanent'
|
|
491
|
+
? new DeliveryError('FALLBACK_FAILED', `outgoing route permanently blocked for ${target.chatId}`)
|
|
492
|
+
: new Error('outgoing route unresolved');
|
|
493
|
+
}
|
|
494
|
+
this.settleCronFlushSuccess(sessionId, entry);
|
|
495
|
+
})
|
|
496
|
+
.catch((err) => {
|
|
497
|
+
const code = err instanceof DeliveryError ? err.code : null;
|
|
498
|
+
const codeStr = code ? ` (${code})` : '';
|
|
499
|
+
process.stderr.write(`[QQ:${this.name}] Cron flush send error${codeStr}: ${sanitizeLogText(err instanceof Error ? err.message : String(err), 200)}\n`);
|
|
500
|
+
if (code === 'RETRY_EXHAUSTED' ||
|
|
501
|
+
code === 'ACTIVE_MSG_DISABLED' ||
|
|
502
|
+
code === 'FALLBACK_FAILED') {
|
|
503
|
+
this.dropCronEntryOrRearm(sessionId, entry);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
entry.pendingRetry = toFlush;
|
|
507
|
+
entry.retryCount = 1;
|
|
508
|
+
if (entry.timer) {
|
|
509
|
+
clearTimeout(entry.timer);
|
|
510
|
+
}
|
|
511
|
+
entry.timer = setTimeout(() => {
|
|
512
|
+
entry.timer = null;
|
|
513
|
+
entry.pendingRetry = '';
|
|
514
|
+
// Guard: if cronBuffer entry was removed (e.g., group deleted),
|
|
515
|
+
// bail out instead of sending to a deleted group.
|
|
516
|
+
if (this.cronBuffer.get(sessionId) !== entry) {
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
const retryTarget = this.router.getTarget(sessionId);
|
|
520
|
+
if (!retryTarget) {
|
|
521
|
+
process.stderr.write(`[QQ:${this.name}] Cron flush dropped after retry: no target for session ${sanitizeLogText(sessionId, 32)}` +
|
|
522
|
+
(entry.buffer
|
|
523
|
+
? `, kept ${entry.buffer.length} chars that arrived during the retries for a later flush\n`
|
|
524
|
+
: '\n'));
|
|
525
|
+
this.dropCronEntryOrRearm(sessionId, entry);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
this.sendMessageWithReplyContext(retryTarget.chatId, toFlush)
|
|
529
|
+
.then((blocked) => {
|
|
530
|
+
// A route-blocked send resolves without reaching the wire,
|
|
531
|
+
// so throw it into the .catch() below instead of running
|
|
532
|
+
// the success body and dropping toFlush.
|
|
533
|
+
if (blocked !== undefined) {
|
|
534
|
+
throw blocked === 'permanent'
|
|
535
|
+
? new DeliveryError('FALLBACK_FAILED', `outgoing route permanently blocked for ${retryTarget.chatId}`)
|
|
536
|
+
: new Error('outgoing route unresolved');
|
|
537
|
+
}
|
|
538
|
+
entry.pendingRetry = '';
|
|
539
|
+
this.settleCronFlushSuccess(sessionId, entry);
|
|
331
540
|
})
|
|
332
|
-
.catch((
|
|
333
|
-
const
|
|
334
|
-
const
|
|
335
|
-
process.stderr.write(`[QQ:${this.name}] Cron flush
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
541
|
+
.catch((retryErr) => {
|
|
542
|
+
const retryCode = retryErr instanceof DeliveryError ? retryErr.code : null;
|
|
543
|
+
const retryCodeStr = retryCode ? ` (${retryCode})` : '';
|
|
544
|
+
process.stderr.write(`[QQ:${this.name}] Cron flush retry failed${retryCodeStr}: ${sanitizeLogText(retryErr instanceof Error ? retryErr.message : String(retryErr), 200)}\n`);
|
|
545
|
+
entry.pendingRetry = '';
|
|
546
|
+
if (retryCode === 'RETRY_EXHAUSTED' ||
|
|
547
|
+
retryCode === 'ACTIVE_MSG_DISABLED' ||
|
|
548
|
+
retryCode === 'FALLBACK_FAILED') {
|
|
549
|
+
this.dropCronEntryOrRearm(sessionId, entry);
|
|
340
550
|
return;
|
|
341
551
|
}
|
|
342
|
-
|
|
343
|
-
entry.retryCount = 1;
|
|
552
|
+
// Transient error on retry (RATE_LIMITED, etc.) — re-schedule with backoff
|
|
553
|
+
entry.retryCount = (entry.retryCount ?? 1) + 1;
|
|
344
554
|
if (entry.timer) {
|
|
345
555
|
clearTimeout(entry.timer);
|
|
346
556
|
}
|
|
557
|
+
entry.pendingRetry = toFlush;
|
|
347
558
|
entry.timer = setTimeout(() => {
|
|
348
559
|
entry.timer = null;
|
|
349
560
|
entry.pendingRetry = '';
|
|
350
|
-
// Guard: if cronBuffer entry was removed (e.g., group deleted),
|
|
351
|
-
// bail out instead of sending to a deleted group.
|
|
352
561
|
if (this.cronBuffer.get(sessionId) !== entry) {
|
|
353
562
|
return;
|
|
354
563
|
}
|
|
355
|
-
const
|
|
356
|
-
if (!
|
|
357
|
-
process.stderr.write(`[QQ:${this.name}] Cron flush dropped after retry: no target for session ${sanitizeLogText(sessionId, 32)}
|
|
358
|
-
|
|
564
|
+
const retryTarget2 = this.router.getTarget(sessionId);
|
|
565
|
+
if (!retryTarget2) {
|
|
566
|
+
process.stderr.write(`[QQ:${this.name}] Cron flush dropped after retry: no target for session ${sanitizeLogText(sessionId, 32)}` +
|
|
567
|
+
(entry.buffer
|
|
568
|
+
? `, kept ${entry.buffer.length} chars that arrived during the retries for a later flush\n`
|
|
569
|
+
: '\n'));
|
|
570
|
+
this.dropCronEntryOrRearm(sessionId, entry);
|
|
359
571
|
return;
|
|
360
572
|
}
|
|
361
|
-
this.sendMessageWithReplyContext(
|
|
362
|
-
.then(() => {
|
|
573
|
+
this.sendMessageWithReplyContext(retryTarget2.chatId, toFlush)
|
|
574
|
+
.then((blocked) => {
|
|
575
|
+
// Same as the first retry: a route-blocked send
|
|
576
|
+
// resolves without reaching the wire, so throw it
|
|
577
|
+
// into the .catch() below rather than running the
|
|
578
|
+
// success body and dropping toFlush.
|
|
579
|
+
if (blocked !== undefined) {
|
|
580
|
+
throw blocked === 'permanent'
|
|
581
|
+
? new DeliveryError('FALLBACK_FAILED', `outgoing route permanently blocked for ${retryTarget2.chatId}`)
|
|
582
|
+
: new Error('outgoing route unresolved');
|
|
583
|
+
}
|
|
363
584
|
entry.pendingRetry = '';
|
|
364
|
-
|
|
365
|
-
this.cronBuffer.get(sessionId) === entry)
|
|
366
|
-
this.cronBuffer.delete(sessionId);
|
|
585
|
+
this.settleCronFlushSuccess(sessionId, entry);
|
|
367
586
|
})
|
|
368
|
-
.catch((
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
const retryCodeStr = retryCode ? ` (${retryCode})` : '';
|
|
373
|
-
process.stderr.write(`[QQ:${this.name}] Cron flush retry failed${retryCodeStr}: ${sanitizeLogText(retryErr instanceof Error ? retryErr.message : String(retryErr), 200)}\n`);
|
|
587
|
+
.catch((err2) => {
|
|
588
|
+
const code2 = err2 instanceof DeliveryError ? err2.code : null;
|
|
589
|
+
const code2Str = code2 ? ` (${code2})` : '';
|
|
590
|
+
process.stderr.write(`[QQ:${this.name}] Cron flush re-retry failed${code2Str}: ${sanitizeLogText(err2 instanceof Error ? err2.message : String(err2), 200)}, toFlush=${toFlush.length}, session=${sanitizeLogText(sessionId, 32)}\n`);
|
|
374
591
|
entry.pendingRetry = '';
|
|
375
|
-
if (
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
this.cronBuffer.get(sessionId) === entry) {
|
|
380
|
-
this.cronBuffer.delete(sessionId);
|
|
381
|
-
}
|
|
592
|
+
if (code2 === 'RETRY_EXHAUSTED' ||
|
|
593
|
+
code2 === 'ACTIVE_MSG_DISABLED' ||
|
|
594
|
+
code2 === 'FALLBACK_FAILED') {
|
|
595
|
+
this.dropCronEntryOrRearm(sessionId, entry);
|
|
382
596
|
return;
|
|
383
597
|
}
|
|
384
|
-
// Transient error
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
const retryTarget2 = this.router.getTarget(sessionId);
|
|
397
|
-
if (!retryTarget2) {
|
|
398
|
-
process.stderr.write(`[QQ:${this.name}] Cron flush dropped after retry: no target for session ${sanitizeLogText(sessionId, 32)}\n`);
|
|
399
|
-
this.cronBuffer.delete(sessionId);
|
|
400
|
-
return;
|
|
401
|
-
}
|
|
402
|
-
this.sendMessageWithReplyContext(retryTarget2.chatId, toFlush)
|
|
403
|
-
.then(() => {
|
|
404
|
-
entry.pendingRetry = '';
|
|
405
|
-
if (!entry.buffer &&
|
|
406
|
-
this.cronBuffer.get(sessionId) === entry)
|
|
407
|
-
this.cronBuffer.delete(sessionId);
|
|
408
|
-
})
|
|
409
|
-
.catch((err2) => {
|
|
410
|
-
const code2 = err2 instanceof DeliveryError
|
|
411
|
-
? err2.code
|
|
412
|
-
: null;
|
|
413
|
-
const code2Str = code2 ? ` (${code2})` : '';
|
|
414
|
-
process.stderr.write(`[QQ:${this.name}] Cron flush re-retry failed${code2Str}: ${sanitizeLogText(err2 instanceof Error ? err2.message : String(err2), 200)}, toFlush=${toFlush.length}, session=${sanitizeLogText(sessionId, 32)}\n`);
|
|
415
|
-
entry.pendingRetry = '';
|
|
416
|
-
if (code2 === 'RETRY_EXHAUSTED' ||
|
|
417
|
-
code2 === 'ACTIVE_MSG_DISABLED' ||
|
|
418
|
-
code2 === 'FALLBACK_FAILED') {
|
|
419
|
-
this.cronBuffer.delete(sessionId);
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
422
|
-
// Transient error: retries exhausted after 3 total attempts
|
|
423
|
-
process.stderr.write(`[QQ:${this.name}] Cron flush retries exhausted, dropped ${toFlush.length} chars for session ${sanitizeLogText(sessionId, 32)}\n`);
|
|
424
|
-
this.cronBuffer.delete(sessionId);
|
|
425
|
-
});
|
|
426
|
-
}, entry.retryCount === 2 ? 10000 : 5000);
|
|
427
|
-
entry.timer.unref();
|
|
598
|
+
// Transient error: retries exhausted after 3 total
|
|
599
|
+
// attempts. Text that arrived while the failed sends
|
|
600
|
+
// were in flight was never attempted, so report it as
|
|
601
|
+
// kept for the re-armed flush rather than dropped.
|
|
602
|
+
const residual = this.cronBuffer.get(sessionId) === entry
|
|
603
|
+
? entry.buffer.length
|
|
604
|
+
: 0;
|
|
605
|
+
process.stderr.write(`[QQ:${this.name}] Cron flush retries exhausted, dropped ${toFlush.length} chars for session ${sanitizeLogText(sessionId, 32)}` +
|
|
606
|
+
(residual > 0
|
|
607
|
+
? `, kept ${residual} chars that arrived during the retries for a later flush\n`
|
|
608
|
+
: '\n'));
|
|
609
|
+
this.dropCronEntryOrRearm(sessionId, entry);
|
|
428
610
|
});
|
|
429
|
-
}, 5000);
|
|
611
|
+
}, entry.retryCount === 2 ? 10000 : 5000);
|
|
430
612
|
entry.timer.unref();
|
|
431
613
|
});
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
});
|
|
614
|
+
}, 5000);
|
|
615
|
+
entry.timer.unref();
|
|
616
|
+
});
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
process.stderr.write(`[QQ:${this.name}] Cron flush dropped: no target for session ${sanitizeLogText(sessionId, 32)}, lost ${toFlush.length} chars\n`);
|
|
621
|
+
this.cronBuffer.delete(sessionId);
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Terminal failure of a cron flush. Text that arrived while the failed send
|
|
625
|
+
* was in flight was never attempted, so it must not be dropped with the
|
|
626
|
+
* entry: re-arm the normal flush for that residual, exactly like the success
|
|
627
|
+
* tail. An entry that no longer owns the session was already replaced, so its
|
|
628
|
+
* successor's text is left alone, and an entry with an empty residual has
|
|
629
|
+
* nothing left to deliver — both go.
|
|
630
|
+
*/
|
|
631
|
+
dropCronEntryOrRearm(sessionId, entry) {
|
|
632
|
+
if (this.cronBuffer.get(sessionId) !== entry)
|
|
633
|
+
return;
|
|
634
|
+
if (entry.buffer) {
|
|
635
|
+
this.armCronFlush(sessionId, entry);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
this.cronBuffer.delete(sessionId);
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Tail of a successful cron send. If the entry still owns the session and
|
|
642
|
+
* a chunk arrived while the send was in flight, arm the normal flush for
|
|
643
|
+
* that residual; otherwise drop the entry once its buffer is empty.
|
|
644
|
+
*/
|
|
645
|
+
settleCronFlushSuccess(sessionId, entry) {
|
|
646
|
+
if (this.cronBuffer.get(sessionId) !== entry)
|
|
647
|
+
return;
|
|
648
|
+
if (entry.buffer) {
|
|
649
|
+
this.armCronFlush(sessionId, entry);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
this.cronBuffer.delete(sessionId);
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Arm the normal 2s idle flush (immediate at the buffer length limit) for
|
|
656
|
+
* `entry`, unless one is already armed. The callback re-checks that the
|
|
657
|
+
* entry still owns the session before flushing.
|
|
658
|
+
*/
|
|
659
|
+
armCronFlush(sessionId, entry) {
|
|
660
|
+
if (entry.timer)
|
|
661
|
+
return;
|
|
662
|
+
const limit = this.qqConfig.bufferFlushLength ?? QQChannel.MAX_BUFFER_LENGTH;
|
|
663
|
+
const delay = entry.buffer.length >= limit ? 0 : 2000;
|
|
664
|
+
entry.timer = setTimeout(() => {
|
|
665
|
+
entry.timer = null;
|
|
666
|
+
if (this.cronBuffer.get(sessionId) !== entry)
|
|
667
|
+
return;
|
|
668
|
+
this.flushCronEntry(sessionId, entry);
|
|
669
|
+
}, delay).unref();
|
|
439
670
|
}
|
|
440
671
|
/**
|
|
441
672
|
* Public gate for external cron/scheduler integration.
|
|
@@ -461,8 +692,10 @@ export class QQChannel extends ChannelBase {
|
|
|
461
692
|
*/
|
|
462
693
|
setBridge(bridge) {
|
|
463
694
|
this.detachCronHandler();
|
|
695
|
+
this.detachBridgeBoundarySeal();
|
|
464
696
|
super.setBridge(bridge);
|
|
465
697
|
this.attachCronHandler();
|
|
698
|
+
this.attachBridgeBoundarySeal();
|
|
466
699
|
}
|
|
467
700
|
// ── ChannelBase interface ──────────────────────────────────────
|
|
468
701
|
async connect() {
|
|
@@ -580,30 +813,69 @@ export class QQChannel extends ChannelBase {
|
|
|
580
813
|
: undefined;
|
|
581
814
|
await this.sendMessageWithReplyContext(chatId, text, replyContext, sourceLabel);
|
|
582
815
|
}
|
|
583
|
-
|
|
816
|
+
/**
|
|
817
|
+
* The reply context a session's next response would carry: the inbound
|
|
818
|
+
* message the turn is answering, resolved through the turn-scoped
|
|
819
|
+
* activePrompts entry. Read by sendResponseMessage and captured once by
|
|
820
|
+
* deliverCancelledStash, which re-attempts a delivery after that entry may
|
|
821
|
+
* already belong to a successor turn.
|
|
822
|
+
*/
|
|
823
|
+
resolveResponseReplyContext(sessionId) {
|
|
584
824
|
const messageId = this.getResponseMessageId(sessionId);
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
await this.sendMessageWithReplyContext(chatId, text,
|
|
825
|
+
return messageId ? this.replyContextByMessageId.get(messageId) : undefined;
|
|
826
|
+
}
|
|
827
|
+
async sendResponseMessage(chatId, text, sessionId, sourceLabel) {
|
|
828
|
+
await this.sendMessageWithReplyContext(chatId, text, this.resolveResponseReplyContext(sessionId), sourceLabel ?? this.getResponseSourceLabel(sessionId));
|
|
589
829
|
}
|
|
590
|
-
async sendMessageWithReplyContext(chatId, text, replyContext, sourceLabel) {
|
|
830
|
+
async sendMessageWithReplyContext(chatId, text, replyContext, sourceLabel, msgIdOverride, msgIdOverrideTimestamp) {
|
|
591
831
|
// <noreply> suppression
|
|
592
832
|
if (text.trim() === '<noreply>') {
|
|
593
833
|
process.stderr.write(`[QQ:${this.name}] <noreply> skipped for ${sanitizeLogText(chatId, 64)}\n`);
|
|
594
|
-
return;
|
|
834
|
+
return undefined;
|
|
595
835
|
}
|
|
596
836
|
const outgoingText = this.formatMarkdownAttributedText(text, sourceLabel);
|
|
597
837
|
const plainOutgoingText = this.formatAttributedText(text, sourceLabel);
|
|
598
838
|
const route = await this.resolveRoute(chatId);
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
839
|
+
// resolveRoute names the class next to the guard that produced it; the
|
|
840
|
+
// caller only forwards it, so a new guard cannot silently default to a
|
|
841
|
+
// retryable class here.
|
|
842
|
+
if ('block' in route)
|
|
843
|
+
return route.block;
|
|
844
|
+
// msgIdOverride is the per-session reply anchor captured when a streaming
|
|
845
|
+
// response started (PR #8241). It takes precedence over the reply context
|
|
846
|
+
// so a concurrent message (same chat, other user) that overwrote the
|
|
847
|
+
// chat-level entry mid-stream cannot re-parent this session's chunks onto
|
|
848
|
+
// that message. When absent, use the explicit reply context — the
|
|
849
|
+
// async-local inbound context, the active prompt's message, or the
|
|
850
|
+
// chat-level latest entry resolved by the caller.
|
|
851
|
+
//
|
|
852
|
+
// The override is TTL-checked against its capture time, exactly like the
|
|
853
|
+
// reply-context entry below: a streamState entry can outlive the 300s
|
|
854
|
+
// window on the deferred/parked paths, and its anchor must then expire to
|
|
855
|
+
// an unanchored active send (a slow turn must never keep sending chunks
|
|
856
|
+
// with an expired msg_id). A supplied override always forces `entry`
|
|
857
|
+
// undefined — an expired one must NOT fall back to the reply context or
|
|
858
|
+
// the chat-level entry, which would re-parent the send onto the message
|
|
859
|
+
// this parameter exists to avoid.
|
|
860
|
+
const overrideFresh = msgIdOverride !== undefined &&
|
|
861
|
+
msgIdOverrideTimestamp !== undefined &&
|
|
862
|
+
Date.now() - msgIdOverrideTimestamp < QQChannel.REPLY_MSG_ID_TTL_MS;
|
|
863
|
+
const entry = msgIdOverride !== undefined || replyContext?.chatId !== chatId
|
|
864
|
+
? undefined
|
|
865
|
+
: replyContext;
|
|
866
|
+
const msgId = overrideFresh
|
|
867
|
+
? msgIdOverride
|
|
868
|
+
: entry && Date.now() - entry.timestamp < QQChannel.REPLY_MSG_ID_TTL_MS
|
|
869
|
+
? entry.msgId
|
|
870
|
+
: undefined;
|
|
871
|
+
if (msgIdOverride !== undefined && !overrideFresh) {
|
|
872
|
+
process.stderr.write(`[QQ:${this.name}] per-session reply anchor expired for ${sanitizeLogText(chatId, 64)}, sending without msg_id\n`);
|
|
873
|
+
}
|
|
605
874
|
if (entry && !msgId) {
|
|
606
875
|
process.stderr.write(`[QQ:${this.name}] replyMsgId entry expired for ${sanitizeLogText(chatId, 64)}, reply context expired, sending without msg_id\n`);
|
|
876
|
+
// A streaming reply anchored to this msgId may still be in flight
|
|
877
|
+
// (per-session msgId): deleteReplyContext keeps its msg_seq counter
|
|
878
|
+
// alive while a live session is still anchored to it.
|
|
607
879
|
this.deleteReplyContext(entry);
|
|
608
880
|
this.saveQQState();
|
|
609
881
|
}
|
|
@@ -643,7 +915,13 @@ export class QQChannel extends ChannelBase {
|
|
|
643
915
|
if (resp.status === 429) {
|
|
644
916
|
process.stderr.write(`[QQ:${this.name}] MESSAGE DROPPED: rate-limited (429) on markdown attempt for ${sanitizeLogText(chatId, 64)}\n`);
|
|
645
917
|
if (msgId) {
|
|
646
|
-
|
|
918
|
+
// Only roll back our own seq: a concurrent send under the same
|
|
919
|
+
// msgId (two sessions can share one) may have advanced the counter
|
|
920
|
+
// past ours, and restoring nextSeq - 1 would forget the seq that
|
|
921
|
+
// send accepted, making later sends replay a pair QQ dedupes.
|
|
922
|
+
if (this.msgSeqMap.get(msgId) === nextSeq) {
|
|
923
|
+
this.msgSeqMap.set(msgId, nextSeq - 1);
|
|
924
|
+
}
|
|
647
925
|
this.saveQQState();
|
|
648
926
|
}
|
|
649
927
|
throw new DeliveryError('RATE_LIMITED', `Message blocked by rate limit for ${sanitizeLogText(chatId, 64)}`);
|
|
@@ -651,7 +929,11 @@ export class QQChannel extends ChannelBase {
|
|
|
651
929
|
// Passive markdown failed (non-429). If we have msgId, roll back
|
|
652
930
|
// and try active retries (no msg_id/msg_seq).
|
|
653
931
|
if (msgId) {
|
|
654
|
-
|
|
932
|
+
// Conditional rollback: see the 429 branch above — a concurrent
|
|
933
|
+
// anchored send may already have advanced the counter past ours.
|
|
934
|
+
if (this.msgSeqMap.get(msgId) === nextSeq) {
|
|
935
|
+
this.msgSeqMap.set(msgId, nextSeq - 1);
|
|
936
|
+
}
|
|
655
937
|
rollbackApplied = true;
|
|
656
938
|
// Check if active messages are allowed for this chat
|
|
657
939
|
if (this.groupActiveMsgEnabled.get(chatId) === false) {
|
|
@@ -669,7 +951,7 @@ export class QQChannel extends ChannelBase {
|
|
|
669
951
|
process.stderr.write(`[QQ:${this.name}] Active markdown retry succeeded for ${sanitizeLogText(chatId, 64)}\n`);
|
|
670
952
|
this.saveQQState();
|
|
671
953
|
await activeMdResp.text().catch(() => '');
|
|
672
|
-
return;
|
|
954
|
+
return undefined;
|
|
673
955
|
}
|
|
674
956
|
const mdErrBody = sanitizeLogText(await activeMdResp.text().catch(() => ''), 200);
|
|
675
957
|
if (activeMdResp.status === 429) {
|
|
@@ -688,7 +970,7 @@ export class QQChannel extends ChannelBase {
|
|
|
688
970
|
process.stderr.write(`[QQ:${this.name}] Active text fallback succeeded for ${sanitizeLogText(chatId, 64)}\n`);
|
|
689
971
|
this.saveQQState();
|
|
690
972
|
await activeTextResp.text().catch(() => '');
|
|
691
|
-
return;
|
|
973
|
+
return undefined;
|
|
692
974
|
}
|
|
693
975
|
const textErrBody = sanitizeLogText(await activeTextResp.text().catch(() => ''), 200);
|
|
694
976
|
if (activeTextResp.status === 429) {
|
|
@@ -717,7 +999,7 @@ export class QQChannel extends ChannelBase {
|
|
|
717
999
|
}
|
|
718
1000
|
process.stderr.write(`[QQ:${this.name}] Plain-text fallback succeeded for ${sanitizeLogText(chatId, 64)}\n`);
|
|
719
1001
|
await fallbackRes.text().catch(() => '');
|
|
720
|
-
return;
|
|
1002
|
+
return undefined;
|
|
721
1003
|
}
|
|
722
1004
|
await resp.text().catch(() => '');
|
|
723
1005
|
if (msgId)
|
|
@@ -726,7 +1008,11 @@ export class QQChannel extends ChannelBase {
|
|
|
726
1008
|
catch (e) {
|
|
727
1009
|
// Rollback on failure if we haven't already
|
|
728
1010
|
if (msgId && !rollbackApplied) {
|
|
729
|
-
|
|
1011
|
+
// Conditional rollback: see the 429 branch above — a concurrent
|
|
1012
|
+
// anchored send may already have advanced the counter past ours.
|
|
1013
|
+
if (this.msgSeqMap.get(msgId) === nextSeq) {
|
|
1014
|
+
this.msgSeqMap.set(msgId, nextSeq - 1);
|
|
1015
|
+
}
|
|
730
1016
|
}
|
|
731
1017
|
if (msgId)
|
|
732
1018
|
this.saveQQState();
|
|
@@ -737,15 +1023,21 @@ export class QQChannel extends ChannelBase {
|
|
|
737
1023
|
}
|
|
738
1024
|
throw e; // Re-throw for .catch() callers
|
|
739
1025
|
}
|
|
1026
|
+
return undefined;
|
|
740
1027
|
}
|
|
741
1028
|
/**
|
|
742
1029
|
* Resolve API routing: handles disposed check, token refresh, chatId validation,
|
|
743
|
-
* sandbox detection, and C2C/group path selection.
|
|
1030
|
+
* sandbox detection, and C2C/group path selection. When a guard fails it
|
|
1031
|
+
* returns `{ block }` naming whether a retry could help, instead of a route.
|
|
744
1032
|
*/
|
|
745
1033
|
async resolveRoute(chatId) {
|
|
1034
|
+
// Each failure reports its own retry class, so this function is the single
|
|
1035
|
+
// place that decides what a retry could fix: the caller used to re-derive
|
|
1036
|
+
// the class from a mirrored copy of these guards, which meant a sixth
|
|
1037
|
+
// guard added here would silently default to 'transient' and be retried.
|
|
746
1038
|
if (this.disposed) {
|
|
747
1039
|
process.stderr.write(`[QQ:${this.name}] resolveRoute: channel disposed, dropping message to ${sanitizeLogText(chatId, 64)}\n`);
|
|
748
|
-
return
|
|
1040
|
+
return { block: 'permanent' };
|
|
749
1041
|
}
|
|
750
1042
|
if (Date.now() >= this.tokenExpiresAt) {
|
|
751
1043
|
try {
|
|
@@ -753,28 +1045,32 @@ export class QQChannel extends ChannelBase {
|
|
|
753
1045
|
}
|
|
754
1046
|
catch (_e) {
|
|
755
1047
|
process.stderr.write(`[QQ:${this.name}] resolveRoute: token refresh failed (${sanitizeLogText(_e instanceof Error ? _e.message : String(_e), 120)}), dropping message to ${sanitizeLogText(chatId, 64)}\n`);
|
|
756
|
-
return
|
|
1048
|
+
return { block: 'transient' };
|
|
757
1049
|
}
|
|
758
1050
|
}
|
|
759
1051
|
if (!this.accessToken) {
|
|
760
1052
|
process.stderr.write(`[QQ:${this.name}] resolveRoute: accessToken is empty after fetchToken\n`);
|
|
761
|
-
return
|
|
1053
|
+
return { block: 'transient' };
|
|
762
1054
|
}
|
|
763
1055
|
if (!isValidChatId(chatId)) {
|
|
764
1056
|
process.stderr.write(`[QQ:${this.name}] resolveRoute: invalid chatId rejected (length=${chatId.length})\n`);
|
|
765
|
-
return
|
|
1057
|
+
return { block: 'permanent' };
|
|
766
1058
|
}
|
|
767
1059
|
const base = getApiBase(Boolean(this.qqConfig.sandbox));
|
|
768
|
-
const routeType = this.
|
|
1060
|
+
const routeType = this.chatTypeFor(chatId);
|
|
769
1061
|
if (routeType !== 'group' && routeType !== 'c2c') {
|
|
770
1062
|
process.stderr.write(`[QQ:${this.name}] resolveRoute: no chat type for ${sanitizeLogText(chatId, 64)}, dropping message\n`);
|
|
771
|
-
return
|
|
1063
|
+
return { block: 'permanent' };
|
|
772
1064
|
}
|
|
773
1065
|
const path = routeType === 'group'
|
|
774
1066
|
? `/v2/groups/${chatId}/messages`
|
|
775
1067
|
: `/v2/users/${chatId}/messages`;
|
|
776
1068
|
return { base, path };
|
|
777
1069
|
}
|
|
1070
|
+
/** The chat type recorded for a chat, if any (see resolveRoute). */
|
|
1071
|
+
chatTypeFor(chatId) {
|
|
1072
|
+
return this.chatTypeMap.get(chatId) || this.qqConfig.chatTypes?.[chatId];
|
|
1073
|
+
}
|
|
778
1074
|
disconnect() {
|
|
779
1075
|
this._reconnectId++;
|
|
780
1076
|
this.disposed = true;
|
|
@@ -822,6 +1118,7 @@ export class QQChannel extends ChannelBase {
|
|
|
822
1118
|
this.connectReject = null;
|
|
823
1119
|
}
|
|
824
1120
|
this.detachCronHandler();
|
|
1121
|
+
this.detachBridgeBoundarySeal();
|
|
825
1122
|
this.chatTypeMap.clear();
|
|
826
1123
|
this.replyMsgId.clear();
|
|
827
1124
|
this.replyContextByMessageId.clear();
|
|
@@ -835,15 +1132,34 @@ export class QQChannel extends ChannelBase {
|
|
|
835
1132
|
process.stderr.write(`[QQ:${this.name}] resetRoutingState: orphaned cron flow (depth=${this._inCronFlow}) during disconnect\n`);
|
|
836
1133
|
}
|
|
837
1134
|
this._inCronFlow = 0;
|
|
838
|
-
for (const [, state] of this.streamState) {
|
|
1135
|
+
for (const [sessionId, state] of this.streamState) {
|
|
839
1136
|
if (state.timer)
|
|
840
1137
|
clearTimeout(state.timer);
|
|
1138
|
+
if (state.buffer) {
|
|
1139
|
+
process.stderr.write(`[QQ:${this.name}] dropping ${state.buffer.length} chars of buffered stream text on disconnect for ${sanitizeLogText(sessionId, 64)}\n`);
|
|
1140
|
+
}
|
|
841
1141
|
}
|
|
842
1142
|
this.streamState.clear();
|
|
1143
|
+
this.sessionReplyMsgId.clear();
|
|
1144
|
+
this.turnCounter.clear();
|
|
843
1145
|
this.flushingSessions.clear();
|
|
844
1146
|
this.pendingStreamDelete.clear();
|
|
845
1147
|
this.flushedSessions.clear();
|
|
846
1148
|
this.activePromptSessions.clear();
|
|
1149
|
+
// Report every divert-cap episode's cumulative total before dropping the
|
|
1150
|
+
// map: disconnect is the last release point for these sessions, so an
|
|
1151
|
+
// episode that accumulated drops after its first overflow line would
|
|
1152
|
+
// otherwise lose its `capDropped - capLogged` total silently. The held
|
|
1153
|
+
// text itself is discarded here too, so each entry's loss is logged the
|
|
1154
|
+
// same way the other teardown sites log theirs.
|
|
1155
|
+
for (const [sessionId, held] of this.streamOrphanBuffer) {
|
|
1156
|
+
this.reportOrphanStashCap(sessionId, held);
|
|
1157
|
+
if (held.text) {
|
|
1158
|
+
process.stderr.write(`[QQ:${this.name}] dropping ${held.text.length} chars of turn ${held.turn} stash on disconnect for ${sanitizeLogText(sessionId, 64)}\n`);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
this.streamOrphanBuffer.clear();
|
|
1162
|
+
this.completedTurns.clear();
|
|
847
1163
|
}
|
|
848
1164
|
/**
|
|
849
1165
|
* QQ Bot API V2 does not provide a typing indicator endpoint, but these
|
|
@@ -851,29 +1167,557 @@ export class QQChannel extends ChannelBase {
|
|
|
851
1167
|
* discriminator (see activePromptSessions). ChannelBase always pairs the
|
|
852
1168
|
* two calls per prompt turn (onPromptEnd runs in the prompt path's
|
|
853
1169
|
* finally, even on error/cancel).
|
|
1170
|
+
*
|
|
1171
|
+
* Set the per-session reply anchor deterministically from the triggering
|
|
1172
|
+
* message's id (ChannelBase passes envelope.messageId, which for QQ is
|
|
1173
|
+
* event.id — the same value setReplyMsgId stores in the chat-level entry).
|
|
1174
|
+
* This replaces the previous opportunistic capture on the first chunk,
|
|
1175
|
+
* which had a blind spot: a slow model turn could pass the chat entry's
|
|
1176
|
+
* 5-minute TTL before its first chunk, and the capture would then pick up
|
|
1177
|
+
* the msgId of a NEWER message in the same chat (another user's turn).
|
|
1178
|
+
* Anchoring at prompt start is immune — the anchor always refers to the
|
|
1179
|
+
* message that actually triggered this session's turn.
|
|
1180
|
+
*
|
|
1181
|
+
* Proactive turns (loop/webhook/cron) have no triggering message; their
|
|
1182
|
+
* replies must go out as active messages, so clear any stale anchor.
|
|
854
1183
|
*/
|
|
855
|
-
onPromptStart(_chatId, sessionId,
|
|
1184
|
+
onPromptStart(_chatId, sessionId, messageId) {
|
|
856
1185
|
this.activePromptSessions.add(sessionId);
|
|
1186
|
+
// Bump the turn generation: streamState entries created by a previous
|
|
1187
|
+
// turn on this session (e.g. one left behind by a deferred send) are now
|
|
1188
|
+
// stale — onResponseChunk compares its turn against this counter and
|
|
1189
|
+
// drops them so this turn's chunks cannot leak into the old turn's state.
|
|
1190
|
+
const turn = (this.turnCounter.get(sessionId) ?? 0) + 1;
|
|
1191
|
+
this.turnCounter.set(sessionId, turn);
|
|
1192
|
+
// A new turn starts: a completion recorded for an earlier turn can never
|
|
1193
|
+
// match this generation again, and the counter restarts at 1 after a
|
|
1194
|
+
// teardown, so drop the record rather than let a reused number alias onto a
|
|
1195
|
+
// turn that has already finished. The number carries no generation of its
|
|
1196
|
+
// own, so this clear is the only guard against that alias.
|
|
1197
|
+
this.completedTurns.delete(sessionId);
|
|
1198
|
+
// A previous turn may have stashed chunks in the orphan buffer while its
|
|
1199
|
+
// deferred chain still owned the streamState entry; a fresh turn can
|
|
1200
|
+
// never own an existing entry, so anything left is dead text and must not
|
|
1201
|
+
// prepend to this turn's HEAD. The drop is logged so the loss is
|
|
1202
|
+
// observable rather than silent.
|
|
1203
|
+
const stashed = this.streamOrphanBuffer.get(sessionId);
|
|
1204
|
+
if (stashed) {
|
|
1205
|
+
this.dropOrphanStash(sessionId, stashed);
|
|
1206
|
+
}
|
|
1207
|
+
// Release the previous turn's reply anchor through the single release
|
|
1208
|
+
// path before overwriting: a raw set/delete here would skip the msgSeqMap
|
|
1209
|
+
// cascade and orphan the old msgId's msg_seq counter. No identity
|
|
1210
|
+
// expectation — this is the normal per-prompt overwrite (every prompt
|
|
1211
|
+
// runs onPromptStart), not a deferred chain settling, so releasing by
|
|
1212
|
+
// sessionId alone is safe. The release is idempotent for sessions that
|
|
1213
|
+
// never held an anchor (proactive turns).
|
|
1214
|
+
this.releaseSessionReplyAnchor(sessionId);
|
|
1215
|
+
if (messageId) {
|
|
1216
|
+
this.sessionReplyMsgId.set(sessionId, {
|
|
1217
|
+
msgId: messageId,
|
|
1218
|
+
timestamp: Date.now(),
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
857
1221
|
}
|
|
858
|
-
|
|
1222
|
+
/**
|
|
1223
|
+
* ChannelBase invokes this unconditionally in a finally block — including
|
|
1224
|
+
* when the prompt was cancelled, in which case onResponseComplete is
|
|
1225
|
+
* skipped. That makes it the one reliable release point for per-session
|
|
1226
|
+
* streaming state: without it a cancelled turn would leave its reply
|
|
1227
|
+
* anchor in sessionReplyMsgId (and the next prompt on the same session
|
|
1228
|
+
* would overwrite it only at its own start, leaking the stale entry's
|
|
1229
|
+
* msg_seq orphan meanwhile).
|
|
1230
|
+
*
|
|
1231
|
+
* Deferred completions are exempt: when onResponseComplete parked the
|
|
1232
|
+
* session in pendingStreamDelete, a final flush is still in flight and
|
|
1233
|
+
* its promise chain owns the streamState lifecycle (it re-flushes the
|
|
1234
|
+
* residual buffer, then releases the anchor). Clearing state here would
|
|
1235
|
+
* trip the .then() identity guard and silently drop the residual buffer.
|
|
1236
|
+
*/
|
|
1237
|
+
onPromptEnd(chatId, sessionId, _messageId) {
|
|
1238
|
+
// Always clear the prompt-in-flight marker first: the PR #8241 teardown
|
|
1239
|
+
// below has early returns (deferred flush chains) that must not leave
|
|
1240
|
+
// this session marked as an active prompt for the cron discriminator.
|
|
859
1241
|
this.activePromptSessions.delete(sessionId);
|
|
1242
|
+
// A cancelled turn whose chunks were diverted into the side buffer while a
|
|
1243
|
+
// predecessor's deferred chain parked the session must still deliver its
|
|
1244
|
+
// HEAD: the early return below hands the whole teardown to that chain, but
|
|
1245
|
+
// the chain only services its own residual — it never reads the successor's
|
|
1246
|
+
// stash, and the next turn would drop it as superseded. Service this turn's
|
|
1247
|
+
// stash first, on this turn's own anchor; the park flag and the
|
|
1248
|
+
// predecessor's entry/buffer stay untouched so its chain can still settle
|
|
1249
|
+
// and release its own anchor.
|
|
1250
|
+
const parkedStash = this.streamOrphanBuffer.get(sessionId);
|
|
1251
|
+
if (this.pendingStreamDelete.has(sessionId) &&
|
|
1252
|
+
parkedStash &&
|
|
1253
|
+
parkedStash.turn === (this.turnCounter.get(sessionId) ?? 0)) {
|
|
1254
|
+
this.reportOrphanStashCap(sessionId, parkedStash);
|
|
1255
|
+
this.streamOrphanBuffer.delete(sessionId);
|
|
1256
|
+
// Pass the stash's own label: by the time this delivery runs the
|
|
1257
|
+
// session's active prompt may already belong to a successor, and the
|
|
1258
|
+
// fallback lookup would then attribute this text to that turn.
|
|
1259
|
+
void this.deliverCancelledStash(chatId, sessionId, parkedStash.text, undefined, undefined, parkedStash.sourceLabel);
|
|
1260
|
+
}
|
|
1261
|
+
if (this.pendingStreamDelete.has(sessionId)) {
|
|
1262
|
+
// Deferred completion (or a cancelled turn's flush below) owns the
|
|
1263
|
+
// teardown: the flush chain's terminal settle releases the anchor and
|
|
1264
|
+
// clears the turn counter / flush records when the state is still
|
|
1265
|
+
// current; if a successor turn replaced the state first, onPromptStart
|
|
1266
|
+
// (turn bump) or onSessionDied cleans them — bounded by the number of
|
|
1267
|
+
// live sessions, so no unbounded growth.
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
let state = this.streamState.get(sessionId);
|
|
1271
|
+
// A cancelled turn's stashed HEAD must still be delivered: while the
|
|
1272
|
+
// previous turn's deferred chain owned the entry, this turn's chunks went
|
|
1273
|
+
// to the side buffer, and a cancel never runs onResponseComplete. Merge
|
|
1274
|
+
// the stash into the residual buffer here — creating the state when the
|
|
1275
|
+
// chain already freed it — so the branches below park and flush it.
|
|
1276
|
+
//
|
|
1277
|
+
// Order: the stash is always the OLDER text — it is the diverted/doomed
|
|
1278
|
+
// head that precedes the window it is being merged into (onResponseChunk's
|
|
1279
|
+
// drain does `held.text + chunk`, onResponseComplete prepends `held.pre`,
|
|
1280
|
+
// and handOffSealedPre documents "the older first"), so both merges
|
|
1281
|
+
// prepend, with or without a send in flight.
|
|
1282
|
+
const stashed = this.streamOrphanBuffer.get(sessionId);
|
|
1283
|
+
if (stashed) {
|
|
1284
|
+
if (stashed.turn === (this.turnCounter.get(sessionId) ?? 0)) {
|
|
1285
|
+
this.reportOrphanStashCap(sessionId, stashed);
|
|
1286
|
+
this.streamOrphanBuffer.delete(sessionId);
|
|
1287
|
+
if (!state) {
|
|
1288
|
+
state = this.createStreamState(chatId, sessionId, '', stashed.turn);
|
|
1289
|
+
this.streamState.set(sessionId, state);
|
|
1290
|
+
}
|
|
1291
|
+
// Only when THIS entry owns the in-flight send does the merge touch a
|
|
1292
|
+
// seal its chain is carrying; when another chain owns the session's
|
|
1293
|
+
// send, this entry's seal and buffer were untouched by it and no
|
|
1294
|
+
// per-flight record may be written on them.
|
|
1295
|
+
const flushInFlight = this.flushingSessions.get(sessionId) === state;
|
|
1296
|
+
// The stash carries the diverted turn's attribution label (a
|
|
1297
|
+
// sub-agent/loop segment): without it the merged flush would go out
|
|
1298
|
+
// unattributed. An existing state's own label wins.
|
|
1299
|
+
if (state.sourceLabel === undefined &&
|
|
1300
|
+
stashed.sourceLabel !== undefined) {
|
|
1301
|
+
state.sourceLabel = stashed.sourceLabel;
|
|
1302
|
+
}
|
|
1303
|
+
// The stash may carry a sealed pre-boundary prefix whose only other
|
|
1304
|
+
// copy the bridge cleared at a boundary: without it on the state, a
|
|
1305
|
+
// permanent failure of the flush below has nothing to hand off and the
|
|
1306
|
+
// opening is lost. Same merge as onResponseChunk's drain site.
|
|
1307
|
+
if (stashed.pre !== undefined) {
|
|
1308
|
+
state.sealedPre = stashed.pre + (state.sealedPre ?? '');
|
|
1309
|
+
// The merge widened the entry's seal while its send may still be in
|
|
1310
|
+
// flight, and that send's carriedSeal is now the widened value's
|
|
1311
|
+
// suffix (the prepended stash head is the older text). Record it
|
|
1312
|
+
// deterministically (not by comparing seal text) so the in-flight
|
|
1313
|
+
// chain's re-buffer backstop does not prepend the carried head a
|
|
1314
|
+
// second time and its success arm strips exactly the head that send
|
|
1315
|
+
// delivered — leaving the prepended head, which is its only copy.
|
|
1316
|
+
if (flushInFlight) {
|
|
1317
|
+
state.sealWidenedInFlight = true;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
if (flushInFlight) {
|
|
1321
|
+
// The prepended stash head sits at the FRONT of the buffer, so the
|
|
1322
|
+
// failed payload's re-buffer has to be inserted after it and before
|
|
1323
|
+
// whatever chunks arrived since the merge. Record where the head
|
|
1324
|
+
// ends; a later re-buffer with no room for the payload may clear the
|
|
1325
|
+
// marker, and flushAndTrack clears it when the next flight starts.
|
|
1326
|
+
state.staleStashPrefix = stashed.text.length;
|
|
1327
|
+
}
|
|
1328
|
+
state.buffer = stashed.text + state.buffer;
|
|
1329
|
+
}
|
|
1330
|
+
else {
|
|
1331
|
+
this.dropOrphanStash(sessionId, stashed);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
// A send is in flight: defer like onResponseComplete does — the in-flight
|
|
1335
|
+
// chain owns the teardown. Clearing the stream entry here while
|
|
1336
|
+
// sendMessage is suspended (resolveRoute→fetchToken) would trip the
|
|
1337
|
+
// chain's identity guard, and releasing the anchor would reset the tail's
|
|
1338
|
+
// msg_seq to 1 after the first flush's (msg-A,1) — QQ dedupes on msg_id +
|
|
1339
|
+
// msg_seq and silently drops the tail. The chain's settle path performs
|
|
1340
|
+
// the release and state teardown exactly once.
|
|
1341
|
+
if (state && this.flushingSessions.has(sessionId)) {
|
|
1342
|
+
this.pendingStreamDelete.add(sessionId);
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
if (state?.timer) {
|
|
1346
|
+
clearTimeout(state.timer);
|
|
1347
|
+
state.timer = null;
|
|
1348
|
+
}
|
|
1349
|
+
if (state && state.buffer) {
|
|
1350
|
+
// Cancelled turn with a buffered residual tail: onResponseComplete is
|
|
1351
|
+
// skipped on cancel, so the idle timer was the only path that would
|
|
1352
|
+
// have delivered this text — on origin/main the timer fired after
|
|
1353
|
+
// cancel and flushed it, and dropping it here is a silent reply loss.
|
|
1354
|
+
// Trigger the flush now (idleFlush → flushAndTrack keeps the full
|
|
1355
|
+
// sendMessage + reply-anchor + msg_seq chain), parking the session in
|
|
1356
|
+
// pendingStreamDelete so the chain's terminal settle performs the
|
|
1357
|
+
// release and state teardown. Clearing streamState before the flush
|
|
1358
|
+
// would trip the chain's identity guards and drop the buffer. This
|
|
1359
|
+
// method is sync and cannot await the async chain — ownership passes
|
|
1360
|
+
// to it, exactly like the deferred-completion path above.
|
|
1361
|
+
this.pendingStreamDelete.add(sessionId);
|
|
1362
|
+
this.idleFlush(sessionId, this._reconnectId);
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
// Release before the deletes: the release guard scans streamState +
|
|
1366
|
+
// flushingSessions for a live flush, so it must still see this session's
|
|
1367
|
+
// entry (and marker) or it would drop the msg_seq counter under a send
|
|
1368
|
+
// still in flight — QQ dedupes on msg_id + msg_seq and drops the tail.
|
|
1369
|
+
// Read the flush marker before the delete below erases it: the completion
|
|
1370
|
+
// record may only go once no chain can still consult it. The guard above
|
|
1371
|
+
// parks whenever a live stream entry has a marker, so this teardown sees
|
|
1372
|
+
// flushInFlight=true only when a chain holds the marker with no streamState
|
|
1373
|
+
// entry of its own. The deletes below clear that marker, so by the time the
|
|
1374
|
+
// chain settles its handOffSealedPre fails the ownsSession gate and never
|
|
1375
|
+
// reaches the record; the record is kept while the marker was live rather
|
|
1376
|
+
// than deleted beneath a settling chain, and the next onPromptStart,
|
|
1377
|
+
// onSessionDied, or disconnect drops it.
|
|
1378
|
+
const flushInFlight = this.flushingSessions.has(sessionId);
|
|
1379
|
+
this.releaseSessionReplyAnchor(sessionId);
|
|
1380
|
+
this.streamState.delete(sessionId);
|
|
1381
|
+
this.flushingSessions.delete(sessionId);
|
|
1382
|
+
this.pendingStreamDelete.delete(sessionId);
|
|
1383
|
+
this.flushedSessions.delete(sessionId);
|
|
1384
|
+
this.turnCounter.delete(sessionId);
|
|
1385
|
+
if (!flushInFlight)
|
|
1386
|
+
this.completedTurns.delete(sessionId);
|
|
1387
|
+
this.streamOrphanBuffer.delete(sessionId);
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* Deliver a cancelled turn's stashed HEAD on its OWN anchor. The session is
|
|
1391
|
+
* parked in pendingStreamDelete while a predecessor turn's deferred chain
|
|
1392
|
+
* settles, so ChannelBase skips onResponseComplete and onPromptEnd's park
|
|
1393
|
+
* early-return would otherwise leave this turn's stash for the next turn to
|
|
1394
|
+
* discard as superseded. Fire-and-forget — onPromptEnd is sync — and never
|
|
1395
|
+
* rejects, so a failed delivery cannot become an unhandled rejection. The
|
|
1396
|
+
* park flag and the predecessor's streamState entry/buffer are deliberately
|
|
1397
|
+
* untouched: that chain still owns them and must settle and release its own
|
|
1398
|
+
* anchor, and this text must not go out under the predecessor's msgId.
|
|
1399
|
+
*
|
|
1400
|
+
* This is the only delivery for a stash that onPromptEnd already deleted from
|
|
1401
|
+
* streamOrphanBuffer (or a sealed head handOffSealedPre cleared from
|
|
1402
|
+
* sealedPre), so a transient failure must not drop the text. RATE_LIMITED is
|
|
1403
|
+
* the one code the flush path classifies as transient, and no copy survives
|
|
1404
|
+
* for a later turn, so the send is re-attempted here under the same
|
|
1405
|
+
* maxFlushRetries bound flushAndTrack uses (<= 0 means unlimited). A
|
|
1406
|
+
* re-attempt is not routed through streamState/idleFlush: the turn is over,
|
|
1407
|
+
* so a state entry for it would be dropped as stale by the next prompt, and
|
|
1408
|
+
* the entry-less in-flight guard below would be lost. Every attempt
|
|
1409
|
+
* reuses the anchor, the attribution label and the reply context captured
|
|
1410
|
+
* before the first one.
|
|
1411
|
+
*
|
|
1412
|
+
* A send also fails to reach the wire without throwing when its route cannot
|
|
1413
|
+
* be resolved; sendMessageWithReplyContext reports that as transient (token
|
|
1414
|
+
* refresh failure) or permanent (disposed / unusable chatId / no chat type),
|
|
1415
|
+
* and the second class is dropped with a log immediately rather than spun on
|
|
1416
|
+
* the backoff forever. Permanent DeliveryErrors and exhaustion drop with a
|
|
1417
|
+
* log, as the other paths do.
|
|
1418
|
+
*/
|
|
1419
|
+
async deliverCancelledStash(chatId, sessionId, text, anchor, anchorTimestamp, sourceLabel) {
|
|
1420
|
+
// Capture the anchor once, before the first attempt: a successor turn can
|
|
1421
|
+
// overwrite sessionReplyMsgId while a re-attempt is pending, and this
|
|
1422
|
+
// turn's text must not go out under the successor's anchor.
|
|
1423
|
+
const { entry: sessionAnchorEntry, msgId: sessionAnchor } = this.resolveSessionReplyAnchor(sessionId);
|
|
1424
|
+
// A caller that knows which turn this text belongs to overrides the lookup
|
|
1425
|
+
// above: by the time such a caller runs, a successor may already own the
|
|
1426
|
+
// session anchor. `null` means the caller knows there is none — deliver
|
|
1427
|
+
// unanchored rather than under the successor's msg_id.
|
|
1428
|
+
const captured = anchor === undefined ? sessionAnchor : (anchor ?? undefined);
|
|
1429
|
+
// The capture time the read site TTL-checks the override against. An anchor
|
|
1430
|
+
// this method resolved itself is already TTL-checked; an explicit one
|
|
1431
|
+
// carries the caller's timestamp (handOffSealedPre passes the state's), and
|
|
1432
|
+
// an expired override then goes unanchored instead of on a stale msg_id.
|
|
1433
|
+
const capturedTimestamp = anchor === undefined ? sessionAnchorEntry?.timestamp : anchorTimestamp;
|
|
1434
|
+
// The attribution label and the reply context are read from per-turn state
|
|
1435
|
+
// the successor turn replaces (activePrompts, and the reply context map via
|
|
1436
|
+
// getResponseMessageId), so both are captured for the same reason as the
|
|
1437
|
+
// anchor: a re-attempt must not describe this text as the successor's turn.
|
|
1438
|
+
// A caller that knows the text's own label (the stash / sealed-head sites,
|
|
1439
|
+
// which carry it from the turn that wrote the text) passes it explicitly;
|
|
1440
|
+
// the lookup is only the fallback for a caller with no such record.
|
|
1441
|
+
// An explicit `null` forbids the reply context too — it resolves through
|
|
1442
|
+
// that same successor-owned state, so using it would anchor the send to the
|
|
1443
|
+
// successor's msg_id, the one thing `null` rules out.
|
|
1444
|
+
const attributionLabel = sourceLabel ?? this.getResponseSourceLabel(sessionId);
|
|
1445
|
+
const replyContext = anchor === null ? undefined : this.resolveResponseReplyContext(sessionId);
|
|
1446
|
+
// Hold the msg_seq counter for the whole delivery, backoff sleeps included:
|
|
1447
|
+
// between attempts this session owns no streamState entry and no flush
|
|
1448
|
+
// marker, so a TTL sweep would otherwise reclaim the counter and the next
|
|
1449
|
+
// attempt would restart at msg_seq 1 — a pair QQ dedupes on (msg_id,
|
|
1450
|
+
// msg_seq) and drops silently.
|
|
1451
|
+
if (captured)
|
|
1452
|
+
this.beginMsgSeqSend(captured);
|
|
1453
|
+
let delivered = false;
|
|
1454
|
+
try {
|
|
1455
|
+
for (let attempt = 1;; attempt++) {
|
|
1456
|
+
// Why the send did not reach the wire, if it did not. Read from the send
|
|
1457
|
+
// itself rather than probed beforehand: a probe's route can go stale
|
|
1458
|
+
// before the send resolves its own, which would drop the text silently,
|
|
1459
|
+
// and the probe would run outside the in-flight msg_seq guard below.
|
|
1460
|
+
let blocked = undefined;
|
|
1461
|
+
try {
|
|
1462
|
+
if (captured) {
|
|
1463
|
+
blocked = await this.sendMessageWithReplyContext(chatId, text, undefined, attributionLabel, captured, capturedTimestamp);
|
|
1464
|
+
}
|
|
1465
|
+
else {
|
|
1466
|
+
// Same entry point sendResponseMessage uses, with the reply context
|
|
1467
|
+
// already captured: its own lookup would run against a successor's
|
|
1468
|
+
// activePrompts entry on a re-attempt.
|
|
1469
|
+
blocked = await this.sendMessageWithReplyContext(chatId, text, replyContext, attributionLabel);
|
|
1470
|
+
}
|
|
1471
|
+
if (blocked === 'transient') {
|
|
1472
|
+
// A route that may resolve on a later attempt: feed the loop's
|
|
1473
|
+
// transient arm, which applies the same retry bound as a thrown
|
|
1474
|
+
// failure.
|
|
1475
|
+
throw new Error('outgoing route unresolved');
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
catch (e) {
|
|
1479
|
+
// RETRY_EXHAUSTED / ACTIVE_MSG_DISABLED / FALLBACK_FAILED are permanent
|
|
1480
|
+
// (see flushAndTrack); everything else — RATE_LIMITED and a plain
|
|
1481
|
+
// network error — is transient and re-attempted under the bound.
|
|
1482
|
+
if (e instanceof DeliveryError &&
|
|
1483
|
+
(e.code === 'RETRY_EXHAUSTED' ||
|
|
1484
|
+
e.code === 'ACTIVE_MSG_DISABLED' ||
|
|
1485
|
+
e.code === 'FALLBACK_FAILED')) {
|
|
1486
|
+
process.stderr.write(`[QQ:${this.name}] cancelled-stash delivery failed (${e.code}): ${sanitizeLogText(e.message, 200)}, dropping ${text.length} chars\n`);
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
if (this.maxFlushRetries <= 0 || attempt < this.maxFlushRetries) {
|
|
1490
|
+
const delay = attempt > 1
|
|
1491
|
+
? QQChannel.IDLE_FLUSH_BACKOFF_MS
|
|
1492
|
+
: QQChannel.IDLE_FLUSH_MS;
|
|
1493
|
+
process.stderr.write(`[QQ:${this.name}] cancelled-stash delivery failed (attempt ${attempt}): ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}, retrying in ${delay}ms\n`);
|
|
1494
|
+
await new Promise((resolve) => {
|
|
1495
|
+
const timer = setTimeout(resolve, delay);
|
|
1496
|
+
timer.unref?.();
|
|
1497
|
+
});
|
|
1498
|
+
continue;
|
|
1499
|
+
}
|
|
1500
|
+
process.stderr.write(`[QQ:${this.name}] cancelled-stash delivery failed: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}, retries exhausted, dropping ${text.length} chars\n`);
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
if (blocked === 'permanent') {
|
|
1504
|
+
// The route can never resolve (channel disposed, unusable chatId, no
|
|
1505
|
+
// chat type), so a retry cannot help even under `unlimited` retries:
|
|
1506
|
+
// drop it with a log rather than spinning the backoff forever.
|
|
1507
|
+
process.stderr.write(`[QQ:${this.name}] cancelled-stash delivery blocked (no usable route): dropping ${text.length} chars\n`);
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
delivered = true;
|
|
1511
|
+
break;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
finally {
|
|
1515
|
+
if (captured)
|
|
1516
|
+
this.endMsgSeqSend(captured);
|
|
1517
|
+
}
|
|
1518
|
+
// Delivered. The release is synchronous and cannot throw (saveQQState
|
|
1519
|
+
// swallows write errors), it sits outside the retry try/catch so a throw
|
|
1520
|
+
// here can never be mistaken for a failed send and re-send text, and it
|
|
1521
|
+
// runs after the in-flight registration is gone so it can reclaim the
|
|
1522
|
+
// counter it owns.
|
|
1523
|
+
if (delivered && captured) {
|
|
1524
|
+
this.releaseSessionReplyAnchor(sessionId, captured);
|
|
1525
|
+
}
|
|
860
1526
|
}
|
|
861
1527
|
// ── Streaming (idle-flush with per-session buffers) ────────────
|
|
862
1528
|
onResponseChunk(chatId, chunk, sessionId, segment) {
|
|
1529
|
+
const currentTurn = this.turnCounter.get(sessionId) ?? 0;
|
|
863
1530
|
let state = this.streamState.get(sessionId);
|
|
1531
|
+
if (state && state.turn !== currentTurn) {
|
|
1532
|
+
// A previous turn's deferred flush chain may still own this entry
|
|
1533
|
+
// (onPromptEnd parked the session in pendingStreamDelete with a live
|
|
1534
|
+
// timer that delivers its residual). It must stay untouched then: the
|
|
1535
|
+
// chain's identity guard (s === state) needs it to tear itself down —
|
|
1536
|
+
// dropping it here would strand the residual forever (its .then() sees
|
|
1537
|
+
// `current !== state` and returns). This chunk belongs to the new turn:
|
|
1538
|
+
// while the parked entry still holds a residual it is stashed in the side
|
|
1539
|
+
// buffer below (so this turn's opening survives the settle window),
|
|
1540
|
+
// otherwise it is consumed (dropped). Once the chain settles and the
|
|
1541
|
+
// stash has been drained, the map is empty and the next chunk starts
|
|
1542
|
+
// fresh.
|
|
1543
|
+
if (state.buffer && this.pendingStreamDelete.has(sessionId)) {
|
|
1544
|
+
// Stash this turn's chunks in a side buffer so the HEAD of this
|
|
1545
|
+
// turn's reply is not silently dropped while the old chain settles
|
|
1546
|
+
// (up to ~10s under rate-limit backoff); they are prepended to the
|
|
1547
|
+
// first fresh entry once the chain frees the slot. The entry records
|
|
1548
|
+
// this turn so a later teardown can tell a live stash from a
|
|
1549
|
+
// superseded one.
|
|
1550
|
+
const held = this.streamOrphanBuffer.get(sessionId);
|
|
1551
|
+
// This path has no flush outlet and a parked predecessor can keep it
|
|
1552
|
+
// parked for the process's life (`maxFlushRetries: 0` = unlimited), so
|
|
1553
|
+
// bound the stash like state.buffer is bounded. Keep the head — the
|
|
1554
|
+
// sealed opening — and log the dropped tail.
|
|
1555
|
+
const limit = this.streamBufferLimit(state);
|
|
1556
|
+
const stashed = held && held.turn === currentTurn
|
|
1557
|
+
? {
|
|
1558
|
+
turn: currentTurn,
|
|
1559
|
+
text: held.text + chunk,
|
|
1560
|
+
pre: held.pre,
|
|
1561
|
+
capDropped: held.capDropped,
|
|
1562
|
+
capLogged: held.capLogged,
|
|
1563
|
+
...(segment?.sourceLabel !== undefined ||
|
|
1564
|
+
held.sourceLabel !== undefined
|
|
1565
|
+
? {
|
|
1566
|
+
sourceLabel: segment?.sourceLabel ?? held.sourceLabel,
|
|
1567
|
+
}
|
|
1568
|
+
: {}),
|
|
1569
|
+
}
|
|
1570
|
+
: {
|
|
1571
|
+
turn: currentTurn,
|
|
1572
|
+
text: chunk,
|
|
1573
|
+
...(segment?.sourceLabel !== undefined
|
|
1574
|
+
? { sourceLabel: segment.sourceLabel }
|
|
1575
|
+
: {}),
|
|
1576
|
+
};
|
|
1577
|
+
if (stashed.text.length > limit) {
|
|
1578
|
+
const before = stashed.text.length;
|
|
1579
|
+
// Cap in UTF-16 units on code-point boundaries, so the cut cannot
|
|
1580
|
+
// split a surrogate pair.
|
|
1581
|
+
stashed.text = truncateUtf16Units(stashed.text, limit);
|
|
1582
|
+
const droppedText = before - stashed.text.length;
|
|
1583
|
+
// `pre` is a prefix of `text` and is what onResponseComplete
|
|
1584
|
+
// prepends, so trimming the tail must trim it too. Those characters
|
|
1585
|
+
// are the ones `droppedText` already counts (the prefix trim is a
|
|
1586
|
+
// subset of the text trim), so the trim is reported but never added
|
|
1587
|
+
// to the loss again — counting both would report characters that
|
|
1588
|
+
// were never in the stash a second time.
|
|
1589
|
+
let preTrimmed = false;
|
|
1590
|
+
if (stashed.pre && stashed.pre.length > stashed.text.length) {
|
|
1591
|
+
preTrimmed = true;
|
|
1592
|
+
stashed.pre = stashed.text;
|
|
1593
|
+
}
|
|
1594
|
+
// One line per episode, not per chunk: the divert window can stay
|
|
1595
|
+
// open for the process's life, so the first overflow reports the
|
|
1596
|
+
// loss immediately and every later chunk only accumulates. The
|
|
1597
|
+
// cumulative total is reported once when the stash leaves the buffer
|
|
1598
|
+
// (reportOrphanStashCap).
|
|
1599
|
+
stashed.capDropped = (stashed.capDropped ?? 0) + droppedText;
|
|
1600
|
+
if ((stashed.capLogged ?? 0) === 0) {
|
|
1601
|
+
stashed.capLogged = stashed.capDropped;
|
|
1602
|
+
process.stderr.write(`[QQ:${this.name}] dropping ${droppedText} chars of diverted turn ${currentTurn} stash` +
|
|
1603
|
+
(preTrimmed ? ` and its sealed pre` : '') +
|
|
1604
|
+
` over the buffer limit for ${sanitizeLogText(sessionId, 64)}\n`);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
this.streamOrphanBuffer.set(sessionId, stashed);
|
|
1608
|
+
// Self-heal: if the parked entry has no send in flight, its settle
|
|
1609
|
+
// chain can never run again (idleFlush discards on a reconnect
|
|
1610
|
+
// generation mismatch without clearing the handle), leaving
|
|
1611
|
+
// pendingStreamDelete armed for the rest of the process and diverting
|
|
1612
|
+
// every later turn out of the streaming path. Re-arm the idle flush
|
|
1613
|
+
// with the CURRENT generation so the residual can settle and un-park
|
|
1614
|
+
// the session. A handle already live under this generation must be
|
|
1615
|
+
// left alone: re-arming on every chunk would push the parked
|
|
1616
|
+
// residual's deadline out for as long as the successor streams.
|
|
1617
|
+
const timerLive = state.timer !== null && state.timerReconnectId === this._reconnectId;
|
|
1618
|
+
if (!this.flushingSessions.has(sessionId) && !timerLive) {
|
|
1619
|
+
if (state.timer)
|
|
1620
|
+
clearTimeout(state.timer);
|
|
1621
|
+
const reconnectId = this._reconnectId;
|
|
1622
|
+
state.timer = setTimeout(() => {
|
|
1623
|
+
this.idleFlush(sessionId, reconnectId);
|
|
1624
|
+
}, QQChannel.IDLE_FLUSH_MS);
|
|
1625
|
+
state.timerReconnectId = reconnectId;
|
|
1626
|
+
state.timer.unref?.();
|
|
1627
|
+
}
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
// Superseded turn (a deferred send parked the entry and the new prompt
|
|
1631
|
+
// started before the send settled): drop the entry so this turn's
|
|
1632
|
+
// chunks cannot append to the old buffer or go out under the old
|
|
1633
|
+
// msgId. Any chars still buffered belong to the dead turn and are
|
|
1634
|
+
// discarded — with a log so the loss is observable rather than silent.
|
|
1635
|
+
// The old chain is safe: its .then()/.catch() identity guards compare
|
|
1636
|
+
// the map entry against the captured state object, and the fresh entry
|
|
1637
|
+
// created below fails that guard, so the chain can release only its
|
|
1638
|
+
// own anchor (expectedMsgId) and never touch a successor's. The
|
|
1639
|
+
// parking/flush records must be cleared here: the old chain's guards
|
|
1640
|
+
// now fail against the fresh entry and would never delete them —
|
|
1641
|
+
// stranding the session so the new turn's onResponseComplete/
|
|
1642
|
+
// onPromptEnd both early-return and the buffer is never flushed
|
|
1643
|
+
// (silent reply loss). flushingSessions is left alone: it is
|
|
1644
|
+
// ownership-keyed, and clearing it here would let the new turn start a
|
|
1645
|
+
// second concurrent send while the old tail send is still awaited.
|
|
1646
|
+
if (state.buffer) {
|
|
1647
|
+
process.stderr.write(`[QQ:${this.name}] dropping ${state.buffer.length} chars of superseded turn ${state.turn} for ${sanitizeLogText(sessionId, 64)}\n`);
|
|
1648
|
+
}
|
|
1649
|
+
// Disarm the entry before the release below: the release guard treats a
|
|
1650
|
+
// truthy timer handle or a non-empty buffer as a live flush and vetoes
|
|
1651
|
+
// the counter, but this block deletes the entry a few lines later, so no
|
|
1652
|
+
// path can ever re-drive that flush. clearTimeout leaves the handle
|
|
1653
|
+
// truthy, so null it explicitly. flushingSessions is deliberately left
|
|
1654
|
+
// set: a genuinely in-flight send must still veto the release.
|
|
1655
|
+
if (state.timer) {
|
|
1656
|
+
clearTimeout(state.timer);
|
|
1657
|
+
state.timer = null;
|
|
1658
|
+
}
|
|
1659
|
+
state.buffer = '';
|
|
1660
|
+
// The entry carries the sealed pre-boundary head, which the boundary
|
|
1661
|
+
// cleared from the bridge's collection — dropping it here would truncate
|
|
1662
|
+
// this turn's opening with no other copy. Hand it off before the delete,
|
|
1663
|
+
// like every other doomed-entry site — but ONLY when this state is not
|
|
1664
|
+
// the session's in-flight flush owner: that chain's payload
|
|
1665
|
+
// already carries the head, its success path clears sealedPre, and its
|
|
1666
|
+
// permanent-failure arm re-stashes an undelivered head. Handing off here
|
|
1667
|
+
// too would deliver the head twice; the guard alone would lose it if that
|
|
1668
|
+
// chain gives up, so its transient arm hands off whenever no retry was
|
|
1669
|
+
// scheduled for this state (the retryScheduled predicate below).
|
|
1670
|
+
if (this.flushingSessions.get(sessionId) !== state) {
|
|
1671
|
+
this.handOffSealedPre(state, sessionId);
|
|
1672
|
+
}
|
|
1673
|
+
// Release before the delete: the release guard scans streamState +
|
|
1674
|
+
// flushingSessions for a live flush, so it must still see this entry's
|
|
1675
|
+
// in-flight marker or it would drop the msg_seq counter under a send
|
|
1676
|
+
// still in flight — QQ dedupes on msg_id + msg_seq and silently drops
|
|
1677
|
+
// the tail. The expectedMsgId identity check keeps a successor turn's
|
|
1678
|
+
// anchor untouched while still cascading this turn's counter away.
|
|
1679
|
+
if (state.msgId !== undefined) {
|
|
1680
|
+
this.releaseSessionReplyAnchor(sessionId, state.msgId);
|
|
1681
|
+
}
|
|
1682
|
+
this.streamState.delete(sessionId);
|
|
1683
|
+
this.pendingStreamDelete.delete(sessionId);
|
|
1684
|
+
this.flushedSessions.delete(sessionId);
|
|
1685
|
+
state = undefined;
|
|
1686
|
+
}
|
|
864
1687
|
if (!state) {
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
1688
|
+
// createStreamState below reuses this session's reply anchor across
|
|
1689
|
+
// buffer windows and drops it past its TTL — the rationale lives there.
|
|
1690
|
+
const held = this.streamOrphanBuffer.get(sessionId);
|
|
1691
|
+
let sealedPre;
|
|
1692
|
+
let stashedLabel;
|
|
1693
|
+
if (held !== undefined) {
|
|
1694
|
+
if (held.turn === currentTurn) {
|
|
1695
|
+
// The previous turn's deferred chain has settled and freed the
|
|
1696
|
+
// streamState entry — prepend the stashed chunks so this turn's
|
|
1697
|
+
// reply HEAD is delivered in order.
|
|
1698
|
+
this.reportOrphanStashCap(sessionId, held);
|
|
1699
|
+
this.streamOrphanBuffer.delete(sessionId);
|
|
1700
|
+
// The sealed pre-boundary head has no other copy (the bridge cleared
|
|
1701
|
+
// its collection at the boundary), so carry it on the state until the
|
|
1702
|
+
// drained send settles: a permanent failure re-stashes it below.
|
|
1703
|
+
sealedPre = held.pre;
|
|
1704
|
+
stashedLabel = held.sourceLabel;
|
|
1705
|
+
chunk = held.text + chunk;
|
|
1706
|
+
}
|
|
1707
|
+
else {
|
|
1708
|
+
this.dropOrphanStash(sessionId, held);
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
state = this.createStreamState(chatId, sessionId, chunk, currentTurn, segment);
|
|
1712
|
+
if (sealedPre !== undefined)
|
|
1713
|
+
state.sealedPre = sealedPre;
|
|
1714
|
+
// The stash carries the diverted turn's attribution label (a
|
|
1715
|
+
// sub-agent/loop segment), and createStreamState only reads one off the
|
|
1716
|
+
// draining segment: drop it here and the merged flush goes out
|
|
1717
|
+
// unattributed. A label the draining segment supplied wins — same merge
|
|
1718
|
+
// as onPromptEnd's stash drain — and this must land before the size-cap
|
|
1719
|
+
// check below, which subtracts the rendered label's length.
|
|
1720
|
+
state.sourceLabel ??= stashedLabel;
|
|
877
1721
|
this.streamState.set(sessionId, state);
|
|
878
1722
|
}
|
|
879
1723
|
else {
|
|
@@ -892,9 +1736,11 @@ export class QQChannel extends ChannelBase {
|
|
|
892
1736
|
if (this.flushingSessions.has(sessionId)) {
|
|
893
1737
|
// Send in-flight — re-buffer and let the in-flight send's .then() pick it up
|
|
894
1738
|
state.buffer = buf + (state.buffer || '');
|
|
1739
|
+
const reconnectId = this._reconnectId;
|
|
895
1740
|
state.timer = setTimeout(() => {
|
|
896
|
-
this.idleFlush(sessionId,
|
|
1741
|
+
this.idleFlush(sessionId, reconnectId);
|
|
897
1742
|
}, QQChannel.IDLE_FLUSH_MS);
|
|
1743
|
+
state.timerReconnectId = reconnectId;
|
|
898
1744
|
state.timer.unref?.();
|
|
899
1745
|
return;
|
|
900
1746
|
}
|
|
@@ -905,25 +1751,51 @@ export class QQChannel extends ChannelBase {
|
|
|
905
1751
|
state.timer = setTimeout(() => {
|
|
906
1752
|
this.idleFlush(sessionId, reconnectId);
|
|
907
1753
|
}, QQChannel.IDLE_FLUSH_MS);
|
|
1754
|
+
state.timerReconnectId = reconnectId;
|
|
908
1755
|
state.timer.unref?.();
|
|
909
1756
|
}
|
|
910
1757
|
idleFlush(sessionId, reconnectId) {
|
|
911
1758
|
if (this._reconnectId !== reconnectId) {
|
|
912
|
-
|
|
1759
|
+
const parked = this.streamState.get(sessionId);
|
|
1760
|
+
if (this.pendingStreamDelete.has(sessionId) && parked?.buffer) {
|
|
1761
|
+
// The generation bumped while a residual was parked and no further
|
|
1762
|
+
// chunk will arrive to self-heal it, so discarding here would strand
|
|
1763
|
+
// the text and leave pendingStreamDelete armed forever. Re-arm under
|
|
1764
|
+
// the current generation; the re-armed timer carries that generation,
|
|
1765
|
+
// so it only re-arms again if another bump happens.
|
|
1766
|
+
if (parked.timer)
|
|
1767
|
+
clearTimeout(parked.timer);
|
|
1768
|
+
const currentReconnectId = this._reconnectId;
|
|
1769
|
+
parked.timer = setTimeout(() => {
|
|
1770
|
+
this.idleFlush(sessionId, currentReconnectId);
|
|
1771
|
+
}, QQChannel.IDLE_FLUSH_MS);
|
|
1772
|
+
parked.timerReconnectId = currentReconnectId;
|
|
1773
|
+
parked.timer.unref?.();
|
|
1774
|
+
process.stderr.write(`[QQ:${this.name}] idleFlush re-armed after reconnect session=${sanitizeLogText(sessionId, 32)}\n`);
|
|
1775
|
+
}
|
|
1776
|
+
else {
|
|
1777
|
+
process.stderr.write(`[QQ:${this.name}] idleFlush discarded (reconnect) session=${sanitizeLogText(sessionId, 32)}\n`);
|
|
1778
|
+
}
|
|
913
1779
|
return;
|
|
914
1780
|
}
|
|
915
1781
|
const state = this.streamState.get(sessionId);
|
|
916
1782
|
if (!state || !state.buffer)
|
|
917
1783
|
return;
|
|
918
1784
|
if (this.flushingSessions.has(sessionId)) {
|
|
919
|
-
// Another send is in-flight — re-schedule idle timer so we retry
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
state.timer
|
|
1785
|
+
// Another send is in-flight — re-schedule the idle timer so we retry
|
|
1786
|
+
// later. The handle on state.timer may be our own expired-but-truthy
|
|
1787
|
+
// timer (it fired while still blocked): clear it so the re-arm below
|
|
1788
|
+
// is unconditional and the tail can never be stranded.
|
|
1789
|
+
if (state.timer) {
|
|
1790
|
+
clearTimeout(state.timer);
|
|
1791
|
+
state.timer = null;
|
|
926
1792
|
}
|
|
1793
|
+
const retryReconnectId = this._reconnectId;
|
|
1794
|
+
state.timer = setTimeout(() => {
|
|
1795
|
+
this.idleFlush(sessionId, retryReconnectId);
|
|
1796
|
+
}, QQChannel.IDLE_FLUSH_MS);
|
|
1797
|
+
state.timerReconnectId = retryReconnectId;
|
|
1798
|
+
state.timer.unref?.();
|
|
927
1799
|
return;
|
|
928
1800
|
}
|
|
929
1801
|
const buffer = state.buffer;
|
|
@@ -937,30 +1809,140 @@ export class QQChannel extends ChannelBase {
|
|
|
937
1809
|
* on failure) logic to eliminate duplication.
|
|
938
1810
|
*/
|
|
939
1811
|
flushAndTrack(sessionId, buffer, state, logLabel) {
|
|
940
|
-
this.flushingSessions.
|
|
1812
|
+
this.flushingSessions.set(sessionId, state);
|
|
1813
|
+
// The seal this payload carries, if any. A boundary can seal NEW text while
|
|
1814
|
+
// this send is in flight (chunks keep arriving into state.buffer), and that
|
|
1815
|
+
// seal describes the residual, not this payload — read it once here so the
|
|
1816
|
+
// success path below clears only what this send actually delivered.
|
|
1817
|
+
const carriedSeal = state.sealedPre;
|
|
1818
|
+
// Scoped to this flight: the boundary hooks set this if a boundary clears
|
|
1819
|
+
// the bridge's collection before this send settles. A stale value from an
|
|
1820
|
+
// earlier flight would re-seal text this payload never carried.
|
|
1821
|
+
state.boundaryClearedInFlight = undefined;
|
|
1822
|
+
state.sealWidenedInFlight = undefined;
|
|
1823
|
+
// Scoped to this flight too: a stash merged during the PREVIOUS flight is
|
|
1824
|
+
// already part of this payload (the re-buffer arms fold it in), so its
|
|
1825
|
+
// boundary marker must not reorder this flight's re-buffer.
|
|
1826
|
+
state.staleStashPrefix = undefined;
|
|
1827
|
+
// Terminal release owed by this chain, performed in .finally() after the
|
|
1828
|
+
// ownership-keyed marker is cleared: releasing while the marker is still
|
|
1829
|
+
// set makes releaseSessionReplyAnchor's in-flight guard return early, and
|
|
1830
|
+
// the state entry is dropped just below, so the msg_seq counter would
|
|
1831
|
+
// otherwise leak forever.
|
|
1832
|
+
let deferredRelease;
|
|
941
1833
|
// sendMessage throws DeliveryError for delivery failures.
|
|
942
1834
|
// RETRY_EXHAUSTED, ACTIVE_MSG_DISABLED, and FALLBACK_FAILED are
|
|
943
1835
|
// permanent. RATE_LIMITED is transient and falls through to re-buffer/retry.
|
|
944
|
-
this.sendMessageWithReplyContext(state.chatId, buffer, state.replyContext, state.sourceLabel)
|
|
945
|
-
.then(() => {
|
|
1836
|
+
this.sendMessageWithReplyContext(state.chatId, buffer, state.replyContext, state.sourceLabel, state.msgId, state.msgIdTimestamp)
|
|
1837
|
+
.then((blocked) => {
|
|
1838
|
+
// A route-blocked send resolves without throwing and never reached the
|
|
1839
|
+
// wire (resolveRoute classifies a failed token refresh as 'transient'),
|
|
1840
|
+
// so running the delivery arm below would record this payload as
|
|
1841
|
+
// delivered: flushedSessions would make onResponseComplete skip its
|
|
1842
|
+
// fullText fallback, the sealed head would be cleared, and the operator
|
|
1843
|
+
// would get no reply and no loss log. Route the block through the same
|
|
1844
|
+
// arms a thrown DeliveryError uses — 'transient' re-buffers/retries
|
|
1845
|
+
// under maxFlushRetries, 'permanent' takes the permanent-failure
|
|
1846
|
+
// teardown. Neither may mark the session flushed.
|
|
1847
|
+
if (blocked !== undefined) {
|
|
1848
|
+
throw blocked === 'permanent'
|
|
1849
|
+
? new DeliveryError('FALLBACK_FAILED', `outgoing route permanently blocked for ${state.chatId}`)
|
|
1850
|
+
: new Error('outgoing route unresolved');
|
|
1851
|
+
}
|
|
1852
|
+
// This send carried the sealed pre-boundary head (the drain folded it
|
|
1853
|
+
// into this buffer), so it must not be re-stashed by a later permanent
|
|
1854
|
+
// failure — onResponseComplete would prepend it and deliver a second
|
|
1855
|
+
// standalone copy. Clear it on both success paths (state current and
|
|
1856
|
+
// session died) because the head is out either way. Guarded on identity
|
|
1857
|
+
// and on the per-flight marker: a boundary that re-sealed the live
|
|
1858
|
+
// turn's residual during the flight wrote a seal that was never in this
|
|
1859
|
+
// payload and is that residual's only copy, so clearing it here would
|
|
1860
|
+
// drop the text when the residual's own flush fails permanently. The
|
|
1861
|
+
// seal text alone cannot tell the two apart — the residual may repeat
|
|
1862
|
+
// the carried seal byte for byte. 'payload' is not a re-seal: the
|
|
1863
|
+
// boundary stripped the delivery from fullText without rewriting
|
|
1864
|
+
// sealedPre, so this send still carried that seal.
|
|
1865
|
+
if (state.sealedPre === carriedSeal &&
|
|
1866
|
+
state.boundaryClearedInFlight !== 'residual') {
|
|
1867
|
+
state.sealedPre = undefined;
|
|
1868
|
+
}
|
|
1869
|
+
else if (state.sealWidenedInFlight === true &&
|
|
1870
|
+
state.boundaryClearedInFlight !== 'residual' &&
|
|
1871
|
+
carriedSeal !== undefined &&
|
|
1872
|
+
(state.sealedPre?.length ?? 0) >= carriedSeal.length) {
|
|
1873
|
+
// onPromptEnd's stash merge prepended the older stash head in front
|
|
1874
|
+
// of the seal this send captured, so the delivered head is the
|
|
1875
|
+
// widened value's suffix: strip exactly the delivered length and keep
|
|
1876
|
+
// the prepended head, which is the merged stash's only copy. The gate
|
|
1877
|
+
// is the per-flight flag, not a comparison of seal text (see
|
|
1878
|
+
// sealClearedPayload): the prepended head may repeat the delivered
|
|
1879
|
+
// head byte for byte, and a 'residual' marker still means the seal is
|
|
1880
|
+
// the undelivered residual's only copy, so it is never narrowed. The
|
|
1881
|
+
// length check keeps a seal a boundary overwrote shorter than the
|
|
1882
|
+
// carried one from being chopped at all.
|
|
1883
|
+
// Non-null: the length test above proved the seal exists and is at
|
|
1884
|
+
// least as long as the carried seal.
|
|
1885
|
+
state.sealedPre =
|
|
1886
|
+
state.sealedPre.slice(0, state.sealedPre.length - carriedSeal.length) || undefined;
|
|
1887
|
+
}
|
|
946
1888
|
// #3: Guard — if session died during in-flight send, touch nothing
|
|
1889
|
+
// of the entry's, but do release the anchor: no later settle can run
|
|
1890
|
+
// for this state, so otherwise its msg_seq counter is stranded.
|
|
947
1891
|
const current = this.streamState.get(sessionId);
|
|
948
|
-
if (current !== state)
|
|
1892
|
+
if (current !== state) {
|
|
1893
|
+
if (state.msgId !== undefined) {
|
|
1894
|
+
this.releaseSessionReplyAnchor(sessionId, state.msgId);
|
|
1895
|
+
}
|
|
949
1896
|
return;
|
|
1897
|
+
}
|
|
950
1898
|
current.retryCount = 0;
|
|
951
1899
|
this.flushedSessions.add(sessionId);
|
|
952
1900
|
if (this.pendingStreamDelete.has(sessionId)) {
|
|
953
1901
|
this.pendingStreamDelete.delete(sessionId);
|
|
954
1902
|
// #2: Flush immediately — idle timer would add unnecessary delay
|
|
1903
|
+
// onResponseComplete already fired. When a residual buffer is still
|
|
1904
|
+
// queued, do NOT release the per-session reply anchor here: the
|
|
1905
|
+
// re-flush's sendMessage reads msg_seq from msgSeqMap, and dropping
|
|
1906
|
+
// the counter now would reset msg_seq to 1 (QQ dedupes on msg_id +
|
|
1907
|
+
// msg_seq and silently drops the tail). Re-arm the pending flag so
|
|
1908
|
+
// whichever branch settles the re-flush chain (its .then() success
|
|
1909
|
+
// path, or .catch() retry/exhaustion) performs the release exactly
|
|
1910
|
+
// once.
|
|
955
1911
|
const s = this.streamState.get(sessionId);
|
|
956
1912
|
if (s === state && s.buffer) {
|
|
1913
|
+
this.pendingStreamDelete.add(sessionId);
|
|
957
1914
|
// Don't clear buffer or retryCount — idleFlush will pick them up.
|
|
1915
|
+
// Clear any stale timer handle first so idleFlush's blocked-branch
|
|
1916
|
+
// re-arm is unconditional.
|
|
1917
|
+
if (s.timer) {
|
|
1918
|
+
clearTimeout(s.timer);
|
|
1919
|
+
s.timer = null;
|
|
1920
|
+
}
|
|
958
1921
|
this.idleFlush(sessionId, this._reconnectId);
|
|
959
1922
|
// Don't return — let .finally() clear flushingSessions
|
|
960
1923
|
// so deferred idleFlush can proceed.
|
|
961
1924
|
}
|
|
1925
|
+
else {
|
|
1926
|
+
// No own anchor (proactive turn / expired TTL) — nothing to
|
|
1927
|
+
// release, and an unconditional release here would delete a
|
|
1928
|
+
// successor turn's anchor. See .catch() release points.
|
|
1929
|
+
deferredRelease = state.msgId;
|
|
1930
|
+
if (s === state) {
|
|
1931
|
+
// Terminal settle of a deferred/cancelled turn while this state
|
|
1932
|
+
// is still current: the turn is fully over, so drop the flush
|
|
1933
|
+
// record and turn counter that onResponseComplete/onPromptEnd
|
|
1934
|
+
// would otherwise have cleaned. Guarded by `s === state` — a
|
|
1935
|
+
// successor turn that replaced the state owns its own
|
|
1936
|
+
// turnCounter/flushedSessions and must be left untouched.
|
|
1937
|
+
this.flushedSessions.delete(sessionId);
|
|
1938
|
+
this.deleteTurnGenerationIfOwned(state, sessionId);
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
962
1941
|
}
|
|
963
|
-
// #8: Clean up streamState only if no content arrived during send
|
|
1942
|
+
// #8: Clean up streamState only if no content arrived during send.
|
|
1943
|
+
// NOTE: does NOT release sessionReplyMsgId here — a mid-response
|
|
1944
|
+
// window gap (no onResponseComplete yet) must keep the anchor so the
|
|
1945
|
+
// next window's fresh state entry reuses it.
|
|
964
1946
|
const s = this.streamState.get(sessionId);
|
|
965
1947
|
if (s === state && !s.buffer) {
|
|
966
1948
|
this.streamState.delete(sessionId);
|
|
@@ -971,43 +1953,287 @@ export class QQChannel extends ChannelBase {
|
|
|
971
1953
|
(e.code === 'RETRY_EXHAUSTED' ||
|
|
972
1954
|
e.code === 'ACTIVE_MSG_DISABLED' ||
|
|
973
1955
|
e.code === 'FALLBACK_FAILED')) {
|
|
974
|
-
|
|
1956
|
+
// The text that accumulated into state.buffer while this send was in
|
|
1957
|
+
// flight is dropped with the entry below, so the loss log must report
|
|
1958
|
+
// it too: the payload count alone under-reports what the operator
|
|
1959
|
+
// loses. Except the residual a boundary captured: captureBoundaryClear
|
|
1960
|
+
// copies exactly that text into sealedPre and flags the flight
|
|
1961
|
+
// 'residual', and the handoff seal below preserves and delivers it
|
|
1962
|
+
// separately, so counting it here would report the same characters
|
|
1963
|
+
// twice. Only the buffer beyond the captured residual is lost here.
|
|
1964
|
+
//
|
|
1965
|
+
// The same holds for the payload itself in the branch below that
|
|
1966
|
+
// folds it into the handoff seal (`boundaryClearedInFlight` set while
|
|
1967
|
+
// this entry still owns the live turn): the seal is then delivered or
|
|
1968
|
+
// re-stashed, so those characters are preserved, not dropped, and
|
|
1969
|
+
// counting them would report the payload as lost while the operator
|
|
1970
|
+
// still receives it. Only the in-flight text beyond the captured
|
|
1971
|
+
// residual is genuinely lost in that branch. Gated on ownsSession
|
|
1972
|
+
// like the handoff itself: an unowned entry's seal is dropped (and
|
|
1973
|
+
// logged) by handOffSealedPre, so its whole payload is a real loss.
|
|
1974
|
+
const payloadFoldedIntoSeal = state.boundaryClearedInFlight !== undefined &&
|
|
1975
|
+
this.ownsLiveTurn(sessionId, state) &&
|
|
1976
|
+
this.ownsSession(sessionId, state);
|
|
1977
|
+
// The else arm of the handoff below preserves the seal this send
|
|
1978
|
+
// carried: the payload's own head when the drain folded the stash's
|
|
1979
|
+
// `pre` in front of its text (a boundary between a chunk and the idle
|
|
1980
|
+
// flush is a normal sequence, and it leaves exactly that seal on the
|
|
1981
|
+
// entry). handOffSealedPre re-stashes or delivers it, so those
|
|
1982
|
+
// characters are preserved while the rest of the payload is not —
|
|
1983
|
+
// charging the whole payload reports delivered text as lost. No
|
|
1984
|
+
// carried seal preserves nothing, so the whole payload stays a real
|
|
1985
|
+
// loss. Gated on ownsSession like the handoff itself: an unowned
|
|
1986
|
+
// entry's seal is dropped (and logged) by handOffSealedPre.
|
|
1987
|
+
const payloadPreservedBySeal = !payloadFoldedIntoSeal &&
|
|
1988
|
+
carriedSeal !== undefined &&
|
|
1989
|
+
this.ownsSession(sessionId, state)
|
|
1990
|
+
? Math.min(carriedSeal.length, buffer.length)
|
|
1991
|
+
: 0;
|
|
1992
|
+
// The widened reconstruction below (else arm) rebuilds the whole
|
|
1993
|
+
// undelivered window from this flight's payload, so every character
|
|
1994
|
+
// of `buffer` is preserved even when the entry is superseded — a
|
|
1995
|
+
// carried seal is not required for that and the old gate on it made
|
|
1996
|
+
// the arm report a loss it no longer causes. Gated on ownsSession
|
|
1997
|
+
// like the handoff itself: an unowned entry's reconstruction is
|
|
1998
|
+
// dropped (and logged) by handOffSealedPre.
|
|
1999
|
+
//
|
|
2000
|
+
// It is owed whenever a boundary cleared the bridge's collection
|
|
2001
|
+
// during the widened flight AND the widened seal does not already
|
|
2002
|
+
// hold the payload: 'residual' means the boundary overwrote the
|
|
2003
|
+
// widened value, and `carriedSeal === undefined` means the flight
|
|
2004
|
+
// started with no seal at all, so onPromptEnd's merge could only
|
|
2005
|
+
// prepend the older head. With a carried seal and marker 'payload'
|
|
2006
|
+
// the live seal already ends with the widened value, so rebuilding
|
|
2007
|
+
// there would duplicate the payload.
|
|
2008
|
+
const widenedWindowReconstructionOwed = !payloadFoldedIntoSeal &&
|
|
2009
|
+
state.sealWidenedInFlight === true &&
|
|
2010
|
+
state.boundaryClearedInFlight !== undefined &&
|
|
2011
|
+
(state.boundaryClearedInFlight === 'residual' ||
|
|
2012
|
+
carriedSeal === undefined);
|
|
2013
|
+
const payloadReconstructedIntoSeal = widenedWindowReconstructionOwed &&
|
|
2014
|
+
this.ownsSession(sessionId, state);
|
|
2015
|
+
const preservedInFlight = payloadFoldedIntoSeal || payloadReconstructedIntoSeal
|
|
2016
|
+
? buffer.length
|
|
2017
|
+
: payloadPreservedBySeal;
|
|
2018
|
+
// A widened in-flight seal already folds the whole `state.buffer`
|
|
2019
|
+
// window into the handoff below, so every character counted here is
|
|
2020
|
+
// delivered (or re-stashed) and the subtraction would report them as
|
|
2021
|
+
// lost: the widened reconstruction is `head + payload + residual`, of
|
|
2022
|
+
// which only the residual is the captured, separately-preserved part.
|
|
2023
|
+
// Nothing is dropped in flight on that arm, so report zero. Without a
|
|
2024
|
+
// widening the subtraction is the pre-existing arithmetic and every
|
|
2025
|
+
// un-widened log line stays byte for byte what it was.
|
|
2026
|
+
const droppedInFlight = state.sealWidenedInFlight === true
|
|
2027
|
+
? 0
|
|
2028
|
+
: state.buffer.length - this.capturedResidual(state).length;
|
|
2029
|
+
const droppedParts = [];
|
|
2030
|
+
const lostInFlight = buffer.length - preservedInFlight;
|
|
2031
|
+
if (lostInFlight > 0) {
|
|
2032
|
+
droppedParts.push(`${lostInFlight} chars`);
|
|
2033
|
+
}
|
|
2034
|
+
if (droppedInFlight > 0) {
|
|
2035
|
+
droppedParts.push(`${droppedInFlight} chars buffered in flight`);
|
|
2036
|
+
}
|
|
2037
|
+
process.stderr.write(`[QQ:${this.name}] ${logLabel} delivery failed (${e.code}): ${sanitizeLogText(e.message, 200)}, ` +
|
|
2038
|
+
(droppedParts.length > 0
|
|
2039
|
+
? `dropping ${droppedParts.join(' plus ')}`
|
|
2040
|
+
: preservedInFlight > 0
|
|
2041
|
+
? `dropping nothing (${preservedInFlight} chars preserved in the handoff seal)`
|
|
2042
|
+
: 'dropping nothing') +
|
|
2043
|
+
'\n');
|
|
975
2044
|
// RETRY_EXHAUSTED / ACTIVE_MSG_DISABLED / FALLBACK_FAILED = permanent failure.
|
|
976
2045
|
// Drop everything — including any residual buffer that arrived concurrently.
|
|
977
2046
|
const current = this.streamState.get(sessionId);
|
|
2047
|
+
// Hand the sealed pre-boundary head off BEFORE any delete and outside
|
|
2048
|
+
// the identity guard below: this entry carries the only copy,
|
|
2049
|
+
// and the boundary cleared the bridge's collection so it is absent
|
|
2050
|
+
// from fullText. The handoff is itself gated on session ownership
|
|
2051
|
+
// inside handOffSealedPre (ownsSession), which drops the seal when the
|
|
2052
|
+
// session was torn down or replaced outright. A superseded state
|
|
2053
|
+
// reached here precisely because the superseded branch left the head
|
|
2054
|
+
// in place for the in-flight owner — when this send is that owner and
|
|
2055
|
+
// it fails permanently, this arm is the only remaining chance to
|
|
2056
|
+
// re-stash it; a parked turn with no successor delivers it on its own
|
|
2057
|
+
// anchor. handOffSealedPre is idempotent (it clears sealedPre at
|
|
2058
|
+
// its top), so a state that already handed off is a no-op. The seal
|
|
2059
|
+
// this send carried is passed too: a boundary may have re-sealed the
|
|
2060
|
+
// state while it was in flight, and only this chain knows the older
|
|
2061
|
+
// head ever existed. When such a boundary cleared the collection
|
|
2062
|
+
// during the flight, the entry's seal is widened to the whole payload
|
|
2063
|
+
// first (folded in, so the merge below is not applied twice) — but
|
|
2064
|
+
// only while this state still owns the live turn: a superseded
|
|
2065
|
+
// entry's text is abandoned with its permanent failure and must not
|
|
2066
|
+
// be injected into a successor's reply.
|
|
2067
|
+
if (payloadFoldedIntoSeal) {
|
|
2068
|
+
// A boundary cleared the bridge's collection, so the whole
|
|
2069
|
+
// undelivered window must be re-sealed. When onPromptEnd's stash
|
|
2070
|
+
// merge widened the seal during this flight, that window is the
|
|
2071
|
+
// reconstruction rebufferAfterStashMerge builds from the entry —
|
|
2072
|
+
// the OLDER stashed head, then this payload, then whatever
|
|
2073
|
+
// accumulated since — exactly what the transient re-buffer arm
|
|
2074
|
+
// seals. sealClearedPayload would instead put the payload in front
|
|
2075
|
+
// of the head and drop the head with the entry. Both arms ask the
|
|
2076
|
+
// same rule (sealForClearedWindow) so they cannot drift.
|
|
2077
|
+
state.sealedPre = this.sealForClearedWindow(state, buffer, () => this.rebufferAfterStashMerge(state, buffer));
|
|
2078
|
+
this.handOffSealedPre(state, sessionId);
|
|
2079
|
+
}
|
|
2080
|
+
else {
|
|
2081
|
+
// onPromptEnd's stash merge already folded the carried head into
|
|
2082
|
+
// the live seal as its suffix (sealWidenedInFlight), so passing it
|
|
2083
|
+
// again would prepend the delivered head a second time in
|
|
2084
|
+
// handOffSealedPre's merge. Same rule resealOnRebuffer applies.
|
|
2085
|
+
//
|
|
2086
|
+
// EXCEPT when the widened seal does not already hold the payload.
|
|
2087
|
+
// Two explicit states say so, and neither is a text comparison:
|
|
2088
|
+
//
|
|
2089
|
+
// * marker 'residual' — the boundary overwrote the widened seal
|
|
2090
|
+
// with its own live residual, which is the OLDER stashed head
|
|
2091
|
+
// plus whatever arrived since the boundary. The in-flight payload
|
|
2092
|
+
// belongs BETWEEN those two portions: the exact
|
|
2093
|
+
// `head + payload + newer` window rebufferAfterStashMerge
|
|
2094
|
+
// rebuilds. Suppressing `carriedSeal` would drop the payload (it
|
|
2095
|
+
// is on no wire and in no seal) while a plain prepend would
|
|
2096
|
+
// transpose it in front of the older head ('PA').
|
|
2097
|
+
//
|
|
2098
|
+
// * `carriedSeal === undefined` with any boundary marker — the
|
|
2099
|
+
// flight started with no seal, so onPromptEnd's merge could only
|
|
2100
|
+
// prepend the older head; the widened seal is the head alone and
|
|
2101
|
+
// misses the payload the boundary stripped from the collection.
|
|
2102
|
+
//
|
|
2103
|
+
// With a carried seal and marker 'payload' (or no marker) the live
|
|
2104
|
+
// seal still ends with the widened value and already holds the
|
|
2105
|
+
// carried head as its suffix, so rebuilding there would duplicate
|
|
2106
|
+
// the payload (the very 'PAP' this branch prevents). No marker at
|
|
2107
|
+
// all means no boundary cleared the collection, so the payload is
|
|
2108
|
+
// still in fullText and must not be welded into the seal.
|
|
2109
|
+
//
|
|
2110
|
+
// The window is rebuilt from THIS FLIGHT's payload, not from
|
|
2111
|
+
// `carriedSeal`: the send may have started with no seal at all, and
|
|
2112
|
+
// `buffer` is what the wire was asked to carry either way. Both arms
|
|
2113
|
+
// therefore ask sealForClearedWindow with the same payload, so they
|
|
2114
|
+
// cannot drift — the payloadFoldedIntoSeal arm above uses exactly
|
|
2115
|
+
// the same expression.
|
|
2116
|
+
if (widenedWindowReconstructionOwed) {
|
|
2117
|
+
state.sealedPre = this.sealForClearedWindow(state, buffer, () => this.rebufferAfterStashMerge(state, buffer));
|
|
2118
|
+
}
|
|
2119
|
+
this.handOffSealedPre(state, sessionId, undefined, state.sealWidenedInFlight ? undefined : carriedSeal);
|
|
2120
|
+
}
|
|
978
2121
|
if (current === state) {
|
|
979
2122
|
this.streamState.delete(sessionId);
|
|
980
2123
|
}
|
|
981
|
-
|
|
2124
|
+
// Release only when the settle is terminal (the session was parked
|
|
2125
|
+
// for teardown) or this entry was superseded by a later turn. While
|
|
2126
|
+
// this state is still the session's live turn, the anchor belongs to
|
|
2127
|
+
// the reply still streaming: releasing it now cascades the
|
|
2128
|
+
// msgSeqMap counter away, and the next chunk re-resolves the same
|
|
2129
|
+
// msg_id from replyContextByMessageId and restarts msg_seq at 1 —
|
|
2130
|
+
// a pair QQ already accepted, which it dedupes and silently drops.
|
|
2131
|
+
// onResponseComplete's terminal release reclaims it at turn end.
|
|
2132
|
+
const turnStillLive = this.ownsLiveTurn(sessionId, state) &&
|
|
2133
|
+
!this.pendingStreamDelete.has(sessionId);
|
|
2134
|
+
if (!turnStillLive && state.msgId !== undefined) {
|
|
2135
|
+
this.releaseSessionReplyAnchor(sessionId, state.msgId);
|
|
2136
|
+
}
|
|
2137
|
+
// Only consume the park/flush records when this chain still owns
|
|
2138
|
+
// the session's entry: a successor turn that parked its own
|
|
2139
|
+
// residual must keep its flags.
|
|
2140
|
+
if (current === state && this.pendingStreamDelete.has(sessionId)) {
|
|
982
2141
|
this.pendingStreamDelete.delete(sessionId);
|
|
983
2142
|
this.flushedSessions.delete(sessionId);
|
|
2143
|
+
this.deleteTurnGenerationIfOwned(state, sessionId);
|
|
984
2144
|
}
|
|
985
2145
|
return;
|
|
986
2146
|
}
|
|
987
2147
|
process.stderr.write(`[QQ:${this.name}] ${logLabel} send failed: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n`);
|
|
988
2148
|
// #1: Never undo previously-succeeded flush records on failure
|
|
2149
|
+
//
|
|
2150
|
+
// The sealed pre-boundary head must be handed off exactly when
|
|
2151
|
+
// this chain gives up on delivering it, and the chain gives up when it
|
|
2152
|
+
// neither succeeded nor scheduled a retry FOR THIS STATE. The predicate
|
|
2153
|
+
// is therefore "scheduled a retry", NOT "msgId present": a state with
|
|
2154
|
+
// no msgId (session anchor expired mid-turn, or a proactive turn) still
|
|
2155
|
+
// carries a head, and gating the handoff on msgId drops it. Every
|
|
2156
|
+
// branch below that arms a retry for `current === state` sets this
|
|
2157
|
+
// flag; every other transient path falls through to the handoff at the
|
|
2158
|
+
// end of this arm, which covers pending/non-pending x current/not.
|
|
2159
|
+
let retryScheduled = false;
|
|
989
2160
|
if (this.pendingStreamDelete.has(sessionId)) {
|
|
990
|
-
// Session is ending - retry up to MAX_FLUSH_RETRIES
|
|
991
|
-
|
|
2161
|
+
// Session is ending - retry up to MAX_FLUSH_RETRIES. When a retry is
|
|
2162
|
+
// scheduled, re-arm the pending flag so whichever branch settles the
|
|
2163
|
+
// retry chain (its .then() success path, or exhaustion below)
|
|
2164
|
+
// performs the anchor release exactly once — releasing earlier would
|
|
2165
|
+
// reset msg_seq for the retried tail send (see .then() above).
|
|
992
2166
|
const current = this.streamState.get(sessionId);
|
|
2167
|
+
// Only consume the park flag when this chain still owns the
|
|
2168
|
+
// session's entry: a successor turn that parked its own residual
|
|
2169
|
+
// must keep its flag.
|
|
993
2170
|
if (current === state) {
|
|
994
|
-
|
|
2171
|
+
this.pendingStreamDelete.delete(sessionId);
|
|
2172
|
+
// Restore the failed send's text to the buffer in the order the
|
|
2173
|
+
// seal states: the stashed head onPromptEnd merged during this
|
|
2174
|
+
// flight sits at the front of the buffer and is OLDER than the
|
|
2175
|
+
// payload, which in turn is older than any chunk that arrived since
|
|
2176
|
+
// (see rebufferAfterStashMerge). Without a widening this is the
|
|
2177
|
+
// plain prepend: the payload predates the text that accumulated
|
|
2178
|
+
// during the flight.
|
|
2179
|
+
current.buffer = this.rebufferAfterStashMerge(current, buffer);
|
|
2180
|
+
// The failed payload is buffer-resident again and must be sealed.
|
|
2181
|
+
// A boundary that cleared the bridge's collection while this send
|
|
2182
|
+
// was in flight removed the payload's whole text from fullText —
|
|
2183
|
+
// the seal it carried may cover only a prefix — so the whole
|
|
2184
|
+
// payload is re-sealed, plus the residual it sealed itself (flagged
|
|
2185
|
+
// deterministically, not by comparing seal text). Only a chain that
|
|
2186
|
+
// still owns the live turn may write the seal: a superseded
|
|
2187
|
+
// entry's payload is abandoned with its failure and must not be
|
|
2188
|
+
// injected into a successor's reply. The carried-seal
|
|
2189
|
+
// branch is the backstop for a re-seal whose boundary marker was
|
|
2190
|
+
// missed.
|
|
2191
|
+
this.resealOnRebuffer(sessionId, state, current, buffer, carriedSeal);
|
|
2192
|
+
this.carryWidenedWindowIntoSeal(current);
|
|
995
2193
|
current.retryCount++;
|
|
996
2194
|
if (this.maxFlushRetries <= 0 ||
|
|
997
2195
|
current.retryCount < this.maxFlushRetries) {
|
|
2196
|
+
this.pendingStreamDelete.add(sessionId);
|
|
998
2197
|
const reconnectId = this._reconnectId;
|
|
999
2198
|
const delay = current.retryCount > 1
|
|
1000
2199
|
? QQChannel.IDLE_FLUSH_BACKOFF_MS
|
|
1001
2200
|
: QQChannel.IDLE_FLUSH_MS;
|
|
2201
|
+
// onPromptEnd parks BEFORE it clears the entry's timer, so a
|
|
2202
|
+
// chunk that arrived while this send was in flight can still
|
|
2203
|
+
// hold a live idle handle here. Overwriting it without clearing
|
|
2204
|
+
// would orphan that timer: it fires at IDLE_FLUSH_MS, ahead of
|
|
2205
|
+
// the IDLE_FLUSH_BACKOFF_MS retry armed below, collapsing the
|
|
2206
|
+
// backoff tier and burning maxFlushRetries faster — and no
|
|
2207
|
+
// teardown path can reach the orphan because state.timer now
|
|
2208
|
+
// holds the new handle.
|
|
2209
|
+
if (current.timer) {
|
|
2210
|
+
clearTimeout(current.timer);
|
|
2211
|
+
current.timer = null;
|
|
2212
|
+
}
|
|
1002
2213
|
current.timer = setTimeout(() => {
|
|
1003
2214
|
this.idleFlush(sessionId, reconnectId);
|
|
1004
2215
|
}, delay);
|
|
2216
|
+
current.timerReconnectId = reconnectId;
|
|
1005
2217
|
current.timer.unref?.();
|
|
2218
|
+
// Retry armed for this state — this chain still owns the head.
|
|
2219
|
+
retryScheduled = true;
|
|
1006
2220
|
}
|
|
1007
2221
|
else {
|
|
2222
|
+
// The entry carries the sealed pre-boundary head; hand it off
|
|
2223
|
+
// before the delete drops the only copy (see handOffSealedPre).
|
|
2224
|
+
// This branch consumed the park flag above, so it states the
|
|
2225
|
+
// turn is over explicitly — otherwise the handoff would re-stash
|
|
2226
|
+
// into a turn whose counter it is about to drop.
|
|
2227
|
+
this.handOffSealedPre(state, sessionId, true);
|
|
1008
2228
|
this.streamState.delete(sessionId);
|
|
2229
|
+
if (state.msgId !== undefined) {
|
|
2230
|
+
this.releaseSessionReplyAnchor(sessionId, state.msgId);
|
|
2231
|
+
}
|
|
1009
2232
|
// #2: Clean up flushedSessions on retry exhaustion
|
|
1010
2233
|
this.flushedSessions.delete(sessionId);
|
|
2234
|
+
// Deferred turn fully abandoned — drop its turn-generation
|
|
2235
|
+
// records only when they still belong to this turn.
|
|
2236
|
+
this.deleteTurnGenerationIfOwned(state, sessionId);
|
|
1011
2237
|
process.stderr.write(`[QQ:${this.name}] ${logLabel} retries exhausted for ${sanitizeLogText(sessionId, 64)}\n`);
|
|
1012
2238
|
}
|
|
1013
2239
|
}
|
|
@@ -1017,17 +2243,33 @@ export class QQChannel extends ChannelBase {
|
|
|
1017
2243
|
const current = this.streamState.get(sessionId);
|
|
1018
2244
|
// #6: Identity guard — only operate on the same state reference
|
|
1019
2245
|
if (current === state) {
|
|
1020
|
-
current.buffer =
|
|
2246
|
+
current.buffer = this.rebufferAfterStashMerge(current, buffer);
|
|
2247
|
+
// Same re-seal and turn-ownership rule as the parked branch above
|
|
2248
|
+
// (see resealOnRebuffer for why the whole payload is sealed and
|
|
2249
|
+
// `carriedSeal` is the backstop).
|
|
2250
|
+
this.resealOnRebuffer(sessionId, state, current, buffer, carriedSeal);
|
|
2251
|
+
this.carryWidenedWindowIntoSeal(current);
|
|
1021
2252
|
// #3: If re-buffer exceeds max length, flush immediately
|
|
1022
2253
|
if (current.buffer.length >= this.streamBufferLimit(current)) {
|
|
1023
2254
|
current.retryCount++;
|
|
1024
2255
|
if (this.maxFlushRetries > 0 &&
|
|
1025
2256
|
current.retryCount >= this.maxFlushRetries) {
|
|
2257
|
+
// Hand the sealed pre-boundary head off before the delete
|
|
2258
|
+
// drops the only copy (see handOffSealedPre).
|
|
2259
|
+
this.handOffSealedPre(state, sessionId);
|
|
1026
2260
|
this.streamState.delete(sessionId);
|
|
2261
|
+
// No release here: the turn is not over (onResponseComplete
|
|
2262
|
+
// has not fired) — the anchor still belongs to this reply,
|
|
2263
|
+
// and onResponseComplete's terminal release cleans it up.
|
|
2264
|
+
// Releasing now would make later chunks fall back to the
|
|
2265
|
+
// racy chat-level entry.
|
|
1027
2266
|
this.flushedSessions.delete(sessionId);
|
|
1028
2267
|
process.stderr.write(`[QQ:${this.name}] ${logLabel} retries exhausted (buffer exceeds limit) for ${sanitizeLogText(sessionId, 64)}\n`);
|
|
1029
2268
|
}
|
|
1030
2269
|
else {
|
|
2270
|
+
// Retry armed for this state (idleFlush re-flushes now, or
|
|
2271
|
+
// re-arms while a send is in flight): this chain owns the head.
|
|
2272
|
+
retryScheduled = true;
|
|
1031
2273
|
this.idleFlush(sessionId, this._reconnectId);
|
|
1032
2274
|
}
|
|
1033
2275
|
}
|
|
@@ -1043,27 +2285,143 @@ export class QQChannel extends ChannelBase {
|
|
|
1043
2285
|
current.timer = setTimeout(() => {
|
|
1044
2286
|
this.idleFlush(sessionId, reconnectId);
|
|
1045
2287
|
}, delay);
|
|
2288
|
+
current.timerReconnectId = reconnectId;
|
|
1046
2289
|
current.timer.unref?.();
|
|
1047
2290
|
}
|
|
2291
|
+
// Retry armed (or already pending) for this state — this chain
|
|
2292
|
+
// still owns the head.
|
|
2293
|
+
retryScheduled = true;
|
|
1048
2294
|
}
|
|
1049
2295
|
else {
|
|
2296
|
+
// Hand the sealed pre-boundary head off before the delete
|
|
2297
|
+
// drops the only copy (see handOffSealedPre).
|
|
2298
|
+
this.handOffSealedPre(state, sessionId);
|
|
1050
2299
|
this.streamState.delete(sessionId);
|
|
2300
|
+
// No release here — same rationale as the buffer-over-limit
|
|
2301
|
+
// branch above: the turn has not ended, so the anchor is
|
|
2302
|
+
// still this reply's and onResponseComplete's terminal
|
|
2303
|
+
// release will clean it up.
|
|
1051
2304
|
// #2: Clean up flushedSessions on retry exhaustion
|
|
1052
2305
|
this.flushedSessions.delete(sessionId);
|
|
1053
2306
|
process.stderr.write(`[QQ:${this.name}] ${logLabel} retries exhausted for ${sanitizeLogText(sessionId, 64)}\n`);
|
|
1054
2307
|
}
|
|
1055
2308
|
}
|
|
1056
2309
|
}
|
|
2310
|
+
else {
|
|
2311
|
+
// The entry was destroyed while this send was in flight (session
|
|
2312
|
+
// death or a successor turn). Nothing will settle for this state,
|
|
2313
|
+
// so release its anchor here — expectedMsgId keeps a successor's
|
|
2314
|
+
// anchor untouched. The sealed head is NOT handled here:
|
|
2315
|
+
// ownership passes on by the retryScheduled check at the end of
|
|
2316
|
+
// this arm, whose predicate is "no retry armed for this state",
|
|
2317
|
+
// not "msgId present".
|
|
2318
|
+
// The payload has no entry left to re-buffer into and no retry can
|
|
2319
|
+
// carry it, so it is abandoned here (a superseded turn's payload is
|
|
2320
|
+
// never re-sealed into a successor). Report the loss like the
|
|
2321
|
+
// permanent arm does instead of letting it vanish with the entry.
|
|
2322
|
+
if (buffer) {
|
|
2323
|
+
process.stderr.write(`[QQ:${this.name}] ${logLabel} dropped ${buffer.length} chars of an abandoned turn for ${sanitizeLogText(sessionId, 64)}\n`);
|
|
2324
|
+
}
|
|
2325
|
+
if (state.msgId !== undefined) {
|
|
2326
|
+
this.releaseSessionReplyAnchor(sessionId, state.msgId);
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
// No branch above armed a retry for this state, so this chain
|
|
2331
|
+
// has given up on delivering the sealed pre-boundary head — pass its
|
|
2332
|
+
// ownership on. handOffSealedPre is idempotent (it clears sealedPre at
|
|
2333
|
+
// its top), so the exhaustion arms that already handed off are
|
|
2334
|
+
// unaffected. Deliberately not gated on msgId: the head's ownership is
|
|
2335
|
+
// independent of the reply anchor.
|
|
2336
|
+
if (!retryScheduled) {
|
|
2337
|
+
this.handOffSealedPre(state, sessionId);
|
|
1057
2338
|
}
|
|
1058
2339
|
})
|
|
1059
2340
|
.finally(() => {
|
|
1060
|
-
//
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
2341
|
+
// Ownership-keyed release: only the chain that still owns the marker
|
|
2342
|
+
// may clear it, so a superseded chain cannot unblock a successor's
|
|
2343
|
+
// concurrent send.
|
|
2344
|
+
if (this.flushingSessions.get(sessionId) !== state)
|
|
2345
|
+
return;
|
|
2346
|
+
this.flushingSessions.delete(sessionId);
|
|
2347
|
+
if (deferredRelease !== undefined) {
|
|
2348
|
+
this.releaseSessionReplyAnchor(sessionId, deferredRelease);
|
|
2349
|
+
}
|
|
2350
|
+
// A successor turn can only park while this chain's marker is live
|
|
2351
|
+
// (onResponseComplete/onPromptEnd test .has(), not identity), so every
|
|
2352
|
+
// settle path above guards on the state reference and never reaches
|
|
2353
|
+
// the park flag. Service it here — the one place that knows the marker
|
|
2354
|
+
// just became free — by handing the residual to a fresh idle timer;
|
|
2355
|
+
// the re-flush's own settle then consumes the flag.
|
|
2356
|
+
if (this.pendingStreamDelete.has(sessionId)) {
|
|
2357
|
+
const parked = this.streamState.get(sessionId);
|
|
2358
|
+
if (parked && parked !== state) {
|
|
2359
|
+
if (parked.buffer) {
|
|
2360
|
+
if (parked.timer)
|
|
2361
|
+
clearTimeout(parked.timer);
|
|
2362
|
+
const reconnectId = this._reconnectId;
|
|
2363
|
+
parked.timer = setTimeout(() => {
|
|
2364
|
+
this.idleFlush(sessionId, reconnectId);
|
|
2365
|
+
}, QQChannel.IDLE_FLUSH_MS);
|
|
2366
|
+
parked.timerReconnectId = reconnectId;
|
|
2367
|
+
parked.timer.unref?.();
|
|
2368
|
+
}
|
|
2369
|
+
else {
|
|
2370
|
+
// No residual to deliver — the turn is over, so run the terminal
|
|
2371
|
+
// teardown now rather than leaving the park flag to outlive it.
|
|
2372
|
+
// Clear the timer first: the release guard scans this entry for a
|
|
2373
|
+
// live handle and would otherwise keep the msg_seq counter for a
|
|
2374
|
+
// turn that is already gone.
|
|
2375
|
+
if (parked.timer) {
|
|
2376
|
+
clearTimeout(parked.timer);
|
|
2377
|
+
parked.timer = null;
|
|
2378
|
+
}
|
|
2379
|
+
if (parked.msgId !== undefined) {
|
|
2380
|
+
this.releaseSessionReplyAnchor(sessionId, parked.msgId);
|
|
2381
|
+
}
|
|
2382
|
+
this.pendingStreamDelete.delete(sessionId);
|
|
2383
|
+
this.flushedSessions.delete(sessionId);
|
|
2384
|
+
this.deleteTurnGenerationIfOwned(parked, sessionId);
|
|
2385
|
+
if (this.streamState.get(sessionId) === parked) {
|
|
2386
|
+
this.streamState.delete(sessionId);
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
1064
2390
|
}
|
|
1065
2391
|
});
|
|
1066
2392
|
}
|
|
2393
|
+
/**
|
|
2394
|
+
* Drop the session's turn-generation records — the turn counter and the
|
|
2395
|
+
* completion record completedTurns — only when they still belong to the
|
|
2396
|
+
* settling turn. A successor turn that started while this chain was in flight
|
|
2397
|
+
* bumped the counter and cleared the completion record already (see
|
|
2398
|
+
* onPromptStart); deleting them here would reset the successor's stale-state
|
|
2399
|
+
* detection to 0 and let a stale completion record alias onto its turn.
|
|
2400
|
+
*
|
|
2401
|
+
* The counter is kept while this session still has a prompt in flight. A
|
|
2402
|
+
* settle can run in the microtask window between onResponseComplete parking
|
|
2403
|
+
* the session and ChannelBase's onPromptEnd (which clears the active marker
|
|
2404
|
+
* first), and onPromptEnd then recognises the turn's own re-stashed sealed
|
|
2405
|
+
* head by comparing the stash's turn against this counter — as does the
|
|
2406
|
+
* parked-stash service before its early return. Deleting the counter here
|
|
2407
|
+
* makes that gate read 0 and discard the head as superseded: the reply's
|
|
2408
|
+
* opening reaches no wire, no buffer and no stash while the turn reports
|
|
2409
|
+
* completed. The completion record still goes: it is turn-scoped, and a
|
|
2410
|
+
* later prompt reusing the number must not alias it. onPromptEnd,
|
|
2411
|
+
* onPromptStart and onSessionDied all still delete the counter once the
|
|
2412
|
+
* prompt is over, so it stays bounded by the live sessions and can never be
|
|
2413
|
+
* inherited by a successor turn.
|
|
2414
|
+
*/
|
|
2415
|
+
deleteTurnGenerationIfOwned(state, sessionId) {
|
|
2416
|
+
if (state.turn !== (this.turnCounter.get(sessionId) ?? 0))
|
|
2417
|
+
return;
|
|
2418
|
+
if (this.completedTurns.get(sessionId) === state.turn) {
|
|
2419
|
+
this.completedTurns.delete(sessionId);
|
|
2420
|
+
}
|
|
2421
|
+
if (this.activePromptSessions.has(sessionId))
|
|
2422
|
+
return;
|
|
2423
|
+
this.turnCounter.delete(sessionId);
|
|
2424
|
+
}
|
|
1067
2425
|
onToolCall(_chatId, event) {
|
|
1068
2426
|
const state = this.streamState.get(event.sessionId);
|
|
1069
2427
|
if (!state || !state.buffer)
|
|
@@ -1080,16 +2438,164 @@ export class QQChannel extends ChannelBase {
|
|
|
1080
2438
|
}
|
|
1081
2439
|
onResponseBoundary(_chatId, sessionId) {
|
|
1082
2440
|
const state = this.streamState.get(sessionId);
|
|
2441
|
+
const currentTurn = this.turnCounter.get(sessionId) ?? 0;
|
|
2442
|
+
// The bridge's collection is cleared here, in-flight payload included: seal
|
|
2443
|
+
// the live turn's residual and record what a re-buffer must re-seal. The
|
|
2444
|
+
// ungated bridge observer reaches the same seal when this hook is
|
|
2445
|
+
// suppressed. The bridge's chunk collection is cleared on every boundary,
|
|
2446
|
+
// so a stash accumulated before this point is no longer part of any later
|
|
2447
|
+
// fullText and must still be prepended when the turn completes. Seal that
|
|
2448
|
+
// portion separately: chunks arriving after the boundary append to `text`
|
|
2449
|
+
// but are already in fullText and must not be prepended too.
|
|
2450
|
+
this.captureBoundaryClear(sessionId);
|
|
2451
|
+
this.sealOrphanStash(sessionId);
|
|
2452
|
+
// A stale entry belongs to an earlier turn; when the deletes below drop it
|
|
2453
|
+
// its parked flush chain can never settle again, so its reply anchor has
|
|
2454
|
+
// to be released here (expectedMsgId-gated, so a live turn's own anchor is
|
|
2455
|
+
// untouched). A stale entry kept by the branch below must NOT release: it
|
|
2456
|
+
// still needs the anchor to deliver its residual.
|
|
2457
|
+
const staleMsgId = state && state.turn !== currentTurn ? state.msgId : undefined;
|
|
2458
|
+
if (state &&
|
|
2459
|
+
(state.buffer ||
|
|
2460
|
+
this.flushingSessions.has(sessionId) ||
|
|
2461
|
+
this.pendingStreamDelete.has(sessionId))) {
|
|
2462
|
+
// An unflushed residual must survive the boundary. The bridge emits this
|
|
2463
|
+
// boundary immediately before the tool-call/permission event that
|
|
2464
|
+
// QQChannel.onToolCall flushes that very buffer with, so deleting the
|
|
2465
|
+
// entry here silently destroys the pre-tool text. A send still in flight
|
|
2466
|
+
// or a turn parked for teardown has the same claim on its residual — no
|
|
2467
|
+
// other path can re-deliver it. Keep the entry; the tool-call event (or
|
|
2468
|
+
// the idle timer below) delivers it.
|
|
2469
|
+
// Do NOT arm pendingStreamDelete: for the in-flight case that flag means
|
|
2470
|
+
// "the turn is over", and for the parked case it is already armed. This
|
|
2471
|
+
// is a mid-turn window gap, so hand the residual to the idle timer
|
|
2472
|
+
// instead — idleFlush's blocked branch re-arms while the send is live.
|
|
2473
|
+
if (state.buffer) {
|
|
2474
|
+
// The live turn's prefix is already sealed above by
|
|
2475
|
+
// captureBoundaryClear, which applies the same live-turn gate.
|
|
2476
|
+
// Leave a handle already live under this generation alone: a parked
|
|
2477
|
+
// retry's backoff timer is already going to deliver this residual, and
|
|
2478
|
+
// re-arming at the shorter idle cadence on every boundary would
|
|
2479
|
+
// collapse that tier and push the parked residual's deadline out. Same
|
|
2480
|
+
// guard as the divert self-heal below.
|
|
2481
|
+
const timerLive = state.timer !== null && state.timerReconnectId === this._reconnectId;
|
|
2482
|
+
if (!timerLive) {
|
|
2483
|
+
if (state.timer)
|
|
2484
|
+
clearTimeout(state.timer);
|
|
2485
|
+
const reconnectId = this._reconnectId;
|
|
2486
|
+
state.timer = setTimeout(() => {
|
|
2487
|
+
this.idleFlush(sessionId, reconnectId);
|
|
2488
|
+
}, QQChannel.IDLE_FLUSH_MS);
|
|
2489
|
+
state.timerReconnectId = reconnectId;
|
|
2490
|
+
state.timer.unref?.();
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
return;
|
|
2494
|
+
}
|
|
2495
|
+
// The entry is being dropped: disarm its timer before the delete.
|
|
1083
2496
|
if (state?.timer) {
|
|
1084
2497
|
clearTimeout(state.timer);
|
|
2498
|
+
state.timer = null;
|
|
1085
2499
|
}
|
|
1086
2500
|
this.streamState.delete(sessionId);
|
|
1087
2501
|
this.flushingSessions.delete(sessionId);
|
|
1088
2502
|
this.pendingStreamDelete.delete(sessionId);
|
|
1089
2503
|
this.flushedSessions.delete(sessionId);
|
|
2504
|
+
if (staleMsgId !== undefined) {
|
|
2505
|
+
this.releaseSessionReplyAnchor(sessionId, staleMsgId);
|
|
2506
|
+
}
|
|
1090
2507
|
}
|
|
1091
2508
|
async onResponseComplete(chatId, fullText, sessionId, segment) {
|
|
1092
2509
|
const state = this.streamState.get(sessionId);
|
|
2510
|
+
const currentTurn = this.turnCounter.get(sessionId) ?? 0;
|
|
2511
|
+
// This turn's completion has run — the deferred branch below counts too.
|
|
2512
|
+
// It will not run again for this turn, so a stash tagged with it would have
|
|
2513
|
+
// no guaranteed consumer: record that for handOffSealedPre.
|
|
2514
|
+
this.completedTurns.set(sessionId, currentTurn);
|
|
2515
|
+
if (state && state.turn !== currentTurn) {
|
|
2516
|
+
// Stale entry owned by a previous turn's deferred flush chain — it
|
|
2517
|
+
// will deliver its own residual and tear itself down. Send this turn's
|
|
2518
|
+
// text through the base path so it is neither lost nor mixed with the
|
|
2519
|
+
// old turn's anchor.
|
|
2520
|
+
// This turn's own anchor (set by onPromptStart) may still be valid —
|
|
2521
|
+
// use it instead of falling back to the racy chat-level entry, which
|
|
2522
|
+
// this PR exists to avoid re-parenting onto.
|
|
2523
|
+
// A live turn's stashed pre-boundary text is prepended only when a
|
|
2524
|
+
// boundary has cleared the bridge's chunk collection since it was
|
|
2525
|
+
// taken; otherwise fullText already carries those chunks and prepending
|
|
2526
|
+
// would deliver the head twice. A superseded stash is dropped
|
|
2527
|
+
// with a log.
|
|
2528
|
+
let replyText = fullText;
|
|
2529
|
+
const held = this.streamOrphanBuffer.get(sessionId);
|
|
2530
|
+
if (held !== undefined) {
|
|
2531
|
+
if (held.turn === currentTurn) {
|
|
2532
|
+
this.reportOrphanStashCap(sessionId, held);
|
|
2533
|
+
this.streamOrphanBuffer.delete(sessionId);
|
|
2534
|
+
if (held.pre) {
|
|
2535
|
+
replyText = held.pre + fullText;
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
else {
|
|
2539
|
+
this.dropOrphanStash(sessionId, held);
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
// Only the stash this send actually carries may be reported as a sealed
|
|
2543
|
+
// head: a superseded stash is dropped above and never enters replyText,
|
|
2544
|
+
// so handing it to the checked send would report the same characters a
|
|
2545
|
+
// second time as a loss the delivery never caused.
|
|
2546
|
+
const carried = held !== undefined && held.turn === currentTurn ? held : undefined;
|
|
2547
|
+
const { entry: capturedEntry, msgId: captured } = this.resolveSessionReplyAnchor(sessionId);
|
|
2548
|
+
try {
|
|
2549
|
+
if (captured) {
|
|
2550
|
+
// Keep the [sender · task] attribution: sendMessage hardcodes an
|
|
2551
|
+
// undefined sourceLabel, so the anchored send goes through the
|
|
2552
|
+
// reply-context helper. The stale entry's own sourceLabel belongs to
|
|
2553
|
+
// the old turn, so only this turn's label is used.
|
|
2554
|
+
// Mark the send in flight for the release guard: the surviving
|
|
2555
|
+
// streamState entry carries the OLD turn's msgId, so the
|
|
2556
|
+
// streamState/flushingSessions check cannot see this send.
|
|
2557
|
+
this.beginMsgSeqSend(captured);
|
|
2558
|
+
try {
|
|
2559
|
+
await this.sendFinalSegmentChecked(sessionId, carried, () => this.sendMessageWithReplyContext(chatId, replyText, undefined, segment?.sourceLabel ?? this.getResponseSourceLabel(sessionId), captured, capturedEntry?.timestamp));
|
|
2560
|
+
}
|
|
2561
|
+
finally {
|
|
2562
|
+
this.endMsgSeqSend(captured);
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
else {
|
|
2566
|
+
// super.onResponseComplete types as void and dispatches to the QQ
|
|
2567
|
+
// sendResponseMessage override, which drops the SendBlock — an
|
|
2568
|
+
// anchorless stale completion (this turn's own reply anchor absent or
|
|
2569
|
+
// past REPLY_MSG_ID_TTL_MS) would look delivered to the catch below,
|
|
2570
|
+
// which only sees throws. Send what the base path sends and route the
|
|
2571
|
+
// block through the checked helper.
|
|
2572
|
+
await this.sendFinalSegmentChecked(sessionId, carried, () => this.sendMessageWithReplyContext(chatId, replyText, this.resolveResponseReplyContext(sessionId), segment?.sourceLabel ?? this.getResponseSourceLabel(sessionId)));
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
catch (e) {
|
|
2576
|
+
this.logLostSealedHead(carried?.pre, sessionId, e);
|
|
2577
|
+
throw e;
|
|
2578
|
+
}
|
|
2579
|
+
finally {
|
|
2580
|
+
// Release for both outcomes. onPromptEnd cannot cover this branch: the
|
|
2581
|
+
// predecessor's park flag makes it early-return, and on a throw the
|
|
2582
|
+
// in-try release used to be skipped, leaving this turn's anchor (and
|
|
2583
|
+
// its msg_seq counter) behind an in-flight-send veto forever. The
|
|
2584
|
+
// `finally` runs after endMsgSeqSend, so the counter this send owns is
|
|
2585
|
+
// reclaimable. Identity-gated either way: `captured` for a live anchor,
|
|
2586
|
+
// the raw entry's msgId for one that expired — /clear deletes the
|
|
2587
|
+
// session queue, so a successor turn can install its own anchor while a
|
|
2588
|
+
// wedged send is still in flight, and an unguarded release would delete
|
|
2589
|
+
// it. With no anchor of this turn's at all there is nothing to release.
|
|
2590
|
+
if (captured) {
|
|
2591
|
+
this.releaseSessionReplyAnchor(sessionId, captured);
|
|
2592
|
+
}
|
|
2593
|
+
else if (capturedEntry) {
|
|
2594
|
+
this.releaseSessionReplyAnchor(sessionId, capturedEntry.msgId);
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
return;
|
|
2598
|
+
}
|
|
1093
2599
|
if (state?.timer) {
|
|
1094
2600
|
clearTimeout(state.timer);
|
|
1095
2601
|
state.timer = null;
|
|
@@ -1100,14 +2606,152 @@ export class QQChannel extends ChannelBase {
|
|
|
1100
2606
|
return;
|
|
1101
2607
|
}
|
|
1102
2608
|
const wasFlushed = this.flushedSessions.has(sessionId);
|
|
1103
|
-
|
|
2609
|
+
// A streamState entry's buffer is authoritative whenever the entry exists:
|
|
2610
|
+
// an empty one means the turn's text already went out as flushed segments.
|
|
2611
|
+
// fullText is only the fallback for a turn that never created an entry and
|
|
2612
|
+
// never flushed, so a buffered residual shadows the daemon task output the
|
|
2613
|
+
// bridge picks.
|
|
2614
|
+
let remaining = state?.buffer ?? (wasFlushed ? '' : fullText);
|
|
2615
|
+
// A live turn's stashed pre-boundary text must go out with the final text
|
|
2616
|
+
// only when a boundary cleared the bridge's collection since it was taken —
|
|
2617
|
+
// state.buffer and fullText both miss it then. Without a boundary, fullText
|
|
2618
|
+
// already carries the diverted chunks, and prepending would double the HEAD.
|
|
2619
|
+
// The entry is removed here, before the send, so a chunk arriving
|
|
2620
|
+
// during the send cannot drain the same stash into a fresh entry and
|
|
2621
|
+
// duplicate the HEAD. A superseded entry is dropped with a log.
|
|
2622
|
+
const held = this.streamOrphanBuffer.get(sessionId);
|
|
2623
|
+
if (held !== undefined) {
|
|
2624
|
+
if (held.turn === currentTurn) {
|
|
2625
|
+
this.reportOrphanStashCap(sessionId, held);
|
|
2626
|
+
this.streamOrphanBuffer.delete(sessionId);
|
|
2627
|
+
if (held.pre) {
|
|
2628
|
+
remaining = held.pre + remaining;
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
else {
|
|
2632
|
+
this.dropOrphanStash(sessionId, held);
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
// Only the stash this send actually carries may be reported as a sealed
|
|
2636
|
+
// head (see the stale-state branch above).
|
|
2637
|
+
const carried = held !== undefined && held.turn === currentTurn ? held : undefined;
|
|
1104
2638
|
const sourceLabel = segment?.sourceLabel ??
|
|
1105
2639
|
state?.sourceLabel ??
|
|
1106
2640
|
this.getResponseSourceLabel(sessionId);
|
|
2641
|
+
// TTL-check the anchor like onResponseChunk does: a final segment sent
|
|
2642
|
+
// after the anchor expired must go out as an active message instead of
|
|
2643
|
+
// with a stale msg_id.
|
|
2644
|
+
const { entry: anchorEntry, msgId: capturedMsgId } = this.resolveSessionReplyAnchor(sessionId);
|
|
1107
2645
|
this.streamState.delete(sessionId);
|
|
1108
2646
|
this.flushedSessions.delete(sessionId);
|
|
2647
|
+
// This path just took over a parked deferred flush and delivered the
|
|
2648
|
+
// residual itself, so the park flag must go with the entry. Left behind,
|
|
2649
|
+
// onPromptEnd early-returns on it and the NEXT turn's first successful
|
|
2650
|
+
// flush consumes it mid-turn and runs the terminal settle, which clears
|
|
2651
|
+
// flushedSessions and makes onResponseComplete re-send that turn's text.
|
|
2652
|
+
this.pendingStreamDelete.delete(sessionId);
|
|
1109
2653
|
if (remaining) {
|
|
1110
|
-
|
|
2654
|
+
try {
|
|
2655
|
+
if (capturedMsgId) {
|
|
2656
|
+
// Final segment keeps this session's reply anchor (per-session msgId),
|
|
2657
|
+
// consistent with how idleFlush/flushAndTrack send mid-stream chunks,
|
|
2658
|
+
// and keeps the [sender · task] attribution: sendMessage hardcodes an
|
|
2659
|
+
// undefined sourceLabel, so the anchored send goes through the
|
|
2660
|
+
// reply-context helper instead (msgIdOverride still wins over the
|
|
2661
|
+
// reply context, so the anchor is unchanged).
|
|
2662
|
+
// Mark the send in flight for the release guard: the streamState
|
|
2663
|
+
// entry was deleted above, so nothing else can tell the guard that
|
|
2664
|
+
// this msgId's counter is still being used.
|
|
2665
|
+
this.beginMsgSeqSend(capturedMsgId);
|
|
2666
|
+
try {
|
|
2667
|
+
await this.sendFinalSegmentChecked(sessionId, carried, () => this.sendMessageWithReplyContext(chatId, remaining, undefined, sourceLabel, capturedMsgId, anchorEntry?.timestamp));
|
|
2668
|
+
}
|
|
2669
|
+
finally {
|
|
2670
|
+
this.endMsgSeqSend(capturedMsgId);
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
else {
|
|
2674
|
+
if (anchorEntry) {
|
|
2675
|
+
// The anchor existed but outlived its TTL — a long turn whose final
|
|
2676
|
+
// segment arrived late. Fall back to the session-aware base path
|
|
2677
|
+
// (active send) and say so: a silent fallback here is what made the
|
|
2678
|
+
// final segment race the chat-level entry in the first place.
|
|
2679
|
+
process.stderr.write(`[QQ:${this.name}] per-session reply anchor expired for final segment of ${sanitizeLogText(sessionId, 64)}\n`);
|
|
2680
|
+
}
|
|
2681
|
+
await this.sendFinalSegmentChecked(sessionId, carried, () => this.sendMessageWithReplyContext(chatId, remaining, this.resolveResponseReplyContext(sessionId), sourceLabel));
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
catch (e) {
|
|
2685
|
+
this.logLostSealedHead(carried?.pre, sessionId, e);
|
|
2686
|
+
throw e;
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
// Release the anchor only AFTER the final segment went out. Releasing
|
|
2690
|
+
// first would drop the msgSeqMap counter for capturedMsgId (when no other
|
|
2691
|
+
// session is anchored to it and the chat entry has moved on), making the
|
|
2692
|
+
// final sendMessage above resolve nextSeq = 1 — QQ dedupes on msg_id +
|
|
2693
|
+
// msg_seq and would silently drop the reply tail. If sendMessage throws,
|
|
2694
|
+
// ChannelBase's finally still runs onPromptEnd, which releases the anchor
|
|
2695
|
+
// (idempotent — a second release here is a no-op).
|
|
2696
|
+
// Identity-gated: /clear deletes the session queue, so a successor turn can
|
|
2697
|
+
// install its own anchor while a wedged final send is still in flight;
|
|
2698
|
+
// releasing by sessionId alone would delete the successor's anchor. Pass
|
|
2699
|
+
// this turn's resolved msgId (the raw entry's for an expired anchor) so
|
|
2700
|
+
// only an anchor that still belongs to this turn is removed. With no anchor
|
|
2701
|
+
// of this turn's, there is nothing to release.
|
|
2702
|
+
const releaseMsgId = capturedMsgId ?? anchorEntry?.msgId;
|
|
2703
|
+
if (releaseMsgId !== undefined) {
|
|
2704
|
+
this.releaseSessionReplyAnchor(sessionId, releaseMsgId);
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
/**
|
|
2708
|
+
* A stash's sealed head has no other copy — the bridge cleared it from its
|
|
2709
|
+
* collection at the boundary — so a delivery that fails after the entry was
|
|
2710
|
+
* consumed must report the loss (length and error) instead of dropping it
|
|
2711
|
+
* silently. Delete-before-send is deliberate: it stops a chunk arriving
|
|
2712
|
+
* mid-send from draining the same stash twice.
|
|
2713
|
+
*/
|
|
2714
|
+
logLostSealedHead(head, sessionId, error) {
|
|
2715
|
+
if (!head)
|
|
2716
|
+
return;
|
|
2717
|
+
process.stderr.write(`[QQ:${this.name}] dropping ${head.length} chars of sealed head: delivery failed (${sanitizeLogText(error instanceof Error ? error.message : String(error), 160)}) for ${sanitizeLogText(sessionId, 64)}\n`);
|
|
2718
|
+
}
|
|
2719
|
+
/**
|
|
2720
|
+
* Act on the route class a completion-path send reports. A blocked send never
|
|
2721
|
+
* reached the wire but throws nothing, so without this the final segment is
|
|
2722
|
+
* indistinguishable from a delivered one: the sealed head's loss log is
|
|
2723
|
+
* bypassed and the turn is reported delivered. A transient block (token
|
|
2724
|
+
* refresh failure) can clear on a later attempt, so it is re-attempted; a
|
|
2725
|
+
* permanent block, or exhaustion, reports the sealed head this path consumed
|
|
2726
|
+
* instead of dropping it silently. Exhaustion is bounded by maxFlushRetries
|
|
2727
|
+
* when that is positive, and by FINAL_SEGMENT_ROUTE_ATTEMPTS when
|
|
2728
|
+
* maxFlushRetries is the documented "unlimited" (<= 0): this send is awaited
|
|
2729
|
+
* inside the turn, so unlike the fire-and-forget flush paths it cannot be
|
|
2730
|
+
* allowed to retry forever — the turn's finally (and with it the session's
|
|
2731
|
+
* turn lock) would never run. The caller holds the msg_seq counter across the
|
|
2732
|
+
* whole call and releases the anchor only after it returns, so a re-attempt
|
|
2733
|
+
* cannot restart at seq 1 (QQ dedupes on msg_id + msg_seq).
|
|
2734
|
+
*/
|
|
2735
|
+
async sendFinalSegmentChecked(sessionId, held, send) {
|
|
2736
|
+
const bound = this.maxFlushRetries > 0
|
|
2737
|
+
? this.maxFlushRetries
|
|
2738
|
+
: QQChannel.FINAL_SEGMENT_ROUTE_ATTEMPTS;
|
|
2739
|
+
for (let attempt = 1;; attempt++) {
|
|
2740
|
+
const blocked = await send();
|
|
2741
|
+
if (blocked === undefined)
|
|
2742
|
+
return;
|
|
2743
|
+
if (blocked === 'permanent' || attempt >= bound) {
|
|
2744
|
+
this.logLostSealedHead(held?.pre, sessionId, new Error(blocked === 'permanent'
|
|
2745
|
+
? 'outgoing route permanently blocked'
|
|
2746
|
+
: 'outgoing route blocked, retries exhausted'));
|
|
2747
|
+
return;
|
|
2748
|
+
}
|
|
2749
|
+
const delay = attempt > 1 ? QQChannel.IDLE_FLUSH_BACKOFF_MS : QQChannel.IDLE_FLUSH_MS;
|
|
2750
|
+
process.stderr.write(`[QQ:${this.name}] final segment blocked by unresolved route (attempt ${attempt}), retrying in ${delay}ms for ${sanitizeLogText(sessionId, 64)}\n`);
|
|
2751
|
+
await new Promise((resolve) => {
|
|
2752
|
+
const timer = setTimeout(resolve, delay);
|
|
2753
|
+
timer.unref?.();
|
|
2754
|
+
});
|
|
1111
2755
|
}
|
|
1112
2756
|
}
|
|
1113
2757
|
streamBufferLimit(state) {
|
|
@@ -1117,16 +2761,422 @@ export class QQChannel extends ChannelBase {
|
|
|
1117
2761
|
const attributed = this.formatMarkdownAttributedText('x', state.sourceLabel);
|
|
1118
2762
|
return Math.max(1, configured - (attributed.length - 1));
|
|
1119
2763
|
}
|
|
2764
|
+
/**
|
|
2765
|
+
* Build a fresh streamState entry for a session's first chunk, resolving
|
|
2766
|
+
* the session's reply anchor (TTL-checked) and the segment's reply context.
|
|
2767
|
+
* Shared by onResponseChunk's fresh-entry path and onPromptEnd's delivery
|
|
2768
|
+
* of a cancelled turn's stashed head.
|
|
2769
|
+
*/
|
|
2770
|
+
createStreamState(chatId, sessionId, chunk, currentTurn, segment) {
|
|
2771
|
+
// Reuse the session's reply anchor across buffer windows (a single
|
|
2772
|
+
// response may flush several times, each creating a fresh state entry).
|
|
2773
|
+
// The anchor was set deterministically by onPromptStart from the
|
|
2774
|
+
// triggering message's id; drop it when stale (past the 5-minute TTL)
|
|
2775
|
+
// so a long stream's later windows fall back to the active send path
|
|
2776
|
+
// instead of sending chunks with an expired msg_id.
|
|
2777
|
+
let anchor;
|
|
2778
|
+
let anchorTimestamp;
|
|
2779
|
+
const { entry: anchorEntry, msgId: freshAnchor } = this.resolveSessionReplyAnchor(sessionId);
|
|
2780
|
+
if (anchorEntry) {
|
|
2781
|
+
if (freshAnchor !== undefined) {
|
|
2782
|
+
anchor = freshAnchor;
|
|
2783
|
+
// Carry the capture time so the flush/sealed-head read sites can
|
|
2784
|
+
// expire this anchor when the entry outlives the TTL.
|
|
2785
|
+
anchorTimestamp = anchorEntry.timestamp;
|
|
2786
|
+
}
|
|
2787
|
+
else {
|
|
2788
|
+
// Drop the stale anchor through the release path so its orphaned
|
|
2789
|
+
// msg_seq counter is purged too (a raw delete would leave it behind).
|
|
2790
|
+
process.stderr.write(`[QQ:${this.name}] per-session reply anchor expired for ${sanitizeLogText(sessionId, 64)}, falling back to active send\n`);
|
|
2791
|
+
this.releaseSessionReplyAnchor(sessionId);
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2794
|
+
const messageId = segment?.messageId ?? this.getResponseMessageId(sessionId);
|
|
2795
|
+
const replyContext = messageId
|
|
2796
|
+
? this.replyContextByMessageId.get(messageId)
|
|
2797
|
+
: undefined;
|
|
2798
|
+
return {
|
|
2799
|
+
chatId,
|
|
2800
|
+
buffer: chunk,
|
|
2801
|
+
timer: null,
|
|
2802
|
+
retryCount: 0,
|
|
2803
|
+
msgId: anchor,
|
|
2804
|
+
...(anchorTimestamp !== undefined
|
|
2805
|
+
? { msgIdTimestamp: anchorTimestamp }
|
|
2806
|
+
: {}),
|
|
2807
|
+
turn: currentTurn,
|
|
2808
|
+
...(replyContext ? { replyContext } : {}),
|
|
2809
|
+
...(segment?.sourceLabel ? { sourceLabel: segment.sourceLabel } : {}),
|
|
2810
|
+
};
|
|
2811
|
+
}
|
|
2812
|
+
/**
|
|
2813
|
+
* Report the tail a capped divert episode accumulated after its first
|
|
2814
|
+
* overflow line. See the cap branch in onResponseChunk: the first drop is
|
|
2815
|
+
* logged immediately so the loss is visible, later chunks only count, and
|
|
2816
|
+
* this emits the cumulative total once per episode when the stash leaves
|
|
2817
|
+
* the side buffer. `capDropped` counts characters, not fields: the sealed
|
|
2818
|
+
* pre's own trim is a subset of the text trim and is not charged again.
|
|
2819
|
+
*/
|
|
2820
|
+
reportOrphanStashCap(sessionId, held) {
|
|
2821
|
+
const total = held.capDropped ?? 0;
|
|
2822
|
+
const logged = held.capLogged ?? 0;
|
|
2823
|
+
if (total <= logged)
|
|
2824
|
+
return;
|
|
2825
|
+
process.stderr.write(`[QQ:${this.name}] diverted turn ${held.turn} stash capped: ${total - logged} further chars dropped (${total} total) for ${sanitizeLogText(sessionId, 64)}\n`);
|
|
2826
|
+
}
|
|
2827
|
+
/**
|
|
2828
|
+
* Drop a side-buffer entry that cannot be delivered (it belongs to a
|
|
2829
|
+
* superseded turn, or a fresh turn is starting and can never own it) and
|
|
2830
|
+
* log the discarded text so the loss is observable.
|
|
2831
|
+
*/
|
|
2832
|
+
dropOrphanStash(sessionId, held) {
|
|
2833
|
+
this.reportOrphanStashCap(sessionId, held);
|
|
2834
|
+
this.streamOrphanBuffer.delete(sessionId);
|
|
2835
|
+
if (held.text) {
|
|
2836
|
+
process.stderr.write(`[QQ:${this.name}] dropping ${held.text.length} chars of superseded turn ${held.turn} for ${sanitizeLogText(sessionId, 64)}\n`);
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
/**
|
|
2840
|
+
* The seal a failed payload needs when a boundary cleared the bridge's
|
|
2841
|
+
* collection while the send was in flight: the payload's whole text is now
|
|
2842
|
+
* absent from fullText, so it is sealed outright rather than only by the seal
|
|
2843
|
+
* the send carried, which may cover just a prefix. `residual` is the text the
|
|
2844
|
+
* boundary stripped from the live turn, captured by the boundary hooks ('' =
|
|
2845
|
+
* nothing), and is appended after the payload — no string comparison against
|
|
2846
|
+
* the carried seal, so a residual whose text repeats it still counts.
|
|
2847
|
+
*/
|
|
2848
|
+
sealClearedPayload(payload, residual) {
|
|
2849
|
+
return residual ? payload + residual : payload;
|
|
2850
|
+
}
|
|
2851
|
+
/**
|
|
2852
|
+
* Put a transiently-failed payload back on `current` in the order the seal
|
|
2853
|
+
* states it in.
|
|
2854
|
+
*
|
|
2855
|
+
* With no stash merged during this flight the payload predates whatever
|
|
2856
|
+
* accumulated while it was in flight — chunks sent to state.buffer by the
|
|
2857
|
+
* bridge's fallback path, or a residual that arrived mid-send — so the plain
|
|
2858
|
+
* prepend (`payload + buffer`) is correct and is what the non-widened case
|
|
2859
|
+
* has always done.
|
|
2860
|
+
*
|
|
2861
|
+
* After onPromptEnd merged a stash while this send was flight, the buffer is
|
|
2862
|
+
* `[stashed head][chunks that arrived since]`: the merge prepends the head,
|
|
2863
|
+
* and every later writer appends. That head is OLDER than the failed payload
|
|
2864
|
+
* (it is the seal's leading segment — see handOffSealedPre, "the older
|
|
2865
|
+
* first"), so the payload belongs BETWEEN the two portions and the
|
|
2866
|
+
* reconstruction is `head + payload + newer`. Reconstructing it is the whole
|
|
2867
|
+
* reason `staleStashPrefix` exists: the failed payload's text may repeat
|
|
2868
|
+
* either portion of the buffer, the newer text may itself begin with the
|
|
2869
|
+
* payload's text, and the payload may even be empty, so no boundary can be
|
|
2870
|
+
* found by comparing text. A `startsWith`/dedup test against the buffer would
|
|
2871
|
+
* strip the newer text's own leading characters — dropping real reply text —
|
|
2872
|
+
* so the split uses the recorded length alone and never compares strings.
|
|
2873
|
+
* The marker is consumed here — the payload is folded in and the next flight
|
|
2874
|
+
* must not reorder against it again (flushAndTrack also resets it).
|
|
2875
|
+
*
|
|
2876
|
+
* A boundary that sealed a residual of its own while the send was in flight
|
|
2877
|
+
* does not change that order: captureBoundaryClear records the residual and
|
|
2878
|
+
* marks the re-seal, but the residual is only the text that accumulated
|
|
2879
|
+
* since the boundary — it never contains the in-flight payload, and it does
|
|
2880
|
+
* not know that the head prepended in front of it is older. A plain prepend
|
|
2881
|
+
* would therefore read `head + payload + newer` as `payload + head + newer`.
|
|
2882
|
+
* The reconstruction above stays the same; resealOnRebuffer's boundary arm
|
|
2883
|
+
* extends the widened seal to match it.
|
|
2884
|
+
*/
|
|
2885
|
+
rebufferAfterStashMerge(current, payload) {
|
|
2886
|
+
const afterFlight = current.buffer || '';
|
|
2887
|
+
const headLength = current.staleStashPrefix;
|
|
2888
|
+
// Consumed either way, ahead of both arms: the payload is folded in below
|
|
2889
|
+
// and the next flight must not reorder against this merge.
|
|
2890
|
+
current.staleStashPrefix = undefined;
|
|
2891
|
+
if (headLength !== undefined && headLength > 0) {
|
|
2892
|
+
const head = afterFlight.slice(0, headLength);
|
|
2893
|
+
const newer = afterFlight.slice(headLength);
|
|
2894
|
+
return head + payload + newer;
|
|
2895
|
+
}
|
|
2896
|
+
return payload + afterFlight;
|
|
2897
|
+
}
|
|
2898
|
+
/**
|
|
2899
|
+
* The seal owed for `payload` when a boundary cleared the bridge's collection
|
|
2900
|
+
* while its send was in flight. onPromptEnd's stash merge may have widened the
|
|
2901
|
+
* seal during this flight; the undelivered window is then the reconstruction
|
|
2902
|
+
* rebufferAfterStashMerge builds (the OLDER stashed head, then this payload,
|
|
2903
|
+
* then whatever accumulated since), read lazily so the non-widened path never
|
|
2904
|
+
* consumes the stash marker. Otherwise the plain payload-plus-residual seal
|
|
2905
|
+
* stands. Shared by the transient re-buffer arms (resealOnRebuffer) and the
|
|
2906
|
+
* permanent handoff: all describe the same window, so they must not drift.
|
|
2907
|
+
*/
|
|
2908
|
+
sealForClearedWindow(state, payload, widenedWindow) {
|
|
2909
|
+
return state.sealWidenedInFlight === true
|
|
2910
|
+
? widenedWindow()
|
|
2911
|
+
: this.sealClearedPayload(payload, this.capturedResidual(state));
|
|
2912
|
+
}
|
|
2913
|
+
/**
|
|
2914
|
+
* Re-seal a failed payload that a transient re-buffer puts back on `current`:
|
|
2915
|
+
* a boundary that cleared the bridge's collection while the send was in
|
|
2916
|
+
* flight removed the payload's whole text from fullText, so the entire
|
|
2917
|
+
* payload is sealed, plus the residual the boundary captured (flagged
|
|
2918
|
+
* deterministically, not by comparing seal text). Only a chain that still
|
|
2919
|
+
* owns the live turn may write the seal — a superseded entry's payload is
|
|
2920
|
+
* abandoned with its failure and must not be injected into a successor's
|
|
2921
|
+
* reply. `carriedSeal` is the backstop for a re-seal whose boundary marker
|
|
2922
|
+
* was missed, but it is skipped when the seal was widened during this flight
|
|
2923
|
+
* (sealWidenedInFlight — onPromptEnd's stash merge already folded the carried
|
|
2924
|
+
* head in as the widened seal's suffix, so prepending it again would seal it
|
|
2925
|
+
* twice). Shared by the parked and non-parked re-buffer arms, and by the
|
|
2926
|
+
* permanent handoff through sealForClearedWindow; they must stay identical
|
|
2927
|
+
* because they encode the same rule.
|
|
2928
|
+
*/
|
|
2929
|
+
resealOnRebuffer(sessionId, state, current, payload, carriedSeal) {
|
|
2930
|
+
if (!this.ownsLiveTurn(sessionId, state))
|
|
2931
|
+
return;
|
|
2932
|
+
if (state.boundaryClearedInFlight !== undefined) {
|
|
2933
|
+
// The boundary cleared the bridge's collection, so the whole undelivered
|
|
2934
|
+
// window must be re-sealed. When onPromptEnd's stash merge widened the
|
|
2935
|
+
// seal during this flight, that window is exactly what
|
|
2936
|
+
// rebufferAfterStashMerge just rebuilt in `current.buffer`: the older
|
|
2937
|
+
// stashed head, then this payload, then the residual/whatever
|
|
2938
|
+
// accumulated since. `payload + capturedResidual` would instead put the
|
|
2939
|
+
// payload in front of the head and overwrite the widened head — its only
|
|
2940
|
+
// copy. The permanent handoff asks the same rule. Without a widening the
|
|
2941
|
+
// buffer is already `payload + residual` and the plain seal stands byte
|
|
2942
|
+
// for byte. `current === state` at both call sites, so `current.buffer` is
|
|
2943
|
+
// this state's reconstructed window.
|
|
2944
|
+
current.sealedPre = this.sealForClearedWindow(state, payload, () => current.buffer || '');
|
|
2945
|
+
}
|
|
2946
|
+
else if (carriedSeal !== undefined &&
|
|
2947
|
+
!state.sealWidenedInFlight &&
|
|
2948
|
+
current.sealedPre !== carriedSeal) {
|
|
2949
|
+
current.sealedPre = carriedSeal + (current.sealedPre ?? '');
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2952
|
+
/**
|
|
2953
|
+
* Carry a widened, boundary-cleared re-buffer into the seal the NEXT flight
|
|
2954
|
+
* captures, so the knowledge survives the per-flight resets and a permanent
|
|
2955
|
+
* failure on a retry re-stashes the whole window instead of the stale
|
|
2956
|
+
* residual.
|
|
2957
|
+
*
|
|
2958
|
+
* resealOnRebuffer writes exactly this for a live turn, but its live-turn
|
|
2959
|
+
* gate skips a superseded entry — whose re-buffer would otherwise keep the
|
|
2960
|
+
* residual the boundary overwrote ('AC') while `current.buffer` already holds
|
|
2961
|
+
* the reconstruction ('APC'). The retry flight then starts with `sealedPre`
|
|
2962
|
+
* 'AC' and, because flushAndTrack resets sealWidenedInFlight /
|
|
2963
|
+
* boundaryClearedInFlight at every flight start, its permanent arm can no
|
|
2964
|
+
* longer rebuild the window: the payload 'P' is on no wire and in no seal and
|
|
2965
|
+
* the entry is deleted. Recording the reconstruction on the entry hands the
|
|
2966
|
+
* retry flight the one value it needs: its success path still clears it
|
|
2967
|
+
* without delivering, and only a give-up re-stashes it (or delivers it), the
|
|
2968
|
+
* same handoff the widened permanent arm performs.
|
|
2969
|
+
*
|
|
2970
|
+
* Gated on the boundary marker AND the widening, not on the buffer alone: a
|
|
2971
|
+
* widened re-buffer with NO boundary leaves the newer text in the bridge's
|
|
2972
|
+
* collection, so welding it into the seal would deliver it twice. The value
|
|
2973
|
+
* is the reconstruction `rebufferAfterStashMerge` just wrote to
|
|
2974
|
+
* `current.buffer` — no seal/buffer text comparison anywhere.
|
|
2975
|
+
*/
|
|
2976
|
+
carryWidenedWindowIntoSeal(current) {
|
|
2977
|
+
if (current.sealWidenedInFlight !== true)
|
|
2978
|
+
return;
|
|
2979
|
+
if (current.boundaryClearedInFlight === undefined)
|
|
2980
|
+
return;
|
|
2981
|
+
if (!current.buffer)
|
|
2982
|
+
return;
|
|
2983
|
+
current.sealedPre = current.buffer;
|
|
2984
|
+
}
|
|
2985
|
+
/**
|
|
2986
|
+
* Whether a settling flush chain still owns the session: its entry is the
|
|
2987
|
+
* live streamState entry, or the chain still holds the flush marker. The
|
|
2988
|
+
* marker is cleared only by `.finally()` after the settle arms, so this stays
|
|
2989
|
+
* true while those arms hand state off; a session torn down by onSessionDied
|
|
2990
|
+
* (or replaced outright) owns neither, and its chain must not write shared
|
|
2991
|
+
* per-session state.
|
|
2992
|
+
*/
|
|
2993
|
+
ownsSession(sessionId, state) {
|
|
2994
|
+
return (this.streamState.get(sessionId) === state ||
|
|
2995
|
+
this.flushingSessions.get(sessionId) === state);
|
|
2996
|
+
}
|
|
2997
|
+
/**
|
|
2998
|
+
* Whether this entry is still the turn that owns the session's counter. A
|
|
2999
|
+
* superseded entry's text is abandoned with its failure and must not be
|
|
3000
|
+
* sealed into a successor's reply.
|
|
3001
|
+
*/
|
|
3002
|
+
ownsLiveTurn(sessionId, state) {
|
|
3003
|
+
return state.turn === (this.turnCounter.get(sessionId) ?? 0);
|
|
3004
|
+
}
|
|
3005
|
+
/**
|
|
3006
|
+
* Hand a doomed entry's sealed pre-boundary head to whatever can still
|
|
3007
|
+
* deliver it, before the entry carrying the only copy is dropped. The
|
|
3008
|
+
* boundary cleared the bridge's chunk collection, so this text is absent
|
|
3009
|
+
* from fullText: dropping it truncates the reply's opening with no log.
|
|
3010
|
+
*
|
|
3011
|
+
* A later settle of this turn (onResponseComplete has not run yet), or a
|
|
3012
|
+
* successor turn that owns the counter now, can still prepend it — re-stash,
|
|
3013
|
+
* merging into an existing stash. A fresh entry is tagged with the turn that
|
|
3014
|
+
* owns the counter, never the doomed one: every consumer gates on the entry
|
|
3015
|
+
* turn matching the live turn, so a doomed tag is dropped as superseded by
|
|
3016
|
+
* the successor's first chunk and the head is lost anyway. The merge branch
|
|
3017
|
+
* keeps the existing entry's turn, which a stash always carries as the turn
|
|
3018
|
+
* that wrote it. `pre` carries the sealed head alone and is never widened to
|
|
3019
|
+
* the whole dropped buffer: the post-boundary text is still in fullText and
|
|
3020
|
+
* prepending it again would deliver it twice.
|
|
3021
|
+
*
|
|
3022
|
+
* Otherwise the turn is already over — completion returned and parked the
|
|
3023
|
+
* session, or the caller is the park-consuming exhaustion branch, which
|
|
3024
|
+
* clears the flag before reaching here — and this turn still owns the
|
|
3025
|
+
* counter, so the block that drops the entry also drops the counter and a
|
|
3026
|
+
* stash would be discarded as superseded. Deliver the sealed head on this
|
|
3027
|
+
* turn's own anchor instead — the same path onPromptEnd already uses for a
|
|
3028
|
+
* parked cancelled turn's HEAD. The same delivery is taken when the turn
|
|
3029
|
+
* being tagged has already run its completion (see completedTurns): its
|
|
3030
|
+
* completion is the reader that would prepend the stash, it will not run
|
|
3031
|
+
* again, and a stash it cannot consume is dropped by the next onPromptStart.
|
|
3032
|
+
*
|
|
3033
|
+
* `turnIsOver` defaults to the park flag, which is still armed at the four
|
|
3034
|
+
* sites that reach here while it is; the pending-exhaustion branch has
|
|
3035
|
+
* already consumed it, so it passes the fact explicitly.
|
|
3036
|
+
*
|
|
3037
|
+
* `carriedSeal` is the seal a permanently-failed send captured when it
|
|
3038
|
+
* started (flushAndTrack). A later boundary can re-seal the same state's
|
|
3039
|
+
* residual while that send is in flight, overwriting `sealedPre`; the failed
|
|
3040
|
+
* send carried the older head, which then has no other copy. Both are
|
|
3041
|
+
* recovered here — the older first, since the seal always covers a later
|
|
3042
|
+
* buffer window.
|
|
3043
|
+
*/
|
|
3044
|
+
handOffSealedPre(state, sessionId, turnIsOver = this.pendingStreamDelete.has(sessionId), carriedSeal) {
|
|
3045
|
+
// A chain whose session was torn down (onSessionDied) or replaced outright
|
|
3046
|
+
// owns neither map: its seal has no consumer left, and re-writing the side
|
|
3047
|
+
// buffer here would later prepend a dead turn's head to an unrelated
|
|
3048
|
+
// successor reply. A superseded chain that still holds the flush
|
|
3049
|
+
// marker legitimately hands off, which is why both maps are consulted.
|
|
3050
|
+
if (!this.ownsSession(sessionId, state)) {
|
|
3051
|
+
// The seal is dropped here, so log it like every other loss: nothing else
|
|
3052
|
+
// observes this path.
|
|
3053
|
+
if (state.sealedPre !== undefined) {
|
|
3054
|
+
process.stderr.write(`[QQ:${this.name}] dropping ${state.sealedPre.length} chars of sealed head for unowned session ${sanitizeLogText(sessionId, 64)}\n`);
|
|
3055
|
+
}
|
|
3056
|
+
return;
|
|
3057
|
+
}
|
|
3058
|
+
const liveSeal = state.sealedPre;
|
|
3059
|
+
const sealed = carriedSeal !== undefined && carriedSeal !== liveSeal
|
|
3060
|
+
? carriedSeal + (liveSeal ?? '')
|
|
3061
|
+
: liveSeal;
|
|
3062
|
+
if (sealed === undefined)
|
|
3063
|
+
return;
|
|
3064
|
+
state.sealedPre = undefined;
|
|
3065
|
+
// The turn this text ends up tagged with: the one an existing stash already
|
|
3066
|
+
// carries when the two are merged, since the write below keeps that tag.
|
|
3067
|
+
const existing = this.streamOrphanBuffer.get(sessionId);
|
|
3068
|
+
const taggedTurn = existing?.turn ?? this.turnCounter.get(sessionId) ?? state.turn;
|
|
3069
|
+
// A successor that ended by cancel can never read a re-stash either:
|
|
3070
|
+
// onPromptStart marks the session active and onPromptEnd clears it, while
|
|
3071
|
+
// completedTurns is only written by onResponseComplete, so the cancel path
|
|
3072
|
+
// is invisible to the record. With no prompt active there is no completion
|
|
3073
|
+
// left that could consume the stash, and the next onPromptStart would drop
|
|
3074
|
+
// it as dead text — deliver on this turn's own anchor instead.
|
|
3075
|
+
const noSuccessorCanConsume = (turnIsOver && (this.turnCounter.get(sessionId) ?? 0) === state.turn) ||
|
|
3076
|
+
!this.activePromptSessions.has(sessionId);
|
|
3077
|
+
// That turn has already run its own onResponseComplete, so a re-stash is a
|
|
3078
|
+
// write with no guaranteed consumer: the next onPromptStart drops it as
|
|
3079
|
+
// superseded and the sealed opening is lost. Deliver it on this turn's
|
|
3080
|
+
// anchor instead.
|
|
3081
|
+
const completionAlreadyRan = this.completedTurns.get(sessionId) === taggedTurn;
|
|
3082
|
+
if (noSuccessorCanConsume || completionAlreadyRan) {
|
|
3083
|
+
// The sealed text belongs to this state's turn, so anchor it there: a
|
|
3084
|
+
// successor may already own the session anchor by the time this runs.
|
|
3085
|
+
// Its attribution label is passed for the same reason — the fallback
|
|
3086
|
+
// lookup resolves against a possibly-successor active prompt.
|
|
3087
|
+
void this.deliverCancelledStash(state.chatId, sessionId, sealed, state.msgId ?? null, state.msgIdTimestamp, state.sourceLabel);
|
|
3088
|
+
return;
|
|
3089
|
+
}
|
|
3090
|
+
const limit = this.streamBufferLimit(state);
|
|
3091
|
+
// This is the second write site for the side buffer. The sealed head is the
|
|
3092
|
+
// reason this handoff exists, so it is kept whole and only the successor's
|
|
3093
|
+
// contribution is capped. The stash is therefore at most
|
|
3094
|
+
// max(streamBufferLimit(state), sealed.length); `sealed` is itself bounded —
|
|
3095
|
+
// a carried seal plus a boundary seal, each derived from a capped buffer —
|
|
3096
|
+
// so this write site cannot grow without bound.
|
|
3097
|
+
const room = Math.max(0, limit - sealed.length);
|
|
3098
|
+
let merged = {
|
|
3099
|
+
turn: taggedTurn,
|
|
3100
|
+
text: sealed,
|
|
3101
|
+
pre: sealed,
|
|
3102
|
+
// The doomed entry's own [sender · task] attribution must survive the
|
|
3103
|
+
// re-stash: the successor's completion would otherwise deliver this head
|
|
3104
|
+
// under the main agent's identity instead of the one that wrote it.
|
|
3105
|
+
...(state.sourceLabel !== undefined
|
|
3106
|
+
? { sourceLabel: state.sourceLabel }
|
|
3107
|
+
: {}),
|
|
3108
|
+
};
|
|
3109
|
+
if (existing !== undefined) {
|
|
3110
|
+
const successorText = truncateUtf16Units(existing.text, room);
|
|
3111
|
+
const dropped = existing.text.length - successorText.length;
|
|
3112
|
+
merged = {
|
|
3113
|
+
turn: existing.turn,
|
|
3114
|
+
text: sealed + successorText,
|
|
3115
|
+
pre: sealed + truncateUtf16Units(existing.pre ?? '', room),
|
|
3116
|
+
// Carry the cap accounting across the merge. This site reports its own
|
|
3117
|
+
// drop below, so that count is marked logged as well: the later
|
|
3118
|
+
// summary must not repeat it. Left off entirely when the episode never
|
|
3119
|
+
// hit the cap, so the stash keeps its plain shape.
|
|
3120
|
+
...(existing.capDropped !== undefined || dropped > 0
|
|
3121
|
+
? {
|
|
3122
|
+
capDropped: (existing.capDropped ?? 0) + dropped,
|
|
3123
|
+
capLogged: (existing.capLogged ?? 0) + dropped,
|
|
3124
|
+
}
|
|
3125
|
+
: {}),
|
|
3126
|
+
...(existing.sourceLabel !== undefined
|
|
3127
|
+
? { sourceLabel: existing.sourceLabel }
|
|
3128
|
+
: {}),
|
|
3129
|
+
};
|
|
3130
|
+
// `pre` is a prefix of `text` and is what onResponseComplete prepends, so
|
|
3131
|
+
// it must never outrun the kept text; the trims above keep that true, and
|
|
3132
|
+
// this stays as the safety net.
|
|
3133
|
+
if (merged.pre !== undefined && merged.pre.length > merged.text.length) {
|
|
3134
|
+
merged.pre = merged.text;
|
|
3135
|
+
}
|
|
3136
|
+
if (dropped > 0) {
|
|
3137
|
+
process.stderr.write(`[QQ:${this.name}] dropping ${dropped} chars of successor stash over the buffer limit, sealed head kept, for ${sanitizeLogText(sessionId, 64)}\n`);
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
this.streamOrphanBuffer.set(sessionId, merged);
|
|
3141
|
+
}
|
|
1120
3142
|
onSessionDied(sessionId) {
|
|
1121
3143
|
const state = this.streamState.get(sessionId);
|
|
1122
3144
|
if (state?.timer) {
|
|
1123
3145
|
clearTimeout(state.timer);
|
|
3146
|
+
state.timer = null;
|
|
3147
|
+
}
|
|
3148
|
+
// Disarm the residual too, for the same reason as onResponseChunk's
|
|
3149
|
+
// superseded branch: the release below must not veto on state this block
|
|
3150
|
+
// destroys, or the dead session's counter orphans with nothing left to
|
|
3151
|
+
// reclaim it. flushingSessions stays set — a genuine in-flight send still
|
|
3152
|
+
// owns that marker.
|
|
3153
|
+
if (state)
|
|
3154
|
+
state.buffer = '';
|
|
3155
|
+
// Release before the deletes so the release guard still sees this
|
|
3156
|
+
// session's in-flight marker (flushingSessions) and can keep the msg_seq
|
|
3157
|
+
// counter while a live flush owns it — a delete-then-release order would
|
|
3158
|
+
// drop the counter under an in-flight send and its tail would re-resolve
|
|
3159
|
+
// msg_seq from 1 (QQ dedupes on msg_id + msg_seq).
|
|
3160
|
+
this.releaseSessionReplyAnchor(sessionId);
|
|
3161
|
+
// The entry's sealed head is the only copy of a boundary-cleared opening
|
|
3162
|
+
// and a died session has no owner left to hand it to, so its loss must be
|
|
3163
|
+
// observable like every other drop (matching the unowned-session log in
|
|
3164
|
+
// handOffSealedPre) instead of vanishing with the entry below.
|
|
3165
|
+
if (state?.sealedPre) {
|
|
3166
|
+
process.stderr.write(`[QQ:${this.name}] dropping ${state.sealedPre.length} chars of sealed head for unowned session ${sanitizeLogText(sessionId, 64)}\n`);
|
|
1124
3167
|
}
|
|
1125
3168
|
this.streamState.delete(sessionId);
|
|
1126
3169
|
this.flushingSessions.delete(sessionId);
|
|
1127
3170
|
this.pendingStreamDelete.delete(sessionId);
|
|
1128
3171
|
this.flushedSessions.delete(sessionId);
|
|
1129
3172
|
this.activePromptSessions.delete(sessionId);
|
|
3173
|
+
this.turnCounter.delete(sessionId);
|
|
3174
|
+
// The stash is head text the bridge already cleared at a boundary, so its
|
|
3175
|
+
// loss must be observable like every other drop rather than silent.
|
|
3176
|
+
const heldStash = this.streamOrphanBuffer.get(sessionId);
|
|
3177
|
+
if (heldStash)
|
|
3178
|
+
this.dropOrphanStash(sessionId, heldStash);
|
|
3179
|
+
this.completedTurns.delete(sessionId);
|
|
1130
3180
|
super.onSessionDied(sessionId);
|
|
1131
3181
|
}
|
|
1132
3182
|
// ── State Persistence (cross-server context continuation) ──────
|
|
@@ -1202,6 +3252,86 @@ export class QQChannel extends ChannelBase {
|
|
|
1202
3252
|
this.cronTextHandlerAttached = false;
|
|
1203
3253
|
}
|
|
1204
3254
|
}
|
|
3255
|
+
/**
|
|
3256
|
+
* Attach the ungated `responseBoundary` observer to the current bridge.
|
|
3257
|
+
* No-op if already attached.
|
|
3258
|
+
*/
|
|
3259
|
+
attachBridgeBoundarySeal() {
|
|
3260
|
+
if (this.bridgeBoundarySealAttached)
|
|
3261
|
+
return;
|
|
3262
|
+
this.bridge.on?.('responseBoundary', this._bridgeBoundarySeal);
|
|
3263
|
+
this.bridgeBoundarySealAttached = true;
|
|
3264
|
+
}
|
|
3265
|
+
/** Detach the boundary observer. No-op if not attached. */
|
|
3266
|
+
detachBridgeBoundarySeal() {
|
|
3267
|
+
if (!this.bridgeBoundarySealAttached)
|
|
3268
|
+
return;
|
|
3269
|
+
this.bridge.off?.('responseBoundary', this._bridgeBoundarySeal);
|
|
3270
|
+
this.bridgeBoundarySealAttached = false;
|
|
3271
|
+
}
|
|
3272
|
+
/**
|
|
3273
|
+
* Seal the pre-boundary portion of a stashed head. Called from both the
|
|
3274
|
+
* adapter hook and the bridge's own boundary event: ChannelBase suppresses
|
|
3275
|
+
* the hook while a cancel is pending, but the bridge clears its chunk
|
|
3276
|
+
* collection on every boundary it emits, so only the ungated observer can
|
|
3277
|
+
* guarantee `pre` is set before the turn completes.
|
|
3278
|
+
*/
|
|
3279
|
+
sealOrphanStash(sessionId) {
|
|
3280
|
+
const stashed = this.streamOrphanBuffer.get(sessionId);
|
|
3281
|
+
if (stashed)
|
|
3282
|
+
stashed.pre = stashed.text;
|
|
3283
|
+
}
|
|
3284
|
+
/**
|
|
3285
|
+
* A boundary clears the bridge's chunk collection. Seal the live turn's
|
|
3286
|
+
* buffer-resident prefix for the completion path, and record on the
|
|
3287
|
+
* entry whose flush is in flight what its re-buffer must re-seal: the payload
|
|
3288
|
+
* the boundary stripped from fullText, always, plus that residual when the
|
|
3289
|
+
* flushed entry is the live turn's own. Called by the adapter hook and by the
|
|
3290
|
+
* ungated bridge observer — the observer is the only signal when ChannelBase
|
|
3291
|
+
* suppresses the hook (cancel pending) — and both run on a normal boundary,
|
|
3292
|
+
* where the assignments are idempotent. Keyed off the in-flight marker rather
|
|
3293
|
+
* than streamState, so a successor turn replacing the entry cannot hide the
|
|
3294
|
+
* chain whose payload is still in the air.
|
|
3295
|
+
*/
|
|
3296
|
+
captureBoundaryClear(sessionId) {
|
|
3297
|
+
const state = this.streamState.get(sessionId);
|
|
3298
|
+
const currentTurn = this.turnCounter.get(sessionId) ?? 0;
|
|
3299
|
+
// Seal ONLY the live turn's buffer-resident prefix: a stale/parked
|
|
3300
|
+
// predecessor's buffer is delivered by its own chain on its own anchor, and
|
|
3301
|
+
// sealing it would let the permanent-failure arm re-stash that text into a
|
|
3302
|
+
// successor turn's reply.
|
|
3303
|
+
const liveResidual = state !== undefined && state.turn === currentTurn && state.buffer
|
|
3304
|
+
? state.buffer
|
|
3305
|
+
: undefined;
|
|
3306
|
+
if (state !== undefined && liveResidual !== undefined) {
|
|
3307
|
+
state.sealedPre = liveResidual;
|
|
3308
|
+
}
|
|
3309
|
+
const flushing = this.flushingSessions.get(sessionId);
|
|
3310
|
+
if (!flushing)
|
|
3311
|
+
return;
|
|
3312
|
+
// A boundary always strips the in-flight payload from fullText. The
|
|
3313
|
+
// residual rides along only when it belongs to the flushed entry itself,
|
|
3314
|
+
// whose `sealedPre` was just set to it — never a successor's buffer, and
|
|
3315
|
+
// never the stale carried seal. A later boundary may upgrade 'payload' to
|
|
3316
|
+
// 'residual', never the reverse.
|
|
3317
|
+
if (flushing === state && liveResidual !== undefined) {
|
|
3318
|
+
flushing.boundaryClearedInFlight = 'residual';
|
|
3319
|
+
}
|
|
3320
|
+
else if (flushing.boundaryClearedInFlight === undefined) {
|
|
3321
|
+
flushing.boundaryClearedInFlight = 'payload';
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
/**
|
|
3325
|
+
* The residual text a boundary captured for this entry, or '' when it
|
|
3326
|
+
* recorded none. The text lives in `sealedPre`, written by
|
|
3327
|
+
* captureBoundaryClear in the same call that sets the marker, so it is always
|
|
3328
|
+
* a real residual and never the seal the failed payload carried.
|
|
3329
|
+
*/
|
|
3330
|
+
capturedResidual(state) {
|
|
3331
|
+
return state.boundaryClearedInFlight === 'residual'
|
|
3332
|
+
? (state.sealedPre ?? '')
|
|
3333
|
+
: '';
|
|
3334
|
+
}
|
|
1205
3335
|
/** Flush pending state writes immediately (called on disconnect). */
|
|
1206
3336
|
flushQQState() {
|
|
1207
3337
|
if (this.saveTimer) {
|
|
@@ -1301,8 +3431,15 @@ export class QQChannel extends ChannelBase {
|
|
|
1301
3431
|
process.stderr.write(`[QQ:${this.name}] restoreQQState: accepted ${this.msgSeqMap.size} msgSeqMap entries (rejected ${totalRaw - this.msgSeqMap.size})\n`);
|
|
1302
3432
|
}
|
|
1303
3433
|
}
|
|
3434
|
+
// Same holder predicate as every other msg_seq reclamation site: a
|
|
3435
|
+
// cold-start restore is reached with the in-memory holder collections
|
|
3436
|
+
// intact (INVALID_SESSION / non-1000 WS close set coldStart without a
|
|
3437
|
+
// restart), and replyContextByMessageId only names each chat's *latest*
|
|
3438
|
+
// msgId. Filtering on that map alone dropped the counter a session
|
|
3439
|
+
// anchored to an older msgId still needed, so the next anchored flush
|
|
3440
|
+
// restarted at msg_seq 1 and QQ deduped the tail away.
|
|
1304
3441
|
for (const msgId of this.msgSeqMap.keys()) {
|
|
1305
|
-
if (!this.
|
|
3442
|
+
if (!this.isMsgSeqStillInUse(msgId)) {
|
|
1306
3443
|
this.msgSeqMap.delete(msgId);
|
|
1307
3444
|
}
|
|
1308
3445
|
}
|
|
@@ -1412,19 +3549,493 @@ export class QQChannel extends ChannelBase {
|
|
|
1412
3549
|
`);
|
|
1413
3550
|
}
|
|
1414
3551
|
}
|
|
3552
|
+
/**
|
|
3553
|
+
* Purge orphaned session mappings left over from older scope eras:
|
|
3554
|
+
* `<channel>:__single__` keys (single-scope era, see PR #6457) and this
|
|
3555
|
+
* channel's own three-part user-scope keys.
|
|
3556
|
+
*
|
|
3557
|
+
* `<channel>:__single__` is the live routing key under an explicit 'single'
|
|
3558
|
+
* sessionScope, so it is an orphan only under any other scope. There it can
|
|
3559
|
+
* never be routed to again and is dead weight in the router maps and the
|
|
3560
|
+
* persisted sessions file — and worse, restore re-attaches it via
|
|
3561
|
+
* bridge.loadSession on every restart, silently resetting continuity.
|
|
3562
|
+
*
|
|
3563
|
+
* User-scope keys (`<channel>:<sender>:<chat>`) are unroutable under every
|
|
3564
|
+
* scope other than 'user' (including 'single'): SessionRouter's
|
|
3565
|
+
* thread/chat_thread routing key is `<channel>:<chatId>` and its 'single'
|
|
3566
|
+
* key is `<channel>:__single__`, while resolve() is a bare map lookup with
|
|
3567
|
+
* no shape fallback. This channel's own user-scope entries are therefore
|
|
3568
|
+
* purged under every recognized non-'user' scope; an unrecognized scope
|
|
3569
|
+
* fails closed (see knownScope below). A sibling channel's are left alone,
|
|
3570
|
+
* decided by target ownership, never by key prefix. The purge runs after
|
|
3571
|
+
* restore, so on the first boot those keys are still re-attached once by
|
|
3572
|
+
* bridge.loadSession and then released here; afterwards they are gone from
|
|
3573
|
+
* the persisted file.
|
|
3574
|
+
*
|
|
3575
|
+
* The destructive half is opt-in via `purgeLegacySessions` (default false):
|
|
3576
|
+
* the default scope changing is not the operator asking to delete persisted
|
|
3577
|
+
* conversations. With it off, the doomed routes are only counted and
|
|
3578
|
+
* reported, and nothing is released. With it on, before the first deletion
|
|
3579
|
+
* the doomed routes are appended to `<name>-sessions-purged.json` in the
|
|
3580
|
+
* channel state directory so an operator can restore a legacy conversation
|
|
3581
|
+
* by hand; if that write fails, nothing is deleted. Each record carries the
|
|
3582
|
+
* route's `cwd` when the router's persisted route store still has it, so a
|
|
3583
|
+
* restored route resolves to the same workspace. The file accumulates purge
|
|
3584
|
+
* records and is never read back automatically.
|
|
3585
|
+
*
|
|
3586
|
+
* Runs AFTER restoreSessions(): SessionRouter exposes no public API to drop
|
|
3587
|
+
* persisted entries before restore (readPersistedEntries/deleteByKey are
|
|
3588
|
+
* private), and rewriting the persist file from here would be fragile and
|
|
3589
|
+
* race-prone. The orphan is therefore re-attached by bridge.loadSession
|
|
3590
|
+
* during restore and then released here — both the router mapping and the
|
|
3591
|
+
* daemon-side session (see bridge.discardSession below) are torn down.
|
|
3592
|
+
*/
|
|
3593
|
+
purgeSingleScopeOrphans() {
|
|
3594
|
+
const scope = this.config.sessionScope;
|
|
3595
|
+
// Fail closed on an unrecognized scope. SessionRouter.routingKey() switches
|
|
3596
|
+
// on scope with `case 'user': default:`, so any value outside the
|
|
3597
|
+
// SessionScope union still builds LIVE `<channel>:<sender>:<chat>` keys.
|
|
3598
|
+
// Testing orphanhood with `scope !== 'user'` therefore treats an operator
|
|
3599
|
+
// typo (or a value written by a different version) as "every live route is
|
|
3600
|
+
// an orphan" and discards all of the channel's persisted sessions on each
|
|
3601
|
+
// cold start. Only a scope this code can reason about may authorise a
|
|
3602
|
+
// destructive purge.
|
|
3603
|
+
const knownScope = scope === 'user' ||
|
|
3604
|
+
scope === 'thread' ||
|
|
3605
|
+
scope === 'chat_thread' ||
|
|
3606
|
+
scope === 'single';
|
|
3607
|
+
const singleScope = scope === 'single';
|
|
3608
|
+
if (!knownScope) {
|
|
3609
|
+
// Fail closed, but not silently: the operator asked for a cleanup (or at
|
|
3610
|
+
// least has a scope this build cannot reason about) and must know that
|
|
3611
|
+
// nothing was counted or purged.
|
|
3612
|
+
process.stderr.write(`[QQ:${this.name}] purgeSingleScopeOrphans skipped: unrecognized sessionScope '${sanitizeLogText(scope, 32)}' — refusing to guess which keys a scope would build, so nothing was purged\n`);
|
|
3613
|
+
return;
|
|
3614
|
+
}
|
|
3615
|
+
try {
|
|
3616
|
+
// Optional-call like the READY path: an externally supplied router may
|
|
3617
|
+
// not expose getAll; fall back to an empty list rather than crash.
|
|
3618
|
+
const all = this.router.getAll?.() ?? [];
|
|
3619
|
+
// Phase 1: collect what would be deleted, with the predicate that doomed
|
|
3620
|
+
// it. Nothing is torn down yet — the rescue copy below must reach disk
|
|
3621
|
+
// before the first removeSessionId(), because that call persists.
|
|
3622
|
+
// getAll() reports no cwd, so the only source for a doomed route's cwd is
|
|
3623
|
+
// the store the router persists to, which records entry.cwd verbatim
|
|
3624
|
+
// (SessionRouter.persist). In daemon mode that is the shared router's own
|
|
3625
|
+
// routes.json, not this channel's sessions.json, so resolve the path off
|
|
3626
|
+
// the router and fall back to globalSessionsPath only for a supplied
|
|
3627
|
+
// router that exposes no persistPath (external/duck-typed). The fallback
|
|
3628
|
+
// cannot serve standalone mode: there the internally built router's own
|
|
3629
|
+
// persistPath is the per-channel file and wins, so the fallback reads the
|
|
3630
|
+
// shared sessions.json. Read it once, best-effort: a missing, empty, or
|
|
3631
|
+
// unparsable file — or a key absent from it — must leave cwd off the
|
|
3632
|
+
// record rather than fall back to the router default, which would point a
|
|
3633
|
+
// hand-restored route at the wrong workspace.
|
|
3634
|
+
const routerPersistPath = this.router['persistPath'];
|
|
3635
|
+
const cwdStorePath = typeof routerPersistPath === 'string' && routerPersistPath.length > 0
|
|
3636
|
+
? routerPersistPath
|
|
3637
|
+
: this.globalSessionsPath;
|
|
3638
|
+
const persistedCwdByKey = new Map();
|
|
3639
|
+
try {
|
|
3640
|
+
if (existsSync(cwdStorePath)) {
|
|
3641
|
+
const raw = JSON.parse(readFileSync(cwdStorePath, 'utf-8'));
|
|
3642
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
3643
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
3644
|
+
const entry = value;
|
|
3645
|
+
const meta = {};
|
|
3646
|
+
if (typeof entry?.['cwd'] === 'string' &&
|
|
3647
|
+
entry['cwd'].length > 0) {
|
|
3648
|
+
meta.cwd = entry['cwd'];
|
|
3649
|
+
}
|
|
3650
|
+
if (entry?.['isolation'] === 'worktree') {
|
|
3651
|
+
meta.isolation = 'worktree';
|
|
3652
|
+
}
|
|
3653
|
+
if (typeof entry?.['workspaceCwd'] === 'string' &&
|
|
3654
|
+
entry['workspaceCwd'].length > 0) {
|
|
3655
|
+
meta.workspaceCwd = entry['workspaceCwd'];
|
|
3656
|
+
}
|
|
3657
|
+
if (typeof entry?.['turns'] === 'number') {
|
|
3658
|
+
meta.turns = entry['turns'];
|
|
3659
|
+
}
|
|
3660
|
+
if (typeof entry?.['startedAt'] === 'number') {
|
|
3661
|
+
meta.startedAt = entry['startedAt'];
|
|
3662
|
+
}
|
|
3663
|
+
if (Object.keys(meta).length > 0) {
|
|
3664
|
+
persistedCwdByKey.set(key, meta);
|
|
3665
|
+
}
|
|
3666
|
+
}
|
|
3667
|
+
}
|
|
3668
|
+
}
|
|
3669
|
+
}
|
|
3670
|
+
catch (e) {
|
|
3671
|
+
// Best-effort: a rescue record without cwd is still restorable by hand.
|
|
3672
|
+
process.stderr.write(`[QQ:${this.name}] purgeSingleScopeOrphans route metadata read failed, rescue copies omit cwd/isolation: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n`);
|
|
3673
|
+
}
|
|
3674
|
+
const doomed = [];
|
|
3675
|
+
for (const entry of all) {
|
|
3676
|
+
// Match only this channel's own keys, on the entry's base key (a
|
|
3677
|
+
// message route wraps it, see unwrapMessageRoutingKey):
|
|
3678
|
+
// in daemon mode the router is shared across
|
|
3679
|
+
// channels, so a suffix match on ':__single__' would also hit sibling
|
|
3680
|
+
// channels' live single-scope routing state and silently reset their
|
|
3681
|
+
// sessions. Orphan keys from the single-scope era are
|
|
3682
|
+
// `<thisChannel>:__single__`, so the base-key match still cleans up
|
|
3683
|
+
// this channel's orphans without touching sibling routes — but only
|
|
3684
|
+
// when the current scope is not 'single', where this key is the live
|
|
3685
|
+
// one. Gated on knownScope too, and there the purge is suppressed
|
|
3686
|
+
// deliberately (with a log): an unrecognized scope cannot tell us which
|
|
3687
|
+
// keys a scope would build, so even a currently unreachable
|
|
3688
|
+
// `__single__` route is left for the operator rather than guessed at.
|
|
3689
|
+
// Key equality alone is not ownership: channel names are unrestricted
|
|
3690
|
+
// user config and may contain colons, so a sibling named `<ourName>:x`
|
|
3691
|
+
// can route `chatId: 'x', threadId: '__single__'` (or `senderId: 'x',
|
|
3692
|
+
// chatId: '__single__'` under 'user') to the same base string. Apply
|
|
3693
|
+
// the target check here too; a target-less entry still has to be
|
|
3694
|
+
// purgeable, which is why the check permits `undefined`.
|
|
3695
|
+
// Legacy user-scope routes of THIS channel are unroutable under every
|
|
3696
|
+
// non-'user' scope (including 'single', whose key is
|
|
3697
|
+
// `<channel>:__single__`), so a persisted user-era key there can never
|
|
3698
|
+
// resolve again. Ownership comes from the entry's target, not from a
|
|
3699
|
+
// key prefix, so a sibling channel whose name prefixes ours is never
|
|
3700
|
+
// touched. The base key is compared against the exact string the
|
|
3701
|
+
// router builds for 'user' scope rather than being split on ':' — the
|
|
3702
|
+
// channel name is unrestricted user config and may itself contain
|
|
3703
|
+
// colons, so the part count does not identify the scope.
|
|
3704
|
+
const baseKey = unwrapMessageRoutingKey(entry.key);
|
|
3705
|
+
const ownsEntry = entry.target === undefined || entry.target.channelName === this.name;
|
|
3706
|
+
const isSingleOrphan = knownScope &&
|
|
3707
|
+
!singleScope &&
|
|
3708
|
+
baseKey === singleScopeRoutingKey(this.name) &&
|
|
3709
|
+
ownsEntry;
|
|
3710
|
+
const isOwnLegacyUserKey = knownScope &&
|
|
3711
|
+
scope !== 'user' &&
|
|
3712
|
+
entry.target?.channelName === this.name &&
|
|
3713
|
+
baseKey ===
|
|
3714
|
+
`${entry.target.channelName}:${entry.target.senderId}:${entry.target.chatId}`;
|
|
3715
|
+
if (isSingleOrphan || isOwnLegacyUserKey) {
|
|
3716
|
+
doomed.push({
|
|
3717
|
+
kind: isSingleOrphan ? 'single' : 'user',
|
|
3718
|
+
key: entry.key,
|
|
3719
|
+
sessionId: entry.sessionId,
|
|
3720
|
+
target: entry.target,
|
|
3721
|
+
...(persistedCwdByKey.get(entry.key) ?? {}),
|
|
3722
|
+
});
|
|
3723
|
+
}
|
|
3724
|
+
}
|
|
3725
|
+
if (doomed.length > 0 && this.qqConfig.purgeLegacySessions !== true) {
|
|
3726
|
+
// A default changing is not an operator request to delete persisted
|
|
3727
|
+
// conversations, so the destructive half is opt-in.
|
|
3728
|
+
process.stderr.write(`[QQ:${this.name}] Left ${doomed.length} orphaned session route(s) in place (nothing removed); set "purgeLegacySessions": true to delete them\n`);
|
|
3729
|
+
return;
|
|
3730
|
+
}
|
|
3731
|
+
if (doomed.length > 0) {
|
|
3732
|
+
// Append to any earlier record instead of truncating it: it is the
|
|
3733
|
+
// only copy of routes a previous purge already deleted. A file that
|
|
3734
|
+
// cannot be read, or does not hold a records list, is quarantined
|
|
3735
|
+
// rather than replaced, so a torn-but-hand-recoverable copy survives
|
|
3736
|
+
// the new record.
|
|
3737
|
+
let earlier = [];
|
|
3738
|
+
let unreadable;
|
|
3739
|
+
try {
|
|
3740
|
+
if (existsSync(this.sessionsPurgedPath)) {
|
|
3741
|
+
const raw = JSON.parse(readFileSync(this.sessionsPurgedPath, 'utf-8'));
|
|
3742
|
+
if (Array.isArray(raw)) {
|
|
3743
|
+
earlier = raw;
|
|
3744
|
+
}
|
|
3745
|
+
else if (raw !== null &&
|
|
3746
|
+
typeof raw === 'object' &&
|
|
3747
|
+
Array.isArray(raw.routes)) {
|
|
3748
|
+
// An earlier build of this branch wrote a single record as a bare
|
|
3749
|
+
// object. It is the only copy of routes that purge already
|
|
3750
|
+
// deleted, so keep it as one prior record rather than discard it.
|
|
3751
|
+
earlier = [raw];
|
|
3752
|
+
}
|
|
3753
|
+
else {
|
|
3754
|
+
unreadable = 'is not a purge-record list';
|
|
3755
|
+
}
|
|
3756
|
+
}
|
|
3757
|
+
}
|
|
3758
|
+
catch (e) {
|
|
3759
|
+
unreadable = `unreadable: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}`;
|
|
3760
|
+
}
|
|
3761
|
+
if (unreadable !== undefined) {
|
|
3762
|
+
// Quarantine on the same pattern SessionRouter uses for its persist
|
|
3763
|
+
// file: keep the damaged copy instead of letting the write below
|
|
3764
|
+
// destroy whatever an operator could still salvage by hand.
|
|
3765
|
+
const quarantinePath = `${this.sessionsPurgedPath}.corrupt-${Date.now()}`;
|
|
3766
|
+
try {
|
|
3767
|
+
renameSync(this.sessionsPurgedPath, quarantinePath);
|
|
3768
|
+
process.stderr.write(`[QQ:${this.name}] purgeSingleScopeOrphans rescue file ${unreadable}, quarantined to ${quarantinePath}\n`);
|
|
3769
|
+
}
|
|
3770
|
+
catch (e) {
|
|
3771
|
+
process.stderr.write(`[QQ:${this.name}] purgeSingleScopeOrphans rescue file ${unreadable}, quarantine failed: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n`);
|
|
3772
|
+
}
|
|
3773
|
+
}
|
|
3774
|
+
// Self-describing so an operator can restore a route by hand; there is
|
|
3775
|
+
// no automatic restore, and no other path writes this file.
|
|
3776
|
+
const tmpPath = `${this.sessionsPurgedPath}.tmp`;
|
|
3777
|
+
try {
|
|
3778
|
+
// Write a sibling temp file then rename it over the rescue file: an
|
|
3779
|
+
// in-place truncating write that fails (ENOSPC/EIO, a kill mid-write)
|
|
3780
|
+
// would erase the earlier records just read, and `{ mode: 0o600 }`
|
|
3781
|
+
// does not tighten an existing 0o644 file the way a fresh rename does.
|
|
3782
|
+
writeFileSync(tmpPath, JSON.stringify([
|
|
3783
|
+
...earlier,
|
|
3784
|
+
{
|
|
3785
|
+
purgedAt: new Date().toISOString(),
|
|
3786
|
+
sessionScope: scope,
|
|
3787
|
+
routes: doomed,
|
|
3788
|
+
},
|
|
3789
|
+
].slice(-QQChannel.MAX_PURGE_RECORDS), null, 2), { mode: 0o600 });
|
|
3790
|
+
renameSync(tmpPath, this.sessionsPurgedPath);
|
|
3791
|
+
process.stderr.write(`[QQ:${this.name}] Saved ${doomed.length} session route(s) about to be purged to ${this.sessionsPurgedPath}\n`);
|
|
3792
|
+
}
|
|
3793
|
+
catch (e) {
|
|
3794
|
+
// A failed write can leave a partial sibling behind; drop it
|
|
3795
|
+
// best-effort so a failed purge leaves no garbage, never masking the
|
|
3796
|
+
// original error.
|
|
3797
|
+
try {
|
|
3798
|
+
unlinkSync(tmpPath);
|
|
3799
|
+
}
|
|
3800
|
+
catch {
|
|
3801
|
+
/* best-effort cleanup */
|
|
3802
|
+
}
|
|
3803
|
+
// Fail closed: without the rescue copy the deletions are not
|
|
3804
|
+
// recoverable by hand, so nothing is removed.
|
|
3805
|
+
process.stderr.write(`[QQ:${this.name}] purgeSingleScopeOrphans rescue write failed, leaving ${doomed.length} session route(s) in place: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n`);
|
|
3806
|
+
return;
|
|
3807
|
+
}
|
|
3808
|
+
}
|
|
3809
|
+
// Phase 2: perform the deletions exactly as before, one entry at a time.
|
|
3810
|
+
let singlePurged = 0;
|
|
3811
|
+
let userPurged = 0;
|
|
3812
|
+
for (const entry of doomed) {
|
|
3813
|
+
// Release the daemon-side session too: restoreSessions() already
|
|
3814
|
+
// re-attached it via bridge.loadSession, so without this the orphan
|
|
3815
|
+
// stays alive in the daemon until the process ends. removeSessionId
|
|
3816
|
+
// below only clears the router maps. Best-effort — a bridge without
|
|
3817
|
+
// discardSession (or a failed discard) must not break the purge.
|
|
3818
|
+
// No binding token: the orphan is no longer routed to, and purge
|
|
3819
|
+
// runs before any new route can reference it.
|
|
3820
|
+
try {
|
|
3821
|
+
void this.bridge
|
|
3822
|
+
.discardSession?.(entry.sessionId)
|
|
3823
|
+
.catch(() => undefined);
|
|
3824
|
+
}
|
|
3825
|
+
catch {
|
|
3826
|
+
// Best-effort cleanup must not abort the purge.
|
|
3827
|
+
}
|
|
3828
|
+
if (this.router.removeSessionId(entry.sessionId)) {
|
|
3829
|
+
if (entry.kind === 'single')
|
|
3830
|
+
singlePurged++;
|
|
3831
|
+
else
|
|
3832
|
+
userPurged++;
|
|
3833
|
+
}
|
|
3834
|
+
}
|
|
3835
|
+
// Report the two kinds separately: the user-scope branch deletes persisted
|
|
3836
|
+
// conversations that a reader may still want to recover, so it must not be
|
|
3837
|
+
// reported as single-scope housekeeping.
|
|
3838
|
+
if (singlePurged > 0) {
|
|
3839
|
+
process.stderr.write(`[QQ:${this.name}] Purged ${singlePurged} orphaned single-scope session mapping(s)\n`);
|
|
3840
|
+
}
|
|
3841
|
+
if (userPurged > 0) {
|
|
3842
|
+
process.stderr.write(`[QQ:${this.name}] Purged ${userPurged} orphaned user-scope session mapping(s)\n`);
|
|
3843
|
+
}
|
|
3844
|
+
}
|
|
3845
|
+
catch (e) {
|
|
3846
|
+
process.stderr.write(`[QQ:${this.name}] purgeSingleScopeOrphans failed: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n`);
|
|
3847
|
+
}
|
|
3848
|
+
}
|
|
1415
3849
|
// ── ReplyMsgId helpers ────────────────────────────────────────
|
|
3850
|
+
/**
|
|
3851
|
+
* Resolve a session's reply anchor, returning both the raw entry and the
|
|
3852
|
+
* msgId only while the entry is still inside REPLY_MSG_ID_TTL_MS. Callers
|
|
3853
|
+
* that just need a usable anchor take `.msgId`; callers that must tell an
|
|
3854
|
+
* absent anchor from an expired one (the final-segment fallback logs the
|
|
3855
|
+
* expired case) branch on `.entry`. One site, so the freshness rule cannot
|
|
3856
|
+
* drift between the flush, cancelled-stash, completion and create paths.
|
|
3857
|
+
*/
|
|
3858
|
+
resolveSessionReplyAnchor(sessionId) {
|
|
3859
|
+
const entry = this.sessionReplyMsgId.get(sessionId);
|
|
3860
|
+
const fresh = entry !== undefined &&
|
|
3861
|
+
Date.now() - entry.timestamp < QQChannel.REPLY_MSG_ID_TTL_MS;
|
|
3862
|
+
return { entry, msgId: fresh ? entry.msgId : undefined };
|
|
3863
|
+
}
|
|
3864
|
+
/**
|
|
3865
|
+
* Release a session's reply anchor, cascading to msgSeqMap when the
|
|
3866
|
+
* anchored msgId is no longer referenced by any live session or by the
|
|
3867
|
+
* chat's current replyMsgId entry. Idempotent — safe to call on sessions
|
|
3868
|
+
* that never held an anchor. This is the single release path for
|
|
3869
|
+
* sessionReplyMsgId so every release point (flush completion, permanent
|
|
3870
|
+
* delivery failure, retry exhaustion, onResponseComplete, onSessionDied,
|
|
3871
|
+
* onPromptEnd, group removal) also cleans up msg_seq counters whose msgId
|
|
3872
|
+
* can never be used again — otherwise they accumulate forever in memory
|
|
3873
|
+
* and in the persisted QQ state.
|
|
3874
|
+
*
|
|
3875
|
+
* When expectedMsgId is given, the map entry is only removed if it still
|
|
3876
|
+
* points at that msgId. A deferred send chain may settle after the next
|
|
3877
|
+
* prompt on the same session has already overwritten the anchor with a new
|
|
3878
|
+
* msgId; releasing by sessionId alone would delete the *successor's* anchor
|
|
3879
|
+
* (the exact defect this parameter guards). The msgSeqMap cascade still
|
|
3880
|
+
* runs for expectedMsgId so the superseded turn's counter cannot orphan.
|
|
3881
|
+
*/
|
|
3882
|
+
releaseSessionReplyAnchor(sessionId, expectedMsgId) {
|
|
3883
|
+
const current = this.sessionReplyMsgId.get(sessionId);
|
|
3884
|
+
if (expectedMsgId === undefined) {
|
|
3885
|
+
// Caller has no identity expectation — preserve legacy behavior and
|
|
3886
|
+
// drop whatever anchor the session currently holds.
|
|
3887
|
+
this.sessionReplyMsgId.delete(sessionId);
|
|
3888
|
+
}
|
|
3889
|
+
else if (current !== undefined && current.msgId === expectedMsgId) {
|
|
3890
|
+
// Still this turn's anchor — safe to remove.
|
|
3891
|
+
this.sessionReplyMsgId.delete(sessionId);
|
|
3892
|
+
}
|
|
3893
|
+
// Otherwise (expectedMsgId given but the current entry is missing or has
|
|
3894
|
+
// been overwritten by a successor turn) leave the map entry untouched —
|
|
3895
|
+
// it no longer belongs to this turn. The cascade below still runs for
|
|
3896
|
+
// expectedMsgId so this turn's msg_seq counter cannot orphan.
|
|
3897
|
+
const target = expectedMsgId ?? current?.msgId;
|
|
3898
|
+
if (target === undefined)
|
|
3899
|
+
return;
|
|
3900
|
+
// Derived reclamation: the predicate now covers every holder —
|
|
3901
|
+
// another session's anchor, the chat-level anchor that used to be scanned
|
|
3902
|
+
// inline here, a live stream entry, an in-flight send — so this one call
|
|
3903
|
+
// replaces the veto + scan + delete. Releasing while a holder remains
|
|
3904
|
+
// would drop the counter and the tail's sendMessage would resolve
|
|
3905
|
+
// nextSeq = 1 after the first flush's (msg-A,1); QQ dedupes on msg_id +
|
|
3906
|
+
// msg_seq and silently drops the tail.
|
|
3907
|
+
this.reclaimMsgSeq(target);
|
|
3908
|
+
}
|
|
3909
|
+
/**
|
|
3910
|
+
* Whether a msgId's msg_seq counter is still needed: another session is
|
|
3911
|
+
* anchored to it, the chat-level replyMsgId entry still points at it, a
|
|
3912
|
+
* streamState entry holds it with a pending residual or an in-flight flush,
|
|
3913
|
+
* an anchored send under it is in flight (including the entry-less sends
|
|
3914
|
+
* flushingSessions cannot see), or the replyContextByMessageId routing map
|
|
3915
|
+
* still names it. The single predicate for every msgSeqMap reclamation site,
|
|
3916
|
+
* so the release veto and the TTL/teardown reclaimers cannot disagree and
|
|
3917
|
+
* drop a counter under a live send. It errs toward retaining: a counter that
|
|
3918
|
+
* survives too long only makes a later send under the same msgId start at a
|
|
3919
|
+
* higher msg_seq, which QQ accepts, while reclaiming it early loses the tail
|
|
3920
|
+
* (QQ dedupes on msg_id + msg_seq).
|
|
3921
|
+
*/
|
|
3922
|
+
isMsgSeqStillInUse(msgId) {
|
|
3923
|
+
if (this.isMsgIdAnchoredBySession(msgId))
|
|
3924
|
+
return true;
|
|
3925
|
+
// Chat-level anchor: this scan used to live inline in
|
|
3926
|
+
// releaseSessionReplyAnchor only, so the TTL/teardown reclaimers could
|
|
3927
|
+
// drop a counter the chat entry still named. Folding it in gives every
|
|
3928
|
+
// site the same holder set.
|
|
3929
|
+
for (const [, entry] of this.replyMsgId) {
|
|
3930
|
+
if (entry.msgId === msgId)
|
|
3931
|
+
return true;
|
|
3932
|
+
}
|
|
3933
|
+
for (const [sid, s] of this.streamState) {
|
|
3934
|
+
if (s.msgId === msgId &&
|
|
3935
|
+
(s.buffer || s.timer || this.flushingSessions.has(sid)))
|
|
3936
|
+
return true;
|
|
3937
|
+
}
|
|
3938
|
+
if ((this.inFlightMsgSeqSends.get(msgId) ?? 0) > 0)
|
|
3939
|
+
return true;
|
|
3940
|
+
// Routing map: replyContextByMessageId is the structure a send
|
|
3941
|
+
// actually resolves its outgoing msg_id from (sendResponseMessage,
|
|
3942
|
+
// sendMessageWithReplyContext, handleInbound), so a counter it still names
|
|
3943
|
+
// is still reachable by a send even when no anchor/stream/in-flight holder
|
|
3944
|
+
// remains. Plain presence, not a TTL check: this predicate must err toward
|
|
3945
|
+
// retaining, and it cannot leak — the sweep's first loop already evicts
|
|
3946
|
+
// expired entries through deleteReplyContext before the orphan pass runs.
|
|
3947
|
+
return this.replyContextByMessageId.has(msgId);
|
|
3948
|
+
}
|
|
3949
|
+
/**
|
|
3950
|
+
* Drop a msgId's msg_seq counter once no holder remains. Derived,
|
|
3951
|
+
* not decided per call site: a release site that vetoes reclamation while a
|
|
3952
|
+
* holder still exists never gets a second chance once that holder later
|
|
3953
|
+
* disappears, so every site routes through isMsgSeqStillInUse and the sweep
|
|
3954
|
+
* re-checks the counter map itself. Returns whether the counter was dropped;
|
|
3955
|
+
* the drop is persisted so a cold restart cannot resurrect it.
|
|
3956
|
+
*/
|
|
3957
|
+
reclaimMsgSeq(msgId) {
|
|
3958
|
+
if (this.isMsgSeqStillInUse(msgId))
|
|
3959
|
+
return false;
|
|
3960
|
+
if (!this.msgSeqMap.delete(msgId))
|
|
3961
|
+
return false;
|
|
3962
|
+
this.saveQQState();
|
|
3963
|
+
return true;
|
|
3964
|
+
}
|
|
3965
|
+
/**
|
|
3966
|
+
* Safety net that reclaims a msg_seq counter once its last holder is gone:
|
|
3967
|
+
* the iteration domain is the counter map itself, so a
|
|
3968
|
+
* counter whose only *naming* entry (session anchor, chat anchor,
|
|
3969
|
+
* replyContextByMessageId routing entry, stream entry) was already deleted
|
|
3970
|
+
* is still visited. It cannot make orphaning impossible — a counter can
|
|
3971
|
+
* outlive its last holder between two 60s ticks — but it guarantees the
|
|
3972
|
+
* counter is dropped on the next tick instead of leaking forever. Deleting
|
|
3973
|
+
* from a Map while iterating its keys is safe. Returns whether anything was
|
|
3974
|
+
* reclaimed.
|
|
3975
|
+
*/
|
|
3976
|
+
reclaimOrphanMsgSeqCounters() {
|
|
3977
|
+
let reclaimed = false;
|
|
3978
|
+
for (const msgId of this.msgSeqMap.keys()) {
|
|
3979
|
+
if (this.reclaimMsgSeq(msgId))
|
|
3980
|
+
reclaimed = true;
|
|
3981
|
+
}
|
|
3982
|
+
return reclaimed;
|
|
3983
|
+
}
|
|
3984
|
+
/** Mark an anchored send under msgId in flight for the release guard. */
|
|
3985
|
+
beginMsgSeqSend(msgId) {
|
|
3986
|
+
this.inFlightMsgSeqSends.set(msgId, (this.inFlightMsgSeqSends.get(msgId) ?? 0) + 1);
|
|
3987
|
+
}
|
|
3988
|
+
/** Clear the matching beginMsgSeqSend once the send has settled. */
|
|
3989
|
+
endMsgSeqSend(msgId) {
|
|
3990
|
+
const remaining = (this.inFlightMsgSeqSends.get(msgId) ?? 1) - 1;
|
|
3991
|
+
if (remaining > 0)
|
|
3992
|
+
this.inFlightMsgSeqSends.set(msgId, remaining);
|
|
3993
|
+
else
|
|
3994
|
+
this.inFlightMsgSeqSends.delete(msgId);
|
|
3995
|
+
}
|
|
3996
|
+
/** Whether any live session is still anchored to this msgId. */
|
|
3997
|
+
isMsgIdAnchoredBySession(msgId) {
|
|
3998
|
+
// for...of instead of [...values()].some(...) — this is called inside
|
|
3999
|
+
// cleanupExpiredReplyMsgIds' interval loop, so avoid allocating an array
|
|
4000
|
+
// on every call.
|
|
4001
|
+
for (const a of this.sessionReplyMsgId.values()) {
|
|
4002
|
+
if (a.msgId === msgId)
|
|
4003
|
+
return true;
|
|
4004
|
+
}
|
|
4005
|
+
return false;
|
|
4006
|
+
}
|
|
4007
|
+
/**
|
|
4008
|
+
* Set replyMsgId for a chat, replacing any previous entry. The previous
|
|
4009
|
+
* msgId's msg_seq counter is deliberately NOT dropped here (see the NOTE
|
|
4010
|
+
* below): it is left to the TTL sweep, which reclaims it only once no
|
|
4011
|
+
* holder remains.
|
|
4012
|
+
*/
|
|
1416
4013
|
setReplyMsgId(chatId, msgId) {
|
|
1417
4014
|
const timestamp = Date.now();
|
|
4015
|
+
// NOTE: main's #10145 (session-aware delivery) deliberately does NOT drop
|
|
4016
|
+
// the previous msgId's msg_seq counter here; counters are reclaimed by TTL
|
|
4017
|
+
// through deleteReplyContext / startReplyMsgIdCleanup. PR #8241's eager,
|
|
4018
|
+
// session-guarded delete is therefore not applied: main's TTL path (now
|
|
4019
|
+
// itself guarded by isMsgIdAnchoredBySession) supersedes it.
|
|
1418
4020
|
this.replyMsgId.set(chatId, { msgId, timestamp });
|
|
1419
4021
|
this.replyContextByMessageId.set(msgId, { chatId, msgId, timestamp });
|
|
1420
4022
|
this.saveQQState();
|
|
1421
4023
|
}
|
|
1422
4024
|
deleteReplyContext(context) {
|
|
1423
4025
|
this.replyContextByMessageId.delete(context.msgId);
|
|
1424
|
-
|
|
4026
|
+
// Drop the chat-level naming entry BEFORE the reclaim: while it is still
|
|
4027
|
+
// present it is itself a holder, so reclaiming first would always veto and
|
|
4028
|
+
// the counter would linger until the next 60s tick.
|
|
1425
4029
|
if (this.replyMsgId.get(context.chatId)?.msgId === context.msgId) {
|
|
1426
4030
|
this.replyMsgId.delete(context.chatId);
|
|
1427
4031
|
}
|
|
4032
|
+
// A streaming reply anchored to this msgId may still be in flight
|
|
4033
|
+
// (per-session msgId, PR #8241): reclaimMsgSeq keeps its msg_seq counter
|
|
4034
|
+
// alive while any remaining holder still needs it — a session anchor, the
|
|
4035
|
+
// chat-level anchor, a buffered or in-flight stream entry, an anchored send
|
|
4036
|
+
// in flight, or the replyContextByMessageId routing entry (the full set
|
|
4037
|
+
// lives in isMsgSeqStillInUse).
|
|
4038
|
+
this.reclaimMsgSeq(context.msgId);
|
|
1428
4039
|
}
|
|
1429
4040
|
/**
|
|
1430
4041
|
* Start periodic cleanup of expired replyMsgId entries.
|
|
@@ -1436,6 +4047,10 @@ export class QQChannel extends ChannelBase {
|
|
|
1436
4047
|
this.replyMsgIdCleanupTimer = setInterval(() => {
|
|
1437
4048
|
const cutoff = Date.now() - QQChannel.REPLY_MSG_ID_TTL_MS;
|
|
1438
4049
|
let dirty = false;
|
|
4050
|
+
// Whether any of the tick's steps reclaimed a counter, so the explicit
|
|
4051
|
+
// save below only runs for a tick that evicted entries without dropping
|
|
4052
|
+
// one (reclaimMsgSeq persists internally).
|
|
4053
|
+
let reclaimed = false;
|
|
1439
4054
|
for (const context of this.replyContextByMessageId.values()) {
|
|
1440
4055
|
if (context.timestamp < cutoff) {
|
|
1441
4056
|
this.deleteReplyContext(context);
|
|
@@ -1444,12 +4059,45 @@ export class QQChannel extends ChannelBase {
|
|
|
1444
4059
|
}
|
|
1445
4060
|
for (const [chatId, entry] of this.replyMsgId) {
|
|
1446
4061
|
if (entry.timestamp < cutoff) {
|
|
1447
|
-
|
|
4062
|
+
// Drop the naming entry BEFORE the reclaim, like deleteReplyContext:
|
|
4063
|
+
// while it is still present it is itself a holder, so reclaiming
|
|
4064
|
+
// first would always veto. A still-live holder (session anchor,
|
|
4065
|
+
// stream entry, in-flight send, routing map) vetoes as before.
|
|
4066
|
+
// Pre-reorder, that first call was a self-vetoed no-op and this
|
|
4067
|
+
// tick's orphan pass below reclaimed the counter anyway, so the
|
|
4068
|
+
// ordering has no observable difference (accepted zero coverage).
|
|
1448
4069
|
this.replyMsgId.delete(chatId);
|
|
4070
|
+
if (this.reclaimMsgSeq(entry.msgId))
|
|
4071
|
+
reclaimed = true;
|
|
1449
4072
|
dirty = true;
|
|
1450
4073
|
}
|
|
1451
4074
|
}
|
|
1452
|
-
|
|
4075
|
+
// TTL safety net for orphaned per-session anchors: every release path
|
|
4076
|
+
// cleans its own entry, but any missed release would otherwise leak
|
|
4077
|
+
// the anchor (and its msgSeqMap counter) forever. Passing the entry's
|
|
4078
|
+
// own msgId as the identity expectation always matches, so the anchor
|
|
4079
|
+
// entry IS deleted here even when a live stream still holds it; the
|
|
4080
|
+
// veto lives in reclaimMsgSeq/isMsgSeqStillInUse, which keeps only the
|
|
4081
|
+
// msg_seq counter alive while a holder remains.
|
|
4082
|
+
// No dirty flag here: releaseSessionReplyAnchor already persists
|
|
4083
|
+
// internally when it actually drops the msgSeqMap counter — marking
|
|
4084
|
+
// dirty unconditionally would force a full serialization every 60s
|
|
4085
|
+
// tick while an expired anchor is present.
|
|
4086
|
+
for (const [sessionId, entry] of this.sessionReplyMsgId) {
|
|
4087
|
+
if (entry.timestamp < cutoff) {
|
|
4088
|
+
this.releaseSessionReplyAnchor(sessionId, entry.msgId);
|
|
4089
|
+
}
|
|
4090
|
+
}
|
|
4091
|
+
// Derived reclamation: the iteration domain is the counter
|
|
4092
|
+
// map itself, so a counter whose only naming entry was already deleted —
|
|
4093
|
+
// earlier in this tick or on a previous one — is still visited, and one
|
|
4094
|
+
// that outlived its last holder between two ticks is dropped here rather
|
|
4095
|
+
// than leaking. reclaimMsgSeq persists internally when it drops a counter,
|
|
4096
|
+
// which already covers the TTL evictions above; the explicit save below
|
|
4097
|
+
// is only needed for a tick that evicted entries without dropping one.
|
|
4098
|
+
if (this.reclaimOrphanMsgSeqCounters())
|
|
4099
|
+
reclaimed = true;
|
|
4100
|
+
if (dirty && !reclaimed)
|
|
1453
4101
|
this.saveQQState();
|
|
1454
4102
|
}, 60_000);
|
|
1455
4103
|
this.replyMsgIdCleanupTimer.unref();
|
|
@@ -1661,6 +4309,7 @@ export class QQChannel extends ChannelBase {
|
|
|
1661
4309
|
this.isReconnecting = false;
|
|
1662
4310
|
this.coldStart = false;
|
|
1663
4311
|
this.attachCronHandler();
|
|
4312
|
+
this.attachBridgeBoundarySeal();
|
|
1664
4313
|
}
|
|
1665
4314
|
handleGatewayMessage(msg, onReady) {
|
|
1666
4315
|
const op = msg['op'];
|
|
@@ -1696,6 +4345,7 @@ export class QQChannel extends ChannelBase {
|
|
|
1696
4345
|
.restoreSessions()
|
|
1697
4346
|
.then(() => {
|
|
1698
4347
|
this.fixRestoredSessions();
|
|
4348
|
+
this.purgeSingleScopeOrphans();
|
|
1699
4349
|
const all = this.router.getAll?.();
|
|
1700
4350
|
const count = all?.length ?? 0;
|
|
1701
4351
|
process.stderr.write(`[QQ:${this.name}] Ready (${count} sessions)\n`);
|
|
@@ -1705,6 +4355,10 @@ export class QQChannel extends ChannelBase {
|
|
|
1705
4355
|
})
|
|
1706
4356
|
.catch(() => {
|
|
1707
4357
|
this.fixRestoredSessions();
|
|
4358
|
+
// A partial restore attached orphans too (each loaded session
|
|
4359
|
+
// is exactly what the purge releases), so the repair branch
|
|
4360
|
+
// must run the same purge as the success branch.
|
|
4361
|
+
this.purgeSingleScopeOrphans();
|
|
1708
4362
|
process.stderr.write(`[QQ:${this.name}] WARNING: router session restore failed — cron messages will be dropped until sessions re-establish\n`);
|
|
1709
4363
|
this.finalizeReady();
|
|
1710
4364
|
this._checkGroupAllPolicyRequireMention();
|
|
@@ -2382,8 +5036,9 @@ export class QQChannel extends ChannelBase {
|
|
|
2382
5036
|
// Non-@-bot messages pass isMentioned:false, which causes GroupGate.requireMention
|
|
2383
5037
|
// to silently drop them before they reach the LLM. This is by core design:
|
|
2384
5038
|
// non-@-bot group messages should flow through cron/log only, not trigger AI.
|
|
2385
|
-
//
|
|
2386
|
-
//
|
|
5039
|
+
// The isMentioned=true override for non-@-bot keyword/all matches (PR #6457
|
|
5040
|
+
// review thread #17, landed in 998d36068b, then reverted there) was
|
|
5041
|
+
// intentionally not restored — it violated principle #1.
|
|
2387
5042
|
// Users must set requireMention: false in group config
|
|
2388
5043
|
// (e.g., `groups: { '*': { requireMention: false } }`) to allow
|
|
2389
5044
|
// non-@-bot keyword/all messages to reach the AI.
|
|
@@ -2445,16 +5100,21 @@ export class QQChannel extends ChannelBase {
|
|
|
2445
5100
|
}
|
|
2446
5101
|
this.chatTypeMap.delete(groupId);
|
|
2447
5102
|
this.groupActiveMsgEnabled.delete(groupId);
|
|
2448
|
-
// msgSeqMap is keyed by message ID, not group_openid — get the
|
|
2449
|
-
//
|
|
5103
|
+
// msgSeqMap is keyed by message ID, not group_openid — get the message ID
|
|
5104
|
+
// from replyMsgId before dropping the entry. The entry is removed BEFORE
|
|
5105
|
+
// the reclaim: while it is still present it is itself a holder, so
|
|
5106
|
+
// reclaiming first would always veto and leave the counter until the next
|
|
5107
|
+
// 60s sweep tick. A still-live holder (session anchor, stream entry,
|
|
5108
|
+
// in-flight send, routing map) vetoes as before.
|
|
2450
5109
|
const replyEntry = this.replyMsgId.get(groupId);
|
|
2451
|
-
if (replyEntry)
|
|
2452
|
-
this.msgSeqMap.delete(replyEntry.msgId);
|
|
2453
5110
|
this.replyMsgId.delete(groupId);
|
|
5111
|
+
if (replyEntry) {
|
|
5112
|
+
this.reclaimMsgSeq(replyEntry.msgId);
|
|
5113
|
+
}
|
|
2454
5114
|
for (const context of this.replyContextByMessageId.values()) {
|
|
2455
5115
|
if (context.chatId === groupId) {
|
|
2456
5116
|
this.replyContextByMessageId.delete(context.msgId);
|
|
2457
|
-
this.
|
|
5117
|
+
this.reclaimMsgSeq(context.msgId);
|
|
2458
5118
|
}
|
|
2459
5119
|
}
|
|
2460
5120
|
this.botOpenIdByGroup.delete(groupId);
|
|
@@ -2477,8 +5137,27 @@ export class QQChannel extends ChannelBase {
|
|
|
2477
5137
|
let cleanedStreams = 0;
|
|
2478
5138
|
for (const [sid, state] of this.streamState) {
|
|
2479
5139
|
if (state.chatId === groupId) {
|
|
2480
|
-
if (state.timer)
|
|
5140
|
+
if (state.timer) {
|
|
2481
5141
|
clearTimeout(state.timer);
|
|
5142
|
+
state.timer = null;
|
|
5143
|
+
}
|
|
5144
|
+
// Disarm the residual before the release: the guard would otherwise
|
|
5145
|
+
// veto on state this same block destroys (the entry is deleted two
|
|
5146
|
+
// lines later), leaving the counter orphaned with no path left to
|
|
5147
|
+
// reclaim it. Same ordering property as onResponseChunk's superseded
|
|
5148
|
+
// branch; flushingSessions stays set for a genuine in-flight send.
|
|
5149
|
+
state.buffer = '';
|
|
5150
|
+
// Release before the deletes so the release guard still sees this
|
|
5151
|
+
// entry's in-flight marker and keeps the msg_seq counter while a
|
|
5152
|
+
// send owns it (release-before-delete ordering). Expected-msgId-gated:
|
|
5153
|
+
// under 'single' scope the matched session is channel-wide and may
|
|
5154
|
+
// already belong to a newer turn, whose anchor must not be deleted. A
|
|
5155
|
+
// proactive/loop turn's entry carries no msgId; releasing with
|
|
5156
|
+
// `undefined` would mean "no identity expectation" and delete the
|
|
5157
|
+
// shared session's live anchor, so skip the release entirely then.
|
|
5158
|
+
if (state.msgId !== undefined) {
|
|
5159
|
+
this.releaseSessionReplyAnchor(sid, state.msgId);
|
|
5160
|
+
}
|
|
2482
5161
|
this.flushingSessions.delete(sid);
|
|
2483
5162
|
this.pendingStreamDelete.delete(sid);
|
|
2484
5163
|
this.flushedSessions.delete(sid);
|