evolcore 0.0.29 → 0.0.30
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 +7 -0
- package/dist/agents/codex-runner.js +61 -14
- package/dist/aun/aid/agentmd.js +224 -19
- package/dist/aun/aid/index.js +2 -1
- package/dist/aun/aid/managed-operation.js +70 -4
- package/dist/aun/group/index.js +1 -0
- package/dist/{core → aun}/group/invite-service.js +4 -4
- package/dist/channels/daemon.js +1 -1
- package/dist/cli/agent-command.js +7 -7
- package/dist/cli/aun-commands.js +97 -37
- package/dist/cli/data-command.js +2 -1
- package/dist/cli/managed-command-guard.js +2 -2
- package/dist/cli/model.js +1 -1
- package/dist/cli/response.js +1 -1
- package/dist/cli/trigger-command.js +52 -29
- package/dist/config/boot-log.js +95 -1
- package/dist/config/builtin-roles.js +1 -0
- package/dist/config/peer-role-resolver.js +21 -0
- package/dist/core/agent-ipc-registration.js +409 -0
- package/dist/core/agent-runtime.js +435 -0
- package/dist/core/auth/delegation-authorization.js +58 -0
- package/dist/core/auth/operation-catalog.js +8 -0
- package/dist/core/bootstrap-service.js +65 -1
- package/dist/core/channel-runtime.js +435 -0
- package/dist/core/command/command-handler.js +4 -1
- package/dist/core/daemon-bootstrap.js +151 -0
- package/dist/core/daemon-control.js +205 -0
- package/dist/core/daemon-lifecycle.js +225 -0
- package/dist/core/data-migration.js +4 -1
- package/dist/core/handoff/ipc-registration.js +156 -0
- package/dist/core/ipc-registration.js +457 -0
- package/dist/core/memory/consolidation-trigger.js +8 -0
- package/dist/core/memory/consolidation.js +838 -0
- package/dist/core/message/aun-ipc-sender.js +226 -0
- package/dist/core/message/outbound.js +165 -0
- package/dist/core/permission/approval-gateway.js +13 -0
- package/dist/core/permission/ec-command-parser.js +80 -35
- package/dist/core/permission/sandbox-runtime.js +23 -2
- package/dist/core/permission/tool-error-code.js +1 -0
- package/dist/core/permission/tool-policy.js +61 -24
- package/dist/core/session/session-idle-monitor.js +17 -0
- package/dist/index.js +352 -4841
- package/dist/ipc.js +2 -1
- package/dist/stats/db.js +24 -0
- package/dist/stats/index.js +1 -1
- package/dist/stats/writer.js +25 -1
- package/dist/trigger/application-service.js +157 -0
- package/dist/trigger/authorization.js +371 -0
- package/dist/trigger/ipc-service.js +406 -0
- package/dist/trigger/local-files.js +133 -0
- package/dist/trigger/manager.js +235 -22
- package/dist/trigger/scheduler.js +30 -5
- package/dist/trigger/script-executor.js +176 -39
- package/dist/trigger/script-sandbox.js +458 -0
- package/dist/trigger/system-definitions.js +188 -0
- package/dist/trigger/validation.js +0 -3
- package/dist/utils/aid-bind.js +1 -1
- package/dist/utils/evolcore-version.js +74 -0
- package/dist/utils/logger.js +16 -0
- package/kits/docs/evolcore/INDEX.md +1 -1
- package/kits/docs/evolcore/aid.md +9 -1
- package/kits/docs/evolcore/model.md +3 -2
- package/kits/docs/evolcore/rpc.md +4 -0
- package/kits/docs/evolcore/trigger.md +15 -1
- package/kits/schemas/relation-config.schema.9.json +1 -1
- package/kits/templates/system-fragments/commands.md +0 -1
- package/package.json +1 -1
- package/dist/core/memory/launch-consolidation.js +0 -395
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { AUNChannel } from '../channels/aun.js';
|
|
4
|
+
import { startServiceProxy } from '../aun/service-proxy.js';
|
|
5
|
+
import { remoteAccessService } from './auth/remote-access-store.js';
|
|
6
|
+
import { authorizationDenialData, formatAuthorizationDenial } from './auth/authorization-denial.js';
|
|
7
|
+
import { controlReplyContext } from './message/outbound.js';
|
|
8
|
+
import { enforceMenuResponseSize, evaluateMenuClientCompatibility, evolMenuResponseTransportMetadata, hasValidMenuId, logMenuRequestCompleted, logMenuRequestReceived, MENU_RESPONSE_MAX_BYTES, MENU_RESPONSE_SAFETY_MARGIN_BYTES, MenuRequestDeduper, menuFailure, menuPayloadFingerprint, normalizeMenuResponseTiming, parseMenuControl, validateMenuRequest, withMenuProcessingTime, } from './command/menu-protocol.js';
|
|
9
|
+
import { isProcessLevelOwner } from './command/command-handler.js';
|
|
10
|
+
import { onShutdown } from '../utils/cross-platform.js';
|
|
11
|
+
import { logger } from '../utils/logger.js';
|
|
12
|
+
import { resolvePaths } from '../paths.js';
|
|
13
|
+
const controlMenuDeduper = new MenuRequestDeduper();
|
|
14
|
+
export function startDaemonControlAid({ daemonCfg, cmdHandler, bindService, diagService, getProcessOwners, }) {
|
|
15
|
+
if (!daemonCfg.aid)
|
|
16
|
+
return undefined;
|
|
17
|
+
const aunPath = resolvePaths().root;
|
|
18
|
+
const certKey = path.join(aunPath, 'AIDs', daemonCfg.aid, 'private', 'key.json');
|
|
19
|
+
if (!fs.existsSync(certKey)) {
|
|
20
|
+
logger.warn(`控制 AID 证书缺失:${daemonCfg.aid}(AUN 控制通道后台重连;如需重建运行 ec init)`);
|
|
21
|
+
}
|
|
22
|
+
const controlChannel = new AUNChannel({
|
|
23
|
+
aid: daemonCfg.aid,
|
|
24
|
+
agentName: daemonCfg.aid,
|
|
25
|
+
channelName: 'control',
|
|
26
|
+
pureIdentity: true,
|
|
27
|
+
defaultEncrypt: daemonCfg.aun?.defaultEncrypt,
|
|
28
|
+
aunTrace: daemonCfg.debug?.aunTrace,
|
|
29
|
+
aunSdkLog: daemonCfg.debug?.aunSdkLog,
|
|
30
|
+
});
|
|
31
|
+
const controlConnectPromise = controlChannel.connect()
|
|
32
|
+
.then(() => {
|
|
33
|
+
logger.info(`✓ 控制 AID 已连接: ${daemonCfg.aid}`);
|
|
34
|
+
})
|
|
35
|
+
.catch((e) => {
|
|
36
|
+
logger.warn(`控制 AID 首连失败(后台自动重连,不影响 daemon 主流程): ${e?.message || e}`);
|
|
37
|
+
});
|
|
38
|
+
controlChannel.onMessage(async (opts) => {
|
|
39
|
+
let activeMenuRequest;
|
|
40
|
+
let menuCompletionLogged = false;
|
|
41
|
+
const menuFlowContext = {
|
|
42
|
+
source: 'control-aun',
|
|
43
|
+
selfAid: daemonCfg.aid,
|
|
44
|
+
messageId: opts.messageId,
|
|
45
|
+
requesterAid: opts.peerId,
|
|
46
|
+
authMethod: opts.protectedHeaders?.auth_method === 'remote-access-token' || opts.protectedHeaders?.auth_method === 'ecweb-token'
|
|
47
|
+
? opts.protectedHeaders.auth_method
|
|
48
|
+
: 'daemon-owner',
|
|
49
|
+
clientName: opts.protectedHeaders?.client_name ?? 'evol',
|
|
50
|
+
};
|
|
51
|
+
try {
|
|
52
|
+
const menuTiming = normalizeMenuResponseTiming(opts);
|
|
53
|
+
const text = (opts.content || '').trim();
|
|
54
|
+
let parsed;
|
|
55
|
+
try {
|
|
56
|
+
parsed = JSON.parse(text);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
parsed = null;
|
|
60
|
+
}
|
|
61
|
+
const menuResponseContext = () => ({
|
|
62
|
+
...controlReplyContext(opts, evolMenuResponseTransportMetadata(opts.protectedHeaders?.sdk_version, opts.protectedHeaders?.menu_volatile_v1)),
|
|
63
|
+
...(opts.peerDeviceId ? { toDeviceId: opts.peerDeviceId } : {}),
|
|
64
|
+
});
|
|
65
|
+
const sendControlMenuResponse = async (response) => {
|
|
66
|
+
const timedResponse = withMenuProcessingTime(enforceMenuResponseSize(response, MENU_RESPONSE_MAX_BYTES - MENU_RESPONSE_SAFETY_MARGIN_BYTES), menuTiming);
|
|
67
|
+
const request = activeMenuRequest ?? {
|
|
68
|
+
type: 'menu.unknown',
|
|
69
|
+
id: response.id,
|
|
70
|
+
...(response.name ? { name: response.name } : {}),
|
|
71
|
+
};
|
|
72
|
+
try {
|
|
73
|
+
await controlChannel.sendStructuredOrThrow(opts.channelId, timedResponse, menuResponseContext());
|
|
74
|
+
logMenuRequestCompleted(request, timedResponse, menuFlowContext, { delivery: 'sent' });
|
|
75
|
+
menuCompletionLogged = true;
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
logMenuRequestCompleted(request, timedResponse, menuFlowContext, {
|
|
79
|
+
delivery: 'failed',
|
|
80
|
+
reason: 'transport-error',
|
|
81
|
+
transportError: error,
|
|
82
|
+
});
|
|
83
|
+
menuCompletionLogged = true;
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
if (bindService && parsed?.type === 'bind.request') {
|
|
88
|
+
const response = await bindService.handleRequest(parsed, opts.peerId);
|
|
89
|
+
if (response) {
|
|
90
|
+
await controlChannel.sendStructuredOrThrow(opts.channelId, response, controlReplyContext(opts));
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const menuControl = parseMenuControl(text);
|
|
95
|
+
if (menuControl.isMenu) {
|
|
96
|
+
activeMenuRequest = menuControl.request;
|
|
97
|
+
logMenuRequestReceived(menuControl.request, menuFlowContext);
|
|
98
|
+
}
|
|
99
|
+
const isRoleMenu = menuControl.isMenu
|
|
100
|
+
&& (menuControl.name === 'role' || menuControl.request.cmd === '/role');
|
|
101
|
+
const currentProcessOwners = getProcessOwners();
|
|
102
|
+
if (/^\/diag(?:\s|$)/i.test(text)) {
|
|
103
|
+
const command = text.slice(5).trim();
|
|
104
|
+
const result = await diagService.execute(command.toLowerCase() === 'stop'
|
|
105
|
+
? { action: 'stop' } : { action: 'start', problem: command }, opts.peerId);
|
|
106
|
+
await controlChannel.sendMessage(opts.channelId, result.message, controlReplyContext(opts));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (menuControl.isMenu && !hasValidMenuId(menuControl)) {
|
|
110
|
+
logger.warn(`[ControlMenu] dropped malformed request without id type=${menuControl.type}`);
|
|
111
|
+
logMenuRequestCompleted(menuControl.request, undefined, menuFlowContext, {
|
|
112
|
+
delivery: 'dropped',
|
|
113
|
+
reason: 'missing-id',
|
|
114
|
+
});
|
|
115
|
+
menuCompletionLogged = true;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const menuClientCompatibilityError = menuControl.isMenu
|
|
119
|
+
? evaluateMenuClientCompatibility({ encrypted: opts.encrypted, protectedHeaders: opts.protectedHeaders })
|
|
120
|
+
: null;
|
|
121
|
+
if (menuControl.isMenu && menuClientCompatibilityError) {
|
|
122
|
+
await sendControlMenuResponse(menuFailure({ id: menuControl.id, ...(menuControl.name?.trim() ? { name: menuControl.name } : {}) }, menuClientCompatibilityError));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const protectedMenuHeaders = opts.protectedHeaders;
|
|
126
|
+
const hasRemoteAccessToken = protectedMenuHeaders?.auth_method === 'remote-access-token'
|
|
127
|
+
|| protectedMenuHeaders?.auth_method === 'ecweb-token';
|
|
128
|
+
if (text.toLowerCase() === '/pair' && !isProcessLevelOwner(opts.peerId, currentProcessOwners)) {
|
|
129
|
+
logger.debug(`控制 AID 收到非 owner 配对请求,忽略: from=${opts.peerId}`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (menuControl.isMenu && !isRoleMenu && !hasRemoteAccessToken && !isProcessLevelOwner(opts.peerId, currentProcessOwners)) {
|
|
133
|
+
const response = menuFailure({ id: menuControl.id, ...(menuControl.name?.trim() ? { name: menuControl.name } : {}) }, {
|
|
134
|
+
code: 'NOT_ALLOWED',
|
|
135
|
+
message: formatAuthorizationDenial('Control-channel process operations require DaemonOwner.', 'DAEMON_OWNER_REQUIRED'),
|
|
136
|
+
data: authorizationDenialData('DAEMON_OWNER_REQUIRED'),
|
|
137
|
+
});
|
|
138
|
+
await sendControlMenuResponse(response);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (!isRoleMenu && !hasRemoteAccessToken && !isProcessLevelOwner(opts.peerId, currentProcessOwners)) {
|
|
142
|
+
logger.debug(`控制 AID 收到非 owner 消息,忽略: from=${opts.peerId}`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (text.toLowerCase() === '/pair') {
|
|
146
|
+
const pair = remoteAccessService.getPairingCode(opts.peerId, Date.now(), 'pair');
|
|
147
|
+
const mins = Math.max(0, Math.round((pair.expiresAt - Date.now()) / 60000));
|
|
148
|
+
const reply = `远程控制配对码:${pair.code}(约 ${mins} 分钟内有效,仅可配对一次)`;
|
|
149
|
+
await controlChannel.sendMessage(opts.channelId, reply, controlReplyContext(opts));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (menuControl.isMenu) {
|
|
153
|
+
const validationError = validateMenuRequest(menuControl.request);
|
|
154
|
+
if (validationError) {
|
|
155
|
+
await sendControlMenuResponse(menuFailure({ id: menuControl.id, ...(menuControl.name?.trim() ? { name: menuControl.name } : {}) }, validationError));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const deduped = await controlMenuDeduper.execute([opts.channelId, opts.peerId, opts.peerDeviceId || '', menuControl.id].join('\u001f'), menuPayloadFingerprint(menuControl.raw), () => cmdHandler.execMenuForControl(parsed, opts.peerId, {
|
|
159
|
+
encrypted: opts.encrypted,
|
|
160
|
+
protectedHeaders: opts.protectedHeaders,
|
|
161
|
+
}));
|
|
162
|
+
if ('retry' in deduped) {
|
|
163
|
+
if (deduped.replayed) {
|
|
164
|
+
logger.warn(`[MenuControl] category=request-replay request=${menuControl.id}`
|
|
165
|
+
+ ` sender=${opts.peerId} replay=true`);
|
|
166
|
+
}
|
|
167
|
+
if (deduped.retry) {
|
|
168
|
+
logger.warn(`[MenuControl] category=request-retry request=${menuControl.id}`
|
|
169
|
+
+ ` sender=${opts.peerId} retry=true`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const response = 'conflict' in deduped
|
|
173
|
+
? menuFailure({ id: menuControl.id, ...(menuControl.name?.trim() ? { name: menuControl.name } : {}) }, { code: 'CONFLICT', message: 'Request ID was already used with a different payload' })
|
|
174
|
+
: deduped.value;
|
|
175
|
+
await sendControlMenuResponse(response);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
await controlChannel.sendMessage(opts.channelId, '可用指令:/pair(获取远程控制配对码)、/diag <问题描述>、/diag stop', controlReplyContext(opts));
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
if (activeMenuRequest && !menuCompletionLogged) {
|
|
182
|
+
logMenuRequestCompleted(activeMenuRequest, undefined, menuFlowContext, {
|
|
183
|
+
delivery: 'failed',
|
|
184
|
+
reason: 'handler-error',
|
|
185
|
+
transportError: e,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
logger.warn(`控制 AID 消息处理失败: ${e?.message || e}`);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
void controlConnectPromise;
|
|
192
|
+
const proxyServices = (daemonCfg.services ?? []).filter((service) => service.enabled === true && service.proxy?.enabled === true);
|
|
193
|
+
if (proxyServices.length > 0) {
|
|
194
|
+
const channel = controlChannel;
|
|
195
|
+
const aid = daemonCfg.aid;
|
|
196
|
+
const config = proxyServices;
|
|
197
|
+
setTimeout(() => {
|
|
198
|
+
const proxyHandle = startServiceProxy(channel, aid, config);
|
|
199
|
+
if (proxyHandle) {
|
|
200
|
+
onShutdown(() => proxyHandle.stop());
|
|
201
|
+
}
|
|
202
|
+
}, 500);
|
|
203
|
+
}
|
|
204
|
+
return controlChannel;
|
|
205
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { tryParseChannelKey } from './channel-loader.js';
|
|
2
|
+
import { replyContextFromSession } from './message/message-utils.js';
|
|
3
|
+
import { logger } from '../utils/logger.js';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import { disposeAgentInstances } from './runner-reload-transaction.js';
|
|
6
|
+
export function daemonIdleTimeoutMs(settings) {
|
|
7
|
+
const idleTimeoutSec = settings.idleMonitor?.timeout;
|
|
8
|
+
return typeof idleTimeoutSec === 'number' && Number.isFinite(idleTimeoutSec) && idleTimeoutSec > 0
|
|
9
|
+
? idleTimeoutSec * 1000
|
|
10
|
+
: 120_000;
|
|
11
|
+
}
|
|
12
|
+
export function daemonConversationWatchdogMs(settings) {
|
|
13
|
+
const idleMs = daemonIdleTimeoutMs(settings);
|
|
14
|
+
return Math.ceil(idleMs * 5 + 60_000);
|
|
15
|
+
}
|
|
16
|
+
export function daemonConversationTotalExecutionMs(settings) {
|
|
17
|
+
const seconds = settings.idleMonitor?.maxExecutionTime;
|
|
18
|
+
return typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0
|
|
19
|
+
? seconds * 1000
|
|
20
|
+
: undefined;
|
|
21
|
+
}
|
|
22
|
+
export async function cleanupStartupHandoffs(recoveryAids, handoffRuntime) {
|
|
23
|
+
for (const aid of recoveryAids) {
|
|
24
|
+
try {
|
|
25
|
+
const discarded = await handoffRuntime.discardExpired([aid]);
|
|
26
|
+
if (discarded > 0)
|
|
27
|
+
logger.info(`[Handoff] discarded ${discarded} expired handoff(s) for ${aid}`);
|
|
28
|
+
const expired = handoffRuntime.expireQueued([aid]);
|
|
29
|
+
if (expired > 0)
|
|
30
|
+
logger.info(`[Handoff] expired ${expired} stale queued delivery item(s) for ${aid}`);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
logger.error(`[Handoff] startup TTL cleanup failed for ${aid}:`, error);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export async function recoverStartupHandoffs({ channelInstances, recoveryAids, agentRegistry, queuePendingObserverHints, startupObserverRecoveries, handoffRuntime, setRestoringStartupQueue, startRestoredQueue, }) {
|
|
38
|
+
try {
|
|
39
|
+
for (const inst of channelInstances) {
|
|
40
|
+
if (inst.channelType !== 'aun')
|
|
41
|
+
continue;
|
|
42
|
+
const agent = agentRegistry.resolveByChannel(inst.adapter.channelKey);
|
|
43
|
+
if (agent && recoveryAids.includes(agent.aid)) {
|
|
44
|
+
startupObserverRecoveries.push(...queuePendingObserverHints(agent.aid, inst.adapter.channelKey));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
await Promise.all(startupObserverRecoveries);
|
|
48
|
+
const outcomes = await Promise.allSettled(recoveryAids.map(aid => handoffRuntime.recover([aid])));
|
|
49
|
+
outcomes.forEach((outcome, index) => {
|
|
50
|
+
if (outcome.status === 'rejected') {
|
|
51
|
+
logger.error(`[Handoff] startup recovery failed for ${recoveryAids[index]}:`, outcome.reason);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
setRestoringStartupQueue(false);
|
|
57
|
+
startupObserverRecoveries.length = 0;
|
|
58
|
+
startRestoredQueue();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export function resumePendingSessions({ sessionManager, messageQueue, droppedTriggerSessionIds, agentRegistry, agentMap, }) {
|
|
62
|
+
const pendingSessions = sessionManager.getPendingProcessingSessions();
|
|
63
|
+
if (pendingSessions.length === 0)
|
|
64
|
+
return;
|
|
65
|
+
logger.info(`[Resume] Found ${pendingSessions.length} pending session(s) from before restart`);
|
|
66
|
+
for (const session of pendingSessions) {
|
|
67
|
+
if (droppedTriggerSessionIds.has(session.id)) {
|
|
68
|
+
if (messageQueue.isProcessing(session.id) || messageQueue.getQueueLength(session.id) > 0) {
|
|
69
|
+
logger.info(`[Resume] session ${session.id}: Trigger scheduler already reclaimed execution, skipping generic resume`);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
logger.info(`[Resume] session ${session.id}: dropped stale Trigger execution was not reclaimable, clearing processing`);
|
|
73
|
+
sessionManager.clearProcessing(session.id);
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (messageQueue.isProcessing(session.id) || messageQueue.getQueueLength(session.id) > 0) {
|
|
78
|
+
logger.info(`[Resume] session ${session.id}: persisted queue already restored, skipping generic resume`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (!session.agentSessionId) {
|
|
82
|
+
sessionManager.clearProcessing(session.id);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const owningAgent = agentRegistry.resolveByChannel(session.channel);
|
|
86
|
+
if (!owningAgent) {
|
|
87
|
+
logger.warn(`[Resume] session ${session.id}: channel "${session.channel}" not routable, skipping`);
|
|
88
|
+
sessionManager.clearProcessing(session.id);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const evolName = owningAgent.aid;
|
|
92
|
+
const baseagentName = typeof session.baseagent === 'string' ? session.baseagent.trim() : '';
|
|
93
|
+
if (!baseagentName) {
|
|
94
|
+
logger.error(`[Resume] session ${session.id}: missing baseagent binding, refusing implicit runner fallback`);
|
|
95
|
+
sessionManager.clearProcessing(session.id);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const agent = agentMap.get(`${evolName}::${baseagentName}`);
|
|
99
|
+
if (!agent) {
|
|
100
|
+
logger.error(`[Resume] session ${session.id}: runner unavailable for ${evolName}::${baseagentName}; refusing fallback`);
|
|
101
|
+
sessionManager.clearProcessing(session.id);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
logger.info(`[Resume] Resuming session: ${session.id} (agent: ${evolName}::${baseagentName})`);
|
|
105
|
+
const parsedResumeKey = tryParseChannelKey(session.channel);
|
|
106
|
+
const resumeSelfAID = session.selfAID || parsedResumeKey?.selfAID;
|
|
107
|
+
const resumeMessage = {
|
|
108
|
+
channel: session.channel,
|
|
109
|
+
channelType: session.channelType || parsedResumeKey?.type,
|
|
110
|
+
selfAID: resumeSelfAID,
|
|
111
|
+
channelId: session.channelId,
|
|
112
|
+
groupId: session.chatType === 'group' ? (session.metadata?.groupId || session.channelId) : undefined,
|
|
113
|
+
chatType: session.chatType === 'group' ? 'group' : 'private',
|
|
114
|
+
content: '服务已重启,请继续之前未完成的任务。',
|
|
115
|
+
timestamp: Date.now(),
|
|
116
|
+
peerId: '',
|
|
117
|
+
threadId: session.threadId || undefined,
|
|
118
|
+
replyContext: replyContextFromSession(session),
|
|
119
|
+
};
|
|
120
|
+
sessionManager.clearProcessing(session.id);
|
|
121
|
+
messageQueue.enqueue(session.id, resumeMessage, session.projectPath, {
|
|
122
|
+
sessionKeyField: session.sessionKey,
|
|
123
|
+
selfAID: resumeSelfAID,
|
|
124
|
+
}).catch(error => {
|
|
125
|
+
logger.error(`[Resume] Failed to resume session ${session.id}:`, error);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
export function createDaemonShutdown({ ipcServer, readySignalPath, bindService, ecwebSupervisor, messageQueue, permissionGateway, removePermissionConfigListener, stopStatsPersistence, triggerSchedulers, agentMap, eventBus, removeGroupInviteConfigListener, groupInviteReviewService, channelLoader, channelInstances, controlChannel, sessionManager, removeAll, exit, }) {
|
|
130
|
+
let shutdownSignal = 'unknown';
|
|
131
|
+
let shutdownInProgress = false;
|
|
132
|
+
return async (signal) => {
|
|
133
|
+
if (shutdownInProgress)
|
|
134
|
+
return;
|
|
135
|
+
shutdownInProgress = true;
|
|
136
|
+
if (signal)
|
|
137
|
+
shutdownSignal = signal;
|
|
138
|
+
const pid = process.pid;
|
|
139
|
+
const ppid = process.ppid;
|
|
140
|
+
logger.info(`\n\nShutting down gracefully... (signal=${shutdownSignal}, pid=${pid}, ppid=${ppid})`);
|
|
141
|
+
ipcServer.stopCpuTracking();
|
|
142
|
+
ipcServer.stop();
|
|
143
|
+
try {
|
|
144
|
+
fs.unlinkSync(readySignalPath);
|
|
145
|
+
}
|
|
146
|
+
catch { }
|
|
147
|
+
bindService?.stopCleanup();
|
|
148
|
+
const shutdownStep = async (label, operation) => {
|
|
149
|
+
try {
|
|
150
|
+
await operation();
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
logger.error(`[Shutdown] ${label} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
try {
|
|
157
|
+
await shutdownStep('stop ECWeb supervisor', async () => {
|
|
158
|
+
await ecwebSupervisor?.stop();
|
|
159
|
+
});
|
|
160
|
+
const queueShutdown = messageQueue.shutdownGracefully();
|
|
161
|
+
await shutdownStep('cancel pending permissions', () => permissionGateway.cancelAllPending('daemon_restart'));
|
|
162
|
+
await shutdownStep('wait for trigger schedulers', async () => {
|
|
163
|
+
await triggerSchedulers.waitForIdle(5_000);
|
|
164
|
+
});
|
|
165
|
+
await shutdownStep('suspend trigger schedulers', async () => {
|
|
166
|
+
await triggerSchedulers.suspendForDaemonExit();
|
|
167
|
+
});
|
|
168
|
+
await shutdownStep('interrupt active message queue tasks', async () => { await queueShutdown; });
|
|
169
|
+
await shutdownStep('dispose agent runners', async () => {
|
|
170
|
+
const currentInstances = [...agentMap.entries()].map(([key, agent]) => {
|
|
171
|
+
const split = key.lastIndexOf('::');
|
|
172
|
+
return {
|
|
173
|
+
evolagentName: split > 0 ? key.slice(0, split) : '<unknown>',
|
|
174
|
+
baseagent: split > 0 ? key.slice(split + 2) : 'unknown',
|
|
175
|
+
agent,
|
|
176
|
+
};
|
|
177
|
+
});
|
|
178
|
+
await disposeAgentInstances(currentInstances, (instance, error) => {
|
|
179
|
+
logger.warn(`[Shutdown] Failed to dispose runner ${instance.evolagentName}::${instance.baseagent}: ${error instanceof Error ? error.message : String(error)}`);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
eventBus.publish({ type: 'system:shutdown', timestamp: Date.now() });
|
|
183
|
+
removeGroupInviteConfigListener();
|
|
184
|
+
await shutdownStep('stop group invitation review', () => groupInviteReviewService.dispose());
|
|
185
|
+
await shutdownStep('stop global skill watcher', async () => {
|
|
186
|
+
const { stopGlobalSkillWatcher } = await import('./skill/global-watcher.js');
|
|
187
|
+
await stopGlobalSkillWatcher();
|
|
188
|
+
});
|
|
189
|
+
await shutdownStep('disconnect channels', () => channelLoader.disconnectAll(channelInstances));
|
|
190
|
+
for (const instance of channelInstances) {
|
|
191
|
+
const type = instance.channelType || instance.adapter.channelName;
|
|
192
|
+
eventBus.publish({
|
|
193
|
+
type: 'channel:disconnected',
|
|
194
|
+
channel: type,
|
|
195
|
+
channelName: instance.adapter.channelName,
|
|
196
|
+
reason: 'shutdown',
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
if (controlChannel) {
|
|
200
|
+
await shutdownStep('disconnect control AID', () => controlChannel?.disconnect());
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
logger.error(`[Shutdown] Unexpected shutdown failure: ${error instanceof Error ? error.stack || error.message : String(error)}`);
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
await shutdownStep('remove permission config listener', () => removePermissionConfigListener?.());
|
|
208
|
+
await shutdownStep('stop stats persistence', () => stopStatsPersistence?.());
|
|
209
|
+
try {
|
|
210
|
+
sessionManager.close();
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
logger.error(`[Shutdown] session manager close failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
removeAll();
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
logger.error(`[Shutdown] instance cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
220
|
+
}
|
|
221
|
+
logger.info('✓ Shutdown complete');
|
|
222
|
+
exit(0);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
}
|
|
@@ -1197,7 +1197,10 @@ export function finalizeDataMigration(root, manifest) {
|
|
|
1197
1197
|
}
|
|
1198
1198
|
for (const source of liveSources)
|
|
1199
1199
|
fs.rmSync(source, { recursive: true, force: false });
|
|
1200
|
-
|
|
1200
|
+
const discardedSources = manifest.operations
|
|
1201
|
+
.filter(operation => operation.kind === 'discard')
|
|
1202
|
+
.map(operation => operation.source);
|
|
1203
|
+
pruneEmptyLegacyParents(root, [...absoluteSources, ...discardedSources]);
|
|
1201
1204
|
const finalization = {
|
|
1202
1205
|
schemaVersion: 1,
|
|
1203
1206
|
migrationId: manifest.id,
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { isManagementRole } from '../../config/builtin-roles.js';
|
|
2
|
+
import { hasTrustedFullAccessDelegation, trustedExecutionIdentityInput, } from '../auth/agent-delegation.js';
|
|
3
|
+
import { authorizeOperation, buildAuthSubject } from '../auth/auth-gateway.js';
|
|
4
|
+
import { parsePeerKey } from '../relation/peer-identity.js';
|
|
5
|
+
export function registerHandoffIpc({ ipcServer, agentDelegationRegistry, responseEngine, sessionManager, agentRegistry, handoffRuntime, getProcessOwners, }) {
|
|
6
|
+
const resolveManagedHandoffScope = async (params, operation) => {
|
|
7
|
+
const delegation = agentDelegationRegistry.validate(params.delegationToken, params.sessionId, params.delegationCommandHash);
|
|
8
|
+
if (!delegation.ok)
|
|
9
|
+
return { ok: false, code: delegation.code, error: delegation.reason };
|
|
10
|
+
const runtime = responseEngine.getTaskRuntimeContext(params.sessionId);
|
|
11
|
+
const session = await sessionManager.getSessionById(params.sessionId);
|
|
12
|
+
const selfAid = runtime?.selfAid || session?.selfAID;
|
|
13
|
+
if (!selfAid || !session) {
|
|
14
|
+
return { ok: false, code: 'HANDOFF_CALL_SESSION_INVALID', error: 'current session is invalid' };
|
|
15
|
+
}
|
|
16
|
+
if (delegation.grant.selfAid !== selfAid) {
|
|
17
|
+
return { ok: false, code: 'INVALID_DELEGATION', error: 'delegation self agent does not match current session' };
|
|
18
|
+
}
|
|
19
|
+
if (params.agent && params.agent !== selfAid && !hasTrustedFullAccessDelegation(delegation.grant)) {
|
|
20
|
+
return { ok: false, code: 'HANDOFF_AGENT_SCOPE_MISMATCH', error: 'agent does not match the current session' };
|
|
21
|
+
}
|
|
22
|
+
let conversationId;
|
|
23
|
+
try {
|
|
24
|
+
conversationId = parsePeerKey(delegation.grant.peerKey).channelId;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return { ok: false, code: 'INVALID_DELEGATION', error: 'delegation contains an invalid relation key' };
|
|
28
|
+
}
|
|
29
|
+
const subject = buildAuthSubject({
|
|
30
|
+
selfAid,
|
|
31
|
+
actorId: delegation.grant.actorId,
|
|
32
|
+
channel: delegation.grant.channel,
|
|
33
|
+
channelType: delegation.grant.channelType,
|
|
34
|
+
channelId: conversationId,
|
|
35
|
+
chatType: delegation.grant.chatType,
|
|
36
|
+
conversationId,
|
|
37
|
+
processOwners: getProcessOwners(),
|
|
38
|
+
...trustedExecutionIdentityInput(delegation.grant),
|
|
39
|
+
});
|
|
40
|
+
const managementRole = isManagementRole(subject.role) || subject.dataScope === 'daemon';
|
|
41
|
+
const decision = authorizeOperation({
|
|
42
|
+
source: 'agent-tool',
|
|
43
|
+
subject,
|
|
44
|
+
intent: {
|
|
45
|
+
operation,
|
|
46
|
+
scope: managementRole ? 'agent' : 'relation',
|
|
47
|
+
source: 'agent-tool',
|
|
48
|
+
args: {
|
|
49
|
+
self: selfAid,
|
|
50
|
+
peer: conversationId,
|
|
51
|
+
peerKey: delegation.grant.peerKey,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
auditMetadata: { taskId: delegation.grant.taskId, messageId: delegation.grant.messageId },
|
|
55
|
+
});
|
|
56
|
+
if (!decision.allow) {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
code: decision.command?.code ?? 'ROLE_ACCESS_DENIED',
|
|
60
|
+
error: decision.reason,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
ok: true,
|
|
65
|
+
aid: selfAid,
|
|
66
|
+
currentSessionId: params.sessionId,
|
|
67
|
+
managementRole,
|
|
68
|
+
role: subject.role,
|
|
69
|
+
actorId: delegation.grant.actorId,
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
const resolveHandoffQueryScope = async (params, operation) => {
|
|
73
|
+
if (params.sessionId) {
|
|
74
|
+
return resolveManagedHandoffScope({ ...params, sessionId: params.sessionId }, operation);
|
|
75
|
+
}
|
|
76
|
+
if (!params.agent) {
|
|
77
|
+
return { ok: false, code: 'HANDOFF_AGENT_REQUIRED', error: '--agent is required outside a task context' };
|
|
78
|
+
}
|
|
79
|
+
if (!agentRegistry.get(params.agent)) {
|
|
80
|
+
return { ok: false, code: 'HANDOFF_AGENT_NOT_FOUND', error: `agent not found: ${params.agent}` };
|
|
81
|
+
}
|
|
82
|
+
return { ok: true, aid: params.agent, currentSessionId: undefined, managementRole: true };
|
|
83
|
+
};
|
|
84
|
+
const returnHandoff = async (params) => {
|
|
85
|
+
if (!params.sessionId)
|
|
86
|
+
return responseEngine.returnHandoffResult(params);
|
|
87
|
+
const scope = await resolveManagedHandoffScope({ ...params, sessionId: params.sessionId }, 'ec.handoff.return');
|
|
88
|
+
if (!scope.ok)
|
|
89
|
+
return scope;
|
|
90
|
+
return responseEngine.returnAuthorizedHandoffResult({ sessionId: params.sessionId, handoffId: params.handoffId, content: params.content }, scope.aid);
|
|
91
|
+
};
|
|
92
|
+
const recoverHandoff = async (params) => {
|
|
93
|
+
if (!params.sessionId)
|
|
94
|
+
return { ok: false, code: 'HANDOFF_CALL_SESSION_INVALID', error: 'current session is required' };
|
|
95
|
+
const scope = await resolveManagedHandoffScope({ ...params, sessionId: params.sessionId }, 'ec.handoff.recover');
|
|
96
|
+
if (!scope.ok)
|
|
97
|
+
return scope;
|
|
98
|
+
if (scope.role !== 'owner') {
|
|
99
|
+
return { ok: false, code: 'ROLE_ACCESS_DENIED', error: 'handoff recovery requires the current Agent owner' };
|
|
100
|
+
}
|
|
101
|
+
return responseEngine.recoverAuthorizedHandoffResult({
|
|
102
|
+
sessionId: params.sessionId,
|
|
103
|
+
handoffId: params.handoffId,
|
|
104
|
+
content: params.content,
|
|
105
|
+
actorId: scope.actorId,
|
|
106
|
+
}, scope.aid);
|
|
107
|
+
};
|
|
108
|
+
const statusHandoff = async (params) => {
|
|
109
|
+
const scope = await resolveHandoffQueryScope(params, 'ec.handoff.status');
|
|
110
|
+
if (!scope.ok)
|
|
111
|
+
return scope;
|
|
112
|
+
const status = handoffRuntime.status(scope.aid, params.handoffId);
|
|
113
|
+
if (!status)
|
|
114
|
+
return { ok: false, code: 'HANDOFF_NOT_FOUND', error: 'handoff not found' };
|
|
115
|
+
if (!scope.managementRole
|
|
116
|
+
&& status.origin_session_id !== scope.currentSessionId
|
|
117
|
+
&& status.target_session_id !== scope.currentSessionId) {
|
|
118
|
+
return { ok: false, code: 'HANDOFF_NOT_FOUND', error: 'handoff not found in the current session' };
|
|
119
|
+
}
|
|
120
|
+
return status;
|
|
121
|
+
};
|
|
122
|
+
const listHandoff = async (params) => {
|
|
123
|
+
const scope = await resolveHandoffQueryScope(params, 'ec.handoff.list');
|
|
124
|
+
if (!scope.ok)
|
|
125
|
+
return scope;
|
|
126
|
+
if (!scope.managementRole && params.filterSessionId && params.filterSessionId !== scope.currentSessionId) {
|
|
127
|
+
return { ok: false, code: 'HANDOFF_SESSION_SCOPE_MISMATCH', error: 'only the current session can be queried' };
|
|
128
|
+
}
|
|
129
|
+
return handoffRuntime.listHandoffs({
|
|
130
|
+
selfAid: scope.aid,
|
|
131
|
+
state: params.state,
|
|
132
|
+
sessionId: scope.managementRole ? params.filterSessionId : scope.currentSessionId,
|
|
133
|
+
limit: params.limit,
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
const traceHandoff = async (params) => {
|
|
137
|
+
const scope = await resolveHandoffQueryScope(params, 'ec.handoff.trace');
|
|
138
|
+
if (!scope.ok)
|
|
139
|
+
return scope;
|
|
140
|
+
if (!scope.managementRole) {
|
|
141
|
+
const status = handoffRuntime.status(scope.aid, params.handoffId);
|
|
142
|
+
if (!status
|
|
143
|
+
|| (status.origin_session_id !== scope.currentSessionId
|
|
144
|
+
&& status.target_session_id !== scope.currentSessionId)) {
|
|
145
|
+
return { ok: false, code: 'HANDOFF_NOT_FOUND', error: 'handoff not found in the current session' };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return handoffRuntime.traceHandoff(scope.aid, params.handoffId, params.limit)
|
|
149
|
+
?? { ok: false, code: 'HANDOFF_NOT_FOUND', error: 'handoff not found' };
|
|
150
|
+
};
|
|
151
|
+
ipcServer.setHandoffReturnExecutor(returnHandoff);
|
|
152
|
+
ipcServer.setHandoffRecoverReturnExecutor(recoverHandoff);
|
|
153
|
+
ipcServer.setHandoffStatusExecutor(statusHandoff);
|
|
154
|
+
ipcServer.setHandoffListExecutor(listHandoff);
|
|
155
|
+
ipcServer.setHandoffTraceExecutor(traceHandoff);
|
|
156
|
+
}
|