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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Changelog

## 0.6.8 — 2026-08-01
## 0.6.8 — 2026-08-02

- Serialize fresh-namespace migration-marker publication across concurrent processes so Windows does not reject competing first-start renames with `EPERM`.
- Keep a second OpenCode process usable when it opens a session whose goal-state shard is already leased: ordinary chat and unrelated tools remain available, while goal commands and tools fail safely without reading, changing, or prompting from that session's goal state. After the owner exits, the next explicit goal command or tool retries ownership and reloads any active goal paused. Lease contention is now typed and owner metadata is sanitized; unrelated filesystem failures still fail closed.
- Harden the single-writer lease with immutable per-owner claims so delayed publication, simultaneous stale reclaim, and concurrent release cannot remove a replacement owner's lease. A complete compatibility guard is published atomically with no replacement, preventing old/current startup races; legacy, incomplete, tampered, or unsupported lease layouts fail closed for explicit manual recovery. Owner reads remain bounded and symlink-safe, slow passive command guards keep blocking tools until an authenticated new boundary, delayed control errors cannot pause a newer goal run, takeover retains Plan/model execution context, and advisory host logging cannot stall loading or disposal.

Expand Down
14 changes: 14 additions & 0 deletions scripts/mutation-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,20 @@ const mutants = [
to: " try {\n if (false) return\n if (await pathExists(persistenceOptions.migrationMarkerPath)) return",
test: "test/goal-plugin.test.js",
},
{
name: "fresh migration markers require aggregate lease ownership",
file: "src/goal-plugin.js",
from: " const freshMigrationLease = await acquireMigrationLease(\n persistenceOptions.stateFilePath,\n persistenceOptions.migrationMarkerPath,\n )",
to: " const freshMigrationLease = { release: async () => false }",
test: "test/session-concurrency.test.js",
},
{
name: "fresh migration marker leases are released",
file: "src/goal-plugin.js",
from: " await freshMigrationLease.release()",
to: " await Promise.resolve()",
test: "test/session-concurrency.test.js",
},
{
name: "disposed command continuations cannot mutate state",
file: "src/goal-plugin.js",
Expand Down
17 changes: 15 additions & 2 deletions src/goal-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -1788,9 +1788,22 @@ async function migrateLegacyState(persistenceOptions, client) {
}

// A fresh project has no aggregate or legacy state. Mark the namespace so a
// later session does not repeatedly probe global fallback paths.
// later session does not repeatedly probe global fallback paths. Separate
// session processes must still serialize this shared marker: POSIX rename
// replaces an existing destination, while Windows can reject that race.
if (currentRuntime().disposed) return
await writeMigrationMarker(persistenceOptions.migrationMarkerPath)
const freshMigrationLease = await acquireMigrationLease(
persistenceOptions.stateFilePath,
persistenceOptions.migrationMarkerPath,
)
if (!freshMigrationLease) return
try {
if (currentRuntime().disposed) return
if (await pathExists(persistenceOptions.migrationMarkerPath)) return
await writeMigrationMarker(persistenceOptions.migrationMarkerPath)
} finally {
await freshMigrationLease.release()
}
}

async function loadPersistedSessionState(persistence, client, sessionID) {
Expand Down
73 changes: 72 additions & 1 deletion test/session-concurrency.test.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import assert from "node:assert/strict"
import { createHash } from "node:crypto"
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"
import { promises as sharedFs } from "node:fs"
import { mkdtemp, readFile, readdir, rm, stat } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { spawn } from "node:child_process"
import test from "node:test"
import { GoalPlugin } from "../src/goal-plugin.js"
import { acquirePersistenceLease } from "../src/persistence-lease.js"

function sessionStatePath(stateFilePath, sessionID) {
const key = createHash("sha256").update(sessionID).digest("hex")
Expand Down Expand Up @@ -68,6 +70,75 @@ function waitForExit(child) {
return new Promise((resolve) => child.once("exit", resolve))
}

test("fresh namespaces serialize migration-marker publication", { timeout: 5_000 }, async () => {
const directory = await mkdtemp(join(tmpdir(), "goal-plugin-fresh-migration-"))
const stateFilePath = join(directory, "state.json")
const migrationMarkerPath = join(`${stateFilePath}.sessions`, ".migration-v1-complete")
const migrationLockPath = `${stateFilePath}.lock`
const originalLstat = sharedFs.lstat
let observedMigrationLock
const migrationLockObserved = new Promise((resolve) => {
observedMigrationLock = resolve
})
let migrationBlocker = await acquirePersistenceLease(stateFilePath)
let releasedLease
let hooks
let loading
sharedFs.lstat = async (...args) => {
if (args[0] === migrationLockPath) observedMigrationLock()
return originalLstat(...args)
}

try {
hooks = await GoalPlugin(
{
client: {
app: { log: async () => {} },
session: { messages: async () => ({ data: [] }), promptAsync: async () => ({}) },
},
directory,
},
{ stateFilePath, registerTools: false, minDelayMs: 1 },
)
let loadSettled = false
loading = hooks["chat.params"]({ sessionID: "fresh-migration-session", agent: "build" })
.then((value) => {
loadSettled = true
return value
})

const firstBoundary = await Promise.race([
migrationLockObserved.then(() => "lock"),
loading.then(() => "loaded"),
])
assert.equal(
firstBoundary,
"lock",
"fresh migration must consult the aggregate lease before publishing its marker",
)
assert.equal(loadSettled, false, "fresh session loading must wait for migration ownership")
await assert.rejects(stat(migrationMarkerPath), { code: "ENOENT" })

await migrationBlocker.release()
migrationBlocker = null
await loading
const marker = JSON.parse(await readFile(migrationMarkerPath, "utf8"))
assert.equal(marker.version, 1)
assert.equal(Number.isFinite(marker.migratedAt), true)
releasedLease = await acquirePersistenceLease(stateFilePath)
assert.ok(releasedLease, "fresh migration must release the aggregate lease after publishing")
await releasedLease.release()
releasedLease = null
} finally {
sharedFs.lstat = originalLstat
await migrationBlocker?.release().catch(() => false)
await releasedLease?.release().catch(() => false)
await Promise.allSettled([loading].filter(Boolean))
await hooks?.dispose()
await rm(directory, { recursive: true, force: true })
}
})

test("independent sessions in one project persist concurrently in separate shards", async () => {
const directory = await mkdtemp(join(tmpdir(), "goal-plugin-concurrent-sessions-"))
const stateFilePath = join(directory, "state.json")
Expand Down