Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix closing sessions #6114

Open
wants to merge 57 commits into
base: main
Choose a base branch
from
Open

Fix closing sessions #6114

wants to merge 57 commits into from

Conversation

tofarr
Copy link
Collaborator

@tofarr tofarr commented Jan 7, 2025

End-user friendly description of the problem this fixes or functionality that this introduces

This PR improves the handling of multiple conversations and session management in OpenHands. It ensures that user workspaces are preserved even after disconnections or server restarts, and implements a smart session management system that automatically handles conversation limits.

  • Include this change in the Release Notes. If checked, you must provide an end-user friendly description for your change below

Improved multi-conversation support with automatic session management and workspace preservation. Users can now maintain multiple conversations across different tabs while ensuring their work is preserved, even after disconnections or server restarts.


Summary of Changes

  • Added user_id tracking to sessions for better user-specific resource management
  • Implemented proper closing of stale sessions to prevent resource leaks
  • Added "agent stopped" event emission for better frontend state management
  • Enhanced recovery mechanism to preserve workspace/files after disconnection
  • Added smart session management for handling multiple conversations

Acceptance Criteria for Multi-conversation Runtime Management

Recovery

  • Start a conversation
  • Disconnect
  • Restart the server
  • Verify workspace/files are preserved

Conversation Limits

  • Start 4 conversations in different tabs
  • First conversation goes to "agent stopped"
  • Sending a new message starts it back up, and another conversation goes to "agent stopped"
  • Verify workspace is totally recovered

Testing Instructions

To run this PR locally, use the following command:

docker run -it --rm   -p 3000:3000   -v /var/run/docker.sock:/var/run/docker.sock   --add-host host.docker.internal:host-gateway   -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:6769659-nikolaik   --name openhands-app-6769659   docker.all-hands.dev/all-hands-ai/openhands:6769659
  -p 3000:3000 \
  -v /var/run/docker.sock:/var/run/docker.sock \
  --add-host host.docker.internal:host-gateway \
  -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:b2a0de2-nikolaik \
  --name openhands-app-b2a0de2 \
  docker.all-hands.dev/all-hands-ai/openhands:b2a0de2

@tofarr tofarr marked this pull request as ready for review January 7, 2025 19:51
Comment on lines -192 to -195
if sid in self._detached_conversations:
conversation, _ = self._detached_conversations.pop(sid)
self._active_conversations[sid] = (conversation, 1)
logger.info(f'Reusing detached conversation {sid}')
return conversation
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why did we lose this?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we just leave _attached_conversations until the whole thing closes? That seems reasonable actually...

Copy link
Collaborator Author

@tofarr tofarr Jan 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The concept of stored detached conversations was replaced with a general concept of session staleness. A session is considered stale and subject to close if...

  • It does not have any connections to it.
    AND...
  • It has not had an update within the close_delay (Now 15 seconds by default).

Note: I think there may actually have been a bug here before my changes where the stale check was initialized along with the runloop and was not always being hit.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume you mean 15 minutes? 😅 15 seconds seems unbelievably low, just a quick tab away

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. It is 15 minutes. (I actually changed this from 15 seconds to 15 minutes on Monday:

)

sids = {sid for sid, _ in items}
return sids

