fix: preserve stale task identity history
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 26s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
This commit is contained in:
tegwick 2026-08-31 02:02:35 +02:00
parent ddce470944
commit a32112e5ab
4 changed files with 65 additions and 44 deletions

View file

@ -884,7 +884,7 @@ async def reset_repository_projection(
"""
from datetime import datetime, timezone
from sqlalchemy import func, or_, select
from sqlalchemy import select
from api.models.managed_repo import ManagedRepo
from api.models.task import Task
@ -1200,10 +1200,13 @@ async def reset_repository_projection(
row = matched.get(key)
if row is None:
continue
desired_record_ids = {
task.record_id.strip().lower() for task in workplan.tasks
}
groups: dict[str, list[Any]] = {}
for task_row in tasks_by_wp.get(row.id, []):
record_id = (task_row.record_id or "").strip().lower()
if record_id:
if record_id and record_id in desired_record_ids:
groups.setdefault(record_id, []).append(task_row)
for task_rows in groups.values():
if len(task_rows) > 1:
@ -1226,9 +1229,11 @@ async def reset_repository_projection(
return outcome
# Check every task the reset would create in one query. A derived UUID held
# by another workplan is a global identity collision, even when the two
# human-readable rows happen to have different titles. Letting the INSERT
# discover this would turn a deterministic refusal into an IntegrityError.
# by another workplan is a global identity collision. A repeated record_id
# on a different workplan is not sufficient by itself: terminal rows from
# older, wrong projections retain that label as history and tasks have no
# projection-retirement marker. Letting the INSERT discover a UUID collision
# would turn a deterministic refusal into an IntegrityError.
creating_tasks: dict[str, tuple[DerivedWorkplan, DerivedTask]] = {}
for key, workplan in want.items():
row = matched.get(key)
@ -1247,10 +1252,6 @@ async def reset_repository_projection(
creating_tasks[task.uuid] = (workplan, task)
if creating_tasks:
creating_tasks_by_record_id = {
task.record_id.strip().lower(): (workplan, task)
for workplan, task in creating_tasks.values()
}
current_holder_filters = [Workplan.projection_retired_at.is_(None)]
if stale:
# The caller has already acknowledged these retirements (the
@ -1267,11 +1268,8 @@ async def reset_repository_projection(
.join(Workplan, Workplan.id == Task.workplan_id)
.where(
*current_holder_filters,
or_(
Task.id.in_(
[uuid.UUID(task_id) for task_id in creating_tasks]
),
func.lower(Task.record_id).in_(creating_tasks_by_record_id),
Task.id.in_(
[uuid.UUID(task_id) for task_id in creating_tasks]
),
)
)
@ -1280,15 +1278,10 @@ async def reset_repository_projection(
if task_holders:
outcome.status = "refused"
for holder in task_holders:
candidate = creating_tasks.get(str(holder.id))
if candidate is None:
candidate = creating_tasks_by_record_id[
(holder.record_id or "").strip().lower()
]
workplan, task = candidate
workplan, task = creating_tasks[str(holder.id)]
outcome.refused.append(
{
"reason": "task identity already belongs to another current workplan",
"reason": "derived task identifier already belongs to another current workplan",
"kind": "task",
"record_id": task.record_id,
"uuid": task.uuid,