repository-rename verify: accept descendant heads and retired dispatch after source-synced
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 12s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 41s

Finishing work during the operation retires it from active dispatch, and the
source repository legitimately advances after source-synced. verify now treats
retired dispatch ids as present and accepts a Forge head that contains the
recorded source-synced head (Forgejo compare); unrelated drift still fails.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 101414@bnt-lap001
Assistant-Session: 60b5a221-d435-42b9-b778-983c90c7eed6
This commit is contained in:
tegwick 2026-09-30 19:37:35 +02:00
parent 6924d2410f
commit b451bd10e5
4 changed files with 114 additions and 14 deletions

View file

@ -83,6 +83,10 @@ class ForgeRepositoryGateway(Protocol):
self, *, instance: str, owner: str, old_name: str, new_name: str
) -> ForgeRepositorySnapshot: ...
async def descends_from(
self, *, instance: str, owner: str, name: str, base: str, head: str
) -> bool: ...
def _write_token() -> str | None:
path = os.environ.get(WRITE_TOKEN_FILE_ENV)
@ -207,6 +211,34 @@ class ForgejoRepositoryGateway:
raise ForgeRepositoryUnreadable("Renamed Forge repository is not readable")
return renamed
async def descends_from(
self, *, instance: str, owner: str, name: str, base: str, head: str
) -> bool:
"""True when ``base`` is an ancestor of (or equal to) ``head``.
``compare/<head>...<base>`` lists commits reachable from ``base`` but not
from ``head``; it is empty exactly when ``base`` is contained in ``head``.
"""
if base == head:
return True
url = f"{self._api(instance, owner, name)}/compare/{quote(head, safe='')}...{quote(base, safe='')}"
try:
async with httpx.AsyncClient(
timeout=settings.repository_rename_forge_timeout_seconds
) as client:
response = await client.get(url, headers=self._headers(forge_read_token()))
if response.status_code == 404:
return False
response.raise_for_status()
data = response.json()
except (httpx.HTTPError, ValueError) as exc:
raise ForgeRepositoryUnreadable(
f"Forge ancestry check failed ({type(exc).__name__})"
) from exc
commits = data.get("commits")
total = data.get("total_commits")
return (isinstance(commits, list) and not commits) or total == 0
_gateway = ForgejoRepositoryGateway()

View file

@ -47,6 +47,7 @@ from api.services.forge_repository import (
ForgeRepositoryGateway,
ForgeRepositoryRedirected,
ForgeRepositorySnapshot,
ForgeRepositoryError,
ForgeRepositoryUnreadable,
)
@ -1248,7 +1249,22 @@ async def verify_operation(
if forge is not None:
add("forge_coordinate", forge.name == expected_name, expected_name, forge.name)
add("forge_repository_id", forge.repository_id == operation.expected_forge_repository_id, operation.expected_forge_repository_id, forge.repository_id)
add("source_commit", forge.head_commit == _expected_head(operation), _expected_head(operation), forge.head_commit)
anchor = _expected_head(operation)
head_ok = forge.head_commit == anchor
if not head_ok and "source-synced" in (_telemetry(operation)[0].get("phases") or {}):
# After source-synced the source repository keeps moving (its own
# commits and sync writebacks); the recorded head must stay contained.
try:
head_ok = await gateway.descends_from(
instance=operation.expected_forge_instance,
owner=operation.expected_forge_owner,
name=expected_name,
base=anchor,
head=forge.head_commit,
)
except (ForgeRepositoryError, AttributeError):
head_ok = False
add("source_commit", head_ok, anchor, forge.head_commit)
add("default_branch", forge.default_branch == operation.expected_default_branch, operation.expected_default_branch, forge.default_branch)
add("projection_readable", forge.projection_readable and forge.projection_source_present, True, forge.projection_readable and forge.projection_source_present)
expected_canonical = operation.old_slug if operation.phase in {"preflighted", "forge-renamed", "rolled-back"} else operation.new_slug
@ -1302,6 +1318,25 @@ async def verify_operation(
"baseline": int(baseline_record.get("count") or 0),
"current": int(current_record.get("count") or 0),
}
# Active dispatch is a status-derived view: finishing a task during the
# operation retires it from dispatch without losing the record. Only a
# dispatch id whose task/workplan no longer exists counts as missing.
retired_dispatch: list[str] = []
live_ids = set((current.get("tasks") or {}).get("ids") or []) | set(
(current.get("workplans") or {}).get("ids") or []
)
for bucket in (missing, changed_relationships):
ids = bucket.get("active_dispatch")
if not ids:
continue
gone = [item for item in ids if item not in live_ids]
retired_dispatch = sorted(set(retired_dispatch) | (set(ids) - set(gone)))
if gone:
bucket["active_dispatch"] = gone
else:
del bucket["active_dispatch"]
if retired_dispatch:
counts.pop("active_dispatch", None)
# New telemetry may legitimately arrive while a phased operation is being
# executed. Continuity means every baseline identity still exists; it does
# not freeze the repository's append-only history at the preflight count.
@ -1309,7 +1344,7 @@ async def verify_operation(
"relationship_continuity",
not missing and not changed_relationships,
{"missing": {}, "changed": {}},
{"missing": missing, "changed": changed_relationships},
{"missing": missing, "changed": changed_relationships, "retired_dispatch": retired_dispatch},
)
add(
"record_counts_non_decreasing",