fix(doctor): warn on volatile SQLite state (#94725)

* fix(doctor): warn on volatile SQLite state

* fix(doctor): resolve symlinked state paths
This commit is contained in:
Vincent Koc
2026-06-19 08:56:37 +08:00
committed by GitHub
parent c79f1e5441
commit c9605779ef
4 changed files with 234 additions and 1 deletions

View File

@@ -397,6 +397,7 @@ That stages grounded durable candidates into the short-term dreaming store while
- **State dir permissions**: verifies writability; offers to repair permissions (and emits a `chown` hint when owner/group mismatch is detected).
- **macOS cloud-synced state dir**: warns when state resolves under iCloud Drive (`~/Library/Mobile Documents/com~apple~CloudDocs/...`) or `~/Library/CloudStorage/...` because sync-backed paths can cause slower I/O and lock/sync races.
- **Linux SD or eMMC state dir**: warns when state resolves to an `mmcblk*` mount source, because SD or eMMC-backed random I/O can be slower and wear faster under session and credential writes.
- **Linux volatile state dir**: warns when state resolves to `tmpfs` or `ramfs`, because sessions, credentials, config, and SQLite state with its WAL/journal sidecars will disappear on reboot. Docker `overlay` mounts are intentionally not flagged because their writable layers persist across host reboots while the container remains.
- **Session dirs missing**: `sessions/` and the session store directory are required to persist history and avoid `ENOENT` crashes.
- **Transcript mismatch**: warns when recent session entries have missing transcript files.
- **Main session "1-line JSONL"**: flags when the main transcript has only one line (history is not accumulating).

View File

@@ -24,6 +24,7 @@ describe("detectLinuxSdBackedStateDir", () => {
const result = detectLinuxSdBackedStateDir("/home/pi/.openclaw", {
platform: "linux",
mountInfo,
resolveRealPath: (statePath) => statePath,
});
expect(result).toEqual({
@@ -51,6 +52,7 @@ describe("detectLinuxSdBackedStateDir", () => {
const result = detectLinuxSdBackedStateDir("/home/user/.openclaw", {
platform: "linux",
mountInfo,
resolveRealPath: (statePath) => statePath,
resolveDeviceRealPath: (devicePath) => {
if (devicePath === "/dev/disk/by-uuid/abcd-1234") {
return "/dev/mmcblk0p2";

View File

@@ -287,6 +287,27 @@ function tryResolveRealPath(targetPath: string): string | null {
}
}
function resolvePathThroughExistingAncestor(
targetPath: string,
resolveRealPath: (targetPath: string) => string | null,
pathOps: Pick<typeof path, "resolve" | "dirname" | "basename">,
): string | null {
const missingSegments: string[] = [];
let candidate = pathOps.resolve(targetPath);
while (true) {
const resolved = resolveRealPath(candidate);
if (resolved) {
return pathOps.resolve(resolved, ...missingSegments);
}
const parent = pathOps.dirname(candidate);
if (parent === candidate) {
return null;
}
missingSegments.unshift(pathOps.basename(candidate));
candidate = parent;
}
}
function decodeMountInfoPath(value: string): string {
return value.replace(/\\([0-7]{3})/g, (_, octal: string) =>
String.fromCharCode(Number.parseInt(octal, 8)),
@@ -436,7 +457,9 @@ export function detectLinuxSdBackedStateDir(
const linuxPath = path.posix;
const resolveRealPath = deps?.resolveRealPath ?? tryResolveRealPath;
const resolvedStatePath = resolveRealPath(stateDir) ?? linuxPath.resolve(stateDir);
const resolvedStatePath =
resolvePathThroughExistingAncestor(stateDir, resolveRealPath, linuxPath) ??
linuxPath.resolve(stateDir);
const mountInfo = deps?.mountInfo ?? tryReadLinuxMountInfo();
if (!mountInfo) {
return null;
@@ -491,6 +514,72 @@ export function formatLinuxSdBackedStateDirWarning(
].join("\n");
}
type LinuxVolatileStateDir = {
path: string;
mountPoint: string;
fsType: string;
};
/** Filesystems whose state disappears on reboot. Docker overlayfs is intentionally excluded. */
const VOLATILE_FS_TYPES = new Set(["tmpfs", "ramfs"]);
/** Detects Linux state directories mounted on filesystems that do not survive a reboot. */
export function detectLinuxVolatileStateDir(
stateDir: string,
deps?: {
platform?: NodeJS.Platform;
mountInfo?: string;
resolveRealPath?: (targetPath: string) => string | null;
},
): LinuxVolatileStateDir | null {
const platform = deps?.platform ?? process.platform;
if (platform !== "linux") {
return null;
}
const linuxPath = path.posix;
const resolveRealPath = deps?.resolveRealPath ?? tryResolveRealPath;
const resolvedStatePath =
resolvePathThroughExistingAncestor(stateDir, resolveRealPath, linuxPath) ??
linuxPath.resolve(stateDir);
const mountInfo = deps?.mountInfo ?? tryReadLinuxMountInfo();
if (!mountInfo) {
return null;
}
const mountEntry = findLinuxMountInfoEntryForPath(
resolvedStatePath,
parseLinuxMountInfo(mountInfo),
linuxPath,
);
if (!mountEntry || !VOLATILE_FS_TYPES.has(mountEntry.fsType)) {
return null;
}
return {
path: linuxPath.resolve(resolvedStatePath),
mountPoint: linuxPath.resolve(mountEntry.mountPoint),
fsType: mountEntry.fsType,
};
}
/** Formats the warning for state stored on a volatile Linux filesystem. */
export function formatLinuxVolatileStateDirWarning(
displayStateDir: string,
volatileDir: LinuxVolatileStateDir,
): string {
const safeFsType = escapeControlCharsForTerminal(volatileDir.fsType);
const safeMountPoint =
volatileDir.mountPoint === "/"
? "/"
: escapeControlCharsForTerminal(shortenHomePath(volatileDir.mountPoint));
return [
`- State directory is on a volatile filesystem (${displayStateDir}; fs ${safeFsType}, mount ${safeMountPoint}).`,
"- Sessions, credentials, config, and SQLite state (including WAL/journal sidecars) will be lost on reboot.",
"- Move OPENCLAW_STATE_DIR to a persistent filesystem to avoid data loss.",
].join("\n");
}
/** Detects macOS state directories under iCloud Drive or CloudStorage providers. */
export function detectMacCloudSyncedStateDir(
stateDir: string,
@@ -642,6 +731,7 @@ export async function noteStateIntegrity(
const requireOAuthDir = shouldRequireOAuthDir(cfg, env);
const cloudSyncedStateDir = detectMacCloudSyncedStateDir(stateDir);
const linuxSdBackedStateDir = detectLinuxSdBackedStateDir(stateDir);
const linuxVolatileStateDir = detectLinuxVolatileStateDir(stateDir);
const suppressOrphanTranscriptWarning = shouldSuppressOrphanTranscriptWarning(cfg, agentId);
if (cloudSyncedStateDir) {
@@ -657,6 +747,9 @@ export async function noteStateIntegrity(
if (linuxSdBackedStateDir) {
warnings.push(formatLinuxSdBackedStateDirWarning(displayStateDir, linuxSdBackedStateDir));
}
if (linuxVolatileStateDir) {
warnings.push(formatLinuxVolatileStateDirWarning(displayStateDir, linuxVolatileStateDir));
}
let stateDirExists = existsDir(stateDir);
if (!stateDirExists) {

View File

@@ -0,0 +1,137 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
detectLinuxVolatileStateDir,
formatLinuxVolatileStateDirWarning,
} from "./doctor-state-integrity.js";
describe("detectLinuxVolatileStateDir", () => {
const TMPFS_MOUNT_INFO = [
"22 1 0:21 / / rw,relatime - ext4 /dev/sda1 rw",
"30 22 0:30 / /tmp rw,nosuid,nodev - tmpfs tmpfs rw",
"35 22 0:35 / /home/user/.openclaw rw - tmpfs tmpfs rw,size=1048576k",
].join("\n");
const RAMFS_MOUNT_INFO = [
"22 1 0:21 / / rw,relatime - ext4 /dev/sda1 rw",
"35 22 0:35 / /home/user/.openclaw rw - ramfs ramfs rw",
].join("\n");
const OVERLAY_MOUNT_INFO = [
"22 1 0:21 / / rw,relatime - overlay overlay rw,lowerdir=/lower,upperdir=/upper",
].join("\n");
const EXT4_MOUNT_INFO = "22 1 0:21 / / rw,relatime - ext4 /dev/sda1 rw";
it.each([
["tmpfs", TMPFS_MOUNT_INFO],
["ramfs", RAMFS_MOUNT_INFO],
])("detects %s state directories", (fsType, mountInfo) => {
const result = detectLinuxVolatileStateDir("/home/user/.openclaw", {
platform: "linux",
mountInfo,
resolveRealPath: (targetPath) => targetPath,
});
expect(result).toMatchObject({
path: "/home/user/.openclaw",
mountPoint: "/home/user/.openclaw",
fsType,
});
});
it("uses the most specific matching mount", () => {
const mountInfo = [
"22 1 0:21 / / rw - ext4 /dev/sda1 rw",
"30 22 0:30 / /home rw - ext4 /dev/sda2 rw",
"35 30 0:35 / /home/user/.openclaw rw - tmpfs tmpfs rw",
].join("\n");
expect(
detectLinuxVolatileStateDir("/home/user/.openclaw", {
platform: "linux",
mountInfo,
resolveRealPath: (targetPath) => targetPath,
}),
).toMatchObject({
mountPoint: "/home/user/.openclaw",
fsType: "tmpfs",
});
});
it("detects a missing state directory through an existing symlink", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-volatile-"));
try {
const volatileMount = path.join(root, "volatile");
const stateLink = path.join(root, "state");
fs.mkdirSync(volatileMount);
fs.symlinkSync(volatileMount, stateLink, "dir");
const resolvedVolatileMount = fs.realpathSync(volatileMount);
const result = detectLinuxVolatileStateDir(path.join(stateLink, "openclaw"), {
platform: "linux",
mountInfo: [
"22 1 0:21 / / rw,relatime - ext4 /dev/sda1 rw",
`35 22 0:35 / ${resolvedVolatileMount} rw - tmpfs tmpfs rw`,
].join("\n"),
});
expect(result).toMatchObject({
path: path.join(resolvedVolatileMount, "openclaw"),
mountPoint: resolvedVolatileMount,
fsType: "tmpfs",
});
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
it.each([
["overlay", OVERLAY_MOUNT_INFO],
["ext4", EXT4_MOUNT_INFO],
])("does not flag %s filesystems", (_name, mountInfo) => {
expect(
detectLinuxVolatileStateDir("/home/user/.openclaw", {
platform: "linux",
mountInfo,
resolveRealPath: (targetPath) => targetPath,
}),
).toBeNull();
});
it("does not inspect mount information on non-Linux platforms", () => {
expect(
detectLinuxVolatileStateDir("/home/user/.openclaw", {
platform: "darwin",
mountInfo: TMPFS_MOUNT_INFO,
resolveRealPath: (targetPath) => targetPath,
}),
).toBeNull();
});
it("does not warn when mount information is unavailable", () => {
expect(
detectLinuxVolatileStateDir("/home/user/.openclaw", {
platform: "linux",
mountInfo: "",
resolveRealPath: (targetPath) => targetPath,
}),
).toBeNull();
});
});
describe("formatLinuxVolatileStateDirWarning", () => {
it("covers all SQLite state and sidecar files under the volatile state directory", () => {
const warning = formatLinuxVolatileStateDirWarning("~/.openclaw", {
path: "/home/user/.openclaw",
mountPoint: "/home/user/.openclaw",
fsType: "tmpfs",
});
expect(warning).toContain("volatile filesystem");
expect(warning).toContain("tmpfs");
expect(warning).toContain("SQLite state");
expect(warning).toContain("WAL/journal sidecars");
expect(warning).toContain("lost on reboot");
expect(warning).toContain("OPENCLAW_STATE_DIR");
});
});