refactor(workspace): remove chat-slot agent pool to prevent workspace pollution

Chat-slot agents were being persisted as durable entries in openclaw.json,
causing spurious workspace directories (e.g. chat-slot-main-1) to appear.
Only explicit workspace creation via init now creates durable agent entries.
Workspace discovery and session routing ignore chat-slot internals.
This commit is contained in:
kumarabhirup 2026-03-17 12:35:18 -07:00
parent 3cd51759da
commit 11478c752e
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
14 changed files with 114 additions and 120 deletions

View File

@ -215,8 +215,7 @@ describe("Chat API routes", () => {
expect(persistUserMessage).toHaveBeenCalledWith("s1", expect.objectContaining({ id: "m1" })); expect(persistUserMessage).toHaveBeenCalledWith("s1", expect.objectContaining({ id: "m1" }));
}); });
it("repairs managed workspace routing before starting a persisted session run", async () => { it("uses the persisted workspace agent id when available", async () => {
const { ensureManagedWorkspaceRouting } = await import("@/lib/workspace");
const { getSessionMeta } = await import("@/app/api/web-sessions/shared"); const { getSessionMeta } = await import("@/app/api/web-sessions/shared");
const { startRun, hasActiveRun, subscribeToRun } = await import("@/lib/active-runs"); const { startRun, hasActiveRun, subscribeToRun } = await import("@/lib/active-runs");
vi.mocked(hasActiveRun).mockReturnValue(false); vi.mocked(hasActiveRun).mockReturnValue(false);
@ -230,7 +229,6 @@ describe("Chat API routes", () => {
workspaceName: "default", workspaceName: "default",
workspaceRoot: "/home/testuser/.openclaw-dench/workspace", workspaceRoot: "/home/testuser/.openclaw-dench/workspace",
workspaceAgentId: "main", workspaceAgentId: "main",
chatAgentId: "chat-slot-main-2",
} as never); } as never);
const { POST } = await import("./route.js"); const { POST } = await import("./route.js");
@ -245,14 +243,9 @@ describe("Chat API routes", () => {
}), }),
}); });
await POST(req); await POST(req);
expect(ensureManagedWorkspaceRouting).toHaveBeenCalledWith(
"default",
"/home/testuser/.openclaw-dench/workspace",
{ markDefault: false },
);
expect(startRun).toHaveBeenCalledWith( expect(startRun).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
overrideAgentId: "chat-slot-main-2", overrideAgentId: "main",
}), }),
); );
}); });

View File

