Files
bench/tests/test_self_hosting.py
T
istosandClaude Fable 5 cc931a9174 Start cleanly when bench is the project itself
Three layout defects broke ./start.sh in the self-hosted repo, one of
which (missing local/state/) broke every fresh vendored install too:

- install.py resolved the project as TM.parent, a hardcoded vendored-
  layout assumption. It now asks git for the toplevel from the manager's
  directory (same resolution as config._repo_root), falling back to the
  parent when git is unavailable — vendored installs still find the host
  repo, self-hosted bench finds itself instead of its parent.

- adapters/claude/wire hardcoded ".task-manager/" into the emit hook
  command and plansDirectory. Both are now derived from the manager's
  path relative to the project root, so vendored installs keep the
  .task-manager/ prefix and self-hosted bench gets prefix-free paths.
  _is_ours also recognises the emit.py suffix, so settings wired with
  the old literal path count as stale and are repaired idempotently.

- board.py and state.py created the sessions/agent dirs without
  parents=True; local/state/ is gitignored and ships empty, so a virgin
  checkout died with FileNotFoundError before serving. Boot now creates
  the whole chain, wiring or no wiring.

tests/test_self_hosting.py (stdlib unittest) covers both layouts' wiring,
stale-path repair, idempotent re-runs, refusal without .claude/, root
resolution with and without git, and an integration boot of board.py
from a scratch checkout with no local/state/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 19:55:57 +02:00

265 lines
9.7 KiB
Python

