evolcore 0.0.20 → 0.0.22
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 +65 -0
- package/README.md +58 -9
- package/bin/codex-managed-hook.mjs +3 -0
- package/bin/install-codex-managed-hooks.mjs +3 -1
- package/dist/agents/baseagent.js +10 -6
- package/dist/agents/claude-runner.js +393 -108
- package/dist/agents/codex-app-server-client.js +41 -7
- package/dist/agents/codex-runner.js +1292 -220
- 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 +189 -80
- package/dist/aun/service-proxy.js +43 -25
- package/dist/channels/aun.js +618 -123
- 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 +98 -123
- package/dist/cli/init.js +27 -15
- package/dist/cli/task-context.js +50 -0
- package/dist/cli/trigger-command.js +14 -5
- package/dist/cli/watch-logs.js +10 -3
- package/dist/config/builtin-roles.js +1 -0
- package/dist/config/config-field-policy.js +19 -5
- package/dist/config/config-manager.js +167 -22
- package/dist/config/contact-book-store.js +25 -3
- 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 +43 -1
- package/dist/core/auth/auth-gateway.js +41 -4
- package/dist/core/auth/authorization-audit.js +216 -8
- package/dist/core/auth/operation-authorizer.js +41 -1
- package/dist/core/auth/operation-catalog.js +9 -1
- package/dist/core/bootstrap-messages.js +8 -0
- package/dist/core/bootstrap-service.js +99 -27
- 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 +311 -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 +288 -80
- package/dist/core/command/menu-protocol.js +1 -1
- 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 +377 -36
- package/dist/core/data-migration.js +11 -1
- package/dist/core/event-catalog.js +37 -0
- package/dist/core/evolagent.js +4 -0
- package/dist/core/handoff/dispatcher.js +4 -0
- package/dist/core/handoff/runtime.js +33 -3
- package/dist/core/handoff/store.js +32 -9
- package/dist/core/inference/text-inference.js +7 -15
- package/dist/core/message/im-renderer.js +90 -87
- package/dist/core/message/{inbound-admission.js → message-admission.js} +51 -1
- package/dist/core/message/message-bridge.js +184 -12
- package/dist/core/message/message-log.js +47 -7
- package/dist/core/message/message-queue.js +227 -16
- package/dist/core/message/message-utils.js +12 -5
- package/dist/core/message/response-engine.js +658 -109
- 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 +627 -69
- package/dist/core/permission/mode.js +18 -3
- package/dist/core/{protected-paths.js → permission/protected-paths.js} +27 -14
- package/dist/core/permission/readonly-shell-query.js +263 -9
- package/dist/core/permission/sandbox-runtime.js +159 -1
- package/dist/core/permission/tool-error-code.js +12 -0
- package/dist/core/permission/tool-policy.js +618 -23
- package/dist/core/session/session-fs-store.js +154 -5
- package/dist/core/session/session-manager.js +329 -30
- package/dist/core/session/session-renew.js +37 -13
- package/dist/core/session/session-turn-coordinator.js +16 -5
- package/dist/eck/kit-renderer.js +1 -1
- package/dist/index.js +316 -50
- package/dist/ipc.js +459 -29
- package/dist/paths.js +82 -7
- package/dist/response-system/context-builder.js +1 -7
- package/dist/response-system/engines/v1/proactive-flow.js +7 -2
- package/dist/stats/price-resolver.js +4 -0
- package/dist/trigger/anomaly-store.js +1 -0
- package/dist/trigger/feedback.js +70 -7
- package/dist/trigger/history.js +79 -4
- package/dist/trigger/legacy-session-history.js +2 -2
- package/dist/trigger/parser.js +13 -3
- package/dist/trigger/scheduler.js +20 -3
- 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 +30 -6
- 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/dist/utils/tool-summary.js +59 -0
- package/dist/utils/windows-shell-trust.js +201 -0
- package/kits/docs/INDEX.md +1 -1
- package/kits/docs/evolcore/INDEX.md +3 -3
- package/kits/docs/evolcore/agent-create.md +146 -0
- package/kits/docs/evolcore/agent.md +6 -0
- package/kits/docs/evolcore/contact.md +7 -1
- package/kits/docs/evolcore/group-collaboration.md +251 -0
- package/kits/docs/evolcore/group-rules.md +1 -19
- package/kits/docs/evolcore/group.md +3 -1
- package/kits/docs/evolcore/msg.md +16 -0
- package/kits/docs/evolcore/trigger.md +6 -3
- package/kits/docs/prompt-loading-architecture.md +6 -0
- package/kits/eck_message_manifest.json +6 -6
- package/kits/schemas/_meta.json +7 -4
- package/kits/schemas/agent-config.schema.11.json +13 -0
- package/kits/schemas/agent-config.schema.12.json +427 -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/message-fragments/item.md +1 -1
- 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/kits/templates/system-fragments/bootstrap.md +2 -1
- package/kits/templates/system-fragments/commands.md +2 -2
- package/package.json +6 -3
- package/skills/eclink/SKILL.md +2 -0
- package/dist/config/aun-gateway-config.js +0 -2
|
@@ -155,11 +155,160 @@ export function atomicWriteText(filePath, content) {
|
|
|
155
155
|
atomicWrite(filePath, content);
|
|
156
156
|
}
|
|
157
157
|
export function appendJsonl(filePath, record) {
|
|
158
|
-
const line = JSON.stringify(record) + '\n';
|
|
159
|
-
const fd = fs.openSync(filePath, 'a');
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
158
|
+
const line = Buffer.from(JSON.stringify(record) + '\n');
|
|
159
|
+
const fd = fs.openSync(filePath, 'a+');
|
|
160
|
+
try {
|
|
161
|
+
recoverJsonlTail(fd, filePath);
|
|
162
|
+
writeAllSync(fd, line);
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
fs.closeSync(fd);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** Durable append used by authoritative topic Session snapshots. */
|
|
169
|
+
export function appendJsonlDurable(filePath, record) {
|
|
170
|
+
const line = Buffer.from(JSON.stringify(record) + '\n');
|
|
171
|
+
const fd = fs.openSync(filePath, 'a+');
|
|
172
|
+
try {
|
|
173
|
+
recoverJsonlTail(fd, filePath);
|
|
174
|
+
writeAllSync(fd, line);
|
|
175
|
+
fs.fsyncSync(fd);
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
fs.closeSync(fd);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function writeAllSync(fd, content) {
|
|
182
|
+
let offset = 0;
|
|
183
|
+
while (offset < content.length) {
|
|
184
|
+
const written = fs.writeSync(fd, content, offset, content.length - offset, null);
|
|
185
|
+
if (written <= 0)
|
|
186
|
+
throw new Error('JSONL write made no progress');
|
|
187
|
+
offset += written;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Remove only an unterminated final record. Committed (newline-terminated)
|
|
192
|
+
* bytes are never rewritten here; the latest reader is responsible for
|
|
193
|
+
* rejecting a malformed committed record instead of hiding it.
|
|
194
|
+
*/
|
|
195
|
+
function recoverJsonlTail(fd, filePath) {
|
|
196
|
+
const stat = fs.fstatSync(fd);
|
|
197
|
+
if (stat.size === 0)
|
|
198
|
+
return;
|
|
199
|
+
const finalByte = Buffer.allocUnsafe(1);
|
|
200
|
+
const finalRead = fs.readSync(fd, finalByte, 0, 1, stat.size - 1);
|
|
201
|
+
if (finalRead !== 1)
|
|
202
|
+
throw new Error(`short JSONL tail read while recovering ${filePath}`);
|
|
203
|
+
if (finalByte[0] === 0x0a)
|
|
204
|
+
return;
|
|
205
|
+
const chunkSize = 64 * 1024;
|
|
206
|
+
let cursor = stat.size;
|
|
207
|
+
let lastNewline = -1;
|
|
208
|
+
while (cursor > 0 && lastNewline < 0) {
|
|
209
|
+
const start = Math.max(0, cursor - chunkSize);
|
|
210
|
+
const length = cursor - start;
|
|
211
|
+
const chunk = Buffer.allocUnsafe(length);
|
|
212
|
+
let offset = 0;
|
|
213
|
+
while (offset < length) {
|
|
214
|
+
const read = fs.readSync(fd, chunk, offset, length - offset, start + offset);
|
|
215
|
+
if (read <= 0)
|
|
216
|
+
throw new Error(`short JSONL read while recovering ${filePath}`);
|
|
217
|
+
offset += read;
|
|
218
|
+
}
|
|
219
|
+
const index = chunk.lastIndexOf(0x0a);
|
|
220
|
+
if (index >= 0)
|
|
221
|
+
lastNewline = start + index;
|
|
222
|
+
cursor = start;
|
|
223
|
+
}
|
|
224
|
+
const committedSize = lastNewline < 0 ? 0 : lastNewline + 1;
|
|
225
|
+
if (committedSize !== stat.size) {
|
|
226
|
+
fs.ftruncateSync(fd, committedSize);
|
|
227
|
+
fs.fsyncSync(fd);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function readExactAt(fd, length, position, filePath) {
|
|
231
|
+
const content = Buffer.allocUnsafe(length);
|
|
232
|
+
let offset = 0;
|
|
233
|
+
while (offset < length) {
|
|
234
|
+
const read = fs.readSync(fd, content, offset, length - offset, position + offset);
|
|
235
|
+
if (read <= 0)
|
|
236
|
+
throw new Error(`short JSONL read at ${filePath}:${position + offset}`);
|
|
237
|
+
offset += read;
|
|
238
|
+
}
|
|
239
|
+
return content;
|
|
240
|
+
}
|
|
241
|
+
function findLastNewline(fd, endExclusive, filePath) {
|
|
242
|
+
const chunkSize = 64 * 1024;
|
|
243
|
+
let cursor = endExclusive;
|
|
244
|
+
while (cursor > 0) {
|
|
245
|
+
const start = Math.max(0, cursor - chunkSize);
|
|
246
|
+
const chunk = readExactAt(fd, cursor - start, start, filePath);
|
|
247
|
+
const index = chunk.lastIndexOf(0x0a);
|
|
248
|
+
if (index >= 0)
|
|
249
|
+
return start + index;
|
|
250
|
+
cursor = start;
|
|
251
|
+
}
|
|
252
|
+
return -1;
|
|
253
|
+
}
|
|
254
|
+
function parseJsonlLineStrict(line, filePath, location) {
|
|
255
|
+
try {
|
|
256
|
+
return JSON.parse(line);
|
|
257
|
+
}
|
|
258
|
+
catch (cause) {
|
|
259
|
+
const error = new Error(`Invalid committed JSONL record at ${filePath}:${location}`, { cause });
|
|
260
|
+
error.code = 'JSONL_COMMITTED_RECORD_INVALID';
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Read only the latest newline-terminated JSONL record. An unterminated tail
|
|
266
|
+
* is uncommitted and ignored; a malformed latest committed record is fatal.
|
|
267
|
+
*/
|
|
268
|
+
export function readLastJsonlLineStrict(filePath) {
|
|
269
|
+
let fd;
|
|
270
|
+
try {
|
|
271
|
+
fd = fs.openSync(filePath, 'r');
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
if (error.code === 'ENOENT')
|
|
275
|
+
return undefined;
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
const size = fs.fstatSync(fd).size;
|
|
280
|
+
if (size === 0)
|
|
281
|
+
return undefined;
|
|
282
|
+
const finalByte = readExactAt(fd, 1, size - 1, filePath)[0];
|
|
283
|
+
const finalNewline = finalByte === 0x0a ? size - 1 : findLastNewline(fd, size, filePath);
|
|
284
|
+
if (finalNewline < 0)
|
|
285
|
+
return undefined;
|
|
286
|
+
let lineEnd = finalNewline;
|
|
287
|
+
while (lineEnd >= 0) {
|
|
288
|
+
const previousNewline = findLastNewline(fd, lineEnd, filePath);
|
|
289
|
+
const lineStart = previousNewline + 1;
|
|
290
|
+
const line = readExactAt(fd, lineEnd - lineStart, lineStart, filePath).toString('utf8').trim();
|
|
291
|
+
if (line)
|
|
292
|
+
return parseJsonlLineStrict(line, filePath, lineStart);
|
|
293
|
+
if (previousNewline < 0)
|
|
294
|
+
return undefined;
|
|
295
|
+
lineEnd = previousNewline;
|
|
296
|
+
}
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
fs.closeSync(fd);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/** Reconfirm durability after an append reported an indeterminate failure. */
|
|
304
|
+
export function fsyncJsonlFile(filePath) {
|
|
305
|
+
const fd = fs.openSync(filePath, 'r+');
|
|
306
|
+
try {
|
|
307
|
+
fs.fsyncSync(fd);
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
fs.closeSync(fd);
|
|
311
|
+
}
|
|
163
312
|
}
|
|
164
313
|
export function readJsonFile(filePath) {
|
|
165
314
|
try {
|
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import { ensureDir } from '../../utils/atomic-write.js';
|
|
2
2
|
import { logger } from '../../utils/logger.js';
|
|
3
3
|
import { encodePath } from '../../utils/cross-platform.js';
|
|
4
|
-
import { chatDirPath, generateSessionId, formatTimestamp, atomicWriteJson, atomicWriteText, appendJsonl, readJsonFile, readLastJsonlLine, readAllJsonlLines, scanChatDirs, scanMetaFiles, ensureChatDir, readThreadIndex, writeThreadIndex, } from './session-fs-store.js';
|
|
4
|
+
import { chatDirPath, generateSessionId, formatTimestamp, atomicWriteJson, atomicWriteText, appendJsonl, appendJsonlDurable, fsyncJsonlFile, readJsonFile, readLastJsonlLine, readLastJsonlLineStrict, readAllJsonlLines, scanChatDirs, scanMetaFiles, ensureChatDir, readThreadIndex, writeThreadIndex, } from './session-fs-store.js';
|
|
5
5
|
import { sessionToFile, fileToSession, formatSessionKey, DEFAULT_THREAD_ID } from './session-mapper.js';
|
|
6
6
|
import { tryParseChannelKey } from '../channel-loader.js';
|
|
7
7
|
import { isSystemControlChannel } from '../system-channels.js';
|
|
8
8
|
import path from 'path';
|
|
9
9
|
import fs from 'fs';
|
|
10
10
|
import os from 'os';
|
|
11
|
+
/** Metadata keys owned by ordinary topic/session routing writers. */
|
|
12
|
+
const TOPIC_METADATA_PROTECTED_KEYS = new Set(['turnState', 'resumeAt', 'agentSessions']);
|
|
13
|
+
const TOPIC_BACKEND_SCOPED_KEYS = ['resumeAt', 'agentSessions'];
|
|
14
|
+
function topicMetadataOwnershipError(key) {
|
|
15
|
+
const error = new Error(`topic metadata field is owned by a dedicated updater: ${key}`);
|
|
16
|
+
error.code = 'TOPIC_METADATA_OWNERSHIP';
|
|
17
|
+
return error;
|
|
18
|
+
}
|
|
19
|
+
export function normalizeBackendSessionId(value) {
|
|
20
|
+
const normalized = typeof value === 'string' ? value.trim() : '';
|
|
21
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
22
|
+
}
|
|
11
23
|
function newSessionStableMetadata(chatType, ...sources) {
|
|
12
24
|
const keys = chatType === 'group'
|
|
13
25
|
? ['channelKey', 'groupId', 'groupName', 'mentionMode']
|
|
@@ -280,7 +292,11 @@ export class SessionManager {
|
|
|
280
292
|
}
|
|
281
293
|
appendMeta(channel, channelId, session) {
|
|
282
294
|
const file = sessionToFile(session);
|
|
283
|
-
|
|
295
|
+
const metaPath = this.metaPathForSession(session);
|
|
296
|
+
if (session.threadId)
|
|
297
|
+
appendJsonlDurable(metaPath, file);
|
|
298
|
+
else
|
|
299
|
+
appendJsonl(metaPath, file);
|
|
284
300
|
}
|
|
285
301
|
/**
|
|
286
302
|
* Session 持久化的唯一事务原语。所有 session 写操作都应经此,业务层不直接调
|
|
@@ -301,8 +317,50 @@ export class SessionManager {
|
|
|
301
317
|
* @returns 是否真的写入了 .jsonl(去重命中时返回 false)
|
|
302
318
|
*/
|
|
303
319
|
persistSession(session, intent, opts) {
|
|
320
|
+
// Topic session snapshots can be written by synchronous legacy paths
|
|
321
|
+
// (mark/clear processing) after an async /renew has committed. Preserve
|
|
322
|
+
// the newest backend fence unless this call is one of the two explicit
|
|
323
|
+
// boundary writers. This is a last-line guard against a stale object
|
|
324
|
+
// replaying an old agentSessionId into the JSONL history.
|
|
325
|
+
if (session.threadId && !opts?.backendMutation) {
|
|
326
|
+
const latest = this.readMetaLatest(this.metaPathForSession(session));
|
|
327
|
+
const latestId = normalizeBackendSessionId(latest?.agentSessionId);
|
|
328
|
+
if (latest) {
|
|
329
|
+
// Generic writers may update turn/route metadata, but never the
|
|
330
|
+
// backend boundary itself. Always take the latest binding and owner.
|
|
331
|
+
session.agentSessionId = latestId;
|
|
332
|
+
session.baseagent = latest.baseagent;
|
|
333
|
+
// Merge metadata at field level as well. A caller can hold a stale
|
|
334
|
+
// Session object across an await; preserving keys from the latest
|
|
335
|
+
// snapshot prevents unrelated turn/route updates from being erased.
|
|
336
|
+
// The backend boundary remains entirely owned by the latest snapshot.
|
|
337
|
+
const requestedMetadata = session.metadata ?? {};
|
|
338
|
+
const mergedMetadata = {
|
|
339
|
+
...(latest.metadata ?? {}),
|
|
340
|
+
...requestedMetadata,
|
|
341
|
+
...(!opts?.turnStateMutation
|
|
342
|
+
? (latest.metadata?.turnState
|
|
343
|
+
? { turnState: JSON.parse(JSON.stringify(latest.metadata.turnState)) }
|
|
344
|
+
: { turnState: undefined })
|
|
345
|
+
: {}),
|
|
346
|
+
};
|
|
347
|
+
for (const key of TOPIC_BACKEND_SCOPED_KEYS) {
|
|
348
|
+
if (Object.prototype.hasOwnProperty.call(latest.metadata ?? {}, key)) {
|
|
349
|
+
const value = latest.metadata[key];
|
|
350
|
+
mergedMetadata[key] = value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
delete mergedMetadata[key];
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
session.metadata = mergedMetadata;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
304
359
|
if (!opts?.forceWrite) {
|
|
305
|
-
const
|
|
360
|
+
const metaPath = this.metaPathForSession(session);
|
|
361
|
+
const lastMeta = session.threadId
|
|
362
|
+
? readLastJsonlLineStrict(metaPath)
|
|
363
|
+
: readLastJsonlLine(metaPath);
|
|
306
364
|
if (lastMeta && this.sessionFilesEqual(lastMeta, sessionToFile(session))) {
|
|
307
365
|
// .jsonl 末行已与目标一致:仍可能需要把缓存对齐('set' 语义)
|
|
308
366
|
if (intent === 'set') {
|
|
@@ -334,7 +392,10 @@ export class SessionManager {
|
|
|
334
392
|
return JSON.stringify(stripVolatile(a)) === JSON.stringify(stripVolatile(b));
|
|
335
393
|
}
|
|
336
394
|
readMetaLatest(metaFilePath) {
|
|
337
|
-
const
|
|
395
|
+
const isTopic = path.basename(path.dirname(metaFilePath)) === '_threads';
|
|
396
|
+
const file = isTopic
|
|
397
|
+
? readLastJsonlLineStrict(metaFilePath)
|
|
398
|
+
: readLastJsonlLine(metaFilePath);
|
|
338
399
|
if (!file)
|
|
339
400
|
return undefined;
|
|
340
401
|
return fileToSession(file);
|
|
@@ -342,13 +403,10 @@ export class SessionManager {
|
|
|
342
403
|
/**
|
|
343
404
|
* 为 by-sessionId 改方法加载"当前 session 状态"。
|
|
344
405
|
*
|
|
345
|
-
* 设计契约(docs/refactor/01-db-to-fs.md):
|
|
346
|
-
* active.json 是热路径权威源。.jsonl 是历史档案。
|
|
347
|
-
*
|
|
348
406
|
* 读取策略:
|
|
349
407
|
* 1. 先按 sessionId 定位 .jsonl 文件(确认 session 存在 + 拿到 channel/channelId)
|
|
350
|
-
* 2.
|
|
351
|
-
* 3.
|
|
408
|
+
* 2. topic session 始终读取 .jsonl 最新有效 snapshot;active.json 只是缓存
|
|
409
|
+
* 3. 主 session 保持既有 active.json 热路径兼容,找不到时回退 .jsonl 末行
|
|
352
410
|
*
|
|
353
411
|
* 返回 { current }:caller 修改后交给 persistSession 写回(去重由 persistSession 内建)。
|
|
354
412
|
*/
|
|
@@ -360,9 +418,9 @@ export class SessionManager {
|
|
|
360
418
|
const fromJsonl = this.readMetaLatest(found.metaPath);
|
|
361
419
|
if (!fromJsonl)
|
|
362
420
|
return undefined;
|
|
363
|
-
//
|
|
421
|
+
// active.json 不能参与 topic backend boundary/CAS;它可能落后于线程 JSONL。
|
|
364
422
|
const active = this.readActive(fromJsonl.channel, fromJsonl.channelId);
|
|
365
|
-
const base = (active && active.id === sessionId) ? active : fromJsonl;
|
|
423
|
+
const base = (!fromJsonl.threadId && active && active.id === sessionId) ? active : fromJsonl;
|
|
366
424
|
const current = JSON.parse(JSON.stringify(base));
|
|
367
425
|
return { current };
|
|
368
426
|
}
|
|
@@ -441,6 +499,111 @@ export class SessionManager {
|
|
|
441
499
|
}
|
|
442
500
|
return threadIds;
|
|
443
501
|
}
|
|
502
|
+
latestSessionForBoundary(sessionId) {
|
|
503
|
+
const found = this.findSessionFileById(sessionId);
|
|
504
|
+
if (!found)
|
|
505
|
+
return { found };
|
|
506
|
+
return { found, session: this.readMetaLatest(found.metaPath) };
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* The single short read-modify-append primitive for topic backend fields.
|
|
510
|
+
* The callback must remain synchronous: all filesystem operations in this
|
|
511
|
+
* critical section are synchronous, so no await can interleave a stale
|
|
512
|
+
* snapshot between validation and append in the single-process daemon.
|
|
513
|
+
*/
|
|
514
|
+
mutateLatestTopicSession(sessionId, mutation) {
|
|
515
|
+
const state = this.latestSessionForBoundary(sessionId);
|
|
516
|
+
if (!state.found || !state.session) {
|
|
517
|
+
const error = new Error(`session not found: ${sessionId}`);
|
|
518
|
+
error.code = 'SESSION_NOT_FOUND';
|
|
519
|
+
throw error;
|
|
520
|
+
}
|
|
521
|
+
// Keep this callback synchronous. Node cannot interleave another
|
|
522
|
+
// read-modify-append while the strict latest read and append are running.
|
|
523
|
+
return mutation(state.session, state.found);
|
|
524
|
+
}
|
|
525
|
+
persistConfirmedTopicBoundary(current, found, next) {
|
|
526
|
+
try {
|
|
527
|
+
this.persistSession(next, 'none', { forceWrite: true, backendMutation: true });
|
|
528
|
+
}
|
|
529
|
+
catch (error) {
|
|
530
|
+
// A synchronous append can report a late failure after the complete
|
|
531
|
+
// target line is visible. Confirm only the exact latest boundary; the
|
|
532
|
+
// operation history is deliberately not part of this protocol.
|
|
533
|
+
const confirmed = this.readMetaLatest(found.metaPath);
|
|
534
|
+
if (confirmed && this.sessionFilesEqual(sessionToFile(confirmed), sessionToFile(next))) {
|
|
535
|
+
try {
|
|
536
|
+
fsyncJsonlFile(found.metaPath);
|
|
537
|
+
}
|
|
538
|
+
catch (syncError) {
|
|
539
|
+
const persistenceError = new Error('topic backend mutation durability is unknown', { cause: syncError });
|
|
540
|
+
persistenceError.code = 'BACKEND_PERSISTENCE_UNKNOWN';
|
|
541
|
+
throw persistenceError;
|
|
542
|
+
}
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
throw error;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
/** Clear a topic binding in one short latest-snapshot mutation. */
|
|
549
|
+
async rotateTopicBackend(input) {
|
|
550
|
+
if (!input.expectedThreadId.trim())
|
|
551
|
+
throw new Error('backend rotation requires a non-empty threadId');
|
|
552
|
+
return this.mutateLatestTopicSession(input.sessionId, (current, found) => {
|
|
553
|
+
if (!current.threadId || current.threadId !== input.expectedThreadId) {
|
|
554
|
+
const error = new Error('backend rotation threadId mismatch');
|
|
555
|
+
error.code = 'THREAD_MISMATCH';
|
|
556
|
+
throw error;
|
|
557
|
+
}
|
|
558
|
+
const wasBound = !!normalizeBackendSessionId(current.agentSessionId);
|
|
559
|
+
const next = JSON.parse(JSON.stringify(current));
|
|
560
|
+
next.agentSessionId = undefined;
|
|
561
|
+
const nextMetadata = { ...(next.metadata ?? {}) };
|
|
562
|
+
delete nextMetadata.resumeAt;
|
|
563
|
+
delete nextMetadata.agentSessions;
|
|
564
|
+
next.metadata = nextMetadata;
|
|
565
|
+
this.persistConfirmedTopicBoundary(current, found, next);
|
|
566
|
+
return { status: 'rotated', sessionId: input.sessionId, wasBound };
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Bind a backend discovered by the currently active turn.
|
|
571
|
+
* A stale or retired discovery is a normal, non-throwing rejection so the
|
|
572
|
+
* runner can dispose only its local runtime.
|
|
573
|
+
*/
|
|
574
|
+
async activateTopicBackend(input) {
|
|
575
|
+
const agentSessionId = normalizeBackendSessionId(input.agentSessionId);
|
|
576
|
+
if (!agentSessionId)
|
|
577
|
+
return 'stale';
|
|
578
|
+
if (input.lease.sessionId !== input.sessionId
|
|
579
|
+
|| !Number.isSafeInteger(input.lease.generation) || input.lease.generation < 0
|
|
580
|
+
|| !input.lease.taskId.trim() || !input.lease.turnId.trim() || !input.lease.inputId.trim()) {
|
|
581
|
+
const error = new Error('backend activation turn lease is invalid');
|
|
582
|
+
error.code = 'BACKEND_TURN_LEASE_INVALID';
|
|
583
|
+
throw error;
|
|
584
|
+
}
|
|
585
|
+
return this.mutateLatestTopicSession(input.sessionId, (current, found) => {
|
|
586
|
+
if (!current.threadId)
|
|
587
|
+
return 'stale';
|
|
588
|
+
const active = current.metadata?.turnState?.active;
|
|
589
|
+
if (!active
|
|
590
|
+
|| active.taskId !== input.lease.taskId
|
|
591
|
+
|| active.turnId !== input.lease.turnId
|
|
592
|
+
|| active.generation !== input.lease.generation
|
|
593
|
+
|| active.inputId !== input.lease.inputId
|
|
594
|
+
|| current.metadata?.turnState?.generation !== input.lease.generation)
|
|
595
|
+
return 'stale';
|
|
596
|
+
const currentId = normalizeBackendSessionId(current.agentSessionId);
|
|
597
|
+
if (currentId === agentSessionId)
|
|
598
|
+
return 'already_active';
|
|
599
|
+
if (currentId && currentId !== agentSessionId)
|
|
600
|
+
return 'conflict';
|
|
601
|
+
const next = JSON.parse(JSON.stringify(current));
|
|
602
|
+
next.agentSessionId = agentSessionId;
|
|
603
|
+
this.persistConfirmedTopicBoundary(current, found, next);
|
|
604
|
+
return 'activated';
|
|
605
|
+
});
|
|
606
|
+
}
|
|
444
607
|
markProcessing(sessionId, taskId) {
|
|
445
608
|
const now = Date.now();
|
|
446
609
|
const state = taskId ? `${now}:${taskId}` : String(now);
|
|
@@ -515,17 +678,31 @@ export class SessionManager {
|
|
|
515
678
|
const result = [];
|
|
516
679
|
const chatDirs = scanChatDirs(this.sessionsDir);
|
|
517
680
|
for (const { dirPath } of chatDirs) {
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
681
|
+
// Startup recovery covers both main and topic sessions. Topic metadata
|
|
682
|
+
// is read strictly; one corrupt committed record must not make the
|
|
683
|
+
// daemon abandon recovery for every other chat.
|
|
684
|
+
const metaDirs = [dirPath, path.join(dirPath, '_threads')];
|
|
685
|
+
for (const metaDir of metaDirs) {
|
|
686
|
+
for (const metaFile of scanMetaFiles(metaDir)) {
|
|
687
|
+
const metaPath = path.join(metaDir, metaFile);
|
|
688
|
+
let session;
|
|
689
|
+
try {
|
|
690
|
+
session = this.readMetaLatest(metaPath);
|
|
691
|
+
}
|
|
692
|
+
catch (error) {
|
|
693
|
+
logger.error(`[SessionManager] Skipping corrupt session metadata during startup recovery: ${metaPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
if (!session?.processingState)
|
|
697
|
+
continue;
|
|
698
|
+
const colonIdx = session.processingState.indexOf(':');
|
|
699
|
+
const ts = parseInt(colonIdx > 0 ? session.processingState.slice(0, colonIdx) : session.processingState, 10);
|
|
700
|
+
if (!isNaN(ts) && (now - ts) < maxAgeMs) {
|
|
701
|
+
result.push(session);
|
|
702
|
+
}
|
|
703
|
+
else {
|
|
704
|
+
this.clearProcessing(session.id);
|
|
705
|
+
}
|
|
529
706
|
}
|
|
530
707
|
}
|
|
531
708
|
}
|
|
@@ -651,6 +828,7 @@ export class SessionManager {
|
|
|
651
828
|
}
|
|
652
829
|
// Create new session
|
|
653
830
|
const sessionMetadata = { ...(metadata || {}) };
|
|
831
|
+
sessionMetadata.__sessionCreatedAt = Date.now();
|
|
654
832
|
const newIdentity = identity ?? this.resolveIdentity(channel, userId, chatType, identityConversationId);
|
|
655
833
|
const resolvedBaseagent = baseagent || 'claude';
|
|
656
834
|
const session = {
|
|
@@ -684,22 +862,86 @@ export class SessionManager {
|
|
|
684
862
|
return session;
|
|
685
863
|
}
|
|
686
864
|
async updateSession(sessionId, updates) {
|
|
865
|
+
// This operation only holds the short synchronous snapshot writer inside
|
|
866
|
+
// persistSession; it must not wait behind a long-running boundary action.
|
|
687
867
|
const loaded = this.loadSessionForUpdate(sessionId);
|
|
688
868
|
if (!loaded)
|
|
689
869
|
return;
|
|
690
870
|
const { current } = loaded;
|
|
871
|
+
if (current.threadId && updates.baseagent !== undefined) {
|
|
872
|
+
const error = new Error('topic baseagent is immutable while backend rotation is managed');
|
|
873
|
+
error.code = 'TOPIC_BASEAGENT_IMMUTABLE';
|
|
874
|
+
throw error;
|
|
875
|
+
}
|
|
876
|
+
if (current.threadId && Object.prototype.hasOwnProperty.call(updates, 'agentSessionId')) {
|
|
877
|
+
const error = new Error('topic backend binding requires rotation or turn-lease activation');
|
|
878
|
+
error.code = 'TOPIC_BACKEND_BINDING_PROTECTED';
|
|
879
|
+
throw error;
|
|
880
|
+
}
|
|
691
881
|
if (updates.baseagent !== undefined)
|
|
692
882
|
current.baseagent = updates.baseagent;
|
|
693
883
|
if (updates.chatType !== undefined)
|
|
694
884
|
current.chatType = updates.chatType;
|
|
695
885
|
if (updates.name !== undefined)
|
|
696
886
|
current.name = updates.name;
|
|
697
|
-
if (updates.metadata !== undefined)
|
|
698
|
-
current.
|
|
699
|
-
|
|
700
|
-
|
|
887
|
+
if (updates.metadata !== undefined) {
|
|
888
|
+
if (current.threadId) {
|
|
889
|
+
for (const key of Object.keys(updates.metadata)) {
|
|
890
|
+
if (TOPIC_METADATA_PROTECTED_KEYS.has(key))
|
|
891
|
+
throw topicMetadataOwnershipError(key);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
current.metadata = {
|
|
895
|
+
...(current.metadata ?? {}),
|
|
896
|
+
...updates.metadata,
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
if ('agentSessionId' in updates) {
|
|
900
|
+
current.agentSessionId = normalizeBackendSessionId(updates.agentSessionId);
|
|
901
|
+
}
|
|
902
|
+
// The generic update API is not allowed to mutate a topic backend
|
|
903
|
+
// boundary. Explicit rotation/activation APIs are the only writers that
|
|
904
|
+
// may change the binding.
|
|
905
|
+
this.persistSession(current, 'sync');
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Apply a route/display metadata patch without accepting backend or turn
|
|
909
|
+
* ownership. `undefined` removes a field from the next snapshot.
|
|
910
|
+
*/
|
|
911
|
+
async patchSessionMetadata(sessionId, patch) {
|
|
912
|
+
const loaded = this.loadSessionForUpdate(sessionId);
|
|
913
|
+
if (!loaded)
|
|
914
|
+
return;
|
|
915
|
+
const { current } = loaded;
|
|
916
|
+
for (const key of Object.keys(patch)) {
|
|
917
|
+
if (current.threadId && TOPIC_METADATA_PROTECTED_KEYS.has(key)) {
|
|
918
|
+
throw topicMetadataOwnershipError(key);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
const metadata = { ...(current.metadata ?? {}) };
|
|
922
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
923
|
+
if (value === undefined)
|
|
924
|
+
delete metadata[key];
|
|
925
|
+
else
|
|
926
|
+
metadata[key] = value;
|
|
927
|
+
}
|
|
928
|
+
current.metadata = metadata;
|
|
701
929
|
this.persistSession(current, 'sync');
|
|
702
930
|
}
|
|
931
|
+
/** Persist only the turn coordinator-owned metadata field. */
|
|
932
|
+
async updateTurnState(sessionId, turnState) {
|
|
933
|
+
const loaded = this.loadSessionForUpdate(sessionId);
|
|
934
|
+
if (!loaded)
|
|
935
|
+
return;
|
|
936
|
+
const { current } = loaded;
|
|
937
|
+
const metadata = { ...(current.metadata ?? {}) };
|
|
938
|
+
if (turnState === undefined)
|
|
939
|
+
delete metadata.turnState;
|
|
940
|
+
else
|
|
941
|
+
metadata.turnState = JSON.parse(JSON.stringify(turnState));
|
|
942
|
+
current.metadata = metadata;
|
|
943
|
+
this.persistSession(current, 'sync', { turnStateMutation: true });
|
|
944
|
+
}
|
|
703
945
|
getOrCreateThreadSession(channel, channelId, threadId, defaultProjectPath, metadata, name, baseagent, selfAID, channelType, peerType, chatType) {
|
|
704
946
|
// 使用精确路径(channelType + selfAID)
|
|
705
947
|
const chatDir = (channelType && selfAID)
|
|
@@ -711,7 +953,14 @@ export class SessionManager {
|
|
|
711
953
|
const metaPath = path.join(chatDir, '_threads', `${existingEntry.sessionId}.jsonl`);
|
|
712
954
|
const existing = this.readMetaLatest(metaPath);
|
|
713
955
|
if (existing) {
|
|
714
|
-
|
|
956
|
+
// Topic JSONL is the authoritative backend boundary. A missing
|
|
957
|
+
// provider file must not be converted into an in-memory UNBOUND view
|
|
958
|
+
// by this generic lookup; doing so would let the next run create a
|
|
959
|
+
// backend without an explicit rotation. Repair/rotation decides how
|
|
960
|
+
// to handle an unavailable bound backend.
|
|
961
|
+
const validSessionId = existing.threadId
|
|
962
|
+
? normalizeBackendSessionId(existing.agentSessionId)
|
|
963
|
+
: this.validateSessionFile(existing);
|
|
715
964
|
let mutated = false;
|
|
716
965
|
if (metadata) {
|
|
717
966
|
const creatorPeerId = existing.metadata?.peerId;
|
|
@@ -831,19 +1080,64 @@ export class SessionManager {
|
|
|
831
1080
|
const active = this.readActive(channel, channelId);
|
|
832
1081
|
if (!active)
|
|
833
1082
|
return;
|
|
1083
|
+
if (active.threadId) {
|
|
1084
|
+
// Topic backend binding is owned exclusively by rotation or an active
|
|
1085
|
+
// TurnLease activation. Keep this legacy channel-scoped API main-session
|
|
1086
|
+
// only so stale callers cannot resurrect a renewed topic backend.
|
|
1087
|
+
logger.warn(`[SessionManager] Ignoring topic agentSessionId update through legacy channel API: sessionId=${active.id}`);
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
834
1090
|
active.agentSessionId = agentSessionId;
|
|
835
1091
|
this.persistSession(active, 'sync');
|
|
836
1092
|
}
|
|
837
|
-
async updateAgentSessionIdBySessionId(sessionId, agentSessionId) {
|
|
1093
|
+
async updateAgentSessionIdBySessionId(sessionId, agentSessionId, turnLease) {
|
|
838
1094
|
const loaded = this.loadSessionForUpdate(sessionId);
|
|
839
1095
|
if (!loaded)
|
|
840
|
-
return;
|
|
1096
|
+
return 'stale';
|
|
841
1097
|
const { current } = loaded;
|
|
1098
|
+
if (current.threadId) {
|
|
1099
|
+
if (turnLease && normalizeBackendSessionId(agentSessionId)) {
|
|
1100
|
+
return this.activateTopicBackend({ sessionId, lease: turnLease, agentSessionId });
|
|
1101
|
+
}
|
|
1102
|
+
// Topic backend binding is never writable through the legacy API. Empty
|
|
1103
|
+
// callbacks may still clear runner-local state, but cannot clear the
|
|
1104
|
+
// authoritative Session without a boundary-scoped operation.
|
|
1105
|
+
logger.warn(`[SessionManager] Ignoring topic agentSessionId update without an active turn lease: sessionId=${sessionId}`);
|
|
1106
|
+
return 'stale';
|
|
1107
|
+
}
|
|
842
1108
|
current.agentSessionId = agentSessionId;
|
|
843
1109
|
const wrote = this.persistSession(current, 'sync');
|
|
844
1110
|
if (wrote) {
|
|
845
1111
|
logger.info(`[SessionManager] Updating agent_session_id: sessionId=${sessionId}, agentSessionId=${agentSessionId}`);
|
|
846
1112
|
}
|
|
1113
|
+
return 'legacy_updated';
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* Clear a main-session backend only when the durable latest snapshot still
|
|
1117
|
+
* points at the caller's expected ID. This is the recovery counterpart to
|
|
1118
|
+
* provider-side "session not found" errors and must never rotate a topic.
|
|
1119
|
+
*/
|
|
1120
|
+
async clearMainSessionBackendIfMatches(sessionId, expectedAgentSessionId, metadataKeys = []) {
|
|
1121
|
+
const loaded = this.loadSessionForUpdate(sessionId);
|
|
1122
|
+
if (!loaded)
|
|
1123
|
+
return 'missing';
|
|
1124
|
+
const { current } = loaded;
|
|
1125
|
+
if (current.threadId)
|
|
1126
|
+
return 'topic';
|
|
1127
|
+
const currentId = normalizeBackendSessionId(current.agentSessionId);
|
|
1128
|
+
if (!currentId)
|
|
1129
|
+
return 'already_clear';
|
|
1130
|
+
if (currentId !== normalizeBackendSessionId(expectedAgentSessionId))
|
|
1131
|
+
return 'mismatch';
|
|
1132
|
+
current.agentSessionId = undefined;
|
|
1133
|
+
if (metadataKeys.length > 0 && current.metadata) {
|
|
1134
|
+
const metadata = { ...current.metadata };
|
|
1135
|
+
for (const key of metadataKeys)
|
|
1136
|
+
delete metadata[key];
|
|
1137
|
+
current.metadata = metadata;
|
|
1138
|
+
}
|
|
1139
|
+
this.persistSession(current, 'sync');
|
|
1140
|
+
return 'cleared';
|
|
847
1141
|
}
|
|
848
1142
|
async switchAgent(channel, channelId, projectPath, newBaseagent) {
|
|
849
1143
|
const inheritedChatType = this.getActiveChatType(channel, channelId);
|
|
@@ -965,7 +1259,10 @@ export class SessionManager {
|
|
|
965
1259
|
const session = this.readMetaLatest(metaPath);
|
|
966
1260
|
if (!session)
|
|
967
1261
|
return undefined;
|
|
968
|
-
|
|
1262
|
+
// Do not consult provider-file existence to derive topic binding state;
|
|
1263
|
+
// the topic JSONL boundary is authoritative and may intentionally point
|
|
1264
|
+
// at a backend that needs an explicit /renew or repair operation.
|
|
1265
|
+
const validSessionId = normalizeBackendSessionId(session.agentSessionId);
|
|
969
1266
|
return { ...session, agentSessionId: validSessionId };
|
|
970
1267
|
}
|
|
971
1268
|
async listSessions(channel, channelId) {
|
|
@@ -1155,6 +1452,7 @@ export class SessionManager {
|
|
|
1155
1452
|
throw new Error(`[SessionManager] createNewSession: baseagent is empty for channel=${channel} channelId=${channelId}`);
|
|
1156
1453
|
}
|
|
1157
1454
|
const metadata = newSessionStableMetadata(inheritedChatType, activeMetadata, identityMetadata);
|
|
1455
|
+
metadata.__sessionCreatedAt = Date.now();
|
|
1158
1456
|
if (identityMetadata?.sessionBoundaryOperationId) {
|
|
1159
1457
|
metadata.sessionBoundaryOperationId = identityMetadata.sessionBoundaryOperationId;
|
|
1160
1458
|
}
|
|
@@ -1317,6 +1615,7 @@ export class SessionManager {
|
|
|
1317
1615
|
delete metadata.agentSessions;
|
|
1318
1616
|
delete metadata.resumeAt;
|
|
1319
1617
|
delete metadata.replyContext;
|
|
1618
|
+
metadata.__sessionRenewedAt = Date.now();
|
|
1320
1619
|
const session = {
|
|
1321
1620
|
id: generateSessionId(),
|
|
1322
1621
|
channel: sourceSession.channel,
|