2026-07-11 18:49:18 +08:00
|
|
|
"""实时信号引擎:多品种 / 多周期并发调度。
|
|
|
|
|
|
|
|
|
|
- 每个「监控项」= (数据源, 品种, 周期, 策略因子)。
|
|
|
|
|
- 后台线程按周期自适应节奏轮询,出现新 bar 才重算,信号取最后已收盘 bar。
|
|
|
|
|
- 共享 (源,品种,周期) 的 K 线抓取结果做短 TTL 缓存,避免重复请求。
|
|
|
|
|
- 监控清单持久化到 web_settings,重启恢复。
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import threading
|
|
|
|
|
import time
|
|
|
|
|
from collections import deque
|
|
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
from model_core.vocab import VOCAB_VERSION
|
|
|
|
|
from strategy_manager.live_signal import evaluate_signal, min_exposure
|
|
|
|
|
from web.data_sources.base import bars_to_raw_dict
|
|
|
|
|
from web.data_sources.factory import SOURCE_KINDS, get_source
|
|
|
|
|
from web.settings import load_settings, save_settings
|
|
|
|
|
|
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
|
|
|
|
# 每个周期的轮询节奏(秒)
|
|
|
|
|
_CADENCE = {
|
|
|
|
|
"1m": 15, "5m": 30, "15m": 45, "30m": 60,
|
|
|
|
|
"1h": 60, "4h": 120, "1d": 300, "1w": 600, "1M": 600,
|
|
|
|
|
}
|
2026-07-12 00:22:48 +08:00
|
|
|
# K 线周期长度(秒);用于推算「下一根已收盘 bar」时间
|
|
|
|
|
_TF_SECONDS = {
|
|
|
|
|
"1m": 60,
|
|
|
|
|
"5m": 300,
|
|
|
|
|
"15m": 900,
|
|
|
|
|
"30m": 1800,
|
|
|
|
|
"1h": 3600,
|
|
|
|
|
"4h": 14400,
|
|
|
|
|
"1d": 86400,
|
|
|
|
|
"1w": 604800,
|
|
|
|
|
"1M": 2592000, # 近似 30 天
|
|
|
|
|
}
|
2026-07-11 18:49:18 +08:00
|
|
|
_DEFAULT_CADENCE = 60
|
|
|
|
|
_N_BARS = 500 # 每次拉取的历史 bar 数(喂给特征引擎)
|
|
|
|
|
_HISTORY_LEN = 60 # 保留的信号强度历史点数(供 sparkline)
|
|
|
|
|
_VALID_KINDS = {k for k, _ in SOURCE_KINDS}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cadence_for(tf: str) -> int:
|
|
|
|
|
return _CADENCE.get(tf, _DEFAULT_CADENCE)
|
|
|
|
|
|
|
|
|
|
|
2026-07-12 00:22:48 +08:00
|
|
|
def _next_bar_close_at(last_bar_open: int | None, timeframe: str, now: float | None = None) -> int | None:
|
|
|
|
|
"""根据最后已收盘 bar 的开盘时间,推算下次收盘(即下次信号更新)的 Unix 秒。
|
|
|
|
|
|
|
|
|
|
若最后一根已收盘 bar 已过时太久(超过约 2 个周期仍无新 bar),视为休市/断档,
|
|
|
|
|
返回 None,避免在周末等时段虚构「几分钟后更新」的倒计时。
|
|
|
|
|
"""
|
|
|
|
|
if last_bar_open is None:
|
|
|
|
|
return None
|
|
|
|
|
period = _TF_SECONDS.get(timeframe)
|
|
|
|
|
if not period:
|
|
|
|
|
return None
|
|
|
|
|
now_i = int(now if now is not None else time.time())
|
|
|
|
|
last_open = int(last_bar_open)
|
|
|
|
|
last_close = last_open + period
|
|
|
|
|
# 仍未到收盘(常见于 MT5 终端时钟快于本机、或未剔除形成中 bar)
|
|
|
|
|
if last_close > now_i:
|
|
|
|
|
return last_close
|
|
|
|
|
# 正常交易中:上一根收盘距今至多约 1 个周期;再放宽到 2 个周期容错拉取延迟
|
|
|
|
|
if now_i - last_close > period * 2:
|
|
|
|
|
return None
|
|
|
|
|
# last_open 开盘 → last_close 收盘;当前形成中的 bar 在 +2*period 收盘
|
|
|
|
|
nxt = last_open + 2 * period
|
|
|
|
|
while nxt <= now_i:
|
|
|
|
|
nxt += period
|
|
|
|
|
if nxt - last_close > period * 2:
|
|
|
|
|
return None
|
|
|
|
|
return nxt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ensure_closed_bars(bars: list, timeframe: str, now: float | None = None) -> list:
|
2026-07-15 23:18:36 +08:00
|
|
|
"""按本机时钟剔掉时间戳仍在未来的 K 线(双保险,防数据源时钟偏快)。
|
|
|
|
|
|
|
|
|
|
各数据源的 fetch_bars(drop_forming=True) 已负责剔除「正在形成」的 bar;
|
|
|
|
|
这里只删 ts > now 的明显异常 bar(数据源时钟偏快导致返回了未来 bar)。
|
|
|
|
|
|
|
|
|
|
不再用 `ts + period > now` 判断:该式假设 ts=开盘时刻,对 A 股日线(15:00
|
|
|
|
|
收盘但 period 按 86400 秒算)会在收盘当晚误删当天已收盘 bar——因为
|
|
|
|
|
ts+86400 落到次日,恒大于当晚的 now。改用 ts>now 后,已收盘 bar 的 ts
|
|
|
|
|
必然 <= now,不会被误删;而真正未收盘/未来的 bar 由各源 drop_forming 处理。
|
|
|
|
|
"""
|
|
|
|
|
if not bars:
|
2026-07-12 00:22:48 +08:00
|
|
|
return bars
|
|
|
|
|
now_i = int(now if now is not None else time.time())
|
|
|
|
|
out = list(bars)
|
2026-07-15 23:18:36 +08:00
|
|
|
while out and int(out[-1].ts) > now_i:
|
2026-07-12 00:22:48 +08:00
|
|
|
out.pop()
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 18:49:18 +08:00
|
|
|
def _load_strategy_meta(path: str) -> dict[str, Any]:
|
|
|
|
|
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
|
|
|
if isinstance(data, list):
|
|
|
|
|
return {"formula": data, "vocab_version": "legacy", "symbol": None, "timeframe": None, "best_score": None}
|
|
|
|
|
return {
|
|
|
|
|
"formula": data.get("formula"),
|
|
|
|
|
"vocab_version": data.get("vocab_version"),
|
|
|
|
|
"symbol": data.get("symbol"),
|
|
|
|
|
"timeframe": data.get("timeframe"),
|
|
|
|
|
"best_score": data.get("best_score"),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class WatchTask:
|
|
|
|
|
id: str
|
|
|
|
|
source: str
|
|
|
|
|
symbol: str
|
|
|
|
|
timeframe: str
|
|
|
|
|
strategy_file: str
|
|
|
|
|
strategy_name: str
|
|
|
|
|
formula: list[int]
|
|
|
|
|
vocab_version: str | None
|
|
|
|
|
strategy_symbol: str | None
|
|
|
|
|
strategy_timeframe: str | None
|
|
|
|
|
best_score: float | None
|
|
|
|
|
cadence_s: int
|
|
|
|
|
# 运行时状态
|
|
|
|
|
state: str = "pending" # pending|ok|insufficient|error
|
|
|
|
|
direction: str | None = None
|
|
|
|
|
strength: float | None = None
|
|
|
|
|
position: float | None = None
|
|
|
|
|
factor_value: float | None = None
|
|
|
|
|
bars_used: int | None = None
|
|
|
|
|
last_bar_ts: int | None = None
|
|
|
|
|
updated_at: float | None = None
|
|
|
|
|
message: str = ""
|
|
|
|
|
warn: str = ""
|
|
|
|
|
next_due: float = 0.0
|
|
|
|
|
history: deque = field(default_factory=lambda: deque(maxlen=_HISTORY_LEN))
|
|
|
|
|
|
|
|
|
|
def to_public(self) -> dict[str, Any]:
|
2026-07-12 00:22:48 +08:00
|
|
|
now = time.time()
|
|
|
|
|
next_close = _next_bar_close_at(self.last_bar_ts, self.timeframe, now)
|
|
|
|
|
live = next_close is not None
|
2026-07-11 18:49:18 +08:00
|
|
|
return {
|
|
|
|
|
"id": self.id,
|
|
|
|
|
"source": self.source,
|
|
|
|
|
"symbol": self.symbol,
|
|
|
|
|
"timeframe": self.timeframe,
|
|
|
|
|
"strategy_name": self.strategy_name,
|
|
|
|
|
"strategy_symbol": self.strategy_symbol,
|
|
|
|
|
"strategy_timeframe": self.strategy_timeframe,
|
|
|
|
|
"best_score": self.best_score,
|
|
|
|
|
"state": self.state,
|
|
|
|
|
"direction": self.direction,
|
|
|
|
|
"strength": self.strength,
|
|
|
|
|
"position": self.position,
|
|
|
|
|
"factor_value": self.factor_value,
|
|
|
|
|
"bars_used": self.bars_used,
|
|
|
|
|
"last_bar_ts": self.last_bar_ts,
|
2026-07-12 00:22:48 +08:00
|
|
|
"session_live": live,
|
|
|
|
|
"next_bar_close_at": next_close,
|
|
|
|
|
"seconds_to_next": (
|
|
|
|
|
max(0, int(next_close - now)) if next_close is not None else None
|
|
|
|
|
),
|
2026-07-11 18:49:18 +08:00
|
|
|
"updated_at": self.updated_at,
|
|
|
|
|
"message": self.message,
|
2026-07-14 19:08:12 +08:00
|
|
|
"tv_blocked": self.message == "TV_CONNECTIVITY_BLOCKED",
|
2026-07-11 18:49:18 +08:00
|
|
|
"warn": self.warn,
|
|
|
|
|
"threshold": min_exposure(),
|
|
|
|
|
"history": list(self.history),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def persist_dict(self) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"id": self.id,
|
|
|
|
|
"source": self.source,
|
|
|
|
|
"symbol": self.symbol,
|
|
|
|
|
"timeframe": self.timeframe,
|
|
|
|
|
"strategy_file": self.strategy_file,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RealtimeManager:
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
self._tasks: dict[str, WatchTask] = {}
|
|
|
|
|
self._lock = threading.RLock()
|
|
|
|
|
self._running = False
|
|
|
|
|
self._thread: threading.Thread | None = None
|
|
|
|
|
self._executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="rt")
|
|
|
|
|
self._inflight: set[str] = set()
|
|
|
|
|
self._inflight_lock = threading.Lock()
|
|
|
|
|
# K线缓存:(kind,symbol,tf) -> (monotonic_ts, bars)
|
|
|
|
|
self._bar_cache: dict[tuple[str, str, str], tuple[float, list]] = {}
|
|
|
|
|
self._loaded = False
|
2026-07-14 19:08:12 +08:00
|
|
|
self._tv_blocked_until = 0.0
|
2026-07-11 18:49:18 +08:00
|
|
|
|
|
|
|
|
# ── 持久化 ──────────────────────────────────────────────────────────
|
|
|
|
|
def load_persisted(self) -> None:
|
|
|
|
|
if self._loaded:
|
|
|
|
|
return
|
|
|
|
|
self._loaded = True
|
|
|
|
|
watches = load_settings().get("realtime_watches") or []
|
|
|
|
|
for w in watches:
|
|
|
|
|
try:
|
|
|
|
|
self._add_task_internal(
|
|
|
|
|
w["source"], w["symbol"], w["timeframe"], w["strategy_file"],
|
|
|
|
|
persist=False,
|
|
|
|
|
)
|
|
|
|
|
except Exception:
|
|
|
|
|
continue
|
|
|
|
|
if self._tasks:
|
|
|
|
|
self._ensure_thread()
|
|
|
|
|
|
|
|
|
|
def _persist(self) -> None:
|
|
|
|
|
save_settings({"realtime_watches": [t.persist_dict() for t in self._tasks.values()]})
|
|
|
|
|
|
|
|
|
|
# ── 增删 ────────────────────────────────────────────────────────────
|
|
|
|
|
def add_watch(self, source: str, symbol: str, timeframe: str, strategy_file: str) -> dict[str, Any]:
|
|
|
|
|
task = self._add_task_internal(source, symbol, timeframe, strategy_file, persist=True)
|
|
|
|
|
self._ensure_thread()
|
|
|
|
|
return task.to_public()
|
|
|
|
|
|
|
|
|
|
def _add_task_internal(
|
|
|
|
|
self, source: str, symbol: str, timeframe: str, strategy_file: str, persist: bool
|
|
|
|
|
) -> WatchTask:
|
|
|
|
|
source = (source or "").strip()
|
|
|
|
|
symbol = (symbol or "").strip()
|
|
|
|
|
timeframe = (timeframe or "").strip()
|
|
|
|
|
if source not in _VALID_KINDS:
|
|
|
|
|
raise ValueError(f"未知数据源: {source}")
|
|
|
|
|
if not symbol:
|
|
|
|
|
raise ValueError("请填写品种")
|
|
|
|
|
src = get_source(source)
|
|
|
|
|
if timeframe not in src.supported_timeframes():
|
|
|
|
|
raise ValueError(f"{src.label} 不支持周期 {timeframe}")
|
|
|
|
|
|
|
|
|
|
path = strategy_file
|
|
|
|
|
if not Path(path).is_absolute():
|
|
|
|
|
path = str((PROJECT_ROOT / path).resolve())
|
|
|
|
|
if not Path(path).exists():
|
|
|
|
|
raise ValueError(f"策略文件不存在: {strategy_file}")
|
|
|
|
|
meta = _load_strategy_meta(path)
|
|
|
|
|
if not meta.get("formula"):
|
|
|
|
|
raise ValueError("策略文件缺少 formula")
|
|
|
|
|
|
|
|
|
|
name = Path(path).stem
|
|
|
|
|
task_id = f"{source}:{symbol}:{timeframe}:{name}"
|
|
|
|
|
|
|
|
|
|
warn = ""
|
|
|
|
|
if meta.get("vocab_version") and meta["vocab_version"] not in (VOCAB_VERSION, "legacy"):
|
|
|
|
|
warn = f"词表版本不符({meta['vocab_version']} vs {VOCAB_VERSION}),信号可能失真"
|
|
|
|
|
elif meta.get("symbol") and meta["symbol"] != symbol:
|
|
|
|
|
warn = f"该因子为 {meta['symbol']} 训练,跨品种运行仅供参考"
|
|
|
|
|
|
|
|
|
|
task = WatchTask(
|
|
|
|
|
id=task_id,
|
|
|
|
|
source=source,
|
|
|
|
|
symbol=symbol,
|
|
|
|
|
timeframe=timeframe,
|
|
|
|
|
strategy_file=path,
|
|
|
|
|
strategy_name=name,
|
|
|
|
|
formula=[int(t) for t in meta["formula"]],
|
|
|
|
|
vocab_version=meta.get("vocab_version"),
|
|
|
|
|
strategy_symbol=meta.get("symbol"),
|
|
|
|
|
strategy_timeframe=meta.get("timeframe"),
|
|
|
|
|
best_score=meta.get("best_score"),
|
|
|
|
|
cadence_s=_cadence_for(timeframe),
|
|
|
|
|
warn=warn,
|
|
|
|
|
next_due=0.0,
|
|
|
|
|
)
|
|
|
|
|
with self._lock:
|
|
|
|
|
self._tasks[task_id] = task
|
|
|
|
|
if persist:
|
|
|
|
|
self._persist()
|
|
|
|
|
return task
|
|
|
|
|
|
|
|
|
|
def remove_watch(self, task_id: str) -> bool:
|
|
|
|
|
with self._lock:
|
|
|
|
|
existed = self._tasks.pop(task_id, None) is not None
|
|
|
|
|
if existed:
|
|
|
|
|
self._persist()
|
|
|
|
|
return existed
|
|
|
|
|
|
|
|
|
|
def clear(self) -> None:
|
|
|
|
|
with self._lock:
|
|
|
|
|
self._tasks.clear()
|
|
|
|
|
self._persist()
|
|
|
|
|
|
|
|
|
|
# ── 状态 ────────────────────────────────────────────────────────────
|
|
|
|
|
def status(self) -> dict[str, Any]:
|
|
|
|
|
with self._lock:
|
|
|
|
|
watches = [t.to_public() for t in self._tasks.values()]
|
2026-07-12 00:22:48 +08:00
|
|
|
nearest = None
|
|
|
|
|
for w in watches:
|
|
|
|
|
s = w.get("seconds_to_next")
|
|
|
|
|
if s is None:
|
|
|
|
|
continue
|
|
|
|
|
if nearest is None or s < nearest:
|
|
|
|
|
nearest = s
|
|
|
|
|
return {
|
|
|
|
|
"running": self._running,
|
|
|
|
|
"count": len(watches),
|
|
|
|
|
"watches": watches,
|
|
|
|
|
"server_time": time.time(),
|
|
|
|
|
"nearest_seconds_to_next": nearest,
|
|
|
|
|
}
|
2026-07-11 18:49:18 +08:00
|
|
|
|
|
|
|
|
# ── 调度线程 ────────────────────────────────────────────────────────
|
|
|
|
|
def start(self) -> None:
|
|
|
|
|
self._ensure_thread()
|
|
|
|
|
|
|
|
|
|
def _ensure_thread(self) -> None:
|
|
|
|
|
with self._lock:
|
|
|
|
|
if self._running and self._thread and self._thread.is_alive():
|
|
|
|
|
return
|
|
|
|
|
self._running = True
|
|
|
|
|
self._thread = threading.Thread(target=self._loop, name="realtime", daemon=True)
|
|
|
|
|
self._thread.start()
|
|
|
|
|
|
|
|
|
|
def stop(self) -> None:
|
|
|
|
|
self._running = False
|
|
|
|
|
|
|
|
|
|
def _loop(self) -> None:
|
|
|
|
|
while self._running:
|
|
|
|
|
try:
|
|
|
|
|
self._tick()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
time.sleep(1.5)
|
|
|
|
|
|
|
|
|
|
def _tick(self) -> None:
|
|
|
|
|
now = time.monotonic()
|
|
|
|
|
with self._lock:
|
|
|
|
|
due = [t for t in self._tasks.values() if now >= t.next_due]
|
|
|
|
|
for task in due:
|
|
|
|
|
with self._inflight_lock:
|
|
|
|
|
if task.id in self._inflight:
|
|
|
|
|
continue
|
|
|
|
|
self._inflight.add(task.id)
|
|
|
|
|
# 预置下次到期,避免重复提交
|
|
|
|
|
task.next_due = now + task.cadence_s
|
|
|
|
|
self._executor.submit(self._evaluate_task, task)
|
|
|
|
|
|
|
|
|
|
def _get_bars(self, source: str, symbol: str, timeframe: str):
|
|
|
|
|
"""带短 TTL 缓存的 K 线抓取(同一 源/品种/周期 的多因子复用)。"""
|
|
|
|
|
key = (source, symbol, timeframe)
|
|
|
|
|
ttl = max(10.0, _cadence_for(timeframe) * 0.8)
|
|
|
|
|
now = time.monotonic()
|
|
|
|
|
cached = self._bar_cache.get(key)
|
|
|
|
|
if cached and (now - cached[0]) < ttl:
|
|
|
|
|
return cached[1]
|
|
|
|
|
src = get_source(source)
|
|
|
|
|
bars = src.fetch_bars(symbol, timeframe, _N_BARS, drop_forming=True)
|
2026-07-12 00:22:48 +08:00
|
|
|
bars = _ensure_closed_bars(bars, timeframe)
|
2026-07-11 18:49:18 +08:00
|
|
|
self._bar_cache[key] = (now, bars)
|
|
|
|
|
return bars
|
|
|
|
|
|
|
|
|
|
def _evaluate_task(self, task: WatchTask) -> None:
|
|
|
|
|
try:
|
|
|
|
|
bars = self._get_bars(task.source, task.symbol, task.timeframe)
|
|
|
|
|
if not bars:
|
|
|
|
|
self._set_error(task, "未获取到 K 线")
|
|
|
|
|
return
|
|
|
|
|
last_ts = bars[-1].ts
|
|
|
|
|
raw = bars_to_raw_dict(bars)
|
|
|
|
|
result = evaluate_signal(task.formula, raw)
|
|
|
|
|
|
|
|
|
|
task.state = result.get("state", "error")
|
|
|
|
|
task.message = result.get("message", "")
|
|
|
|
|
task.bars_used = result.get("bars_used", len(bars))
|
|
|
|
|
task.last_bar_ts = last_ts
|
|
|
|
|
task.updated_at = time.time()
|
|
|
|
|
if task.state == "ok":
|
2026-07-12 00:22:48 +08:00
|
|
|
new_dir = result["direction"]
|
|
|
|
|
prev_dir = task.direction
|
|
|
|
|
task.direction = new_dir
|
2026-07-11 18:49:18 +08:00
|
|
|
task.strength = result["strength"]
|
|
|
|
|
task.position = result["position"]
|
|
|
|
|
task.factor_value = result["factor_value"]
|
|
|
|
|
task.history.append(round(result["strength"], 4))
|
2026-07-12 00:22:48 +08:00
|
|
|
# 已有上次方向且发生转折时推飞书(首次算出方向不打扰)
|
|
|
|
|
if prev_dir and new_dir and prev_dir != new_dir:
|
|
|
|
|
try:
|
|
|
|
|
from web.feishu_notify import notify_direction_flip
|
|
|
|
|
|
|
|
|
|
notify_direction_flip(
|
|
|
|
|
symbol=task.symbol,
|
|
|
|
|
timeframe=task.timeframe,
|
|
|
|
|
strategy_name=task.strategy_name,
|
|
|
|
|
prev_direction=prev_dir,
|
|
|
|
|
new_direction=new_dir,
|
|
|
|
|
strength=task.strength,
|
|
|
|
|
factor_value=task.factor_value,
|
|
|
|
|
)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2026-07-11 18:49:18 +08:00
|
|
|
except Exception as exc: # noqa: BLE001
|
2026-07-14 19:08:12 +08:00
|
|
|
msg = str(exc)
|
|
|
|
|
if task.source == "tradingview":
|
|
|
|
|
try:
|
|
|
|
|
from web.data_sources.tradingview_connectivity import (
|
|
|
|
|
TV_CONNECTIVITY_BLOCKED,
|
|
|
|
|
check_tradingview_connectivity,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
now_m = time.monotonic()
|
|
|
|
|
if now_m < self._tv_blocked_until:
|
|
|
|
|
msg = TV_CONNECTIVITY_BLOCKED
|
|
|
|
|
else:
|
|
|
|
|
ok, _detail = check_tradingview_connectivity(
|
|
|
|
|
timeout_s=12.0, max_attempts=1, retry_delay_s=0.0
|
|
|
|
|
)
|
|
|
|
|
if not ok:
|
|
|
|
|
self._tv_blocked_until = now_m + 120.0
|
|
|
|
|
msg = TV_CONNECTIVITY_BLOCKED
|
|
|
|
|
else:
|
|
|
|
|
self._tv_blocked_until = 0.0
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
self._set_error(task, msg)
|
2026-07-11 18:49:18 +08:00
|
|
|
finally:
|
|
|
|
|
with self._inflight_lock:
|
|
|
|
|
self._inflight.discard(task.id)
|
|
|
|
|
|
|
|
|
|
def _set_error(self, task: WatchTask, message: str) -> None:
|
|
|
|
|
task.state = "error"
|
|
|
|
|
task.message = message
|
|
|
|
|
task.updated_at = time.time()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
realtime_manager = RealtimeManager()
|