"""Layout resolution: the manager must start cleanly both vendored one
level inside a host repo (.task-manager/) and self-hosted, where this repo
is simultaneously the distribution and the project.
Stdlib only, like everything else here:
python3 -m unittest discover -s tests -v
"""
from __future__ import annotations
import json
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import time
import unittest
import urllib.request
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
ADAPTER_FILES = ("wire", "emit.py")
EMIT_SUFFIX = "manager/core/adapters/claude/emit.py"
OLD_EMIT_CMD = ('python3 "$CLAUDE_PROJECT_DIR/.task-manager/'
'manager/core/adapters/claude/emit.py"')
class TempDirTestCase(unittest.TestCase):
def setUp(self):
# resolve(): macOS tempdirs live behind the /var → /private/var
# symlink, and both wire and git report resolved paths.
self.tmp = Path(tempfile.mkdtemp(prefix="bench-test-")).resolve()
self.addCleanup(shutil.rmtree, self.tmp, True)
def make_manager(tm_root: Path) -> None:
"""A minimal manager tree at tm_root: install.py plus the claude
adapter's wire and emit.py, copied from this repo."""
adapter = tm_root / "manager" / "core" / "adapters" / "claude"
adapter.mkdir(parents=True)
for name in ADAPTER_FILES:
shutil.copy(REPO / "manager" / "core" / "adapters" / "claude" / name,
adapter / name)
shutil.copy(REPO / "install.py", tm_root / "install.py")
def run(cmd: list, cwd: Path) -> subprocess.CompletedProcess:
env = {k: v for k, v in os.environ.items() if not k.startswith("BOARD_")}
return subprocess.run([str(a) for a in cmd], cwd=str(cwd), env=env,
capture_output=True, text=True)
def wire(tm_root: Path, project: Path) -> subprocess.CompletedProcess:
script = tm_root / "manager" / "core" / "adapters" / "claude" / "wire"
return run([sys.executable, script, project], cwd=project)
def settings(project: Path) -> dict:
return json.loads((project / ".claude" / "settings.json").read_text())
def our_hook_commands(cfg: dict) -> set:
return {
hook["command"]
for groups in cfg.get("hooks", {}).values()
for group in groups
for hook in group.get("hooks", [])
if "emit.py" in hook.get("command", "")
}
class WireLayouts(TempDirTestCase):
def test_self_hosted_paths_have_no_task_manager_segment(self):
project = self.tmp / "bench"
make_manager(project)
(project / ".claude").mkdir()
result = wire(project, project)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
cfg = settings(project)
self.assertEqual(cfg["plansDirectory"], "./plans")
self.assertEqual(
our_hook_commands(cfg),
{f'python3 "$CLAUDE_PROJECT_DIR/{EMIT_SUFFIX}"'})
def test_vendored_paths_keep_task_manager_segment(self):
host = self.tmp / "host"
make_manager(host / ".task-manager")
(host / ".claude").mkdir()
result = wire(host / ".task-manager", host)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
cfg = settings(host)
self.assertEqual(cfg["plansDirectory"], "./.task-manager/plans")
self.assertEqual(
our_hook_commands(cfg),
{f'python3 "$CLAUDE_PROJECT_DIR/.task-manager/{EMIT_SUFFIX}"'})
def test_stale_hardcoded_path_is_repaired_in_place(self):
project = self.tmp / "bench"
make_manager(project)
(project / ".claude").mkdir()
stale_hook = {"type": "command", "command": OLD_EMIT_CMD, "timeout": 5}
(project / ".claude" / "settings.json").write_text(json.dumps({
"plansDirectory": "./.task-manager/plans",
"hooks": {"SessionStart": [{"hooks": [stale_hook]}],
"PreToolUse": [{"matcher": "Bash", "hooks": [stale_hook]}]},
}))
result = wire(project, project)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
cfg = settings(project)
self.assertEqual(cfg["plansDirectory"], "./plans")
self.assertEqual(
our_hook_commands(cfg),
{f'python3 "$CLAUDE_PROJECT_DIR/{EMIT_SUFFIX}"'})
hooks = [hook for group in cfg["hooks"]["SessionStart"]
for hook in group["hooks"]]
self.assertEqual(len(hooks), 1, "stale hook must be replaced, not kept")
def test_second_run_is_a_no_op(self):
for project, tm_root in (
(self.tmp / "bench", self.tmp / "bench"),
(self.tmp / "host", self.tmp / "host" / ".task-manager"),
):
with self.subTest(project=project.name):
make_manager(tm_root)
(project / ".claude").mkdir()
wire(tm_root, project)
before = (project / ".claude" / "settings.json").read_text()
result = wire(tm_root, project)
self.assertEqual(result.returncode, 0)
self.assertIn("nothing to do", result.stdout)
self.assertEqual(
(project / ".claude" / "settings.json").read_text(), before)
def test_refuses_project_without_claude_dir(self):
project = self.tmp / "bench"
make_manager(project)
result = wire(project, project)
self.assertEqual(result.returncode, 1)
self.assertIn("not a .claude-initialised project", result.stdout)
class InstallRootResolution(TempDirTestCase):
def _git_init(self, path: Path) -> None:
subprocess.run(["git", "init", "--quiet", str(path)], check=True,
capture_output=True)
def test_vendored_resolves_host_repo_root(self):
host = self.tmp / "host"
make_manager(host / ".task-manager")
self._git_init(host)
(host / ".claude").mkdir()
result = run([sys.executable, host / ".task-manager" / "install.py"],
cwd=host)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(settings(host)["plansDirectory"],
"./.task-manager/plans")
def test_self_hosted_resolves_own_repo_not_parent(self):
project = self.tmp / "bench"
make_manager(project)
self._git_init(project)
(project / ".claude").mkdir()
result = run([sys.executable, project / "install.py"], cwd=project)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(settings(project)["plansDirectory"], "./plans")
self.assertFalse((self.tmp / ".claude").exists(),
"must not wire the parent of the repo")
def test_no_git_falls_back_to_parent(self):
host = self.tmp / "host"
make_manager(host / ".task-manager")
(host / ".claude").mkdir()
result = run([sys.executable, host / ".task-manager" / "install.py"],
cwd=host)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(settings(host)["plansDirectory"],
"./.task-manager/plans")
def test_refusal_names_the_resolved_root(self):
project = self.tmp / "bench"
make_manager(project)
self._git_init(project)
result = run([sys.executable, project / "install.py"], cwd=project)
self.assertEqual(result.returncode, 1)
self.assertIn(f"{project} is not a .claude-initialised project",
result.stdout)
class VirginBoot(TempDirTestCase):
"""board.py must boot and serve from a checkout with no local/state/
and no wiring at all — start.sh tolerates a failed wire on purpose."""
def test_board_boots_without_state_dirs_or_wiring(self):
tm_root = self.tmp / "bench"
shutil.copytree(REPO / "manager" / "core",
tm_root / "manager" / "core",
ignore=shutil.ignore_patterns("__pycache__"))
(tm_root / "manager" / "local").mkdir()
for stage in ("backlog", "to-do", "in-progress", "review", "done"):
(tm_root / "tasks" / stage).mkdir(parents=True)
probe = socket.socket()
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
probe.close()
env = {k: v for k, v in os.environ.items() if not k.startswith("BOARD_")}
proc = subprocess.Popen(
[sys.executable, str(tm_root / "manager" / "core" / "board.py"),
"--port", str(port), "--no-open"],
env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
try:
state = None
for _ in range(50):
if proc.poll() is not None:
break
try:
with urllib.request.urlopen(
f"http://127.0.0.1:{port}/api/state",
timeout=1) as response:
state = json.load(response)
break
except OSError:
time.sleep(0.2)
if proc.poll() is not None:
out, err = proc.communicate()
self.fail(f"board died (rc={proc.returncode}):\n{out}\n{err}")
self.assertIsNotNone(state, "board never answered /api/state")
local_state = tm_root / "manager" / "local" / "state"
self.assertTrue((local_state / "sessions").is_dir())
self.assertTrue((local_state / "agent").is_dir())
finally:
proc.terminate()
proc.wait(timeout=10)
for stream in (proc.stdout, proc.stderr):
if stream:
stream.close()
if __name__ == "__main__":
unittest.main()