feat: improve agent deletion handling by ignoring missing gateway agents
This commit is contained in:
@@ -37,6 +37,15 @@ if TYPE_CHECKING:
|
||||
from app.models.boards import Board
|
||||
|
||||
|
||||
def _is_missing_gateway_agent_error(exc: OpenClawGatewayError) -> bool:
|
||||
message = str(exc).lower()
|
||||
if not message:
|
||||
return False
|
||||
if any(marker in message for marker in ("unknown agent", "no such agent", "agent does not exist")):
|
||||
return True
|
||||
return "agent" in message and "not found" in message
|
||||
|
||||
|
||||
async def delete_board(session: AsyncSession, *, board: Board) -> OkResponse:
|
||||
"""Delete a board and all dependent records, cleaning gateway state when configured."""
|
||||
agents = await Agent.objects.filter_by(board_id=board.id).all(session)
|
||||
@@ -46,17 +55,19 @@ async def delete_board(session: AsyncSession, *, board: Board) -> OkResponse:
|
||||
gateway = await require_gateway_for_board(session, board, require_workspace_root=True)
|
||||
# Ensure URL is present (required for gateway cleanup calls).
|
||||
gateway_client_config(gateway)
|
||||
try:
|
||||
for agent in agents:
|
||||
for agent in agents:
|
||||
try:
|
||||
await OpenClawGatewayProvisioner().delete_agent_lifecycle(
|
||||
agent=agent,
|
||||
gateway=gateway,
|
||||
)
|
||||
except OpenClawGatewayError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Gateway cleanup failed: {exc}",
|
||||
) from exc
|
||||
except OpenClawGatewayError as exc:
|
||||
if _is_missing_gateway_agent_error(exc):
|
||||
continue
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Gateway cleanup failed: {exc}",
|
||||
) from exc
|
||||
|
||||
if task_ids:
|
||||
await crud.delete_where(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
@@ -345,3 +346,92 @@ async def test_control_plane_upsert_agent_handles_already_exists(monkeypatch):
|
||||
|
||||
assert calls[0][0] == "agents.create"
|
||||
assert calls[1][0] == "agents.update"
|
||||
|
||||
|
||||
def test_is_missing_agent_error_matches_gateway_agent_not_found() -> None:
|
||||
assert agent_provisioning._is_missing_agent_error(
|
||||
agent_provisioning.OpenClawGatewayError('agent "mc-abc" not found'),
|
||||
)
|
||||
assert not agent_provisioning._is_missing_agent_error(
|
||||
agent_provisioning.OpenClawGatewayError("dial tcp: connection refused"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_agent_lifecycle_ignores_missing_gateway_agent(monkeypatch) -> None:
|
||||
class _ControlPlaneStub:
|
||||
def __init__(self) -> None:
|
||||
self.deleted_sessions: list[str] = []
|
||||
|
||||
async def delete_agent(self, agent_id: str, *, delete_files: bool = True) -> None:
|
||||
_ = (agent_id, delete_files)
|
||||
raise agent_provisioning.OpenClawGatewayError('agent "mc-abc" not found')
|
||||
|
||||
async def delete_agent_session(self, session_key: str) -> None:
|
||||
self.deleted_sessions.append(session_key)
|
||||
|
||||
gateway = _GatewayStub(
|
||||
id=uuid4(),
|
||||
name="Acme",
|
||||
url="ws://gateway.example/ws",
|
||||
token=None,
|
||||
workspace_root="/tmp/openclaw",
|
||||
)
|
||||
agent = SimpleNamespace(
|
||||
id=uuid4(),
|
||||
name="Worker",
|
||||
board_id=uuid4(),
|
||||
openclaw_session_id=None,
|
||||
is_board_lead=False,
|
||||
)
|
||||
control_plane = _ControlPlaneStub()
|
||||
monkeypatch.setattr(agent_provisioning, "_control_plane_for_gateway", lambda _g: control_plane)
|
||||
|
||||
await agent_provisioning.OpenClawGatewayProvisioner().delete_agent_lifecycle(
|
||||
agent=agent, # type: ignore[arg-type]
|
||||
gateway=gateway, # type: ignore[arg-type]
|
||||
delete_files=True,
|
||||
delete_session=True,
|
||||
)
|
||||
|
||||
assert len(control_plane.deleted_sessions) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_agent_lifecycle_raises_on_non_missing_agent_error(monkeypatch) -> None:
|
||||
class _ControlPlaneStub:
|
||||
async def delete_agent(self, agent_id: str, *, delete_files: bool = True) -> None:
|
||||
_ = (agent_id, delete_files)
|
||||
raise agent_provisioning.OpenClawGatewayError("gateway timeout")
|
||||
|
||||
async def delete_agent_session(self, session_key: str) -> None:
|
||||
_ = session_key
|
||||
raise AssertionError("delete_agent_session should not be called")
|
||||
|
||||
gateway = _GatewayStub(
|
||||
id=uuid4(),
|
||||
name="Acme",
|
||||
url="ws://gateway.example/ws",
|
||||
token=None,
|
||||
workspace_root="/tmp/openclaw",
|
||||
)
|
||||
agent = SimpleNamespace(
|
||||
id=uuid4(),
|
||||
name="Worker",
|
||||
board_id=uuid4(),
|
||||
openclaw_session_id=None,
|
||||
is_board_lead=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
agent_provisioning,
|
||||
"_control_plane_for_gateway",
|
||||
lambda _g: _ControlPlaneStub(),
|
||||
)
|
||||
|
||||
with pytest.raises(agent_provisioning.OpenClawGatewayError):
|
||||
await agent_provisioning.OpenClawGatewayProvisioner().delete_agent_lifecycle(
|
||||
agent=agent, # type: ignore[arg-type]
|
||||
gateway=gateway, # type: ignore[arg-type]
|
||||
delete_files=True,
|
||||
delete_session=True,
|
||||
)
|
||||
|
||||
@@ -4,13 +4,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api import boards
|
||||
import app.services.board_lifecycle as board_lifecycle
|
||||
from app.models.boards import Board
|
||||
from app.services.openclaw.gateway_rpc import OpenClawGatewayError
|
||||
|
||||
_NO_EXEC_RESULTS_ERROR = "No more exec_results left for session.exec"
|
||||
|
||||
@@ -85,3 +88,69 @@ async def test_delete_board_cleans_tag_assignments_before_tasks() -> None:
|
||||
deleted_table_names = [statement.table.name for statement in session.executed]
|
||||
assert "tag_assignments" in deleted_table_names
|
||||
assert deleted_table_names.index("tag_assignments") < deleted_table_names.index("tasks")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_board_ignores_missing_gateway_agent(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Deleting a board should continue when gateway reports agent not found."""
|
||||
session: Any = _FakeSession(exec_results=[[]])
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
organization_id=uuid4(),
|
||||
name="Demo Board",
|
||||
slug="demo-board",
|
||||
gateway_id=uuid4(),
|
||||
)
|
||||
agent = SimpleNamespace(id=uuid4(), board_id=board.id)
|
||||
gateway = SimpleNamespace(url="ws://gateway.example/ws", token=None, workspace_root="/tmp")
|
||||
called = {"delete_agent_lifecycle": 0}
|
||||
|
||||
async def _fake_all(_session: object) -> list[object]:
|
||||
return [agent]
|
||||
|
||||
async def _fake_require_gateway_for_board(
|
||||
_session: object,
|
||||
_board: object,
|
||||
*,
|
||||
require_workspace_root: bool,
|
||||
) -> object:
|
||||
_ = require_workspace_root
|
||||
return gateway
|
||||
|
||||
async def _fake_delete_agent_lifecycle(
|
||||
_self: object,
|
||||
*,
|
||||
agent: object,
|
||||
gateway: object,
|
||||
delete_files: bool = True,
|
||||
delete_session: bool = True,
|
||||
) -> str | None:
|
||||
_ = (agent, gateway, delete_files, delete_session)
|
||||
called["delete_agent_lifecycle"] += 1
|
||||
raise OpenClawGatewayError('agent "mc-worker" not found')
|
||||
|
||||
monkeypatch.setattr(
|
||||
board_lifecycle.Agent,
|
||||
"objects",
|
||||
SimpleNamespace(filter_by=lambda **_kwargs: SimpleNamespace(all=_fake_all)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
board_lifecycle,
|
||||
"require_gateway_for_board",
|
||||
_fake_require_gateway_for_board,
|
||||
)
|
||||
monkeypatch.setattr(board_lifecycle, "gateway_client_config", lambda _gateway: None)
|
||||
monkeypatch.setattr(
|
||||
board_lifecycle.OpenClawGatewayProvisioner,
|
||||
"delete_agent_lifecycle",
|
||||
_fake_delete_agent_lifecycle,
|
||||
)
|
||||
|
||||
await boards.delete_board(
|
||||
session=session,
|
||||
board=board,
|
||||
)
|
||||
|
||||
assert called["delete_agent_lifecycle"] == 1
|
||||
assert board in session.deleted
|
||||
assert session.committed == 1
|
||||
|
||||
Reference in New Issue
Block a user