Merge feature/orchestrator/s52-night-per-agent into master

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

# Conflicts:
#	docs/requirements/001-agent-orchestrator.md
#	orchestrator/kvb_orchestrator/view.py
#	orchestrator/templates/page-board.js
#	tests/test_hub_state.py
This commit is contained in:
Kevin Veen-Birkenbach committed 2026-11-11 07:53:40 +00:00
commit c3ab6b7e18
18 files changed
+408 -64

No files matched your search

+86 -2
View File
@@ -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
@@ -677,11 +677,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 = [];
@@ -1950,8 +2000,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"])
@@ -1979,6 +2030,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, ""
+2 -2
View File
@@ -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)
+52 -1
View File
@@ -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))
+37
View File
@@ -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")