Harden coding retro resolver selection

This commit is contained in:
2026-06-18 15:13:08 +02:00
parent 206bb336d2
commit 23e2316dff
3 changed files with 148 additions and 4 deletions

View File

@@ -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())

View File

@@ -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([

View File

@@ -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.