evolcore 0.0.19 → 0.0.21

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 (136) hide show
  1. package/CHANGELOG.md +43 -1
  2. package/README.md +60 -9
  3. package/bin/install-codex-managed-hooks.mjs +61 -0
  4. package/dist/agents/baseagent.js +10 -6
  5. package/dist/agents/claude-runner.js +383 -111
  6. package/dist/agents/codex-app-server-client.js +10 -2
  7. package/dist/agents/codex-runner.js +405 -137
  8. package/dist/agents/ecagent-runner.js +174 -63
  9. package/dist/agents/gemini-runner.js +130 -30
  10. package/dist/agents/request-identity.js +25 -0
  11. package/dist/agents/runner-types.js +19 -0
  12. package/dist/aun/aid/agentmd.js +66 -2
  13. package/dist/aun/aid/identity.js +4 -1
  14. package/dist/aun/aid/index.js +1 -1
  15. package/dist/aun/msg/group.js +86 -9
  16. package/dist/aun/msg/history.js +213 -36
  17. package/dist/aun/msg/managed-operation.js +58 -9
  18. package/dist/aun/msg/p2p.js +26 -11
  19. package/dist/aun/outbox.js +295 -68
  20. package/dist/aun/service-proxy.js +43 -25
  21. package/dist/channels/aun.js +1004 -273
  22. package/dist/channels/daemon.js +6 -1
  23. package/dist/cli/agent-command.js +4 -3
  24. package/dist/cli/agent.js +66 -56
  25. package/dist/cli/aun-commands.js +177 -42
  26. package/dist/cli/command-log.js +10 -11
  27. package/dist/cli/contact.js +1 -0
  28. package/dist/cli/daemon-commands.js +81 -121
  29. package/dist/cli/index.js +1 -0
  30. package/dist/cli/init.js +76 -24
  31. package/dist/cli/restart-monitor.js +3 -3
  32. package/dist/cli/task-context.js +46 -0
  33. package/dist/cli/trigger-command.js +1 -1
  34. package/dist/cli/watch-logs.js +10 -3
  35. package/dist/config/builtin-roles.js +1 -0
  36. package/dist/config/config-field-policy.js +16 -5
  37. package/dist/config/config-manager.js +226 -24
  38. package/dist/config/config-operation-service.js +1 -2
  39. package/dist/config/contact-operation-service.js +32 -1
  40. package/dist/config/contact-request-service.js +44 -0
  41. package/dist/config/daemon-services.js +186 -0
  42. package/dist/config/gateway-config.js +29 -16
  43. package/dist/config/lifecycle.js +16 -5
  44. package/dist/config/role-service.js +54 -3
  45. package/dist/config/schema-migration.js +550 -0
  46. package/dist/config-store.js +161 -12
  47. package/dist/core/agent-application-service.js +279 -0
  48. package/dist/core/audit/log-integrity.js +102 -0
  49. package/dist/core/auth/agent-delegation.js +31 -1
  50. package/dist/core/auth/auth-gateway.js +33 -4
  51. package/dist/core/auth/authorization-audit.js +155 -4
  52. package/dist/core/auth/operation-authorizer.js +41 -1
  53. package/dist/core/auth/operation-catalog.js +9 -1
  54. package/dist/core/bootstrap-messages.js +2 -2
  55. package/dist/core/bootstrap-service.js +27 -38
  56. package/dist/core/causation/aun-association.js +7 -4
  57. package/dist/core/channel-loader.js +0 -2
  58. package/dist/core/command/agent-control.js +56 -16
  59. package/dist/core/command/command-handler.js +290 -44
  60. package/dist/core/command/connect-menu.js +3 -4
  61. package/dist/core/command/group-menu.js +5 -7
  62. package/dist/core/command/menu-handler.js +279 -80
  63. package/dist/core/command/role-menu.js +21 -11
  64. package/dist/core/command/slash-gate.js +85 -18
  65. package/dist/core/command/slash-handler.js +350 -32
  66. package/dist/core/event-catalog.js +5 -0
  67. package/dist/core/evolagent.js +9 -4
  68. package/dist/core/handoff/dispatcher.js +4 -0
  69. package/dist/core/handoff/runtime.js +10 -0
  70. package/dist/core/handoff/store.js +32 -9
  71. package/dist/core/inference/text-inference.js +7 -15
  72. package/dist/core/message/im-renderer.js +83 -84
  73. package/dist/core/message/{inbound-admission.js → message-admission.js} +51 -1
  74. package/dist/core/message/message-bridge.js +124 -10
  75. package/dist/core/message/message-log.js +14 -7
  76. package/dist/core/message/message-queue.js +206 -16
  77. package/dist/core/message/message-utils.js +12 -5
  78. package/dist/core/message/response-engine.js +495 -72
  79. package/dist/core/message/send-receipt.js +1 -0
  80. package/dist/core/message/stream-debouncer.js +9 -2
  81. package/dist/core/model/model-catalog.js +23 -15
  82. package/dist/core/model/model-diagnostics.js +28 -10
  83. package/dist/core/permission/approval-gateway.js +180 -6
  84. package/dist/core/permission/ec-command-parser.js +465 -56
  85. package/dist/core/permission/mode.js +18 -3
  86. package/dist/core/{protected-paths.js → permission/protected-paths.js} +17 -5
  87. package/dist/core/permission/readonly-shell-query.js +263 -9
  88. package/dist/core/permission/sandbox-runtime.js +205 -13
  89. package/dist/core/permission/tool-policy.js +673 -62
  90. package/dist/core/relation/peer-identity.js +18 -0
  91. package/dist/core/session/session-fs-store.js +154 -5
  92. package/dist/core/session/session-manager.js +299 -30
  93. package/dist/core/session/session-renew.js +19 -12
  94. package/dist/core/session/session-turn-coordinator.js +11 -4
  95. package/dist/eck/kit-renderer.js +18 -9
  96. package/dist/index.js +283 -65
  97. package/dist/ipc.js +374 -24
  98. package/dist/paths.js +64 -7
  99. package/dist/response-system/context-builder.js +1 -7
  100. package/dist/trigger/anomaly-store.js +1 -0
  101. package/dist/trigger/feedback.js +56 -5
  102. package/dist/trigger/history.js +79 -4
  103. package/dist/trigger/legacy-session-history.js +2 -2
  104. package/dist/trigger/parser.js +3 -2
  105. package/dist/trigger/validation.js +6 -1
  106. package/dist/utils/atomic-write.js +27 -0
  107. package/dist/utils/ecweb-utils.js +16 -2
  108. package/dist/utils/error-utils.js +4 -1
  109. package/dist/utils/logger.js +21 -2
  110. package/dist/utils/process-tree-stats.js +24 -4
  111. package/dist/utils/process-tree-worker.js +31 -0
  112. package/dist/utils/project-path.js +1 -2
  113. package/dist/utils/stats.js +52 -18
  114. package/dist/utils/welcome.js +2 -2
  115. package/kits/docs/INDEX.md +1 -1
  116. package/kits/docs/evolcore/INDEX.md +1 -1
  117. package/kits/docs/evolcore/contact.md +7 -1
  118. package/kits/docs/evolcore/msg.md +16 -0
  119. package/kits/rules/01-overview.md +1 -1
  120. package/kits/rules/03-identity.md +1 -1
  121. package/kits/rules/05-venue.md +1 -1
  122. package/kits/schemas/_meta.json +10 -5
  123. package/kits/schemas/agent-config.schema.10.json +2 -1
  124. package/kits/schemas/agent-config.schema.11.json +421 -0
  125. package/kits/schemas/daemon.schema.5.json +135 -0
  126. package/kits/schemas/daemon.schema.6.json +131 -0
  127. package/kits/schemas/defaults.schema.5.json +119 -0
  128. package/kits/schemas/migrations/README.md +3 -1
  129. package/kits/schemas/relation-config.schema.8.json +13 -0
  130. package/kits/schemas/role-config.schema.1.json +1 -2
  131. package/kits/schemas/single-session.schema.3.json +32 -0
  132. package/kits/templates/roles/admin.json +1 -0
  133. package/kits/templates/roles/member.json +1 -0
  134. package/kits/templates/roles/visitor.json +1 -0
  135. package/package.json +7 -3
  136. package/skills/eclink/SKILL.md +2 -0
