Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d4a0d12ec | ||
|
|
c1d2bad901 |
+1
-103
@@ -53,8 +53,6 @@ OUTCOME_CANDIDATE_SET_DRIFT = "candidate_set_drift"
|
|||||||
SKIP_CLAIMED_BY_OTHER_SESSION = "claimed_by_other_session"
|
SKIP_CLAIMED_BY_OTHER_SESSION = "claimed_by_other_session"
|
||||||
# #776: controller-supplied pre-rank exclusion.
|
# #776: controller-supplied pre-rank exclusion.
|
||||||
SKIP_EXCLUDED_BY_CONTROLLER = "excluded_by_controller"
|
SKIP_EXCLUDED_BY_CONTROLLER = "excluded_by_controller"
|
||||||
# #844: epic / child-only implementation container (pre-rank).
|
|
||||||
SKIP_EPIC_OR_CHILD_ONLY_CONTAINER = "epic_or_child_only_container"
|
|
||||||
|
|
||||||
# Ownership verdicts for a live claim on a candidate (#765).
|
# Ownership verdicts for a live claim on a candidate (#765).
|
||||||
OWNERSHIP_OWN = "own"
|
OWNERSHIP_OWN = "own"
|
||||||
@@ -132,39 +130,6 @@ 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).
|
|
||||||
_CHILD_ONLY_BODY_MARKERS: tuple[str, ...] = (
|
|
||||||
"implementation is delivered via child issues only",
|
|
||||||
"implementation is delivered through child issues only",
|
|
||||||
"implementation is delivered via child issues",
|
|
||||||
"implementation is delivered through child issues",
|
|
||||||
"do not implement product features in this epic",
|
|
||||||
"do not implement product features in this epic issue itself",
|
|
||||||
"no product feature implementation is claimed complete solely on this epic",
|
|
||||||
"implementable child issues remain independently eligible",
|
|
||||||
"owns the product roadmap and linkage",
|
|
||||||
"this epic owns the product roadmap",
|
|
||||||
"coordination container",
|
|
||||||
"child-only container",
|
|
||||||
"implementation is delegated to child",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Explicit epic / umbrella labels (structured evidence preferred over title).
|
|
||||||
_EPIC_LABELS: frozenset[str] = frozenset(
|
|
||||||
{
|
|
||||||
"type:epic",
|
|
||||||
"epic",
|
|
||||||
"kind:epic",
|
|
||||||
"scope:epic",
|
|
||||||
"type:umbrella",
|
|
||||||
"umbrella",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class WorkCandidate:
|
class WorkCandidate:
|
||||||
"""One assignable Gitea issue or PR presented to the allocator."""
|
"""One assignable Gitea issue or PR presented to the allocator."""
|
||||||
@@ -174,7 +139,6 @@ class WorkCandidate:
|
|||||||
state: str = "open"
|
state: str = "open"
|
||||||
labels: tuple[str, ...] = ()
|
labels: tuple[str, ...] = ()
|
||||||
title: str = ""
|
title: str = ""
|
||||||
body: str = ""
|
|
||||||
priority: int = 0
|
priority: int = 0
|
||||||
head_sha: str | None = None
|
head_sha: str | None = None
|
||||||
# Routing signals (callers derive from Gitea / review feedback).
|
# Routing signals (callers derive from Gitea / review feedback).
|
||||||
@@ -194,7 +158,6 @@ class WorkCandidate:
|
|||||||
self.labels = tuple(
|
self.labels = tuple(
|
||||||
str(x).strip().lower() for x in (self.labels or ()) if str(x).strip()
|
str(x).strip().lower() for x in (self.labels or ()) if str(x).strip()
|
||||||
)
|
)
|
||||||
self.body = str(self.body or "")
|
|
||||||
if self.kind not in WORK_KINDS:
|
if self.kind not in WORK_KINDS:
|
||||||
raise InvalidWorkKindError(
|
raise InvalidWorkKindError(
|
||||||
f"candidate kind '{self.kind}' is not assignable; only "
|
f"candidate kind '{self.kind}' is not assignable; only "
|
||||||
@@ -208,7 +171,6 @@ class WorkCandidate:
|
|||||||
"state": self.state,
|
"state": self.state,
|
||||||
"labels": list(self.labels),
|
"labels": list(self.labels),
|
||||||
"title": self.title,
|
"title": self.title,
|
||||||
"body": self.body,
|
|
||||||
"priority": self.priority,
|
"priority": self.priority,
|
||||||
"head_sha": self.head_sha,
|
"head_sha": self.head_sha,
|
||||||
"request_changes_current_head": self.request_changes_current_head,
|
"request_changes_current_head": self.request_changes_current_head,
|
||||||
@@ -222,51 +184,6 @@ class WorkCandidate:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def classify_epic_or_child_only_container(
|
|
||||||
c: WorkCandidate,
|
|
||||||
) -> tuple[bool, str | None]:
|
|
||||||
"""Return whether *c* is an epic / child-only implementation container (#844).
|
|
||||||
|
|
||||||
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).
|
|
||||||
|
|
||||||
PRs are never classified as containers here (they already have a head).
|
|
||||||
"""
|
|
||||||
if c.kind != "issue":
|
|
||||||
return False, None
|
|
||||||
|
|
||||||
labels = set(c.labels)
|
|
||||||
epic_label = sorted(labels & _EPIC_LABELS)
|
|
||||||
body_l = (c.body or "").lower()
|
|
||||||
title = (c.title or "").strip()
|
|
||||||
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 ")
|
|
||||||
|
|
||||||
if epic_label:
|
|
||||||
detail = f"label={epic_label[0]}"
|
|
||||||
if body_hits:
|
|
||||||
detail = f"{detail}; body_marker={body_hits[0]!r}"
|
|
||||||
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.
|
|
||||||
detail = f"body_marker={body_hits[0]!r}"
|
|
||||||
if title_epic_prefix:
|
|
||||||
detail = f"title_epic_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.
|
|
||||||
return False, None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SkipRecord:
|
class SkipRecord:
|
||||||
kind: str
|
kind: str
|
||||||
@@ -933,8 +850,7 @@ def allocate_next_work(
|
|||||||
ownership_defects: list[dict[str, Any]] = []
|
ownership_defects: list[dict[str, Any]] = []
|
||||||
controller_excluded: list[dict[str, Any]] = []
|
controller_excluded: list[dict[str, Any]] = []
|
||||||
|
|
||||||
# #776 AC2 + #844: remove excluded numbers *and* epic/child-only containers
|
# #776 AC2: remove excluded numbers *before* ranking / selection / lease.
|
||||||
# *before* ranking / selection / lease so they never receive assignments.
|
|
||||||
rankable: list[WorkCandidate] = []
|
rankable: list[WorkCandidate] = []
|
||||||
for c in candidates:
|
for c in candidates:
|
||||||
if int(c.number) in exclude_set:
|
if int(c.number) in exclude_set:
|
||||||
@@ -1013,23 +929,6 @@ def allocate_next_work(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
# #844: epics / child-only containers are never direct implement targets.
|
|
||||||
is_container, container_detail = classify_epic_or_child_only_container(c)
|
|
||||||
if is_container:
|
|
||||||
detail = container_detail or "epic or child-only container"
|
|
||||||
reason = (
|
|
||||||
f"{c.kind}#{c.number} {SKIP_EPIC_OR_CHILD_ONLY_CONTAINER}: "
|
|
||||||
f"{detail}; implementation is delegated to child issues"
|
|
||||||
)
|
|
||||||
skipped.append(
|
|
||||||
SkipRecord(
|
|
||||||
c.kind,
|
|
||||||
c.number,
|
|
||||||
reason,
|
|
||||||
SKIP_EPIC_OR_CHILD_ONLY_CONTAINER,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
rankable.append(c)
|
rankable.append(c)
|
||||||
|
|
||||||
ordered = sort_candidates(rankable)
|
ordered = sort_candidates(rankable)
|
||||||
@@ -1442,7 +1341,6 @@ def candidate_from_dict(data: dict[str, Any]) -> WorkCandidate:
|
|||||||
state=str(data.get("state") or "open"),
|
state=str(data.get("state") or "open"),
|
||||||
labels=tuple(data.get("labels") or ()),
|
labels=tuple(data.get("labels") or ()),
|
||||||
title=str(data.get("title") or ""),
|
title=str(data.get("title") or ""),
|
||||||
body=str(data.get("body") or ""),
|
|
||||||
priority=priority,
|
priority=priority,
|
||||||
head_sha=data.get("head_sha"),
|
head_sha=data.get("head_sha"),
|
||||||
request_changes_current_head=bool(data.get("request_changes_current_head")),
|
request_changes_current_head=bool(data.get("request_changes_current_head")),
|
||||||
|
|||||||
@@ -19912,7 +19912,6 @@ def _allocator_candidates_from_gitea(
|
|||||||
state="open",
|
state="open",
|
||||||
labels=tuple(labels),
|
labels=tuple(labels),
|
||||||
title=title,
|
title=title,
|
||||||
body=body,
|
|
||||||
priority=20 if "status:ready" in labels else 1,
|
priority=20 if "status:ready" in labels else 1,
|
||||||
blocked=blocked,
|
blocked=blocked,
|
||||||
dependency_unmet=dep_unmet,
|
dependency_unmet=dep_unmet,
|
||||||
|
|||||||
@@ -1,243 +0,0 @@
|
|||||||
"""Allocator epic / child-only container pre-rank exclusion (#844).
|
|
||||||
|
|
||||||
Covers:
|
|
||||||
* Issue #631-shaped child-only epic is excluded before ranking.
|
|
||||||
* Implementable child issues remain eligible and can be selected.
|
|
||||||
* Ordinary issues that merely mention "epic" in title/body are not excluded.
|
|
||||||
* Excluded containers never receive assignments or workflow leases.
|
|
||||||
* Structured skip reason ``epic_or_child_only_container`` is reported.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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,
|
|
||||||
classify_epic_or_child_only_container,
|
|
||||||
)
|
|
||||||
from control_plane_db import ControlPlaneDB
|
|
||||||
|
|
||||||
REMOTE = "prgs"
|
|
||||||
ORG = "Scaled-Tech-Consulting"
|
|
||||||
REPO = "Gitea-Tools"
|
|
||||||
|
|
||||||
# Minimal body mirroring issue #631 authoritative scope language.
|
|
||||||
_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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
_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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ClassifyEpicContainerTest(unittest.TestCase):
|
|
||||||
def test_631_shaped_body_and_title_is_container(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.assertIsNotNone(detail)
|
|
||||||
self.assertIn("body_marker", detail or "")
|
|
||||||
|
|
||||||
def test_body_markers_without_epic_title(self) -> None:
|
|
||||||
c = _issue(
|
|
||||||
900,
|
|
||||||
title="Control plane roadmap tracker",
|
|
||||||
body="Implementation is delivered via child issues only.",
|
|
||||||
)
|
|
||||||
is_c, _ = classify_epic_or_child_only_container(c)
|
|
||||||
self.assertTrue(is_c)
|
|
||||||
|
|
||||||
def test_epic_label_alone_is_container(self) -> None:
|
|
||||||
c = _issue(
|
|
||||||
901,
|
|
||||||
title="Roadmap linkage",
|
|
||||||
body="Track children.",
|
|
||||||
labels=("status:ready", "type:epic"),
|
|
||||||
)
|
|
||||||
is_c, detail = classify_epic_or_child_only_container(c)
|
|
||||||
self.assertTrue(is_c)
|
|
||||||
self.assertIn("type:epic", detail or "")
|
|
||||||
|
|
||||||
def test_title_epic_prefix_alone_not_container(self) -> None:
|
|
||||||
"""Title-only 'Epic:' without body scope evidence stays eligible (#844)."""
|
|
||||||
c = _issue(
|
|
||||||
902,
|
|
||||||
title="Epic: something mentioned only in 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_incidental_epic_word_not_container(self) -> None:
|
|
||||||
c = _issue(
|
|
||||||
903,
|
|
||||||
title="Document epic handoff conventions",
|
|
||||||
body=(
|
|
||||||
"Update the docs so implementable issues that mention an epic "
|
|
||||||
"remain independently executable."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
is_c, _ = classify_epic_or_child_only_container(c)
|
|
||||||
self.assertFalse(is_c)
|
|
||||||
|
|
||||||
def test_prs_never_classified(self) -> None:
|
|
||||||
pr = WorkCandidate(
|
|
||||||
kind="pr",
|
|
||||||
number=10,
|
|
||||||
state="open",
|
|
||||||
title="Epic: fake",
|
|
||||||
body="Implementation is delivered via child issues only.",
|
|
||||||
head_sha="a" * 40,
|
|
||||||
priority=5,
|
|
||||||
)
|
|
||||||
is_c, _ = classify_epic_or_child_only_container(pr)
|
|
||||||
self.assertFalse(is_c)
|
|
||||||
|
|
||||||
|
|
||||||
class AllocateEpicContainerExclusionTest(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-844",
|
|
||||||
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_631_shaped_epic_excluded_child_selected(self) -> None:
|
|
||||||
epic = _issue(
|
|
||||||
631,
|
|
||||||
title="Epic: MCP Control Plane Web Console",
|
|
||||||
body=_EPIC_631_BODY,
|
|
||||||
)
|
|
||||||
child = _issue(
|
|
||||||
637,
|
|
||||||
title="Web Console: Workflow-event timeline model (Phase 1)",
|
|
||||||
body=_CHILD_BODY,
|
|
||||||
)
|
|
||||||
res = self._alloc([epic, child], 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"]}
|
|
||||||
self.assertIn(631, skipped)
|
|
||||||
self.assertEqual(
|
|
||||||
skipped[631]["reason_code"], SKIP_EPIC_OR_CHILD_ONLY_CONTAINER
|
|
||||||
)
|
|
||||||
self.assertIn(SKIP_EPIC_OR_CHILD_ONLY_CONTAINER, skipped[631]["reason"])
|
|
||||||
|
|
||||||
def test_container_cannot_receive_assignment_or_lease(self) -> None:
|
|
||||||
epic = _issue(
|
|
||||||
631,
|
|
||||||
title="Epic: MCP Control Plane Web Console",
|
|
||||||
body=_EPIC_631_BODY,
|
|
||||||
)
|
|
||||||
res = self._alloc([epic], apply=True)
|
|
||||||
self.assertTrue(res["success"], res)
|
|
||||||
# Only container present → no safe work; never assigned_work.
|
|
||||||
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"]}
|
|
||||||
self.assertEqual(
|
|
||||||
skipped[631]["reason_code"], SKIP_EPIC_OR_CHILD_ONLY_CONTAINER
|
|
||||||
)
|
|
||||||
# No lease row for the epic.
|
|
||||||
leases = self.db.list_active_leases(
|
|
||||||
remote=REMOTE, org=ORG, repo=REPO
|
|
||||||
) if hasattr(self.db, "list_active_leases") else []
|
|
||||||
# Prefer generic inventory if available.
|
|
||||||
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.assertNotEqual(work_number, 631)
|
|
||||||
|
|
||||||
def test_incidental_epic_title_remains_eligible(self) -> None:
|
|
||||||
ordinary = _issue(
|
|
||||||
700,
|
|
||||||
title="Document epic handoff conventions",
|
|
||||||
body="Write runbook text about epic vs child issues.",
|
|
||||||
)
|
|
||||||
res = self._alloc([ordinary], apply=False)
|
|
||||||
self.assertTrue(res["success"], res)
|
|
||||||
self.assertEqual(res["selected"]["number"], 700)
|
|
||||||
self.assertEqual(res["skipped"], [])
|
|
||||||
|
|
||||||
def test_apply_selects_child_not_epic(self) -> None:
|
|
||||||
epic = _issue(
|
|
||||||
631,
|
|
||||||
title="Epic: MCP Control Plane Web Console",
|
|
||||||
body=_EPIC_631_BODY,
|
|
||||||
)
|
|
||||||
child = _issue(
|
|
||||||
637,
|
|
||||||
title="Web Console: Workflow-event timeline model (Phase 1)",
|
|
||||||
body=_CHILD_BODY,
|
|
||||||
)
|
|
||||||
res = self._alloc([epic, 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)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""Integration tests for autonomous canonical handoffs and dependency-aware task orchestration (#628).
|
||||||
|
|
||||||
|
Verifies the 21 acceptance criteria specified in umbrella Issue #628:
|
||||||
|
- Non-terminal stage handoff generation and retrieval
|
||||||
|
- Multi-worker concurrency and exclusive task assignment isolation
|
||||||
|
- Structured dependency graph integration with the work allocator
|
||||||
|
- Head SHA invalidation and stale review decision protection
|
||||||
|
"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from canonical_thread_handoff import (
|
||||||
|
format_cth_body,
|
||||||
|
parse_cth_comment,
|
||||||
|
assess_cth_comment,
|
||||||
|
)
|
||||||
|
import dependency_graph
|
||||||
|
from control_plane_db import ControlPlaneDB
|
||||||
|
from allocator_service import (
|
||||||
|
WorkCandidate,
|
||||||
|
classify_skip,
|
||||||
|
ROLE_AUTHOR,
|
||||||
|
ROLE_REVIEWER,
|
||||||
|
ROLE_MERGER,
|
||||||
|
ROLE_RECONCILER,
|
||||||
|
OWNERSHIP_OWN,
|
||||||
|
OWNERSHIP_FOREIGN,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue628Orchestration(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.db_path = os.path.join(self._tmp.name, "cp.sqlite3")
|
||||||
|
self.db = ControlPlaneDB(self.db_path)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def test_canonical_handoff_serialization_and_retrieval(self):
|
||||||
|
"""AC1 & AC2: Every non-terminal stage stores and retrieves a valid canonical handoff."""
|
||||||
|
handoff = format_cth_body(
|
||||||
|
cth_type="Author Handoff",
|
||||||
|
status="completed",
|
||||||
|
next_owner="reviewer",
|
||||||
|
current_blocker="none",
|
||||||
|
decision="Implementation complete, tests passing",
|
||||||
|
proof="pytest tests/test_issue_628_orchestration.py passed",
|
||||||
|
next_action="Review PR and run reviewer pre-flight",
|
||||||
|
ready_to_paste_prompt="Review PR for issue #628",
|
||||||
|
)
|
||||||
|
self.assertIn("CTH: Author Handoff", handoff)
|
||||||
|
|
||||||
|
parsed = parse_cth_comment(handoff)
|
||||||
|
self.assertIsNotNone(parsed)
|
||||||
|
self.assertEqual(parsed["cth_type"], "Author Handoff")
|
||||||
|
|
||||||
|
assessment = assess_cth_comment(handoff)
|
||||||
|
self.assertFalse(assessment["block"])
|
||||||
|
|
||||||
|
def test_exclusive_task_unit_single_owner(self):
|
||||||
|
"""AC5 & AC6: Concurrency isolation ensures an exclusive task unit has only one active owner."""
|
||||||
|
candidate = WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=628,
|
||||||
|
title="Umbrella #628 test candidate",
|
||||||
|
state="open",
|
||||||
|
labels=["status:in-progress"],
|
||||||
|
blocked=False,
|
||||||
|
dependency_unmet=False,
|
||||||
|
)
|
||||||
|
# Foreign ownership MUST be skipped
|
||||||
|
skip_foreign = classify_skip(
|
||||||
|
c=candidate,
|
||||||
|
role=ROLE_AUTHOR,
|
||||||
|
terminal_pr=None,
|
||||||
|
claim_ownership=OWNERSHIP_FOREIGN,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(skip_foreign)
|
||||||
|
self.assertIn("active lease", skip_foreign)
|
||||||
|
|
||||||
|
# Own/Self claim remains selectable for session resumption
|
||||||
|
skip_self = classify_skip(
|
||||||
|
c=candidate,
|
||||||
|
role=ROLE_AUTHOR,
|
||||||
|
terminal_pr=None,
|
||||||
|
claim_ownership=OWNERSHIP_OWN,
|
||||||
|
)
|
||||||
|
self.assertIsNone(skip_self)
|
||||||
|
|
||||||
|
def test_durable_dependency_graph_blocking(self):
|
||||||
|
"""AC8, AC9, AC10: Durable dependency edges exclude blocked tasks from assignment."""
|
||||||
|
# Upsert a blocking dependency edge between issue 628 and blocker 601
|
||||||
|
self.db.upsert_dependency_edge(
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
source_kind="issue",
|
||||||
|
source_number=628,
|
||||||
|
target_kind="issue",
|
||||||
|
target_number=601,
|
||||||
|
edge_type=dependency_graph.EDGE_ISSUE_BLOCKED_BY_ISSUE,
|
||||||
|
state=dependency_graph.STATE_UNMET,
|
||||||
|
blocking_condition="Target issue #601 is not closed",
|
||||||
|
completion_condition="Target issue #601 is closed",
|
||||||
|
evidence={"source": "unit_test"},
|
||||||
|
)
|
||||||
|
|
||||||
|
edges = self.db.list_dependency_edges(
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
source_kind="issue",
|
||||||
|
source_number=628,
|
||||||
|
)
|
||||||
|
self.assertEqual(len(edges), 1)
|
||||||
|
self.assertEqual(edges[0]["state"], "unmet")
|
||||||
|
self.assertEqual(edges[0]["target_number"], 601)
|
||||||
|
|
||||||
|
# When dependency is unmet, candidate is blocked from selection
|
||||||
|
candidate = WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=628,
|
||||||
|
title="Blocked candidate",
|
||||||
|
state="open",
|
||||||
|
labels=[],
|
||||||
|
blocked=False,
|
||||||
|
dependency_unmet=True,
|
||||||
|
dependency_reason="issue#628 is blocked by unmet dependency issue#601",
|
||||||
|
)
|
||||||
|
skip_reason = classify_skip(
|
||||||
|
c=candidate,
|
||||||
|
role=ROLE_AUTHOR,
|
||||||
|
terminal_pr=None,
|
||||||
|
claim_ownership=OWNERSHIP_OWN,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(skip_reason)
|
||||||
|
self.assertIn("issue#601", skip_reason)
|
||||||
|
|
||||||
|
def test_dependency_completion_reevaluation(self):
|
||||||
|
"""AC11: Dependency completion updates edge state to MET."""
|
||||||
|
self.db.upsert_dependency_edge(
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
source_kind="issue",
|
||||||
|
source_number=628,
|
||||||
|
target_kind="issue",
|
||||||
|
target_number=601,
|
||||||
|
edge_type=dependency_graph.EDGE_ISSUE_BLOCKED_BY_ISSUE,
|
||||||
|
state=dependency_graph.STATE_UNMET,
|
||||||
|
blocking_condition="Target issue #601 is open",
|
||||||
|
completion_condition="Target issue #601 is closed",
|
||||||
|
evidence={"source": "unit_test"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark edge as met upon target issue closure
|
||||||
|
self.db.upsert_dependency_edge(
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
source_kind="issue",
|
||||||
|
source_number=628,
|
||||||
|
target_kind="issue",
|
||||||
|
target_number=601,
|
||||||
|
edge_type=dependency_graph.EDGE_ISSUE_BLOCKED_BY_ISSUE,
|
||||||
|
state=dependency_graph.STATE_MET,
|
||||||
|
blocking_condition="Target issue #601 is open",
|
||||||
|
completion_condition="Target issue #601 is closed",
|
||||||
|
evidence={"source": "target_closed_event"},
|
||||||
|
)
|
||||||
|
|
||||||
|
edges = self.db.list_dependency_edges(
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
source_kind="issue",
|
||||||
|
source_number=628,
|
||||||
|
)
|
||||||
|
self.assertEqual(len(edges), 1)
|
||||||
|
self.assertEqual(edges[0]["state"], "met")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user