Browse Source

fix(v7): 收口 review 修复回归

lingfengQAQ 2 days ago
parent
commit
6b312a4e09

+ 2 - 0
.trellis/spec/backend/database-guidelines.md

@@ -46,6 +46,8 @@
 
 5.2 软失败不回滚:名册缺失或格式解析不动属"best-effort 跳过",记 warning、不阻断重建、不回滚已写的 chapters/threads/secrets;只有别名冲突等硬错才回滚。名册非必需(角色卡可独立入 `entities`)。
 
+5.3 缓存管理器禁止先删除旧 `index.db` 再重建。必须先在临时数据库中完成 DDL + 全量扫描,成功后再替换正式库;普通手动重建失败时保留上一份可用缓存,定稿后刷新失败时必须让旧缓存不可继续作为最新事实读取(删除/失效),避免 `next` 读旧章号重抄本章。
+
 ## 6. 待增量补充
 
 - [ ] 各表的列定义与索引(实现 O4 精准读取接口时定稿)

+ 2 - 0
.trellis/spec/backend/error-handling.md

@@ -37,6 +37,8 @@
 
 3.4 定稿回滚范围必须**收窄到本次写入文件集合**(`written`),逐文件 `restore`(已跟踪)+ `clean`(未跟踪新文件),禁止对整棵 `定稿/`+`大纲/` 子树 `restore`/`clean`,以免误伤同子树其他章的未登记手改。
 
+3.5 同章重写导致旧文件名变化时,旧章文件的删除/挪走也属于本次定稿变更,必须纳入同一回滚边界:commit 成功前保留可恢复备份,commit 失败时恢复旧文件并清除新文件,禁止留下"旧章消失、新章未提交"的中间态。
+
 ## 4. 面向作者的错误文案
 
 4.1 必须全中文、自然流畅;禁止出现堆栈、错误码、英文术语,也不强求口语化。

+ 1 - 1
.trellis/tasks/06-27-m0-repo-skeleton/task.json

@@ -21,6 +21,6 @@
   "children": [],
   "parent": null,
   "relatedFiles": [],
-  "notes": "",
+  "notes": "实现完成,Windows 原生 + Linux/WSL 双平台本地 node --test 各 4 绿;唯一挂起项:GitHub 真 CI(作者暂不 push)。commit c53bf63/dbcb322/779854c。",
   "meta": {}
 }

+ 1 - 1
.trellis/tasks/06-27-m4-ai-roles/implement.md

@@ -90,7 +90,7 @@ P5 AC 复核 + CI 双平台;真模型 smoke 推迟文档
 
 ## 后续(M4 完成后,单独任务)
 
-**M1-M4 全量 review**:通审四个里程碑代码 + 全部迁移产物("不带 v6 问题进 v7"复查);用户明确要求。  
+**M1-M4 全量 review**:通审四个里程碑代码 + 全部迁移产物("不带 v6 问题进 v7"复查);用户明确要求。
 下面这份是从两篇 review 合并出来的修复 backlog,按“先救主流程、再补稳健性、最后回填 spec”排。
 merged 收敛版之外的 deep 报告独有项(作者手改丢失、回滚范围、重建冲突、版本对齐)补在对应段末,标 `(deep)`,避免被合并版吞掉。
 

+ 15 - 15
.trellis/tasks/06-27-m4-ai-roles/review-m1-m4-merged.md

@@ -4,7 +4,7 @@
 
 ## 结论
 
-整体上,M1-M4 的架构方向是对的:DTO 隔离、确定性编排、宿主壳生成、知识迁移这几条主线都立住了。  
+整体上,M1-M4 的架构方向是对的:DTO 隔离、确定性编排、宿主壳生成、知识迁移这几条主线都立住了。
 但如果按“真实作者流程能不能稳定跑通”来审,当前仍有几处会直接卡住主循环,不能算完全收口。
 
 **结论分层:**
@@ -16,78 +16,78 @@
 
 ### B1. 定稿后缓存不重建,`next` 可能重抄旧章
 
-`finalize` 写入新正文后没有同步刷新 `.cache/index.db`,而 `next` 侧只会在缓存缺失/损坏/空表时重建。  
+`finalize` 写入新正文后没有同步刷新 `.cache/index.db`,而 `next` 侧只会在缓存缺失/损坏/空表时重建。
 这会导致磁盘已经写了新章,但状态机仍按旧 `MAX(chapter_num)` 继续跑,出现“已定稿第 3 章,却又起第 3 章细纲”的回环。
 
 这是主循环级问题,不是 smoke 推迟能覆盖掉的。
 
 ### B2. 建书流程没有把 git 仓库初始化责任落到生产代码
 
