Merge branch 'feature/agent-orchestrator/tree'
🧪 Test / 🧪 Lock + lint (push) Waiting to run

🎯 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 09:20:41 +00:00
commit 6b4a8aca50
17 files changed
+453 -251

No files matched your search

+2 -2
View File
@@ -399,9 +399,9 @@ make project TARGET=/path/to/repo
Both commands also patch the target's `.claude/settings.local.json` — the machine-local, untracked file, so a project's reviewed `.claude/settings.json` is never rewritten by an install: the caveman and ponytail plugins are enabled so their modes auto-activate on session start, and two reminder hooks are registered — `kvb-dream` points each agent at `/kvb-dream` at most once every twelve hours (stamped per session in `~/.claude/.dream-stamps/`), and `kvb-coach` runs its short form at the first prompt of every full hour, once across all agents (stamped in `~/.claude/.coach-hours/`); the global install also enables the `kvb-dream-notify.timer` systemd user timer, which recommends `/kvb-dream` in a GNOME notification daily at 17:00 local time and catches up once after a missed run. A PreToolUse gate from the `kvb-org-organize` skill blocks edits to files in a git worktree until the session holds a claim (`skills/kvb-org-organize/scripts/council.py`), and its resource watch (`skills/kvb-org-organize/scripts/watch.py`) measures disk, Docker and caches, cleans up low-risk data and blocks new heavy jobs before the disk runs out; it stays inactive until `~/.claude/agents-council/config.json` exists, and because its hooks run that config's commands outside the sandbox, the install adds `Edit(~/.claude/agents-council/**)` to `permissions.deny`, which also covers Write in every permission mode. The `kvb-org-radio` skill registers five hooks: its prompt hook and a PostToolBatch hook deliver the immediate radio (`call`, `broadcast`, `security`, `pan-pan`, `mayday`) mid-turn, a Stop hook delivers the post (`post`, `newsletter` and call receipts) when the turn ends, an `asyncRewake` Stop hook wakes an idle session for the radio, and a PreToolUse hook refuses every writing action while a mayday holds, except those of its sender and of the agents that join it with `radio.py assist`. The PreToolUse hook of the `kvb-org-queue` skill sends heavy commands through `queue.py hold` once the config sets `queue_enforce`, and holds a held command to the settings' Bash deny and ask rules, which see only `queue.py hold`. The global install also installs the MEDUSA security scanner with pipx for `kvb-check-security`, unless `medusa` is already on the PATH. Two hooks of the `kvb-org-perform` skill (`skills/kvb-org-perform/scripts/log.py`) log every Agent spawn with its task class, model and effort, and store the quality feedback a sandboxed session prints, in `~/.local/state/agents-council/subagent-model.jsonl`. Every hook runs `/usr/bin/python3 -I`, so neither the `python3` of a sandbox-writable virtualenv on the `PATH` nor `PYTHONPATH` reaches the hooks, which run outside the sandbox; older bare `python3` hook entries are rewritten. The reminder hooks run `/usr/bin/bash` and pin `PATH=/usr/bin:/bin`, and the council scripts keep only the `PATH` directories this uid cannot write (`skills/kvb-org-organize/scripts/hookpath.py`), so no binary an agent plants on the inherited `PATH` runs outside the sandbox. Existing settings are preserved.
The global install also runs the Agent Orchestrator, a local review page in which the operator approves or rejects the agents' staged commits (the `kvb-git-diff` skill): it copies `orchestrator/` to `~/.local/share/kvb-orchestrator`, starts it there with `docker compose up -d --build` (restart `unless-stopped`, bound to `127.0.0.1` only, state in `~/.local/state/kvb-orchestrator`) and registers its MCP server for Claude Code at user scope. Its port is `KVB_ORCHESTRATOR_PORT` in `orchestrator/.env`; the hub page is `http://localhost:<port>/`, the MCP endpoint `/mcp`. Without docker the hub is skipped and the commit skills ask in chat. The hub reads new radio traffic aloud in maritime phraseology and takes push-to-talk input; its image builds both engines from pinned, sha256-verified sources and runs each as a separate process: Piper (`piper-tts`, GPL-3.0-or-later) with the `en_US-libritts-high` voice (904 speakers, trained on LibriTTS, CC BY 4.0, from `rhasspy/piper-voices`) and whisper.cpp (MIT) with the multilingual `ggml-base-q8_0` model (OpenAI Whisper weights, MIT). The operator's own voice is `KVB_ORCHESTRATOR_OPERATOR_VOICE` in `orchestrator/.env`.
The global install also runs the Agent Orchestrator, a local review page in which the operator approves or rejects the agents' staged commits (the `kvb-git-diff` skill): it copies `orchestrator/` to `~/.local/share/kvb-orchestrator`, starts it there with `docker compose up -d --build` (restart `unless-stopped`, bound to `127.0.0.1` only, state in `~/.local/state/kvb-orchestrator`) and registers its MCP server for Claude Code at user scope. Its port is `KVB_ORCHESTRATOR_PORT` in `orchestrator/.env`; the hub page is `http://localhost:<port>/`, the MCP endpoint `/mcp`. Without docker the hub is skipped and the commit skills ask in chat. The hub reads new radio traffic aloud in maritime phraseology and takes push-to-talk input; its image builds both engines from pinned, sha256-verified sources and runs each as a separate process: Piper (`piper-tts`, GPL-3.0-or-later) with the `en_US-libritts-high` voice (904 speakers, trained on LibriTTS, CC BY 4.0, from `rhasspy/piper-voices`) and whisper.cpp (MIT) with the multilingual `ggml-base-q8_0` model (OpenAI Whisper weights, MIT). The operator's own voice is `KVB_ORCHESTRATOR_OPERATOR_VOICE` in `orchestrator/.env`. Its Tree reads only the file `pkgmgr branch tree` writes: the install clones `PKGMGR_REPO` (default the GitHub repository, never the sandbox-writable local checkout), builds the newest `v*` tag a fully trusted GPG key signed into `~/.local/share/kvb-orchestrator/pkgmgr`, and enables the `kvb-orchestrator-tree.timer` user timer, which writes `~/.local/state/pkgmgr/tree/branch-tree.json` every minute while the hub page polls; the hub mounts that directory read-only and starts no git. A signed tag without `branch tree` leaves the timer off.
Without reinstalling the skills, `make orchestrator-install` installs, builds and starts only the hub, `make orchestrator-check` checks the installed hub (image, container, logs, HTTP, pinned keys, hook, runner, disk; exit 1 on a failure), `make orchestrator-pin CREDENTIAL=<blob>` pins the passkey that "Passkey registrieren" shows, `make orchestrator-runner` enables the runner service, and `make orchestrator-hook` registers the PermissionRequest hook in `~/.claude/settings.json`, replacing an earlier Agent Orchestrator entry and keeping the file mode, after a copy to `settings.json.before-kvb-orchestrator-hook` (Claude Code reads `~/.claude/settings.local.json` only in sessions started in the home directory). Agents that reach the Docker socket are root-equivalent: until the agent VMs of core requirement 093 isolate them, the signatures do not hold against such an agent. `make watch-check` shows whether the resource watch hook is stuck (running hooks, holders of the kept queue history, age of the stored stage).
Without reinstalling the skills, `make orchestrator-install` installs, builds and starts only the hub, `make orchestrator-check` checks the installed hub (image, container, logs, HTTP, pinned keys, hook, runner, tree timer, disk; exit 1 on a failure), `make orchestrator-pin CREDENTIAL=<blob>` pins the passkey that "Passkey registrieren" shows, `make orchestrator-runner` enables the runner service, and `make orchestrator-hook` registers the PermissionRequest hook in `~/.claude/settings.json`, replacing an earlier Agent Orchestrator entry and keeping the file mode, after a copy to `settings.json.before-kvb-orchestrator-hook` (Claude Code reads `~/.claude/settings.local.json` only in sessions started in the home directory). Agents that reach the Docker socket are root-equivalent: until the agent VMs of core requirement 093 isolate them, the signatures do not hold against such an agent. `make watch-check` shows whether the resource watch hook is stuck (running hooks, holders of the kept queue history, age of the stored stage).
The hub is only a relay: every approval of an ask, a staged commit or an operator command is signed end to end with a WebAuthn passkey held in KeePassXC (through KeePassXC-Browser, relying party `localhost`, origin `http://localhost:<port>`; ES256, EdDSA and RS256, so a hardware key can be pinned beside it). To register, open the hub at `http://localhost:<port>/` (WebAuthn needs the domain; on `127.0.0.1` the page links there), choose "Passkey registrieren" in the settings and run the host command the page shows, `python3 ~/.local/share/kvb-orchestrator/client/approval.py pin --port <port> --approvers ~/.config/kvb-orchestrator/approvers.json --credential '<blob>'`, in a terminal outside every sandbox: it pins the key in `~/.config/kvb-orchestrator/approvers.json` with mode 0600, a file no sandboxed agent can write and the hub mounts read-only; the hub never pins. Each approval, or one bulk selection, is one signature over the hashes of every item, a nonce and an expiry; each auto-approval rule is signed once. The executors verify it themselves against the pinned keys and fail closed: the runner (`client/runner.py`, the `kvb-orchestrator-runner` user service) runs only signed commands, each signature once, recorded in `~/.local/state/kvb-orchestrator-approvals/runner.json`; the PermissionRequest hook (`client/permission_hook.py --port <port> --timeout <seconds> --approvers ~/.config/kvb-orchestrator/approvers.json --replay ~/.local/state/kvb-orchestrator-approvals/hook.json`) allows only a signed item that matches its own tool, command, cwd and session, else it leaves the decision to Claude Code's own prompt; the commit skills run `approval.py verify` on the staged tree before committing, else they ask in the terminal. With no key pinned, every executor refuses; decisions and rejections stay unsigned. A signed rule never covers a command holding `;`, `&`, `|`, a backtick, `$`, `<`, `>`, parentheses or a line break: such a command always asks. The hub page is trusted while it asks for a signature or shows the blob to pin: whoever controls the hub container can show one item and have another signed, or hand over a key of its own. Any local process, sandboxed agents included, can also send the unsigned operator actions (messages, decision choices and rules, board actions, rejections) with the hub's guard headers, and the container reads the host `/tmp` for the review pages in `/tmp/kvb-git-diff`. All three close with the agent VMs of core requirement 093, like the Docker-socket gap.
+23 -15
View File
@@ -101,14 +101,19 @@ from three views down to any agent, branch or artefact.
21. **Delete from the tree**: merged, orphaned branches are deleted by click
in the Artifacts Tree (phase 4), per item and as "Alle gemergten
löschen":
- **Credentials**: only while KeePassXC is unlocked; it hands pkgmgr the
GitHub token through the Secret Service and the SSH key through its SSH
agent, and no secret passes the orchestrator container. Rejected: the
master password typed into the dialog (secret crosses container and
browser), a token-only, local-only delete.
- **Scope**: exactly what `pkgmgr branch gc --apply` deletes for a
`merged-clean` item (worktree, local and remote branch, compose stack,
images); a gc skip reason locks the button. Rejected: local only.
- **Credentials**: only while the Secret Service hands pkgmgr the GitHub
token, which proves the merge and the missing open pull request; no
secret passes the orchestrator container. Rejected: the master password
typed into the dialog (secret crosses container and browser), the SSH
key through the KeePassXC agent (a local delete pushes nothing;
operator 2026-11-06).
- **Scope**: local only (operator 2026-11-06): the worktree, the local
branch, the compose stack without its volumes and its images of a
`merged-clean` item, never a remote branch; a gc skip reason locks the
button, and the dry run lists ignored files, any beyond known caches
(`__pycache__`, `node_modules`, `.venv`) locking it too. Rejected: what
`pkgmgr branch gc --apply` deletes today (remote branch and
`compose down -v`).
- **Confirmation**: the click shows the gc dry-run lines of the chosen
items, and "Löschen" needs the operator signature (`/api/sign`).
Rejected: a second click without signature, one click without preview.
@@ -296,8 +301,8 @@ flowchart TD
without one the file says `forge: unavailable` and "merged by" falls
back to ancestor and line.
- [ ] `delete_ready` is true only while the token resolves through the
Secret Service and the KeePassXC SSH agent holds a key; otherwise it is
false with the reason.
Secret Service, which the scan never unlocks; otherwise it is false
with the reason.
- [ ] `--format yaml` prints the same data.
- [ ] `--output FILE` writes it atomically.
- [ ] The scan starts no hook, filter, fsmonitor, pager or external diff of a
@@ -305,10 +310,11 @@ flowchart TD
`core.fsmonitor`).
- [ ] `pkgmgr branch open|close|drop|gc` rewrite the file when it exists.
- [ ] The orchestrator install enables a systemd user timer that writes it
every 60 s.
every 60 s while the page polled within the last 5 min, with pkgmgr
built from the newest signed tag.
- [ ] The orchestrator mounts the file read-only and reads only it.
- [ ] `hub/kvb_hub/tree.py`'s git scan is gone and no module of the
orchestrator starts `git`.
- [ ] `orchestrator/kvb_orchestrator/tree.py`'s git scan is gone and no
module of the orchestrator starts `git`.
### Agents and Artifacts views
@@ -342,8 +348,10 @@ flowchart TD
and tip sha per item, no command) into a host-mounted job directory and
holds no git, token or key.
- [ ] A systemd user path unit verifies the signature on the host and runs
`pkgmgr branch gc --apply` for exactly the signed items, matched
exactly, never as a pattern.
`pkgmgr branch gc --apply --local` for exactly the signed items, matched
exactly, never as a pattern; `--local` removes the worktree, the local
branch, the compose stack without `-v` and its images, never a remote
branch, and needs no SSH key.
- [ ] The unit refuses, and changes nothing for, an item whose tip or state
differs from the signed one, an unknown repository, a non-`merged-clean`
item and a job with a bad or missing signature, and reports why.
+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();
+69 -5
View File
@@ -2,8 +2,9 @@
# Usage: orchestrator.sh install | migrate | check | pin <blob> | runner | hook
# install migrate, then copy orchestrator/ to ~/.local/share/kvb-orchestrator,
# write its .env, build and start the container, register the MCP
# server for Claude Code; re-enable the runner and re-register the
# hook when the migration took them over
# server for Claude Code, build pkgmgr from the newest signed tag of
# PKGMGR_REPO and enable the tree timer; re-enable the runner and
# re-register the hook when the migration took them over
# migrate move every trace of the earlier name to the kvb-orchestrator names
# once: container, image, code, state, approvals, pinned keys, MCP
# server, PermissionRequest hook, runner unit; a second run changes
@@ -31,6 +32,9 @@ SETTINGS="${HOME}/.claude/settings.json"
LOCAL_SETTINGS="${HOME}/.claude/settings.local.json"
HOOK_TIMEOUT=110
RUNNER="${NAME}-runner.service"
TREE_UNIT="${NAME}-tree"
TREE_DIR="${HOME}/.local/state/pkgmgr/tree"
PKGMGR_REPO="${PKGMGR_REPO:-https://github.com/kevinveenbirkenbach/package-manager.git}"
IMAGE="${NAME}:local"
FLOOR_GB=15
FAILED=0
@@ -137,7 +141,7 @@ install_hub() {
port="$(port_of "${REPO_ROOT}/orchestrator/.env")"
log "skills: starting the Agent Orchestrator on http://localhost:${port}/"
rm -rf "${HUB}"
mkdir -p "${HUB}" "${STATE}" "${APPROVALS}" "${COUNCIL}" "${HOME}/.claude/projects" "${HOME}/Repositories" "${HOME}/.local/share/worktrees" "${UNITS}" "${APPROVERS}"
mkdir -p "${HUB}" "${STATE}" "${APPROVALS}" "${COUNCIL}" "${HOME}/.claude/projects" "${TREE_DIR}" "${UNITS}" "${APPROVERS}"
chmod 700 "${APPROVERS}"
cp -a "${REPO_ROOT}/orchestrator/." "${HUB}/"
# Python loads a cached .pyc whose header matches the source mtime and size, and cp -a keeps the mtime: one planted in the sandbox-writable tree would run on the host
@@ -145,8 +149,8 @@ install_hub() {
chmod +x "${HUB}/client/permission_hook.py" "${HUB}/client/runner.py" "${HUB}/client/approval.py"
log "skills: the PermissionRequest hook client is ${HUB}/client/permission_hook.py"
log "skills: approvals need a pinned passkey: settings, 'Passkey registrieren', then 'make orchestrator-pin CREDENTIAL=<blob>'; until then every executor refuses"
printf 'KVB_ORCHESTRATOR_USER=%s:%s\nKVB_ORCHESTRATOR_STATE=%s\nKVB_ORCHESTRATOR_COUNCIL=%s\nKVB_ORCHESTRATOR_SKILLS=%s\nKVB_ORCHESTRATOR_PROJECTS=%s\nKVB_ORCHESTRATOR_REPOS=%s\nKVB_ORCHESTRATOR_WORKTREES=%s\nKVB_ORCHESTRATOR_TIMERS=%s\nKVB_ORCHESTRATOR_APPROVERS=%s\n' \
"$(id -u)" "$(id -g)" "${STATE}" "${COUNCIL}" "${HOME}/.claude/skills" "${HOME}/.claude/projects" "${HOME}/Repositories" "${HOME}/.local/share/worktrees" "${UNITS}" "${APPROVERS}" >>"${HUB}/.env"
printf 'KVB_ORCHESTRATOR_USER=%s:%s\nKVB_ORCHESTRATOR_STATE=%s\nKVB_ORCHESTRATOR_COUNCIL=%s\nKVB_ORCHESTRATOR_SKILLS=%s\nKVB_ORCHESTRATOR_PROJECTS=%s\nKVB_ORCHESTRATOR_TREE=%s\nKVB_ORCHESTRATOR_TIMERS=%s\nKVB_ORCHESTRATOR_APPROVERS=%s\n' \
"$(id -u)" "$(id -g)" "${STATE}" "${COUNCIL}" "${HOME}/.claude/skills" "${HOME}/.claude/projects" "${TREE_DIR}" "${UNITS}" "${APPROVERS}" >>"${HUB}/.env"
if ! docker compose --file "${HUB}/compose.yml" up -d --build; then
warn "skills: docker compose failed; the Agent Orchestrator stays down until 'make orchestrator-install' succeeds."
fi
@@ -162,9 +166,64 @@ install_hub() {
fi
}
# Args: scratch directory. Prints the newest v* tag of PKGMGR_REPO that a fully
# trusted key signed and unpacks its tree into <scratch>/src. A fresh clone,
# because the local checkout's config is sandbox-writable and git runs its
# gpg.program, hooks and fsmonitor on the host.
fetch_pkgmgr() {
local scratch="$1" tag
if ! git clone --quiet --bare --filter=blob:none "${PKGMGR_REPO}" "${scratch}/mirror"; then
warn "skills: cloning ${PKGMGR_REPO} failed; the tree timer stays off."
return 1
fi
for tag in $(git -C "${scratch}/mirror" tag --list 'v*' --sort=-v:refname); do
if git -C "${scratch}/mirror" -c gpg.minTrustLevel=fully verify-tag "${tag}" &>/dev/null; then
mkdir "${scratch}/src"
git -C "${scratch}/mirror" archive "${tag}" | tar -x -C "${scratch}/src"
printf '%s\n' "${tag}"
return 0
fi
done
warn "skills: no v* tag of ${PKGMGR_REPO} carries a fully trusted signature; the tree timer stays off."
return 1
}
build_pkgmgr() {
local scratch tag venv="${HUB}/pkgmgr" built=1
scratch="$(mktemp -d)"
if tag="$(fetch_pkgmgr "${scratch}")"; then
if ! grep -qs '"tree"' "${scratch}/src/src/pkgmgr/cli/parser/branch_cmd.py"; then
warn "skills: pkgmgr ${tag} has no 'branch tree'; the tree timer stays off until a release has it."
else
rm -rf "${venv}"
if python3 -m venv "${venv}" && "${venv}/bin/pip" install --quiet "${scratch}/src[keyring]"; then
log "skills: built pkgmgr ${tag} into ${venv}"
built=0
else
warn "skills: installing pkgmgr ${tag} into ${venv} failed; the tree timer stays off."
fi
fi
fi
rm -rf "${scratch}"
return "${built}"
}
tree_timer() {
if ! has_systemd; then
warn "skills: no systemd user session; the pkgmgr tree file stays unwritten."
return 0
fi
build_pkgmgr || return 0
cp -f "${HUB}/systemd/${TREE_UNIT}.service" "${HUB}/systemd/${TREE_UNIT}.timer" "${UNITS}/"
systemctl --user daemon-reload
systemctl --user enable --now "${TREE_UNIT}.timer"
log "skills: ${TREE_UNIT}.timer writes ${TREE_DIR}/branch-tree.json every 60 s while the Agent Orchestrator page polls"
}
install_all() {
migrate || return 0
install_hub
tree_timer
if ((TOOK_RUNNER)); then runner || true; fi
if ((TOOK_HOOK)); then
if [[ -f "${HUB}/client/permission_hook.py" ]]; then hook; else warn "skills: run 'make orchestrator-hook' once the Agent Orchestrator is installed"; fi
@@ -223,6 +282,11 @@ check() {
else
todo "runner off: make orchestrator-runner"
fi
if [[ "$(systemctl --user is-active "${TREE_UNIT}.timer" 2>/dev/null || true)" == "active" ]]; then
ok "${TREE_UNIT}.timer active"
else
todo "${TREE_UNIT}.timer off: make orchestrator-install with a signed pkgmgr tag that has 'branch tree'"
fi
free="$(df --output=avail -BG / | tail -1 | tr -dc '0-9')"
if ((free >= FLOOR_GB)); then ok "/ has ${free} GB free"; else fail "/ has ${free} GB free, floor ${FLOOR_GB} GB"; fi
return "${FAILED}"
+177
View File
@@ -0,0 +1,177 @@
{
"generated_at": "2026-11-06T12:00:00+00:00",
"host_pid_namespace": true,
"delete_ready": false,
"delete_reason": "Secret Service locked",
"repos": [
{
"path": "@ROOT@/repos/github.com/me/demo",
"remote": "@ROOT@/origin.git",
"default": "main",
"missing": false,
"known": true,
"forge": "none",
"scanned_at": "2026-11-06T12:00:00+00:00",
"warnings": [],
"items": [
{
"branch": "feature",
"worktree": "@ROOT@/worktrees/github.com/me/demo/feature",
"head": "e7a0755c85d1c17bcdd24a7dc622681e2af5fc2a",
"detached": false,
"locked": null,
"worktree_state": "changed",
"staged": 0,
"unstaged": 0,
"untracked": 1,
"upstream": null,
"ahead": null,
"behind": null,
"gone": false,
"local_sha": "e7a0755c85d1c17bcdd24a7dc622681e2af5fc2a",
"remotes": {},
"compose_projects": [],
"images": [],
"state": "dirty",
"merged_by": null,
"merged_into": null,
"size_bytes": null,
"skip": null,
"actions": [
"worktree",
"local-branch"
],
"reason": "dirty: 1 paths",
"result": null
},
{
"branch": "main",
"worktree": "@ROOT@/repos/github.com/me/demo",
"head": "e7a0755c85d1c17bcdd24a7dc622681e2af5fc2a",
"detached": false,
"locked": null,
"worktree_state": "clean",
"staged": 0,
"unstaged": 0,
"untracked": 0,
"upstream": "origin/main",
"ahead": 1,
"behind": 0,
"gone": false,
"local_sha": "e7a0755c85d1c17bcdd24a7dc622681e2af5fc2a",
"remotes": {
"origin": "cff243cb6d57c4b4b435b418fd49d64c9751dda8"
},
"compose_projects": [],
"images": [],
"state": "protected",
"merged_by": null,
"merged_into": null,
"size_bytes": null,
"skip": null,
"actions": [],
"reason": "default or protected branch",
"result": null
},
{
"branch": null,
"worktree": "@ROOT@/worktrees/github.com/me/demo/detached",
"head": "e7a0755c85d1c17bcdd24a7dc622681e2af5fc2a",
"detached": true,
"locked": null,
"worktree_state": "clean",
"staged": 0,
"unstaged": 0,
"untracked": 0,
"upstream": null,
"ahead": null,
"behind": null,
"gone": false,
"local_sha": null,
"remotes": {},
"compose_projects": [],
"images": [],
"state": "unmerged",
"merged_by": null,
"merged_into": null,
"size_bytes": null,
"skip": null,
"actions": [
"worktree"
],
"reason": "unmerged: 1 commits not in origin/main",
"result": null
}
]
},
{
"path": "@ROOT@/outside/elsewhere",
"remote": null,
"default": "main",
"missing": false,
"known": false,
"forge": "none",
"scanned_at": "2026-11-06T12:00:00+00:00",
"warnings": [],
"items": [
{
"branch": "lone",
"worktree": "@ROOT@/worktrees/x/y/lone",
"head": "ff5731c77d13db964854912057ed09267431b6d9",
"detached": false,
"locked": null,
"worktree_state": "clean",
"staged": 0,
"unstaged": 0,
"untracked": 0,
"upstream": null,
"ahead": null,
"behind": null,
"gone": false,
"local_sha": "ff5731c77d13db964854912057ed09267431b6d9",
"remotes": {},
"compose_projects": [],
"images": [],
"state": "unmerged",
"merged_by": null,
"merged_into": null,
"size_bytes": null,
"skip": null,
"actions": [
"worktree",
"local-branch"
],
"reason": "no own commits",
"result": null
},
{
"branch": "main",
"worktree": "@ROOT@/outside/elsewhere",
"head": "ff5731c77d13db964854912057ed09267431b6d9",
"detached": false,
"locked": null,
"worktree_state": "clean",
"staged": 0,
"unstaged": 0,
"untracked": 0,
"upstream": null,
"ahead": null,
"behind": null,
"gone": false,
"local_sha": "ff5731c77d13db964854912057ed09267431b6d9",
"remotes": {},
"compose_projects": [],
"images": [],
"state": "protected",
"merged_by": null,
"merged_into": null,
"size_bytes": null,
"skip": null,
"actions": [],
"reason": "default or protected branch",
"result": null
}
]
}
]
}
+1 -7
View File
@@ -99,13 +99,7 @@ class TestOrchestratorE2E(HubCase):
})
self.request("feat: orchestrator", "")
self.create_ask(ASK)
repo = self.repos / "github.com" / "me" / "demo"
repo.mkdir(parents=True)
self.git(repo, "init", "--quiet", "--initial-branch=main")
(repo / "a.txt").write_text("a\n", encoding="utf-8")
self.git(repo, "add", "a.txt")
self.git(repo, "commit", "--quiet", "-m", "a")
self.git(repo, "worktree", "add", "--quiet", "-b", "feature", str(self.worktrees / "github.com" / "me" / "demo" / "feature"))
self.write_tree()
self.base = f"http://{self.host}/"
def drive(self, steps: list[dict]) -> dict:
+9 -4
View File
@@ -40,12 +40,11 @@ class HubCase(unittest.TestCase):
self.state = base / "state"
self.outside = base / "outside"
self.projects = base / "projects"
self.repos = base / "repos"
self.worktrees = base / "worktrees"
self.tree_file = base / "tree" / "branch-tree.json"
self.timers = base / "timers"
self.approvers = base / "config" / "approvers.json"
self.replay = base / "replay"
for path in (self.pages, self.state, self.outside, self.projects, self.repos, self.worktrees, self.timers):
for path in (self.pages, self.state, self.outside, self.projects, self.tree_file.parent, self.timers):
path.mkdir()
silence = mock.patch.object(server.Handler, "log_message")
silence.start()
@@ -64,7 +63,7 @@ class HubCase(unittest.TestCase):
transcripts=self.projects,
scripts=self.scripts,
templates=HUB / "templates",
places=server.Places(repos=self.repos, worktrees=self.worktrees, timers=self.timers),
places=server.Places(tree_file=self.tree_file, timers=self.timers),
model=server.Model(url="", model="", key=""),
engines=self.engines,
approvers=self.approvers,
@@ -190,6 +189,12 @@ class HubCase(unittest.TestCase):
text = content if isinstance(content, str) else json.dumps(content)
path.write_text(text, encoding="utf-8")
def write_tree(self) -> Path:
root = self.outside / "scan"
sample = (Path(__file__).parent / "branch-tree.json").read_text(encoding="utf-8")
self.tree_file.write_text(sample.replace("@ROOT@", str(root)), encoding="utf-8")
return root
def jsonl(self, rows: list[dict]) -> str:
return "".join(json.dumps(row) + "\n" for row in rows)
+2 -2
View File
@@ -174,8 +174,8 @@ class TestHubSpeech(HubCase):
def test_the_server_requires_every_engine_argument(self):
args = {
"--bind": "127.0.0.1", "--listen-port": "0", "--public-port": "1", "--council": str(self.council), "--pages": str(self.pages), "--state": str(self.state),
"--host-state": str(HOST_STATE), "--skills": str(self.outside), "--transcripts": str(self.projects), "--templates": str(HUB / "templates"), "--repos": str(self.repos),
"--worktrees": str(self.worktrees), "--timers": str(self.timers), "--llm-key-file": os.devnull, "--llm-url": "", "--llm-model": "",
"--host-state": str(HOST_STATE), "--skills": str(self.outside), "--transcripts": str(self.projects), "--templates": str(HUB / "templates"),
"--tree-file": str(self.tree_file), "--timers": str(self.timers), "--llm-key-file": os.devnull, "--llm-url": "", "--llm-model": "",
"--piper": str(self.engines.piper), "--voices": str(self.engines.voices), "--operator-voice": "en_US-test-high:9", "--whisper": str(self.engines.whisper),
"--whisper-model": str(self.engines.whisper_model), "--scratch": str(self.engines.scratch), "--approvers": str(self.approvers),
}
+55 -71
View File
@@ -1,90 +1,74 @@
from __future__ import annotations
import ast
import datetime
import json
import os
import unittest
from hub_case import HubCase
from hub_support import PAGE
from hub_support import HUB, PAGE
GENERATED = datetime.datetime(2026, 11, 6, 12, tzinfo=datetime.UTC).timestamp()
def starts_git(path) -> list[int]:
lines = []
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
if isinstance(node, ast.Constant) and isinstance(node.value, str) and node.value.split(" ")[0] in ("git", "/usr/bin/git"):
lines.append(node.lineno)
return lines
class TestHubTree(HubCase):
def test_tree_lists_repos_branches_and_worktrees(self):
origin = self.outside / "origin.git"
self.git(self.outside, "init", "--quiet", "--bare", "--initial-branch=main", str(origin))
repo = self.repos / "github.com" / "me" / "demo"
repo.mkdir(parents=True)
self.git(repo, "init", "--quiet", "--initial-branch=main")
(repo / "a.txt").write_text("a", encoding="utf-8")
self.git(repo, "add", "a.txt")
self.git(repo, "commit", "--quiet", "-m", "a")
self.git(repo, "remote", "add", "origin", str(origin))
self.git(repo, "push", "--quiet", "-u", "origin", "main")
(repo / "b.txt").write_text("b", encoding="utf-8")
self.git(repo, "add", "b.txt")
self.git(repo, "commit", "--quiet", "-m", "b")
feature = self.worktrees / "github.com" / "me" / "demo" / "feature"
self.git(repo, "worktree", "add", "--quiet", "-b", "feature", str(feature))
(feature / "dirty.txt").write_text("x", encoding="utf-8")
other = self.outside / "elsewhere"
other.mkdir()
self.git(other, "init", "--quiet", "--initial-branch=main")
(other / "c.txt").write_text("c", encoding="utf-8")
self.git(other, "add", "c.txt")
self.git(other, "commit", "--quiet", "-m", "c")
lone = self.worktrees / "x" / "y" / "lone"
self.git(other, "worktree", "add", "--quiet", "-b", "lone", str(lone))
feature, repo, other, lone = (path.resolve() for path in (feature, repo, other, lone))
self.write_council({"rasci.json": {"work/feature": {"title": "F", "status": "running", "worker": "Skills session", "workdir": str(feature)}}})
self.tool("review_request", {**self.arguments("feat: tree", "", self.page("tree.html", PAGE)), "worktree": str(feature), "branch": "feature"})
def test_tree_serves_the_pkgmgr_file_with_workers_and_reviews(self):
root = self.write_tree()
demo, feature = f"{root}/repos/github.com/me/demo", f"{root}/worktrees/github.com/me/demo/feature"
self.write_council({"rasci.json": {"work/feature": {"title": "F", "status": "running", "worker": "Skills session", "workdir": feature}}})
self.tool("review_request", {**self.arguments("feat: tree", "", self.page("tree.html", PAGE)), "worktree": feature, "branch": "feature"})
status, _, body = self.get("/api/tree")
self.assertEqual(status, 200, body)
data = json.loads(body)
repos = {entry["name"]: entry for entry in data["repos"]}
self.assertEqual(set(repos), {"github.com/me/demo", str(other)})
demo = {entry["name"]: entry for entry in repos["github.com/me/demo"]["branches"]}
self.assertEqual((demo["main"]["upstream"], demo["main"]["ahead"], demo["main"]["behind"]), ("origin/main", 1, 0))
trees = [(tree["path"], tree["dirty"], tree["workers"], tree["review"]) for tree in demo["main"]["worktrees"] + demo["feature"]["worktrees"]]
self.assertEqual(trees, [(str(repo), False, [], None), (str(feature), True, ["Skills"], {"status": "pending", "gate": "pass", "title": "feat: tree"})])
self.assertEqual([tree["path"] for tree in {entry["name"]: entry for entry in repos[str(other)]["branches"]}["lone"]["worktrees"]], [str(lone)])
self.assertEqual(json.loads(self.get("/api/tree")[2])["scanned"], data["scanned"])
repos = {entry["path"]: entry for entry in data["repos"]}
self.assertEqual((repos[demo]["name"], repos[demo]["known"], repos[f"{root}/outside/elsewhere"]["known"]), ("github.com/me/demo", True, False))
self.assertEqual((data["scanned"], data["delete_ready"], data["delete_reason"]), (GENERATED, False, "Secret Service locked"))
branches = {entry["name"]: entry for entry in repos[demo]["branches"]}
main = branches["main"]
self.assertEqual((main["upstream"], main["ahead"], main["behind"], main["gone"], main["state"]), ("origin/main", 1, 0, False, "protected"))
self.assertEqual((branches["feature"]["state"], branches["feature"]["reason"]), ("dirty", "dirty: 1 paths"))
trees = [(tree["path"], tree["dirty"], tree["untracked"], tree["workers"], tree["review"]) for tree in main["worktrees"] + branches["feature"]["worktrees"]]
self.assertEqual(trees, [(demo, False, 0, [], None), (feature, True, 1, ["Skills"], {"status": "pending", "gate": "pass", "title": "feat: tree"})])
self.assertEqual([(tree["path"], tree["workers"]) for tree in repos[demo]["detached"]], [(f"{root}/worktrees/github.com/me/demo/detached", [])])
def test_tree_runs_no_command_a_repo_config_names(self):
marker = self.outside / "ran"
trap, filtered = (self.repos / "github.com" / "me" / name for name in ("trap", "filtered"))
for repo in (trap, filtered):
repo.mkdir(parents=True)
self.git(repo, "init", "--quiet", "--initial-branch=main")
(repo / "a.txt").write_text("a", encoding="utf-8")
self.git(repo, "add", "a.txt")
self.git(repo, "commit", "--quiet", "-m", "a")
self.git(trap, "config", "core.fsmonitor", f"touch {marker}")
self.git(filtered, "config", "filter.x.clean", f"touch {marker}")
(filtered / ".gitattributes").write_text("* filter=x\n", encoding="utf-8")
(filtered / "a.txt").write_text("b", encoding="utf-8")
def test_tree_reads_the_file_again_once_it_changes(self):
self.write_tree()
self.assertEqual(len(json.loads(self.get("/api/tree")[2])["repos"]), 2)
data = json.loads(self.tree_file.read_text(encoding="utf-8"))
data["repos"] = data["repos"][:1]
self.tree_file.write_text(json.dumps(data), encoding="utf-8")
self.assertEqual(len(json.loads(self.get("/api/tree")[2])["repos"]), 1)
def test_a_missing_or_broken_file_answers_with_the_reason(self):
status, _, body = self.get("/api/tree")
self.assertEqual(status, 200, body)
dirty = {entry["name"]: entry["branches"][0]["worktrees"][0]["dirty"] for entry in json.loads(body)["repos"]}
self.assertEqual(dirty, {"github.com/me/trap": False, "github.com/me/filtered": None})
self.assertFalse(marker.exists())
self.assertEqual((json.loads(body)["repos"], json.loads(body)["scanned"]), ([], None))
self.assertIn("no pkgmgr tree file", json.loads(body)["error"])
self.tree_file.write_text('{"repos": [{"path": "/x"}]}', encoding="utf-8")
self.assertIn("unreadable", json.loads(self.get("/api/tree")[2])["error"])
def test_tree_never_fetches_a_missing_object(self):
marker = self.outside / "fetched"
origin = self.outside / "lazy.git"
self.git(self.outside, "init", "--quiet", "--bare", "--initial-branch=main", str(origin))
repo = self.repos / "github.com" / "me" / "lazy"
repo.mkdir(parents=True)
self.git(repo, "init", "--quiet", "--initial-branch=main")
(repo / "a.txt").write_text("a", encoding="utf-8")
self.git(repo, "add", "a.txt")
self.git(repo, "commit", "--quiet", "-m", "a")
self.git(repo, "remote", "add", "origin", str(origin))
self.git(repo, "push", "--quiet", "-u", "origin", "main")
self.git(repo, "config", "remote.origin.promisor", "true")
self.git(repo, "config", "remote.origin.uploadpack", f"touch {marker}; git-upload-pack")
(repo / ".git" / "refs" / "remotes" / "origin" / "main").write_text("1" * 40 + "\n", encoding="utf-8")
status, _, body = self.get("/api/tree")
self.assertEqual(status, 200, body)
self.assertFalse(marker.exists())
def test_the_state_poll_beats_the_heartbeat_the_tree_timer_waits_for(self):
heartbeat = self.state / "tree-heartbeat"
self.assertFalse(heartbeat.exists())
self.snapshot()
self.assertTrue(heartbeat.is_file())
os.utime(heartbeat, (0, 0))
self.snapshot()
self.assertGreater(heartbeat.stat().st_mtime, 0)
def test_no_module_of_the_orchestrator_starts_git(self):
sources = [HUB / "server.py", *sorted((HUB / "kvb_orchestrator").glob("*.py"))]
found = {path.name: starts_git(path) for path in sources}
self.assertEqual({name: lines for name, lines in found.items() if lines}, {})
if __name__ == "__main__":
+1 -1
View File
@@ -82,7 +82,7 @@ class TestOrchestratorMigration(unittest.TestCase):
(stubs / tool).symlink_to("stub")
self.state = self.root / "stub.json"
self.state.write_text(json.dumps({"calls": [], "images": [f"{OLD}:local"], "mcp": {OLD: "http://127.0.0.1:8737/mcp"}, "enabled": [f"{OLD}-runner.service"]}), encoding="utf-8")
self.env = {**os.environ, "HOME": str(self.home), "PATH": f"{stubs}:{os.environ['PATH']}", "STUB_STATE": str(self.state), "AGENTS_COUNCIL_ROOT": str(self.root / "council")}
self.env = {**os.environ, "HOME": str(self.home), "PATH": f"{stubs}:{os.environ['PATH']}", "STUB_STATE": str(self.state), "AGENTS_COUNCIL_ROOT": str(self.root / "council"), "PKGMGR_REPO": str(self.root / "no-pkgmgr")}
share, local, config = self.home / ".local" / "share", self.home / ".local" / "state", self.home / ".config"
self.write(share / OLD / "compose.yml", f"name: {OLD}\n")
self.write(share / OLD / ".env", "KVB_HUB_PORT=8737\n")