Skip to content

Commit 02a6c11

Browse files
committed
Add 蹬王之王 (reset-card-autouse): 额度到期雷达 + 无头守护进程 + Bot 看门狗
Mini App 看板 + Rust 后台守护进程,通过 macOS launchd 常驻(与桌面端完全独立)。 ## 看板(Mini App) - 积分与重置卡的到期雷达(按「先过期先用」决定用卡还是留积分,不让额度白白作废) - 5 小时额度到线自动暂停进行中的目标,回落后自动恢复**并叫醒** - 额度到顶时自动使用重置卡(带幂等键,重试安全) - 500/429 等可重试错误**一直重试**,退避封顶 60 秒、可中断 ## 守护进程(Rust) - 只做**判定 + 记账**;**投递**交给本插件的 MiniApp(它对 bot 会话有 `agentAccess`) - Bot 看门狗:因临时性故障挂掉的 bot 复用原会话自动重新启用 - 彻底**不依赖 cron**:唤醒改由「MiniApp 起一个 agent 回合 + 会话内 `session send`」完成 ## 这版最要紧的一件事:把「静默失效」变成会说话 「守护进程在跑、日志干净、实际一个 bot 都没被看护」是比报错更坏的一类故障。 这一版修掉了六个实例: 1. minimax-bot 的状态文件里有一个**被截断的代理对转义**,Rust 的 `serde_json` 严格拒绝 ⇒ 名单读不出来 ⇒ 整个 Bot 看门狗静默失效(Python/Node 都能解析,只有 Rust 拒) 2. 拿会话上的 `error_code` 判「坏没坏」——那是**陈旧值**(运行时不清它) ⇒ 实测 11 次唤醒里 **8 次是误判**(那些 bot 的最新回合其实是 completed) ⇒ 改看 `local_runtime_turn_ingress` 里**最新回合**的状态 3. acked 的 30 分钟窗口同时管「投递过了吗」与「多久能再救」 ⇒ 窗口一过,已投递过的请求又变回未处理 ⇒ **每 30 分钟重投一次** ⇒ 拆成两个键:投递判据**永久**(唯一实例 id),频率限制单独用 bot 键 + 时间戳 4. 读不到 bot 名单时原本**一个字都不打** ⇒ 现在明确告警 5. `parse_lenient_json` 按字节切 `&str` ⇒ 遇到中文会 **panic** ⇒ 守护进程崩溃循环 ⇒ 改纯字节解析 + `catch_unwind` 兜底 6. 「按 acked 清理失败计数」在 acked 永久化后变成误伤 ⇒ **3 次封顶失效 ⇒ 无限唤醒** ⇒ 失败计数只在**成功**时归零 ## 其它 - **唤醒链路自检**:宿主重启后运行时没起来 ⇒ 明确告警;积压 15 轮不减少 ⇒ 判定投递空转 - **本地 MCP 端点**:`watchdog_status` / `watchdog_wake` / `watchdog_decide`(刻意不暴露扣卡) - **客户端 401 自愈**:连续 3 次 401 ⇒ 整页重载(上限 5 次)⇒ 面板不再卡在「实时连接中断」 - **一致性是测出来的**:Rust/Python/MiniApp 三方决策逐字比对 19/19, 且校验器有「注入漂移必须被抓到」的自检 测试:Rust 75/75、Python 20/20、MiniApp 144/144、三方一致性 19/19。 文档:中文 README(含 30 个踩过的坑)、英文 README、`docs/BOT_DIAGNOSIS.md`(排查手册)。
1 parent ea9b6cc commit 02a6c11

44 files changed

