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:
@@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user