evolcore 0.0.11 → 0.0.12
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/bin/codex-managed-hook.mjs +80 -0
- package/dist/agents/claude-runner.js +35 -2
- package/dist/agents/codex-app-server-client.js +120 -14
- package/dist/agents/codex-runner.js +273 -35
- package/dist/agents/ecagent-runner.js +36 -4
- package/dist/agents/gemini-runner.js +1 -0
- package/dist/aun/aid/client.js +11 -21
- package/dist/aun/aid/managed-operation.js +107 -0
- package/dist/aun/msg/group.js +81 -12
- package/dist/aun/msg/managed-operation.js +804 -0
- package/dist/aun/msg/mention-schema.js +20 -0
- package/dist/aun/msg/p2p.js +7 -0
- package/dist/channels/aun.js +288 -108
- package/dist/channels/daemon.js +13 -0
- package/dist/cli/agent-command.js +56 -8
- package/dist/cli/agent.js +170 -6
- package/dist/cli/aun-commands.js +270 -69
- package/dist/cli/bench.js +12 -3
- package/dist/cli/daemon-commands.js +31 -31
- package/dist/cli/fs-command.js +60 -0
- package/dist/cli/handoff-command.js +3 -0
- package/dist/cli/index.js +30 -2
- package/dist/cli/managed-command-guard.js +41 -0
- package/dist/cli/model.js +11 -4
- package/dist/cli/queue-command.js +11 -1
- package/dist/cli/response.js +71 -0
- package/dist/cli/restart-monitor.js +4 -18
- package/dist/cli/trigger-command.js +4 -0
- package/dist/config/builtin-roles.js +4 -0
- package/dist/config/contact-book-store.js +11 -3
- package/dist/config/contact-request-service.js +161 -15
- package/dist/config/mention-mode.js +8 -24
- package/dist/core/auth/agent-delegation.js +0 -1
- package/dist/core/auth/operation-authorizer.js +30 -7
- package/dist/core/auth/operation-catalog.js +187 -25
- package/dist/core/bootstrap-messages.js +18 -0
- package/dist/core/bootstrap-service.js +63 -4
- package/dist/core/capability/providers/claude-capability-provider.js +9 -31
- package/dist/core/capability/providers/codex-capability-provider.js +4 -15
- package/dist/core/capability/skill-discovery.js +55 -0
- package/dist/core/channel-loader.js +1 -1
- package/dist/core/command/cli-intent-parser.js +9 -0
- package/dist/core/command/command-handler.js +467 -6
- package/dist/core/command/connect-menu.js +15 -0
- package/dist/core/command/group-menu.js +4 -4
- package/dist/core/command/menu-catalog.js +366 -0
- package/dist/core/command/menu-handler.js +147 -35
- package/dist/core/command/menu-protocol.js +71 -0
- package/dist/core/command/slash-gate.js +5 -2
- package/dist/core/command/slash-handler.js +41 -21
- package/dist/core/daemon-file-cache.js +2 -1
- package/dist/core/data-migration.js +9 -2
- package/dist/core/evolagent.js +4 -2
- package/dist/core/handoff/dispatcher.js +5 -2
- package/dist/core/handoff/runtime.js +145 -21
- package/dist/core/handoff/store.js +24 -0
- package/dist/core/handoff/types.js +1 -0
- package/dist/core/message/logical-queue-bridge.js +1 -0
- package/dist/core/message/mention-schema.js +126 -0
- package/dist/core/message/message-bridge.js +236 -78
- package/dist/core/message/message-log.js +1 -0
- package/dist/core/message/message-queue.js +1 -0
- package/dist/core/message/response-engine.js +54 -36
- package/dist/core/permission/ec-command-parser.js +156 -10
- package/dist/core/permission/sandbox-runtime.js +22 -1
- package/dist/core/permission/tool-policy.js +73 -30
- package/dist/core/protected-paths.js +7 -2
- package/dist/core/session/session-manager.js +21 -2
- package/dist/eck/manifest-engine.js +39 -13
- package/dist/index.js +384 -98
- package/dist/ipc.js +218 -9
- package/dist/response-system/coordinator.js +6 -4
- package/dist/response-system/engines/v1/proactive-flow.js +26 -8
- package/dist/response-system/modes/single-session/index.js +14 -1
- package/dist/response-system/selector.js +6 -5
- package/dist/trigger/anomaly-store.js +4 -0
- package/dist/trigger/script-executor.js +1 -0
- package/dist/utils/codex-app-server-registry.js +90 -0
- package/dist/utils/codex-cli.js +5 -1
- package/dist/utils/cross-platform.js +15 -0
- package/dist/utils/npm-ops.js +5 -12
- package/dist/utils/tool-summary.js +11 -0
- package/kits/docs/channels/aun.md +1 -1
- package/kits/docs/context-assembly.md +2 -2
- package/kits/docs/evolcore/agent.md +11 -6
- package/kits/docs/evolcore/aid.md +14 -0
- package/kits/docs/prompt-loading-architecture.md +1 -2
- package/kits/docs/venues/group.md +1 -1
- package/kits/eck_manifest.json +0 -12
- package/kits/rules/04-relation.md +1 -1
- package/kits/rules/05-venue.md +2 -2
- package/kits/schemas/single-session.schema.1.json +3 -3
- package/kits/schemas/single-session.schema.2.json +3 -3
- package/kits/templates/roles/admin.json +22 -0
- package/kits/templates/roles/member.json +27 -0
- package/kits/templates/roles/visitor.json +33 -3
- package/kits/templates/system-fragments/bootstrap.md +1 -1
- package/kits/templates/system-fragments/channel.md +1 -1
- package/kits/templates/system-fragments/session.md +1 -1
- package/package.json +2 -2
- package/dist/core/command/evol-menu-version-gate.js +0 -39
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* - 只缓存"文件 → 解析后内容",不缓存按 vars 渲染后的结果。
|
|
11
11
|
*
|
|
12
12
|
* 策略(reload/重启永远全量失效,无视策略;策略只决定"平时每次读怎么检查"):
|
|
13
|
-
* - on-reload:平时不检查,直接用缓存(kits
|
|
13
|
+
* - on-reload:平时不检查,直接用缓存(kits 文件)。靠 reload/重启刷新。
|
|
14
14
|
* - manual:同 on-reload,额外支持显式 invalidate(file) 单刷。
|
|
15
15
|
* - mtime:每次读 statSync 门控 size + mtime,变了自动重读(working.md、config.json)。
|
|
16
16
|
*
|
|
@@ -35,6 +35,7 @@ function newCounters() {
|
|
|
35
35
|
*/
|
|
36
36
|
const GROUP_CAPS = {
|
|
37
37
|
'relation-prefs': 512,
|
|
38
|
+
'eck-runtime': 512,
|
|
38
39
|
};
|
|
39
40
|
export class FileCache {
|
|
40
41
|
cache = new Map();
|
|
@@ -1303,7 +1303,10 @@ function verifyArchiveContains(archivePath, sourcePaths) {
|
|
|
1303
1303
|
.replace(/\/+$/, '');
|
|
1304
1304
|
let entries;
|
|
1305
1305
|
try {
|
|
1306
|
-
entries = new Set(execFileSync(tar, ['-tzf', archivePath], {
|
|
1306
|
+
entries = new Set(execFileSync(tar, ['-tzf', archivePath], {
|
|
1307
|
+
encoding: 'utf8',
|
|
1308
|
+
windowsHide: process.platform === 'win32',
|
|
1309
|
+
})
|
|
1307
1310
|
.split('\n')
|
|
1308
1311
|
.map(normalizeArchivePath)
|
|
1309
1312
|
.filter(Boolean));
|
|
@@ -1401,7 +1404,11 @@ export function finalizeDataMigration(root, manifest) {
|
|
|
1401
1404
|
const temporary = `${archivePath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
|
|
1402
1405
|
const tar = process.platform === 'win32' ? 'tar.exe' : 'tar';
|
|
1403
1406
|
try {
|
|
1404
|
-
execFileSync(tar, ['-czf', temporary, ...sourcePaths], {
|
|
1407
|
+
execFileSync(tar, ['-czf', temporary, ...sourcePaths], {
|
|
1408
|
+
cwd: root,
|
|
1409
|
+
stdio: 'pipe',
|
|
1410
|
+
windowsHide: process.platform === 'win32',
|
|
1411
|
+
});
|
|
1405
1412
|
verifyArchiveContains(temporary, sourcePaths);
|
|
1406
1413
|
fs.renameSync(temporary, archivePath);
|
|
1407
1414
|
}
|
package/dist/core/evolagent.js
CHANGED
|
@@ -276,7 +276,8 @@ export class EvolAgent {
|
|
|
276
276
|
*/
|
|
277
277
|
getPersona() {
|
|
278
278
|
const personaPath = path.join(agentPersonalDir(this.aid), 'persona.md');
|
|
279
|
-
|
|
279
|
+
const raw = fileCache.getText(personaPath, { policy: 'mtime', group: this.agentFilesGroup() });
|
|
280
|
+
return raw === null ? null : (raw.trim() || null);
|
|
280
281
|
}
|
|
281
282
|
/**
|
|
282
283
|
* 读取 personal/memory/working.md 内容。走 fileCache(mtime 门控):
|
|
@@ -285,7 +286,8 @@ export class EvolAgent {
|
|
|
285
286
|
*/
|
|
286
287
|
getWorkingMemory() {
|
|
287
288
|
const workingPath = path.join(agentPersonalDir(this.aid), 'memory', 'working.md');
|
|
288
|
-
|
|
289
|
+
const raw = fileCache.getText(workingPath, { policy: 'mtime', group: this.agentFilesGroup() });
|
|
290
|
+
return raw === null ? null : (raw.trim() || null);
|
|
289
291
|
}
|
|
290
292
|
/** 清除本 agent 身份层缓存(reload 后重新读取)。只失效自己的文件组,不波及他人。 */
|
|
291
293
|
invalidatePersonaCache() {
|
|
@@ -117,8 +117,9 @@ export class HandoffDispatcher {
|
|
|
117
117
|
if (this.pausedAgents.has(selfAid))
|
|
118
118
|
return false;
|
|
119
119
|
const current = this.store.get(selfAid, next.handoff_id);
|
|
120
|
-
if (!current || current.state !== 'queued' || current.attention_required)
|
|
121
|
-
return current?.state === 'target_sent';
|
|
120
|
+
if (!current || current.state !== 'queued' || current.attention_required) {
|
|
121
|
+
return current?.state === 'target_sent' || current?.state === 'discarded';
|
|
122
|
+
}
|
|
122
123
|
this.store.recordSendStarted(selfAid, current.handoff_id);
|
|
123
124
|
try {
|
|
124
125
|
const send = await this.sendWithinQueueDeadline(current);
|
|
@@ -171,6 +172,8 @@ export class HandoffDispatcher {
|
|
|
171
172
|
await new Promise(resolve => setTimeout(resolve, this.retryDelaysMs[Math.min(attempt - 1, this.retryDelaysMs.length - 1)] ?? 0));
|
|
172
173
|
}
|
|
173
174
|
}
|
|
175
|
+
if (this.store.get(selfAid, next.handoff_id)?.state === 'discarded')
|
|
176
|
+
continue;
|
|
174
177
|
if (!sent) {
|
|
175
178
|
this.store.recordSendExhausted(selfAid, next.handoff_id);
|
|
176
179
|
logger.error(`[Handoff] target send retries exhausted: ${next.handoff_id}`);
|
|
@@ -7,6 +7,7 @@ import { createRootCausation, deriveCausation, normalizeCausation } from '../cau
|
|
|
7
7
|
import { recordCausationSpan } from '../causation/audit.js';
|
|
8
8
|
import { logger } from '../../utils/logger.js';
|
|
9
9
|
const HANDOFF_STALE_WARN_MS = 72 * 60 * 60 * 1000;
|
|
10
|
+
const HANDOFF_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
10
11
|
const HANDOFF_LARGE_AGGREGATE_COUNT = 10;
|
|
11
12
|
const HANDOFF_LARGE_AGGREGATE_CHARS = 50_000;
|
|
12
13
|
export class HandoffTargetBlockedError extends Error {
|
|
@@ -43,6 +44,15 @@ export class TriggerHandoffRequiredForbiddenError extends Error {
|
|
|
43
44
|
this.name = 'TriggerHandoffRequiredForbiddenError';
|
|
44
45
|
}
|
|
45
46
|
}
|
|
47
|
+
export class HandoffSessionReplacedError extends Error {
|
|
48
|
+
sessionId;
|
|
49
|
+
code = 'HANDOFF_SESSION_REPLACED';
|
|
50
|
+
constructor(sessionId) {
|
|
51
|
+
super(`handoff session was replaced during creation: ${sessionId}`);
|
|
52
|
+
this.sessionId = sessionId;
|
|
53
|
+
this.name = 'HandoffSessionReplacedError';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
46
56
|
export class HandoffRuntime {
|
|
47
57
|
sessionManager;
|
|
48
58
|
messageQueue;
|
|
@@ -52,6 +62,8 @@ export class HandoffRuntime {
|
|
|
52
62
|
now;
|
|
53
63
|
staleWarned = new Set();
|
|
54
64
|
largeAggregateWarned = new Set();
|
|
65
|
+
lifecycleSequence = 0;
|
|
66
|
+
sessionDiscardSequence = new Map();
|
|
55
67
|
constructor(sessionManager, messageQueue, sender, store = new HandoffStore(), options = {}) {
|
|
56
68
|
this.sessionManager = sessionManager;
|
|
57
69
|
this.messageQueue = messageQueue;
|
|
@@ -60,6 +72,7 @@ export class HandoffRuntime {
|
|
|
60
72
|
this.dispatcher = new HandoffDispatcher(store, this.mutexes, sender, options);
|
|
61
73
|
}
|
|
62
74
|
async createOutbound(input) {
|
|
75
|
+
const operationSequence = ++this.lifecycleSequence;
|
|
63
76
|
throwIfAborted(input.signal);
|
|
64
77
|
const origin = await this.sessionManager.getSessionById(input.originSessionId);
|
|
65
78
|
throwIfAborted(input.signal);
|
|
@@ -81,24 +94,29 @@ export class HandoffRuntime {
|
|
|
81
94
|
if (input.explicitReturnPolicy === 'required' && input.causation?.trigger?.path.length) {
|
|
82
95
|
throw new TriggerHandoffRequiredForbiddenError();
|
|
83
96
|
}
|
|
84
|
-
await this.dispatcher.expireTargetQueued(input.selfAid, target.id);
|
|
85
|
-
throwIfAborted(input.signal);
|
|
86
|
-
const head = this.store.listByTarget(input.selfAid, target.id, 'queued')[0];
|
|
87
|
-
if (head?.attention_required) {
|
|
88
|
-
throw new HandoffTargetBlockedError(head.handoff_id, head.attention_reason ?? 'UNKNOWN_ATTENTION');
|
|
89
|
-
}
|
|
90
97
|
const handoffCausation = deriveCausation(normalizeCausation(input.causation) ?? createRootCausation());
|
|
91
|
-
const handoff = this.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
98
|
+
const handoff = await this.mutexes.forKey(this.targetKey(input.selfAid, target.id)).runExclusive(async () => {
|
|
99
|
+
const replacedSessionId = [origin.id, target.id].find(sessionId => ((this.sessionDiscardSequence.get(this.sessionKey(input.selfAid, sessionId)) ?? 0) > operationSequence));
|
|
100
|
+
if (replacedSessionId)
|
|
101
|
+
throw new HandoffSessionReplacedError(replacedSessionId);
|
|
102
|
+
this.dispatcher.expireQueued(input.selfAid, target.id);
|
|
103
|
+
throwIfAborted(input.signal);
|
|
104
|
+
const head = this.store.listByTarget(input.selfAid, target.id, 'queued')[0];
|
|
105
|
+
if (head?.attention_required) {
|
|
106
|
+
throw new HandoffTargetBlockedError(head.handoff_id, head.attention_reason ?? 'UNKNOWN_ATTENTION');
|
|
107
|
+
}
|
|
108
|
+
return this.store.create({
|
|
109
|
+
selfAid: input.selfAid,
|
|
110
|
+
originSessionId: origin.id,
|
|
111
|
+
originMessageId: input.originMessageId,
|
|
112
|
+
targetSessionId: target.id,
|
|
113
|
+
payload: input.payload,
|
|
114
|
+
encrypt: input.encrypt,
|
|
115
|
+
returnPolicy: input.explicitReturnPolicy ?? 'required',
|
|
116
|
+
originAuthorization: input.originAuthorization,
|
|
117
|
+
causation: handoffCausation,
|
|
118
|
+
now: this.now?.(),
|
|
119
|
+
});
|
|
102
120
|
});
|
|
103
121
|
recordCausationSpan(handoffCausation, 'handoff.request', {
|
|
104
122
|
status: 'started',
|
|
@@ -106,6 +124,10 @@ export class HandoffRuntime {
|
|
|
106
124
|
});
|
|
107
125
|
await this.dispatcher.drain(input.selfAid, target.id);
|
|
108
126
|
const dispatched = this.store.get(input.selfAid, handoff.handoff_id) ?? handoff;
|
|
127
|
+
const replacedSessionId = [origin.id, target.id].find(sessionId => ((this.sessionDiscardSequence.get(this.sessionKey(input.selfAid, sessionId)) ?? 0) > operationSequence));
|
|
128
|
+
if (dispatched.state === 'discarded' || replacedSessionId) {
|
|
129
|
+
throw new HandoffSessionReplacedError(replacedSessionId ?? target.id);
|
|
130
|
+
}
|
|
109
131
|
if (dispatched.state === 'failed') {
|
|
110
132
|
throw new HandoffDeliveryFailedError(dispatched.handoff_id);
|
|
111
133
|
}
|
|
@@ -142,7 +164,9 @@ export class HandoffRuntime {
|
|
|
142
164
|
}
|
|
143
165
|
markBindingIncomplete(selfAid, handoffId) {
|
|
144
166
|
const instance = this.store.get(selfAid, handoffId);
|
|
145
|
-
if (instance
|
|
167
|
+
if (instance
|
|
168
|
+
&& (instance.state === 'target_context_pending' || instance.state === 'return_pending')
|
|
169
|
+
&& !instance.attention_required) {
|
|
146
170
|
this.store.markAttention(selfAid, handoffId, 'REPLY_BINDING_INCOMPLETE');
|
|
147
171
|
}
|
|
148
172
|
}
|
|
@@ -207,16 +231,31 @@ export class HandoffRuntime {
|
|
|
207
231
|
role: origin.chatType === 'group' ? (origin.identity?.role ?? 'none') : undefined,
|
|
208
232
|
sessionKeyField: origin.sessionKey,
|
|
209
233
|
selfAID: origin.selfAID,
|
|
210
|
-
onPersisted: () =>
|
|
234
|
+
onPersisted: () => {
|
|
235
|
+
if (this.store.get(selfAid, handoffId)?.state !== 'origin_queued') {
|
|
236
|
+
throw new Error(`handoff is no longer deliverable: ${handoffId}`);
|
|
237
|
+
}
|
|
238
|
+
this.store.recordOriginEnqueued(selfAid, handoffId, messageId);
|
|
239
|
+
},
|
|
211
240
|
});
|
|
212
241
|
}
|
|
213
242
|
catch (error) {
|
|
214
|
-
this.store.
|
|
243
|
+
if (this.store.get(selfAid, handoffId)?.state === 'origin_queued') {
|
|
244
|
+
this.store.recordOriginEnqueueFailed(selfAid, handoffId, error instanceof Error ? error.message : String(error));
|
|
245
|
+
}
|
|
215
246
|
}
|
|
216
247
|
}
|
|
217
248
|
buildPromptItem(message) {
|
|
218
249
|
return this.buildPromptItems(message)[0] ?? null;
|
|
219
250
|
}
|
|
251
|
+
isDiscardedDelivery(message) {
|
|
252
|
+
const delivery = message.handoffDelivery;
|
|
253
|
+
const selfAid = message.selfAID;
|
|
254
|
+
if (!delivery || !selfAid)
|
|
255
|
+
return false;
|
|
256
|
+
const ids = delivery.handoffIds?.length ? delivery.handoffIds : [delivery.handoffId];
|
|
257
|
+
return ids.length > 0 && ids.every(handoffId => this.store.get(selfAid, handoffId)?.state === 'discarded');
|
|
258
|
+
}
|
|
220
259
|
buildPromptItems(message) {
|
|
221
260
|
const delivery = message.handoffDelivery;
|
|
222
261
|
const selfAid = message.selfAID;
|
|
@@ -367,9 +406,54 @@ export class HandoffRuntime {
|
|
|
367
406
|
expired += this.dispatcher.expireQueued(selfAid).length;
|
|
368
407
|
return expired;
|
|
369
408
|
}
|
|
370
|
-
async
|
|
409
|
+
async discardSession(selfAid, sessionId) {
|
|
410
|
+
this.sessionDiscardSequence.set(this.sessionKey(selfAid, sessionId), ++this.lifecycleSequence);
|
|
411
|
+
const candidates = this.store.list(selfAid)
|
|
412
|
+
.filter(instance => (instance.origin_session_id === sessionId || instance.target_session_id === sessionId) && instance.state !== 'failed' && instance.state !== 'completed' && instance.state !== 'discarded');
|
|
413
|
+
let discarded = 0;
|
|
414
|
+
for (const instance of candidates) {
|
|
415
|
+
const result = await this.discardWithTargetLock(selfAid, instance.handoff_id, 'session_replaced', {
|
|
416
|
+
session_id: sessionId,
|
|
417
|
+
});
|
|
418
|
+
if (result)
|
|
419
|
+
discarded++;
|
|
420
|
+
}
|
|
421
|
+
return discarded;
|
|
422
|
+
}
|
|
423
|
+
async discardExpired(selfAids, retentionMs = HANDOFF_RETENTION_MS) {
|
|
424
|
+
const now = this.now?.() ?? Date.now();
|
|
425
|
+
let discarded = 0;
|
|
426
|
+
for (const selfAid of selfAids) {
|
|
427
|
+
const latestEventVersions = new Map();
|
|
428
|
+
for (const event of this.store.events(selfAid)) {
|
|
429
|
+
if (!event.handoff_id || event.to_version === undefined)
|
|
430
|
+
continue;
|
|
431
|
+
latestEventVersions.set(event.handoff_id, Math.max(latestEventVersions.get(event.handoff_id) ?? 0, event.to_version));
|
|
432
|
+
}
|
|
433
|
+
for (const instance of this.store.list(selfAid)) {
|
|
434
|
+
if (instance.state === 'failed' || instance.state === 'completed' || instance.state === 'discarded')
|
|
435
|
+
continue;
|
|
436
|
+
if (now - instance.created_at <= retentionMs)
|
|
437
|
+
continue;
|
|
438
|
+
if ((latestEventVersions.get(instance.handoff_id) ?? 0) !== instance.version)
|
|
439
|
+
continue;
|
|
440
|
+
const result = await this.discardWithTargetLock(selfAid, instance.handoff_id, 'retention_expired', {
|
|
441
|
+
retention_ms: retentionMs,
|
|
442
|
+
age_ms: now - instance.created_at,
|
|
443
|
+
}, instance.version, now);
|
|
444
|
+
if (result) {
|
|
445
|
+
discarded++;
|
|
446
|
+
logger.info(`[Handoff] discarded expired handoff: ${instance.handoff_id}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return discarded;
|
|
451
|
+
}
|
|
452
|
+
async recover(selfAids, options = {}) {
|
|
371
453
|
const originEnqueues = [];
|
|
372
454
|
for (const selfAid of selfAids) {
|
|
455
|
+
if (options.discardExpired !== false)
|
|
456
|
+
await this.discardExpired([selfAid]);
|
|
373
457
|
// v1 initially represented return=none as return_pending and required an
|
|
374
458
|
// empty manual return. It has already been shown to a target turn, so
|
|
375
459
|
// finish it before version reconciliation instead of prompting again.
|
|
@@ -412,6 +496,40 @@ export class HandoffRuntime {
|
|
|
412
496
|
}
|
|
413
497
|
await Promise.all(originEnqueues);
|
|
414
498
|
}
|
|
499
|
+
async discardWithTargetLock(selfAid, handoffId, reason, details, expectedVersion, now = this.now?.()) {
|
|
500
|
+
const snapshot = this.store.get(selfAid, handoffId);
|
|
501
|
+
if (!snapshot)
|
|
502
|
+
return false;
|
|
503
|
+
const discarded = await this.mutexes.forKey(this.targetKey(selfAid, snapshot.target_session_id)).runExclusive(async () => {
|
|
504
|
+
const current = this.store.get(selfAid, handoffId);
|
|
505
|
+
if (!current || current.state === 'failed' || current.state === 'completed' || current.state === 'discarded') {
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
if (expectedVersion !== undefined && current.version !== expectedVersion)
|
|
509
|
+
return null;
|
|
510
|
+
this.store.discard(selfAid, handoffId, reason, details, now);
|
|
511
|
+
return current;
|
|
512
|
+
});
|
|
513
|
+
if (!discarded)
|
|
514
|
+
return false;
|
|
515
|
+
await this.cancelOriginDelivery(discarded);
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
async cancelOriginDelivery(instance) {
|
|
519
|
+
if (instance.state !== 'origin_queued' && instance.state !== 'origin_delivered')
|
|
520
|
+
return;
|
|
521
|
+
const messageId = instance.origin_delivery_message_id
|
|
522
|
+
?? `origin-deliver:${instance.handoff_id}:${instance.origin_session_id}`;
|
|
523
|
+
const queue = this.messageQueue;
|
|
524
|
+
try {
|
|
525
|
+
const queued = queue.cancelMessageByIdInSession?.(instance.origin_session_id, messageId) ?? false;
|
|
526
|
+
if (!queued)
|
|
527
|
+
await queue.cancelActiveAndWait?.(messageId);
|
|
528
|
+
}
|
|
529
|
+
catch (error) {
|
|
530
|
+
logger.warn(`[Handoff] failed to cancel discarded origin delivery ${messageId}:`, error);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
415
533
|
warnIfStale(instance) {
|
|
416
534
|
if (instance.state !== 'target_sent' && instance.state !== 'return_pending')
|
|
417
535
|
return;
|
|
@@ -425,6 +543,12 @@ export class HandoffRuntime {
|
|
|
425
543
|
+ ` ageHours=${Math.floor(ageMs / 3_600_000)}`
|
|
426
544
|
+ ` originSession=${instance.origin_session_id} targetSession=${instance.target_session_id}`);
|
|
427
545
|
}
|
|
546
|
+
targetKey(selfAid, targetSessionId) {
|
|
547
|
+
return `${selfAid}\u0000${targetSessionId}`;
|
|
548
|
+
}
|
|
549
|
+
sessionKey(selfAid, sessionId) {
|
|
550
|
+
return `${selfAid}\u0000${sessionId}`;
|
|
551
|
+
}
|
|
428
552
|
warnIfLargeAggregate(message, instances, direction) {
|
|
429
553
|
const chars = message.content.length
|
|
430
554
|
+ instances.reduce((total, instance) => total + this.requestSummary(instance).length, 0);
|
|
@@ -9,6 +9,7 @@ import { HANDOFF_SCHEMA_VERSION, HANDOFF_QUERY_DEFAULT_LIMIT, HANDOFF_QUERY_MAX_
|
|
|
9
9
|
import { normalizeCausation } from '../causation/context.js';
|
|
10
10
|
const SAFE_ID_RE = /^[A-Za-z0-9._-]+$/;
|
|
11
11
|
export const HANDOFF_ID_RE = /^h-[A-Za-z0-9._-]+$/;
|
|
12
|
+
const TERMINAL_HANDOFF_STATES = new Set(['failed', 'completed', 'discarded']);
|
|
12
13
|
function safeSegment(value, field) {
|
|
13
14
|
if (!value || !SAFE_ID_RE.test(value) || value === '.' || value === '..') {
|
|
14
15
|
throw new Error(`invalid ${field}`);
|
|
@@ -514,6 +515,29 @@ export class HandoffStore {
|
|
|
514
515
|
this.write(selfAid, instance);
|
|
515
516
|
return instance;
|
|
516
517
|
}
|
|
518
|
+
discard(selfAid, handoffId, reason, details = {}, now = Date.now()) {
|
|
519
|
+
const instance = this.get(selfAid, handoffId);
|
|
520
|
+
if (!instance)
|
|
521
|
+
throw new Error('handoff not found');
|
|
522
|
+
if (TERMINAL_HANDOFF_STATES.has(instance.state))
|
|
523
|
+
return instance;
|
|
524
|
+
const previousState = instance.state;
|
|
525
|
+
const previousVersion = instance.version;
|
|
526
|
+
instance.state = 'discarded';
|
|
527
|
+
instance.attention_required = false;
|
|
528
|
+
instance.attention_reason = null;
|
|
529
|
+
instance.version++;
|
|
530
|
+
instance.updated_at = now;
|
|
531
|
+
instance.completed_at = now;
|
|
532
|
+
this.appendEvent(selfAid, 'handoff_discarded', handoffId, {
|
|
533
|
+
fromVersion: previousVersion,
|
|
534
|
+
toVersion: instance.version,
|
|
535
|
+
details: { ...details, reason, previous_state: previousState },
|
|
536
|
+
now,
|
|
537
|
+
});
|
|
538
|
+
this.write(selfAid, instance);
|
|
539
|
+
return instance;
|
|
540
|
+
}
|
|
517
541
|
requireState(selfAid, handoffId, expected) {
|
|
518
542
|
const instance = this.get(selfAid, handoffId);
|
|
519
543
|
if (!instance)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
export class MentionSchemaError extends Error {
|
|
2
|
+
code = 'INVALID_MENTION_SCHEMA';
|
|
3
|
+
index;
|
|
4
|
+
constructor(message, index) {
|
|
5
|
+
super(index === undefined ? message : `mentions[${index}]: ${message}`);
|
|
6
|
+
this.name = 'MentionSchemaError';
|
|
7
|
+
this.index = index;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function isRecord(value) {
|
|
11
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
function hasOwn(value, key) {
|
|
14
|
+
return Object.prototype.hasOwnProperty.call(value, key);
|
|
15
|
+
}
|
|
16
|
+
function assertMetadata(value, index) {
|
|
17
|
+
if (hasOwn(value, 'display') && typeof value.display !== 'string') {
|
|
18
|
+
throw new MentionSchemaError('display must be a string', index);
|
|
19
|
+
}
|
|
20
|
+
for (const key of ['offset', 'length']) {
|
|
21
|
+
if (!hasOwn(value, key))
|
|
22
|
+
continue;
|
|
23
|
+
const field = value[key];
|
|
24
|
+
if (typeof field !== 'number' || !Number.isInteger(field) || field < 0) {
|
|
25
|
+
throw new MentionSchemaError(`${key} must be a non-negative integer`, index);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
for (const key of ['name', 'key']) {
|
|
29
|
+
if (hasOwn(value, key) && typeof value[key] !== 'string') {
|
|
30
|
+
throw new MentionSchemaError(`${key} must be a string`, index);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Validate and copy canonical mention entries.
|
|
36
|
+
*
|
|
37
|
+
* This is intentionally strict: the old string array format is rejected
|
|
38
|
+
* instead of being converted, because conversion can hide a producer bug and
|
|
39
|
+
* make routing appear to work with the wrong wire contract.
|
|
40
|
+
*/
|
|
41
|
+
export function normalizeMentionEntries(raw) {
|
|
42
|
+
if (raw === undefined)
|
|
43
|
+
return [];
|
|
44
|
+
if (!Array.isArray(raw)) {
|
|
45
|
+
throw new MentionSchemaError('mentions must be an array');
|
|
46
|
+
}
|
|
47
|
+
const normalized = [];
|
|
48
|
+
for (const [index, value] of raw.entries()) {
|
|
49
|
+
if (typeof value === 'string') {
|
|
50
|
+
throw new MentionSchemaError('legacy string entries are not supported; use {aid} or {scope:"all"}', index);
|
|
51
|
+
}
|
|
52
|
+
if (!isRecord(value)) {
|
|
53
|
+
throw new MentionSchemaError('entry must be an object', index);
|
|
54
|
+
}
|
|
55
|
+
const hasScope = hasOwn(value, 'scope');
|
|
56
|
+
const hasAid = hasOwn(value, 'aid');
|
|
57
|
+
const hasUserId = hasOwn(value, 'userId');
|
|
58
|
+
const discriminatorCount = Number(hasScope) + Number(hasAid) + Number(hasUserId);
|
|
59
|
+
if (discriminatorCount !== 1) {
|
|
60
|
+
throw new MentionSchemaError('entry must contain exactly one of scope, aid, or userId', index);
|
|
61
|
+
}
|
|
62
|
+
assertMetadata(value, index);
|
|
63
|
+
if (hasScope) {
|
|
64
|
+
if (value.scope !== 'all') {
|
|
65
|
+
throw new MentionSchemaError('scope must be "all"', index);
|
|
66
|
+
}
|
|
67
|
+
normalized.push({ ...value, scope: 'all' });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (hasAid) {
|
|
71
|
+
if (typeof value.aid !== 'string' || value.aid.length === 0 || value.aid.trim() !== value.aid) {
|
|
72
|
+
throw new MentionSchemaError('aid must be a non-empty trimmed string', index);
|
|
73
|
+
}
|
|
74
|
+
if (value.aid === 'all') {
|
|
75
|
+
throw new MentionSchemaError('all is a scope, not an aid; use {scope:"all"}', index);
|
|
76
|
+
}
|
|
77
|
+
normalized.push({ ...value, aid: value.aid });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (typeof value.userId !== 'string' || value.userId.length === 0 || value.userId.trim() !== value.userId) {
|
|
81
|
+
throw new MentionSchemaError('userId must be a non-empty trimmed string', index);
|
|
82
|
+
}
|
|
83
|
+
if (value.userId === 'all') {
|
|
84
|
+
throw new MentionSchemaError('all is a scope, not a userId; use {scope:"all"}', index);
|
|
85
|
+
}
|
|
86
|
+
normalized.push({ ...value, userId: value.userId });
|
|
87
|
+
}
|
|
88
|
+
return normalized;
|
|
89
|
+
}
|
|
90
|
+
export function mentionEntryIsAll(entry) {
|
|
91
|
+
return 'scope' in entry && entry.scope === 'all';
|
|
92
|
+
}
|
|
93
|
+
/** Return all concrete target IDs, including channel-native userId entries. */
|
|
94
|
+
export function mentionEntryTargets(entries) {
|
|
95
|
+
const targets = [];
|
|
96
|
+
const seen = new Set();
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
const target = 'aid' in entry && typeof entry.aid === 'string'
|
|
99
|
+
? entry.aid
|
|
100
|
+
: 'userId' in entry && typeof entry.userId === 'string'
|
|
101
|
+
? entry.userId
|
|
102
|
+
: undefined;
|
|
103
|
+
if (!target || target === 'all' || seen.has(target))
|
|
104
|
+
continue;
|
|
105
|
+
seen.add(target);
|
|
106
|
+
targets.push(target);
|
|
107
|
+
}
|
|
108
|
+
return targets;
|
|
109
|
+
}
|
|
110
|
+
export function hasMentionAll(entries) {
|
|
111
|
+
return entries.some(mentionEntryIsAll);
|
|
112
|
+
}
|
|
113
|
+
/** IDs used by renderers; keeps `all` visible as a derived value. */
|
|
114
|
+
export function mentionEntryAids(entries) {
|
|
115
|
+
const aids = mentionEntryTargets(entries);
|
|
116
|
+
if (hasMentionAll(entries))
|
|
117
|
+
aids.push('all');
|
|
118
|
+
return aids;
|
|
119
|
+
}
|
|
120
|
+
export function mentionEntryKey(entry) {
|
|
121
|
+
if ('scope' in entry && entry.scope === 'all')
|
|
122
|
+
return 'scope:all';
|
|
123
|
+
if ('aid' in entry)
|
|
124
|
+
return `aid:${entry.aid}`;
|
|
125
|
+
return `user:${entry.userId}`;
|
|
126
|
+
}
|