mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* feat(lifecycle): inherit advanced upstream base on work re-claims A re-claim reused a branch cut at an earlier claim, so upstream work merged since (UX/UI landing on the root after the cell branch was cut) never reached BE/FE branches — divergence and avoidable conflicts. _finalize_claim now merges the advanced base into the pre-existing branch via the dependency-lineage merge: already-ancestor is a no-op, a conflict aborts at the cut point and leaves a transition note steering the agent to sync_branch, a clean merge logs an audit trail; never fails the claim. Double-gated: by role (developer/cell_pm/main_pm — QA/documenter/gate claims review the branch as pushed and never move it) AND by pre-claim status (pending/needs_revision only — a PM's i_will_plan re-claim of its own awaiting_pm_review task must not move a branch that already passed QA + the PR gate). Fresh cuts already branch from the live remote base. Cell-PM prompt now orders reading the upstream design docs before planning. * fix(lifecycle): extract base-inheritance gate predicate for xenon budget The four-condition inline gate pushed _finalize_claim to cyclomatic rank C; the quality gate caps blocks at B. The decision moves to a pure module-level predicate, byte-for-byte the same logic. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
270 lines
8.5 KiB
Python
270 lines
8.5 KiB
Python
"""Upstream base inheritance on re-claim.
|
|
|
|
A re-claim reuses a branch cut at an earlier claim; upstream work merged
|
|
since (a sibling UX/UI cell landing on the root, master advancing under a
|
|
root) never reached it, so BE/FE branches diverged from design work they
|
|
were meant to build on. ``_finalize_claim`` now merges the advanced base
|
|
into the pre-existing branch on WORK claims (developer / cell_pm / main_pm)
|
|
via ``_inherit_upstream_base`` — QA/doc/gate claims never move the branch,
|
|
and a fresh cut already branches from the live remote base.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from roboco.foundation.policy.content import markers
|
|
from roboco.models.base import TaskStatus
|
|
from roboco.services.task import TaskService
|
|
|
|
|
|
def _service() -> TaskService:
|
|
svc = TaskService.__new__(TaskService)
|
|
svc.log = MagicMock()
|
|
session = MagicMock()
|
|
session.flush = AsyncMock()
|
|
session.refresh = AsyncMock()
|
|
svc.session = session
|
|
return svc
|
|
|
|
|
|
def _claim_task(
|
|
branch_name: str | None,
|
|
project_id: Any = None,
|
|
status: TaskStatus = TaskStatus.PENDING,
|
|
) -> MagicMock:
|
|
return MagicMock(
|
|
id=uuid4(),
|
|
project_id=project_id if project_id is not None else uuid4(),
|
|
branch_name=branch_name,
|
|
status=status,
|
|
assigned_to=None,
|
|
claimed_by=None,
|
|
claimed_at=None,
|
|
last_heartbeat_at=None,
|
|
active_claimant_id=None,
|
|
orchestration_markers={},
|
|
)
|
|
|
|
|
|
def _wire_finalize(svc: TaskService) -> AsyncMock:
|
|
"""Stub every _finalize_claim collaborator; return the inherit mock."""
|
|
object.__setattr__(svc, "_set_original_developer_context", MagicMock())
|
|
object.__setattr__(svc, "_validate_and_set_status", MagicMock())
|
|
object.__setattr__(svc, "_emit_status_transition_audit", MagicMock())
|
|
object.__setattr__(svc, "_ensure_branch_for_task", AsyncMock(return_value="b"))
|
|
object.__setattr__(svc, "_create_work_session_if_needed", AsyncMock())
|
|
object.__setattr__(svc, "_inject_proactive_context", AsyncMock())
|
|
object.__setattr__(svc, "_CLAIMABLE_STATUSES", {TaskStatus.PENDING})
|
|
inherit = AsyncMock()
|
|
object.__setattr__(svc, "_inherit_upstream_base", inherit)
|
|
return inherit
|
|
|
|
|
|
def _agent(role: str) -> MagicMock:
|
|
agent = MagicMock()
|
|
agent.role.value = role
|
|
return agent
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _finalize_claim gating
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dev_reclaim_with_existing_branch_inherits() -> None:
|
|
svc = _service()
|
|
inherit = _wire_finalize(svc)
|
|
task = _claim_task("feature/backend/AAA--BBB")
|
|
|
|
await svc._finalize_claim(task, _agent("developer"), uuid4())
|
|
|
|
inherit.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pm_reclaim_with_existing_branch_inherits() -> None:
|
|
svc = _service()
|
|
inherit = _wire_finalize(svc)
|
|
task = _claim_task("feature/main_pm/AAA")
|
|
|
|
await svc._finalize_claim(task, _agent("cell_pm"), uuid4())
|
|
|
|
inherit.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pm_review_queue_reclaim_never_inherits() -> None:
|
|
"""A PM's i_will_plan re-claim of its own AWAITING_PM_REVIEW task must
|
|
not move a branch that already passed QA + the PR gate — a silent base
|
|
merge there would put unreviewed content under the merge decision."""
|
|
svc = _service()
|
|
inherit = _wire_finalize(svc)
|
|
task = _claim_task("feature/main_pm/AAA", status=TaskStatus.AWAITING_PM_REVIEW)
|
|
|
|
await svc._finalize_claim(task, _agent("cell_pm"), uuid4())
|
|
|
|
inherit.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_needs_revision_reclaim_inherits() -> None:
|
|
"""A bounced task re-claimed by its dev is the flagship inherit case."""
|
|
svc = _service()
|
|
inherit = _wire_finalize(svc)
|
|
task = _claim_task("feature/backend/AAA--BBB", status=TaskStatus.NEEDS_REVISION)
|
|
|
|
await svc._finalize_claim(task, _agent("developer"), uuid4())
|
|
|
|
inherit.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_qa_claim_never_moves_the_branch() -> None:
|
|
svc = _service()
|
|
inherit = _wire_finalize(svc)
|
|
task = _claim_task("feature/backend/AAA--BBB")
|
|
|
|
await svc._finalize_claim(task, _agent("qa"), uuid4())
|
|
|
|
inherit.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fresh_branch_skips_inheritance() -> None:
|
|
"""No pre-claim branch → the fresh cut already builds on the live base."""
|
|
svc = _service()
|
|
inherit = _wire_finalize(svc)
|
|
task = _claim_task(None)
|
|
|
|
await svc._finalize_claim(task, _agent("developer"), uuid4())
|
|
|
|
inherit.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_branchless_coordination_skips_inheritance() -> None:
|
|
svc = _service()
|
|
inherit = _wire_finalize(svc)
|
|
task = _claim_task("feature/main_pm/AAA")
|
|
task.project_id = None
|
|
|
|
await svc._finalize_claim(task, _agent("main_pm"), uuid4())
|
|
|
|
inherit.assert_not_awaited()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _inherit_upstream_base behavior
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _patched_deps(
|
|
svc: TaskService,
|
|
merge_status: dict[str, Any] | Exception,
|
|
parent_branch: str = "feature/main_pm/AAA",
|
|
) -> tuple[Any, Any, AsyncMock]:
|
|
project = MagicMock(slug="roboco-api")
|
|
proj_svc = MagicMock()
|
|
proj_svc.get = AsyncMock(return_value=project)
|
|
git_svc = MagicMock()
|
|
git_svc.get_workspace = AsyncMock(return_value=MagicMock())
|
|
merge = AsyncMock(
|
|
side_effect=merge_status
|
|
if isinstance(merge_status, Exception)
|
|
else [merge_status]
|
|
)
|
|
git_svc.merge_dependency_lineage = merge
|
|
object.__setattr__(
|
|
svc, "_resolve_parent_branch", AsyncMock(return_value=parent_branch)
|
|
)
|
|
return proj_svc, git_svc, merge
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inherit_merges_parent_branch() -> None:
|
|
svc = _service()
|
|
task = _claim_task("feature/backend/AAA--BBB")
|
|
proj_svc, git_svc, merge = _patched_deps(svc, {"status": "merged"})
|
|
|
|
with (
|
|
patch(
|
|
"roboco.services.project.get_project_service",
|
|
MagicMock(return_value=proj_svc),
|
|
),
|
|
patch("roboco.services.git.get_git_service", MagicMock(return_value=git_svc)),
|
|
):
|
|
await svc._inherit_upstream_base(task, uuid4())
|
|
|
|
merge.assert_awaited_once()
|
|
args = merge.await_args
|
|
assert args is not None
|
|
assert args.args[2] == "feature/backend/AAA--BBB"
|
|
assert args.args[3] == "feature/main_pm/AAA"
|
|
assert task.orchestration_markers == {}, "clean merge leaves no note"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inherit_conflict_notes_the_task() -> None:
|
|
svc = _service()
|
|
task = _claim_task("feature/backend/AAA--BBB")
|
|
proj_svc, git_svc, _ = _patched_deps(
|
|
svc, {"status": "conflict", "files": ["a.py", "b.py"]}
|
|
)
|
|
|
|
with (
|
|
patch(
|
|
"roboco.services.project.get_project_service",
|
|
MagicMock(return_value=proj_svc),
|
|
),
|
|
patch("roboco.services.git.get_git_service", MagicMock(return_value=git_svc)),
|
|
):
|
|
await svc._inherit_upstream_base(task, uuid4())
|
|
|
|
note = markers.get_transition_note(task, "base_inheritance_conflict")
|
|
assert note is not None
|
|
assert "a.py, b.py" in note
|
|
assert "sync_branch" in note
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inherit_skips_when_base_equals_branch() -> None:
|
|
svc = _service()
|
|
task = _claim_task("feature/main_pm/AAA")
|
|
proj_svc, git_svc, merge = _patched_deps(
|
|
svc, {"status": "merged"}, parent_branch="feature/main_pm/AAA"
|
|
)
|
|
|
|
with (
|
|
patch(
|
|
"roboco.services.project.get_project_service",
|
|
MagicMock(return_value=proj_svc),
|
|
),
|
|
patch("roboco.services.git.get_git_service", MagicMock(return_value=git_svc)),
|
|
):
|
|
await svc._inherit_upstream_base(task, uuid4())
|
|
|
|
merge.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inherit_never_fails_the_claim() -> None:
|
|
svc = _service()
|
|
task = _claim_task("feature/backend/AAA--BBB")
|
|
proj_svc, git_svc, _ = _patched_deps(svc, RuntimeError("network down"))
|
|
|
|
with (
|
|
patch(
|
|
"roboco.services.project.get_project_service",
|
|
MagicMock(return_value=proj_svc),
|
|
),
|
|
patch("roboco.services.git.get_git_service", MagicMock(return_value=git_svc)),
|
|
):
|
|
await svc._inherit_upstream_base(task, uuid4()) # must not raise
|
|
|
|
svc.log.warning.assert_called()
|