@yeaft/webchat-agent 1.0.680 → 1.0.681

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.
@@ -1 +1 @@
1
- {"version":"1.0.680"}
1
+ {"version":"1.0.681"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.680",
3
+ "version": "1.0.681",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -17,9 +17,10 @@
17
17
  * Reference: yeaft-yeaft-core-systems.md §4.1, yeaft-yeaft-brainstorm-v5.1.md
18
18
  */
19
19
 
20
- import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync, statSync, appendFileSync, rmSync } from 'fs';
20
+ import { accessSync, constants, existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync, statSync, appendFileSync, rmSync } from 'fs';
21
21
  import { createHash, randomUUID } from 'node:crypto';
22
22
  import { hostname } from 'node:os';
23
+ import { DatabaseSync } from 'node:sqlite';
23
24
  import { join, basename, resolve } from 'path';
24
25
  import { isPermissionError } from '../init.js';
25
26
  import { writeAtomic } from '../storage/atomic.js';
@@ -33,7 +34,7 @@ import {
33
34
  } from './visible-entry.js';
34
35
  import { copyPersistedSessionAttachments, readPersistedAttachmentFile, persistYeaftAttachments, MAX_TOTAL_BYTES } from '../attachments.js';
35
36
  import { markConversationDirty } from './history-index-state.js';
36
- import { historyError, isHumanRequest, messageOrigin, historicalReplayRow, planSessionMerge, materializeSessionMerge } from './session-history.js';
37
+ import { historyError, isHumanRequest, messageOrigin, planSessionMerge, materializeSessionMerge, materializeSessionCopy, sessionCopyOrigins } from './session-history.js';
37
38
 
