{
  "sha": "a2b0de46850dc321cec2e40ab65cc4baa5e3b895",
  "message": "Limit persistent news retries and order articles by publication time",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "created_at": "2026-10-02T07:24:32Z",
  "files": [
    {
      "filename": ".github/workflows/hourly.yml",
      "previous_filename": "",
      "status": "modified",
      "additions": 7,
      "deletions": 1,
      "changes": 8
    },
    {
      "filename": "pipeline/build.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 15,
      "deletions": 15,
      "changes": 30
    },
    {
      "filename": "pipeline/dedupe.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 11,
      "deletions": 1,
      "changes": 12
    },
    {
      "filename": "pipeline/news_retry.py",
      "previous_filename": "",
      "status": "added",
      "additions": 117,
      "deletions": 0,
      "changes": 117
    },
    {
      "filename": "pipeline/write.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 23,
      "deletions": 11,
      "changes": 34
    },
    {
      "filename": "templates/article.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 1,
      "deletions": 1,
      "changes": 2
    },
    {
      "filename": "tests/test_dedupe.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 12,
      "deletions": 0,
      "changes": 12
    },
    {
      "filename": "tests/test_news_chronology.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 8,
      "deletions": 5,
      "changes": 13
    },
    {
      "filename": "tests/test_news_freshness.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 3,
      "deletions": 2,
      "changes": 5
    },
    {
      "filename": "tests/test_news_retry.py",
      "previous_filename": "",
      "status": "added",
      "additions": 66,
      "deletions": 0,
      "changes": 66
    },
    {
      "filename": "tests/test_write_offline.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 37,
      "deletions": 0,
      "changes": 37
    }
  ],
  "diff": "@@ -19,6 +19,7 @@ on:\\n       - \"pipeline/dedupe.py\"\\n       - \"pipeline/fulltext.py\"\\n       - \"pipeline/draft_recovery.py\"\\n+      - \"pipeline/news_retry.py\"\\n       - \"pipeline/manual_draft.py\"\\n       - \"pipeline/airport_codes.py\"\\n       - \"config/airport_iata_aliases.json\"\\n@@ -62,6 +63,8 @@ jobs:\\n           python tests/test_provider_health.py\\n           python tests/test_build_resilience.py\\n           python tests/test_news_freshness.py\\n+          python tests/test_news_retry.py\\n+          python tests/test_news_chronology.py\\n \\n   update:\\n     if: github.event_name != 'pull_request'\\n@@ -91,7 +94,10 @@ jobs:\\n         run: python tests/test_airport_codes.py\\n \\n       - name: Test news freshness invariants\\n-        run: python tests/test_news_freshness.py\\n+        run: |\\n+          python tests/test_news_freshness.py\\n+          python tests/test_news_retry.py\\n+          python tests/test_news_chronology.py\\n \\n       - name: Test provider cooldown persistence\\n         run: python tests/test_provider_health.py\\n@@ -1219,34 +1219,32 @@ def side(own, other):\\n         selected_image = None\\n     image = normalize_image(selected_image, titles=[\\n         (raw.get(lang) or {}).get(\"title\") for lang in (\"zh\", \"en\")])\\n-    # Display the SOURCE's newest publication time when the write stage\\n-    # recorded one. Chronological feeds use this same timestamp so a late\\n-    # generated article cannot place an older source above newer news.\\n+    # Feeds use first site publication; source chronology stays separate.\\n     display_dt = publication_dt\\n+    source_dt = None\\n     if raw.get(\"sourcePublishedUtc\"):\\n         try:\\n-            display_dt = parse_iso(str(raw[\"sourcePublishedUtc\"]))\\n+            source_dt = parse_iso(str(raw[\"sourcePublishedUtc\"]))\\n         except (ValueError, TypeError):\\n             pass\\n     tpe = display_dt.astimezone(TPE)\\n     publication_tpe = publication_dt.astimezone(TPE)\\n     modified_tpe = modified_dt.astimezone(TPE)\\n     late_ingest = bool(\\n-        raw.get(\"sourcePublishedUtc\")\\n-        and publication_dt - display_dt >= timedelta(hours=24)\\n+        source_dt and publication_dt - source_dt >= timedelta(hours=24)\\n     )\\n     today_tpe = now_utc().astimezone(TPE).date()\\n     # Date-only source stamps (FAA/CAA give no clock time -> 00:00Z) must\\n     # not render as a precise-looking \"08:00\" TPE: show the date instead.\\n-    date_only = (raw.get(\"sourcePublishedUtc\")\\n-                 and display_dt.hour == 0 and display_dt.minute == 0)\\n-    if date_only:\\n-        time_label = tpe.strftime(\"%m/%d\")\\n-        meta_ts = tpe.strftime(\"%Y-%m-%d\")\\n-    else:\\n-        time_label = (clock_12(tpe) if tpe.date() == today_tpe\\n-                      else tpe.strftime(\"%m/%d\"))\\n-        meta_ts = f\"{tpe:%Y-%m-%d} {clock_12(tpe)} UTC+8\"\\n+    source_meta_ts = None\\n+    if source_dt:\\n+        source_tpe = source_dt.astimezone(TPE)\\n+        source_meta_ts = (source_tpe.strftime(\"%Y-%m-%d\")\\n+                          if source_dt.hour == 0 and source_dt.minute == 0 else\\n+                          f\"{source_tpe:%Y-%m-%d} {clock_12(source_tpe)} UTC+8\")\\n+    time_label = (clock_12(tpe) if tpe.date() == today_tpe\\n+                  else tpe.strftime(\"%m/%d\"))\\n+    meta_ts = f\"{tpe:%Y-%m-%d} {clock_12(tpe)} UTC+8\"\\n     return {\\n         \"id\": art_id,\\n         \"dt\": dt,\\n@@ -1266,6 +1264,7 @@ def side(own, other):\\n         \"source\": str(raw.get(\"primarySource\") or (sources[0][\"name\"] if sources else \"—\")),\\n         \"time\": time_label,\\n         \"meta_ts\": meta_ts,\\n+        \"source_meta_ts\": source_meta_ts,\\n         \"zh\": side(zh, en),\\n         \"en\": side(en, zh),\\n         \"sources\": sources,\\n@@ -1448,6 +1447,7 @@ def art_view(a, lang: str):\\n         id=a[\"id\"], cat=a[\"cat\"], tag_class=a[\"tag_class\"], image=a[\"image\"],\\n         cat_label=_bi(CATS.get(a[\"cat\"]), lang, a[\"cat\"]),\\n         source=a[\"source\"], time=a[\"time\"], meta_ts=a[\"meta_ts\"],\\n+        source_meta_ts=a[\"source_meta_ts\"],\\n         sources=a[\"sources\"], url=page_url(lang, f\"news/{a['id']}/\"),\\n         writer_model=a[\"writer_model\"],\\n         article_format=a[\"article_format\"],\\n@@ -11,6 +11,7 @@\\n from __future__ import annotations\\n \\n import re\\n+from news_retry import RetryLedger\\n from datetime import datetime, timedelta, timezone\\n from difflib import SequenceMatcher\\n \\n@@ -425,7 +426,16 @@ def main() -> None:\\n         reverse=True,\\n     )\\n     major_groups = sum(1 for entry in ranked if entry[1])\\n-    selected = ranked[:MAX_GROUPS]\\n+    retry_ledger = RetryLedger(now)\\n+    # Schedule before the discovery cap so cooling or exhausted major\\n+    # stories cannot displace fresh news every hour.\\n+    candidates = [{\"items\": entry[5], \"rank\": entry} for entry in ranked]\\n+    eligible, deferred = retry_ledger.plan(candidates, MAX_GROUPS)\\n+    selected = [g[\"rank\"] for g in eligible]\\n+    # Keep cooling rows visible in pending, within the existing queue cap.\\n+    selected += [g[\"rank\"] for g in deferred\\n+                 if not retry_ledger.exhausted(g)][:MAX_GROUPS - len(selected)]\\n+    retry_ledger.save()\\n     selected_material_groups = sum(1 for entry in selected if entry[2])\\n     selected_major_groups = sum(1 for entry in selected if entry[1])\\n \\n@@ -0,0 +1,117 @@\\n+\"\"\"Persistent, source-keyed news retry budgets across hourly runs.\\n+\\n+No prompts, generated copy or exception text are persisted. Exhausted rows\\n+are the failure register; a genuinely new source URL reopens a story.\\n+\"\"\"\\n+from __future__ import annotations\\n+\\n+import hashlib\\n+from datetime import timedelta\\n+\\n+from common import DATA_DIR, iso_minute, load_json, norm_url, parse_iso, save_json\\n+\\n+MAX_RETRIES_PER_RUN = 2\\n+RETRY_HOURS = (2, 6, 24)\\n+RETENTION_DAYS = 30\\n+\\n+\\n+def source_urls(group):\\n+    return sorted({norm_url(str(item[\"url\"]))\\n+                   for item in group.get(\"items\", [])\\n+                   if isinstance(item, dict) and item.get(\"url\")})\\n+\\n+\\n+class RetryLedger:\\n+    def __init__(self, now):\\n+        self.now = now\\n+        self.path = DATA_DIR / \"news-retry.json\"\\n+        payload = load_json(self.path, {})\\n+        rows = payload.get(\"stories\", {}) if isinstance(payload, dict) else {}\\n+        self.rows = {}\\n+        for key, row in rows.items() if isinstance(rows, dict) else []:\\n+            if not isinstance(row, dict) or not isinstance(row.get(\"sourceUrls\"), list):\\n+                continue\\n+            try:\\n+                updated = parse_iso(str(row.get(\"updatedUtc\")))\\n+                for field in (\"contentFailures\", \"serviceFailures\"):\\n+                    if not isinstance(row.get(field), int) or row[field] < 0:\\n+                        raise ValueError(\"invalid failure counter\")\\n+                if row.get(\"nextRetryUtc\"):\\n+                    parse_iso(str(row[\"nextRetryUtc\"]))\\n+            except (ValueError, TypeError):\\n+                continue\\n+            if updated >= now - timedelta(days=RETENTION_DAYS):\\n+                self.rows[key] = row\\n+\\n+    def save(self):\\n+        save_json(self.path, {\"schemaVersion\": 1, \"stories\": self.rows})\\n+\\n+    def state(self, group):\\n+        urls = set(source_urls(group))\\n+        matches = [(key, row) for key, row in self.rows.items()\\n+                   if urls.intersection(row[\"sourceUrls\"])]\\n+        if not matches:\\n+            return None, None\\n+        # Joining previously separate groups cannot bypass either retry cap.\\n+        key, row = max(matches, key=lambda entry: (\\n+            entry[1].get(\"status\") == \"exhausted\", entry[1][\"contentFailures\"]))\\n+        known = set().union(*(set(r[\"sourceUrls\"]) for _, r in matches))\\n+        if urls - known:\\n+            history = list(row.get(\"history\") or [])[-4:]\\n+            history.append({k: row.get(k) for k in (\\n+                \"status\", \"contentFailures\", \"serviceFailures\", \"updatedUtc\")})\\n+            row = {\"sourceUrls\": sorted(known | urls), \"contentFailures\": 0,\\n+                   \"serviceFailures\": 0, \"status\": \"ready\", \"history\": history,\\n+                   \"updatedUtc\": iso_minute(self.now), \"nextRetryUtc\": None}\\n+            for old_key, _ in matches:\\n+                self.rows.pop(old_key, None)\\n+            self.rows[key] = row\\n+        return key, row\\n+\\n+    def plan(self, groups, limit):\\n+        fresh, retries, deferred = [], [], []\\n+        for group in groups:\\n+            _, row = self.state(group)\\n+            if row is None or not (row[\"contentFailures\"] or row[\"serviceFailures\"]):\\n+                fresh.append(group)\\n+            elif row.get(\"status\") == \"exhausted\" or (\\n+                    row.get(\"nextRetryUtc\") and\\n+                    parse_iso(row[\"nextRetryUtc\"]) > self.now):\\n+                deferred.append(group)\\n+            else:\\n+                retries.append(group)\\n+        retry_count = min(MAX_RETRIES_PER_RUN, len(retries), limit)\\n+        chosen = fresh[:limit - retry_count] + retries[:retry_count]\\n+        # Fill unused capacity with fresh news, never extra old retries.\\n+        deferred += fresh[limit - retry_count:] + retries[retry_count:]\\n+        return chosen, deferred\\n+\\n+    def exhausted(self, group):\\n+        _, row = self.state(group)\\n+        return bool(row and row.get(\"status\") == \"exhausted\")\\n+\\n+    def failure(self, group, *, content):\\n+        key, row = self.state(group)\\n+        urls = source_urls(group)\\n+        if not urls:\\n+            return  # a title alone is not a stable retry identity\\n+        if row is None:\\n+            key = hashlib.sha256(urls[0].encode()).hexdigest()[:24]\\n+            row = {\"sourceUrls\": urls, \"contentFailures\": 0,\\n+                   \"serviceFailures\": 0, \"history\": []}\\n+        row[\"sourceUrls\"] = sorted(set(row[\"sourceUrls\"]) | set(urls))\\n+        field = \"contentFailures\" if content else \"serviceFailures\"\\n+        row[field] += 1\\n+        row[\"updatedUtc\"] = iso_minute(self.now)\\n+        row[\"failureClass\"] = \"content_validation\" if content else \"provider_failure\"\\n+        row[\"status\"] = \"exhausted\" if row[\"contentFailures\"] > len(RETRY_HOURS) else \"waiting\"\\n+        row[\"nextRetryUtc\"] = (None if row[\"status\"] == \"exhausted\" else\\n+            iso_minute(self.now + timedelta(hours=RETRY_HOURS[min(row[field] - 1, 2)])))\\n+        row[\"title\"] = str((group.get(\"items\") or [{}])[0].get(\"title\") or \"\")[:300]\\n+        self.rows[key] = row\\n+\\n+    def complete(self, group):\\n+        urls = set(source_urls(group))\\n+        for key, row in list(self.rows.items()):\\n+            if urls.intersection(row[\"sourceUrls\"]):\\n+                self.rows.pop(key)\\n@@ -38,6 +38,7 @@\\n from pathlib import Path\\n \\n from opencc import OpenCC\\n+import news_retry\\n from draft_recovery import (\\n     normalize_reader_copy, normalize_fact_ids, repair_prompt, draft_timeout, ProviderCallTimeout,\\n )\\n@@ -2011,9 +2012,7 @@ def build_article(draft: dict, group: dict, now, used_ids: set, writer=None,\\n         except (TypeError, ValueError):\\n             continue\\n     if source_dates:\\n-        # The source's own newest publication time - the site displays this,\\n-        # never the generation time, so multi-day-old releases are not\\n-        # presented as breaking news.\\n+        # Preserve source chronology separately from first site publication.\\n         article[\"sourcePublishedUtc\"] = iso_minute(max(source_dates))\\n     elif existing_article and existing_article.get(\"sourcePublishedUtc\"):\\n         article[\"sourcePublishedUtc\"] = existing_article[\"sourcePublishedUtc\"]\\n@@ -2657,6 +2656,8 @@ def main() -> None:\\n             rejected_groups.append(group)\\n             rejected_ids.add(group_id)\\n     groups = scoped_groups\\n+    retry_ledger = news_retry.RetryLedger(now)\\n+    groups, deferred_groups = retry_ledger.plan(groups, MAX_GROUPS_PER_RUN)\\n \\n     # --- 1. retire and migrate the former manual-review queue ------------\\n     # Each row is re-checked with the same deterministic source, binding,\\n@@ -2826,6 +2827,8 @@ def main() -> None:\\n             draft, writer, verified = None, None, None\\n             final_model = None\\n             stopped_by_refusal = False\\n+            content_failed = False\\n+            calls_before = sum(ai_calls.values())\\n             fallback_repair_used = False\\n             group_deadline = min(draft_deadline,\\n                                  time.monotonic() + MAX_DRAFT_SECONDS_PER_GROUP)\\n@@ -2846,6 +2849,7 @@ def main() -> None:\\n                                                         run_trace,\\n                                                         deadline=group_deadline)\\n                 except DraftInvalid:\\n+                    content_failed = True\\n                     if provider is not providers[0]:\\n                         fallback_repair_used = True\\n                     _record_provider_response(\\n@@ -2897,6 +2901,7 @@ def main() -> None:\\n                     # Genuine refusal / safety block: content-based, so\\n                     # don't shop the group around to a laxer provider.\\n                     stopped_by_refusal = True\\n+                    content_failed = True\\n                     final_model = provider.label\\n                     break\\n                 final_model = provider.label\\n@@ -2918,7 +2923,9 @@ def main() -> None:\\n             if group.get(\"id\") in rejected_ids:\\n                 continue\\n             if draft is None:\\n-                skipped += 1  # provider failures/refusals retry next hour\\n+                skipped += 1\\n+                if sum(ai_calls.values()) > calls_before:\\n+                    retry_ledger.failure(group, content=content_failed)\\n                 run_trace.finish(\\n                     \"refused\" if stopped_by_refusal else \"retry_pending\",\\n                     \"refusal\" if stopped_by_refusal else \"retry_pending\",\\n@@ -2933,6 +2940,7 @@ def main() -> None:\\n             run_trace.add_duration(\"articleBuild\", article_started)\\n             if article is None:\\n                 skipped += 1\\n+                retry_ledger.failure(group, content=True)\\n                 run_trace.finish(\\n                     \"retry_pending\", \"article_build_failed\", writer.label)\\n                 continue\\n@@ -2989,15 +2997,17 @@ def main() -> None:\\n         # re-emit from dedupe and burn another API call next hour.\\n         update_seen(published_groups + rejected_groups, now)\\n     consumed = published_ids | rejected_ids\\n-    if consumed:\\n-        # fulltext lives in data/fulltext.json (cache), never in the\\n-        # committed pending file - next run re-attaches it for free\\n-        remaining = [_strip_fulltext(g) for g in groups\\n-                     if g.get(\"id\") not in consumed]\\n-        save_json(PENDING_PATH, {**pending, \"groups\": remaining})\\n+    for group in published_groups + rejected_groups:\\n+        retry_ledger.complete(group)\\n+    # Exhausted stories remain only in the failure register. They are not\\n+    # marked seen: a genuinely new source can reopen them at discovery.\\n+    remaining = [_strip_fulltext(g) for g in groups + deferred_groups\\n+                 if g.get(\"id\") not in consumed and not retry_ledger.exhausted(g)]\\n+    save_json(PENDING_PATH, {**pending, \"groups\": remaining})\\n+    retry_ledger.save()\\n \\n     refresh_stats(now)\\n-    remaining_count = sum(1 for g in groups if g.get(\"id\") not in consumed)\\n+    remaining_count = len(remaining)\\n     stalled = bool(ai_calls and remaining_count and not new_articles\\n                    and not updated_articles)\\n     stats = load_json(STATS_PATH, {})\\n@@ -3006,6 +3016,8 @@ def main() -> None:\\n         \"updatedUtc\": iso_minute(now),\\n         \"published\": len(new_articles), \"revised\": len(updated_articles),\\n         \"pending\": remaining_count,\\n+        \"retryExhausted\": sum(row.get(\"status\") == \"exhausted\"\\n+                              for row in retry_ledger.rows.values()),\\n     }\\n     save_json(STATS_PATH, stats)\\n     if stalled:\\n@@ -20,7 +20,7 @@ <h1 class=\"article-title\">{{ a.title }}</h1>\\n       <div class=\"article-meta text-muted\">\\n         <time datetime=\"{{ a.published_iso }}\" style=\"font-weight:800;color:var(--color-accent);\">{{ t.publishedLabel }}{{ t.metaColon }}{{ a.published_meta_ts }}</time>\\n         {% if a.modified_iso != a.published_iso %}<time datetime=\"{{ a.modified_iso }}\">{{ t.modifiedLabel }}{{ t.metaColon }}{{ a.modified_meta_ts }}</time>{% endif %}\\n-        <span>{{ t.sourceTimeLabel }}{{ t.metaColon }}{{ a.meta_ts }}</span>\\n+        {% if a.source_meta_ts %}<span>{{ t.sourceTimeLabel }}{{ t.metaColon }}{{ a.source_meta_ts }}</span>{% endif %}\\n         <span>{{ t.articleBylineLabel }}{{ t.metaColon }}<a href=\"{{ about_url }}\">{{ t.articleByline }}</a></span>\\n         <span>{{ t.autoCompiled }} · {{ t.mainSource }}: {{ a.source }}</span>\\n         <span style=\"margin-left:auto;\">{{ t.refreshNote }}</span>\\n@@ -284,6 +284,18 @@ def test_group_cap_and_recency_order() -> None:\\n         # Single-source groups rank purely by newest publishedUtc, so the\\n         # 12 newest stories are emitted in recency order.\\n         assert [g[\"items\"][0][\"title\"] for g in groups] == titles[:12]\\n+        # Suppression happens before MAX_GROUPS: blocked stories cannot\\n+        # monopolize discovery or be reset by regenerated group IDs.\\n+        ledger = {\"schemaVersion\": 1, \"stories\": {\\n+            str(i): {\"sourceUrls\": [items[i][\"url\"]], \"contentFailures\": 4,\\n+                     \"serviceFailures\": 0, \"status\": \"exhausted\",\\n+                     \"updatedUtc\": _stamp(hours=0), \"nextRetryUtc\": None}\\n+            for i in range(12)\\n+        }}\\n+        (data_dir / \"news-retry.json\").write_text(json.dumps(ledger), encoding=\"utf-8\")\\n+        _run_dedupe(data_dir)\\n+        remaining = _load_pending(data_dir)[\"groups\"]\\n+        assert [g[\"items\"][0][\"title\"] for g in remaining] == titles[12:]\\n \\n \\n def test_taiwan_airline_story_is_reserved_ahead_of_general_cap() -> None:\\n@@ -38,15 +38,18 @@ def article(id, published, source=None, sort=None):\\n         with patch.object(build, \"ARTICLES_DIR\", folder):\\n             collected = build.collect_articles()\\n     assert [a[\"id\"] for a in collected] == [\\n-        \"newer-manual\", \"no-source-time\", \"older-late-generated\",\\n-        \"invalid-source-time\", \"date-only-source\",\\n+        \"date-only-source\", \"older-late-generated\", \"newer-manual\",\\n+        \"no-source-time\", \"invalid-source-time\",\\n     ]\\n-    assert collected[0][\"meta_ts\"] == \"2026-09-30 4:50 PM UTC+8\"\\n-    assert collected[-1][\"meta_ts\"] == \"2026-09-26\"\\n+    assert collected[0][\"meta_ts\"] == \"2026-10-01 11:00 AM UTC+8\"\\n+    assert collected[0][\"source_meta_ts\"] == \"2026-09-26\"\\n+    assert collected[-1][\"source_meta_ts\"] is None\\n     for prepared in collected:\\n         indexed = build.search_index_item(prepared, [])\\n         assert build.parse_iso(indexed[\"published\"]) == prepared[\"news_dt\"]\\n-    assert collected[0][\"published_iso\"] == \"2026-09-30T08:50:00Z\"\\n+    assert collected[2][\"published_iso\"] == \"2026-09-30T08:50:00Z\"\\n+    revised = rows[0] | {\"updatedUtc\": \"2026-10-02T08:00:00Z\"}\\n+    assert build.prep_article(revised)[\"news_dt\"] == collected[1][\"news_dt\"]\\n \\n \\n if __name__ == \"__main__\":\\n@@ -41,13 +41,14 @@ def main() -> None:\\n         assert source[\"type\"] == \"rss\"\\n         assert \"news.google.com/rss/search\" in source[\"endpoint\"]\\n \\n-    # Source publication time drives reader-facing chronology.\\n+    # First site publication drives reader-facing chronology.\\n     late = build.prep_article(_article(\\n         published=\"2026-10-01T12:00Z\",\\n         source_published=\"2026-09-29T10:00Z\",\\n     ))\\n     assert late is not None\\n-    assert late[\"news_dt\"].isoformat().startswith(\"2026-09-29T10:00\")\\n+    assert late[\"news_dt\"].isoformat().startswith(\"2026-10-01T12:00\")\\n+    assert late[\"source_meta_ts\"].startswith(\"2026-09-29\")\\n     assert late[\"published_dt\"].isoformat().startswith(\"2026-10-01T12:00\")\\n     assert late[\"late_ingest\"] is True\\n \\n@@ -0,0 +1,66 @@\\n+\"\"\"Persistent story retry budgets without API calls or production writes.\"\"\"\\n+import sys\\n+import tempfile\\n+from datetime import datetime, timedelta, timezone\\n+from pathlib import Path\\n+from unittest.mock import patch\\n+\\n+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / \"pipeline\"))\\n+import news_retry\\n+\\n+\\n+def story(n, extra=False):\\n+    urls = [f\"https://example.com/news/{n}\"]\\n+    if extra:\\n+        urls.append(f\"https://official.example.com/news/{n}\")\\n+    return {\"id\": str(n), \"items\": [{\"url\": u, \"title\": \"Test news\"} for u in urls]}\\n+\\n+\\n+def main():\\n+    now = datetime(2026, 10, 1, tzinfo=timezone.utc)\\n+    with tempfile.TemporaryDirectory() as tmp, patch.object(news_retry, \"DATA_DIR\", Path(tmp)):\\n+        ledger = news_retry.RetryLedger(now)\\n+        group = story(1)\\n+        for failure, hours in enumerate((2, 6, 24), 1):\\n+            ledger.failure(group, content=True)\\n+            ledger.save()\\n+            ledger = news_retry.RetryLedger(now)\\n+            assert ledger.state(group)[1][\"contentFailures\"] == failure\\n+            chosen, deferred = ledger.plan([group | {\"id\": \"regenerated\"}], 10)\\n+            assert not chosen and len(deferred) == 1\\n+            now += timedelta(hours=hours)\\n+            ledger = news_retry.RetryLedger(now)\\n+            assert ledger.plan([group], 10)[0] == [group]\\n+        ledger.failure(group, content=True)\\n+        ledger.save()\\n+        ledger = news_retry.RetryLedger(now + timedelta(days=1))\\n+        assert ledger.exhausted(group)\\n+        assert not ledger.plan([group], 10)[0]\\n+        renewed = story(1, extra=True)\\n+        assert ledger.plan([renewed], 10)[0] == [renewed]\\n+        assert ledger.state(renewed)[1][\"contentFailures\"] == 0\\n+        assert not ledger.exhausted(group)\\n+        ledger.failure(renewed, content=True)\\n+        ledger.complete(renewed)\\n+        assert not ledger.rows\\n+\\n+        for n in range(5):\\n+            ledger.failure(story(n), content=False)\\n+        ledger.now += timedelta(hours=2)\\n+        fresh = [story(n) for n in range(10, 22)]\\n+        chosen, deferred = ledger.plan([story(n) for n in range(5)] + fresh, 10)\\n+        assert chosen[:8] == fresh[:8]\\n+        assert len(chosen) == 10 and len(deferred) == 7\\n+        assert all(r[\"contentFailures\"] == 0 for r in ledger.rows.values())\\n+        for _ in range(5):\\n+            ledger.failure(story(0), content=False)\\n+        assert not ledger.exhausted(story(0))\\n+        assert ledger.state(story(0))[1][\"nextRetryUtc\"] == news_retry.iso_minute(\\n+            ledger.now + timedelta(hours=24))\\n+        ledger.save()\\n+        assert not news_retry.RetryLedger(ledger.now + timedelta(days=31)).rows\\n+    print(\"PASS retry persistence, 2/6/24h backoff, cap, new sources and provider separation\")\\n+\\n+\\n+if __name__ == \"__main__\":\\n+    main()\\n@@ -1145,8 +1145,45 @@ def draft(self, *args):\\n           \"timed-out story is not marked consumed\")\\n \\n \\n+def test_persistent_story_retry_budget():\\n+    reset_data_dir()\\n+    pending = load(DATA / \"pending.json\")\\n+    group = pending[\"groups\"][0]\\n+    pending[\"groups\"] = [group]\\n+    common.save_json(DATA / \"pending.json\", pending)\\n+    solo = FakeProvider(\"gemini\", [None])\\n+    original_build, original_now = write.build_providers, write.now_utc\\n+    clock = [NOW]\\n+    write.build_providers = lambda: [solo]\\n+    write.now_utc = lambda: clock[0]\\n+    try:\\n+        for hours in (2, 6, 24):\\n+            write.main()\\n+            attempted = solo.calls\\n+            write.main()\\n+            check(solo.calls == attempted, \"cooling story spends zero model calls\")\\n+            clock[0] += timedelta(hours=hours)\\n+        write.main()\\n+        check(solo.calls == 4, \"initial failure plus three retries exhausts the budget\")\\n+        check(not load(DATA / \"pending.json\")[\"groups\"],\\n+              \"exhausted story leaves pending and remains in failure register\")\\n+        check(next(iter(load(DATA / \"news-retry.json\")[\"stories\"].values()))[\"status\"]\\n+              == \"exhausted\", \"exhaustion persists across runs\")\\n+        pending[\"groups\"] = [group | {\"id\": \"rediscovered\"}]\\n+        common.save_json(DATA / \"pending.json\", pending)\\n+        write.main()\\n+        check(solo.calls == 4, \"rediscovery with a new group ID cannot bypass the cap\")\\n+        group[\"items\"].append(group[\"items\"][0] | {\"url\": \"https://official.example.com/new-source\"})\\n+        common.save_json(DATA / \"pending.json\", pending)\\n+        write.main()\\n+        check(solo.calls == 5, \"a new source reopens the story\")\\n+    finally:\\n+        write.build_providers, write.now_utc = original_build, original_now\\n+\\n+\\n def main():\\n     tests = [test_flexible_publication_keeps_evidence_gates,\\n+             test_persistent_story_retry_budget,\\n              test_story_budget_allows_later_groups,\\n              test_budget_saves_completed_articles_and_retains_unattempted_groups,\\n              test_publish_flow,"
}
