diff --git a/packages/codev/src/agent-farm/servers/tower-instances.ts b/packages/codev/src/agent-farm/servers/tower-instances.ts index fecd92804..5d2c36600 100644 --- a/packages/codev/src/agent-farm/servers/tower-instances.ts +++ b/packages/codev/src/agent-farm/servers/tower-instances.ts @@ -393,13 +393,18 @@ export async function launchInstance(workspacePath: string): Promise<{ success: const ptySession = manager.getSession(session.id); if (ptySession) { ptySession.attachShellper(client, replayData, shellperInfo.pid, sessionId); + // Auto-restart is configured at the shellper level — tell PtySession + // to keep WebSocket clients connected when the process exits. + ptySession.restartOnExit = true; } entry.architect = session.id; _deps.saveTerminalSession(session.id, resolvedPath, 'architect', null, shellperInfo.pid, shellperInfo.socketPath, shellperInfo.pid, shellperInfo.startTime); - // Clean up cache/SQLite when the shellper session exits + // Clean up cache/SQLite when the shellper session permanently exits + // (e.g., max restarts exceeded or killed). With restartOnExit, this + // only fires for permanent death — normal exits are suppressed by PtySession. if (ptySession) { ptySession.on('exit', (exitCode?: number, signal?: number | string | null) => { const currentEntry = _deps!.getWorkspaceTerminalsEntry(resolvedPath); diff --git a/packages/codev/src/agent-farm/servers/tower-terminals.ts b/packages/codev/src/agent-farm/servers/tower-terminals.ts index 37502017b..533b2c687 100644 --- a/packages/codev/src/agent-farm/servers/tower-terminals.ts +++ b/packages/codev/src/agent-farm/servers/tower-terminals.ts @@ -486,6 +486,10 @@ async function _reconcileTerminalSessionsInner(): Promise { const ptySession = manager.getSession(session.id); if (ptySession) { ptySession.attachShellper(client, replayData, dbSession.shellper_pid!, dbSession.id); + // Architect sessions have auto-restart — keep WebSocket clients connected on exit + if (dbSession.type === 'architect') { + ptySession.restartOnExit = true; + } } // Register in workspaceTerminals Map @@ -504,7 +508,7 @@ async function _reconcileTerminalSessionsInner(): Promise { dbSession.shellper_socket, dbSession.shellper_pid, dbSession.shellper_start_time); _deps.registerKnownWorkspace(workspacePath); - // Clean up on exit + // Clean up on exit (only fires for permanent death when restartOnExit is set) if (ptySession) { ptySession.on('exit', () => { const currentEntry = getWorkspaceTerminalsEntry(workspacePath); @@ -635,8 +639,12 @@ export async function getTerminalsForWorkspace( const ptySession = manager.getSession(newSession.id); if (ptySession) { ptySession.attachShellper(client, replayData, dbSession.shellper_pid!, dbSession.id); + // Architect sessions have auto-restart — keep WebSocket clients connected on exit + if (dbSession.type === 'architect') { + ptySession.restartOnExit = true; + } - // Clean up on exit (same as startup reconciliation path) + // Clean up on exit (only fires for permanent death when restartOnExit is set) ptySession.on('exit', () => { const currentEntry = getWorkspaceTerminalsEntry(dbSession.workspace_path); if (dbSession.type === 'architect' && currentEntry.architect === newSession.id) { diff --git a/packages/codev/src/terminal/__tests__/tower-shellper-integration.test.ts b/packages/codev/src/terminal/__tests__/tower-shellper-integration.test.ts index 085d31a8c..5e6c98e8c 100644 --- a/packages/codev/src/terminal/__tests__/tower-shellper-integration.test.ts +++ b/packages/codev/src/terminal/__tests__/tower-shellper-integration.test.ts @@ -237,6 +237,93 @@ describe('PtySession + ShellperClient integration', () => { }); }); + describe('restartOnExit behavior (Bugfix #418)', () => { + it('suppresses exit event and keeps clients when restartOnExit is true', () => { + session.attachShellper(mockClient, Buffer.alloc(0), 9999); + session.restartOnExit = true; + + const wsClient = { send: vi.fn() }; + session.attach(wsClient); + + const exitSpy = vi.fn(); + session.on('exit', exitSpy); + + mockClient.simulateExit(0); + + // Exit event should NOT fire — auto-restart will handle it + expect(exitSpy).not.toHaveBeenCalled(); + // WebSocket client should still be attached (not cleared by cleanupShellper) + expect(session.info.status).toBe('exited'); // exitCode is set + // A restarting message should have been written to the terminal + const ringContent = session.ringBuffer.getAll().join(''); + expect(ringContent).toContain('restarting'); + }); + + it('cancels cleanup when new data arrives after exit (process restarted)', () => { + vi.useFakeTimers(); + session.attachShellper(mockClient, Buffer.alloc(0), 9999); + session.restartOnExit = true; + + const exitSpy = vi.fn(); + session.on('exit', exitSpy); + + // Process exits + mockClient.simulateExit(0); + expect(exitSpy).not.toHaveBeenCalled(); + expect(session.status).toBe('exited'); // exitCode is set initially + + // Process restarts — new data arrives before timeout + vi.advanceTimersByTime(2000); // 2s restart delay + mockClient.simulateData('new session started\r\n'); + + // exitCode should be cleared — session is running again + expect(session.status).toBe('running'); + + // Write should work after restart + session.write('test input'); + expect(mockClient.writeData).toContain('test input'); + + // Advance past the 10s cleanup timeout + vi.advanceTimersByTime(10_000); + + // Exit should NOT have fired — restart succeeded + expect(exitSpy).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('falls through to normal exit cleanup when no data arrives (max restarts)', () => { + vi.useFakeTimers(); + session.attachShellper(mockClient, Buffer.alloc(0), 9999); + session.restartOnExit = true; + + const exitSpy = vi.fn(); + session.on('exit', exitSpy); + + // Process exits + mockClient.simulateExit(1); + expect(exitSpy).not.toHaveBeenCalled(); + + // No restart happens — advance past 10s timeout + vi.advanceTimersByTime(10_000); + + // Exit should fire now (permanent death) + expect(exitSpy).toHaveBeenCalledWith(1, null); + vi.useRealTimers(); + }); + + it('emits exit normally when restartOnExit is false (default)', () => { + session.attachShellper(mockClient, Buffer.alloc(0), 9999); + // restartOnExit defaults to false + + const exitSpy = vi.fn(); + session.on('exit', exitSpy); + + mockClient.simulateExit(0); + + expect(exitSpy).toHaveBeenCalledWith(0, null); + }); + }); + describe('detach behavior for shellper sessions', () => { it('does not start disconnect timer for shellper-backed sessions', () => { vi.useFakeTimers(); diff --git a/packages/codev/src/terminal/pty-session.ts b/packages/codev/src/terminal/pty-session.ts index 5e406438e..7690164aa 100644 --- a/packages/codev/src/terminal/pty-session.ts +++ b/packages/codev/src/terminal/pty-session.ts @@ -48,6 +48,9 @@ export class PtySession extends EventEmitter { private shellperClient: IShellperClient | null = null; private _shellperBacked = false; private _shellperSessionId: string | null = null; + private _restartOnExit = false; + private _restartCleanupTimeout: ReturnType | null = null; + private _restartCancelFn: (() => void) | null = null; private shellperPid = -1; private cols: number; private rows: number; @@ -136,6 +139,39 @@ export class PtySession extends EventEmitter { // Handle shellper exit (process inside shellper exited) client.on('exit', (exitInfo: { code: number; signal: string | null }) => { this.exitCode = exitInfo.code; + if (this._restartOnExit) { + // Clear any pending restart state from a previous exit (crash loop guard) + if (this._restartCleanupTimeout) { + clearTimeout(this._restartCleanupTimeout); + if (this._restartCancelFn) { + client.removeListener('data', this._restartCancelFn); + } + } + // Process will auto-restart via SessionManager — keep WebSocket clients + // connected and don't emit 'exit' so Tower doesn't clear references. + this.onPtyData('\r\n\x1b[90m[Process exited — restarting...]\x1b[0m\r\n'); + // Wait for the process to restart. If new data arrives (process restarted), + // cancel the cleanup timer. If no data within 10s (e.g. max restarts + // exceeded), fall through to normal exit cleanup. + this._restartCleanupTimeout = setTimeout(() => { + client.removeListener('data', cancelCleanup); + this._restartCleanupTimeout = null; + this._restartCancelFn = null; + this.emit('exit', exitInfo.code, exitInfo.signal); + this.cleanupShellper(); + }, 10_000); + const cancelCleanup = () => { + clearTimeout(this._restartCleanupTimeout!); + client.removeListener('data', cancelCleanup); + this._restartCleanupTimeout = null; + this._restartCancelFn = null; + // Process restarted — reset exitCode so write/resize work again + this.exitCode = undefined; + }; + this._restartCancelFn = cancelCleanup; + client.on('data', cancelCleanup); + return; + } this.emit('exit', exitInfo.code, exitInfo.signal); // For shellper-backed sessions, cleanup closes disk log and clients // but doesn't clear the ring buffer (shellper may still have replay data) @@ -163,6 +199,19 @@ export class PtySession extends EventEmitter { return this._shellperSessionId; } + /** + * Whether this session should suppress exit cleanup because the process + * will auto-restart via SessionManager. When true, the exit handler + * keeps WebSocket clients connected and does not emit 'exit'. + */ + get restartOnExit(): boolean { + return this._restartOnExit; + } + + set restartOnExit(value: boolean) { + this._restartOnExit = value; + } + /** * Detach from shellper client during Tower shutdown. * Removes all event listeners so that SessionManager.shutdown() disconnecting