38
39
  /**
39
40
  * Default cold-start "recent window" size, expressed in TURNS (not raw
@@ -84,6 +85,7 @@ const SEGMENT_TARGET_BYTES = 1024 * 1024;
84
85
  const SEGMENT_FIRST_NAME = '000001.jsonl';
85
86
  const MESSAGE_SEQUENCE_FILE = 'message-sequence.json';
86
87
  const MESSAGE_SEQUENCE_LOCK_DIR = 'message-sequence.lock';
88
+ const MESSAGE_SEQUENCE_MUTEX_FILE = 'message-sequence-lock.db';
87
89
  const MESSAGE_SEQUENCE_LOCK_STALE_MS = 30_000;
88
90
  const MESSAGE_SEQUENCE_LOCK_ATTEMPTS = 200;
89
91
  const MESSAGE_SEQUENCE_LOCK_RETRY_MS = 5;
@@ -142,38 +144,42 @@ function sleepSync(ms) {
142
144
  Atomics.wait(buffer, 0, 0, ms);
143
145
  }
144
146
 
145
- function processIsAlive(pid) {
146
- if (!Number.isInteger(pid) || pid <= 0) return false;
147
+ // The SQLite transaction serializes BOTH reservation and abandoned-directory
148
+ // recovery. Kernel locks disappear on process exit; unlike rename-after-check,
149
+ // no contender can replace the observed directory while another reclaims it.
150
+ function withMessageSequenceMutex(root, callback) {
151
+ const path = join(root, MESSAGE_SEQUENCE_MUTEX_FILE);
152
+ let db;
147
153
  try {
148
- process.kill(pid, 0);
149
- return true;
154
+ db = new DatabaseSync(path);
155
+ db.exec('PRAGMA busy_timeout=1000; BEGIN IMMEDIATE;');
156
+ return callback();
150
157
  } catch (error) {
151
- return error?.code === 'EPERM';
158
+ // SQLite wraps OS permission failures. Only normalize a confirmed read-only
159
+ // path; busy/corrupt/missing-parent errors remain real failures.
160
+ if (error?.code === 'ERR_SQLITE_ERROR' && [8, 14].includes(error.errcode & 255)) {
161
+ try {
162
+ accessSync(root, constants.W_OK);
163
+ if (existsSync(path)) accessSync(path, constants.W_OK);
164
+ } catch (permissionError) {
165
+ if (isPermissionError(permissionError)) throw permissionError;
166
+ }
167
+ }
168
+ throw error;
169
+ } finally {
170
+ try { db?.exec('ROLLBACK;'); } catch {}
171
+ db?.close();
152
172
  }
153
173
  }
154
174
 
155
- function readMessageSequenceLock(lockDir) {
175
+ function abandonedMessageSequenceLock(lockDir) {
156
176
  const details = statSync(lockDir);
157
- try {
158
- return { owner: JSON.parse(readFileSync(join(lockDir, 'owner.json'), 'utf8')), details };
159
- } catch {
160
- return { owner: null, details };
177
+ let owner;
178
+ try { owner = JSON.parse(readFileSync(join(lockDir, 'owner.json'), 'utf8')); } catch {}
179
+ if (owner?.host === hostname() && Number.isInteger(owner.pid) && owner.pid > 0) {
180
+ try { process.kill(owner.pid, 0); return false; }
181
+ catch (error) { return error?.code === 'ESRCH'; }
161
182
  }
162
- }
163
-
164
- function messageSequenceLockIdentity(owner) {
165
- if (!owner) return null;
166
- if (typeof owner.token === 'string' && owner.token) return `token:${owner.token}`;
167
- return `legacy:${owner.host || ''}:${Number(owner.pid) || 0}:${Number(owner.startedAt) || 0}`;
168
- }
169
-
170
- function messageSequenceLockCanBeTaken(lockDir) {
171
- const { owner, details } = readMessageSequenceLock(lockDir);
172
- if (owner?.host === hostname()) return !processIsAlive(Number(owner.pid));
173
- // Yeaft data roots are local, but a hostname can change after restoring a
174
- // machine image. Never steal a live local owner's lock based on age alone;
175
- // an unverifiable remote/legacy owner is reclaimable only after the stale
176
- // window and only if takeMessageSequenceLock observes the same identity.
177
183
  return Date.now() - details.mtimeMs > MESSAGE_SEQUENCE_LOCK_STALE_MS;
178
184
  }
179
185
 
@@ -203,29 +209,6 @@ function releaseMessageSequenceLock(lockDir, token) {
203
209
  return true;
204
210
  }
205
211
 
206
- function takeMessageSequenceLock(lockDir) {
207
- const observed = readMessageSequenceLock(lockDir).owner;
208
- if (!messageSequenceLockCanBeTaken(lockDir)) return false;
209
- const observedIdentity = messageSequenceLockIdentity(observed);
210
- const claimed = `${lockDir}.stale-${randomUUID()}`;
211
- try {
212
- renameSync(lockDir, claimed);
213
- } catch (error) {
214
- if (error?.code === 'ENOENT') return true;
215
- return false;
216
- }
217
- const claimedOwner = readMessageSequenceLock(claimed).owner;
218
- const ownerChanged = messageSequenceLockIdentity(claimedOwner) !== observedIdentity;
219
- const ownerRevived = claimedOwner?.host === hostname()
220
- && processIsAlive(Number(claimedOwner.pid));
221
- if (ownerChanged || ownerRevived) {
222
- try { renameSync(claimed, lockDir); } catch {}
223
- return false;
224
- }
225
- rmSync(claimed, { recursive: true, force: true });
226
- return true;
227
- }
228
-
229
212
  function acquireMessageSequenceLock(lockDir) {
230
213
  for (let attempt = 0; attempt < MESSAGE_SEQUENCE_LOCK_ATTEMPTS; attempt += 1) {
231
214
  const token = randomUUID();
@@ -233,8 +216,13 @@ function acquireMessageSequenceLock(lockDir) {
233
216
  mkdirSync(lockDir, { mode: 0o700 });
234
217
  } catch (error) {
235
218
  if (error?.code !== 'EEXIST') throw error;
219
+ // Caller holds the kernel-backed mutex, including throughout recovery.
220
+ // All current writers take it before touching the directory/sidecar.
236
221
  try {
237
- if (takeMessageSequenceLock(lockDir)) continue;
222
+ if (abandonedMessageSequenceLock(lockDir)) {
223
+ rmSync(lockDir, { recursive: true });
224
+ continue;
225
+ }
238
226
  } catch (inspectionError) {
239
227
  if (inspectionError?.code === 'ENOENT') continue;
240
228
  throw inspectionError;
@@ -255,7 +243,7 @@ function acquireMessageSequenceLock(lockDir) {
255
243
  throw error;
256
244
  }
257
245
  }
258
- throw new Error('Timed out reserving conversation message ids');
246
+ throw historyError('message_sequence_locked', `Timed out reserving conversation message ids: ${lockDir}`);
259
247
  }
260
248
 
261
249
  function latestTodoWriteSnapshot(toolCalls) {
@@ -1119,14 +1107,14 @@ class SegmentStore {
1119
1107
  * Read physical rows without applying reflection tombstones. Session cloning
1120
1108
  * needs the complete durable transcript, including rows hidden by folding.
1121
1109
  */
