mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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.
121 lines
4.6 KiB
Python
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"
|
|
)
|