-当前 `persistCreateBook` 只写 `book.yaml` / `总纲.md` / `第01卷.md`,没有把 `git init`、`core.quotepath false`、`.gitignore` 这几件事真正落地。  
+当前 `persistCreateBook` 只写 `book.yaml` / `总纲.md` / `第01卷.md`,没有把 `git init`、`core.quotepath false`、`.gitignore` 这几件事真正落地。
 结果是新书仓库未必可直接定稿,缓存也可能被错误跟踪,后续写章和提交链路会断。
 
 ### B3. 多文件写入没有原子边界
 
-`persistCreateBook`、`persistVolumeReview`、`persistRepair`、`persistReviewReport` 都是顺序写多个文件。中途失败会留下半套产物。  
+`persistCreateBook`、`persistVolumeReview`、`persistRepair`、`persistReviewReport` 都是顺序写多个文件。中途失败会留下半套产物。
 这和“定稿必须原子”“多文件写入要么全成要么原样保留”的规范冲突,属于实打实的数据完整性风险。
 
 ## 高优先级问题
 
 ### H1. ReviewInput 组装不全,且角色命中只认正名
 
-两审输入里,设计要求的 `相关条目`、`名册待确认新专名` 还没有真正进入 DTO。  
+两审输入里,设计要求的 `相关条目`、`名册待确认新专名` 还没有真正进入 DTO。
 另外,相关角色靠 `草稿全文.includes(name)` 按文件名正名命中,别名不会被纳入。这样会让审查上下文缺料,尤其是作者正文里常用别名时。
 
 ### H2. Review schema 对坏输入不够安全
 
-`validateReviewReport` 默认假设 `issues` 里的每个元素都是对象。只要混进 `null`、字符串或别的坏值,就可能直接抛异常。  
+`validateReviewReport` 默认假设 `issues` 里的每个元素都是对象。只要混进 `null`、字符串或别的坏值,就可能直接抛异常。
 同时 `blocking` 现在是 `!!issue.blocking`,字符串 `"false"` 这类值会被当成真,语义上太松。
 
 ### H3. 审稿报告保存的是归一化结果,不是原始输出
 
-设计文档写的是保存 raw output,但当前 `评审报告/事实审查.json`、`编辑审.json` 实际写入的是校验器复算后的对象。  
+设计文档写的是保存 raw output,但当前 `评审报告/事实审查.json`、`编辑审.json` 实际写入的是校验器复算后的对象。
 这会让后续排查模型漂移、对比原始输出变难。
 
 ### H4. `books.jsonl` 部分损坏时没有触发真正的自愈
 
-现在只要 `books.jsonl` 还能读出部分有效行,就不会进入扫描重建。坏行会被吞掉,但不会回写修复。  
+现在只要 `books.jsonl` 还能读出部分有效行,就不会进入扫描重建。坏行会被吞掉,但不会回写修复。
 这会让书单长期处在“半坏不坏”的状态。
 
 ### H5. 绝对路径检测范围太窄
 
-host shell validator 里的绝对路径正则只覆盖少数 Windows/Linux 用户目录形态,像 `/tmp/...`、`/opt/...`、UNC 路径、`C:/...` 这类都可能漏掉。  
+host shell validator 里的绝对路径正则只覆盖少数 Windows/Linux 用户目录形态,像 `/tmp/...`、`/opt/...`、UNC 路径、`C:/...` 这类都可能漏掉。
 这会削弱“生成物不带本机绝对路径”的保证。
 
 ## Spec 问题
 
 ### S1. 数据表定义与实现不一致
 
-规范里列的是五张表,但实现里实际上还有 `entity_aliases`。  
+规范里列的是五张表,但实现里实际上还有 `entity_aliases`。
 这不是代码错,是 spec 漏列了,后续如果不回填,所有审查都会在这里卡一次。
 
 ### S2. “未知字段保留” 与 “禁止嵌套映射” 的边界没钉死
 
-规范一边说未知字段要保留原样写回,一边又禁止嵌套映射。  
+规范一边说未知字段要保留原样写回,一边又禁止嵌套映射。
 这两个约束现在没有明确边界,作者一旦加自定义嵌套字段,脚本会和规范互相打架。
 
 ### S3. `core.quotepath false` 的责任方没明确
 
-规范要求它必须设置,但没有说明是建书、安装器还是别的入口负责。  
+规范要求它必须设置,但没有说明是建书、安装器还是别的入口负责。
 没有责任方,就很容易像现在这样没人真正实现。
 
 ### S4. 多文件原子性边界未豁免或未收口
 
-错误处理规范要求多文件写入原子,但 review / persist 这条链路天然就是多文件。  
+错误处理规范要求多文件写入原子,但 review / persist 这条链路天然就是多文件。
 规范要么补豁免边界,要么补统一的原子写入模式,否则实现永远会显得“不合规”。
 
 ### S5. 一些“待增量补充”长期未闭环
 
-数据库列定义、错误退出码、AI 调用预算、日志/CLI 约定等都还停在“待增量补充”。  
+数据库列定义、错误退出码、AI 调用预算、日志/CLI 约定等都还停在“待增量补充”。
 M1-M4 已经推进到能跑主流程的阶段,这些空白该回填,不然后续任务的依据会一直悬着。
 
 ## 需要保留的判断
 
