fix(mcp,extraction): exit a daemon whose install changed; never store a grammar-load failure (#2335, #2336) (#2346)

* fix(mcp,extraction): a daemon whose install changed exits; grammar-load failures are never stored (#2335, #2336)

A daemon that outlived an upgrade loaded grammars lazily from an install
that no longer existed, and stored every file its watcher re-indexed as a
zero-node row under the file's new content hash: no hash-based sync ever
revisited those rows, and `codegraph status` called the index up to date.

- The daemon checks its own package.json every 30 s
  (CODEGRAPH_DAEMON_INSTALL_CHECK_MS, 0 turns it off) and exits once the
  file is gone or carries another version, so the next session starts a
  daemon from the current install.
- A result whose grammar failed to load (`parser_error`) is never stored:
  the file keeps its previous data and the next sync or index retries it,
  and a row an older engine stored that way is re-indexed even though its
  hash matches. The tag-based CFML path reports a missing grammar with the
  same code.
- A launcher whose own install was deleted no longer crashes on the spawn
  error of the daemon it tried to start.
- `codegraph status` and `status --json` (filesNeedingReindex,
  filesWithParseErrors) report indexed files whose symbols are missing
  although their content is current (#2336), and `files --json` carries
  each file's recorded errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): a session whose own install changed never claims the writer lock (#2335)

A daemon now exits once its install is upgraded or removed, and nobody
holds the writer slot for a successor at that point. Its sessions run the
same replaced code and serve themselves in-process the moment it goes; one
that found the slot free claimed it as its writer even though its engine
could not load (a real npm upgrade: "Cannot find module
'./sqlite-adapter'"), and so kept a daemon from the current install from
starting. Such a session now falls back read-only and says to restart it.

Also adds the CHANGELOG entries for the install check and grammar-load
failures (#2335) and for the status report (#2336).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-05 15:08:14 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 511d86e94d
commit dea076fd1e
15 changed files with 706 additions and 15 deletions
+3 -1
View File
@@ -147,10 +147,12 @@ export class CfmlExtractor {
private extractTagBased(): void {
const parser = getParser('cfml');
if (!parser) {
// Same code as TreeSitterExtractor's missing parser, so a grammar that
// failed to load is never stored as this file's index data (#2335).
this.errors.push({
message: 'cfml grammar not loaded',
severity: 'error',
code: 'unsupported_language',
code: 'parser_error',
});
return;
}
+16 -1
View File
@@ -9,7 +9,7 @@
import * as path from 'path';
import * as fsp from 'fs/promises';
import { Parser, Language as WasmLanguage } from 'web-tree-sitter';
import { Language } from '../types';
import { ExtractionError, Language } from '../types';
export type GrammarLanguage = Exclude<Language, 'svelte' | 'vue' | 'astro' | 'liquid' | 'razor' | 'yaml' | 'twig' | 'xml' | 'properties' | 'unknown'>;
@@ -828,6 +828,21 @@ export function getUnavailableGrammarErrors(): Partial<Record<Language, string>>
return out;
}
/**
* Whether these extraction errors say the file was never parsed because its
* grammar was not available (`parser_error`, recorded by the extractor when
* `getParser` has nothing for the language).
*
* That is a fact about the running process, not about the file: a daemon whose
* install was upgraded or deleted underneath it fails every lazy grammar load
* (#2335). Such a result must never replace a file's index data, and a row an
* older engine stored that way must be re-indexed — not treated as current
* because its content hash still matches.
*/
export function hasGrammarLoadFailure(errors: readonly ExtractionError[] | undefined): boolean {
return !!errors && errors.some((e) => e.code === 'parser_error');
}
/**
* Get language display name
*/
+32 -8
View File
@@ -25,7 +25,7 @@ import { ParseWorkerPool, resolveParsePoolSize, resolveParseTimeoutMs } from './
import { StoreWriter, StoreBundle, finalizeStoreBundle } from './store-writer';
import { materializeKernelResult } from './kernel';
import { detectGeneratedFile } from './generated-detection';
import { detectLanguage, isSourceFile, isLanguageSupported, isFileLevelOnlyLanguage, initGrammars, loadGrammarsForLanguages, readGrammarWasmBytes, isMpegTransportStream, hasMpegTsExtension, MPEG_TS_SNIFF_BYTES } from './grammars';
import { detectLanguage, isSourceFile, isLanguageSupported, isFileLevelOnlyLanguage, initGrammars, loadGrammarsForLanguages, readGrammarWasmBytes, isMpegTransportStream, hasMpegTsExtension, MPEG_TS_SNIFF_BYTES, hasGrammarLoadFailure } from './grammars';
import { loadExtensionOverrides, loadIncludeIgnoredPatterns, loadExcludePatterns, loadIncludePatterns, PROJECT_CONFIG_FILENAME } from '../project-config';
import { isCodeGraphDataDir } from '../directory';
import { logDebug, logWarn } from '../errors';
@@ -2289,11 +2289,17 @@ export class ExtractionOrchestrator {
const nodeCount = result.kernelCounts?.nodes ?? result.nodes.length;
const edgeCount = result.kernelCounts?.edges ?? result.edges.length;
// A file whose grammar failed to load was never parsed (#2335).
const grammarUnavailable = hasGrammarLoadFailure(result.errors);
// Store: on the writer thread when active (fresh DB — bundles applied
// in the same file order this chain dispatches them), else on the main
// thread (SQLite connections are per-thread).
const language = detectLanguage(filePath, content, overrides);
if (storeWriter) {
if (grammarUnavailable) {
// Store nothing: a row from an earlier run keeps its data, and with no
// row (or an older hash) the next sync or index retries the file.
} else if (storeWriter) {
if (result.kernelBuffers) {
// Buffers go to the writer as-is; the worker decodes + finalizes.
// The main thread's only per-file work stays O(1) + the content hash.
@@ -2321,7 +2327,9 @@ export class ExtractionOrchestrator {
errors.push(...result.errors);
}
if (nodeCount > 0) {
if (grammarUnavailable) {
filesErrored++;
} else if (nodeCount > 0) {
filesIndexed++;
totalNodes += nodeCount;
totalEdges += edgeCount;
@@ -2735,7 +2743,9 @@ export class ExtractionOrchestrator {
errors.push(...result.errors);
}
if (result.nodes.length > 0) {
if (hasGrammarLoadFailure(result.errors)) {
filesErrored++; // nothing was stored (#2335)
} else if (result.nodes.length > 0) {
filesIndexed++;
totalNodes += result.nodes.length;
totalEdges += result.edges.length;
@@ -2918,6 +2928,12 @@ export class ExtractionOrchestrator {
result: ExtractionResult,
onYield?: MaybeYield
): Promise<void> {
// The file was never parsed: its grammar failed to load (#2335). Storing
// this would replace the file's symbols with an empty row under the new
// content hash, which no hash-based sync revisits. Keep whatever the index
// has; the stale (or missing) row makes the next sync retry the file.
if (hasGrammarLoadFailure(result.errors)) return;
// A kernel result can arrive as an undecoded buffer transport (empty
// node/edge arrays, tables riding in kernelBuffers). Decode it before
// storing — persisting the transport as-is records the file as having no
@@ -2944,7 +2960,10 @@ export class ExtractionOrchestrator {
const existingIsMarker =
existingFile.nodeCount === 0 && (existingFile.errors?.length ?? 0) > 0;
const incomingHasContent = result.nodes.length > 0;
if (!existingIsMarker || !incomingHasContent) {
// A row an older engine stored while the grammar could not load
// (#2335) records no parse at all: any real result replaces it.
const existingNeverParsed = hasGrammarLoadFailure(existingFile.errors);
if (!existingNeverParsed && (!existingIsMarker || !incomingHasContent)) {
return; // No changes
}
}
@@ -3400,13 +3419,18 @@ export class ExtractionOrchestrator {
}
const fullPath = path.join(this.rootDir, filePath);
const tracked = trackedMap.get(filePath);
// A row an older engine stored while the file's grammar could not load
// (#2335) holds no parse of these bytes: re-index it even though its
// size, mtime and hash all match. Rows with a real parse error are
// deterministic and are not retried.
const neverParsed = tracked !== undefined && hasGrammarLoadFailure(tracked.errors);
// Cheap pre-filter: an already-indexed file whose size AND mtime both match
// the DB is unchanged — skip it without reading or hashing. (A content
// change that preserves both exactly is the blind spot every mtime-based
// incremental tool accepts; `index --force` is the escape hatch. Git bumps
// mtime on every file it writes during checkout/merge, so pulls are caught.)
if (tracked) {
if (tracked && !neverParsed) {
try {
const stat = fs.statSync(fullPath);
if (stat.size === tracked.size && Math.floor(stat.mtimeMs) === Math.floor(tracked.modifiedAt)) {
@@ -3442,7 +3466,7 @@ export class ExtractionOrchestrator {
filesToIndex.push(filePath);
changedFilePaths.push(filePath);
filesAdded++;
} else if (tracked.contentHash !== contentHash) {
} else if (tracked.contentHash !== contentHash || neverParsed) {
onFileChange?.(filePath, content);
filesToIndex.push(filePath);
changedFilePaths.push(filePath);
@@ -3477,7 +3501,7 @@ export class ExtractionOrchestrator {
const result = await this.indexFile(filePath);
if (result.errors.some(e => e.severity === 'error')) failedFilePaths.push(filePath);
nodesUpdated += result.nodes.length;
if (!hasGrammarLoadFailure(result.errors)) nodesUpdated += result.nodes.length; // else nothing was stored (#2335)
const pause = backpressure?.();
if (pause) await pause;