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 f810774f6c
commit 3fc577fb5e
9 changed files with 767 additions and 851 deletions

View File

@@ -0,0 +1,104 @@
# ProjectEngine Migration Plan
Status: In progress
Last updated: 2026-04-12
## Goal
Consolidate all engine subsystem wiring into `ProjectEngine` so that every code path
(single-project CLI, multi-project ProjectManager, child-process worker) gets the full
subsystem set from one place. This eliminates the class of bugs where a subsystem is
added in one code path but forgotten in another (e.g., TriageProcessor was missing from
InProcessRuntime for multi-project mode).
## Current architecture
```
Single-project CLI (serve.ts / dashboard.ts)
└─ Creates subsystems inline + passes to createServer
Multi-project (ProjectManager)
└─ InProcessRuntime / ChildProcessRuntime / RemoteNodeRuntime
└─ ChildProcessRuntime → child-process-worker.ts → ProjectEngine
```
## What ProjectEngine now handles
These subsystems are managed by ProjectEngine and should NOT be duplicated inline:
| Subsystem | Source |
|---|---|
| InProcessRuntime (TaskStore, Scheduler, TaskExecutor, TriageProcessor, StuckTaskDetector, AgentSemaphore, WorktreePool, UsageLimitPauser, AgentStore) | via InProcessRuntime |
| PrMonitor + PrCommentHandler | ProjectEngine |
| NtfyNotifier | ProjectEngine |
| CronRunner + AutomationStore | ProjectEngine |
| Auto-merge queue (with conflict retry, verification error handling, cooldown retry, buffer failure healing) | ProjectEngine |
| Settings event listeners (global pause, unpause, engine unpause, stuck timeout, insight extraction sync) | ProjectEngine |
## What remains inline in serve.ts / dashboard.ts
These components are CLI-specific and NOT yet in ProjectEngine. Future migration
candidates are marked with priority.
### High priority (shared across serve.ts and dashboard.ts)
| Component | Why it's inline | Migration path |
|---|---|---|
| **MissionAutopilot** | Needs scheduler ref (circular dep via `setScheduler`). Created before engine start. | Add to ProjectEngine. Break circular dep by having ProjectEngine call `setScheduler()` internally after runtime start. |
| **MissionExecutionLoop** | Coupled to MissionAutopilot. Needs taskStore + missionStore + rootDir. | Move alongside MissionAutopilot into ProjectEngine. |
| **SelfHealingManager** | Needs executor + triage refs for recovery callbacks. Currently uses late-binding `executorRef`/`triageRef`. | Add to ProjectEngine. Wire callbacks to internal runtime's executor/triage. Expose `recoverCompletedTask` etc. |
### Medium priority (shared but with CLI-specific behavior)
| Component | Why it's inline | Migration path |
|---|---|---|
| **HeartbeatMonitor** | Utility path (no semaphore). Needs agentStore, taskStore, rootDir, and CLI-specific callbacks (`onMissed`, `onTerminated` log to console). | Add to ProjectEngine with configurable callbacks. Keep as utility (no semaphore gating). |
| **HeartbeatTriggerScheduler** | Paired with HeartbeatMonitor. Needs agentStore + callback to HeartbeatMonitor. | Move alongside HeartbeatMonitor. |
| **AuthStorage + ModelRegistry + extension loading** | Pi-coding-agent specific. Discovers extensions, registers providers, syncs OpenRouter models. | Keep in CLI layer — this is auth/model wiring, not engine orchestration. Not a ProjectEngine concern. |
### Low priority (CLI-specific, keep inline)
| Component | Reason to keep inline |
|---|---|
| **PluginStore + PluginLoader** | Plugin system is a dashboard/CLI concern, not engine. |
| **`createServer()` call** | HTTP server setup is CLI-specific. |
| **Diagnostic utilities** | Process monitoring, memory logging — CLI concern. |
| **Port selection** | Interactive prompt — CLI concern. |
| **CentralCore node registration** | Registers local node status — serve.ts specific. |
| **`onMemoryInsightRunProcessed` callback** | Already passed via `onInsightRunProcessed` to ProjectEngine. The detailed `processAndAuditInsightExtraction` call can stay as the callback impl. |
## Migration sequence
### Phase 1 (current — in progress)
- [x] Add TriageProcessor to InProcessRuntime
- [x] Create ProjectEngine wrapper
- [x] Migrate child-process-worker to ProjectEngine
- [x] Shared global semaphore from ProjectManager
- [x] Move richer merge logic (verification handling, cooldown retry) into ProjectEngine
- [ ] Migrate serve.ts to use ProjectEngine (agent in progress)
- [ ] Migrate dashboard.ts to use ProjectEngine (agent in progress)
### Phase 2 (next)
- [ ] Move MissionAutopilot + MissionExecutionLoop into ProjectEngine
- Add `missionStore` to ProjectEngineOptions
- Create MissionAutopilot internally, wire setScheduler after start
- Expose via `getMissionAutopilot()` / `getMissionExecutionLoop()`
- [ ] Move SelfHealingManager into ProjectEngine
- Wire callbacks to internal runtime's executor/triage
- No external configuration needed
### Phase 3 (later)
- [ ] Move HeartbeatMonitor + HeartbeatTriggerScheduler into ProjectEngine
- Add heartbeat callbacks to ProjectEngineOptions
- Keep as utility path (no semaphore)
- Expose via `getHeartbeatMonitor()`
- [ ] Audit serve.ts / dashboard.ts for remaining inline engine wiring
- [ ] Consider making `createServer` accept a ProjectEngine directly
## Design principles
1. **ProjectEngine is the single source of truth** for engine subsystem composition
2. **CLI layer** only handles: HTTP server, auth, plugins, diagnostics, UI-specific callbacks
3. **Callbacks over hardcoding** — ProjectEngine accepts option callbacks for CLI-specific behavior (merge strategy, PR merge, insight processing, etc.)
4. **No duplicate subsystems** — if ProjectEngine creates it, CLI must not also create it
5. **Dev mode** — dashboard.ts `opts.dev` skips engine start entirely; ProjectEngine handles this via not calling `start()`

