Phase 3 of requirement 001. The orchestrator no longer starts git: orchestrator/kvb_orchestrator/tree.py maps the document that pkgmgr branch tree writes (repos, branches, worktrees, detached heads, delete_ready) onto the Tree view, re-reads it when mtime or size change, and reports a missing or broken file as an empty tree with the error. compose.yml mounts the tree directory read-only (a file mount would keep the replaced inode). /api/state touches state/tree-heartbeat; kvb-orchestrator-tree.timer runs every minute and its oneshot service runs only while that heartbeat is younger than 5 minutes, with PATH=/usr/bin:/bin and the pkgmgr venv that the install builds from the newest v* tag a fully trusted GPG key signed, cloned fresh from PKGMGR_REPO because the local checkout's config is sandbox-writable. Dialectic verdict 2026-11-06 (2 rounds, 5 skeptics, ~85 %): hardened git runner in pkgmgr, gc classification shared with the tree scan, forge facts only from an unlocked Secret Service token with a 600 s cache, atomic tree output refreshed by open/close/drop/gc, host units outside sandbox-writable paths, orchestrator reads the file only. Losing objections: extending the host runner (it would run bash -c on signed text), a new payload kind, copying the pkgmgr checkout, a tree limited to .worktrees. Operator answers: signed-tag venv, UI delete keeps volumes and is local only, ignored files beyond known caches block a delete, scan only while the page polls. Delete jobs, gc_item signing and the UI delete path follow in phase 4. MEDUSA acceptances (kvb-think-criticize 2026-11-07, 1 skeptic): MEDUSA-MM-SCAN-058 orchestrator/server.py:64 and :80 false positives (case-insensitive substring pla in --templates and Places; --tree-file is a read-only JSON file, no model loading). 🎯 Fix: 93 % 💥 Break: 4 % 💸 Cost: – ⏱️ Time: – 🛡️ Security: 100 % 🏆 Score: 89 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
127 lines
4.7 KiB
Python
127 lines
4.7 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import threading
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from .council import agent
|
|
from .records import iso_epoch
|
|
from .register import ENDED, status_of
|
|
|
|
LABEL_PARTS = 3
|
|
# renaming it silently stops the tree timer: kvb-orchestrator-tree.service tests this file's age
|
|
TREE_HEARTBEAT = "tree-heartbeat"
|
|
|
|
|
|
def worktree(item: dict) -> dict:
|
|
state = item["worktree_state"]
|
|
unread = state == "changed" and item["staged"] is None
|
|
return {
|
|
"path": item["worktree"],
|
|
"head": item["head"] or "",
|
|
"branch": item["branch"] or "",
|
|
"prunable": state == "missing",
|
|
"dirty": None if state == "missing" or unread else state == "changed",
|
|
"state": state,
|
|
"staged": item["staged"],
|
|
"unstaged": item["unstaged"],
|
|
"untracked": item["untracked"],
|
|
"locked": item["locked"],
|
|
}
|
|
|
|
|
|
def branch(item: dict) -> dict:
|
|
remote = next(iter(item["remotes"].values()), "")
|
|
return {
|
|
"name": item["branch"],
|
|
"head": item["local_sha"] or item["head"] or remote,
|
|
"upstream": item["upstream"] or "",
|
|
"ahead": item["ahead"] or 0,
|
|
"behind": item["behind"] or 0,
|
|
"gone": item["gone"],
|
|
"state": item["state"],
|
|
"merged_by": item["merged_by"],
|
|
"skip": item["skip"],
|
|
"reason": item["reason"],
|
|
"worktrees": [worktree(item)] if item["worktree"] else [],
|
|
}
|
|
|
|
|
|
def repo(entry: dict) -> dict:
|
|
items = entry["items"]
|
|
return {
|
|
**{key: value for key, value in entry.items() if key != "items"},
|
|
"name": "/".join(Path(entry["path"]).parts[-LABEL_PARTS:]),
|
|
"branches": [branch(item) for item in items if item["branch"]],
|
|
"detached": [worktree(item) for item in items if item["detached"]],
|
|
}
|
|
|
|
|
|
def read_tree(path: Path) -> dict:
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
return {
|
|
"repos": sorted((repo(entry) for entry in data["repos"]), key=lambda entry: entry["name"]),
|
|
"scanned": iso_epoch(data["generated_at"]),
|
|
"delete_ready": data["delete_ready"],
|
|
"delete_reason": data["delete_reason"],
|
|
}
|
|
except (OSError, ValueError, KeyError, TypeError, AttributeError) as error:
|
|
return {"repos": [], "scanned": None, "error": f"pkgmgr tree file {path} unreadable: {error}"}
|
|
|
|
|
|
def inside(path: str, folder: str) -> bool:
|
|
return path == folder or path.startswith(f"{folder.rstrip('/')}/")
|
|
|
|
|
|
def assign(scan: dict, tasks: dict[str, dict], claims: list[dict], reviews: list[dict]) -> dict:
|
|
current = {task: row for task, row in tasks.items() if status_of(row) not in ENDED}
|
|
holders: dict[str, set[str]] = {}
|
|
for task, row in current.items():
|
|
names = {agent(row.get("worker"))} | {claim["agent"] for claim in claims if claim["office"] == task}
|
|
holders[task] = {name for name in names if name}
|
|
latest: dict[str, dict] = {}
|
|
for review in reviews:
|
|
latest[review["worktree"]] = {"status": review["status"], "gate": review.get("gate", ""), "title": review["title"]}
|
|
repos = []
|
|
for entry in scan["repos"]:
|
|
branches = []
|
|
for item in entry["branches"]:
|
|
trees = []
|
|
for tree in item["worktrees"]:
|
|
workers = set()
|
|
for task, row in current.items():
|
|
workdir, name = row.get("workdir"), row.get("branch")
|
|
by_dir = isinstance(workdir, str) and workdir.startswith("/") and inside(workdir, tree["path"])
|
|
by_branch = not isinstance(workdir, str) and name == item["name"]
|
|
if by_dir or by_branch:
|
|
workers |= holders[task]
|
|
trees.append({**tree, "workers": sorted(workers, key=str.lower), "review": latest.get(tree["path"])})
|
|
branches.append({**item, "worktrees": trees})
|
|
repos.append({**entry, "branches": branches, "detached": [{**tree, "workers": [], "review": latest.get(tree["path"])} for tree in entry["detached"]]})
|
|
return {**scan, "repos": repos}
|
|
|
|
|
|
@dataclass
|
|
class Tree:
|
|
file: Path
|
|
heartbeat: Path
|
|
lock: threading.Lock
|
|
cached: dict | None
|
|
stamp: tuple[int, int] | None
|
|
|
|
def beat(self) -> None:
|
|
self.heartbeat.touch()
|
|
|
|
def get(self) -> dict:
|
|
with self.lock:
|
|
try:
|
|
info = self.file.stat()
|
|
except OSError as error:
|
|
return {"repos": [], "scanned": None, "error": f"no pkgmgr tree file at {self.file}: {error.strerror}"}
|
|
stamp = (info.st_mtime_ns, info.st_size)
|
|
if self.cached is None or stamp != self.stamp:
|
|
self.cached, self.stamp = read_tree(self.file), stamp
|
|
return self.cached
|