fix: improve stability with memory leak fixes and atomic writes

Session Manager:
- Store event handlers for proper cleanup on session stop
- Add resetSessionManager() for test isolation

State Store:
- Use atomic write pattern (temp file + rename) to prevent corruption
- Add tokenStats to AppState interface

Subagent Watcher:
- Clean up pendingToolCalls on agent completion
- Add cleanupStaleAgents() for 24h+ old agents
- Clear all state maps on stop() for clean restart

Web Server:
- Remove listeners from transcript watcher before stopping
- Clean up respawn timers when session is deleted
- Use cleanupSession() in spawn orchestrator for proper resource cleanup

Frontend:
- Close existing EventSource before reconnecting to prevent duplicates
- Close failed connection before scheduling reconnect

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-26 12:35:00 +01:00
co-authored by Claude Opus 4.5
parent 6e4564abd1
commit fe334c2ea6
6 changed files with 131 additions and 37 deletions
+54 -17
View File
@@ -48,8 +48,17 @@ export interface SessionManagerEvents {
* @fires SessionManagerEvents.sessionOutput
* @fires SessionManagerEvents.sessionCompletion
*/
/** Stored event handlers for a session, used for cleanup */
interface SessionHandlers {
output: (data: string) => void;
error: (data: string) => void;
completion: (phrase: string) => void;
exit: () => void;
}
export class SessionManager extends EventEmitter {
private sessions: Map<string, Session> = new Map();
private sessionHandlers: Map<string, SessionHandlers> = new Map();
private store = getStore();
/**
@@ -89,25 +98,32 @@ export class SessionManager extends EventEmitter {
const session = new Session({ workingDir });
// Set up event forwarding
session.on('output', (data) => {
this.emit('sessionOutput', session.id, data);
this.updateSessionState(session);
});
// Set up event forwarding with stored handlers for cleanup
const handlers: SessionHandlers = {
output: (data: string) => {
this.emit('sessionOutput', session.id, data);
this.updateSessionState(session);
},
error: (data: string) => {
this.emit('sessionError', session.id, data);
this.updateSessionState(session);
},
completion: (phrase: string) => {
this.emit('sessionCompletion', session.id, phrase);
},
exit: () => {
this.emit('sessionStopped', session.id);
this.updateSessionState(session);
},
};
session.on('error', (data) => {
this.emit('sessionError', session.id, data);
this.updateSessionState(session);
});
session.on('output', handlers.output);
session.on('error', handlers.error);
session.on('completion', handlers.completion);
session.on('exit', handlers.exit);
session.on('completion', (phrase) => {
this.emit('sessionCompletion', session.id, phrase);
});
session.on('exit', () => {
this.emit('sessionStopped', session.id);
this.updateSessionState(session);
});
// Store handlers for later cleanup
this.sessionHandlers.set(session.id, handlers);
await session.start();
@@ -136,6 +152,16 @@ export class SessionManager extends EventEmitter {
return;
}
// Remove event listeners to prevent memory leaks
const handlers = this.sessionHandlers.get(id);
if (handlers) {
session.off('output', handlers.output);
session.off('error', handlers.error);
session.off('completion', handlers.completion);
session.off('exit', handlers.exit);
this.sessionHandlers.delete(id);
}
await session.stop();
this.sessions.delete(id);
this.updateSessionState(session);
@@ -235,3 +261,14 @@ export function getSessionManager(): SessionManager {
}
return managerInstance;
}
/**
* Resets the singleton SessionManager instance.
* Primarily used for testing to ensure test isolation.
*/
export async function resetSessionManager(): Promise<void> {
if (managerInstance) {
await managerInstance.stopAllSessions();
managerInstance = null;
}
}