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:
1 parent
9ef2fb6327
commit
912a3dc07f
17 files changed
+453
-251
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user