fix: clean up attachments for released subagent runs

This commit is contained in:
Tak Hoffman
2026-03-24 09:52:18 -05:00
parent 35de467b1a
commit 938f8f4d83
3 changed files with 73 additions and 15 deletions

View File

@@ -1,19 +1,24 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const noop = () => {};
const mocks = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({})),
ensureRuntimePluginsLoaded: vi.fn(),
ensureContextEnginesInitialized: vi.fn(),
resolveContextEngine: vi.fn(),
onSubagentEnded: vi.fn(async () => {}),
onAgentEvent: vi.fn(() => () => {}),
onAgentEvent: vi.fn(() => noop),
persistSubagentRunsToDisk: vi.fn(),
restoreSubagentRunsFromDisk: vi.fn(() => 0),
getSubagentRunsSnapshotForRead: vi.fn((runs: Map<string, unknown>) => new Map(runs)),
}));
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
return {
...actual,
loadConfig: vi.fn(() => ({})),
loadConfig: mocks.loadConfig,
};
});
@@ -34,9 +39,9 @@ vi.mock("./runtime-plugins.js", () => ({
}));
vi.mock("./subagent-registry-state.js", () => ({
getSubagentRunsSnapshotForRead: vi.fn((runs: Map<string, unknown>) => new Map(runs)),
getSubagentRunsSnapshotForRead: mocks.getSubagentRunsSnapshotForRead,
persistSubagentRunsToDisk: mocks.persistSubagentRunsToDisk,
restoreSubagentRunsFromDisk: vi.fn(() => 0),
restoreSubagentRunsFromDisk: mocks.restoreSubagentRunsFromDisk,
}));
vi.mock("./subagent-announce-queue.js", () => ({
@@ -47,33 +52,40 @@ vi.mock("./timeout.js", () => ({
resolveAgentTimeoutMs: vi.fn(() => 1_000),
}));
import {
registerSubagentRun,
releaseSubagentRun,
resetSubagentRegistryForTests,
} from "./subagent-registry.js";
describe("subagent-registry context-engine bootstrap", () => {
beforeEach(() => {
let mod: typeof import("./subagent-registry.js");
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
mocks.resolveContextEngine.mockResolvedValue({
onSubagentEnded: mocks.onSubagentEnded,
});
resetSubagentRegistryForTests({ persist: false });
mod = await import("./subagent-registry.js");
mod.resetSubagentRegistryForTests({ persist: false });
});
it("reloads runtime plugins with the spawned workspace before subagent end hooks", async () => {
registerSubagentRun({
it("reloads runtime plugins with the spawned workspace before released subagent end hooks", async () => {
mod.addSubagentRunForTests({
runId: "run-1",
childSessionKey: "agent:main:session:child",
controllerSessionKey: "agent:main:session:parent",
requesterSessionKey: "agent:main:session:parent",
requesterOrigin: undefined,
requesterDisplayKey: "parent",
task: "task",
cleanup: "keep",
expectsCompletionMessage: undefined,
spawnMode: "run",
workspaceDir: "/tmp/workspace",
createdAt: 1,
startedAt: 1,
sessionStartedAt: 1,
accumulatedRuntimeMs: 0,
cleanupHandled: false,
});
releaseSubagentRun("run-1");
mod.releaseSubagentRun("run-1");
await vi.waitFor(() => {
expect(mocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({

View File

@@ -464,4 +464,46 @@ describe("subagent registry seam flow", () => {
await expect(fs.access(attachmentsDir)).rejects.toMatchObject({ code: "ENOENT" });
});
});
it("removes attachments for released delete-mode runs", async () => {
const attachmentsRootDir = await fs.mkdtemp(
path.join(os.tmpdir(), "openclaw-release-attachments-"),
);
const attachmentsDir = path.join(attachmentsRootDir, "child");
await fs.mkdir(attachmentsDir, { recursive: true });
await fs.writeFile(path.join(attachmentsDir, "artifact.txt"), "artifact");
mod.addSubagentRunForTests({
runId: "run-release-delete",
childSessionKey: "agent:main:subagent:release-delete",
controllerSessionKey: "agent:main:main",
requesterSessionKey: "agent:main:main",
requesterOrigin: undefined,
requesterDisplayKey: "main",
task: "release attachments",
cleanup: "delete",
expectsCompletionMessage: undefined,
spawnMode: "run",
attachmentsDir,
attachmentsRootDir,
createdAt: 1,
startedAt: 1,
sessionStartedAt: 1,
accumulatedRuntimeMs: 0,
cleanupHandled: false,
});
mod.releaseSubagentRun("run-release-delete");
await vi.waitFor(async () => {
await expect(fs.access(attachmentsDir)).rejects.toMatchObject({ code: "ENOENT" });
});
await vi.waitFor(() => {
expect(mocks.onSubagentEnded).toHaveBeenCalledWith({
childSessionKey: "agent:main:subagent:release-delete",
reason: "released",
workspaceDir: undefined,
});
});
});
});

View File

@@ -1545,6 +1545,10 @@ export function releaseSubagentRun(runId: string) {
clearPendingLifecycleError(runId);
const entry = subagentRuns.get(runId);
if (entry) {
const shouldDeleteAttachments = entry.cleanup === "delete" || !entry.retainAttachmentsOnKeep;
if (shouldDeleteAttachments) {
void safeRemoveAttachmentsDir(entry);
}
void notifyContextEngineSubagentEnded({
childSessionKey: entry.childSessionKey,
reason: "released",