From 57a0edd9b90e6277ac0c7051fe0184ef070f620b Mon Sep 17 00:00:00 2001 From: Eva Date: Thu, 23 Jul 2026 02:05:28 +0700 Subject: [PATCH 01/10] fix(analyze): restart staged embeddings from empty table --- gitnexus/src/core/run-analyze.ts | 67 ++++++- gitnexus/test/unit/run-analyze-staged.test.ts | 172 +++++++++++++++++- 2 files changed, 236 insertions(+), 3 deletions(-) diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index fd28daaa3e..d61f6a3281 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -1139,9 +1139,16 @@ const runFullAnalysisImpl = async ( throw new Error('`--staged` cannot be combined with `--repair-fts`; repair is in-place only.'); } + let stagedWorkspaceResumed = false; + let canonicalMetaAtStageStart: RepoMeta | null = null; if (stagedPaths) { - const canonicalMeta = await loadMeta(canonicalMetaDir); - const prepared = await prepareStagedWorkspace(stagedPaths, canonicalMeta, repositorySource); + canonicalMetaAtStageStart = await loadMeta(canonicalMetaDir); + const prepared = await prepareStagedWorkspace( + stagedPaths, + canonicalMetaAtStageStart, + repositorySource, + ); + stagedWorkspaceResumed = prepared.resumed; log( prepared.resumed ? 'Resuming the existing staged generation; the canonical index remains untouched.' @@ -1160,6 +1167,62 @@ const runFullAnalysisImpl = async ( // stores, inspect via LadybugDB, or touch sidecars until every invariant // needed for a surgical incremental write is established. let existingMeta = await loadMeta(metaDir); + if (stagedPaths) { + const stagedCompletedCandidate = + stagedWorkspaceResumed && + !!existingMeta && + !existingMeta.incrementalInProgress && + !existingMeta.embeddingCheckpoint && + existingMeta.lastCommit === currentCommit && + (!canonicalMetaAtStageStart || + canonicalMetaAtStageStart.lastCommit !== existingMeta.lastCommit || + canonicalMetaAtStageStart.indexedAt !== existingMeta.indexedAt); + + // electric.4-.7 proved that restoring or resuming embedding rows inside a + // staged copy is not a safe compatibility surface. Keep the useful part of + // staged analyze (isolated full generation + validated promotion), but make + // every semantic refresh choose an empty-table rebuild explicitly. A clean, + // already-finalized stage remains eligible for the fast-path promotion + // below; it is complete derived state, not an embedding checkpoint. + if (stagedCompletedCandidate) { + // A complete stage that died immediately before promotion should be + // promoted, even when the original invocation included --force. It is + // immutable validated output, not a cache/resume surface. + options = { ...options, force: false }; + } else { + if (existingMeta?.embeddingCheckpoint && !options.dropEmbeddings) { + throw new Error( + 'Staged embedding checkpoint resume is disabled. The canonical index is unchanged. ' + + 'Preserve the staged artifact for forensics, or restart it as a clean isolated ' + + 'generation with `--staged --embeddings --drop-embeddings`.', + ); + } + if (existingMeta && options.embeddings && !options.dropEmbeddings) { + throw new Error( + 'Staged semantic refresh cannot reuse an existing embedding table. The canonical ' + + 'index is unchanged. Re-run with `--staged --embeddings --drop-embeddings` to ' + + 'regenerate embeddings from an empty isolated table.', + ); + } + if ( + (existingMeta?.stats?.embeddings ?? 0) > 0 && + !options.embeddings && + !options.dropEmbeddings + ) { + throw new Error( + 'Staged analyze cannot preserve an existing embedding corpus safely. The canonical ' + + 'index is unchanged. Choose `--staged --embeddings --drop-embeddings` for a clean ' + + 'semantic regeneration, or `--staged --drop-embeddings` to build graph and FTS only.', + ); + } + if (options.dropEmbeddings || (options.embeddings && !existingMeta)) { + // The target is the isolated staged DB. Forcing a full write wipes only + // that disposable copy; the canonical generation stays readable until + // the final integrity scan and journaled promotion both succeed. + options = { ...options, force: true }; + } + } + } if (options.incrementalOnly) { if (!existingMeta) { incrementalOnlyStop('no existing index metadata is available'); diff --git a/gitnexus/test/unit/run-analyze-staged.test.ts b/gitnexus/test/unit/run-analyze-staged.test.ts index 039edec6e5..2ebced0856 100644 --- a/gitnexus/test/unit/run-analyze-staged.test.ts +++ b/gitnexus/test/unit/run-analyze-staged.test.ts @@ -27,10 +27,16 @@ const repositoryIdentity = (repo: string): RepositorySourceIdentity => ({ describe('runFullAnalysis --staged', () => { let priorHome: string | undefined; let priorInstallPolicy: string | undefined; + let priorEmbeddingUrl: string | undefined; + let priorEmbeddingModel: string | undefined; + let priorEmbeddingDims: string | undefined; beforeEach(() => { priorHome = process.env.GITNEXUS_HOME; priorInstallPolicy = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL; + priorEmbeddingUrl = process.env.GITNEXUS_EMBEDDING_URL; + priorEmbeddingModel = process.env.GITNEXUS_EMBEDDING_MODEL; + priorEmbeddingDims = process.env.GITNEXUS_EMBEDDING_DIMS; process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = 'never'; }); @@ -39,11 +45,175 @@ describe('runFullAnalysis --staged', () => { else process.env.GITNEXUS_HOME = priorHome; if (priorInstallPolicy === undefined) delete process.env.GITNEXUS_LBUG_EXTENSION_INSTALL; else process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = priorInstallPolicy; + if (priorEmbeddingUrl === undefined) delete process.env.GITNEXUS_EMBEDDING_URL; + else process.env.GITNEXUS_EMBEDDING_URL = priorEmbeddingUrl; + if (priorEmbeddingModel === undefined) delete process.env.GITNEXUS_EMBEDDING_MODEL; + else process.env.GITNEXUS_EMBEDDING_MODEL = priorEmbeddingModel; + if (priorEmbeddingDims === undefined) delete process.env.GITNEXUS_EMBEDDING_DIMS; + else process.env.GITNEXUS_EMBEDDING_DIMS = priorEmbeddingDims; + vi.unstubAllGlobals(); await Promise.all( tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), ); }); + it('refuses staged embedding preservation unless clean regeneration is explicit', async () => { + const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-no-restore-')); + tempDirs.push(repo); + process.env.GITNEXUS_HOME = path.join(repo, '.registry-home'); + await fs.writeFile(path.join(repo, 'index.ts'), 'export const value = 1;\n'); + execFileSync('git', ['init'], { cwd: repo }); + execFileSync('git', ['add', 'index.ts'], { cwd: repo }); + execFileSync( + 'git', + ['-c', 'user.name=test', '-c', 'user.email=test@test', 'commit', '-m', 'initial'], + { cwd: repo }, + ); + + await runFullAnalysis(repo, { skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} }); + const canonical = getStoragePaths(repo); + const meta = await loadMeta(canonical.storagePath); + if (!meta) throw new Error('expected canonical metadata'); + await saveMeta(canonical.storagePath, { + ...meta, + stats: { ...meta.stats, embeddings: 1 }, + }); + const before = await fs.stat(canonical.lbugPath); + + await expect( + runFullAnalysis( + repo, + { staged: true, embeddings: true, skipAgentsMd: true, skipSkills: true }, + { onProgress: () => {} }, + ), + ).rejects.toThrow(/--staged --embeddings --drop-embeddings/); + + const after = await fs.stat(canonical.lbugPath); + expect(after.ino).toBe(before.ino); + expect(after.mtimeMs).toBe(before.mtimeMs); + }); + + it('regenerates staged embeddings from an empty isolated table when drop is explicit', async () => { + const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-clean-embedding-')); + tempDirs.push(repo); + process.env.GITNEXUS_HOME = path.join(repo, '.registry-home'); + process.env.GITNEXUS_EMBEDDING_URL = 'http://test.invalid/v1'; + process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model'; + process.env.GITNEXUS_EMBEDDING_DIMS = String(EMBEDDING_DIMS); + const vector = new Array(EMBEDDING_DIMS).fill(0.125); + vi.stubGlobal( + 'fetch', + vi.fn(async (_input, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) as { input?: unknown[] }; + const count = Array.isArray(body.input) ? body.input.length : 1; + return { + ok: true, + json: async () => ({ + data: Array.from({ length: count }, () => ({ embedding: vector })), + }), + }; + }), + ); + await fs.writeFile( + path.join(repo, 'index.ts'), + 'export function cleanStageEmbedding() { return 1; }\n', + ); + execFileSync('git', ['init'], { cwd: repo }); + execFileSync('git', ['add', 'index.ts'], { cwd: repo }); + execFileSync( + 'git', + ['-c', 'user.name=test', '-c', 'user.email=test@test', 'commit', '-m', 'initial'], + { cwd: repo }, + ); + + await runFullAnalysis(repo, { skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} }); + const canonical = getStoragePaths(repo); + const meta = await loadMeta(canonical.storagePath); + if (!meta) throw new Error('expected canonical metadata'); + await saveMeta(canonical.storagePath, { + ...meta, + stats: { ...meta.stats, embeddings: 1 }, + }); + + await runFullAnalysis( + repo, + { + staged: true, + embeddings: true, + dropEmbeddings: true, + skipAgentsMd: true, + skipSkills: true, + }, + { onProgress: () => {} }, + ); + + const finalMeta = await loadMeta(canonical.storagePath); + expect(finalMeta?.stats?.embeddings).toBeGreaterThan(0); + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + await adapter.initLbugReadOnlyNonRecovering(canonical.lbugPath); + try { + await expect(adapter.inspectEmbeddingIntegrity()).resolves.toMatchObject({ + physicalRows: finalMeta?.stats?.embeddings, + emptyIdRows: 0, + emptyNodeIdRows: 0, + invalidChunkRows: 0, + noncanonicalIdRows: 0, + duplicateIdRows: 0, + duplicateSemanticRows: 0, + orphanRows: 0, + wrongDimensionRows: 0, + }); + } finally { + await adapter.closeLbug(); + } + const staged = getStagedAnalyzePaths(canonical.lbugPath, canonical.storagePath); + await expect(fs.access(staged.stageRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + }, 120_000); + + it('never resumes a staged embedding checkpoint without an explicit clean rebuild', async () => { + const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-checkpoint-refusal-')); + tempDirs.push(repo); + process.env.GITNEXUS_HOME = path.join(repo, '.registry-home'); + await fs.writeFile(path.join(repo, 'index.ts'), 'export const value = 1;\n'); + execFileSync('git', ['init'], { cwd: repo }); + execFileSync('git', ['add', 'index.ts'], { cwd: repo }); + execFileSync( + 'git', + ['-c', 'user.name=test', '-c', 'user.email=test@test', 'commit', '-m', 'initial'], + { cwd: repo }, + ); + + await runFullAnalysis(repo, { skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} }); + const canonical = getStoragePaths(repo); + const canonicalMeta = await loadMeta(canonical.storagePath); + if (!canonicalMeta) throw new Error('expected canonical metadata'); + const canonicalBefore = await fs.stat(canonical.lbugPath); + const staged = getStagedAnalyzePaths(canonical.lbugPath, canonical.storagePath); + await prepareStagedWorkspace(staged, canonicalMeta, repositoryIdentity(repo)); + await saveMeta(staged.stagedMetaDir, { + ...canonicalMeta, + embeddingCheckpoint: { + at: new Date().toISOString(), + nodesProcessed: 0, + totalNodes: 1, + chunksProcessed: 0, + model: 'test-model', + dimensions: EMBEDDING_DIMS, + pendingNodeIds: ['Function:pending'], + }, + }); + + await expect( + runFullAnalysis( + repo, + { staged: true, embeddings: true, skipAgentsMd: true, skipSkills: true }, + { onProgress: () => {} }, + ), + ).rejects.toThrow(/checkpoint resume is disabled/i); + expect((await fs.stat(canonical.lbugPath)).ino).toBe(canonicalBefore.ino); + expect((await loadMeta(staged.stagedMetaDir))?.embeddingCheckpoint).toBeDefined(); + }); + it('keeps the canonical DB inode unchanged until validated promotion', async () => { const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-run-')); tempDirs.push(repo); @@ -176,7 +346,7 @@ describe('runFullAnalysis --staged', () => { const result = await runFullAnalysis( repo, - { staged: true, skipAgentsMd: true, skipSkills: true }, + { staged: true, force: true, skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} }, ); From cd4c4acdcd6aab7bc2a1e816c71d3d645d83fe68 Mon Sep 17 00:00:00 2001 From: Eva Date: Thu, 23 Jul 2026 02:13:53 +0700 Subject: [PATCH 02/10] fix(analyze): preflight staged source before replacement --- gitnexus/src/core/run-analyze.ts | 65 +++++++++++++------ gitnexus/src/core/staged-promotion.ts | 36 ++++++++++ gitnexus/test/unit/run-analyze-staged.test.ts | 53 +++++++++++++-- 3 files changed, 127 insertions(+), 27 deletions(-) diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index d61f6a3281..62fad36884 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -147,6 +147,7 @@ import { discardStagedWorkspace, getStagedAnalyzePaths, hasPendingPromotion, + inspectStagedWorkspaceSource, prepareStagedWorkspace, promoteStagedGeneration, validateStagedGeneration, @@ -1140,9 +1141,48 @@ const runFullAnalysisImpl = async ( } let stagedWorkspaceResumed = false; + let stagedWorkspaceExisted = false; + let canonicalDatabaseExistedAtStageStart = false; let canonicalMetaAtStageStart: RepoMeta | null = null; if (stagedPaths) { canonicalMetaAtStageStart = await loadMeta(canonicalMetaDir); + const stagedSourceAtStart = await inspectStagedWorkspaceSource( + stagedPaths, + canonicalMetaAtStageStart, + repositorySource, + ); + const canonicalDatabaseStateAtStart = await lstatIfPresent(canonicalPaths.lbugPath); + stagedWorkspaceExisted = stagedSourceAtStart.exists; + canonicalDatabaseExistedAtStageStart = canonicalDatabaseStateAtStart !== null; + + if (stagedWorkspaceExisted && !options.dropEmbeddings) { + const stagedMetaAtStart = await loadMeta(stagedPaths.stagedMetaDir); + const mayBeCompletedCandidate = + stagedSourceAtStart.matchesSource && + !!stagedMetaAtStart && + !stagedMetaAtStart.incrementalInProgress && + !stagedMetaAtStart.embeddingCheckpoint && + stagedMetaAtStart.lastCommit === currentCommit; + if (!mayBeCompletedCandidate) { + throw new Error( + 'An incomplete or source-mismatched staged generation already exists. It was left ' + + 'untouched for forensics. Re-run with `--drop-embeddings` only when replacing that ' + + 'derived stage with a clean isolated generation is intentional.', + ); + } + } + if ( + !stagedWorkspaceExisted && + canonicalDatabaseExistedAtStageStart && + !options.dropEmbeddings + ) { + throw new Error( + 'Starting staged work from an existing canonical database requires explicit ' + + '`--drop-embeddings`. The canonical index is unchanged. Semantic refreshes must use ' + + '`--staged --embeddings --drop-embeddings`; graph-only rebuilds must use ' + + '`--staged --drop-embeddings`.', + ); + } const prepared = await prepareStagedWorkspace( stagedPaths, canonicalMetaAtStageStart, @@ -1190,29 +1230,14 @@ const runFullAnalysisImpl = async ( // immutable validated output, not a cache/resume surface. options = { ...options, force: false }; } else { - if (existingMeta?.embeddingCheckpoint && !options.dropEmbeddings) { - throw new Error( - 'Staged embedding checkpoint resume is disabled. The canonical index is unchanged. ' + - 'Preserve the staged artifact for forensics, or restart it as a clean isolated ' + - 'generation with `--staged --embeddings --drop-embeddings`.', - ); - } - if (existingMeta && options.embeddings && !options.dropEmbeddings) { - throw new Error( - 'Staged semantic refresh cannot reuse an existing embedding table. The canonical ' + - 'index is unchanged. Re-run with `--staged --embeddings --drop-embeddings` to ' + - 'regenerate embeddings from an empty isolated table.', - ); - } if ( - (existingMeta?.stats?.embeddings ?? 0) > 0 && - !options.embeddings && - !options.dropEmbeddings + !options.dropEmbeddings && + (stagedWorkspaceExisted || canonicalDatabaseExistedAtStageStart) ) { throw new Error( - 'Staged analyze cannot preserve an existing embedding corpus safely. The canonical ' + - 'index is unchanged. Choose `--staged --embeddings --drop-embeddings` for a clean ' + - 'semantic regeneration, or `--staged --drop-embeddings` to build graph and FTS only.', + 'Staged generation did not qualify for completed-stage promotion and was left ' + + 'untouched. Re-run with explicit `--drop-embeddings` to replace it with a clean ' + + 'isolated generation.', ); } if (options.dropEmbeddings || (options.embeddings && !existingMeta)) { diff --git a/gitnexus/src/core/staged-promotion.ts b/gitnexus/src/core/staged-promotion.ts index 48f0fd67a0..778841c513 100644 --- a/gitnexus/src/core/staged-promotion.ts +++ b/gitnexus/src/core/staged-promotion.ts @@ -377,6 +377,42 @@ export const withAnalyzeOwnershipLock = async ( /** @deprecated Use the common ownership lock so plain and staged writers cannot overlap. */ export const withStagedAnalyzeLock = withAnalyzeOwnershipLock; +/** + * Read-only preflight used by callers that must preserve a prior staged + * artifact instead of letting preparation replace source-mismatched derived + * state. A legacy manifest without complete source identity never matches. + */ +export const inspectStagedWorkspaceSource = async ( + paths: StagedAnalyzePaths, + canonicalMeta: RepoMeta | null, + sourceRepo: RepositorySourceIdentity, +): Promise<{ exists: boolean; matchesSource: boolean }> => { + let exists = false; + try { + await fs.lstat(paths.stageRoot); + exists = true; + } catch (error) { + if (!isMissingFilesystemError(error)) throw error; + } + if (!exists) return { exists: false, matchesSource: false }; + + const manifest = await readManifest(paths); + if (!manifest?.sourceMetaFiles || !manifest.sourceRepo) { + return { exists: true, matchesSource: false }; + } + const canonicalDb = await statRegularFile(paths.canonicalLbugPath); + const sourceMeta = canonicalMeta ? metaIdentity(canonicalMeta) : undefined; + const sourceMetaFiles = await statMetadataFiles(paths.canonicalMetaDir); + return { + exists: true, + matchesSource: + identitiesEqual(manifest.sourceMeta, sourceMeta) && + identitiesEqual(manifest.sourceMetaFiles, sourceMetaFiles) && + identitiesEqual(manifest.sourceDb, canonicalDb) && + identitiesEqual(manifest.sourceRepo, sourceRepo), + }; +}; + /** * Create or resume the isolated build workspace. A stage tree is removed only * when a complete canonical generation or a durable stage intent/manifest diff --git a/gitnexus/test/unit/run-analyze-staged.test.ts b/gitnexus/test/unit/run-analyze-staged.test.ts index 2ebced0856..2825cc906d 100644 --- a/gitnexus/test/unit/run-analyze-staged.test.ts +++ b/gitnexus/test/unit/run-analyze-staged.test.ts @@ -57,7 +57,7 @@ describe('runFullAnalysis --staged', () => { ); }); - it('refuses staged embedding preservation unless clean regeneration is explicit', async () => { + it('refuses a full staged write when metadata hides physical embeddings', async () => { const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-no-restore-')); tempDirs.push(repo); process.env.GITNEXUS_HOME = path.join(repo, '.registry-home'); @@ -74,19 +74,45 @@ describe('runFullAnalysis --staged', () => { const canonical = getStoragePaths(repo); const meta = await loadMeta(canonical.storagePath); if (!meta) throw new Error('expected canonical metadata'); + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + await adapter.initLbug(canonical.lbugPath); + try { + const [owner] = await adapter.executeQuery( + 'MATCH (n) WHERE n.id IS NOT NULL RETURN n.id AS id LIMIT 1', + ); + const nodeId = String(owner?.id ?? owner?.[0] ?? ''); + if (!nodeId) throw new Error('expected a graph node for the embedding fixture'); + await adapter.executeWithReusedStatement( + 'CREATE (e:CodeEmbedding {id: $id, nodeId: $nodeId, chunkIndex: $chunkIndex, ' + + 'startLine: 1, endLine: 1, embedding: $embedding, contentHash: $contentHash})', + [ + { + id: `${nodeId}:0`, + nodeId, + chunkIndex: 0, + embedding: new Array(EMBEDDING_DIMS).fill(0.25), + contentHash: 'hidden-by-stale-meta', + }, + ], + ); + } finally { + await adapter.closeLbug(); + } await saveMeta(canonical.storagePath, { ...meta, - stats: { ...meta.stats, embeddings: 1 }, + // Reproduce the observed stale-metadata shape: the database has one + // physical vector while metadata claims zero. + stats: { ...meta.stats, embeddings: 0 }, }); const before = await fs.stat(canonical.lbugPath); await expect( runFullAnalysis( repo, - { staged: true, embeddings: true, skipAgentsMd: true, skipSkills: true }, + { staged: true, force: true, skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} }, ), - ).rejects.toThrow(/--staged --embeddings --drop-embeddings/); + ).rejects.toThrow(/requires explicit `--drop-embeddings`/); const after = await fs.stat(canonical.lbugPath); expect(after.ino).toBe(before.ino); @@ -202,6 +228,16 @@ describe('runFullAnalysis --staged', () => { pendingNodeIds: ['Function:pending'], }, }); + const stagedBefore = await fs.stat(staged.stagedLbugPath); + const stagedMetaPath = path.join(staged.stagedMetaDir, 'gitnexus.json'); + const stagedMetaBefore = await fs.readFile(stagedMetaPath); + await fs.writeFile(path.join(repo, 'index.ts'), 'export const value = 2;\n'); + execFileSync('git', ['add', 'index.ts'], { cwd: repo }); + execFileSync( + 'git', + ['-c', 'user.name=test', '-c', 'user.email=test@test', 'commit', '-m', 'advance-source'], + { cwd: repo }, + ); await expect( runFullAnalysis( @@ -209,9 +245,12 @@ describe('runFullAnalysis --staged', () => { { staged: true, embeddings: true, skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} }, ), - ).rejects.toThrow(/checkpoint resume is disabled/i); + ).rejects.toThrow(/incomplete or source-mismatched staged generation/i); expect((await fs.stat(canonical.lbugPath)).ino).toBe(canonicalBefore.ino); - expect((await loadMeta(staged.stagedMetaDir))?.embeddingCheckpoint).toBeDefined(); + const stagedAfter = await fs.stat(staged.stagedLbugPath); + expect(stagedAfter.ino).toBe(stagedBefore.ino); + expect(stagedAfter.mtimeMs).toBe(stagedBefore.mtimeMs); + await expect(fs.readFile(stagedMetaPath)).resolves.toEqual(stagedMetaBefore); }); it('keeps the canonical DB inode unchanged until validated promotion', async () => { @@ -242,7 +281,7 @@ describe('runFullAnalysis --staged', () => { let sawPrePromotion = false; const result = await runFullAnalysis( repo, - { staged: true, skipAgentsMd: true, skipSkills: true }, + { staged: true, dropEmbeddings: true, skipAgentsMd: true, skipSkills: true }, { onProgress: (_phase, percent) => { if (percent >= 99) return; From c3a86c72f240cebebd7c2a8c76c359961ba35eba Mon Sep 17 00:00:00 2001 From: Eva Date: Thu, 23 Jul 2026 02:45:21 +0700 Subject: [PATCH 03/10] test(hooks): await child output before asserting cap --- gitnexus/test/unit/hook-global-lock.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gitnexus/test/unit/hook-global-lock.test.ts b/gitnexus/test/unit/hook-global-lock.test.ts index c13bb27f10..46d9291410 100644 --- a/gitnexus/test/unit/hook-global-lock.test.ts +++ b/gitnexus/test/unit/hook-global-lock.test.ts @@ -42,9 +42,9 @@ describe('Claude hook machine-global concurrency cap', () => { const waitForGate = () => { if (!fs.existsSync(process.argv[3])) { setTimeout(waitForGate, 10); return; } const release = acquireHookSlot(process.argv[1]); - if (!release) { process.stdout.write('denied\\n'); process.exit(0); } + if (!release) { process.stdout.write('denied\\n'); return; } process.stdout.write('acquired\\n'); - setTimeout(() => { release(); process.exit(0); }, 2000); + setTimeout(() => { release(); }, 2000); }; waitForGate(); `; @@ -66,7 +66,7 @@ describe('Claude hook machine-global concurrency cap', () => { child.stdout.on('data', (chunk) => (stdout += String(chunk))); child.stderr.on('data', (chunk) => (stderr += String(chunk))); child.on('error', reject); - child.on('exit', (code) => { + child.on('close', (code) => { if (code !== 0) reject(new Error(stderr || `child exited ${code}`)); else resolve(stdout.trim()); }); From 967785e9943cb145c1127671a2b90b22beb6957d Mon Sep 17 00:00:00 2001 From: Eva Date: Thu, 23 Jul 2026 03:17:00 +0700 Subject: [PATCH 04/10] fix(analyze): preserve completed-stage intent --- gitnexus/src/core/run-analyze.ts | 26 +++- gitnexus/test/unit/run-analyze-staged.test.ts | 114 ++++++++++++++++++ 2 files changed, 138 insertions(+), 2 deletions(-) diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 62fad36884..1ef522dc98 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -1111,6 +1111,7 @@ const runFullAnalysisImpl = async ( // promotion journal before any fast path can report success. In particular, // a crash at old-backed-up may leave the canonical pathname temporarily // absent even when metadata still looks current. + let recoveredPendingPromotion = false; if (await hasPendingPromotion(promotionPaths)) { if (options.incrementalOnly) { throw new Error( @@ -1122,6 +1123,7 @@ const runFullAnalysisImpl = async ( await promoteStagedGeneration(promotionPaths, commitStagedMetadataAndRegistry, { readRepositoryIdentity, }); + recoveredPendingPromotion = true; log('Recovered and completed the previous staged promotion.'); } @@ -1140,8 +1142,27 @@ const runFullAnalysisImpl = async ( throw new Error('`--staged` cannot be combined with `--repair-fts`; repair is in-place only.'); } + if (recoveredPendingPromotion) { + const recoveredMeta = await loadMeta(canonicalMetaDir); + if (!recoveredMeta) { + throw new Error('Recovered staged promotion is missing canonical metadata.'); + } + progress('done', 100, 'Recovered staged promotion'); + return { + repoName: + options.registryName ?? + getInferredRepoName(repoPath) ?? + path.basename(resolveRepoIdentityRoot(repoPath)), + repoPath, + stats: recoveredMeta.stats ?? {}, + alreadyUpToDate: true, + isPrimaryBranch: !placement.branch, + }; + } + let stagedWorkspaceResumed = false; let stagedWorkspaceExisted = false; + let promoteCompletedStageFastPath = false; let canonicalDatabaseExistedAtStageStart = false; let canonicalMetaAtStageStart: RepoMeta | null = null; if (stagedPaths) { @@ -1209,6 +1230,7 @@ const runFullAnalysisImpl = async ( let existingMeta = await loadMeta(metaDir); if (stagedPaths) { const stagedCompletedCandidate = + !options.dropEmbeddings && stagedWorkspaceResumed && !!existingMeta && !existingMeta.incrementalInProgress && @@ -1228,7 +1250,7 @@ const runFullAnalysisImpl = async ( // A complete stage that died immediately before promotion should be // promoted, even when the original invocation included --force. It is // immutable validated output, not a cache/resume surface. - options = { ...options, force: false }; + promoteCompletedStageFastPath = true; } else { if ( !options.dropEmbeddings && @@ -1627,7 +1649,7 @@ const runFullAnalysisImpl = async ( if ( existingMeta && !existingMeta.embeddingCheckpoint && - !options.force && + (!options.force || promoteCompletedStageFastPath) && existingMeta.lastCommit === currentCommit ) { // Non-git folders have currentCommit = '' — always rebuild since we can't detect changes diff --git a/gitnexus/test/unit/run-analyze-staged.test.ts b/gitnexus/test/unit/run-analyze-staged.test.ts index 2825cc906d..b775c25b3d 100644 --- a/gitnexus/test/unit/run-analyze-staged.test.ts +++ b/gitnexus/test/unit/run-analyze-staged.test.ts @@ -397,6 +397,120 @@ describe('runFullAnalysis --staged', () => { await expect(fs.access(staged.journalPath)).rejects.toMatchObject({ code: 'ENOENT' }); }); + it('keeps force when a completed stage fast path falls through on dirty source', async () => { + const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-dirty-force-')); + tempDirs.push(repo); + process.env.GITNEXUS_HOME = path.join(repo, '.registry-home'); + await fs.writeFile(path.join(repo, 'index.ts'), 'export const value = 1;\n'); + execFileSync('git', ['init'], { cwd: repo }); + execFileSync('git', ['add', 'index.ts'], { cwd: repo }); + execFileSync( + 'git', + ['-c', 'user.name=test', '-c', 'user.email=test@test', 'commit', '-m', 'initial'], + { cwd: repo }, + ); + await runFullAnalysis(repo, { skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} }); + + const canonical = getStoragePaths(repo); + const canonicalMeta = await loadMeta(canonical.storagePath); + if (!canonicalMeta) throw new Error('expected canonical metadata'); + const staged = getStagedAnalyzePaths(canonical.lbugPath, canonical.storagePath); + await prepareStagedWorkspace(staged, canonicalMeta, repositoryIdentity(repo)); + const completedAt = '2026-07-20T12:00:00.000Z'; + await saveMeta(staged.stagedMetaDir, { ...canonicalMeta, indexedAt: completedAt }); + await fs.writeFile(path.join(repo, 'index.ts'), 'export const value = 2;\n'); + + const result = await runFullAnalysis( + repo, + { staged: true, force: true, skipAgentsMd: true, skipSkills: true }, + { onProgress: () => {} }, + ); + + expect(result.alreadyUpToDate).not.toBe(true); + expect((await loadMeta(canonical.storagePath))?.indexedAt).not.toBe(completedAt); + await expect(fs.access(staged.stageRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('replaces rather than promotes a completed stage when drop is explicit', async () => { + const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-completed-drop-')); + tempDirs.push(repo); + process.env.GITNEXUS_HOME = path.join(repo, '.registry-home'); + await fs.writeFile(path.join(repo, 'index.ts'), 'export const value = 1;\n'); + execFileSync('git', ['init'], { cwd: repo }); + execFileSync('git', ['add', 'index.ts'], { cwd: repo }); + execFileSync( + 'git', + ['-c', 'user.name=test', '-c', 'user.email=test@test', 'commit', '-m', 'initial'], + { cwd: repo }, + ); + await runFullAnalysis(repo, { skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} }); + + const canonical = getStoragePaths(repo); + const canonicalMeta = await loadMeta(canonical.storagePath); + if (!canonicalMeta) throw new Error('expected canonical metadata'); + const staged = getStagedAnalyzePaths(canonical.lbugPath, canonical.storagePath); + await prepareStagedWorkspace(staged, canonicalMeta, repositoryIdentity(repo)); + const completedAt = '2026-07-20T12:00:00.000Z'; + await saveMeta(staged.stagedMetaDir, { ...canonicalMeta, indexedAt: completedAt }); + + const result = await runFullAnalysis( + repo, + { + staged: true, + force: true, + dropEmbeddings: true, + skipAgentsMd: true, + skipSkills: true, + }, + { onProgress: () => {} }, + ); + + expect(result.alreadyUpToDate).not.toBe(true); + expect((await loadMeta(canonical.storagePath))?.indexedAt).not.toBe(completedAt); + await expect(fs.access(staged.stageRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('returns success after recovering a pending staged promotion without drop', async () => { + const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-recovery-success-')); + tempDirs.push(repo); + process.env.GITNEXUS_HOME = path.join(repo, '.registry-home'); + await fs.writeFile(path.join(repo, 'index.ts'), 'export const value = 1;\n'); + execFileSync('git', ['init'], { cwd: repo }); + execFileSync('git', ['add', 'index.ts'], { cwd: repo }); + execFileSync( + 'git', + ['-c', 'user.name=test', '-c', 'user.email=test@test', 'commit', '-m', 'initial'], + { cwd: repo }, + ); + await runFullAnalysis(repo, { skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} }); + + const canonical = getStoragePaths(repo); + const canonicalMeta = await loadMeta(canonical.storagePath); + if (!canonicalMeta) throw new Error('expected canonical metadata'); + const staged = getStagedAnalyzePaths(canonical.lbugPath, canonical.storagePath); + await prepareStagedWorkspace(staged, canonicalMeta, repositoryIdentity(repo)); + const recoveredAt = '2026-07-20T12:00:00.000Z'; + await saveMeta(staged.stagedMetaDir, { ...canonicalMeta, indexedAt: recoveredAt }); + await expect( + promoteStagedGeneration(staged, async () => 'repo', { + afterBoundary: (boundary) => { + if (boundary === 'old-backed-up') throw new Error('simulated crash'); + }, + }), + ).rejects.toThrow('simulated crash'); + + const result = await runFullAnalysis( + repo, + { staged: true, skipAgentsMd: true, skipSkills: true }, + { onProgress: () => {} }, + ); + + expect(result.alreadyUpToDate).toBe(true); + expect((await loadMeta(canonical.storagePath))?.indexedAt).toBe(recoveredAt); + await expect(fs.access(staged.stageRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.access(staged.journalPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + it('finishes committed promotion cleanup after the staged metadata directory is gone', async () => { const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-committed-cleanup-')); tempDirs.push(repo); From 7008977587714bea49b343800790849d28a869f0 Mon Sep 17 00:00:00 2001 From: Eva Date: Thu, 23 Jul 2026 03:21:20 +0700 Subject: [PATCH 05/10] fix(analyze): refuse dirty completed stages --- gitnexus/src/core/run-analyze.ts | 8 +++ gitnexus/test/unit/run-analyze-staged.test.ts | 63 +++++++++++++++---- 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 1ef522dc98..43d6ae344d 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -1696,6 +1696,14 @@ const runFullAnalysisImpl = async ( return true; // conservative on git failure } })(); + if (dirty && stagedPaths && promoteCompletedStageFastPath) { + throw new Error( + 'A completed staged generation cannot be resumed against a dirty working tree. ' + + 'It was left untouched for forensics. Commit or discard the source changes to ' + + 'promote it unchanged, or re-run with explicit `--drop-embeddings` to replace it ' + + 'with a clean isolated generation.', + ); + } // Registration wrinkle around the fast path (#2264). A prior // `analyze --name X` that hit a name collision writes meta.json (meta-save // runs before registerRepo) then fails before registering, leaving the diff --git a/gitnexus/test/unit/run-analyze-staged.test.ts b/gitnexus/test/unit/run-analyze-staged.test.ts index b775c25b3d..1e697f0af6 100644 --- a/gitnexus/test/unit/run-analyze-staged.test.ts +++ b/gitnexus/test/unit/run-analyze-staged.test.ts @@ -397,7 +397,7 @@ describe('runFullAnalysis --staged', () => { await expect(fs.access(staged.journalPath)).rejects.toMatchObject({ code: 'ENOENT' }); }); - it('keeps force when a completed stage fast path falls through on dirty source', async () => { + it('refuses a dirty completed stage with embeddings before snapshot preservation', async () => { const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-dirty-force-')); tempDirs.push(repo); process.env.GITNEXUS_HOME = path.join(repo, '.registry-home'); @@ -416,19 +416,59 @@ describe('runFullAnalysis --staged', () => { if (!canonicalMeta) throw new Error('expected canonical metadata'); const staged = getStagedAnalyzePaths(canonical.lbugPath, canonical.storagePath); await prepareStagedWorkspace(staged, canonicalMeta, repositoryIdentity(repo)); + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + await adapter.initLbug(staged.stagedLbugPath); + try { + const [owner] = await adapter.executeQuery( + 'MATCH (n) WHERE n.id IS NOT NULL RETURN n.id AS id LIMIT 1', + ); + const nodeId = String(owner?.id ?? owner?.[0] ?? ''); + if (!nodeId) throw new Error('expected a graph node for the embedding fixture'); + await adapter.executeWithReusedStatement( + 'CREATE (e:CodeEmbedding {id: $id, nodeId: $nodeId, chunkIndex: $chunkIndex, ' + + 'startLine: 1, endLine: 1, embedding: $embedding, contentHash: $contentHash})', + [ + { + id: `${nodeId}:0`, + nodeId, + chunkIndex: 0, + embedding: new Array(EMBEDDING_DIMS).fill(0.25), + contentHash: 'dirty-completed-stage', + }, + ], + ); + } finally { + await adapter.closeLbug(); + } const completedAt = '2026-07-20T12:00:00.000Z'; - await saveMeta(staged.stagedMetaDir, { ...canonicalMeta, indexedAt: completedAt }); + await saveMeta(staged.stagedMetaDir, { + ...canonicalMeta, + indexedAt: completedAt, + stats: { ...canonicalMeta.stats, embeddings: 1 }, + }); + const before = await fs.stat(staged.stagedLbugPath); await fs.writeFile(path.join(repo, 'index.ts'), 'export const value = 2;\n'); - const result = await runFullAnalysis( - repo, - { staged: true, force: true, skipAgentsMd: true, skipSkills: true }, - { onProgress: () => {} }, + await expect( + runFullAnalysis( + repo, + { staged: true, force: true, skipAgentsMd: true, skipSkills: true }, + { onProgress: () => {} }, + ), + ).rejects.toThrow( + /completed staged generation cannot be resumed against a dirty working tree/i, ); - expect(result.alreadyUpToDate).not.toBe(true); - expect((await loadMeta(canonical.storagePath))?.indexedAt).not.toBe(completedAt); - await expect(fs.access(staged.stageRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + const after = await fs.stat(staged.stagedLbugPath); + expect(after.ino).toBe(before.ino); + expect(after.mtimeMs).toBe(before.mtimeMs); + expect(await loadMeta(staged.stagedMetaDir)).toMatchObject({ + indexedAt: completedAt, + stats: { embeddings: 1 }, + }); + await expect( + fs.access(path.join(staged.stagedMetaDir, 'embedding-preservation.jsonl')), + ).rejects.toMatchObject({ code: 'ENOENT' }); }); it('replaces rather than promotes a completed stage when drop is explicit', async () => { @@ -557,8 +597,9 @@ describe('runFullAnalysis --staged', () => { it('refuses malformed staged embeddings before the first promotion journal', async () => { const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-integrity-')); - tempDirs.push(repo); - process.env.GITNEXUS_HOME = path.join(repo, '.registry-home'); + const registryHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-registry-')); + tempDirs.push(repo, registryHome); + process.env.GITNEXUS_HOME = registryHome; await fs.writeFile(path.join(repo, 'index.ts'), 'export const value = 1;\n'); execFileSync('git', ['init'], { cwd: repo }); execFileSync('git', ['add', 'index.ts'], { cwd: repo }); From 310a086cf12c570f9db0f98da4377743d9e57be7 Mon Sep 17 00:00:00 2001 From: Eva Date: Thu, 23 Jul 2026 04:02:11 +0700 Subject: [PATCH 06/10] fix(analyze): make staged recovery terminal and explicit --- gitnexus/src/cli/analyze.ts | 21 +++ gitnexus/src/core/run-analyze.ts | 137 +++++------------- gitnexus/src/server/analyze-worker-ipc.ts | 9 +- gitnexus/test/unit/analyze-gitnexusrc.test.ts | 21 +++ gitnexus/test/unit/analyze-worker-ipc.test.ts | 1 + gitnexus/test/unit/run-analyze-staged.test.ts | 72 ++++++--- 6 files changed, 137 insertions(+), 124 deletions(-) diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 1cd64a2851..67367403b3 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -1468,6 +1468,27 @@ const analyzeCommandImpl = async ( }, ); + if (result.recoveredPromotionOnly) { + await assertAnalysisFinalized(repoPath); + clearInterval(elapsedTimer); + process.removeListener('SIGINT', sigintHandler); + process.removeListener('SIGTERM', sigtermHandler); + await emitResourceEvent('complete', 'recovered-promotion-only', 100); + await closeResourceLog(); + console.log = origLog; + // eslint-disable-next-line no-console -- restoring after intentional progress-bar routing + console.warn = origWarn; + // eslint-disable-next-line no-console -- restoring after intentional progress-bar routing + console.error = origError; + bar.stop(); + console.log(' Recovered the previous staged promotion.'); + console.log(' The current checkout was not analyzed in this recovery-only run.'); + console.log( + ' Run `gitnexus analyze --staged --drop-embeddings` to build and promote a clean generation for the current checkout.\n', + ); + return; + } + if (result.alreadyUpToDate) { // Even the fast path must prove the repo is discoverable. A prior // run can write meta.json and then fail before registerRepo(); in diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 43d6ae344d..6bbd523995 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -463,6 +463,12 @@ export interface AnalyzeResult { embeddings?: number; }; alreadyUpToDate?: boolean; + /** + * True when this invocation only completed a previously journaled staged + * promotion. The recovered generation is durable and registered, but the + * current checkout was deliberately not analyzed in the same invocation. + */ + recoveredPromotionOnly?: boolean; /** The raw pipeline result — only populated when needed by callers (e.g. skill generation). */ pipelineResult?: any; /** True when analyze only repaired FTS indexes and skipped pipeline re-analysis. */ @@ -1111,7 +1117,6 @@ const runFullAnalysisImpl = async ( // promotion journal before any fast path can report success. In particular, // a crash at old-backed-up may leave the canonical pathname temporarily // absent even when metadata still looks current. - let recoveredPendingPromotion = false; if (await hasPendingPromotion(promotionPaths)) { if (options.incrementalOnly) { throw new Error( @@ -1123,26 +1128,7 @@ const runFullAnalysisImpl = async ( await promoteStagedGeneration(promotionPaths, commitStagedMetadataAndRegistry, { readRepositoryIdentity, }); - recoveredPendingPromotion = true; log('Recovered and completed the previous staged promotion.'); - } - - // Analyze indexes the working tree, not an arbitrary ref. Recover a pending - // staged promotion first so a crash cannot leave the canonical pathname - // absent merely because the user switched branches before retrying. Once - // recovery is complete, refuse to start a new build for a mismatched label. - if (options.branch && checkedOutBranch && options.branch !== checkedOutBranch) { - throw new Error( - `--branch "${options.branch}" does not match the checked-out branch "${checkedOutBranch}". ` + - `Check out "${options.branch}" before indexing it, or omit --branch to index the current branch.`, - ); - } - - if (stagedPaths && options.repairFts) { - throw new Error('`--staged` cannot be combined with `--repair-fts`; repair is in-place only.'); - } - - if (recoveredPendingPromotion) { const recoveredMeta = await loadMeta(canonicalMetaDir); if (!recoveredMeta) { throw new Error('Recovered staged promotion is missing canonical metadata.'); @@ -1155,14 +1141,27 @@ const runFullAnalysisImpl = async ( path.basename(resolveRepoIdentityRoot(repoPath)), repoPath, stats: recoveredMeta.stats ?? {}, - alreadyUpToDate: true, + recoveredPromotionOnly: true, isPrimaryBranch: !placement.branch, }; } - let stagedWorkspaceResumed = false; + // Analyze indexes the working tree, not an arbitrary ref. A journal recovery + // above is intentionally terminal: combining crash recovery with a second + // source build recreates the ambiguous lifecycle that staged mode is meant to + // avoid. The caller must start a fresh explicit analyze for the current tree. + if (options.branch && checkedOutBranch && options.branch !== checkedOutBranch) { + throw new Error( + `--branch "${options.branch}" does not match the checked-out branch "${checkedOutBranch}". ` + + `Check out "${options.branch}" before indexing it, or omit --branch to index the current branch.`, + ); + } + + if (stagedPaths && options.repairFts) { + throw new Error('`--staged` cannot be combined with `--repair-fts`; repair is in-place only.'); + } + let stagedWorkspaceExisted = false; - let promoteCompletedStageFastPath = false; let canonicalDatabaseExistedAtStageStart = false; let canonicalMetaAtStageStart: RepoMeta | null = null; if (stagedPaths) { @@ -1177,20 +1176,11 @@ const runFullAnalysisImpl = async ( canonicalDatabaseExistedAtStageStart = canonicalDatabaseStateAtStart !== null; if (stagedWorkspaceExisted && !options.dropEmbeddings) { - const stagedMetaAtStart = await loadMeta(stagedPaths.stagedMetaDir); - const mayBeCompletedCandidate = - stagedSourceAtStart.matchesSource && - !!stagedMetaAtStart && - !stagedMetaAtStart.incrementalInProgress && - !stagedMetaAtStart.embeddingCheckpoint && - stagedMetaAtStart.lastCommit === currentCommit; - if (!mayBeCompletedCandidate) { - throw new Error( - 'An incomplete or source-mismatched staged generation already exists. It was left ' + - 'untouched for forensics. Re-run with `--drop-embeddings` only when replacing that ' + - 'derived stage with a clean isolated generation is intentional.', - ); - } + throw new Error( + 'An unjournaled staged generation already exists. It was left untouched for forensics ' + + 'and will not be promoted or resumed automatically. Re-run with `--drop-embeddings` ' + + 'only when replacing that derived stage with a clean isolated generation is intentional.', + ); } if ( !stagedWorkspaceExisted && @@ -1209,7 +1199,6 @@ const runFullAnalysisImpl = async ( canonicalMetaAtStageStart, repositorySource, ); - stagedWorkspaceResumed = prepared.resumed; log( prepared.resumed ? 'Resuming the existing staged generation; the canonical index remains untouched.' @@ -1229,45 +1218,15 @@ const runFullAnalysisImpl = async ( // needed for a surgical incremental write is established. let existingMeta = await loadMeta(metaDir); if (stagedPaths) { - const stagedCompletedCandidate = - !options.dropEmbeddings && - stagedWorkspaceResumed && - !!existingMeta && - !existingMeta.incrementalInProgress && - !existingMeta.embeddingCheckpoint && - existingMeta.lastCommit === currentCommit && - (!canonicalMetaAtStageStart || - canonicalMetaAtStageStart.lastCommit !== existingMeta.lastCommit || - canonicalMetaAtStageStart.indexedAt !== existingMeta.indexedAt); - // electric.4-.7 proved that restoring or resuming embedding rows inside a // staged copy is not a safe compatibility surface. Keep the useful part of - // staged analyze (isolated full generation + validated promotion), but make - // every semantic refresh choose an empty-table rebuild explicitly. A clean, - // already-finalized stage remains eligible for the fast-path promotion - // below; it is complete derived state, not an embedding checkpoint. - if (stagedCompletedCandidate) { - // A complete stage that died immediately before promotion should be - // promoted, even when the original invocation included --force. It is - // immutable validated output, not a cache/resume surface. - promoteCompletedStageFastPath = true; - } else { - if ( - !options.dropEmbeddings && - (stagedWorkspaceExisted || canonicalDatabaseExistedAtStageStart) - ) { - throw new Error( - 'Staged generation did not qualify for completed-stage promotion and was left ' + - 'untouched. Re-run with explicit `--drop-embeddings` to replace it with a clean ' + - 'isolated generation.', - ); - } - if (options.dropEmbeddings || (options.embeddings && !existingMeta)) { - // The target is the isolated staged DB. Forcing a full write wipes only - // that disposable copy; the canonical generation stays readable until - // the final integrity scan and journaled promotion both succeed. - options = { ...options, force: true }; - } + // staged analyze (isolated full generation + validated promotion). Every + // replacement of existing derived state is explicit and starts clean. + if (options.dropEmbeddings || (options.embeddings && !existingMeta)) { + // The target is the isolated staged DB. Forcing a full write wipes only + // that disposable copy; the canonical generation stays readable until + // the final integrity scan and journaled promotion both succeed. + options = { ...options, force: true }; } } if (options.incrementalOnly) { @@ -1649,7 +1608,7 @@ const runFullAnalysisImpl = async ( if ( existingMeta && !existingMeta.embeddingCheckpoint && - (!options.force || promoteCompletedStageFastPath) && + !options.force && existingMeta.lastCommit === currentCommit ) { // Non-git folders have currentCommit = '' — always rebuild since we can't detect changes @@ -1696,14 +1655,6 @@ const runFullAnalysisImpl = async ( return true; // conservative on git failure } })(); - if (dirty && stagedPaths && promoteCompletedStageFastPath) { - throw new Error( - 'A completed staged generation cannot be resumed against a dirty working tree. ' + - 'It was left untouched for forensics. Commit or discard the source changes to ' + - 'promote it unchanged, or re-run with explicit `--drop-embeddings` to replace it ' + - 'with a clean isolated generation.', - ); - } // Registration wrinkle around the fast path (#2264). A prior // `analyze --name X` that hit a name collision writes meta.json (meta-save // runs before registerRepo) then fails before registering, leaving the @@ -1719,7 +1670,6 @@ const runFullAnalysisImpl = async ( const healUnregistered = options.allowDuplicateName === true && !(await isRepoRegistered(repoPath)); if (!dirty && !healUnregistered) { - let promotedProjectName: string | undefined; // ── #2354: restamp the workspace label on a same-commit branch flip ── // The flat slot follows the checked-out working tree; a branch switch // at the SAME commit with a clean tree changes nothing the pipeline @@ -1762,21 +1712,7 @@ const runFullAnalysisImpl = async ( } } if (stagedPaths) { - const canonicalMeta = await loadMeta(canonicalMetaDir); - const stageWasFinalizedAfterItsCanonicalSource = - !canonicalMeta || - canonicalMeta.lastCommit !== existingMeta.lastCommit || - canonicalMeta.indexedAt !== existingMeta.indexedAt; - if (stageWasFinalizedAfterItsCanonicalSource) { - // The prior process can die after finalizing the staged DB/meta but - // before writing the promotion journal. A resumed run then reaches - // this same-commit fast path. Promote that validated generation - // instead of silently discarding completed work. - progress('lbug', 99, 'Promoting completed staged generation...'); - promotedProjectName = await promoteValidatedStage(stagedPaths); - } else { - await discardStagedWorkspace(stagedPaths); - } + await discardStagedWorkspace(stagedPaths); } else if (!options.incrementalOnly) { await ensureGitNexusIgnored(repoPath); } @@ -1785,7 +1721,6 @@ const runFullAnalysisImpl = async ( // canonical repo basename (#1259) but leaves arbitrary subdirs // and `--skip-git` paths unchanged (#1232/#1233 intent preserved). repoName: - promotedProjectName ?? options.registryName ?? getInferredRepoName(repoPath) ?? path.basename(resolveRepoIdentityRoot(repoPath)), diff --git a/gitnexus/src/server/analyze-worker-ipc.ts b/gitnexus/src/server/analyze-worker-ipc.ts index 335e8cb839..e7d8a8fa57 100644 --- a/gitnexus/src/server/analyze-worker-ipc.ts +++ b/gitnexus/src/server/analyze-worker-ipc.ts @@ -51,7 +51,13 @@ import type { AnalyzeResult } from '../core/run-analyze.js'; */ export type AnalyzeResultIpc = Pick< AnalyzeResult, - 'repoName' | 'repoPath' | 'stats' | 'alreadyUpToDate' | 'ftsRepairedOnly' | 'ftsSkipped' + | 'repoName' + | 'repoPath' + | 'stats' + | 'alreadyUpToDate' + | 'recoveredPromotionOnly' + | 'ftsRepairedOnly' + | 'ftsSkipped' >; /** @@ -66,6 +72,7 @@ export function projectAnalyzeResultForIpc(result: AnalyzeResult): AnalyzeResult repoPath: result.repoPath, stats: result.stats, alreadyUpToDate: result.alreadyUpToDate, + recoveredPromotionOnly: result.recoveredPromotionOnly, ftsRepairedOnly: result.ftsRepairedOnly, ftsSkipped: result.ftsSkipped, }; diff --git a/gitnexus/test/unit/analyze-gitnexusrc.test.ts b/gitnexus/test/unit/analyze-gitnexusrc.test.ts index fa1b3f9fba..cf5af62aa1 100644 --- a/gitnexus/test/unit/analyze-gitnexusrc.test.ts +++ b/gitnexus/test/unit/analyze-gitnexusrc.test.ts @@ -117,6 +117,27 @@ describe('analyzeCommand .gitnexusrc wiring (#243)', () => { expect(opts.skipSkills).toBeFalsy(); }); + it('reports journal recovery as recovery-only instead of already up to date', async () => { + runFullAnalysisMock.mockResolvedValueOnce({ + repoName: 'repo', + repoPath: dir, + stats: {}, + recoveredPromotionOnly: true, + }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + await analyzeCommand(dir, {}); + const output = logSpy.mock.calls.flat().join('\n'); + expect(output).toContain('Recovered the previous staged promotion.'); + expect(output).toContain('The current checkout was not analyzed'); + expect(output).toContain('gitnexus analyze --staged --drop-embeddings'); + expect(output).not.toContain('Already up to date'); + } finally { + logSpy.mockRestore(); + } + }); + it('indexOnly from config remains stronger than context/skills options', async () => { await writeRc({ indexOnly: true }); const { analyzeCommand } = await import('../../src/cli/analyze.js'); diff --git a/gitnexus/test/unit/analyze-worker-ipc.test.ts b/gitnexus/test/unit/analyze-worker-ipc.test.ts index 5d4738bee6..05b561c557 100644 --- a/gitnexus/test/unit/analyze-worker-ipc.test.ts +++ b/gitnexus/test/unit/analyze-worker-ipc.test.ts @@ -46,6 +46,7 @@ describe('#2112: analyze-worker IPC projection', () => { repoPath: '/repos/demo', stats: { files: 3, nodes: 1, edges: 0 }, alreadyUpToDate: false, + recoveredPromotionOnly: undefined, ftsRepairedOnly: undefined, ftsSkipped: true, }); diff --git a/gitnexus/test/unit/run-analyze-staged.test.ts b/gitnexus/test/unit/run-analyze-staged.test.ts index 1e697f0af6..948f7a61fe 100644 --- a/gitnexus/test/unit/run-analyze-staged.test.ts +++ b/gitnexus/test/unit/run-analyze-staged.test.ts @@ -245,7 +245,7 @@ describe('runFullAnalysis --staged', () => { { staged: true, embeddings: true, skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} }, ), - ).rejects.toThrow(/incomplete or source-mismatched staged generation/i); + ).rejects.toThrow(/unjournaled staged generation already exists/i); expect((await fs.stat(canonical.lbugPath)).ino).toBe(canonicalBefore.ino); const stagedAfter = await fs.stat(staged.stagedLbugPath); expect(stagedAfter.ino).toBe(stagedBefore.ino); @@ -356,7 +356,7 @@ describe('runFullAnalysis --staged', () => { await expect(fs.access(staged.journalPath)).rejects.toMatchObject({ code: 'ENOENT' }); }); - it('promotes a completed resumed stage when the process died before journaling', async () => { + it('refuses an unjournaled completed stage instead of auto-promoting it', async () => { const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-prejournal-')); tempDirs.push(repo); const registryHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-registry-')); @@ -375,25 +375,27 @@ describe('runFullAnalysis --staged', () => { const canonical = getStoragePaths(repo); const canonicalMeta = await loadMeta(canonical.storagePath); if (!canonicalMeta) throw new Error('expected canonical metadata'); - const before = await fs.stat(canonical.lbugPath); + const canonicalBefore = await fs.stat(canonical.lbugPath); const staged = getStagedAnalyzePaths(canonical.lbugPath, canonical.storagePath); await prepareStagedWorkspace(staged, canonicalMeta, repositoryIdentity(repo)); await saveMeta(staged.stagedMetaDir, { ...canonicalMeta, indexedAt: '2026-07-20T12:00:00.000Z', }); + const stagedBefore = await fs.stat(staged.stagedLbugPath); - const result = await runFullAnalysis( - repo, - { staged: true, force: true, skipAgentsMd: true, skipSkills: true }, - { onProgress: () => {} }, - ); + await expect( + runFullAnalysis( + repo, + { staged: true, force: true, skipAgentsMd: true, skipSkills: true }, + { onProgress: () => {} }, + ), + ).rejects.toThrow(/unjournaled staged generation already exists/i); - expect(result.alreadyUpToDate).toBe(true); - expect((await fs.stat(canonical.lbugPath)).ino).not.toBe(before.ino); - expect((await loadMeta(canonical.storagePath))?.indexedAt).toBe('2026-07-20T12:00:00.000Z'); - await expect(fs.access(staged.stageRoot)).rejects.toMatchObject({ code: 'ENOENT' }); - await expect(fs.access(staged.backupLbugPath)).rejects.toMatchObject({ code: 'ENOENT' }); + expect((await fs.stat(canonical.lbugPath)).ino).toBe(canonicalBefore.ino); + expect((await fs.stat(staged.stagedLbugPath)).ino).toBe(stagedBefore.ino); + expect((await loadMeta(staged.stagedMetaDir))?.indexedAt).toBe('2026-07-20T12:00:00.000Z'); + await expect(fs.access(staged.stageRoot)).resolves.toBeUndefined(); await expect(fs.access(staged.journalPath)).rejects.toMatchObject({ code: 'ENOENT' }); }); @@ -455,9 +457,7 @@ describe('runFullAnalysis --staged', () => { { staged: true, force: true, skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} }, ), - ).rejects.toThrow( - /completed staged generation cannot be resumed against a dirty working tree/i, - ); + ).rejects.toThrow(/unjournaled staged generation already exists/i); const after = await fs.stat(staged.stagedLbugPath); expect(after.ino).toBe(before.ino); @@ -545,7 +545,8 @@ describe('runFullAnalysis --staged', () => { { onProgress: () => {} }, ); - expect(result.alreadyUpToDate).toBe(true); + expect(result.recoveredPromotionOnly).toBe(true); + expect(result.alreadyUpToDate).not.toBe(true); expect((await loadMeta(canonical.storagePath))?.indexedAt).toBe(recoveredAt); await expect(fs.access(staged.stageRoot)).rejects.toMatchObject({ code: 'ENOENT' }); await expect(fs.access(staged.journalPath)).rejects.toMatchObject({ code: 'ENOENT' }); @@ -589,13 +590,30 @@ describe('runFullAnalysis --staged', () => { await fs.rm(staged.stageRoot, { recursive: true, force: true }); await expect(fs.access(staged.journalPath)).resolves.toBeUndefined(); - await runFullAnalysis(repo, { skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} }); + await fs.writeFile(path.join(repo, 'index.ts'), 'export const value = 2;\n'); + execFileSync('git', ['add', 'index.ts'], { cwd: repo }); + execFileSync( + 'git', + ['-c', 'user.name=test', '-c', 'user.email=test@test', 'commit', '-m', 'advanced'], + { cwd: repo }, + ); + const result = await runFullAnalysis( + repo, + { skipAgentsMd: true, skipSkills: true }, + { onProgress: () => {} }, + ); + + expect(result.recoveredPromotionOnly).toBe(true); + expect(result.alreadyUpToDate).not.toBe(true); + expect((await loadMeta(canonical.storagePath))?.lastCommit).not.toBe( + execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim(), + ); await expect(fs.access(staged.journalPath)).rejects.toMatchObject({ code: 'ENOENT' }); await expect(fs.access(staged.backupLbugPath)).rejects.toMatchObject({ code: 'ENOENT' }); }); - it('refuses malformed staged embeddings before the first promotion journal', async () => { + it('refuses malformed staged embeddings while recovering a prepared promotion', async () => { const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-integrity-')); const registryHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-registry-')); tempDirs.push(repo, registryHome); @@ -643,15 +661,24 @@ describe('runFullAnalysis --staged', () => { await adapter.closeLbug(); } + await expect( + promoteStagedGeneration(staged, async () => 'repo', { + afterBoundary: (boundary) => { + if (boundary === 'prepared') throw new Error('simulated crash'); + }, + }), + ).rejects.toThrow('simulated crash'); + await expect(fs.access(staged.journalPath)).resolves.toBeUndefined(); + await expect( runFullAnalysis( repo, { staged: true, skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} }, ), - ).rejects.toThrow(/staged DB validation failed embedding integrity/i); + ).rejects.toThrow(/pending staged promotion candidate.*embedding integrity/i); expect((await fs.stat(canonical.lbugPath)).ino).toBe(canonicalBefore.ino); - await expect(fs.access(staged.journalPath)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.access(staged.journalPath)).resolves.toBeUndefined(); }); it('recovers an old-backed-up journal during plain analyze before its fast path', async () => { @@ -703,7 +730,8 @@ describe('runFullAnalysis --staged', () => { { onProgress: () => {} }, ); - expect(result.alreadyUpToDate).toBe(true); + expect(result.recoveredPromotionOnly).toBe(true); + expect(result.alreadyUpToDate).not.toBe(true); await expect(fs.access(canonical.lbugPath)).resolves.toBeUndefined(); await expect(fs.access(staged.journalPath)).rejects.toMatchObject({ code: 'ENOENT' }); }); From bbaad42feeaa42ae11b11a5416856af11fa1d4a6 Mon Sep 17 00:00:00 2001 From: Eva Date: Thu, 23 Jul 2026 04:05:24 +0700 Subject: [PATCH 07/10] fix(server): fail closed on recovery-only analyze --- gitnexus/src/server/analyze-launch.ts | 33 +++++++++++++++++-- .../test/unit/analyze-launch-recovery.test.ts | 33 +++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 gitnexus/test/unit/analyze-launch-recovery.test.ts diff --git a/gitnexus/src/server/analyze-launch.ts b/gitnexus/src/server/analyze-launch.ts index b505cdfcf7..a9638e326c 100644 --- a/gitnexus/src/server/analyze-launch.ts +++ b/gitnexus/src/server/analyze-launch.ts @@ -25,6 +25,7 @@ import { import { logger } from '../core/logger.js'; import type { JobManager } from './analyze-job.js'; import type { WorkerMessage } from './analyze-worker.js'; +import type { AnalyzeResultIpc } from './analyze-worker-ipc.js'; const _require = createRequire(import.meta.url); @@ -50,6 +51,23 @@ export interface LaunchOptions { const MAX_WORKER_RETRIES = 2; +const RECOVERY_ONLY_ERROR = + 'Recovered a previous staged promotion, but the current checkout was not analyzed. ' + + 'Start a new analysis with dropEmbeddings=true (CLI: `gitnexus analyze --staged --drop-embeddings`).'; + +/** + * Translate the worker's successful terminal result into the server job + * contract. Recovery-only is deliberately a failed analyze request: the prior + * promotion is durable, but reporting ordinary completion would claim that the + * current checkout was indexed when it was not. + */ +export const completionUpdateForWorkerResult = ( + result: AnalyzeResultIpc, +): { status: 'complete' | 'failed'; repoName: string; error?: string } => + result.recoveredPromotionOnly + ? { status: 'failed', repoName: result.repoName, error: RECOVERY_ONLY_ERROR } + : { status: 'complete', repoName: result.repoName }; + /** * The worker reports `complete` over IPC before its on-disk finalization * (LadybugDB checkpoint + native handle release + metadata write) is visible @@ -182,19 +200,28 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) { }); } else if (msg.type === 'complete') { releaseRepoLock(analyzeLockKey); - // Before marking complete: (1) wait for the worker's on-disk + const terminalUpdate = completionUpdateForWorkerResult(msg.result); + // Before recording the terminal result: (1) wait for the worker's on-disk // finalization to settle (see waitForSettledIndex), (2) evict the // cached DB handle — same invalidation DELETE /api/repo performs, a // handle opened before the rewrite reads pre-rewrite state — and // only then (3) reinitialize the backend. This makes the ordering // comment below true in practice: the repo is actually queryable // when the client receives the SSE complete event. - waitForSettledIndex(targetPath, jobStartMs) + // A recovery-only result may merely clean a committed journal without + // rewriting the canonical DB/meta, so the normal mtime gate cannot + // prove this job's write and would wait the full timeout. The worker + // has already completed the journal transaction; evict/reload now, + // then fail the analyze request with explicit retry guidance. + const settle = msg.result.recoveredPromotionOnly + ? Promise.resolve() + : waitForSettledIndex(targetPath, jobStartMs); + settle .then(() => closeDbHandle()) .catch(() => {}) // best-effort: eviction failure must not fail the job .then(() => backend.init()) .then(() => { - jobManager.updateJob(job.id, { status: 'complete', repoName: msg.result.repoName }); + jobManager.updateJob(job.id, terminalUpdate); }) .catch((err) => { logger.error({ err }, 'backend.init() failed after analyze:'); diff --git a/gitnexus/test/unit/analyze-launch-recovery.test.ts b/gitnexus/test/unit/analyze-launch-recovery.test.ts new file mode 100644 index 0000000000..8c6a27a1fb --- /dev/null +++ b/gitnexus/test/unit/analyze-launch-recovery.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { completionUpdateForWorkerResult } from '../../src/server/analyze-launch.js'; +import type { AnalyzeResultIpc } from '../../src/server/analyze-worker-ipc.js'; + +const result = (recoveredPromotionOnly?: boolean): AnalyzeResultIpc => ({ + repoName: 'demo', + repoPath: '/repos/demo', + stats: { nodes: 10, edges: 12 }, + alreadyUpToDate: undefined, + recoveredPromotionOnly, + ftsRepairedOnly: undefined, + ftsSkipped: undefined, +}); + +describe('analyze worker recovery-only parent contract', () => { + it('fails closed with retry guidance instead of reporting ordinary completion', () => { + expect(completionUpdateForWorkerResult(result(true))).toEqual({ + status: 'failed', + repoName: 'demo', + error: + 'Recovered a previous staged promotion, but the current checkout was not analyzed. ' + + 'Start a new analysis with dropEmbeddings=true (CLI: `gitnexus analyze --staged --drop-embeddings`).', + }); + }); + + it('keeps an ordinary successful analysis complete', () => { + expect(completionUpdateForWorkerResult(result())).toEqual({ + status: 'complete', + repoName: 'demo', + }); + }); +}); From f473aff8135f8099ae89bb1c263f9a70bd85888f Mon Sep 17 00:00:00 2001 From: Eva Date: Thu, 23 Jul 2026 04:05:53 +0700 Subject: [PATCH 08/10] test(server): preserve recovery-only worker result --- gitnexus/test/unit/analyze-worker-ipc.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/gitnexus/test/unit/analyze-worker-ipc.test.ts b/gitnexus/test/unit/analyze-worker-ipc.test.ts index 05b561c557..b0034b821e 100644 --- a/gitnexus/test/unit/analyze-worker-ipc.test.ts +++ b/gitnexus/test/unit/analyze-worker-ipc.test.ts @@ -62,6 +62,19 @@ describe('#2112: analyze-worker IPC projection', () => { expect(roundTripped.stats.nodes).toBe(1); }); + it('preserves the recovery-only discriminator across the worker IPC boundary', () => { + const projected = projectAnalyzeResultForIpc({ + repoName: 'demo', + repoPath: '/repos/demo', + stats: {}, + recoveredPromotionOnly: true, + }); + expect(JSON.parse(JSON.stringify(projected))).toMatchObject({ + repoName: 'demo', + recoveredPromotionOnly: true, + }); + }); + it('anchors the hazard: serializing the RAW result throws (the bug the projection prevents)', () => { // Without the projection, `send({type:'complete', result})` would throw a // TypeError in the worker, get caught, and mis-report this success as a From 944eb2801b7800afd210a575d00dcb84cc327f54 Mon Sep 17 00:00:00 2001 From: Eva Date: Thu, 23 Jul 2026 04:26:48 +0700 Subject: [PATCH 09/10] fix(analyze): finish recovery bookkeeping safely --- gitnexus/src/cli/analyze.ts | 8 ++- gitnexus/src/core/run-analyze.ts | 58 ++++++++++++++++--- .../test/unit/analyze-no-stats-bridge.test.ts | 12 ++++ gitnexus/test/unit/run-analyze-staged.test.ts | 35 ++++++++++- 4 files changed, 103 insertions(+), 10 deletions(-) diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 67367403b3..c190670c2e 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -1202,10 +1202,14 @@ const analyzeCommandImpl = async ( if ( options.incrementalOnly && - (options.force || options.repairFts || options.repairVector || options.skills) + (options.force || + options.repairFts || + options.repairVector || + options.dropEmbeddings || + options.skills) ) { cliError( - ' Cannot combine `--incremental-only` with `--force`, `--repair-fts`, `--repair-vector`, or `--skills`. ' + + ' Cannot combine `--incremental-only` with `--force`, `--repair-fts`, `--repair-vector`, `--drop-embeddings`, or `--skills`. ' + 'The preservation contract refuses every option that can require a full rebuild.\n', ); process.exitCode = 1; diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 6bbd523995..4bf95acca4 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -1125,20 +1125,58 @@ const runFullAnalysisImpl = async ( } progress('lbug', 1, 'Recovering staged promotion...'); await validatePendingPromotionEmbeddingCandidate(promotionPaths); - await promoteStagedGeneration(promotionPaths, commitStagedMetadataAndRegistry, { - readRepositoryIdentity, - }); + const recoveredPromotion = await promoteStagedGeneration( + promotionPaths, + commitStagedMetadataAndRegistry, + { + readRepositoryIdentity, + }, + ); log('Recovered and completed the previous staged promotion.'); const recoveredMeta = await loadMeta(canonicalMetaDir); if (!recoveredMeta) { throw new Error('Recovered staged promotion is missing canonical metadata.'); } + const recoveredRepoName = + recoveredPromotion.projectName ?? + options.registryName ?? + getInferredRepoName(repoPath) ?? + path.basename(resolveRepoIdentityRoot(repoPath)); + + // Recovery completes the same canonical promotion as the normal finalize + // path, so finish its source-side bookkeeping before returning the explicit + // recovery-only result. Context generation is best-effort, matching the + // ordinary successful path. + await ensureGitNexusIgnored(repoPath); + if (!placement.branch) { + try { + await generateAIContextFiles( + repoPath, + storagePath, + recoveredRepoName, + { + files: recoveredMeta.stats?.files, + nodes: recoveredMeta.stats?.nodes, + edges: recoveredMeta.stats?.edges, + communities: recoveredMeta.stats?.communities, + processes: recoveredMeta.stats?.processes, + }, + undefined, + { + skipAgentsMd: options.skipAgentsMd, + skipSkills: options.skipSkills, + noStats: options.noStats, + defaultBranch: options.defaultBranch, + hasPdg: recoveredMeta.pdg !== undefined, + }, + ); + } catch { + // Best-effort — the recovered canonical index remains valid. + } + } progress('done', 100, 'Recovered staged promotion'); return { - repoName: - options.registryName ?? - getInferredRepoName(repoPath) ?? - path.basename(resolveRepoIdentityRoot(repoPath)), + repoName: recoveredRepoName, repoPath, stats: recoveredMeta.stats ?? {}, recoveredPromotionOnly: true, @@ -3224,6 +3262,12 @@ export async function runFullAnalysis( options: AnalyzeOptions, callbacks: AnalyzeCallbacks, ): Promise { + if (options.incrementalOnly && options.dropEmbeddings) { + throw new Error( + 'Cannot combine `--incremental-only` with `--drop-embeddings`. ' + + 'The preservation contract refuses every option that can require a clean rebuild.', + ); + } if (options.repairVector) { const conflicts = [ options.force && '--force', diff --git a/gitnexus/test/unit/analyze-no-stats-bridge.test.ts b/gitnexus/test/unit/analyze-no-stats-bridge.test.ts index d82047db69..0dc7cff823 100644 --- a/gitnexus/test/unit/analyze-no-stats-bridge.test.ts +++ b/gitnexus/test/unit/analyze-no-stats-bridge.test.ts @@ -200,6 +200,18 @@ describe('analyzeCommand commander → runFullAnalysis noStats bridge (#1477)', expect(opts.incrementalOnly).toBe(true); }); + it('rejects combining --incremental-only with --drop-embeddings', async () => { + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + + await analyzeCommand(undefined, { incrementalOnly: true, dropEmbeddings: true }); + + expect(process.exitCode).toBe(1); + expect(cliErrorMock).toHaveBeenCalledWith( + expect.stringMatching(/cannot combine `--incremental-only`.*`--drop-embeddings`/i), + ); + expect(runFullAnalysisMock).not.toHaveBeenCalled(); + }); + it('passes --staged through to runFullAnalysis', async () => { const { analyzeCommand } = await import('../../src/cli/analyze.js'); diff --git a/gitnexus/test/unit/run-analyze-staged.test.ts b/gitnexus/test/unit/run-analyze-staged.test.ts index 948f7a61fe..99c16fdac5 100644 --- a/gitnexus/test/unit/run-analyze-staged.test.ts +++ b/gitnexus/test/unit/run-analyze-staged.test.ts @@ -57,6 +57,28 @@ describe('runFullAnalysis --staged', () => { ); }); + it('rejects incremental-only drop before creating staged storage', async () => { + const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-conflict-')); + tempDirs.push(repo); + await fs.writeFile(path.join(repo, 'index.ts'), 'export const value = 1;\n'); + execFileSync('git', ['init'], { cwd: repo }); + execFileSync('git', ['add', 'index.ts'], { cwd: repo }); + execFileSync( + 'git', + ['-c', 'user.name=test', '-c', 'user.email=test@test', 'commit', '-m', 'initial'], + { cwd: repo }, + ); + + await expect( + runFullAnalysis( + repo, + { staged: true, incrementalOnly: true, dropEmbeddings: true }, + { onProgress: () => {} }, + ), + ).rejects.toThrow(/cannot combine `--incremental-only` with `--drop-embeddings`/i); + await expect(fs.access(path.join(repo, '.gitnexus'))).rejects.toMatchObject({ code: 'ENOENT' }); + }); + it('refuses a full staged write when metadata hides physical embeddings', async () => { const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-no-restore-')); tempDirs.push(repo); @@ -538,16 +560,27 @@ describe('runFullAnalysis --staged', () => { }, }), ).rejects.toThrow('simulated crash'); + await fs.rm(path.join(canonical.storagePath, '.gitignore'), { force: true }); + await fs.rm(path.join(repo, 'AGENTS.md'), { force: true }); + await fs.rm(path.join(repo, 'CLAUDE.md'), { force: true }); const result = await runFullAnalysis( repo, - { staged: true, skipAgentsMd: true, skipSkills: true }, + { staged: true, skipSkills: true }, { onProgress: () => {} }, ); expect(result.recoveredPromotionOnly).toBe(true); expect(result.alreadyUpToDate).not.toBe(true); expect((await loadMeta(canonical.storagePath))?.indexedAt).toBe(recoveredAt); + await expect(fs.readFile(path.join(canonical.storagePath, '.gitignore'), 'utf8')).resolves.toBe( + '*\n', + ); + await expect(fs.readFile(path.join(repo, '.git', 'info', 'exclude'), 'utf8')).resolves.toMatch( + /\.gitnexus\//, + ); + await expect(fs.access(path.join(repo, 'AGENTS.md'))).resolves.toBeUndefined(); + await expect(fs.access(path.join(repo, 'CLAUDE.md'))).resolves.toBeUndefined(); await expect(fs.access(staged.stageRoot)).rejects.toMatchObject({ code: 'ENOENT' }); await expect(fs.access(staged.journalPath)).rejects.toMatchObject({ code: 'ENOENT' }); }); From 46fde6ce0b04e0958b2bede581e9c46a7ae87a7b Mon Sep 17 00:00:00 2001 From: Eva Date: Thu, 23 Jul 2026 04:54:27 +0700 Subject: [PATCH 10/10] fix(server): require forced clean recovery retry --- gitnexus/src/server/analyze-launch.ts | 3 ++- gitnexus/test/unit/analyze-launch-recovery.test.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/gitnexus/src/server/analyze-launch.ts b/gitnexus/src/server/analyze-launch.ts index a9638e326c..cf0e2ccfec 100644 --- a/gitnexus/src/server/analyze-launch.ts +++ b/gitnexus/src/server/analyze-launch.ts @@ -53,7 +53,8 @@ const MAX_WORKER_RETRIES = 2; const RECOVERY_ONLY_ERROR = 'Recovered a previous staged promotion, but the current checkout was not analyzed. ' + - 'Start a new analysis with dropEmbeddings=true (CLI: `gitnexus analyze --staged --drop-embeddings`).'; + 'Start a new analysis with force=true and dropEmbeddings=true ' + + '(CLI: `gitnexus analyze --staged --drop-embeddings`).'; /** * Translate the worker's successful terminal result into the server job diff --git a/gitnexus/test/unit/analyze-launch-recovery.test.ts b/gitnexus/test/unit/analyze-launch-recovery.test.ts index 8c6a27a1fb..31a0030e98 100644 --- a/gitnexus/test/unit/analyze-launch-recovery.test.ts +++ b/gitnexus/test/unit/analyze-launch-recovery.test.ts @@ -20,7 +20,8 @@ describe('analyze worker recovery-only parent contract', () => { repoName: 'demo', error: 'Recovered a previous staged promotion, but the current checkout was not analyzed. ' + - 'Start a new analysis with dropEmbeddings=true (CLI: `gitnexus analyze --staged --drop-embeddings`).', + 'Start a new analysis with force=true and dropEmbeddings=true ' + + '(CLI: `gitnexus analyze --staged --drop-embeddings`).', }); });