88 * of the MCP stdio transport.
99 */
1010
11+ import fs from "node:fs" ;
12+ import path from "node:path" ;
1113import { collectionName , projectIdFromPath } from "../config.js" ;
12- import { QDRANT_MODE } from "../constants.js" ;
14+ import { QDRANT_COLLECTION_PREFIX , QDRANT_MODE } from "../constants.js" ;
1315import { isDockerAvailable , isQdrantRunning } from "./docker.js" ;
1416import { getIndexingInProgressProjects , getPersistedIndexingStatus , indexProject , requestCancellation , updateProjectIndex } from "./indexer.js" ;
1517import { getLockHolderPid , releaseAllLocks } from "./lock.js" ;
1618import { logger } from "./logger.js" ;
17- import { listCodebaseCollections } from "./qdrant.js" ;
19+ import { getProjectMetadata , listCodebaseCollections } from "./qdrant.js" ;
1820import { isWatching , startWatching , stopAllWatchers } from "./watcher.js" ;
1921
2022/**
21- * Auto-resume for the current project (process.cwd()) on server startup.
23+ * Auto-resume indexed projects on server startup.
2224 *
23- * If the project has a completed index:
24- * - Start file watcher
25- * - Run incremental update to catch changes made while the MCP was down
25+ * Which projects are resumed is controlled by two opt-in environment
26+ * variables (issue #70). Default (both unset) keeps the original behavior:
27+ * only the project at process.cwd() is considered.
2628 *
27- * If the project has an incomplete (interrupted) index:
28- * - Run indexProject which skips already-hashed files and embeds the rest,
29- * correctly resuming the full index (supports codebase_stop cancellation)
30- * - Watcher starts automatically after indexProject completes
29+ * - SOCRATICODE_AUTO_RESUME_PROJECTS: comma-separated list of project
30+ * paths to resume. Takes precedence over SOCRATICODE_AUTO_RESUME.
31+ * - SOCRATICODE_AUTO_RESUME=all: resume every indexed project that has a
32+ * stored path in its Qdrant metadata.
33+ *
34+ * Multi-project modes resume strictly sequentially: each project's catch-up
35+ * (incremental update or interrupted-index recovery) completes before the
36+ * next starts, so N projects never stampede the embedding provider. Skipped
37+ * projects (missing directory, no stored path, not indexed) are logged at
38+ * warn level so misconfiguration is visible.
39+ *
40+ * Per project, if the index is complete: start the file watcher and run an
41+ * incremental update to catch changes made while the MCP was down. If the
42+ * index is incomplete (interrupted): resume it via indexProject, which skips
43+ * already-hashed files and honours codebase_stop cancellation; the watcher
44+ * starts after recovery completes.
3145 *
3246 * Runs in the background after server startup — non-blocking, non-fatal.
3347 *
34- * @param projectPath - Override project path (defaults to process.cwd()).
35- * Only used by tests — production always uses process.cwd().
48+ * @param projectPath - Override project path for the default single-project
49+ * mode (defaults to process.cwd()). Only used by tests — production always
50+ * uses process.cwd().
3651 */
3752export async function autoResumeIndexedProjects ( projectPath ?: string ) : Promise < void > {
3853 try {
@@ -49,7 +64,28 @@ export async function autoResumeIndexedProjects(projectPath?: string): Promise<v
4964 }
5065 }
5166
52- // Only consider the current project
67+ // Read at call time (not module load) so tests and MCP host configs
68+ // that set the variables after import are honoured.
69+ const explicitList = process . env . SOCRATICODE_AUTO_RESUME_PROJECTS ?. trim ( ) ;
70+ const resumeMode = process . env . SOCRATICODE_AUTO_RESUME ?. trim ( ) ;
71+
72+ if ( explicitList ) {
73+ await resumeProjectList ( explicitList ) ;
74+ return ;
75+ }
76+
77+ if ( resumeMode ) {
78+ if ( resumeMode . toLowerCase ( ) === "all" ) {
79+ await resumeAllIndexedProjects ( ) ;
80+ return ;
81+ }
82+ logger . warn ( "Auto-resume: unrecognized SOCRATICODE_AUTO_RESUME value, using default behavior (current project only)" , {
83+ value : resumeMode ,
84+ } ) ;
85+ // Fall through to the default single-project behavior below.
86+ }
87+
88+ // Default: only consider the current project
5389 const resolvedPath = projectPath ?? process . cwd ( ) ;
5490
5591 // If CWD is root or home, the MCP host hasn't opened a specific project yet — skip
@@ -58,92 +94,218 @@ export async function autoResumeIndexedProjects(projectPath?: string): Promise<v
5894 return ;
5995 }
6096
61- const projectId = projectIdFromPath ( resolvedPath ) ;
62- const collection = collectionName ( projectId ) ;
63-
6497 const collections = await listCodebaseCollections ( ) ;
65- if ( ! collections . includes ( collection ) ) {
66- logger . info ( "Auto-resume: current project not yet indexed, skipping" , { projectPath : resolvedPath } ) ;
67- return ;
98+ await resumeProject ( resolvedPath , collections , "info" ) ;
99+ } catch ( err ) {
100+ logger . warn ( "Auto-resume failed (non-fatal)" , {
101+ error : err instanceof Error ? err . message : String ( err ) ,
102+ } ) ;
103+ }
104+ }
105+
106+ /**
107+ * Resume the explicit comma-separated project list from
108+ * SOCRATICODE_AUTO_RESUME_PROJECTS, sequentially. Paths are resolved to
109+ * absolute and deduplicated; entries whose directory does not exist are
110+ * skipped with a warning. One project failing does not stop the rest.
111+ */
112+ async function resumeProjectList ( rawList : string ) : Promise < void > {
113+ const targets = [ ...new Set (
114+ rawList
115+ . split ( "," )
116+ . map ( ( p ) => p . trim ( ) )
117+ . filter ( ( p ) => p . length > 0 )
118+ . map ( ( p ) => path . resolve ( p ) ) ,
119+ ) ] ;
120+
121+ if ( targets . length === 0 ) {
122+ logger . warn ( "Auto-resume: SOCRATICODE_AUTO_RESUME_PROJECTS is set but contains no paths" ) ;
123+ return ;
124+ }
125+
126+ logger . info ( "Auto-resume: resuming explicit project list sequentially" , { count : targets . length } ) ;
127+ const collections = await listCodebaseCollections ( ) ;
128+
129+ for ( const target of targets ) {
130+ if ( ! fs . existsSync ( target ) ) {
131+ logger . warn ( "Auto-resume: listed project directory does not exist, skipping" , { projectPath : target } ) ;
132+ continue ;
68133 }
134+ try {
135+ await resumeProject ( target , collections , "warn" ) ;
136+ } catch ( err ) {
137+ logger . warn ( "Auto-resume: project resume failed, continuing with remaining projects" , {
138+ projectPath : target ,
139+ error : err instanceof Error ? err . message : String ( err ) ,
140+ } ) ;
141+ }
142+ }
143+ }
69144
70- // Check persisted indexing status to detect interrupted indexing
71- const persistedStatus = await getPersistedIndexingStatus ( resolvedPath ) ;
145+ /**
146+ * Resume every indexed project found in Qdrant (SOCRATICODE_AUTO_RESUME=all),
147+ * sequentially. Project paths come from each collection's stored metadata;
148+ * collections without a stored path (indexed before path tracking) and paths
149+ * that no longer exist on disk are skipped with a warning.
150+ */
151+ async function resumeAllIndexedProjects ( ) : Promise < void > {
152+ const collections = await listCodebaseCollections ( ) ;
153+ const codebasePrefix = `${ QDRANT_COLLECTION_PREFIX } codebase_` ;
154+ const codebaseCollections = collections . filter ( ( c ) => c . startsWith ( codebasePrefix ) ) ;
72155
73- if ( persistedStatus === "in-progress" ) {
74- // Check if another process is already indexing (orphan from previous session)
75- const orphanPid = await getLockHolderPid ( resolvedPath , "index" ) ;
76- if ( orphanPid !== null ) {
77- logger . info ( "Auto-resume: skipping — another process is still indexing this project" , {
78- projectPath : resolvedPath , orphanPid,
79- } ) ;
80- return ;
81- }
156+ if ( codebaseCollections . length === 0 ) {
157+ logger . info ( "Auto-resume: no indexed projects found, nothing to resume" ) ;
158+ return ;
159+ }
82160
83- // Index was interrupted — resume it via indexProject.
84- // indexProject skips already-hashed files, embeds the rest, and honours
85- // codebase_stop cancellation requests (unlike updateProjectIndex's incremental diff).
86- // Do NOT start watcher yet — it will start after indexing completes.
87- logger . info ( "Auto-resume: detected incomplete index, resuming indexing" , { projectPath : resolvedPath } ) ;
88- const resumeOnProgress = ( msg : string ) => logger . info ( msg , { tool : "auto-resume" , projectPath : resolvedPath } ) ;
89- indexProject ( resolvedPath , resumeOnProgress )
90- . then ( async ( result ) => {
91- logger . info ( "Auto-resume: incomplete index recovery completed" , {
92- projectPath : resolvedPath ,
93- filesIndexed : result . filesIndexed ,
94- chunksCreated : result . chunksCreated ,
95- cancelled : result . cancelled ,
96- } ) ;
97- // Now start the watcher after recovery — only if not cancelled
98- if ( ! result . cancelled && ! isWatching ( resolvedPath ) ) {
99- const started = await startWatching ( resolvedPath ) ;
100- if ( started ) {
101- logger . info ( "Auto-resume: started file watcher after recovery" , { projectPath : resolvedPath } ) ;
102- }
103- }
104- } )
105- . catch ( ( err ) => {
106- logger . warn ( "Auto-resume: incomplete index recovery failed (non-fatal)" , {
107- projectPath : resolvedPath ,
108- error : err instanceof Error ? err . message : String ( err ) ,
109- } ) ;
110- } ) ;
161+ const targets : string [ ] = [ ] ;
162+ const seen = new Set < string > ( ) ;
163+ for ( const coll of codebaseCollections ) {
164+ const metadata = await getProjectMetadata ( coll ) ;
165+ if ( ! metadata ?. projectPath ) {
166+ logger . warn ( "Auto-resume: project has no stored path (indexed before path tracking), cannot auto-resume. Re-index it once to fix this permanently." , {
167+ collection : coll ,
168+ } ) ;
169+ continue ;
170+ }
171+ const resolved = path . resolve ( metadata . projectPath ) ;
172+ if ( seen . has ( resolved ) ) continue ;
173+ seen . add ( resolved ) ;
174+ if ( ! fs . existsSync ( resolved ) ) {
175+ logger . warn ( "Auto-resume: indexed project directory no longer exists, skipping" , {
176+ projectPath : resolved ,
177+ collection : coll ,
178+ } ) ;
179+ continue ;
180+ }
181+ targets . push ( resolved ) ;
182+ }
183+
184+ logger . info ( "Auto-resume: resuming all indexed projects sequentially" , { count : targets . length } ) ;
185+
186+ for ( const target of targets ) {
187+ try {
188+ await resumeProject ( target , collections , "warn" ) ;
189+ } catch ( err ) {
190+ logger . warn ( "Auto-resume: project resume failed, continuing with remaining projects" , {
191+ projectPath : target ,
192+ error : err instanceof Error ? err . message : String ( err ) ,
193+ } ) ;
194+ }
195+ }
196+ }
197+
198+ /**
199+ * Resume a single project: start its watcher and catch up its index.
200+ * Awaits the catch-up work to completion so callers can sequence multiple
201+ * projects without overlapping embedding load.
202+ *
203+ * @param resolvedPath - Absolute project path.
204+ * @param collections - Pre-fetched Qdrant collection names (fetched once per
205+ * startup so multi-project modes do not re-query per project).
206+ * @param skipLogLevel - "warn" in multi-project modes so misconfigured
207+ * entries are visible; "info" for the default cwd mode (an unindexed cwd
208+ * is normal, not a misconfiguration).
209+ */
210+ async function resumeProject (
211+ resolvedPath : string ,
212+ collections : string [ ] ,
213+ skipLogLevel : "info" | "warn" ,
214+ ) : Promise < void > {
215+ const projectId = projectIdFromPath ( resolvedPath ) ;
216+ const collection = collectionName ( projectId ) ;
217+
218+ if ( ! collections . includes ( collection ) ) {
219+ if ( skipLogLevel === "warn" ) {
220+ logger . warn ( "Auto-resume: project not yet indexed, skipping" , { projectPath : resolvedPath } ) ;
221+ } else {
222+ logger . info ( "Auto-resume: current project not yet indexed, skipping" , { projectPath : resolvedPath } ) ;
223+ }
224+ return ;
225+ }
226+
227+ // Check persisted indexing status to detect interrupted indexing
228+ const persistedStatus = await getPersistedIndexingStatus ( resolvedPath ) ;
229+
230+ if ( persistedStatus === "in-progress" ) {
231+ // Check if another process is already indexing (orphan from previous session)
232+ const orphanPid = await getLockHolderPid ( resolvedPath , "index" ) ;
233+ if ( orphanPid !== null ) {
234+ logger . info ( "Auto-resume: skipping — another process is still indexing this project" , {
235+ projectPath : resolvedPath , orphanPid,
236+ } ) ;
111237 return ;
112238 }
113239
114- // Index is complete — start watcher and do incremental catch-up
115- if ( ! isWatching ( resolvedPath ) ) {
240+ // Index was interrupted — resume it via indexProject.
241+ // indexProject skips already-hashed files, embeds the rest, and honours
242+ // codebase_stop cancellation requests (unlike updateProjectIndex's incremental diff).
243+ // Do NOT start watcher yet — it will start after indexing completes.
244+ logger . info ( "Auto-resume: detected incomplete index, resuming indexing" , { projectPath : resolvedPath } ) ;
245+ const resumeOnProgress = ( msg : string ) => logger . info ( msg , { tool : "auto-resume" , projectPath : resolvedPath } ) ;
246+ try {
247+ const result = await indexProject ( resolvedPath , resumeOnProgress ) ;
248+ logger . info ( "Auto-resume: incomplete index recovery completed" , {
249+ projectPath : resolvedPath ,
250+ filesIndexed : result . filesIndexed ,
251+ chunksCreated : result . chunksCreated ,
252+ cancelled : result . cancelled ,
253+ } ) ;
254+ // Now start the watcher after recovery — only if not cancelled
255+ if ( ! result . cancelled && ! isWatching ( resolvedPath ) ) {
256+ const started = await startWatching ( resolvedPath ) ;
257+ if ( started ) {
258+ logger . info ( "Auto-resume: started file watcher after recovery" , { projectPath : resolvedPath } ) ;
259+ }
260+ }
261+ } catch ( err ) {
262+ logger . warn ( "Auto-resume: incomplete index recovery failed (non-fatal)" , {
263+ projectPath : resolvedPath ,
264+ error : err instanceof Error ? err . message : String ( err ) ,
265+ } ) ;
266+ }
267+ return ;
268+ }
269+
270+ // Index is complete — start watcher and do incremental catch-up.
271+ // Watcher startup failure must not skip the catch-up below: a project
272+ // without a live watcher is degraded, but a project with a stale index
273+ // is broken for search.
274+ if ( ! isWatching ( resolvedPath ) ) {
275+ try {
116276 const started = await startWatching ( resolvedPath ) ;
117277 if ( started ) {
118278 logger . info ( "Auto-resume: started file watcher" , { projectPath : resolvedPath } ) ;
119279 }
280+ } catch ( err ) {
281+ logger . warn ( "Auto-resume: failed to start watcher, continuing with incremental catch-up" , {
282+ projectPath : resolvedPath ,
283+ error : err instanceof Error ? err . message : String ( err ) ,
284+ } ) ;
120285 }
286+ }
121287
122- // Incremental update in the background — only re-embeds actually changed files
123- // Note: updateProjectIndex now handles code graph rebuild internally
124- updateProjectIndex ( resolvedPath )
125- . then ( async ( result ) => {
126- const changed = result . added + result . updated + result . removed ;
127- if ( changed > 0 ) {
128- logger . info ( "Auto-resume: incremental update completed" , {
129- projectPath : resolvedPath ,
130- added : result . added ,
131- updated : result . updated ,
132- removed : result . removed ,
133- filesChanged : changed ,
134- } ) ;
135- } else {
136- logger . info ( "Auto-resume: index is up to date" , { projectPath : resolvedPath } ) ;
137- }
138- } )
139- . catch ( ( err ) => {
140- logger . warn ( "Auto-resume: incremental update failed (non-fatal)" , {
141- projectPath : resolvedPath ,
142- error : err instanceof Error ? err . message : String ( err ) ,
143- } ) ;
288+ // Incremental update: only re-embeds actually changed files. Awaited so
289+ // multi-project resume stays sequential (auto-resume as a whole already
290+ // runs in the background, so this blocks nothing user-facing).
291+ // Note: updateProjectIndex handles code graph rebuild internally.
292+ try {
293+ const result = await updateProjectIndex ( resolvedPath ) ;
294+ const changed = result . added + result . updated + result . removed ;
295+ if ( changed > 0 ) {
296+ logger . info ( "Auto-resume: incremental update completed" , {
297+ projectPath : resolvedPath ,
298+ added : result . added ,
299+ updated : result . updated ,
300+ removed : result . removed ,
301+ filesChanged : changed ,
144302 } ) ;
303+ } else {
304+ logger . info ( "Auto-resume: index is up to date" , { projectPath : resolvedPath } ) ;
305+ }
145306 } catch ( err ) {
146- logger . warn ( "Auto-resume failed (non-fatal)" , {
307+ logger . warn ( "Auto-resume: incremental update failed (non-fatal)" , {
308+ projectPath : resolvedPath ,
147309 error : err instanceof Error ? err . message : String ( err ) ,
148310 } ) ;
149311 }
0 commit comments