View File

@@ -3,7 +3,10 @@ import type {
Task,
CentralCore,
Settings,
MergeResult,
AutomationStore as AutomationStoreType,
ScheduledTask,
AutomationRunResult,
} from "@fusion/core";
import { InProcessRuntime } from "./runtimes/in-process-runtime.js";
import type { ProjectRuntimeConfig } from "./project-runtime.js";
@@ -30,6 +33,12 @@ export interface ProjectEngineOptions {
projectId?: string;
/** Base URL for ntfy.sh notifications */
ntfyBaseUrl?: string;
/**
* An already-initialized TaskStore to use instead of creating a new one.
* When provided, InProcessRuntime will skip TaskStore construction and init().
* Useful when the caller (e.g. dashboard.ts) owns and watches the store.
*/
externalTaskStore?: TaskStore;
/**
* Returns the merge strategy for the current settings.
* If not provided, defaults to "direct".
@@ -50,6 +59,11 @@ export interface ProjectEngineOptions {
* Invoked after CronRunner completes a memory insight extraction schedule.
*/
onInsightRunProcessed?: (schedule: unknown, result: unknown) => void | Promise<void>;
/**
* Whether to skip starting NtfyNotifier. Useful when the caller manages
* notifications independently. Defaults to false (notifier is started).
*/
skipNotifier?: boolean;
}
/**
@@ -83,6 +97,8 @@ export class ProjectEngine {
private shuttingDown = false;
private static readonly MAX_AUTO_MERGE_RETRIES = 3;
/** 30-minute cooldown before a retry-exhausted task gets another sweep attempt */
private static readonly AUTO_MERGE_COOLDOWN_MS = 30 * 60 * 1000;
// Event handler references for cleanup
private settingsHandlers: Array<(...args: any[]) => void> = [];
@@ -93,7 +109,11 @@ export class ProjectEngine {
centralCore: CentralCore,
private options: ProjectEngineOptions = {},
) {
this.runtime = new InProcessRuntime(config, centralCore);
// Pass through externalTaskStore to the runtime config if provided
const runtimeConfig: ProjectRuntimeConfig = options.externalTaskStore
? { ...config, externalTaskStore: options.externalTaskStore }
: config;
this.runtime = new InProcessRuntime(runtimeConfig, centralCore);
}
/**
@@ -113,12 +133,14 @@ export class ProjectEngine {
this.prCommentHandler!.handleNewComments(taskId, prInfo, comments),
);
// 3. Initialize NtfyNotifier
this.notifier = new NtfyNotifier(store, {
projectId: this.options.projectId,
ntfyBaseUrl: this.options.ntfyBaseUrl,
});
await this.notifier.start();
// 3. Initialize NtfyNotifier (unless caller manages it externally)
if (!this.options.skipNotifier) {
this.notifier = new NtfyNotifier(store, {
projectId: this.options.projectId,
ntfyBaseUrl: this.options.ntfyBaseUrl,
});
await this.notifier.start();
}
// 4. Initialize AutomationStore + CronRunner
try {
@@ -129,7 +151,7 @@ export class ProjectEngine {
const aiPromptExecutor = await createAiPromptExecutor(cwd);
this.cronRunner = new CronRunner(store, this.automationStore, {
aiPromptExecutor,
onScheduleRunProcessed: this.options.onInsightRunProcessed as any,
onScheduleRunProcessed: this.buildInsightRunHandler(cwd),
});
// Sync insight extraction automation on startup
@@ -231,15 +253,120 @@ export class ProjectEngine {
return this.cronRunner;
}
// ── Auto-merge subsystem ──
private canMergeTask(task: Task): boolean {
const blocker = this.options.getTaskMergeBlocker?.(task);
if (blocker) return false;
return (task.mergeRetries ?? 0) < ProjectEngine.MAX_AUTO_MERGE_RETRIES;
/** Get the AutomationStore (if initialized). */
getAutomationStore(): AutomationStoreType | undefined {
return this.automationStore;
}
private enqueueMerge(taskId: string): void {
/**
* Enqueue a task ID for auto-merge if it is not already queued or active.
* Exposed publicly so callers can integrate the engine's merge queue with
* an external `onMerge` callback (e.g. dashboard's createServer call).
*/
enqueueMerge(taskId: string): void {
this.internalEnqueueMerge(taskId);
}
/**
* Directly perform an AI-powered merge for a task (semaphore-gated).
* This is the manual "merge now" path, bypassing the auto-merge queue.
* Returns the full MergeResult so it can be used as the `onMerge` callback
* in createServer().
*/
async onMerge(taskId: string): Promise<MergeResult> {
const store = this.runtime.getTaskStore();
const cwd = this.config.workingDirectory;
const semaphore = (this.runtime as any).globalSemaphore;
const pool = (this.runtime as any).worktreePool;
const agentStore = (this.runtime as any).agentStore;
const usageLimitPauser = (this.runtime as any).usageLimitPauser;
const rawMerge = () =>
aiMergeTask(store, cwd, taskId, {
pool,
usageLimitPauser,
agentStore,
onSession: (session) => {
this.activeMergeSession = session;
},
});
const result = semaphore
? await semaphore.run(rawMerge, PRIORITY_MERGE)
: await rawMerge();
this.activeMergeSession = null;
return result;
}
// ── Merge eligibility helpers (richer logic from dashboard.ts) ──
/**
* True when a retry-exhausted task in "in-review" has a verification buffer
* failure that can be auto-healed by resetting mergeRetries and re-running.
*/
private hasAutoHealableVerificationBufferFailure(task: {
mergeRetries?: number | null;
column: string;
error?: string | null;
log?: Array<{ action?: string }>;
}): boolean {
if (task.column !== "in-review") return false;
if ((task.mergeRetries ?? 0) < ProjectEngine.MAX_AUTO_MERGE_RETRIES) return false;
const err = task.error ?? "";
const matchesVerificationError =
err.includes("Deterministic test verification failed") ||
err.includes("Deterministic build verification failed") ||
err.includes("Build verification failed") ||
err.includes("Test verification failed");
if (!matchesVerificationError) return false;
return (
task.log?.some(
(entry) =>
entry.action?.includes("[verification] test command failed (exit 0)") ||
entry.action?.includes("[verification] build command failed (exit 0)") ||
entry.action?.includes("output exceeded buffer"),
) ?? false
);
}
/**
* True when a retry-exhausted task has been idle long enough for a
* 30-minute cooldown merge attempt.
*/
private isRetryCooldownElapsed(task: { updatedAt?: string | null }): boolean {
if (!task.updatedAt) return false;
const updated = Date.parse(task.updatedAt);
if (Number.isNaN(updated)) return false;
return Date.now() - updated >= ProjectEngine.AUTO_MERGE_COOLDOWN_MS;
}
/**
* Returns true if the task is eligible for auto-merge. Uses richer eligibility
* checks: merge blocker, retry limit, auto-heal patterns, cooldown elapsed.
*/
private canMergeTask(task: {
id?: string;
mergeRetries?: number | null;
column: string;
paused?: boolean;
status?: string | null;
error?: string | null;
steps?: Array<{ status: string }>;
workflowStepResults?: Array<{ status: string }>;
log?: Array<{ action?: string }>;
updatedAt?: string | null;
}): boolean {
if (this.options.getTaskMergeBlocker?.(task as Task)) return false;
return (
(task.mergeRetries ?? 0) < ProjectEngine.MAX_AUTO_MERGE_RETRIES ||
this.hasAutoHealableVerificationBufferFailure(task) ||
this.isRetryCooldownElapsed(task)
);
}
private internalEnqueueMerge(taskId: string): void {
if (this.mergeActive.has(taskId)) return;
this.mergeActive.add(taskId);
this.mergeQueue.push(taskId);
@@ -257,23 +384,59 @@ export class ProjectEngine {
while (this.mergeQueue.length > 0 && !this.shuttingDown) {
const taskId = this.mergeQueue.shift()!;
try {
// Re-check autoMerge and pause before each merge
const settings = await store.getSettings();
if (settings.globalPause || settings.enginePaused) {
runtimeLog.log(
`Auto-merge skipping ${taskId}${settings.globalPause ? "global pause" : "engine paused"} active`,
);
continue;
}
if (!settings.autoMerge) {
runtimeLog.log(`Auto-merge skipping ${taskId} — autoMerge disabled`);
continue;
}
const task = await store.getTask(taskId);
if (!task || task.column !== "in-review") {
continue;
}
const settings = await store.getSettings();
if (settings.globalPause || settings.enginePaused) break;
if (!this.canMergeTask(task as any)) {
continue;
}
// Auto-heal verification buffer failures by resetting retry counter
if (this.hasAutoHealableVerificationBufferFailure(task as any)) {
await store.logEntry(
taskId,
"Auto-healing stale deterministic verification buffer failure; retrying merge verification",
);
await store.updateTask(taskId, { mergeRetries: 0, error: null, status: null });
} else if (
(task.mergeRetries ?? 0) >= ProjectEngine.MAX_AUTO_MERGE_RETRIES &&
this.isRetryCooldownElapsed(task as any)
) {
await store.logEntry(
taskId,
`Auto-merge retry cooldown elapsed (${Math.round(ProjectEngine.AUTO_MERGE_COOLDOWN_MS / 60000)}m idle); resetting retries for another attempt`,
);
await store.updateTask(taskId, { mergeRetries: 0 });
}
const mergeStrategy = this.options.getMergeStrategy?.(settings) ?? "direct";
if (mergeStrategy === "pull-request" && this.options.processPullRequestMerge) {
runtimeLog.log(`Processing PR flow for ${taskId}...`);
runtimeLog.log(`Auto-merge processing PR flow for ${taskId}...`);
const result = await this.options.processPullRequestMerge(store, cwd, taskId);
runtimeLog.log(`PR merge result for ${taskId}: ${result}`);
if (result === "merged") {
runtimeLog.log(`Auto-merge PR merged: ${taskId}`);
} else if (result === "waiting") {
runtimeLog.log(`Auto-merge PR waiting: ${taskId}`);
}
} else {
// Direct merge via AI agent, gated by semaphore
runtimeLog.log(`Merging ${taskId}...`);
runtimeLog.log(`Auto-merge merging ${taskId}...`);
const semaphore = (this.runtime as any).globalSemaphore;
const pool = (this.runtime as any).worktreePool;
const agentStore = (this.runtime as any).agentStore;
@@ -296,66 +459,110 @@ export class ProjectEngine {
}
this.activeMergeSession = null;
runtimeLog.log(`Merged ${taskId}`);
runtimeLog.log(`Auto-merge merged: ${taskId}`);
// Reset retries on success
if (task.mergeRetries && task.mergeRetries > 0) {
const latestTask = await store.getTask(taskId).catch(() => null);
if (latestTask?.mergeRetries && latestTask.mergeRetries > 0) {
await store.updateTask(taskId, { mergeRetries: 0 });
}
}
} catch (err: any) {
this.activeMergeSession = null;
const errorMsg = err?.message ?? String(err);
runtimeLog.error(`Merge failed for ${taskId}: ${errorMsg}`);
runtimeLog.error(`Auto-merge failed for ${taskId}: ${errorMsg}`);
// Conflict retry with exponential backoff
const isConflictError =
errorMsg.includes("conflict") || errorMsg.includes("Conflict");
const settingsOnErr = await store
.getSettings()
.catch(() => ({ autoResolveConflicts: true }));
const taskOnErr = await store.getTask(taskId).catch(() => null);
const mergeStrategyOnErr =
this.options.getMergeStrategy?.(settingsOnErr as Settings) ?? "direct";
if (isConflictError) {
// Deterministic verification failure: move back to in-progress
const isVerificationError =
err?.name === "VerificationError" ||
errorMsg.includes("Deterministic test verification failed") ||
errorMsg.includes("Deterministic build verification failed");
if (taskOnErr && isVerificationError) {
const failedKind = errorMsg.includes("build verification") ? "build" : "test";
try {
const task = await store.getTask(taskId);
const settings = await store.getSettings();
if (
task &&
settings.autoResolveConflicts !== false &&
(task.mergeRetries ?? 0) < ProjectEngine.MAX_AUTO_MERGE_RETRIES
) {
const retryCount = (task.mergeRetries ?? 0) + 1;
await store.updateTask(taskId, {
mergeRetries: retryCount,
status: null,
});
// Exponential backoff: 5s, 10s, 20s
const delayMs = 5000 * Math.pow(2, (task.mergeRetries ?? 0));
runtimeLog.log(
`Merge conflict retry ${retryCount}/${ProjectEngine.MAX_AUTO_MERGE_RETRIES} for ${taskId} in ${delayMs / 1000}s`,
);
setTimeout(() => {
if (!this.shuttingDown) this.enqueueMerge(taskId);
}, delayMs);
}
await store.addTaskComment(
taskId,
`Deterministic ${failedKind} verification failed during merge. ` +
`See the prior [verification] log entry for the truncated command output. ` +
`Please fix the failing ${failedKind} and push the update so the merge can retry.`,
"agent",
);
await store.updateTask(taskId, { status: null, mergeRetries: 0, error: null });
await store.moveTask(taskId, "in-progress");
await store.logEntry(
taskId,
`Deterministic ${failedKind} verification failed — moved back to in-progress for remediation`,
);
runtimeLog.log(
`Auto-merge: ${taskId} deterministic ${failedKind} verification failed — moved to in-progress`,
);
} catch {
// best-effort retry
runtimeLog.error(
`Auto-merge: failed to return ${taskId} to in-progress after verification failure`,
);
}
continue;
}
// Verification failure — move back to in-progress
const isVerificationError =
errorMsg.includes("Verification failed") ||
errorMsg.includes("verification failed");
if (mergeStrategyOnErr === "direct") {
const isConflictError =
errorMsg.includes("conflict") || errorMsg.includes("Conflict");
if (isVerificationError && !isConflictError) {
try {
const task = await store.getTask(taskId);
if (task?.column === "in-review") {
await store.moveTask(taskId, "in-progress");
runtimeLog.log(`Verification failure — ${taskId} moved back to in-progress`);
if (taskOnErr && isConflictError) {
const currentRetries = taskOnErr.mergeRetries ?? 0;
if (
(settingsOnErr as Settings).autoResolveConflicts !== false &&
currentRetries < ProjectEngine.MAX_AUTO_MERGE_RETRIES
) {
const newRetryCount = currentRetries + 1;
await store.updateTask(taskId, { mergeRetries: newRetryCount, status: null });
// Exponential backoff: 5s, 10s, 20s
const delayMs = 5000 * Math.pow(2, currentRetries);
runtimeLog.log(
`Auto-merge conflict retry ${newRetryCount}/${ProjectEngine.MAX_AUTO_MERGE_RETRIES} for ${taskId} in ${delayMs / 1000}s`,
);
setTimeout(() => {
if (!this.shuttingDown) this.internalEnqueueMerge(taskId);
}, delayMs);
} else {
// Max retries exceeded or auto-resolve disabled
try {
await store.updateTask(taskId, { status: null });
} catch {
/* best-effort */
}
}
} else {
// Non-conflict error — stop retrying until user intervenes
try {
await store.updateTask(taskId, {
status: null,
mergeRetries: ProjectEngine.MAX_AUTO_MERGE_RETRIES,
error: errorMsg,
});
} catch {
/* best-effort */
}
}
} else {
try {
await store.updateTask(taskId, {
status: null,
mergeRetries: ProjectEngine.MAX_AUTO_MERGE_RETRIES,
error: errorMsg,
});
} catch {
// best-effort
/* best-effort */
}
}
} finally {
@@ -375,7 +582,7 @@ export class ProjectEngine {
const settings = await store.getSettings();
if (settings.globalPause || settings.enginePaused) return;
if (!settings.autoMerge) return;
this.enqueueMerge(task.id);
this.internalEnqueueMerge(task.id);
} catch {
// ignore settings read errors
}
@@ -389,11 +596,11 @@ export class ProjectEngine {
if (!settings.autoMerge) return;
const tasks = await store.listTasks({ column: "in-review" });
const eligible = tasks.filter((t) => this.canMergeTask(t));
const eligible = tasks.filter((t) => this.canMergeTask(t as any));
if (eligible.length > 0) {
runtimeLog.log(`Auto-merge startup sweep: enqueueing ${eligible.length} task(s)`);
for (const t of eligible) {
this.enqueueMerge(t.id);
this.internalEnqueueMerge(t.id);
}
}
} catch {
@@ -412,8 +619,8 @@ export class ProjectEngine {
if (!settings.globalPause && !settings.enginePaused && settings.autoMerge) {
const tasks = await store.listTasks({ column: "in-review" });
for (const t of tasks) {
if (this.canMergeTask(t)) {
this.enqueueMerge(t.id);
if (this.canMergeTask(t as any)) {
this.internalEnqueueMerge(t.id);
}
}
}
@@ -451,7 +658,13 @@ export class ProjectEngine {
this.settingsHandlers.push(onGlobalPause);
// 2. Global unpause — resume orphaned tasks + sweep in-review
const onGlobalUnpause = async ({ settings: s, previous: prev }: { settings: Settings; previous: Settings }) => {
const onGlobalUnpause = async ({
settings: s,
previous: prev,
}: {
settings: Settings;
previous: Settings;
}) => {
if (prev.globalPause && !s.globalPause) {
runtimeLog.log("Global unpause — resuming agentic activity");
@@ -460,17 +673,21 @@ export class ProjectEngine {
executor?.resumeOrphaned?.().catch((err: Error) =>
runtimeLog.error("Failed to resume orphaned tasks on unpause:", err),
);
} catch { /* ignore */ }
} catch {
/* ignore */
}
if (s.autoMerge) {
try {
const tasks = await store.listTasks({ column: "in-review" });
for (const t of tasks) {
if (this.canMergeTask(t)) {
this.enqueueMerge(t.id);
if (this.canMergeTask(t as any)) {
this.internalEnqueueMerge(t.id);
}
}
} catch { /* ignore */ }
} catch {
/* ignore */
}
}
}
};
@@ -478,7 +695,13 @@ export class ProjectEngine {
this.settingsHandlers.push(onGlobalUnpause);
// 3. Engine unpause — same as global unpause
const onEngineUnpause = async ({ settings: s, previous: prev }: { settings: Settings; previous: Settings }) => {
const onEngineUnpause = async ({
settings: s,
previous: prev,
}: {
settings: Settings;
previous: Settings;
}) => {
if (prev.enginePaused && !s.enginePaused) {
runtimeLog.log("Engine unpaused — resuming agentic activity");
@@ -487,17 +710,21 @@ export class ProjectEngine {
executor?.resumeOrphaned?.().catch((err: Error) =>
runtimeLog.error("Failed to resume orphaned tasks on engine unpause:", err),
);
} catch { /* ignore */ }
} catch {
/* ignore */
}
if (s.autoMerge) {
try {
const tasks = await store.listTasks({ column: "in-review" });
for (const t of tasks) {
if (this.canMergeTask(t)) {
this.enqueueMerge(t.id);
if (this.canMergeTask(t as any)) {
this.internalEnqueueMerge(t.id);
}
}
} catch { /* ignore */ }
} catch {
/* ignore */
}
}
}
};
@@ -505,7 +732,13 @@ export class ProjectEngine {
this.settingsHandlers.push(onEngineUnpause);
// 4. Stuck task timeout change — trigger immediate check
const onStuckTimeoutChange = async ({ settings: s, previous: prev }: { settings: Settings; previous: Settings }) => {
const onStuckTimeoutChange = async ({
settings: s,
previous: prev,
}: {
settings: Settings;
previous: Settings;
}) => {
if (s.taskStuckTimeoutMs !== prev.taskStuckTimeoutMs) {
runtimeLog.log(
`Stuck task timeout changed to ${s.taskStuckTimeoutMs}ms — running immediate check`,
@@ -513,23 +746,29 @@ export class ProjectEngine {
try {
const detector = (this.runtime as any).stuckTaskDetector;
await detector?.checkNow?.();
} catch { /* ignore */ }
} catch {
/* ignore */
}
}
};
store.on("settings:updated", onStuckTimeoutChange);
this.settingsHandlers.push(onStuckTimeoutChange);
// 5. Insight extraction settings change — sync automation
const onInsightSettingsChange = async ({ settings: s, previous: prev }: { settings: Settings; previous: Settings }) => {
const onInsightSettingsChange = async ({
settings: s,
previous: prev,
}: {
settings: Settings;
previous: Settings;
}) => {
const insightKeys = [
"insightExtractionEnabled",
"insightExtractionSchedule",
"insightExtractionMinIntervalMs",
] as const;
const changed = insightKeys.some(
(key) => (s as any)[key] !== (prev as any)[key],
);
const changed = insightKeys.some((key) => (s as any)[key] !== (prev as any)[key]);
if (!changed || !this.automationStore) return;
try {
@@ -548,4 +787,81 @@ export class ProjectEngine {
store.on("settings:updated", onInsightSettingsChange);
this.settingsHandlers.push(onInsightSettingsChange);
}
/**
* Build the onScheduleRunProcessed callback for CronRunner.
* Chains the built-in processAndAuditInsightExtraction with any
* caller-provided onInsightRunProcessed callback.
*/
private buildInsightRunHandler(
cwd: string,
): (schedule: ScheduledTask, result: AutomationRunResult) => Promise<void> {
const callerCallback = this.options.onInsightRunProcessed;
return async (schedule: ScheduledTask, result: AutomationRunResult): Promise<void> => {
// Invoke caller-provided callback first (e.g. for test hooks)
if (callerCallback) {
try {
await callerCallback(schedule, result);
} catch (err) {
runtimeLog.warn(
"onInsightRunProcessed callback error:",
err instanceof Error ? err.message : err,
);
}
}
// Run built-in processAndAuditInsightExtraction
try {
const { INSIGHT_EXTRACTION_SCHEDULE_NAME, processAndAuditInsightExtraction } =
await import("@fusion/core");
if (
typeof INSIGHT_EXTRACTION_SCHEDULE_NAME !== "string" ||
typeof processAndAuditInsightExtraction !== "function"
) {
return;
}
if (schedule.name !== INSIGHT_EXTRACTION_SCHEDULE_NAME) {
return;
}
const stepResults = result.stepResults ?? [];
const aiStep = stepResults.find(
(sr) =>
sr.stepName === "Extract Memory Insights and Prune" ||
sr.stepName === "Extract Memory Insights",
);
if (!aiStep) {
runtimeLog.log(`No insight extraction step found in ${schedule.name} result`);
return;
}
runtimeLog.log("Processing memory insight extraction run...");
const auditReport = await processAndAuditInsightExtraction(cwd, {
rawResponse: aiStep.output ?? "",
stepSuccess: aiStep.success,
runAt: result.startedAt,
error: aiStep.error,
});
const pruneStatus = auditReport.pruning.applied
? ` | Pruned: ${auditReport.pruning.originalSize} -> ${auditReport.pruning.newSize} chars`
: ` | Pruning: ${auditReport.pruning.reason}`;
runtimeLog.log(
`Memory audit complete — Health: ${auditReport.health}, ` +
`Insights: ${auditReport.insightsMemory.insightCount}${pruneStatus}`,
);
} catch (err) {
runtimeLog.warn(
"Failed to process insight extraction:",
err instanceof Error ? err.message : err,
);
}
};
}
}

View File

@@ -95,6 +95,8 @@ export class ProjectManager extends EventEmitter<ProjectManagerEvents> {
private runtimes = new Map<string, ProjectRuntime>();
private projectNames = new Map<string, string>();
private globalSemaphore: AgentSemaphore;
/** Mutable limit read by the shared semaphore's getter function. */
private currentGlobalLimit = 4;
/**
* @param centralCore - CentralCore reference for global coordination
@@ -103,11 +105,10 @@ export class ProjectManager extends EventEmitter<ProjectManagerEvents> {
super();
this.setMaxListeners(100);
// Initialize global semaphore with limit from CentralCore
this.globalSemaphore = new AgentSemaphore(() => {
// This will be updated dynamically from CentralCore
return 4; // Default, will refresh
});
// Initialize global semaphore with a getter that reads the mutable limit.
// This single semaphore instance is shared across all runtimes so
// cross-project concurrency is enforced correctly.
this.globalSemaphore = new AgentSemaphore(() => this.currentGlobalLimit);
// Refresh the global limit periodically
this.refreshGlobalLimit();
@@ -122,9 +123,7 @@ export class ProjectManager extends EventEmitter<ProjectManagerEvents> {
private async refreshGlobalLimit(): Promise<void> {
try {
const state = await this.centralCore.getGlobalConcurrencyState();
// Update semaphore limit dynamically
// Note: AgentSemaphore reads limit via getter, so we update the source
this.globalSemaphore = new AgentSemaphore(() => state.globalMaxConcurrent);
this.currentGlobalLimit = state.globalMaxConcurrent;
} catch (error) {
projectManagerLog.warn("Failed to refresh global concurrency limit:", error);
}
@@ -161,8 +160,11 @@ export class ProjectManager extends EventEmitter<ProjectManagerEvents> {
// Create appropriate runtime based on isolation mode
let runtime: ProjectRuntime;
// Inject the shared global semaphore so all runtimes share one concurrency pool.
const configWithSemaphore = { ...config, globalSemaphore: this.globalSemaphore };
if (config.isolationMode === "child-process") {
runtime = new ChildProcessRuntime(config, this.centralCore);
runtime = new ChildProcessRuntime(configWithSemaphore, this.centralCore);
} else {
let assignedNode = undefined;
@@ -184,7 +186,7 @@ export class ProjectManager extends EventEmitter<ProjectManagerEvents> {
});
} else {
// Default to local in-process runtime (includes unassigned + local-node assigned)
runtime = new InProcessRuntime(config, this.centralCore);
runtime = new InProcessRuntime(configWithSemaphore, this.centralCore);
}
}

View File

@@ -45,6 +45,16 @@ export interface ProjectRuntimeConfig {
maxWorktrees: number;
/** Optional project settings override */
settings?: ProjectSettings;
/** Shared global semaphore from ProjectManager. When provided, the runtime
* uses this semaphore for concurrency control instead of creating its own.
* This ensures cross-project concurrency limits are enforced. */
globalSemaphore?: import("./concurrency.js").AgentSemaphore;
/**
* An already-initialized TaskStore to use instead of creating a new one.
* When provided, the runtime will skip TaskStore construction and init().
* Useful when the caller (e.g. dashboard.ts) owns and watches the store.
*/
externalTaskStore?: TaskStore;
}
/**

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.
*