Lines changed: 17754 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Python 字节码缓存(跑测试时会重新生成,不该进版本库)
2+
__pycache__/
3+
*.pyc
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"schemaVersion": 1,
3+
"name": "expiry-guardian",
4+
"displayName": "蹬王之王",
5+
"version": "1.6.0",
6+
"description": "实时追踪积分与重置卡的到期时间,额度打满时自动暂停所有进行中的目标、额度回落后自动恢复并叫醒;无人值守后台守护进程配合 Mini App 看板,按「先过期先用」自动使用重置卡,遇到 500/429 等可重试错误会一直重试。v1.6 修掉一个让 Bot 守护完全静默的真 bug(minimax-bot 状态文件里一个被截断的代理对转义导致严格 JSON 解析失败),并新增唤醒链路自检:宿主重启后运行时没起来时如实告警、投递空转时也能发现,绝不再「看起来一切正常、实际一个 bot 都没叫醒」。",
7+
"author": "User",
8+
"icon": "icon.png",
9+
"category": "Productivity",
10+
"exampleQueries": [
11+
"打开蹬王之王,看我的积分和重置卡什么时候过期。",
12+
"我的额度快用完了,该用重置卡还是留着?",
13+
"看看我有多少积分要过期了。"
14+
],
15+
"apps": [],
16+
"mcpServers": [
17+
"servers.mcp.json"
18+
],
19+
"skills": [],
20+
"darkIcon": "icon-dark.png"
21+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Hylouis
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

‎plugins/hylouis233/reset-card-autouse/README.md‎

