generated from coulomb/repo-seed
Harden coding retro resolver selection
This commit is contained in:
@@ -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())
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user