Compare commits

..

8 Commits

Author SHA1 Message Date
openhands
08096db29f test 2025-09-18 22:50:21 -04:00
openhands
b2b6ddf90c test 2025-09-18 22:24:35 -04:00
openhands
87fe36d811 test 2025-09-18 21:44:34 -04:00
openhands
39d255d313 test 2025-09-18 21:27:03 -04:00
openhands
e334b67f21 Add logging 2025-09-18 20:48:24 -04:00
chuckbutkus
d5c02bf87b Merge branch 'main' into allow-custom-user 2025-09-17 22:43:30 -04:00
openhands
14a4664fe8 Make su commands optional 2025-09-17 22:40:21 -04:00
sp.wack
774caf0607 feat: refactor status indicators (#10983)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-17 22:32:55 +04:00
10 changed files with 132 additions and 21 deletions

View File

@@ -2,8 +2,6 @@ import React, { useRef, useCallback, useState, useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { ConversationStatus } from "#/types/conversation-status";
import { ServerStatus } from "#/components/features/controls/server-status";
import { AgentStatus } from "#/components/features/controls/agent-status";
import { ChatSendButton } from "./chat-send-button";
import { ChatAddFileButton } from "./chat-add-file-button";
import { cn, isMobileDevice } from "#/utils/utils";
@@ -20,6 +18,8 @@ import {
} from "#/state/conversation-slice";
import { CHAT_INPUT } from "#/utils/constants";
import { RootState } from "#/store";
import { ServerStatus } from "../controls/server-status";
import { AgentStatus } from "../controls/agent-status";
export interface CustomChatInputProps {
disabled?: boolean;

View File

@@ -263,8 +263,8 @@ export function WsClientProvider({
}
sio.io.opts.query = sio.io.opts.query || {};
sio.io.opts.query.latest_event_id = lastEventRef.current?.id;
updateStatusWhenErrorMessagePresent(data);
updateStatusWhenErrorMessagePresent(data);
setErrorMessage(hasValidMessageProperty(data) ? data.message : "");
}
@@ -296,8 +296,29 @@ export function WsClientProvider({
if (!conversationId) {
throw new Error("No conversation ID provided");
}
if (conversation?.status !== "RUNNING" && !conversation?.runtime_status) {
return () => undefined; // conversation not yet loaded
// Clear error messages when conversation is intentionally stopped
if (conversation && conversation.status === "STOPPED") {
removeErrorMessage();
setWebSocketStatus("DISCONNECTED");
return () => undefined; // conversation intentionally stopped
}
// Set connecting status when conversation is starting
if (conversation && conversation.status === "STARTING") {
removeErrorMessage();
setWebSocketStatus("CONNECTING");
return () => undefined; // conversation is starting, will connect when ready
}
// Only connect when conversation is fully loaded and running
if (
!conversation ||
conversation.status !== "RUNNING" ||
!conversation.runtime_status ||
conversation.runtime_status === "STATUS$STOPPED"
) {
return () => undefined; // conversation not ready for WebSocket connection
}
let sio = sioRef.current;

View File

@@ -1,8 +1,13 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router";
import ConversationService from "#/api/conversation-service/conversation-service.api";
export const useStopConversation = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { conversationId: currentConversationId } = useParams<{
conversationId: string;
}>();
return useMutation({
mutationFn: (variables: { conversationId: string }) =>
@@ -32,5 +37,14 @@ export const useStopConversation = () => {
// Also invalidate the conversations list for consistency
queryClient.invalidateQueries({ queryKey: ["user", "conversations"] });
},
onSuccess: (_, variables) => {
// Only redirect if we're stopping the conversation we're currently viewing
if (
currentConversationId &&
variables.conversationId === currentConversationId
) {
navigate("/");
}
},
});
};

View File

@@ -1,6 +1,7 @@
import React from "react";
import { useNavigate } from "react-router";
import { useDispatch } from "react-redux";
import { useQueryClient } from "@tanstack/react-query";
import { useConversationId } from "#/hooks/use-conversation-id";
import { clearTerminal } from "#/state/command-slice";
@@ -19,7 +20,6 @@ import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { useDocumentTitleFromState } from "#/hooks/use-document-title-from-state";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
import { ConversationSubscriptionsProvider } from "#/context/conversation-subscriptions-provider";
import { useUserProviders } from "#/hooks/use-user-providers";
@@ -28,16 +28,19 @@ import { ConversationMain } from "#/components/features/conversation/conversatio
import { ConversationName } from "#/components/features/conversation/conversation-name";
import { ConversationTabs } from "#/components/features/conversation/conversation-tabs/conversation-tabs";
import { useStartConversation } from "#/hooks/mutation/use-start-conversation";
function AppContent() {
useConversationConfig();
const { conversationId } = useConversationId();
const { data: conversation, isFetched, refetch } = useActiveConversation();
const { mutate: startConversation } = useStartConversation();
const { data: isAuthed } = useIsAuthed();
const { providers } = useUserProviders();
const dispatch = useDispatch();
const navigate = useNavigate();
const queryClient = useQueryClient();
// Fetch batch feedback data when conversation is loaded
useBatchFeedback();
@@ -45,6 +48,13 @@ function AppContent() {
// Set the document title to the conversation title when available
useDocumentTitleFromState();
// Force fresh conversation data when navigating to prevent stale cache issues
React.useEffect(() => {
queryClient.invalidateQueries({
queryKey: ["user", "conversation", conversationId],
});
}, [conversationId, queryClient]);
React.useEffect(() => {
if (isFetched && !conversation && isAuthed) {
displayErrorToast(
@@ -52,13 +62,25 @@ function AppContent() {
);
navigate("/");
} else if (conversation?.status === "STOPPED") {
// start the conversation if the state is stopped on initial load
ConversationService.startConversation(
conversation.conversation_id,
providers,
).then(() => refetch());
// If conversation is STOPPED, attempt to start it
startConversation(
{ conversationId: conversation.conversation_id, providers },
{
onError: (error) => {
displayErrorToast(`Failed to start conversation: ${error.message}`);
// Refetch the conversation to ensure UI consistency
refetch();
},
},
);
}
}, [conversation?.conversation_id, isFetched, isAuthed, providers]);
}, [
conversation?.conversation_id,
conversation?.status,
isFetched,
isAuthed,
providers,
]);
React.useEffect(() => {
dispatch(clearTerminal());

View File

@@ -161,6 +161,7 @@ class EventStream(EventStore):
self._clean_up_subscriber(subscriber_id, callback_id)
def add_event(self, event: Event, source: EventSource) -> None:
logger.info(f'Adding event with ID {event.id}')
if event.id != Event.INVALID_ID:
raise ValueError(
f'Event already has an ID:{event.id}. It was probably added back to the EventStream from inside a handler, triggering a loop.'
@@ -183,6 +184,7 @@ class EventStream(EventStore):
if len(current_write_page) == self.cache_size:
self._write_page_cache = []
logger.info(f'Event now has ID {event.id}')
if event.id is not None:
# Write the event to the store - this can take some time
event_json = json.dumps(data)
@@ -204,12 +206,18 @@ class EventStream(EventStore):
def _store_cache_page(self, current_write_page: list[dict]):
"""Store a page in the cache. Reading individual events is slow when there are a lot of them, so we use pages."""
logger.info(
f'Writing event cache if page len {len(current_write_page)} is greater than {self.cache_size}'
)
if len(current_write_page) < self.cache_size:
return
start = current_write_page[0]['id']
end = start + self.cache_size
contents = json.dumps(current_write_page)
cache_filename = self._get_filename_for_cache(start, end)
logger.info(
f'writing event cache to {cache_filename} in file store of type {type(self.file_store)}'
)
self.file_store.write(cache_filename, contents)
def set_secrets(self, secrets: dict[str, str]) -> None:

View File

@@ -13,6 +13,15 @@ from openhands.runtime.plugins.requirement import Plugin, PluginRequirement
from openhands.runtime.utils import find_available_tcp_port
from openhands.utils.shutdown_listener import should_continue
SU_TO_USER = os.getenv('SU_TO_USER', 'true').lower() in (
'1',
'true',
't',
'yes',
'y',
'on',
)
@dataclass
class JupyterRequirement(PluginRequirement):
@@ -36,7 +45,7 @@ class JupyterPlugin(Plugin):
if not is_local_runtime:
# Non-LocalRuntime
prefix = f'su - {username} -s '
prefix = f'su - {username} -s ' if SU_TO_USER else ''
# cd to code repo, setup all env vars and run micromamba
poetry_prefix = (
'cd /openhands/code\n'

View File

@@ -16,6 +16,14 @@ from openhands.runtime.utils.system import check_port_available
from openhands.utils.shutdown_listener import should_continue
RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME')
SU_TO_USER = os.getenv('SU_TO_USER', 'true').lower() in (
'1',
'true',
't',
'yes',
'y',
'on',
)
@dataclass
@@ -85,13 +93,19 @@ class VSCodePlugin(Plugin):
if path_mode:
base_path_flag = f' --server-base-path /{runtime_id}/vscode'
cmd = (
f"su - {username} -s /bin/bash << 'EOF'\n"
f'sudo chown -R {username}:{username} /openhands/.openvscode-server\n'
f'cd {workspace_path}\n'
f'exec /openhands/.openvscode-server/bin/openvscode-server --host 0.0.0.0 --connection-token {self.vscode_connection_token} --port {self.vscode_port} --disable-workspace-trust{base_path_flag}\n'
'EOF'
)
cmd = (
(
f"su - {username} -s /bin/bash << 'EOF'\n"
if SU_TO_USER
else "/bin/bash << 'EOF'\n"
)
+ f'sudo chown -R {username}:{username} /openhands/.openvscode-server\n'
+ f'cd {workspace_path}\n'
+ 'exec /openhands/.openvscode-server/bin/openvscode-server '
+ f'--host 0.0.0.0 --connection-token {self.vscode_connection_token} '
+ f'--port {self.vscode_port} --disable-workspace-trust{base_path_flag}\n'
+ 'EOF'
)
# Using asyncio.create_subprocess_shell instead of subprocess.Popen
# to avoid ASYNC101 linting error

View File

@@ -21,6 +21,14 @@ from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
from openhands.utils.shutdown_listener import should_continue
RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME')
SU_TO_USER = os.getenv('SU_TO_USER', 'true').lower() in (
'1',
'true',
't',
'yes',
'y',
'on',
)
def split_bash_commands(commands: str) -> list[str]:
@@ -195,7 +203,9 @@ class BashSession:
def initialize(self) -> None:
self.server = libtmux.Server()
_shell_command = '/bin/bash'
if self.username in list(filter(None, [RUNTIME_USERNAME, 'root', 'openhands'])):
if SU_TO_USER and self.username in list(
filter(None, [RUNTIME_USERNAME, 'root', 'openhands'])
):
# This starts a non-login (new) shell for the given user
_shell_command = f'su {self.username} -'

View File

@@ -1,4 +1,5 @@
import threading
import traceback
from typing import Optional
import httpx
@@ -62,6 +63,11 @@ class BatchedWebHookFileStore(FileStore):
batch_size_limit_bytes: Size limit in bytes after which a batch is sent.
If None, uses the default constant WEBHOOK_BATCH_SIZE_LIMIT_BYTES.
"""
logger.info(
f'BatchedWebHookFileStore __init__ called with filestore type {type(file_store)}'
)
stack = '\n'.join(traceback.format_stack())
logger.info('BatchedWebHookFileStore __init__ stack trace:\n%s', stack)
self.file_store = file_store
self.base_url = base_url
if client is None:
@@ -89,6 +95,9 @@ class BatchedWebHookFileStore(FileStore):
path: The path to write to
contents: The contents to write
"""
logger.info(
f'BatchedWebHookFileStore write to {path} in filestore of type {type(self.file_store)}'
)
self.file_store.write(path, contents)
self._queue_update(path, 'write', contents)

View File

@@ -1,9 +1,11 @@
import os
import traceback
from typing import Any, TypedDict
import boto3
import botocore
from openhands.core.logger import openhands_logger as logger
from openhands.storage.files import FileStore
@@ -37,6 +39,8 @@ class S3FileStore(FileStore):
)
def write(self, path: str, contents: str | bytes) -> None:
stack = '\n'.join(traceback.format_stack())
logger.info('S3FileStore write stack trace:\n%s', stack)
try:
as_bytes = (
contents.encode('utf-8') if isinstance(contents, str) else contents