Skip to content

Commit db2e038

Browse files
Merge pull request giancarloerra#72 from giancarloerra/feat/auto-resume-all-projects
feat(startup): opt-in auto-resume of all indexed projects
2 parents 6e670e1 + 7a6dd96 commit db2e038

3 files changed

Lines changed: 521 additions & 85 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1216,6 +1216,8 @@ The rest of this section documents the variables themselves. Pass them using whi
12161216
| `SOCRATICODE_PROJECT_ID` | *(none)* | Override the auto-generated project ID. When set, all paths resolve to the same Qdrant collections, allowing multiple directories (e.g. git worktrees of the same repo) to share a single index. Must match `[a-zA-Z0-9_-]+`. Takes precedence over the `projectId` field in `.socraticode.json`. |
12171217
| `SOCRATICODE_BRANCH_AWARE` | `false` | When `true`, append the current git branch name to the project ID, creating separate Qdrant collections per branch. Ignored when `SOCRATICODE_PROJECT_ID` is set or when `projectId` is set in `.socraticode.json`. |
12181218
| `SOCRATICODE_LINKED_PROJECTS` | *(none)* | Comma-separated list of additional project paths to include in cross-project search. Merged with paths from `.socraticode.json`. Non-existent paths are silently skipped. |
1219+
| `SOCRATICODE_AUTO_RESUME` | *(none)* | When set to `all`, server startup auto-resumes the file watcher plus an incremental catch-up update for **every** indexed project that has a stored path, not just the current working directory. Projects resume one at a time (sequentially) to avoid overloading the embedding provider. Skipped projects (directory no longer exists, or indexed before path tracking was added) are logged at warn level. Useful when one MCP server session should keep many canonical checkouts fresh. |
1220+
| `SOCRATICODE_AUTO_RESUME_PROJECTS` | *(none)* | Comma-separated list of project paths to auto-resume on server startup (sequentially), e.g. `/repos/api,/repos/web`. Takes precedence over `SOCRATICODE_AUTO_RESUME`. Paths that do not exist or are not indexed are skipped with a warning. |
12191221
| `SOCRATICODE_LOG_LEVEL` | `info` | Log verbosity: `debug`, `info`, `warn`, `error` |
12201222
| `SOCRATICODE_LOG_FILE` | *(none)* | Absolute path to a log file. When set, all log entries are appended to this file (a session separator is written on each server start). Useful for debugging when the MCP host doesn't surface log notifications. |
12211223
@@ -1383,6 +1385,11 @@ The watcher will not auto-start if a full index or incremental update is current
13831385
progress, if the project has not been indexed yet, or if another MCP process is already
13841386
watching the same project.
13851387
1388+
If you work across many indexed repos and want all of them resumed at server startup
1389+
(watcher plus catch-up update), not just the one you opened, see the
1390+
`SOCRATICODE_AUTO_RESUME` and `SOCRATICODE_AUTO_RESUME_PROJECTS` environment variables
1391+
in the [Indexing Behaviour](#indexing-behaviour) table.
1392+
13861393
### Can multiple AI agents work on the same codebase at the same time?
13871394
13881395
Yes — this is a first-class supported workflow. When multiple agents (each running their own MCP server instance) are pointed at the same project directory, they automatically share the same Qdrant index. The first agent to trigger indexing acquires a cross-process lock and builds the index; any other agent that tries to index simultaneously receives current progress instead of starting a duplicate operation. All agents can search concurrently with no coordination needed — Qdrant handles parallel reads natively.

src/services/startup.ts

Lines changed: 245 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -8,31 +8,46 @@
88
* of the MCP stdio transport.
99
*/
1010

11+
import fs from "node:fs";
12+
import path from "node:path";
1113
import { collectionName, projectIdFromPath } from "../config.js";
12-
import { QDRANT_MODE } from "../constants.js";
14+
import { QDRANT_COLLECTION_PREFIX, QDRANT_MODE } from "../constants.js";
1315
import { isDockerAvailable, isQdrantRunning } from "./docker.js";
1416
import { getIndexingInProgressProjects, getPersistedIndexingStatus, indexProject, requestCancellation, updateProjectIndex } from "./indexer.js";
1517
import { getLockHolderPid, releaseAllLocks } from "./lock.js";
1618
import { logger } from "./logger.js";
17-
import { listCodebaseCollections } from "./qdrant.js";
19+
import { getProjectMetadata, listCodebaseCollections } from "./qdrant.js";
1820
import { 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
*/
3752
export 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

Comments
 (0)