Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion daemon/gsd_daemon/probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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*(.*)$")
Expand All @@ -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 = (
Expand Down Expand Up @@ -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] = []
Expand All @@ -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"
Expand Down
55 changes: 55 additions & 0 deletions tests/test_daemon_attention.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading