Files
roboco/tests/foundation/test_lifecycle_spec.py
T
Renn F d87e2d9b4e fix(lifecycle): stop PMs re-claiming tasks out of the closure queue — kills the i_will_plan review loop
A cell/main PM's i_will_plan could legally re-claim its own task from
awaiting_pm_review (a CLAIM_RULES edge added for post-respawn recovery),
resetting the task to in_progress and re-running submit_up -> pr_pass ->
awaiting_pm_review forever: one Sentinel conventions child looped eleven
full laps in four hours (14 reviews on one PR, 37 agent spawns) while
its root's closure check fired eighteen times and the Main PM could
never close anything.

The claim edge is gone from every table that carried it — CLAIM_RULES,
the claim ActionSpec's source statuses, the StatusTransition row, the
service-layer _ROLE_CLAIM_STATUSES twin, and the legacy enforcement
shim's operational-edge/role-gate entries (left divergent, it would be
the same silent two-table drift that produced this bug). The respawn
case the edge existed for is now served properly: _handle_pm_reentry
gained a third contract — a PM calling i_will_plan on its own
awaiting_pm_review task gets a steering envelope (no claim, no state
change) pointing at complete/request_changes, and give_me_work's next
hint for that status says the same instead of steering back into
i_will_plan. The no-transition review-claim path and the pm-review
dispatch prompt were already correct and are untouched, so closure
still converges through them.

A new bidirectional test asserts CLAIM_RULES and _ROLE_CLAIM_STATUSES
stay identical per PM role (the old comment claimed a sync test existed;
it checked one direction only). Lifecycle artifacts regenerated; the
parity suite's three unshaped session mocks fixed, zero AsyncMock
warnings remain.
2026-07-30 23:37:27 +02:00

1161 lines
43 KiB
Python

