From 7a21fef0e4f800f4098c89e20ad4c739fb5e0dcd Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 28 Aug 2026 01:25:06 +0200 Subject: [PATCH] fix(projection): repair rows retired before retirement released identifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit made retirement stamp the slug, but `stale` excludes rows already retired — so nothing revisits them and the stamp never reaches the 32 rows retired earlier today: core-hub's two REPO-WP rows and the 30 ad-hoc records. Each still holds an identifier no file derives, blocking whichever repository legitimately owns it. The reset now stamps any retired row whose identifier is still unreleased, using that row's own retirement timestamp rather than today's, so the record keeps saying when it was retired. Completing a decision already taken is not a new retirement, so this runs without acknowledgement and is reported as `released`, separately from `retired`. Repairing it in the reset rather than by hand keeps the hub a read model and makes the fix reproducible for any row that reaches this state again. 735 pass. Co-Authored-By: Claude Opus 5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 2583210@bnt-lap001 Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006 --- api/services/forge_projection.py | 18 ++++++++++++++++-- tests/test_forge_projection.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) 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