diff --git a/.gitignore b/.gitignore index 8630e5c..ce36e7c 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ docs/phase2-report.md docs/phase3-report.md docs/phase35-report.md docs/adapters/zcode-format.md +.release-state.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 880b04a..34331ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ 所有显著变更将记录在此文件中。 格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本遵循 [SemVer](https://semver.org/lang/zh-CN/)。 +## [0.4.5] - 2026-09-13 + +### 修复 +- **assistant 消息整批丢失(用户实报,另一 AI 交叉诊断确认)**:ZCode 先写 message 行、正文 part 流式后到;守护同步恰逢窗口期读到"无正文"行时跳过该行、游标却照常越过——正文落库后永久失扫。实测某会话 2167 条 assistant 全部丢失(user 完好:user 消息原子写入不踩竞态)。修复:宽限期(10 分钟)内游标停在空正文行之前,下轮重扫追回(seq_num 唯一键幂等);过宽限的空行不拖死游标 +- **历史缺口自愈**:schema v5 迁移自动清空 zcode 源游标,升级后首次 sync 全量重扫,丢失的 assistant 消息自动补回(已捕获的不重复);claude-code 源不受影响(JSONL 行内原子写入无此竞态) + +### 测试 +- 新增竞态回归 4 例(R1 跳过不越界/追回、R2 幂等、R3 宽限防停滞、R4 全周期)+ v5 迁移 1 例,破坏性验证通过(回退修复 R1 即红) + ## [0.4.4] - 2026-09-07 ### 修复 diff --git a/docs/test-forget.md b/docs/test-forget.md index c7e4580..4447880 100644 --- a/docs/test-forget.md +++ b/docs/test-forget.md @@ -180,3 +180,10 @@ C4/C5 的执行取决于开放点 1 的裁决(允许复活=按预期行为断 ### G2 不变量演进(related 落地时登记) G2 的“恒 15 工具”已升级为更强断言:16 工具精确名单 + 删除类名称黑名单扫描(delete/remove/forget/purge)。防删除泄漏的意图不变,计数随只读工具增长合法演进。 + +### 手动项执行结果(2026-09-07 回填,§J 步骤 2 清账) + +| 项 | 结果 | 明细 | +|---|---|---| +| E5 双 CLI 竞态 | ✅ 通过 | 真实双进程(dist CLI):A 预览→B 抢先 --yes 删除成功→A 持过期快照 --yes 得 not-found;终态 0 部分删除残留、forget_log 仅 1 条(B 的) | +| H2 墓碑压测 | ✅ 通过 | 5000 行墓碑,sync 周期均耗 2.00ms(含 loadTombstones 整表载入),远低于 50ms 基线——无 per-session 点查劣化 | diff --git a/package.json b/package.json index c44416f..d12ed3c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ewanjasper/sessionrelay", - "version": "0.4.4", + "version": "0.4.5", "description": "会话接力 SessionRelay — 属于项目、不属于任何厂商的本地记忆层(跨 Agent 会话记忆 / 中文检索 / MCP / HOP 交接协议)", "license": "MIT", "type": "module", @@ -10,6 +10,7 @@ "scripts": { "test": "vitest run", "e2e:pack": "node scripts/pack-e2e.mjs", + "release": "node scripts/release.mjs", "test:watch": "vitest", "typecheck": "tsc --noEmit", "s1": "vitest run test/unit/tokenize test/integration/search.spec.ts", diff --git a/scripts/release.mjs b/scripts/release.mjs new file mode 100644 index 0000000..8385219 --- /dev/null +++ b/scripts/release.mjs @@ -0,0 +1,173 @@ +// 一键发版链(发版漏环节三次事故的根治): +// bump → CHANGELOG 校验 → build+test → commit+tag → push → 等 CI 绿 → npm publish → GitHub release +// 每个检查点失败即停;已完成的步骤幂等可续跑。 +// 用法: +// node scripts/release.mjs patch # 0.4.4 → 0.4.5 +// node scripts/release.mjs minor # 0.4.4 → 0.5.0 +// node scripts/release.mjs <显式版本> # 0.5.0 +// node scripts/release.mjs resume # 从上次中断处续跑 +// 前置:CHANGELOG 顶部已写好【将要发布的版本】条目(Unreleased 或目标版本号均可,脚本自动改名)。 +import { execFileSync, execSync } from 'node:child_process'; +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import path from 'node:path'; + +const ROOT = path.resolve(import.meta.dirname, '..'); +const pkgPath = path.join(ROOT, 'package.json'); +const clPath = path.join(ROOT, 'CHANGELOG.md'); +const statePath = path.join(ROOT, '.release-state.json'); +const NPM = 'https://registry.npmjs.org/'; +const REPO = 'EwanJasper/SessionRelay'; + +const log = (s) => console.log(`[release] ${s}`); +const die = (s) => { console.error(`[release] ✗ ${s}`); process.exit(1); }; +const sh = (cmd, opts = {}) => execSync(cmd, { encoding: 'utf-8', cwd: ROOT, stdio: opts.quiet ? 'pipe' : 'inherit', ...opts }); +const today = () => new Date().toISOString().slice(0, 10); + +let state = existsSync(statePath) ? JSON.parse(readFileSync(statePath, 'utf-8')) : { done: [] }; +const mark = (step) => { state.done.push(step); writeFileSync(statePath, JSON.stringify(state, null, 2)); }; +const isDone = (step) => state.done.includes(step); +const savePkg = () => JSON.parse(readFileSync(pkgPath, 'utf-8')); + +// ── 0) 参数 ── +const arg = process.argv[2]; +if (!arg || arg === '--help') die('用法:node scripts/release.mjs '); +const pkg0 = savePkg(); + +// ── 1) 确定目标版本 ── +let target; +if (arg === 'resume') { + if (!state.target) die('resume 但无 .release-state.json——从头运行:node scripts/release.mjs '); + target = state.target; + log(`续跑:目标 v${target},已完成 ${state.done.join(', ') || '(无)'}`); +} else { + const [maj, min, pat] = pkg0.version.split('.').map(Number); + if (arg === 'patch') target = `${maj}.${min}.${pat + 1}`; + else if (arg === 'minor') target = `${maj}.${min + 1}.0`; + else if (arg === 'major') target = `${maj + 1}.0.0`; + else if (/^\d+\.\d+\.\d+$/.test(arg)) target = arg; + else die(`无法识别的参数:${arg}`); + state = { target, done: [] }; + writeFileSync(statePath, JSON.stringify(state, null, 2)); +} +if (target === pkg0.version && !isDone('bump')) die(`目标版本 ${target} 与 package.json 相同`); + +// ── 2) 预检:工作区干净(resume 除外)——必须在写 state 之前,否则 state 文件自己弄脏树 ── +if (!isDone('commit')) { + const dirty = sh('git status --porcelain', { quiet: true }).trim(); + if (dirty) die('工作区有未提交改动,先提交或 stash(发版应从干净树开始)'); +} + +// ── 1b) 确定目标版本后才落 state(clean 检查已过)── +if (arg !== 'resume' && !state.done?.length) { + writeFileSync(statePath, JSON.stringify(state, null, 2)); +} + +// ── 3) CHANGELOG:顶部条目必须是目标版本或 Unreleased(改名+补日期),否则拒绝 ── +const cl = readFileSync(clPath, 'utf-8'); +const topM = /^## \[([^\]]+)\](?: - (\d{4}-\d{2}-\d{2}))?/m.exec(cl); +if (!topM) die('CHANGELOG 找不到版本条目(## [x.y.z] 或 ## [Unreleased])'); +if (topM[1] !== target && topM[1] !== 'Unreleased') { + die(`CHANGELOG 顶部是 [${topM[1]}],既不是目标 ${target} 也不是 Unreleased——先写好发版条目再跑`); +} +if (!isDone('changelog')) { + if (topM[1] === 'Unreleased') { + // 标准写法 Unreleased 无日期——补日期 + 改名(带 MISS 检测,不再静默) + const patched = cl.replace('## [Unreleased]', `## [${target}] - ${today()}`); + if (patched === cl) die('Unreleased 条目替换失败(不应发生)'); + writeFileSync(clPath, patched); + log(`CHANGELOG:Unreleased → [${target}] - ${today()}`); + } else { + log(`CHANGELOG:顶部已是 [${target}] ✓`); + } + mark('changelog'); +} + +// ── 4) bump package.json ── +if (!isDone('bump')) { + const pkg = savePkg(); + pkg.version = target; + // 保持字段顺序:直接字符串替换 version 行最稳(避免重排整个文件) + const raw = readFileSync(pkgPath, 'utf-8').replace(new RegExp(`("version":\\s*)"${pkg0.version === pkg.version ? '[^"]+' : pkg0.version}"`), `$1"${target}"`); + writeFileSync(pkgPath, raw); + if (savePkg().version !== target) die('version 写入失败'); + log(`package.json → ${target}`); + mark('bump'); +} + +// ── 5) 产物断言 + 全量验证 ── +if (!isDone('verify')) { + log('check:artifacts + build + test ...'); + sh('npm run check:artifacts', { quiet: true }); + sh('npm run build', { quiet: true }); + sh('npm test', { quiet: true }); + log('验证全绿 ✓'); + mark('verify'); +} + +// ── 6) commit + tag + push ── +if (!isDone('push')) { + sh('git add -A'); + const bumpOnly = sh('git status --porcelain', { quiet: true }).trim(); + if (bumpOnly || !isDone('commit')) { + sh(`git -c user.email="107449146+EwanJasper@users.noreply.github.com" -c user.name="EwanJasper" commit -m "chore: release ${target}"`, { quiet: true }); + } + if (!isDone('commit')) mark('commit'); + const tag = `v${target}`; + try { sh(`git tag ${tag}`, { quiet: true }); } catch { log(`tag ${tag} 已存在,继续`); } + sh('git push'); + try { sh(`git push origin ${tag}`, { quiet: true }); } catch { log('tag 已在远端'); } + log('已推送,等待 CI ...'); + mark('push'); +} + +// ── 7) 等 CI 绿(硬门禁:0.4.0 事故的教训——CI 没绿绝不 publish)── +if (!isDone('ci')) { + const sha = sh('git rev-parse HEAD', { quiet: true }).trim(); + let ok = false; + for (let i = 0; i < 40; i++) { // 最多 20 分钟 + let out = ''; + try { out = sh(`gh run list --commit ${sha} --json status,conclusion --limit 1`, { quiet: true }); } catch { /* gh 偶发 */ } + const runs = JSON.parse(out || '[]'); + if (runs.length > 0 && runs[0].status === 'completed') { + if (runs[0].conclusion === 'success') { ok = true; break; } + die(`CI 结论 = ${runs[0].conclusion}(非 success)——修完再 resume`); + } + process.stdout.write('.'); + await new Promise((r) => setTimeout(r, 30_000)); + } + console.log(''); + if (!ok) die('CI 20 分钟未完成——稍后 node scripts/release.mjs resume'); + log('CI 绿 ✓'); + mark('ci'); +} + +// ── 8) npm publish ── +if (!isDone('publish')) { + sh(`npm publish --registry ${NPM}`, { quiet: false }); + log('npm publish ✓(传播延迟数分钟属正常)'); + mark('publish'); +} + +// ── 9) GitHub release ── +if (!isDone('release')) { + const clNow = readFileSync(clPath, 'utf-8'); + const entry = /^## \[([^\]]+)\] - \d{4}-\d{2}-\d{2}\n([\s\S]*?)(?=\n## |\n?$)/m.exec(clNow); + if (!entry || entry[1] !== target) die('CHANGELOG 顶部条目与目标版本不符,无法生成 release notes'); + const notes = `${entry[2].trim()}\n\n**Full Changelog**: https://github.com/${REPO}/compare/v${prevVersion(target)}...v${target}`; + writeFileSync(path.join(ROOT, '.release-notes.tmp'), notes); + sh(`gh release create v${target} --title "v${target}" --notes-file .release-notes.tmp`, { quiet: true }); + sh('node -e "require(\'fs\').unlinkSync(\'.release-notes.tmp\')"', { quiet: true }); + log(`GitHub release v${target} ✓`); + mark('release'); +} + +// ── 收尾 ── +sh('node -e "require(\'fs\').unlinkSync(\'.release-state.json\')"', { quiet: true }); +log(`🎉 v${target} 发布链完成(npm 传播 + GitHub release 页面可能有数分钟延迟)`); + +function prevVersion(t) { + const [maj, min, pat] = t.split('.').map(Number); + if (pat > 0) return `${maj}.${min}.${pat - 1}`; + if (min > 0) return `${maj}.${min - 1}.0`; + return `${maj - 1}.0.0`; +} diff --git a/src/adapters/zcode/index.ts b/src/adapters/zcode/index.ts index 2751212..83b7b61 100644 --- a/src/adapters/zcode/index.ts +++ b/src/adapters/zcode/index.ts @@ -72,7 +72,17 @@ export function readNew(ds: DiscoveredSession, dbPath: string, cursor: unknown): const messages: ReadResult['messages'] = []; let maxSeq = 0; + // 游标竞态防线(用户实测 assistant 消息整批丢失的根因): + // ZCode 先写 message 行、text part 流式后到。读到"无正文"的行时若游标照样 + // 越过,正文落库后永远不会再被扫描(rowid > cursor)。对策:宽限期内 + // (10 分钟)游标停在第一个空正文行之前,等下一轮重扫;过宽限的空行 + // (已删除/中断的消息)不再拖住游标。重扫靠 messages (session_id, seq_num) + // 唯一键幂等,不会重复插入。 + const EMPTY_GRACE_MS = 10 * 60_000; + let firstRecentEmpty: number | null = null; // 宽限期内空正文行的最小 rowid + let examined = cur.rowid ?? 0; for (const r of rows) { + examined = r.mrowid; let role: string; try { role = JSON.parse(r.data).role; @@ -84,7 +94,12 @@ export function readNew(ds: DiscoveredSession, dbPath: string, cursor: unknown): .map((p) => { try { return (JSON.parse(p.data).text ?? '') as string; } catch { return ''; } }) .join('\n') .trim(); - if (!content) continue; + if (!content) { + if (r.time_created >= Date.now() - EMPTY_GRACE_MS && firstRecentEmpty === null) { + firstRecentEmpty = r.mrowid; + } + continue; + } const seq = r.sequence ?? r.mrowid; messages.push({ role: role === 'user' ? 'user' : 'assistant', @@ -124,7 +139,10 @@ export function readNew(ds: DiscoveredSession, dbPath: string, cursor: unknown): } catch { /* 坏 compaction part 跳过 */ } } - const maxRowid = rows.length > 0 ? rows[rows.length - 1].mrowid : cur.rowid ?? 0; + // 游标结算:宽限期内有空正文行 → 停在它之前(下一轮从这行重扫); + // 否则推进到本次扫描末尾(现行为)。取 max 防回退。 + const safeRowid = firstRecentEmpty !== null ? firstRecentEmpty - 1 : examined; + const maxRowid = Math.max(cur.rowid ?? 0, rows.length > 0 ? safeRowid : cur.rowid ?? 0); const lastComp = compParts.length > 0 ? compParts[compParts.length - 1].time_created : (cursorObj.lastCompaction ?? 0); return { messages, badLines: 0, cursor: { rowid: maxRowid, lastCompaction: lastComp } }; } finally { diff --git a/src/store/db.ts b/src/store/db.ts index da90605..baf1703 100644 --- a/src/store/db.ts +++ b/src/store/db.ts @@ -9,7 +9,7 @@ export { dbFile } from '../shared/paths.js'; export type DB = Database.Database; -const SCHEMA_VERSION = 4; +const SCHEMA_VERSION = 5; const DDL = ` CREATE TABLE IF NOT EXISTS sessions ( @@ -211,6 +211,12 @@ export function createDb(file: string = ':memory:'): DB { db.exec('ALTER TABLE sessions ADD COLUMN original_message_count INTEGER DEFAULT 0'); } } + // v5 迁移(游标竞态修复的自愈):清空 zcode 源游标,下次 sync 全量重扫。 + // 0.4.4 及之前 assistant 消息可能因 part 流式延迟被跳过且游标越过——重扫按 + // messages (session_id, seq_num) 唯一键幂等,已捕获的不会重复,缺失的被补齐。 + if (v < 5) { + db.prepare("DELETE FROM source_files WHERE source = 'zcode'").run(); + } db.pragma(`user_version = ${SCHEMA_VERSION}`); return db; } @@ -235,6 +241,9 @@ export function openExisting(file: string): DB { db.exec('ALTER TABLE sessions ADD COLUMN original_message_count INTEGER DEFAULT 0'); } } + if (v < 5) { + db.prepare("DELETE FROM source_files WHERE source = 'zcode'").run(); + } db.pragma(`user_version = ${SCHEMA_VERSION}`); } return db; diff --git a/test/forget/all-reset.spec.ts b/test/forget/all-reset.spec.ts index b79fd83..712bed3 100644 --- a/test/forget/all-reset.spec.ts +++ b/test/forget/all-reset.spec.ts @@ -64,7 +64,7 @@ describe('forget · --all 整库重置', () => { expect(r.exitCode).toBeNull(); // 空库已重建(当前 schema 版本;v4 起含 session_vectors) const db = openExisting(dbFile(PROJECT)); - expect(db.pragma('user_version', { simple: true })).toBeGreaterThanOrEqual(4); + expect(db.pragma('user_version', { simple: true })).toBeGreaterThanOrEqual(5); expect(Object.keys(countsByState(db, PID)).length).toBe(0); db.close(); // ignore 保留(防复活关键:库没了规则还在) diff --git a/test/forget/functional.spec.ts b/test/forget/functional.spec.ts index e45c8c0..7db431f 100644 --- a/test/forget/functional.spec.ts +++ b/test/forget/functional.spec.ts @@ -335,7 +335,7 @@ describe('forget · F 边界', () => { // ══════════ G 组:兼容性回归 ══════════ describe('forget · G 兼容性', () => { - it('G1 v2 老库升级:user_version 2→当前(4)自动迁移,语义向量表一并就位', () => { + it('G1 v2 老库升级:user_version 2→当前(5)自动迁移,语义向量表一并就位', () => { const old = path.join(TMP, 'v2db', 'relay.sqlite'); fs.mkdirSync(path.dirname(old), { recursive: true }); const db = createDb(old); @@ -343,7 +343,7 @@ describe('forget · G 兼容性', () => { db.exec('DROP TABLE forget_tombstones; DROP TABLE forget_log; DROP TABLE forget_detail; DROP TABLE IF EXISTS session_vectors;'); db.close(); const db2 = openExisting(old); - expect(db2.pragma('user_version', { simple: true })).toBe(4); + expect(db2.pragma('user_version', { simple: true })).toBe(5); for (const t of ['forget_tombstones', 'forget_log', 'forget_detail', 'session_vectors']) { expect(db2.prepare(`SELECT COUNT(*) n FROM ${t}`).get()).toBeTruthy(); } @@ -354,7 +354,7 @@ describe('forget · G 兼容性', () => { const fut = path.join(TMP, 'v5db', 'relay.sqlite'); fs.mkdirSync(path.dirname(fut), { recursive: true }); const db = createDb(fut); - db.pragma('user_version = 5'); // 模拟未来版本创建的库 + db.pragma('user_version = 6'); // 模拟未来版本创建的库 db.close(); expect(() => openExisting(fut)).toThrow(/更新版本的 srelay/); }); diff --git a/test/integration/migration-v5.spec.ts b/test/integration/migration-v5.spec.ts new file mode 100644 index 0000000..bb156c4 --- /dev/null +++ b/test/integration/migration-v5.spec.ts @@ -0,0 +1,28 @@ +// v5 迁移:清 zcode 游标(游标竞态修复后的历史数据自愈) +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createDb, openExisting } from '../../src/store/db.js'; + +describe('schema v5 迁移 · zcode 游标重置', () => { + it('M1 v4 库打开时清空 zcode source_files 游标,其他源保留', () => { + const f = path.resolve('test/.tmp/mig-v5/relay.sqlite'); + fs.rmSync(path.dirname(f), { recursive: true, force: true }); + fs.mkdirSync(path.dirname(f), { recursive: true }); + // 造 v4 库:zcode + claude-code 两条游标 + const db = createDb(f); + db.pragma('user_version = 4'); // 模拟 0.4.4 库 + db.prepare("INSERT INTO source_files (source, file_path, cursor, last_seen) VALUES ('zcode', 'zcode:sess_x', '{\"rowid\":36822}', ?)") + .run(new Date().toISOString()); + db.prepare("INSERT INTO source_files (source, file_path, cursor, last_seen) VALUES ('claude-code', 'D:/x/a.jsonl', '{\"offset\":100}', ?)") + .run(new Date().toISOString()); + db.close(); + // 打开:迁移自动跑 + const db2 = openExisting(f); + expect(db2.pragma('user_version', { simple: true })).toBe(5); + expect((db2.prepare("SELECT COUNT(*) n FROM source_files WHERE source = 'zcode'").get() as { n: number }).n).toBe(0); + expect((db2.prepare("SELECT COUNT(*) n FROM source_files WHERE source = 'claude-code'").get() as { n: number }).n).toBe(1); + db2.close(); + fs.rmSync(path.dirname(f), { recursive: true, force: true }); + }); +}); diff --git a/test/integration/zcode-race.spec.ts b/test/integration/zcode-race.spec.ts new file mode 100644 index 0000000..00b18c5 --- /dev/null +++ b/test/integration/zcode-race.spec.ts @@ -0,0 +1,93 @@ +// zcode adapter 游标竞态回归(用户实报:assistant 消息整批丢失) +// 机制:ZCode 先写 message 行、text part 流式后到;旧实现读到空正文行时游标照样 +// 越过,正文落库后永久丢失。修复:宽限期(10min)内游标停在第一个空正文行之前。 +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { readNew, resetConn } from '../../src/adapters/zcode/index.js'; + +const TMP = path.resolve('test/.tmp/zcode-race'); +const DB = path.join(TMP, 'db.sqlite'); + +let z: Database.Database; +let seq = 0; + +beforeAll(() => { + fs.rmSync(TMP, { recursive: true, force: true }); + fs.mkdirSync(TMP, { recursive: true }); + z = new Database(DB); + z.exec(` + CREATE TABLE session (id text primary key, project_id text, directory text not null, title text not null, + time_created integer not null, time_updated integer not null); + CREATE TABLE message (id text primary key, session_id text not null, time_created integer not null, + data text not null, sequence integer); + CREATE TABLE part (id text primary key, message_id text not null, session_id text not null, + time_created integer, time_updated integer, data text not null, sequence integer); + `); +}); +afterAll(() => { resetConn(); z.close(); fs.rmSync(TMP, { recursive: true, force: true }); }); + +let sessionN = 0; +function freshSession(): { sid: string; ds: { source: string; sourceSessionId: string; sourceFile: string; sizeBytes: number; mtimeMs: number } } { + const sid = `sess_race_${++sessionN}`; + z.prepare('INSERT INTO session VALUES (?,?,?,?,?,?)').run(sid, 'proj', 'D:/x', '竞态会话', Date.now(), Date.now()); + return { sid, ds: { source: 'zcode', sourceSessionId: sid, sourceFile: `zcode:${sid}`, sizeBytes: 0, mtimeMs: Date.now() } }; +} + +function addMsg(sid: string, id: string, role: 'user' | 'assistant', text: string | null, at = Date.now()) { + z.prepare('INSERT INTO message VALUES (?,?,?,?,?)').run(id, sid, at, JSON.stringify({ role, time: { created: at } }), ++seq); + if (text !== null) { + z.prepare('INSERT INTO part VALUES (?,?,?,?,?,?,?)').run(`${id}_p0`, id, sid, at, at, JSON.stringify({ type: 'text', text }), 0); + } +} +const rowidOf = (id: string) => (z.prepare('SELECT rowid FROM message WHERE id = ?').get(id) as { rowid: number }).rowid; + +describe('zcode adapter · 游标竞态(streaming part 延迟落库)', () => { + it('R1 user 立即可读;assistant 正文未到 → 跳过但游标停在它之前;part 到达后下轮读到', () => { + const { sid, ds } = freshSession(); + const now = Date.now(); + addMsg(sid, 'u1', 'user', '用户提问', now - 5000); + addMsg(sid, 'a1', 'assistant', null, now - 3000); // assistant 行已写,正文 part 未到 + // 第一轮:只有 user;游标不得越过 a1 + const r1 = readNew(ds, DB, {}); + expect(r1.messages.map((m) => m.role)).toEqual(['user']); + expect(r1.cursor.rowid).toBeLessThan(rowidOf('a1')); // 修复前 = a1.rowid(越过即丢失) + // 第二轮:正文到达(模拟流式完成) + z.prepare('INSERT INTO part VALUES (?,?,?,?,?,?,?)').run('a1_p0', 'a1', sid, now, now, JSON.stringify({ type: 'text', text: 'assistant 的回答' }), 0); + const r2 = readNew(ds, DB, r1.cursor); + expect(r2.messages.map((m) => m.role)).toEqual(['assistant']); // 追回 + expect(r2.messages[0].content).toBe('assistant 的回答'); + }); + + it('R2 已插入消息重扫幂等:正常推进后不重出', () => { + const { sid, ds } = freshSession(); + const now = Date.now(); + addMsg(sid, 'u2', 'user', '第二轮提问', now - 1000); + const r1 = readNew(ds, DB, {}); + expect(r1.messages.length).toBe(1); + const r2 = readNew(ds, DB, r1.cursor); + expect(r2.messages.length).toBe(0); // 游标已到底,无重扫重出 + }); + + it('R3 过宽限(10min)的空行不再拖住游标(防永久停滞)', () => { + const { sid, ds } = freshSession(); + const now = Date.now(); + addMsg(sid, 'a3', 'assistant', null, now - 15 * 60_000); // 15 分钟前的空 assistant 行:等不到了 + addMsg(sid, 'u3', 'user', '第三轮', now - 1000); + const r = readNew(ds, DB, {}); + expect(r.cursor.rowid).toBeGreaterThanOrEqual(rowidOf('a3')); // 越过(放弃等待) + expect(r.messages.map((m) => m.role)).toEqual(['user']); + }); + + it('R4 竞态全周期:断档后的历史空行过宽限 → 新消息正常推进', () => { + const { sid, ds } = freshSession(); + const now = Date.now(); + // 模拟真实受害现场:一批历史空 assistant 行(已过宽限) + for (let i = 0; i < 3; i++) addMsg(sid, `old${i}`, 'assistant', null, now - 20 * 60_000); + addMsg(sid, 'u4', 'user', '新提问', now - 1000); + const r1 = readNew(ds, DB, {}); + expect(r1.messages.map((m) => m.role)).toEqual(['user']); // 历史空行不阻塞新消息 + expect(r1.cursor.rowid).toBeGreaterThanOrEqual(rowidOf('u4')); + }); +}); diff --git a/test/semantic/semantic.spec.ts b/test/semantic/semantic.spec.ts index a942847..1f2a692 100644 --- a/test/semantic/semantic.spec.ts +++ b/test/semantic/semantic.spec.ts @@ -189,7 +189,7 @@ describe('semantic · 兼容与降级', () => { db.exec('DROP TABLE session_vectors'); db.close(); const db2 = createDb(':memory:'); // openExisting 同源迁移逻辑 - expect(db2.pragma('user_version', { simple: true })).toBe(4); + expect(db2.pragma('user_version', { simple: true })).toBe(5); expect((db2.prepare('SELECT COUNT(*) n FROM session_vectors').get() as { n: number }).n).toBe(0); db2.close(); void db;