evolcore 0.0.12 → 0.0.13

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.
Files changed (104) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/ec-safe-output.js +26 -7
  3. package/dist/agents/claude-runner.js +53 -7
  4. package/dist/agents/codex-app-server-client.js +53 -5
  5. package/dist/agents/codex-runner.js +104 -59
  6. package/dist/agents/ecagent-runner.js +4 -1
  7. package/dist/aun/aid/control-aid.js +36 -13
  8. package/dist/aun/aid/store.js +13 -7
  9. package/dist/aun/group-fs-download.js +56 -0
  10. package/dist/aun/group-identity.js +1 -2
  11. package/dist/aun/msg/group.js +11 -33
  12. package/dist/aun/msg/index.js +1 -2
  13. package/dist/aun/msg/managed-operation.js +116 -55
  14. package/dist/aun/msg/upload.js +16 -1
  15. package/dist/aun/outbox.js +31 -2
  16. package/dist/aun/rpc/client.js +66 -0
  17. package/dist/aun/rpc/index.js +1 -2
  18. package/dist/aun/storage/{upload.js → client.js} +11 -2
  19. package/dist/aun/storage/index.js +1 -2
  20. package/dist/channels/aun.js +371 -136
  21. package/dist/cli/agent.js +9 -96
  22. package/dist/cli/cli-argv.js +10 -0
  23. package/dist/cli/config.js +9 -17
  24. package/dist/cli/ctl-command.js +1 -1
  25. package/dist/cli/daemon-commands.js +39 -24
  26. package/dist/cli/fs-command.js +19 -10
  27. package/dist/cli/init.js +77 -8
  28. package/dist/cli/queue-command.js +42 -0
  29. package/dist/cli/restart-monitor.js +3 -14
  30. package/dist/cli/trigger-command.js +7 -0
  31. package/dist/config/access-policy.js +1 -2
  32. package/dist/config/builtin-roles.js +5 -0
  33. package/dist/config/config-manager.js +21 -1
  34. package/dist/config/peer-role-resolver.js +6 -2
  35. package/dist/config/role-migration-startup.js +53 -0
  36. package/dist/config/role-store.js +1 -1
  37. package/dist/core/auth/agent-delegation.js +1 -1
  38. package/dist/core/auth/auth-gateway.js +29 -1
  39. package/dist/core/auth/authorization-audit.js +15 -0
  40. package/dist/core/auth/operation-authorizer.js +1 -0
  41. package/dist/core/auth/operation-catalog.js +9 -0
  42. package/dist/core/bootstrap-messages.js +9 -2
  43. package/dist/core/bootstrap-service.js +1 -0
  44. package/dist/core/command/command-handler.js +198 -51
  45. package/dist/core/command/menu-catalog.js +4 -0
  46. package/dist/core/command/menu-handler.js +85 -18
  47. package/dist/core/command/menu-protocol.js +23 -2
  48. package/dist/core/command/slash-handler.js +58 -10
  49. package/dist/core/event-catalog.js +6 -0
  50. package/dist/core/handoff/runtime.js +12 -1
  51. package/dist/core/handoff/store.js +5 -1
  52. package/dist/core/interaction-router.js +10 -0
  53. package/dist/core/message/im-renderer.js +19 -0
  54. package/dist/core/message/message-bridge.js +63 -11
  55. package/dist/core/message/message-log.js +2 -0
  56. package/dist/core/message/message-queue.js +267 -39
  57. package/dist/core/message/message-utils.js +81 -4
  58. package/dist/core/message/response-engine.js +107 -41
  59. package/dist/core/message/stream-debouncer.js +10 -2
  60. package/dist/core/permission/approval-gateway.js +1 -0
  61. package/dist/core/permission/ec-command-parser.js +93 -14
  62. package/dist/core/permission/index.js +1 -1
  63. package/dist/core/permission/mode.js +15 -0
  64. package/dist/core/permission/readonly-shell-query.js +148 -8
  65. package/dist/core/permission/sandbox-runtime.js +21 -0
  66. package/dist/core/permission/tool-policy.js +249 -27
  67. package/dist/core/protected-paths.js +107 -2
  68. package/dist/core/session/session-manager.js +21 -2
  69. package/dist/core/session/session-mapper.js +20 -1
  70. package/dist/eck/detect.js +18 -1
  71. package/dist/eck/group-rules-sync.js +15 -44
  72. package/dist/index.js +161 -86
  73. package/dist/ipc.js +16 -3
  74. package/dist/paths.js +3 -0
  75. package/dist/trigger/feedback.js +64 -11
  76. package/dist/trigger/manager.js +21 -1
  77. package/dist/trigger/parser.js +14 -2
  78. package/dist/trigger/scheduler.js +1 -0
  79. package/dist/trigger/validation.js +67 -6
  80. package/dist/utils/ecweb-supervisor.js +332 -0
  81. package/dist/utils/error-utils.js +10 -0
  82. package/kits/docs/evolcore/trigger.md +17 -1
  83. package/kits/migrations/migrate-contact-book-v2.mjs +1 -2
  84. package/kits/schemas/role-config.schema.1.json +1 -0
  85. package/kits/templates/roles/admin.json +5 -0
  86. package/kits/templates/roles/member.json +7 -1
  87. package/kits/templates/roles/visitor.json +2 -0
  88. package/package.json +1 -1
  89. package/dist/aun/msg/payload-type.js +0 -27
  90. package/dist/aun/rpc/caller.js +0 -42
  91. package/dist/aun/rpc/connection.js +0 -25
  92. package/dist/aun/storage/manage.js +0 -10
  93. package/dist/config/access-policy-domain.js +0 -18
  94. package/dist/config/config-batch-get.js +0 -11
  95. package/dist/config/owner-policy.js +0 -4
  96. package/dist/config/role-config-v4-startup.js +0 -32
  97. package/dist/config/role-config-v5-startup.js +0 -27
  98. package/dist/core/auth/trigger-authorization.js +0 -15
  99. package/dist/core/interaction-registration.js +0 -10
  100. package/dist/core/permission/execution-sandbox.js +0 -16
  101. package/dist/core/relation/peer-key.js +0 -1
  102. package/dist/core/session/session-key.js +0 -24
  103. package/dist/eck/baseagent-caps.js +0 -18
  104. package/dist/eck/rules-loader.js +0 -28
