mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
fix: server shutdown no longer kills tmux sessions — detach instead of cleanup
Previously, server.stop() called cleanupSession(killMux=false) for all sessions, which still tore down session state, removed listeners, killed PTY processes via node-pty SIGHUP, and broadcast session:deleted. This caused Claude sessions running inside tmux to be disrupted during COM deploys. Now server shutdown just persists state, removes listeners, and lets the Node.js process exit naturally. The tmux sessions survive independently, and restoreMuxSessions() finds them alive on restart. Also adds defense-in-depth in session.stop(): when killMux=false, skip PTY kill entirely (just null the reference). And adds 'detached' lifecycle event type for accurate audit logging.
This commit is contained in:
@@ -35,7 +35,7 @@ When user says "COM":
|
|||||||
1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`)
|
1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`)
|
||||||
2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web`
|
2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web`
|
||||||
|
|
||||||
**Version**: 0.1560 (must match `package.json` for npm publish)
|
**Version**: 0.1561 (must match `package.json` for npm publish)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudeman",
|
"name": "claudeman",
|
||||||
"version": "0.1560",
|
"version": "0.1561",
|
||||||
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
+31
-25
@@ -2055,40 +2055,46 @@ export class Session extends EventEmitter {
|
|||||||
this.cleanupTrackerListeners();
|
this.cleanupTrackerListeners();
|
||||||
|
|
||||||
if (this.ptyProcess) {
|
if (this.ptyProcess) {
|
||||||
const pid = this.ptyProcess.pid;
|
if (killMux) {
|
||||||
|
// Full kill: SIGTERM → wait → SIGKILL the PTY and its children
|
||||||
|
const pid = this.ptyProcess.pid;
|
||||||
|
|
||||||
// First try graceful SIGTERM
|
// First try graceful SIGTERM
|
||||||
try {
|
try {
|
||||||
this.ptyProcess.kill();
|
this.ptyProcess.kill();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[Session] Failed to send SIGTERM to PTY process (may already be dead):', err);
|
console.warn('[Session] Failed to send SIGTERM to PTY process (may already be dead):', err);
|
||||||
}
|
|
||||||
|
|
||||||
// Give it a moment to terminate gracefully
|
|
||||||
await new Promise(resolve => setTimeout(resolve, GRACEFUL_SHUTDOWN_DELAY_MS));
|
|
||||||
|
|
||||||
// Force kill with SIGKILL if still alive
|
|
||||||
try {
|
|
||||||
if (pid) {
|
|
||||||
process.kill(pid, 'SIGKILL');
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.warn('[Session] Failed to send SIGKILL to process (already terminated):', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also try to kill any child processes in the process group
|
// Give it a moment to terminate gracefully
|
||||||
try {
|
await new Promise(resolve => setTimeout(resolve, GRACEFUL_SHUTDOWN_DELAY_MS));
|
||||||
if (pid) {
|
|
||||||
process.kill(-pid, 'SIGKILL');
|
// Force kill with SIGKILL if still alive
|
||||||
|
try {
|
||||||
|
if (pid) {
|
||||||
|
process.kill(pid, 'SIGKILL');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Session] Failed to send SIGKILL to process (already terminated):', err);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.warn('[Session] Failed to send SIGKILL to process group (may not exist):', err);
|
// Also try to kill any child processes in the process group
|
||||||
|
try {
|
||||||
|
if (pid) {
|
||||||
|
process.kill(-pid, 'SIGKILL');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Session] Failed to send SIGKILL to process group (may not exist):', err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Server shutdown: just detach — the process lives on inside tmux
|
||||||
|
console.log('[Session] Detaching from PTY (server shutdown) — mux session preserved');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.ptyProcess = null;
|
this.ptyProcess = null;
|
||||||
}
|
}
|
||||||
this._pid = null;
|
this._pid = null;
|
||||||
this._status = 'stopped';
|
this._status = killMux ? 'stopped' : 'idle';
|
||||||
this._currentTaskId = null;
|
this._currentTaskId = null;
|
||||||
|
|
||||||
// Clear task description cache and agent tree to prevent memory leak
|
// Clear task description cache and agent tree to prevent memory leak
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export type LifecycleEventType =
|
|||||||
| 'started' // PTY process launched (interactive/shell/prompt)
|
| 'started' // PTY process launched (interactive/shell/prompt)
|
||||||
| 'exit' // PTY process exited (with exit code)
|
| 'exit' // PTY process exited (with exit code)
|
||||||
| 'deleted' // cleanupSession() called — session removed
|
| 'deleted' // cleanupSession() called — session removed
|
||||||
|
| 'detached' // Server shutdown — PTY left alive in tmux for recovery
|
||||||
| 'recovered' // Session restored from tmux on server restart
|
| 'recovered' // Session restored from tmux on server restart
|
||||||
| 'stale_cleaned' // Removed from state.json by cleanupStaleSessions()
|
| 'stale_cleaned' // Removed from state.json by cleanupStaleSessions()
|
||||||
| 'mux_died' // tmux session died (detected by reconciliation)
|
| 'mux_died' // tmux session died (detected by reconciliation)
|
||||||
|
|||||||
+43
-9
@@ -3999,7 +3999,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
|||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
const lifecycleLog = getLifecycleLog();
|
const lifecycleLog = getLifecycleLog();
|
||||||
lifecycleLog.log({
|
lifecycleLog.log({
|
||||||
event: 'deleted',
|
event: killMux ? 'deleted' : 'detached',
|
||||||
sessionId,
|
sessionId,
|
||||||
name: session?.name,
|
name: session?.name,
|
||||||
mode: session?.mode,
|
mode: session?.mode,
|
||||||
@@ -5522,14 +5522,48 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
|||||||
Array.from(this.scheduledRuns.keys()).map(id => this.stopScheduledRun(id))
|
Array.from(this.scheduledRuns.keys()).map(id => this.stopScheduledRun(id))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Properly clean up all remaining sessions in parallel (removes listeners, clears state, etc.)
|
// On server shutdown, DO NOT call cleanupSession — it tears down session state,
|
||||||
// Don't kill mux sessions on server stop - they can be reattached on restart
|
// removes listeners, kills PTY processes, and broadcasts session:deleted.
|
||||||
// Use Promise.race with a 30s timeout to prevent shutdown from hanging indefinitely
|
// Instead, just persist current state and let the PTY die naturally when process exits.
|
||||||
const sessionCleanup = Promise.allSettled(
|
// The tmux sessions survive independently, and restoreMuxSessions() will find them on restart.
|
||||||
Array.from(this.sessions.keys()).map(id => this.cleanupSession(id, false, 'server_shutdown'))
|
for (const [sessionId, session] of this.sessions) {
|
||||||
);
|
// Persist final state so recovery has up-to-date tokens, ralph state, etc.
|
||||||
const shutdownTimeout = new Promise<void>(resolve => setTimeout(resolve, 30_000));
|
this._persistSessionStateNow(session);
|
||||||
await Promise.race([sessionCleanup, shutdownTimeout]);
|
// Remove listeners to avoid spurious events during teardown
|
||||||
|
const listeners = this.sessionListenerRefs.get(sessionId);
|
||||||
|
if (listeners) {
|
||||||
|
session.off('terminal', listeners.terminal);
|
||||||
|
session.off('clearTerminal', listeners.clearTerminal);
|
||||||
|
session.off('message', listeners.message);
|
||||||
|
session.off('error', listeners.error);
|
||||||
|
session.off('completion', listeners.completion);
|
||||||
|
session.off('exit', listeners.exit);
|
||||||
|
session.off('working', listeners.working);
|
||||||
|
session.off('idle', listeners.idle);
|
||||||
|
session.off('taskCreated', listeners.taskCreated);
|
||||||
|
session.off('taskUpdated', listeners.taskUpdated);
|
||||||
|
session.off('taskCompleted', listeners.taskCompleted);
|
||||||
|
session.off('taskFailed', listeners.taskFailed);
|
||||||
|
session.off('autoClear', listeners.autoClear);
|
||||||
|
session.off('autoCompact', listeners.autoCompact);
|
||||||
|
session.off('cliInfoUpdated', listeners.cliInfoUpdated);
|
||||||
|
session.off('ralphLoopUpdate', listeners.ralphLoopUpdate);
|
||||||
|
session.off('ralphTodoUpdate', listeners.ralphTodoUpdate);
|
||||||
|
session.off('ralphCompletionDetected', listeners.ralphCompletionDetected);
|
||||||
|
session.off('ralphStatusBlockDetected', listeners.ralphStatusBlockDetected);
|
||||||
|
session.off('ralphCircuitBreakerUpdate', listeners.ralphCircuitBreakerUpdate);
|
||||||
|
session.off('ralphExitGateMet', listeners.ralphExitGateMet);
|
||||||
|
session.off('bashToolStart', listeners.bashToolStart);
|
||||||
|
session.off('bashToolEnd', listeners.bashToolEnd);
|
||||||
|
session.off('bashToolsUpdate', listeners.bashToolsUpdate);
|
||||||
|
this.sessionListenerRefs.delete(sessionId);
|
||||||
|
}
|
||||||
|
session.removeAllListeners();
|
||||||
|
// Close file streams and image watchers (these are server-side resources)
|
||||||
|
fileStreamManager.closeSessionStreams(sessionId);
|
||||||
|
imageWatcher.unwatchSession(sessionId);
|
||||||
|
}
|
||||||
|
// Don't delete sessions from the map or state.json — recovery needs them
|
||||||
|
|
||||||
// Flush state store to prevent data loss from debounced saves
|
// Flush state store to prevent data loss from debounced saves
|
||||||
this.store.flushAll();
|
this.store.flushAll();
|
||||||
|
|||||||
Reference in New Issue
Block a user