























@@ -61,6 +61,15 @@ function expectSessionStore(
6161expect(loadSessionStore(storePath, { skipCache: true })).toEqual(sessions);
6262}
636364+function readJsonLogs(): Array<Record<string, unknown>> {
65+return runtime.log.mock.calls
66+.filter((call): call is [string, ...unknown[]] => {
67+const arg = call[0];
68+return typeof arg === "string" && arg.startsWith("{");
69+})
70+.map((call) => JSON.parse(call[0]) as Record<string, unknown>);
71+}
72+6473describe("agents delete command", () => {
6574beforeEach(() => {
6675configMocks.readConfigFileSnapshot.mockReset();
@@ -175,4 +184,202 @@ describe("agents delete command", () => {
175184});
176185});
177186});
187+188+it("skips workspace removal when another agent shares the same workspace (#70890)", async () => {
189+await withStateDirEnv("openclaw-agents-delete-shared-workspace-", async ({ stateDir }) => {
190+const sharedWorkspace = path.join(stateDir, "workspace-shared");
191+await fs.mkdir(sharedWorkspace, { recursive: true });
192+193+const now = Date.now();
194+const cfg: OpenClawConfig = {
195+agents: {
196+list: [
197+{ id: "main", workspace: sharedWorkspace },
198+{ id: "ops", workspace: sharedWorkspace },
199+],
200+},
201+} satisfies OpenClawConfig;
202+await arrangeAgentsDeleteTest({
203+ stateDir,
204+ cfg,
205+deletedAgentId: "ops",
206+sessions: {
207+"agent:ops:main": { sessionId: "sess-ops-main", updatedAt: now + 1 },
208+"agent:main:main": { sessionId: "sess-main", updatedAt: now + 2 },
209+},
210+});
211+212+await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime);
213+214+// Workspace should still exist — it was shared
215+const stat = await fs.stat(sharedWorkspace).catch(() => null);
216+expect(stat).not.toBeNull();
217+218+// The JSON output should report why the workspace was retained.
219+const jsonOutput = readJsonLogs();
220+expect(jsonOutput).toHaveLength(1);
221+expect(jsonOutput[0]).toMatchObject({
222+workspaceRetained: true,
223+workspaceRetainedReason: "shared",
224+workspaceSharedWith: ["main"],
225+});
226+expect(processMocks.runCommandWithTimeout).not.toHaveBeenCalledWith(
227+["trash", sharedWorkspace],
228+{ timeoutMs: 5000 },
229+);
230+});
231+});
232+233+it("skips workspace removal when another agent workspace overlaps a child path (#70890)", async () => {
234+await withStateDirEnv("openclaw-agents-delete-overlapping-workspace-", async ({ stateDir }) => {
235+const sharedWorkspace = path.join(stateDir, "workspace-shared");
236+const childWorkspace = path.join(sharedWorkspace, "ops-child");
237+await fs.mkdir(childWorkspace, { recursive: true });
238+239+const now = Date.now();
240+const cfg: OpenClawConfig = {
241+agents: {
242+list: [
243+{ id: "main", workspace: sharedWorkspace },
244+{ id: "ops", workspace: childWorkspace },
245+],
246+},
247+} satisfies OpenClawConfig;
248+await arrangeAgentsDeleteTest({
249+ stateDir,
250+ cfg,
251+deletedAgentId: "ops",
252+sessions: {
253+"agent:ops:main": { sessionId: "sess-ops-main", updatedAt: now + 1 },
254+"agent:main:main": { sessionId: "sess-main", updatedAt: now + 2 },
255+},
256+});
257+258+await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime);
259+260+expect(readJsonLogs()[0]).toMatchObject({
261+workspaceRetained: true,
262+workspaceSharedWith: ["main"],
263+});
264+expect(processMocks.runCommandWithTimeout).not.toHaveBeenCalledWith(
265+["trash", childWorkspace],
266+{ timeoutMs: 5000 },
267+);
268+});
269+});
270+271+it("skips workspace removal when deleting a parent workspace that contains another agent workspace (#70890)", async () => {
272+await withStateDirEnv("openclaw-agents-delete-parent-workspace-", async ({ stateDir }) => {
273+const sharedWorkspace = path.join(stateDir, "workspace-shared");
274+const childWorkspace = path.join(sharedWorkspace, "main-child");
275+await fs.mkdir(childWorkspace, { recursive: true });
276+277+const now = Date.now();
278+const cfg: OpenClawConfig = {
279+agents: {
280+list: [
281+{ id: "main", workspace: childWorkspace },
282+{ id: "ops", workspace: sharedWorkspace },
283+],
284+},
285+} satisfies OpenClawConfig;
286+await arrangeAgentsDeleteTest({
287+ stateDir,
288+ cfg,
289+deletedAgentId: "ops",
290+sessions: {
291+"agent:ops:main": { sessionId: "sess-ops-main", updatedAt: now + 1 },
292+"agent:main:main": { sessionId: "sess-main", updatedAt: now + 2 },
293+},
294+});
295+296+await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime);
297+298+expect(readJsonLogs()[0]).toMatchObject({
299+workspaceRetained: true,
300+workspaceSharedWith: ["main"],
301+});
302+expect(processMocks.runCommandWithTimeout).not.toHaveBeenCalledWith(
303+["trash", sharedWorkspace],
304+{ timeoutMs: 5000 },
305+);
306+});
307+});
308+309+it.runIf(process.platform !== "win32")(
310+"skips workspace removal when another agent reaches the same directory through a symlink (#70890)",
311+async () => {
312+await withStateDirEnv("openclaw-agents-delete-symlink-workspace-", async ({ stateDir }) => {
313+const realWorkspace = path.join(stateDir, "workspace-real");
314+const aliasWorkspace = path.join(stateDir, "workspace-alias");
315+await fs.mkdir(realWorkspace, { recursive: true });
316+await fs.symlink(realWorkspace, aliasWorkspace, "dir");
317+318+const now = Date.now();
319+const cfg: OpenClawConfig = {
320+agents: {
321+list: [
322+{ id: "main", workspace: realWorkspace },
323+{ id: "ops", workspace: aliasWorkspace },
324+],
325+},
326+} satisfies OpenClawConfig;
327+await arrangeAgentsDeleteTest({
328+ stateDir,
329+ cfg,
330+deletedAgentId: "ops",
331+sessions: {
332+"agent:ops:main": { sessionId: "sess-ops-main", updatedAt: now + 1 },
333+"agent:main:main": { sessionId: "sess-main", updatedAt: now + 2 },
334+},
335+});
336+337+await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime);
338+339+expect(readJsonLogs()[0]).toMatchObject({
340+workspaceRetained: true,
341+workspaceSharedWith: ["main"],
342+});
343+expect(processMocks.runCommandWithTimeout).not.toHaveBeenCalledWith(
344+["trash", aliasWorkspace],
345+{ timeoutMs: 5000 },
346+);
347+});
348+},
349+);
350+351+it("trashes workspace when no other agent shares it", async () => {
352+await withStateDirEnv("openclaw-agents-delete-unique-workspace-", async ({ stateDir }) => {
353+const opsWorkspace = path.join(stateDir, "workspace-ops");
354+const mainWorkspace = path.join(stateDir, "workspace-main");
355+await fs.mkdir(opsWorkspace, { recursive: true });
356+await fs.mkdir(mainWorkspace, { recursive: true });
357+358+const now = Date.now();
359+const cfg: OpenClawConfig = {
360+agents: {
361+list: [
362+{ id: "main", workspace: mainWorkspace },
363+{ id: "ops", workspace: opsWorkspace },
364+],
365+},
366+} satisfies OpenClawConfig;
367+await arrangeAgentsDeleteTest({
368+ stateDir,
369+ cfg,
370+deletedAgentId: "ops",
371+sessions: {
372+"agent:ops:main": { sessionId: "sess-ops-main", updatedAt: now + 1 },
373+"agent:main:main": { sessionId: "sess-main", updatedAt: now + 2 },
374+},
375+});
376+377+await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime);
378+379+// trash command should have been called for the workspace
380+expect(processMocks.runCommandWithTimeout).toHaveBeenCalledWith(["trash", opsWorkspace], {
381+timeoutMs: 5000,
382+});
383+});
384+});
178385});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。