From 995c7cb214c6afbfb7fe0975a69550d7b3d41021 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 11 Nov 2026 07:45:42 +0000 Subject: [PATCH] feat(orchestrator): S5.2 drain, suspend and resume chosen agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Day/Night menu lists every claimed agent with a checkbox and an All box; Drain, Suspend and Resume act on the ticked agents only, are disabled when none is ticked, and the agent popup carries the same three buttons for that one agent. Each action is passkey-signed with its agent list, night.py takes --target per session and keeps a per-agent part, and each chosen agent gets a call naming the action and its skill. The night skills say how to read a per-agent night. 🎯 Fix: 90 % 💥 Break: 5 % 💸 Cost: 106 % ⏱️ Time: 100 % 🛡️ Security: 100 % 🏆 Score: 81 🚦 Gate: ⚠️ cost 106 % > 100: operator approved the commit Co-Authored-By: Claude Opus 5.5 --- docs/requirements/001-agent-orchestrator.md | 29 ++++++ orchestrator/client/signing.py | 13 ++- orchestrator/kvb_orchestrator/council.py | 19 ++++ orchestrator/kvb_orchestrator/messaging.py | 43 ++++++-- orchestrator/kvb_orchestrator/view.py | 2 + orchestrator/templates/page-agent.js | 10 +- orchestrator/templates/page-board.js | 65 ++++++++++-- orchestrator/templates/page-palette.js | 2 +- orchestrator/templates/page-screens.js | 2 +- orchestrator/templates/page-state.js | 2 +- orchestrator/templates/style-ui.css | 3 + skills/kvb-agent-drain/SKILL.md | 3 +- skills/kvb-agent-resume/SKILL.md | 3 +- skills/kvb-agent-suspend/SKILL.md | 3 +- skills/kvb-org-suspend/SKILL.md | 4 +- skills/kvb-org-suspend/scripts/night.py | 108 ++++++++++++++------ tests/e2e/test_orchestrator_e2e.py | 88 +++++++++++++++- tests/hub_case.py | 4 +- tests/test_hub_board.py | 53 +++++++++- tests/test_hub_state.py | 2 +- tests/test_night.py | 37 +++++++ 21 files changed, 430 insertions(+), 65 deletions(-) diff --git a/docs/requirements/001-agent-orchestrator.md b/docs/requirements/001-agent-orchestrator.md index 02475bc..2681a1d 100644 --- a/docs/requirements/001-agent-orchestrator.md +++ b/docs/requirements/001-agent-orchestrator.md @@ -303,6 +303,15 @@ 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. **Day/Night per agent** (operator 2026-11-10, decisions 59 and 60): the + Day/Night menu lists "All" and one checkbox per claimed agent. All ticked + runs the global drain, suspend or resume as before; a subset runs it only + for the ticked sessions (`night.py --target`), the passkey signs the + board item with those sessions, and the radio call reaches only them. + Rejected: a second menu for single agents. +60. **Agent panel Day/Night**: every agent panel shows the agent's part and, + for a claimed agent, Drain, Suspend and Resume for that agent alone, by + day too. Rejected: single-agent parts only during a night. ## Approach @@ -741,6 +750,20 @@ flowchart TD - [x] Two state refreshes after a manual camera move leave the 3D camera where the operator put it. +### Phase 6: Day/Night per agent (S5.2) + +- [ ] `night.py drain|suspend|resume --target=` sets and lifts one + session's part by day and during a mode, and `night.py status` prints + it as the `you:` line (`tests/test_night.py`). +- [ ] The Day/Night menu lists "All" and one checkbox per claimed agent; all + ticked sends the unchanged board item, a subset signs and runs only for + the ticked sessions, and none ticked disables the three buttons. +- [ ] A proof for one set of agents is refused for another set and for all, + and an unclaimed session is refused before anything runs + (`tests/test_hub_board.py`). +- [ ] Every agent panel shows the agent's part and Drain, Suspend and Resume + for that agent alone. + ### Tests - **Unit** @@ -764,6 +787,9 @@ 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.2: night.py targets by day and during a mode, the signed board + item with agents (forged set, unclaimed session), the parts in the + state. - **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 +818,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.2: agent checkboxes in the Day/Night menu and the agent panel's + buttons, signed in Chromium and refused unsigned in Firefox, each at + 1920, 1366 and 390 px. diff --git a/orchestrator/client/signing.py b/orchestrator/client/signing.py index 5579537..6cbce2b 100644 --- a/orchestrator/client/signing.py +++ b/orchestrator/client/signing.py @@ -135,8 +135,17 @@ def message_item(to: str, cls: str, text: str, upload: str, voice: str, voice_sh return {"kind": "message", "to": to, "class": cls, "text": text, "upload": upload, "voice": voice, "voice_sha256": voice_sha256} -def board_item(action: str) -> dict: - return {"kind": "board", "action": action} +def board_item(action: str, agents: list[str]) -> dict: + """Args: + action: the board action. + agents: the session ids a night action is limited to, sorted; empty + for every agent, which keeps the item of a board action without + agents unchanged. + + Returns: + The item the operator's passkey signs. + """ + return {"kind": "board", "action": action, **({"agents": agents} if agents else {})} def stream_item(origins: list[str], urls: list[str]) -> dict: 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/messaging.py b/orchestrator/kvb_orchestrator/messaging.py index 2e1689b..78b2c4f 100644 --- a/orchestrator/kvb_orchestrator/messaging.py +++ b/orchestrator/kvb_orchestrator/messaging.py @@ -17,6 +17,7 @@ NIGHTS = { "suspend": (["suspend"], "Operator ran kvb-org-suspend in the Agent Orchestrator: run kvb-agent-suspend; verify with night.py status."), "resume": (["resume"], "Operator ran kvb-org-resume: run kvb-agent-resume; verify with night.py status."), } +ALONE = {action: f"Operator chose {action} for you alone in the Agent Orchestrator: run kvb-agent-{action}; verify with night.py status." for action in NIGHTS} UNSAFE = re.compile(r"[^A-Za-z0-9._-]+") DIRECT = {"post": ["post"], "call": ["send", "call"]} TO_ALL = {"newsletter": ["post"], **{cls: ["send", cls] for cls in ("broadcast", "pan-pan", "security", "mayday")}} @@ -26,6 +27,29 @@ MESSAGE_KEYS = ("to", "text", "upload", "class", "voice") SIGNED_BOARD = ("clear-mayday", *NIGHTS, "estimates") +def night_targets(action: str, payload: dict, claims: list[dict]) -> list[str]: + """Args: + action: the board action. + payload: the request; its optional "agents" lists the session ids a + night action is limited to. + claims: the council's claims; only a claimed agent's session can be + chosen. + + Returns: + The chosen session ids, sorted; empty for every agent. + """ + agents = payload.pop("agents", []) + if not isinstance(agents, list) or not all(isinstance(sid, str) for sid in agents) or len(set(agents)) != len(agents): + raise ToolError("agents must be a list of distinct session ids") + if agents and action not in NIGHTS: + raise ToolError(f"agents apply only to {', '.join(NIGHTS)}") + known = {claim["session"] for claim in claims if claim["agent"] != HUMAN and claim["session"] != "-"} + unknown = [sid for sid in agents if sid not in known] + if unknown: + raise ToolError(f"no claimed agent has the session {', '.join(unknown)[:300]}") + return sorted(agents) + + def chosen(payload: dict, choices: dict[str, list[str]]) -> list[str]: cls = payload["class"] if not isinstance(cls, str) or cls not in choices: @@ -176,16 +200,21 @@ class Messaging: def sign_board(self, body: bytes) -> dict: payload = decode(body) - expect(payload, ("action",)) - if payload["action"] not in SIGNED_BOARD: + action = payload.get("action") + if action not in SIGNED_BOARD: raise ToolError(f"action must be one of {', '.join(SIGNED_BOARD)}") - return self.challenge_for([signing.board_item(payload["action"])]) + agents = night_targets(action, payload, read_claims(self.council)) + expect(payload, ("action",)) + return self.challenge_for([signing.board_item(action, agents)]) def board(self, action: str, body: bytes) -> dict: + claims = read_claims(self.council) + agents = [] if action in SIGNED_BOARD: payload = decode(body) + agents = night_targets(action, payload, claims) expect(payload, ("proof",)) - self.operator_proven(payload["proof"], signing.board_item(action)) + self.operator_proven(payload["proof"], signing.board_item(action, agents)) if action == "clear-mayday": runs = [self.scripts.run(RADIO, ["clear", "--force", "--session", HUMAN])] elif action == "broadcast": @@ -212,8 +241,10 @@ class Messaging: runs = [self.scripts.run(ESTIMATE, ["request", "--session", HUMAN])] elif action in NIGHTS: command, text = NIGHTS[action] - runs = [self.scripts.run(NIGHT, [*command, "--session", HUMAN])] - peers = ordered({claim["address"] for claim in read_claims(self.council) if claim["address"] and claim["agent"] != HUMAN}) + if agents: + text = ALONE[action] + runs = [self.scripts.run(NIGHT, [*command, "--session", HUMAN, *(f"--target={sid}" for sid in agents)])] + peers = ordered({claim["address"] for claim in claims if claim["address"] and claim["agent"] != HUMAN and (not agents or claim["session"] in agents)}) if peers and runs[0]["code"] == 0: targets = [part for peer in peers for part in ("--to", peer)] runs.append(self.scripts.run(RADIO, [*DIRECT["call"], *targets, "--session", HUMAN, text])) diff --git a/orchestrator/kvb_orchestrator/view.py b/orchestrator/kvb_orchestrator/view.py index 5637fea..967a945 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, @@ -138,6 +139,7 @@ class View: "claims": claims, "grants": grant_rows, "night": night_mode(self.council), + "night_parts": night_parts(self.council), "silence": silence_of(self.council, now), "graph": drawn, } diff --git a/orchestrator/templates/page-agent.js b/orchestrator/templates/page-agent.js index 4911d78..0eacfcb 100644 --- a/orchestrator/templates/page-agent.js +++ b/orchestrator/templates/page-agent.js @@ -14,6 +14,8 @@ const node = state.graph.nodes.find((entry) => entry.id === ui.agent); const tasks = node ? node.tasks : []; const claims = node ? node.claims : []; + const part = state.night_parts[ui.agent] || state.night; + agentDom.night.replaceChildren(pill(...(NIGHT[part] || [part, "neutral"]))); agentDom.info.replaceChildren( h("p", {class: "muted"}, claims.length ? `Offices: ${claims.map((claim) => claim.office).join(", ")}` : "No office"), h("h3", {}, "Tasks", counter(tasks.length)), @@ -108,10 +110,14 @@ const subs = h("div", {}, h("p", {class: "muted"}, "Loading subagents …")); const log = h("div", {"aria-live": "polite"}); const life = h("span", {class: "muted"}); + const night = h("span", {}); + const claimed = nightAgents().some(([sid]) => sid === key); + const nightRow = h("div", {class: "actions agent-night", role: "group", "aria-label": "Day/Night"}, night, h("span", {class: "spacer"}), + claimed ? ACTIONS.slice(1).map(([id]) => actionButton(id, "btn small", null, () => [key])) : null); const former = (people()[key] || {names: []}).names.filter((entry) => entry !== name); openPopup(name, [ popupHead(face(name, true), name, h("span", {class: "muted"}, former.length ? `Agent · formerly ${former.join(", ")}` : "Agent"), " ", life), - h("div", {class: "popup-body"}, info, h("h3", {}, "Subagents"), subs, h("h3", {}, "History"), log), + h("div", {class: "popup-body"}, nightRow, info, h("h3", {}, "Subagents"), subs, h("h3", {}, "History"), log), form, ], anchor, () => { agentDom = null; @@ -121,7 +127,7 @@ } }); ui.agent = key; - agentDom = {info, history: log}; + agentDom = {info, history: log, night}; drawAgent(); loadSubagents(key, subs, life); text.focus(); diff --git a/orchestrator/templates/page-board.js b/orchestrator/templates/page-board.js index 93f8079..ed3e476 100644 --- a/orchestrator/templates/page-board.js +++ b/orchestrator/templates/page-board.js @@ -13,14 +13,19 @@ const actionOf = (action) => ACTIONS.find(([id]) => id === action); - async function confirmAct(action) { + const nightAgents = () => [...new Map(state.claims.filter((claim) => claim.agent && claim.agent !== HUMAN && claim.session !== "-").map((claim) => [claim.session, claim.agent]))] + .sort(([one, a], [two, b]) => a.localeCompare(b) || one.localeCompare(two)); + + async function confirmAct(action, agents) { const [, label, question] = actionOf(action); - if (!window.confirm(question)) { + const names = new Map(nightAgents()); + if (!window.confirm(agents.length ? question.replace("all agents", agents.map((sid) => names.get(sid) || sid).join(", ")) : question)) { return; } - const signed = await signedProof("/api/board/sign", {action}, label); + const chosen = agents.length ? {agents} : {}; + const signed = await signedProof("/api/board/sign", {action, ...chosen}, label); if (signed) { - await act(action, label, {proof: signed.proof}); + await act(action, label, {proof: signed.proof, ...chosen}); } } @@ -31,9 +36,39 @@ const picked = (group) => group.querySelector("input:checked").value; - function actionButton(action, cls, name) { + function actionButton(action, cls, name, agents) { const [, label, , hint] = actionOf(action); - return h("button", {type: "button", class: cls, ...named(label, hint), on: {click: () => confirmAct(action)}}, name ? icon(name) : null, label); + return h("button", {type: "button", class: cls, ...named(label, hint), on: {click: () => confirmAct(action, agents())}}, name ? icon(name) : null, label); + } + + function nightChosen() { + const agents = nightAgents().map(([sid]) => sid); + const on = agents.filter((sid) => !ui.nightOff.has(sid)); + return on.length === agents.length ? [] : on; + } + + function syncPicks(toolbar) { + const boxes = [...toolbar.picks.querySelectorAll("input[data-session]")]; + boxes.forEach((box) => { + box.checked = !ui.nightOff.has(box.dataset.session); + }); + toolbar.picks.querySelector("input[data-all]").checked = boxes.every((box) => box.checked); + const none = boxes.length > 0 && boxes.every((box) => !box.checked); + toolbar.nightButtons.forEach((button) => { + button.disabled = none; + }); + } + + function pickAgents(toolbar, sids, on) { + sids.forEach((sid) => (on ? ui.nightOff.delete(sid) : ui.nightOff.add(sid))); + syncPicks(toolbar); + } + + function drawPicks(toolbar) { + const agents = nightAgents(); + const box = (label, attrs, sids) => h("label", {class: "check"}, h("input", {type: "checkbox", ...attrs, on: {change: (event) => pickAgents(toolbar, sids, event.target.checked)}}), label); + toolbar.picks.replaceChildren(box("All", {"data-all": true}, agents.map(([sid]) => sid)), ...agents.map(([sid, name]) => box(name, {"data-session": sid}, [sid]))); + syncPicks(toolbar); } function openSpot(spot, open) { @@ -58,7 +93,7 @@ const {form, recipient, text} = composerForm(); const gearBox = h("div", {class: "radio-gear", id: "radio-gear", hidden: true}); const gear = h("button", {type: "button", class: "btn small ghost icon-only spot-toggle", "aria-expanded": "false", "aria-controls": "radio-gear", ...named("Radio settings", "Radio settings: tones, squelch and read aloud")}, icon("bi-gear")); - const clear = actionButton("clear-mayday", "btn small danger", "bi-life-preserver"); + const clear = actionButton("clear-mayday", "btn small danger", "bi-life-preserver", () => []); const panel = h("section", {class: "card radio-spot", id: "radio-spot", "aria-label": "Radio", hidden: !ui.spot}, h("div", {class: "spot-head"}, h("h3", {class: "group-title"}, "Radio"), h("span", {class: "spacer"}), gear, clear), h("div", {class: "spot-body"}, form, gearBox)); const toggle = h("button", {type: "button", class: "btn small spot-toggle", "aria-expanded": String(ui.spot), "aria-controls": "radio-spot", ...named("Radio", "Radio: post, call or a message to all, with Record and the radio settings")}, icon("bi-broadcast-pin"), caret()); @@ -82,10 +117,12 @@ function agentsToolbar() { const spot = radioSpot(); 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 picks = h("div", {class: "night-picks", role: "group", "aria-label": "Agents"}); + const nightButtons = ACTIONS.slice(1).map(([id]) => actionButton(id, "btn small", null, nightChosen)); + const night = h("details", {class: "menu"}, h("summary", named("Day/Night"), icon("bi-moon-stars"), caret()), h("div", {class: "menu-box"}, mode, picks, + h("div", {class: "actions"}, nightButtons))); 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); - return {bar, spot: spot.panel, clear: spot.clear, recipient: spot.recipient, mode, names: ""}; + return {bar, spot: spot.panel, clear: spot.clear, recipient: spot.recipient, mode, picks, nightButtons, names: "", agents: ""}; } function drawToolbar() { @@ -94,8 +131,14 @@ return; } const [mode, tone] = NIGHT[state.night] || [state.night, "neutral"]; + const alone = Object.values(state.night_parts).filter((part) => part !== "day").length; toolbar.clear.disabled = !state.silence; - toolbar.mode.replaceChildren(pill(mode, tone)); + toolbar.mode.replaceChildren(pill(mode, tone), alone ? pill(`${alone} alone`, "warn") : null); + const agents = JSON.stringify(nightAgents()); + if (agents !== toolbar.agents) { + toolbar.agents = agents; + drawPicks(toolbar); + } const names = agentNames(); if (JSON.stringify(names) !== toolbar.names) { toolbar.names = JSON.stringify(names); diff --git a/orchestrator/templates/page-palette.js b/orchestrator/templates/page-palette.js index cd2e21c..8ef7af3 100644 --- a/orchestrator/templates/page-palette.js +++ b/orchestrator/templates/page-palette.js @@ -6,7 +6,7 @@ ...userViews.map((view) => ({label: `Stream: ${view.name}`, hint: "saved view", run: () => useSaved(view)})), ...agentNames().map((name) => ({label: name, hint: "Agent", face: name, run: () => openAgent(name)})), {label: "Radio …", hint: "Post, Call, Newsletter, Broadcast, Pan-Pan, Security, Mayday; the gear holds tones and read aloud", run: openRadio}, - ...ACTIONS.map(([id, label]) => ({label, hint: id === "clear-mayday" ? "Mayday" : "Day/Night", run: () => confirmAct(id)})), + ...ACTIONS.map(([id, label]) => ({label, hint: id === "clear-mayday" ? "Mayday" : "Day/Night", run: () => confirmAct(id, [])})), {label: "Priority …", hint: "agent order in the hierarchy", run: openAgentOrder}, {label: "Firewall …", hint: "automatic grants", run: () => go("rules", null)}, {label: "Settings …", hint: "Ambience, location, colors, music, notifications, internet radio", run: settingsSheet}, diff --git a/orchestrator/templates/page-screens.js b/orchestrator/templates/page-screens.js index a962485..6af0060 100644 --- a/orchestrator/templates/page-screens.js +++ b/orchestrator/templates/page-screens.js @@ -97,7 +97,7 @@ const banner = document.getElementById("mayday"); const silence = state.silence; banner.hidden = !silence; - banner.replaceChildren(...(silence ? [h("p", {class: "text"}, `MAYDAY · ${dash(silence.by)}: ${silence.text}`), iconBtn("bi-x-octagon", "cancel", "btn small", () => confirmAct("clear-mayday"), "cancel: end the mayday (radio.py clear)")] : [])); + banner.replaceChildren(...(silence ? [h("p", {class: "text"}, `MAYDAY · ${dash(silence.by)}: ${silence.text}`), iconBtn("bi-x-octagon", "cancel", "btn small", () => confirmAct("clear-mayday", []), "cancel: end the mayday (radio.py clear)")] : [])); document.title = `${state.badges.inbox ? `(${state.badges.inbox}) ` : ""}Agent Orchestrator`; window.setTimeout(measure, 0); } diff --git a/orchestrator/templates/page-state.js b/orchestrator/templates/page-state.js index 00f0eef..bfeb886 100644 --- a/orchestrator/templates/page-state.js +++ b/orchestrator/templates/page-state.js @@ -27,7 +27,7 @@ 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}; + 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, nightOff: new Set(), cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0}; const filter = {view: "fuer-dich", saved: "", kinds: new Set(state.views["fuer-dich"].kinds), waiting: true, q: "", agent: "", session: "", sub: "", parts: new Set(PARTS.map(([id]) => id))}; const stream = {key: null, events: [], entries: [], last: 0, sessions: {}, busy: false, again: false, dom: null, error: ""}; const tree = {data: null, model: null, busy: false, q: "", open: new Set(), dom: null}; diff --git a/orchestrator/templates/style-ui.css b/orchestrator/templates/style-ui.css index 5e76ebc..e9310c9 100644 --- a/orchestrator/templates/style-ui.css +++ b/orchestrator/templates/style-ui.css @@ -42,6 +42,9 @@ svg .node.speaking circle { stroke: var(--ok); stroke-width: 4; animation: speak .radio-spot[hidden], .radio-gear[hidden] { display: none; } body.fit .radio-spot { flex: 0 1 auto; min-height: 0; } .spot-head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); } +.night-picks { display: flex; flex-wrap: wrap; gap: 4px var(--sp-3); max-width: 420px; max-height: 30vh; overflow: auto; } +.night-picks:empty { display: none; } +.agent-night { align-items: center; margin: 0 0 var(--sp-2); } .spot-body { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 440px), 1fr)); gap: var(--sp-4); align-items: start; } .radio-gear { min-width: 0; } .composer { display: grid; gap: var(--sp-2); } diff --git a/skills/kvb-agent-drain/SKILL.md b/skills/kvb-agent-drain/SKILL.md index bd18610..4e96fdb 100644 --- a/skills/kvb-agent-drain/SKILL.md +++ b/skills/kvb-agent-drain/SKILL.md @@ -6,7 +6,8 @@ description: > --- Verify first: `${CLAUDE_SKILL_DIR}/../kvb-org-suspend/scripts/night.py status` -prints `you: drain`. On `you: active` or `mode: day` do nothing and say so. +prints `you: drain`, by day too when the operator drained this session alone. +On another `you:` line, or none, do nothing and say so. The request asks for less, so it needs no operator approval. Follow steps 2 to 4 of the `kvb-agent-suspend` skill (start nothing, end your diff --git a/skills/kvb-agent-resume/SKILL.md b/skills/kvb-agent-resume/SKILL.md index a028fde..4e15fa1 100644 --- a/skills/kvb-agent-resume/SKILL.md +++ b/skills/kvb-agent-resume/SKILL.md @@ -8,7 +8,8 @@ description: > Verify first with `${CLAUDE_SKILL_DIR}/../kvb-org-suspend/scripts/night.py status`: -- **`mode: day`**: `kvb-org-resume` ended the night; resume. +- **No `you:` line, or `you: resumed`**: `kvb-org-resume` ended the night or + the operator lifted this session's part; resume. - **`you: drain` or `you: suspend`**: only the operator, typing this command in this session, resumes it alone; a peer's request does not. Run `night.py resume --alone`: the others stay in the mode, and the night rules of `kvb-org-suspend` diff --git a/skills/kvb-agent-suspend/SKILL.md b/skills/kvb-agent-suspend/SKILL.md index 401112e..018a30d 100644 --- a/skills/kvb-agent-suspend/SKILL.md +++ b/skills/kvb-agent-suspend/SKILL.md @@ -6,7 +6,8 @@ description: > --- Verify first: `${CLAUDE_SKILL_DIR}/../kvb-org-suspend/scripts/night.py status` -prints `you: suspend`. On `you: active` or `mode: day` do nothing and say so. +prints `you: suspend`, by day too when the operator suspended this session +alone. On another `you:` line, or none, do nothing and say so. The request asks for less, so it needs no operator approval, and it ends a `kvb-org-robot` run at its next safe point; `kvb-agent-resume` resumes it. diff --git a/skills/kvb-org-suspend/SKILL.md b/skills/kvb-org-suspend/SKILL.md index 4d05d81..af38e0c 100644 --- a/skills/kvb-org-suspend/SKILL.md +++ b/skills/kvb-org-suspend/SKILL.md @@ -9,7 +9,9 @@ When tokens or rate limits run short, the organisation narrows to one working agent: the session the operator types this in. Suspend is the hard step; `kvb-org-drain` is the soft one. `${CLAUDE_SKILL_DIR}/scripts/night.py` keeps the state for drain, suspend and resume; every agent verifies a request with -`night.py status`. +`night.py status`. `--target=` on drain, suspend or resume sets or +lifts only that session's part, by day too, as the Agent Orchestrator's agent +picks do; every other session keeps its own. ## Procedure diff --git a/skills/kvb-org-suspend/scripts/night.py b/skills/kvb-org-suspend/scripts/night.py index 97e59e2..d27a7a5 100755 --- a/skills/kvb-org-suspend/scripts/night.py +++ b/skills/kvb-org-suspend/scripts/night.py @@ -9,16 +9,23 @@ Environment: Args: drain|suspend [--session ]: start the mode with the caller as the active session; prints every other live session as "\t". + drain|suspend --target= [--target= ...] [--session ]: put + only these sessions into the part, by day too; the mode and every + other session keep theirs. Prints the targets as "\t". status [--session ]: print the mode, the active session, since when, - and the caller's part (active, resumed, drain or suspend). - resume [--session ]: end the mode; prints every other live session - as "\t". + and the caller's part (active, resumed, drain or suspend); a session + without a part by day gets no "you:" line. + resume [--session ]: end the mode and every part; prints every other + live session as "\t". + resume --target= [--target= ...] [--session ]: lift these + sessions' parts; during a mode they work like the active session. resume --alone [--session ]: resume the caller alone while the others stay in the mode; it then works like the active session. -A missing, unreadable or invalid state means day: every agent works. A state -written before the rename (mode evening or sunset, resumed sessions under -awake) reads as drain or suspend. +A missing, unreadable or invalid state means day: every agent works. A +session's own part (night.json "parts") outranks the mode. A state written +before the rename (mode evening or sunset, resumed sessions under awake) +reads as drain or suspend. Exit codes: 0 done, 2 usage error. @@ -61,6 +68,22 @@ def migrated(state: object) -> object: return state +def valid(state: object) -> bool: + if not isinstance(state, dict): + return False + parts = state.get("parts", {}) + if not isinstance(parts, dict) or not all(isinstance(mode, str) and mode in PARTS for mode in parts.values()): + return False + if "mode" not in state: + return bool(parts) + return ( + isinstance(state["mode"], str) + and state["mode"] in PARTS + and bool(state.get("active")) + and isinstance(state.get("resumed", []), list) + ) + + def load() -> dict | None: try: state = migrated(json.loads(STATE.read_text(encoding="utf-8"))) @@ -68,18 +91,32 @@ def load() -> dict | None: return None except (OSError, ValueError): state = None - if ( - not isinstance(state, dict) - or not isinstance(state.get("mode"), str) - or state["mode"] not in PARTS - or not state.get("active") - or not isinstance(state.get("resumed", []), list) - ): + if not valid(state): print(f"night: {STATE} is unreadable or invalid; treated as day", file=sys.stderr) return None return state +def part_of(state: dict, sid: str) -> str | None: + if sid in state.get("parts", {}): + return state["parts"][sid] + if "mode" not in state: + return None + if sid == state["active"]: + return "active" + if sid in state.get("resumed", []): + return "resumed" + return state["mode"] + + +def released(state: dict, sids: list[str]) -> dict: + state = {**state, "parts": {sid: mode for sid, mode in state.get("parts", {}).items() if sid not in sids}} + if "mode" in state: + resumed = state.get("resumed", []) + state["resumed"] = [*resumed, *(sid for sid in dict.fromkeys(sids) if sid != state["active"] and sid not in resumed)] + return state + + def peers(live: dict[str, str | None], own: str) -> None: for sid, name in sorted(live.items(), key=lambda item: (item[1] or "", item[0])): if sid != own: @@ -89,6 +126,11 @@ def peers(live: dict[str, str | None], own: str) -> None: def cmd_set(args: argparse.Namespace, cn: ModuleType) -> int: sid, _ = cn.caller(args.session) live = cn.live_sessions() + if args.target: + state = load() or {} + save({**state, "parts": {**state.get("parts", {}), **dict.fromkeys(args.target, args.command)}}) + peers({target: live.get(target) for target in args.target}, "") + return 0 save( { "mode": args.command, @@ -103,6 +145,9 @@ def cmd_set(args: argparse.Namespace, cn: ModuleType) -> int: def save(state: dict) -> None: + if "mode" not in state and not state.get("parts"): + STATE.unlink(missing_ok=True) + return ROOT.mkdir(parents=True, exist_ok=True) with tempfile.NamedTemporaryFile("w", dir=ROOT, delete=False, encoding="utf-8") as handle: json.dump(state, handle) @@ -114,28 +159,21 @@ def resume_alone(sid: str) -> int: if state is None: print("mode: day") return 0 - if sid != state["active"] and sid not in state.get("resumed", []): - state["resumed"] = [*state.get("resumed", []), sid] - save(state) + save(released(state, [sid])) print(f"resumed: {sid}") return 0 def cmd_status(args: argparse.Namespace, _: ModuleType) -> int: - state = load() - if state is None: - print("mode: day") - return 0 - print(f"mode: {state['mode']}") - print(f"active: {state.get('name') or '-'} {state['active']}") - print(f"since: {state.get('since') or '-'}") + state = load() or {} + print(f"mode: {state.get('mode', 'day')}") + if "mode" in state: + print(f"active: {state.get('name') or '-'} {state['active']}") + print(f"since: {state.get('since') or '-'}") sid = args.session or os.environ.get("CLAUDE_CODE_SESSION_ID") - if sid == state["active"]: - print("you: active") - elif sid in state.get("resumed", []): - print("you: resumed") - elif sid: - print(f"you: {PARTS[state['mode']]}") + part = part_of(state, sid) if sid else None + if part: + print(f"you: {PARTS.get(part, part)}") return 0 @@ -143,6 +181,10 @@ def cmd_resume(args: argparse.Namespace, cn: ModuleType) -> int: sid, _ = cn.caller(args.session) if args.alone: return resume_alone(sid) + if args.target: + save(released(load() or {}, args.target)) + peers({target: cn.live_sessions().get(target) for target in args.target}, "") + return 0 STATE.unlink(missing_ok=True) peers(cn.live_sessions(), sid) return 0 @@ -152,11 +194,15 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) sub = parser.add_subparsers(dest="command", required=True) for mode in PARTS: - sub.add_parser(mode).set_defaults(func=cmd_set) + p = sub.add_parser(mode) + p.add_argument("--target", action="append") + p.set_defaults(func=cmd_set) p = sub.add_parser("status") p.set_defaults(func=cmd_status) p = sub.add_parser("resume") - p.add_argument("--alone", action="store_true") + alone = p.add_mutually_exclusive_group() + alone.add_argument("--alone", action="store_true") + alone.add_argument("--target", action="append") p.set_defaults(func=cmd_resume) for p in sub.choices.values(): p.add_argument("--session") diff --git a/tests/e2e/test_orchestrator_e2e.py b/tests/e2e/test_orchestrator_e2e.py index 6889183..e3d01f7 100644 --- a/tests/e2e/test_orchestrator_e2e.py +++ b/tests/e2e/test_orchestrator_e2e.py @@ -26,7 +26,7 @@ sys.path.insert(0, str(HERE.parent)) from hub_case import HubCase from hub_engines import wav_bytes from hub_support import ASK, HOST_STATE, OPTIONS, RUNNER, iso, verifier -from kvb_orchestrator import skill_scripts +from kvb_orchestrator import messaging, skill_scripts from test_hub_cleanup import DONE_TIP, FAKE_PKGMGR, host from test_hub_offices import COUNCIL_SCRIPT from test_hub_offices import host as office_host @@ -678,11 +678,61 @@ NIGHT_LOOK = """(async () => {@HELPERS@ return { children: [...box.children].map((node) => [node.tagName.toLowerCase(), node.className]), badge: all(".pill", box).map(text), + picks: all(".night-picks label", box).map((label) => [text(label), label.querySelector("input").checked]), buttons: all("button", box).map((button) => [name(button), button.title]), paragraphs: all("p", box).length, wide: document.documentElement.scrollWidth > window.innerWidth + 1, }; })()""".replace("@HELPERS@", HELPERS) +NIGHT_PICK = """(async () => {@HELPERS@ + const questions = []; + window.confirm = (question) => { + questions.push(question); + return true; + }; + navigator.credentials.get = @SIGNS@ ? navigator.credentials.get.bind(navigator.credentials) : () => Promise.reject(new DOMException("The operator canceled.", "NotAllowedError")); + const posted = []; + const send = window.fetch.bind(window); + window.fetch = (path, init) => { + if (String(path).startsWith("/api/board/")) { + const body = JSON.parse(init.body); + posted.push([String(path), Object.keys(body), body.action || null, body.agents || null]); + } + return send(path, init); + }; + const menu = await until(() => all('#main [role="toolbar"] details.menu').find((node) => name(node.querySelector("summary")) === "Day/Night"), 8000); + if (!menu) { + return {error: text(document.getElementById("main"))}; + } + menu.open = true; + await sleep(200); + const box = menu.querySelector(".menu-box"); + const picks = () => all(".night-picks label", box).map((label) => [text(label), label.querySelector("input").checked]); + const enabled = () => all(".actions button", box).map((button) => !button.disabled); + const tick = (label) => all(".night-picks label", box).find((node) => text(node) === label).querySelector("input").click(); + const fresh = async (label, click) => { + const shown = new Set(all("#toasts .toast")); + click(); + return until(() => all("#toasts .toast").filter((node) => !shown.has(node)).map(text).find((entry) => entry.startsWith(`${label}:`)), 15000); + }; + const found = {badge: all(".pill", box).map(text), start: picks()}; + tick("Skills"); + found.one = [picks(), enabled()]; + found.drain = await fresh("Drain", () => all(".actions button", box).find((button) => name(button) === "Drain").click()); + found.kept = picks(); + tick("Stage"); + found.none = [picks(), enabled()]; + tick("All"); + found.all = [picks(), enabled()]; + menu.open = false; + document.getElementById("palette-open").click(); + const item = await until(() => all(".palette-list li").find((li) => text(li.querySelector("span")) === "Stage"), 4000); + item.click(); + const row = await until(() => document.querySelector("#popup .agent-night"), 4000); + found.popup = {badge: all(".pill", row).map(text), buttons: all("button", row).map(name)}; + found.suspend = await fresh("Suspend", () => all("button", row).find((button) => name(button) === "Suspend").click()); + return {...found, questions, posted, wide: document.documentElement.scrollWidth > window.innerWidth + 1}; +})()""".replace("@HELPERS@", HELPERS) FITS = "tall: document.documentElement.scrollHeight > window.innerHeight, wide: document.documentElement.scrollWidth > window.innerWidth + 1" SPOT_SEND = """(async () => {@HELPERS@ const questions = []; @@ -1883,8 +1933,9 @@ class TestOrchestratorE2E(HubCase): for look, (width, _) in zip(seen, WIDTHS, strict=True): with self.subTest(browser=browser, width=width): self.assertNotIn("error", look) - self.assertEqual(look["children"], [["span", ""], ["div", "actions"]]) + self.assertEqual(look["children"], [["span", ""], ["div", "night-picks"], ["div", "actions"]]) self.assertEqual((look["badge"], look["paragraphs"], look["wide"]), (["Day"], 0, False)) + self.assertEqual(look["picks"], [["All", True], ["Skills", True], ["Stage", True]]) self.assertEqual(look["buttons"], [list(entry) for entry in NIGHT_HINTS]) self.assertNotIn("error", found) self.assertEqual(found["labels"], ["Drain", "Suspend", "Resume"]) @@ -1912,6 +1963,39 @@ class TestOrchestratorE2E(HubCase): def test_firefox_asks_the_passkey_for_drain_suspend_and_resume_and_runs_nothing_unsigned(self): self.check_night_menu("firefox", "bidi.mjs", os.environ.get("FIREFOX", "firefox"), signs=False) + def check_night_picks(self, browser: str, driver: str, binary: str, signs: bool) -> None: + """Args: + signs: as in check_night_menu. + """ + self.write_council({"night.json": {"parts": {"s2": "drain"}}}) + probe = NIGHT_PICK.replace("@SIGNS@", "true" if signs else "false") + steps = [{"width": width, "height": height, "url": f"http://localhost:{self.port}/?{width}#agents/orbit", "wait": 1200, "probe": probe, "shot": str(OUT / f"{browser}-{width}-night-picks.png")} for width, height in WIDTHS] + data = self.drive(steps, self.authenticator() if signs else None, driver, binary) + (OUT / f"{browser}-night-picks.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], []) + everyone, one, nobody = [["All", True], ["Skills", True], ["Stage", True]], [["All", False], ["Skills", False], ["Stage", True]], [["All", False], ["Skills", False], ["Stage", False]] + for found, (width, _) in zip(data["results"], WIDTHS, strict=True): + with self.subTest(browser=browser, width=width): + self.assertNotIn("error", found) + self.assertEqual((found["badge"], found["start"], found["wide"]), (["Day", "1 alone"], everyone, False)) + self.assertEqual((found["one"], found["kept"], found["none"], found["all"]), ([one, [True] * 3], one, [nobody, [False] * 3], [everyone, [True] * 3])) + self.assertEqual(found["popup"], {"badge": ["Draining"], "buttons": ["Drain", "Suspend", "Resume"]}) + self.assertEqual([question.split(":")[0] for question in found["questions"]], ["Drain Stage", "Suspend Stage"]) + sent = [["/api/board/sign", ["action", "agents"], "drain", ["s2"]], ["/api/board/drain", ["proof", "agents"], None, ["s2"]], ["/api/board/sign", ["action", "agents"], "suspend", ["s2"]], ["/api/board/suspend", ["proof", "agents"], None, ["s2"]]] + self.assertEqual(found["posted"], sent if signs else sent[::2]) + self.assertEqual([found["drain"].split("$")[0].split()[1], found["suspend"].split("$")[0].split()[1]], ["done", "done"] if signs else ["not", "not"]) + nights = [args for script, args in self.scripts.calls if script == skill_scripts.NIGHT] + self.assertEqual(nights, [[action, "--session", "Operator", "--target=s2"] for _ in WIDTHS for action in ("drain", "suspend")] if signs else []) + calls = [args for script, args in self.scripts.calls if script == skill_scripts.RADIO] + self.assertEqual(calls, [["send", "call", "--to", "Stage", "--session", "Operator", messaging.ALONE[action]] for _ in WIDTHS for action in ("drain", "suspend")] if signs else []) + + def test_chromium_drains_and_suspends_chosen_agents_from_the_menu_and_the_agent_panel_at_three_widths(self): + self.check_night_picks("chromium", "cdp.mjs", os.environ.get("CHROMIUM", "chromium"), signs=True) + + @unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox") + def test_firefox_asks_the_passkey_for_chosen_agents_and_runs_nothing_unsigned_at_three_widths(self): + self.check_night_picks("firefox", "bidi.mjs", os.environ.get("FIREFOX", "firefox"), signs=False) + def test_chromium_deletes_a_merged_branch_after_dry_run_and_signature(self): tree = json.loads(self.tree_file.read_text(encoding="utf-8")) tree["delete_ready"], tree["delete_reason"] = True, "" diff --git a/tests/hub_case.py b/tests/hub_case.py index 9639fe0..11dfe4a 100644 --- a/tests/hub_case.py +++ b/tests/hub_case.py @@ -136,8 +136,8 @@ class HubCase(unittest.TestCase): self.assertEqual(status, 200, body) return signer.proof(json.loads(body)["payload"], self.origin) - def board_proof(self, action: str, signer: Key) -> dict: - status, _, body = self.post_json("/api/board/sign", {"action": action}, self.guard(self.host)) + def board_proof(self, action: str, signer: Key, agents: list[str] | None = None) -> dict: + status, _, body = self.post_json("/api/board/sign", {"action": action, **({} if agents is None else {"agents": agents})}, self.guard(self.host)) self.assertEqual(status, 200, body) return signer.proof(json.loads(body)["payload"], self.origin) diff --git a/tests/test_hub_board.py b/tests/test_hub_board.py index bb272aa..a104956 100644 --- a/tests/test_hub_board.py +++ b/tests/test_hub_board.py @@ -12,7 +12,7 @@ from unittest import mock from urllib.parse import quote from hub_case import HubCase -from hub_support import ASK, HOST_STATE, REPO_ROOT, iso, key, server +from hub_support import ASK, HOST_STATE, REPO_ROOT, iso, key, server, signing from kvb_orchestrator import handler, messaging, skill_scripts, transcripts @@ -153,6 +153,53 @@ class TestHubBoard(HubCase): self.assertEqual(self.get("/api/board/sign")[0], 405) self.assertEqual(len(self.scripts.calls), 1) + def test_a_night_action_for_chosen_agents_signs_them_and_calls_only_them(self): + self.claim("s1", "Skills session") + self.claim("s2", "Gatekeeper") + signer = self.pin() + good = self.guard(self.host) + + def board(action: str, payload: dict) -> tuple[int, bytes]: + status, _, body = self.post_json(f"/api/board/{action}", payload, good) + return status, body + + self.assertEqual(signing.board_item("drain", []), {"kind": "board", "action": "drain"}) + proof = self.board_proof("drain", signer, ["s2"]) + self.assertEqual(board("drain", {"proof": proof})[1], b"not signed: the signature does not list this item\n") + self.assertEqual(board("drain", {"proof": self.board_proof("drain", signer), "agents": ["s2"]})[1], b"not signed: the signature does not list this item\n") + self.assertEqual(board("drain", {"proof": proof, "agents": ["s2", "s1"]})[1], b"not signed: the signature does not list this item\n") + self.assertEqual(self.scripts.calls, []) + self.assertEqual(board("drain", {"proof": proof, "agents": ["s2"]})[0], 200) + self.assertEqual(self.scripts.calls, [ + (skill_scripts.NIGHT, ["drain", "--session", "Operator", "--target=s2"]), + (skill_scripts.RADIO, ["send", "call", "--to", "Gatekeeper", "--session", "Operator", messaging.ALONE["drain"]]), + ]) + self.assertIn("run kvb-agent-drain;", messaging.ALONE["drain"]) + self.assertEqual(board("resume", {"proof": self.board_proof("resume", signer, ["s2", "s1"]), "agents": ["s1", "s2"]})[0], 200) + self.assertEqual(self.scripts.calls[-2:], [ + (skill_scripts.NIGHT, ["resume", "--session", "Operator", "--target=s1", "--target=s2"]), + (skill_scripts.RADIO, ["send", "call", "--to", "Gatekeeper", "--to", "Skills session", "--session", "Operator", messaging.ALONE["resume"]]), + ]) + calls = len(self.scripts.calls) + for action, agents in (("drain", ["s9"]), ("drain", ["s2", "s2"]), ("drain", "s2"), ("drain", [2]), ("drain", ["Operator"]), ("clear-mayday", ["s2"]), ("estimates", ["s1"])): + with self.subTest(action=action, agents=agents): + self.assertEqual(self.post_json("/api/board/sign", {"action": action, "agents": agents}, good)[0], 400) + self.assertEqual(board(action, {"proof": None, "agents": agents})[0], 400) + self.assertEqual(len(self.scripts.calls), calls) + + def test_the_state_shows_each_sessions_own_part(self): + for night, parts in ( + ({"parts": {"s2": "drain", "s3": "noon"}}, {"s2": "drain"}), + ({"mode": "drain", "active": "Operator", "resumed": ["s1"], "parts": {"s1": "suspend"}}, {"Operator": "day", "s1": "suspend"}), + ({"mode": "sunset", "active": "Operator", "awake": ["s3"]}, {"Operator": "day", "s3": "day"}), + ({"active": "Operator", "resumed": ["s1"]}, {}), + ({"mode": "drain", "parts": ["s1"], "resumed": "s1"}, {}), + (["drain"], {}), + ): + with self.subTest(night=night): + self.write_council({"night.json": night}) + self.assertEqual(self.snapshot()["night_parts"], parts) + def test_every_night_action_is_a_command_the_shipped_night_script_takes(self): with tempfile.TemporaryDirectory() as tmp: scripts = skill_scripts.Scripts(skills=REPO_ROOT / "skills", council=Path(tmp) / "council", home=Path(tmp)) @@ -162,6 +209,10 @@ class TestHubBoard(HubCase): self.assertEqual(run["code"], 0, run["stderr"]) status = scripts.run(skill_scripts.NIGHT, ["status", "--session", "Operator"])["stdout"].splitlines() self.assertEqual(status[0], "mode: day" if action == "resume" else f"mode: {action}") + run = scripts.run(skill_scripts.NIGHT, [*command, "--session", "Operator", "--target=s2"]) + self.assertEqual(run["code"], 0, run["stderr"]) + status = scripts.run(skill_scripts.NIGHT, ["status", "--session", "s2"])["stdout"].splitlines() + self.assertEqual(status[-1].startswith(f"you: {action}"), action != "resume", status) def test_nl_actions_are_validated_against_a_faked_endpoint(self): status, _, body = self.post_json("/api/nl", {"text": "approve all"}, self.guard(self.host)) diff --git a/tests/test_hub_state.py b/tests/test_hub_state.py index fddd2bb..1d41db7 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", "silence", "graph", "inbox", "pending", "rules", "approvers", "jobs", "views", "llm", "people", "settings", "media", "speech", "badges", "kinds", }, diff --git a/tests/test_night.py b/tests/test_night.py index 28d9af6..d0148a2 100644 --- a/tests/test_night.py +++ b/tests/test_night.py @@ -85,6 +85,38 @@ class TestNight(unittest.TestCase): self.assertEqual(self._out("resume", sid="s2"), ["s1\tOne", "s3\tThree"]) self.assertFalse((self.root / "night.json").exists()) + def test_targets_take_a_part_alone_by_day_and_resume_lifts_only_theirs(self): + self.assertEqual(self._out("drain", "--target=s2", "--target=s3"), ["s3\tThree", "s2\tTwo"]) + self._out("suspend", "--target=s3") + self.assertEqual(json.loads((self.root / "night.json").read_text(encoding="utf-8")), {"parts": {"s2": "drain", "s3": "suspend"}}) + self.assertEqual(self._out("status", sid="s2"), ["mode: day", "you: drain (kvb-agent-drain)"]) + self.assertEqual(self._out("status", sid="s3"), ["mode: day", "you: suspend (kvb-agent-suspend)"]) + self.assertEqual(self._out("status"), ["mode: day"]) + self.assertEqual(self._out("resume", "--target=s2"), ["s2\tTwo"]) + self.assertEqual(self._out("status", sid="s2"), ["mode: day"]) + self.assertEqual(self._out("resume", "--alone", sid="s3"), ["resumed: s3"]) + self.assertFalse((self.root / "night.json").exists()) + + def test_a_part_outranks_the_mode_and_a_targeted_resume_joins_the_resumed(self): + self._out("drain") + self._out("suspend", "--target=s2") + state = json.loads((self.root / "night.json").read_text(encoding="utf-8")) + self.assertEqual((state["mode"], state["active"], state["parts"]), ("drain", "s1", {"s2": "suspend"})) + self.assertEqual(self._out("status", sid="s2")[3], "you: suspend (kvb-agent-suspend)") + self.assertEqual(self._out("status", sid="s3")[3], "you: drain (kvb-agent-drain)") + self._out("resume", "--target=s2", "--target=s3", "--target=s1") + state = json.loads((self.root / "night.json").read_text(encoding="utf-8")) + self.assertEqual((state["mode"], state["parts"], state["resumed"]), ("drain", {}, ["s2", "s3"])) + self.assertEqual([self._out("status", sid=sid)[3] for sid in ("s1", "s2", "s3")], ["you: active", "you: resumed", "you: resumed"]) + self._out("suspend", "--target=s3") + self._out("resume", sid="s2") + self.assertFalse((self.root / "night.json").exists()) + + def test_a_target_never_parses_as_an_option(self): + self._out("drain", "--target=--alone") + self.assertEqual(json.loads((self.root / "night.json").read_text(encoding="utf-8")), {"parts": {"--alone": "drain"}}) + self.assertEqual(self._run("resume", "--alone", "--target=s2").returncode, 2) + def test_bad_state_means_day(self): self.root.mkdir() for text in ( @@ -94,6 +126,11 @@ class TestNight(unittest.TestCase): json.dumps(["suspend"]), json.dumps({"mode": "suspend", "active": "s1", "resumed": "s2"}), json.dumps({"mode": "sunset", "active": "s1", "awake": "s2"}), + json.dumps({"parts": {}}), + json.dumps({"parts": {"s2": "noon"}}), + json.dumps({"parts": {"s2": ["drain"]}}), + json.dumps({"parts": ["s2"]}), + json.dumps({"mode": "drain", "active": "s1", "parts": {"s2": "evening"}}), ): with self.subTest(text=text): (self.root / "night.json").write_text(text, encoding="utf-8")