evolcore 0.0.19 → 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.
Files changed (136) hide show
  1. package/CHANGELOG.md +43 -1
  2. package/README.md +60 -9
  3. package/bin/install-codex-managed-hooks.mjs +61 -0
  4. package/dist/agents/baseagent.js +10 -6
  5. package/dist/agents/claude-runner.js +383 -111
  6. package/dist/agents/codex-app-server-client.js +10 -2
  7. package/dist/agents/codex-runner.js +405 -137
  8. package/dist/agents/ecagent-runner.js +174 -63
  9. package/dist/agents/gemini-runner.js +130 -30
  10. package/dist/agents/request-identity.js +25 -0
  11. package/dist/agents/runner-types.js +19 -0
  12. package/dist/aun/aid/agentmd.js +66 -2
  13. package/dist/aun/aid/identity.js +4 -1
  14. package/dist/aun/aid/index.js +1 -1
  15. package/dist/aun/msg/group.js +86 -9
  16. package/dist/aun/msg/history.js +213 -36
  17. package/dist/aun/msg/managed-operation.js +58 -9
  18. package/dist/aun/msg/p2p.js +26 -11
  19. package/dist/aun/outbox.js +295 -68
  20. package/dist/aun/service-proxy.js +43 -25
  21. package/dist/channels/aun.js +1004 -273
  22. package/dist/channels/daemon.js +6 -1
  23. package/dist/cli/agent-command.js +4 -3
  24. package/dist/cli/agent.js +66 -56
  25. package/dist/cli/aun-commands.js +177 -42
  26. package/dist/cli/command-log.js +10 -11
  27. package/dist/cli/contact.js +1 -0
  28. package/dist/cli/daemon-commands.js +81 -121
  29. package/dist/cli/index.js +1 -0
  30. package/dist/cli/init.js +76 -24
  31. package/dist/cli/restart-monitor.js +3 -3
  32. package/dist/cli/task-context.js +46 -0
  33. package/dist/cli/trigger-command.js +1 -1
  34. package/dist/cli/watch-logs.js +10 -3
  35. package/dist/config/builtin-roles.js +1 -0
  36. package/dist/config/config-field-policy.js +16 -5
  37. package/dist/config/config-manager.js +226 -24
  38. package/dist/config/config-operation-service.js +1 -2
  39. package/dist/config/contact-operation-service.js +32 -1
  40. package/dist/config/contact-request-service.js +44 -0
  41. package/dist/config/daemon-services.js +186 -0
  42. package/dist/config/gateway-config.js +29 -16
  43. package/dist/config/lifecycle.js +16 -5
  44. package/dist/config/role-service.js +54 -3
  45. package/dist/config/schema-migration.js +550 -0
  46. package/dist/config-store.js +161 -12
  47. package/dist/core/agent-application-service.js +279 -0
  48. package/dist/core/audit/log-integrity.js +102 -0
  49. package/dist/core/auth/agent-delegation.js +31 -1
  50. package/dist/core/auth/auth-gateway.js +33 -4
  51. package/dist/core/auth/authorization-audit.js +155 -4
  52. package/dist/core/auth/operation-authorizer.js +41 -1
  53. package/dist/core/auth/operation-catalog.js +9 -1
  54. package/dist/core/bootstrap-messages.js +2 -2
  55. package/dist/core/bootstrap-service.js +27 -38
  56. package/dist/core/causation/aun-association.js +7 -4
  57. package/dist/core/channel-loader.js +0 -2
  58. package/dist/core/command/agent-control.js +56 -16
  59. package/dist/core/command/command-handler.js +290 -44
  60. package/dist/core/command/connect-menu.js +3 -4
  61. package/dist/core/command/group-menu.js +5 -7
  62. package/dist/core/command/menu-handler.js +279 -80
  63. package/dist/core/command/role-menu.js +21 -11
  64. package/dist/core/command/slash-gate.js +85 -18
  65. package/dist/core/command/slash-handler.js +350 -32
  66. package/dist/core/event-catalog.js +5 -0
  67. package/dist/core/evolagent.js +9 -4
  68. package/dist/core/handoff/dispatcher.js +4 -0
  69. package/dist/core/handoff/runtime.js +10 -0
  70. package/dist/core/handoff/store.js +32 -9
  71. package/dist/core/inference/text-inference.js +7 -15
  72. package/dist/core/message/im-renderer.js +83 -84
  73. package/dist/core/message/{inbound-admission.js → message-admission.js} +51 -1
  74. package/dist/core/message/message-bridge.js +124 -10
  75. package/dist/core/message/message-log.js +14 -7
  76. package/dist/core/message/message-queue.js +206 -16
  77. package/dist/core/message/message-utils.js +12 -5
  78. package/dist/core/message/response-engine.js +495 -72
  79. package/dist/core/message/send-receipt.js +1 -0
  80. package/dist/core/message/stream-debouncer.js +9 -2
  81. package/dist/core/model/model-catalog.js +23 -15
  82. package/dist/core/model/model-diagnostics.js +28 -10
  83. package/dist/core/permission/approval-gateway.js +180 -6
  84. package/dist/core/permission/ec-command-parser.js +465 -56
  85. package/dist/core/permission/mode.js +18 -3
  86. package/dist/core/{protected-paths.js → permission/protected-paths.js} +17 -5
  87. package/dist/core/permission/readonly-shell-query.js +263 -9
  88. package/dist/core/permission/sandbox-runtime.js +205 -13
  89. package/dist/core/permission/tool-policy.js +673 -62
  90. package/dist/core/relation/peer-identity.js +18 -0
  91. package/dist/core/session/session-fs-store.js +154 -5
  92. package/dist/core/session/session-manager.js +299 -30
  93. package/dist/core/session/session-renew.js +19 -12
  94. package/dist/core/session/session-turn-coordinator.js +11 -4
  95. package/dist/eck/kit-renderer.js +18 -9
  96. package/dist/index.js +283 -65
  97. package/dist/ipc.js +374 -24
  98. package/dist/paths.js +64 -7
  99. package/dist/response-system/context-builder.js +1 -7
  100. package/dist/trigger/anomaly-store.js +1 -0
  101. package/dist/trigger/feedback.js +56 -5
  102. package/dist/trigger/history.js +79 -4
  103. package/dist/trigger/legacy-session-history.js +2 -2
  104. package/dist/trigger/parser.js +3 -2
  105. package/dist/trigger/validation.js +6 -1
  106. package/dist/utils/atomic-write.js +27 -0
  107. package/dist/utils/ecweb-utils.js +16 -2
  108. package/dist/utils/error-utils.js +4 -1
  109. package/dist/utils/logger.js +21 -2
  110. package/dist/utils/process-tree-stats.js +24 -4
  111. package/dist/utils/process-tree-worker.js +31 -0
  112. package/dist/utils/project-path.js +1 -2
  113. package/dist/utils/stats.js +52 -18
  114. package/dist/utils/welcome.js +2 -2
  115. package/kits/docs/INDEX.md +1 -1
  116. package/kits/docs/evolcore/INDEX.md +1 -1
  117. package/kits/docs/evolcore/contact.md +7 -1
  118. package/kits/docs/evolcore/msg.md +16 -0
  119. package/kits/rules/01-overview.md +1 -1
  120. package/kits/rules/03-identity.md +1 -1
  121. package/kits/rules/05-venue.md +1 -1
  122. package/kits/schemas/_meta.json +10 -5
  123. package/kits/schemas/agent-config.schema.10.json +2 -1
  124. package/kits/schemas/agent-config.schema.11.json +421 -0
  125. package/kits/schemas/daemon.schema.5.json +135 -0
  126. package/kits/schemas/daemon.schema.6.json +131 -0
  127. package/kits/schemas/defaults.schema.5.json +119 -0
  128. package/kits/schemas/migrations/README.md +3 -1
  129. package/kits/schemas/relation-config.schema.8.json +13 -0
  130. package/kits/schemas/role-config.schema.1.json +1 -2
  131. package/kits/schemas/single-session.schema.3.json +32 -0
  132. package/kits/templates/roles/admin.json +1 -0
  133. package/kits/templates/roles/member.json +1 -0
  134. package/kits/templates/roles/visitor.json +1 -0
  135. package/package.json +7 -3
  136. package/skills/eclink/SKILL.md +2 -0
