From e9cc3e89ea152c70e40277f4b71b843f3361eaed Mon Sep 17 00:00:00 2001 From: Wenyu Date: Thu, 16 Jul 2026 06:01:55 -0400 Subject: [PATCH] fix: record interrupted runs so the update no-op check retries them Since #277, a run that fails mid-stream still persists .last-update.json so already-generated content stays diffable. That left a crashed run indistinguishable from a completed one: the next update sees a clean worktree with an unchanged git head and skips as a no-op, so a possibly partial wiki is treated as current until an unrelated commit lands. Update metadata now records a run status. The failure path writes status "interrupted", and getUpdateNoopStatus no longer skips when the previous run was interrupted. Metadata written by older versions has no status field and is treated as complete, so upgrades do not force a spurious re-run. A completed retry that changes no content still rewrites the metadata to clear a leftover interrupted status, so the no-op skip recovers instead of re-running forever. Adds five regression tests; three fail against the previous code. Co-Authored-By: Claude Fable 5 --- src/agent/index.ts | 7 ++- src/agent/types.ts | 3 ++ src/agent/utils.ts | 25 ++++++++-- test/run-metadata.test.ts | 101 ++++++++++++++++++++++++++++++++++++++ test/update-noop.test.ts | 32 +++++++++++- 5 files changed, 162 insertions(+), 6 deletions(-) diff --git a/src/agent/index.ts b/src/agent/index.ts index 15687fa8..b7e3b9e1 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -272,8 +272,10 @@ async function runOpenWikiAgentCore( emitDebug(options, "stream=completed"); } catch (error) { // Persist metadata even when the stream fails late, so content that was - // already generated stays diffable by future updates. Persistence errors - // are swallowed here so the original run error propagates. + // already generated stays diffable by future updates. The run is recorded + // as interrupted so the next update is not skipped as a no-op against a + // possibly partial wiki. Persistence errors are swallowed here so the + // original run error propagates. try { const metadataWritten = await persistRunMetadataIfChanged( command, @@ -281,6 +283,7 @@ async function runOpenWikiAgentCore( modelId, outputMode, openWikiSnapshotBefore, + "interrupted", ); emitDebug( options, diff --git a/src/agent/types.ts b/src/agent/types.ts index aa9bcf63..22e10b39 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -41,11 +41,14 @@ export type OpenWikiRunOptions = { userMessage?: string | null; }; +export type UpdateRunStatus = "complete" | "interrupted"; + export type UpdateMetadata = { updatedAt: string; command: OpenWikiCommand; gitHead?: string; model: string; + status?: UpdateRunStatus; }; export type RunContext = { diff --git a/src/agent/utils.ts b/src/agent/utils.ts index 0432482d..656d67e8 100644 --- a/src/agent/utils.ts +++ b/src/agent/utils.ts @@ -18,6 +18,7 @@ import type { OpenWikiRunOptions, RunContext, UpdateMetadata, + UpdateRunStatus, } from "./types.js"; import type { Dirent } from "node:fs"; @@ -92,6 +93,10 @@ export async function getUpdateNoopStatus( return { shouldSkip: false, reason: "missing previous update git head" }; } + if (lastUpdate.status === "interrupted") { + return { shouldSkip: false, reason: "previous update was interrupted" }; + } + const head = await getGitHead(cwd); if (!head) { @@ -139,13 +144,16 @@ export function shouldCheckUpdateNoop(options: OpenWikiRunOptions): boolean { } /** - * Records a successful init/update run so future updates can diff from this git head. + * Records an init/update run so future updates can diff from this git head. + * Interrupted runs are recorded with status "interrupted" so the update + * no-op check knows the wiki may be partial and does not skip the retry. */ export async function writeLastUpdateMetadata( command: OpenWikiCommand, cwd: string, modelId: string, outputMode: OpenWikiOutputMode = "repository", + status: UpdateRunStatus = "complete", ): Promise { const metadataFile = getMetadataFilePath(cwd, outputMode); const metadata: UpdateMetadata = { @@ -153,6 +161,7 @@ export async function writeLastUpdateMetadata( command, gitHead: outputMode === "repository" ? await getGitHead(cwd) : undefined, model: modelId, + status, }; await mkdir(path.dirname(metadataFile), { recursive: true }); @@ -174,6 +183,7 @@ export async function persistRunMetadataIfChanged( modelId: string, outputMode: OpenWikiOutputMode, snapshotBefore: OpenWikiContentSnapshot | null, + status: UpdateRunStatus = "complete", ): Promise { if (command === "chat" || snapshotBefore === null) { return false; @@ -182,10 +192,15 @@ export async function persistRunMetadataIfChanged( if ( snapshotBefore === (await createOpenWikiContentSnapshot(cwd, outputMode)) ) { - return false; + // A completed run clears a previous interrupted status even when the + // content did not change, so the update no-op check can skip again. + const lastUpdate = await readLastUpdate(cwd, outputMode); + if (status !== "complete" || lastUpdate?.status !== "interrupted") { + return false; + } } - await writeLastUpdateMetadata(command, cwd, modelId, outputMode); + await writeLastUpdateMetadata(command, cwd, modelId, outputMode, status); return true; } @@ -231,6 +246,10 @@ async function readLastUpdate( ? parsedMetadata.gitHead : undefined, model: parsedMetadata.model, + // Metadata written before the status field existed is treated as + // complete so upgrades do not force a spurious re-run. + status: + parsedMetadata.status === "interrupted" ? "interrupted" : "complete", }; } diff --git a/test/run-metadata.test.ts b/test/run-metadata.test.ts index 1fbf5286..389d6bb0 100644 --- a/test/run-metadata.test.ts +++ b/test/run-metadata.test.ts @@ -48,6 +48,107 @@ describe("persistRunMetadataIfChanged", () => { expect(metadata).not.toBeNull(); expect(metadata?.command).toBe("init"); expect(metadata?.model).toBe("test-model"); + expect(metadata?.status).toBe("complete"); + }); + + test("records status interrupted when a failed run persists metadata", async () => { + const cwd = await createTempRepo(); + const snapshotBefore = await createOpenWikiContentSnapshot( + cwd, + "repository", + ); + + await mkdir(path.join(cwd, "openwiki"), { recursive: true }); + await writeFile(path.join(cwd, "openwiki", "index.md"), "# Docs\n", "utf8"); + + const written = await persistRunMetadataIfChanged( + "update", + cwd, + "test-model", + "repository", + snapshotBefore, + "interrupted", + ); + + expect(written).toBe(true); + const metadata = await readMetadata(cwd, "openwiki/.last-update.json"); + expect(metadata?.status).toBe("interrupted"); + }); + + test("clears an interrupted status when a completed run changes nothing", async () => { + const cwd = await createTempRepo(); + + await mkdir(path.join(cwd, "openwiki"), { recursive: true }); + await writeFile(path.join(cwd, "openwiki", "index.md"), "# Docs\n", "utf8"); + const interruptedSnapshot = await createOpenWikiContentSnapshot( + cwd, + "repository", + ); + await writeFile( + path.join(cwd, "openwiki", "index.md"), + "# Fixed\n", + "utf8", + ); + await persistRunMetadataIfChanged( + "update", + cwd, + "test-model", + "repository", + interruptedSnapshot, + "interrupted", + ); + + // Retry run completes without writing anything: the snapshot is + // unchanged, but the interrupted flag must still be cleared so the + // update no-op check can skip again. + const retrySnapshot = await createOpenWikiContentSnapshot( + cwd, + "repository", + ); + const written = await persistRunMetadataIfChanged( + "update", + cwd, + "test-model", + "repository", + retrySnapshot, + ); + + expect(written).toBe(true); + const metadata = await readMetadata(cwd, "openwiki/.last-update.json"); + expect(metadata?.status).toBe("complete"); + }); + + test("does not rewrite metadata when nothing changed after a complete run", async () => { + const cwd = await createTempRepo(); + + await mkdir(path.join(cwd, "openwiki"), { recursive: true }); + await writeFile(path.join(cwd, "openwiki", "index.md"), "# Docs\n", "utf8"); + const snapshotBefore = await createOpenWikiContentSnapshot( + cwd, + "repository", + ); + await writeFile(path.join(cwd, "openwiki", "index.md"), "# Done\n", "utf8"); + await persistRunMetadataIfChanged( + "update", + cwd, + "test-model", + "repository", + snapshotBefore, + ); + + const unchangedSnapshot = await createOpenWikiContentSnapshot( + cwd, + "repository", + ); + const written = await persistRunMetadataIfChanged( + "update", + cwd, + "test-model", + "repository", + unchangedSnapshot, + ); + + expect(written).toBe(false); }); test("writes metadata in local-wiki mode when content changed", async () => { diff --git a/test/update-noop.test.ts b/test/update-noop.test.ts index e530b1d2..632cd4d0 100644 --- a/test/update-noop.test.ts +++ b/test/update-noop.test.ts @@ -33,7 +33,11 @@ async function createRepoWithOpenWiki(): Promise { return repo; } -async function writeLastUpdate(repo: string, gitHead: string): Promise { +async function writeLastUpdate( + repo: string, + gitHead: string, + extraFields: Record = {}, +): Promise { await writeFile( path.join(repo, "openwiki", ".last-update.json"), `${JSON.stringify({ @@ -41,6 +45,7 @@ async function writeLastUpdate(repo: string, gitHead: string): Promise { command: "update", gitHead, model: "test-model", + ...extraFields, })}\n`, "utf8", ); @@ -89,6 +94,31 @@ describe("getUpdateNoopStatus", () => { expect(status.shouldSkip).toBe(true); }); + test("does not skip update when the previous run was interrupted", async () => { + const repo = await createRepoWithOpenWiki(); + const head = await git(repo, ["rev-parse", "HEAD"]); + await writeLastUpdate(repo, head, { status: "interrupted" }); + + const status = await getUpdateNoopStatus(repo); + + expect(status).toEqual({ + shouldSkip: false, + reason: "previous update was interrupted", + }); + }); + + test("skips update when the previous complete run predates the status field", async () => { + // Metadata written by versions without the status field must keep + // behaving as a completed run and not force a spurious re-run. + const repo = await createRepoWithOpenWiki(); + const head = await git(repo, ["rev-parse", "HEAD"]); + await writeLastUpdate(repo, head); + + const status = await getUpdateNoopStatus(repo); + + expect(status.shouldSkip).toBe(true); + }); + test("does not skip update when commits since the last run touch source files", async () => { const repo = await createRepoWithOpenWiki(); const head = await git(repo, ["rev-parse", "HEAD"]);