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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { createServer, loadTlsCredentialsFromEnv, type ServerOptions } from "./server.js";
|
||||
export { stopAllDevServers, destroyAllDevServerManagers, getActiveProcessManagers } from "./dev-server-routes.js";
|
||||
export {
|
||||
createRuntimeLogger,
|
||||
getRuntimeLogSink,
|
||||
|
||||
@@ -534,6 +534,41 @@ export class TaskExecutor {
|
||||
return new Set([...this.executing, ...this.recoveringCompleted]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort the in-flight bash subprocess (if any) on every active agent session.
|
||||
*
|
||||
* Invoked at runtime shutdown so detached subprocess trees spawned by agent
|
||||
* bash tools — including grandchildren like vitest workers — are killed via
|
||||
* pi-coding-agent's killProcessTree. Without this, when the worker is killed
|
||||
* those process groups are orphaned because they're detached.
|
||||
*
|
||||
* Sessions are not disposed here so any near-complete agent loop still has a
|
||||
* chance to wrap up during the runtime's graceful drain window.
|
||||
*/
|
||||
abortAllSessionBash(): void {
|
||||
for (const [taskId, { session }] of this.activeSessions) {
|
||||
try {
|
||||
session.abortBash();
|
||||
} catch (err) {
|
||||
executorLog.warn(`abortAllSessionBash: failed for task ${taskId}: ${err}`);
|
||||
}
|
||||
}
|
||||
for (const [agentId, session] of this.childSessions) {
|
||||
try {
|
||||
session.abortBash();
|
||||
} catch (err) {
|
||||
executorLog.warn(`abortAllSessionBash: failed for child agent ${agentId}: ${err}`);
|
||||
}
|
||||
}
|
||||
for (const [taskId, stepExecutor] of this.activeStepExecutors) {
|
||||
try {
|
||||
stepExecutor.abortAllSessionBash();
|
||||
} catch (err) {
|
||||
executorLog.warn(`abortAllSessionBash: failed for step executor ${taskId}: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param store — Task store instance (also used to listen for events)
|
||||
* @param rootDir — Project root directory
|
||||
|
||||
@@ -838,6 +838,22 @@ export class InProcessRuntime
|
||||
runtimeLog.log("MissionExecutionLoop stopped");
|
||||
}
|
||||
|
||||
// 7b. Abort in-flight bash subprocess trees on every active agent
|
||||
// session. Each bash command was spawned with `detached: true` (own
|
||||
// process group), so killing the worker alone leaks vitest / npm / build
|
||||
// grandchildren as orphans. This call routes through pi-coding-agent's
|
||||
// AbortController -> killProcessTree, taking down the whole subtree.
|
||||
// Sessions are intentionally NOT disposed here so near-complete steps
|
||||
// can still wrap up during the drain window below.
|
||||
if (this.executor) {
|
||||
try {
|
||||
this.executor.abortAllSessionBash();
|
||||
runtimeLog.log("Aborted in-flight bash subprocesses on active sessions");
|
||||
} catch (err) {
|
||||
runtimeLog.warn(`Failed to abort in-flight bash subprocesses: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Wait for active tasks to complete (30 second timeout)
|
||||
const shutdownTimeout = 30000;
|
||||
const startTime = Date.now();
|
||||
|
||||
@@ -543,6 +543,11 @@ const RETRY_DELAYS_MS = [1_000, 5_000, 15_000];
|
||||
/** A minimal session handle stored for termination support. */
|
||||
interface SessionHandle {
|
||||
dispose: () => void;
|
||||
/** Abort the session's currently-running bash command (if any) so its
|
||||
* detached subprocess tree — including grandchildren like vitest workers —
|
||||
* is killed via pi-coding-agent's killProcessTree. dispose() alone only
|
||||
* disconnects listeners and leaves bash subtrees orphaned. */
|
||||
abortBash: () => void;
|
||||
}
|
||||
|
||||
function resolveExecutorModelPair(
|
||||
@@ -695,6 +700,23 @@ export class StepSessionExecutor {
|
||||
* After calling this method, any in-progress or future `executeStep()` calls
|
||||
* will return a failed result immediately.
|
||||
*/
|
||||
/**
|
||||
* Abort in-flight bash on every active step session without disposing the
|
||||
* sessions. Used during runtime shutdown so detached bash subprocess trees
|
||||
* (including vitest workers) are killed via pi-coding-agent's
|
||||
* killProcessTree. Sessions remain alive so near-complete steps can still
|
||||
* finish during the runtime's graceful drain window.
|
||||
*/
|
||||
abortAllSessionBash(): void {
|
||||
for (const [stepIdx, handle] of this.activeSessions) {
|
||||
try {
|
||||
handle.abortBash();
|
||||
} catch (err) {
|
||||
stepExecLog.warn(`Failed to abort bash for step ${stepIdx}: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async terminateAllSessions(): Promise<void> {
|
||||
this.aborted = true;
|
||||
stepExecLog.log(
|
||||
@@ -702,6 +724,11 @@ export class StepSessionExecutor {
|
||||
);
|
||||
|
||||
for (const [stepIdx, handle] of this.activeSessions) {
|
||||
try {
|
||||
handle.abortBash();
|
||||
} catch (err) {
|
||||
stepExecLog.warn(`Failed to abort bash for step ${stepIdx}: ${err}`);
|
||||
}
|
||||
try {
|
||||
handle.dispose();
|
||||
} catch (err) {
|
||||
@@ -916,7 +943,10 @@ export class StepSessionExecutor {
|
||||
// Pass the canonical task ID (e.g. "FN-1452") as the third argument so
|
||||
// that stuck-kill callbacks (beforeRequeue, onStuck) operate on the real
|
||||
// task rather than the compound step key ("FN-1452-step-1").
|
||||
const handle: SessionHandle = { dispose: () => session?.dispose() };
|
||||
const handle: SessionHandle = {
|
||||
dispose: () => session?.dispose(),
|
||||
abortBash: () => session?.abortBash(),
|
||||
};
|
||||
this.activeSessions.set(stepIndex, handle);
|
||||
stuckTaskDetector?.trackTask(trackingKey, { dispose: () => session?.dispose() }, taskDetail.id);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user