import { execSync } from "node:child_process"; import type { AddressInfo } from "node:net"; import { createInterface } from "node:readline"; import { TaskStore, AutomationStore, CentralCore, AgentStore, getTaskMergeBlocker } from "@fusion/core"; import type { Settings, TaskDetail, PrInfo } from "@fusion/core"; import { createServer, GitHubClient } from "@fusion/dashboard"; import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask, UsageLimitPauser, PRIORITY_MERGE, scanIdleWorktrees, cleanupOrphanedWorktrees, NtfyNotifier, PrMonitor, PrCommentHandler, CronRunner, StuckTaskDetector, SelfHealingManager, MissionAutopilot, createAiPromptExecutor } from "@fusion/engine"; import { AuthStorage, DefaultPackageManager, ModelRegistry, SettingsManager, discoverAndLoadExtensions, getAgentDir, createExtensionRuntime } from "@mariozechner/pi-coding-agent"; /** * Prompt the user for a port number interactively. * Shows "Port [4040]: " and accepts user input or Enter for default. * Validates input is a valid port number (1-65535). * Re-prompts on invalid input. * Handles SIGINT (Ctrl+C) gracefully. */ export function promptForPort(defaultPort: number = 4040, input: NodeJS.ReadableStream = process.stdin): Promise { return new Promise((resolve, reject) => { const rl = createInterface({ input, output: process.stdout, }); // Handle Ctrl+C during prompt const sigintHandler = () => { rl.close(); console.log("\n"); reject(new Error("Interactive prompt cancelled")); }; process.on("SIGINT", sigintHandler); const ask = () => { rl.question(`Port [${defaultPort}]: `, (answer) => { const trimmed = answer.trim(); // Empty input: use default if (trimmed === "") { process.removeListener("SIGINT", sigintHandler); rl.close(); resolve(defaultPort); return; } // Validate as number const port = parseInt(trimmed, 10); if (isNaN(port)) { console.log(`Invalid input: "${trimmed}" is not a number`); ask(); return; } // Validate port range if (port < 1 || port > 65535) { console.log(`Invalid port: ${port} (must be between 1 and 65535)`); ask(); return; } process.removeListener("SIGINT", sigintHandler); rl.close(); resolve(port); }); }; ask(); }); } export function getMergeStrategy(settings: Pick): NonNullable { return settings.mergeStrategy ?? "direct"; } export function getTaskBranchName(taskId: string): string { return `fusion/${taskId.toLowerCase()}`; } function buildPullRequestTitle(task: Pick): string { return task.title ? `${task.id}: ${task.title}` : task.id; } function buildPullRequestBody(task: Pick): string { return [`Automated PR for ${task.id}.`, "", task.description].join("\n"); } function cleanupMergedTaskArtifacts(cwd: string, task: Pick): void { const branch = getTaskBranchName(task.id); if (task.worktree) { try { execSync(`git worktree remove \"${task.worktree}\" --force`, { cwd, stdio: "pipe", }); } catch { // Best-effort cleanup — worktree may already be gone. } } try { execSync(`git branch -d \"${branch}\"`, { cwd, stdio: "pipe", }); } catch { try { execSync(`git branch -D \"${branch}\"`, { cwd, stdio: "pipe", }); } catch { // Best-effort cleanup — branch may already be gone. } } } export async function processPullRequestMergeTask( store: TaskStore, cwd: string, taskId: string, github: Pick, ): Promise<"waiting" | "merged" | "skipped"> { const task = await store.getTask(taskId); if (getTaskMergeBlocker(task)) { return "skipped"; } const branch = getTaskBranchName(task.id); let prInfo: PrInfo | undefined = task.prInfo; if (!prInfo) { await store.updateTask(task.id, { status: "creating-pr" }); const existingPr = await github.findPrForBranch({ head: branch, state: "all" }); prInfo = existingPr ?? await github.createPr({ title: buildPullRequestTitle(task), body: buildPullRequestBody(task), head: branch, }); await store.updatePrInfo(task.id, prInfo); await store.logEntry( task.id, existingPr ? "Linked existing PR" : "Created PR", `PR #${prInfo.number}: ${prInfo.url}`, ); } if (!prInfo) { throw new Error(`Failed to create or resolve pull request for ${task.id}`); } const mergeStatus = await github.getPrMergeStatus(undefined, undefined, prInfo.number); const refreshedPrInfo: PrInfo = { ...prInfo, ...mergeStatus.prInfo, lastCheckedAt: new Date().toISOString(), }; await store.updatePrInfo(task.id, refreshedPrInfo); if (mergeStatus.prInfo.status === "merged") { cleanupMergedTaskArtifacts(cwd, task); await store.moveTask(task.id, "done"); await store.updateTask(task.id, { status: null, mergeRetries: 0 }); await store.logEntry(task.id, "Pull request merged", `PR #${prInfo.number}: ${prInfo.url}`); return "merged"; } if (!mergeStatus.mergeReady) { if (mergeStatus.prInfo.status === "open") { await store.updateTask(task.id, { status: "awaiting-pr-checks" }); } else { await store.updateTask(task.id, { status: null }); } return "waiting"; } await store.updateTask(task.id, { status: "merging-pr" }); const mergedPr = await github.mergePr({ number: prInfo.number, method: "squash" }); await store.updatePrInfo(task.id, { ...mergedPr, lastCheckedAt: new Date().toISOString() }); cleanupMergedTaskArtifacts(cwd, task); await store.moveTask(task.id, "done"); await store.updateTask(task.id, { status: null, mergeRetries: 0 }); await store.logEntry(task.id, "Pull request merged", `PR #${mergedPr.number}: ${mergedPr.url}`); return "merged"; } export async function runDashboard(port: number, opts: { paused?: boolean; dev?: boolean; interactive?: boolean; open?: boolean } = {}) { // Handle interactive port selection let selectedPort = port; if (opts.interactive) { try { selectedPort = await promptForPort(port); } catch (err: any) { if (err.message === "Interactive prompt cancelled") { console.log("Cancelled — exiting"); process.exit(0); } throw err; } } const cwd = process.cwd(); const store = new TaskStore(cwd); await store.init(); await store.watch(); const handlers: Array<{ target: NodeJS.EventEmitter; event: string | symbol; handler: (...args: any[]) => void; }> = []; let disposed = false; let mergeRetryTimer: ReturnType | null = null; function registerHandler( target: NodeJS.EventEmitter, event: string | symbol, handler: (...args: any[]) => void, ): void { target.on(event, handler); handlers.push({ target, event, handler }); } // ── AutomationStore: scheduled task persistence ────────────────────── const automationStore = new AutomationStore(cwd); await automationStore.init(); // ── AgentStore: agent lifecycle tracking ────────────────────────── // // Tracks spawned agents so they appear in the dashboard's Agents view // and are properly managed throughout their lifecycle (creation, state // transitions, termination). Passed to TaskExecutor for agent spawning. // const agentStore = new AgentStore({ rootDir: store.getFusionDir() }); await agentStore.init(); // ── NtfyNotifier: push notifications for task completion and failures ─ // // Resolve the project ID from the central registry so that notification // deep links include ?project=...&task=... for multi-project dashboards. // Falls back to no project ID (task-only links) when the central DB is // unavailable or the project is not registered (single-project / legacy). // let ntfyProjectId: string | undefined; try { const central = new CentralCore(); await central.init(); const registered = await central.getProjectByPath(cwd); await central.close(); if (registered) { ntfyProjectId = registered.id; } } catch { // Central DB unavailable or project not registered — backward compatible } const notifier = new NtfyNotifier(store, { projectId: ntfyProjectId }); notifier.start(); // Set enginePaused if starting in paused mode if (opts.paused) { await store.updateSettings({ enginePaused: true }); console.log("[engine] Starting in paused mode — automation disabled"); } // ── Shared concurrency semaphore ────────────────────────────────── // // Gates all agentic activities (triage, execution, merge) behind a // single slot limit so they collectively respect settings.maxConcurrent. // Created eagerly so the merge queue can reference it; the engine block // below passes it to triage/executor/scheduler as well. // // The limit is read from a cached value that is refreshed from the store // on each scheduler poll cycle (see engine block below). This avoids // async I/O in the synchronous getter while still picking up live changes. // const initialSettings = await store.getSettings(); let cachedMaxConcurrent = initialSettings.maxConcurrent; const semaphore = new AgentSemaphore(() => cachedMaxConcurrent); // ── Shared worktree pool ────────────────────────────────────────── // // Enables worktree recycling across tasks when `recycleWorktrees` is // enabled in settings. Completed task worktrees are returned to the // pool instead of being deleted; new tasks acquire a warm worktree // preserving build caches (node_modules, dist/, etc.). // // Created unconditionally — the `recycleWorktrees` gating logic lives // inside TaskExecutor and aiMergeTask (see HAI-037). When the setting // is off the pool simply stays empty. // const pool = new WorktreePool(); // ── Startup: rehydrate or clean up worktrees from previous runs ──── // // When `recycleWorktrees` is true, scan the .worktrees/ directory for // idle worktrees (not assigned to any active task) and load them into // the pool so new tasks can reuse them instead of creating fresh ones. // // When `recycleWorktrees` is false, clean up orphaned worktrees left // behind by previous engine runs to avoid disk waste. // if (initialSettings.recycleWorktrees) { const idlePaths = await scanIdleWorktrees(cwd, store); if (idlePaths.length > 0) { pool.rehydrate(idlePaths); console.log(`[engine] Rehydrated pool with ${idlePaths.length} idle worktree(s)`); } } else { const cleaned = await cleanupOrphanedWorktrees(cwd, store); if (cleaned > 0) { console.log(`[engine] Cleaned up ${cleaned} orphaned worktree(s)`); } } // ── Usage limit pauser ────────────────────────────────────────────── // // Shared pauser that triggers globalPause when any agent hits an API // usage limit (rate limits, overloaded, quota exceeded). A single // instance is shared across triage, executor, and merger so that the // pause is deduplicated across concurrent agents. // const usageLimitPauser = new UsageLimitPauser(store); const githubClient = new GitHubClient(process.env.GITHUB_TOKEN); // AI-powered merge handler (used by the web UI for manual merges). // Wrapped with the shared semaphore so merges count toward the global // concurrency limit alongside triage and execution agents. // // Track the active merge session so it can be killed on global pause. let activeMergeSession: { dispose: () => void } | null = null; const rawMerge = (taskId: string) => aiMergeTask(store, cwd, taskId, { pool, usageLimitPauser, agentStore, onAgentText: (delta) => process.stdout.write(delta), onSession: (session) => { activeMergeSession = session; }, }); const onMerge = (taskId: string) => semaphore.run(() => rawMerge(taskId), PRIORITY_MERGE); // When globalPause transitions from false → true, terminate the active merge session. registerHandler(store, "settings:updated", ({ settings, previous }) => { if (settings.globalPause && !previous.globalPause) { if (activeMergeSession) { console.log("[auto-merge] Global pause — terminating active merge session"); activeMergeSession.dispose(); activeMergeSession = null; } } }); // ── Serialized auto-merge queue ───────────────────────────────────── // // Three paths feed into this queue: // 1. Event-driven: `task:moved` → "in-review" (immediate reaction) // 2. Startup sweep: tasks already in "in-review" when the engine starts // 3. Periodic retry: a setInterval catches tasks stuck in "in-review" // after a previous merge attempt failed // // The queue ensures only one `aiMergeTask` runs at a time, preventing // concurrent git merge operations in rootDir. Task IDs in the queue or // actively being processed are tracked in `mergeActive` so the periodic // sweep doesn't re-enqueue them. // const mergeQueue: string[] = []; const mergeActive = new Set(); // IDs queued or currently merging let mergeRunning = false; const maxAutoMergeRetries = 3; function canAutoMergeTask(task: { mergeRetries?: number | null; column: string; paused?: boolean; status?: string | null; error?: string | null; steps?: Array<{ status: string }>; workflowStepResults?: Array<{ status: string }> }): boolean { if (getTaskMergeBlocker(task as any)) return false; return (task.mergeRetries ?? 0) < maxAutoMergeRetries; } /** Enqueue a task for auto-merge if not already queued/active. */ function enqueueMerge(taskId: string): void { if (mergeActive.has(taskId)) return; mergeActive.add(taskId); mergeQueue.push(taskId); drainMergeQueue(); } /** Process the merge queue sequentially. */ async function drainMergeQueue(): Promise { if (mergeRunning) return; mergeRunning = true; try { while (mergeQueue.length > 0) { const taskId = mergeQueue.shift()!; try { // Re-check autoMerge and globalPause before each merge (setting may have been toggled) const settings = await store.getSettings(); if (settings.globalPause || settings.enginePaused) { console.log(`[auto-merge] Skipping ${taskId} — ${settings.globalPause ? "global pause" : "engine paused"} active`); continue; } if (!settings.autoMerge) { console.log(`[auto-merge] Skipping ${taskId} — autoMerge disabled`); continue; } // Verify the task is still in-review and not paused const task = await store.getTask(taskId); if (!canAutoMergeTask(task as any)) { continue; } const mergeStrategy = getMergeStrategy(settings); if (mergeStrategy === "pull-request") { console.log(`[auto-merge] Processing PR flow for ${taskId}...`); const result = await processPullRequestMergeTask(store, cwd, taskId, githubClient); if (result === "merged") { console.log(`[auto-merge] ✓ ${taskId} merged via pull request`); } else if (result === "waiting") { console.log(`[auto-merge] … ${taskId} waiting on PR checks or reviews`); } } else { console.log(`[auto-merge] Merging ${taskId}...`); await onMerge(taskId); console.log(`[auto-merge] ✓ ${taskId} merged`); // Clear mergeRetries on success if (task.mergeRetries && task.mergeRetries > 0) { await store.updateTask(taskId, { mergeRetries: 0 }); } } } catch (err: any) { const errorMsg = err.message ?? String(err); console.log(`[auto-merge] ✗ ${taskId}: ${errorMsg}`); const settings = await store.getSettings().catch(() => ({ autoResolveConflicts: true, mergeStrategy: "direct" as const })); const task = await store.getTask(taskId).catch(() => null); const mergeStrategy = getMergeStrategy(settings); if (mergeStrategy === "direct") { // Check if this is a conflict error and if we should retry const isConflictError = errorMsg.includes("conflict") || errorMsg.includes("Conflict"); if (task && isConflictError) { const currentRetries = task.mergeRetries ?? 0; const maxRetries = maxAutoMergeRetries; if (settings.autoResolveConflicts !== false && currentRetries < maxRetries) { // Increment retry counter and re-enqueue with delay const newRetryCount = currentRetries + 1; await store.updateTask(taskId, { mergeRetries: newRetryCount, status: null }); // Calculate exponential backoff delay: 5s, 10s, 20s const delayMs = 5000 * Math.pow(2, currentRetries); console.log(`[auto-merge] ↻ ${taskId}: retry ${newRetryCount}/${maxRetries} in ${delayMs / 1000}s`); setTimeout(() => { enqueueMerge(taskId); }, delayMs); } else { // Max retries exceeded or auto-resolve disabled - keep in in-review if (currentRetries >= maxRetries) { console.log(`[auto-merge] ⊘ ${taskId}: max retries (${maxRetries}) exceeded — manual resolution required`); } else { console.log(`[auto-merge] ⊘ ${taskId}: autoResolveConflicts disabled — manual resolution required`); } // Reset task status so it doesn't appear stuck as "merging" in the UI try { await store.updateTask(taskId, { status: null }); } catch { /* best-effort */ } } } else { // Non-conflict error - stop auto-retrying until a user intervenes. // This prevents the periodic sweep from re-enqueueing the same // broken merge on every poll cycle. try { await store.updateTask(taskId, { status: null, mergeRetries: maxAutoMergeRetries, error: errorMsg, }); } catch { /* best-effort */ } } } else { try { await store.updateTask(taskId, { status: null, mergeRetries: maxAutoMergeRetries, error: errorMsg, }); } catch { /* best-effort */ } } } finally { mergeActive.delete(taskId); } } } finally { mergeRunning = false; } } // Auto-merge: when a task lands in "in-review" and autoMerge is enabled, // enqueue it for serialized merge processing. registerHandler(store, "task:moved", async ({ task, to }) => { if (to !== "in-review") return; if (!canAutoMergeTask(task as any)) return; try { const settings = await store.getSettings(); if (settings.globalPause || settings.enginePaused) return; if (!settings.autoMerge) return; enqueueMerge(task.id); } catch { /* ignore settings read errors */ } }); // ── Auth & model wiring ──────────────────────────────────────────── // AuthStorage manages OAuth/API-key credentials (stored in ~/.pi/agent/auth.json). // ModelRegistry discovers available models from configured providers. // Passing these to createServer enables the dashboard's Authentication // tab (login/logout) and Model selector. const authStorage = AuthStorage.create(); const modelRegistry = new ModelRegistry(authStorage); try { // Resolve extension paths from pi settings packages (npm, git, local). // This picks up extensions like @howaboua/pi-glm-via-anthropic that // register custom providers (e.g. glm-5.1) via registerProvider(). const agentDir = getAgentDir(); const piSettingsManager = SettingsManager.create(cwd, agentDir); const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager: piSettingsManager, }); const resolvedPaths = await packageManager.resolve(); const packageExtensionPaths = resolvedPaths.extensions .filter((r) => r.enabled) .map((r) => r.path); // Load all extensions: filesystem-discovered + package-resolved const extensionsResult = await discoverAndLoadExtensions(packageExtensionPaths, cwd, undefined); for (const { path, error } of extensionsResult.errors) { console.log(`[extensions] Failed to load ${path}: ${error}`); } for (const { name, config, extensionPath } of extensionsResult.runtime.pendingProviderRegistrations) { try { modelRegistry.registerProvider(name, config); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.log(`[extensions] Failed to register provider from ${extensionPath}: ${message}`); } } extensionsResult.runtime.pendingProviderRegistrations = []; modelRegistry.refresh(); // Eagerly sync OpenRouter models — the pi-openrouter-realtime extension // only registers providers on session_start (TUI-only event), so kick off // a fetch here so the dashboard model list is populated. Respects the // openrouterModelSync setting (defaults to true). (async () => { try { const settings = await store.getSettings(); if (settings.openrouterModelSync === false) return; const hasOrAuth = await authStorage.getApiKey("openrouter"); const headers: Record = {}; if (hasOrAuth) headers["Authorization"] = `Bearer ${hasOrAuth}`; const res = await fetch("https://openrouter.ai/api/v1/models", { headers }); if (!res.ok) return; const json = await res.json() as { data?: Array<{ id: string; name: string; context_length?: number; top_provider?: { max_completion_tokens?: number }; pricing?: Record; architecture?: { modality?: string; input_modalities?: string[] } }> }; const orModels = (json.data || []).map((m: any) => { const id = (m.id || "").toLowerCase(); const name = (m.name || "").toLowerCase(); const reasoning = id.includes(":thinking") || id.includes("-r1") || id.includes("/r1") || id.includes("o1-") || id.includes("o3-") || id.includes("o4-") || id.includes("reasoner") || name.includes("thinking") || name.includes("reasoner"); const hasVision = m.architecture?.input_modalities?.includes("image") ?? m.architecture?.modality?.includes("multimodal") ?? false; function parseCost(v?: string) { const n = parseFloat(v || "0"); return isNaN(n) ? 0 : n * 1_000_000; } return { id: m.id, name: m.name || m.id, reasoning, input: (hasVision ? ["text", "image"] : ["text"]) as ("text" | "image")[], cost: { input: parseCost(m.pricing?.prompt), output: parseCost(m.pricing?.completion), cacheRead: parseCost(m.pricing?.input_cache_read), cacheWrite: parseCost(m.pricing?.input_cache_write) }, contextWindow: m.context_length || 128000, maxTokens: m.top_provider?.max_completion_tokens || 16384, }; }); modelRegistry.registerProvider("openrouter", { baseUrl: "https://openrouter.ai/api/v1", apiKey: "OPENROUTER_API_KEY", api: "openai-completions", models: orModels, }); console.log(`[openrouter] Synced ${orModels.length} models from OpenRouter API`); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.log(`[openrouter] Failed to sync models: ${message}`); } })(); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.log(`[extensions] Failed to discover extensions: ${message}`); createExtensionRuntime(); modelRegistry.refresh(); } // ── MissionAutopilot: autonomous mission progression ───────────── // // Created before createServer so it can be passed to both the server // and the Scheduler. The scheduler reference is set after Scheduler // construction via setScheduler() to break the circular dependency. // In dev mode the autopilot is created but never started. // const missionAutopilot = new MissionAutopilot(store, store.getMissionStore()); // Start the web server with AI merge, auth, and model registry wired in const app = createServer(store, { onMerge, authStorage, modelRegistry, automationStore, missionAutopilot }); function dispose(): void { if (disposed) return; disposed = true; for (const { target, event, handler } of handlers) { target.off(event, handler); } handlers.length = 0; if (mergeRetryTimer) { clearTimeout(mergeRetryTimer); mergeRetryTimer = null; } } // Start the AI engine (unless in dev mode) if (!opts.dev) { // ── Self-healing: auto-unpause, stuck kill budgets, maintenance ───── const selfHealing = new SelfHealingManager(store, { rootDir: cwd, recoverCompletedTask: (task) => executorRef.current?.recoverCompletedTask(task) ?? Promise.resolve(false), getExecutingTaskIds: () => executorRef.current?.getExecutingTaskIds() ?? new Set(), recoverApprovedTriageTask: (task) => triageRef.current?.recoverApprovedTask(task) ?? Promise.resolve(false), getSpecifyingTaskIds: () => triageRef.current?.getProcessingTaskIds() ?? new Set(), }); // ── Stuck task detector: monitors agent sessions for stagnation ──── // Created before triage/executor so it can be passed in options. // The onStuck callback is wired via late-binding closures on triageRef // and executorRef to avoid circular construction order dependencies. const executorRef: { current: TaskExecutor | null } = { current: null }; const triageRef: { current: TriageProcessor | null } = { current: null }; const stuckTaskDetector = new StuckTaskDetector(store, { beforeRequeue: (taskId) => selfHealing.checkStuckBudget(taskId), onLoopDetected: (event) => executorRef.current?.handleLoopDetected(event) ?? Promise.resolve(false), onStuck: (event) => { // Notify whichever component owns this task (triage or executor). // Both check their own tracking sets so only the owner acts. triageRef.current?.markStuckAborted(event.taskId); executorRef.current?.markStuckAborted(event.taskId, event.shouldRequeue); console.log( `[engine] ⚠ ${event.taskId} stuck (${event.reason}) — ` + `no progress for ${Math.round(event.noProgressMs / 60_000)}min, ` + `${event.activitySinceProgress} events since last progress — ` + `terminated, ${event.shouldRequeue ? "will retry" : "budget exhausted"}`, ); }, }); const triage = new TriageProcessor(store, cwd, { semaphore, usageLimitPauser, stuckTaskDetector, agentStore, onSpecifyStart: (t) => console.log(`[engine] Specifying ${t.id}...`), onSpecifyComplete: (t) => console.log(`[engine] ✓ ${t.id} → todo`), onSpecifyError: (t, e) => console.log(`[engine] ✗ ${t.id}: ${e.message}`), }); triageRef.current = triage; const executor = new TaskExecutor(store, cwd, { semaphore, pool, usageLimitPauser, stuckTaskDetector, agentStore, onStart: (t, p) => console.log(`[engine] Executing ${t.id} in ${p}`), onComplete: (t) => console.log(`[engine] ✓ ${t.id} → in-review`), onError: (t, e) => console.log(`[engine] ✗ ${t.id}: ${e.message}`), }); executorRef.current = executor; const settings = await store.getSettings(); const prMonitor = new PrMonitor(); const prCommentHandler = new PrCommentHandler(store); prMonitor.onNewComments((taskId, prInfo, comments) => prCommentHandler.handleNewComments(taskId, prInfo, comments), ); // ── MissionAutopilot is already created above (before createServer) ── // The scheduler reference is set after construction via setScheduler() // to break the circular dependency. const scheduler = new Scheduler(store, { semaphore, prMonitor, missionStore: store.getMissionStore(), missionAutopilot, onSchedule: (t) => console.log(`[engine] Scheduled ${t.id}`), onBlocked: (t, deps) => console.log(`[engine] ${t.id} blocked by ${deps.join(", ")}`), onClosedPrFeedback: async (taskId, prInfo, comments) => { await prCommentHandler.createFollowUpTask(taskId, prInfo, comments); }, }); // Break circular dependency: Scheduler ↔ MissionAutopilot missionAutopilot.setScheduler(scheduler); // ── CronRunner: scheduled task execution ────────────────────────── const aiPromptExecutor = await createAiPromptExecutor(cwd); const cronRunner = new CronRunner(store, automationStore, { aiPromptExecutor }); cronRunner.start(); triage.start(); scheduler.start(); missionAutopilot.start(); stuckTaskDetector.start(); selfHealing.start(); // ── Startup sweep: resume orphaned in-progress tasks ────────────── executor.resumeOrphaned().catch((err) => console.error("[engine] Failed to resume orphaned tasks:", err), ); // ── Startup sweep: enqueue any tasks already in "in-review" ─────── if (settings.autoMerge) { const existing = await store.listTasks(); const inReview = existing.filter((t) => canAutoMergeTask(t as any)); if (inReview.length > 0) { console.log( `[auto-merge] Startup sweep: enqueueing ${inReview.length} in-review task(s)`, ); for (const t of inReview) { enqueueMerge(t.id); } } } // ── Immediate unpause: resume orphans + merge sweep ───────────── // When globalPause transitions from true → false, immediately: // 1. Refresh cachedMaxConcurrent so the semaphore picks up live changes // 2. Resume orphaned in-progress tasks whose agents were killed by pause // 3. Sweep the merge queue for in-review tasks that need merging registerHandler(store, "settings:updated", async ({ settings: s, previous: prev }) => { if (prev.globalPause && !s.globalPause) { console.log("[engine] Global unpause — resuming agentic activity"); cachedMaxConcurrent = s.maxConcurrent ?? cachedMaxConcurrent; executor.resumeOrphaned().catch((err) => console.error("[engine] Failed to resume orphaned tasks on unpause:", err), ); if (s.autoMerge) { try { const tasks = await store.listTasks(); for (const t of tasks) { if (canAutoMergeTask(t as any)) { enqueueMerge(t.id); } } } catch { /* ignore errors in unpause sweep */ } } } }); // ── Immediate engine-unpause: resume orphans + merge sweep ──────── // When enginePaused transitions from true → false, same resume logic // as globalPause unpause: pick up orphaned tasks and sweep merge queue. registerHandler(store, "settings:updated", async ({ settings: s, previous: prev }) => { if (prev.enginePaused && !s.enginePaused) { console.log("[engine] Engine unpaused — resuming agentic activity"); cachedMaxConcurrent = s.maxConcurrent ?? cachedMaxConcurrent; executor.resumeOrphaned().catch((err) => console.error("[engine] Failed to resume orphaned tasks on engine unpause:", err), ); if (s.autoMerge) { try { const tasks = await store.listTasks(); for (const t of tasks) { if (!getTaskMergeBlocker(t)) { enqueueMerge(t.id); } } } catch { /* ignore errors in unpause sweep */ } } } }); // ── Stuck task timeout change: immediate check ──────────────────── // When taskStuckTimeoutMs is changed (e.g., user reduces timeout), // immediately check for stuck tasks under the new timer value. registerHandler(store, "settings:updated", async ({ settings: s, previous: prev }) => { if (s.taskStuckTimeoutMs !== prev.taskStuckTimeoutMs) { try { console.log(`[stuck-detector] Timeout changed to ${s.taskStuckTimeoutMs}ms — running immediate check`); await stuckTaskDetector.checkNow(); } catch (err) { console.error("[stuck-detector] Error during immediate stuck-task check:", err); } } }); // ── Periodic retry: catch failed merges on each poll cycle ──────── // Uses a setTimeout chain so the interval dynamically follows // settings.pollIntervalMs without requiring an engine restart. async function scheduleMergeRetry(): Promise { if (disposed) return; const currentSettings = await store.getSettings().catch(() => settings); const interval = currentSettings.pollIntervalMs ?? 15_000; mergeRetryTimer = setTimeout(async () => { if (disposed) return; try { const s = await store.getSettings(); // Refresh the cached limit so the semaphore picks up live changes cachedMaxConcurrent = s.maxConcurrent; if (!s.globalPause && !s.enginePaused && s.autoMerge) { const tasks = await store.listTasks(); for (const t of tasks) { if (canAutoMergeTask(t as any)) { enqueueMerge(t.id); } } } } catch { /* ignore errors in periodic sweep */ } if (!disposed) { scheduleMergeRetry(); } }, interval); } // Kick off the first retry after the current poll interval scheduleMergeRetry(); const shutdown = () => { dispose(); selfHealing.stop(); stuckTaskDetector.stop(); missionAutopilot.stop(); triage.stop(); scheduler.stop(); cronRunner.stop(); notifier.stop(); store.close(); process.exit(0); }; registerHandler(process, "SIGINT", shutdown); registerHandler(process, "SIGTERM", shutdown); } // Dev mode: simplified shutdown handlers (no engine components) if (opts.dev) { const devShutdown = () => { dispose(); notifier.stop(); store.close(); process.exit(0); }; registerHandler(process, "SIGINT", devShutdown); registerHandler(process, "SIGTERM", devShutdown); } const server = app.listen(selectedPort); server.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "EADDRINUSE") { server.listen(0); } else { console.error(`Failed to start server: ${err.message}`); process.exit(1); } }); server.on("listening", () => { const actualPort = (server.address() as AddressInfo).port; if (actualPort !== selectedPort) { console.log(`⚠ Port ${selectedPort} in use, using ${actualPort} instead`); } console.log(); console.log(` fn board`); console.log(` ────────────────────────`); console.log(` → http://localhost:${actualPort}`); console.log(); console.log(` Tasks stored in .fusion/tasks/`); console.log(` Merge: AI-assisted (conflict resolution + commit messages)`); if (opts.dev) { console.log(` AI engine: ✗ disabled (dev mode)`); } else { console.log(` AI engine: ✓ active`); console.log(` • triage: auto-specifying tasks`); console.log(` • scheduler: dependency-aware execution`); console.log(` • cron: scheduled task execution`); } console.log(` File watcher: ✓ active`); console.log(` Press Ctrl+C to stop`); console.log(); }); return { dispose }; }