2026-06-30 08:08:35 +02:00
|
|
|
"""Spawn-time worktree ensure (F123, Phase B) + clone self-heal.
|
2026-06-29 11:32:34 +02:00
|
|
|
|
2026-06-30 08:08:35 +02:00
|
|
|
A respawn re-points the container ``-w`` at the task's worktree. If the worktree
|
|
|
|
|
was pruned while the agent was down, ``docker run -w <missing>`` starts the
|
|
|
|
|
agent in a non-existent directory and its first command fails. If the whole
|
|
|
|
|
clone root vanished (disk loss, a redeploy that wiped ``/data/workspaces``,
|
|
|
|
|
manual cleanup) the resume path ``git -C <missing>`` fatal-looped every tick:
|
|
|
|
|
the reaper-style claim release preserves ownership + branch_name, so the
|
|
|
|
|
re-dispatch is a RESUME (not a fresh claim), ``create_branch`` never re-runs
|
|
|
|
|
to re-clone, and the same missing clone fails again. ``_ensure_worktree_before
|
|
|
|
|
_spawn`` is the chokepoint that now self-heals both; it is a no-op for
|
2026-06-29 11:32:34 +02:00
|
|
|
branchless / no-task spawns (no worktree).
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Any
|
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from roboco.models.runtime import SpawnGitContext
|
|
|
|
|
from roboco.runtime.orchestrator import AgentOrchestrator, AgentReadinessError
|
|
|
|
|
from roboco.services.workspace import WorkspaceError
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_orchestrator() -> AgentOrchestrator:
|
|
|
|
|
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
|
|
|
|
orch._bg_tasks = set()
|
|
|
|
|
orch._running = True
|
|
|
|
|
return orch
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@asynccontextmanager
|
|
|
|
|
async def _fake_db_ctx(db: Any) -> Any:
|
|
|
|
|
yield db
|
|
|
|
|
|
|
|
|
|
|
2026-06-30 08:08:35 +02:00
|
|
|
def _ctx() -> SpawnGitContext:
|
|
|
|
|
return SpawnGitContext(
|
2026-06-29 11:32:34 +02:00
|
|
|
project_slug="roboco-api",
|
|
|
|
|
branch_name="feature/backend/abc12345",
|
|
|
|
|
task_short_id="a3c40fe7",
|
|
|
|
|
)
|
2026-06-30 08:08:35 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_ensures_worktree_when_task_short_id_set() -> None:
|
|
|
|
|
orch = _make_orchestrator()
|
2026-06-29 11:32:34 +02:00
|
|
|
db = MagicMock()
|
|
|
|
|
ws = MagicMock()
|
2026-06-30 08:08:35 +02:00
|
|
|
ws.ensure_worktree_self_heal = AsyncMock()
|
|
|
|
|
ws.ensure_workspace = AsyncMock()
|
2026-06-29 11:32:34 +02:00
|
|
|
|
|
|
|
|
with (
|
|
|
|
|
patch("roboco.db.base.get_db_context", return_value=_fake_db_ctx(db)),
|
|
|
|
|
patch("roboco.services.workspace.WorkspaceService", return_value=ws),
|
2026-06-30 08:08:35 +02:00
|
|
|
patch(
|
|
|
|
|
"roboco.services.workspace.WorkspaceService._is_workspace_healthy",
|
|
|
|
|
return_value=True,
|
|
|
|
|
),
|
2026-06-29 11:32:34 +02:00
|
|
|
):
|
|
|
|
|
await orch._ensure_worktree_before_spawn(
|
2026-06-30 08:08:35 +02:00
|
|
|
_ctx(), "roboco-api", "backend", "be-dev-1", "task-1"
|
2026-06-29 11:32:34 +02:00
|
|
|
)
|
|
|
|
|
|
2026-06-30 08:08:35 +02:00
|
|
|
# Healthy clone -> no re-clone, just the worktree self-heal.
|
|
|
|
|
ws.ensure_workspace.assert_not_awaited()
|
|
|
|
|
ws.ensure_worktree_self_heal.assert_awaited_once()
|
2026-07-24 20:29:20 +02:00
|
|
|
call = ws.ensure_worktree_self_heal.call_args
|
|
|
|
|
assert call.args[0] == Path("/data/workspaces/roboco-api/backend/be-dev-1")
|
|
|
|
|
assert call.args[1] == Path(
|
2026-06-29 11:32:34 +02:00
|
|
|
"/data/workspaces/roboco-api/backend/be-dev-1/.worktrees/a3c40fe7"
|
|
|
|
|
)
|
2026-07-24 20:29:20 +02:00
|
|
|
assert call.args[2] == "feature/backend/abc12345"
|
|
|
|
|
assert call.args[3] == "roboco-api"
|
|
|
|
|
# be-dev-1 is a developer — an author-capable role.
|
|
|
|
|
assert call.kwargs["can_author"] is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_reader_role_classified_as_non_author_for_refresh() -> None:
|
|
|
|
|
# A QA/PR-reviewer/PM respawn onto the SAME task-branch worktree must be
|
|
|
|
|
# classified as a pure reader so ensure_worktree_self_heal knows a stale
|
|
|
|
|
# checkout there is always safe to reset to origin.
|
|
|
|
|
orch = _make_orchestrator()
|
|
|
|
|
ws = MagicMock()
|
|
|
|
|
ws.ensure_worktree_self_heal = AsyncMock()
|
|
|
|
|
ws.ensure_workspace = AsyncMock()
|
|
|
|
|
|
|
|
|
|
with (
|
|
|
|
|
patch("roboco.db.base.get_db_context", return_value=_fake_db_ctx(MagicMock())),
|
|
|
|
|
patch("roboco.services.workspace.WorkspaceService", return_value=ws),
|
|
|
|
|
patch(
|
|
|
|
|
"roboco.services.workspace.WorkspaceService._is_workspace_healthy",
|
|
|
|
|
return_value=True,
|
|
|
|
|
),
|
|
|
|
|
):
|
|
|
|
|
await orch._ensure_worktree_before_spawn(
|
|
|
|
|
_ctx(), "roboco-api", "backend", "be-qa", "task-1"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert ws.ensure_worktree_self_heal.call_args.kwargs["can_author"] is False
|
2026-06-30 08:08:35 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_heals_missing_clone_before_self_heal() -> None:
|
|
|
|
|
# The bug: a vanished clone_root fatal-looped because the resume path ran
|
|
|
|
|
# `git -C <missing>` and the reaper-style release preserved ownership, so
|
|
|
|
|
# the next dispatch never re-cloned. Now an unhealthy clone is re-cloned
|
|
|
|
|
# (ensure_workspace) BEFORE the worktree self-heal runs.
|
|
|
|
|
orch = _make_orchestrator()
|
|
|
|
|
ws = MagicMock()
|
|
|
|
|
ws.ensure_worktree_self_heal = AsyncMock()
|
|
|
|
|
ws.ensure_workspace = AsyncMock()
|
|
|
|
|
|
|
|
|
|
with (
|
|
|
|
|
patch("roboco.db.base.get_db_context", return_value=_fake_db_ctx(MagicMock())),
|
|
|
|
|
patch("roboco.services.workspace.WorkspaceService", return_value=ws),
|
|
|
|
|
patch(
|
|
|
|
|
"roboco.services.workspace.WorkspaceService._is_workspace_healthy",
|
|
|
|
|
return_value=False,
|
|
|
|
|
),
|
|
|
|
|
):
|
|
|
|
|
await orch._ensure_worktree_before_spawn(
|
|
|
|
|
_ctx(), "roboco-api", "backend", "be-dev-1", "task-1"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
ws.ensure_workspace.assert_awaited_once_with("roboco-api", "be-dev-1")
|
|
|
|
|
ws.ensure_worktree_self_heal.assert_awaited_once()
|
2026-06-29 11:32:34 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_noop_when_no_task_short_id() -> None:
|
|
|
|
|
# A branchless / no-task spawn has no worktree — must not touch the FS.
|
|
|
|
|
orch = _make_orchestrator()
|
|
|
|
|
ctx = SpawnGitContext(project_slug="roboco-api", branch_name=None)
|
|
|
|
|
|
|
|
|
|
db = MagicMock()
|
|
|
|
|
ws = MagicMock()
|
2026-06-30 08:08:35 +02:00
|
|
|
ws.ensure_worktree_self_heal = AsyncMock()
|
|
|
|
|
ws.ensure_workspace = AsyncMock()
|
2026-06-29 11:32:34 +02:00
|
|
|
with (
|
|
|
|
|
patch("roboco.db.base.get_db_context", return_value=_fake_db_ctx(db)),
|
|
|
|
|
patch("roboco.services.workspace.WorkspaceService", return_value=ws),
|
|
|
|
|
):
|
|
|
|
|
await orch._ensure_worktree_before_spawn(
|
|
|
|
|
ctx, "roboco-api", "backend", "be-dev-1", "task-1"
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-30 08:08:35 +02:00
|
|
|
ws.ensure_worktree_self_heal.assert_not_called()
|
|
|
|
|
ws.ensure_workspace.assert_not_called()
|
2026-06-29 11:32:34 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_fatal_failure_releases_claim_and_aborts() -> None:
|
2026-06-30 08:08:35 +02:00
|
|
|
# A FATAL git-state failure (WorkspaceError — the clone won't re-clone, the
|
|
|
|
|
# token is missing, or the branch ref is unrecoverable) must NOT launch the
|
|
|
|
|
# container at a missing -w path. It releases the claim (so the next
|
|
|
|
|
# dispatch retries the rebuild) and aborts with AgentReadinessError.
|
2026-06-29 11:32:34 +02:00
|
|
|
orch = _make_orchestrator()
|
|
|
|
|
release = AsyncMock()
|
|
|
|
|
object.__setattr__(orch, "_release_claim_to_pending", release)
|
|
|
|
|
task_id = str(uuid4())
|
|
|
|
|
ws = MagicMock()
|
2026-06-30 08:08:35 +02:00
|
|
|
ws.ensure_worktree_self_heal = MagicMock(
|
2026-06-29 11:32:34 +02:00
|
|
|
side_effect=WorkspaceError("git worktree re-add failed")
|
|
|
|
|
)
|
2026-06-30 08:08:35 +02:00
|
|
|
ws.ensure_workspace = AsyncMock()
|
2026-06-29 11:32:34 +02:00
|
|
|
|
|
|
|
|
with (
|
|
|
|
|
patch("roboco.db.base.get_db_context", return_value=_fake_db_ctx(MagicMock())),
|
|
|
|
|
patch("roboco.services.workspace.WorkspaceService", return_value=ws),
|
2026-06-30 08:08:35 +02:00
|
|
|
patch(
|
|
|
|
|
"roboco.services.workspace.WorkspaceService._is_workspace_healthy",
|
|
|
|
|
return_value=True,
|
|
|
|
|
),
|
2026-06-29 11:32:34 +02:00
|
|
|
pytest.raises(AgentReadinessError, match="worktree ensure failed"),
|
|
|
|
|
):
|
|
|
|
|
await orch._ensure_worktree_before_spawn(
|
2026-06-30 08:08:35 +02:00
|
|
|
_ctx(), "roboco-api", "backend", "be-dev-1", task_id
|
2026-06-29 11:32:34 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
release.assert_awaited_once_with(task_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_transient_failure_aborts_without_release() -> None:
|
|
|
|
|
# A TRANSIENT failure (DB hiccup / other) must still abort (don't launch at
|
|
|
|
|
# a possibly-missing path) but must NOT release the claim — a fresh claim
|
|
|
|
|
# would not help and re-cloning is destructive. Next tick retries the same
|
|
|
|
|
# claim.
|
|
|
|
|
orch = _make_orchestrator()
|
|
|
|
|
release = AsyncMock()
|
|
|
|
|
object.__setattr__(orch, "_release_claim_to_pending", release)
|
|
|
|
|
task_id = str(uuid4())
|
|
|
|
|
ws = MagicMock()
|
2026-06-30 08:08:35 +02:00
|
|
|
ws.ensure_worktree_self_heal = MagicMock(side_effect=RuntimeError("db down"))
|
|
|
|
|
ws.ensure_workspace = AsyncMock()
|
2026-06-29 11:32:34 +02:00
|
|
|
|
|
|
|
|
with (
|
|
|
|
|
patch("roboco.db.base.get_db_context", return_value=_fake_db_ctx(MagicMock())),
|
|
|
|
|
patch("roboco.services.workspace.WorkspaceService", return_value=ws),
|
2026-06-30 08:08:35 +02:00
|
|
|
patch(
|
|
|
|
|
"roboco.services.workspace.WorkspaceService._is_workspace_healthy",
|
|
|
|
|
return_value=True,
|
|
|
|
|
),
|
2026-06-29 11:32:34 +02:00
|
|
|
pytest.raises(AgentReadinessError, match="transient"),
|
|
|
|
|
):
|
|
|
|
|
await orch._ensure_worktree_before_spawn(
|
2026-06-30 08:08:35 +02:00
|
|
|
_ctx(), "roboco-api", "backend", "be-dev-1", task_id
|
2026-06-29 11:32:34 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
release.assert_not_awaited()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_recoverable_ensure_no_raise_no_release() -> None:
|
2026-06-30 08:08:35 +02:00
|
|
|
# The happy / recoverable path (worktree present, or pruned-but-re-added /
|
|
|
|
|
# clone-re-cloned) must stay a silent no-op — the F123 Phase B happy path.
|
|
|
|
|
# No raise, no claim release.
|
2026-06-29 11:32:34 +02:00
|
|
|
orch = _make_orchestrator()
|
|
|
|
|
release = AsyncMock()
|
|
|
|
|
object.__setattr__(orch, "_release_claim_to_pending", release)
|
|
|
|
|
ws = MagicMock()
|
2026-06-30 08:08:35 +02:00
|
|
|
ws.ensure_worktree_self_heal = AsyncMock() # succeeds
|
|
|
|
|
ws.ensure_workspace = AsyncMock()
|
2026-06-29 11:32:34 +02:00
|
|
|
|
|
|
|
|
with (
|
|
|
|
|
patch("roboco.db.base.get_db_context", return_value=_fake_db_ctx(MagicMock())),
|
|
|
|
|
patch("roboco.services.workspace.WorkspaceService", return_value=ws),
|
2026-06-30 08:08:35 +02:00
|
|
|
patch(
|
|
|
|
|
"roboco.services.workspace.WorkspaceService._is_workspace_healthy",
|
|
|
|
|
return_value=True,
|
|
|
|
|
),
|
2026-06-29 11:32:34 +02:00
|
|
|
):
|
|
|
|
|
await orch._ensure_worktree_before_spawn(
|
2026-06-30 08:08:35 +02:00
|
|
|
_ctx(), "roboco-api", "backend", "be-dev-1", str(uuid4())
|
2026-06-29 11:32:34 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
release.assert_not_awaited()
|