diff --git a/CHANGELOG.md b/CHANGELOG.md index 97098afdf5a..c7f985e4a60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//agent` but the matching `agents.list[]` entries are missing from config. (#65113) Thanks @neeravmakwana. ## 2026.4.11 diff --git a/src/commands/doctor-state-integrity.test.ts b/src/commands/doctor-state-integrity.test.ts index 058e1373db9..0271d61c6a4 100644 --- a/src/commands/doctor-state-integrity.test.ts +++ b/src/commands/doctor-state-integrity.test.ts @@ -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 ?? ""); diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index ce85c2fe618..e24af7f2557 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -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(); + 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");