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)