gitnexus 1.6.13-rc.72 → 1.6.13-rc.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@ import { LBUG_DIRECTORY } from '../storage/storage-constants.js';
4
4
  import path from 'node:path';
5
5
  import { cliInfo } from './cli-message.js';
6
6
  import { getGitRoot } from '../storage/git.js';
7
- import { acquireIndexLock, requireExclusiveIndexLock } from '../storage/index-lock.js';
7
+ import { acquireIndexLock, requireExclusiveIndexLock, sweepStagingArtifacts, } from '../storage/index-lock.js';
8
8
  import { getStoragePaths, loadMeta, saveMeta } from '../storage/repo-manager.js';
9
9
  import { closeLbug, executeQuery, executeWithReusedStatement, fetchExistingEmbeddingHashes, initLbug, } from '../core/lbug/lbug-adapter.js';
10
10
  import { decideEmbeddingResume, mintInterruptedCheckpoint, mintPartialCheckpoint, mintUnverifiedCountCheckpoint, } from '../core/embedding-checkpoint.js';
@@ -24,9 +24,17 @@ export const embeddingsSyncCommand = async (inputPath) => {
24
24
  // Writes go to the slot's own graph. A shared-store checkout that reads an
25
25
  // immutable commit graph (#3352) takes a private copy first.
26
26
  const lbugPath = path.join(metaDir, LBUG_DIRECTORY);
27
- const lock = await acquireIndexLock(metaDir);
27
+ const lock = await acquireIndexLock(metaDir, { sweep: false });
28
28
  try {
29
29
  requireExclusiveIndexLock(lock, `Cannot acquire the index lock at ${metaDir}; refusing an unlocked embeddings sync.`);
30
+ // Sync writes the published graph and cannot recover a staged generation.
31
+ // Reject even malformed receipts before sweeping staging files or writing.
32
+ const recoveryCheckpoint = (await loadMeta(metaDir))?.embeddingCheckpoint;
33
+ if (recoveryCheckpoint && Object.hasOwn(recoveryCheckpoint, 'recovery')) {
34
+ throw new Error('Cannot sync embeddings: the index checkpoint references staged embeddings. ' +
35
+ 'Run `gitnexus analyze` to recover them first.');
36
+ }
37
+ sweepStagingArtifacts(metaDir);
30
38
  if (!(await ensurePrivateSharedGraph(metaDir, (m) => console.log(` ${m}`)))) {
31
39
  throw new Error('The shared graph this checkout reads is gone. Run gitnexus analyze first.');
32
40
  }
@@ -0,0 +1,170 @@
1
+ /** Isolated strict native reader. Never import this entrypoint into analyze. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import lbug from '@ladybugdb/core';
5
+ import { createLbugDatabase, toNativeSafePath } from '../lbug/lbug-config.js';
6
+ import { FAMILY_SUFFIXES } from '../../storage/embedding-recovery.js';
7
+ import { abortCachedEmbeddingsBuilder, createCachedEmbeddingsBuilder, finalizeCachedEmbeddingsSnapshot, ingestCachedEmbeddingRow, } from './embedding-restore-spill.js';
8
+ /** Native replay and close may checkpoint; only give them disposable copies. */
9
+ function copyRecoveryFamily(dbPath, exportDir) {
10
+ const replayDir = fs.mkdtempSync(path.join(exportDir, 'replay-'));
11
+ const replayPath = path.join(replayDir, path.basename(dbPath));
12
+ const noFollow = fs.constants.O_NOFOLLOW ?? 0;
13
+ const flags = fs.constants.O_RDONLY | noFollow | (fs.constants.O_NONBLOCK ?? 0);
14
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
15
+ for (const suffix of FAMILY_SUFFIXES) {
16
+ const sourcePath = dbPath + suffix;
17
+ let entry;
18
+ try {
19
+ entry = fs.lstatSync(sourcePath, { bigint: true });
20
+ }
21
+ catch (error) {
22
+ if (suffix && error.code === 'ENOENT')
23
+ continue;
24
+ throw error;
25
+ }
26
+ if (!entry.isFile())
27
+ throw new Error('staged embedding family is not a regular file');
28
+ const source = fs.openSync(sourcePath, flags);
29
+ let destination;
30
+ try {
31
+ const opened = fs.fstatSync(source, { bigint: true });
32
+ const current = fs.lstatSync(sourcePath, { bigint: true });
33
+ if (!opened.isFile() ||
34
+ !current.isFile() ||
35
+ (noFollow === 0 && opened.ino === 0n) ||
36
+ opened.dev !== entry.dev ||
37
+ opened.ino !== entry.ino ||
38
+ opened.dev !== current.dev ||
39
+ opened.ino !== current.ino) {
40
+ throw new Error('staged embedding family changed while opening');
41
+ }
42
+ destination = fs.openSync(replayPath + suffix, 'wx', 0o600);
43
+ let copied = 0n;
44
+ for (;;) {
45
+ const bytesRead = fs.readSync(source, buffer, 0, buffer.length, null);
46
+ if (bytesRead === 0)
47
+ break;
48
+ fs.writeFileSync(destination, buffer.subarray(0, bytesRead));
49
+ copied += BigInt(bytesRead);
50
+ }
51
+ const after = fs.fstatSync(source, { bigint: true });
52
+ if (copied !== opened.size ||
53
+ after.size !== opened.size ||
54
+ after.mtimeNs !== opened.mtimeNs ||
55
+ after.ctimeNs !== opened.ctimeNs) {
56
+ throw new Error('staged embedding family changed while copying');
57
+ }
58
+ }
59
+ finally {
60
+ try {
61
+ if (destination !== undefined)
62
+ fs.closeSync(destination);
63
+ }
64
+ finally {
65
+ fs.closeSync(source);
66
+ }
67
+ }
68
+ }
69
+ return replayPath;
70
+ }
71
+ async function extract() {
72
+ const [dbPath, exportDir, dimensionsArg] = process.argv.slice(2);
73
+ const dimensions = Number(dimensionsArg);
74
+ if (!dbPath || !exportDir || !Number.isInteger(dimensions) || dimensions <= 0) {
75
+ throw new Error('invalid staged embedding extraction arguments');
76
+ }
77
+ // The parent owns exportDir and reclaims it even after killing this child.
78
+ const replayPath = copyRecoveryFamily(dbPath, exportDir);
79
+ const builder = createCachedEmbeddingsBuilder({ inMemoryRowLimit: 0, spillDir: exportDir });
80
+ const rejectedNodeIds = new Set();
81
+ let db;
82
+ let conn;
83
+ try {
84
+ // Avoid openLbugConnection's test-fixture lock sweep: a recovery source must
85
+ // never have its WAL removed, even when an external slot resembles a fixture.
86
+ db = createLbugDatabase(lbug, toNativeSafePath(replayPath), { throwOnWalReplayFailure: true });
87
+ conn = new lbug.Connection(db);
88
+ const queried = await conn.query('MATCH (e:CodeEmbedding) RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex, e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding, e.contentHash AS contentHash');
89
+ const results = Array.isArray(queried) ? queried : [queried];
90
+ try {
91
+ if (results.length !== 1)
92
+ throw new Error('unexpected staged embedding query result');
93
+ const result = results[0];
94
+ while (await result.hasNext()) {
95
+ const raw = await result.getNext();
96
+ const rec = raw;
97
+ const nodeId = rec.nodeId ?? rec[0];
98
+ if (typeof nodeId !== 'string' || !nodeId)
99
+ throw new Error('invalid staged embedding node id');
100
+ const chunkIndex = rec.chunkIndex ?? rec[1];
101
+ const startLine = rec.startLine ?? rec[2];
102
+ const endLine = rec.endLine ?? rec[3];
103
+ const embedding = rec.embedding ?? rec[4];
104
+ const contentHash = rec.contentHash ?? rec[5];
105
+ const vector = Array.isArray(embedding) ||
106
+ (ArrayBuffer.isView(embedding) && !(embedding instanceof DataView))
107
+ ? Array.from(embedding)
108
+ : undefined;
109
+ if (!Number.isInteger(chunkIndex) ||
110
+ Number(chunkIndex) < 0 ||
111
+ !Number.isInteger(startLine) ||
112
+ Number(startLine) < 0 ||
113
+ !Number.isInteger(endLine) ||
114
+ Number(endLine) < Number(startLine) ||
115
+ typeof contentHash !== 'string' ||
116
+ !contentHash ||
117
+ !vector ||
118
+ vector.length !== dimensions ||
119
+ vector.some((value) => typeof value !== 'number' ||
120
+ !Number.isFinite(value) ||
121
+ !Number.isFinite(Math.fround(value)))) {
122
+ rejectedNodeIds.add(nodeId);
123
+ continue;
124
+ }
125
+ ingestCachedEmbeddingRow(builder, { nodeId, chunkIndex, startLine, endLine, embedding: vector, contentHash }, true);
126
+ }
127
+ }
128
+ finally {
129
+ for (const result of results)
130
+ await result.close();
131
+ }
132
+ // Both closes must succeed. Suppressed native teardown errors are unsafe.
133
+ await conn.close();
134
+ await db.close();
135
+ const snapshot = finalizeCachedEmbeddingsSnapshot(builder);
136
+ if (snapshot.spill)
137
+ fs.renameSync(snapshot.spill.path, path.join(exportDir, 'vectors.bin'));
138
+ const manifest = {
139
+ version: 1,
140
+ dimensions,
141
+ rows: snapshot.rows,
142
+ rejectedNodeIds: [...rejectedNodeIds],
143
+ };
144
+ fs.writeFileSync(path.join(exportDir, 'manifest.json'), JSON.stringify(manifest), {
145
+ flag: 'wx',
146
+ mode: 0o600,
147
+ });
148
+ }
149
+ catch (err) {
150
+ abortCachedEmbeddingsBuilder(builder);
151
+ // Cleanup is best effort on a rejected source, never used to approve output.
152
+ try {
153
+ await conn?.close();
154
+ }
155
+ catch {
156
+ /* rejected */
157
+ }
158
+ try {
159
+ await db?.close();
160
+ }
161
+ catch {
162
+ /* rejected */
163
+ }
164
+ throw err;
165
+ }
166
+ }
167
+ extract().catch((err) => {
168
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
169
+ process.exitCode = 1;
170
+ });
@@ -0,0 +1,23 @@
1
+ import { type CachedEmbeddingMeta, type CachedEmbeddingsSnapshot } from './embedding-restore-spill.js';
2
+ export interface StagedEmbeddingRecoveryOptions {
3
+ dimensions: number;
4
+ /** Active checkpoint window and any incomplete inherited restore groups. */
5
+ excludedNodeIds?: Iterable<string>;
6
+ timeoutMs?: number;
7
+ }
8
+ export interface StagedEmbeddingExport {
9
+ version: 1;
10
+ dimensions: number;
11
+ rows: CachedEmbeddingMeta[];
12
+ rejectedNodeIds: string[];
13
+ }
14
+ /** A contiguous prefix is safe only when its node is outside every unsafe window. */
15
+ export declare function validateRecoveredNodeGroups(rows: readonly CachedEmbeddingMeta[], excludedNodeIds?: ReadonlySet<string>): Set<string>;
16
+ /** Whole recovered nodes replace whole published groups; vectors stay in bounded batches. */
17
+ export declare function mergeRecoveredEmbeddings(live: CachedEmbeddingsSnapshot, recovered: CachedEmbeddingsSnapshot): CachedEmbeddingsSnapshot;
18
+ /**
19
+ * Caller must validate checkpoint identity, schema, exact generation, and hold the
20
+ * index lock. A subprocess contains native WAL replay/query/destructor failures.
21
+ * A failed strict open is never retried with WAL removed or validation disabled.
22
+ */
23
+ export declare function recoverStagedEmbeddings(dbPath: string, options: StagedEmbeddingRecoveryOptions): Promise<CachedEmbeddingsSnapshot>;
@@ -0,0 +1,183 @@
1
+ /** Recover paid embedding rows without opening an interrupted native DB in analyze. */
2
+ import { spawn } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { abortCachedEmbeddingsBuilder, createCachedEmbeddingsBuilder, EmbeddingSpillReader, emptyCachedEmbeddingsSnapshot, finalizeCachedEmbeddingsSnapshot, ingestCachedEmbeddingRow, materializeCachedEmbeddings, } from './embedding-restore-spill.js';
8
+ const RESTORE_BATCH_SIZE = 200;
9
+ const DEFAULT_EXTRACTION_TIMEOUT_MS = 120_000;
10
+ /** A contiguous prefix is safe only when its node is outside every unsafe window. */
11
+ export function validateRecoveredNodeGroups(rows, excludedNodeIds = new Set()) {
12
+ const groups = new Map();
13
+ for (const row of rows) {
14
+ let group = groups.get(row.nodeId);
15
+ if (!group) {
16
+ group = { hash: row.contentHash, ordinals: new Set(), invalid: false };
17
+ groups.set(row.nodeId, group);
18
+ }
19
+ if (typeof row.nodeId !== 'string' ||
20
+ !row.nodeId ||
21
+ typeof row.contentHash !== 'string' ||
22
+ !row.contentHash ||
23
+ row.contentHash !== group.hash ||
24
+ !Number.isInteger(row.chunkIndex) ||
25
+ row.chunkIndex < 0 ||
26
+ group.ordinals.has(row.chunkIndex) ||
27
+ !Number.isInteger(row.startLine) ||
28
+ row.startLine < 0 ||
29
+ !Number.isInteger(row.endLine) ||
30
+ row.endLine < row.startLine)
31
+ group.invalid = true;
32
+ group.ordinals.add(row.chunkIndex);
33
+ }
34
+ const accepted = new Set();
35
+ for (const [nodeId, group] of groups) {
36
+ if (group.invalid || excludedNodeIds.has(nodeId))
37
+ continue;
38
+ // Unique ordinals with max n-1 and zero present have no holes.
39
+ if (!group.ordinals.has(0))
40
+ continue;
41
+ if ([...group.ordinals].some((ordinal) => ordinal >= group.ordinals.size))
42
+ continue;
43
+ accepted.add(nodeId);
44
+ }
45
+ return accepted;
46
+ }
47
+ /** Whole recovered nodes replace whole published groups; vectors stay in bounded batches. */
48
+ export function mergeRecoveredEmbeddings(live, recovered) {
49
+ const builder = createCachedEmbeddingsBuilder({ inMemoryRowLimit: 0 });
50
+ try {
51
+ appendSnapshotRows(live, live.rows.filter((row) => !recovered.embeddingNodeIds.has(row.nodeId)), builder);
52
+ appendSnapshotRows(recovered, recovered.rows, builder);
53
+ return finalizeCachedEmbeddingsSnapshot(builder);
54
+ }
55
+ catch (err) {
56
+ abortCachedEmbeddingsBuilder(builder);
57
+ throw err;
58
+ }
59
+ }
60
+ function appendSnapshotRows(snapshot, rows, builder) {
61
+ const reader = snapshot.spill ? new EmbeddingSpillReader(snapshot.spill) : undefined;
62
+ try {
63
+ for (let i = 0; i < rows.length; i += RESTORE_BATCH_SIZE) {
64
+ const batch = materializeCachedEmbeddings(snapshot, rows.slice(i, i + RESTORE_BATCH_SIZE), reader);
65
+ for (const row of batch)
66
+ ingestCachedEmbeddingRow(builder, row, true);
67
+ }
68
+ }
69
+ finally {
70
+ reader?.close();
71
+ }
72
+ }
73
+ /**
74
+ * Caller must validate checkpoint identity, schema, exact generation, and hold the
75
+ * index lock. A subprocess contains native WAL replay/query/destructor failures.
76
+ * A failed strict open is never retried with WAL removed or validation disabled.
77
+ */
78
+ export async function recoverStagedEmbeddings(dbPath, options) {
79
+ if (!Number.isInteger(options.dimensions) || options.dimensions <= 0) {
80
+ throw new Error('invalid staged embedding dimensions');
81
+ }
82
+ const dbStat = fs.lstatSync(dbPath);
83
+ if (!dbStat.isFile() || dbStat.isSymbolicLink())
84
+ throw new Error('staged embedding DB is not a regular file');
85
+ const exportDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-stage-export-'));
86
+ try {
87
+ await runExtractionChild(dbPath, exportDir, options);
88
+ const manifestPath = path.join(exportDir, 'manifest.json');
89
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
90
+ if (manifest.version !== 1 ||
91
+ manifest.dimensions !== options.dimensions ||
92
+ !Array.isArray(manifest.rows) ||
93
+ !Array.isArray(manifest.rejectedNodeIds) ||
94
+ manifest.rejectedNodeIds.some((id) => typeof id !== 'string') ||
95
+ manifest.rows.some((row, i) => !row || row.vectorIndex !== i))
96
+ throw new Error('invalid staged embedding export manifest');
97
+ if (manifest.rows.length === 0)
98
+ return emptyCachedEmbeddingsSnapshot();
99
+ const spill = {
100
+ path: path.join(exportDir, 'vectors.bin'),
101
+ dims: options.dimensions,
102
+ rowCount: manifest.rows.length,
103
+ };
104
+ const vectorStat = fs.lstatSync(spill.path);
105
+ if (!vectorStat.isFile() ||
106
+ vectorStat.isSymbolicLink() ||
107
+ vectorStat.size !== 12 + spill.rowCount * spill.dims * 4) {
108
+ throw new Error('invalid staged embedding export size');
109
+ }
110
+ const excluded = new Set(options.excludedNodeIds ?? []);
111
+ for (const nodeId of manifest.rejectedNodeIds)
112
+ excluded.add(nodeId);
113
+ const accepted = validateRecoveredNodeGroups(manifest.rows, excluded);
114
+ const exported = {
115
+ rows: manifest.rows,
116
+ embeddings: [],
117
+ embeddingNodeIds: accepted,
118
+ spill,
119
+ };
120
+ const builder = createCachedEmbeddingsBuilder({ inMemoryRowLimit: 0 });
121
+ const reader = new EmbeddingSpillReader(spill);
122
+ try {
123
+ const rows = manifest.rows.filter((row) => accepted.has(row.nodeId));
124
+ for (let i = 0; i < rows.length; i += RESTORE_BATCH_SIZE) {
125
+ for (const row of materializeCachedEmbeddings(exported, rows.slice(i, i + RESTORE_BATCH_SIZE), reader)) {
126
+ if (row.embedding.length !== options.dimensions ||
127
+ row.embedding.some((value) => !Number.isFinite(value))) {
128
+ throw new Error('invalid staged embedding vector');
129
+ }
130
+ ingestCachedEmbeddingRow(builder, row, true);
131
+ }
132
+ }
133
+ return finalizeCachedEmbeddingsSnapshot(builder);
134
+ }
135
+ catch (err) {
136
+ abortCachedEmbeddingsBuilder(builder);
137
+ throw err;
138
+ }
139
+ finally {
140
+ reader.close();
141
+ }
142
+ }
143
+ finally {
144
+ fs.rmSync(exportDir, { recursive: true, force: true });
145
+ }
146
+ }
147
+ function runExtractionChild(dbPath, exportDir, options) {
148
+ const compiledPath = fileURLToPath(new URL('./staged-embedding-recovery-child.js', import.meta.url));
149
+ const sourcePath = compiledPath.replace(/\.js$/, '.ts');
150
+ const childPath = fs.existsSync(compiledPath) ? compiledPath : sourcePath;
151
+ const args = childPath.endsWith('.ts')
152
+ ? ['--import', import.meta.resolve('tsx'), childPath]
153
+ : [childPath];
154
+ args.push(dbPath, exportDir, String(options.dimensions));
155
+ return new Promise((resolve, reject) => {
156
+ const child = spawn(process.execPath, args, {
157
+ stdio: ['ignore', 'ignore', 'pipe'],
158
+ windowsHide: true,
159
+ });
160
+ let stderr = '';
161
+ let timedOut = false;
162
+ child.stderr.on('data', (chunk) => {
163
+ if (stderr.length < 4096)
164
+ stderr += chunk.toString().slice(0, 4096 - stderr.length);
165
+ });
166
+ const timer = setTimeout(() => {
167
+ timedOut = true;
168
+ // A process boundary is safe to kill even while native code is executing.
169
+ child.kill('SIGKILL');
170
+ }, options.timeoutMs ?? DEFAULT_EXTRACTION_TIMEOUT_MS);
171
+ child.once('error', (err) => {
172
+ clearTimeout(timer);
173
+ reject(err);
174
+ });
175
+ child.once('close', (code, signal) => {
176
+ clearTimeout(timer);
177
+ if (code === 0 && !signal && !timedOut)
178
+ resolve();
179
+ else
180
+ reject(new Error(`staged embedding extraction failed${timedOut ? ' (timeout)' : ` (${signal ?? code})`}${stderr ? `: ${stderr.trim()}` : ''}`));
181
+ });
182
+ });
183
+ }
@@ -17,7 +17,8 @@ import fs from 'fs/promises';
17
17
  import { constants as fsConstants, existsSync } from 'node:fs';
18
18
  import { randomUUID } from 'node:crypto';
19
19
  import { retryRename } from '../storage/fs-atomic.js';
20
- import { acquireIndexLock, requireExclusiveIndexLock } from '../storage/index-lock.js';
20
+ import { acquireIndexLock, requireExclusiveIndexLock, sweepStagingArtifacts, } from '../storage/index-lock.js';
21
+ import { resolveEmbeddingRecovery } from '../storage/embedding-recovery.js';
21
22
  import { invalidateNodeWorkspacePackages } from './ingestion/import-resolvers/node-workspace-packages.js';
22
23
  import { logNameFallbackSummary, summarizeNameFallback, countCallsByLanguage, } from './ingestion/scope-resolution/name-fallback-summary.js';
23
24
  import { runPipelineFromRepo } from './ingestion/pipeline.js';
@@ -657,6 +658,8 @@ export async function runFullAnalysis(repoPath, options, callbacks, runnerIdenti
657
658
  const log = (msg) => callbacks.onLog?.(stripControlCharacters(msg));
658
659
  const acquireOpts = {
659
660
  log,
661
+ // Resolve and validate the canonical slot under the lock before cleanup.
662
+ sweep: false,
660
663
  onWaitStart: () => callbacks.onProgress('lock', 0, 'Waiting for another analyze to finish on this index…'),
661
664
  };
662
665
  let writeTarget = await resolveWriteTarget(repoPath, options);
@@ -702,6 +705,7 @@ export async function runFullAnalysis(repoPath, options, callbacks, runnerIdenti
702
705
  const flatShared = writeTarget.placement.branch ? undefined : writeTarget.sharedStore;
703
706
  if (flatShared)
704
707
  await seedSharedSlot(flatShared, repoPath, log);
708
+ sweepStagingArtifacts(writeTarget.metaDir, log);
705
709
  const slotToLeave = options.noShare ? await optedInSlotToLeave(repoPath) : undefined;
706
710
  const result = await runFullAnalysisInner(repoPath, options, callbacks, writeTarget, contentRetention, runnerIdentityAtBootstrap);
707
711
  if (flatShared) {
@@ -1144,7 +1148,11 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, c
1144
1148
  }
1145
1149
  if (decision.action === 'abort')
1146
1150
  throw new Error(decision.error);
1147
- log(decision.log);
1151
+ log(decision.action === 'resume' && checkpoint.recovery
1152
+ ? `Previous analyze recorded an embedding checkpoint (${checkpoint.nodesProcessed}/` +
1153
+ `${checkpoint.totalNodes} nodes); validating staged vectors before retrying ` +
1154
+ `${decision.pendingNodeIds.size} pending node(s).`
1155
+ : decision.log);
1148
1156
  if (options.dropEmbeddings) {
1149
1157
  // --drop-embeddings has always implied a rebuild here; the decision only
1150
1158
  // covers the marker.
@@ -1877,6 +1885,68 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, c
1877
1885
  }
1878
1886
  }
1879
1887
  }
1888
+ // A checkpoint's pending decision and its paid, complete vectors are
1889
+ // independent: --force discards the former but can still reuse the latter.
1890
+ // Select only the explicitly referenced generation, never an orphan by age.
1891
+ const stagedRecovery = resolveEmbeddingRecovery(metaDir, existingMeta?.embeddingCheckpoint);
1892
+ const stagedCheckpoint = existingMeta?.embeddingCheckpoint;
1893
+ const inheritedUnsafeNodeIds = new Set([
1894
+ ...pendingEmbeddingNodeIds,
1895
+ ...(stagedRecovery?.unsafeNodeIds ?? []),
1896
+ ]);
1897
+ if (shouldLoadCache && !options.dropEmbeddings && stagedRecovery && stagedCheckpoint) {
1898
+ if (!embeddingIdentityForRun) {
1899
+ const { resolveEmbeddingIdentity } = await import('./embeddings/embedding-identity.js');
1900
+ embeddingIdentityForRun = resolveEmbeddingIdentity();
1901
+ }
1902
+ const marker = stagedCheckpoint;
1903
+ const matchesIdentity = marker.model === embeddingIdentityForRun.model &&
1904
+ marker.dimensions === embeddingIdentityForRun.dimensions &&
1905
+ marker.provider === embeddingIdentityForRun.provider;
1906
+ if (matchesIdentity && stagedRecovery.schemaFingerprint === SCHEMA_FINGERPRINT) {
1907
+ // A force-discarded pending decision must not turn a known incomplete
1908
+ // inherited group into a reusable cache merely because its hash matches.
1909
+ if (inheritedUnsafeNodeIds.size > 0) {
1910
+ const rows = cachedSnapshot.rows.filter((row) => !inheritedUnsafeNodeIds.has(row.nodeId));
1911
+ cachedSnapshot = {
1912
+ ...cachedSnapshot,
1913
+ rows,
1914
+ embeddingNodeIds: new Set(rows.map((row) => row.nodeId)),
1915
+ };
1916
+ }
1917
+ let recovered;
1918
+ try {
1919
+ const { recoverStagedEmbeddings, mergeRecoveredEmbeddings } = await import('./embeddings/staged-embedding-recovery.js');
1920
+ recovered = await recoverStagedEmbeddings(stagedRecovery.dbPath, {
1921
+ dimensions: embeddingIdentityForRun.dimensions,
1922
+ excludedNodeIds: stagedRecovery.unsafeNodeIds,
1923
+ });
1924
+ const liveCacheDims = snapshotEmbeddingDims(cachedSnapshot);
1925
+ if (liveCacheDims !== undefined && liveCacheDims !== embeddingIdentityForRun.dimensions) {
1926
+ log(`Embedding dimensions changed (${liveCacheDims}d -> ` +
1927
+ `${embeddingIdentityForRun.dimensions}d), discarding published cache`);
1928
+ discardCachedEmbeddings();
1929
+ }
1930
+ if (recovered.rows.length > 0) {
1931
+ const merged = mergeRecoveredEmbeddings(cachedSnapshot, recovered);
1932
+ disposeEmbeddingSpill(cachedSnapshot.spill);
1933
+ adoptCachedEmbeddings(merged);
1934
+ }
1935
+ log(`Recovered ${recovered.rows.length} complete staged embedding chunk(s) ` +
1936
+ `for ${recovered.embeddingNodeIds.size} node(s); unchanged content can reuse them.`);
1937
+ }
1938
+ catch (err) {
1939
+ log(`Warning: could not recover staged embeddings (${err.message}); ` +
1940
+ 'the retry will regenerate missing chunks.');
1941
+ }
1942
+ finally {
1943
+ disposeEmbeddingSpill(recovered?.spill);
1944
+ }
1945
+ }
1946
+ else {
1947
+ log('Staged embedding identity or schema changed; its vectors will not be reused.');
1948
+ }
1949
+ }
1880
1950
  // ── Load incremental parse cache ──────────────────────────────────
1881
1951
  // Content-addressed: `--force` reuses parser shards; `useParseCache: false`
1882
1952
  // stages a new generation under a run-unique parse-rebuild.* dir and publishes
@@ -3516,6 +3586,14 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, c
3516
3586
  embeddingIdentityForRun = resolveEmbeddingIdentity();
3517
3587
  }
3518
3588
  const embeddingIdentity = embeddingIdentityForRun;
3589
+ const stagedRecoveryEnabled = useAtomicSwap && isManualCheckpointEnabled();
3590
+ if (useAtomicSwap && !stagedRecoveryEnabled) {
3591
+ log('Manual WAL checkpoints are disabled; new staged work cannot be recovered after interruption. ' +
3592
+ 'Any previous durable recovery source is retained until publication.');
3593
+ }
3594
+ const unsafeRecoveryNodeIds = new Set([...inheritedUnsafeNodeIds, ...restoreFailedNodeIds]);
3595
+ let activeWindowNodeIds = [];
3596
+ let recoveryGenerationDurable = false;
3519
3597
  // Build a Map<nodeId, contentHash> from cached embeddings for incremental mode
3520
3598
  let existingEmbeddings;
3521
3599
  if (cachedSnapshot.embeddingNodeIds.size > 0) {
@@ -3560,6 +3638,13 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, c
3560
3638
  stagedCheckpointEmbeddingCount = embeddings;
3561
3639
  }
3562
3640
  const latestMeta = (await loadMeta(metaDir)) ?? existingMeta;
3641
+ // An in-place write or manual-checkpoint opt-out cannot create a new
3642
+ // recoverable staged generation. Keep the complete previous receipt:
3643
+ // updated progress or unsafe nodes would describe different source bytes.
3644
+ const preservedRecoveryCheckpoint = !stagedRecoveryEnabled &&
3645
+ resolveEmbeddingRecovery(metaDir, latestMeta?.embeddingCheckpoint)
3646
+ ? latestMeta?.embeddingCheckpoint
3647
+ : undefined;
3563
3648
  // First-ever analyze of this repo: no meta exists on disk yet (the
3564
3649
  // pre-wipe dirty stamp only fires when one does). Mint the minimum
3565
3650
  // RepoMeta requires, with `lastCommit: ''` — never `currentCommit` —
@@ -3570,6 +3655,7 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, c
3570
3655
  lastCommit: '',
3571
3656
  indexedAt: new Date().toISOString(),
3572
3657
  };
