feat(FN-975): add agent spawning support for hierarchical task delegation
- Add spawn settings (maxSpawnedAgentsPerParent, maxSpawnedAgentsGlobal) to ProjectSettings type - Implement AgentStore access and spawned agent tracking in TaskExecutor - Add spawn_agent tool with child agent execution in isolated worktrees - Wire up child termination on parent session end and system prompt generation - Add comprehensive test suite (554 lines) covering spawning, limits, termination, and error handling - Document agent spawning architecture, IPC protocol, and usage in AGENTS.md
This commit is contained in:
@@ -830,6 +830,14 @@ export interface ProjectSettings {
|
||||
/** Maximum number of times the stuck-task detector can kill and re-queue a task
|
||||
* before it is marked as permanently failed. Default: 3. */
|
||||
maxStuckKills?: number;
|
||||
/** Maximum number of child agents a single parent agent can spawn.
|
||||
* Limits the fan-out per executor task to prevent resource exhaustion.
|
||||
* Default: 5. */
|
||||
maxSpawnedAgentsPerParent?: number;
|
||||
/** Maximum total spawned agents across all parent agents in a single executor instance.
|
||||
* Provides a global safety cap regardless of how many parent agents are running.
|
||||
* Default: 20. */
|
||||
maxSpawnedAgentsGlobal?: number;
|
||||
/** Interval in milliseconds for periodic maintenance (worktree pruning, WAL checkpoint,
|
||||
* orphan cleanup). 0 disables. Default: 900000 (15 min). */
|
||||
maintenanceIntervalMs?: number;
|
||||
@@ -962,6 +970,8 @@ export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
|
||||
autoUnpauseBaseDelayMs: 300_000,
|
||||
autoUnpauseMaxDelayMs: 3_600_000,
|
||||
maxStuckKills: 3,
|
||||
maxSpawnedAgentsPerParent: 5,
|
||||
maxSpawnedAgentsGlobal: 20,
|
||||
maintenanceIntervalMs: 900_000,
|
||||
autoUpdatePrStatus: false,
|
||||
autoCreatePr: false,
|
||||
@@ -1055,6 +1065,8 @@ export const PROJECT_SETTINGS_KEYS: ReadonlyArray<keyof ProjectSettings> = [
|
||||
"insightExtractionSchedule",
|
||||
"insightExtractionMinIntervalMs",
|
||||
"memoryEnabled",
|
||||
"maxSpawnedAgentsPerParent",
|
||||
"maxSpawnedAgentsGlobal",
|
||||
] as const;
|
||||
|
||||
export interface BoardConfig {
|
||||
|
||||
@@ -6882,3 +6882,557 @@ describe("TaskExecutor loop recovery", () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Agent Spawning Tests ─────────────────────────────────────────────────
|
||||
|
||||
function createMockAgentStore() {
|
||||
let nextId = 1;
|
||||
const agents = new Map<string, any>();
|
||||
|
||||
return {
|
||||
createAgent: vi.fn(async (input: any) => {
|
||||
const agentId = `agent-${String(nextId++).padStart(8, "0")}`;
|
||||
const agent = {
|
||||
id: agentId,
|
||||
name: input.name,
|
||||
role: input.role,
|
||||
state: "idle" as string,
|
||||
reportsTo: input.reportsTo,
|
||||
metadata: input.metadata ?? {},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
agents.set(agentId, agent);
|
||||
return agent;
|
||||
}),
|
||||
updateAgentState: vi.fn(async (agentId: string, newState: string) => {
|
||||
const agent = agents.get(agentId);
|
||||
if (agent) {
|
||||
agent.state = newState;
|
||||
agent.updatedAt = new Date().toISOString();
|
||||
}
|
||||
return agent;
|
||||
}),
|
||||
_agents: agents,
|
||||
};
|
||||
}
|
||||
|
||||
async function captureToolsWithAgentStore(agentStore?: any, settingsOverride?: any): Promise<{
|
||||
tools: Record<string, (id: string, params: any) => Promise<any>>;
|
||||
store: ReturnType<typeof createMockStore>;
|
||||
executor: TaskExecutor;
|
||||
}> {
|
||||
const store = createMockStore();
|
||||
store.updateStep.mockResolvedValue({
|
||||
steps: [
|
||||
{ name: "Preflight", status: "done" },
|
||||
{ name: "Implement", status: "in-progress" },
|
||||
{ name: "Testing", status: "pending" },
|
||||
],
|
||||
});
|
||||
const mergedSettings = {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: false,
|
||||
worktreeInitCommand: undefined,
|
||||
...settingsOverride,
|
||||
};
|
||||
store.getSettings.mockResolvedValue(mergedSettings);
|
||||
|
||||
let capturedTools: any[] = [];
|
||||
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
|
||||
capturedTools = opts.customTools || [];
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
sessionManager: {
|
||||
getLeafId: vi.fn().mockReturnValue("leaf-id"),
|
||||
branchWithSummary: vi.fn(),
|
||||
},
|
||||
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
||||
},
|
||||
} as any;
|
||||
});
|
||||
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
// Mock execSync for worktree operations
|
||||
vi.mocked(execSync).mockReturnValue("");
|
||||
|
||||
const options: any = {};
|
||||
if (agentStore) {
|
||||
options.agentStore = agentStore;
|
||||
}
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test", options);
|
||||
|
||||
await executor.execute({
|
||||
id: "FN-SPAWN",
|
||||
title: "Spawn Test",
|
||||
description: "Spawn test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const tools: Record<string, any> = {};
|
||||
for (const t of capturedTools) {
|
||||
tools[t.name] = t.execute;
|
||||
}
|
||||
return { tools, store, executor };
|
||||
}
|
||||
|
||||
describe("Agent Spawning", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
vi.mocked(execSync).mockReturnValue("");
|
||||
});
|
||||
|
||||
it("spawn_agent tool is registered in customTools", async () => {
|
||||
const { tools } = await captureToolsWithAgentStore();
|
||||
expect(tools.spawn_agent).toBeDefined();
|
||||
expect(typeof tools.spawn_agent).toBe("function");
|
||||
});
|
||||
|
||||
it("returns error when AgentStore is not configured", async () => {
|
||||
const { tools } = await captureToolsWithAgentStore(undefined);
|
||||
const result = await tools.spawn_agent("call1", {
|
||||
name: "researcher",
|
||||
role: "engineer",
|
||||
task: "Research something",
|
||||
});
|
||||
|
||||
expect(result.content[0].text).toContain("not available");
|
||||
expect(result.content[0].text).toContain("no AgentStore configured");
|
||||
expect(result.details.state).toBe("error");
|
||||
});
|
||||
|
||||
it("creates agent in AgentStore with correct reportsTo", async () => {
|
||||
const agentStore = createMockAgentStore();
|
||||
const { tools } = await captureToolsWithAgentStore(agentStore);
|
||||
|
||||
const result = await tools.spawn_agent("call1", {
|
||||
name: "researcher",
|
||||
role: "engineer",
|
||||
task: "Research authentication patterns",
|
||||
});
|
||||
|
||||
expect(agentStore.createAgent).toHaveBeenCalledOnce();
|
||||
const createInput = agentStore.createAgent.mock.calls[0][0];
|
||||
expect(createInput.name).toBe("researcher");
|
||||
expect(createInput.role).toBe("engineer");
|
||||
expect(createInput.reportsTo).toBe("FN-SPAWN");
|
||||
expect(createInput.metadata.type).toBe("spawned");
|
||||
expect(createInput.metadata.parentTaskId).toBe("FN-SPAWN");
|
||||
});
|
||||
|
||||
it("returns correct SpawnAgentResult structure with state", async () => {
|
||||
const agentStore = createMockAgentStore();
|
||||
const { tools } = await captureToolsWithAgentStore(agentStore);
|
||||
|
||||
const result = await tools.spawn_agent("call1", {
|
||||
name: "researcher",
|
||||
role: "engineer",
|
||||
task: "Research authentication patterns",
|
||||
});
|
||||
|
||||
// Parse the JSON from the text content
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed).toHaveProperty("agentId");
|
||||
expect(parsed).toHaveProperty("name", "researcher");
|
||||
expect(parsed).toHaveProperty("state", "running");
|
||||
expect(parsed).toHaveProperty("role", "engineer");
|
||||
expect(parsed).toHaveProperty("message");
|
||||
expect(parsed.message).toContain("researcher");
|
||||
expect(parsed.message).toContain("Research authentication patterns");
|
||||
|
||||
// Also check details object
|
||||
expect(result.details.agentId).toBe(parsed.agentId);
|
||||
expect(result.details.state).toBe("running");
|
||||
});
|
||||
|
||||
it("transitions agent to active state after creation", async () => {
|
||||
const agentStore = createMockAgentStore();
|
||||
const { tools } = await captureToolsWithAgentStore(agentStore);
|
||||
|
||||
await tools.spawn_agent("call1", {
|
||||
name: "worker",
|
||||
role: "custom",
|
||||
task: "Do some work",
|
||||
});
|
||||
|
||||
// Agent is created in idle, then transitioned to active
|
||||
const agentId = agentStore.createAgent.mock.calls[0][0];
|
||||
expect(agentStore.updateAgentState).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
"active"
|
||||
);
|
||||
});
|
||||
|
||||
it("creates child agent session via createKbAgent", async () => {
|
||||
const agentStore = createMockAgentStore();
|
||||
const { tools } = await captureToolsWithAgentStore(agentStore);
|
||||
|
||||
await tools.spawn_agent("call1", {
|
||||
name: "worker",
|
||||
role: "engineer",
|
||||
task: "Do some work",
|
||||
});
|
||||
|
||||
// createKbAgent is called at least twice: once for parent, once for child
|
||||
expect(mockedCreateHaiAgent.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Find the child session call
|
||||
const childCall = mockedCreateHaiAgent.mock.calls.find(
|
||||
(call: any) => call[0].systemPrompt?.includes("child agent spawned")
|
||||
);
|
||||
expect(childCall).toBeDefined();
|
||||
expect(childCall![0].tools).toBe("coding");
|
||||
expect(childCall![0].systemPrompt).toContain("FN-SPAWN");
|
||||
});
|
||||
|
||||
it("respects per-parent maxSpawnedAgentsPerParent limit", async () => {
|
||||
const agentStore = createMockAgentStore();
|
||||
const { tools } = await captureToolsWithAgentStore(agentStore, {
|
||||
maxSpawnedAgentsPerParent: 2,
|
||||
});
|
||||
|
||||
// Spawn 2 agents (limit)
|
||||
await tools.spawn_agent("call1", { name: "a1", role: "engineer", task: "task 1" });
|
||||
await tools.spawn_agent("call2", { name: "a2", role: "engineer", task: "task 2" });
|
||||
|
||||
// 3rd should be rejected
|
||||
const result = await tools.spawn_agent("call3", { name: "a3", role: "engineer", task: "task 3" });
|
||||
expect(result.content[0].text).toContain("Per-parent spawn limit reached");
|
||||
expect(result.content[0].text).toContain("2/2");
|
||||
expect(result.details.state).toBe("error");
|
||||
});
|
||||
|
||||
it("respects global maxSpawnedAgentsGlobal limit", async () => {
|
||||
const agentStore = createMockAgentStore();
|
||||
const { tools } = await captureToolsWithAgentStore(agentStore, {
|
||||
maxSpawnedAgentsGlobal: 3,
|
||||
});
|
||||
|
||||
// Spawn 3 agents (global limit)
|
||||
await tools.spawn_agent("call1", { name: "a1", role: "engineer", task: "task 1" });
|
||||
await tools.spawn_agent("call2", { name: "a2", role: "engineer", task: "task 2" });
|
||||
await tools.spawn_agent("call3", { name: "a3", role: "engineer", task: "task 3" });
|
||||
|
||||
// 4th should hit global limit
|
||||
const result = await tools.spawn_agent("call4", { name: "a4", role: "engineer", task: "task 4" });
|
||||
expect(result.content[0].text).toContain("Global spawn limit reached");
|
||||
expect(result.content[0].text).toContain("3/3");
|
||||
expect(result.details.state).toBe("error");
|
||||
});
|
||||
|
||||
it("uses default limits when settings are not specified", async () => {
|
||||
const agentStore = createMockAgentStore();
|
||||
// No spawn settings in the store — defaults should apply
|
||||
const { tools } = await captureToolsWithAgentStore(agentStore);
|
||||
|
||||
// Should be able to spawn (defaults: 5 per parent, 20 global)
|
||||
const result = await tools.spawn_agent("call1", {
|
||||
name: "worker",
|
||||
role: "engineer",
|
||||
task: "task 1",
|
||||
});
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.state).toBe("running");
|
||||
});
|
||||
|
||||
it("handles errors during agent creation gracefully", async () => {
|
||||
const agentStore = createMockAgentStore();
|
||||
agentStore.createAgent.mockRejectedValue(new Error("DB connection failed"));
|
||||
|
||||
const { tools } = await captureToolsWithAgentStore(agentStore);
|
||||
|
||||
const result = await tools.spawn_agent("call1", {
|
||||
name: "worker",
|
||||
role: "engineer",
|
||||
task: "task 1",
|
||||
});
|
||||
|
||||
expect(result.content[0].text).toContain("Failed to spawn agent");
|
||||
expect(result.content[0].text).toContain("DB connection failed");
|
||||
expect(result.details.state).toBe("error");
|
||||
});
|
||||
|
||||
it("trims whitespace from agent name", async () => {
|
||||
const agentStore = createMockAgentStore();
|
||||
const { tools } = await captureToolsWithAgentStore(agentStore);
|
||||
|
||||
await tools.spawn_agent("call1", {
|
||||
name: " researcher ",
|
||||
role: "engineer",
|
||||
task: "task 1",
|
||||
});
|
||||
|
||||
const createInput = agentStore.createAgent.mock.calls[0][0];
|
||||
expect(createInput.name).toBe("researcher");
|
||||
});
|
||||
|
||||
it("truncates long task descriptions in result message", async () => {
|
||||
const agentStore = createMockAgentStore();
|
||||
const { tools } = await captureToolsWithAgentStore(agentStore);
|
||||
|
||||
const longTask = "A".repeat(200);
|
||||
const result = await tools.spawn_agent("call1", {
|
||||
name: "worker",
|
||||
role: "engineer",
|
||||
task: longTask,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.message).toContain("...");
|
||||
// The message should contain the first 100 chars
|
||||
expect(parsed.message.length).toBeLessThan(longTask.length + 50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Agent Spawning - Child Termination", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
vi.mocked(execSync).mockReturnValue("");
|
||||
});
|
||||
|
||||
it("parent termination triggers all child terminations", async () => {
|
||||
const agentStore = createMockAgentStore();
|
||||
const mockDispose = vi.fn();
|
||||
|
||||
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: mockDispose,
|
||||
sessionManager: {
|
||||
getLeafId: vi.fn().mockReturnValue("leaf-id"),
|
||||
branchWithSummary: vi.fn(),
|
||||
},
|
||||
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
||||
},
|
||||
} as any;
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
store.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: false,
|
||||
});
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
||||
|
||||
await executor.execute({
|
||||
id: "FN-PARENT",
|
||||
title: "Parent Task",
|
||||
description: "Parent",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// execute() should have completed, disposing the parent session
|
||||
// and any child sessions that were spawned
|
||||
expect(mockDispose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("terminateChildAgent cleans up maps and decrements count", async () => {
|
||||
const agentStore = createMockAgentStore();
|
||||
const store = createMockStore();
|
||||
store.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: false,
|
||||
});
|
||||
|
||||
const mockDispose = vi.fn();
|
||||
mockedCreateHaiAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: mockDispose,
|
||||
sessionManager: {
|
||||
getLeafId: vi.fn().mockReturnValue("leaf-id"),
|
||||
branchWithSummary: vi.fn(),
|
||||
},
|
||||
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
||||
},
|
||||
} as any);
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
||||
|
||||
// Access internal state via any for testing
|
||||
const internals = executor as any;
|
||||
|
||||
// Simulate spawned agent tracking state
|
||||
const childId = "agent-test-child";
|
||||
const mockSession = { dispose: vi.fn() };
|
||||
internals.childSessions.set(childId, mockSession);
|
||||
internals.spawnedAgents.set("FN-PARENT", new Set([childId]));
|
||||
internals.totalSpawnedCount = 1;
|
||||
|
||||
// Terminate the child
|
||||
await internals.terminateChildAgent(childId);
|
||||
|
||||
expect(mockSession.dispose).toHaveBeenCalled();
|
||||
expect(internals.childSessions.has(childId)).toBe(false);
|
||||
expect(internals.spawnedAgents.get("FN-PARENT")?.has(childId)).toBe(false);
|
||||
expect(internals.totalSpawnedCount).toBe(0);
|
||||
expect(agentStore.updateAgentState).toHaveBeenCalledWith(childId, "terminated");
|
||||
});
|
||||
|
||||
it("terminateChildAgent handles missing session gracefully", async () => {
|
||||
const agentStore = createMockAgentStore();
|
||||
const store = createMockStore();
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
||||
const internals = executor as any;
|
||||
|
||||
internals.totalSpawnedCount = 1;
|
||||
|
||||
// Terminate a child that doesn't have a session in the map
|
||||
await internals.terminateChildAgent("nonexistent-agent");
|
||||
|
||||
// Should still decrement counter and attempt state update
|
||||
expect(internals.totalSpawnedCount).toBe(0);
|
||||
expect(agentStore.updateAgentState).toHaveBeenCalledWith("nonexistent-agent", "terminated");
|
||||
});
|
||||
|
||||
it("terminateAllChildren handles no children gracefully", async () => {
|
||||
const agentStore = createMockAgentStore();
|
||||
const store = createMockStore();
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
||||
const internals = executor as any;
|
||||
|
||||
// Should not throw when there are no children
|
||||
await internals.terminateAllChildren("FN-NONE");
|
||||
expect(agentStore.updateAgentState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("terminateAllChildren terminates all children and cleans up", async () => {
|
||||
const agentStore = createMockAgentStore();
|
||||
const store = createMockStore();
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
||||
const internals = executor as any;
|
||||
|
||||
// Set up multiple children
|
||||
const child1 = { dispose: vi.fn() };
|
||||
const child2 = { dispose: vi.fn() };
|
||||
internals.childSessions.set("c1", child1);
|
||||
internals.childSessions.set("c2", child2);
|
||||
internals.spawnedAgents.set("FN-PARENT", new Set(["c1", "c2"]));
|
||||
internals.totalSpawnedCount = 2;
|
||||
|
||||
await internals.terminateAllChildren("FN-PARENT");
|
||||
|
||||
expect(child1.dispose).toHaveBeenCalled();
|
||||
expect(child2.dispose).toHaveBeenCalled();
|
||||
expect(internals.spawnedAgents.has("FN-PARENT")).toBe(false);
|
||||
expect(internals.totalSpawnedCount).toBe(0);
|
||||
expect(agentStore.updateAgentState).toHaveBeenCalledWith("c1", "terminated");
|
||||
expect(agentStore.updateAgentState).toHaveBeenCalledWith("c2", "terminated");
|
||||
});
|
||||
|
||||
it("terminateChildAgent handles AgentStore errors gracefully", async () => {
|
||||
const agentStore = createMockAgentStore();
|
||||
agentStore.updateAgentState.mockRejectedValue(new Error("DB error"));
|
||||
const store = createMockStore();
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
||||
const internals = executor as any;
|
||||
|
||||
const mockSession = { dispose: vi.fn() };
|
||||
internals.childSessions.set("c1", mockSession);
|
||||
internals.totalSpawnedCount = 1;
|
||||
|
||||
// Should not throw even when AgentStore fails
|
||||
await internals.terminateChildAgent("c1");
|
||||
expect(mockSession.dispose).toHaveBeenCalled();
|
||||
expect(internals.totalSpawnedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Agent Spawning - runSpawnedChild", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("updates agent state to running then active on success", async () => {
|
||||
const agentStore = createMockAgentStore();
|
||||
const store = createMockStore();
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
||||
const internals = executor as any;
|
||||
|
||||
const mockSession = { dispose: vi.fn() };
|
||||
internals.childSessions.set("agent-test", mockSession);
|
||||
internals.totalSpawnedCount = 1;
|
||||
|
||||
await internals.runSpawnedChild("agent-test", mockSession, "Do the research");
|
||||
|
||||
// Should transition: running → active
|
||||
expect(agentStore.updateAgentState).toHaveBeenCalledWith("agent-test", "running");
|
||||
expect(agentStore.updateAgentState).toHaveBeenCalledWith("agent-test", "active");
|
||||
// Should clean up
|
||||
expect(internals.childSessions.has("agent-test")).toBe(false);
|
||||
expect(internals.totalSpawnedCount).toBe(0);
|
||||
});
|
||||
|
||||
it("updates agent state to error on failure", async () => {
|
||||
const agentStore = createMockAgentStore();
|
||||
const store = createMockStore();
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
||||
const internals = executor as any;
|
||||
|
||||
const mockSession = { dispose: vi.fn() };
|
||||
internals.childSessions.set("agent-test", mockSession);
|
||||
internals.totalSpawnedCount = 1;
|
||||
|
||||
// Make promptWithFallback throw
|
||||
const { promptWithFallback } = await import("./pi.js");
|
||||
vi.mocked(promptWithFallback).mockRejectedValueOnce(new Error("API error"));
|
||||
|
||||
await internals.runSpawnedChild("agent-test", mockSession, "Do the research");
|
||||
|
||||
expect(agentStore.updateAgentState).toHaveBeenCalledWith("agent-test", "running");
|
||||
expect(agentStore.updateAgentState).toHaveBeenCalledWith("agent-test", "error");
|
||||
// Should still clean up
|
||||
expect(internals.childSessions.has("agent-test")).toBe(false);
|
||||
expect(internals.totalSpawnedCount).toBe(0);
|
||||
});
|
||||
|
||||
it("cleans up even when state update fails", async () => {
|
||||
const agentStore = createMockAgentStore();
|
||||
agentStore.updateAgentState.mockRejectedValue(new Error("DB down"));
|
||||
const store = createMockStore();
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any);
|
||||
const internals = executor as any;
|
||||
|
||||
const mockSession = { dispose: vi.fn() };
|
||||
internals.childSessions.set("agent-test", mockSession);
|
||||
internals.totalSpawnedCount = 1;
|
||||
|
||||
// Should not throw even when state updates fail
|
||||
await internals.runSpawnedChild("agent-test", mockSession, "Do the research");
|
||||
|
||||
expect(internals.childSessions.has("agent-test")).toBe(false);
|
||||
expect(internals.totalSpawnedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import type { TaskStore, Task, TaskDetail, StepStatus, Settings, WorkflowStep, MissionStore, Slice } from "@fusion/core";
|
||||
import type { TaskStore, Task, TaskDetail, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability } from "@fusion/core";
|
||||
import type { AgentStore } from "@fusion/core";
|
||||
import { findWorktreeUser } from "./merger.js";
|
||||
import { generateWorktreeName, slugify } from "./worktree-names.js";
|
||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||
@@ -51,6 +52,28 @@ const taskAddDepParams = Type.Object({
|
||||
confirm: Type.Optional(Type.Boolean({ description: "Set to true to confirm adding the dependency. Required because adding a dep to an in-progress task will stop execution and discard current work." })),
|
||||
});
|
||||
|
||||
const spawnAgentParams = Type.Object({
|
||||
name: Type.String({ description: "Name for the child agent" }),
|
||||
role: Type.Union([
|
||||
Type.Literal("triage"),
|
||||
Type.Literal("executor"),
|
||||
Type.Literal("reviewer"),
|
||||
Type.Literal("merger"),
|
||||
Type.Literal("engineer"),
|
||||
Type.Literal("custom"),
|
||||
], { description: "Role for the child agent" }),
|
||||
task: Type.String({ description: "Task description for the child agent to execute" }),
|
||||
});
|
||||
|
||||
/** Result returned from spawn_agent tool */
|
||||
interface SpawnAgentResult {
|
||||
agentId: string;
|
||||
name: string;
|
||||
state: AgentState;
|
||||
role: AgentCapability;
|
||||
message: string;
|
||||
}
|
||||
|
||||
|
||||
const reviewStepParams = Type.Object({
|
||||
step: Type.Number({ description: "Step number to review" }),
|
||||
@@ -145,6 +168,34 @@ model, read-only access) to independently assess your work.
|
||||
- Removing code is acceptable ONLY when it is explicitly part of your task's mission
|
||||
- If you remove existing functionality, you MUST create a changeset in \`.changeset/\` explaining the removal and rationale
|
||||
|
||||
## Spawning Child Agents
|
||||
|
||||
You can spawn child agents to handle parallel work or specialized sub-tasks:
|
||||
|
||||
**When to use \`spawn_agent\`:**
|
||||
- Parallel work that can be divided into independent chunks
|
||||
- Specialized tasks requiring different expertise or tools
|
||||
- Delegation of sub-tasks to specialized agents
|
||||
|
||||
**How to spawn:**
|
||||
\`\`\`javascript
|
||||
spawn_agent({
|
||||
name: "researcher",
|
||||
role: "engineer",
|
||||
task: "Research best practices for authentication in React applications"
|
||||
})
|
||||
\`\`\`
|
||||
|
||||
**Child agent behavior:**
|
||||
- Each child runs in its own git worktree (branched from your worktree)
|
||||
- Children execute autonomously and report completion
|
||||
- When you end (task_done), all spawned children are terminated
|
||||
- Check AgentStore for spawned agent status
|
||||
|
||||
**Limits:**
|
||||
- Max 5 spawned agents per parent by default (configurable via settings)
|
||||
- Max 20 total spawned agents system-wide (configurable via settings)
|
||||
|
||||
## Completion
|
||||
After all steps are done, tests pass, and docs are updated:
|
||||
\`\`\`bash
|
||||
@@ -164,6 +215,8 @@ export interface TaskExecutorOptions {
|
||||
usageLimitPauser?: UsageLimitPauser;
|
||||
/** Stuck task detector — monitors agent sessions for stagnation and triggers recovery. */
|
||||
stuckTaskDetector?: StuckTaskDetector;
|
||||
/** AgentStore for tracking spawned child agents. If not provided, spawning is disabled. */
|
||||
agentStore?: import("@fusion/core").AgentStore;
|
||||
missionStore?: MissionStore;
|
||||
onSliceComplete?: (slice: Slice) => void;
|
||||
onStart?: (task: Task, worktreePath: string) => void;
|
||||
@@ -191,6 +244,12 @@ export class TaskExecutor {
|
||||
* Tracks compact-and-resume attempt count per execute() lifecycle.
|
||||
* Reset at execute() lifecycle end (finally block). */
|
||||
private loopRecoveryState = new Map<string, { attempts: number; pending: boolean }>();
|
||||
/** Spawned child agent IDs per parent task ID. Used for lifecycle tracking. */
|
||||
private spawnedAgents = new Map<string, Set<string>>();
|
||||
/** Child agent sessions keyed by agent ID. Used for termination. */
|
||||
private childSessions = new Map<string, AgentSession>();
|
||||
/** Total count of currently spawned agents (across all parents). */
|
||||
private totalSpawnedCount = 0;
|
||||
|
||||
/**
|
||||
* @param store — Task store instance (also used to listen for events)
|
||||
@@ -592,6 +651,7 @@ export class TaskExecutor {
|
||||
this.createTaskAddDepTool(task.id),
|
||||
this.createTaskDoneTool(task.id, () => { taskDone = true; }),
|
||||
this.createReviewStepTool(task.id, worktreePath, detail.prompt, codeReviewVerdicts, sessionRef, stepCheckpoints, detail),
|
||||
this.createSpawnAgentTool(task.id, worktreePath, settings),
|
||||
];
|
||||
|
||||
const agentLogger = new AgentLogger({
|
||||
@@ -863,6 +923,8 @@ export class TaskExecutor {
|
||||
stuckDetector?.untrackTask(task.id);
|
||||
await agentLogger.flush();
|
||||
session.dispose();
|
||||
// Terminate all spawned child agents when parent session ends
|
||||
await this.terminateAllChildren(task.id);
|
||||
// Clear session file when task completes or fails (not when paused —
|
||||
// the file is preserved so unpause can resume the conversation).
|
||||
// Check both the local flag (graceful exit) and the instance set
|
||||
@@ -2359,6 +2421,186 @@ If issues are found that need attention, describe them clearly.`;
|
||||
getWorktreePath(taskId: string): string | undefined {
|
||||
return this.activeWorktrees.get(taskId);
|
||||
}
|
||||
|
||||
// ── Agent Spawning ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Terminate all child agents spawned by a parent task.
|
||||
* Called from the finally block of agentWork when the parent session ends.
|
||||
*/
|
||||
private async terminateAllChildren(parentTaskId: string): Promise<void> {
|
||||
const childIds = this.spawnedAgents.get(parentTaskId);
|
||||
if (!childIds || childIds.size === 0) return;
|
||||
|
||||
executorLog.log(`Terminating ${childIds.size} child agents for parent ${parentTaskId}`);
|
||||
|
||||
for (const childId of childIds) {
|
||||
await this.terminateChildAgent(childId);
|
||||
}
|
||||
this.spawnedAgents.delete(parentTaskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate a single child agent by ID.
|
||||
* Disposes the session, updates AgentStore state, and cleans up tracking Maps.
|
||||
*/
|
||||
private async terminateChildAgent(childId: string): Promise<void> {
|
||||
const childSession = this.childSessions.get(childId);
|
||||
if (childSession) {
|
||||
childSession.dispose();
|
||||
this.childSessions.delete(childId);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.options.agentStore?.updateAgentState(childId, "terminated");
|
||||
} catch {
|
||||
// Agent may not exist in store — that's ok for cleanup
|
||||
}
|
||||
|
||||
this.totalSpawnedCount = Math.max(0, this.totalSpawnedCount - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a spawned child agent's task to completion.
|
||||
* Handles state transitions and cleanup.
|
||||
*/
|
||||
private async runSpawnedChild(
|
||||
agentId: string,
|
||||
childSession: AgentSession,
|
||||
taskPrompt: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.options.agentStore?.updateAgentState(agentId, "running");
|
||||
} catch {
|
||||
// State update failure shouldn't block execution
|
||||
}
|
||||
|
||||
try {
|
||||
await promptWithFallback(childSession, taskPrompt);
|
||||
// Normal completion — mark as active (available)
|
||||
try {
|
||||
await this.options.agentStore?.updateAgentState(agentId, "active");
|
||||
} catch { /* non-critical */ }
|
||||
} catch (err: any) {
|
||||
// Error during execution — mark as error
|
||||
try {
|
||||
await this.options.agentStore?.updateAgentState(agentId, "error");
|
||||
} catch { /* non-critical */ }
|
||||
executorLog.warn(`Child agent ${agentId} failed: ${err.message}`);
|
||||
} finally {
|
||||
this.childSessions.delete(agentId);
|
||||
this.totalSpawnedCount = Math.max(0, this.totalSpawnedCount - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the spawn_agent tool definition.
|
||||
* Allows the parent agent to spawn child agents with delegated tasks.
|
||||
*/
|
||||
private createSpawnAgentTool(taskId: string, worktreePath: string, settings: Settings): ToolDefinition {
|
||||
return {
|
||||
name: "spawn_agent",
|
||||
label: "Spawn Agent",
|
||||
description:
|
||||
"Spawn a child agent to handle parallel work or specialized sub-tasks. " +
|
||||
"Each child runs in its own git worktree (branched from your worktree) and executes autonomously. " +
|
||||
"When you end (task_done), all spawned children are terminated.",
|
||||
parameters: spawnAgentParams,
|
||||
execute: async (_id: string, params: Static<typeof spawnAgentParams>) => {
|
||||
const { name, role, task: taskPrompt } = params;
|
||||
|
||||
// Check if AgentStore is available
|
||||
if (!this.options.agentStore) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Agent spawning is not available (no AgentStore configured)" }],
|
||||
details: { agentId: "", state: "error" },
|
||||
};
|
||||
}
|
||||
|
||||
// Read spawn limits from settings
|
||||
const maxPerParent = settings.maxSpawnedAgentsPerParent ?? 5;
|
||||
const maxGlobal = settings.maxSpawnedAgentsGlobal ?? 20;
|
||||
|
||||
// Check per-parent limit
|
||||
const currentPerParent = this.spawnedAgents.get(taskId)?.size ?? 0;
|
||||
if (currentPerParent >= maxPerParent) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Per-parent spawn limit reached (${currentPerParent}/${maxPerParent}). Wait for children to finish or reduce parallelism.` }],
|
||||
details: { agentId: "", state: "error" },
|
||||
};
|
||||
}
|
||||
|
||||
// Check global limit
|
||||
if (this.totalSpawnedCount >= maxGlobal) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Global spawn limit reached (${this.totalSpawnedCount}/${maxGlobal}). Cannot spawn more agents.` }],
|
||||
details: { agentId: "", state: "error" },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Create agent in AgentStore with reportsTo = parent task ID
|
||||
const agent = await this.options.agentStore.createAgent({
|
||||
name: name.trim(),
|
||||
role: role as AgentCapability,
|
||||
reportsTo: taskId,
|
||||
metadata: { type: "spawned", parentTaskId: taskId },
|
||||
});
|
||||
|
||||
// Create git worktree for child (branched from parent's worktree)
|
||||
const childWorktreeName = generateWorktreeName(this.rootDir);
|
||||
const childWorktreePath = join(this.rootDir, ".worktrees", childWorktreeName);
|
||||
const childBranch = `fusion/spawn-${agent.id}`;
|
||||
await this.createWorktree(childBranch, childWorktreePath, taskId, worktreePath);
|
||||
|
||||
// Transition agent to active state
|
||||
await this.options.agentStore.updateAgentState(agent.id, "active");
|
||||
|
||||
// Create child agent session
|
||||
const { session: childSession } = await createKbAgent({
|
||||
cwd: childWorktreePath,
|
||||
systemPrompt: `You are a child agent spawned by a parent task executor. Your job is to complete the following delegated task. Work autonomously and thoroughly. Report your findings and results.\n\nParent task: ${taskId}\nChild agent: ${agent.id} (${name})`,
|
||||
tools: "coding",
|
||||
defaultProvider: settings.defaultProvider,
|
||||
defaultModelId: settings.defaultModelId,
|
||||
fallbackProvider: settings.fallbackProvider,
|
||||
fallbackModelId: settings.fallbackModelId,
|
||||
});
|
||||
|
||||
// Store tracking state
|
||||
this.childSessions.set(agent.id, childSession);
|
||||
if (!this.spawnedAgents.has(taskId)) {
|
||||
this.spawnedAgents.set(taskId, new Set());
|
||||
}
|
||||
this.spawnedAgents.get(taskId)!.add(agent.id);
|
||||
this.totalSpawnedCount++;
|
||||
|
||||
// Run child asynchronously (don't await — parent continues working)
|
||||
this.runSpawnedChild(agent.id, childSession, taskPrompt).catch((err: any) => {
|
||||
executorLog.warn(`Child agent ${agent.id} async error: ${err.message}`);
|
||||
});
|
||||
|
||||
const result: SpawnAgentResult = {
|
||||
agentId: agent.id,
|
||||
name: agent.name,
|
||||
state: "running",
|
||||
role: agent.role,
|
||||
message: `Agent "${name}" spawned and executing task: ${taskPrompt.slice(0, 100)}${taskPrompt.length > 100 ? "..." : ""}`,
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
||||
details: result,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Failed to spawn agent: ${err.message}` }],
|
||||
details: { agentId: "", state: "error", message: err.message },
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user