diff --git a/daemon/gsd_daemon/probe.py b/daemon/gsd_daemon/probe.py index 23c9ba34..ef1d76ab 100644 --- a/daemon/gsd_daemon/probe.py +++ b/daemon/gsd_daemon/probe.py @@ -37,7 +37,7 @@ re.IGNORECASE | re.MULTILINE) REVIEW_CYCLE_RE = re.compile(r"^Cycle:\s*(\d+)", re.MULTILINE) REVIEW_DEPTH_RE = re.compile(r"^Depth:\s*([A-Za-z-]+)", re.MULTILINE) -WAVE_REVIEW_FILE_RE = re.compile(r"^wave-\d+\.cycle\d+(?:\.[a-z]+)?\.md$") +WAVE_REVIEW_FILE_RE = re.compile(r"^wave-(\d+)\.cycle(\d+)(\.[a-z]+)?\.md$") GAP_REVIEW_FILE_RE = re.compile(r"^final-gap-\d+\.md$") CRITERION_HEADING_RE = re.compile(r"^###\s+(.+?)\s*$", re.MULTILINE) CRITERION_ID_RE = re.compile(r"^(SC\d+)\s*—\s*(.*)$") @@ -50,6 +50,7 @@ STALE_AFTER_S = 12 * 3600 NEGATIVE_REVIEW_VERDICTS = {"blocked", "fail", "failed", "not-met", "unverifiable"} +WAVE_VERDICTS = {"pass", "blocked"} # the values the runtime accepts for a wave review NEGATIVE_CRITERION_VERDICTS = {"not-met", "unverifiable"} STATE_KEYS = ( @@ -677,6 +678,22 @@ def _human_age(seconds: float) -> str: return f"{max(0, int(seconds // 3600))}h" +def _superseded_reviews(reviews: List[dict]) -> set: + """Wave review files that a later cycle of the same wave and lens replaces. + + Only a cycle with a valid wave verdict replaces an older one: a review that + is still being written, with no verdict or an empty verdict line, does not. + """ + cycles: Dict[tuple, Dict[int, object]] = {} + for review in reviews: + match = WAVE_REVIEW_FILE_RE.match(str(review.get("file"))) + if match and str(review.get("verdict")).lower() in WAVE_VERDICTS: + wave, cycle, lens = match.groups() + cycles.setdefault((int(wave), lens), {})[int(cycle)] = review.get("file") + return {name for found in cycles.values() + for cycle, name in found.items() if cycle < max(found)} + + def compute_attention(status: ProjectStatus, now: Optional[datetime] = None) -> List[dict]: items: List[dict] = [] @@ -690,7 +707,10 @@ def compute_attention(status: ProjectStatus, if task.status and "blocked" in task.status.lower(): label = f"{task.id} — {task.title}" if task.title else str(task.id) items.append({"kind": "blocked", "label": label, "ref": task.id}) + superseded = _superseded_reviews(status.reviews) for review in status.reviews: + if review.get("file") in superseded: + continue verdict = review.get("verdict") if isinstance(verdict, str) and verdict.lower() in NEGATIVE_REVIEW_VERDICTS: name = review.get("file") or "review" diff --git a/tests/test_daemon_attention.py b/tests/test_daemon_attention.py index 9077c3ff..943fe9d7 100644 --- a/tests/test_daemon_attention.py +++ b/tests/test_daemon_attention.py @@ -234,6 +234,61 @@ def test_passing_reviews_are_not_attention(self) -> None: self.assertEqual(self.kinds(status), ["unverified"]) self.assertEqual(status.health, "amber") + def write_wave_reviews(self, *reviews: tuple) -> None: + review_dir = self.root / ".project" / "review" + review_dir.mkdir() + for wave, cycle, lens, verdict in reviews: + (review_dir / f"wave-{wave}.cycle{cycle}{lens}.md").write_bytes( + (f"# Review — wave {wave}, cycle {cycle}\n\n" + + (f"Wave verdict: {verdict}\n" if verdict else "") + + f"Cycle: {cycle}\n").encode("utf-8"), + ) + + def failed_refs(self) -> list: + status = probe.probe_project(self.root) + return [item["ref"] for item in status.attention if item["kind"] == "failed"] + + def test_blocked_cycle_superseded_by_a_passed_cycle_is_not_attention(self) -> None: + make_project(self.root) + self.write_wave_reviews((1, 1, "", "blocked"), (1, 2, "", "pass")) + self.assertEqual(self.failed_refs(), []) + # The earlier cycle stays in the review history. + files = [review["file"] for review in probe.probe_project(self.root).reviews] + self.assertEqual(files, ["wave-1.cycle1.md", "wave-1.cycle2.md"]) + + def test_blocked_newest_cycle_is_one_failed_row_per_wave(self) -> None: + make_project(self.root) + self.write_wave_reviews( + (1, 1, "", "blocked"), (1, 2, "", "blocked"), + (2, 1, "", "blocked"), (10, 1, "", "pass"), + ) + self.assertEqual(self.failed_refs(), ["wave-1.cycle2.md", "wave-2.cycle1.md"]) + + def test_deep_review_cycles_are_superseded_per_lens(self) -> None: + make_project(self.root) + self.write_wave_reviews( + (1, 1, ".contract", "blocked"), (1, 1, ".adversarial", "blocked"), + (1, 2, ".adversarial", "pass"), + ) + self.assertEqual(self.failed_refs(), ["wave-1.cycle1.contract.md"]) + + def test_newer_cycle_without_a_verdict_keeps_the_blocked_cycle(self) -> None: + make_project(self.root) + self.write_wave_reviews((1, 1, "", "blocked"), (1, 2, "", None)) + self.assertEqual(self.failed_refs(), ["wave-1.cycle1.md"]) + self.assertEqual(probe.probe_project(self.root).health, "red") + + def test_newer_cycle_with_an_empty_verdict_line_keeps_the_blocked_cycle(self) -> None: + make_project(self.root) + # "Wave verdict:" with no value: the verdict pattern reads the next line's first word. + self.write_wave_reviews((1, 1, "", "blocked"), (1, 2, "", " ")) + self.assertEqual(self.failed_refs(), ["wave-1.cycle1.md"]) + + def test_review_cycles_compare_as_numbers(self) -> None: + make_project(self.root) + self.write_wave_reviews((1, 9, "", "blocked"), (1, 10, "", "pass")) + self.assertEqual(self.failed_refs(), []) + def test_stale_active_project_amber(self) -> None: make_project(self.root) age_tree(self.root / ".project", seconds=3 * 86400)