🎯 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:
17 files changed
+453
-251
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
+69
-5
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user