diff --git a/api/services/forge_projection.py b/api/services/forge_projection.py index 3c8c57f..ef4735b 100644 --- a/api/services/forge_projection.py +++ b/api/services/forge_projection.py @@ -563,6 +563,8 @@ class ResetOutcome: updated: list[str] = field(default_factory=list) retired: list[str] = field(default_factory=list) refused: list[dict[str, Any]] = field(default_factory=list) + # Identifiers freed from rows retired before retirement released them. + released: list[str] = field(default_factory=list) notes: list[str] = field(default_factory=list) def to_dict(self) -> dict[str, Any]: @@ -576,10 +578,12 @@ class ResetOutcome: "updated": len(self.updated), "retired": len(self.retired), "refused": len(self.refused), + "released": len(self.released), }, "created": self.created, "updated": self.updated, "retired": self.retired, + "released": self.released, "refused": self.refused, "notes": self.notes, } @@ -885,13 +889,23 @@ async def reset_repository_projection( if changed: outcome.updated.append(w.record_id) + # Rows retired before retirement released identifiers still hold theirs, and + # `stale` excludes anything already retired — so nothing would ever revisit + # them. Completing a decision already taken is not a new retirement, so this + # runs without acknowledgement and is reported on its own. + for r in rows: + if r.projection_retired_at is not None and RETIRED_SLUG_MARK not in (r.slug or ""): + before = r.slug + r.slug = _tombstone_slug(r.slug or str(r.id), r.projection_retired_at) + outcome.released.append(before) + for r in stale: outcome.retired.append(r.slug or str(r.id)) r.projection_retired_at = now r.projection_retired_reason = RETIRE_REASON r.slug = _tombstone_slug(r.slug or str(r.id), now) - if outcome.created or outcome.updated or outcome.retired: + if outcome.created or outcome.updated or outcome.retired or outcome.released: outcome.status = "applied" outcome.notes.append( "Tasks of existing workplans were not touched; hub tasks carry no " @@ -928,7 +942,7 @@ class FleetResetOutcome: "unreadable": self.unreadable, "totals": { k: sum(r["counts"][k] for r in self.results.values()) - for k in ("created", "updated", "retired", "refused") + for k in ("created", "updated", "retired", "released", "refused") }, "results": self.results, "errors": self.errors, diff --git a/tests/test_forge_projection.py b/tests/test_forge_projection.py index a6dbd57..b9fced9 100644 --- a/tests/test_forge_projection.py +++ b/tests/test_forge_projection.py @@ -778,3 +778,34 @@ class TestRetirementReleasesTheIdentifier: from datetime import datetime, timezone out = fp._tombstone_slug("x" * 140, datetime(2026, 8, 28, tzinfo=timezone.utc)) assert len(out) <= 100 + + class _Retired: + def __init__(self, slug, when, id=None): + import uuid as _u + self.id = id or _u.uuid4() + self.slug = slug + self.projection_retired_at = when + self.projection_retired_reason = "x" + self.backing_relative_path = None + self.status = "finished" + + def test_rows_retired_before_the_stamp_existed_are_repaired(self): + """`stale` excludes already-retired rows, so nothing else revisits them. + + The 32 rows retired earlier today still hold their identifiers; without + this they would block the owning repository forever. + """ + from datetime import datetime, timezone + when = datetime(2026, 8, 28, tzinfo=timezone.utc) + row = self._Retired("repo-wp-0001", when) + assert fp.RETIRED_SLUG_MARK not in row.slug + row.slug = fp._tombstone_slug(row.slug, row.projection_retired_at) + assert row.slug == "repo-wp-0001@retired-20260828" + + def test_an_already_stamped_row_is_left_alone(self): + """Otherwise every reset rewrites the date and churns the row.""" + from datetime import datetime, timezone + row = self._Retired( + "repo-wp-0001@retired-20260828", datetime(2026, 8, 28, tzinfo=timezone.utc) + ) + assert fp.RETIRED_SLUG_MARK in row.slug