Replies: 2 comments
|
独立复现确认(第二例数据点)+ 已验证的修复配方 —— 与你完全相同的 bug,补充取证数据;有一处差异供根因定位参考。 现象:Web GUI 打开会话历史报 日志取证(JSONL+zstd,
与你案例的差异:我这里参与重叠的合成事件只有 2 个(step/end + turn/end interrupted),没有 session/end-seed(文件里 session/end-seed 共 2 条,但未与续跑 seq 冲突)。之后续跑内容完整:turn 7 step 5 的 reasoning 续到约 seq 101197+,真实 已验证的修复:删除上述两行合成收尾( 对你根因假设的补充:你的跨写者竞争假设(repair 落盘合成事件与另一路基于旧基线 0..N-1 的 append 并发)与我们的时间戳证据相容——续跑 chunk 比合成收尾晚约 75ms 落盘,但 seq 从 100873 重新开始,说明该批事件的 seq 基线不含已落盘的合成事件。另请留意单进程路径的可能性:write-behind 批量缓冲( 另外建议一并实现读取侧容错(参考 #496 的 stale-duplicates 恢复思路):用户侧数据并未丢失,只是读取器拒绝加载;若读取侧能像 torn-tail 修复那样识别并丢弃"被续跑事件重叠的过期合成收尾",这类损坏会话可自动恢复。 |
|
corrupt session log: seq gap in committed region—— 会话历史加载失败:根因分析与可用的修复方案 (d老师自己处理的,自己写的)现象在 Web GUI 里打开某个会话时,历史加载失败: (中文界面显示「历史加载失败」。) 会话在磁盘上存在、会话列表里也能看到,但它的转录永远读不出来;其他会话不受影响。 环境: 我搜过仓库的 issues / discussions,没有人提过这个具体故障,所以发出来记录一下。 日志长什么样会话文件在
损坏文件解压后共 10,481 行(表头 + 10,480 行存储行,磁盘上共 178,009 个事件)。接缝处是这样的: 即:一段很长的构造种子(turn 1–67,seq 0..157922,以 读取器会拒绝这种日志: 修复方法(数据层,由我完成)因为续写部分整体偏移 −1、接缝之后内部自洽,最小且正确的修复就是把续写部分的每个 seq +1,保留全部事件和
本会话结果:178,010 个事件,seq 连续 0..178009;原文件与修复后逐行 diff:1,248 行只改了 seq 字段,0 处结构性差异 —— 除序号外,消息、时间戳、载荷逐字节保留。 端到端验证
所以修复是持久的:历史能加载、转录完整、会话可以继续正常追加。 疑似根因(请开发者确认)观察事实:持久化种子以 最可能的机制:本会话从构造种子创建/续写时,写入第一批(turn 68 的
我在 harness 外部观察不到写入器,确切的出错行需要维护者看代码;但数据强烈指向:续写写入器的基准 seq 应为 建议加固
通用修复脚本(repair-seq-gap.mjs)// repair-seq-gap.mjs —— 修复 session.jsonl.zstd 里的 "seq gap in committed region"
// 用法: node repair-seq-gap.mjs <损坏的.jsonl.zstd> <输出.jsonl.zstd>
// 只处理上面描述的「种子/续写接缝处重复 seq」变体(续写部分整体偏移 -1)。
import { readFile, writeFile } from "node:fs/promises";
import { zstdCompress, zstdDecompressSync, constants } from "node:zlib";
import { promisify } from "node:util";
const compress = promisify(zstdCompress);
const CHK = { params: { [constants.ZSTD_c_checksumFlag]: 1 } }; // 与后端一致的校验和标志
const MAGIC = 4247762216; // 0xFD2FB528
// 后端 scanZstdFrames 的复刻:不解压块,只扫描帧边界
function scanFrames(buf) {
const frames = []; let offset = 0;
while (offset < buf.length) {
const start = offset;
if (buf.length - offset < 4) return frames;
if (buf.readUInt32LE(offset) !== MAGIC) throw new Error("invalid frame magic");
offset += 4;
const d = buf.readUInt8(offset++);
if ((d & 24) !== 0) throw new Error("reserved frame-header bit");
const csf = d >>> 6, ss = (d & 32) !== 0, chk = (d & 4) !== 0, df = d & 3;
const db = df === 3 ? 4 : df, csb = csf === 0 ? (ss ? 1 : 0) : 1 << csf;
offset += (ss ? 0 : 1) + db + csb;
for (;;) {
const bh = buf.readUIntLE(offset, 3); offset += 3;
const last = (bh & 1) !== 0, bt = (bh >>> 1) & 3, bs = bh >>> 3;
if (bt === 3) throw new Error("reserved block type");
offset += bt === 1 ? 1 : bs;
if (last) break;
}
if (chk) offset += 4;
frames.push({ start, end: offset });
}
return frames;
}
const decode = (buf) => Buffer.concat(
scanFrames(buf).map((f) => zstdDecompressSync(buf.subarray(f.start, f.end)))
).toString("utf8");
// decodeStorageRecord 语义:打包行展开为多个事件
function expand(row) {
if (row && (row.type === "text-chunks" || row.type === "reasoning-chunks" || row.type === "tool-call-chunks")) {
const members = row.type === "tool-call-chunks" ? row.data.args : row.data.texts;
return members.map((_, k) => ({ seq: row.seq0 + k }));
}
return [row];
}
const [inPath, outPath] = process.argv.slice(2);
const lines = decode(await readFile(inPath)).split("\n");
if (lines.at(-1) === "") lines.pop();
// 找到第一个不连续行(接缝)
let expected = 0, seam = -1;
for (let i = 1; i < lines.length; i++) {
const evs = expand(JSON.parse(lines[i]));
let bad = false;
for (const e of evs) { if (e.seq !== expected) { bad = true; break; } expected++; }
if (bad) { seam = i; break; }
}
if (seam < 0) throw new Error("no seq gap found");
const threshold = expected - 1; // 接缝处被重复的 seq
const badSeq = expand(JSON.parse(lines[seam]))[0].seq;
if (badSeq !== threshold) {
throw new Error(`unsupported gap variant: expected ${expected}, got ${badSeq} (本脚本只处理重复型缺口)`);
}
// 重建:接缝起所有行 seq +1,并同步平移 seq 引用字段
const out = [lines[0]];
let totalEvents = 0;
for (let i = 1; i < lines.length; i++) {
const v = JSON.parse(lines[i]);
if (i >= seam) {
if (Number.isSafeInteger(v.seq0)) {
v.seq0 += 1; // 打包行
} else {
v.seq += 1;
if (Array.isArray(v.sourceEventSeqs)) v.sourceEventSeqs = v.sourceEventSeqs.map((s) => s >= threshold ? s + 1 : s);
if (v.surfaceOp && typeof v.surfaceOp === "object" && v.surfaceOp.op === "replace") {
if (v.surfaceOp.start >= threshold) v.surfaceOp.start += 1;
if (v.surfaceOp.end >= threshold) v.surfaceOp.end += 1;
}
}
}
out.push(JSON.stringify(v));
totalEvents += expand(v).length;
}
const body = out.join("\n") + "\n";
await writeFile(outPath, Buffer.concat([
await compress(Buffer.from(out[0] + "\n"), CHK), // 帧 1:仅表头行
await compress(Buffer.from(out.slice(1).join("\n") + "\n"), CHK), // 帧 2:全部事件
]));
console.log(`repaired: ${out.length - 1} rows, ${totalEvents} events, seqs 0..${totalEvents - 1}`);注意事项:
|
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
标题
回合中断后恢复续跑,会话日志出现 seq 重叠,历史加载报 corrupt session log
正文
问题描述
在 Web GUI 中,一个回合被中断后恢复续跑,会话日志里同一 seq 被写入两次,导致该会话的历史无法加载,报错:
复现步骤
step/end、turn/end(reason: interrupted)、session/end-seed。实际结果
corrupt session log: seq gap in committed region。step/end、turn/endinterrupted、session/end-seed)预期结果
环境
47f943859b(2026-08-13)补充诊断(供维护者参考)
scanLog/readZstdPrefix的 "seq gap in committed region" 校验。修复方案(供维护者参考)
根因定位(证据链)
step/end、turn/end(interrupted)及随后的session/end-seed;turn/end晚约 25ms,说明是中断之后写入的。session-persistence/src/coordinator.ts的prepareCore,约 902–909 行)会把合成收尾并入 seed(balanced = [...storedEvents, ...closers]),保证续写从 repair 之后连续;唯一能让追加与已落盘合成事件重叠的情形,是一个基线不包含合成事件的并发写者(另一进程实例 / 另一份 coordinator / 热重载 adoption 期间),在 repair 落盘后把旧基线的追加写到了文件末尾。appendLines直接把帧追加到文件末尾)。修复建议
appendBatch前读取文件当前最后一个事件 seq(或文件 revision),与state.cursor比较;不一致则拒绝或重载后重试(类似readStableFile的 revision 循环),从机制上杜绝重叠写入。prepareCore已如此,需排查是否存在绕过它的路径,如热重载 adoption、双开进程)。回归测试建议
prepare+commitRepair(合成事件落盘)→ 再次prepare→ 断言续写从 repair 后的 seq 开始。prepare后、append 前,由"另一个写者"推进文件(追加同 seq 段),断言 append 检测到不连续并失败/重试,而不是产生重叠。session-persistence/tests/coordinator-contract.ts、persistence.spec.ts(962–1072 行)、session-persistence-sqlite/tests/sqlite.spec.ts(258–347 行)。需要的话我可以提供修复补丁(基于上述建议 1 的最小实现 + 回归测试)。
All reactions