Files
bench/tests/test_install_first_boot.py
istosandClaude Opus 5 6829136f42 start: a pinned port stays pinned across a restart
The free-port probe bound without SO_REUSEADDR while the board's own
ThreadingHTTPServer sets it, so the socket a just-stopped board left in
TIME_WAIT read as "taken by something else": a routine stop/start walked
the board to the next port and wrote that over the user's BOARD_PORT pin.
The probe now binds exactly as the server does, which is the whole race.

Behind it, a held port gets a few seconds (BOARD_PORT_WAIT, 5s) to clear
before the walk, re-asking is_our_board each beat — a restart races its
own predecessor far more often than a stranger takes the port. Walking
off a pinned port still persists, since the hooks and agents read
BOARD_PORT and must reach the live board, but it now says so in full:
the right file (manager/local/.env, not manager/.env), old → new, and
how to reclaim the pin.

Tested end to end against a scratch host with a stub board.py, over real
sockets: a genuine TIME_WAIT remnant, a listener that lets go mid-wait,
a listener that does not, and our own board answering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 07:32:54 +02:00

418 lines
20 KiB
Python

"""install.py's first run: it clears the distribution's own cards so a new
host starts with a pristine board (and no later run ever touches the
host's own), then asks the handful of settings questions it cannot answer
for the project and writes manager/local/.env. Run with:
python3 -m unittest discover -s tests
install.py is exercised end-to-end as a subprocess against scratch host
layouts — the same entry point start.sh uses — so what is asserted is
what a real first boot does to disk. The questions need a terminal on
stdin, so those runs get a real pty; every other run gets /dev/null,
which is also the non-interactive case the board must never block in.
"""
import importlib.util
import os
import pty
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
STAGES = ["backlog", "to-do", "in-progress", "review", "done"]
KEEP = {".gitkeep", "task-template.md"}
EXAMPLE = REPO / "manager" / "core" / ".env.example"
CTRL_D = "\x04" # end of transmission: the one keystroke that skips
def load_install():
"""install.py as a module, for its pure helpers."""
spec = importlib.util.spec_from_file_location(
"bench_install", REPO / "install.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def make_host(root: Path) -> Path:
"""A host project with a freshly vendored .task-manager: the real
install.py, .env.example and claude adapter, plus the distribution's
shipped cards."""
host = root / "host"
(host / ".claude").mkdir(parents=True)
tm = host / ".task-manager"
tm.mkdir()
shutil.copy(REPO / "install.py", tm / "install.py")
shutil.copytree(REPO / "manager" / "core" / "adapters" / "claude",
tm / "manager" / "core" / "adapters" / "claude")
shutil.copy(EXAMPLE, tm / "manager" / "core" / ".env.example")
for stage in STAGES + ["archive"]:
d = tm / "tasks" / stage
d.mkdir(parents=True)
(d / ".gitkeep").touch()
(d / "00-shipped-card.md").write_text("# Shipped\n", encoding="utf-8")
(tm / "tasks" / "task-template.md").write_text("# Template\n", encoding="utf-8")
for extra in ["plans", "reference"]:
d = tm / extra
d.mkdir()
(d / ".gitkeep").touch()
(d / "shipped.md").write_text("shipped\n", encoding="utf-8")
(tm / "reference" / "shots").mkdir()
(tm / "reference" / "shots" / "board.png").write_bytes(b"png")
return tm
def clean_env() -> dict:
return {k: v for k, v in os.environ.items() if not k.startswith("BOARD_")}
def run_install(tm: Path, *args: str) -> subprocess.CompletedProcess:
"""A run with nothing on stdin — a hook, update.sh, CI, or a developer
piping the output somewhere. Setup must never ask here."""
return subprocess.run(
[sys.executable, str(tm / "install.py"), *args],
capture_output=True, text=True, cwd=tm.parent, env=clean_env(),
stdin=subprocess.DEVNULL)
def run_install_tty(tm: Path, answers: list[str], *args: str) -> str:
"""The interactive run: stdin is a real terminal, so setup asks, and
`answers` are typed at it in order (an empty string = bare Enter, the
default). Returns stdout+stderr; the answers are echoed by the tty to
the master side, not into what is captured here."""
master, slave = pty.openpty()
proc = subprocess.Popen(
[sys.executable, str(tm / "install.py"), *args],
stdin=slave, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, cwd=tm.parent, env=clean_env())
os.close(slave)
try:
for answer in answers:
os.write(master, answer.encode() if answer == CTRL_D
else (answer + "\n").encode())
out, _ = proc.communicate(timeout=60)
except subprocess.TimeoutExpired: # a question we did not answer
proc.kill()
out, _ = proc.communicate()
raise AssertionError(f"install.py never finished. Output:\n{out}")
finally:
os.close(master)
return out
def shipped_files(tm: Path) -> list[Path]:
"""Every file under the cleaned directories that first boot should
have removed — empty means the board is pristine."""
return [p
for top in [tm / "tasks", tm / "plans", tm / "reference"]
for p in top.rglob("*")
if p.is_file() and p.name not in KEEP]
class FirstBoot(unittest.TestCase):
def setUp(self):
self.scratch = Path(tempfile.mkdtemp()).resolve()
self.addCleanup(shutil.rmtree, self.scratch, True)
self.tm = make_host(self.scratch)
def test_first_run_clears_shipped_content_and_prints_each_removal(self):
result = run_install(self.tm)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(shipped_files(self.tm), [])
for stage in STAGES + ["archive"]:
self.assertTrue((self.tm / "tasks" / stage / ".gitkeep").is_file())
self.assertTrue((self.tm / "tasks" / "task-template.md").is_file())
self.assertTrue((self.tm / "plans" / ".gitkeep").is_file())
self.assertTrue((self.tm / "reference" / ".gitkeep").is_file())
for line in ["tasks/backlog/00-shipped-card.md",
"tasks/archive/00-shipped-card.md",
"plans/shipped.md", "reference/shots"]:
self.assertIn(f"removed {line}", result.stdout)
self.assertTrue((self.tm / "manager" / "local" / "state").is_dir())
def test_second_run_removes_nothing_and_host_cards_survive(self):
run_install(self.tm)
card = self.tm / "tasks" / "backlog" / "20-host-card.md"
card.write_text("# The host's own\n", encoding="utf-8")
result = run_install(self.tm)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertNotIn("removed", result.stdout)
self.assertTrue(card.is_file())
self.assertIn("ok", result.stdout)
def test_dry_run_lists_without_removing(self):
result = run_install(self.tm, "--dry-run")
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("would remove tasks/backlog/00-shipped-card.md",
result.stdout)
self.assertNotIn("removed ", result.stdout)
self.assertNotEqual(shipped_files(self.tm), [])
self.assertFalse((self.tm / "manager" / "local" / "state").exists())
def test_existing_env_file_disarms_the_guard(self):
local = self.tm / "manager" / "local"
local.mkdir(parents=True)
(local / ".env").write_text("BOARD_PORT=26071\n", encoding="utf-8")
result = run_install(self.tm)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertNotIn("removed", result.stdout)
self.assertNotEqual(shipped_files(self.tm), [])
self.assertFalse((local / "state").exists())
def test_symlinked_leftover_is_unlinked_not_followed(self):
"""A symlink among the leftovers is removed as a link — the
directory it points to survives untouched."""
outside = self.scratch / "outside"
outside.mkdir()
(outside / "precious.md").write_text("keep me\n", encoding="utf-8")
link = self.tm / "tasks" / "backlog" / "10-linked"
link.symlink_to(outside)
result = run_install(self.tm)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertFalse(link.is_symlink())
self.assertFalse(link.exists())
self.assertTrue((outside / "precious.md").is_file())
def test_self_hosted_repo_is_never_cleaned(self):
"""When the manager IS the repo (bench itself, or a dev clone of
it), tasks/ is that repo's history — even unwired, never touched."""
subprocess.run(["git", "init", "-q", str(self.tm)], check=True,
capture_output=True)
(self.tm / ".claude").mkdir()
result = run_install(self.tm)
self.assertNotIn("removed", result.stdout)
self.assertNotEqual(shipped_files(self.tm), [])
self.assertFalse((self.tm / "manager" / "local" / "state").exists())
class FirstRunSettings(unittest.TestCase):
"""The questions a first run asks, and the manager/local/.env it
writes from the answers."""
def setUp(self):
self.scratch = Path(tempfile.mkdtemp()).resolve()
self.addCleanup(shutil.rmtree, self.scratch, True)
self.tm = make_host(self.scratch)
self.env_file = self.tm / "manager" / "local" / ".env"
self.example = (EXAMPLE).read_text(encoding="utf-8")
def values(self) -> dict:
return load_install().env_values(
self.env_file.read_text(encoding="utf-8"))
def test_bare_enter_everywhere_writes_the_shipped_defaults(self):
"""Every question defaulted → the file is the example, save the one
key that is read off the project rather than asked about. A scratch
host names no test runner, so that key lands empty."""
out = run_install_tty(self.tm, ["", ""])
self.assertIn("solo or team?", out)
self.assertIn("which agent adapter?", out)
self.assertEqual(
self.env_file.read_text(encoding="utf-8"),
self.example.replace("BOARD_AGENT_COMMANDS=python3 -m unittest",
"BOARD_AGENT_COMMANDS="))
def test_the_test_command_is_never_asked_for(self):
"""It was a third question once. It wanted an answer about a repo
the person may have just cloned, before anything had explained why
the board needed one."""
out = run_install_tty(self.tm, ["", ""])
self.assertNotIn("runs this project's tests", out)
def test_the_test_command_is_read_off_the_project(self):
(self.tm.parent / "package.json").write_text("{}\n", encoding="utf-8")
run_install_tty(self.tm, ["", ""])
self.assertEqual(self.values()["BOARD_AGENT_COMMANDS"], "npm test")
def test_a_project_naming_no_runner_gets_an_empty_allowlist(self):
"""Empty is honest: the agent runs no project commands until
someone fills it in. The example's Python default would be quietly
wrong in most repos, and wrong is worse than absent here."""
run_install_tty(self.tm, ["", ""])
self.assertEqual(self.values()["BOARD_AGENT_COMMANDS"], "")
def test_the_first_marker_wins(self):
"""A repo with both a package.json and a tests/ directory is a JS
project with tests, not a Python one."""
(self.tm.parent / "package.json").write_text("{}\n", encoding="utf-8")
(self.tm.parent / "tests").mkdir(exist_ok=True)
run_install_tty(self.tm, ["", ""])
self.assertEqual(self.values()["BOARD_AGENT_COMMANDS"], "npm test")
def test_answers_are_substituted_into_the_whole_example(self):
(self.tm.parent / "package.json").write_text("{}\n", encoding="utf-8")
out = run_install_tty(self.tm, ["team", "claude"])
written = self.env_file.read_text(encoding="utf-8")
self.assertEqual(self.values()["BOARD_COMMIT_MOVES"], "1")
self.assertEqual(self.values()["BOARD_SYNC"], "1")
self.assertEqual(self.values()["BOARD_AGENT_COMMANDS"], "npm test")
# Every other key and every comment survives — the written file is
# where the project reads what else it can change.
self.assertEqual(sorted(self.values()),
sorted(load_install().env_values(self.example)))
self.assertIn("# Seconds between disk polls of the stage directories.",
written)
self.assertEqual(self.values()["BOARD_PORT"], "26071")
self.assertIn("Wrote .task-manager/manager/local/.env", out)
def test_solo_leaves_both_team_settings_empty(self):
run_install_tty(self.tm, ["solo", ""])
self.assertEqual(self.values()["BOARD_COMMIT_MOVES"], "")
self.assertEqual(self.values()["BOARD_SYNC"], "")
def test_an_invalid_answer_is_asked_again(self):
out = run_install_tty(self.tm, ["both", "team", ""])
self.assertIn("answer solo or team.", out)
self.assertEqual(self.values()["BOARD_SYNC"], "1")
def test_ctrl_d_skips_the_rest_and_writes_the_defaults(self):
"""The detected command is settled before the first question, so a
Ctrl-D part-way through still leaves the project's own runner —
not the example's Python one, which is what a later detection
would have been skipped past."""
(self.tm.parent / "package.json").write_text("{}\n", encoding="utf-8")
out = run_install_tty(self.tm, ["team", CTRL_D])
self.assertIn("skipped", out)
self.assertEqual(self.values()["BOARD_SYNC"], "1")
self.assertEqual(self.values()["BOARD_AGENT_COMMANDS"], "npm test")
def test_the_adapter_question_enumerates_the_directories(self):
"""Adapters are listed from disk, so a project's own local one is
offered beside the shipped ones."""
for name in ["opencode"]:
d = self.tm / "manager" / "core" / "adapters" / name
d.mkdir(parents=True)
(d / "run").write_text("#!/bin/sh\n", encoding="utf-8")
mine = self.tm / "manager" / "local" / "adapters" / "mine"
mine.mkdir(parents=True)
(mine / "run").write_text("#!/bin/sh\n", encoding="utf-8")
out = run_install_tty(self.tm, ["", "mine"])
self.assertIn("here: claude, opencode, mine.", out)
self.assertEqual(self.values()["BOARD_AGENT_ADAPTER"], "mine")
def test_an_existing_env_is_never_touched_and_the_run_stays_quiet(self):
run_install_tty(self.tm, ["team", ""])
written = self.env_file.read_text(encoding="utf-8")
result = run_install(self.tm)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("ok", result.stdout)
self.assertNotIn("solo or team", result.stdout)
self.assertEqual(self.env_file.read_text(encoding="utf-8"), written)
# And with a terminal too: an .env present means no questions, so
# this run must finish without anything typed at it.
out = run_install_tty(self.tm, [])
self.assertNotIn("solo or team", out)
self.assertEqual(self.env_file.read_text(encoding="utf-8"), written)
def test_without_a_terminal_it_says_so_and_writes_nothing(self):
result = run_install(self.tm)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("no .task-manager/manager/local/.env", result.stdout)
self.assertIn(".task-manager/install.py --setup", result.stdout)
self.assertFalse(self.env_file.exists())
def test_dry_run_reports_the_questions_and_writes_nothing(self):
result = run_install(self.tm, "--dry-run")
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("would ask", result.stdout)
self.assertFalse(self.env_file.exists())
def test_setup_rewrites_an_existing_file_from_its_own_values(self):
"""--setup is the only way back to the questions, and it offers
what the file says today — including keys it never asks about,
which survive untouched."""
self.env_file.parent.mkdir(parents=True, exist_ok=True)
self.env_file.write_text(
self.example.replace("BOARD_PORT=26071", "BOARD_PORT=26099")
.replace("BOARD_SYNC=\n", "BOARD_SYNC=1\n")
.replace("BOARD_AGENT_COMMANDS=python3 -m unittest",
"BOARD_AGENT_COMMANDS=make test"),
encoding="utf-8")
out = run_install_tty(self.tm, ["", ""], "--setup")
self.assertIn("[team]", out) # the current file's answer…
self.assertEqual(self.values()["BOARD_SYNC"], "1")
self.assertEqual(self.values()["BOARD_PORT"], "26099")
# …and the hand-edited command survives, detection notwithstanding:
# an existing answer is never second-guessed by a marker file.
self.assertEqual(self.values()["BOARD_AGENT_COMMANDS"], "make test")
out = run_install_tty(self.tm, ["solo", ""], "--setup")
self.assertEqual(self.values()["BOARD_SYNC"], "")
self.assertEqual(self.values()["BOARD_COMMIT_MOVES"], "")
self.assertEqual(self.values()["BOARD_PORT"], "26099")
self.assertEqual(self.values()["BOARD_AGENT_COMMANDS"], "make test")
def test_setup_without_a_terminal_leaves_the_file_alone(self):
self.env_file.parent.mkdir(parents=True, exist_ok=True)
self.env_file.write_text("BOARD_PORT=26099\n", encoding="utf-8")
result = run_install(self.tm, "--setup")
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(self.env_file.read_text(encoding="utf-8"),
"BOARD_PORT=26099\n")
def test_first_boot_both_clears_the_cards_and_writes_the_env(self):
"""The order is load-bearing: .env is one of the two things the
first-boot guard reads, so writing it early would skip the clean."""
out = run_install_tty(self.tm, ["", ""])
self.assertIn("removed tasks/backlog/00-shipped-card.md", out)
self.assertEqual(shipped_files(self.tm), [])
self.assertTrue(self.env_file.is_file())
card = self.tm / "tasks" / "backlog" / "20-host-card.md"
card.write_text("# The host's own\n", encoding="utf-8")
result = run_install(self.tm)
self.assertNotIn("removed", result.stdout)
self.assertTrue(card.is_file())
def test_start_sh_port_fallback_survives_the_written_file(self):
"""start.sh persists a fallback port into the same file. It is the
one other writer, and it must leave the rest of it intact."""
run_install_tty(self.tm, ["team", "", ""])
text = (REPO / "start.sh").read_text(encoding="utf-8")
snippet = (text.split('python3 - "$ENV_FILE" "$port" <<\'PY\'\n', 1)[1]
.split("\nPY\n", 1)[0])
result = subprocess.run(
[sys.executable, "-c", snippet, str(self.env_file), "26072"],
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self.values()["BOARD_PORT"], "26072")
self.assertEqual(self.values()["BOARD_SYNC"], "1")
self.assertIn("# Seconds between disk polls of the stage directories.",
self.env_file.read_text(encoding="utf-8"))
class EnvFileHelpers(unittest.TestCase):
"""The pure halves of setup, unit-tested directly."""
def setUp(self):
self.install = load_install()
def test_substitute_rewrites_in_place_and_keeps_everything_else(self):
base = "# a comment\nBOARD_PORT=26071\n\n# another\nBOARD_SYNC=\n"
out = self.install.substitute(base, {"BOARD_SYNC": "1"})
self.assertEqual(
out, "# a comment\nBOARD_PORT=26071\n\n# another\nBOARD_SYNC=1\n")
def test_substitute_appends_a_key_the_base_never_mentions(self):
out = self.install.substitute("BOARD_PORT=26071\n",
{"BOARD_SYNC": "1"})
self.assertEqual(out, "BOARD_PORT=26071\nBOARD_SYNC=1\n")
def test_env_values_reads_quotes_and_ignores_comments(self):
values = self.install.env_values(
"# BOARD_SYNC=1\nBOARD_AGENT_COMMANDS='npm test'\nBOARD_SYNC=\n")
self.assertEqual(values,
{"BOARD_AGENT_COMMANDS": "npm test", "BOARD_SYNC": ""})
def test_env_on_follows_the_flag_rule(self):
for off in ["", " ", "0", "false", "No", "OFF"]:
self.assertFalse(self.install.env_on(off), off)
for on in ["1", "yes", "true", "anything"]:
self.assertTrue(self.install.env_on(on), on)
if __name__ == "__main__":
unittest.main()