diff --git a/.torve/sources/operator/a-rebased-landing-records-the-pre-rebase-commit.yaml b/.torve/sources/operator/a-rebased-landing-records-the-pre-rebase-commit.yaml index b183c6a0..6dbc02f4 100644 --- a/.torve/sources/operator/a-rebased-landing-records-the-pre-rebase-commit.yaml +++ b/.torve/sources/operator/a-rebased-landing-records-the-pre-rebase-commit.yaml @@ -20,3 +20,10 @@ summary: | The landing file is written before the rebase and committed after it. It should name the rebased work commit, which the lane knows once `rebase_in_worktree` returns, or be written after the rebase. + + Fixed at 8b9d3374. The runner writes the landing file at the attempt, + before any rebase, and the rebase replays it unchanged. After a rebase + onto a moved base, the lane now points each replayed landing file at its + work commit's new sha, the parent of the commit that added the file, and + amends the rebased tip; a document branch rebased onto `main` is + restated the same way. diff --git a/src/torve/application/lane.py b/src/torve/application/lane.py index fe1348c5..fadb0b82 100644 --- a/src/torve/application/lane.py +++ b/src/torve/application/lane.py @@ -19,6 +19,7 @@ from __future__ import annotations +import re import subprocess from collections.abc import Callable from dataclasses import dataclass, field @@ -750,6 +751,57 @@ def _keep_ref(root: Path, ref: str, sha: str) -> None: # ....................... # +def _restate_landings(workdir: Path, onto: str) -> None: + """Point every landing file a rebase replayed at its work commit's new sha. + + A landing names the work commit before it (S-0058/D-12, S-0059/D-9), and a + rebase gives that commit a new sha while replaying the file unchanged, so + the record would name a commit no branch holds. A landing commit always + follows its work commit, so the work commit's new sha is the parent of the + commit that added the file; the correction amends the rebased tip.""" + + from torve.domain.spec import LANDING_FILE + + def git(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", "-C", str(workdir), *args], capture_output=True, text=True, check=False + ) + + changed: list[str] = [] + + for name in git("diff", "--name-only", "--diff-filter=A", f"{onto}..HEAD").stdout.split(): + if not LANDING_FILE.match(name.rsplit("/", 1)[-1]): + continue + + path = workdir / name + text = path.read_text(encoding="utf-8") + named = re.search(r"^commit: '?([0-9a-f]{7,40})'?$", text, re.MULTILINE) + + if ( + named is None + or git("merge-base", "--is-ancestor", named.group(1), "HEAD").returncode == 0 + ): + continue + + added = git( + "log", "--format=%H", "--diff-filter=A", f"{onto}..HEAD", "--", name + ).stdout.split() + + if not added: + continue + + work = git("rev-parse", f"{added[0]}^").stdout.strip() + path.write_text(text[: named.start(1)] + work + text[named.end(1) :], encoding="utf-8") + changed.append(name) + + if changed: + git("add", "--", *changed) + git("commit", "--amend", "--no-edit", "--no-verify") + + +# ....................... # + + def _completes(root: Path, task_id: str, document: str) -> bool: """Whether this landing leaves the document complete (S-0093/D-1).""" @@ -1427,6 +1479,7 @@ def _rebase_document( return try: + _restate_landings(workdir, base) exit_code, summary = _regate(workdir, base, None) finally: @@ -1721,6 +1774,7 @@ def _land_rebased( return try: + _restate_landings(workdir, base) exit_code, summary = _regate(workdir, base, task_id) finally: diff --git a/src/torve/application/projections.py b/src/torve/application/projections.py index a9d321fe..b93cadb0 100644 --- a/src/torve/application/projections.py +++ b/src/torve/application/projections.py @@ -1456,14 +1456,34 @@ def status_report(root: Path, *, board: Board | None = None) -> dict[str, Any]: carriers was ever written (S-0044/A-5). """ - if board is not None: - runs = runs_from_board(board) + runs = runs_from_board(board) if board is not None else [] - if runs: - return {"schema_version": 1, "runs": runs} + if not runs: + runs = [s.to_record() for s in RunState.load_all(root.resolve() / naming.WORKTREE_DIR)] - states = RunState.load_all(root.resolve() / naming.WORKTREE_DIR) - return {"schema_version": 1, "runs": [s.to_record() for s in states]} + landed = landed_ids(root, board) + + for run in runs: + if run.get("state") == TaskState.READY.value and run.get("task_id") in landed: + run["state"] = LANDED + + return {"schema_version": 1, "runs": runs} + + +# What a reader is shown for a candidate whose landing is recorded. The state +# machine ends a candidate at `ready` and records the landing beside it, so a +# task that landed weeks ago read as waiting to land; the display says what the +# record already knows, and the state machine is unchanged. +LANDED = "landed" + + +def landed_ids(root: Path, board: Board | None = None) -> set[str]: + """Every task whose landing is recorded: on the board, by the lane, or by + the tree's own trailers and landing files.""" + + landed = {view.task_id for view in board.tasks.values() if view.landed_sha} if board else set() + + return landed | set(lane_landings(root)) | shipped_ids(root) # ....................... # diff --git a/src/torve/cli/manager.py b/src/torve/cli/manager.py index 52f35b62..ff1d6d12 100644 --- a/src/torve/cli/manager.py +++ b/src/torve/cli/manager.py @@ -41,7 +41,7 @@ load_config, runtime_for, ) -from torve.domain.states import EXIT_CONFIG, EXIT_OK +from torve.domain.states import EXIT_CONFIG, EXIT_OK, TaskState if TYPE_CHECKING: from collections.abc import AsyncGenerator, Callable @@ -529,11 +529,9 @@ def _document_waits(root: Path, board: Board) -> dict[str, dict[str, list[str]]] so the reader knows which pull request to look at. A landing the board, the lane or the base already holds is no wait.""" - from torve.application.projections import cross_document_waits, lane_landings, shipped_ids + from torve.application.projections import cross_document_waits, landed_ids - landed = {view.task_id for view in board.tasks.values() if view.landed_sha} - landed |= set(lane_landings(root)) | shipped_ids(root) - waits = cross_document_waits(root, landed) + waits = cross_document_waits(root, landed_ids(root, board)) return { task_id: by_document for task_id, by_document in waits.items() if task_id in board.tasks @@ -563,8 +561,19 @@ def board_cmd( call, which is the same thing a manager does when it restarts. """ + from torve.application.projections import LANDED, landed_ids + result = asyncio.run(_board(dsn_for(root, dsn) or None, partition)) waits = _document_waits(root, result) + landed = landed_ids(root, result) + + def shown(view: TaskView) -> str: + """A candidate whose landing is recorded reads `landed`, not `ready`.""" + + if view.state is TaskState.READY and view.task_id in landed: + return LANDED + + return str(view.state) if fmt is Format.JSON: emit_json( @@ -579,7 +588,7 @@ def board_cmd( # nobody will ever claim it. The role is what tells # the two kinds of queued apart. "role": view.contract.role if view.contract else None, - "state": str(view.state), + "state": shown(view), "attempts": view.attempts, "claimed_by": view.claimed_by, "landed_sha": view.landed_sha, @@ -603,7 +612,7 @@ def board_cmd( ( view.task_id, view.contract.role if view.contract else "—", - view.escalation or str(view.state), + view.escalation or shown(view), str(view.attempts), view.claimed_by or "—", _burn(view), diff --git a/tests/test_cli.py b/tests/test_cli.py index 29550cf0..a6e28ec3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -170,6 +170,28 @@ def test_status_json_carries_persisted_records(tmp_path): assert json.loads(result.stdout) == {"schema_version": 1, "runs": []} +def test_status_shows_a_landed_candidate_as_landed(tmp_path): + from torve.application.runstate import RunState + from torve.application.telemetry import engine_event + from torve.base import naming + from torve.domain.states import TaskState + + for task_id in ("T-7501", "T-7502"): + state = RunState(task_id=task_id, path=naming.state_file(tmp_path, task_id)) + state.state = TaskState.READY + state.save() + + # T-7501's landing is recorded; T-7502 is a candidate still waiting to land. + engine_event( + tmp_path, "lane_landed", {"task": "T-7501", "mode": "fast-forward", "sha": "a" * 40} + ) + + result = CliRunner().invoke(app, ["status", "--root", str(tmp_path), "--format", "json"]) + assert result.exit_code == 0, result.output + states = {run["task_id"]: run["state"] for run in json.loads(result.stdout)["runs"]} + assert states == {"T-7501": "landed", "T-7502": "ready"} + + def test_doctor_json_and_exit(): result = CliRunner().invoke(app, ["doctor", "--format", "json"]) document = json.loads(result.stdout) diff --git a/tests/test_lane.py b/tests/test_lane.py index 157e38c6..183b61f1 100644 --- a/tests/test_lane.py +++ b/tests/test_lane.py @@ -88,6 +88,50 @@ def test_two_candidates_land_serially_first_ff_then_rebased(lane_repo): assert all(r["approver"] == "Lane Operator" for r in landed) +def _landing_commit(root: Path, task_id: str) -> str: + """The runner's second commit on a candidate: a landing file naming the + work commit before it (S-0059/D-9). Returns the landing file's path.""" + branch = naming.branch(task_id) + work = git(root, "rev-parse", branch) + git(root, "checkout", "-q", branch) + path = f".torve/execution/{task_id}-1-20261006T000000Z.yaml" + (root / path).parent.mkdir(parents=True, exist_ok=True) + (root / path).write_text(f"task: {task_id}\ncommit: {work}\n", encoding="utf-8") + git(root, "add", "-A") + git(root, "commit", "-q", "--no-gpg-sign", "-m", f"torve({task_id}): landing of attempt 1") + git(root, "checkout", "-q", "main") + return path + + +def test_a_rebased_landing_names_the_commit_that_landed(lane_repo): + candidate(lane_repo, "T-7011", "one.py", "one = 1\n") + candidate(lane_repo, "T-7012", "two.py", "two = 2\n") + path = _landing_commit(lane_repo, "T-7012") + before = git(lane_repo, "rev-parse", f"{naming.branch('T-7012')}~1") + + result = invoke_merge(lane_repo) + assert result.exit_code == 0, result.output + + # T-7012 was rebased onto T-7011's landing, so its work commit has a new + # sha. The landing file on main must name that one, not the pre-rebase + # commit no branch holds any more (S-0058/D-12). + named = git(lane_repo, "show", f"main:{path}").split("commit: ", 1)[1].strip() + added = git(lane_repo, "log", "--format=%H", "--diff-filter=A", "main", "--", path) + assert named != before + assert named == git(lane_repo, "rev-parse", f"{added}^") + git(lane_repo, "merge-base", "--is-ancestor", named, "main") + + +def test_a_fast_forward_landing_keeps_the_commit_it_named(lane_repo): + candidate(lane_repo, "T-7013", "one.py", "one = 1\n") + path = _landing_commit(lane_repo, "T-7013") + work = git(lane_repo, "rev-parse", f"{naming.branch('T-7013')}~1") + + assert invoke_merge(lane_repo).exit_code == 0 + + assert git(lane_repo, "show", f"main:{path}").split("commit: ", 1)[1].strip() == work + + def test_a_red_rebase_puts_the_branch_back_so_the_next_pass_regates(lane_repo): """T-0391 landed on a battery that had gone red, in two passes. diff --git a/tests/test_manager.py b/tests/test_manager.py index af1224a9..3ebe7a66 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -1297,6 +1297,10 @@ async def fake_board(dsn, partition): assert shown.exit_code == 0, shown.output assert json.loads(shown.output)["waiting_on_documents"] == {"T-0003": {"S-0090": ["T-0002"]}} + # A candidate whose landing is recorded reads landed, not ready. + states = {row["task"]: row["state"] for row in json.loads(shown.output)["tasks"]} + assert states["T-0001"] == "landed" + assert states["T-0002"] != "landed" text = runner.invoke(app, ["manager", "board", "repo", "--root", str(tmp_path)])