fix(workspace): role-aware worktree refresh at every spawn (#692)

* fix(workspace): refresh a present per-task worktree at every respawn

ensure_worktree_self_heal treated an already-present worktree as a pure
no-op (venv-link + chown only), so a worktree created once at first claim
or first claim_review stayed frozen at that commit across every later
respawn even as new commits landed on origin — the root mechanism behind
a live multi-round QA/PR-gate bounce loop, where the reviewer kept
re-examining its own stale round-1 checkout.

_ensure_worktree_before_spawn now classifies the caller's role
(WORKTREE_AUTHOR_ROLES: developer/documenter, mirroring the gateway
commit tool's RBAC) and _refresh_present_worktree compares local HEAD
against origin/<branch>: behind-or-equal fast-forwards for every role
(never discarding an author's uncommitted edits to do it); strictly
ahead is always left alone; diverged only resets for a pure reader,
whose local history can never be anything but a stale prior-round
checkout.

conventions_check_for_task's list-vs-content gap (list from git objects,
content from the physical worktree) is closed as a side effect: the
reviewer's worktree is now current as of spawn, and the branch under
review gains no further commits while it sits in awaiting_pr_review.

* fix(workspace): refresh re-added worktrees; fail the dirty guard toward preservation

- A pruned worktree re-added from a surviving local ref now runs the same
  fetch-and-classify refresh as a present one, so an evicted reviewer
  worktree cannot resurrect a stale checkout.
- A failing git status reads as dirty, never clean: the guard that
  protects an author's uncommitted edits fails toward preservation.
- The hard reset verifies the worktree is actually on the task branch
  first; a detached or drifted worktree is left alone with a warning.
- The conventions-check docstring states the remaining second-claim
  ceiling instead of claiming full closure.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-24 20:29:20 +02:00
committed by GitHub
co-authored by Renn F
parent e97f46af6e
commit 987eb09c78
6 changed files with 518 additions and 28 deletions
@@ -69,13 +69,40 @@ async def test_ensures_worktree_when_task_short_id_set() -> None:
# Healthy clone -> no re-clone, just the worktree self-heal.
ws.ensure_workspace.assert_not_awaited()
ws.ensure_worktree_self_heal.assert_awaited_once()
args = ws.ensure_worktree_self_heal.call_args.args
assert args[0] == Path("/data/workspaces/roboco-api/backend/be-dev-1")
assert args[1] == Path(
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(
"/data/workspaces/roboco-api/backend/be-dev-1/.worktrees/a3c40fe7"
)
assert args[2] == "feature/backend/abc12345"
assert args[3] == "roboco-api"
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
@pytest.mark.asyncio