feat(FN-3106): add draft planning workflow with createAiSession plugin API

This merge delivers the full draft planning feature (FN-3106) — API routes for planning subtasks, the AiSessionStore backend for draft sessions, a draft planning API client, auto-creation of planning drafts from user input, and corresponding UI polish in PlanningModeModal with accessibility-focused

Fusion-Task-Id: FN-3106
This commit is contained in:
Fusion
2026-05-02 13:28:52 -07:00
committed by gsxdsm
parent 5ebccc43cc
commit 174453465f
11 changed files with 135 additions and 62 deletions

View File

@@ -0,0 +1,14 @@
---
"@runfusion/fusion": patch
---
Fix dashboard freezing for several seconds while a Fusion agent runs a long verification command (e.g. `pnpm test`).
Root cause was in `runVerificationCommand`'s output capture (`packages/engine/src/run-verification-tool.ts`). The captured stdout/stderr buffers used a string-concat + re-encode pattern: once total output exceeded 200 KB, every subsequent line did `Buffer.from(buf.tail).subarray(...).toString("utf8")`, allocating and re-decoding the entire ~100 KB tail per line. A vitest run dumping 50k+ lines produced multiple GB of GC churn, which stalled the dashboard event loop in stop-the-world pauses (matching the symptom: occasional multi-second freezes with no CPU spike on the host).
The buffer is now stored as a chunk array; tail compaction runs only when accumulated size grows past 2× the cap, making per-line append amortized O(1). All 12 existing `run-verification-command` tests pass unchanged.
Two follow-on changes shipped in the same patch:
- **Embedded terminal PTY ingestion** (`packages/dashboard/src/terminal-service.ts`) had the same anti-pattern: `outputBuffer.slice(0, 4096)` + `outputBuffer.slice(4096)` on every 4 ms flush tick. Switched to a chunk array with O(1) drain. Throttle bumped from 4 ms to 16 ms (60 fps) and per-flush cap from 4 KB to 64 KB. This was not the cause of the user-reported freeze, but the same O(N²) hazard would surface under any flood from a terminal pane.
- **Vitest worker fan-out tightened**: per-package cap lowered from `min(6, cpus()-1)` to `min(4, cpus()-1)` in cli/dashboard/desktop/mobile/plugin-sdk/engine (engine had no cap before). Each config now explicitly pins `pool` (`forks` or `threads`) and only sets the matching `poolOptions`, removing the dual-pool declaration. Worst-case `pnpm test` fan-out: ~12 workers → ~8.

View File

@@ -2,11 +2,11 @@ import { defineConfig } from "vitest/config";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { cpus } from "node:os"; import { cpus } from "node:os";
// Cap fan-out to 6 so high-core dev machines don't spawn 27+ workers per // Cap fan-out to 4 so high-core dev machines don't spawn 27+ workers per
// package — that saturates the box when multiple workspace packages test // package — that saturates the box when multiple workspace packages test
// concurrently or when the dashboard has agents running tests in parallel. // concurrently or when the dashboard has agents running tests in parallel.
// Override with VITEST_MAX_WORKERS for explicit fast/serial runs. // Override with VITEST_MAX_WORKERS for explicit fast/serial runs.
const defaultMaxWorkers = Math.min(6, Math.max(1, cpus().length - 1)); const defaultMaxWorkers = Math.min(4, Math.max(1, cpus().length - 1));
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10); const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
const maxWorkers = Math.max(1, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : defaultMaxWorkers); const maxWorkers = Math.max(1, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : defaultMaxWorkers);
process.env.VITEST_MAX_WORKERS = String(maxWorkers); process.env.VITEST_MAX_WORKERS = String(maxWorkers);
@@ -38,8 +38,9 @@ export default defineConfig({
resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts"), resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts"),
], ],
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")], globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
pool: "forks",
maxWorkers, maxWorkers,
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } }, poolOptions: { forks: { minForks: 1, maxForks: maxWorkers } },
fileParallelism: true, fileParallelism: true,
coverage: { coverage: {
enabled: false, enabled: false,

View File

@@ -23,8 +23,9 @@ export default defineConfig({
"./src/__test-utils__/vitest-setup.ts", "./src/__test-utils__/vitest-setup.ts",
], ],
globalSetup: ["./src/__test-utils__/vitest-teardown.ts"], globalSetup: ["./src/__test-utils__/vitest-teardown.ts"],
pool: "forks",
maxWorkers, maxWorkers,
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } }, poolOptions: { forks: { minForks: 1, maxForks: maxWorkers } },
fileParallelism: true, fileParallelism: true,
// Core runs a large SQLite-heavy suite while other workspace packages test concurrently. // Core runs a large SQLite-heavy suite while other workspace packages test concurrently.
// Use a slightly higher timeout to reduce nondeterministic slow-machine flakes. // Use a slightly higher timeout to reduce nondeterministic slow-machine flakes.

