@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.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
- flushingSessions = new Set();
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 'single' session scope
248
- // because all group messages share one conversation context.
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 !== 'single') {
252
- const originalScope = config.sessionScope;
253
- process.stderr.write(`[QQ:${name}] WARNING: groupAllPolicy is '${qqCfg.groupAllPolicy}' but sessionScope is '${originalScope}' (not 'single'). Forcing sessionScope to 'single' to ensure shared group context.\n`);
254
- config = { ...config, sessionScope: 'single' };
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
- if (toFlush) {
325
- const target = this.router.getTarget(sessionId);
326
- if (target) {
327
- this.sendMessageWithReplyContext(target.chatId, toFlush)
328
- .then(() => {
329
- if (!entry.buffer && this.cronBuffer.get(sessionId) === entry)
330
- this.cronBuffer.delete(sessionId);
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((err) => {
333
- const code = err instanceof DeliveryError ? err.code : null;
334
- const codeStr = code ? ` (${code})` : '';
335
- process.stderr.write(`[QQ:${this.name}] Cron flush send error${codeStr}: ${sanitizeLogText(err instanceof Error ? err.message : String(err), 200)}\n`);
336
- if (code === 'RETRY_EXHAUSTED' ||
337
- code === 'ACTIVE_MSG_DISABLED' ||
338
- code === 'FALLBACK_FAILED') {
339
- this.cronBuffer.delete(sessionId);
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
- entry.pendingRetry = toFlush;
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 retryTarget = this.router.getTarget(sessionId);
356
- if (!retryTarget) {
357
- process.stderr.write(`[QQ:${this.name}] Cron flush dropped after retry: no target for session ${sanitizeLogText(sessionId, 32)}\n`);
358
- this.cronBuffer.delete(sessionId);
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(retryTarget.chatId, toFlush)
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
- if (!entry.buffer &&
365
- this.cronBuffer.get(sessionId) === entry)
366
- this.cronBuffer.delete(sessionId);
585
+ this.settleCronFlushSuccess(sessionId, entry);
367
586
  })
368
- .catch((retryErr) => {
369
- const retryCode = retryErr instanceof DeliveryError
370
- ? retryErr.code
371
- : null;
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 (retryCode === 'RETRY_EXHAUSTED' ||
376
- retryCode === 'ACTIVE_MSG_DISABLED' ||
377
- retryCode === 'FALLBACK_FAILED') {
378
- if (!entry.buffer &&
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 on retry (RATE_LIMITED, etc.) — re-schedule with backoff
385
- entry.retryCount = (entry.retryCount ?? 1) + 1;
386
- if (entry.timer) {
387
- clearTimeout(entry.timer);
388
- }
389
- entry.pendingRetry = toFlush;
390
- entry.timer = setTimeout(() => {
391
- entry.timer = null;
392
- entry.pendingRetry = '';
393
- if (this.cronBuffer.get(sessionId) !== entry) {
394
- return;
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
- return;
433
- }
434
- }
435
- process.stderr.write(`[QQ:${this.name}] Cron flush dropped: no target for session ${sanitizeLogText(sessionId, 32)}, lost ${toFlush.length} chars\n`);
436
- this.cronBuffer.delete(sessionId);
437
- }, delay).unref();
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
- async sendResponseMessage(chatId, text, sessionId, sourceLabel) {
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
- const replyContext = messageId
586
- ? this.replyContextByMessageId.get(messageId)
587
- : undefined;
588
- await this.sendMessageWithReplyContext(chatId, text, replyContext, sourceLabel ?? this.getResponseSourceLabel(sessionId));
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
- if (!route)
600
- return;
601
- const entry = replyContext?.chatId === chatId ? replyContext : undefined;
602
- const msgId = entry && Date.now() - entry.timestamp < QQChannel.REPLY_MSG_ID_TTL_MS
603
- ? entry.msgId
604
- : undefined;
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
- this.msgSeqMap.set(msgId, nextSeq - 1);
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
- this.msgSeqMap.set(msgId, nextSeq - 1);
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
- this.msgSeqMap.set(msgId, nextSeq - 1);
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. Returns null if any guard fails.
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 null;
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 null;
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 null;
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 null;
1057
+ return { block: 'permanent' };
766
1058
  }
767
1059
  const base = getApiBase(Boolean(this.qqConfig.sandbox));
768
- const routeType = this.chatTypeMap.get(chatId) || this.qqConfig.chatTypes?.[chatId];
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 null;
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, _messageId) {
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
- onPromptEnd(_chatId, sessionId, _messageId) {
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
- const messageId = segment?.messageId ?? this.getResponseMessageId(sessionId);
866
- const replyContext = messageId
867
- ? this.replyContextByMessageId.get(messageId)
868
- : undefined;
869
- state = {
870
- chatId,
871
- buffer: chunk,
872
- timer: null,
873
- retryCount: 0,
874
- ...(replyContext ? { replyContext } : {}),
875
- ...(segment?.sourceLabel ? { sourceLabel: segment.sourceLabel } : {}),
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, this._reconnectId);
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
- process.stderr.write(`[QQ:${this.name}] idleFlush discarded (reconnect) session=${sanitizeLogText(sessionId, 32)}\n`);
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 later
920
- if (!state.timer) {
921
- const retryReconnectId = this._reconnectId;
922
- state.timer = setTimeout(() => {
923
- this.idleFlush(sessionId, retryReconnectId);
924
- }, QQChannel.IDLE_FLUSH_MS);
925
- state.timer.unref?.();
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.add(sessionId);
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
- process.stderr.write(`[QQ:${this.name}] ${logLabel} delivery failed (${e.code}): ${sanitizeLogText(e.message, 200)}, dropping ${buffer.length} chars\n`);
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
- if (this.pendingStreamDelete.has(sessionId)) {
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
- this.pendingStreamDelete.delete(sessionId);
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
- current.buffer = buffer;
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 = buffer + (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
- // #1: Identity guard — only delete if no new state replaced us
1061
- const current = this.streamState.get(sessionId);
1062
- if (!current || current === state) {
1063
- this.flushingSessions.delete(sessionId);
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
- const remaining = state?.buffer ?? (wasFlushed ? '' : fullText);
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
- await this.sendResponseMessage(chatId, remaining, sessionId, sourceLabel);
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.replyContextByMessageId.has(msgId)) {
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
- this.msgSeqMap.delete(context.msgId);
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
- this.msgSeqMap.delete(entry.msgId);
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
- if (dirty)
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
- // Thread #17's fix (setting isMentioned=true for non-@-bot keyword/all matches)
2386
- // was intentionally reverted — it violated principle #1.
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
- // message ID from replyMsgId before deleting the reply entry.
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.msgSeqMap.delete(context.msgId);
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);