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

View File

@ -3,10 +3,6 @@ import {
resolveActiveAgentId,
resolveAgentWorkspacePrefix,
resolveOpenClawStateDir,
resolveWorkspaceDirForName,
resolveWorkspaceRoot,
getActiveWorkspaceName,
ensureManagedWorkspaceRouting,
} from "@/lib/workspace";
import {
startRun,
@ -145,18 +141,8 @@ export async function POST(req: Request) {
});
const sessionMeta = getSessionMeta(sessionId);
const workspaceName =
sessionMeta?.workspaceName
?? getActiveWorkspaceName()
?? "default";
const workspaceRoot =
sessionMeta?.workspaceRoot
?? resolveWorkspaceRoot()
?? resolveWorkspaceDirForName(workspaceName);
ensureManagedWorkspaceRouting(workspaceName, workspaceRoot, { markDefault: false });
const effectiveAgentId =
sessionMeta?.chatAgentId
?? sessionMeta?.workspaceAgentId
sessionMeta?.workspaceAgentId
?? resolveActiveAgentId();
try {
@ -193,11 +179,11 @@ export async function POST(req: Request) {
} catch { /* ignore enqueue errors on closed stream */ }
}, 15_000);
unsubscribe = subscribeToRun(
runKey,
(event: SseEvent | null) => {
if (closed) {return;}
if (event === null) {
unsubscribe = subscribeToRun(
runKey,
(event: SseEvent | null) => {
if (closed) {return;}
if (event === null) {
closed = true;
if (keepalive) { clearInterval(keepalive); keepalive = null; }
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", () => ({
discoverWorkspaces: vi.fn(() => []),
ensureManagedWorkspaceRouting: vi.fn(),
getActiveWorkspaceName: vi.fn(() => null),
resolveOpenClawStateDir: vi.fn(() => "/home/testuser/.openclaw-dench"),
resolveWorkspaceDirForName: vi.fn((name: string) =>
@ -176,11 +175,6 @@ describe("profiles API", () => {
expect(json.stateDir).toBe(STATE_DIR);
expect(json.workspaceRoot).toBe(`${STATE_DIR}/workspace-work`);
expect(json.workspace.name).toBe("work");
expect(workspace.ensureManagedWorkspaceRouting).toHaveBeenCalledWith(
"work",
`${STATE_DIR}/workspace-work`,
{ markDefault: false },
);
expect(workspace.setUIActiveWorkspace).toHaveBeenCalledWith("work");
});

View File

@ -1,9 +1,7 @@
import {
discoverWorkspaces,
ensureManagedWorkspaceRouting,
getActiveWorkspaceName,
resolveOpenClawStateDir,
resolveWorkspaceDirForName,
resolveWorkspaceRoot,
setUIActiveWorkspace,
} 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);
const activeWorkspace = getActiveWorkspaceName();
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 { type WebSessionMeta, ensureDir, readIndex, writeIndex } from "./shared";
import {
ensureManagedWorkspaceRouting,
getActiveWorkspaceName,
resolveActiveAgentId,
resolveWorkspaceDirForName,
resolveWorkspaceRoot,
} from "@/lib/workspace";
import { allocateChatAgent } from "@/lib/chat-agent-registry";
export { type WebSessionMeta };
@ -38,22 +36,8 @@ export async function POST(req: Request) {
const workspaceName = getActiveWorkspaceName() ?? "default";
const workspaceRoot = resolveWorkspaceRoot() ?? resolveWorkspaceDirForName(workspaceName);
ensureManagedWorkspaceRouting(workspaceName, workspaceRoot, { markDefault: false });
const workspaceAgentId = resolveActiveAgentId();
// 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 gatewaySessionKey = `agent:${workspaceAgentId}:web:${id}`;
const session: WebSessionMeta = {
id,
@ -65,9 +49,8 @@ export async function POST(req: Request) {
workspaceName: workspaceName || undefined,
workspaceRoot,
workspaceAgentId,
chatAgentId,
gatewaySessionKey,
agentMode: chatAgentId ? "ephemeral" : "workspace",
agentMode: "workspace",
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. */
export function resolveSessionAgentId(sessionId: string, fallbackAgentId: string): string {
const meta = getSessionMeta(sessionId);
return meta?.chatAgentId ?? meta?.workspaceAgentId ?? fallbackAgentId;
return meta?.workspaceAgentId ?? fallbackAgentId;
}
/** Resolve the gateway session key for a session.
* Uses pinned metadata when available, constructs from the given agent ID otherwise. */
export function resolveSessionKey(sessionId: string, fallbackAgentId: string): string {
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),
resolveWorkspaceRoot: vi.fn(() => null),
ensureAgentInConfig: vi.fn(),
ensureChatAgentPool: vi.fn(),
}));
describe("POST /api/workspace/init", () => {

View File

@ -14,7 +14,6 @@ import {
isValidWorkspaceName,
resolveWorkspaceRoot,
ensureAgentInConfig,
ensureChatAgentPool,
} from "@/lib/workspace";
import {
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.
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.
setUIActiveWorkspace(workspaceName);
const activeWorkspace = getActiveWorkspaceName();

View File

@ -1,9 +1,7 @@
import {
discoverWorkspaces,
ensureManagedWorkspaceRouting,
getActiveWorkspaceName,
resolveOpenClawStateDir,
resolveWorkspaceDirForName,
resolveWorkspaceRoot,
setUIActiveWorkspace,
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);
setDefaultAgentInConfig(requestedWorkspace);
trackServer("workspace_switched");

View File

@ -23,7 +23,6 @@ import {
writeFile,
} from "node:fs/promises";
import { resolveWebChatDir, resolveOpenClawStateDir, resolveActiveAgentId } from "./workspace";
import { markChatAgentIdle } from "./chat-agent-registry";
import {
type AgentProcessHandle,
type AgentEvent,
@ -1971,9 +1970,6 @@ function wireChildProcess(run: ActiveRun): void {
// Normal completion path.
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).
flushPersistence(run).catch(() => {});
@ -2106,8 +2102,6 @@ function finalizeWaitingRun(run: ActiveRun): void {
stopSubscribeProcess(run);
try { markChatAgentIdle(run.sessionId); } catch { /* best-effort */ }
flushPersistence(run).catch(() => {});
for (const sub of run.subscribers) {

View File

@ -379,6 +379,30 @@ describe("workspace (flat workspace model)", () => {
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 () => {
const { discoverWorkspaces, mockReaddir, mockExists, mockReadFile } =
await importWorkspace();
@ -614,7 +638,7 @@ describe("workspace (flat workspace model)", () => {
});
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 } =
await importWorkspace();
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.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-2")?.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")).toBeUndefined();
expect(written.agents.list.find((agent) => agent.id === "kumareth")?.default).toBe(true);
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));
}
function isInternalWorkspaceNameForDiscovery(name: string): boolean {
const lowered = name.toLowerCase();
return lowered === GATEWAY_MAIN_AGENT_ID || lowered.startsWith(CHAT_SLOT_PREFIX);
}
function stateDirPath(): string {
return join(resolveOpenClawHomeDir(), FIXED_STATE_DIRNAME);
}
@ -149,7 +154,7 @@ function scanWorkspaceNames(stateDir: string): string[] {
const names = readdirSync(stateDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.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));
} catch {
return [];
@ -412,18 +417,15 @@ function applyDefaultAgentMarker(list: OpenClawAgentEntry[], targetAgentId: stri
return changed;
}
function ensureChatSlotEntries(
list: OpenClawAgentEntry[],
baseId: string,
workspaceDir: string,
poolSize: number,
): boolean {
let changed = false;
for (let i = 1; i <= poolSize; i++) {
const slotId = `${CHAT_SLOT_PREFIX}${baseId}-${i}`;
changed = upsertAgentWorkspace(list, slotId, workspaceDir) || changed;
function removeChatSlotEntries(list: OpenClawAgentEntry[], baseId?: string): boolean {
const prefix = baseId ? `${CHAT_SLOT_PREFIX}${baseId}-` : CHAT_SLOT_PREFIX;
const next = list.filter((agent) => !agent.id.startsWith(prefix));
if (next.length === list.length) {
return false;
}
return changed;
list.length = 0;
list.push(...next);
return true;
}
/**
@ -448,6 +450,9 @@ export function ensureAgentInConfig(
const list = ensureConfigAgentList(config);
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;
if (options?.markDefault ?? true) {
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
* knows about them at startup. Each slot shares the workspace directory
* of the parent workspace agent, enabling concurrent chat sessions.
* Legacy compatibility helper.
*
* 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 {
void workspaceDir;
void poolSize;
const normalized = normalizeWorkspaceName(workspaceName);
if (!normalized) {
throw new Error("Invalid workspace name.");
}
const config = readOpenClawConfig();
let changed = syncDefaultWorkspacePointer(config, normalized, workspaceDir);
let changed = false;
const list = ensureConfigAgentList(config);
const baseId = workspaceNameToAgentId(normalized);
changed = ensureChatSlotEntries(list, baseId, workspaceDir, poolSize) || changed;
changed = removeChatSlotEntries(list, baseId) || changed;
if (changed) {
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.
* This is used on chat creation/send so stale `main` or `chat-slot-main-*`
* entries are redirected back to the intended managed workspace.
* Repair the workspace mapping for an existing managed agent without creating
* new entries. This also prunes stale `chat-slot-*` agent entries.
*/
export function ensureManagedWorkspaceRouting(
workspaceName: string,
workspaceDir: string,
options?: { markDefault?: boolean; poolSize?: number },
): void {
void options;
const normalized = normalizeWorkspaceName(workspaceName);
if (!normalized) {
throw new Error("Invalid workspace name.");
@ -498,15 +505,13 @@ export function ensureManagedWorkspaceRouting(
let changed = syncDefaultWorkspacePointer(config, normalized, workspaceDir);
const list = ensureConfigAgentList(config);
const resolvedId = workspaceNameToAgentId(normalized);
changed = upsertAgentWorkspace(list, resolvedId, workspaceDir) || changed;
changed = ensureChatSlotEntries(
list,
resolvedId,
workspaceDir,
options?.poolSize ?? DEFAULT_CHAT_POOL_SIZE,
) || changed;
if (options?.markDefault) {
const existing = list.find((agent) => agent.id === resolvedId);
if (existing && existing.workspace !== workspaceDir) {
existing.workspace = workspaceDir;
changed = true;
}
changed = removeChatSlotEntries(list, resolvedId) || changed;
if (options?.markDefault && existing) {
changed = applyDefaultAgentMarker(list, resolvedId) || changed;
}
@ -519,13 +524,8 @@ export function ensureManagedWorkspaceRouting(
* Return the list of chat slot agent IDs for a workspace.
*/
export function getChatSlotAgentIds(workspaceName?: string): string[] {
const config = readOpenClawConfig();
const list = config.agents?.list;
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);
void workspaceName;
return [];
}
export { CHAT_SLOT_PREFIX, DEFAULT_CHAT_POOL_SIZE };

View File

@ -244,6 +244,34 @@ describe("discoverWorkspaceDirs", () => {
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", () => {
const ws = path.join(tempDir, "workspace");
mkdirSync(ws, { recursive: true });

View File

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