Merge feature/orchestrator/s53-agent-presence into master

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	docs/requirements/001-agent-orchestrator.md
#	orchestrator/templates/page-board.js
#	tests/e2e/test_orchestrator_e2e.py
This commit is contained in:
Kevin Veen-Birkenbach committed 2026-11-11 07:48:48 +00:00
commit afebf24703
24 files changed
+798 -13

No files matched your search

@@ -334,6 +334,34 @@ from three views down to any agent, branch or artefact.
remaining digits and hashes. Rejected for paths: every segment with
"slash", the file name only, mapping to known repositories, not speaking
paths.
61. **Agent states** (operator 2026-11-10, decisions 61 to 64): the hub
derives six states per agent session: sleeping (drain or suspend part,
idle),
suspending (drain or suspend part, still busy), working (process alive,
busy), alive (process alive, idle), dead (process gone while a claim or
task still names it) and unreachable (busy without an update for 15
minutes, or a call without a receipt for 10 minutes). Inputs: the Claude
Code session files (status, pid and its start tick), the night state and
the radio receipts; a runtime without a session file shows alive or dead
by its claim heartbeat. The hub's container sees neither the session
files nor the host's processes, so a host timer
(`kvb-orchestrator-sessions.timer`, every 15 seconds while the page
polls) writes a snapshot with each process checked into the hub's
state; one older than 5 minutes counts as absent. Rejected: an agent
hook reporting its own state (a hook and a reinstall on every agent),
the claim heartbeat alone (no working versus alive), the host's PID
namespace or `/proc` in the container (every process's command line in
reach of the web-facing hub).
62. **State symbol**: an animated Bootstrap icon from the vendored sprite
next to every agent in Orbit, Chord, Swimlane, Tree and 3D and in the
agent panel; `prefers-reduced-motion` stops the animation. Rejected:
emoji (font-dependent), a colored ring only (six states hard to tell
apart).
63. **Status menu**: a "Status" menu in the Agents toolbar holds one checkbox
per state and "Faces in 3D", stored per browser like the saved views,
no passkey. Rejected: hub-wide settings with a signature.
64. **3D faces**: agent nodes in the 3D view show their face, on by default
and switchable; repositories and branches stay spheres.
## Approach
@@ -785,6 +813,19 @@ flowchart TD
- [ ] The spot shows the speaking caption, the last five sent messages and
the stations with play and stop.
### Phase 6: agent states and 3D faces (S5.3)
- [ ] The state of every fixture session matches decision 61 for each of the
six states and for a session without a session file (unit test).
- [ ] Orbit, Chord, Swimlane, Tree, 3D and the agent panel show the state
symbol of each fixture agent.
- [ ] Unticking a state in the Status menu hides its symbols in every view;
the choice survives a reload.
- [ ] The 3D view shows agent faces by default and spheres after unticking
"Faces in 3D".
- [ ] `make orchestrator-install` enables `kvb-orchestrator-sessions.timer`,
and `make orchestrator-check` reports it active.
### Tests
- **Unit**
@@ -812,6 +853,10 @@ flowchart TD
redirecting to `#stream` with the spot flag.
- [ ] S5.5: ICAO digit words, Zulu times, DTG with offset conversion,
dates, decimals and spoken paths.
- [ ] S5.3: each agent state from a fixture snapshot, night state and
receipts, a session without a record; the host client's snapshot
from fixture session files with a reused pid; a stale or malformed
snapshot read as absent.
- **Integration**
- [ ] Two scratch sessions exchange `post` and `call` through the real hook
entry points (JSON on stdin): the post appears only on the callee's
@@ -844,3 +889,6 @@ flowchart TD
class sends from it, the icon-only Cancel mayday, the speaking
caption and the stations inside the spot, in Chromium and Firefox at
1920, 1366 and 390 px.
- [ ] S5.3: state symbols in every Agents view and the panel, the Status
menu toggles through a reload, 3D faces on and off, in Chromium and
Firefox at 1920, 1366 and 390 px.
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Args:
--sessions: Claude Code's sessions directory, one <pid>.json per session,
e.g. ~/.claude/sessions.
--output: the snapshot the Agent Orchestrator reads, e.g.
~/.local/state/kvb-orchestrator/agent-sessions.json.
Returns:
Runs on the host, because the hub's container sees neither these files
nor the host's processes: replaces --output with {"written": <epoch>,
"sessions": {<session id>: {"name", "status", "statusUpdatedAt",
"running"}}}, running being whether the record's pid still runs the
process it started. The exit code is 0.
"""
from __future__ import annotations
import argparse
import json
import os
import stat
import tempfile
import time
from pathlib import Path
PROC = Path("/proc")
KEPT = ("name", "status", "statusUpdatedAt")
RECORD_BYTES = 65536
VALUE_CHARS = 200
def read_record(path: Path) -> object:
"""Returns:
The parsed JSON of path, read without following a symlink and only
when it is a regular file of at most RECORD_BYTES.
Raises:
OSError: path is missing, a symlink, no regular file or too big.
ValueError, RecursionError: its content is no JSON or nested too deep.
"""
handle = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
with os.fdopen(handle, "rb") as stream:
info = os.fstat(stream.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_size > RECORD_BYTES:
raise OSError(f"{path}: no regular file of at most {RECORD_BYTES} bytes")
return json.loads(stream.read(RECORD_BYTES + 1).decode("utf-8"))
def kept(record: dict) -> dict:
return {
key: record[key]
for key in KEPT
if isinstance(record.get(key), str | int | float) and not isinstance(record[key], bool) and len(str(record[key])) <= VALUE_CHARS
}
def read_sessions(folder: Path) -> dict[str, dict]:
"""Returns:
The session records under folder by session id; unreadable files and
records without a session id are left out.
"""
found = {}
for path in sorted(folder.glob("*.json")):
try:
data = read_record(path)
except (OSError, ValueError, RecursionError):
continue
if isinstance(data, dict) and isinstance(data.get("sessionId"), str):
found[data["sessionId"]] = data
return found
def running(record: dict, proc: Path) -> bool:
"""Returns:
Whether the record's pid still runs the process it started: the
start tick in /proc/<pid>/stat (field 22) equals procStart, so a
reused pid counts as gone.
"""
pid, start = record.get("pid"), record.get("procStart")
if isinstance(pid, bool) or not isinstance(pid, int) or not isinstance(start, str):
return False
try:
fields = (proc / str(pid) / "stat").read_text(encoding="utf-8").rpartition(")")[2].split()
except OSError:
return False
return len(fields) > 19 and fields[19] == start
def snapshot(folder: Path, proc: Path, now: float) -> dict:
return {
"written": now,
"sessions": {
sid: {**kept(record), "running": running(record, proc)}
for sid, record in read_sessions(folder).items()
},
}
def write(output: Path, data: dict) -> None:
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=output.parent, prefix=f".{output.name}.", delete=False) as handle:
try:
json.dump(data, handle)
except BaseException:
os.unlink(handle.name)
raise
os.replace(handle.name, output)
def main() -> int:
parser = argparse.ArgumentParser(description="Snapshot Claude Code's sessions for the Agent Orchestrator.")
parser.add_argument("--sessions", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
write(args.output, snapshot(args.sessions, PROC, time.time()))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+19
View File
@@ -169,6 +169,25 @@ def night_mode(council: Path) -> str:
return mode if mode in MODES else "day"
def night_parts(council: Path) -> dict[str, str]:
"""Args:
council: the council directory with night.json.
Returns:
Per session the part that differs from night_mode: drain or suspend
for one set alone, day for the active and the resumed sessions while
a mode holds.
"""
data = read_json(council / "night.json")
if not isinstance(data, dict):
return {}
resumed = data.get("resumed", data.get("awake"))
working = [data.get("active"), *(resumed if isinstance(resumed, list) else [])]
parts = {sid: "day" for sid in working if isinstance(sid, str)} if night_mode(council) != "day" else {}
alone = data.get("parts")
return {**parts, **({sid: mode for sid, mode in alone.items() if mode in MODES} if isinstance(alone, dict) else {})}
def silence_of(council: Path, now: float) -> dict | None:
data = read_json(council / "radio-silence.json")
if not isinstance(data, dict):
+3
View File
@@ -25,6 +25,7 @@ from .mcp import Mcp
from .messaging import Messaging
from .names import Names
from .offices import Offices
from .presence import SNAPSHOT
from .redirects import Redirects
from .requesting import Requesting
from .reviewing import Reviewing
@@ -85,6 +86,7 @@ class Hub(Mcp, Reviewing, Asking, Messaging, Choosing, Approving, Denying, Reque
names: Names
watch: Watch
tree: Tree
agent_sessions: Path
model: Model
timers: Path
settings: Settings
@@ -132,6 +134,7 @@ def open_hub(council: Path, pages: Path, public_port: int, state: Path, host_sta
redirects=redirects,
watch=Watch(),
tree=Tree(file=places.tree_file, heartbeat=state / TREE_HEARTBEAT, lock=threading.Lock(), cached=None, stamp=None),
agent_sessions=state / SNAPSHOT,
model=model,
timers=places.timers,
council=council,
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
from pathlib import Path
from .council import HUMAN, MODES, agent
from .records import epoch, read_json
STATES = ("sleeping", "suspending", "alive", "working", "dead", "unreachable")
STALE = 15 * 60
UNANSWERED = 10 * 60
SNAPSHOT = "agent-sessions.json"
SNAPSHOT_STALE = 5 * 60
def read_snapshot(path: Path, now: float) -> dict[str, dict]:
"""Args:
path: the snapshot client/sessions.py writes on the host.
now: the moment to judge at.
Returns:
Its session records by session id; empty when it is missing,
malformed, written more than SNAPSHOT_STALE ago or dated in the
future.
"""
data = read_json(path)
if not isinstance(data, dict) or not isinstance(data.get("sessions"), dict):
return {}
written = data.get("written")
if isinstance(written, bool) or not isinstance(written, int | float) or not 0 <= now - written <= SNAPSHOT_STALE:
return {}
return {sid: record for sid, record in data["sessions"].items() if isinstance(record, dict)}
def unanswered(news: list[dict], now: float) -> list[tuple[str, float]]:
"""Returns:
(callee name, call time) of every call older than UNANSWERED that its
callee never receipted.
"""
receipts = {(agent(row.get("from")), round(epoch(row.get("call")) or 0, 3)) for row in news if row.get("class") == "receipt"}
late = []
for row in news:
ts = epoch(row.get("ts"))
if row.get("class") != "call" or ts is None or now - ts < UNANSWERED or not isinstance(row.get("to"), list):
continue
late += [(name, ts) for name in map(agent, row["to"]) if name and (name, round(ts, 3)) not in receipts]
return late
def presence(sessions: dict[str, dict], parts: dict[str, str], mode: str, claims: list[dict], news: list[dict], now: float) -> dict[str, str]:
"""Args:
sessions: the session records of read_snapshot; running says
whether a record's process still runs.
parts: night_parts, a session's own part over the mode.
mode: night_mode, drain, suspend or day.
claims: the council claims; a session without a record shows alive
or dead by its freshest beat.
news: the radio rows, for calls without a receipt.
now: the moment to judge at.
Returns:
One of STATES per session id (decision 61).
"""
late = unanswered(news, now)
beats: dict[str, list[float]] = {}
names: dict[str, set[str]] = {}
for claim in claims:
if claim["session"] != "-" and claim["agent"] != HUMAN:
beats.setdefault(claim["session"], []).append(claim["beat"] or 0)
names.setdefault(claim["session"], set()).add(claim["agent"])
states = {sid: "alive" if now - max(beat) < STALE else "dead" for sid, beat in beats.items() if sid not in sessions}
for sid, record in sessions.items():
if record.get("running") is not True:
states[sid] = "dead"
continue
busy = record.get("status") == "busy"
stamp = record.get("statusUpdatedAt")
updated = (epoch(stamp / 1000) if isinstance(stamp, int | float) and not isinstance(stamp, bool) else None) or 0
called = names.get(sid, set()) | {agent(record.get("name"))}
if (busy and now - updated > STALE) or any(name in called and ts > updated for name, ts in late):
states[sid] = "unreachable"
continue
part = parts.get(sid, mode)
if part in MODES:
states[sid] = "suspending" if busy else "sleeping"
else:
states[sid] = "working" if busy else "alive"
return states
+4
View File
@@ -8,6 +8,7 @@ from .council import (
NEWS,
grants,
night_mode,
night_parts,
radio_rows,
read_claims,
sessions,
@@ -22,6 +23,7 @@ from .jobs import JOB_OPEN
from .messaging import message_row
from .orbits import TRACK_STEPS, track
from .phrasing import phrase
from .presence import presence, read_snapshot
from .records import epoch, iso_epoch, read_jsonl
from .register import (
AGENT_ORDER,
@@ -138,6 +140,8 @@ class View:
"claims": claims,
"grants": grant_rows,
"night": night_mode(self.council),
"night_parts": night_parts(self.council),
"presence": presence(read_snapshot(self.agent_sessions, now), night_parts(self.council), night_mode(self.council), claims, news, now),
"silence": silence_of(self.council, now),
"graph": drawn,
}
@@ -0,0 +1,10 @@
[Unit]
Description=Agent Orchestrator sessions: Claude Code's sessions and whether their process runs, 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=/usr/bin/env python3 %h/.local/share/kvb-orchestrator/client/sessions.py --sessions %h/.claude/sessions --output %h/.local/state/kvb-orchestrator/agent-sessions.json
Nice=10
TimeoutStartSec=60
@@ -0,0 +1,10 @@
[Unit]
Description=Agent Orchestrator sessions 15 seconds after its last run
[Timer]
OnActiveSec=5s
OnUnitInactiveSec=15s
AccuracySec=1s
[Install]
WantedBy=timers.target
+1
View File
@@ -15,6 +15,7 @@
const tasks = node ? node.tasks : [];
const claims = node ? node.claims : [];
agentDom.info.replaceChildren(
...[presenceMark(ui.agent, true)].filter(Boolean),
h("p", {class: "muted"}, claims.length ? `Offices: ${claims.map((claim) => claim.office).join(", ")}` : "No office"),
h("h3", {}, "Tasks", counter(tasks.length)),
tasks.length ? h("ul", {class: "mini"}, tasks.map((task) => h("li", {},
+23 -1
View File
@@ -91,10 +91,32 @@
const mode = h("span", {});
const night = h("details", {class: "menu"}, h("summary", named("Day/Night"), icon("bi-moon-stars"), caret()), h("div", {class: "menu-box"}, mode,
h("div", {class: "actions"}, ACTIONS.slice(1).map(([id]) => actionButton(id, "btn small")))));
const bar = h("div", {class: "toolbar", role: "toolbar", "aria-label": "Controls"}, iconBtn("bi-sort-down", "Priority", "btn small", openAgentOrder, "Priority: agent order in the hierarchy"), night);
const bar = h("div", {class: "toolbar", role: "toolbar", "aria-label": "Controls"}, iconBtn("bi-sort-down", "Priority", "btn small", openAgentOrder, "Priority: agent order in the hierarchy"), night, statusMenu());
return {bar, mode};
}
function lookBox(id, label, on, run) {
const state = Object.hasOwn(PRESENCE, id) ? {class: "check presence", "data-state": id, "data-tone": PRESENCE[id][2]} : {class: "check"};
return h("label", state, h("input", {type: "checkbox", checked: on, "data-look": id, on: {change: (event) => run(event.target.checked)}}), state["data-state"] ? icon(PRESENCE[id][1], "glyph") : null, label);
}
function setLook(change) {
change();
keep(STORE.status, {hidden: [...look.hidden], faces: look.faces});
render(false);
drawAgent();
}
function statusMenu() {
return h("details", {class: "menu"}, h("summary", named("Status", "Status: which agent states show their symbol, and faces in 3D"), icon("bi-activity"), caret()),
h("div", {class: "menu-box"}, h("h3", {}, "Agent states"),
Object.entries(PRESENCE).map(([id, [label]]) => lookBox(id, label, !look.hidden.has(id), (on) => setLook(() => (on ? look.hidden.delete(id) : look.hidden.add(id))))),
h("h3", {}, "3D"),
lookBox("faces", "Faces in 3D", look.faces, (on) => setLook(() => {
look.faces = on;
}))));
}
function drawToolbar() {
drawSpot();
const toolbar = graphDom && graphDom.toolbar;
+9 -1
View File
@@ -37,7 +37,15 @@
const MOVE = 2000;
const RELAYOUT = 60000;
const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
const STORE = {views: "kvb-orchestrator.views"};
const STORE = {views: "kvb-orchestrator.views", status: "kvb-orchestrator.status"};
const PRESENCE = {
working: ["Working", "bi-gear-fill", "info"],
alive: ["Alive", "bi-heart-pulse-fill", "ok"],
sleeping: ["Sleeping", "bi-moon-stars-fill", "violet"],
suspending: ["Suspending", "bi-hourglass-split", "warn"],
unreachable: ["Unreachable", "bi-wifi-off", "bad"],
dead: ["Dead", "bi-x-octagon-fill", "bad"],
};
const SOUND_KINDS = [["mayday", "MAYDAY / PAN-PAN"], ["review", "Reviews, asks, decisions"], ["direct", "Post and calls to you"], ["radio", "All radio"]];
const SPEECH_CLASSES = [["post", "Post"], ["call", "Call"], ["newsletter", "Newsletter"], ["broadcast", "Broadcast"], ["mayday", "MAYDAY"], ["pan-pan", "PAN-PAN"], ["security", "Sécurité"]];
const SPEECH_QUEUE = 20;
+14
View File
@@ -75,6 +75,20 @@
return h("span", {class: `avatar${big ? " big" : ""}${talking(key) ? " speaking" : ""}`, "data-face": key, "aria-hidden": "true"},
s("svg", {class: "face-art", viewBox: `0 0 ${Faces.SIZE} ${Faces.SIZE}`, focusable: "false"}, faceParts(key)));
};
const presenceOf = (key) => {
const value = (state.presence || {})[key];
return Object.hasOwn(PRESENCE, value) && !look.hidden.has(value) ? value : null;
};
const presenceMark = (key, labelled) => {
const value = presenceOf(key);
return value ? h("span", {class: "presence", "data-state": value, "data-tone": PRESENCE[value][2], role: "img", ...named(PRESENCE[value][0])}, icon(PRESENCE[value][1], "glyph"), labelled ? PRESENCE[value][0] : null) : null;
};
const presenceGlyph = (key, x, y, size) => {
const value = presenceOf(key);
return value ? s("g", {class: "presence", "data-state": value, "data-tone": PRESENCE[value][2], transform: `translate(${fixed(x)} ${fixed(y)})`},
s("title", {}, PRESENCE[value][0]), s("rect", {class: "presence-dot", x: fixed(-size * 0.65), y: fixed(-size * 0.65), width: fixed(size * 1.3), height: fixed(size * 1.3), rx: fixed(size * 0.65)}),
s("g", {class: "glyph"}, s("use", {href: `#${PRESENCE[value][1]}`, x: fixed(-size / 2), y: fixed(-size / 2), width: fixed(size), height: fixed(size)}))) : null;
};
const person = (name) => (name && name !== "-" && name !== "all" && name !== HUMAN ? h("button", {type: "button", class: "linkish", title: `Open ${name}`, on: {click: () => openAgent(name)}}, name) : dash(name));
const people = () => state.people || {};
const labelOf = (key) => (people()[key] || {name: String(key).replace(/^name:/, "")}).name;
@@ -114,6 +114,7 @@
lanes.forEach((node, index) => {
svg.append(s("rect", {class: index % 2 ? "lane odd" : "lane", x: 0, y: top + index * lane, width: full, height: lane}));
svg.append(s("text", {class: "lane-label", x: 14, y: top + index * lane + lane / 2, dy: "0.35em", "text-anchor": "start", "data-pick": node.id, on: {click: () => select(node.id)}}, node.label));
svg.append(...[presenceGlyph(node.id, left - 14, top + index * lane + lane / 2, 12)].filter(Boolean));
});
for (let index = 0; index <= 6; index += 1) {
const ts = begin + ((end - begin) * index) / 6;
@@ -138,6 +138,7 @@
s("title", {}, `${node.label} · ${NODE_KINDS[node.kind]}${node.parent ? ` of ${labelOf(node.parent)}` : ""}${tasks}`),
s("circle", {r}),
node.kind === "subagent" ? null : s("g", {class: "face-art", transform: `translate(${-r} ${-r}) scale(${(2 * r) / Faces.SIZE})`}, faceParts(node.id)),
node.kind === "subagent" ? null : presenceGlyph(node.id, r * 0.8, -r * 0.8, Math.max(10, r * 0.7)),
caption(node, at, r, gap));
}
+146 -2
View File
@@ -10,7 +10,8 @@
const SPAWNED = new Set(["twig", "subagent"]);
const DEPTH_GAP = 90;
const TIME_DEPTH = 640;
const g3 = {lib: null, three: null, graph: null, host: null, tab: "", sig: "", planes: [], colors: null, failed: "", picked: null, fit: false};
const TOP = {operator: 6, chair: 4, session: 2.5};
const g3 = {lib: null, three: null, THREE: null, graph: null, host: null, tab: "", sig: "", planes: [], colors: null, failed: "", picked: null, fit: false, badges: new Map(), faces: new Set(), textures: new Map(), pulse: 0};
const layoutsOf = (tab) => (tab === "tasks" ? LAYOUTS.filter(([id]) => id !== "spawn") : LAYOUTS);
const layoutOf = (tab) => (layoutsOf(tab).some(([id]) => id === space.views.graph.axis) ? space.views.graph.axis : "none");
@@ -84,6 +85,7 @@
const nodes = state.graph.nodes.map((node) => ({
id: node.id, kind: node.kind, label: node.label, depth: depthOf[node.kind], ts: born(node) || (node.timeline[0] || {}).since || state.since,
parent: node.kind === "operator" ? null : node.kind === "chair" ? HUMAN : node.kind === "session" ? chairKey() : node.parent || chairKey(),
face: look.faces && Object.hasOwn(TOP, node.kind), badge: node.kind === "chair" || node.kind === "session",
}));
for (const node of state.graph.nodes.filter((entry) => entry.kind === "session" || entry.kind === "chair")) {
const walk = (twig, parent, depth) => {
@@ -196,7 +198,135 @@
}
function signature3d(tab, data) {
return JSON.stringify([tab, layoutOf(tab), data.nodes.map((node) => [node.id, node.kind, node.depth, node.ts, node.tone]), data.links.map((link) => [link.source, link.target, link.kind])]);
return JSON.stringify([tab, layoutOf(tab), data.nodes.map((node) => [node.id, node.kind, node.depth, node.ts, node.tone, node.face]), data.links.map((link) => [link.source, link.target, link.kind])]);
}
function canvasTexture(size, paint) {
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
paint(canvas.getContext("2d"));
const texture = new g3.THREE.CanvasTexture(canvas);
texture.colorSpace = g3.THREE.SRGBColorSpace;
return texture;
}
function cached(key, make) {
if (!g3.textures.has(key)) {
g3.textures.set(key, make());
}
return g3.textures.get(key);
}
function paintFace(context, key) {
context.scale(128 / Faces.SIZE, 128 / Faces.SIZE);
for (const [tag, part] of Faces.of(key).parts) {
const path = tag === "path" ? new Path2D(part.d) : new Path2D();
if (tag === "ellipse") {
path.ellipse(part.cx, part.cy, part.rx, part.ry, 0, 0, 2 * Math.PI);
} else if (tag === "rect") {
path.roundRect(part.x, part.y, part.width, part.height, part.rx || 0);
}
context.globalAlpha = part.opacity ?? 1;
if (part.fill && part.fill !== "none") {
context.fillStyle = part.fill;
context.fill(path);
}
if (part.stroke) {
context.globalAlpha *= part["stroke-opacity"] ?? 1;
context.strokeStyle = part.stroke;
context.lineWidth = part["stroke-width"] || 1;
context.lineCap = part["stroke-linecap"] || "butt";
context.lineJoin = part["stroke-linejoin"] || "miter";
context.stroke(path);
}
}
}
function badgeTexture(value) {
const [, name, tone] = PRESENCE[value];
const color = g3.colors[tone] || g3.colors.text;
return cached(`${value}:${color}`, () => canvasTexture(64, (context) => {
context.fillStyle = "rgba(255, 255, 255, 0.92)";
context.arc(32, 32, 31, 0, 2 * Math.PI);
context.fill();
context.fillStyle = color;
context.translate(12, 12);
context.scale(2.5, 2.5);
for (const path of document.querySelectorAll(`#${name} path`)) {
context.fill(new Path2D(path.getAttribute("d")), path.getAttribute("fill-rule") || "nonzero");
}
}));
}
function count3d() {
const box = g3.host && g3.host.querySelector(".graph3d");
if (box) {
box.dataset.faces = String(g3.faces.size);
box.dataset.badges = String([...g3.badges.values()].filter((badge) => badge.visible).length);
}
if (!g3.pulse && g3.badges.size && !calm.matches) {
g3.pulse = window.requestAnimationFrame(pulse3d);
}
}
function paintBadge(key, badge) {
const value = presenceOf(key);
badge.visible = value !== null;
badge.userData.state = value;
const map = value ? badgeTexture(value) : null;
if (map && badge.material.map !== map) {
badge.material.map = map;
badge.material.needsUpdate = true;
}
}
function badges3d() {
for (const [key, badge] of g3.badges) {
paintBadge(key, badge);
}
count3d();
}
function pulse3d(now) {
g3.pulse = 0;
if (!g3.graph || !g3.badges.size || calm.matches) {
return;
}
for (const badge of g3.badges.values()) {
const {state: value, size} = badge.userData;
const beat = value === "alive" ? 1 + 0.2 * Math.max(0, Math.sin(now / 150)) : 1;
badge.scale.set(size * beat, size * beat, 1);
badge.material.rotation = value === "working" ? now / 500 : value === "suspending" ? Math.PI * (Math.floor(now / 1200) % 2) : 0;
badge.material.opacity = ["sleeping", "dead", "unreachable"].includes(value) ? 0.6 + 0.4 * Math.cos(now / (value === "unreachable" ? 200 : 900)) ** 2 : 1;
}
g3.pulse = window.requestAnimationFrame(pulse3d);
}
function object3d(node) {
const THREE = g3.THREE;
if (!THREE || !(node.face || node.badge)) {
return null;
}
const radius = Math.cbrt(TOP[node.kind]) * 5;
const group = new THREE.Group();
if (node.face) {
const face = new THREE.Sprite(new THREE.SpriteMaterial({map: cached(`face:${node.id}`, () => canvasTexture(128, (context) => paintFace(context, node.id)))}));
face.scale.set(radius * 2.4, radius * 2.4, 1);
group.add(face);
g3.faces.add(node.id);
}
if (node.badge) {
const badge = new THREE.Sprite(new THREE.SpriteMaterial());
badge.userData.size = radius * 1.1;
badge.scale.set(badge.userData.size, badge.userData.size, 1);
badge.position.set(0, radius * 1.7, 0);
paintBadge(node.id, badge);
g3.badges.set(node.id, badge);
group.add(badge);
}
count3d();
return group;
}
async function stage3d(tab, host) {
@@ -209,6 +339,7 @@
const data = model3d(tab);
const sig = signature3d(tab, data);
if (g3.host === host && g3.sig === sig) {
badges3d();
return;
}
if (g3.host !== host) {
@@ -232,6 +363,12 @@
host.replaceChildren(emptyState("3D not loaded", g3.failed || "3d-force-graph is missing."));
return;
}
if (tab === "agents" && !g3.THREE) {
g3.THREE = await threeModule();
if (g3.host !== host) {
return;
}
}
if (!g3.graph) {
try {
g3.graph = Graph({controlType: "orbit"})(box);
@@ -246,6 +383,7 @@
.nodeColor((node) => (node.id === g3.picked ? g3.colors.alarm : g3.colors[node.tone || KIND_COLORS[node.kind]] || colors.text)).linkColor((link) => g3.colors[LINK_COLORS[link.kind]] || colors.text)
.linkWidth((link) => (link.kind === "contains" || link.kind === "spawn" ? 0.6 : Math.min(0.8 + Math.log2(1 + (link.count || 1)), 4)))
.linkDirectionalArrowLength((link) => (DEPENDENCIES.has(link.kind) ? 5 : 0)).linkDirectionalArrowRelPos(1)
.nodeThreeObject(object3d).nodeThreeObjectExtend((node) => !node.face)
.onNodeClick((node) => pick3d(g3.tab, node)).onEngineStop(builtFit);
g3.fit = true;
g3.graph.warmupTicks(calm.matches ? 160 : 80);
@@ -257,6 +395,8 @@
}
const layout = layoutOf(tab);
place(data.nodes, layout);
g3.badges = new Map();
g3.faces = new Set();
g3.graph.width(box.clientWidth).height(box.clientHeight).graphData(data);
drawPlanes(layout, data.nodes);
const words = `${data.nodes.length} nodes · ${data.links.length} edges · ${LAYOUTS.find(([id]) => id === layout)[1]}${calm.matches ? " · without motion (prefers-reduced-motion)" : ""}`;
@@ -271,10 +411,14 @@
g3.graph._destructor();
}
}
window.cancelAnimationFrame(g3.pulse);
g3.pulse = 0;
g3.graph = null;
g3.host = null;
g3.sig = "";
g3.planes = [];
g3.badges = new Map();
g3.faces = new Set();
}
function refresh3d() {
+2 -1
View File
@@ -11,7 +11,7 @@
function hierarchySig() {
const held = state.claims.map((claim) => [claim.office, claim.session, claim.agent, claim.work, claim.beat !== null && claim.beat >= state.now - ACTIVE_BEAT]);
return JSON.stringify([held, state.agent_order, state.people]);
return JSON.stringify([held, state.agent_order, state.people, state.presence, [...look.hidden]]);
}
function drawHierarchy(force) {
@@ -56,6 +56,7 @@
return h("div", {class: "org-row", "data-office": claim.office},
h("span", {class: "org-office", title: `${title} ${claim.office}`}, icon(OFFICE_ICONS[claim.kind]), h("strong", {}, label)),
who ? h("button", {type: "button", class: "linkish org-holder", "data-focus": `agent:${claim.office}`, title: `Open ${who}`, on: {click: () => openAgent(who, near(`#main [data-focus="agent:${CSS.escape(claim.office)}"]`))}}, face(who, false), who) : h("span", {class: "muted"}, "–"),
claim.session === "-" ? null : presenceMark(claim.session, false),
active ? chip("bi-play-circle", "active", "ok") : chip("bi-pause-circle", "no heartbeat", "neutral"),
claim.work && claim.work !== "-" ? h("span", {class: "muted org-work", title: claim.work}, short(claim.work)) : null,
h("span", {class: "org-actions"},
+1 -1
View File
@@ -93,7 +93,7 @@
const name = open ? h("button", {type: "button", class: "linkish", title: entry.kind === "twig" ? "Show transcript in the stream" : `Open ${entry.label}`, "data-pick": entry.kind === "twig" ? null : entry.id, on: {click: open}}, entry.label) : h("strong", {}, entry.label);
const meta = entry.kind === "twig" ? h("span", {class: "muted"}, `Depth ${entry.row.depth} · `, when(entry.row.started)) : h("span", {class: "muted"}, NODE_KINDS[entry.kind]);
return h("li", {"data-kind": entry.kind, "data-running": entry.running === null ? null : String(entry.running)},
h("div", {class: "spawn-row"}, h("span", {class: "state", role: "img", ...named(NODE_KINDS[entry.kind] || "Subagent")}, icon(SPAWN_KINDS[entry.kind])), name, running, meta),
h("div", {class: "spawn-row"}, h("span", {class: "state", role: "img", ...named(NODE_KINDS[entry.kind] || "Subagent")}, icon(SPAWN_KINDS[entry.kind])), name, entry.kind === "session" || entry.kind === "chair" ? presenceMark(entry.id, false) : null, running, meta),
entry.children.length ? h("ul", {class: "spawn-tree"}, entry.children.map(spawnItem)) : null);
}
+6
View File
@@ -25,6 +25,11 @@
.map((view) => ({name: view.name.slice(0, 60), kinds: view.kinds, waiting: view.waiting === true, q: typeof view.q === "string" ? view.q : "", agent: typeof view.agent === "string" ? view.agent : ""}));
}
function validLook(value) {
const hidden = value && Array.isArray(value.hidden) ? value.hidden.filter((name) => Object.hasOwn(PRESENCE, name)) : [];
return {hidden: new Set(hidden), faces: !(value && value.faces === false)};
}
let state = JSON.parse(document.getElementById("state").textContent);
const KINDS = Object.entries(state.kinds);
const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree", tasks: "list"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", spot: false, cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
@@ -46,6 +51,7 @@
views: {graph: {...state.settings.space.graph}, sky: {...state.settings.space.sky}}, glides: {graph: null, sky: null}, edits: 0, saved: 0, timer: 0, look: false, compass: null,
};
let userViews = validViews(stored(STORE.views, stored("kvb-hub.views", [])));
const look = validLook(stored(STORE.status, null));
let settings = state.settings;
let settingsSig = JSON.stringify(settings);
let agentDom = null;
+24
View File
@@ -18,6 +18,30 @@ svg .node.speaking circle { stroke: var(--ok); stroke-width: 4; animation: speak
@media (prefers-reduced-motion: reduce) {
.radio-caption .speaker-face::after, svg .node.speaking circle, .speaking .face-art .mouth { animation: none; }
}
.presence { display: inline-flex; align-items: center; gap: 4px; flex: none; color: var(--muted); font-size: var(--fs-sm); font-weight: 600; }
.presence[data-tone="ok"] { color: var(--ok); }
.presence[data-tone="bad"] { color: var(--bad); }
.presence[data-tone="warn"] { color: var(--warn); }
.presence[data-tone="info"] { color: var(--info); }
.presence[data-tone="violet"] { color: var(--violet); }
svg .presence { fill: currentColor; }
svg .presence-dot { fill: var(--panel); stroke: currentColor; stroke-width: 1; }
.presence .glyph { transform-box: fill-box; transform-origin: center; }
.presence[data-state="working"] .glyph { animation: presence-spin 3s linear infinite; }
.presence[data-state="alive"] .glyph { animation: presence-beat 1.6s ease-in-out infinite; }
.presence[data-state="sleeping"] .glyph { animation: presence-drift 4s ease-in-out infinite; }
.presence[data-state="suspending"] .glyph { animation: presence-flip 2.4s ease-in-out infinite; }
.presence[data-state="unreachable"] .glyph { animation: presence-blink 1.2s ease-in-out infinite; }
.presence[data-state="dead"] .glyph { animation: presence-fade 5s ease-in-out infinite; }
@keyframes presence-spin { to { transform: rotate(360deg); } }
@keyframes presence-beat { 15%, 45% { transform: scale(1.25); } 30% { transform: scale(1); } }
@keyframes presence-drift { 50% { transform: translateY(-15%); opacity: .6; } }
@keyframes presence-flip { 50%, 100% { transform: rotate(180deg); } }
@keyframes presence-blink { 50% { opacity: .25; } }
@keyframes presence-fade { 50% { opacity: .35; } }
@media (prefers-reduced-motion: reduce) {
.presence .glyph { animation: none; }
}
.stations { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }
.station { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) auto auto; align-items: center; gap: var(--sp-2); min-width: 0; padding: 3px 3px 3px 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--sunken); }
.station-name, .station-when { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+21 -2
View File
@@ -4,7 +4,7 @@
# write its .env, build and start the container, register the MCP
# server for Claude Code, build pkgmgr from the newest signed tag of
# PKGMGR_REPO and enable the tree timer and the cleanup path unit,
# enable the offices path unit; re-enable the runner and re-register
# enable the sessions timer and the offices path unit; re-enable the runner and re-register
# the hook when the migration took them over; an enabled runner gets
# the fresh unit and a restart
# migrate move every trace of the earlier name to the kvb-orchestrator names
@@ -39,6 +39,7 @@ RUNNER="${NAME}-runner.service"
TREE_UNIT="${NAME}-tree"
CLEANUP_UNIT="${NAME}-cleanup"
OFFICES_UNIT="${NAME}-offices"
SESSIONS_UNIT="${NAME}-sessions"
INSTALLED_SKILLS="${HOME}/.claude/skills"
HOST_SCRIPTS=(kvb-org-organize/scripts/council.py kvb-org-organize/scripts/nofollow.py kvb-org-rasci/scripts/rasci.py)
TREE_DIR="${HOME}/.local/state/pkgmgr/tree"
@@ -154,7 +155,7 @@ install_hub() {
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
find "${HUB}" -name __pycache__ -type d -prune -exec rm -rf {} +
chmod +x "${HUB}/client/permission_hook.py" "${HUB}/client/runner.py" "${HUB}/client/approval.py" "${HUB}/client/cleanup.py" "${HUB}/client/offices.py"
chmod +x "${HUB}/client/permission_hook.py" "${HUB}/client/runner.py" "${HUB}/client/approval.py" "${HUB}/client/cleanup.py" "${HUB}/client/offices.py" "${HUB}/client/sessions.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_TREE=%s\nKVB_ORCHESTRATOR_TIMERS=%s\nKVB_ORCHESTRATOR_APPROVERS=%s\n' \
@@ -230,6 +231,18 @@ tree_timer() {
log "skills: ${CLEANUP_UNIT}.path deletes the merged branches the operator signs in the Artifacts Tree, locally only"
}
sessions_timer() {
if ! has_systemd; then
warn "skills: no systemd user session; the agent states stay unknown."
return 0
fi
cp -f "${HUB}/systemd/${SESSIONS_UNIT}.service" "${HUB}/systemd/${SESSIONS_UNIT}.timer" "${UNITS}/"
systemctl --user daemon-reload
systemctl --user enable --now "${SESSIONS_UNIT}.timer"
systemctl --user restart "${SESSIONS_UNIT}.timer"
log "skills: ${SESSIONS_UNIT}.timer writes ${STATE}/agent-sessions.json every 15 seconds while the Agent Orchestrator page polls"
}
skills_current() {
local script
for script in "${HOST_SCRIPTS[@]}"; do
@@ -257,6 +270,7 @@ install_all() {
migrate || return 0
install_hub
tree_timer
sessions_timer
offices_unit
if ((TOOK_RUNNER)) || runner_enabled; then runner || true; fi
if ((TOOK_HOOK)); then
@@ -335,6 +349,11 @@ check() {
else
todo "${CLEANUP_UNIT}.path off: deleting merged branches from the Artifacts Tree waits for make orchestrator-install"
fi
if [[ "$(systemctl --user is-active "${SESSIONS_UNIT}.timer" 2>/dev/null || true)" == "active" ]]; then
ok "${SESSIONS_UNIT}.timer active"
else
todo "${SESSIONS_UNIT}.timer off: agent states wait for make orchestrator-install"
fi
if [[ "$(systemctl --user is-active "${OFFICES_UNIT}.path" 2>/dev/null || true)" == "active" ]]; then
ok "${OFFICES_UNIT}.path active"
else
+99 -3
View File
@@ -751,6 +751,62 @@ RADIO_JUMP = """(async () => {@HELPERS@
header: all("header.top button").map((button) => [button.id, name(button), text(button)]), radioTab: Boolean(document.getElementById("tab-radio")),
};
})()""".replace("@HELPERS@", HELPERS)
PRESENCE_HELPERS = """
const states = () => all("#main .presence:not(label)").map((node) => node.dataset.state).sort();
const statusMenu = () => {
const menu = all('#main [role="toolbar"] details.menu').find((node) => name(node.querySelector("summary")) === "Status");
menu.open = true;
return menu;
};
const looks = (menu) => all("input[data-look]", menu).map((input) => [input.dataset.look, input.checked]);
const graph3d = async (faces) => {
const box = await until(() => {
const found = document.querySelector("#main .graph3d");
return found && found.dataset.faces !== undefined && (faces === null || found.dataset.faces === faces) ? found : null;
}, 15000);
return box ? [Number(box.dataset.faces), Number(box.dataset.badges)] : null;
};
"""
PRESENCE_WALK = """(async () => {@HELPERS@@PRESENCE@
const views = {};
for (const view of ["orbit", "chord", "swimlane", "tree", "hierarchy"]) {
location.hash = `#agents/${view}`;
await until(() => all("#main .presence:not(label)").length, 6000);
await sleep(300);
views[view] = states();
}
location.hash = "#agents/orbit";
await sleep(700);
const node = document.querySelector('#main svg .node[data-pick="s1"]');
const motion = getComputedStyle(node.querySelector(".presence .glyph")).animationName;
node.dispatchEvent(new MouseEvent("click", {bubbles: true}));
const mark = await until(() => document.querySelector("#popup .presence"), 4000);
const profile = mark ? [mark.dataset.state, text(mark), mark.getAttribute("aria-label")] : null;
(document.activeElement || document.body).dispatchEvent(new KeyboardEvent("keydown", {key: "Escape", bubbles: true}));
await sleep(300);
location.hash = "#agents/3d";
const shown = await graph3d(null);
const menu = statusMenu();
const before = looks(menu);
menu.querySelector('input[data-look="working"]').click();
menu.querySelector('input[data-look="faces"]').click();
const hidden = await graph3d("0");
location.hash = "#agents/orbit";
await sleep(700);
return {views, motion, profile, shown, before, hidden, orbit: states(), @FITS@};
})()""".replace("@HELPERS@", HELPERS).replace("@PRESENCE@", PRESENCE_HELPERS).replace("@FITS@", FITS)
PRESENCE_READ = """(async () => {@HELPERS@@PRESENCE@
await until(() => document.querySelector('#main svg .node[data-pick="s2"]'), 8000);
await sleep(300);
const menu = statusMenu();
const kept = {boxes: looks(menu), orbit: states()};
menu.querySelector('input[data-look="working"]').click();
menu.querySelector('input[data-look="faces"]').click();
await sleep(300);
const back = states();
location.hash = "#agents/3d";
return {...kept, back, restored: await graph3d(null)};
})()""".replace("@HELPERS@", HELPERS).replace("@PRESENCE@", PRESENCE_HELPERS)
GEAR_SAVE = """(async () => {@HELPERS@@CLASHES@
const spot = document.getElementById("radio-spot");
const gear = all(".spot-head button", spot).find((button) => name(button) === "Radio settings");
@@ -1642,7 +1698,7 @@ class TestOrchestratorE2E(HubCase):
self.assertTrue(by_hash[f"#{tab}/3d"]["canvas"], by_hash[f"#{tab}/3d"]["note"])
self.assertRegex(by_hash[f"#{tab}/3d"]["note"], r"^\d+ nodes · \d+ edges · Force$")
agents = by_hash["#agents/orbit"]
self.assertEqual(agents["toolbar"], ["Priority", "Day/Night"])
self.assertEqual(agents["toolbar"], ["Priority", "Day/Night", "Status"])
self.assertEqual(agents["spot"], {"hidden": True, "channels": CHOICE_CHANNELS})
hierarchy = by_hash["#agents/hierarchy"]
self.assertEqual([office for office, _ in hierarchy["org"]], ["chair", "identity/oidc", "work/s1", "work/s2"])
@@ -1799,7 +1855,7 @@ class TestOrchestratorE2E(HubCase):
for result, (width, _) in zip(data["results"], WIDTHS, strict=True):
with self.subTest(width=width):
self.assertNotIn("error", result)
self.assertEqual(result["toolbar"], ["Priority", "Day/Night", "Drain", "Suspend", "Resume"])
self.assertEqual(result["toolbar"], ["Priority", "Day/Night", "Drain", "Suspend", "Resume", "Status"])
self.assertEqual((result["title"], result["shown"]), ("Cancel mayday: lift the radio silence so the agents write again; your passkey signs it", ""))
self.assertEqual(result["declined"], {"question": [question], "posted": [], "toasts": []})
self.assertEqual(result["accepted"], {"question": [question], "posted": ["/api/board/sign", "/api/board/clear-mayday"]})
@@ -1836,7 +1892,7 @@ class TestOrchestratorE2E(HubCase):
self.assertNotIn("error", probe)
self.assertEqual((sent["closed"], sent["open"]), ([True, "false"], [False, "true", True]))
self.assertEqual(sent["inline"], ["static", True, True])
self.assertEqual((sent["toolbar"], sent["channels"], sent["record"]), (["Priority", "Day/Night"], CHOICE_CHANNELS, ["Record"]))
self.assertEqual((sent["toolbar"], sent["channels"], sent["record"]), (["Priority", "Day/Night", "Status"], CHOICE_CHANNELS, ["Record"]))
self.assertEqual(sent["heads"], [["Radio settings", "Radio settings: tones, squelch and read aloud"], ["Cancel mayday", "Cancel mayday: lift the radio silence so the agents write again; your passkey signs it"]])
self.assertEqual((sent["tall"], sent["wide"]), (False, False))
self.assertEqual([run["cls"] for run in sent["runs"]], CHOICE_CHANNELS)
@@ -2509,6 +2565,46 @@ class TestOrchestratorE2E(HubCase):
def test_firefox_draws_the_trend_forecast_point_tips_eta_and_tasks_3d_at_three_widths(self):
self.check_trend_eta_3d("firefox", "bidi.mjs", os.environ.get("FIREFOX", "firefox"))
def check_presence(self, browser: str, driver: str, binary: str) -> None:
"""Args:
browser: the file name prefix of the screenshots and the result.
driver: the browser driver script next to this file.
binary: the browser to drive.
"""
record = {"status": "busy", "statusUpdatedAt": int(time.time() * 1000), "name": "Skills session", "running": True}
self.httpd.hub.agent_sessions.write_text(json.dumps({"written": time.time(), "sessions": {"s1": record}}), encoding="utf-8")
steps = []
for width, height in WIDTHS:
steps += [
{"width": width, "height": height, "url": f"{self.base}?{width}#agents/orbit", "wait": 1200, "probe": PRESENCE_WALK, "shot": str(OUT / f"{browser}-{width}-agents-presence.png")},
{"width": width, "height": height, "url": f"{self.base}?{width}-reloaded#agents/orbit", "wait": 1200, "probe": PRESENCE_READ, "shot": str(OUT / f"{browser}-{width}-agents-presence-3d.png")},
]
data = self.drive(steps, None, driver, binary)
(OUT / f"{browser}-presence.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
self.assertEqual([error for error in data["errors"] if "(style-src-elem)" not in error and "/favicon.ico" not in error], [])
pair = ["dead", "working"]
order = ["working", "alive", "sleeping", "suspending", "unreachable", "dead", "faces"]
for index, (width, _) in enumerate(WIDTHS):
walk, read = data["results"][2 * index : 2 * index + 2]
with self.subTest(width=width):
self.assertNotIn("error", walk)
self.assertNotIn("error", read)
self.assertEqual(walk["views"], {"orbit": pair, "chord": pair, "swimlane": pair, "tree": pair, "hierarchy": ["dead", "dead", "working", "working"]})
self.assertEqual((walk["motion"], walk["profile"]), ("presence-spin", ["working", "Working", "Working"]))
self.assertGreaterEqual(walk["shown"][0], 3, walk["shown"])
self.assertEqual(walk["shown"][1], 2)
self.assertEqual(walk["before"], [[look, True] for look in order])
self.assertEqual((walk["hidden"], walk["orbit"], walk["wide"]), ([0, 1], ["dead"], False))
self.assertEqual(read["boxes"], [[look, look not in ("working", "faces")] for look in order])
self.assertEqual((read["orbit"], read["back"], read["restored"]), (["dead"], pair, walk["shown"]))
def test_chromium_shows_agent_states_and_3d_faces_switchable_through_a_reload_at_three_widths(self):
self.check_presence("chromium", "cdp.mjs", os.environ.get("CHROMIUM", "chromium"))
@unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox")
def test_firefox_shows_agent_states_and_3d_faces_switchable_through_a_reload_at_three_widths(self):
self.check_presence("firefox", "bidi.mjs", os.environ.get("FIREFOX", "firefox"))
def test_chromium_pins_a_task_priority_after_signature_and_the_runner_applies_it(self):
signer = self.pin()
private = "".join(line for line in signer.pem.read_text(encoding="utf-8").splitlines() if not line.startswith("-----"))
+147
View File
@@ -0,0 +1,147 @@
"""Pin decision 61: each agent state from the host's session snapshot, the night state, radio receipts and claim beats."""
from __future__ import annotations
import json
import os
import tempfile
import unittest
from pathlib import Path
import hub_support # noqa: F401
import sessions as client
from kvb_orchestrator.presence import SNAPSHOT_STALE, presence, read_snapshot
NOW = 1_800_000_000.0
FRESH = int((NOW - 60) * 1000)
OLD = int((NOW - 1800) * 1000)
def record(sid: str, status: str, updated: int, name: str) -> dict:
return {"status": status, "statusUpdatedAt": updated, "name": name, "running": sid != "gone"}
def claim(session: str, name: str, beat: float | None) -> dict:
return {"session": session, "agent": name, "beat": beat}
class TestPresence(unittest.TestCase):
def states(self, sessions: dict[str, dict], parts: dict[str, str], mode: str, claims: list[dict], news: list[dict]) -> dict[str, str]:
return presence(sessions, parts, mode, claims, news, NOW)
def test_each_state_by_day_and_by_night(self):
sessions = {sid: record(sid, status, updated, name) for sid, status, updated, name in (
("work", "busy", FRESH, "Worker"),
("idle", "idle", OLD, "Idler"),
("gone", "idle", FRESH, "Ghost"),
("stuck", "busy", OLD, "Stuck"),
("called", "idle", OLD, "Callee"),
("answered", "idle", OLD, "Answerer"),
("sleep", "idle", FRESH, "Sleeper"),
("park", "busy", FRESH, "Parker"),
)}
call = NOW - 700
news = [
{"ts": call, "from": "Operator", "to": ["Callee"], "class": "call"},
{"ts": call, "from": "Operator", "to": ["Answerer"], "class": "call"},
{"ts": call + 2, "from": "Answerer", "to": ["Operator"], "class": "receipt", "call": call},
{"ts": NOW - 60, "from": "Operator", "to": ["Idler"], "class": "call"},
]
claims = [claim("codex-on", "Codex", NOW - 60), claim("codex-off", "Codex old", NOW - 3600), claim("-", "Nobody", NOW), claim("op", "Operator", NOW)]
day = self.states(sessions, {"sleep": "suspend", "park": "drain"}, "day", claims, news)
self.assertEqual(day, {
"work": "working", "idle": "alive", "gone": "dead", "stuck": "unreachable", "called": "unreachable",
"answered": "alive", "sleep": "sleeping", "park": "suspending", "codex-on": "alive", "codex-off": "dead",
})
night = self.states(sessions, {"idle": "day", "work": "suspend"}, "drain", [], [])
self.assertEqual({sid: night[sid] for sid in ("work", "idle", "called", "sleep", "park")}, {
"work": "suspending", "idle": "alive", "called": "sleeping", "sleep": "sleeping", "park": "suspending",
})
def test_a_status_update_after_the_call_counts_as_reached(self):
news = [{"ts": NOW - 900, "from": "Operator", "to": ["Alias"], "class": "call"}]
claims = [claim("later", "Alias", NOW)]
self.assertEqual(self.states({"later": record("later", "idle", FRESH, "Later")}, {}, "day", claims, news), {"later": "alive"})
self.assertEqual(self.states({"later": record("later", "idle", OLD, "Later")}, {}, "day", claims, news), {"later": "unreachable"})
def test_a_record_without_running_true_is_dead(self):
for value in (False, None, "yes", 1):
with self.subTest(value=value):
entry = {**record("s", "busy", FRESH, "S"), "running": value}
self.assertEqual(self.states({"s": entry}, {}, "day", [], []), {"s": "dead"})
class TestSnapshot(unittest.TestCase):
def test_the_host_client_keeps_name_status_and_whether_the_process_runs(self):
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp)
proc, folder = base / "proc", base / "sessions"
(proc / "4242").mkdir(parents=True)
folder.mkdir()
(proc / "4242" / "stat").write_text("4242 (claude (main)) S " + " ".join(str(n) for n in range(1, 19)) + " 777 0 0\n", encoding="utf-8")
live = {"sessionId": "s1", "pid": 4242, "procStart": "777", "status": "busy", "statusUpdatedAt": FRESH, "name": "One", "cwd": "/secret"}
(folder / "1.json").write_text(json.dumps(live), encoding="utf-8")
(folder / "2.json").write_text(json.dumps({**live, "sessionId": "s2", "procStart": "778"}), encoding="utf-8")
(folder / "3.json").write_text(json.dumps({"status": "idle"}), encoding="utf-8")
(folder / "4.json").write_text("{broken", encoding="utf-8")
(folder / "5.json").write_text(json.dumps(["s5"]), encoding="utf-8")
output = base / "agent-sessions.json"
client.write(output, client.snapshot(folder, proc, NOW))
kept = {"status": "busy", "statusUpdatedAt": FRESH, "name": "One"}
self.assertEqual(json.loads(output.read_text(encoding="utf-8")), {
"written": NOW, "sessions": {"s1": {**kept, "running": True}, "s2": {**kept, "running": False}},
})
self.assertEqual(sorted(path.name for path in base.iterdir()), ["agent-sessions.json", "proc", "sessions"])
self.assertEqual(client.snapshot(folder / "missing", proc, NOW), {"written": NOW, "sessions": {}})
def test_the_host_client_skips_hostile_records_and_leaves_no_temp_file(self):
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp)
proc, folder = base / "proc", base / "sessions"
proc.mkdir()
folder.mkdir()
good = {"sessionId": "s1", "pid": 1, "procStart": "1", "status": "busy", "statusUpdatedAt": FRESH, "name": "One"}
(base / "outside.json").write_text(json.dumps({**good, "sessionId": "s9"}), encoding="utf-8")
(folder / "1.json").write_text(json.dumps({**good, "name": {"x": 1}, "status": "y" * 201, "statusUpdatedAt": True}), encoding="utf-8")
(folder / "2.json").symlink_to(base / "outside.json")
os.mkfifo(folder / "3.json")
(folder / "4.json").write_text("[" * 30000 + "]" * 30000, encoding="utf-8")
(folder / "5.json").write_text(json.dumps({**good, "sessionId": "s5", "name": "z" * client.RECORD_BYTES}), encoding="utf-8")
self.assertEqual(client.snapshot(folder, proc, NOW), {"written": NOW, "sessions": {"s1": {"running": False}}})
output = base / "agent-sessions.json"
with self.assertRaises(TypeError):
client.write(output, {"written": object()})
self.assertEqual(sorted(path.name for path in base.iterdir()), ["outside.json", "proc", "sessions"])
def test_running_needs_an_int_pid_and_the_same_process_start(self):
with tempfile.TemporaryDirectory() as tmp:
proc = Path(tmp)
(proc / "4242").mkdir()
(proc / "4242" / "stat").write_text("4242 (claude (main)) S " + " ".join(str(n) for n in range(1, 19)) + " 777 0 0\n", encoding="utf-8")
entry = {"pid": 4242, "procStart": "777"}
self.assertTrue(client.running(entry, proc))
for bad in ({**entry, "procStart": "778"}, {**entry, "pid": 4243}, {**entry, "pid": True}, {**entry, "pid": "4242"}, {"pid": 4242}):
with self.subTest(bad=bad):
self.assertFalse(client.running(bad, proc))
def test_the_hub_reads_only_a_fresh_well_formed_snapshot(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "agent-sessions.json"
self.assertEqual(read_snapshot(path, NOW), {})
entry = record("s1", "busy", FRESH, "One")
for data, expected in (
({"written": NOW - SNAPSHOT_STALE, "sessions": {"s1": entry, "s2": "x"}}, {"s1": entry}),
({"written": NOW - SNAPSHOT_STALE - 1, "sessions": {"s1": entry}}, {}),
({"written": True, "sessions": {"s1": entry}}, {}),
({"written": NOW + 1, "sessions": {"s1": entry}}, {}),
({"written": float("nan"), "sessions": {"s1": entry}}, {}),
({"written": NOW, "sessions": ["s1"]}, {}),
(["s1"], {}),
):
with self.subTest(data=data):
path.write_text(json.dumps(data), encoding="utf-8")
self.assertEqual(read_snapshot(path, NOW), expected)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -151,7 +151,7 @@ class TestHubState(HubCase):
set(state),
{
"now", "since", "until", "first", "alarm_since", "reviews", "asks", "decisions", "messages", "operator",
"register", "tasks", "task_edges", "agent_order", "radio", "claims", "grants", "night", "silence", "graph",
"register", "tasks", "task_edges", "agent_order", "radio", "claims", "grants", "night", "night_parts", "presence", "silence", "graph",
"inbox", "pending", "rules", "approvers", "jobs", "views", "llm", "people", "settings", "media", "speech",
"badges", "kinds",
},
+2 -1
View File
@@ -122,6 +122,7 @@ class TestOrchestratorMigration(unittest.TestCase):
self.assertEqual((local / f"{NEW}-approvals" / "runner.json").read_text(encoding="utf-8"), '{"used": {}}\n')
self.assertEqual(((config / NEW).stat().st_mode & 0o777, (config / NEW / "approvers.json").stat().st_mode & 0o777), (0o700, 0o600))
self.assertTrue((units / f"{NEW}-runner.service").is_file())
self.assertTrue((units / f"{NEW}-sessions.timer").is_file())
self.assertIn(f"KVB_ORCHESTRATOR_STATE={local / NEW}", (share / NEW / ".env").read_text(encoding="utf-8"))
settings = self.home / ".claude" / "settings.json"
data = json.loads(settings.read_text(encoding="utf-8"))
@@ -130,7 +131,7 @@ class TestOrchestratorMigration(unittest.TestCase):
self.assertIn(f"{share / NEW}/client/permission_hook.py", hooks[1]["hooks"][0]["command"])
self.assertEqual(settings.stat().st_mode & 0o777, 0o640)
self.assertIn(f"{OLD}/client", (self.home / ".claude" / f"settings.json.before-{NEW}-migration").read_text(encoding="utf-8"))
self.assertEqual((first["images"], first["mcp"], first["enabled"]), ([f"{NEW}:local"], {NEW: "http://127.0.0.1:8737/mcp"}, [f"{NEW}-offices.path", f"{NEW}-runner.service"]))
self.assertEqual((first["images"], first["mcp"], first["enabled"]), ([f"{NEW}:local"], {NEW: "http://127.0.0.1:8737/mcp"}, [f"{NEW}-offices.path", f"{NEW}-runner.service", f"{NEW}-sessions.timer"]))
self.assertIn(["docker", "compose", "--file", str(share / OLD / "compose.yml"), "down", "--remove-orphans"], first["calls"])
before = snapshot(self.home)