evolcore 0.0.20 → 0.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +58 -9
- package/dist/agents/baseagent.js +10 -6
- package/dist/agents/claude-runner.js +379 -108
- package/dist/agents/codex-app-server-client.js +10 -2
- package/dist/agents/codex-runner.js +402 -135
- package/dist/agents/ecagent-runner.js +171 -61
- package/dist/agents/gemini-runner.js +130 -30
- package/dist/agents/request-identity.js +25 -0
- package/dist/agents/runner-types.js +19 -0
- package/dist/aun/aid/agentmd.js +59 -2
- package/dist/aun/aid/identity.js +4 -1
- package/dist/aun/aid/index.js +1 -1
- package/dist/aun/msg/group.js +72 -6
- package/dist/aun/msg/history.js +213 -36
- package/dist/aun/msg/managed-operation.js +58 -9
- package/dist/aun/msg/p2p.js +5 -0
- package/dist/aun/outbox.js +182 -80
- package/dist/aun/service-proxy.js +43 -25
- package/dist/channels/aun.js +409 -88
- package/dist/channels/daemon.js +6 -1
- package/dist/cli/agent-command.js +4 -3
- package/dist/cli/agent.js +66 -56
- package/dist/cli/aun-commands.js +177 -42
- package/dist/cli/command-log.js +10 -11
- package/dist/cli/contact.js +1 -0
- package/dist/cli/daemon-commands.js +69 -115
- package/dist/cli/init.js +27 -15
- package/dist/cli/task-context.js +46 -0
- package/dist/cli/trigger-command.js +1 -1
- package/dist/cli/watch-logs.js +10 -3
- package/dist/config/builtin-roles.js +1 -0
- package/dist/config/config-field-policy.js +16 -5
- package/dist/config/config-manager.js +135 -17
- package/dist/config/contact-operation-service.js +32 -1
- package/dist/config/contact-request-service.js +44 -0
- package/dist/config/daemon-services.js +186 -0
- package/dist/config/gateway-config.js +20 -9
- package/dist/config/role-service.js +54 -3
- package/dist/config/schema-migration.js +550 -0
- package/dist/config-store.js +151 -9
- package/dist/core/agent-application-service.js +279 -0
- package/dist/core/audit/log-integrity.js +102 -0
- package/dist/core/auth/agent-delegation.js +31 -1
- package/dist/core/auth/auth-gateway.js +33 -4
- package/dist/core/auth/authorization-audit.js +150 -2
- package/dist/core/auth/operation-authorizer.js +41 -1
- package/dist/core/auth/operation-catalog.js +9 -1
- package/dist/core/bootstrap-service.js +6 -2
- package/dist/core/causation/aun-association.js +7 -4
- package/dist/core/command/agent-control.js +56 -16
- package/dist/core/command/command-handler.js +290 -44
- package/dist/core/command/connect-menu.js +3 -4
- package/dist/core/command/group-menu.js +5 -7
- package/dist/core/command/menu-handler.js +279 -80
- package/dist/core/command/role-menu.js +21 -11
- package/dist/core/command/slash-gate.js +85 -18
- package/dist/core/command/slash-handler.js +350 -32
- package/dist/core/event-catalog.js +5 -0
- package/dist/core/evolagent.js +4 -0
- package/dist/core/handoff/dispatcher.js +4 -0
- package/dist/core/handoff/runtime.js +10 -0
- package/dist/core/handoff/store.js +32 -9
- package/dist/core/inference/text-inference.js +7 -15
- package/dist/core/message/im-renderer.js +83 -84
- package/dist/core/message/{inbound-admission.js → message-admission.js} +51 -1
- package/dist/core/message/message-bridge.js +124 -10
- package/dist/core/message/message-log.js +14 -7
- package/dist/core/message/message-queue.js +206 -16
- package/dist/core/message/message-utils.js +12 -5
- package/dist/core/message/response-engine.js +486 -68
- package/dist/core/message/send-receipt.js +1 -0
- package/dist/core/message/stream-debouncer.js +9 -2
- package/dist/core/model/model-catalog.js +23 -15
- package/dist/core/model/model-diagnostics.js +28 -10
- package/dist/core/permission/approval-gateway.js +180 -6
- package/dist/core/permission/ec-command-parser.js +410 -54
- package/dist/core/permission/mode.js +18 -3
- package/dist/core/{protected-paths.js → permission/protected-paths.js} +17 -5
- package/dist/core/permission/readonly-shell-query.js +263 -9
- package/dist/core/permission/sandbox-runtime.js +159 -1
- package/dist/core/permission/tool-policy.js +575 -21
- package/dist/core/session/session-fs-store.js +154 -5
- package/dist/core/session/session-manager.js +299 -30
- package/dist/core/session/session-renew.js +19 -12
- package/dist/core/session/session-turn-coordinator.js +11 -4
- package/dist/eck/kit-renderer.js +1 -1
- package/dist/index.js +253 -46
- package/dist/ipc.js +374 -24
- package/dist/paths.js +64 -7
- package/dist/response-system/context-builder.js +1 -7
- package/dist/trigger/anomaly-store.js +1 -0
- package/dist/trigger/feedback.js +56 -5
- package/dist/trigger/history.js +79 -4
- package/dist/trigger/legacy-session-history.js +2 -2
- package/dist/trigger/parser.js +3 -2
- package/dist/trigger/validation.js +6 -1
- package/dist/utils/atomic-write.js +27 -0
- package/dist/utils/ecweb-utils.js +16 -2
- package/dist/utils/error-utils.js +4 -1
- package/dist/utils/logger.js +21 -2
- package/dist/utils/process-tree-stats.js +24 -4
- package/dist/utils/process-tree-worker.js +31 -0
- package/dist/utils/project-path.js +1 -2
- package/kits/docs/INDEX.md +1 -1
- package/kits/docs/evolcore/INDEX.md +1 -1
- package/kits/docs/evolcore/contact.md +7 -1
- package/kits/docs/evolcore/msg.md +16 -0
- package/kits/schemas/_meta.json +4 -2
- package/kits/schemas/agent-config.schema.11.json +13 -0
- package/kits/schemas/daemon.schema.5.json +0 -1
- package/kits/schemas/daemon.schema.6.json +131 -0
- package/kits/schemas/defaults.schema.5.json +15 -3
- package/kits/schemas/migrations/README.md +3 -1
- package/kits/schemas/relation-config.schema.8.json +13 -0
- package/kits/schemas/role-config.schema.1.json +1 -2
- package/kits/schemas/single-session.schema.3.json +32 -0
- package/kits/templates/roles/admin.json +1 -0
- package/kits/templates/roles/member.json +1 -0
- package/kits/templates/roles/visitor.json +1 -0
- package/package.json +6 -3
- package/skills/eclink/SKILL.md +2 -0
- package/dist/config/aun-gateway-config.js +0 -2
|
@@ -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
|
}
|
|
@@ -684,22 +861,86 @@ export class SessionManager {
|
|
|
684
861
|
return session;
|
|
685
862
|
}
|
|
686
863
|
async updateSession(sessionId, updates) {
|
|
864
|
+
// This operation only holds the short synchronous snapshot writer inside
|
|
865
|
+
// persistSession; it must not wait behind a long-running boundary action.
|
|
687
866
|
const loaded = this.loadSessionForUpdate(sessionId);
|
|
688
867
|
if (!loaded)
|
|
689
868
|
return;
|
|
690
869
|
const { current } = loaded;
|
|
870
|
+
if (current.threadId && updates.baseagent !== undefined) {
|
|
871
|
+
const error = new Error('topic baseagent is immutable while backend rotation is managed');
|
|
872
|
+
error.code = 'TOPIC_BASEAGENT_IMMUTABLE';
|
|
873
|
+
throw error;
|
|
874
|
+
}
|
|
875
|
+
if (current.threadId && Object.prototype.hasOwnProperty.call(updates, 'agentSessionId')) {
|
|
876
|
+
const error = new Error('topic backend binding requires rotation or turn-lease activation');
|
|
877
|
+
error.code = 'TOPIC_BACKEND_BINDING_PROTECTED';
|
|
878
|
+
throw error;
|
|
879
|
+
}
|
|
691
880
|
if (updates.baseagent !== undefined)
|
|
692
881
|
current.baseagent = updates.baseagent;
|
|
693
882
|
if (updates.chatType !== undefined)
|
|
694
883
|
current.chatType = updates.chatType;
|
|
695
884
|
if (updates.name !== undefined)
|
|
696
885
|
current.name = updates.name;
|
|
697
|
-
if (updates.metadata !== undefined)
|
|
698
|
-
current.
|
|
699
|
-
|
|
700
|
-
|
|
886
|
+
if (updates.metadata !== undefined) {
|
|
887
|
+
if (current.threadId) {
|
|
888
|
+
for (const key of Object.keys(updates.metadata)) {
|
|
889
|
+
if (TOPIC_METADATA_PROTECTED_KEYS.has(key))
|
|
890
|
+
throw topicMetadataOwnershipError(key);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
current.metadata = {
|
|
894
|
+
...(current.metadata ?? {}),
|
|
895
|
+
...updates.metadata,
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
if ('agentSessionId' in updates) {
|
|
899
|
+
current.agentSessionId = normalizeBackendSessionId(updates.agentSessionId);
|
|
900
|
+
}
|
|
901
|
+
// The generic update API is not allowed to mutate a topic backend
|
|
902
|
+
// boundary. Explicit rotation/activation APIs are the only writers that
|
|
903
|
+
// may change the binding.
|
|
904
|
+
this.persistSession(current, 'sync');
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Apply a route/display metadata patch without accepting backend or turn
|
|
908
|
+
* ownership. `undefined` removes a field from the next snapshot.
|
|
909
|
+
*/
|
|
910
|
+
async patchSessionMetadata(sessionId, patch) {
|
|
911
|
+
const loaded = this.loadSessionForUpdate(sessionId);
|
|
912
|
+
if (!loaded)
|
|
913
|
+
return;
|
|
914
|
+
const { current } = loaded;
|
|
915
|
+
for (const key of Object.keys(patch)) {
|
|
916
|
+
if (current.threadId && TOPIC_METADATA_PROTECTED_KEYS.has(key)) {
|
|
917
|
+
throw topicMetadataOwnershipError(key);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
const metadata = { ...(current.metadata ?? {}) };
|
|
921
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
922
|
+
if (value === undefined)
|
|
923
|
+
delete metadata[key];
|
|
924
|
+
else
|
|
925
|
+
metadata[key] = value;
|
|
926
|
+
}
|
|
927
|
+
current.metadata = metadata;
|
|
701
928
|
this.persistSession(current, 'sync');
|
|
702
929
|
}
|
|
930
|
+
/** Persist only the turn coordinator-owned metadata field. */
|
|
931
|
+
async updateTurnState(sessionId, turnState) {
|
|
932
|
+
const loaded = this.loadSessionForUpdate(sessionId);
|
|
933
|
+
if (!loaded)
|
|
934
|
+
return;
|
|
935
|
+
const { current } = loaded;
|
|
936
|
+
const metadata = { ...(current.metadata ?? {}) };
|
|
937
|
+
if (turnState === undefined)
|
|
938
|
+
delete metadata.turnState;
|
|
939
|
+
else
|
|
940
|
+
metadata.turnState = JSON.parse(JSON.stringify(turnState));
|
|
941
|
+
current.metadata = metadata;
|
|
942
|
+
this.persistSession(current, 'sync', { turnStateMutation: true });
|
|
943
|
+
}
|
|
703
944
|
getOrCreateThreadSession(channel, channelId, threadId, defaultProjectPath, metadata, name, baseagent, selfAID, channelType, peerType, chatType) {
|
|
704
945
|
// 使用精确路径(channelType + selfAID)
|
|
705
946
|
const chatDir = (channelType && selfAID)
|
|
@@ -711,7 +952,14 @@ export class SessionManager {
|
|
|
711
952
|
const metaPath = path.join(chatDir, '_threads', `${existingEntry.sessionId}.jsonl`);
|
|
712
953
|
const existing = this.readMetaLatest(metaPath);
|
|
713
954
|
if (existing) {
|
|
714
|
-
|
|
955
|
+
// Topic JSONL is the authoritative backend boundary. A missing
|
|
956
|
+
// provider file must not be converted into an in-memory UNBOUND view
|
|
957
|
+
// by this generic lookup; doing so would let the next run create a
|
|
958
|
+
// backend without an explicit rotation. Repair/rotation decides how
|
|
959
|
+
// to handle an unavailable bound backend.
|
|
960
|
+
const validSessionId = existing.threadId
|
|
961
|
+
? normalizeBackendSessionId(existing.agentSessionId)
|
|
962
|
+
: this.validateSessionFile(existing);
|
|
715
963
|
let mutated = false;
|
|
716
964
|
if (metadata) {
|
|
717
965
|
const creatorPeerId = existing.metadata?.peerId;
|
|
@@ -831,19 +1079,37 @@ export class SessionManager {
|
|
|
831
1079
|
const active = this.readActive(channel, channelId);
|
|
832
1080
|
if (!active)
|
|
833
1081
|
return;
|
|
1082
|
+
if (active.threadId) {
|
|
1083
|
+
// Topic backend binding is owned exclusively by rotation or an active
|
|
1084
|
+
// TurnLease activation. Keep this legacy channel-scoped API main-session
|
|
1085
|
+
// only so stale callers cannot resurrect a renewed topic backend.
|
|
1086
|
+
logger.warn(`[SessionManager] Ignoring topic agentSessionId update through legacy channel API: sessionId=${active.id}`);
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
834
1089
|
active.agentSessionId = agentSessionId;
|
|
835
1090
|
this.persistSession(active, 'sync');
|
|
836
1091
|
}
|
|
837
|
-
async updateAgentSessionIdBySessionId(sessionId, agentSessionId) {
|
|
1092
|
+
async updateAgentSessionIdBySessionId(sessionId, agentSessionId, turnLease) {
|
|
838
1093
|
const loaded = this.loadSessionForUpdate(sessionId);
|
|
839
1094
|
if (!loaded)
|
|
840
|
-
return;
|
|
1095
|
+
return 'stale';
|
|
841
1096
|
const { current } = loaded;
|
|
1097
|
+
if (current.threadId) {
|
|
1098
|
+
if (turnLease && normalizeBackendSessionId(agentSessionId)) {
|
|
1099
|
+
return this.activateTopicBackend({ sessionId, lease: turnLease, agentSessionId });
|
|
1100
|
+
}
|
|
1101
|
+
// Topic backend binding is never writable through the legacy API. Empty
|
|
1102
|
+
// callbacks may still clear runner-local state, but cannot clear the
|
|
1103
|
+
// authoritative Session without a boundary-scoped operation.
|
|
1104
|
+
logger.warn(`[SessionManager] Ignoring topic agentSessionId update without an active turn lease: sessionId=${sessionId}`);
|
|
1105
|
+
return 'stale';
|
|
1106
|
+
}
|
|
842
1107
|
current.agentSessionId = agentSessionId;
|
|
843
1108
|
const wrote = this.persistSession(current, 'sync');
|
|
844
1109
|
if (wrote) {
|
|
845
1110
|
logger.info(`[SessionManager] Updating agent_session_id: sessionId=${sessionId}, agentSessionId=${agentSessionId}`);
|
|
846
1111
|
}
|
|
1112
|
+
return 'legacy_updated';
|
|
847
1113
|
}
|
|
848
1114
|
async switchAgent(channel, channelId, projectPath, newBaseagent) {
|
|
849
1115
|
const inheritedChatType = this.getActiveChatType(channel, channelId);
|
|
@@ -965,7 +1231,10 @@ export class SessionManager {
|
|
|
965
1231
|
const session = this.readMetaLatest(metaPath);
|
|
966
1232
|
if (!session)
|
|
967
1233
|
return undefined;
|
|
968
|
-
|
|
1234
|
+
// Do not consult provider-file existence to derive topic binding state;
|
|
1235
|
+
// the topic JSONL boundary is authoritative and may intentionally point
|
|
1236
|
+
// at a backend that needs an explicit /renew or repair operation.
|
|
1237
|
+
const validSessionId = normalizeBackendSessionId(session.agentSessionId);
|
|
969
1238
|
return { ...session, agentSessionId: validSessionId };
|
|
970
1239
|
}
|
|
971
1240
|
async listSessions(channel, channelId) {
|
|
@@ -5,7 +5,6 @@ import { isTransientProtocolMessage, messageLogPath } from '../message/message-l
|
|
|
5
5
|
import { readAllJsonlLines } from './session-fs-store.js';
|
|
6
6
|
import { ResponseModeRegistry } from '../../response-system/registry.js';
|
|
7
7
|
import { registerBuiltinModes } from '../../response-system/modes/index.js';
|
|
8
|
-
import { resolveEffectiveResponseModeConfig } from '../../response-system/config-resolver.js';
|
|
9
8
|
import { normalizeBaseagent } from '../../agents/baseagent.js';
|
|
10
9
|
const DEFAULT_AFTER_HOURS = 24;
|
|
11
10
|
const DEFAULT_FALLBACK = 'continue';
|
|
@@ -230,23 +229,31 @@ export class SessionRenewService {
|
|
|
230
229
|
}, { cache: true, expand: true });
|
|
231
230
|
}
|
|
232
231
|
resolveAuxiliaryConfig(config, baseagent) {
|
|
233
|
-
const resolved = resolveEffectiveResponseModeConfig(this.responseModeRegistry, config);
|
|
234
|
-
const rawModel = resolved.modeConfig.auxiliaryModel;
|
|
235
232
|
const canonicalBaseagent = normalizeBaseagent(baseagent).canonical;
|
|
236
|
-
const
|
|
237
|
-
?
|
|
238
|
-
:
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
233
|
+
const baseagentConfig = canonicalBaseagent !== 'unknown'
|
|
234
|
+
? config.baseagents?.[canonicalBaseagent]
|
|
235
|
+
: undefined;
|
|
236
|
+
const rawBaseagentConfig = baseagentConfig && typeof baseagentConfig === 'object'
|
|
237
|
+
? baseagentConfig
|
|
238
|
+
: undefined;
|
|
239
|
+
// Auxiliary inference is owned exclusively by the selected baseagent. Old
|
|
240
|
+
// response-mode bucket values must be migrated before they can take effect.
|
|
241
|
+
const configuredMode = typeof config.responseMode === 'string' && config.responseMode.trim()
|
|
242
|
+
? config.responseMode.trim()
|
|
243
|
+
: undefined;
|
|
244
|
+
const responseMode = configuredMode && this.responseModeRegistry.has(configuredMode)
|
|
245
|
+
? configuredMode
|
|
246
|
+
: this.responseModeRegistry.getPreferredId() ?? 'single-session';
|
|
247
|
+
const rawModel = rawBaseagentConfig?.auxiliaryModel;
|
|
248
|
+
const rawEffort = rawBaseagentConfig?.auxiliaryEffort;
|
|
249
|
+
const model = typeof rawModel === 'string' && rawModel.trim()
|
|
250
|
+
? rawModel.trim()
|
|
243
251
|
: undefined;
|
|
244
|
-
const rawEffort = resolved.modeConfig.auxiliaryEffort;
|
|
245
252
|
const effort = typeof rawEffort === 'string' && AUXILIARY_EFFORTS.has(rawEffort)
|
|
246
253
|
? rawEffort
|
|
247
254
|
: undefined;
|
|
248
255
|
return {
|
|
249
|
-
responseMode
|
|
256
|
+
responseMode,
|
|
250
257
|
model,
|
|
251
258
|
effort,
|
|
252
259
|
invalidEffort: rawEffort !== undefined && effort === undefined,
|