#395·inkos

audit/revise 覆写 state-degraded 章节的 status 却保留 reviewNote,导致该章永久无法 repair-state(并可能让下一章静默跳过结算)

Author: pengyzCreated Sep 15, 2026Updated Sep 15, 2026

环境

  • @actalk/inkos 1.8.0,Node v24.14.0,Linux
  • 受影响数据:chapters/index.json 里两个章节同时满足
    • status === "ready-for-review"
    • reviewNote = {"kind":"state-degraded","baseStatus":"ready-for-review","injectedIssues":[…]}

现象

这两章都是正常降级过的reviewNote 就是 InkOS 自己写的),但它们无法用官方命令修复

bash
$ inkos write repair-state <book> 14
Chapter 14 is not state-degraded.          # ← 但它明明是

repair-state 的准入恰好是(dist/pipeline/runner.js:1778-1779):

javascript
if (targetMeta.status !== "state-degraded") {
    throw new Error(`Chapter ${targetChapter} is not state-degraded.`);
}

根因:降级写入是对的,后续的 audit 把 status 覆写掉了,却留下 reviewNote

① 降级写入路径正确设置status = "state-degraded"

dist/pipeline/runner.js:1691

javascript
const resolvedStatus = chapterStatus ?? (auditResult.passed ? "ready-for-review" : "audit-failed");

:1695 status: resolvedStatus,(传入 persistChapterArtifacts

dist/pipeline/chapter-persistence.js:12 / 18-20

javascript
status: params.status,
reviewNote: params.status === "state-degraded"
    ? buildStateDegradedReviewNote(params.auditResult.passed ? "ready-for-review" : "audit-failed", params.degradedIssues)
    : undefined,

dist/pipeline/chapter-truth-validation.js:26 / :71chapterStatus: "state-degraded" / chapterStatus = "state-degraded";

即:reviewNote 只在 params.status === "state-degraded" 时写入,所以同一 entry 的 status 必然就是 "state-degraded" 上游单测也钉死了这一点:packages/core/src/__tests__/pipeline-runner.test.ts:2651,2656result.statusindex[0].status 均为 state-degraded)、:2798,2799auditIssues 必含注入 issue)。全 dist 只有一个 persistChapterArtifacts 调用点(runner.js:1692),不存在第二条降级写入路径。

② 但审计会覆写 status,而 ...ch 展开保留了 reviewNote

dist/pipeline/runner.js:862-869auditDraft):

javascript
const updated = index.map((ch) => ch.number === targetChapter
    ? { ...ch,                                                   // ← 保留 reviewNote
        status: (result.passed ? "ready-for-review" : "audit-failed"),
        updatedAt: new Date().toISOString(),
        auditIssues: result.issues.map(...) }
    : ch);

没有 state-degraded 守卫reviseChapter 同样(runner.js:1171-1183),commands/review.js:104 的 approve 也覆写 status 并保留 reviewNote。

于是产生搁浅记录status 被改回可放行值,reviewNote 仍在 → repair-state 永久进不去。

③ 数据证据(决定性)

在受影响的两条记录上程序化核对:reviewNote.injectedIssuesauditIssues 交集为 0

而降级写入时 auditIssues 必然包含注入 issue(上游单测 :2799 断言)。→ 说明 auditIssues(与 status 出自同一条语句)是被后续某次重审整批替换的。

可以排除其它可能:reviseChapter(该章未被标 needs-revision)、resync(成功时会清 reviewNote,见 runner.js:1955-1968)、review approve(status 会变 approved)。auditDraft 是唯一自洽解释。

隔离复现(stub 掉 LLM,只跑真实索引更新逻辑):

BEFORE: status=state-degraded, reviewNote={...}, auditIssues=["[warning] fresh audit issue"]
AFTER : status=ready-for-review, reviewNote 原样保留, auditIssues=["[info] stub audit issue"]

精确复刻了受影响数据的形态。

后果(比"修不了"更严重)

  1. 该章永久无法用 repair-state 修复——准入只看 status
  2. 下一章的结算可能被静默跳过assertNoPendingStateRepairrunner.js:2503)会因为这个被改回的 status放行,于是"正文重审通过"被当成"状态已结算"。本项目就出现过这个后果——快照只到 0..13 而章节已到 15,逐章摘要缺失。
  3. 要恢复只能靠外部手段:放宽准入、或手工把 status 改回 state-degraded(本项目采用的临时解,repair-state 随后正常读取 baseStatus/injectedIssues 完成修复,见 runner.js:1840-1848)。

复现

bash
# 1) 让某章进入降级:结算校验失败且重试后仍有 warning → status=state-degraded + reviewNote
# 2) 对该章跑一次普通审计
inkos audit <book> <n> --json
# 3) 观察 chapters/index.json:
#    status 变回 ready-for-review/audit-failed,而 reviewNote 仍在
# 4) 再尝试修复
inkos write repair-state <book> <n>
#    → Chapter <n> is not state-degraded.

(另注:inkos write sync <book> <n> 不要求 state-degraded,但受 latest-only 限制,只能在目标章是最新章时使用;Studio 的 /resync/:chapter 同理。)

建议修复

1)止新:审计/修订不应降级已降级的章节

dist/pipeline/runner.js:865(TS pipeline/runner.ts:1317-1325)与 :1176

javascript
status: ch.status === "state-degraded"
    ? "state-degraded"
    : (result.passed ? "ready-for-review" : "audit-failed"),

或至少在覆写时 reviewNote: undefined理由:正文重审通过 ≠ truth-state 已结算。改成 ready-for-review 会让 assertNoPendingStateRepairrunner.js:2503)放行下一章,从而静默丢掉状态结算

2)兼容存量:准入放宽到"或 reviewNote 声明了降级"

dist/pipeline/runner.js:1778(TS runner.ts:2404):

javascript
if (targetMeta.status !== "state-degraded"
    && parseStateDegradedReviewNote(targetMeta.reviewNote) === null) {
    throw new Error(`Chapter ${targetChapter} is not state-degraded.`);
}

理由:修复体(_repairChapterStateLocked)本来就从 reviewNote 还原 baseStatus/injectedIssues 并在成功后清空它(runner.js:1840-1848),所以放宽准入天然兼容存量坏数据,修完即自愈。

⚠️ 两处建议应当一起做。只做 2 不做 1,新的搁浅记录会持续产生。

附:修正说明

我最初提交前提交过一版分析,认为"降级时忘记写 status"。那是错的——降级写入路径是对的(见 ① 与上游单测)。真正的问题是 ② 的覆写。特此更正,避免误导。