mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
191 lines
6.2 KiB
Python
191 lines
6.2 KiB
Python
"""Cancel tears down the per-task worktree (F123, Phase C).
|
|||
|
|
|
||
|
|
``_delete_task_branch_best_effort`` already deletes the task's REMOTE branch on
|
||
|
|
cancel. Without also removing the local per-task worktree at
|
||
|
|
``{clone_root}/.worktrees/{task-short}/``, every cancelled task leaks a full
|
||
|
|
working tree on the assignee's clone — disk blowup (plan risk #6). The reaper
|
||
|
|
(stale-claim → pending) must NOT remove it (a re-claim reuses it); only the
|
||
|
|
terminal cancel path does. The assignee is joined-eager-loaded on the task and
|
||
|
|
``_abandon_work_session_for_task`` does not clear ``assigned_to``, so the
|
||
|
|
clone root is resolvable at the cancel hook.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from pathlib import Path
|
||
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
|
from uuid import uuid4
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from roboco.models.base import Team
|
||
|
|
from roboco.services.task import TaskService
|
||
|
|
|
||
|
|
|
||
|
|
def _service() -> TaskService:
|
||
|
|
svc = TaskService.__new__(TaskService)
|
||
|
|
svc.log = MagicMock()
|
||
|
|
svc.session = MagicMock()
|
||
|
|
return svc
|
||
|
|
|
||
|
|
|
||
|
|
def _session(slug: str | None = "roboco-api") -> MagicMock:
|
||
|
|
# Build on a local MagicMock (sub-attr assignment is allowed there) then
|
||
|
|
# callers assign the whole thing to ``svc.session`` — assigning
|
||
|
|
# ``svc.session.execute`` directly trips mypy's method-assign on the typed
|
||
|
|
# AsyncSession attribute.
|
||
|
|
session = MagicMock()
|
||
|
|
session.execute = AsyncMock(return_value=_project_result(slug))
|
||
|
|
return session
|
||
|
|
|
||
|
|
|
||
|
|
def _task(*, branch: str | None, assignee: MagicMock | None) -> MagicMock:
|
||
|
|
task_id = uuid4()
|
||
|
|
return MagicMock(
|
||
|
|
id=task_id,
|
||
|
|
project_id=uuid4(),
|
||
|
|
branch_name=branch,
|
||
|
|
assignee=assignee,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _project_result(slug: str | None) -> MagicMock:
|
||
|
|
result = MagicMock()
|
||
|
|
result.scalar_one_or_none.return_value = slug
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_cancel_removes_worktree_for_assignee() -> None:
|
||
|
|
svc = _service()
|
||
|
|
task = _task(
|
||
|
|
branch="feature/backend/abc12345",
|
||
|
|
assignee=MagicMock(slug="be-dev-1", team=Team.BACKEND),
|
||
|
|
)
|
||
|
|
short = str(task.id)[:8]
|
||
|
|
clone = Path("/data/workspaces/roboco-api/backend/be-dev-1")
|
||
|
|
|
||
|
|
svc.session = _session("roboco-api")
|
||
|
|
|
||
|
|
git_service = MagicMock()
|
||
|
|
git_service.delete_task_branch = AsyncMock()
|
||
|
|
ws_svc = MagicMock()
|
||
|
|
ws_svc.get_clone_root_path = MagicMock(return_value=clone)
|
||
|
|
ws_svc.remove_worktree = AsyncMock()
|
||
|
|
|
||
|
|
with (
|
||
|
|
patch(
|
||
|
|
"roboco.services.git.get_git_service",
|
||
|
|
MagicMock(return_value=git_service),
|
||
|
|
),
|
||
|
|
patch(
|
||
|
|
"roboco.services.workspace.get_workspace_service",
|
||
|
|
MagicMock(return_value=ws_svc),
|
||
|
|
),
|
||
|
|
):
|
||
|
|
await svc._delete_task_branch_best_effort(task)
|
||
|
|
|
||
|
|
git_service.delete_task_branch.assert_awaited_once_with(
|
||
|
|
"roboco-api", "feature/backend/abc12345"
|
||
|
|
)
|
||
|
|
ws_svc.get_clone_root_path.assert_called_once_with(
|
||
|
|
"roboco-api", Team.BACKEND, "be-dev-1"
|
||
|
|
)
|
||
|
|
ws_svc.remove_worktree.assert_awaited_once()
|
||
|
|
args = ws_svc.remove_worktree.await_args.args
|
||
|
|
assert args[0] == clone, "remove must target the clone root"
|
||
|
|
assert args[1] == clone / ".worktrees" / short, (
|
||
|
|
f"remove must target the task worktree {clone}/.worktrees/{short}; "
|
||
|
|
f"got {args[1]}"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_cancel_skips_worktree_when_no_assignee() -> None:
|
||
|
|
# Unassigned at cancel time (e.g. pooled task cancelled before any claim) —
|
||
|
|
# no clone root to resolve, so the worktree step is skipped. The remote
|
||
|
|
# branch is still deleted.
|
||
|
|
svc = _service()
|
||
|
|
task = _task(branch="feature/backend/abc12345", assignee=None)
|
||
|
|
svc.session = _session("roboco-api")
|
||
|
|
|
||
|
|
git_service = MagicMock()
|
||
|
|
git_service.delete_task_branch = AsyncMock()
|
||
|
|
ws_svc = MagicMock()
|
||
|
|
ws_svc.remove_worktree = AsyncMock()
|
||
|
|
|
||
|
|
with (
|
||
|
|
patch(
|
||
|
|
"roboco.services.git.get_git_service",
|
||
|
|
MagicMock(return_value=git_service),
|
||
|
|
),
|
||
|
|
patch(
|
||
|
|
"roboco.services.workspace.get_workspace_service",
|
||
|
|
MagicMock(return_value=ws_svc),
|
||
|
|
),
|
||
|
|
):
|
||
|
|
await svc._delete_task_branch_best_effort(task)
|
||
|
|
|
||
|
|
git_service.delete_task_branch.assert_awaited_once()
|
||
|
|
ws_svc.remove_worktree.assert_not_awaited()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_cancel_skips_worktree_when_no_branch() -> None:
|
||
|
|
# Branchless coordination root — no worktree was ever created.
|
||
|
|
svc = _service()
|
||
|
|
task = _task(branch=None, assignee=MagicMock(slug="be-dev-1", team=Team.BACKEND))
|
||
|
|
svc.session = _session(None)
|
||
|
|
|
||
|
|
git_service = MagicMock()
|
||
|
|
git_service.delete_task_branch = AsyncMock()
|
||
|
|
ws_svc = MagicMock()
|
||
|
|
ws_svc.remove_worktree = AsyncMock()
|
||
|
|
|
||
|
|
with (
|
||
|
|
patch(
|
||
|
|
"roboco.services.git.get_git_service",
|
||
|
|
MagicMock(return_value=git_service),
|
||
|
|
),
|
||
|
|
patch(
|
||
|
|
"roboco.services.workspace.get_workspace_service",
|
||
|
|
MagicMock(return_value=ws_svc),
|
||
|
|
),
|
||
|
|
):
|
||
|
|
await svc._delete_task_branch_best_effort(task)
|
||
|
|
|
||
|
|
git_service.delete_task_branch.assert_not_awaited()
|
||
|
|
ws_svc.remove_worktree.assert_not_awaited()
|
||
|
|
svc.session.execute.assert_not_awaited()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_worktree_cleanup_failure_does_not_raise() -> None:
|
||
|
|
# Best-effort: a remove_worktree failure (missing clone, git error) must not
|
||
|
|
# abort the cancel — the remote branch was already deleted.
|
||
|
|
svc = _service()
|
||
|
|
task = _task(
|
||
|
|
branch="feature/backend/abc12345",
|
||
|
|
assignee=MagicMock(slug="be-dev-1", team=Team.BACKEND),
|
||
|
|
)
|
||
|
|
svc.session = _session("roboco-api")
|
||
|
|
|
||
|
|
git_service = MagicMock()
|
||
|
|
git_service.delete_task_branch = AsyncMock()
|
||
|
|
ws_svc = MagicMock()
|
||
|
|
ws_svc.get_clone_root_path = MagicMock(
|
||
|
|
return_value=Path("/data/workspaces/roboco-api/backend/be-dev-1")
|
||
|
|
)
|
||
|
|
ws_svc.remove_worktree = AsyncMock(side_effect=RuntimeError("boom"))
|
||
|
|
|
||
|
|
with (
|
||
|
|
patch(
|
||
|
|
"roboco.services.git.get_git_service",
|
||
|
|
MagicMock(return_value=git_service),
|
||
|
|
),
|
||
|
|
patch(
|
||
|
|
"roboco.services.workspace.get_workspace_service",
|
||
|
|
MagicMock(return_value=ws_svc),
|
||
|
|
),
|
||
|
|
):
|
||
|
|
await svc._delete_task_branch_best_effort(task) # must not raise
|