Files
roboco/tests/property/test_state_machine_invariants.py
T
Renn F 4829f93a68 fix(gateway): unblock task claim; full Phase 0/1/2 remediation
Resolves the 100% claim-failure rate introduced by the gateway rewrite
  (commit 62bda0c plus 78 follow-ups). Live smoke runs hit
  `404 /api/v2/flow/developer/...` on every dev verb plus a manifest
  fallback that silently exposed off-role verbs to PMs — confirmed
  firing simultaneously in NAS agent logs (be-dev-1, be-pm, main-pm).

  Audit reports under docs/internal/audit_2026_05_04/ catalogue 49
  defects across gateway, services, prompts, MCP transport, substrate,
  and tests (8 detail reports + master synthesis). Six smoking guns;
  three proven in production logs.

  Phase 0 — unblock claim:
  - URL prefix /api/v2/flow/dev → /developer; slug-map board roles
    (product_owner, head_marketing) → /board (D-01)
  - _i_will_work_on AttributeError on None across pending /
    needs_revision / claimed re-entry branches (D-02)
  - Seed last_heartbeat_at in _qa_or_doc_claim (D-03)
  - Drop misleading i_have_committed verb; dev flow uses commit() (D-04)
  - Manifest mount via compose; flow_server + do_server fail loud
    instead of exposing all-verbs fallback (D-12)
  - MCP _post() surfaces envelope body on 4xx so agents see remediate
    hints (D-13)
    on git failure so retries aren't blocked by half-state (S-01)

  Phase 1 — lifecycle stability:
  - _resolve_skill falls back to AgentTable.capabilities (D-06)
  - main_pm_complete uses kwargs for escalate_to_ceo (D-07)
  - i_am_done auto-runs submit_verification when in_progress (D-08)
  - active_claimant_id wired in claim/unclaim paths — single-claimant
    invariant now functional (D-05)
  - qa_pass/qa_fail assert claimed_by parity with qa_agent_id (D-18)
  - Prompt-drift sweep: fail() shape, i_am_done(task_id, notes),
    subtask cap (12 hard / 8 soft), error-code symbology rewritten in
    base.md + per-role anti-patterns (D-10/11/29/30/31, D-37)

  Phase 2 — invariants + architecture:
  - Real-DB integration test exercising claim → in_progress → commit
    → submit_for_qa → i_am_done → awaiting_qa (P2-1)
  - choreographer.py → package; 3 of 6 role mixins extracted
    (board, doc, qa). _impl.py 2,526 → 2,080 lines (-18%). Continuation
    plan in docs/internal/audit_2026_05_04/p2_2_decompose_plan.md (P2-2)
  - Closure guards consolidated via _subtasks_not_terminal_envelope (P2-3)
  - TaskService.unclaim_for_reaper routed through canonical
    _validate_and_set_status; in_progress → pending added to
    VALID_TRANSITIONS (P2-4)
  - Dead code removed: i_am_done_with_catchup verb, _run_catch_up helper
    (P2-5)
  - 6 state-machine invariants asserted via property test (P2-6)
  - attempt_id (uuid4) stamped on every gateway.rejected audit row (P2-7)
  - _reconcile_orphan_claims_on_startup rolls back tasks left CLAIMED
    with branch_name=NULL from prior crashes (P2-8)
  - scripts/regenerate_verb_tables.py introspects Pydantic schemas +
    role_config; compose_prompt injects per-role tables as a layer.
    Eliminates the prompt-drift class structurally (P2-9)

  Other:
  - D-48: orchestrator mounts host's ~/.claude.json when present so
    agents don't boot from backup recovery on every spawn
  - D-49: dev dispatcher rejects role-mismatched spawns (e.g. doc task
    assigned to dev agent)

  Tests: 553 pass · ruff + mypy clean. Live NAS smoke verification
  pending — needs the stack brought back up.
2026-05-04 23:43:55 +02:00

121 lines
4.6 KiB
Python

