Kairo-Jinkendo/backend/steering/graph/execution_engine.py
Lars b9bfa9e5bd
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 2m18s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 19s
Test Suite / playwright-smoke (push) Successful in 12s
AP1.16d + AP1.9c: Planning Debt in Attention und Cockpit-Signale.
Attention-Regeln planning_debt/execution_waiting; Portfolio-Kacheln zeigen Steuerungssignale.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 11:48:34 +02:00

318 lines
10 KiB
Python

"""Execution plan read models — AP1.16b (Action dependency graph)."""
from __future__ import annotations
from typing import Any, Optional
DONE_STATUSES = frozenset({"done"})
OPEN_STATUSES = frozenset(
{"open", "ready", "in_progress", "blocked", "review_required"}
)
TERMINAL_SKIP = frozenset({"done", "discarded"})
def _is_predecessor_satisfied(status: str) -> bool:
return status in DONE_STATUSES
def _sort_actions(actions: list[dict[str, Any]]) -> list[dict[str, Any]]:
return sorted(
actions,
key=lambda action: (
action.get("sort_order", 0),
action.get("title") or "",
str(action.get("id", "")),
),
)
def _sequential_order_blocked_by(
action: dict[str, Any],
sorted_actions: list[dict[str, Any]],
) -> list[str]:
"""Fallback ohne Kanten: sequenzielle Actions nach sort_order."""
blocked_by: list[str] = []
action_id = str(action["id"])
for prev in sorted_actions:
if str(prev["id"]) == action_id:
break
if not _is_predecessor_satisfied(prev.get("status", "open")):
blocked_by.append(str(prev["id"]))
return blocked_by
def _compute_critical_path(
*,
action_by_id: dict[str, dict[str, Any]],
requires_predecessors: dict[str, list[str]],
) -> list[str]:
"""Längste Kette über requires-Kanten (Memoization)."""
memo: dict[str, list[str]] = {}
def chain_for(action_id: str) -> list[str]:
if action_id in memo:
return memo[action_id]
preds = requires_predecessors.get(action_id, [])
if not preds:
memo[action_id] = [action_id]
return memo[action_id]
best: list[str] = []
for pred_id in preds:
if pred_id not in action_by_id:
continue
upstream = chain_for(pred_id)
if len(upstream) > len(best):
best = upstream
memo[action_id] = best + [action_id]
return memo[action_id]
longest: list[str] = []
for action_id in action_by_id:
candidate = chain_for(action_id)
if len(candidate) > len(longest):
longest = candidate
return longest
def compute_planning_debt(
*,
actions: list[dict[str, Any]],
roadmap_items: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Aktive Gates ohne verknüpfte Actions → Planning Debt (AP1.16d light)."""
active_gates = [
item
for item in roadmap_items
if item.get("status") == "active"
]
if not active_gates:
return []
actions_by_gate: dict[str, int] = {}
for action in actions:
gate_id = action.get("roadmap_item_id")
if not gate_id:
continue
gid = str(gate_id)
actions_by_gate[gid] = actions_by_gate.get(gid, 0) + 1
debts: list[dict[str, Any]] = []
for gate in active_gates:
gid = str(gate["id"])
if actions_by_gate.get(gid, 0) == 0:
debts.append(
{
"kind": "missing_execution_plan",
"roadmap_item_id": gid,
"title": gate.get("title"),
"message": "Aktiver Zielzustand ohne Durchführungsplan",
}
)
return debts
def planning_debt_to_attention_items(
*,
initiative_id: str,
debts: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Map Planning Debt read model → Attention Items (AP1.16d)."""
items: list[dict[str, Any]] = []
for debt in debts:
gate_id = str(debt["roadmap_item_id"])
items.append(
{
"kind": "planning_debt",
"severity": "warning",
"title": debt.get("title") or "Zielzustand",
"summary": debt.get("message") or "Durchführungsplan fehlt",
"scope_type": "milestone",
"scope_id": gate_id,
"initiative_id": initiative_id,
"action_id": None,
"blocker_id": None,
"milestone_id": gate_id,
"reason_code": "planning_debt",
"data_source": "execution_graph",
}
)
return items
def execution_waiting_to_attention_items(
*,
initiative_id: str,
actions: list[dict[str, Any]],
graph_state: dict[str, Any],
) -> list[dict[str, Any]]:
"""Actions blocked by Execution-Graph (nicht Status=blocked) → Attention."""
action_by_id = {str(action["id"]): action for action in actions}
items: list[dict[str, Any]] = []
for action_id in graph_state.get("blocked_actions", []):
action = action_by_id.get(str(action_id))
if not action:
continue
if action.get("status") in ("done", "discarded", "blocked"):
continue
meta = graph_state.get("items", {}).get(str(action_id), {})
blocked_by = meta.get("blocked_by") or []
summary = (
"Wartet auf Vorgänger-Arbeitspaket"
if len(blocked_by) == 1
else f"Wartet auf {len(blocked_by)} Vorgänger"
)
items.append(
{
"kind": "execution_waiting",
"severity": "info",
"title": action.get("title") or "Arbeitspaket",
"summary": summary,
"scope_type": "action",
"scope_id": str(action_id),
"initiative_id": initiative_id,
"action_id": str(action_id),
"blocker_id": None,
"milestone_id": (
str(action["roadmap_item_id"]) if action.get("roadmap_item_id") else None
),
"reason_code": "execution_waiting",
"data_source": "execution_graph",
}
)
return items
def compute_execution_graph_state(
*,
actions: list[dict[str, Any]],
dependencies: list[dict[str, Any]],
scope_roadmap_item_id: Optional[str] = None,
) -> dict[str, Any]:
"""
Read Model pro Initiative (optional Gate-Scope).
DB-Kante: predecessor → successor
- requires: successor blocked bis predecessor done
- blocks: successor blocked solange predecessor offen
- relates: keine Blockade
"""
if scope_roadmap_item_id:
scope = str(scope_roadmap_item_id)
actions = [
a
for a in actions
if a.get("roadmap_item_id") and str(a["roadmap_item_id"]) == scope
]
action_ids = {str(a["id"]) for a in actions}
dependencies = [
dep
for dep in dependencies
if str(dep["predecessor_action_id"]) in action_ids
and str(dep["successor_action_id"]) in action_ids
]
sorted_actions = _sort_actions(actions)
action_by_id = {str(action["id"]): action for action in actions}
has_any_deps = len(dependencies) > 0
blocked_by_map: dict[str, list[str]] = {
action_id: [] for action_id in action_by_id
}
requires_predecessors: dict[str, list[str]] = {
action_id: [] for action_id in action_by_id
}
for dep in dependencies:
kind = dep.get("dependency_kind") or "requires"
pred_id = str(dep["predecessor_action_id"])
succ_id = str(dep["successor_action_id"])
if pred_id not in action_by_id or succ_id not in action_by_id:
continue
if kind == "requires":
requires_predecessors.setdefault(succ_id, []).append(pred_id)
predecessor = action_by_id[pred_id]
if not _is_predecessor_satisfied(predecessor.get("status", "open")):
if pred_id not in blocked_by_map[succ_id]:
blocked_by_map[succ_id].append(pred_id)
elif kind == "blocks":
blocker = action_by_id[pred_id]
if blocker.get("status", "open") in OPEN_STATUSES:
if pred_id not in blocked_by_map[succ_id]:
blocked_by_map[succ_id].append(pred_id)
# relates: no block
if not has_any_deps:
for action in sorted_actions:
action_id = str(action["id"])
for prereq_id in _sequential_order_blocked_by(action, sorted_actions):
if prereq_id not in blocked_by_map[action_id]:
blocked_by_map[action_id].append(prereq_id)
if prereq_id not in requires_predecessors.get(action_id, []):
requires_predecessors.setdefault(action_id, []).append(prereq_id)
action_states: dict[str, dict[str, Any]] = {}
blocked_actions: list[str] = []
ready_actions: list[str] = []
for action_id, action in action_by_id.items():
status = action.get("status", "open")
blocked_by = blocked_by_map.get(action_id, [])
blocked = len(blocked_by) > 0 and status not in TERMINAL_SKIP
ready = status in OPEN_STATUSES and not blocked
action_states[action_id] = {
"status": status,
"action_kind": action.get("action_kind", "delivery"),
"sort_order": action.get("sort_order", 0),
"roadmap_item_id": (
str(action["roadmap_item_id"]) if action.get("roadmap_item_id") else None
),
"blocked": blocked,
"ready": ready,
"blocked_by": blocked_by,
}
if blocked:
blocked_actions.append(action_id)
if ready:
ready_actions.append(action_id)
critical_path = _compute_critical_path(
action_by_id=action_by_id,
requires_predecessors=requires_predecessors,
)
result: dict[str, Any] = {
"items": action_states,
"blocked_actions": blocked_actions,
"ready_actions": ready_actions,
"critical_path": critical_path,
"scope_roadmap_item_id": scope_roadmap_item_id,
}
return result
def load_initiative_execution_graph_state(
*,
tenant_id: str,
initiative_id: str,
scope_roadmap_item_id: Optional[str] = None,
) -> dict[str, Any]:
"""Lädt Actions, Kanten und optional Planning Debt."""
from services import actions as action_service
from services import execution_plan as execution_plan_service
actions = action_service.list_actions_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
)
dependencies = execution_plan_service.list_dependencies_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
)
return compute_execution_graph_state(
actions=actions,
dependencies=dependencies,
scope_roadmap_item_id=scope_roadmap_item_id,
)