@@ -2,7 +2,7 @@ import { createHash, randomBytes } from 'crypto';
2
2
  import { logger } from '../../utils/logger.js';
3
3
  import { StreamDebouncer } from './stream-debouncer.js';
4
4
  import { appendMessageLog, appendMessageLogStrict, buildInboundEntry, isTransientProtocolMessage } from './message-log.js';
5
- import { buildEnvelope, sendInteractionPayload } from './message-utils.js';
5
+ import { buildEnvelope, isInteractionSendAccepted, sendInteractionPayload } from './message-utils.js';
6
6
  import { chatDirPath } from '../session/session-fs-store.js';
7
7
  import { formatChannelKey, tryParseChannelKey } from '../channel-loader.js';
8
8
  import { agentDir, resolvePaths } from '../../paths.js';
@@ -25,7 +25,10 @@ import { createRootCausation, deriveCausation, normalizeCausation } from '../cau
25
25
  import { recordCausationSpan } from '../causation/audit.js';
26
26
  import { renderActionAsText } from '../interaction-router.js';
27
27
  import { planApprovalRoute } from '../permission/approval-gateway.js';
28
- import { consumeAdmissionReplyBudget, clearAdmissionReplyBudgets, evaluateInboundAdmissionPreflight, evaluateOrdinaryInboundAdmission, } from './inbound-admission.js';
28
+ import { parseFullAccessCommand } from '../command/slash-gate.js';
29
+ import { isFullAccessEnabled } from '../../config-store.js';
30
+ import { auditFullAccessEvent } from '../auth/authorization-audit.js';
31
+ import { consumeAdmissionReplyBudget, clearAdmissionReplyBudgets, evaluateInboundAdmissionPreflight, evaluateOrdinaryInboundAdmission, } from './message-admission.js';
29
32
  const CONTACT_REJECTION_NOTICE = '[rejection] Not accepting messages from you.';
30
33
  const REJECTABLE_PEER_TYPES = new Set(['ai', 'bot', 'agent', 'service']);
31
34
  const REJECTION_MAX = 3;
@@ -431,6 +434,27 @@ export class MessageBridge {
431
434
  await this.sendContactRequestProtocolResponse(adapter, msg, result, owningAgent?.name);
432
435
  return true;
433
436
  }
