perf(dashboard): cache gh CLI checks and defer SQLite integrity scan

Cold-start dashboard responsiveness went from ~99s to ~6-11s. CPU profiling
identified two synchronous-spawn hotspots blocking the event loop:

- `GitHubTrackingReconciler` scanned up to 200 done tasks per startup,
  each call into `getIssue` invoking `isGhAvailable()` + `isGhAuthenticated()`
  via `execFileSync`. `gh auth status` makes a network roundtrip, so 400
  sync spawns ≈ 71s of pure event-loop blocking (69% of cold-start CPU).
  Memoized both checks with a 60s TTL; `resetGhAvailabilityCache()` is
  exported for login/logout flows that need immediate invalidation.

- `PRAGMA integrity_check(100)` walks every page of the SQLite file (~7s
  per database, multiple DBs × projects). The deferred check was scheduled
  3s after init — right in the responsiveness-critical window. Pushed to
  60s so the user is already interacting before it runs; check itself is
  unchanged.

Also yields the event loop between major InProcessRuntime init phases and
between self-healing recovery steps (34 per project), defers orphan-task
AI agent resumption by 30s (env-overridable, auto-zero under Vitest), and
ships an opt-in `FUSION_TRACE_EL_LAG=/path/to/file` event-loop lag tracer
that diagnosed all of the above.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-26 23:41:28 -07:00
parent cac08d733b
commit 390bd7f923
10 changed files with 184 additions and 25 deletions

View File

@@ -1,6 +1,7 @@
// port-4040-allowlist: this file embeds the "never kill port 4040" rule in the executor prompt.
import { exec, execSync } from "node:child_process";
import { promisify } from "node:util";
import { setImmediate as setImmediateCb } from "node:timers";
// Internal git plumbing intentionally bypasses sandbox backends.
const execAsync = promisify(exec);
@@ -168,6 +169,34 @@ export {
taskLogParams,
} from "./agent-tools.js";
const yieldEventLoop = (): Promise<void> => new Promise((resolve) => setImmediateCb(resolve));
/**
* How long to wait after engine startup before spawning AI agent sessions for
* orphaned in-progress tasks. The work itself (worktree setup, pi-coding-agent
* session creation, child process spawn) is heavy and saturates the event
* loop, which makes the dashboard unresponsive during cold start when there
* are orphaned tasks from a prior run. Pushing this work past the initial
* load window keeps the UI snappy; the tasks still resume — just after the
* user has had time to see the board.
*
* Override via FUSION_RESUME_ORPHAN_DELAY_MS. Defaults to 0 under Vitest so
* existing tests that expect immediate resumption keep passing without
* needing per-test plumbing.
*
* Read lazily so an env-var change between module load and resumeOrphaned()
* call (e.g. set in a test setup file) is observed.
*/
function getResumeOrphanDelayMs(): number {
const raw = process.env.FUSION_RESUME_ORPHAN_DELAY_MS;
if (raw !== undefined) {
const parsed = Number.parseInt(raw, 10);
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
}
if (process.env.VITEST || process.env.NODE_ENV === "test") return 0;
return 30_000;
}
const tokenCacheMetricsLog = createLogger("token-cache-metrics");
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
@@ -2893,7 +2922,22 @@ export class TaskExecutor {
if (inProgress.length === 0) return;
executorLog.log(`Found ${inProgress.length} orphaned in-progress task(s)`);
const resumeDelayMs = getResumeOrphanDelayMs();
if (resumeDelayMs > 0) {
executorLog.log(
`Deferring orphan task resumption for ${resumeDelayMs}ms to keep dashboard responsive during cold start`,
);
}
// When the delay is zero (default in tests and when explicitly disabled),
// skip the setTimeout indirection so the spawn happens on the current
// microtask — matching the legacy behavior callers may rely on.
const scheduleResume = resumeDelayMs > 0
? (fn: () => void) => { setTimeout(fn, resumeDelayMs); }
: (fn: () => void) => { fn(); };
let yieldNext = false;
for (const task of inProgress) {
if (yieldNext) await yieldEventLoop();
yieldNext = true;
// Fast-path: if the task already completed its work (all steps done),
// move it directly to in-review instead of re-executing from scratch.
if (this.isTaskWorkComplete(task) && !task.mergeDetails) {
@@ -2903,13 +2947,15 @@ export class TaskExecutor {
}
executorLog.log(`${task.id} is already complete — fast-pathing to in-review`);
this.recoveringCompleted.add(task.id);
void this.recoverCompletedTask(task)
.catch((err) =>
executorLog.error(`Failed to recover completed orphan ${task.id}:`, err),
)
.finally(() => {
this.recoveringCompleted.delete(task.id);
});
scheduleResume(() => {
void this.recoverCompletedTask(task)
.catch((err) =>
executorLog.error(`Failed to recover completed orphan ${task.id}:`, err),
)
.finally(() => {
this.recoveringCompleted.delete(task.id);
});
});
continue;
}
@@ -2926,9 +2972,11 @@ export class TaskExecutor {
} catch (err) {
executorLog.error(`Failed to write resume log for ${task.id}:`, err);
}
this.execute(task).catch((err) =>
executorLog.error(`Failed to resume ${task.id}:`, err),
);
scheduleResume(() => {
this.execute(task).catch((err) =>
executorLog.error(`Failed to resume ${task.id}:`, err),
);
});
}
}