View File

@@ -569,9 +569,9 @@ describe("TerminalService", () => {
// Advance exactly to the resize debounce boundary // Advance exactly to the resize debounce boundary
vi.advanceTimersByTime(150); vi.advanceTimersByTime(150);
// The resize debounce should have moved suppressed data to outputBuffer // The resize debounce should have moved suppressed data to the output
// and scheduled a flush. Advance past the flush throttle. // queue and scheduled a flush. Advance past the flush throttle window.
vi.advanceTimersByTime(10); vi.advanceTimersByTime(20);
expect(dataListener).toHaveBeenCalledWith(session.id, "shell prompt> "); expect(dataListener).toHaveBeenCalledWith(session.id, "shell prompt> ");

View File

@@ -190,9 +190,12 @@ const MIN_MAX_SESSIONS = 1;
const MAX_MAX_SESSIONS = 100; const MAX_MAX_SESSIONS = 100;
const DEFAULT_MAX_SESSIONS = 10; const DEFAULT_MAX_SESSIONS = 10;
// Throttle output to prevent overwhelming WebSocket under heavy load // Throttle output to prevent overwhelming WebSocket under heavy load.
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive input // 16ms = 60fps, plenty for terminal redraw while leaving event-loop budget
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency // for the rest of the dashboard. Larger flush cap keeps `pnpm test`-class
// floods from generating thousands of timer-driven micro-flushes.
const OUTPUT_THROTTLE_MS = 16;
const OUTPUT_BATCH_SIZE = 64 * 1024; // 64KB per WebSocket frame
// Stale session threshold: sessions inactive for more than 5 minutes are eligible for eviction // Stale session threshold: sessions inactive for more than 5 minutes are eligible for eviction
export const STALE_SESSION_THRESHOLD_MS = 300_000; // 5 minutes export const STALE_SESSION_THRESHOLD_MS = 300_000; // 5 minutes
@@ -230,7 +233,14 @@ export interface TerminalSession {
lastActivityAt: Date; lastActivityAt: Date;
shell: string; shell: string;
scrollbackBuffer: string; scrollbackBuffer: string;
outputBuffer: string; /**
* Pending output chunks awaiting flush to clients. Stored as an array
* (not a single concatenated string) so heavy bursts — e.g. a `pnpm test`
* run inside an embedded terminal — don't trigger O(N²) string copies on
* every flush tick, which previously caused multi-second event-loop stalls.
*/
outputChunks: string[];
outputBytes: number;
flushTimeout: NodeJS.Timeout | null; flushTimeout: NodeJS.Timeout | null;
resizeInProgress: boolean; resizeInProgress: boolean;
resizeDebounceTimeout: NodeJS.Timeout | null; resizeDebounceTimeout: NodeJS.Timeout | null;
@@ -241,7 +251,7 @@ export interface TerminalSession {
* This prevents the initial shell prompt (and other output) from being lost * This prevents the initial shell prompt (and other output) from being lost
* when it falls inside the 150 ms resize-suppression window. * when it falls inside the 150 ms resize-suppression window.
*/ */
resizeSuppressedBuffer: string; resizeSuppressedChunks: string[];
/** Internal flush callback set by createSession; used by resize debounce */ /** Internal flush callback set by createSession; used by resize debounce */
_flushOutput: (() => void) | null; _flushOutput: (() => void) | null;
} }
@@ -659,17 +669,21 @@ export class TerminalService extends EventEmitter {
lastActivityAt: new Date(), lastActivityAt: new Date(),
shell, shell,
scrollbackBuffer: "", scrollbackBuffer: "",
outputBuffer: "", outputChunks: [],
outputBytes: 0,
flushTimeout: null, flushTimeout: null,
resizeInProgress: false, resizeInProgress: false,
resizeDebounceTimeout: null, resizeDebounceTimeout: null,
resizeSuppressedBuffer: "", resizeSuppressedChunks: [],
_flushOutput: null, _flushOutput: null,
}; };
this.sessions.set(id, session); this.sessions.set(id, session);
// Flush buffered output to clients (throttled) // Flush buffered output to clients (throttled).
// Drains chunks from the front of the queue up to OUTPUT_BATCH_SIZE bytes
// per frame. Using an array avoids the O(N) string-slice that the previous
// implementation paid on every tick under heavy load.
const flushOutput = () => { const flushOutput = () => {
// Guard against firing after session was killed // Guard against firing after session was killed
if (!this.sessions.has(id)) { if (!this.sessions.has(id)) {
@@ -677,21 +691,29 @@ export class TerminalService extends EventEmitter {
return; return;
} }
if (session.outputBuffer.length === 0) { if (session.outputChunks.length === 0) {
session.outputBytes = 0;
session.flushTimeout = null; session.flushTimeout = null;
return; return;
} }
let dataToSend = session.outputBuffer; const drained: string[] = [];
if (dataToSend.length > OUTPUT_BATCH_SIZE) { let drainedBytes = 0;
dataToSend = session.outputBuffer.slice(0, OUTPUT_BATCH_SIZE); while (session.outputChunks.length > 0 && drainedBytes < OUTPUT_BATCH_SIZE) {
session.outputBuffer = session.outputBuffer.slice(OUTPUT_BATCH_SIZE); const next = session.outputChunks.shift() as string;
drained.push(next);
drainedBytes += next.length;
}
session.outputBytes = Math.max(0, session.outputBytes - drainedBytes);
if (session.outputChunks.length > 0) {
// More to send — schedule another flush after the throttle window.
session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS); session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS);
} else { } else {
session.outputBuffer = "";
session.flushTimeout = null; session.flushTimeout = null;
} }
const dataToSend = drained.length === 1 ? drained[0] : drained.join("");
this.dataCallbacks.forEach((cb) => cb(id, dataToSend)); this.dataCallbacks.forEach((cb) => cb(id, dataToSend));
this.emit("data", id, dataToSend); this.emit("data", id, dataToSend);
}; };
@@ -712,12 +734,13 @@ export class TerminalService extends EventEmitter {
// to avoid rendering artifacts, but queue the data so it is flushed to // to avoid rendering artifacts, but queue the data so it is flushed to
// clients once the resize debounce completes (no data loss). // clients once the resize debounce completes (no data loss).
if (session.resizeInProgress) { if (session.resizeInProgress) {
session.resizeSuppressedBuffer += data; session.resizeSuppressedChunks.push(data);
return; return;
} }
// Buffer output for throttled delivery // Buffer output for throttled delivery
session.outputBuffer += data; session.outputChunks.push(data);
session.outputBytes += data.length;
if (!session.flushTimeout) { if (!session.flushTimeout) {
session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS); session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS);
@@ -737,7 +760,9 @@ export class TerminalService extends EventEmitter {
session.resizeDebounceTimeout = null; session.resizeDebounceTimeout = null;
} }
session._flushOutput = null; session._flushOutput = null;
session.resizeSuppressedBuffer = ""; session.resizeSuppressedChunks.length = 0;
session.outputChunks.length = 0;
session.outputBytes = 0;
this.sessions.delete(id); this.sessions.delete(id);
this.exitCallbacks.forEach((cb) => cb(id, exitCode ?? 0)); this.exitCallbacks.forEach((cb) => cb(id, exitCode ?? 0));
this.emit("exit", id, exitCode ?? 0); this.emit("exit", id, exitCode ?? 0);
@@ -812,9 +837,12 @@ export class TerminalService extends EventEmitter {
// This ensures the initial shell prompt (and any other output that // This ensures the initial shell prompt (and any other output that
// landed inside the suppression window) is delivered to clients // landed inside the suppression window) is delivered to clients
// rather than being silently dropped. // rather than being silently dropped.
if (session.resizeSuppressedBuffer.length > 0) { if (session.resizeSuppressedChunks.length > 0) {
session.outputBuffer += session.resizeSuppressedBuffer; for (const chunk of session.resizeSuppressedChunks) {
session.resizeSuppressedBuffer = ""; session.outputChunks.push(chunk);
session.outputBytes += chunk.length;
}
session.resizeSuppressedChunks.length = 0;
if (!session.flushTimeout && session._flushOutput) { if (!session.flushTimeout && session._flushOutput) {
session.flushTimeout = setTimeout(session._flushOutput, OUTPUT_THROTTLE_MS); session.flushTimeout = setTimeout(session._flushOutput, OUTPUT_THROTTLE_MS);
} }
@@ -920,7 +948,8 @@ export class TerminalService extends EventEmitter {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session) return null; if (!session) return null;
session.outputBuffer = ""; session.outputChunks.length = 0;
session.outputBytes = 0;
if (session.flushTimeout) { if (session.flushTimeout) {
clearTimeout(session.flushTimeout); clearTimeout(session.flushTimeout);
session.flushTimeout = null; session.flushTimeout = null;

View File

@@ -3,9 +3,9 @@ import react from "@vitejs/plugin-react";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { cpus } from "node:os"; import { cpus } from "node:os";
// Cap fan-out to 6 so high-core dev machines don't spawn 27+ workers per // Cap fan-out to 4 so high-core dev machines don't spawn 27+ workers per
// package — that saturates the box when workspace packages test concurrently. // package — that saturates the box when workspace packages test concurrently.
const defaultMaxWorkers = Math.min(6, Math.max(1, cpus().length - 1)); const defaultMaxWorkers = Math.min(4, Math.max(1, cpus().length - 1));
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10); const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
const maxWorkers = Math.max(1, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : defaultMaxWorkers); const maxWorkers = Math.max(1, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : defaultMaxWorkers);
process.env.VITEST_MAX_WORKERS = String(maxWorkers); process.env.VITEST_MAX_WORKERS = String(maxWorkers);
@@ -33,8 +33,9 @@ export default defineConfig({
"./vitest.setup.ts", "./vitest.setup.ts",
], ],
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")], globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
pool: "forks",
maxWorkers, maxWorkers,
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } }, poolOptions: { forks: { minForks: 1, maxForks: maxWorkers } },
fileParallelism: true, fileParallelism: true,
isolate: true, isolate: true,
// Dashboard route and integration-heavy suites can exceed the Vitest // Dashboard route and integration-heavy suites can exceed the Vitest

View File

@@ -2,8 +2,8 @@ import { defineConfig } from "vitest/config";
import { cpus } from "node:os"; import { cpus } from "node:os";
import { resolve } from "node:path"; import { resolve } from "node:path";
// Cap fan-out to 6 to avoid saturating high-core machines under workspace concurrency. // Cap fan-out to 4 to avoid saturating high-core machines under workspace concurrency.
const defaultMaxWorkers = Math.min(6, Math.max(1, cpus().length - 1)); const defaultMaxWorkers = Math.min(4, Math.max(1, cpus().length - 1));
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10); const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
const maxWorkers = Math.max(1, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : defaultMaxWorkers); const maxWorkers = Math.max(1, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : defaultMaxWorkers);
process.env.VITEST_MAX_WORKERS = String(maxWorkers); process.env.VITEST_MAX_WORKERS = String(maxWorkers);
@@ -14,8 +14,9 @@ export default defineConfig({
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")], globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
testTimeout: 30_000, testTimeout: 30_000,
hookTimeout: 30_000, hookTimeout: 30_000,
pool: "threads",
maxWorkers, maxWorkers,
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } }, poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers } },
fileParallelism: true, fileParallelism: true,
passWithNoTests: true, passWithNoTests: true,
projects: [ projects: [

View File

@@ -91,36 +91,60 @@ export interface VerificationResult {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Output buffer helper — keeps head + tail within the byte cap // Output buffer helper — keeps head + tail within the byte cap
//
// Stores head/tail as chunk arrays rather than concatenated strings.
// The previous implementation re-encoded the entire ~100 KB tail through
// `Buffer.from(...).subarray(...).toString()` on *every* appended line once
// output crossed MAX_OUTPUT_BYTES — for a `pnpm test` run dumping 50k lines
// that produced gigabytes of GC churn and stalled the dashboard event loop.
// Now we just push chunks and only compact the tail when its byte size grows
// past 2× the cap, making the amortized cost per append O(1).
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function appendToBuffer( interface OutputBuffer {
buf: { head: string; tail: string; totalBytes: number }, headChunks: string[];
chunk: string, headBytes: number;
): void { tailChunks: string[];
tailBytes: number;
totalBytes: number;
}
function createBuffer(): OutputBuffer {
return { headChunks: [], headBytes: 0, tailChunks: [], tailBytes: 0, totalBytes: 0 };
}
function appendToBuffer(buf: OutputBuffer, chunk: string): void {
const chunkBytes = Buffer.byteLength(chunk, "utf8"); const chunkBytes = Buffer.byteLength(chunk, "utf8");
buf.totalBytes += chunkBytes; buf.totalBytes += chunkBytes;
if (buf.totalBytes <= MAX_OUTPUT_BYTES) { if (buf.headBytes + chunkBytes <= MAX_OUTPUT_BYTES) {
buf.head += chunk; buf.headChunks.push(chunk);
buf.headBytes += chunkBytes;
return; return;
} }
// Overflow: funnel excess into tail (keep at most half the cap in tail) // Overflow: funnel into tail. Keep at most half the cap in tail, but only
// compact when we're well over so per-line cost stays amortized O(1).
const tailCap = MAX_OUTPUT_BYTES / 2; const tailCap = MAX_OUTPUT_BYTES / 2;
buf.tail += chunk; buf.tailChunks.push(chunk);
if (Buffer.byteLength(buf.tail, "utf8") > tailCap) { buf.tailBytes += chunkBytes;
// Truncate tail from the front — keep newest content if (buf.tailBytes > tailCap * 2) {
const bytes = Buffer.from(buf.tail, "utf8"); // Drop oldest chunks until under the cap.
buf.tail = bytes.subarray(bytes.length - tailCap).toString("utf8"); while (buf.tailChunks.length > 1 && buf.tailBytes - Buffer.byteLength(buf.tailChunks[0], "utf8") >= tailCap) {
const dropped = buf.tailChunks.shift() as string;
buf.tailBytes -= Buffer.byteLength(dropped, "utf8");
}
} }
} }
function flattenBuffer(buf: { head: string; tail: string; totalBytes: number }): string { function flattenBuffer(buf: OutputBuffer): string {
if (buf.tail.length === 0) return buf.head; const head = buf.headChunks.join("");
if (buf.tailChunks.length === 0) return head;
const tail = buf.tailChunks.join("");
return ( return (
buf.head + head +
`\n\n[... output truncated — ${buf.totalBytes} bytes total, showing head + tail ...]\n\n` + `\n\n[... output truncated — ${buf.totalBytes} bytes total, showing head + tail ...]\n\n` +
buf.tail tail
); );
} }
@@ -150,8 +174,8 @@ export async function runVerificationCommand(
const startMs = Date.now(); const startMs = Date.now();
const warnings: string[] = []; const warnings: string[] = [];
const stdoutBuf = { head: "", tail: "", totalBytes: 0 }; const stdoutBuf = createBuffer();
const stderrBuf = { head: "", tail: "", totalBytes: 0 }; const stderrBuf = createBuffer();
return new Promise<VerificationResult>((resolve) => { return new Promise<VerificationResult>((resolve) => {
// Use shell: true so Node picks the platform default — /bin/sh on POSIX, // Use shell: true so Node picks the platform default — /bin/sh on POSIX,

View File

@@ -2,7 +2,8 @@ import { defineConfig } from "vitest/config";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { cpus } from "node:os"; import { cpus } from "node:os";
const defaultMaxWorkers = Math.max(1, cpus().length - 1); // Cap fan-out to 4 to avoid saturating high-core machines under workspace concurrency.
const defaultMaxWorkers = Math.min(4, Math.max(1, cpus().length - 1));
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10); const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
const maxWorkers = Math.max(1, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : defaultMaxWorkers); const maxWorkers = Math.max(1, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : defaultMaxWorkers);
process.env.VITEST_MAX_WORKERS = String(maxWorkers); process.env.VITEST_MAX_WORKERS = String(maxWorkers);
@@ -23,10 +24,10 @@ export default defineConfig({
resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts"), resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts"),
], ],
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")], globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
maxWorkers,
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },
fileParallelism: true,
pool: "threads", pool: "threads",
maxWorkers,
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers } },
fileParallelism: true,
// Enable isolate to allow parallel execution of tests with conflicting mocks // Enable isolate to allow parallel execution of tests with conflicting mocks
isolate: true, isolate: true,
coverage: { coverage: {

View File

@@ -2,8 +2,8 @@ import { defineConfig } from "vitest/config";
import { cpus } from "node:os"; import { cpus } from "node:os";
import { resolve } from "node:path"; import { resolve } from "node:path";
// Cap fan-out to 6 to avoid saturating high-core machines under workspace concurrency. // Cap fan-out to 4 to avoid saturating high-core machines under workspace concurrency.
const defaultMaxWorkers = Math.min(6, Math.max(1, cpus().length - 1)); const defaultMaxWorkers = Math.min(4, Math.max(1, cpus().length - 1));
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10); const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
const maxWorkers = Math.max(1, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : defaultMaxWorkers); const maxWorkers = Math.max(1, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : defaultMaxWorkers);
process.env.VITEST_MAX_WORKERS = String(maxWorkers); process.env.VITEST_MAX_WORKERS = String(maxWorkers);
@@ -13,8 +13,9 @@ export default defineConfig({
include: ["src/**/*.test.ts"], include: ["src/**/*.test.ts"],
setupFiles: [resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts")], setupFiles: [resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts")],
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")], globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
pool: "forks",
maxWorkers, maxWorkers,
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } }, poolOptions: { forks: { minForks: 1, maxForks: maxWorkers } },
fileParallelism: true, fileParallelism: true,
}, },
}); });

View File

@@ -3,8 +3,8 @@ import { cpus } from "node:os";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
// Cap fan-out to 6 to avoid saturating high-core machines under workspace concurrency. // Cap fan-out to 4 to avoid saturating high-core machines under workspace concurrency.
const defaultMaxWorkers = Math.min(6, Math.max(1, cpus().length - 1)); const defaultMaxWorkers = Math.min(4, Math.max(1, cpus().length - 1));
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10); const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
const maxWorkers = Math.max(1, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : defaultMaxWorkers); const maxWorkers = Math.max(1, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : defaultMaxWorkers);
process.env.VITEST_MAX_WORKERS = String(maxWorkers); process.env.VITEST_MAX_WORKERS = String(maxWorkers);
@@ -21,6 +21,6 @@ export default defineConfig({
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")], globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
pool: "threads", pool: "threads",
maxWorkers, maxWorkers,
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } }, poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers } },
}, },
}); });