Skip to content

Commit 537b3a5

Browse files
Memtensor-AIAutoDev Bot
andauthored
Fix #1873: fix: openclaw doctor reports DuplicateOpenClawRuntimeError (false positive) (#1874)
fix: openclaw doctor reports false positive DuplicateOpenClawRuntimeError Add diagnostic mode detection to skip runtime lock acquisition when openclaw doctor is running alongside the gateway. Changes: - Add skipLock parameter to AcquireOpenClawRuntimeLockOptions - Modify acquireOpenClawRuntimeLock to return no-op lock when skipLock=true - Add isDiagnosticMode() helper checking OPENCLAW_DIAGNOSTIC_MODE env var and process args - Update register() to detect diagnostic mode and skip lock acquisition - Add unit tests for diagnostic mode skipLock behavior - Add integration test verifying doctor can run alongside gateway Fixes #1873 Co-authored-by: AutoDev Bot <autodev@memtensor.ai>
1 parent dc24201 commit 537b3a5

4 files changed

Lines changed: 192 additions & 3 deletions

File tree

apps/memos-local-plugin/adapters/openclaw/index.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,15 +278,47 @@ async function closeViewerAfterFailedBootstrap(
278278

279279
// ─── Registration ──────────────────────────────────────────────────────────
280280

281+
/**
282+
* Detect if running in diagnostic mode (e.g., `openclaw doctor`).
283+
*
284+
* Diagnostic processes should skip runtime lock acquisition to avoid
285+
* false positive DuplicateOpenClawRuntimeError when the gateway is running.
286+
*/
287+
function isDiagnosticMode(): boolean {
288+
// Check for OPENCLAW_DIAGNOSTIC_MODE environment variable
289+
if (process.env.OPENCLAW_DIAGNOSTIC_MODE === "1" ||
290+
process.env.OPENCLAW_DIAGNOSTIC_MODE === "true") {
291+
return true;
292+
}
293+
294+
// Check if process title or argv contains "doctor"
295+
if (process.title?.includes("doctor")) {
296+
return true;
297+
}
298+
299+
if (process.argv.some(arg => arg.includes("doctor"))) {
300+
return true;
301+
}
302+
303+
return false;
304+
}
305+
281306
function register(api: OpenClawPluginApi): void {
307+
const diagnosticMode = isDiagnosticMode();
308+
282309
let runtimeLock: OpenClawRuntimeLockHandle;
283310
try {
284311
runtimeLock = acquireOpenClawRuntimeLock({
285312
home: resolveHome("openclaw"),
286313
pluginId: PLUGIN_ID,
287314
version: PLUGIN_VERSION,
288315
viewerPort: OPENCLAW_VIEWER_PORT,
316+
skipLock: diagnosticMode,
289317
});
318+
319+
if (diagnosticMode) {
320+
api.logger.info("memos-local: running in diagnostic mode (lock acquisition skipped)");
321+
}
290322
} catch (err) {
291323
const duplicate = err instanceof DuplicateOpenClawRuntimeError;
292324
api.logger.error("memos-local: duplicate OpenClaw runtime blocked", {

apps/memos-local-plugin/adapters/openclaw/runtime-lock.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ export interface AcquireOpenClawRuntimeLockOptions {
3232
pid?: number;
3333
now?: () => number;
3434
unwrittenOwnerStaleMs?: number;
35+
/**
36+
* Skip lock acquisition for read-only diagnostic processes (e.g., `openclaw doctor`).
37+
* When true, returns a no-op lock handle that doesn't create lock files.
38+
*/
39+
skipLock?: boolean;
3540
}
3641

3742
export class DuplicateOpenClawRuntimeError extends Error {
@@ -58,9 +63,30 @@ export function acquireOpenClawRuntimeLock(
5863
options: AcquireOpenClawRuntimeLockOptions,
5964
): OpenClawRuntimeLockHandle {
6065
const lockDir = openClawRuntimeLockDir(options.home);
61-
const ownerFile = path.join(lockDir, OWNER_FILENAME);
62-
const now = options.now ?? Date.now;
6366
const pid = options.pid ?? process.pid;
67+
const now = options.now ?? Date.now;
68+
69+
// Skip lock acquisition for diagnostic processes (e.g., openclaw doctor)
70+
if (options.skipLock) {
71+
const noopOwner: OpenClawRuntimeLockOwner = {
72+
pluginId: options.pluginId,
73+
version: options.version,
74+
pid,
75+
token: "diagnostic-noop",
76+
startedAt: now(),
77+
dbFile: options.home.dbFile,
78+
viewerPort: options.viewerPort,
79+
};
80+
return {
81+
lockDir,
82+
owner: noopOwner,
83+
release() {
84+
// No-op: diagnostic mode doesn't hold a lock
85+
},
86+
};
87+
}
88+
89+
const ownerFile = path.join(lockDir, OWNER_FILENAME);
6490
const unwrittenOwnerStaleMs =
6591
options.unwrittenOwnerStaleMs ?? UNWRITTEN_OWNER_STALE_MS;
6692

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* Integration test for diagnostic mode (openclaw doctor) behavior.
3+
*
4+
* Verifies that when OPENCLAW_DIAGNOSTIC_MODE is set, the plugin
5+
* can register even when a gateway instance is already holding the lock.
6+
*/
7+
import { describe, it, expect, afterEach } from "vitest";
8+
import fs from "node:fs";
9+
import os from "node:os";
10+
import path from "node:path";
11+
import type { ResolvedHome } from "../../core/config/index.js";
12+
import {
13+
acquireOpenClawRuntimeLock,
14+
DuplicateOpenClawRuntimeError,
15+
} from "../../adapters/openclaw/runtime-lock.js";
16+
17+
const roots: string[] = [];
18+
19+
afterEach(() => {
20+
for (const root of roots.splice(0)) {
21+
fs.rmSync(root, { recursive: true, force: true });
22+
}
23+
});
24+
25+
function tmpHome(): ResolvedHome {
26+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "memos-diag-"));
27+
roots.push(root);
28+
return {
29+
root,
30+
configFile: path.join(root, "config.yaml"),
31+
dataDir: path.join(root, "data"),
32+
dbFile: path.join(root, "data", "memos.db"),
33+
skillsDir: path.join(root, "skills"),
34+
logsDir: path.join(root, "logs"),
35+
daemonDir: path.join(root, "daemon"),
36+
};
37+
}
38+
39+
describe("Diagnostic mode integration", () => {
40+
it("allows doctor process to run alongside gateway", () => {
41+
const home = tmpHome();
42+
43+
// Simulate gateway acquiring lock
44+
const gatewayLock = acquireOpenClawRuntimeLock({
45+
home,
46+
pluginId: "memos-local-plugin",
47+
version: "2.0.6",
48+
viewerPort: 18799,
49+
pid: process.pid,
50+
skipLock: false,
51+
});
52+
53+
expect(gatewayLock.owner.token).not.toBe("diagnostic-noop");
54+
55+
// Simulate doctor process with skipLock
56+
const doctorLock = acquireOpenClawRuntimeLock({
57+
home,
58+
pluginId: "memos-local-plugin",
59+
version: "2.0.6",
60+
viewerPort: 18799,
61+
pid: process.pid + 1,
62+
skipLock: true,
63+
});
64+
65+
expect(doctorLock.owner.token).toBe("diagnostic-noop");
66+
expect(() => doctorLock.release()).not.toThrow();
67+
68+
gatewayLock.release();
69+
});
70+
71+
it("still blocks duplicate gateway instances", () => {
72+
const home = tmpHome();
73+
74+
const lock1 = acquireOpenClawRuntimeLock({
75+
home,
76+
pluginId: "memos-local-plugin",
77+
version: "2.0.6",
78+
viewerPort: 18799,
79+
pid: process.pid,
80+
skipLock: false,
81+
});
82+
83+
expect(() => {
84+
acquireOpenClawRuntimeLock({
85+
home,
86+
pluginId: "memos-local-plugin",
87+
version: "2.0.6",
88+
viewerPort: 18799,
89+
pid: process.pid,
90+
skipLock: false,
91+
});
92+
}).toThrow(DuplicateOpenClawRuntimeError);
93+
94+
lock1.release();
95+
});
96+
});

apps/memos-local-plugin/tests/unit/adapters/openclaw-runtime-lock.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ function tmpHome(): ResolvedHome {
3333
};
3434
}
3535

36-
function acquire(home: ResolvedHome, pid = process.pid) {
36+
function acquire(home: ResolvedHome, pid = process.pid, skipLock = false) {
3737
return acquireOpenClawRuntimeLock({
3838
home,
3939
pluginId: "memos-local-plugin",
@@ -42,6 +42,7 @@ function acquire(home: ResolvedHome, pid = process.pid) {
4242
pid,
4343
now: () => 1_700_000_000_000,
4444
unwrittenOwnerStaleMs: 0,
45+
skipLock,
4546
});
4647
}
4748

@@ -98,4 +99,38 @@ describe("OpenClaw runtime lock", () => {
9899

99100
lock.release();
100101
});
102+
103+
it("allows diagnostic mode to skip lock when gateway is running", () => {
104+
const home = tmpHome();
105+
const gatewayLock = acquire(home, process.pid, false);
106+
107+
// Diagnostic mode should not throw even though gateway lock exists
108+
const diagnosticLock = acquire(home, process.pid + 1, true);
109+
expect(diagnosticLock.owner.token).toBe("diagnostic-noop");
110+
111+
// Gateway lock file should still exist
112+
const ownerPath = path.join(gatewayLock.lockDir, "owner.json");
113+
expect(fs.existsSync(ownerPath)).toBe(true);
114+
115+
// Diagnostic release is a no-op
116+
diagnosticLock.release();
117+
expect(fs.existsSync(ownerPath)).toBe(true);
118+
119+
// Gateway release cleans up
120+
gatewayLock.release();
121+
expect(fs.existsSync(gatewayLock.lockDir)).toBe(false);
122+
});
123+
124+
it("diagnostic mode does not create lock files", () => {
125+
const home = tmpHome();
126+
const lock = acquire(home, process.pid, true);
127+
const lockDir = openClawRuntimeLockDir(home);
128+
129+
// Lock directory should not be created in diagnostic mode
130+
expect(fs.existsSync(lockDir)).toBe(false);
131+
expect(lock.owner.token).toBe("diagnostic-noop");
132+
133+
lock.release();
134+
expect(fs.existsSync(lockDir)).toBe(false);
135+
});
101136
});

0 commit comments

Comments
 (0)