feat: multi-project engine runtime improvements

- Update project manager and runtime interfaces for multi-project coordination
- Add project engine configuration to core types and settings schema
- Enhance child-process worker tests for signal handling coverage
- Extend in-process runtime with per-project engine lifecycle hooks

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
gsxdsm
2026-04-12 19:59:12 -07:00
parent 415f636056
commit 2793da84bd
9 changed files with 767 additions and 851 deletions

View File

@@ -13,13 +13,15 @@ const mockState = vi.hoisted(() => ({
runtimes: [] as any[],
}));
vi.mock("../logger.js", () => ({
runtimeLog: {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("../logger.js", () => {
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
return {
runtimeLog: mockLogger,
createLogger: () => mockLogger,
schedulerLog: mockLogger,
triageLog: mockLogger,
};
});
vi.mock("@fusion/core", () => ({
CentralCore: class MockCentralCore {},
@@ -88,6 +90,21 @@ vi.mock("./in-process-runtime.js", () => {
return { InProcessRuntime: MockInProcessRuntime };
});
vi.mock("../project-engine.js", async () => {
const { InProcessRuntime } = await import("./in-process-runtime.js");
class MockProjectEngine {
private runtime: any;
constructor(config: any, centralCore: any, _options?: any) {
this.runtime = new InProcessRuntime(config, centralCore);
}
start = vi.fn(async () => { await this.runtime.start(); });
stop = vi.fn(async () => { await this.runtime.stop(); });
getRuntime = vi.fn(() => this.runtime);
getTaskStore = vi.fn(() => null);
}
return { ProjectEngine: MockProjectEngine };
});
type MockWorker = {
handlers: Map<string, (payload: unknown) => Promise<unknown> | unknown>;
onCommand: ReturnType<typeof vi.fn>;
@@ -348,7 +365,9 @@ describe("child-process-worker", () => {
expect(runtime.stop).toHaveBeenCalledTimes(1);
});
expect(worker.shutdown).toHaveBeenCalledTimes(1);
await vi.waitFor(() => {
expect(worker.shutdown).toHaveBeenCalledTimes(1);
});
});
it("SIGINT stops runtime and shuts down IPC worker", async () => {
@@ -363,6 +382,8 @@ describe("child-process-worker", () => {
expect(runtime.stop).toHaveBeenCalledTimes(1);
});
expect(worker.shutdown).toHaveBeenCalledTimes(1);
await vi.waitFor(() => {
expect(worker.shutdown).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -89,6 +89,7 @@ export class InProcessRuntime
private routineRunner?: RoutineRunner;
private routineScheduler?: RoutineScheduler;
private missionExecutionLoop?: MissionExecutionLoop;
private missionAutopilot?: MissionAutopilot;
private triageProcessor?: TriageProcessor;
/**
@@ -124,11 +125,16 @@ export class InProcessRuntime
runtimeLog.log(`Starting InProcessRuntime for project ${this.config.projectId}`);
try {
// 1. Initialize TaskStore
// 1. Initialize TaskStore (use external if provided, otherwise create new)
const { TaskStore, PluginStore: PluginStoreClass, PluginLoader: PluginLoaderClass } = await import("@fusion/core");
this.taskStore = new TaskStore(this.config.workingDirectory);
await this.taskStore.init();
runtimeLog.log(`TaskStore initialized for project ${this.config.projectId}`);
if (this.config.externalTaskStore) {
this.taskStore = this.config.externalTaskStore;
runtimeLog.log(`TaskStore provided externally for project ${this.config.projectId}`);
} else {
this.taskStore = new TaskStore(this.config.workingDirectory);
await this.taskStore.init();
runtimeLog.log(`TaskStore initialized for project ${this.config.projectId}`);
}
// 2. Initialize Plugin system (PluginStore + PluginLoader + PluginRunner)
this.pluginStore = new PluginStoreClass(this.taskStore.getFusionDir());
@@ -164,15 +170,21 @@ export class InProcessRuntime
);
}
// 4. Initialize global semaphore from CentralCore
const globalLimit = await this.getGlobalConcurrencyLimit();
this.globalSemaphore = new AgentSemaphore(() => globalLimit);
// 4. Initialize global semaphore — use shared one from ProjectManager if provided,
// otherwise create a local one from CentralCore (single-project mode).
if (this.config.globalSemaphore) {
this.globalSemaphore = this.config.globalSemaphore;
} else {
const globalLimit = await this.getGlobalConcurrencyLimit();
this.globalSemaphore = new AgentSemaphore(() => globalLimit);
}
// 5. Initialize Scheduler
const missionStore = this.taskStore.getMissionStore();
const missionAutopilot = missionStore
this.missionAutopilot = missionStore
? new MissionAutopilot(this.taskStore, missionStore)
: undefined;
const missionAutopilot = this.missionAutopilot;
// Initialize MissionExecutionLoop for validation cycle handling
const missionExecutionLoop = missionStore
@@ -482,6 +494,9 @@ export class InProcessRuntime
void this.scheduler.reconcileAllMissionFeatures();
}
// 14. Start MissionAutopilot background polling
this.missionAutopilot?.start();
this.setStatus("active");
runtimeLog.log(`InProcessRuntime started for project ${this.config.projectId}`);
} catch (error) {
@@ -563,6 +578,12 @@ export class InProcessRuntime
runtimeLog.log("Scheduler stopped");
}
// 7. Stop mission autopilot background polling
if (this.missionAutopilot) {
this.missionAutopilot.stop();
runtimeLog.log("MissionAutopilot stopped");
}
// 7. Stop mission execution loop
if (this.missionExecutionLoop) {
this.missionExecutionLoop.stop();
@@ -709,6 +730,22 @@ export class InProcessRuntime
return this.triageProcessor;
}
/**
* Get the MissionAutopilot instance (if initialized).
* Returns undefined when no MissionStore is available.
*/
getMissionAutopilot(): MissionAutopilot | undefined {
return this.missionAutopilot;
}
/**
* Get the MissionExecutionLoop instance (if initialized).
* Returns undefined when no MissionStore is available.
*/
getMissionExecutionLoop(): MissionExecutionLoop | undefined {
return this.missionExecutionLoop;
}
/**
* Execute a heartbeat run for an agent.
*