From 0264053b085f1c9e8e9ca8db89cd8e8063f81c59 Mon Sep 17 00:00:00 2001 From: RRRosmontis Date: Sat, 19 Sep 2026 16:45:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20systemd=20?= =?UTF-8?q?=E5=AE=9A=E6=97=B6=E6=89=A7=E8=A1=8C=E4=B8=8E=E6=96=AD=E7=82=B9?= =?UTF-8?q?=E7=BB=AD=E8=B7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend/server.py: 信令 WebSocket 被远端关闭或视频流停滞超过阈值时自动拆除会话, 由 finally 释放网易云端实例,避免会话空挂、后续 /start 一直拿到 503 - bin/maa-online-daily-run: 新增日常任务包装脚本,等待 API 就绪、启动云游戏、 停滞看门狗(连续 ScreencapFailed 或 Disconnected)、失败即释放云会话并最多重试 3 次 - bin/maa-resume-tasks.py: 新增断点续跑规划器,重试时只运行未出现 Completed 的任务, 全部完成则直接判定成功,不再开启新会话;StartUp 永不跳过 - systemd/: 新增 server/daily/update 服务与定时器模板,每天 04:00 起随机 0-2 小时执行 - bin/maa-online-install-systemd: 渲染 __ROOT__ 并安装、启用上述单元 - README.md, user_config/README.md: 补充自动定时执行、断点续跑与已知限制说明 - .gitignore: 忽略生成的 config/tasks/daily-resume.toml --- .gitignore | 1 + README.md | 37 +++++++- backend/server.py | 22 ++++- bin/maa-online-daily-run | 142 +++++++++++++++++++++++++++++ bin/maa-online-install-systemd | 39 ++++++++ bin/maa-resume-tasks.py | 145 ++++++++++++++++++++++++++++++ systemd/maa-daily.service | 14 +++ systemd/maa-daily.timer | 11 +++ systemd/maa-online-server.service | 16 ++++ systemd/maa-update.service | 7 ++ systemd/maa-update.timer | 11 +++ user_config/README.md | 39 ++++++++ 12 files changed, 481 insertions(+), 3 deletions(-) create mode 100755 bin/maa-online-daily-run create mode 100755 bin/maa-online-install-systemd create mode 100755 bin/maa-resume-tasks.py create mode 100644 systemd/maa-daily.service create mode 100644 systemd/maa-daily.timer create mode 100644 systemd/maa-online-server.service create mode 100644 systemd/maa-update.service create mode 100644 systemd/maa-update.timer diff --git a/.gitignore b/.gitignore index af93633..394c8a6 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ backend/.pending-login runtime/ config/profiles/cloud.toml config/tasks/daily.toml +config/tasks/daily-resume.toml user_config/daily.toml user_config/backups/ tests/fake-actions.jsonl diff --git a/README.md b/README.md index 39ed807..0f88c75 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,39 @@ bin/maa-online-update 该脚本更新官方 maa-cli、MaaCore、基础资源和热更新资源。适配代码位于官方管理目录之外,不会被 `maa update` 清理。 +## 自动定时执行 + +仓库自带 systemd 单元模板(`systemd/`)。以 root 执行: + +```bash +sudo bin/maa-online-install-systemd +``` + +脚本会把单元渲染到 `/etc/systemd/system/`(`__ROOT__` 替换为当前检出路径)并启用: + +| 单元 | 作用 | +|---|---| +| `maa-online-server.service` | 云游戏 API 常驻服务(`Restart=always`,启动前清除代理环境变量) | +| `maa-daily.timer` | 每天 04:00 起随机 0–2 小时触发日常任务 | +| `maa-update.timer` | 每周日 03:20 起随机 30 分钟执行更新 | + +日常任务由 `bin/maa-online-daily-run` 驱动:等待 API 就绪 → 启动云游戏 → 在停滞看门狗下执行 +`config/tasks/daily.toml` → 失败则释放云会话并重试。定时器使用 `Persistent=true`,机器重启 +错过触发时间后会自动补跑;同一单元不会并发执行。 + +重试采用**断点续跑**:`bin/maa-resume-tasks.py` 读取前几次尝试的日志,跳过已出现 +`<任务类型> Completed` 的任务,只运行剩余任务;若全部完成则直接判定成功,不再开启新会话。 +`StartUp` 永不跳过,因为每次 `/start` 都是全新的云实例。详见 +[`user_config/README.md`](user_config/README.md#自动定时与断点续跑)。 + +查看运行情况: + +```bash +systemctl list-timers maa-daily.timer maa-update.timer +journalctl -u maa-daily.service -n 50 +journalctl -u maa-online-server.service -n 50 +``` + ## 离线测试 启动假后端: @@ -161,7 +194,9 @@ MaaCore -> adb-cloud -> fake backend: PASS - 云视频压缩与网络抖动会降低 MAA 图像识别稳定性; - 高频截图在低性能服务器上较慢,基建换班可能耗时较长; - Home/ESC 等键值尚未完整验证; -- 断线自动恢复仍有限; +- 云端会主动断开会话(信令 WebSocket close、视频流停滞)。后端会在检测到这类情况时自动拆除 + 会话并释放云端实例,日常包装脚本另有停滞看门狗与最多 3 次断点续跑重试,但仍无法保证每次都跑完; +- 云游戏免费额度通常有限(常见为每天 30 分钟),一次完整日常约 24 分钟,重试会进一步消耗额度; - 后端可能出现少量 H.264 解码丢包警告; - 游戏自动化会真实消耗招聘许可、理智、信用等资源,请先 dry-run 并审阅配置。 diff --git a/backend/server.py b/backend/server.py index a70b7a2..1553a7c 100644 --- a/backend/server.py +++ b/backend/server.py @@ -35,6 +35,7 @@ WIDTH = int(os.getenv("MAA_ONLINE_WIDTH", "1280")) HEIGHT = int(os.getenv("MAA_ONLINE_HEIGHT", "720")) FPS = os.getenv("MAA_ONLINE_FPS", "30") START_WAIT_SECONDS = float(os.getenv("MAA_ONLINE_START_WAIT_SECONDS", "30")) +STALE_LIMIT = float(os.getenv("MAA_ONLINE_STALE_LIMIT_SECONDS", "120")) # --- 颜色定义 --- class Colors: @@ -439,9 +440,26 @@ async def run_cloud_game(): raise RuntimeError("failed to receive the first cloud-game video frame") # Use a cancellable wait instead of Event().wait() - # This allows the task to be properly cancelled by signals + # This allows the task to be properly cancelled by signals. + # Also watch for a dead cloud session: if the remote signaling + # websocket closes or the video stream stalls for too long, tear + # the session down (finally-block releases the NetEase session and + # closes the peer connection) so the next /start reconnects cleanly + # instead of serving 503s forever. while True: - await asyncio.sleep(1) + await asyncio.sleep(5) + sock = app_state.sock + if sock is not None and sock.close_code is not None: + raise RuntimeError( + f"cloud signaling websocket closed (code {sock.close_code}); tearing down session" + ) + snapshotter = app_state.snapshotter + if snapshotter is not None: + last_recv = getattr(snapshotter, "_last_recv_ts", 0.0) + if last_recv and (time.monotonic() - last_recv) > STALE_LIMIT: + raise RuntimeError( + f"cloud video stream stale for over {STALE_LIMIT:.0f}s; tearing down session" + ) except asyncio.CancelledError: print(f"\n{Colors.CYAN}[*] Cloud game task cancelled.{Colors.RESET}") diff --git a/bin/maa-online-daily-run b/bin/maa-online-daily-run new file mode 100755 index 0000000..c4510d9 --- /dev/null +++ b/bin/maa-online-daily-run @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# MAA Online daily runner, designed for systemd oneshot execution. +# +# Flow: wait for the cloud API -> plan remaining tasks from earlier attempts -> +# start a cloud session -> run the daily task file under a stall watchdog -> +# on failure release the cloud session and retry (up to MAA_ONLINE_MAX_ATTEMPTS). +# +# Retries use resume mode: bin/maa-resume-tasks.py inspects the earlier +# attempts' logs and only the tasks that never reported " Completed" are +# run again, so a retry does not re-burn cloud-game minutes on finished work. +# +# Manual catch-up run (for example after a failed night, using the remaining +# daily quota): +# MAA_ONLINE_RESUME_FROM="artifacts/maa-daily-.log" bin/maa-online-daily-run +set -uo pipefail + +ROOT="${MAA_ONLINE_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +API="${MAA_ONLINE_API:-http://127.0.0.1:22888}" +LOG_DIR="${MAA_ONLINE_LOG_DIR:-$ROOT/artifacts}" +MAA_ONLINE_BIN="${MAA_ONLINE_BIN:-$ROOT/bin/maa-online}" +PYTHON="${MAA_ONLINE_PYTHON:-$ROOT/runtime/venv/bin/python}" +RESUME_HELPER="$ROOT/bin/maa-resume-tasks.py" +DAILY_SOURCE="${MAA_ONLINE_DAILY_SOURCE:-$ROOT/config/tasks/daily.toml}" +RESUME_FILE="${MAA_ONLINE_RESUME_OUT:-$ROOT/config/tasks/daily-resume.toml}" + +MAX_ATTEMPTS="${MAA_ONLINE_MAX_ATTEMPTS:-3}" +STALL_FAILS="${MAA_ONLINE_STALL_FAILS:-12}" # consecutive ScreencapFailed (~24s+) +ATTEMPT_MAX_SECONDS="${MAA_ONLINE_ATTEMPT_MAX_SECONDS:-3000}" # per-attempt ceiling + +mkdir -p "$LOG_DIR" + +api() { # api + curl --noproxy '*' -fsS --max-time 180 -X "$1" "$API$2" +} + +echo "[daily-run] $(date --iso-8601=seconds) waiting for cloud API at $API ..." +ready="" +for _ in $(seq 1 90); do + if api GET /info >/dev/null 2>&1; then ready=1; break; fi + sleep 2 +done +if [ -z "$ready" ]; then + echo "[daily-run] ERROR: cloud API not ready after 180s" >&2 + exit 2 +fi + +REASON="" +run_daily_watchdog() { # arg1: log file, arg2: task file name; sets REASON on abort + local log="$1" + local task="$2" + REASON="" + "$MAA_ONLINE_BIN" run "$task" --batch -v --log-file="$log" & + local maa_pid=$! + while kill -0 "$maa_pid" 2>/dev/null; do + sleep 5 + if [ $(( $(date +%s) - attempt_epoch )) -gt "$ATTEMPT_MAX_SECONDS" ]; then + REASON="attempt exceeded ${ATTEMPT_MAX_SECONDS}s ceiling" + break + fi + local streak + streak=$(awk '/ScreencapFailed/{c++; next} {c=0} END{print c+0}' "$log" 2>/dev/null) + if [ "${streak:-0}" -ge "$STALL_FAILS" ]; then + REASON="screencap stalled (${streak} consecutive failures; cloud session likely dead)" + break + fi + if grep -qE '\] Disconnected$' "$log" 2>/dev/null; then + REASON="MAA reported Disconnected (cloud session lost)" + break + fi + done + if [ -n "$REASON" ]; then + echo "[daily-run] watchdog: $REASON; aborting attempt" >&2 + kill -INT "$maa_pid" 2>/dev/null + for _ in $(seq 1 6); do kill -0 "$maa_pid" 2>/dev/null || break; sleep 5; done + kill -0 "$maa_pid" 2>/dev/null && kill -9 "$maa_pid" 2>/dev/null + wait "$maa_pid" 2>/dev/null + return 1 + fi + wait "$maa_pid" || { REASON="maa exited rc=$?"; return 1; } + return 0 +} + +attempt_logs=() +if [ -n "${MAA_ONLINE_RESUME_FROM:-}" ]; then + read -r -a seeded <<< "$MAA_ONLINE_RESUME_FROM" + attempt_logs+=("${seeded[@]}") + echo "[daily-run] seeded resume planning with: ${attempt_logs[*]}" +fi + +plan_tasks() { # echoes the task file to run ("daily", "daily-resume" or "NONE") + if [ "${#attempt_logs[@]}" -eq 0 ]; then + echo daily + return + fi + local result + result=$("$PYTHON" "$RESUME_HELPER" "${attempt_logs[@]}" \ + --source "$DAILY_SOURCE" --out "$RESUME_FILE" 2>&1 | tail -n1) + case "$result" in + NONE) + echo NONE + ;; + RESUME_TASKS=*) + echo "[daily-run] resume plan: ${result#RESUME_TASKS=}" >&2 + echo daily-resume + ;; + *) + echo "[daily-run] WARN: resume planning failed (${result}); using full daily" >&2 + echo daily + ;; + esac +} + +attempt=0 +while [ "$attempt" -lt "$MAX_ATTEMPTS" ]; do + attempt=$((attempt + 1)) + TASK_FILE=$(plan_tasks) + if [ "$TASK_FILE" = "NONE" ]; then + echo "[daily-run] $(date --iso-8601=seconds) every daily task already completed in an earlier attempt; nothing left to do" + exit 0 + fi + echo "[daily-run] attempt $attempt/$MAX_ATTEMPTS: task file '$TASK_FILE'; starting cloud game ..." + if ! api POST /start; then + echo "[daily-run] attempt $attempt: /start failed (cloud service or token issue)" >&2 + sleep 20 + continue + fi + RUN_LOG="$LOG_DIR/maa-daily-$(date +%Y%m%d-%H%M%S).log" + echo "[daily-run] attempt $attempt: running '$TASK_FILE' (log: $RUN_LOG) ..." + attempt_epoch=$(date +%s) + if run_daily_watchdog "$RUN_LOG" "$TASK_FILE"; then + echo "[daily-run] $(date --iso-8601=seconds) daily completed successfully (attempt $attempt)" + exit 0 + fi + attempt_logs+=("$RUN_LOG") + echo "[daily-run] attempt $attempt failed: ${REASON:-unknown}; releasing cloud session" >&2 + api POST /exit >/dev/null 2>&1 || true + sleep 30 +done + +echo "[daily-run] ERROR: daily failed after $MAX_ATTEMPTS attempts (last: ${REASON:-unknown})" >&2 +echo "[daily-run] if the failure mentions login/token, re-run: runtime/venv/bin/python backend/login.py request " >&2 +exit 1 diff --git a/bin/maa-online-install-systemd b/bin/maa-online-install-systemd new file mode 100755 index 0000000..7ceef4f --- /dev/null +++ b/bin/maa-online-install-systemd @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Install (or refresh) the systemd units that schedule the MAA Online daily +# task. Run as root. Units are rendered from systemd/*.tmpl-style sources in +# this repository with __ROOT__ replaced by the checkout path. +set -euo pipefail + +ROOT="${MAA_ONLINE_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +SRC="$ROOT/systemd" +DST="${MAA_ONLINE_SYSTEMD_DIR:-/etc/systemd/system}" +UNITS=(maa-online-server.service maa-daily.service maa-daily.timer maa-update.service maa-update.timer) + +if [ "$(id -u)" != "0" ]; then + echo "error: must run as root (writes to $DST)" >&2 + exit 1 +fi +if [ ! -d "$SRC" ]; then + echo "error: $SRC not found" >&2 + exit 1 +fi + +for unit in "${UNITS[@]}"; do + sed "s|__ROOT__|$ROOT|g" "$SRC/$unit" > "$DST/$unit" + echo "installed $DST/$unit" +done + +systemctl daemon-reload +systemctl enable --now maa-online-server.service +systemctl enable maa-daily.timer maa-update.timer +systemctl start maa-daily.timer maa-update.timer + +echo +systemctl --no-pager list-timers maa-daily.timer maa-update.timer +cat < Completed", and writes a task file containing +only the remaining tasks. The daily runner (bin/maa-online-daily-run) calls it +automatically before every retry. + +Usage: + maa-resume-tasks.py [ ...] [--source ] [--out ] + +Output (stdout, last line): + RESUME_TASKS=, remaining tasks written to --out + NONE nothing left to do + ERROR: could not build a resume file (caller should + fall back to the full daily run) + +StartUp is intentionally never skipped: every /start yields a fresh cloud +instance, and the global `startup = true` flag guarantees the game is launched +and sitting on the main screen before the remaining tasks run. +""" + +from __future__ import annotations + +import argparse +import os +import re +import sys +from pathlib import Path + +ROOT = Path(os.environ.get("MAA_ONLINE_ROOT") or Path(__file__).resolve().parent.parent) +DEFAULT_SOURCE = Path(os.environ.get("MAA_ONLINE_DAILY_SOURCE", ROOT / "config/tasks/daily.toml")) +DEFAULT_OUT = Path(os.environ.get("MAA_ONLINE_RESUME_OUT", ROOT / "config/tasks/daily-resume.toml")) + +TASK_HEADER = re.compile(r"^\[\[tasks\]\]\s*$") +TYPE_RE = re.compile(r'^type\s*=\s*"([^"]+)"\s*$', re.MULTILINE) +NAME_RE = re.compile(r'^name\s*=\s*"([^"]+)"\s*$', re.MULTILINE) +COMPLETED_RE = re.compile(r"^.*\b(\w+) Completed\s*$", re.MULTILINE) + +# Task types that are never skipped even when a completion marker is present: +# a retry always reconnects to a fresh cloud instance and must relaunch/verify +# the game before the remaining tasks can run. +NEVER_SKIP = {"StartUp"} + + +def split_source(text: str) -> tuple[str, list[str]]: + """Return (prelude, task blocks). + + A block owns the contiguous comment lines directly above its [[tasks]] + header, so a "# 1. xxx" section comment travels with its task instead of + being left behind in the prelude. Blocks keep their exact formatting. + """ + lines = text.splitlines(keepends=True) + header_indexes = [i for i, line in enumerate(lines) if TASK_HEADER.match(line)] + if not header_indexes: + raise ValueError("no [[tasks]] blocks found in source file") + + starts = [] + for index in header_indexes: + start = index + while start - 1 >= 0 and lines[start - 1].lstrip().startswith("#"): + start -= 1 + starts.append(start) + + prelude = "".join(lines[: starts[0]]) + blocks = [] + for position, start in enumerate(starts): + end = starts[position + 1] if position + 1 < len(starts) else len(lines) + blocks.append("".join(lines[start:end]).strip("\n") + "\n") + return prelude, blocks + + +def completed_types(log_text: str) -> set[str]: + return set(COMPLETED_RE.findall(log_text)) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("logs", nargs="+", help="previous attempts' MAA log files") + parser.add_argument("--source", default=str(DEFAULT_SOURCE)) + parser.add_argument("--out", default=str(DEFAULT_OUT)) + args = parser.parse_args() + + source = Path(args.source) + if not source.is_file(): + print(f"ERROR:source task file not found: {source}") + return 0 + + try: + prelude, blocks = split_source(source.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + print(f"ERROR:{exc}") + return 0 + + done: set[str] = set() + for log in args.logs: + log_path = Path(log) + if not log_path.is_file(): + continue + try: + done |= completed_types(log_path.read_text(encoding="utf-8", errors="replace")) + except OSError as exc: + print(f"ERROR:cannot read previous log {log}: {exc}") + return 0 + + kept: list[str] = [] + kept_names: list[str] = [] + for block in blocks: + match = TYPE_RE.search(block) + if not match: + continue + task_type = match.group(1) + if task_type in done and task_type not in NEVER_SKIP: + continue + kept.append(block) + name_match = NAME_RE.search(block) + kept_names.append(name_match.group(1) if name_match else task_type) + + if not kept: + print("NONE") + return 0 + + header = ( + "# AUTO-GENERATED by bin/maa-resume-tasks.py -- do not edit.\n" + f"# Source: {source}\n" + f"# Already completed (skipped): {', '.join(sorted(done)) or 'none'}\n\n" + ) + out = Path(args.out) + try: + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text( + header + prelude.rstrip("\n") + "\n\n" + "\n".join(kept), encoding="utf-8" + ) + except OSError as exc: + print(f"ERROR:cannot write resume file: {exc}") + return 0 + + print(f"RESUME_TASKS={','.join(kept_names)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/systemd/maa-daily.service b/systemd/maa-daily.service new file mode 100644 index 0000000..f2969da --- /dev/null +++ b/systemd/maa-daily.service @@ -0,0 +1,14 @@ +[Unit] +Description=MAA Online daily task (Arknights via NetEase cloud game) +Requires=maa-online-server.service +After=maa-online-server.service + +[Service] +Type=oneshot +ExecStart=__ROOT__/bin/maa-online-daily-run +# A normal run takes about 25 minutes; the runner's own per-attempt watchdog +# aborts a hung session well before this ceiling. +TimeoutStartSec=7200 +# Safety net: whatever stops the unit (success, failure, timeout), make sure +# the remote cloud session is released. +ExecStopPost=/bin/sh -c "/usr/bin/curl --noproxy '*' -fsS -X POST http://127.0.0.1:22888/exit || true" diff --git a/systemd/maa-daily.timer b/systemd/maa-daily.timer new file mode 100644 index 0000000..5855a0a --- /dev/null +++ b/systemd/maa-daily.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run MAA Online daily at a random moment between 04:00 and 06:00 + +[Timer] +OnCalendar=*-*-* 04:00:00 +RandomizedDelaySec=2h +Persistent=true +Unit=maa-daily.service + +[Install] +WantedBy=timers.target diff --git a/systemd/maa-online-server.service b/systemd/maa-online-server.service new file mode 100644 index 0000000..d63a9a4 --- /dev/null +++ b/systemd/maa-online-server.service @@ -0,0 +1,16 @@ +[Unit] +Description=MAA Online cloud game API server +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=__ROOT__/bin/maa-online-server +Restart=always +RestartSec=5 +# Belt and braces: never let a proxy environment leak into the cloud gaming +# server (bin/maa-online-server already unsets these). +Environment=NO_PROXY=localhost,127.0.0.1,::1 + +[Install] +WantedBy=multi-user.target diff --git a/systemd/maa-update.service b/systemd/maa-update.service new file mode 100644 index 0000000..a4fb587 --- /dev/null +++ b/systemd/maa-update.service @@ -0,0 +1,7 @@ +[Unit] +Description=Weekly MAA Online maintenance (maa-cli, MaaCore and resource updates) + +[Service] +Type=oneshot +ExecStart=__ROOT__/bin/maa-online-update +TimeoutStartSec=1800 diff --git a/systemd/maa-update.timer b/systemd/maa-update.timer new file mode 100644 index 0000000..417a966 --- /dev/null +++ b/systemd/maa-update.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Weekly MAA Online maintenance, Sunday 03:20 (before the daily window) + +[Timer] +OnCalendar=Sun *-*-* 03:20:00 +RandomizedDelaySec=30m +Persistent=true +Unit=maa-update.service + +[Install] +WantedBy=timers.target diff --git a/user_config/README.md b/user_config/README.md index 65e9c3e..63696dc 100644 --- a/user_config/README.md +++ b/user_config/README.md @@ -195,3 +195,42 @@ curl -X POST http://127.0.0.1:22888/exit - 领取每日/每周任务奖励; - 领取所有邮件; - 任务结束后退出云游戏。 + +## 自动定时与断点续跑 + +仓库提供 systemd 单元,可让日常任务每天自动执行(安装见根目录 README 的「自动定时执行」一节): + +| 单元 | 作用 | +|---|---| +| `maa-online-server.service` | 云游戏 API 常驻服务,崩溃自动重启,启动前清除代理环境变量 | +| `maa-daily.timer` | 每天 04:00 起随机 0–2 小时触发 | +| `maa-update.timer` | 每周日 03:20 起随机 30 分钟,更新 maa-cli / MaaCore / 资源 | + +`bin/maa-online-daily-run` 的流程:等待 API 就绪 → 规划剩余任务 → 启动云游戏 → 在停滞看门狗下 +执行任务 → 失败则释放云会话并重试(默认最多 3 次)。 + +看门狗会在出现连续 `ScreencapFailed` 或日志中的 `Disconnected` 时中止当前尝试;后端本身也会在信令 +WebSocket 被远端关闭、或视频流停滞超过 `MAA_ONLINE_STALE_LIMIT_SECONDS`(默认 120 秒)时主动拆掉 +会话,避免云实例空挂。 + +**断点续跑**:第 2 次及之后的重试不再重跑整份 `daily.toml`。`bin/maa-resume-tasks.py` 读取前几次尝试 +的日志,跳过已出现 `<任务类型> Completed` 的任务,只把剩余任务写入 +`config/tasks/daily-resume.toml` 后执行。若所有任务都已完成,脚本直接判定成功,不再开新会话。 +`StartUp` 永不跳过:每次连接都是新的云实例,必须先进入游戏。 + +手动补跑(例如某天中断后,用当天剩余额度把没做完的任务补上): + +```bash +MAA_ONLINE_RESUME_FROM="artifacts/maa-daily-20260919-053237.log" bin/maa-online-daily-run +``` + +查看执行情况: + +```bash +systemctl list-timers maa-daily.timer +journalctl -u maa-daily.service -n 50 +ls -lt artifacts/maa-daily-*.log | head +``` + +注意:网易云游戏免费额度有限(常见为每天 30 分钟),而一次完整日常约需 24 分钟,重试次数越多越容易 +额度耗尽。断点续跑正是为压缩重试开销而设计的,但仍建议关注额度消耗。