Compare commits

...

34 Commits

Author SHA1 Message Date
openhands
281af7a52a Fix _openhands_run to properly handle command arguments 2025-03-01 15:28:08 +00:00
openhands
cc87fba772 Use _openhands_run to execute commands 2025-03-01 15:26:23 +00:00
openhands
44efc9945b Fix PS1 prompt by clearing history and sending dummy command 2025-03-01 15:20:48 +00:00
openhands
689965dcba Fix PS1 prompt by using printf instead of echo 2025-03-01 15:19:12 +00:00
openhands
725066e1df Fix PS1 prompt by using a bash function 2025-03-01 15:16:24 +00:00
openhands
271075d3fd Fix PS1 prompt JSON formatting 2025-03-01 15:13:33 +00:00
openhands
e2d8400d7a Fix PS1 prompt by using actual values instead of escape sequences 2025-03-01 15:10:52 +00:00
openhands
b7d8d1e7f4 Fix PS1 prompt escaping by using a temporary file 2025-03-01 15:07:43 +00:00
openhands
66320f9714 Fix PS1 prompt escaping in bash session 2025-03-01 15:04:47 +00:00
openhands
38ed7f84bd Fix PS1 metadata formatting 2025-03-01 15:02:10 +00:00
openhands
994bfb2706 Improve process tracking robustness 2025-03-01 14:59:26 +00:00
openhands
753f7953ca Add comprehensive process management tests 2025-03-01 14:49:55 +00:00
openhands
1d3909f42b Fix mypy error by importing CmdOutputMetadata 2025-03-01 14:48:33 +00:00
openhands
ab899831ce add stopprocessesaction 2025-03-01 14:21:43 +00:00
openhands
92b919cbc6 Fix test to use StopProcessesAction instead of Ctrl+C 2025-03-01 13:46:38 +00:00
openhands
5e9729fbb9 Add test to demonstrate stop button not terminating background processes 2025-03-01 13:39:04 +00:00
openhands
3c5f432696 Add test to demonstrate stop button not terminating background processes 2025-03-01 13:25:13 +00:00
Graham Neubig
395e54bc77 Merge branch 'main' into neubig/refactor-bash-command-processing 2025-03-01 08:03:47 -05:00
Graham Neubig
6d8f983fb4 Fixed get processes 2025-03-01 07:53:10 -05:00
Graham Neubig
487b189025 Update 2025-02-27 18:38:51 -05:00
Graham Neubig
e328c4d7b8 Merge branch 'main' into neubig/refactor-bash-command-processing 2025-02-27 14:04:28 -05:00
Graham Neubig
31fd6064ea Merge branch 'neubig/refactor-bash-command-processing' of github.com:All-Hands-AI/OpenHands into neubig/refactor-bash-command-processing 2025-02-27 14:02:41 -05:00
Graham Neubig
1933180ba1 Fixed 2025-02-27 14:02:27 -05:00
openhands
2e958d2e9c Fix _closed attribute initialization in BashSession to avoid issues in __del__ 2025-02-27 18:49:24 +00:00
openhands
af9a2277b0 Fix linting issues in security invariant files 2025-02-27 18:32:03 +00:00
openhands
8c12c2edce Fix bash command processing tests by adding delay and skipping process info in tests 2025-02-27 17:31:48 +00:00
openhands
2c4f3612bc Merge remote-tracking branch 'origin/main' into neubig/refactor-bash-command-processing 2025-02-27 17:22:37 +00:00
openhands
6fd51a6ae7 Improve process detection logic to handle edge cases 2025-02-27 16:01:57 +00:00
openhands
926e3983d1 Apply linting fixes 2025-02-27 15:51:42 +00:00
openhands
1975d39ec4 Add logging for running processes in command polling loop 2025-02-27 15:49:50 +00:00
openhands
f557731ef7 Add unit test for get_running_processes method 2025-02-27 15:36:06 +00:00
openhands
26b857fc40 Add get_running_processes method to BashSession 2025-02-27 15:19:08 +00:00
Graham Neubig
10fbf83dac Small refactor 2025-02-26 08:54:15 -05:00
Graham Neubig
8d0381aae7 Refactor bash command processing 2025-02-26 08:47:21 -05:00
12 changed files with 663 additions and 84 deletions

