Cherry-pick process lifecycle fixes for multi-day autonomous operation

- shell: add trackDetachedChildPid / untrackDetachedChildPid /
  killTrackedDetachedChildren (#9b7948c)
- bash: track/untrack detached child PIDs so they are killed on shutdown
- interactive-mode: register SIGTERM/SIGHUP handlers for clean shutdown
  (#5d440b0); kill tracked bash children on shutdown
- rpc-mode: register SIGTERM/SIGHUP handlers, refactor to forceShutdown()
  that deduplicates shutdown path (#5d440b0); kill tracked bash children
- print-mode: register SIGTERM/SIGHUP handlers for graceful exit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-04-18 14:38:55 +02:00
parent 54e1ba3804
commit 3bb93b1612
5 changed files with 96 additions and 7 deletions

View file

@ -6,7 +6,7 @@ import { join } from "node:path";
import type { AgentTool } from "@singularity-forge/pi-agent-core";
import { type Static, Type } from "@sinclair/typebox";
import { spawn } from "child_process";
import { getShellConfig, getShellEnv, killProcessTree, sanitizeCommand } from "../../utils/shell.js";
import { getShellConfig, getShellEnv, killProcessTree, sanitizeCommand, trackDetachedChildPid, untrackDetachedChildPid } from "../../utils/shell.js";
import { type BashInterceptorRule, compileInterceptor, DEFAULT_BASH_INTERCEPTOR_RULES } from "./bash-interceptor.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js";
import type { ArtifactManager } from "../artifact-manager.js";
@ -168,6 +168,7 @@ const defaultBashOperations: BashOperations = {
env: env ?? getShellEnv(),
stdio: ["ignore", "pipe", "pipe"],
});
if (child.pid) trackDetachedChildPid(child.pid);
let timedOut = false;
@ -192,6 +193,7 @@ const defaultBashOperations: BashOperations = {
// Handle shell spawn errors
child.on("error", (err) => {
if (child.pid) untrackDetachedChildPid(child.pid);
if (timeoutHandle) clearTimeout(timeoutHandle);
if (signal) signal.removeEventListener("abort", onAbort);
reject(err);
@ -214,6 +216,7 @@ const defaultBashOperations: BashOperations = {
// Handle process exit
child.on("close", (code) => {
if (child.pid) untrackDetachedChildPid(child.pid);
restoreWindowsVTInput();
if (timeoutHandle) clearTimeout(timeoutHandle);
if (signal) signal.removeEventListener("abort", onAbort);

View file

@ -102,6 +102,7 @@ import {
handleModelCommand as handleModelCommandController,
updateAvailableProviderCount as updateAvailableProviderCountController,
} from "./controllers/model-controller.js";
import { killTrackedDetachedChildren } from "../../utils/shell.js";
import {
getAvailableThemes,
getAvailableThemesWithPaths,
@ -211,6 +212,8 @@ export class InteractiveMode {
// Agent subscription unsubscribe function
private unsubscribe?: () => void;
private signalCleanupHandlers: Array<() => void> = [];
// Branch change listener unsubscribe function
private _branchChangeUnsub?: () => void;
@ -412,6 +415,8 @@ export class InteractiveMode {
async init(): Promise<void> {
if (this.isInitialized) return;
this.registerSignalHandlers();
// Load changelog (only show new entries, skip for resumed sessions)
this.changelogMarkdown = this.getChangelogForDisplay();
@ -2388,6 +2393,22 @@ export class InteractiveMode {
*/
private isShuttingDown = false;
private registerSignalHandlers(): void {
this.unregisterSignalHandlers();
const signals: NodeJS.Signals[] = ["SIGTERM"];
if (process.platform !== "win32") signals.push("SIGHUP");
for (const signal of signals) {
const handler = () => { void this.shutdown(); };
process.on(signal, handler);
this.signalCleanupHandlers.push(() => process.off(signal, handler));
}
}
private unregisterSignalHandlers(): void {
for (const cleanup of this.signalCleanupHandlers) cleanup();
this.signalCleanupHandlers = [];
}
private async shutdown(): Promise<void> {
const shutdownBehavior = this.options.shutdownBehavior ?? "exit_process";
if (shutdownBehavior === "ignore") {
@ -2397,6 +2418,8 @@ export class InteractiveMode {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
this.unregisterSignalHandlers();
killTrackedDetachedChildren();
// Flush any queued settings writes before shutdown
await this.settingsManager.flush();
@ -3988,6 +4011,7 @@ export class InteractiveMode {
}
stop(): void {
this.unregisterSignalHandlers();
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;

View file

@ -53,6 +53,29 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
});
let exitCode = 0;
let disposed = false;
const signalCleanupHandlers: Array<() => void> = [];
const disposeSession = (): void => {
if (disposed) return;
disposed = true;
unsubscribe();
};
const registerSignalHandlers = (): void => {
const signals: NodeJS.Signals[] = ["SIGTERM"];
if (process.platform !== "win32") signals.push("SIGHUP");
for (const signal of signals) {
const handler = () => {
disposeSession();
process.exit(signal === "SIGHUP" ? 129 : 143);
};
process.on(signal, handler);
signalCleanupHandlers.push(() => process.off(signal, handler));
}
};
registerSignalHandlers();
try {
// Send initial message with attachments
@ -97,7 +120,8 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
});
});
} finally {
unsubscribe();
for (const cleanup of signalCleanupHandlers) cleanup();
disposeSession();
}
if (exitCode !== 0) {

View file

@ -13,6 +13,7 @@
import * as crypto from "node:crypto";
import type { AgentSession } from "../../core/agent-session.js";
import { killTrackedDetachedChildren } from "../../utils/shell.js";
import type {
ExtensionUIContext,
ExtensionUIDialogOptions,
@ -84,6 +85,8 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
// Shutdown request flag
let shutdownRequested = false;
let shuttingDown = false;
const signalCleanupHandlers: Array<() => void> = [];
// v2 protocol version detection state
let protocolVersion: 1 | 2 = 1;
@ -822,19 +825,35 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
*/
let detachInput = () => {};
async function checkShutdownRequested(): Promise<void> {
if (!shutdownRequested) return;
async function forceShutdown(exitCode = 0): Promise<never> {
if (shuttingDown) process.exit(exitCode);
shuttingDown = true;
killTrackedDetachedChildren();
for (const cleanup of signalCleanupHandlers) cleanup();
const currentRunner = session.extensionRunner;
if (currentRunner?.hasHandlers("session_shutdown")) {
await currentRunner.emit({ type: "session_shutdown" });
}
unsubscribe();
embeddedInteractiveMode?.stop();
detachInput();
process.stdin.pause();
process.exit(0);
process.exit(exitCode);
}
const registerSignalHandlers = (): void => {
const signals: NodeJS.Signals[] = ["SIGTERM"];
if (process.platform !== "win32") signals.push("SIGHUP");
for (const signal of signals) {
const handler = () => { void forceShutdown(signal === "SIGHUP" ? 129 : 143); };
process.on(signal, handler);
signalCleanupHandlers.push(() => process.off(signal, handler));
}
};
async function checkShutdownRequested(): Promise<void> {
if (!shutdownRequested) return;
await forceShutdown(0);
}
const handleInputLine = async (line: string) => {
@ -889,6 +908,8 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
}
};
registerSignalHandlers();
detachInput = attachJsonlLineReader(process.stdin, (line) => {
void handleInputLine(line);
});

View file

@ -183,6 +183,23 @@ export function sanitizeBinaryOutput(str: string): string {
.join("");
}
const trackedDetachedChildPids = new Set<number>();
export function trackDetachedChildPid(pid: number): void {
trackedDetachedChildPids.add(pid);
}
export function untrackDetachedChildPid(pid: number): void {
trackedDetachedChildPids.delete(pid);
}
export function killTrackedDetachedChildren(): void {
for (const pid of trackedDetachedChildPids) {
killProcessTree(pid);
}
trackedDetachedChildPids.clear();
}
/**
* Kill a process and all its children (cross-platform)
*/