fix(resolution): sweep orphaned unresolved refs so an interrupted index heals on sync (#1187) (#1191)

An indexing run killed mid-"Resolving refs" (crash, Ctrl-C, the #1122
watchdog kill) left the refs it never reached parked in unresolved_refs.
The git-scoped sync fast path only re-resolves changed files' refs, so
those files' call edges were missing permanently — a too-small blast
radius clustering by package/module (the #1187 field report: 3 of 10
caller files for a Spring @Resource-injected method) — until a full
re-index.

- sync() now sweeps leftover unresolved refs with the batched resolver
  after its scoped pass, including on no-change syncs, so a bare
  `codegraph sync` recovers a wedged index (and heals pre-fix indexes
  on the first post-upgrade sync)
- the scoped pass deletes unresolvable rows too (parity with the
  batched path), making "rows at rest" a sound orphan signal
- drop the batched loop's early break that abandoned all later batches
  when one batch was all-unresolvable (its rows WERE consumed — that
  early stop could orphan the rest of the table at init)
- surface the state: `codegraph status` warns, `status --json` gains
  index.pendingRefs, and MCP codegraph_status tells agents the blast
  radius is incomplete until the next sync

Verified end-to-end on a 2,414-file synthetic Spring repo: SIGKILL
mid-resolution reproduces the reporter's exact 3-of-10-callers state;
a bare sync now heals it to 10/10 with the edge count converging to
the clean-init total; a healthy-index sync stays a no-op.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-06 14:20:19 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent 7f325134e0
commit 4c15f84aa4
6 changed files with 323 additions and 7 deletions
+44 -2
View File
@@ -608,7 +608,8 @@ export class CodeGraph {
}
// Resolve references if files were updated
if (result.filesAdded > 0 || result.filesModified > 0) {
const filesChanged = result.filesAdded > 0 || result.filesModified > 0;
if (filesChanged) {
if (result.changedFilePaths) {
// Scope resolution to changed files (git fast path — bounded set)
const unresolvedRefs = this.queries.getUnresolvedReferencesByFiles(result.changedFilePaths);
@@ -644,7 +645,38 @@ export class CodeGraph {
});
});
}
}
// Orphan sweep (#1187). A resolution pass that dies mid-run — the #850
// daemon liveness watchdog's SIGKILL (#1122), Ctrl-C, a crash — leaves
// the refs it never reached in unresolved_refs, and the git-scoped fast
// path above never revisits them (it reads only the changed files'
// rows). Those files' call edges were then missing PERMANENTLY, with
// nothing to see except a too-small blast radius, until a full
// re-index. A completed pass deletes every row it processed (resolved
// or not), so any row still present now is such an orphan — or a row
// parked by an older engine whose scoped pass kept unresolvable refs.
// Grind them down with the batched resolver; this also makes a bare
// `codegraph sync` the recovery command for a wedged index. On a
// healthy index this is one COUNT query.
const orphanCount = this.queries.getUnresolvedReferencesCount();
if (orphanCount > 0) {
options.onProgress?.({
phase: 'resolving',
current: 0,
total: orphanCount,
});
await this.resolveReferencesBatched((current, total) => {
options.onProgress?.({
phase: 'resolving',
current,
total,
});
});
}
if (filesChanged || orphanCount > 0) {
// Second pass: chained calls whose method lives on a supertype the
// receiver conforms to (protocol-extension / inherited). Needs the
// implements/extends edges built above (#750).
@@ -655,7 +687,7 @@ export class CodeGraph {
}
// Refresh planner stats + checkpoint the WAL after bulk writes.
if (result.filesAdded > 0 || result.filesModified > 0 || result.filesRemoved > 0) {
if (filesChanged || result.filesRemoved > 0 || orphanCount > 0) {
this.db.runMaintenance();
}
@@ -873,6 +905,16 @@ export class CodeGraph {
return this.resolver.resolveAndPersistBatched(onProgress);
}
/**
* References extracted but not yet resolved into edges. Zero on a healthy
* index — a completed resolution pass consumes every row. Non-zero at rest
* means a pass was interrupted mid-run (killed indexer, crash — #1187), so
* some files' call edges are missing; the next `sync` sweeps them.
*/
getPendingReferenceCount(): number {
return this.queries.getUnresolvedReferencesCount();
}
/**
* Get detected frameworks in the project
*/