Skip to content
Merged
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<timestamp>-<uuid>` 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.
Expand Down
103 changes: 98 additions & 5 deletions dist/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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() === "";
}
Expand All @@ -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({
Expand Down Expand Up @@ -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;
}));
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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();
Expand Down
18 changes: 18 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
getGoal,
getGoalInternal,
markGoalUnmet,
onStateRecovery,
pauseGoalForPlanMode,
PLAN_MODE_STOP_REASON,
recordAssistantProgress,
Expand All @@ -25,6 +26,7 @@ import {
reserveContinuation,
rollbackContinuationAttempt,
setGoalStatus,
statePath,
updateGoalObjective,
validateObjective,
} from "./state"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading