修复完整历史与运行态内存放大并重建发布包
This commit is contained in:
@@ -73,7 +73,7 @@ function createApi(dir, options = {}) {
|
||||
'textByteLength', 'truncateTextValue', 'sanitizePersistValue', 'sanitizeToolCallsForPersist',
|
||||
'mapToPairs', 'codexAppStatePath', 'codexAppTurnKey', 'serializeCodexAppEntry',
|
||||
'buildCodexAppStateJson', 'writeCodexAppTurnState', 'persistCodexAppTurnState',
|
||||
'cleanupCodexAppTurnState', 'loadCodexAppTurnState', 'hasCodexAppTurnMessage',
|
||||
'cleanupCodexAppTurnState', 'loadCodexAppTurnState', 'findSessionMessage', 'hasCodexAppTurnMessage',
|
||||
'appendCodexAppRecoveryNotice', 'recoverCodexAppTurnState',
|
||||
'hasRuntimeOutput', 'retryTailText', 'retryPreviewValue', 'buildCodexRetryToolSummary',
|
||||
'shouldUseCodexAppContinuationRetry', 'buildCodexAppContinuationRetryText',
|
||||
|
||||
@@ -135,6 +135,7 @@ function createCliCompletionApi(dir, options = {}) {
|
||||
'hasRuntimeOutput',
|
||||
'codexOutputContent',
|
||||
'cliRunKey',
|
||||
'findSessionMessage',
|
||||
'hasCliRunMessage',
|
||||
'isContextLimitError',
|
||||
'handleProcessComplete',
|
||||
@@ -248,6 +249,7 @@ function createCodexAppStateApi(dir, options = {}) {
|
||||
'writeCodexAppTurnState',
|
||||
'loadCodexAppTurnState',
|
||||
'cleanupCodexAppTurnState',
|
||||
'findSessionMessage',
|
||||
'hasCodexAppTurnMessage',
|
||||
'appendCodexAppRecoveryNotice',
|
||||
'recoverCodexAppTurnState',
|
||||
@@ -410,6 +412,7 @@ function createCrossConversationApi(dir, options = {}) {
|
||||
const sent = [];
|
||||
const pending = new Map();
|
||||
const api = new Function('fs', 'path', 'crypto', 'dir', 'storeApi', 'pending', 'sent', 'options', 'CCWEB_REPLY_MODES', `
|
||||
const sessionHistoryStore = storeApi.sessionHistoryStore;
|
||||
const wsSend = (ws, payload) => sent.push(payload);
|
||||
const loadSession = storeApi.loadSession;
|
||||
const saveSession = session => (options.failSave ? false : storeApi.saveSession(session));
|
||||
@@ -427,6 +430,7 @@ function createCrossConversationApi(dir, options = {}) {
|
||||
'setPendingCrossConversationReply',
|
||||
'updatePendingCrossConversationReply',
|
||||
'deletePendingCrossConversationReply',
|
||||
'findSessionMessage',
|
||||
'hasProcessedCrossConversationReply',
|
||||
'extractCrossConversationReplyText',
|
||||
'buildCrossConversationReplyContent',
|
||||
@@ -536,7 +540,7 @@ function runTests() {
|
||||
assert.equal(seedApi.saveSession(sessionFixture(sessionId, 'codex', [
|
||||
{ id: 'user-before-cli', role: 'user', content: '读取异常前已经保存的问题' },
|
||||
])), true);
|
||||
const archivePath = path.join(dir, '_history', `${sessionId}.json`);
|
||||
const archivePath = path.join(dir, '_history', sessionId, 'manifest.json');
|
||||
const archiveBefore = fs.readFileSync(archivePath, 'utf8');
|
||||
const fullText = 'CLI 完成时必须保留的唯一输出';
|
||||
const runPath = writeRunArtifacts(dir, sessionId, {
|
||||
@@ -579,7 +583,7 @@ function runTests() {
|
||||
assert.equal(seedApi.saveSession(sessionFixture(sessionId, 'codexapp', [
|
||||
{ id: 'user-before-app', role: 'user', content: '读取异常前已经保存的 App 问题' },
|
||||
])), true);
|
||||
const archivePath = path.join(dir, '_history', `${sessionId}.json`);
|
||||
const archivePath = path.join(dir, '_history', sessionId, 'manifest.json');
|
||||
const archiveBefore = fs.readFileSync(archivePath, 'utf8');
|
||||
// 同 CLI 夹具:损坏真实档案制造“会话暂不可读”,恢复时先修复档案再补交。
|
||||
const corruptedArchive = '{损坏的会话归档:Codex App 读取失败夹具';
|
||||
|
||||
262
scripts/json-persistence-unit.js
Normal file
262
scripts/json-persistence-unit.js
Normal file
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { readJsonSync, writeJsonSync, cloneJsonValue, jsonValueEqual } = require('../lib/json-persistence');
|
||||
const { createSessionHistoryStore } = require('../lib/session-history-store');
|
||||
|
||||
const BLOCK_BYTES = 64 * 1024;
|
||||
|
||||
function nativeJson(value) {
|
||||
return JSON.stringify(value, (key, item) => {
|
||||
if (typeof item === 'bigint') return String(item);
|
||||
if (item instanceof Map) return Object.fromEntries(item);
|
||||
if (item instanceof Set) return [...item];
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
function temporaryRoot() {
|
||||
if (process.platform === 'linux' && typeof fs.statfsSync === 'function') {
|
||||
try {
|
||||
const stat = fs.statfsSync('/dev/shm');
|
||||
if (Number(stat.bavail) * Number(stat.bsize) >= 32 * 1024 * 1024) return '/dev/shm';
|
||||
} catch {}
|
||||
}
|
||||
return os.tmpdir();
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
const dir = fs.mkdtempSync(path.join(temporaryRoot(), 'ccweb-json-persistence-'));
|
||||
const failures = [];
|
||||
let checks = 0;
|
||||
let sequence = 0;
|
||||
function check(name, callback) {
|
||||
try {
|
||||
callback();
|
||||
checks += 1;
|
||||
console.log(`通过:${name}`);
|
||||
} catch (error) {
|
||||
failures.push(name);
|
||||
console.error(`失败:${name}:${error.stack}`);
|
||||
}
|
||||
}
|
||||
function rawFile(json) {
|
||||
const filename = path.join(dir, `input-${++sequence}.json`);
|
||||
fs.writeFileSync(filename, json);
|
||||
return filename;
|
||||
}
|
||||
function encode(value, filesystem = fs) {
|
||||
const filename = path.join(dir, `output-${++sequence}.json`);
|
||||
const fd = fs.openSync(filename, 'wx', 0o600);
|
||||
try { writeJsonSync(filesystem, fd, value); } finally { fs.closeSync(fd); }
|
||||
return { filename, json: fs.readFileSync(filename, 'utf8') };
|
||||
}
|
||||
function assertNative(value) {
|
||||
const expectedJson = nativeJson(value);
|
||||
const expected = JSON.parse(expectedJson);
|
||||
const output = encode(value);
|
||||
assert.equal(output.json, expectedJson);
|
||||
assert.deepEqual(readJsonSync(fs, output.filename), expected);
|
||||
assert.deepEqual(cloneJsonValue(value), expected);
|
||||
}
|
||||
|
||||
try {
|
||||
check('JSON基本类型、数值边界与控制字符与原生持久化语义一致', () => {
|
||||
const samples = [null, true, false, 0, -0, 1.25, 1e-100, 1e100, NaN, Infinity, -Infinity,
|
||||
'', '中文🙂\u0000\b\f\n\r\t\\"/\u2028\u2029', '\ud800\udfff', 9007199254740993n];
|
||||
samples.forEach(assertNative);
|
||||
assertNative({ values: samples, boxed: [new Number(4), new String('包装文字'), new Boolean(false)] });
|
||||
});
|
||||
|
||||
check('BigInt、Map、Set、日期与toJSON保持项目转换及键参数', () => {
|
||||
function fixture() {
|
||||
return {
|
||||
bigint: 123456789012345678901234567890n,
|
||||
map: new Map([['a', 1n], ['__proto__', { own: true }], ['set', new Set([2, 3])]]),
|
||||
set: new Set(['x', 4n]),
|
||||
date: new Date('2026-10-14T01:02:03.456Z'),
|
||||
custom: { toJSON(key) { return { key, value: 9n }; } },
|
||||
array: [{ toJSON(key) { return `数组键 ${key}`; } }],
|
||||
};
|
||||
}
|
||||
assertNative(fixture());
|
||||
assertNative({ toJSON(key) { return { rootKey: key, value: new Map([['ok', true]]) }; } });
|
||||
for (const action of ['write', 'clone']) {
|
||||
let reads = 0;
|
||||
let calls = 0;
|
||||
const custom = {
|
||||
get toJSON() {
|
||||
reads += 1;
|
||||
return function (key) { calls += 1; assert.equal(this, custom); return { key, value: '原值' }; };
|
||||
},
|
||||
};
|
||||
const result = action === 'write' ? JSON.parse(encode({ custom }).json) : cloneJsonValue({ custom });
|
||||
assert.deepEqual(result, { custom: { key: 'custom', value: '原值' } });
|
||||
assert.equal(reads, 1, 'toJSON属性只能读取一次');
|
||||
assert.equal(calls, 1, 'toJSON只能执行一次');
|
||||
}
|
||||
});
|
||||
|
||||
check('对象省略与数组空洞置null、重复引用及危险属性名完整保存', () => {
|
||||
const shared = { stable: '原值', nested: ['完整内容'] };
|
||||
const object = JSON.parse('{"__proto__":{"value":"自己的属性"},"constructor":"构造键","prototype":"普通键"}');
|
||||
object.missing = undefined;
|
||||
object.callback = () => 1;
|
||||
object.symbol = Symbol('省略');
|
||||
object.array = [undefined, () => 1, Symbol('数组位置'), , shared, shared];
|
||||
object.left = shared;
|
||||
object.right = shared;
|
||||
assertNative(object);
|
||||
const cloned = cloneJsonValue(object);
|
||||
assert.equal(Object.hasOwn(cloned, '__proto__'), true);
|
||||
assert.deepEqual(cloned.__proto__, { value: '自己的属性' });
|
||||
assert.equal(Object.hasOwn(cloned, 'missing'), false);
|
||||
assert.equal(cloned.array.length, 6);
|
||||
assert.deepEqual(cloned.array.slice(0, 4), [null, null, null, null]);
|
||||
});
|
||||
|
||||
check('容器复制隔离原对象并保留不可变字符串值', () => {
|
||||
const text = '必须保留完整正文🙂'.repeat(20000);
|
||||
const source = { text, nested: { rows: [{ value: 1 }] } };
|
||||
const cloned = cloneJsonValue(source);
|
||||
assert.notEqual(cloned, source);
|
||||
assert.notEqual(cloned.nested, source.nested);
|
||||
assert.notEqual(cloned.nested.rows, source.nested.rows);
|
||||
assert.notEqual(cloned.nested.rows[0], source.nested.rows[0]);
|
||||
assert.equal(cloned.text, text);
|
||||
cloned.nested.rows[0].value = 2;
|
||||
assert.equal(source.nested.rows[0].value, 1);
|
||||
assert.equal(jsonValueEqual(source, cloneJsonValue(source)), true);
|
||||
assert.equal(jsonValueEqual(source, cloned), false);
|
||||
assert.equal(jsonValueEqual(cloneJsonValue({ value: NaN, omitted: undefined }), { value: null }), true);
|
||||
assert.equal(jsonValueEqual(cloneJsonValue([undefined, , Infinity]), [null, null, null]), true);
|
||||
assert.equal(jsonValueEqual(cloneJsonValue(10n), '10'), true);
|
||||
assert.equal(jsonValueEqual(cloneJsonValue(new Map([['value', 1]])), { value: 1 }), true);
|
||||
assert.equal(jsonValueEqual({ left: 1, right: { value: 2 } }, { right: { value: 2 }, left: 1 }), true);
|
||||
assert.equal(jsonValueEqual(undefined, undefined), true);
|
||||
assert.equal(jsonValueEqual(undefined, null), false);
|
||||
});
|
||||
|
||||
check('顶层不可持久化值拒绝提交且循环引用失败', () => {
|
||||
for (const value of [undefined, () => 1, Symbol('顶层')]) {
|
||||
assert.equal(cloneJsonValue(value), undefined);
|
||||
assert.throws(() => encode(value), TypeError);
|
||||
}
|
||||
const cyclic = { text: '已经写出的字段' };
|
||||
cyclic.self = cyclic;
|
||||
assert.throws(() => encode(cyclic), TypeError);
|
||||
assert.throws(() => cloneJsonValue(cyclic), TypeError);
|
||||
});
|
||||
|
||||
check('64KiB边界跨UTF-8、JSON转义和长字符串时完整读取', () => {
|
||||
const prefix = '{"text":"';
|
||||
const utf8Json = prefix + 'a'.repeat(BLOCK_BYTES - Buffer.byteLength(prefix) - 1) + '🙂中文","tail":{"value":true}}';
|
||||
const escapedJson = prefix + 'b'.repeat(BLOCK_BYTES - Buffer.byteLength(prefix) - 1) + '\\uD83D\\uDE42\\n\\"\\\\","tail":[1,2,3]}';
|
||||
for (const json of [utf8Json, escapedJson, '\r\n ' + '[1e-3,true,null,{"duplicate":1,"duplicate":2}]' + '\t ']) {
|
||||
const filename = rawFile(json);
|
||||
let maxRead = 0;
|
||||
const filesystem = new Proxy(fs, { get(target, key) {
|
||||
if (key === 'readFileSync') return () => { throw new Error('流式读取禁止整文件readFileSync'); };
|
||||
if (key === 'readSync') return (fd, buffer, offset, length, position) => {
|
||||
maxRead = Math.max(maxRead, length);
|
||||
assert(length <= BLOCK_BYTES);
|
||||
return target.readSync(fd, buffer, offset, length, position);
|
||||
};
|
||||
return target[key];
|
||||
} });
|
||||
assert.deepEqual(readJsonSync(filesystem, filename), JSON.parse(json));
|
||||
assert(maxRead > 0 && maxRead <= BLOCK_BYTES);
|
||||
}
|
||||
});
|
||||
|
||||
check('多次短读不丢UTF-8字节和转义,读取错误关闭文件', () => {
|
||||
const json = '{"text":"中文🙂\\n\\uD83D\\uDE42\\\\\\\"","number":-2.5e+3,"rows":[true,null,{"own":1}]}';
|
||||
const filename = rawFile(json);
|
||||
let opened = 0;
|
||||
let closed = 0;
|
||||
let failRead = false;
|
||||
const filesystem = new Proxy(fs, { get(target, key) {
|
||||
if (key === 'openSync') return (...args) => { opened += 1; return target.openSync(...args); };
|
||||
if (key === 'closeSync') return (...args) => { closed += 1; return target.closeSync(...args); };
|
||||
if (key === 'readSync') return (fd, buffer, offset, length, position) => {
|
||||
if (failRead) throw new Error('模拟读取中断');
|
||||
return target.readSync(fd, buffer, offset, Math.min(length, 3), position);
|
||||
};
|
||||
return target[key];
|
||||
} });
|
||||
assert.deepEqual(readJsonSync(filesystem, filename), JSON.parse(json));
|
||||
failRead = true;
|
||||
assert.throws(() => readJsonSync(filesystem, filename), /模拟读取中断/);
|
||||
assert.equal(closed, opened, '读取成功或失败均应关闭文件');
|
||||
});
|
||||
|
||||
check('非法JSON、截断值、尾随内容与控制字符均拒绝', () => {
|
||||
const invalid = ['', ' ', '{', '[1,', '{"a":}', '{"a":1,}', '[1,]', 'true false', 'nullx', '+1', '01', '1.', '1e',
|
||||
'NaN', 'Infinity', 'undefined', '\ufeff{}', '{unquoted:1}', '"未闭合', '"\\x01"', '"\\u12"', '"\n"', '"\u0000"'];
|
||||
for (const json of invalid) {
|
||||
assert.throws(() => JSON.parse(json));
|
||||
assert.throws(() => readJsonSync(fs, rawFile(json)), `非法JSON必须拒绝:${JSON.stringify(json)}`);
|
||||
}
|
||||
for (const json of ['1e400', '-0', '[1,2,3]', '{"__proto__":{"own":true},"constructor":4}']) {
|
||||
assert.deepEqual(readJsonSync(fs, rawFile(json)), JSON.parse(json));
|
||||
}
|
||||
});
|
||||
|
||||
check('分段编码每次最多64KiB且正确处理短写', () => {
|
||||
const value = { text: '中文🙂\\"\n\u0000'.repeat(25000), nested: { lines: ['保持原值'] } };
|
||||
let maxWrite = 0;
|
||||
let calls = 0;
|
||||
const filesystem = new Proxy(fs, { get(target, key) {
|
||||
if (key === 'writeFileSync') return () => { throw new Error('流式编码禁止整文件writeFileSync'); };
|
||||
if (key === 'writeSync') return (fd, data, ...args) => {
|
||||
calls += 1;
|
||||
const bytes = typeof data === 'string' ? Buffer.byteLength(data, typeof args[1] === 'string' ? args[1] : 'utf8') : args[1] ?? data.length;
|
||||
maxWrite = Math.max(maxWrite, bytes);
|
||||
assert(bytes <= BLOCK_BYTES, `单次编码写入 ${bytes} 字节超出64KiB`);
|
||||
// 固定短写发生在字节缓冲层,不能以UTF-16字符数量当作已写字节数。
|
||||
if (typeof data === 'string') {
|
||||
const encoded = Buffer.from(data, typeof args[1] === 'string' ? args[1] : 'utf8');
|
||||
return target.writeSync(fd, encoded, 0, Math.min(11, encoded.length), args[0] ?? null);
|
||||
}
|
||||
return target.writeSync(fd, data, args[0] ?? 0, Math.min(11, bytes), args[2] ?? null);
|
||||
};
|
||||
return target[key];
|
||||
} });
|
||||
const output = encode(value, filesystem);
|
||||
assert.equal(output.json, nativeJson(value));
|
||||
assert(calls > 1);
|
||||
assert(maxWrite <= BLOCK_BYTES);
|
||||
});
|
||||
|
||||
check('JSON中途编码异常保留原子归档且无临时文件残留', () => {
|
||||
const store = createSessionHistoryStore({ sessionsDir: dir });
|
||||
const id = 'json-atomic-failure';
|
||||
const original = { version: 1, agent: 'codexapp', sessionId: id, fullText: '上次完整运行态', toolCalls: [] };
|
||||
store.saveTurn(id, original);
|
||||
const filename = path.join(dir, '_history', '_turns', `${id}.json`);
|
||||
const oldBytes = fs.readFileSync(filename);
|
||||
const cyclic = { ...original, fullText: '新正文'.repeat(100000) };
|
||||
cyclic.self = cyclic;
|
||||
assert.throws(() => store.saveTurn(id, cyclic), TypeError);
|
||||
const customFailure = { ...original, fullText: '已编码新正文'.repeat(100000), tail: { toJSON() { throw new Error('模拟toJSON异常'); } } };
|
||||
assert.throws(() => store.saveTurn(id, customFailure), /模拟toJSON异常/);
|
||||
assert.deepEqual(fs.readFileSync(filename), oldBytes);
|
||||
assert.deepEqual(store.loadTurn(id), original);
|
||||
assert.equal(fs.readdirSync(path.dirname(filename)).some(name => name.endsWith('.tmp')), false);
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
if (failures.length) {
|
||||
console.error(`JSON持久化回归失败:${failures.length} 项,已通过 ${checks} 项`);
|
||||
process.exitCode = 1;
|
||||
} else console.log(`JSON持久化回归通过:${checks} 项`);
|
||||
return { checks, failures };
|
||||
}
|
||||
|
||||
if (require.main === module) runTests();
|
||||
module.exports = { runTests };
|
||||
@@ -4748,7 +4748,7 @@ function assertServerSessionHistoryRequestIdContract() {
|
||||
}
|
||||
|
||||
function assertHistoryRecallContract() {
|
||||
for (const script of ['session-history-store-unit.js', 'session-history-unit.js', 'history-runtime-unit.js', 'history-runtime-completion-unit.js', 'codex-rollouts-unit.js', 'history-frontend-unit.js', 'codexapp-retry-history-unit.js', 'streaming-retry-frontend-unit.js', 'assistant-bubble-controls-unit.js']) {
|
||||
for (const script of ['json-persistence-unit.js', 'runtime-state-memory-unit.js', 'session-history-store-unit.js', 'session-history-memory-unit.js', 'session-history-unit.js', 'history-runtime-unit.js', 'history-runtime-completion-unit.js', 'codex-rollouts-unit.js', 'history-frontend-unit.js', 'codexapp-retry-history-unit.js', 'streaming-retry-frontend-unit.js', 'assistant-bubble-controls-unit.js']) {
|
||||
const result = spawnSync(process.execPath, [path.join(REPO_DIR, 'scripts', script)], { encoding: 'utf8', timeout: 60000 });
|
||||
assert(result.status === 0, `${script}: ${result.stderr || result.stdout || result.error}`);
|
||||
}
|
||||
@@ -4828,7 +4828,8 @@ function assertHistoryRecallContract() {
|
||||
'Paged history requests should use the resolved full history and echo requestId'
|
||||
);
|
||||
assert(
|
||||
/prefetchChunks: targetMessageIndex === null \? 0/.test(serverSource),
|
||||
/const prefetchChunks = targetMessageIndex === null \? 0/.test(serverSource)
|
||||
&& /Math\.min\(HISTORY_MAX_CHUNKS_PER_LOAD,/.test(serverSource),
|
||||
'Normal session loads should keep older history on demand instead of prefetching it all'
|
||||
);
|
||||
|
||||
|
||||
238
scripts/runtime-state-memory-unit.js
Normal file
238
scripts/runtime-state-memory-unit.js
Normal file
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { createSessionHistoryStore } = require('../lib/session-history-store');
|
||||
|
||||
const ID = 'synthetic-runtime-memory';
|
||||
const TARGET_BYTES = 80 * 1024 * 1024;
|
||||
const BLOCK_BYTES = 64 * 1024;
|
||||
const LARGE_PIECE = 'a'.repeat(32 * 1024 - 16) + '\\引号"\n';
|
||||
const ASCII_PIECE = 'b'.repeat(32 * 1024 - 16) + '\\quote"\n';
|
||||
const SPECIAL = JSON.parse('{"text":"中文🙂\\n\\\\\\\"","__proto__":{"own":"保留值"},"constructor":"普通属性"}');
|
||||
const startedAt = Date.now();
|
||||
|
||||
function stage(message) {
|
||||
process.stderr.write(`[运行态内存回归 +${((Date.now() - startedAt) / 1000).toFixed(2)} 秒] ${message}\n`);
|
||||
}
|
||||
|
||||
function temporaryRoot() {
|
||||
// 此项测编码和解析的内存峰值;慢盘fsync会耗尽60秒预算,优先真实tmpfs。
|
||||
if (process.platform === 'linux' && typeof fs.statfsSync === 'function') {
|
||||
try {
|
||||
const stat = fs.statfsSync('/dev/shm');
|
||||
fs.accessSync('/dev/shm', fs.constants.W_OK);
|
||||
if (Number(stat.bavail) * Number(stat.bsize) >= 512 * 1024 * 1024) return '/dev/shm';
|
||||
} catch {}
|
||||
}
|
||||
return os.tmpdir();
|
||||
}
|
||||
|
||||
function textHash(value) {
|
||||
const hash = crypto.createHash('sha256');
|
||||
for (let offset = 0; offset < value.length; offset += 32 * 1024) hash.update(value.slice(offset, offset + 32 * 1024));
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
// 资料由父进程直接写标准version:1 JSON,不通过待测编码器制造测试基线。
|
||||
function writeFixture(sessionsDir) {
|
||||
const directory = path.join(sessionsDir, '_history', '_turns');
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
const filename = path.join(directory, `${ID}.json`);
|
||||
const header = JSON.stringify({ version: 1, agent: 'codexapp', sessionId: ID, turnId: 'synthetic-turn', turnStatus: 'running', special: SPECIAL }).slice(0, -1) + ',"fullText":"';
|
||||
const betweenTool = '","toolCalls":[{"id":"synthetic-tool","name":"run","input":{"text":"完整输入"},"result":"';
|
||||
const betweenDelta = '"}],"toolOutputDeltas":[["synthetic-tool","';
|
||||
const betweenItem = '"]],"agentMessageItems":[["synthetic-item","';
|
||||
const ending = '"]],"agentMessagePendingPrefixes":[],"lastUsage":{"inputTokens":1,"outputTokens":2}}';
|
||||
const fixedBytes = Buffer.byteLength(header + betweenTool + betweenDelta + betweenItem + ending);
|
||||
const toolBytes = 12 * 1024 * 1024;
|
||||
const deltaBytes = 4 * 1024 * 1024;
|
||||
const itemBytes = 4 * 1024 * 1024;
|
||||
const fullTextBytes = TARGET_BYTES - fixedBytes - toolBytes - deltaBytes - itemBytes;
|
||||
const fd = fs.openSync(filename, 'wx', 0o600);
|
||||
function writeField(encodedBytes, piece) {
|
||||
const encoded = JSON.stringify(piece).slice(1, -1);
|
||||
const size = Buffer.byteLength(encoded);
|
||||
const hash = crypto.createHash('sha256');
|
||||
let length = 0;
|
||||
let remaining = encodedBytes;
|
||||
while (remaining >= size) {
|
||||
fs.writeSync(fd, encoded);
|
||||
hash.update(piece);
|
||||
length += piece.length;
|
||||
remaining -= size;
|
||||
}
|
||||
if (remaining) {
|
||||
const final = 'z'.repeat(remaining);
|
||||
fs.writeSync(fd, final);
|
||||
hash.update(final);
|
||||
length += final.length;
|
||||
}
|
||||
return { length, hash: hash.digest('hex') };
|
||||
}
|
||||
const expected = {};
|
||||
try {
|
||||
fs.writeSync(fd, header);
|
||||
expected.fullText = writeField(fullTextBytes, ASCII_PIECE);
|
||||
fs.writeSync(fd, betweenTool);
|
||||
expected.result = writeField(toolBytes, LARGE_PIECE);
|
||||
fs.writeSync(fd, betweenDelta);
|
||||
expected.delta = writeField(deltaBytes, LARGE_PIECE);
|
||||
fs.writeSync(fd, betweenItem);
|
||||
expected.item = writeField(itemBytes, LARGE_PIECE);
|
||||
fs.writeSync(fd, ending);
|
||||
} finally { fs.closeSync(fd); }
|
||||
assert.equal(fs.statSync(filename).size, TARGET_BYTES);
|
||||
return { filename, expected };
|
||||
}
|
||||
|
||||
function fileHash(filename) {
|
||||
const hash = crypto.createHash('sha256');
|
||||
const buffer = Buffer.allocUnsafe(BLOCK_BYTES);
|
||||
const fd = fs.openSync(filename, 'r');
|
||||
try {
|
||||
let bytes;
|
||||
while ((bytes = fs.readSync(fd, buffer, 0, buffer.length, null))) hash.update(buffer.subarray(0, bytes));
|
||||
} finally { fs.closeSync(fd); }
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function trackedFilesystem(filename) {
|
||||
const opened = new Map();
|
||||
const metrics = { reads: 0, writes: 0, maxRead: 0, maxWrite: 0, readBytes: 0, writeBytes: 0 };
|
||||
let failure = null;
|
||||
let bytesSinceFailureMode = 0;
|
||||
function runtimeFile(value) {
|
||||
const resolved = typeof value === 'number' ? opened.get(value) || '' : path.resolve(String(value));
|
||||
return resolved === filename || resolved.startsWith(`${filename}.`);
|
||||
}
|
||||
const filesystem = new Proxy(fs, { get(target, key) {
|
||||
if (key === 'openSync') return (file, ...args) => {
|
||||
const fd = target.openSync(file, ...args);
|
||||
opened.set(fd, path.resolve(String(file)));
|
||||
return fd;
|
||||
};
|
||||
if (key === 'closeSync') return fd => { const result = target.closeSync(fd); opened.delete(fd); return result; };
|
||||
if (key === 'readFileSync') return (file, ...args) => {
|
||||
assert.equal(runtimeFile(file), false, '完整运行态禁止整份readFileSync');
|
||||
return target.readFileSync(file, ...args);
|
||||
};
|
||||
if (key === 'writeFileSync') return (file, ...args) => {
|
||||
assert.equal(runtimeFile(file), false, '完整运行态禁止整份writeFileSync');
|
||||
return target.writeFileSync(file, ...args);
|
||||
};
|
||||
if (key === 'readSync') return (fd, buffer, offset, length, position) => {
|
||||
if (runtimeFile(fd)) {
|
||||
assert(length <= BLOCK_BYTES, '单次运行态读取不能超过64KiB');
|
||||
metrics.maxRead = Math.max(metrics.maxRead, length);
|
||||
metrics.reads += 1;
|
||||
}
|
||||
const bytes = target.readSync(fd, buffer, offset, length, position);
|
||||
if (runtimeFile(fd)) metrics.readBytes += bytes;
|
||||
return bytes;
|
||||
};
|
||||
if (key === 'writeSync') return (fd, data, ...args) => {
|
||||
if (runtimeFile(fd)) {
|
||||
const bytes = typeof data === 'string' ? Buffer.byteLength(data, typeof args[1] === 'string' ? args[1] : 'utf8') : args[1] ?? data.length;
|
||||
assert(bytes <= BLOCK_BYTES, '单次运行态编码写入不能超过64KiB');
|
||||
metrics.maxWrite = Math.max(metrics.maxWrite, bytes);
|
||||
metrics.writes += 1;
|
||||
if (failure === 'write' && bytesSinceFailureMode >= 1024 * 1024) throw new Error('模拟运行态中途write失败');
|
||||
const written = target.writeSync(fd, data, ...args);
|
||||
metrics.writeBytes += written;
|
||||
bytesSinceFailureMode += written;
|
||||
return written;
|
||||
}
|
||||
return target.writeSync(fd, data, ...args);
|
||||
};
|
||||
if (key === 'fsyncSync') return fd => {
|
||||
if (failure === 'fsync' && runtimeFile(fd)) throw new Error('模拟运行态fsync失败');
|
||||
return target.fsyncSync(fd);
|
||||
};
|
||||
if (key === 'renameSync') return (from, to) => {
|
||||
if (failure === 'rename' && String(to) === filename) throw new Error('模拟运行态rename失败');
|
||||
return target.renameSync(from, to);
|
||||
};
|
||||
return target[key];
|
||||
} });
|
||||
return { fs: filesystem, metrics, fail(mode) { failure = mode; bytesSinceFailureMode = 0; } };
|
||||
}
|
||||
|
||||
function assertComplete(state, fixture) {
|
||||
assert.equal(state.version, 1, '旧version:1运行态须兼容');
|
||||
assert.equal(state.agent, 'codexapp');
|
||||
assert.equal(state.sessionId, ID);
|
||||
assert.equal(state.turnStatus, 'running');
|
||||
assert.deepEqual(state.special, SPECIAL);
|
||||
for (const [name, value] of [['fullText', state.fullText], ['result', state.toolCalls[0].result], ['delta', state.toolOutputDeltas[0][1]], ['item', state.agentMessageItems[0][1]]]) {
|
||||
assert.equal(value.length, fixture.expected[name].length, `${name} 长度发生变化`);
|
||||
assert.equal(textHash(value), fixture.expected[name].hash, `${name} 正文摘要发生变化`);
|
||||
}
|
||||
assert.deepEqual(state.toolCalls[0].input, { text: '完整输入' });
|
||||
assert.deepEqual(state.lastUsage, { inputTokens: 1, outputTokens: 2 });
|
||||
}
|
||||
|
||||
function runChild(sessionsDir, fixture) {
|
||||
const tracked = trackedFilesystem(fixture.filename);
|
||||
let store = createSessionHistoryStore({ sessionsDir, fs: tracked.fs });
|
||||
stage('开始读取旧version:1完整运行态');
|
||||
let state = store.loadTurn(ID);
|
||||
assertComplete(state, fixture);
|
||||
stage('旧运行态摘要校验通过,开始分段保存');
|
||||
store.saveTurn(ID, state);
|
||||
const committedHash = fileHash(fixture.filename);
|
||||
assert.equal(fs.statSync(fixture.filename).size, TARGET_BYTES, '完整持久化不能裁剪运行态');
|
||||
stage('分段保存完成,开始原子失败注入');
|
||||
state.turnStatus = 'new-uncommitted-status';
|
||||
for (const mode of ['write', 'fsync', 'rename']) {
|
||||
tracked.fail(mode);
|
||||
assert.throws(() => store.saveTurn(ID, state), /模拟运行态/);
|
||||
assert.equal(fileHash(fixture.filename), committedHash, `${mode}失败必须保留上次完整文件`);
|
||||
assert.equal(fs.readdirSync(path.dirname(fixture.filename)).some(name => name.endsWith('.tmp')), false, `${mode}失败不能遗留临时文件`);
|
||||
stage(`${mode}失败保护通过`);
|
||||
}
|
||||
tracked.fail(null);
|
||||
state = null;
|
||||
store = null;
|
||||
if (typeof global.gc === 'function') global.gc();
|
||||
const reopened = createSessionHistoryStore({ sessionsDir, fs: tracked.fs });
|
||||
state = reopened.loadTurn(ID);
|
||||
assertComplete(state, fixture);
|
||||
assert.equal(reopened.loadTurn('missing-runtime'), null);
|
||||
assert(tracked.metrics.reads > 1 && tracked.metrics.writes > 1);
|
||||
console.log(JSON.stringify({ archiveMiB: TARGET_BYTES / 1024 / 1024, peakRssMiB: process.resourceUsage().maxRSS / 1024,
|
||||
maxReadKiB: tracked.metrics.maxRead / 1024, maxWriteKiB: tracked.metrics.maxWrite / 1024,
|
||||
reads: tracked.metrics.reads, writes: tracked.metrics.writes, failureModes: 3 }));
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
const sessionsDir = fs.mkdtempSync(path.join(temporaryRoot(), 'ccweb-runtime-memory-'));
|
||||
try {
|
||||
stage(`开始生成80MiB合成完整运行态(${path.dirname(sessionsDir)})`);
|
||||
const fixture = writeFixture(sessionsDir);
|
||||
stage('合成旧运行态完成');
|
||||
const child = spawnSync(process.execPath, ['--expose-gc', '--max-old-space-size=256', __filename, '--child', sessionsDir, JSON.stringify(fixture)], {
|
||||
encoding: 'utf8', timeout: 60000, maxBuffer: 512 * 1024,
|
||||
stdio: ['ignore', 'pipe', process.env.CCWEB_RUNTIME_TEST_PROGRESS === '1' ? 'inherit' : 'pipe'],
|
||||
});
|
||||
if (child.error || child.status !== 0) process.stderr.write(child.stderr || '');
|
||||
assert.ifError(child.error);
|
||||
assert.equal(child.status, 0, `256MiB堆运行态子进程失败(信号:${child.signal || '无'}):${child.stderr || child.stdout}`);
|
||||
const result = JSON.parse(child.stdout.trim());
|
||||
console.log(`通过:${result.archiveMiB.toFixed(1)} MiB完整运行态兼容读取、分段保存、重开后四项正文摘要一致`);
|
||||
console.log(`通过:write/fsync/rename三类失败保留旧文件且无临时文件,单次读取/写入最大 ${result.maxReadKiB.toFixed(1)}/${result.maxWriteKiB.toFixed(1)} KiB`);
|
||||
console.log(`内存:256 MiB堆限制下RSS峰值 ${result.peakRssMiB.toFixed(1)} MiB;读取仍持有完整运行态对象,分段处理限制临时编码副本`);
|
||||
return result;
|
||||
} finally { fs.rmSync(sessionsDir, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
if (process.argv[2] === '--child') runChild(process.argv[3], JSON.parse(process.argv[4]));
|
||||
else runTests();
|
||||
}
|
||||
module.exports = { runTests };
|
||||
314
scripts/session-history-memory-unit.js
Normal file
314
scripts/session-history-memory-unit.js
Normal file
@@ -0,0 +1,314 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { createSessionHistoryStore } = require('../lib/session-history-store');
|
||||
|
||||
const ID = 'synthetic-memory-history';
|
||||
const TARGET_BYTES = Math.round(70.5 * 1024 * 1024);
|
||||
const TAIL_COUNT = 12;
|
||||
const PAGE_COUNT = 17;
|
||||
const SAVE_COUNT = 6;
|
||||
const ORIGINAL_CONTENT = '完整历史:中文、引号"、反斜线\\、换行\n、表情🙂\n' + 'x'.repeat(62 * 1024);
|
||||
const ORIGINAL_RESULT = '完整工具输出:' + 't'.repeat(2 * 1024);
|
||||
const startedAt = Date.now();
|
||||
|
||||
function stage(message) {
|
||||
process.stderr.write(`[历史内存回归 +${((Date.now() - startedAt) / 1000).toFixed(2)} 秒] ${message}\n`);
|
||||
}
|
||||
|
||||
function temporaryRoot() {
|
||||
// 内存回归优先使用真实 tmpfs;慢盘逐块 fsync 可能耗尽 60 秒测试预算。
|
||||
// 文件写入、校验和 fsync 都保持真实调用,原子失败语义由存储单测覆盖。
|
||||
if (process.platform === 'linux' && typeof fs.statfsSync === 'function') {
|
||||
try {
|
||||
const stat = fs.statfsSync('/dev/shm');
|
||||
fs.accessSync('/dev/shm', fs.constants.W_OK);
|
||||
if (Number(stat.bavail) * Number(stat.bsize) >= 512 * 1024 * 1024) return '/dev/shm';
|
||||
} catch {}
|
||||
}
|
||||
return os.tmpdir();
|
||||
}
|
||||
|
||||
function originalMessage(index) {
|
||||
return {
|
||||
id: `original-${index}`,
|
||||
role: index % 2 ? 'assistant' : 'user',
|
||||
content: `${index}:${ORIGINAL_CONTENT}`,
|
||||
timestamp: new Date(1800000000000 + index * 1000).toISOString(),
|
||||
toolCalls: [{
|
||||
id: `tool-${index}`,
|
||||
name: 'synthetic-tool',
|
||||
input: { text: '输入完整保存', nested: { array: [index, { value: '\\"\n🙂' }] } },
|
||||
result: ORIGINAL_RESULT,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function addedMessage(index) {
|
||||
return { id: `added-${index}`, role: 'assistant', content: `新增消息 ${index}:` + 'a'.repeat(4096) };
|
||||
}
|
||||
|
||||
function digest(value) {
|
||||
return crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||||
}
|
||||
|
||||
// 测试资料由父进程逐条写入,避免测试自身先建立整份大对象。
|
||||
function writeLegacyFixture(sessionsDir) {
|
||||
const historyDir = path.join(sessionsDir, '_history');
|
||||
fs.mkdirSync(historyDir, { recursive: true });
|
||||
const archivePath = path.join(historyDir, `${ID}.json`);
|
||||
const revision = crypto.randomUUID();
|
||||
const metadata = JSON.parse('{"__proto__":{"saved":"原值"},"constructor":"保留键","deep":{"a":{"b":{"c":{"value":"完整值"}}}}}');
|
||||
const session = { id: ID, agent: 'codexapp', codexAppThreadId: 'synthetic-thread', title: '内存回归合成资料', metadata };
|
||||
const head = JSON.stringify({ version: 1, revision, complete: true, resetRevision: null, session }).slice(0, -2) + ',"messages":[';
|
||||
const fd = fs.openSync(archivePath, 'wx', 0o600);
|
||||
let bytes = Buffer.byteLength(head);
|
||||
let count = 0;
|
||||
const paddingHead = '],"padding":"';
|
||||
const ending = '"}}';
|
||||
const paddingOverhead = Buffer.byteLength(paddingHead + ending);
|
||||
let padding;
|
||||
let batch = [];
|
||||
let batchBytes = 0;
|
||||
function flushBatch() {
|
||||
if (!batch.length) return;
|
||||
fs.writeSync(fd, batch.join(''));
|
||||
batch = [];
|
||||
batchBytes = 0;
|
||||
}
|
||||
try {
|
||||
fs.writeSync(fd, head);
|
||||
while (true) {
|
||||
const json = `${count ? ',' : ''}${JSON.stringify(originalMessage(count))}`;
|
||||
if (bytes + Buffer.byteLength(json) + paddingOverhead > TARGET_BYTES) break;
|
||||
batch.push(json);
|
||||
batchBytes += Buffer.byteLength(json);
|
||||
if (batchBytes >= 1024 * 1024) flushBatch();
|
||||
bytes += Buffer.byteLength(json);
|
||||
count += 1;
|
||||
}
|
||||
padding = 'p'.repeat(TARGET_BYTES - bytes - paddingOverhead);
|
||||
flushBatch();
|
||||
fs.writeSync(fd, paddingHead + padding + ending);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
return { archivePath, count, bytes: fs.statSync(archivePath).size, metadataHash: digest(metadata), paddingHash: digest(padding) };
|
||||
}
|
||||
|
||||
function createTrackedFilesystem(archivePath) {
|
||||
const opened = new Map();
|
||||
let metrics;
|
||||
function reset(label) {
|
||||
metrics = { label, chunkFiles: new Set(), chunkBytes: 0, legacyBytes: 0, legacyMaxRead: 0,
|
||||
chunkWrites: 0, chunkFsyncMs: 0, chunkFsyncs: 0, nextLegacyProgress: 8 * 1024 * 1024 };
|
||||
}
|
||||
reset('初始化');
|
||||
function resolveFile(value) {
|
||||
if (typeof value === 'number') return opened.get(value) || '';
|
||||
return value ? path.resolve(String(value)) : '';
|
||||
}
|
||||
function recordRead(file, bytes) {
|
||||
if (file === archivePath) {
|
||||
metrics.legacyBytes += bytes;
|
||||
metrics.legacyMaxRead = Math.max(metrics.legacyMaxRead, bytes);
|
||||
if (metrics.legacyBytes >= metrics.nextLegacyProgress) {
|
||||
stage(`已扫描旧归档 ${(metrics.legacyBytes / 1024 / 1024).toFixed(1)} MiB,写块 ${metrics.chunkWrites} 次,块 fsync 累计 ${(metrics.chunkFsyncMs / 1000).toFixed(2)} 秒`);
|
||||
metrics.nextLegacyProgress += 8 * 1024 * 1024;
|
||||
}
|
||||
}
|
||||
if (file.includes(`${path.sep}chunks${path.sep}`)) {
|
||||
metrics.chunkFiles.add(file);
|
||||
metrics.chunkBytes += bytes;
|
||||
}
|
||||
}
|
||||
const filesystem = new Proxy(fs, {
|
||||
get(target, key) {
|
||||
if (key === 'openSync') return (file, ...args) => {
|
||||
const fd = target.openSync(file, ...args);
|
||||
const resolved = resolveFile(file);
|
||||
opened.set(fd, resolved);
|
||||
if (resolved.includes(`${path.sep}chunks${path.sep}`) && String(args[0]).includes('w')) metrics.chunkWrites += 1;
|
||||
return fd;
|
||||
};
|
||||
if (key === 'closeSync') return fd => {
|
||||
const result = target.closeSync(fd);
|
||||
opened.delete(fd);
|
||||
return result;
|
||||
};
|
||||
if (key === 'readFileSync') return (file, ...args) => {
|
||||
const resolved = resolveFile(file);
|
||||
assert.notEqual(resolved, archivePath, '旧完整归档必须流式读取,禁止整文件 readFileSync');
|
||||
const value = target.readFileSync(file, ...args);
|
||||
recordRead(resolved, typeof value === 'string' ? Buffer.byteLength(value) : value.length);
|
||||
return value;
|
||||
};
|
||||
if (key === 'readSync') return (fd, ...args) => {
|
||||
const file = resolveFile(fd);
|
||||
const requested = typeof args[1] === 'object' ? args[1].length ?? args[0].length : args[2] ?? args[0].length;
|
||||
if (file === archivePath) assert(requested <= 256 * 1024, '迁移旧归档的单次读取必须有界');
|
||||
const bytes = target.readSync(fd, ...args);
|
||||
recordRead(file, bytes);
|
||||
return bytes;
|
||||
};
|
||||
if (key === 'fsyncSync') return fd => {
|
||||
const chunk = resolveFile(fd).includes(`${path.sep}chunks${path.sep}`);
|
||||
const started = Date.now();
|
||||
const result = target.fsyncSync(fd);
|
||||
if (chunk) {
|
||||
metrics.chunkFsyncs += 1;
|
||||
metrics.chunkFsyncMs += Date.now() - started;
|
||||
if (metrics.chunkFsyncs % 16 === 0) stage(`块 fsync ${metrics.chunkFsyncs} 次,累计 ${(metrics.chunkFsyncMs / 1000).toFixed(2)} 秒`);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
return target[key];
|
||||
},
|
||||
});
|
||||
return { fs: filesystem, reset, snapshot: () => ({ ...metrics, chunkFiles: metrics.chunkFiles.size }) };
|
||||
}
|
||||
|
||||
function runChild(sessionsDir, fixture) {
|
||||
const tracked = createTrackedFilesystem(fixture.archivePath);
|
||||
const store = createSessionHistoryStore({ sessionsDir, fs: tracked.fs });
|
||||
for (const name of ['loadTail', 'loadMetadata', 'loadHistoryPage']) {
|
||||
assert.equal(typeof store[name], 'function', `分块归档缺少 ${name} 接口`);
|
||||
}
|
||||
const observations = [];
|
||||
stage('开始迁移旧归档并加载首个尾页');
|
||||
let tail = store.loadTail(ID, TAIL_COUNT);
|
||||
stage('旧归档迁移完成');
|
||||
assert(tail, '旧归档迁移后必须可加载尾部');
|
||||
assert.equal(tail.messages.length, TAIL_COUNT);
|
||||
assert.equal(tail.messages[0].id, `original-${fixture.count - TAIL_COUNT}`);
|
||||
assert.equal(digest(tail.messages.at(-1)), digest(originalMessage(fixture.count - 1)));
|
||||
observations.push(tracked.snapshot());
|
||||
const manifestPath = path.join(sessionsDir, '_history', ID, 'manifest.json');
|
||||
const chunkDir = path.join(sessionsDir, '_history', ID, 'chunks');
|
||||
assert(fs.existsSync(manifestPath), '旧归档应发布版本 2 清单');
|
||||
assert.equal(JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version, 2);
|
||||
const initialChunkFiles = fs.readdirSync(chunkDir).filter(name => name.endsWith('.json'));
|
||||
assert(initialChunkFiles.length > 2, '大归档应拆成多个独立消息块');
|
||||
|
||||
function assertBoundedRead(label, callback, metadataOnly = false) {
|
||||
tracked.reset(label);
|
||||
const result = callback();
|
||||
const observation = tracked.snapshot();
|
||||
assert.equal(observation.legacyBytes, 0, `${label} 不应重新扫描旧完整归档`);
|
||||
assert(observation.chunkFiles < initialChunkFiles.length, `${label} 不应加载全部消息块`);
|
||||
assert(observation.chunkWrites < initialChunkFiles.length, `${label} 不应重写全部消息块`);
|
||||
if (metadataOnly) assert.equal(observation.chunkFiles, 0, `${label} 不应读取消息块`);
|
||||
observations.push(observation);
|
||||
return result;
|
||||
}
|
||||
|
||||
const metadata = assertBoundedRead('只加载元数据', () => store.loadMetadata(ID), true);
|
||||
assert.equal(metadata.id, ID);
|
||||
assert.equal(digest(metadata.metadata), fixture.metadataHash);
|
||||
assert.equal(digest(metadata.padding), fixture.paddingHash);
|
||||
assert(!metadata.messages || metadata.messages.length === 0, '元数据接口不应携带消息正文');
|
||||
|
||||
for (let index = 0; index < SAVE_COUNT; index += 1) {
|
||||
stage(`开始第 ${index + 1} 次尾部加载和保存`);
|
||||
tail = assertBoundedRead(`第 ${index + 1} 次加载尾部`, () => store.loadTail(ID, TAIL_COUNT));
|
||||
const lastOriginal = tail.messages.find(message => message.id === `original-${fixture.count - 1}`);
|
||||
assert(lastOriginal, '尾页应该覆盖待更新的最后一条原始消息');
|
||||
lastOriginal.content = originalMessage(fixture.count - 1).content + `|第 ${index + 1} 次完整修改`;
|
||||
lastOriginal.toolCalls[0].result = ORIGINAL_RESULT + `|第 ${index + 1} 次工具输出`;
|
||||
tail.messages.push(addedMessage(index));
|
||||
tail.title = `已提交 ${index + 1} 次尾部更新`;
|
||||
assertBoundedRead(`第 ${index + 1} 次保存尾部`, () => store.save(tail));
|
||||
stage(`第 ${index + 1} 次尾部保存完成`);
|
||||
}
|
||||
|
||||
const finalMetadata = assertBoundedRead('重新加载最新元数据', () => store.loadMetadata(ID), true);
|
||||
assert.equal(finalMetadata.title, `已提交 ${SAVE_COUNT} 次尾部更新`);
|
||||
assert.equal(digest(finalMetadata.metadata), fixture.metadataHash);
|
||||
assert.equal(digest(finalMetadata.padding), fixture.paddingHash);
|
||||
const reopened = createSessionHistoryStore({ sessionsDir, fs: tracked.fs });
|
||||
let before = fixture.count + SAVE_COUNT;
|
||||
let checked = 0;
|
||||
let pageCount = 0;
|
||||
let revision;
|
||||
stage('开始重开存储并逐页校验全部历史');
|
||||
while (before > 0) {
|
||||
const page = assertBoundedRead(`分页 ${pageCount + 1}`, () => reopened.loadHistoryPage(ID, before, PAGE_COUNT));
|
||||
assert.equal(page.total, fixture.count + SAVE_COUNT, '尾页保存必须保留未加载的全部历史');
|
||||
assert.equal(page.end, before);
|
||||
assert.equal(page.start, Math.max(0, before - PAGE_COUNT));
|
||||
assert.equal(page.messages.length, page.end - page.start);
|
||||
assert.equal(page.complete, true);
|
||||
if (revision) assert.equal(page.revision, revision, '分页期间应保持同一归档修订');
|
||||
revision = page.revision;
|
||||
page.messages.forEach((message, offset) => {
|
||||
const position = page.start + offset;
|
||||
const expected = position < fixture.count ? originalMessage(position) : addedMessage(position - fixture.count);
|
||||
if (position === fixture.count - 1) {
|
||||
expected.content += `|第 ${SAVE_COUNT} 次完整修改`;
|
||||
expected.toolCalls[0].result += `|第 ${SAVE_COUNT} 次工具输出`;
|
||||
}
|
||||
assert.equal(message.id, expected.id, `第 ${position} 条消息的顺序或稳定 ID 错误`);
|
||||
assert.equal(digest(message), digest(expected), `第 ${position} 条正文、工具或元数据出现截断或变化`);
|
||||
checked += 1;
|
||||
});
|
||||
before = page.start;
|
||||
pageCount += 1;
|
||||
if (pageCount % 16 === 0) stage(`已校验 ${pageCount} 页、${checked} 条消息`);
|
||||
}
|
||||
assert.equal(checked, fixture.count + SAVE_COUNT);
|
||||
const currentManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
const currentChunkFiles = new Set(currentManifest.entries.map(entry => entry.file));
|
||||
const reusedChunks = initialChunkFiles.filter(name => currentChunkFiles.has(name)).length;
|
||||
assert(reusedChunks > 0, '尾部更新必须复用未变化的消息块');
|
||||
stage(`全部 ${pageCount} 页、${checked} 条历史校验完成`);
|
||||
console.log(JSON.stringify({
|
||||
archiveMiB: fixture.bytes / 1024 / 1024,
|
||||
originalMessages: fixture.count,
|
||||
checkedMessages: checked,
|
||||
pages: pageCount,
|
||||
saves: SAVE_COUNT,
|
||||
peakRssMiB: process.resourceUsage().maxRSS / 1024,
|
||||
initialChunks: initialChunkFiles.length,
|
||||
reusedChunks,
|
||||
maxChunksPerBoundedOperation: Math.max(...observations.slice(1).map(item => item.chunkFiles)),
|
||||
legacyMaxReadKiB: observations[0].legacyMaxRead / 1024,
|
||||
migrationFsyncSeconds: observations[0].chunkFsyncMs / 1000,
|
||||
}));
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
const sessionsDir = fs.mkdtempSync(path.join(temporaryRoot(), 'ccweb-history-memory-'));
|
||||
try {
|
||||
stage(`开始流式写入 70.5 MiB 合成夹具(${path.dirname(sessionsDir)})`);
|
||||
const fixture = writeLegacyFixture(sessionsDir);
|
||||
stage(`夹具写入完成,共 ${fixture.count} 条原始消息`);
|
||||
assert.equal(fixture.bytes, TARGET_BYTES);
|
||||
const child = spawnSync(process.execPath, ['--max-old-space-size=256', __filename, '--child', sessionsDir, JSON.stringify(fixture)], {
|
||||
encoding: 'utf8', timeout: 60000, maxBuffer: 512 * 1024,
|
||||
stdio: ['ignore', 'pipe', process.env.CCWEB_HISTORY_TEST_PROGRESS === '1' ? 'inherit' : 'pipe'],
|
||||
});
|
||||
if (child.error || child.status !== 0) process.stderr.write(child.stderr || '');
|
||||
assert.ifError(child.error);
|
||||
assert.equal(child.status, 0, `256 MiB 堆子进程失败(信号:${child.signal || '无'}):\n${child.stderr || child.stdout}`);
|
||||
const result = JSON.parse(child.stdout.trim());
|
||||
console.log(`通过:${result.archiveMiB.toFixed(1)} MiB 旧归档流式迁移,256 MiB 堆内逐页校验 ${result.checkedMessages} 条完整历史`);
|
||||
console.log(`通过:${result.saves} 次尾部保存、${result.pages} 页读取,单次最多触达 ${result.maxChunksPerBoundedOperation}/${result.initialChunks} 个原始块`);
|
||||
console.log(`内存:子进程 RSS 峰值 ${result.peakRssMiB.toFixed(1)} MiB;旧归档单次读取最大 ${result.legacyMaxReadKiB.toFixed(1)} KiB;复用 ${result.reusedChunks} 个原始块`);
|
||||
return result;
|
||||
} finally {
|
||||
fs.rmSync(sessionsDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
if (process.argv[2] === '--child') runChild(process.argv[3], JSON.parse(process.argv[4]));
|
||||
else runTests();
|
||||
}
|
||||
|
||||
module.exports = { runTests };
|
||||
@@ -36,11 +36,13 @@ function createServerHarness(sessionsDir, options = {}) {
|
||||
'sanitizeToolCallsForPersist', 'sanitizeMessageForPersist', 'sanitizeMessagesForPersist',
|
||||
'sanitizeSessionForPersist', 'buildSessionJsonForPersist', 'writeFileAtomicSync',
|
||||
'safeReadSessionJson', 'loadSession', 'preserveNewerTaskTrackingForSessionSave',
|
||||
'findSessionMessage',
|
||||
'saveSession', 'sessionHistoryCacheKey', 'cacheSessionHistory', 'loadNativeSessionHistory',
|
||||
'resolveSessionHistory',
|
||||
'listSessionFiles', 'refreshSessionSnapshotCache', 'prepareNativeHistoryImport',
|
||||
'handleSlashCommand', 'handleDeleteSession', 'findCcwebPromptMessage', 'removeCcwebPromptMessage',
|
||||
'rollbackPersistedCodexAppSteerMessage', 'handleLoadHistoryPage',
|
||||
'buildSessionInfoPayload', 'handleResumeSession',
|
||||
];
|
||||
const api = new Function('fs', 'path', 'crypto', 'SESSIONS_DIR', 'sessionHistoryStore', 'rollouts', 'options', 'logs', `
|
||||
const SESSION_MAX_JSON_BYTES = options.snapshotMaxBytes || 512 * 1024;
|
||||
@@ -60,6 +62,8 @@ function createServerHarness(sessionsDir, options = {}) {
|
||||
const retainedCodexAppTurns = new Map();
|
||||
const activeCodexAppGoalCommands = new Map();
|
||||
const activeCodexAppCompactions = new Map();
|
||||
const pendingCodexCapacityRetries = new Map();
|
||||
const wsSessionMap = new Map();
|
||||
const codexAppGoalStates = new Map();
|
||||
const pendingSlashCommands = new Map();
|
||||
const pendingCompactRetries = new Map();
|
||||
@@ -83,6 +87,10 @@ function createServerHarness(sessionsDir, options = {}) {
|
||||
function wsSend(ws, payload) { responses.push(payload); }
|
||||
function attachClientRequestId(payload, source) { return { ...payload, requestId: source?.requestId }; }
|
||||
function taskTrackingSnapshotForSession() { return null; }
|
||||
function crossConversationWaitState() { return {}; }
|
||||
function detachWsFromActiveRuntimes() {}
|
||||
function isSessionRunning() { return false; }
|
||||
function attachActiveRuntimeToWs() {}
|
||||
function publicTitleMetadata() { return {}; }
|
||||
function sessionModelLabel(session) { return session.model; }
|
||||
function isCodexAppSession(session) { return session.agent === 'codexapp'; }
|
||||
@@ -105,9 +113,16 @@ function createServerHarness(sessionsDir, options = {}) {
|
||||
${pureSource}
|
||||
${names.map(name => extract(name)).join('\n')}
|
||||
return {
|
||||
loadSession, saveSession, resolveSessionHistory, listSessionFiles, refreshSessionSnapshotCache,
|
||||
loadSession: (id, loadOptions = {}) => {
|
||||
const explicitWindow = ['metadata', 'count', 'window', 'messageId', 'promptId'].some(key => Object.prototype.hasOwnProperty.call(loadOptions, key));
|
||||
const full = Object.prototype.hasOwnProperty.call(loadOptions, 'full')
|
||||
? loadOptions.full : options.fullHistory !== false && !explicitWindow;
|
||||
return loadSession(id, { ...loadOptions, full });
|
||||
},
|
||||
saveSession, resolveSessionHistory, listSessionFiles, refreshSessionSnapshotCache, findSessionMessage,
|
||||
prepareNativeHistoryImport, handleSlashCommand, handleDeleteSession, removeCcwebPromptMessage,
|
||||
rollbackPersistedCodexAppSteerMessage, handleLoadHistoryPage, responses, logs, sessionHistoryStore,
|
||||
buildSessionInfoPayload, handleResumeSession,
|
||||
};
|
||||
`)(filesystem, path, crypto, sessionsDir, sessionHistoryStore, rollouts, options, logs);
|
||||
return api;
|
||||
@@ -302,6 +317,16 @@ check('首次快照失败的孤立档案重开后仍可读取并参与列表且
|
||||
assert(fs.existsSync(path.join(dir, 'orphan.json')));
|
||||
});
|
||||
|
||||
check('运行态保留目录和同名旧文件不会干扰完整归档与会话列表', dir => {
|
||||
const api = createServerHarness(dir);
|
||||
assert.equal(api.saveSession(fixture('listed-session', 3)), true);
|
||||
api.sessionHistoryStore.saveTurn('listed-session', { sessionId: 'listed-session', agent: 'codexapp' });
|
||||
fs.writeFileSync(path.join(dir, '_history', '_turns.json'), '{}');
|
||||
assert.deepEqual(api.sessionHistoryStore.listTurnIds(), ['listed-session']);
|
||||
assert.deepEqual(api.sessionHistoryStore.listIds(), ['listed-session']);
|
||||
assert.deepEqual(api.listSessionFiles(), ['listed-session.json']);
|
||||
});
|
||||
|
||||
check('可信旧修订并发追加和插入保留锚点顺序以及特殊 JSON 键', dir => {
|
||||
const api = createServerHarness(dir);
|
||||
const original = fixture('concurrent', 4);
|
||||
@@ -389,7 +414,7 @@ check('档案异常禁止回退预览覆盖,删除档案后旧加载对象不
|
||||
assert.equal(api.saveSession(fixture('corrupt', 220)), true);
|
||||
const stale = api.loadSession('corrupt');
|
||||
const oldSnapshot = fs.readFileSync(path.join(dir, 'corrupt.json'), 'utf8');
|
||||
fs.writeFileSync(path.join(dir, '_history', 'corrupt.json'), '{损坏文件');
|
||||
fs.writeFileSync(path.join(dir, '_history', 'corrupt', 'manifest.json'), '{损坏文件');
|
||||
assert.equal(api.loadSession('corrupt'), null);
|
||||
assert.equal(api.saveSession(stale), false);
|
||||
assert.equal(fs.readFileSync(path.join(dir, 'corrupt.json'), 'utf8'), oldSnapshot);
|
||||
@@ -445,7 +470,7 @@ check('旧快照的真实 rollout 前缀确认后先归档,移除 rollout 后
|
||||
const api = createServerHarness(dir, { nativeDir, nativeFiles: [rolloutPath] });
|
||||
const resolved = api.resolveSessionHistory(api.loadSession(id));
|
||||
assert.equal(resolved.messages.length, 8);
|
||||
assert(fs.existsSync(path.join(dir, '_history', `${id}.json`)), '确认前缀后应提交自身档案');
|
||||
assert(fs.existsSync(path.join(dir, '_history', id, 'manifest.json')), '确认前缀后应提交自身档案');
|
||||
fs.unlinkSync(rolloutPath);
|
||||
const reopened = createServerHarness(dir);
|
||||
const reloaded = reopened.loadSession(id);
|
||||
@@ -462,7 +487,8 @@ check('原生日志不匹配和不存在时只返回已有旧快照并保留缺
|
||||
assert.equal(history.messages.length, 57);
|
||||
assert.equal(history.available, false);
|
||||
assert.equal(api.saveSession(loaded), true);
|
||||
assert.equal(createServerHarness(dir).resolveSessionHistory(createServerHarness(dir).loadSession('unconfirmed')).available, false);
|
||||
const reopened = createServerHarness(dir);
|
||||
assert.equal(reopened.resolveSessionHistory(reopened.loadSession('unconfirmed')).available, false);
|
||||
});
|
||||
|
||||
check('归档提交失败的旧前缀恢复只提供原快照而不宣称恢复成功', dir => {
|
||||
@@ -538,6 +564,402 @@ check('生产分页出口读取57条旧快照:首屏12以外45条完整有序
|
||||
assert.equal(new Set(chunks.map(message => message.id)).size, 45);
|
||||
});
|
||||
|
||||
check('真实默认加载650条历史只持有180条尾部且显式窗口不走完整加载', dir => {
|
||||
const api = createServerHarness(dir, { fullHistory: false });
|
||||
const original = fixture('bounded-default', 650);
|
||||
assert.equal(api.saveSession(original), true);
|
||||
const loaded = api.loadSession(original.id);
|
||||
assert.equal(loaded.messages.length, 180);
|
||||
assert.equal(loaded.historySnapshotBaseIndex, 470);
|
||||
assert.deepEqual(loaded.messages, original.messages.slice(470));
|
||||
const state = api.sessionHistoryStore.state(loaded);
|
||||
assert.equal(state.partial, true);
|
||||
assert.equal(state.start, 470);
|
||||
assert.equal(state.total, 650);
|
||||
assert.deepEqual(api.loadSession(original.id, { full: true }).messages, original.messages);
|
||||
const fullDefault = createServerHarness(dir);
|
||||
assert.equal(fullDefault.loadSession(original.id, { count: 7 }).messages.length, 7);
|
||||
assert.equal(fullDefault.loadSession(original.id, { metadata: true }).messages.length, 0);
|
||||
assert.equal(fullDefault.loadSession(original.id, { window: { start: 470, end: 650 } }).messages.length, 180);
|
||||
});
|
||||
|
||||
check('有界尾部修改删除前插追加精确保存且未加载前缀原样保留', dir => {
|
||||
const api = createServerHarness(dir, { fullHistory: false });
|
||||
const original = fixture('bounded-edit', 650);
|
||||
assert.equal(api.saveSession(original), true);
|
||||
const loaded = api.loadSession(original.id);
|
||||
loaded.messages[0].content = '尾部同 ID 更新';
|
||||
loaded.messages[0].toolCalls = [{ id: 'changed-tool', input: { retained: true }, result: '完整工具结果' }];
|
||||
loaded.messages.splice(1, 1);
|
||||
loaded.messages.splice(2, 0, { id: 'inserted-before-tail', role: 'assistant', content: '已有气泡前插入' });
|
||||
loaded.messages.push({ id: 'new-tail', role: 'user', content: '追加完整内容' });
|
||||
const expected = [...original.messages.slice(0, 470), ...loaded.messages];
|
||||
assert.equal(api.saveSession(loaded), true);
|
||||
const restored = api.loadSession(original.id, { full: true });
|
||||
assert.deepEqual(restored.messages, expected);
|
||||
assert.deepEqual(restored.messages.slice(0, 470), original.messages.slice(0, 470));
|
||||
assert.equal(restored.messages.some(message => message.id === 'message-471'), false);
|
||||
});
|
||||
|
||||
check('中间加载窗口修改删除前插保存同时保留未加载前缀和后缀', dir => {
|
||||
const api = createServerHarness(dir, { fullHistory: false });
|
||||
const original = fixture('middle-window', 650);
|
||||
original.messages[12].ccwebPrompt = { id: 'early-prompt' };
|
||||
assert.equal(api.saveSession(original), true);
|
||||
const tail = api.loadSession(original.id);
|
||||
assert.deepEqual(api.findSessionMessage(tail, message => message?.ccwebPrompt?.id === 'early-prompt'), original.messages[12]);
|
||||
const promptWindow = api.loadSession(original.id, { promptId: 'early-prompt' });
|
||||
assert.equal(promptWindow.messages.length, 1);
|
||||
assert.equal(promptWindow.messages[0].id, 'message-12');
|
||||
const messageWindow = api.loadSession(original.id, { messageId: 'message-10' });
|
||||
assert.equal(messageWindow.messages.length, 1);
|
||||
assert.equal(messageWindow.messages[0].id, 'message-10');
|
||||
const loaded = api.loadSession(original.id, { window: { start: 250, end: 255 } });
|
||||
assert.equal(loaded.messages.length, 5);
|
||||
assert.equal(loaded.historySnapshotBaseIndex, 250);
|
||||
loaded.messages[0].content = '中间窗口同 ID 更新';
|
||||
loaded.messages.splice(1, 1);
|
||||
loaded.messages.splice(2, 0, { id: 'middle-insert', role: 'assistant', content: '中间窗口前插内容' });
|
||||
const expected = [...original.messages.slice(0, 250), ...loaded.messages, ...original.messages.slice(255)];
|
||||
assert.equal(api.saveSession(loaded), true);
|
||||
const restored = api.loadSession(original.id, { full: true });
|
||||
assert.deepEqual(restored.messages, expected);
|
||||
assert.deepEqual(restored.messages.slice(0, 250), original.messages.slice(0, 250));
|
||||
assert.deepEqual(restored.messages.slice(255), original.messages.slice(255));
|
||||
assert.equal(restored.messages.some(message => message.id === 'message-251'), false);
|
||||
});
|
||||
|
||||
check('仅元数据加载更新标题及taskTracking不读取或覆盖未加载消息', dir => {
|
||||
const api = createServerHarness(dir);
|
||||
const original = fixture('metadata-only', 650);
|
||||
original.taskTracking = { version: 2, tasks: [{ id: 'task-a', title: '原任务', status: 'pending' }] };
|
||||
assert.equal(api.saveSession(original), true);
|
||||
const metadata = api.loadSession(original.id, { metadata: true });
|
||||
assert.equal(metadata.messages.length, 0);
|
||||
assert.equal(api.sessionHistoryStore.state(metadata).partial, true);
|
||||
metadata.title = '仅元数据更新的标题';
|
||||
metadata.taskTracking = { version: 3, tasks: [{ id: 'task-a', title: '新任务', status: 'completed' }] };
|
||||
assert.equal(api.saveSession(metadata), true);
|
||||
const restored = api.loadSession(original.id);
|
||||
assert.equal(restored.title, metadata.title);
|
||||
assert.deepEqual(restored.taskTracking, metadata.taskTracking);
|
||||
assert.deepEqual(restored.messages, original.messages);
|
||||
});
|
||||
|
||||
check('同修订任务版本保护只读取修订头且提交后同步活跃对象', dir => {
|
||||
let tracking = false;
|
||||
let wholeReads = 0;
|
||||
const filesystem = new Proxy(fs, {
|
||||
get(target, key) {
|
||||
if (key === 'readFileSync') return (...args) => {
|
||||
if (tracking) wholeReads += 1;
|
||||
return target.readFileSync(...args);
|
||||
};
|
||||
return target[key];
|
||||
},
|
||||
});
|
||||
const api = createServerHarness(dir, { fs: filesystem, fullHistory: false });
|
||||
const original = fixture('same-task-version', 650);
|
||||
original.taskTracking = { version: 5, statusId: 'waiting', summary: '已提交任务' };
|
||||
assert.equal(api.saveSession(original), true);
|
||||
const loaded = api.loadSession(original.id);
|
||||
const taskReference = loaded.taskTracking;
|
||||
loaded.taskTracking.version = 1;
|
||||
loaded.taskTracking.statusId = '旧任务';
|
||||
tracking = true;
|
||||
const committed = api.sessionHistoryStore.save(loaded);
|
||||
tracking = false;
|
||||
assert.equal(wholeReads, 0, '同修订提交不得重新读取清单或消息正文');
|
||||
assert.deepEqual(committed.taskTrackingPreserved, { incomingVersion: 1, persistedVersion: 5 });
|
||||
assert.equal(loaded.taskTracking, taskReference, '任务对象仍原位协调');
|
||||
assert.deepEqual(loaded.taskTracking, original.taskTracking);
|
||||
loaded.taskTracking.summary = '提交后修改';
|
||||
const disk = api.loadSession(original.id, { metadata: true });
|
||||
assert.equal(disk.taskTracking.summary, '已提交任务', '活跃对象不能污染可信清单');
|
||||
assert.equal(api.loadSession(original.id, { full: true }).messages.length, 650);
|
||||
});
|
||||
|
||||
check('旧修订普通保存完整保留更高任务版本与流式消息引用', dir => {
|
||||
const api = createServerHarness(dir, { fullHistory: false });
|
||||
const original = fixture('stale-task-version', 650);
|
||||
original.taskTracking = { version: 2, statusId: 'initial', nested: { value: '原任务' } };
|
||||
assert.equal(api.saveSession(original), true);
|
||||
const streaming = api.loadSession(original.id);
|
||||
const message = streaming.messages.at(-1);
|
||||
const metadata = api.loadSession(original.id, { metadata: true });
|
||||
metadata.taskTracking = { version: 6, statusId: 'newer', nested: { value: '新任务' } };
|
||||
assert.equal(api.saveSession(metadata, { taskBoardMetadata: true }), true);
|
||||
streaming.taskTracking = { version: 4, statusId: 'stale', nested: { value: '旧对象修改' } };
|
||||
message.content += '|增量';
|
||||
assert.equal(api.saveSession(streaming), true);
|
||||
assert.deepEqual(streaming.taskTracking, metadata.taskTracking);
|
||||
assert.equal(streaming.messages.at(-1), message);
|
||||
const restored = api.loadSession(original.id, { full: true });
|
||||
assert.equal(restored.messages.length, 650);
|
||||
assert.equal(restored.messages.at(-1).content, message.content);
|
||||
assert.deepEqual(restored.taskTracking, metadata.taskTracking);
|
||||
});
|
||||
|
||||
check('任务看板显式提交可删除任务状态而普通保存保留已提交版本', dir => {
|
||||
const api = createServerHarness(dir);
|
||||
const original = fixture('task-metadata-removal', 3);
|
||||
original.taskTracking = { version: 3, statusId: 'waiting' };
|
||||
assert.equal(api.saveSession(original), true);
|
||||
const ordinary = api.loadSession(original.id);
|
||||
delete ordinary.taskTracking;
|
||||
assert.equal(api.saveSession(ordinary), true);
|
||||
assert.deepEqual(ordinary.taskTracking, original.taskTracking);
|
||||
const board = api.loadSession(original.id, { metadata: true });
|
||||
delete board.taskTracking;
|
||||
assert.equal(api.saveSession(board, { taskBoardMetadata: true }), true);
|
||||
const restored = api.loadSession(original.id);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(restored, 'taskTracking'), false);
|
||||
assert.deepEqual(restored.messages, original.messages);
|
||||
});
|
||||
|
||||
check('任务状态保护遇清单提交失败不提前修改对象或推进基线', dir => {
|
||||
let failManifest = false;
|
||||
const filesystem = new Proxy(fs, {
|
||||
get(target, key) {
|
||||
if (key === 'renameSync') return (from, to) => {
|
||||
if (failManifest && path.basename(to) === 'manifest.json') throw new Error('模拟任务清单提交失败');
|
||||
return target.renameSync(from, to);
|
||||
};
|
||||
return target[key];
|
||||
},
|
||||
});
|
||||
const api = createServerHarness(dir, { fs: filesystem });
|
||||
const original = fixture('task-failed-commit', 4);
|
||||
original.taskTracking = { version: 5, statusId: 'saved' };
|
||||
assert.equal(api.saveSession(original), true);
|
||||
const loaded = api.loadSession(original.id);
|
||||
loaded.taskTracking = { version: 1, statusId: 'stale' };
|
||||
const pendingTracking = loaded.taskTracking;
|
||||
const revision = api.sessionHistoryStore.state(loaded).revision;
|
||||
failManifest = true;
|
||||
assert.equal(api.saveSession(loaded), false);
|
||||
assert.equal(loaded.taskTracking, pendingTracking);
|
||||
assert.equal(loaded.taskTracking.version, 1);
|
||||
assert.equal(api.sessionHistoryStore.state(loaded).revision, revision);
|
||||
failManifest = false;
|
||||
assert.equal(api.saveSession(loaded), true);
|
||||
assert.deepEqual(loaded.taskTracking, original.taskTracking);
|
||||
});
|
||||
|
||||
check('不同尾部加载范围的旧修订合并修改删除及新增并保留各自顺序', dir => {
|
||||
const api = createServerHarness(dir, { fullHistory: false });
|
||||
const original = fixture('partial-ranges', 650);
|
||||
assert.equal(api.saveSession(original), true);
|
||||
const narrow = api.sessionHistoryStore.loadTail(original.id, 5);
|
||||
const wide = api.sessionHistoryStore.loadTail(original.id, 30);
|
||||
wide.messages[0].content = '较宽窗口的修改';
|
||||
wide.messages.splice(wide.messages.findIndex(message => message.id === 'message-640'), 1);
|
||||
wide.messages.push({ id: 'wide-new', role: 'user', content: '较宽窗口追加' });
|
||||
assert.equal(api.saveSession(wide), true);
|
||||
narrow.messages[0].content = '旧修订较窄窗口的修改';
|
||||
narrow.messages.splice(1, 0, { id: 'narrow-insert', role: 'assistant', content: '较窄窗口前插' });
|
||||
narrow.messages.push({ id: 'narrow-new', role: 'assistant', content: '较窄窗口追加' });
|
||||
assert.equal(api.saveSession(narrow), true);
|
||||
const restored = api.loadSession(original.id, { full: true });
|
||||
const expected = original.messages.filter(message => message.id !== 'message-640').map(message => ({ ...message }));
|
||||
expected.find(message => message.id === 'message-620').content = '较宽窗口的修改';
|
||||
expected.find(message => message.id === 'message-645').content = '旧修订较窄窗口的修改';
|
||||
expected.splice(expected.findIndex(message => message.id === 'message-646'), 0, { id: 'narrow-insert', role: 'assistant', content: '较窄窗口前插' });
|
||||
expected.push({ id: 'narrow-new', role: 'assistant', content: '较窄窗口追加' }, { id: 'wide-new', role: 'user', content: '较宽窗口追加' });
|
||||
assert.deepEqual(restored.messages, expected);
|
||||
assert.deepEqual(narrow.messages, expected.slice(644));
|
||||
});
|
||||
|
||||
check('部分加载明确reset清空全部历史后旧运行对象只保存元数据', dir => {
|
||||
const api = createServerHarness(dir, { fullHistory: false });
|
||||
const original = fixture('partial-clear', 650);
|
||||
assert.equal(api.saveSession(original), true);
|
||||
const stale = api.loadSession(original.id);
|
||||
const current = api.loadSession(original.id);
|
||||
current.messages = [];
|
||||
current.codexAppThreadId = null;
|
||||
api.sessionHistoryStore.markComplete(current, { reset: true });
|
||||
assert.equal(api.saveSession(current), true);
|
||||
assert.deepEqual(api.loadSession(original.id, { full: true }).messages, []);
|
||||
stale.messages[0].content = '清空后旧轮才返回的输出';
|
||||
stale.messages.push({ id: 'stale-output', role: 'assistant', content: '不能复活旧历史' });
|
||||
stale.model = '旧对象本次修改的模型';
|
||||
assert.equal(api.saveSession(stale), true);
|
||||
const restored = api.loadSession(original.id, { full: true });
|
||||
assert.deepEqual(restored.messages, []);
|
||||
assert.equal(restored.codexAppThreadId, null);
|
||||
assert.equal(restored.model, stale.model);
|
||||
});
|
||||
|
||||
check('尾部manifest提交失败不推进基线且重试保留并发元数据与完整前缀', dir => {
|
||||
let failManifest = false;
|
||||
const filesystem = new Proxy(fs, { get(target, key) {
|
||||
if (key === 'renameSync') return (from, to) => {
|
||||
if (failManifest && path.basename(to) === 'manifest.json') throw new Error('模拟清单提交失败');
|
||||
return target.renameSync(from, to);
|
||||
};
|
||||
return target[key];
|
||||
} });
|
||||
const api = createServerHarness(dir, { fullHistory: false, fs: filesystem });
|
||||
const original = fixture('partial-retry', 650);
|
||||
assert.equal(api.saveSession(original), true);
|
||||
const pending = api.loadSession(original.id);
|
||||
const baselineRevision = api.sessionHistoryStore.state(pending).revision;
|
||||
const oldSnapshot = fs.readFileSync(path.join(dir, `${original.id}.json`), 'utf8');
|
||||
pending.messages[0].content = '失败重试后应保存的正文';
|
||||
pending.messages.push({ id: 'retry-new', role: 'assistant', content: '只提交一次的新消息' });
|
||||
failManifest = true;
|
||||
assert.equal(api.saveSession(pending), false);
|
||||
assert.equal(api.sessionHistoryStore.state(pending).revision, baselineRevision);
|
||||
assert.equal(fs.readFileSync(path.join(dir, `${original.id}.json`), 'utf8'), oldSnapshot);
|
||||
assert.deepEqual(api.loadSession(original.id, { full: true }).messages, original.messages);
|
||||
failManifest = false;
|
||||
const metadata = api.loadSession(original.id, { metadata: true });
|
||||
metadata.title = '失败后其他对象提交的标题';
|
||||
assert.equal(api.saveSession(metadata), true);
|
||||
assert.equal(api.saveSession(pending), true);
|
||||
assert.notEqual(api.sessionHistoryStore.state(pending).revision, baselineRevision);
|
||||
const restored = api.loadSession(original.id, { full: true });
|
||||
assert.equal(restored.title, metadata.title);
|
||||
assert.deepEqual(restored.messages.slice(0, 470), original.messages.slice(0, 470));
|
||||
assert.equal(restored.messages[470].content, '失败重试后应保存的正文');
|
||||
assert.equal(restored.messages.length, 651);
|
||||
assert.equal(restored.messages.filter(message => message.id === 'retry-new').length, 1);
|
||||
});
|
||||
|
||||
check('不完整归档分页总量和消息不计入持久化裁剪提示', dir => {
|
||||
const api = createServerHarness(dir, { fullHistory: false });
|
||||
const original = fixture('incomplete-pages', 57);
|
||||
original.messages.unshift({ id: 'persistence-notice', role: 'system', ccwebPersistenceNotice: true, content: '仅显示的旧截断提示' });
|
||||
original.historySnapshotBaseIndex = 500;
|
||||
const expectedMessages = original.messages.slice(1);
|
||||
assert.equal(api.saveSession(original), true);
|
||||
const directPage = api.sessionHistoryStore.loadHistoryPage(original.id, 57, 24);
|
||||
assert.equal(directPage.total, 57);
|
||||
assert.equal(directPage.complete, false);
|
||||
const chunks = [];
|
||||
let cursor = 57;
|
||||
while (cursor > 0) {
|
||||
api.handleLoadHistoryPage(null, { sessionId: original.id, before: cursor, requestId: `archive-page-${cursor}` });
|
||||
const page = api.responses.at(-1);
|
||||
assert.equal(page.type, 'session_history_chunk');
|
||||
assert.equal(page.historyTotal, 57);
|
||||
assert.equal(page.historyAvailable, false);
|
||||
assert.equal(page.messages.some(message => message.ccwebPersistenceNotice === true), false);
|
||||
chunks.unshift(...page.messages);
|
||||
assert(page.historyCursor < cursor, '分页游标必须前进');
|
||||
cursor = page.historyCursor;
|
||||
}
|
||||
assert.deepEqual(chunks, expectedMessages);
|
||||
});
|
||||
|
||||
check('尾部追加ID撞未加载前缀时保留原始前缀并去重', dir => {
|
||||
const api = createServerHarness(dir, { fullHistory: false });
|
||||
const original = fixture('partial-collision', 650);
|
||||
assert.equal(api.saveSession(original), true);
|
||||
const loaded = api.loadSession(original.id);
|
||||
loaded.messages.push({ id: 'message-5', role: 'assistant', content: '碰撞内容不能覆盖未知前缀' });
|
||||
loaded.messages.push({ id: 'unique-tail', role: 'assistant', content: '真正新增的尾部内容' });
|
||||
assert.equal(api.saveSession(loaded), true);
|
||||
const restored = api.loadSession(original.id, { full: true });
|
||||
assert.deepEqual(restored.messages.slice(0, 650), original.messages);
|
||||
assert.equal(restored.messages.length, 651);
|
||||
assert.equal(restored.messages.filter(message => message.id === 'message-5').length, 1);
|
||||
assert.equal(restored.messages.at(-1).id, 'unique-tail');
|
||||
});
|
||||
|
||||
check('真实归档首屏及恢复仅返回12条并逐页读取650条完整有序历史', dir => {
|
||||
const api = createServerHarness(dir, { fullHistory: false });
|
||||
const original = fixture('bounded-first-screen', 650);
|
||||
assert.equal(api.saveSession(original), true);
|
||||
const loaded = api.loadSession(original.id);
|
||||
assert.equal(loaded.messages.length, 180);
|
||||
const info = api.buildSessionInfoPayload(loaded);
|
||||
assert.equal(info.type, 'session_info');
|
||||
assert.equal(info.messages.length, 12);
|
||||
assert.deepEqual(info.messages, original.messages.slice(638));
|
||||
assert.equal(info.historyTotal, 650);
|
||||
assert.equal(info.historyBaseIndex, 638);
|
||||
assert.equal(info.historyCursor, 638);
|
||||
assert.equal(info.historyTruncated, true);
|
||||
assert.equal(info.historyAvailable, true);
|
||||
api.handleLoadHistoryPage(null, { sessionId: original.id, before: 638, requestId: 'first-earlier-page' });
|
||||
const firstPage = api.responses.at(-1);
|
||||
assert.equal(firstPage.type, 'session_history_chunk');
|
||||
assert.equal(firstPage.messages.length, 24);
|
||||
assert.deepEqual(firstPage.messages, original.messages.slice(614, 638));
|
||||
assert.equal(firstPage.historyTotal, 650);
|
||||
assert.equal(firstPage.historyBaseIndex, 614);
|
||||
assert.equal(firstPage.historyCursor, 614);
|
||||
const older = [...firstPage.messages];
|
||||
let cursor = firstPage.historyCursor;
|
||||
while (cursor > 0) {
|
||||
api.handleLoadHistoryPage(null, { sessionId: original.id, before: cursor, requestId: `complete-page-${cursor}` });
|
||||
const page = api.responses.at(-1);
|
||||
assert.equal(page.type, 'session_history_chunk');
|
||||
assert.equal(page.historyTotal, 650);
|
||||
assert.equal(page.historyAvailable, true);
|
||||
assert(page.messages.length <= 24, '每页消息数必须有界');
|
||||
assert(page.historyCursor < cursor, '历史游标必须前进');
|
||||
older.unshift(...page.messages);
|
||||
cursor = page.historyCursor;
|
||||
}
|
||||
const complete = [...older, ...info.messages];
|
||||
assert.deepEqual(complete, original.messages);
|
||||
assert.equal(new Set(complete.map(message => message.id)).size, 650);
|
||||
api.handleResumeSession({}, { sessionId: original.id, requestId: 'resume-bounded-history' });
|
||||
const resumed = api.responses.at(-1);
|
||||
assert.equal(resumed.type, 'resume_session_result');
|
||||
assert.equal(resumed.messages.length, 12);
|
||||
assert.deepEqual(resumed.messages, info.messages);
|
||||
assert.equal(resumed.historyTotal, info.historyTotal);
|
||||
assert.equal(resumed.historyBaseIndex, info.historyBaseIndex);
|
||||
assert.equal(resumed.historyAvailable, true);
|
||||
assert.equal(resumed.isRunning, false);
|
||||
});
|
||||
|
||||
check('旧尾部对象协调新增深层元数据后原位修改不污染不可变基线', dir => {
|
||||
const api = createServerHarness(dir, { fullHistory: false });
|
||||
const original = fixture('partial-nested-baseline', 650);
|
||||
assert.equal(api.saveSession(original), true);
|
||||
const streaming = api.loadSession(original.id);
|
||||
const metadata = api.loadSession(original.id, { metadata: true });
|
||||
metadata.nested = { left: 1, right: 1 };
|
||||
assert.equal(api.saveSession(metadata), true);
|
||||
assert.equal(api.saveSession(streaming), true);
|
||||
assert.deepEqual(streaming.nested, { left: 1, right: 1 });
|
||||
const laterMetadata = api.loadSession(original.id, { metadata: true });
|
||||
laterMetadata.nested.right = 2;
|
||||
assert.equal(api.saveSession(laterMetadata), true);
|
||||
streaming.nested.left = 2;
|
||||
assert.equal(api.saveSession(streaming), true);
|
||||
const restored = api.loadSession(original.id, { full: true });
|
||||
assert.deepEqual(restored.nested, { left: 2, right: 2 });
|
||||
assert.deepEqual(streaming.nested, { left: 2, right: 2 });
|
||||
assert.deepEqual(restored.messages, original.messages);
|
||||
});
|
||||
|
||||
check('中间窗口协调并发尾部提交后再次保存仍保留前后缀', dir => {
|
||||
const api = createServerHarness(dir, { fullHistory: false });
|
||||
const original = fixture('middle-window-rebase', 650);
|
||||
assert.equal(api.saveSession(original), true);
|
||||
const middle = api.loadSession(original.id, { window: { start: 30, end: 33 } });
|
||||
const tail = api.loadSession(original.id);
|
||||
const addedTail = { id: 'concurrent-tail', role: 'assistant', content: '另一运行对象追加的尾部' };
|
||||
tail.messages.push(addedTail);
|
||||
assert.equal(api.saveSession(tail), true);
|
||||
middle.messages[0].content = '中间窗口第一次更新';
|
||||
middle.messages.splice(1, 1);
|
||||
assert.equal(api.saveSession(middle), true);
|
||||
assert.deepEqual(middle.messages.map(message => message.id), ['message-30', 'message-32']);
|
||||
assert.equal(middle.historySnapshotBaseIndex, 30);
|
||||
middle.messages[0].content = '中间窗口第二次更新';
|
||||
middle.messages.splice(1, 0, { id: 'middle-second-insert', role: 'assistant', content: '第二次保存前插内容' });
|
||||
const expected = [...original.messages.slice(0, 30), ...middle.messages, ...original.messages.slice(33), addedTail];
|
||||
assert.equal(api.saveSession(middle), true);
|
||||
assert.deepEqual(api.loadSession(original.id, { full: true }).messages, expected);
|
||||
});
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`完整历史回归失败:${failures.length} 项,已通过 ${checks} 项`);
|
||||
process.exitCode = 1;
|
||||
|
||||
Reference in New Issue
Block a user