Sfoglia il codice sorgente

fix(pipeline): recover from state validation failures

Ma 7 mesi fa
parent
commit
2e2264f255

+ 4 - 2
packages/core/src/__tests__/models.test.ts

@@ -281,19 +281,21 @@ describe("ChapterStatusSchema", () => {
     "auditing",
     "audit-passed",
     "audit-failed",
+    "state-degraded",
     "revising",
     "ready-for-review",
     "approved",
     "rejected",
     "published",
+    "imported",
   ] as const;
 
   it.each(allStatuses)("accepts '%s'", (value) => {
     expect(ChapterStatusSchema.parse(value)).toBe(value);
   });
 
-  it("has exactly 12 valid statuses", () => {
-    expect(ChapterStatusSchema.options).toHaveLength(12);
+  it("has exactly 13 valid statuses", () => {
+    expect(ChapterStatusSchema.options).toHaveLength(13);
   });
 
   it("rejects unknown status", () => {

+ 251 - 0
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -2145,6 +2145,257 @@ describe("PipelineRunner", () => {
     await rm(root, { recursive: true, force: true });
   });
 
+  it("retries settlement after state contradictions without rewriting the chapter body", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture({
+      inputGovernanceMode: "legacy",
+    });
+    const storyDir = join(state.bookDir(bookId), "story");
+
+    await Promise.all([
+      writeFile(join(storyDir, "current_state.md"), "stable state", "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "stable hooks", "utf-8"),
+      writeFile(join(storyDir, "particle_ledger.md"), "stable ledger", "utf-8"),
+    ]);
+
+    const writeSpy = vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        content: "Healthy chapter body with the copper token in his coat.",
+        wordCount: "Healthy chapter body with the copper token in his coat.".length,
+        updatedState: "broken state",
+        updatedHooks: "broken hooks",
+        updatedLedger: "broken ledger",
+      }),
+    );
+    const settleSpy = vi.spyOn(
+      WriterAgent.prototype as unknown as {
+        settleChapterState: (input: Record<string, unknown>) => Promise<WriteChapterOutput>;
+      },
+      "settleChapterState",
+    ).mockResolvedValue(
+      createWriterOutput({
+        content: "Healthy chapter body with the copper token in his coat.",
+        wordCount: "Healthy chapter body with the copper token in his coat.".length,
+        updatedState: "fixed state",
+        updatedHooks: "fixed hooks",
+        updatedLedger: "fixed ledger",
+      }),
+    );
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
+      createAuditResult({
+        passed: true,
+        issues: [],
+        summary: "clean",
+      }),
+    );
+    vi.spyOn(StateValidatorAgent.prototype, "validate")
+      .mockResolvedValueOnce({
+        passed: false,
+        warnings: [{
+          category: "unsupported_change",
+          description: "状态写成铜牌未带在身上,但正文明确写了怀里的铜牌。",
+        }],
+      })
+      .mockResolvedValueOnce({
+        passed: true,
+        warnings: [],
+      });
+
+    const result = await runner.writeNextChapter(bookId);
+
+    expect(result.status).toBe("ready-for-review");
+    expect(writeSpy).toHaveBeenCalledTimes(1);
+    expect(settleSpy).toHaveBeenCalledTimes(1);
+    expect(settleSpy).toHaveBeenCalledWith(expect.objectContaining({
+      chapterNumber: 1,
+      title: "Test Chapter",
+      content: "Healthy chapter body with the copper token in his coat.",
+      validationFeedback: expect.stringContaining("怀里的铜牌"),
+    }));
+    await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe("fixed state");
+    await expect(readFile(join(storyDir, "pending_hooks.md"), "utf-8")).resolves.toBe("fixed hooks");
+
+    await rm(root, { recursive: true, force: true });
+  });
+
+  it("persists a state-degraded chapter without advancing truth files when settlement retry still contradicts the body", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture({
+      inputGovernanceMode: "legacy",
+    });
+    const bookDir = state.bookDir(bookId);
+    const storyDir = join(bookDir, "story");
+    const chaptersDir = join(bookDir, "chapters");
+
+    await Promise.all([
+      writeFile(join(storyDir, "current_state.md"), "stable state", "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "stable hooks", "utf-8"),
+      writeFile(join(storyDir, "particle_ledger.md"), "stable ledger", "utf-8"),
+    ]);
+
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        content: "Healthy chapter body with the copper token in his coat.",
+        wordCount: "Healthy chapter body with the copper token in his coat.".length,
+        updatedState: "broken state",
+        updatedHooks: "broken hooks",
+        updatedLedger: "broken ledger",
+      }),
+    );
+    vi.spyOn(
+      WriterAgent.prototype as unknown as {
+        settleChapterState: (input: Record<string, unknown>) => Promise<WriteChapterOutput>;
+      },
+      "settleChapterState",
+    ).mockResolvedValue(
+      createWriterOutput({
+        content: "Healthy chapter body with the copper token in his coat.",
+        wordCount: "Healthy chapter body with the copper token in his coat.".length,
+        updatedState: "still broken state",
+        updatedHooks: "still broken hooks",
+        updatedLedger: "still broken ledger",
+      }),
+    );
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
+      createAuditResult({
+        passed: true,
+        issues: [],
+        summary: "clean",
+      }),
+    );
+    vi.spyOn(StateValidatorAgent.prototype, "validate")
+      .mockResolvedValueOnce({
+        passed: false,
+        warnings: [{
+          category: "unsupported_change",
+          description: "settler 把铜牌写没了,但正文仍然明确带在身上。",
+        }],
+      })
+      .mockResolvedValueOnce({
+        passed: false,
+        warnings: [{
+          category: "unsupported_change",
+          description: "重试后仍然把铜牌写没了。",
+        }],
+      });
+
+    const result = await runner.writeNextChapter(bookId);
+    const savedIndex = await state.loadChapterIndex(bookId);
+
+    expect(result.status).toBe("state-degraded");
+    expect(savedIndex[0]?.status).toBe("state-degraded");
+    expect(savedIndex[0]?.auditIssues).toContain("[warning] 重试后仍然把铜牌写没了。");
+    await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe("stable state");
+    await expect(readFile(join(storyDir, "pending_hooks.md"), "utf-8")).resolves.toBe("stable hooks");
+    await expect(readFile(join(storyDir, "particle_ledger.md"), "utf-8")).resolves.toBe("stable ledger");
+    await expect(readdir(chaptersDir)).resolves.toContain("0001_Test_Chapter.md");
+    await expect(stat(join(storyDir, "snapshots", "1"))).rejects.toThrow();
+
+    await rm(root, { recursive: true, force: true });
+  });
+
+  it("blocks writing a new chapter when the latest persisted chapter is state-degraded", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture({
+      inputGovernanceMode: "legacy",
+    });
+    const now = "2026-03-19T00:00:00.000Z";
+    const storyDir = join(state.bookDir(bookId), "story");
+
+    await Promise.all([
+      writeFile(join(storyDir, "current_state.md"), "stable state", "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "stable hooks", "utf-8"),
+      state.saveChapterIndex(bookId, [{
+        number: 1,
+        title: "Broken Persistence",
+        status: "state-degraded" as ChapterMeta["status"],
+        wordCount: 1234,
+        createdAt: now,
+        updatedAt: now,
+        auditIssues: ["[warning] state validation degraded"],
+        lengthWarnings: [],
+      }]),
+      writeFile(join(state.bookDir(bookId), "chapters", "0001_Broken_Persistence.md"), "# 第1章 Broken Persistence\n\nbody", "utf-8"),
+    ]);
+
+    await expect(runner.writeNextChapter(bookId)).rejects.toThrow(/state-degraded/i);
+
+    await rm(root, { recursive: true, force: true });
+  });
+
+  it("repairs the latest state-degraded chapter from persisted body without rewriting it", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture({
+      inputGovernanceMode: "legacy",
+    });
+    const now = "2026-03-19T00:00:00.000Z";
+    const bookDir = state.bookDir(bookId);
+    const storyDir = join(bookDir, "story");
+
+    await Promise.all([
+      writeFile(join(storyDir, "current_state.md"), "stable state", "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "stable hooks", "utf-8"),
+      writeFile(join(storyDir, "particle_ledger.md"), "stable ledger", "utf-8"),
+      writeFile(
+        join(bookDir, "chapters", "0001_Broken_Persistence.md"),
+        "# 第1章 Broken Persistence\n\nHealthy chapter body with the copper token in his coat.",
+        "utf-8",
+      ),
+      state.saveChapterIndex(bookId, [{
+        number: 1,
+        title: "Broken Persistence",
+        status: "state-degraded" as ChapterMeta["status"],
+        wordCount: 55,
+        createdAt: now,
+        updatedAt: now,
+        auditIssues: ["[warning] 重试后仍然把铜牌写没了。"],
+        lengthWarnings: [],
+        reviewNote: JSON.stringify({
+          kind: "state-degraded",
+          baseStatus: "ready-for-review",
+          injectedIssues: ["[warning] 重试后仍然把铜牌写没了。"],
+        }),
+      }]),
+    ]);
+
+    vi.spyOn(
+      WriterAgent.prototype as unknown as {
+        settleChapterState: (input: Record<string, unknown>) => Promise<WriteChapterOutput>;
+      },
+      "settleChapterState",
+    ).mockResolvedValue(
+      createWriterOutput({
+        chapterNumber: 1,
+        title: "Broken Persistence",
+        content: "Healthy chapter body with the copper token in his coat.",
+        wordCount: "Healthy chapter body with the copper token in his coat.".length,
+        updatedState: "fixed state",
+        updatedHooks: "fixed hooks",
+        updatedLedger: "fixed ledger",
+      }),
+    );
+    vi.spyOn(StateValidatorAgent.prototype, "validate").mockResolvedValue({
+      passed: true,
+      warnings: [],
+    });
+
+    const result = await (
+      runner as unknown as {
+        repairChapterState: (bookId: string, chapterNumber?: number) => Promise<{
+          status: string;
+          chapterNumber: number;
+        }>;
+      }
+    ).repairChapterState(bookId, 1);
+    const savedIndex = await state.loadChapterIndex(bookId);
+
+    expect(result.status).toBe("ready-for-review");
+    expect(result.chapterNumber).toBe(1);
+    await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe("fixed state");
+    await expect(readFile(join(storyDir, "pending_hooks.md"), "utf-8")).resolves.toBe("fixed hooks");
+    expect(savedIndex[0]?.status).toBe("ready-for-review");
+    expect(savedIndex[0]?.auditIssues).toEqual([]);
+    expect(savedIndex[0]?.reviewNote).toBeUndefined();
+
+    await rm(root, { recursive: true, force: true });
+  });
+
   it("still persists the chapter when the state validator appends markdown after a valid JSON verdict", async () => {
     vi.restoreAllMocks();
     vi.spyOn(LengthNormalizerAgent.prototype, "normalizeChapter").mockImplementation(

+ 55 - 0
packages/core/src/__tests__/scheduler.test.ts

@@ -1,5 +1,6 @@
 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
 import { Scheduler, type SchedulerConfig } from "../pipeline/scheduler.js";
+import type { BookConfig } from "../models/book.js";
 
 function createConfig(): SchedulerConfig {
   return {
@@ -65,4 +66,58 @@ describe("Scheduler", () => {
     await blockedCycle;
     scheduler.stop();
   });
+
+  it("treats state-degraded chapter results as handled failures", async () => {
+    const onChapterComplete = vi.fn();
+    const scheduler = new Scheduler({
+      ...createConfig(),
+      onChapterComplete,
+    });
+    const bookConfig: BookConfig = {
+      id: "book-1",
+      title: "Book 1",
+      platform: "other",
+      genre: "other",
+      status: "active",
+      targetChapters: 10,
+      chapterWordCount: 2200,
+      createdAt: "2026-04-01T00:00:00.000Z",
+      updatedAt: "2026-04-01T00:00:00.000Z",
+    };
+
+    vi.spyOn(
+      (scheduler as unknown as { pipeline: { writeNextChapter: (bookId: string, words?: number, temp?: number) => Promise<unknown> } }).pipeline,
+      "writeNextChapter",
+    ).mockResolvedValue({
+        chapterNumber: 3,
+        title: "Broken State",
+        wordCount: 2100,
+        revised: false,
+        status: "state-degraded",
+        auditResult: {
+          passed: true,
+          issues: [{
+            severity: "warning",
+            category: "state-validation",
+            description: "state validation still failed after retry",
+            suggestion: "repair state before continuing",
+          }],
+          summary: "clean",
+        },
+    });
+    const handleAuditFailure = vi.spyOn(
+      scheduler as unknown as { handleAuditFailure: (bookId: string, chapterNumber: number, issueCategories?: string[]) => Promise<void> },
+      "handleAuditFailure",
+    ).mockResolvedValue(undefined);
+
+    const success = await (
+      scheduler as unknown as {
+        writeOneChapter: (bookId: string, bookConfig: BookConfig) => Promise<boolean>;
+      }
+    ).writeOneChapter("book-1", bookConfig);
+
+    expect(success).toBe(false);
+    expect(handleAuditFailure).toHaveBeenCalledWith("book-1", 3, ["state-validation"]);
+    expect(onChapterComplete).toHaveBeenCalledWith("book-1", 3, "state-degraded");
+  });
 });

+ 5 - 0
packages/core/src/agents/settler-prompts.ts

@@ -171,6 +171,7 @@ export function buildSettlerUserPrompt(params: {
   readonly observations?: string;
   readonly selectedEvidenceBlock?: string;
   readonly governedControlBlock?: string;
+  readonly validationFeedback?: string;
 }): string {
   const ledgerBlock = params.ledger
     ? `\n## 当前资源账本\n${params.ledger}\n`
@@ -202,9 +203,13 @@ export function buildSettlerUserPrompt(params: {
   const outlineBlock = controlBlock.length === 0
     ? `\n## 卷纲\n${params.volumeOutline}\n`
     : "";
+  const validationFeedbackBlock = params.validationFeedback
+    ? `\n## 状态校验反馈\n${params.validationFeedback}\n\n请严格纠正这些矛盾,只修正 truth files,不要改写正文,不要引入正文中不存在的新事实。\n`
+    : "";
 
   return `请分析第${params.chapterNumber}章「${params.title}」的正文,更新所有追踪文件。
 ${observationsBlock}
+${validationFeedbackBlock}
 ## 本章正文
 
 ${params.content}

+ 107 - 1
packages/core/src/agents/writer.ts

@@ -18,7 +18,7 @@ import { analyzeAITells } from "./ai-tells.js";
 import type { ChapterTrace, ContextPackage, RuleStack } from "../models/input-governance.js";
 import type { LengthSpec } from "../models/length-governance.js";
 import type { RuntimeStateDelta } from "../models/runtime-state.js";
-import { buildLengthSpec } from "../utils/length-metrics.js";
+import { buildLengthSpec, countChapterLength } from "../utils/length-metrics.js";
 import { filterHooks, filterSummaries, filterSubplots, filterEmotionalArcs, filterCharacterMatrix } from "../utils/context-filter.js";
 import { buildGovernedMemoryEvidenceBlocks } from "../utils/governed-context.js";
 import {
@@ -51,6 +51,18 @@ export interface WriteChapterInput {
   readonly temperatureOverride?: number;
 }
 
+export interface SettleChapterStateInput {
+  readonly book: BookConfig;
+  readonly bookDir: string;
+  readonly chapterNumber: number;
+  readonly title: string;
+  readonly content: string;
+  readonly chapterIntent?: string;
+  readonly contextPackage?: ContextPackage;
+  readonly ruleStack?: RuleStack;
+  readonly validationFeedback?: string;
+}
+
 export interface TokenUsage {
   readonly promptTokens: number;
   readonly completionTokens: number;
@@ -295,6 +307,7 @@ export class WriterAgent extends BaseAgent {
       chapterIntent: input.chapterIntent,
       contextPackage: input.contextPackage,
       ruleStack: input.ruleStack,
+      validationFeedback: undefined,
       originalHooks: hooks,
       originalSubplots: subplotBoard,
       originalEmotionalArcs: emotionalArcs,
@@ -392,6 +405,97 @@ export class WriterAgent extends BaseAgent {
     };
   }
 
+  async settleChapterState(input: SettleChapterStateInput): Promise<WriteChapterOutput> {
+    const [
+      currentState,
+      ledger,
+      hooks,
+      chapterSummaries,
+      subplotBoard,
+      emotionalArcs,
+      characterMatrix,
+      volumeOutline,
+    ] = await Promise.all([
+      this.readFileOrDefault(join(input.bookDir, "story/current_state.md")),
+      this.readFileOrDefault(join(input.bookDir, "story/particle_ledger.md")),
+      this.readFileOrDefault(join(input.bookDir, "story/pending_hooks.md")),
+      this.readFileOrDefault(join(input.bookDir, "story/chapter_summaries.md")),
+      this.readFileOrDefault(join(input.bookDir, "story/subplot_board.md")),
+      this.readFileOrDefault(join(input.bookDir, "story/emotional_arcs.md")),
+      this.readFileOrDefault(join(input.bookDir, "story/character_matrix.md")),
+      this.readFileOrDefault(join(input.bookDir, "story/volume_outline.md")),
+    ]);
+
+    const { profile: genreProfile } = await readGenreProfile(this.ctx.projectRoot, input.book.genre);
+    const parsedBookRules = await readBookRules(input.bookDir);
+    const bookRules = parsedBookRules?.rules ?? null;
+    const resolvedLanguage = input.book.language ?? genreProfile.language;
+    const governedMemoryBlocks = input.contextPackage
+      ? buildGovernedMemoryEvidenceBlocks(input.contextPackage, resolvedLanguage)
+      : undefined;
+
+    const settleResult = await this.settle({
+      book: input.book,
+      genreProfile,
+      bookRules,
+      chapterNumber: input.chapterNumber,
+      title: input.title,
+      content: input.content,
+      currentState,
+      ledger: genreProfile.numericalSystem ? ledger : "",
+      hooks,
+      chapterSummaries,
+      subplotBoard,
+      emotionalArcs,
+      characterMatrix,
+      volumeOutline,
+      selectedEvidenceBlock: governedMemoryBlocks
+        ? this.joinGovernedEvidenceBlocks(governedMemoryBlocks)
+        : undefined,
+      chapterIntent: input.chapterIntent,
+      contextPackage: input.contextPackage,
+      ruleStack: input.ruleStack,
+      validationFeedback: input.validationFeedback,
+      originalHooks: hooks,
+      originalSubplots: subplotBoard,
+      originalEmotionalArcs: emotionalArcs,
+      originalCharacterMatrix: characterMatrix,
+    });
+    const settlement = settleResult.settlement;
+    const runtimeStateArtifacts = await this.buildRuntimeStateArtifactsIfPresent(
+      input.bookDir,
+      settlement.runtimeStateDelta,
+      resolvedLanguage,
+    );
+
+    return {
+      chapterNumber: input.chapterNumber,
+      title: input.title,
+      content: input.content,
+      wordCount: countChapterLength(
+        input.content,
+        resolvedLanguage === "en" ? "en_words" : "zh_chars",
+      ),
+      preWriteCheck: "",
+      postSettlement: settlement.postSettlement,
+      runtimeStateDelta: runtimeStateArtifacts?.resolvedDelta ?? settlement.runtimeStateDelta,
+      runtimeStateSnapshot: runtimeStateArtifacts?.snapshot ?? settlement.runtimeStateSnapshot,
+      updatedState: runtimeStateArtifacts?.currentStateMarkdown ?? settlement.updatedState,
+      updatedLedger: settlement.updatedLedger,
+      updatedHooks: runtimeStateArtifacts?.hooksMarkdown ?? settlement.updatedHooks,
+      chapterSummary: settlement.runtimeStateDelta
+        ? this.renderDeltaSummaryRow(settlement.runtimeStateDelta)
+        : settlement.chapterSummary,
+      updatedChapterSummaries: runtimeStateArtifacts?.chapterSummariesMarkdown,
+      updatedSubplots: settlement.updatedSubplots,
+      updatedEmotionalArcs: settlement.updatedEmotionalArcs,
+      updatedCharacterMatrix: settlement.updatedCharacterMatrix,
+      postWriteErrors: [],
+      postWriteWarnings: [],
+      tokenUsage: settleResult.usage,
+    };
+  }
+
   private async settle(params: {
     readonly book: BookConfig;
     readonly genreProfile: GenreProfile;
@@ -411,6 +515,7 @@ export class WriterAgent extends BaseAgent {
     readonly chapterIntent?: string;
     readonly contextPackage?: ContextPackage;
     readonly ruleStack?: RuleStack;
+    readonly validationFeedback?: string;
     readonly originalHooks: string;
     readonly originalSubplots: string;
     readonly originalEmotionalArcs: string;
@@ -472,6 +577,7 @@ export class WriterAgent extends BaseAgent {
       observations,
       selectedEvidenceBlock: params.selectedEvidenceBlock,
       governedControlBlock,
+      validationFeedback: params.validationFeedback,
     });
 
     // Settler outputs all truth files — scale with content size

+ 1 - 0
packages/core/src/models/chapter.ts

@@ -8,6 +8,7 @@ export const ChapterStatusSchema = z.enum([
   "auditing",
   "audit-passed",
   "audit-failed",
+  "state-degraded",
   "revising",
   "ready-for-review",
   "approved",

+ 414 - 18
packages/core/src/pipeline/runner.ts

@@ -13,7 +13,7 @@ import { LengthNormalizerAgent } from "../agents/length-normalizer.js";
 import { ChapterAnalyzerAgent } from "../agents/chapter-analyzer.js";
 import { ContinuityAuditor } from "../agents/continuity.js";
 import { ReviserAgent, DEFAULT_REVISE_MODE, type ReviseMode } from "../agents/reviser.js";
-import { StateValidatorAgent } from "../agents/state-validator.js";
+import { StateValidatorAgent, type ValidationResult, type ValidationWarning } from "../agents/state-validator.js";
 import { RadarAgent } from "../agents/radar.js";
 import type { RadarSource } from "../agents/radar-source.js";
 import { readGenreProfile } from "../agents/rules-reader.js";
@@ -61,7 +61,7 @@ export interface ChapterPipelineResult {
   readonly wordCount: number;
   readonly auditResult: AuditResult;
   readonly revised: boolean;
-  readonly status: "ready-for-review" | "audit-failed";
+  readonly status: "ready-for-review" | "audit-failed" | "state-degraded";
   readonly lengthWarnings?: ReadonlyArray<string>;
   readonly lengthTelemetry?: LengthTelemetry;
   readonly tokenUsage?: TokenUsageSummary;
@@ -964,10 +964,20 @@ export class PipelineRunner {
     }
   }
 
+  async repairChapterState(bookId: string, chapterNumber?: number): Promise<ChapterPipelineResult> {
+    const releaseLock = await this.state.acquireBookLock(bookId);
+    try {
+      return await this._repairChapterStateLocked(bookId, chapterNumber);
+    } finally {
+      await releaseLock();
+    }
+  }
+
   private async _writeNextChapterLocked(bookId: string, wordCount?: number, temperatureOverride?: number): Promise<ChapterPipelineResult> {
     await this.state.ensureControlDocuments(bookId);
     const book = await this.state.loadBookConfig(bookId);
     const bookDir = this.state.bookDir(bookId);
+    await this.assertNoPendingStateRepair(bookId);
     const chapterNumber = await this.state.getNextChapterNumber(bookId);
     const stageLanguage = await this.resolveBookLanguage(book);
     this.logStage(stageLanguage, { zh: "准备章节输入", en: "preparing chapter inputs" });
@@ -1237,12 +1247,15 @@ export class PipelineRunner {
     // 4.1 Validate settler output before writing
     this.logStage(stageLanguage, { zh: "校验真相文件变更", en: "validating truth file updates" });
     const storyDir = join(bookDir, "story");
-    const [oldState, oldHooks] = await Promise.all([
+    const [oldState, oldHooks, oldLedger] = await Promise.all([
       readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""),
       readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
+      readFile(join(storyDir, "particle_ledger.md"), "utf-8").catch(() => ""),
     ]);
     const validator = new StateValidatorAgent(this.agentCtxFor("state-validator", bookId));
-    let validation;
+    let validation: ValidationResult;
+    let chapterStatus: ChapterPipelineResult["status"] | null = null;
+    let degradedIssues: ReadonlyArray<AuditIssue> = [];
     try {
       validation = await validator.validate(
         finalContent, chapterNumber,
@@ -1264,8 +1277,38 @@ export class PipelineRunner {
       }
     }
     if (!validation.passed) {
-      const reason = validation.warnings[0]?.description ?? "validator reported contradictions";
-      throw new Error(`State validation failed for chapter ${chapterNumber}: ${reason}`);
+      const recovery = await this.retrySettlementAfterValidationFailure({
+        writer,
+        validator,
+        book,
+        bookDir,
+        chapterNumber,
+        title: persistenceOutput.title,
+        content: finalContent,
+        reducedControlInput,
+        oldState,
+        oldHooks,
+        originalValidation: validation,
+        language: pipelineLang,
+      });
+
+      if (recovery.kind === "recovered") {
+        persistenceOutput = recovery.output;
+        validation = recovery.validation;
+      } else {
+        chapterStatus = "state-degraded";
+        degradedIssues = recovery.issues;
+        persistenceOutput = this.buildStateDegradedPersistenceOutput({
+          output: persistenceOutput,
+          oldState,
+          oldHooks,
+          oldLedger,
+        });
+        auditResult = {
+          ...auditResult,
+          issues: [...auditResult.issues, ...recovery.issues],
+        };
+      }
     }
 
     // 4.2 Final paragraph shape check on persisted content (post-normalize, post-revise)
@@ -1303,10 +1346,12 @@ export class PipelineRunner {
     }
 
     await writer.saveChapter(bookDir, persistenceOutput, gp.numericalSystem, pipelineLang);
-    await writer.saveNewTruthFiles(bookDir, persistenceOutput, pipelineLang);
-    await this.syncLegacyStructuredStateFromMarkdown(bookDir, chapterNumber, persistenceOutput);
-    this.logStage(stageLanguage, { zh: "同步记忆索引", en: "syncing memory indexes" });
-    await this.syncNarrativeMemoryIndex(bookId);
+    if (chapterStatus !== "state-degraded") {
+      await writer.saveNewTruthFiles(bookDir, persistenceOutput, pipelineLang);
+      await this.syncLegacyStructuredStateFromMarkdown(bookDir, chapterNumber, persistenceOutput);
+      this.logStage(stageLanguage, { zh: "同步记忆索引", en: "syncing memory indexes" });
+      await this.syncNarrativeMemoryIndex(bookId);
+    }
 
     // 5. Update chapter index
     const existingIndex = await this.state.loadChapterIndex(bookId);
@@ -1314,7 +1359,7 @@ export class PipelineRunner {
     const newEntry: ChapterMeta = {
       number: chapterNumber,
       title: persistenceOutput.title,
-      status: auditResult.passed ? "ready-for-review" : "audit-failed",
+      status: chapterStatus ?? (auditResult.passed ? "ready-for-review" : "audit-failed"),
       wordCount: finalWordCount,
       createdAt: now,
       updatedAt: now,
@@ -1322,6 +1367,12 @@ export class PipelineRunner {
         (i) => `[${i.severity}] ${i.description}`,
       ),
       lengthWarnings,
+      reviewNote: chapterStatus === "state-degraded"
+        ? this.buildStateDegradedReviewNote(
+            auditResult.passed ? "ready-for-review" : "audit-failed",
+            degradedIssues,
+          )
+        : undefined,
       lengthTelemetry,
       tokenUsage: totalUsage,
     };
@@ -1333,7 +1384,7 @@ export class PipelineRunner {
     const driftIssues = auditResult.issues.filter(
       (i) => i.severity === "critical" || i.severity === "warning",
     );
-    if (driftIssues.length > 0) {
+    if (chapterStatus !== "state-degraded" && driftIssues.length > 0) {
       const storyDir = join(bookDir, "story");
       try {
         const statePath = join(storyDir, "current_state.md");
@@ -1367,20 +1418,26 @@ export class PipelineRunner {
     }
 
     // 5.6 Snapshot state for rollback support
-    this.logStage(stageLanguage, { zh: "更新章节索引与快照", en: "updating chapter index and snapshots" });
-    await this.state.snapshotState(bookId, chapterNumber);
-    await this.syncCurrentStateFactHistory(bookId, chapterNumber);
+    if (chapterStatus !== "state-degraded") {
+      this.logStage(stageLanguage, { zh: "更新章节索引与快照", en: "updating chapter index and snapshots" });
+      await this.state.snapshotState(bookId, chapterNumber);
+      await this.syncCurrentStateFactHistory(bookId, chapterNumber);
+    }
 
     // 6. Send notification
     if (this.config.notifyChannels && this.config.notifyChannels.length > 0) {
-      const statusEmoji = auditResult.passed ? "✅" : "⚠️";
+      const statusEmoji = chapterStatus === "state-degraded"
+        ? "🧯"
+        : auditResult.passed ? "✅" : "⚠️";
       const chapterLength = formatLengthCount(finalWordCount, lengthSpec.countingMode);
       await dispatchNotification(this.config.notifyChannels, {
         title: `${statusEmoji} ${book.title} 第${chapterNumber}章`,
         body: [
           `**${persistenceOutput.title}** | ${chapterLength}`,
           revised ? "📝 已自动修正" : "",
-          `审稿: ${auditResult.passed ? "通过" : "需人工审核"}`,
+          chapterStatus === "state-degraded"
+            ? "状态结算: 已降级保存,需先修复 state 再继续"
+            : `审稿: ${auditResult.passed ? "通过" : "需人工审核"}`,
           ...auditResult.issues
             .filter((i) => i.severity !== "info")
             .map((i) => `- [${i.severity}] ${i.description}`),
@@ -1395,6 +1452,7 @@ export class PipelineRunner {
       wordCount: finalWordCount,
       passed: auditResult.passed,
       revised,
+      status: chapterStatus ?? (auditResult.passed ? "ready-for-review" : "audit-failed"),
     });
 
     return {
@@ -1403,13 +1461,130 @@ export class PipelineRunner {
       wordCount: finalWordCount,
       auditResult,
       revised,
-      status: auditResult.passed ? "ready-for-review" : "audit-failed",
+      status: chapterStatus ?? (auditResult.passed ? "ready-for-review" : "audit-failed"),
       lengthWarnings,
       lengthTelemetry,
       tokenUsage: totalUsage,
     };
   }
 
+  private async _repairChapterStateLocked(bookId: string, chapterNumber?: number): Promise<ChapterPipelineResult> {
+    const book = await this.state.loadBookConfig(bookId);
+    const bookDir = this.state.bookDir(bookId);
+    const stageLanguage = await this.resolveBookLanguage(book);
+    const index = [...(await this.state.loadChapterIndex(bookId))];
+    if (index.length === 0) {
+      throw new Error(`Book "${bookId}" has no persisted chapters to repair.`);
+    }
+
+    const targetChapter = chapterNumber ?? index[index.length - 1]!.number;
+    const targetIndex = index.findIndex((chapter) => chapter.number === targetChapter);
+    if (targetIndex < 0) {
+      throw new Error(`Chapter ${targetChapter} not found in "${bookId}".`);
+    }
+    const targetMeta = index[targetIndex]!;
+    const latestChapter = Math.max(...index.map((chapter) => chapter.number));
+    if (targetMeta.status !== "state-degraded") {
+      throw new Error(`Chapter ${targetChapter} is not state-degraded.`);
+    }
+    if (targetChapter !== latestChapter) {
+      throw new Error(`Only the latest state-degraded chapter can be repaired safely (latest is ${latestChapter}).`);
+    }
+
+    this.logStage(stageLanguage, { zh: "修复章节状态结算", en: "repairing chapter state settlement" });
+    const { profile: gp } = await this.loadGenreProfile(book.genre);
+    const pipelineLang = book.language ?? gp.language;
+    const content = await this.readChapterContent(bookDir, targetChapter);
+    const storyDir = join(bookDir, "story");
+    const [oldState, oldHooks] = await Promise.all([
+      readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""),
+      readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
+    ]);
+
+    const writer = new WriterAgent(this.agentCtxFor("writer", bookId));
+    let repairedOutput = await writer.settleChapterState({
+      book,
+      bookDir,
+      chapterNumber: targetChapter,
+      title: targetMeta.title,
+      content,
+    });
+    const validator = new StateValidatorAgent(this.agentCtxFor("state-validator", bookId));
+    let validation = await validator.validate(
+      content,
+      targetChapter,
+      oldState,
+      repairedOutput.updatedState,
+      oldHooks,
+      repairedOutput.updatedHooks,
+      pipelineLang,
+    );
+
+    if (!validation.passed) {
+      const recovery = await this.retrySettlementAfterValidationFailure({
+        writer,
+        validator,
+        book,
+        bookDir,
+        chapterNumber: targetChapter,
+        title: targetMeta.title,
+        content,
+        oldState,
+        oldHooks,
+        originalValidation: validation,
+        language: pipelineLang,
+      });
+      if (recovery.kind !== "recovered") {
+        throw new Error(
+          recovery.issues[0]?.description
+            ?? `State repair still failed for chapter ${targetChapter}.`,
+        );
+      }
+      repairedOutput = recovery.output;
+      validation = recovery.validation;
+    }
+
+    if (!validation.passed) {
+      throw new Error(`State repair still failed for chapter ${targetChapter}.`);
+    }
+
+    await writer.saveChapter(bookDir, repairedOutput, gp.numericalSystem, pipelineLang);
+    await writer.saveNewTruthFiles(bookDir, repairedOutput, pipelineLang);
+    await this.syncLegacyStructuredStateFromMarkdown(bookDir, targetChapter, repairedOutput);
+    await this.syncNarrativeMemoryIndex(bookId);
+    await this.state.snapshotState(bookId, targetChapter);
+    await this.syncCurrentStateFactHistory(bookId, targetChapter);
+
+    const baseStatus = this.resolveStateDegradedBaseStatus(targetMeta);
+    const degradedMetadata = this.parseStateDegradedReviewNote(targetMeta.reviewNote);
+    const injectedIssues = new Set(degradedMetadata?.injectedIssues ?? []);
+    index[targetIndex] = {
+      ...targetMeta,
+      status: baseStatus,
+      updatedAt: new Date().toISOString(),
+      auditIssues: targetMeta.auditIssues.filter((issue) => !injectedIssues.has(issue)),
+      reviewNote: undefined,
+    };
+    await this.state.saveChapterIndex(bookId, index);
+
+    const repairedPassesAudit = baseStatus !== "audit-failed";
+    return {
+      chapterNumber: targetChapter,
+      title: targetMeta.title,
+      wordCount: targetMeta.wordCount,
+      auditResult: {
+        passed: repairedPassesAudit,
+        issues: [],
+        summary: repairedPassesAudit ? "state repaired" : "state repaired but chapter still needs review",
+      },
+      revised: false,
+      status: baseStatus,
+      lengthWarnings: targetMeta.lengthWarnings,
+      lengthTelemetry: targetMeta.lengthTelemetry,
+      tokenUsage: targetMeta.tokenUsage,
+    };
+  }
+
   // ---------------------------------------------------------------------------
   // Import operations (style imitation + canon for spinoff)
   // ---------------------------------------------------------------------------
@@ -1851,6 +2026,227 @@ ${matrix}`,
     };
   }
 
+  private async assertNoPendingStateRepair(bookId: string): Promise<void> {
+    const existingIndex = await this.state.loadChapterIndex(bookId);
+    const latestChapter = [...existingIndex].sort((left, right) => right.number - left.number)[0];
+    if (latestChapter?.status !== "state-degraded") {
+      return;
+    }
+
+    throw new Error(
+      `Latest chapter ${latestChapter.number} is state-degraded. Repair state or rewrite that chapter before continuing.`,
+    );
+  }
+
+  private async retrySettlementAfterValidationFailure(params: {
+    readonly writer: WriterAgent;
+    readonly validator: StateValidatorAgent;
+    readonly book: BookConfig;
+    readonly bookDir: string;
+    readonly chapterNumber: number;
+    readonly title: string;
+    readonly content: string;
+    readonly reducedControlInput?: {
+      chapterIntent: string;
+      contextPackage: ContextPackage;
+      ruleStack: RuleStack;
+    };
+    readonly oldState: string;
+    readonly oldHooks: string;
+    readonly originalValidation: ValidationResult;
+    readonly language: LengthLanguage;
+  }): Promise<
+    | {
+      readonly kind: "recovered";
+      readonly output: WriteChapterOutput;
+      readonly validation: ValidationResult;
+    }
+    | {
+      readonly kind: "degraded";
+      readonly issues: ReadonlyArray<AuditIssue>;
+    }
+  > {
+    this.logWarn(params.language, {
+      zh: `状态校验失败,正在仅重试结算层(第${params.chapterNumber}章)`,
+      en: `State validation failed; retrying settlement only for chapter ${params.chapterNumber}`,
+    });
+
+    const retryOutput = await params.writer.settleChapterState({
+      book: params.book,
+      bookDir: params.bookDir,
+      chapterNumber: params.chapterNumber,
+      title: params.title,
+      content: params.content,
+      chapterIntent: params.reducedControlInput?.chapterIntent,
+      contextPackage: params.reducedControlInput?.contextPackage,
+      ruleStack: params.reducedControlInput?.ruleStack,
+      validationFeedback: this.buildStateValidationFeedback(
+        params.originalValidation.warnings,
+        params.language,
+      ),
+    });
+
+    let retryValidation: ValidationResult;
+    try {
+      retryValidation = await params.validator.validate(
+        params.content,
+        params.chapterNumber,
+        params.oldState,
+        retryOutput.updatedState,
+        params.oldHooks,
+        retryOutput.updatedHooks,
+        params.language,
+      );
+    } catch (error) {
+      throw new Error(`State validation retry failed for chapter ${params.chapterNumber}: ${String(error)}`);
+    }
+
+    if (retryValidation.warnings.length > 0) {
+      this.logWarn(params.language, {
+        zh: `状态校验重试后,第${params.chapterNumber}章仍有 ${retryValidation.warnings.length} 条警告`,
+        en: `State validation retry still reports ${retryValidation.warnings.length} warning(s) for chapter ${params.chapterNumber}`,
+      });
+      for (const warning of retryValidation.warnings) {
+        this.config.logger?.warn(`  [${warning.category}] ${warning.description}`);
+      }
+    }
+
+    if (retryValidation.passed) {
+      return {
+        kind: "recovered",
+        output: retryOutput,
+        validation: retryValidation,
+      };
+    }
+
+    return {
+      kind: "degraded",
+      issues: this.buildStateDegradedIssues(retryValidation.warnings, params.language),
+    };
+  }
+
+  private buildStateValidationFeedback(
+    warnings: ReadonlyArray<ValidationWarning>,
+    language: LengthLanguage,
+  ): string {
+    if (warnings.length === 0) {
+      return language === "en"
+        ? "The previous settlement contradicted the chapter text. Reconcile truth files strictly to the body."
+        : "上一次状态结算与正文矛盾。请严格以正文为准修正 truth files。";
+    }
+
+    if (language === "en") {
+      return [
+        "The previous settlement failed validation. Fix these contradictions against the chapter body:",
+        ...warnings.map((warning) => `- [${warning.category}] ${warning.description}`),
+      ].join("\n");
+    }
+
+    return [
+      "上一次状态结算未通过校验。请对照正文修正以下矛盾:",
+      ...warnings.map((warning) => `- [${warning.category}] ${warning.description}`),
+    ].join("\n");
+  }
+
+  private buildStateDegradedIssues(
+    warnings: ReadonlyArray<ValidationWarning>,
+    language: LengthLanguage,
+  ): ReadonlyArray<AuditIssue> {
+    if (warnings.length > 0) {
+      return warnings.map((warning) => ({
+        severity: "warning" as const,
+        category: "state-validation",
+        description: warning.description,
+        suggestion: language === "en"
+          ? "Repair chapter state from the persisted body before continuing."
+          : "请先基于已保存正文修复本章 state,再继续后续章节。",
+      }));
+    }
+
+    return [{
+      severity: "warning",
+      category: "state-validation",
+      description: language === "en"
+        ? "State validation still failed after settlement retry."
+        : "状态结算重试后仍未通过校验。",
+      suggestion: language === "en"
+        ? "Repair chapter state from the persisted body before continuing."
+        : "请先基于已保存正文修复本章 state,再继续后续章节。",
+    }];
+  }
+
+  private buildStateDegradedPersistenceOutput(params: {
+    readonly output: WriteChapterOutput;
+    readonly oldState: string;
+    readonly oldHooks: string;
+    readonly oldLedger: string;
+  }): WriteChapterOutput {
+    return {
+      ...params.output,
+      runtimeStateDelta: undefined,
+      runtimeStateSnapshot: undefined,
+      updatedState: params.oldState,
+      updatedLedger: params.oldLedger,
+      updatedHooks: params.oldHooks,
+      updatedChapterSummaries: undefined,
+    };
+  }
+
+  private buildStateDegradedReviewNote(
+    baseStatus: "ready-for-review" | "audit-failed",
+    issues: ReadonlyArray<AuditIssue>,
+  ): string {
+    return JSON.stringify({
+      kind: "state-degraded",
+      baseStatus,
+      injectedIssues: issues.map((issue) => `[${issue.severity}] ${issue.description}`),
+    });
+  }
+
+  private parseStateDegradedReviewNote(reviewNote?: string): {
+    readonly kind: "state-degraded";
+    readonly baseStatus: "ready-for-review" | "audit-failed";
+    readonly injectedIssues: ReadonlyArray<string>;
+  } | null {
+    if (!reviewNote) {
+      return null;
+    }
+
+    try {
+      const parsed = JSON.parse(reviewNote) as {
+        kind?: unknown;
+        baseStatus?: unknown;
+        injectedIssues?: unknown;
+      };
+      if (
+        parsed.kind !== "state-degraded"
+        || (parsed.baseStatus !== "ready-for-review" && parsed.baseStatus !== "audit-failed")
+        || !Array.isArray(parsed.injectedIssues)
+      ) {
+        return null;
+      }
+
+      return {
+        kind: "state-degraded",
+        baseStatus: parsed.baseStatus,
+        injectedIssues: parsed.injectedIssues.filter((issue): issue is string => typeof issue === "string"),
+      };
+    } catch {
+      return null;
+    }
+  }
+
+  private resolveStateDegradedBaseStatus(chapter: ChapterMeta): "ready-for-review" | "audit-failed" {
+    const metadata = this.parseStateDegradedReviewNote(chapter.reviewNote);
+    if (metadata) {
+      return metadata.baseStatus;
+    }
+
+    return chapter.auditIssues.some((issue) => issue.startsWith("[critical]"))
+      ? "audit-failed"
+      : "ready-for-review";
+  }
+
   // ---------------------------------------------------------------------------
   // Helpers
   // ---------------------------------------------------------------------------