@@ -21,6 +21,9 @@ export class MessageQueue {
21
21
  interceptors = new Map();
22
22
  mutedAgents = new Set(); // 禁言的 agent:消息照常入队,但不取出给大模型
23
23
  persistencePath;
24
+ dedupPath;
25
+ dedupTtlMs;
26
+ durableMessageIds = new Map();
24
27
  activeBatches = new Map();
25
28
  processingStartTime = new Map(); // queueKey → 处理开始时间戳
26
29
  queueKeyToSessionKey = new Map(); // queueKey → sessionKey(human-readable 格式)
@@ -32,6 +35,10 @@ export class MessageQueue {
32
35
  constructor(handler, options) {
33
36
  this.handler = handler;
34
37
  this.persistencePath = options?.persistencePath;
38
+ this.dedupPath = options?.dedupPath
39
+ ?? (this.persistencePath ? `${this.persistencePath}.dedup.json` : undefined);
40
+ this.dedupTtlMs = Math.max(60_000, options?.dedupTtlMs ?? 7 * 24 * 60 * 60 * 1000);
41
+ this.restoreDurableDedup();
35
42
  }
36
43
  /** 暴露逻辑队列桥接,供响应模式系统注入队列工厂 */
37
44
  getLogicalQueueBridge() {
@@ -108,16 +115,22 @@ export class MessageQueue {
108
115
  /**
109
116
  * 检查消息是否应该处理(去重)
110
117
  *
111
- * 去重 key = `${sessionKey}:${messageId}`,而非裸 messageId。
118
+ * 去重 key = `${selfAID}:${sessionKey}:${messageId}`,而非裸 messageId。
112
119
  * MessageQueue 是进程级单例,被所有 evolagent 共享。AUN 群广播时同一条群消息
113
120
  * 会投递给群里每个 evolagent,它们 messageId 相同但 session 不同,必须各处理一次。
114
- * 裸 messageId 去重会让先入队的 agent 吞掉其他 agent 的消息。
121
+ * 裸 messageId 或仅按 session 去重都会让先入队的 agent 吞掉其他 agent 的消息。
115
122
  */
116
- shouldProcess(sessionKey, message) {
123
+ dedupKey(sessionKey, messageId, selfAID) {
124
+ return `${selfAID ?? ''}:${sessionKey}:${messageId}`;
125
+ }
126
+ shouldProcess(sessionKey, message, selfAID) {
117
127
  if (!message.messageId)
118
128
  return true; // 无 ID 的消息不去重
119
- const dedupKey = `${sessionKey}:${message.messageId}`;
120
- if (this.recentMessageIds.has(dedupKey) || this.hasEnqueuedMessage(sessionKey, message.messageId)) {
129
+ const scope = selfAID ?? message.selfAID;
130
+ const dedupKey = this.dedupKey(sessionKey, message.messageId, scope);
131
+ if (this.recentMessageIds.has(dedupKey)
132
+ || this.durableMessageIds.has(dedupKey)
133
+ || this.hasEnqueuedMessage(sessionKey, message.messageId, scope)) {
121
134
  logger.debug(`[Queue] Duplicate message ${dedupKey}, skipping`);
122
135
  return false;
123
136
  }
@@ -125,17 +138,69 @@ export class MessageQueue {
125
138
  setTimeout(() => this.recentMessageIds.delete(dedupKey), this.DEDUP_WINDOW);
126
139
  return true;
127
140
  }
128
- hasEnqueuedMessage(sessionKey, messageId) {
141
+ rememberDurably(sessionKey, message, selfAID) {
142
+ if (!this.dedupPath || !message.messageId)
143
+ return;
144
+ this.durableMessageIds.set(this.dedupKey(sessionKey, message.messageId, selfAID ?? message.selfAID), Date.now());
145
+ this.persistDurableDedup();
146
+ }
147
+ restoreDurableDedup() {
148
+ if (!this.dedupPath || !fs.existsSync(this.dedupPath))
149
+ return;
150
+ const now = Date.now();
151
+ try {
152
+ const raw = JSON.parse(fs.readFileSync(this.dedupPath, 'utf8'));
153
+ const entries = Array.isArray(raw)
154
+ ? raw
155
+ : raw && typeof raw === 'object' && Array.isArray(raw.entries)
156
+ ? raw.entries
157
+ : [];
158
+ let changed = false;
159
+ for (const entry of entries) {
160
+ const key = typeof entry?.key === 'string' ? entry.key : undefined;
161
+ const seenAt = typeof entry?.seenAt === 'number' ? entry.seenAt : undefined;
162
+ if (!key || seenAt === undefined || !Number.isFinite(seenAt) || now - seenAt > this.dedupTtlMs) {
163
+ changed = true;
164
+ continue;
165
+ }
166
+ this.durableMessageIds.set(key, seenAt);
167
+ }
168
+ if (changed)
169
+ this.persistDurableDedup();
170
+ }
171
+ catch (error) {
172
+ logger.warn(`[Queue] Failed to restore durable dedup ledger: ${error instanceof Error ? error.message : String(error)}`);
173
+ }
174
+ }
175
+ persistDurableDedup() {
176
+ if (!this.dedupPath)
177
+ return;
178
+ const now = Date.now();
179
+ for (const [key, seenAt] of this.durableMessageIds) {
180
+ if (now - seenAt > this.dedupTtlMs)
181
+ this.durableMessageIds.delete(key);
182
+ }
183
+ try {
184
+ fs.mkdirSync(path.dirname(this.dedupPath), { recursive: true });
185
+ const tmp = `${this.dedupPath}.tmp-${process.pid}-${Date.now()}`;
186
+ fs.writeFileSync(tmp, JSON.stringify({ version: 1, updatedAt: now, entries: [...this.durableMessageIds].map(([key, seenAt]) => ({ key, seenAt })) }));
187
+ fs.renameSync(tmp, this.dedupPath);
188
+ }
189
+ catch (error) {
190
+ logger.warn(`[Queue] Failed to persist durable dedup ledger: ${error instanceof Error ? error.message : String(error)}`);
191
+ }
192
+ }
193
+ hasEnqueuedMessage(sessionKey, messageId, selfAID) {
129
194
  for (const [queueKey, queue] of this.queues) {
130
195
  if (!this.matchesSession(queueKey, sessionKey))
131
196
  continue;
132
- if (queue.some(item => this.messageIdsFor(item).includes(messageId)))
197
+ if (queue.some(item => this.partsOf(item).some(part => this.partMatchesMessage(part, messageId, selfAID, queueKey))))
133
198
  return true;
134
199
  }
135
200
  for (const [queueKey, active] of this.activeBatches) {
136
201
  if (!this.matchesSession(queueKey, sessionKey))
137
202
  continue;
138
- if (this.messageIdsFor(active).includes(messageId))
203
+ if (this.partsOf(active).some(part => this.partMatchesMessage(part, messageId, selfAID, queueKey)))
139
204
  return true;
140
205
  }
141
206
  return false;
@@ -143,7 +208,7 @@ export class MessageQueue {
143
208
  forgetProcessed(sessionKey, message) {
144
209
  if (!message.messageId)
145
210
  return;
146
- this.recentMessageIds.delete(`${sessionKey}:${message.messageId}`);
211
+ this.recentMessageIds.delete(this.dedupKey(sessionKey, message.messageId, message.selfAID));
147
212
  }
148
213
  /**
149
214
  * 检查队列 key 是否属于指定 sessionKey
@@ -160,6 +225,10 @@ export class MessageQueue {
160
225
  const prefix = selfAID ? `${selfAID}::` : '';
161
226
  return `${prefix}${sessionKey}::${normalized}`;
162
227
  }
228
+ selfAIDFromQueueKey(queueKey) {
229
+ const parts = queueKey.split('::');
230
+ return parts.length >= 3 ? parts[0] : undefined;
231
+ }
163
232
  sessionKeyFromQueueKey(queueKey) {
164
233
  // 格式:selfAID::sessionKey::projectPath 或 sessionKey::projectPath(旧格式兼容)
165
234
  const parts = queueKey.split('::');
@@ -181,6 +250,7 @@ export class MessageQueue {
181
250
  role: item.role,
182
251
  resolve: item.resolve,
183
252
  reject: item.reject,
253
+ enqueuedAt: item.enqueuedAt ?? item.message.enqueuedAt,
184
254
  };
185
255
  }
186
256
  itemFromPart(part) {
@@ -191,6 +261,7 @@ export class MessageQueue {
191
261
  role: part.role,
192
262
  resolve: part.resolve,
193
263
  reject: part.reject,
264
+ enqueuedAt: part.enqueuedAt ?? part.message.enqueuedAt,
194
265
  };
195
266
  }
196
267
  partsOf(item) {
@@ -207,21 +278,37 @@ export class MessageQueue {
207
278
  .map(part => part.message.messageId)
208
279
  .filter((id) => !!id);
209
280
  }
281
+ partMatchesMessage(part, messageId, selfAID, queueKey) {
282
+ if (part.message.messageId !== messageId)
283
+ return false;
284
+ if (selfAID === undefined)
285
+ return true;
286
+ const messageScope = part.message.selfAID ?? (queueKey ? this.selfAIDFromQueueKey(queueKey) : undefined);
287
+ return messageScope === selfAID;
288
+ }
210
289
  peerIdsFor(item) {
211
290
  return this.partsOf(item).map(part => part.message.peerId);
212
291
  }
213
- serializePart(part, enqueuedAt) {
292
+ serializePart(part, fallbackEnqueuedAt) {
293
+ const enqueuedAt = part.enqueuedAt ?? part.message.enqueuedAt ?? fallbackEnqueuedAt ?? Date.now();
294
+ const message = part.message.enqueuedAt === enqueuedAt
295
+ ? part.message
296
+ : { ...part.message, enqueuedAt };
214
297
  return {
215
- message: part.message,
298
+ message,
216
299
  projectPath: part.projectPath,
217
300
  agentName: part.agentName,
218
301
  role: part.role,
219
302
  enqueuedAt,
220
303
  };
221
304
  }
222
- serializeItem(item, enqueuedAt) {
305
+ serializeItem(item, fallbackEnqueuedAt) {
306
+ const enqueuedAt = item.enqueuedAt ?? item.message.enqueuedAt ?? fallbackEnqueuedAt ?? Date.now();
307
+ const message = item.message.enqueuedAt === enqueuedAt
308
+ ? item.message
309
+ : { ...item.message, enqueuedAt };
223
310
  return {
224
- message: item.message,
311
+ message,
225
312
  projectPath: item.projectPath,
226
313
  agentName: item.agentName,
227
314
  role: item.role,
@@ -230,17 +317,25 @@ export class MessageQueue {
230
317
  };
231
318
  }
232
319
  restoredPart(item) {
320
+ const enqueuedAt = item.enqueuedAt ?? item.message.enqueuedAt;
321
+ const message = enqueuedAt === undefined
322
+ ? this.normalizeRestoredMessage(item.message)
323
+ : this.normalizeRestoredMessage({ ...item.message, enqueuedAt });
233
324
  return {
234
- message: this.normalizeRestoredMessage(item.message),
325
+ message,
235
326
  projectPath: item.projectPath,
236
327
  agentName: item.agentName || DEFAULT_AGENT_NAME,
237
328
  role: item.role,
238
329
  resolve: () => { },
239
330
  reject: () => { },
331
+ enqueuedAt,
240
332
  };
241
333
  }
242
334
  restoreSubmittedPart(item) {
243
- const source = this.normalizeRestoredMessage(item.message);
335
+ const sourceEnqueuedAt = item.enqueuedAt ?? item.message.enqueuedAt;
336
+ const source = this.normalizeRestoredMessage(sourceEnqueuedAt === undefined
337
+ ? item.message
338
+ : { ...item.message, enqueuedAt: sourceEnqueuedAt });
244
339
  const message = {
245
340
  ...source,
246
341
  content: RESTART_RESUME_CONTENT,
@@ -272,6 +367,7 @@ export class MessageQueue {
272
367
  role: item.role,
273
368
  resolve: () => { },
274
369
  reject: () => { },
370
+ enqueuedAt: sourceEnqueuedAt,
275
371
  };
276
372
  }
277
373
  normalizeRestoredMessage(message) {
@@ -299,9 +395,53 @@ export class MessageQueue {
299
395
  role: this.highestRole(parts),
300
396
  resolve: () => parts.forEach(part => part.resolve()),
301
397
  reject: (error) => parts.forEach(part => part.reject(error)),
398
+ enqueuedAt: this.earliestPartEnqueuedAt(parts),
302
399
  parts,
303
400
  };
304
401
  }
402
+ firstEnqueuedAt(item) {
403
+ const parts = 'parts' in item && item.parts ? item.parts : [item];
404
+ let value;
405
+ for (const part of parts) {
406
+ const candidate = part.enqueuedAt ?? part.message.enqueuedAt;
407
+ if (candidate === undefined)
408
+ continue;
409
+ value = value === undefined ? candidate : Math.min(value, candidate);
410
+ }
411
+ return value;
412
+ }
413
+ earliestEnqueuedAt(items) {
414
+ let value;
415
+ for (const item of items) {
416
+ const candidate = this.firstEnqueuedAt(item);
417
+ if (candidate === undefined)
418
+ continue;
419
+ value = value === undefined ? candidate : Math.min(value, candidate);
420
+ }
421
+ return value;
422
+ }
423
+ earliestPartEnqueuedAt(parts) {
424
+ let value;
425
+ for (const part of parts) {
426
+ const candidate = part.enqueuedAt ?? part.message.enqueuedAt;
427
+ if (candidate === undefined)
428
+ continue;
429
+ value = value === undefined ? candidate : Math.min(value, candidate);
430
+ }
431
+ return value;
432
+ }
433
+ firstReceivedAt(items) {
434
+ let value;
435
+ for (const item of items) {
436
+ for (const part of this.partsOf(item)) {
437
+ const candidate = part.message.receivedAt ?? part.message.timestamp;
438
+ if (candidate === undefined)
439
+ continue;
440
+ value = value === undefined ? candidate : Math.min(value, candidate);
441
+ }
442
+ }
443
+ return value;
444
+ }
305
445
  highestRole(items) {
306
446
  const rank = {
307
447
  none: 0,
@@ -431,19 +571,18 @@ export class MessageQueue {
431
571
  try {
432
572
  const buckets = [];
433
573
  const activeBuckets = [];
434
- const enqueuedAt = Date.now();
435
574
  for (const [queueKey, queue] of this.queues.entries()) {
436
575
  if (queue.length === 0)
437
576
  continue;
438
577
  buckets.push({
439
578
  queueKey,
440
- items: queue.map(item => this.serializeItem(item, enqueuedAt)),
579
+ items: queue.map(item => this.serializeItem(item)),
441
580
  });
442
581
  }
443
582
  for (const [queueKey, item] of this.activeBatches.entries()) {
444
583
  activeBuckets.push({
445
584
  queueKey,
446
- items: [this.serializeItem(item, enqueuedAt)],
585
+ items: [this.serializeItem(item)],
447
586
  });
448
587
  }
449
588
  fs.mkdirSync(path.dirname(this.persistencePath), { recursive: true });
@@ -496,13 +635,15 @@ export class MessageQueue {
496
635
  if (parts.length === 0)
497
636
  continue;
498
637
  const rawParts = parts
499
- .sort((a, b) => (a.message.timestamp ?? 0) - (b.message.timestamp ?? 0));
638
+ .sort((a, b) => (a.message.receivedAt ?? a.message.timestamp ?? 0)
639
+ - (b.message.receivedAt ?? b.message.timestamp ?? 0));
500
640
  for (const part of rawParts) {
501
641
  const rawItem = this.itemFromPart(part);
502
642
  queue.push(rawItem);
503
643
  if (rawItem.message.messageId) {
504
- const dedupKey = `${sessionKey}:${rawItem.message.messageId}`;
644
+ const dedupKey = this.dedupKey(sessionKey, rawItem.message.messageId, rawItem.message.selfAID);
505
645
  this.recentMessageIds.add(dedupKey);
646
+ this.durableMessageIds.set(dedupKey, Date.now());
506
647
  setTimeout(() => this.recentMessageIds.delete(dedupKey), this.DEDUP_WINDOW);
507
648
  }
508
649
  }
@@ -538,13 +679,15 @@ export class MessageQueue {
538
679
  : this.itemFromPart(first.message.handoffDelivery
539
680
  ? this.restoredPart(first)
540
681
  : this.restoreSubmittedPart(first));
541
- submittedItem.message.timestamp = first.message.timestamp;
682
+ submittedItem.message.timestamp = first.message.receivedAt ?? first.message.timestamp;
683
+ submittedItem.message.enqueuedAt = first.enqueuedAt ?? first.message.enqueuedAt;
542
684
  queue.push(submittedItem);
543
685
  for (const part of rawParts) {
544
686
  if (!part.message.messageId)
545
687
  continue;
546
- const dedupKey = `${sessionKey}:${part.message.messageId}`;
688
+ const dedupKey = this.dedupKey(sessionKey, part.message.messageId, part.message.selfAID);
547
689
  this.recentMessageIds.add(dedupKey);
690
+ this.durableMessageIds.set(dedupKey, Date.now());
548
691
  setTimeout(() => this.recentMessageIds.delete(dedupKey), this.DEDUP_WINDOW);
549
692
  }
550
693
  restored += rawParts.length;
@@ -564,6 +707,27 @@ export class MessageQueue {
564
707
  logger.warn('[Queue] Ignoring unsupported persisted queue file');
565
708
  return 0;
566
709
  }
710
+ // Version-1 snapshots did not carry per-item enqueue times. Preserve
711
+ // their last durable snapshot time instead of making restored history
712
+ // look newly queued at the moment of daemon restart.
713
+ const legacyEnqueuedAt = typeof parsed.updatedAt === 'number' && Number.isFinite(parsed.updatedAt)
714
+ ? parsed.updatedAt
715
+ : Date.now();
716
+ for (const bucket of [...parsed.queues, ...(Array.isArray(parsed.active) ? parsed.active : [])]) {
717
+ for (const item of bucket.items ?? []) {
718
+ if (!item?.message)
719
+ continue;
720
+ if (item.enqueuedAt === undefined && item.message?.enqueuedAt === undefined)
721
+ item.enqueuedAt = legacyEnqueuedAt;
722
+ for (const part of item.parts ?? []) {
723
+ if (!part?.message)
724
+ continue;
725
+ if (part.enqueuedAt === undefined && part.message?.enqueuedAt === undefined) {
726
+ part.enqueuedAt = item.enqueuedAt ?? legacyEnqueuedAt;
727
+ }
728
+ }
729
+ }
730
+ }
567
731
  const restoredKeys = new Set();
568
732
  if (Array.isArray(parsed.active)) {
569
733
  for (const bucket of parsed.active) {
@@ -574,6 +738,7 @@ export class MessageQueue {
574
738
  restored += this.restoreBucket(bucket, restoredKeys);
575
739
  }
576
740
  this.persistQueuesImmediate();
741
+ this.persistDurableDedup();
577
742
  if (restored > 0) {
578
743
  logger.info(`[Queue] Restored ${restored} persisted message(s) from ${this.persistencePath}`);
579
744
  }
@@ -617,8 +782,13 @@ export class MessageQueue {
617
782
  if (options?.signal?.aborted) {
618
783
  return Promise.reject(abortError(options.signal.reason));
619
784
  }
785
+ if (options?.selfAID && message.selfAID !== options.selfAID) {
786
+ // The queue option is the authoritative physical-copy scope. A shared
787
+ // broadcast object must be cloned per agent before dedup/cancel logic.
788
+ message = { ...message, selfAID: options.selfAID };
789
+ }
620
790
  // 消息去重检查
621
- if (!this.shouldProcess(sessionKey, message)) {
791
+ if (!this.shouldProcess(sessionKey, message, options?.selfAID)) {
622
792
  options?.onPersisted?.();
623
793
  return Promise.resolve();
624
794
  }
@@ -676,7 +846,18 @@ export class MessageQueue {
676
846
  const queue = this.queues.get(queueKey);
677
847
  const queuedResolve = options?.resolveOnPersist ? cleanupAbort : resolveOnce;
678
848
  const queuedReject = options?.resolveOnPersist ? cleanupAbort : rejectOnce;
679
- const queuedItem = { message, projectPath, agentName, role: options?.role, resolve: queuedResolve, reject: queuedReject };
849
+ // Capture the physical enqueue instant once. It must not be regenerated
850
+ // on every persistence snapshot, otherwise old messages appear fresh.
851
+ const enqueuedAt = Date.now();
852
+ // Clone at the physical queue boundary. The same inbound object may be
853
+ // broadcast to multiple agents; mutating it would make all copies share
854
+ // the last queue's enqueue timestamp.
855
+ message = {
856
+ ...message,
857
+ enqueuedAt,
858
+ timestamp: message.receivedAt ?? message.timestamp ?? enqueuedAt,
859
+ };
860
+ const queuedItem = { message, projectPath, agentName, role: options?.role, resolve: queuedResolve, reject: queuedReject, enqueuedAt };
680
861
  queue.push(queuedItem);
681
862
  this.logicalQueue.enqueue(queueKey, message); // 通知逻辑队列记录出队顺序
682
863
  abortHandler = () => {
@@ -693,7 +874,7 @@ export class MessageQueue {
693
874
  this.persistQueuesImmediate();
694
875
  }
695
876
  else if (message.messageId) {
696
- void this.cancelActiveAndWait(message.messageId).finally(() => {
877
+ void this.cancelActiveAndWait(message.messageId, message.selfAID).finally(() => {
697
878
  this.forgetProcessed(sessionKey, message);
698
879
  settleReject(error);
699
880
  });
@@ -715,6 +896,10 @@ export class MessageQueue {
715
896
  rejectOnce(new Error('failed to persist message queue'));
716
897
  return;
717
898
  }
899
+ // A duplicate must not re-enter a runner after the queue entry has
900
+ // survived its write-ahead snapshot. Keep this separate from the queue
901
+ // snapshot because the latter is removed as soon as the task completes.
902
+ this.rememberDurably(sessionKey, message, options?.selfAID);
718
903
  try {
719
904
  options?.onPersisted?.();
720
905
  }
@@ -851,6 +1036,7 @@ export class MessageQueue {
851
1036
  role: this.highestRole(rawParts),
852
1037
  resolve: () => rawParts.forEach(part => part.resolve()),
853
1038
  reject: (error) => rawParts.forEach(part => part.reject(error)),
1039
+ enqueuedAt: this.earliestPartEnqueuedAt(rawParts),
854
1040
  parts: rawParts,
855
1041
  };
856
1042
  const batchRole = this.commonRole(rawItems);
@@ -866,6 +1052,7 @@ export class MessageQueue {
866
1052
  const activeState = {
867
1053
  baseagent: merged.message.baseagent,
868
1054
  peerId: this.uniquePeerId(rawItems),
1055
+ selfAID: merged.message.selfAID ?? this.selfAIDFromQueueKey(queueKey),
869
1056
  messageIds: activeMessageIds,
870
1057
  };
871
1058
  this.activeStates.set(queueKey, activeState);
@@ -991,7 +1178,7 @@ export class MessageQueue {
991
1178
  const parts = this.partsOf(item);
992
1179
  let ts = Number.POSITIVE_INFINITY;
993
1180
  for (const part of parts)
994
- ts = Math.min(ts, part.message.timestamp ?? 0);
1181
+ ts = Math.min(ts, part.message.receivedAt ?? part.message.timestamp ?? 0);
995
1182
  return Number.isFinite(ts) ? ts : 0;
996
1183
  }
997
1184
  /**
@@ -1073,6 +1260,8 @@ export class MessageQueue {
1073
1260
  sameDevice: m.sameDevice, sameNetwork: m.sameNetwork, sameEgressIp: m.sameEgressIp,
1074
1261
  encrypted: m.encrypted,
1075
1262
  content: m.content, timestamp: m.timestamp,
1263
+ receivedAt: m.receivedAt,
1264
+ gatewaySeq: m.gatewaySeq,
1076
1265
  causation: m.causation,
1077
1266
  images: m.images && m.images.length > 0 ? m.images : undefined,
1078
1267
  mentions: m.mentions && m.mentions.length > 0 ? m.mentions : undefined,
@@ -1090,6 +1279,8 @@ export class MessageQueue {
1090
1279
  break;
1091
1280
  }
1092
1281
  }
1282
+ const firstReceivedAt = this.firstReceivedAt(items);
1283
+ const earliestEnqueuedAt = this.earliestEnqueuedAt(items);
1093
1284
  const merged = {
1094
1285
  ...last.message,
1095
1286
  content: contents.join('\n'),
@@ -1098,6 +1289,9 @@ export class MessageQueue {
1098
1289
  mentions: allMentions.length > 0 ? allMentions : undefined,
1099
1290
  messageId: latestMessageId,
1100
1291
  sourceMessageIds: sourceMessageIds.length > 0 ? sourceMessageIds : undefined,
1292
+ receivedAt: firstReceivedAt,
1293
+ timestamp: firstReceivedAt ?? last.message.timestamp,
1294
+ enqueuedAt: earliestEnqueuedAt,
1101
1295
  // 密文优先:合并批次内任一条密文,则整轮(interactive 自动回复的单值出站)按密文。
1102
1296
  // 任一条 encrypted===true 即 true;否则若有明确的 false 则 false;全 undefined(非 aun)则 undefined。
1103
1297
  encrypted: subMessages.some(s => s.encrypted === true)
@@ -1122,6 +1316,7 @@ export class MessageQueue {
1122
1316
  agentName: last.agentName,
1123
1317
  resolve: () => { }, // 由调用方管理
1124
1318
  reject: () => { },
1319
+ enqueuedAt: earliestEnqueuedAt,
1125
1320
  };
1126
1321
  }
1127
1322
  getQueueLength(sessionKey) {
@@ -1157,14 +1352,14 @@ export class MessageQueue {
1157
1352
  }
1158
1353
  return false;
1159
1354
  }
1160
- cancel(messageId) {
1161
- for (const queue of this.queues.values()) {
1162
- const idx = queue.findIndex(q => this.messageIdsFor(q).includes(messageId));
1355
+ cancel(messageId, selfAID) {
1356
+ for (const [queueKey, queue] of this.queues.entries()) {
1357
+ const idx = queue.findIndex(q => this.partsOf(q).some(part => this.partMatchesMessage(part, messageId, selfAID, queueKey)));
1163
1358
  if (idx === -1)
1164
1359
  continue;
1165
1360
  const target = queue[idx];
1166
1361
  const parts = this.partsOf(target);
1167
- const partIdx = parts.findIndex(part => part.message.messageId === messageId);
1362
+ const partIdx = parts.findIndex(part => this.partMatchesMessage(part, messageId, selfAID, queueKey));
1168
1363
  if (partIdx === -1)
1169
1364
  continue;
1170
1365
  if (parts.length === 1) {
@@ -1186,26 +1381,31 @@ export class MessageQueue {
1186
1381
  * 撤回正在执行的消息:如果 messageId 正在处理中,触发 interrupt。
1187
1382
  * @returns true 如果找到并触发了中断
1188
1383
  */
1189
- cancelActive(messageId) {
1190
- const cancellation = this.beginActiveCancellation(messageId);
1384
+ cancelActive(messageId, selfAID) {
1385
+ const cancellation = this.beginActiveCancellation(messageId, selfAID);
1191
1386
  if (cancellation.barrier)
1192
1387
  void cancellation.barrier;
1193
1388
  return cancellation.found;
1194
1389
  }
1195
1390
  /** Cancel an active message and wait until the runner interrupt barrier settles. */
1196
- async cancelActiveAndWait(messageId) {
1197
- const cancellation = this.beginActiveCancellation(messageId);
1391
+ async cancelActiveAndWait(messageId, selfAID) {
1392
+ const cancellation = this.beginActiveCancellation(messageId, selfAID);
1198
1393
  await cancellation.barrier;
1199
1394
  return cancellation.found;
1200
1395
  }
1201
- beginActiveCancellation(messageId) {
1396
+ beginActiveCancellation(messageId, selfAID) {
1202
1397
  for (const [queueKey, activeState] of this.activeStates.entries()) {
1203
1398
  if (!activeState.messageIds.has(messageId))
1204
1399
  continue;
1400
+ if (selfAID !== undefined && activeState.selfAID !== selfAID)
1401
+ continue;
1205
1402
  const active = this.activeBatches.get(queueKey);
1206
- if (active && this.messageIdsFor(active).includes(messageId)) {
1403
+ if (active && !this.partsOf(active).some(part => this.partMatchesMessage(part, messageId, selfAID, queueKey))) {
1404
+ continue;
1405
+ }
1406
+ if (active && this.partsOf(active).some(part => this.partMatchesMessage(part, messageId, selfAID, queueKey))) {
1207
1407
  const parts = this.partsOf(active);
1208
- const remaining = parts.filter(part => part.message.messageId !== messageId);
1408
+ const remaining = parts.filter(part => !this.partMatchesMessage(part, messageId, selfAID, queueKey));
1209
1409
  if (remaining.length === 0) {
1210
1410
  this.activeBatches.delete(queueKey);
1211
1411
  this.processingStartTime.delete(queueKey);
@@ -1439,9 +1639,14 @@ export class MessageQueue {
1439
1639
  buildSnapshot(status, queueKey, sessionKey, item) {
1440
1640
  const { channelType, channelId } = this.parseSessionKey(sessionKey);
1441
1641
  const startTime = this.processingStartTime.get(queueKey);
1642
+ const receivedAt = item.message.receivedAt ?? item.message.timestamp;
1643
+ const enqueuedAt = this.firstEnqueuedAt(item);
1442
1644
  const elapsedMs = status === 'active' && startTime != null
1443
1645
  ? Date.now() - startTime
1444
1646
  : undefined;
1647
+ const queueWaitMs = enqueuedAt == null
1648
+ ? undefined
1649
+ : Math.max(0, (status === 'active' && startTime != null ? startTime : Date.now()) - enqueuedAt);
1445
1650
  return {
1446
1651
  status,
1447
1652
  sessionKey,
@@ -1451,9 +1656,26 @@ export class MessageQueue {
1451
1656
  peerName: item.message.peerName,
1452
1657
  preview: this.truncatePreview(item.message.content),
1453
1658
  messageId: item.message.messageId,
1659
+ receivedAt,
1660
+ gatewaySeq: item.message.gatewaySeq,
1661
+ enqueuedAt,
1662
+ queueWaitMs,
1663
+ processingStartedAt: status === 'active' ? startTime : undefined,
1454
1664
  elapsedMs,
1455
1665
  };
1456
1666
  }
1667
+ matchesTimeRange(item, range) {
1668
+ if (!range || (range.after === undefined && range.before === undefined))
1669
+ return true;
1670
+ const value = item.message.receivedAt ?? item.message.timestamp ?? item.message.enqueuedAt ?? this.firstEnqueuedAt(item);
1671
+ if (value === undefined)
1672
+ return false;
1673
+ if (range.after !== undefined && value <= range.after)
1674
+ return false;
1675
+ if (range.before !== undefined && value >= range.before)
1676
+ return false;
1677
+ return true;
1678
+ }
1457
1679
  /**
1458
1680
  * 通过 human-readable sessionKey 反查 sessionId(cli interrupt 用)
1459
1681
  */
@@ -1469,7 +1691,7 @@ export class MessageQueue {
1469
1691
  * @param sessionId - Session.id(meta_YYYYMMDD_TS)
1470
1692
  * @returns 仅返回 pending 状态的消息
1471
1693
  */
1472
- getQueueItemsBySession(sessionId) {
1694
+ getQueueItemsBySession(sessionId, range) {
1473
1695
  const result = [];
1474
1696
  for (const [queueKey, queue] of this.queues.entries()) {
1475
1697
  if (!this.matchesSession(queueKey, sessionId))
@@ -1477,6 +1699,8 @@ export class MessageQueue {
1477
1699
  const sessionKey = this.queueKeyToSessionKey.get(queueKey)
1478
1700
  || this.sessionKeyFromQueueKey(queueKey);
1479
1701
  for (const item of queue) {
1702
+ if (!this.matchesTimeRange(item, range))
1703
+ continue;
1480
1704
  result.push(this.buildSnapshot('pending', queueKey, sessionKey, item));
1481
1705
  }
1482
1706
  }
@@ -1487,13 +1711,15 @@ export class MessageQueue {
1487
1711
  * @param agentName - agent 内部名称
1488
1712
  * @returns 返回所有状态(active + pending)的消息
1489
1713
  */
1490
- getQueueItemsByAgent(agentName) {
1714
+ getQueueItemsByAgent(agentName, range) {
1491
1715
  const result = [];
1492
1716
  // active 项
1493
1717
  for (const [queueKey, item] of this.activeBatches.entries()) {
1494
1718
  const itemAgent = item.agentName || DEFAULT_AGENT_NAME;
1495
1719
  if (itemAgent !== agentName)
1496
1720
  continue;
1721
+ if (!this.matchesTimeRange(item, range))
1722
+ continue;
1497
1723
  const sessionKey = this.queueKeyToSessionKey.get(queueKey)
1498
1724
  || this.sessionKeyFromQueueKey(queueKey);
1499
1725
  result.push(this.buildSnapshot('active', queueKey, sessionKey, item));
@@ -1504,6 +1730,8 @@ export class MessageQueue {
1504
1730
  const itemAgent = item.agentName || DEFAULT_AGENT_NAME;
1505
1731
  if (itemAgent !== agentName)
1506
1732
  continue;
1733
+ if (!this.matchesTimeRange(item, range))
1734
+ continue;
1507
1735
  const sessionKey = this.queueKeyToSessionKey.get(queueKey)
1508
1736
  || this.sessionKeyFromQueueKey(queueKey);
1509
1737
  result.push(this.buildSnapshot('pending', queueKey, sessionKey, item));