[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:
Renn F
2026-06-28 11:28:23 +02:00
parent 78272de4fc
commit 2f32228673
5 changed files with 229 additions and 15 deletions
+43
View File
@@ -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