diff --git a/manager/core/agents.py b/manager/core/agents.py index 09d7f21..3c6e1ad 100644 --- a/manager/core/agents.py +++ b/manager/core/agents.py @@ -22,8 +22,17 @@ import config import events import reports import state -from taskfiles import (actor_name, append_to_task, find_stage_of, move_task, - read_task, set_assignee) +from taskfiles import (actor_name, append_to_task, collect, find_stage_of, + move_task, read_task, set_assignee) + +# A phase runs on an integration branch of its own — phases.py owns the +# behaviour, the name lives here because this is where a launch decides +# what to branch from. +PHASE_BRANCH_PREFIX = "phase/" + + +def phase_branch(phase_file: str) -> str: + return PHASE_BRANCH_PREFIX + phase_file[:-3] def _report_of(record: dict, text: str | None = None) -> str: @@ -201,7 +210,38 @@ def _fresh_branch_point() -> tuple[str | None, str | None]: return "origin/main", None -def _claim_for_launch(filename: str, stage: str, takeover: bool) -> None: +def phase_branch_point(filename: str) -> tuple[str | None, str | None]: + """Where a *member of a running phase* branches from: the phase's own + branch, not main. + + That is the whole reason a phase has a branch. Related cards run one + after another, so card two branched from main could not see card one's + work while card one sat unmerged in review/ — it would conflict, or + quietly build the same thing twice. Branching from the phase tip is + what makes the list add up. + + (None, None) for a card in no phase, or one whose phase has not been + started — then the ordinary fresh branch point applies. + """ + phase = None + try: + for stage in collect()["stages"]: + for task in stage["tasks"]: + if task["file"] == filename: + phase = task.get("phase") + except OSError: + return None, None + if not phase: + return None, None + branch = phase_branch(phase["file"]) + exists = subprocess.run(["git", "-C", str(config.REPO), "rev-parse", + "--verify", "--quiet", branch], capture_output=True) + if exists.returncode != 0: + return None, None + return branch, f"branched from {branch}, the phase's own branch" + + +def claim_for_launch(filename: str, stage: str, takeover: bool = False) -> None: """One agent per task is a board-memory rule; across machines the card file is the only thing every board can see, so the claim is what gates a launch here. @@ -238,7 +278,7 @@ def start_agent(filename: str, stage: str, takeover: bool = False) -> dict: # Moving a card to in-progress is the commitment; only then does work start. _validate(filename, stage, {"in-progress"}, "work starts from in-progress/ — move the card there first") - _claim_for_launch(filename, stage, takeover) + claim_for_launch(filename, stage, takeover) stem = filename[:-3] branch = f"task/{stem}" @@ -267,7 +307,11 @@ def start_agent(filename: str, stage: str, takeover: bool = False) -> dict: base = _git("merge-base", "main", branch).stdout.strip() result = _git("worktree", "add", str(worktree), branch) else: - point, base_note = _fresh_branch_point() + # A phase member starts from its phase's tip; everything else + # from the newest main this checkout can see. + point, base_note = phase_branch_point(filename) + if point is None: + point, base_note = _fresh_branch_point() if point: base = _git("rev-parse", point).stdout.strip() result = _git("worktree", "add", "--no-track", "-b", branch, diff --git a/manager/core/taskfiles.py b/manager/core/taskfiles.py index 0ffbf64..cd29c71 100644 --- a/manager/core/taskfiles.py +++ b/manager/core/taskfiles.py @@ -450,6 +450,40 @@ def append_to_task(filename: str, stage: str, text: str, what: str) -> bool: return True +def append_to_section(filename: str, stage: str, heading: str, line: str, + what: str) -> bool: + """Add one line under `## `, creating the section at the end of + the card when it is not there yet. + + The third door, and the narrowest: a running record where every entry is + one line and belongs under one heading — the phase log. `append_to_task` + would scatter those lines through the file as other sections (a work + report, a review) landed between them, and the record would stop being + readable in the one place a person looks. It commits like every other + board-made write. + """ + path = config.TASKS / stage / filename + try: + text = path.read_text(encoding="utf-8") + except OSError: + return False + section = re.compile(rf"^##\s+{re.escape(heading)}\s*$(.*?)(?=^##\s|\Z)", + re.MULTILINE | re.DOTALL).search(text) + if section: + body = section.group(1).strip("\n") + updated = f"## {heading}\n\n{body}\n{line}\n\n" if body else \ + f"## {heading}\n\n{line}\n\n" + text = text[:section.start()] + updated + text[section.end():] + else: + text = text.rstrip("\n") + f"\n\n## {heading}\n\n{line}\n" + try: + path.write_text(text, encoding="utf-8") + except OSError: + return False + commit_edit(filename, stage, what) + return True + + def move_task(filename: str, source: str, target: str, actor: str = "you") -> dict: """Move a task file between stage directories and fix its Status line. diff --git a/tests/test_actor_acts.py b/tests/test_actor_acts.py index b7745aa..1512b93 100644 --- a/tests/test_actor_acts.py +++ b/tests/test_actor_acts.py @@ -430,7 +430,7 @@ class ClaimsGateLaunches(Boards): BRANCH).returncode, 1) def test_the_deliberate_takeover_reassigns_the_card(self): - agents._claim_for_launch(FILENAME, "in-progress", True) + agents.claim_for_launch(FILENAME, "in-progress", True) self.assertIn("**Assignee:** elena", self.text(self.elena)) self.assertEqual(self.text(self.elena).count("**Assignee:**"), 1) @@ -440,7 +440,7 @@ class ClaimsGateLaunches(Boards): for s in self.summaries())) def test_the_takeover_reaches_the_other_board(self): - agents._claim_for_launch(FILENAME, "in-progress", True) + agents.claim_for_launch(FILENAME, "in-progress", True) self.assertEqual(sync.push_now(), "ok") self.use(self.ada) @@ -450,7 +450,7 @@ class ClaimsGateLaunches(Boards): def test_an_unclaimed_card_claims_on_launch(self): self.place(self.elena, "in-progress", card("In Progress"), commit=False) - agents._claim_for_launch(FILENAME, "in-progress", False) + agents.claim_for_launch(FILENAME, "in-progress", False) self.assertIn("**Assignee:** elena", self.text(self.elena)) self.assertTrue(any("claimed" in s for s in self.summaries())) @@ -460,7 +460,7 @@ class ClaimsGateLaunches(Boards): commit=False) head = git(self.elena, "rev-parse", "HEAD").stdout - agents._claim_for_launch(FILENAME, "in-progress", False) + agents.claim_for_launch(FILENAME, "in-progress", False) self.assertEqual(git(self.elena, "rev-parse", "HEAD").stdout, head, "nothing to record: it was already yours") @@ -470,7 +470,7 @@ class ClaimsGateLaunches(Boards): a lock — a hand-written line stays decoration.""" self.patch(SYNC=False, COMMIT_MOVES=False) - agents._claim_for_launch(FILENAME, "in-progress", False) + agents.claim_for_launch(FILENAME, "in-progress", False) self.assertIn("**Assignee:** ada", self.text(self.elena))