@ -3,10 +3,6 @@ import {
resolveActiveAgentId, resolveActiveAgentId,
resolveAgentWorkspacePrefix, resolveAgentWorkspacePrefix,
resolveOpenClawStateDir, resolveOpenClawStateDir,
resolveWorkspaceDirForName,
resolveWorkspaceRoot,
getActiveWorkspaceName,
ensureManagedWorkspaceRouting,
} from "@/lib/workspace"; } from "@/lib/workspace";
import { import {
startRun, startRun,
@ -145,18 +141,8 @@ export async function POST(req: Request) {
}); });
const sessionMeta = getSessionMeta(sessionId); const sessionMeta = getSessionMeta(sessionId);
const workspaceName =
sessionMeta?.workspaceName
?? getActiveWorkspaceName()
?? "default";
const workspaceRoot =
sessionMeta?.workspaceRoot
?? resolveWorkspaceRoot()
?? resolveWorkspaceDirForName(workspaceName);
ensureManagedWorkspaceRouting(workspaceName, workspaceRoot, { markDefault: false });
const effectiveAgentId = const effectiveAgentId =
sessionMeta?.chatAgentId sessionMeta?.workspaceAgentId
?? sessionMeta?.workspaceAgentId
?? resolveActiveAgentId(); ?? resolveActiveAgentId();
try { try {
@ -193,11 +179,11 @@ export async function POST(req: Request) {
} catch { /* ignore enqueue errors on closed stream */ } } catch { /* ignore enqueue errors on closed stream */ }
}, 15_000); }, 15_000);
unsubscribe = subscribeToRun( unsubscribe = subscribeToRun(
runKey, runKey,
(event: SseEvent | null) => { (event: SseEvent | null) => {
if (closed) {return;} if (closed) {return;}
if (event === null) { if (event === null) {
closed = true; closed = true;
if (keepalive) { clearInterval(keepalive); keepalive = null; } if (keepalive) { clearInterval(keepalive); keepalive = null; }
try { controller.close(); } catch { /* already closed */ } try { controller.close(); } catch { /* already closed */ }

View File

@ -2,7 +2,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("@/lib/workspace", () => ({ vi.mock("@/lib/workspace", () => ({
discoverWorkspaces: vi.fn(() => []), discoverWorkspaces: vi.fn(() => []),
ensureManagedWorkspaceRouting: vi.fn(),
getActiveWorkspaceName: vi.fn(() => null), getActiveWorkspaceName: vi.fn(() => null),
resolveOpenClawStateDir: vi.fn(() => "/home/testuser/.openclaw-dench"), resolveOpenClawStateDir: vi.fn(() => "/home/testuser/.openclaw-dench"),
resolveWorkspaceDirForName: vi.fn((name: string) => resolveWorkspaceDirForName: vi.fn((name: string) =>
@ -176,11 +175,6 @@ describe("profiles API", () => {
expect(json.stateDir).toBe(STATE_DIR); expect(json.stateDir).toBe(STATE_DIR);
expect(json.workspaceRoot).toBe(`${STATE_DIR}/workspace-work`); expect(json.workspaceRoot).toBe(`${STATE_DIR}/workspace-work`);
expect(json.workspace.name).toBe("work"); expect(json.workspace.name).toBe("work");
expect(workspace.ensureManagedWorkspaceRouting).toHaveBeenCalledWith(
"work",
`${STATE_DIR}/workspace-work`,
{ markDefault: false },
);
expect(workspace.setUIActiveWorkspace).toHaveBeenCalledWith("work"); expect(workspace.setUIActiveWorkspace).toHaveBeenCalledWith("work");
}); });

View File

@ -1,9 +1,7 @@
import { import {
discoverWorkspaces, discoverWorkspaces,
ensureManagedWorkspaceRouting,
getActiveWorkspaceName, getActiveWorkspaceName,
resolveOpenClawStateDir, resolveOpenClawStateDir,
resolveWorkspaceDirForName,
resolveWorkspaceRoot, resolveWorkspaceRoot,
setUIActiveWorkspace, setUIActiveWorkspace,
} from "@/lib/workspace"; } from "@/lib/workspace";
@ -46,10 +44,6 @@ export async function POST(req: Request) {
); );
} }
const workspaceRoot =
discovered.find((workspace) => workspace.name === requestedWorkspace)?.workspaceDir
?? resolveWorkspaceDirForName(requestedWorkspace);
ensureManagedWorkspaceRouting(requestedWorkspace, workspaceRoot, { markDefault: false });
setUIActiveWorkspace(requestedWorkspace); setUIActiveWorkspace(requestedWorkspace);
const activeWorkspace = getActiveWorkspaceName(); const activeWorkspace = getActiveWorkspaceName();
const selected = discoverWorkspaces().find((workspace) => workspace.name === activeWorkspace) ?? null; const selected = discoverWorkspaces().find((workspace) => workspace.name === activeWorkspace) ?? null;

View File

@ -3,13 +3,11 @@ import { randomUUID } from "node:crypto";
import { trackServer } from "@/lib/telemetry"; import { trackServer } from "@/lib/telemetry";
import { type WebSessionMeta, ensureDir, readIndex, writeIndex } from "./shared"; import { type WebSessionMeta, ensureDir, readIndex, writeIndex } from "./shared";
import { import {
ensureManagedWorkspaceRouting,
getActiveWorkspaceName, getActiveWorkspaceName,
resolveActiveAgentId, resolveActiveAgentId,
resolveWorkspaceDirForName, resolveWorkspaceDirForName,
resolveWorkspaceRoot, resolveWorkspaceRoot,
} from "@/lib/workspace"; } from "@/lib/workspace";
import { allocateChatAgent } from "@/lib/chat-agent-registry";
export { type WebSessionMeta }; export { type WebSessionMeta };
@ -38,22 +36,8 @@ export async function POST(req: Request) {
const workspaceName = getActiveWorkspaceName() ?? "default"; const workspaceName = getActiveWorkspaceName() ?? "default";
const workspaceRoot = resolveWorkspaceRoot() ?? resolveWorkspaceDirForName(workspaceName); const workspaceRoot = resolveWorkspaceRoot() ?? resolveWorkspaceDirForName(workspaceName);
ensureManagedWorkspaceRouting(workspaceName, workspaceRoot, { markDefault: false });
const workspaceAgentId = resolveActiveAgentId(); const workspaceAgentId = resolveActiveAgentId();
const gatewaySessionKey = `agent:${workspaceAgentId}:web:${id}`;
// Assign a pool slot agent for concurrent chat support.
// Falls back to the workspace agent if no slots are available.
let chatAgentId: string | undefined;
let effectiveAgentId = workspaceAgentId;
try {
const slot = allocateChatAgent(id);
chatAgentId = slot.chatAgentId;
effectiveAgentId = slot.chatAgentId;
} catch {
// Fall back to workspace agent
}
const gatewaySessionKey = `agent:${effectiveAgentId}:web:${id}`;
const session: WebSessionMeta = { const session: WebSessionMeta = {
id, id,
@ -65,9 +49,8 @@ export async function POST(req: Request) {
workspaceName: workspaceName || undefined, workspaceName: workspaceName || undefined,
workspaceRoot, workspaceRoot,
workspaceAgentId, workspaceAgentId,
chatAgentId,
gatewaySessionKey, gatewaySessionKey,
agentMode: chatAgentId ? "ephemeral" : "workspace", agentMode: "workspace",
lastActiveAt: now, lastActiveAt: now,
}; };

View File

@ -110,12 +110,16 @@ export function getSessionMeta(sessionId: string): WebSessionMeta | undefined {
* Uses pinned metadata when available, falls back to workspace-global resolution. */ * Uses pinned metadata when available, falls back to workspace-global resolution. */
export function resolveSessionAgentId(sessionId: string, fallbackAgentId: string): string { export function resolveSessionAgentId(sessionId: string, fallbackAgentId: string): string {
const meta = getSessionMeta(sessionId); const meta = getSessionMeta(sessionId);
return meta?.chatAgentId ?? meta?.workspaceAgentId ?? fallbackAgentId; return meta?.workspaceAgentId ?? fallbackAgentId;
} }
/** Resolve the gateway session key for a session. /** Resolve the gateway session key for a session.
* Uses pinned metadata when available, constructs from the given agent ID otherwise. */ * Uses pinned metadata when available, constructs from the given agent ID otherwise. */
export function resolveSessionKey(sessionId: string, fallbackAgentId: string): string { export function resolveSessionKey(sessionId: string, fallbackAgentId: string): string {
const meta = getSessionMeta(sessionId); const meta = getSessionMeta(sessionId);
return meta?.gatewaySessionKey ?? `agent:${fallbackAgentId}:web:${sessionId}`; if (meta?.gatewaySessionKey && !meta.gatewaySessionKey.includes(":chat-slot-")) {
return meta.gatewaySessionKey;
}
const agentId = meta?.workspaceAgentId ?? fallbackAgentId;
return `agent:${agentId}:web:${sessionId}`;
} }

View File

@ -24,7 +24,6 @@ vi.mock("@/lib/workspace", () => ({
isValidWorkspaceName: vi.fn(() => true), isValidWorkspaceName: vi.fn(() => true),
resolveWorkspaceRoot: vi.fn(() => null), resolveWorkspaceRoot: vi.fn(() => null),
ensureAgentInConfig: vi.fn(), ensureAgentInConfig: vi.fn(),
ensureChatAgentPool: vi.fn(),
})); }));
describe("POST /api/workspace/init", () => { describe("POST /api/workspace/init", () => {

View File

@ -14,7 +14,6 @@ import {
isValidWorkspaceName, isValidWorkspaceName,
resolveWorkspaceRoot, resolveWorkspaceRoot,
ensureAgentInConfig, ensureAgentInConfig,
ensureChatAgentPool,
} from "@/lib/workspace"; } from "@/lib/workspace";
import { import {
BOOTSTRAP_TEMPLATE_CONTENT, BOOTSTRAP_TEMPLATE_CONTENT,
@ -208,9 +207,6 @@ export async function POST(req: Request) {
// Register a per-workspace agent in openclaw.json and make it the default. // Register a per-workspace agent in openclaw.json and make it the default.
ensureAgentInConfig(workspaceName, workspaceDir); ensureAgentInConfig(workspaceName, workspaceDir);
// Pre-create a pool of chat agent slots for concurrent web chat sessions.
ensureChatAgentPool(workspaceName, workspaceDir);
// Switch the UI to the new workspace. // Switch the UI to the new workspace.
setUIActiveWorkspace(workspaceName); setUIActiveWorkspace(workspaceName);
const activeWorkspace = getActiveWorkspaceName(); const activeWorkspace = getActiveWorkspaceName();

View File

@ -1,9 +1,7 @@
import { import {
discoverWorkspaces, discoverWorkspaces,
ensureManagedWorkspaceRouting,
getActiveWorkspaceName, getActiveWorkspaceName,
resolveOpenClawStateDir, resolveOpenClawStateDir,
resolveWorkspaceDirForName,
resolveWorkspaceRoot, resolveWorkspaceRoot,
setUIActiveWorkspace, setUIActiveWorkspace,
setDefaultAgentInConfig, setDefaultAgentInConfig,
@ -48,10 +46,6 @@ export async function POST(req: Request) {
); );
} }
const workspaceRoot =
discovered.find((workspace) => workspace.name === requestedWorkspace)?.workspaceDir
?? resolveWorkspaceDirForName(requestedWorkspace);
ensureManagedWorkspaceRouting(requestedWorkspace, workspaceRoot, { markDefault: false });
setUIActiveWorkspace(requestedWorkspace); setUIActiveWorkspace(requestedWorkspace);
setDefaultAgentInConfig(requestedWorkspace); setDefaultAgentInConfig(requestedWorkspace);
trackServer("workspace_switched"); trackServer("workspace_switched");

View File

@ -23,7 +23,6 @@ import {
writeFile, writeFile,
} from "node:fs/promises"; } from "node:fs/promises";
import { resolveWebChatDir, resolveOpenClawStateDir, resolveActiveAgentId } from "./workspace"; import { resolveWebChatDir, resolveOpenClawStateDir, resolveActiveAgentId } from "./workspace";
import { markChatAgentIdle } from "./chat-agent-registry";
import { import {
type AgentProcessHandle, type AgentProcessHandle,
type AgentEvent, type AgentEvent,
@ -1971,9 +1970,6 @@ function wireChildProcess(run: ActiveRun): void {
// Normal completion path. // Normal completion path.
run.status = exitedClean ? "completed" : "error"; run.status = exitedClean ? "completed" : "error";
// Release the chat agent pool slot so it can be reused.
try { markChatAgentIdle(run.sessionId); } catch { /* best-effort */ }
// Final persistence flush (removes _streaming flag). // Final persistence flush (removes _streaming flag).
flushPersistence(run).catch(() => {}); flushPersistence(run).catch(() => {});
@ -2106,8 +2102,6 @@ function finalizeWaitingRun(run: ActiveRun): void {
stopSubscribeProcess(run); stopSubscribeProcess(run);
try { markChatAgentIdle(run.sessionId); } catch { /* best-effort */ }
flushPersistence(run).catch(() => {}); flushPersistence(run).catch(() => {});
for (const sub of run.subscribers) { for (const sub of run.subscribers) {

View File

@ -379,6 +379,30 @@ describe("workspace (flat workspace model)", () => {
expect(workspaces[0]?.isActive).toBe(true); expect(workspaces[0]?.isActive).toBe(true);
}); });
it("ignores internal workspace directories for main/chat-slot agent ids", async () => {
const { discoverWorkspaces, mockReaddir, mockExists, mockReadFile } =
await importWorkspace();
mockReadFile.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReaddir.mockReturnValue([
makeDirent("workspace", true),
makeDirent("workspace-main", true),
makeDirent("workspace-chat-slot-main-1", true),
] as unknown as Dirent[]);
mockExists.mockImplementation((p) => {
const s = String(p);
return (
s === join(STATE_DIR, "workspace") ||
s === join(STATE_DIR, "workspace-main") ||
s === join(STATE_DIR, "workspace-chat-slot-main-1")
);
});
const workspaces = discoverWorkspaces();
expect(workspaces.map((workspace) => workspace.name)).toEqual(["default"]);
});
it("keeps root default and workspace-dench as distinct workspaces", async () => { it("keeps root default and workspace-dench as distinct workspaces", async () => {
const { discoverWorkspaces, mockReaddir, mockExists, mockReadFile } = const { discoverWorkspaces, mockReaddir, mockExists, mockReadFile } =
await importWorkspace(); await importWorkspace();
@ -614,7 +638,7 @@ describe("workspace (flat workspace model)", () => {
}); });
describe("ensureManagedWorkspaceRouting", () => { describe("ensureManagedWorkspaceRouting", () => {
it("repairs the default workspace agent and chat slots without flipping the active default agent", async () => { it("repairs the default workspace agent and prunes chat slots without flipping the active default agent", async () => {
const { ensureManagedWorkspaceRouting, mockExists, mockReadFile, mockWriteFile } = const { ensureManagedWorkspaceRouting, mockExists, mockReadFile, mockWriteFile } =
await importWorkspace(); await importWorkspace();
const configPath = join(STATE_DIR, "openclaw.json"); const configPath = join(STATE_DIR, "openclaw.json");
@ -657,8 +681,8 @@ describe("workspace (flat workspace model)", () => {
expect(written.agents.defaults.workspace).toBe(defaultWorkspaceDir); expect(written.agents.defaults.workspace).toBe(defaultWorkspaceDir);
expect(written.agents.list.find((agent) => agent.id === "main")?.workspace).toBe(defaultWorkspaceDir); expect(written.agents.list.find((agent) => agent.id === "main")?.workspace).toBe(defaultWorkspaceDir);
expect(written.agents.list.find((agent) => agent.id === "chat-slot-main-1")?.workspace).toBe(defaultWorkspaceDir); expect(written.agents.list.find((agent) => agent.id === "chat-slot-main-1")).toBeUndefined();
expect(written.agents.list.find((agent) => agent.id === "chat-slot-main-2")?.workspace).toBe(defaultWorkspaceDir); expect(written.agents.list.find((agent) => agent.id === "chat-slot-main-2")).toBeUndefined();
expect(written.agents.list.find((agent) => agent.id === "kumareth")?.default).toBe(true); expect(written.agents.list.find((agent) => agent.id === "kumareth")?.default).toBe(true);
expect(written.agents.list.find((agent) => agent.id === "main")?.default).toBeUndefined(); expect(written.agents.list.find((agent) => agent.id === "main")?.default).toBeUndefined();
}); });

View File

@ -85,6 +85,11 @@ function workspaceNameFromDirName(dirName: string): string | null {
return normalizeWorkspaceName(dirName.slice(WORKSPACE_PREFIX.length)); return normalizeWorkspaceName(dirName.slice(WORKSPACE_PREFIX.length));
} }
function isInternalWorkspaceNameForDiscovery(name: string): boolean {
const lowered = name.toLowerCase();
return lowered === GATEWAY_MAIN_AGENT_ID || lowered.startsWith(CHAT_SLOT_PREFIX);
}
function stateDirPath(): string { function stateDirPath(): string {
return join(resolveOpenClawHomeDir(), FIXED_STATE_DIRNAME); return join(resolveOpenClawHomeDir(), FIXED_STATE_DIRNAME);
} }
@ -149,7 +154,7 @@ function scanWorkspaceNames(stateDir: string): string[] {
const names = readdirSync(stateDir, { withFileTypes: true }) const names = readdirSync(stateDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory()) .filter((entry) => entry.isDirectory())
.map((entry) => workspaceNameFromDirName(entry.name)) .map((entry) => workspaceNameFromDirName(entry.name))
.filter((name): name is string => Boolean(name)); .filter((name): name is string => Boolean(name && !isInternalWorkspaceNameForDiscovery(name)));
return [...new Set(names)].toSorted((a, b) => a.localeCompare(b)); return [...new Set(names)].toSorted((a, b) => a.localeCompare(b));
} catch { } catch {
return []; return [];
@ -412,18 +417,15 @@ function applyDefaultAgentMarker(list: OpenClawAgentEntry[], targetAgentId: stri
return changed; return changed;
} }
function ensureChatSlotEntries( function removeChatSlotEntries(list: OpenClawAgentEntry[], baseId?: string): boolean {
list: OpenClawAgentEntry[], const prefix = baseId ? `${CHAT_SLOT_PREFIX}${baseId}-` : CHAT_SLOT_PREFIX;
baseId: string, const next = list.filter((agent) => !agent.id.startsWith(prefix));
workspaceDir: string, if (next.length === list.length) {
poolSize: number, return false;
): boolean {
let changed = false;
for (let i = 1; i <= poolSize; i++) {
const slotId = `${CHAT_SLOT_PREFIX}${baseId}-${i}`;
changed = upsertAgentWorkspace(list, slotId, workspaceDir) || changed;
} }
return changed; list.length = 0;
list.push(...next);
return true;
} }
/** /**
@ -448,6 +450,9 @@ export function ensureAgentInConfig(
const list = ensureConfigAgentList(config); const list = ensureConfigAgentList(config);
const resolvedId = workspaceNameToAgentId(normalized); const resolvedId = workspaceNameToAgentId(normalized);
// Chat slots are internal, ephemeral session mechanics and should not be
// persisted as durable named agents in config.
changed = removeChatSlotEntries(list) || changed;
changed = upsertAgentWorkspace(list, resolvedId, workspaceDir) || changed; changed = upsertAgentWorkspace(list, resolvedId, workspaceDir) || changed;
if (options?.markDefault ?? true) { if (options?.markDefault ?? true) {
changed = applyDefaultAgentMarker(list, resolvedId) || changed; changed = applyDefaultAgentMarker(list, resolvedId) || changed;
@ -459,21 +464,23 @@ export function ensureAgentInConfig(
} }
/** /**
* Pre-create a pool of chat agent slots in `agents.list[]` so the gateway * Legacy compatibility helper.
* knows about them at startup. Each slot shares the workspace directory *
* of the parent workspace agent, enabling concurrent chat sessions. * Chat-slot agents are no longer persisted in openclaw.json. This function
* now prunes stale slot entries if present.
*/ */
export function ensureChatAgentPool(workspaceName: string, workspaceDir: string, poolSize = DEFAULT_CHAT_POOL_SIZE): void { export function ensureChatAgentPool(workspaceName: string, workspaceDir: string, poolSize = DEFAULT_CHAT_POOL_SIZE): void {
void workspaceDir;
void poolSize;
const normalized = normalizeWorkspaceName(workspaceName); const normalized = normalizeWorkspaceName(workspaceName);
if (!normalized) { if (!normalized) {
throw new Error("Invalid workspace name."); throw new Error("Invalid workspace name.");
} }
const config = readOpenClawConfig(); const config = readOpenClawConfig();
let changed = syncDefaultWorkspacePointer(config, normalized, workspaceDir); let changed = false;
const list = ensureConfigAgentList(config); const list = ensureConfigAgentList(config);
const baseId = workspaceNameToAgentId(normalized); const baseId = workspaceNameToAgentId(normalized);
changed = removeChatSlotEntries(list, baseId) || changed;
changed = ensureChatSlotEntries(list, baseId, workspaceDir, poolSize) || changed;
if (changed) { if (changed) {
writeOpenClawConfig(config); writeOpenClawConfig(config);
@ -481,15 +488,15 @@ export function ensureChatAgentPool(workspaceName: string, workspaceDir: string,
} }
/** /**
* Repair the managed agent mapping for a workspace in a single config pass. * Repair the workspace mapping for an existing managed agent without creating
* This is used on chat creation/send so stale `main` or `chat-slot-main-*` * new entries. This also prunes stale `chat-slot-*` agent entries.
* entries are redirected back to the intended managed workspace.
*/ */
export function ensureManagedWorkspaceRouting( export function ensureManagedWorkspaceRouting(
workspaceName: string, workspaceName: string,
workspaceDir: string, workspaceDir: string,
options?: { markDefault?: boolean; poolSize?: number }, options?: { markDefault?: boolean; poolSize?: number },
): void { ): void {
void options;
const normalized = normalizeWorkspaceName(workspaceName); const normalized = normalizeWorkspaceName(workspaceName);
if (!normalized) { if (!normalized) {
throw new Error("Invalid workspace name."); throw new Error("Invalid workspace name.");
@ -498,15 +505,13 @@ export function ensureManagedWorkspaceRouting(
let changed = syncDefaultWorkspacePointer(config, normalized, workspaceDir); let changed = syncDefaultWorkspacePointer(config, normalized, workspaceDir);
const list = ensureConfigAgentList(config); const list = ensureConfigAgentList(config);
const resolvedId = workspaceNameToAgentId(normalized); const resolvedId = workspaceNameToAgentId(normalized);
const existing = list.find((agent) => agent.id === resolvedId);
changed = upsertAgentWorkspace(list, resolvedId, workspaceDir) || changed; if (existing && existing.workspace !== workspaceDir) {
changed = ensureChatSlotEntries( existing.workspace = workspaceDir;
list, changed = true;
resolvedId, }
workspaceDir, changed = removeChatSlotEntries(list, resolvedId) || changed;
options?.poolSize ?? DEFAULT_CHAT_POOL_SIZE, if (options?.markDefault && existing) {
) || changed;
if (options?.markDefault) {
changed = applyDefaultAgentMarker(list, resolvedId) || changed; changed = applyDefaultAgentMarker(list, resolvedId) || changed;
} }
@ -519,13 +524,8 @@ export function ensureManagedWorkspaceRouting(
* Return the list of chat slot agent IDs for a workspace. * Return the list of chat slot agent IDs for a workspace.
*/ */
export function getChatSlotAgentIds(workspaceName?: string): string[] { export function getChatSlotAgentIds(workspaceName?: string): string[] {
const config = readOpenClawConfig(); void workspaceName;
const list = config.agents?.list; return [];
if (!Array.isArray(list)) { return []; }
const baseId = workspaceNameToAgentId(workspaceName ?? getActiveWorkspaceName() ?? DEFAULT_WORKSPACE_NAME);
const prefix = `${CHAT_SLOT_PREFIX}${baseId}-`;
return list.filter((a) => a.id.startsWith(prefix)).map((a) => a.id);
} }
export { CHAT_SLOT_PREFIX, DEFAULT_CHAT_POOL_SIZE }; export { CHAT_SLOT_PREFIX, DEFAULT_CHAT_POOL_SIZE };

View File

@ -244,6 +244,34 @@ describe("discoverWorkspaceDirs", () => {
expect(dirs).toHaveLength(2); expect(dirs).toHaveLength(2);
}); });
it("ignores chat-slot agent workspace entries", () => {
const wsDefault = path.join(tempDir, "workspace");
const wsUser = path.join(tempDir, "workspace-user");
const wsSlot = path.join(tempDir, "workspace-chat-slot-main-1");
mkdirSync(wsDefault, { recursive: true });
mkdirSync(wsUser, { recursive: true });
mkdirSync(wsSlot, { recursive: true });
writeFileSync(
path.join(tempDir, "openclaw.json"),
JSON.stringify({
agents: {
defaults: { workspace: wsDefault },
list: [
{ id: "main", workspace: wsDefault },
{ id: "user", workspace: wsUser },
{ id: "chat-slot-main-1", workspace: wsSlot },
],
},
}),
"utf-8",
);
const dirs = discoverWorkspaceDirs(tempDir);
expect(dirs).toContain(path.resolve(wsDefault));
expect(dirs).toContain(path.resolve(wsUser));
expect(dirs).not.toContain(path.resolve(wsSlot));
});
it("deduplicates workspace dirs", () => { it("deduplicates workspace dirs", () => {
const ws = path.join(tempDir, "workspace"); const ws = path.join(tempDir, "workspace");
mkdirSync(ws, { recursive: true }); mkdirSync(ws, { recursive: true });

View File

@ -241,6 +241,7 @@ export type SkillSyncResult = {
*/ */
export function discoverWorkspaceDirs(stateDir: string): string[] { export function discoverWorkspaceDirs(stateDir: string): string[] {
const dirs = new Set<string>(); const dirs = new Set<string>();
const CHAT_SLOT_PREFIX = "chat-slot-";
for (const name of ["openclaw.json", "config.json"]) { for (const name of ["openclaw.json", "config.json"]) {
const configPath = path.join(stateDir, name); const configPath = path.join(stateDir, name);
if (!existsSync(configPath)) { if (!existsSync(configPath)) {
@ -250,7 +251,7 @@ export function discoverWorkspaceDirs(stateDir: string): string[] {
const raw = JSON.parse(readFileSync(configPath, "utf-8")) as { const raw = JSON.parse(readFileSync(configPath, "utf-8")) as {
agents?: { agents?: {
defaults?: { workspace?: string }; defaults?: { workspace?: string };
list?: Array<{ workspace?: string }>; list?: Array<{ id?: string; workspace?: string }>;
}; };
}; };
const defaultWs = raw?.agents?.defaults?.workspace?.trim(); const defaultWs = raw?.agents?.defaults?.workspace?.trim();
@ -258,6 +259,10 @@ export function discoverWorkspaceDirs(stateDir: string): string[] {
dirs.add(path.resolve(defaultWs)); dirs.add(path.resolve(defaultWs));
} }
for (const agent of raw?.agents?.list ?? []) { for (const agent of raw?.agents?.list ?? []) {
const id = agent.id?.trim().toLowerCase();
if (id?.startsWith(CHAT_SLOT_PREFIX)) {
continue;
}
const ws = agent.workspace?.trim(); const ws = agent.workspace?.trim();
if (ws && existsSync(ws)) { if (ws && existsSync(ws)) {
dirs.add(path.resolve(ws)); dirs.add(path.resolve(ws));