mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-21 05:32:53 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 ?? "");
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user