mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(gateway): rejected PM is told the exact complete() call (#170, partial)
Smoke-15 wedge: leaf 1533ce56 sat at awaiting_pm_review owned by
be-pm, but the PMs looped firing complete/unblock at the wrong
(parent) task_ids — every rejection was generic ("not assigned to
you" / "not ready for completion"), so minimax never discovered it
just needed `complete(1533ce56)`.
New _own_review_hint: on a cell_pm/main_pm complete-guard rejection
(not-owner, wrong-state, or main-pm-on-non-root), if the PM owns a
DIFFERENT task that is awaiting_pm_review, append a remediate suffix
naming it and the exact `complete(task_id='<id>', notes='...')` call.
Best-effort (never raises into the rejection path), pure guidance —
no control-flow or state-machine change.
Scope: this is the bounded, low-risk slice of #170 (fix b). The
parent-state corruption + missing recovery transition (root->paused /
cell->blocked from earlier mis-targeted verbs, fix a/c) is a lifecycle
state-machine change deferred for explicit design alignment — tracked
in #170.
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
"""#170: a rejected PM must be told the exact complete() call to make.
|
||||
|
||||
Smoke-15 wedge: leaf 1533ce56 sat at awaiting_pm_review owned by
|
||||
be-pm, but be-pm/main-pm looped firing complete/unblock at the wrong
|
||||
(parent) task_ids — no rejection ever named the one actionable task,
|
||||
so minimax never issued `complete(1533ce56)`. The complete guards now
|
||||
append `_own_review_hint`: if the PM owns a DIFFERENT task that is
|
||||
awaiting_pm_review, the remediate names it + the exact call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
||||
|
||||
|
||||
def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
||||
base: dict[str, Any] = {
|
||||
"task": AsyncMock(),
|
||||
"work_session": AsyncMock(),
|
||||
"git": AsyncMock(),
|
||||
"a2a": AsyncMock(),
|
||||
"journal": AsyncMock(),
|
||||
"audit": AsyncMock(),
|
||||
"evidence_repo": AsyncMock(),
|
||||
"messaging": AsyncMock(),
|
||||
}
|
||||
base.update(overrides)
|
||||
repo = base["evidence_repo"]
|
||||
for m in (
|
||||
"list_unread_a2a",
|
||||
"list_unread_mentions",
|
||||
"list_pending_notifications",
|
||||
"task_metadata_gaps",
|
||||
"recent_team_activity",
|
||||
"blockers_in_lane",
|
||||
"journal_highlights_for_task",
|
||||
):
|
||||
getattr(repo, m).return_value = []
|
||||
_ldef = base["journal"].latest_decision_at.return_value
|
||||
if type(_ldef).__name__ in ("MagicMock", "AsyncMock"):
|
||||
base["journal"].latest_decision_at.return_value = datetime.now(UTC)
|
||||
return ChoreographerDeps(**base)
|
||||
|
||||
|
||||
def _owned(task_id: Any, status: str) -> SimpleNamespace:
|
||||
return SimpleNamespace(id=task_id, status=status)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hint_names_the_owned_awaiting_review_task() -> None:
|
||||
pm = uuid4()
|
||||
ready_id = uuid4()
|
||||
rejected_id = uuid4()
|
||||
deps = _make_deps()
|
||||
deps.task.list_by_assignee = AsyncMock(
|
||||
return_value=[
|
||||
_owned(rejected_id, "in_progress"),
|
||||
_owned(ready_id, "awaiting_pm_review"),
|
||||
]
|
||||
)
|
||||
c = Choreographer(deps)
|
||||
|
||||
hint = await c._own_review_hint(pm, rejected_id)
|
||||
assert str(ready_id) in hint
|
||||
assert f"complete(task_id='{ready_id}'" in hint
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hint_excludes_the_rejected_task_itself() -> None:
|
||||
pm = uuid4()
|
||||
same = uuid4()
|
||||
deps = _make_deps()
|
||||
deps.task.list_by_assignee = AsyncMock(
|
||||
return_value=[_owned(same, "awaiting_pm_review")]
|
||||
)
|
||||
c = Choreographer(deps)
|
||||
assert await c._own_review_hint(pm, same) == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hint_empty_when_nothing_ready() -> None:
|
||||
pm = uuid4()
|
||||
deps = _make_deps()
|
||||
deps.task.list_by_assignee = AsyncMock(
|
||||
return_value=[_owned(uuid4(), "in_progress")]
|
||||
)
|
||||
c = Choreographer(deps)
|
||||
assert await c._own_review_hint(pm, uuid4()) == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hint_best_effort_swallows_errors() -> None:
|
||||
pm = uuid4()
|
||||
deps = _make_deps()
|
||||
deps.task.list_by_assignee = AsyncMock(side_effect=RuntimeError("db down"))
|
||||
c = Choreographer(deps)
|
||||
assert await c._own_review_hint(pm, uuid4()) == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cell_pm_complete_guard_not_owner_surfaces_hint() -> None:
|
||||
"""The smoke-15 case: main-pm calls complete on a leaf it doesn't
|
||||
own while its real task is awaiting_pm_review elsewhere."""
|
||||
caller = uuid4()
|
||||
other_owner = uuid4()
|
||||
rejected_id = uuid4()
|
||||
ready_id = uuid4()
|
||||
deps = _make_deps()
|
||||
deps.task.list_by_assignee = AsyncMock(
|
||||
return_value=[_owned(ready_id, "awaiting_pm_review")]
|
||||
)
|
||||
c = Choreographer(deps)
|
||||
t = MagicMock(
|
||||
id=rejected_id,
|
||||
assigned_to=other_owner,
|
||||
status="awaiting_pm_review",
|
||||
team="backend",
|
||||
task_type="code",
|
||||
)
|
||||
|
||||
env = await c._cell_pm_complete_guard(caller, rejected_id, t, "notes")
|
||||
assert env is not None
|
||||
body = env.as_dict()
|
||||
assert body["error"] == "not_authorized"
|
||||
assert f"complete(task_id='{ready_id}'" in body["remediate"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cell_pm_complete_guard_wrong_state_surfaces_hint() -> None:
|
||||
pm = uuid4()
|
||||
rejected_id = uuid4()
|
||||
ready_id = uuid4()
|
||||
deps = _make_deps()
|
||||
deps.task.list_by_assignee = AsyncMock(
|
||||
return_value=[_owned(ready_id, "awaiting_pm_review")]
|
||||
)
|
||||
c = Choreographer(deps)
|
||||
t = MagicMock(
|
||||
id=rejected_id,
|
||||
assigned_to=pm,
|
||||
status="in_progress",
|
||||
team="backend",
|
||||
task_type="planning",
|
||||
)
|
||||
|
||||
env = await c._cell_pm_complete_guard(pm, rejected_id, t, "notes")
|
||||
assert env is not None
|
||||
body = env.as_dict()
|
||||
assert body["error"] == "invalid_state"
|
||||
assert f"complete(task_id='{ready_id}'" in body["remediate"]
|
||||
Reference in New Issue
Block a user