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