evolcore 0.0.20 → 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.
- package/CHANGELOG.md +22 -0
- package/README.md +58 -9
- package/dist/agents/baseagent.js +10 -6
- package/dist/agents/claude-runner.js +379 -108
- package/dist/agents/codex-app-server-client.js +10 -2
- package/dist/agents/codex-runner.js +402 -135
- package/dist/agents/ecagent-runner.js +171 -61
- package/dist/agents/gemini-runner.js +130 -30
- package/dist/agents/request-identity.js +25 -0
- package/dist/agents/runner-types.js +19 -0
- package/dist/aun/aid/agentmd.js +59 -2
- package/dist/aun/aid/identity.js +4 -1
- package/dist/aun/aid/index.js +1 -1
- package/dist/aun/msg/group.js +72 -6
- package/dist/aun/msg/history.js +213 -36
- package/dist/aun/msg/managed-operation.js +58 -9
- package/dist/aun/msg/p2p.js +5 -0
- package/dist/aun/outbox.js +182 -80
- package/dist/aun/service-proxy.js +43 -25
- package/dist/channels/aun.js +409 -88
- package/dist/channels/daemon.js +6 -1
- package/dist/cli/agent-command.js +4 -3
- package/dist/cli/agent.js +66 -56
- package/dist/cli/aun-commands.js +177 -42
- package/dist/cli/command-log.js +10 -11
- package/dist/cli/contact.js +1 -0
- package/dist/cli/daemon-commands.js +69 -115
- package/dist/cli/init.js +27 -15
- package/dist/cli/task-context.js +46 -0
- package/dist/cli/trigger-command.js +1 -1
- package/dist/cli/watch-logs.js +10 -3
- package/dist/config/builtin-roles.js +1 -0
- package/dist/config/config-field-policy.js +16 -5
- package/dist/config/config-manager.js +135 -17
- package/dist/config/contact-operation-service.js +32 -1
- package/dist/config/contact-request-service.js +44 -0
- package/dist/config/daemon-services.js +186 -0
- package/dist/config/gateway-config.js +20 -9
- package/dist/config/role-service.js +54 -3
- package/dist/config/schema-migration.js +550 -0
- package/dist/config-store.js +151 -9
- package/dist/core/agent-application-service.js +279 -0
- package/dist/core/audit/log-integrity.js +102 -0
- package/dist/core/auth/agent-delegation.js +31 -1
- package/dist/core/auth/auth-gateway.js +33 -4
- package/dist/core/auth/authorization-audit.js +150 -2
- package/dist/core/auth/operation-authorizer.js +41 -1
- package/dist/core/auth/operation-catalog.js +9 -1
- package/dist/core/bootstrap-service.js +6 -2
- package/dist/core/causation/aun-association.js +7 -4
- package/dist/core/command/agent-control.js +56 -16
- package/dist/core/command/command-handler.js +290 -44
- package/dist/core/command/connect-menu.js +3 -4
- package/dist/core/command/group-menu.js +5 -7
- package/dist/core/command/menu-handler.js +279 -80
- package/dist/core/command/role-menu.js +21 -11
- package/dist/core/command/slash-gate.js +85 -18
- package/dist/core/command/slash-handler.js +350 -32
- package/dist/core/event-catalog.js +5 -0
- package/dist/core/evolagent.js +4 -0
- package/dist/core/handoff/dispatcher.js +4 -0
- package/dist/core/handoff/runtime.js +10 -0
- package/dist/core/handoff/store.js +32 -9
- package/dist/core/inference/text-inference.js +7 -15
- package/dist/core/message/im-renderer.js +83 -84
- package/dist/core/message/{inbound-admission.js → message-admission.js} +51 -1
- package/dist/core/message/message-bridge.js +124 -10
- package/dist/core/message/message-log.js +14 -7
- package/dist/core/message/message-queue.js +206 -16
- package/dist/core/message/message-utils.js +12 -5
- package/dist/core/message/response-engine.js +486 -68
- package/dist/core/message/send-receipt.js +1 -0
- package/dist/core/message/stream-debouncer.js +9 -2
- package/dist/core/model/model-catalog.js +23 -15
- package/dist/core/model/model-diagnostics.js +28 -10
- package/dist/core/permission/approval-gateway.js +180 -6
- package/dist/core/permission/ec-command-parser.js +410 -54
- package/dist/core/permission/mode.js +18 -3
- package/dist/core/{protected-paths.js → permission/protected-paths.js} +17 -5
- package/dist/core/permission/readonly-shell-query.js +263 -9
- package/dist/core/permission/sandbox-runtime.js +159 -1
- package/dist/core/permission/tool-policy.js +575 -21
- package/dist/core/session/session-fs-store.js +154 -5
- package/dist/core/session/session-manager.js +299 -30
- package/dist/core/session/session-renew.js +19 -12
- package/dist/core/session/session-turn-coordinator.js +11 -4
- package/dist/eck/kit-renderer.js +1 -1
- package/dist/index.js +253 -46
- package/dist/ipc.js +374 -24
- package/dist/paths.js +64 -7
- package/dist/response-system/context-builder.js +1 -7
- package/dist/trigger/anomaly-store.js +1 -0
- package/dist/trigger/feedback.js +56 -5
- package/dist/trigger/history.js +79 -4
- package/dist/trigger/legacy-session-history.js +2 -2
- package/dist/trigger/parser.js +3 -2
- package/dist/trigger/validation.js +6 -1
- package/dist/utils/atomic-write.js +27 -0
- package/dist/utils/ecweb-utils.js +16 -2
- package/dist/utils/error-utils.js +4 -1
- package/dist/utils/logger.js +21 -2
- package/dist/utils/process-tree-stats.js +24 -4
- package/dist/utils/process-tree-worker.js +31 -0
- package/dist/utils/project-path.js +1 -2
- package/kits/docs/INDEX.md +1 -1
- package/kits/docs/evolcore/INDEX.md +1 -1
- package/kits/docs/evolcore/contact.md +7 -1
- package/kits/docs/evolcore/msg.md +16 -0
- package/kits/schemas/_meta.json +4 -2
- package/kits/schemas/agent-config.schema.11.json +13 -0
- package/kits/schemas/daemon.schema.5.json +0 -1
- package/kits/schemas/daemon.schema.6.json +131 -0
- package/kits/schemas/defaults.schema.5.json +15 -3
- package/kits/schemas/migrations/README.md +3 -1
- package/kits/schemas/relation-config.schema.8.json +13 -0
- package/kits/schemas/role-config.schema.1.json +1 -2
- package/kits/schemas/single-session.schema.3.json +32 -0
- package/kits/templates/roles/admin.json +1 -0
- package/kits/templates/roles/member.json +1 -0
- package/kits/templates/roles/visitor.json +1 -0
- package/package.json +6 -3
- package/skills/eclink/SKILL.md +2 -0
- package/dist/config/aun-gateway-config.js +0 -2
|
@@ -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 {
|
|
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
|
-
|
|
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: '
|
|
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
|
|
606
|
-
|
|
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
|
|
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
|
|
674
|
-
return { ...session, baseagent: preferred
|
|
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=${
|
|
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
|
|
239
|
+
return undefined;
|
|
237
240
|
try {
|
|
238
|
-
|
|
241
|
+
for (const line of fs.readFileSync(file, 'utf-8').split('\n')) {
|
|
239
242
|
if (!line.trim())
|
|
240
|
-
|
|
243
|
+
continue;
|
|
241
244
|
try {
|
|
242
245
|
const entry = JSON.parse(line);
|
|
243
|
-
|
|
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
|
-
|
|
252
|
+
// skip malformed message log lines
|
|
247
253
|
}
|
|
248
|
-
}
|
|
254
|
+
}
|
|
255
|
+
return undefined;
|
|
249
256
|
}
|
|
250
257
|
catch {
|
|
251
|
-
return
|
|
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
|
|
186
|
-
|
|
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.
|
|
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
|
-
*
|
|
1441
|
-
*
|
|
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
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
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
|
-
|
|
1449
|
-
|
|
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,
|
|
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
|
-
*
|
|
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
|
|
151
|
+
if (!receipt)
|
|
152
|
+
return LEGACY_INTERACTION_ACCEPTED;
|
|
153
|
+
if (receipt.status === 'sent' && !(receipt.messageId || receipt.messages[0]?.messageId))
|
|
145
154
|
return false;
|
|
146
|
-
|
|
147
|
-
return receipt.messages[0]?.messageId || false;
|
|
148
|
-
return 'sent';
|
|
155
|
+
return receipt;
|
|
149
156
|
}
|
|
150
157
|
catch {
|
|
151
158
|
return false;
|