View File

@@ -1,8 +1,10 @@
import type { Task, TaskStore } from "@fusion/core";
import type { TaskExecutor } from "./executor.js";
import { createLogger } from "./logger.js";
import { setImmediate as setImmediateCb } from "node:timers";
const log = createLogger("restart-recovery");
const yieldEventLoop = (): Promise<void> => new Promise((resolve) => setImmediateCb(resolve));
export function hasStepProgress(task: Task): boolean {
const steps = Array.isArray(task.steps) ? task.steps : [];
@@ -102,6 +104,7 @@ export class RestartRecoveryCoordinator {
if (!this.mustSafeRetry(task)) continue;
await this.safeRequeue(task);
requeued++;
await yieldEventLoop();
}
if (requeued > 0) {

View File

@@ -44,6 +44,9 @@ import { EphemeralWorkerManager } from "../ephemeral-worker-manager.js";
import { validateProjectNodeMapping } from "../node-dispatch-validation.js";
import { attachAgentLinkSync } from "../task-agent-sync.js";
import { createRunAuditor, generateSyntheticRunId } from "../run-audit.js";
import { setImmediate as setImmediateCb } from "node:timers";
const yieldEventLoop = (): Promise<void> => new Promise((resolve) => setImmediateCb(resolve));
/**
* InProcessRuntime runs a project within the main process.
@@ -187,6 +190,8 @@ export class InProcessRuntime
// Initialize MessageStore early so TaskExecutor receives send_message capability.
this.messageStore = new MessageStoreClass(this.taskStore.getDatabase());
await yieldEventLoop();
// 2. Initialize Plugin system (PluginStore + PluginLoader + PluginRunner)
this.pluginStore = new PluginStoreClass(this.config.workingDirectory);
await this.pluginStore.init();
@@ -205,6 +210,8 @@ export class InProcessRuntime
await this.pluginRunner.init();
runtimeLog.log(`PluginRunner initialized`);
await yieldEventLoop();
// 3. Initialize WorktreePool
// Reap half-initialized orphan worktree directories before doing anything
@@ -249,6 +256,8 @@ export class InProcessRuntime
);
}
await yieldEventLoop();
// 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) {
@@ -269,6 +278,8 @@ export class InProcessRuntime
}
}
await yieldEventLoop();
// 5a. Initialize AgentStore (required for scheduler assignment, reflection service, and heartbeat monitoring)
let agentStoreForReflection: import("@fusion/core").AgentStore | undefined;
try {
@@ -281,6 +292,8 @@ export class InProcessRuntime
}
this.agentStore = agentStoreForReflection;
await yieldEventLoop();
// 5. Initialize Scheduler
const missionStore = this.taskStore.getMissionStore();
this.missionAutopilot = missionStore
@@ -358,6 +371,8 @@ export class InProcessRuntime
});
await yieldEventLoop();
// 5b. Initialize TaskExecutor
this.stuckTaskDetector = new StuckTaskDetector(this.taskStore, {
beforeRequeue: (taskId, reason, event) => this.selfHealingManager?.checkStuckBudget(taskId, reason, event) ?? Promise.resolve(true),
@@ -497,6 +512,8 @@ export class InProcessRuntime
})();
});
await yieldEventLoop();
// 6. Initialize HeartbeatMonitor (reuses AgentStore from step 5a)
if (this.heartbeatMonitor) {
// Already started — nothing to do
@@ -589,7 +606,9 @@ export class InProcessRuntime
}
if (this.workerManager) {
this.workerManager.attachStateChangeListener();
await this.workerManager.reconcileOrphaned();
void this.workerManager.reconcileOrphaned().catch((err) => {
runtimeLog.warn(`Deferred workerManager.reconcileOrphaned failed: ${err instanceof Error ? err.message : String(err)}`);
});
}
// Register existing non-ephemeral, heartbeat-enabled agents in tickable states.
@@ -682,6 +701,8 @@ export class InProcessRuntime
runtimeLog.warn("RoutineScheduler initialization skipped:", routineErr instanceof Error ? routineErr.message : routineErr);
}
await yieldEventLoop();
// 7. Initialize SelfHealingManager
this.chatStore ??= new ChatStore(this.taskStore.getFusionDir(), this.taskStore.getDatabase());
this.selfHealingManager = new SelfHealingManager(this.taskStore, {
@@ -744,8 +765,14 @@ export class InProcessRuntime
);
} else {
this.startupRecoveryDeferred = false;
await this.resumeStartupRecoverySequence();
// Defer recovery sequence so the runtime start() returns quickly.
// The sequence runs git operations and may resume orphaned tasks,
// both of which can block the event loop for several seconds.
// Running it in the background lets the HTTP server become
// responsive sooner while still performing the recovery work.
void this.resumeStartupRecoverySequence().catch((err) => {
runtimeLog.error("Deferred startup recovery sequence failed:", err);
});
}
// 11. Start scheduler and triage processor

View File

@@ -25,6 +25,7 @@
import { exec, execSync } from "node:child_process";
import { promisify } from "node:util";
import { setImmediate as setImmediateCb } from "node:timers";
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { isAbsolute, join, relative, resolve } from "node:path";
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, parseExplicitDuplicateMarker, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core";
@@ -64,6 +65,7 @@ import { DependencyBlockedTodoReporter } from "./dependency-blocked-todo-reporte
const log = createLogger("self-healing");
const worktreeMetadataReconcileLog = createLogger("worktree-metadata-reconcile");
const execAsync = promisify(exec);
const yieldEventLoop = (): Promise<void> => new Promise((resolve) => setImmediateCb(resolve));
const DONE_TASK_INTEGRITY_SWEEP_LIMIT = 50;
const BOARD_STALL_NOTIFICATION_COOLDOWN_MS = 60 * 60_000;
const DB_CORRUPTION_NOTIFICATION_COOLDOWN_MS = 60 * 60 * 1000;
@@ -828,6 +830,7 @@ export class SelfHealingManager {
const stepErrMessage = stepErr instanceof Error ? stepErr.message : String(stepErr);
log.error(`Startup recovery step "${step.name}" failed: ${stepErrMessage} — continuing with remaining steps`);
}
await yieldEventLoop();
}
}
@@ -1416,6 +1419,7 @@ export class SelfHealingManager {
} catch (stepErr) {
log.error(`Maintenance batch 1 step "${fn.name}" failed: ${stepErr instanceof Error ? stepErr.message : String(stepErr)}`);
}
await yieldEventLoop();
}
const recoverySettings = await this.store.getSettings();
@@ -1489,6 +1493,7 @@ export class SelfHealingManager {
} catch (stepErr) {
log.error(`Maintenance batch 2 step "${fn.name}" failed: ${stepErr instanceof Error ? stepErr.message : String(stepErr)}`);
}
await yieldEventLoop();
}
}
@@ -1503,6 +1508,7 @@ export class SelfHealingManager {
} catch (stepErr) {
log.error(`Maintenance batch 3 step "${fn.name}" failed: ${stepErr instanceof Error ? stepErr.message : String(stepErr)}`);
}
await yieldEventLoop();
}
const elapsedMs = Date.now() - startMs;