2026-10-20 TUE UTC 12:42 PM TPE 8:42 PM Auto-updates hourly 中文 EN
LIVE WIRE
← Back to the site changelog Site Changelog

Original commit record

Limit persistent news retries and order articles by publication time

SHA
a2b0de46850dc321cec2e40ab65cc4baa5e3b895
Commit date
Author
nnneeeooo-nnneeeooo
Committer
nnneeeooo-nnneeeooo

Changed files (11)

  • .github/workflows/hourly.yml modified · +7 / −1
  • pipeline/build.py modified · +15 / −15
  • pipeline/dedupe.py modified · +11 / −1
  • pipeline/news_retry.py added · +117 / −0
  • pipeline/write.py modified · +23 / −11
  • templates/article.html modified · +1 / −1
  • tests/test_dedupe.py modified · +12 / −0
  • tests/test_news_chronology.py modified · +8 / −5
  • tests/test_news_freshness.py modified · +3 / −2
  • tests/test_news_retry.py added · +66 / −0
  • tests/test_write_offline.py modified · +37 / −0

Full 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,

You found a secret theme!

8-bit mode is unlocked. Refresh to add it to your theme selector.