1122
- readAllRaw({ includeCold = false } = {}) {
1110
+ readAllRaw({ includeCold = false, appendOrder = false } = {}) {
1123
1111
  if (!this.hasData()) return [];
1124
1112
  const idx = this.loadIndex();
1125
- return (idx.segments || [])
1113
+ const rows = (idx.segments || [])
1126
1114
  .slice()
1127
- .sort((a, b) => (a.firstSeq || 0) - (b.firstSeq || 0))
1128
- .flatMap(segment => this.#readSegment(segment.file, { includeCold }))
1129
- .sort(compareMessagesBySeq);
1115
+ .sort((a, b) => appendOrder ? a.file.localeCompare(b.file) : (a.firstSeq || 0) - (b.firstSeq || 0))
1116
+ .flatMap(segment => this.#readSegment(segment.file, { includeCold, appendOrder }));
1117
+ return appendOrder ? rows : rows.sort(compareMessagesBySeq);
1130
1118
  }
1131
1119
 
1132
1120
  *scan({ beforeSeq = Infinity, afterSeq = -Infinity, desc = false, includeCold = false, scanStats = null, projection = 'context' } = {}) {
@@ -1353,10 +1341,10 @@ class SegmentStore {
1353
1341
  out.streamId = typeof out.streamId === 'string' && out.streamId ? out.streamId : null;
1354
1342
  out.revision = Number.isFinite(Number(out.revision)) ? Math.max(0, Number(out.revision)) : 0;
1355
1343
  out.segments = Array.isArray(out.segments) ? out.segments.filter(s => s && s.file) : [];
1356
- out.segments.sort((a, b) => (Number(a.firstSeq) || 0) - (Number(b.firstSeq) || 0));
1344
+ out.segments.sort((a, b) => a.file.localeCompare(b.file));
1357
1345
  const maxSeq = out.segments.reduce((max, seg) => Math.max(max, Number(seg.lastSeq) || 0), 0);
1358
1346
  out.nextSeq = Math.max(Number(out.nextSeq) || 1, maxSeq + 1);
1359
- out.activeSegment = out.activeSegment || out.segments[out.segments.length - 1]?.file || SEGMENT_FIRST_NAME;
1347
+ out.activeSegment = out.segments[out.segments.length - 1]?.file || out.activeSegment || SEGMENT_FIRST_NAME;
1360
1348
  out.totalMessages = Number(out.totalMessages) || out.segments.reduce((sum, seg) => sum + (Number(seg.count) || 0), 0);
1361
1349
  out.foldedMessageIds = Array.isArray(out.foldedMessageIds)
1362
1350
  ? Array.from(new Set(out.foldedMessageIds.filter(id => typeof id === 'string' && id)))
@@ -1369,7 +1357,7 @@ class SegmentStore {
1369
1357
  if (!existsSync(this.segmentDir)) return idx;
1370
1358
  const files = readdirSync(this.segmentDir).filter(f => f.endsWith('.jsonl')).sort();
1371
1359
  for (const file of files) {
1372
- const rows = this.#readSegment(file, { includeCold: true });
1360
+ const rows = this.#readSegment(file, { includeCold: true, appendOrder: true });
1373
1361
  if (rows.length === 0) continue;
1374
1362
  const seqs = rows.map(r => parseSeqFromId(r.id)).filter(Number.isFinite);
1375
1363
  const path = join(this.segmentDir, file);
@@ -1399,7 +1387,7 @@ class SegmentStore {
1399
1387
  return true;
1400
1388
  }
1401
1389
 
1402
- #readSegment(file, { beforeSeq = Infinity, afterSeq = -Infinity, desc = false, includeCold = false } = {}) {
1390
+ #readSegment(file, { beforeSeq = Infinity, afterSeq = -Infinity, desc = false, includeCold = false, appendOrder = false } = {}) {
1403
1391
  const path = join(this.segmentDir, file);
1404
1392
  if (!existsSync(path)) return [];
1405
1393
  const raw = readFileSync(path, 'utf8');
@@ -1413,7 +1401,7 @@ class SegmentStore {
1413
1401
  if (!includeCold && msg.cold === true) continue;
1414
1402
  rows.push(msg);
1415
1403
  }
1416
- rows.sort(compareMessagesBySeq);
1404
+ if (!appendOrder) rows.sort(compareMessagesBySeq);
1417
1405
  return desc ? rows.reverse() : rows;
1418
1406
  }
1419
1407
  }
@@ -1575,6 +1563,7 @@ export class ConversationStore {
1575
1563
  return {
1576
1564
  ...msg,
1577
1565
  id: `m${String(seq).padStart(4, '0')}`,
1566
+ seq,
1578
1567
  time: msg.time || new Date().toISOString(),
1579
1568
  tokens_est: msg.tokens_est || estimateTokens(msg.content || ''),
1580
1569
  };
@@ -1583,6 +1572,7 @@ export class ConversationStore {
1583
1572
  const fullMsg = {
1584
1573
  ...msg,
1585
1574
  id,
1575
+ seq,
1586
1576
  time: msg.time || new Date().toISOString(),
1587
1577
  tokens_est: msg.tokens_est || estimateTokens(msg.content || ''),
1588
1578
  };
@@ -1637,6 +1627,7 @@ export class ConversationStore {
1637
1627
  return rows.map((msg, index) => ({
1638
1628
  ...msg,
1639
1629
  id: `m${String(firstSeq + index).padStart(4, '0')}`,
1630
+ seq: firstSeq + index,
1640
1631
  time: msg.time || new Date().toISOString(),
1641
1632
  tokens_est: msg.tokens_est || estimateTokens(msg.content || ''),
1642
1633
  }));
@@ -1644,6 +1635,7 @@ export class ConversationStore {
1644
1635
  const persisted = rows.map((msg, index) => ({
1645
1636
  ...msg,
1646
1637
  id: `m${String(firstSeq + index).padStart(4, '0')}`,
1638
+ seq: firstSeq + index,
1647
1639
  time: msg.time || new Date().toISOString(),
1648
1640
  tokens_est: msg.tokens_est || estimateTokens(msg.content || ''),
1649
1641
  }));
@@ -2042,7 +2034,7 @@ export class ConversationStore {
2042
2034
  if (typeof sessionId !== 'string' || !sessionId) {
2043
2035
  throw historyError('invalid_fork_boundary', 'Session identity is required');
2044
2036
  }
2045
- const { rows: durableRows, streamId } = this.#loadDurableSession(sessionId);
2037
+ const { rows: durableRows, streamId } = this.#loadDurableSession(sessionId, { allowDuplicateIds: true });
2046
2038
  const rows = Object.hasOwn(options, 'beforeMessageId')
2047
2039
  ? durableRows.slice(0, this.#sessionCopyBeforeMessageCutoff(durableRows, streamId, options.beforeMessageId))
2048
2040
  : durableRows;
@@ -2060,6 +2052,9 @@ export class ConversationStore {
2060
2052
  const fence = typeof beforeMessageId === 'string' ? beforeMessageId.trim() : '';
2061
2053
  if (!fence) throw historyError('invalid_fork_boundary', 'beforeMessageId must be a nonempty string');
2062
2054
  const index = rows.findIndex(row => row.id === fence);
2055
+ if (rows.filter(row => row.id === fence).length > 1) {
2056
+ throw historyError('ambiguous_fork_boundary', 'Message boundary identifies multiple durable rows');
2057
+ }
2063
2058
  if (index < 0) throw historyError('fork_boundary_not_found', `Human message not found: ${fence}`);
2064
2059
  if (!isHumanRequest(rows[index])) throw historyError('invalid_fork_boundary', 'beforeMessageId must identify a durable human request');
2065
2060
  const user = rows[index];
@@ -2142,13 +2137,15 @@ export class ConversationStore {
2142
2137
  * 不会把活动 turn 截短伪装成完整 turn。前缀删改拒绝为 invalid_fork_boundary。
2143
2138
  * 兼容旧 inclusive throughMessageId(null 为空)和 exclusive beforeMessageId;
2144
2139
  * 所有已提供边界均验证,取较早 cutoff。runtime 负责在最早活动输入前捕获。
2140
+ * 重复的旧 ID 按物理记录逐条复制;idMap 仅包含可唯一映射的旧 ID。
2141
+ * 歧义引用保存在目标行 unresolvedMessageReferences 中,不连错、不丢消息。
2145
2142
  * @returns {{ copiedCount: number, idMap: Map<string, string> }}
2146
2143
  */
2147
2144
  copySession(sourceSessionId, targetSessionId, options = {}) {
2148
2145
  if (!sourceSessionId || !targetSessionId || sourceSessionId === targetSessionId) {
2149
2146
  return { copiedCount: 0, idMap: new Map() };
2150
2147
  }
2151
- const { rows: durableRows, streamId } = this.#loadDurableSession(sourceSessionId);
2148
+ const { rows: durableRows, streamId } = this.#loadDurableSession(sourceSessionId, { allowDuplicateIds: true });
2152
2149
  let rows = durableRows;
2153
2150
  let cutoff = rows.length;
2154
2151
  let capturedCutoff = null;
@@ -2172,6 +2169,9 @@ export class ConversationStore {
2172
2169
  const fence = typeof options.throughMessageId === 'string' ? options.throughMessageId.trim() : '';
2173
2170
  if (!fence) throw historyError('invalid_fork_boundary', 'throughMessageId must be a nonempty string or null');
2174
2171
  const index = rows.findIndex(row => row.id === fence);
2172
+ if (rows.filter(row => row.id === fence).length > 1) {
2173
+ throw historyError('ambiguous_fork_boundary', 'Message boundary identifies multiple durable rows');
2174
+ }
2175
2175
  if (index < 0) throw historyError('fork_boundary_not_found', `Durable message not found: ${fence}`);
2176
2176
  cutoff = Math.min(cutoff, index + 1);
2177
2177
  }
@@ -2223,29 +2223,11 @@ export class ConversationStore {
2223
2223
  if (rows.length === 0) return { copiedCount: 0, idMap: new Map() };
2224
2224
 
2225
2225
  const firstSeq = this.#reserveSeqRange(rows.length);
2226
- const idMap = new Map(rows.map((row, index) => [
2227
- row.id,
2228
- `m${String(firstSeq + index).padStart(4, '0')}`,
2229
- ]));
2230
- const remapId = id => idMap.get(id) || id;
2231
2226
  const assetRows = copyPersistedSessionAttachments(rows, { yeaftDir: this.#dir, sourceSessionId, targetSessionId });
2232
- const copies = assetRows.map(row => {
2233
- const copy = {
2234
- ...historicalReplayRow(row),
2235
- id: idMap.get(row.id),
2236
- sessionId: targetSessionId,
2237
- origin: messageOrigin(row, sourceSessionId, streamId),
2238
- };
2239
- delete copy.seq;
2240
- if (row.quote?.messageId) copy.quote = { ...row.quote, messageId: remapId(row.quote.messageId) };
2241
- if (idMap.has(row.causalRootId)) copy.causalRootId = remapId(row.causalRootId);
2242
- if (Array.isArray(row.foldedMessageIds)) {
2243
- copy.foldedMessageIds = row.foldedMessageIds.map(remapId);
2244
- }
2245
- if (Array.isArray(row.sourceMessageIds)) {
2246
- copy.sourceMessageIds = row.sourceMessageIds.map(remapId);
2247
- }
2248
- return copy;
2227
+ const { rows: copies, idMap } = materializeSessionCopy(assetRows, {
2228
+ sourceSessionId, streamId, targetSessionId, firstSeq,
2229
+ sourceRows: durableRows,
2230
+ origins: sessionCopyOrigins(durableRows, sourceSessionId, streamId),
2249
2231
  });
2250
2232
  const target = this.#segmentStoreForConversationDir(this.#sessionConversationDir(targetSessionId));
2251
2233
  const written = target.appendBatch(copies);
@@ -2256,7 +2238,7 @@ export class ConversationStore {
2256
2238
  // Session irrecoverably incomplete. Verify the physical target rows before
2257
2239
  // the higher-level CRUD operation commits the clone.
2258
2240
  const persistedIds = new Set(target.readAllRaw({ includeCold: true }).map(row => row.id));
2259
- const expectedIds = [...idMap.values()];
2241
+ const expectedIds = copies.map(row => row.id);
2260
2242
  if (written.length !== rows.length
2261
2243
  || persistedIds.size !== expectedIds.length
2262
2244
  || expectedIds.some(id => !persistedIds.has(id))) {
@@ -2265,10 +2247,12 @@ export class ConversationStore {
2265
2247
  return { copiedCount: written.length, idMap };
2266
2248
  }
2267
2249
 
2268
- #loadDurableSession(sourceSessionId) {
2250
+ #loadDurableSession(sourceSessionId, { allowDuplicateIds = false } = {}) {
2269
2251
  const primary = this.#segmentStoreForConversationDir(this.#sessionConversationDir(sourceSessionId));
2270
- const segmentedRows = primary.readAllRaw({ includeCold: true });
2271
- if (new Set(segmentedRows.map(row => row.id)).size !== segmentedRows.length) {
2252
+ const segmentedRows = primary.readAllRaw({ includeCold: true, appendOrder: true });
2253
+ const segmentIds = new Set(segmentedRows.map(row => row.id));
2254
+ const hasDuplicates = segmentIds.size !== segmentedRows.length;
2255
+ if (hasDuplicates && !allowDuplicateIds) {
2272
2256
  throw historyError('ambiguous_merge_history', 'Duplicate persisted identity within one Session');
2273
2257
  }
2274
2258
  const legacyRows = this.#sessionFileEntries('all', sourceSessionId)
@@ -2279,15 +2263,25 @@ export class ConversationStore {
2279
2263
  }
2280
2264
  })
2281
2265
  .filter(Boolean);
2282
- // Migration can temporarily leave the same durable row in both the legacy
2283
- // markdown layout and the segment store. Preserve legacy-only rows, but let
2284
- // the canonical segment copy win when both contain the same persisted id.
2285
- const rowsById = new Map();
2286
- for (const row of [...legacyRows, ...segmentedRows]) {
2266
+ // A shadow is collapsible only when its legacy-representable payload is
2267
+ // identical, not merely its ID. Distinct hot/cold/segment rows all survive.
2268
+ const shadowKey = row => serializeMessage({ ...row, time: row.time || '<missing>' });
2269
+ const shadows = new Map();
2270
+ for (const row of segmentedRows) {
2271
+ const key = shadowKey(row);
2272
+ shadows.set(key, (shadows.get(key) || 0) + 1);
2273
+ }
2274
+ const legacy = [];
2275
+ for (const row of legacyRows) {
2287
2276
  if (row?.sessionId !== sourceSessionId || typeof row.id !== 'string' || !row.id) continue;
2288
- rowsById.set(row.id, row);
2289
- }
2290
- const rows = [...rowsById.values()].sort(compareMessagesBySeq);
2277
+ const key = shadowKey(row);
2278
+ if (shadows.get(key)) { shadows.set(key, shadows.get(key) - 1); continue; }
2279
+ legacy.push(row);
2280
+ }
2281
+ // Legacy-only rows predate the segmented transcript; each block has its
2282
+ // own stable order. Never switch order after an append introduces a clash.
2283
+ const rows = [...legacy.sort(compareMessagesBySeq),
2284
+ ...segmentedRows.filter(row => row?.sessionId === sourceSessionId)];
2291
2285
  return { sessionId: sourceSessionId, streamId: rows.length || primary.hasData() ? primary.metadata().streamId : null, rows };
2292
2286
  }
2293
2287
 
@@ -3785,16 +3779,21 @@ export class ConversationStore {
3785
3779
  * that did not maintain an allocator high-water mark.
3786
3780
  * @returns {number}
3787
3781
  */
3788
- #scanNextSeq() {
3782
+ #scanNextSeq({ verifyRows = false } = {}) {
3789
3783
  let maxSeq = 0;
3790
- for (const dir of [this.#chatDir, ...this.#sessionConversationDirs({ primaryOnly: true })]) {
3784
+ for (const dir of [this.#chatDir, ...this.#chatConversationDirs(), ...this.#sessionConversationDirs({ primaryOnly: true })]) {
3791
3785
  const store = this.#segmentStoreForConversationDir(dir);
3792
3786
  if (store.hasData()) {
3793
3787
  const idx = store.loadIndex();
3794
3788
  maxSeq = Math.max(maxSeq, (Number(idx.nextSeq) || 1) - 1);
3789
+ if (verifyRows) {
3790
+ for (const row of store.readAllRaw({ includeCold: true, appendOrder: true })) {
3791
+ maxSeq = Math.max(maxSeq, parseSeqFromId(row.id) || 0, Number(row.seq) || 0);
3792
+ }
3793
+ }
3795
3794
  }
3796
3795
  }
3797
- for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#sessionMessageDirs('messages'), ...this.#sessionMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
3796
+ for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#chatMessageDirs('messages'), ...this.#chatMessageDirs('cold'), ...this.#sessionMessageDirs('messages'), ...this.#sessionMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
3798
3797
  if (!existsSync(dir)) continue;
3799
3798
  for (const file of readdirSync(dir)) {
3800
3799
  const match = file.match(/^m(\d+)\.md$/);
@@ -3807,17 +3806,19 @@ export class ConversationStore {
3807
3806
  /**
3808
3807
  * Reserve one non-overlapping range of global message ids.
3809
3808
  *
3810
- * `mkdir` is the cross-process mutex: it is atomic on supported local file
3811
- * systems and therefore coordinates long-lived stores as well as short-lived
3812
- * Session-copy stores. The atomic high-water sidecar makes a whole transcript
3813
- * reservation O(1) once the lock is held, while the disk scan keeps upgrades
3814
- * and manually restored transcripts safe.
3809
+ * SQLite BEGIN IMMEDIATE owns the cross-process mutex and crash recovery;
3810
+ * the directory lock also fences earlier writers during rolling upgrades. The atomic high-water sidecar makes a whole transcript
3811
+ * reservation cheap after the first verified bootstrap. A legacy/missing
3812
+ * sidecar requires actual transcript ID verification, not only derived seq
3813
+ * indexes. Restore/import is offline-only and must invalidate the sidecar;
3814
+ * neither a cache epoch nor a scan can fence already in-flight writers.
3815
3815
  *
3816
3816
  * @param {number} count
3817
3817
  * @returns {number} first reserved sequence
3818
3818
  */
3819
3819
  #reserveSeqRange(count) {
3820
3820
  const size = Math.max(1, Math.floor(Number(count) || 1));
3821
+ if (!Number.isSafeInteger(size)) throw new Error('Invalid conversation message id reservation');
3821
3822
  const rootKey = resolve(this.#dir);
3822
3823
  const sequencePath = join(this.#dir, MESSAGE_SEQUENCE_FILE);
3823
3824
  const cached = messageSequenceReservations.get(rootKey);
@@ -3827,6 +3828,7 @@ export class ConversationStore {
3827
3828
  const durableNext = Number(recorded?.nextSeq);
3828
3829
  if (Number.isSafeInteger(durableNext)
3829
3830
  && durableNext >= cached.end
3831
+ && recorded.transcriptIdsVerified === true
3830
3832
  && typeof recorded?.epoch === 'string'
3831
3833
  && recorded.epoch === cached.epoch) {
3832
3834
  const firstSeq = cached.next;
@@ -3843,35 +3845,39 @@ export class ConversationStore {
3843
3845
  // monotonic identities, not a promise of contiguous durable rows.
3844
3846
  const durableSize = Math.max(size, MESSAGE_SEQUENCE_RESERVATION_SIZE);
3845
3847
  const lockDir = join(this.#dir, MESSAGE_SEQUENCE_LOCK_DIR);
3846
- const lockToken = acquireMessageSequenceLock(lockDir);
3847
-
3848
- try {
3849
- let recordedNext = null;
3850
- let epoch = null;
3848
+ return withMessageSequenceMutex(this.#dir, () => {
3849
+ const lockToken = acquireMessageSequenceLock(lockDir);
3851
3850
  try {
3852
- const recorded = JSON.parse(readFileSync(sequencePath, 'utf8'));
3853
- const nextSeq = Number(recorded?.nextSeq);
3854
- if (Number.isSafeInteger(nextSeq) && nextSeq > 0) {
3855
- recordedNext = nextSeq;
3856
- if (typeof recorded.epoch === 'string' && recorded.epoch) epoch = recorded.epoch;
3851
+ let recordedNext = null;
3852
+ let epoch = null;
3853
+ let transcriptIdsVerified = false;
3854
+ try {
3855
+ const recorded = JSON.parse(readFileSync(sequencePath, 'utf8'));
3856
+ const nextSeq = Number(recorded?.nextSeq);
3857
+ if (Number.isSafeInteger(nextSeq) && nextSeq > 0) {
3858
+ recordedNext = nextSeq;
3859
+ transcriptIdsVerified = recorded.transcriptIdsVerified === true;
3860
+ if (typeof recorded.epoch === 'string' && recorded.epoch) epoch = recorded.epoch;
3861
+ }
3862
+ } catch (error) {
3863
+ if (error?.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error;
3857
3864
  }
3858
- } catch (error) {
3859
- if (error?.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error;
3860
- }
3861
- // A syntactically valid sidecar can still be stale after a partial restore
3862
- // or crash. Never trust it below the durable transcript high-water mark.
3863
- const scannedNext = this.#scanNextSeq();
3864
- const firstSeq = Math.max(recordedNext || 1, scannedNext);
3865
- epoch ||= randomUUID();
3866
- writeAtomic(sequencePath, `${JSON.stringify({ version: 1, epoch, nextSeq: firstSeq + durableSize })}\n`, { mode: 0o600 });
3867
- messageSequenceReservations.set(rootKey, {
3868
- epoch,
3869
- next: firstSeq + size,
3870
- end: firstSeq + durableSize,
3871
- });
3872
- return firstSeq;
3873
- } finally {
3874
- releaseMessageSequenceLock(lockDir, lockToken);
3875
- }
3865
+ // A syntactically valid sidecar can still be stale after a partial restore
3866
+ // or crash. Never trust it below the durable transcript high-water mark.
3867
+ const scannedNext = this.#scanNextSeq({ verifyRows: !transcriptIdsVerified });
3868
+ const firstSeq = Math.max(recordedNext || 1, scannedNext);
3869
+ if (!Number.isSafeInteger(firstSeq + durableSize)) throw new Error('Conversation message id sequence exhausted');
3870
+ epoch ||= randomUUID();
3871
+ writeAtomic(sequencePath, `${JSON.stringify({ version: 1, epoch, transcriptIdsVerified: true, nextSeq: firstSeq + durableSize })}\n`, { mode: 0o600 });
3872
+ messageSequenceReservations.set(rootKey, {
3873
+ epoch,
3874
+ next: firstSeq + size,
3875
+ end: firstSeq + durableSize,
3876
+ });
3877
+ return firstSeq;
3878
+ } finally {
3879
+ releaseMessageSequenceLock(lockDir, lockToken);
3880
+ }
3881
+ });
3876
3882
  }
3877
3883
  }
@@ -35,7 +35,81 @@ export function historicalReplayRow(row) {
35
35
  }
36
36
 
37
37
  function originKey(origin) {
38
- return JSON.stringify([origin.streamId, origin.messageId]);
38
+ return JSON.stringify([origin.streamId, origin.messageId, origin.rowOccurrence ?? null, origin.copyOccurrence ?? null]);
39
+ }
40
+
41
+ /** 在完整源上分配稳定 lineage,再应用复制边界;保留旧 occurrence 命名空间。 */
42
+ export function sessionCopyOrigins(rows, sourceSessionId, streamId) {
43
+ const origins = rows.map(row => messageOrigin(row, sourceSessionId, streamId));
44
+ const reserved = new Set(origins.map(originKey));
45
+ const used = new Set();
46
+ return origins.map(origin => {
47
+ if (!used.has(originKey(origin))) { used.add(originKey(origin)); return origin; }
48
+ let occurrence = 2;
49
+ let candidate;
50
+ do {
51
+ candidate = { ...origin, copyOccurrence: [...(origin.copyOccurrence || []), occurrence++] };
52
+ } while (reserved.has(originKey(candidate)) || used.has(originKey(candidate)));
53
+ used.add(originKey(candidate));
54
+ return candidate;
55
+ });
56
+ }
57
+
58
+ /**
59
+ * 按源记录位置分配复制身份,不能用 Map<旧 ID, 新 ID> 覆盖冲突记录。
60
+ * 无法唯一识别目标的引用从可执行引用字段移到诊断元数据;保留
61
+ * 原引用及所有目标候选,不阻断复制,也不猜测折叠/因果关系。源行不变。
62
+ * idMap 只提供唯一旧 ID 的兼容查询,绝不返回任意一个冲突候选。
63
+ */
64
+ export function materializeSessionCopy(rows, { sourceSessionId, streamId, targetSessionId, firstSeq, sourceRows = rows, origins = sessionCopyOrigins(sourceRows, sourceSessionId, streamId) }) {
65
+ const ids = rows.map((_, index) => `m${String(firstSeq + index).padStart(4, '0')}`);
66
+ const occurrences = new Map();
67
+ sourceRows.forEach((row, index) => {
68
+ const positions = occurrences.get(row.id) || [];
69
+ positions.push(index);
70
+ occurrences.set(row.id, positions);
71
+ });
72
+ const idMap = new Map([...occurrences].filter(([, positions]) => positions.length === 1 && positions[0] < rows.length)
73
+ .map(([id, positions]) => [id, ids[positions[0]]]));
74
+ const copies = rows.map((row, index) => {
75
+ const copy = { ...historicalReplayRow(row), id: ids[index], seq: firstSeq + index,
76
+ sessionId: targetSessionId, origin: origins[index] };
77
+ // Existing diagnostics also belong to the copied namespace on nested forks.
78
+ const unresolved = (row.unresolvedMessageReferences || []).map(reference => ({ ...reference,
79
+ candidateMessageIds: (reference.candidateMessageIds || []).flatMap(id =>
80
+ occurrences.has(id) ? occurrences.get(id).map(candidate => ids[candidate]).filter(Boolean) : [id]),
81
+ }));
82
+ const remap = (id, field, referenceIndex) => {
83
+ const positions = occurrences.get(id);
84
+ if (!positions) return id; // External/client identities are not persisted row IDs.
85
+ // Duplicate bare IDs contain no record-level target identity. Role,
86
+ // proximity and a matching VP turn cannot prove the intended record
87
+ // (and may select this very row). Never infer a live reference from them.
88
+ if (positions.length === 1) return ids[positions[0]] || id;
89
+ unresolved.push({ field, ...(referenceIndex === undefined ? {} : { index: referenceIndex }),
90
+ messageId: id, candidateMessageIds: positions.map(position => ids[position]).filter(Boolean),
91
+ candidateOrigins: positions.map(position => origins[position]) });
92
+ return undefined;
93
+ };
94
+ if (row.causalRootId) {
95
+ const value = remap(row.causalRootId, 'causalRootId');
96
+ if (value === undefined) delete copy.causalRootId;
97
+ else copy.causalRootId = value;
98
+ }
99
+ if (row.quote?.messageId) {
100
+ copy.quote = { ...row.quote };
101
+ const value = remap(row.quote.messageId, 'quote.messageId');
102
+ if (value === undefined) delete copy.quote.messageId;
103
+ else copy.quote.messageId = value;
104
+ }
105
+ for (const field of ['foldedMessageIds', 'sourceMessageIds']) {
106
+ if (Array.isArray(row[field])) copy[field] = row[field]
107
+ .map((id, referenceIndex) => remap(id, field, referenceIndex)).filter(id => id !== undefined);
108
+ }
109
+ if (unresolved.length) copy.unresolvedMessageReferences = unresolved;
110
+ return copy;
111
+ });
112
+ return { rows: copies, idMap };
39
113
  }
40
114
 
41
115
  function stableJson(value) {
@@ -218,6 +292,14 @@ function equivalentRows(left, right, aliases) {
218
292
  if (Array.isArray(row[key])) copy[key] = row[key].map(remap);
219
293
  }
220
294
  if (row.quote?.messageId) copy.quote = { ...row.quote, messageId: remap(row.quote.messageId) };
295
+ if (row.unresolvedMessageReferences) copy.unresolvedMessageReferences = row.unresolvedMessageReferences
296
+ .map(reference => {
297
+ if (reference.candidateOrigins) {
298
+ const { candidateMessageIds, ...identity } = reference;
299
+ return identity; // Scope-independent evidence includes excluded suffix candidates.
300
+ }
301
+ return { ...reference, candidateMessageIds: reference.candidateMessageIds.map(remap) };
302
+ });
221
303
  if (Array.isArray(row.attachments)) copy.attachments = row.attachments.map(att => {
222
304
  const { path, ...metadata } = att;
223
305
  return metadata;
@@ -326,6 +408,8 @@ export function materializeSessionMerge(plan, targetSessionId, firstSeq) {
326
408
  if (Array.isArray(row[key])) copy[key] = row[key].map(remap);
327
409
  }
328
410
  if (row.quote?.messageId) copy.quote = { ...row.quote, messageId: remap(row.quote.messageId) };
411
+ if (row.unresolvedMessageReferences) copy.unresolvedMessageReferences = row.unresolvedMessageReferences
412
+ .map(reference => ({ ...reference, candidateMessageIds: reference.candidateMessageIds.map(remap) }));
329
413
  if (row.toolCalls) copy.toolCalls = row.toolCalls.map(call => ({ ...call, id: calls.get(toolScope(row, call.id)) }));
330
414
  if (turn.links.results.has(index)) copy.toolCallId = callId(turn.links.results.get(index));
331
415
  if (calls.has(toolScope(row, row.presentationCallId))) copy.presentationCallId = calls.get(toolScope(row, row.presentationCallId));