diff --git a/packages/cli/src/__tests__/dev-with-memory-lib.test.ts b/packages/cli/src/__tests__/dev-with-memory-lib.test.ts
index 7911b8d387..ddc93fee31 100644
--- a/packages/cli/src/__tests__/dev-with-memory-lib.test.ts
+++ b/packages/cli/src/__tests__/dev-with-memory-lib.test.ts
@@ -69,12 +69,22 @@ describe("dev-with-memory prebuild options", () => {
]);
});
- it("defaults dashboard startup to client-only prebuild instead of full workspace build", () => {
+ it("rebuilds core + engine + dashboard (UI) for dashboard startup, not the full workspace", () => {
+ // FN-6638/stale-dist: dev dashboard must refresh engine + core dist (not
+ // just the client bundle) so landed fixes are not silently stale.
expect(resolvePrebuildMode("auto", ["dashboard", "--port", "4050"])).toBe("client");
expect(getPrebuildCommand("client")).toEqual({
command: "pnpm",
- args: ["--filter", "@fusion/dashboard", "build:client"],
- label: "dashboard client build",
+ args: [
+ "--filter",
+ "@fusion/core",
+ "--filter",
+ "@fusion/engine",
+ "--filter",
+ "@fusion/dashboard",
+ "build",
+ ],
+ label: "core + engine + dashboard build",
});
});
diff --git a/scripts/__tests__/dist-freshness.test.mjs b/scripts/__tests__/dist-freshness.test.mjs
new file mode 100644
index 0000000000..2c8dc79e50
--- /dev/null
+++ b/scripts/__tests__/dist-freshness.test.mjs
@@ -0,0 +1,90 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+
+import { computeDistStaleness, formatDistStalenessWarning } from "../lib/dist-freshness.mjs";
+
+/*
+FNXC:DevWorkflow 2026-06-18-16:50:
+FN-6638 stale-dist guard tests. Verifies the startup freshness check flags a
+src-ahead-of-dist build, stays quiet when fresh, and never false-positives for
+pure-source (no dist) or packaged (no src) layouts.
+*/
+
+// In-memory fs seam: paths are exact strings; dirs list children; files carry mtimeMs.
+function makeFs({ dirs, files }) {
+ const dirSet = new Set(dirs);
+ // files: { "
": [{ name, mtimeMs, isDir? }] } keyed by parent dir
+ return {
+ existsSync: (p) => dirSet.has(p),
+ readdirSync: (dir) =>
+ (files[dir] ?? []).map((e) => ({
+ name: e.name,
+ isDirectory: () => Boolean(e.isDir),
+ })),
+ statSync: (p) => {
+ // p is "/"; look it up by scanning entries
+ for (const [dir, entries] of Object.entries(files)) {
+ for (const e of entries) {
+ if (`${dir}/${e.name}` === p) return { mtimeMs: e.mtimeMs };
+ }
+ }
+ return { mtimeMs: 0 };
+ },
+ };
+}
+
+const ROOT = "/repo";
+
+function layout({ srcMs, distMs, withSrc = true, withDist = true }) {
+ const dirs = [];
+ const files = {};
+ const srcDir = `${ROOT}/packages/engine/src`;
+ const distDir = `${ROOT}/packages/engine/dist`;
+ if (withSrc) {
+ dirs.push(srcDir);
+ files[srcDir] = [{ name: "executor.ts", mtimeMs: srcMs }];
+ }
+ if (withDist) {
+ dirs.push(distDir);
+ files[distDir] = [{ name: "executor.js", mtimeMs: distMs }];
+ }
+ return makeFs({ dirs, files });
+}
+
+test("flags stale when src is newer than dist beyond slack", () => {
+ const fs = layout({ srcMs: 10_000, distMs: 1_000 });
+ const result = computeDistStaleness({ rootDir: ROOT, packages: ["engine"], fs });
+ assert.equal(result.stale, true);
+ assert.equal(result.packages[0].stale, true);
+ const warning = formatDistStalenessWarning(result);
+ assert.match(warning, /STALE BUILD/);
+ assert.match(warning, /@fusion\/engine/);
+ assert.match(warning, /pnpm build/);
+});
+
+test("not stale when dist is newer than src", () => {
+ const fs = layout({ srcMs: 1_000, distMs: 10_000 });
+ const result = computeDistStaleness({ rootDir: ROOT, packages: ["engine"], fs });
+ assert.equal(result.stale, false);
+ assert.equal(formatDistStalenessWarning(result), null);
+});
+
+test("not stale within slack window", () => {
+ const fs = layout({ srcMs: 1_500, distMs: 1_000 }); // 500ms < 2000ms slack
+ const result = computeDistStaleness({ rootDir: ROOT, packages: ["engine"], fs });
+ assert.equal(result.stale, false);
+});
+
+test("skips packages with no dist (pure source run)", () => {
+ const fs = layout({ srcMs: 10_000, distMs: 0, withDist: false });
+ const result = computeDistStaleness({ rootDir: ROOT, packages: ["engine"], fs });
+ assert.equal(result.stale, false);
+ assert.equal(result.packages.length, 0);
+});
+
+test("skips packages with no src (packaged install)", () => {
+ const fs = layout({ srcMs: 0, distMs: 10_000, withSrc: false });
+ const result = computeDistStaleness({ rootDir: ROOT, packages: ["engine"], fs });
+ assert.equal(result.stale, false);
+ assert.equal(result.packages.length, 0);
+});
diff --git a/scripts/dev-with-memory-lib.mjs b/scripts/dev-with-memory-lib.mjs
index 2e4679c337..bbf0f378b0 100644
--- a/scripts/dev-with-memory-lib.mjs
+++ b/scripts/dev-with-memory-lib.mjs
@@ -93,10 +93,30 @@ export function getPrebuildCommand(mode) {
case "full":
return { command: "pnpm", args: ["build"], label: "workspace build" };
case "client":
+ /*
+ FNXC:DevWorkflow 2026-06-18-16:40:
+ FN-6638/stale-dist: `pnpm dev dashboard` must rebuild @fusion/core and
+ @fusion/engine alongside the dashboard UI, not only the client bundle.
+ Although the CLI runs under `--conditions=source` (engine/core resolve to
+ src), the running process and any dist-resolving consumer (plugins,
+ sub-imports, a later non-dev `fn`/`pnpm local`) load built dist. Leaving
+ engine/core dist stale is exactly how landed fixes (FN-6644/6647/6648,
+ etc.) silently failed to run for ~2 days. pnpm builds these in dependency
+ order (core → engine → dashboard); dashboard `build` runs the vite client
+ bundle + server tsc, so the UI is rebuilt too.
+ */
return {
command: "pnpm",
- args: ["--filter", "@fusion/dashboard", "build:client"],
- label: "dashboard client build",
+ args: [
+ "--filter",
+ "@fusion/core",
+ "--filter",
+ "@fusion/engine",
+ "--filter",
+ "@fusion/dashboard",
+ "build",
+ ],
+ label: "core + engine + dashboard build",
};
case "none":
case "auto":
diff --git a/scripts/dev-with-memory.mjs b/scripts/dev-with-memory.mjs
index f5ec1c371b..46bb94b8be 100644
--- a/scripts/dev-with-memory.mjs
+++ b/scripts/dev-with-memory.mjs
@@ -129,6 +129,25 @@ async function warnIfSourceVersionBehind() {
await warnIfSourceVersionBehind();
+// FNXC:DevWorkflow 2026-06-18-16:50:
+// FN-6638 stale-dist guard. Warn (loudly, best-effort) when built dist/ is older
+// than src/ so a never-rebuilt/never-restarted process does not silently run
+// phantom-old code. When a prebuild is about to run it will refresh dist, so the
+// check is informational there; for --prebuild none / dist-resolving consumers
+// it is the safety net. Never let the check break startup.
+async function warnIfDistStale() {
+ if (process.env.FUSION_SKIP_DIST_FRESHNESS_CHECK === "1") return;
+ try {
+ const { computeDistStaleness, formatDistStalenessWarning } = await import("./lib/dist-freshness.mjs");
+ const warning = formatDistStalenessWarning(computeDistStaleness({ rootDir: process.cwd() }));
+ if (warning) console.warn(warning);
+ } catch {
+ // Best-effort only. Startup must not depend on the freshness check.
+ }
+}
+
+await warnIfDistStale();
+
if (!prebuildCommand) {
runApp(forwardedArgs);
} else {
diff --git a/scripts/lib/dist-freshness.mjs b/scripts/lib/dist-freshness.mjs
new file mode 100644
index 0000000000..7da4d37221
--- /dev/null
+++ b/scripts/lib/dist-freshness.mjs
@@ -0,0 +1,110 @@
+/*
+FNXC:DevWorkflow 2026-06-18-16:50:
+FN-6638 stale-dist guard. The running Fusion process loads built `dist/` for
+@fusion/core, @fusion/engine, and @fusion/dashboard (directly, via plugins, via
+dist-resolving sub-imports, or whenever a non-dev/packaged `fn` runs). When a
+long-lived process or a stale build runs `dist/` that is OLDER than the `src/`
+on disk, landed fixes silently never execute — that is how FN-6644/6647/6648
+(and others) appeared "fixed" for ~2 days while the running engine still parked
+completed tasks failed. This module computes that staleness so startup can warn
+loudly (rebuild + restart) instead of running phantom-old code.
+
+Design / guardrails:
+- Pure + injectable (fs + now) so it is unit-testable and never throws into the
+ startup path.
+- A package is only evaluated when BOTH its `src/` and `dist/` exist. Missing
+ `dist/` = running purely from source (fresh, not stale). Missing `src/` =
+ packaged/published install with no source tree to compare against (not stale).
+- Staleness = newest `.ts`/`.tsx` mtime under `src/` is NEWER than the package's
+ dist build marker (newest `.js` mtime under `dist/`), beyond a small slack to
+ absorb filesystem mtime jitter.
+*/
+
+import { existsSync, readdirSync, statSync } from "node:fs";
+import { join } from "node:path";
+
+const DEFAULT_PACKAGES = ["core", "engine", "dashboard"];
+// Slack absorbs build/checkout mtime jitter so we only flag a real source-ahead.
+const DEFAULT_SLACK_MS = 2_000;
+const SRC_EXTENSIONS = [".ts", ".tsx"];
+const DIST_EXTENSIONS = [".js"];
+// Never descend into these — they are not the package's own emitted output.
+const SKIP_DIRS = new Set(["node_modules", ".git", "__tests__", "coverage"]);
+
+function newestMtimeMs(dir, extensions, fs) {
+ let newest = 0;
+ let stack = [dir];
+ while (stack.length > 0) {
+ const current = stack.pop();
+ let entries;
+ try {
+ entries = fs.readdirSync(current, { withFileTypes: true });
+ } catch {
+ continue;
+ }
+ for (const entry of entries) {
+ if (entry.isDirectory()) {
+ if (SKIP_DIRS.has(entry.name)) continue;
+ stack.push(join(current, entry.name));
+ continue;
+ }
+ if (!extensions.some((ext) => entry.name.endsWith(ext))) continue;
+ try {
+ const ms = fs.statSync(join(current, entry.name)).mtimeMs;
+ if (ms > newest) newest = ms;
+ } catch {
+ // unreadable file — ignore, do not let it break the scan
+ }
+ }
+ }
+ return newest;
+}
+
+/**
+ * Compute dist staleness for a source checkout.
+ *
+ * @param {object} [options]
+ * @param {string} [options.rootDir] repo root (defaults to cwd)
+ * @param {string[]} [options.packages] package dir names under packages/
+ * @param {number} [options.slackMs] mtime slack
+ * @param {object} [options.fs] fs seam ({ existsSync, readdirSync, statSync })
+ * @returns {{ stale: boolean, packages: Array<{ name: string, srcNewestMs: number, distNewestMs: number, stale: boolean }> }}
+ */
+export function computeDistStaleness(options = {}) {
+ const rootDir = options.rootDir ?? process.cwd();
+ const packages = options.packages ?? DEFAULT_PACKAGES;
+ const slackMs = options.slackMs ?? DEFAULT_SLACK_MS;
+ const fs = options.fs ?? { existsSync, readdirSync, statSync };
+
+ const results = [];
+ for (const name of packages) {
+ const srcDir = join(rootDir, "packages", name, "src");
+ const distDir = join(rootDir, "packages", name, "dist");
+ // Both must exist: no src = packaged install; no dist = pure source run.
+ if (!fs.existsSync(srcDir) || !fs.existsSync(distDir)) continue;
+ const srcNewestMs = newestMtimeMs(srcDir, SRC_EXTENSIONS, fs);
+ const distNewestMs = newestMtimeMs(distDir, DIST_EXTENSIONS, fs);
+ if (srcNewestMs === 0 || distNewestMs === 0) continue;
+ const stale = srcNewestMs - distNewestMs > slackMs;
+ results.push({ name, srcNewestMs, distNewestMs, stale });
+ }
+ return { stale: results.some((r) => r.stale), packages: results };
+}
+
+/**
+ * Build the operator warning lines for a stale result (or null when fresh).
+ * Kept separate from I/O so it is testable and the caller owns logging.
+ */
+export function formatDistStalenessWarning(result) {
+ if (!result || !result.stale) return null;
+ const staleNames = result.packages.filter((p) => p.stale).map((p) => p.name);
+ return [
+ "",
+ `[fusion] ⚠ STALE BUILD: ${staleNames.map((n) => `@fusion/${n}`).join(", ")} dist/ is OLDER than src/.`,
+ "[fusion] The running process may execute outdated compiled code, so recently landed",
+ "[fusion] fixes will NOT take effect until you rebuild AND restart:",
+ "[fusion] pnpm build # then restart the dashboard/engine process",
+ "[fusion] (Set FUSION_SKIP_DIST_FRESHNESS_CHECK=1 to silence this check.)",
+ "",
+ ].join("\n");
+}