@@ -151,6 +151,24 @@ export class PeerIdentityCache {
151
151
  * @param forceRefresh 强制刷新(忽略缓存时效)
152
152
  */
153
153
  static async resolve(channelType, peerId, agentDir, store, forceRefresh = false) {
154
+ // A malformed transport envelope must never turn into a relation key such
155
+ // as `aun#` or trigger an agent.md lookup for an empty AID. Keep the
156
+ // fallback in-memory so callers can safely render an unknown peer without
157
+ // creating a bogus relation directory.
158
+ if (typeof peerId !== 'string' || !peerId.trim()) {
159
+ logger.debug(`[PeerIdentityCache] Ignored empty peer id: channel=${channelType}`);
160
+ return {
161
+ aid: '',
162
+ type: 'unknown',
163
+ isAgent: true,
164
+ agentMdHash: '',
165
+ agentMdUpdatedAt: 0,
166
+ verifiedAt: 0,
167
+ lastCheckedAt: Date.now(),
168
+ source: 'unknown',
169
+ };
170
+ }
171
+ peerId = peerId.trim();
154
172
  // 1. 缓存检查
155
173
  if (!forceRefresh && !this.needsRefresh(channelType, peerId, agentDir)) {
156
174
  const cached = this.get(channelType, peerId, agentDir);
@@ -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
- fs.writeSync(fd, line);
161
- fs.fsyncSync(fd);
162
- fs.closeSync(fd);
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
- appendJsonl(this.metaPathForSession(session), file);
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 lastMeta = readLastJsonlLine(this.metaPathForSession(session));
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 file = readLastJsonlLine(metaFilePath);
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. 优先读 active.json(如果 active.id === sessionId)—— 当前状态
351
- * 3. 否则 fallback .jsonl 末行 —— 非活跃 session 的更新(如多 session 并存时改非 active 那个)
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
- // 优先用 active.json 的当前状态(如果它就是这个 sessionId)
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
- for (const metaFile of scanMetaFiles(dirPath)) {
519
- const session = this.readMetaLatest(path.join(dirPath, metaFile));
520
- if (!session?.processingState)
521
- continue;
522
- const colonIdx = session.processingState.indexOf(':');
523
- const ts = parseInt(colonIdx > 0 ? session.processingState.slice(0, colonIdx) : session.processingState, 10);
524
- if (!isNaN(ts) && (now - ts) < maxAgeMs) {
525
- result.push(session);
526
- }
527
- else {
528
- this.clearProcessing(session.id);
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.metadata = updates.metadata;
699
- if ('agentSessionId' in updates)
700
- current.agentSessionId = updates.agentSessionId ?? undefined;
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
- const validSessionId = this.validateSessionFile(existing);
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
- const validSessionId = this.validateSessionFile(session);
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) {