2026-10-16 15:47:10 +08:00
|
|
|
"""Dual live-submit gate. Default: dry-run / blocked.
|
|
|
|
|
|
|
|
|
|
Live is allowed only when ALL of:
|
|
|
|
|
- ALLOW_LIVE_SUBMIT=1
|
|
|
|
|
- LIVE_CONFIRM=I_CONFIRM_LIVE_SUBMIT
|
|
|
|
|
- STRATEGY_ID present
|
|
|
|
|
|
2026-10-16 19:00:15 +08:00
|
|
|
Phase2 client exists but requires explicit injection. No automatic wiring.
|
|
|
|
|
Queue ≥95 blocks; at most one injected submit per process.
|
2026-10-16 15:47:10 +08:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import os
|
2026-10-16 19:00:15 +08:00
|
|
|
import threading
|
|
|
|
|
from .queue_depth import queue_depth
|
2026-10-16 15:47:10 +08:00
|
|
|
from dataclasses import dataclass
|
|
|
|
|
from typing import Any, Callable, Dict, Optional, Protocol
|
|
|
|
|
|
|
|
|
|
from .strategy import strategy_id_present
|
|
|
|
|
|
2026-10-16 19:00:15 +08:00
|
|
|
_submit_count = 0
|
|
|
|
|
_submit_lock = threading.Lock()
|
|
|
|
|
|
2026-10-16 15:47:10 +08:00
|
|
|
LIVE_CONFIRM_VALUE = "I_CONFIRM_LIVE_SUBMIT"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SubmitClient(Protocol):
|
|
|
|
|
"""Minimal interface for a submit client (injected fake in tests)."""
|
|
|
|
|
|
|
|
|
|
def submit(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
|
|
...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class GateDecision:
|
|
|
|
|
mode: str # "dry-run" | "live" | "blocked"
|
|
|
|
|
allowed: bool
|
|
|
|
|
reason: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _allow_live_flag() -> bool:
|
|
|
|
|
return os.environ.get("ALLOW_LIVE_SUBMIT", "0").strip() == "1"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _live_confirm_ok() -> bool:
|
|
|
|
|
return os.environ.get("LIVE_CONFIRM", "").strip() == LIVE_CONFIRM_VALUE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def evaluate_gate() -> GateDecision:
|
|
|
|
|
"""Evaluate whether live submit is permitted."""
|
|
|
|
|
if not strategy_id_present():
|
|
|
|
|
return GateDecision(
|
|
|
|
|
mode="blocked",
|
|
|
|
|
allowed=False,
|
|
|
|
|
reason="STRATEGY_ID missing or empty",
|
|
|
|
|
)
|
|
|
|
|
if not _allow_live_flag():
|
|
|
|
|
return GateDecision(
|
|
|
|
|
mode="dry-run",
|
|
|
|
|
allowed=False,
|
|
|
|
|
reason="ALLOW_LIVE_SUBMIT is not 1 (default dry-run)",
|
|
|
|
|
)
|
|
|
|
|
if not _live_confirm_ok():
|
|
|
|
|
return GateDecision(
|
|
|
|
|
mode="blocked",
|
|
|
|
|
allowed=False,
|
|
|
|
|
reason=f"LIVE_CONFIRM must be exactly {LIVE_CONFIRM_VALUE!r}",
|
|
|
|
|
)
|
2026-10-16 19:00:15 +08:00
|
|
|
depth = queue_depth()
|
|
|
|
|
if depth is None and ("ASYNC_QUEUE_DEPTH" in os.environ or "QUEUE_SNAPSHOT_JSON" in os.environ):
|
|
|
|
|
return GateDecision("blocked", False, "queue depth invalid/unknown; human confirmation required")
|
|
|
|
|
if depth is not None and depth >= 95:
|
|
|
|
|
return GateDecision("blocked", False, "queue ≥95: 禁 live")
|
2026-10-16 15:47:10 +08:00
|
|
|
return GateDecision(
|
|
|
|
|
mode="live",
|
|
|
|
|
allowed=True,
|
|
|
|
|
reason="dual gate open (ALLOW_LIVE_SUBMIT=1 and LIVE_CONFIRM set)",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def attempt_live_submit(
|
|
|
|
|
payload: Dict[str, Any],
|
|
|
|
|
client: Optional[SubmitClient] = None,
|
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
|
"""Attempt a live submit only if the gate opens and a client is injected.
|
|
|
|
|
|
2026-10-16 19:00:15 +08:00
|
|
|
Phase2 TaskAddClient must be explicitly injected and dual-gated.
|
|
|
|
|
Tests must use fake clients or mock HTTP only.
|
2026-10-16 15:47:10 +08:00
|
|
|
"""
|
2026-10-16 19:00:15 +08:00
|
|
|
global _submit_count
|
2026-10-16 15:47:10 +08:00
|
|
|
decision = evaluate_gate()
|
|
|
|
|
if not decision.allowed:
|
|
|
|
|
return {
|
|
|
|
|
"ok": False,
|
|
|
|
|
"mode": decision.mode,
|
|
|
|
|
"reason": decision.reason,
|
|
|
|
|
"submitted": False,
|
|
|
|
|
}
|
|
|
|
|
if client is None:
|
|
|
|
|
return {
|
|
|
|
|
"ok": False,
|
|
|
|
|
"mode": "blocked",
|
|
|
|
|
"reason": (
|
2026-10-16 19:00:15 +08:00
|
|
|
"Phase2 task/add client exists; explicit client injection required; "
|
|
|
|
|
"no real request without injection and dual gate"
|
2026-10-16 15:47:10 +08:00
|
|
|
),
|
|
|
|
|
"submitted": False,
|
|
|
|
|
}
|
2026-10-16 19:00:15 +08:00
|
|
|
with _submit_lock:
|
|
|
|
|
if _submit_count >= 1:
|
|
|
|
|
return dict(ok=False, mode="blocked", submitted=False,
|
|
|
|
|
reason="process limit: at most 1 real add")
|
|
|
|
|
# Reserve before invocation: a transport failure may still have created a task.
|
|
|
|
|
_submit_count += 1
|
2026-10-16 15:47:10 +08:00
|
|
|
result = client.submit(payload)
|
|
|
|
|
return {
|
|
|
|
|
"ok": True,
|
|
|
|
|
"mode": "live",
|
|
|
|
|
"reason": decision.reason,
|
|
|
|
|
"submitted": True,
|
|
|
|
|
"result": result,
|
|
|
|
|
}
|