diff --git a/CHANGELOG.md b/CHANGELOG.md index 2929518a..2e86bb77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.5.20 + +- Fixed Windows TUI loop state writes failing with `EPERM` / `EEXIST` when renaming a project-local `*.tmp` over an existing session state file. Heartbeat and due-timer updates no longer drop jobs when antivirus, IDE indexers, or OpenCode snapshots briefly lock the destination. +- Write atomic state payloads under the OS temp directory first, then replace the target with rename plus a copy/unlink fallback and short retries, so project trees no longer accumulate `opencode-loop/*.tmp` files that OpenCode tried to git-snapshot. + ## 0.5.19 - Made the asynchronous goal prompt smoke test wait for the observable SDK call instead of assuming a single event-loop tick is always sufficient under load. diff --git a/README.md b/README.md index 149e6b9f..72cfb41a 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ v0.5.11 includes a referenced heartbeat scheduler. This is important in OpenCode ## Current status -**v0.5.19 hardens Goal Mode, package updates, and the background daemon.** Package installs are pinned to the installed version so OpenCode cannot keep loading an older cached release, scheduler-created goal messages no longer self-interrupt on delayed updates, finite daemon failures return nonzero, model/agent selection is supported, Windows scheduled tasks use a short launcher that stays below the `/TR` limit, and asynchronous release verification is reliable under load. +**v0.5.20 fixes Windows TUI state writes.** Session job state is written through the OS temp directory with rename plus copy/unlink fallback and short retries, so antivirus locks and OpenCode snapshots no longer drop `/loop` jobs with `EPERM` on rename. **v0.5.19** hardens Goal Mode, package updates, and the background daemon: package installs are pinned to the installed version so OpenCode cannot keep loading an older cached release, scheduler-created goal messages no longer self-interrupt on delayed updates, finite daemon failures return nonzero, model/agent selection is supported, Windows scheduled tasks use a short launcher that stays below the `/TR` limit, and asynchronous release verification is reliable under load. The known update-related symptoms from older builds are fixed: @@ -1087,6 +1087,12 @@ Recent plugin events are appended to: .opencode/opencode-loop/loop.log ``` +Add `.opencode/opencode-loop/` to project `.gitignore` when possible. Runtime state is rewritten often, and on Windows file locks from scanners or snapshot tools can interrupt in-place renames. Since v0.5.20 the plugin writes state payloads via the OS temp directory with a rename/copy fallback so jobs survive those locks; gitignoring the directory still keeps noise out of commits. + +## Example progress.md + +Create one with: + ## Example progress.md Create one with: @@ -1130,6 +1136,11 @@ Improve the application in small safe steps. ## Changelog highlights +### v0.5.20 + +- Fixed Windows `EPERM` / `EEXIST` failures when rewriting `.opencode/opencode-loop/ses_*.json` during heartbeat and due-timer updates. +- Atomic state payloads are staged outside the project so OpenCode no longer git-snapshots `opencode-loop/*.tmp` pathspecs. + ### v0.5.17 - Added a tool-denied local command agent and restored the normal coding agent/model for scheduled iterations. diff --git a/package.json b/package.json index 8c785a4d..fbcde98e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bybrawe/opencode-loop", - "version": "0.5.19", + "version": "0.5.20", "description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: heartbeat scheduler, idle-safe loops, scheduled commands, compact scheduling, verification, checkpoints, and persistent coding goals.", "type": "module", "main": "src/index.js", diff --git a/scripts/comprehensive-test.mjs b/scripts/comprehensive-test.mjs index 4019092e..c62bd3a5 100644 --- a/scripts/comprehensive-test.mjs +++ b/scripts/comprehensive-test.mjs @@ -475,6 +475,36 @@ async function testInitializationDoesNotWaitForLocalApi() { } } +async function testWindowsSafeStatePersistence() { + const h = await createHarness() + try { + const stateDir = path.join(h.directory, ".opencode", "opencode-loop") + + // Rapid replacements exercise atomic overwrite of an existing state file. + // On Windows this used to fail with EPERM when rename targeted a locked file + // next to a project-local *.tmp, leaving jobs empty and the heartbeat stuck. + for (let index = 0; index < 25; index++) { + await h.command("loop", `10m --no-now --name sticky action-${index}`) + } + + const state = await h.readState() + assert.equal(state.jobs.length, 1) + assert.equal(state.jobs[0].name, "sticky") + assert.equal(state.jobs[0].action, "action-24") + + // Temp payloads must live outside the project so OpenCode git snapshots and + // Windows file locks do not see opencode-loop/*.tmp pathspecs. + const leftovers = (await fs.readdir(stateDir)).filter((name) => name.endsWith(".tmp")) + assert.deepEqual(leftovers, [], "state writes must not leave project-local temp files") + + await h.command("loop-clear") + const cleared = await h.readState() + assert.equal(cleared.jobs.length, 0) + } finally { + await h.cleanup() + } +} + await testParserAndPresets() await testLifecycleAndCommandDedupe() await testWatchScheduling() @@ -482,5 +512,6 @@ await testActionRoutingAndSafety() await testStopsPreflightAndGoalLifecycle() await testLoopOwnedGoalMessageUpdatesDoNotSelfInterrupt() await testInitializationDoesNotWaitForLocalApi() +await testWindowsSafeStatePersistence() console.log("OpenCode Loop comprehensive test passed") diff --git a/src/index.js b/src/index.js index 9507b958..4ab38763 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,5 @@ import { promises as fs } from "node:fs" +import os from "node:os" import path from "node:path" import { spawn } from "node:child_process" import { tool } from "@opencode-ai/plugin/tool" @@ -387,17 +388,77 @@ async function readState(directory, sessionID) { } } +function isRetriableStateWriteError(error) { + const code = error?.code + return code === "EPERM" || code === "EACCES" || code === "EBUSY" || code === "EEXIST" || code === "EAGAIN" +} + +async function delay(ms) { + await new Promise((resolve) => setTimeout(resolve, ms)) +} + +// Windows often fails POSIX-style "write temp next to target, then rename over it": +// existing destinations can return EPERM/EEXIST while antivirus, IDE indexers, or +// OpenCode's own snapshotter briefly hold the state file. Temp files next to the +// target also show up in project git snapshots as *.tmp pathspec noise. +// +// Write the payload outside the project first, then replace the target with +// rename when possible and a copy/unlink fallback with short retries. +async function writeFileAtomically(target, contents, options = {}) { + const encoding = options.encoding || "utf8" + const attempts = Math.max(1, Number(options.attempts) || 5) + const temp = path.join( + os.tmpdir(), + `opencode-loop-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`, + ) + await fs.writeFile(temp, contents, encoding) + try { + let lastError + for (let attempt = 0; attempt < attempts; attempt++) { + try { + await fs.rename(temp, target) + return + } catch (error) { + lastError = error + const code = error?.code + // Cross-device rename is expected when the project is not on the temp volume. + // On Windows, rename-over-existing can also fail while the destination is locked. + if (code === "EXDEV" || isRetriableStateWriteError(error)) { + try { + await fs.copyFile(temp, target) + return + } catch (copyError) { + lastError = copyError + // Last resort: direct overwrite. Less atomic, but better than dropping jobs. + if (attempt === attempts - 1) { + await fs.writeFile(target, contents, encoding) + return + } + if (!isRetriableStateWriteError(copyError)) throw copyError + } + } else if (attempt === attempts - 1) { + try { + await fs.writeFile(target, contents, encoding) + return + } catch { + throw error + } + } + } + await delay(25 * (attempt + 1)) + } + throw lastError || new Error(`Failed to write ${target}`) + } finally { + try { await fs.rm(temp, { force: true }) } catch {} + } +} + async function writeState(directory, sessionID, state) { await withStateWriteLock(directory, sessionID, async () => { await ensureDir(stateDir(directory)) const target = statePath(directory, sessionID) - const temp = `${target}.${process.pid}.${Date.now()}.tmp` - try { - await fs.writeFile(temp, JSON.stringify({ version: 4, jobs: state.jobs || [] }, null, 2)) - await fs.rename(temp, target) - } finally { - try { await fs.rm(temp, { force: true }) } catch {} - } + const payload = JSON.stringify({ version: 4, jobs: state.jobs || [] }, null, 2) + await writeFileAtomically(target, payload) }) }