437
+ /**
438
+ * Agent self-service contact add: create/update a pending request and send
439
+ * the same Owner action card used by inbound `/request`.
440
+ */
441
+ async submitAgentContactRequest(input) {
442
+ const owningAgent = this.agentRegistry?.get(input.selfAid) ?? null;
443
+ const submission = await submitContactRequest({
444
+ selfAid: input.selfAid,
445
+ applicantAid: input.applicantAid,
446
+ sourceChannelKey: input.sourceChannelKey,
447
+ note: '通过 ec contact add 发起的联系人申请',
448
+ });
449
+ if ((submission.code !== 'submitted' && submission.code !== 'resubmitted') || !submission.requestId) {
450
+ return { ...submission, reviewCardSent: false };
451
+ }
452
+ const reviewCardSent = await this.deliverContactReviewCard(input.selfAid, input.applicantAid, submission, owningAgent);
453
+ if (!reviewCardSent) {
454
+ logger.warn(`[MessageBridge] Agent contact add request persisted without review card: self=${input.selfAid} applicant=${input.applicantAid} request=${submission.requestId}`);
455
+ }
456
+ return { ...submission, reviewCardSent };
457
+ }
434
458
  contactRequestReply(result) {
435
459
  switch (result.code) {
436
460
  case 'submitted': return '联系人申请已提交,等待管理员审核。';
@@ -507,14 +531,17 @@ export class MessageBridge {
507
531
  toolInput: { applicantAid, requestId: submission.requestId },
508
532
  summary: `Review contact request from ${applicantAid}`,
509
533
  grantable: true,
510
- approverPolicy: 'agent_manager',
534
+ // Contact membership is an Agent-owner decision. Do not use the broader
535
+ // agent_manager policy here: that policy intentionally prefers an Admin,
536
+ // which made request cards invisible to the configured Owner.
537
+ approverPolicy: 'agent_owner',
511
538
  createdAt: Date.parse(submission.submittedAt),
512
539
  }, {
513
540
  userId: applicantAid,
514
541
  chatType: 'private',
515
542
  selfAid,
516
543
  approvalRouting: {
517
- approverPolicy: 'agent_manager',
544
+ approverPolicy: 'agent_owner',
518
545
  owners,
519
546
  admins,
520
547
  resolveApproverCandidates,
@@ -602,10 +629,11 @@ export class MessageBridge {
602
629
  chatmode: 'interactive',
603
630
  replyContext: { delivery: { chatType: 'private' }, metadata: { source: 'handoff', chatmode: 'interactive' } },
604
631
  });
605
- const sent = await sendInteractionPayload(route.adapter, envelope, interaction, renderActionAsText(interaction), envelope.replyContext);
606
- if (!sent)
632
+ const receipt = await sendInteractionPayload(route.adapter, envelope, interaction, renderActionAsText(interaction), envelope.replyContext);
633
+ const accepted = isInteractionSendAccepted(receipt);
634
+ if (!accepted)
607
635
  await this.interactionRouter.cancel(submission.requestId, 'delivery_failed');
608
- return !!sent;
636
+ return accepted;
609
637
  }
610
638
  async sendContactReviewOutcome(adapter, ownerAid, applicantAid, code, agentName) {
611
639
  const text = code === 'approved' ? `已通过 ${applicantAid} 的联系人申请。`
@@ -666,12 +694,23 @@ export class MessageBridge {
666
694
  const available = this.availableBaseagentsFor(channelName);
667
695
  if (available.length === 0 || available.includes(session.baseagent))
668
696
  return session;
697
+ // A topic's backend binding is fenced by SessionManager and cannot be
698
+ // cleared as a side effect of routing alignment. Reassigning the
699
+ // baseagent while leaving the old provider's agentSessionId persisted
700
+ // would make the new runner resume an incompatible backend. Keep the
701
+ // binding intact and let normal baseagent-mismatch handling report it;
702
+ // an explicit boundary operation must perform any future migration.
703
+ if (session.threadId) {
704
+ logger.error(`[MessageBridge] Cannot align topic to another baseagent: `
705
+ + `session=${session.id} thread=${session.threadId} current=${session.baseagent} preferred=${owningAgent.baseagent}`);
706
+ return session;
707
+ }
669
708
  const preferred = available.includes(owningAgent.baseagent)
670
709
  ? owningAgent.baseagent
671
710
  : available[0];
672
711
  logger.warn(`[MessageBridge] Aligning unavailable session baseagent: session=${session.id} ${session.baseagent} -> ${preferred} agent=${owningAgent.name} available=${available.join(',')}`);
673
- await this.sessionManager.updateSession(session.id, { baseagent: preferred, agentSessionId: null });
674
- return { ...session, baseagent: preferred, agentSessionId: undefined };
712
+ await this.sessionManager.updateSession(session.id, { baseagent: preferred });
713
+ return { ...session, baseagent: preferred };
675
714
  }
676
715
  /**
677
716
  * 为渠道注册消息桥梁:入站处理管线 + 出站命令响应
@@ -685,8 +724,14 @@ export class MessageBridge {
685
724
  register(channelName, onMessage, sendReply, adapter, channelType) {
686
725
  const effectiveChannelType = channelType || channelName;
687
726
  onMessage(async (msg) => {
727
+ let releaseSessionIngress;
728
+ const releaseIngress = () => {
729
+ releaseSessionIngress?.();
730
+ releaseSessionIngress = undefined;
731
+ };
688
732
  try {
689
733
  let content = msg.content.trim();
734
+ let executionPermissionOverride;
690
735
  // Channels that expose adapter receive time (notably AUN) must keep
691
736
  // that timestamp through session resolution and queue processing.
692
737
  // The fallback is only for legacy adapters that do not provide it.
@@ -890,6 +935,61 @@ export class MessageBridge {
890
935
  await this.sendAccessDenied(adapter, channelName, msg, sendReply);
891
936
  return;
892
937
  }
938
+ // /fa is an execution prefix, not a session-mode switch or a normal
939
+ // quick command. Only this trusted bridge path can construct the
940
+ // internal per-call authorization carried into ResponseEngine.
941
+ const fullAccessCommand = parseFullAccessCommand(content);
942
+ if (fullAccessCommand.matched) {
943
+ const reject = async (text) => {
944
+ logger.warn(`[MessageBridge] fullaccess command rejected: channel=${channelName} `
945
+ + `actor=${authSubject.principalId ?? authSubject.actorId ?? '<none>'} reason=${text}`);
946
+ auditFullAccessEvent({
947
+ event: 'fullaccess.command.rejected',
948
+ source: 'fullaccess-command',
949
+ decision: 'deny',
950
+ actorId: authSubject.principalId ?? authSubject.actorId,
951
+ processRole: authSubject.processRole,
952
+ messageId: msg.messageId,
953
+ reason: text,
954
+ });
955
+ await sendReply(msg.channelId, `❌ ${text}`, msg.replyContext);
956
+ };
957
+ if (msg.source !== undefined && msg.source !== 'user') {
958
+ await reject('fullaccess 只能由用户直接发送的 /fa 或 /fullaccess 命令发起');
959
+ return;
960
+ }
961
+ if (!fullAccessCommand.prompt) {
962
+ await reject('用法:/fa <提示词> 或 /fullaccess <提示词>');
963
+ return;
964
+ }
965
+ if (!isFullAccessEnabled()) {
966
+ await reject('fullaccess 功能未启用,请先设置 daemon.json.fullaccess.enabled=true');
967
+ return;
968
+ }
969
+ if (!authSubject.isDaemonOwner || !authSubject.principalId) {
970
+ await reject('仅可信 daemon-owner 可以使用 fullaccess');
971
+ return;
972
+ }
973
+ if (!msg.messageLogContent)
974
+ msg.messageLogContent = content;
975
+ content = fullAccessCommand.prompt;
976
+ executionPermissionOverride = {
977
+ permissionMode: 'fullaccess',
978
+ processRole: 'fullaccess-run',
979
+ dataScope: 'daemon',
980
+ source: 'fullaccess-command',
981
+ authorizedBy: authSubject.principalId,
982
+ };
983
+ logger.warn(`[MessageBridge] fullaccess command accepted: channel=${channelName} `
984
+ + `actor=${authSubject.principalId} message=${msg.messageId ?? '<none>'}`);
985
+ auditFullAccessEvent({
986
+ event: 'fullaccess.command.accepted',
987
+ source: 'fullaccess-command',
988
+ actorId: authSubject.principalId,
989
+ processRole: authSubject.processRole,
990
+ messageId: msg.messageId,
991
+ });
992
+ }
893
993
  // 0. 仅阻止协议遥测进入 session / model / messages.jsonl;协议本身已在
894
994
  // channel/main/events/AUN/handoff 专用链路中处理和记录。
895
995
  // 注:menu.* 自定义消息已由上方 handleMenuControl 快速路径处理。
@@ -928,6 +1028,11 @@ export class MessageBridge {
928
1028
  const contentForCmd = content.replace(/^(>[^\n]*\n)+\n?/, '').trim();
929
1029
  const cmdContent = contentForCmd || content;
930
1030
  const isCmd = msg.source !== 'handoff'
1031
+ // Once /fa has been accepted, its remainder is always model input.
1032
+ // A prompt such as `/fa /status` must not be re-routed as the
1033
+ // ordinary `/status` quick command after the prefix is removed.
1034
+ && !executionPermissionOverride
1035
+ && !parseFullAccessCommand(cmdContent).matched
931
1036
  && this.cmdHandler.isCommand(cmdContent);
932
1037
  if (isCmd) {
933
1038
  logger.debug(`[MessageBridge] Command detected: "${cmdContent}", routing to handler`);
@@ -966,6 +1071,7 @@ export class MessageBridge {
966
1071
  }
967
1072
  }
968
1073
  if (msg.source !== 'handoff'
1074
+ && !executionPermissionOverride
969
1075
  && await this.handleCommand(cmdContent, channelName, msg.channelId, (text) => {
970
1076
  const taskId = `cmd-${msg.messageId || Date.now()}`;
971
1077
  logger.channelOut({ channel: channelName, channelId: msg.channelId, taskId, correlationId: taskId, sessionId: msg.replyContext?.sessionId, agentAid: msg.selfAID, payload: { kind: 'command.result', text } });
@@ -1011,6 +1117,9 @@ export class MessageBridge {
1011
1117
  ?? this.defaultProjectPath;
1012
1118
  const hadMainSession = msg.threadId ? true : this.sessionManager.hasMainSession(channelName, msg.channelId, msg.channelType || effectiveChannelType, msg.selfAID || selfAid);
1013
1119
  let session = await this.sessionManager.getOrCreateSession(channelName, msg.channelId, effectiveProjectPath, msg.threadId, Object.keys(metadata).length ? metadata : undefined, this.extractTopicName(msg), msg.peerId, chatType, owningAgent?.baseagent, msg.selfAID, msg.channelType || effectiveChannelType, msg.peerType, identity);
1120
+ if (session.threadId && typeof this.messageQueue.reserveSessionIngress === 'function') {
1121
+ releaseSessionIngress = this.messageQueue.reserveSessionIngress(session.id);
1122
+ }
1014
1123
  session = await this.alignSessionBaseagent(channelName, session);
1015
1124
  const replyToMessageId = typeof msg.replyContext?.metadata?.refMessageId === 'string'
1016
1125
  ? msg.replyContext.metadata.refMessageId
@@ -1065,6 +1174,7 @@ export class MessageBridge {
1065
1174
  topicName: this.extractTopicName(msg),
1066
1175
  replyContext: msg.replyContext,
1067
1176
  source: msg.source,
1177
+ ...(executionPermissionOverride ? { executionPermissionOverride } : {}),
1068
1178
  boundSessionId: session.id,
1069
1179
  resolvedIdentity: identity,
1070
1180
  mentionMode: msg.mentionMode,
@@ -1158,6 +1268,7 @@ export class MessageBridge {
1158
1268
  role: !isInterrupt ? (session.identity?.role ?? 'none') : undefined,
1159
1269
  sessionKeyField: session.sessionKey,
1160
1270
  selfAID, // ← 传递 selfAID,用于队列隔离
1271
+ onPersisted: releaseIngress,
1161
1272
  });
1162
1273
  }
1163
1274
  catch (error) {
@@ -1193,6 +1304,9 @@ export class MessageBridge {
1193
1304
  catch (error) {
1194
1305
  logger.error(`[MessageBridge] Error in onMessage handler for ${channelName}:`, error);
1195
1306
  }
1307
+ finally {
1308
+ releaseIngress();
1309
+ }
1196
1310
  });
1197
1311
  }
1198
1312
  // ── Menu Protocol ──
@@ -1541,7 +1655,7 @@ export class MessageBridge {
1541
1655
  return;
1542
1656
  logger.warn(`[MenuControl] category=${category} request=${parsed.id || '<missing>'}`
1543
1657
  + ` type=${parsed.type} name=${parsed.name || '<none>'} action=${parsed.action || '<none>'}`
1544
- + ` transport=${channel} scope=${this.shortHash(scope)} sender=${this.shortHash(msg.peerId)}`
1658
+ + ` transport=${channel} scope=${scope || 'none'} sender=${msg.peerId || 'none'}`
1545
1659
  + (category === 'request-replay' ? ' replay=true' : '')
1546
1660
  + (category === 'request-retry' ? ' retry=true' : '')
1547
1661
  + `${code ? ` code=${code}` : ''}${decision.suppressed ? ` suppressed=${decision.suppressed}` : ''}`);
@@ -231,24 +231,31 @@ export function messageLogPath(chatDir) {
231
231
  return path.join(chatDir, MESSAGE_LOG_FILE);
232
232
  }
233
233
  export function hasMessageLogOperation(chatDir, operationId) {
234
+ return findMessageLogOperationMessageId(chatDir, operationId) !== undefined;
235
+ }
236
+ export function findMessageLogOperationMessageId(chatDir, operationId) {
234
237
  const file = messageLogPath(chatDir);
235
238
  if (!fs.existsSync(file))
236
- return false;
239
+ return undefined;
237
240
  try {
238
- return fs.readFileSync(file, 'utf-8').split('\n').some(line => {
241
+ for (const line of fs.readFileSync(file, 'utf-8').split('\n')) {
239
242
  if (!line.trim())
240
- return false;
243
+ continue;
241
244
  try {
242
245
  const entry = JSON.parse(line);
243
- return entry?.dir === 'out' && entry.operationId === operationId;
246
+ if (entry?.dir === 'out' && entry.operationId === operationId) {
247
+ if (typeof entry.msgId === 'string' && entry.msgId)
248
+ return entry.msgId;
249
+ }
244
250
  }
245
251
  catch {
246
- return false;
252
+ // skip malformed message log lines
247
253
  }
248
- });
254
+ }
255
+ return undefined;
249
256
  }
250
257
  catch {
251
- return false;
258
+ return undefined;
252
259
  }
253
260
  }
254
261
  export function resolveChatDir(sessionsDir, channelType, channelId, selfAID) {
@@ -3,6 +3,7 @@ import fs from 'fs';
3
3
  import { logger } from '../../utils/logger.js';
4
4
  import { LogicalQueueBridge } from './logical-queue-bridge.js';
5
5
  import { normalizeCausation } from '../causation/context.js';
6
+ import { KeyedFairMutex } from '../handoff/mutex.js';
6
7
  const DEFAULT_AGENT_NAME = '<unknown>';
7
8
  const RESTART_RESUME_CONTENT = 'evolcore 服务已重启,请继续之前未完成的任务。';
8
9
  export class MessageQueue {
@@ -10,6 +11,9 @@ export class MessageQueue {
10
11
  processing = new Set();
11
12
  processingAgent = new Map(); // queueKey → agentName(处理中项目的 agent)
12
13
  externalLocks = new Map();
14
+ prequeueReservations = new Map();
15
+ /** Serializes fast session commands while retaining the existing dequeue gate. */
16
+ sessionBarrierMutex = new KeyedFairMutex();
13
17
  handler;
14
18
  activeStates = new Map(); // queueKey → active task state
15
19
  interruptCallback;
@@ -32,6 +36,7 @@ export class MessageQueue {
32
36
  shutdownPromise;
33
37
  /** 逻辑队列桥接:管理出队顺序(响应模式可注入自定义策略,默认 FIFO) */
34
38
  logicalQueue = new LogicalQueueBridge();
39
+ enqueueSequence = 0;
35
40
  constructor(handler, options) {
36
41
  this.handler = handler;
37
42
  this.persistencePath = options?.persistencePath;
@@ -138,6 +143,42 @@ export class MessageQueue {
138
143
  setTimeout(() => this.recentMessageIds.delete(dedupKey), this.DEDUP_WINDOW);
139
144
  return true;
140
145
  }
146
+ /**
147
+ * Claim a stable inbound message for a fast command that bypasses the
148
+ * normal queue. The claim uses exactly the same durable ledger and key as
149
+ * ordinary enqueue deduplication, but deliberately does not enqueue or
150
+ * affect queue scheduling.
151
+ */
152
+ claimInbound(sessionKey, messageId, selfAID) {
153
+ const normalizedMessageId = messageId.trim();
154
+ if (!normalizedMessageId) {
155
+ const error = new Error('inbound claim requires a stable messageId');
156
+ error.code = 'INBOUND_MESSAGE_ID_REQUIRED';
157
+ throw error;
158
+ }
159
+ const key = this.dedupKey(sessionKey, normalizedMessageId, selfAID);
160
+ if (this.recentMessageIds.has(key)
161
+ || this.durableMessageIds.has(key)
162
+ || this.hasEnqueuedMessage(sessionKey, normalizedMessageId, selfAID)) {
163
+ return 'duplicate';
164
+ }
165
+ if (!this.dedupPath) {
166
+ const error = new Error('durable inbound dedup is unavailable');
167
+ error.code = 'INBOUND_DEDUP_UNAVAILABLE';
168
+ throw error;
169
+ }
170
+ const seenAt = Date.now();
171
+ this.durableMessageIds.set(key, seenAt);
172
+ if (!this.persistDurableDedup()) {
173
+ this.durableMessageIds.delete(key);
174
+ const error = new Error('failed to persist inbound dedup claim');
175
+ error.code = 'INBOUND_DEDUP_PERSIST_FAILED';
176
+ throw error;
177
+ }
178
+ this.recentMessageIds.add(key);
179
+ setTimeout(() => this.recentMessageIds.delete(key), this.DEDUP_WINDOW);
180
+ return 'claimed';
181
+ }
141
182
  rememberDurably(sessionKey, message, selfAID) {
142
183
  if (!this.dedupPath || !message.messageId)
143
184
  return;
@@ -174,20 +215,64 @@ export class MessageQueue {
174
215
  }
175
216
  persistDurableDedup() {
176
217
  if (!this.dedupPath)
177
- return;
218
+ return false;
178
219
  const now = Date.now();
179
220
  for (const [key, seenAt] of this.durableMessageIds) {
180
221
  if (now - seenAt > this.dedupTtlMs)
181
222
  this.durableMessageIds.delete(key);
182
223
  }
224
+ const tmp = `${this.dedupPath}.tmp-${process.pid}-${Date.now()}-${this.enqueueSequence}`;
225
+ let fd;
183
226
  try {
184
227
  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 })) }));
228
+ const content = Buffer.from(JSON.stringify({
229
+ version: 1,
230
+ updatedAt: now,
231
+ entries: [...this.durableMessageIds].map(([key, seenAt]) => ({ key, seenAt })),
232
+ }));
233
+ fd = fs.openSync(tmp, 'w');
234
+ let offset = 0;
235
+ while (offset < content.length) {
236
+ const written = fs.writeSync(fd, content, offset, content.length - offset, null);
237
+ if (written <= 0)
238
+ throw new Error('dedup ledger write made no progress');
239
+ offset += written;
240
+ }
241
+ fs.fsyncSync(fd);
242
+ fs.closeSync(fd);
243
+ fd = undefined;
187
244
  fs.renameSync(tmp, this.dedupPath);
245
+ const persistedFd = fs.openSync(this.dedupPath, 'r');
246
+ try {
247
+ fs.fsyncSync(persistedFd);
248
+ }
249
+ finally {
250
+ fs.closeSync(persistedFd);
251
+ }
252
+ if (process.platform !== 'win32') {
253
+ const directoryFd = fs.openSync(path.dirname(this.dedupPath), 'r');
254
+ try {
255
+ fs.fsyncSync(directoryFd);
256
+ }
257
+ finally {
258
+ fs.closeSync(directoryFd);
259
+ }
260
+ }
261
+ return true;
188
262
  }
189
263
  catch (error) {
190
264
  logger.warn(`[Queue] Failed to persist durable dedup ledger: ${error instanceof Error ? error.message : String(error)}`);
265
+ if (fd !== undefined) {
266
+ try {
267
+ fs.closeSync(fd);
268
+ }
269
+ catch { }
270
+ }
271
+ try {
272
+ fs.unlinkSync(tmp);
273
+ }
274
+ catch { }
275
+ return false;
191
276
  }
192
277
  }
193
278
  hasEnqueuedMessage(sessionKey, messageId, selfAID) {
@@ -768,6 +853,8 @@ export class MessageQueue {
768
853
  shouldRestoreMessage(message, sessionId) {
769
854
  // The Trigger scheduler records open runs as daemon_restart. Replaying their
770
855
  // queued work afterwards would execute side effects for an already-failed run.
856
+ if (message.executionPermissionOverride)
857
+ return false;
771
858
  if (message.source !== 'trigger' && !message.triggerMeta)
772
859
  return true;
773
860
  this.droppedTriggerSessionIds.add(sessionId);
@@ -794,9 +881,9 @@ export class MessageQueue {
794
881
  }
795
882
  // 拦截器检查:AskUserQuestion 等场景的一次性消息拦截
796
883
  const interceptor = this.interceptors.get(sessionKey);
797
- const canAnswerInteraction = message.source === undefined
884
+ const canAnswerInteraction = !message.executionPermissionOverride && (message.source === undefined
798
885
  || message.source === 'user'
799
- || message.source === 'card-trigger';
886
+ || message.source === 'card-trigger');
800
887
  if (interceptor && canAnswerInteraction) {
801
888
  this.interceptors.delete(sessionKey);
802
889
  logger.debug(`[Queue] Message intercepted for ${sessionKey}`);
@@ -849,6 +936,7 @@ export class MessageQueue {
849
936
  // Capture the physical enqueue instant once. It must not be regenerated
850
937
  // on every persistence snapshot, otherwise old messages appear fresh.
851
938
  const enqueuedAt = Date.now();
939
+ this.enqueueSequence += 1;
852
940
  // Clone at the physical queue boundary. The same inbound object may be
853
941
  // broadcast to multiple agents; mutating it would make all copies share
854
942
  // the last queue's enqueue timestamp.
@@ -1154,7 +1242,10 @@ export class MessageQueue {
1154
1242
  return result;
1155
1243
  }
1156
1244
  isMergeBarrier(message) {
1157
- return !!message.handoffDelivery || !!message.triggerMeta || !!message.causation?.trigger?.path.length;
1245
+ return !!message.executionPermissionOverride
1246
+ || !!message.handoffDelivery
1247
+ || !!message.triggerMeta
1248
+ || !!message.causation?.trigger?.path.length;
1158
1249
  }
1159
1250
  isOriginHandoff(message) {
1160
1251
  return message.source === 'handoff' && message.handoffDelivery?.direction === 'origin';
@@ -1437,23 +1528,122 @@ export class MessageQueue {
1437
1528
  return { found: false, barrier: Promise.resolve() };
1438
1529
  }
1439
1530
  /**
1440
- * 外部锁:快速命令(/compact, /clear)执行期间阻塞队列处理
1441
- * 返回 release 函数
1531
+ * Capture the session's queue state and install a dequeue gate in one
1532
+ * synchronous critical section. Messages arriving after this call remain
1533
+ * durable in the queue, but processNext will wait until the last holder
1534
+ * releases the gate.
1442
1535
  */
1443
- acquireLock(sessionKey) {
1444
- let releaseFn;
1445
- const promise = new Promise(resolve => { releaseFn = resolve; });
1446
- this.externalLocks.set(sessionKey, promise);
1536
+ acquireDequeueGate(sessionKey) {
1537
+ const existing = this.externalLocks.get(sessionKey);
1538
+ if (existing) {
1539
+ existing.holders += 1;
1540
+ let released = false;
1541
+ return {
1542
+ snapshot: { ...existing.snapshot },
1543
+ release: () => {
1544
+ if (released)
1545
+ return;
1546
+ released = true;
1547
+ existing.holders -= 1;
1548
+ if (existing.holders > 0)
1549
+ return;
1550
+ if (this.externalLocks.get(sessionKey) === existing)
1551
+ this.externalLocks.delete(sessionKey);
1552
+ existing.releasePromise();
1553
+ },
1554
+ };
1555
+ }
1556
+ let releasePromise;
1557
+ const promise = new Promise(resolve => { releasePromise = resolve; });
1558
+ let queued = 0;
1559
+ let active = false;
1560
+ let dispatching = false;
1561
+ for (const [queueKey, queue] of this.queues.entries()) {
1562
+ if (!this.matchesSession(queueKey, sessionKey))
1563
+ continue;
1564
+ queued += this.queueRawMessageCount(queue);
1565
+ if (this.processing.has(queueKey) || this.activeBatches.has(queueKey))
1566
+ active = true;
1567
+ if (this.processing.has(queueKey) && !this.activeBatches.has(queueKey))
1568
+ dispatching = true;
1569
+ }
1570
+ for (const queueKey of this.activeBatches.keys()) {
1571
+ if (this.matchesSession(queueKey, sessionKey))
1572
+ active = true;
1573
+ }
1574
+ const state = {
1575
+ promise,
1576
+ releasePromise,
1577
+ holders: 1,
1578
+ snapshot: {
1579
+ prequeue: this.prequeueReservations.get(sessionKey) ?? 0,
1580
+ active,
1581
+ queued,
1582
+ dispatching,
1583
+ cutoff: this.enqueueSequence,
1584
+ },
1585
+ };
1586
+ this.externalLocks.set(sessionKey, state);
1587
+ let released = false;
1588
+ return {
1589
+ snapshot: { ...state.snapshot },
1590
+ release: () => {
1591
+ if (released)
1592
+ return;
1593
+ released = true;
1594
+ state.holders -= 1;
1595
+ if (state.holders > 0)
1596
+ return;
1597
+ if (this.externalLocks.get(sessionKey) === state)
1598
+ this.externalLocks.delete(sessionKey);
1599
+ state.releasePromise();
1600
+ },
1601
+ };
1602
+ }
1603
+ /**
1604
+ * Mark an admitted message before it enters debounce or other pre-queue
1605
+ * work. The returned release is idempotent so callers can use it both from
1606
+ * onPersisted and a surrounding finally block.
1607
+ */
1608
+ reserveSessionIngress(sessionKey) {
1609
+ this.prequeueReservations.set(sessionKey, (this.prequeueReservations.get(sessionKey) ?? 0) + 1);
1610
+ let released = false;
1447
1611
  return () => {
1448
- this.externalLocks.delete(sessionKey);
1449
- releaseFn();
1612
+ if (released)
1613
+ return;
1614
+ released = true;
1615
+ const remaining = (this.prequeueReservations.get(sessionKey) ?? 1) - 1;
1616
+ if (remaining > 0)
1617
+ this.prequeueReservations.set(sessionKey, remaining);
1618
+ else
1619
+ this.prequeueReservations.delete(sessionKey);
1450
1620
  };
1451
1621
  }
1622
+ /**
1623
+ * Run a fast session command while no queued work can start. The queue
1624
+ * snapshot and external gate are installed before the callback is invoked;
1625
+ * messages arriving afterwards remain durable but are outside this probe.
1626
+ */
1627
+ async withSessionBarrier(sessionKey, callback) {
1628
+ return this.sessionBarrierMutex.forKey(sessionKey).runExclusive(async () => {
1629
+ const gate = this.acquireDequeueGate(sessionKey);
1630
+ try {
1631
+ return await callback(gate.snapshot);
1632
+ }
1633
+ finally {
1634
+ gate.release();
1635
+ }
1636
+ });
1637
+ }
1638
+ /** Backwards-compatible lock API used by existing quick commands. */
1639
+ acquireLock(sessionKey) {
1640
+ return this.acquireDequeueGate(sessionKey).release;
1641
+ }
1452
1642
  /** 检查是否有外部锁 */
1453
1643
  getExternalLock(queueKey) {
1454
- for (const [key, promise] of this.externalLocks) {
1644
+ for (const [key, state] of this.externalLocks) {
1455
1645
  if (this.matchesSession(queueKey, key))
1456
- return promise;
1646
+ return state.promise;
1457
1647
  }
1458
1648
  return undefined;
1459
1649
  }
@@ -4,6 +4,12 @@
4
4
  * 从 message-processor.ts 提取的独立工具函数,供 ResponseEngine 和其他模块使用。
5
5
  */
6
6
  import { renderCommandCardAsText, renderActionAsText } from '../interaction-router.js';
7
+ /** A legacy adapter resolved its send call without providing a structured receipt. */
8
+ export const LEGACY_INTERACTION_ACCEPTED = 'legacy-accepted';
9
+ export function isInteractionSendAccepted(result) {
10
+ return result === LEGACY_INTERACTION_ACCEPTED
11
+ || (result !== false && (result.status === 'sent' || result.status === 'queued'));
12
+ }
7
13
  /**
8
14
  * 构造出站消息信封
9
15
  */
@@ -129,7 +135,8 @@ export function defaultFallbackText(interaction) {
129
135
  * 通过统一的 adapter.send 入口发送交互负载
130
136
  *
131
137
  * 通过 adapter.send(envelope, { kind: 'interaction', ... }) 发送交互。
132
- * 成功返回 'sent',失败返回 false。
138
+ * 保留 adapter 的结构化回执,让调用方区分 sent、queued、failed
139
+ * suppressed;legacy adapter 的 void 返回表示已接受但尚无可验证回执。
133
140
  */
134
141
  export async function sendInteractionPayload(adapter, envelope, interaction, fallbackText, replyCtx) {
135
142
  const text = fallbackText ?? defaultFallbackText(interaction);
@@ -141,11 +148,11 @@ export async function sendInteractionPayload(adapter, envelope, interaction, fal
141
148
  try {
142
149
  const enriched = replyCtx ? withEnvelopeReplyContext(envelope, replyCtx) : envelope;
143
150
  const receipt = await adapter.send(enriched, payload);
144
- if (receipt?.status === 'suppressed')
151
+ if (!receipt)
152
+ return LEGACY_INTERACTION_ACCEPTED;
153
+ if (receipt.status === 'sent' && !(receipt.messageId || receipt.messages[0]?.messageId))
145
154
  return false;
146
- if (receipt?.status === 'sent')
147
- return receipt.messages[0]?.messageId || false;
148
- return 'sent';
155
+ return receipt;
149
156
  }
150
157
  catch {
151
158
  return false;