Initial commit: a0_software_orchestrator v1.0
- Auto-Registration-Bug behoben (register_project/get_project_id/resolve_project Trennung) - 25 Tests gruen (Pytest) - block_compactor-Tool refactored (Option B: Soft-Check statt Hard-Block) - 4 Restbaustellen gefixt - DB-Schema: plugin_settings-Tabelle hinzugefuegt - 3 Schattenprojekte aus DB geloescht - Plan v3 + Refactor-Plan + Worklog dokumentiert
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
"""Block Compact Protocol – Schwellen, Pflicht-Artefakte und sichere Trigger.
|
||||
|
||||
NEU (additiv, DB-backed): Definiert das Block-Compact-Protokoll für den
|
||||
A0 Software Orchestrator. Verwendet die DB-Tabellen orch_* (Migration v3).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import yaml
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
||||
get_project_id,
|
||||
list_next_steps,
|
||||
list_worklog,
|
||||
list_todos,
|
||||
list_kv_keys,
|
||||
get_kv,
|
||||
get_status,
|
||||
_db,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin-Root für Config-Lookup (default_config.yaml)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schwellen (werden zur Laufzeit aus config.json block_compact überschrieben)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_THRESHOLDS: Dict[str, Any] = {
|
||||
"enabled": True,
|
||||
"threshold_warn": 0.8,
|
||||
"threshold_hard": 0.9,
|
||||
"min_lines_user_status": 30,
|
||||
"max_conversation_summary_lines": 200,
|
||||
"require_user_approval": False,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pflicht-Artefakte – werden in der DB geprüft (nicht mehr als Files!)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MANDATORY_ARTIFACTS: List[str] = [
|
||||
"worklog", # orch_worklog: mind. 1 Eintrag
|
||||
"todo", # orch_todos: mind. 1 open todo
|
||||
"project_state", # orch_kv key='project_state' oder 'phase'
|
||||
"current_status", # orch_status: gesetzt
|
||||
"next_steps", # orch_next_steps: mind. 1 pending
|
||||
]
|
||||
|
||||
# Zusätzliche Artefakte, die das Block-Compact-Protokoll schreibt
|
||||
BLOCK_COMPACT_EXTRA_ARTIFACTS: List[str] = [
|
||||
"block_compact_marker", # orch_block_compact (DB)
|
||||
"snapshot", # orch_snapshots (DB)
|
||||
"worklog_entry", # orch_worklog Eintrag (DB)
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trigger-Bedingungen – wann ein Compact SICHER ist
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_block_compact_config(project_root: str = "") -> Dict[str, Any]:
|
||||
"""Liest die block_compact-Konfiguration aus Plugin- und Projekt-Config.
|
||||
|
||||
Args:
|
||||
project_root: Optional. Wird hier nicht mehr benötigt (DB-basiert),
|
||||
aber als Parameter behalten für Rückwärtskompatibilität.
|
||||
|
||||
Returns:
|
||||
Konfig-Dict mit aktivierten Schwellen etc.
|
||||
"""
|
||||
config = dict(DEFAULT_THRESHOLDS)
|
||||
|
||||
# Suche zuerst nach <project_root>/.a0/config.json
|
||||
candidate_paths = []
|
||||
if project_root:
|
||||
candidate_paths.append(
|
||||
(os.path.join(project_root, ".a0", "config.json"), json.load)
|
||||
)
|
||||
# Fallback: Plugin-Default
|
||||
candidate_paths.append(
|
||||
(os.path.join(_PLUGIN_ROOT, "default_config.yaml"), yaml.safe_load)
|
||||
)
|
||||
|
||||
for path, loader in candidate_paths:
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = loader(f)
|
||||
except (json.JSONDecodeError, yaml.YAMLError, OSError, UnicodeDecodeError):
|
||||
continue
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
block_cfg = data.get("block_compact", {})
|
||||
if isinstance(block_cfg, dict) and block_cfg:
|
||||
config.update(block_cfg)
|
||||
break
|
||||
return config
|
||||
|
||||
|
||||
def should_block_compact(
|
||||
project_name: str = "",
|
||||
project_root: str = "",
|
||||
estimated_ratio: float = 0.0,
|
||||
open_subagent_calls: int = 0,
|
||||
quality_gate_passed: bool = True,
|
||||
user_gave_new_instruction: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Entscheidet, ob ein Block-Compact jetzt SICHER ausgelöst werden darf.
|
||||
|
||||
Args:
|
||||
project_name: Projektname (DB-Lookup, bevorzugt).
|
||||
project_root: Legacy – wird zu basename() aufgelöst.
|
||||
estimated_ratio: Token-Verbrauch (0.0–1.0).
|
||||
open_subagent_calls: Anzahl offener Subagent-Calls.
|
||||
quality_gate_passed: True, wenn Quality-Gate passed.
|
||||
user_gave_new_instruction: True, wenn User in dieser Runde neue Anweisung gab.
|
||||
|
||||
Returns:
|
||||
dict mit allowed, forced, reason, config.
|
||||
"""
|
||||
# Bug-Fix: basename-Fallback entfernt – würde ungültige/Phantom-Namen
|
||||
# erzeugen. project_name ist Pflicht.
|
||||
if not project_name or not project_name.strip():
|
||||
return {
|
||||
"allowed": False, "forced": False,
|
||||
"reason": "project_name is required (basename-fallback removed)",
|
||||
"config": get_block_compact_config(project_root=project_root),
|
||||
}
|
||||
name = project_name.strip()
|
||||
config = get_block_compact_config(project_root=project_root)
|
||||
|
||||
# 1. Disabled?
|
||||
if not config.get("enabled", True):
|
||||
return {
|
||||
"allowed": False, "forced": False,
|
||||
"reason": "block_compact disabled in config", "config": config,
|
||||
}
|
||||
|
||||
# 2. Quality-Gate failed?
|
||||
if not quality_gate_passed:
|
||||
return {
|
||||
"allowed": False, "forced": False,
|
||||
"reason": "quality_gate failed – fix first, compact after",
|
||||
"config": config,
|
||||
}
|
||||
|
||||
# 3. Offene Subagent-Calls?
|
||||
if open_subagent_calls > 0:
|
||||
return {
|
||||
"allowed": False, "forced": False,
|
||||
"reason": f"{open_subagent_calls} subagent call(s) still open",
|
||||
"config": config,
|
||||
}
|
||||
|
||||
# 4. User hat neue Anweisung in dieser Runde gegeben?
|
||||
if user_gave_new_instruction:
|
||||
return {
|
||||
"allowed": False, "forced": False,
|
||||
"reason": "user gave new instruction this turn – process first",
|
||||
"config": config,
|
||||
}
|
||||
|
||||
# 5. Pflicht-Artefakte vorhanden?
|
||||
# (Refactor Option B / Soft-Check 2026-06-16: HARD-Block entfernt.
|
||||
# Stattdessen prüft block_compactor.execute die Artefakte als
|
||||
# WARN und schreibt trotzdem. Diese Funktion behält Token-Ratio,
|
||||
# Subagent, Quality-Gate, User-Instruction, next_steps als BLOCK.)
|
||||
# artifacts = check_mandatory_artifacts(name) # Soft-Check-Pfad: block_compactor
|
||||
# for artifact_key, ok in artifacts.items():
|
||||
# if not ok:
|
||||
# return {
|
||||
# "allowed": False, "forced": False,
|
||||
# "reason": f"mandatory artifact missing: {artifact_key}",
|
||||
# "config": config,
|
||||
# }
|
||||
|
||||
# 6. next_steps nicht leer
|
||||
try:
|
||||
pid = get_project_id(name)
|
||||
if pid is None:
|
||||
raise LookupError(f"project '{name}' not found in DB")
|
||||
pending_steps = list_next_steps(pid, status="pending")
|
||||
if not pending_steps:
|
||||
return {
|
||||
"allowed": False, "forced": False,
|
||||
"reason": "orch_next_steps has no pending entries – next block not defined",
|
||||
"config": config,
|
||||
}
|
||||
except (ValueError, LookupError):
|
||||
return {
|
||||
"allowed": False, "forced": False,
|
||||
"reason": f"project {name!r} not found in DB",
|
||||
"config": config,
|
||||
}
|
||||
|
||||
# 7. Token-Schwelle
|
||||
threshold_warn = float(config.get("threshold_warn", 0.8))
|
||||
threshold_hard = float(config.get("threshold_hard", 0.9))
|
||||
forced = estimated_ratio >= threshold_hard
|
||||
if estimated_ratio < threshold_warn and not forced:
|
||||
return {
|
||||
"allowed": False, "forced": False,
|
||||
"reason": f"context ratio {estimated_ratio:.2f} below warn-threshold {threshold_warn:.2f}",
|
||||
"config": config,
|
||||
}
|
||||
|
||||
return {
|
||||
"allowed": True,
|
||||
"forced": forced,
|
||||
"reason": "all preconditions met",
|
||||
"config": config,
|
||||
}
|
||||
|
||||
|
||||
def _has_working_todo_table(pid: int) -> bool:
|
||||
"""Prüft, ob die Todo-Tabelle vorhanden und das Schema nutzbar ist.
|
||||
|
||||
Hintergrund (Bugfix): Vorher wurde 'mind. 1 open todo' als Pflicht
|
||||
verlangt. Das blockierte das Block-Compact-Protokoll fälschlich,
|
||||
wenn die Session beendet ist und keine offenen Todos anstehen
|
||||
(gültiger Zustand). Korrekt ist: Todo-Mechanismus ist verfügbar =
|
||||
Tabelle existiert + die für die Listen-Abfrage nötigen Spalten sind da.
|
||||
"""
|
||||
db = _db()
|
||||
# 1. Tabelle existiert?
|
||||
row = db.conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='orch_todos'"
|
||||
).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
# 2. Erforderliche Spalten vorhanden?
|
||||
required = {"id", "project_id", "status"}
|
||||
cols = {r[1] for r in db.conn.execute("PRAGMA table_info(orch_todos)").fetchall()}
|
||||
return required.issubset(cols)
|
||||
|
||||
|
||||
def check_mandatory_artifacts(project_name: str) -> Dict[str, bool]:
|
||||
"""Prüft, ob alle 5 Pflicht-Artefakte (DB) vorhanden sind.
|
||||
|
||||
Returns:
|
||||
dict artifact_key -> exists (bool)
|
||||
"""
|
||||
try:
|
||||
pid = get_project_id(project_name)
|
||||
if pid is None:
|
||||
raise LookupError(f"project {project_name!r} not in DB")
|
||||
except (ValueError, LookupError):
|
||||
# Projekt nicht in DB → alle Artefakte fehlen
|
||||
return {k: False for k in MANDATORY_ARTIFACTS}
|
||||
|
||||
return {
|
||||
"worklog": len(list_worklog(pid, limit=1)) > 0,
|
||||
"todo": _has_working_todo_table(pid),
|
||||
"project_state": (
|
||||
get_kv(pid, "project_state") is not None
|
||||
or get_kv(pid, "phase") is not None
|
||||
),
|
||||
"current_status": get_status(pid) is not None,
|
||||
"next_steps": len(list_next_steps(pid, status="pending", limit=1)) > 0,
|
||||
}
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
"""ISO-8601 UTC timestamp."""
|
||||
return datetime.utcnow().isoformat() + "Z"
|
||||
|
||||
|
||||
def get_resume_payload(project_name: str) -> Dict[str, Any]:
|
||||
"""Liest alle Resume-relevanten Daten aus der DB für `block_resume.py`."""
|
||||
try:
|
||||
pid = get_project_id(project_name)
|
||||
except (ValueError, LookupError):
|
||||
pid = None
|
||||
|
||||
if pid is None:
|
||||
return {
|
||||
"project_name": project_name,
|
||||
"resume_exists": False,
|
||||
"conversation_summary_exists": False,
|
||||
"project_state": {},
|
||||
"next_steps": "",
|
||||
}
|
||||
|
||||
from .db_state_store import get_block_compact
|
||||
bc = get_block_compact(pid) or {}
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"project_name": project_name,
|
||||
"resume_exists": bool(bc.get("resume_md")),
|
||||
"conversation_summary_exists": bool(bc.get("conversation_summary_md")),
|
||||
"project_state": {
|
||||
"project_id": pid,
|
||||
"block_compact": bc,
|
||||
},
|
||||
"next_steps": list_next_steps(pid, status="pending"),
|
||||
}
|
||||
if bc.get("resume_md"):
|
||||
payload["resume"] = bc["resume_md"]
|
||||
if bc.get("conversation_summary_md"):
|
||||
payload["conversation_summary"] = bc["conversation_summary_md"]
|
||||
return payload
|
||||
Reference in New Issue
Block a user