diff --git a/README.md b/README.md index 8c1bed7..3dbf66e 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,8 @@ Ordinary fsync improves crash consistency but is not `F_FULLFSYNC`, so sudden po If the rename succeeds but syncing the parent directory reports a genuine I/O error, the mutation reports a write failure even though the new valid state may already be present. This avoids claiming durability that the filesystem did not confirm. +If a non-empty state file contains only whitespace, a UTF-8 BOM, or NUL bytes after an interrupted write, the next mutation preserves its exact contents beside the state file as `goals.json.corrupt--` before writing recovered state. If another process replaces the state during recovery, the mutation refuses to overwrite that newer content. If the quarantine copy itself cannot be created, the plugin reports the failure and continues recovery rather than making every prompt fail indefinitely. OpenCode 1 also records the quarantine outcome and path through its application log so the data-loss event remains discoverable. + ## Credits This plugin follows Codex's native goal-mode semantics where OpenCode plugin hooks allow it. Several hardening ideas were adapted from William Ricchiuti's [`willytop8/OpenCode-goal-plugin`](https://github.com/willytop8/OpenCode-goal-plugin), especially lifecycle history, checkpoints, no-progress safeguards, budget wrap-up behavior, and strict-provider-safe system prompt merging. Thank you, William. diff --git a/dist/server.js b/dist/server.js index 5a93652..680cf7f 100644 --- a/dist/server.js +++ b/dist/server.js @@ -3,6 +3,7 @@ import { z } from "zod"; // src/state.ts +import { randomUUID as randomUUID2 } from "crypto"; import { mkdir, readFile } from "fs/promises"; import { homedir } from "os"; import { dirname as dirname2, join } from "path"; @@ -220,6 +221,23 @@ function mutableState(state) { return JSON.parse(JSON.stringify(state)); } var warnedEmptyStatePaths = new Set; +var stateRecoveryListeners = new Set; +function onStateRecovery(stateFile, report) { + const listener = { stateFile, report }; + stateRecoveryListeners.add(listener); + return () => stateRecoveryListeners.delete(listener); +} +function notifyStateRecovery(notice) { + for (const listener of stateRecoveryListeners) { + if (listener.stateFile !== notice.stateFile) + continue; + Promise.resolve().then(() => listener.report(notice)).catch((error) => { + try { + console.error(`[opencode-goal-plugin] Failed to report quarantined state at ${notice.quarantineFile}:`, error instanceof Error ? error.message : String(error)); + } catch {} + }); + } +} function isStatePadding(character) { return character === "\x00" || character.trim() === ""; } @@ -232,24 +250,53 @@ function parseStateText(raw, file) { end -= 1; const content = raw.slice(start, end); if (content) - return JSON.parse(content); + return { value: JSON.parse(content), recoveryContent: null }; if (!warnedEmptyStatePaths.has(file)) { warnedEmptyStatePaths.add(file); console.warn(`[opencode-goal-plugin] Empty or zero-filled state file at ${file}; recovering with empty state.`); } - return emptyState(); + return { value: emptyState(), recoveryContent: raw || null }; } function decodeState(value) { return Schema.decodeUnknown(StateSchema)(value).pipe(Effect.map(mutableState), Effect.map(normalizeState), Effect.mapError((cause) => new StateDecodeError({ cause }))); } -function readStateEffect(file = statePath()) { +function readStateResultEffect(file = statePath()) { return Effect.tryPromise({ try: () => readFile(file, "utf8"), catch: (cause) => new StateReadError({ cause }) }).pipe(Effect.flatMap((raw) => Effect.try({ try: () => parseStateText(raw, file), catch: (cause) => new StateDecodeError({ cause }) - })), Effect.flatMap(decodeState), Effect.catchAll((error) => error._tag === "StateReadError" && isMissingStateFile(error.cause) ? Effect.succeed(emptyState()) : Effect.fail(error))); + })), Effect.flatMap(({ value, recoveryContent }) => decodeState(value).pipe(Effect.map((state) => ({ state, recoveryContent })))), Effect.catchAll((error) => error._tag === "StateReadError" && isMissingStateFile(error.cause) ? Effect.succeed({ state: emptyState(), recoveryContent: null }) : Effect.fail(error))); +} +function readStateEffect(file = statePath()) { + return readStateResultEffect(file).pipe(Effect.map(({ state }) => state)); +} +function quarantineStateEffect(file, content) { + return Effect.promise(async () => { + const quarantineFile = `${file}.corrupt-${Date.now()}-${randomUUID2()}`; + try { + await mkdir(dirname2(file), { recursive: true, mode: 448 }); + await atomicWriteFile(quarantineFile, content); + return { quarantineFile, error: null }; + } catch (error) { + return { quarantineFile, error: error instanceof Error ? error.message : String(error) }; + } + }); +} +function verifyRecoverySourceEffect(file, expectedContent, quarantineFile) { + return Effect.promise(async () => { + try { + return await readFile(file, "utf8") === expectedContent; + } catch (error) { + if (!isMissingStateFile(error)) { + try { + console.error(`[opencode-goal-plugin] Could not re-read ${file} after preserving it at ${quarantineFile}; continuing recovery:`, error instanceof Error ? error.message : String(error)); + } catch {} + } + return true; + } + }); } function writeStateEffect(state, file = statePath()) { return Effect.tryPromise({ @@ -278,11 +325,46 @@ async function mutate(fn) { return enqueueMutation(() => { const file = statePath(); return Effect.runPromise(Effect.gen(function* () { - const state = yield* readStateEffect(file); + const { state, recoveryContent } = yield* readStateResultEffect(file); const result = yield* Effect.tryPromise({ try: () => Promise.resolve(fn(state)), catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)) }); + if (recoveryContent != null) { + const quarantine = yield* quarantineStateEffect(file, recoveryContent); + if (quarantine.error != null) { + const notice = { + stateFile: file, + quarantineFile: quarantine.quarantineFile, + outcome: "quarantineFailed", + error: quarantine.error + }; + try { + console.error(`[opencode-goal-plugin] Could not quarantine corrupt state at ${file}; continuing recovery:`, quarantine.error); + } catch {} + notifyStateRecovery(notice); + } else { + const unchanged = yield* verifyRecoverySourceEffect(file, recoveryContent, quarantine.quarantineFile); + if (!unchanged) { + const message = "goal state changed while recovery was being quarantined; refusing to overwrite it"; + notifyStateRecovery({ + stateFile: file, + quarantineFile: quarantine.quarantineFile, + outcome: "sourceChanged", + error: message + }); + return yield* Effect.fail(new StateWriteError({ cause: new Error(message) })); + } + try { + console.warn(`[opencode-goal-plugin] Preserved corrupt state from ${file} at ${quarantine.quarantineFile}; continuing recovery.`); + } catch {} + notifyStateRecovery({ + stateFile: file, + quarantineFile: quarantine.quarantineFile, + outcome: "quarantined" + }); + } + } yield* writeStateEffect(state, file); return result; })); @@ -1932,6 +2014,16 @@ var server = async ({ client }, options) => { const planAgents = restrictedAgentSet(options); const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase()); const goalServices = { options: options ?? {}, isPlanAgent }; + const stopStateRecoveryReporting = onStateRecovery(statePath(), async ({ stateFile, quarantineFile, outcome, error }) => { + await client.app?.log?.({ + body: { + service: "opencode-goal-plugin", + level: "error", + message: outcome === "quarantined" ? "Corrupt goal state quarantined before recovery" : outcome === "sourceChanged" ? "Goal state changed during recovery; refusing to overwrite it" : "Corrupt goal state could not be quarantined; continuing recovery", + extra: { stateFile, quarantineFile, outcome, ...error ? { error } : {} } + } + }); + }); let disposed = false; async function taskBlockStatus(sessionID) { if (!deferWhileTasksActive) @@ -2180,6 +2272,7 @@ var server = async ({ client }, options) => { return { async dispose() { disposed = true; + stopStateRecoveryReporting(); for (const scheduled of scheduledContinuations.values()) clearTimeout(scheduled.timer); scheduledContinuations.clear(); diff --git a/src/server.ts b/src/server.ts index 12f7cdb..16c1b5d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -15,6 +15,7 @@ import { getGoal, getGoalInternal, markGoalUnmet, + onStateRecovery, pauseGoalForPlanMode, PLAN_MODE_STOP_REASON, recordAssistantProgress, @@ -25,6 +26,7 @@ import { reserveContinuation, rollbackContinuationAttempt, setGoalStatus, + statePath, updateGoalObjective, validateObjective, } from "./state" @@ -958,6 +960,21 @@ const server: Plugin = async ({ client }, options?: Options) => { const planAgents = restrictedAgentSet(options) const isPlanAgent = (agent: unknown) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase()) const goalServices: GoalServices = { options: options ?? {}, isPlanAgent } + const stopStateRecoveryReporting = onStateRecovery(statePath(), async ({ stateFile, quarantineFile, outcome, error }) => { + await client.app?.log?.({ + body: { + service: "opencode-goal-plugin", + level: "error", + message: + outcome === "quarantined" + ? "Corrupt goal state quarantined before recovery" + : outcome === "sourceChanged" + ? "Goal state changed during recovery; refusing to overwrite it" + : "Corrupt goal state could not be quarantined; continuing recovery", + extra: { stateFile, quarantineFile, outcome, ...(error ? { error } : {}) }, + }, + }) + }) // Set by dispose so in-flight operations triggered before disposal cannot // schedule new timers or invoke continuations afterward. let disposed = false @@ -1250,6 +1267,7 @@ const server: Plugin = async ({ client }, options?: Options) => { return { async dispose() { disposed = true + stopStateRecoveryReporting() for (const scheduled of scheduledContinuations.values()) clearTimeout(scheduled.timer) scheduledContinuations.clear() for (const watchdog of turnWatchdogs.values()) clearTimeout(watchdog.timer) diff --git a/src/state.ts b/src/state.ts index 63b68c2..0808e17 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto" import { readFileSync } from "node:fs" import { mkdir, readFile } from "node:fs/promises" import { homedir } from "node:os" @@ -290,12 +291,47 @@ function mutableState(state: Schema.Schema.Type): State { } const warnedEmptyStatePaths = new Set() +export type StateRecoveryNotice = { + stateFile: string + quarantineFile: string + outcome: "quarantined" | "quarantineFailed" | "sourceChanged" + error?: string +} +type StateRecoveryListener = { + stateFile: string + report: (notice: StateRecoveryNotice) => Promise | void +} +const stateRecoveryListeners = new Set() + +export function onStateRecovery(stateFile: string, report: StateRecoveryListener["report"]) { + const listener = { stateFile, report } + stateRecoveryListeners.add(listener) + return () => stateRecoveryListeners.delete(listener) +} + +function notifyStateRecovery(notice: StateRecoveryNotice) { + for (const listener of stateRecoveryListeners) { + if (listener.stateFile !== notice.stateFile) continue + void Promise.resolve() + .then(() => listener.report(notice)) + .catch((error) => { + try { + console.error( + `[opencode-goal-plugin] Failed to report quarantined state at ${notice.quarantineFile}:`, + error instanceof Error ? error.message : String(error), + ) + } catch { + // Reporting must never block state recovery. + } + }) + } +} function isStatePadding(character: string) { return character === "\0" || character.trim() === "" } -function parseStateText(raw: string, file: string): unknown { +function parseStateText(raw: string, file: string) { // trim handles whitespace and UTF-8 BOMs. NUL padding can remain after an // interrupted filesystem write, so tolerate it only at the file boundaries. let start = 0 @@ -303,13 +339,13 @@ function parseStateText(raw: string, file: string): unknown { while (start < end && isStatePadding(raw[start]!)) start += 1 while (end > start && isStatePadding(raw[end - 1]!)) end -= 1 const content = raw.slice(start, end) - if (content) return JSON.parse(content) as unknown + if (content) return { value: JSON.parse(content) as unknown, recoveryContent: null } if (!warnedEmptyStatePaths.has(file)) { warnedEmptyStatePaths.add(file) console.warn(`[opencode-goal-plugin] Empty or zero-filled state file at ${file}; recovering with empty state.`) } - return emptyState() + return { value: emptyState(), recoveryContent: raw || null } } function decodeState(value: unknown) { @@ -320,7 +356,7 @@ function decodeState(value: unknown) { ) } -function readStateEffect(file = statePath()) { +function readStateResultEffect(file = statePath()) { return Effect.tryPromise({ try: () => readFile(file, "utf8"), catch: (cause) => new StateReadError({ cause }), @@ -331,13 +367,54 @@ function readStateEffect(file = statePath()) { catch: (cause) => new StateDecodeError({ cause }), }), ), - Effect.flatMap(decodeState), + Effect.flatMap(({ value, recoveryContent }) => + decodeState(value).pipe(Effect.map((state) => ({ state, recoveryContent }))), + ), Effect.catchAll((error) => - error._tag === "StateReadError" && isMissingStateFile(error.cause) ? Effect.succeed(emptyState()) : Effect.fail(error), + error._tag === "StateReadError" && isMissingStateFile(error.cause) + ? Effect.succeed({ state: emptyState(), recoveryContent: null }) + : Effect.fail(error), ), ) } +function readStateEffect(file = statePath()) { + return readStateResultEffect(file).pipe(Effect.map(({ state }) => state)) +} + +function quarantineStateEffect(file: string, content: string) { + return Effect.promise(async () => { + const quarantineFile = `${file}.corrupt-${Date.now()}-${randomUUID()}` + try { + await mkdir(dirname(file), { recursive: true, mode: 0o700 }) + await atomicWriteFile(quarantineFile, content) + return { quarantineFile, error: null } + } catch (error) { + return { quarantineFile, error: error instanceof Error ? error.message : String(error) } + } + }) +} + +function verifyRecoverySourceEffect(file: string, expectedContent: string, quarantineFile: string) { + return Effect.promise(async () => { + try { + return (await readFile(file, "utf8")) === expectedContent + } catch (error) { + if (!isMissingStateFile(error)) { + try { + console.error( + `[opencode-goal-plugin] Could not re-read ${file} after preserving it at ${quarantineFile}; continuing recovery:`, + error instanceof Error ? error.message : String(error), + ) + } catch { + // Diagnostics must never block state recovery. + } + } + return true + } + }) +} + function writeStateEffect(state: State, file = statePath()) { return Effect.tryPromise({ try: async () => { @@ -366,7 +443,7 @@ function readStateSync(): State { try { const file = statePath() const raw = readFileSync(file, "utf8") - return normalizeState(mutableState(Schema.decodeUnknownSync(StateSchema)(parseStateText(raw, file)))) + return normalizeState(mutableState(Schema.decodeUnknownSync(StateSchema)(parseStateText(raw, file).value))) } catch (error) { if (isMissingStateFile(error)) return emptyState() throw error @@ -389,11 +466,55 @@ async function mutate(fn: (state: State) => T | Promise) { const file = statePath() return Effect.runPromise( Effect.gen(function* () { - const state = yield* readStateEffect(file) + const { state, recoveryContent } = yield* readStateResultEffect(file) const result = yield* Effect.tryPromise({ try: () => Promise.resolve(fn(state)), catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), }) + if (recoveryContent != null) { + const quarantine = yield* quarantineStateEffect(file, recoveryContent) + if (quarantine.error != null) { + const notice: StateRecoveryNotice = { + stateFile: file, + quarantineFile: quarantine.quarantineFile, + outcome: "quarantineFailed", + error: quarantine.error, + } + try { + console.error( + `[opencode-goal-plugin] Could not quarantine corrupt state at ${file}; continuing recovery:`, + quarantine.error, + ) + } catch { + // Diagnostics must never block state recovery. + } + notifyStateRecovery(notice) + } else { + const unchanged = yield* verifyRecoverySourceEffect(file, recoveryContent, quarantine.quarantineFile) + if (!unchanged) { + const message = "goal state changed while recovery was being quarantined; refusing to overwrite it" + notifyStateRecovery({ + stateFile: file, + quarantineFile: quarantine.quarantineFile, + outcome: "sourceChanged", + error: message, + }) + return yield* Effect.fail(new StateWriteError({ cause: new Error(message) })) + } + try { + console.warn( + `[opencode-goal-plugin] Preserved corrupt state from ${file} at ${quarantine.quarantineFile}; continuing recovery.`, + ) + } catch { + // Diagnostics must never block state recovery. + } + notifyStateRecovery({ + stateFile: file, + quarantineFile: quarantine.quarantineFile, + outcome: "quarantined", + }) + } + } yield* writeStateEffect(state, file) return result }), diff --git a/test/server-v2.test.ts b/test/server-v2.test.ts index 1a20a9c..ca528b8 100644 --- a/test/server-v2.test.ts +++ b/test/server-v2.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, test } from "bun:test" -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises" import { join } from "node:path" import { tmpdir } from "node:os" import plugin from "../src/server" @@ -289,6 +289,7 @@ test("V2 create_goal recovers from a zero-filled state file", async () => { expect(contentOf(created)).toContain('"objective": "recover V2 state"') expect((await getGoal("ses_v2"))?.objective).toBe("recover V2 state") + expect((await readdir(dir)).filter((name) => name.startsWith("goals.json.corrupt-"))).toHaveLength(1) mock.stream.end() await cleanup() }) diff --git a/test/server.test.ts b/test/server.test.ts index f89720b..ed5c667 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, setSystemTime, test } from "bun:test" +import { afterEach, beforeEach, expect, setSystemTime, spyOn, test } from "bun:test" import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" import { join } from "node:path" import { tmpdir } from "node:os" @@ -701,6 +701,153 @@ test("per-prompt chat hook recovers from an empty state file", async () => { expect(JSON.parse(await readFile(process.env.OPENCODE_GOAL_STATE_PATH!, "utf8"))).toEqual({ version: 1, goals: {} }) }) +test("zero-filled state recovery reports the quarantine through app logging", async () => { + const file = process.env.OPENCODE_GOAL_STATE_PATH! + await writeFile(file, "\0".repeat(28_454), "utf8") + const logs: unknown[] = [] + const hooks = await setupServer( + { + client: { + app: { log: async (input: unknown) => logs.push(input) }, + session: { promptAsync: async () => {} }, + }, + } as never, + { auto_continue: false }, + ) + const tools = hooks.tool + if (!tools) throw new Error("expected goal tools to be registered") + + await requireTool(tools.create_goal, "create_goal").execute( + { objective: "recover with evidence" }, + { sessionID: "ses_1", agent: "build" } as never, + ) + + await waitFor(() => logs.length === 1) + expect(logs[0]).toMatchObject({ + body: { + service: "opencode-goal-plugin", + level: "error", + message: "Corrupt goal state quarantined before recovery", + extra: { stateFile: file }, + }, + }) + expect(JSON.stringify(logs[0])).toContain(`${file}.corrupt-`) +}) + +test("state recovery reporting is scoped to the configured state path", async () => { + const firstLogs: unknown[] = [] + await setupServer( + { client: { app: { log: async (input: unknown) => firstLogs.push(input) } } } as never, + { auto_continue: false }, + ) + const secondFile = join(dir, "other-goals.json") + process.env.OPENCODE_GOAL_STATE_PATH = secondFile + await writeFile(secondFile, "\0\0", "utf8") + const secondLogs: unknown[] = [] + const second = await setupServer( + { client: { app: { log: async (input: unknown) => secondLogs.push(input) } } } as never, + { auto_continue: false }, + ) + const tools = second.tool + if (!tools) throw new Error("expected goal tools to be registered") + + await requireTool(tools.create_goal, "create_goal").execute( + { objective: "recover the second state" }, + { sessionID: "ses_1", agent: "build" } as never, + ) + + await waitFor(() => secondLogs.length === 1) + expect(firstLogs).toEqual([]) +}) + +test("disposing a server unregisters its state recovery reporter", async () => { + const staleLogs: unknown[] = [] + const stale = await setupServer( + { client: { app: { log: async (input: unknown) => staleLogs.push(input) } } } as never, + { auto_continue: false }, + ) + await stale.dispose?.() + const activeLogs: unknown[] = [] + const active = await setupServer( + { client: { app: { log: async (input: unknown) => activeLogs.push(input) } } } as never, + { auto_continue: false }, + ) + await writeFile(process.env.OPENCODE_GOAL_STATE_PATH!, "\0\0", "utf8") + const tools = active.tool + if (!tools) throw new Error("expected goal tools to be registered") + + await requireTool(tools.create_goal, "create_goal").execute( + { objective: "recover after reload" }, + { sessionID: "ses_1", agent: "build" } as never, + ) + + await waitFor(() => activeLogs.length === 1) + expect(staleLogs).toEqual([]) +}) + +test("application logging failures do not block state recovery", async () => { + await writeFile(process.env.OPENCODE_GOAL_STATE_PATH!, "\0\0", "utf8") + const errors: string[] = [] + const error = spyOn(console, "error").mockImplementation((...args) => errors.push(args.map(String).join(" "))) + const hooks = await setupServer( + { + client: { + app: { log: async () => Promise.reject(new Error("logger unavailable")) }, + }, + } as never, + { auto_continue: false }, + ) + const tools = hooks.tool + if (!tools) throw new Error("expected goal tools to be registered") + + try { + await requireTool(tools.create_goal, "create_goal").execute( + { objective: "recover without logger" }, + { sessionID: "ses_1", agent: "build" } as never, + ) + await waitFor(() => errors.length === 1) + } finally { + error.mockRestore() + } + + expect((await getGoal("ses_1"))?.objective).toBe("recover without logger") + expect(errors[0]).toContain("Failed to report quarantined state") +}) + +test("quarantine write failures are reported without blocking recovery", async () => { + const file = join(dir, "g".repeat(170)) + process.env.OPENCODE_GOAL_STATE_PATH = file + await writeFile(file, "\0\0", "utf8") + const logs: unknown[] = [] + const errors: string[] = [] + const error = spyOn(console, "error").mockImplementation((...args) => errors.push(args.map(String).join(" "))) + const hooks = await setupServer( + { client: { app: { log: async (input: unknown) => logs.push(input) } } } as never, + { auto_continue: false }, + ) + const tools = hooks.tool + if (!tools) throw new Error("expected goal tools to be registered") + + try { + await requireTool(tools.create_goal, "create_goal").execute( + { objective: "recover after quarantine failure" }, + { sessionID: "ses_1", agent: "build" } as never, + ) + await waitFor(() => logs.length === 1) + } finally { + error.mockRestore() + } + + expect((await getGoal("ses_1"))?.objective).toBe("recover after quarantine failure") + expect(logs[0]).toMatchObject({ + body: { + message: "Corrupt goal state could not be quarantined; continuing recovery", + extra: { stateFile: file, outcome: "quarantineFailed" }, + }, + }) + expect(errors.some((message) => message.includes("Could not quarantine corrupt state"))).toBe(true) +}) + test("message transform records assistant checkpoints", async () => { const hooks = await setupServer( { diff --git a/test/state.test.ts b/test/state.test.ts index 4ba8147..0f8c4cc 100644 --- a/test/state.test.ts +++ b/test/state.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, setSystemTime, spyOn, test } from "bun:test" -import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises" +import { mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises" import { join } from "node:path" import { tmpdir } from "node:os" import { @@ -501,6 +501,7 @@ test("does not overwrite corrupt persisted state", async () => { await expect(createGoal("ses_1", "ship the plugin", null)).rejects.toThrow() expect(await readFile(process.env.OPENCODE_GOAL_STATE_PATH!, "utf8")).toBe("{not valid json") + expect((await readdir(dir)).filter((name) => name.includes(".corrupt-"))).toEqual([]) }) test("treats empty and zero-filled state files as missing for async and sync reads", async () => { @@ -511,6 +512,7 @@ test("treats empty and zero-filled state files as missing for async and sync rea expect(getGoalSync("ses_1")).toBeNull() expect(await readFile(process.env.OPENCODE_GOAL_STATE_PATH!, "utf8")).toBe(content) } + expect(await readdir(dir)).toEqual(["goals.json"]) }) test("loads valid state prefixed by a UTF-8 BOM", async () => { @@ -533,6 +535,35 @@ test("creates and persists a goal from an empty state file", async () => { version: 1, goals: { ses_1: { objective: "recover safely" } }, }) + expect((await readdir(dir)).filter((name) => name.includes(".corrupt-"))).toEqual([]) +}) + +test("quarantines a non-empty zero-filled state before replacing it", async () => { + const file = process.env.OPENCODE_GOAL_STATE_PATH! + const damaged = "\0".repeat(28_454) + await writeFile(file, damaged, "utf8") + + const created = await createGoal("ses_1", "recover safely", null) + + expect(created.objective).toBe("recover safely") + expect((await getGoal("ses_1"))?.objective).toBe("recover safely") + const quarantines = (await readdir(dir)).filter((name) => name.startsWith("goals.json.corrupt-")) + expect(quarantines).toHaveLength(1) + expect(await readFile(join(dir, quarantines[0]!), "utf8")).toBe(damaged) +}) + +test("quarantines non-empty whitespace and BOM-only state before replacing it", async () => { + for (const [index, damaged] of [" \n\t", "\uFEFF"].entries()) { + const file = join(dir, `goals-${index}.json`) + process.env.OPENCODE_GOAL_STATE_PATH = file + await writeFile(file, damaged, "utf8") + + await createGoal(`ses_${index}`, "recover padded state", null) + + const quarantines = (await readdir(dir)).filter((name) => name.startsWith(`goals-${index}.json.corrupt-`)) + expect(quarantines).toHaveLength(1) + expect(await readFile(join(dir, quarantines[0]!), "utf8")).toBe(damaged) + } }) test("warns once for each empty state file path", async () => {