feat(FN-1737): auto-delete child/task-worker agents and hide system agents by default

- Auto-delete spawned child agents when their parent task terminates (reportsTo cleanup)
- Auto-delete task-worker agents when their owned task completes
- Add includeSystem filter to AgentStore.list() and REST API
- Hide system agents by default on the agents page (show only user-facing agents)
- Wire includeSystem toggle through the API layer and AgentsView component
- Add changeset for @gsxdsm/fusion patch release
- Add comprehensive tests for agent cleanup and includeSystem filtering
This commit is contained in:
Fusion
2026-04-15 20:36:42 -07:00
committed by gsxdsm
parent f83d3483a6
commit c8a0876f45
13 changed files with 390 additions and 10 deletions

View File

@@ -0,0 +1,5 @@
---
"@gsxdsm/fusion": patch
---
Auto-delete ephemeral agents from the agents page. Task-worker agents (created by InProcessRuntime) and spawned child agents (created by TaskExecutor) are now auto-deleted after termination. The agents page hides system agents by default with a toggle to show them.

View File

@@ -2,6 +2,13 @@
## Architecture ## Architecture
- **`FN-1737 Ephemeral Agent Auto-Deletion`**: Runtime-created agents (task-workers created by `InProcessRuntime` and spawned child agents created by `TaskExecutor`) are now auto-deleted from `AgentStore` after reaching a terminal state. A 5-second delay allows the UI to observe the "terminated" state before deletion. The `includeSystem` filter in `AgentStore.listAgents()` allows callers to exclude these ephemeral agents:
- `agent.metadata?.agentKind === "task-worker"` — task-worker agents
- `agent.metadata?.type === "spawned"` — spawned child agents
- `agent.metadata?.taskWorker === true` — legacy marker
- `agent.metadata?.managedBy === "task-executor"` — executor-managed agents
- Default: `includeSystem: false` excludes these from the agents page UI
- **`FN-1736 Multi-Project Scoping Audit`**: Comprehensive audit of project-scoping across the Fusion stack found: - **`FN-1736 Multi-Project Scoping Audit`**: Comprehensive audit of project-scoping across the Fusion stack found:
- SSE/WebSocket endpoints (`/api/tasks/:id/logs/stream`, `/api/events`, `/api/ws`) already use `resolveProjectScopedStore()` or `getProjectContext()` correctly - SSE/WebSocket endpoints (`/api/tasks/:id/logs/stream`, `/api/events`, `/api/ws`) already use `resolveProjectScopedStore()` or `getProjectContext()` correctly
- Badge WebSocket (`setupBadgeWebSocket`) properly scopes per-project with listeners on scoped stores - Badge WebSocket (`setupBadgeWebSocket`) properly scopes per-project with listeners on scoped stores

View File

@@ -998,6 +998,75 @@ describe("AgentStore", () => {
expect(agents).toHaveLength(1); expect(agents).toHaveLength(1);
expect(agents[0].name).toBe("Valid"); expect(agents[0].name).toBe("Valid");
}); });
it("filters out system agents when includeSystem is false", async () => {
// Create a normal agent
const normal = await store.createAgent({ name: "Normal Agent", role: "executor" });
// Create a task-worker agent
const taskWorker = await store.createAgent({
name: "executor-FN-TEST",
role: "executor",
metadata: { agentKind: "task-worker" },
});
// Create a spawned child agent
const spawned = await store.createAgent({
name: "spawned-agent",
role: "executor",
metadata: { type: "spawned" },
});
// Create an agent with taskWorker metadata
const withTaskWorker = await store.createAgent({
name: "task-worker-agent",
role: "executor",
metadata: { taskWorker: true },
});
// Create an agent with managedBy metadata
const managedBy = await store.createAgent({
name: "managed-agent",
role: "executor",
metadata: { managedBy: "task-executor" },
});
// Without includeSystem filter, all agents are returned
const allAgents = await store.listAgents();
expect(allAgents).toHaveLength(5);
// With includeSystem: false, system agents are filtered out
const nonSystemAgents = await store.listAgents({ includeSystem: false });
expect(nonSystemAgents).toHaveLength(1);
expect(nonSystemAgents[0].id).toBe(normal.id);
// With includeSystem: true, all agents are returned
const systemAgents = await store.listAgents({ includeSystem: true });
expect(systemAgents).toHaveLength(5);
});
it("includeSystem filter works with state filter", async () => {
// Create a normal agent
const normal = await store.createAgent({ name: "Normal Agent", role: "executor" });
// Create a task-worker agent
const taskWorker = await store.createAgent({
name: "executor-FN-TEST",
role: "executor",
metadata: { agentKind: "task-worker" },
});
await store.recordHeartbeat(taskWorker.id, "ok");
await store.updateAgentState(taskWorker.id, "active");
// Without includeSystem, but with state=active - only returns active non-system agents
const activeNonSystem = await store.listAgents({ state: "active", includeSystem: false });
expect(activeNonSystem).toHaveLength(0);
// With includeSystem: true, returns all active agents
const activeAll = await store.listAgents({ state: "active", includeSystem: true });
expect(activeAll).toHaveLength(1);
expect(activeAll[0].id).toBe(taskWorker.id);
});
}); });
// ── Org Hierarchy ──────────────────────────────────────────────── // ── Org Hierarchy ────────────────────────────────────────────────

