feat(tui+release): persist vitest memory guard, aggregate root CHANGELOG, lockstep all packages
TUI: vitest memory-guard threshold and on/off toggle now persist to global settings (vitestAutoKillEnabled / vitestKillThresholdPct), so they survive dashboard restarts. Stats panel shows the system-memory used percentage next to used/free. Utilities panel exposes [+/-] to adjust the threshold in 5% steps (50–99%). Release: scripts/release.mjs auto-syncs a root CHANGELOG.md aggregated from every packages/*/CHANGELOG.md, grouped by version with one sub-block per package. Versioning: all private @fusion/* packages joined the changesets fixed group with the public cli + cli-alias and were aligned to 0.2.5, so every release bumps every package and produces per-package CHANGELOG entries that the aggregator picks up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -378,6 +378,11 @@ function StatsPanel({ state, isFocused }: { state: DashboardState; isFocused: bo
|
||||
<Text dimColor>used</Text>
|
||||
<Text>{formatBytes(sys.systemFreeMem)}</Text>
|
||||
<Text dimColor>free</Text>
|
||||
{sys.systemTotalMem > 0 && (
|
||||
<Text color={sysMemColor(sys.systemTotalMem - sys.systemFreeMem, sys.systemTotalMem)}>
|
||||
{((sys.systemTotalMem - sys.systemFreeMem) / sys.systemTotalMem * 100).toFixed(1)}%
|
||||
</Text>
|
||||
)}
|
||||
</StatRow>
|
||||
<StatRow label="Cores">
|
||||
<Text>{sys.cpuCount}</Text>
|
||||
@@ -591,12 +596,14 @@ function ExpandedLog({ entry, index, total }: { entry: LogEntry; index: number;
|
||||
|
||||
function UtilitiesPanel({ state, isFocused }: { state: DashboardState; isFocused: boolean }) {
|
||||
const autoKill = state.autoKillVitestOnPressure;
|
||||
const thresholdPct = Math.round(state.vitestKillThreshold * 100);
|
||||
const actions: Array<{ key: string; label: string }> = [
|
||||
{ key: "r", label: "Refresh Stats" },
|
||||
{ key: "c", label: "Clear Logs" },
|
||||
{ key: "t", label: "Toggle Engine Pause" },
|
||||
{ key: "k", label: "Kill Vitest Processes" },
|
||||
{ key: "v", label: `Auto-Kill Vitest >90% Mem: ${autoKill ? "ON" : "OFF"}` },
|
||||
{ key: "v", label: `Auto-Kill Vitest >${thresholdPct}% Mem: ${autoKill ? "ON" : "OFF"}` },
|
||||
{ key: "+/-", label: `Adjust Threshold (${thresholdPct}%)` },
|
||||
{ key: "?", label: "Help" },
|
||||
];
|
||||
return (
|
||||
@@ -632,7 +639,8 @@ function HelpOverlay() {
|
||||
["[r]", "Refresh stats (Utilities)"],
|
||||
["[c]", "Clear logs (Utilities)"],
|
||||
["[k]", "Kill all vitest processes (Utilities)"],
|
||||
["[v]", "Toggle auto-kill vitest >90% mem (Utilities)"],
|
||||
["[v]", "Toggle auto-kill vitest on memory pressure (Utilities)"],
|
||||
["[+/-]", "Adjust vitest kill memory threshold (Utilities)"],
|
||||
["[↑/↓/k/j]", "Navigate list / log entries"],
|
||||
["[Home / G]", "First / last log entry (Logs)"],
|
||||
["[Enter/Space]", "Expand log entry (Logs)"],
|
||||
@@ -769,7 +777,7 @@ function StatusBar({ state, controller: _controller }: { state: DashboardState;
|
||||
if (activeSection === "logs") {
|
||||
hotkeys.push("↑↓ navigate", "w wrap", "f filter", "Enter expand");
|
||||
} else if (activeSection === "utilities") {
|
||||
hotkeys.push("r refresh", "c clear logs", "t toggle pause", "k kill vitest", "v auto-kill");
|
||||
hotkeys.push("r refresh", "c clear logs", "t toggle pause", "k kill vitest", "v auto-kill", "+/- threshold");
|
||||
} else {
|
||||
hotkeys.push("Tab cycle panel", "1-5 jump");
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@ export class DashboardTUI {
|
||||
// When true, sampleSystemStats() kills any running vitest processes if
|
||||
// system memory usage crosses 90%. Toggled by [v] in the Utilities panel.
|
||||
autoKillVitestOnPressure = true;
|
||||
// System-memory ratio (0..1) at which auto-kill triggers. Adjustable from
|
||||
// the Utilities panel via [+]/[-] in 5% steps. Clamped to [0.5, 0.99].
|
||||
vitestKillThreshold = 0.9;
|
||||
// Throttle so we don't spam kills while the sampler keeps firing during
|
||||
// sustained pressure (sampler runs every 2s).
|
||||
private lastAutoKillAt = 0;
|
||||
@@ -117,6 +120,7 @@ export class DashboardTUI {
|
||||
interactiveData: this.interactiveData,
|
||||
interactiveView: this.interactiveView,
|
||||
autoKillVitestOnPressure: this.autoKillVitestOnPressure,
|
||||
vitestKillThreshold: this.vitestKillThreshold,
|
||||
};
|
||||
return this.cachedSnapshot;
|
||||
}
|
||||
@@ -210,12 +214,12 @@ export class DashboardTUI {
|
||||
const usedRatio = (total - free) / total;
|
||||
// 30s minimum gap between auto-kills — vitest restart and OS reclaim
|
||||
// both take a few seconds; firing every 2s would flap.
|
||||
if (usedRatio > 0.9 && now - this.lastAutoKillAt > 30_000) {
|
||||
if (usedRatio > this.vitestKillThreshold && now - this.lastAutoKillAt > 30_000) {
|
||||
this.lastAutoKillAt = now;
|
||||
const result = this.killVitestProcesses();
|
||||
if (result.killed > 0) {
|
||||
this.warn(
|
||||
`Auto-killed ${result.killed} vitest process${result.killed === 1 ? "" : "es"} (system memory at ${Math.round(usedRatio * 100)}%)`,
|
||||
`Auto-killed ${result.killed} vitest process${result.killed === 1 ? "" : "es"} (system memory at ${Math.round(usedRatio * 100)}%, threshold ${Math.round(this.vitestKillThreshold * 100)}%)`,
|
||||
"memory-guard",
|
||||
);
|
||||
}
|
||||
@@ -257,15 +261,49 @@ export class DashboardTUI {
|
||||
return { killed, pids };
|
||||
}
|
||||
|
||||
adjustVitestKillThreshold(deltaPct: number): number {
|
||||
const next = this.vitestKillThreshold + deltaPct / 100;
|
||||
this.vitestKillThreshold = Math.max(0.5, Math.min(0.99, Math.round(next * 100) / 100));
|
||||
this.notify();
|
||||
void this.persistVitestKillSettings({ thresholdPct: Math.round(this.vitestKillThreshold * 100) });
|
||||
return this.vitestKillThreshold;
|
||||
}
|
||||
|
||||
toggleAutoKillVitest(): boolean {
|
||||
this.autoKillVitestOnPressure = !this.autoKillVitestOnPressure;
|
||||
if (!this.autoKillVitestOnPressure) {
|
||||
this.lastAutoKillAt = 0;
|
||||
}
|
||||
this.notify();
|
||||
void this.persistVitestKillSettings({ enabled: this.autoKillVitestOnPressure });
|
||||
return this.autoKillVitestOnPressure;
|
||||
}
|
||||
|
||||
/** Apply persisted values from global settings on startup. Does not
|
||||
* trigger a write-back. */
|
||||
hydrateVitestKillSettings(values: { enabled?: boolean; thresholdPct?: number }): void {
|
||||
if (typeof values.enabled === "boolean") {
|
||||
this.autoKillVitestOnPressure = values.enabled;
|
||||
}
|
||||
if (typeof values.thresholdPct === "number" && Number.isFinite(values.thresholdPct)) {
|
||||
const ratio = values.thresholdPct / 100;
|
||||
this.vitestKillThreshold = Math.max(0.5, Math.min(0.99, ratio));
|
||||
}
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private async persistVitestKillSettings(
|
||||
partial: { enabled?: boolean; thresholdPct?: number },
|
||||
): Promise<void> {
|
||||
if (!this.callbacks?.onPersistVitestKillSettings) return;
|
||||
try {
|
||||
await this.callbacks.onPersistVitestKillSettings(partial);
|
||||
} catch {
|
||||
// Best-effort persistence — the in-memory toggle remains in effect
|
||||
// even if disk write fails. The next adjust will retry.
|
||||
}
|
||||
}
|
||||
|
||||
setSettings(settings: SettingsValues): void {
|
||||
this.settings = settings;
|
||||
this.notify();
|
||||
@@ -419,11 +457,23 @@ export class DashboardTUI {
|
||||
case "v": {
|
||||
const enabled = this.toggleAutoKillVitest();
|
||||
this.log(
|
||||
`Auto-kill vitest on memory pressure (>90%): ${enabled ? "ON" : "OFF"}`,
|
||||
`Auto-kill vitest on memory pressure (>${Math.round(this.vitestKillThreshold * 100)}%): ${enabled ? "ON" : "OFF"}`,
|
||||
"memory-guard",
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "+":
|
||||
case "=": {
|
||||
const v = this.adjustVitestKillThreshold(+5);
|
||||
this.log(`Vitest kill threshold: ${Math.round(v * 100)}%`, "memory-guard");
|
||||
break;
|
||||
}
|
||||
case "-":
|
||||
case "_": {
|
||||
const v = this.adjustVitestKillThreshold(-5);
|
||||
this.log(`Vitest kill threshold: ${Math.round(v * 100)}%`, "memory-guard");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,12 @@ export interface TUICallbacks {
|
||||
onRefreshStats: () => Promise<void>;
|
||||
onClearLogs: () => void;
|
||||
onTogglePause: (paused: boolean) => Promise<SettingsValues>;
|
||||
/** Persist vitest memory-guard settings to global settings so they
|
||||
* survive across dashboard restarts. Optional — when undefined, the
|
||||
* controller treats them as session-local. */
|
||||
onPersistVitestKillSettings?: (
|
||||
partial: { enabled?: boolean; thresholdPct?: number },
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
// Slim project shape used by interactive mode
|
||||
@@ -280,6 +286,7 @@ export interface DashboardState {
|
||||
interactiveData: InteractiveData | null;
|
||||
interactiveView: InteractiveView;
|
||||
autoKillVitestOnPressure: boolean;
|
||||
vitestKillThreshold: number;
|
||||
}
|
||||
|
||||
export const SECTION_ORDER: SectionId[] = ["system", "logs", "utilities", "stats", "settings"];
|
||||
@@ -304,5 +311,6 @@ export function createInitialState(): DashboardState {
|
||||
interactiveData: null,
|
||||
interactiveView: "board",
|
||||
autoKillVitestOnPressure: true,
|
||||
vitestKillThreshold: 0.9,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -732,6 +732,18 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
globalPause: false,
|
||||
};
|
||||
},
|
||||
onPersistVitestKillSettings: async (partial) => {
|
||||
if (!store) return;
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (typeof partial.enabled === "boolean") {
|
||||
patch.vitestAutoKillEnabled = partial.enabled;
|
||||
}
|
||||
if (typeof partial.thresholdPct === "number") {
|
||||
patch.vitestKillThresholdPct = partial.thresholdPct;
|
||||
}
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
await store.getGlobalSettingsStore().updateSettings(patch);
|
||||
},
|
||||
});
|
||||
// Start the TUI
|
||||
await tui.start();
|
||||
@@ -1788,6 +1800,22 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
globalPause: settings.globalPause ?? false,
|
||||
});
|
||||
|
||||
// Hydrate the TUI memory guard from persisted global settings so the
|
||||
// user's previous toggle/threshold survives across dashboard restarts.
|
||||
try {
|
||||
const globalSettings = await store.getGlobalSettingsStore().getSettings();
|
||||
tui.hydrateVitestKillSettings({
|
||||
enabled: typeof globalSettings.vitestAutoKillEnabled === "boolean"
|
||||
? globalSettings.vitestAutoKillEnabled
|
||||
: undefined,
|
||||
thresholdPct: typeof globalSettings.vitestKillThresholdPct === "number"
|
||||
? globalSettings.vitestKillThresholdPct
|
||||
: undefined,
|
||||
});
|
||||
} catch {
|
||||
// Fall back to controller defaults if global settings can't be read.
|
||||
}
|
||||
|
||||
// Populate initial stats
|
||||
const tasks = await store.listTasks({ slim: true, includeArchived: false });
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
Reference in New Issue
Block a user