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"]);