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:
co-authored by
Claude Fable 5
parent
7f325134e0
commit
4c15f84aa4
@@ -807,6 +807,9 @@ program
|
||||
const buildInfo = cg.getIndexBuildInfo();
|
||||
const reindexRecommended = cg.isIndexStale();
|
||||
const indexState = cg.getIndexState();
|
||||
// Zero on a healthy index; non-zero at rest means a resolution pass was
|
||||
// interrupted, so some files' call edges are missing (#1187).
|
||||
const pendingRefs = cg.getPendingReferenceCount();
|
||||
|
||||
// JSON output mode
|
||||
if (options.json) {
|
||||
@@ -842,6 +845,10 @@ program
|
||||
// (a run was killed mid-index — the index is truncated) |
|
||||
// 'failed' | null (predates the marker).
|
||||
state: indexState,
|
||||
// References awaiting resolution. Non-zero at rest means an
|
||||
// interrupted resolution pass left edges missing; the next
|
||||
// sync sweeps them (#1187).
|
||||
pendingRefs,
|
||||
},
|
||||
}));
|
||||
cg.destroy();
|
||||
@@ -862,6 +869,9 @@ program
|
||||
} else if (indexState === 'failed') {
|
||||
warn('The last index run failed — results may be incomplete. Re-run "codegraph index".');
|
||||
}
|
||||
if (pendingRefs > 0) {
|
||||
warn(`${formatNumber(pendingRefs)} references from an interrupted run are awaiting resolution — some callers/impact edges are missing. Run "codegraph sync" to resolve them.`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
// Index stats
|
||||
|
||||
+44
-2
@@ -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
|
||||
*/
|
||||
|
||||
@@ -4038,6 +4038,19 @@ export class ToolHandler {
|
||||
);
|
||||
}
|
||||
|
||||
// Non-zero at rest means a resolution pass was interrupted mid-run, so
|
||||
// some files' call/impact edges are missing until the next sync sweeps
|
||||
// the leftovers (#1187). Surface it — an agent trusting an incomplete
|
||||
// blast radius is worse than one that knows to re-sync.
|
||||
const pendingRefs = cg.getPendingReferenceCount();
|
||||
if (pendingRefs > 0) {
|
||||
lines.push(
|
||||
`**Pending resolution:** ⚠ ${pendingRefs} references from an interrupted ` +
|
||||
`index run — some caller/impact edges are missing until the next sync ` +
|
||||
`(any file change triggers it, or run \`codegraph sync\`)`
|
||||
);
|
||||
}
|
||||
|
||||
lines.push('', '**Nodes by Kind:**');
|
||||
|
||||
for (const [kind, count] of Object.entries(stats.nodesByKind)) {
|
||||
|
||||
+24
-5
@@ -968,6 +968,23 @@ export class ReferenceResolver {
|
||||
);
|
||||
}
|
||||
|
||||
// Delete unresolvable refs too — parity with resolveAndPersistBatched.
|
||||
// Keeping them bought nothing: a ref is only ever retried when its file
|
||||
// is re-extracted, which cascade-deletes and re-inserts its rows anyway.
|
||||
// And it broke the #1187 orphan sweep's invariant — after a COMPLETED
|
||||
// pass the table must hold nothing that pass processed, so that any row
|
||||
// still present belongs to an interrupted run and the sweep can key off
|
||||
// a bare row count.
|
||||
if (result.unresolved.length > 0) {
|
||||
this.queries.deleteSpecificResolvedReferences(
|
||||
result.unresolved.map((r) => ({
|
||||
fromNodeId: r.fromNodeId,
|
||||
referenceName: r.referenceName,
|
||||
referenceKind: r.referenceKind,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1152,11 +1169,13 @@ export class ReferenceResolver {
|
||||
// Yield so progress UI can render between batches
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
// If nothing was resolved or removed in this batch, we'd loop forever
|
||||
// on the same rows. Break to avoid infinite loop.
|
||||
if (result.resolved.length === 0 && result.unresolved.length === batch.length) {
|
||||
break;
|
||||
}
|
||||
// NOTE: there used to be an extra early break here when a batch resolved
|
||||
// nothing (`result.unresolved.length === batch.length`). That was wrong:
|
||||
// an all-unresolvable batch still DELETES its rows (progress), yet the
|
||||
// break abandoned every batch after it in the same run — on a repo whose
|
||||
// first 5000 refs are all external/stdlib calls, resolution stopped at
|
||||
// batch one and left the rest of the table as permanent orphans (#1187).
|
||||
// The count-based guard below catches the true no-progress case.
|
||||
|
||||
// Non-progress guard (defense-in-depth). Because we re-read from offset 0
|
||||
// each pass, the unresolved_refs table MUST shrink every iteration — both
|
||||
|
||||
Reference in New Issue
Block a user