"""Tier 1 — spec self-tests. Fast (no DB, no network)."""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any, cast
from uuid import uuid4
import pytest
from roboco.foundation import _validate_lifecycle as _validate
from roboco.foundation._validate_lifecycle import reachable_from
from roboco.foundation.policy import lifecycle as spec
from roboco.foundation.policy.lifecycle import _INTENT_VERBS, IntentSpec
from roboco.models.base import TaskStatus as ModelTaskStatus
from roboco.models.base import TaskType as ModelTaskType
def test_role_enum_has_every_pre_gateway_role() -> None:
"""Every role from PERMISSIONS.md must be enumerated.
The canonical Role enum is now defined in `roboco.foundation.identity`
and re-exported here. It includes the 9 pre-gateway roles plus the
SYSTEM sentinel used for orchestrator-generated rows. The pre-gateway
PERMISSIONS.md is the historical canon — SYSTEM is the post-foundation
addition that doesn't appear in policy tables.
"""
expected = {
"developer",
"qa",
"documenter",
"cell_pm",
"main_pm",
"product_owner",
"head_marketing",
"auditor",
"pr_reviewer", # reviews inbound external/fork PRs (read-only)
"prompter", # post-gateway intake role (human-only, drafts tasks)
"secretary", # CEO's chief-of-staff (human-only, gated CEO authority)
"ceo",
"system",
}
actual = {r.value for r in spec.Role}
assert actual == expected, f"Role enum drift: {actual ^ expected}"
def test_status_enum_has_every_pre_gateway_status() -> None:
"""Every status from STATUS_TRANSITIONS.md must be enumerated."""
expected = {
"backlog",
"pending",
"claimed",
"in_progress",
"blocked",
"paused",
"verifying",
"awaiting_qa",
"needs_revision",
"awaiting_documentation",
"awaiting_pr_review",
"awaiting_pm_review",
"awaiting_ceo_approval",
"completed",
"cancelled",
}
actual = {s.value for s in spec.Status}
assert actual == expected, f"Status enum drift: {actual ^ expected}"
def test_task_type_enum_matches_models() -> None:
"""The spec's TaskType must match the existing models.base.TaskType.
If the existing model adds/removes a type, the spec must be updated
in lockstep — that's the entire point of this module.
"""
spec_values = {t.value for t in spec.TaskType}
model_values = {t.value for t in ModelTaskType}
assert spec_values == model_values, (
f"TaskType drift between lifecycle.spec and models.base: "
f"{spec_values ^ model_values}"
)
def test_status_enum_matches_models() -> None:
"""The spec's Status must match models.base.TaskStatus — the ORM column
type and the lifecycle map must not drift. TaskType has this guard; Status
did not, so adding/renaming a status in one enum only wedged silently."""
spec_values = {s.value for s in spec.Status}
model_values = {s.value for s in ModelTaskStatus}
assert spec_values == model_values, (
f"Status drift between lifecycle.spec and models.base: "
f"{spec_values ^ model_values}"
)
def test_status_enum_parity_validator_passes_on_real_spec() -> None:
"""The import-time parity validator must agree with the real enums."""
_validate._check_status_enum_parity() # no raise
def test_status_coverage_rejects_stray_string_target() -> None:
"""A transition referencing a non-Status target string must fail the
coverage validator — the old check was a tautology (STATUS_GRAPH keys every
Status by construction) and let stray-string targets through."""
fake = (
SimpleNamespace(
source=spec.Status.PENDING, target="bogus_state", triggered_by_action="x"
),
)
original = spec._STATUS_TRANSITIONS
spec._STATUS_TRANSITIONS = cast("Any", fake)
try:
with pytest.raises(_validate.LifecycleSpecError, match="non-Status"):
_validate._check_status_enum_coverage()
finally:
spec._STATUS_TRANSITIONS = original
def test_status_coverage_rejects_orphan_non_terminal_source() -> None:
"""A non-terminal status that is the source of no transition (an orphan
state) must fail the coverage validator — the cancel fan-out made the old
'is a key in STATUS_GRAPH' check structurally always-true."""
original = spec._STATUS_TRANSITIONS
spec._STATUS_TRANSITIONS = tuple(
t for t in original if t.source is not spec.Status.PAUSED
)
try:
with pytest.raises(
_validate.LifecycleSpecError, match="no outgoing transition"
):
_validate._check_status_enum_coverage()
finally:
spec._STATUS_TRANSITIONS = original
def test_terminal_exit_requires_a_completed_path() -> None:
"""Every non-terminal status must reach COMPLETED specifically — the cancel
fan-out made the old {COMPLETED, CANCELLED} check trivial, so a status whose
sole exit was cancel passed the guard with no real forward completion path."""
original = spec.STATUS_GRAPH
fake = dict(original)
fake[spec.Status.PAUSED] = frozenset({spec.Status.CANCELLED})
spec.STATUS_GRAPH = fake
try:
with pytest.raises(_validate.LifecycleSpecError, match="no path to COMPLETED"):
_validate._check_terminal_exits()
finally:
spec.STATUS_GRAPH = original
def test_decision_allow_has_no_rejection_kind() -> None:
d = spec.Decision.allow()
assert d.allowed is True
assert d.rejection_kind is None
assert d.message is None
assert d.missing == []
assert d.remediate is None
def test_decision_reject_requires_rejection_kind() -> None:
d = spec.Decision.reject(
kind="not_authorized",
message="role 'developer' may not call delegate",
remediate="only PMs delegate; call give_me_work() instead",
)
assert d.allowed is False
assert d.rejection_kind == "not_authorized"
assert d.message == "role 'developer' may not call delegate"
assert d.remediate == "only PMs delegate; call give_me_work() instead"
def test_decision_tracing_gap_carries_missing_list() -> None:
d = spec.Decision.tracing_gap(
missing=["plan", "journal:decision"],
remediate="provide plan and a journal:decision entry",
)
assert d.allowed is False
assert d.rejection_kind == "tracing_gap"
assert d.missing == ["plan", "journal:decision"]
assert d.remediate == "provide plan and a journal:decision entry"
def test_decision_tracing_gap_defensively_copies_missing() -> None:
"""tracing_gap must isolate the stored list from the caller's source."""
src = ["plan"]
d = spec.Decision.tracing_gap(missing=src, remediate="r")
src.append("mutated")
assert d.missing == ["plan"]
def test_decision_invariants_enforced_at_construction() -> None:
"""allowed=True ⇒ rejection_kind None; allowed=False ⇒ kind set."""
with pytest.raises(ValueError, match="allowed=True requires rejection_kind=None"):
spec.Decision(
allowed=True,
rejection_kind="not_authorized",
message="x",
missing=[],
remediate="x",
)
with pytest.raises(ValueError, match="allowed=False requires rejection_kind"):
spec.Decision(
allowed=False,
rejection_kind=None,
message="x",
missing=[],
remediate="x",
)
def test_decision_invariant_rejects_allowed_with_missing_or_remediate() -> None:
"""allowed=True with missing or remediate set raises (Fix 1 lock-in)."""
with pytest.raises(
ValueError, match="allowed=True requires missing=\\[\\] and remediate=None"
):
spec.Decision(
allowed=True,
rejection_kind=None,
message=None,
missing=["plan"],
remediate=None,
)
with pytest.raises(
ValueError,
match="allowed=True requires missing=\\[\\] and remediate=None",
):
spec.Decision(
allowed=True,
rejection_kind=None,
message=None,
missing=[],
remediate="oops",
)
def test_precondition_check_returns_bool() -> None:
"""A Precondition.check() is the gate-table evaluator."""
p = spec.Precondition(
key="commits>=1",
check=lambda task, _agent, _ctx: bool(getattr(task, "commits", None)),
remediate="commit at least once before opening a PR",
missing_token="commits>=1",
)
task_with = SimpleNamespace(commits=["abc"])
task_without = SimpleNamespace(commits=[])
assert p.check(task_with, None, None) is True
assert p.check(task_without, None, None) is False
def test_action_spec_holds_role_status_and_precondition_data() -> None:
a = spec.ActionSpec(
name="claim",
allowed_roles=frozenset({spec.Role.DEVELOPER}),
source_statuses=frozenset({spec.Status.PENDING, spec.Status.NEEDS_REVISION}),
target_status=spec.Status.CLAIMED,
allowed_task_types=None,
preconditions=(),
self_review_block=False,
needs_team_match=True,
)
assert a.name == "claim"
assert spec.Role.DEVELOPER in a.allowed_roles
assert a.target_status == spec.Status.CLAIMED
def test_intent_spec_composes_atomic_actions() -> None:
i = spec.IntentSpec(
name="i_will_work_on",
allowed_roles=frozenset({spec.Role.DEVELOPER}),
description="Claim a task and start work on it.",
composes=("claim", "set_plan", "start"),
extra_preconditions=(),
side_effects=(),
next_hint=lambda _t: "edit + commit, then open_pr",
)
assert i.composes == ("claim", "set_plan", "start")
assert i.next_hint(None) == "edit + commit, then open_pr"
def test_status_transition_carries_role_constraint_optional() -> None:
t = spec.StatusTransition(
source=spec.Status.AWAITING_QA,
target=spec.Status.AWAITING_DOCUMENTATION,
triggered_by_action="qa_pass",
role_constraint=frozenset({spec.Role.QA}),
)
assert t.source == spec.Status.AWAITING_QA
assert t.target == spec.Status.AWAITING_DOCUMENTATION
assert t.triggered_by_action == "qa_pass"
assert t.role_constraint == frozenset({spec.Role.QA})
def test_status_transitions_includes_dev_path() -> None:
"""The dev happy path: pending → claimed → in_progress → verifying → awaiting_qa."""
sources = {(t.source, t.target) for t in spec._STATUS_TRANSITIONS}
assert (spec.Status.PENDING, spec.Status.CLAIMED) in sources
assert (spec.Status.CLAIMED, spec.Status.IN_PROGRESS) in sources
assert (spec.Status.IN_PROGRESS, spec.Status.VERIFYING) in sources
assert (spec.Status.VERIFYING, spec.Status.AWAITING_QA) in sources
def test_status_transitions_includes_qa_paths() -> None:
sources = {(t.source, t.target) for t in spec._STATUS_TRANSITIONS}
assert (spec.Status.AWAITING_QA, spec.Status.CLAIMED) in sources # QA claims
assert (spec.Status.AWAITING_QA, spec.Status.AWAITING_DOCUMENTATION) in sources
assert (spec.Status.AWAITING_QA, spec.Status.NEEDS_REVISION) in sources
def test_status_transitions_includes_ceo_paths() -> None:
sources = {(t.source, t.target) for t in spec._STATUS_TRANSITIONS}
assert (spec.Status.AWAITING_PM_REVIEW, spec.Status.COMPLETED) in sources
assert (
spec.Status.AWAITING_PM_REVIEW,
spec.Status.AWAITING_CEO_APPROVAL,
) in sources
assert (spec.Status.AWAITING_CEO_APPROVAL, spec.Status.COMPLETED) in sources
assert (spec.Status.AWAITING_CEO_APPROVAL, spec.Status.NEEDS_REVISION) in sources
# #100: a branchless coordination root rejected by the CEO routes to PENDING
# (Main PM re-plans) — the edge is in the spec so the audited privileged
# override that applies it can't be wedged by future admin-override tightening.
assert (spec.Status.AWAITING_CEO_APPROVAL, spec.Status.PENDING) in sources
# A blocked task the PM cannot resolve can also be surfaced to the CEO.
assert (spec.Status.BLOCKED, spec.Status.AWAITING_CEO_APPROVAL) in sources
def test_status_transitions_includes_block_pause_paths() -> None:
sources = {(t.source, t.target) for t in spec._STATUS_TRANSITIONS}
assert (spec.Status.IN_PROGRESS, spec.Status.BLOCKED) in sources
assert (spec.Status.IN_PROGRESS, spec.Status.PAUSED) in sources
assert (spec.Status.BLOCKED, spec.Status.IN_PROGRESS) in sources
assert (spec.Status.PAUSED, spec.Status.IN_PROGRESS) in sources
def test_every_non_terminal_status_can_be_cancelled() -> None:
"""PERMISSIONS.md says PM/CEO can cancel from any state."""
cancellable = {
t.source for t in spec._STATUS_TRANSITIONS if t.target == spec.Status.CANCELLED
}
non_terminal = set(spec.Status) - {spec.Status.COMPLETED, spec.Status.CANCELLED}
assert non_terminal <= cancellable, (
f"Statuses missing a cancel transition: {non_terminal - cancellable}"
)
def test_status_graph_lookup_returns_targets() -> None:
"""STATUS_GRAPH is a quick `source -> {targets}` lookup."""
assert spec.Status.CLAIMED in spec.STATUS_GRAPH[spec.Status.PENDING]
assert spec.Status.AWAITING_QA in spec.STATUS_GRAPH[spec.Status.VERIFYING]
assert spec.STATUS_GRAPH[spec.Status.COMPLETED] == frozenset()
def test_status_transitions_role_constraints_match_canon() -> None:
"""role_constraint must encode the per-row role gates from
PERMISSIONS.md / STATUS_TRANSITIONS.md exactly. Tests that look only
at (source, target) pairs miss role-typo regressions; this test
pins the gates explicitly.
"""
by_pair = {
(t.source, t.target, t.triggered_by_action): t.role_constraint
for t in spec._STATUS_TRANSITIONS
}
# QA is the only role that can claim awaiting_qa
assert by_pair[
(spec.Status.AWAITING_QA, spec.Status.CLAIMED, "claim")
] == frozenset({spec.Role.QA})
# Documenter is the only role that can claim awaiting_documentation
assert by_pair[
(spec.Status.AWAITING_DOCUMENTATION, spec.Status.CLAIMED, "claim")
] == frozenset({spec.Role.DOCUMENTER})
# qa_pass / qa_fail: QA only
assert by_pair[
(spec.Status.AWAITING_QA, spec.Status.AWAITING_DOCUMENTATION, "qa_pass")
] == frozenset({spec.Role.QA})
assert by_pair[
(spec.Status.AWAITING_QA, spec.Status.NEEDS_REVISION, "qa_fail")
] == frozenset({spec.Role.QA})
# docs_complete: documenter only
assert by_pair[
(
spec.Status.AWAITING_DOCUMENTATION,
spec.Status.AWAITING_PM_REVIEW,
"docs_complete",
)
] == frozenset({spec.Role.DOCUMENTER})
# PM complete: cell + main PM (not board, not CEO)
assert by_pair[
(spec.Status.AWAITING_PM_REVIEW, spec.Status.COMPLETED, "complete")
] == frozenset({spec.Role.CELL_PM, spec.Role.MAIN_PM})
# escalate_to_ceo: main_pm + product_owner + head_marketing — from a
# completed review and from a blocked task, same role gate.
escalate_roles = frozenset(
{
spec.Role.MAIN_PM,
spec.Role.PRODUCT_OWNER,
spec.Role.HEAD_MARKETING,
}
)
assert (
by_pair[
(
spec.Status.AWAITING_PM_REVIEW,
spec.Status.AWAITING_CEO_APPROVAL,
"escalate_to_ceo",
)
]
== escalate_roles
)
assert (
by_pair[
(
spec.Status.BLOCKED,
spec.Status.AWAITING_CEO_APPROVAL,
"escalate_to_ceo",
)
]
== escalate_roles
)
# CEO actions: CEO only
assert by_pair[
(spec.Status.AWAITING_CEO_APPROVAL, spec.Status.COMPLETED, "ceo_approve")
] == frozenset({spec.Role.CEO})
assert by_pair[
(spec.Status.AWAITING_CEO_APPROVAL, spec.Status.NEEDS_REVISION, "ceo_reject")
] == frozenset({spec.Role.CEO})
# Cancel: PM + CEO from any non-terminal status EXCEPT the CEO approval
# queue — cancelling a task the CEO is reviewing is the CEO's call, so
# awaiting_ceo_approval -> cancelled is gated to CEO only (a PM cancelling
# it would bypass the human CEO gate).
cancel_constraint = frozenset({spec.Role.CELL_PM, spec.Role.MAIN_PM, spec.Role.CEO})
for src in spec.Status:
if src in (spec.Status.COMPLETED, spec.Status.CANCELLED):
continue
expected = (
frozenset({spec.Role.CEO})
if src is spec.Status.AWAITING_CEO_APPROVAL
else cancel_constraint
)
assert by_pair[(src, spec.Status.CANCELLED, "cancel")] == expected, (
f"cancel from {src.value} has wrong role_constraint"
)
def test_atomic_action_table_has_pre_gateway_actions() -> None:
"""Every task tool from PERMISSIONS.md must have an ActionSpec."""
expected = {
"activate",
"claim",
"start",
"set_plan",
"block",
"unblock",
"pause",
"resume",
"submit_verification",
"submit_qa",
"qa_pass",
"qa_fail",
"docs_complete",
"complete",
"submit_pm_review",
"escalate_to_ceo",
"ceo_approve",
"ceo_reject",
"cancel",
"create_subtask",
}
assert expected <= set(spec._ATOMIC_ACTIONS), (
f"Missing ActionSpec entries: {expected - set(spec._ATOMIC_ACTIONS)}"
)
def test_claim_action_allows_developer_from_pending() -> None:
a = spec._ATOMIC_ACTIONS["claim"]
assert spec.Role.DEVELOPER in a.allowed_roles
assert spec.Status.PENDING in a.source_statuses
assert a.target_status == spec.Status.CLAIMED
def test_qa_pass_self_review_blocks() -> None:
"""A QA cannot qa_pass a task they themselves committed to."""
assert spec._ATOMIC_ACTIONS["qa_pass"].self_review_block is True
assert spec._ATOMIC_ACTIONS["qa_fail"].self_review_block is True
assert spec._ATOMIC_ACTIONS["docs_complete"].self_review_block is True
def test_claim_rules_match_pre_gateway_table() -> None:
"""PERMISSIONS.md "What Each Role Can Claim From" — exact match.
PMs claim from PENDING and NEEDS_REVISION — the latter to recover a rejected
coordination task (pr_fail / qa_fail / ceo_reject) by re-planning and
re-delegating fixes (scoped by give_me_work routing, which offers only the
caller's own assigned tasks). BACKLOG → PENDING is a separate `activate`
action (strict transitions; no implicit activate-on-claim).
AWAITING_PM_REVIEW is deliberately absent from both PM roles — a claim
edge there let a respawned PM's i_will_plan legally re-claim its own
review-queue task and loop the submit_up -> pr_pass -> awaiting_pm_review
cycle forever. See test_awaiting_pm_review_not_claimable_by_any_role.
"""
assert spec.CLAIM_RULES[spec.Role.DEVELOPER] == frozenset(
{spec.Status.PENDING, spec.Status.NEEDS_REVISION}
)
assert spec.CLAIM_RULES[spec.Role.QA] == frozenset({spec.Status.AWAITING_QA})
assert spec.CLAIM_RULES[spec.Role.DOCUMENTER] == frozenset(
{spec.Status.PENDING, spec.Status.AWAITING_DOCUMENTATION}
)
assert spec.CLAIM_RULES[spec.Role.CELL_PM] == frozenset(
{spec.Status.PENDING, spec.Status.NEEDS_REVISION}
)
assert spec.CLAIM_RULES[spec.Role.MAIN_PM] == frozenset(
{spec.Status.PENDING, spec.Status.NEEDS_REVISION}
)
def test_awaiting_pm_review_not_claimable_by_any_role() -> None:
"""The claim edge that let a respawned PM's i_will_plan reset an
awaiting_pm_review task (looping submit_up -> pr_pass -> awaiting_pm_review
forever) is permanently closed: no role may claim from this status. A PM
re-entering its own review task is steered by the choreographer directly
to complete/request_changes, never through claim.
"""
for role, statuses in spec.CLAIM_RULES.items():
assert spec.Status.AWAITING_PM_REVIEW not in statuses, role
# PR_REVIEWER's own review-gate claim (a different status) is untouched.
assert spec.Status.AWAITING_PR_REVIEW in spec.CLAIM_RULES[spec.Role.PR_REVIEWER]
def test_team_rules_pin_team_for_seeded_agents() -> None:
assert spec.ROLE_TEAM_RULES["be-dev-1"] == "backend"
assert spec.ROLE_TEAM_RULES["be-pm"] == "backend"
assert spec.ROLE_TEAM_RULES["fe-qa"] == "frontend"
assert spec.ROLE_TEAM_RULES["main-pm"] is None # cross-cell
def test_intent_verbs_table_has_every_gateway_verb() -> None:
"""Every gateway intent verb must have an IntentSpec."""
expected = {
"give_me_work",
"i_will_work_on",
"i_will_plan",
"delegate",
"open_pr",
"i_am_done",
"i_am_blocked",
"unclaim",
"resume",
"i_am_idle",
"claim_review",
"pass_review",
"fail_review",
"claim_doc_task",
"i_documented",
"complete",
"escalate_up",
"escalate_to_ceo",
"submit_up",
"unblock",
"triage",
"triage_all",
}
assert expected <= set(spec._INTENT_VERBS), (
f"Missing IntentSpec entries: {expected - set(spec._INTENT_VERBS)}"
)
def test_i_will_work_on_composes_claim_set_plan_start() -> None:
iv = spec._INTENT_VERBS["i_will_work_on"]
assert iv.composes == ("claim", "set_plan", "start")
assert spec.Role.DEVELOPER in iv.allowed_roles
def test_i_will_plan_composes_claim_set_plan_start() -> None:
"""PMs use i_will_plan; the composition mirrors i_will_work_on."""
iv = spec._INTENT_VERBS["i_will_plan"]
assert iv.composes == ("claim", "set_plan", "start")
assert iv.allowed_roles == frozenset({spec.Role.CELL_PM, spec.Role.MAIN_PM})
def test_i_am_done_composes_submit_verification_then_submit_qa() -> None:
iv = spec._INTENT_VERBS["i_am_done"]
assert iv.composes == ("submit_verification", "submit_qa")
def test_open_pr_has_git_side_effects() -> None:
"""open_pr is a side-effect-only verb (no DB transition)."""
iv = spec._INTENT_VERBS["open_pr"]
assert "push_branch" in iv.side_effects
assert "create_pr" in iv.side_effects
assert iv.composes == () # pure side effect verb
def test_delegate_composes_create_subtask() -> None:
iv = spec._INTENT_VERBS["delegate"]
assert iv.composes == ("create_subtask",)
assert iv.allowed_roles == frozenset({spec.Role.CELL_PM, spec.Role.MAIN_PM})
_STUB_TASK_DEFAULTS: dict[str, Any] = {
"status": "pending",
"task_type": "code",
"commits": [],
"plan": None,
"assigned_to": None,
"pr_number": None,
}
def _stub_task(**overrides: Any) -> SimpleNamespace:
fields = {**_STUB_TASK_DEFAULTS, **overrides}
fields["commits"] = fields["commits"] or []
return SimpleNamespace(**fields)
def test_can_claim_developer_pending_allowed() -> None:
d = spec.can_claim(spec.Role.DEVELOPER, _stub_task(status="pending"))
assert d.allowed is True
def test_can_claim_developer_completed_rejected() -> None:
d = spec.can_claim(spec.Role.DEVELOPER, _stub_task(status="completed"))
assert d.allowed is False
assert d.rejection_kind == "invalid_state"
def test_can_claim_developer_awaiting_qa_rejected() -> None:
"""Devs cannot claim awaiting_qa - that's QA's path."""
d = spec.can_claim(spec.Role.DEVELOPER, _stub_task(status="awaiting_qa"))
assert d.allowed is False
assert d.rejection_kind == "not_authorized"
def test_can_invoke_intent_developer_can_call_i_will_work_on() -> None:
d = spec.can_invoke_intent(
spec.Role.DEVELOPER,
"i_will_work_on",
_stub_task(status="pending"),
context=spec.Context(plan="my plan"),
)
assert d.allowed is True
def test_can_invoke_intent_pm_cannot_call_i_will_work_on() -> None:
"""PMs use i_will_plan; i_will_work_on is dev-only."""
d = spec.can_invoke_intent(
spec.Role.CELL_PM,
"i_will_work_on",
_stub_task(status="pending"),
context=spec.Context(plan="x"),
)
assert d.allowed is False
assert d.rejection_kind == "not_authorized"
def test_can_invoke_intent_developer_open_pr_no_commits_tracing_gap() -> None:
"""open_pr requires >=1 commit. Without one -> tracing_gap."""
d = spec.can_invoke_intent(
spec.Role.DEVELOPER,
"open_pr",
_stub_task(status="in_progress", commits=[]),
context=spec.Context(),
)
assert d.allowed is False
assert d.rejection_kind == "tracing_gap"
assert "commits>=1" in d.missing
# --------------------------------------------------------------------------- #
# open_pr must enforce the PR-open state gate (parity with the HTTP path)
# --------------------------------------------------------------------------- #
def _owned_task(**overrides: Any) -> SimpleNamespace:
"""A task owned by ``actor`` with commits and no prior PR — only the state
gate can fail, isolating the PR-open-state precondition."""
actor = overrides.pop("actor_id", uuid4())
return _stub_task(
assigned_to=actor,
commits=["abc123"],
pr_number=None,
**overrides,
)
def test_open_pr_rejected_on_claimed_task() -> None:
"""``open_pr`` must be rejected from ``claimed`` — only ``in_progress`` may
open a PR (mirrors the HTTP path's ``_assert_pr_create_allowed``)."""
actor = uuid4()
d = spec.can_invoke_intent(
spec.Role.DEVELOPER,
"open_pr",
_owned_task(status="claimed", actor_id=actor),
context=spec.Context(actor_id=actor),
)
assert d.allowed is False
assert d.rejection_kind == "invalid_state"
def test_open_pr_rejected_on_paused_task() -> None:
actor = uuid4()
d = spec.can_invoke_intent(
spec.Role.DEVELOPER,
"open_pr",
_owned_task(status="paused", actor_id=actor),
context=spec.Context(actor_id=actor),
)
assert d.allowed is False
assert d.rejection_kind == "invalid_state"
def test_open_pr_rejected_on_blocked_task() -> None:
actor = uuid4()
d = spec.can_invoke_intent(
spec.Role.DEVELOPER,
"open_pr",
_owned_task(status="blocked", actor_id=actor),
context=spec.Context(actor_id=actor),
)
assert d.allowed is False
assert d.rejection_kind == "invalid_state"
def test_open_pr_rejected_on_completed_task() -> None:
"""A completed task is terminal — opening a PR on it is nonsensical."""
actor = uuid4()
d = spec.can_invoke_intent(
spec.Role.DEVELOPER,
"open_pr",
_owned_task(status="completed", actor_id=actor),
context=spec.Context(actor_id=actor),
)
assert d.allowed is False
assert d.rejection_kind == "invalid_state"
@pytest.mark.parametrize(
"status",
[
"in_progress",
"verifying",
"awaiting_qa",
"awaiting_documentation",
"needs_revision",
],
)
def test_open_pr_allowed_in_pr_open_states(status: str) -> None:
"""Regression guard: every PR-open-eligible state still lets the owner open
a PR — the new state gate must not over-restrict the legitimate path."""
actor = uuid4()
d = spec.can_invoke_intent(
spec.Role.DEVELOPER,
"open_pr",
_owned_task(status=status, actor_id=actor),
context=spec.Context(actor_id=actor),
)
assert d.allowed is True, f"open_pr should be allowed from {status}"
def test_open_pr_state_gate_takes_priority_over_unowned() -> None:
"""A non-owner in a wrong state: ownership (not_authorized) is checked
before state, mirroring the HTTP path's assignee-first ordering."""
d = spec.can_invoke_intent(
spec.Role.DEVELOPER,
"open_pr",
_owned_task(status="claimed", actor_id=uuid4()),
context=spec.Context(actor_id=uuid4()), # different actor -> not owner
)
assert d.allowed is False
assert d.rejection_kind == "not_authorized"
def test_escalate_up_rejected_on_completed_task() -> None:
"""A PM must not resurrect a COMPLETED task via ``escalate_up`` — the spec
gate rejects terminal tasks 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:
"""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:
"""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
):
verbs = spec.valid_next_verbs(spec.Role.DEVELOPER, _stub_task(status="in_progress"))
assert "open_pr" in verbs
assert "i_am_done" in verbs
assert "i_am_blocked" in verbs
def test_valid_next_verbs_pm_pending_includes_i_will_plan() -> None:
# A PM i_will_plan's a PLANNING task (coordination), not a code task — the
# PM/code claim carve-out (Fix 2) removes i_will_plan from a pending code
# task's verb set, so the legitimate path is exercised with task_type=planning.
verbs = spec.valid_next_verbs(
spec.Role.CELL_PM, _stub_task(status="pending", task_type="planning")
)
assert "i_will_plan" in verbs
def test_composed_actions_for_returns_intent_composition() -> None:
assert spec.composed_actions_for("i_will_work_on") == ("claim", "set_plan", "start")
assert spec.composed_actions_for("open_pr") == ()
def test_intents_for_role_returns_role_scoped_verbs() -> None:
dev_verbs = spec.intents_for_role(spec.Role.DEVELOPER)
assert "i_will_work_on" in dev_verbs
assert "open_pr" in dev_verbs
assert "i_am_done" in dev_verbs
assert "delegate" not in dev_verbs # PM only
assert "claim_review" not in dev_verbs # QA only
def test_status_after_returns_target_status() -> None:
assert spec.status_after("claim", spec.Status.PENDING) == spec.Status.CLAIMED
assert (
spec.status_after("submit_qa", spec.Status.VERIFYING) == spec.Status.AWAITING_QA
)
assert (
spec.status_after("set_plan", spec.Status.IN_PROGRESS) is None
) # no transition
def test_can_invoke_intent_open_pr_passes_when_owner_with_commits() -> None:
"""Green path for open_pr: owner + commits + no prior PR → allow."""
owner_id = uuid4()
task = _stub_task(
status="in_progress",
commits=["abc"],
pr_number=None,
assigned_to=owner_id,
)
d = spec.can_invoke_intent(
spec.Role.DEVELOPER,
"open_pr",
task,
context=spec.Context(actor_id=owner_id),
)
assert d.allowed is True, f"expected allow, got {d}"
def test_can_invoke_intent_open_pr_rejects_non_owner() -> None:
"""Non-owner trying open_pr → not_authorized (PRECONDITION_OWNERSHIP)."""
owner_id = uuid4()
intruder_id = uuid4()
task = _stub_task(
status="in_progress",
commits=["abc"],
pr_number=None,
assigned_to=owner_id,
)
d = spec.can_invoke_intent(
spec.Role.DEVELOPER,
"open_pr",
task,
context=spec.Context(actor_id=intruder_id),
)
assert d.allowed is False
assert d.rejection_kind == "not_authorized"
# ---------------------------------------------------------------------------
# Task 8 — self-consistency validators (`_validate.py`)
# ---------------------------------------------------------------------------
def test_validators_pass_on_real_spec() -> None:
"""Importing roboco.foundation.policy.lifecycle must not raise —
module-level import IS the test. We additionally call the runner
directly so a future refactor that detaches it from import doesn't
silently skip the gate.
"""
_validate.run_all_lifecycle_validators()
def test_every_status_reachable_from_pending() -> None:
"""Reachability — except CANCELLED is its own thing and BACKLOG predates pending."""
reachable = reachable_from(spec.Status.PENDING)
expected_reachable = set(spec.Status) - {spec.Status.BACKLOG, spec.Status.CANCELLED}
assert expected_reachable <= reachable, (
f"Unreachable from pending: {expected_reachable - reachable}"
)
def test_every_intent_verb_composes_known_actions() -> None:
"""Every IntentSpec.composes must reference declared atomic actions."""
for name, iv in spec._INTENT_VERBS.items():
for action_name in iv.composes:
assert action_name in spec._ATOMIC_ACTIONS, (
f"Intent '{name}' composes unknown action '{action_name}'"
)
def test_self_review_symmetry() -> None:
"""If qa_pass blocks, qa_fail and docs_complete must too."""
qp = spec._ATOMIC_ACTIONS["qa_pass"].self_review_block
qf = spec._ATOMIC_ACTIONS["qa_fail"].self_review_block
dc = spec._ATOMIC_ACTIONS["docs_complete"].self_review_block
assert qp == qf == dc, (
"self_review_block asymmetry between qa_pass/qa_fail/docs_complete"
)
def test_run_all_validators_raises_on_unknown_intent_action(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If an IntentSpec.composes references a non-existent action, the
validator must raise LifecycleSpecError. Pins the gate's actual
behavior — without this test, refactors that move run_all_validators()
out of the import path could silently disable the gate.
"""
iv = _INTENT_VERBS["delegate"]
broken = IntentSpec(
name=iv.name,
allowed_roles=iv.allowed_roles,
description=iv.description,
composes=("create_subtask", "ZZZ_FAKE_ACTION_DOES_NOT_EXIST"),
extra_preconditions=iv.extra_preconditions,
side_effects=iv.side_effects,
next_hint=iv.next_hint,
)
patched_intents = dict(_INTENT_VERBS)
patched_intents["delegate"] = broken
monkeypatch.setattr(
"roboco.foundation.policy.lifecycle._INTENT_VERBS", patched_intents
)
with pytest.raises(_validate.LifecycleSpecError, match="ZZZ_FAKE_ACTION"):
_validate.run_all_lifecycle_validators()
def test_next_hint_pr_fail_main_pm_root_steers_to_redelegate() -> None:
"""A ``pr_fail`` on a Main-PM branch-bearing root must steer the Main PM to
re-delegate the fixes, NOT re-submit the unchanged root. The root is an
assembled cell→root / root→master PR — coordination, not the Main PM's own
code — so re-submitting it is the 2026-06-27 infinite ``pr_fail`` loop."""
t = SimpleNamespace(team=spec.Team.MAIN_PM, branch_name="feature/main_pm/c80e19ff")
hint = _INTENT_VERBS["pr_fail"].next_hint(t)
assert "re-delegate" in hint
assert "do NOT re-submit" in hint
def test_next_hint_pr_fail_cell_dev_keeps_dev_revise() -> None:
"""A cell / dev task is revised in place by its dev, so ``pr_fail`` keeps the
dev-revise hint (the cell→root PR carries that dev's own code)."""
t = SimpleNamespace(team=spec.Team.BACKEND, branch_name="feature/backend/abc12345")
hint = _INTENT_VERBS["pr_fail"].next_hint(t)
assert hint == "idle - dev will revise and re-submit"
def test_next_hint_pr_fail_branchless_main_pm_keeps_dev_revise() -> None:
"""A branchless Main-PM umbrella (no ``branch_name``) assembles no PR of its
own, so the gate never lands a ``pr_fail`` on it — but defensively it keeps
the dev-revise hint rather than the re-delegate steer."""
t = SimpleNamespace(team=spec.Team.MAIN_PM, branch_name=None)
hint = _INTENT_VERBS["pr_fail"].next_hint(t)
assert hint == "idle - dev will revise and re-submit"
def test_unmigrated_is_pinned() -> None:
"""The known-debt set; remove an entry once that consumer is migrated."""
assert (
frozenset(
{
"enforcement.task_lifecycle._LEGACY_OPERATIONAL_EDGES",
"enforcement.task_lifecycle._LEGACY_ROLE_GATES",
}
)
== spec.UNMIGRATED
)
# --- PM/code claim invariant (Fix 2 + bug-1): the claim gate does NOT block a
# PM claiming a code task. A PM's only claim verb is i_will_plan, and planning a
# code-typed PARENT (to decompose + delegate the code) is legitimate (bug-1:
# scoping pm_cannot_execute_code to i_will_plan deadlocked the slice). Execution
# is blocked at the intent level — i_will_work_on is _DEV_ROLES only. The
# create/delegate guards (pm_cannot_own_code) block a PM from being ASSIGNED a
# fresh code task; the needs_revision carve-out (a PM resolving review issues
# directly / recovering a rejected coordination task) is naturally allowed
# because PMs claim NEEDS_REVISION. These pin that the claim gate does not
# regress bug-1.
def _claim_task(*, status: str, task_type: str) -> Any:
return SimpleNamespace(status=status, task_type=task_type)
def test_claim_allows_cell_pm_claiming_code_from_pending() -> None:
"""bug-1: a cell PM i_will_plan-ing a code-typed parent (PENDING) to plan +
delegate the code MUST be allowed — rejecting it deadlocks the slice."""
t = _claim_task(status="pending", task_type="code")
d = spec.can_invoke_action(spec.Role.CELL_PM, "claim", t)
assert d.allowed, d.message
def test_claim_allows_cell_pm_claiming_code_from_needs_revision() -> None:
"""Carve-out: a PM may take a code task in needs_revision to resolve the
review/QA issues directly / recover a rejected coordination task."""
t = _claim_task(status="needs_revision", task_type="code")
d = spec.can_invoke_action(spec.Role.CELL_PM, "claim", t)
assert d.allowed, d.message
def test_claim_allows_main_pm_claiming_code_from_needs_revision() -> None:
"""The same carve-out holds for the Main PM (coordination-recovery path)."""
t = _claim_task(status="needs_revision", task_type="code")
d = spec.can_invoke_action(spec.Role.MAIN_PM, "claim", t)
assert d.allowed, d.message
def test_claim_allows_main_pm_claiming_code_from_pending() -> None:
"""bug-1 parity: a Main PM planning a code-typed parent (PENDING) is allowed
for the same reason as the cell PM — execution is blocked at i_will_work_on,
not at the claim gate."""
t = _claim_task(status="pending", task_type="code")
d = spec.can_invoke_action(spec.Role.MAIN_PM, "claim", t)
assert d.allowed, d.message
def test_claim_allows_pm_claiming_planning_from_pending() -> None:
"""A PM claiming a planning task is the legitimate coordination path."""
t = _claim_task(status="pending", task_type="planning")
assert spec.can_invoke_action(spec.Role.CELL_PM, "claim", t).allowed
assert spec.can_invoke_action(spec.Role.MAIN_PM, "claim", t).allowed
def test_claim_allows_developer_claiming_code_from_pending() -> None:
"""A developer claiming fresh code is unaffected (the PM invariant is
enforced at create/delegate + i_will_work_on, not the claim gate)."""
t = _claim_task(status="pending", task_type="code")
assert spec.can_invoke_action(spec.Role.DEVELOPER, "claim", t).allowed
# ---------------------------------------------------------------------------
# Edge cases — logical-gap element sweep (2026-06-30)
# ---------------------------------------------------------------------------
def test_claim_pr_review_rejected_on_gate_task_points_to_claim_gate_review() -> None:
"""claim_pr_review is for an inbound external-PR task in PENDING only. An
awaiting_pr_review gate task must be rejected (and remediation must point
the reviewer at claim_gate_review), not silently accepted by the spec gate."""
d = spec.can_invoke_intent(
spec.Role.PR_REVIEWER,
"claim_pr_review",
_stub_task(status="awaiting_pr_review"),
)
assert d.allowed is False
assert d.rejection_kind == "invalid_state"
assert "claim_gate_review" in (d.remediate or "")
def test_claim_pr_review_allowed_on_pending_external_review() -> None:
"""Green path: a pending external-PR review task is claimable."""
d = spec.can_invoke_intent(
spec.Role.PR_REVIEWER,
"claim_pr_review",
_stub_task(status="pending"),
)
assert d.allowed is True
def test_needs_team_match_rejects_cross_team_claim_when_agent_team_supplied() -> None:
"""needs_team_match was a dead spec field; when the caller supplies the
agent's team via Context, the spec gate must enforce it (a backend dev
cannot claim a frontend task)."""
d = spec.can_invoke_action(
spec.Role.DEVELOPER,
"claim",
_stub_task(status="pending", team="frontend"),
context=spec.Context(agent_team="backend"),
)
assert d.allowed is False
assert d.rejection_kind == "not_authorized"
def test_needs_team_match_allows_same_team_claim() -> None:
d = spec.can_invoke_action(
spec.Role.DEVELOPER,
"claim",
_stub_task(status="pending", team="backend"),
context=spec.Context(agent_team="backend"),
)
assert d.allowed is True
def test_needs_team_match_defers_when_agent_team_absent() -> None:
"""Backward compat: without agent_team in Context, the spec gate stays
permissive (the service layer still enforces team-match)."""
d = spec.can_invoke_action(
spec.Role.DEVELOPER,
"claim",
_stub_task(status="pending", team="frontend"),
)
assert d.allowed is True
def test_valid_next_verbs_omits_claim_review_when_qa_not_in_awaiting_qa() -> None:
"""valid_next_verbs must apply claim-rule narrowing for empty-compose
claim verbs; a QA reviewer on a COMPLETED task must not be told
claim_review is callable."""
verbs = spec.valid_next_verbs(spec.Role.QA, _stub_task(status="completed"))
assert "claim_review" not in verbs
def test_valid_next_verbs_includes_claim_review_for_qa_in_awaiting_qa() -> None:
verbs = spec.valid_next_verbs(spec.Role.QA, _stub_task(status="awaiting_qa"))
assert "claim_review" in verbs
def test_pr_reviewer_has_unclaim_release_verb() -> None:
"""A PR reviewer who cannot finish a review must have a self-release
verb (unclaim), not wedge the lane until the stale-claim reaper."""
assert "unclaim" in spec.intents_for_role(spec.Role.PR_REVIEWER)
def test_unclaim_allowed_for_pr_reviewer() -> None:
d = spec.can_invoke_intent(
spec.Role.PR_REVIEWER,
"unclaim",
_stub_task(status="awaiting_pr_review"),
)
assert d.allowed is True
def test_complete_intent_declares_no_inverted_pr_merge_side_effect() -> None:
"""complete's IntentSpec must not declare a trailing pr_merge side_effect:
TaskService.complete asserts the PR is already merged, so the merge runs
FIRST (choreographer verb body owns the ordering). The spec must match
reality, not lie about a complete-then-merge composition."""
iv = spec._INTENT_VERBS["complete"]
assert iv.composes == ("complete",)
assert iv.side_effects == ()