fix(projection): repair rows retired before retirement released identifiers
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s

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 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
This commit is contained in:
tegwick 2026-08-28 01:25:06 +02:00
parent 95e30188e6
commit 7a21fef0e4
2 changed files with 47 additions and 2 deletions

View file

@ -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,

View file

@ -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