"""State machine invariant checks (audit P2-6).
Originally specced as hypothesis-driven, but hypothesis isn't a project
dependency, so the same invariants are asserted via deterministic
exhaustive enumeration plus a bounded random walk. The intent is identical:
produce a structural sweep that catches the audit's identified risks
(orphan states, transitions writing fields outside the lifecycle module,
terminal states being mistakenly listed as escape points).
Invariants checked:
1. Every state declared in ``VALID_TRANSITIONS`` appears as either a
source or a target — no entries that nothing transitions into and
nothing transitions out of.
2. Every non-terminal state has at least one outgoing transition.
3. Terminal states (``completed``, ``cancelled``) have no outgoing
transitions.
4. ``is_terminal_state`` is consistent with the empty-transition list
in ``VALID_TRANSITIONS``.
5. Every state is reachable from the initial state ``backlog`` (BFS).
6. Bounded random walk from ``backlog``: any sequence of valid
transitions stays within the declared state set; never crosses into
undeclared states; terminal states absorb (no further transitions).
"""
from __future__ import annotations
import random
from roboco.enforcement.task_lifecycle import (
VALID_TRANSITIONS,
get_valid_transitions,
is_terminal_state,
)
_INITIAL_STATE = "backlog"
_TERMINAL_STATES = {"completed", "cancelled"}
_DECLARED_STATES = set(VALID_TRANSITIONS.keys())
def test_no_orphan_states() -> None:
"""Invariant 1 — every state is reachable + has an exit if non-terminal."""
targets: set[str] = set()
for outgoing in VALID_TRANSITIONS.values():
targets.update(outgoing)
targets.add(_INITIAL_STATE) # initial state has no inbound by convention
sources = {state for state, outs in VALID_TRANSITIONS.items() if outs}
# Every declared state must appear as a target or be terminal.
orphan_targets = _DECLARED_STATES - targets
assert not orphan_targets, f"states with no inbound transition: {orphan_targets}"
# Every non-terminal declared state must have outbound transitions.
orphan_sources = (_DECLARED_STATES - sources) - _TERMINAL_STATES
assert not orphan_sources, (
f"non-terminal states with no outbound transitions: {orphan_sources}"
)
def test_terminal_states_have_no_exits() -> None:
"""Invariant 3 — terminal states must not list any outgoing transitions."""
for state in _TERMINAL_STATES:
assert state in VALID_TRANSITIONS, f"terminal state {state} not declared"
assert VALID_TRANSITIONS[state] == [], (
f"terminal state {state} declares outgoing transitions: "
f"{VALID_TRANSITIONS[state]}"
)
def test_is_terminal_state_consistent_with_transitions() -> None:
"""Invariant 4 — is_terminal_state(s) iff VALID_TRANSITIONS[s] is empty."""
for state, outgoing in VALID_TRANSITIONS.items():
assert is_terminal_state(state) == (len(outgoing) == 0), (
f"is_terminal_state({state!r})={is_terminal_state(state)} "
f"but outgoing transitions = {outgoing}"
)
def test_every_state_reachable_from_initial() -> None:
"""Invariant 5 — BFS from backlog covers every declared state."""
visited: set[str] = set()
frontier: list[str] = [_INITIAL_STATE]
while frontier:
state = frontier.pop()
if state in visited:
continue
visited.add(state)
for nxt in VALID_TRANSITIONS.get(state, []):
if nxt not in visited:
frontier.append(nxt)
unreachable = _DECLARED_STATES - visited
assert not unreachable, f"states unreachable from {_INITIAL_STATE!r}: {unreachable}"
def test_random_walks_stay_within_declared_states() -> None:
"""Invariant 6 — bounded random walks from backlog never leave the declared set."""
rng = random.Random(20260504)
walks = 100
max_steps = 50
for _ in range(walks):
state = _INITIAL_STATE
for _ in range(max_steps):
assert state in _DECLARED_STATES, f"walked into undeclared state {state!r}"
outs = get_valid_transitions(state)
if not outs:
# Terminal — walk stops.
assert is_terminal_state(state), (
f"non-terminal state {state!r} has no transitions"
)
break
state = rng.choice(outs)
def test_no_self_loops() -> None:
"""Bonus invariant — no state may transition to itself."""
for state, outs in VALID_TRANSITIONS.items():
assert state not in outs, (
f"state {state!r} has a self-loop in VALID_TRANSITIONS"
)