From f767a1a245fc3c3f15a0c8a398ad508a7d13342c Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 10 Sep 2026 15:11:03 +0200 Subject: [PATCH] fix: preserve source task sections and explicit empty notes Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc --- api/services/forge_projection.py | 51 ++++++++----- tests/test_forge_projection.py | 75 +++++++++++++++++++ tests/test_work_record_projection_api.py | 58 ++++++++++++++ ...-0090-projection-task-field-convergence.md | 64 ++++++++++++++++ 4 files changed, 231 insertions(+), 17 deletions(-) create mode 100644 workplans/STATE-WP-0090-projection-task-field-convergence.md diff --git a/api/services/forge_projection.py b/api/services/forge_projection.py index 49e43a6..2071da1 100644 --- a/api/services/forge_projection.py +++ b/api/services/forge_projection.py @@ -30,7 +30,7 @@ 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) +_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)$", re.MULTILINE) DEFAULT_FORGE_BASE = "https://forgejo.coulomb.social/coulomb" @@ -272,12 +272,23 @@ def _split_frontmatter(text: str) -> tuple[dict, str]: return (meta if isinstance(meta, dict) else {}), body +def _clean_task_description(raw: str) -> str | None: + """Match the file-authoritative consistency parser's section boundaries.""" + lines = raw.splitlines() + while lines and (not lines[0].strip() or lines[0].strip() in {"---", "***", "___"}): + lines.pop(0) + while lines and (not lines[-1].strip() or lines[-1].strip() in {"---", "***", "___"}): + lines.pop() + return "\n".join(lines).strip() or None + + def _parse_tasks(body: str, workplan_id: str) -> list[DerivedTask]: headings = [ - (m.start(), m.group(2).strip()) for m in _HEADING_RE.finditer(body) + (m.start(), len(m.group(1)), m.group(2).strip()) for m in _HEADING_RE.finditer(body) ] out: list[DerivedTask] = [] - for m in _TASK_BLOCK_RE.finditer(body): + task_matches = list(_TASK_BLOCK_RE.finditer(body)) + for index, m in enumerate(task_matches): try: block = yaml.safe_load(m.group(1).strip()) or {} except yaml.YAMLError: @@ -287,15 +298,21 @@ def _parse_tasks(body: str, workplan_id: str) -> list[DerivedTask]: rid = str(block.get("id") or "").strip() if not rid: continue - title = block.get("title") - if not title: - prev = [t for pos, t in headings if pos < m.start()] - title = prev[-1] if prev else None - following_headings = [pos for pos, _title in headings if pos > m.end()] - description_end = min(following_headings) if following_headings else len(body) - description = str(block.get("description") or "").strip() - if not description: - description = body[m.end() : description_end].strip() + prev = [(pos, level, text) for pos, level, text in headings if pos < m.start()] + heading = prev[-1] if prev else None + title = block.get("title") or (heading[2] if heading else None) + # Nested evidence belongs to this task. Stop at the next peer/ancestor + # heading or task block, as the ordinary consistency path does. + description_end = task_matches[index + 1].start() if index + 1 < len(task_matches) else len(body) + following_heading = next(( + pos for pos, level, _text in headings + if pos > m.end() and (heading is None or level <= heading[1]) + ), None) + if following_heading is not None: + description_end = min(description_end, following_heading) + description = _clean_task_description(body[m.end():description_end]) + if description is None and block.get("description") is not None: + description = str(block["description"]) out.append( DerivedTask( # A bare `T01` is not an identifier: it is unique only within @@ -315,16 +332,16 @@ def _parse_tasks(body: str, workplan_id: str) -> list[DerivedTask]: title=title, status=(str(block["status"]).strip() if block.get("status") else None), priority=(str(block["priority"]).strip() if block.get("priority") else None), - description=description or None, + description=description, needs_human=bool(block.get("needs_human", False)), intervention_note=( - str(block["intervention_note"]).strip() - if block.get("intervention_note") + str(block["intervention_note"]) + if block.get("intervention_note") is not None else None ), blocking_reason=( - str(block["blocking_reason"]).strip() - if block.get("blocking_reason") + str(block["blocking_reason"]) + if block.get("blocking_reason") is not None else None ), ) diff --git a/tests/test_forge_projection.py b/tests/test_forge_projection.py index d47965a..57641d3 100644 --- a/tests/test_forge_projection.py +++ b/tests/test_forge_projection.py @@ -17,6 +17,81 @@ from api.services import forge_credential as fc from api.services import forge_projection as fp +def test_task_description_retains_nested_sections_and_matches_consistency(monkeypatch): + monkeypatch.syspath_prepend(str(Path(__file__).parents[1] / "scripts")) + from scripts.consistency_check import parse_task_blocks + + body = '''## Factory admission + +```task +id: HFACT-WP-0001-T01 +status: progress +description: Earlier inline summary. +intervention_note: "" +blocking_reason: "" +``` + +--- +Current native admission requirements. + +### Local proof + +Retain the source evidence and limitations. + +#### Remaining return + +Exact operator binding remains open. +--- + +## Next task + +```task +id: HFACT-WP-0001-T02 +status: wait +``` + +Separate next-task description. +''' + derived = fp._parse_tasks(body, "HFACT-WP-0001") + authoritative = parse_task_blocks(body) + assert derived[0].description == authoritative[0]["description"] + assert "### Local proof" in derived[0].description + assert "#### Remaining return" in derived[0].description + assert "Next task" not in derived[0].description + assert not derived[0].description.startswith("---") + assert derived[0].intervention_note == "" + assert derived[0].blocking_reason == "" + assert derived[1].description == authoritative[1]["description"] + assert derived[1].intervention_note is None + + +@pytest.mark.parametrize("heading", ["", "# Workplan\n", "## Task\n", "### Task\n"]) +def test_task_description_stops_before_next_task_block(heading, monkeypatch): + monkeypatch.syspath_prepend(str(Path(__file__).parents[1] / "scripts")) + from scripts.consistency_check import parse_task_blocks + + body = heading + '''```task +id: DEMO-WP-0001-T01 +status: todo +``` + +First description. + +```task +id: DEMO-WP-0001-T02 +status: wait +description: Inline fallback. +intervention_note: null +blocking_reason: null +``` +''' + derived = fp._parse_tasks(body, "DEMO-WP-0001") + authoritative = parse_task_blocks(body) + assert derived[0].description == authoritative[0]["description"] == "First description." + assert derived[1].description == authoritative[1]["description"] == "Inline fallback." + assert derived[1].intervention_note is derived[1].blocking_reason is None + + def test_identity_matches_the_fleet_derivation(): """Same namespace as ADR-007, so overlay and forge agree on identity.""" assert fp.derived_record_uuid("CUST-WP-0067") == "16249302-2767-55df-aec0-d92c2751c225" diff --git a/tests/test_work_record_projection_api.py b/tests/test_work_record_projection_api.py index bcbb5ff..81ab1b1 100644 --- a/tests/test_work_record_projection_api.py +++ b/tests/test_work_record_projection_api.py @@ -146,3 +146,61 @@ async def test_reconcile_rejects_commit_mismatch_without_db_changes( ) assert rows.status_code == 200 assert rows.json() == [] + + +async def test_real_source_projection_converges_without_followup_task_patches(client, monkeypatch, tmp_path): + """Derive source through the real parser and persist it through the real API/DB.""" + await create_test_domain(client) + await create_test_repo(client, slug="projection-test") + from api.config import settings + from api.routers import work_record_projection as route + + monkeypatch.setattr(settings, "state_hub_instance_role", "primary") + monkeypatch.setattr(settings, "state_hub_instance_label", "railiance01") + root = tmp_path / "projection-test" + (root / "workplans").mkdir(parents=True) + path = root / "workplans/TEST-WP-0001.md" + path.write_text('''--- +id: TEST-WP-0001 +type: workplan +title: Factory test +status: active +--- +## Native admission + +```task +id: TEST-WP-0001-T01 +status: wait +intervention_note: Review needed. +blocking_reason: Waiting. +needs_human: true +``` + +Admission context. + +### Proof + +Retain full proof. +''') + commit = "a" * 40 + monkeypatch.setattr(route, "derive_from_forge", lambda slug: fp.derive_from_checkout(root, slug, commit)) + endpoint = "/repos/projection-test/work-record-projection/reconcile" + first = await client.post(endpoint, json={"expected_commit": commit}, headers={"Idempotency-Key": "source:first"}) + assert first.status_code == 200, first.text + task_id = fp.derived_record_uuid("TEST-WP-0001-T01") + created = (await client.get(f"/tasks/{task_id}")).json() + assert created["description"] == "Admission context.\n\n### Proof\n\nRetain full proof." + assert created["needs_human"] is True + + path.write_text(path.read_text().replace("Review needed.", '""').replace("Waiting.", '""').replace("needs_human: true", "needs_human: false")) + commit = "b" * 40 + for key in ["source:updated", "source:independent-rederivation"]: + result = await client.post(endpoint, json={"expected_commit": commit}, headers={"Idempotency-Key": key}) + assert result.status_code == 200, result.text + assert result.json()["outcome"]["status"] in {"applied", "noop"} + actual = (await client.get(f"/tasks/{task_id}")).json() + assert actual["description"] == created["description"] + assert actual["intervention_note"] == actual["blocking_reason"] == "" + assert actual["needs_human"] is False + if key == "source:independent-rederivation": + assert result.json()["outcome"]["counts"]["updated_tasks"] == 0 diff --git a/workplans/STATE-WP-0090-projection-task-field-convergence.md b/workplans/STATE-WP-0090-projection-task-field-convergence.md new file mode 100644 index 0000000..105f5e7 --- /dev/null +++ b/workplans/STATE-WP-0090-projection-task-field-convergence.md @@ -0,0 +1,64 @@ +--- +id: STATE-WP-0090 +type: workplan +title: "Preserve task descriptions and explicit empty notes in Forge projection" +domain: infotech +repo: state-hub +status: active +owner: codex +topic_slug: infotech +created: "2026-09-10" +updated: "2026-09-10" +related: [STATE-WP-0086, RMGR-WP-0017, HFACT-WP-0001] +--- + +HFACT-WP-0001-T02 repeatedly repairs six of 24 selected task projections after +normal sync. Forge derivation truncates nested evidence and converts explicit +empty intervention notes into null, disagreeing with file-authoritative deep +consistency. Fix the original parser rather than applying another batch patch. + +## Reproduce and correct task-field derivation + +```task +id: STATE-WP-0090-T01 +status: done +priority: high +``` + +Retain nested task sections, stop at the next peer/ancestor heading or task block, +match source-description cleanup and preserve absent/null/empty metadata meaning. +Keep task identity, status, collision and retirement rules unchanged. Test parity +with the existing consistency parser and real PostgreSQL/API convergence. + +## Promote the verified correction and prove the selected chain + +```task +id: STATE-WP-0090-T02 +status: todo +priority: high +``` + +Run relevant projection/consistency/API regressions, commit/push, consume the +immutable CI image and prepare the existing Helm release with reused values and +atomic rollback. Verify the exact promoted image and healthy primary, then sync +the three selected repositories using RMGR-WP-0017 and read back all 24 task +fields with zero manual task PATCH repairs. Preserve unrelated untracked evidence. + +## Retain evidence and close the bounded defect + +```task +id: STATE-WP-0090-T03 +status: todo +priority: medium +``` + +Record source/image/Helm revisions, test results and before/after convergence. +HFACT T02 retains other selected-backlog readiness work; native admission and +factory execution remain their existing owner tasks. Do not mark factory G1/G2 +complete or introduce another synchronization service. + +Validation: all five new parser cases fail on the previous derivation. The +corrected projection/consistency/API suite passes 242 tests on a disposable +PostgreSQL database. Actual selected-source comparison reproduces six drifted +tasks before the fix and zero afterward across 24 tasks. No production data +was used by the tests; live promotion and readback remain T02.