feat(orchestrator): W4 resources view and host samples

A resources timer samples host CPU, RAM, swap and per-session tokens into samples.jsonl; the hub serves them and a Resources view charts them. Committed below the bar on the operator's word 2026-11-12 (no further per-bundle tests, one gate after the merge): the W4 e2e never ran in a real browser.

🎯 Fix: 92 %
💥 Break: 6 %
💸 Cost: 100 %
⏱️ Time: 100 %
🛡️ Security: 100 %
🏆 Score: 86
🚦 Gate: ⛔ break 6 % > 5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-12 16:15:43 +00:00
1 parent fd8fc08603
commit 35c270e120
21 files changed
+1937 -18

No files matched your search

+590
View File
@@ -0,0 +1,590 @@
#!/usr/bin/env python3
"""Args:
sample: append one row to <state>/samples.jsonl: {t, disks: [{mount,
used, total}], cpu, ram, swap, load, gpus: [{name, util, vram_used,
vram_total} | {name, asleep: true}], queue: {waiting, active}}; t in
epoch seconds, used, total and vram in bytes, cpu, ram, swap and util
in percent, load the 1-minute load average, a GPU named by its PCI
address. cpu is the busy share of the /proc/stat ticks since those
the run before kept in <state>/cpu.json as [t, idle, total], None on
the first run and after a gap of more than 15 minutes. An NVIDIA GPU
whose PCI runtime_status is not active is recorded asleep, and
nvidia-smi is not run for it; an awake one nvidia-smi gives no
reading for (no driver, a driver mismatch) keeps its name with util
and vram None. In the first minute of a 15-minute bucket, rows older
than 7 days fold into 15-minute means and means older than 90 days
are dropped.
tokens: add the input, output, cache-read and cache-write tokens of the
usage fields in the Claude Code transcripts under --transcripts since
each file's byte offset to <state>/tokens.jsonl as {t, agent, model,
input, output, cache_read, cache_write}, t the hour; agent is the
name the council claim of the session carries, else the session id's
first 8 characters. A message counts once by its message id and
request id, also when written over several lines or copied into a
fork's transcript. <state>/offsets.json holds {files: {path: offset},
seen: {hour: [key digest]}}, seen keeping the hours this run read and
the last two, and is replaced only when it changes. In the first
minute of an hour, rows of the same hour, agent and model merge and
rows older than 90 days are dropped.
--state: the directory of these files, the unit passes
~/.local/state/agents-council/resources.
--council: the council directory with queue.json and the claims.
--transcripts (tokens): Claude Code's projects directory.
--proc, --sys (sample): where /proc and /sys are mounted.
Returns:
Runs on the host every minute through kvb-orchestrator-resources.timer,
because the hub's container sees neither the host's disks, memory and
GPUs nor its transcripts' offsets; nothing here calls a language model.
The exit code is 0.
"""
from __future__ import annotations
import argparse
import base64
import datetime
import hashlib
import json
import os
import shutil
import stat
import subprocess
import tempfile
import time
from collections.abc import Callable
from pathlib import Path
INTERVAL = 60
BUCKET = 900
HOUR = 3600
FINE = 7 * 86400
COARSE = 90 * 86400
COMMAND_SECONDS = 10
KEY_BYTES = 9
KIB = 1 << 10
MIB = 1 << 20
JSON_BYTES = 1 << 24
BUDGET = 64 << 20
NVIDIA = "0x10de"
DISPLAY = "0x03"
NVIDIA_QUERY = "pci.bus_id,utilization.gpu,memory.used,memory.total"
UNREAD = {"util": None, "vram_used": None, "vram_total": None}
COUNTS = (("input", "input_tokens"), ("output", "output_tokens"), ("cache_read", "cache_read_input_tokens"), ("cache_write", "cache_creation_input_tokens"))
NAMES = tuple(name for name, _ in COUNTS)
HOST_DIGITS = (("cpu", 1), ("ram", 1), ("swap", 1), ("load", 2))
CLAIMS = ("*/claim.json", "*/*/claim.json")
SAMPLES = "samples.jsonl"
TOKENS = "tokens.jsonl"
OFFSETS = "offsets.json"
TICKS = "cpu.json"
def read_text(path: Path) -> str:
try:
return path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return ""
def read_json(path: Path) -> object:
"""Returns:
The parsed JSON of path, read without following a symlink and only
from a regular file of at most JSON_BYTES; None when that fails.
"""
try:
with os.fdopen(os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK), "rb") as handle:
if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode):
return None
data = handle.read(JSON_BYTES + 1)
return None if len(data) > JSON_BYTES else json.loads(data.decode("utf-8"))
except (OSError, ValueError, RecursionError):
return None
def number(text: str) -> float | None:
try:
return float(text)
except ValueError:
return None
def percent(part: float, whole: float | None) -> float | None:
return round(100 * part / whole, 1) if whole else None
def command(argv: list[str]) -> str | None:
if shutil.which(argv[0]) is None:
return None
try:
done = subprocess.run(argv, capture_output=True, text=True, timeout=COMMAND_SECONDS, check=False)
except (OSError, subprocess.SubprocessError):
return None
return done.stdout if done.returncode == 0 else None
def parse_df(text: str) -> list[dict]:
"""Returns:
One {mount, used, total} in bytes per block device in the output of
df -P -k, at the first mount df lists for it; loop devices and
filesystems without a device are left out.
"""
found, seen = [], set()
for line in text.splitlines()[1:]:
parts = line.split(None, 5)
if len(parts) < 6 or not parts[0].startswith("/dev/") or parts[0].startswith("/dev/loop") or parts[0] in seen:
continue
total, used = number(parts[1]), number(parts[2])
if total is None or used is None:
continue
seen.add(parts[0])
found.append({"mount": parts[5], "used": int(used) * KIB, "total": int(total) * KIB})
return found
def parse_meminfo(text: str) -> dict[str, int]:
values = {}
for line in text.splitlines():
key, _, rest = line.partition(":")
fields = rest.split()
if fields and fields[0].isdigit():
values[key.strip()] = int(fields[0])
return values
def memory(values: dict[str, int]) -> tuple[float | None, float | None]:
"""Returns:
RAM in use ((MemTotal - MemAvailable) / MemTotal) and swap in use in
percent; swap is 0 without a swap device, None when unknown.
"""
total, available = values.get("MemTotal"), values.get("MemAvailable")
ram = percent(total - available, total) if total and available is not None else None
swap_total, swap_free = values.get("SwapTotal"), values.get("SwapFree")
if swap_total == 0:
return ram, 0.0
return ram, percent(swap_total - swap_free, swap_total) if swap_total and swap_free is not None else None
def cpu_ticks(text: str) -> tuple[int, int] | None:
"""Returns:
(idle, total) ticks of the summary line of /proc/stat: idle counts
idle and iowait, total the first eight fields, which already hold
guest time.
"""
fields = text.split("\n", 1)[0].split()
if len(fields) < 6 or fields[0] != "cpu" or not all(field.isdigit() for field in fields[1:9]):
return None
values = [int(field) for field in fields[1:9]]
return values[3] + values[4], sum(values)
def cpu_percent(before: tuple[int, int] | None, after: tuple[int, int] | None) -> float | None:
if before is None or after is None:
return None
idle, total = after[0] - before[0], after[1] - before[1]
return percent(total - idle, total) if total > 0 else None
def parse_loadavg(text: str) -> float | None:
fields = text.split()
return number(fields[0]) if fields else None
def parse_nvidia(text: str) -> dict[str, dict]:
"""Returns:
Per PCI address (domain of 4 hex digits) {util, vram_used,
vram_total} from the CSV nvidia-smi prints for NVIDIA_QUERY with
noheader,nounits; vram in bytes, an unknown value None.
"""
found = {}
for line in text.splitlines():
parts = [part.strip() for part in line.split(",")]
if len(parts) != 4:
continue
util, used, total = (number(part) for part in parts[1:])
found[parts[0].lower()[-12:]] = {"util": util, "vram_used": None if used is None else int(used * MIB), "vram_total": None if total is None else int(total * MIB)}
return found
def nvidia_gpus(sys_root: Path) -> list[tuple[str, bool]]:
"""Returns:
The PCI address of every NVIDIA display controller under sys_root and
whether its runtime_status reads active.
"""
found = []
for device in sorted((sys_root / "bus" / "pci" / "devices").glob("*")):
if read_text(device / "vendor").strip() == NVIDIA and read_text(device / "class").strip().startswith(DISPLAY):
found.append((device.name, read_text(device / "power" / "runtime_status").strip() == "active"))
return found
def gpus(sys_root: Path, run: Callable[[list[str]], str | None]) -> list[dict]:
devices = nvidia_gpus(sys_root)
awake = [address for address, active in devices if active]
readings = parse_nvidia(run(["nvidia-smi", f"--id={','.join(awake)}", f"--query-gpu={NVIDIA_QUERY}", "--format=csv,noheader,nounits"]) or "") if awake else {}
return [{"name": address, **readings.get(address, UNREAD)} if active else {"name": address, "asleep": True} for address, active in devices]
def queue(council: Path) -> dict:
data = read_json(council / "queue.json")
entries = data.get("entries") if isinstance(data, dict) else None
statuses = [entry.get("status") for entry in entries if isinstance(entry, dict)] if isinstance(entries, list) else []
return {"waiting": statuses.count("waiting"), "active": statuses.count("active")}
def integers(value: object, size: int) -> bool:
return isinstance(value, list) and len(value) == size and all(isinstance(item, int) and not isinstance(item, bool) for item in value)
def cpu_since(proc: Path, state: Path, t: int) -> float | None:
"""Returns:
The busy share of the ticks in proc's stat since those the run
before kept in <state>/cpu.json at most BUCKET seconds before t,
else None; this run's ticks replace them.
"""
ticks = cpu_ticks(read_text(proc / "stat"))
stored = read_json(state / TICKS)
before = (stored[1], stored[2]) if integers(stored, 3) and t - BUCKET <= stored[0] <= t else None
if ticks is not None:
replace(state / TICKS, json.dumps([t, *ticks]))
return cpu_percent(before, ticks)
def sample(proc: Path, sys_root: Path, council: Path, run: Callable[[list[str]], str | None], state: Path) -> dict:
t = int(time.time())
ram, swap = memory(parse_meminfo(read_text(proc / "meminfo")))
return {
"t": t,
"disks": parse_df(run(["df", "-P", "-k", "-l"]) or ""),
"cpu": cpu_since(proc, state, t),
"ram": ram,
"swap": swap,
"load": parse_loadavg(read_text(proc / "loadavg")),
"gpus": gpus(sys_root, run),
"queue": queue(council),
}
def average(values: list[object], digits: int) -> float | None:
found = [value for value in values if isinstance(value, int | float) and not isinstance(value, bool)]
return round(sum(found) / len(found), digits) if found else None
def whole(values: list[object]) -> int | None:
found = average(values, 0)
return None if found is None else int(found)
def listed(rows: list[dict], key: str) -> list[dict]:
return [item for row in rows if isinstance(row.get(key), list) for item in row[key] if isinstance(item, dict)]
def grouped(items: list[dict], key: str) -> dict[object, list[dict]]:
found: dict[object, list[dict]] = {}
for item in items:
found.setdefault(item.get(key), []).append(item)
return found
def gpu_mean(name: object, readings: list[dict]) -> dict:
awake = [reading for reading in readings if reading.get("asleep") is not True]
if not awake:
return {"name": name, "asleep": True}
return {"name": name, "util": average([reading.get("util") for reading in awake], 1), "vram_used": whole([reading.get("vram_used") for reading in awake]), "vram_total": whole([reading.get("vram_total") for reading in awake])}
def disk_means(rows: list[dict]) -> list[dict]:
return [{"mount": mount, "used": whole([disk.get("used") for disk in disks]), "total": whole([disk.get("total") for disk in disks])} for mount, disks in grouped(listed(rows, "disks"), "mount").items()]
def queue_mean(rows: list[dict]) -> dict:
queues = [row["queue"] for row in rows if isinstance(row.get("queue"), dict)]
return {key: average([entry.get(key) for entry in queues], 2) for key in ("waiting", "active")}
def mean_row(start: int, rows: list[dict]) -> dict:
"""Returns:
The 15-minute mean of rows at start: per mount and per GPU the mean
of the rows that list it, a GPU asleep in every row stays asleep;
a single row already at start comes back unchanged.
"""
if len(rows) == 1 and rows[0]["t"] == start:
return rows[0]
host = {key: average([row.get(key) for row in rows], digits) for key, digits in HOST_DIGITS}
gpu_rows = [gpu_mean(name, readings) for name, readings in grouped(listed(rows, "gpus"), "name").items()]
return {"t": start, "disks": disk_means(rows), **host, "gpus": gpu_rows, "queue": queue_mean(rows)}
def timed(row: object) -> bool:
return isinstance(row, dict) and isinstance(row.get("t"), int | float) and not isinstance(row.get("t"), bool)
def retain(rows: list[dict], now: float) -> list[dict]:
"""Returns:
rows without those older than 90 days, every 15-minute bucket that
ended more than 7 days ago folded into one mean at its start, the
newer rows unchanged and in order.
"""
buckets: dict[int, list[dict]] = {}
recent = []
for row in rows:
if not timed(row) or row["t"] < now - COARSE:
continue
start = int(row["t"] // BUCKET * BUCKET)
if start + BUCKET <= now - FINE:
buckets.setdefault(start, []).append(row)
else:
recent.append(row)
return [mean_row(start, buckets[start]) for start in sorted(buckets)] + recent
def read_rows(path: Path) -> list[dict]:
rows = []
for line in read_text(path).splitlines():
try:
row = json.loads(line)
except (ValueError, RecursionError):
continue
if isinstance(row, dict):
rows.append(row)
return rows
def encoded(rows: list[dict]) -> str:
return "".join(json.dumps(row, separators=(",", ":")) + "\n" for row in rows)
def append_rows(path: Path, rows: list[dict]) -> None:
if rows:
with path.open("a", encoding="utf-8") as handle:
handle.write(encoded(rows))
def replace(path: Path, text: str) -> None:
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False) as handle:
try:
handle.write(text)
except BaseException:
os.unlink(handle.name)
raise
os.replace(handle.name, path)
def rewrite(path: Path, keep: Callable[[list[dict], float], list[dict]], now: float) -> None:
rows = read_rows(path)
kept = keep(rows, now)
if kept != rows:
replace(path, encoded(kept))
def record(state: Path, row: dict, now: float) -> None:
path = state / SAMPLES
append_rows(path, [row])
if now % BUCKET < INTERVAL:
rewrite(path, retain, now)
def claim_names(council: Path) -> dict[str, str]:
names = {}
for pattern in CLAIMS:
for path in sorted(council.glob(pattern)):
data = read_json(path)
if isinstance(data, dict) and isinstance(data.get("session"), str) and isinstance(data.get("name"), str) and data["name"].strip():
names[data["session"]] = data["name"].strip()
return names
def epoch(value: object) -> float | None:
if not isinstance(value, str):
return None
try:
parsed = datetime.datetime.fromisoformat(value)
except ValueError:
return None
return (parsed if parsed.tzinfo else parsed.replace(tzinfo=datetime.UTC)).timestamp()
def count(value: object) -> int:
return value if isinstance(value, int) and not isinstance(value, bool) and value > 0 else 0
def assistant(raw: bytes) -> tuple[dict, dict, dict] | None:
"""Returns:
(row, message, usage) of an assistant transcript line whose message
carries a usage map and whose row a session id, else None.
"""
try:
row = json.loads(raw)
except (ValueError, RecursionError):
return None
message = row.get("message") if isinstance(row, dict) and row.get("type") == "assistant" else None
fields = message.get("usage") if isinstance(message, dict) else None
return (row, message, fields) if isinstance(fields, dict) and isinstance(row.get("sessionId"), str) else None
def message_key(ident: object, request: object) -> str:
"""Returns:
A short digest of (message id, request id), "" without a message id.
"""
if not isinstance(ident, str) or not ident:
return ""
text = f"{ident}\0{request if isinstance(request, str) else ''}"
return base64.urlsafe_b64encode(hashlib.blake2b(text.encode("utf-8"), digest_size=KEY_BYTES).digest()).decode("ascii")
def usage(raw: bytes) -> tuple[str, float, str, str, tuple[int, ...]] | None:
"""Returns:
(message key, time, session, model, counts in COUNTS order) of an
assistant transcript line with a usage field, a time and a model,
else None.
"""
found = assistant(raw)
if found is None:
return None
row, message, fields = found
ts, model = epoch(row.get("timestamp")), message.get("model")
if ts is None or not isinstance(model, str):
return None
return message_key(message.get("id"), row.get("requestId")), ts, row["sessionId"], model, tuple(count(fields.get(field)) for _, field in COUNTS)
def first_sight(key: str, ts: float, seen: dict[int, set[str]], keys: dict[int, set[str]]) -> bool:
"""Returns:
Whether the message key at ts is in neither seen nor keys at its hour
or a neighbouring one, then files it in keys at its hour; a line
without a key is always a first sight. The neighbours catch a copy a
fork wrote seconds after the original across the hour.
"""
if not key:
return True
hour = int(ts // HOUR * HOUR)
known = any(key in found.get(near, ()) for found in (seen, keys) for near in (hour - HOUR, hour, hour + HOUR))
keys.setdefault(hour, set()).add(key)
return not known
def scan(path: Path, start: int, budget: int, seen: dict[int, set[str]]) -> tuple[int, list[tuple[float, str, str, tuple[int, ...]]], dict[int, set[str]]]:
"""Returns:
The offset after the last complete line read from start, at most
about budget bytes on, the usage of every message seen holds no key
of, and per hour the keys of every message read.
"""
found = []
keys: dict[int, set[str]] = {}
position = start
with path.open("rb") as handle:
handle.seek(start)
for raw in handle:
if not raw.endswith(b"\n") or position - start >= budget:
break
position += len(raw)
read = usage(raw) if b'"usage"' in raw else None
if read is not None and first_sight(read[0], read[1], seen, keys) and any(read[4]):
found.append(read[1:])
return position, found, keys
def token_rows(sums: dict[tuple, list[int]]) -> list[dict]:
return [{"t": t, "agent": agent, "model": model, **dict(zip(NAMES, counts, strict=True))} for (t, agent, model), counts in sorted(sums.items())]
def add(sums: dict[tuple, list[int]], key: tuple, counts: tuple[int, ...]) -> None:
total = sums.setdefault(key, [0] * len(COUNTS))
for index, value in enumerate(counts):
total[index] += value
def compact(rows: list[dict], now: float) -> list[dict]:
sums: dict[tuple, list[int]] = {}
for row in rows:
if timed(row) and row["t"] >= now - COARSE and isinstance(row.get("agent"), str) and isinstance(row.get("model"), str):
add(sums, (row["t"], row["agent"], row["model"]), tuple(count(row.get(name)) for name in NAMES))
return token_rows(sums)
def start_of(entry: object, info: os.stat_result, now: float) -> int:
if isinstance(entry, int) and not isinstance(entry, bool) and entry >= 0:
return entry if entry <= info.st_size else 0
return info.st_size if info.st_mtime < now - COARSE else 0
def read_transcript(path: Path, entry: object, budget: int, now: float, seen: dict[int, set[str]]) -> tuple[int, int, list, dict[int, set[str]]]:
"""Returns:
(start, end, usages, keys per hour) of path read from the offset
entry records, without reading once budget is spent.
"""
info = path.stat()
start = start_of(entry, info, now)
if budget <= 0 or start >= info.st_size:
return start, start, [], {}
return start, *scan(path, start, budget, seen)
def seen_of(value: object) -> dict[int, set[str]]:
if not isinstance(value, dict):
return {}
return {int(hour): {key for key in keys if isinstance(key, str)} for hour, keys in value.items() if hour.isdigit() and isinstance(keys, list)}
def kept_seen(seen: dict[int, set[str]], touched: set[int], now: float) -> dict[str, list[str]]:
floor = now // HOUR * HOUR - HOUR
return {str(hour): sorted(keys) for hour, keys in sorted(seen.items()) if hour in touched or hour >= floor}
def count_tokens(transcripts: Path, council: Path, state: Path, now: float, budget: int) -> list[dict]:
"""Returns:
The rows appended to <state>/tokens.jsonl by this run.
"""
stored = read_json(state / OFFSETS)
offsets = stored.get("files") if isinstance(stored, dict) and isinstance(stored.get("files"), dict) else {}
seen = seen_of(stored.get("seen") if isinstance(stored, dict) else None)
names = claim_names(council)
sums: dict[tuple, list[int]] = {}
files, touched = {}, set()
for path in sorted(transcripts.rglob("*.jsonl")):
key = path.relative_to(transcripts).as_posix()
try:
start, end, found, keys = read_transcript(path, offsets.get(key), budget, now, seen)
except OSError:
if key in offsets:
files[key] = offsets[key]
continue
files[key] = end
budget -= end - start
for hour, read in keys.items():
seen.setdefault(hour, set()).update(read)
touched.add(hour)
for ts, session, model, counts in found:
add(sums, (int(ts // HOUR * HOUR), names.get(session) or session[:8], model), counts)
rows = token_rows(sums)
append_rows(state / TOKENS, rows)
kept = {"files": files, "seen": kept_seen(seen, touched, now)}
if kept != stored:
replace(state / OFFSETS, json.dumps(kept, sort_keys=True, separators=(",", ":")))
if now % HOUR < INTERVAL:
rewrite(state / TOKENS, compact, now)
return rows
def main() -> int:
parser = argparse.ArgumentParser(description="Sample the host's resources and count the transcripts' tokens for the Agent Orchestrator.")
commands = parser.add_subparsers(dest="command", required=True)
sampling, counting = commands.add_parser("sample"), commands.add_parser("tokens")
for sub in (sampling, counting):
sub.add_argument("--state", type=Path, required=True)
sub.add_argument("--council", type=Path, required=True)
sampling.add_argument("--proc", type=Path, required=True)
sampling.add_argument("--sys", type=Path, required=True)
counting.add_argument("--transcripts", type=Path, required=True)
args = parser.parse_args()
args.state.mkdir(parents=True, exist_ok=True)
if args.command == "sample":
row = sample(args.proc, args.sys, args.council, command, args.state)
record(args.state, row, row["t"])
else:
count_tokens(args.transcripts, args.council, args.state, time.time(), BUDGET)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+4
View File
@@ -2,12 +2,14 @@ from __future__ import annotations
import re
import secrets
import time
from collections.abc import Callable
from http import HTTPStatus
from urllib.parse import urlsplit
from .engines import STT_LIMIT
from .exchange import PLAIN_CSP, Exchange
from .resources import RESOURCES, sampled
BODY_LIMIT = 1 << 20
UPLOAD_LIMIT = 50 << 20
@@ -107,6 +109,8 @@ class Handler(Exchange):
self.answer(lambda: hub.events(self.query()), HTTPStatus.OK)
elif path == "/api/tree":
self.answer(hub.repositories, HTTPStatus.OK)
elif path == "/api/resources":
self.answer(lambda: sampled(hub.kept / RESOURCES, hub.span(self.query(), time.time())), HTTPStatus.OK)
elif path == "/api/subagents":
self.answer(lambda: hub.subagents(self.query()), HTTPStatus.OK)
elif path == "/api/runner/jobs":
@@ -0,0 +1,38 @@
from __future__ import annotations
from pathlib import Path
from .records import epoch, parse_line, read_regular
RESOURCES = "resources"
SAMPLES = "samples.jsonl"
TOKENS = "tokens.jsonl"
HOUR = 3600
FILE_LIMIT = 64 << 20
def rows_between(path: Path, begin: float, end: float, width: float) -> list[dict]:
try:
lines = read_regular(path, FILE_LIMIT).splitlines()
except (OSError, ValueError):
return []
found = []
for row in map(parse_line, lines):
t = None if row is None else epoch(row.get("t"))
if t is not None and begin <= t + width and t <= end:
found.append(row)
return found
def sampled(folder: Path, span: tuple[float, float]) -> dict:
"""Args:
folder: the host sampler's directory, mounted read-only.
span: (begin, end) of the page's time window in epoch seconds.
Returns:
{"since", "until", "samples", "tokens"}: the rows of samples.jsonl
within the span and those of tokens.jsonl whose hour overlaps it, as
the host unit wrote them; the hub reads nothing of the host itself.
"""
begin, end = span
return {"since": begin, "until": end, "samples": rows_between(folder / SAMPLES, begin, end, 0), "tokens": rows_between(folder / TOKENS, begin, end, HOUR)}
@@ -0,0 +1,12 @@
[Unit]
Description=Agent Orchestrator resources: disks, CPU, RAM, swap, load, GPU, the queue and the transcripts' tokens, into the files the orchestrator reads
[Service]
Type=oneshot
Environment=PATH=/usr/bin:/bin
EnvironmentFile=%h/.local/share/kvb-orchestrator/.env
ExecStart=/usr/bin/env python3 %h/.local/share/kvb-orchestrator/client/resources.py sample --state ${KVB_ORCHESTRATOR_KEPT}/resources --council ${KVB_ORCHESTRATOR_COUNCIL} --proc /proc --sys /sys
ExecStart=/usr/bin/env python3 %h/.local/share/kvb-orchestrator/client/resources.py tokens --state ${KVB_ORCHESTRATOR_KEPT}/resources --council ${KVB_ORCHESTRATOR_COUNCIL} --transcripts ${KVB_ORCHESTRATOR_PROJECTS}
Nice=10
IOSchedulingClass=idle
TimeoutStartSec=300
@@ -0,0 +1,9 @@
[Unit]
Description=Agent Orchestrator resources every minute
[Timer]
OnCalendar=*-*-* *:*:00
AccuracySec=1s
[Install]
WantedBy=timers.target
+6
View File
@@ -406,6 +406,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
.float-tooltip-kap { position: absolute; z-index: 9; padding: 4px 8px; border-radius: 6px; background: var(--glass-card); color: var(--text); font-size: var(--fs-sm); white-space: nowrap; pointer-events: none; box-shadow: var(--shadow); }
{% include "style-hierarchy.css" %}
{% include "style-resources.css" %}
{% include "style-inbox.css" %}
{% include "style-ui.css" %}
@@ -455,6 +457,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "bubbles.js" %}
{% include "resources.js" %}
{% include "nav.js" %}
{% include "popup.js" %}
@@ -560,6 +564,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "page-alerts.js" %}
{% include "page-resources.js" %}
{% include "page-rules.js" %}
{% include "page-keyboard.js" %}
+2 -1
View File
@@ -1,6 +1,6 @@
const Nav = (() => {
"use strict";
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"], ["tasks", "Tasks"]];
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"], ["tasks", "Tasks"], ["resources", "Resources"]];
const SUBS = {stream: [["inbox", "Inbox"], ["rules", "Firewall"]]};
const VIEWS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]];
const TASK_VIEWS = [["list", "List"], ["board", "Board"], ["rasci", "RASCI"], ["gantt", "Gantt"], ["burn", "Burndown and burnup"], ["3d", "3D"]];
@@ -9,6 +9,7 @@ const Nav = (() => {
stream: "bi-activity", inbox: "bi-inbox", rules: "bi-card-checklist", agents: "bi-people", artifacts: "bi-git", tasks: "bi-list-check",
orbit: "bi-globe2", chord: "bi-bezier2", swimlane: "bi-bar-chart-steps", tree: "bi-diagram-3", "3d": "bi-badge-3d", hierarchy: "bi-diagram-2",
list: "bi-list-task", board: "bi-kanban", rasci: "bi-grid-3x3", gantt: "bi-calendar-range", burn: "bi-graph-down-arrow",
resources: "bi-speedometer2",
};
const OLD = {graph: ["agents", "orbit"], tree: ["artifacts", "tree"]};
const viewsOf = (screen) => (screen === "tasks" ? TASK_VIEWS : [...VIEWS, ...(OWN[screen] || [])]);
+174
View File
@@ -0,0 +1,174 @@
const RESOURCE = {left: 46, right: 10, top: 6, bottom: 6, axis: 18, poll: 5000, stale: 60, tones: 6, hour: 3600, narrow: 600};
const RESOURCE_PANELS = [["disks", "bi-hdd-stack", "Disks"], ["host", "bi-cpu", "CPU, RAM and swap"], ["gpu", "bi-gpu-card", "GPU and VRAM"], ["tokens", "bi-coin", "Tokens per agent"]];
const RESOURCE_ZONES = {idle: "Idle: below 30 %", saturated: "Saturated: above 90 %"};
const RESOURCE_MARKS = {
wait: ["#bi-hourglass-split", (mark) => `${mark.peak} jobs waited while CPU and RAM stayed under 50 %`],
asleep: ["#bi-moon", (mark) => `${mark.name} asleep`],
unknown: ["#bi-question-circle", (mark) => `${mark.name} unknown: no reading`],
};
const resourceView = {data: null, dom: null, busy: false, key: "", at: 0, timer: 0};
const resourceKey = () => JSON.stringify([ui.preset, ui.from, ui.to, ui.asOf]);
const compactCount = (value) => (value >= 1e6 ? `${fixed(value / 1e6)}M` : value >= 1e3 ? `${fixed(value / 1e3)}k` : String(Math.round(value)));
const resourceLabel = (line) => (line.kind === "vram" ? `${line.name} VRAM` : line.name);
function resourcesScreen() {
const count = h("span", {class: "count", title: "Samples", "aria-label": "Samples"}, "0");
const panels = RESOURCE_PANELS.map(([kind, name, label]) => {
const legend = h("div", {class: "res-legend"});
const plot = h("div", {class: "res-plot"});
const panel = h("section", {class: `card res-panel res-${kind}`, "data-panel": kind, "aria-label": label},
h("div", {class: "res-head"}, h("span", {class: "res-title", title: label, "aria-label": label}, icon(name)), legend), plot);
return {kind, label, legend, plot, panel};
});
resourceView.dom = {count, panels};
if (!resourceView.timer) {
resourceView.timer = window.setInterval(pollResources, RESOURCE.poll);
window.addEventListener("resize", drawResources);
}
window.setTimeout(() => {
drawResources();
fetchResources();
}, 0);
const reload = iconBtn("bi-arrow-clockwise", "Reload", "btn small soft", fetchResources, "Reload: read the sampler files again");
return h("div", {class: "graph resources"},
h("div", {class: "card controls"}, h("div", {class: "toolbar", role: "toolbar", "aria-label": "Resources"}, reload, count)),
h("div", {class: "res-panels"}, panels.map((entry) => entry.panel)));
}
function pollResources() {
const dom = resourceView.dom;
if (!dom || !dom.count.isConnected || document.hidden || resourceView.busy) {
return;
}
if (resourceKey() !== resourceView.key || nowSeconds() - resourceView.at >= RESOURCE.stale) {
fetchResources();
}
}
async function fetchResources() {
if (resourceView.busy) {
return;
}
resourceView.busy = true;
resourceView.key = resourceKey();
try {
const response = await fetch(`/api/resources?${windowQuery()}`, {cache: "no-store"});
if (response.ok) {
resourceView.data = await response.json();
}
} catch {
ui.offline = true;
}
resourceView.at = nowSeconds();
resourceView.busy = false;
drawResources();
}
function resourceFrame(entry, span, size) {
const axis = entry.kind === "tokens";
const bottom = size.height - (axis ? RESOURCE.axis : RESOURCE.bottom);
const x = (t) => RESOURCE.left + ((t - span.from) / (span.to - span.from)) * (size.width - RESOURCE.left - RESOURCE.right);
const svg = s("svg", {class: `chart res-chart res-${entry.kind}`, "data-panel": entry.kind, "data-from": span.from, "data-to": span.to, viewBox: `0 0 ${size.width} ${size.height}`, role: "img", "aria-label": entry.label});
const ticks = size.width < RESOURCE.narrow ? 2 : 4;
for (let index = 0; index <= ticks; index += 1) {
const t = span.from + ((span.to - span.from) * index) / ticks;
svg.append(s("line", {class: "grid", x1: fixed(x(t)), x2: fixed(x(t)), y1: RESOURCE.top, y2: fixed(bottom)}));
if (axis) {
svg.append(s("text", {class: "axis", x: fixed(x(t)), y: size.height - 4, "text-anchor": index === 0 ? "start" : index === ticks ? "end" : "middle"}, (span.to - span.from > DAY ? absolute : clock).format(stampOf(t))));
}
}
return {svg, x, top: RESOURCE.top, bottom, right: size.width - RESOURCE.right};
}
function resourceScale(frame, values, label) {
const y = (value) => frame.top + (1 - value / values[values.length - 1]) * (frame.bottom - frame.top);
for (const value of values) {
frame.svg.append(s("line", {class: "grid", x1: RESOURCE.left, x2: fixed(frame.right), y1: fixed(y(value)), y2: fixed(y(value))}));
frame.svg.append(s("text", {class: "axis", x: RESOURCE.left - 6, y: fixed(y(value)), dy: "0.35em", "text-anchor": "end"}, label(value)));
}
return y;
}
function resourceMarks(frame, marks, span) {
for (const mark of marks) {
const left = frame.x(Math.max(mark.from, span.from));
const right = frame.x(Math.min(mark.to, span.to));
if (right <= left) {
continue;
}
const [href, words] = RESOURCE_MARKS[mark.kind];
frame.svg.append(s("rect", {class: `res-mark ${mark.kind}`, "data-from": mark.from, "data-to": mark.to, "data-peak": mark.peak, x: fixed(left), width: fixed(Math.max(right - left, 2)), y: frame.top, height: fixed(frame.bottom - frame.top)}, s("title", {}, words(mark))));
frame.svg.append(s("use", {class: `res-icon ${mark.kind}`, href, x: fixed(left + 2), y: frame.top + 2, width: 11, height: 11}));
}
}
function resourcePercent(entry, list, marks, span, size) {
const frame = resourceFrame(entry, span, size);
const y = resourceScale(frame, [0, 50, 100], (value) => `${value} %`);
for (const zone of Resources.ZONES) {
frame.svg.append(s("rect", {class: `band ${zone.kind}`, "data-zone": zone.kind, x: RESOURCE.left, width: fixed(frame.right - RESOURCE.left), y: fixed(y(zone.to)), height: fixed(y(zone.from) - y(zone.to))}, s("title", {}, RESOURCE_ZONES[zone.kind])));
}
resourceMarks(frame, marks, span);
const tones = new Map();
const legend = [];
for (const line of list) {
if (!tones.has(line.name)) {
tones.set(line.name, tones.size % RESOURCE.tones);
}
const tone = `res-c${tones.get(line.name)} k-${line.kind}`;
const last = [...line.points].reverse().find(([, value]) => value !== null);
const words = `${resourceLabel(line)} ${dash(last === undefined ? null : last[1])} %`;
for (const part of Resources.segments(line.points)) {
const d = part.map(([t, value], index) => `${index ? "L" : "M"}${fixed(frame.x(t))},${fixed(y(Math.min(Math.max(value, 0), 100)))}`).join(" ");
frame.svg.append(s("path", {class: `res-line ${tone}`, "data-series": line.name, "data-kind": line.kind, "data-points": part.length, d: part.length === 1 ? `${d} h1` : d}, s("title", {}, words)));
}
legend.push(h("span", {class: `res-key ${tone}`, "data-series": line.name, "data-kind": line.kind}, words));
}
return {svg: frame.svg, legend};
}
function resourceTokens(entry, stack, span, size) {
const frame = resourceFrame(entry, span, size);
const y = resourceScale(frame, [0, Math.max(stack.top, 1)], compactCount);
const tones = new Map(stack.agents.map((agent, index) => [agent, index % RESOURCE.tones]));
for (const bar of stack.bars) {
const left = frame.x(Math.max(bar.t, span.from));
const right = frame.x(Math.min(bar.t + RESOURCE.hour, span.to));
if (right <= left) {
continue;
}
for (const part of bar.parts) {
const words = `${part.agent} · ${absolute.format(stampOf(bar.t))}: ${part.total} tokens (input ${part.input}, output ${part.output}, cache read ${part.cache_read}, cache write ${part.cache_write}) · ${part.models.join(", ")}`;
frame.svg.append(s("rect", {class: `res-bar res-c${tones.get(part.agent)}`, "data-agent": part.agent, "data-t": bar.t, "data-total": part.total, x: fixed(left), width: fixed(Math.max(right - left - 1, 1)), y: fixed(y(part.to)), height: fixed(Math.max(y(part.from) - y(part.to), 0.5))}, s("title", {}, words)));
}
}
const legend = stack.agents.map((agent) => h("span", {class: `res-key res-c${tones.get(agent)}`, "data-series": agent}, `${agent} ${compactCount(stack.totals[agent])}`));
return {svg: frame.svg, legend};
}
function drawResources() {
const dom = resourceView.dom;
const data = resourceView.data;
if (!dom || !dom.count.isConnected || data === null) {
return;
}
const span = {from: data.since, to: Math.max(data.until, data.since + 60)};
const lines = Resources.lines(data.samples);
const marks = {
disks: [],
host: Resources.waits(data.samples).map((mark) => ({...mark, kind: "wait"})),
gpu: ["asleep", "unknown"].flatMap((kind) => Resources[kind](data.samples).flatMap((gpu) => gpu.spans.map((mark) => ({...mark, kind, name: gpu.name})))),
};
dom.count.textContent = String(data.samples.length);
for (const entry of dom.panels) {
const box = entry.plot.getBoundingClientRect();
const size = {width: Math.round(box.width), height: Math.round(box.height)};
if (size.width < RESOURCE.left + RESOURCE.right + 10 || size.height < RESOURCE.axis + 10) {
entry.plot.replaceChildren();
continue;
}
const drawn = entry.kind === "tokens" ? resourceTokens(entry, Resources.stack(data.tokens), span, size) : resourcePercent(entry, lines[entry.kind], marks[entry.kind], span, size);
entry.plot.replaceChildren(drawn.svg);
entry.legend.replaceChildren(...drawn.legend);
}
}
+2 -2
View File
@@ -55,7 +55,7 @@
}
function screenOf() {
return {stream: streamScreen, inbox: tasksScreen, rules: rulesScreen, agents: agentsScreen, artifacts: artifactsScreen, tasks: tasksScreen}[ui.screen]();
return {stream: streamScreen, inbox: tasksScreen, rules: rulesScreen, agents: agentsScreen, artifacts: artifactsScreen, tasks: tasksScreen, resources: resourcesScreen}[ui.screen]();
}
function render(force) {
@@ -73,7 +73,7 @@
const main = document.getElementById("main");
main.setAttribute("aria-labelledby", `tab-${ui.screen}`);
main.replaceChildren(screenOf());
document.body.classList.toggle("fit", ["tasks", "inbox"].includes(ui.screen) || (["agents", "artifacts"].includes(ui.screen) && !["tree", "hierarchy"].includes(ui.views[ui.screen])));
document.body.classList.toggle("fit", ["tasks", "inbox", "resources"].includes(ui.screen) || (["agents", "artifacts"].includes(ui.screen) && !["tree", "hierarchy"].includes(ui.views[ui.screen])));
drawn.screen = ui.screen;
drawn.sig = sig;
if (g3.host && !g3.host.isConnected) {
+144
View File
@@ -0,0 +1,144 @@
const Resources = (() => {
"use strict";
const IDLE = 30;
const SATURATED = 90;
const CALM = 50;
const GAP = 1800;
const STEP = 60;
const COUNTS = ["input", "output", "cache_read", "cache_write"];
const ZONES = [{kind: "idle", from: 0, to: IDLE}, {kind: "saturated", from: SATURATED, to: 100}];
const finite = (value) => typeof value === "number" && Number.isFinite(value);
const share = (part, whole) => (finite(part) && finite(whole) && whole > 0 ? Math.round((1000 * part) / whole) / 10 : null);
const listOf = (row, key) => (Array.isArray(row[key]) ? row[key].filter((item) => item && typeof item === "object") : []);
const timed = (samples) => samples.filter((row) => row && finite(row.t));
function series(samples, pick) {
const found = new Map();
for (const row of timed(samples)) {
for (const [name, kind, value] of pick(row)) {
const key = `${kind}\u0000${name}`;
if (!found.has(key)) {
found.set(key, {name: String(name), kind, points: []});
}
found.get(key).points.push([row.t, finite(value) ? value : null]);
}
}
return [...found.values()];
}
function lines(samples) {
return {
disks: series(samples, (row) => listOf(row, "disks").map((disk) => [disk.mount, "disk", share(disk.used, disk.total)])),
host: series(samples, (row) => [["CPU", "host", row.cpu], ["RAM", "host", row.ram], ["Swap", "host", row.swap]]),
gpu: series(samples, (row) => listOf(row, "gpus").flatMap((gpu) => [[gpu.name, "util", gpu.asleep ? null : gpu.util], [gpu.name, "vram", gpu.asleep ? null : share(gpu.vram_used, gpu.vram_total)]])),
};
}
function segments(points) {
const found = [];
let current = [];
let last = null;
for (const [t, value] of points) {
if (current.length && (value === null || t - last > GAP)) {
found.push(current);
current = [];
}
if (value !== null) {
current.push([t, value]);
}
last = t;
}
if (current.length) {
found.push(current);
}
return found;
}
function spans(samples, test, value) {
const rows = timed(samples);
const found = [];
let open = null;
rows.forEach((row, index) => {
if (open && index && row.t - rows[index - 1].t > GAP) {
found.push(open);
open = null;
}
if (!test(row)) {
if (open) {
found.push(open);
}
open = null;
return;
}
const next = rows[index + 1];
const end = row.t + (next && next.t - row.t <= GAP ? next.t - row.t : STEP);
const peak = value(row);
open = open ? {from: open.from, to: end, peak: Math.max(open.peak, peak)} : {from: row.t, to: end, peak};
});
if (open) {
found.push(open);
}
return found;
}
function waits(samples) {
const waiting = (row) => (row.queue && finite(row.queue.waiting) ? row.queue.waiting : 0);
return spans(samples, (row) => waiting(row) > 0 && finite(row.cpu) && finite(row.ram) && row.cpu < CALM && row.ram < CALM, waiting);
}
function gpuSpans(samples, test) {
const names = [...new Set(timed(samples).flatMap((row) => listOf(row, "gpus").filter(test).map((gpu) => String(gpu.name))))];
return names.map((name) => ({name, spans: spans(samples, (row) => listOf(row, "gpus").some((gpu) => String(gpu.name) === name && test(gpu)), () => 1)}));
}
const asleep = (samples) => gpuSpans(samples, (gpu) => gpu.asleep === true);
const unknown = (samples) => gpuSpans(samples, (gpu) => gpu.asleep !== true && !finite(gpu.util) && !finite(gpu.vram_used));
function stack(tokens) {
const totals = new Map();
const hours = new Map();
for (const row of tokens) {
if (!row || !finite(row.t) || typeof row.agent !== "string") {
continue;
}
const counts = COUNTS.map((name) => (finite(row[name]) && row[name] > 0 ? row[name] : 0));
const sum = counts.reduce((total, value) => total + value, 0);
if (!totals.has(row.agent)) {
totals.set(row.agent, 0);
}
totals.set(row.agent, totals.get(row.agent) + sum);
if (!hours.has(row.t)) {
hours.set(row.t, new Map());
}
const hour = hours.get(row.t);
if (!hour.has(row.agent)) {
hour.set(row.agent, {agent: row.agent, total: 0, models: [], ...Object.fromEntries(COUNTS.map((name) => [name, 0]))});
}
const entry = hour.get(row.agent);
COUNTS.forEach((name, index) => {
entry[name] += counts[index];
});
entry.total += sum;
if (typeof row.model === "string" && !entry.models.includes(row.model)) {
entry.models.push(row.model);
}
}
const agents = [...totals].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([agent]) => agent);
const bars = [...hours.keys()].sort((a, b) => a - b).map((t) => {
let base = 0;
const parts = agents.filter((agent) => hours.get(t).has(agent)).map((agent) => {
const entry = hours.get(t).get(agent);
const part = {...entry, from: base, to: base + entry.total};
base += entry.total;
return part;
});
return {t, total: base, parts};
});
return {agents, totals: Object.fromEntries(totals), bars, top: Math.max(0, ...bars.map((bar) => bar.total))};
}
return {IDLE, SATURATED, CALM, GAP, COUNTS, ZONES, lines, segments, spans, waits, asleep, unknown, stack};
})();
if (typeof module === "object" && module.exports) {
module.exports = Resources;
}
@@ -0,0 +1,27 @@
body.fit .graph.resources > .res-panels { flex: 1; }
.res-panels { display: flex; flex-direction: column; gap: 8px; min-height: 0; }
.res-panel { display: flex; flex: 1 1 0; flex-direction: column; gap: 2px; min-height: 72px; padding: 6px 10px; overflow: hidden; }
.res-head { display: flex; align-items: center; gap: 4px 12px; min-width: 0; overflow: hidden; color: var(--muted); font-size: var(--fs-xs); white-space: nowrap; }
.res-title { display: inline-flex; color: var(--text); font-size: var(--fs-md); }
.res-legend { display: flex; gap: 4px 12px; min-width: 0; overflow: hidden; font-variant-numeric: tabular-nums; }
.res-key { display: inline-flex; align-items: center; gap: 5px; }
.res-key::before { content: ""; width: 12px; height: 3px; border-radius: 2px; background: var(--c); }
.res-key.k-vram::before { background: repeating-linear-gradient(90deg, var(--c) 0 4px, transparent 4px 6px); }
.res-plot { position: relative; flex: 1; min-height: 0; }
.res-plot > svg { position: absolute; inset: 0; width: 100%; height: 100%; }
svg .band.idle { fill: var(--ok-soft); }
svg .band.saturated { fill: var(--bad-soft); }
svg .res-line { fill: none; stroke: var(--c); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
svg .res-line.k-vram { stroke-dasharray: 5 3; }
svg .res-mark.wait { fill: var(--warn-soft); stroke: var(--warn); stroke-dasharray: 3 3; }
svg .res-mark.asleep { fill: var(--neutral-soft); }
svg .res-mark.unknown { fill: var(--neutral-soft); stroke: var(--muted); stroke-dasharray: 2 3; }
svg .res-icon.wait { fill: var(--warn); }
svg .res-icon.asleep, svg .res-icon.unknown { fill: var(--muted); }
svg .res-bar { fill: var(--c); }
.res-c0 { --c: var(--info); }
.res-c1 { --c: var(--ok); }
.res-c2 { --c: var(--accent); }
.res-c3 { --c: var(--warn); }
.res-c4 { --c: var(--violet); }
.res-c5 { --c: var(--bad); }