async def get_running_agent_loops_in_cluster(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
async def get_running_agent_loops_in_cluster(
async def get_running_agent_loops_remotely(

this seems like maybe a better name?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

openhands/server/session/session.py Show resolved Hide resolved
Comment on lines 238 to 242
logger.info(
f'Attached conversations: {len(self._active_conversations)}'
)
logger.info(
f'Detached conversations: {len(self._detached_conversations)}'
)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why remove?

Comment on lines -192 to -195
if sid in self._detached_conversations:
conversation, _ = self._detached_conversations.pop(sid)
self._active_conversations[sid] = (conversation, 1)
logger.info(f'Reusing detached conversation {sid}')
return conversation
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we just leave _attached_conversations until the whole thing closes? That seems reasonable actually...

async def _cleanup_session_later(self, sid: str):
# Once there have been no connections to a session for a reasonable period, we close it
try:
await asyncio.sleep(self.config.sandbox.close_delay)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to remove this config right?

Copy link
Collaborator Author

@tofarr tofarr Jan 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIK, there are OSS users that use this value - they have a use case where they want a session to persist for 8 hours while there is no connection to it. (As opposed to the 15 seconds we have by default)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, we have been using a long N hours close_delay to keep our workspaces running around even after every browser closes.

With this new PR, is there a better way to achieve the same effect?

Copy link
Collaborator Author

@tofarr tofarr Jan 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@diwu-sf - The settings you currently use should be fine - but you may get away with a shorter delay because the new behavior is that a conversation will be stopped if all three of the following are true:

  1. It has not been updated in close_delay seconds.
  2. There are no connections to it.
  3. The agent is not in a running state. (This one is new!)

Now that I think about it, one thing that may affect you is that we have introduced a limit of 3 concurrent conversations per user. (So if you already have 3 running and start another it will kill one of the old ones regardless of the 3 criteria above - this is designed to stop the system crashing due to users trying to start too many concurrent docker containers). If this will affect you, we can introduce a config setting for this too.

@kripper
Copy link

kripper commented Jan 8, 2025

I tested this:

  • I created a conversation/sandbox (worked fine).
  • I restarted the OH server
  • I joined the conversation URL

It failed, because the container couldn't be started.

Some remarks:

Logs:

18:55:55 - openhands:INFO: docker_runtime.py:147 - [runtime e770430539174979bf2296e8c6d3fde5] Waiting for client to become ready at http://localhost:0...
18:55:55 - openhands:ERROR: agent_session.py:200 - Runtime initialization failed: Container openhands-runtime-e770430539174979bf2296e8c6d3fde5 has exited.
Traceback (most recent call last):
  File "/workspaces/OpenHands/openhands/server/session/agent_session.py", line 198, in _create_runtime
    await self.runtime.connect()
  File "/workspaces/OpenHands/openhands/runtime/impl/docker/docker_runtime.py", line 150, in connect
    await call_sync_from_async(self._wait_until_alive)
  File "/workspaces/OpenHands/openhands/utils/async_utils.py", line 18, in call_sync_from_async
    result = await coro
             ^^^^^^^^^^
  File "/usr/local/python/3.12.1/lib/python3.12/concurrent/futures/thread.py", line 58, in run
    result = self.fn(*self.args, **self.kwargs)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/workspaces/OpenHands/openhands/utils/async_utils.py", line 17, in <lambda>
    coro = loop.run_in_executor(None, lambda: fn(*args, **kwargs))
                                              ^^^^^^^^^^^^^^^^^^^
  File "/home/codespace/.cache/pypoetry/virtualenvs/openhands-ai-QLt0qIPP-py3.12/lib/python3.12/site-packages/tenacity/__init__.py", line 336, in wrapped_f
    return copy(f, *args, **kw)
           ^^^^^^^^^^^^^^^^^^^^
  File "/home/codespace/.cache/pypoetry/virtualenvs/openhands-ai-QLt0qIPP-py3.12/lib/python3.12/site-packages/tenacity/__init__.py", line 475, in __call__
    do = self.iter(retry_state=retry_state)
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/codespace/.cache/pypoetry/virtualenvs/openhands-ai-QLt0qIPP-py3.12/lib/python3.12/site-packages/tenacity/__init__.py", line 376, in iter
    result = action(retry_state)
             ^^^^^^^^^^^^^^^^^^^
  File "/home/codespace/.cache/pypoetry/virtualenvs/openhands-ai-QLt0qIPP-py3.12/lib/python3.12/site-packages/tenacity/__init__.py", line 398, in <lambda>
    self._add_action_func(lambda rs: rs.outcome.result())
                                     ^^^^^^^^^^^^^^^^^^^
  File "/usr/local/python/3.12.1/lib/python3.12/concurrent/futures/_base.py", line 449, in result
    return self.__get_result()
           ^^^^^^^^^^^^^^^^^^^
  File "/usr/local/python/3.12.1/lib/python3.12/concurrent/futures/_base.py", line 401, in __get_result
    raise self._exception
  File "/home/codespace/.cache/pypoetry/virtualenvs/openhands-ai-QLt0qIPP-py3.12/lib/python3.12/site-packages/tenacity/__init__.py", line 478, in __call__
    result = fn(*args, **kwargs)
             ^^^^^^^^^^^^^^^^^^^
  File "/workspaces/OpenHands/openhands/runtime/impl/docker/docker_runtime.py", line 328, in _wait_until_alive
    raise AgentRuntimeDisconnectedError(
openhands.core.exceptions.AgentRuntimeDisconnectedError: Container openhands-runtime-e770430539174979bf2296e8c6d3fde5 has exited.
18:55:55 - openhands:WARNING: agent_session.py:295 - State could not be restored: [Errno 2] No such file or directory: '/home/codespace/openhands_file_store/sessions/e770430539174979bf2296e8c6d3fde5/agent_state.pkl'
18:55:55 - openhands:INFO: agent_controller.py:388 - [Agent Controller e770430539174979bf2296e8c6d3fde5] Setting agent(CodeActAgent) state from AgentState.LOADING to AgentState.ERROR
18:55:55 - openhands:INFO: agent_controller.py:388 - [Agent Controller e770430539174979bf2296e8c6d3fde5] Setting agent(CodeActAgent) state from AgentState.ERROR to AgentState.INIT
18:55:55 - openhands:ERROR: manager.py:209 - Error connecting to conversation e770430539174979bf2296e8c6d3fde5: Container openhands-runtime-e770430539174979bf2296e8c6d3fde5 has exited.
INFO:     127.0.0.1:39866 - "GET /api/conversations/e770430539174979bf2296e8c6d3fde5/vscode-url HTTP/1.1" 404 Not Found
18:55:55 - openhands:ERROR: manager.py:209 - Error connecting to conversation e770430539174979bf2296e8c6d3fde5: Container openhands-runtime-e770430539174979bf2296e8c6d3fde5 has exited.
INFO:     127.0.0.1:39854 - "GET /api/conversations/e770430539174979bf2296e8c6d3fde5/list-files HTTP/1.1" 404 Not Found

@tofarr
Copy link
Collaborator Author

tofarr commented Jan 8, 2025

@kripper - I think your issue may actually be unrelated (And sounds like a config issue), so I'm gonna respond on the ticket you opened

@kripper
Copy link

kripper commented Jan 8, 2025

I can reproduce consistently:

It happens only the first time I execute "make run" after rebooting the box.
When I execute "make run" the second time it works fine (and so on).

Maybe something in /tmp?
/tmp is erased after reboot.

Copy link
Collaborator

@rbren rbren left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like there are leaky file descriptors somewhere in here :/

If you run while true; do lsof -p $(pgrep -f "tracker_fd") | wc -l; sleep 1; done in a terminal while using the app, you can see the number go up continuously. (I suggest setting close_delay to something small to see this)

@rbren
Copy link
Collaborator

rbren commented Jan 8, 2025

Should we set close_delay to something much smaller, now that we're checking if the agent is running? Might as well be more aggressive


await wait_all(self._close_session(sid) for sid in sid_to_close)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAICT this bails out if of the _close_session calls errors. Maybe we need to handle errors inside of _close_session?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wait_all calls all the given coroutines, and gathers any exceptions from them, which are then rethrown after all have resolved. (So there should be no need to handle exceptions separately)

I suppose the one place in _close_session that could benefit from having an additional try / except is the redis code where we publish the session_closing event.

Added, as I suppose it can't hurt!

@kripper
Copy link

kripper commented Jan 8, 2025

I confirm this PR works fine and that #6148 (comment) is unrelated.

@tofarr tofarr force-pushed the fix-closing-sessions branch from 0463fe3 to b77c8be Compare January 9, 2025 21:21
@tofarr
Copy link
Collaborator Author

tofarr commented Jan 9, 2025

Should we set close_delay to something much smaller, now that we're checking if the agent is running? Might as well be more aggressive

I've reduced the default here to 15 seconds

@@ -163,9 +163,6 @@ async def close(self) -> None:
filter_hidden=True,
)
)

# unsubscribe from the event stream
self.event_stream.unsubscribe(EventStreamSubscriber.AGENT_CONTROLLER, self.id)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why was this problematic? The controller subscribes in its init, and unsubscribes in its close, it seemed to make sense

Copy link
Collaborator Author

@tofarr tofarr Jan 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This resulted in a double unsubscribe. Closing the stream also unsubscribes all. (And is done before this):

def close(self):
.

So we would get a constant message in the logs Callback not found during unsubscribe

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But stream.close() appears to be called only from agent_session, so when running with an UI. What about running evals or other external scripts via main.py or other cli clients?

@tofarr tofarr requested a review from rbren January 10, 2025 15:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants