From 23e2316dff22b354dee3fe3b456ebe2ad4fdb84e Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 18 Jun 2026 15:13:08 +0200 Subject: [PATCH] Harden coding retro resolver selection --- .../context_resolvers/state_hub.py | 69 ++++++++++++++++++- tests/test_state_hub_context_resolver.py | 66 +++++++++++++++++- .../ACTIVITY-WP-0008-weekly-coding-retro.md | 17 ++++- 3 files changed, 148 insertions(+), 4 deletions(-) diff --git a/src/activity_core/context_resolvers/state_hub.py b/src/activity_core/context_resolvers/state_hub.py index 38d80ed..1af86ba 100644 --- a/src/activity_core/context_resolvers/state_hub.py +++ b/src/activity_core/context_resolvers/state_hub.py @@ -219,11 +219,13 @@ def _coding_retro(params: dict[str, Any]) -> dict[str, Any]: """ event_type = str(params.get("event_type") or "coding_retro") limit = _bounded_int(params.get("limit", 100), default=100, minimum=1, maximum=500) - items = _fetch_json("/progress/", {"limit": limit}) + query_params = {"event_type": event_type, "limit": limit} + items = _fetch_json("/progress/", query_params) if not isinstance(items, list): return _empty_coding_retro(event_type) - item = _latest_progress_item(items, event_type) + window_days = _optional_int(params.get("window_days")) + item = _latest_progress_item(items, event_type, window_days) if item is None: return _empty_coding_retro(event_type) @@ -256,12 +258,18 @@ def _empty_coding_retro(event_type: str) -> dict[str, Any]: def _latest_progress_item( items: list[Any], event_type: str, + window_days: int | None = None, ) -> dict[str, Any] | None: newest: dict[str, Any] | None = None newest_key: tuple[datetime, int] | None = None for index, item in enumerate(items): if not isinstance(item, dict) or item.get("event_type") != event_type: continue + if window_days is not None and not _progress_matches_window_days( + item, + window_days, + ): + continue key = (_parse_progress_timestamp(item.get("created_at")), index) if newest_key is None or key > newest_key: newest = item @@ -295,6 +303,56 @@ def _progress_detail(item: dict[str, Any]) -> dict[str, Any]: return {} +def _progress_matches_window_days(item: dict[str, Any], window_days: int) -> bool: + detail = _progress_detail(item) + return _progress_window_days(detail) == window_days + + +def _progress_window_days(detail: dict[str, Any]) -> int | None: + window = detail.get("window") + if isinstance(window, dict): + direct = _optional_int(window.get("days") or window.get("window_days")) + if direct is not None: + return direct + ranged = _window_days_from_range( + window.get("since") or window.get("window_start"), + window.get("until") or window.get("window_end"), + ) + if ranged is not None: + return ranged + + direct = _optional_int(detail.get("days") or detail.get("window_days")) + if direct is not None: + return direct + return _window_days_from_range( + detail.get("since") or detail.get("window_start"), + detail.get("until") or detail.get("window_end"), + ) + + +def _window_days_from_range(start: Any, end: Any) -> int | None: + start_ts = _parse_optional_timestamp(start) + end_ts = _parse_optional_timestamp(end) + if start_ts is None or end_ts is None or end_ts < start_ts: + return None + seconds = (end_ts - start_ts).total_seconds() + if seconds <= 0: + return None + return max(1, round(seconds / 86400)) + + +def _parse_optional_timestamp(value: Any) -> datetime | None: + if not isinstance(value, str) or not value: + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + def _normalise_coding_retro_suggestions(value: Any) -> list[dict[str, Any]]: if not isinstance(value, list): return [] @@ -374,6 +432,13 @@ def _bounded_int(value: Any, *, default: int, minimum: int, maximum: int) -> int return max(minimum, min(maximum, number)) +def _optional_int(value: Any) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None + + def _clean_scalar(value: Any) -> str: return " ".join(str(value or "").split()) diff --git a/tests/test_state_hub_context_resolver.py b/tests/test_state_hub_context_resolver.py index da5383e..70a744e 100644 --- a/tests/test_state_hub_context_resolver.py +++ b/tests/test_state_hub_context_resolver.py @@ -215,6 +215,29 @@ def test_coding_retro_returns_latest_progress_suggestions(monkeypatch) -> None: ], }, }, + { + "id": "newer-30-day-retro", + "event_type": "coding_retro", + "summary": "monthly coding retro ready", + "created_at": "2026-06-07T17:15:00Z", + "detail": { + "generated_at": "2026-06-07T17:14:30Z", + "window": { + "days": 30, + "since": "2026-05-08T00:00:00Z", + "until": "2026-06-07T00:00:00Z", + }, + "suggestions": [ + { + "repo": "broad-retro-repo", + "title": "Should not displace the weekly retro", + "recommendation": "Keep weekly schedule bounded.", + "priority": "high", + "score": 99, + } + ], + }, + }, ]) monkeypatch.setenv("STATE_HUB_URL", "http://state-hub.test/") @@ -229,7 +252,7 @@ def test_coding_retro_returns_latest_progress_suggestions(monkeypatch) -> None: assert calls == [ { "url": "http://state-hub.test/progress/", - "params": {"limit": 20}, + "params": {"event_type": "coding_retro", "limit": 20}, "timeout": 10.0, } ] @@ -251,6 +274,47 @@ def test_coding_retro_returns_latest_progress_suggestions(monkeypatch) -> None: ] +def test_coding_retro_returns_empty_when_window_does_not_match(monkeypatch) -> None: + def fake_get(url: str, **kwargs: Any) -> DummyResponse: + return DummyResponse([ + { + "id": "monthly-retro", + "event_type": "coding_retro", + "summary": "monthly coding retro ready", + "created_at": "2026-06-07T17:10:00Z", + "detail": { + "window": {"days": 30}, + "suggestions": [ + { + "repo": "activity-core", + "title": "Broad retro item", + "recommendation": "Do not emit from weekly schedule.", + "priority": "high", + "score": 10, + } + ], + }, + } + ]) + + monkeypatch.setattr(httpx, "get", fake_get) + + result = StateHubContextResolver().resolve( + "coding_retro", + None, + {"event_type": "coding_retro", "window_days": 7}, + ) + + assert result == { + "suggestions": [], + "window": None, + "generated_at": None, + "source_progress_id": None, + "event_type": "coding_retro", + "summary": "", + } + + def test_coding_retro_returns_empty_shape_when_not_published(monkeypatch) -> None: def fake_get(url: str, **kwargs: Any) -> DummyResponse: return DummyResponse([ diff --git a/workplans/ACTIVITY-WP-0008-weekly-coding-retro.md b/workplans/ACTIVITY-WP-0008-weekly-coding-retro.md index 6261b54..794cb3d 100644 --- a/workplans/ACTIVITY-WP-0008-weekly-coding-retro.md +++ b/workplans/ACTIVITY-WP-0008-weekly-coding-retro.md @@ -8,7 +8,7 @@ status: blocked owner: codex topic_slug: custodian created: "2026-06-07" -updated: "2026-06-07" +updated: "2026-06-17" state_hub_workstream_id: "7387fc50-1f2c-471a-9d85-bb085cbd0b63" --- @@ -47,6 +47,12 @@ resolver. It reads recent `/progress/` items, selects the latest `event_type=coding_retro`, normalizes `suggestions[]`, and returns an empty suggestion list while the upstream publisher has not produced a read model yet. +**2026-06-17:** Hardened the resolver lookup after live review found recent +non-retro progress could hide older retro events. The resolver now queries +State Hub with `event_type=coding_retro` and only selects a read model matching +the requested `window_days`, so the weekly schedule cannot accidentally route a +broader 30-day retro batch. + ## `weekly-coding-retro` Activity-Definition ```task @@ -92,3 +98,12 @@ make fix-consistency REPO=activity-core Live State Hub did not yet expose a published `event_type=coding_retro` progress item, so the real dry-run, duplicate check, and `enabled: true` flip remain blocked on `AGENTIC-WP-0010`. + +**2026-06-17:** `AGENTIC-WP-0010` is finished and State Hub has +`coding_retro` progress. A live no-write smoke now resolves the matching weekly +read model `ec20ac1c-ef50-4db4-a5dc-364d31a259a5` +(`generated_at=2026-06-07T19:25:19Z`, `window.days=7`) and emits zero task +specs because that weekly read model has zero suggestions. The schedule remains +disabled until a non-empty weekly read model, or an explicit operator decision +that a zero-suggestion dry-run is an acceptable enablement proof, confirms +correct routing and no duplicate target tasks on re-run.