Merge pull request 'fix(agent): prune completed tasks from Agent._tasks to prevent unbounded growth' (#9225) from fix/agent-task-list-memory-leak into master
CI / push-validation (push) Successful in 27s
CI / build (push) Successful in 44s
CI / lint (push) Successful in 45s
CI / helm (push) Successful in 49s
CI / quality (push) Successful in 53s
CI / typecheck (push) Successful in 1m5s
CI / e2e_tests (push) Successful in 1m0s
CI / security (push) Successful in 1m14s
CI / benchmark-regression (push) Failing after 41s
CI / benchmark-publish (push) Failing after 1m48s
CI / unit_tests (push) Successful in 5m19s
CI / integration_tests (push) Successful in 8m45s
CI / docker (push) Successful in 1m28s
CI / lint (pull_request) Successful in 33s
CI / build (pull_request) Successful in 35s
CI / helm (pull_request) Successful in 33s
CI / quality (pull_request) Successful in 52s
CI / push-validation (pull_request) Successful in 33s
CI / typecheck (pull_request) Successful in 1m13s
CI / security (pull_request) Successful in 1m16s
CI / unit_tests (pull_request) Successful in 4m53s
CI / docker (pull_request) Successful in 1m49s
CI / coverage (push) Successful in 11m59s
CI / status-check (push) Successful in 4s
CI / integration_tests (pull_request) Successful in 9m37s
CI / coverage (pull_request) Successful in 9m29s
CI / status-check (pull_request) Successful in 3s

This commit was merged in pull request #9225.
This commit is contained in:
2026-06-03 05:10:51 +00:00
committed by Forgejo
4 changed files with 285 additions and 2 deletions
+1
View File
@@ -73,3 +73,4 @@ Below are some of the specific details of various contributions.
* HAL 9000 has contributed the TDD scenario for plan tree correction visual marking (PR #8671 / issue #8576): added a failing BDD scenario proving that corrected nodes (decisions with is_correction=True) are not visually distinguished in the plan tree output, formalizing Spec Requirement #7 as an executable specification.
* HAL 9000 has contributed the `--clone-into` CLI argument for `container-instance`, the `CloneIntoHandler` module, the `devcontainer-instance` snapshot sandbox strategy, and the `ContainerLifecycleState.DISCOVERED` terminology alignment (PR #8304, issue #7555).
* HAL 9000 has contributed the ACMS Context Tier Hydration documentation (PR #9208 / issue #6175): documented the `context_tier_hydrator` module in the ACMS Architecture section of the specification, covering its public interface, file listing strategy, budget limits, and fragment structure.
* HAL 9000 has contributed the agent task memory leak fix (#9044): replaced `list.remove` with `set.discard` as the done_callback for asyncio tasks in `Agent._tasks`, preventing unbounded memory growth in long-lived agents and ensuring safe concurrent task removal.
@@ -0,0 +1,44 @@
@a2a @agents @memory_leak @agent_task_pruning
Feature: Agent Task Memory Leak Fix
As a CleverAgents developer
I want to ensure that completed tasks are removed from Agent._tasks
So that long-lived agent instances do not accumulate unbounded memory
Background:
Given a test agent for memory leak testing
@agent_task_pruning @memory_leak
Scenario: Completed tasks are removed from _tasks list after processing
When I send a message to the agent
And I wait for the task to complete
Then the _tasks list should be empty
And the message should have been processed
@agent_task_pruning @memory_leak
Scenario: Multiple messages result in bounded _tasks list
When I send 10 messages to the agent
And I wait for all tasks to complete
Then the _tasks list should be empty
And all 10 messages should have been processed
@agent_task_pruning @memory_leak
Scenario: Failed tasks are also removed from _tasks list
When I send a message that will fail to the agent
And I wait for the task to complete
Then the _tasks list should be empty
And the error should have been raised
@agent_task_pruning @memory_leak
Scenario: _tasks list does not grow unboundedly during long-lived operation
When I send 100 messages to the agent in rapid succession
And I wait for all tasks to complete
Then the _tasks list should be empty
And the maximum _tasks list size should not exceed 10
And all 100 messages should have been processed
@agent_task_pruning @memory_leak
Scenario: Task removal is thread-safe with concurrent messages
When I send 50 messages to the agent concurrently
And I wait for all tasks to complete
Then the _tasks list should be empty
And all 50 messages should have been processed
@@ -0,0 +1,235 @@
"""Steps for testing Agent task memory leak fix."""
import asyncio
import threading
from collections.abc import Callable
from typing import Any
from behave import given, then, when
from cleveragents.agents.base import Agent
class TestAgent(Agent):
"""Test agent for memory leak testing."""
def __init__(
self,
name: str,
config: dict[str, Any] | None = None,
fail: bool = False,
):
self.fail = fail
self.processed_messages: list[Any] = []
super().__init__(name, config)
async def process_message(
self, message: Any, context: dict[str, Any] | None = None
) -> Any:
"""Process a message, optionally failing."""
if self.fail:
raise RuntimeError("Intentional test failure")
self.processed_messages.append(message)
return f"processed:{message}"
def get_capabilities(self) -> list[str]:
return ["test"]
class _BackgroundLoop:
"""A persistent asyncio event loop running in a background thread.
This is required because ``asyncio.create_task()`` (used inside
``Agent._setup_processing_pipeline``) requires a *running* event loop.
Behave step functions are synchronous, so we keep a dedicated loop alive
in a daemon thread and submit all coroutines to it via
``asyncio.run_coroutine_threadsafe``.
"""
def __init__(self) -> None:
self._loop = asyncio.new_event_loop()
self._thread = threading.Thread(
target=self._loop.run_forever,
daemon=True,
name="behave-agent-test-loop",
)
self._thread.start()
@property
def loop(self) -> asyncio.AbstractEventLoop:
return self._loop
def run(self, coro: Any) -> None:
"""Submit *coro* to the background loop and block until it completes."""
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
future.result(timeout=10.0)
def stop(self) -> None:
self._loop.call_soon_threadsafe(self._loop.stop)
self._thread.join(timeout=5.0)
# Module-level singleton — shared across all scenarios in a test run.
_BG_LOOP = _BackgroundLoop()
def _send_message(agent: Agent, message: Any) -> None:
"""Send a message to *agent* from within the background event loop.
``Agent.send_message`` triggers ``asyncio.create_task`` synchronously
inside the RxPY ``on_next`` callback. That call requires a *running*
event loop, so we must invoke it from within the background loop thread.
"""
async def _do_send() -> None:
agent.send_message(message)
_BG_LOOP.run(_do_send())
def _wait_for_tasks(
agent: Agent,
*,
timeout: float = 5.0,
interval: float = 0.05,
tracker: Callable[[int], None] | None = None,
) -> None:
"""Drive the background event loop until the agent's task set is empty."""
async def _wait() -> None:
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
while loop.time() < deadline:
active = len(agent._tasks)
if tracker is not None:
tracker(active)
if active == 0:
break
await asyncio.sleep(interval)
_BG_LOOP.run(_wait())
@given("a test agent for memory leak testing")
def step_basic_agent_setup(context: Any) -> None:
"""Set up a test agent for memory leak testing."""
# Instantiate the agent inside the background loop so that
# ``asyncio.create_task`` (called during ``_setup_processing_pipeline``)
# has a running event loop available.
async def _make_agent() -> TestAgent:
return TestAgent("test-agent")
future = asyncio.run_coroutine_threadsafe(_make_agent(), _BG_LOOP.loop)
context.agent = future.result(timeout=5.0)
context.processed_count = 0
context.max_tasks_size = 0
context.error_raised = False
@when("I send a message to the agent")
def step_send_message(context: Any) -> None:
"""Send a single message to the agent."""
_send_message(context.agent, "test-message")
@when("I send {count:d} messages to the agent")
def step_send_multiple_messages(context: Any, count: int) -> None:
"""Send multiple messages to the agent."""
context.expected_count = count
for i in range(count):
_send_message(context.agent, f"message-{i}")
@when("I send {count:d} messages to the agent in rapid succession")
def step_send_multiple_messages_rapid(context: Any, count: int) -> None:
"""Send multiple messages rapidly to the agent."""
context.expected_count = count
for i in range(count):
_send_message(context.agent, f"message-{i}")
@when("I send {count:d} messages to the agent concurrently")
def step_send_multiple_messages_concurrent(context: Any, count: int) -> None:
"""Send multiple messages concurrently to the agent."""
context.expected_count = count
for i in range(count):
_send_message(context.agent, f"concurrent-message-{i}")
@when("I wait for the task to complete")
def step_wait_for_task(context: Any) -> None:
"""Wait for a single task to complete."""
_wait_for_tasks(context.agent)
@when("I wait for all tasks to complete")
def step_wait_for_all_tasks(context: Any) -> None:
"""Wait for all tasks to complete."""
def _track(active: int) -> None:
if active > context.max_tasks_size:
context.max_tasks_size = active
_wait_for_tasks(context.agent, tracker=_track)
@when("I send a message that will fail to the agent")
def step_send_failing_message(context: Any) -> None:
"""Send a message to an agent that will fail."""
async def _make_fail_agent() -> TestAgent:
return TestAgent("fail-agent", fail=True)
future = asyncio.run_coroutine_threadsafe(_make_fail_agent(), _BG_LOOP.loop)
context.fail_agent = future.result(timeout=5.0)
_send_message(context.fail_agent, "will-fail")
@then("the _tasks list should be empty")
def step_verify_tasks_empty(context: Any) -> None:
"""Verify that the _tasks list is empty."""
_wait_for_tasks(context.agent)
assert len(context.agent._tasks) == 0, (
f"Expected _tasks to be empty, but found {len(context.agent._tasks)} tasks"
)
@then("the message should have been processed")
def step_verify_message_processed(context: Any) -> None:
"""Verify that the message was processed."""
assert len(context.agent.processed_messages) > 0, (
"Expected at least one message to be processed"
)
@then("all {count:d} messages should have been processed")
def step_verify_all_messages_processed(context: Any, count: int) -> None:
"""Verify that all messages were processed."""
_wait_for_tasks(context.agent)
assert len(context.agent.processed_messages) == count, (
f"Expected {count} messages to be processed, "
f"but found {len(context.agent.processed_messages)}"
)
@then("the maximum _tasks list size should not exceed {max_size:d}")
def step_verify_max_tasks_size(context: Any, max_size: int) -> None:
"""Verify that the maximum _tasks list size did not exceed a threshold."""
assert context.max_tasks_size <= max_size, (
f"Expected max _tasks size to be <= {max_size}, "
f"but found {context.max_tasks_size}"
)
@then("the error should have been raised")
def step_verify_error_raised(context: Any) -> None:
"""Verify that an error was raised during processing and tasks were cleaned up."""
_wait_for_tasks(context.fail_agent)
assert len(context.fail_agent._tasks) == 0, (
"Expected _tasks to be empty even after error"
)
# Confirm that no messages were processed (the error prevented processing)
assert len(context.fail_agent.processed_messages) == 0, (
"Expected no messages to be processed when agent is configured to fail"
)
+5 -2
View File
@@ -25,13 +25,16 @@ class Agent(ABC):
self.config = config or {}
self.input_stream: Any = Subject()
self.output_stream: Any = Subject()
self._tasks: list[asyncio.Task[Any]] = []
self._tasks: set[asyncio.Task[Any]] = set()
self._setup_processing_pipeline()
def _setup_processing_pipeline(self) -> None:
def _on_next(msg: Any) -> None:
task = asyncio.create_task(self._process_wrapper(msg))
self._tasks.append(task)
self._tasks.add(task)
# Add callback to remove task from set when it completes
# This prevents unbounded growth of _tasks set (memory leak fix)
task.add_done_callback(self._tasks.discard)
self.input_stream.subscribe(
on_next=_on_next,