Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,15 +272,18 @@ 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,
cwd,
modelId,
outputMode,
openWikiSnapshotBefore,
"interrupted",
);
emitDebug(
options,
Expand Down
3 changes: 3 additions & 0 deletions src/agent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
25 changes: 22 additions & 3 deletions src/agent/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
OpenWikiRunOptions,
RunContext,
UpdateMetadata,
UpdateRunStatus,
} from "./types.js";
import type { Dirent } from "node:fs";

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -139,20 +144,24 @@ 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<void> {
const metadataFile = getMetadataFilePath(cwd, outputMode);
const metadata: UpdateMetadata = {
updatedAt: new Date().toISOString(),
command,
gitHead: outputMode === "repository" ? await getGitHead(cwd) : undefined,
model: modelId,
status,
};

await mkdir(path.dirname(metadataFile), { recursive: true });
Expand All @@ -174,6 +183,7 @@ export async function persistRunMetadataIfChanged(
modelId: string,
outputMode: OpenWikiOutputMode,
snapshotBefore: OpenWikiContentSnapshot | null,
status: UpdateRunStatus = "complete",
): Promise<boolean> {
if (command === "chat" || snapshotBefore === null) {
return false;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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",
};
}

Expand Down
101 changes: 101 additions & 0 deletions test/run-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
32 changes: 31 additions & 1 deletion test/update-noop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,19 @@ async function createRepoWithOpenWiki(): Promise<string> {
return repo;
}

async function writeLastUpdate(repo: string, gitHead: string): Promise<void> {
async function writeLastUpdate(
repo: string,
gitHead: string,
extraFields: Record<string, unknown> = {},
): Promise<void> {
await writeFile(
path.join(repo, "openwiki", ".last-update.json"),
`${JSON.stringify({
updatedAt: new Date().toISOString(),
command: "update",
gitHead,
model: "test-model",
...extraFields,
})}\n`,
"utf8",
);
Expand Down Expand Up @@ -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"]);
Expand Down