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.
This commit is contained in:
Dominik Jain authored and Dr. Dominik Jain committed 2026-07-24 13:23:06 +02:00
1 parent d86fd6c0c8
commit 140ffaf3ff
3 files changed
+35 -15

No files matched your search

+2
View File
@@ -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)
+24 -12
View File
@@ -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:
"""
+9 -3
View File
@@ -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)