diff --git a/design/extension.md b/design/extension.md
index fb59ffc8..9a8a5832 100644
--- a/design/extension.md
+++ b/design/extension.md
@@ -1173,6 +1173,24 @@ the model registry is wired up initially.
by-name `loadSingleType`, and the extension-attach predicate)
retain their existing failure semantics because they are not in
the read-only steady-state loop.
+ - **Fingerprint preservation on build failure (issue #265).** When
+ `bundleWithCache` cannot regenerate a bundle (bare specifiers without
+ a project `deno.json`, or a transient build error) and falls back to
+ the cached `.js`, the `rebundleAndUpdateCatalog` caller preserves the
+ catalog's _stored_ `source_fingerprint` instead of writing the new
+ one. This keeps the file "stale" so `findStaleFiles` retries on the
+ next warm-start invocation. Without this, the new fingerprint would
+ be written alongside the old bundle content, permanently masking the
+ staleness — `findStaleFiles` would see matching fingerprints and
+ never retry. The warning log fires only on the fallback case
+ (`fromCache && newFingerprint !== catalogFingerprint`), not on
+ legitimate cache hits where the source hasn't changed.
+ `findStaleFiles` uses fingerprint comparison, not RowState, for
+ staleness decisions. `BundleBuildFailed` rows are skipped when
+ fingerprints match (source unchanged) and retried when they mismatch
+ (source changed) — warm-start and reconcile operate on orthogonal
+ axes.
+
- If not populated (first run or DB deleted): falls back to the existing
full-import path, then populates the catalog from the loaded registry
diff --git a/integration/source_extension_rebundle_test.ts b/integration/source_extension_rebundle_test.ts
new file mode 100644
index 00000000..a8b246e4
--- /dev/null
+++ b/integration/source_extension_rebundle_test.ts
@@ -0,0 +1,224 @@
+// Swamp, an Automation Framework
+// Copyright (C) 2026 System Initiative, Inc.
+//
+// This file is part of Swamp.
+//
+// Swamp is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License version 3
+// as published by the Free Software Foundation, with the Swamp
+// Extension and Definition Exception (found in the "COPYING-EXCEPTION"
+// file).
+//
+// Swamp is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with Swamp. If not, see .
+
+import { assertEquals, assertStringIncludes } from "@std/assert";
+import { join } from "@std/path";
+import { ensureDir } from "@std/fs";
+import { initializeTestRepo, runCliCommand } from "./test_helpers.ts";
+import { stringify as stringifyYaml } from "@std/yaml";
+
+const BUNDLEABLE_V1 = `
+import { z } from "npm:zod@4";
+
+export const model = {
+ type: "@user/source-rebundle-it",
+ version: "2026.01.01.1",
+ methods: {
+ run: {
+ description: "Run",
+ arguments: z.object({}),
+ execute: async () => ({ dataHandles: [], greeting: "V1" }),
+ },
+ },
+};
+`;
+
+const BARE_SPECIFIER_V2 = `
+import { z } from "zod";
+
+export const model = {
+ type: "@user/source-rebundle-it",
+ version: "2026.01.01.2",
+ methods: {
+ run: {
+ description: "Run",
+ arguments: z.object({}),
+ execute: async () => ({ dataHandles: [], greeting: "V2" }),
+ },
+ newMethod: {
+ description: "New method added in V2",
+ arguments: z.object({}),
+ execute: async () => ({ dataHandles: [], result: "NEW" }),
+ },
+ },
+};
+`;
+
+const WARNING_SUBSTRING = "source fingerprint preserved";
+
+/**
+ * Warm-start regression test for issue #265. Verifies that source-mounted
+ * extensions with bare specifiers (triggering isExpectedBundleFailure) don't
+ * permanently poison the catalog fingerprint when the bundle can't be
+ * regenerated.
+ *
+ * Three assertion groups:
+ * (a) Warning-log precision: unchanged source produces no warning.
+ * (b) Permanent-failure determinism: modified source with bare specifier
+ * preserves catalog fingerprint and emits warning on every warm-start.
+ * (c) Bundle content: old bundle stays on disk, new methods don't appear.
+ */
+Deno.test("Source-mounted extension: fingerprint preserved when bundle build fails (#265)", async () => {
+ const repoDir = await Deno.makeTempDir({
+ prefix: "swamp_it_source_rebundle_",
+ });
+ const extDir = await Deno.makeTempDir({
+ prefix: "swamp_it_source_ext_",
+ });
+ try {
+ await initializeTestRepo(repoDir);
+
+ // Set up a source-mounted extension in a separate directory (no deno.json).
+ const modelsDir = join(extDir, "models");
+ await ensureDir(modelsDir);
+ const sourcePath = join(modelsDir, "source_rebundle_it.ts");
+
+ // Write .swamp-sources.yaml pointing to the external directory.
+ await Deno.writeTextFile(
+ join(repoDir, ".swamp-sources.yaml"),
+ stringifyYaml({
+ sources: [{ path: extDir }],
+ } as Record),
+ );
+
+ // --- Step 1: Prime with bundleable V1 ---
+ await Deno.writeTextFile(sourcePath, BUNDLEABLE_V1);
+ const prime = await runCliCommand(
+ ["model", "type", "search", "source-rebundle-it", "--json"],
+ repoDir,
+ );
+ assertEquals(
+ prime.code,
+ 0,
+ `Prime run failed:\nstdout=${prime.stdout}\nstderr=${prime.stderr}`,
+ );
+ assertStringIncludes(prime.stdout, "@user/source-rebundle-it");
+
+ // Verify V1 bundle was created with V1 content.
+ const bundlePath = await findBundle(repoDir, "source_rebundle_it.js");
+ const v1Bundle = await Deno.readTextFile(bundlePath);
+ assertStringIncludes(v1Bundle, "V1", "V1 bundle must contain V1 marker");
+
+ // --- Step 2 (scenario c): Unchanged source — no warning ---
+ // Use non-JSON mode so LogTape warnings reach stderr.
+ const unchanged = await runCliCommand(
+ ["model", "type", "search", "source-rebundle-it"],
+ repoDir,
+ );
+ assertEquals(unchanged.code, 0);
+ assertEquals(
+ unchanged.stderr.includes(WARNING_SUBSTRING),
+ false,
+ "No warning expected when source is unchanged (legitimate cache hit)",
+ );
+
+ // --- Step 3 (scenario b): Modify source to bare specifier + new method ---
+ await Deno.writeTextFile(sourcePath, BARE_SPECIFIER_V2);
+
+ const firstRetry = await runCliCommand(
+ ["model", "type", "search", "source-rebundle-it"],
+ repoDir,
+ );
+ assertEquals(
+ firstRetry.code,
+ 0,
+ `First retry failed:\nstdout=${firstRetry.stdout}\nstderr=${firstRetry.stderr}`,
+ );
+
+ // Warning must fire: fromCache=true AND fingerprint differs.
+ assertStringIncludes(
+ firstRetry.stderr,
+ WARNING_SUBSTRING,
+ "Warning expected when bundle build fails and fingerprint is preserved",
+ );
+
+ // Bundle content must still be V1 (stale — the build failed).
+ const afterFirstRetry = await Deno.readTextFile(bundlePath);
+ assertStringIncludes(
+ afterFirstRetry,
+ "V1",
+ "Bundle must still contain V1 marker — build failed, old cache used",
+ );
+ assertEquals(
+ afterFirstRetry.includes("V2"),
+ false,
+ "V2 marker must NOT be in bundle — build failed",
+ );
+
+ // --- Step 4 (scenario b continued): Second warm-start — deterministic ---
+ const secondRetry = await runCliCommand(
+ ["model", "type", "search", "source-rebundle-it"],
+ repoDir,
+ );
+ assertEquals(
+ secondRetry.code,
+ 0,
+ `Second retry failed:\nstdout=${secondRetry.stdout}\nstderr=${secondRetry.stderr}`,
+ );
+
+ // Warning must fire again — fingerprint still mismatches.
+ assertStringIncludes(
+ secondRetry.stderr,
+ WARNING_SUBSTRING,
+ "Warning expected on second retry — fingerprint still preserved",
+ );
+
+ // Bundle must still be V1.
+ const afterSecondRetry = await Deno.readTextFile(bundlePath);
+ assertStringIncludes(
+ afterSecondRetry,
+ "V1",
+ "Bundle must still contain V1 marker on second retry",
+ );
+ } finally {
+ try {
+ await Deno.remove(repoDir, { recursive: true });
+ } catch { /* EBUSY from sqlite — temp dir is ephemeral, OS reclaims */ }
+ try {
+ await Deno.remove(extDir, { recursive: true });
+ } catch { /* best-effort cleanup */ }
+ }
+});
+
+/**
+ * Locates the bundle file by walking `.swamp/bundles/` — the intermediate
+ * hash segment varies per source dir, so we search by filename.
+ */
+async function findBundle(
+ repoDir: string,
+ bundleName: string,
+): Promise {
+ const bundlesRoot = join(repoDir, ".swamp", "bundles");
+ return await walkForFile(bundlesRoot, bundleName);
+}
+
+async function walkForFile(dir: string, target: string): Promise {
+ for await (const entry of Deno.readDir(dir)) {
+ const path = join(dir, entry.name);
+ if (entry.isFile && entry.name === target) return path;
+ if (entry.isDirectory) {
+ try {
+ return await walkForFile(path, target);
+ } catch {
+ // Not found in this subtree — continue.
+ }
+ }
+ }
+ throw new Error(`${target} not found under ${dir}`);
+}
diff --git a/src/domain/datastore/user_datastore_loader.ts b/src/domain/datastore/user_datastore_loader.ts
index 4e318e0f..43b34fc0 100644
--- a/src/domain/datastore/user_datastore_loader.ts
+++ b/src/domain/datastore/user_datastore_loader.ts
@@ -31,6 +31,7 @@ import {
uint8ArrayToBase64,
} from "../models/bundle.ts";
import {
+ type BundleResult,
computeSourceFingerprint,
createFreshnessCache,
findStaleFiles as findStaleFilesShared,
@@ -179,12 +180,16 @@ export class UserDatastoreLoader {
continue;
}
- const js = await this.bundleWithCache(
+ const { js, fromCache } = await this.bundleWithCache(
absolutePath,
file,
denoPath,
baseDir,
);
+ if (fromCache) {
+ logger
+ .warn`Using cached bundle for ${file} — source may have changed but bundle could not be regenerated`;
+ }
const module = await this.importBundle(js, file, baseDir);
if (!module.datastore) {
@@ -242,7 +247,7 @@ export class UserDatastoreLoader {
relativePath: string,
denoPath: string,
boundaryDir: string,
- ): Promise {
+ ): Promise {
if (this.repoDir) {
const bundlePath = join(
this.repoDir,
@@ -272,7 +277,7 @@ export class UserDatastoreLoader {
// and we'd wastefully spawn Deno before falling back to the
// cached bundle anyway.
if (bundleExists && isExpectedBundleFailure(absolutePath, this.repoDir)) {
- return await Deno.readTextFile(bundlePath);
+ return { js: await Deno.readTextFile(bundlePath), fromCache: true };
}
// Try to rebundle from source. If bundling fails (e.g. bare specifiers
@@ -286,7 +291,7 @@ export class UserDatastoreLoader {
await Deno.mkdir(dirname(bundlePath), { recursive: true });
await Deno.writeTextFile(bundlePath, js);
logger.debug`Wrote datastore bundle cache: ${bundlePath}`;
- return js;
+ return { js, fromCache: false };
} catch (bundleError) {
if (bundleExists) {
try {
@@ -313,7 +318,7 @@ export class UserDatastoreLoader {
logger
.warn`Rebundle failed for ${relativePath}, using cached bundle: ${msg}`;
}
- return cached;
+ return { js: cached, fromCache: true };
} catch {
// Cache file was removed between stat and read — treat as no cache.
}
@@ -323,7 +328,8 @@ export class UserDatastoreLoader {
}
// No repo dir — just bundle without caching
- return await bundleExtension(absolutePath, denoPath);
+ const js = await bundleExtension(absolutePath, denoPath);
+ return { js, fromCache: false };
}
/**
@@ -717,6 +723,7 @@ export class UserDatastoreLoader {
typeNormalized: string;
bundlePath: string;
fingerprint: string;
+ fromCache: boolean;
}
| null
> {
@@ -727,7 +734,7 @@ export class UserDatastoreLoader {
installZodGlobal();
const denoPath = await this.denoRuntime.ensureDeno();
- const js = await this.bundleWithCache(
+ const { js, fromCache } = await this.bundleWithCache(
args.absolutePath,
args.relativePath,
denoPath,
@@ -755,6 +762,7 @@ export class UserDatastoreLoader {
typeNormalized: parsed.data.type.toLowerCase(),
bundlePath,
fingerprint,
+ fromCache,
};
}
@@ -773,7 +781,7 @@ export class UserDatastoreLoader {
return;
}
- const js = await this.bundleWithCache(
+ const { js, fromCache } = await this.bundleWithCache(
absolutePath,
relativePath,
denoPath,
@@ -793,6 +801,23 @@ export class UserDatastoreLoader {
absolutePath,
baseDir,
);
+
+ // When the bundle came from cache (build failed or isExpectedBundleFailure),
+ // preserve the catalog's stored fingerprint so findStaleFiles retries on the
+ // next warm-start. Only warn on the fallback case (fingerprint actually
+ // differs), not on legitimate cache hits where source hasn't changed.
+ let effectiveFingerprint = sourceFingerprint;
+ if (fromCache) {
+ const existing = catalog.findBySourcePath(absolutePath);
+ if (existing?.source_fingerprint) {
+ if (existing.source_fingerprint !== sourceFingerprint) {
+ logger
+ .warn`Bundle could not be regenerated for ${relativePath} — source fingerprint preserved, will retry on next command`;
+ }
+ effectiveFingerprint = existing.source_fingerprint;
+ }
+ }
+
const bundlePath = this.getDatastoreBundlePath(relativePath, baseDir);
const parsed = UserDatastoreSchema.safeParse(module.datastore);
@@ -803,7 +828,7 @@ export class UserDatastoreLoader {
kind: "datastore",
bundlePath,
sourceMtime,
- sourceFingerprint,
+ sourceFingerprint: effectiveFingerprint,
});
throw new Error(this.formatValidationError(parsed.error));
}
@@ -819,7 +844,7 @@ export class UserDatastoreLoader {
description: parsed.data.description,
extends_type: "",
source_mtime: sourceMtime,
- source_fingerprint: sourceFingerprint,
+ source_fingerprint: effectiveFingerprint,
});
// Also register since we already imported
diff --git a/src/domain/drivers/user_driver_loader.ts b/src/domain/drivers/user_driver_loader.ts
index a7b2b59c..15271a9f 100644
--- a/src/domain/drivers/user_driver_loader.ts
+++ b/src/domain/drivers/user_driver_loader.ts
@@ -31,6 +31,7 @@ import {
uint8ArrayToBase64,
} from "../models/bundle.ts";
import {
+ type BundleResult,
computeSourceFingerprint,
createFreshnessCache,
findStaleFiles as findStaleFilesShared,
@@ -205,12 +206,16 @@ export class UserDriverLoader {
continue;
}
- const js = await this.bundleWithCache(
+ const { js, fromCache } = await this.bundleWithCache(
absolutePath,
file,
denoPath,
baseDir,
);
+ if (fromCache) {
+ logger
+ .warn`Using cached bundle for ${file} — source may have changed but bundle could not be regenerated`;
+ }
const module = await this.importBundle(js, file, baseDir);
if (!module.driver) {
@@ -267,7 +272,7 @@ export class UserDriverLoader {
relativePath: string,
denoPath: string,
boundaryDir: string,
- ): Promise {
+ ): Promise {
if (this.repoDir) {
const bundlePath = this.resolveBundlePath(
bundleNamespace(boundaryDir, this.repoDir),
@@ -294,7 +299,7 @@ export class UserDriverLoader {
// and we'd wastefully spawn Deno before falling back to the
// cached bundle anyway.
if (bundleExists && isExpectedBundleFailure(absolutePath, this.repoDir)) {
- return await Deno.readTextFile(bundlePath);
+ return { js: await Deno.readTextFile(bundlePath), fromCache: true };
}
// Try to rebundle from source. If bundling fails (e.g. bare specifiers
@@ -308,7 +313,7 @@ export class UserDriverLoader {
await Deno.mkdir(dirname(bundlePath), { recursive: true });
await Deno.writeTextFile(bundlePath, js);
logger.debug`Wrote driver bundle cache: ${bundlePath}`;
- return js;
+ return { js, fromCache: false };
} catch (bundleError) {
if (bundleExists) {
try {
@@ -335,7 +340,7 @@ export class UserDriverLoader {
logger
.warn`Rebundle failed for ${relativePath}, using cached bundle: ${msg}`;
}
- return cached;
+ return { js: cached, fromCache: true };
} catch {
// Cache file was removed between stat and read — treat as no cache.
}
@@ -345,7 +350,8 @@ export class UserDriverLoader {
}
// No repo dir — just bundle without caching
- return await bundleExtension(absolutePath, denoPath);
+ const js = await bundleExtension(absolutePath, denoPath);
+ return { js, fromCache: false };
}
/**
@@ -716,6 +722,7 @@ export class UserDriverLoader {
typeNormalized: string;
bundlePath: string;
fingerprint: string;
+ fromCache: boolean;
}
| null
> {
@@ -726,7 +733,7 @@ export class UserDriverLoader {
installZodGlobal();
const denoPath = await this.denoRuntime.ensureDeno();
- const js = await this.bundleWithCache(
+ const { js, fromCache } = await this.bundleWithCache(
args.absolutePath,
args.relativePath,
denoPath,
@@ -754,6 +761,7 @@ export class UserDriverLoader {
typeNormalized: parsed.data.type.toLowerCase(),
bundlePath,
fingerprint,
+ fromCache,
};
}
@@ -772,7 +780,7 @@ export class UserDriverLoader {
return;
}
- const js = await this.bundleWithCache(
+ const { js, fromCache } = await this.bundleWithCache(
absolutePath,
relativePath,
denoPath,
@@ -792,6 +800,23 @@ export class UserDriverLoader {
absolutePath,
baseDir,
);
+
+ // When the bundle came from cache (build failed or isExpectedBundleFailure),
+ // preserve the catalog's stored fingerprint so findStaleFiles retries on the
+ // next warm-start. Only warn on the fallback case (fingerprint actually
+ // differs), not on legitimate cache hits where source hasn't changed.
+ let effectiveFingerprint = sourceFingerprint;
+ if (fromCache) {
+ const existing = catalog.findBySourcePath(absolutePath);
+ if (existing?.source_fingerprint) {
+ if (existing.source_fingerprint !== sourceFingerprint) {
+ logger
+ .warn`Bundle could not be regenerated for ${relativePath} — source fingerprint preserved, will retry on next command`;
+ }
+ effectiveFingerprint = existing.source_fingerprint;
+ }
+ }
+
const bundlePath = this.getDriverBundlePath(relativePath, baseDir);
const parsed = UserDriverSchema.safeParse(module.driver);
@@ -802,7 +827,7 @@ export class UserDriverLoader {
kind: "driver",
bundlePath,
sourceMtime,
- sourceFingerprint,
+ sourceFingerprint: effectiveFingerprint,
});
throw new Error(this.formatValidationError(parsed.error));
}
@@ -818,7 +843,7 @@ export class UserDriverLoader {
description: parsed.data.description,
extends_type: "",
source_mtime: sourceMtime,
- source_fingerprint: sourceFingerprint,
+ source_fingerprint: effectiveFingerprint,
});
// Also register since we already imported
diff --git a/src/domain/extensions/bundle_freshness.ts b/src/domain/extensions/bundle_freshness.ts
index 5c658910..959d46e3 100644
--- a/src/domain/extensions/bundle_freshness.ts
+++ b/src/domain/extensions/bundle_freshness.ts
@@ -66,6 +66,16 @@ export interface FreshnessCatalogRow {
state?: string;
}
+/**
+ * Return type for bundleWithCache across all extension loaders.
+ * Distinguishes freshly-built bundles from stale cache fallbacks so
+ * callers can decide whether to advance the catalog fingerprint.
+ */
+export interface BundleResult {
+ readonly js: string;
+ readonly fromCache: boolean;
+}
+
/**
* Per-invocation cache that dedups file hashing and transitive dep
* resolution across multiple computeSourceFingerprint calls. Safe to
diff --git a/src/domain/models/user_model_loader.ts b/src/domain/models/user_model_loader.ts
index bcb510e3..81294fa2 100644
--- a/src/domain/models/user_model_loader.ts
+++ b/src/domain/models/user_model_loader.ts
@@ -31,6 +31,7 @@ import {
uint8ArrayToBase64,
} from "./bundle.ts";
import {
+ type BundleResult,
computeSourceFingerprint,
createFreshnessCache,
findStaleFiles as findStaleFilesShared,
@@ -430,12 +431,16 @@ export class UserModelLoader {
continue;
}
- const js = await this.bundleWithCache(
+ const { js, fromCache } = await this.bundleWithCache(
absolutePath,
file,
denoPath,
baseDir,
);
+ if (fromCache) {
+ logger
+ .warn`Using cached bundle for ${file} — source may have changed but bundle could not be regenerated`;
+ }
const module = await this.importBundle(js, file, baseDir);
if (module.model) {
@@ -1150,6 +1155,7 @@ export class UserModelLoader {
typeNormalized: string;
bundlePath: string;
fingerprint: string;
+ fromCache: boolean;
}
| null
> {
@@ -1162,7 +1168,7 @@ export class UserModelLoader {
// bundles — same precondition as loadModels / buildIndex.
installZodGlobal();
const denoPath = await this.denoRuntime.ensureDeno();
- const js = await this.bundleWithCache(
+ const { js, fromCache } = await this.bundleWithCache(
args.absolutePath,
args.relativePath,
denoPath,
@@ -1184,6 +1190,7 @@ export class UserModelLoader {
typeNormalized: ModelType.create(parsed.data.type).normalized,
bundlePath: this.getBundlePath(args.relativePath, args.baseDir),
fingerprint,
+ fromCache,
};
}
@@ -1197,6 +1204,7 @@ export class UserModelLoader {
typeNormalized: ModelType.create(parsed.data.type).normalized,
bundlePath: this.getBundlePath(args.relativePath, args.baseDir),
fingerprint,
+ fromCache,
};
}
@@ -1227,7 +1235,7 @@ export class UserModelLoader {
return undefined; // Not a model/extension file
}
- const js = await this.bundleWithCache(
+ const { js, fromCache } = await this.bundleWithCache(
absolutePath,
relativePath,
denoPath,
@@ -1242,6 +1250,22 @@ export class UserModelLoader {
baseDir,
);
+ // When the bundle came from cache (build failed or isExpectedBundleFailure),
+ // preserve the catalog's stored fingerprint so findStaleFiles retries on the
+ // next warm-start. Only warn on the fallback case (fingerprint actually
+ // differs), not on legitimate cache hits where source hasn't changed.
+ let effectiveFingerprint = sourceFingerprint;
+ if (fromCache) {
+ const existing = catalog.findBySourcePath(absolutePath);
+ if (existing?.source_fingerprint) {
+ if (existing.source_fingerprint !== sourceFingerprint) {
+ logger
+ .warn`Bundle could not be regenerated for ${relativePath} — source fingerprint preserved, will retry on next command`;
+ }
+ effectiveFingerprint = existing.source_fingerprint;
+ }
+ }
+
if (module.model) {
const bundlePath = this.getBundlePath(relativePath, baseDir);
const parsed = UserModelSchema.safeParse(module.model);
@@ -1252,7 +1276,7 @@ export class UserModelLoader {
kind: "model",
bundlePath,
sourceMtime,
- sourceFingerprint,
+ sourceFingerprint: effectiveFingerprint,
});
throw new Error(formatUserModelError(parsed.error));
}
@@ -1267,7 +1291,7 @@ export class UserModelLoader {
description: "",
extends_type: "",
source_mtime: sourceMtime,
- source_fingerprint: sourceFingerprint,
+ source_fingerprint: effectiveFingerprint,
});
// Also register the full definition since we already imported it
@@ -1304,7 +1328,7 @@ export class UserModelLoader {
kind: "extension",
bundlePath,
sourceMtime,
- sourceFingerprint,
+ sourceFingerprint: effectiveFingerprint,
});
throw new Error(parsed.error.message);
}
@@ -1319,7 +1343,7 @@ export class UserModelLoader {
description: "",
extends_type: typeNormalized,
source_mtime: sourceMtime,
- source_fingerprint: sourceFingerprint,
+ source_fingerprint: effectiveFingerprint,
});
}
@@ -1376,7 +1400,7 @@ export class UserModelLoader {
relativePath: string,
denoPath: string,
boundaryDir: string,
- ): Promise {
+ ): Promise {
if (this.repoDir) {
const bundlePath = this.resolveBundlePath(
bundleNamespace(boundaryDir, this.repoDir),
@@ -1407,7 +1431,7 @@ export class UserModelLoader {
// findStaleFiles — this branch only fires when both a bundle
// exists AND the source can't be locally rebundled.
if (bundleExists && isExpectedBundleFailure(absolutePath, this.repoDir)) {
- return await Deno.readTextFile(bundlePath);
+ return { js: await Deno.readTextFile(bundlePath), fromCache: true };
}
// Try to rebundle from source. If bundling fails (e.g. bare specifiers
@@ -1431,7 +1455,7 @@ export class UserModelLoader {
await Deno.mkdir(dirname(bundlePath), { recursive: true });
await Deno.writeTextFile(bundlePath, js);
logger.debug`Wrote bundle cache: ${bundlePath}`;
- return js;
+ return { js, fromCache: false };
} catch (bundleError) {
if (bundleExists) {
try {
@@ -1460,7 +1484,7 @@ export class UserModelLoader {
// Do NOT touch the cache mtime — the next run should retry
// bundling so the user sees the error again until fixed.
}
- return cached;
+ return { js: cached, fromCache: true };
} catch {
// Cache file was removed between stat and read — treat as no cache.
}
@@ -1475,7 +1499,10 @@ export class UserModelLoader {
logger
.warn`Using discovered deno config for ${absolutePath}: ${denoConfigPath}`;
}
- return await bundleExtension(absolutePath, denoPath, { denoConfigPath });
+ const js = await bundleExtension(absolutePath, denoPath, {
+ denoConfigPath,
+ });
+ return { js, fromCache: false };
}
/**
diff --git a/src/domain/reports/user_report_loader.ts b/src/domain/reports/user_report_loader.ts
index 32ec92ea..363ca26b 100644
--- a/src/domain/reports/user_report_loader.ts
+++ b/src/domain/reports/user_report_loader.ts
@@ -30,6 +30,7 @@ import {
uint8ArrayToBase64,
} from "../models/bundle.ts";
import {
+ type BundleResult,
computeSourceFingerprint,
createFreshnessCache,
findStaleFiles as findStaleFilesShared,
@@ -205,12 +206,16 @@ export class UserReportLoader {
continue;
}
- const js = await this.bundleWithCache(
+ const { js, fromCache } = await this.bundleWithCache(
absolutePath,
file,
denoPath,
baseDir,
);
+ if (fromCache) {
+ logger
+ .warn`Using cached bundle for ${file} — source may have changed but bundle could not be regenerated`;
+ }
const module = await this.importBundle(js, file, baseDir);
if (!module.report) {
@@ -566,6 +571,7 @@ export class UserReportLoader {
typeNormalized: string;
bundlePath: string;
fingerprint: string;
+ fromCache: boolean;
}
| null
> {
@@ -576,7 +582,7 @@ export class UserReportLoader {
installZodGlobal();
const denoPath = await this.denoRuntime.ensureDeno();
- const js = await this.bundleWithCache(
+ const { js, fromCache } = await this.bundleWithCache(
args.absolutePath,
args.relativePath,
denoPath,
@@ -604,6 +610,7 @@ export class UserReportLoader {
typeNormalized: parsed.data.name.toLowerCase(),
bundlePath,
fingerprint,
+ fromCache,
};
}
@@ -622,7 +629,7 @@ export class UserReportLoader {
return;
}
- const js = await this.bundleWithCache(
+ const { js, fromCache } = await this.bundleWithCache(
absolutePath,
relativePath,
denoPath,
@@ -642,6 +649,23 @@ export class UserReportLoader {
absolutePath,
baseDir,
);
+
+ // When the bundle came from cache (build failed or isExpectedBundleFailure),
+ // preserve the catalog's stored fingerprint so findStaleFiles retries on the
+ // next warm-start. Only warn on the fallback case (fingerprint actually
+ // differs), not on legitimate cache hits where source hasn't changed.
+ let effectiveFingerprint = sourceFingerprint;
+ if (fromCache) {
+ const existing = catalog.findBySourcePath(absolutePath);
+ if (existing?.source_fingerprint) {
+ if (existing.source_fingerprint !== sourceFingerprint) {
+ logger
+ .warn`Bundle could not be regenerated for ${relativePath} — source fingerprint preserved, will retry on next command`;
+ }
+ effectiveFingerprint = existing.source_fingerprint;
+ }
+ }
+
const bundlePath = this.getReportBundlePath(relativePath, baseDir);
const parsed = UserReportSchema.safeParse(module.report);
@@ -652,7 +676,7 @@ export class UserReportLoader {
kind: "report",
bundlePath,
sourceMtime,
- sourceFingerprint,
+ sourceFingerprint: effectiveFingerprint,
});
throw new Error(this.formatValidationError(parsed.error));
}
@@ -668,7 +692,7 @@ export class UserReportLoader {
description: parsed.data.description,
extends_type: "",
source_mtime: sourceMtime,
- source_fingerprint: sourceFingerprint,
+ source_fingerprint: effectiveFingerprint,
});
// Also register since we already imported
@@ -704,7 +728,7 @@ export class UserReportLoader {
relativePath: string,
denoPath: string,
boundaryDir: string,
- ): Promise {
+ ): Promise {
if (this.repoDir) {
const bundlePath = this.resolveBundlePath(
bundleNamespace(boundaryDir, this.repoDir),
@@ -731,7 +755,7 @@ export class UserReportLoader {
// and we'd wastefully spawn Deno before falling back to the
// cached bundle anyway.
if (bundleExists && isExpectedBundleFailure(absolutePath, this.repoDir)) {
- return await Deno.readTextFile(bundlePath);
+ return { js: await Deno.readTextFile(bundlePath), fromCache: true };
}
// Try to rebundle from source. If bundling fails (e.g. bare specifiers
@@ -745,7 +769,7 @@ export class UserReportLoader {
await Deno.mkdir(dirname(bundlePath), { recursive: true });
await Deno.writeTextFile(bundlePath, js);
logger.debug`Wrote report bundle cache: ${bundlePath}`;
- return js;
+ return { js, fromCache: false };
} catch (bundleError) {
if (bundleExists) {
try {
@@ -772,7 +796,7 @@ export class UserReportLoader {
logger
.warn`Rebundle failed for ${relativePath}, using cached bundle: ${msg}`;
}
- return cached;
+ return { js: cached, fromCache: true };
} catch {
// Cache file was removed between stat and read — treat as no cache.
}
@@ -782,7 +806,8 @@ export class UserReportLoader {
}
// No repo dir — just bundle without caching
- return await bundleExtension(absolutePath, denoPath);
+ const js = await bundleExtension(absolutePath, denoPath);
+ return { js, fromCache: false };
}
/**
diff --git a/src/domain/vaults/user_vault_loader.ts b/src/domain/vaults/user_vault_loader.ts
index a35be244..191cc675 100644
--- a/src/domain/vaults/user_vault_loader.ts
+++ b/src/domain/vaults/user_vault_loader.ts
@@ -31,6 +31,7 @@ import {
uint8ArrayToBase64,
} from "../models/bundle.ts";
import {
+ type BundleResult,
computeSourceFingerprint,
createFreshnessCache,
findStaleFiles as findStaleFilesShared,
@@ -209,12 +210,16 @@ export class UserVaultLoader {
continue;
}
- const js = await this.bundleWithCache(
+ const { js, fromCache } = await this.bundleWithCache(
absolutePath,
file,
denoPath,
baseDir,
);
+ if (fromCache) {
+ logger
+ .warn`Using cached bundle for ${file} — source may have changed but bundle could not be regenerated`;
+ }
const module = await this.importBundle(js, file, baseDir);
if (!module.vault) {
@@ -272,7 +277,7 @@ export class UserVaultLoader {
relativePath: string,
denoPath: string,
boundaryDir: string,
- ): Promise {
+ ): Promise {
if (this.repoDir) {
const bundlePath = this.resolveBundlePath(
bundleNamespace(boundaryDir, this.repoDir),
@@ -299,7 +304,7 @@ export class UserVaultLoader {
// and we'd wastefully spawn Deno before falling back to the
// cached bundle anyway.
if (bundleExists && isExpectedBundleFailure(absolutePath, this.repoDir)) {
- return await Deno.readTextFile(bundlePath);
+ return { js: await Deno.readTextFile(bundlePath), fromCache: true };
}
// Try to rebundle from source. If bundling fails (e.g. bare specifiers
@@ -313,7 +318,7 @@ export class UserVaultLoader {
await Deno.mkdir(dirname(bundlePath), { recursive: true });
await Deno.writeTextFile(bundlePath, js);
logger.debug`Wrote vault bundle cache: ${bundlePath}`;
- return js;
+ return { js, fromCache: false };
} catch (bundleError) {
if (bundleExists) {
try {
@@ -340,7 +345,7 @@ export class UserVaultLoader {
logger
.warn`Rebundle failed for ${relativePath}, using cached bundle: ${msg}`;
}
- return cached;
+ return { js: cached, fromCache: true };
} catch {
// Cache file was removed between stat and read — treat as no cache.
}
@@ -350,7 +355,8 @@ export class UserVaultLoader {
}
// No repo dir — just bundle without caching
- return await bundleExtension(absolutePath, denoPath);
+ const js = await bundleExtension(absolutePath, denoPath);
+ return { js, fromCache: false };
}
/**
@@ -725,6 +731,7 @@ export class UserVaultLoader {
typeNormalized: string;
bundlePath: string;
fingerprint: string;
+ fromCache: boolean;
}
| null
> {
@@ -735,7 +742,7 @@ export class UserVaultLoader {
installZodGlobal();
const denoPath = await this.denoRuntime.ensureDeno();
- const js = await this.bundleWithCache(
+ const { js, fromCache } = await this.bundleWithCache(
args.absolutePath,
args.relativePath,
denoPath,
@@ -763,6 +770,7 @@ export class UserVaultLoader {
typeNormalized: parsed.data.type.toLowerCase(),
bundlePath,
fingerprint,
+ fromCache,
};
}
@@ -781,7 +789,7 @@ export class UserVaultLoader {
return;
}
- const js = await this.bundleWithCache(
+ const { js, fromCache } = await this.bundleWithCache(
absolutePath,
relativePath,
denoPath,
@@ -803,6 +811,22 @@ export class UserVaultLoader {
);
const bundlePath = this.getVaultBundlePath(relativePath, baseDir);
+ // When the bundle came from cache (build failed or isExpectedBundleFailure),
+ // preserve the catalog's stored fingerprint so findStaleFiles retries on the
+ // next warm-start. Only warn on the fallback case (fingerprint actually
+ // differs), not on legitimate cache hits where source hasn't changed.
+ let effectiveFingerprint = sourceFingerprint;
+ if (fromCache) {
+ const existing = catalog.findBySourcePath(absolutePath);
+ if (existing?.source_fingerprint) {
+ if (existing.source_fingerprint !== sourceFingerprint) {
+ logger
+ .warn`Bundle could not be regenerated for ${relativePath} — source fingerprint preserved, will retry on next command`;
+ }
+ effectiveFingerprint = existing.source_fingerprint;
+ }
+ }
+
const parsed = UserVaultSchema.safeParse(module.vault);
if (!parsed.success) {
markCatalogValidationFailed({
@@ -811,7 +835,7 @@ export class UserVaultLoader {
kind: "vault",
bundlePath,
sourceMtime,
- sourceFingerprint,
+ sourceFingerprint: effectiveFingerprint,
});
throw new Error(this.formatValidationError(parsed.error));
}
@@ -827,7 +851,7 @@ export class UserVaultLoader {
description: parsed.data.description,
extends_type: "",
source_mtime: sourceMtime,
- source_fingerprint: sourceFingerprint,
+ source_fingerprint: effectiveFingerprint,
});
// Also register since we already imported
diff --git a/src/infrastructure/persistence/extension_catalog_store.ts b/src/infrastructure/persistence/extension_catalog_store.ts
index 0bb81a60..f3b457f0 100644
--- a/src/infrastructure/persistence/extension_catalog_store.ts
+++ b/src/infrastructure/persistence/extension_catalog_store.ts
@@ -689,6 +689,17 @@ export class ExtensionCatalogStore {
);
}
+ /**
+ * Returns the entry for a specific source path (PK lookup), or undefined.
+ */
+ findBySourcePath(sourcePath: string): ExtensionTypeRow | undefined {
+ const stmt = this.db.prepare(
+ "SELECT * FROM bundle_types WHERE source_path = ?",
+ );
+ const row = stmt.get(sourcePath) as Record | undefined;
+ return row ? this.mapRow(row) : undefined;
+ }
+
/**
* Removes a bundle type entry by source path.
*/