@qwen-code/channel-dws 0.23.1-preview.0 → 0.23.1-preview.2

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.
@@ -12,7 +12,7 @@ const MAX_DOCUMENT_CONTEXT_CHARS = 12_000;
12
12
  const MAX_TODO_CONTEXT_CHARS = 12_000;
13
13
  const MAX_COMMENT_CHARS = 4_000;
14
14
  const MAX_PROCESSED_ITEMS = 5_000;
15
- const MAX_DIRECT_REPLAY_DISPATCHES = 16;
15
+ const MAX_REPLAY_CONVERSATIONS = 16;
16
16
  /**
17
17
  * How many times one inbound message may fail its turn before it is dropped.
18
18
  *
@@ -164,7 +164,8 @@ function isPendingMessage(value) {
164
164
  return false;
165
165
  }
166
166
  const pending = value;
167
- const sourceValid = pending.source?.kind === 'direct' ||
167
+ const sourceValid = pending.source?.kind === 'at' ||
168
+ pending.source?.kind === 'direct' ||
168
169
  pending.source?.kind === 'group-all' ||
169
170
  (pending.source?.kind === 'group' &&
170
171
  typeof pending.source.conversationId === 'string' &&
@@ -173,7 +174,8 @@ function isPendingMessage(value) {
173
174
  if (!sourceValid || typeof message !== 'object' || message === null) {
174
175
  return false;
175
176
  }
176
- return ((message.type === 'user_im_message_receive_o2o' ||
177
+ return ((message.type === 'user_im_message_receive_at' ||
178
+ message.type === 'user_im_message_receive_o2o' ||
177
179
  message.type === 'user_im_message_receive_o2o_all' ||
178
180
  message.type === 'user_im_message_receive_group' ||
179
181
  message.type === 'user_im_message_receive_group_all') &&
@@ -262,6 +264,13 @@ function sourceLabel(source) {
262
264
  return 'all group messages';
263
265
  return 'group messages';
264
266
  }
267
+ function sameImSource(left, right) {
268
+ if (left.kind !== right.kind)
269
+ return false;
270
+ if (left.kind !== 'group')
271
+ return true;
272
+ return right.kind === 'group' && left.conversationId === right.conversationId;
273
+ }
265
274
  function retryLimit(error) {
266
275
  if (!(error instanceof DwsEventProcessError))
267
276
  return 1;
@@ -326,13 +335,17 @@ export class DwsChannel extends PollingChannelBase {
326
335
  endReactionKeys = new Set();
327
336
  notifiedSenderPairingNotifications = new Set();
328
337
  processingMessages = new Map();
329
- queuedDirectMessages = new Map();
330
- // Replay-started direct dispatches only. The cap must not be consumed by
338
+ queuedMessages = new Map();
339
+ // Replay-started dispatches only. The cap must not be consumed by
331
340
  // live or followup traffic, whose queue entries outlive their turn.
332
- replayDirectDispatches = new Map();
333
- directConversationTails = new Map();
334
- directMessageStartResolvers = new Map();
341
+ replayDispatches = new Map();
342
+ attemptedPendingMessages = new Set();
343
+ conversationTails = new Map();
344
+ messageStartResolvers = new Map();
345
+ processingMessageGenerations = new Map();
335
346
  pendingMessageCapacityWaiters = new Set();
347
+ drainingImSubscriptions = new Set();
348
+ pendingImStartups = new Set();
336
349
  pollAbortController = new AbortController();
337
350
  lifecycleGeneration = 0;
338
351
  connectionStartedAt = 0;
@@ -467,7 +480,7 @@ export class DwsChannel extends PollingChannelBase {
467
480
  notificationCheckpoint: cursor.notificationCheckpoint,
468
481
  mentionCheckpoint: cursor.mentionCheckpoint,
469
482
  pendingDocumentNotifications: (cursor.pendingDocumentNotifications ?? []).slice(-MAX_PROCESSED_ITEMS),
470
- pendingMessages: (cursor.pendingMessages ?? []).slice(-MAX_PROCESSED_ITEMS),
483
+ pendingMessages: (cursor.pendingMessages ?? []).slice(),
471
484
  processedMessages: cursor.processedMessages.slice(-MAX_PROCESSED_ITEMS),
472
485
  imTargets: cursor.imTargets.slice(-MAX_IM_TARGETS),
473
486
  todosInitialized: cursor.todosInitialized ?? false,
@@ -480,9 +493,13 @@ export class DwsChannel extends PollingChannelBase {
480
493
  if (this.connected)
481
494
  return;
482
495
  const generation = ++this.lifecycleGeneration;
483
- this.connectionStartedAt = Date.now();
484
496
  this.pollAbortController.abort();
485
497
  this.pollAbortController = new AbortController();
498
+ await this.waitForDisconnect();
499
+ if (generation !== this.lifecycleGeneration) {
500
+ throw new Error('DWS channel connection was cancelled.');
501
+ }
502
+ this.connectionStartedAt = Date.now();
486
503
  await this.client.assertCompatible?.(this.pollAbortController.signal);
487
504
  if (generation !== this.lifecycleGeneration) {
488
505
  throw new Error('DWS channel connection was cancelled.');
@@ -542,7 +559,10 @@ export class DwsChannel extends PollingChannelBase {
542
559
  }
543
560
  this.connected = true;
544
561
  try {
545
- await Promise.all(this.imStates.map((state) => this.startImSourceWithRetry(state, generation)));
562
+ const startups = this.imStates.map((state) => this.startImSourceWithRetry(state, generation));
563
+ for (const startup of startups)
564
+ this.trackImStartup(startup);
565
+ await Promise.all(startups);
546
566
  if (generation !== this.lifecycleGeneration || !this.connected) {
547
567
  throw new Error('DWS channel connection was cancelled.');
548
568
  }
@@ -576,12 +596,13 @@ export class DwsChannel extends PollingChannelBase {
576
596
  this.cleanupReaction(key, 'disconnect reaction removal');
577
597
  }
578
598
  this.sessionReactionKeys.clear();
579
- this.queuedDirectMessages.clear();
580
- this.replayDirectDispatches.clear();
581
- for (const resolve of this.directMessageStartResolvers.values())
599
+ this.queuedMessages.clear();
600
+ this.replayDispatches.clear();
601
+ this.attemptedPendingMessages.clear();
602
+ for (const { resolve } of this.messageStartResolvers.values())
582
603
  resolve();
583
- this.directMessageStartResolvers.clear();
584
- this.directConversationTails.clear();
604
+ this.messageStartResolvers.clear();
605
+ this.conversationTails.clear();
585
606
  for (const resolve of this.pendingMessageCapacityWaiters)
586
607
  resolve();
587
608
  this.pendingMessageCapacityWaiters.clear();
@@ -590,11 +611,35 @@ export class DwsChannel extends PollingChannelBase {
590
611
  if (state.retryTimer)
591
612
  clearTimeout(state.retryTimer);
592
613
  state.retryTimer = undefined;
593
- state.subscription?.stop();
614
+ const subscription = state.subscription;
615
+ if (subscription) {
616
+ void this.drainImSubscription(subscription);
617
+ }
594
618
  state.subscription = undefined;
595
619
  state.restartAttempts = 0;
596
620
  }
597
621
  }
622
+ async waitForDisconnect() {
623
+ while (this.pendingImStartups.size > 0 ||
624
+ this.drainingImSubscriptions.size > 0) {
625
+ await Promise.all([
626
+ ...this.pendingImStartups,
627
+ ...this.drainingImSubscriptions,
628
+ ]);
629
+ }
630
+ }
631
+ trackImStartup(startup) {
632
+ const settled = startup.then(() => undefined, () => undefined);
633
+ this.pendingImStartups.add(settled);
634
+ void settled.then(() => this.pendingImStartups.delete(settled));
635
+ }
636
+ drainImSubscription(subscription) {
637
+ const drain = subscription.closed.then(() => undefined, () => undefined);
638
+ this.drainingImSubscriptions.add(drain);
639
+ void drain.then(() => this.drainingImSubscriptions.delete(drain));
640
+ subscription.stop();
641
+ return drain;
642
+ }
598
643
  supportsProactiveSend() {
599
644
  return true;
600
645
  }
@@ -777,6 +822,7 @@ export class DwsChannel extends PollingChannelBase {
777
822
  const signal = this.pollAbortController.signal;
778
823
  if (!this.connected || signal.aborted)
779
824
  return;
825
+ this.attemptedPendingMessages.clear();
780
826
  const endTime = Date.now();
781
827
  await this.replayPendingMessages(signal);
782
828
  if (signal.aborted || !this.connected)
@@ -796,8 +842,16 @@ export class DwsChannel extends PollingChannelBase {
796
842
  for (const message of mentions.messages) {
797
843
  if (signal.aborted || !this.connected)
798
844
  return;
799
- await this.receiveImMessage({ kind: 'at' }, message, true);
845
+ const key = messageKey(message);
846
+ if (this.cursor.processedMessages.includes(key))
847
+ continue;
848
+ if (this.hasPendingMessage(key))
849
+ continue;
850
+ this.enqueuePendingConversation(message.conversationId);
851
+ await this.admitHistoryMessage({ kind: 'at' }, message);
800
852
  }
853
+ if (signal.aborted || !this.connected)
854
+ return;
801
855
  if (mentions.nextCursor) {
802
856
  this.cursor.mentionCheckpoint = {
803
857
  ...mentionCheckpoint,
@@ -836,13 +890,14 @@ export class DwsChannel extends PollingChannelBase {
836
890
  // shared retry budget twice per poll.
837
891
  if (this.hasPendingMessage(key))
838
892
  continue;
839
- await this.receiveDirectMessage(message, true).admitted;
893
+ this.enqueuePendingConversation(message.conversationId);
894
+ await this.admitHistoryMessage({ kind: 'direct' }, message);
840
895
  }
841
896
  if (signal.aborted || !this.connected)
842
897
  return;
843
898
  if (this.notificationWatermarkPulledBack) {
844
899
  // R4-4: a stale direct message replayed while this window's
845
- // fetch was in flight, and `admitReceivedDirectMessage` pulled the
900
+ // fetch was in flight, and `admitReceivedMessage` pulled the
846
901
  // watermark back
847
902
  // to cover it. That replay was left UNMARKED on purpose for history
848
903
  // polling, so finishing this window normally would undo the rescue:
@@ -1005,19 +1060,21 @@ export class DwsChannel extends PollingChannelBase {
1005
1060
  this.saveCursor();
1006
1061
  }
1007
1062
  async startImSource(state, generation) {
1063
+ const connectionStartedAt = this.connectionStartedAt;
1008
1064
  const subscription = await this.client.subscribeToIm(state.source, (message) => {
1009
1065
  state.lastError = undefined;
1010
1066
  state.restartAttempts = 0;
1011
- return state.source.kind === 'direct'
1012
- ? this.receiveDirectMessage(message)
1013
- : this.receiveImMessage(state.source, message);
1067
+ return this.receiveImMessage(state.source, message, false, false, {
1068
+ generation,
1069
+ connectionStartedAt,
1070
+ });
1014
1071
  }, (error) => {
1015
1072
  if (error instanceof DwsEventProcessError)
1016
1073
  state.lastError = error;
1017
1074
  this.logImError(state.source, error);
1018
1075
  });
1019
1076
  if (!this.connected || generation !== this.lifecycleGeneration) {
1020
- subscription.stop();
1077
+ await this.drainImSubscription(subscription);
1021
1078
  return;
1022
1079
  }
1023
1080
  state.lastError = undefined;
@@ -1096,13 +1153,14 @@ export class DwsChannel extends PollingChannelBase {
1096
1153
  if (!this.connected)
1097
1154
  return;
1098
1155
  state.lastError = undefined;
1099
- void this.startImSource(state, this.lifecycleGeneration).catch((error) => {
1156
+ const startup = this.startImSource(state, this.lifecycleGeneration).catch((error) => {
1100
1157
  const resolvedError = error instanceof Error ? error : new Error(String(error));
1101
1158
  this.logImError(state.source, resolvedError);
1102
1159
  this.scheduleImRestart(state, resolvedError instanceof DwsEventProcessError
1103
1160
  ? resolvedError
1104
1161
  : undefined);
1105
1162
  });
1163
+ this.trackImStartup(startup);
1106
1164
  }, delay);
1107
1165
  state.retryTimer.unref?.();
1108
1166
  }
@@ -1114,30 +1172,8 @@ export class DwsChannel extends PollingChannelBase {
1114
1172
  this.config.groups['*']?.requireMention ??
1115
1173
  true);
1116
1174
  }
1117
- async receiveImMessage(source, message, fromHistory = false) {
1118
- if (!this.connected)
1119
- return;
1120
- if (this.markSelfMessageProcessed(message))
1121
- return;
1122
- if (this.isStaleLiveMessage(message, fromHistory) &&
1123
- message.eventTime !== undefined) {
1124
- this.markProcessedMessage(messageKey(message));
1125
- this.saveCursor();
1126
- return;
1127
- }
1128
- if (source.kind === 'group-all' &&
1129
- this.requiresMention(message.conversationId)) {
1130
- return;
1131
- }
1132
- if ((source.kind === 'group' || source.kind === 'group-all') &&
1133
- this.config.groupPolicy === 'pairing' &&
1134
- !this.groupGate.isGroupApproved(message.conversationId)) {
1135
- return;
1136
- }
1137
- await this.dispatchImMessage(source, message, messageKey(message));
1138
- }
1139
- receiveDirectMessage(message, fromHistory = false, reportFailure = fromHistory) {
1140
- const admission = this.admitReceivedDirectMessage(message, fromHistory, reportFailure);
1175
+ receiveImMessage(source, message, fromHistory = false, reportFailure = fromHistory, admissionContext) {
1176
+ const admission = this.admitReceivedMessage(source, message, fromHistory, reportFailure, admissionContext);
1141
1177
  const completed = admission.then(({ completion }) => completion);
1142
1178
  void completed.catch(() => undefined);
1143
1179
  return {
@@ -1145,68 +1181,145 @@ export class DwsChannel extends PollingChannelBase {
1145
1181
  completed,
1146
1182
  };
1147
1183
  }
1148
- async admitReceivedDirectMessage(message, fromHistory, reportFailure) {
1149
- if (!this.connected)
1150
- return { completion: Promise.resolve() };
1184
+ async admitReceivedMessage(source, message, fromHistory, reportFailure, admissionContext) {
1185
+ const isAmbientSource = source.kind === 'group' || source.kind === 'group-all';
1186
+ const isCurrentLifecycle = this.connected &&
1187
+ (admissionContext === undefined ||
1188
+ admissionContext.generation === this.lifecycleGeneration);
1189
+ if (!isCurrentLifecycle && (!admissionContext || !isAmbientSource)) {
1190
+ return { completion: Promise.resolve(), remembered: true };
1191
+ }
1151
1192
  const key = messageKey(message);
1152
1193
  if (this.markSelfMessageProcessed(message)) {
1153
- return { completion: Promise.resolve() };
1194
+ return { completion: Promise.resolve(), remembered: true };
1154
1195
  }
1155
- if (this.isStaleLiveMessage(message, fromHistory) &&
1196
+ if (this.isStaleLiveMessage(message, fromHistory, admissionContext?.connectionStartedAt) &&
1156
1197
  message.eventTime !== undefined) {
1157
- // A replayed direct message is left UNMARKED on purpose, for history
1158
- // polling to pick up. That only works if polling will ever look
1159
- // that far back: on a fresh cursor `notificationWatermark` starts at
1160
- // `connectionStartedAt` and the query window opens at `watermark − 5s`
1161
- // — exactly this branch's drop boundary — so every message this branch
1162
- // parks was strictly outside every window the watermark would ever
1163
- // produce, now and forever, since the window only moves forward. Pull
1164
- // the watermark back to cover what we just declined to handle.
1165
- this.cursor.notificationWatermark = Math.min(this.cursor.notificationWatermark ?? this.connectionStartedAt, message.eventTime);
1166
- this.notificationWatermarkPulledBack = true;
1167
- // R6-1: the flag alone only covers a replay that lands while a poll's
1168
- // fetch is in flight — `pollOnce` clears it before every fetch, so a
1169
- // pullback arriving BETWEEN two polls is reset before it is ever read.
1170
- // A persisted multi-page checkpoint would then resume its own window,
1171
- // whose `startTime` is already past this replay, and finish by setting
1172
- // the watermark to `checkpoint.endTime` — pulling it forward again over
1173
- // a replay no window will ever cover. Drop the checkpoint here too, so
1174
- // the pullback survives regardless of when it arrived.
1175
- this.cursor.notificationCheckpoint = undefined;
1176
- process.stderr.write(`[Channel:${this.name}] parked a stale direct message for history polling and pulled the watermark back to ${message.eventTime}: ${sanitizeLogText(message.messageId, 120)}\n`);
1198
+ if (source.kind === 'direct') {
1199
+ this.parkStaleDirectMessage(message);
1200
+ return { completion: Promise.resolve(), remembered: true };
1201
+ }
1202
+ this.markProcessedMessage(key);
1177
1203
  this.saveCursor();
1178
- return { completion: Promise.resolve() };
1204
+ return { completion: Promise.resolve(), remembered: true };
1205
+ }
1206
+ if (this.shouldFilterImMessage(source, message)) {
1207
+ if (source.kind === 'group' || source.kind === 'group-all') {
1208
+ const text = stripMessagePrefix(message.content.trim(), this.dwsMessagePrefix);
1209
+ if (!this.dwsMessagePrefix || text) {
1210
+ const envelope = this.createImEnvelope(source, message, text);
1211
+ if (this.groupGate.check(envelope, { createPairingRequest: false })
1212
+ .reason === 'mention_required' &&
1213
+ !this.queuedMessages.has(key) &&
1214
+ !this.cursor.processedMessages.includes(key) &&
1215
+ !this.hasPendingMessage(key)) {
1216
+ this.recordPendingGroupHistory(envelope);
1217
+ }
1218
+ }
1219
+ }
1220
+ this.removePersistedPendingMessageForSource(key, source);
1221
+ return { completion: Promise.resolve(), remembered: true };
1222
+ }
1223
+ if (!isCurrentLifecycle) {
1224
+ if (this.cursor.processedMessages.includes(key)) {
1225
+ this.removePersistedPendingMessage(key);
1226
+ return { completion: Promise.resolve(), remembered: true };
1227
+ }
1228
+ this.rememberDrainingPendingMessage(source, message);
1229
+ return { completion: Promise.resolve(), remembered: true };
1179
1230
  }
1180
- return this.admitDirectMessage({ kind: 'direct' }, message, key, reportFailure);
1231
+ return this.admitMessage(source, message, key, reportFailure);
1232
+ }
1233
+ async admitHistoryMessage(source, message) {
1234
+ const { completion, remembered } = await this.admitReceivedMessage(source, message, true, true);
1235
+ if (remembered) {
1236
+ void completion.catch(() => undefined);
1237
+ return;
1238
+ }
1239
+ await completion;
1240
+ }
1241
+ shouldFilterImMessage(source, message) {
1242
+ const groupConfig = this.config.groups[message.conversationId] ?? this.config.groups['*'];
1243
+ if ((source.kind === 'group' || source.kind === 'group-all') &&
1244
+ (groupConfig?.requireMention ?? true)) {
1245
+ return true;
1246
+ }
1247
+ return ((source.kind === 'group' || source.kind === 'group-all') &&
1248
+ this.config.groupPolicy === 'pairing' &&
1249
+ !this.groupGate.isGroupApproved(message.conversationId));
1250
+ }
1251
+ parkStaleDirectMessage(message) {
1252
+ // A replayed direct message is left UNMARKED on purpose, for history
1253
+ // polling to pick up. Pull the watermark back because the normal window
1254
+ // starts after this message and only moves forward.
1255
+ this.cursor.notificationWatermark = Math.min(this.cursor.notificationWatermark ?? this.connectionStartedAt, message.eventTime);
1256
+ this.notificationWatermarkPulledBack = true;
1257
+ // R6-1: also drop a persisted multi-page checkpoint so a replay that
1258
+ // arrives between polls cannot be skipped when that checkpoint resumes.
1259
+ this.cursor.notificationCheckpoint = undefined;
1260
+ process.stderr.write(`[Channel:${this.name}] parked a stale direct message for history polling and pulled the watermark back to ${message.eventTime}: ${sanitizeLogText(message.messageId, 120)}\n`);
1261
+ this.saveCursor();
1181
1262
  }
1182
1263
  markSelfMessageProcessed(message) {
1183
1264
  if (!this.isSelfMessage(message))
1184
1265
  return false;
1185
- this.markProcessedMessage(messageKey(message));
1266
+ const key = messageKey(message);
1267
+ this.markProcessedMessage(key);
1268
+ this.removePendingMessage(key);
1186
1269
  this.saveCursor();
1187
1270
  return true;
1188
1271
  }
1189
- isStaleLiveMessage(message, fromHistory) {
1272
+ isStaleLiveMessage(message, fromHistory, connectionStartedAt = this.connectionStartedAt) {
1190
1273
  return (!fromHistory &&
1191
1274
  message.eventTime !== undefined &&
1192
- message.eventTime < this.connectionStartedAt - 5_000);
1275
+ message.eventTime < connectionStartedAt - 5_000);
1193
1276
  }
1194
- async admitDirectMessage(source, message, key, reportFailure) {
1277
+ async admitMessage(source, message, key, reportFailure) {
1195
1278
  if (this.cursor.processedMessages.includes(key)) {
1196
1279
  this.removePersistedPendingMessage(key);
1197
- return { completion: Promise.resolve() };
1280
+ return { completion: Promise.resolve(), remembered: true };
1198
1281
  }
1282
+ if (!reportFailure) {
1283
+ this.enqueuePendingConversation(message.conversationId);
1284
+ }
1285
+ let remembered = true;
1199
1286
  if (!this.hasPendingMessage(key)) {
1200
- if (!(await this.rememberPendingMessage(source, message))) {
1201
- return { completion: Promise.resolve() };
1287
+ const dispatchUnparkedAtCapacity = (source.kind === 'at' || source.kind === 'direct') &&
1288
+ this.connected &&
1289
+ (this.cursor.pendingMessages?.length ?? 0) >= MAX_PROCESSED_ITEMS;
1290
+ remembered = dispatchUnparkedAtCapacity
1291
+ ? false
1292
+ : source.kind === 'group' || source.kind === 'group-all'
1293
+ ? await this.rememberPendingMessageWhenAvailable(source, message)
1294
+ : await this.rememberPendingMessage(source, message);
1295
+ if (!remembered) {
1296
+ if (!dispatchUnparkedAtCapacity) {
1297
+ return { completion: Promise.resolve(), remembered };
1298
+ }
1202
1299
  }
1203
1300
  }
1204
1301
  return {
1205
- completion: this.scheduleDirectMessage(source, message, key, reportFailure),
1302
+ completion: this.scheduleMessage(source, message, key, reportFailure),
1303
+ remembered,
1206
1304
  };
1207
1305
  }
1208
- scheduleDirectMessage(source, message, key, reportFailure) {
1209
- const queued = this.queuedDirectMessages.get(key);
1306
+ enqueuePendingConversation(conversationId) {
1307
+ for (const pending of [...(this.cursor.pendingMessages ?? [])]) {
1308
+ if (pending.message.conversationId !== conversationId)
1309
+ continue;
1310
+ const key = messageKey(pending.message);
1311
+ if (this.attemptedPendingMessages.has(key))
1312
+ continue;
1313
+ if (this.queuedMessages.has(key))
1314
+ continue;
1315
+ const dispatch = this.receiveImMessage(pending.source, pending.message, true, true);
1316
+ void dispatch.admitted.catch((error) => {
1317
+ process.stderr.write(`[Channel:${this.name}] pending DWS message remains degraded: ${sanitizeLogText(error instanceof Error ? error.message : String(error), 300)}\n`);
1318
+ });
1319
+ }
1320
+ }
1321
+ scheduleMessage(source, message, key, reportFailure) {
1322
+ const queued = this.queuedMessages.get(key);
1210
1323
  if (queued)
1211
1324
  return queued;
1212
1325
  if (this.cursor.processedMessages.includes(key)) {
@@ -1218,11 +1331,11 @@ export class DwsChannel extends PollingChannelBase {
1218
1331
  try {
1219
1332
  if (!this.connected || generation !== this.lifecycleGeneration)
1220
1333
  return;
1221
- await this.dispatchImMessage(source, message, key);
1334
+ await this.dispatchImMessage(source, message, key, generation);
1222
1335
  }
1223
1336
  finally {
1224
- if (this.queuedDirectMessages.get(key) === task) {
1225
- this.queuedDirectMessages.delete(key);
1337
+ if (this.queuedMessages.get(key) === task) {
1338
+ this.queuedMessages.delete(key);
1226
1339
  }
1227
1340
  }
1228
1341
  };
@@ -1230,24 +1343,30 @@ export class DwsChannel extends PollingChannelBase {
1230
1343
  const started = new Promise((resolve) => {
1231
1344
  resolveStarted = resolve;
1232
1345
  });
1233
- const previous = this.directConversationTails.get(message.conversationId);
1346
+ const previous = this.conversationTails.get(message.conversationId);
1347
+ const groupConfig = source.kind === 'direct'
1348
+ ? undefined
1349
+ : (this.config.groups[message.conversationId] ??
1350
+ this.config.groups['*']);
1234
1351
  // Live turns only need FIFO through ChannelBase registration; replay and
1235
1352
  // followup turns must wait for the prior turn to finish.
1236
- const predecessor = reportFailure || this.config.dispatchMode === 'followup'
1353
+ const predecessor = reportFailure ||
1354
+ (groupConfig?.dispatchMode ?? this.config.dispatchMode) === 'followup'
1237
1355
  ? previous?.completed
1238
1356
  : previous?.started;
1239
1357
  const task = predecessor
1240
1358
  ? predecessor.catch(() => undefined).then(dispatch)
1241
1359
  : Promise.resolve().then(dispatch);
1242
1360
  const tail = { started, completed: task };
1243
- this.queuedDirectMessages.set(key, task);
1244
- this.directMessageStartResolvers.set(key, resolveStarted);
1245
- this.directConversationTails.set(message.conversationId, tail);
1361
+ this.queuedMessages.set(key, task);
1362
+ const startResolver = { generation, resolve: resolveStarted };
1363
+ this.messageStartResolvers.set(key, startResolver);
1364
+ this.conversationTails.set(message.conversationId, tail);
1246
1365
  void task
1247
1366
  .finally(() => {
1248
- this.releaseDirectMessageStart(message.conversationId, message.messageId);
1249
- if (this.directConversationTails.get(message.conversationId) === tail) {
1250
- this.directConversationTails.delete(message.conversationId);
1367
+ this.releaseMessageStart(message.conversationId, message.messageId, startResolver);
1368
+ if (this.conversationTails.get(message.conversationId) === tail) {
1369
+ this.conversationTails.delete(message.conversationId);
1251
1370
  }
1252
1371
  })
1253
1372
  .catch(() => undefined);
@@ -1258,25 +1377,33 @@ export class DwsChannel extends PollingChannelBase {
1258
1377
  }
1259
1378
  return task;
1260
1379
  }
1261
- async dispatchImMessage(source, message, key) {
1380
+ async dispatchImMessage(source, message, key, generation) {
1262
1381
  let waitedOnInFlight = false;
1382
+ let waitedOnCurrentGeneration = false;
1263
1383
  let inFlightError;
1264
1384
  while (true) {
1265
1385
  const existing = this.processingMessages.get(key);
1266
1386
  if (!existing)
1267
1387
  break;
1268
1388
  waitedOnInFlight = true;
1389
+ if (this.processingMessageGenerations.get(key) === generation) {
1390
+ waitedOnCurrentGeneration = true;
1391
+ }
1269
1392
  inFlightError = await existing.then(() => undefined, (error) => error);
1270
1393
  }
1394
+ if (!this.connected || generation !== this.lifecycleGeneration)
1395
+ return;
1271
1396
  if (this.cursor.processedMessages.includes(key))
1272
1397
  return;
1273
- // The in-flight turn failed and parked the message while this caller
1274
- // waited; replay already re-drives parked entries every poll, so a new
1275
- // turn here would spend the retry budget twice in one poll. Rethrow
1276
- // instead of returning so `replayPendingMessages` keeps the entry — a
1277
- // normal return there deletes it.
1278
- if (waitedOnInFlight && this.hasPendingMessage(key))
1398
+ // A duplicate in the same lifecycle must not spend the retry budget twice
1399
+ // in one poll. A replay from a later reconnect generation is different:
1400
+ // it owns the persisted retry and must run before newer conversation work.
1401
+ if (waitedOnInFlight &&
1402
+ waitedOnCurrentGeneration &&
1403
+ this.hasPendingMessage(key)) {
1279
1404
  throw inFlightError;
1405
+ }
1406
+ this.processingMessageGenerations.set(key, generation);
1280
1407
  const task = this.processImMessage(source, message, key);
1281
1408
  this.processingMessages.set(key, task);
1282
1409
  try {
@@ -1289,6 +1416,7 @@ export class DwsChannel extends PollingChannelBase {
1289
1416
  finally {
1290
1417
  if (this.processingMessages.get(key) === task) {
1291
1418
  this.processingMessages.delete(key);
1419
+ this.processingMessageGenerations.delete(key);
1292
1420
  }
1293
1421
  }
1294
1422
  }
@@ -1339,8 +1467,32 @@ export class DwsChannel extends PollingChannelBase {
1339
1467
  await this.processDocumentNotification(message, key, documentNotification);
1340
1468
  return;
1341
1469
  }
1342
- const isGroup = source.kind !== 'direct';
1343
- const envelope = {
1470
+ const envelope = this.createImEnvelope(source, message, text);
1471
+ this.rememberInboundReactionTarget(message.conversationId, message.messageId);
1472
+ try {
1473
+ await this.handleInbound(envelope);
1474
+ }
1475
+ catch (error) {
1476
+ this.attemptedPendingMessages.add(key);
1477
+ // Under budget the throw propagates so redelivery observes the failure
1478
+ // while persisted replay can retry it. Once the budget is spent the
1479
+ // message is marked processed and the error is swallowed, which is the
1480
+ // only thing that lets the watermark move past it.
1481
+ if (this.recordInboundFailure(key, error, () => {
1482
+ this.markProcessedMessage(key);
1483
+ this.removePendingMessage(key);
1484
+ })) {
1485
+ throw error;
1486
+ }
1487
+ return;
1488
+ }
1489
+ this.clearInboundFailure(key);
1490
+ this.removePendingMessage(key);
1491
+ this.markProcessedMessage(key);
1492
+ this.saveCursor();
1493
+ }
1494
+ createImEnvelope(source, message, text = message.content.trim()) {
1495
+ return {
1344
1496
  channelName: this.name,
1345
1497
  senderId: message.senderId,
1346
1498
  senderName: message.senderName,
@@ -1352,7 +1504,7 @@ export class DwsChannel extends PollingChannelBase {
1352
1504
  ...(message.referencedText
1353
1505
  ? { referencedText: message.referencedText }
1354
1506
  : {}),
1355
- isGroup,
1507
+ isGroup: source.kind !== 'direct',
1356
1508
  isMentioned: source.kind === 'at',
1357
1509
  isReplyToBot: false,
1358
1510
  metadata: [
@@ -1361,34 +1513,6 @@ export class DwsChannel extends PollingChannelBase {
1361
1513
  `DWS event ID: ${message.eventId}`,
1362
1514
  ].join('\n'),
1363
1515
  };
1364
- this.rememberInboundReactionTarget(message.conversationId, message.messageId);
1365
- try {
1366
- await this.handleInbound(envelope);
1367
- }
1368
- catch (error) {
1369
- if (source.kind !== 'at') {
1370
- // R12-1: park failed direct messages next to ambient group ones.
1371
- // Direct-message history may be unavailable, and ambient group
1372
- // messages have no history fallback. `at` messages need no parking:
1373
- // the pinned mention checkpoint re-fetches them.
1374
- await this.rememberFailedMessage(source, message);
1375
- }
1376
- // Under budget the throw propagates exactly as before, so redelivery
1377
- // and concurrent-duplicate retry keep their contracts. Once the budget
1378
- // is spent the message is marked processed and the error is swallowed,
1379
- // which is the only thing that lets the watermark move past it.
1380
- if (this.recordInboundFailure(key, error, () => {
1381
- this.markProcessedMessage(key);
1382
- this.removePendingMessage(key);
1383
- })) {
1384
- throw error;
1385
- }
1386
- return;
1387
- }
1388
- this.clearInboundFailure(key);
1389
- this.removePendingMessage(key);
1390
- this.markProcessedMessage(key);
1391
- this.saveCursor();
1392
1516
  }
1393
1517
  /**
1394
1518
  * Account one failed inbound turn, and drop the message once its budget is
@@ -1453,15 +1577,18 @@ export class DwsChannel extends PollingChannelBase {
1453
1577
  this.saveCursor();
1454
1578
  }
1455
1579
  catch (error) {
1580
+ if (source.kind === 'group' || source.kind === 'group-all') {
1581
+ return true;
1582
+ }
1456
1583
  this.removePendingMessage(key);
1457
1584
  throw error;
1458
1585
  }
1459
1586
  return true;
1460
1587
  }
1461
- async rememberFailedMessage(source, message) {
1588
+ async rememberPendingMessageWhenAvailable(source, message) {
1462
1589
  const key = messageKey(message);
1463
1590
  if (this.hasPendingMessage(key))
1464
- return;
1591
+ return true;
1465
1592
  const generation = this.lifecycleGeneration;
1466
1593
  while (this.connected &&
1467
1594
  generation === this.lifecycleGeneration &&
@@ -1470,6 +1597,19 @@ export class DwsChannel extends PollingChannelBase {
1470
1597
  this.pendingMessageCapacityWaiters.add(resolve);
1471
1598
  });
1472
1599
  }
1600
+ if (this.hasPendingMessage(key))
1601
+ return true;
1602
+ if (!this.connected || generation !== this.lifecycleGeneration) {
1603
+ // Ambient group streams have no history fallback. A disconnect wakes
1604
+ // capacity waiters, so persist their already-read message before the
1605
+ // event process exits even when that temporarily exceeds the soft cap.
1606
+ this.rememberDrainingPendingMessage(source, message);
1607
+ return true;
1608
+ }
1609
+ return this.rememberPendingMessage(source, message);
1610
+ }
1611
+ rememberDrainingPendingMessage(source, message) {
1612
+ const key = messageKey(message);
1473
1613
  if (this.hasPendingMessage(key))
1474
1614
  return;
1475
1615
  const pending = this.cursor.pendingMessages ?? [];
@@ -1478,14 +1618,20 @@ export class DwsChannel extends PollingChannelBase {
1478
1618
  try {
1479
1619
  this.saveCursor();
1480
1620
  }
1481
- catch {
1482
- return;
1621
+ catch (error) {
1622
+ process.stderr.write(`[Channel:${this.name}] could not persist a draining DWS message; keeping it in memory: ${sanitizeLogText(error instanceof Error ? error.message : String(error), 300)}\n`);
1483
1623
  }
1484
1624
  }
1485
1625
  removePersistedPendingMessage(key) {
1486
1626
  if (this.removePendingMessage(key))
1487
1627
  this.saveCursor();
1488
1628
  }
1629
+ removePersistedPendingMessageForSource(key, source) {
1630
+ const ownsPendingMessage = (this.cursor.pendingMessages ?? []).some((pending) => messageKey(pending.message) === key &&
1631
+ sameImSource(pending.source, source));
1632
+ if (ownsPendingMessage)
1633
+ this.removePersistedPendingMessage(key);
1634
+ }
1489
1635
  removePendingMessage(key) {
1490
1636
  const pending = this.cursor.pendingMessages ?? [];
1491
1637
  const remaining = pending.filter((item) => messageKey(item.message) !== key);
@@ -1611,37 +1757,71 @@ export class DwsChannel extends PollingChannelBase {
1611
1757
  }
1612
1758
  }
1613
1759
  async replayPendingMessages(signal) {
1760
+ const activeBySource = new Map();
1761
+ const activeConversations = new Set();
1762
+ for (const { sourceKind, conversationId, } of this.replayDispatches.values()) {
1763
+ if (!activeConversations.has(conversationId)) {
1764
+ activeBySource.set(sourceKind, (activeBySource.get(sourceKind) ?? 0) + 1);
1765
+ activeConversations.add(conversationId);
1766
+ }
1767
+ }
1768
+ const selectedConversations = new Set();
1769
+ const blockedConversations = new Set();
1614
1770
  for (const pending of [...(this.cursor.pendingMessages ?? [])]) {
1615
1771
  if (signal.aborted || !this.connected)
1616
1772
  return;
1617
1773
  const key = messageKey(pending.message);
1618
- if (pending.source.kind === 'direct') {
1619
- if (this.queuedDirectMessages.has(key))
1620
- continue;
1621
- if (this.replayDirectDispatches.size >= MAX_DIRECT_REPLAY_DISPATCHES) {
1622
- continue;
1623
- }
1624
- const dispatched = this.scheduleDirectMessage(pending.source, pending.message, key, true);
1625
- this.replayDirectDispatches.set(key, dispatched);
1626
- void dispatched
1627
- .finally(() => {
1628
- if (this.replayDirectDispatches.get(key) === dispatched) {
1629
- this.replayDirectDispatches.delete(key);
1774
+ if (this.queuedMessages.has(key))
1775
+ continue;
1776
+ if (this.markSelfMessageProcessed(pending.message))
1777
+ continue;
1778
+ if (this.shouldFilterImMessage(pending.source, pending.message)) {
1779
+ if (pending.source.kind === 'group' ||
1780
+ pending.source.kind === 'group-all') {
1781
+ const text = stripMessagePrefix(pending.message.content.trim(), this.dwsMessagePrefix);
1782
+ if (!this.dwsMessagePrefix || text) {
1783
+ const envelope = this.createImEnvelope(pending.source, pending.message, text);
1784
+ if (this.groupGate.check(envelope, { createPairingRequest: false })
1785
+ .reason === 'mention_required') {
1786
+ this.recordPendingGroupHistory(envelope);
1787
+ }
1630
1788
  }
1631
- })
1632
- .catch(() => undefined);
1789
+ }
1790
+ this.removePersistedPendingMessageForSource(key, pending.source);
1633
1791
  continue;
1634
1792
  }
1635
- try {
1636
- await this.receiveImMessage(pending.source, pending.message, true);
1637
- this.removePendingMessage(key);
1638
- this.saveCursor();
1793
+ if (activeConversations.has(pending.message.conversationId))
1794
+ continue;
1795
+ if (blockedConversations.has(pending.message.conversationId))
1796
+ continue;
1797
+ if (!selectedConversations.has(pending.message.conversationId)) {
1798
+ const activeForSource = activeBySource.get(pending.source.kind) ?? 0;
1799
+ if (activeForSource >= MAX_REPLAY_CONVERSATIONS) {
1800
+ blockedConversations.add(pending.message.conversationId);
1801
+ continue;
1802
+ }
1803
+ activeBySource.set(pending.source.kind, activeForSource + 1);
1804
+ selectedConversations.add(pending.message.conversationId);
1639
1805
  }
1640
- catch (error) {
1806
+ const dispatch = this.receiveImMessage(pending.source, pending.message, true, true);
1807
+ const completed = dispatch.completed;
1808
+ const replay = {
1809
+ sourceKind: pending.source.kind,
1810
+ conversationId: pending.message.conversationId,
1811
+ };
1812
+ this.replayDispatches.set(key, replay);
1813
+ void dispatch.admitted.catch((error) => {
1641
1814
  if (signal.aborted || !this.connected)
1642
1815
  return;
1643
1816
  process.stderr.write(`[Channel:${this.name}] pending DWS message remains degraded: ${sanitizeLogText(error instanceof Error ? error.message : String(error), 300)}\n`);
1644
- }
1817
+ });
1818
+ void completed
1819
+ .finally(() => {
1820
+ if (this.replayDispatches.get(key) === replay) {
1821
+ this.replayDispatches.delete(key);
1822
+ }
1823
+ })
1824
+ .catch(() => undefined);
1645
1825
  }
1646
1826
  }
1647
1827
  async replayPendingDocumentNotifications(signal) {
@@ -1738,13 +1918,17 @@ export class DwsChannel extends PollingChannelBase {
1738
1918
  reactionKey(conversationId, messageId) {
1739
1919
  return `${conversationId}\0${messageId}`;
1740
1920
  }
1741
- releaseDirectMessageStart(conversationId, messageId) {
1921
+ releaseMessageStart(conversationId, messageId, expected) {
1742
1922
  const key = `${conversationId}\0${messageId}`;
1743
- const resolve = this.directMessageStartResolvers.get(key);
1744
- if (!resolve)
1923
+ const resolver = this.messageStartResolvers.get(key);
1924
+ if (!resolver || (expected && resolver !== expected))
1745
1925
  return;
1746
- this.directMessageStartResolvers.delete(key);
1747
- resolve();
1926
+ if (!expected &&
1927
+ this.processingMessageGenerations.get(key) !== resolver.generation) {
1928
+ return;
1929
+ }
1930
+ this.messageStartResolvers.delete(key);
1931
+ resolver.resolve();
1748
1932
  }
1749
1933
  rememberInboundReactionTarget(chatId, messageId, conversationId = chatId) {
1750
1934
  const key = this.reactionKey(chatId, messageId);
@@ -1904,7 +2088,7 @@ export class DwsChannel extends PollingChannelBase {
1904
2088
  onTaskLifecycle(event) {
1905
2089
  if (event.type === 'started') {
1906
2090
  if (event.messageId) {
1907
- this.releaseDirectMessageStart(event.chatId, event.messageId);
2091
+ this.releaseMessageStart(event.chatId, event.messageId);
1908
2092
  }
1909
2093
  this.startReaction(event.chatId, event.messageId, event.sessionId);
1910
2094
  return;