feat(orchestrator): read the pkgmgr branch tree file instead of running git

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>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-07 06:51:49 +00:00
1 parent 9ef2fb6327
commit 912a3dc07f
17 files changed
+453 -251

No files matched your search

+5 -12
View File
@@ -44,10 +44,8 @@ services:
- /projects
- --templates
- /app/templates
- --repos
- "${KVB_ORCHESTRATOR_REPOS:?written into .env by scripts/install.sh}"
- --worktrees
- "${KVB_ORCHESTRATOR_WORKTREES:?written into .env by scripts/install.sh}"
- --tree-file
- /tree/branch-tree.json
- --timers
- /timers
- --llm-url
@@ -100,14 +98,9 @@ services:
bind:
create_host_path: false
- type: bind
source: "${KVB_ORCHESTRATOR_REPOS:?written into .env by scripts/install.sh}"
target: "${KVB_ORCHESTRATOR_REPOS:?written into .env by scripts/install.sh}"
read_only: true
bind:
create_host_path: false
- type: bind
source: "${KVB_ORCHESTRATOR_WORKTREES:?written into .env by scripts/install.sh}"
target: "${KVB_ORCHESTRATOR_WORKTREES:?written into .env by scripts/install.sh}"
# the directory, not the file: pkgmgr replaces the file, and a file mount keeps showing the old inode
source: "${KVB_ORCHESTRATOR_TREE:?written into .env by scripts/orchestrator.sh}"
target: /tree
read_only: true
bind:
create_host_path: false
+3 -5
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import math
import threading
from dataclasses import dataclass
from pathlib import Path
@@ -27,14 +26,13 @@ from .skill_scripts import Runner
from .sources import Sources
from .timeline import Timeline
from .transcripts import Transcripts
from .tree import Tree
from .tree import TREE_HEARTBEAT, Tree
from .view import View
@dataclass(frozen=True)
class Places:
repos: Path
worktrees: Path
tree_file: Path
timers: Path
@@ -99,7 +97,7 @@ def open_hub(council: Path, pages: Path, public_port: int, state: Path, host_sta
names=Names(state / "names.json"),
settings=Settings(state / "settings.json"),
watch=Watch(),
tree=Tree(repos=places.repos, worktrees=places.worktrees, home=state, lock=threading.Lock(), cached=None, scanned=-math.inf),
tree=Tree(file=places.tree_file, heartbeat=state / TREE_HEARTBEAT, lock=threading.Lock(), cached=None, stamp=None),
model=model,
timers=places.timers,
council=council,
+78 -119
View File
@@ -1,121 +1,74 @@
from __future__ import annotations
import os
import re
import subprocess
import json
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from .council import agent
from .records import iso_epoch
from .register import ENDED, status_of
from .skill_scripts import SAFE_PATH
TREE_TTL = 60.0
TREE_DEPTH = 6
GIT_TIMEOUT = 10
LABEL_PARTS = 3
# renaming it silently stops the tree timer: kvb-orchestrator-tree.service tests this file's age
TREE_HEARTBEAT = "tree-heartbeat"
def git(cwd: Path, args: list[str], home: Path) -> str | None:
env = {"PATH": SAFE_PATH, "HOME": str(home), "GIT_CONFIG_NOSYSTEM": "1", "GIT_NO_LAZY_FETCH": "1", "GIT_OPTIONAL_LOCKS": "0", "GIT_TERMINAL_PROMPT": "0", "LC_ALL": "C"}
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:
done = subprocess.run(
["git", "-c", "safe.directory=*", "-c", "core.fsmonitor=false", "-C", str(cwd), *args],
capture_output=True,
encoding="utf-8",
errors="replace",
timeout=GIT_TIMEOUT,
env=env,
stdin=subprocess.DEVNULL,
check=False,
)
except (OSError, subprocess.TimeoutExpired):
return None
return done.stdout if done.returncode == 0 else None
def parse_worktrees(text: str) -> list[dict]:
found = []
for block in text.strip().split("\n\n"):
lines = dict(line.partition(" ")[::2] for line in block.splitlines() if line)
if "worktree" not in lines or "bare" in lines:
continue
found.append({
"path": lines["worktree"], "head": lines.get("HEAD", ""), "branch": lines.get("branch", "").removeprefix("refs/heads/"),
"prunable": "prunable" in lines,
})
return found
def tracking(text: str) -> tuple[int, int, bool]:
counts = {name: int(number) for name, number in re.findall(r"(ahead|behind) ([0-9]+)", text)}
return counts.get("ahead", 0), counts.get("behind", 0), text.strip() == "gone"
def parse_refs(text: str) -> list[dict]:
found = []
for line in text.splitlines():
parts = line.split("\x00")
if len(parts) != 4:
continue
ahead, behind, gone = tracking(parts[3])
found.append({"name": parts[0], "head": parts[1], "upstream": parts[2], "ahead": ahead, "behind": behind, "gone": gone})
return found
def linked(root: Path) -> list[Path]:
found = []
for top, dirs, names in os.walk(root):
here = Path(top)
if ".git" in names or ".git" in dirs:
found.append(here)
dirs[:] = []
elif len(here.relative_to(root).parts) >= TREE_DEPTH:
dirs[:] = []
else:
dirs.sort()
return found
def dirty(path: Path, home: Path) -> bool | None:
# agents write these repos, and status runs every filter.*.clean command the config names
if git(path, ["config", "--get-regexp", r"^filter\."], home) is not None:
return None
status = git(path, ["status", "--porcelain=v1", "--untracked-files=normal", "--ignore-submodules=all"], home)
return None if status is None else bool(status.strip())
def repo_entry(path: Path, label: str, home: Path) -> dict:
trees = parse_worktrees(git(path, ["worktree", "list", "--porcelain"], home) or "")
refs = git(path, ["for-each-ref", "--format=%(refname:short)%00%(objectname)%00%(upstream:short)%00%(upstream:track,nobracket)", "refs/heads"], home)
branches = parse_refs(refs or "")
for tree in trees:
tree["dirty"] = None if tree["prunable"] else dirty(Path(tree["path"]), home)
known = {branch["name"] for branch in branches}
branches += [{"name": tree["branch"], "head": tree["head"], "upstream": "", "ahead": 0, "behind": 0, "gone": False} for tree in trees if tree["branch"] and tree["branch"] not in known]
for branch in branches:
branch["worktrees"] = [tree for tree in trees if tree["branch"] == branch["name"]]
return {"path": str(path), "name": label, "branches": branches, "detached": [tree for tree in trees if not tree["branch"]]}
def scan_tree(repos: Path, worktrees: Path, home: Path) -> dict:
root = repos.resolve()
try:
mains = sorted(path.resolve() for path in root.glob("*/*/*") if (path / ".git").is_dir())
except OSError:
mains = []
entries = {str(path): repo_entry(path, str(path.relative_to(root)), home) for path in mains if path.is_relative_to(root)}
seen = {tree["path"] for entry in entries.values() for branch in entry["branches"] for tree in branch["worktrees"]}
seen |= {tree["path"] for entry in entries.values() for tree in entry["detached"]}
for path in linked(worktrees.resolve()):
if str(path) in seen:
continue
common = git(path, ["rev-parse", "--path-format=absolute", "--git-common-dir"], home)
main = Path(common.strip()).parent if common else None
if main is not None and str(main) not in entries and (main / ".git").is_dir():
entries[str(main)] = repo_entry(main, str(main), home)
return {"repos": sorted(entries.values(), key=lambda entry: entry["name"]), "scanned": time.time()}
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:
@@ -134,34 +87,40 @@ def assign(scan: dict, tasks: dict[str, dict], claims: list[dict], reviews: list
repos = []
for entry in scan["repos"]:
branches = []
for branch in entry["branches"]:
for item in entry["branches"]:
trees = []
for tree in branch["worktrees"]:
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 == branch["name"]
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({**branch, "worktrees": trees})
repos.append({**entry, "branches": branches})
return {"repos": repos, "scanned": scan["scanned"]}
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:
repos: Path
worktrees: Path
home: Path
file: Path
heartbeat: Path
lock: threading.Lock
cached: dict | None
scanned: float
stamp: tuple[int, int] | None
def beat(self) -> None:
self.heartbeat.touch()
def get(self) -> dict:
with self.lock:
if self.cached is None or time.monotonic() - self.scanned >= TREE_TTL:
self.cached = scan_tree(self.repos, self.worktrees, self.home)
self.scanned = time.monotonic()
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
+1
View File
@@ -60,6 +60,7 @@ class View:
sampled = single(query, "track")
if sampled not in (None, "1"):
raise ToolError("track must be 1 or left out")
self.tree.beat()
return self.state(begin, end, now, TRACK_STEPS if sampled else 0)
def first(self, news: list[dict], history: list[dict], claims: list[dict], now: float) -> float:
+4 -6
View File
@@ -19,10 +19,8 @@
--transcripts: directory the Claude Code transcripts (*.jsonl) lie under.
--templates: directory holding index.html.j2 and the *.js modules it
includes.
--repos: directory holding the repositories as <host>/<owner>/<repo>,
mounted read-only at its host path.
--worktrees: directory holding linked worktrees, mounted read-only at its
host path.
--tree-file: the JSON file `pkgmgr branch tree --output` writes, mounted
read-only; read again whenever it changes.
--timers: directory holding the systemd user units (*.timer).
--llm-url: base URL of an OpenAI-compatible chat endpoint; empty turns the
natural-language command line off.
@@ -63,7 +61,7 @@ def main() -> int:
parser.add_argument("--bind", required=True)
for name in ("--listen-port", "--public-port"):
parser.add_argument(name, type=int, required=True)
paths = ("--council", "--pages", "--state", "--host-state", "--skills", "--transcripts", "--templates", "--repos", "--worktrees", "--timers", "--llm-key-file", "--approvers")
paths = ("--council", "--pages", "--state", "--host-state", "--skills", "--transcripts", "--templates", "--tree-file", "--timers", "--llm-key-file", "--approvers")
for name in (*paths, "--piper", "--voices", "--whisper", "--whisper-model", "--scratch"):
parser.add_argument(name, type=Path, required=True)
for name in ("--llm-url", "--llm-model", "--operator-voice"):
@@ -79,7 +77,7 @@ def main() -> int:
transcripts=args.transcripts,
scripts=Scripts(skills=args.skills, council=args.council, home=args.state),
templates=args.templates,
places=Places(repos=args.repos, worktrees=args.worktrees, timers=args.timers),
places=Places(tree_file=args.tree_file, timers=args.timers),
model=Model(url=args.llm_url.strip(), model=args.llm_model.strip(), key=read_key(args.llm_key_file)),
engines=Engines(piper=args.piper, voices=args.voices, operator_voice=args.operator_voice, whisper=args.whisper, whisper_model=args.whisper_model, scratch=args.scratch),
approvers=args.approvers,
@@ -0,0 +1,11 @@
[Unit]
Description=Agent Orchestrator tree: pkgmgr branch tree into the file the orchestrator reads, while its page polls
[Service]
Type=oneshot
Environment=PATH=/usr/bin:/bin
ExecCondition=/bin/sh -c 'test -n "$(find %h/.local/state/kvb-orchestrator/tree-heartbeat -mmin -5 2>/dev/null)"'
ExecStart=%h/.local/share/kvb-orchestrator/pkgmgr/bin/pkgmgr branch tree --format json --output %h/.local/state/pkgmgr/tree/branch-tree.json
Nice=10
IOSchedulingClass=idle
TimeoutStartSec=55
@@ -0,0 +1,9 @@
[Unit]
Description=Agent Orchestrator tree every 60 s
[Timer]
OnCalendar=minutely
AccuracySec=1s
[Install]
WantedBy=timers.target
+3 -2
View File
@@ -45,8 +45,9 @@
if (!tree.dom) {
return;
}
if (!tree.data) {
tree.dom.list.replaceChildren(emptyState(tree.busy ? "Lese Repositories …" : "Kein Tree", "Die Repositories werden höchstens jede Minute neu gelesen."));
if (!tree.data || tree.data.error) {
tree.dom.count.textContent = "";
tree.dom.list.replaceChildren(emptyState(tree.busy ? "Lese Repositories …" : "Kein Tree", tree.data ? tree.data.error : "pkgmgr schreibt den Tree jede Minute, solange diese Seite offen ist."));
return;
}
const q = tree.q.trim().toLowerCase();