feat(04-03): add adaptive timeout to Orchestrator
- Add _is_long_command method to detect long-running commands - Update handle_message to use adaptive timeout based on command type - Long commands (execute-phase, plan-phase, etc.) use timeout=None - Regular commands use DEFAULT_TIMEOUT (60 seconds) - Add TestAdaptiveTimeout test class with 6 test cases Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -83,6 +83,17 @@ class Orchestrator:
|
|||||||
self.user_context_managers[user_id] = UserContextManager(user_id, self._users_root)
|
self.user_context_managers[user_id] = UserContextManager(user_id, self._users_root)
|
||||||
return self.user_context_managers[user_id]
|
return self.user_context_managers[user_id]
|
||||||
|
|
||||||
|
def _is_long_command(self, command: str) -> bool:
|
||||||
|
"""Check if command requires no timeout.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: The command name (e.g., 'execute-phase', 'status').
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if command is in LONG_COMMANDS, False otherwise.
|
||||||
|
"""
|
||||||
|
return command in self.LONG_COMMANDS
|
||||||
|
|
||||||
def _handle_new_project(self, user_id: str, project_name: str) -> Dict[str, Any]:
|
def _handle_new_project(self, user_id: str, project_name: str) -> Dict[str, Any]:
|
||||||
"""Handle /gsd:new-project command.
|
"""Handle /gsd:new-project command.
|
||||||
|
|
||||||
@@ -273,8 +284,11 @@ class Orchestrator:
|
|||||||
if args:
|
if args:
|
||||||
full_command = f"{full_command} {args}"
|
full_command = f"{full_command} {args}"
|
||||||
|
|
||||||
# Execute command
|
# Determine timeout based on command type
|
||||||
result = self.executor.execute(session, full_command)
|
timeout = None if self._is_long_command(command) else self.executor.DEFAULT_TIMEOUT
|
||||||
|
|
||||||
|
# Execute command with adaptive timeout
|
||||||
|
result = self.executor.execute(session, full_command, timeout=timeout)
|
||||||
|
|
||||||
# Truncate output if needed
|
# Truncate output if needed
|
||||||
output = result.output
|
output = result.output
|
||||||
|
|||||||
@@ -848,3 +848,160 @@ class TestSpecialCommands:
|
|||||||
|
|
||||||
# Verify get_session was called with the project from user context
|
# Verify get_session was called with the project from user context
|
||||||
orch.tmux_manager.get_session.assert_called_with("context-project")
|
orch.tmux_manager.get_session.assert_called_with("context-project")
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdaptiveTimeout:
|
||||||
|
"""Tests for adaptive timeout based on command type."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def orchestrator_with_mocks(self):
|
||||||
|
"""Create orchestrator with mocked dependencies for timeout tests."""
|
||||||
|
from gsd_openclaw.orchestrator import Orchestrator
|
||||||
|
|
||||||
|
mock_tmux_manager = MagicMock()
|
||||||
|
mock_formatter = MagicMock()
|
||||||
|
mock_executor = MagicMock()
|
||||||
|
mock_executor.DEFAULT_TIMEOUT = 60.0 # Set real timeout value on mock
|
||||||
|
|
||||||
|
orchestrator = Orchestrator(
|
||||||
|
tmux_manager=mock_tmux_manager,
|
||||||
|
formatter=mock_formatter
|
||||||
|
)
|
||||||
|
orchestrator.executor = mock_executor
|
||||||
|
|
||||||
|
return orchestrator
|
||||||
|
|
||||||
|
def test_orchestrator_has_long_commands_set(self, orchestrator_with_mocks):
|
||||||
|
"""Orchestrator should have LONG_COMMANDS set defined."""
|
||||||
|
orch = orchestrator_with_mocks
|
||||||
|
|
||||||
|
assert hasattr(orch, 'LONG_COMMANDS')
|
||||||
|
assert 'execute-phase' in orch.LONG_COMMANDS
|
||||||
|
assert 'plan-phase' in orch.LONG_COMMANDS
|
||||||
|
assert 'discuss-phase' in orch.LONG_COMMANDS
|
||||||
|
assert 'new-project' in orch.LONG_COMMANDS
|
||||||
|
|
||||||
|
def test_is_long_command_returns_true_for_long_commands(self, orchestrator_with_mocks):
|
||||||
|
"""_is_long_command should return True for commands in LONG_COMMANDS."""
|
||||||
|
orch = orchestrator_with_mocks
|
||||||
|
|
||||||
|
assert orch._is_long_command('execute-phase') is True
|
||||||
|
assert orch._is_long_command('plan-phase') is True
|
||||||
|
assert orch._is_long_command('discuss-phase') is True
|
||||||
|
assert orch._is_long_command('new-project') is True
|
||||||
|
|
||||||
|
def test_is_long_command_returns_false_for_regular_commands(self, orchestrator_with_mocks):
|
||||||
|
"""_is_long_command should return False for commands not in LONG_COMMANDS."""
|
||||||
|
orch = orchestrator_with_mocks
|
||||||
|
|
||||||
|
assert orch._is_long_command('status') is False
|
||||||
|
assert orch._is_long_command('list') is False
|
||||||
|
assert orch._is_long_command('help') is False
|
||||||
|
assert orch._is_long_command('use') is False
|
||||||
|
|
||||||
|
def test_execute_called_with_none_timeout_for_long_commands(self, orchestrator_with_mocks):
|
||||||
|
"""execute() should be called with timeout=None for long commands."""
|
||||||
|
from gsd_openclaw.tmux_session import TmuxSession
|
||||||
|
|
||||||
|
orch = orchestrator_with_mocks
|
||||||
|
|
||||||
|
mock_session = MagicMock(spec=TmuxSession)
|
||||||
|
mock_session.session_id = "test-123"
|
||||||
|
mock_session.tmux_session = MagicMock()
|
||||||
|
mock_session.tmux_session.active_pane = MagicMock()
|
||||||
|
orch.tmux_manager.get_session.return_value = mock_session
|
||||||
|
|
||||||
|
orch.executor.execute.return_value = CommandResult(
|
||||||
|
output="Executing phase...",
|
||||||
|
completed=True,
|
||||||
|
has_error=False,
|
||||||
|
error_type=None
|
||||||
|
)
|
||||||
|
orch.formatter.format_success.return_value = {"card": "success"}
|
||||||
|
|
||||||
|
# Call a long command
|
||||||
|
result = orch.handle_message(
|
||||||
|
user_id="user1",
|
||||||
|
message="/gsd:execute-phase 01-core",
|
||||||
|
channel="test-channel",
|
||||||
|
context={"project": "test-project"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify execute was called with timeout=None
|
||||||
|
call_args = orch.executor.execute.call_args
|
||||||
|
assert call_args[1].get('timeout') is None
|
||||||
|
|
||||||
|
def test_execute_called_with_default_timeout_for_regular_commands(self, orchestrator_with_mocks):
|
||||||
|
"""execute() should be called with DEFAULT_TIMEOUT for regular commands."""
|
||||||
|
from gsd_openclaw.tmux_session import TmuxSession
|
||||||
|
|
||||||
|
orch = orchestrator_with_mocks
|
||||||
|
|
||||||
|
mock_session = MagicMock(spec=TmuxSession)
|
||||||
|
mock_session.session_id = "test-123"
|
||||||
|
mock_session.tmux_session = MagicMock()
|
||||||
|
mock_session.tmux_session.active_pane = MagicMock()
|
||||||
|
orch.tmux_manager.get_session.return_value = mock_session
|
||||||
|
|
||||||
|
orch.executor.execute.return_value = CommandResult(
|
||||||
|
output="Status: OK",
|
||||||
|
completed=True,
|
||||||
|
has_error=False,
|
||||||
|
error_type=None
|
||||||
|
)
|
||||||
|
orch.formatter.format_success.return_value = {"card": "success"}
|
||||||
|
|
||||||
|
# Call a regular command
|
||||||
|
result = orch.handle_message(
|
||||||
|
user_id="user1",
|
||||||
|
message="/gsd:status",
|
||||||
|
channel="test-channel",
|
||||||
|
context={"project": "test-project"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify execute was called with DEFAULT_TIMEOUT (60.0)
|
||||||
|
call_args = orch.executor.execute.call_args
|
||||||
|
assert call_args[1].get('timeout') == 60.0
|
||||||
|
|
||||||
|
def test_handle_message_passes_correct_timeout_based_on_command_type(self, orchestrator_with_mocks):
|
||||||
|
"""handle_message should pass correct timeout based on command type."""
|
||||||
|
from gsd_openclaw.tmux_session import TmuxSession
|
||||||
|
|
||||||
|
orch = orchestrator_with_mocks
|
||||||
|
|
||||||
|
mock_session = MagicMock(spec=TmuxSession)
|
||||||
|
mock_session.session_id = "test-123"
|
||||||
|
mock_session.tmux_session = MagicMock()
|
||||||
|
mock_session.tmux_session.active_pane = MagicMock()
|
||||||
|
orch.tmux_manager.get_session.return_value = mock_session
|
||||||
|
|
||||||
|
orch.executor.execute.return_value = CommandResult(
|
||||||
|
output="Output",
|
||||||
|
completed=True,
|
||||||
|
has_error=False,
|
||||||
|
error_type=None
|
||||||
|
)
|
||||||
|
orch.formatter.format_success.return_value = {"card": "success"}
|
||||||
|
|
||||||
|
# Test plan-phase (long command)
|
||||||
|
orch.handle_message(
|
||||||
|
user_id="user1",
|
||||||
|
message="/gsd:plan-phase feature-x",
|
||||||
|
channel="test-channel",
|
||||||
|
context={"project": "test-project"}
|
||||||
|
)
|
||||||
|
call_args = orch.executor.execute.call_args
|
||||||
|
assert call_args[1].get('timeout') is None
|
||||||
|
|
||||||
|
# Reset mock
|
||||||
|
orch.executor.execute.reset_mock()
|
||||||
|
|
||||||
|
# Test discuss-phase (long command)
|
||||||
|
orch.handle_message(
|
||||||
|
user_id="user1",
|
||||||
|
message="/gsd:discuss-phase about architecture",
|
||||||
|
channel="test-channel",
|
||||||
|
context={"project": "test-project"}
|
||||||
|
)
|
||||||
|
call_args = orch.executor.execute.call_args
|
||||||
|
assert call_args[1].get('timeout') is None
|
||||||
|
|||||||
Reference in New Issue
Block a user