5769c1cd22
- 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
470 lines
18 KiB
Python
470 lines
18 KiB
Python
"""Tool: block_compactor – Block-Compact-Protokoll ausführen (DB + Repo-Sync).
|
||
|
||
Nach jedem abgeschlossenen Block:
|
||
1. Snapshot in DB (orch_snapshots)
|
||
2. Block-Compact-Marker in DB (orch_block_compact)
|
||
3. Worklog-Eintrag in DB (orch_worklog)
|
||
4. History-Snapshot in Git-Repo (docs/projects/<name>/snapshots/<ts>.json)
|
||
(nur wenn project_repo_root übergeben wird)
|
||
|
||
Wird am Block-Ende aufgerufen, BEVOR dem User der Status gemeldet wird.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from helpers.tool import Tool, Response
|
||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
||
get_project_id, now_iso, get_kv, set_kv,
|
||
append_worklog, set_status, add_next_step,
|
||
set_block_compact, get_block_compact,
|
||
create_snapshot, collect_full_state,
|
||
list_kv_keys, get_status,
|
||
)
|
||
from usr.plugins.a0_software_orchestrator.helpers.compact_protocol import (
|
||
MANDATORY_ARTIFACTS, get_block_compact_config,
|
||
should_block_compact, check_mandatory_artifacts,
|
||
)
|
||
from usr.plugins.a0_software_orchestrator.helpers.context_budget import estimate_ratio
|
||
from usr.plugins.a0_software_orchestrator.helpers.exceptions import PluginDBError
|
||
|
||
|
||
def _atomic_write(path: str, content: str) -> None:
|
||
"""Schreibt atomar: erst temp-File, dann rename."""
|
||
try:
|
||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||
tmp = path + ".tmp"
|
||
with open(tmp, "w", encoding="utf-8") as f:
|
||
f.write(content)
|
||
os.replace(tmp, path)
|
||
except OSError as e:
|
||
raise PluginDBError(f"Atomic write failed for {path}: {e}") from e
|
||
|
||
|
||
def _build_resume_md(
|
||
project_name: str,
|
||
block_id: str,
|
||
block_summary: str,
|
||
next_block: str,
|
||
artifacts_state: Dict[str, bool],
|
||
) -> str:
|
||
"""Baut den Inhalt für resume.md (History-Snapshot im Repo)."""
|
||
artifact_lines = "\n".join(
|
||
f"- [{'x' if ok else ' '}] {k}" for k, ok in artifacts_state.items()
|
||
)
|
||
return (
|
||
f"# Resume Marker – {project_name}\n\n"
|
||
f"- **Last completed block**: `{block_id}`\n"
|
||
f"- **Completed at**: {now_iso()}\n"
|
||
f"- **Next block**: {next_block}\n\n"
|
||
f"## What was done in this block\n\n"
|
||
f"{block_summary.strip() or '_no summary provided_'}\n\n"
|
||
f"## Mandatory artifacts state at compact time\n\n"
|
||
f"{artifact_lines}\n\n"
|
||
f"## How to resume (read in this order)\n\n"
|
||
f"1. `db.orch_block_compact[project_id]` – current block marker (DB)\n"
|
||
f"2. `db.orch_status[project_id]` – one-line status (DB)\n"
|
||
f"3. `db.orch_next_steps[project_id]` – pending next steps (DB)\n"
|
||
f"4. `docs/projects/{project_name}/snapshots/<latest>.json` – full state (Repo)\n"
|
||
f"5. `db.orch_worklog[project_id]` – detailed block history (DB)\n\n"
|
||
f"## Guardrails on resume\n\n"
|
||
f"- Do NOT re-run subagents for blocks already marked done in worklog.\n"
|
||
f"- Re-validate artifacts; missing items = block re-open.\n"
|
||
f"- If `orch_next_steps` is empty, ask the user before continuing.\n"
|
||
)
|
||
|
||
|
||
def _build_conversation_summary_md(
|
||
block_id: str,
|
||
block_summary: str,
|
||
decisions: Optional[List[str]] = None,
|
||
open_questions: Optional[List[str]] = None,
|
||
key_findings: Optional[List[str]] = None,
|
||
max_lines: int = 200,
|
||
) -> str:
|
||
"""Baut den Inhalt für conversation_summary.md (History-Snapshot im Repo)."""
|
||
decisions = decisions or []
|
||
open_questions = open_questions or []
|
||
key_findings = key_findings or []
|
||
|
||
sections: List[str] = [
|
||
f"# Conversation Summary – Block `{block_id}`",
|
||
"",
|
||
f"_Generated: {now_iso()}_",
|
||
"",
|
||
"## Block summary",
|
||
"",
|
||
(block_summary.strip() or "_no summary provided_"),
|
||
"",
|
||
"## Key decisions",
|
||
"",
|
||
]
|
||
if decisions:
|
||
sections.extend(f"- {d}" for d in decisions)
|
||
else:
|
||
sections.append("_none recorded_")
|
||
sections.append("")
|
||
sections.append("## Key findings")
|
||
sections.append("")
|
||
if key_findings:
|
||
sections.extend(f"- {f}" for f in key_findings)
|
||
else:
|
||
sections.append("_none recorded_")
|
||
sections.append("")
|
||
sections.append("## Open questions")
|
||
sections.append("")
|
||
if open_questions:
|
||
sections.extend(f"- {q}" for q in open_questions)
|
||
else:
|
||
sections.append("_none recorded_")
|
||
sections.append("")
|
||
|
||
raw = "\n".join(sections)
|
||
lines = raw.splitlines()
|
||
if len(lines) <= max_lines:
|
||
return raw
|
||
head = lines[: max_lines - 1]
|
||
return "\n".join(head + [f"... [truncated to {max_lines} lines] ..."])
|
||
|
||
|
||
def _sync_to_repo(
|
||
project_name: str,
|
||
project_repo_root: str,
|
||
block_id: str,
|
||
block_summary: str,
|
||
next_block: str,
|
||
decisions: Optional[List[str]],
|
||
key_findings: Optional[List[str]],
|
||
open_questions: Optional[List[str]],
|
||
artifacts_state: Dict[str, bool],
|
||
state_snapshot: Dict[str, Any],
|
||
) -> Dict[str, str]:
|
||
"""Schreibt History-Snapshots in das Git-Repo.
|
||
|
||
Layout:
|
||
<project_repo_root>/
|
||
├── docs/projects/<project_name>/
|
||
│ ├── snapshots/<ts>.json – vollständiger State
|
||
│ ├── worklog.md – append-only (im Block-Eintrag)
|
||
│ ├── decisions.md – append-only (wenn decisions)
|
||
│ ├── resume.md – letzter Resume-Marker
|
||
│ └── conversation_summary.md – letzte komprimierte Zusammenfassung
|
||
|
||
Returns dict mit den geschriebenen Pfaden.
|
||
"""
|
||
docs_dir = os.path.join(
|
||
project_repo_root, "docs", "projects", project_name
|
||
)
|
||
snap_dir = os.path.join(docs_dir, "snapshots")
|
||
os.makedirs(snap_dir, exist_ok=True)
|
||
|
||
paths: Dict[str, str] = {}
|
||
|
||
# 1. JSON-Snapshot
|
||
ts_safe = now_iso().replace(":", "-")
|
||
snap_path = os.path.join(snap_dir, f"{ts_safe}.json")
|
||
_atomic_write(
|
||
snap_path,
|
||
json.dumps(
|
||
{
|
||
"block_id": block_id,
|
||
"next_block": next_block,
|
||
"block_summary": block_summary,
|
||
"decisions": decisions or [],
|
||
"key_findings": key_findings or [],
|
||
"open_questions": open_questions or [],
|
||
"artifacts_state": artifacts_state,
|
||
"state": state_snapshot,
|
||
"snapshot_at": now_iso(),
|
||
},
|
||
indent=2, ensure_ascii=False, default=str,
|
||
),
|
||
)
|
||
paths["snapshot"] = snap_path
|
||
|
||
# 2. worklog.md (append-only)
|
||
worklog_path = os.path.join(docs_dir, "worklog.md")
|
||
if not os.path.exists(worklog_path):
|
||
_atomic_write(worklog_path, f"# Worklog – {project_name}\n\n")
|
||
with open(worklog_path, "a", encoding="utf-8") as f:
|
||
f.write(
|
||
f"\n## Block `{block_id}` @ {now_iso()}\n\n"
|
||
f"- **next**: {next_block}\n"
|
||
f"- **summary**: {block_summary.strip() or '_none_'}\n"
|
||
)
|
||
for d in (decisions or []):
|
||
f.write(f"- **decision**: {d}\n")
|
||
for f_ in (key_findings or []):
|
||
f.write(f"- **finding**: {f_}\n")
|
||
for q in (open_questions or []):
|
||
f.write(f"- **question**: {q}\n")
|
||
paths["worklog"] = worklog_path
|
||
|
||
# 3. decisions.md (append-only, eigene Datei für Architektur-Entscheidungen)
|
||
if decisions:
|
||
decisions_path = os.path.join(docs_dir, "decisions.md")
|
||
if not os.path.exists(decisions_path):
|
||
_atomic_write(decisions_path, f"# Decisions – {project_name}\n\n")
|
||
with open(decisions_path, "a", encoding="utf-8") as f:
|
||
f.write(f"\n## Block `{block_id}` @ {now_iso()}\n\n")
|
||
for d in decisions:
|
||
f.write(f"- {d}\n")
|
||
paths["decisions"] = decisions_path
|
||
|
||
# 4. resume.md (letzter Marker, überschreibt)
|
||
resume_path = os.path.join(docs_dir, "resume.md")
|
||
_atomic_write(
|
||
resume_path,
|
||
_build_resume_md(
|
||
project_name=project_name,
|
||
block_id=block_id,
|
||
block_summary=block_summary,
|
||
next_block=next_block,
|
||
artifacts_state=artifacts_state,
|
||
),
|
||
)
|
||
paths["resume"] = resume_path
|
||
|
||
# 5. conversation_summary.md (überschreibt, max 200 Zeilen)
|
||
summary_path = os.path.join(docs_dir, "conversation_summary.md")
|
||
_atomic_write(
|
||
summary_path,
|
||
_build_conversation_summary_md(
|
||
block_id=block_id,
|
||
block_summary=block_summary,
|
||
decisions=decisions,
|
||
key_findings=key_findings,
|
||
open_questions=open_questions,
|
||
),
|
||
)
|
||
paths["conversation_summary"] = summary_path
|
||
|
||
return paths
|
||
|
||
|
||
class BlockCompactor(Tool):
|
||
"""Sichert Block-State am Ende eines abgeschlossenen Aufgabenblocks.
|
||
|
||
Args (kwargs):
|
||
project_name (str): Projektname (bevorzugt).
|
||
project_root (str): Legacy – wird zu basename() aufgelöst.
|
||
project_repo_root (str): Pfad zum Git-Repo (für History-Sync).
|
||
block_id (str): Eindeutige Block-ID (z.B. "T012", "B-requirements-v1").
|
||
block_summary (str): Was wurde gemacht? (Pflicht)
|
||
next_block (str): Was kommt als nächstes? (default: siehe next_steps)
|
||
decisions (list[str]): Wichtige Entscheidungen.
|
||
open_questions (list[str]): Offene Fragen.
|
||
key_findings (list[str]): Wichtige Erkenntnisse.
|
||
estimated_context_tokens (int): Für Token-Ratio-Berechnung.
|
||
max_context_tokens (int): Default 8000.
|
||
open_subagent_calls (int): Default 0.
|
||
quality_gate_passed (bool): Default True.
|
||
user_gave_new_instruction (bool): Default False.
|
||
force (bool): Default False. Nur Token-Schwelle, nicht Preconditions.
|
||
"""
|
||
|
||
async def execute(
|
||
self,
|
||
project_name: str = "",
|
||
project_root: str = "",
|
||
project_repo_root: str = "",
|
||
block_id: str = "",
|
||
block_summary: str = "",
|
||
next_block: str = "",
|
||
decisions: Optional[List[str]] = None,
|
||
open_questions: Optional[List[str]] = None,
|
||
key_findings: Optional[List[str]] = None,
|
||
estimated_context_tokens: int = 0,
|
||
max_context_tokens: int = 8000,
|
||
open_subagent_calls: int = 0,
|
||
quality_gate_passed: bool = True,
|
||
user_gave_new_instruction: bool = False,
|
||
force: bool = False,
|
||
**kwargs,
|
||
):
|
||
# Bug-Fix: _resolve_name entfernt (basename-Fallback erzeugte
|
||
# potenziell ungültige/Phantom-Namen). project_name ist jetzt Pflicht.
|
||
if not project_name or not project_name.strip():
|
||
return Response(
|
||
message="ERROR: project_name is required (basename-fallback removed; "
|
||
"pass explicit project_name or registered name)",
|
||
break_loop=False,
|
||
)
|
||
name = project_name.strip()
|
||
try:
|
||
pid = get_project_id(name)
|
||
if pid is None:
|
||
raise LookupError(f"project '{name}' is not registered")
|
||
except (ValueError, LookupError) as e:
|
||
return Response(message=f"ERROR: {e}", break_loop=False)
|
||
except PluginDBError as e:
|
||
return Response(message=f"Database error: {e}", break_loop=False)
|
||
|
||
if not block_id:
|
||
return Response(
|
||
message="ERROR: block_id is required (e.g. 'T012', 'B-requirements-v1')",
|
||
break_loop=False,
|
||
)
|
||
if not block_summary:
|
||
return Response(
|
||
message="ERROR: block_summary is required",
|
||
break_loop=False,
|
||
)
|
||
|
||
# 1. Token-Ratio
|
||
ratio = (
|
||
estimate_ratio(int(estimated_context_tokens), int(max_context_tokens))
|
||
if estimated_context_tokens and max_context_tokens
|
||
else 0.0
|
||
)
|
||
|
||
# 2. Preconditions prüfen
|
||
decision = should_block_compact(
|
||
project_name=name,
|
||
estimated_ratio=ratio,
|
||
open_subagent_calls=int(open_subagent_calls),
|
||
quality_gate_passed=bool(quality_gate_passed),
|
||
user_gave_new_instruction=bool(user_gave_new_instruction),
|
||
)
|
||
|
||
if not decision["allowed"] and not force:
|
||
return Response(
|
||
message=(
|
||
f"BLOCK COMPACT BLOCKED: {decision['reason']}\n"
|
||
f"Fix preconditions first, then retry. "
|
||
f"Use force=true to override token threshold only (NOT preconditions)."
|
||
),
|
||
break_loop=False,
|
||
)
|
||
if not decision["allowed"] and force:
|
||
# Bei force=True: Token-Schwelle darf überfahren werden,
|
||
# ANDERE Preconditions (quality, subagents, user, artifacts, next_steps)
|
||
# NICHT.
|
||
reason = decision.get("reason", "")
|
||
if not reason.startswith("context ratio"):
|
||
return Response(
|
||
message=(
|
||
f"BLOCK COMPACT BLOCKED: {reason}\n"
|
||
f"force=true may ONLY override the token-ratio precondition."
|
||
),
|
||
break_loop=False,
|
||
)
|
||
|
||
# 3. Pflicht-Artefakte prüfen (Option B / Soft-Check)
|
||
# WARN statt BLOCK: Tool schreibt trotzdem, User sieht Hinweise.
|
||
# Hard-Block bleibt nur für: Token-Ratio, Subagent > 0,
|
||
# Quality-Gate fail (via should_block_compact oben).
|
||
artifacts = check_mandatory_artifacts(name)
|
||
missing = [k for k, v in artifacts.items() if not v]
|
||
warnings: List[str] = []
|
||
if missing:
|
||
warnings.append(f"mandatory artifacts incomplete: {missing}")
|
||
|
||
# 4. next_block: aus DB holen wenn nicht angegeben
|
||
if not next_block or next_block == "see next_steps.md":
|
||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import list_next_steps
|
||
pending = list_next_steps(pid, status="pending")
|
||
if pending:
|
||
next_block = pending[0]["content"]
|
||
else:
|
||
next_block = "(none defined)"
|
||
|
||
# 5. Snapshot in DB
|
||
state_snapshot = collect_full_state(pid)
|
||
snap_id = create_snapshot(
|
||
pid, trigger="block_compact", state=state_snapshot
|
||
)
|
||
|
||
# 6. Block-Compact-Marker in DB
|
||
set_block_compact(
|
||
pid,
|
||
block_id=block_id,
|
||
next_block=next_block,
|
||
block_summary=block_summary,
|
||
decisions=decisions,
|
||
key_findings=key_findings,
|
||
open_questions=open_questions,
|
||
resume_md=_build_resume_md(
|
||
name, block_id, block_summary, next_block, artifacts
|
||
),
|
||
conversation_summary_md=_build_conversation_summary_md(
|
||
block_id, block_summary, decisions, open_questions, key_findings
|
||
),
|
||
context_ratio=ratio,
|
||
)
|
||
|
||
# 7. Worklog-Eintrag
|
||
append_worklog(
|
||
pid,
|
||
phase=get_kv(pid, "phase"),
|
||
work_block=block_id,
|
||
agent="block_compactor",
|
||
summary=f"Block compact: {block_summary[:200]}",
|
||
details=json.dumps({
|
||
"next_block": next_block,
|
||
"decisions": decisions or [],
|
||
"key_findings": key_findings or [],
|
||
"open_questions": open_questions or [],
|
||
"context_ratio": round(ratio, 3),
|
||
"snapshot_id": snap_id,
|
||
}, default=str),
|
||
)
|
||
|
||
# 8. History-Sync in Git-Repo (optional, wenn project_repo_root gegeben)
|
||
repo_paths: Dict[str, str] = {}
|
||
if project_repo_root and os.path.isdir(project_repo_root):
|
||
try:
|
||
repo_paths = _sync_to_repo(
|
||
project_name=name,
|
||
project_repo_root=project_repo_root,
|
||
block_id=block_id,
|
||
block_summary=block_summary,
|
||
next_block=next_block,
|
||
decisions=decisions,
|
||
key_findings=key_findings,
|
||
open_questions=open_questions,
|
||
artifacts_state=artifacts,
|
||
state_snapshot=state_snapshot,
|
||
)
|
||
except Exception as exc:
|
||
repo_paths = {"_error": repr(exc)}
|
||
|
||
# 9. Status zurückgeben
|
||
forced = bool(decision.get("forced"))
|
||
forced_marker = " [FORCED]" if forced else ""
|
||
|
||
repo_summary = ""
|
||
if repo_paths and "_error" not in repo_paths:
|
||
repo_summary = (
|
||
f"\n- repo snapshot: `{repo_paths.get('snapshot', '?')}`"
|
||
f"\n- repo worklog: `{repo_paths.get('worklog', '?')}`"
|
||
f"\n- repo resume: `{repo_paths.get('resume', '?')}`"
|
||
)
|
||
elif "_error" in repo_paths:
|
||
repo_summary = f"\n- repo sync FAILED: {repo_paths['_error']}"
|
||
else:
|
||
repo_summary = "\n- repo sync: SKIPPED (no project_repo_root)"
|
||
|
||
return Response(
|
||
message=(
|
||
f"BLOCK COMPACT READY{forced_marker}\n\n"
|
||
f"- project: `{name}` (pid={pid})\n"
|
||
f"- block_id: `{block_id}`\n"
|
||
f"- next_block: {next_block}\n"
|
||
f"- db snapshot_id: {snap_id}\n"
|
||
f"- context_ratio: {ratio:.2f}\n"
|
||
f"- mandatory_artifacts: {sum(artifacts.values())}/{len(artifacts)} "
|
||
+ ("✅" if not missing else f"⚠️ missing: {missing}")
|
||
+ (f"\n- warnings:\n - " + "\n - ".join(warnings) if warnings else "")
|
||
+ f"{repo_summary}\n\n"
|
||
f"NEXT for the orchestrator:\n"
|
||
f"1. Confirm all 5 mandatory artifacts present.\n"
|
||
f"2. Provide user with max 30-line status report.\n"
|
||
f"3. On resume in a new chat, call `block_resume` with project_name={name!r}.\n"
|
||
f"4. If this is the 3rd completed block, run `quality_gate` with action='run' and category='release_auditor' to enforce release auditing."
|
||
),
|
||
break_loop=False,
|
||
)
|