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,469 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in New Issue
Block a user