Przeglądaj źródła

fix(state): allow repair-state to reapply latest chapter

Ma 7 miesięcy temu
rodzic
commit
85dd250dd3

+ 3 - 0
packages/core/src/__tests__/chapter-state-recovery.test.ts

@@ -126,6 +126,9 @@ describe("chapter-state-recovery", () => {
     expect(result.kind).toBe("recovered");
     expect(capturedFeedback).toContain("上一次状态结算未通过校验");
     expect(capturedFeedback).toContain("铜牌位置与正文矛盾");
+    expect(writer.settleChapterState).toHaveBeenCalledWith(expect.objectContaining({
+      allowReapply: true,
+    }));
     expect(logWarn).toHaveBeenCalledWith(expect.objectContaining({
       zh: expect.stringContaining("仅重试结算层"),
     }));

+ 4 - 1
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -2457,7 +2457,7 @@ describe("PipelineRunner", () => {
       }]),
     ]);
 
-    vi.spyOn(
+    const settleSpy = vi.spyOn(
       WriterAgent.prototype as unknown as {
         settleChapterState: (input: Record<string, unknown>) => Promise<WriteChapterOutput>;
       },
@@ -2490,6 +2490,9 @@ describe("PipelineRunner", () => {
 
     expect(result.status).toBe("ready-for-review");
     expect(result.chapterNumber).toBe(1);
+    expect(settleSpy).toHaveBeenCalledWith(expect.objectContaining({
+      allowReapply: true,
+    }));
     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");

+ 64 - 0
packages/core/src/__tests__/state-reducer.test.ts

@@ -158,6 +158,70 @@ describe("applyRuntimeStateDelta", () => {
     ).toThrow(/duplicate summary/i);
   });
 
+  it("allows reapplying the same chapter when explicitly enabled", () => {
+    const result = applyRuntimeStateDelta({
+      snapshot: {
+        manifest: {
+          schemaVersion: 2,
+          language: "zh",
+          lastAppliedChapter: 12,
+          projectionVersion: 1,
+          migrationWarnings: [],
+        },
+        currentState: {
+          chapter: 12,
+          facts: [],
+        },
+        hooks: {
+          hooks: [],
+        },
+        chapterSummaries: {
+          rows: [
+            {
+              chapter: 12,
+              title: "旧版河埠对账",
+              characters: "林月",
+              events: "旧摘要。",
+              stateChanges: "旧变化。",
+              hookActivity: "旧钩子",
+              mood: "紧绷",
+              chapterType: "主线推进",
+            },
+          ],
+        },
+      },
+      delta: RuntimeStateDeltaSchema.parse({
+        chapter: 12,
+        hookOps: {
+          upsert: [],
+          resolve: [],
+          defer: [],
+        },
+        chapterSummary: {
+          chapter: 12,
+          title: "新版河埠对账",
+          characters: "林月",
+          events: "新摘要。",
+          stateChanges: "新变化。",
+          hookActivity: "新钩子",
+          mood: "压抑",
+          chapterType: "修订",
+        },
+        notes: [],
+      }),
+      allowReapply: true,
+    });
+
+    expect(result.manifest.lastAppliedChapter).toBe(12);
+    expect(result.chapterSummaries.rows).toEqual([
+      expect.objectContaining({
+        chapter: 12,
+        title: "新版河埠对账",
+        events: "新摘要。",
+      }),
+    ]);
+  });
+
   it("ignores resolve and defer operations for unknown hooks", () => {
     const result = applyRuntimeStateDelta({
       snapshot: {

+ 4 - 0
packages/core/src/agents/writer.ts

@@ -57,6 +57,7 @@ export interface SettleChapterStateInput {
   readonly chapterNumber: number;
   readonly title: string;
   readonly content: string;
+  readonly allowReapply?: boolean;
   readonly chapterIntent?: string;
   readonly contextPackage?: ContextPackage;
   readonly ruleStack?: RuleStack;
@@ -469,6 +470,7 @@ export class WriterAgent extends BaseAgent {
       settlement.runtimeStateDelta,
       resolvedLanguage,
       input.chapterNumber,
+      input.allowReapply,
     );
 
     return {
@@ -1151,6 +1153,7 @@ ${overrides}\n`;
     delta: RuntimeStateDelta | undefined,
     language: "zh" | "en",
     authoritativeChapterNumber?: number,
+    allowReapply?: boolean,
   ): Promise<RuntimeStateArtifacts | null> {
     if (!delta) return null;
     const safeDelta = authoritativeChapterNumber === undefined
@@ -1160,6 +1163,7 @@ ${overrides}\n`;
       bookDir,
       delta: safeDelta,
       language,
+      allowReapply,
     });
   }
 

+ 1 - 0
packages/core/src/pipeline/chapter-state-recovery.ts

@@ -58,6 +58,7 @@ export async function retrySettlementAfterValidationFailure(
     chapterNumber: params.chapterNumber,
     title: params.title,
     content: params.content,
+    allowReapply: true,
     chapterIntent: params.reducedControlInput?.chapterIntent,
     contextPackage: params.reducedControlInput?.contextPackage,
     ruleStack: params.reducedControlInput?.ruleStack,

+ 1 - 0
packages/core/src/pipeline/runner.ts

@@ -1498,6 +1498,7 @@ export class PipelineRunner {
       chapterNumber: targetChapter,
       title: targetMeta.title,
       content,
+      allowReapply: true,
     });
     const validator = new StateValidatorAgent(this.agentCtxFor("state-validator", bookId));
     let validation = await validator.validate(

+ 2 - 0
packages/core/src/state/runtime-state-store.ts

@@ -60,6 +60,7 @@ export async function buildRuntimeStateArtifacts(params: {
   readonly bookDir: string;
   readonly delta: RuntimeStateDelta;
   readonly language: "zh" | "en";
+  readonly allowReapply?: boolean;
 }): Promise<RuntimeStateArtifacts> {
   const snapshot = await loadRuntimeStateSnapshot(params.bookDir);
   const { resolvedDelta } = arbitrateRuntimeStateDeltaHooks({
@@ -69,6 +70,7 @@ export async function buildRuntimeStateArtifacts(params: {
   const next = applyRuntimeStateDelta({
     snapshot,
     delta: resolvedDelta,
+    allowReapply: params.allowReapply,
   });
 
   return {

+ 10 - 3
packages/core/src/state/state-reducer.ts

@@ -25,6 +25,7 @@ export interface RuntimeStateSnapshot {
 export function applyRuntimeStateDelta(params: {
   readonly snapshot: RuntimeStateSnapshot;
   readonly delta: RuntimeStateDelta;
+  readonly allowReapply?: boolean;
 }): RuntimeStateSnapshot {
   const snapshot = {
     manifest: StateManifestSchema.parse(params.snapshot.manifest),
@@ -33,8 +34,9 @@ export function applyRuntimeStateDelta(params: {
     chapterSummaries: ChapterSummariesStateSchema.parse(params.snapshot.chapterSummaries),
   };
   const delta = RuntimeStateDeltaSchema.parse(params.delta);
+  const allowReapply = params.allowReapply ?? false;
 
-  if (delta.chapter <= snapshot.manifest.lastAppliedChapter) {
+  if (allowReapply ? delta.chapter < snapshot.manifest.lastAppliedChapter : delta.chapter <= snapshot.manifest.lastAppliedChapter) {
     throw new Error(`delta chapter ${delta.chapter} goes backwards`);
   }
 
@@ -45,6 +47,7 @@ export function applyRuntimeStateDelta(params: {
   if (
     delta.chapterSummary
     && snapshot.chapterSummaries.rows.some((row) => row.chapter === delta.chapterSummary?.chapter)
+    && !allowReapply
   ) {
     throw new Error(`duplicate summary row for chapter ${delta.chapterSummary.chapter}`);
   }
@@ -55,7 +58,7 @@ export function applyRuntimeStateDelta(params: {
     snapshot.manifest.language,
     delta,
   );
-  const chapterSummaries = applySummaryDelta(snapshot.chapterSummaries, delta);
+  const chapterSummaries = applySummaryDelta(snapshot.chapterSummaries, delta, allowReapply);
 
   const next: RuntimeStateSnapshot = {
     manifest: {
@@ -240,6 +243,7 @@ function applyCurrentStatePatch(
 function applySummaryDelta(
   state: ChapterSummariesState,
   delta: RuntimeStateDelta,
+  allowReapply = false,
 ): ChapterSummariesState {
   if (!delta.chapterSummary) {
     return {
@@ -248,6 +252,9 @@ function applySummaryDelta(
   }
 
   return {
-    rows: [...state.rows, delta.chapterSummary].sort((left, right) => left.chapter - right.chapter),
+    rows: [
+      ...(allowReapply ? state.rows.filter((row) => row.chapter !== delta.chapterSummary!.chapter) : state.rows),
+      delta.chapterSummary,
+    ].sort((left, right) => left.chapter - right.chapter),
   };
 }