mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(lifecycle): stop cell-PM redirect clobbering review handoffs; cascade dependency prune on delete/cancel
Two live bugs the CEO hit daily, both in TaskService: 1. awaiting_qa / awaiting_documentation tasks were assigned to the cell PM (be-pm) and deadlocked until manually reassigned. _resolve_cell_pm_redirect forces every cell-PM-owned task type (documentation/design/research/planning/ administrative) onto the cell PM on every reassign(). The gateway hands off to QA (i_am_done) and to the documenter (pass_review) via reassign(), so the redirect clobbered the rightful reviewer — who has no claim right on those states per CLAIM_RULES. The redirect is for delegation/escalation ownership routing, not for clobbering a review handoff. Gate it on _REVIEW_HANDOFF_STATUSES (awaiting_qa / awaiting_documentation / awaiting_pr_review); awaiting_pm_review is deliberately excluded (the cell PM IS the owner there, so the redirect still fires). 2. Deleting or cancelling a task left a dependent BLOCKED on it stuck forever — the CEO caught a good-to-go task held by a stale dependency on a deleted task. delete() and cancel() never called _unblock_dependents (the edge-prune only the complete()/ceo_approve paths ran), so a BLOCKED dependent was never auto-revived. (A PENDING dependent's claim was not blocked — unmet_dependency_ids treats a missing row as "met" — but a BLOCKED dependent is past the gate and only _unblock_dependents revives it.) Both paths now loop _unblock_dependents over the root and every descendant; idempotent, and a deleted id in completed_dependency_ids is a dead-end node in the sequence graph so it cannot false-block. Tests: 3 unit (review-handoff preserved for QA/doc/PR-reviewer; PM-review and CLAIMED redirects still fire) + 3 integration (delete/cancel revive a BLOCKED dependent; delete prunes a PENDING dependent).
This commit is contained in:
@@ -1780,6 +1780,79 @@ async def test_unblock_dependents_keeps_blocked_when_other_deps_remain(
|
||||
assert other_blocker.id not in refreshed.completed_dependency_ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# delete / cancel must cascade the dependency prune + revive blocked dependents
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_unblocks_blocked_dependent(
|
||||
task_setup: dict, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""A task BLOCKED on a deleted task must be auto-revived — the delete
|
||||
path cascades the same _unblock_dependents prune the COMPLETE path runs.
|
||||
Without it the BLOCKED dependent is never revived (it is past the claim
|
||||
gate, which only treats a missing dep row as "met" for PENDING claims)."""
|
||||
svc = task_setup["svc"]
|
||||
blocker = await svc.create(_req(task_setup))
|
||||
dependent = await svc.create(_req(task_setup))
|
||||
dependent.status = TaskStatus.BLOCKED
|
||||
dependent.dependency_ids = [blocker.id]
|
||||
await db_session.flush()
|
||||
|
||||
deleted = await svc.delete(blocker.id)
|
||||
assert deleted is True
|
||||
|
||||
refreshed = await svc.get(dependent.id)
|
||||
assert refreshed is not None
|
||||
assert blocker.id not in refreshed.dependency_ids
|
||||
# Revived out of BLOCKED — back to workable state.
|
||||
assert refreshed.status != TaskStatus.BLOCKED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_prunes_stale_dependency_from_pending_dependent(
|
||||
task_setup: dict, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""A PENDING dependent's stale dep id is pruned on delete (data hygiene);
|
||||
it is not revived because it was never BLOCKED."""
|
||||
svc = task_setup["svc"]
|
||||
blocker = await svc.create(_req(task_setup))
|
||||
dependent = await svc.create(_req(task_setup))
|
||||
dependent.dependency_ids = [blocker.id]
|
||||
await db_session.flush()
|
||||
|
||||
await svc.delete(blocker.id)
|
||||
|
||||
refreshed = await svc.get(dependent.id)
|
||||
assert refreshed is not None
|
||||
assert refreshed.status == TaskStatus.PENDING
|
||||
assert blocker.id not in refreshed.dependency_ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_unblocks_blocked_dependent(
|
||||
task_setup: dict, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Cancelling a blocker revives a BLOCKED dependent the same way — the
|
||||
cancelled id is pruned and the dependent resumes. A cancelled task is
|
||||
terminal, so it must never hold a dependent in BLOCKED forever."""
|
||||
svc = task_setup["svc"]
|
||||
blocker = await svc.create(_req(task_setup))
|
||||
dependent = await svc.create(_req(task_setup))
|
||||
dependent.status = TaskStatus.BLOCKED
|
||||
dependent.dependency_ids = [blocker.id]
|
||||
await db_session.flush()
|
||||
|
||||
cancelled = await svc.cancel(blocker.id, agent_role="cell_pm")
|
||||
assert cancelled is not None
|
||||
|
||||
refreshed = await svc.get(dependent.id)
|
||||
assert refreshed is not None
|
||||
assert blocker.id not in refreshed.dependency_ids
|
||||
assert refreshed.status != TaskStatus.BLOCKED
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# claim — gate validations (team mismatch, role mismatch, self-review)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user