fix: kill agent-spawned bash trees and dev servers on dashboard shutdown

TUI quit ('q'/Ctrl+C) bypassed signal handlers via process.exit(0), and
neither shutdown path closed the HTTP server, so server.close()'s
stopAllDevServers() listener never ran. In-flight agent bash commands
(spawned detached for their own pgroup) were also never aborted, so
their subprocess trees — including vitest workers — survived as orphans.

Route the TUI quit through SIGINT so the registered shutdown handler
runs, await stopAllDevServers() in both shutdown paths, and abort
in-flight bash on every active agent session at the start of the
runtime drain so killProcessTree reaches every grandchild.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-25 14:13:30 -07:00
parent e8aab69c67
commit 55cef71b8f
6 changed files with 109 additions and 3 deletions

View File

@@ -3555,11 +3555,15 @@ export function DashboardApp({ controller }: DashboardAppProps) {
// Global key handling
useInput((input, key) => {
// Quit
// Quit — route through SIGINT so the dashboard's shutdown handler runs
// (stops dev-server child process groups, engines, mesh, etc.). Calling
// process.exit(0) directly here orphans node/vitest children spawned by
// user-project dev servers.
if (input === "q" || input === "Q" || (key.ctrl && input === "c")) {
void controller.stop();
exit();
process.exit(0);
process.kill(process.pid, "SIGINT");
return;
}
// View switching shortcuts — b/a/g enter interactive + set view

View File

@@ -23,6 +23,7 @@ import {
createSkillsAdapter,
getProjectSettingsPath,
loadTlsCredentialsFromEnv,
stopAllDevServers,
type RuntimeLogger,
} from "@fusion/dashboard";
import { aiMergeTask, MissionAutopilot, MissionExecutionLoop, HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext, ProjectEngineManager, PeerExchangeService } from "@fusion/engine";
@@ -1434,6 +1435,16 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
dispose();
stopDiagnosticInterval();
// Tear down user-project dev-server children (and their process groups)
// before exiting. server.close() is not awaited on this exit path, so
// its `close` listener that does the same cleanup may not run in time.
try {
await stopAllDevServers();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logSink.warn(`Failed to stop dev servers: ${message}`, "dashboard");
}
// Stop all project engines uniformly
await engineManager.stopAll();
@@ -1658,6 +1669,15 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
if (triggerScheduler) triggerScheduler.stop();
if (heartbeatMonitorImpl) heartbeatMonitorImpl.stop();
// Tear down user-project dev-server children (and their process groups)
// before exiting. process.exit below skips server.close()'s cleanup hook.
try {
await stopAllDevServers();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logSink.warn(`Failed to stop dev servers: ${message}`, "dashboard");
}
// Stop peer exchange service
if (peerExchangeService) {
try {