Skip to content
Open
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
32 changes: 32 additions & 0 deletions apps/memos-local-plugin/adapters/openclaw/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,15 +278,47 @@ 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({
home: resolveHome("openclaw"),
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", {
Expand Down
30 changes: 28 additions & 2 deletions apps/memos-local-plugin/adapters/openclaw/runtime-lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;

Expand Down
96 changes: 96 additions & 0 deletions apps/memos-local-plugin/tests/integration/diagnostic-mode.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -42,6 +42,7 @@ function acquire(home: ResolvedHome, pid = process.pid) {
pid,
now: () => 1_700_000_000_000,
unwrittenOwnerStaleMs: 0,
skipLock,
});
}

Expand Down Expand Up @@ -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);
});
});
Loading