From fdf8722fc17ad8c1ae47e91c31bae3e5a02804c3 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 11 Nov 2026 07:44:02 +0000 Subject: [PATCH] feat(orchestrator): S5.3 agent states, Status menu and 3D faces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Agents view marks each agent as working, alive, sleeping, suspending, unreachable or dead with an animated symbol; a Status menu hides states and switches the 3D faces, kept through a reload. The hub's container sees neither ~/.claude/sessions nor host processes, so the host timer kvb-orchestrator-sessions.timer runs client/sessions.py every 15 seconds while the page polls: it reads the session records without following symlinks, only regular files up to 64 KiB, keeps name, status and statusUpdatedAt within type and length bounds, checks the pid's process start, and writes agent-sessions.json atomically. The hub ignores a snapshot that is missing, malformed, older than five minutes or dated in the future. make orchestrator-install enables the timer and orchestrator-check reports it. 🎯 Fix: 90 % 💥 Break: 5 % 💸 Cost: 100 % ⏱️ Time: 100 % 🛡️ Security: 100 % 🏆 Score: 86 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 --- docs/requirements/001-agent-orchestrator.md | 48 ++++++ orchestrator/client/sessions.py | 119 ++++++++++++++ orchestrator/kvb_orchestrator/council.py | 19 +++ orchestrator/kvb_orchestrator/hub.py | 3 + orchestrator/kvb_orchestrator/presence.py | 87 ++++++++++ orchestrator/kvb_orchestrator/view.py | 4 + .../systemd/kvb-orchestrator-sessions.service | 10 ++ .../systemd/kvb-orchestrator-sessions.timer | 10 ++ orchestrator/templates/page-agent.js | 1 + orchestrator/templates/page-board.js | 24 ++- orchestrator/templates/page-constants.js | 10 +- orchestrator/templates/page-dom.js | 14 ++ orchestrator/templates/page-graph-charts.js | 1 + orchestrator/templates/page-graph-layout.js | 1 + orchestrator/templates/page-graph3d.js | 148 +++++++++++++++++- orchestrator/templates/page-hierarchy.js | 3 +- orchestrator/templates/page-spawn.js | 2 +- orchestrator/templates/page-state.js | 6 + orchestrator/templates/style-ui.css | 24 +++ scripts/orchestrator.sh | 23 ++- tests/e2e/test_orchestrator_e2e.py | 102 +++++++++++- tests/test_hub_presence.py | 147 +++++++++++++++++ tests/test_hub_state.py | 2 +- tests/test_orchestrator_migration.py | 3 +- 24 files changed, 798 insertions(+), 13 deletions(-) create mode 100755 orchestrator/client/sessions.py create mode 100644 orchestrator/kvb_orchestrator/presence.py create mode 100644 orchestrator/systemd/kvb-orchestrator-sessions.service create mode 100644 orchestrator/systemd/kvb-orchestrator-sessions.timer create mode 100644 tests/test_hub_presence.py diff --git a/docs/requirements/001-agent-orchestrator.md b/docs/requirements/001-agent-orchestrator.md index 02475bc..5283522 100644 --- a/docs/requirements/001-agent-orchestrator.md +++ b/docs/requirements/001-agent-orchestrator.md @@ -303,6 +303,34 @@ from three views down to any agent, branch or artefact. time-agent-depth layout. 58. **Steady 3D camera**: the 3D view fits the graph once per build; state refreshes keep the operator's camera. +59. **Agent states** (operator 2026-11-10, decisions 59 to 62): 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). +60. **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). +61. **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. +62. **3D faces**: agent nodes in the 3D view show their face, on by default + and switchable; repositories and branches stay spheres. ## Approach @@ -741,6 +769,19 @@ flowchart TD - [x] Two state refreshes after a manual camera move leave the 3D camera where the operator put it. +### Phase 6: agent states and 3D faces (S5.3) + +- [ ] The state of every fixture session matches decision 59 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** @@ -764,6 +805,10 @@ flowchart TD business value signature (forged, replayed, altered), sort orders. - [x] S5.1: regression and velocity trend with their forecast dates, point tooltips, ETA in both modes, radio settings stored from the gear. + - [ ] 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 @@ -792,3 +837,6 @@ flowchart TD - [x] S5.1: radio spot (send, gear, cancel mayday), Day/Night tooltips, one-row Tasks toolbar, trend and point tooltips, ETA column, Tasks 3D and a steady 3D camera, each 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. diff --git a/orchestrator/client/sessions.py b/orchestrator/client/sessions.py new file mode 100755 index 0000000..ec78d64 --- /dev/null +++ b/orchestrator/client/sessions.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Args: + --sessions: Claude Code's sessions directory, one .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": , + "sessions": {: {"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//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()) diff --git a/orchestrator/kvb_orchestrator/council.py b/orchestrator/kvb_orchestrator/council.py index 9293520..03afee0 100644 --- a/orchestrator/kvb_orchestrator/council.py +++ b/orchestrator/kvb_orchestrator/council.py @@ -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): diff --git a/orchestrator/kvb_orchestrator/hub.py b/orchestrator/kvb_orchestrator/hub.py index e9d49af..10364cc 100644 --- a/orchestrator/kvb_orchestrator/hub.py +++ b/orchestrator/kvb_orchestrator/hub.py @@ -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, diff --git a/orchestrator/kvb_orchestrator/presence.py b/orchestrator/kvb_orchestrator/presence.py new file mode 100644 index 0000000..df7cc12 --- /dev/null +++ b/orchestrator/kvb_orchestrator/presence.py @@ -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 59). + """ + 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 diff --git a/orchestrator/kvb_orchestrator/view.py b/orchestrator/kvb_orchestrator/view.py index 5637fea..9c65776 100644 --- a/orchestrator/kvb_orchestrator/view.py +++ b/orchestrator/kvb_orchestrator/view.py @@ -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, } diff --git a/orchestrator/systemd/kvb-orchestrator-sessions.service b/orchestrator/systemd/kvb-orchestrator-sessions.service new file mode 100644 index 0000000..85893f0 --- /dev/null +++ b/orchestrator/systemd/kvb-orchestrator-sessions.service @@ -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 diff --git a/orchestrator/systemd/kvb-orchestrator-sessions.timer b/orchestrator/systemd/kvb-orchestrator-sessions.timer new file mode 100644 index 0000000..3659cc8 --- /dev/null +++ b/orchestrator/systemd/kvb-orchestrator-sessions.timer @@ -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 diff --git a/orchestrator/templates/page-agent.js b/orchestrator/templates/page-agent.js index 4911d78..90cea5a 100644 --- a/orchestrator/templates/page-agent.js +++ b/orchestrator/templates/page-agent.js @@ -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", {}, diff --git a/orchestrator/templates/page-board.js b/orchestrator/templates/page-board.js index 93f8079..c859235 100644 --- a/orchestrator/templates/page-board.js +++ b/orchestrator/templates/page-board.js @@ -84,10 +84,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"}, spot.toggle, 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"}, spot.toggle, iconBtn("bi-sort-down", "Priority", "btn small", openAgentOrder, "Priority: agent order in the hierarchy"), night, statusMenu()); return {bar, spot: spot.panel, clear: spot.clear, recipient: spot.recipient, mode, names: ""}; } + 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() { const toolbar = graphDom && graphDom.toolbar; if (!toolbar) { diff --git a/orchestrator/templates/page-constants.js b/orchestrator/templates/page-constants.js index e0e1de9..8db3dcc 100644 --- a/orchestrator/templates/page-constants.js +++ b/orchestrator/templates/page-constants.js @@ -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; diff --git a/orchestrator/templates/page-dom.js b/orchestrator/templates/page-dom.js index 1782a26..cb84674 100644 --- a/orchestrator/templates/page-dom.js +++ b/orchestrator/templates/page-dom.js @@ -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; diff --git a/orchestrator/templates/page-graph-charts.js b/orchestrator/templates/page-graph-charts.js index 676326b..bf7b04f 100644 --- a/orchestrator/templates/page-graph-charts.js +++ b/orchestrator/templates/page-graph-charts.js @@ -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; diff --git a/orchestrator/templates/page-graph-layout.js b/orchestrator/templates/page-graph-layout.js index 999dd94..009df0a 100644 --- a/orchestrator/templates/page-graph-layout.js +++ b/orchestrator/templates/page-graph-layout.js @@ -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)); } diff --git a/orchestrator/templates/page-graph3d.js b/orchestrator/templates/page-graph3d.js index fbc867d..6d66734 100644 --- a/orchestrator/templates/page-graph3d.js +++ b/orchestrator/templates/page-graph3d.js @@ -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() { diff --git a/orchestrator/templates/page-hierarchy.js b/orchestrator/templates/page-hierarchy.js index be205e7..bd6def1 100644 --- a/orchestrator/templates/page-hierarchy.js +++ b/orchestrator/templates/page-hierarchy.js @@ -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"}, diff --git a/orchestrator/templates/page-spawn.js b/orchestrator/templates/page-spawn.js index f628afa..a8d2c53 100644 --- a/orchestrator/templates/page-spawn.js +++ b/orchestrator/templates/page-spawn.js @@ -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); } diff --git a/orchestrator/templates/page-state.js b/orchestrator/templates/page-state.js index 00f0eef..2fa8eff 100644 --- a/orchestrator/templates/page-state.js +++ b/orchestrator/templates/page-state.js @@ -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; diff --git a/orchestrator/templates/style-ui.css b/orchestrator/templates/style-ui.css index 5e76ebc..aba4743 100644 --- a/orchestrator/templates/style-ui.css +++ b/orchestrator/templates/style-ui.css @@ -25,6 +25,30 @@ svg .node.speaking circle { stroke: var(--ok); stroke-width: 4; animation: speak @media (prefers-reduced-motion: reduce) { .speaker.speaking .speaker-face::after, .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; } diff --git a/scripts/orchestrator.sh b/scripts/orchestrator.sh index ec421ab..cd08030 100644 --- a/scripts/orchestrator.sh +++ b/scripts/orchestrator.sh @@ -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='; 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 diff --git a/tests/e2e/test_orchestrator_e2e.py b/tests/e2e/test_orchestrator_e2e.py index 6889183..053f5a2 100644 --- a/tests/e2e/test_orchestrator_e2e.py +++ b/tests/e2e/test_orchestrator_e2e.py @@ -743,6 +743,62 @@ RADIO_JUMP = """(async () => {@HELPERS@ await sleep(200); return {composers, hash: location.hash, open: Boolean(spot), focused: Boolean(spot) && document.activeElement === spot.querySelector("textarea")}; })()""".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"); @@ -1634,7 +1690,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"], ["Radio", "Priority", "Day/Night"]) + self.assertEqual(agents["toolbar"], ["Radio", "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"]) @@ -1791,7 +1847,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"], ["Radio", "Priority", "Day/Night", "Drain", "Suspend", "Resume"]) + self.assertEqual(result["toolbar"], ["Radio", "Priority", "Day/Night", "Drain", "Suspend", "Resume", "Status"]) self.assertEqual(result["title"], "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"]}) @@ -1825,7 +1881,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"]), (["Radio", "Priority", "Day/Night"], CHOICE_CHANNELS, ["Record"])) + self.assertEqual((sent["toolbar"], sent["channels"], sent["record"]), (["Radio", "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) @@ -2498,6 +2554,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("-----")) diff --git a/tests/test_hub_presence.py b/tests/test_hub_presence.py new file mode 100644 index 0000000..da8c21f --- /dev/null +++ b/tests/test_hub_presence.py @@ -0,0 +1,147 @@ +"""Pin decision 59: 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() diff --git a/tests/test_hub_state.py b/tests/test_hub_state.py index fddd2bb..7650fd1 100644 --- a/tests/test_hub_state.py +++ b/tests/test_hub_state.py @@ -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", }, diff --git a/tests/test_orchestrator_migration.py b/tests/test_orchestrator_migration.py index 336d339..3e000ca 100644 --- a/tests/test_orchestrator_migration.py +++ b/tests/test_orchestrator_migration.py @@ -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)