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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
31 changes: 31 additions & 0 deletions scripts/comprehensive-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -475,12 +475,43 @@ 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()
await testActionRoutingAndSafety()
await testStopsPreflightAndGoalLifecycle()
await testLoopOwnedGoalMessageUpdatesDoNotSelfInterrupt()
await testInitializationDoesNotWaitForLocalApi()
await testWindowsSafeStatePersistence()

console.log("OpenCode Loop comprehensive test passed")
75 changes: 68 additions & 7 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)
})
}

Expand Down