Skip to content

fix: strip stop hooks from SDK to prevent conversation stall#651

Draft
xingyaoww wants to merge 2 commits intomainfrom
openhands/fix-stop-hooks-stall-649
Draft

fix: strip stop hooks from SDK to prevent conversation stall#651
xingyaoww wants to merge 2 commits intomainfrom
openhands/fix-stop-hooks-stall-649

Conversation

@xingyaoww
Copy link
Copy Markdown
Contributor

@xingyaoww xingyaoww commented Apr 6, 2026

What Changed

Fixes #649 — Stop hooks cause conversation to stall in 'Working' state after FinishAction.

Problem

When a user configures a Stop hook in .openhands/hooks.json, the conversation stalls in the "Working" state after the agent completes (FinishAction). Two issues in how the SDK's run loop handles stop hooks cause this:

  1. Exit code 2 treated as "block" → infinite loop: Python exits with code 2 when it can't find a script file (python /nonexistent.py). The SDK's hook executor interprets exit code 2 as "deny the stop", causing the run loop to set status back to RUNNING, call agent.step() again (which triggers another FinishAction), and repeat indefinitely — each iteration involving an LLM call.

  2. Stop hooks run while holding the state lock → pause blocked: The SDK's run loop executes stop hooks inside with self._state: (holding the FIFO lock), so pause() cannot acquire the lock while hooks run. Combined with the infinite loop, the UI becomes permanently unresponsive.

Solution

Strip stop hooks from the HookConfig before passing to the SDK's Conversation, and handle them at the CLI level after conversation.run() returns. This:

  • Keeps pause/ESC responsive — stop hooks no longer hold the SDK's state lock
  • Better error handling — missing scripts, timeouts, and crashes are treated as "allow stop" (not "block stop")
  • Preserves legitimate block behavior — deliberate exit code 2 (without file-not-found stderr) and JSON {"decision": "deny"} responses still block the stop

Changes

File Change
openhands_cli/stop_hooks.py New — CLI-level stop hook execution with robust error handling
openhands_cli/setup.py Added strip_stop_hooks() helper; setup_conversation() now returns (conversation, stop_matchers)
openhands_cli/tui/core/conversation_runner.py Executes stop hooks after conversation.run() returns with FINISHED status
openhands_cli/acp_impl/agent/local_agent.py Strips stop hooks before passing to SDK
openhands_cli/acp_impl/agent/remote_agent.py Strips stop hooks before passing to SDK
tests/test_stop_hooks.py New — 16 tests covering strip logic, hook execution, error handling
tests/tui/test_headless_mode.py Updated mocks for new setup_conversation() return type

Verification

make lint    # ✅ All checks passed
make test    # ✅ 1293 passed

This PR was created by an AI assistant (OpenHands) on behalf of the user.

@xingyaoww can click here to continue refining the PR


🚀 Try this PR

uvx --python 3.12 git+https://github.com/OpenHands/OpenHands-CLI.git@openhands/fix-stop-hooks-stall-649

Stop hooks executed inside the SDK's run-loop state lock cause two issues:
1. A missing hook script exits with code 2 (Python's file-not-found),
   which the SDK interprets as 'block the stop' — creating an infinite
   LLM-calling loop.
2. The SDK holds its FIFO lock while running stop hooks, which blocks
   pause/ESC from acquiring the lock.

Fix: Strip stop hooks from HookConfig before passing to the SDK's
Conversation. Execute them at the CLI level after conversation.run()
returns, with better error handling:
- Missing scripts (exit code 2 with file-not-found stderr) → allow stop
- Timeouts and crashes → allow stop
- Only explicit blocks (deliberate exit code 2 or JSON deny) → deny stop

Co-authored-by: openhands <openhands@all-hands.dev>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 6, 2026

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands_cli
   setup.py55983%71–74, 79–82, 90
   stop_hooks.py37294%113, 117
openhands_cli/acp_impl/agent
   local_agent.py85495%80, 144, 154, 168
   remote_agent.py1486456%85–86, 92–93, 97–98, 102, 104–108, 111–113, 123–124, 127–129, 133–135, 145–146, 161–163, 167, 169–170, 176, 180–181, 183, 192–193, 198–200, 211–212, 217, 219–220, 224, 231–232, 234–235, 238–240, 244, 246, 254, 256–257, 288, 350–352, 358–359
openhands_cli/tui/core
   conversation_runner.py1134560%107–109, 116, 118, 129, 135, 161, 168–169, 172–174, 184, 193, 198–200, 204–205, 213, 215–218, 226, 229–230, 234, 241, 245–246, 251, 253, 259–260, 265, 267, 269, 276, 279, 284, 286, 298–299
TOTAL669691586% 

- Use stored conversation_id and get_work_dir() instead of reaching
  through conversation.state (ConversationStateProtocol doesn't expose
  conversation_id, BaseConversation doesn't expose workspace directly)
- Fix E303 too many blank lines in test file

Co-authored-by: openhands <openhands@all-hands.dev>
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.

Stop hooks cause conversation to stall in 'Working' state after FinishAction

2 participants