mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[F043] guard escalate_up against resurrecting terminal tasks
escalate_up had composes=() and no source-status guard, so a PM could escalate a COMPLETED/CANCELLED task and apply_escalation set it back to BLOCKED — bypassing the state machine's terminal-state invariant. Defense in depth: - spec: add PRECONDITION_NON_TERMINAL to escalate_up's extra_preconditions so the lifecycle gate rejects terminal tasks (invalid_state) before the journal:decision write fires; generalize _check_intent_preconditions to honor non-tracing rejection_kind (not_authorized / invalid_state). - service: apply_escalation (the single write primitive) returns False and refuses to mutate a terminal task — covers the HTTP escalate route which bypasses the spec gate. escalate() / escalate_up_to_role() return None on refusal so the gateway emits a clean invalid_state envelope. - route: the HTTP escalate route 409s a terminal task BEFORE sending the escalation notification (so a finished task isn't yanked back, PM not pinged).
This commit is contained in:
@@ -566,6 +566,49 @@ def test_can_invoke_intent_developer_open_pr_no_commits_tracing_gap() -> None:
|
||||
assert "commits>=1" in d.missing
|
||||
|
||||
|
||||
def test_escalate_up_rejected_on_completed_task() -> None:
|
||||
"""F043: a PM must not resurrect a COMPLETED task via escalate_up.
|
||||
|
||||
escalate_up has composes=() and historically no source-status guard, so the
|
||||
spec gate accepted it on a terminal task and apply_escalation set it back to
|
||||
BLOCKED — bypassing the state machine's terminal-state invariant. The spec
|
||||
now rejects terminal tasks (completed / cancelled) before the journal:decision
|
||||
write fires.
|
||||
"""
|
||||
d = spec.can_invoke_intent(
|
||||
spec.Role.CELL_PM,
|
||||
"escalate_up",
|
||||
_stub_task(status="completed"),
|
||||
context=spec.Context(notes="stuck on something"),
|
||||
)
|
||||
assert d.allowed is False
|
||||
assert d.rejection_kind == "invalid_state"
|
||||
|
||||
|
||||
def test_escalate_up_rejected_on_cancelled_task() -> None:
|
||||
"""F043: cancelled is terminal — escalate_up must not resurrect it either."""
|
||||
d = spec.can_invoke_intent(
|
||||
spec.Role.MAIN_PM,
|
||||
"escalate_up",
|
||||
_stub_task(status="cancelled"),
|
||||
context=spec.Context(notes="stuck on something"),
|
||||
)
|
||||
assert d.allowed is False
|
||||
assert d.rejection_kind == "invalid_state"
|
||||
|
||||
|
||||
def test_escalate_up_allowed_on_blocked_task() -> None:
|
||||
"""F043: the terminal guard must not over-restrict — BLOCKED is the natural
|
||||
escalation source and must still be allowed."""
|
||||
d = spec.can_invoke_intent(
|
||||
spec.Role.CELL_PM,
|
||||
"escalate_up",
|
||||
_stub_task(status="blocked"),
|
||||
context=spec.Context(notes="stuck on something"),
|
||||
)
|
||||
assert d.allowed is True
|
||||
|
||||
|
||||
def test_valid_next_verbs_developer_in_progress_includes_open_pr_and_i_am_done() -> (
|
||||
None
|
||||
):
|
||||
|
||||
@@ -428,6 +428,102 @@ async def test_is_board_advisory_agent_classifies_roles() -> None:
|
||||
assert await svc._is_board_advisory_agent(uuid4()) is expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_escalation_refuses_completed_task() -> None:
|
||||
# F043: a COMPLETED task is terminal — apply_escalation must not resurrect
|
||||
# it to BLOCKED. The HTTP escalate route bypasses the spec gate, so the
|
||||
# single write primitive must refuse terminal tasks itself. Returns False
|
||||
# so callers (escalate / HTTP route) can surface a clean invalid_state / 409
|
||||
# instead of mutating a finished task.
|
||||
svc = _service()
|
||||
original_assignee = uuid4()
|
||||
task = MagicMock(
|
||||
id=uuid4(),
|
||||
parent_task_id=uuid4(),
|
||||
task_type=TaskType.CODE,
|
||||
assigned_to=original_assignee,
|
||||
blocker_raised_by=None,
|
||||
status=TaskStatus.COMPLETED,
|
||||
)
|
||||
flush = AsyncMock()
|
||||
object.__setattr__(svc.session, "flush", flush)
|
||||
_bind(svc, "_is_board_advisory_agent", AsyncMock(return_value=False))
|
||||
_bind(svc, "_emit_status_transition_audit", MagicMock())
|
||||
|
||||
applied = await svc.apply_escalation(
|
||||
task=task,
|
||||
target_agent_id=uuid4(),
|
||||
escalator_slug="be-pm",
|
||||
target_slug="main-pm",
|
||||
reason="please review",
|
||||
)
|
||||
|
||||
assert applied is False
|
||||
assert task.status == TaskStatus.COMPLETED # untouched — not resurrected
|
||||
assert task.assigned_to == original_assignee # no reassignment happened
|
||||
flush.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_escalation_refuses_cancelled_task() -> None:
|
||||
# F043: cancelled is terminal too — must not be resurrected via escalation.
|
||||
svc = _service()
|
||||
task = MagicMock(
|
||||
id=uuid4(),
|
||||
parent_task_id=uuid4(),
|
||||
task_type=TaskType.CODE,
|
||||
assigned_to=uuid4(),
|
||||
blocker_raised_by=None,
|
||||
status=TaskStatus.CANCELLED,
|
||||
)
|
||||
flush = AsyncMock()
|
||||
object.__setattr__(svc.session, "flush", flush)
|
||||
_bind(svc, "_is_board_advisory_agent", AsyncMock(return_value=False))
|
||||
|
||||
applied = await svc.apply_escalation(
|
||||
task=task,
|
||||
target_agent_id=uuid4(),
|
||||
escalator_slug="be-pm",
|
||||
target_slug="main-pm",
|
||||
reason="please review",
|
||||
)
|
||||
|
||||
assert applied is False
|
||||
assert task.status == TaskStatus.CANCELLED
|
||||
flush.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_escalation_blocks_non_terminal_task() -> None:
|
||||
# F043: the terminal guard must not over-restrict — a normal in_progress
|
||||
# task still escalates (blocked + reassigned) and returns True.
|
||||
svc = _service()
|
||||
target_id = uuid4()
|
||||
task = MagicMock(
|
||||
id=uuid4(),
|
||||
parent_task_id=uuid4(),
|
||||
task_type=TaskType.CODE,
|
||||
assigned_to=uuid4(),
|
||||
blocker_raised_by=None,
|
||||
dev_notes=None,
|
||||
status=TaskStatus.IN_PROGRESS,
|
||||
)
|
||||
_bind(svc, "_is_board_advisory_agent", AsyncMock(return_value=False))
|
||||
_bind(svc, "_emit_status_transition_audit", MagicMock())
|
||||
|
||||
applied = await svc.apply_escalation(
|
||||
task=task,
|
||||
target_agent_id=target_id,
|
||||
escalator_slug="be-pm",
|
||||
target_slug="main-pm",
|
||||
reason="cell blocked",
|
||||
)
|
||||
|
||||
assert applied is True
|
||||
assert task.status == TaskStatus.BLOCKED
|
||||
assert task.assigned_to == target_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_escalation_emits_blocked_audit_event() -> None:
|
||||
"""A non-divert escalation sets BLOCKED and MUST record a task.blocked audit
|
||||
|
||||
Reference in New Issue
Block a user