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")
|
event_type = str(params.get("event_type") or "coding_retro")
|
||||||
limit = _bounded_int(params.get("limit", 100), default=100, minimum=1, maximum=500)
|
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):
|
if not isinstance(items, list):
|
||||||
return _empty_coding_retro(event_type)
|
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:
|
if item is None:
|
||||||
return _empty_coding_retro(event_type)
|
return _empty_coding_retro(event_type)
|
||||||
|
|
||||||
@@ -256,12 +258,18 @@ def _empty_coding_retro(event_type: str) -> dict[str, Any]:
|
|||||||
def _latest_progress_item(
|
def _latest_progress_item(
|
||||||
items: list[Any],
|
items: list[Any],
|
||||||
event_type: str,
|
event_type: str,
|
||||||
|
window_days: int | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
newest: dict[str, Any] | None = None
|
newest: dict[str, Any] | None = None
|
||||||
newest_key: tuple[datetime, int] | None = None
|
newest_key: tuple[datetime, int] | None = None
|
||||||
for index, item in enumerate(items):
|
for index, item in enumerate(items):
|
||||||
if not isinstance(item, dict) or item.get("event_type") != event_type:
|
if not isinstance(item, dict) or item.get("event_type") != event_type:
|
||||||
continue
|
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)
|
key = (_parse_progress_timestamp(item.get("created_at")), index)
|
||||||
if newest_key is None or key > newest_key:
|
if newest_key is None or key > newest_key:
|
||||||
newest = item
|
newest = item
|
||||||
@@ -295,6 +303,56 @@ def _progress_detail(item: dict[str, Any]) -> dict[str, Any]:
|
|||||||
return {}
|
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]]:
|
def _normalise_coding_retro_suggestions(value: Any) -> list[dict[str, Any]]:
|
||||||
if not isinstance(value, list):
|
if not isinstance(value, list):
|
||||||
return []
|
return []
|
||||||
@@ -374,6 +432,13 @@ def _bounded_int(value: Any, *, default: int, minimum: int, maximum: int) -> int
|
|||||||
return max(minimum, min(maximum, number))
|
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:
|
def _clean_scalar(value: Any) -> str:
|
||||||
return " ".join(str(value or "").split())
|
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/")
|
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 == [
|
assert calls == [
|
||||||
{
|
{
|
||||||
"url": "http://state-hub.test/progress/",
|
"url": "http://state-hub.test/progress/",
|
||||||
"params": {"limit": 20},
|
"params": {"event_type": "coding_retro", "limit": 20},
|
||||||
"timeout": 10.0,
|
"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 test_coding_retro_returns_empty_shape_when_not_published(monkeypatch) -> None:
|
||||||
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
|
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
|
||||||
return DummyResponse([
|
return DummyResponse([
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ status: blocked
|
|||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: custodian
|
topic_slug: custodian
|
||||||
created: "2026-06-07"
|
created: "2026-06-07"
|
||||||
updated: "2026-06-07"
|
updated: "2026-06-17"
|
||||||
state_hub_workstream_id: "7387fc50-1f2c-471a-9d85-bb085cbd0b63"
|
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
|
`event_type=coding_retro`, normalizes `suggestions[]`, and returns an empty
|
||||||
suggestion list while the upstream publisher has not produced a read model yet.
|
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
|
## `weekly-coding-retro` Activity-Definition
|
||||||
|
|
||||||
```task
|
```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
|
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
|
item, so the real dry-run, duplicate check, and `enabled: true` flip remain
|
||||||
blocked on `AGENTIC-WP-0010`.
|
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