Card 14: fresh work branches start from fetched origin/main
A fresh ▸ start work used to branch from whatever HEAD the board's checkout happened to hold, so anything merged on GitHub or pushed by a teammate was missing from the agent's world. Now, when an origin remote exists, the launch fetches origin/main (bounded by BOARD_FETCH_TIMEOUT, default 10s) and branches from it — without ever touching the main checkout itself. No remote, a failed fetch or a timeout all fall back to branching from HEAD exactly as before: launching is never blocked by network weather. The ticker narrates honestly either way: when origin/main is ahead of the local checkout the launch line says by how much, and when the fetch had to be skipped it says that too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+49
-5
@@ -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()
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user