fix: preserve stale task identity history
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
This commit is contained in:
parent
ddce470944
commit
a32112e5ab
4 changed files with 65 additions and 44 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -11,12 +11,13 @@
|
|||
"same_workplan_groups": 0,
|
||||
"cross_repository_groups": 31
|
||||
},
|
||||
"projection_classification": {
|
||||
"one_current_claimant_with_retired_history": 104,
|
||||
"retired_history_only": 15,
|
||||
"multiple_non_retired_claimants": 12
|
||||
"slug_tombstone_shape": {
|
||||
"exactly_one_untombstoned_workplan_slug": 104,
|
||||
"tombstoned_workplan_slugs_only": 15,
|
||||
"multiple_untombstoned_workplan_slugs": 12,
|
||||
"note": "Slug tombstones are an operational clue, not task-level projection membership. Tasks have no projection-retirement field."
|
||||
},
|
||||
"multiple_non_retired_claimants": [
|
||||
"multiple_untombstoned_workplan_slugs": [
|
||||
{
|
||||
"record_id_prefix": "CUST-WP-0010b-T",
|
||||
"task_identity_groups": 2,
|
||||
|
|
@ -25,7 +26,7 @@
|
|||
"cust-wp-0010"
|
||||
],
|
||||
"workplan_status": "finished",
|
||||
"cause": "duplicate workplan projection over one backing file"
|
||||
"cause": "stale completed task rows left by an earlier wrong workplan/backing-file projection"
|
||||
},
|
||||
{
|
||||
"record_id_prefix": "SECRETS-WP-0002-T",
|
||||
|
|
@ -35,14 +36,20 @@
|
|||
"secrets-wp-0002"
|
||||
],
|
||||
"workplan_status": "finished",
|
||||
"cause": "duplicate workplan projection over one backing file"
|
||||
"cause": "stale completed task rows left by an earlier wrong workplan/backing-file projection"
|
||||
}
|
||||
],
|
||||
"decision": {
|
||||
"retired_rows": "preserve as historical evidence; exclude from current projection ambiguity",
|
||||
"duplicate_inside_matched_workplan": "refuse reset and report every claimant UUID",
|
||||
"historical_rows": "preserve; a repeated label outside the owning workplan's desired task set is not current projection ambiguity",
|
||||
"duplicate_inside_matched_workplan_desired_set": "refuse reset and report every claimant UUID",
|
||||
"duplicate_inside_forge_projection": "refuse reset; require file-level disposition",
|
||||
"new_task_identity_held_by_other_current_workplan": "refuse before insert",
|
||||
"duplicate_across_retired_or_displaced_workplans": "does not block current projection reset"
|
||||
"new_derived_task_uuid_held_by_other_current_workplan": "refuse before insert",
|
||||
"duplicate_record_id_across_other_workplans": "preserve as historical evidence; do not block unless the forge repeats the identity or the derived UUID collides"
|
||||
},
|
||||
"live_reconcile": {
|
||||
"state-hub": "noop at ddce470944e25b2f79927e7b8e2dfe3104cc4528",
|
||||
"the-custodian": "applied 74 workplan and 169 task field updates; zero creates, retirements, cancellations, or refusals",
|
||||
"secrets-engine": "applied 10 workplan and 26 task field updates; zero creates, retirements, cancellations, or refusals",
|
||||
"second_pass": "the-custodian and secrets-engine both returned noop with every count zero"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1156,6 +1156,23 @@ class TestExistingWorkplanTasks:
|
|||
assert out.refused[0]["reason"].startswith("current workplan contains duplicate")
|
||||
assert session.added == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_stale_history_inside_current_workplan_does_not_block(self):
|
||||
row = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md")
|
||||
desired = _TaskRow("DEMO-WP-0001-T01", status="todo", workplan_id=row.id)
|
||||
stale_a = _TaskRow("DEMO-WP-0001-T09", status="done", workplan_id=row.id)
|
||||
stale_b = _TaskRow("demo-wp-0001-t09", status="done", workplan_id=row.id)
|
||||
session = _FakeSession(
|
||||
repo=_Repo(), rows=[row], task_rows=[desired, stale_a, stale_b]
|
||||
)
|
||||
out = await fp.reset_repository_projection(
|
||||
session,
|
||||
"demo",
|
||||
derived=self._derived(("DEMO-WP-0001-T01", "Do it", "todo")),
|
||||
)
|
||||
assert out.status in {"applied", "noop"}
|
||||
assert out.refused == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_derived_task_uuid_held_elsewhere_is_refused_before_insert(self):
|
||||
row = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md")
|
||||
|
|
@ -1170,7 +1187,7 @@ class TestExistingWorkplanTasks:
|
|||
)
|
||||
out = await fp.reset_repository_projection(session, "demo", derived=derived)
|
||||
assert out.status == "refused"
|
||||
assert out.refused[0]["reason"].startswith("task identity already belongs")
|
||||
assert out.refused[0]["reason"].startswith("derived task identifier already belongs")
|
||||
assert session.added == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -378,24 +378,28 @@ canonical task identities, so T02 remains `progress`.
|
|||
|
||||
**Duplicate identity disposition (2026-08-31).** A fresh primary audit found
|
||||
131 duplicate task `record_id` groups across 274 rows. The global count is not
|
||||
the reset safety boundary: 104 groups have exactly one current projection
|
||||
claimant plus retired history, and 15 exist only under retired workplans. Those
|
||||
rows are retained as history and do not compete with the matched current
|
||||
workplan.
|
||||
the reset safety boundary: all 131 cross workplan boundaries, while none repeats
|
||||
inside one workplan. A task has no projection-retirement field, so workplan slug
|
||||
tombstones are only a diagnostic clue; current task membership is whether the
|
||||
Forge still derives that id for that owning workplan.
|
||||
|
||||
The remaining 12 groups are two duplicated finished workplan projections:
|
||||
The 12 groups with multiple untombstoned owning workplan slugs come from two
|
||||
earlier wrong workplan/backing-file projections:
|
||||
`CUST-WP-0010b-T01..T02` and `SECRETS-WP-0002-T01..T10`. They must be resolved
|
||||
by retiring the displaced workplan projection, not by deleting or re-keying its
|
||||
tasks. Evidence:
|
||||
by reconciling the owning workplans while preserving their completed historical
|
||||
task rows, not by deleting or re-keying those rows. Live exact-commit reconciles
|
||||
did that with zero creates, retirements, cancellations, or refusals. Evidence:
|
||||
`docs/evidence/STATE-WP-0083-task-identity-audit-2026-08-31.json`.
|
||||
|
||||
The diff now reports an explicit `ambiguous` class instead of silently choosing
|
||||
the last row in a dictionary. Reset refuses duplicate identities declared by
|
||||
the forge, duplicate identified rows inside the matched current workplan, and
|
||||
any task creation whose canonical id or derived UUID is held by another current
|
||||
workplan. These checks are batched; retired/displaced workplan tasks are outside
|
||||
the current matching set and remain preserved. Live deployment and a clean
|
||||
repository diff remain before T02 can close.
|
||||
any task creation whose derived UUID is held by another current workplan. A
|
||||
repeated human-readable id outside the owning workplan's current desired set is
|
||||
historical evidence, not a competing projection. These checks are batched;
|
||||
retired/displaced workplan tasks remain preserved. The second exact-commit pass
|
||||
for both affected repositories returned `noop` with every count zero. Final
|
||||
deployment of the refined boundary remains before T02 can close.
|
||||
|
||||
|
||||
## Restore a migration mechanism for central
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue