fix: warn about orphaned agent dirs (#65113) (thanks @neeravmakwana)

* doctor: warn about orphaned agent dirs

* docs(changelog): note orphaned agent warning

* doctor: preserve orphan agent dir casing

* doctor: flag unreachable agent dirs

* fix: polish orphan agent dir warning

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Neerav Makwana
2026-04-12 00:00:08 -04:00
committed by GitHub
parent ef98a8dd49
commit 33836abc53
3 changed files with 210 additions and 1 deletions

View File

@@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Matrix/mentions: keep room mention gating strict while accepting visible `@displayName` Matrix URI labels, so `requireMention` works for non-OpenClaw Matrix clients again. (#64796) Thanks @hclsys.
- Doctor: warn when on-disk agent directories still exist under `~/.openclaw/agents/<id>/agent` but the matching `agents.list[]` entries are missing from config. (#65113) Thanks @neeravmakwana.
## 2026.4.11

View File

@@ -58,6 +58,17 @@ function stateIntegrityText(): string {
.join("\n");
}
function createAgentDir(agentId: string, includeNestedAgentDir = true) {
const stateDir = process.env.OPENCLAW_STATE_DIR;
if (!stateDir) {
throw new Error("OPENCLAW_STATE_DIR is not set");
}
const targetDir = includeNestedAgentDir
? path.join(stateDir, "agents", agentId, "agent")
: path.join(stateDir, "agents", agentId);
fs.mkdirSync(targetDir, { recursive: true });
}
const OAUTH_PROMPT_MATCHER = expect.objectContaining({
message: expect.stringContaining("Create OAuth dir at"),
});
@@ -144,6 +155,110 @@ describe("doctor state integrity oauth dir checks", () => {
expect(stateIntegrityText()).toContain("CRITICAL: OAuth dir missing");
});
it("warns about orphaned on-disk agent directories missing from agents.list", async () => {
createAgentDir("big-brain");
createAgentDir("cerebro");
const text = await runStateIntegrityText({
agents: {
list: [{ id: "main", default: true }],
},
});
expect(text).toContain("without a matching agents.list entry");
expect(text).toContain("Examples: big-brain, cerebro");
expect(text).toContain("config-driven routing, identity, and model selection will ignore them");
});
it("detects orphaned agent dirs even when the on-disk folder casing differs", async () => {
createAgentDir("Research");
const text = await runStateIntegrityText({
agents: {
list: [{ id: "main", default: true }],
},
});
expect(text).toContain("without a matching agents.list entry");
expect(text).toContain("Examples: Research (id research)");
});
it("ignores configured agent dirs and incomplete agent folders", async () => {
createAgentDir("main");
createAgentDir("ops");
createAgentDir("staging", false);
const text = await runStateIntegrityText({
agents: {
list: [{ id: "main", default: true }, { id: "ops" }],
},
});
expect(text).not.toContain("without a matching agents.list entry");
expect(text).not.toContain("Examples:");
});
it("warns when a case-mismatched agent dir does not resolve to the configured agent path", async () => {
createAgentDir("Research");
const realpathNative = fs.realpathSync.native.bind(fs.realpathSync);
const realpathSpy = vi
.spyOn(fs.realpathSync, "native")
.mockImplementation((target, options) => {
const targetPath = String(target);
if (targetPath.endsWith(`${path.sep}agents${path.sep}research${path.sep}agent`)) {
const error = new Error("ENOENT");
(error as NodeJS.ErrnoException).code = "ENOENT";
throw error;
}
return realpathNative(target, options);
});
try {
const text = await runStateIntegrityText({
agents: {
list: [{ id: "main", default: true }, { id: "research" }],
},
});
expect(text).toContain("without a matching agents.list entry");
expect(text).toContain("Examples: Research (id research)");
} finally {
realpathSpy.mockRestore();
}
});
it("does not warn when a case-mismatched dir resolves to the configured agent path", async () => {
createAgentDir("Research");
const realpathNative = fs.realpathSync.native.bind(fs.realpathSync);
const resolvedResearchAgentDir = realpathNative(
path.join(process.env.OPENCLAW_STATE_DIR ?? "", "agents", "Research", "agent"),
);
const realpathSpy = vi
.spyOn(fs.realpathSync, "native")
.mockImplementation((target, options) => {
const targetPath = String(target);
if (targetPath.endsWith(`${path.sep}agents${path.sep}research${path.sep}agent`)) {
return resolvedResearchAgentDir;
}
return realpathNative(target, options);
});
try {
const text = await runStateIntegrityText({
agents: {
list: [{ id: "main", default: true }, { id: "research" }],
},
});
expect(text).not.toContain("without a matching agents.list entry");
expect(text).not.toContain("Examples:");
} finally {
realpathSpy.mockRestore();
}
});
it("detects orphan transcripts and offers archival remediation", async () => {
const cfg: OpenClawConfig = {};
setupSessionState(cfg, process.env, process.env.HOME ?? "");

View File

@@ -1,7 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { listAgentEntries, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { listBundledChannelPluginIds } from "../channels/plugins/bundled-ids.js";
import { hasBundledChannelPersistedAuthState } from "../channels/plugins/persisted-auth-state.js";
import { formatCliCommand } from "../cli/command-format.js";
@@ -19,6 +19,7 @@ import {
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
import { resolveMemoryBackendConfig } from "../memory-host-sdk/engine-storage.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { parseAgentSessionKey } from "../sessions/session-key-utils.js";
import { asNullableObjectRecord } from "../shared/record-coerce.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
@@ -59,6 +60,86 @@ function existsFile(filePath: string): boolean {
}
}
type OrphanAgentDir = {
dirName: string;
agentId: string;
};
function tryResolveNativeRealPath(targetPath: string): string | null {
try {
return fs.realpathSync.native(targetPath);
} catch {
return null;
}
}
function isReachableConfiguredAgentDir(params: {
agentsRoot: string;
dirName: string;
agentId: string;
}): boolean {
if (params.dirName === params.agentId) {
return true;
}
const rawDir = path.join(params.agentsRoot, params.dirName, "agent");
const normalizedDir = path.join(params.agentsRoot, params.agentId, "agent");
const rawRealPath = tryResolveNativeRealPath(rawDir);
const normalizedRealPath = tryResolveNativeRealPath(normalizedDir);
return rawRealPath !== null && rawRealPath === normalizedRealPath;
}
function formatOrphanAgentDirLabel(entry: OrphanAgentDir): string {
return entry.dirName === entry.agentId ? entry.agentId : `${entry.dirName} (id ${entry.agentId})`;
}
function formatOrphanAgentDirPreview(entries: OrphanAgentDir[], limit = 3): string {
const labels = entries.slice(0, limit).map(formatOrphanAgentDirLabel);
const remaining = entries.length - labels.length;
if (remaining > 0) {
return `${labels.join(", ")}, and ${remaining} more`;
}
return labels.join(", ");
}
function listOrphanAgentDirs(cfg: OpenClawConfig, stateDir: string): OrphanAgentDir[] {
const configuredIds = new Set<string>();
configuredIds.add(normalizeAgentId(resolveDefaultAgentId(cfg)));
for (const entry of listAgentEntries(cfg)) {
configuredIds.add(normalizeAgentId(entry.id));
}
const agentsRoot = path.join(stateDir, "agents");
try {
const entries = fs.readdirSync(agentsRoot, { withFileTypes: true });
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => ({
dirName: entry.name,
agentId: normalizeAgentId(entry.name),
}))
.filter(({ dirName, agentId }) => {
const hasNestedAgentDir = existsDir(path.join(agentsRoot, dirName, "agent"));
if (!hasNestedAgentDir) {
return false;
}
if (!configuredIds.has(agentId)) {
return true;
}
return !isReachableConfiguredAgentDir({
agentsRoot,
dirName,
agentId,
});
})
.toSorted(
(left, right) =>
left.agentId.localeCompare(right.agentId) || left.dirName.localeCompare(right.dirName),
);
} catch {
return [];
}
}
function canWriteDir(dir: string): boolean {
try {
fs.accessSync(dir, fs.constants.W_OK);
@@ -718,6 +799,18 @@ export async function noteStateIntegrity(
);
}
const orphanAgentDirs = listOrphanAgentDirs(cfg, stateDir);
if (orphanAgentDirs.length > 0) {
warnings.push(
[
`- Found ${countLabel(orphanAgentDirs.length, "agent directory", "agent directories")} on disk without a matching agents.list entry.`,
" These agents can still have sessions/auth state on disk, but config-driven routing, identity, and model selection will ignore them.",
` Examples: ${formatOrphanAgentDirPreview(orphanAgentDirs)}`,
` Restore the missing agents.list entries or remove stale dirs after confirming they are no longer needed: ${shortenHomePath(path.join(stateDir, "agents"))}`,
].join("\n"),
);
}
const store = loadSessionStore(storePath);
const sessionPathOpts = resolveSessionFilePathOptions({ agentId, storePath });
const entries = Object.entries(store).filter(([, entry]) => entry && typeof entry === "object");