From ade6340a22bebb45b92fb0cffebd767f6fb9dbda Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 29 Apr 2026 16:41:37 -0400 Subject: [PATCH] Recover from terminal hangs when shell integration breaks or the pty exits (#313312) --- .../executeStrategy/executeStrategy.ts | 17 ++++++++++++ .../browser/tools/runInTerminalTool.ts | 27 ++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts index a999bd6c23e..2972141b2ac 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts @@ -243,6 +243,23 @@ export async function trackIdleOnPrompt( scheduler.schedule(); } }, 30_000)); + // Hard wall-clock safety net for the case where shell integration never + // engages at all — no OSC `C`/`D` is ever parsed so state never advances + // to Executing, and yet the existing data-idle fallbacks somehow fail to + // fire (e.g. because data arrives in a single event before listeners are + // active, or because `onData` throttling masks idle periods). Without + // this, trackIdleOnPrompt can block the rich strategy for the lifetime + // of the chat request. Genuinely long-running commands with working + // shell integration reach Executing/PromptAfterExecuting before this + // fires, so this is purely a fallback for the broken-handshake path. + const hardCapScheduler = store.add(new RunOnceScheduler(() => { + if (state === TerminalState.Initial || state === TerminalState.Prompt) { + log?.(`Hard cap fired after 60s in state ${stateNames[state]} (dataEvents=${dataEventCount})`); + setState(TerminalState.PromptAfterExecuting, 'hardCap'); + scheduler.schedule(); + } + }, 60_000)); + hardCapScheduler.schedule(); // Only schedule when a prompt sequence (A) is seen after an execute sequence (C). This prevents // cases where the command is executed before the prompt is written. While not perfect, sitting // on an A without a C following shortly after is a very good indicator that the command is done diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index cf91a703a0c..a3004c0cd1b 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -2282,9 +2282,34 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { })); // Clean up all background resources when the terminal is disposed - // (e.g. user closes the terminal) to avoid leaking listeners and monitors. + // (e.g. user closes the terminal). Send a completion notification so + // the agent isn't left waiting for an `onCommandFinished` event that + // will never fire — the pty exited before shell integration could + // emit the end marker. Output captured here is whatever was buffered + // up until disposal. + // Capture the execution reference now — by the time onDisposed fires, + // onDidDisposeInstance listeners may have already removed it from + // _activeExecutions. + const executionForDisposal = RunInTerminalTool._activeExecutions.get(termId); store.add(terminalInstance.onDisposed(() => { + if (handleSessionCancelled()) { + return; + } + const currentOutput = executionForDisposal?.getOutput() ?? ''; + const exitCode = terminalInstance.exitCode; + const exitCodeText = exitCode !== undefined ? ` with exit code ${exitCode}` : ''; disposeNotification(); + const message = `[Terminal ${termId} notification: terminal exited${exitCodeText}. The terminal process ended before the command could complete normally; further commands cannot be sent to this terminal ID.]\nTerminal output:\n${currentOutput}`; + this._logService.debug(`RunInTerminalTool: Background terminal ${termId} disposed${exitCodeText}, notifying chat session`); + this._chatService.sendRequest(chatSessionResource, message, { + ...sendOptions, + queue: ChatRequestQueueKind.Steering, + isSystemInitiated: true, + systemInitiatedLabel: localize('terminalProcessExited', "`{0}` terminal exited", commandName), + terminalExecutionId: termId, + }).catch(e => { + this._logService.warn(`RunInTerminalTool: Failed to send terminal-exited notification for terminal ${termId}`, e); + }); })); // When a checkpoint is restored, requests are removed from the model.