From 0fbd44ac40d1a4c1f09055b9f49f308e06289db0 Mon Sep 17 00:00:00 2001 From: willytop8 Date: Sun, 2 Aug 2026 01:02:52 -0500 Subject: [PATCH] fix(persistence): serialize fresh migration markers --- CHANGELOG.md | 3 +- scripts/mutation-contract.mjs | 14 ++++++ src/goal-plugin.js | 17 +++++++- test/session-concurrency.test.js | 73 +++++++++++++++++++++++++++++++- 4 files changed, 103 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0df555..f82b97c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/scripts/mutation-contract.mjs b/scripts/mutation-contract.mjs index 254a2b5..bdcecef 100644 --- a/scripts/mutation-contract.mjs +++ b/scripts/mutation-contract.mjs @@ -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", diff --git a/src/goal-plugin.js b/src/goal-plugin.js index 3997740..0e1cec5 100644 --- a/src/goal-plugin.js +++ b/src/goal-plugin.js @@ -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) { diff --git a/test/session-concurrency.test.js b/test/session-concurrency.test.js index 37c2c44..871a22a 100644 --- a/test/session-concurrency.test.js +++ b/test/session-concurrency.test.js @@ -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") @@ -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")