Skip to content
21 changes: 21 additions & 0 deletions gitnexus/src/cli/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
99 changes: 76 additions & 23 deletions gitnexus/src/core/run-analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ import {
discardStagedWorkspace,
getStagedAnalyzePaths,
hasPendingPromotion,
inspectStagedWorkspaceSource,
prepareStagedWorkspace,
promoteStagedGeneration,
validateStagedGeneration,
Expand Down Expand Up @@ -462,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. */
Expand Down Expand Up @@ -1122,12 +1129,27 @@ const runFullAnalysisImpl = async (
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.');
}
progress('done', 100, 'Recovered staged promotion');
return {
Comment thread
100yenadmin marked this conversation as resolved.
repoName:
options.registryName ??
getInferredRepoName(repoPath) ??
path.basename(resolveRepoIdentityRoot(repoPath)),
repoPath,
stats: recoveredMeta.stats ?? {},
recoveredPromotionOnly: true,
isPrimaryBranch: !placement.branch,
};
}

// 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.
// 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}". ` +
Expand All @@ -1139,9 +1161,44 @@ const runFullAnalysisImpl = async (
throw new Error('`--staged` cannot be combined with `--repair-fts`; repair is in-place only.');
}

let stagedWorkspaceExisted = false;
let canonicalDatabaseExistedAtStageStart = 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 stagedSourceAtStart = await inspectStagedWorkspaceSource(
stagedPaths,
canonicalMetaAtStageStart,
repositorySource,
);
const canonicalDatabaseStateAtStart = await lstatIfPresent(canonicalPaths.lbugPath);
stagedWorkspaceExisted = stagedSourceAtStart.exists;
canonicalDatabaseExistedAtStageStart = canonicalDatabaseStateAtStart !== null;

if (stagedWorkspaceExisted && !options.dropEmbeddings) {
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 &&
canonicalDatabaseExistedAtStageStart &&
!options.dropEmbeddings
) {
Comment thread
100yenadmin marked this conversation as resolved.
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,
repositorySource,
);
Comment thread
100yenadmin marked this conversation as resolved.
log(
prepared.resumed
? 'Resuming the existing staged generation; the canonical index remains untouched.'
Expand All @@ -1160,6 +1217,18 @@ 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) {
// 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). 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 };
Comment thread
100yenadmin marked this conversation as resolved.
}
}
if (options.incrementalOnly) {
if (!existingMeta) {
incrementalOnlyStop('no existing index metadata is available');
Expand Down Expand Up @@ -1601,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
Expand Down Expand Up @@ -1644,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);
}
Expand All @@ -1667,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)),
Expand Down
36 changes: 36 additions & 0 deletions gitnexus/src/core/staged-promotion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,42 @@ export const withAnalyzeOwnershipLock = async <T>(
/** @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
Expand Down
33 changes: 30 additions & 3 deletions gitnexus/src/server/analyze-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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`).';
Comment thread
100yenadmin marked this conversation as resolved.
Outdated

/**
* 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
Expand Down Expand Up @@ -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:');
Expand Down
9 changes: 8 additions & 1 deletion gitnexus/src/server/analyze-worker-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
>;

/**
Expand All @@ -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,
};
Expand Down
21 changes: 21 additions & 0 deletions gitnexus/test/unit/analyze-gitnexusrc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
33 changes: 33 additions & 0 deletions gitnexus/test/unit/analyze-launch-recovery.test.ts
Original file line number Diff line number Diff line change
@@ -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',
});
});
});
Loading
Loading