View File

@@ -985,12 +985,27 @@ export class AgentStore extends EventEmitter {
return agent; return agent;
} }
/**
* Check if an agent is a system-generated ephemeral agent (task-worker or spawned child).
* These agents are created at runtime by the engine and should typically be hidden
* from the default agents page view.
*/
private isSystemAgent(agent: Agent): boolean {
const metadata = agent.metadata ?? {};
return (
metadata.agentKind === "task-worker" ||
metadata.type === "spawned" ||
metadata.taskWorker === true ||
metadata.managedBy === "task-executor"
);
}
/** /**
* List all agents, optionally filtered by state. * List all agents, optionally filtered by state.
* @param filter - Optional filter criteria * @param filter - Optional filter criteria
* @returns Array of agents * @returns Array of agents
*/ */
async listAgents(filter?: { state?: AgentState; role?: AgentCapability }): Promise<Agent[]> { async listAgents(filter?: { state?: AgentState; role?: AgentCapability; includeSystem?: boolean }): Promise<Agent[]> {
const files = await readdir(this.agentsDir).catch(() => [] as string[]); const files = await readdir(this.agentsDir).catch(() => [] as string[]);
const agentFiles = files.filter((f) => f.endsWith(".json") && !f.includes("-heartbeats") && !f.includes("-sessions") && !f.includes("-runs") && !f.includes("-revisions")); const agentFiles = files.filter((f) => f.endsWith(".json") && !f.includes("-heartbeats") && !f.includes("-sessions") && !f.includes("-runs") && !f.includes("-revisions"));
@@ -1004,6 +1019,9 @@ export class AgentStore extends EventEmitter {
if (filter?.state && agent.state !== filter.state) continue; if (filter?.state && agent.state !== filter.state) continue;
if (filter?.role && agent.role !== filter.role) continue; if (filter?.role && agent.role !== filter.role) continue;
// When includeSystem is explicitly false, filter out system agents
if (filter?.includeSystem === false && this.isSystemAgent(agent)) continue;
agents.push(agent); agents.push(agent);
} catch { } catch {
// Skip corrupted files // Skip corrupted files

View File

@@ -2282,12 +2282,14 @@ export function proxyApi<T>(path: string, opts?: RequestInit & { nodeId?: string
/** Fetch all agents, optionally filtered by state or role */ /** Fetch all agents, optionally filtered by state or role */
export function fetchAgents( export function fetchAgents(
filter?: { state?: AgentState; role?: AgentCapability }, filter?: { state?: AgentState; role?: AgentCapability; includeSystem?: boolean },
projectId?: string, projectId?: string,
): Promise<Agent[]> { ): Promise<Agent[]> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (filter?.state) params.set("state", filter.state); if (filter?.state) params.set("state", filter.state);
if (filter?.role) params.set("role", filter.role); if (filter?.role) params.set("role", filter.role);
if (filter?.includeSystem === true) params.set("includeSystem", "true");
else if (filter?.includeSystem === false) params.set("includeSystem", "false");
if (projectId) params.set("projectId", projectId); if (projectId) params.set("projectId", projectId);
const query = params.size > 0 ? `?${params.toString()}` : ""; const query = params.size > 0 ? `?${params.toString()}` : "";
return api<Agent[]>(`/agents${query}`); return api<Agent[]>(`/agents${query}`);

View File

@@ -251,6 +251,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
const [editingRoleForAgent, setEditingRoleForAgent] = useState<string | null>(null); const [editingRoleForAgent, setEditingRoleForAgent] = useState<string | null>(null);
const roleSelectRef = useRef<HTMLSelectElement>(null); const roleSelectRef = useRef<HTMLSelectElement>(null);
const [showSystemAgents, setShowSystemAgents] = useState(false);
const hierarchy = useAgentHierarchy(agents, projectId); const hierarchy = useAgentHierarchy(agents, projectId);
@@ -287,14 +288,14 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
setIsLoading(true); setIsLoading(true);
try { try {
const filter = filterState !== "all" ? { state: filterState } : undefined; const filter = filterState !== "all" ? { state: filterState } : undefined;
const data = await fetchAgents(filter, projectId); const data = await fetchAgents({ ...filter, includeSystem: showSystemAgents }, projectId);
setAgents(data); setAgents(data);
} catch (err: any) { } catch (err: any) {
addToast(`Failed to load agents: ${err.message}`, "error"); addToast(`Failed to load agents: ${err.message}`, "error");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [filterState, addToast, projectId]); }, [filterState, showSystemAgents, addToast, projectId]);
useEffect(() => { useEffect(() => {
void loadAgents(); void loadAgents();
@@ -528,6 +529,16 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
</select> </select>
</div> </div>
<label className="checkbox-label agent-system-filter">
<input
type="checkbox"
checked={showSystemAgents}
onChange={(e) => setShowSystemAgents(e.target.checked)}
aria-label="Show system agents"
/>
Show system agents
</label>
<div className="agent-controls-actions"> <div className="agent-controls-actions">
<button <button
className="btn" className="btn"

View File

@@ -430,7 +430,7 @@ describe("AgentsView", () => {
fireEvent.change(filterSelect, { target: { value: "active" } }); fireEvent.change(filterSelect, { target: { value: "active" } });
await waitFor(() => { await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalledWith({ state: "active" }, undefined); expect(mockFetchAgents).toHaveBeenCalledWith({ state: "active", includeSystem: false }, undefined);
}); });
}); });
@@ -445,13 +445,117 @@ describe("AgentsView", () => {
fireEvent.change(filterSelect, { target: { value: "idle" } }); fireEvent.change(filterSelect, { target: { value: "idle" } });
await waitFor(() => { await waitFor(() => {
expect(mockFetchAgents).toHaveBeenLastCalledWith({ state: "idle" }, undefined); expect(mockFetchAgents).toHaveBeenLastCalledWith({ state: "idle", includeSystem: false }, undefined);
}); });
fireEvent.change(filterSelect, { target: { value: "all" } }); fireEvent.change(filterSelect, { target: { value: "all" } });
await waitFor(() => { await waitFor(() => {
expect(mockFetchAgents).toHaveBeenLastCalledWith(undefined, undefined); expect(mockFetchAgents).toHaveBeenLastCalledWith({ includeSystem: false }, undefined);
});
});
});
describe("show system agents toggle", () => {
it("renders the system agents checkbox", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Show system agents")).toBeTruthy();
});
// Checkbox should be unchecked by default
const checkbox = screen.getByLabelText("Show system agents") as HTMLInputElement;
expect(checkbox.checked).toBe(false);
});
it("passes includeSystem: false by default to fetchAgents", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByText("All States")).toBeTruthy();
});
// Default call should include includeSystem: false
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenLastCalledWith({ includeSystem: false }, undefined);
});
});
it("toggles system agents visibility when checkbox is clicked", async () => {
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
await waitFor(() => {
expect(screen.getByText("All States")).toBeTruthy();
});
const checkbox = screen.getByLabelText("Show system agents");
fireEvent.click(checkbox);
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenLastCalledWith({ includeSystem: true }, projectId);
});
});
it("combines system agents toggle with state filter", async () => {
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
await waitFor(() => {
expect(screen.getByText("All States")).toBeTruthy();
});
// First enable system agents toggle
const checkbox = screen.getByLabelText("Show system agents");
fireEvent.click(checkbox);
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenLastCalledWith({ includeSystem: true }, projectId);
});
// Then filter by state
const filterSelect = screen.getByDisplayValue("All States");
fireEvent.change(filterSelect, { target: { value: "active" } });
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenLastCalledWith({ state: "active", includeSystem: true }, projectId);
});
});
it("shows system agents in agent list when checkbox is enabled", async () => {
const systemAgents: Agent[] = [
{
id: "agent-sys-001",
name: "executor-FN-TEST",
role: "executor" as AgentCapability,
state: "terminated" as AgentState,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: { agentKind: "task-worker" },
},
];
// Mock returns only normal agents by default (excluding terminated)
mockFetchAgents.mockResolvedValue(mockAgents.slice(0, 3));
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
await waitFor(() => {
expect(screen.getByText("Test Agent 1")).toBeTruthy();
});
// Normal agents should be visible
expect(screen.queryByText("executor-FN-TEST")).toBeNull();
// Update mock to return system agents too (next call)
mockFetchAgents.mockResolvedValueOnce([...mockAgents.slice(0, 3), ...systemAgents]);
// Enable system agents toggle
const checkbox = screen.getByLabelText("Show system agents");
fireEvent.click(checkbox);
// Now the agents should be reloaded with system agents included
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalledWith({ includeSystem: true }, projectId);
}); });
}); });
}); });