View File

@@ -11,6 +11,7 @@ import { addUserMessage } from "#/state/chat-slice";
import { RootState } from "#/store";
import { AgentState } from "#/types/agent-state";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { getStopProcessesCommand } from "#/services/terminal-service";
import { FeedbackModal } from "../feedback/feedback-modal";
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
import { TypingIndicator } from "./typing-indicator";
@@ -82,7 +83,8 @@ export function ChatInterface() {
const handleStop = () => {
posthog.capture("stop_button_clicked");
send(generateAgentStateChangeEvent(AgentState.STOPPED));
send(getStopProcessesCommand()); // First kill all processes
send(generateAgentStateChangeEvent(AgentState.STOPPED)); // Then change agent state
};
const handleSendContinueMsg = () => {

View File

@@ -4,3 +4,8 @@ export function getTerminalCommand(command: string, hidden: boolean = false) {
const event = { action: ActionType.RUN, args: { command, hidden } };
return event;
}
export function getStopProcessesCommand() {
const event = { action: ActionType.STOP_PROCESSES, args: {} };
return event;
}

View File

@@ -38,6 +38,9 @@ enum ActionType {
// Changes the state of the agent, e.g. to paused or running
CHANGE_AGENT_STATE = "change_agent_state",
// Stops all running processes in the terminal
STOP_PROCESSES = "stop_processes",
}
export default ActionType;

View File

@@ -82,5 +82,8 @@ class ActionTypeSchema(BaseModel):
SEND_PR: str = Field(default='send_pr')
"""Send a PR to github."""
STOP_PROCESSES: str = Field(default='stop_processes')
"""Stop all running processes in the terminal."""
ActionType = ActionTypeSchema()

View File

@@ -8,7 +8,7 @@ from openhands.events.action.agent import (
ChangeAgentStateAction,
)
from openhands.events.action.browse import BrowseInteractiveAction, BrowseURLAction
from openhands.events.action.commands import CmdRunAction, IPythonRunCellAction
from openhands.events.action.commands import CmdRunAction, IPythonRunCellAction, StopProcessesAction
from openhands.events.action.empty import NullAction
from openhands.events.action.files import (
FileEditAction,
@@ -35,4 +35,5 @@ __all__ = [
'MessageAction',
'ActionConfirmationStatus',
'AgentThinkAction',
'StopProcessesAction',
]

View File

@@ -60,3 +60,18 @@ class IPythonRunCellAction(Action):
@property
def message(self) -> str:
return f'Running Python code interactively: {self.code}'
@dataclass
class StopProcessesAction(Action):
action: str = ActionType.STOP_PROCESSES
runnable: ClassVar[bool] = True
confirmation_state: ActionConfirmationStatus = ActionConfirmationStatus.CONFIRMED
security_risk: ActionSecurityRisk | None = None
def __str__(self) -> str:
return '**StopProcessesAction**\nStopping all running processes'
@property
def message(self) -> str:
return 'Stopping all running processes'

View File

@@ -42,12 +42,13 @@ class CmdOutputMetadata(BaseModel):
'hostname': r'\h',
'working_dir': r'$(pwd)',
'py_interpreter_path': r'$(which python 2>/dev/null || echo "")',
'timestamp': r'$(date +%s)',
},
indent=2,
)
# Make sure we escape double quotes in the JSON string
# So that PS1 will keep them as part of the output
prompt += json_str.replace('"', r'\"')
prompt += (
json_str # No need to escape quotes since we're using single quotes in PS1
)
prompt += CMD_OUTPUT_PS1_END + '\n' # Ensure there's a newline at the end
return prompt

View File

@@ -41,9 +41,11 @@ from openhands.events.action import (
FileReadAction,
FileWriteAction,
IPythonRunCellAction,
StopProcessesAction,
)
from openhands.events.event import FileEditSource, FileReadSource
from openhands.events.observation import (
CmdOutputMetadata,
CmdOutputObservation,
ErrorObservation,
FileEditObservation,
@@ -271,6 +273,17 @@ class ActionExecutor:
obs = await call_sync_from_async(self.bash_session.execute, action)
return obs
async def stop_processes(self, action: StopProcessesAction) -> CmdOutputObservation:
assert self.bash_session is not None
success = await call_sync_from_async(self.bash_session.kill_all_processes)
return CmdOutputObservation(
content='All running processes have been terminated'
if success
else 'No processes were terminated',
command='',
metadata=CmdOutputMetadata(),
)
async def run_ipython(self, action: IPythonRunCellAction) -> Observation:
assert self.bash_session is not None
if 'jupyter' in self.plugins:

View File

@@ -7,9 +7,10 @@ from enum import Enum
import bashlex
import libtmux
import psutil
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import CmdRunAction
from openhands.events.action import Action, CmdRunAction, StopProcessesAction
from openhands.events.observation import ErrorObservation
from openhands.events.observation.commands import (
CMD_OUTPUT_PS1_END,
@@ -225,11 +226,49 @@ class BashSession:
_initial_window.kill_window()
# Configure bash to use simple PS1 and disable PS2
# First get the current user and hostname
self.pane.send_keys('whoami > /tmp/user.txt && hostname > /tmp/host.txt')
time.sleep(0.1) # Wait for commands to complete
# Now set PS1 with actual values instead of escape sequences
# Use a function to generate the PS1 prompt to avoid escaping issues
self.pane.send_keys(
f'export PROMPT_COMMAND=\'export PS1="{self.PS1}"\'; export PS2=""'
'function _openhands_ps1() {\n'
' local pid="$!"\n'
' local exit_code="$?"\n'
' local username="$(cat /tmp/user.txt)"\n'
' local hostname="$(cat /tmp/host.txt)"\n'
' local working_dir="$(pwd)"\n'
' local py_interpreter_path="$(which python 2>/dev/null || echo \\"\\")"\n'
' local timestamp="$(date +%s)"\n'
' printf "\\n###PS1JSON###\\n{\\n"\n'
' printf " \\"pid\\": \\"%s\\",\\n" "$pid"\n'
' printf " \\"exit_code\\": \\"%s\\",\\n" "$exit_code"\n'
' printf " \\"username\\": \\"%s\\",\\n" "$username"\n'
' printf " \\"hostname\\": \\"%s\\",\\n" "$hostname"\n'
' printf " \\"working_dir\\": \\"%s\\",\\n" "$working_dir"\n'
' printf " \\"py_interpreter_path\\": \\"%s\\",\\n" "$py_interpreter_path"\n'
' printf " \\"timestamp\\": \\"%s\\"\\n" "$timestamp"\n'
' printf "}\\n###PS1END###\\n"\n'
'}\n'
'function _openhands_run() {\n'
' local cmd="$1"\n'
' shift\n'
' eval "$cmd $*"\n'
' local exit_code="$?"\n'
' _openhands_ps1\n'
' return "$exit_code"\n'
'}\n'
'export PROMPT_COMMAND=\'export PS1="$(_openhands_ps1)"\'; export PS2=""; history -c'
)
time.sleep(0.1) # Wait for command to take effect
self._clear_screen()
# Send a dummy command to get a clean PS1 prompt
self.pane.send_keys('true')
time.sleep(0.1) # Wait for command to complete
# Clear the screen again to remove the dummy command output
self._clear_screen()
# Wait for the PS1 prompt to appear
time.sleep(0.1)
# Store the last command for interactive input handling
self.prev_status: BashCommandStatus | None = None
@@ -256,10 +295,43 @@ class BashSession:
)
return content
def kill_process(self, pid: int) -> bool:
"""Kill a process by its PID.
Args:
pid (int): The PID of the process to kill.
Returns:
bool: True if the process was killed successfully, False otherwise.
"""
try:
process = psutil.Process(pid)
process.kill()
return True
except (psutil.NoSuchProcess, psutil.AccessDenied):
return False
def kill_all_processes(self) -> bool:
"""Kill all processes associated with the current command.
Returns:
bool: True if any processes were killed successfully, False otherwise.
"""
process_info = self.get_running_processes()
success = False
for pid in process_info['process_pids']:
if pid != int(
self.pane.cmd('display-message', '-p', '#{pane_pid}').stdout[0].strip()
):
if self.kill_process(pid):
success = True
return success
def close(self):
"""Clean up the session."""
if self._closed:
return
self.kill_all_processes() # Kill any remaining processes
self.session.kill_session()
self._closed = True
@@ -429,6 +501,170 @@ class BashSession:
# Clear the current content
self._clear_screen()
def get_running_processes(self):
"""Get a list of processes that are currently running in the bash session.
Returns:
dict: A dictionary containing:
- 'is_command_running': Boolean indicating if the last command is still running
- 'current_command_pid': PID of the currently running command (if any)
- 'processes': List of all processes visible to this bash session
- 'command_processes': List of processes that are likely part of the current command
- 'process_pids': List of PIDs of all processes
- 'command_pids': List of PIDs of processes that are likely part of the current command
"""
# Check if a command is running in this session
pane_content = self._get_pane_content()
ps1_matches = CmdOutputMetadata.matches_ps1_metadata(pane_content)
is_command_running = not pane_content.rstrip().endswith(
CMD_OUTPUT_PS1_END.rstrip()
)
# If we have a PS1 prompt and no command is running, we're in a clean state
if len(ps1_matches) > 0 and not is_command_running:
return {
'is_command_running': False,
'current_command_pid': None,
'processes': [],
'command_processes': [],
'process_pids': [],
'command_pids': [],
}
# Get the shell's PID directly from tmux
try:
shell_pid_str = (
self.pane.cmd('display-message', '-p', '#{pane_pid}').stdout[0].strip()
)
shell_pid = int(shell_pid_str)
except (IndexError, ValueError):
logger.warning('Failed to get shell PID from tmux')
return {
'is_command_running': is_command_running,
'current_command_pid': None,
'processes': [],
'command_processes': [],
'process_pids': [],
'command_pids': [],
}
try:
# Get process information for the shell
shell_process = psutil.Process(shell_pid)
process_list = []
command_processes = []
current_command_pid = None
# Get all child processes recursively
children = shell_process.children(recursive=True)
# Add the shell process first
process_str = f"{shell_pid} {shell_process.ppid()} {shell_process.status()[0]} {' '.join(shell_process.cmdline())}"
process_list.append(process_str)
# First pass: identify direct children of the shell
for child in children:
try:
# Skip if no cmdline (might be a kernel process)
cmdline = child.cmdline()
if not cmdline:
continue
# Format the process info
status_flag = child.status()[0]
# Build process string (PID PPID STATUS COMMAND)
cmd_str = ' '.join(cmdline)
process_str = f'{child.pid} {child.ppid()} {status_flag} {cmd_str}'
process_list.append(process_str)
# Direct child of shell = likely current command
if child.ppid() == shell_pid:
if not current_command_pid:
current_command_pid = child.pid
command_processes.append(process_str)
except (psutil.NoSuchProcess, psutil.AccessDenied):
# Process may have terminated while we were examining it
continue
# Second pass: identify children of command processes
for child in children:
try:
cmdline = child.cmdline()
if not cmdline:
continue
# Skip if already identified as command process
if any(
child.pid == int(proc.split()[0]) for proc in command_processes
):
continue
# Format process info
status_flag = child.status()[0]
cmd_str = ' '.join(cmdline)
process_str = f'{child.pid} {child.ppid()} {status_flag} {cmd_str}'
# Check if this is a child of any command process
child_ppid = child.ppid()
if any(
child_ppid == int(proc.split()[0]) for proc in command_processes
):
command_processes.append(process_str)
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
# If we have a running command but couldn't identify processes, it might be a shell builtin
if is_command_running and not command_processes:
logger.debug(
'Command appears to be running but no child processes detected. '
'This might be a shell builtin or a command that completed very quickly.'
)
except (psutil.NoSuchProcess, psutil.AccessDenied) as e:
logger.warning(f'Error accessing process information: {e}')
return {
'is_command_running': is_command_running,
'current_command_pid': None,
'processes': [],
'command_processes': [],
'process_pids': [],
'command_pids': [],
}
# Extract PIDs from process strings
process_pids = []
command_pids = []
for proc in process_list:
try:
pid = int(proc.split()[0])
process_pids.append(pid)
except (ValueError, IndexError):
continue
for proc in command_processes:
try:
pid = int(proc.split()[0])
command_pids.append(pid)
except (ValueError, IndexError):
continue
# Update is_command_running based on process state
if not is_command_running and command_processes:
is_command_running = True
elif is_command_running and not command_processes:
is_command_running = False
return {
'is_command_running': is_command_running,
'current_command_pid': current_command_pid,
'processes': process_list,
'command_processes': command_processes,
'process_pids': process_pids,
'command_pids': command_pids,
}
def _combine_outputs_between_matches(
self,
pane_content: str,
@@ -464,34 +700,105 @@ class BashSession:
logger.debug(f'COMBINED OUTPUT: {combined_output}')
return combined_output
def execute(self, action: CmdRunAction) -> CmdOutputObservation | ErrorObservation:
def execute(self, action: Action) -> CmdOutputObservation | ErrorObservation:
"""Execute a command in the bash session."""
if not self._initialized:
raise RuntimeError('Bash session is not initialized')
# Strip the command of any leading/trailing whitespace
logger.debug(f'RECEIVED ACTION: {action}')
command = action.command.strip()
is_input: bool = action.is_input
# If the previous command is not completed, we need to check if the command is empty
# Handle StopProcessesAction
if isinstance(action, StopProcessesAction):
success = self.kill_all_processes()
return CmdOutputObservation(
content='All running processes have been terminated'
if success
else 'No processes were terminated',
command='',
metadata=CmdOutputMetadata(),
)
# Handle CmdRunAction
if not isinstance(action, CmdRunAction):
return ErrorObservation(f'Unsupported action type: {type(action)}')
command = action.command.strip()
is_input = action.is_input
# Handle different command types
if command == '':
return self._handle_empty_command(action)
elif is_input:
return self._handle_input_command(action)
else:
return self._handle_normal_command(action)
def _handle_empty_command(self, action: CmdRunAction) -> CmdOutputObservation:
"""Handle an empty command (usually to retrieve more output from a running command)."""
assert action.command.strip() == ''
# If the previous command is not in a continuing state, return an error
if self.prev_status not in {
BashCommandStatus.CONTINUE,
BashCommandStatus.NO_CHANGE_TIMEOUT,
BashCommandStatus.HARD_TIMEOUT,
}:
if command == '':
return CmdOutputObservation(
content='ERROR: No previous running command to retrieve logs from.',
command='',
metadata=CmdOutputMetadata(),
)
if is_input:
return CmdOutputObservation(
content='ERROR: No previous running command to interact with.',
command='',
metadata=CmdOutputMetadata(),
)
return CmdOutputObservation(
content='ERROR: No previous running command to retrieve logs from.',
command='',
metadata=CmdOutputMetadata(),
)
# Start polling for command completion
return self._poll_for_command_completion('', action)
def _handle_input_command(self, action: CmdRunAction) -> CmdOutputObservation:
"""Handle an input command (sent to a running process)."""
command = action.command.strip()
# If the previous command is not in a continuing state, return an error
if self.prev_status not in {
BashCommandStatus.CONTINUE,
BashCommandStatus.NO_CHANGE_TIMEOUT,
BashCommandStatus.HARD_TIMEOUT,
}:
return CmdOutputObservation(
content='ERROR: No previous running command to interact with.',
command='',
metadata=CmdOutputMetadata(),
)
# Check if it's a special key
is_special_key = self._is_special_key(command)
# Send the input to the pane
logger.debug(f'SENDING INPUT TO RUNNING PROCESS: {command!r}')
self.pane.send_keys(
command,
enter=not is_special_key,
)
# Start polling for command completion
return self._poll_for_command_completion(command, action)
def _handle_normal_command(
self, action: CmdRunAction
) -> CmdOutputObservation | ErrorObservation:
"""Handle a normal command."""
command = action.command.strip()
# Check if command is running previous command first
last_pane_output = self._get_pane_content()
if (
self.prev_status
in {
BashCommandStatus.HARD_TIMEOUT,
BashCommandStatus.NO_CHANGE_TIMEOUT,
}
and not last_pane_output.endswith(
CMD_OUTPUT_PS1_END
) # prev command is not completed
):
return self._handle_interrupted_command(command, last_pane_output)
# Check if the command is a single command or multiple commands
splited_commands = split_bash_commands(command)
@@ -504,67 +811,56 @@ class BashSession:
)
)
# Convert command to raw string and send it
is_special_key = self._is_special_key(command)
command = escape_bash_special_chars(command)
logger.debug(f'SENDING COMMAND: {command!r}')
if is_special_key:
self.pane.send_keys(command, enter=False)
else:
self.pane.send_keys(f'_openhands_run {command}')
# Start polling for command completion
return self._poll_for_command_completion(command, action)
def _handle_interrupted_command(
self, command: str, last_pane_output: str
) -> CmdOutputObservation:
"""Handle the case where a new command is sent while a previous command is still running."""
_ps1_matches = CmdOutputMetadata.matches_ps1_metadata(last_pane_output)
raw_command_output = self._combine_outputs_between_matches(
last_pane_output, _ps1_matches
)
metadata = CmdOutputMetadata() # No metadata available
metadata.suffix = (
f'\n[Your command "{command}" is NOT executed. '
f'The previous command is still running - You CANNOT send new commands until the previous command is completed. '
'By setting `is_input` to `true`, you can interact with the current process: '
"You may wait longer to see additional output of the previous command by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys ("C-c", "C-z", "C-d") to interrupt/kill the previous command before sending your new command.]'
)
logger.debug(f'PREVIOUS COMMAND OUTPUT: {raw_command_output}')
command_output = self._get_command_output(
command,
raw_command_output,
metadata,
continue_prefix='[Below is the output of the previous command.]\n',
)
return CmdOutputObservation(
command=command,
content=command_output,
metadata=metadata,
)
def _poll_for_command_completion(
self, command: str, action: CmdRunAction
) -> CmdOutputObservation:
"""Poll for command completion and handle timeouts."""
start_time = time.time()
last_change_time = start_time
last_pane_output = self._get_pane_content()
# When prev command is still running, and we are trying to send a new command
if (
self.prev_status
in {
BashCommandStatus.HARD_TIMEOUT,
BashCommandStatus.NO_CHANGE_TIMEOUT,
}
and not last_pane_output.endswith(
CMD_OUTPUT_PS1_END
) # prev command is not completed
and not is_input
and command != '' # not input and not empty command
):
_ps1_matches = CmdOutputMetadata.matches_ps1_metadata(last_pane_output)
raw_command_output = self._combine_outputs_between_matches(
last_pane_output, _ps1_matches
)
metadata = CmdOutputMetadata() # No metadata available
metadata.suffix = (
f'\n[Your command "{command}" is NOT executed. '
f'The previous command is still running - You CANNOT send new commands until the previous command is completed. '
'By setting `is_input` to `true`, you can interact with the current process: '
"You may wait longer to see additional output of the previous command by sending empty command '', "
'send other commands to interact with the current process, '
'or send keys ("C-c", "C-z", "C-d") to interrupt/kill the previous command before sending your new command.]'
)
logger.debug(f'PREVIOUS COMMAND OUTPUT: {raw_command_output}')
command_output = self._get_command_output(
command,
raw_command_output,
metadata,
continue_prefix='[Below is the output of the previous command.]\n',
)
return CmdOutputObservation(
command=command,
content=command_output,
metadata=metadata,
)
# Send actual command/inputs to the pane
if command != '':
is_special_key = self._is_special_key(command)
if is_input:
logger.debug(f'SENDING INPUT TO RUNNING PROCESS: {command!r}')
self.pane.send_keys(
command,
enter=not is_special_key,
)
else:
# convert command to raw string
command = escape_bash_special_chars(command)
logger.debug(f'SENDING COMMAND: {command!r}')
self.pane.send_keys(
command,
enter=not is_special_key,
)
# Loop until the command completes or times out
while should_continue():
_start_time = time.time()
@@ -575,6 +871,18 @@ class BashSession:
)
logger.debug(f'BEGIN OF PANE CONTENT: {cur_pane_output.split("\n")[:10]}')
logger.debug(f'END OF PANE CONTENT: {cur_pane_output.split("\n")[-10:]}')
# Log running processes for debugging
try:
process_info = self.get_running_processes()
logger.debug(
f'RUNNING PROCESSES: is_command_running={process_info["is_command_running"]}, '
f'current_command_pid={process_info["current_command_pid"]}, '
f'command_processes_count={len(process_info["command_processes"])}'
)
except Exception as e:
logger.warning(f'Failed to get running processes: {e}')
ps1_matches = CmdOutputMetadata.matches_ps1_metadata(cur_pane_output)
if cur_pane_output != last_pane_output:
last_pane_output = cur_pane_output
@@ -582,7 +890,6 @@ class BashSession:
logger.debug(f'CONTENT UPDATED DETECTED at {last_change_time}')
# 1) Execution completed
# if the last command output contains the end marker
if cur_pane_output.rstrip().endswith(CMD_OUTPUT_PS1_END.rstrip()):
return self._handle_completed_command(
command,
@@ -591,8 +898,6 @@ class BashSession:
)
# 2) Execution timed out since there's no change in output
# for a while (self.NO_CHANGE_TIMEOUT_SECONDS)
# We ignore this if the command is *blocking
time_since_last_change = time.time() - last_change_time
logger.debug(
f'CHECKING NO CHANGE TIMEOUT ({self.NO_CHANGE_TIMEOUT_SECONDS}s): elapsed {time_since_last_change}. Action blocking: {action.blocking}'

View File

@@ -386,3 +386,40 @@ def test_python_interactive_input():
assert session.prev_status == BashCommandStatus.COMPLETED
session.close()
def test_get_running_processes():
"""Test the get_running_processes method to detect running processes."""
session = BashSession(work_dir=os.getcwd(), no_change_timeout_seconds=2)
session.initialize()
# First check with no running command
process_info = session.get_running_processes()
assert isinstance(process_info, dict)
assert 'is_command_running' in process_info
assert process_info['is_command_running'] is False
assert 'processes' in process_info
assert len(process_info['processes']) == 1 # should have the shell process
assert 'command_processes' in process_info
assert len(process_info['command_processes']) == 0
assert 'current_command_pid' in process_info
assert process_info['current_command_pid'] is None
session.execute(CmdRunAction('sleep 120', blocking=False))
# Check running processes
process_info = session.get_running_processes()
assert process_info['is_command_running'] is True
assert process_info['current_command_pid'] is not None
assert len(process_info['command_processes']) > 0
# Send Ctrl+C to terminate the process
session.execute(CmdRunAction('C-c', is_input=True))
# Verify process is no longer running
process_info = session.get_running_processes()
assert process_info['is_command_running'] is False
assert process_info['current_command_pid'] is None
assert len(process_info['command_processes']) == 0
session.close()

View File

@@ -0,0 +1,161 @@
"""Tests for process management functionality."""
import os
import time
from openhands.events.action import CmdRunAction, StopProcessesAction
from openhands.runtime.utils.bash import BashSession
def test_multiple_processes():
"""Test handling multiple processes running simultaneously."""
session = BashSession(work_dir=os.getcwd(), no_change_timeout_seconds=2)
session.initialize()
# Start multiple background processes
obs = session.execute(CmdRunAction('sleep 60 & sleep 70 & sleep 80 &'))
assert obs.metadata.exit_code == 0
# Check that all processes are tracked
process_info = session.get_running_processes()
assert process_info['is_command_running'] is True
assert len(process_info['command_processes']) >= 3 # At least 3 sleep processes
# Stop all processes
obs = session.execute(StopProcessesAction())
assert 'All running processes have been terminated' in obs.content
# Verify all processes are stopped
process_info = session.get_running_processes()
assert process_info['is_command_running'] is False
assert len(process_info['command_processes']) == 0
session.close()
def test_nested_processes():
"""Test handling nested processes."""
session = BashSession(work_dir=os.getcwd(), no_change_timeout_seconds=2)
session.initialize()
# Start a process that spawns other processes
obs = session.execute(
CmdRunAction('bash -c "while true; do sleep 1 & sleep 2 & sleep 3; done" &')
)
assert obs.metadata.exit_code == 0
time.sleep(2) # Give time for processes to spawn
# Check that parent and child processes are tracked
process_info = session.get_running_processes()
assert process_info['is_command_running'] is True
assert (
len(process_info['command_processes']) >= 4
) # bash + at least 3 sleep processes
# Stop all processes
obs = session.execute(StopProcessesAction())
assert 'All running processes have been terminated' in obs.content
# Verify all processes are stopped
process_info = session.get_running_processes()
assert process_info['is_command_running'] is False
assert len(process_info['command_processes']) == 0
session.close()
def test_process_termination_signals():
"""Test process termination with different signals."""
session = BashSession(work_dir=os.getcwd(), no_change_timeout_seconds=2)
session.initialize()
# Start a process that ignores SIGTERM
obs = session.execute(
CmdRunAction(
'python3 -c "import signal, time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(60)" &'
)
)
assert obs.metadata.exit_code == 0
time.sleep(1) # Give time for process to start
# Check that process is running
process_info = session.get_running_processes()
assert process_info['is_command_running'] is True
assert len(process_info['command_processes']) >= 1
# Stop all processes (should use SIGKILL for stubborn processes)
obs = session.execute(StopProcessesAction())
assert 'All running processes have been terminated' in obs.content
# Verify process is stopped
process_info = session.get_running_processes()
assert process_info['is_command_running'] is False
assert len(process_info['command_processes']) == 0
session.close()
def test_process_tracking_edge_cases():
"""Test edge cases in process tracking."""
session = BashSession(work_dir=os.getcwd(), no_change_timeout_seconds=2)
session.initialize()
# Test very short-lived process
obs = session.execute(CmdRunAction('sleep 0.1'))
assert obs.metadata.exit_code == 0
# Process should complete before we can track it
process_info = session.get_running_processes()
assert process_info['is_command_running'] is False
assert len(process_info['command_processes']) == 0
# Test process that exits immediately
obs = session.execute(CmdRunAction('exit 0'))
assert obs.metadata.exit_code == 0
process_info = session.get_running_processes()
assert process_info['is_command_running'] is False
assert len(process_info['command_processes']) == 0
# Test process that forks and exits
obs = session.execute(CmdRunAction('(sleep 30 &) && exit 0'))
assert obs.metadata.exit_code == 0
time.sleep(1) # Give time for fork to complete
# The sleep process should still be tracked
process_info = session.get_running_processes()
assert process_info['is_command_running'] is True
assert len(process_info['command_processes']) >= 1
# Stop all processes
obs = session.execute(StopProcessesAction())
assert 'All running processes have been terminated' in obs.content
session.close()
def test_stop_processes_idempotency():
"""Test that StopProcessesAction is idempotent."""
session = BashSession(work_dir=os.getcwd(), no_change_timeout_seconds=2)
session.initialize()
# First call with no processes running
obs = session.execute(StopProcessesAction())
assert 'No processes were terminated' in obs.content
# Start some processes
session.execute(CmdRunAction('sleep 60 & sleep 70 &'))
time.sleep(1)
# First stop - should terminate processes
obs = session.execute(StopProcessesAction())
assert 'All running processes have been terminated' in obs.content
# Second stop - should be safe to call
obs = session.execute(StopProcessesAction())
assert 'No processes were terminated' in obs.content
session.close()

View File

@@ -0,0 +1,33 @@
import time
from openhands.events.action import CmdRunAction, StopProcessesAction
from openhands.runtime.utils.bash import BashSession
def test_stop_button_background_process():
session = BashSession(work_dir="/tmp", no_change_timeout_seconds=2)
session.initialize()
# Start a process that runs indefinitely and detaches from the terminal
obs = session.execute(
CmdRunAction("nohup sleep 60 > /dev/null 2>&1 &") # Background process that detaches from terminal
)
time.sleep(2) # Give time for the process to start
# Get initial process info
process_info = session.get_running_processes()
print("Initial process info:", process_info) # Debug output
assert any("sleep" in p for p in process_info["processes"]), "Expected to find sleep process"
initial_processes = [p for p in process_info["processes"] if "sleep" in p]
assert len(initial_processes) > 0, "Expected at least one sleep process"
# Send StopProcessesAction to stop it
obs = session.execute(StopProcessesAction())
time.sleep(1) # Give time for processes to be killed
# Check if process is still running (it should be terminated)
process_info = session.get_running_processes()
print("Process info after StopProcessesAction:", process_info) # Debug output
assert not any("sleep" in p for p in process_info["processes"]), "Background process should be terminated"
session.close()