From 140ffaf3ffbf66af67c070e6d28e145db0b77c16 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 24 Jul 2026 02:12:04 +0200 Subject: [PATCH] Fix: Tool calls exceeding the timeout blocked the task executor indefinitely; tasks are now cancelled on timeout and the executor recovers Previously, Tool.apply_ex issued its task without a timeout, so a hung tool would block the TaskExecutor's dispatcher thread forever: the caller received a TimeoutError after tool_timeout, but no further tasks were ever processed. Changes: - Tool.apply_ex now passes the configured tool timeout to issue_task, bounding the dispatcher's wait as well as the caller's. - TaskExecutor.Task.result gained cancel_on_timeout (default True): on timeout, the task is cancelled, preventing execution of tasks still waiting in the queue and discarding the result of tasks already running. - Task.wait_until_done no longer takes a timeout parameter; it uses the task's own timeout and returns whether the task completed. The dispatcher logs a warning and continues with the next task if the timeout was reached. - TaskExecutor.execute_task now propagates its timeout to result() instead of waiting indefinitely, thus also benefiting from cancel-on-timeout. - Corrected the docstring of Task.result to match the actual semantics. --- CHANGELOG.md | 2 ++ src/serena/task_executor.py | 36 ++++++++++++++++++++++------------ src/serena/tools/tools_base.py | 12 +++++++++--- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10688d6c..ce43a05e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. * General: + - Fix: a tool call exceeding the timeout blocked the task executor indefinitely; the executor now + recovers without user-induced cancellation - Add Grok Build support (context `grok`, setup CLI, hooks) - The `languages` key in project configurations was changed to `language_servers` to better reflect the actual semantics (configurations are automatically migrated) diff --git a/src/serena/task_executor.py b/src/serena/task_executor.py index 6e560145..d6aefd0d 100644 --- a/src/serena/task_executor.py +++ b/src/serena/task_executor.py @@ -77,18 +77,26 @@ class TaskExecutor: """ return self.future.done() - def result(self, timeout: float | None = None) -> T: + def result(self, timeout: float | None = None, cancel_on_timeout: bool = True) -> T: """ Blocks until the task is done or the timeout is reached, and returns the result. If an exception occurred during task execution, it is raised here. - If the timeout is reached, a TimeoutError is raised (but the task is not cancelled). + If the timeout is reached, a TimeoutError is raised. If the task is cancelled, a CancelledError is raised. - :param timeout: the maximum time to wait in seconds; if None, use the task's own timeout - (which may be None to wait indefinitely) + :param timeout: the maximum time to wait in seconds; if None, wait indefinitely + :param cancel_on_timeout: whether to cancel the task if the timeout is reached. + If the task has not yet started, cancellation prevents its execution entirely; + if it is already running, the underlying thread continues to run, but its result + is discarded and any waiter will receive a CancelledError. :return: the result of the task """ - return self.future.result(timeout=timeout) + try: + return self.future.result(timeout=timeout) + except concurrent.futures.TimeoutError: + if cancel_on_timeout: + self.cancel() + raise def cancel(self) -> None: """ @@ -98,18 +106,20 @@ class TaskExecutor: """ self.future.cancel() - def wait_until_done(self, timeout: float | None = None) -> None: + def wait_until_done(self) -> bool: """ - Waits until the task is done or the timeout is reached. + Waits until the task is done or its timeout is reached. The task is done if it either completed successfully, failed with an exception, or was cancelled. - :param timeout: the maximum time to wait in seconds; if None, use the task's own timeout - (which may be None to wait indefinitely) + :return: True if the task is done (successfully, with failure, or via cancellation), False if the timeout was reached """ try: - self.future.result(timeout=timeout) + self.future.result(timeout=self.timeout) + except concurrent.futures.TimeoutError: + return False except: pass + return True def _process_task_queue(self) -> None: while True: @@ -130,7 +140,9 @@ class TaskExecutor: task.start() # wait for task completion - task.wait_until_done(timeout=task.timeout) + is_done = task.wait_until_done() + if not is_done: + log.warning("Task %s did not complete within the timeout of %s seconds; continuing ...", task.name, task.timeout) with self._task_executor_lock: self._task_executor_current_task = None if task.logged: @@ -219,7 +231,7 @@ class TaskExecutor: :return: the result of the task execution """ task_obj = self.issue_task(task, name=name, logged=logged, timeout=timeout) - return task_obj.result() + return task_obj.result(timeout=timeout) def get_last_executed_task(self) -> TaskInfo | None: """ diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index 2d1ead66..665f8a2e 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -419,13 +419,19 @@ class Tool(Component): return result # execute the tool in the agent's task executor, with timeout + # (task timeout bounds task execution in the dispatcher once it runs, result timeout limits the time we wait) tool_call_error: ToolCallError + timeout = self.agent.serena_config.tool_timeout try: - task_exec = self.agent.issue_task(task, name=self.__class__.__name__) - return task_exec.result(timeout=self.agent.serena_config.tool_timeout) + task_exec = self.agent.issue_task(task, name=self.__class__.__name__, timeout=timeout) + return task_exec.result(timeout=timeout) except ToolCallError as e: tool_call_error = e - except Exception as e: # typically TimeoutError (other exceptions caught and forwarded as ToolCallError in the task) + except TimeoutError: + msg = f"Tool execution timed out after {timeout} seconds. " + log.error(msg) + tool_call_error = ToolCallError(msg) + except Exception as e: # unexpected errors (exceptions in the task itself are caught and forwarded as ToolCallError) msg = f"{e.__class__.__name__}: {e}" log.error(msg) tool_call_error = ToolCallError(msg)