View File

@@ -23759,6 +23759,27 @@ html .column.drag-over * {
padding-right: 4px; padding-right: 4px;
} }
.agent-system-filter {
font-size: 13px;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
user-select: none;
}
.agent-system-filter:hover {
color: var(--text);
}
.agent-system-filter input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--todo);
cursor: pointer;
}
.agent-create-form { .agent-create-form {
display: flex; display: flex;
gap: var(--space-md); gap: var(--space-md);

View File

@@ -9647,24 +9647,29 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
/** /**
* GET /api/agents * GET /api/agents
* List all agents with optional filtering. * List all agents with optional filtering.
* Query params: state, role * Query params: state, role, includeSystem
*/ */
router.get("/agents", async (req, res) => { router.get("/agents", async (req, res) => {
try { try {
const filter: { state?: string; role?: string } = {}; const filter: { state?: string; role?: string; includeSystem?: boolean } = {};
if (req.query.state && typeof req.query.state === "string") { if (req.query.state && typeof req.query.state === "string") {
filter.state = req.query.state; filter.state = req.query.state;
} }
if (req.query.role && typeof req.query.role === "string") { if (req.query.role && typeof req.query.role === "string") {
filter.role = req.query.role; filter.role = req.query.role;
} }
if (req.query.includeSystem === "true") {
filter.includeSystem = true;
} else if (req.query.includeSystem === "false") {
filter.includeSystem = false;
}
const { store: scopedStore } = await getProjectContext(req); const { store: scopedStore } = await getProjectContext(req);
const { AgentStore } = await import("@fusion/core"); const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init(); await agentStore.init();
const agents = await agentStore.listAgents(filter as { state?: "idle" | "active" | "paused" | "terminated"; role?: import("@fusion/core").AgentCapability }); const agents = await agentStore.listAgents(filter as { state?: "idle" | "active" | "paused" | "terminated"; role?: import("@fusion/core").AgentCapability; includeSystem?: boolean });
res.json(agents); res.json(agents);
} catch (err: any) { } catch (err: any) {
if (err instanceof ApiError) { if (err instanceof ApiError) {

View File

@@ -9168,6 +9168,46 @@ describe("Agent Spawning - Child Termination", () => {
expect(mockSession.dispose).toHaveBeenCalled(); expect(mockSession.dispose).toHaveBeenCalled();
expect(internals.totalSpawnedCount).toBe(0); expect(internals.totalSpawnedCount).toBe(0);
}); });
it("terminateChildAgent auto-deletes agent after 5 second delay", async () => {
vi.useFakeTimers();
try {
const agentStore = createMockAgentStore() as any;
// Add deleteAgent mock to the agent store
agentStore.deleteAgent = vi.fn().mockResolvedValue(undefined);
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
const internals = executor as any;
const mockSession = { dispose: vi.fn() };
const childId = "agent-auto-delete-test";
internals.childSessions.set(childId, mockSession);
internals.totalSpawnedCount = 1;
// Terminate the child
const terminatePromise = internals.terminateChildAgent(childId);
// Session should be disposed immediately
expect(mockSession.dispose).toHaveBeenCalled();
// deleteAgent should not be called yet (before 5 seconds)
expect(agentStore.deleteAgent).not.toHaveBeenCalled();
// Advance timers by 5 seconds
await vi.advanceTimersByTimeAsync(5000);
// Now deleteAgent should have been called
expect(agentStore.deleteAgent).toHaveBeenCalledTimes(1);
expect(agentStore.deleteAgent).toHaveBeenCalledWith(childId);
// Should not throw even when delete fails
await terminatePromise;
} finally {
vi.useRealTimers();
}
});
}); });
describe("Agent Spawning - runSpawnedChild", () => { describe("Agent Spawning - runSpawnedChild", () => {

View File

@@ -3917,6 +3917,12 @@ and show an appropriate message to the user.\`
// Agent may not exist in store — that's ok for cleanup // Agent may not exist in store — that's ok for cleanup
} }
// Auto-delete the child agent after a short delay so the UI can observe
// the terminal state before the agent is removed.
void setTimeout(() => {
this.options.agentStore?.deleteAgent(childId).catch(() => {});
}, 5000);
this.totalSpawnedCount = Math.max(0, this.totalSpawnedCount - 1); this.totalSpawnedCount = Math.max(0, this.totalSpawnedCount - 1);
} }

View File

@@ -558,6 +558,88 @@ describe("InProcessRuntime", () => {
await new Promise((resolve) => setTimeout(resolve, 25)); await new Promise((resolve) => setTimeout(resolve, 25));
expect(executeSpy).not.toHaveBeenCalled(); expect(executeSpy).not.toHaveBeenCalled();
}, 30000); }, 30000);
it("auto-deletes task-worker agent on task completion after 5 second delay", async () => {
vi.useFakeTimers();
try {
await runtime.start();
const store = getAgentStore(runtime);
const deleteAgentSpy = vi.spyOn(store, "deleteAgent").mockResolvedValue(undefined);
const executorOptions = mockExecutorCtor.mock.calls.at(-1)?.[0] as {
onStart?: (task: Task, worktreePath: string) => void;
onComplete?: (task: Task) => void;
};
expect(executorOptions.onComplete).toBeTypeOf("function");
// Create a task-worker agent first via onStart
executorOptions.onStart?.({ id: "FN-AUTO1" } as Task, join(testDir, "worktree-FN-AUTO1"));
await vi.waitFor(async () => {
const agents = await store.listAgents();
expect(agents.some((a: Agent) => a.name === "executor-FN-AUTO1")).toBe(true);
});
// Clear previous calls and trigger onComplete
deleteAgentSpy.mockClear();
executorOptions.onComplete?.({ id: "FN-AUTO1" } as Task);
// Verify deleteAgent was not called immediately (before 5 seconds)
expect(deleteAgentSpy).not.toHaveBeenCalled();
// Advance timers by 5 seconds
await vi.advanceTimersByTimeAsync(5000);
// Now deleteAgent should have been called
expect(deleteAgentSpy).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
}, 30000);
it("auto-deletes task-worker agent on task error after 5 second delay", async () => {
vi.useFakeTimers();
try {
await runtime.start();
const store = getAgentStore(runtime);
const deleteAgentSpy = vi.spyOn(store, "deleteAgent").mockResolvedValue(undefined);
const executorOptions = mockExecutorCtor.mock.calls.at(-1)?.[0] as {
onError?: (task: Task, error: Error) => void;
};
expect(executorOptions.onError).toBeTypeOf("function");
// Create a task-worker agent first via onStart
const onStartOptions = mockExecutorCtor.mock.calls.at(-1)?.[0] as {
onStart?: (task: Task, worktreePath: string) => void;
};
onStartOptions.onStart?.({ id: "FN-AUTO2" } as Task, join(testDir, "worktree-FN-AUTO2"));
await vi.waitFor(async () => {
const agents = await store.listAgents();
expect(agents.some((a: Agent) => a.name === "executor-FN-AUTO2")).toBe(true);
});
// Clear previous calls and trigger onError
deleteAgentSpy.mockClear();
executorOptions.onError?.({ id: "FN-AUTO2" } as Task, new Error("Task failed"));
// Verify deleteAgent was not called immediately (before 5 seconds)
expect(deleteAgentSpy).not.toHaveBeenCalled();
// Advance timers by 5 seconds
await vi.advanceTimersByTimeAsync(5000);
// Now deleteAgent should have been called
expect(deleteAgentSpy).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
}, 30000);
}); });
describe("configuration", () => { describe("configuration", () => {

View File

@@ -304,6 +304,11 @@ export class InProcessRuntime
if (agentId && this.agentStore) { if (agentId && this.agentStore) {
void this.agentStore.updateAgentState(agentId, "terminated").catch(() => {}); void this.agentStore.updateAgentState(agentId, "terminated").catch(() => {});
this.taskAgentMap.delete(task.id); this.taskAgentMap.delete(task.id);
// Auto-delete the task-worker agent after a short delay so the UI
// can observe the terminal state before the agent is removed.
void setTimeout(() => {
this.agentStore?.deleteAgent(agentId).catch(() => {});
}, 5000);
} }
}, },
onError: (task, error) => { onError: (task, error) => {
@@ -331,6 +336,11 @@ export class InProcessRuntime
if (agentId && this.agentStore) { if (agentId && this.agentStore) {
void this.agentStore.updateAgentState(agentId, "terminated").catch(() => {}); void this.agentStore.updateAgentState(agentId, "terminated").catch(() => {});
this.taskAgentMap.delete(task.id); this.taskAgentMap.delete(task.id);
// Auto-delete the task-worker agent after a short delay so the UI
// can observe the terminal state before the agent is removed.
void setTimeout(() => {
this.agentStore?.deleteAgent(agentId).catch(() => {});
}, 5000);
} }
}, },
}; };