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, ""