feat(KB-501): implement multi-project runtime architecture with HybridExecutor

- Add ProjectRuntime interface with InProcessRuntime and ChildProcessRuntime implementations
- Implement HybridExecutor for multi-project task orchestration
- Add IPC protocol for host-worker communication in child-process mode
- Implement health monitoring with automatic restart for child processes
- Add comprehensive tests for hybrid-executor and project-runtime
- Update AGENTS.md with multi-project runtime documentation
- Add changeset for hybrid-executor-runtime release
This commit is contained in:
gsxdsm
2026-03-31 20:17:25 -07:00
parent 7852d74872
commit 6307fea203
8 changed files with 1391 additions and 0 deletions

View File

@@ -0,0 +1,344 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { CentralCore, RegisteredProject } from "@fusion/core";
import { HybridExecutor } from "../hybrid-executor.js";
import type { ProjectRuntimeConfig } from "../project-runtime.js";
// Mock the ProjectManager
const mockRuntimes = new Map();
const mockProjectIds: string[] = [];
vi.mock("../project-manager.js", () => ({
ProjectManager: vi.fn().mockImplementation(() => ({
addProject: vi.fn().mockImplementation((config: ProjectRuntimeConfig) => {
const runtime = {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
getStatus: vi.fn().mockReturnValue("active"),
getTaskStore: vi.fn(),
getScheduler: vi.fn(),
getMetrics: vi.fn().mockReturnValue({
inFlightTasks: 0,
activeAgents: 0,
lastActivityAt: new Date().toISOString(),
}),
on: vi.fn().mockReturnThis(),
};
mockRuntimes.set(config.projectId, runtime);
mockProjectIds.push(config.projectId);
return Promise.resolve(runtime);
}),
removeProject: vi.fn().mockImplementation((projectId: string) => {
mockRuntimes.delete(projectId);
const index = mockProjectIds.indexOf(projectId);
if (index > -1) mockProjectIds.splice(index, 1);
return Promise.resolve(undefined);
}),
getRuntime: vi.fn().mockImplementation((projectId: string) => {
return mockRuntimes.get(projectId);
}),
listRuntimes: vi.fn().mockImplementation(() => {
return Array.from(mockRuntimes.values());
}),
getProjectIds: vi.fn().mockImplementation(() => {
return [...mockProjectIds];
}),
getGlobalMetrics: vi.fn().mockResolvedValue({
totalInFlightTasks: 0,
totalActiveAgents: 0,
runtimeCountByStatus: {
active: 0,
paused: 0,
errored: 0,
stopped: 0,
starting: 0,
stopping: 0,
},
totalRuntimes: 0,
}),
acquireGlobalSlot: vi.fn().mockResolvedValue(true),
releaseGlobalSlot: vi.fn().mockResolvedValue(undefined),
stopAll: vi.fn().mockImplementation(() => {
mockRuntimes.clear();
mockProjectIds.length = 0;
return Promise.resolve(undefined);
}),
on: vi.fn().mockReturnThis(),
})),
}));
describe("HybridExecutor", () => {
let executor: HybridExecutor;
let mockCentralCore: CentralCore;
const mockProject: RegisteredProject = {
id: "proj_test123",
name: "Test Project",
path: "/tmp/test-project",
status: "initializing",
isolationMode: "in-process",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
beforeEach(() => {
mockCentralCore = {
listProjects: vi.fn().mockResolvedValue([mockProject]),
getProject: vi.fn().mockResolvedValue(mockProject),
registerProject: vi.fn().mockResolvedValue(mockProject),
unregisterProject: vi.fn().mockResolvedValue(undefined),
updateProject: vi.fn().mockResolvedValue(mockProject),
getGlobalConcurrencyState: vi.fn().mockResolvedValue({
globalMaxConcurrent: 4,
currentlyActive: 0,
queuedCount: 0,
projectsActive: {},
}),
updateProjectHealth: vi.fn().mockResolvedValue(undefined),
logActivity: vi.fn().mockResolvedValue(undefined),
acquireGlobalSlot: vi.fn().mockResolvedValue(true),
releaseGlobalSlot: vi.fn().mockResolvedValue(undefined),
removeAllListeners: vi.fn(),
on: vi.fn().mockReturnThis(),
} as unknown as CentralCore;
executor = new HybridExecutor(mockCentralCore);
});
afterEach(async () => {
try {
await executor.shutdown();
} catch {
// Ignore cleanup errors
}
vi.clearAllMocks();
mockRuntimes.clear();
mockProjectIds.length = 0;
});
describe("initialization", () => {
it("should start with initialized = false", () => {
expect(executor.isInitialized()).toBe(false);
});
it("should set initialized = true after initialize()", async () => {
await executor.initialize();
expect(executor.isInitialized()).toBe(true);
});
it("should load existing projects on initialize", async () => {
await executor.initialize();
expect(mockCentralCore.listProjects).toHaveBeenCalled();
});
it("should setup CentralCore listeners on initialize", async () => {
await executor.initialize();
expect(mockCentralCore.on).toHaveBeenCalledWith(
"project:registered",
expect.any(Function)
);
expect(mockCentralCore.on).toHaveBeenCalledWith(
"project:unregistered",
expect.any(Function)
);
expect(mockCentralCore.on).toHaveBeenCalledWith(
"project:updated",
expect.any(Function)
);
});
it("should be idempotent (initialize multiple times)", async () => {
await executor.initialize();
const callCount = (mockCentralCore.listProjects as ReturnType<typeof vi.fn>).mock.calls.length;
await executor.initialize();
expect(mockCentralCore.listProjects).toHaveBeenCalledTimes(callCount);
});
});
describe("project lifecycle", () => {
const testConfig: ProjectRuntimeConfig = {
projectId: "proj_test123",
workingDirectory: "/tmp/test-project",
isolationMode: "in-process",
maxConcurrent: 2,
maxWorktrees: 4,
};
beforeEach(async () => {
await executor.initialize();
});
it("should add a project runtime", async () => {
const runtime = await executor.addProject(testConfig);
expect(runtime).toBeDefined();
});
it("should emit project:added when adding project", async () => {
const handler = vi.fn();
executor.on("project:added", handler);
await executor.addProject(testConfig);
expect(handler).toHaveBeenCalledWith({
projectId: "proj_test123",
projectName: "Test Project",
});
});
it("should remove a project runtime", async () => {
await executor.addProject(testConfig);
await executor.removeProject("proj_test123");
// Should not throw
});
it("should emit project:removed when removing project", async () => {
const handler = vi.fn();
executor.on("project:removed", handler);
await executor.addProject(testConfig);
await executor.removeProject("proj_test123");
expect(handler).toHaveBeenCalledWith({
projectId: "proj_test123",
projectName: "Test Project",
});
});
it("should get a runtime by project ID", async () => {
await executor.addProject(testConfig);
const runtime = executor.getRuntime("proj_test123");
expect(runtime).toBeDefined();
});
it("should list all runtimes", async () => {
await executor.addProject(testConfig);
const runtimes = executor.listRuntimes();
expect(Array.isArray(runtimes)).toBe(true);
});
it("should get all project IDs", async () => {
await executor.addProject(testConfig);
const ids = executor.getProjectIds();
expect(ids).toContain("proj_test123");
});
});
describe("global metrics", () => {
beforeEach(async () => {
await executor.initialize();
});
it("should get global metrics", async () => {
const metrics = await executor.getGlobalMetrics();
expect(metrics).toHaveProperty("totalInFlightTasks");
expect(metrics).toHaveProperty("totalActiveAgents");
expect(metrics).toHaveProperty("runtimeCountByStatus");
expect(metrics).toHaveProperty("totalRuntimes");
});
});
describe("concurrency slots", () => {
beforeEach(async () => {
await executor.initialize();
});
it("should acquire global slot", async () => {
const acquired = await executor.acquireGlobalSlot("proj_test123");
expect(acquired).toBe(true);
});
it("should release global slot", async () => {
await executor.releaseGlobalSlot("proj_test123");
// Should not throw
});
});
describe("event forwarding", () => {
beforeEach(async () => {
await executor.initialize();
});
it("should support task:created event", () => {
const handler = vi.fn();
executor.on("task:created", handler);
// The event should be listenable
expect(handler).not.toHaveBeenCalled();
});
it("should support task:moved event", () => {
const handler = vi.fn();
executor.on("task:moved", handler);
expect(handler).not.toHaveBeenCalled();
});
it("should support task:updated event", () => {
const handler = vi.fn();
executor.on("task:updated", handler);
expect(handler).not.toHaveBeenCalled();
});
it("should support error event", () => {
const handler = vi.fn();
executor.on("error", handler);
expect(handler).not.toHaveBeenCalled();
});
it("should support health:changed event", () => {
const handler = vi.fn();
executor.on("health:changed", handler);
expect(handler).not.toHaveBeenCalled();
});
});
describe("shutdown", () => {
beforeEach(async () => {
await executor.initialize();
});
it("should set initialized = false after shutdown", async () => {
await executor.shutdown();
expect(executor.isInitialized()).toBe(false);
});
it("should be safe to call shutdown multiple times", async () => {
await executor.shutdown();
await executor.shutdown(); // Should not throw
expect(executor.isInitialized()).toBe(false);
});
it("should remove CentralCore listeners on shutdown", async () => {
await executor.shutdown();
expect(mockCentralCore.removeAllListeners).toHaveBeenCalledWith(
"project:registered"
);
expect(mockCentralCore.removeAllListeners).toHaveBeenCalledWith(
"project:unregistered"
);
expect(mockCentralCore.removeAllListeners).toHaveBeenCalledWith(
"project:updated"
);
});
});
describe("updateProject", () => {
const testConfig: ProjectRuntimeConfig = {
projectId: "proj_test123",
workingDirectory: "/tmp/test-project",
isolationMode: "in-process",
maxConcurrent: 2,
maxWorktrees: 4,
};
beforeEach(async () => {
await executor.initialize();
await executor.addProject(testConfig);
});
it("should throw if runtime not found", async () => {
await expect(
executor.updateProject("non-existent", { maxConcurrent: 4 })
).rejects.toThrow("Runtime not found");
});
});
});