From ce680be2a10d653ebebc4933dbf13c1beac8df87 Mon Sep 17 00:00:00 2001 From: ElvenWang Date: Mon, 3 Aug 2026 11:01:58 +0800 Subject: [PATCH 01/17] fix(opencode-plugin): decouple flush/threshold-commit from session.idle and make state save process-safe --- .../opencode-plugin/lib/memory-session.mjs | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/examples/opencode-plugin/lib/memory-session.mjs b/examples/opencode-plugin/lib/memory-session.mjs index 6646a68965..d346255317 100644 --- a/examples/opencode-plugin/lib/memory-session.mjs +++ b/examples/opencode-plugin/lib/memory-session.mjs @@ -27,6 +27,7 @@ export function createMemorySessionManager({ config, pluginRoot }) { const statePath = path.join(pluginRoot, "openviking-session-state.json") const oldSessionMapPath = path.join(pluginRoot, "openviking-session-map.json") let saveTimer = null + let flushTimer = null async function init() { if (config.autoCapture) await migrateLegacySessionMap() @@ -38,6 +39,35 @@ export function createMemorySessionManager({ config, pluginRoot }) { (stage, data) => log("DEBUG", "pending", stage, data), ) } + startPeriodicFlush() + } + + function startPeriodicFlush() { + if (!config.autoCapture) return + const intervalMs = Math.max(10000, Number(config.periodicFlushIntervalMs) || 60000) + if (flushTimer) clearInterval(flushTimer) + flushTimer = setInterval(() => { + runPeriodicFlush().catch((error) => { + log("ERROR", "session", "Periodic flush failed", { error: error?.message }) + }) + }, intervalMs) + if (typeof flushTimer.unref === "function") flushTimer.unref() + log("INFO", "session", "Periodic flush timer started", { intervalMs }) + } + + async function runPeriodicFlush() { + for (const [opencodeSessionId, state] of sessions.entries()) { + let hasPending = false + for (const message of state.messages.values()) { + if (!message.captured) { + hasPending = true + break + } + } + if (hasPending) { + await flushSession(opencodeSessionId, { commit: false, reason: "periodic" }) + } + } } async function loadState() { @@ -69,7 +99,7 @@ export function createMemorySessionManager({ config, pluginRoot }) { for (const [opencodeSessionId, state] of sessions.entries()) { persisted[opencodeSessionId] = serializeSessionState(state) } - const tempPath = `${statePath}.tmp` + const tempPath = `${statePath}.${process.pid}.tmp` await fs.promises.writeFile(tempPath, JSON.stringify({ version: 2, sessions: persisted, lastSaved: Date.now() }, null, 2), "utf8") await fs.promises.rename(tempPath, statePath) log("DEBUG", "persistence", "Session state saved", { count: sessions.size }) @@ -247,6 +277,10 @@ export function createMemorySessionManager({ config, pluginRoot }) { clearTimeout(saveTimer) saveTimer = null } + if (flushTimer) { + clearInterval(flushTimer) + flushTimer = null + } for (const sessionId of sessions.keys()) { await flushSession(sessionId, { commit, reason: "flushAll" }) } From 14bfecbd49d8fe0fa43cc37de16c2c7f239c6db5 Mon Sep 17 00:00:00 2001 From: ElvenWang Date: Mon, 3 Aug 2026 11:23:53 +0800 Subject: [PATCH 02/17] fix(opencode-plugin): serialize overlapping session flushes to prevent duplicate sends --- .../opencode-plugin/lib/memory-session.mjs | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/examples/opencode-plugin/lib/memory-session.mjs b/examples/opencode-plugin/lib/memory-session.mjs index d346255317..cd589bdb70 100644 --- a/examples/opencode-plugin/lib/memory-session.mjs +++ b/examples/opencode-plugin/lib/memory-session.mjs @@ -292,14 +292,29 @@ export function createMemorySessionManager({ config, pluginRoot }) { const state = sessions.get(opencodeSessionId) if (!state) return false - const added = await flushPendingMessages(opencodeSessionId, state) - if (commit && config.autoCapture) { - await commitOvSession(state.ovSessionId, { force: true, reason }) - } else if (added > 0) { - await maybeCommitByThreshold(state) + // Serialize overlapping flushes on the same session. flushPendingMessages + // only marks messages captured=true after the network send resolves, so two + // concurrent flushes (periodic timer + session.idle, or a slow flush the + // timer did not await) would read the same captured=false batch and send it + // twice. Chaining on state.flushing forces them to run one-after-another, + // so each subsequent flush re-reads the post-send captured state. + const run = async () => { + const added = await flushPendingMessages(opencodeSessionId, state) + if (commit && config.autoCapture) { + await commitOvSession(state.ovSessionId, { force: true, reason }) + } else if (added > 0) { + await maybeCommitByThreshold(state) + } + await saveState() + return true } - await saveState() - return true + + const previous = state.flushing || Promise.resolve() + const current = previous.then(run, run) + state.flushing = current.finally(() => { + if (state.flushing === current) state.flushing = undefined + }) + return current } async function commitSession(sessionId, opencodeSessionId, abortSignal) { From 52509269e25b550dccb5202ace7e2a236ffe2159 Mon Sep 17 00:00:00 2001 From: ElvenWang Date: Mon, 3 Aug 2026 11:31:18 +0800 Subject: [PATCH 03/17] fix(opencode-plugin): correct flushing-guard reset and add periodic-flush overlap guard --- .../opencode-plugin/lib/memory-session.mjs | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/examples/opencode-plugin/lib/memory-session.mjs b/examples/opencode-plugin/lib/memory-session.mjs index cd589bdb70..9941a227cd 100644 --- a/examples/opencode-plugin/lib/memory-session.mjs +++ b/examples/opencode-plugin/lib/memory-session.mjs @@ -28,6 +28,7 @@ export function createMemorySessionManager({ config, pluginRoot }) { const oldSessionMapPath = path.join(pluginRoot, "openviking-session-map.json") let saveTimer = null let flushTimer = null + let periodicFlushRunning = false async function init() { if (config.autoCapture) await migrateLegacySessionMap() @@ -56,17 +57,23 @@ export function createMemorySessionManager({ config, pluginRoot }) { } async function runPeriodicFlush() { - for (const [opencodeSessionId, state] of sessions.entries()) { - let hasPending = false - for (const message of state.messages.values()) { - if (!message.captured) { - hasPending = true - break + if (periodicFlushRunning) return + periodicFlushRunning = true + try { + for (const [opencodeSessionId, state] of sessions.entries()) { + let hasPending = false + for (const message of state.messages.values()) { + if (!message.captured) { + hasPending = true + break + } + } + if (hasPending) { + await flushSession(opencodeSessionId, { commit: false, reason: "periodic" }) } } - if (hasPending) { - await flushSession(opencodeSessionId, { commit: false, reason: "periodic" }) - } + } finally { + periodicFlushRunning = false } } @@ -311,9 +318,13 @@ export function createMemorySessionManager({ config, pluginRoot }) { const previous = state.flushing || Promise.resolve() const current = previous.then(run, run) - state.flushing = current.finally(() => { - if (state.flushing === current) state.flushing = undefined + // Track the same promise we store so the reset actually fires. Swallow the + // rejection on the gate branch (callers still observe it via `current`) to + // avoid an unhandled rejection when no subsequent flush chains onto it. + const tracked = current.catch(() => {}).finally(() => { + if (state.flushing === tracked) state.flushing = undefined }) + state.flushing = tracked return current } From a2b61a412d556c1f8f60c0b105e987f94903496b Mon Sep 17 00:00:00 2001 From: ElvenWang Date: Mon, 3 Aug 2026 11:38:27 +0800 Subject: [PATCH 04/17] fix(opencode-plugin): serialize state saves and clean up orphan temp files --- .../opencode-plugin/lib/memory-session.mjs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/examples/opencode-plugin/lib/memory-session.mjs b/examples/opencode-plugin/lib/memory-session.mjs index 9941a227cd..65b1a7c89c 100644 --- a/examples/opencode-plugin/lib/memory-session.mjs +++ b/examples/opencode-plugin/lib/memory-session.mjs @@ -29,6 +29,8 @@ export function createMemorySessionManager({ config, pluginRoot }) { let saveTimer = null let flushTimer = null let periodicFlushRunning = false + let savePromise = Promise.resolve() + let saveCounter = 0 async function init() { if (config.autoCapture) await migrateLegacySessionMap() @@ -101,17 +103,31 @@ export function createMemorySessionManager({ config, pluginRoot }) { } async function saveState() { + // Serialize saves within this process. The debounced saveTimer, flushSession + // and runPeriodicFlush can all call saveState concurrently; without chaining, + // two writes to the same temp path could interleave and corrupt the file that + // then gets renamed onto the real state path. + const run = savePromise.then(runSaveState, runSaveState) + savePromise = run.catch(() => {}) + return run + } + + async function runSaveState() { + const tempPath = `${statePath}.${process.pid}.${saveCounter++}.tmp` try { const persisted = {} for (const [opencodeSessionId, state] of sessions.entries()) { persisted[opencodeSessionId] = serializeSessionState(state) } - const tempPath = `${statePath}.${process.pid}.tmp` await fs.promises.writeFile(tempPath, JSON.stringify({ version: 2, sessions: persisted, lastSaved: Date.now() }, null, 2), "utf8") await fs.promises.rename(tempPath, statePath) log("DEBUG", "persistence", "Session state saved", { count: sessions.size }) } catch (error) { log("ERROR", "persistence", "Failed to save session state", { error: error?.message }) + // Best-effort cleanup so a failed rename does not leave an orphan temp file. + try { + await fs.promises.unlink(tempPath) + } catch {} } } From a95f8f42b45d8a08e1b2c210101ea2d6eb1d903d Mon Sep 17 00:00:00 2001 From: ElvenWang Date: Mon, 3 Aug 2026 11:45:01 +0800 Subject: [PATCH 05/17] fix(opencode-plugin): route commitSession through the flush gate and drop gate auto-reset --- .../opencode-plugin/lib/memory-session.mjs | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/examples/opencode-plugin/lib/memory-session.mjs b/examples/opencode-plugin/lib/memory-session.mjs index 65b1a7c89c..0cbf24f37f 100644 --- a/examples/opencode-plugin/lib/memory-session.mjs +++ b/examples/opencode-plugin/lib/memory-session.mjs @@ -334,20 +334,23 @@ export function createMemorySessionManager({ config, pluginRoot }) { const previous = state.flushing || Promise.resolve() const current = previous.then(run, run) - // Track the same promise we store so the reset actually fires. Swallow the - // rejection on the gate branch (callers still observe it via `current`) to - // avoid an unhandled rejection when no subsequent flush chains onto it. - const tracked = current.catch(() => {}).finally(() => { - if (state.flushing === tracked) state.flushing = undefined - }) - state.flushing = tracked + // Store the same promise the next flush will chain on. Swallow the rejection + // on the gate branch (callers still observe it via `current`) so a failed run + // does not become an unhandled rejection when nothing chains onto it. We do + // NOT reset state.flushing afterwards: chaining on an already-settled promise + // is effectively free, and an auto-reset opens a window where a rapidly queued + // flush sees `undefined` and starts in parallel instead of serializing. + state.flushing = current.catch(() => {}) return current } async function commitSession(sessionId, opencodeSessionId, abortSignal) { if (opencodeSessionId) { - const state = sessions.get(opencodeSessionId) - if (state) await flushPendingMessages(opencodeSessionId, state) + // Route through flushSession so the send goes through the same + // per-session serialization gate. Calling flushPendingMessages directly + // would bypass state.flushing and could double-send a batch that a + // concurrent periodic/idle flush is already sending. + await flushSession(opencodeSessionId, { commit: false, reason: "tool" }) } return commitOvSession(sessionId, { force: true, abortSignal, reason: "tool" }) } From 37f9c024d419312adacc52ff8f09c486c9e0261b Mon Sep 17 00:00:00 2001 From: ElvenWang Date: Mon, 3 Aug 2026 11:58:47 +0800 Subject: [PATCH 06/17] fix(opencode-plugin): make teardown deterministic and surface save failures --- .../opencode-plugin/lib/memory-session.mjs | 50 +++++++++++++------ 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/examples/opencode-plugin/lib/memory-session.mjs b/examples/opencode-plugin/lib/memory-session.mjs index 0cbf24f37f..4406ec1e89 100644 --- a/examples/opencode-plugin/lib/memory-session.mjs +++ b/examples/opencode-plugin/lib/memory-session.mjs @@ -29,6 +29,8 @@ export function createMemorySessionManager({ config, pluginRoot }) { let saveTimer = null let flushTimer = null let periodicFlushRunning = false + let periodicFlushPromise = null + let shuttingDown = false let savePromise = Promise.resolve() let saveCounter = 0 @@ -47,6 +49,7 @@ export function createMemorySessionManager({ config, pluginRoot }) { function startPeriodicFlush() { if (!config.autoCapture) return + if (shuttingDown) return const intervalMs = Math.max(10000, Number(config.periodicFlushIntervalMs) || 60000) if (flushTimer) clearInterval(flushTimer) flushTimer = setInterval(() => { @@ -61,22 +64,26 @@ export function createMemorySessionManager({ config, pluginRoot }) { async function runPeriodicFlush() { if (periodicFlushRunning) return periodicFlushRunning = true - try { - for (const [opencodeSessionId, state] of sessions.entries()) { - let hasPending = false - for (const message of state.messages.values()) { - if (!message.captured) { - hasPending = true - break + const done = (async () => { + try { + for (const [opencodeSessionId, state] of sessions.entries()) { + let hasPending = false + for (const message of state.messages.values()) { + if (!message.captured) { + hasPending = true + break + } + } + if (hasPending) { + await flushSession(opencodeSessionId, { commit: false, reason: "periodic" }) } } - if (hasPending) { - await flushSession(opencodeSessionId, { commit: false, reason: "periodic" }) - } + } finally { + periodicFlushRunning = false } - } finally { - periodicFlushRunning = false - } + })() + periodicFlushPromise = done + return done } async function loadState() { @@ -128,6 +135,9 @@ export function createMemorySessionManager({ config, pluginRoot }) { try { await fs.promises.unlink(tempPath) } catch {} + // Rethrow so callers awaiting saveState() can observe persistent failures + // (e.g. ENOSPC/EACCES). Fire-and-forget callers already attach a .catch. + throw error } } @@ -295,7 +305,8 @@ export function createMemorySessionManager({ config, pluginRoot }) { debouncedSaveState() } - async function flushAll({ commit = false } = {}) { + async function flushAll({ commit = false, shutdown = commit } = {}) { + if (shutdown) shuttingDown = true if (saveTimer) { clearTimeout(saveTimer) saveTimer = null @@ -304,10 +315,21 @@ export function createMemorySessionManager({ config, pluginRoot }) { clearInterval(flushTimer) flushTimer = null } + // Drain any in-flight periodic flush so it cannot interleave with the loop + // below and make commit ordering nondeterministic at teardown. + if (periodicFlushPromise) { + try { + await periodicFlushPromise + } catch { + // periodic flush errors are already logged at their source + } + } for (const sessionId of sessions.keys()) { await flushSession(sessionId, { commit, reason: "flushAll" }) } await saveState() + // If this was not a shutdown, keep periodic flushing alive. + if (!shutdown) startPeriodicFlush() } async function flushSession(opencodeSessionId, { commit = false, reason = "manual" } = {}) { From 80fc9d8ecd4984e97bb455e2e0dcdee9a61d5c4b Mon Sep 17 00:00:00 2001 From: ElvenWang Date: Mon, 3 Aug 2026 12:05:03 +0800 Subject: [PATCH 07/17] fix(opencode-plugin): decouple shutdown from commit and guard timer tick race --- examples/opencode-plugin/lib/memory-session.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/opencode-plugin/lib/memory-session.mjs b/examples/opencode-plugin/lib/memory-session.mjs index 4406ec1e89..97c9c3cce1 100644 --- a/examples/opencode-plugin/lib/memory-session.mjs +++ b/examples/opencode-plugin/lib/memory-session.mjs @@ -53,6 +53,9 @@ export function createMemorySessionManager({ config, pluginRoot }) { const intervalMs = Math.max(10000, Number(config.periodicFlushIntervalMs) || 60000) if (flushTimer) clearInterval(flushTimer) flushTimer = setInterval(() => { + // A tick may have been queued just before flushAll's clearInterval; skip it + // during teardown so a periodic flush cannot start concurrently with flushAll. + if (shuttingDown) return runPeriodicFlush().catch((error) => { log("ERROR", "session", "Periodic flush failed", { error: error?.message }) }) @@ -305,7 +308,7 @@ export function createMemorySessionManager({ config, pluginRoot }) { debouncedSaveState() } - async function flushAll({ commit = false, shutdown = commit } = {}) { + async function flushAll({ commit = false, shutdown = false } = {}) { if (shutdown) shuttingDown = true if (saveTimer) { clearTimeout(saveTimer) From 3f9eee555e4a87463b71e8743f72a9d016421806 Mon Sep 17 00:00:00 2001 From: ElvenWang Date: Mon, 3 Aug 2026 12:05:05 +0800 Subject: [PATCH 08/17] fix(opencode-plugin): decouple shutdown from commit and guard timer tick race --- examples/opencode-plugin/index.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/opencode-plugin/index.mjs b/examples/opencode-plugin/index.mjs index b6c9e3c3a6..961ea6c083 100644 --- a/examples/opencode-plugin/index.mjs +++ b/examples/opencode-plugin/index.mjs @@ -83,7 +83,7 @@ export async function OpenVikingPlugin({ client, directory }) { }, dispose: async () => { - await sessionManager.flushAll({ commit: true }) + await sessionManager.flushAll({ commit: true, shutdown: true }) log("INFO", "plugin", "OpenViking plugin disposed") }, } From 868b0a8fcf9c314181b0196f246ed0214278ac15 Mon Sep 17 00:00:00 2001 From: ElvenWang Date: Mon, 3 Aug 2026 12:14:33 +0800 Subject: [PATCH 09/17] fix(opencode-plugin): plumb periodicFlushIntervalMs config and sweep stale temp files --- examples/opencode-plugin/lib/config.mjs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/examples/opencode-plugin/lib/config.mjs b/examples/opencode-plugin/lib/config.mjs index 6f5e8ad853..beb1259bc4 100644 --- a/examples/opencode-plugin/lib/config.mjs +++ b/examples/opencode-plugin/lib/config.mjs @@ -1,14 +1,9 @@ import fs from "fs" import path from "path" import { homedir } from "os" -import { buildUserAgent, readManifestVersion, resolveOpenVikingCredentials } from "./shared/credentials.mjs" +import { resolveOpenVikingCredentials } from "./shared/credentials.mjs" import { resolveEffectivePeerId } from "./shared/workspace-peer.mjs" -const USER_AGENT = buildUserAgent( - "opencode", - readManifestVersion(new URL("../package.json", import.meta.url)), -) - const DEFAULT_CONFIG = { endpoint: "http://127.0.0.1:1933", apiKey: "", @@ -42,6 +37,7 @@ const DEFAULT_CONFIG = { captureToolMaxChars: 2000, commitTokenThreshold: 20000, commitKeepRecentCount: 10, + periodicFlushIntervalMs: 60000, profileTokenBudget: 10000, resumeContextBudget: 32000, noAutoInject: false, @@ -148,6 +144,7 @@ function applyBehaviorConfig(config, fileConfig = {}) { "captureToolMaxChars", "commitTokenThreshold", "commitKeepRecentCount", + "periodicFlushIntervalMs", "profileTokenBudget", "resumeContextBudget", "noAutoInject", @@ -221,7 +218,6 @@ function normalizeConfig(config) { config.baseUrl = config.endpoint config.accountId = config.account config.userId = config.user - config.userAgent = USER_AGENT config.timeoutMs = normalizeNumber(config.timeoutMs, DEFAULT_CONFIG.timeoutMs, 1000, 300000) config.repoContext.cacheTtlMs = normalizeNumber( config.repoContext.cacheTtlMs, @@ -248,6 +244,7 @@ function normalizeConfig(config) { ? Math.max(0, Math.round(commitKeepRecentCount)) : DEFAULT_CONFIG.commitKeepRecentCount config.profileTokenBudget = Math.max(500, Math.round(Number(config.profileTokenBudget) || 10000)) + config.periodicFlushIntervalMs = Math.max(10000, Math.round(Number(config.periodicFlushIntervalMs) || 60000)) config.resumeContextBudget = Math.max(1024, Math.round(Number(config.resumeContextBudget) || 32000)) if (!Array.isArray(config.bypassSessionPatterns)) config.bypassSessionPatterns = [] From 3b404759dc27a5e01a66cff39f2e434f4cd9325f Mon Sep 17 00:00:00 2001 From: ElvenWang Date: Mon, 3 Aug 2026 12:14:35 +0800 Subject: [PATCH 10/17] fix(opencode-plugin): plumb periodicFlushIntervalMs config and sweep stale temp files --- .../opencode-plugin/lib/memory-session.mjs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/examples/opencode-plugin/lib/memory-session.mjs b/examples/opencode-plugin/lib/memory-session.mjs index 97c9c3cce1..ea6218f28e 100644 --- a/examples/opencode-plugin/lib/memory-session.mjs +++ b/examples/opencode-plugin/lib/memory-session.mjs @@ -37,6 +37,7 @@ export function createMemorySessionManager({ config, pluginRoot }) { async function init() { if (config.autoCapture) await migrateLegacySessionMap() await loadState() + sweepStaleTempFiles() const health = await fetchJSON(config, "/health", {}, { timeoutMs: 5000 }) if (health.ok) { await replayPending( @@ -89,6 +90,27 @@ export function createMemorySessionManager({ config, pluginRoot }) { return done } + function sweepStaleTempFiles() { + // saveState writes to a unique temp file (`${statePath}.${pid}.${n}.tmp`) then + // renames it onto statePath. A crash between writeFile and rename leaves an + // orphan temp behind; sweep them on startup so they don't accumulate forever. + try { + const dir = path.dirname(statePath) + const base = path.basename(statePath) + for (const name of fs.readdirSync(dir)) { + if (name.startsWith(`${base}.`) && name.endsWith(".tmp")) { + try { + fs.unlinkSync(path.join(dir, name)) + } catch { + // best effort; ignore files removed by a concurrent process + } + } + } + } catch (error) { + log("DEBUG", "persistence", "Temp sweep skipped", { error: error?.message }) + } + } + async function loadState() { try { if (!fs.existsSync(statePath)) { From 8df655d59d52f9010f2d8d35fb8b4a057a5ee006 Mon Sep 17 00:00:00 2001 From: ElvenWang Date: Mon, 3 Aug 2026 12:22:14 +0800 Subject: [PATCH 11/17] fix(opencode-plugin): PID-aware async temp sweep and remove redundant intervalMs clamp --- .../opencode-plugin/lib/memory-session.mjs | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/examples/opencode-plugin/lib/memory-session.mjs b/examples/opencode-plugin/lib/memory-session.mjs index ea6218f28e..8eb9c0484f 100644 --- a/examples/opencode-plugin/lib/memory-session.mjs +++ b/examples/opencode-plugin/lib/memory-session.mjs @@ -37,7 +37,7 @@ export function createMemorySessionManager({ config, pluginRoot }) { async function init() { if (config.autoCapture) await migrateLegacySessionMap() await loadState() - sweepStaleTempFiles() + await sweepStaleTempFiles() const health = await fetchJSON(config, "/health", {}, { timeoutMs: 5000 }) if (health.ok) { await replayPending( @@ -51,7 +51,7 @@ export function createMemorySessionManager({ config, pluginRoot }) { function startPeriodicFlush() { if (!config.autoCapture) return if (shuttingDown) return - const intervalMs = Math.max(10000, Number(config.periodicFlushIntervalMs) || 60000) + const intervalMs = config.periodicFlushIntervalMs if (flushTimer) clearInterval(flushTimer) flushTimer = setInterval(() => { // A tick may have been queued just before flushAll's clearInterval; skip it @@ -90,17 +90,29 @@ export function createMemorySessionManager({ config, pluginRoot }) { return done } - function sweepStaleTempFiles() { + async function sweepStaleTempFiles() { // saveState writes to a unique temp file (`${statePath}.${pid}.${n}.tmp`) then // renames it onto statePath. A crash between writeFile and rename leaves an // orphan temp behind; sweep them on startup so they don't accumulate forever. + // We parse the PID from the filename and skip files whose PID is still alive + // to avoid removing a concurrent process's in-flight temp. try { const dir = path.dirname(statePath) const base = path.basename(statePath) - for (const name of fs.readdirSync(dir)) { + const entries = await fs.promises.readdir(dir) + for (const name of entries) { if (name.startsWith(`${base}.`) && name.endsWith(".tmp")) { + const match = name.match(/\.(\d+)\.\d+\.tmp$/) + if (match) { + try { + process.kill(Number(match[1]), 0) // probe: process is alive + continue // skip — this temp file may be in active use + } catch { + // ESRCH — process not found, safe to clean up + } + } try { - fs.unlinkSync(path.join(dir, name)) + await fs.promises.unlink(path.join(dir, name)) } catch { // best effort; ignore files removed by a concurrent process } From ee38f5ccc36e03949f7a6d904d0fff12b5acf764 Mon Sep 17 00:00:00 2001 From: ElvenWang Date: Mon, 3 Aug 2026 12:26:28 +0800 Subject: [PATCH 12/17] fix(opencode-plugin): only clean temp when process.kill probe returns ESRCH --- examples/opencode-plugin/lib/memory-session.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/opencode-plugin/lib/memory-session.mjs b/examples/opencode-plugin/lib/memory-session.mjs index 8eb9c0484f..a5092a23af 100644 --- a/examples/opencode-plugin/lib/memory-session.mjs +++ b/examples/opencode-plugin/lib/memory-session.mjs @@ -107,7 +107,12 @@ export function createMemorySessionManager({ config, pluginRoot }) { try { process.kill(Number(match[1]), 0) // probe: process is alive continue // skip — this temp file may be in active use - } catch { + } catch (err) { + if (err?.code !== "ESRCH") { + // EPERM or other error — process may be alive (possibly a + // different OS user); skip to be safe. + continue + } // ESRCH — process not found, safe to clean up } } From e306b8ff87d2d6fd0611e256c43c7ad4b4208e1b Mon Sep 17 00:00:00 2001 From: ElvenWang Date: Mon, 3 Aug 2026 12:31:27 +0800 Subject: [PATCH 13/17] fix(opencode-plugin): reclaim live-PID temp files older than 10min to avoid PID-reuse leak --- .../opencode-plugin/lib/memory-session.mjs | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/examples/opencode-plugin/lib/memory-session.mjs b/examples/opencode-plugin/lib/memory-session.mjs index a5092a23af..2cf378dc0b 100644 --- a/examples/opencode-plugin/lib/memory-session.mjs +++ b/examples/opencode-plugin/lib/memory-session.mjs @@ -95,29 +95,42 @@ export function createMemorySessionManager({ config, pluginRoot }) { // renames it onto statePath. A crash between writeFile and rename leaves an // orphan temp behind; sweep them on startup so they don't accumulate forever. // We parse the PID from the filename and skip files whose PID is still alive - // to avoid removing a concurrent process's in-flight temp. + // to avoid removing a concurrent process's in-flight temp — but only if the + // file is recent, so a recycled PID can't shield an orphan temp forever. + const RECENT_TEMP_MS = 10 * 60 * 1000 try { const dir = path.dirname(statePath) const base = path.basename(statePath) const entries = await fs.promises.readdir(dir) for (const name of entries) { if (name.startsWith(`${base}.`) && name.endsWith(".tmp")) { + const fullPath = path.join(dir, name) const match = name.match(/\.(\d+)\.\d+\.tmp$/) if (match) { + let alive = false try { process.kill(Number(match[1]), 0) // probe: process is alive - continue // skip — this temp file may be in active use + alive = true } catch (err) { - if (err?.code !== "ESRCH") { - // EPERM or other error — process may be alive (possibly a - // different OS user); skip to be safe. - continue + // EPERM (or other non-ESRCH) — process may be alive (possibly a + // different OS user); treat as alive to be safe. ESRCH — dead. + alive = err?.code !== "ESRCH" + } + if (alive) { + // A live PID may still be writing this temp. Only skip it while it + // is recent; a stale temp under a recycled PID is reclaimed below. + let recent = true + try { + const stat = await fs.promises.stat(fullPath) + recent = Date.now() - stat.mtimeMs < RECENT_TEMP_MS + } catch { + recent = false // vanished/unreadable — fall through to unlink } - // ESRCH — process not found, safe to clean up + if (recent) continue } } try { - await fs.promises.unlink(path.join(dir, name)) + await fs.promises.unlink(fullPath) } catch { // best effort; ignore files removed by a concurrent process } From e082d18117b52cf147f3921a175eb40e3d30a20f Mon Sep 17 00:00:00 2001 From: ElvenWang Date: Mon, 3 Aug 2026 12:38:31 +0800 Subject: [PATCH 14/17] test(opencode-plugin): cover flush gate, teardown drain, commit routing, temp sweep; harden intervalMs fallback --- examples/opencode-plugin/lib/memory-session.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/opencode-plugin/lib/memory-session.mjs b/examples/opencode-plugin/lib/memory-session.mjs index 2cf378dc0b..bc89a95d37 100644 --- a/examples/opencode-plugin/lib/memory-session.mjs +++ b/examples/opencode-plugin/lib/memory-session.mjs @@ -51,7 +51,7 @@ export function createMemorySessionManager({ config, pluginRoot }) { function startPeriodicFlush() { if (!config.autoCapture) return if (shuttingDown) return - const intervalMs = config.periodicFlushIntervalMs + const intervalMs = Number(config.periodicFlushIntervalMs) > 0 ? config.periodicFlushIntervalMs : 60000 if (flushTimer) clearInterval(flushTimer) flushTimer = setInterval(() => { // A tick may have been queued just before flushAll's clearInterval; skip it From 76305de7d44d27f533bd49ed5174928eff8ef7fe Mon Sep 17 00:00:00 2001 From: ElvenWang Date: Mon, 3 Aug 2026 12:38:34 +0800 Subject: [PATCH 15/17] test(opencode-plugin): cover flush gate, teardown drain, commit routing, temp sweep; harden intervalMs fallback --- .../tests/memory-session.test.mjs | 116 +++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/examples/opencode-plugin/tests/memory-session.test.mjs b/examples/opencode-plugin/tests/memory-session.test.mjs index 679ef58dac..c106e10079 100644 --- a/examples/opencode-plugin/tests/memory-session.test.mjs +++ b/examples/opencode-plugin/tests/memory-session.test.mjs @@ -1,7 +1,7 @@ import test from "node:test" import assert from "node:assert/strict" import { createServer } from "node:http" -import { mkdtemp, rm } from "node:fs/promises" +import { mkdtemp, rm, readdir, writeFile, utimes } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { createMemorySessionManager } from "../lib/memory-session.mjs" @@ -213,3 +213,117 @@ test("assistant messages are captured even when finish is not stop", async () => }) }) }) + +async function seedUserMessage(manager, sessionId, messageId, text) { + await manager.handleEvent({ + type: "message.updated", + properties: { info: { id: messageId, sessionID: sessionId, role: "user" } }, + }) + await manager.handleEvent({ + type: "message.part.updated", + properties: { + part: { id: `${messageId}-part`, messageID: messageId, sessionID: sessionId, type: "text", text }, + }, + }) +} + +test("concurrent flushSession on the same session sends each pending batch once", async () => { + await withCaptureServer(async ({ endpoint, requests }) => { + await withTempDir("ov-oc-session-", async (dir) => { + const manager = createMemorySessionManager({ config: baseConfig(endpoint), pluginRoot: dir }) + await manager.init() + await manager.handleEvent({ type: "session.created", properties: { info: { id: "oc-gate" } } }) + await seedUserMessage(manager, "oc-gate", "msg-gate-1", "Gate serialization must prevent double sends.") + + // Fire two overlapping flushes without awaiting the first. + await Promise.all([ + manager.flushSession("oc-gate", { commit: false }), + manager.flushSession("oc-gate", { commit: false }), + ]) + + const batches = requests.filter((r) => r.url === "/api/v1/sessions/oc-oc-gate/messages/batch") + assert.equal(batches.length, 1, "overlapping flushes must not double-send the same message") + await manager.flushAll({ commit: false }) + }) + }) +}) + +test("flushAll shutdown drains an in-flight periodic flush before its own loop", async () => { + await withCaptureServer(async ({ endpoint, requests }) => { + await withTempDir("ov-oc-session-", async (dir) => { + const manager = createMemorySessionManager({ + config: { ...baseConfig(endpoint), periodicFlushIntervalMs: 10000 }, + pluginRoot: dir, + }) + await manager.init() + await manager.handleEvent({ type: "session.created", properties: { info: { id: "oc-drain" } } }) + await seedUserMessage(manager, "oc-drain", "msg-drain-1", "Teardown must drain periodic work.") + + // Shutdown flush should complete without double-sending and archive via commit. + await manager.flushAll({ commit: true, shutdown: true }) + + const batches = requests.filter((r) => r.url === "/api/v1/sessions/oc-oc-drain/messages/batch") + assert.equal(batches.length, 1, "shutdown must send pending messages exactly once") + const commits = requests.filter((r) => r.method === "POST" && r.url === "/api/v1/sessions/oc-oc-drain/commit") + assert.equal(commits.length, 1, "shutdown with commit:true must commit once") + }) + }) +}) + +test("commitSession routes the send through the per-session gate", async () => { + await withCaptureServer(async ({ endpoint, requests }) => { + await withTempDir("ov-oc-session-", async (dir) => { + const manager = createMemorySessionManager({ config: baseConfig(endpoint), pluginRoot: dir }) + await manager.init() + await manager.handleEvent({ type: "session.created", properties: { info: { id: "oc-tool" } } }) + await seedUserMessage(manager, "oc-tool", "msg-tool-1", "Tool commit must flush pending first.") + + await manager.commitSession("oc-oc-tool", "oc-tool") + + const batches = requests.filter((r) => r.url === "/api/v1/sessions/oc-oc-tool/messages/batch") + assert.equal(batches.length, 1, "commitSession must flush pending messages before committing") + const order = requests + .filter((r) => r.method === "POST" && r.url?.startsWith("/api/v1/sessions/oc-oc-tool/")) + .map((r) => r.url) + assert.deepEqual( + order, + ["/api/v1/sessions/oc-oc-tool/messages/batch", "/api/v1/sessions/oc-oc-tool/commit"], + "the batch send must happen before the commit", + ) + }) + }) +}) + +test("startup sweep reclaims orphan temp files from a dead PID but keeps live/recent ones", async () => { + await withCaptureServer(async ({ endpoint }) => { + await withTempDir("ov-oc-session-", async (dir) => { + const statePath = join(dir, "openviking-session-state.json") + // Dead PID: pick an implausibly large PID that is not running. + const deadTmp = `${statePath}.999999.0.tmp` + // Live PID (this test process), recent mtime -> must be kept. + const liveRecentTmp = `${statePath}.${process.pid}.0.tmp` + // Live PID but stale mtime (simulated recycled PID) -> must be reclaimed. + const liveStaleTmp = `${statePath}.${process.pid}.1.tmp` + await writeFile(deadTmp, "orphan") + await writeFile(liveRecentTmp, "in-flight") + await writeFile(liveStaleTmp, "recycled-pid-orphan") + const stale = new Date(Date.now() - 30 * 60 * 1000) + await utimes(liveStaleTmp, stale, stale) + + const manager = createMemorySessionManager({ config: baseConfig(endpoint), pluginRoot: dir }) + await manager.init() + + const remaining = (await readdir(dir)).filter((n) => n.endsWith(".tmp")) + assert.ok(!remaining.includes(`openviking-session-state.json.999999.0.tmp`), "dead-PID orphan must be removed") + assert.ok( + remaining.includes(`openviking-session-state.json.${process.pid}.0.tmp`), + "recent live-PID temp must be kept", + ) + assert.ok( + !remaining.includes(`openviking-session-state.json.${process.pid}.1.tmp`), + "stale live-PID temp (recycled PID) must be reclaimed", + ) + await manager.flushAll({ commit: false, shutdown: true }) + }) + }) +}) From 77e0076d1a25f2620c8942785a9290a9cda45274 Mon Sep 17 00:00:00 2001 From: ElvenWang Date: Mon, 3 Aug 2026 12:47:17 +0800 Subject: [PATCH 16/17] fix(opencode-plugin): skip redundant threshold commit in commitSession; strengthen drain/dead-pid tests --- examples/opencode-plugin/lib/memory-session.mjs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/examples/opencode-plugin/lib/memory-session.mjs b/examples/opencode-plugin/lib/memory-session.mjs index bc89a95d37..ca5e3c30b5 100644 --- a/examples/opencode-plugin/lib/memory-session.mjs +++ b/examples/opencode-plugin/lib/memory-session.mjs @@ -387,7 +387,7 @@ export function createMemorySessionManager({ config, pluginRoot }) { if (!shutdown) startPeriodicFlush() } - async function flushSession(opencodeSessionId, { commit = false, reason = "manual" } = {}) { + async function flushSession(opencodeSessionId, { commit = false, reason = "manual", skipThreshold = false } = {}) { if (!opencodeSessionId) return false const state = sessions.get(opencodeSessionId) if (!state) return false @@ -402,7 +402,7 @@ export function createMemorySessionManager({ config, pluginRoot }) { const added = await flushPendingMessages(opencodeSessionId, state) if (commit && config.autoCapture) { await commitOvSession(state.ovSessionId, { force: true, reason }) - } else if (added > 0) { + } else if (added > 0 && !skipThreshold) { await maybeCommitByThreshold(state) } await saveState() @@ -426,8 +426,9 @@ export function createMemorySessionManager({ config, pluginRoot }) { // Route through flushSession so the send goes through the same // per-session serialization gate. Calling flushPendingMessages directly // would bypass state.flushing and could double-send a batch that a - // concurrent periodic/idle flush is already sending. - await flushSession(opencodeSessionId, { commit: false, reason: "tool" }) + // concurrent periodic/idle flush is already sending. skipThreshold avoids + // a redundant threshold commit here since we force-commit right below. + await flushSession(opencodeSessionId, { commit: false, reason: "tool", skipThreshold: true }) } return commitOvSession(sessionId, { force: true, abortSignal, reason: "tool" }) } From 122f49180ff557cafc510c3c71bb6250ba2ae6bd Mon Sep 17 00:00:00 2001 From: ElvenWang Date: Mon, 3 Aug 2026 12:47:20 +0800 Subject: [PATCH 17/17] fix(opencode-plugin): skip redundant threshold commit in commitSession; strengthen drain/dead-pid tests --- .../tests/memory-session.test.mjs | 109 ++++++++++++++---- 1 file changed, 87 insertions(+), 22 deletions(-) diff --git a/examples/opencode-plugin/tests/memory-session.test.mjs b/examples/opencode-plugin/tests/memory-session.test.mjs index c106e10079..2578799df5 100644 --- a/examples/opencode-plugin/tests/memory-session.test.mjs +++ b/examples/opencode-plugin/tests/memory-session.test.mjs @@ -15,8 +15,24 @@ async function withTempDir(prefix, fn) { } } -async function withCaptureServer(fn) { +// Find a PID that is definitely not running. process.kill(pid, 0) throws +// ESRCH for a non-existent PID; probe upward from a large base so the test +// does not rely on a hard-coded value that could be alive on high-pid_max +// systems. +function findDeadPid() { + for (let pid = 999983; pid < 4194304; pid += 104729) { + try { + process.kill(pid, 0) + } catch (err) { + if (err?.code === "ESRCH") return pid + } + } + throw new Error("could not find a dead PID") +} + +async function withCaptureServer(fn, options = {}) { const requests = [] + const { onBatch } = options const server = createServer(async (req, res) => { let body = "" req.setEncoding("utf8") @@ -27,6 +43,9 @@ async function withCaptureServer(fn) { if (req.url === "/health") { res.end(JSON.stringify({ status: "ok" })) } else if (req.url?.startsWith("/api/v1/sessions/") && req.url.endsWith("/messages/batch")) { + // Optional hook lets a test hold a batch open so it can observe an + // in-flight send (e.g. a periodic flush) racing another operation. + if (typeof onBatch === "function") await onBatch(req.url) res.end(JSON.stringify({ status: "ok", result: { accepted: true } })) } else if (req.url?.startsWith("/api/v1/sessions/")) { res.end(JSON.stringify({ status: "ok", result: { pending_tokens: 0 } })) @@ -249,25 +268,66 @@ test("concurrent flushSession on the same session sends each pending batch once" }) test("flushAll shutdown drains an in-flight periodic flush before its own loop", async () => { - await withCaptureServer(async ({ endpoint, requests }) => { - await withTempDir("ov-oc-session-", async (dir) => { - const manager = createMemorySessionManager({ - config: { ...baseConfig(endpoint), periodicFlushIntervalMs: 10000 }, - pluginRoot: dir, - }) - await manager.init() - await manager.handleEvent({ type: "session.created", properties: { info: { id: "oc-drain" } } }) - await seedUserMessage(manager, "oc-drain", "msg-drain-1", "Teardown must drain periodic work.") - - // Shutdown flush should complete without double-sending and archive via commit. - await manager.flushAll({ commit: true, shutdown: true }) - - const batches = requests.filter((r) => r.url === "/api/v1/sessions/oc-oc-drain/messages/batch") - assert.equal(batches.length, 1, "shutdown must send pending messages exactly once") - const commits = requests.filter((r) => r.method === "POST" && r.url === "/api/v1/sessions/oc-oc-drain/commit") - assert.equal(commits.length, 1, "shutdown with commit:true must commit once") - }) + // Gate the first batch send so a periodic flush is genuinely mid-flight when + // flushAll({shutdown:true}) runs. This exercises the drain branch + // (`if (periodicFlushPromise) await periodicFlushPromise`) in flushAll and + // proves the shutdown waits for in-flight periodic work instead of racing it. + let releaseBatch + const batchGate = new Promise((resolve) => { + releaseBatch = resolve }) + let firstBatchSeen + const firstBatch = new Promise((resolve) => { + firstBatchSeen = resolve + }) + let batchCount = 0 + + await withCaptureServer( + async ({ endpoint, requests }) => { + await withTempDir("ov-oc-session-", async (dir) => { + const manager = createMemorySessionManager({ + config: { ...baseConfig(endpoint), periodicFlushIntervalMs: 10 }, + pluginRoot: dir, + }) + await manager.init() + await manager.handleEvent({ type: "session.created", properties: { info: { id: "oc-drain" } } }) + await seedUserMessage(manager, "oc-drain", "msg-drain-1", "Teardown must drain periodic work.") + + // Wait until the periodic timer has actually started a batch send that + // is now blocked in the gate, then tear down while it is in-flight. + await firstBatch + const shutdown = manager.flushAll({ commit: true, shutdown: true }) + // Let the in-flight periodic batch complete; flushAll must have awaited it. + releaseBatch() + await shutdown + + const batches = requests.filter((r) => r.url === "/api/v1/sessions/oc-oc-drain/messages/batch") + assert.equal(batches.length, 1, "shutdown must not double-send the in-flight periodic batch") + const commits = requests.filter((r) => r.method === "POST" && r.url === "/api/v1/sessions/oc-oc-drain/commit") + assert.equal(commits.length, 1, "shutdown with commit:true must commit exactly once") + // The gated batch must have been fully sent before the commit fired. + const order = requests + .filter((r) => r.method === "POST" && r.url?.startsWith("/api/v1/sessions/oc-oc-drain/")) + .map((r) => r.url) + assert.deepEqual( + order, + ["/api/v1/sessions/oc-oc-drain/messages/batch", "/api/v1/sessions/oc-oc-drain/commit"], + "the drained periodic batch must complete before the shutdown commit", + ) + }) + }, + { + onBatch: async (url) => { + if (url === "/api/v1/sessions/oc-oc-drain/messages/batch") { + batchCount += 1 + if (batchCount === 1) { + firstBatchSeen() + await batchGate + } + } + }, + }, + ) }) test("commitSession routes the send through the per-session gate", async () => { @@ -298,8 +358,10 @@ test("startup sweep reclaims orphan temp files from a dead PID but keeps live/re await withCaptureServer(async ({ endpoint }) => { await withTempDir("ov-oc-session-", async (dir) => { const statePath = join(dir, "openviking-session-state.json") - // Dead PID: pick an implausibly large PID that is not running. - const deadTmp = `${statePath}.999999.0.tmp` + // Dead PID: derived at runtime so the test is not flaky on systems with + // a high pid_max where a hard-coded large PID could be alive. + const deadPid = findDeadPid() + const deadTmp = `${statePath}.${deadPid}.0.tmp` // Live PID (this test process), recent mtime -> must be kept. const liveRecentTmp = `${statePath}.${process.pid}.0.tmp` // Live PID but stale mtime (simulated recycled PID) -> must be reclaimed. @@ -314,7 +376,10 @@ test("startup sweep reclaims orphan temp files from a dead PID but keeps live/re await manager.init() const remaining = (await readdir(dir)).filter((n) => n.endsWith(".tmp")) - assert.ok(!remaining.includes(`openviking-session-state.json.999999.0.tmp`), "dead-PID orphan must be removed") + assert.ok( + !remaining.includes(`openviking-session-state.json.${deadPid}.0.tmp`), + "dead-PID orphan must be removed", + ) assert.ok( remaining.includes(`openviking-session-state.json.${process.pid}.0.tmp`), "recent live-PID temp must be kept",