-第一份 review 提到的“宿主 CLI 缝”和真模型 smoke 推迟,我保留,但它更像后续接线问题,不是当前最核心的阻断项。  
+第一份 review 提到的“宿主 CLI 缝”和真模型 smoke 推迟,我保留,但它更像后续接线问题,不是当前最核心的阻断项。
 第二份 review 抓到的主循环缺口更重,所以最终版应以前者为 follow-up、以后者为 blocker。
 
 ## 最终排序

+ 86 - 33
v7/src/cache/index.js

@@ -4,6 +4,8 @@ import path from 'node:path'
 import { SCHEMA_SQL } from './schema.js'
 import { rebuildCache } from './rebuilder.js'
 
+let rebuildCounter = 0
+
 /**
  * CacheManager:管理 .cache/index.db 五表。
  */
@@ -19,67 +21,118 @@ export class CacheManager {
    * @returns {Promise<void>}
    */
   async ensureReady(repoPath) {
-    // 检查 db 文件是否存在
+    let needsRebuild = false
+
     try {
       await fs.access(this.dbPath)
     } catch (err) {
-      // 不存在,先创建目录
       const dir = path.dirname(this.dbPath)
       await fs.mkdir(dir, { recursive: true })
-      // 重建
-      return this.rebuildFromSource(repoPath)
+      needsRebuild = true
     }
 
-    // 打开现有数据库
-    try {
-      this.db = new DatabaseSync(this.dbPath)
-      // 验证表是否存在
-      const tables = this.db
-        .prepare("SELECT name FROM sqlite_master WHERE type='table'")
-        .all()
-      if (tables.length === 0) {
-        // 空数据库,重建
-        this.db.close()
-        return this.rebuildFromSource(repoPath)
+    if (!needsRebuild) {
+      try {
+        this.db = new DatabaseSync(this.dbPath)
+        const tables = this.db
+          .prepare("SELECT name FROM sqlite_master WHERE type='table'")
+          .all()
+        if (tables.length === 0) {
+          this.db.close()
+          this.db = null
+          needsRebuild = true
+        }
+      } catch (err) {
+        if (this.db) {
+          try {
+            this.db.close()
+          } catch {
+            // 忽略关闭失败
+          }
+        }
+        this.db = null
+        needsRebuild = true
       }
-    } catch (err) {
-      // 损坏,重建
-      return this.rebuildFromSource(repoPath)
     }
+
+    if (needsRebuild) {
+      const result = await this.rebuildFromSource(repoPath)
+      if (!result.ok) throw new Error(`缓存重建失败:${result.errors.join(';')}`)
+      return result
+    }
+
+    return { ok: true, warnings: [], errors: [] }
   }
 
   /**
    * 全量重建缓存。
    * @param {string} repoPath
+   * @param {{keepExistingOnFailure?: boolean}} [opts]
    * @returns {Promise<{ok: boolean, warnings: string[], errors: string[]}>}
    */
-  async rebuildFromSource(repoPath) {
+  async rebuildFromSource(repoPath, opts = {}) {
+    const { keepExistingOnFailure = true } = opts
+    let hadExisting = false
+    try {
+      await fs.access(this.dbPath)
+      hadExisting = true
+    } catch {
+      hadExisting = false
+    }
+
     // 关闭现有连接
     if (this.db) {
       this.db.close()
       this.db = null
     }
 
-    // 删除旧数据库
-    try {
-      await fs.unlink(this.dbPath)
-    } catch (err) {
-      // 文件不存在,忽略
-    }
-
     // 确保 .cache 目录存在:rebuildFromSource 可被直接调用,不保证先过 ensureReady
     await fs.mkdir(path.dirname(this.dbPath), { recursive: true })
 
-    // 创建新数据库
-    this.db = new DatabaseSync(this.dbPath)
+    const tmpPath = `${this.dbPath}.rebuild.${process.pid}.${rebuildCounter++}`
+    let tmpDb = null
+    try {
+      tmpDb = new DatabaseSync(tmpPath)
+      tmpDb.exec(SCHEMA_SQL)
+      const result = await rebuildCache(repoPath, tmpDb)
+      tmpDb.close()
+      tmpDb = null
 
-    // 执行 DDL
-    this.db.exec(SCHEMA_SQL)
+      if (!result.ok) {
+        await fs.rm(tmpPath, { force: true })
+        await this._handleFailedRebuild(hadExisting, keepExistingOnFailure)
+        return result
+      }
 
-    // 调用重建器
-    const result = await rebuildCache(repoPath, this.db)
+      await fs.rm(this.dbPath, { force: true })
+      await fs.rename(tmpPath, this.dbPath)
+      this.db = new DatabaseSync(this.dbPath)
+      return result
+    } catch (err) {
+      if (tmpDb) {
+        try {
+          tmpDb.close()
+        } catch {
+          // 忽略关闭失败
+        }
+      }
+      await fs.rm(tmpPath, { force: true })
+      await this._handleFailedRebuild(hadExisting, keepExistingOnFailure)
+      return { ok: false, warnings: [], errors: [`重建失败:${err.message}`] }
+    }
+  }
 
-    return result
+  async _handleFailedRebuild(hadExisting, keepExistingOnFailure) {
+    if (!hadExisting) return
+    if (keepExistingOnFailure) {
+      this.db = new DatabaseSync(this.dbPath)
+      return
+    }
+    try {
+      await fs.rm(this.dbPath, { force: true })
+    } catch {
+      // 删除失败时保持关闭状态,后续 git 健康/缓存重建路径会兜底
+    }
   }
 
   /**

+ 9 - 0
v7/src/finalize/git.js

@@ -45,6 +45,15 @@ export function createGit(repoPath) {
         // 文件锁等致 clean 失败:尽力而为,不破坏 {ok,error} 契约(M3 git 健康检查兜底)
       }
     },
+    /** 工作树内容是否真的有 diff(不含纯 stat/换行刷新噪声) */
+    async hasDiff(paths) {
+      try {
+        await run(['diff', '--quiet', '--', ...paths])
+        return false
+      } catch {
+        return true
+      }
+    },
     async revCount() {
       try {
         const { stdout } = await run(['rev-list', '--count', 'HEAD'])

+ 54 - 21
v7/src/finalize/index.js

@@ -39,17 +39,25 @@ export async function finalizeChapter(ctx, payload, opts = {}) {
   if (!Number.isInteger(chapterNum)) return { ok: false, error: '章号必须是整数' }
   if (!frontMatter || !frontMatter.标题) return { ok: false, error: '缺少章档案或标题' }
 
-  const written = []
+  const stageFiles = []
+  const rollbackFiles = []
+  const chapterBackups = []
+  let cacheRefresh = null
   try {
     // 2. 写工作树(全部落 定稿/大纲,非 工作区)
-    const cw = await new ChapterWriter(repoPath).writeChapter(chapterNum, frontMatter, body)
+    const cw = await new ChapterWriter(repoPath).writeChapter(chapterNum, frontMatter, body, {
+      preserveBackups: true,
+    })
     if (!cw.ok) throw new Error(cw.error)
-    written.push(cw.filePath)
+    stageFiles.push(cw.filePath, ...(cw.removedPaths || []))
+    rollbackFiles.push(cw.filePath)
+    chapterBackups.push(...(cw.backups || []))
 
     if (summary != null) {
       const sw = await new SummaryWriter(repoPath).writeChapterSummary(chapterNum, summary)
       if (!sw.ok) throw new Error(sw.error)
-      written.push(sw.filePath)
+      stageFiles.push(sw.filePath)
+      rollbackFiles.push(sw.filePath)
     }
 
     const tlw = new ThreadLedgerWriter(repoPath)
@@ -63,50 +71,67 @@ export async function finalizeChapter(ctx, payload, opts = {}) {
         if (!r.ok) throw new Error(r.error)
       }
       const f = await tlw._findThreadFile(t.id)
-      if (f) written.push(f)
+      if (f) {
+        stageFiles.push(f)
+        rollbackFiles.push(f)
+      }
     }
 
     const ew = new EntityWriter(repoPath)
     for (const c of characterUpdates) {
       const r = await ew.updateCharacter(c.name, c.updates)
       if (!r.ok) throw new Error(r.error)
-      written.push(path.join(repoPath, '定稿', '设定', '角色', `${c.name}.md`))
+      const filePath = path.join(repoPath, '定稿', '设定', '角色', `${c.name}.md`)
+      stageFiles.push(filePath)
+      rollbackFiles.push(filePath)
     }
     for (const row of rosterUpserts) {
       const r = await ew.upsertRosterRow(row)
       if (!r.ok) throw new Error(r.error)
-      written.push(path.join(repoPath, '定稿', '设定', '名册.md'))
+      const filePath = path.join(repoPath, '定稿', '设定', '名册.md')
+      stageFiles.push(filePath)
+      rollbackFiles.push(filePath)
     }
 
     const tw = new TimelineWriter(repoPath)
     for (const tr of timelineRows) {
       const r = await tw.appendRow(tr.volumeNum, tr.row)
       if (!r.ok) throw new Error(r.error)
-      written.push(r.filePath)
+      stageFiles.push(r.filePath)
+      rollbackFiles.push(r.filePath)
     }
 
     const secw = new SecretWriter(repoPath)
     for (const s of secretWrites) {
       const r = await secw.write(s.id, s.frontMatter, s.content)
       if (!r.ok) throw new Error(r.error)
-      written.push(r.filePath)
+      stageFiles.push(r.filePath)
+      rollbackFiles.push(r.filePath)
     }
 
     // 故障注入点(断电模拟,仅测试用)
     if (opts.faultAfterWrite) throw new Error('注入故障:写工作树后、commit 前中断')
 
     // 3. git add + commit(原子点)
-    const relFiles = [...new Set(written)].map((f) => path.relative(repoPath, f))
+    const relFiles = [...new Set(stageFiles)].map((f) => path.relative(repoPath, f))
     await git.add(relFiles)
     const commitHash = await git.commit(buildCommitMessage(chapterNum, frontMatter.标题, commitLines))
 
+    for (const b of chapterBackups) {
+      try {
+        await fs.rm(b.backup, { force: true })
+      } catch {
+        // 备份残留不影响已完成 commit;文件名不以 .md 结尾,不进缓存扫描
+      }
+    }
+
     // P0-1:定稿后同步刷新缓存,避免 next 读旧章号重抄本章。
-    // 重建失败不阻断定稿(已 commit 入档);next 入口 ensureReady 会在 db 损坏时兜底重建。
+    // 重建失败不阻断定稿(已 commit 入档),但不能继续保留旧缓存,否则 next 会读旧章号
     if (ctx.cache) {
       try {
-        await ctx.cache.rebuildFromSource(repoPath)
-      } catch {
-        // 缓存重建尽力而为
+        cacheRefresh = await ctx.cache.rebuildFromSource(repoPath, { keepExistingOnFailure: false })
+      } catch (err) {
+        cacheRefresh = { ok: false, warnings: [], errors: [`缓存刷新失败:${err.message}`] }
       }
     }
 
@@ -115,17 +140,25 @@ export async function finalizeChapter(ctx, payload, opts = {}) {
       await fs.rm(path.join(repoPath, '工作区', wf), { force: true })
     }
 
-    return { ok: true, commitHash, error: '' }
+    return { ok: true, commitHash, cacheRefresh, error: '' }
   } catch (err) {
-    // commit 前中断:回滚本次 written 集合(非整棵 定稿/大纲 子树,避免误伤同子树其他章手改)。
-    // written 在 try 外声明,catch 可见。逐文件 restore:新章文件未跟踪会让整条 restore
-    // 报错被吞,逐个跑才能精确复原已跟踪文件;clean 删本次新建的未跟踪文件。
-    const relFiles = [...new Set(written)].map((f) => path.relative(repoPath, f))
+    // commit 前中断:回滚本次 stage/rollback 集合(非整棵 定稿/大纲 子树,避免误伤同子树其他章手改)。
+    // 逐文件 restore:新章文件未跟踪会让整条 restore 报错被吞,逐个跑才能精确复原已跟踪文件。
+    const relStageFiles = [...new Set(stageFiles)].map((f) => path.relative(repoPath, f))
+    const relRollbackFiles = [...new Set(rollbackFiles)].map((f) => path.relative(repoPath, f))
     try {
-      for (const rel of relFiles) {
+      for (const rel of relStageFiles) {
         await git.restore([rel])
       }
-      await git.clean(relFiles)
+      await git.clean(relRollbackFiles)
+      for (const b of chapterBackups.toReversed()) {
+        await fs.rm(b.original, { force: true })
+        await fs.rename(b.backup, b.original)
+      }
+      for (const b of chapterBackups) {
+        const rel = path.relative(repoPath, b.original)
+        if (!(await git.hasDiff([rel]))) await git.restore([rel])
+      }
     } catch {
       // 回滚尽力而为;M3 git 健康检查兜底
     }

+ 13 - 2
v7/src/review/index.js

@@ -200,8 +200,19 @@ export async function runReviews(ctx, { chapterNum, draftPath, mode = 'complete'
   const inp = await assembleReviewInput(ctx, { chapterNum, draftPath })
   if (!inp.ok) return { ok: false, errors: [inp.error] }
 
-  const rawFact = await reviewers.factCheck(inp.input)
-  const rawEdit = await reviewers.editorial(inp.input)
+  let rawFact
+  let rawEdit
+  if (mode === 'degraded') {
+    if (typeof reviewers.degraded !== 'function') {
+      return { ok: false, errors: ['兼容模式需要注入 degraded reviewer(单次 AI 调用)'] }
+    }
+    const raw = await reviewers.degraded(inp.input)
+    rawFact = raw.factCheck ?? raw.事实审查 ?? { issues: [] }
+    rawEdit = raw.editorial ?? raw.编辑审 ?? { issues: [] }
+  } else {
+    rawFact = await reviewers.factCheck(inp.input)
+    rawEdit = await reviewers.editorial(inp.input)
+  }
 
   const vFact = validateReviewReport(rawFact, { reviewType: 'factCheck' })
   const vEdit = validateReviewReport(rawEdit, { reviewType: 'editorial' })

+ 40 - 6
v7/src/storage/adapters/ChapterWriter.js

@@ -6,14 +6,16 @@ import { serializeFrontMatter } from '../serializers/front-matter.js'
  * ChapterWriter:写新章到定稿(M2 定稿流程调用)。
  */
 
+let backupCounter = 0
+
 /** 文件名净化:Windows 非法字符 <>:"/\|?* 与控制字符替成 _(标题本体不改,只净化文件名)。 */
 function sanitizeFileName(title) {
   const s = String(title).replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').replace(/\s+/g, ' ').trim()
   return s || '未命名'
 }
 
-/** 删除同章旧文件(标题可能不同),避免 scanChapters 撞 PRIMARY KEY(P0-3a)。 */
-async function removeOldChapterFiles(dir, chapterNum, safeTitle) {
+/** 临时挪走同章旧文件(标题可能不同),避免 scanChapters 撞 PRIMARY KEY(P0-3a)。 */
+async function backupOldChapterFiles(dir, chapterNum, safeTitle) {
   const prefix = `${String(chapterNum).padStart(4, '0')}-`
   const target = `${prefix}${safeTitle}.md`
   let files = []
@@ -22,10 +24,32 @@ async function removeOldChapterFiles(dir, chapterNum, safeTitle) {
   } catch {
     return
   }
+  const backups = []
   for (const f of files) {
     if (!f.startsWith(prefix) || !f.endsWith('.md')) continue
     if (f === target) continue
-    await fs.rm(path.join(dir, f), { force: true })
+    const original = path.join(dir, f)
+    const backup = path.join(dir, `${f}.wnwbackup.${process.pid}.${backupCounter++}`)
+    await fs.rename(original, backup)
+    backups.push({ original, backup })
+  }
+  return backups
+}
+
+async function restoreBackups(backups) {
+  for (const b of backups.toReversed()) {
+    try {
+      await fs.rm(b.original, { force: true })
+      await fs.rename(b.backup, b.original)
+    } catch {
+      // 尽力恢复;调用方会拿到原始错误
+    }
+  }
+}
+
+async function discardBackups(backups) {
+  for (const b of backups) {
+    await fs.rm(b.backup, { force: true })
   }
 }
 
@@ -40,20 +64,30 @@ export class ChapterWriter {
    * @param {number} chapterNum
    * @param {object} frontMatter - 章档案(章号/标题/卷/视角/字数/章定位/钩子/情绪定位/伏笔[]/...)
    * @param {string} body - 正文(不含 front matter)
+   * @param {{preserveBackups?: boolean}} [opts] finalize 需要保留备份到 commit 成功后,供失败回滚
    * @returns {Promise<{ok: boolean, filePath: string, error: string}>}
    */
-  async writeChapter(chapterNum, frontMatter, body) {
+  async writeChapter(chapterNum, frontMatter, body, opts = {}) {
+    const backups = []
     try {
       const title = frontMatter.标题 || '未命名'
       const safeTitle = sanitizeFileName(title)
       const dir = path.join(this.repoPath, '定稿', '正文')
       await fs.mkdir(dir, { recursive: true })
-      await removeOldChapterFiles(dir, chapterNum, safeTitle)
+      backups.push(...(await backupOldChapterFiles(dir, chapterNum, safeTitle)))
       const fileName = `${String(chapterNum).padStart(4, '0')}-${safeTitle}.md`
       const filePath = path.join(dir, fileName)
       await fs.writeFile(filePath, serializeFrontMatter(frontMatter, body), 'utf8')
-      return { ok: true, filePath, error: '' }
+      if (!opts.preserveBackups) await discardBackups(backups)
+      return {
+        ok: true,
+        filePath,
+        removedPaths: backups.map((b) => b.original),
+        backups,
+        error: '',
+      }
     } catch (err) {
+      await restoreBackups(backups)
       return { ok: false, filePath: '', error: `写章节 ${chapterNum} 失败:${err.message}` }
     }
   }

+ 47 - 10
v7/src/storage/atomic.js

@@ -4,30 +4,67 @@ import path from 'node:path'
 let counter = 0
 
 /**
- * 原子批量写:全部先落 .tmp 再 rename,任一失败回滚(删 tmp)
+ * 原子批量写:全部先落 .tmp,再备份旧文件并 rename,任一失败回滚
  * 同目录 rename 原子(同卷),保证多文件"要么全成要么原样"(spec error-handling §3.1)。
  * @param {string} repoPath
  * @param {Array<{path: string, content: string}>} files 相对路径
  * @returns {Promise<string[]>} 已写的相对路径
  */
 export async function writeAtomicBatch(repoPath, files) {
+  const seen = new Set()
   const plans = []
-  for (const f of files) {
-    const full = path.join(repoPath, f.path)
-    await fs.mkdir(path.dirname(full), { recursive: true })
-    const tmp = `${full}.wnwtmp.${process.pid}.${counter++}`
-    await fs.writeFile(tmp, f.content, 'utf8')
-    plans.push({ tmp, final: full, rel: f.path })
-  }
   try {
+    for (const f of files) {
+      if (seen.has(f.path)) throw new Error(`批量写入包含重复路径:${f.path}`)
+      seen.add(f.path)
+      const full = path.join(repoPath, f.path)
+      await fs.mkdir(path.dirname(full), { recursive: true })
+      const n = counter++
+      const tmp = `${full}.wnwtmp.${process.pid}.${n}`
+      const backup = `${full}.wnwbackup.${process.pid}.${n}`
+      const plan = { tmp, final: full, backup, existed: false, rel: f.path }
+      plans.push(plan)
+      await fs.writeFile(tmp, f.content, 'utf8')
+
+      try {
+        const stat = await fs.stat(full)
+        if (stat.isDirectory()) throw new Error(`目标路径是目录,不能写入文件:${f.path}`)
+        await fs.rename(full, backup)
+        plan.existed = true
+      } catch (err) {
+        if (err.code !== 'ENOENT') throw err
+      }
+    }
+
     for (const p of plans) {
       await fs.rename(p.tmp, p.final)
     }
-  } catch (err) {
     for (const p of plans) {
-      await fs.rm(p.tmp, { force: true })
+      if (p.existed) await fs.rm(p.backup, { force: true })
+    }
+  } catch (err) {
+    for (const p of plans.toReversed()) {
+      await restorePlan(p)
     }
     throw err
   }
   return plans.map((p) => p.rel)
 }
+
+async function restorePlan(plan) {
+  try {
+    await fs.rm(plan.tmp, { force: true })
+  } catch {
+    // 尽力回滚
+  }
+  try {
+    if (plan.existed) {
+      await fs.rm(plan.final, { force: true })
+      await fs.rename(plan.backup, plan.final)
+    } else {
+      await fs.rm(plan.final, { force: true })
+    }
+  } catch {
+    // 尽力回滚;调用方会收到原始错误
+  }
+}

+ 57 - 6
v7/test/cache/rebuilder.test.js

@@ -2,7 +2,7 @@ import { test } from 'node:test'
 import assert from 'node:assert/strict'
 import path from 'node:path'
 import os from 'node:os'
-import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
+import { access, mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
 import { CacheManager } from '../../src/cache/index.js'
 
 // 在临时目录构造一个最小书仓库,files 是 { 相对路径: 内容 }
@@ -116,11 +116,62 @@ test('别名冲突 → ROLLBACK 不留半库(P1-1:已写 chapters 也回滚
   try {
     const result = await cache.rebuildFromSource(root)
     assert.equal(result.ok, false, '别名冲突应拒绝重建')
-    // 事务回滚:chapters 不应残留半填数据
-    const ch = await cache.query('SELECT COUNT(*) AS c FROM chapters')
-    assert.equal(ch[0].c, 0, '别名冲突应 ROLLBACK,chapters 不留半填数据')
-    const th = await cache.query('SELECT COUNT(*) AS c FROM threads')
-    assert.equal(th[0].c, 0, 'threads 同样回滚')
+    // 事务回滚且失败临时库不应被提升为可用缓存
+    await assert.rejects(() => cache.query('SELECT COUNT(*) AS c FROM chapters'))
+  } finally {
+    await cache.close()
+    await rm(root, { recursive: true, force: true })
+  }
+})
+
+test('重建失败保留上一份可用缓存,不替换成空库', async () => {
+  const root = await makeRepo({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测试\n',
+    '定稿/正文/0001-开局.md': '---\n章号: 1\n标题: 开局\n卷: 1\n字数: 100\n章定位: 推进\n---\n正文。',
+    '定稿/设定/名册.md': 名册仅林晚,
+  })
+  const cache = new CacheManager(path.join(root, '.cache', 'index.db'))
+  try {
+    await cache.ensureReady(root)
+    const before = await cache.query('SELECT COUNT(*) AS c FROM chapters')
+    assert.equal(before[0].c, 1)
+
+    await writeFile(
+      path.join(root, '定稿/设定/名册.md'),
+      '| 正名 | 别名 | 类型 | 首现章 |\n|------|------|------|--------|\n' +
+        '| 林晚 | 影 | character | 1 |\n| 老者 | 影 | character | 1 |\n',
+      'utf8'
+    )
+    const result = await cache.rebuildFromSource(root)
+    assert.equal(result.ok, false, '坏源文件应让本次重建失败')
+    const after = await cache.query('SELECT COUNT(*) AS c FROM chapters')
+    assert.equal(after[0].c, 1, '失败重建不应把旧缓存替换成空库')
+  } finally {
+    await cache.close()
+    await rm(root, { recursive: true, force: true })
+  }
+})
+
+test('重建失败可选择移除旧缓存,避免继续读取陈旧数据', async () => {
+  const root = await makeRepo({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测试\n',
+    '定稿/正文/0001-开局.md': '---\n章号: 1\n标题: 开局\n卷: 1\n字数: 100\n章定位: 推进\n---\n正文。',
+    '定稿/设定/名册.md': 名册仅林晚,
+  })
+  const dbPath = path.join(root, '.cache', 'index.db')
+  const cache = new CacheManager(dbPath)
+  try {
+    await cache.ensureReady(root)
+    await writeFile(
+      path.join(root, '定稿/设定/名册.md'),
+      '| 正名 | 别名 | 类型 | 首现章 |\n|------|------|------|--------|\n' +
+        '| 林晚 | 影 | character | 1 |\n| 老者 | 影 | character | 1 |\n',
+      'utf8'
+    )
+    const result = await cache.rebuildFromSource(root, { keepExistingOnFailure: false })
+    assert.equal(result.ok, false)
+    await assert.rejects(() => cache.query('SELECT COUNT(*) AS c FROM chapters'))
+    await assert.rejects(() => access(dbPath))
   } finally {
     await cache.close()
     await rm(root, { recursive: true, force: true })

+ 35 - 0
v7/test/finalize/finalize.test.js

@@ -120,3 +120,38 @@ test('finalizeChapter 断电回滚(P1-7):不误伤同子树其他章的未
     await cleanup()
   }
 })
+
+test('finalizeChapter 改同章标题后断电:旧章恢复,新章不残留', async () => {
+  const { ctx, cleanup } = await gitBookCtx()
+  try {
+    const oldPath = path.join(ctx.repoPath, '定稿/正文/0001-开局.md')
+    const oldContent = await fs.readFile(oldPath, 'utf8')
+    const r = await finalizeChapter(ctx, {
+      chapterNum: 1,
+      frontMatter: {
+        章号: 1,
+        标题: '改名',
+        卷: 1,
+        字数: 100,
+        章定位: '推进',
+      },
+      body: '新正文',
+    }, { faultAfterWrite: true })
+    assert.equal(r.ok, false)
+
+    assert.equal(
+      (await fs.readFile(oldPath, 'utf8')).replace(/\r\n/g, '\n'),
+      oldContent.replace(/\r\n/g, '\n'),
+      '旧标题章文件必须恢复同一内容'
+    )
+    await assert.rejects(() => fs.access(path.join(ctx.repoPath, '定稿/正文/0001-改名.md')))
+    const { stdout } = await execFileAsync(
+      'git',
+      ['status', '--porcelain', '--', '定稿/正文'],
+      { cwd: ctx.repoPath, encoding: 'utf8' }
+    )
+    assert.equal(stdout.trim(), '')
+  } finally {
+    await cleanup()
+  }
+})

+ 14 - 1
v7/test/review/orchestration.test.js

@@ -84,9 +84,22 @@ test('runReviews:DI 注入两审 → 校验+合并+落盘审稿单与评审报
 test('runReviews:降级模式 → 审稿单含兼容声明', async () => {
   const { ctx, cleanup, root } = await makeReviewBook()
   try {
-    const reviewers = { factCheck: async () => ({ issues: [] }), editorial: async () => ({ issues: [] }) }
+    let calls = 0
+    const reviewers = {
+      degraded: async () => {
+        calls++
+        return { factCheck: { issues: [] }, editorial: { issues: [] } }
+      },
+      factCheck: async () => {
+        throw new Error('降级模式不应单独调用事实审查')
+      },
+      editorial: async () => {
+        throw new Error('降级模式不应单独调用编辑审')
+      },
+    }
     const r = await runReviews(ctx, { chapterNum: 2, draftPath: '工作区/草稿.md', mode: 'degraded', reviewers })
     assert.equal(r.ok, true)
+    assert.equal(calls, 1, '降级模式预算应为单次 AI 调用')
     const 审稿 = await fs.readFile(path.join(root, '工作区', '审稿.md'), 'utf8')
     assert.match(审稿, /兼容模式/)
   } finally { await cleanup() }

+ 27 - 0
v7/test/storage/atomic.test.js

@@ -0,0 +1,27 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import os from 'node:os'
+import path from 'node:path'
+import { promises as fs } from 'node:fs'
+import { writeAtomicBatch } from '../../src/storage/atomic.js'
+
+test('writeAtomicBatch:后续文件失败时,已替换文件恢复原样', async () => {
+  const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-atomic-'))
+  try {
+    await fs.writeFile(path.join(root, 'a.txt'), 'old', 'utf8')
+    await fs.mkdir(path.join(root, 'b.txt'))
+
+    await assert.rejects(() =>
+      writeAtomicBatch(root, [
+        { path: 'a.txt', content: 'new' },
+        { path: 'b.txt', content: 'bad' },
+      ])
+    )
+
+    assert.equal(await fs.readFile(path.join(root, 'a.txt'), 'utf8'), 'old')
+    const bStat = await fs.stat(path.join(root, 'b.txt'))
+    assert.equal(bStat.isDirectory(), true, '既有目录不应被批量写删除')
+  } finally {
+    await fs.rm(root, { recursive: true, force: true })
+  }
+})