diff --git a/allocator_service.py b/allocator_service.py index 2b752b1..9f32fd9 100644 --- a/allocator_service.py +++ b/allocator_service.py @@ -133,10 +133,15 @@ ROLE_ACTIONS: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = { # Body phrases that prove an issue is an implementation container, not a -# unit of direct author work (#844). Matched case-insensitively against the -# issue body. Title alone is never sufficient (ordinary issues may mention -# "epic" incidentally). +# unit of direct author work (#844 / #854). Matched case-insensitively against +# the issue body. Title alone is never sufficient (ordinary issues may mention +# "epic", "roadmap", "vision", or "umbrella" incidentally). +# +# #854 extends the #844 marker set so product-vision (#652), phased-roadmap +# (#653), and umbrella (#655) coordination records — which do not use the word +# "epic" — are classified with the same semantic exclusion as epic containers. _CHILD_ONLY_BODY_MARKERS: tuple[str, ...] = ( + # Epic / child-only (#844, live #631) "implementation is delivered via child issues only", "implementation is delivered through child issues only", "implementation is delivered via child issues", @@ -150,9 +155,26 @@ _CHILD_ONLY_BODY_MARKERS: tuple[str, ...] = ( "coordination container", "child-only container", "implementation is delegated to child", + # Vision / roadmap / umbrella coordination (#854, live #652/#653/#655). + # Prefer authoritative non-implementation / child-only scope language over + # bare words like "roadmap" so ordinary implementable issues that mention + # a parent vision or roadmap stay eligible. + "do not implement features on this issue", + "implementing features on this roadmap issue", + "implementation is via linked children only", + "no product feature claimed complete on this issue alone", + "phased delivery roadmap and epic sequencing", + "this issue is the enduring source of truth", + "enduring source of truth for the", + "canonical product vision — enduring source of truth", + "canonical product vision - enduring source of truth", + "state: vision-active", + "state: roadmap-active", ) -# Explicit epic / umbrella labels (structured evidence preferred over title). +# Explicit epic / umbrella / vision / roadmap labels (structured evidence +# preferred over title). Tracker alone is *not* included — ordinary issues +# may carry a tracker label without being non-implementable containers. _EPIC_LABELS: frozenset[str] = frozenset( { "type:epic", @@ -161,6 +183,16 @@ _EPIC_LABELS: frozenset[str] = frozenset( "scope:epic", "type:umbrella", "umbrella", + "kind:umbrella", + "scope:umbrella", + "type:vision", + "vision", + "kind:vision", + "scope:vision", + "type:roadmap", + "roadmap", + "kind:roadmap", + "scope:roadmap", } ) @@ -222,16 +254,45 @@ class WorkCandidate: } +def _title_container_prefix(title_l: str) -> str | None: + """Return a coordination-title prefix token if *title_l* uses one (#854). + + Title prefixes alone never exclude; they only corroborate body/label + evidence. Ordinary issues may say "roadmap" or "vision" mid-title. + """ + for prefix, token in ( + ("epic:", "title_epic_prefix"), + ("epic ", "title_epic_prefix"), + ("umbrella:", "title_umbrella_prefix"), + ("umbrella ", "title_umbrella_prefix"), + ("roadmap:", "title_roadmap_prefix"), + ("roadmap ", "title_roadmap_prefix"), + ("product vision:", "title_vision_prefix"), + ("product vision ", "title_vision_prefix"), + ("vision:", "title_vision_prefix"), + ("vision ", "title_vision_prefix"), + ): + if title_l.startswith(prefix): + return token + return None + + def classify_epic_or_child_only_container( c: WorkCandidate, ) -> tuple[bool, str | None]: - """Return whether *c* is an epic / child-only implementation container (#844). + """Return whether *c* is a non-implementable coordination container (#844/#854). Exclusion uses structured evidence first (labels, body scope language). - A bare title containing the word "epic" is **not** enough — ordinary - implementable issues may mention epics incidentally. A title that is - explicitly prefixed ``Epic:`` only counts when the body also proves - child-only / no-direct-implementation scope (or an epic label is present). + A bare title containing the words "epic", "roadmap", "vision", or + "umbrella" is **not** enough — ordinary implementable issues may mention + those terms incidentally. Explicit title prefixes (``Epic:``, ``Roadmap:``, + ``Product vision:``, ``Umbrella:``) only count when the body also proves + child-only / no-direct-implementation scope (or a container label is + present). + + Covers epic, product-vision, phased-roadmap, umbrella, and child-only + records so the allocator never assigns coordination containers as direct + author work. PRs are never classified as containers here (they already have a head). """ @@ -245,25 +306,28 @@ def classify_epic_or_child_only_container( title_l = title.lower() body_hits = [m for m in _CHILD_ONLY_BODY_MARKERS if m in body_l] - title_epic_prefix = title_l.startswith("epic:") or title_l.startswith("epic ") + title_prefix = _title_container_prefix(title_l) if epic_label: detail = f"label={epic_label[0]}" if body_hits: detail = f"{detail}; body_marker={body_hits[0]!r}" + if title_prefix: + detail = f"{title_prefix}; {detail}" return True, detail if body_hits: - # Body proves child-only / umbrella scope. Title "Epic:" is corroborating - # but not required — containers without the word still exclude. + # Body proves child-only / vision / roadmap / umbrella scope. Title + # prefixes are corroborating but not required — containers without the + # title word still exclude. detail = f"body_marker={body_hits[0]!r}" - if title_epic_prefix: - detail = f"title_epic_prefix; {detail}" + if title_prefix: + detail = f"{title_prefix}; {detail}" return True, detail - # Title-only "Epic:" without body scope evidence is insufficient (#844 AC: - # eligibility does not rely solely on the word "Epic" in a title). - # Similarly, incidental "epic" mid-title without markers stays eligible. + # Title-only coordination prefix without body scope evidence is + # insufficient (#844/#854 AC: eligibility does not rely solely on a title + # word). Incidental mid-title mentions without markers stay eligible. return False, None diff --git a/tests/test_issue_854_semantic_container_exclusion.py b/tests/test_issue_854_semantic_container_exclusion.py new file mode 100644 index 0000000..803796a --- /dev/null +++ b/tests/test_issue_854_semantic_container_exclusion.py @@ -0,0 +1,378 @@ +"""Allocator semantic container exclusion for vision/roadmap/umbrella (#854). + +#844 excluded epic / child-only containers (live #631) but product-vision +(#652), phased-roadmap (#653), and umbrella (#655) coordination records still +ranked as implementable work. This module is the live-equivalent canary: + +* #631 / #652 / #653 / #655-shaped records are all excluded in one inventory. +* Independently executable children remain eligible and can be selected. +* Ordinary issues that merely mention vision / roadmap / umbrella stay eligible. +* Excluded containers never receive assignments or workflow leases. +* Structured skip reason ``epic_or_child_only_container`` is reported. +* Candidate-set fingerprint remains stable after exclusions. +""" + +from __future__ import annotations + +import os +import tempfile +import unittest + +from allocator_service import ( + OUTCOME_ASSIGNED, + OUTCOME_PREVIEW, + SKIP_EPIC_OR_CHILD_ONLY_CONTAINER, + WorkCandidate, + allocate_next_work, + candidate_set_fingerprint, + classify_epic_or_child_only_container, +) +from control_plane_db import ControlPlaneDB + +REMOTE = "prgs" +ORG = "Scaled-Tech-Consulting" +REPO = "Gitea-Tools" + +# Minimal bodies mirroring live coordination records (not full issue text). +_EPIC_631_BODY = """ +## Scope (umbrella) + +This epic owns the **product roadmap and linkage** for the Web Console. +Implementation is delivered via child issues only. + +## Explicit non-goals + +* Do not implement product features in this epic issue itself. +* No product feature implementation is claimed complete solely on this epic. +""" + +_VISION_652_BODY = """ +## Canonical product vision — enduring source of truth + +**This issue is the enduring source of truth for the MCP Control Plane Web Console product vision.** + +## Implementation linkage + +* **Do not implement features on this issue.** +* Sequencing: roadmap issue + #631 children. + +## Canonical issue state + +```text +STATE: vision-active +WHO_IS_NEXT: controller (triage/ordering) / author (implementation of linked children only) +``` +""" + +_ROADMAP_653_BODY = """ +## Purpose + +This issue is the **phased delivery roadmap and epic sequencing** for the MCP Control Plane Web Console. + +## Non-goals + +* Implementing features on this roadmap issue. +* Deleting vision items by omitting them from phases without #652 change log. + +## Canonical issue state + +```text +STATE: roadmap-active +WHO_IS_NEXT: author +``` +""" + +_UMBRELLA_655_BODY = """ +## Scope (umbrella) + +This issue owns the **canonical restart-governance program**. Implementation is via linked children only. + +## Acceptance criteria (umbrella) + +6. No product feature claimed complete on this issue alone. +""" + +_CHILD_BODY = """ +## Problem + +Operators need a workflow-event timeline model for Phase 1. + +## Acceptance criteria + +- [ ] Timeline model API exists +""" + + +def _issue( + number: int, + *, + title: str = "", + body: str = "", + labels: tuple[str, ...] = ("status:ready", "type:feature"), + priority: int = 20, +) -> WorkCandidate: + return WorkCandidate( + kind="issue", + number=number, + state="open", + labels=labels, + title=title or f"issue {number}", + body=body, + priority=priority, + ) + + +def _live_shaped_containers() -> list[WorkCandidate]: + return [ + _issue( + 631, + title="Epic: MCP Control Plane Web Console", + body=_EPIC_631_BODY, + ), + _issue( + 652, + title="Product vision: MCP Control Plane Web Console (canonical)", + body=_VISION_652_BODY, + ), + _issue( + 653, + title="Roadmap: MCP Control Plane Web Console (phased delivery)", + body=_ROADMAP_653_BODY, + ), + _issue( + 655, + title="Umbrella: Governed MCP restart coordination and zero-disruption recovery", + body=_UMBRELLA_655_BODY, + ), + ] + + +class ClassifySemanticContainersTest(unittest.TestCase): + def test_652_vision_is_container(self) -> None: + c = _issue( + 652, + title="Product vision: MCP Control Plane Web Console (canonical)", + body=_VISION_652_BODY, + ) + is_c, detail = classify_epic_or_child_only_container(c) + self.assertTrue(is_c) + self.assertIsNotNone(detail) + self.assertIn("body_marker", detail or "") + + def test_653_roadmap_is_container(self) -> None: + c = _issue( + 653, + title="Roadmap: MCP Control Plane Web Console (phased delivery)", + body=_ROADMAP_653_BODY, + ) + is_c, detail = classify_epic_or_child_only_container(c) + self.assertTrue(is_c) + self.assertIn("body_marker", detail or "") + + def test_655_umbrella_is_container(self) -> None: + c = _issue( + 655, + title="Umbrella: Governed MCP restart coordination and zero-disruption recovery", + body=_UMBRELLA_655_BODY, + ) + is_c, detail = classify_epic_or_child_only_container(c) + self.assertTrue(is_c) + self.assertIn("body_marker", detail or "") + + def test_631_still_container_after_854(self) -> None: + c = _issue( + 631, + title="Epic: MCP Control Plane Web Console", + body=_EPIC_631_BODY, + ) + is_c, detail = classify_epic_or_child_only_container(c) + self.assertTrue(is_c) + self.assertIn("body_marker", detail or "") + + def test_incidental_vision_roadmap_umbrella_words_not_container(self) -> None: + cases = ( + ( + "Document vision handoff conventions", + "Update docs so implementable issues that mention a vision " + "remain independently executable.", + ), + ( + "Clarify roadmap sequencing notes", + "Write a short note about how the roadmap issue relates to children.", + ), + ( + "Umbrella recovery checklist for authors", + "Authors should still implement the concrete recovery fix here.", + ), + ( + "Product vision wording in the help text", + "Fix a typo in the operator-facing help string that says product vision.", + ), + ) + for title, body in cases: + with self.subTest(title=title): + c = _issue(900, title=title, body=body) + is_c, detail = classify_epic_or_child_only_container(c) + self.assertFalse(is_c) + self.assertIsNone(detail) + + def test_title_prefix_alone_not_container(self) -> None: + for title in ( + "Epic: something mentioned only in title", + "Roadmap: title only without body scope", + "Product vision: title only without body scope", + "Umbrella: title only without body scope", + ): + with self.subTest(title=title): + c = _issue( + 901, + title=title, + body="Implement a concrete fix for the allocator skip list.", + ) + is_c, detail = classify_epic_or_child_only_container(c) + self.assertFalse(is_c) + self.assertIsNone(detail) + + def test_roadmap_label_alone_is_container(self) -> None: + c = _issue( + 902, + title="Console delivery sequencing", + body="Track phased delivery only.", + labels=("status:ready", "type:roadmap"), + ) + is_c, detail = classify_epic_or_child_only_container(c) + self.assertTrue(is_c) + self.assertIn("type:roadmap", detail or "") + + def test_child_referencing_parent_policy_stays_eligible(self) -> None: + """Children may quote parent policy without becoming containers.""" + c = _issue( + 637, + title="Web Console: Workflow-event timeline model (Phase 1)", + body=( + _CHILD_BODY + + "\n\nParent #652 says do not implement on the vision issue; " + "this child is the implementable unit." + ), + ) + is_c, _ = classify_epic_or_child_only_container(c) + self.assertFalse(is_c) + + +class AllocateSemanticContainerExclusionTest(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3")) + + def _alloc(self, candidates, **kwargs): + defaults = dict( + session_id="sess-854", + role="author", + remote=REMOTE, + org=ORG, + repo=REPO, + profile_name="prgs-author", + username="jcwalker3", + claims={}, + apply=False, + ) + defaults.update(kwargs) + return allocate_next_work(self.db, candidates=candidates, **defaults) + + def test_live_equivalent_canary_excludes_all_containers_selects_child(self) -> None: + containers = _live_shaped_containers() + child = _issue( + 637, + title="Web Console: Workflow-event timeline model (Phase 1)", + body=_CHILD_BODY, + ) + inventory = containers + [child] + res = self._alloc(inventory, apply=False) + self.assertTrue(res["success"], res) + self.assertEqual(res["outcome"], OUTCOME_PREVIEW) + self.assertEqual(res["selected"]["number"], 637) + + skipped = {s["number"]: s for s in res["skipped"]} + for number in (631, 652, 653, 655): + self.assertIn(number, skipped, res["skipped"]) + self.assertEqual( + skipped[number]["reason_code"], + SKIP_EPIC_OR_CHILD_ONLY_CONTAINER, + ) + self.assertIn( + SKIP_EPIC_OR_CHILD_ONLY_CONTAINER, skipped[number]["reason"] + ) + + def test_containers_cannot_receive_assignment_or_lease(self) -> None: + containers = _live_shaped_containers() + res = self._alloc(containers, apply=True) + self.assertTrue(res["success"], res) + self.assertNotEqual(res["outcome"], OUTCOME_ASSIGNED) + self.assertIsNone(res.get("assignment")) + self.assertIsNone(res.get("selected")) + skipped = {s["number"]: s for s in res["skipped"]} + for number in (631, 652, 653, 655): + self.assertEqual( + skipped[number]["reason_code"], + SKIP_EPIC_OR_CHILD_ONLY_CONTAINER, + ) + + leases = [] + if hasattr(self.db, "list_active_leases"): + leases = self.db.list_active_leases( + remote=REMOTE, org=ORG, repo=REPO + ) + if not leases and hasattr(self.db, "list_leases"): + leases = self.db.list_leases(remote=REMOTE, org=ORG, repo=REPO) + for lease in leases or []: + work_number = ( + lease.get("work_number") if isinstance(lease, dict) else None + ) + self.assertNotIn(work_number, {631, 652, 653, 655}) + + def test_apply_selects_child_not_container(self) -> None: + containers = _live_shaped_containers() + child = _issue( + 637, + title="Web Console: Workflow-event timeline model (Phase 1)", + body=_CHILD_BODY, + ) + res = self._alloc(containers + [child], apply=True) + self.assertTrue(res["success"], res) + self.assertEqual(res["outcome"], OUTCOME_ASSIGNED) + self.assertEqual(res["selected"]["number"], 637) + self.assertEqual(res["assignment"]["work_number"], 637) + + def test_fingerprint_stable_with_containers_present(self) -> None: + containers = _live_shaped_containers() + child = _issue( + 637, + title="Web Console: Workflow-event timeline model (Phase 1)", + body=_CHILD_BODY, + ) + inventory = containers + [child] + fp_before = candidate_set_fingerprint(inventory) + res = self._alloc(inventory, apply=False) + self.assertTrue(res["success"], res) + self.assertEqual(res["selected"]["number"], 637) + # Allocator reports the same CAS fingerprint for the full candidate set. + reported = res.get("candidate_set_fingerprint") + self.assertEqual(reported, fp_before) + # Re-fingerprint of the same inventory is byte-stable. + self.assertEqual(candidate_set_fingerprint(inventory), fp_before) + + def test_incidental_mentions_remain_eligible(self) -> None: + ordinary = _issue( + 700, + title="Document roadmap handoff conventions", + body="Write runbook text about vision vs roadmap vs child issues.", + ) + res = self._alloc([ordinary], apply=False) + self.assertTrue(res["success"], res) + self.assertEqual(res["selected"]["number"], 700) + self.assertEqual(res["skipped"], []) + + +if __name__ == "__main__": + unittest.main()