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:
parent
3cd51759da
commit
11478c752e
@ -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",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 */ }
|
||||||
|
|||||||
@ -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");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user