Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/codev/src/agent-farm/servers/tower-instances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 10 additions & 2 deletions packages/codev/src/agent-farm/servers/tower-terminals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,10 @@ async function _reconcileTerminalSessionsInner(): Promise<void> {
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
Expand All @@ -504,7 +508,7 @@ async function _reconcileTerminalSessionsInner(): Promise<void> {
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);
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
49 changes: 49 additions & 0 deletions packages/codev/src/terminal/pty-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout> | null = null;
private _restartCancelFn: (() => void) | null = null;
private shellperPid = -1;
private cols: number;
private rows: number;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down