socraticode
Version:
SocratiCode — MCP server for local codebase indexing, semantic search, and code dependency graphs. All private, all local via Docker.
379 lines • 17.2 kB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
/**
* Startup lifecycle helpers — auto-resume and graceful shutdown coordination.
*
* Extracted from src/index.ts so the logic can be unit-tested independently
* of the MCP stdio transport.
*/
import fs from "node:fs";
import path from "node:path";
import { collectionName, projectIdFromPath } from "../config.js";
import { getWatcherMode, QDRANT_COLLECTION_PREFIX, QDRANT_MODE } from "../constants.js";
import { stopAutomaticCleanup } from "./auto-cleanup.js";
import { isGraphBuildInProgress } from "./code-graph.js";
import { isDockerAvailable, isQdrantRunning } from "./docker.js";
import { resumeGitRefresh, stopAllGitRefreshes } from "./git-refresh.js";
import { getIndexingInProgressProjects, getPersistedIndexingStatus, indexProject, requestCancellation, updateProjectIndex } from "./indexer.js";
import { getLockHolderPid, releaseAllLocks } from "./lock.js";
import { logger } from "./logger.js";
import { getProjectMetadata, listCodebaseCollections } from "./qdrant.js";
import { cleanStaleGenerations, coordinateProject, loadSymbolGraphMeta } from "./symbol-graph-store.js";
import { isWatching, startWatchingAutomatically, stopAllWatchers } from "./watcher.js";
/**
* Auto-resume indexed projects on server startup.
*
* Which projects are resumed is controlled by two opt-in environment
* variables (issue #70). Default (both unset) keeps the original behavior:
* only the project at process.cwd() is considered.
*
* - SOCRATICODE_AUTO_RESUME_PROJECTS: comma-separated list of project
* paths to resume. Takes precedence over unset/all behavior.
* - SOCRATICODE_AUTO_RESUME=all: resume every indexed project that has a
* stored path in its Qdrant metadata.
* - SOCRATICODE_AUTO_RESUME=off: skip all startup catch-up and interrupted
* index recovery. This takes precedence over an explicit project list.
*
* Multi-project modes resume strictly sequentially: each project's catch-up
* (incremental update or interrupted-index recovery) completes before the
* next starts, so N projects never stampede the embedding provider. Skipped
* projects (missing directory, no stored path, not indexed) are logged at
* warn level so misconfiguration is visible.
*
* Per project, if the index is complete: start the file watcher and run an
* incremental update to catch changes made while the MCP was down. If the
* index is incomplete (interrupted): resume it via indexProject, which skips
* already-hashed files and honours codebase_stop cancellation; the watcher
* starts after recovery completes.
*
* Runs in the background after server startup — non-blocking, non-fatal.
*
* @param projectPath - Override project path for the default single-project
* mode (defaults to process.cwd()). Only used by tests — production always
* uses process.cwd().
*/
export async function autoResumeIndexedProjects(projectPath) {
try {
// Read at call time (not module load) so tests and MCP host configs that
// set variables after import are honoured. `off` is checked before any
// Docker or Qdrant access, and overrides the explicit project list: the
// promise is that startup performs no indexing work at all.
const resumeMode = process.env.SOCRATICODE_AUTO_RESUME?.trim();
if (resumeMode?.toLowerCase() === "off") {
logger.info("Auto-resume: disabled by SOCRATICODE_AUTO_RESUME=off");
return;
}
// In managed mode, check if Docker and Qdrant are already running — don't start them.
// In external mode, skip Docker checks and let listCodebaseCollections() fail if unreachable.
if (QDRANT_MODE === "managed") {
if (!(await isDockerAvailable())) {
logger.info("Auto-resume: Docker not available, skipping");
return;
}
if (!(await isQdrantRunning())) {
logger.info("Auto-resume: Qdrant not running, skipping");
return;
}
}
const explicitList = process.env.SOCRATICODE_AUTO_RESUME_PROJECTS?.trim();
if (explicitList) {
await resumeProjectList(explicitList);
return;
}
if (resumeMode) {
if (resumeMode.toLowerCase() === "all") {
await resumeAllIndexedProjects();
return;
}
logger.warn("Auto-resume: unrecognized SOCRATICODE_AUTO_RESUME value, using default behavior (current project only)", {
value: resumeMode,
});
// Fall through to the default single-project behavior below.
}
// Default: only consider the current project
const resolvedPath = projectPath ?? process.cwd();
// If CWD is root or home, the MCP host hasn't opened a specific project yet — skip
if (resolvedPath === "/" || resolvedPath === process.env.HOME) {
logger.info("Auto-resume: CWD is root/home, no specific project — skipping", { projectPath: resolvedPath });
return;
}
const collections = await listCodebaseCollections();
await resumeProject(resolvedPath, collections, "info");
}
catch (err) {
logger.warn("Auto-resume failed (non-fatal)", {
error: err instanceof Error ? err.message : String(err),
});
}
}
/**
* Resume the explicit comma-separated project list from
* SOCRATICODE_AUTO_RESUME_PROJECTS, sequentially. Paths are resolved to
* absolute and deduplicated; entries whose directory does not exist are
* skipped with a warning. One project failing does not stop the rest.
*/
async function resumeProjectList(rawList) {
const targets = [...new Set(rawList
.split(",")
.map((p) => p.trim())
.filter((p) => p.length > 0)
.map((p) => path.resolve(p)))];
if (targets.length === 0) {
logger.warn("Auto-resume: SOCRATICODE_AUTO_RESUME_PROJECTS is set but contains no paths");
return;
}
logger.info("Auto-resume: resuming explicit project list sequentially", { count: targets.length });
const collections = await listCodebaseCollections();
for (const target of targets) {
if (!fs.existsSync(target)) {
logger.warn("Auto-resume: listed project directory does not exist, skipping", { projectPath: target });
continue;
}
try {
await resumeProject(target, collections, "warn");
}
catch (err) {
logger.warn("Auto-resume: project resume failed, continuing with remaining projects", {
projectPath: target,
error: err instanceof Error ? err.message : String(err),
});
}
}
}
/**
* Resume every indexed project found in Qdrant (SOCRATICODE_AUTO_RESUME=all),
* sequentially. Project paths come from each collection's stored metadata;
* collections without a stored path (indexed before path tracking) and paths
* that no longer exist on disk are skipped with a warning.
*/
async function resumeAllIndexedProjects() {
const collections = await listCodebaseCollections();
const codebasePrefix = `${QDRANT_COLLECTION_PREFIX}codebase_`;
const codebaseCollections = collections.filter((c) => c.startsWith(codebasePrefix));
if (codebaseCollections.length === 0) {
logger.info("Auto-resume: no indexed projects found, nothing to resume");
return;
}
const targets = [];
const seen = new Set();
for (const coll of codebaseCollections) {
const metadata = await getProjectMetadata(coll);
if (!metadata?.projectPath) {
logger.warn("Auto-resume: project has no stored path (indexed before path tracking), cannot auto-resume. Re-index it once to fix this permanently.", {
collection: coll,
});
continue;
}
const resolved = path.resolve(metadata.projectPath);
if (seen.has(resolved))
continue;
seen.add(resolved);
if (!fs.existsSync(resolved)) {
logger.warn("Auto-resume: indexed project directory no longer exists, skipping", {
projectPath: resolved,
collection: coll,
});
continue;
}
targets.push(resolved);
}
logger.info("Auto-resume: resuming all indexed projects sequentially", { count: targets.length });
for (const target of targets) {
try {
await resumeProject(target, collections, "warn");
}
catch (err) {
logger.warn("Auto-resume: project resume failed, continuing with remaining projects", {
projectPath: target,
error: err instanceof Error ? err.message : String(err),
});
}
}
}
/**
* Resume a single project: start its watcher and catch up its index.
* Awaits the catch-up work to completion so callers can sequence multiple
* projects without overlapping embedding load.
*
* @param resolvedPath - Absolute project path.
* @param collections - Pre-fetched Qdrant collection names (fetched once per
* startup so multi-project modes do not re-query per project).
* @param skipLogLevel - "warn" in multi-project modes so misconfigured
* entries are visible; "info" for the default cwd mode (an unindexed cwd
* is normal, not a misconfiguration).
*/
async function resumeProject(resolvedPath, collections, skipLogLevel) {
const projectId = projectIdFromPath(resolvedPath);
const collection = collectionName(projectId);
if (!collections.includes(collection)) {
if (skipLogLevel === "warn") {
logger.warn("Auto-resume: project not yet indexed, skipping", { projectPath: resolvedPath });
}
else {
logger.info("Auto-resume: current project not yet indexed, skipping", { projectPath: resolvedPath });
}
return;
}
if (getWatcherMode() === "git") {
const resumedProjectId = await resumeGitRefresh(resolvedPath);
if (resumedProjectId)
await cleanStaleSymbolGraphGenerations(resolvedPath, resumedProjectId);
return;
}
// Check persisted indexing status to detect interrupted indexing
const persistedStatus = await getPersistedIndexingStatus(resolvedPath);
if (persistedStatus === "in-progress") {
// Check if another process is already indexing (orphan from previous session)
const orphanPid = await getLockHolderPid(resolvedPath, "index");
if (orphanPid !== null) {
logger.info("Auto-resume: skipping — another process is still indexing this project", {
projectPath: resolvedPath, orphanPid,
});
return;
}
// Index was interrupted — resume it via indexProject.
// indexProject skips already-hashed files, embeds the rest, and honours
// codebase_stop cancellation requests (unlike updateProjectIndex's incremental diff).
// Do NOT start watcher yet — it will start after indexing completes.
logger.info("Auto-resume: detected incomplete index, resuming indexing", { projectPath: resolvedPath });
const resumeOnProgress = (msg) => logger.info(msg, { tool: "auto-resume", projectPath: resolvedPath });
try {
const result = await indexProject(resolvedPath, resumeOnProgress);
logger.info("Auto-resume: incomplete index recovery completed", {
projectPath: resolvedPath,
filesIndexed: result.filesIndexed,
chunksCreated: result.chunksCreated,
cancelled: result.cancelled,
});
// Now start the watcher after recovery — only if not cancelled
if (!result.cancelled && !isWatching(resolvedPath)) {
const started = await startWatchingAutomatically(resolvedPath);
if (started) {
logger.info("Auto-resume: started file watcher after recovery", { projectPath: resolvedPath });
}
}
}
catch (err) {
logger.warn("Auto-resume: incomplete index recovery failed (non-fatal)", {
projectPath: resolvedPath,
error: err instanceof Error ? err.message : String(err),
});
}
return;
}
// Index is complete — start watcher and do incremental catch-up.
// Watcher startup failure must not skip the catch-up below: a project
// without a live watcher is degraded, but a project with a stale index
// is broken for search.
if (!isWatching(resolvedPath)) {
try {
const started = await startWatchingAutomatically(resolvedPath);
if (started) {
logger.info("Auto-resume: started file watcher", { projectPath: resolvedPath });
}
}
catch (err) {
logger.warn("Auto-resume: failed to start watcher, continuing with incremental catch-up", {
projectPath: resolvedPath,
error: err instanceof Error ? err.message : String(err),
});
}
}
// Incremental update: only re-embeds actually changed files. Awaited so
// multi-project resume stays sequential (auto-resume as a whole already
// runs in the background, so this blocks nothing user-facing).
// Note: updateProjectIndex handles code graph rebuild internally.
try {
const result = await updateProjectIndex(resolvedPath);
const changed = result.added + result.updated + result.removed;
if (changed > 0) {
logger.info("Auto-resume: incremental update completed", {
projectPath: resolvedPath,
added: result.added,
updated: result.updated,
removed: result.removed,
filesChanged: changed,
});
}
else {
logger.info("Auto-resume: index is up to date", { projectPath: resolvedPath });
}
}
catch (err) {
logger.warn("Auto-resume: incremental update failed (non-fatal)", {
projectPath: resolvedPath,
error: err instanceof Error ? err.message : String(err),
});
}
await cleanStaleSymbolGraphGenerations(resolvedPath, projectId);
}
/** Reuse the startup cleanup for both file-watcher and Git catch-up paths. */
async function cleanStaleSymbolGraphGenerations(resolvedPath, projectId) {
// Retire abandoned or superseded generations, coordinated with graph rebuilds.
if (!isGraphBuildInProgress(resolvedPath)) {
try {
await coordinateProject(projectId, async () => {
const meta = await loadSymbolGraphMeta(projectId);
if (meta?.generation) {
await cleanStaleGenerations(projectId, meta.generation);
}
});
}
catch {
// Non-fatal
}
}
}
/** Wait for any active indexing operations to complete (with timeout). */
export async function awaitActiveIndexing(timeoutMs = 60_000) {
const active = getIndexingInProgressProjects();
if (active.length === 0)
return;
logger.info("Waiting for active indexing to complete before shutdown", {
projects: active,
});
const deadline = Date.now() + timeoutMs;
while (getIndexingInProgressProjects().length > 0 && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 500));
}
const remaining = getIndexingInProgressProjects();
if (remaining.length > 0) {
logger.warn("Forcing shutdown — indexing still in progress. Progress is checkpointed; it will resume with no data loss.", {
projects: remaining,
});
}
}
/**
* Execute graceful shutdown: cancel in-flight indexing, wait for completion,
* stop watchers, release locks.
*
* @param closeServer - Optional callback to close the MCP server.
*/
export async function gracefulShutdown(signal, closeServer) {
logger.info(`Received ${signal}, shutting down gracefully...`);
stopAllGitRefreshes();
await stopAutomaticCleanup();
// Signal all in-flight indexing in this process to stop at the next batch boundary
for (const project of getIndexingInProgressProjects()) {
requestCancellation(project);
}
await awaitActiveIndexing();
await stopAllWatchers();
await releaseAllLocks();
logger.info("Graceful shutdown complete");
// Close the MCP server transport last — the host may have already severed
// the stdio pipe (especially on SIGTERM), so server.close() can hang or
// throw. Wrap in a timeout so the process still exits cleanly.
if (closeServer) {
try {
await Promise.race([
closeServer(),
new Promise((resolve) => setTimeout(resolve, 3_000)),
]);
}
catch {
// Broken pipe or transport already closed — not fatal during shutdown
}
}
}
//# sourceMappingURL=startup.js.map