fix(fts): stop warning "FTS extension unavailable" on the run that installs it

On a machine with no cached LadybugDB extension, the first `gitnexus analyze`
logs a WARN

  GitNexus: FTS extension unavailable; continuing without FTS features.
  load-only policy (no install attempted); LOAD fts failed: ...

and then, in the same run, installs FTS and builds every search index. Nothing
was degraded — only the log was wrong, and it sent users chasing a broken
install path that does not exist (see the first of the two warn lines in #2184,
where only the second one is real).

The line comes from `initLbug`'s writable FTS pre-load. That call deliberately
never installs (analyze owns extension installation), so on a cold cache it is
*expected* to miss; Phase 3 retries moments later with the `auto` policy and
succeeds. `ExtensionManager.markUnavailable` had no way to tell that
speculative probe from a final answer, so it reported every miss as a user-
facing degradation.

Adds `quiet` to `ExtensionEnsureOptions`: the outcome is still recorded in
capabilities, but it is logged at debug level and does not consume the
once-per-(extension, reason) warn budget — so a later real failure with the
same reason still warns. Set only on the writable `initLbug` pre-load. The
read-only serve/MCP branch keeps `{ policy: 'load-only' }` with no `quiet`:
there is no later retry there, so that warning is accurate. Analyze Phase 3,
`--repair-fts` and genuinely-offline installs (#2184) are untouched and still
report loudly.

Verified end-to-end against a temp `HOME` with no `~/.lbdb`: analyze emits no
FTS warning, installs `libfts.lbug_extension`, and builds all FTS indexes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Artur Kaminski
2026-07-26 07:42:08 +02:00
co-authored by Claude Opus 5
parent 89bbdcf566
commit 91953a3cba
3 changed files with 68 additions and 5 deletions
+26 -4
View File
@@ -43,6 +43,18 @@ export interface ExtensionCapability {
export interface ExtensionEnsureOptions {
policy?: ExtensionInstallPolicy;
installTimeoutMs?: number;
/**
* Speculative probe: log an unavailable outcome at debug level instead of
* warn, and do not consume the once-per-(extension, reason) warn budget.
*
* Set only by callers that do NOT own the extension's lifecycle and know a
* later owner will retry with an install-capable policy — currently the
* writable `initLbug` pre-load, whose miss on a cold cache is expected and
* is fixed moments later by analyze Phase 3. Never set it on a path where
* the failure is the final answer (serve/MCP read paths), or a real
* degradation goes unreported.
*/
quiet?: boolean;
}
export interface ExtensionManagerOptions {
@@ -230,9 +242,10 @@ export class ExtensionManager {
const timeoutMs =
opts.installTimeoutMs ?? this.options.installTimeoutMs ?? getExtensionInstallTimeoutMs();
const warn = this.options.warn ?? ((msg: string) => logger.warn(msg));
const quiet = opts.quiet === true;
if (policy === 'never') {
this.markUnavailable(name, label, 'extension install policy is "never"', warn);
this.markUnavailable(name, label, 'extension install policy is "never"', warn, quiet);
return false;
}
@@ -248,6 +261,7 @@ export class ExtensionManager {
label,
`load-only policy (no install attempted); LOAD ${name} failed: ${loadError}`,
warn,
quiet,
);
return false;
}
@@ -267,6 +281,7 @@ export class ExtensionManager {
label,
`${install.message}; LOAD ${name} had failed: ${loadError}`,
warn,
quiet,
);
return false;
}
@@ -282,6 +297,7 @@ export class ExtensionManager {
label,
`LOAD ${name} failed after successful INSTALL: ${retryError}`,
warn,
quiet,
);
return false;
}
@@ -316,6 +332,7 @@ export class ExtensionManager {
label: string,
reason: string,
warn: (message: string) => void,
quiet = false,
): void {
// Classify once here (the single load-failure sink, run per Database not per
// request) so the hot per-request warning path does no file I/O (#2383 F3).
@@ -325,12 +342,17 @@ export class ExtensionManager {
reason,
diagnosis: diagnoseExtensionLoad(reason, label),
});
const message = `GitNexus: ${label} extension unavailable; continuing without ${label} features. ${reason}`;
// A quiet probe must not register the dedup key: the owning caller may hit
// the identical reason later, and that one is the real degradation report.
if (quiet) {
logger.debug(message);
return;
}
const key = `${name}:${reason}`;
if (this.warnedKeys.has(key)) return;
this.warnedKeys.add(key);
warn(
`GitNexus: ${label} extension unavailable; continuing without ${label} features. ${reason}`,
);
warn(message);
}
}
+7 -1
View File
@@ -922,7 +922,13 @@ const doInitLbug = async (dbPath: string, readOnly: boolean = false) => {
// FTS powers baseline search, so initialize it with the core DB. Read-only
// serve/MCP paths must never run DDL or trigger network INSTALL; analyze owns
// schema/index creation and extension installation.
await loadFTSExtension(undefined, readOnly ? { policy: 'load-only' } : {});
//
// `quiet` on the writable branch: on a cold machine this pre-load is EXPECTED
// to miss (default policy is load-only, extension not yet on disk) and analyze
// Phase 3 installs it moments later in the same run. Warning here reported a
// degradation that never happened — the run went on to build every FTS index.
// Phase 3 (and the read-only branch) still warn for real failures.
await loadFTSExtension(undefined, readOnly ? { policy: 'load-only' } : { quiet: true });
currentDbPath = dbPath;
return { db, conn };
@@ -258,6 +258,41 @@ describe('ExtensionManager — observability', () => {
expect(warn).toHaveBeenCalledTimes(1);
});
// initLbug's writable FTS pre-load is a speculative probe — on a cold
// machine it misses, then analyze Phase 3 installs and every FTS index builds.
// The probe must not report a degradation that the same run repairs.
it('stays silent on a quiet probe that a later install-capable call repairs', async () => {
const installExtension = vi.fn().mockResolvedValue(okInstall);
const warn = vi.fn();
const manager = new ExtensionManager({ installExtension, warn });
const query = vi
.fn()
.mockRejectedValueOnce(new Error('Extension "fts" not found'))
.mockRejectedValueOnce(new Error('Extension "fts" not found'))
.mockResolvedValueOnce({});
await expect(
manager.ensure(query, 'fts', 'FTS', { policy: 'load-only', quiet: true }),
).resolves.toBe(false);
expect(warn).not.toHaveBeenCalled();
await expect(manager.ensure(query, 'fts', 'FTS', { policy: 'auto' })).resolves.toBe(true);
expect(warn).not.toHaveBeenCalled();
expect(manager.getCapabilities()).toEqual([{ name: 'fts', loaded: true }]);
});
it('still warns on a genuine load-only miss that follows a quiet probe of the same reason', async () => {
const warn = vi.fn();
const manager = new ExtensionManager({ policy: 'load-only', warn });
const query = vi.fn().mockRejectedValue(new Error('Extension "fts" not found'));
await manager.ensure(query, 'fts', 'FTS', { quiet: true });
await manager.ensure(query, 'fts', 'FTS');
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('continuing without FTS features'));
});
});
describe('ExtensionManager — input validation', () => {