{
  "sha": "8c002ae7ee67846b9cd8fd7385195db10916915a",
  "message": "fix: reserve independent repair and time for final Luna fallback (#300)",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "web-flow",
    "name": "",
    "email": ""
  },
  "created_at": "2026-10-13T01:37:13Z",
  "files": [
    {
      "filename": "pipeline/write.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 61,
      "deletions": 8,
      "changes": 69
    },
    {
      "filename": "tests/test_write_offline.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 101,
      "deletions": 0,
      "changes": 101
    }
  ],
  "diff": "@@ -82,6 +82,7 @@\\n MAX_GROUPS_PER_RUN = 10\\n MAX_DRAFT_SECONDS_PER_RUN = 15 * 60\\n MAX_MODEL_ATTEMPT_SECONDS = 60\\n+PREFERRED_ATTEMPT_SECONDS_WITH_LUNA = 30\\n MAX_DRAFT_SECONDS_PER_GROUP = 120\\n PROVIDER_ROUTING_OVERHEAD_SECONDS = 5\\n TRANSIENT_PLATFORM_FAILURE_LIMIT = 2\\n@@ -1484,14 +1485,17 @@ def _normalize_reject_reason(candidate: dict) -> None:\\n             f\"to establish a new event. Model detail: {reason}\")\\n \\n \\n-def _provider_attempt_allowance(provider, deadline: float | None = None) -> float:\\n+def _provider_attempt_allowance(provider, deadline: float | None = None,\\n+                                max_attempt_seconds: float | None = None) -> float:\\n     \"\"\"Return this provider's bounded call budget, clipped to its story time.\"\"\"\\n     allowance = getattr(\\n         provider, \"max_attempt_seconds\", MAX_MODEL_ATTEMPT_SECONDS)\\n     if (isinstance(allowance, bool)\\n             or not isinstance(allowance, (int, float))\\n             or allowance <= 0):\\n         allowance = MAX_MODEL_ATTEMPT_SECONDS\\n+    if max_attempt_seconds is not None:\\n+        allowance = min(allowance, max_attempt_seconds)\\n     if deadline is not None:\\n         allowance = min(allowance, deadline - time.monotonic())\\n     return allowance\\n@@ -1507,9 +1511,34 @@ def _provider_group_budget(provider) -> float:\\n     return budget\\n \\n \\n+def _is_luna_provider(provider) -> bool:\\n+    return (getattr(provider, \"name\", None) == \"openai\"\\n+            and getattr(provider, \"model\", None) == \"gpt-6-luna\")\\n+\\n+\\n+def _luna_reserve_seconds(providers) -> float:\\n+    \"\"\"Reserve one initial draft and one independent repair for final Luna.\"\"\"\\n+    return sum(2 * _provider_attempt_allowance(provider)\\n+               + 2 * PROVIDER_ROUTING_OVERHEAD_SECONDS\\n+               for provider in providers if _is_luna_provider(provider))\\n+\\n+\\n def _provider_chain_budget(providers) -> float:\\n     \"\"\"Leave room for each preferred route to time out before GPT-6 Luna.\"\"\"\\n     providers = tuple(providers)\\n+    luna_reserve = _luna_reserve_seconds(providers)\\n+    if luna_reserve:\\n+        preferred = [provider for provider in providers\\n+                     if not _is_luna_provider(provider)]\\n+        # Existing primary/shared fallback repairs remain bounded, while\\n+        # neither can spend Luna's own repair time.\\n+        preferred_budget = sum(_provider_attempt_allowance(\\n+            provider, max_attempt_seconds=PREFERRED_ATTEMPT_SECONDS_WITH_LUNA)\\n+            for provider in preferred)\\n+        if preferred:\\n+            preferred_budget += 2 * PREFERRED_ATTEMPT_SECONDS_WITH_LUNA\\n+        return luna_reserve + preferred_budget + (\\n+            len(preferred) + 2) * PROVIDER_ROUTING_OVERHEAD_SECONDS\\n     budget = max(\\n         (_provider_group_budget(provider) for provider in providers),\\n         default=MAX_DRAFT_SECONDS_PER_GROUP,\\n@@ -1527,7 +1556,8 @@ def _provider_chain_budget(providers) -> float:\\n \\n def _validated_draft(provider, group: dict, tries: int, ai_calls=None,\\n                      run_trace: _RunTrace | None = None,\\n-                     deadline: float | None = None):\\n+                     deadline: float | None = None,\\n+                     max_attempt_seconds: float | None = None):\\n     \"\"\"Call `provider` up to `tries` times until a draft survives validation\\n     and quote verification.\\n \\n@@ -1556,7 +1586,8 @@ def _validated_draft(provider, group: dict, tries: int, ai_calls=None,\\n                                                 previous_candidate)\\n                 if run_trace is not None:\\n                     run_trace.add_duration(\"promptAssembly\", prompt_started)\\n-                allowance = _provider_attempt_allowance(provider, deadline)\\n+                allowance = _provider_attempt_allowance(\\n+                    provider, deadline, max_attempt_seconds)\\n                 with draft_timeout(allowance):\\n                     candidate = provider.draft(\\n                         SYSTEM_PROMPT, user_prompt, DRAFT_SCHEMA)\\n@@ -2863,6 +2894,10 @@ def main() -> None:\\n     else:\\n         print(\"write: provider order: \"\\n               + \" -> \".join(p.label for p in providers))\\n+        if _luna_reserve_seconds(providers):\\n+            print(\"write: Luna final fallback policy: preferred calls capped \"\\n+                  f\"at {PREFERRED_ATTEMPT_SECONDS_WITH_LUNA}s; independent \"\\n+                  \"draft/repair time reserved\")\\n         try:\\n             # Thin-story companion retrieval: excerpt-only items (e.g.\\n             # Simple Flying) act as a discovery index and the pipeline\\n@@ -2962,6 +2997,11 @@ def main() -> None:\\n                 continue\\n             if drafted >= MAX_GROUPS_PER_RUN:\\n                 break  # LLM budget for this run is spent; the rest waits\\n+            if draft_deadline - time.monotonic() < _luna_reserve_seconds(alive):\\n+                print(\"write: insufficient run time for Luna draft and repair; \"\\n+                      \"retaining remaining groups for retry\")\\n+                run_trace.finish(\"retry_pending\", \"retry_pending\")\\n+                break\\n             drafted += 1\\n             draft, writer, verified = None, None, None\\n             final_model = None\\n@@ -2980,17 +3020,30 @@ def main() -> None:\\n                     # Removed mid-group by a platform-level auth/quota\\n                     # death earlier in this same iteration.\\n                     continue\\n-                # One primary retry and one responsive-fallback repair per\\n-                # group; transport failures do not spend the repair budget.\\n-                tries = 2 if provider is providers[0] or not fallback_repair_used else 1\\n+                is_luna = _is_luna_provider(provider)\\n+                luna_reserve = _luna_reserve_seconds(alive)\\n+                provider_deadline = group_deadline\\n+                attempt_cap = None\\n+                if luna_reserve and not is_luna:\\n+                    provider_deadline -= luna_reserve\\n+                    attempt_cap = PREFERRED_ATTEMPT_SECONDS_WITH_LUNA\\n+                    if time.monotonic() >= provider_deadline:\\n+                        print(f\"write: {provider.label} skipped on group \"\\n+                              f\"{group.get('id')}: preserving Luna draft/repair time\")\\n+                        continue\\n+                # Luna has its own repair, independent of the shared repair\\n+                # for preferred fallbacks. Transport failures still fail over.\\n+                tries = 2 if (is_luna or provider is providers[0]\\n+                              or not fallback_repair_used) else 1\\n                 try:\\n                     candidate, facts = _validated_draft(provider, group,\\n                                                         tries, ai_calls,\\n                                                         run_trace,\\n-                                                        deadline=group_deadline)\\n+                                                        deadline=provider_deadline,\\n+                                                        max_attempt_seconds=attempt_cap)\\n                 except DraftInvalid:\\n                     content_failed = True\\n-                    if provider is not providers[0]:\\n+                    if provider is not providers[0] and not is_luna:\\n                         fallback_repair_used = True\\n                     _record_provider_response(\\n                         provider, transient_failure_counts)\\n@@ -16,6 +16,7 @@\\n import sys\\n import tempfile\\n import traceback\\n+from contextlib import contextmanager\\n from datetime import timedelta\\n from pathlib import Path\\n \\n@@ -975,6 +976,104 @@ def test_luna_is_used_after_preferred_service_failures():\\n     print(\"test_luna_is_used_after_preferred_service_failures: done\")\\n \\n \\n+def test_luna_repairs_after_shared_fallback_repair_is_spent():\\n+    \"\"\"A responsive preferred fallback cannot consume Luna's repair.\"\"\"\\n+    for valid_repair in (True, False):\\n+        reset_data_dir()\\n+        pending = load(DATA / \"pending.json\")\\n+        group = pending[\"groups\"][1]\\n+        pending[\"groups\"] = [group]\\n+        common.save_json(DATA / \"pending.json\", pending)\\n+        bad = json.loads(json.dumps(DRAFT_BIZ))\\n+        bad[\"cat\"] = \"not-a-cat\"\\n+        short = json.loads(json.dumps(DRAFT_BIZ))\\n+        short[\"zh\"][\"body\"] = [\"短訊\", \"短訊\"]\\n+        fabricated = json.loads(json.dumps(DRAFT_BIZ))\\n+        fabricated[\"facts\"][0][\"sourceQuote\"] = \"This quote is fabricated.\"\\n+        primary = FakeProvider(\"gemini\", [providers.ProviderError(\"HTTP 503\")])\\n+        fallback = FakeProvider(\"wechat\", [bad])\\n+\\n+        class Luna(FakeProvider):\\n+            model = \"gpt-6-luna\"\\n+            max_attempt_seconds = 90\\n+            group_budget_seconds = 180\\n+\\n+            def draft(self, system_prompt, user_prompt, schema):\\n+                self.prompts.append(user_prompt)\\n+                return super().draft(system_prompt, user_prompt, schema)\\n+\\n+        luna = Luna(\"openai\", [short, DRAFT_BIZ if valid_repair else fabricated])\\n+        luna.prompts = []\\n+        original = write.build_providers\\n+        write.build_providers = lambda: [primary, fallback, luna]\\n+        try:\\n+            write.main()\\n+        finally:\\n+            write.build_providers = original\\n+        check(primary.calls == 1 and fallback.calls == 2 and luna.calls == 2,\\n+              \"Luna independently repairs after preferred fallback uses both calls\")\\n+        check(\"body does not meet\" in luna.prompts[1],\\n+              \"Luna repair receives the actual failed body validation\")\\n+        if valid_repair:\\n+            check(len(all_articles()) == 1\\n+                  and all_articles()[0][\"writer\"] == \"openai:fake\",\\n+                  \"a valid Luna repair publishes the formerly short draft\")\\n+        else:\\n+            check(not all_articles() and load(DATA / \"pending.json\")[\"groups\"],\\n+                  \"fabricated Luna repair remains unpublished and retryable\")\\n+            check(common.norm_url(group[\"items\"][0][\"url\"])\\n+                  not in load(DATA / \"seen.json\")[\"urls\"],\\n+                  \"failed Luna repair does not consume the source\")\\n+\\n+\\n+def test_preferred_timeout_preserves_luna_and_run_budget():\\n+    \"\"\"A clipped run deadline still leaves two full Luna calls, then stops.\"\"\"\\n+    reset_data_dir()\\n+    pending = load(DATA / \"pending.json\")\\n+    pending[\"groups\"] = [pending[\"groups\"][1], pending[\"groups\"][0]]\\n+    common.save_json(DATA / \"pending.json\", pending)\\n+    clock, allowances, order = [0.0], [], []\\n+    bad = json.loads(json.dumps(DRAFT_BIZ))\\n+    bad[\"cat\"] = \"not-a-cat\"\\n+\\n+    class TimedProvider(FakeProvider):\\n+        def draft(self, *args):\\n+            order.append(self.name)\\n+            clock[0] += allowances[-1]\\n+            return super().draft(*args)\\n+\\n+    first = TimedProvider(\"gemini\", [providers.ProviderError(\"ReadTimeout\")])\\n+    skipped = TimedProvider(\"wechat\", [DRAFT_BIZ])\\n+    luna = TimedProvider(\"openai\", [bad, DRAFT_BIZ])\\n+    luna.model = \"gpt-6-luna\"\\n+    luna.max_attempt_seconds = 90\\n+    luna.group_budget_seconds = 180\\n+\\n+    @contextmanager\\n+    def fake_timeout(seconds):\\n+        allowances.append(seconds)\\n+        yield\\n+\\n+    original = (write.build_providers, write.time.monotonic,\\n+                write.draft_timeout, write.MAX_DRAFT_SECONDS_PER_RUN)\\n+    write.build_providers = lambda: [first, skipped, luna]\\n+    write.time.monotonic = lambda: clock[0]\\n+    write.draft_timeout = fake_timeout\\n+    write.MAX_DRAFT_SECONDS_PER_RUN = 220\\n+    try:\\n+        write.main()\\n+    finally:\\n+        (write.build_providers, write.time.monotonic,\\n+         write.draft_timeout, write.MAX_DRAFT_SECONDS_PER_RUN) = original\\n+    check(order == [\"gemini\", \"openai\", \"openai\"],\\n+          \"preferred routes yield to final Luna when its reserved time is reached\")\\n+    check(allowances == [30, 90, 90] and clock[0] == 210,\\n+          \"preferred request is capped and Luna gets two full calls within run cap\")\\n+    check(len(all_articles()) == 1\\n+          and len(load(DATA / \"pending.json\")[\"groups\"]) == 1,\\n+          \"completed article is saved; next story waits for a full fallback budget\")\\n+\\n+\\n def test_editorial_gate():\\n     \"\"\"Model reject skips the group; fabricated quotes are dropped/blocking.\"\"\"\\n     reset_data_dir()\\n@@ -1328,6 +1427,8 @@ def main():\\n              test_model_chain_and_routing_policy,\\n              test_provider_specific_time_budget,\\n              test_luna_is_used_after_preferred_service_failures,\\n+             test_luna_repairs_after_shared_fallback_repair_is_spent,\\n+             test_preferred_timeout_preserves_luna_and_run_budget,\\n              test_editorial_gate,\\n              test_completeness_precheck,\\n              test_major_title_only_is_retained_for_material_retry,"
}
