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:
5
.changeset/fix-agent-cleanup.md
Normal file
5
.changeset/fix-agent-cleanup.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user