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 (#644)
* 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>
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user