diff --git a/api/services/forge_projection.py b/api/services/forge_projection.py index b503c78..96cc24d 100644 --- a/api/services/forge_projection.py +++ b/api/services/forge_projection.py @@ -26,6 +26,8 @@ import yaml # Same derivation as ADR-007 / repo-manager, so a forge-derived projection and a # preliminary overlay compute identical identities for the same record. +from api.services.task_record_id_backfill import qualify_task_id + _WORK_RECORD_NAMESPACE = uuid.UUID("a4058507-5c4a-5a00-ab06-fffa4fb46009") _TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL) _HEADING_RE = re.compile(r"^(#{1,4})\s+(.+?)$", re.MULTILINE) @@ -258,7 +260,7 @@ def _split_frontmatter(text: str) -> tuple[dict, str]: return (meta if isinstance(meta, dict) else {}), body -def _parse_tasks(body: str) -> list[DerivedTask]: +def _parse_tasks(body: str, workplan_id: str) -> list[DerivedTask]: headings = [ (m.start(), m.group(2).strip()) for m in _HEADING_RE.finditer(body) ] @@ -279,10 +281,20 @@ def _parse_tasks(body: str) -> list[DerivedTask]: title = prev[-1] if prev else None out.append( DerivedTask( - record_id=rid, + # A bare `T01` is not an identifier: it is unique only within + # its workplan, so `uuid5("T01")` is the same UUID for every + # workplan in the fleet. llm-connect's 91 task blocks derive + # just 49 distinct UUIDs unqualified, and creating its + # workplans fails on a duplicate task primary key. + # + # Qualifying with the owning workplan is the same rule + # `task_record_id_backfill.qualify_task_id` applies to stored + # ids; both must agree or the backfill and the projection + # disagree about what a task is called. + record_id=qualify_task_id(rid, workplan_id) or rid, # Derived, not read from the file: the forge projection must not # inherit an identifier the file happens to carry. - uuid=derived_record_uuid(rid), + uuid=derived_record_uuid(qualify_task_id(rid, workplan_id) or rid), title=title, status=(str(block["status"]).strip() if block.get("status") else None), priority=(str(block["priority"]).strip() if block.get("priority") else None), @@ -319,7 +331,7 @@ def derive_from_checkout(repo_root: Path, repo_slug: str, commit: str) -> Derive status=(str(meta["status"]).strip() if meta.get("status") else None), relative_path=str(path.relative_to(repo_root).as_posix()), archived=path.parent.name == "archived", - tasks=_parse_tasks(body), + tasks=_parse_tasks(body, rid), ) ) proj.workplans.sort(key=lambda w: w.record_id) diff --git a/tests/test_forge_projection.py b/tests/test_forge_projection.py index 32eca8d..51c9e0e 100644 --- a/tests/test_forge_projection.py +++ b/tests/test_forge_projection.py @@ -845,3 +845,45 @@ class TestUuidMatchWins: uuid_at = src.index("want_by_uuid.get(str(row.id))") rekey_at = src.index("_identity_is_derived(row) or _slug_is_identifier") assert uuid_at < rekey_at, "UUID match must precede the re-key heuristics" + + +class TestTaskIdentityIsQualified: + """A bare `T01` is unique within its workplan, not in the fleet. + + Unqualified, `uuid5("T01")` is the same UUID for every workplan that has a + T01 — llm-connect's 91 task blocks derive 49 distinct UUIDs, and creating + its workplans dies on `duplicate key value violates unique constraint + "tasks_pkey"`. + """ + + BODY = """ +```task +id: T01 +status: done +``` + +```task +id: T02 +status: todo +``` +""" + + def test_short_ids_are_qualified_by_their_workplan(self): + a = fp._parse_tasks(self.BODY, "LLM-WP-0001") + b = fp._parse_tasks(self.BODY, "LLM-WP-0002") + assert [t.record_id for t in a] == ["LLM-WP-0001-T01", "LLM-WP-0001-T02"] + assert {t.uuid for t in a}.isdisjoint({t.uuid for t in b}) + + def test_an_already_qualified_id_is_left_alone(self): + body = "```task\nid: LLM-WP-0009-T03\nstatus: todo\n```" + assert fp._parse_tasks(body, "LLM-WP-0001")[0].record_id == "LLM-WP-0009-T03" + + def test_it_agrees_with_the_backfill(self): + """Both must apply the same rule or they disagree on a task's name.""" + from api.services.task_record_id_backfill import qualify_task_id + got = fp._parse_tasks(self.BODY, "LLM-WP-0001")[0].record_id + assert got == qualify_task_id("T01", "LLM-WP-0001") + + def test_uuid_follows_the_qualified_id(self): + t = fp._parse_tasks(self.BODY, "LLM-WP-0001")[0] + assert t.uuid == fp.derived_record_uuid("LLM-WP-0001-T01")