import { exec } from "node:child_process"; import type { AddressInfo } from "node:net"; import { TaskStore } from "@kb/core"; import { createServer } from "@kb/dashboard"; import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask, UsageLimitPauser, PRIORITY_MERGE, scanIdleWorktrees, cleanupOrphanedWorktrees, NtfyNotifier } from "@kb/engine"; import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent"; function openBrowser(url: string): void { const cmd = process.platform === "darwin" ? `open "${url}"` : process.platform === "win32" ? `start "" "${url}"` : `xdg-open "${url}"`; exec(cmd, () => {}); } export async function runDashboard(port: number, opts: { open?: boolean; paused?: boolean; dev?: boolean } = {}) { const cwd = process.cwd(); const store = new TaskStore(cwd); await store.init(); await store.watch(); // ── NtfyNotifier: push notifications for task completion and failures ─ const notifier = new NtfyNotifier(store); 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); // 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, onAgentText: (delta) => process.stdout.write(delta), onAgentTool: (name) => console.log(`[merger] tool: ${name}`), 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. store.on("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; /** 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 (task.column !== "in-review" || task.paused) { continue; } 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}`); // Check if this is a conflict error and if we should retry const isConflictError = errorMsg.includes("conflict") || errorMsg.includes("Conflict"); const task = await store.getTask(taskId).catch(() => null); if (task && isConflictError) { const settings = await store.getSettings().catch(() => ({ autoResolveConflicts: true })); const currentRetries = task.mergeRetries ?? 0; const maxRetries = 3; 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 - reset task status try { await store.updateTask(taskId, { status: null }); } 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. store.on("task:moved", async ({ task, to }) => { if (to !== "in-review") return; if (task.paused) 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); // Start the web server with AI merge, auth, and model registry wired in const app = createServer(store, { onMerge, authStorage, modelRegistry }); // Start the AI engine (unless in dev mode) if (!opts.dev) { const triage = new TriageProcessor(store, cwd, { semaphore, usageLimitPauser, 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}`), }); const executor = new TaskExecutor(store, cwd, { semaphore, pool, usageLimitPauser, 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}`), }); const settings = await store.getSettings(); const scheduler = new Scheduler(store, { semaphore, onSchedule: (t) => console.log(`[engine] Scheduled ${t.id}`), onBlocked: (t, deps) => console.log(`[engine] ${t.id} blocked by ${deps.join(", ")}`), }); triage.start(); scheduler.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) => t.column === "in-review" && !t.paused); 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 store.on("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 (t.column === "in-review" && !t.paused) { 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. store.on("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 (t.column === "in-review" && !t.paused) { enqueueMerge(t.id); } } } catch { /* ignore errors in unpause sweep */ } } } }); // ── 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. let mergeRetryTimer: ReturnType | null = null; async function scheduleMergeRetry(): Promise { const currentSettings = await store.getSettings().catch(() => settings); const interval = currentSettings.pollIntervalMs ?? 15_000; mergeRetryTimer = setTimeout(async () => { 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 (t.column === "in-review" && !t.paused) { enqueueMerge(t.id); } } } } catch { /* ignore errors in periodic sweep */ } scheduleMergeRetry(); }, interval); } // Kick off the first retry after the current poll interval scheduleMergeRetry(); process.on("SIGINT", () => { triage.stop(); scheduler.stop(); notifier.stop(); if (mergeRetryTimer) clearTimeout(mergeRetryTimer); store.stopWatching(); process.exit(0); }); } // Dev mode: simplified SIGINT handler (no engine components) if (opts.dev) { process.on("SIGINT", () => { notifier.stop(); store.stopWatching(); process.exit(0); }); } const server = app.listen(port); 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 !== port) { console.log(`⚠ Port ${port} in use, using ${actualPort} instead`); } console.log(); console.log(` kb board`); console.log(` ────────────────────────`); console.log(` → http://localhost:${actualPort}`); console.log(); console.log(` Tasks stored in .kb/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(` File watcher: ✓ active`); console.log(` Press Ctrl+C to stop`); console.log(); if (opts.open !== false) { openBrowser(`http://localhost:${actualPort}`); } }); }