3658
+ const interrupted = mintInterruptedCheckpoint(embeddingIdentity, checkpoint, pendingNodeIds);
3573
3659
  await saveMeta(metaDir, {
3574
3660
  ...base,
3575
3661
  ...(embeddings === undefined || buildPath !== lbugPath
@@ -3577,7 +3663,18 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, c
3577
3663
  : { stats: { ...base.stats, embeddings } }),
3578
3664
  // Written by a run that is still IN FLIGHT — see the `kind` doc in
3579
3665
  // repo-manager.ts.
3580
- embeddingCheckpoint: mintInterruptedCheckpoint(embeddingIdentity, checkpoint, pendingNodeIds),
3666
+ embeddingCheckpoint: preservedRecoveryCheckpoint ?? {
3667
+ ...interrupted,
3668
+ ...(stagedRecoveryEnabled
3669
+ ? {
3670
+ recovery: {
3671
+ stagingFile: path.basename(buildPath),
3672
+ schemaFingerprint: SCHEMA_FINGERPRINT,
3673
+ unsafeNodeIds: [...unsafeRecoveryNodeIds],
3674
+ },
3675
+ }
3676
+ : {}),
3677
+ },
3581
3678
  });
3582
3679
  };
3583
3680
  embeddingResult = await runEmbeddingPipeline(executeQuery, executeWithReusedStatement, (p) => {
@@ -3591,7 +3688,21 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, c
3591
3688
  }, {}, cachedSnapshot.embeddingNodeIds.size > 0 ? cachedSnapshot.embeddingNodeIds : undefined, existingEmbeddings, {
3592
3689
  forceReembedNodeIds: pendingEmbeddingNodeIds,
3593
3690
  onCheckpointWindowStart: async ({ nodeIds, ...checkpoint }) => {
3691
+ const handoff = stagedRecoveryEnabled && !recoveryGenerationDurable;
3692
+ if (handoff) {
3693
+ if (!(await checkpointOnce())) {
3694
+ throw new Error('Could not checkpoint restored embeddings before recovery handoff.');
3695
+ }
3696
+ recoveryGenerationDurable = true;
3697
+ }
3698
+ activeWindowNodeIds = nodeIds;
3699
+ for (const id of nodeIds)
3700
+ unsafeRecoveryNodeIds.add(id);
3594
3701
  await saveEmbeddingCheckpoint(checkpoint, nodeIds);
3702
+ // Reclaim the old source only after the new durable generation's
3703
+ // reference is saved. Later windows retain this same generation.
3704
+ if (handoff)
3705
+ sweepStagingArtifacts(metaDir, log);
3595
3706
  },
3596
3707
  // ── The mid-run count is a DIAGNOSTIC, not a gate (#2790) ──────
3597
3708
  // This used to run the count query bare. THIS callback's rejection
@@ -3605,7 +3716,13 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, c
3605
3716
  // touch stats.embeddings" signal — so the checkpoint still lands,
3606
3717
  // with whatever count is already on disk left alone.
3607
3718
  onCheckpoint: async (checkpoint) => {
3608
- await checkpointOnce();
3719
+ const durable = await checkpointOnce();
3720
+ if (stagedRecoveryEnabled && !durable) {
3721
+ throw new Error('Could not checkpoint the completed embedding window for recovery.');
3722
+ }
3723
+ for (const id of activeWindowNodeIds)
3724
+ unsafeRecoveryNodeIds.delete(id);
3725
+ activeWindowNodeIds = [];
3609
3726
  const measured = await measurePersistedEmbeddingCount(executeQuery);
3610
3727
  if (measured.kind === 'unknown') {
3611
3728
  log(`Warning: could not measure persisted embeddings at the embedding checkpoint ` +
@@ -4022,6 +4139,7 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, c
4022
4139
  // is a crash-safety improvement: a failed swap leaves the previous index
4023
4140
  // live and the next run recovers via the full-rebuild path.
4024
4141
  await saveMeta(metaDir, meta);
4142
+ sweepStagingArtifacts(metaDir, log);
4025
4143
  // Registry freshness is published only after the graph and its metadata.
4026
4144
  // A failed close, swap, or metadata save must leave the previous registry
4027
4145
  // receipt intact, just as it leaves the cache unpublished.
@@ -4217,7 +4335,11 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, c
4217
4335
  // rethrow below is the surface, and the lock's sweep remains the backstop.
4218
4336
  if (useAtomicSwap && buildPath !== lbugPath) {
4219
4337
  try {
4220
- await wipeLbugDbFiles(buildPath);
4338
+ const recovery = resolveEmbeddingRecovery(metaDir, (await loadMeta(metaDir))?.embeddingCheckpoint);
4339
+ // Both paths belong to this locked slot. The validated generation
4340
+ // basename identifies the same file even through a directory alias.
4341
+ if (recovery?.stagingFile !== path.basename(buildPath))
4342
+ await wipeLbugDbFiles(buildPath);
4221
4343
  }
4222
4344
  catch {
4223
4345
  /* swallow — orphan reclamation must never mask the real failure */
@@ -4230,6 +4352,10 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, c
4230
4352
  // IndexLockTimeoutError and other domain failures with `instanceof`.
4231
4353
  recordLiveIndexMutationRisk(err);
4232
4354
  }
4355
+ if (/max(?:imum)?(?: database| db)? size|database size limit|maxDBSize/i.test(String(err))) {
4356
+ log('The database size limit was reached. Set GITNEXUS_LBUG_MAX_DB_SIZE to a larger ' +
4357
+ 'byte limit before retrying analyze; retained complete embeddings can be reused.');
4358
+ }
4233
4359
  throw err;
4234
4360
  }
4235
4361
  }
@@ -72,6 +72,7 @@ export interface SpringAopMetadata {
72
72
  * shared decoder. Other DECLARES edges (for example Spring Bean factories)
73
73
  * and malformed/forward-version evidence are ignored. Query failures are
74
74
  * fail-soft because older or partially upgraded indexes must remain readable.
75
+ * Corrupt identities propagate to the context/impact integrity error boundary.
75
76
  */
76
77
  export declare function querySpringAopMetadata(lbugPath: string, symbolId: string, symbolType: string): Promise<SpringAopMetadata | undefined>;
77
78
  export {};