Files
a0_software_orchestrator/tools/block_compactor.py
T
Software Orchestrator 5769c1cd22 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
2026-06-16 22:13:06 +00:00

470 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
)