diff --git a/manager/core/.env.example b/manager/core/.env.example index 10a5475..fda49ce 100644 --- a/manager/core/.env.example +++ b/manager/core/.env.example @@ -33,6 +33,12 @@ BOARD_GH_BIN=gh BOARD_GIT_REMOTE= BOARD_PR_POLL_INTERVAL=60 +# Seconds a work-agent launch waits for `git fetch origin main` before +# giving up and branching from local HEAD. Fresh launches branch from +# origin/main when the fetch succeeds; no remote or a dead network just +# means today's behaviour, never a blocked launch. +BOARD_FETCH_TIMEOUT=10 + # Seconds between disk polls of the stage directories. BOARD_WATCH_INTERVAL=2 diff --git a/manager/core/agents.py b/manager/core/agents.py index bcbab95..fc55bd8 100644 --- a/manager/core/agents.py +++ b/manager/core/agents.py @@ -141,6 +141,40 @@ def _launch(mode: str, prompt: str, cwd: Path, agent_id: str, filename: str, log return proc, log_file +def _fresh_branch_point() -> tuple[str | None, str | None]: + """Where a brand-new task branch should start: the newest main that + exists. With an `origin` remote, fetch its main (bounded by + FETCH_TIMEOUT) and branch from origin/main — never touching the main + checkout itself, the fetched ref is only the branch point. No remote, + a failed fetch or a timeout all mean today's behaviour: branch from + HEAD, because launching must never be blocked by network weather. + + Returns (start point, ticker note); (None, …) means HEAD. The note is + non-None whenever the branch point deserves a mention — origin/main + ahead of this checkout, or a fetch that had to be skipped. + """ + def _git(*args: str) -> subprocess.CompletedProcess: + return subprocess.run(["git", "-C", str(config.REPO), *args], + capture_output=True, text=True) + + if "origin" not in _git("remote").stdout.split(): + return None, None + try: + fetched = subprocess.run( + ["git", "-C", str(config.REPO), "fetch", "origin", "main"], + capture_output=True, text=True, timeout=config.FETCH_TIMEOUT) + except subprocess.TimeoutExpired: + return None, "fetch of origin/main timed out; branched from local HEAD" + if fetched.returncode != 0 or \ + _git("rev-parse", "--verify", "--quiet", "origin/main").returncode != 0: + return None, "fetch of origin/main failed; branched from local HEAD" + ahead = _git("rev-list", "--count", "main..origin/main").stdout.strip() + if ahead.isdigit() and int(ahead) > 0: + return "origin/main", (f"branched from origin/main, " + f"{ahead} ahead of this checkout") + return "origin/main", None + + def start_agent(filename: str, stage: str) -> dict: # Moving a card to in-progress is the commitment; only then does work start. _validate(filename, stage, {"in-progress"}, @@ -156,6 +190,7 @@ def start_agent(filename: str, stage: str) -> dict: branch_exists = _git("rev-parse", "--verify", "--quiet", branch).returncode == 0 continuing = worktree.exists() + base_note = None if continuing: # earlier work exists — the agent continues on it rather than refusing current = subprocess.run( @@ -172,8 +207,14 @@ def start_agent(filename: str, stage: str) -> dict: base = _git("merge-base", "main", branch).stdout.strip() result = _git("worktree", "add", str(worktree), branch) else: - base = _git("rev-parse", "HEAD").stdout.strip() - result = _git("worktree", "add", "-b", branch, str(worktree)) + point, base_note = _fresh_branch_point() + if point: + base = _git("rev-parse", point).stdout.strip() + result = _git("worktree", "add", "--no-track", "-b", branch, + str(worktree), point) + else: + base = _git("rev-parse", "HEAD").stdout.strip() + result = _git("worktree", "add", "-b", branch, str(worktree)) if result.returncode != 0: raise ValueError(f"git worktree add failed: {result.stderr.strip()[:300]}") @@ -197,11 +238,14 @@ def start_agent(filename: str, stage: str) -> dict: } with state.LOCK: state.AGENTS[agent_id] = record + summary = (f"{name} is back on {filename} — continuing branch {branch}" + if continuing else + f"{name} started on {filename} (branch {branch})") + if base_note: + summary += f" — {base_note}" state.record_board_event({ "kind": "agent", "actor": "agent", "file": filename, - "summary": (f"{name} is back on {filename} — continuing branch {branch}" - if continuing else - f"{name} started on {filename} (branch {branch})"), + "summary": summary, }) threading.Thread(target=_reap_agent, args=(agent_id, proc, log_file), daemon=True).start() diff --git a/manager/core/config.py b/manager/core/config.py index 106fd58..39c7529 100644 --- a/manager/core/config.py +++ b/manager/core/config.py @@ -101,6 +101,11 @@ GH_BIN = setting("BOARD_GH_BIN", "gh") GIT_REMOTE = setting("BOARD_GIT_REMOTE", "") PR_POLL_INTERVAL = float(setting("BOARD_PR_POLL_INTERVAL", "60")) +# How long a work-agent launch waits for `git fetch origin main` before +# branching from local HEAD instead. Launching must never be blocked by +# network weather; this bounds the whole delay. +FETCH_TIMEOUT = float(setting("BOARD_FETCH_TIMEOUT", "10")) + WATCH_INTERVAL = float(setting("BOARD_WATCH_INTERVAL", "2")) EVENTS_CAP = int(setting("BOARD_EVENTS_CAP", "800")) BOARD_EVENTS_CAP = int(setting("BOARD_HISTORY_CAP", "300")) diff --git a/tests/test_fresh_branch_point.py b/tests/test_fresh_branch_point.py new file mode 100644 index 0000000..2ba3282 --- /dev/null +++ b/tests/test_fresh_branch_point.py @@ -0,0 +1,124 @@ +"""Where a fresh work launch branches from: origin/main when a fetch can +reach it, local HEAD when there is no origin or the network fails — and +never a blocked launch either way. Run with: +python3 -m unittest discover -s tests +""" + +import subprocess +import sys +import tempfile +import time +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO / "manager" / "core")) + +import agents # noqa: E402 +import config # noqa: E402 + + +def _git(cwd: Path, *args: str) -> str: + return subprocess.check_output( + ["git", "-C", str(cwd), *args], text=True, + stderr=subprocess.DEVNULL).strip() + + +def _commit(cwd: Path, msg: str) -> None: + subprocess.check_call( + ["git", "-C", str(cwd), "-c", "user.email=t@t", "-c", "user.name=t", + "commit", "-q", "--allow-empty", "-m", msg]) + + +class FreshBranchPoint(unittest.TestCase): + """_fresh_branch_point decides the base of a brand-new task branch.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + root = Path(self._tmp.name) + self.upstream = root / "upstream" + self.upstream.mkdir() + _git(self.upstream, "init", "-q", "-b", "main") + _commit(self.upstream, "root") + self.local = root / "local" + subprocess.check_call( + ["git", "clone", "-q", str(self.upstream), str(self.local)], + stderr=subprocess.DEVNULL) + self._saved = config.REPO, config.FETCH_TIMEOUT + config.REPO = self.local + config.FETCH_TIMEOUT = 5.0 + + def tearDown(self): + config.REPO, config.FETCH_TIMEOUT = self._saved + self._tmp.cleanup() + + def _hang_remote(self, name: str) -> None: + """Point a remote at a transport that never answers.""" + _git(self.local, "config", "protocol.ext.allow", "always") + # ext:: runs the command as the remote helper; sleep never answers + # git's handshake, so the fetch blocks until the timeout fires. + _git(self.local, "remote", "set-url", name, "ext::sleep 30") + + def test_no_remote_branches_from_head_silently(self): + _git(self.local, "remote", "remove", "origin") + self.assertEqual(agents._fresh_branch_point(), (None, None)) + + def test_only_origin_counts_and_is_never_fetched_when_absent(self): + # A hanging remote under another name: if anything fetched it, this + # test would stall; instead the launch path answers HEAD instantly. + _git(self.local, "remote", "rename", "origin", "upstream") + self._hang_remote("upstream") + started = time.monotonic() + self.assertEqual(agents._fresh_branch_point(), (None, None)) + self.assertLess(time.monotonic() - started, 2) + + def test_origin_ahead_branches_from_its_tip_and_says_so(self): + _commit(self.upstream, "landed elsewhere 1") + _commit(self.upstream, "landed elsewhere 2") + point, note = agents._fresh_branch_point() + self.assertEqual(point, "origin/main") + self.assertEqual(note, "branched from origin/main, " + "2 ahead of this checkout") + # The fetch refreshed the ref the branch will start from. + self.assertEqual(_git(self.local, "rev-parse", "origin/main"), + _git(self.upstream, "rev-parse", "main")) + + def test_worktree_from_origin_main_has_its_tip_as_merge_base(self): + # The same commands start_agent runs for a fresh branch. + _commit(self.upstream, "landed elsewhere") + point, _ = agents._fresh_branch_point() + worktree = Path(self._tmp.name) / "wt" + _git(self.local, "worktree", "add", "--no-track", "-b", "task/x", + str(worktree), point) + self.assertEqual(_git(self.local, "merge-base", "task/x", "origin/main"), + _git(self.upstream, "rev-parse", "main")) + # --no-track: the task branch must not adopt origin/main as upstream. + upstream_cfg = subprocess.run( + ["git", "-C", str(self.local), "config", "branch.task/x.merge"], + capture_output=True) + self.assertNotEqual(upstream_cfg.returncode, 0) + + def test_origin_in_sync_is_used_without_narration(self): + self.assertEqual(agents._fresh_branch_point(), ("origin/main", None)) + + def test_unreachable_origin_falls_back_to_head_and_says_so(self): + _git(self.local, "remote", "set-url", "origin", + str(Path(self._tmp.name) / "gone")) + point, note = agents._fresh_branch_point() + self.assertIsNone(point) + self.assertEqual(note, "fetch of origin/main failed; " + "branched from local HEAD") + + def test_hanging_fetch_times_out_within_bound_and_says_so(self): + self._hang_remote("origin") + config.FETCH_TIMEOUT = 0.5 + started = time.monotonic() + point, note = agents._fresh_branch_point() + self.assertLess(time.monotonic() - started, 5) + self.assertIsNone(point) + self.assertEqual(note, "fetch of origin/main timed out; " + "branched from local HEAD") + + +if __name__ == "__main__": + unittest.main()