From 8297762eebb648e79ce22e1e7c7af02376a9bd06 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 20:17:56 -0700 Subject: [PATCH 1/4] Address PR review feedback (#1780) - P2: filter directly-changed test files to paths that still exist on disk (existingChangedTestFilesInPackage) so deleted/renamed .test paths from `git diff` never reach `vitest run` positionally; all-deletions diff falls into the delegate-to-gate path. - P1: make heavy-package delegation gate-coverage-aware (GATE_COVERED_MEMORY_ENVELOPE_PACKAGES). Engine delegation keeps the accurate "curated engine-core subset ran above" note; dashboard delegation now warns that the gate runs no dashboard tests and names the CI full-suite backstop, so the coverage gap is loud instead of a silent false-green. - +4 regression tests (115/115). Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/__tests__/test-changed.test.mjs | 67 +++++++++++++++++++++ scripts/test-changed.mjs | 77 ++++++++++++++++++++++--- 2 files changed, 135 insertions(+), 9 deletions(-) diff --git a/scripts/__tests__/test-changed.test.mjs b/scripts/__tests__/test-changed.test.mjs index 3b08603696..62e85c4c0f 100644 --- a/scripts/__tests__/test-changed.test.mjs +++ b/scripts/__tests__/test-changed.test.mjs @@ -45,6 +45,8 @@ import { partitionScopedAffectedPackages, isTestFilePath, changedSourceFilesAffectingPackage, + existingChangedTestFilesInPackage, + GATE_COVERED_MEMORY_ENVELOPE_PACKAGES, } from "../test-changed.mjs"; import { deriveBudgetMs } from "../lib/run-vitest-watchdog.mjs"; @@ -1867,3 +1869,68 @@ test("changedSourceFilesAffectingPackage: out-of-graph and irrelevant paths stay [], ); }); + +// FNXC:TestInfrastructure 2026-06-26-09:15: `git diff --name-only` lists deleted / +// renamed-away `.test` paths; those must NOT reach the positional `vitest run ` +// call (they would fail the bounded lane on a missing file). Regression for the P2 +// deletion case: filter the directly-changed test files to ones still on disk, and +// an all-deletions diff must yield [] so the caller delegates to the gate. +test("existingChangedTestFilesInPackage: keeps live in-package test files, drops deleted ones", () => { + const tmp = mkdtempSync(path.join(tmpdir(), "fusion-changed-tests-")); + try { + const liveRel = "packages/engine/src/__tests__/live.test.ts"; + const deletedRel = "packages/engine/src/__tests__/deleted.test.ts"; + mkdirSync(path.join(tmp, "packages/engine/src/__tests__"), { recursive: true }); + writeFileSync(path.join(tmp, liveRel), "// live\n"); + // deletedRel intentionally NOT written to disk (simulates a removed test) + assert.deepEqual( + existingChangedTestFilesInPackage([deletedRel, liveRel], "packages/engine", { projectRoot: tmp }), + [liveRel], + ); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("existingChangedTestFilesInPackage: all-deletions diff yields empty (delegate-to-gate path)", () => { + const tmp = mkdtempSync(path.join(tmpdir(), "fusion-changed-tests-")); + try { + // No test files written: every changed test path was a deletion. + assert.deepEqual( + existingChangedTestFilesInPackage( + ["packages/engine/src/__tests__/gone-a.test.ts", "packages/engine/src/__tests__/gone-b.test.ts"], + "packages/engine", + { projectRoot: tmp }, + ), + [], + ); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("existingChangedTestFilesInPackage: excludes non-test and out-of-package paths", () => { + const tmp = mkdtempSync(path.join(tmpdir(), "fusion-changed-tests-")); + try { + const inPkgSource = "packages/engine/src/self-healing.ts"; + const otherPkgTest = "packages/dashboard/src/__tests__/x.test.ts"; + mkdirSync(path.join(tmp, "packages/engine/src"), { recursive: true }); + mkdirSync(path.join(tmp, "packages/dashboard/src/__tests__"), { recursive: true }); + writeFileSync(path.join(tmp, inPkgSource), "// src\n"); + writeFileSync(path.join(tmp, otherPkgTest), "// other\n"); + assert.deepEqual( + existingChangedTestFilesInPackage([inPkgSource, otherPkgTest], "packages/engine", { projectRoot: tmp }), + [], + ); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// FNXC:TestInfrastructure 2026-06-26-09:15: the merge gate re-covers a delegated +// engine lane (curated engine-core subset) but runs NO dashboard tests. Lock that +// asymmetry so the delegation messaging never overclaims dashboard gate coverage. +test("GATE_COVERED_MEMORY_ENVELOPE_PACKAGES: engine covered, dashboard not", () => { + assert.equal(GATE_COVERED_MEMORY_ENVELOPE_PACKAGES.has(ENGINE_SCOPED_AFFECTED_PACKAGE), true); + assert.equal(GATE_COVERED_MEMORY_ENVELOPE_PACKAGES.has(DASHBOARD_SCOPED_AFFECTED_PACKAGE), false); +}); diff --git a/scripts/test-changed.mjs b/scripts/test-changed.mjs index 141d7007c6..027dd83ec6 100644 --- a/scripts/test-changed.mjs +++ b/scripts/test-changed.mjs @@ -1337,6 +1337,19 @@ export const SCOPED_AFFECTED_MEMORY_ENVELOPES = Object.freeze({ }), }); +/* +FNXC:TestInfrastructure 2026-06-26-09:15: +Which heavy memory-envelope packages the merge gate (`pnpm test:gate`) genuinely +re-covers when the wide-fan-out guard delegates their cross-cutting coverage. +The gate runs `@fusion/engine test:core` (a curated engine-core allow-list) plus +the CI-shape test — it runs NO `@fusion/dashboard` tests. So a delegated engine +lane still gets a real (curated subset) safety net, but a delegated dashboard +lane gets ZERO gate coverage and would be a silent false-green. Treat dashboard +delegation as a loud "not covered by the gate; CI full-suite.yml is the backstop" +warning instead of a reassuring "delegated to the gate" message. +*/ +export const GATE_COVERED_MEMORY_ENVELOPE_PACKAGES = Object.freeze(new Set([ENGINE_SCOPED_AFFECTED_PACKAGE])); + export function prependNodeOption(currentOptions, option) { return [option, currentOptions || ""].join(" ").trim(); } @@ -1399,6 +1412,35 @@ export function isTestFilePath(file) { return /\.(test|spec)\.[cm]?[jt]sx?$/.test(file); } +/* +FNXC:TestInfrastructure 2026-06-26-09:15: +`changedFiles` comes from `git diff --name-only`, which lists DELETED and +renamed-away `.test`/`.spec` paths alongside live ones. Those paths no longer +exist on disk, but the wide-fan-out guard passes its picks positionally to +`vitest run ` (via `path.relative`). A removed test path reaching Vitest +makes the bounded changed lane fail on a file that is gone instead of treating +the deletion as the no-test-left case. Filter the directly-changed test files +to paths that still EXIST on disk; if every changed test in the package was a +deletion the result is empty, and the caller must then take the same +delegate-to-the-gate path as the "no changed test files" case rather than +handing Vitest an empty/garbage positional set. +*/ +/** + * Directly-changed, still-on-disk test files inside a package directory. + * @param {string[]|null|undefined} changedFiles repo-relative diff paths + * @param {string} pkgDir repo-relative package dir (e.g. "packages/engine") + * @param {{ projectRoot?: string }} [opts] + * @returns {string[]} + */ +export function existingChangedTestFilesInPackage(changedFiles, pkgDir, { projectRoot = rootDir } = {}) { + return (changedFiles ?? []).filter( + (file) => + isTestFilePath(file) && + (file === pkgDir || file.startsWith(`${pkgDir}/`)) && + existsSync(path.join(projectRoot, file)), + ); +} + /* FNXC:TestInfrastructure 2026-06-25-14:30: Why this guard exists (root cause of "pnpm test takes >15min and gets killed"): @@ -1692,21 +1734,38 @@ export async function main(argv = process.argv.slice(2)) { }); if (wideSource.length > 0) { const pkgDir = packageDirByName.get(pkg) ?? `packages/${pkg.replace(/^@[^/]+\//, "")}`; - explicitChangedTestFiles = (changedFiles ?? []).filter( - (file) => isTestFilePath(file) && (file === pkgDir || file.startsWith(`${pkgDir}/`)), - ); + // Filter to test files that still EXIST on disk: `git diff --name-only` + // includes deleted/renamed-away `.test` paths, and a removed path passed + // positionally to `vitest run` (line ~1720) would fail the bounded lane + // on a file that no longer exists. An all-deletions diff yields an empty + // list, which falls into the same delegate-to-gate `continue` below as + // the no-changed-tests case (FNXC:TestInfrastructure 2026-06-26-09:15). + explicitChangedTestFiles = existingChangedTestFilesInPackage(changedFiles, pkgDir); notFullyTestedPackages.add(pkg); + // FNXC:TestInfrastructure 2026-06-26-09:15: the gate re-covers a delegated + // engine lane (curated engine-core subset) but runs NO dashboard tests, so + // a delegated dashboard lane is uncovered. Don't claim "delegated to the + // gate" for packages the gate doesn't run — warn loudly and name the real + // backstop (CI full-suite.yml / `pnpm test:full`) so the gap is visible. + const gateCovered = GATE_COVERED_MEMORY_ENVELOPE_PACKAGES.has(pkg); + const wideSourceDesc = `${wideSource[0]}${wideSource.length > 1 ? `, +${wideSource.length - 1} more` : ""}`; + const delegationNote = gateCovered + ? "delegating wider `vitest --changed` coverage to the merge-gate suite (curated engine-core subset ran above)." + : `the merge gate does NOT run ${pkg} tests, so this wider coverage is NOT re-run here; ` + + "CI full-suite.yml (non-blocking, on push to main) is the backstop. Run `pnpm test:full` for the full sweep."; if (explicitChangedTestFiles.length === 0) { - console.log( - `[test-changed] ${pkg}: a changed non-test source file (${wideSource[0]}${wideSource.length > 1 ? `, +${wideSource.length - 1} more` : ""}) ` + + const log = gateCovered ? console.log : console.warn; + log( + `[test-changed] ${pkg}: a changed non-test source file (${wideSourceDesc}) ` + "would fan `vitest --changed` out to ~the full suite at this heavy 1-worker lane; " + - "delegating cross-cutting coverage to the merge-gate suite (ran above). Run `pnpm test:full` for the full sweep.", + `no directly-changed ${pkg} test file to run, so ${delegationNote}`, ); continue; } - console.log( - `[test-changed] ${pkg}: changed non-test source detected; running ONLY the ${explicitChangedTestFiles.length} directly-changed test file(s) ` + - "and delegating wider `vitest --changed` coverage to the merge-gate suite (ran above).", + const log = gateCovered ? console.log : console.warn; + log( + `[test-changed] ${pkg}: changed non-test source detected; running ONLY the ${explicitChangedTestFiles.length} directly-changed test file(s); ` + + delegationNote, ); } } From 2cff1864c96c44418cbd29d4d08e2a10f7b5cd36 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 21:42:22 -0700 Subject: [PATCH 2/4] fix: bound @fusion/core affected lane + tighten changed watchdog under engine kill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes to make `pnpm test` reliably minimal and fail gracefully: - @fusion/core is now a memory-envelope/wide-fan-out package (was unguarded). It's the hub nearly everything imports (~354 test files), so a core source edit made `vitest --changed` expand to ~the whole core suite and blow past the engine's 15-min verification kill -> SIGKILL + task restart. Adding it to SCOPED_AFFECTED_MEMORY_ENVELOPES applies the wide-fan-out guard (run only directly-changed core tests, else delegate) and the bounded env. core is NOT gate-covered, so delegation warns loudly rather than false-greens. - Lower CLASS_BUDGET_BANDS.changed ceiling 20min -> 13min so the script watchdog fails a runaway local lane itself (exit 124, no restart) BEFORE the engine's 15-min kill restarts the whole task. A tightening, not a timeout-widening. Guard test pins ceiling < 900_000ms. - Raise scoped-affected worker fan-out 1 -> 4 (operator decision). Was 1 only for OOM safety (FN-6854/FN-6874); the fan-out guard now bounds the set so the hundreds-of-files OOM driver no longer reaches these workers. Heap stays 6144MB/worker (~4x6GB on the lane) — revisit if a RAM-constrained CI runner OOMs. Trades FN-5048 worker-knob guidance for throughput, scoped to the bounded affected lanes only. Tests: test-changed 117/117, watchdog 15/15, eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/run-vitest-watchdog.test.mjs | 18 ++++++ scripts/__tests__/test-changed.test.mjs | 63 ++++++++++++++++--- scripts/lib/run-vitest-watchdog.mjs | 15 ++++- scripts/test-changed.mjs | 43 ++++++++++++- 4 files changed, 128 insertions(+), 11 deletions(-) diff --git a/scripts/__tests__/run-vitest-watchdog.test.mjs b/scripts/__tests__/run-vitest-watchdog.test.mjs index 191dbf0ec2..65ba64f730 100644 --- a/scripts/__tests__/run-vitest-watchdog.test.mjs +++ b/scripts/__tests__/run-vitest-watchdog.test.mjs @@ -37,6 +37,24 @@ test("deriveBudgetMs: no fresh timing falls back to the per-class ceiling", () = ); }); +test("the `changed` ceiling must sit below the engine's 15-min verification kill", () => { + // FNXC:TestInfrastructure 2026-06-26-13:05: a stale/missing timings snapshot + // makes deriveBudgetMs return the `changed` ceiling. That ceiling MUST stay + // under VERIFICATION_TIMEOUT_WORKSPACE_MS (900_000 = 15min in + // packages/engine/src/verification-utils.ts) so the script watchdog fails a + // runaway local affected lane itself (exit 124, no task restart) instead of + // letting the engine SIGKILL `pnpm test` and restart the whole task. If this + // assertion ever trips, lower CLASS_BUDGET_BANDS.changed.ceiling — do not + // raise the engine kill. + const ENGINE_VERIFICATION_KILL_MS = 900_000; + const ceiling = deriveBudgetMs({ klass: "changed", expectedDurationMs: 1000, timingsFresh: false }); + assert.equal(ceiling, CLASS_BUDGET_BANDS.changed.ceiling); + assert.ok( + CLASS_BUDGET_BANDS.changed.ceiling < ENGINE_VERIFICATION_KILL_MS, + `changed ceiling ${CLASS_BUDGET_BANDS.changed.ceiling}ms must be < engine kill ${ENGINE_VERIFICATION_KILL_MS}ms`, + ); +}); + test("deriveBudgetMs: fresh timing tightens within the band", () => { // expected×multiplier between floor and ceiling → use the tightened value. // 300s × 3.5 = 1050s, which sits between the shard floor (15min) and diff --git a/scripts/__tests__/test-changed.test.mjs b/scripts/__tests__/test-changed.test.mjs index 62e85c4c0f..b0da039c2d 100644 --- a/scripts/__tests__/test-changed.test.mjs +++ b/scripts/__tests__/test-changed.test.mjs @@ -47,6 +47,11 @@ import { changedSourceFilesAffectingPackage, existingChangedTestFilesInPackage, GATE_COVERED_MEMORY_ENVELOPE_PACKAGES, + SCOPED_AFFECTED_MEMORY_ENVELOPES, + CORE_SCOPED_AFFECTED_PACKAGE, + CORE_SCOPED_AFFECTED_HEAP_MB, + CORE_SCOPED_AFFECTED_WORKERS, + createScopedAffectedMemoryEnvelopeEnv, } from "../test-changed.mjs"; import { deriveBudgetMs } from "../lib/run-vitest-watchdog.mjs"; @@ -289,7 +294,7 @@ function assertScopedAffectedEnv(env, { heapMb, workers }) { assert.equal(env.HOME, "/tmp/fusion-home"); } -test("partitionScopedAffectedPackages: isolates dashboard and engine into separate envelope groups", () => { +test("partitionScopedAffectedPackages: isolates core, dashboard, and engine into separate envelope groups", () => { assert.deepEqual(summarizeScopedAffectedGroups([DASHBOARD_SCOPED_AFFECTED_PACKAGE]), [ { packages: [DASHBOARD_SCOPED_AFFECTED_PACKAGE], @@ -298,19 +303,30 @@ test("partitionScopedAffectedPackages: isolates dashboard and engine into separa }, ]); - assert.deepEqual(summarizeScopedAffectedGroups(["@fusion/core", DASHBOARD_SCOPED_AFFECTED_PACKAGE]), [ - { packages: ["@fusion/core"], engineMemoryEnvelope: false, memoryEnvelopePackage: null }, + // FNXC:TestInfrastructure 2026-06-26-12:40: @fusion/core is now its own + // memory-envelope group (no longer a regular package), so the wide-fan-out + // guard and bounded heap/worker env apply. Group order follows + // SCOPED_AFFECTED_MEMORY_ENVELOPES key order: engine, dashboard, core. + assert.deepEqual(summarizeScopedAffectedGroups([CORE_SCOPED_AFFECTED_PACKAGE, DASHBOARD_SCOPED_AFFECTED_PACKAGE]), [ { packages: [DASHBOARD_SCOPED_AFFECTED_PACKAGE], engineMemoryEnvelope: false, memoryEnvelopePackage: DASHBOARD_SCOPED_AFFECTED_PACKAGE, }, + { + packages: [CORE_SCOPED_AFFECTED_PACKAGE], + engineMemoryEnvelope: false, + memoryEnvelopePackage: CORE_SCOPED_AFFECTED_PACKAGE, + }, ]); assert.deepEqual( - summarizeScopedAffectedGroups(["@fusion/core", DASHBOARD_SCOPED_AFFECTED_PACKAGE, ENGINE_SCOPED_AFFECTED_PACKAGE]), + summarizeScopedAffectedGroups([ + CORE_SCOPED_AFFECTED_PACKAGE, + DASHBOARD_SCOPED_AFFECTED_PACKAGE, + ENGINE_SCOPED_AFFECTED_PACKAGE, + ]), [ - { packages: ["@fusion/core"], engineMemoryEnvelope: false, memoryEnvelopePackage: null }, { packages: [ENGINE_SCOPED_AFFECTED_PACKAGE], engineMemoryEnvelope: true, @@ -321,14 +337,47 @@ test("partitionScopedAffectedPackages: isolates dashboard and engine into separa engineMemoryEnvelope: false, memoryEnvelopePackage: DASHBOARD_SCOPED_AFFECTED_PACKAGE, }, + { + packages: [CORE_SCOPED_AFFECTED_PACKAGE], + engineMemoryEnvelope: false, + memoryEnvelopePackage: CORE_SCOPED_AFFECTED_PACKAGE, + }, ], ); - assert.deepEqual(summarizeScopedAffectedGroups(["@fusion/core", "@runfusion/fusion"]), [ - { packages: ["@fusion/core", "@runfusion/fusion"], engineMemoryEnvelope: false, memoryEnvelopePackage: null }, + // A genuinely regular package stays in the shared regular group; core splits out. + assert.deepEqual(summarizeScopedAffectedGroups([CORE_SCOPED_AFFECTED_PACKAGE, "@runfusion/fusion"]), [ + { packages: ["@runfusion/fusion"], engineMemoryEnvelope: false, memoryEnvelopePackage: null }, + { + packages: [CORE_SCOPED_AFFECTED_PACKAGE], + engineMemoryEnvelope: false, + memoryEnvelopePackage: CORE_SCOPED_AFFECTED_PACKAGE, + }, ]); }); +test("@fusion/core is a wide-fan-out memory-envelope package but is NOT gate-covered", () => { + // It must be bounded (guard applies) ... + assert.ok( + Object.keys(SCOPED_AFFECTED_MEMORY_ENVELOPES).includes(CORE_SCOPED_AFFECTED_PACKAGE), + "core must be a memory-envelope package so the wide-fan-out guard runs only directly-changed core tests", + ); + // ... yet must NOT claim gate coverage (the merge gate runs no core suite), + // so a delegated core lane warns loudly instead of reporting a false green. + assert.ok( + !GATE_COVERED_MEMORY_ENVELOPE_PACKAGES.has(CORE_SCOPED_AFFECTED_PACKAGE), + "core is not covered by the merge gate; delegation must warn, not reassure", + ); +}); + +test("core scoped-affected env applies the bounded heap and single-worker envelope", () => { + const env = createScopedAffectedMemoryEnvelopeEnv(CORE_SCOPED_AFFECTED_PACKAGE, { + NODE_OPTIONS: "--trace-warnings", + HOME: "/tmp/fusion-home", + }); + assertScopedAffectedEnv(env, { heapMb: CORE_SCOPED_AFFECTED_HEAP_MB, workers: CORE_SCOPED_AFFECTED_WORKERS }); +}); + test("createDashboardScopedAffectedEnv: caps heap, preserves env, lowers workers, and leaves watchdog finite", () => { const env = createDashboardScopedAffectedEnv({ NODE_OPTIONS: "--trace-warnings", diff --git a/scripts/lib/run-vitest-watchdog.mjs b/scripts/lib/run-vitest-watchdog.mjs index 1294836a0d..9ee9c90ed6 100644 --- a/scripts/lib/run-vitest-watchdog.mjs +++ b/scripts/lib/run-vitest-watchdog.mjs @@ -52,8 +52,21 @@ export const CLASS_BUDGET_BANDS = { the two values are not coupled and may diverge. */ shard: { floor: 15 * MINUTE, ceiling: 30 * MINUTE }, + /* + FNXC:TestInfrastructure 2026-06-26-12:40: + The `changed` ceiling MUST sit below the engine's per-task verification kill + (`VERIFICATION_TIMEOUT_WORKSPACE_MS = 900_000` = 15min, verification-utils.ts). + This band is the bound for a local changed-file affected-lane invocation + (`pnpm test` in changed mode). When the timings snapshot is stale (the common + case), deriveBudgetMs returns this ceiling. At the old 20min ceiling the script + watchdog NEVER fired before the engine's 15min kill, so a runaway lane was + SIGKILLed by the engine and the whole task RESTARTED (stacked 15-min timeouts) + instead of failing the lane cleanly here (exit 124, no restart). 13min leaves + margin under the 15min kill so the script fails the lane itself first. Lowering + a ceiling is a tightening, not a timeout-widening appeasement. + */ // One local changed-file package invocation. - changed: { floor: 2 * MINUTE, ceiling: 20 * MINUTE }, + changed: { floor: 2 * MINUTE, ceiling: 13 * MINUTE }, // One dashboard quality lane (heap-managed). Matches the historical 15min. "dashboard-lane": { floor: 15 * MINUTE, ceiling: 30 * MINUTE }, }; diff --git a/scripts/test-changed.mjs b/scripts/test-changed.mjs index 027dd83ec6..b7df291da1 100644 --- a/scripts/test-changed.mjs +++ b/scripts/test-changed.mjs @@ -1317,13 +1317,45 @@ export function packageHasVitestConfig(pkgDir, projectRoot = rootDir) { return VITEST_CONFIG_BASENAMES.some((name) => existsSync(path.join(projectRoot, pkgDir, name))); } +/* +FNXC:TestInfrastructure 2026-06-26-13:05: +Scoped-affected worker fan-out was raised 1 -> 4 (operator decision). It was 1 +purely for OOM safety (FN-6854/FN-6874: heavy affected lanes OS-OOM-SIGKILLed +even at concurrency=1). Two things make 4 acceptable now: (1) the wide-fan-out +guard below bounds each heavy lane to a few directly-changed test files, so the +hundreds-of-files set that drove the OOM no longer reaches these workers; (2) the +heap cap stays 6144MB PER WORKER, so this lane can now use up to ~4x6GB ≈ 24GB — +fine on the 256GB host, but if a RAM-constrained CI runner OOM-SIGKILLs a heavy +lane again, lower this back toward 1 (or drop the per-worker heap) rather than +widening timeouts. This intentionally trades the FN-5048 "don't raise worker +knobs" guidance for throughput, scoped to the bounded affected lanes only. +*/ export const ENGINE_SCOPED_AFFECTED_PACKAGE = "@fusion/engine"; export const ENGINE_SCOPED_AFFECTED_HEAP_MB = "6144"; -export const ENGINE_SCOPED_AFFECTED_WORKERS = "1"; +export const ENGINE_SCOPED_AFFECTED_WORKERS = "4"; export const DASHBOARD_SCOPED_AFFECTED_PACKAGE = "@fusion/dashboard"; export const DASHBOARD_SCOPED_AFFECTED_HEAP_MB = "6144"; -export const DASHBOARD_SCOPED_AFFECTED_WORKERS = "1"; +export const DASHBOARD_SCOPED_AFFECTED_WORKERS = "4"; +export const CORE_SCOPED_AFFECTED_PACKAGE = "@fusion/core"; +export const CORE_SCOPED_AFFECTED_HEAP_MB = "6144"; +export const CORE_SCOPED_AFFECTED_WORKERS = "4"; +/* +FNXC:TestInfrastructure 2026-06-26-12:40: +`@fusion/core` is a memory-envelope/wide-fan-out package too — it was the +remaining `pnpm test` timeout path after engine/dashboard were bounded. core is +the hub nearly every package imports and has ~354 test files (db.test 21s, +mission-store 16s, ...). A non-test core SOURCE edit (e.g. store.ts/db.ts) makes +`vitest --changed` expand to ~the whole core suite at this real-git + +sqlite-heavy lane and blow past the engine's 15-min verification kill, which then +SIGKILLs `pnpm test` and RESTARTS the task — stacked 15-min timeouts. Listing +core here makes `partitionScopedAffectedPackages` treat it as its own +memory-envelope group so the wide-fan-out guard (run only directly-changed core +test files, else delegate) and the bounded heap/worker env both apply. core is +intentionally NOT in GATE_COVERED_MEMORY_ENVELOPE_PACKAGES (the gate runs no core +suite), so a delegated core lane emits the loud "not covered by gate; run +`pnpm test:full`" warning rather than a silent false-green. +*/ export const SCOPED_AFFECTED_MEMORY_ENVELOPES = Object.freeze({ [ENGINE_SCOPED_AFFECTED_PACKAGE]: Object.freeze({ packageName: ENGINE_SCOPED_AFFECTED_PACKAGE, @@ -1335,6 +1367,11 @@ export const SCOPED_AFFECTED_MEMORY_ENVELOPES = Object.freeze({ heapMb: DASHBOARD_SCOPED_AFFECTED_HEAP_MB, workers: DASHBOARD_SCOPED_AFFECTED_WORKERS, }), + [CORE_SCOPED_AFFECTED_PACKAGE]: Object.freeze({ + packageName: CORE_SCOPED_AFFECTED_PACKAGE, + heapMb: CORE_SCOPED_AFFECTED_HEAP_MB, + workers: CORE_SCOPED_AFFECTED_WORKERS, + }), }); /* @@ -1359,7 +1396,7 @@ export function createScopedAffectedMemoryEnvelopeEnv(packageName, env = process if (!envelope) return env; /* FNXC:TestInfrastructure 2026-06-21-11:24: - The engine affected lane can select hundreds of real-git-heavy files when `vitest --changed` sees a widely imported boundary. Run that scoped lane in its own memory envelope: cap Node old-space like the dashboard heap runner and lower Vitest worker fan-out to one process so the lane returns a real pass/fail verdict instead of an OS OOM SIGKILL. Keep watchdog timing outside this env so hangs still fail through `runWithWatchdog`. + The engine affected lane can select hundreds of real-git-heavy files when `vitest --changed` sees a widely imported boundary. Run that scoped lane in its own memory envelope: cap Node old-space like the dashboard heap runner and bound Vitest worker fan-out (see SCOPED_AFFECTED_WORKERS) so the lane returns a real pass/fail verdict instead of an OS OOM SIGKILL. Keep watchdog timing outside this env so hangs still fail through `runWithWatchdog`. FNXC:TestInfrastructure 2026-06-21-16:28: FN-6874 showed the dashboard changed-mode affected lane can OOM/SIGKILL even with `FUSION_TEST_CONCURRENCY=1 FUSION_TEST_WORKSPACE_CONCURRENCY=1`, so worker fan-out alone is not the failure mode. Give each heavy scoped package its own bounded heap envelope while preserving caller env and keeping the finite changed-class watchdog outside this env so hangs still fail instead of being masked. From c36f234da2eecad3e527ebefdd55d0404d70db00 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 22:30:57 -0700 Subject: [PATCH 3/4] Address PR review feedback (#1785) - P2 (greptile): anchor the changed-test existence check at the git repo root (repoRootForExistence via `git rev-parse --show-toplevel`) instead of rootDir, so a script run from a package subdir without FUSION_PROJECT_DIR no longer forms a doubled path and silently drops live tests into the delegate path. +1 regression test (default root resolves to repo root). - coderabbit: fix stale "1-worker lane" wording in the delegation log (now "heavy memory-envelope lane") and the "single-worker envelope" test title, both stale after the 1->4 worker change. test-changed 118/118, eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/__tests__/test-changed.test.mjs | 10 +++++++++- scripts/test-changed.mjs | 23 +++++++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/scripts/__tests__/test-changed.test.mjs b/scripts/__tests__/test-changed.test.mjs index b0da039c2d..062ddada37 100644 --- a/scripts/__tests__/test-changed.test.mjs +++ b/scripts/__tests__/test-changed.test.mjs @@ -370,7 +370,7 @@ test("@fusion/core is a wide-fan-out memory-envelope package but is NOT gate-cov ); }); -test("core scoped-affected env applies the bounded heap and single-worker envelope", () => { +test("core scoped-affected env applies the bounded heap and worker envelope", () => { const env = createScopedAffectedMemoryEnvelopeEnv(CORE_SCOPED_AFFECTED_PACKAGE, { NODE_OPTIONS: "--trace-warnings", HOME: "/tmp/fusion-home", @@ -1976,6 +1976,14 @@ test("existingChangedTestFilesInPackage: excludes non-test and out-of-package pa } }); +// FNXC:TestInfrastructure 2026-06-26-13:40: regression for the doubled-path subdir +// bug — with NO projectRoot passed, the existence root must resolve to the git repo +// root (not cwd), so a real repo-relative test path is found from any cwd. +test("existingChangedTestFilesInPackage: default existence root anchors at the git repo root", () => { + const selfRel = "scripts/__tests__/test-changed.test.mjs"; // this very file — guaranteed on disk + assert.deepEqual(existingChangedTestFilesInPackage([selfRel], "scripts"), [selfRel]); +}); + // FNXC:TestInfrastructure 2026-06-26-09:15: the merge gate re-covers a delegated // engine lane (curated engine-core subset) but runs NO dashboard tests. Lock that // asymmetry so the delegation messaging never overclaims dashboard gate coverage. diff --git a/scripts/test-changed.mjs b/scripts/test-changed.mjs index b7df291da1..f8c6a326ba 100644 --- a/scripts/test-changed.mjs +++ b/scripts/test-changed.mjs @@ -1469,7 +1469,26 @@ handing Vitest an empty/garbage positional set. * @param {{ projectRoot?: string }} [opts] * @returns {string[]} */ -export function existingChangedTestFilesInPackage(changedFiles, pkgDir, { projectRoot = rootDir } = {}) { +/* +FNXC:TestInfrastructure 2026-06-26-13:40: +Existence checks for changed test files must anchor at the GIT REPO ROOT, not +`rootDir` (which falls back to `process.cwd()`). `git diff --name-only` returns +repo-root-relative paths regardless of the cwd it runs from, so joining them +against a `rootDir` that is a package SUBDIR (script run from a package without +FUSION_PROJECT_DIR) would form a doubled path (packages/x/packages/x/...), make +`existsSync` false, and silently DROP live changed tests into the delegate path. +Resolve the toplevel once via git so the check is correct from any cwd inside the +repo; fall back to rootDir if git can't report a toplevel. +*/ +let _repoToplevelCache; +function repoRootForExistence() { + if (_repoToplevelCache !== undefined) return _repoToplevelCache; + const top = gitOutput(["rev-parse", "--show-toplevel"]); + _repoToplevelCache = top && top.trim() ? top.trim() : rootDir; + return _repoToplevelCache; +} + +export function existingChangedTestFilesInPackage(changedFiles, pkgDir, { projectRoot = repoRootForExistence() } = {}) { return (changedFiles ?? []).filter( (file) => isTestFilePath(file) && @@ -1794,7 +1813,7 @@ export async function main(argv = process.argv.slice(2)) { const log = gateCovered ? console.log : console.warn; log( `[test-changed] ${pkg}: a changed non-test source file (${wideSourceDesc}) ` + - "would fan `vitest --changed` out to ~the full suite at this heavy 1-worker lane; " + + "would fan `vitest --changed` out to ~the full suite at this heavy memory-envelope lane; " + `no directly-changed ${pkg} test file to run, so ${delegationNote}`, ); continue; From 1699c6003330ec9a1bb0c8cd47592b2047a88432 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 22:56:10 -0700 Subject: [PATCH 4/4] fix: anchor rootDir at the git toplevel so subdir runs don't skip tests (#1785) greptile: my earlier existence-check anchor was necessary but insufficient. `rootDir` (= process.cwd() when FUSION_PROJECT_DIR is unset) drives ALL workspace discovery (readWorkspacePatterns / listWorkspacePackageInfos / packageHasVitestConfig). Launched from a package subdir, cwd-based discovery found no packages, so decideExecutionPlan saw "no affected package", ran only the gate, and exited successfully WITHOUT running the live changed package tests. Fix the root cause: resolveRepoRoot() resolves the git toplevel as the fallback (FUSION_PROJECT_DIR still the explicit override; cwd only when git can't report a toplevel). This is correct from any cwd inside the repo, including a git worktree (how the engine runs per-task verification). repoRootForExistence is now redundant and removed; the existence check defaults back to rootDir. Demonstrated: resolveRepoRoot() from packages/core (no FUSION_PROJECT_DIR) now resolves the repo root and finds the workspace. +1 regression test. 121/121. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/__tests__/test-changed.test.mjs | 21 ++++++++++ scripts/test-changed.mjs | 53 +++++++++++++++---------- 2 files changed, 53 insertions(+), 21 deletions(-) diff --git a/scripts/__tests__/test-changed.test.mjs b/scripts/__tests__/test-changed.test.mjs index a0c475b51b..3b53873e62 100644 --- a/scripts/__tests__/test-changed.test.mjs +++ b/scripts/__tests__/test-changed.test.mjs @@ -46,6 +46,7 @@ import { isTestFilePath, changedSourceFilesAffectingPackage, existingChangedTestFilesInPackage, + resolveRepoRoot, GATE_COVERED_MEMORY_ENVELOPE_PACKAGES, SCOPED_AFFECTED_MEMORY_ENVELOPES, CORE_SCOPED_AFFECTED_PACKAGE, @@ -2032,6 +2033,26 @@ test("existingChangedTestFilesInPackage: default existence root anchors at the g assert.deepEqual(existingChangedTestFilesInPackage([selfRel], "scripts"), [selfRel]); }); +// FNXC:TestInfrastructure 2026-06-26-14:40: regression for the "subdirectory runs +// skip" bug — rootDir (which drives ALL workspace discovery) must resolve to the +// git toplevel, not process.cwd(), so a run launched from a package subdir without +// FUSION_PROJECT_DIR still finds the workspace instead of exiting through the gate. +test("resolveRepoRoot: honors FUSION_PROJECT_DIR else resolves the git toplevel (cwd-independent)", () => { + const saved = process.env.FUSION_PROJECT_DIR; + try { + process.env.FUSION_PROJECT_DIR = path.join(path.sep, "explicit", "root"); + assert.equal(resolveRepoRoot(), path.resolve(path.join(path.sep, "explicit", "root"))); + delete process.env.FUSION_PROJECT_DIR; + const top = resolveRepoRoot(); + assert.ok(path.isAbsolute(top), "toplevel must be absolute"); + // The resolved root must contain this workspace (cwd-independent), not a subdir. + assert.ok(existsSync(path.join(top, "scripts/test-changed.mjs")), "resolved root must be the repo root"); + } finally { + if (saved === undefined) delete process.env.FUSION_PROJECT_DIR; + else process.env.FUSION_PROJECT_DIR = saved; + } +}); + // FNXC:TestInfrastructure 2026-06-26-09:15: the merge gate re-covers a delegated // engine lane (curated engine-core subset) but runs NO dashboard tests. Lock that // asymmetry so the delegation messaging never overclaims dashboard gate coverage. diff --git a/scripts/test-changed.mjs b/scripts/test-changed.mjs index d5dcc86dc4..741708d282 100644 --- a/scripts/test-changed.mjs +++ b/scripts/test-changed.mjs @@ -96,9 +96,31 @@ function parseWorkspacePackagesFromYaml(rawYaml) { return packages; } -const rootDir = process.env.FUSION_PROJECT_DIR - ? path.resolve(process.env.FUSION_PROJECT_DIR) - : process.cwd(); +/* +FNXC:TestInfrastructure 2026-06-26-14:40: +This is a workspace-wide test runner: it MUST anchor at the repo root, not the +cwd. If launched from a package subdirectory without FUSION_PROJECT_DIR, a bare +`process.cwd()` root made workspace discovery (readWorkspacePatterns / +listWorkspacePackageInfos / packageHasVitestConfig) find no packages, so +decideExecutionPlan saw "no affected package", ran only the gate, and exited +SUCCESSFULLY without running the live changed package tests (greptile). Resolve +the git toplevel as the fallback so every cwd inside the repo (including a git +worktree, which is how the engine runs per-task verification) resolves to the +correct root. FUSION_PROJECT_DIR remains the explicit override; fall back to cwd +only when git can't report a toplevel (e.g. not a repo). +*/ +export function resolveRepoRoot() { + if (process.env.FUSION_PROJECT_DIR) return path.resolve(process.env.FUSION_PROJECT_DIR); + try { + const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }); + const top = r.status === 0 ? (r.stdout ?? "").trim() : ""; + if (top) return top; + } catch { + // git unavailable / not a repo — fall through to cwd + } + return process.cwd(); +} +const rootDir = resolveRepoRoot(); /** @type {string} Cache format version — bump when the shape or hash inputs change. */ const CACHE_FORMAT_VERSION = 1; @@ -1480,25 +1502,14 @@ handing Vitest an empty/garbage positional set. * @returns {string[]} */ /* -FNXC:TestInfrastructure 2026-06-26-13:40: -Existence checks for changed test files must anchor at the GIT REPO ROOT, not -`rootDir` (which falls back to `process.cwd()`). `git diff --name-only` returns -repo-root-relative paths regardless of the cwd it runs from, so joining them -against a `rootDir` that is a package SUBDIR (script run from a package without -FUSION_PROJECT_DIR) would form a doubled path (packages/x/packages/x/...), make -`existsSync` false, and silently DROP live changed tests into the delegate path. -Resolve the toplevel once via git so the check is correct from any cwd inside the -repo; fall back to rootDir if git can't report a toplevel. +FNXC:TestInfrastructure 2026-06-26-14:40: +Existence checks for changed test files join repo-root-relative `git diff` paths +against `projectRoot` (default `rootDir`). `rootDir` is now resolved to the git +toplevel (see resolveRepoRoot above), so this is correct from any cwd inside the +repo — no doubled path, no silently-dropped live test. Deleted/renamed-away test +paths correctly fail existsSync and fall into the delegate-to-gate path. */ -let _repoToplevelCache; -function repoRootForExistence() { - if (_repoToplevelCache !== undefined) return _repoToplevelCache; - const top = gitOutput(["rev-parse", "--show-toplevel"]); - _repoToplevelCache = top && top.trim() ? top.trim() : rootDir; - return _repoToplevelCache; -} - -export function existingChangedTestFilesInPackage(changedFiles, pkgDir, { projectRoot = repoRootForExistence() } = {}) { +export function existingChangedTestFilesInPackage(changedFiles, pkgDir, { projectRoot = rootDir } = {}) { return (changedFiles ?? []).filter( (file) => isTestFilePath(file) &&