Lines changed: 341 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
#!/usr/bin/env bash
2+
# 校验「工作区源码」与「插件包」**测试文件**保持一致。
3+
#
4+
# 2026-10-20 反复踩到的坑:工作区(miniapps/expiry-guardian/)与插件包
5+
# (plugins/hylouis233/reset-card-autouse/)各有一份测试,路径布局还不同
6+
# (工作区是 `./x.mjs`,插件包是 `../miniapp/node/x.mjs`)。
7+
# 改了一边忘了另一边 ⇒ 插件包跑的是旧测试,出现「17/18」这种看不懂的结果。
8+
#
9+
# ⇒ 这个脚本只比**测试文件的内容**(忽略 import 路径差异),
10+
# 不一致就报错并指出是哪几个文件。
11+
set -euo pipefail
12+
13+
WORKSPACE="${WORKSPACE:-$HOME/Desktop/mmx/miniapps/expiry-guardian}"
14+
PLUGIN="${PLUGIN:-$(cd "$(dirname "$0")" && pwd)}"
15+
16+
# 插件包里测试在 tests/,工作区在 miniapp/node/。
17+
PAIRS=(
18+
"bot_wakeup.test.mjs"
19+
"config-fidelity.test.mjs"
20+
"decision.test.mjs"
21+
"mcp.test.mjs"
22+
"server-lint.test.mjs"
23+
)
24+
25+
fail=0
26+
for f in "${PAIRS[@]}"; do
27+
a="$WORKSPACE/miniapp/node/$f"
28+
b="$PLUGIN/tests/$f"
29+
if [ ! -f "$a" ] || [ ! -f "$b" ]; then
30+
echo " SKIP $f(缺文件)"
31+
continue
32+
fi
33+
# 归一化:去掉 import 路径差异与空白差异。
34+
na=$(sed -E "s#\.\./miniapp/node/##g; s#'\./#'#g" "$a" | tr -d ' \t\r\n')
35+
nb=$(sed -E "s#\.\./miniapp/node/##g; s#'\./#'#g" "$b" | tr -d ' \t\r\n')
36+
if [ "$na" = "$nb" ]; then
37+
echo " OK $f"
38+
else
39+
echo " DIFF $f ← 工作区与插件包不一致"
40+
diff <(sed -E "s#\.\./miniapp/node/##g; s#'\./#'#g" "$a") \
41+
<(sed -E "s#\.\./miniapp/node/##g; s#'\./#'#g" "$b") | head -12 || true
42+
fail=1
43+
fi
44+
done
45+
46+
if [ "$fail" -ne 0 ]; then
47+
echo
48+
echo "❌ 工作区与插件包的测试文件不一致 —— 改完记得两边都同步。"
49+
exit 1
50+
fi
51+
echo
52+
echo "✅ 工作区与插件包的测试文件一致"
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
#!/usr/bin/env python3
2+
"""后台决策测试 —— 逐条对齐 Mini App「蹬王之王」的 decide()。
3+
4+
Mini App 的分支(packages/expiry-guardian/miniapp/node/server.mjs::decide):
5+
未耗尽 → idle
6+
耗尽 + 无积分 + 有卡 → use-card
7+
耗尽 + 无积分 + 无卡 → wait
8+
耗尽 + 有积分 + 无卡 → keep-credit
9+
耗尽 + 都有,卡先过期 → use-card
10+
耗尽 + 都有,积分先过期 → keep-credit
11+
12+
本文件逐条复刻这些分支,任一不一致即视为失败。
13+
纯函数测试:不触网、不扣卡。
14+
"""
15+
import importlib.util
16+
import sys
17+
import time
18+
from pathlib import Path
19+
20+
spec = importlib.util.spec_from_file_location(
21+
"bg", Path(__file__).with_name("reset_card_autouse.py")
22+
)
23+
m = importlib.util.module_from_spec(spec)
24+
sys.modules["bg"] = m
25+
spec.loader.exec_module(m)
26+
27+
NOW = int(time.time() * 1000)
28+
DAY = 86400000
29+
30+
31+
def qs(quota_type, used, total=100.0, unlimited=False):
32+
return m.QuotaState(quota_type=quota_type, total=total, used=used, is_unlimited=unlimited)
33+
34+
35+
def card(days):
36+
return {
37+
"card_id": f"C{days}",
38+
"out_biz_no": f"biz:{days}",
39+
"source_type": "mcode_signin",
40+
"expire_time": NOW + days * DAY,
41+
}
42+
43+
44+
passed = failed = 0
45+
46+
47+
def check(name, got, want):
48+
global passed, failed
49+
if got == want:
50+
passed += 1
51+
print(f" PASS {name} -> {got}")
52+
else:
53+
failed += 1
54+
print(f" FAIL {name} -> {got} (want {want})")
55+
56+
57+
def decide(used5, used7d=None, cards=None, credit_days=None):
58+
quotas = [qs(1, used5)]
59+
quotas.append(qs(2, 0, unlimited=True) if used7d is None else qs(2, used7d))
60+
exp = None if credit_days is None else NOW + credit_days * DAY
61+
return m.decide_background(quotas, cards or [], exp, "平台积分")[0]
62+
63+
64+
print("=== Mini App 分支 1:未到触发线(99%) → idle ===")
65+
# 上界取 98 而不是 99:触发线就是 99%,99% 已经属于「该动手了」,
66+
# 那条在下面单独断言。早先这里写的是 99,是 99% 提前触发设计落地之前的残留。
67+
for pct in (0, 50, 90, 98):
68+
check(f"5h 已用 {pct}% → idle", decide(pct, cards=[card(7)], credit_days=13), "idle")
69+
70+
# 触发线本身就应该动手 —— 额度没真正耗尽,但再等一次请求就会失败。
71+
check("5h 已用 99% → use-card(触发线)", decide(99, cards=[card(7)], credit_days=13), "use-card")
72+
73+
print("\n=== Mini App 分支 2:耗尽 + 无积分 + 有卡 → use-card ===")
74+
check("5h 100% + 无积分 + 有卡", decide(100, cards=[card(7)], credit_days=None), "use-card")
75+
76+
print("\n=== Mini App 分支 3:耗尽 + 无积分 + 无卡 → wait ===")
77+
check("5h 100% + 无积分 + 无卡", decide(100, cards=[], credit_days=None), "wait")
78+
79+
print("\n=== Mini App 分支 4:耗尽 + 有积分 + 无卡 → keep-credit ===")
80+
check("5h 100% + 有积分 + 无卡", decide(100, cards=[], credit_days=13), "keep-credit")
81+
82+
print("\n=== Mini App 分支 5:耗尽 + 都有 + 卡先过期 → use-card ===")
83+
check("卡 7 天 / 积分 13 天", decide(100, cards=[card(7)], credit_days=13), "use-card")
84+
check("卡 5 天 / 积分 30 天", decide(100, cards=[card(5)], credit_days=30), "use-card")
85+
86+
print("\n=== Mini App 分支 6:耗尽 + 都有 + 积分先过期 → keep-credit ===")
87+
check("卡 20 天 / 积分 5 天", decide(100, cards=[card(20)], credit_days=5), "keep-credit")
88+
check("卡 30 天 / 积分 1 天", decide(100, cards=[card(30)], credit_days=1), "keep-credit")
89+
90+
print("\n=== 周限额单独耗尽也应触发(与 Mini App 的 weekly.exhausted 一致)===")
91+
check("5h 未耗尽 + 周 100% → 进入判定",
92+
decide(10, used7d=100, cards=[card(7)], credit_days=13), "use-card")
93+
check("5h 未耗尽 + 周未耗尽 → idle",
94+
decide(10, used7d=50, cards=[card(7)], credit_days=13), "idle")
95+
96+
print("\n=== 不限量的周限额不算耗尽(与 is_unlimited 判定一致)===")
97+
check("周不限量 + 5h 100% + 卡先过期", decide(100, cards=[card(7)], credit_days=13), "use-card")
98+
99+
print("\n=== 理由文本 ===")
100+
cases = [
101+
("未耗尽", ([qs(1, 50), qs(2, 0, unlimited=True)], [card(7)], NOW + 13 * DAY, "平台积分"), "额度充足"),
102+
("无积分用卡", ([qs(1, 100), qs(2, 0, unlimited=True)], [card(7)], None, ""), "没有可用积分"),
103+
("无卡留积分", ([qs(1, 100), qs(2, 0, unlimited=True)], [], NOW + 13 * DAY, "平台积分"), "没有可用的重置卡"),
104+
("卡先过期", ([qs(1, 100), qs(2, 0, unlimited=True)], [card(7)], NOW + 13 * DAY, "平台积分"), "先用卡保住积分"),
105+
("积分先过期", ([qs(1, 100), qs(2, 0, unlimited=True)], [card(20)], NOW + 5 * DAY, "平台积分"), "让积分先消耗"),
106+
]
107+
for label, args, needle in cases:
108+
_, reason = m.decide_background(*args)
109+
ok = isinstance(reason, str) and needle in reason
110+
if ok:
111+
passed += 1
112+
else:
113+
failed += 1
114+
print(f" {'PASS' if ok else 'FAIL'} {label}: {reason}")
115+
116+
print(f"\n{passed}/{passed + failed} passed")
117+
sys.exit(0 if failed == 0 else 1)
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
{
2+
"at": "2026-10-16T19:07:04.579Z",
3+
"results": [
4+
{
5+
"n": "未耗尽 / 卡7 / 积13",
6+
"action": "idle",
7+
"reason": "额度充足,暂不需要处理"
8+
},
9+
{
10+
"n": "未耗尽 / 无卡 / 积13",
11+
"action": "idle",
12+
"reason": "额度充足,暂不需要处理"
13+
},
14+
{
15+
"n": "耗尽 / 卡先过期",
16+
"action": "use-card",
17+
"reason": "重置卡(10月24日)比平台积分(10月30日)先过期,先用卡保住积分"
18+
},
19+
{
20+
"n": "耗尽 / 积分先过期",
21+
"action": "use-credit",
22+
"reason": "平台积分(10月22日)比重置卡(11月6日)先过期,不用卡,让积分先消耗"
23+
},
24+
{
25+
"n": "耗尽 / 无卡 / 有积分",
26+
"action": "use-credit",
27+
"reason": "没有可用的重置卡,让平台积分(10月30日到期)先消耗"
28+
},
29+
{
30+
"n": "耗尽 / 有卡 / 无积分",
31+
"action": "use-card",
32+
"reason": "没有可用积分,使用重置卡重置额度"
33+
},
34+
{
35+
"n": "耗尽 / 无卡 / 无积分",
36+
"action": "wait",
37+
"reason": "额度已耗尽,既没有积分也没有可用的重置卡"
38+
},
39+
{
40+
"n": "周限额耗尽 / 5h未耗尽",
41+
"action": "use-card",
42+
"reason": "重置卡(10月24日)比平台积分(10月30日)先过期,先用卡保住积分"
43+
},
44+
{
45+
"n": "周限额耗尽 / 积分先过期",
46+
"action": "use-credit",
47+
"reason": "平台积分(10月22日)比重置卡(11月6日)先过期,不用卡,让积分先消耗"
48+
},
49+
{
50+
"n": "周限额未耗尽 / 5h未耗尽",
51+
"action": "idle",
52+
"reason": "额度充足,暂不需要处理"
53+
},
54+
{
55+
"n": "5h 99% 不算耗尽",
56+
"action": "idle",
57+
"reason": "额度充足,暂不需要处理"
58+
},
59+
{
60+
"n": "5h 100% 边界算耗尽",
61+
"action": "use-card",
62+
"reason": "重置卡(10月18日)比平台积分(12月16日)先过期,先用卡保住积分"
63+
}
64+
]
65+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#!/bin/sh
2+
# 一致性总检查:决策单测 + 跨语言一致性 + 校验器自检。
3+
#
4+
# 第三步是重点:如果校验器对「已知错误」也报一致,那它本身失效了,
5+
# 等于没有校验。所以这里主动注入一处漂移,要求必须被抓到。
6+
#
7+
# 任一环节失败即以非零退出,可直接挂到 CI 或 git hook 上。
8+
set -e
9+
DIR="$(cd "$(dirname "$0")" && pwd)"
10+
cd "$DIR"
11+
12+
TMP="$(mktemp -d)"
13+
trap 'rm -rf "$TMP"' EXIT
14+
fail=0
15+
16+
echo "=== 1/3 后台决策单测 ==="
17+
if python3 bg_decision.test.py > "$TMP/unit.log" 2>&1; then
18+
tail -1 "$TMP/unit.log"
19+
else
20+
echo "✗ 决策单测失败"
21+
tail -20 "$TMP/unit.log"
22+
fail=1
23+
fi
24+
25+
echo
26+
echo "=== 2/3 跨语言一致性(后台 vs Mini App「蹬王之王」)==="
27+
if node verify-parity.mjs > "$TMP/parity.log" 2>&1; then
28+
tail -1 "$TMP/parity.log"
29+
else
30+
echo "✗ 两边策略已漂移"
31+
tail -25 "$TMP/parity.log"
32+
fail=1
33+
fi
34+
35+
echo
36+
echo "=== 3/3 校验器自检(注入漂移必须被抓到)==="
37+
python3 - "$TMP" <<'PY'
38+
import pathlib, sys
39+
tmp = pathlib.Path(sys.argv[1])
40+
src = pathlib.Path("reset_card_autouse.py").read_text()
41+
needle = 'f"没有可用的重置卡,让{credit_desc}({_fmt_day(credit_expiry_ms)}到期)先消耗"'
42+
if needle not in src:
43+
print("✗ 自检锚点已失效:后台代码结构变了,请同步更新 check-parity.sh")
44+
raise SystemExit(1)
45+
# 把「无卡 → keep-credit」改成「无卡 → wait」,这是一处真实的漂移
46+
broken = src.replace(
47+
'return "keep-credit", (\n ' + needle,
48+
'return "wait", "自检注入的错误行为"',
49+
)
50+
if broken == src:
51+
print("✗ 注入失败:目标分支结构与预期不符")
52+
raise SystemExit(1)
53+
(tmp / "broken.py").write_text(broken)
54+
PY
55+
56+
if [ ! -f "$TMP/broken.py" ]; then
57+
echo "✗ 自检注入失败,跳过"
58+
fail=1
59+
elif MCODE_BG_SCRIPT="$TMP/broken.py" node verify-parity.mjs > "$TMP/selftest.log" 2>&1; then
60+
echo "✗ 校验器对注入的漂移仍报一致 —— 校验器本身已失效"
61+
fail=1
62+
else
63+
echo "✓ 成功检出注入的漂移"
64+
grep -m1 '动作不一致' "$TMP/selftest.log" | sed 's/^/ /' || true
65+
fi
66+
67+
echo
68+
if [ "$fail" -eq 0 ]; then
69+
echo "✓ 全部通过:后台服务与 Mini App 策略一致"
70+
else
71+
echo "✗ 存在不一致,需修复"
72+
fi
73+
exit "$fail"

0 commit comments

Comments
 (0)