diff --git a/apps/memos-local-plugin/adapters/openclaw/index.ts b/apps/memos-local-plugin/adapters/openclaw/index.ts index 9c318889a..7518336ba 100644 --- a/apps/memos-local-plugin/adapters/openclaw/index.ts +++ b/apps/memos-local-plugin/adapters/openclaw/index.ts @@ -278,7 +278,34 @@ async function closeViewerAfterFailedBootstrap( // ─── Registration ────────────────────────────────────────────────────────── +/** + * Detect if running in diagnostic mode (e.g., `openclaw doctor`). + * + * Diagnostic processes should skip runtime lock acquisition to avoid + * false positive DuplicateOpenClawRuntimeError when the gateway is running. + */ +function isDiagnosticMode(): boolean { + // Check for OPENCLAW_DIAGNOSTIC_MODE environment variable + if (process.env.OPENCLAW_DIAGNOSTIC_MODE === "1" || + process.env.OPENCLAW_DIAGNOSTIC_MODE === "true") { + return true; + } + + // Check if process title or argv contains "doctor" + if (process.title?.includes("doctor")) { + return true; + } + + if (process.argv.some(arg => arg.includes("doctor"))) { + return true; + } + + return false; +} + function register(api: OpenClawPluginApi): void { + const diagnosticMode = isDiagnosticMode(); + let runtimeLock: OpenClawRuntimeLockHandle; try { runtimeLock = acquireOpenClawRuntimeLock({ @@ -286,7 +313,12 @@ function register(api: OpenClawPluginApi): void { pluginId: PLUGIN_ID, version: PLUGIN_VERSION, viewerPort: OPENCLAW_VIEWER_PORT, + skipLock: diagnosticMode, }); + + if (diagnosticMode) { + api.logger.info("memos-local: running in diagnostic mode (lock acquisition skipped)"); + } } catch (err) { const duplicate = err instanceof DuplicateOpenClawRuntimeError; api.logger.error("memos-local: duplicate OpenClaw runtime blocked", { diff --git a/apps/memos-local-plugin/adapters/openclaw/runtime-lock.ts b/apps/memos-local-plugin/adapters/openclaw/runtime-lock.ts index 55d2f6e43..e3cdbd3ea 100644 --- a/apps/memos-local-plugin/adapters/openclaw/runtime-lock.ts +++ b/apps/memos-local-plugin/adapters/openclaw/runtime-lock.ts @@ -32,6 +32,11 @@ export interface AcquireOpenClawRuntimeLockOptions { pid?: number; now?: () => number; unwrittenOwnerStaleMs?: number; + /** + * Skip lock acquisition for read-only diagnostic processes (e.g., `openclaw doctor`). + * When true, returns a no-op lock handle that doesn't create lock files. + */ + skipLock?: boolean; } export class DuplicateOpenClawRuntimeError extends Error { @@ -58,9 +63,30 @@ export function acquireOpenClawRuntimeLock( options: AcquireOpenClawRuntimeLockOptions, ): OpenClawRuntimeLockHandle { const lockDir = openClawRuntimeLockDir(options.home); - const ownerFile = path.join(lockDir, OWNER_FILENAME); - const now = options.now ?? Date.now; const pid = options.pid ?? process.pid; + const now = options.now ?? Date.now; + + // Skip lock acquisition for diagnostic processes (e.g., openclaw doctor) + if (options.skipLock) { + const noopOwner: OpenClawRuntimeLockOwner = { + pluginId: options.pluginId, + version: options.version, + pid, + token: "diagnostic-noop", + startedAt: now(), + dbFile: options.home.dbFile, + viewerPort: options.viewerPort, + }; + return { + lockDir, + owner: noopOwner, + release() { + // No-op: diagnostic mode doesn't hold a lock + }, + }; + } + + const ownerFile = path.join(lockDir, OWNER_FILENAME); const unwrittenOwnerStaleMs = options.unwrittenOwnerStaleMs ?? UNWRITTEN_OWNER_STALE_MS; diff --git a/apps/memos-local-plugin/tests/integration/diagnostic-mode.test.ts b/apps/memos-local-plugin/tests/integration/diagnostic-mode.test.ts new file mode 100644 index 000000000..c2b75f1eb --- /dev/null +++ b/apps/memos-local-plugin/tests/integration/diagnostic-mode.test.ts @@ -0,0 +1,96 @@ +/** + * Integration test for diagnostic mode (openclaw doctor) behavior. + * + * Verifies that when OPENCLAW_DIAGNOSTIC_MODE is set, the plugin + * can register even when a gateway instance is already holding the lock. + */ +import { describe, it, expect, afterEach } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { ResolvedHome } from "../../core/config/index.js"; +import { + acquireOpenClawRuntimeLock, + DuplicateOpenClawRuntimeError, +} from "../../adapters/openclaw/runtime-lock.js"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +function tmpHome(): ResolvedHome { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "memos-diag-")); + roots.push(root); + return { + root, + configFile: path.join(root, "config.yaml"), + dataDir: path.join(root, "data"), + dbFile: path.join(root, "data", "memos.db"), + skillsDir: path.join(root, "skills"), + logsDir: path.join(root, "logs"), + daemonDir: path.join(root, "daemon"), + }; +} + +describe("Diagnostic mode integration", () => { + it("allows doctor process to run alongside gateway", () => { + const home = tmpHome(); + + // Simulate gateway acquiring lock + const gatewayLock = acquireOpenClawRuntimeLock({ + home, + pluginId: "memos-local-plugin", + version: "2.0.6", + viewerPort: 18799, + pid: process.pid, + skipLock: false, + }); + + expect(gatewayLock.owner.token).not.toBe("diagnostic-noop"); + + // Simulate doctor process with skipLock + const doctorLock = acquireOpenClawRuntimeLock({ + home, + pluginId: "memos-local-plugin", + version: "2.0.6", + viewerPort: 18799, + pid: process.pid + 1, + skipLock: true, + }); + + expect(doctorLock.owner.token).toBe("diagnostic-noop"); + expect(() => doctorLock.release()).not.toThrow(); + + gatewayLock.release(); + }); + + it("still blocks duplicate gateway instances", () => { + const home = tmpHome(); + + const lock1 = acquireOpenClawRuntimeLock({ + home, + pluginId: "memos-local-plugin", + version: "2.0.6", + viewerPort: 18799, + pid: process.pid, + skipLock: false, + }); + + expect(() => { + acquireOpenClawRuntimeLock({ + home, + pluginId: "memos-local-plugin", + version: "2.0.6", + viewerPort: 18799, + pid: process.pid, + skipLock: false, + }); + }).toThrow(DuplicateOpenClawRuntimeError); + + lock1.release(); + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/adapters/openclaw-runtime-lock.test.ts b/apps/memos-local-plugin/tests/unit/adapters/openclaw-runtime-lock.test.ts index bbfa37cda..b80000c02 100644 --- a/apps/memos-local-plugin/tests/unit/adapters/openclaw-runtime-lock.test.ts +++ b/apps/memos-local-plugin/tests/unit/adapters/openclaw-runtime-lock.test.ts @@ -33,7 +33,7 @@ function tmpHome(): ResolvedHome { }; } -function acquire(home: ResolvedHome, pid = process.pid) { +function acquire(home: ResolvedHome, pid = process.pid, skipLock = false) { return acquireOpenClawRuntimeLock({ home, pluginId: "memos-local-plugin", @@ -42,6 +42,7 @@ function acquire(home: ResolvedHome, pid = process.pid) { pid, now: () => 1_700_000_000_000, unwrittenOwnerStaleMs: 0, + skipLock, }); } @@ -98,4 +99,38 @@ describe("OpenClaw runtime lock", () => { lock.release(); }); + + it("allows diagnostic mode to skip lock when gateway is running", () => { + const home = tmpHome(); + const gatewayLock = acquire(home, process.pid, false); + + // Diagnostic mode should not throw even though gateway lock exists + const diagnosticLock = acquire(home, process.pid + 1, true); + expect(diagnosticLock.owner.token).toBe("diagnostic-noop"); + + // Gateway lock file should still exist + const ownerPath = path.join(gatewayLock.lockDir, "owner.json"); + expect(fs.existsSync(ownerPath)).toBe(true); + + // Diagnostic release is a no-op + diagnosticLock.release(); + expect(fs.existsSync(ownerPath)).toBe(true); + + // Gateway release cleans up + gatewayLock.release(); + expect(fs.existsSync(gatewayLock.lockDir)).toBe(false); + }); + + it("diagnostic mode does not create lock files", () => { + const home = tmpHome(); + const lock = acquire(home, process.pid, true); + const lockDir = openClawRuntimeLockDir(home); + + // Lock directory should not be created in diagnostic mode + expect(fs.existsSync(lockDir)).toBe(false); + expect(lock.owner.token).toBe("diagnostic-noop"); + + lock.release(); + expect(fs.existsSync(lockDir)).toBe(false); + }); });