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:
parent
54e1ba3804
commit
3bb93b1612
5 changed files with 96 additions and 7 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue