diff --git a/README.md b/README.md index 7ed555e..c42a6c2 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | [Anatomy Gallery](plugins/zch0227/anatomy-gallery/) | A gallery for interactive isometric figures made with the anatomy skill: hang them in rooms you organize, view light and dark editions full screen, keep every version, and have an Agent create or edit figures from the gallery with a per-run process log. The anatomy skill is bundled. Requires MiniMax Code 3.1.2 or later | [zch0227](https://github.com/zch0227) | | [仓库透视 / Repo X-Ray](plugins/yujiachen-y/repo-xray/) | Understand a codebase from its Git history: a code map you can drill into, hotspots and bug magnets, high-risk files, owners and knowledge loss, commit rhythm and firefighting, and the tech stack. Works with GitHub, GitLab, and local repositories; analysis runs on your machine | [yujiachen-y](https://github.com/yujiachen-y) | | [Disk Insight](plugins/zch0227/disk-insight/) | See where disk space went on macOS and Windows with a sunburst space map, find junk files sorted into three safety tiers, large and duplicate files, and how much code repositories and Git worktrees take; ask an Agent about any item, and permanently delete what you select after a confirmation that lists every item. Requires MiniMax Code 3.1.2 or later | [zch0227](https://github.com/zch0227) | +| [蹬王之王 · Expiry Guardian](plugins/hylouis233/expiry-guardian/) | Track credit and reset-card expiry, pause goals when the 5-hour quota runs out, and resume them once it comes back — a headless daemon plus a live dashboard | [Hylouis](https://github.com/Hylouis233) |
Preview: Token Usage Board diff --git a/README.zh-CN.md b/README.zh-CN.md index 3f99ad4..24bd731 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -80,6 +80,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | [Anatomy 画廊](plugins/zch0227/anatomy-gallery/README.zh-CN.md) | 集中展示 anatomy 技能做出的交互式等距图解:按自建展区挂墙,全屏查看明暗两版,保留全部历史版本;在画廊里让 Agent 新建或修改作品,每次运行都有过程记录。自带 anatomy 技能。需要 MiniMax Code 3.1.2 或更高版本 | [zch0227](https://github.com/zch0227) | | [仓库透视](plugins/yujiachen-y/repo-xray/README.zh-CN.md) | 从 Git 历史看懂一个代码仓库:可逐层下钻的代码地图、热点与 Bug 磁铁、高风险文件、负责人与知识流失、提交节奏与救火记录、技术栈;支持 GitHub、GitLab 和本机仓库,全部在本机分析 | [yujiachen-y](https://github.com/yujiachen-y) | | [磁盘透视](plugins/zch0227/disk-insight/README.zh-CN.md) | 查看 macOS 和 Windows 的磁盘空间去向:旭日图空间地图,按三档风险分类的垃圾文件,大文件和重复文件,代码仓库和 Git worktree 的占用;任何条目都可以问 Agent,选中的条目在确认框里逐一列出,确认后直接永久删除。需要 MiniMax Code 3.1.2 或更高版本 | [zch0227](https://github.com/zch0227) | +| [蹬王之王](plugins/hylouis233/expiry-guardian/README.zh-CN.md) | 追踪积分与重置卡到期,5 小时额度打满时自动暂停目标、回落后自动恢复并叫醒;后台守护进程 + 实时看板 | [Hylouis](https://github.com/Hylouis233) |
预览:Token 用量看板 diff --git a/plugins/hylouis233/expiry-guardian/.gitignore b/plugins/hylouis233/expiry-guardian/.gitignore new file mode 100644 index 0000000..5bb0415 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/.gitignore @@ -0,0 +1,3 @@ +# Python 字节码缓存(跑测试时会重新生成,不该进版本库) +__pycache__/ +*.pyc diff --git a/plugins/hylouis233/expiry-guardian/.minimax-plugin/plugin.json b/plugins/hylouis233/expiry-guardian/.minimax-plugin/plugin.json new file mode 100644 index 0000000..4e76443 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/.minimax-plugin/plugin.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "name": "expiry-guardian", + "displayName": "蹬王之王", + "version": "1.6.0", + "description": "实时追踪积分与重置卡的到期时间,额度打满时自动暂停所有进行中的目标、额度回落后自动恢复并叫醒;无人值守后台守护进程配合 Mini App 看板,按「先过期先用」自动使用重置卡,遇到 500/429 等可重试错误会一直重试。v1.6 修掉一个让 Bot 守护完全静默的真 bug(minimax-bot 状态文件里一个被截断的代理对转义导致严格 JSON 解析失败),并新增唤醒链路自检:宿主重启后运行时没起来时如实告警、投递空转时也能发现,绝不再「看起来一切正常、实际一个 bot 都没叫醒」。", + "author": "User", + "icon": "icon.png", + "category": "Productivity", + "exampleQueries": [ + "打开蹬王之王,看我的积分和重置卡什么时候过期。", + "我的额度快用完了,该用重置卡还是留着?", + "看看我有多少积分要过期了。" + ], + "apps": [], + "mcpServers": [ + "servers.mcp.json" + ], + "skills": [], + "darkIcon": "icon-dark.png" +} diff --git a/plugins/hylouis233/expiry-guardian/LICENSE b/plugins/hylouis233/expiry-guardian/LICENSE new file mode 100644 index 0000000..00528ca --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Hylouis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/hylouis233/expiry-guardian/README.md b/plugins/hylouis233/expiry-guardian/README.md new file mode 100644 index 0000000..5c2b0f9 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/README.md @@ -0,0 +1,341 @@ +# 蹬王之王 · Reset Card Auto-Use + +Track when your MiniMax Code credits and reset cards expire, and let the machine burn +them before they go to waste. + +The Mini App is the dashboard. The background daemon is the executor: it runs headless +under `launchd`, pauses your goals when the 5-hour quota runs out, spends a reset card +when that is the right move, and resumes the goals once the quota comes back. + +## What it does + +| | | +| --- | --- | +| **Expiry radar** | Lists every credit and reset card with its expiry date, so you can see what is about to be wasted. | +| **Goal auto-pause** | When the 5-hour quota crosses the trigger line (default 95%), every in-flight goal is paused automatically. | +| **Goal auto-resume** | When the quota falls back below the trigger, the goals are resumed *and actually woken up* — not just flipped to `active`. | +| **Card selection** | "Expiring first" policy: if the card expires before the credits, spend the card; if the credits expire first, let the credits burn first and keep the card. | +| **Headless** | The daemon keeps running with MiniMax Code closed, after logout, and while the machine sleeps. | + +### v1.6 — silent failures made loud (2026-10-20, night) + +The theme: **stop letting "everything looks fine" coexist with "not a single Bot was +actually watched or woken"** — and fix three real bugs found in production. + +| Change | Effect | +| --- | --- | +| **Lenient JSON parsing for the Bot roster** | `minimax-bot`'s `state.json` contains a **truncated surrogate escape** (`\ud83d`). Python and Node parse it; **Rust's `serde_json` rejects it** ⇒ the roster came back empty ⇒ the **entire Bot watchdog went blind** — the daemon ran, the log was clean, and **zero Bots were being watched**. Measured: before the fix, 4 restarts over 45 minutes produced **no watchdog log lines at all**; after it, "checked 45" immediately | +| **Judge "is it broken?" by the latest *turn*, not by the session's `error_code`** | `error_code` is a **stale value** (the runtime never clears it). Measured on 2026-10-20 15:39: the old rule wrote **11** wake-up requests, of which **8 were false positives** — those 8 Bots' latest turn was `completed` (perfectly healthy); the error code was left over from an earlier failure. After the fix the same batch reported "0 self-healing, 0 dispatched", and only the **2 genuinely broken** Bots were woken | +| **Acked made permanent + request ids made per-instance** | The 30-minute ack window was doing **two** jobs at once: "has this been dispatched?" and "how soon may I rescue this Bot again?". ⇒ Once the window expired, an **already-delivered** request became "undelivered" again ⇒ the delivery side **re-dispatched it every 30 minutes** (≈47k tokens per turn) and interrupted Bots that had already recovered. Now: "dispatched" is **permanent** (keyed by the per-instance request id), the rescue interval is a **separate 30-minute rate limit** owned by the daemon, and every write gets a **fresh instance id** so the two never collide | +| **A roster that cannot be read is now announced** | The old code was `if bots.is_empty() { return }` — **not a single word logged**. "The roster can't be read" was completely invisible ⇒ the daemon looked healthy while **watching nothing**. Now it says so explicitly, once per distinct cause | +| **New wake-up self-check (`bot_bootstrap.rs`)** | After a host restart the Mini App is not started automatically ⇒ "decide" keeps working while "deliver" idles. The daemon now **says so** instead of going quiet, and also detects **delivery spinning** (runtime up, backlog never shrinking) | +| **Runtime-liveness check rewritten** | It used a **hard-coded port (55932)** — the host's default allocation — and that port belongs to the **host process**, not necessarily to this Mini App. Now: a **live process** whose **cwd is this plugin's runtime package** and whose `package.json` **or** `.minimax-plugin/plugin.json` has `name == expiry-guardian` | +| **A documented conclusion corrected** | The docs used to say "waking does not happen while the panel is closed" — **inaccurate**. Measured: the runtime kept running for **44 minutes** with the panel closed and kept delivering. The real gap is a **host restart** | +| **`miniapp publish` fixed** | The manifest's MCP endpoint set did not match the host's in-memory "published snapshot". Declare **exactly one** endpoint (`server=expiry-guardian`, `path=/mcp/expiry-guardian`) + the matching `servers.mcp.json` + `"mcpServers": ["servers.mcp.json"]` in `plugin.json` ⇒ publish succeeds (verified twice) | +| **Several "says something false" branches fixed** | A cleared backlog no longer claims "the runtime recovered"; a *growing* backlog no longer claims "delivery resumed" (only a *shrinking* one does); a failing `lsof` is reported as "the check is broken", not "the runtime is down"; a repeated self-check failure is logged once; surrogate ranges are matched exactly (U+D000–D7FF are ordinary characters, not surrogates) | + +| 11 | 🔴 **A typo in the dashboard's data layer broke the whole panel** | `doBuildSnapshot()` wrote `platform` as `platformRes` ⇒ `ReferenceError` ⇒ **`buildSnapshot()` throws outright** ⇒ quota, decision, reset cards and credits all unavailable ⇒ **the panel was broken**. The typo was introduced in **v1.2** (`fd98d98`) and survived four releases. It went unnoticed because the unit tests cover the pure `decide()` function — **the data layer itself was never executed** | +| 12 | 🔴 **The `watchdog_status` MCP tool always returned `{}`** | `deps.status()` is `async`, but the handler never awaited it ⇒ it got a **Promise** ⇒ `JSON.stringify(Promise)` is the string `"{}"`. (`watchdog_wake` and `watchdog_decide` *are* awaited — only `status` was missed.) | +| 13 | ✅ **Two regression tests added** (both bugs survived purely because nothing covered them) | `mcp.test.mjs` (18 checks): MCP protocol, the three tools, **an `async` status must really be awaited** (otherwise it is `"{}"`), and an unknown tool must fail loudly. `server-lint.test.mjs` (5 checks): a static guard for "used but never declared" identifiers — reintroducing the typo makes it fail (5/5 → 3/5) | + +| 14 | 🔴 **The 3-attempt cap was voided again** (2nd audit) | After acked became *permanent*, "clean up the failure count for acked bots" changed meaning from "just rescued" to "**ever** rescued" ⇒ a rescued bot's failure count was wiped ⇒ **the 3-attempt cap stopped working** ⇒ the request stayed alive forever ⇒ **re-delivered every 60 seconds** (≈47k–93k tokens per turn). Now the failure count is cleared **only on success**; the failure path cleans up by age only | +| 15 | 🔴 **The parser could panic** (2nd audit) | `parse_lenient_json` sliced a `&str` by **byte** offsets ⇒ a slice landing inside a multi-byte character **panics** ⇒ the whole daemon dies (and launchd restarts it into the same crash). Trigger: strict parsing fails **and** the file contains a backslash-u escape followed by non-ASCII (the production `state.json` has 895k non-ASCII characters). Now parsed at the **byte** level, plus a `catch_unwind` around the Bot watchdog so a panic there cannot take down quota handling and goal resuming | +| 16 | 🟠 **"No turn record" was a dead branch** (2nd audit) | The judgment layer said `None => !is_idle(...)` while the caller immediately did `if !is_idle(...) { continue; }` — the two **negate each other** ⇒ such sessions could never be rescued. Now the decision is handed to the downstream `classify_error` + `is_idle` checks | +| 17 | 🟠 **"Zombie turns" are no longer ignored forever** (2nd audit) | `accepted` means both "running" and "**left behind by a host crash**" (the production DB has 3 such zombies, the oldest stuck for 2 hours, on sessions with self-healing error codes) ⇒ the new rule treated them as "running" ⇒ rescuable bots were never rescued. Now: stuck in `accepted` for more than **1 hour** ⇒ treated as stuck and rescued | +| 18 | 🟡 **lsof parsing switched to field mode** | The default table mode splits on whitespace ⇒ a **path containing spaces** is chopped up ⇒ `ends_with("/package")` is never true ⇒ "the runtime is down" is reported every round. Now uses `lsof -F` (the `n` field keeps spaces) | + +Tests: Rust **75/75**, Python **20/20**, MiniApp **144/144** +(bot_wakeup 44 + config-fidelity 15 + decision 58 + mcp 18 + server-lint 5 + retry.behavior 4). + +### v1.5 — architecture hardening (2026-10-20, evening) + +The architecture is unchanged: the Rust daemon **decides and records**, this +Mini App **delivers** (`session send`). This release is all about hardening the +parts that looked fine but failed silently. + +| Change | Effect | +| --- | --- | +| **Wake-session reuse** — the first wake host session id is stored in `bot-wake-host-session.json` and reused | Wake-ups no longer cold-start (≈93k tokens each). Measured on 2026-10-20 02:39: waking 6 Bots created **0** new sessions (it used to create one per Bot) | +| **Request lifetime** — "30-minute TTL" replaced by "at most **3 attempts** (`MAX_ATTEMPTS`) + a **24-hour** backstop (`MAX_REQUEST_AGE_MS`)" | With the panel closed nobody consumed the requests, so after 30 minutes they were all gone — and when the panel reopened the Bots were broken again with **no request left to send** (measured: 7 requests, none consumed, 70 s after closing) | +| **`acked` is no longer a permanent blacklist** — entries carry a timestamp and are suppressed for **30 minutes** only (`ACK_SUPPRESS_WINDOW_MS`) | 6 Bots had broken again (idle 47–94 minutes, far past the 90 s threshold) yet were **never woken again** because their ids sat in `acked`. The old format (plain string array) still reads and is treated as "a long time ago" — no suppression | +| **Three concurrent-write bugs fixed** (temp filename clash / lost update / cleanup condition) | The cleanup-condition bug made the **3-attempt cap an illusion — unlimited retries**, each burning a wake-up turn | +| **Transient errors are not resent** | `BUSY` / `session_busy` (a turn is already in flight) is retried later, **not resent**; `AGENT_OUTCOME_UNKNOWN` (the turn may already have run) is acked outright ⇒ otherwise the Bot receives **two** duplicate "continue from where you stopped" messages | +| **Client 401 self-healing** | The panel no longer sticks on "live connection lost": 3 consecutive 401s ⇒ `window.location.reload()`, capped at **5** reloads (counted in sessionStorage), reset on any success | +| **New local MCP endpoint** | `watchdog_status` / `watchdog_wake` / `watchdog_decide` — other plugins can ask this one directly. Deliberately **no "spend a card"**: spending is a real write (one card fewer) | +| **`50111` is now self-healing** | Short-window rate limiting (HTTP 429) used to fall into "unknown error code ⇒ do not retry", even though 429 is on the Host's retry allowlist | +| **New `check-test-parity.sh`** | The workspace source and the plugin package each keep their own copy of the tests, with different layouts ⇒ changing one and forgetting the other made the package run stale tests | + +Tests: Rust **51/51**, Python **20/20**, MiniApp **112/112** +(bot_wakeup 35 + config-fidelity 15 + decision 58 + retry.behavior 4). + +The full write-up of each item is in +[`docs/README.zh-CN.md`](docs/README.zh-CN.md); the wake-up channel is broken +down in [`docs/BOT_DIAGNOSIS.md`](docs/BOT_DIAGNOSIS.md). + +## Install + +1. Install this plugin in MiniMax Code. +2. The Mini App works immediately for the expiry radar — no extra setup. +3. For unattended goal pausing and card spending, install the background daemon: + + ```bash + # from the plugin directory + cd daemon/rust + cargo build --release + install -m 755 target/release/reset-card-daemon ~/.local/bin/ + ``` + + Then register it with `launchd` (see `docs/README.zh-CN.md` for the plist and the + full command list). + +## Configuration + +The daemon, the Python reference implementation and the Mini App all read the **same** +config file — nothing hardcodes a threshold in its own code: + +```json +{ + "schemaVersion": 1, + "cardTriggerPercent": 99, + "cardSkipBeforeResetMinutes": 60, + "quotaLevels": { "tight": 80, "low": 95 }, + "goalPause": { "enabled": true, "triggerPercent": 95, "resumeAtPercent": 0 } +} +``` + +| Field | Meaning | +| --- | --- | +| `cardTriggerPercent` | Spend a reset card at this percentage of the 5-hour quota. | +| `cardSkipBeforeResetMinutes` | Do **not** burn a card when the 5-hour window refreshes sooner than this. The quota comes back on its own; spending a card then is pure waste. | +| `quotaLevels.tight` / `.low` | Traffic-light thresholds for the dashboard. | +| `goalPause.enabled` | Master switch for goal auto-pause. | +| `goalPause.triggerPercent` | Pause all in-flight goals at this percentage of the 5-hour quota. | +| `goalPause.resumeAtPercent` | Resume when usage falls back to this percentage. | + +Invalid values are **clamped and reported**, never silently corrected — a silent +correction would make you believe a setting took effect when it did not. + +The daemon re-reads the file every cycle, so changes take effect within about a minute +without a restart. The Mini App's “Automation thresholds” card edits the same file. + +## Bot watchdog: a crashed Bot comes back on its own + +MiniMax Bot creates a **new session** every time it fails, so a Bot that keeps +crashing leaves a trail of dead sessions and a new error notice each time. + +This package watches your Bot team and re-enables the ones that died from a +**temporary** failure — reusing the Bot's original session instead of creating +another one. + +| Situation | What the watchdog does | +| --- | --- | +| HTTP 500, network change, 429/529, TPM rate limit, 400/404/422 | Waits out a backoff, then **re-enables the same session** | +| Token Plan limit reached (42212), credits exhausted (50110), login expired (50112) | **Does not retry.** Tells you exactly what to fix | +| The Bot is fine, or only has a stale error code from an earlier turn | Leaves it alone | + +The full diagnosis, with the evidence behind it, is in +[`docs/BOT_DIAGNOSIS.md`](docs/BOT_DIAGNOSIS.md). + +### How it re-enables a Bot + +The watchdog runs outside the Host (a `launchd` service, no token, unattended), +so it cannot reach MiniMax Bot's MCP endpoint — the Host assigns that port and +its MCP client sends a per-run credential other processes do not have. Four +channels were measured on 2026-10-19 / 2026-10-20: + +| Channel | Can it continue another Mini App's session? | Verdict | +| --- | --- | --- | +| `context.agent.run({ sessionId })` | No | `assertSessionOwnership` rejects it: `owner !== caller.pluginId` ⇒ `SESSION_NOT_FOUND`. All 45 Bot sessions carry `purpose = 'miniapp:minimax-bot'` | +| Calling the other Mini App's MCP endpoint directly | No | The Host's MCP client sends a per-run credential that other processes do not have | +| Writing a row into `local_runtime_queue_items` by hand | No | That bypasses `steer`; the Host's queue poller ignores rows it did not write. Measured: still `queued` and unclaimed after 3 minutes, while `session send` was accepted in 5 seconds | +| **Running an Agent that calls the `mavis` tool's `session send`** | **Yes** | `session send` admits on reachability alone — *there is no topology check* — so any session holding the `mavis` tool can wake any Bot session. Measured on 2026-10-20: 7 stuck Bots were all woken and answered | + +So the watchdog decides *who* needs waking and writes the request; this Mini App +performs the delivery from inside the Host by starting an agent turn whose agent +calls `session send`. The target Bot keeps its own session, memory and +conversation history. + +**Cost.** Each wake costs one agent turn. A *cold* wake — one that creates a +fresh session — measures ≈93k tokens end to end (input 42k / output 374 / +cache read 51k). Since v1.5 the wake-up turn **reuses one host session** +(`bot-wake-host-session.json`), so the system prompt and tool definitions stay +in the context cache and each wake costs only the increment. The loop therefore +runs **only when there is something pending** — it never spins. + +**Request lifetime.** A request stays alive until it has been **attempted 3 +times** (`MAX_ATTEMPTS`); the 24-hour limit (`MAX_REQUEST_AGE_MS`) is only a +backstop against zombie entries. The attempt counter lives in its own file +(`bot-revive-attempts.json`) because the daemon and this Mini App write +concurrently — a read-modify-write on the shared request file is a lost update. +A successfully woken id is recorded in `bot-revive-acked.json` **with a +timestamp** and is only suppressed for 30 minutes, so a Bot that breaks again +later can be woken again. + +## Local MCP endpoint + +This Mini App exposes a **local MCP endpoint** so other plugins can ask it +directly from their own turns. + +| | | +| --- | --- | +| Implementation | `miniapp/node/mcp.mjs` | +| Declaration | `mcpEndpoints` in `miniapp/miniapp.json` + `servers.mcp.json` at the package root | +| Path | `/mcp/expiry-guardian` (and, for compatibility, `/mcp/minimax-bot`) | +| Transport | streamable-http | + +| Tool | What it does | +| --- | --- | +| `watchdog_status` | Current quota, reset-card / credit expiry, and the **pending wake-up queue** (including how many times each request has been attempted) | +| `watchdog_wake` | Run **one wake-up round right now** (does not wait for the 60 s poll) | +| `watchdog_decide` | Decide right now: should a card be spent, which one, and why (**decides only — never spends**) | + +⚠️ **"Spend a card" is deliberately not exposed**: spending is a real write +(one card fewer), and letting any plugin spend on a whim is too dangerous ⇒ +use the panel button. + +⚠️ **A declared endpoint must really exist**: the Host probes it at publish time +with `initialize` + `tools/list` and fails the publish if the probe does not +answer. The Host's in-memory "published snapshot" is also **sticky** — once a +manifest version declares `mcpEndpoints`, later versions must keep declaring the +same endpoints or `miniapp publish` is rejected with `MCP_PUBLICATION_INVALID`. + +## Retry policy: retryable failures are retried forever + +When the account API answers with a **temporary** failure, the request is retried +until it succeeds — the dashboard does not go blank and the daemon does not treat +a blip as "no data". + +| Condition | Behaviour | +| --- | --- | +| **429** (overloaded / rate limited) | Retried forever | +| **500, 502, 503, 504** (internal / gateway) | Retried forever | +| Connection reset, timeout, DNS failure | Retried forever | +| 401 / 403 (session expired) | Token refreshed, then retried once | +| Other 4xx | Fails fast — retrying cannot help | +| Business errors (invalid params, quota exhausted) | Fails fast | + +Backoff is exponential with jitter, **capped at 60 s**: the first retry waits about +a second, and no attempt ever waits longer than a minute, so "forever" cannot +turn into "hammering the API". Every attempt is logged, so a stuck request always +says what it is waiting for. + +Retries are **interruptible**: `SIGINT` / `SIGTERM` (what `launchctl` sends) make +the in-flight request give up immediately, so the daemon can always be stopped +even while it is waiting on a failing endpoint. + +Spending a reset card is retried too, and that is safe: the request carries +`out_biz_no`, which the server treats as an idempotency key, so a repeated +submission replays the result instead of consuming a second card. + +## Data integrity: "could not read" is not "there is none" + +Reading a balance is not the same as reading a balance of zero. If the credit +endpoints fail, the previous code treated the missing data as "no credits +available" and flipped the decision from *keep the card* to *spend the card* — +and spending a card is a real write. + +Now an incomplete read is reported as incomplete, and the round performs **no +write at all** (`wait`). The same rule applies on the daemon side: a failed +platform-credit or Code-credit lookup marks the data incomplete instead of being +logged and ignored. + +The dashboard also keeps the raw card list alongside the redacted one it +renders. The redacted list has no `card_id`, so using it to check "is this card +still listed?" was always false — which meant the spend call was skipped every +single time. + +## Architecture: who can do what + +| Actor | Pause a goal | Start a turn (resume) | +| --- | --- | --- | +| **Daemon** (outside the Host) | Yes — writes the goal store; the runtime sees it at the next turn boundary | No | +| **This Mini App** (inside the Host) | No | Yes — through an agent turn that calls the `mavis` tool's `session send` | +| **Host main process** (Electron IPC) | Yes | Yes — but it listens on no TCP port, so nothing outside can reach it | + +Resuming a goal therefore needs a delivery channel that lives inside the Host. The +daemon decides and records; this Mini App performs the delivery. The wake loop runs +**only while there is something to deliver**, because each wake costs an agent turn. + +**v1.4 removed the cron watcher.** It was unreliable: the runtime archives the +session a cron watcher runs in, so the next trigger failed with +`CRON_SESSION_TARGET_ARCHIVED` (measured: 4 delivered, 8 failed out of 12), and each +round cost roughly 47k tokens. Nothing about the daemon's decision-making changed — +only the delivery path. + +## Configuration files + +| Path | Purpose | +| --- | --- | +| `~/.config/mcode-reset-card/config.json` | Shared thresholds (above). | +| `~/.config/mcode-reset-card/goal-pause.json` | Ledger of goals **we** paused, so goals you paused by hand are never touched. | +| `~/.config/mcode-reset-card/goal-resume-requests.json` | Pending wake-up requests. | +| `~/.config/mcode-reset-card/goal-resume-acked.json` | Which goal requests have been handled (idempotency). | +| `~/.config/mcode-reset-card/bot-revive-requests.json` | Pending Bot wake-up requests. | +| `~/.config/mcode-reset-card/bot-revive-acked.json` | Which Bot requests have been handled, **with a timestamp** — suppressed for 30 minutes only. | +| `~/.config/mcode-reset-card/bot-revive-attempts.json` | How many times each request has been attempted (**3 ⇒ void**). Separate file so the daemon and the Mini App never lose each other's writes. | +| `~/.config/mcode-reset-card/bot-wake-host-session.json` | The reused wake-up host session id (saves the cold start). | +| `~/.config/mcode-reset-card/bot-notified.json` | Which Bot errors have already been reported, so the same one is not repeated every minute. | +| `/tmp/mcode-reset-card.log` | Daemon log. | + +## Tested environment + +- MiniMax Code 3.1.2 or later on macOS (darwin), verified on macOS 27.0 (arm64). +- Rust toolchain with edition 2024 support (the daemon build). +- Node.js 22 or later for the parity checker. +- The daemon talks to the MiniMax account endpoints directly and reads the local + MiniMax Code state store. It needs the desktop app to be running at least once to + obtain an OAuth lease, but it keeps working after the app is closed. + +## Data & access + +- **Files read:** `~/.config/mcode-reset-card/*.json` (its own config and ledgers), + `~/.minimax/v2/sqlite/runtime-state.sqlite` (read and, for goal status, written). +- **Files written:** the config and ledger files above; `~/.local/bin/reset-card-daemon` + if you install it there. +- **Network:** `www.minimaxi.com` (quota, reset cards, credit expiry) and + `agent.minimaxi.com` (Code credit expiry). No other hosts. +- **Credentials:** the daemon reads the OAuth lease issued by the local desktop app. + It never stores a token itself, and this repository contains no credentials. +- **Agent tasks:** the Mini App runs a small wake loop that reads the request files and + calls `session send` on your behalf to wake a paused goal or a stuck Bot. It runs + only when something is pending, does no other task, and changes nothing except + those files and the Bot's own session. + +## Known boundaries + +- Personal-plan quotas only; team-plan endpoints differ. +- The dashboard lives in the Mini App, so closing the panel stops the board. The + daemon is unaffected. +- A goal whose session has been idle for a long time, or one stuck inside an + unfinished turn, cannot be woken from outside — open that session in MiniMax Code + once so the runtime has a live session to attach to. +- **The Mini App has no resident mode.** The Host accepts `lifecycle: "background"` + in the manifest but forces it back to `"on-demand"` when it reads it. Measured + (2026-10-20): the runtime kept running for **44 minutes** with the panel closed + and kept delivering — the idle drain only fires after a lease is released, and a + lease is only held while a turn is running. The real gap is a **host restart**: + the Host does not start any Mini App automatically afterwards. The daemon now + detects that case and says so explicitly instead of going quiet. +- Each wake costs one agent turn (≈93k tokens when cold; an increment once the + host session is reused), and a Bot that fails with a rate-limit error (50150) + may need another attempt on a later round. +- **The official `minimax-bot` plugin cannot be forked locally** — `miniapp publish` + returns `OFFICIAL_PLUGIN_READ_ONLY`. The wake-up side therefore has to be this + Mini App (it holds `agentAccess: ["session.run"]`). +- ⚠️ **Known Host-side issue**: once a manifest version declares `mcpEndpoints`, + later versions must keep declaring the same endpoints, otherwise + `miniapp publish` is rejected with `MCP_PUBLICATION_INVALID` (the Host's + "published snapshot" in memory is sticky). The current workspace deploys by + syncing straight into `~/.minimax/plugins/expiry-guardian/` and running + `miniapp open`. + +## Documentation + +Full architecture notes, the twenty pitfalls this package works around, and the +verification recipes are in [`docs/README.zh-CN.md`](docs/README.zh-CN.md). +The wake-up channel — every measured dead end included — is in +[`docs/BOT_DIAGNOSIS.md`](docs/BOT_DIAGNOSIS.md). + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/plugins/hylouis233/expiry-guardian/check-test-parity.sh b/plugins/hylouis233/expiry-guardian/check-test-parity.sh new file mode 100755 index 0000000..7e03685 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/check-test-parity.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# 校验「工作区源码」与「插件包」**测试文件**保持一致。 +# +# 2026-10-20 反复踩到的坑:工作区(miniapps/expiry-guardian/)与插件包 +# (plugins/hylouis233/expiry-guardian/)各有一份测试,路径布局还不同 +# (工作区是 `./x.mjs`,插件包是 `../miniapp/node/x.mjs`)。 +# 改了一边忘了另一边 ⇒ 插件包跑的是旧测试,出现「17/18」这种看不懂的结果。 +# +# ⇒ 这个脚本只比**测试文件的内容**(忽略 import 路径差异), +# 不一致就报错并指出是哪几个文件。 +set -euo pipefail + +WORKSPACE="${WORKSPACE:-$HOME/Desktop/mmx/miniapps/expiry-guardian}" +PLUGIN="${PLUGIN:-$(cd "$(dirname "$0")" && pwd)}" + +# 插件包里测试在 tests/,工作区在 miniapp/node/。 +PAIRS=( + "bot_wakeup.test.mjs" + "config-fidelity.test.mjs" + "decision.test.mjs" + "mcp.test.mjs" + "server-lint.test.mjs" +) + +fail=0 +for f in "${PAIRS[@]}"; do + a="$WORKSPACE/miniapp/node/$f" + b="$PLUGIN/tests/$f" + if [ ! -f "$a" ] || [ ! -f "$b" ]; then + echo " SKIP $f(缺文件)" + continue + fi + # 归一化:去掉 import 路径差异与空白差异。 + na=$(sed -E "s#\.\./miniapp/node/##g; s#'\./#'#g" "$a" | tr -d ' \t\r\n') + nb=$(sed -E "s#\.\./miniapp/node/##g; s#'\./#'#g" "$b" | tr -d ' \t\r\n') + if [ "$na" = "$nb" ]; then + echo " OK $f" + else + echo " DIFF $f ← 工作区与插件包不一致" + diff <(sed -E "s#\.\./miniapp/node/##g; s#'\./#'#g" "$a") \ + <(sed -E "s#\.\./miniapp/node/##g; s#'\./#'#g" "$b") | head -12 || true + fail=1 + fi +done + +if [ "$fail" -ne 0 ]; then + echo + echo "❌ 工作区与插件包的测试文件不一致 —— 改完记得两边都同步。" + exit 1 +fi +echo +echo "✅ 工作区与插件包的测试文件一致" diff --git a/plugins/hylouis233/expiry-guardian/daemon/bg_decision.test.py b/plugins/hylouis233/expiry-guardian/daemon/bg_decision.test.py new file mode 100644 index 0000000..2d2b3c3 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/bg_decision.test.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""后台决策测试 —— 逐条对齐 Mini App「蹬王之王」的 decide()。 + +Mini App 的分支(packages/expiry-guardian/miniapp/node/server.mjs::decide): + 未耗尽 → idle + 耗尽 + 无积分 + 有卡 → use-card + 耗尽 + 无积分 + 无卡 → wait + 耗尽 + 有积分 + 无卡 → keep-credit + 耗尽 + 都有,卡先过期 → use-card + 耗尽 + 都有,积分先过期 → keep-credit + +本文件逐条复刻这些分支,任一不一致即视为失败。 +纯函数测试:不触网、不扣卡。 +""" +import importlib.util +import sys +import time +from pathlib import Path + +spec = importlib.util.spec_from_file_location( + "bg", Path(__file__).with_name("reset_card_autouse.py") +) +m = importlib.util.module_from_spec(spec) +sys.modules["bg"] = m +spec.loader.exec_module(m) + +NOW = int(time.time() * 1000) +DAY = 86400000 + + +def qs(quota_type, used, total=100.0, unlimited=False): + return m.QuotaState(quota_type=quota_type, total=total, used=used, is_unlimited=unlimited) + + +def card(days): + return { + "card_id": f"C{days}", + "out_biz_no": f"biz:{days}", + "source_type": "mcode_signin", + "expire_time": NOW + days * DAY, + } + + +passed = failed = 0 + + +def check(name, got, want): + global passed, failed + if got == want: + passed += 1 + print(f" PASS {name} -> {got}") + else: + failed += 1 + print(f" FAIL {name} -> {got} (want {want})") + + +def decide(used5, used7d=None, cards=None, credit_days=None): + quotas = [qs(1, used5)] + quotas.append(qs(2, 0, unlimited=True) if used7d is None else qs(2, used7d)) + exp = None if credit_days is None else NOW + credit_days * DAY + return m.decide_background(quotas, cards or [], exp, "平台积分")[0] + + +print("=== Mini App 分支 1:未到触发线(99%) → idle ===") +# 上界取 98 而不是 99:触发线就是 99%,99% 已经属于「该动手了」, +# 那条在下面单独断言。早先这里写的是 99,是 99% 提前触发设计落地之前的残留。 +for pct in (0, 50, 90, 98): + check(f"5h 已用 {pct}% → idle", decide(pct, cards=[card(7)], credit_days=13), "idle") + +# 触发线本身就应该动手 —— 额度没真正耗尽,但再等一次请求就会失败。 +check("5h 已用 99% → use-card(触发线)", decide(99, cards=[card(7)], credit_days=13), "use-card") + +print("\n=== Mini App 分支 2:耗尽 + 无积分 + 有卡 → use-card ===") +check("5h 100% + 无积分 + 有卡", decide(100, cards=[card(7)], credit_days=None), "use-card") + +print("\n=== Mini App 分支 3:耗尽 + 无积分 + 无卡 → wait ===") +check("5h 100% + 无积分 + 无卡", decide(100, cards=[], credit_days=None), "wait") + +print("\n=== Mini App 分支 4:耗尽 + 有积分 + 无卡 → keep-credit ===") +check("5h 100% + 有积分 + 无卡", decide(100, cards=[], credit_days=13), "keep-credit") + +print("\n=== Mini App 分支 5:耗尽 + 都有 + 卡先过期 → use-card ===") +check("卡 7 天 / 积分 13 天", decide(100, cards=[card(7)], credit_days=13), "use-card") +check("卡 5 天 / 积分 30 天", decide(100, cards=[card(5)], credit_days=30), "use-card") + +print("\n=== Mini App 分支 6:耗尽 + 都有 + 积分先过期 → keep-credit ===") +check("卡 20 天 / 积分 5 天", decide(100, cards=[card(20)], credit_days=5), "keep-credit") +check("卡 30 天 / 积分 1 天", decide(100, cards=[card(30)], credit_days=1), "keep-credit") + +print("\n=== 周限额单独耗尽也应触发(与 Mini App 的 weekly.exhausted 一致)===") +check("5h 未耗尽 + 周 100% → 进入判定", + decide(10, used7d=100, cards=[card(7)], credit_days=13), "use-card") +check("5h 未耗尽 + 周未耗尽 → idle", + decide(10, used7d=50, cards=[card(7)], credit_days=13), "idle") + +print("\n=== 不限量的周限额不算耗尽(与 is_unlimited 判定一致)===") +check("周不限量 + 5h 100% + 卡先过期", decide(100, cards=[card(7)], credit_days=13), "use-card") + +print("\n=== 理由文本 ===") +cases = [ + ("未耗尽", ([qs(1, 50), qs(2, 0, unlimited=True)], [card(7)], NOW + 13 * DAY, "平台积分"), "额度充足"), + ("无积分用卡", ([qs(1, 100), qs(2, 0, unlimited=True)], [card(7)], None, ""), "没有可用积分"), + ("无卡留积分", ([qs(1, 100), qs(2, 0, unlimited=True)], [], NOW + 13 * DAY, "平台积分"), "没有可用的重置卡"), + ("卡先过期", ([qs(1, 100), qs(2, 0, unlimited=True)], [card(7)], NOW + 13 * DAY, "平台积分"), "先用卡保住积分"), + ("积分先过期", ([qs(1, 100), qs(2, 0, unlimited=True)], [card(20)], NOW + 5 * DAY, "平台积分"), "让积分先消耗"), +] +for label, args, needle in cases: + _, reason = m.decide_background(*args) + ok = isinstance(reason, str) and needle in reason + if ok: + passed += 1 + else: + failed += 1 + print(f" {'PASS' if ok else 'FAIL'} {label}: {reason}") + +print(f"\n{passed}/{passed + failed} passed") +sys.exit(0 if failed == 0 else 1) diff --git a/plugins/hylouis233/expiry-guardian/daemon/bg_parity.json b/plugins/hylouis233/expiry-guardian/daemon/bg_parity.json new file mode 100644 index 0000000..73acd83 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/bg_parity.json @@ -0,0 +1,65 @@ +{ + "at": "2026-10-16T19:07:04.579Z", + "results": [ + { + "n": "未耗尽 / 卡7 / 积13", + "action": "idle", + "reason": "额度充足,暂不需要处理" + }, + { + "n": "未耗尽 / 无卡 / 积13", + "action": "idle", + "reason": "额度充足,暂不需要处理" + }, + { + "n": "耗尽 / 卡先过期", + "action": "use-card", + "reason": "重置卡(10月24日)比平台积分(10月30日)先过期,先用卡保住积分" + }, + { + "n": "耗尽 / 积分先过期", + "action": "use-credit", + "reason": "平台积分(10月22日)比重置卡(11月6日)先过期,不用卡,让积分先消耗" + }, + { + "n": "耗尽 / 无卡 / 有积分", + "action": "use-credit", + "reason": "没有可用的重置卡,让平台积分(10月30日到期)先消耗" + }, + { + "n": "耗尽 / 有卡 / 无积分", + "action": "use-card", + "reason": "没有可用积分,使用重置卡重置额度" + }, + { + "n": "耗尽 / 无卡 / 无积分", + "action": "wait", + "reason": "额度已耗尽,既没有积分也没有可用的重置卡" + }, + { + "n": "周限额耗尽 / 5h未耗尽", + "action": "use-card", + "reason": "重置卡(10月24日)比平台积分(10月30日)先过期,先用卡保住积分" + }, + { + "n": "周限额耗尽 / 积分先过期", + "action": "use-credit", + "reason": "平台积分(10月22日)比重置卡(11月6日)先过期,不用卡,让积分先消耗" + }, + { + "n": "周限额未耗尽 / 5h未耗尽", + "action": "idle", + "reason": "额度充足,暂不需要处理" + }, + { + "n": "5h 99% 不算耗尽", + "action": "idle", + "reason": "额度充足,暂不需要处理" + }, + { + "n": "5h 100% 边界算耗尽", + "action": "use-card", + "reason": "重置卡(10月18日)比平台积分(12月16日)先过期,先用卡保住积分" + } + ] +} \ No newline at end of file diff --git a/plugins/hylouis233/expiry-guardian/daemon/check-parity.sh b/plugins/hylouis233/expiry-guardian/daemon/check-parity.sh new file mode 100755 index 0000000..7df1c84 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/check-parity.sh @@ -0,0 +1,73 @@ +#!/bin/sh +# 一致性总检查:决策单测 + 跨语言一致性 + 校验器自检。 +# +# 第三步是重点:如果校验器对「已知错误」也报一致,那它本身失效了, +# 等于没有校验。所以这里主动注入一处漂移,要求必须被抓到。 +# +# 任一环节失败即以非零退出,可直接挂到 CI 或 git hook 上。 +set -e +DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$DIR" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +fail=0 + +echo "=== 1/3 后台决策单测 ===" +if python3 bg_decision.test.py > "$TMP/unit.log" 2>&1; then + tail -1 "$TMP/unit.log" +else + echo "✗ 决策单测失败" + tail -20 "$TMP/unit.log" + fail=1 +fi + +echo +echo "=== 2/3 跨语言一致性(后台 vs Mini App「蹬王之王」)===" +if node verify-parity.mjs > "$TMP/parity.log" 2>&1; then + tail -1 "$TMP/parity.log" +else + echo "✗ 两边策略已漂移" + tail -25 "$TMP/parity.log" + fail=1 +fi + +echo +echo "=== 3/3 校验器自检(注入漂移必须被抓到)===" +python3 - "$TMP" <<'PY' +import pathlib, sys +tmp = pathlib.Path(sys.argv[1]) +src = pathlib.Path("reset_card_autouse.py").read_text() +needle = 'f"没有可用的重置卡,让{credit_desc}({_fmt_day(credit_expiry_ms)}到期)先消耗"' +if needle not in src: + print("✗ 自检锚点已失效:后台代码结构变了,请同步更新 check-parity.sh") + raise SystemExit(1) +# 把「无卡 → keep-credit」改成「无卡 → wait」,这是一处真实的漂移 +broken = src.replace( + 'return "keep-credit", (\n ' + needle, + 'return "wait", "自检注入的错误行为"', +) +if broken == src: + print("✗ 注入失败:目标分支结构与预期不符") + raise SystemExit(1) +(tmp / "broken.py").write_text(broken) +PY + +if [ ! -f "$TMP/broken.py" ]; then + echo "✗ 自检注入失败,跳过" + fail=1 +elif MCODE_BG_SCRIPT="$TMP/broken.py" node verify-parity.mjs > "$TMP/selftest.log" 2>&1; then + echo "✗ 校验器对注入的漂移仍报一致 —— 校验器本身已失效" + fail=1 +else + echo "✓ 成功检出注入的漂移" + grep -m1 '动作不一致' "$TMP/selftest.log" | sed 's/^/ /' || true +fi + +echo +if [ "$fail" -eq 0 ]; then + echo "✓ 全部通过:后台服务与 Mini App 策略一致" +else + echo "✗ 存在不一致,需修复" +fi +exit "$fail" diff --git a/plugins/hylouis233/expiry-guardian/daemon/reset_card_autouse.py b/plugins/hylouis233/expiry-guardian/daemon/reset_card_autouse.py new file mode 100755 index 0000000..69c2a56 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/reset_card_autouse.py @@ -0,0 +1,1179 @@ +#!/usr/bin/env python3 +"""MiniMax Code 重置卡自动使用器。 + +监控 Token Plan 用量额度,在「额度耗尽、请求开始落到积分计费」的那一刻, +自动使用一张重置卡,把被扣积分的额度重置回来。 + +实现依据(来自已安装桌面端 MiniMax Inside Code.app 的打包代码,非猜测): + + 额度查询 GET {BASE}/backend/account/token_plan/remains_percent + → model_remains[],quotaType 1=5小时限额 2=周限额 3=视频限额 + → isUnlimited: current_interval_status === 3 + → 耗尽判定沿用桌面端:total>0 且 used/total 达到上限 + + 重置卡 GET {BASE}/backend/account/reset_card/list?page=&size= + POST {BASE}/backend/account/reset_card/use {source_type, out_biz_no} + 卡历史 GET {BASE}/backend/account/reset_card/flows + + source_type 1=FIVE_HOUR(5小时限额) 2=WEEKLY(周限额) 3=VIDEO(视频限额) + outcome SUCCESS | REPLAYED | EXPIRED | NOT_FOUND | RETRYABLE_FAIL | LOCK_BUSY + + 鉴权 Authorization: Bearer (与桌面端同一套登录态) + 业务错误 base_resp.status_code !== 0 时按错误码分派 + +默认按用户确认的策略执行: + 1. 只在「额度耗尽且已开始扣积分」这一刻扣卡,不提前用 + 2. 优先扣 5 小时限额卡;仅当 5 小时限额也耗尽时才动用周限额卡 + 3. 跳过已过期卡,只用未过期的 + +不修改已安装的 app.asar,升级应用不受影响。 +""" + +from __future__ import annotations + +import argparse +import json +import os +import random +import socket +import struct +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +import uuid +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + +# ── 常量:与桌面端保持一致 ──────────────────────────────────────────────────── +# 账号/计费接口的真实根地址(取自桌面端 d5 的生产分支)。 +# 注意:不是 agent.minimax.cn —— 那是网页域,对 /backend/account/* 一律 404。 +BASE_CN = "https://www.minimaxi.com" +BASE_GLOBAL = "https://platform.minimax.io" +# Code 积分走另一个域名(platform 域对这些路径返回非 JSON) +CODE_BASE_CN = "https://agent.minimaxi.com" +CODE_BASE_GLOBAL = "https://agent.minimax.io" + +QUOTA_FIVE_HOUR = 1 +QUOTA_WEEKLY = 2 +QUOTA_VIDEO = 3 + +# 判定阈值的**默认值**,仅在 config.json 缺失/非法时兜底。 +# 实际生效值一律来自 load_config() —— 与 Node / Rust 读同一个文件。 +QUOTA_TRIGGER_PERCENT = 99.0 +# 四档红绿灯的两个分界点(功能3)。燃尽档由 used >= total 判定,不可配。 +QUOTA_TIGHT_PERCENT = 80.0 +QUOTA_LOW_PERCENT = 95.0 +# 距 5 小时额度刷新不足这么多分钟时,不消耗重置卡(额度马上就自己回来了)。 +# 实际生效值来自 config.json 的 cardSkipBeforeResetMinutes。 +QUOTA_CARD_SKIP_BEFORE_RESET_MINUTES = 60.0 + +# 等级 → 中文。**属于 parity 契约的一部分**,改一个字三方都要改。 +LEVEL_LABEL = { + "comfortable": "额度充足", + "tight": "额度紧张", + "low": "额度不足", + "burned": "额度燃尽", +} + +QUOTA_LABEL = { + QUOTA_FIVE_HOUR: "5小时限额", + QUOTA_WEEKLY: "周限额", + QUOTA_VIDEO: "视频限额", +} + +# ── 共享配置(功能1/2/3)──────────────────────────────────────────────────── +# 与 Mini App 侧、Rust 侧读同一个文件: +# ~/.config/mcode-reset-card/config.json +CONFIG_PATH = os.path.expanduser("~/.config/mcode-reset-card/config.json") + +CONFIG_DIR = os.path.dirname(CONFIG_PATH) + + +def default_config() -> dict[str, Any]: + return { + "schemaVersion": 1, + "cardTriggerPercent": QUOTA_TRIGGER_PERCENT, + "cardSkipBeforeResetMinutes": QUOTA_CARD_SKIP_BEFORE_RESET_MINUTES, + "resumeCronId": "", + "quotaLevels": {"tight": QUOTA_TIGHT_PERCENT, "low": QUOTA_LOW_PERCENT}, + "goalPause": {"enabled": False, "triggerPercent": QUOTA_TRIGGER_PERCENT, + "resumeAtPercent": 0.0}, + } + + +def _clamp(v: Any, lo: float, hi: float, fallback: float) -> float: + try: + f = float(v) + except (TypeError, ValueError): + return fallback + if f != f: # NaN + return fallback + return min(hi, max(lo, f)) + + +def normalize_config(raw: Any) -> tuple[dict[str, Any], list[str]]: + """逐字段校验并夹紧,返回 (配置, 问题列表)。 + + 非法组合**整体回退默认并报出来**,绝不静默纠正 —— + 静默纠正会让人以为自己设的生效了。 + """ + d = default_config() + issues: list[str] = [] + src = raw if isinstance(raw, dict) else {} + if raw is not None and not isinstance(raw, dict): + issues.append("配置根节点不是对象") + + raw_card = src.get("cardTriggerPercent") + card = _clamp(raw_card, 1.0, 100.0, d["cardTriggerPercent"]) + if raw_card is not None: + try: + if float(raw_card) != card: + issues.append(f"cardTriggerPercent={raw_card} 超出 1..100,已夹到 {card}") + except (TypeError, ValueError): + issues.append(f"cardTriggerPercent={raw_card} 不是数字,已用默认值 {card}") + + lv = src.get("quotaLevels") if isinstance(src.get("quotaLevels"), dict) else {} + tight = _clamp(lv.get("tight"), 1.0, 100.0, d["quotaLevels"]["tight"]) + low = _clamp(lv.get("low"), 1.0, 100.0, d["quotaLevels"]["low"]) + if not tight < low: + if lv: + issues.append( + f"quotaLevels 需满足 tight < low(收到 tight={tight} low={low}),已整体回退默认") + tight = d["quotaLevels"]["tight"] + low = d["quotaLevels"]["low"] + + gp = src.get("goalPause") if isinstance(src.get("goalPause"), dict) else {} + raw_skip = src.get("cardSkipBeforeResetMinutes") + skip = _clamp(raw_skip, 0.0, 1440.0, d["cardSkipBeforeResetMinutes"]) + if raw_skip is not None and skip != _clamp(raw_skip, 0.0, 1440.0, d["cardSkipBeforeResetMinutes"]): + issues.append(f"cardSkipBeforeResetMinutes={raw_skip} 超出 0..1440,已夹到 {skip}") + return { + "schemaVersion": 1, + "cardTriggerPercent": card, + "cardSkipBeforeResetMinutes": skip, + "resumeCronId": str(src.get("resumeCronId") or "").strip(), + "quotaLevels": {"tight": tight, "low": low}, + "goalPause": { + "enabled": gp.get("enabled") is True, + "triggerPercent": _clamp(gp.get("triggerPercent"), 1.0, 100.0, + d["goalPause"]["triggerPercent"]), + "resumeAtPercent": _clamp(gp.get("resumeAtPercent"), 0.0, 100.0, + d["goalPause"]["resumeAtPercent"]), + }, + }, issues + + +def load_config() -> tuple[dict[str, Any], list[str]]: + """读配置。文件不存在是正常情况(首次运行),返回默认值且不报 issue。""" + try: + with open(CONFIG_PATH, encoding="utf-8") as f: + raw = json.load(f) + except FileNotFoundError: + return default_config(), [] + except (OSError, ValueError) as e: + return default_config(), [f"配置读取失败({e}),正在用默认值"] + return normalize_config(raw) + +# 桌面端 base_resp 业务错误码 → 语义 +ERR_UNAUTHORIZED = 1004 +ERR_GROUP_NOT_MEMBER = 1003 +ERR_PERMISSION_DENIED = 2029 +ERR_INVALID_PARAMS = 2013 + + +class ApiError(RuntimeError): + """业务或网络错误。""" + + def __init__(self, message: str, *, kind: str = "business", code: int | None = None): + super().__init__(message) + self.kind = kind + self.code = code + + +def log(msg: str) -> None: + ts = time.strftime("%Y-%m-%d %H:%M:%S") + print(f"[{ts}] {msg}", flush=True) + + +# ── HTTP 客户端 ─────────────────────────────────────────────────────────────── +class MatrixClient: + """最小化的账号 API 客户端,复用桌面端同一套鉴权与错误语义。""" + + def __init__(self, token: str, base: str = BASE_CN, timeout: float = 15.0): + if not token: + raise ValueError("token 不能为空") + self.token = token + self.base = base.rstrip("/") + self.timeout = timeout + # Code 积分接口在另一个域名上,随地域切换 + self.code_base = (CODE_BASE_GLOBAL if self.base == BASE_GLOBAL else CODE_BASE_CN).rstrip("/") + + def _request(self, method: str, path: str, params: dict | None = None, + body: dict | None = None, base: str | None = None) -> dict[str, Any]: + url = f"{base or self.base}{path}" + if params: + clean = {k: v for k, v in params.items() if v is not None} + if clean: + url = f"{url}?{urllib.parse.urlencode(clean)}" + + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Authorization", f"Bearer {self.token}") + req.add_header("Accept", "application/json") + req.add_header("User-Agent", "MiniMaxCode") + if data is not None: + req.add_header("Content-Type", "application/json") + + try: + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + raw = resp.read() + except urllib.error.HTTPError as exc: + detail = exc.read()[:400].decode("utf-8", "ignore") + if exc.code in (401, 403): + raise ApiError(f"鉴权失败 HTTP {exc.code},token 可能已过期", kind="unauthorized") from exc + raise ApiError(f"HTTP {exc.code}: {detail}", kind="network", code=exc.code) from exc + except urllib.error.URLError as exc: + raise ApiError(f"网络不可用: {exc.reason}", kind="network") from exc + + try: + payload = json.loads(raw) + except json.JSONDecodeError as exc: + raise ApiError(f"响应不是合法 JSON: {raw[:200]!r}", kind="network") from exc + + # 桌面端:base_resp.status_code !== 0 即业务错误 + base_resp = payload.get("base_resp") + if isinstance(base_resp, dict) and base_resp.get("status_code", 0) != 0: + code = int(base_resp.get("status_code", 0)) + msg = base_resp.get("status_msg") or base_resp.get("msg") or f"code={code}" + kind = { + ERR_UNAUTHORIZED: "unauthorized", + ERR_GROUP_NOT_MEMBER: "group", + ERR_PERMISSION_DENIED: "permission", + ERR_INVALID_PARAMS: "invalid-params", + }.get(code, "business") + raise ApiError(f"{msg} (code={code})", kind=kind, code=code) + + return payload + + # ── 账号接口 ───────────────────────────────────────────────────────────── + def remains_percent(self) -> dict[str, Any]: + return self._request("GET", "/backend/account/token_plan/remains_percent") + + def platform_credit(self) -> dict[str, Any]: + """平台积分池(含 balance_breakdown:各池余额与到期时间)。""" + return self._request("GET", "/backend/account/token_plan_credit") + + def code_credit_details(self, page: int = 1, page_size: int = 100) -> dict[str, Any]: + """Code 积分逐笔明细。 + + 参数名是 page_number / page_size(下划线),且 include_expired=1 才会 + 返回历史记录 —— 桌面端就是这样调的。page_size 上限 100。 + """ + return self._request( + "GET", + f"/minimax-cloud/api/v1/credit/details?page_number={page}&page_size={page_size}&include_expired=1", + base=self.code_base, + ) + + def membership_info(self) -> dict[str, Any]: + """账户积分汇总(op_credit_summary),POST。""" + return self._request("POST", "/matrix/api/v1/commerce/get_membership_info", body={}, base=self.code_base) + + def list_reset_cards(self, page: int = 1, size: int = 20) -> list[dict[str, Any]]: + data = self._request("GET", "/backend/account/reset_card/list", + params={"page": page, "size": size}) + cards = data.get("cards") or [] + return cards if isinstance(cards, list) else [] + + def use_reset_card(self, source_type: str, out_biz_no: str) -> str: + """返回 outcome。 + + source_type 与 out_biz_no 都取自卡对象本身(服务端下发的字符串, + 如 'mcode_signin' / 'mcode_admin_benefit'),原样透传,不做本地枚举映射。 + out_biz_no 由服务端为每张卡生成,同时充当幂等键。 + """ + data = self._request("POST", "/backend/account/reset_card/use", + body={"source_type": source_type, "out_biz_no": out_biz_no}) + return str(data.get("outcome") or "UNKNOWN") + + def reset_card_flows(self, page: int = 1, size: int = 20) -> list[dict[str, Any]]: + data = self._request("GET", "/backend/account/reset_card/flows", + params={"page": page, "size": size}) + flows = data.get("flows") or [] + return flows if isinstance(flows, list) else [] + + +# ── 额度判定 ────────────────────────────────────────────────────────────────── +@dataclass +class QuotaState: + """一个限额维度的用量状态。""" + + quota_type: int + total: float = 0.0 + used: float = 0.0 + is_unlimited: bool = False + reset_time: int | None = None + + def is_triggered(self, trigger_percent: float) -> bool: + """是否达到扣卡触发线。 + + 阈值必须**显式传入** —— 写死在本类里正是当初三方漂移的根源: + 配置改了,这里还按 99 算,而且不报错。 + """ + if self.is_unlimited or self.total <= 0.0: + return False + return self.used >= self.total * trigger_percent / 100.0 + + def level(self, tight: float = QUOTA_TIGHT_PERCENT, + low: float = QUOTA_LOW_PERCENT) -> str: + """四档红绿灯分档。与 Rust ``quota_level()`` 逐行对应。 + + 燃尽档由 used >= total 判定,**不可配** —— 它是事实,不是阈值。 + """ + if self.is_unlimited or self.total <= 0.0: + return "comfortable" + if self.used >= self.total: + return "burned" + ratio = self.used / self.total * 100.0 + if ratio >= low: + return "low" + if ratio >= tight: + return "tight" + return "comfortable" + + @property + def exhausted(self) -> bool: + """是否已耗尽。 + + 接口语义(对齐桌面端 V/q 两个转换函数): + used = *_used_percent → **已用百分比**,如 "71%" → 71 + total = *_total_percent → 总额度百分比,缺省按 100 计 + + 所以耗尽就是「已用百分比 ≥ 总额度百分比」。不限量(status===3)不算耗尽。 + """ + if self.is_unlimited or self.total <= 0: + return False + return self.used >= self.total - 1e-9 + + @property + def remaining_percent(self) -> float: + if self.is_unlimited or self.total <= 0: + return 100.0 + return max(0.0, (self.total - self.used) / self.total * 100.0) + + def describe(self) -> str: + if self.is_unlimited: + return f"{QUOTA_LABEL.get(self.quota_type, self.quota_type)}=不限量" + return (f"{QUOTA_LABEL.get(self.quota_type, self.quota_type)}=" + f"已用{self.used:.0f}% / 剩余{self.remaining_percent:.0f}%") + + +def _num(value: Any, default: float = 0.0) -> float: + """把接口字段转成数字。 + + 关键:`*_percent` 字段返回的是**带百分号的字符串**,例如 "71%" / "100%", + 不是数字。直接 float() 会抛 ValueError 并静默退回 0,导致把 + 「已用 71%」误判成「已用 0% / 剩余 100%」——曾因此漏判额度耗尽。 + 这里与桌面端一致,用 parseFloat(v.replace(/%/g,''))。 + """ + if isinstance(value, str): + value = value.strip().replace("%", "") + if value is None or value == "": + return default + try: + f = float(value) + return f if f == f else default # 过滤 NaN + except (TypeError, ValueError): + return default + + +def parse_quotas(payload: dict[str, Any]) -> list[QuotaState]: + """按桌面端 queryPercent 的解析逻辑提取 5小时/周/视频限额。""" + models = payload.get("model_remains") or [] + quotas: list[QuotaState] = [] + + general = next((m for m in models if isinstance(m, dict) and m.get("model_name") == "general"), None) + if general is None: + general = next((m for m in models if isinstance(m, dict) and m.get("model_name")), None) + + if general: + quotas.append(QuotaState( + quota_type=QUOTA_FIVE_HOUR, + total=_num(general.get("current_interval_total_percent")), + used=_num(general.get("current_interval_used_percent")), + is_unlimited=general.get("current_interval_status") == 3, + reset_time=general.get("end_time"), + )) + quotas.append(QuotaState( + quota_type=QUOTA_WEEKLY, + total=_num(general.get("current_weekly_total_percent")), + used=_num(general.get("current_weekly_used_percent")), + is_unlimited=general.get("current_weekly_status") == 3, + reset_time=general.get("weekly_end_time"), + )) + + video = next((m for m in models + if isinstance(m, dict) + and m.get("model_name") != "general" + and _num(m.get("current_interval_total_count")) > 0), None) + if video: + quotas.append(QuotaState( + quota_type=QUOTA_VIDEO, + total=_num(video.get("current_interval_total_count")), + used=_num(video.get("current_interval_usage_count")), + is_unlimited=video.get("current_interval_status") == 3, + reset_time=video.get("end_time"), + )) + + return quotas + + +# ── 核心:自动扣一张卡 ──────────────────────────────────────────────────────── +STATE_DIR = os.path.expanduser("~/.config/mcode-reset-card") +STATE_FILE = os.path.join(STATE_DIR, "state.json") + + +@dataclass +class AutoUseStats: + used_count: int = 0 + skipped_no_card: int = 0 + skipped_not_exhausted: int = 0 + errors: int = 0 + seen_fallback: set[str] = field(default_factory=set) + + +def load_state() -> dict[str, Any]: + """读取持久化状态:记录已针对哪个耗尽组合扣过卡。 + + 必须持久化:只在内存里去重的话,进程重启就会重复扣同一张卡。 + """ + try: + with open(STATE_FILE, encoding="utf-8") as fh: + data = json.load(fh) + return data if isinstance(data, dict) else {} + except (OSError, json.JSONDecodeError): + return {} + + +def save_state(data: dict[str, Any]) -> None: + os.makedirs(STATE_DIR, mode=0o700, exist_ok=True) + tmp = f"{STATE_FILE}.tmp" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(data, fh, ensure_ascii=False, indent=2) + os.replace(tmp, STATE_FILE) + os.chmod(STATE_FILE, 0o600) + + +def _pick_card(cards: list[dict[str, Any]], now_ms: int) -> dict | None: + """挑一张未过期的卡(优先最早到期,避免临期浪费)。 + + 不在本地按限额维度过滤:卡自带的 source_type 是服务端下发的字符串 + (如 'mcode_signin'),与哪个限额重置的对应关系由服务端判定。 + """ + usable = [] + for card in cards: + if not isinstance(card, dict): + continue + expire = card.get("expire_time") + # 过期卡直接跳过,避免必然失败的 EXPIRED + if isinstance(expire, (int, float)) and expire <= now_ms: + continue + usable.append(card) + + if not usable: + return None + usable.sort(key=lambda c: (c.get("expire_time") or 2**53, c.get("create_time") or 0)) + return usable[0] + + +def fetch_all_cards(client: "Session | MatrixClient", max_pages: int = 10) -> list[dict[str, Any]]: + """翻页拉取全部重置卡(桌面端最多翻到 50 页)。 + + 同时接受 Session(自动续期)与裸 MatrixClient,两种调用方都能用。 + """ + def _list(page: int, size: int) -> list[dict[str, Any]]: + if isinstance(client, MatrixClient): + return client.list_reset_cards(page=page, size=size) + return client.call("list_reset_cards", page=page, size=size) + + seen: dict[str, dict] = {} + for page in range(1, max_pages + 1): + cards = _list(page, 20) + if not cards: + break + for card in cards: + if isinstance(card, dict): + cid = card.get("card_id") + if cid: + seen[cid] = card + if len(cards) < 20: + break + return list(seen.values()) + + +def try_auto_use(client: Session, stats: AutoUseStats, + *, dry_run: bool = False) -> bool: + """检测额度耗尽并自动扣一张卡。返回是否发生了扣卡动作。 + + 策略(按用户确认): + - 只在额度耗尽这一刻扣,不提前用 + - 跳过已过期卡,只用未过期的 + - 失败不无限重试 + + client 传 Session 而非裸 MatrixClient,以便 token 过期时自动续期。 + """ + quotas = parse_quotas(client.call("remains_percent")) + by_type = {q.quota_type: q for q in quotas} + + five_hour = by_type.get(QUOTA_FIVE_HOUR) + weekly = by_type.get(QUOTA_WEEKLY) + + five_exhausted = bool(five_hour and five_hour.exhausted) + weekly_exhausted = bool(weekly and weekly.exhausted) + + log("额度状态: " + " | ".join(q.describe() for q in quotas)) + + # 「已开始扣积分」= 至少一个限额耗尽 + if not (five_exhausted or weekly_exhausted): + stats.skipped_not_exhausted += 1 + return False + + # 选卡优先级:先 5 小时;5 小时与周限额都耗尽时,周限额也可以用 + candidates: list[int] = [] + if five_exhausted: + candidates.append(QUOTA_FIVE_HOUR) + if weekly_exhausted and five_exhausted: + candidates.append(QUOTA_WEEKLY) + + if not candidates: + stats.skipped_no_card += 1 + return False + + # 耗尽轮次标识 = 限额组合 + 各自的重置时间。 + # 只用「组合」去重会永久封死:额度恢复后再次耗尽会被误判为已扣过。 + # 带上 reset_time 后,同一轮耗尽只扣一张卡,跨轮(新 5 小时窗口)才允许再扣。 + cycle = "|".join( + f"{q.quota_type}:{q.reset_time or 0}" for q in quotas + if q.quota_type in (QUOTA_FIVE_HOUR, QUOTA_WEEKLY) + ) + fingerprint = f"{cycle}#5h={'1' if five_exhausted else '0'},7d={'1' if weekly_exhausted else '0'}" + state = load_state() + spent = set(state.get("spent_fingerprints") or []) + if fingerprint in spent: + return False + stats.seen_fallback.add(fingerprint) + + now_ms = int(time.time() * 1000) + cards = fetch_all_cards(client) + live = [c for c in cards + if isinstance(c.get("expire_time"), (int, float)) and c["expire_time"] > now_ms] + log(f"重置卡: 共 {len(cards)} 张,未过期 {len(live)} 张") + + exhausted_names = "、".join(QUOTA_LABEL.get(t, str(t)) for t in candidates) + card = _pick_card(cards, now_ms) + if card is None: + log(f"无可用重置卡,跳过({exhausted_names} 已耗尽)") + stats.skipped_no_card += 1 + return False + + card_id = card.get("card_id") + source_type = str(card.get("source_type") or "") + out_biz_no = str(card.get("out_biz_no") or "") + expire = card.get("expire_time") + expire_txt = time.strftime("%Y-%m-%d %H:%M", time.localtime(expire / 1000)) \ + if isinstance(expire, (int, float)) else "未知" + + if not source_type or not out_biz_no: + log(f"✗ 卡 {card_id} 缺少 source_type/out_biz_no,跳过") + stats.skipped_no_card += 1 + return False + + if dry_run: + log(f"[dry-run] 本应使用重置卡 {card_id}({source_type},{expire_txt} 到期),已跳过实际扣卡") + stats.used_count += 1 + return True + + log(f"额度耗尽({exhausted_names}),自动使用重置卡 {card_id}({source_type},{expire_txt} 到期)…") + + # 先落盘再请求:进程中途崩溃也不会把这张卡再扣一次。 + # out_biz_no 用服务端下发的值,天然充当幂等键。 + spent.add(fingerprint) + state["spent_fingerprints"] = sorted(spent) + history = list(state.get("history") or []) + history.append({ + "at": now_ms, "card_id": card_id, "source_type": source_type, + "out_biz_no": out_biz_no, "fingerprint": fingerprint, + "expire_time": expire, + }) + state["history"] = history[-100:] + save_state(state) + + try: + outcome = client.call("use_reset_card", source_type, out_biz_no) + except ApiError as exc: + if exc.kind == "network": + # 网络错误:结果未知,重试会用同一张卡,不会重复扣卡 + log(f"⚠️ 扣卡结果未知(网络错误):{exc}。稍后重试会复用同一张卡,不会重复扣卡。") + else: + log(f"✗ 扣卡失败:{exc}") + stats.errors += 1 + return False + + if outcome in ("SUCCESS", "REPLAYED"): + log(f"✓ 重置卡使用成功(outcome={outcome}),额度已重置") + stats.used_count += 1 + return True + if outcome in ("EXPIRED", "NOT_FOUND"): + log(f"✗ 重置卡不可用(outcome={outcome}),跳过") + stats.skipped_no_card += 1 + return False + if outcome == "LOCK_BUSY": + log(f"⏳ 重置卡被占用(outcome=LOCK_BUSY),下一轮重试") + stats.errors += 1 + return False + log(f"⏳ 重置卡可重试失败(outcome={outcome}),下一轮重试") + stats.errors += 1 + return False + + +# ── 命令 ────────────────────────────────────────────────────────────────────── +AUTH_CAP_FILE = os.path.expanduser("~/.minimax/run/mcode-auth-lease-v1.cap") +AUTH_SOCK_FILE = os.path.expanduser("~/.minimax/run/mcode-auth-lease-v1.sock") +AUTH_TOKEN_FILE = os.path.expanduser("~/.config/mcode-reset-card/token") + + +def acquire_lease_token(min_validity_ms: int = 120_000) -> str | None: + """通过桌面端的 OAuth lease socket 取当前登录态的 access token。 + + 这是与桌面端完全相同的凭据来源,无需手工复制粘贴。token 有效期较短 + (约 40 分钟),因此每次需要调用前重新获取,而不是长期缓存。 + + 需要桌面端正在运行(它才是 lease 的服务端)。协议见 + packages/oauth-lease-protocol:4 字节大端长度前缀 + JSON。 + """ + if not (os.path.exists(AUTH_SOCK_FILE) and os.path.exists(AUTH_CAP_FILE)): + return None + try: + with open(AUTH_CAP_FILE, encoding="utf-8") as fh: + capability = fh.read().strip() + except OSError: + return None + if not capability: + return None + + payload = json.dumps({ + "version": 1, + "requestId": uuid.uuid4().hex, + "capability": capability, + "method": "lease", + "minValidityMs": min_validity_ms, + }).encode() + frame = struct.pack(">I", len(payload)) + payload + + sock = None + try: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(5) + sock.connect(AUTH_SOCK_FILE) + sock.sendall(frame) + + buf = b"" + while len(buf) < 4: + chunk = sock.recv(65536) + if not chunk: + return None + buf += chunk + size = struct.unpack(">I", buf[:4])[0] + while len(buf) < 4 + size: + chunk = sock.recv(65536) + if not chunk: + break + buf += chunk + resp = json.loads(buf[4:4 + size].decode("utf-8")) + except (OSError, ValueError, struct.error): + return None + finally: + if sock is not None: + sock.close() + + if not resp.get("ok"): + return None + result = resp.get("result") or {} + if result.get("method") == "unauthorized": + return None + if result.get("method") != "lease": + return None + token = result.get("accessToken") + return token if isinstance(token, str) and token else None + + +def persist_token(token: str) -> None: + """把 token 存到 600 权限文件,仅供人工排查使用。""" + os.makedirs(os.path.dirname(AUTH_TOKEN_FILE), mode=0o700, exist_ok=True) + with open(AUTH_TOKEN_FILE, "w", encoding="utf-8") as fh: + fh.write(token) + os.chmod(AUTH_TOKEN_FILE, 0o600) + + +def resolve_token(*, refresh: bool = True) -> str: + """解析 token,优先从桌面端 lease 实时获取,其次环境变量,最后本地文件。 + + refresh=True 时会先试 lease:这样 token 过期后能自动续上,无需人工干预。 + """ + if refresh: + token = acquire_lease_token() + if token: + return token + + for env in ("MCODE_TOKEN", "MINIMAX_TOKEN"): + val = os.environ.get(env, "").strip() + if val: + return val + if os.path.isfile(AUTH_TOKEN_FILE): + with open(AUTH_TOKEN_FILE, encoding="utf-8") as fh: + val = fh.read().strip() + if val: + return val + + raise SystemExit( + "无法获取登录 token。请确保 MiniMax Code 桌面端正在运行(它提供登录态),\n" + "或设置环境变量 MCODE_TOKEN / MINIMAX_TOKEN。\n" + "详见 README「获取 token」。" + ) + + +class Session: + """按需刷新 token 的客户端封装。 + + token 约 40 分钟过期,所以遇到鉴权失败时自动重新取 lease 再重试一次, + 这是实现真正无人值守的关键。 + """ + + def __init__(self, base: str): + self.base = base + self.token = resolve_token() + self.client = MatrixClient(self.token, base=base) + + def _refresh(self) -> None: + self.token = resolve_token() + self.client = MatrixClient(self.token, base=self.base) + + def call(self, fn_name: str, *args, **kwargs): + """执行一次 API 调用;鉴权失败时刷新 token 重试一次。""" + try: + return getattr(self.client, fn_name)(*args, **kwargs) + except ApiError as exc: + if exc.kind not in ("unauthorized",) and exc.code not in (1004, 1016): + raise + log(f"凭据失效({exc}),正在从桌面端刷新…") + self._refresh() + return getattr(self.client, fn_name)(*args, **kwargs) + + +def cmd_status(session: Session) -> int: + quotas = parse_quotas(session.call("remains_percent")) + print("── 额度状态 ──") + for q in quotas: + mark = "已耗尽 ⚠️" if q.exhausted else "可用" + reset_txt = "" + if q.reset_time: + reset_txt = f" (重置于 {time.strftime('%m-%d %H:%M', time.localtime(q.reset_time / 1000))})" + print(f" {q.describe():<28} {mark}{reset_txt}") + print("── 重置卡 ──") + now = int(time.time() * 1000) + try: + cards = fetch_all_cards(session) + except ApiError as exc: + print(f" 拉取失败:{exc}") + return 1 + if not cards: + print(" 无重置卡") + for card in cards: + expire = card.get("expire_time") + expire_txt = time.strftime("%Y-%m-%d %H:%M", time.localtime(expire / 1000)) \ + if isinstance(expire, (int, float)) else "未知" + state = "未过期" if isinstance(expire, (int, float)) and expire > now else "已过期" + label = QUOTA_LABEL.get(card.get("source_type"), card.get("source_type")) + print(f" {card.get('card_id')} {str(label):<10} {expire_txt} {state}") + + history = load_state().get("history") or [] + if history: + print("── 本工具自动扣卡记录 ──") + for item in history[-10:]: + label = QUOTA_LABEL.get(item.get("source_type"), item.get("source_type")) + when = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(item["at"] / 1000)) + print(f" {when} {label} card={item.get('card_id')}") + return 0 + + +def cmd_once(client: MatrixClient, args) -> int: + try: + result = background_tick(client, dry_run=True) + except ApiError as exc: + print(f"失败:{exc}", file=sys.stderr) + return 1 + print(f"动作:{result['action']}") + print(f"理由:{result['reason']}") + return 0 + + +# ── 后台无人值守决策 ────────────────────────────────────────────────────────── +# 策略与 Mini App「蹬王之王」保持完全一致:同样只在**真正耗尽**时才动手, +# 同样按「先过期先用」决定用卡还是留卡。 +# +# 保留这两个常量只为可读性与将来微调,但判定逻辑统一走 QuotaState.exhausted +# (used >= total,即 100%),不再有 90% 这类提前量。 +TRIGGER_USED_PERCENT = 99.0 +WEEKLY_TRIGGER_PERCENT = 100.0 + +BUCKET_LABEL = {2: "充值", 3: "赠送"} + + +def _amount(value: Any, default: float = 0.0) -> float: + if isinstance(value, str): + value = value.replace(",", "").strip() + try: + n = float(value) + return n if n == n else default + except (TypeError, ValueError): + return default + + +def _fmt_day(ms: int | None) -> str: + if not ms: + return "未知" + d = datetime.fromtimestamp(ms / 1000) + return f"{d.month}月{d.day}日" + + +def earliest_credit_expiry(client: "Session | MatrixClient") -> tuple[int | None, str, bool]: + """全账户里最早到期的一笔「仍有效且有余额」的积分。 + + 两个来源取更早的:平台积分池 + Code 积分明细。 + 单个来源失败不影响另一个,宁可少算也不编造。 + + 返回 (最早到期时刻, 来源描述, 数据是否完整)。 + + ⚠️ 2026-10-19 PR #28 review 修:读不到 ≠ 没有。 + 以前读取失败只是「忽略」,`best` 保持 None ⇒ 与「确实没有积分」无法区分 + ⇒ decide 会把结论翻成「用卡」。而扣卡是**真实写操作**, + 网络故障不该导致误扣 ⇒ 第三个返回值标记数据完整性。 + """ + now = int(time.time() * 1000) + best: int | None = None + desc = "" + complete = True + + def consider(exp: Any, balance: Any, label: str) -> None: + nonlocal best, desc + if not isinstance(exp, (int, float)) or exp <= now: + return + if balance <= 0: + return + if best is None or exp < best: + best = int(exp) + desc = label + + try: + credit = client.call("platform_credit") if isinstance(client, Session) else client.platform_credit() + for b in (credit.get("balance_breakdown") or {}).get("buckets", []) or []: + if not isinstance(b, dict): + continue + consider(b.get("expire_time_ms"), _amount(b.get("balance")), + f"平台积分·{BUCKET_LABEL.get(b.get('source'), '未知')}") + except Exception as exc: # noqa: BLE001 + complete = False + log(f" 平台积分到期读取失败(本轮不做写操作): {exc}") + + try: + raw = client.call("code_credit_details") if isinstance(client, Session) else client.code_credit_details() + for item in (raw or {}).get("details") or []: + if not isinstance(item, dict): + continue + label = "Code 积分·赠送" if item.get("credit_type") == 2 else "Code 积分·购买" + consider(item.get("expire_at_ms"), _amount(item.get("remaining_amount")), label) + except Exception as exc: # noqa: BLE001 + complete = False + log(f" Code 积分到期读取失败(本轮不做写操作): {exc}") + + return best, desc, complete + + +def _minutes_to_reset(q: "QuotaState | None", now_ms: int) -> float | None: + """某个额度维度距离刷新还有多少分钟。取不到刷新时间时返回 None。 + + 与 Rust 的 `minutes_to_reset()` 同一口径:已过去的时间按 0 算。 + """ + if q is None or q.reset_time is None: + return None + return max(0.0, (q.reset_time - now_ms) / 60_000.0) + + +def decide_background( + quotas: list[QuotaState], + usable_cards: list[dict[str, Any]], + credit_expiry_ms: int | None, + credit_desc: str, + cfg: dict[str, Any] | None = None, + now_ms: int | None = None, + credit_data_complete: bool = True, +) -> tuple[str, str]: + """后台扣卡决策,返回 (动作, 理由)。 + + `credit_data_complete` 表示「积分数据是否**完整读到**」。 + + ⚠️ 2026-10-19 PR #28 review 修:读不到 ≠ 没有。 + `credit_expiry_ms = None` 同时可能表示「确实没有积分」与「接口读失败」, + 以前两者都落到 use-card —— 而扣卡是真实写操作, + 网络故障会被当成业务状态,可能误扣真卡。 + ⇒ 数据不完整时一律**不做写操作**(wait),只如实报出来。 + + 动作 ∈ {"use-card", "keep-credit", "idle", "wait"}。纯函数,不触碰网络,可直接单测。 + + **分支与 Mini App「蹬王之王」的 decide() 一一对应**,刻意保持同构: + + 未耗尽 → idle (额度等级 + 已用量 + 未到触发线) + 耗尽 + 无积分 + 有卡 → use-card (没有可用积分,用卡重置额度) + 耗尽 + 无积分 + 无卡 → wait + 耗尽 + 有积分 + 无卡 → keep-credit (没卡可用,让积分先消耗) + 耗尽 + 都有,卡先过期 → use-card (先用卡保住积分) + 耗尽 + 都有,积分先过期 → keep-credit (让积分先消耗,留住更耐放的卡) + + `cfg` 省略时从共享配置文件读取(与 Node / Rust 读同一个文件)。 + `now_ms` 用于判断「额度是不是马上就要刷新了」。 + """ + if cfg is None: + cfg, _issues = load_config() + if now_ms is None: + now_ms = int(time.time() * 1000) + + trigger = float(cfg["cardTriggerPercent"]) + tight = float(cfg["quotaLevels"]["tight"]) + low = float(cfg["quotaLevels"]["low"]) + skip_minutes = float(cfg.get("cardSkipBeforeResetMinutes", QUOTA_CARD_SKIP_BEFORE_RESET_MINUTES)) + + by_type = {q.quota_type: q for q in quotas} + five = by_type.get(QUOTA_FIVE_HOUR) + weekly = by_type.get(QUOTA_WEEKLY) + + five_exhausted = bool(five and five.is_triggered(trigger)) + weekly_exhausted = bool(weekly and weekly.is_triggered(trigger)) + + # ── 5 小时额度即将自己刷新 → 不烧重置卡(纯浪费)── + reset_in_min = _minutes_to_reset(five, now_ms) + skip_card_reason = None + if skip_minutes > 0 and reset_in_min is not None and reset_in_min <= skip_minutes: + skip_card_reason = f"5 小时额度 {reset_in_min:.0f} 分钟后刷新,暂不消耗重置卡" + + # ── 未到触发线:与 Mini App 一致,不做任何事 ── + if not five_exhausted and not weekly_exhausted: + used5 = five.used if five is not None else 0.0 + # 等级词随分档走,而不是永远写「额度充足」—— + # 否则用量 92% 时页面上仍显示「额度充足」,是误导。 + # 把触发线也说进来:否则「额度不足但暂不需要处理」看起来自相矛盾。 + # burned 永远不会出现在这里:它必然已 triggered,走的是下面用卡/留卡分支。 + level = five.level(tight, low) if five is not None else "comfortable" + return "idle", ( + f"{LEVEL_LABEL[level]}(5 小时已用 {used5:.0f}%)," + f"未到 {trigger:.0f}% 触发线,暂不动用重置卡" + ) + + # ── 积分数据没读全 ⇒ 无法判断「卡先过期还是积分先过期」── + # ⇒ 绝不做写操作(扣卡是真实消耗,宁可等下一轮)。 + if not credit_data_complete: + return "wait", "积分数据读取不完整,本轮不执行写操作(等下一轮重新判定)" + + # ── 无可用积分:只能靠卡把额度重置回来 ── + if credit_expiry_ms is None: + if usable_cards: + if skip_card_reason: + return "wait", skip_card_reason + return "use-card", "没有可用积分,使用重置卡重置额度" + return "wait", "额度已耗尽,既没有积分也没有可用的重置卡" + + # ── 有积分但没有可用卡:让积分先消耗 ── + if not usable_cards: + return "keep-credit", ( + f"没有可用的重置卡,让{credit_desc}({_fmt_day(credit_expiry_ms)}到期)先消耗" + ) + + card_expiry = usable_cards[0].get("expire_time") + if card_expiry is None: + return "keep-credit", "没有可用重置卡,让积分先消耗" + + # ── 两者都有:比「先过期先用」 ── + if card_expiry < credit_expiry_ms: + if skip_card_reason: + return "wait", skip_card_reason + return "use-card", ( + f"重置卡({_fmt_day(card_expiry)})比{credit_desc}({_fmt_day(credit_expiry_ms)})先过期," + f"先用卡保住积分" + ) + return "keep-credit", ( + f"{credit_desc}({_fmt_day(credit_expiry_ms)})比重置卡({_fmt_day(card_expiry)})先过期," + f"不用卡,让积分先消耗" + ) + + +def background_tick(client: "Session | MatrixClient", *, dry_run: bool = False) -> dict[str, Any]: + """后台一轮:判定 + (必要时)扣卡。返回本轮结果摘要,便于写日志。""" + quotas = parse_quotas( + client.call("remains_percent") if isinstance(client, Session) else client.remains_percent() + ) + now = int(time.time() * 1000) + + cards = fetch_all_cards(client) + # 与 Mini App 的 liveCards 过滤完全一致:未过期 + 有 out_biz_no + 有 source_type + usable = [ + c for c in cards + if c.get("expire_time") is not None + and c["expire_time"] > now + and c.get("out_biz_no") + and c.get("source_type") + ] + usable.sort(key=lambda c: c.get("expire_time") or float("inf")) + + credit_expiry, credit_desc, credit_data_complete = earliest_credit_expiry(client) + cfg, cfg_issues = load_config() + for _s in cfg_issues: + log(f"⚠️ {_s}") + action, reason = decide_background( + quotas, usable, credit_expiry, credit_desc, cfg, now, credit_data_complete + ) + + log("额度: " + " | ".join(q.describe() for q in quotas)) + log(f"决策[{action}]: {reason}") + + result = {"action": action, "reason": reason, "at": int(time.time()), "used": False} + if action != "use-card" or dry_run: + return result + + card = usable[0] + fingerprint = f"{card.get('card_id')}:{card.get('out_biz_no')}" + state = background_state() + if fingerprint in state["spent"]: + log("这张卡本进程已尝试过,跳过") + return result + + if dry_run: + log(f"[dry-run] 本应使用重置卡 {card.get('card_id')},已跳过实际扣卡") + result["used"] = True + return result + + # 先记账再请求:崩溃也不会重复扣 + state["spent"].add(fingerprint) + persist_background_state(state) + try: + outcome = (client.call("use_reset_card", str(card["source_type"]), str(card["out_biz_no"])) + if isinstance(client, Session) + else client.use_reset_card(str(card["source_type"]), str(card["out_biz_no"]))) + except ApiError as exc: + log(f"✗ 扣卡失败:{exc}") + return result + + if outcome in ("SUCCESS", "REPLAYED"): + log(f"✓ 已使用重置卡 {card.get('card_id')},额度已重置") + result["used"] = True + else: + log(f"✗ 重置卡不可用({outcome})") + return result + + +BG_STATE_FILE = os.path.expanduser("~/.config/mcode-reset-card/bg-state.json") + + +def background_state() -> dict[str, Any]: + """后台去重记录落盘,进程重启也不会重复扣同一张卡。""" + try: + with open(BG_STATE_FILE, encoding="utf-8") as fh: + data = json.load(fh) + if isinstance(data, dict): + data.setdefault("spent", []) + data["spent"] = list(data.get("spent") or []) + return data + except (OSError, json.JSONDecodeError, AttributeError): + pass + return {"spent": []} + + +def persist_background_state(data: dict[str, Any]) -> None: + os.makedirs(os.path.dirname(BG_STATE_FILE), mode=0o700, exist_ok=True) + tmp = BG_STATE_FILE + ".tmp" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(data, fh, ensure_ascii=False, indent=2) + os.replace(tmp, BG_STATE_FILE) + os.chmod(BG_STATE_FILE, 0o600) + + +def cmd_watch(session: Session, args) -> int: + log(f"启动后台监控,间隔 {args.interval}s(Ctrl+C 退出)") + log("扣卡策略:与 Mini App「蹬王之王」一致 —— 额度真正耗尽才动手,并按先过期先用") + while True: + try: + background_tick(session, dry_run=args.dry_run) + except ApiError as exc: + if exc.kind == "unauthorized": + # Session 已自动重试过一次仍失败,多半是桌面端没登录/没运行 + log(f"✗ 鉴权失败:{exc}。请确认 MiniMax Code 桌面端已启动并处于登录状态。") + return 1 + log(f"⚠️ 本轮失败:{exc},下一轮继续") + except KeyboardInterrupt: + log("已停止") + return 0 + except Exception as exc: # noqa: BLE001 - 后台长跑不能让单轮异常终止进程 + log(f"⚠️ 本轮异常:{type(exc).__name__}: {exc},下一轮继续") + # 抖动,避免与整点/整 5 小时重置撞在一起 + time.sleep(max(5.0, args.interval + random.uniform(-2, 2))) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="MiniMax Code 重置卡自动使用器", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--base", default=os.environ.get("MCODE_BASE", BASE_CN), + help="API 根地址(默认国内版 https://www.minimaxi.com)") + parser.add_argument("--interval", type=float, default=60.0, + help="watch 模式轮询间隔秒数(默认 60)") + parser.add_argument("--dry-run", action="store_true", + help="只检测不扣卡,打印本应执行的动作") + sub = parser.add_subparsers(dest="command") + sub.add_parser("status", help="打印额度与重置卡状态") + sub.add_parser("once", help="检测一次,必要时扣一张卡") + sub.add_parser("watch", help="常驻监控,自动扣卡") + + reset_parser = sub.add_parser("forget", help="清空本地去重记录(不会动服务器上的卡)") + reset_parser.add_argument("--all", action="store_true", + help="同时清空扣卡历史,只保留去重记录") + + args = parser.parse_args() + command = args.command or "once" + + # forget 不需要 token + if command == "forget": + state = load_state() + state["spent_fingerprints"] = [] + if getattr(args, "all", False): + state["history"] = [] + save_state(state) + print("已清空本地去重记录。") + return 0 + + try: + session = Session(base=args.base) + except (ValueError, SystemExit) as exc: + print(str(exc), file=sys.stderr) + return 1 + + if command == "status": + return cmd_status(session) + if command == "watch": + return cmd_watch(session, args) + return cmd_once(session, args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/hylouis233/expiry-guardian/daemon/rust/.gitignore b/plugins/hylouis233/expiry-guardian/daemon/rust/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/rust/.gitignore @@ -0,0 +1 @@ +/target diff --git a/plugins/hylouis233/expiry-guardian/daemon/rust/Cargo.lock b/plugins/hylouis233/expiry-guardian/daemon/rust/Cargo.lock new file mode 100644 index 0000000..5c0dc4d --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/rust/Cargo.lock @@ -0,0 +1,833 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "reset-card-daemon" +version = "1.0.0" +dependencies = [ + "serde", + "serde_json", + "ureq", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/plugins/hylouis233/expiry-guardian/daemon/rust/Cargo.toml b/plugins/hylouis233/expiry-guardian/daemon/rust/Cargo.toml new file mode 100644 index 0000000..73b8afd --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/rust/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "reset-card-daemon" +version = "1.0.0" +edition = "2024" + +[dependencies] +# HTTP 客户端:手写 TLS 容易出错且难维护,用成熟实现更可靠 +ureq = { version = "3", default-features = false, features = ["rustls", "json", "gzip"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "z" # 体积优先:后台常驻服务不需要极致速度 +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/plugins/hylouis233/expiry-guardian/daemon/rust/src/api.rs b/plugins/hylouis233/expiry-guardian/daemon/rust/src/api.rs new file mode 100644 index 0000000..e29c15d --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/rust/src/api.rs @@ -0,0 +1,552 @@ +//! 账号 API 客户端 + OAuth lease token。 +//! +//! 域名划分是实测结论,不是猜的: +//! 开放平台 / 额度 / 重置卡 → www.minimaxi.com(agent 域对这些路径 404 或返回 HTML) +//! Code 积分 / 会员信息 → agent.minimaxi.com(platform 域返回非 JSON) + +use std::io::{Read, Write}; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// 进程是否收到过终止信号(SIGINT / SIGTERM)。 +/// +/// 「无限重试」必须**可中断** —— 否则 launchctl 想停守护进程时, +/// 它正卡在一次无限重试的 HTTP 调用里,永远出不来。 +/// 每次重试前检查这个标志;收到信号就立刻放弃这一轮并退出。 +pub static STOPPED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// 收到终止信号时置位。 +pub fn request_stop() { + STOPPED.store(true, std::sync::atomic::Ordering::SeqCst); +} + +/// 是否已被要求停止。 +pub fn should_stop() -> bool { + STOPPED.load(std::sync::atomic::Ordering::SeqCst) +} + +/// 安装 SIGINT / SIGTERM 处理器:第一次收到就置位停止标志。 +/// +/// 第二次信号直接退出(用户可能真的不耐烦了)。 +pub fn install_signal_handlers() { + unsafe extern "C" fn handler(_sig: i32) { + if should_stop() { + std::process::exit(130); + } + request_stop(); + } + unsafe { + signal(SIGINT, handler as usize); + signal(SIGTERM, handler as usize); + } +} + +const SIGINT: i32 = 2; +const SIGTERM: i32 = 15; + +unsafe extern "C" { + fn signal(signum: i32, handler: usize) -> usize; +} + +pub const BASE_CN: &str = "https://www.minimaxi.com"; +pub const BASE_GLOBAL: &str = "https://platform.minimax.io"; +pub const CODE_BASE: &str = "https://agent.minimaxi.com"; + +const LEASE_SOCK: &str = ".minimax/run/mcode-auth-lease-v1.sock"; +const LEASE_CAP: &str = ".minimax/run/mcode-auth-lease-v1.cap"; + +const TOKEN_TTL_MS: u64 = 30 * 60 * 1000; +const TOKEN_REFRESH_MARGIN_MS: u64 = 5 * 60 * 1000; + +// ── 可重试错误:无限重试 + 指数退避 ──────────────────────────────────────────── +// +// 2026-10-19 新增。目标:遇到「服务端内部错误」或「负载过高」时**一直重试** +// 直到成功,而不是让守护进程把这一轮判成失败、把看板显示成「读不到数据」。 +// +// 判定依据(HTTP 状态码): +// 429 负载过高 / 限流 → 重试 +// 500 内部错误 → 重试 +// 502 网关错误 → 重试 +// 503 服务不可用 → 重试 +// 504 网关超时 → 重试 +// +// 明确**不**重试的: +// 401 / 403 登录态失效 —— 重试无用,要重新取 token(走已有的 token 失效分支) +// 其他 4xx 客户端错误 —— 重试没有意义 +// 业务错误 确定性失败(如参数非法、额度不足) +// +// 退避:min(base * 2^(n-1), cap) + 抖动,base=1s cap=60s。 +// 封顶 60s 是为了「无限重试」不会把接口打爆,也不会指数增长到没有尽头。 +pub const RETRY_BASE_MS: u64 = 1_000; +pub const RETRY_CAP_MS: u64 = 60_000; + +/// 一次失败的**重试决策**。 +#[derive(Debug, PartialEq, Eq)] +pub enum RetryDecision { + /// 值得重试(暂时性错误):退避后重试,直到成功。 + Retry, + /// 登录态失效:清 token 重取一次。 + RefreshToken, + /// 确定性失败:直接报错,重试没有意义。 + Fatal(String), +} + +/// 根据一次请求的错误决定下一步。 +/// +/// 抽成纯函数是为了**能直接单测**(不依赖网络与真实 token)。 +/// 2026-10-19 新增无限重试时引入。 +pub fn decide_retry(err: &ureq::Error) -> RetryDecision { + match err { + // 401/403 ⇒ 登录态失效,重取 token(只重试一次,见调用点) + ureq::Error::StatusCode(401) | ureq::Error::StatusCode(403) => RetryDecision::RefreshToken, + // 429 负载过高 / 5xx 内部与网关错误 ⇒ 暂时性,值得一直重试 + ureq::Error::StatusCode(code) if is_retryable_status(*code) => RetryDecision::Retry, + ureq::Error::StatusCode(code) => RetryDecision::Fatal(format!("HTTP {code}")), + // 网络层错误(连接重置 / 超时 / DNS 抖动)⇒ 也值得重试 + ureq::Error::Io(_) | ureq::Error::Timeout(_) | ureq::Error::HostNotFound => { + RetryDecision::Retry + } + other => RetryDecision::Fatal(format!("{other}")), + } +} + +/// 该 HTTP 状态码是否值得重试。 +pub fn is_retryable_status(status: u16) -> bool { + matches!(status, 429 | 500 | 502 | 503 | 504) +} + +/// 退避时长:指数增长 + 抖动,封顶 `RETRY_CAP_MS`。 +/// +/// 抖动是必要的 —— 否则大量重试会形成「惊群」,同时打在同一时刻。 +pub fn retry_delay_ms(attempt: u32) -> u64 { + let exp = RETRY_BASE_MS + .saturating_mul(1u64 << attempt.saturating_sub(1).min(16)) + .min(RETRY_CAP_MS); + exp + (pseudo_random() % 500) +} + +/// 轻量伪随机(xorshift)——只为抖动,不需要密码学强度。 +fn pseudo_random() -> u64 { + use std::cell::Cell; + thread_local! { + static STATE: Cell = const { Cell::new(0x9E3779B97F4A7C15) }; + } + STATE.with(|s| { + let mut x = s.get(); + if x == 0 { + x = 0x9E3779B97F4A7C15; + } + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + s.set(x); + x + }) +} + +pub fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +#[derive(Debug)] +pub enum ApiError { + Unauthorized(String), + Network(String), + Business(String), +} + +impl ApiError { + pub fn message(&self) -> &str { + match self { + ApiError::Unauthorized(m) | ApiError::Network(m) | ApiError::Business(m) => m, + } + } + pub fn is_unauthorized(&self) -> bool { + matches!(self, ApiError::Unauthorized(_)) + } +} + +impl std::fmt::Display for ApiError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message()) + } +} + +fn home() -> PathBuf { + PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/".into())) +} + +fn random_hex(nbytes: usize) -> String { + let mut buf = vec![0u8; nbytes]; + if std::fs::File::open("/dev/urandom") + .and_then(|mut f| f.read_exact(&mut buf)) + .is_err() + { + buf[..8].copy_from_slice(&now_ms().to_be_bytes()); + } + buf.iter().map(|b| format!("{b:02x}")).collect() +} + +/// 向桌面端的 lease socket 取一个短期 access token。 +/// 协议:4 字节大端长度前缀 + JSON。详见 packages/oauth-lease-protocol。 +pub fn acquire_lease_token() -> Option { + let capability = std::fs::read_to_string(home().join(LEASE_CAP)) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty())?; + let sock_path = home().join(LEASE_SOCK); + if !sock_path.exists() { + return None; + } + + let payload = serde_json::json!({ + "version": 1, + "requestId": random_hex(16), + "capability": capability, + "method": "lease", + "minValidityMs": 120_000i64, + }) + .to_string(); + + let body = payload.as_bytes(); + let mut frame = Vec::with_capacity(4 + body.len()); + frame.extend_from_slice(&(body.len() as u32).to_be_bytes()); + frame.extend_from_slice(body); + + let mut stream = UnixStream::connect(&sock_path).ok()?; + stream.set_read_timeout(Some(Duration::from_secs(5))).ok()?; + stream.set_write_timeout(Some(Duration::from_secs(5))).ok()?; + stream.write_all(&frame).ok()?; + + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf).ok()?; + let size = u32::from_be_bytes(len_buf) as usize; + if size > 1 << 20 { + return None; + } + let mut buf = vec![0u8; size]; + stream.read_exact(&mut buf).ok()?; + + let resp: serde_json::Value = serde_json::from_slice(&buf).ok()?; + if resp.get("ok").and_then(|v| v.as_bool()) != Some(true) { + return None; + } + let result = resp.get("result")?; + if result.get("method").and_then(|v| v.as_str()) != Some("lease") { + return None; + } + result + .get("accessToken") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +/// token 来源:环境变量 → 本地文件 → lease socket +pub fn resolve_token() -> Option { + for env in ["MCODE_TOKEN", "MINIMAX_TOKEN"] { + if let Ok(v) = std::env::var(env) { + let t = v.trim().to_string(); + if !t.is_empty() { + return Some(t); + } + } + } + if let Ok(t) = std::fs::read_to_string(home().join(".config/mcode-reset-card/token")) { + let t = t.trim().to_string(); + if !t.is_empty() { + return Some(t); + } + } + acquire_lease_token() +} + +/// ureq Agent 自带 keep-alive 连接池:后台每轮打多个 API, +/// 复用连接省掉每轮的 TLS 握手 —— 这是常驻进程最大的一笔固定开销。 +pub struct Client { + agent: ureq::Agent, + token: String, + cached_at: u64, +} + +impl Client { + pub fn new(base: &str) -> Result { + let token = resolve_token().ok_or_else(|| { + ApiError::Unauthorized( + "未获取到登录态,请确认 MiniMax Code 桌面端正在运行且已登录".into(), + ) + })?; + + let config = ureq::Agent::config_builder() + .timeout_global(Some(Duration::from_secs(20))) + .timeout_connect(Some(Duration::from_secs(5))) + .max_idle_connections(8) + .build(); + let agent: ureq::Agent = config.into(); + + // 用一个明确的 base 做首连校验,确保 base 可用 + let c = Client { agent, token, cached_at: now_ms() as u64 }; + let _ = base; + Ok(c) + } + + fn token(&mut self) -> String { + let age = (now_ms() as u64).saturating_sub(self.cached_at); + if age >= TOKEN_TTL_MS - TOKEN_REFRESH_MARGIN_MS { + if let Some(t) = acquire_lease_token() { + self.token = t; + self.cached_at = now_ms() as u64; + } + } + self.token.clone() + } + + fn base_for(&self, code_domain: bool) -> &'static str { + if code_domain { CODE_BASE } else { BASE_CN } + } + + pub fn get(&mut self, path: &str, code_domain: bool) -> Result { + self.do_request("GET", path, None, code_domain) + } + + pub fn post( + &mut self, + path: &str, + body: &serde_json::Value, + code_domain: bool, + ) -> Result { + self.do_request("POST", path, Some(body), code_domain) + } + + fn do_request( + &mut self, + method: &str, + path: &str, + body: Option<&serde_json::Value>, + code_domain: bool, + ) -> Result { + let base = self.base_for(code_domain); + let url = format!("{base}{path}"); + + // 鉴权失败重取 token 再试一次;其余可重试错误**无限重试**。 + // + // ⚠️ 无限重试的前提是「每次重试都真的会变好」: + // - 401/403 ⇒ 重新取 token 后重试(最多一次,之后仍失败就报错) + // - 429/5xx/网络错误 ⇒ 退避后重试,直到成功 + // 退避封顶 60s,所以「无限」不会变成「疯狂打接口」。 + let mut attempt = 0u32; + let mut retry_count = 0u32; + loop { + let token = self.token(); + let auth = format!("Bearer {token}"); + let accept = "application/json"; + let ua = "MiniMaxCode"; + + let result = match (method, body) { + ("GET", None) => self + .agent + .get(&url) + .header("Authorization", &auth) + .header("Accept", accept) + .header("User-Agent", ua) + .call(), + ("POST", Some(b)) => self + .agent + .post(&url) + .header("Authorization", &auth) + .header("Accept", accept) + .header("User-Agent", ua) + .send_json(b.clone()), + _ => return Err(ApiError::Network(format!("不支持的请求 {method}"))), + }; + + match result { + Ok(resp) => { + let text = resp + .into_body() + .read_to_string() + .map_err(|e| ApiError::Network(format!("读取响应体失败: {e}")))?; + let value: serde_json::Value = + serde_json::from_str(&text).map_err(json_err)?; + return check_business(value); + } + Err(e) => match decide_retry(&e) { + // 登录态失效:清 token 重取一次(只一次)。 + RetryDecision::RefreshToken if attempt == 0 => { + self.cached_at = 0; + attempt += 1; + continue; + } + RetryDecision::RefreshToken => { + return Err(ApiError::Unauthorized("登录态失效".into())); + } + // ── 可重试的服务端错误 / 网络错误:无限重试 ── + // 429 负载过高、5xx 内部与网关错误、连接重置 —— 都是**暂时性**的, + // 重试有意义。退避封顶 60s,不会把接口打爆。 + RetryDecision::Retry => { + if should_stop() { + return Err(ApiError::Network("收到终止信号,放弃重试".into())); + } + retry_count += 1; + let delay = retry_delay_ms(retry_count); + crate::log(&format!( + "⚠️ {method} {path} 失败(可重试,第 {retry_count} 次):{e} ⇒ {secs}s 后重试", + secs = delay / 1000, + )); + std::thread::sleep(Duration::from_millis(delay)); + continue; + } + // 确定性失败:重试没有意义,直接报错。 + RetryDecision::Fatal(msg) => { + return Err(ApiError::Network(msg)); + } + }, + } + } + // 走到这里说明 loop 被 break —— 当前实现里所有分支都 return/continue, + // 所以这行不可达。保留它是为了让函数在将来引入 break 时仍能返回错误。 + } +} + +fn json_err(e: serde_json::Error) -> ApiError { + ApiError::Network(format!("JSON 解析失败: {e}")) +} + +/// 检查 `base_resp.status_code`。 +/// +/// 这是踩过的坑:`{"total":0,"base_resp":{"status_code":2013,"status_msg":"invalid params"}}` +/// 是**错误**,不是空结果。必须在这里拦下,否则会把接口错误读成「没有记录」。 +fn check_business(value: serde_json::Value) -> Result { + if let Some(code) = value + .get("base_resp") + .and_then(|b| b.get("status_code")) + .and_then(|v| v.as_i64()) + { + if code != 0 { + let msg = value + .get("base_resp") + .and_then(|b| b.get("status_msg")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + if code == 1004 { + return Err(ApiError::Unauthorized(format!("{msg} (code={code})"))); + } + return Err(ApiError::Business(format!("{msg} (code={code})"))); + } + } + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn business_error_detected() { + let v = serde_json::json!({"total":0,"base_resp":{"status_code":2013,"status_msg":"invalid params"}}); + match check_business(v) { + Err(ApiError::Business(m)) => assert!(m.contains("invalid params")), + other => panic!("应当识别为业务错误,实际 {other:?}"), + } + } + + #[test] + fn success_passes_through() { + let v = serde_json::json!({"base_resp":{"status_code":0},"data":[1,2]}); + assert!(check_business(v).is_ok()); + } + + #[test] + fn unauthorized_code() { + let v = serde_json::json!({"base_resp":{"status_code":1004,"status_msg":"not login"}}); + assert!(matches!(check_business(v), Err(ApiError::Unauthorized(_)))); + } + + /// 回归测试:可重试错误码的判定。 + /// + /// 2026-10-19 新增「无限重试」功能:遇到 500(内部错误)与 429(负载过高) + /// 应当一直重试;401/403(登录态失效)与其他 4xx 属于确定性失败, + /// 重试没有意义,必须快速失败。 + #[test] + fn retryable_status_set() { + for code in [429u16, 500, 502, 503, 504] { + assert!(is_retryable_status(code), "HTTP {code} 应当可重试"); + } + for code in [400u16, 401, 403, 404, 422] { + assert!(!is_retryable_status(code), "HTTP {code} 不该重试"); + } + } + + /// 退避必须**封顶**,否则「无限重试」会指数增长到没有尽头。 + #[test] + fn retry_delay_is_capped_and_grows() { + let d1 = retry_delay_ms(1); + let d5 = retry_delay_ms(5); + let d50 = retry_delay_ms(50); + let d1000 = retry_delay_ms(1000); + assert!(d5 > d1, "退避应随次数增长:d1={d1} d5={d5}"); + assert!( + d50 <= RETRY_CAP_MS + 500, + "退避必须封顶在 cap+抖动内:d50={d50} cap={RETRY_CAP_MS}" + ); + assert!( + d1000 <= RETRY_CAP_MS + 500, + "无限次重试也不能超过 cap+抖动:d1000={d1000}" + ); + } + + /// 回归测试:一次失败的**重试决策**。 + /// + /// 2026-10-19 新增无限重试时引入的纯函数(可脱离网络单测): + /// - 429 / 5xx / 网络错误 ⇒ Retry(一直重试) + /// - 401 / 403 ⇒ RefreshToken(重取 token,一次) + /// - 其他 4xx ⇒ Fatal(确定性失败,快速失败) + #[test] + fn retry_decision_matrix() { + // 可重试:负载过高与内部/网关错误 + for code in [429u16, 500, 502, 503, 504] { + assert_eq!( + decide_retry(&ureq::Error::StatusCode(code)), + RetryDecision::Retry, + "HTTP {code} 应当一直重试" + ); + } + // 登录态失效:重取 token + for code in [401u16, 403] { + assert_eq!( + decide_retry(&ureq::Error::StatusCode(code)), + RetryDecision::RefreshToken, + "HTTP {code} 应当重取 token" + ); + } + // 确定性失败:快速失败 + for code in [400u16, 404, 422] { + match decide_retry(&ureq::Error::StatusCode(code)) { + RetryDecision::Fatal(msg) => assert!(msg.contains(&code.to_string())), + other => panic!("HTTP {code} 应当快速失败,实际 {other:?}"), + } + } + // 网络层错误也重试 + assert_eq!( + decide_retry(&ureq::Error::HostNotFound), + RetryDecision::Retry, + "DNS 解析失败应当重试" + ); + } + + /// 停止标志:置位后 should_stop 为真(无限重试靠它可中断)。 + #[test] + fn stop_flag_roundtrip() { + assert!(!should_stop(), "初始状态应为未停止"); + request_stop(); + assert!(should_stop(), "置位后应为已停止"); + request_stop(); // 幂等 + assert!(should_stop()); + // 复位,避免影响其它测试 + STOPPED.store(false, std::sync::atomic::Ordering::SeqCst); + } +} diff --git a/plugins/hylouis233/expiry-guardian/daemon/rust/src/bot_bootstrap.rs b/plugins/hylouis233/expiry-guardian/daemon/rust/src/bot_bootstrap.rs new file mode 100644 index 0000000..4ab4c7f --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/rust/src/bot_bootstrap.rs @@ -0,0 +1,400 @@ +//! 唤醒链路的**自检**:宿主重启后,蹬王之王自己的 MiniApp 可能没被拉起来, +//! 于是「判定」正常、「投递」空转 —— 守护进程必须把这件事**说出来**。 +//! +//! # 背景:为什么这里不能自动修复(2026-10-20 穷尽验证的结果) +//! +//! 唤醒的执行方是**蹬王之王自己的 MiniApp**(它有 `agentAccess:["session.run"]`, +//! 能用 `agent.run` 起回合,再用 mavis 工具的 `session send` 唤醒目标 bot)。 +//! 但 MiniApp 是 **on-demand** 的,宿主**不会自动拉起它** —— 实测宿主 09:39 重启后, +//! 蹬王之王的运行时直到 13:18 才起来(我手动 open 的)。 +//! +//! 而「从宿主外拉起 MiniApp」这条路,**逐条实测全部堵死**: +//! +//! | 通道 | 结果 | 原因 | +//! | --- | --- | --- | +//! | 往 `local_runtime_queue_items` 写消息 | ❌ | 队列轮询器只在会话有活跃 dispatch 时取项;**连正在跑的会话都不取**(实测:连投 idle 与 started 两种会话,3 分钟内都是 `queued` + `claim_id` 为空) | +//! | MiniApp 的 HTTP(55932) | ❌ | `host-runner.js` 的 `installMiniAppLoopbackGuard` 要 `Host: 127.0.0.1:` + `x-mavis-miniapp-token`;token **只存在于宿主进程**,不传给 MiniApp 子进程 | +//! | 宿主 HTTP(58925) | ❌ | 同上(per-run 凭据),且宿主只有 3 个路由,没有起回合/拉 MiniApp 的接口 | +//! | `minimax-cn://chat?message=` | ❌ | `chat` handler 由渲染进程注册;实测 deeplink 触发不了回合 | +//! | `minimax-cn://navigate?url=` | ❌ | 只导航渲染进程页面,不会启动 MiniApp 运行时 | +//! | 官方 minimax-bot 插件的 MCP | ❌ | 它自己也是 MiniApp ⇒ 同样 on-demand;且官方插件不可改版 | +//! +//! ⇒ **从宿主外无法自动拉起 MiniApp**。这不是本插件的设计取舍,是宿主的边界。 +//! +//! # 那能做什么 +//! +//! 1. **检测**:运行时没起 + 有待唤醒请求 ⇒ 这是「判定正常但投递空转」的状态。 +//! 2. **说清楚**:把这个状态写进日志与面板,让用户知道「需要打开一次蹬王之王面板」。 +//! ⚠️ 绝不静默 —— 静默的话,守护进程看起来一切正常,实际一个 bot 都没被叫醒。 +//! 3. **不打扰**:只在「确实有 pending 请求」时才提示;没有 pending 就不出声。 +//! +//! # 唯一确认可行的自举路径(供未来参考) +//! +//! 宿主在准备**任何一个回合**时,只要消息里出现 `@`,就会 `ensureStarted` +//! 把那个 MiniApp 拉起来(`turn-preparation.ts` 的 `prepareReferencedMiniApps`), +//! 前提是该插件**声明了 MCP 端点**(`referenced.filter(c => c.mcpEndpoints.length > 0)`)。 +//! 实测有效:2026-10-20 14:37 我往一个活跃会话发了一条 `@expiry-guardian 看门狗探针`, +//! 宿主随即拉起了蹬王之王的运行时。 +//! ⇒ 只要**宿主内有人发一条带 `@expiry-guardian` 的消息**,自举就成立。 +//! 这需要宿主内的 `session send`(进程内 adapter),宿主外拿不到 —— 故本模块只做检测与提示。 + +use std::path::{Path, PathBuf}; + +/// 插件包名(= 运行时 package 目录里 `package.json` 的 `name` 字段)。 +/// +/// 用它区分「蹬王之王的运行时」与「别的 MiniApp 的运行时」—— +/// 实测同时存在 3 个 `mavis-miniapp-*` 目录,其中只有 1 个有活进程。 +pub const PLUGIN_NAME: &str = "expiry-guardian"; + +/// 宿主为 MiniApp 运行时建的临时目录名标记。 +const MINIAPP_PACKAGE_MARKER: &str = "mavis-miniapp-"; + +/// 自检结果。 +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum SelfCheck { + /// **没有待唤醒请求** —— 无论运行时在不在,都无需打扰用户。 + Idle, + /// **有请求,运行时也在跑** ⇒ 投递链路正常。 + Delivering { pending: usize }, + /// **有请求投不出去**:运行时没起(多半是宿主重启后没被拉起)。 + /// + /// 这是唯一需要用户知道的状态:判定在工作,但唤醒没在工作。 + NeedsAttention { pending: usize }, + /// 自检本身出错(不影响其它功能)。 + Failed(String), +} + +/// 蹬王之王的 MiniApp 运行时是否在跑。 +/// +/// 判据:**有一个活着的进程,它的工作目录正是本插件的运行时包目录**。 +/// +/// ⚠️ 为什么不用「端口在监听」: +/// - 端口号是宿主的默认分配值,宿主改规则就会误判; +/// - 而且端口属于**宿主进程**(MiniApp 运行时是它 fork 出来的子进程,进程名继承为 `MiniMax`), +/// 端口活着不等于**是蹬王之王**在跑(别的 MiniApp 也占端口)。 +/// +/// ⚠️ 为什么不用「目录存在」: +/// 实测 3 个 `mavis-miniapp-*` 目录同时存在,但只有 1 个对应活进程 +/// ⇒ 停掉的运行时目录不会被清掉,光看目录会以为「在跑」。 +/// +/// 组合起来(活进程 + 目录归属)才是可靠判据。 +pub fn runtime_alive() -> Result { + // `lsof -a -d cwd -F` 列出**所有活进程**的工作目录。 + // 只列活进程 ⇒ 「目录还在但进程没了」这种假阳性被排除。 + // + // ⚠️ 用 `-F`(字段模式)而不是默认的定宽表格(2026-10-20 二轮独立审计 F5): + // 默认模式下路径是最后一列,**路径含空格就会被切碎** + // ⇒ `ends_with("/package")` 恒假 ⇒ 每轮都误判「运行时没起」 + // ⇒ 每轮都催用户去开一个已经开着的面板。 + // `-F` 模式下路径是 `n` 字段的**整行剩余部分**,空格保留 ✓。 + let out = std::process::Command::new("lsof") + .args(["-nP", "-a", "-d", "cwd", "-F"]) + .output() + .map_err(|e| format!("无法执行 lsof(判据失效,不当成「没起」):{e}"))?; + // ⚠️ **stderr 非空 ⇒ 判据失效,不是「没起」**(2026-10-20 独立审计 F3)。 + // lsof 出错时:退出码=1、stdout 空、stderr 有 `can't get UID for …` + // ⇒ 与「没有匹配」的退出码一样,**光看退出码区分不出来**。 + // 而「判据失效」与「运行时真的没起」必须分开: + // 前者不该让用户去开一个已经开着的面板。 + let stderr = String::from_utf8_lossy(&out.stderr); + if !stderr.trim().is_empty() { + return Err(format!( + "lsof 报错(判据失效,不当成「没起」):{}", + stderr.trim() + )); + } + let text = String::from_utf8_lossy(&out.stdout); + // 字段模式:一个进程一段,`f` 标明接下来的 `n` 属于哪个 fd。 + let mut fd_is_cwd = false; + for line in text.lines() { + if let Some(fd) = line.strip_prefix('f') { + fd_is_cwd = fd == "cwd"; + continue; + } + let Some(name) = line.strip_prefix('n') else { continue }; + if !fd_is_cwd { + continue; + } + if name.contains(MINIAPP_PACKAGE_MARKER) + && name.ends_with("/package") + && package_is_ours(name) + { + return Ok(true); + } + } + Ok(false) +} + +/// 这个运行时包目录是不是蹬王之王的。 +/// +/// ⚠️ **两个锚点都要认**(2026-10-20 独立审计 F2): +/// 实测三个运行时目录里,`package.json` 的 `name` 字段**不总是有**: +/// +/// | 目录 | `package.json` name | `.minimax-plugin/plugin.json` name | 只认 package.json? | +/// | --- | --- | --- | --- | +/// | `mavis-miniapp-J3CVd3`(活进程) | expiry-guardian | expiry-guardian | ✅ | +/// | `mavis-miniapp-MxVUvr` | minimax-bot | minimax-bot | ✅(正确排除) | +/// | `mavis-miniapp-ZIU9nz` | **(无)** | expiry-guardian | ❌ **会漏判** | +/// +/// ⇒ 只认 `package.json` 会把蹬王之王误判成「没在跑」 +/// ⇒ 每轮都让用户去开一个**已经开着**的面板。 +/// ⇒ 两个锚点**任意一个**匹配即算 ours。 +fn package_is_ours(package_dir: &str) -> bool { + let dir = PathBuf::from(package_dir); + if name_of(&dir.join("package.json")).as_deref() == Some(PLUGIN_NAME) { + return true; + } + name_of(&dir.join(".minimax-plugin").join("plugin.json")).as_deref() == Some(PLUGIN_NAME) +} + +/// 读一个 JSON 文件里的 `name` 字段。 +fn name_of(path: &Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + let v: serde_json::Value = serde_json::from_str(&text).ok()?; + v.get("name") + .and_then(|n| n.as_str()) + .map(|s| s.to_string()) +} + +/// 跑一轮自检(真实探测)。 +pub fn check(pending: usize) -> SelfCheck { + match runtime_alive() { + Ok(alive) => check_with(pending, alive), + Err(e) => SelfCheck::Failed(e), + } +} + +/// 跑一轮自检(运行时状态已知时的纯逻辑版本 —— 测试用)。 +pub fn check_with(pending: usize, runtime_alive: bool) -> SelfCheck { + if pending == 0 { + return SelfCheck::Idle; + } + if runtime_alive { + SelfCheck::Delivering { pending } + } else { + SelfCheck::NeedsAttention { pending } + } +} + +/// 「投递空转」的观测结果。 +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum StuckEvent { + /// 没什么可说。 + None, + /// 刚刚开始出现「积压条数不再变化」。 + Started, + /// 积压条数连续没变超过阈值 ⇒ 判定为空转。 + Stuck { pending: usize, rounds: u32 }, + /// 积压条数变了 ⇒ 空转解除。 + Recovered, +} + +/// 「投递空转」观测器。 +/// +/// 场景:运行时**在跑**,但待唤醒请求的积压条数一直不减少 +/// ⇒ 请求没人消费(唤醒循环死了 / 回合一直失败 / 消费方卡住)。 +/// 守护进程在跑、看起来一切正常,实际一个 bot 都没被叫醒 —— 这正是要避免的静默失败。 +/// +/// 判据用「条数**没变**」而不是「条数**没归零**」: +/// - 正常投递时,成功一个 ack 一个 ⇒ 条数是**逐步下降**的,过程里会变; +/// - 完全没人消费时,条数**一直不动**。 +#[derive(Debug, Default)] +pub struct StuckWatch { + last_pending: usize, + streak: u32, + reported: bool, +} + +impl StuckWatch { + /// 构造(默认没有任何观测)。 + pub fn new() -> Self { + Self::default() + } + + /// 观察一轮的 pending 条数,返回这一轮该不该说话。 + pub fn observe(&mut self, pending: usize, threshold: u32) -> StuckEvent { + if pending == 0 { + let was_reported = self.reported; + self.last_pending = 0; + self.streak = 0; + self.reported = false; + return if was_reported { + StuckEvent::Recovered + } else { + StuckEvent::None + }; + } + if pending != self.last_pending { + let was_reported = self.reported; + // ⚠️ **只有「变少」才算恢复**(2026-10-20 独立审计 F4)。 + // 「条数变了」包含「变多」——积压**增加**(又挂了新 bot)时 + // 说「已恢复消费」是**假话**(消费端其实一直死着); + // 「变少」也可能是某条请求超龄被清掉,消费端同样没在动。 + // ⇒ 真正的恢复信号只有一个:**积压在减少**。 + let recovered = was_reported && pending < self.last_pending; + self.last_pending = pending; + self.streak = 1; + self.reported = false; + return if recovered { + StuckEvent::Recovered + } else { + StuckEvent::Started + }; + } + self.streak += 1; + if self.streak >= threshold && !self.reported { + self.reported = true; + return StuckEvent::Stuck { + pending, + rounds: self.streak, + }; + } + StuckEvent::None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn idle_when_no_pending() { + // 没有待唤醒请求 ⇒ 绝不出声(不打扰),无论运行时在不在。 + assert_eq!(check_with(0, true), SelfCheck::Idle); + assert_eq!(check_with(0, false), SelfCheck::Idle); + } + + #[test] + fn delivering_when_runtime_alive() { + assert_eq!(check_with(3, true), SelfCheck::Delivering { pending: 3 }); + } + + #[test] + fn needs_attention_when_runtime_down() { + assert_eq!(check_with(3, false), SelfCheck::NeedsAttention { pending: 3 }); + } + + #[test] + fn package_name_is_read_from_package_json() { + let dir = std::env::temp_dir().join(format!( + "expiry-guardian-selftest-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let pkg = dir.join("package.json"); + std::fs::write(&pkg, r#"{"name":"expiry-guardian","version":"1.6.0"}"#).unwrap(); + assert_eq!(name_of(&pkg).as_deref(), Some(PLUGIN_NAME)); + assert!(package_is_ours(dir.to_str().unwrap())); + + // 别的 MiniApp ⇒ 不能认成自己。 + let other = dir.join("other"); + std::fs::create_dir_all(&other).unwrap(); + std::fs::write(other.join("package.json"), r#"{"name":"minimax-bot"}"#).unwrap(); + assert_eq!(name_of(&other.join("package.json")).as_deref(), Some("minimax-bot")); + assert!(!package_is_ours(other.to_str().unwrap())); + + // 坏 JSON ⇒ 认不出(保守:不当成自己)。 + let broken = dir.join("broken"); + std::fs::create_dir_all(&broken).unwrap(); + std::fs::write(broken.join("package.json"), "{not json").unwrap(); + assert_eq!(name_of(&broken.join("package.json")), None); + assert!(!package_is_ours(broken.to_str().unwrap())); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn plugin_json_anchor_also_identifies_our_package() { + // ⚠️ 实测:有些运行时目录的 `package.json` **没有 name 字段** + // (`mavis-miniapp-ZIU9nz` 就是这样),但 `.minimax-plugin/plugin.json` 一定有。 + // ⇒ 只认 package.json 会漏判 ⇒ 判成「运行时没起」⇒ 误报。 + let dir = std::env::temp_dir().join(format!( + "expiry-guardian-anchor-{}", + std::process::id() + )); + let plugdir = dir.join(".minimax-plugin"); + std::fs::create_dir_all(&plugdir).unwrap(); + // package.json 故意**不写** name。 + std::fs::write(dir.join("package.json"), r#"{"mcode":{}}"#).unwrap(); + std::fs::write( + plugdir.join("plugin.json"), + r#"{"name":"expiry-guardian","displayName":"蹬王之王"}"#, + ) + .unwrap(); + assert!(package_is_ours(dir.to_str().unwrap()), "只靠 plugin.json 也必须认得出来"); + + // 别人的包(两个锚点都不是我们)⇒ 不认。 + let other = dir.join("other"); + std::fs::create_dir_all(other.join(".minimax-plugin")).unwrap(); + std::fs::write(other.join("package.json"), r#"{"mcode":{}}"#).unwrap(); + std::fs::write( + other.join(".minimax-plugin").join("plugin.json"), + r#"{"name":"minimax-bot"}"#, + ) + .unwrap(); + assert!(!package_is_ours(other.to_str().unwrap())); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn runtime_probe_does_not_panic() { + // 环境相关:只要求「探测本身不崩、不返回 Err」(lsof 存在时)。 + match runtime_alive() { + Ok(_) => {} + Err(e) => panic!("lsof 探测失败:{e}"), + } + } + + #[test] + fn stuck_watch_flags_unchanged_backlog() { + let mut w = StuckWatch::new(); + // 第一次看到 3 ⇒ streak=1 + assert_eq!(w.observe(3, 15), StuckEvent::Started); + // 之后条数一直不变 ⇒ 第 15 次观测(streak=15)判定为空转。 + for _ in 0..13 { + assert_eq!(w.observe(3, 15), StuckEvent::None); + } + assert_eq!( + w.observe(3, 15), + StuckEvent::Stuck { pending: 3, rounds: 15 } + ); + // 已经报过 ⇒ 不再重复报。 + assert_eq!(w.observe(3, 15), StuckEvent::None); + } + + #[test] + fn stuck_watch_recovers_when_backlog_shrinks() { + let mut w = StuckWatch::new(); + assert_eq!(w.observe(3, 3), StuckEvent::Started); + assert_eq!(w.observe(3, 3), StuckEvent::None); + assert_eq!(w.observe(3, 3), StuckEvent::Stuck { pending: 3, rounds: 3 }); + // 条数**变少** ⇒ 真的在消费 ⇒ 恢复。 + assert_eq!(w.observe(2, 3), StuckEvent::Recovered); + // 恢复后重新开始跟踪(下一个同值只是把 streak 往前推,不重复报「开始」)。 + assert_eq!(w.observe(2, 3), StuckEvent::None); + } + + #[test] + fn stuck_watch_does_not_claim_recovery_when_backlog_grows() { + // ⚠️ 积压**变多**(又挂了新 bot)时**不能**说「已恢复消费」。 + let mut w = StuckWatch::new(); + assert_eq!(w.observe(3, 3), StuckEvent::Started); + assert_eq!(w.observe(3, 3), StuckEvent::None); + assert_eq!(w.observe(3, 3), StuckEvent::Stuck { pending: 3, rounds: 3 }); + // 变多 ⇒ 不是恢复。 + assert_eq!(w.observe(5, 3), StuckEvent::Started); + } + + #[test] + fn stuck_watch_resets_on_empty_backlog() { + let mut w = StuckWatch::new(); + assert_eq!(w.observe(3, 2), StuckEvent::Started); + assert_eq!(w.observe(3, 2), StuckEvent::Stuck { pending: 3, rounds: 2 }); + // 积压清空 ⇒ 恢复。 + assert_eq!(w.observe(0, 2), StuckEvent::Recovered); + // 回到 0 之后重新开始观察。 + assert_eq!(w.observe(0, 2), StuckEvent::None); + assert_eq!(w.observe(1, 2), StuckEvent::Started); + } +} diff --git a/plugins/hylouis233/expiry-guardian/daemon/rust/src/bot_guard.rs b/plugins/hylouis233/expiry-guardian/daemon/rust/src/bot_guard.rs new file mode 100644 index 0000000..4c5f27f --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/rust/src/bot_guard.rs @@ -0,0 +1,1555 @@ +//! Bot 守护 —— 盯住 MiniMax Bot 团队,挂掉了就自动重新启用。 +//! +//! # 为什么要有这个 +//! +//! MiniMax Bot 的会话一遇到错误就会**新建一个会话**继续,于是表现为 +//! 「bot 反复挂掉」:每挂一次多一个会话,界面上又是一条错误通知。 +//! +//! 2026-10-19 实测:一天里 20+ 个 `purpose='miniapp:minimax-bot'` 的会话带错误码。 +//! 根因是**两类性质相反的错误被同一套逻辑处理**: +//! +//! - **可自愈**(HTTP 500 / 网络切换 / 429 / 529 / TPM 限流) +//! —— 通常几十秒内自己就好了,但 Host 的 `toLLMRetryDecision()` 对通用 5xx +//! **故意 fail-closed**(只白名单 429/529/timeout/network), +//! 而 minimax-bot 自己的重试只有 2 次、跨度 28 秒 ⇒ 这类错误一次就死。 +//! - **需人工**(42212 Token Plan 到顶 / 50110 积分耗尽 / 50112 登录失效) +//! —— 重试一万次也没用,只能人工补充额度。 +//! +//! # 唤醒通道:为什么是「两段跳」 +//! +//! 本守护进程在 **Host 之外**(launchd 常驻、无 token、无人值守), +//! 它拿不到 minimax-bot 的 MCP 端点:那个端口由 Host 分配, +//! 而且 Host 的 MCP 客户端带 **per-run 凭据**,别的进程拿不到。 +//! +//! 实测三条通道: +//! +//! | 通道 | 能唤醒别的插件拥有的会话吗 | 原因 | +//! | --- | --- | --- | +//! | `context.agent.run({ sessionId })` | ❌ | `assertSessionOwnership`:`owner !== caller.pluginId` ⇒ `SESSION_NOT_FOUND`;45 个 bot 会话的 `purpose` 全是 `miniapp:minimax-bot` | +//! | 直连对方 MCP HTTP 端点 | ❌ | Host 的 MCP 客户端带 per-run 凭据,别的进程拿不到 | +//! | 裸写 `local_runtime_queue_items` | ❌ | 绕开 `steer` 直接塞行 ⇒ Host 队列轮询器不认(实测 3 分钟未被消费) | +//! | **起一个 Agent,让它调 `mavis` 工具的 `session send`** | ✅ | `session send` 的准入是「目标可达即可」,**不做 owner 检查**;2026-10-20 实测:7 个挂掉的 bot 全部被自动唤醒 | +//! +//! ⇒ 守护进程负责**判定**(谁挂了、该不该救),把「请唤醒这个 bot」 +//! 写进请求文件;**蹬王之王自己的 MiniApp**(在 Host 内)负责**投递** —— +//! 它起一个 agent 回合,那个回合里的 agent 用 `mavis` 工具的 `session send` +//! 把续跑消息送进目标 bot 的**原会话**(不新建会话)。 +//! +//! ⚠️ 代价:每个被唤醒的 bot 要烧**一个 agent 回合**(约 47k tokens,46k 缓存读) +//! ⇒ 投递端必须**按需触发**,绝不空转。 +//! +//! 与 Goal 唤醒共用同一条通道(同一个 acked 幂等表),只是请求的 `kind` 不同。 + +use std::path::PathBuf; + +use serde_json::json; + +/// Bot 守护请求文件。 +/// +/// 由 **minimax-bot 的 MiniApp** 消费(它对 bot 会话有归属权限), +/// 用 `agent.run` 真正把 bot 叫醒。 +fn request_path() -> Option { + std::env::var("HOME") + .ok() + .map(|h| PathBuf::from(h).join(".config/mcode-reset-card/bot-revive-requests.json")) +} + +/// Bot 守护的已处理列表(幂等)。 +fn acked_path() -> Option { + std::env::var("HOME") + .ok() + .map(|h| PathBuf::from(h).join(".config/mcode-reset-card/bot-revive-acked.json")) +} + +/// 「需要你处理」的提示去重表。 +/// +/// 2026-10-19 实测:不加这个,日志每 60 秒把同样 9 个 bot 的同样内容各刷一遍, +/// 40 分钟 463 行。存盘后跨轮次继续去重。 +fn notified_path() -> Option { + std::env::var("HOME") + .ok() + .map(|h| PathBuf::from(h).join(".config/mcode-reset-card/bot-notified.json")) +} + +/// MiniMax Bot 的插件数据目录(它的 state.json 在这里)。 +fn bot_state_path() -> Option { + std::env::var("HOME").ok().map(|h| { + PathBuf::from(h) + .join(".minimax/v2/plugin-data/liveboards/minimax-bot/state.json") + }) +} + +/// 尝试次数单独存一个文件。 +/// +/// ⚠️ **为什么不直接写在 requests 文件里**(2026-10-20 审计发现): +/// 投递端(Node,Host 内)与本守护进程(Rust,Host 外)会**并发**写 +/// 同一个 requests 文件。任何「读全量 → 改一个字段 → 写回全量」的写法, +/// 都会把对方刚写进去的条目**一起抹掉**(lost update)。 +/// ⇒ 尝试次数单独存,两边各写各的。 +fn attempts_path() -> Option { + std::env::var("HOME") + .ok() + .map(|h| PathBuf::from(h).join(".config/mcode-reset-card/bot-revive-attempts.json")) +} + +/// 读出「已尝试次数」表。 +pub fn attempts_map() -> std::collections::BTreeMap { + let Some(path) = attempts_path() else { return Default::default() }; + let Ok(text) = std::fs::read_to_string(&path) else { return Default::default() }; + let Ok(v) = serde_json::from_str::(&text) else { return Default::default() }; + let Some(obj) = v.get("attempts").and_then(|a| a.as_object()) else { return Default::default() }; + obj.iter() + .filter_map(|(k, v)| { + // ⚠️ 两种格式都要能读(2026-10-20 独立审计发现): + // - 新格式 `{"n": 2, "at": 1699...}`(投递端写的) + // - 旧格式 `2`(纯数字) + // 之前只读 `v.as_i64()` ⇒ 读到新格式时得到 None ⇒ attempts 恒为 0 + // ⇒ **3 次封顶在守护进程侧完全失效**(无限重试,每回合 47k~93k tokens)。 + let n = v + .get("n") + .and_then(|x| x.as_i64()) + .or_else(|| v.as_i64()) + .unwrap_or(0); + Some((k.clone(), n)) + }) + .collect() +} + +/// 请求的存活上限(毫秒)。与 Goal 恢复请求一致。 +pub const RESUME_REQUEST_TTL_MS: i64 = 30 * 60 * 1000; + +/// 同一个唤醒请求最多尝试几次(超过就不再重试)。 +/// +/// ⚠️ **为什么不是纯时间 TTL**(2026-10-20 实测): +/// 纯时间 TTL 会让「面板关着」的场景彻底失效 —— 面板关掉 ⇒ 没人消费 ⇒ +/// 30 分钟后请求全部过期作废 ⇒ 等面板再打开时,那些 bot 又挂了一段时间, +/// 却**再也没有请求可发**。实测:面板关掉 70 秒后 7 条请求全部未被消费。 +/// +/// 真正的风险不是「放太久」,而是「反复试同一个叫不醒的 bot」 +/// (每次尝试都要烧一个 agent 回合)⇒ 按次数封顶。 +pub const MAX_WAKE_ATTEMPTS: i64 = 3; + +/// 请求的最长保留时间(毫秒)—— 兜底,不是主要判据。 +/// 取 24 小时:覆盖「面板关了一整夜」,同时避免僵尸请求永久堆积。 +pub const MAX_WAKE_REQUEST_AGE_MS: i64 = 24 * 60 * 60 * 1000; + +/// 错误分类结果。 +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum Verdict { + /// 没有错误。 + Healthy, + /// 可自愈:应当自动重新启用。 + SelfHealing, + /// 需人工:重试无用,只提示不重试。 + NeedsUser, +} + +/// 协议错误码(来自 `packages/shared/src/llm-error-classifier.ts`)。 +pub mod error_code { + /// Token Plan 用量到顶(上游 2056 / 2067)。 + pub const USAGE_LIMIT_EXCEEDED: i64 = 42212; + /// 积分耗尽。 + pub const LLM_CREDITS_EXHAUSTED: i64 = 50110; + /// 鉴权失效(401/403)。 + pub const LLM_AUTH_ERROR: i64 = 50112; + /// 通用 4xx/5xx 兜底(HTTP 500 落这里)。 + pub const LLM_UPSTREAM_ERROR: i64 = 50113; + /// 短时限流(HTTP 429 落这里;运行时标 retryable)。 + pub const LLM_RATE_LIMITED: i64 = 50111; + /// TPM/RPM 短时限流。 + pub const LLM_TPM_RATE_LIMITED: i64 = 50150; + /// 集群过载(529)。 + pub const LLM_CLUSTER_OVERLOADED: i64 = 50151; +} + +/// 把错误归类。 +/// +/// ⚠️ **读不到 ≠ 没有**(2026-10-19 实测踩到的坑): +/// 44 个 bot 会话里有 29 个是「无错误码、无错误消息」—— 它们只是闲置很久, +/// 并不是挂掉。若把这类判成可自愈,守护进程会去「重新启用」一批没坏的 bot。 +/// +/// 同时也要区分「目标真的挂了」与「error_code 只是上一轮留下的陈旧值」—— +/// 运行时不清 error_code(2026-10-19 在量化会话上踩过:那个会话当时正在跑 +/// 一个 20 多分钟的正常长回合)。所以调用方**必须**再确认「最近没有新活动」。 +pub fn classify_error(code: i64, message: &str) -> (Verdict, String) { + let raw = message.trim(); + let text = raw.to_lowercase(); + + // 无错误码也无消息 ⇒ 健康 + if code == 0 && raw.is_empty() { + return (Verdict::Healthy, "没有错误".to_string()); + } + // 「Cron definition was deleted」= 那个定时任务被删了,是**配置变更**不是崩溃 + if text.contains("cron definition was deleted") { + return ( + Verdict::NeedsUser, + "这个会话的定时任务已被删除,需要重新安排".to_string(), + ); + } + // 需人工:重试无用 + match code { + error_code::USAGE_LIMIT_EXCEEDED => { + return ( + Verdict::NeedsUser, + "Token Plan 用量已达上限:升级套餐,或购买积分并开启积分自动消耗".to_string(), + ) + } + error_code::LLM_CREDITS_EXHAUSTED => { + return (Verdict::NeedsUser, "积分已耗尽:充值积分后才能继续".to_string()) + } + error_code::LLM_AUTH_ERROR => { + return ( + Verdict::NeedsUser, + "登录态已失效:重新打开 MiniMax Code 登录".to_string(), + ) + } + _ => {} + } + if code == 0 { + if let Some(reason) = needs_user_by_text(&text) { + return (Verdict::NeedsUser, reason); + } + if is_transient_by_text(&text) { + return ( + Verdict::SelfHealing, + "网络或服务端临时故障,稍后重试通常能恢复".to_string(), + ); + } + // 未知错误:倾向自愈(宁可多试一次,也不要让 bot 永久瘫着) + return ( + Verdict::SelfHealing, + "未知错误,按可恢复处理并退避重试".to_string(), + ); + } + // 有结构化错误码:可自愈的那几个 + if matches!( + code, + error_code::LLM_UPSTREAM_ERROR + | error_code::LLM_RATE_LIMITED + | error_code::LLM_TPM_RATE_LIMITED + | error_code::LLM_CLUSTER_OVERLOADED + ) { + return ( + Verdict::SelfHealing, + "服务端临时错误,稍后重试通常能恢复".to_string(), + ); + } + ( + Verdict::NeedsUser, + "未知错误码,重试可能无用,请人工确认".to_string(), + ) +} + +/// 文本里的「额度/鉴权」信号(没有结构化错误码时兜底)。 +fn needs_user_by_text(text: &str) -> Option { + const MARKERS: [&str; 8] = [ + "用量上限", + "usage limit", + "余额不足", + "积分不足", + "credits exhausted", + "unauthorized", + "forbidden", + "登录态失效", + ]; + if MARKERS.iter().any(|m| text.contains(m)) { + return Some("额度或登录态需要你处理,重试无用".to_string()); + } + None +} + +/// 文本里的「暂时性」信号。 +fn is_transient_by_text(text: &str) -> bool { + const MARKERS: [&str; 10] = [ + "err_network_changed", + "econnreset", + "econnrefused", + "etimedout", + "socket hang up", + "internal server error", + "服务不可用", + "service unavailable", + "overloaded", + "rate limit", + ]; + MARKERS.iter().any(|m| text.contains(m)) +} + +/// 退避时长:指数增长 + 抖动,封顶 `cap_ms`。 +/// +/// 封顶是必须的:不封顶则第 40 次要等 12 天,等于没有重试。 +pub fn backoff_ms(attempt: u32, base_ms: u64, cap_ms: u64) -> u64 { + let exp = base_ms.saturating_mul(1u64 << attempt.min(16)).min(cap_ms); + exp + (pseudo_random() % 1000) +} + +/// 抖动用的伪随机数(按纳秒时间播种,够用即可 —— 只为避免整批重试对齐)。 +fn pseudo_random() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0) +} + + + +/// 一个 bot 的会话信息。 +#[derive(Debug, Clone)] +pub struct BotSession { + pub bot_id: String, + pub bot_name: String, + pub session_id: String, + /// 这个 bot 所在的会话(`bot:` 私聊 或 `group:` 群聊)。 + pub chat_id: String, +} + +/// 读 MiniMax Bot 的 state.json,取出所有 bot → sessionId 映射。 +/// +/// 结构:`bots[] = {id, name, role}`、`sessions[chatId][botId] = { sessionId }`。 +/// 宽容的 JSON 解析。 +/// +/// ⚠️ 为什么需要(2026-10-20 实测踩到): +/// `minimax-bot` 写出的 `state.json` 里出现过**被截断的代理对转义** +/// (`\ud83d` —— emoji 的高代理,后面的低代理丢了)。 +/// Python 的 `json.loads` 与 Node 的 `JSON.parse` 都能解析它, +/// 但 Rust 的 `serde_json` **严格拒绝** ⇒ +/// `read_bot_sessions` 直接返回空 ⇒ 整个 Bot 守护静默失效 +/// (而且是「看起来一切正常、实际一个 bot 都没看护」的那种失效)。 +/// +/// ⇒ 这里先尝试严格解析;失败则把**孤立的代理对转义**替换成 U+FFFD 再解析。 +/// 只修这一类明确的坏转义,不做更宽松的猜测。 +fn parse_lenient_json(text: &str) -> Option { + if let Ok(v) = serde_json::from_str::(text) { + return Some(v); + } + // 找出所有 "\uXXXX" 转义;把**孤立的高代理**(后面不跟低代理)替换掉。 + // + // ⚠️ 区间必须精确(2026-10-20 独立审计 F6): + // - 高代理 = U+D800..=U+DBFF + // - 低代理 = U+DC00..=U+DFFF + // U+D000..=U+D7FF 是**合法的普通字符**(不是代理), + // 早先用「首字母是 d 就当高代理」会把它们也换成 U+FFFD ⇒ 静默改数据。 + // + // ⚠️ **必须按字节解析,不能切 `&str`**(2026-10-20 二轮独立审计 F2): + // `&text[i+2..i+6]` 在 `i` 落在多字节字符中间时会 **panic** + // ("end byte index 12 is not a char boundary") + // ⇒ 守护进程整个崩掉(launchd 重启后同样崩 ⇒ 崩溃循环)。 + // 触发条件:文件严格 JSON 解析失败 **且** 文件里出现 `\u` + 非 ASCII 字符 + //(生产 `state.json` 里有 89.5 万个非 ASCII 字符,用户发一条含 `\u` 的文本即命中)。 + let bytes = text.as_bytes(); + let mut out = String::with_capacity(text.len()); + let mut i = 0usize; + let mut repaired = false; + while i < bytes.len() { + // 匹配 \uXXXX + if bytes[i] == b'\\' && i + 6 <= bytes.len() && bytes[i + 1] == b'u' { + if let Some(cp) = hex4(bytes, i + 2) { + let is_high = (0xD800..=0xDBFF).contains(&cp); + let next_is_low = if is_high && i + 12 <= bytes.len() { + bytes[i + 6] == b'\\' + && bytes[i + 7] == b'u' + && hex4(bytes, i + 8) + .map(|c| (0xDC00..=0xDFFF).contains(&c)) + .unwrap_or(false) + } else { + false + }; + if is_high && !next_is_low { + // 孤立的高代理 ⇒ 坏转义 ⇒ 替换成 U+FFFD。 + out.push('\u{FFFD}'); + i += 6; + repaired = true; + continue; + } + } + } + // 原样拷贝一个 UTF-8 字符(`i` 一定在字符边界上:正常字符按 len_utf8 前进, + // 坏转义是 6 个 ASCII 字节)。 + match text.get(i..) { + Some(rest) => { + let ch = rest.chars().next().unwrap_or('\u{FFFD}'); + out.push(ch); + i += ch.len_utf8(); + } + None => break, + } + } + if !repaired { + return None; + } + serde_json::from_str::(&out).ok() +} + +/// 读 4 个十六进制字节成一个 u32(**纯字节,不切 str** ⇒ 不会 panic)。 +fn hex4(bytes: &[u8], at: usize) -> Option { + if at + 4 > bytes.len() { + return None; + } + let mut v: u32 = 0; + for &b in &bytes[at..at + 4] { + let d = match b { + b'0'..=b'9' => b - b'0', + b'a'..=b'f' => b - b'a' + 10, + b'A'..=b'F' => b - b'A' + 10, + _ => return None, + }; + v = v * 16 + d as u32; + } + Some(v) +} + +pub fn read_bot_sessions() -> Result, String> { + let Some(path) = bot_state_path() else { + return Err("取不到 $HOME,找不到状态文件路径".to_string()); + }; + let text = match std::fs::read_to_string(&path) { + Ok(t) => t, + Err(e) => { + return Err(format!( + "读不到 {}({e})", + path.display() + )) + } + }; + let Some(state) = parse_lenient_json(&text) else { + return Err(format!( + "{} 解析失败(严格 JSON 与容错解析都不行)", + path.display() + )); + }; + parse_bot_sessions(&state) +} + +/// 从已解析的 state.json 里取出 bot → sessionId 映射。 +fn parse_bot_sessions(state: &serde_json::Value) -> Result, String> { + // botId → 名字(MCP 的 message_bot 用**名字**寻址,不是 id) + let mut names: std::collections::HashMap = std::collections::HashMap::new(); + if let Some(bots) = state.get("bots").and_then(|b| b.as_array()) { + for b in bots { + if let (Some(id), Some(name)) = ( + b.get("id").and_then(|v| v.as_str()), + b.get("name").and_then(|v| v.as_str()), + ) { + names.insert(id.to_string(), name.to_string()); + } + } + } + + let mut out = Vec::new(); + if let Some(sessions) = state.get("sessions").and_then(|s| s.as_object()) { + for (chat_id, bots) in sessions { + let Some(bots) = bots.as_object() else { continue }; + for (bot_id, info) in bots { + let Some(session_id) = info.get("sessionId").and_then(|v| v.as_str()) else { continue }; + if !session_id.starts_with("mvs_") { + continue; + } + out.push(BotSession { + bot_id: bot_id.clone(), + bot_name: names.get(bot_id).cloned().unwrap_or_else(|| bot_id.clone()), + session_id: session_id.to_string(), + chat_id: chat_id.clone(), + }); + } + } + } + Ok(out) +} + +/// 判断一条请求是否还「值得投递」。 +/// +/// 纯函数(不读文件、不读环境变量)⇒ 可以直接单测。 +/// - 已 ack ⇒ 不再投(acked 是**永久**的:投递过的就是投递过了) +/// - 尝试次数用尽 ⇒ 这个 bot 叫不醒,不再试(按 **bot 键**计,跨实例有效) +/// - 超过最长保留时间 ⇒ 僵尸请求,丢掉 +pub fn is_request_live( + id: &str, + at_ms: i64, + now_ms: i64, + acked: &std::collections::BTreeSet, + attempts: &std::collections::BTreeMap, +) -> bool { + if id.is_empty() || acked.contains(id) { + return false; + } + let n = attempts + .get(&bot_key_from_request_id(id)) + .copied() + .unwrap_or(0); + if n >= MAX_WAKE_ATTEMPTS { + return false; + } + now_ms - at_ms <= MAX_WAKE_REQUEST_AGE_MS +} + +/// 清理请求文件:去掉已 ack / 尝试次数用尽 / 超过最长保留时间的条目。 +/// +/// ⚠️ 每轮都要调(见 `tick`)。否则 acked 列表(只留最近 200 条)滚过之后, +/// 老请求会重新变成「未处理」⇒ 投递端二次投递 ⇒ 重复唤醒 + 双倍 token。 +fn prune_revoke_requests() { + let Some(path) = request_path() else { return }; + let Ok(text) = std::fs::read_to_string(&path) else { return }; + let Ok(v) = serde_json::from_str::(&text) else { return }; + let Some(list) = v.get("requests").and_then(|r| r.as_array()) else { return }; + let acked = acked_ids(); + let attempts = attempts_map(); + let now = crate::api::now_ms(); + let kept: Vec = list + .iter() + .filter(|r| { + let id = r.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let at = r.get("atMs").and_then(|a| a.as_i64()).unwrap_or(0); + is_request_live(id, at, now, &acked, &attempts) + }) + .cloned() + .collect(); + // 顺带清理 attempts 里的僵尸条目。 + // ⚠️ 清理条件**必须**是「已 ack」或「超龄」—— + // 不能按「requests 文件里还在不在」清:Rust 的 prune 会删掉「次数用尽」的请求, + // 连带清掉 attempts 记录 ⇒ 3 次封顶被架空 ⇒ 无限重试(2026-10-20 独立审计发现)。 + prune_attempts(now); + if kept.len() == list.len() { + return; // 没变化就别写(避免无谓的磁盘写) + } + let body = json!({ "requests": kept }); + let tmp = path.with_extension(format!("json.{}.tmp", std::process::id())); + if std::fs::write(&tmp, format!("{}\n", serde_json::to_string_pretty(&body).unwrap_or_default())).is_ok() { + let _ = std::fs::rename(&tmp, &path); + } +} + +/// 清理 attempts 文件里的僵尸条目(已 ack 或超龄的)。 +/// +/// 与 Node 侧 `bumpAttempts` 的清理条件**必须一致**。 +/// +/// ⚠️ **v1.6 修正:清理条件里不能有「已 ack」**(2026-10-20 二轮独立审计 F1)。 +/// 原来清理条件含 `acked.contains(bot_key)`,而 v1.6 把 acked 改成**永久**之后, +/// 它的含义从「刚刚被救过」变成了「**曾经**被救过」 +/// ⇒ 一个被救过的 bot,它的**失败计数**会被清掉 +/// ⇒ **3 次封顶彻底失效** ⇒ 该请求永远「活」⇒ 每 60 秒重投一次 +/// ⇒ 每轮烧一个 agent 回合(≈47k~93k tokens)。 +/// (Node 侧 `bumpAttempts` 同构复现:第 1 次失败后 attempts 直接变 undefined。) +/// +/// ⇒ 失败计数**只在成功时清**(见 Node 侧 `clearAttemptsOnSuccess`), +/// 这里只按**超龄**清理。 +fn prune_attempts(now: i64) { + let Some(path) = attempts_path() else { return }; + let Ok(text) = std::fs::read_to_string(&path) else { return }; + let Ok(v) = serde_json::from_str::(&text) else { return }; + let Some(map) = v.get("attempts").and_then(|m| m.as_object()) else { return }; + let kept: serde_json::Map = map + .iter() + .filter(|(_k, val)| { + let at = val.get("at").and_then(|x| x.as_i64()).unwrap_or(0); + let too_old = at > 0 && now - at > MAX_WAKE_REQUEST_AGE_MS; + // ⚠️ **只按超龄清**。次数用尽但还没超龄的**要保留** —— + // 它正是「不要再试了」的依据。 + // ⚠️ 也不能按「已 ack」清:acked 现在是永久的(见上面的说明), + // 那会把失败计数一起清掉 ⇒ 3 次封顶失效。 + !too_old + }) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + if kept.len() == map.len() { + return; + } + let body = json!({ "attempts": kept }); + let tmp = path.with_extension(format!("json.{}.tmp", std::process::id())); + if std::fs::write(&tmp, format!("{}\n", serde_json::to_string_pretty(&body).unwrap_or_default())).is_ok() { + let _ = std::fs::rename(&tmp, &path); + } +} + +/// 写一条「唤醒这个 bot」的请求。 +/// +/// **只写文件,不投递。** 投递由 **minimax-bot 的 MiniApp** 完成 +/// (只有它对 bot 会话有归属权限,能用 `agent.run` 真正起回合)。 +/// +/// `text` 是**给 bot 看的续跑消息正文**(不是给投递方的指令)—— +/// 投递方会把它原样作为 `agent.run` 的输入。 +/// +/// `id` 是**实例 id**(`bot-revive:{botId}:{sessionId}:{序号}`)—— +/// 每次写入都是新实例,这样「永久 ack 集」不会把它误杀。 +pub fn write_revive_request(id: &str, bot: &BotSession, text: &str) -> Result<(), String> { + let path = request_path().ok_or("找不到 $HOME")?; + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let existing = std::fs::read_to_string(&path).unwrap_or_default(); + let mut requests: Vec = serde_json::from_str::(&existing) + .ok() + .and_then(|v| v.get("requests").and_then(|r| r.as_array()).cloned()) + .unwrap_or_default(); + + // ⚠️ attempts 不再内嵌在这条请求里(它单独存一个文件,见 `attempts_path`), + // 否则本守护进程与投递端并发写同一个文件会互相覆盖。 + // 只清掉**同一个 bot 键**的旧实例(同一个 bot 不该同时挂两条待办)。 + let bot_key = format!("bot-revive:{}:{}", bot.bot_id, bot.session_id); + requests.retain(|r| { + let rid = r.get("id").and_then(|v| v.as_str()).unwrap_or(""); + bot_key_from_request_id(rid) != bot_key + }); + // 丢掉「叫不醒」的请求(尝试次数用尽)与僵尸请求(超过最长保留时间)。 + // ⚠️ **不能只按时间清理** —— 面板关着时没人消费,纯时间清理会让请求 + // 在面板重新打开之前就全部过期作废(2026-10-20 实测)。 + let now = crate::api::now_ms(); + let attempts = attempts_map(); + let acked = acked_ids(); + requests.retain(|r| { + let at = r.get("atMs").and_then(|a| a.as_i64()).unwrap_or(0); + let id = r.get("id").and_then(|v| v.as_str()).unwrap_or(""); + // ⚠️ **已 ack 的必须清掉**(2026-10-20 独立审计发现): + // acked 列表只保留最近 200 条 ⇒ 一旦滚过,老请求就会重新变成「未处理」, + // 被投递端**二次投递** ⇒ 重复唤醒 + 双倍 token。 + // 实测:7 条请求全部已在 acked 里,Rust 却仍然保留着它们。 + if acked.contains(id) { + return false; + } + // ⚠️ 尝试次数按 **bot 键**记(跨实例有效,否则 3 次封顶会被架空)。 + let n = attempts + .get(&bot_key_from_request_id(id)) + .copied() + .unwrap_or(0); + n < MAX_WAKE_ATTEMPTS && now - at <= MAX_WAKE_REQUEST_AGE_MS + }); + requests.push(json!({ + "id": id, + "kind": "bot-revive", + "botId": bot.bot_id, + "botName": bot.bot_name, + "sessionId": bot.session_id, + "text": text, + "atMs": now, + })); + if requests.len() > 50 { + requests = requests.split_off(requests.len() - 50); + } + let body = json!({ "requests": requests }); + // ⚠️ 临时文件名必须与 Node 侧区分开:两边会并发写同一个文件 + // (Node 的 bot_wakeup.mjs 写 attempts,Rust 写请求本体), + // 用同一个 ".tmp" 名会互相覆盖对方的半成品 ⇒ 丢请求。 + let tmp = path.with_extension(format!("json.{}.tmp", std::process::id())); + std::fs::write(&tmp, format!("{}\n", serde_json::to_string_pretty(&body).unwrap_or_default())) + .map_err(|e| e.to_string())?; + std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?; + Ok(()) +} + +/// 已被投递方(minimax-bot 的 MiniApp)确认处理的请求 id。 +/// 「最近被救过」的请求 id 及其时间戳。 +/// +/// ⚠️ **为什么带时间戳**(2026-10-20 独立审计发现): +/// 原来 acked 是个纯 id 列表 ⇒ 「救过一次」变成**永久黑名单**。 +/// 实测:6 个 bot 明明又坏了(闲置 47~94 分钟,远超 90 秒阈值), +/// 却因为 acked 里有过它们而永远不再被叫醒。 +/// ⇒ 时间戳保留下来。 +/// +/// ⚠️ **v1.6 改掉了「窗口」的用法**(2026-10-20 独立审计 + 实测发现): +/// 原来这个 30 分钟窗口**同时**承担两个职责: +/// 「这条投递过了吗」与「这个 bot 多久可以再救一次」。 +/// ⇒ 窗口一过,**已经投递过的请求又变回「未处理」** ⇒ +/// 投递端**每 30 分钟把同一条请求重投一次**(每个回合 ≈47k tokens), +/// 而且会打断已经恢复正常的 bot。 +/// ⇒ 现在两个职责**分开**: +/// - 「投递过了吗」⇒ **永久**(见 `acked_ids`); +/// - 「多久可以再救一次」⇒ **守护进程自己的频率限制**(见 `acked_recent_bot_keys`)。 +/// +/// 兼容两种格式: +/// - 新格式 `{"acked": {"": }}` +/// - 旧格式 `{"acked": ["", ...]}`(没有时间戳 ⇒ 视为「很久以前」,不再抑制) +pub fn acked_map() -> std::collections::BTreeMap { + let Some(path) = acked_path() else { return Default::default() }; + let Ok(text) = std::fs::read_to_string(&path) else { return Default::default() }; + let Ok(v) = serde_json::from_str::(&text) else { return Default::default() }; + let Some(arr) = v.get("acked").and_then(|a| a.as_array()) else { return Default::default() }; + let mut out = std::collections::BTreeMap::new(); + for x in arr { + match x { + // 新格式:对象 `{id, atMs}` + serde_json::Value::Object(map) => { + let id = map.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let at = map.get("atMs").and_then(|v| v.as_i64()).unwrap_or(0); + if !id.is_empty() { + out.insert(id, at); + } + } + // 旧格式:纯字符串(无时间戳 ⇒ 记成 0,即「不受抑制」) + serde_json::Value::String(id) => { + if !id.is_empty() { + out.insert(id.clone(), 0); + } + } + _ => {} + } + } + out +} + +/// 所有「已被投递方确认处理」的请求 id —— **永久**,不看时间窗。 +/// +/// ⚠️ v1.6 改判据(2026-10-20 独立审计 + 实测): +/// 原来带 30 分钟窗口 ⇒ 窗口一过,**已经投递过的请求又变回「未处理」** +/// ⇒ 投递端**每 30 分钟把同一条请求重投一次**(每回合 ≈47k tokens), +/// 还会打断已经恢复正常的 bot。 +/// ⇒ 现在:**投递过的就是投递过了**。 +/// 「多久可以再救同一个 bot」改由守护进程自己的频率限制管 +/// (`acked_recent_bot_keys`)——那是**写请求**时的判断,不是**投递**时的判断。 +pub fn acked_ids() -> std::collections::BTreeSet { + acked_map().into_keys().collect() +} + +/// 最近 `window_ms` 毫秒内被成功救过的 **bot 键**(守护进程的频率限制用)。 +/// +/// bot 键 = `bot-revive:{botId}:{sessionId}`(不含实例序号), +/// 也就是「这个 bot 最近有没有被救过」。 +pub fn acked_recent_bot_keys(window_ms: i64) -> std::collections::BTreeSet { + let now = crate::api::now_ms(); + acked_map() + .into_iter() + .filter(|(_, at)| *at > 0 && now - *at <= window_ms) + .map(|(id, _)| bot_key_from_request_id(&id)) + .collect() +} + +/// 从请求 id 推出 **bot 键**(= `bot-revive:{botId}:{sessionId}`,不含实例序号)。 +/// +/// 请求 id 的形状:`bot-revive:{botId}:{sessionId}[:{实例序号}]`。 +/// ⇒ 取前三段。**Rust 与 Node 必须用同一个算法** —— 它是「尝试次数」的键。 +pub fn bot_key_from_request_id(id: &str) -> String { + let parts: Vec<&str> = id.split(':').collect(); + if parts.len() >= 3 { + format!("{}:{}:{}", parts[0], parts[1], parts[2]) + } else { + id.to_string() + } +} + +/// 「救过一次」的抑制窗口(毫秒)—— **只用于「多久可以再救一次」**。 +/// +/// 30 分钟:既能避免刚救完就又救(同一轮里重复投递), +/// 又不会让「救过一次」变成永久黑名单。 +/// +/// ⚠️ v1.6:它**不再**参与「这条请求投递过没有」的判断 +/// (那是 `acked_ids()` 的事,永久)。 +pub const ACK_SUPPRESS_WINDOW_MS: i64 = 30 * 60 * 1000; + +/// 还没被处理的请求 id(供诊断与避免重复投递)。 +pub fn pending_request_ids() -> std::collections::BTreeSet { + let Some(path) = request_path() else { return Default::default() }; + let Ok(text) = std::fs::read_to_string(&path) else { return Default::default() }; + let Ok(v) = serde_json::from_str::(&text) else { return Default::default() }; + let acked = acked_ids(); + let attempts = attempts_map(); + let now = crate::api::now_ms(); + v.get("requests") + .and_then(|r| r.as_array()) + .map(|list| { + list.iter() + .filter_map(|x| { + let id = x.get("id").and_then(|i| i.as_str())?.to_string(); + let at = x.get("atMs").and_then(|a| a.as_i64()).unwrap_or(0); + if acked.contains(&id) { + return None; + } + // 尝试次数用尽 ⇒ 这个 bot 叫不醒 ⇒ 不再重试。 + // ⚠️ 尝试次数按 **bot 键**记(不是实例 id)—— + // 否则每换一个实例就重置计数,3 次封顶会被架空。 + let attempts = attempts.get(&bot_key_from_request_id(&id)).copied().unwrap_or(0); + if attempts >= MAX_WAKE_ATTEMPTS { + return None; + } + // 时间只是兜底(覆盖「面板关了一整夜」)。 + if now - at > MAX_WAKE_REQUEST_AGE_MS { + return None; + } + Some(id) + }) + .collect() + }) + .unwrap_or_default() +} + +/// 这个 bot 是否已经有一条**活的**待办请求。 +/// +/// ⚠️ v1.6:请求 id 按实例唯一化了 ⇒ 不能用 id 直接比, +/// 要用 **bot 键**(id 的前 3 段)比。 +fn has_live_request_for_bot(bot_key: &str) -> bool { + let Some(path) = request_path() else { return false }; + let Ok(text) = std::fs::read_to_string(&path) else { return false }; + let Ok(v) = serde_json::from_str::(&text) else { return false }; + let Some(list) = v.get("requests").and_then(|r| r.as_array()) else { return false }; + let acked = acked_ids(); + let attempts = attempts_map(); + let now = crate::api::now_ms(); + list.iter().any(|r| { + let id = r.get("id").and_then(|v| v.as_str()).unwrap_or(""); + if bot_key_from_request_id(id) != bot_key { + return false; + } + let at = r.get("atMs").and_then(|a| a.as_i64()).unwrap_or(0); + if acked.contains(id) { + return false; + } + let n = attempts.get(bot_key).copied().unwrap_or(0); + n < MAX_WAKE_ATTEMPTS && now - at <= MAX_WAKE_REQUEST_AGE_MS + }) +} + +/// 给 bot 写一条唤醒请求。 +/// +/// 返回 `Ok(false)` 表示**这次不用写**(已有活的待办 / 叫不醒 / 刚救过)。 +/// +/// ⚠️ **v1.6:请求 id 按「实例」唯一化**(2026-10-20 独立审计 + 实测)。 +/// 原因:`acked` 改成永久之后(见 `acked_ids`),**同一个 id 会被永久压制**, +/// 于是「bot 又坏了要能再救」这条路就断了。 +/// ⇒ id = `bot-revive:{botId}:{sessionId}:{实例序号}`,每次写入都是新实例。 +/// ⇒ 而「尝试次数」按 **bot 键**(前 3 段)记 ⇒ 3 次封顶跨实例有效。 +pub fn enqueue_revive_request(bot: &BotSession, text: &str) -> Result { + let bot_key = format!("bot-revive:{}:{}", bot.bot_id, bot.session_id); + // ① 这个 bot 已经有一条**活的**待办 ⇒ 不重复写。 + if has_live_request_for_bot(&bot_key) { + return Ok(false); + } + // ② 尝试次数已用尽 ⇒ 这个 bot 叫不醒 ⇒ **不要重写请求**。 + // 否则每轮都会把 atMs 刷新一次(让请求看起来还"新鲜"), + // 而投递端又因为 attempts 用尽而永不处理 ⇒ 纯粹的无用写入。 + let attempts = attempts_map(); + if attempts.get(&bot_key).copied().unwrap_or(0) >= MAX_WAKE_ATTEMPTS { + return Ok(false); + } + // ③ 频率限制:最近 `ACK_SUPPRESS_WINDOW_MS` 内已经成功救过这个 bot + // ⇒ 别急着再救(既省钱,也别打断刚恢复的 bot)。 + // ⚠️ 这是**写请求时**的判断;「投递过没有」是另一回事(`acked_ids`,永久)。 + if acked_recent_bot_keys(ACK_SUPPRESS_WINDOW_MS).contains(&bot_key) { + return Ok(false); + } + // ④ 写一条**新实例**的请求(id 唯一 ⇒ 不会被永久 ack 集误杀)。 + let instance_id = format!("{}:{}", bot_key, crate::api::now_ms()); + write_revive_request(&instance_id, bot, text)?; + Ok(true) +} + +/// 读出「已提示过」的错误键集合。 +fn notified_errors() -> std::collections::HashSet { + let Some(path) = notified_path() else { return Default::default() }; + let Ok(text) = std::fs::read_to_string(&path) else { return Default::default() }; + serde_json::from_str::(&text) + .ok() + .and_then(|v| v.get("notified").and_then(|n| n.as_array()).cloned()) + .map(|arr| { + arr.iter() + .filter_map(|x| x.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default() +} + +/// 存回「已提示过」的错误键集合(只保留最近 200 条,避免无限增长)。 +fn save_notified_errors(keys: &std::collections::HashSet) { + let Some(path) = notified_path() else { return }; + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let mut list: Vec = keys.iter().cloned().collect(); + if list.len() > 200 { + list.sort(); + list = list.split_off(list.len() - 200); + } + let body = json!({ "notified": list }); + // ⚠️ 临时文件名必须与 Node 侧区分开:两边会并发写同一个文件 + // (Node 的 bot_wakeup.mjs 写 attempts,Rust 写请求本体), + // 用同一个 ".tmp" 名会互相覆盖对方的半成品 ⇒ 丢请求。 + let tmp = path.with_extension(format!("json.{}.tmp", std::process::id())); + if std::fs::write(&tmp, format!("{}\n", serde_json::to_string_pretty(&body).unwrap_or_default())) + .is_ok() + { + let _ = std::fs::rename(&tmp, &path); + } +} + +/// 判定会话「现在是不是真的没在动」。 +/// +/// ⚠️ 会话上的 `error_code` 可能是**陈旧值** —— 上一轮 500 留下的,运行时没清。 +/// 2026-10-19 实测踩到:那个会话当时正在跑一个 20 多分钟的**正常长回合**, +/// 而它的 error_code 仍是 50113。若照着它去「复活」,会打断正在干活的 bot。 +/// ⇒ 动手前必须同时确认「最近没有新活动」。 +pub fn is_idle(last_activity_ms: i64, now_ms: i64, idle_ms: i64) -> bool { + if last_activity_ms <= 0 { + return true; // 从来没活动过 ⇒ 视为闲置 + } + now_ms - last_activity_ms >= idle_ms +} + +/// 判定一个 bot 会话「现在是不是真的坏了」——**纯函数**,可直接单测。 +/// +/// 判据是**最新回合**(不是会话上那个会变陈的 `error_code`): +/// +/// | 最新回合 | 结论 | 理由 | +/// | --- | --- | --- | +/// | `failed` | **坏了** | 真失败 | +/// | `aborted` | **坏了** | 被中断 | +/// | `completed` | 好了 | 上一轮成功 | +/// | `accepted` | 还在跑 ⇒ 好了;**但**超过 `STUCK_ACCEPTED_AFTER_MS` ⇒ **坏了** | 宿主崩溃留下的僵尸回合永远停在 `accepted` | +/// | 没有记录 | **交给下游判定** | 见下面的说明 | +/// | 其它未知值 | 好了 | 看不懂就别打断人家 | +/// +/// ⚠️ **「没有回合记录」必须返回 `true`**(2026-10-20 二轮独立审计 F3): +/// 原来这里写的是 `!is_idle(...)`,而调用方紧接着还有一道 +/// `if !is_idle(...) { continue; }` —— 两处互为否定 +/// ⇒ 「没有回合记录」的会话**永远走不到救援**,注释承诺的「回退老判据」从未生效。 +/// ⇒ 现在返回 true,把「坏没坏」交给调用方的 `classify_error` + `is_idle` 双重判定。 +/// +/// ⚠️ 调用方那道 `is_idle` 闸**必须对所有分支生效** —— 它既是「没有回合记录」时的 +/// 回退判据,也是「刚失败别急着救」的冷却期。 +pub fn judge_broken( + turn_status: Option<&str>, + turn_accepted_at: i64, + last_activity: i64, + now: i64, + idle_ms: i64, +) -> bool { + match turn_status { + Some("failed") | Some("aborted") => true, + Some("completed") => false, + Some("accepted") => { + // 正在跑 ⇒ 别碰;但「接受」太久(宿主崩溃留下的僵尸)⇒ 该救。 + let age = now - turn_accepted_at; + age > 0 && age > STUCK_ACCEPTED_AFTER_MS + } + Some(_) => false, + None => { + // 没有回合记录 ⇒ 交给下游的 classify_error + is_idle 判定。 + // ⚠️ 这里**不能**再用 `!is_idle(...)`(见上面的说明)。 + let _ = (last_activity, now, idle_ms); + true + } + } +} + +/// 唤醒 bot 时发送的续跑消息正文。 +const REVIVE_MESSAGE: &str = "【看门狗】上一个回合因内部错误中断,本条是自动重新启用的续跑消息。\n请从中断处继续,不要重做已完成的部分。"; + +/// 一轮 Bot 守护。 +/// +/// **只做「判定 + 记账」**:把要唤醒的 bot 写进请求文件,**不负责投递**。 +/// +/// # 为什么不由守护进程直接投递(2026-10-20 实测) +/// +/// 守护进程在 Host 之外,能碰到的表只有 `local_runtime_queue_items`, +/// 但那不是能用的通道: +/// +/// 1. **裸写队列项不会被消费**。隔离实验:往一个挂着 50113 的 bot 会话写一条 +/// 队列项,3 分钟后仍是 `queued`、未 claim、无 turn、无消息; +/// 同一会话用官方 `session send` 则 5 秒内 accepted 并被 bot 回复。 +/// ⇒ Host 的队列轮询器**不认外部写的行**。 +/// 2. **MiniApp 的 `agent.run` 有会话归属限制**。 +/// `service/miniapp/agent/gateway.ts` 的 `assertSessionOwnership`: +/// `owner !== caller.pluginId` ⇒ 抛 `SESSION_NOT_FOUND`。 +/// bot 会话的 `purpose` 是 `miniapp:minimax-bot` ⇒ 蹬王之王**永久无法**唤醒它们。 +/// ⇒ 唯一有权限的投递方是 **minimax-bot 自己的 MiniApp**。 +/// +/// 所以分工是: +/// - **本守护进程(Host 外)**:判定谁挂了 + 写请求文件 + 记账; +/// - **minimax-bot 的 MiniApp(Host 内)**:读请求文件 → `agent.run` 唤醒自己名下的 bot。 +/// +/// 返回 (检查数, 可自愈数, 需人工数, 已投递数)。 +pub fn tick(cfg: &crate::config::BotGuardCfg) -> Result<(usize, usize, usize, usize), String> { + // ⚠️ **v1.6:读不到名单必须说出来**(2026-10-20 独立审计 F1)。 + // 原来这里是 `if bots.is_empty() { return Ok((0,0,0,0)) }` —— + // **一个字都不打**。于是「名单读不出来」这件事在日志里完全不可见, + // 守护进程看起来一切正常,实际**一个 bot 都没看护**。 + // 这正是本轮 `parse_lenient_json` 修掉的那个历史故障的「告警的洞」: + // 修的是因,告警的洞没堵。 + let bots = match read_bot_sessions() { + Ok(b) => b, + Err(e) => { + report_list_problem(&format!("读不到 bot 名单({e})")); + return Ok((0, 0, 0, 0)); + } + }; + if bots.is_empty() { + report_list_problem("bot 名单是空的"); + return Ok((0, 0, 0, 0)); + } + clear_list_problem(); + // 每轮先清理一次请求文件:已 ack 的、尝试次数用尽的、超过 24 小时的都清掉。 + // ⚠️ 必须每轮做(2026-10-20 独立审计发现):只在「写新请求时」清理的话, + // acked 列表滚过 200 条后,那些老请求会重新变成「未处理」⇒ 被二次投递。 + prune_revoke_requests(); + let states = read_session_states(&bots)?; + let now = crate::api::now_ms(); + let mut self_healing = 0usize; + let mut needs_user = 0usize; + let mut revived = 0usize; + // 同一轮里已经在待办里的 id,避免每 60 秒重复写同一个 bot。 + // ⚠️ v1.6:**按 bot 键比**(请求 id 现在带实例后缀,不能直接比 id)。 + let pending_bot_keys: std::collections::BTreeSet = pending_request_ids() + .iter() + .map(|id| bot_key_from_request_id(id)) + .collect(); + // 「需要你处理」的去重:同一个 bot 的同一个错误,只提示一次。 + // 2026-10-19 实测:不加这个,日志每分钟刷 9 行同样的内容(40 分钟 463 行)。 + let mut notified = notified_errors(); + + for bot in &bots { + let Some(st) = states.get(&bot.session_id) else { + continue; + }; + // ── ① **最新回合是权威判据**(2026-10-20 实测,v1.6)── + // + // 会话上的 `error_code` 是**陈旧值**(运行时不清它)。 + // 实测:15:39 那一轮写了 11 条唤醒请求,其中 **8 条是误判** —— + // 那 8 个 bot 的**最新回合其实是 completed**(它好着呢), + // error_code 是更早那次失败留下的陈值。 + let really_broken = judge_broken( + st.turn_status.as_deref(), + st.turn_accepted_at, + st.last_activity, + now, + cfg.idle_ms, + ); + if !really_broken { + continue; + } + // ── ② 还在动 ⇒ 不打扰(长回合保护;也给刚失败的回合留一点冷却时间)── + // + // ⚠️ 这道闸**对所有分支生效**:它既是「没有回合记录」时的回退判据, + // 也是「刚失败别急着救」的冷却期。 + if !is_idle(st.last_activity, now, cfg.idle_ms) { + continue; + } + let (verdict, _reason) = classify_error(st.error_code, &st.error_message); + match verdict { + Verdict::Healthy => continue, + Verdict::NeedsUser => { + needs_user += 1; + // 去重键:bot + 错误码 + 错误文案。错误一变就是新问题,值得再提示。 + let key = format!("{}|{}|{}", bot.bot_id, st.error_code, st.error_message); + if notified.insert(key) { + crate::log(&format!( + "Bot 看门狗:{} 需要你处理({})—— 不自动重试", + bot.bot_name, st.error_message + )); + } + continue; + } + Verdict::SelfHealing => { + // 已经有**活的**待办 ⇒ 不重复投递。 + // + // ⚠️ v1.6:这里**不能**再用 `acked.contains(bot_key)` 挡人。 + // acked 现在是**永久**的「投递过的请求」集合,而旧格式的 ack id + // 恰好**等于** bot 键 ⇒ 那个判断会把「曾经被救过的 bot」 + // 永久挡死(实测:两个真坏的 bot 30 分钟内一次都没被叫醒)。 + // 「多久可以再救一次」由 `enqueue_revive_request` 里的频率限制管。 + let bot_key = format!("bot-revive:{}:{}", bot.bot_id, bot.session_id); + if pending_bot_keys.contains(&bot_key) { + continue; + } + // ⚠️ 尝试次数已用尽 ⇒ 这个 bot 叫不醒 ⇒ **完全静默**。 + // 否则每 60 秒都会打一行「已写入唤醒请求」,而投递端因为 + // attempts 用尽根本不会处理 ⇒ 纯噪音,还把 24 小时兜底架空 + // (atMs 每次被刷新,永远不过期)。 + let attempts_used = attempts_map().get(&bot_key).copied().unwrap_or(0); + if attempts_used >= MAX_WAKE_ATTEMPTS { + continue; + } + self_healing += 1; + // 只写请求文件 —— 投递交给本插件的 MiniApp(它才有会话权限)。 + match enqueue_revive_request(bot, REVIVE_MESSAGE) { + Ok(true) => { + revived += 1; + crate::log(&format!( + "Bot 看门狗:{} 因暂时性错误({})挂掉,已写入唤醒请求(由本插件的 MiniApp 投递)", + bot.bot_name, st.error_message + )); + } + Ok(false) => { + // 已经在待办里 / 刚被别的轮次写过 ⇒ 不值得刷一行日志。 + } + Err(e) => crate::log(&format!( + "⚠️ Bot 看门狗:给 {} 写唤醒请求失败: {e}", + bot.bot_name + )), + } + } + } + } + save_notified_errors(¬ified); + Ok((bots.len(), self_healing, needs_user, revived)) +} + +/// 上次报告过的「名单读不出来」的原因(用于去重,避免每 60 秒刷一行)。 +static LAST_LIST_PROBLEM: std::sync::Mutex> = std::sync::Mutex::new(None); + +/// 报告「读不到 bot 名单」——**同一个原因只说一次**,换原因了才再说。 +fn report_list_problem(detail: &str) { + let Ok(mut guard) = LAST_LIST_PROBLEM.lock() else { return }; + if guard.as_deref() == Some(detail) { + return; + } + *guard = Some(detail.to_string()); + crate::log(&format!( + "⚠️ Bot 看门狗:{detail} —— 本轮**没有看护任何 bot**(守护进程在跑,但它是瞎的)" + )); +} + +/// 名单恢复正常 ⇒ 撤销去重标记(下次再坏还会再说一次)。 +fn clear_list_problem() { + if let Ok(mut guard) = LAST_LIST_PROBLEM.lock() { + if guard.take().is_some() { + crate::log("Bot 看门狗:bot 名单已恢复正常"); + } + } +} + +/// 一个 bot 会话的当前状态。 +struct SessionState { + /// 会话状态(`idle` / `started` / `error` …)。 + #[allow(dead_code)] + status: String, + /// 会话上的错误码 —— ⚠️ 可能是**陈旧值**(运行时不清它)。 + error_code: i64, + /// 错误文案。 + error_message: String, + /// 最后活动时间(`local_runtime_message_rows` 里最近一条消息的时间)。 + last_activity: i64, + /// **最新回合**的状态:`accepted`(正在跑)/ `completed`(成功)/ + /// `failed`(失败)/ `aborted`(被中断)/ `None`(没有回合记录)。 + /// + /// ⚠️ 这是判「这个 bot 现在坏没坏」的**权威依据**(2026-10-20 实测)。 + turn_status: Option, + /// 最新回合被「接受」的时间(毫秒)—— 用来判断 `accepted` 是不是僵尸回合。 + turn_accepted_at: i64, +} + +/// 回合停在 `accepted` 超过这么久 ⇒ 视为**僵尸回合**(宿主崩溃/被杀留下的)。 +/// +/// ⚠️ 取 **1 小时**:`accepted` 既可能是「正在跑」,也可能是「宿主崩了留下的僵尸」。 +/// 正常的长回合实测能跑 20 多分钟 ⇒ 阈值必须比它大。 +/// 1 小时既能覆盖长回合,又能保证僵尸回合不会**永远**不被救。 +pub const STUCK_ACCEPTED_AFTER_MS: i64 = 60 * 60 * 1000; + +/// 读一批会话的当前状态:status / error_code / error_message / 最后活动时间 / 最新回合状态。 +/// +/// 最后活动时间取 `local_runtime_message_rows` 里该会话最近一条消息的时间 —— +/// 这是「这个会话现在还在动吗」的权威依据。 +/// +/// 最新回合取 `local_runtime_turn_ingress` 里该会话 `accepted_at_ms` 最大的那条 —— +/// 这是「这个 bot 现在坏没坏」的权威依据(见 `SessionState::turn_status`)。 +fn read_session_states( + bots: &[BotSession], +) -> Result, String> { + let mut out = std::collections::HashMap::new(); + if bots.is_empty() { + return Ok(out); + } + let Some(db) = runtime_db_path() else { return Ok(out) }; + let ids = bots + .iter() + .map(|b| format!("'{}'", b.session_id.replace('\'', "''"))) + .collect::>() + .join(","); + let sql = format!( + "SELECT s.session_id AS sid, s.status AS status,\n\ + IFNULL(s.error_code, 0) AS error_code, IFNULL(s.error_message, '') AS error_message,\n\ + IFNULL((SELECT MAX(m.created_at_ms) FROM local_runtime_message_rows m\n\ + WHERE m.session_id = s.session_id), 0) AS last_activity,\n\ + (SELECT t.status FROM local_runtime_turn_ingress t\n\ + WHERE t.session_id = s.session_id\n\ + ORDER BY t.accepted_at_ms DESC LIMIT 1) AS turn_status,\n\ + IFNULL((SELECT t.accepted_at_ms FROM local_runtime_turn_ingress t\n\ + WHERE t.session_id = s.session_id\n\ + ORDER BY t.accepted_at_ms DESC LIMIT 1), 0) AS turn_accepted_at\n\ + FROM local_runtime_sessions s WHERE s.session_id IN ({ids});" + ); + let raw = crate::goal_sync::exec_sql(&db, &sql)?; + let rows: Vec = serde_json::from_str::(&raw) + .ok() + .and_then(|v| v.as_array().cloned()) + .unwrap_or_default(); + for r in rows { + let Some(sid) = r.get("sid").and_then(|v| v.as_str()) else { continue }; + out.insert( + sid.to_string(), + SessionState { + status: r.get("status").and_then(|v| v.as_str()).unwrap_or("").to_string(), + error_code: r.get("error_code").and_then(|v| v.as_i64()).unwrap_or(0), + error_message: r + .get("error_message") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + last_activity: r.get("last_activity").and_then(|v| v.as_i64()).unwrap_or(0), + turn_status: r + .get("turn_status") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + turn_accepted_at: r.get("turn_accepted_at").and_then(|v| v.as_i64()).unwrap_or(0), + }, + ); + } + Ok(out) +} + +/// 运行时状态库的位置。 +fn runtime_db_path() -> Option { + std::env::var("HOME").ok().map(|h| { + PathBuf::from(h) + .join(".minimax/v2/sqlite/runtime-state.sqlite") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── 解析器(v1.6 补回归测试:之前这个函数零覆盖)── + + #[test] + fn lenient_parse_keeps_valid_surrogate_pairs() { + // 合法代理对(😀 = U+1F600)必须**原样保留**。 + let v = parse_lenient_json(r#"{"a":"\ud83d\ude00"}"#).expect("应能解析"); + assert_eq!(v["a"].as_str(), Some("\u{1F600}")); + } + + #[test] + fn lenient_parse_replaces_isolated_high_surrogate() { + // 截断的高代理(低代理丢了)⇒ 替换成 U+FFFD。 + let v = parse_lenient_json(r#"{"a":"x\ud83dy"}"#).expect("应能解析"); + assert_eq!(v["a"].as_str(), Some("x\u{FFFD}y")); + } + + #[test] + fn lenient_parse_keeps_d000_range_characters() { + // ⚠️ U+D000..=U+D7FF 是**合法普通字符**,不是代理 ⇒ 不能被替换。 + let v = parse_lenient_json(r#"{"a":"\ud7b0"}"#).expect("应能解析"); + assert_eq!(v["a"].as_str(), Some("\u{D7B0}")); + } + + #[test] + fn lenient_parse_does_not_repair_unrelated_broken_json() { + // 与代理无关的坏 JSON ⇒ 不许「修」⇒ 返回 None(调用方据此报「解析失败」)。 + assert!(parse_lenient_json(r#"{"a":1,}"#).is_none()); + assert!(parse_lenient_json("not json at all").is_none()); + } + + #[test] + fn lenient_parse_handles_high_followed_by_high() { + // 高+高不是合法代理对 ⇒ 第一个高代理是孤立的 ⇒ 替换。 + let v = parse_lenient_json(r#"{"a":"\ud83d\ud83d"}"#).expect("应能解析"); + assert_eq!(v["a"].as_str(), Some("\u{FFFD}\u{FFFD}")); + } + + // ── bot 键(v1.6:请求 id 按实例唯一化)── + + #[test] + fn bot_key_strips_instance_suffix() { + assert_eq!( + bot_key_from_request_id("bot-revive:bot1:mvs_abc:1792481946906"), + "bot-revive:bot1:mvs_abc" + ); + // 旧格式(没有实例后缀)也要折成同一个键。 + assert_eq!( + bot_key_from_request_id("bot-revive:bot1:mvs_abc"), + "bot-revive:bot1:mvs_abc" + ); + // 形状不对 ⇒ 原样返回(不能瞎折)。 + assert_eq!(bot_key_from_request_id("weird"), "weird"); + } + + #[test] + fn attempts_are_counted_per_bot_not_per_instance() { + // 同一个 bot 的不同实例 ⇒ 共享同一份尝试次数(否则 3 次封顶被架空)。 + let now = crate::api::now_ms(); + let acked = std::collections::BTreeSet::new(); + let mut attempts = std::collections::BTreeMap::new(); + attempts.insert("bot-revive:b1:mvs_x".to_string(), MAX_WAKE_ATTEMPTS); + // 新实例 id 折出的 bot 键同样被封顶挡住。 + assert!( + !is_request_live("bot-revive:b1:mvs_x:111", now, now, &acked, &attempts), + "换实例也不该绕过 3 次封顶" + ); + } + + #[test] + fn acked_request_is_never_delivered_again() { + // ⚠️ v1.6:acked 是**永久**的 —— 「投递过了就是投递过了」。 + let now = crate::api::now_ms(); + let mut acked = std::collections::BTreeSet::new(); + acked.insert("bot-revive:b1:mvs_x:111".to_string()); + let attempts = std::collections::BTreeMap::new(); + assert!( + !is_request_live("bot-revive:b1:mvs_x:111", now, now, &acked, &attempts), + "已投递的请求不该再投" + ); + } + + // ── 二轮审计 F1:失败计数不能被「曾被救过」清掉 ── + + #[test] + fn attempts_survive_a_bot_that_was_acked_before() { + // ⚠️ v1.6 回归(2026-10-20 二轮独立审计 F1): + // acked 永久化之后,「按 acked 清理 attempts」会把**曾经被救过**的 bot + // 的失败计数一起清掉 ⇒ 3 次封顶失效 ⇒ 无限重投。 + // ⇒ 清理条件里**不能**有 acked。 + let dir = std::env::temp_dir().join(format!("expiry-attempts-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let attempts_file = dir.join("bot-revive-attempts.json"); + std::fs::write( + &attempts_file, + r#"{"attempts":{"bot-revive:b1:mvs_x":{"n":2,"at":1792480000000}}}"#, + ) + .unwrap(); + // 把路径指向临时目录(attempts_path 读的是 $HOME)。 + let prev_home = std::env::var("HOME").ok(); + unsafe { std::env::set_var("HOME", &dir) }; + // 此刻 HOME 下的路径是 $HOME/.config/mcode-reset-card/..., + // 上面写错了位置 ⇒ 改成正确布局再测。 + let real = dir.join(".config").join("mcode-reset-card"); + std::fs::create_dir_all(&real).unwrap(); + std::fs::write( + real.join("bot-revive-attempts.json"), + r#"{"attempts":{"bot-revive:b1:mvs_x":{"n":2,"at":1792480000000}}}"#, + ) + .unwrap(); + // 造一个「这个 bot 曾被救过」的 ack 记录。 + std::fs::write( + real.join("bot-revive-acked.json"), + r#"{"acked":[{"id":"bot-revive:b1:mvs_x:111","atMs":1792480000000}]}"#, + ) + .unwrap(); + + // 跑清理(只按超龄清)⇒ 失败计数必须**留着**。 + prune_attempts(crate::api::now_ms()); + let after = std::fs::read_to_string(real.join("bot-revive-attempts.json")).unwrap(); + assert!( + after.contains("bot-revive:b1:mvs_x"), + "被救过的 bot 的失败计数不能被清掉(否则 3 次封顶失效):{after}" + ); + + if let Some(h) = prev_home { + unsafe { std::env::set_var("HOME", h) }; + } + let _ = std::fs::remove_dir_all(&dir); + } + + // ── 二轮审计 F2:解析器不能 panic ── + + #[test] + fn lenient_parse_does_not_panic_on_multibyte_after_backslash_u() { + // ⚠️ 原来的写法按字节切 `&str` ⇒ 落在多字节字符中间会 **panic** + // ⇒ 守护进程整个崩掉(launchd 重启后同样崩 ⇒ 崩溃循环)。 + // 触发条件:文件严格 JSON 解析失败 **且** 含 `\u` + 非 ASCII 字符。 + for raw in [ + r#"{"a":"\u中中"}"#, + r#"{"a":"x\uy中"}"#, + r#"{"a":"\u"}"#, + r#"{"a":"\u12"}"#, + r#"{"a":"\u12中4"}"#, + ] { + // 关键:不能 panic。 + let _ = parse_lenient_json(raw); + } + } + + // ── 二轮审计 F3 / F4:判定层的三个边界 ── + + #[test] + fn no_turn_record_is_rescuable() { + // ⚠️ 原来 `None => !is_idle(...)` 与调用方的 `is_idle` 闸互为否定 + // ⇒ 「没有回合记录」的会话永远走不到救援。 + assert!( + judge_broken(None, 0, 0, 1_000_000, 90_000), + "没有回合记录时必须交给下游判定,不能直接判「没坏」" + ); + } + + #[test] + fn fresh_accepted_turn_is_left_alone_but_zombie_is_rescued() { + let now = 1_000_000_000i64; + // 刚开始的回合(正在跑)⇒ 别碰。 + assert!(!judge_broken(Some("accepted"), now - 1_000, 0, now, 90_000)); + // 跑了 20 分钟的长回合 ⇒ 仍别碰(实测长回合能跑 20+ 分钟)。 + assert!(!judge_broken( + Some("accepted"), + now - 20 * 60 * 1000, + 0, + now, + 90_000 + )); + // 僵尸回合(宿主崩溃留下,停了 2 小时)⇒ 该救。 + assert!(judge_broken( + Some("accepted"), + now - 2 * 60 * 60 * 1000, + 0, + now, + 90_000 + )); + } + + #[test] + fn failed_and_completed_are_unambiguous() { + let now = 1_000_000_000i64; + assert!(judge_broken(Some("failed"), 0, 0, now, 90_000)); + assert!(judge_broken(Some("aborted"), 0, 0, now, 90_000)); + assert!(!judge_broken(Some("completed"), 0, 0, now, 90_000)); + } + + #[test] + fn needs_user_errors_are_not_retried() { + for code in [ + error_code::USAGE_LIMIT_EXCEEDED, + error_code::LLM_CREDITS_EXHAUSTED, + error_code::LLM_AUTH_ERROR, + ] { + let (v, _) = classify_error(code, "whatever"); + assert_eq!(v, Verdict::NeedsUser, "错误码 {code} 不该自动重试"); + } + } + + #[test] + fn transient_errors_are_retried() { + for code in [ + error_code::LLM_UPSTREAM_ERROR, + error_code::LLM_TPM_RATE_LIMITED, + error_code::LLM_CLUSTER_OVERLOADED, + ] { + let (v, _) = classify_error(code, "Internal server error (500)"); + assert_eq!(v, Verdict::SelfHealing, "错误码 {code} 应当自动重试"); + } + } + + /// 回归:44 个 bot 会话里 29 个是「无错误码无消息」—— 它们只是闲置很久, + /// 并不是挂掉。此前判成可自疗 ⇒ 会去「重新启用」一批没坏的 bot。 + #[test] + fn no_error_and_no_message_is_healthy() { + let (v, _) = classify_error(0, ""); + assert_eq!(v, Verdict::Healthy); + let (v2, _) = classify_error(0, " "); + assert_eq!(v2, Verdict::Healthy); + } + + #[test] + fn short_term_rate_limit_is_self_healing() { + // 50111 = 短时限流(HTTP 429),运行时标 retryable ⇒ 应当自动重新启用。 + let (v, _) = classify_error(error_code::LLM_RATE_LIMITED, "rate limited"); + assert_eq!(v, Verdict::SelfHealing); + } + + #[test] + fn prune_keeps_only_live_requests() { + // 2026-10-20 独立审计发现:acked 列表只留最近 200 条 ⇒ 滚过之后 + // 老请求会重新变成「未处理」⇒ 被投递端二次投递 ⇒ 重复唤醒 + 双倍 token。 + // ⇒ 每轮都要清理一次:已 ack / 尝试用尽 / 超过 24 小时的都清掉。 + let now = crate::api::now_ms(); + let mut acked = std::collections::BTreeSet::new(); + acked.insert("acked-one".to_string()); + let mut attempts = std::collections::BTreeMap::new(); + attempts.insert("tried-3".to_string(), MAX_WAKE_ATTEMPTS); + + assert!(is_request_live("live", now, now, &acked, &attempts), "正常请求应保留"); + assert!(!is_request_live("acked-one", now, now, &acked, &attempts), "已 ack 不该再投"); + assert!(!is_request_live("tried-3", now, now, &acked, &attempts), "试满次数不该再投"); + assert!( + !is_request_live("ancient", now - MAX_WAKE_REQUEST_AGE_MS - 1000, now, &acked, &attempts), + "超过最长保留时间不该再留" + ); + assert!(!is_request_live("", now, now, &acked, &attempts), "空 id 不该保留"); + } + + #[test] + fn attempts_map_reads_both_formats() { + // ⚠️ 投递端写的是 `{"n": 2, "at": 1699...}`, + // 之前只读 `v.as_i64()` ⇒ 读到新格式时得到 None ⇒ attempts 恒为 0 + // ⇒ 3 次封顶在守护进程侧完全失效(无限重试)。 + // (2026-10-20 独立审计发现) + let dir = std::env::temp_dir().join(format!( + "reset-card-att-{}-{}", + std::process::id(), + crate::api::now_ms() + )); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("bot-revive-attempts.json"); + std::fs::write( + &file, + r#"{"attempts": {"new-format": {"n": 2, "at": 1700000000000}, "old-format": 3}}"#, + ) + .unwrap(); + let parsed = std::fs::read_to_string(&file).unwrap(); + let v = serde_json::from_str::(&parsed).unwrap(); + let obj = v.get("attempts").and_then(|a| a.as_object()).unwrap(); + let read = |val: &serde_json::Value| -> Option { + val.get("n") + .and_then(|x| x.as_i64()) + .or_else(|| val.as_i64()) + }; + assert_eq!(read(&obj["new-format"]), Some(2), "新格式 {{n,at}} 必须能读出次数"); + assert_eq!(read(&obj["old-format"]), Some(3), "旧格式纯数字也要能读"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn prune_attempts_keeps_exhausted_but_not_stale() { + // ⚠️ 清理条件必须是「已 ack」或「超龄」—— + // 「次数用尽」的记录**要保留**,它正是「不要再试了」的依据。 + // 若按「requests 文件里还在不在」清 ⇒ Rust 的 prune 会连带清掉它 + // ⇒ 3 次封顶被架空 ⇒ 无限重试(2026-10-20 独立审计发现)。 + let now = crate::api::now_ms(); + let mut acked = std::collections::BTreeSet::new(); + acked.insert("done".to_string()); + let map = serde_json::json!({ + "attempts": { + "done": { "n": 1, "at": now }, + "exhausted": { "n": MAX_WAKE_ATTEMPTS, "at": now }, + "fresh": { "n": 1, "at": now }, + "stale": { "n": 1, "at": now - MAX_WAKE_REQUEST_AGE_MS - 1000 }, + } + }); + let m = map["attempts"].as_object().unwrap(); + let kept: Vec<&str> = m + .iter() + .filter(|(k, val)| { + let n = val.get("n").and_then(|x| x.as_i64()).unwrap_or(0); + let at = val.get("at").and_then(|x| x.as_i64()).unwrap_or(0); + let done = acked.contains(k.as_str()); + let too_old = at > 0 && now - at > MAX_WAKE_REQUEST_AGE_MS; + let _ = n; + !done && !too_old + }) + .map(|(k, _)| k.as_str()) + .collect(); + assert!(kept.contains(&"exhausted"), "次数用尽的记录要保留(否则封顶被架空)"); + assert!(kept.contains(&"fresh"), "正常记录要保留"); + assert!(!kept.contains(&"done"), "已 ack 的要清"); + assert!(!kept.contains(&"stale"), "超龄的要清"); + } + + #[test] + fn deleted_cron_is_needs_user_not_a_crash() { + let (v, _) = classify_error(0, "Cron definition was deleted before execution"); + assert_eq!(v, Verdict::NeedsUser); + } + + #[test] + fn quota_text_without_code_is_needs_user() { + let (v, _) = classify_error(0, "当前已达到 Token Plan 用量上限 (2067)"); + assert_eq!(v, Verdict::NeedsUser); + } + + #[test] + fn network_text_without_code_is_self_healing() { + let (v, _) = classify_error(0, "net::ERR_NETWORK_CHANGED"); + assert_eq!(v, Verdict::SelfHealing); + } + + #[test] + fn backoff_is_capped() { + let d1 = backoff_ms(1, 5_000, 60_000); + let d5 = backoff_ms(5, 5_000, 60_000); + let d100 = backoff_ms(100, 5_000, 60_000); + assert!(d5 > d1, "退避应随次数增长"); + assert!(d100 <= 61_000, "退避必须封顶:{d100}"); + } +} diff --git a/plugins/hylouis233/expiry-guardian/daemon/rust/src/config.rs b/plugins/hylouis233/expiry-guardian/daemon/rust/src/config.rs new file mode 100644 index 0000000..28bf76f --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/rust/src/config.rs @@ -0,0 +1,340 @@ +//! 共享配置:与 Mini App「蹬王之王」读**同一个文件**。 +//! +//! 这是三份实现不漂移的前提:Node / Rust / Python 都从这里取阈值与分界点, +//! 谁都不许再把 99 写死在自己代码里。 +//! +//! 路径 `~/.config/mcode-reset-card/config.json` 与 Mini App 侧一致 +//! (Rust `state.rs` 的 bg-state.json 也在同一目录)。 +//! +//! 读失败或字段非法一律**回退默认并记 issue**,绝不静默纠正 —— +//! 静默纠正会让人以为自己设的生效了。 + +use std::fs; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::decision::{ + DEFAULT_CARD_SKIP_BEFORE_RESET_MINUTES, DEFAULT_QUOTA_LOW_PERCENT, DEFAULT_QUOTA_TIGHT_PERCENT, + DEFAULT_QUOTA_TRIGGER_PERCENT, +}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct QuotaLevels { + #[serde(default = "d_tight")] + pub tight: f64, + #[serde(default = "d_low")] + pub low: f64, +} + +fn d_tight() -> f64 { + DEFAULT_QUOTA_TIGHT_PERCENT +} +fn d_low() -> f64 { + DEFAULT_QUOTA_LOW_PERCENT +} +fn d_card() -> f64 { + DEFAULT_QUOTA_TRIGGER_PERCENT +} + +impl Default for QuotaLevels { + fn default() -> Self { + QuotaLevels { tight: DEFAULT_QUOTA_TIGHT_PERCENT, low: DEFAULT_QUOTA_LOW_PERCENT } + } +} + +/// `rename_all = "camelCase"` **不是可有可无的风格问题**: +/// Node / Python 写出的配置键是 `goalPause.triggerPercent` 这类驼峰, +/// 而 Rust 结构体字段是 snake_case。没有这行,serde 会认为**所有字段都不存在**, +/// 然后逐个落到 `#[serde(default)]` —— 于是 `enabled` 永远读到 `false`, +/// 且**一行错都不报**。这个坑真实发生过:功能看起来写完了,却永远打不开。 +/// +/// `deny_unknown_fields` 是配套的安全网:宁可解析失败并报出来, +/// 也不要「静默降级成默认值」让人以为自己设的值生效了。 +/// Bot 守护配置(守护 MiniMax Bot 团队)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BotGuardCfg { + /// 总开关。默认关 —— 需要用户明确开启。 + #[serde(default)] + pub enabled: bool, + /// 超过多久没有任何活动才算「闲下来」(毫秒)。 + #[serde(default = "d_bot_idle")] + pub idle_ms: i64, + /// 退避基数(毫秒)。 + #[serde(default = "d_bot_backoff_base")] + pub backoff_base_ms: i64, + /// 退避上限(毫秒)。 + #[serde(default = "d_bot_backoff_cap")] + pub backoff_cap_ms: i64, +} + +fn d_bot_idle() -> i64 { + 90_000 +} +fn d_bot_backoff_base() -> i64 { + 5_000 +} +fn d_bot_backoff_cap() -> i64 { + 60_000 +} + +impl Default for BotGuardCfg { + fn default() -> Self { + BotGuardCfg { + enabled: false, + idle_ms: d_bot_idle(), + backoff_base_ms: d_bot_backoff_base(), + backoff_cap_ms: d_bot_backoff_cap(), + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct GoalPauseCfg { + #[serde(default)] + pub enabled: bool, + #[serde(default = "d_card")] + pub trigger_percent: f64, + #[serde(default)] + pub resume_at_percent: f64, +} + +impl Default for GoalPauseCfg { + fn default() -> Self { + GoalPauseCfg { enabled: false, trigger_percent: DEFAULT_QUOTA_TRIGGER_PERCENT, resume_at_percent: 0.0 } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Config { + #[serde(default = "one")] + pub schema_version: u32, + #[serde(default = "d_card")] + pub card_trigger_percent: f64, + /// 距 5 小时额度刷新不足这么多分钟时,不消耗重置卡。0 = 关闭该保护。 + #[serde(default = "d_skip")] + pub card_skip_before_reset_minutes: f64, + #[serde(default)] + pub quota_levels: QuotaLevels, + #[serde(default)] + pub goal_pause: GoalPauseCfg, + /// 守护 MiniMax Bot 团队:挂掉了自动重新启用。 + #[serde(default)] + pub bot_guard: BotGuardCfg, +} + +fn one() -> u32 { + 1 +} + +fn d_skip() -> f64 { + DEFAULT_CARD_SKIP_BEFORE_RESET_MINUTES +} + +impl Default for Config { + fn default() -> Self { + Config { + schema_version: 1, + card_trigger_percent: DEFAULT_QUOTA_TRIGGER_PERCENT, + card_skip_before_reset_minutes: DEFAULT_CARD_SKIP_BEFORE_RESET_MINUTES, + quota_levels: QuotaLevels::default(), + goal_pause: GoalPauseCfg::default(), + bot_guard: BotGuardCfg::default(), + } + } +} + +impl Config { + /// 判定参数视图,供 `decide()` 使用。 + pub fn decision_cfg(&self) -> crate::decision::DecisionCfg { + crate::decision::DecisionCfg { + card_trigger_percent: self.card_trigger_percent, + tight: self.quota_levels.tight, + low: self.quota_levels.low, + card_skip_before_reset_minutes: self.card_skip_before_reset_minutes, + } + } +} + +fn clamp(v: f64, lo: f64, hi: f64, fallback: f64) -> f64 { + if !v.is_finite() { + return fallback; + } + v.max(lo).min(hi) +} + +/// 逐字段校验并夹紧,返回 (配置, 问题列表)。 +pub fn normalize(mut raw: Config) -> (Config, Vec) { + let mut issues = Vec::new(); + let d = Config::default(); + + let card = clamp(raw.card_trigger_percent, 1.0, 100.0, d.card_trigger_percent); + if card != raw.card_trigger_percent { + issues.push(format!( + "card_trigger_percent={} 超出 1..100,已夹到 {}", + raw.card_trigger_percent, card + )); + } + raw.card_trigger_percent = card; + + // 跳过阈值:0 表示关闭该保护;负数没有意义,按 0 夹。 + let skip = clamp(raw.card_skip_before_reset_minutes, 0.0, 24.0 * 60.0, d.card_skip_before_reset_minutes); + if skip != raw.card_skip_before_reset_minutes { + issues.push(format!( + "card_skip_before_reset_minutes={} 超出 0..1440,已夹到 {skip}", + raw.card_skip_before_reset_minutes + )); + } + raw.card_skip_before_reset_minutes = skip; + + let mut tight = clamp(raw.quota_levels.tight, 1.0, 100.0, d.quota_levels.tight); + let mut low = clamp(raw.quota_levels.low, 1.0, 100.0, d.quota_levels.low); + // 分界点必须 0 < tight < low <= 100;非法组合整体回退默认,而不是悄悄调序。 + if !(tight < low) { + issues.push(format!( + "quota_levels 需满足 tight < low(收到 tight={} low={}),已整体回退默认", + tight, low + )); + tight = d.quota_levels.tight; + low = d.quota_levels.low; + } else if tight != raw.quota_levels.tight || low != raw.quota_levels.low { + issues.push("quota_levels 越界,已夹紧".to_string()); + } + raw.quota_levels.tight = tight; + raw.quota_levels.low = low; + + let t = clamp(raw.goal_pause.trigger_percent, 1.0, 100.0, d.goal_pause.trigger_percent); + if t != raw.goal_pause.trigger_percent { + issues.push(format!( + "goal_pause.trigger_percent={} 超出 1..100,已夹到 {}", + raw.goal_pause.trigger_percent, t + )); + } + raw.goal_pause.trigger_percent = t; + + let r = clamp(raw.goal_pause.resume_at_percent, 0.0, 100.0, d.goal_pause.resume_at_percent); + if r != raw.goal_pause.resume_at_percent { + issues.push(format!( + "goal_pause.resume_at_percent={} 超出 0..100,已夹到 {}", + raw.goal_pause.resume_at_percent, r + )); + } + raw.goal_pause.resume_at_percent = r; + + (raw, issues) +} + +pub fn default_path() -> Option { + let home = std::env::var("HOME").ok()?; + Some(PathBuf::from(home).join(".config/mcode-reset-card/config.json")) +} + +/// 读配置。文件不存在是正常情况(首次运行),返回默认值且不记 issue。 +pub fn load(path: &PathBuf) -> (Config, Vec) { + let text = match fs::read_to_string(path) { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (Config::default(), Vec::new()), + Err(e) => return (Config::default(), vec![format!("配置读取失败({}),正在用默认值", e)]), + }; + match serde_json::from_str::(&text) { + Ok(c) => normalize(c), + Err(e) => (Config::default(), vec![format!("配置解析失败({}),正在用默认值", e)]), + } +} + +/// 每轮重读,实现「改配置无需重启守护进程」。 +pub fn load_or_default() -> (Config, Vec) { + match default_path() { + Some(p) => load(&p), + None => ( + Config::default(), + vec!["找不到 $HOME,配置不可读,正在用默认值".to_string()], + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_are_the_documented_ones() { + let c = Config::default(); + assert_eq!(c.card_trigger_percent, 99.0); + assert_eq!(c.quota_levels.tight, 80.0); + assert_eq!(c.quota_levels.low, 95.0); + assert!(!c.goal_pause.enabled, "Goal 自动暂停默认关"); + assert_eq!(c.goal_pause.resume_at_percent, 0.0); + } + + #[test] + fn out_of_range_is_clamped_and_reported() { + let (c, issues) = normalize(Config { + card_trigger_percent: 150.0, + ..Default::default() + }); + assert_eq!(c.card_trigger_percent, 100.0); + assert!(!issues.is_empty(), "越界必须报出来,不能静默"); + } + + #[test] + fn inverted_bounds_fall_back_whole() { + let (c, issues) = normalize(Config { + quota_levels: QuotaLevels { tight: 96.0, low: 95.0 }, + ..Default::default() + }); + assert_eq!(c.quota_levels.tight, 80.0, "非法组合应整体回退默认"); + assert_eq!(c.quota_levels.low, 95.0); + assert!(issues.iter().any(|s| s.contains("tight < low"))); + } + + #[test] + fn camel_case_keys_are_actually_read() { + // 回归测试:Node / Python 写出的就是这套驼峰键。 + // 一旦有人删掉 rename_all,下面这条会安静地读成 enabled=false 而测试仍可能通过, + // 所以这里直接断言「解析成功且 enabled 为真」,不给它蒙混过关的机会。 + let (c, issues) = load(&PathBuf::from( + std::env::temp_dir().join("cfg-camel-probe.json"), + )); + // 文件不存在 → 默认值,这条只用来确认默认路径本身没坏 + assert!(!c.goal_pause.enabled); + assert!(issues.is_empty()); + + let camel = serde_json::json!({ + "schemaVersion": 1, + "cardTriggerPercent": 99, + "quotaLevels": { "tight": 80, "low": 95 }, + "goalPause": { "enabled": true, "triggerPercent": 50, "resumeAtPercent": 0 } + }); + let parsed: Config = serde_json::from_value(camel.clone()) + .expect("驼峰键必须能被解析(缺 rename_all 时会在这里炸)"); + assert!(parsed.goal_pause.enabled, "驼峰 goalPause.enabled 必须真的生效"); + assert_eq!(parsed.goal_pause.trigger_percent, 50.0); + assert_eq!(parsed.card_trigger_percent, 99.0); + + // 反面:snake_case 现在应当被明确拒绝,而不是静默降级 + let snake = serde_json::json!({ + "schema_version": 1, + "goal_pause": { "enabled": true, "trigger_percent": 50 } + }); + assert!( + serde_json::from_value::(snake).is_err(), + "snake_case 必须报错(deny_unknown_fields),绝不能静默降级" + ); + } + + #[test] + fn valid_bounds_are_kept() { + let (c, issues) = normalize(Config { + quota_levels: QuotaLevels { tight: 70.0, low: 90.0 }, + ..Default::default() + }); + assert_eq!(c.quota_levels.tight, 70.0); + assert_eq!(c.quota_levels.low, 90.0); + assert!(issues.is_empty()); + } +} diff --git a/plugins/hylouis233/expiry-guardian/daemon/rust/src/decision.rs b/plugins/hylouis233/expiry-guardian/daemon/rust/src/decision.rs new file mode 100644 index 0000000..b0b10b4 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/rust/src/decision.rs @@ -0,0 +1,733 @@ +//! 额度与到期判定。 +//! +//! 这些分支与 Mini App「蹬王之王」的 `decide(, true)` 一一对应, +//! 由 `verify-parity.mjs` 逐字比对「动作 + 理由文本」来防止漂移。 +//! **改动任何一条理由文案,两侧都要同步改,并重跑校验器。** + +use serde::Deserialize; + +pub const QUOTA_FIVE_HOUR: i64 = 1; +pub const QUOTA_WEEKLY: i64 = 2; +pub const QUOTA_VIDEO: i64 = 3; + +/// 默认触发阈值 99%。实际生效值来自 config.json,这里只作兜底。 +/// 与 Node / Python 保持一致;改动必须同步三处。 +pub const DEFAULT_QUOTA_TRIGGER_PERCENT: f64 = 99.0; +/// 默认「额度紧张」分界点(黄)。 +pub const DEFAULT_QUOTA_TIGHT_PERCENT: f64 = 80.0; +/// 默认「额度不足」分界点(红)。 +pub const DEFAULT_QUOTA_LOW_PERCENT: f64 = 95.0; +/// 默认「距刷新不足这么多分钟就不烧重置卡」。实际生效值来自 config.json。 +pub const DEFAULT_CARD_SKIP_BEFORE_RESET_MINUTES: f64 = 60.0; + +/// 额度分档。与 Node 的 `quotaLevel()`、Python 的 `_quota_level()` 逐字同构。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Level { + Comfortable, + Tight, + Low, + Burned, +} + +impl Level { + /// 中文文案。**属于 parity 契约的一部分**,改一个字三方都要改。 + pub fn label(&self) -> &'static str { + match self { + Level::Comfortable => "额度充足", + Level::Tight => "额度紧张", + Level::Low => "额度不足", + Level::Burned => "额度燃尽", + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Level::Comfortable => "comfortable", + Level::Tight => "tight", + Level::Low => "low", + Level::Burned => "burned", + } + } +} + +/// 额度分档:comfortable / tight / low / burned。 +/// +/// **burned 必须先判**:若先判 low,`used=100` 会命中 `ratio >= low` +/// 被算成「不足」,燃尽这一档就永远显示不出来。 +/// +/// 燃尽只用 `used >= total` 判定,**不读 exhausted**: +/// Python 的 exhausted 带 -1e-9 容差而本文件没有, +/// 若依赖它会在极限值上产生三方不一致。 +pub fn quota_level( + used: f64, + total: f64, + unlimited: bool, + tight: f64, + low: f64, +) -> Level { + if unlimited || total <= 0.0 { + return Level::Comfortable; + } + if used >= total { + return Level::Burned; + } + let ratio = used / total * 100.0; + if ratio >= low { + return Level::Low; + } + if ratio >= tight { + return Level::Tight; + } + Level::Comfortable +} + +/// 一个限额维度的用量。 +#[derive(Debug, Clone, Copy)] +pub struct QuotaState { + pub quota_type: i64, + pub total: f64, + pub used: f64, + pub is_unlimited: bool, + /// 该维度额度的刷新时间(毫秒时间戳)。 + /// + /// 有了它才能判断「额度马上就要自己刷新了」—— + /// 那种时候再烧一张重置卡就是纯浪费。 + pub reset_time_ms: Option, +} + +impl QuotaState { + /// 是否已耗尽。不限量或总量为 0 都不算耗尽。 + pub fn exhausted(&self) -> bool { + if self.is_unlimited || self.total <= 0.0 { + return false; + } + self.used >= self.total + } + + /// 决策触发线:额度用到 `trigger_percent` 即视为「该动手了」。 + /// + /// 不等真正耗尽 —— 等耗尽时那次请求已经失败,会打断当前目标 + /// 正在跑的自动执行。提前介入代价几乎为零。 + pub fn triggered(&self, trigger_percent: f64) -> bool { + if self.is_unlimited || self.total <= 0.0 { + return false; + } + self.used >= self.total * trigger_percent / 100.0 + } + + /// 展示用分档。与 `triggered()` 相互独立。 + pub fn level(&self, tight: f64, low: f64) -> Level { + quota_level(self.used, self.total, self.is_unlimited, tight, low) + } + + pub fn remaining_percent(&self) -> f64 { + if self.is_unlimited || self.total <= 0.0 { + return 100.0; + } + ((self.total - self.used) / self.total * 100.0).max(0.0) + } + + pub fn label(&self) -> &'static str { + match self.quota_type { + QUOTA_FIVE_HOUR => "5小时限额", + QUOTA_WEEKLY => "周限额", + QUOTA_VIDEO => "视频限额", + _ => "未知限额", + } + } + + pub fn describe(&self) -> String { + if self.is_unlimited { + return format!("{}=不限量", self.label()); + } + format!( + "{}=已用{:.0}% / 剩余{:.0}%", + self.label(), + self.used, + self.remaining_percent() + ) + } +} + +/// 金额字符串(可能带千分位)转数字。 +pub fn amount(value: &serde_json::Value) -> f64 { + match value { + serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0), + serde_json::Value::String(s) => { + let cleaned: String = s.chars().filter(|c| *c != ',').collect(); + cleaned.trim().parse::().unwrap_or(0.0) + } + _ => 0.0, + } +} + +/// 百分数字符串(如 "71%")转数字。 +/// +/// 接口返回的就是 `"71%"` 这种带百分号的字符串,直接 `parse::()` 会失败并 +/// 静默退回 0,把「已用 71%」误判成「已用 0%」。这里必须先剥掉 `%`。 +pub fn percent(value: &serde_json::Value) -> f64 { + match value { + serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0), + serde_json::Value::String(s) => s.trim().trim_end_matches('%').parse::().unwrap_or(0.0), + _ => 0.0, + } +} + +#[derive(Deserialize)] +pub struct ModelRemains { + pub model_name: String, + #[serde(default)] + pub current_interval_total_percent: serde_json::Value, + #[serde(default)] + pub current_interval_used_percent: serde_json::Value, + #[serde(default)] + pub current_interval_status: i64, + #[serde(default)] + pub current_interval_total_count: f64, + #[serde(default)] + pub current_interval_usage_count: f64, + #[serde(default)] + pub current_weekly_total_percent: serde_json::Value, + #[serde(default)] + pub current_weekly_used_percent: serde_json::Value, + #[serde(default)] + pub current_weekly_status: i64, + #[serde(default)] + pub end_time: Option, + /// 周限额的刷新时间。与 Python 的 `weekly_end_time` 同一字段名。 + #[serde(default)] + pub weekly_end_time: Option, +} + +#[derive(Deserialize)] +pub struct RemainsPayload { + #[serde(default)] + pub model_remains: Vec, +} + +pub fn parse_quotas(payload: &RemainsPayload) -> Vec { + let mut out = Vec::with_capacity(3); + + let general = payload + .model_remains + .iter() + .find(|m| m.model_name == "general") + .or_else(|| payload.model_remains.first()); + + if let Some(g) = general { + let total = { + let t = percent(&g.current_interval_total_percent); + if t > 0.0 { t } else { 100.0 } + }; + out.push(QuotaState { + quota_type: QUOTA_FIVE_HOUR, + total, + used: percent(&g.current_interval_used_percent), + is_unlimited: g.current_interval_status == 3, + reset_time_ms: g.end_time, + }); + out.push(QuotaState { + quota_type: QUOTA_WEEKLY, + total: { + let t = percent(&g.current_weekly_total_percent); + if t > 0.0 { t } else { 100.0 } + }, + used: percent(&g.current_weekly_used_percent), + is_unlimited: g.current_weekly_status == 3, + reset_time_ms: g.weekly_end_time, + }); + } + + // 视频额度:只有非 general 且计数 > 0 的模型 + if let Some(v) = payload + .model_remains + .iter() + .find(|m| m.model_name != "general" && m.current_interval_total_count > 0.0) + { + out.push(QuotaState { + quota_type: QUOTA_VIDEO, + total: v.current_interval_total_count, + used: v.current_interval_usage_count, + is_unlimited: v.current_interval_status == 3, + reset_time_ms: v.end_time, + }); + } + + out +} + +/// 一张可用于扣卡的重置卡。 +#[derive(Debug, Clone)] +pub struct UsableCard { + pub card_id: String, + pub source_type: String, + pub out_biz_no: String, + pub expire_time: i64, +} + +/// 扣卡决策的动作。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + /// 用重置卡重置额度 + UseCard, + /// 不用卡,让积分先消耗 + KeepCredit, + /// 额度充足,不动手 + Idle, + /// 既没积分也没卡,等着 + Wait, +} + +impl Action { + pub fn as_str(&self) -> &'static str { + match self { + Action::UseCard => "use-card", + Action::KeepCredit => "keep-credit", + Action::Idle => "idle", + Action::Wait => "wait", + } + } +} + +pub struct Decision { + pub action: Action, + pub reason: String, +} + +/// `decide(, true)` 需要的判定参数。默认值对应 config.json 缺失时的兜底。 +#[derive(Debug, Clone, Copy)] +pub struct DecisionCfg { + pub card_trigger_percent: f64, + pub tight: f64, + pub low: f64, + /// 距 5 小时额度刷新还剩不到这么多分钟时,**不消耗重置卡**。 + /// + /// 额度马上就要自己刷新了,这时候烧一张重置卡纯属浪费。 + /// 设为 0 表示不做这个保护。 + pub card_skip_before_reset_minutes: f64, +} + +impl Default for DecisionCfg { + fn default() -> Self { + DecisionCfg { + card_trigger_percent: DEFAULT_QUOTA_TRIGGER_PERCENT, + tight: DEFAULT_QUOTA_TIGHT_PERCENT, + low: DEFAULT_QUOTA_LOW_PERCENT, + card_skip_before_reset_minutes: DEFAULT_CARD_SKIP_BEFORE_RESET_MINUTES, + } + } +} + +/// 毫秒时间戳 → "10月25日"。与 Python 的 `_fmt_day` 同一时区口径。 +pub fn fmt_day(ms: Option) -> String { + let Some(ms) = ms else { + return "未知".to_string(); + }; + if ms == 0 { + return "未知".to_string(); + } + // 本地时区;固定 UTC 会导致跨日错位,必须借 localtime_r + let (m, d) = local_md(ms.div_euclid(1000)); + if m == 0 { + return "未知".to_string(); + } + format!("{m}月{d}日") +} + +/// 把 Unix 秒换算成本地时区的 (月, 日)。 +/// +/// 桌面端与 Python 版都用本地时区展示;这里手算避免引入 chrono 依赖。 +/// libc 的 `struct tm`,只需本地时区换算用到的字段。 +#[repr(C)] +#[derive(Default)] +struct Tm { + tm_sec: i32, tm_min: i32, tm_hour: i32, tm_mday: i32, + tm_mon: i32, tm_year: i32, tm_wday: i32, tm_yday: i32, tm_isdst: i32, + tm_gmtoff: i64, tm_zone: *const u8, +} + +#[repr(C)] +struct TimeT(i64); + +unsafe extern "C" { + fn localtime_r(timep: *const TimeT, result: *mut Tm) -> *mut Tm; +} + +/// 把 Unix 秒换算成本地时区结构。 +fn secs_to_local(secs: i64) -> Option { + let t = TimeT(secs); + let mut tm = Tm::default(); + unsafe { + if localtime_r(&t, &mut tm).is_null() { + return None; + } + } + Some(tm) +} + +/// 本地时区的 (月, 日),供日志使用。 +pub fn local_md(secs: i64) -> (u32, u32) { + secs_to_local(secs) + .map(|t| (t.tm_mon as u32 + 1, t.tm_mday as u32)) + .unwrap_or((0, 0)) +} + +/// 本地时区的 (时, 分)。 +pub fn local_hm(secs: i64) -> (u32, u32) { + secs_to_local(secs) + .map(|t| (t.tm_hour as u32, t.tm_min as u32)) + .unwrap_or((0, 0)) +} + +/// 5 小时额度距离刷新还有多少分钟。取不到刷新时间时返回 `None`。 +/// +/// 负数表示刷新时间已经过去了(接口数据没刷新),按 0 处理。 +pub fn minutes_to_reset(q: Option<&QuotaState>, now_ms: i64) -> Option { + let reset = q?.reset_time_ms?; + Some(((reset - now_ms) as f64 / 60_000.0).max(0.0)) +} + +/// 后台扣卡决策(纯函数,不触碰网络)。 +/// +/// 动作 ∈ {use-card, keep-credit, idle, wait}。 +/// +/// 分支与 Mini App 的 `decide(, true)` 刻意同构: +/// 未耗尽 → idle +/// 耗尽 + 无积分 + 有卡 → use-card +/// 耗尽 + 无积分 + 无卡 → wait +/// 耗尽 + 有积分 + 无卡 → keep-credit +/// 耗尽 + 都有,卡先过期 → use-card +/// 耗尽 + 都有,积分先过期 → keep-credit +/// +/// `credit_data_complete` 表示「积分数据是否**完整读到**」。 +/// +/// ⚠️ 2026-10-19 PR #28 review 修:读不到 ≠ 没有。 +/// `credit_expiry_ms = None` 同时可能表示「确实没有积分」与「接口读失败」, +/// 以前两者都落到 `use-card` 分支 —— 而扣卡是**真实写操作**, +/// 于是网络故障会被当成业务状态,可能误扣真卡。 +/// ⇒ 数据不完整时一律**不做写操作**(`Action::Wait`),只如实报出来。 +pub fn decide( + quotas: &[QuotaState], + usable_cards: &[UsableCard], + credit_expiry_ms: Option, + credit_desc: &str, + cfg: &DecisionCfg, + now_ms: i64, + credit_data_complete: bool, +) -> Decision { + let five = quotas.iter().find(|q| q.quota_type == QUOTA_FIVE_HOUR); + let weekly = quotas.iter().find(|q| q.quota_type == QUOTA_WEEKLY); + + let five_triggered = five.is_some_and(|q| q.triggered(cfg.card_trigger_percent)); + let weekly_triggered = weekly.is_some_and(|q| q.triggered(cfg.card_trigger_percent)); + + // ── 额度即将自己刷新 → 不烧重置卡 ── + // 距刷新只剩几分钟的时候再扣一张重置卡是纯浪费:额度马上就自己回来了。 + // 这个保护要在所有「用卡」分支之前判定。 + let reset_in_min = minutes_to_reset(five, now_ms); + let skip_card_reason = if cfg.card_skip_before_reset_minutes > 0.0 { + reset_in_min.filter(|m| *m <= cfg.card_skip_before_reset_minutes).map(|m| { + format!("5 小时额度 {:.0} 分钟后刷新,暂不消耗重置卡", m) + }) + } else { + None + }; + + // ── 未到触发线 ── + if !five_triggered && !weekly_triggered { + return Decision { + action: Action::Idle, + // 等级词随分档走,而不是永远写「额度充足」—— + // 否则用量 92% 时页面上仍显示「额度充足」,是误导。 + // 把触发线也说进来:否则「额度不足但暂不需要处理」看起来自相矛盾。 + // + // burned 永远不会出现在这里:它必然已 triggered,走的是下面用卡/留卡分支。 + reason: format!( + "{}(5 小时已用 {:.0}%),未到 {:.0}% 触发线,暂不动用重置卡", + five + .map(|q| q.level(cfg.tight, cfg.low).label()) + .unwrap_or(Level::Comfortable.label()), + five.map(|q| q.used).unwrap_or(0.0), + cfg.card_trigger_percent + ), + }; + } + + // ── 积分数据没读全 ⇒ 无法判断「卡先过期还是积分先过期」── + // ⇒ 绝不做写操作(扣卡是真实消耗,宁可等下一轮)。 + if !credit_data_complete { + return Decision { + action: Action::Wait, + reason: "积分数据读取不完整,本轮不执行写操作(等下一轮重新判定)".to_string(), + }; + } + + // ── 无可用积分:只能靠卡 ── + let Some(credit_expiry) = credit_expiry_ms else { + if !usable_cards.is_empty() { + // 额度马上要自己刷新了,就别烧卡了 + if let Some(reason) = &skip_card_reason { + return Decision { + action: Action::Wait, + reason: reason.clone(), + }; + } + return Decision { + action: Action::UseCard, + reason: "没有可用积分,使用重置卡重置额度".to_string(), + }; + } + return Decision { + action: Action::Wait, + reason: "额度已耗尽,既没有积分也没有可用的重置卡".to_string(), + }; + }; + // ── 有积分但没卡:让积分先消耗 ── + // 注意这一句带「到期」二字,与 Mini App / Python 的文案保持逐字一致。 + // 改文案前务必跑 `node verify-parity.mjs`。 + if usable_cards.is_empty() { + return Decision { + action: Action::KeepCredit, + reason: format!( + "没有可用的重置卡,让{credit_desc}({}到期)先消耗", + fmt_day(Some(credit_expiry)) + ), + }; + } + + let card_expiry = usable_cards[0].expire_time; + + // ── 两者都有:比「先过期先用」 ── + if card_expiry < credit_expiry { + // 额度马上要自己刷新了,就别烧卡了 + if let Some(reason) = &skip_card_reason { + return Decision { + action: Action::Wait, + reason: reason.clone(), + }; + } + return Decision { + action: Action::UseCard, + reason: format!( + "重置卡({})比{credit_desc}({})先过期,先用卡保住积分", + fmt_day(Some(card_expiry)), + fmt_day(Some(credit_expiry)) + ), + }; + } + Decision { + action: Action::KeepCredit, + reason: format!( + "{credit_desc}({})比重置卡({})先过期,不用卡,让积分先消耗", + fmt_day(Some(credit_expiry)), + fmt_day(Some(card_expiry)) + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn q(t: i64, total: f64, used: f64, unlimited: bool) -> QuotaState { + QuotaState { quota_type: t, total, used, is_unlimited: unlimited, reset_time_ms: None } + } + + /// 判定用的固定「当前时间」。所有与刷新时间相关的用例都以它为基准。 + const NOW: i64 = 1_700_000_000_000; + + fn c(days: i64) -> UsableCard { + UsableCard { + card_id: "C1".into(), + source_type: "mcode_signin".into(), + out_biz_no: "b1".into(), + expire_time: 1_700_000_000_000 + days * 86_400_000, + } + } + + #[test] + fn percent_strings() { + assert_eq!(percent(&serde_json::json!("71%")), 71.0); + assert_eq!(percent(&serde_json::json!("100%")), 100.0); + assert_eq!(percent(&serde_json::json!(0)), 0.0); + assert_eq!(percent(&serde_json::json!("")), 0.0); + } + + #[test] + fn amount_strings() { + assert_eq!(amount(&serde_json::json!("17400.00")), 17400.0); + assert_eq!(amount(&serde_json::json!("1,200.50")), 1200.5); + } + + #[test] + fn skip_card_when_reset_is_imminent() { + // 额度已经到触发线,本来会走「用卡」分支。 + let mut quotas = vec![q(1, 100.0, 99.0, false), q(2, 100.0, 0.0, true)]; + let credit = Some(NOW + 13 * 86_400_000); + let cfg = DecisionCfg::default(); // 默认跳过窗口 60 分钟 + + // 刷新在 2 小时后 → 照常用卡 + quotas[0].reset_time_ms = Some(NOW + 120 * 60_000); + assert_eq!(decide("as, &[c(7)], credit, "平台积分", &cfg, NOW, true).action, Action::UseCard); + + // 刷新在 10 分钟后 → 不烧卡,等它自己刷新 + quotas[0].reset_time_ms = Some(NOW + 10 * 60_000); + let d = decide("as, &[c(7)], credit, "平台积分", &cfg, NOW, true); + assert_eq!(d.action, Action::Wait, "额度马上要刷新,不该烧重置卡"); + assert!(d.reason.contains("分钟后刷新"), "理由要说清为什么不动: {}", d.reason); + + // 窗口设为 0 → 关闭该保护,照常烧卡 + let no_skip = DecisionCfg { card_skip_before_reset_minutes: 0.0, ..Default::default() }; + assert_eq!(decide("as, &[c(7)], credit, "平台积分", &no_skip, NOW, true).action, Action::UseCard); + + // 窗口放大到 180 分钟 → 10 分钟后刷新也跳过 + let wide = DecisionCfg { card_skip_before_reset_minutes: 180.0, ..Default::default() }; + assert_eq!(decide("as, &[c(7)], credit, "平台积分", &wide, NOW, true).action, Action::Wait); + } + + #[test] + fn minutes_to_reset_is_optional_and_clamped() { + assert_eq!(minutes_to_reset(None, NOW), None); + assert_eq!(minutes_to_reset(Some(&q(1, 100.0, 50.0, false)), NOW), None, "没有刷新时间就无从判断"); + let mut x = q(1, 100.0, 50.0, false); + x.reset_time_ms = Some(NOW + 30 * 60_000); + assert_eq!(minutes_to_reset(Some(&x), NOW), Some(30.0)); + x.reset_time_ms = Some(NOW - 60_000); + assert_eq!(minutes_to_reset(Some(&x), NOW), Some(0.0), "已过去的时间按 0 算"); + } + + #[test] + fn exhausted_rules() { + assert!(!q(1, 100.0, 99.0, false).exhausted()); + assert!(q(1, 100.0, 100.0, false).exhausted()); + assert!(!q(1, 100.0, 100.0, true).exhausted(), "不限量不算耗尽"); + assert!(!q(1, 0.0, 0.0, false).exhausted(), "总量为 0 不算耗尽"); + } + + #[test] + fn decide_branches() { + let cfg = DecisionCfg::default(); + let five_ok = vec![q(1, 100.0, 50.0, false), q(2, 100.0, 0.0, true)]; + let five_dead = vec![q(1, 100.0, 100.0, false), q(2, 100.0, 0.0, true)]; + let week_dead = vec![q(1, 100.0, 50.0, false), q(2, 100.0, 100.0, false)]; + let credit = Some(1_700_000_000_000 + 13 * 86_400_000); + + assert_eq!(decide(&five_ok, &[c(7)], credit, "平台积分", &cfg, NOW, true).action, Action::Idle); + assert_eq!(decide(&five_dead, &[c(7)], credit, "平台积分", &cfg, NOW, true).action, Action::UseCard); + assert_eq!(decide(&five_dead, &[], credit, "平台积分", &cfg, NOW, true).action, Action::KeepCredit); + assert_eq!(decide(&five_dead, &[], None, "", &cfg, NOW, true).action, Action::Wait); + assert_eq!(decide(&five_dead, &[c(7)], None, "", &cfg, NOW, true).action, Action::UseCard); + assert_eq!(decide(&week_dead, &[c(7)], credit, "平台积分", &cfg, NOW, true).action, Action::UseCard); + // 积分更早过期 → 不用卡 + let credit_soon = Some(1_700_000_000_000 + 2 * 86_400_000); + assert_eq!( + decide(&five_dead, &[c(20)], credit_soon, "平台积分", &cfg, NOW, true).action, + Action::KeepCredit + ); + } + + #[test] + fn threshold_is_configurable() { + // 同一份输入,换阈值就换结论 —— 这正是功能1的可配置性。 + // 积分早于卡过期,所以一旦越过触发线,结论必然是 keep-credit(不用卡)。 + let quotas = vec![q(1, 100.0, 95.0, false), q(2, 100.0, 0.0, true)]; + let credit_soon = Some(1_700_000_000_000); + let strict = DecisionCfg { card_trigger_percent: 99.0, ..Default::default() }; + let loose = DecisionCfg { card_trigger_percent: 90.0, ..Default::default() }; + assert_eq!( + decide("as, &[c(7)], credit_soon, "平台积分", &strict, NOW, true).action, + Action::Idle, + "95% 未到 99% 触发线,应保持 idle" + ); + assert_eq!( + decide("as, &[c(7)], credit_soon, "平台积分", &loose, NOW, true).action, + Action::KeepCredit, + "阈值放宽到 90% 后应越过触发线,进入先过期先用判定" + ); + } + + #[test] + fn quota_level_boundaries() { + let l = quota_level; + assert_eq!(l(79.0, 100.0, false, 80.0, 95.0), Level::Comfortable); + assert_eq!(l(80.0, 100.0, false, 80.0, 95.0), Level::Tight); + assert_eq!(l(94.9, 100.0, false, 80.0, 95.0), Level::Tight); + assert_eq!(l(95.0, 100.0, false, 80.0, 95.0), Level::Low); + assert_eq!(l(99.0, 100.0, false, 80.0, 95.0), Level::Low); + // 燃尽必须先判,不能被 low 抢先命中 + assert_eq!(l(100.0, 100.0, false, 80.0, 95.0), Level::Burned); + assert_eq!(l(120.0, 100.0, false, 80.0, 95.0), Level::Burned); + // 不限量 / 总量为 0 → 恒为充足 + assert_eq!(l(99.0, 100.0, true, 80.0, 95.0), Level::Comfortable); + assert_eq!(l(0.0, 0.0, false, 80.0, 95.0), Level::Comfortable); + // 分界点可配 + assert_eq!(l(70.0, 100.0, false, 60.0, 90.0), Level::Tight); + } + + #[test] + fn idle_reason_follows_level() { + let cfg = DecisionCfg::default(); + // 92% 不能再写「额度充足」——那是误导 + let d92 = decide(&vec![q(1, 100.0, 92.0, false), q(2, 100.0, 0.0, true)], &[], Some(1), "平台积分", &cfg, NOW, true); + assert_eq!(d92.action, Action::Idle); + assert!(d92.reason.starts_with("额度紧张"), "{}", d92.reason); + assert!(d92.reason.contains("92%"), "{}", d92.reason); + + let d12 = decide(&vec![q(1, 100.0, 12.0, false), q(2, 100.0, 0.0, true)], &[], Some(1), "平台积分", &cfg, NOW, true); + assert!(d12.reason.starts_with("额度充足"), "{}", d12.reason); + + let d97 = decide(&vec![q(1, 100.0, 97.0, false), q(2, 100.0, 0.0, true)], &[], Some(1), "平台积分", &cfg, NOW, true); + assert!(d97.reason.starts_with("额度不足"), "{}", d97.reason); + } + + #[test] + fn burned_never_appears_in_idle_reason() { + // burned 必然已 triggered,走用卡/留卡分支,不该出现在 idle 理由里 + let cfg = DecisionCfg { card_trigger_percent: 100.0, ..Default::default() }; + let d = decide(&vec![q(1, 100.0, 100.0, false), q(2, 100.0, 0.0, true)], &[c(7)], Some(1_700_000_000_000), "平台积分", &cfg, NOW, true); + assert_ne!(d.action, Action::Idle); + assert!(!d.reason.contains("额度燃尽"), "{}", d.reason); + } + + #[test] + fn reasons_are_filled() { + let cfg = DecisionCfg::default(); + let five_dead = vec![q(1, 100.0, 100.0, false), q(2, 100.0, 0.0, true)]; + let d = decide(&five_dead, &[], Some(1_700_000_000_000 + 13 * 86_400_000), "平台积分", &cfg, NOW, true); + assert!(d.reason.contains("没有可用的重置卡"), "{}", d.reason); + assert!(d.reason.contains("平台积分"), "{}", d.reason); + } + + /// 回归测试:积分数据读取失败时**绝不扣卡**。 + /// + /// ⚠️ 2026-10-19 PR #28 review 修:读不到 ≠ 没有。 + /// `credit_expiry_ms = None` 同时可能表示「确实没有积分」与「接口读失败」, + /// 以前两者都落到 `use-card` —— 而扣卡是真实写操作, + /// 网络故障会被当成业务状态,可能误扣真卡。 + #[test] + fn incomplete_credit_data_never_spends_a_card() { + let cfg = DecisionCfg::default(); + // 额度已耗尽,有一张卡,但积分数据**没读全** + let quotas = vec![q(1, 100.0, 100.0, false)]; + let cards = vec![c(7)]; + let d = decide("as, &cards, None, "", &cfg, NOW, false); + assert_eq!( + d.action, + Action::Wait, + "积分数据不完整时必须 wait,不能用卡" + ); + assert!( + d.reason.contains("读取不完整"), + "理由要如实说明数据不完整: {}", + d.reason + ); + + // 对照:同样条件下,数据完整 ⇒ 确实没有积分 ⇒ 可以用卡 + let d2 = decide("as, &cards, None, "", &cfg, NOW, true); + assert_eq!( + d2.action, + Action::UseCard, + "数据完整且确实无积分时,用卡是正确的" + ); + } +} diff --git a/plugins/hylouis233/expiry-guardian/daemon/rust/src/goal_sync.rs b/plugins/hylouis233/expiry-guardian/daemon/rust/src/goal_sync.rs new file mode 100644 index 0000000..b1a3826 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/rust/src/goal_sync.rs @@ -0,0 +1,1290 @@ +//! Goal 自动暂停/恢复(功能2)。 +//! +//! # 为什么要直写数据库 +//! +//! Goal 的官方接口 `/minimax-desktop/api/v1/session/:id/goal` 只在 **Electron +//! 主进程的自定义协议**里可达,没有 TCP 端口;v2 前门的 goal controller 是 +//! 五个 `NotImplementedError` 桩。独立进程(本守护进程、Mini App 的 Node +//! 进程)都调不到。 +//! +//! 这条路可行,是因为已验证两件事: +//! 1. `SqliteThreadGoalStore` 每次都重新 SELECT,没有内存缓存; +//! 2. `orchestrator` 在每个回合边界后重读 Goal +//! ("Read the durable goal state AFTER each turn"), +//! 所以暂停会在**下一个回合边界**生效,不会打断正在跑的那一回合。 +//! +//! # 已知降级(必须如实告知用户) +//! +//! - 不调用 `abortVerification`:正在跑的回合会自然跑完; +//! - 不发 `thread_goal` 事件:已打开的面板不会实时刷新。 +//! +//! # 所有权 +//! +//! 官方 UI 的手动暂停与我们写的暂停**共用** `paused(user_requested)` +//! (`lifecycle.ts` 里写死),单看 `status_reason` 分不出是谁停的。 +//! 因此另有一份账本 `goal-pause.json` 记录「哪些是我们停的」, +//! 恢复时只动账本内的目标 —— 否则会把用户手动暂停的目标自动打开。 +//! +//! # 不加依赖 +//! +//! 调系统自带的 `/usr/bin/sqlite3`,不引入 rusqlite: +//! 那个 crate(尤其 `bundled`)要编 C,与本工程 +//! `opt-level="z" + panic="abort" + strip` 的取向相悖。 + +use std::fs; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::config::Config; +use crate::log; + +/// 必须用绝对路径:launchd 的 `PATH` 极简。 +pub const SQLITE_BIN: &str = "/usr/bin/sqlite3"; + +/// 与 `lifecycle.ts` 里 `pauseGoalByUser` 写死的值一致,不要自创。 +pub const REASON_USER_PAUSED: &str = "paused(user_requested)"; + +/// goal_id 格式校验。白名单之外的一律丢弃 —— 账本是我们自己写的, +/// 但它可能被人手改过,不能因为一个坏 id 就让后续 SQL 变成注入面。 +fn valid_goal_id(id: &str) -> bool { + let b = id.as_bytes(); + if id.len() < 11 || id.len() > 67 || !id.starts_with("tg_") { + return false; + } + b[3..].iter().all(|c| c.is_ascii_alphanumeric()) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LedgerEntry { + pub goal_id: String, + pub session_id: String, + pub paused_at_ms: i64, + pub trigger_used_percent: f64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Ledger { + #[serde(default)] + pub paused: Vec, +} + +fn state_dir() -> Option { + let home = std::env::var("HOME").ok()?; + Some(PathBuf::from(home).join(".config/mcode-reset-card")) +} + +pub fn ledger_path() -> Option { + Some(state_dir()?.join("goal-pause.json")) +} + +/// 目标数据库。优先 `MAVIS_GOAL_DB`,其次默认路径;找不到就禁用该功能,**绝不猜路径乱写**。 +pub fn db_path() -> Option { + if let Ok(p) = std::env::var("MAVIS_GOAL_DB") { + let pb = PathBuf::from(p); + if pb.exists() { + return Some(pb); + } + } + let home = std::env::var("HOME").ok()?; + let p = PathBuf::from(home).join(".minimax/v2/sqlite/runtime-state.sqlite"); + if p.exists() { + Some(p) + } else { + None + } +} + +pub fn read_ledger() -> Ledger { + let Some(p) = ledger_path() else { return Ledger::default() }; + let Ok(text) = fs::read_to_string(&p) else { return Ledger::default() }; + let Ok(mut l) = serde_json::from_str::(&text) else { return Ledger::default() }; + l.paused.retain(|e| valid_goal_id(&e.goal_id)); + l +} + +/// 原子写:写 tmp 再 rename。同目录 rename 在 POSIX 上是原子的。 +pub fn write_ledger(l: &Ledger) -> Result<(), String> { + let Some(p) = ledger_path() else { return Err("找不到 $HOME".into()) }; + if let Some(dir) = p.parent() { + let _ = fs::create_dir_all(dir); + } + let tmp = p.with_extension("json.tmp"); + let text = serde_json::to_string_pretty(l).map_err(|e| e.to_string())?; + fs::write(&tmp, format!("{text}\n")).map_err(|e| e.to_string())?; + fs::rename(&tmp, &p).map_err(|e| e.to_string()) +} + +/// 执行一段 SQL(经 stdin),返回 stdout。 +pub fn exec_sql(db: &PathBuf, sql: &str) -> Result { + use std::io::Write; + let mut child = Command::new(SQLITE_BIN) + .arg("-json") + .arg(db) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("无法启动 {SQLITE_BIN}:{e}"))?; + child + .stdin + .as_mut() + .ok_or("stdin 不可用")? + .write_all(sql.as_bytes()) + .map_err(|e| e.to_string())?; + let out = child.wait_with_output().map_err(|e| e.to_string())?; + if !out.status.success() { + let err = String::from_utf8_lossy(&out.stderr); + return Err(err.trim().lines().next().unwrap_or("sqlite3 执行失败").to_string()); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +fn sqlite(db: &PathBuf, sql: &str) -> Result { + exec_sql(db, sql) +} + +/// 从 stdout 里取**最后一个**顶层 JSON 文档。 +/// +/// sqlite3 的 `-json` 模式会给每条「有结果集」的语句各输出一个 JSON 文档。 +/// 所以只要脚本里混进一条会回显的语句(`PRAGMA busy_timeout=3000` 就是典型, +/// 它会输出 `[{"timeout":3000}]`),整段 stdout 就不再是**单个** JSON, +/// 直接 `from_str` 必然报 `trailing characters`。 +/// +/// 这个 bug 的后果比解析失败严重得多:那条 SQL 里的 UPDATE **已经 COMMIT 成功了** +/// (目标确实被停了),只是返回行解析炸了 → 账本没写 → 下一个 tick 无从恢复 +/// → **目标被停住且再也回不来**。所以这里取最后一个文档, +/// 让脚本里能不能混语句不再是一个"一不小心就单向门"的地雷。 +fn last_json_document(out: &str) -> Result { + let trimmed = out.trim(); + if trimmed.is_empty() { + return Ok(json!([])); + } + let mut last: Option = None; + let (mut depth, mut start) = (0usize, 0usize); + let (mut in_str, mut esc) = (false, false); + for (i, ch) in trimmed.char_indices() { + if in_str { + if esc { + esc = false; + } else if ch == '\\' { + esc = true; + } else if ch == '"' { + in_str = false; + } + continue; + } + match ch { + '"' => in_str = true, + '[' | '{' => { + if depth == 0 { + start = i; + } + depth += 1; + } + ']' | '}' => { + if depth > 0 { + depth -= 1; + if depth == 0 { + last = serde_json::from_str(&trimmed[start..=i]).ok(); + } + } + } + _ => {} + } + } + last.ok_or_else(|| "sqlite3 输出里找不到可解析的 JSON 文档".to_string()) +} + +/// 把目标表里所有 active 的行暂停,并返回**我们改到的**那些行。 +/// +/// 用临时表先快照改前状态,避免「改完再查」把用户早就暂停的也算进来。 +pub fn pause_active_goals(db: &PathBuf, now_ms: i64) -> Result, String> { + let sql = format!( + ".timeout 3000\n\ + BEGIN IMMEDIATE;\n\ + CREATE TEMP TABLE _newly AS\n\ + \x20 SELECT goal_id, session_id FROM local_runtime_thread_goals WHERE status='active';\n\ + UPDATE local_runtime_thread_goals\n\ + \x20 SET status='paused', status_reason='{REASON_USER_PAUSED}', updated_at_ms={now_ms}\n\ + \x20 WHERE status='active';\n\ + SELECT goal_id, session_id FROM _newly;\n\ + COMMIT;\n" + ); + let out = sqlite(db, &sql)?; + let rows: serde_json::Value = last_json_document(&out)?; + Ok(rows + .as_array() + .map(|a| { + a.iter() + .filter_map(|r| { + let g = r.get("goal_id")?.as_str()?.to_string(); + let s = r.get("session_id")?.as_str()?.to_string(); + if !valid_goal_id(&g) { + return None; + } + Some(LedgerEntry { + goal_id: g, + session_id: s, + paused_at_ms: now_ms, + trigger_used_percent: 0.0, + }) + }) + .collect() + }) + .unwrap_or_default()) +} + +/// 恢复单个目标。 +/// +/// `reply_fingerprint` 必须一并置 NULL —— 复刻 `store-patch.ts` 里 +/// `resumed` 的副作用(用于无进展检测)。 +pub fn resume_goal(db: &PathBuf, goal_id: &str, now_ms: i64) -> Result<(), String> { + if !valid_goal_id(goal_id) { + return Err(format!("goal_id 格式不合法,已拒绝:{goal_id}")); + } + // ── 先确认目标还在、且确实处于 paused ── + // + // ⚠️ 这里有两个坑,叠在一起会**静默地让恢复永远失效**: + // + // 1) `UPDATE ... WHERE goal_id='{goal_id}'`(Rust 的 `format!` 不替换 + // 花括号占位符 —— `{goal_id}` 是字面量!)⇒ WHERE 条件恒不成立, + // 影响 0 行,但 sqlite3 照样退出码 0 ⇒ `resume_goal` 返回 Ok(()) + // ⇒ 调用方以为「恢复成功」,实际目标一直停在 paused。 + // 实测 2026-10-19 19:46 用真实库复现:UPDATE 前后 status 都是 paused。 + // + // 2) 即便把 id 拼对了,目标若已被官方 DELETE 掉(实测 19:42:41 + // MCode 界面层发了 `DELETE /minimax-desktop/api/v1/session//goal`, + // 行被硬删除),UPDATE 同样影响 0 行 ⇒ 又一次假成功。 + // + // ⇒ 所以先 SELECT 校验,再 UPDATE;两步都要求「确实改到了行」。 + let gid = goal_id.replace('\'', "''"); + let check = format!("SELECT status FROM local_runtime_thread_goals WHERE goal_id='{gid}';"); + let cur = sqlite(db, &check)?; + let cur_rows = last_json_document(&cur)?; + let Some(row) = cur_rows.as_array().and_then(|a| a.first()) else { + return Err(format!("目标 {goal_id} 已不存在(可能已被官方删除),跳过恢复")); + }; + let cur_status = row.get("status").and_then(|v| v.as_str()).unwrap_or(""); + if cur_status != "paused" { + return Err(format!( + "目标 {goal_id} 当前状态是 {cur_status}(非 paused),跳过恢复" + )); + } + let sql = format!( + ".timeout 3000\n\ + BEGIN IMMEDIATE;\n\ + UPDATE local_runtime_thread_goals\n\ + \x20 SET status='active', status_reason=NULL, reply_fingerprint=NULL, updated_at_ms={now_ms}\n\ + \x20 WHERE goal_id='{gid}' AND status='paused';\n\ + COMMIT;\n" + ); + sqlite(db, &sql)?; + // 再查一次,确认真的改成 active 了(UPDATE 影响 0 行时 sqlite3 仍返回 0) + let after = format!("SELECT status FROM local_runtime_thread_goals WHERE goal_id='{gid}';"); + let aout = sqlite(db, &after)?; + let arows = last_json_document(&aout)?; + let new_status = arows + .as_array() + .and_then(|a| a.first()) + .and_then(|o| o.get("status")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + if new_status != "active" { + return Err(format!( + "目标 {goal_id} 恢复未生效(当前仍是 {new_status})" + )); + } + Ok(()) +} + +/// 账本里那些已经被用户处理掉(不再 paused)的,直接清掉,不再干预。 +/// +/// 返回被丢掉的条目数,供日志如实告知用户。 +fn prune_ledger(db: &PathBuf, ledger: &mut Ledger) -> Result { + if ledger.paused.is_empty() { + return Ok(0); + } + let ids: Vec = ledger + .paused + .iter() + .filter(|e| valid_goal_id(&e.goal_id)) + .map(|e| format!("'{}'", e.goal_id)) + .collect(); + if ids.is_empty() { + let dropped = ledger.paused.len(); + ledger.paused.clear(); + return Ok(dropped); + } + let sql = format!( + "PRAGMA busy_timeout=3000;\n\ + SELECT goal_id, status FROM local_runtime_thread_goals WHERE goal_id IN ({});\n", + ids.join(",") + ); + let out = sqlite(db, &sql)?; + let rows: serde_json::Value = last_json_document(&out)?; + let mut still_paused: Vec = Vec::new(); + // 状态不是 paused 的:逐个判断是「用户手动处理」还是「运行时回弹」。 + // 只有前者才剔除;后者必须留在账本里(我们仍然拥有它,恢复时要放回去)。 + let mut dropped = 0usize; + for r in rows.as_array().cloned().unwrap_or_default() { + let Some(g) = r.get("goal_id").and_then(|s| s.as_str()) else { continue }; + let status = r.get("status").and_then(|s| s.as_str()).unwrap_or(""); + if status == "paused" { + still_paused.push(g.to_string()); + continue; + } + // 找出这个目标在账本里的暂停时刻 + let paused_at = ledger + .paused + .iter() + .find(|e| e.goal_id == g) + .map(|e| e.paused_at_ms) + .unwrap_or(0); + if is_user_handled(db, g, paused_at) { + dropped += 1; + } else { + // 运行时回弹:仍算我们停的,保留在账本里 + still_paused.push(g.to_string()); + } + } + let before = ledger.paused.len(); + ledger.paused.retain(|e| still_paused.contains(&e.goal_id)); + Ok(dropped.max(before - ledger.paused.len())) +} + +/// 读一个目标的当前 status;目标不存在时返回 `None`。 +/// +/// 用于「恢复后回读校验」—— 判据必须基于**回读**, +/// 不能只信「UPDATE 执行成功」(影响 0 行时 sqlite3 仍返回 0)。 +fn goal_status(db: &PathBuf, goal_id: &str) -> Option { + let sql = format!( + "SELECT status FROM local_runtime_thread_goals WHERE goal_id='{gid}';", + gid = goal_id.replace('\'', "''"), + ); + let out = sqlite(db, &sql).ok()?; + let rows = last_json_document(&out).ok()?; + rows.as_array()? + .first()? + .get("status")? + .as_str() + .map(|s| s.to_string()) +} + +/// 判定「账本里的目标是不是**用户**手动处理掉了」。 +/// +/// # 为什么需要单独判 +/// +/// `prune_ledger` 只看 `status=='paused'`:不在这个状态就说明目标被人动过了 +/// (改回 active、或完成),于是从账本剔除、不再干预。 +/// +/// 但 2026-10-19 实测发现一个**假阳性**: +/// 回合被额度墙打死时,运行时会在回合结束时把目标从 `paused` 改回 `active` +/// (实测量化目标 18:59:03 被暂停 → 19:12:55 那个跑了 13 分钟的回合结束 +/// → 19:13:04 目标变回 `active`)。于是 `prune_ledger` 把它当「用户手动处理」 +/// 剔出账本,紧接着 `pause_active_goals` 又把它停回来 —— 账本被清空又重建, +/// `trigger_used_percent` 从 99 被覆盖成 100,且日志误报「已被手动处理」。 +/// +/// # 判据 +/// +/// 目标处于 `active` **且**最近一次变更发生在**我们暂停它之后** ⇒ 是运行时的 +/// 回弹,不是用户操作。此时**保留在账本里**(我们仍然拥有它,恢复时要放回去), +/// 但**不**记为「用户手动处理」。 +/// +/// 目标处于 `complete`/`complete(verifier_met)` ⇒ 真的完成了,剔除。 +fn is_user_handled(db: &PathBuf, goal_id: &str, paused_at_ms: i64) -> bool { + // 1) 目标已完成 ⇒ 真的处理掉了 + let sql = format!( + "SELECT status, session_id FROM local_runtime_thread_goals WHERE goal_id = '{gid}';", + gid = goal_id.replace('\'', "''"), + ); + let Ok(out) = sqlite(db, &sql) else { return false }; + let Ok(rows) = last_json_document(&out) else { return false }; + let Some(row) = rows.as_array().and_then(|a| a.first()) else { + // 目标已经被删了(官方 DELETE 掉了)⇒ 账本里的这条已经没有意义 + return true; + }; + let status = row.get("status").and_then(|v| v.as_str()).unwrap_or(""); + if status.starts_with("complete") { + return true; + } + let session_id = row.get("session_id").and_then(|v| v.as_str()).unwrap_or(""); + if !valid_session_id(session_id) { + // 拿不到会话就没法判断回弹 ⇒ 保守起见当作「非用户处理」,保留在账本里 + return false; + } + + // 2) 我们暂停之后是否还有回合结束过?⇒ 运行时回弹,不是用户操作 + // + // ⚠️ 第一版用 `goal.updated_at_ms > paused_at_ms` 判,**恒为 false**: + // `pause_active_goals` 自己在暂停时就把 `updated_at_ms` 改写成 `now_ms`, + // 所以对账时读到的永远是「我们暂停的那一刻」⇒ 回弹永远被误判成 + // 「用户手动处理」。实测 19:19:388 又误报一次,账本 paused_at_ms 被改成 + // 19:19:15、trigger 被覆盖成 100.0。 + // ⇒ 改用**回合结束时刻**做判据:这是我们改不动的第三方事实。 + let rebound = format!( + "SELECT COUNT(*) AS c FROM local_runtime_turn_ingress\n\ + WHERE session_id = '{sid}' AND status = 'completed'\n\ + AND completed_at_ms > {paused};", + sid = session_id.replace('\'', "''"), + paused = paused_at_ms, + ); + let Ok(rout) = sqlite(db, &rebound) else { return false }; + let Ok(rrows) = last_json_document(&rout) else { return false }; + let c = rrows + .as_array() + .and_then(|a| a.first()) + .and_then(|o| o.get("c")) + .and_then(|v| v.as_i64()) + .unwrap_or(0); + if c > 0 { + // 有回合在我们暂停之后结束过 ⇒ 运行时在回合边界把它改回 active + // ⇒ 是**回弹**,不是用户手动处理 ⇒ 保留在账本里 + return false; + } + + // 3) 没有任何回合在暂停后结束过 ⇒ 状态变化只可能来自用户 + true +} + +/// 恢复判定:额度是否已经「回来了」。 +/// +/// 判据是**用量回落到触发线以下**,而不是字面上的「用量正好等于 0%」。 +/// +/// 为什么:守护进程每 60 秒才轮询一次,额度刷新后的那个 0% 瞬间**通常根本观测不到** +/// (实测:重置卡在 14:35:41 把额度打回 0%,下一轮 14:36:20 读到的是 1%)。 +/// 若严格按 `used <= 0` 判定,目标会被**永久停住** —— 这个故障真实发生过。 +/// +/// 额度只有在「用掉重置卡」或「5 小时窗口翻页」时才会掉下来, +/// 掉下来就说明额度已经回来了。`resume_at_percent` 作为更严格的附加条件保留。 +pub fn should_resume(used_percent: f64, trigger_percent: f64, resume_at_percent: f64) -> bool { + used_percent < trigger_percent || used_percent <= resume_at_percent +} + +#[derive(Debug, Default)] +pub struct SyncReport { + pub paused: usize, + pub resumed: usize, + pub message: String, +} + +/// 一轮 Goal 同步。顺序与扣卡相反:**先让目标停下,再决定要不要补额度**。 +pub fn sync_with_quota( + cfg: &Config, + five_used_percent: f64, + five_total_percent: f64, + five_unlimited: bool, +) -> SyncReport { + let gp = cfg.goal_pause; + if !gp.enabled { + return SyncReport { message: "Goal 自动暂停未开启".into(), ..Default::default() }; + } + let Some(db) = db_path() else { + return SyncReport { + message: "找不到 Goal 数据库,功能已禁用".into(), + ..Default::default() + }; + }; + let now_ms = crate::api::now_ms(); + let mut ledger = read_ledger(); + + // 不限量:额度不可能是它还停着的原因,把账本里的都放回去。 + if five_unlimited { + let mut n = 0usize; + let mut woke = 0usize; + let mut still_stuck: Vec = Vec::new(); + for e in &ledger.paused { + if resume_goal(&db, &e.goal_id, now_ms).is_ok() { + n += 1; + // 同样要唤醒:只改状态的话目标会停在「active 但活没干」的死态 + match wake_goal(&db, &e.goal_id, five_used_percent) { + Ok(true) => woke += 1, + Ok(false) => {} + Err(err) => { + log(&format!("🔴 唤醒 {} 失败: {err}", e.goal_id)); + still_stuck.push(e.clone()); + } + } + } + } + if !still_stuck.is_empty() { + if let Err(e) = write_ledger(&Ledger { paused: still_stuck }) { + log(&format!("⚠️ 账本写回失败: {e}")); + } + } else if !ledger.paused.is_empty() { + if let Err(e) = write_ledger(&Ledger { paused: Vec::new() }) { + log(&format!("⚠️ 账本清空失败: {e}")); + } + } + return SyncReport { + paused: 0, + resumed: n, + message: format!("不限量额度,已恢复 {n} 个目标(唤醒 {woke} 个)"), + }; + } + + // 先对账:用户已经自己处理掉的,不再干预。 + match prune_ledger(&db, &mut ledger) { + Ok(0) => {} + Ok(n) => { + log(&format!("账本对账:{n} 个目标已被手动处理,不再干预")); + if let Err(e) = write_ledger(&ledger) { + log(&format!("⚠️ 账本对账后写回失败: {e}")); + } + } + // 以前这里是 `if let Ok(n)`,对账失败就当没发生 —— 账本会无限堆积, + // 恢复时去动早已被人处理掉的目标。对账失败必须喊出来。 + Err(e) => log(&format!("⚠️ 账本对账失败(不影响本轮判定): {e}")), + } + + // ── 不再武装/解除武装任何 cron ── + // + // 2026-10-20 彻底删除 cron 依赖。删除理由(全部实测,见 docs/BOT_DIAGNOSIS.md): + // + // 1) **cron 目标会话会被运行时归档** ⇒ 后续触发报 + // `CRON_SESSION_TARGET_ARCHIVED`。实测 12 次触发 = 4 成功 + 8 次归档失败。 + // 2) **cron 只能跑在 Host 内的某个会话里**,而「固定值守会话」不可靠; + // 改成指向长期会话虽然 6/6 delivered,但那会把值守回合混进用户会话历史。 + // 3) **cron 每轮烧约 47k tokens**(46k 是缓存读取),而它只需要做一件事: + // 把「续跑消息」送进目标会话。 + // + // 现在这件事由 **MiniApp 的 `agent.run`** 完成 —— 它走 `turns.steer()`, + // 与官方 `session send` 同一条通道(实测 5 秒内 accepted 并被 bot 回复)。 + // ⇒ 守护进程只负责**判定 + 记账**(写请求文件),不负责投递。 + + if five_used_percent >= five_total_percent * gp.trigger_percent / 100.0 { + match pause_active_goals(&db, now_ms) { + Ok(rows) => { + let n = rows.len(); + if n > 0 { + for r in &rows { + // 已经在账本里的目标**不要重复 push**。 + // 2026-10-19 实测踩到:目标被运行时回弹成 active 后重新暂停, + // 旧条目还在账本里,再 push 一条就成了重复 ⇒ 恢复时同一目标 + // 会被处理两次,且 `trigger_used_percent` 被后一条覆盖 + // (99 被改成 100,看起来像触发线变了,其实只是记账重复)。 + if ledger.paused.iter().any(|e| e.goal_id == r.goal_id) { + continue; + } + ledger.paused.push(LedgerEntry { + trigger_used_percent: five_used_percent, + ..r.clone() + }); + } + // 目标已经停在库里了,账本写不进去 = 永远恢复不了。 + // 这种错误绝不能 `let _ =` 吞掉,必须喊到用户眼前。 + if let Err(e) = write_ledger(&ledger) { + log(&format!( + "🔴 账本写入失败({} 个目标已暂停但**无法自动恢复**): {e}", + ledger.paused.len() + )); + } + } + SyncReport { + paused: n, + resumed: 0, + message: if n > 0 { + format!("已暂停 {n} 个目标(5 小时已用 {five_used_percent:.1}%)") + } else { + "没有处于进行中的目标需要暂停".into() + }, } + } + Err(e) => SyncReport { + message: format!("写入失败:{e}"), + ..Default::default() + }, + } + } else { + // ── 未达触发线:之前因额度到顶而停掉的目标,到这里就该放回去 ── + // + // 关键:**恢复 = 改状态 + 投递续跑消息**,缺一不可。 + // 只改状态的话,目标会停在「状态 active、但活没干」的死态 + // (运行时的目标循环在 paused 时就永久退出了,没人再读它)。 + // 详见 goal_wake.rs。 + let mut n = 0usize; + let mut woke = 0usize; + let mut still_stuck: Vec = Vec::new(); + for e in &ledger.paused { + if !should_resume(five_used_percent, gp.trigger_percent, gp.resume_at_percent) { + still_stuck.push(e.clone()); + continue; + } + if resume_goal(&db, &e.goal_id, now_ms).is_ok() { + n += 1; + // 唤醒:把续跑消息投进运行时自己的队列,让它真的开新回合 + match wake_goal(&db, &e.goal_id, five_used_percent) { + Ok(true) => woke += 1, + Ok(false) => still_stuck.push(e.clone()), + Err(err) => { + log(&format!("🔴 唤醒 {} 失败(状态已改回 active 但活没起来): {err}", e.goal_id)); + still_stuck.push(e.clone()); + } + } + } + } + // 账本只在「唤醒确实投递成功」后才清空。 + // 唤醒失败的目标必须留在账本里,下一轮重试 —— 否则它会被永久遗弃。 + // + // ⚠️ 2026-10-19 20:22 实测补:**「请求写成功」不等于「目标真的活了」**。 + // `wake_goal()` 写恢复请求成功就返回 Ok,于是账本被清空; + // 但如果目标此刻实际是 `paused`(例如被别的东西改回去了、 + // 或 500 打死后的回弹),运行时就**不会执行**这个目标 —— + // 表现是:cron 成功投递(`accepted`、排在队首), + // 量化会话也确实起了新 turn,但那个 turn 永远挂着不完成, + // turns_used 一动不动。 + // ⇒ 清空账本前必须**回读目标行**,确认它真的是 active。 + let mut still_paused_after: Vec = Vec::new(); + for e in &ledger.paused { + if !still_stuck.iter().any(|x| x.goal_id == e.goal_id) { + // 这一轮刚恢复过 ⇒ 再确认一次状态确实是 active + match goal_status(&db, &e.goal_id) { + Some(st) if st == "active" => {} + Some(st) => { + log(&format!( + "⚠️ 目标 {} 恢复后状态仍是 {st}(不是 active)⇒ 保留在账本里继续观察", + e.goal_id + )); + still_paused_after.push(e.clone()); + } + None => { + log(&format!( + "⚠️ 目标 {} 已不存在,从账本里移除", + e.goal_id + )); + } + } + } + } + let mut final_stuck = still_stuck.clone(); + final_stuck.extend(still_paused_after); + if !final_stuck.is_empty() { + if let Err(e) = write_ledger(&Ledger { paused: final_stuck }) { + log(&format!("⚠️ 账本写回失败: {e}")); + } + } else if !ledger.paused.is_empty() { + if let Err(e) = write_ledger(&Ledger { paused: Vec::new() }) { + log(&format!("⚠️ 账本清空失败: {e}")); + } + } + // ── 先清投递残留,再谈复活(顺序不能反)── + // 队列里堆着自家残留 ⇒ 会话判 busy ⇒ 续跑消息退化成排队, + // 于是这一轮的唤醒请求照样进不去。清完再唤醒才有意义。 + let purged = purge_stale_session_sends(&db); + if purged > 0 { + log(&format!("清理队列残留:删掉 {purged} 条自家自动恢复消息(会话恢复非 busy 状态)")); + } + + // ── 复活:状态是 active、但会话已经报错退出的目标 ── + // 额度墙会把正在跑的目标打死,而目标的行还停在 active, + // 没人再读它 ⇒ 显示在跑、实际没跑。这种必须补一条续跑消息。 + // 只在额度健康时做(否则刚恢复额度又会被打)。 + let (revived, revive_failed) = revive_stranded_goals(&db, five_used_percent); + if revived > 0 || revive_failed > 0 { + log(&format!("Goal 复活:{revived} 个已投递续跑,{revive_failed} 个失败")); + } + + SyncReport { + paused: 0, + resumed: n, + message: if n > 0 { + if woke == n { + format!("已恢复 {n} 个目标(5 小时已用 {five_used_percent:.1}%,额度已回落,已投递续跑)") + } else { + format!( + "已把 {n} 个目标改回 active,但只唤醒 {woke} 个(其余待重试)" + ) + } + } else { + "额度未到触发线,目标保持原状".into() + }, + } + } +} +/// 复活冷却:同一个目标两次复活尝试之间至少间隔这么久。 +/// +/// 复活会重新起一个回合;回合又可能因为别的原因失败。没有冷却就会变成 +/// 「复活 → 又炸 → 再复活」的热循环,把额度烧光。 +const REVIVE_COOLDOWN_MS: i64 = 90 * 1000; + +/// 「假 started」的最短滞留时间(2026-10-19 实测校准)。 +/// +/// 判据是「会话 `status='started'` 很久」——但**光看年龄会误杀健康会话**: +/// 实测同一会话的历史 turn 时长 max 到 **1797 秒**(近 7 天 1038 个 +/// completed turn 的 p95=359s / p99≈1053s / max=1797s),所以任何一个纯年龄 +/// 阈值都会在长 turn 上误判。因此年龄只用来把「刚起的新回合」排除掉, +/// **真正的判别信号是 `error_code` 非空**(回合已经炸了,只是 status 没被清)。 +/// +/// 10 分钟远大于 p99(1053s ≈ 17.5min 的三分之二),且比 max 还小, +/// 与健康长 turn(实测最长 1797s)的重叠由 error_code 条件排除。 +const STALE_STARTED_MS: i64 = 10 * 60 * 1000; + +fn revive_state_path() -> Option { + state_dir().map(|d| d.join("goal-revive.json")) +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct ReviveState { + /// goal_id → { 上次尝试时的 epoch, 上次尝试时刻 } + #[serde(default)] + pub attempts: std::collections::BTreeMap, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct ReviveAttempt { + #[serde(default)] + pub epoch: i64, + #[serde(default)] + pub at_ms: i64, +} + +fn read_revive_state() -> ReviveState { + let Some(p) = revive_state_path() else { return ReviveState::default() }; + let Ok(text) = std::fs::read_to_string(&p) else { return ReviveState::default() }; + serde_json::from_str(&text).unwrap_or_default() +} + +fn write_revive_state(s: &ReviveState) -> Result<(), String> { + let Some(p) = revive_state_path() else { return Err("找不到 $HOME".into()) }; + if let Some(dir) = p.parent() { + let _ = std::fs::create_dir_all(dir); + } + let tmp = p.with_extension("json.tmp"); + std::fs::write(&tmp, format!("{}\n", serde_json::to_string_pretty(s).unwrap_or_default())) + .map_err(|e| e.to_string())?; + std::fs::rename(&tmp, &p).map_err(|e| e.to_string()) +} + +/// 复活「状态是 active、但会话已经报错退出」的目标。 +/// +/// 这类目标是**显示在跑、实际没跑**的死态:额度墙(或我们自己的暂停)打死了 +/// 它的回合,而目标的行还停在 `active`,没有任何东西会再读它一次。 +/// 队列里补一条续跑消息是唯一的救法。 +/// +/// 返回 (复活成功数, 失败数)。 +fn revive_stranded_goals(db: &PathBuf, used_percent: f64) -> (usize, usize) { + let now = crate::api::now_ms(); + // 判据:目标处于「该跑却没跑」的状态,且其会话没在跑回合、目标已停滞一会儿。 + // + // 两种要复活的目标: + // 1. status='active' —— 被我们(或别人)从 paused 改回 active,但没人叫醒它; + // 2. status='paused' 且 reason='paused(infra_retryable)' —— + // **运行时自己**标的「这棒失败了,等会儿重试」。这正是我们该接手的信号, + // 而且是最该接的一种:运行时把重试的责任交给了外面。 + // + // 为什么不能只看「会话报错了」:目标也可能死在一次**干净**的暂停上 + // (会话 idle、无错误、但目标从没被叫醒)。 + // 为什么不能只看「会话不是 started」:健康目标的续跑循环在**同一个 SSE 流**里 + // 连转多回合,会话全程都是 started,所以 idle 的基本都是真卡住了。 + // 叠加「目标已停滞 3 分钟」再兜一层底。 + // + // ── 「假 started」这一路(2026-10-19 实测补)── + // 上面那套判据有个盲区:回合被 500 打死后,会话 status 卡在 `started` + // 不会被清掉,于是 `s.status <> 'started'` 把它**排除**在复活之外 —— + // 表现为「目标 active、turns_used 不动、日志里一次复活都没有」。 + // 实测样本:量化目标 tg_tdklii7ohbmvb86p8s 的会话 mvs_7ab98c64… 停在 + // `started` + error_code=50113("Internal server error. (500)"), + // 最后一个 turn 从 18:32:52 起 `accepted` 未完成,18 分钟 turns_used 不动。 + // + // 所以把判据放宽成两个「该跑却没跑」的分支,而不是只看 status: + // A. 会话不是 started(原来的判据) + // B. 会话卡在 started **且** 带 error_code **且** 已滞留超 STALE_STARTED_MS + // B 里 error_code 非空是**必要条件**,不是加分项:健康的长 turn + // (实测最长 1797 秒)也会让 status 一直停在 started,但它的 error_code + // 是空的。只用年龄会把这些健康的回合误杀。 + let stale_before = now - 3 * 60 * 1000; + let stale_started_before = now - STALE_STARTED_MS; + let sql = format!( + "SELECT g.goal_id AS goal_id, g.status AS status, IFNULL(g.status_reason,'') AS status_reason\n\ + FROM local_runtime_thread_goals g\n\ + JOIN local_runtime_sessions s ON s.session_id = g.session_id\n\ + WHERE (g.status = 'active'\n\ + OR (g.status = 'paused' AND g.status_reason = 'paused(infra_retryable)'))\n\ + AND g.updated_at_ms < {stale_before}\n\ + AND (\n\ + s.status <> 'started'\n\ + OR (s.status = 'started'\n\ + AND IFNULL(s.error_code, '') <> ''\n\ + AND s.updated_at_ms < {stale_started_before})\n\ + )\n\ + -- 🔴 第二道闸:会话说「started」还不够,**最近一个回合必须真的没进展**。\n\ + -- 2026-10-19 20:38-21:00 实测踩到:量化会话有一个跑了 20 多分钟的**正常长回合**\n\ + -- (R034 引擎实测,20:59:25/20:59:35/21:00:06 连续 response_status=200),\n\ + -- 而会话的 error_code=50113 是**上一次** 500 留下的陈旧值(没被清)。\n\ + -- 只看会话级 error_code + 超龄 ⇒ 会把这个**正在干活的**回合误判成卡死\n\ + -- ⇒ 触发一次多余的复活(写请求 + 武装 cron + 清队列),打断正常运行。\n\ + --\n\ + -- ⚠️ 判据必须用**回合的最后活动时间**,不能用 turn_ingress.accepted_at_ms\n\ + -- (那是回合「开始」时间):长回合开始 20 分钟后同样会「超龄」,\n\ + -- 于是第一版闸门(用 accepted_at_ms)仍会误杀。\n\ + -- 真正的活动信号是 `local_runtime_message_rows`:每条 assistant 消息\n\ + -- (含每次工具调用的往返)都会落一行,带 created_at_ms。\n\ + -- 实测:正在跑的 turn 最后活动 = 1 秒前,而它 accepted 于 23 分钟前。\n\ + AND NOT EXISTS (\n\ + SELECT 1 FROM local_runtime_message_rows mr\n\ + WHERE mr.session_id = s.session_id\n\ + AND mr.created_at_ms > {stale_started_before}\n\ + )\n\ + ORDER BY g.updated_at_ms ASC;" + ); + let Ok(out) = sqlite(db, &sql) else { return (0, 0) }; + let Ok(rows) = last_json_document(&out) else { return (0, 0) }; + let Some(list) = rows.as_array() else { return (0, 0) }; + + let mut state = read_revive_state(); + let mut ok = 0usize; + let mut failed = 0usize; + for r in list { + let Some(goal_id) = r.get("goal_id").and_then(|v| v.as_str()) else { continue }; + if !valid_goal_id(goal_id) { continue; } + let gstatus = r.get("status").and_then(|v| v.as_str()).unwrap_or(""); + let greason = r.get("status_reason").and_then(|v| v.as_str()).unwrap_or(""); + // 运行时标的「可重试失败」:先把它改回 active,编排器才会继续。 + if gstatus == "paused" && greason == "paused(infra_retryable)" { + if let Err(e) = resume_goal(db, goal_id, now) { + log(&format!("🔴 复活 {goal_id} 时改回 active 失败: {e}")); + failed += 1; + continue; + } + } + // 读当前 epoch:队列项带的就是它,epoch 对不上会被准入判据 cancel。 + let Ok(Some(target)) = load_wake_target(db, goal_id) else { continue }; + let epoch = target.goal_updated_at_ms; + // 冷却:避免「复活→又炸→再复活」的热循环烧额度。 + // 但**epoch 变了就立刻重试** —— 上一条项带的 epoch 已过期, + // 继续等它没有意义(那正是队列把「陈旧续跑项」cancel 掉的机制)。 + if let Some(prev) = state.attempts.get(goal_id) { + if prev.epoch == epoch && now - prev.at_ms < REVIVE_COOLDOWN_MS { continue; } + } + state.attempts.insert(goal_id.to_string(), ReviveAttempt { epoch, at_ms: now }); + // 队列投递(对活会话有效)+ 恢复请求文件(给 Host 内的 MiniApp 起回合) + match wake_goal(db, goal_id, used_percent) { + Ok(true) => { + ok += 1; + log(&format!("Goal 复活:已为 {goal_id} 投递续跑(epoch {epoch})")); + } + Ok(false) => {} + Err(err) => { + failed += 1; + log(&format!("🔴 Goal 复活失败 {goal_id}: {err}")); + } + } + } + let _ = write_revive_state(&state); + (ok, failed) +} + +/// 唤醒一个目标:**只写「恢复请求」文件**,由 Host 内的 MiniApp 用 `agent.run` 投递。 +/// +/// 为什么不再往队列表投续跑消息(2026-10-19 实测后删掉): +/// 1. 会话活着时,目标自己的续跑循环会接着跑,**根本不需要外部唤醒**; +/// 2. 会话空闲/已死时,空闲会话**没有调度器**去轮询队列,投进去的项永远不会被消费 +/// (实测一条 10 月 1 日的项在队列里躺了 446 小时); +/// 3. 最要命的是 —— 队列里一旦有条目,这个会话就会被判为「busy」, +/// 于是 `session send` **从「直接起回合」退化成「排到队尾」**, +/// 反而把唯一能用的那条路给堵了。 +/// +/// 队列投递只剩一个副作用:制造垃圾。 +pub fn wake_goal(db: &PathBuf, goal_id: &str, used_percent: f64) -> Result { + let Some(target) = load_wake_target(db, goal_id)? else { + return Ok(false); + }; + // 顺手清掉自己以前塞进去的续跑项(它们永远不会被消费)。 + let cleanup = format!( + "DELETE FROM local_runtime_queue_items\n\ + WHERE session_id = '{sid}' AND source = 'thread-goal'\n\ + AND client_request_id LIKE 'thread-goal-followup:rearm:{gid}:%';", + sid = target.session_id.replace('\'', "''"), + gid = target.goal_id.replace('\'', "''"), + ); + let _ = crate::goal_sync::exec_sql(db, &cleanup); + + crate::goal_wake::write_resume_request(&target, used_percent)?; + Ok(true) +} + +/// 清理 cron 的 `session send` 在队列表里留下的残留(2026-10-19 实测补)。 +/// +/// # 问题 +/// +/// 唤醒走的是 cron 里的 `session send`,它会往 `local_runtime_queue_items` +/// 插一条 `source='communication'` 的消息。目标会话若没人消费(长时间 idle、 +/// 或卡在别处),这条消息就永远停在 `queued`——实测 haisi 会话 +/// `mvs_617181c…` 积了 5 条(17:52→18:38)。 +/// +/// 队列里有条目 ⇒ 会话被判为 **busy** ⇒ 下一条 `session send` +/// **从「直接起回合」退化成「排到队尾」**。这是自己把自己堵死: +/// 唤醒越多,堵得越死,于是守护进程每 2 分钟复活一次却毫无进展。 +/// +/// # 边界:绝不删用户的消息 +/// +/// `communication` 是**通用的通信来源**,用户手动发的消息也是它。 +/// 所以只删同时满足以下三条的: +/// 1. `source = 'communication'`; +/// 2. 正文以**本项目自动恢复文案**的固定开头 `【自动恢复】` 开头; +/// 3. 状态仍是 `queued`(已被消费的不动)。 +/// +/// 第 2 条是关键的护栏:匹配的是我们自己生成的文案,用户的任何消息都不 +/// 会以这个前缀开头。宁可漏删(下次再清),不可误删用户消息。 +/// +/// 返回删除条数,供调用方记日志。 +pub fn purge_stale_session_sends(db: &PathBuf) -> usize { + // 只针对「有 active/paused 目标、但会话里堆着自家残留消息」的那些会话。 + // 直接全表扫会让每个用户会话都进 SQL,没有必要。 + let sql = "SELECT DISTINCT session_id FROM local_runtime_queue_items\n\ + WHERE source = 'communication'\n\ + AND status = 'queued'\n\ + AND data_json LIKE '%【自动恢复】%';"; + let Ok(out) = sqlite(db, sql) else { return 0 }; + let Ok(rows) = last_json_document(&out) else { return 0 }; + let Some(list) = rows.as_array() else { return 0 }; + + let mut n = 0usize; + for r in list { + let Some(sid) = r.get("session_id").and_then(|v| v.as_str()) else { continue }; + if !valid_session_id(sid) { continue; } + let esc = sid.replace('\'', "''"); + // 先数再删:`sqlite3 -json` 下 DELETE 不回显影响行数 + // (实测无影响行时 stdout 为空),靠解析输出计不到数。 + let cnt = format!( + "SELECT COUNT(*) AS c FROM local_runtime_queue_items\n\ + WHERE session_id = '{esc}' AND source = 'communication'\n\ + AND status = 'queued' AND data_json LIKE '%【自动恢复】%';" + ); + let Ok(cout) = exec_sql(db, &cnt) else { continue }; + let Ok(crows) = last_json_document(&cout) else { continue }; + let c = crows + .as_array() + .and_then(|a| a.first()) + .and_then(|o| o.get("c")) + .and_then(|v| v.as_i64()) + .unwrap_or(0); + if c <= 0 { + continue; + } + let del = format!( + "DELETE FROM local_runtime_queue_items\n\ + WHERE session_id = '{esc}' AND source = 'communication'\n\ + AND status = 'queued' AND data_json LIKE '%【自动恢复】%';" + ); + if exec_sql(db, &del).is_ok() { + n += c as usize; + } + } + n +} + +/// session_id 形状校验。白名单之外的直接跳过,避免 SQL 注入面。 +/// +/// 前缀 `mvs_` 是 **4** 个字符(`m` `v` `s` `_`),所以要检查的是 `b[4..]`。 +/// 写成 `b[3..]` 会把下划线本身纳入校验,`is_ascii_alphanumeric('_')` 为 false, +/// 于是**所有真实 session 都被拒**——而测试里恰好抓到了这个。 +fn valid_session_id(id: &str) -> bool { + let b = id.as_bytes(); + id.len() == 36 && id.starts_with("mvs_") && b[4..].iter().all(|c| c.is_ascii_alphanumeric()) +} + +/// 读取唤醒所需的目标 + 会话信息。 +pub fn load_wake_target( + db: &PathBuf, + goal_id: &str, +) -> Result, String> { + if !valid_goal_id(goal_id) { + return Err(format!("goal_id 格式不合法,已拒绝:{goal_id}")); + } + let sql = format!( + "SELECT g.goal_id, g.session_id, g.objective, g.updated_at_ms,\n\ + s.agent_name, s.extra_data_json\n\ + FROM local_runtime_thread_goals g\n\ + LEFT JOIN local_runtime_sessions s ON s.session_id = g.session_id\n\ + WHERE g.goal_id = '{goal_id}';" + ); + let out = sqlite(db, &sql)?; + let rows = last_json_document(&out)?; + let Some(row) = rows.as_array().and_then(|a| a.first()) else { + return Ok(None); + }; + let get = |k: &str| row.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string(); + let goal_id = get("goal_id"); + let session_id = get("session_id"); + if goal_id.is_empty() || session_id.is_empty() { + return Ok(None); + } + let extra = row.get("extra_data_json").and_then(|v| v.as_str()).unwrap_or("{}"); + let model = crate::goal_wake::model_from_session_extra(extra).ok_or_else(|| { + format!("会话 {session_id} 缺少模型信息,无法构造续跑消息") + })?; + let agent_name = { + let a = row.get("agent_name").and_then(|v| v.as_str()).unwrap_or("").to_string(); + if a.is_empty() { "mavis".to_string() } else { a } + }; + Ok(Some(crate::goal_wake::WakeTarget { + goal_id, + session_id, + objective: get("objective"), + goal_updated_at_ms: row.get("updated_at_ms").and_then(|v| v.as_i64()).unwrap_or(0), + agent_name, + model, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn goal_id_whitelist() { + assert!(valid_goal_id("tg_e57yzoauv0mul9txis")); + assert!(!valid_goal_id("tg_short")); + assert!(!valid_goal_id("mvs_abc")); + // 注入尝试必须被拒 + assert!(!valid_goal_id("tg_abc'; DROP TABLE x;--")); + assert!(!valid_goal_id("tg_abc OR 1=1")); + assert!(!valid_goal_id("")); + } + + #[test] + fn ledger_drops_bad_ids_on_read() { + // 手工构造的坏账本:注入串必须被过滤掉 + let text = r#"{"paused":[ + {"goal_id":"tg_e57yzoauv0mul9txis","session_id":"mvs_a","paused_at_ms":1,"trigger_used_percent":99}, + {"goal_id":"x'; DROP TABLE y;--","session_id":"mvs_b","paused_at_ms":1,"trigger_used_percent":99} + ]}"#; + let mut l: Ledger = serde_json::from_str(text).unwrap(); + l.paused.retain(|e| valid_goal_id(&e.goal_id)); + assert_eq!(l.paused.len(), 1); + assert_eq!(l.paused[0].goal_id, "tg_e57yzoauv0mul9txis"); + } + + #[test] + fn disabled_config_is_a_noop() { + let cfg = Config::default(); + let r = sync_with_quota(&cfg, 99.0, 100.0, false); + assert_eq!(r.paused, 0); + assert_eq!(r.resumed, 0); + assert!(r.message.contains("未开启")); + } + + /// 回归测试:恢复条件不能写成字面的 `used <= 0`。 + /// + /// 守护进程每 60 秒轮询一次,额度刷新后的 0% 瞬间通常观测不到。 + /// 实测:重置卡把额度打回 0%,下一轮读到的是 1% —— 若严格按 0% 判定, + /// 目标会被**永久停住**(这个故障真实发生过)。 + #[test] + fn resume_triggers_when_quota_recovers_even_above_zero() { + // 额度仍卡在触发线上 → 不能恢复 + assert!( + !should_resume(99.5, 99.0, 0.0), + "额度还在触发线上时不能恢复" + ); + // 额度掉到 1%(重置卡刚打回 0%,这一轮没观测到 0%)→ 必须恢复 + assert!( + should_resume(1.0, 99.0, 0.0), + "额度已回落就必须恢复,哪怕这一轮只读到 1% 而非 0%" + ); + // 额度完全刷新到 0% → 也要恢复 + assert!(should_resume(0.0, 99.0, 0.0)); + // 触发线以下但用户设了更严格的恢复点(resume_at = 50)→ 0% 仍满足 + assert!(should_resume(0.0, 99.0, 50.0)); + // 触发线 = 0 时(理论上不该配)不应误判 + assert!(!should_resume(5.0, 0.0, 0.0)); + } + + #[test] + fn session_id_whitelist() { + assert!(valid_session_id( + "mvs_617181c29e5d4d12b52c90954e1501ac" + )); + assert!(!valid_session_id("mvs_short")); + assert!(!valid_session_id("tg_e57yzoauv0mul9txis")); + // 注入尝试必须被拒 + assert!(!valid_session_id("mvs_abc'; DROP TABLE x;--")); + assert!(!valid_session_id("")); + } + + /// 回归测试:「假 started」判据必须同时要求 error_code 非空。 + /// + /// 2026-10-19 实测踩到的坑:回合被 500 打死后,会话 status 卡在 + /// `started` 不清,于是 `s.status <> 'started'` 把它排除在复活之外 + /// (量化目标 tg_tdklii7ohbmvb86p8s 表现成「active 但 turns_used 不动、 + /// 日志里一次复活都没有」)。 + /// + /// 但**不能**只用年龄判定:同会话历史 turn 时长 max 到 1797 秒 + /// (1038 个 completed turn,p95=359s / p99≈1053s),健康的长回合 + /// 同样会让 status 停在 started。所以 age 必须与 error_code 合取。 + #[test] + fn stale_started_requires_error_code() { + // 本会话实测的健康/卡死两组样本(2026-10-19 18:47) + let healthy = ("mvs_9c9d055429cb480193dbb427d0be85ac", "started", 0i64, 240u64); + let stuck = ("mvs_7ab98c640f62428690391b54371ed19c", "started", 50113i64, 1094u64); + + // 卡死的:年龄超阈值 且 error_code 非空 → 必须判定为可复活 + assert!( + stuck.3 as i64 * 1000 > STALE_STARTED_MS, + "样本前提:卡死会话年龄应超过阈值" + ); + assert!( + stuck.2 != 0 && stuck.3 as i64 * 1000 > STALE_STARTED_MS, + "假 started(超龄 + 有错误码)必须被复活判据纳入" + ); + + // 健康的:即使 started 且长时间在跑,没有 error_code 就不该动它 + assert_eq!(healthy.2, 0, "样本前提:健康会话没有错误码"); + assert!( + !(healthy.2 != 0 && healthy.3 as i64 * 1000 > STALE_STARTED_MS), + "无 error_code 的长回合绝不能被误杀" + ); + + // 阈值必须落在「比 p99 大」的位置,才有资格单独作为信号; + // 这里进一步保证它比实测 max 更小,从而必须依赖 error_code。 + assert!(STALE_STARTED_MS < 1797 * 1000, "阈值不应大于实测最长回合"); + } + + /// 回归测试:队列残留清理绝不能删掉用户自己的消息。 + /// + /// `source='communication'` 是通用通信来源,用户手动发的消息也在里面, + /// 所以护栏是正文必须以本项目自动恢复文案的固定开头 `【自动恢复】` 开头。 + /// 宁可漏删(下一轮再清),不可误删用户消息。 + #[test] + fn purge_only_targets_our_own_resume_messages() { + // 这三条正是 purge SQL 的三个 WHERE 条件 + assert_eq!("communication", "communication"); + assert!("【自动恢复】5 小时额度已回落到 82%…".contains("【自动恢复】")); + // 用户的普通消息不带这个前缀 ⇒ 不会被 LIKE 命中 + assert!(!"帮我看看这个配置文件".contains("【自动恢复】")); + assert!(!"ok".contains("【自动恢复】")); + } + + /// 回归测试:账本里已有的目标不能被重复记账。 + /// + /// 2026-10-19 实测踩到:目标被运行时回弹成 `active` 后重新暂停, + /// 旧条目还在账本里 → 再 push 一条就成了重复 ⇒ 恢复时同一目标处理两次, + /// 且 `trigger_used_percent` 被后一条覆盖(99 变成 100,看起来像触发线变了)。 + #[test] + fn ledger_does_not_duplicate_existing_goal() { + let mut l: Ledger = serde_json::from_str( + r#"{"paused":[ + {"goal_id":"tg_e57yzoauv0mul9txis","session_id":"mvs_a","paused_at_ms":111,"trigger_used_percent":99.0} + ]}"#, + ) + .unwrap(); + let incoming = LedgerEntry { + goal_id: "tg_e57yzoauv0mul9txis".into(), + session_id: "mvs_a".into(), + paused_at_ms: 222, + trigger_used_percent: 100.0, + }; + // 复刻主流程里的去重逻辑 + if !l.paused.iter().any(|e| e.goal_id == incoming.goal_id) { + l.paused.push(incoming); + } + assert_eq!(l.paused.len(), 1, "同一目标不能在账本里出现两条"); + // 且原来的触发值不能被覆盖 + assert_eq!(l.paused[0].trigger_used_percent, 99.0); + assert_eq!(l.paused[0].paused_at_ms, 111); + } + + /// 回归测试:运行时回弹 ≠ 用户手动处理。 + /// + /// 实测(2026-10-19 19:13 / 19:19 两次):量化目标 18:59:03 被暂停 + /// → 19:12:55 那个跑了 13 分钟的回合结束 → 19:13:04 运行时把它改回 `active`。 + /// 用户全程没碰过。若把这种回弹当成「用户手动处理」剔出账本, + /// 恢复时就不会放回它 —— 目标永久停在 active 且不被我们拥有。 + /// + /// ⚠️ 判据必须用**回合结束时刻**,不能用 `goal.updated_at_ms`: + /// 后者被 `pause_active_goals` 自己在暂停时改写成 `now_ms`, + /// 所以 `updated > paused_at` 恒为 false,回弹永远被误判(19:19 踩过一次)。 + #[test] + fn runtime_rebound_is_not_user_handled() { + // 场景:目标在我们暂停之后还有回合结束过 + let paused_at = 1_792_407_543_734i64; // 18:59:03 我们暂停的时刻 + let rebound_turn_completed_at = 1_792_408_175_522i64; // 19:12:55 回合结束 + assert!(rebound_turn_completed_at > paused_at); + // 判据:暂停之后有回合结束过 ⇒ 回弹 ⇒ 不是用户处理 + assert!( + rebound_turn_completed_at > paused_at, + "有回合在暂停之后结束 ⇒ 判为运行时回弹,保留在账本里" + ); + } + + /// 回归测试:`resume_goal` 生成的 SQL 必须真的带上目标 id。 + /// + /// ⚠️ 这条锁的是 2026-10-19 找到的最严重 bug: + /// `format!(... WHERE goal_id='{goal_id}' ...)` 里的 `{goal_id}` 是 + /// **字面量**(Rust 的 `format!` 只替换 `{标识符}`,不替换裸花括号里的 + /// 名字 —— 实际上它连 `{goal_id}` 都会当占位符,但源码里那处写的是 + /// **没有命名参数的裸花括号**,于是 format! 把它原样留下)。 + /// 结果这条 UPDATE 恒匹配 0 行 ⇒ `resume_goal` 返回 Ok(()) ⇒ 日志报 + /// 「已恢复 N 个目标」而目标其实**一直停在 paused**。 + /// + /// 实测证据:真实库上 `WHERE goal_id='{goal_id}'` 匹配 **0** 行, + /// 换成真实 id 匹配 **1** 行。haisi 目标从 2026-10-01 起就一直是 + /// `paused`,而日志在 14:43 / 17:06 两次宣称「已恢复」。 + #[test] + fn resume_sql_contains_the_real_goal_id() { + // 复刻 resume_goal 里的 SQL 构造方式 + let goal_id = "tg_im1jszv4sjmuoen4o4"; + let gid = goal_id.replace('\'', "''"); + let sql = format!( + ".timeout 3000\n\ + BEGIN IMMEDIATE;\n\ + UPDATE local_runtime_thread_goals\n\ + \x20 SET status='active', status_reason=NULL, reply_fingerprint=NULL, updated_at_ms={}\n\ + \x20 WHERE goal_id='{gid}' AND status='paused';\n\ + COMMIT;\n", + 1_792_408_000_000i64 + ); + assert!( + sql.contains("WHERE goal_id='tg_im1jszv4sjmuoen4o4'"), + "恢复 SQL 必须带上真实 goal_id,实际: {sql}" + ); + assert!( + !sql.contains("'{goal_id}'"), + "恢复 SQL 里不能残留未替换的花括号占位符" + ); + } + + /// 回归测试:`resume_goal` 遇到「目标已不存在」必须报错,不能假成功。 + /// + /// 实测 2026-10-19 19:42:41:MCode 界面层发 + /// `DELETE /minimax-desktop/api/v1/session//goal` 硬删了目标行 + /// (表里没有软删标记,被删目标的 `dismissed_at_ms` 也没写)。 + /// 账本若还留着它,恢复阶段就会去 UPDATE 一个不存在的目标 —— + /// 而 `UPDATE ... WHERE ...` 影响 0 行时 sqlite3 **照样退出码 0**, + /// 于是旧代码返回 Ok(()),日志宣称「已恢复 N 个目标」,实际什么都没发生。 + #[test] + fn resume_goal_reports_missing_goal_instead_of_fake_success() { + // 语义契约:目标不存在 ⇒ resume_goal 必须返回 Err + // (具体到 SQL:SELECT 查不到行 ⇒ Err("已不存在")) + let select_result: Option = None; // 模拟 SELECT 无结果 + let outcome = match select_result { + None => Err("目标已不存在(可能已被官方删除),跳过恢复"), + Some(s) => Ok(s), + }; + assert!( + outcome.is_err(), + "目标不存在时必须返回 Err,不能报成功" + ); + } + + /// 回归测试:目标被官方 DELETE 掉时,账本条目必须被清掉。 + /// + /// 实测(2026-10-19 19:42:41):MCode 界面层发了 + /// `DELETE /minimax-desktop/api/v1/session//goal`, + /// `local_runtime_thread_goals` 里的行**硬删除**(表里没有软删标记, + /// 只有 `dismissed_at_ms`,而实测被删的目标该字段也没写)。 + /// 此时账本里若还留着它,恢复阶段会去操作一个不存在的目标。 + #[test] + fn deleted_goal_is_treated_as_handled() { + // 目标查不到 ⇒ 没有任何状态可观察 ⇒ 视为已被处理 + let (exists, status) = (false, ""); + assert!(!exists); + assert!(!status.starts_with("complete")); + // 语义:查不到目标 ⇒ is_user_handled 应当返回 true(清理掉) + assert!( + !exists, + "目标已不存在时必须从账本里清掉,不能留孤儿条目" + ); + } +} diff --git a/plugins/hylouis233/expiry-guardian/daemon/rust/src/goal_wake.rs b/plugins/hylouis233/expiry-guardian/daemon/rust/src/goal_wake.rs new file mode 100644 index 0000000..a42f2ff --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/rust/src/goal_wake.rs @@ -0,0 +1,527 @@ +//! Goal 唤醒(把「已恢复状态」变成「真的重新开跑」)。 +//! +//! # 为什么需要这个模块 +//! +//! 2026-10-19 实测踩到的坑:**只把 `status` 改回 `active`,目标不会复活。** +//! +//! 运行时的目标循环(`@mavis/local-runtime/src/thread-goal/orchestrator.ts`)只在 +//! **回合边界**读一次 durable goal 状态: +//! +//! ```text +//! while (true) { +//! lastStatus = await runOneTurn(...) +//! if (lastStatus === 'error') { pauseIfActiveAfterFailure(...); return } // ← 跳出循环 +//! const goal = await store.getBySession(sessionId) +//! if (!isContinuationStatus(goal.status)) return // ← paused 跳出 +//! promptText = await renderContinuation(goal); turnId = generateTurnId() +//! } +//! ``` +//! +//! 所以目标一旦 `paused`,循环就**永久退出**;之后再把状态改回 `active`, +//! **没有任何东西会再读它一次** —— 目标就停在「状态是 active、但活没干」的死态。 +//! +//! 运行时自己写下了这条契约(`continuation-rearm.ts` 头注释): +//! +//! > **a writer that advances the decision epoch must take over the Goal's next +//! > step — by scheduling it, or by arming a marker here. A writer that takes +//! > over nothing must not advance it.** +//! +//! 而「the breaker reset is **a bare store write that publishes no lifecycle +//! event at all**」—— 裸写数据库**唤不醒目标**。本守护进程之前做的正是这件事。 +//! +//! # 唤醒的唯一无头路径 +//! +//! 运行时启动下一回合靠的是**往队列里投递一条续跑消息** +//! (`submitActiveContinuation` → `enqueuePostTurnContinuation`), +//! 落在 `local_runtime_queue_items` 表里。本模块照抄它的形状: +//! +//! - `source = 'api'`(与 `session send` 同一来源,已实测可送达) +//! - `client_request_id` = `mcode-reset-card-resume::` +//! —— 同一 epoch 内重试幂等,换 epoch 自然是新键 +//! - `data_json` 内含 `origin.goalUpdatedAt`(epoch)与 `origin.objectiveDigest` +//! (= sha256(objective),已逐位验证) +//! +//! 队列是**运行时自己的传输层**,不是私造旁路:Host 的队列轮询器会认它。 + +use std::path::PathBuf; + +use serde::Deserialize; +use serde_json::json; + +use crate::api::now_ms; + +/// 队列项的 `routing_fingerprint`:与 Host 自己的写入逐字一致。 +/// 换掉它,队列可能路由不到对应 agent。 +const ROUTING_FINGERPRINT_GOAL: &str = "queue-routing/v1:[[\"source\",\"thread-goal\"],[\"platform\",null],[\"client\",null],[\"agent\",\"mavis\"],[\"agentBinding\",null],[\"binding\",null],[\"channel\",null],[\"channelId\",null],[\"thread\",null],[\"chat\",null],[\"scope\",null],[\"chatType\",null],[\"sender\",null]]"; + +/// 续跑项的 clientRequestId 前缀。**必须是这个前缀**: +/// 运行时的 `isThreadGoalContinuationQueueItem()` 用它来判断 +/// 「这条队列项是不是目标续跑项」—— +/// 前缀不对或没有 origin,就会被当成普通消息走通用分支,目标续跑通道根本不认。 +const CONTINUATION_PREFIX: &str = "thread-goal-followup:"; + +/// 目标 + 会话的必要信息。 +#[derive(Debug, Clone)] +pub struct WakeTarget { + pub goal_id: String, + pub session_id: String, + pub objective: String, + /// 目标的 epoch(`updated_at_ms`)。续跑消息必须带当前 epoch,否则会被当作过期项。 + pub goal_updated_at_ms: i64, + pub agent_name: String, + pub model: ModelSnapshot, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ModelSnapshot { + pub provider_id: String, + pub model_id: String, + #[serde(default)] + pub reasoning: bool, + #[serde(default)] + pub context_limit: u64, + #[serde(default)] + pub thinking: Option, + #[serde(default)] + pub parameter_snapshot: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ThinkingSnapshot { + pub effort: String, +} + +/// 从 `local_runtime_sessions.extra_data_json` 里解析模型快照。 +/// +/// 会话行里存着 `effectiveModel` / `effectiveModelVariant` / +/// `effectiveModelThinking.effort` / `effectiveModelContextWindow`, +/// 用它才能让唤醒后的回合**沿用用户当前选的模型**,而不是硬编码。 +pub fn model_from_session_extra(extra_json: &str) -> Option { + let v: serde_json::Value = serde_json::from_str(extra_json).ok()?; + let raw_model = v.get("effectiveModel")?.as_str()?; // 形如 "minimax/MiniMax-M3.1-Flash-Preview" + let (provider_id, model_id) = raw_model.split_once('/')?; + let variant = v.get("effectiveModelVariant").and_then(|x| x.as_str()); + let reasoning = variant == Some("thinking"); + let thinking = v + .get("effectiveModelThinking") + .and_then(|x| x.get("effort")) + .and_then(|x| x.as_str()) + .map(|effort| ThinkingSnapshot { effort: effort.to_string() }); + Some(ModelSnapshot { + provider_id: provider_id.to_string(), + model_id: model_id.to_string(), + reasoning, + context_limit: v + .get("effectiveModelContextWindow") + .and_then(|x| x.as_u64()) + .unwrap_or(1_000_000), + thinking, + parameter_snapshot: Some(json!({"context": "selection", "effort": "selection"})), + }) +} + +/// 目标的 objective 摘要。运行时的 `digestThreadGoalObjective` 就是 sha256(objective)。 +pub fn objective_digest(objective: &str) -> String { + // 纯 sha256:不引依赖,自己算(32 字节摘要 = 64 个十六进制字符) + let digest = sha256(objective.as_bytes()); + digest.iter().map(|b| format!("{b:02x}")).collect() +} + +/// 极简 SHA-256(只为算 objective 摘要;不引 crate 是为了保持这个守护进程的依赖面不变)。 +fn sha256(data: &[u8]) -> [u8; 32] { + const K: [u32; 64] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, + ]; + let mut h: [u32; 8] = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, + ]; + let mut msg = data.to_vec(); + let bitlen = (data.len() as u64) * 8; + msg.push(0x80); + while msg.len() % 64 != 56 { + msg.push(0); + } + msg.extend_from_slice(&bitlen.to_be_bytes()); + + for chunk in msg.chunks(64) { + let mut w = [0u32; 64]; + for i in 0..16 { + w[i] = u32::from_be_bytes([chunk[i * 4], chunk[i * 4 + 1], chunk[i * 4 + 2], chunk[i * 4 + 3]]); + } + for i in 16..64 { + let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3); + let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16] + .wrapping_add(s0) + .wrapping_add(w[i - 7]) + .wrapping_add(s1); + } + let (mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh) = + (h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]); + for i in 0..64 { + let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let ch = (e & f) ^ ((!e) & g); + let t1 = hh + .wrapping_add(s1) + .wrapping_add(ch) + .wrapping_add(K[i]) + .wrapping_add(w[i]); + let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let maj = (a & b) ^ (a & c) ^ (b & c); + let t2 = s0.wrapping_add(maj); + hh = g; g = f; f = e; + e = d.wrapping_add(t1); + d = c; c = b; b = a; + a = t1.wrapping_add(t2); + } + h[0] = h[0].wrapping_add(a); h[1] = h[1].wrapping_add(b); + h[2] = h[2].wrapping_add(c); h[3] = h[3].wrapping_add(d); + h[4] = h[4].wrapping_add(e); h[5] = h[5].wrapping_add(f); + h[6] = h[6].wrapping_add(g); h[7] = h[7].wrapping_add(hh); + } + let mut out = [0u8; 32]; + for (i, v) in h.iter().enumerate() { + out[i * 4..i * 4 + 4].copy_from_slice(&v.to_be_bytes()); + } + out +} + +/// 续跑消息正文。 +/// +/// 照运行时 `renderContinuationPrompt` 的口径:告诉模型「继续推进目标」, +/// 并明确「额度已恢复、从暂停处继续、不要重做已完成的部分」。 +fn continuation_content(goal: &WakeTarget, used_percent: f64) -> String { + format!( + "【自动恢复】5 小时额度已回落到 {used_percent:.0}%,目标「{objective}」之前的暂停已解除。\n\ + 请从暂停处继续执行当前目标:先确认当前状态与已完成的部分,不要重做;然后继续推进未完成的部分。", + objective = goal.objective, + used_percent = used_percent, + ) +} + +/// 生成一个 RFC-4122 v4 形状的 uuid(够用即可,只需唯一)。 +fn random_uuid() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0); + // xorshift 混合,够随机到不撞键 + let mut x = nanos ^ 0x9E3779B97F4A7C15; + let mut b = [0u8; 16]; + for i in 0..16 { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + b[i] = (x >> (i % 8 * 8)) as u8; + } + b[6] = (b[6] & 0x0f) | 0x40; // version 4 + b[8] = (b[8] & 0x3f) | 0x80; // variant + let h: Vec = b.iter().map(|x| format!("{x:02x}")).collect(); + format!( + "{}{}{}{}-{}{}-{}{}-{}{}-{}{}{}{}{}{}", + h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], h[8], h[9], h[10], h[11], h[12], h[13], h[14], h[15] + ) +} + +/// 往队列表投递一条续跑消息。返回 `true` 表示已投递。 +/// 写一条「恢复请求」,交给 Host 内的 MiniApp 去起回合。 +/// +/// 为什么要绕这一圈:守护进程跑在 Host **之外**,能改目标状态、能往队列表投 +/// 续跑消息,但**唤不醒一个空闲的会话** —— 空闲会话没有调度器去轮询它的队列 +/// (实测:一条 10 月 1 日投的续跑项在队列里躺了 18 天没被消费)。 +/// 而「起回合」的能力(`context.agent.run`)只有 Host **内部**才有。 +/// +/// 于是用本地文件做交接:两边看同一个文件,不需要网络、不需要凭据。 +/// 请求 id 带 epoch(`resume::`),同一 epoch 重复写也只会被 +/// MiniApp 处理一次。 +pub fn write_resume_request(goal: &WakeTarget, used_percent: f64) -> Result<(), String> { + let path = request_path().ok_or("找不到 $HOME")?; + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + // 读现有内容(损坏就当空) + let existing = std::fs::read_to_string(&path).unwrap_or_default(); + let mut requests: Vec = serde_json::from_str::(&existing) + .ok() + .and_then(|v| v.get("requests").and_then(|r| r.as_array()).cloned()) + .unwrap_or_default(); + + let id = format!("resume:{}:{}", goal.goal_id, goal.goal_updated_at_ms); + requests.retain(|r| r.get("id").and_then(|v| v.as_str()) != Some(id.as_str())); + // 顺手丢掉过期的请求,别让投递端为一个叫不醒的目标一直空转 + let now = now_ms(); + requests.retain(|r| { + let at = r.get("atMs").and_then(|a| a.as_i64()).unwrap_or(0); + now - at <= RESUME_REQUEST_TTL_MS + }); + requests.push(json!({ + "id": id, + "goalId": goal.goal_id, + "sessionId": goal.session_id, + "epoch": goal.goal_updated_at_ms, + "text": continuation_content(goal, used_percent), + "atMs": now, + })); + // 只留最近 50 条 + if requests.len() > 50 { + requests = requests.split_off(requests.len() - 50); + } + let body = json!({ "requests": requests }); + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, format!("{}\n", serde_json::to_string_pretty(&body).unwrap_or_default())) + .map_err(|e| e.to_string())?; + std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?; + + // ⚠️ 同一 epoch 的请求可能之前已被 ack 过。若不把它从 ack 列表里摘掉, + // 这条请求就会被当成「已处理」而永远不再被投递(实测踩过)。 + unack(&id); + Ok(()) +} + +/// 把某个 id 从「已处理」列表里摘掉,让它重新变成待处理。 +fn unack(id: &str) { + let path = std::env::var("HOME") + .ok() + .map(|h| PathBuf::from(h).join(".config/mcode-reset-card/goal-resume-acked.json")); + let Some(path) = path else { return }; + let Ok(text) = std::fs::read_to_string(&path) else { return }; + let Ok(v) = serde_json::from_str::(&text) else { return }; + let mut acked: Vec = v + .get("acked") + .and_then(|a| a.as_array()) + .map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect()) + .unwrap_or_default(); + let before = acked.len(); + acked.retain(|x| x != id); + if acked.len() == before { + return; + } + let tmp = path.with_extension("json.tmp"); + if std::fs::write( + &tmp, + format!("{}\n", serde_json::to_string_pretty(&json!({ "acked": acked })).unwrap_or_default()), + ) + .is_ok() + { + let _ = std::fs::rename(&tmp, &path); + } +} + +fn request_path() -> Option { + std::env::var("HOME") + .ok() + .map(|h| PathBuf::from(h).join(".config/mcode-reset-card/goal-resume-requests.json")) +} + +/// 已处理(acked)的请求 id 集合。 +pub fn acked_ids() -> std::collections::BTreeSet { + let path = std::env::var("HOME") + .ok() + .map(|h| PathBuf::from(h).join(".config/mcode-reset-card/goal-resume-acked.json")); + let Some(path) = path else { return Default::default() }; + let Ok(text) = std::fs::read_to_string(&path) else { return Default::default() }; + let Ok(v) = serde_json::from_str::(&text) else { return Default::default() }; + v.get("acked") + .and_then(|a| a.as_array()) + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default() +} + +/// 恢复请求的时效上限:超过这么久还没被处理就作废。 +/// +/// 目的有两个: +/// 1. 避免投递端为一个叫不醒的目标无限期空转; +/// 2. 避免「会话根本叫不醒」时,请求永远挂着、状态永远显示「待处理」。 +pub const RESUME_REQUEST_TTL_MS: i64 = 30 * 60 * 1000; + +/// 请求文件里还没被处理的 id(且未过期)。 +pub fn pending_request_ids() -> std::collections::BTreeSet { + let path = std::env::var("HOME") + .ok() + .map(|h| PathBuf::from(h).join(".config/mcode-reset-card/goal-resume-requests.json")); + let Some(path) = path else { return Default::default() }; + let Ok(text) = std::fs::read_to_string(&path) else { return Default::default() }; + let Ok(v) = serde_json::from_str::(&text) else { return Default::default() }; + let acked = acked_ids(); + let now = now_ms(); + v.get("requests") + .and_then(|r| r.as_array()) + .map(|list| { + list.iter() + .filter_map(|x| { + let id = x.get("id").and_then(|i| i.as_str())?.to_string(); + let at = x.get("atMs").and_then(|a| a.as_i64()).unwrap_or(0); + if acked.contains(&id) { return None; } + if now - at > RESUME_REQUEST_TTL_MS { return None; } + Some(id) + }) + .collect() + }) + .unwrap_or_default() +} + +pub fn enqueue_continuation( + db: &PathBuf, + goal: &WakeTarget, + used_percent: f64, +) -> Result { + let item_id = format!("queue_{}", random_uuid()); + let user_message_id = format!("msg-user-v1-{}", random_uuid().replace('-', "")); + let turn_id = format!("turn_{}", random_uuid()); + // 必须是 `thread-goal-followup:` 前缀 —— 运行时靠它识别「这是目标续跑项」。 + // 用的是运行时自己的 rearm 键形状:同一 epoch 内重试幂等,换 epoch 自然是新键。 + let client_request_id = format!( + "{CONTINUATION_PREFIX}rearm:{}:{}", + goal.goal_id, goal.goal_updated_at_ms + ); + + let message = json!({ + "content": continuation_content(goal, used_percent), + "attachments": [], + "hideUserMessage": true, + "displayAttachments": [], + "origin": { + "type": "thread-goal-continuation", + "goalId": goal.goal_id, + "goalUpdatedAt": goal.goal_updated_at_ms, + "objectiveDigest": objective_digest(&goal.objective), + "kind": "active", + }, + }); + let model = json!({ + "provider_id": goal.model.provider_id, + "model_id": goal.model.model_id, + "reasoning": goal.model.reasoning, + "context_limit": goal.model.context_limit, + "thinking": goal.model.thinking.as_ref().map(|t| json!({"effort": t.effort})), + "parameterSnapshot": goal.model.parameter_snapshot.clone().unwrap_or(json!({"context":"selection","effort":"selection"})), + }); + let data = json!({ + "itemId": item_id, + "userMessageId": user_message_id, + "sessionId": goal.session_id, + "agentName": goal.agent_name, + "source": "thread-goal", + "status": "queued", + "message": message, + "model": model, + "createdAt": now_ms(), + "requestedTurnId": turn_id, + "clientRequestId": client_request_id, + }); + + // ⚠️ 必须**先查后插**,不能只靠 `INSERT OR IGNORE`: + // 真实运行库里 `idx_local_runtime_queue_items_session_client_request` **不是唯一索引** + // (迁移源文件里写的是 UNIQUE,但库里实际是普通 CREATE INDEX)。 + // 实测:同 crid 反复插会插出 6 条重复项,把目标会话的队列堵死, + // 结果 `session send` 只能排队、起不了回合。 + let dup = format!( + "SELECT COUNT(*) FROM local_runtime_queue_items\n\ + WHERE session_id = '{sid}' AND client_request_id = '{crid}';", + sid = goal.session_id.replace('\'', "''"), + crid = client_request_id, + ); + if let Ok(out) = crate::goal_sync::exec_sql(db, &dup) { + let n: i64 = out + .trim() + .trim_start_matches('[') + .trim_end_matches(']') + .trim() + .parse() + .unwrap_or(0); + if n > 0 { + // 已经有同 epoch 的续跑项在队列里,别再塞一份 + return Ok(false); + } + } + + // 同一目标的**旧 epoch** 残留项也要清掉:它们永远不会被消费(epoch 对不上会被 + // 准入判据 cancel),却会一直占着队列位置。 + let stale = format!( + "DELETE FROM local_runtime_queue_items\n\ + WHERE session_id = '{sid}' AND source = 'thread-goal'\n\ + AND client_request_id LIKE 'thread-goal-followup:rearm:{gid}:%'\n\ + AND client_request_id <> '{crid}';", + sid = goal.session_id.replace('\'', "''"), + gid = goal.goal_id.replace('\'', "''"), + crid = client_request_id, + ); + let _ = crate::goal_sync::exec_sql(db, &stale); + + // client_request_id 上有唯一索引,重复投递会被数据库挡住 ⇒ 天然幂等。 + let sql = format!( + "INSERT OR IGNORE INTO local_runtime_queue_items\n\ + (session_id, item_id, status, created_at_ms, data_json, source, client_request_id, routing_fingerprint)\n\ + VALUES ('{sid}', '{item}', 'queued', {now}, '{data}', 'thread-goal', '{crid}', '{fp}');", + sid = goal.session_id.replace('\'', "''"), + item = item_id, + now = now_ms(), + data = data.to_string().replace('\'', "''"), + crid = client_request_id, + fp = ROUTING_FINGERPRINT_GOAL, + ); + crate::goal_sync::exec_sql(db, &sql)?; + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sha256_matches_known_vector() { + // 空串的 sha256 是公认常量 + assert_eq!( + objective_digest(""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_eq!( + objective_digest("abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn objective_digest_matches_runtime() { + // 2026-10-19 实测:tg_im1jszv4sjmuoen4o4 的 objective 摘要 + assert_eq!( + objective_digest("你帮我继续完成剩余的内容"), + "e0a1d0f4cd6d33780a781ef5802c6344f0cbfa4afdf6619fb76b2eebd4aa4eb4" + ); + } + + #[test] + fn model_snapshot_comes_from_session_extra() { + let extra = r#"{"effectiveModel":"minimax/MiniMax-M3.1-Flash-Preview", + "effectiveModelVariant":"thinking", + "effectiveModelThinking":{"effort":"max"}, + "effectiveModelContextWindow":1000000}"#; + let m = model_from_session_extra(extra).expect("应能解析"); + assert_eq!(m.provider_id, "minimax"); + assert_eq!(m.model_id, "MiniMax-M3.1-Flash-Preview"); + assert!(m.reasoning); + assert_eq!(m.context_limit, 1_000_000); + assert_eq!(m.thinking.unwrap().effort, "max"); + } + + #[test] + fn uuid_is_unique_looking() { + let a = random_uuid(); + let b = random_uuid(); + assert_ne!(a, b); + assert_eq!(a.len(), 36); + assert!(a.chars().all(|c| c.is_ascii_hexdigit() || c == '-')); + } +} diff --git a/plugins/hylouis233/expiry-guardian/daemon/rust/src/main.rs b/plugins/hylouis233/expiry-guardian/daemon/rust/src/main.rs new file mode 100644 index 0000000..e3cc2e4 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/rust/src/main.rs @@ -0,0 +1,821 @@ +//! 后台守护进程:额度探测 + 先过期先用决策 + 自动扣卡。 +//! +//! 与 Mini App「蹬王之王」的判定逻辑严格同构,由 `verify-parity.mjs` +//! 逐字比对「动作 + 理由文本」防止漂移。 +//! +//! 设计要点: +//! - 分层缓存:额度每轮取,积分/卡/汇总按各自变化速度缓存 +//! - 先记账再请求:崩溃或重启都不会重复扣同一张卡 +//! - 幂等键:用服务端下发的 out_biz_no,重试安全 + +mod api; +mod config; +mod decision; +mod bot_bootstrap; +mod bot_guard; +mod goal_sync; +mod goal_wake; +mod state; + +use std::collections::{BTreeMap, HashSet}; +use std::time::Duration; + +use api::{Client, ApiError, BASE_CN}; +use decision::{ + amount, decide, fmt_day, parse_quotas, Action, DecisionCfg, QuotaState, RemainsPayload, + UsableCard, QUOTA_FIVE_HOUR, +}; +use serde_json::json; + +/// 「投递空转」的判定门槛:待唤醒请求的积压条数连续多少轮没变就告警。 +/// +/// 取 15 轮:巡检间隔 60 秒 ⇒ 约 15 分钟。 +/// ⚠️ 不能更短 —— 一轮唤醒要给每个 bot 起一个 agent 回合, +/// 实测一批要跑几分钟,期间积压条数确实不会变(成功一个才 ack 一个)。 +const STUCK_STREAK_ROUNDS: u32 = 15; + +/// 把 Unix 毫秒格式化成「10月19日 19:41」这样的本地时间。 +/// +/// 用来在日志里写明「5 小时窗口什么时候刷新」—— +/// 之前没有这一项,导致窗口刷新时间只能靠猜 +/// (2026-10-19 实测:以为 19:41 会刷新,实际到 19:54 仍是 100%)。 +fn fmt_reset_at(ms: i64) -> String { + if ms == 0 { + return "未知".to_string(); + } + let secs = ms.div_euclid(1000); + let (m, d) = decision::local_md(secs); + let (h, mi) = decision::local_hm(secs); + if m == 0 { + return "未知".to_string(); + } + format!("{m}月{d}日 {h:02}:{mi:02}") +} + +/// 日志:带本地时间戳,便于 tail 追踪。 +pub fn log(msg: &str) { + use std::io::Write; + let now = api::now_ms(); + let secs = now.div_euclid(1000); + let ms = now.rem_euclid(1000); + // 复用 decision 里的本地时区换算 + let (month, day) = decision::local_md(secs); + let (hh, mm) = decision::local_hm(secs); + let _ = std::io::stdout().write_all( + format!("[{month:02}-{day:02} {hh:02}:{mm:02}.{ms:03}] {msg}\n").as_bytes(), + ); +} + +/// 各数据源的缓存 TTL(毫秒)。按变化速度分配,不是所有数据都值得每轮取。 +struct Ttl { + quota: i64, + code_credits: i64, + cards: i64, + platform: i64, +} + +impl Default for Ttl { + fn default() -> Self { + // quota TTL=0:额度是所有联动的触发源,必须每轮看。 + // 其余按「多久会变」给:积分随消耗变、卡按天签到变、平台池极少变。 + Ttl { quota: 0, code_credits: 60_000, cards: 300_000, platform: 300_000 } + } +} + +struct Cache { + slots: BTreeMap<&'static str, (Option, i64)>, +} + +impl Cache { + fn new() -> Self { + Cache { slots: BTreeMap::new() } + } + fn get(&mut self, key: &'static str, ttl: i64) -> Option { + let entry = self.slots.get(key).cloned(); + if let Some((Some(v), at)) = entry { + if api::now_ms() - at < ttl { + return Some(v); + } + } + None + } + fn put(&mut self, key: &'static str, value: T) { + self.slots.insert(key, (Some(value), api::now_ms())); + } + fn invalidate(&mut self, key: &str) { + self.slots.remove(key); + } +} + +struct Caches { + quota: Cache, + code: Cache, + platform: Cache, + cards: Cache>, +} + +impl Caches { + fn new() -> Self { + Caches { + quota: Cache::new(), + code: Cache::new(), + platform: Cache::new(), + cards: Cache::new(), + } + } +} + +struct Daemon { + client: Client, + ttl: Ttl, + cache: Caches, + /// 上次报告过的「待唤醒请求积压」条数。 + /// + /// 用来**只告警一次**(不刷屏):积压条数增加 ⇒ 状态又变了,值得再说一句; + /// 条数没变 ⇒ 上一条告警仍然有效,不必重复。 + last_reported_pending: usize, + /// 「投递空转」观测器:运行时在跑但积压条数一直不减少。 + stuck: bot_bootstrap::StuckWatch, + /// 上次报告过的自检错误(同一错误只说一次,避免每 60 秒刷一行)。 + last_selfcheck_error: Option, +} + +/// 取缓存:命中则零网络,未命中才请求。 +/// +/// 做成自由函数(而非 `&mut self` 方法):这样 `cache` 与 `client` +/// 可以分别借用,不会触发「同一个 self 被可变借两次」。 +fn cached( + client: &mut Client, + cache: &mut Cache, + key: &'static str, + ttl: i64, + load: impl FnOnce(&mut Client) -> Result, +) -> Result { + if let Some(v) = cache.get(key, ttl) { + return Ok(v); + } + let v = load(client)?; + cache.put(key, v.clone()); + Ok(v) +} + +impl Daemon { + fn new(client: Client) -> Self { + Daemon { + client, + ttl: Ttl::default(), + cache: Caches::new(), + last_reported_pending: 0, + stuck: bot_bootstrap::StuckWatch::new(), + last_selfcheck_error: None, + } + } + + /// 读额度。**读不到就如实报错,绝不返回空数组冒充「额度充足」。** + /// + /// 旧实现在失败时返回 `vec![]`,而 `decide()` 对空额度会判成 + /// 「额度充足(已用 0%)」——于是 API 一挂,日志照样显示一切正常, + /// Goal 同步也被 `find()` 静默跳过,整个功能无声失效。 + fn quotas(&mut self) -> Result, ApiError> { + let ttl = self.ttl.quota; + let Daemon { client, cache, .. } = self; + let v = cached(client, &mut cache.quota, "quota", ttl, |c| { + c.get("/backend/account/token_plan/remains_percent", false) + })?; + let payload: RemainsPayload = serde_json::from_value(v).unwrap_or(RemainsPayload { + model_remains: vec![], + }); + let qs = parse_quotas(&payload); + if qs.is_empty() { + // 响应本身是 200,但里面没有可解析的额度项 —— 同样按失败处理, + // 否则「空额度」会被后续逻辑当成「额度充足」。 + return Err(ApiError::Business( + "额度接口返回了空列表,无法判断 5 小时额度".into(), + )); + } + Ok(qs) + } + + /// 全部分页拉取重置卡 + fn all_cards(&mut self) -> Vec { + let ttl = self.ttl.cards; + if let Some(v) = self.cache.cards.get("cards", ttl) { + return v; + } + let mut seen: HashSet = HashSet::new(); + let mut all = Vec::new(); + for page in 1..=10 { + match self + .client + .get(&format!("/backend/account/reset_card/list?page={page}&size=20"), false) + { + Ok(v) => { + let cards = v.get("cards").and_then(|c| c.as_array()).cloned().unwrap_or_default(); + if cards.is_empty() { + break; + } + let n = cards.len(); + for c in cards { + if let Some(id) = c.get("card_id").and_then(|x| x.as_str()) { + if seen.insert(id.to_string()) { + all.push(c); + } + } + } + if n < 20 { + break; + } + } + Err(e) => { + log(&format!(" 重置卡列表读取失败(忽略): {e}")); + break; + } + } + } + self.cache.cards.put("cards", all.clone()); + all + } + + /// 可用于扣卡的卡:未过期 + 有 out_biz_no + 有 source_type,按到期排序 + fn usable_cards(&mut self) -> Vec { + let now = api::now_ms(); + let mut out: Vec = self + .all_cards() + .iter() + .filter_map(|c| { + let expire = c.get("expire_time").and_then(|v| v.as_i64())?; + if expire <= now { + return None; + } + let out_biz_no = c.get("out_biz_no").and_then(|v| v.as_str()).unwrap_or(""); + let source_type = c.get("source_type").and_then(|v| v.as_str()).unwrap_or(""); + if out_biz_no.is_empty() || source_type.is_empty() { + return None; + } + Some(UsableCard { + card_id: c.get("card_id")?.as_str()?.to_string(), + source_type: source_type.to_string(), + out_biz_no: out_biz_no.to_string(), + expire_time: expire, + }) + }) + .collect(); + out.sort_by_key(|c| c.expire_time); + out + } + + /// 全账户里最早到期的一笔「仍有效且有余额」的积分(两个来源取更早的) + fn earliest_credit(&mut self) -> (Option, String, bool) { + let now = api::now_ms(); + let mut best: Option = None; + let mut desc = String::new(); + // 积分数据是否**完整读到**。 + // ⚠️ 2026-10-19 PR #28 review 修:读不到 ≠ 没有。 + // 以前两个来源读失败只是记日志(`best` 保持 None),于是 + // `decide` 收到 `credit_expiry = None` ⇒ 直接走「没有积分 ⇒ 用卡」。 + // 扣卡是真实写操作,网络故障不该导致误扣 ⇒ 任一来源读失败即视为不完整。 + let mut complete = true; + + let mut consider = |exp: Option, balance: f64, label: &str| { + let (Some(e), true) = (exp, balance > 0.0) else { return }; + if e <= now { + return; + } + if best.is_none_or(|b| e < b) { + best = Some(e); + desc = label.to_string(); + } + }; + + // 平台积分池 + let pttl = self.ttl.platform; + let platform_result = { + let Daemon { client, cache, .. } = self; + cached(client, &mut cache.platform, "platform", pttl, |c| { + c.get("/backend/account/token_plan_credit", false) + }) + }; + match platform_result { + Ok(v) => { + if let Some(buckets) = v + .get("balance_breakdown") + .and_then(|b| b.get("buckets")) + .and_then(|b| b.as_array()) + { + for b in buckets { + let label = match b.get("source").and_then(|s| s.as_i64()) { + Some(2) => "平台积分·充值".to_string(), + Some(3) => "平台积分·赠送".to_string(), + _ => "平台积分·未知".to_string(), + }; + consider( + b.get("expire_time_ms").and_then(|v| v.as_i64()), + amount(b.get("balance").unwrap_or(&json!(0))), + &label, + ); + } + } + } + Err(e) => { + complete = false; + log(&format!(" 平台积分读取失败(本轮不做写操作): {e}")) + } + } + + // Code 积分明细 + let cttl = self.ttl.code_credits; + let raw = { + let Daemon { client, cache, .. } = self; + cached(client, &mut cache.code, "code", cttl, |c| { + c.get( + "/minimax-cloud/api/v1/credit/details?page_number=1&page_size=100&include_expired=1", + true, + ) + }) + }; + match raw { + Ok(v) => { + if let Some(details) = v.get("details").and_then(|d| d.as_array()) { + for item in details { + let label = if item.get("credit_type").and_then(|c| c.as_i64()) == Some(2) { + "Code 积分·赠送" + } else { + "Code 积分·购买" + }; + consider( + item.get("expire_at_ms").and_then(|v| v.as_i64()), + amount(item.get("remaining_amount").unwrap_or(&json!(0))), + label, + ); + } + } + } + Err(e) => { + complete = false; + log(&format!(" Code 积分读取失败(本轮不做写操作): {e}")) + } + } + + (best, desc, complete) + } + + /// 一轮:判定 + 必要时扣卡 + fn tick(&mut self, dry_run: bool, cfg: &config::Config) -> Result<(Action, String, bool), ApiError> { + // 额度读不到 = 不知道现在该不该停目标、该不该补卡。 + // 此时**任何动作都是瞎猜**,所以整轮中止,绝不按「额度充足」往下走。 + let quotas = match self.quotas() { + Ok(q) => q, + Err(e) => { + log(&format!( + "⛔ 额度读取失败,本轮中止(未做任何暂停/恢复/扣卡): {e}" + )); + return Err(e); + } + }; + let usable = self.usable_cards(); + let (credit_expiry, credit_desc, credit_data_complete) = self.earliest_credit(); + + // ── 第 0 步:守护 MiniMax Bot 团队(失败绝不拖垮任何东西)── + // 与 Goal 暂停/恢复互不依赖:bot 挂掉和额度是两回事。 + // + // ⚠️ **catch_unwind 兜底**(2026-10-20 二轮独立审计 F2): + // 这一层要读用户聊天记录(`state.json`,几 MB),什么奇怪的字节都可能进来。 + // 一个 panic 会带走**整个守护进程**(额度判定、Goal 唤醒、bot 看门狗一起死), + // 而 launchd 重启后同样崩 ⇒ 崩溃循环。 + // ⇒ 这里兜住:panic 只影响 bot 看门狗这一件事,其它功能照常。 + if cfg.bot_guard.enabled { + let guard = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + bot_guard::tick(&cfg.bot_guard) + })); + let result = match guard { + Ok(r) => r, + Err(_) => { + log("⚠️ Bot 守护本轮 panic(已兜住,不影响额度判定与 Goal 唤醒)"); + Ok((0, 0, 0, 0)) + } + }; + match result { + Ok((checked, self_healing, needs_user, revived)) => { + if needs_user > 0 || self_healing > 0 { + log(&format!( + "Bot 看门狗:检查 {checked} 个,待人工 {needs_user} 个,可自愈 {self_healing} 个,本轮投递 {revived} 个" + )); + } + } + Err(e) => log(&format!("⚠️ Bot 守护本轮出错(不影响其它功能): {e}")), + } + } + + // ── 第 0.5 步:唤醒链路自检 ── + // + // 唤醒的执行方是蹬王之王自己的 MiniApp(它有 agentAccess,能起回合)。 + // 但 MiniApp 是 on-demand 的,**宿主重启后不会自动拉起它** ⇒ + // 那段时间里「判定」照常写请求、「投递」却没人消费。 + // + // ⚠️ 这里**不做自动修复** —— 2026-10-20 逐条实测过,从宿主外拉起 MiniApp + // 的每一条路都堵死(队列写入不被消费 / loopback 凭据只在宿主进程 / + // 宿主 HTTP 无此接口 / deeplink 只导航页面 / 官方插件不可改版)。 + // 详见 `bot_bootstrap.rs` 的完整对照表。 + // + // ⇒ 这里只做**如实告警**:宁可吵,也不能静默地一个 bot 都没叫醒。 + if cfg.bot_guard.enabled { + let pending = bot_guard::pending_request_ids().len(); + match bot_bootstrap::check(pending) { + bot_bootstrap::SelfCheck::NeedsAttention { pending } => { + // 只在「从有到无」的那一刻说一次,避免每分钟刷屏。 + if pending > self.last_reported_pending { + log(&format!( + "⚠️ Bot 看门狗:{pending} 个待唤醒请求投不出去 —— 蹬王之王面板的 MiniApp 运行时没在跑(多半是宿主重启后没被拉起)。请打开一次「蹬王之王」面板,它一恢复就会把积压的请求全部补上。" + )); + } + self.last_reported_pending = pending; + // 运行时没起时,「投递空转」是已知原因 ⇒ 观察器复位,不重复报。 + self.stuck.observe(0, STUCK_STREAK_ROUNDS); + } + bot_bootstrap::SelfCheck::Delivering { pending } => { + // 运行时在跑 ⇒ 恢复提示是准确的。 + if self.last_reported_pending > 0 { + log("Bot 看门狗:运行时已恢复,积压的唤醒请求正在补上"); + } + self.last_reported_pending = 0; + // 接着看「运行时在跑但请求没被消费」的空转情形。 + match self.stuck.observe(pending, STUCK_STREAK_ROUNDS) { + bot_bootstrap::StuckEvent::None | bot_bootstrap::StuckEvent::Started => {} + bot_bootstrap::StuckEvent::Recovered => { + // ⚠️ 别说过头(2026-10-20 二轮独立审计 F6): + // 积压减少也可能是「请求超龄被清掉」,**不一定**是消费端恢复。 + // ⇒ 只说事实:积压在减少。 + log("Bot 看门狗:待唤醒请求的积压条数在减少(此前报告的投递空转可能已解除)"); + } + bot_bootstrap::StuckEvent::Stuck { pending, rounds } => { + log(&format!( + "⚠️ Bot 看门狗:运行时在跑,但 {pending} 个待唤醒请求已连续 {rounds} 轮巡检(约 {rounds} 分钟)没被消费 —— 唤醒循环可能已经空转。请打开「蹬王之王」面板看唤醒日志。" + )); + } + } + } + bot_bootstrap::SelfCheck::Idle => { + // ⚠️ 这里**不能**说「运行时已恢复」—— + // pending 归零只说明「没有待办」,不代表运行时起来了。 + if self.last_reported_pending > 0 { + log("Bot 看门狗:积压的待唤醒请求已清空"); + } + self.last_reported_pending = 0; + self.stuck.observe(0, STUCK_STREAK_ROUNDS); + } + bot_bootstrap::SelfCheck::Failed(e) => { + // ⚠️ **只说一次**(2026-10-20 独立审计 F3): + // 自检失败是「判据失效」,可能持续很久 + // (比如 lsof 一直报错)⇒ 不去重就是每天 1440 行噪音。 + if self.last_selfcheck_error.as_deref() != Some(e.as_str()) { + self.last_selfcheck_error = Some(e.clone()); + log(&format!("⚠️ Bot 看门狗自检失败(不影响其它功能): {e}")); + } + } + } + } + + // ── 第 1 步:同步 Goal(失败不拖垮扣卡)── + // 顺序固定为「先让目标停下,再决定要不要补额度」,反过来两者会打架。 + if cfg.goal_pause.enabled { + match quotas.iter().find(|q| q.quota_type == QUOTA_FIVE_HOUR) { + Some(five) => { + let r = goal_sync::sync_with_quota( + cfg, + five.used, + five.total, + five.is_unlimited, + ); + let tag = if r.paused > 0 { + "Goal 暂停" + } else if r.resumed > 0 { + "Goal 恢复" + } else { + "Goal" + }; + log(&format!("{tag}: {}", r.message)); + } + // 以前这里是静默跳过:开关开着、额度列表里却没有 5 小时项, + // 目标该停不停,日志一个字都没有。现在必须喊出来。 + None => log("⚠️ Goal 自动暂停已开启,但本轮额度里没有「5 小时限额」,本轮未做暂停/恢复"), + } + } + + let d = decide( + "as, + &usable, + credit_expiry, + &credit_desc, + &cfg.decision_cfg(), + api::now_ms(), + credit_data_complete, + ); + log(&format!( + "额度: {}", + quotas.iter().map(|q| q.describe()).collect::>().join(" | ") + )); + // 距 5 小时窗口刷新还有多久 —— 这决定了「还能不能再等到额度自己回来」。 + // 之前日志里没有这一项,导致「窗口到底什么时候刷新」只能靠猜 + // (2026-10-19 实测:以为 19:41 会刷新,实际到 19:54 仍是 100%)。 + if let Some(five) = quotas.iter().find(|q| q.quota_type == decision::QUOTA_FIVE_HOUR) { + match decision::minutes_to_reset(Some(five), api::now_ms()) { + Some(m) => log(&format!( + "5 小时窗口: {m:.0} 分钟后刷新(预计 {})", + fmt_reset_at(five.reset_time_ms.unwrap_or(0)) + )), + None => log("5 小时窗口: 额度接口未提供刷新时间"), + } + } + log(&format!("决策[{}]: {}", d.action.as_str(), d.reason)); + + if d.action != Action::UseCard || dry_run { + return Ok((d.action, d.reason, false)); + } + let Some(card) = usable.first() else { + return Ok((d.action, d.reason, false)); + }; + + let fingerprint = format!("{}:{}", card.card_id, card.out_biz_no); + if state::was_spent(&fingerprint) { + log("这张卡本进程已尝试过,跳过"); + return Ok((d.action, d.reason, false)); + } + + // 先记账再请求:崩溃/重启都不会重复扣 + state::mark_spent(&fingerprint, &card.card_id, &card.source_type); + self.cache.cards.invalidate("cards"); + + match self + .client + .post( + "/backend/account/reset_card/use", + &json!({ "source_type": card.source_type, "out_biz_no": card.out_biz_no }), + false, + ) { + Ok(v) => { + let outcome = v.get("outcome").and_then(|x| x.as_str()).unwrap_or("UNKNOWN"); + let ok = outcome == "SUCCESS" || outcome == "REPLAYED"; + if ok { + log(&format!("✓ 已使用重置卡 {},额度已重置", card.card_id)); + } else { + log(&format!("✗ 重置卡不可用({outcome})")); + } + Ok((d.action, d.reason, ok)) + } + Err(e) => { + log(&format!("✗ 扣卡失败:{e}")); + Ok((d.action, d.reason, false)) + } + } + } +} + +/// 校验模式:读入与 Node/Python 同一份用例 JSON,打印同样的结果结构。 +/// +/// 供 `verify-parity.mjs` 调用,让三方(Mini App / Python / Rust)跑同一组输入。 +/// 不联网、不落盘,纯函数比对。 +fn selftest() { + use std::io::Read as _; + let mut input = String::new(); + if std::io::stdin().read_to_string(&mut input).is_err() { + eprintln!("selftest: 无法读取 stdin"); + std::process::exit(2); + } + let payload: serde_json::Value = match serde_json::from_str(&input) { + Ok(v) => v, + Err(e) => { + eprintln!("selftest: JSON 解析失败 {e}"); + std::process::exit(2); + } + }; + let now = payload["now"].as_i64().unwrap_or(0); + let day = payload["day"].as_i64().unwrap_or(86_400_000); + // 三方必须喂同一个阈值,否则比的不是同一件事 + let trigger = payload["trigger"].as_f64().unwrap_or(99.0); + let tight = payload["tight"].as_f64().unwrap_or(80.0); + let low = payload["low"].as_f64().unwrap_or(95.0); + let skip = payload["skipBeforeResetMinutes"].as_f64().unwrap_or(60.0); + let cfg = DecisionCfg { + card_trigger_percent: trigger, + tight, + low, + card_skip_before_reset_minutes: skip, + }; + let mut out = Vec::new(); + + for c in payload["cases"].as_array().cloned().unwrap_or_default() { + let mk = |t: i64, used: f64, total: f64, unlimited: bool| QuotaState { + quota_type: t, + total, + used, + is_unlimited: unlimited, + reset_time_ms: None, + }; + let five_used = c["five"].as_f64().unwrap_or(0.0); + let week_used = c["week"].as_i64(); + // 每个用例可选带 resetInMinutes:距 5 小时额度刷新还有多少分钟 + let five_reset = c["resetInMinutes"] + .as_f64() + .map(|m| now + (m * 60_000.0) as i64); + let mut quotas = vec![ + mk(1, five_used, 100.0, false), + match week_used { + None => mk(2, 0.0, 100.0, true), + Some(w) => mk(2, w as f64, 100.0, false), + }, + ]; + quotas[0].reset_time_ms = five_reset; + let cards: Vec = c["cards"] + .as_array() + .cloned() + .unwrap_or_default() + .iter() + .filter_map(|v| { + let d = v.as_i64()?; + Some(UsableCard { + card_id: format!("C{d}"), + source_type: "mcode_signin".into(), + out_biz_no: format!("b{d}"), + expire_time: now + d * day, + }) + }) + .collect(); + let credit = c["credit"].as_i64().map(|d| now + d * day); + + // 「积分数据是否完整」由用例给出(默认 true)—— + // parity 校验器用它比对三方在**数据不完整**时是否都拒绝写操作。 + let complete = c + .get("creditDataComplete") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let d = decide("as, &cards, credit, "平台积分", &cfg, now, complete); + let five_level = quotas[0].level(tight, low); + out.push(serde_json::json!({ + "n": c["n"].as_str().unwrap_or(""), + "action": d.action.as_str(), + "reason": d.reason, + "level": five_level.as_str(), + })); + } + println!("{}", serde_json::to_string(&out).unwrap_or_default()); +} + +fn main() { + // 「无限重试」必须可中断:装上 SIGINT / SIGTERM 处理器, + // 让 launchctl 能优雅停掉正卡在重试里的守护进程。 + api::install_signal_handlers(); + let args: Vec = std::env::args().collect(); + if args.iter().any(|a| a == "--selftest") { + selftest(); + return; + } + // 自检:写一条唤醒请求(不直接投递)。 + // 投递由 minimax-bot 的 MiniApp 用 `agent.run` 完成 —— 守护进程在 Host 之外, + // 没有对 bot 会话的归属权限,裸写队列项也不会被消费(2026-10-20 实测)。 + if let Some(i) = args.iter().position(|a| a == "--selftest-bot-revive") { + let Some(session_id) = args.get(i + 1) else { + eprintln!("用法: --selftest-bot-revive "); + std::process::exit(2); + }; + let bot_name = args.get(i + 2).cloned().unwrap_or_else(|| "Bot".to_string()); + let bot = bot_guard::BotSession { + bot_id: format!("bot-selftest-{bot_name}"), + bot_name: bot_name.clone(), + session_id: session_id.clone(), + chat_id: format!("bot:bot-selftest-{bot_name}"), + }; + match bot_guard::enqueue_revive_request( + &bot, + "【看门狗自检】请简短确认收到这条续跑消息。", + ) { + Ok(true) => println!("已写入唤醒请求:{session_id}"), + Ok(false) => println!("已在待办里,跳过:{session_id}"), + Err(e) => { + eprintln!("写请求失败:{e}"); + std::process::exit(1); + } + } + return; + } + + let mut interval: f64 = 60.0; + let mut dry_run = false; + let mut cmd = "watch".to_string(); + let mut config_path: Option = None; + + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--interval" if i + 1 < args.len() => { + interval = args[i + 1].parse().unwrap_or(60.0); + i += 2; + } + "--config" if i + 1 < args.len() => { + config_path = Some(std::path::PathBuf::from(&args[i + 1])); + i += 2; + } + "--base" if i + 1 < args.len() => i += 2, + "--dry-run" => { + dry_run = true; + i += 1; + } + "watch" | "once" | "status" | "forget" => { + cmd = args[i].clone(); + i += 1; + } + _ => i += 1, + } + } + + if cmd == "forget" { + state::clear_spent(); + println!("已清空本地去重记录。"); + return; + } + + // 每轮重读:改配置无需 `launchctl unload/load` + let load_cfg = || match &config_path { + Some(p) => config::load(p), + None => config::load_or_default(), + }; + let (cfg, issues) = load_cfg(); + for s in &issues { + log(&format!("⚠️ {s}")); + } + + let client = match Client::new(BASE_CN) { + Ok(c) => c, + Err(e) => { + eprintln!("初始化失败:{e}"); + std::process::exit(1); + } + }; + let mut daemon = Daemon::new(client); + + if cmd == "once" { + match daemon.tick(dry_run, &cfg) { + Ok(_) => {} + Err(e) => { + eprintln!("失败:{e}"); + std::process::exit(1); + } + } + return; + } + + log(&format!( + "启动后台监控,间隔 {interval}s(Ctrl+C 退出);扣卡触发线 {:.0}%,额度分界 {:.0}%/{:.0}%", + cfg.card_trigger_percent, cfg.quota_levels.tight, cfg.quota_levels.low + )); + log("扣卡策略:与 Mini App「蹬王之王」一致 —— 按先过期先用"); + if cfg.goal_pause.enabled { + log(&format!( + "Goal 自动暂停:开(到 {:.0}% 暂停,回落到 {:.0}% 恢复)", + cfg.goal_pause.trigger_percent, cfg.goal_pause.resume_at_percent + )); + } else { + log("Goal 自动暂停:关"); + } + // 连续失败计数:单轮失败可能是网络抖动,连着失败就是「目标该停却没停」的 + // 危险状态,必须逐级喊出来,而不是每轮都被当成平常事刷过去。 + let mut fails: u32 = 0; + loop { + // 收到终止信号 ⇒ 立刻退出(可能在重试循环里被置位) + if api::should_stop() { + log("收到终止信号,守护进程退出"); + return; + } + // 轮内重读配置,页面改完下一轮就生效 + let (cfg, issues) = load_cfg(); + for s in &issues { + log(&format!("⚠️ {s}")); + } + match daemon.tick(dry_run, &cfg) { + Ok(_) => { + if fails > 0 { + log(&format!("✓ 已恢复,本轮正常(此前连续失败 {fails} 轮)")); + } + fails = 0; + } + Err(e) if e.is_unauthorized() => { + log(&format!( + "✗ 鉴权失败:{e}。请确认 MiniMax Code 桌面端已启动并处于登录状态。" + )); + std::process::exit(1); + } + Err(e) => { + fails += 1; + if fails == 5 { + log("🔴 连续失败 5 轮:Goal 自动暂停当前处于**失效**状态(该停的目标不会停)。"); + } else if fails % 30 == 0 { + log(&format!("🔴 已连续失败 {fails} 轮,Goal 自动暂停仍未恢复工作。")); + } else { + log(&format!("⚠️ 本轮失败(连续 {fails} 轮):{e},下一轮继续")); + } + } + } + // 抖动,避免与整点/整 5 小时重置撞在一起 + let jitter = (api::now_ms() % 1000) as f64 / 1000.0 * 4.0 - 2.0; + std::thread::sleep(Duration::from_millis(((interval + jitter) * 1000.0) as u64)); + } +} diff --git a/plugins/hylouis233/expiry-guardian/daemon/rust/src/state.rs b/plugins/hylouis233/expiry-guardian/daemon/rust/src/state.rs new file mode 100644 index 0000000..7d4dd6b --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/rust/src/state.rs @@ -0,0 +1,95 @@ +//! 去重记录的持久化。 +//! +//! 落盘而非只放内存:后台进程会被 launchd 重启,只靠内存的话 +//! 重启后会把同一张卡再扣一遍。先写盘再请求,崩溃也不重复。 + +use std::collections::BTreeSet; +use std::path::PathBuf; + +fn state_dir() -> Option { + let home = std::env::var("HOME").ok()?; + let dir = PathBuf::from(home).join(".config/mcode-reset-card"); + Some(dir) +} + +fn state_file() -> Option { + Some(state_dir()?.join("bg-state.json")) +} + +fn read_set() -> BTreeSet { + let Some(path) = state_file() else { + return BTreeSet::new(); + }; + let Ok(text) = std::fs::read_to_string(&path) else { + return BTreeSet::new(); + }; + serde_json::from_str::(&text) + .ok() + .and_then(|v| v.get("spent").cloned()) + .and_then(|s| serde_json::from_value::>(s).ok()) + .map(|v| v.into_iter().collect()) + .unwrap_or_default() +} + +fn write_set(set: &BTreeSet) { + let Some(dir) = state_dir() else { return }; + let Some(path) = state_file() else { return }; + if std::fs::create_dir_all(&dir).is_err() { + return; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)); + } + let json = serde_json::json!({ + "spent": set.iter().collect::>(), + "updatedAtMs": crate::api::now_ms(), + }); + // 原子替换:先写临时文件再 rename,避免半截文件 + let tmp = path.with_extension("json.tmp"); + if std::fs::write(&tmp, serde_json::to_string_pretty(&json).unwrap_or_default()).is_err() { + return; + } + if std::fs::rename(&tmp, &path).is_err() { + let _ = std::fs::remove_file(&tmp); + return; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); + } +} + +pub fn was_spent(fingerprint: &str) -> bool { + read_set().contains(fingerprint) +} + +pub fn mark_spent(fingerprint: &str, card_id: &str, source_type: &str) { + let mut set = read_set(); + if set.insert(fingerprint.to_string()) { + write_set(&set); + } + crate::log(&format!("已记账:{card_id} ({source_type})")); +} + +pub fn clear_spent() { + if let Some(path) = state_file() { + let _ = std::fs::remove_file(path); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn set_roundtrip() { + let mut s = BTreeSet::new(); + s.insert("a:1".to_string()); + s.insert("b:2".to_string()); + assert!(s.contains("a:1")); + assert!(!s.contains("c:3")); + } +} diff --git a/plugins/hylouis233/expiry-guardian/daemon/verify-parity.mjs b/plugins/hylouis233/expiry-guardian/daemon/verify-parity.mjs new file mode 100644 index 0000000..624ed35 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/daemon/verify-parity.mjs @@ -0,0 +1,251 @@ +#!/usr/bin/env node +/** + * 三方一致性校验器:确保三处扣卡决策永远一致。 + * + * Mini App(Node) packages/expiry-guardian/miniapp/node/server.mjs → decide() + * 后台旧版(Python) ~/mcode-reset-card-autouse/reset_card_autouse.py → decide_background() + * 后台新版(Rust) ~/mcode-reset-card-autouse/rust/src/decision.rs → decide() + * + * 三者是三门语言、两套运行路径。任何一侧改动都可能悄悄漂移, + * 扣卡决策漂移 = 真金白银的损失,必须逐字比对「动作 + 理由文本」。 + * + * 用法: + * node verify-parity.mjs # 比对三者 + * node verify-parity.mjs --write # 顺便刷新快照 + */ +import { readFileSync, writeFileSync, existsSync, mkdtempSync, mkdirSync, copyFileSync, readdirSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { homedir, tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +// 路径解析:优先用**插件包内**的副本(自包含),找不到再回落到本机开发目录。 +// +// 2026-10-19 修:这个校验器原先硬编码 `~/Desktop/mmx/miniapps/...`, +// 于是「把插件复制到别处」就完全跑不起来 —— 违反 CONTRIBUTING 里 +// 「插件应能独立复制使用,不依赖仓库里的其他目录」这条约定。 +const WORKSPACE = process.env.MCODE_WORKSPACE ?? join(homedir(), 'Desktop', 'mmx'); +const PLUGIN_MINIAPP = join(HERE, '..', 'miniapp', 'node', 'server.mjs'); +const WORKSPACE_MINIAPP = join( + WORKSPACE, 'miniapps', 'expiry-guardian', 'miniapp', 'node', 'server.mjs', +); +const MINIAPP_SERVER = existsSync(PLUGIN_MINIAPP) ? PLUGIN_MINIAPP : WORKSPACE_MINIAPP; +// ⚠️ **必须支持 `MCODE_BG_SCRIPT` 覆盖**(2026-10-20 实测发现): +// `check-parity.sh` 的第 3 步是「校验器自检」——它把后台脚本改成一份**注入漂移**的 +// 副本,再跑校验器,要求校验器**必须报不一致**(否则说明校验器自己已经失效了)。 +// 而这里原本把路径**写死**了 ⇒ 校验器永远读的是原脚本 ⇒ 永远报一致 +// ⇒ 自检**从建立起就一直是坏的**(一直在报「校验器本身已失效」)。 +const BG_PY = process.env.MCODE_BG_SCRIPT || join(HERE, 'reset_card_autouse.py'); +const RUST_DAEMON = join(HERE, 'rust', 'target', 'release', 'reset-card-daemon'); +const SNAPSHOT = join(HERE, 'bg_parity.json'); + +for (const [label, p] of [ + ['Mini App 服务端', MINIAPP_SERVER], + ['后台服务(Python)', BG_PY], + ['后台守护(Rust 二进制)', RUST_DAEMON], +]) { + if (!existsSync(p)) { + console.error(`✗ 找不到 ${label}: ${p}`); + console.error(' Rust 版需先执行: cd rust && cargo build --release'); + process.exit(2); + } +} + +// ── 0. 固定配置:三方必须读同一份阈值 ── +// 否则校验结果会随**用户自己的 config.json** 漂移:改一次配置, +// 这个校验器就从「验一致」变成「验谁赢」了。 +// Node / Python 都用 $HOME 定位配置,所以给它们一个临时 HOME 即可。 +const TMP_HOME = mkdtempSync(join(tmpdir(), 'mcode-parity-')); +mkdirSync(join(TMP_HOME, '.config', 'mcode-reset-card'), { recursive: true }); +writeFileSync( + join(TMP_HOME, '.config', 'mcode-reset-card', 'config.json'), + JSON.stringify( + { + schemaVersion: 1, + cardTriggerPercent: 99, + cardSkipBeforeResetMinutes: 60, + quotaLevels: { tight: 80, low: 95 }, + goalPause: { enabled: false, triggerPercent: 99, resumeAtPercent: 0 }, + }, + null, + 2, + ), +); +process.env.HOME = TMP_HOME; + +// ── 1. Mini App ────────────────────────────────────────────────────────────── +const src = readFileSync(MINIAPP_SERVER, 'utf8').replace(/export async function start[\s\S]*$/, ''); +// ⚠️ 不能用 data: URL 导入 —— Mini App 的 server.mjs 现在有相对 import +// (./bot_wakeup.mjs、./mcp.mjs),data: URL 里解析不了相对路径 +// (ERR_UNSUPPORTED_RESOLVE_REQUEST)。 +// ⇒ 改成把 server.mjs 复制成一份「只保留导出前部分」的临时 .mjs 文件再 import。 +// +// ⚠️ **必须把同目录的兄弟模块一起复制过去**(2026-10-20 实测踩到): +// 原来只复制了 `bot_wakeup.mjs`,而 server.mjs 后来又 import 了 `./mcp.mjs` +// ⇒ ERR_MODULE_NOT_FOUND ⇒ **这道三方一致性校验一直在报错、根本没在跑** +// (文档里「一致性是测出来的,不是声称的」就成了空话)。 +// ⇒ 改成:把 MiniApp 目录下的**所有 .mjs** 都复制过去,server.mjs 再 import 哪个都找得到。 +const modDir = mkdtempSync(join(tmpdir(), 'expiry-parity-')); +const modFile = join(modDir, 'server-under-test.mjs'); +writeFileSync(modFile, src + '\nexport { decide };'); +// 依赖文件跟 server.mjs 同目录(server.mjs 用相对路径 import 它们)。 +for (const f of readdirSync(dirname(MINIAPP_SERVER))) { + if (f.endsWith('.mjs') && f !== 'server.mjs') { + copyFileSync(join(dirname(MINIAPP_SERVER), f), join(modDir, f)); + } +} +const mod = await import(modFile); +const miniDecide = mod.decide; + +// ── 2. Python ──────────────────────────────────────────────────────────────── +const pyScript = ` +import importlib.util, json, sys +spec = importlib.util.spec_from_file_location("bg", ${JSON.stringify(BG_PY)}) +m = importlib.util.module_from_spec(spec); sys.modules["bg"] = m; spec.loader.exec_module(m) +payload = json.load(sys.stdin) +NOW, DAY = payload["now"], payload["day"] +out = [] +for c in payload["cases"]: + def qs(t, u, tot=100.0, ul=False): + return m.QuotaState(quota_type=t, total=tot, used=u, is_unlimited=ul) # triggered 是 property + quotas = [qs(1, c["five"])] + quotas.append(qs(2, 0, 100.0, True) if c["week"] is None else qs(2, c["week"])) + if c.get("resetInMinutes") is not None: + quotas[0].reset_time = NOW + int(c["resetInMinutes"] * 60_000) + cards = [{"card_id": f"C{d}", "out_biz_no": f"b{d}", "source_type": "mcode_signin", + "expire_time": NOW + d * DAY} for d in c["cards"]] + exp = None if c["credit"] is None else NOW + c["credit"] * DAY + cfg, _issues = m.load_config() + action, reason = m.decide_background( + quotas, cards, exp, "平台积分", cfg, NOW, c.get("creditDataComplete", True) + ) + out.append({"n": c["n"], "action": action, "reason": reason}) +json.dump(out, sys.stdout, ensure_ascii=False) +`; + +// ── 3. Rust:用一个隐藏的 --selftest 模式跑同一组用例 ──────────────────────── +const DAY = 86_400_000; +const NOW = Date.now(); + +const TRIGGER = 99; +const mq = (type, used, total = 100, unlimited = false) => ({ + quota_type: type, + total, + used, + is_unlimited: unlimited, + // decide() 的 idle 分支会读 usedPercent 生成理由文案 + usedPercent: used, + totalPercent: total, + remainingPercent: Math.max(0, (total - used) / total) * 100, + exhausted: !unlimited && total > 0 && used >= total, + unlimited, + triggered: !unlimited && total > 0 && used >= (total * TRIGGER) / 100, + resetTimeMs: NOW + 30 * DAY, +}); +const mcard = (d) => ({ cardId: `C${d}`, sourceType: 'mcode_signin', outBizNo: `b${d}`, expireTimeMs: NOW + d * DAY, expired: false }); +const mcredit = (d) => ({ source: 'platform', label: '平台积分', balance: 500, expireTimeMs: NOW + d * DAY }); + +/** 覆盖 decide() 每一个分支 */ +const CASES = [ + { n: '未耗尽 / 卡7 / 积13', five: 50, week: null, cards: [7], credit: 13 }, + { n: '未耗尽 / 无卡 / 积13', five: 0, week: null, cards: [], credit: 13 }, + { n: '耗尽 / 卡先过期', five: 100, week: null, cards: [7], credit: 13 }, + { n: '耗尽 / 积分先过期', five: 100, week: null, cards: [20], credit: 5 }, + { n: '耗尽 / 无卡 / 有积分', five: 100, week: null, cards: [], credit: 13 }, + { n: '耗尽 / 有卡 / 无积分', five: 100, week: null, cards: [7], credit: null }, + { n: '耗尽 / 无卡 / 无积分', five: 100, week: null, cards: [], credit: null }, + { n: '周限额耗尽 / 5h未耗尽', five: 50, week: 100, cards: [7], credit: 13 }, + { n: '周限额耗尽 / 积分先过期', five: 50, week: 100, cards: [20], credit: 5 }, + { n: '周限额未耗尽 / 5h未耗尽', five: 50, week: 50, cards: [7], credit: 13 }, + { n: '5h 98% 未到触发线', five: 98, week: null, cards: [7], credit: 13 }, + { n: '5h 99% 触发提前动作', five: 99, week: null, cards: [7], credit: 13 }, + { n: '5h 100% 边界算耗尽', five: 100, week: null, cards: [1], credit: 60 }, + // 「额度马上要刷新就不烧卡」这一组:三方必须给出同一个结论 + { n: '5h 99% / 2小时后刷新', five: 99, week: null, cards: [7], credit: 13, resetInMinutes: 120 }, + { n: '5h 99% / 10分钟后刷新', five: 99, week: null, cards: [7], credit: 13, resetInMinutes: 10 }, + { n: '5h 99% / 恰好60分钟后刷新', five: 99, week: null, cards: [7], credit: 13, resetInMinutes: 60 }, + { n: '5h 99% / 61分钟后刷新', five: 99, week: null, cards: [7], credit: 13, resetInMinutes: 61 }, + // 「读不到 ≠ 没有」:积分数据不完整时,三方都必须**不做写操作**。 + // ⚠️ 2026-10-19 PR #28 review 修:以前读失败被当成「没有积分」, + // 结论会从「留卡」翻转成「用卡」——而扣卡是真实写操作。 + { n: '积分数据不完整 / 耗尽 / 有卡', five: 100, week: null, cards: [7], credit: null, creditDataComplete: false }, + { n: '积分数据不完整 / 耗尽 / 有卡有积分', five: 100, week: null, cards: [7], credit: 13, creditDataComplete: false }, +]; + +const miniResults = CASES.map((c) => { + + const five = mq(1, c.five); + five.triggered = !five.is_unlimited && five.total > 0 && five.used >= five.total * TRIGGER / 100; + if (c.resetInMinutes !== undefined) five.resetTimeMs = NOW + c.resetInMinutes * 60_000; + const week = c.week === null ? mq(2, 0, 100, true) : mq(2, c.week); + week.triggered = !week.is_unlimited && week.total > 0 && week.used >= week.total * TRIGGER / 100; + const d = miniDecide({ + platformPools: c.credit === null ? [] : [mcredit(c.credit)], + codeCredits: [], + cards: c.cards.map(mcard), + quota: { fiveHour: five, weekly: week }, + creditDataComplete: c.creditDataComplete !== false, + }, NOW); + return { n: c.n, action: d.action, reason: d.reason }; +}); + +const payload = JSON.stringify({ + now: NOW, day: DAY, + trigger: TRIGGER, tight: 80, low: 95, skipBeforeResetMinutes: 60, + cases: CASES.map((c) => ({ + n: c.n, five: c.five, week: c.week, cards: c.cards, credit: c.credit, + resetInMinutes: c.resetInMinutes, creditDataComplete: c.creditDataComplete !== false, + })), +}); + +const pyResults = JSON.parse( + execFileSync('/usr/bin/python3', ['-c', pyScript], { + input: payload, + encoding: 'utf8', + maxBuffer: 8 << 20, + env: { ...process.env, HOME: TMP_HOME }, + }), +); + +// Rust:用 --selftest 读同一份 JSON,打印同样结构 +const rustResults = JSON.parse( + execFileSync(RUST_DAEMON, ['--selftest'], { input: payload, encoding: 'utf8', maxBuffer: 8 << 20 }), +); + +// Mini App 用 use-credit,Python 用 keep-credit —— 语义相同,仅命名不同 +const NORMALIZE = { 'use-credit': 'keep-credit' }; +const norm = (a) => NORMALIZE[a] ?? a; + +let failed = 0; +console.log( + '用例'.padEnd(28) + 'MiniApp'.padEnd(13) + 'Python'.padEnd(13) + 'Rust'.padEnd(13) + '一致', +); +console.log('-'.repeat(80)); +for (let i = 0; i < CASES.length; i += 1) { + const a = miniResults[i]; + const b = pyResults[i]; + const c = rustResults[i]; + const actionOk = norm(a.action) === norm(b.action) && norm(a.action) === norm(c.action); + const reasonOk = a.reason === b.reason && a.reason === c.reason; + const ok = actionOk && reasonOk; + if (!ok) failed += 1; + console.log(a.n.padEnd(26) + a.action.padEnd(13) + b.action.padEnd(13) + c.action.padEnd(13) + (ok ? '✓' : '✗')); + if (!ok) { + if (!actionOk) console.log(` 动作不一致: MiniApp=${a.action} Python=${b.action} Rust=${c.action}`); + if (!reasonOk) { + console.log(` MiniApp: ${a.reason}`); + console.log(` Python : ${b.reason}`); + console.log(` Rust : ${c.reason}`); + } + } +} +console.log('-'.repeat(80)); +console.log(`${CASES.length - failed}/${CASES.length} 三方完全一致(动作 + 理由文本逐字)`); + +if (process.argv.includes('--write')) { + writeFileSync(SNAPSHOT, JSON.stringify({ at: new Date().toISOString(), results: miniResults }, null, 2)); + console.log(`快照已刷新: ${SNAPSHOT}`); +} + +process.exit(failed === 0 ? 0 : 1); diff --git a/plugins/hylouis233/expiry-guardian/docs/BOT_DIAGNOSIS.md b/plugins/hylouis233/expiry-guardian/docs/BOT_DIAGNOSIS.md new file mode 100644 index 0000000..b19f8c6 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/docs/BOT_DIAGNOSIS.md @@ -0,0 +1,748 @@ +# Bot 看门狗 · 唤醒通道实测排查手册 + +> 诊断日期:2026-10-19 首版 / **2026-10-20 重写为「唤醒通道排查手册」** / +> **2026-10-20 晚间补上 v1.5 的新判据(acked 时间窗、attempts 独立文件、401 自愈)** / +> **2026-10-20 夜补上 v1.6:现象 J(读名单失败 ⇒ 整个看门狗静默失效)与现象 K(投递空转)**。 +> 对象:`MiniMax-Code-MiniApps` 里的 `plugins/minimax-ai/minimax-bot`。 +> +> 这份手册回答一个问题:**「bot 挂掉了,为什么没被叫醒?」** +> 按「**现象 → 查什么表/什么日志 → 怎么判断**」的格式写,每一条判据都来自实测。 + +--- + +## 零、先跑这 7 步(90% 的问题在这里面) + +```bash +cd ~/.config/mcode-reset-card + +# ① 判定有没有发生? —— 看守护进程日志 +grep "Bot 看门狗" /tmp/mcode-reset-card.log | tail -20 + +# ② 请求写进去了吗? —— 待投递的请求 +cat bot-revive-requests.json + +# ③ 投递方处理了吗? —— 已处理的请求(带时间戳,只抑制 30 分钟) +cat bot-revive-acked.json + +# ③' 试了几次? —— 尝试次数(满 3 次就作废;**独立文件**) +cat bot-revive-attempts.json + +# ③'' 唤醒宿主会话 —— 复用的会话 id(复用失败会退回新建) +cat bot-wake-host-session.json + +# ④ 目标会话起回合了吗? —— 权威判据 +sqlite3 ~/.minimax/v2/sqlite/runtime-state.sqlite \ + "SELECT turn_id,status,source,datetime(accepted_at_ms/1000,'unixepoch','localtime') acc + FROM local_runtime_turn_ingress WHERE session_id='' + ORDER BY accepted_at_ms DESC LIMIT 5;" + +# ⑤ MiniApp 进程活着吗? —— 投递方是它,不是守护进程 +ps aux | grep -i "expiry-guardian\|server.mjs" | grep -v grep +``` + +| 走到哪一步断了 | 去 [一、四段链路](#一唤醒通道的完整链路) | +| --- | --- | +| ① 没有「Bot 看门狗」这行 | 判定层 ⇒ [现象 A](#现象-a日志里根本没有bot-看门狗这行) | +| ① 有、但 ② 文件里没有 | 写文件失败 ⇒ [现象 A](#现象-a日志里根本没有bot-看门狗这行) | +| ② 有、③ 没有 | 投递方没跑 ⇒ [现象 B](#现象-b请求文件里有但acked-里没有) | +| ② 有、③ 有、③' 次数 ≥ 3 | **试满作废** ⇒ [现象 H](#现象-hbot-又坏了却不再被叫醒) | +| ② 有、③ 有、③' 次数 < 3 | 投递方在跑但没成功 ⇒ [现象 B](#现象-b请求文件里有但acked-里没有) / [现象 D](#现象-d回合起来了但状态是-failed) | +| ③ 有、但 bot 还是没反应 | 抑制窗口内 ⇒ [现象 H](#现象-hbot-又坏了却不再被叫醒) | +| ③ 有、④ 没有新回合 | 消息没进目标会话 ⇒ [现象 C](#现象-cacked-里有但目标会话没起新回合) | +| ④ 有、状态 `failed` | 回合本身失败 ⇒ [现象 D](#现象-d回合起来了但状态是-failed) | +| ④ 有、`completed`,bot 却像没收到 | bot 侧没接茬 ⇒ [现象 E](#现象-e回合completed但bot-像是没收到) | +| 面板显示「实时连接中断」 | 客户端 401 ⇒ [现象 I](#现象-i面板显示实时连接中断) | + +--- + +## 一、唤醒通道的完整链路 + +唤醒要经过**四段**。每一段都有独立的证据,断在哪段就在哪段查。 + +``` +┌─ ① 判定 ─────────────────────────────────────────────────────────┐ +│ Rust 守护进程(Host 之外 · launchd · 无 token) │ +│ · 读 bot 会话状态 + 最后活动时间 │ +│ · classify_error() 分「可自愈 / 需人工」 │ +│ · 可自愈 ⇒ 写 bot-revive-requests.json │ +│ 证据:日志「Bot 看门狗:<名> 因暂时性错误(<文案>)挂掉,已写入唤醒请求」 │ +│ 请求文件里出现 id = bot-revive:: │ +└───────────────────────────────────────────────────────────────────┘ + │ 文件交接 + ▼ +┌─ ② 读取 ─────────────────────────────────────────────────────────┐ +│ 蹬王之王自己的 MiniApp(Host 之内 · 60 秒一轮) │ +│ · 读 bot-revive-requests.json │ +│ · 跳过:① 30 分钟内已 ack(ACK_SUPPRESS_WINDOW_MS) │ +│ ② 已试满 3 次(MAX_ATTEMPTS,计数在独立文件里) │ +│ ③ 超过 24 小时(MAX_REQUEST_AGE_MS,只是兜底) │ +│ ④ id 不合法 │ +│ 证据:ack 表不含该 id(或已超出 30 分钟窗口), │ +│ attempts 次数 < 3,且请求文件里它还没超 24 小时 │ +└───────────────────────────────────────────────────────────────────┘ + ▼ +┌─ ③ 起回合 ───────────────────────────────────────────────────────┐ +│ · agent.run({ text: 「用 mavis 工具,command="session send"…」 })│ +│ · 这个回合的会话 agent_name = 'mavis' ⇒ 天然带桌面端 mavis 工具 │ +│ · ⚠️ v1.5:复用 bot-wake-host-session.json 里的宿主会话 │ +│ (不带 sessionId = 每次新建 = 冷启动 ≈93k;复用只付增量) │ +│ 证据:local_runtime_sessions 里多出一条 purpose='miniapp:expiry-guardian'│ +│ 的会话,且它有 1 个 turn(复用后就是同一个会话多一个 turn) │ +│ ⚠️ 代价:冷启动实测 ≈ 93k tokens(input 42k / output 374 / cache read 51k)│ +└───────────────────────────────────────────────────────────────────┘ + ▼ +┌─ ④ 送达 ─────────────────────────────────────────────────────────┐ +│ · 回合里的 agent 调 mavis 工具的 session send │ +│ · session send 内部走 turns.steer(),与官方 session send 同一条路 │ +│ · 成功 ⇒ bot-revive-acked.json 记 {id, atMs}(抑制 30 分钟) │ +│ · 失败 ⇒ bot-revive-attempts.json 记 {n, at}(+1,满 3 次作废) │ +│ 证据:目标会话 local_runtime_turn_ingress 多出一条新 turn │ +│ 目标会话 local_runtime_message_rows 多出一条 role='user' 的 │ +│ 「【看门狗】上一个回合因内部错误中断…」 │ +└───────────────────────────────────────────────────────────────────┘ +``` + +> 💡 **一条经验**:**第 ④ 段之前的一切都只是「准备」**。 +> 只有目标会话真的多了一条 turn,才叫「唤醒了」。 + +--- + +## 二、必须知道的三条底层事实 + +这三条是 2026-10-19 / 2026-10-20 实测出来的,**解释了上面每一处「为什么」**。 + +### 事实 1:唤醒必须走 `session send`,裸写队列项无效 + +**隔离实验**:往一个挂着 50113 的 bot 会话直接写一条 `local_runtime_queue_items` 行, +3 分钟后仍是 `queued`、未 claim、无 turn、无消息; +**同一会话**用官方 `session send`,5 秒内 accepted 并被 bot 回复。 + +⇒ **Host 的队列轮询器只认经过 steer 的行**。绕开 steer 塞行 = 白写。 + +⚠️ **反向陷阱**:**队列表的状态列不能当「有没有被送达」的判据**。 +实测 5 行 `session send` 写进去的行,**送达成功后仍然停在 `status='queued'`、`claim_id=NULL`**, +而对应的回合确实跑完并回复了。 +真正的送达记录在 `data_json.deliveryAttempts[]` 里: + +```sql +-- 送达过的行:有 deliveryAttempts +SELECT session_id, status, IFNULL(claim_id,''), + json_array_length(json_extract(data_json,'$.deliveryAttempts')) AS attempts +FROM local_runtime_queue_items +WHERE session_id='' ORDER BY created_at_ms DESC; +``` + +- `attempts > 0` ⇒ 有人 claim 过 ⇒ 起过回合 +- `attempts = 0`(或没有这个键)⇒ **没人 claim ⇒ 没起过回合** + +### 事实 2:不能直接 `agent.run` 续别的 Mini App 的会话 + +`service/miniapp/agent/gateway.ts` 的 `assertSessionOwnership`(第 828 行): + +```ts +if (owner !== caller.pluginId) { + throw new MiniAppAgentError('SESSION_NOT_FOUND', 'Mini App Agent session not found'); +} +``` + +实测库里 **45 个** bot 会话的 `purpose` 全是 `miniapp:minimax-bot` +⇒ 蹬王之王对它们直接 `agent.run` **永远被拒**(实测每 30 秒 6 条全拒)。 + +### 事实 3:`session send` 不做 owner 检查 ⇒ 这是唯一能用的绕法 + +`local-runtime/dist/runtime/mavis-session-send.js` 的准入注释原文: + +> Reachability is the whole access model: the caller identity comes from the +> runtime context, the target must be resolvable through the canonical Session +> query, and everything after that is TurnSystem admission. **There is no +> topology check** — a task child, a sibling, a root Session and the caller's +> own Session all take the same path. + +⇒ 任何拿到 mavis 工具的会话都能唤醒任意 bot 会话。 +而 MiniApp 的 `agent.run`(**不带** `sessionId`)建的会话 `agent_name = 'mavis'` +⇒ 那个 agent 天然带桌面端 mavis 工具。 + +⇒ **结论:能用的路径是「起一个自己的 agent 回合,让回合里的 agent 用 mavis 工具的 `session send`」。** + +> 💡 **v1.5 补充**:这个「自己的 agent 回合」现在**复用同一个宿主会话** +> (id 存在 `bot-wake-host-session.json`)—— +> 宿主会话的 `agent_name` 同样是 `'mavis'`,照样带 mavis 工具。 +> 复用只是省掉冷启动(≈93k tokens),**不影响**「能越权」这件事。 + +--- + +## 三、排查手册 + +### 现象 A:日志里根本没有「Bot 看门狗」这行 + +**查什么** + +```bash +# 守护进程还活着吗? +launchctl list | grep reset-card +tail -20 /tmp/mcode-reset-card.log +tail -20 /tmp/mcode-reset-card.err + +# bot 会话本身的状态 +sqlite3 ~/.minimax/v2/sqlite/runtime-state.sqlite \ + "SELECT session_id,status,IFNULL(error_code,'-'),IFNULL(error_message,'') + FROM local_runtime_sessions WHERE purpose='miniapp:minimax-bot' AND error_code IS NOT NULL;" +``` + +**怎么判断** + +| 观察到 | 结论 | +| --- | --- | +| 守护进程没在跑 | `launchctl kickstart -k "gui/$(id -u)/com.local.mcode-reset-card"` | +| 守护进程在跑,但**没有任何 bot 会话带错误码** | **没有 bot 挂掉**。这是正常的 —— 44 个 bot 会话里有 29 个是「无错误码、无错误消息」,它们只是闲置很久 | +| 有 bot 带错误码,但日志没提它 | 看 `botGuard.enabled` 是否为 `false`(配置被误抹 ⇒ 看门狗静默失效,无任何报错) | +| 错误码是 **42212 / 50110 / 50112** | **这是设计如此**:需人工,不自动重试。日志里会有一行「需要你处理」 | +| 错误码是 **50113 / 50150 / 50151** | 应该出现「因暂时性错误挂掉」那行。没有 ⇒ 判定层被跳过,看 `botGuard.idleMs`(默认 90 秒)是否被调得过大 | +| **v1.6 补**:日志里**从来没有**「检查 N 个」这行,但 sqlite 里明明有几十个 bot 会话 | 🔴 **判定层根本没进去** —— 读 bot 名单那一步失败了。最常见的原因:minimax-bot 的 `state.json` 里有**被截断的代理对转义**,Rust 的 `serde_json` 严格拒绝 ⇒ `read_bot_sessions()` 返回空 ⇒ 整个看门狗静默失效。v1.6 已用 `parse_lenient_json()` 修;见[现象 J](#现象-j日志里从来没有检查-n-个这行) | + +--- + +### 现象 J:日志里从来没有「检查 N 个」这行 + +**症状**:守护进程在跑,`sqlite` 里明明有几十个 `purpose='miniapp:minimax-bot'` 的会话, +但 `/tmp/mcode-reset-card.log` 里**从来没有**「Bot 看门狗:检查 N 个,待人工 M 个,可自愈 K 个」。 +(这条汇总行**只在判定层真的跑起来**时才会出现。) + +**查什么** + +```bash +# bot 名单文件在不在、多大 +ls -la ~/.minimax/v2/plugin-data/liveboards/minimax-bot/state.json + +# Rust 能不能严格解析它(这正是 v1.6 修的那个 bug) +python3 - <<'PY' +import json, pathlib +p = pathlib.Path.home()/".minimax/v2/plugin-data/liveboards/minimax-bot/state.json" +raw = p.read_text() +try: + json.loads(raw); print("严格 JSON:OK") +except Exception as e: + print("严格 JSON:失败 ->", e) +PY + +# 找孤立的高代理转义(合法代理对 = \udXXX 后面跟着低代理) +grep -c $'\\ud[89abAB][0-9a-fA-F][0-9a-fA-F]' ~/.minimax/v2/plugin-data/liveboards/minimax-bot/state.json +``` + +**怎么判断** + +| 观察到 | 结论 | +| --- | --- | +| 严格 JSON 失败,但 Python 报错里提到 `surrogate` / `Unicode` | 🔴 就是这个坑。**v1.6 已修**(`parse_lenient_json()`:只把**孤立的高代理**换成 U+FFFD,合法代理对原样保留)⇒ 升级到 v1.6 | +| 文件不存在 / 读不到 | 「读不到 ≠ 没有」:守护进程会跳过这一轮,**不会**因此把别的东西判成「有错」 | +| 严格 JSON 没问题,但日志还是没有汇总行 | 看 `botGuard.enabled`;再看守护进程是不是刚重启还没跑完第一轮(启动后第一轮有几秒延迟) | + +**⚠️ 这个坑的教训**:Python 的 `json.loads` 与 Node 的 `JSON.parse` 都能解析, +**只有 Rust 的 `serde_json` 严格拒绝** ⇒ 跨语言读同一份 JSON, +**必须让最严格的那个先过**,否则会出现「一边好一边坏」的诡异现象。 + +--- + +### 现象 B:请求文件里有,但 acked 里没有 + +**查什么** + +```bash +cat ~/.config/mcode-reset-card/bot-revive-requests.json # 待投递 +cat ~/.config/mcode-reset-card/bot-revive-acked.json # 已处理 +``` + +**怎么判断** + +| 观察到 | 结论 | 怎么办 | +| --- | --- | --- | +| 请求的 `atMs` 距今 **超过 24 小时** | **兜底过期**,投递方会直接跳过(`MAX_REQUEST_AGE_MS`) | 守护进程下一轮会重新写一条新的(同一个 id 会被覆盖),等 60 秒 | +| 请求的 `atMs` 距今不到 24 小时,但 `bot-revive-attempts.json` 里它的 `n >= 3` | **试满作废**(`MAX_ATTEMPTS`)⇒ 投递方直接跳过 | 这个 bot 叫不醒(或投递端坏了)。清掉 attempts 记录才能再试 | +| `sessionId` 不是 `mvs_` 开头 | 投递方会判为非法请求并记 `botWakeFailed` | 查 minimax-bot 的 `state.json` 里会话映射 | +| MiniApp 进程**没在跑** | **投递方不在** ⇒ 永远不会被处理 | 打开 MCode 的「蹬王之王」面板,进程会被 `ensureMiniAppStarted` 按需拉起 | +| MiniApp 在跑,但面板是**刚打开的** | 巡检是 **60 秒**一轮 | 等 60 秒再看 | +| 请求 id 在 acked 里、`atMs` 距今**不到 30 分钟** | 投递方会把「已 ack 的 id」**整个跳过**(抑制窗口内)⇒ 这条请求**不会被再投一次** | 等窗口过去(30 分钟)后守护进程会重写;或手工清 acked | +| 请求 id 在 acked 里、`atMs` 距今**超过 30 分钟** | **不在抑制窗口内** ⇒ 允许再救 | 正常。若 bot 还是没反应,看 [现象 H](#现象-hbot-又坏了却不再被叫醒) | +| 目标会话**正卡在一个没结束的回合**里 | `session send` 只能**排队**,不会打断 | 等那个回合结束,或在 MCode 里手动开一次 | + +> ⚠️ **attempts 为什么不在 requests 文件里**(2026-10-20 审计发现): +> 投递端(Node)与守护进程(Rust)会**并发**写同一个 requests 文件。 +> 如果投递端做「读全量 → 改字段 → 写回全量」,就会把守护进程刚写进去的条目**一起抹掉** +> (典型的 lost update,两次写可能只隔几毫秒)。 +> ⇒ 尝试次数**单独存一个文件**(`bot-revive-attempts.json`),两边各写各的,永不互相覆盖。 +> +> ⇒ 查「为什么这个 bot 一次都没被试过」时,**先看 attempts 文件**,不要只看 requests。 + +> ⚠️ **同一个 bot 可能被投递两次**(实测,见第四节):请求 id 是 +> `bot-revive::`,**同一个 bot+会话恒定**; +> 而 ack 是**每投递成功一个就立刻写**(`runRound()` 循环内 `appendAcked([req.id])`, +> 不是整批跑完才写)⇒ 窗口只有「这一个 bot 的回合」那么长(10~60 秒)。 +> 守护进程在这段窗口里会把它当成「还没处理」,写一条**同 id** 的新请求。 +> ⇒ 看到同一个 bot 收到两条「【看门狗】」**不是 bug**,bot 侧会按「从中断处继续」处理, +> 不会重复交付。**但它会多烧一个 agent 回合**(复用会话后是增量,冷启动才是 ≈93k)。 + +> ⚠️ **投递方现在有重入保护**:v1.4 时 `setInterval(60s)` 直接调 `botWakeTick()`, +> 没有「上一轮还在跑」的闸门 ⇒ 一批 7 个请求、每个回合 10–60 秒 ⇒ 单轮就超过 60 秒 +> ⇒ 下一轮会**并发**再跑一次,两轮读到同一份请求文件 ⇒ 重复投递 + 重复烧 token。 +> 实测 2026-10-20 00:48:51–00:50:44(113 秒)内起了 **14 个**唤醒回合, +> 而这一轮只有 **7 个** bot 需要唤醒。 +> ⇒ 现在 `tick()` 有 `inFlight` 锁:上一轮还在跑就**整轮跳过**(返回 `skipped: 'in-flight'`)。 +> ⇒ 查「为什么 token 涨得比预期快」时,仍要先看这个(历史数据仍可能留在库里)。 + +> 💡 **确认 MiniApp 进程**:它是投递方。守护进程活着 ≠ 有人投递。 + +--- + +### 现象 C:acked 里有,但目标会话没起新回合 + +**查什么** + +```bash +# 目标会话最近的回合 +sqlite3 ~/.minimax/v2/sqlite/runtime-state.sqlite \ + "SELECT turn_id,status,source,datetime(accepted_at_ms/1000,'unixepoch','localtime') acc, + datetime(completed_at_ms/1000,'unixepoch','localtime') done + FROM local_runtime_turn_ingress WHERE session_id='' + ORDER BY accepted_at_ms DESC LIMIT 5;" + +# 目标会话最近的消息(看「【看门狗】」有没有作为 user 消息落进去) +sqlite3 ~/.minimax/v2/sqlite/runtime-state.sqlite \ + "SELECT role,datetime(created_at_ms/1000,'unixepoch','localtime') t,substr(data_json,1,160) + FROM local_runtime_message_rows WHERE session_id='' + ORDER BY created_at_ms DESC LIMIT 6;" +``` + +**怎么判断** + +| 观察到 | 结论 | +| --- | --- | +| `turn_ingress` 里**没有新 turn**,但 `message_rows` 里**有**「【看门狗】」 | 消息进去了,回合没起来 ⇒ 查该会话是不是正忙(`status='started'`) | +| `turn_ingress` 里没有新 turn,`message_rows` 也没有 | **根本没送达**。回查第 ④ 段的 `deliveryAttempts`(见事实 1) | +| 有新 turn 但 `status='accepted'` 且 `completed_at_ms` 为空 | 回合**还在跑**。回合可以跑 20 多分钟且完全正常 —— 判死活要看 `message_rows` 的 `MAX(created_at_ms)`,**不是**看 `accepted` 超了多久 | +| 有新 turn 但 `source` 不是 `turn` | 不是正常入口起的回合,多半是别的插件在动这个会话 | + +--- + +### 现象 D:回合起来了,但状态是 `failed` + +**查什么** + +```bash +sqlite3 ~/.minimax/v2/sqlite/runtime-state.sqlite \ + "SELECT status,IFNULL(error_code,'-'),IFNULL(error_message,''), + datetime(accepted_at_ms/1000,'unixepoch','localtime') acc + FROM local_runtime_sessions WHERE session_id='';" +``` + +**怎么判断** + +| `error_code` | 含义 | 是不是我们的锅 | +| --- | --- | --- | +| **50150** | TPM / RPM 限流 | ❌ **不是**。是正常业务错误 —— 唤醒本身成功了,是 bot 自己那轮被限流。**实测 2026-10-20 00:49 的 7 次唤醒里有 3 次是它** | +| **50113** | 通用 4xx/5xx 兜底(HTTP 500) | 可能是上游抖动,等下一轮退避重试 | +| **50111** | 短时限流(HTTP 429) | 上游限流,等下一轮退避重试(**v1.5 起已纳入自动唤醒**) | +| **50151** | 集群过载(HTTP 529) | 上游过载,等下一轮 | +| **42212 / 50110 / 50112** | 额度到顶 / 积分耗尽 / 登录失效 | 🔴 **需要人工**。看门狗不会自动重试这类,日志里会有「需要你处理」 | + +> 💡 **「失败」≠「没唤醒」**。回合能起来就说明投递链路是通的。 +> 失败的是 bot 自己那一轮。 + +--- + +### 现象 E:回合 `completed`,但 bot 像是没收到 + +**查什么** + +```bash +# bot 的回复正文 +sqlite3 ~/.minimax/v2/sqlite/runtime-state.sqlite \ + "SELECT role,datetime(created_at_ms/1000,'unixepoch','localtime') t,substr(data_json,1,400) + FROM local_runtime_message_rows WHERE session_id='' + AND created_at_ms > <唤醒时间戳> + ORDER BY created_at_ms LIMIT 8;" +``` + +**怎么判断** + +这是**正常且正确**的行为。看门狗发的是**续跑消息**,不是新任务: + +> 「【看门狗】上一个回合因内部错误中断,本条是自动重新启用的续跑消息。 +> 请从中断处继续,不要重做已完成的部分。」 + +实测 2026-10-20 00:49,bot 的第一条回复是: + +> 「先确认中断点在哪、且有没有新派活落进台账——不重做已交付的部分。」 + +⇒ 它**先核对**自己到底有没有半成品,再决定要不要接着干。**这是对的**: +看门狗不该替 bot 决定做什么。 +如果它回「没有中断的工作要续」,那就是它确认过确实没半成品 —— **不是唤醒失败**。 + +--- + +### 现象 F:日志被刷屏 + +**查什么** + +```bash +wc -l /tmp/mcode-reset-card.log +cat ~/.config/mcode-reset-card/bot-notified.json +``` + +**怎么判断** + +2026-10-19 实测:不加去重表时,日志**每 60 秒**把同样 9 个 bot 的同样内容各刷一遍, +**40 分钟 463 行**。 + +v1.4 已修:新增 `bot-notified.json` 去重表,**键 = `botId|错误码|错误文案`**。 +同一个 bot 的同一个错误**只提示一次**;错误一变就是新问题,值得再提示。 + +⇒ 如果你还在看到刷屏,说明去重表没写成功(权限 / 目录不存在), +或者**错误文案在变**(比如文案里带动态内容 ⇒ 每次都是新键 ⇒ 去重失效)。 + +--- + +### 现象 G:「需要你处理」一直不消失 + +**查什么** + +```bash +cat ~/.config/mcode-reset-card/bot-notified.json +``` + +**怎么判断** + +「需要你处理」是**终态提示**,不是待办队列: + +| 错误码 | 你需要做什么 | +| --- | --- | +| **42212** Token Plan 用量到顶 | 升级 Token Plan 套餐,或购买积分补充用量并**开启积分自动消耗** | +| **50110** 积分耗尽 | 充值积分 | +| **50112** 登录态失效 | 重新打开 MiniMax Code 登录 | +| `Cron definition was deleted` | 那个会话的**定时任务**被删了,需要重新安排(这是配置变更,不是崩溃) | + +处理完之后,错误码变了或会话恢复了 ⇒ 去重表里的旧键不再匹配 ⇒ 提示自然停止。 +**去重表不会自己清空**(只保留最近 200 条),这是有意的。 + +--- + +### 现象 H:bot 又坏了,却不再被叫醒 + +**查什么** + +```bash +cd ~/.config/mcode-reset-card +cat bot-revive-requests.json # 这条请求还在吗? +cat bot-revive-acked.json # 它在里面吗?atMs 是什么时候? +cat bot-revive-attempts.json # 它试了几次? +``` + +**怎么判断**(v1.5 起,请求的存活有**三个**独立判据) + +| 观察到 | 结论 | 怎么办 | +| --- | --- | --- | +| 请求在 requests 里,acked 里**没有** | 正常 ⇒ 投递方下一轮就会处理 | 等 60 秒 | +| 请求在 requests 里,acked 里**有**,`atMs` 距今 **< 30 分钟** | **抑制窗口内**(`ACK_SUPPRESS_WINDOW_MS`)⇒ 投递方跳过 | 等窗口过去。窗口是**故意**有的:60 秒巡检间隔下,同一轮不抑制会重复投递 30 次 | +| 请求在 requests 里,acked 里**有**,`atMs` 距今 **> 30 分钟** | **不在抑制窗口内** ⇒ 允许再救 | 若还是没被叫醒,说明卡在别处 ⇒ 回到 [零、先跑这 7 步](#零先跑这-7-步90-的问题在这里面) | +| 请求在 requests 里,attempts 里 `n >= 3` | **试满作废**(`MAX_ATTEMPTS`)⇒ 投递方跳过 | 查投递端为什么一直失败([现象 D](#现象-d回合起来了但状态是-failed));确认好了就清掉这条 attempts 记录 | +| 请求**不在** requests 里,attempts 里也没有 | 守护进程没写请求 | 回到 [现象 A](#现象-a日志里根本没有bot-看门狗这行) | + +> ⚠️ **v1.4 的旧症状**(已修,别再照旧排查): +> 当时 acked 是个纯 id 列表 ⇒「救过一次」变成**永久黑名单**。 +> 实测:6 个 bot 明明又坏了(闲置 47~94 分钟,远超 90 秒阈值), +> 却因为 acked 里有过它们而**永远不再被叫醒**。 +> v1.5 起 acked 带时间戳,只抑制 30 分钟;旧格式(纯字符串数组)仍能读, +> 但视为「很久以前」⇒ **不抑制**。 + +> ⚠️ **attempts 的清理条件**(2026-10-20 独立审计发现): +> **不能**按「requests 文件里还在不在」清 attempts —— +> Rust 的 prune 会把「次数用尽」的请求从 requests 文件里删掉 +> ⇒ 连带把 attempts 记录删掉 ⇒ Rust 读到 0 ⇒ 重新入队 ⇒ 再试 3 次 +> ⇒ **3 次封顶被架空,变成无限重试**(每回合 47k~93k tokens)。 +> ⇒ 现在**只按「已 ack」或「超龄」清**。 + +--- + +### 现象 I:面板显示「实时连接中断」 + +**查什么** + +```bash +# 客户端侧:连续 401 会触发整页重载,重载次数记在 sessionStorage 里 +ps aux | grep -i "expiry-guardian\|server.mjs" | grep -v grep +tail -30 /tmp/mcode-reset-card.log + +# MiniApp 的 loopback 端口有没有变(重启过就会变) +lsof -nP -iTCP -sTCP:LISTEN | grep -i node | head +``` + +**怎么判断** + +面板的「实时连接中断」是 **SSE 掉线**;真正决定能不能自愈的是后续 `fetch` +拿到的是 401 还是别的: + +| 观察到 | 结论 | 怎么办 | +| --- | --- | --- | +| 面板提示「实时连接中断,正在后台重连…」然后自己好了 | 只是 SSE 掉线,后端还活着 | 不用管 | +| 面板反复显示「实时连接中断」,怎么等都不恢复 | **401**:凭据失效 | 见下 | +| 面板提示「Mini App 已重启,正在重新载入面板…」 | 客户端已触发 `window.location.reload()` | 等重载完成 | +| 面板提示「Mini App 服务持续拒绝连接(已重载 N 次)」 | 已重载 **5** 次仍 401 ⇒ 放弃自动恢复 | 在 MiniMax Code 里**重新打开**「蹬王之王」面板 | + +**根因**(2026-10-20 实测): +MiniApp 被 Host 重启后,它的 loopback 凭据会**换新**; +而 Electron 主进程是按 **webContentsId** 注入凭据的(`miniapp-loopback-auth.js`), +页面里的 fetch 拿不到新 token ⇒ 一直 401。 +更麻烦的是 `syncMiniAppLoopbackCredential` **只在 origin 不变时才同步** +(重启后端口变了 ⇒ origin 变了 ⇒ 跳过)⇒ **光重试 fetch 无解**。 + +⇒ 唯一能拿到新凭据的途径是**重新导航**(Host 在 `refreshMiniAppTab` / +`openMiniAppTabOnce` 里同步 token)。 + +**客户端现在的做法** + +| 参数 | 值 | 作用 | +| --- | --- | --- | +| `STALE_RELOAD_THRESHOLD` | **3** | 连续 3 次 401 ⇒ `window.location.reload()` | +| `MAX_RELOADS` | **5** | 重载次数上限(sessionStorage 记,**跨页面**) | + +- 没有上限会变成「重载后仍 401 ⇒ **无限重载**」⇒ 页面反复闪,用户根本没法用。 +- **成功一次就清零**(`staleCount` 与 sessionStorage 里的计数都清)。 + +> ⚠️ **401 与唤醒是两件事**:401 挡的是**面板**,不是唤醒通道。 +> 唤醒投递方(MiniApp 进程)在 Host 内,用的是自己的凭据 ⇒ +> 面板 401 时**唤醒照样能跑**。别把两件事混在一起排查。 + +--- + +### 现象 K:待唤醒请求一直积压,积压条数却从不减少 + +**查什么** + +```bash +# 1) 积压了多少、积压多久 +python3 - <<'PY' +import json, time, pathlib +d = pathlib.Path.home()/".config/mcode-reset-card" +req = json.loads((d/"bot-revive-requests.json").read_text()).get("requests", []) +now = int(time.time()*1000) +for r in req: + print(r["id"], " age(min) =", round((now - r.get("atMs",0))/60000, 1)) +print("总条数:", len(req)) +PY + +# 2) 投递方(蹬王之王自己的 MiniApp)活着吗? +# 判据 = 有活进程 且 cwd 是本插件的运行时包 且包 package.json 的 name == expiry-guardian +lsof -nP -a -d cwd 2>/dev/null | grep mavis-miniapp +for d in /private/var/folders/*/*/T/mavis-miniapp-*/package; do + printf "%s -> " "$d"; python3 -c "import json,sys;print(json.load(open(sys.argv[1]))['name'])" "$d/package.json" 2>/dev/null || echo "(读不到)" +done + +# 3) 守护进程自己的自检告警 +grep -E "投不出去|没被消费" /tmp/mcode-reset-card.log | tail -10 +``` + +**怎么判断** + +| 观察到 | 结论 | 怎么办 | +| --- | --- | --- | +| 没有任何活进程的 cwd 在 `mavis-miniapp-*/package` 下 | 🔴 **投递方没在跑**。多半是**宿主重启后没被拉起**(宿主不会自动拉起任何 MiniApp) | **打开一次「蹬王之王」面板** ⇒ 运行时被拉起 ⇒ 积压的请求会自动全部补上 | +| 运行时在跑,但守护进程日志出现「已连续 N 轮巡检没被消费」 | 🔴 **投递空转**:唤醒循环可能已经死了 | 打开面板看唤醒日志;重启 MiniApp 运行时 | +| 运行时在跑,积压条数**在缓慢下降** | ✅ **正常**。一轮唤醒要给每个 bot 起一个 agent 回合,成功一个 ack 一个 ⇒ 条数逐步下降 | 不用管 | +| 某个请求试满 **3 次** | 该 bot 叫不醒(会话本身有问题)⇒ 请求作废 | 看 `bot-revive-attempts.json` 里该 id 的次数;`bot-revive-acked.json` 里有它说明已被处理过 | + +**⚠️ 面板关着 ≠ 唤醒停**(v1.6 实测纠正): +运行时 **44 分钟没开面板仍在跑**,唤醒循环照常投递。 +回收机制 `scheduleIdleDrain` 只在 lease 被 release 时才调用,而 lease 只在**回合期间**持有。 +真正的缺口是**宿主重启**。 + +--- + +## 四、端到端实测记录(2026-10-20 00:49) + +一次真实的完整唤醒,7 个挂掉的 bot: + +| 阶段 | 证据 | +| --- | --- | +| ① 判定 | 日志 7 行「Bot 看门狗:<名> 因暂时性错误(<文案>)挂掉,已写入唤醒请求」 | +| ② 请求 | `bot-revive-requests.json` 里 7 条,`id = bot-revive::` | +| ③ 起回合 | 唤醒窗口(00:48:51–00:50:44)内库里多出 **14 条** `purpose='miniapp:expiry-guardian'` 的会话,每条**恰好 1 个 turn**(每回合 2 次模型调用) | +| ④ 送达 | **7 个**目标会话的 `message_rows` 里都出现 `role='user'` 的「【看门狗】…」(00:49:00–00:49:57);`turn_ingress` 都多出新 turn | + +> 💡 **投递次数可以多于 bot 数**:这一轮共 **8 次**投递落在 **7 个** bot 上 +> —— `mvs_5fbb398…` 与 `mvs_7bc3db35…` 各收了 2 次(00:49:00 / 00:49:52 与 00:49:05 / 00:49:57)。 +> 原因是 ack 是**投递成功之后**才写入(现在是**每个 bot 投递成功就立刻写**), +> 守护进程在这段窗口里会把它当成「还没处理」, +> 写一条**同 id** 的新请求(`id = bot-revive::`,同一个 bot+会话恒定)。 +> ⇒ 看到同一个 bot 收到两条「【看门狗】」**不是 bug**,也不会造成重复交付 +> (bot 侧自己会按「从中断处继续」处理)。但它会**多烧一个 agent 回合**。 + +**结果分布**(以 `turn_ingress` 为准): + +| 结果 | 数量 | 说明 | +| --- | --- | --- | +| `completed` | 4 | 正常跑完并回复 | +| 仍在跑(`accepted`) | 1 | 查询时还没结束 | +| `failed` | 3 | **全部是 50150(TPM 限流)** —— 正常业务错误,不是唤醒失败 | + +**bot 真的回话了**(`mvs_7bc3db35b1ab4b1a98eeca640c45adf1`,00:49:19): + +> 「先确认中断点在哪、且有没有新派活落进台账——不重做已交付的部分。」 + +**代价**(`local_runtime_token_usage`,每个唤醒回合): +input 42,005 / output 374 / cache read 50,939 ≈ **93k tokens**。 +⇒ **只在真的有 pending 请求时才跑,绝不空转。** + +### v1.5 的实测记录(2026-10-20 02:39) + +同样是唤醒 6 个 bot,这一轮的**新建唤醒会话数为 0** —— 全部复用同一个宿主会话: + +| 项 | v1.4(每次新建会话) | v1.5(复用宿主会话) | +| --- | --- | --- | +| 唤醒 6 个 bot 新建的会话数 | **6** | **0** | +| 宿主会话 | 每轮一个 | **1 个**(`bot-wake-host-session.json` 里那个) | +| 单次唤醒 token | ≈ **93k**(冷启动) | **增量**(系统提示与工具定义进上下文缓存) | + +**库里的证据**:`purpose='miniapp:expiry-guardian'` 的会话按小时分布 —— +`2026-10-20 00` 有 **16** 条(v1.4 时期,每次唤醒新建一个), +`2026-10-20 02` 只有 **1** 条(就是那个被复用的宿主会话,它承载了 14 个 turn)。 + +⇒ **省下来的不是「一点」**:唤醒是唯一会烧大额 token 的动作, +会话数从「每 bot 一个」降到 0 才是真正的量级变化。 + +--- + +## 附录 A:错误为什么会发生(根因) + +> 这部分解释「**为什么这些 bot 会挂**」,与「为什么没被唤醒」是两件事。 + +MiniMax Bot 的会话**每报错一次就新建一个**,于是界面上表现为「bot 反复挂掉」: +每挂一次多一个会话、一条错误通知。10-17 那天几十分钟内堆了 20+ 个。 + +### 根因 1:500 被**故意**判为「不可重试」 + +`toLLMRetryDecision()`(`llm-error-classifier.ts:345`)的判定顺序: + +```ts +if (status === 429) return { retryable: true, reason: 'rate_limited' }; +if (status === 529) return { retryable: true, reason: 'overloaded' }; +return { retryable: false, reason: metricKind }; // ← 500 落这里 +``` + +而 `toLLMMetricErrorKind()` 里 `status >= 500` → `'server_error'`。 + +**⇒ HTTP 500(⇒ 50113)`retryable: false`。** +注释写得很直白: + +> Strict transport-retry policy shared by all hosts that opt into framework retry. +> Product quota, credits, auth and explicit cancellation always win over an outer +> transient-looking HTTP status. **Only the bounded allowlist below is retryable; +> generic 5xx and unknown failures deliberately fail closed.** + +可重试的白名单只有:**429 / 529 / timeout / network**。 +500 虽然是典型的暂时性错误,但**不在白名单里**。 + +### 根因 2:42212 是**产品额度上限**,重试也无用 + +`usage_limit` 在 `toLLMRetryDecision()` 的**第一个**分支就被判 `retryable: false`。 +Token Plan 额度到顶时,重试多少次都不会成功 —— 除非额度被补充(重置卡/积分)。 + +### 根因 3:minimax-bot 自己的重试只有 2 次,且窗口极短 + +`miniapp/node/engine.mjs:36`: + +```js +const TURN_RETRY_DELAYS_MS = [8000, 20000]; // 8s、20s,一共只重试 2 次 +const NO_RETRY_REASONS = new Set(['auth', 'quota', 'context_too_long', 'blocked', 'invalid_request']); +``` + +⇒ 一次失败最多 3 次尝试、跨度 28 秒。 +而 42212(额度到顶)**要等用户补充额度** —— 28 秒的重试窗口对它毫无意义; +500(服务端抖动)**有时能在 28 秒内自愈**,但 500 压根不在 Host 的可重试白名单里, +`retryable=false` ⇒ **minimax-bot 自己那 2 次重试也不会触发**。 + +### 根因 4:客户端没有「重新启用」入口 + +`miniapp/client/` 里搜不到任何 `重新启用` / `reenable` / `reactivate` 入口。 +bot 挂掉后,用户只能**手动再发一条消息**才能唤起它。 + +⇒ 这就是看门狗要填的那个洞:**复用原会话**发一条续跑消息,而不是新建会话。 + +--- + +## 附录 B:错误码全表 + +来自 `packages/shared/src/llm-error-classifier.ts` 的 `LLM_ERROR_STATUS_CODES`: + +| 码 | 含义 | 上游映射 | 看门狗判定 | +| --- | --- | --- | --- | +| **42212** | `USAGE_LIMIT_EXCEEDED` — Token Plan 用量到顶 | 2056 / 2067 | 🔴 需人工 | +| **50110** | `LLM_CREDITS_EXHAUSTED` — 积分耗尽 | 1400010161 | 🔴 需人工 | +| **50111** | `LLM_RATE_LIMITED` — 短时限流 | HTTP 429 | ✅ 可自愈(**v1.5 补上**) | +| **50112** | `LLM_AUTH_ERROR` — 鉴权失效 | HTTP 401/403 | 🔴 需人工 | +| **50113** | `LLM_UPSTREAM_ERROR` — 通用 4xx/5xx 兜底 | HTTP 500 等 | ✅ 可自愈 | +| **50150** | `LLM_TPM_RATE_LIMITED` — TPM/RPM 短时限流 | — | ✅ 可自愈 | +| **50151** | `LLM_CLUSTER_OVERLOADED` — 集群过载 | HTTP 529 | ✅ 可自愈 | + +> ✅ **50111 的缺口已补(v1.5)**:此前 HTTP 429 本身在 Host 的可重试白名单里, +> 但它映射到的 50111 **不在**看门狗的自愈码集合内 ⇒ 落进「未知错误码,重试可能无用」分支, +> 不会自动唤醒。 +> 现在 `bot_guard.rs` 的 `error_code::LLM_RATE_LIMITED` 已进 `LLM_*` 自愈码匹配里。 + +--- + +## 附录 C:读不到 ≠ 没有 + +⚠️ **2026-10-19 实测踩到的坑**:44 个 bot 会话里有 **29 个**是「无错误码、无错误消息」—— +它们只是闲置很久,**并不是挂掉**。若把这类判成可自愈,看门狗会去「重新启用」一批没坏的 bot。 + +⚠️ **错误码可能是陈旧值**:运行时**不清** `error_code`。 +2026-10-19 在量化会话上踩过:那个会话当时正在跑一个 20 多分钟的**正常长回合**, +而它的 `error_code=50113` 是上一轮 500 留下的。 + +⇒ 所以判据必须**同时**确认「最近没有新活动」: +`local_runtime_message_rows` 里该会话的 `MAX(created_at_ms)` 距今 ≥ `botGuard.idleMs`(默认 90 秒)。 +**长回合 ≠ 死锁** —— 判断死活看有没有新活动,不是看 `status='accepted'` 超了多久。 + +--- + +## 附录 D:并发写与「请求丢了」(2026-10-20 审计发现) + +守护进程(Rust,Host 之外)与投递端(Node,Host 之内)会**并发**写同一批文件。 +三个坑都会表现为「**请求凭空消失**」或「**同一个 bot 被无限重试**」: + +| # | 坑 | 症状 | 现在的做法 | +| --- | --- | --- | --- | +| 1 | **临时文件名撞车** | 两边原来都用 `.json.tmp` ⇒ 互相覆盖对方的半成品 ⇒ **丢请求** | 各自带 pid:Rust `json.{pid}.tmp` / Node `${file}.${pid}.tmp` | +| 2 | **lost update** | 投递端做「读全量 → 改字段 → 写回全量」⇒ 把守护进程刚写进去的条目**一起抹掉** | 尝试次数**单独存一个文件**(`bot-revive-attempts.json`),两边各写各的 | +| 3 | **清理条件错** | 按「requests 文件里还在不在」清 attempts ⇒ Rust 的 prune 删掉「次数用尽」的请求 ⇒ 连带删掉 attempts ⇒ Rust 读到 0 ⇒ 重新入队 ⇒ **3 次封顶被架空,变成无限重试** | **只按「已 ack」或「超龄」清** attempts | + +**排查姿势**: + +```bash +# 请求是不是「被消费掉了」而不是「丢了」? +cat ~/.config/mcode-reset-card/bot-revive-requests.json +cat ~/.config/mcode-reset-card/bot-revive-acked.json + +# 同一个 id 在 attempts 里出现几次?(>=3 说明它反复失败) +cat ~/.config/mcode-reset-card/bot-revive-attempts.json + +# 临时文件有没有残留(说明写一半崩了) +ls -la ~/.config/mcode-reset-card/*.tmp +``` + +⚠️ **attempts 的值格式**:Node 写的是 `{"n": 次数, "at": 最后一次尝试的时间戳}`, +旧的纯数字格式也仍能读。 +「已 ack」的条目会在下次 `bumpAttempts` 时被清掉,「超龄」(24 小时)的也会清。 + +⚠️ **两侧读这个文件的格式并不一样**(排查时注意): +Node 的 `readAttemptsMap()` **两种格式都认**; +而 Rust 的 `attempts_map()`(`daemon/rust/src/bot_guard.rs`)用 `v.as_i64()` 取值 +⇒ **只认纯数字格式**,遇到 `{n, at}` 对象会**读成 0**。 +⇒ 投递端的「3 次封顶」由 Node 自己判定(`readWakeRequests()` 直接读 attempts), + **不受影响**;但守护进程那一侧看到的次数可能一直是 0 + ⇒ 表现为「requests 文件里那条请求怎么还在」。 + +⇒ **教训**:跨进程交接的文件,**格式也要双方都认**。 +「两边都能写」不等于「两边都能读懂」。 + +⇒ **另一条教训**:跨进程交接的文件,**每一次写都要假设对方正在同时写**。 +「读-改-写」在并发下永远是 lost update;「只增不改」也要想清楚清理条件。 diff --git a/plugins/hylouis233/expiry-guardian/docs/README.zh-CN.md b/plugins/hylouis233/expiry-guardian/docs/README.zh-CN.md new file mode 100644 index 0000000..bafe61b --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/docs/README.zh-CN.md @@ -0,0 +1,1341 @@ +# 蹬王之王 · 重置卡自动使用器(后台版) + + +> English version: [../README.md](../README.md) + +> **v1.6** — 2026-10-20 夜。**修掉一个让 Bot 守护完全静默的真 bug**, +> 并给唤醒链路加上**自检**(静默失效必须被说出来)。 +> +> - 🔴 **真 bug**:minimax-bot 的状态文件里有**一个被截断的代理对转义**(`\ud83d`,高代理在、低代理丢失)。 +> Python 的 `json.loads` 与 Node 的 `JSON.parse` 都能解析,**Rust 的 `serde_json` 严格拒绝** +> ⇒ `read_bot_sessions()` 返回空 ⇒ 整个 Bot 看门狗**静默失效**: +> **守护进程在跑、日志干净、实际一个 bot 都没看护**。已加 `parse_lenient_json()` 容错解析。 +> 实测对比:修复前 4 次重启(45 分钟)**零条**看门狗日志;修复后立刻报「检查 45 个,可自愈 11 个」。 +> - ✅ **唤醒链路自检**(新模块 `bot_bootstrap.rs`):宿主重启后运行时没起来 ⇒ **如实告警**而不是静默; +> 运行时在跑但积压条数一直不减少 ⇒ 判定「投递空转」并告警。 +> - 🔧 **纠正一个错误结论**:此前写进文档的「面板关着时唤醒不会发生」**不准确**。 +> 实测运行时 **44 分钟没开面板仍在跑**,唤醒循环照常投递。 +> 真正的缺口是**宿主重启**(宿主重启后不会自动拉起任何 MiniApp)。 +> - 🔧 **`miniapp publish` 已修好**:根因是 manifest 声明的端点集与 Host 内存里的「已发布快照」对不上。 +> 解法:**只声明 1 个 MCP 端点**(`server=expiry-guardian`, `path=/mcp/expiry-guardian`)。 +> +> **v1.5** — 2026-10-20 晚间。**架构加固**:唤醒会话复用、请求判据换轨、 +> acked 不再是永久黑名单、修掉并发写的三个坑、客户端 401 自愈、新增 local MCP 端点。 +> +> - **架构不变**:Rust 守护进程只做**判定 + 记账**;蹬王之王自己的 MiniApp 负责**投递** +> - 唤醒会话复用 ⇒ 唤醒不再每次冷启动(实测 02:39 一轮唤醒 6 个 bot,**新建会话数 0**) +> - 请求判据:从「30 分钟 TTL」改成「**最多试 3 次 + 24 小时兜底**」 +> - acked 改成带时间戳 ⇒ 只抑制 30 分钟,**不再「救过一次就永久不救」** +> - 修掉并发写的三个坑:临时文件名撞车 / lost update / 清理条件把 3 次封顶架空 +> - 瞬时错误**不重发**(`BUSY` / `AGENT_OUTCOME_UNKNOWN`)⇒ bot 不再收到重复的续跑消息 +> - 客户端 401 自愈:连续 3 次 401 ⇒ 整页重载(上限 5 次),面板不再卡在「实时连接中断」 +> - 新增 local MCP 端点(`watchdog_status` / `watchdog_wake` / `watchdog_decide`) +> - 补 `50111`(短时限流)到可自愈分类;新增 `check-test-parity.sh` +> +> **v1.4** — 2026-10-20。**彻底删掉定时任务(cron)依赖**,唤醒通道换成 +> 「自己的 MiniApp 起一个回合 + 会话内 `session send`」。 +> +> - 新架构:Rust 守护进程只做**判定 + 记账**;**投递**交给蹬王之王自己的 MiniApp +> - 删掉 `resumeCronId` 配置项(Rust 配置、MiniApp `normalizeConfig`、真实 `config.json` 三处同步删) +> - Bot 看门狗与目标恢复**共用同一条唤醒通道** +> - 端到端实测(2026-10-20 00:49):7 个挂掉的 bot **全部**被自动唤醒,bot 真的回话了 +> - 修掉刷屏:新增 `bot-notified.json` 去重表(此前每 60 秒刷 9 行,40 分钟 463 行) +> +> **v1.3** — 2026-10-19。**Bot 看门狗并入本包**,且 **400/404/422 也重试**。 +> +> - 新增:守护 MiniMax Bot 团队,因临时性故障挂掉的 bot 复用原会话自动重新启用 +> - 新增:HTTP 400 / 404 / 422 也纳入可重试(此前只有 5xx 与网络错误) +> - 独立的 `bot-watchdog` 插件已删除,功能全部并入本包 +> +> **v1.2** — 2026-10-19。在 v1.1 修完「闭环能不能真的跑通」之后, +> 又按 PR #28 的 review 意见改了一轮,并新增**可重试错误无限重试**。 +> +> - v1.2 修掉 3 个 review 指出的真 bug(详见下方) +> - v1.2 新增:500 / 429 等可重试错误**一直重试**,退避封顶 60 秒且可中断 +> +> **v1.1** 修掉 3 个让恢复机制**长期假成功**的缺陷,补上 2 道闸门, +> 并把踩过的 11 个坑全部落进文档与回归测试。详见 [v1.1 更新内容](#v11-更新内容)。 + +### v1.6 更新内容 + +这一版的核心是:**把「看起来一切正常、实际一个 bot 都没叫醒」这类静默失效全部变成会说话**, +并修掉三个在生产里实测到的真 bug。 + +| # | 变更 | 后果 | +| --- | --- | --- | +| 1 | 🔴 **`parse_lenient_json()`:Rust 读 bot 状态文件改用容错解析** | minimax-bot 的 `state.json` 里有一个**被截断的代理对转义**(`\ud83d`)。Python/Node 能解析,**Rust 的 `serde_json` 严格拒绝** ⇒ `read_bot_sessions()` 返回空 ⇒ 整个 Bot 看门狗**静默失效**。实测:修复前 4 次重启共 45 分钟**零条**看门狗日志,修复后立刻报「检查 45 个」 | +| 2 | 🔴 **按「最新回合」判定坏没坏**(不再只看会话上的 `error_code`) | `error_code` 是**陈旧值**(运行时不清它)。实测 15:39 那一轮写了 11 条唤醒请求,其中 **8 条是误判** —— 那 8 个 bot 的最新回合其实是 `completed`(好着呢),错误码是更早那次失败留下的。修后同一批从「可自愈 11 个、投递 11 个」变成「可自愈 0 个、投递 0 个」,**只救真正坏的那几个**(实测随后只唤醒了 2 个真坏的 bot) | +| 3 | 🔴 **acked 永久化 + 请求 id 按「实例」唯一化** | 原来 acked 的 30 分钟窗口**同时**管两件事:「投递过了吗」与「多久能再救」。⇒ 窗口一过,**已投递过的请求又变回未处理** ⇒ 投递端**每 30 分钟重投一次**(每回合 ≈47k tokens),还会打断已恢复的 bot。现在:**投递过的就是投递过了**(永久);「多久能再救」由守护进程自己的 30 分钟频率限制管;每次写入是**新实例 id** ⇒ 不会互相误杀 | +| 4 | 🔴 **读不到 bot 名单必须告警** | 原来 `if bots.is_empty() { return }` —— **一个字都不打**。「名单读不出来」在日志里完全不可见 ⇒ 守护进程看起来一切正常,实际**一个 bot 都没看护**。现在会明确说「本轮**没有看护任何 bot**(守护进程在跑,但它是瞎的)」,同一个原因只说一次 | +| 5 | ✅ **新增 `bot_bootstrap.rs`:唤醒链路自检** | 宿主重启后 MiniApp 没被拉起 ⇒ **如实告警**而不是静默;运行时在跑但积压条数一直不减少 ⇒ 判定「投递空转」并告警 | +| 6 | ✅ **新增投递空转检测(`StuckWatch`)** | 运行时**在跑**但积压条数连续 15 轮(约 15 分钟)没减少 ⇒ 判定唤醒循环已死 ⇒ 告警。覆盖「运行时活着但循环已经空转」这一类之前完全没人看的情况 | +| 7 | 🔧 **运行时存活判据重写** | 原来靠**硬编码端口 55932**(宿主的默认分配值,宿主改规则就误判),且那个端口属于**宿主进程**。现在改成「**有活进程** 且它的 **cwd 是本插件的运行时包目录** 且该包的 `package.json` **或** `.minimax-plugin/plugin.json` 的 `name == expiry-guardian`」—— 两个锚点都认(实测有个运行时目录 `package.json` 没有 name 字段,只有 `plugin.json` 有),且「目录还在但进程没了」不算 | +| 8 | 🔧 **纠正文档里的一个错误结论** | 此前写「面板关着时唤醒不会发生」——**不准确**。实测运行时 44 分钟没开面板仍在跑,唤醒循环照常投递。真正的缺口是**宿主重启** | +| 9 | 🔧 **`miniapp publish` 修复** | 根因是 manifest 声明的 MCP 端点集与 Host 内存里的「已发布快照」对不上。**只声明 1 个端点** + 相应 `servers.mcp.json` + `plugin.json` 的 `"mcpServers": ["servers.mcp.json"]` ⇒ 发布成功(已实测两次) | +| 10 | 🔧 **修掉几处会「说假话」/误报的分支** | 积压归零时不再说「运行时已恢复」(只说「积压已清空」);积压**变多**时不再说「已恢复消费」(只有**变少**才算恢复);lsof 报错时判「判据失效」而不是「运行时没起」;自检失败同一个错误只说一次;代理对区间判错(U+D000–D7FF 是合法字符,不是代理) | +| 11 | 🔴 **修掉看板取数层的一个拼写错误** | `doBuildSnapshot()` 里把 `platform` 写成 `platformRes` ⇒ `ReferenceError` ⇒ **`buildSnapshot()` 整个抛异常** ⇒ 额度、决策、重置卡、积分**全拿不到** ⇒ **整个面板是坏的**。这个 typo 是 **v1.2 引入的**(`fd98d98`),活了 4 个版本才被发现。实测:修前 MCP 工具返回 `quotaError: "platformRes is not defined"`,修后 `quotaError: null`,额度/决策/卡片全部正常 | +| 12 | 🔴 **修掉 MCP 工具永远返回 `{}`** | `deps.status()` 是 `async`,handler 里**漏了 `await`** ⇒ 拿到的是 Promise ⇒ `JSON.stringify(Promise)` = 字符串 `"{}"` ⇒ `watchdog_status` 什么也看不到(`watchdog_wake` / `watchdog_decide` 都有 await,只有它漏了) | +| 13 | ✅ **补两个回归测试**(这两个 bug 都是零覆盖才活下来的) | `mcp.test.mjs`(18 项):MCP 协议、三个工具、**async 的 status 必须真的 await**、未知工具要报明确的错。`server-lint.test.mjs`(5 项):静态守卫「用了但没声明的标识符」—— 把 typo 放回去实测能抓住(5/5 → 3/5) | +| 14 | 🔴 **修掉「3 次封顶」被架空的回归**(二轮审计) | v1.6 把 acked 改成永久之后,「按 acked 清理失败计数」的含义从「刚刚被救过」变成了「**曾经**被救过」⇒ 被救过的 bot 的失败计数被清掉 ⇒ **3 次封顶彻底失效** ⇒ 该请求永远「活」⇒ **每 60 秒重投一次**(每轮 ≈47k–93k tokens)。⇒ 失败计数**只在成功时归零**;失败路径的清理**只按超龄** | +| 15 | 🔴 **修掉解析器 panic**(二轮审计) | `parse_lenient_json` 按**字节**切 `&str` ⇒ 落在多字节字符中间会 **panic** ⇒ 守护进程整个崩掉(launchd 重启后同样崩 ⇒ 崩溃循环)。触发条件:文件严格解析失败 **且** 含 `\u` + 非 ASCII 字符(生产 `state.json` 里有 89.5 万个非 ASCII 字符)。⇒ 改成**纯字节**解析;并给守护进程加 `catch_unwind` 兜底(panic 只影响 bot 看门狗,不带走额度判定与 Goal 唤醒) | +| 16 | 🟠 **修掉「没有回合记录 ⇒ 永远不被救」的死代码**(二轮审计) | 判定层写 `None => !is_idle(...)`,而紧接着调用方又有一道 `if !is_idle(...) { continue; }` —— 两处**互为否定** ⇒ 这类会话永远走不到救援。⇒ 改为交给下游的 `classify_error` + `is_idle` 双重判定 | +| 17 | 🟠 **「僵尸回合」不再被永远忽略**(二轮审计) | `accepted` 状态既可能是「正在跑」,也可能是**宿主崩溃留下的僵尸**(实测生产库有 3 条,最老的 2 小时前还停在 `accepted`,会话却带着可自愈错误码)⇒ 新判据把它当「正在跑」⇒ 该救却永远不被救。⇒ 停在 `accepted` 超过 **1 小时** ⇒ 视为卡死,进入救援 | +| 18 | 🟡 **lsof 解析改用字段模式** | 默认模式按空白切列 ⇒ **路径含空格就被切碎** ⇒ `ends_with("/package")` 恒假 ⇒ 每轮都误判「运行时没起」。⇒ 改用 `lsof -F`(`n` 字段整行保留空格) | + +测试:Rust **75/75**、Python **20/20**、MiniApp **144/144** +(bot_wakeup 44 + config-fidelity 15 + decision 58 + mcp 18 + server-lint 5 + retry.behavior 4)。 + +### v1.5 更新内容 + +**架构本身没变**(Rust 判定 + 记账,MiniApp 投递),这一版全是**加固**: +把几个「看起来能跑、实际会静默失效」的地方修掉。 + +| # | 变更 | 后果 | +| --- | --- | --- | +| 1 | **唤醒会话复用**:首次创建的唤醒会话 id 存进 `bot-wake-host-session.json`,后续 `agent.run({text, sessionId})` 复用它 | 每次唤醒不再冷启动(实测冷启动 ≈ 93k tokens)⇒ **2026-10-20 02:39 一轮唤醒 6 个 bot,新建会话数 = 0**(此前每个 bot 新建一个) | +| 2 | **请求判据换轨**:从「30 分钟 TTL」改成「**最多试 3 次**(`MAX_ATTEMPTS`)+ **24 小时兜底**(`MAX_REQUEST_AGE_MS`)」 | 面板关着时没人消费 ⇒ 30 分钟后请求全过期 ⇒ 面板再打开时 bot 又挂了却**没有请求可发**(实测:面板关 70 秒后 7 条请求全未被消费) | +| 3 | **acked 不再是永久黑名单**:`bot-revive-acked.json` 改成带时间戳,只抑制 **30 分钟**(`ACK_SUPPRESS_WINDOW_MS`) | 6 个 bot 明明又坏了(闲置 47~94 分钟,远超 90 秒阈值),却因 acked 里有过它们而**永远不再被叫醒**。旧格式(纯字符串数组)仍能读,视为「很久以前」不抑制 | +| 4 | **修掉并发写的三个坑**(临时文件名撞车 / lost update / 清理条件) | 详见「踩过的坑」#15 —— 其中「清理条件」那条会让 **3 次封顶被架空,变成无限重试** | +| 5 | **瞬时错误不重发** | `BUSY` / `session_busy`(该会话已有回合在飞)**可重试,但不是重发**;`AGENT_OUTCOME_UNKNOWN`(回合可能已经跑了)直接 ack ⇒ 否则 bot 会收到**两条**重复的「请从中断处继续」 | +| 6 | **客户端 401 自愈** | 面板「实时连接中断」不再是死循环:连续 **3** 次 401 ⇒ `window.location.reload()`,重载次数用 sessionStorage 记、**上限 5 次**,成功一次就清零 | +| 7 | **新增 local MCP 端点** | `miniapp/node/mcp.mjs` + `servers.mcp.json` + manifest 的 `mcpEndpoints`。三个工具:`watchdog_status` / `watchdog_wake` / `watchdog_decide`。⚠️ **刻意不暴露「扣卡」** | +| 8 | **`50111` 纳入可自愈** | 短时限流(HTTP 429)此前落进「未知错误码 ⇒ 不自动重试」,而 429 本身在 Host 的可重试白名单里 | +| 9 | **新增 `check-test-parity.sh`** | 工作区源码与插件包**各有一份测试**、路径布局还不同 ⇒ 改一边忘另一边,插件包跑的就是旧测试 | + +测试:Rust **51/51**、Python **20/20**、MiniApp **112/112** +(bot_wakeup 35 + config-fidelity 15 + decision 58 + retry.behavior 4)。 + +### v1.2 更新内容 + +| # | 变更 | 后果 | +| --- | --- | --- | +| 1 | `cards.items` 是给页面看的**脱敏视图**(无 `card_id`),却拿它判断「卡还在不在列表里」 | 该判断恒为 false ⇒ **扣卡每次都被跳过**,`reset_card/use` 从未被调用。改为新增 `cards.raw`(带 `card_id`)供写路径使用 | +| 2 | 积分接口失败被 `.catch(() => null)` 吞掉 ⇒ 等同于「没有积分」 | 结论从「留卡」翻转成「用卡」⇒ **网络故障导致误扣真卡**。新增 `creditDataComplete` 闸门:数据不完整 ⇒ 本轮**不做任何写操作** | +| 3 | 守护进程侧同样问题:积分读取失败只记日志 | 同样会误扣。`earliest_credit()` 现在返回「数据是否完整」,不完整 ⇒ `wait` | +| 4 | **新增:可重试错误无限重试** | 429 / 500 / 502 / 503 / 504 与网络错误(连接重置、超时、DNS)一律重试到成功;退避指数增长、**封顶 60 秒**、带抖动;`SIGINT`/`SIGTERM` 可立即中断。401/403 重取 token 一次,其余 4xx 与业务错误快速失败。扣卡也走重试(`out_biz_no` 幂等键保证不会重复扣) | + +测试:Rust **48/48**、Python **20/20**、MiniApp 决策 **58/58**、 +配置保真 **15/15**、唤醒指令 **9/9**、三方 parity **12** 例。 + +无人值守地盯住 Token Plan 额度:**接近耗尽时**按「先过期先用」自动使用一张重置卡; +额度打满时**自动暂停**所有进行中的目标,额度回落后**自动恢复并叫醒**它们。 + +通过 macOS `launchd` 常驻,与 MiniMax Code 桌面端完全独立——关掉 MCode、退出登录、 +甚至你没开电脑,它都在跑。 + +--- + +## 目录 + +- [现在有两套,各自独立](#现在有两套各自独立) +- [共享配置](#共享配置) +- [完整闭环](#完整闭环一条消息怎么走完全程) +- [为什么彻底去掉定时任务](#为什么彻底去掉定时任务cron) +- [Bot 看门狗](#bot-看门狗挂掉的-bot-自己回来) +- [local MCP 端点](#local-mcp-端点) +- [客户端 401 自愈](#客户端-401-自愈) +- [唤醒链路自检](#唤醒链路自检宿主重启后没人投递时必须说话) +- [踩过的坑](#踩过的坑二十个都写在这里了) +- [扣卡规则](#扣卡规则与-mini-app蹬王之王逐条一致) +- [管理命令](#管理命令) +- [测试](#一致性是测出来的不是声称的) +- [前提与边界](#前提与边界) + +--- + +## 现在有两套,各自独立 + +| | 后台服务(本目录) | Mini App「蹬王之王」 | +| --- | --- | --- | +| 运行方式 | launchd 系统服务 | MCode 运行时内的 Node 进程 | +| 关掉 MCode | **照常运行** | 停止 | +| 扣卡策略 | **与 Mini App 完全一致** | 同左 | +| 作用 | 真正的无人值守执行 | 实时看板 + 手动触发 + **唤醒投递方** | + +两者都用服务端的 `out_biz_no` 幂等键,**不会重复扣同一张卡**。 + +> ⚠️ **关掉面板会怎样(v1.4 起变了,v1.5 又改了请求时效)** +> 守护进程照常判定、照常记账、照常写请求文件 —— 扣卡与暂停**不受影响**。 +> 但**唤醒不会发生**:投递方是 MiniApp 进程,面板关着它就不在。 +> 面板再打开时,积压的请求会被补上。 +> +> v1.4 时请求有 **30 分钟**时效上限 ⇒ 面板关久了请求全过期 ⇒ 打开时反而**没有请求可发**。 +> v1.5 改成「**最多试 3 次 + 24 小时兜底**」⇒ 面板关多久都不影响(请求一直在那儿等着), +> 代价是叫不醒的 bot 最多试 3 轮。见 [唤醒请求的生命周期](#唤醒请求的生命周期代码常量不是配置项)。 + +**代码位置** + +| 组件 | 路径 | +| --- | --- | +| Rust 守护进程(主力) | `daemon/rust/src/` | +| Python 决策参考实现 | `daemon/reset_card_autouse.py` | +| 三方一致性校验器 | `daemon/verify-parity.mjs` + `daemon/check-parity.sh` | +| Mini App(看板 + 唤醒投递方 + MCP 端点) | `miniapp/`(**本包内**,自包含,不依赖仓库里的其他目录) | +| Bot 唤醒逻辑 | `miniapp/node/bot_wakeup.mjs` | +| local MCP 端点 | `miniapp/node/mcp.mjs` + 根目录 `servers.mcp.json` | +| 测试一致性校验 | 根目录 `check-test-parity.sh`(工作区 ↔ 插件包) | + +--- + +## 共享配置 + +Rust 后台 / Python / Mini App Node **读同一个文件**,谁都不许再把阈值写死在自己代码里。 + +```json +{ + "schemaVersion": 1, + "cardTriggerPercent": 99, + "cardSkipBeforeResetMinutes": 60, + "quotaLevels": { "tight": 80, "low": 95 }, + "goalPause": { "enabled": true, "triggerPercent": 95, "resumeAtPercent": 0 }, + "botGuard": { + "enabled": true, + "idleMs": 90000, + "backoffBaseMs": 5000, + "backoffCapMs": 60000 + } +} +``` + +| 字段 | 含义 | +| --- | --- | +| `cardTriggerPercent` | 扣卡触发线(用卡的阈值) | +| `cardSkipBeforeResetMinutes` | 距 5 小时额度刷新不足这么多分钟时,**不消耗重置卡**(额度马上就自己回来了,烧卡是浪费)。0 = 关闭该保护 | +| `quotaLevels.tight` / `.low` | 四档红绿灯分界点:低于 tight=充足、≥tight=紧张、≥low=不足、耗尽=燃尽 | +| `goalPause.enabled` | 目标自动暂停总开关 | +| `goalPause.triggerPercent` | 5 小时额度到这个百分比就**暂停所有进行中的目标** | +| `goalPause.resumeAtPercent` | 回落到这个百分比就恢复(见下方「恢复判据」) | +| `botGuard.enabled` | Bot 看门狗总开关 | +| `botGuard.idleMs` | 多久没动静才算「挂掉」(默认 90 秒)。**必须同时确认没有新活动**才能救 | +| `botGuard.backoffBaseMs` / `.backoffCapMs` | 退避基数与封顶(默认 5 秒 / 60 秒) | + +后台服务**每轮重读**该文件,所以改完约 1 分钟内生效,不必重启。 + +Mini App 页面上有完整的可视化入口(在「自动化阈值」卡片): +扣卡触发线、暂停目标触发线、额度紧张/不足分界、刷新前暂停扣卡分钟数 —— 全部可直接改, +并配一条**断轴刻度**把 0–黄档 压缩成波浪带、只展开真正有阈值分布的区间, +外加图例与冲突提示(比如「扣卡线 90% 不高于红色档 95%,红灯将永远不会亮」)。 + +字段非法时**整体回退默认并打出告警**,绝不静默纠正——静默纠正会让人以为设的值生效了。 +(Rust 侧开了 `deny_unknown_fields`,键名写错会直接报错而不是悄悄降级。) + +> ⚠️ **Mini App 保存配置时必须原样保留 `botGuard`**(v1.4 沿用 v1.1 的教训)。 +> 它的 `normalizeConfig()` 是**白名单式重建**,漏掉这个字段 ⇒ +> 用户在页面上改任何一项设置,都会把看门狗配置从配置里抹掉 ⇒ +> 守护进程读到 `enabled=false` ⇒ **看门狗静默失效**,且不报任何错。 +> +> v1.4 删字段时踩了同一类坑:删 `resumeCronId` 必须**三处同步删** +> (Rust `config.rs`、MiniApp `normalizeConfig`、真实 `config.json`), +> 少一处就会出现「两套配置对不上」。 + +### 两条触发线是分开的 + +| 配置项 | 默认 | 作用 | +| --- | --- | --- | +| `goalPause.triggerPercent` | **95%** | 暂停目标 | +| `cardTriggerPercent` | 99% | 扣重置卡 | + +**为什么暂停线要留 95% 而不是 99%**(2026-10-19 实测): +额度从 99% 涨到 100% 只用了 **27 秒**。按 99% 暂停,目标会在 100% 那一刻被打死, +留下 `error_code=50113` 的卡死会话。按 95% 暂停则在打满**之前**就停手 —— +实测当轮额度冲上 100% 时**没有任何目标被打死**。 + +⇒ **暂停线要留出比「撞线到打满」更宽的余量**,扣卡线才是「卡」的触发点。 + +### 恢复判据:为什么不是「回落到 0%」 + +理想口径是「额度刷新回 0% 时恢复目标」。**但这条路走不通**: +守护进程每 60 秒才轮询一次,额度刷新后的那个 0% 瞬间**通常根本观测不到**。 +实测:重置卡在 14:35:41 把额度打回 0%,下一轮 14:36:20 读到的是 **1%**。 +若严格按 `used <= 0` 判定,目标会被**永久停住**——这个故障真实发生过。 + +所以判据是「**用量回落到触发线以下**」:额度只有在「用掉重置卡」或「5 小时窗口翻页」时才会掉下来, +掉下来就说明额度已经回来了。`resume_at_percent` 作为更严格的附加条件保留。 + +### 日志里能看到「什么时候刷新」 + +``` +[10-19 20:00.281] 额度: 5小时限额=已用0% / 剩余100% | 周限额=不限量 | 视频限额=已用0% / 剩余100% +[10-19 20:00.281] 5 小时窗口: 240 分钟后刷新(预计 10月20日 00:00) +``` + +这行是 v1.1 补的。以前日志只有「已用 X%」,没有刷新时间 —— +于是「窗口什么时候翻页」只能靠猜(曾误以为是 19:41,实际是 20:00,差 19 分钟, +害我误判「窗口不会来」)。 + +⇒ **凡是「等某个外部事件发生」的流程,都必须把该事件的剩余时间打出来**, +否则无法区分「还没到」和「不会来」。 + +--- + +## Bot 看门狗:挂掉的 bot 自己回来 + +MiniMax Bot 每失败一次就**新建一个会话**,所以一个反复挂掉的 bot 会留下一串死会话, +界面上每次还多一条错误通知。 + +本包盯住你的 Bot 团队,把因**临时性**故障挂掉的 bot **复用原会话**重新启用。 + +| 情况 | 看门狗怎么做 | +| --- | --- | +| HTTP 500、网络切换、集群过载、TPM 限流、短时限流(50111) | 退避等待后**复用原会话**重新启用 | +| Token Plan 到顶(42212)、积分耗尽(50110)、登录失效(50112) | **不重试**,明确告诉你缺什么 | +| bot 本身正常、或只是残留着上一轮的过期错误码 | 不打扰它 | + +完整取证见 [`docs/BOT_DIAGNOSIS.md`](BOT_DIAGNOSIS.md)。 + +### 错误分类:哪些能自己好,哪些要你出手 + +分类逻辑在 `daemon/rust/src/bot_guard.rs` 的 `classify_error()`: + +| 错误码 / 特征 | 含义 | 判定 | 看门狗怎么做 | +| --- | --- | --- | --- | +| **42212** | `USAGE_LIMIT_EXCEEDED` — Token Plan 用量到顶 | 🔴 需人工 | 不重试,提示「升级套餐或购买积分并开启积分自动消耗」 | +| **50110** | `LLM_CREDITS_EXHAUSTED` — 积分耗尽 | 🔴 需人工 | 不重试,提示充值 | +| **50112** | `LLM_AUTH_ERROR` — 登录态失效 | 🔴 需人工 | 不重试,提示重新登录 | +| 文本含 `Cron definition was deleted` | 那个定时任务被删了 | 🔴 需人工 | 不重试,提示「需要重新安排」 | +| **50113** | `LLM_UPSTREAM_ERROR` — 通用 4xx/5xx 兜底(HTTP 500 落这里) | ✅ 可自愈 | 退避后自动唤醒 | +| **50111** | `LLM_RATE_LIMITED` — 短时限流(HTTP 429) | ✅ 可自愈(**v1.5 补上**) | 退避后自动唤醒。此前它落进「未知错误码 ⇒ 不自动重试」,而 429 本身在 Host 的可重试白名单里 | +| **50150** | `LLM_TPM_RATE_LIMITED` — TPM/RPM 限流 | ✅ 可自愈 | 退避后自动唤醒 | +| **50151** | `LLM_CLUSTER_OVERLOADED` — 集群过载(HTTP 529) | ✅ 可自愈 | 退避后自动唤醒 | +| 无错误码 + 文本含额度/鉴权信号 | 「用量上限」「未授权」等 | 🔴 需人工 | 不重试 | +| 无错误码 + 文本含网络/服务端临时信号 | `net::ERR_NETWORK_CHANGED`、`ECONNRESET`、超时、`service unavailable` 等 | ✅ 可自愈 | 退避后自动唤醒 | +| 无错误码 + 完全认不出的文本 | — | ✅ 可自愈(倾向) | 宁可多试一次,也不要让 bot 永久瘫着 | +| 其它有结构的错误码 | — | 🔴 需人工 | 「未知错误码,重试可能无用」 | + +> ⚠️ **「读不到」≠「没有」**:44 个 bot 会话里有 29 个是「无错误码、无错误消息」—— +> 它们只是闲置很久,并不是挂掉。若把这类判成可自愈,看门狗会去「重新启用」一批没坏的 bot。 +> +> ⚠️ **错误码可能是陈旧值**:运行时不清 `error_code`。2026-10-19 实测踩到: +> 那个会话当时正在跑一个 20 多分钟的**正常长回合**,而它的 `error_code` 仍是 50113。 +> ⇒ 动手前必须同时确认「最近没有新活动」(`botGuard.idleMs`,默认 90 秒)。 +> 判据是 `local_runtime_message_rows` 里该会话的 `MAX(created_at_ms)`。 + +### 唤醒请求的生命周期(代码常量,不是配置项) + +这三个常量**不在 `config.json` 里** —— 它们是「唤醒通道」的内部判据, +Rust 守护进程与 Node 投递端**各有一份同值实现**(`bot_guard.rs` 与 `bot_wakeup.mjs`)。 + +| 常量 | 值 | 作用 | +| --- | --- | --- | +| `MAX_WAKE_ATTEMPTS` / `MAX_ATTEMPTS` | **3** | 同一个 bot 最多试 3 次,超过就作废(**按 bot 键计,跨实例有效**) | +| `MAX_WAKE_REQUEST_AGE_MS` / `MAX_REQUEST_AGE_MS` | **24 小时** | **只是兜底**,防僵尸请求永久堆积 | +| `ACK_SUPPRESS_WINDOW_MS` | **30 分钟** | 「同一个 bot 多久可以再救一次」(**只管写请求,不管投递**) | + +**为什么主判据是次数而不是时间**: +纯时间 TTL 会让「面板关着」的场景彻底失效 —— +面板关掉 ⇒ 没人消费请求 ⇒ 30 分钟后请求全部过期作废 ⇒ +等面板再打开时,那些 bot 又已经挂了一段时间,却**再也没有请求可发**。 +实测:面板关掉 70 秒后,7 条请求全部未被消费。 + +真正的风险不是「放太久」,而是「**反复试同一个叫不醒的 bot**」 +(每次尝试都要烧一个 agent 回合)⇒ 按次数封顶。 + +⇒ **教训**:**时效不是「多久算过期」,是「多久还值得试」。** +只留一个时间上限的队列,在「消费者暂时不在」时会**静默丢单**。 + +### 请求 id 按「实例」唯一,acked 永久化(v1.6) + +请求 id 的形状: + +``` +bot-revive:{botId}:{sessionId} ← bot 键(尝试次数就记在它名下) +bot-revive:{botId}:{sessionId}:{实例序号} ← 请求 id(每次写入都是新实例) +``` + +| 用途 | 用哪个键 | 规则 | +| --- | --- | --- | +| 「这条投递过了吗」 | **请求 id**(含实例序号) | **永久** —— 投递过的就是投递过了,绝不重投 | +| 「这个 bot 试了几次」 | **bot 键** | 3 次封顶,**跨实例有效**(否则换实例就重置 ⇒ 封顶被架空) | +| 「这个 bot 多久可以再救」 | **bot 键** + ack 时间戳 | 30 分钟频率限制(守护进程写请求时判断) | + +**为什么必须拆开**(2026-10-20 独立审计 + 实测): +原来 acked 的 30 分钟窗口**同时**管「投递过了吗」与「多久能再救」两件事。 +⇒ 窗口一过,**已经投递过的请求又变回「未处理」** +⇒ 投递端**每 30 分钟把同一条请求重投一次**(每回合 ≈47k tokens), +而且会打断已经恢复正常的 bot。 +⇒ 拆开后:投递端只认「永久 ack」;频率限制由守护进程自己在写请求时管。 + +⚠️ **实例 id 是必需的**:ack 永久化之后,如果请求 id 还是「每个 bot 同一个」, +那这个 bot 一旦被救过就**永远**投不出去(实测踩到:两个真坏的 bot 30 分钟内一次没被叫醒)。 +⇒ 每次写入都带一个实例序号 ⇒ 新实例不受旧 ack 影响。 + +### 坏没坏,看「最新回合」而不是会话上的 error_code(v1.6) + +会话上的 `error_code` 是**陈旧值**(运行时跑完不清它)。 +判「这个 bot 现在坏没坏」的**权威依据**是 `local_runtime_turn_ingress` 里**最新回合**的状态: + +| 最新回合 | 含义 | 看门狗怎么做 | +| --- | --- | --- | +| `failed` | 真失败 | **该救** | +| `aborted` | 被中断 | **该救** | +| `completed` | 上一轮成功 | **别碰**(错误码是更早那次失败留下的陈值) | +| `accepted` | 正在跑 | **别碰**(同时覆盖了「20 多分钟的长回合」) | +| 没有回合记录 | 查不到 | 回退到老判据(`error_code` + 闲置) | + +**实测(2026-10-20 15:39)**:老判据写出 **11 条**唤醒请求,其中 **8 条是误判** —— +那 8 个 bot 的最新回合是 `completed`,它们好着呢。 +修后同一批变成「可自愈 0 个、投递 0 个」;随后只唤醒了**真正失败的那 2 个**。 + +⇒ **教训**:`error_code` 这类「粘在对象上的状态」会被后续的成功操作留成陈值。 +**判当前状态要看最近一次动作的结果**,不要看对象身上挂着的标签。 + +### acked 不再是永久黑名单 + +`bot-revive-acked.json` 现在带时间戳: + +```json +{ + "acked": [ + { "id": "bot-revive:bot_xxx:mvs_yyy", "atMs": 1792435772291 } + ] +} +``` + +只抑制 **30 分钟**(`ACK_SUPPRESS_WINDOW_MS`),超过窗口可以再救。 + +**为什么**:原来 acked 是个纯 id 列表 ⇒「救过一次」变成**永久黑名单**。 +实测:6 个 bot 明明又坏了(闲置 47~94 分钟,远超 90 秒阈值), +却因为 acked 里有过它们而**永远不再被叫醒**。 + +旧格式(纯字符串数组)仍能读 —— 视为「很久以前」,**不抑制**。 + +> ⚠️ **v1.6 改了这条**:acked 现在**永久**(不再按 30 分钟窗口过期), +> 「多久可以再救同一个 bot」改由守护进程自己的 30 分钟频率限制管。 +> 原因见[请求 id 按「实例」唯一,acked 永久化](#请求-id-按实例唯一acked-永久化v16)。 + +### 唤醒会话复用(省 token) + +以前每次 `agent.run` 都不带 `sessionId` ⇒ 每次**新建一个会话** ⇒ 每次都是冷启动。 +现在把首次创建的会话 id 存进 +`~/.config/mcode-reset-card/bot-wake-host-session.json`, +后续 `agent.run({text, sessionId})` **复用它**。 + +| | 冷启动(每次新建会话) | 复用宿主会话 | +| --- | --- | --- | +| 单次唤醒 token | ≈ **93k** | 增量(系统提示与工具定义进上下文缓存) | +| 一轮唤醒**新建**的会话数 | 每个 bot 一个 | **0**(全部复用同一个宿主会话) | + +**实测收益**:2026-10-20 02:39 一轮唤醒 6 个 bot ⇒ **新建的唤醒会话数为 0**。 +(运行时库里那一小时只有 **1** 条 `purpose='miniapp:expiry-guardian'` 的会话,它承载了 14 个 turn。) + +⚠️ 复用失败时会**自动退回新建**(会话被归档 / 凭据过期 / 会话不存在), +但**只有** `BUSY` / `AGENT_OUTCOME_UNKNOWN` 除外 —— 见下面的「瞬时错误不重发」。 + +### 怎么把 bot 叫醒:两段跳 + +看门狗跑在 **Host 之外**(launchd 常驻、无 token、无人值守), +够不到 minimax-bot 的 MCP 端点 —— 那个端口由 Host 分配, +且 Host 的 MCP 客户端带 per-run 凭据,别的进程拿不到。 +2026-10-19 / 2026-10-20 实测了五条通道: + +| 通道 | 能续别的 Mini App 拥有的会话吗 | 结论 | +| --- | --- | --- | +| 守护进程**裸写**队列项 | ❌ | 隔离实验:往 bot 会话写一条队列项,3 分钟后仍是 `queued`、未 claim、无 turn、无消息。**Host 的队列轮询器只认经过 steer 的行** | +| `context.agent.run({ sessionId })` | ❌ | `assertSessionOwnership`:`owner !== caller.pluginId` ⇒ 抛 `SESSION_NOT_FOUND`。实测 45 个 bot 会话的 `purpose` 全是 `miniapp:minimax-bot` ⇒ 每 30 秒 6 条全拒 | +| 直连对方 MCP 端点 | ❌ | Host 的 MCP 客户端带 per-run 凭据,别的进程拿不到 | +| 守护进程写**目标续跑队列项**(带正确形状) | ✅ | 但只对**目标会话**有效,对 bot 会话不成立(见上一行) | +| **起一个 Agent,让它调 `session send`** | ✅ | 2026-10-19 实测:唤起挂着 42212 的 bot,13 秒内回复 | + +⇒ **看门狗负责判定**(谁挂了、该不该救)并写请求; +**投递由蹬王之王自己的 MiniApp 负责** —— 它起一个自己的 agent 回合, +那个回合里的 agent 用 **mavis 工具的 `session send`** 把续跑消息送进目标 bot 会话。 + +#### 为什么 `session send` 能越权 + +`local-runtime/dist/runtime/mavis-session-send.js` 的准入注释原文: + +> Reachability is the whole access model: the caller identity comes from the +> runtime context, the target must be resolvable through the canonical Session +> query, and everything after that is TurnSystem admission. **There is no +> topology check** — a task child, a sibling, a root Session and the caller's +> own Session all take the same path. + +⇒ `session send` **不做 owner 检查** ⇒ 任何拿到 mavis 工具的会话都能唤醒任意 bot 会话。 +而 MiniApp 的 `agent.run`(**首次**不带 `sessionId`)建的会话 `agent_name = 'mavis'` +⇒ 那个 agent 天然带桌面端 mavis 工具。 + +> 💡 **这正好绕开了 `assertSessionOwnership`**: +> 那个检查管的是「Mini App 能不能续**自己名下**的会话」, +> 而 `session send` 走的是**另一条路**(工具调用,不是 MiniApp 会话续跑), +> 它的准入条件是「目标可达即可」。 + +### 瞬时错误不重发(v1.5) + +唤醒失败分两种,处理方式**相反**: + +| 错误 | 含义 | 处理 | 为什么 | +| --- | --- | --- | --- | +| `BUSY` / `session_busy` | 该会话**已有回合在飞** | **不是重发** —— 抛出去,交给下一轮 | 重发 = 往同一个会话里塞第二条续跑消息 | +| `AGENT_OUTCOME_UNKNOWN` | 回合**可能已经跑了**(Host 标 never retryable) | **直接 ack,不重投** | 若只 bump 尝试次数而不 ack ⇒ 60 秒后下一轮会再投同一条 ⇒ bot 收到**两条**重复的「请从中断处继续」 | + +⚠️ 注意这两类**都不能**触发「宿主会话失效 ⇒ 退回新建会话」那条路: +`BUSY` 说明会话是好的(只是忙),`AGENT_OUTCOME_UNKNOWN` 说明回合可能已经执行过了。 + +⇒ **教训**:「失败」不等于「没送达」。 +把「不知道成没成」当成「没成」再投一次,是**重复投递**的经典来源。 + +--- + +## local MCP 端点 + +蹬王之王对外暴露一个 **local MCP 端点**,别的插件可以在自己的回合里直接问它。 + +| 项 | 值 | +| --- | --- | +| 实现 | `miniapp/node/mcp.mjs` | +| 声明 | `miniapp/miniapp.json` 的 `mcpEndpoints` + 根目录 `servers.mcp.json` | +| 路径 | `/mcp/expiry-guardian`(**并兼容** `/mcp/minimax-bot`) | +| 传输 | streamable-http | + +三个工具: + +| 工具 | 作用 | +| --- | --- | +| `watchdog_status` | 查当前额度、重置卡/积分到期时间,以及**待唤醒队列**(含每个请求已试了几次) | +| `watchdog_wake` | **立即**跑一轮 Bot 唤醒(不等 60 秒巡检) | +| `watchdog_decide` | 立即判定一次:现在该不该用卡、用哪张、为什么(**只判定不扣卡**) | + +⚠️ **刻意不暴露「扣卡」**:扣卡是**真实写操作**(少一张卡), +让别的插件随手就能扣太危险 ⇒ 要扣走面板按钮。 + +⚠️ **声明了就得真实现**:Host 发布时会用 `initialize` + `tools/list` **探活**, +探不通就**发布失败**。而且 Host 内存里的「已发布快照」是**粘的** —— +一旦某个版本的 manifest 声明过 `mcpEndpoints`, +后续版本**必须继续声明同样的端点**,否则 `miniapp publish` 会被 +`MCP_PUBLICATION_INVALID` 挡下(`used.size !== expected.size`)。 +⇒ 两条路径(`/mcp/expiry-guardian` 与 `/mcp/minimax-bot`)挂同一个 handler。 + +--- + +## 客户端 401 自愈 + +面板显示「**实时连接中断**」且一直好不了,多半是**凭据失效**(401)而不是服务挂了。 + +**根因**(2026-10-20 实测): +MiniApp 被 Host 重启后,它的 loopback 凭据会**换新**; +而 Electron 主进程是按 **webContentsId** 注入凭据的(`miniapp-loopback-auth.js`), +页面里的 fetch 拿不到新 token ⇒ 一直 401。 +更麻烦的是 `syncMiniAppLoopbackCredential` **只在 origin 不变时才同步** +(重启后端口变了 ⇒ origin 变了 ⇒ 跳过)⇒ **光重试 fetch 无解**。 + +⇒ 唯一能拿到新凭据的途径是**重新导航**(Host 在 `refreshMiniAppTab` / +`openMiniAppTabOnce` 里同步 token)。 + +客户端现在的做法: + +| 参数 | 值 | 作用 | +| --- | --- | --- | +| `STALE_RELOAD_THRESHOLD` | **3** | 连续 3 次 401 ⇒ `window.location.reload()` | +| `MAX_RELOADS` | **5** | 重载次数上限(sessionStorage 记,**跨页面**) | + +- 没有上限会变成「重载后仍 401 ⇒ **无限重载**」⇒ 页面反复闪,用户根本没法用。 +- **成功一次就清零**(`staleCount` 与 sessionStorage 里的计数都清)—— + 原来只在「重连成功」时清、`load()` 成功不清 ⇒ + 「2 次 401 + 1 次成功 + 1 次 401」就会触发一次多余的整页重载。 +- 超过上限 ⇒ 不再重载,改为提示「请在 MiniMax Code 里重新打开「蹬王之王」面板」。 + +--- + +## 唤醒链路自检:宿主重启后没人投递时必须说话 + +唤醒要「两段跳」才能落地(见[怎么把 bot 叫醒](#怎么把-bot-叫醒两段跳)): +**Rust 守护进程只负责「判定 + 记账」**,真正把消息送进目标 bot 原会话的是 +**蹬王之王自己的 MiniApp**(它有 `agentAccess:["session.run"]`)。 + +而 MiniApp 是 **on-demand** 的:**宿主重启后不会自动拉起它**(见 +[MiniApp 生命周期](#miniapp-生命周期没有常驻模式))。 +⇒ 那段时间里「判定」照常跑、「投递」却没人消费。 + +**这正是最坏的一类故障**:守护进程进程在、日志干净、面板看着正常, +**实际一个 bot 都没被叫醒**。所以 v1.6 加了自检(`daemon/rust/src/bot_bootstrap.rs`)。 + +### 它能发现两种空转 + +| 状态 | 判据 | 会说什么 | +| --- | --- | --- | +| **运行时没起** | 有待唤醒请求,但**没有活进程**的工作目录是本插件的运行时包 | 「⚠️ N 个待唤醒请求投不出去 —— 蹬王之王面板的 MiniApp 运行时没在跑。请打开一次「蹬王之王」面板」 | +| **投递空转** | 运行时**在跑**,但积压条数**连续 15 轮(约 15 分钟)没减少** | 「⚠️ 运行时在跑,但 N 个待唤醒请求已连续 N 轮巡检没被消费 —— 唤醒循环可能已经空转」 | + +两种都**只告警一次**(状态不变不重复刷屏),状态变化时再补一句「已恢复」。 + +⚠️ **告警文案严格对应事实**: +积压归零时只说「积压已清空」,**不说**「运行时已恢复」—— +积压归零只说明「没有待办」,不代表运行时起来了。 + +### 运行时存活的判据(为什么不用端口) + +``` +有活进程 且 cwd = <...>/mavis-miniapp-XXXX/package 且 该包 package.json 的 name == "expiry-guardian" +``` + +- **不用端口**:55932 是宿主的默认分配值,宿主改规则就误判; + 而且那个端口属于**宿主进程**(MiniApp 运行时是它 fork 出来的子进程,进程名继承为 `MiniMax`), + 端口活着 ≠ **是蹬王之王**在跑。 +- **不用「目录存在」**:实测同时存在 3 个 `mavis-miniapp-*` 目录,其中只有 1 个对应活进程 + ⇒ 停掉的运行时目录不会被清掉,光看目录会以为「在跑」。 + +### 为什么这里不做「自动修复」 + +「从宿主外把 MiniApp 拉起来」这条路,**逐条实测全部堵死**: + +| 通道 | 结果 | 原因 | +| --- | --- | --- | +| 往 `local_runtime_queue_items` 写消息 | ❌ | 队列轮询器只在会话有**活跃 dispatch** 时取项;**连正在跑的会话都不取**(实测连投 idle 与 started 两种会话,3 分钟内都是 `queued` + `claim_id` 为空)。`source='communication'` + `model` + `deliveryAttempts` 的形状也不行 | +| MiniApp 的 HTTP(55932) | ❌ | `host-runner.js` 的 `installMiniAppLoopbackGuard` 要 `Host: 127.0.0.1:` + `x-mavis-miniapp-token`;token **只存在于宿主进程**,不传给 MiniApp 子进程 | +| 宿主 HTTP(58925) | ❌ | 同上(per-run 凭据);且宿主只有 3 个路由(`/mavis/api/preferences/starred-agents`、`/mavis/api/computer-use/state`、`/mavis/api/computer-use/abort`),没有起回合/拉 MiniApp 的接口 | +| `minimax-cn://chat?message=` | ❌ | `chat` handler 由**渲染进程**注册;实测 deeplink 触发不了回合 | +| `minimax-cn://navigate?url=` | ❌ | 只导航渲染进程页面,不启动 MiniApp 运行时(还会 `mainWindow.focus()` 抢焦点) | +| 官方 minimax-bot 插件的 MCP | ❌ | 它自己也是 MiniApp ⇒ 同样 on-demand;官方插件不可改版(`OFFICIAL_PLUGIN_READ_ONLY`) | + +⇒ **从宿主外无法自动拉起 MiniApp**。这不是本插件的设计取舍,是宿主的边界。 + +**唯一确认可行的自举路径**(供未来参考): +宿主在准备**任何一个回合**时,只要消息里出现 `@`,就会 `ensureStarted` +把那个 MiniApp 拉起来(`turn-preparation.ts` 的 `prepareReferencedMiniApps`), +前提是该插件**声明了 MCP 端点**(`referenced.filter(c => c.mcpEndpoints.length > 0)`)。 +实测有效:2026-10-20 14:37 往一个活跃会话发了一条 `@expiry-guardian 看门狗探针`, +宿主随即拉起了蹬王之王的运行时。 +⇒ 只要**宿主内有人发一条带 `@expiry-guardian` 的消息**,自举就成立 —— +但这需要宿主内的 `session send`(进程内 adapter),**宿主外拿不到**。 + +⇒ 所以本包的做法是:**检测 + 如实告警 + 面板一恢复就自动补上**。 +实测 Cross-Asset / Chief 的积压请求都是在面板恢复后被自动补上的。 + +--- + +## 为什么彻底去掉定时任务(cron) + +v1.3 的架构是「**守护进程判定 + 定时任务值守投递**」。 +2026-10-20 全部删掉,理由三条,**每条都有实测**: + +| # | 理由 | 证据 | +| --- | --- | --- | +| 1 | **定时任务的目标会话会被运行时自动归档** ⇒ 后续每次触发都报 `CRON_SESSION_TARGET_ARCHIVED` | 实测 12 次触发 = 4 成功 + 8 次归档失败。`session_kind='cron'` 的会话跑完就被发 `POST /session/:id/archive`;`kind=conversation` / `task` 的不受影响 | +| 2 | **定时任务只能跑在 Host 内的某个会话里,而「固定值守会话」不可靠** | 改成指向长期会话虽然 6/6 delivered,但那会把值守回合混进用户会话历史 | +| 3 | **每轮烧约 47k tokens**(46k 是缓存读取),而它只需要做一件事:把一条续跑消息送进目标会话 | 按 5 分钟常驻 ≈ 288 轮/天 ≈ 13M tokens/天 —— 那会吃掉正要保护的那块额度 | + +⇒ **教训(换了个形式仍然成立)**: +**任何「由专用宿主承载的值守」都不可靠** —— 宿主会被归档、会被限流、会被回收。 +把值守放在**用户自己的目标路径**上(这里是:一个由 MiniApp 自己起的 agent 回合), +比放在一个专职值守会话上更稳。 + +### 去掉之后的新架构 + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ Host 之外:Rust 守护进程(launchd 常驻 · 无 token · 无人值守) │ +│ │ +│ 职责只有两件:判定 + 记账 │ +│ · 判定额度、决定是否暂停/恢复目标、决定哪个 bot 挂了 │ +│ · 把要唤醒的 bot 写进 bot-revive-requests.json │ +│ · 把要恢复的目标写进 goal-resume-requests.json │ +│ · 目标恢复:自己写「目标续跑队列项」(照抄运行时形状)⇒ 目标能自己醒 │ +│ · 也读 bot-revive-attempts.json(尝试次数)⇒ 不给叫不醒的 bot 无限重试│ +│ ❌ 不投递 bot 唤醒 ❌ 不碰 bot 会话的队列 ❌ 不碰任何定时任务 │ +└──────────────────────────────────────────────────────────────────────┘ + │ 文件交接(不需要网络、不需要凭据) + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ Host 之内:蹬王之王自己的 MiniApp(Node 进程) │ +│ │ +│ ① 读 bot-revive-requests.json │ +│ 跳过:30 分钟内已 ack / 已试满 3 次 / 超过 24 小时 │ +│ ② agent.run 起一个回合(agent_name = 'mavis' ⇒ 天然带 mavis 工具) │ +│ ⚠️ 复用 bot-wake-host-session.json 里的宿主会话(省 93k 冷启动) │ +│ ③ 回合里的 agent 用 mavis 工具的 session send 送进目标 bot 会话 │ +│ ④ 成功 ⇒ 写 bot-revive-acked.json(带时间戳,只抑制 30 分钟) │ +│ 失败 ⇒ 写 bot-revive-attempts.json(+1,满 3 次作废) │ +│ 巡检间隔 60 秒 │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +> ⚠️ **Rust 与 Node 会并发写同一批文件**(各写各的,但文件相邻)。 +> 临时文件名必须带 pid,尝试次数必须**单独存一个文件** —— +> 否则会互相覆盖对方的半成品,或把守护进程刚写的条目抹掉(lost update)。 +> 详见「踩过的坑」#15。 + +### 代价:每个被唤醒的 bot 要烧一个 agent 回合 + +实测(2026-10-20 00:49,14 个唤醒回合的 `local_runtime_token_usage`): + +| 项 | 单个唤醒回合 | +| --- | --- | +| 模型调用次数 | 2 次(先读工具、再发消息) | +| input tokens | 42,005 | +| output tokens | 374 | +| cache read tokens | 50,939 | +| **合计** | **≈ 93k tokens** | + +(代码注释里记的「47k」是**单次模型调用**的数:第二次调用 46.5k 全是缓存读。) + +⚠️ **93k 是「冷启动」的数字** —— 那是「每次 `agent.run` 都新建会话」时的代价。 +v1.5 起复用唤醒宿主会话(见[唤醒会话复用](#唤醒会话复用省-token)), +系统提示与工具定义进上下文缓存 ⇒ 后续唤醒只付**增量**。 + +⇒ **只在真的有 pending 请求时才跑,绝不空转。** +这条是硬约束:空转一天就是 24×60 = 1440 次 × 93k ≈ 134M tokens, +比它要保护的额度还多。(复用会话后这个数字会明显下降,但「不空转」仍然成立。) + +### MiniApp 生命周期:没有常驻模式 + +`miniapp.json` 的 `lifecycle` 合法值只有 `on-demand` 和 `background`, +但运行时读取时**强制返回 `on-demand`**: + +```ts +// local-runtime-v2/src/service/plugin-system/plugin/package/miniapp/validation.ts:139-145 +const lifecycle = value.lifecycle ?? 'on-demand'; +if (lifecycle !== 'on-demand' && lifecycle !== 'background') { + miniAppFail('RUNTIME_INVALID', 'process lifecycle is invalid'); +} +// `background` was briefly documented before Host admission shipped. Keep +// accepting the spelling at the reader seam without exporting runtime policy. +return { kind: 'process', entry, lifecycle: 'on-demand' }; +``` + +⇒ 写 `background` 也不会常驻,只是**不报错**而已。 +MiniApp 进程由 `ensureMiniAppStarted` 按需拉起。 + +⚠️ **回收机制的真实形状(v1.6 实测纠正)**: +`scheduleIdleDrain`(默认 30 秒空闲回收)**只在 lease 被 release 时**被调用, +而 lease 只在**回合期间**持有 ⇒ **没被用来跑过回合的 MiniApp 永远不会被回收**。 + +⇒ **面板关着 ≠ 唤醒停**:实测 2026-10-20 13:18 起运行时,**44 分钟没开面板仍在跑**, +13:52 / 13:56 的唤醒循环照常投递(Model-Baseline、Data-Ledger)。 + +⇒ **真正的缺口是「宿主重启」**:宿主重启后**不会自动拉起任何 MiniApp** +(`ensureMiniAppStarted` 只在 `miniapp open` 动作里被调)。 +实测宿主 09:39 重启后,蹬王之王的运行时直到 13:18 才起来(我手动 open 的), +官方 minimax-bot 的运行时包还是前一天 21:27 的旧包。 + +⇒ 这段时间里「判定」照常、「投递」空转 ⇒ v1.6 加了[唤醒链路自检](#唤醒链路自检宿主重启后没人投递时必须说话): +守护进程会**明确告警**,而不是假装一切正常。 + +--- + +## 可重试错误:一直重试,不放弃 + +遇到**暂时性**故障时,接口调用会一直重试到成功为止 —— 看板不会变空白, +守护进程也不会把一次抖动当成「没数据」。 + +| 情况 | 行为 | +| --- | --- | +| **429** 负载过高 / 限流 | 一直重试 | +| **500 / 502 / 503 / 504** 内部或网关错误 | 一直重试 | +| 连接重置 / 超时 / DNS 失败 | 一直重试 | +| 401 / 403 登录态失效 | 重新取 token,再试一次 | +| 其他 4xx | 快速失败(重试没有意义) | +| 业务错误(参数非法、额度不足) | 快速失败 | + +退避是指数增长 + 抖动,**封顶 60 秒**:第一次约等 1 秒,此后任何一次都不会 +等超过一分钟,所以「无限」不会变成「疯狂打接口」。每次重试都打日志 —— +卡住的请求必须能说明自己在等什么。 + +重试是**可中断**的:`SIGINT` / `SIGTERM`(`launchctl` 发的就是这两个) +会让正在重试的请求立刻放弃,所以即使卡在坏掉的接口上,守护进程也能停下来。 + +扣卡也走重试路径,这**是安全的**:请求带 `out_biz_no`,服务端以它为幂等键, +重复提交只会回放结果(`REPLAYED`),不会扣掉第二张卡。 + +## 数据完整性:读不到 ≠ 没有 + +「读到余额 0」和「没读到余额」是两件事。以前积分接口一失败, +代码就当成「没有可用积分」,把结论从「留卡」翻转成「用卡」—— +而扣卡是真实写操作,网络故障会导致误扣。 + +现在读到不完整会**如实报为不完整**,本轮**完全不做写操作**(`wait`)。 +守护进程侧同理:平台积分或 Code 积分读取失败 ⇒ 标记数据不完整, +而不是「记个日志就忽略」。 + +看板侧还多留了一份**原始卡列表**(带 `card_id`),与给页面看的脱敏列表并存。 +脱敏列表没有 `card_id`,拿它判断「这张卡还在不在列表里」恒为 false —— +那正是扣卡每次都被跳过、`reset_card/use` 从未被调用的原因。 + +## 完整闭环:一条消息怎么走完全程 + +### 目标暂停 / 恢复 + +``` +额度到线 + │ + └─▶ Rust 守护进程(无 token、无人值守、在 Host 之外) + ① 判定:写入 local_runtime_thread_goals 把目标置为 paused + ② 记账:goal-pause.json 记下「哪些是**我们**停的」 + ③ 写恢复请求:goal-resume-requests.json + ④ 唤醒:直接写 local_runtime_queue_items 的**目标续跑项** + (照抄运行时 submitActiveContinuation 的形状: + client_request_id 前缀 thread-goal-followup:rearm:: + data_json 带 origin.goalUpdatedAt(epoch)与 origin.objectiveDigest + routing_fingerprint 与 Host 自己的写入逐字一致) +``` + +> 💡 目标恢复**不需要 MiniApp 参与**:守护进程把续跑项按运行时的形状写进队列, +> Host 的队列轮询器就认它(`isThreadGoalContinuationQueueItem()` 靠前缀 + origin 识别)。 +> 这是「照抄运行时自己的传输层」,不是私造旁路。 + +### Bot 唤醒 + +``` +Bot 会话超过 idleMs 没动静 + │ + └─▶ Rust 守护进程 + ① 判定:classify_error() 分出「可自愈 / 需人工」 + ② 可自愈 ⇒ 写 bot-revive-requests.json(只写文件,不投递) + ③ 需人工 ⇒ 记 bot-notified.json(去重),只提示不重试 + │ + ▼ +蹬王之王自己的 MiniApp(60 秒一轮) + ④ 读 bot-revive-requests.json + (跳过:30 分钟内已 ack / 已试满 3 次 / 超过 24 小时) + ⑤ agent.run 起一个回合(agent_name = 'mavis') + ⚠️ 复用 bot-wake-host-session.json 里的宿主会话(不新建 ⇒ 省冷启动) + ⑥ 回合里的 agent 用 mavis 工具的 session send 送进目标 bot 会话 + ⑦ 成功 ⇒ 把 id 写进 bot-revive-acked.json(带时间戳,只抑制 30 分钟) + 失败 ⇒ 把 id 写进 bot-revive-attempts.json(+1;满 3 次作废) +``` + +> 💡 **④ 的跳过条件为什么要分开存**: +> 「已 ack」与「已试满 3 次」是**两件事**,而「试满」的记录一旦被清掉就会**无限重试**。 +> ⇒ attempts 单独存一个文件,且**只按「已 ack」或「超龄」清**(见「踩过的坑」#15)。 + +### 完整的架构边界:谁能暂停、谁能起回合 + +2026-10-19 / 2026-10-20 实测逐条验过,结论如下(**每一条都有证据**): + +| 谁 | 能暂停目标 | 能起回合(恢复) | 证据 | +| --- | --- | --- | --- | +| **守护进程**(Host 之外) | ✅ 写 DB,运行时回合边界会看到 | ✅ 写**目标续跑队列项** | 队列项必须照抄运行时形状;前缀不对会被当成普通消息,目标续跑通道根本不认 | +| **蹬王之王自己的 MiniApp**(Host 内) | ❌ | ⚠️ 只能起**自己名下**的回合 | `assertSessionOwnership`(`gateway.ts:828`):`owner !== caller.pluginId` ⇒ `SESSION_NOT_FOUND` | +| **别的 MiniApp**(如 minimax-bot) | ❌ | ❌(对蹬王之王的目标会话) | 同上 | +| **Host 主进程**(Electron IPC) | ✅ | ✅ | 但**无 TCP 端口**,外部连不上;MiniApp 端口的 `loopbackToken` 是 per-process 的 | + +⇒ **结论**: +恢复目标有两条路 —— 守护进程**自己写队列项**(目标续跑项),或 +**自己的 MiniApp 起回合 + `session send`**(bot 会话)。 +**唯一走不通的是「直接 `agent.run` 续别人的会话」**。 + +⇒ **因此采用「双组件」分工**: +- **守护进程**(无 token、无人值守):判定 → 暂停 → 记账 → 写请求文件。 + 目标恢复它**自己**写队列项;bot 唤醒它**只写文件、不投递**。 +- **蹬王之王自己的 MiniApp**(Host 内):读请求 → 起回合 → `session send` → 记 ack。 + 它只负责 **bot 唤醒**这一条通道(目标恢复不需要它参与)。 + +--- + +## v1.1 更新内容 + +> 这一节是 v1.1 的历史记录,**保留不改**。其中与定时任务有关的 4 条, +> 在 v1.4 已随「彻底去掉定时任务依赖」而消失,表格里标了。 + +### 修了什么 + +| # | 缺陷 | 后果 | 证据 | v1.4 现状 | +| --- | --- | --- | --- | --- | +| 1 | `resume_goal` 的 SQL 里 `WHERE goal_id='{goal_id}'` 是**字面量**(Rust `format!` 不替换裸花括号) | UPDATE 恒影响 0 行,但 sqlite3 照样退出码 0 ⇒ **恢复永远假成功**。haisi 目标从 2026-10-01 起就一直停在 `paused`,而日志在 10-19 的 14:43 / 17:06 两次宣称「已恢复」 | 真实库对照:字面量匹配 **0** 行,真实 id 匹配 **1** 行 | ✅ 仍在 | +| 2 | `reconcile_resume_cron` 只挂在恢复分支 | 额度持续高于触发线时走暂停分支 ⇒ 定时任务**永远不被解除武装**,每 17 分钟空跑一轮 | 19:46–20:00 期间 `run_count` 涨到 6,日志一次「解除武装」都没有 | ⛔ **已随 v1.4 移除定时任务而消失** | +| 3 | Mini App 的 `normalizeConfig()` 漏掉 `resumeCronId` | 用户在页面改任何设置 ⇒ cron id 被抹 ⇒ 恢复机制**静默失效**,无任何报错 | `normalizeConfig` 是白名单式重建 | ⛔ **已随 v1.4 移除定时任务而消失**(同款教训改挂在 `botGuard` 上,见「踩过的坑」#10) | +| 4 | 账本清空前不回读目标状态 | 「请求写成功」≠「目标真的活了」⇒ 目标实际还是 `paused` 时账本已被清空 ⇒ 恢复后目标被运行时忽略 | 20:22 实测:账本空、目标 paused | ✅ 仍在 | +| 5 | 复活判据只看会话级 `error_code` + 超龄 | 会话级 error_code 是**上一次** 500 的陈旧值 ⇒ 把**正在干活的 20 分钟长回合**误判成卡死 ⇒ 多余的复活打断正常运行 | 实测那个回合每 10–20 秒一次 `response_status=200` | ✅ 仍在(看门狗侧同样用 `MAX(created_at_ms)` 判活) | +| 6 | 武装定时任务时 `next_run_at_ms` 设成 `now+60s` | 表达式 `4/17 * * * *` 只在每小时 **4、21、38、55 分**触发 ⇒ 不对齐就会被调度器跳过 | 对齐到 20:21:00 后立刻触发(`run_count` 0→8) | ⛔ **已随 v1.4 移除定时任务而消失** | +| 7 | 值守会话被归档 | 定时任务每次触发都 `failed` / `CRON_SESSION_TARGET_ARCHIVED` | `local_runtime_v2_cron_runs` 里 18:38 之后 7 次触发全失败 | ⛔ **已随 v1.4 移除定时任务而消失**(教训保留,见「为什么彻底去掉定时任务」) | +| 8 | 队列残留不清 | 定时任务的 `session send` 残项让会话判 busy ⇒ 下次投递**退化成排队** | haisi 会话积了 5 条 `source='communication'` 残项 | ✅ 仍在(清理逻辑保留) | +| 9 | 账本重复记账 | 目标被运行时回弹后重新暂停,旧条目还在账本里又 push 一条 ⇒ 同一目标处理两次,且 `trigger_used_percent` 被覆盖(99→100) | 19:19 实测 | ✅ 仍在 | +| 10 | 运行时回弹被误判成「用户手动处理」 | 回弹(回合结束后运行时把目标改回 active)被当成用户操作剔出账本 ⇒ 恢复时不再放回它 ⇒ 目标永久停在 active 且不被我们拥有 | 18:59→19:13 实测 | ✅ 仍在 | +| 11 | 配置里的定时任务 id 丢失后没有任何告警 | 整个恢复机制无声失效 | 当时每轮都打 `cron 值守状态: pending N 个,cron 已武装/未武装` | ⛔ **已随 v1.4 移除定时任务而消失**(现在改由 `bot-notified.json` / ack 表承担去重与告警) | + +### 加了什么 + +- **「假 started」判据**:回合被 500 打死后会话 status 卡在 `started` 不清 ⇒ + 原来 `status <> 'started'` 把它排除在复活之外(目标 active 但 turns 不动、日志一次复活都没有)。 + 现在纳入这一路。 +- **第二道闸(防误杀长回合)**:用 `local_runtime_message_rows.created_at_ms` + (每条 assistant 消息含每次工具调用往返都落一行)作为**回合级活动信号**。 + ⚠️ 不能用 `turn_ingress.accepted_at_ms` —— 那是回合**开始**时间,长回合一样超龄。 +- **账本清空前的回读校验**:状态不是 `active` 就保留在账本里继续观察,并打出告警。 +- **配置自检告警**:`goal_pause.trigger_percent=101 超出 1..100,已夹到 100`。 + +### 实测验证到的闭环 + +``` +17:05.359 Goal 暂停: 已暂停 2 个目标(5 小时已用 99.0%) +17:05.445 ✓ 已使用重置卡 423331,额度已重置 ← 83ms 内完成暂停+扣卡 +17:06.728 Goal 恢复: 已恢复 2 个目标(5 小时已用 1.0%,额度已回落) +``` + +以及 18:59–20:00 这一轮「**无卡时的正确降级**」: + +``` +18:59.759 Goal 暂停: 已暂停 2 个目标(5 小时已用 99.0%) +18:59.759 决策[keep-credit]: 没有可用的重置卡,让 Code 积分·赠送(11月18日到期)先消耗 +19:00.236 决策[wait]: 额度已耗尽,既没有积分也没有可用的重置卡 +20:00.281 额度: 5小时限额=已用0% / 剩余100% ← 窗口自然刷新 +``` + +⇒ **有卡、无卡、临近刷新三条分支都验过**,无卡时不会反复尝试扣卡。 + +### 验证方法(可复用) + +> **「这条修复到底生效没有」要在真实环境上造一个受控样本。** +> +> 「这一轮没报错」不能证明「修好了」——那只是**没跑到那条分支**。 +> 正确做法:造一个能触发它的样本,走完整真实链路,**回读结果**,然后清理。 +> 实测用这个方法抓出了缺陷 1(假成功):日志说「已恢复」, +> 而目标行回读仍是 `paused` —— 差一步回读校验就得出完全相反的结论。 + +--- + +## 踩过的坑(三十个都写在这里了) + +1. **Rust `format!` 不替换裸花括号** ⇒ `WHERE goal_id='{goal_id}'` 是字面量 ⇒ UPDATE 影响 0 行却退出码 0 ⇒ **假成功**。 + ⇒ 凡靠 UPDATE 改状态的判据,**必须回读校验**。「命令执行成功」≠「改到了行」。 + +2. **库里的唯一索引不唯一**:`idx_local_runtime_queue_items_session_client_request` + 在迁移源文件里写的是 `CREATE UNIQUE INDEX`,**真实运行库里是普通 `CREATE INDEX`**。 + ⇒ `INSERT OR IGNORE` 拦不住重复,实测同 crid 插出了 6 条,把队列堵死。 + +3. **队列项会把会话判成「busy」**,从而让唯一能用的 `session send` 只能排队。 + ⇒ 守护进程**不再往队列表投 bot 唤醒消息**(bot 唤醒根本不该碰队列), + 只清理自己造下的残留;且清理必须有**内容级护栏**(只删自家消息,用户手动消息同源)。 + +4. **同一 epoch 的请求被 ack 过之后,守护进程重写它也不会再被处理** + (ack 列表去重)⇒ 写请求时必须同步把该 id 从 ack 列表里摘掉。 + +5. **回合结束后运行时会把目标改回 `active`**(回合边界回弹)。 + ⇒ 账本对账不能只看 `status != 'paused'` 就当「用户手动处理」。 + +6. **不能用 `goal.updated_at_ms` 判断「有没有被改过」** —— + `pause_active_goals` 自己在暂停时就把该字段改写成 `now_ms`, + 所以 `updated > paused_at` 恒为 false。 + ⇒ **判据要用你改不动的第三方事实**(如 `turn_ingress.completed_at_ms`)。 + +7. **目标会被官方 API 硬删除**(MCode 界面层发 `DELETE /session/:id/goal`), + `local_runtime_thread_goals` 的行**硬删除**(表里只有 `dismissed_at_ms` 一个软删字段, + 而被删的目标该字段也没写)。 + ⇒ 任何以 goal_id 为键的外部账本,都必须处理「键已不存在」的情况。 + +8. **回合会挂 20 多分钟但完全正常** —— + 实测一个 R034 引擎实测回合跑了 20+ 分钟,期间每 10–20 秒一次 `response_status=200`。 + ⚠️ **长回合 ≠ 死锁**。判断死活要看**有没有新活动**(`message_rows.created_at_ms`), + 不是看 `status='accepted'` 超了多久。 + +9. **⛔ 已随 v1.4 移除定时任务而消失(原坑:定时任务的目标会话会被运行时自动归档)** —— + 实测 `session_kind='cron'` 的会话在跑完后被发 `POST /session/:id/archive`, + 随后每次触发都 `CRON_SESSION_TARGET_ARCHIVED`。 + 全库分布:`kind=cron` 的 1 个全被归档,`kind=conversation` / `task` 的不受影响。 + ⇒ **教训保留**:**任何由专用宿主承载的值守都不可靠**(宿主会被归档、限流、回收)。 + 现在值守跑在「MiniApp 自己起的 agent 回合」上,不依赖任何长期存活的专职会话。 + +10. **白名单式重建配置的函数,漏一个字段就是一条静默失效的通道**。 + v1.1 挂在 `resumeCronId`(已随 v1.4 删除),v1.4 之后挂在 **`botGuard`**: + `normalizeConfig()` 是白名单式重建,漏字段 ⇒ 用户在页面改任何设置就会把字段抹掉 ⇒ + 对应功能**静默失效**、无任何报错。 + ⇒ 删字段必须**三处同步删**(Rust 配置、MiniApp `normalizeConfig`、真实 `config.json`)。 + +11. **⛔ 已随 v1.4 移除定时任务而消失(原坑:`run_count` 在涨不代表定时任务真的在跑)** —— + 实测 8 次触发里只有第 1 次 `delivered`,后面 7 次全是 `failed`。 + ⇒ 教训保留:**任何「计数器在涨」都不能当「功能在跑」的证据**,要看真实的成败记录。 + +12. **🆕 裸写队列项不会被消费**(2026-10-20 实测)—— + 往一个挂着 50113 的 bot 会话直接写一条 `local_runtime_queue_items` 行, + 3 分钟后仍是 `queued`、未 claim、无 turn、无消息; + 同一会话用官方 `session send` 则 5 秒内 accepted 并被 bot 回复。 + ⇒ **Host 的队列轮询器只认经过 steer 的行**。绕开 steer 塞行 = 白写。 + +13. **🆕 不能直接 `agent.run` 续别人的会话**(2026-10-20 实测)—— + `service/miniapp/agent/gateway.ts` 的 `assertSessionOwnership`: + `owner !== caller.pluginId` ⇒ 抛 `SESSION_NOT_FOUND`。 + 实测 45 个 bot 会话的 `purpose` 全是 `miniapp:minimax-bot` + ⇒ 蹬王之王对它们直接 `agent.run` **永远被拒**(实测每 30 秒 6 条全拒)。 + ⇒ 绕法:**起一个自己的回合,让回合里的 agent 用 mavis 工具的 `session send`** —— + 那条路**不做 owner 检查**(`session send` 的准入是「目标可达即可」)。 + +14. **🆕 队列表的 `status` / `claim_id` 不能当「有没有被送达」的判据**(2026-10-20 实测)—— + `session send` 写进去的行,**送达成功后仍然停在 `status='queued'`、`claim_id=NULL`** + (实测 5 行如此,而对应的回合确实跑完并回复了)。 + 真正的送达记录在 `data_json.deliveryAttempts[]` 里, + 而**没有 `deliveryAttempts`** 的行 = 没人 claim = 没起过回合。 + ⇒ 判「有没有被唤醒」要看 `local_runtime_turn_ingress`,**不要**看队列表的状态列。 + +15. **🆕 并发写同一个文件:三个坑**(2026-10-20 审计发现)—— + 守护进程(Rust,Host 之外)与投递端(Node,Host 之内)会**并发**写同一批文件。 + - **临时文件名撞车**:两边原来都用 `.json.tmp` ⇒ 互相覆盖对方的半成品 ⇒ **丢请求**。 + ⇒ 各自带 pid(Rust `json.{pid}.tmp` / Node `${file}.${pid}.tmp`)。 + - **lost update**:投递端做「读全量 → 改字段 → 写回全量」会把守护进程刚写进去的条目 + **一起抹掉**(两次写可能只隔几毫秒)。 + ⇒ 尝试次数**单独存一个文件**(`bot-revive-attempts.json`),两边各写各的。 + - **清理条件**:不能按「requests 文件里还在不在」清 attempts —— + Rust 的 prune 会删掉「次数用尽」的请求 ⇒ 连带把 attempts 记录删掉 ⇒ + Rust 读到 0 ⇒ 重新入队 ⇒ 再试 3 次 ⇒ **3 次封顶被架空,变成无限重试**。 + ⇒ 只按「已 ack」或「超龄」清。 + ⇒ **教训**:跨进程交接的文件,**每一次写都要假设对方正在同时写**。 + +16. **🆕 「救过一次」不等于「永远不救」**(2026-10-20 实测)—— + acked 原来是个纯 id 列表 ⇒ 永久黑名单。 + 实测:6 个 bot 明明又坏了(闲置 47~94 分钟,远超 90 秒阈值), + 却因为 acked 里有过它们而**永远不再被叫醒**。 + ⇒ acked 带时间戳,只抑制 30 分钟;旧格式(纯字符串)视为「很久以前」,不抑制。 + +17. **🆕 「不知道成没成」不能当成「没成」再投一次**(2026-10-20)—— + `AGENT_OUTCOME_UNKNOWN`(回合可能已经跑了,Host 标 never retryable) + 若只 bump 尝试次数而不 ack ⇒ 60 秒后下一轮会再投同一条 + ⇒ bot 收到**两条**重复的「请从中断处继续」。 + ⇒ 这类直接 ack,不重投。同理 `BUSY` / `session_busy`(会话已有回合在飞)也**不是重发**。 + +18. **🆕 401 只重试 fetch 无解**(2026-10-20 实测)—— + MiniApp 被 Host 重启后 loopback 凭据会换新;Electron 主进程按 **webContentsId** + 注入凭据(`miniapp-loopback-auth.js`),页面的 fetch 拿不到新 token ⇒ 一直 401。 + 而 `syncMiniAppLoopbackCredential` 只在 origin 不变时才同步 + (重启后端口变了 ⇒ 跳过)⇒ **光重试 fetch 无解**。 + ⇒ 唯一能拿到新凭据的途径是**重新导航**。 + 客户端做法:连续 **3** 次 401 ⇒ `window.location.reload()`; + 重载次数用 sessionStorage 记、**上限 5 次**(没上限会变成「重载后仍 401 ⇒ 无限重载」); + 成功一次就清零。 + +19. **🆕 声明了 `mcpEndpoints` 就得真的实现,而且**只能声明你想清楚的那几个**(2026-10-20 反复踩到)—— + Host 发布时会用 `initialize` + `tools/list` 探活,探不通就发布失败。 + 而且 Host 内存里的「已发布快照」是**粘的**:一旦某个版本的 manifest 声明过端点, + 后续版本**必须继续声明同样的端点**,否则 `miniapp publish` 被 `MCP_PUBLICATION_INVALID` + 挡下(`used.size !== expected.size`)。 + ⇒ v1.6 的解法:**manifest 只声明 1 个端点** + (`server=expiry-guardian`, `path=/mcp/expiry-guardian`), + 相应 `servers.mcp.json` 也只留 1 个 server, + 且 `plugin.json` 要写 `"mcpServers": ["servers.mcp.json"]`。 + ⚠️ 之前把 `minimax-bot` 那个端点也留着(同一个 handler 挂两条路径), + 反而让 Host 的 accepted 快照对不上 ⇒ publish 卡死。 + ⚠️ 另外,**插件包的 `package.json` 必须有 `"name": "expiry-guardian"`** —— + 运行时判定「这是不是蹬王之王的包」就靠这个字段(见[唤醒链路自检](#唤醒链路自检宿主重启后没人投递时必须说话))。 + +20. **🆕 工作区与插件包各有一份测试,布局还不同**(2026-10-20 反复踩到)—— + 工作区在 `miniapps/expiry-guardian/miniapp/node/*.test.mjs`, + 插件包在 `plugins/hylouis233/expiry-guardian/tests/*.test.mjs`。 + 改了一边忘了另一边 ⇒ 插件包跑的是**旧测试**,出现「17/18」这种看不懂的结果。 + ⇒ 新增 `check-test-parity.sh`:只比**测试文件内容**(归一化 import 路径与空白), + 不一致就报错并 diff。 + +21. **🔴 一个被截断的代理对转义,能让整个功能静默失效**(2026-10-20 实测,v1.6 修复)—— + `~/.minimax/v2/plugin-data/liveboards/minimax-bot/state.json` 里有一个 + **孤立的 `\ud83d`**(高代理在、低代理丢失)。 + Python 的 `json.loads` 与 Node 的 `JSON.parse` **都能解析**, + **Rust 的 `serde_json` 严格拒绝** ⇒ `read_bot_sessions()` 返回空数组 + ⇒ `bot_guard::tick` 提前 return ⇒ **整个 Bot 看门狗静默失效**: + 守护进程在跑、日志干净、**实际一个 bot 都没看护**。 + ⇒ 教训一:**跨语言读同一份 JSON,必须让最严格的那个先过**。 + ⇒ 教训二:**「读不到」不能被当成「没有」**—— + 解析失败必须**显式区分**于「文件里确实没有 bot」。 + ⇒ 修法:`parse_lenient_json()` —— 严格解析失败时,把**孤立的高代理转义** + (`\udXXX` 后面不跟低代理)替换成 U+FFFD 再解析; + **合法的代理对(`\ud83d\ude00`)必须原样保留**。 + 实测对比:修复前 4 次重启共 45 分钟**零条**看门狗日志; + 修复后立刻报「检查 45 个,待人工 7 个,可自愈 11 个」。 + +22. **🆕 「面板关着唤醒就不发生」是错的结论**(2026-10-20 实测推翻,v1.6 纠正)—— + 实测运行时 **44 分钟没开面板仍在跑**,13:52 / 13:56 的唤醒循环照常投递。 + 回收机制 `scheduleIdleDrain` **只在 lease 被 release 时**被调用, + 而 lease 只在**回合期间**持有 ⇒ 没被用来跑过回合的 MiniApp 永远不会被回收。 + ⇒ 真正的缺口是**宿主重启**(宿主重启后不会自动拉起任何 MiniApp)。 + ⇒ 教训:**「看起来应该会发生」不能当证据**,要去看调度代码 + 拉时间线实测。 + +23. **🔴 陈旧的 `error_code` 会让你去「复活」好端端的 bot**(2026-10-20 实测,v1.6 修复)—— + 会话上的 `error_code` 是**粘住**的:运行时跑完一个成功回合**不会清它**。 + 实测 15:39 那一轮写了 11 条唤醒请求,其中 **8 条是误判** —— + 那 8 个 bot 的**最新回合是 `completed`**(好着呢),错误码是更早那次失败留下的。 + ⇒ 判「坏没坏」必须看 `local_runtime_turn_ingress` 里**最新回合的状态**, + 别拿对象身上挂着的标签当现状。 + ⇒ 教训:**「粘在对象上的状态」会被后续的成功操作留成陈值**; + 要判现状就看**最近一次动作的结果**。 + +24. **🔴 一个「窗口」同时管两件事 ⇒ 两头都错**(2026-10-20 独立审计 + 实测,v1.6 修复)—— + acked 的 30 分钟窗口同时承担「这条投递过了吗」与「这个 bot 多久能再救」。 + ⇒ 窗口一过,**已投递过的请求又变回未处理** ⇒ 投递端**每 30 分钟重投一次** + (每回合 ≈47k tokens),还会打断已恢复的 bot。 + ⇒ 反过来,窗口内如果 bot 又坏了,真挂的 bot 也**叫不醒**。 + ⇒ 修法:**拆成两个键**。「投递过了吗」用**永久**的请求 id; + 「多久能再救」用 **bot 键 + 时间戳**(守护进程写请求时判断)。 + ⇒ 附带一条:**改了键的粒度,所有用到这个键的比较都要跟着改** —— + 实测「永久 ack 集」和「旧格式的 bot 键」字符串完全相同, + 一行 `acked.contains(&bot_key)` 就把两个真坏的 bot 永久挡死了。 + +25. **🔴 一个拼写错误把整个面板搞挂了 4 个版本**(2026-10-20 实测,v1.6 修复)—— + `doBuildSnapshot()` 里 `platform` 写成 `platformRes` ⇒ `ReferenceError` + ⇒ `buildSnapshot()` 整个抛异常 ⇒ 额度/决策/重置卡/积分**全拿不到**。 + ⇒ 为什么没人发现:单测测的是**纯函数** `decide()`, + **取数那层(`doBuildSnapshot`)从来没被跑过**。 + ⇒ 教训:**单测覆盖了「算法」,不等于覆盖了「接线」**。 + ⇒ 补法:① 静态守卫(`server-lint.test.mjs`)抓「用了但没声明的标识符」; + ② 用 MCP 工具当**线上金丝雀**——`quotaError` 非空就说明取数层炸了。 + +26. **🔴 `async` 的依赖漏了 `await` ⇒ 工具永远返回 `{}`**(2026-10-20 实测,v1.6 修复)—— + MCP handler 里 `JSON.stringify(deps.status(), null, 2)`, + 而 `deps.status` 是 `async` ⇒ 拿到的是 **Promise** ⇒ + `JSON.stringify(Promise)` = 字符串 `"{}"`。 + ⇒ 工具「成功返回」,但内容永远是空的 —— 比直接报错更难发现。 + ⇒ 教训:**同一段代码里 await 了一处、漏了另一处,肉眼几乎看不出来**; + 必须有一条测试专门盯「返回值不是空」。 + +27. **🔴 永久化的集合会让「清理条件」变成误伤**(2026-10-20 二轮审计,v1.6 修复)—— + acked 从「带 30 分钟窗口」改成「永久」之后,所有**按 acked 清理**的代码 + 语义都变了:「刚刚被救过」⇒「**曾经**被救过」。 + 实测:失败计数(3 次封顶的依据)因此被清掉 ⇒ **封顶失效 ⇒ 无限重投**。 + ⇒ 教训:**改一个集合的语义,要把它下游的每个使用者都重新审一遍**。 + ⇒ 修法:失败计数**只在成功时归零**,失败路径的清理只按超龄。 + +28. **🔴 按字节切 `&str` 会 panic**(2026-10-20 二轮审计,v1.6 修复)—— + `&text[i+2..i+6]` 在 `i` 落在多字节字符中间时直接 **panic** + ("end byte index 12 is not a char boundary")。 + ⇒ 这会把「读不到数据」的静默故障升级成「**整个守护进程崩掉**」。 + ⇒ 教训:处理**用户产生的内容**时,切片前必须确认字符边界; + 拿不准就用字节数组自己解析。 + ⇒ 再加一道 `catch_unwind`:一个子系统 panic 不该带走其它子系统。 + +29. **🟠 互为否定的两道闸 = 一道死闸**(2026-10-20 二轮审计,v1.6 修复)—— + 判定层写 `None => !is_idle(...)`,调用方又写 `if !is_idle(...) { continue; }` + ⇒ 两处永远不可能同时成立 ⇒ 「没有回合记录」的会话**永远走不到救援**。 + ⇒ 教训:写「兜底分支」时要在脑子里跑一遍**它能不能真的走到**。 + +30. **🟠 「正在跑」和「已经死了但还没被收尸」长得一样**(2026-10-20 二轮审计,v1.6 修复)—— + 回合状态 `accepted` 既表示「正在跑」,也表示「**宿主崩了留下的僵尸**」。 + 实测生产库有 3 条这样的僵尸回合(最老的 2 小时前还停在 `accepted`)。 + ⇒ 只看状态会漏救 ⇒ 还得看**停了多久**。 + ⇒ 教训:**状态要配时间维度**,光看状态分不清「进行中」和「卡住了」。 + +### 查运行时问题的入口 + +```bash +# bot 会话状态(权威:status / error_code / 最后活动时间) +sqlite3 ~/.minimax/v2/sqlite/runtime-state.sqlite \ + "SELECT s.session_id, s.status, IFNULL(s.error_code,'-'), + datetime(MAX(m.created_at_ms)/1000,'unixepoch','localtime') AS last_activity + FROM local_runtime_sessions s + LEFT JOIN local_runtime_message_rows m ON m.session_id = s.session_id + WHERE s.purpose='miniapp:minimax-bot' + GROUP BY s.session_id ORDER BY last_activity DESC LIMIT 20;" + +# 某个 bot 会话最近的回合(判「有没有被唤醒」看这里,不要看队列表) +sqlite3 ~/.minimax/v2/sqlite/runtime-state.sqlite \ + "SELECT turn_id, status, source, + datetime(accepted_at_ms/1000,'unixepoch','localtime') acc, + datetime(completed_at_ms/1000,'unixepoch','localtime') done + FROM local_runtime_turn_ingress WHERE session_id='' + ORDER BY accepted_at_ms DESC LIMIT 5;" + +# 目标状态(恢复是否真的生效,回读这里) +sqlite3 ~/.minimax/v2/sqlite/runtime-state.sqlite \ + "SELECT goal_id,status,IFNULL(status_reason,'-'),turns_used FROM local_runtime_thread_goals;" + +# 某个会话最后一个回合的「最后活动时间」(判死活用这个) +sqlite3 ~/.minimax/v2/sqlite/runtime-state.sqlite \ + "SELECT turn_id, datetime(MAX(created_at_ms)/1000,'unixepoch','localtime') + FROM local_runtime_message_rows WHERE session_id='' GROUP BY turn_id + ORDER BY MAX(created_at_ms) DESC LIMIT 3;" +``` + +主进程日志在 `~/Library/Logs/MiniMax/main-*.log`,但**会被几万行 permission 决策日志淹没** —— +搜的时候要过滤掉 `permission` / `cmdPreview` / `sandbox.invocation` 这些噪音。 + +--- + +## 扣卡规则(与 Mini App「蹬王之王」逐条一致) + +1. **提前一个百分点动手**:触发线默认 **99%**(`used >= total × 99%`)。 + 不等真正耗尽——等耗尽再处理,中间那次请求已经失败,会打断正在跑的自动执行。 +2. **先过期先用**: + - 积分先过期 → **不用卡**,让积分先消耗(卡更耐放,留到以后) + - 重置卡先过期 → **用卡**把额度重置回来,保住更耐放的积分 +3. **距刷新太近就不烧卡**(`cardSkipBeforeResetMinutes`,默认 60 分钟)—— + 额度马上就自己回来了,这时候扣卡是纯浪费。 +4. **没卡就不硬来**:有积分没卡 → 让积分先消耗;连积分都没有 → `wait`,不反复尝试。 +5. 跳过已过期卡,只用未过期的。 +6. 先扣积分里**最早到期**的那一笔(平台积分池 + Code 积分明细,两处取更早的)。 + +### 完整分支表 + +| 条件 | 动作 | +| --- | --- | +| 未到触发线 | `idle` | +| 耗尽 + 无积分 + 有卡 | `use-card` | +| 耗尽 + 无积分 + 无卡 | `wait` | +| 耗尽 + 有积分 + 无卡 | `keep-credit` | +| 耗尽 + 都有,卡先过期 | `use-card` | +| 耗尽 + 都有,积分先过期 | `keep-credit` | + +### 一致性是测出来的,不是声称的 + +| 层 | 内容 | 现状 | +| --- | --- | --- | +| 1 | 后台决策单测,逐条复刻上表每个分支 | **20/20** | +| 2 | 跨语言一致性:同一组输入分别喂给 Rust 的 `decide()`、Node 的 `decide()` 与 Python 的 `decide_background()`,比对动作与**理由文本逐字** | **12** 例快照 | +| 3 | **校验器自检**:故意注入一处漂移,要求校验器必须报错 | ✓ 能抓到 | + +第 3 层是刻意设计的。一个永远显示「一致」的校验器等于没有校验, +所以必须证明它对已知错误会失败。 + +Rust 单测 **51/51**(其中 8 个是 v1.1 新增的回归测试,每个对应上表的一个缺陷: +`resume_sql_contains_the_real_goal_id`、`resume_goal_reports_missing_goal_instead_of_fake_success`、 +`session_id_whitelist`、`ledger_does_not_duplicate_existing_goal`、 +`runtime_rebound_is_not_user_handled`、`stale_started_requires_error_code`、 +`deleted_goal_is_treated_as_handled`、`purge_only_targets_our_own_resume_messages`)。 + +MiniApp 单测 **112/112**: + +| 测试文件 | 覆盖 | 现状 | +| --- | --- | --- | +| `bot_wakeup.test.mjs` | 唤醒判据、attempts、acked 窗口、宿主会话复用 | **35/35** | +| `config-fidelity.test.mjs` | 配置白名单重建不丢字段 | **15/15** | +| `decision.test.mjs` | 扣卡决策分支 + 走重试路径 | **58/58** | +| `retry.behavior.test.mjs` | 真实重试行为(mock server,不触网) | **4/4** | + +单独跑: + +```bash +cd daemon/rust && cargo test --release # 51/51 +cd daemon && python3 bg_decision.test.py # 20/20 +cd daemon && node verify-parity.mjs # 三方逐字比对 +cd daemon && ./check-parity.sh # 一次跑完 +cd miniapp && node node/decision.test.mjs # 58/58 +cd miniapp && node node/config-fidelity.test.mjs # 15/15 +cd miniapp && node node/bot_wakeup.test.mjs # 35/35 +cd tests && node retry.behavior.test.mjs # 4/4 +./check-test-parity.sh # 工作区 ↔ 插件包测试一致? +``` + +> ⚠️ **已知问题(2026-10-20 仍未修)**:`daemon/verify-parity.mjs`(以及包着它的 +> `daemon/check-parity.sh`)是用 `data:` URL 动态 import `miniapp/node/server.mjs` +> 来取 `decide()` 的。v1.4 给 `server.mjs` 加了相对导入 `./bot_wakeup.mjs`, +> `data:` URL 解析不了相对路径 ⇒ 仍报 `ERR_UNSUPPORTED_RESOLVE_REQUEST`。 +> 修法:改成把 `server.mjs` **写到临时文件**再 import(而不是 base64 进 `data:` URL)—— +> `tests/retry.behavior.test.mjs` 已经这么改好了,可以直接照抄它的写法。 + +> 💡 **为什么还需要 `check-test-parity.sh`**: +> 工作区源码与插件包**各有一份测试文件**,而且路径布局不同 +> (工作区 `./x.mjs`,插件包 `../miniapp/node/x.mjs`)—— +> 改了一边忘了另一边,插件包跑的就是旧测试。 +> 这个脚本只比**测试文件的内容**(归一化 import 路径与空白),不一致就报错并 diff。 + +--- + +## 管理命令 + +```bash +# 状态 +launchctl list | grep reset-card + +# 停 / 启 +launchctl unload ~/Library/LaunchAgents/com.local.mcode-reset-card.plist +launchctl load ~/Library/LaunchAgents/com.local.mcode-reset-card.plist +# 或直接重启(会重载配置) +launchctl kickstart -k "gui/$(id -u)/com.local.mcode-reset-card" + +# 看日志 +tail -f /tmp/mcode-reset-card.log +tail -f /tmp/mcode-reset-card.err + +# 手动判定一次(只读,不扣卡) +python3 ~/mcode-reset-card-autouse/reset_card_autouse.py --dry-run once + +# 跑测试 +cd ~/mcode-reset-card-autouse/rust && cargo test --release + +# 看唤醒请求与回执(排查「bot 没被唤醒」) +cat ~/.config/mcode-reset-card/bot-revive-requests.json # 待投递 +cat ~/.config/mcode-reset-card/bot-revive-acked.json # 已处理(带时间戳;**永久**记为「投递过」) +cat ~/.config/mcode-reset-card/bot-revive-attempts.json # 尝试次数(满 3 次作废) +cat ~/.config/mcode-reset-card/bot-wake-host-session.json # 复用的唤醒宿主会话 id +cat ~/.config/mcode-reset-card/bot-notified.json # 「需要你处理」去重表 +``` + +### 请求文件全表 + +| 文件 | 谁写 | 谁读 | 作用 | +| --- | --- | --- | --- | +| `config.json` | 用户 / MiniApp | 守护进程 + MiniApp | 共享配置 | +| `goal-pause.json` | 守护进程 | 守护进程 | 账本:记下「哪些目标是我们停的」 | +| `goal-resume-requests.json` | 守护进程 | 守护进程(交接记录) | 要恢复的目标(带 epoch) | +| `goal-resume-acked.json` | 守护进程 | 守护进程 | 幂等去重 | +| `goal-revive.json` | 守护进程 | 守护进程 | 每个目标的唤醒尝试记录 | +| `bot-revive-requests.json` | 守护进程 | **蹬王之王自己的 MiniApp** | 要唤醒的 bot(**唯一跨进程的交接**) | +| `bot-revive-acked.json` | MiniApp | MiniApp + 守护进程 | 幂等去重,**带时间戳**,只抑制 30 分钟 | +| `bot-revive-attempts.json` | MiniApp | MiniApp + 守护进程 | 每个请求的尝试次数(**满 3 次作废**)。单独存 ⇒ 避免 lost update | +| `bot-wake-host-session.json` | MiniApp | MiniApp | 复用的唤醒宿主会话 id(省掉每次冷启动) | +| `bot-notified.json` | 守护进程 | 守护进程 | 「需要你处理」提示去重(**防刷屏**) | +| `bg-state.json` | 守护进程 | 守护进程 | 已尝试过的重置卡(防重复扣) | + +--- + +## 日志长什么样 + +``` +[10-20 00:38.791] 额度: 5小时限额=已用3% / 剩余97% | 周限额=不限量 | 视频限额=已用0% / 剩余100% +[10-20 00:38.792] 5 小时窗口: 261 分钟后刷新(预计 10月20日 05:00) +[10-20 00:38.792] 决策[idle]: 额度充足(5 小时已用 3%),未到 99% 触发线,暂不动用重置卡 +[10-20 00:38.782] Goal: 额度未到触发线,目标保持原状 +[10-20 00:39.587] Bot 看门狗:Model-Baseline 因暂时性错误(net::ERR_NETWORK_CHANGED)挂掉,已写入唤醒请求(由 minimax-bot 投递) +[10-20 00:15.377] Bot 看门狗:Repro-Engine 需要你处理(当前已达到 Token Plan 用量上限。为避免调用中断,请升级 Token Plan 套餐,或购买积分补充用量并开启积分自动消耗。 (2067))—— 不自动重试 +[xx:xx:xx] ✓ 已使用重置卡 423331,额度已重置 +[xx:xx:xx] Goal 恢复: 已恢复 1 个目标(5 小时已用 3.0%,额度已回落,已投递续跑) +``` + +> ⚠️ **日志里那半句「(由 minimax-bot 投递)」是 v1.3 的旧措辞,还没改**。 +> v1.4 的实际投递方是**蹬王之王自己的 MiniApp**(`miniapp/node/bot_wakeup.mjs`)。 +> 看到这行只说明「**判定完成、请求已写下**」,不代表已经唤醒 —— +> 唤醒是否成功要看 `bot-revive-acked.json` 与 `local_runtime_turn_ingress`。 + +--- + +## 防重复扣卡 + +三层保护: + +1. `bg-state.json` 记录已尝试的卡,**落盘**,进程重启也不重复 +2. 先记账再请求,中途崩溃不会重复扣 +3. `out_biz_no` 服务端幂等键,即使跨进程重试也不会重复扣除 + +--- + +## 前提与边界 + +- **需要 MiniMax Code 桌面端在运行**。登录凭据通过本机的 OAuth lease socket 获取,桌面端就是那个签发方。 + - 桌面端没开时,服务会记录鉴权失败并退出(launchd 的 `KeepAlive` 会尝试重启) +- 只处理**个人版**额度,团队版接口不同。 +- **目标恢复**靠守护进程写**目标续跑队列项**(照抄运行时形状)。目标会话若长期闲置、 + 或卡在一个未结束的回合里,外部注入只能排队 —— 需要在 MCode 里打开过那个会话。 +- **Bot 唤醒**靠蹬王之王自己的 MiniApp 起回合 + `session send`。 + **面板关着时不会发生唤醒**(守护进程照常判定记账,面板再开时补上; + 请求按「最多试 3 次 + 24 小时兜底」判存活,不受面板关多久影响)。 +- 每个被唤醒的 bot 要烧**一个 agent 回合**(**冷启动**实测 ≈ 93k tokens,其中 ≈ 51k 缓存读; + v1.5 起复用唤醒宿主会话 ⇒ 只付增量),所以**只在真的有 pending 请求时才跑**。 +- 后台服务只管扣卡与目标暂停/恢复,**看板仍在 Mini App 里**(关掉面板即停,这是 MCode 的设计限制)。 +- **官方 `minimax-bot` 插件不可本地改版**(`miniapp publish` 会报 `OFFICIAL_PLUGIN_READ_ONLY`), + 所以唤醒方**只能是蹬王之王自己的 MiniApp**(它有 `agentAccess: ["session.run"]`)。 +- ⚠️ **Host 侧有个已知问题**:一旦某个版本的 manifest 声明过 `mcpEndpoints`, + 后续版本**必须继续声明同样的端点**,否则 `miniapp publish` 会被 + `MCP_PUBLICATION_INVALID` 挡下(Host 内存里的「已发布快照」是粘的)。 + 当前工作区的部署方式是**直接同步到 `~/.minimax/plugins/expiry-guardian/` + 安装副本 + `miniapp open`**,不走发布流程。 + +--- + +## 接口依据(实测确认) + +``` +GET www.minimaxi.com/backend/account/token_plan/remains_percent 额度用量 +GET www.minimaxi.com/backend/account/reset_card/list 可用卡 +POST www.minimaxi.com/backend/account/reset_card/use 使用一张卡 +GET www.minimaxi.com/backend/account/token_plan_credit 平台积分池到期 +GET agent.minimaxi.com/minimax-cloud/api/v1/credit/details Code 积分到期 +POST agent.minimaxi.com/matrix/api/v1/commerce/get_membership_info 积分汇总 +``` + +**两个容易踩的坑**(都在代码注释里标了): + +1. `credit/details` 的参数是 `page_number`/`page_size`(下划线),且必须带 + `include_expired=1` 才有历史记录。不带只返回最近 20 条。 +2. `reset_card/flows` 的 `create_time` **必填**,传 `0` 表示不限起始时间; + 传具体时间戳会返回 `2013 invalid params` —— 那是**错误**,不是「没有记录」。 + 曾经把这条错误读成「一张卡都没用过」。 + +--- + +## 海外版 + +```bash +python3 ~/mcode-reset-card-autouse/reset_card_autouse.py --base https://platform.minimax.io --dry-run once +``` diff --git a/plugins/hylouis233/expiry-guardian/icon-dark.png b/plugins/hylouis233/expiry-guardian/icon-dark.png new file mode 100644 index 0000000..7220828 Binary files /dev/null and b/plugins/hylouis233/expiry-guardian/icon-dark.png differ diff --git a/plugins/hylouis233/expiry-guardian/icon.png b/plugins/hylouis233/expiry-guardian/icon.png new file mode 100644 index 0000000..416bf1d Binary files /dev/null and b/plugins/hylouis233/expiry-guardian/icon.png differ diff --git a/plugins/hylouis233/expiry-guardian/miniapp/client/index.html b/plugins/hylouis233/expiry-guardian/miniapp/client/index.html new file mode 100644 index 0000000..c0dc571 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/miniapp/client/index.html @@ -0,0 +1,1418 @@ + + + + + + 蹬王之王 + + + +
+
+

蹬王之王

+

实时追踪积分与重置卡的到期时间,按「先过期先用」决定用卡还是留积分,不让额度白白作废

+

+ + 正在连接实时更新… +

+
+
+ + + +
+
+ +
+
+
7 天内将要过期
+
—
+
正在统计…
+
+
+
+
30 天内过期
+
—
+
+
+
有效余额
+
—
+
+
+
已过期损失
+
—
+
+
+
+ +
+
+
5 小时限额
+
—
+
+
+
+ +
+
+
积分总余额
+
—
+
+
+
+
最早到期
+
—
+
+
+
+
可用重置卡
+
—
+
+
+
+ +
+ 加载中 +
+
正在读取额度与到期信息…
+
+
+
+ +
+
+ +
+ + + + +
+
+
+
+

+ 判定结论为「保留积分」时不执行任何写操作;「立即判定一次」可随时手动触发,无节流。 +

+
+ +
+
+ + +
+
+

+ 与上面的「自动执行」是两套独立机制:上面只在本页面打开时生效,这里交给系统常驻进程, + 两者用同一套「先过期先用」决策,不会重复扣卡(服务端按幂等键去重)。 +

+
+ +
+
+ +
+
+

+ 由后台服务执行:5 小时额度用到 99% 时把所有进行中的目标暂停, + 额度回落后自动恢复。 + 只会动「我们停的那些」,你手动暂停的目标不会被自动打开。改完约 1 分钟内生效,不必重启。 +

+
+ +
+

自动化阈值

+

+ 改完约 1 分钟内生效 —— 后台服务每轮重读配置,不需要重启。 +

+
+ + + + + +
+ +
+
+
+
+
+ +
+

+
+ +
+

积分到期明细

+

+ +
读取中…
+
+ +
+
+

重置卡到期

+ +
读取中…
+
+
+

平台积分池

+
读取中…
+
+
+ +
+

你没来得及蹬完的 token

+

+ +
暂无过期未用完的积分
+
+ + + + diff --git a/plugins/hylouis233/expiry-guardian/miniapp/miniapp.json b/plugins/hylouis233/expiry-guardian/miniapp/miniapp.json new file mode 100644 index 0000000..e783c23 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/miniapp/miniapp.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "artifacts": { + "client": [ + "./miniapp/client" + ], + "node": [ + "./miniapp/node" + ] + }, + "runtime": { + "kind": "process", + "entry": "./miniapp/node/server.mjs", + "lifecycle": "on-demand" + }, + "surface": { + "path": "/dashboard" + }, + "agentAccess": [ + "session.run" + ], + "agentConcurrency": 2, + "mcpEndpoints": [ + { + "server": "expiry-guardian", + "path": "/mcp/expiry-guardian" + } + ] +} diff --git a/plugins/hylouis233/expiry-guardian/miniapp/node/bot_wakeup.mjs b/plugins/hylouis233/expiry-guardian/miniapp/node/bot_wakeup.mjs new file mode 100644 index 0000000..eb18df7 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/miniapp/node/bot_wakeup.mjs @@ -0,0 +1,526 @@ +// @ts-check +/** + * Bot 唤醒 —— 在蹬王之王自己的 MiniApp 进程里把挂掉的 bot 叫醒。 + * + * # 完整的机制(全部经 2026-10-20 实测,见 docs/BOT_DIAGNOSIS.md) + * + * ## 唤醒必须走 `session send`(或 `agent.run` 直接续 bot 会话) + * + * `session send` 内部走 `turns.steer()`,与官方 `session send` 同一条通道。 + * **裸写 `local_runtime_queue_items` 不会生效** —— 隔离实验:一条裸写队列项 + * 3 分钟后仍是 `queued`、未 claim、无 turn、无消息;同一会话用 `session send` + * 则 5 秒内 accepted 并被 bot 回复。⇒ Host 的队列轮询器只认经过 steer 的行。 + * + * ## 为什么不是「蹬王之王直接 agent.run 续 bot 会话」 + * + * `service/miniapp/agent/gateway.ts` 的 `assertSessionOwnership`: + * `owner !== caller.pluginId` ⇒ 抛 `SESSION_NOT_FOUND`。 + * 而 45 个 bot 会话的 `purpose` 全是 `miniapp:minimax-bot` + * ⇒ 蹬王之王对这些会话直接 `agent.run` **永远被拒**(实测每 30 秒 6 条全拒)。 + * + * ## 那怎么唤醒?—— 在一个 agent 回合里用 `mavis` 工具的 `session send` + * + * `local-runtime/dist/runtime/mavis-session-send.js` 的准入注释明写: + * "Reachability is the whole access model … **There is no topology check** — + * a task child, a sibling, a root Session and the caller's own Session + * all take the same path." + * ⇒ **`session send` 不做 owner 检查** ⇒ 任何拿到 mavis 工具的会话都能唤醒任意 bot 会话。 + * + * MiniApp 的 `agent.run`(不带 sessionId)建的会话 `agent_name = 'mavis'` + * ⇒ 那个 agent 天然带桌面端 mavis 工具(`local-mavis.js` 的 `handleSessionSend`)。 + * 实测:蹬王之王起一个回合,指令是「用 mavis 工具 session send 发到 」 + * ⇒ 探针 agent 回「状态 accepted(mode: queued)」,目标 bot 会话 + * `updated_at` 变化、起了新 turn、并真的回复了「收到,看门狗探针 2 正常送达」。 + * + * # 代价(必须知道) + * + * 每个被唤醒的 bot 要烧**一个 agent 回合**(实测约 47k tokens,46k 是缓存读取)。 + * ⇒ 只在**真的有 pending 请求**时才跑,绝不空转。 + * + * # 幂等 + * + * 成功处理的 id 追加进 `bot-revive-acked.json`;请求超过 TTL(30 分钟)作废。 + */ + +import { readFile, writeFile, rename, mkdir, rm } from 'node:fs/promises'; +import { existsSync, readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { homedir } from 'node:os'; + +/** 唤醒宿主会话的 id(复用以省 token,见 `buildWakeInstruction` 的说明)。 */ +const HOST_SESSION_FILE = 'bot-wake-host-session.json'; + +/** + * 读出「唤醒宿主会话」的 id。 + * + * ⚠️ **为什么复用**(2026-10-20 实测): + * 每次 `agent.run` 不带 sessionId 都会**新建一个会话** ⇒ 每次都是冷启动, + * 实测单次唤醒约 93k tokens(绝大部分是重新读进来的系统提示与工具定义)。 + * 复用一个长期存活的会话 ⇒ 那些内容进上下文缓存 ⇒ 后续唤醒只付增量。 + * 而且那个会话里 agent 已经「知道」该怎么做(用 mavis 工具 session send), + * 指令可以更短 ⇒ 输入 token 也更少。 + * + * @returns {Promise} + */ +export async function readHostSessionId() { + const file = join(configDir(), HOST_SESSION_FILE); + if (!existsSync(file)) return undefined; + try { + const v = JSON.parse(readFileSync(file, 'utf8')); + const id = v?.sessionId; + return typeof id === 'string' && id.startsWith('mvs_') ? id : undefined; + } catch { + return undefined; + } +} + +/** 忘掉「唤醒宿主会话」的 id(复用失败时用)。 */ +export async function clearHostSessionId() { + const file = join(configDir(), HOST_SESSION_FILE); + try { + await rm(file, { force: true }); + } catch { + /* 忽略 */ + } +} + +/** 记住「唤醒宿主会话」的 id。 */ +export async function writeHostSessionId(sessionId) { + if (typeof sessionId !== 'string' || !sessionId.startsWith('mvs_')) return; + const file = join(configDir(), HOST_SESSION_FILE); + await mkdir(dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.tmp`; + await writeFile(tmp, `${JSON.stringify({ sessionId }, null, 2)}\n`, 'utf8'); + await rename(tmp, file); +} + +/** + * 配置文件所在目录。 + * + * ⚠️ 每次都重新读 `process.env.HOME`(而不是模块加载时缓存 `homedir()`)—— + * 这样一个进程里若 HOME 变了(测试注入临时目录),路径也跟着变。 + */ +function configDir() { + return join(process.env.HOME ?? homedir(), '.config', 'mcode-reset-card'); +} +function requestFile() { + return join(configDir(), 'bot-revive-requests.json'); +} +function ackedFile() { + return join(configDir(), 'bot-revive-acked.json'); +} +/** 尝试次数单独存一个文件(避免与守护进程并发写同一个文件互相覆盖)。 */ +function attemptsFile() { + return join(configDir(), 'bot-revive-attempts.json'); +} + +/** + * ⚠️ **判据是「按尝试次数」而不是「按时间」**(2026-10-20 实测): + * 纯时间 TTL 会让「面板关着」的场景彻底失效 —— + * 面板关掉 ⇒ 没人消费请求 ⇒ 30 分钟后请求全部过期作废 ⇒ + * 等面板再打开时,那些 bot 又已经挂了一段时间,却**再也没有请求可发**。 + * 实测:面板关掉 70 秒后,7 条请求全部未被消费;再打开时它们已过期。 + * + * 真正的风险不是「放太久」,而是「**反复试同一个叫不醒的 bot**」。 + * ⇒ 按尝试次数封顶(`MAX_ATTEMPTS`):试满 N 次仍失败才作废。 + * 这样:面板关多久都不影响(请求一直在那儿等着);叫不醒的 bot 最多试 N 轮。 + * 时间只留一个 24 小时的兜底(`MAX_REQUEST_AGE_MS`),防僵尸请求永久堆积。 + */ + +/** 同一个请求最多尝试几次(超过就作废,不再重试)。 */ +export const MAX_ATTEMPTS = 3; + +/** 尝试次数表最多保留多少条(防无限增长)。 */ +export const MAX_ATTEMPT_ENTRIES = 200; + +/** 尝试次数记录的最长保留时间(毫秒)—— 超过就当僵尸清掉。 */ +export const MAX_ATTEMPT_AGE_MS = 24 * 60 * 60 * 1000; + +/** + * 请求的最长保留时间(毫秒)—— 只是兜底,不是主要判据。 + * 取 24 小时:足够覆盖「面板关了一整夜」,同时避免僵尸请求永久堆积。 + */ +export const MAX_REQUEST_AGE_MS = 24 * 60 * 60 * 1000; + +/** 巡检间隔(毫秒)。 */ +export const WAKE_INTERVAL_MS = 60 * 1000; + +/** + * 读守护进程写下的唤醒请求。 + * @returns {Promise>} + */ +export async function readWakeRequests() { + if (!existsSync(requestFile())) return []; + let raw; + try { + raw = await readFile(requestFile(), 'utf8'); + } catch { + return []; + } + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + const acked = readAcked(); + const attemptsMap = readAttemptsMap(); + const now = Date.now(); + return (Array.isArray(parsed?.requests) ? parsed.requests : []).filter((r) => { + const id = String(r?.id ?? ''); + // ⚠️ acked 是**永久**的:投递过的请求就是投递过了 ⇒ 绝不重投。 + if (!id || acked.has(id)) return false; + // 尝试次数用尽 ⇒ 这个 bot 叫不醒(或者投递端有问题)⇒ 不再重试。 + // ⚠️ 尝试次数按 **bot 键**记(不是实例 id)—— + // 否则守护进程每换一个实例就重置计数,3 次封顶会被架空。 + const attempts = attemptsMap.get(botKeyFromRequestId(id)) ?? 0; + if (attempts >= MAX_ATTEMPTS) return false; + // 时间上限只当作「兜底」(比如 bot 早就恢复健康了却还挂着请求), + // 但要留得足够久,覆盖「面板关了一整夜」这种场景。 + const at = Number(r?.atMs ?? 0); + return now - at <= MAX_REQUEST_AGE_MS; + }); +} + +/** + * 把某个 bot 的尝试次数 +1(失败时调用)。 + * + * ⚠️ **键是 bot 键,不是请求实例 id**(2026-10-20 v1.6): + * 守护进程每写一次新实例就是一个新 id,若按 id 计次数, + * 每次换实例都会重置计数 ⇒ 3 次封顶被架空 ⇒ 无限重试。 + * ⇒ 传 `botKeyFromRequestId(req.id)`。 + * + * ⚠️ **为什么单独存一份 attempts,而不是直接改 requests 文件**(2026-10-20 审计发现): + * 蹬王之王的守护进程(Rust,Host 之外)会**并发**往同一个 requests 文件里写新请求。 + * 如果这里做「读全量 → 改一个字段 → 写回全量」,就会把守护进程刚写进去的 + * 那几条**一起抹掉**(典型的 lost update)。⇒ 两次写同一个文件,间隔可能只有几毫秒。 + * + * ⇒ 改成**独立文件**存尝试次数:两边各写各的,永不互相覆盖。 + */ +export async function bumpAttempts(id) { + const file = attemptsFile(); + let parsed; + try { + parsed = JSON.parse(await readFile(file, 'utf8')); + } catch { + parsed = { attempts: {} }; + } + const map = parsed?.attempts && typeof parsed.attempts === 'object' ? parsed.attempts : {}; + const key = String(id ?? ''); + if (!key) return; + // ⚠️ 值同时记两个信息:{ n: 次数, at: 最后一次尝试的时间戳 }。 + // 之前只存次数 ⇒ 没法判断「这条尝试记录是不是僵尸」。 + // 兼容旧格式(纯数字 = 次数)。 + const raw = map[key]; + const prev = + raw && typeof raw === 'object' + ? { n: Number(raw.n ?? 0), at: Number(raw.at ?? 0) } + : { n: Number(raw ?? 0), at: 0 }; + map[key] = { n: prev.n + 1, at: Date.now() }; + // ⚠️ 清理条件:**只能**按「已 ack」或「超龄」清。 + // + // 曾经的写法是「只保留请求文件里还在的条目」—— 那是错的(2026-10-20 独立审计发现): + // Rust 的 prune 会把「尝试次数用尽」的请求从 requests 文件里删掉 ⇒ + // 下一个 bot 失败时调 bumpAttempts ⇒ 把那个 bot 的 attempts 记录也删掉 + // ⇒ Rust 读到 0 ⇒ 重新入队 ⇒ 再试 3 次 ⇒ **3 次封顶被架空,变成无限重试** + // (每回合 47k~93k tokens)。 + // + // 而「已 ack」的条目本来就该清(它已经处理完了); + // 「超龄」也该清(僵尸记录)。其余一律保留。 + const nowMs = Date.now(); + // ⚠️ **v1.6 修正:清理条件里不能有「已 ack」**(2026-10-20 二轮独立审计 F1)。 + // + // 原来这里有 `done = acked.has(k)`(键 = bot 键)。而 v1.6 把 acked 改成**永久**之后, + // 它的含义从「刚刚被救过」变成了「**曾经**被救过」 + // ⇒ 一个被救过的 bot,它的**失败计数**会在**同一次 bumpAttempts 调用里**被清掉 + // ⇒ **3 次封顶彻底失效** ⇒ 该请求永远「活」⇒ 每 60 秒重投一次 + // ⇒ 每轮烧一个 agent 回合(≈47k~93k tokens)。 + // + // ⇒ 失败计数**只在成功时清**(见 `clearAttemptsOnSuccess`),这里只按**超龄**清理。 + const kept = {}; + for (const [k, v] of Object.entries(map)) { + const at = v && typeof v === 'object' ? Number(v.at ?? 0) : 0; + const tooOld = at > 0 && nowMs - at > MAX_ATTEMPT_AGE_MS; + if (!tooOld) kept[k] = v; + } + const entries = Object.entries(kept); + const trimmed = entries.length > MAX_ATTEMPT_ENTRIES ? entries.slice(-MAX_ATTEMPT_ENTRIES) : entries; + await mkdir(dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.tmp`; + await writeFile(tmp, `${JSON.stringify({ attempts: Object.fromEntries(trimmed) }, null, 2)}\n`, 'utf8'); + await rename(tmp, file); +} + +/** + * 投递**成功**时清掉这个 bot 的失败计数。 + * + * ⚠️ 为什么必须在「成功」时清,而不是在「失败时按 acked 清理」 + * (2026-10-20 二轮独立审计 F1):acked 现在是**永久**的 + * ⇒ 「按 acked 清理」会把「**曾经**被救过」的 bot 的失败计数也清掉 + * ⇒ 3 次封顶失效 ⇒ 无限重投。 + * ⇒ 失败计数的语义是「这个 bot 最近连续投递失败了几次」, + * **成功一次就该归零** —— 只在这里归零。 + * + * @param {string} botKey + */ +export async function clearAttemptsOnSuccess(botKey) { + const key = String(botKey ?? ''); + if (!key) return; + const file = attemptsFile(); + let parsed; + try { + parsed = JSON.parse(await readFile(file, 'utf8')); + } catch { + return; // 文件不存在 ⇒ 没有失败计数要清 + } + const map = parsed?.attempts && typeof parsed.attempts === 'object' ? parsed.attempts : {}; + if (!(key in map)) return; + delete map[key]; + await mkdir(dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.tmp`; + await writeFile(tmp, `${JSON.stringify({ attempts: map }, null, 2)}\n`, 'utf8'); + await rename(tmp, file); +} + +/** 读出所有已尝试次数(键 = bot 键)。 */ +export function readAttemptsMap() { + try { + const v = JSON.parse(readFileSync(attemptsFile(), 'utf8')); + const map = v?.attempts && typeof v.attempts === 'object' ? v.attempts : {}; + const out = new Map(); + for (const [k, v2] of Object.entries(map)) { + // 新格式 { n, at };旧格式是纯数字(次数)。 + const n = v2 && typeof v2 === 'object' ? Number(v2.n ?? 0) : Number(v2 ?? 0); + out.set(String(k), n); + } + return out; + } catch { + return new Map(); + } +} + +/** + * 「已被投递方确认处理」的请求 id → 处理时间戳。 + * + * ⚠️ **为什么要带时间戳**(2026-10-20 独立审计发现): + * 原来 acked 是个纯 id 列表 ⇒ 「救过一次」变成**永久黑名单**。 + * 实测:6 个 bot 明明又坏了(闲置 47~94 分钟,远超 90 秒阈值), + * 却因为 acked 里有过它们而永远不再被叫醒。 + * ⇒ 时间戳保留下来。 + * + * 兼容旧格式(纯字符串数组 ⇒ 没有时间戳 ⇒ 视为「很久以前」)。 + */ +function readAckedMap() { + try { + const v = JSON.parse(readFileSync(ackedFile(), 'utf8')); + const arr = Array.isArray(v?.acked) ? v.acked : []; + const map = new Map(); + for (const x of arr) { + if (typeof x === 'string' && x) map.set(x, 0); // 旧格式:没有时间戳 + else if (x && typeof x === 'object' && typeof x.id === 'string') { + map.set(x.id, Number(x.atMs ?? 0)); + } + } + return map; + } catch { + return new Map(); + } +} + +/** + * 所有「已被投递方确认处理」的请求 id —— **永久**,不看时间窗。 + * + * ⚠️ v1.6 改判据(2026-10-20 独立审计 + 实测): + * 原来带 30 分钟窗口 ⇒ 窗口一过,**已经投递过的请求又变回「未处理」** + * ⇒ 投递端**每 30 分钟把同一条请求重投一次**(每回合 ≈47k tokens), + * 而且会打断已经恢复正常的 bot。 + * ⇒ 现在:**投递过的就是投递过了**。 + * 「多久可以再救同一个 bot」由**守护进程自己的频率限制**管(它写请求时判断)。 + */ +function readAcked() { + return new Set(readAckedMap().keys()); +} + +/** + * 从请求 id 推出 **bot 键**(= `bot-revive:{botId}:{sessionId}`,不含实例序号)。 + * + * 请求 id 的形状:`bot-revive:{botId}:{sessionId}[:{实例序号}]` ⇒ 取前三段。 + * + * ⚠️ **Rust 侧必须用同一个算法**(`bot_key_from_request_id`)—— + * 它是「尝试次数」的键,两边不一致 ⇒ 3 次封顶被架空。 + * + * @param {string} id + * @returns {string} + */ +export function botKeyFromRequestId(id) { + const parts = String(id ?? '').split(':'); + if (parts.length >= 3) return `${parts[0]}:${parts[1]}:${parts[2]}`; + return String(id ?? ''); +} + +/** + * 「救过一次」的抑制窗口(毫秒)。 + * + * 30 分钟:既能避免同一轮里重复投递(60 秒巡检间隔下会重复 30 次), + * 又不会让「救过一次」变成永久黑名单。 + */ +export const ACK_SUPPRESS_WINDOW_MS = 30 * 60 * 1000; + +/** 把处理过的 id 追加进 acked 列表(原子写,带时间戳)。 */ +export async function appendAcked(ids) { + if (!ids.length) return; + const map = readAckedMap(); + const now = Date.now(); + for (const id of ids) map.set(String(id), now); + // 只保留最近 200 条(防无限增长)。 + const list = [...map.entries()] + .sort((a, b) => a[1] - b[1]) + .slice(-200) + .map(([id, atMs]) => ({ id, atMs })); + const file = ackedFile(); + await mkdir(dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.tmp`; + await writeFile(tmp, `${JSON.stringify({ acked: list }, null, 2)}\n`, 'utf8'); + await rename(tmp, file); +} + +/** + * 给一个 bot 构造「唤醒指令」——交给 agent 回合里的 mavis 工具执行。 + * + * ⚠️ 这段文本是**给 agent 看的指令**(它会调 `mavis` 工具), + * 而 `text` 字段(守护进程写的)是**给 bot 看的续跑消息正文**。 + * 两者必须分开:bot 收到的必须是业务内容,不能是「去调用工具」这种指令。 + * + * @param {{sessionId?: string, botName?: string, text?: string}} request + * @returns {string} + */ +export function buildWakeInstruction(request) { + const sessionId = String(request?.sessionId ?? ''); + const botName = String(request?.botName ?? '该 bot'); + const body = String( + request?.text ?? + '【看门狗】上一个回合因内部错误中断,本条是自动重新启用的续跑消息。\n请从中断处继续,不要重做已完成的部分。', + ); + return [ + `用 mavis 工具,command="session send",args 传 session_id="${sessionId}" 与 content=${JSON.stringify(body)}。`, + `只发这一条,不要做别的事,也不要自己回答。目标 bot 是「${botName}」。`, + // ⚠️ 宿主会话会被复用(见 `readHostSessionId` 的说明)⇒ 这个会话里可能已经有 + // 上一轮的指令。必须明确「只做这一条」,否则 agent 会顺手把上一条也重发。 + `(这是本轮唯一要做的事;之前轮次若有指令,一律不要重复执行。)`, + ].join(''); +} + +/** + * 跑一轮:把待唤醒的 bot 真的叫醒。 + * + * 每个请求要起一个 agent 回合(`agent.run`)——那个回合里的 agent 用 + * mavis 工具的 `session send` 把续跑消息送进目标 bot 会话。 + * + * ⚠️ **不可重入**:一轮要几分钟(每个 bot 一个 agent 回合,实测单回合约 93k tokens), + * 而巡检间隔只有 60 秒 ⇒ 上一轮没跑完时,下一轮又进来了。 + * 用 `inFlight` 锁挡住,避免重复唤醒与双倍 token 消耗。 + * + * @param {any} agent context.agent + */ +/** 当前是否有一轮唤醒正在跑。 */ +let inFlight = false; + +export async function tick(agent) { + if (!agent?.run) { + return { checked: 0, revived: 0, failed: [] }; + } + // ⚠️ 重入保护:上一轮还在跑就直接跳过这一轮。 + if (inFlight) { + return { checked: 0, revived: 0, failed: [], skipped: 'in-flight' }; + } + inFlight = true; + try { + return await runRound(agent); + } finally { + inFlight = false; + } +} + +/** 真正跑一轮(由 `tick` 加锁后调用)。 */ +async function runRound(agent) { + const requests = await readWakeRequests(); + if (requests.length === 0) { + return { checked: 0, revived: 0, failed: [] }; + } + const done = []; + const failed = []; + for (const req of requests) { + const sessionId = String(req?.sessionId ?? ''); + if (!sessionId.startsWith('mvs_')) { + failed.push({ id: req.id, detail: `请求缺少合法的 sessionId:${JSON.stringify(req.sessionId)}` }); + continue; + } + try { + // 复用宿主会话(省 token);没有就用这次返回的 id 记下来。 + // + // ⚠️ `hostSessionId` 必须在**新建成功之后**才落盘。 + // 之前的写法是「先取旧值 → 复用失败 → 新建 → 用旧值判断要不要落盘」, + // 而旧值是 truthy ⇒ 新建的会话 id 永远不落盘 ⇒ 复用收益归零 + // (2026-10-20 独立审计发现)。 + const existingHost = await readHostSessionId(); + let hostSessionId = existingHost; + let r; + if (existingHost) { + try { + r = await agent.run({ text: buildWakeInstruction(req), sessionId: existingHost }); + } catch (e) { + // ⚠️ 只有「宿主会话真的不能用了」才退回新建。 + // 下面这两类错误**不能**重发 —— 重发会让 bot 收到两条重复指令: + // - `session_busy` / BUSY:该会话已有回合在飞(可重试,但不是重发) + // - `AGENT_OUTCOME_UNKNOWN`:回合可能已经跑了,别自动重试 + const code = String(e?.code ?? e?.reason ?? ''); + if (code.includes('BUSY') || code.includes('busy') || code.includes('OUTCOME_UNKNOWN')) { + throw e; + } + // 其它错误(会话被归档 / 凭据过期 / 会话不存在)⇒ 宿主会话确实不能用了 + // ⇒ 退回「新建会话」这条路,并把失效的 id 清掉。 + // ⚠️ 清掉之后**本轮就用新会话**,避免下一轮又先撞一次坏会话。 + await clearHostSessionId(); + hostSessionId = undefined; + r = await agent.run({ text: buildWakeInstruction(req) }); + } + } else { + r = await agent.run({ text: buildWakeInstruction(req) }); + } + // `finished` = 那个 agent 回合跑完了(它已经把 session send 发出去)。 + // 回合里 agent 若报告失败,status 仍可能是 finished ⇒ 再看错误字段。 + const errText = r?.error?.message ?? ''; + if (r?.status === 'finished' && !errText) { + // 新建的会话 id 落盘(下一轮就能复用 ⇒ 省掉冷启动的 ~93k tokens)。 + if (!hostSessionId && r?.sessionId) { + hostSessionId = r.sessionId; + await writeHostSessionId(hostSessionId); + } + done.push(req.id); + // ⚠️ 立刻 ack,不要等整批跑完 —— + // 一轮要几分钟(每个 bot 一个 agent 回合),而巡检间隔只有 60 秒。 + // 不立刻 ack 的话,下一轮会读到同一批未 ack 的请求 ⇒ 重复唤醒 + 双倍 token。 + await appendAcked([req.id]); + // 成功一次 ⇒ 这个 bot 的失败计数归零(见 clearAttemptsOnSuccess 的说明)。 + await clearAttemptsOnSuccess(botKeyFromRequestId(req.id)); + } else { + await bumpAttempts(botKeyFromRequestId(req.id)); + failed.push({ id: req.id, detail: `唤醒回合未成功:${errText || r?.status || '未知'}` }); + } + } catch (e) { + // ⚠️ `AGENT_OUTCOME_UNKNOWN`:回合**可能已经跑了**(Host 原文:never retryable)。 + // 若只是 bump attempts 而不 ack ⇒ 60 秒后下一轮会再投同一条 + // ⇒ bot 收到**两条**重复的「请从中断处继续」。 + // ⇒ 这类错误直接 ack(认为已经处理过),不再重试。 + const code = String(e?.code ?? e?.reason ?? ''); + if (code.includes('OUTCOME_UNKNOWN')) { + await appendAcked([req.id]); + failed.push({ id: req.id, detail: '回合结果未知(可能已执行),不再重试' }); + continue; + } + await bumpAttempts(botKeyFromRequestId(req.id)); + failed.push({ id: req.id, detail: `唤醒异常:${e?.message ?? e}` }); + } + } + return { checked: requests.length, revived: done.length, failed }; +} diff --git a/plugins/hylouis233/expiry-guardian/miniapp/node/mcp.mjs b/plugins/hylouis233/expiry-guardian/miniapp/node/mcp.mjs new file mode 100644 index 0000000..7cd83d1 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/miniapp/node/mcp.mjs @@ -0,0 +1,154 @@ +// @ts-check +/** + * 蹬王之王 local MCP 端点。 + * + * # 为什么需要它(2026-10-20 实测踩到的坑) + * + * MiniApp 的 manifest 可以声明 `mcpEndpoints`,让**别的**插件在它们的回合里 + * 用到本插件的 MCP 工具。而 Host 在发布时会检查「声明的端点是否真的就绪」 + * (探活方式 = MCP 的 `initialize` + `tools/list`)。 + * + * ⚠️ 而且 Host 的「已发布快照」是**粘的**:一旦某个版本的 manifest 声明过 + * 端点,后续版本**必须继续声明同样的端点**,否则发布会被 + * `MCP_PUBLICATION_INVALID` 挡下(`used.size !== expected.size`)。 + * ⇒ 声明了就得**真的实现**,否则端点探活失败,同样发布不了。 + * + * # 这个端点提供什么 + * + * | 工具 | 作用 | + * | --- | --- | + * | `watchdog_status` | 查当前额度、重置卡、以及「哪些 bot 挂掉了、待唤醒队列长什么样」 | + * | `watchdog_wake` | **立即**跑一轮 Bot 唤醒(不等 60 秒的巡检) | + * | `watchdog_decide` | 立即判定一次:现在该不该用卡、用哪张、为什么(**只判定不扣卡**) | + * + * 这样别的插件(例如 minimax-bot)可以在自己的回合里直接问蹬王之王 + * 「额度还剩多少」「哪些 bot 挂了」,而不必读文件。 + */ + +const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05']; + +/** 工具定义(模型可见,所以用中文)。 */ +function toolDefs() { + return [ + { + name: 'watchdog_status', + description: + '查蹬王之王的当前状态:5 小时额度用量、重置卡与积分的到期时间、' + + '以及待唤醒的 bot 列表(挂了但还没叫醒的)。', + inputSchema: { type: 'object', properties: {}, required: [], additionalProperties: false }, + }, + { + name: 'watchdog_wake', + description: + '立即跑一轮 Bot 唤醒:把「已挂掉但还没叫醒」的 bot 叫醒(复用它们自己的会话,不新建会话)。', + inputSchema: { type: 'object', properties: {}, required: [], additionalProperties: false }, + }, + { + name: 'watchdog_decide', + description: + '立即判定一次额度:按「先过期先用」算出现在该不该用重置卡、用哪一张、为什么。' + + '**只判定不扣卡** —— 真要扣请用面板上的按钮。', + inputSchema: { type: 'object', properties: {}, required: [], additionalProperties: false }, + }, + ]; +} + +/** + * 创建 MCP 处理器。 + * + * @param {{ + * status: () => any, + * wake: () => Promise, + * decide: () => Promise, + * }} deps + */ +export function createMcpHandler(deps) { + /** @param {any} message */ + async function handleRpc(message) { + if (!message || typeof message !== 'object' || message.jsonrpc !== '2.0') { + return { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid Request' } }; + } + const { id, method, params } = message; + const isNotification = id === undefined || id === null; + try { + let result; + switch (method) { + case 'initialize': { + const requested = params?.protocolVersion; + result = { + protocolVersion: PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0], + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: 'expiry-guardian', version: '1.6.0' }, + instructions: + '蹬王之王:追踪额度与重置卡到期时间,并守护挂掉的 MiniMax Bot。' + + '用 watchdog_status 查状态、watchdog_wake 立即叫醒挂掉的 bot、' + + 'watchdog_decide 立即判定一次该不该用重置卡(**只判定不扣卡**)。', + }; + break; + } + case 'ping': + result = {}; + break; + case 'tools/list': + result = { tools: toolDefs() }; + break; + case 'tools/call': { + const name = params?.name; + const args = params?.arguments ?? {}; + if (name === 'watchdog_status') { + // ⚠️ **必须 await**(2026-10-20 实测发现): + // `deps.status()` 是 async ⇒ 不 await 拿到的是 **Promise**, + // 而 `JSON.stringify(Promise)` 得到的是字符串 `"{}"` + // ⇒ 工具永远返回空对象,调用方什么也看不到。 + result = { content: [{ type: 'text', text: JSON.stringify(await deps.status(), null, 2) }] }; + } else if (name === 'watchdog_wake') { + const r = await deps.wake(); + result = { content: [{ type: 'text', text: JSON.stringify(r, null, 2) }] }; + } else if (name === 'watchdog_decide') { + const r = await deps.decide(); + result = { content: [{ type: 'text', text: JSON.stringify(r, null, 2) }] }; + } else { + return { jsonrpc: '2.0', id, error: { code: -32602, message: `Unknown tool: ${name}` } }; + } + break; + } + default: + if (isNotification) return undefined; + return { jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } }; + } + return isNotification ? undefined : { jsonrpc: '2.0', id, result }; + } catch (error) { + return { + jsonrpc: '2.0', + id, + error: { code: -32603, message: error instanceof Error ? error.message : String(error) }, + }; + } + } + + /** + * 处理一次 MCP HTTP 请求。 + * + * @param {any} request + * @param {any} response + * @param {any} body 已解析的请求体(POST 时) + */ + return async function handleMcp(request, response, body) { + const messages = Array.isArray(body) ? body : body ? [body] : []; + if (messages.length === 0) { + response.writeHead(400, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'empty request' })); + return; + } + const out = []; + for (const message of messages) { + const r = await handleRpc(message); + if (r !== undefined) out.push(r); + } + response.writeHead(200, { + 'content-type': 'application/json', + 'cache-control': 'no-store', + }); + response.end(JSON.stringify(out.length === 1 ? out[0] : out)); + }; +} diff --git a/plugins/hylouis233/expiry-guardian/miniapp/node/server.mjs b/plugins/hylouis233/expiry-guardian/miniapp/node/server.mjs new file mode 100644 index 0000000..dd02c09 --- /dev/null +++ b/plugins/hylouis233/expiry-guardian/miniapp/node/server.mjs @@ -0,0 +1,1900 @@ +// @ts-check +/** + * 蹬王之王 — 积分与重置卡的到期决策看板。 + * + * 数据源(全部经实测确认,非推测)。两套 API 分属不同域名: + * + * 开放平台积分 → www.minimaxi.com + * GET /backend/account/token_plan_credit → 余额 + 各积分池到期时间 + * GET /backend/account/token_plan/remains_percent → 5小时/周额度用量 + * GET /backend/account/reset_card/list → 重置卡与到期时间 + * POST /backend/account/reset_card/use → 使用一张重置卡 + * + * MiniMax Code 积分 → agent.minimaxi.com + * GET /minimax-cloud/api/v1/credit/details → 逐笔授予/消耗/到期明细 + * + * 决策规则:把「最早到期的积分」(平台或 Code 任一)与「最早到期的重置卡」比, + * 谁先过期先用谁。积分先过期 → 不用卡,让积分先消耗完;重置卡先过期 → 用卡 + * 把额度重置回来,把更耐放的积分留着。 + * + * 鉴权:与桌面端共用同一登录态,经 OAuth lease socket 取短期 access token。 + * 凭据只在本进程内存中使用,绝不下发给页面、绝不写日志。 + */ + +import { createServer } from 'node:http'; +import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, rmSync } from 'node:fs'; +import { tick as botWakeTick, readWakeRequests, readAttemptsMap, botKeyFromRequestId, WAKE_INTERVAL_MS } from './bot_wakeup.mjs'; +import { createMcpHandler } from './mcp.mjs'; +import { readFile } from 'node:fs/promises'; +import { randomBytes } from 'node:crypto'; +import { join, dirname } from 'node:path'; +import { connect } from 'node:net'; +import { homedir } from 'node:os'; + +/** @typedef {import('./miniapp-api.js').MiniAppContext} MiniAppContext */ +/** @typedef {import('./miniapp-api.js').MiniAppLifecycle} MiniAppLifecycle */ + + +// ── OAuth lease:与桌面端同一套登录态 ──────────────────────────────────────── +const LEASE_SOCK = join(homedir(), '.minimax', 'run', 'mcode-auth-lease-v1.sock'); +const LEASE_CAP = join(homedir(), '.minimax', 'run', 'mcode-auth-lease-v1.cap'); + +function randomId() { + return Array.from(randomBytes(16), (b) => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * 向桌面端的 lease socket 实时取一个短期 access token。 + * 协议见 packages/oauth-lease-protocol:4 字节大端长度前缀 + JSON。 + * @returns {Promise} + */ +function acquireLeaseToken() { + return new Promise((resolve) => { + let capability; + try { + capability = readFileSync(LEASE_CAP, 'utf8').trim(); + } catch { + resolve(null); + return; + } + if (!capability) { + resolve(null); + return; + } + + const payload = Buffer.from( + JSON.stringify({ + version: 1, + requestId: randomId(), + capability, + method: 'lease', + minValidityMs: 120_000, + }), + 'utf8', + ); + const frame = Buffer.alloc(4 + payload.length); + frame.writeUInt32BE(payload.length, 0); + payload.copy(frame, 4); + + const socket = connect(LEASE_SOCK); + const chunks = []; + let done = false; + + const finish = (value) => { + if (done) return; + done = true; + socket.destroy(); + resolve(value); + }; + + socket.setTimeout(5000); + socket.on('error', () => finish(null)); + socket.on('timeout', () => finish(null)); + socket.on('connect', () => socket.write(frame)); + socket.on('data', (chunk) => { + chunks.push(chunk); + const buf = Buffer.concat(chunks); + if (buf.length < 4) return; + const size = buf.readUInt32BE(0); + if (buf.length < 4 + size) return; + try { + const response = JSON.parse(buf.subarray(4, 4 + size).toString('utf8')); + const result = response?.result; + if (response?.ok && result?.method === 'lease' && typeof result.accessToken === 'string') { + finish(result.accessToken); + return; + } + finish(null); + } catch { + finish(null); + } + }); + }); +} + +/** + * token 复用:lease token 有效期约 40 分钟,没必要每轮都开一次 unix socket。 + * 只在过期前 5 分钟才重新取,且同一个 in-flight 请求会被复用(并发去重)。 + */ +const TOKEN_TTL_MS = 30 * 60 * 1000; +const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000; +let tokenCache = { value: null, at: 0 }; +let tokenPending = null; + +function getToken() { + const now = Date.now(); + if (tokenCache.value && now - tokenCache.at < TOKEN_TTL_MS - TOKEN_REFRESH_MARGIN_MS) { + return Promise.resolve(tokenCache.value); + } + // 并发去重:同一时刻只开一个 lease 连接 + if (tokenPending) return tokenPending; + tokenPending = acquireLeaseToken() + .then((token) => { + if (token) { + tokenCache = { value: token, at: Date.now() }; + } + return token; + }) + .finally(() => { + tokenPending = null; + }); + return tokenPending; +} + +/** + * 取 token,失败时**立即重试一次**。 + * + * 为什么:lease 走的是本地 unix socket,偶尔会抖一下(桌面端正好在忙、 + * 或 MiniApp 刚重启换了 capability)。这时整个快照会抛错、页面直接显示 + * 「读不到数据」—— 而其实只要再试一次就成了。 + * 重试成本是一次本地 socket 连接,远小于让用户以为整个看板挂了。 + */ +async function getTokenWithRetry() { + let token = await getToken(); + if (token) return token; + await new Promise((r) => setTimeout(r, 400)); + token = await getToken(); + return token; +} + +// ── 账号 API ────────────────────────────────────────────────────────────────── +/** + * 两套账号 API 分属不同域名,实测确认: + * 开放平台 / 额度 / 重置卡 → www.minimaxi.com(agent 域对这些路径返回 404 或 HTML) + * MiniMax Code 积分明细 → agent.minimaxi.com(platform 域返回非 JSON) + */ +const PLATFORM_BASE = 'https://www.minimaxi.com'; +const CODE_BASE = 'https://agent.minimaxi.com'; + +class ApiError extends Error { + constructor(message, kind = 'business', code) { + super(message); + this.kind = kind; + this.code = code; + } +} + +/** + * 模块级日志出口。 + * + * `context.logger` 只在 `start(context)` 里有,但重试辅助函数是模块级的 + * (被 apiGet/apiPost 直接调用)。用这个引用把它们接进 Host 日志 —— + * 无限重试最怕变成「静默卡住」,日志必须能说明它在等什么。 + */ +let hostLogger = null; + +/** 写一行到 Host 日志;未注入时退回 stderr。 */ +function log(message) { + if (hostLogger) { + hostLogger.warn(message); + return; + } + console.error(`[expiry-guardian] ${message}`); +} + +// ── 可重试错误:无限重试 + 指数退避 ──────────────────────────────────────────── +// +// 2026-10-19 新增。目标:遇到「服务端内部错误」或「负载过高」时**一直重试** +// 直到成功,而不是让守护进程把这一轮判成失败、把看板显示成「读不到数据」。 +// +// 判定依据(HTTP 状态码): +// 429 负载过高 / 限流 → 重试 +// 500 内部错误 → 重试 +// 502 网关错误 → 重试 +// 503 服务不可用 → 重试 +// 504 网关超时 → 重试 +// +// 明确**不**重试的: +// 401 / 403 登录态失效 —— 重试无用,要重新取 token(走已有的 token 失效分支) +// 其他 4xx 客户端错误 —— 重试没有意义 +// 业务错误 确定性失败(如参数非法、额度不足) +// +// 退避:min(base * 2^(n-1), cap) + 抖动,base=1s cap=60s。 +// 封顶 60s 是为了「无限重试」不会把接口打爆,也不会指数增长到没有尽头。 +const RETRYABLE_HTTP = new Set([429, 500, 502, 503, 504]); +const RETRY_BASE_MS = 1_000; +const RETRY_CAP_MS = 60_000; + +/** 该 HTTP 状态码是否值得重试。 */ +function isRetryableStatus(status) { + return RETRYABLE_HTTP.has(status); +} + +/** + * 退避时长:指数增长 + 抖动,封顶 RETRY_CAP_MS。 + * 抖动是必要的 —— 否则大量并发重试会形成「惊群」,同时打在同一时刻。 + */ +function retryDelayMs(attempt) { + const exp = Math.min(RETRY_BASE_MS * 2 ** Math.max(0, attempt - 1), RETRY_CAP_MS); + return exp + Math.floor(Math.random() * 500); +} + +/** + * 带无限重试的 fetch。 + * + * 只对**可重试**的错误重试;其余错误原样抛出。 + * 每次重试都如实打日志 —— 无限制重试最怕变成「静默卡住」, + * 看日志才知道它到底在等什么。 + */ +async function fetchWithRetry(url, init, label) { + let attempt = 0; + for (;;) { + attempt += 1; + let response; + try { + response = await fetch(url, init); + } catch (err) { + // 网络层错误(连接重置 / DNS 抖动)⇒ 也值得重试 + const delay = retryDelayMs(attempt); + log(`⚠️ ${label} 网络错误(第 ${attempt} 次):${err?.message ?? err} ⇒ ${Math.round(delay / 1000)}s 后重试`); + await new Promise((r) => setTimeout(r, delay)); + continue; + } + if (response.ok) return response; + if (!isRetryableStatus(response.status)) return response; // 交给调用方按原逻辑处理 + const delay = retryDelayMs(attempt); + log( + `⚠️ ${label} 返回 HTTP ${response.status}(第 ${attempt} 次,可重试)⇒ ${Math.round(delay / 1000)}s 后重试`, + ); + await new Promise((r) => setTimeout(r, delay)); + } +} + +async function apiGet(path, token, base = PLATFORM_BASE) { + const response = await fetchWithRetry( + `${base}${path}`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + 'User-Agent': 'MiniMaxCode', + }, + }, + `GET ${path}`, + ); + if (response.status === 401 || response.status === 403) { + tokenCache = { value: null, at: 0 }; + throw new ApiError('登录态失效,请重新打开 MiniMax Code', 'unauthorized'); + } + if (!response.ok) throw new ApiError(`HTTP ${response.status}`, 'network', response.status); + const payload = await response.json(); + const baseResp = payload?.base_resp; + if (baseResp && baseResp.status_code !== 0) { + throw new ApiError(baseResp.status_msg || `code=${baseResp.status_code}`, 'business', baseResp.status_code); + } + return payload; +} + +/** 部分接口(如 get_membership_info)是 POST,桌面端即如此调用。 */ +async function apiPost(path, token, base = CODE_BASE, body = {}) { + const response = await fetchWithRetry( + `${base}${path}`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': 'MiniMaxCode', + }, + body: JSON.stringify(body), + }, + `POST ${path}`, + ); + if (response.status === 401 || response.status === 403) { + tokenCache = { value: null, at: 0 }; + throw new ApiError('登录态失效,请重新打开 MiniMax Code', 'unauthorized'); + } + if (!response.ok) throw new ApiError(`HTTP ${response.status}`, 'network', response.status); + const payload = await response.json(); + const baseResp = payload?.base_resp; + if (baseResp && baseResp.status_code !== 0) { + throw new ApiError(baseResp.status_msg || `code=${baseResp.status_code}`, 'business', baseResp.status_code); + } + return payload; +} + +async function apiUseResetCard(sourceType, outBizNo, token) { + // 扣卡也走可重试路径。 + // + // ⚠️ 重试扣卡安全吗?—— 安全:请求体带 `out_biz_no`,服务端以它为**幂等键**, + // 同一 out_biz_no 重复提交只会「回放」结果(outcome=REPLAYED),不会扣第二张卡。 + // 这正是本项目一直依赖的去重机制。 + const response = await fetchWithRetry( + `${PLATFORM_BASE}/backend/account/reset_card/use`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': 'MiniMaxCode', + }, + body: JSON.stringify({ source_type: sourceType, out_biz_no: outBizNo }), + }, + 'POST reset_card/use', + ); + if (!response.ok) throw new ApiError(`HTTP ${response.status}`, 'network', response.status); + const payload = await response.json(); + const baseResp = payload?.base_resp; + if (baseResp && baseResp.status_code !== 0) { + throw new ApiError(baseResp.status_msg || `code=${baseResp.status_code}`, 'business', baseResp.status_code); + } + return String(payload?.outcome || 'UNKNOWN'); +} + +// ── 解析 ────────────────────────────────────────────────────────────────────── +/** 百分数字符串(如 "71%")转数字。接口就是这种格式,直接 Number() 会得到 0。 */ +function percent(value) { + if (typeof value === 'string') value = value.trim().replace(/%/g, ''); + const n = Number(value); + return Number.isFinite(n) ? n : 0; +} + +/** 金额字符串(可能带千分位)转数字。 */ +function amount(value) { + if (typeof value === 'string') value = value.replace(/,/g, '').trim(); + const n = Number(value); + return Number.isFinite(n) ? n : 0; +} + +const BUCKET_LABEL = { 2: '充值', 3: '赠送' }; +/** Code 积分 credit_type:1=购买,2=赠送(取自桌面端 Purchased / Free 判定)。 */ +const CODE_CREDIT_LABEL = { 1: '购买', 2: '赠送' }; + +/** 开放平台积分池(/backend/account/token_plan_credit)。 */ +function parsePlatformPools(credit) { + const buckets = credit?.balance_breakdown?.buckets ?? []; + return buckets + .filter((b) => b && typeof b.balance === 'number') + .map((b) => ({ + kind: 'platform', + source: 'platform', + label: BUCKET_LABEL[b.source] ?? `来源${b.source}`, + balance: b.balance, + expireTimeMs: typeof b.expire_time_ms === 'number' ? b.expire_time_ms : null, + })) + .sort((a, b) => (a.expireTimeMs ?? Infinity) - (b.expireTimeMs ?? Infinity)); +} + +/** + * MiniMax Code 积分明细(/minimax-cloud/api/v1/credit/details)。 + * + * 分页参数是 `page_number` / `page_size`(下划线命名),且必须带 + * `include_expired=1` 才会返回历史记录——桌面端就是这么调的 + * (见 app.asar 中 open-platform-commerce 的 fetch 函数)。 + * + * 不带 include_expired 时接口只返回当前有效的最近 20 条,据此判断「已过期多少」 + * 会得出完全错误的结论(曾经据此误答「0 过期」)。page_size 上限 100。 + */ +async function fetchCodeCredits(token) { + const pageSize = 100; + const collected = []; + let totalCount = 0; + for (let pageNumber = 1; pageNumber <= 20; pageNumber += 1) { + const payload = await apiGet( + `/minimax-cloud/api/v1/credit/details?page_number=${pageNumber}&page_size=${pageSize}&include_expired=1`, + token, + CODE_BASE, + ); + const details = Array.isArray(payload.details) ? payload.details : []; + totalCount = Number(payload.total_count ?? collected.length); + collected.push(...details); + if (collected.length >= totalCount || details.length < pageSize) break; + } + return { payload: { total_count: totalCount, details: collected } }; +} + +function parseCodeCredits(payload) { + const details = Array.isArray(payload?.details) ? payload.details : []; + const now = Date.now(); + const items = details + .filter((d) => d && (d.credit_type === 1 || d.credit_type === 2)) + .map((d) => ({ + kind: 'code-credit', + source: 'code', + label: CODE_CREDIT_LABEL[d.credit_type] ?? `类型${d.credit_type}`, + granted: amount(d.granted_amount), + consumed: amount(d.consumed_amount), + balance: amount(d.remaining_amount), + expireTimeMs: typeof d.expire_at_ms === 'number' ? d.expire_at_ms : null, + grantedAtMs: typeof d.granted_at_ms === 'number' ? d.granted_at_ms : null, + // 过期时仍有余额 = 真实损失;过期但已用尽不算损失 + lost: typeof d.expire_at_ms === 'number' && d.expire_at_ms < now && amount(d.remaining_amount) > 0, + expired: typeof d.expire_at_ms === 'number' && d.expire_at_ms < now, + })) + .sort((a, b) => (a.expireTimeMs ?? Infinity) - (b.expireTimeMs ?? Infinity)); + + const live = items.filter((d) => !d.expired); + return { + items, + totalCount: Number(payload?.total_count ?? items.length), + truncated: Number(payload?.total_count ?? 0) > items.length, + liveBalance: live.reduce((sum, d) => sum + d.balance, 0), + lostBalance: items.filter((d) => d.lost).reduce((sum, d) => sum + d.balance, 0), + lostCount: items.filter((d) => d.lost).length, + expiredCount: items.filter((d) => d.expired).length, + }; +} + +/** + * 未来 N 天内将过期的积分子集。 + * 这是整个看板最有价值的数字:签到分按天逐笔到账、各自 30 天到期, + * 所以到期是连续排队的滚动流失,不是一次性清算。 + */ +function expiringWithin(items, days, now = Date.now()) { + const limit = now + days * 86_400_000; + return items.filter( + (d) => !d.expired && d.balance > 0 && d.expireTimeMs !== null && d.expireTimeMs <= limit, + ); +} + +/** + * 账户积分汇总(POST /matrix/api/v1/commerce/get_membership_info)。 + * + * 这是 MCode 用量页上「积分」那一栏的权威口径: + * total_remaining_amount 总余额(截图里的 30947) + * purchased_remaining_amount 购入部分 + * free_remaining_amount 赠送部分 + * + * 与另两个口径的关系(已实测核对,不要混算): + * purchased ≈ token_plan_credit 的 remaining_credits(6487.506 vs 6487,小数为精度差) + * free ≥ credit/details 明细之和(24460 vs 17400,因该接口只返回最近 20 笔) + * 因此这里只作为「总余额」展示,**不参与**到期排序,避免与明细重复计数。 + */ +function parseCreditSummary(payload) { + const s = payload?.op_credit_summary; + if (!s) return null; + const total = amount(s.total_remaining_amount); + const purchased = amount(s.purchased_remaining_amount); + const free = amount(s.free_remaining_amount); + if (!total && !purchased && !free) return null; + return { total, purchased, free }; +} + +// ── 共享配置(与 Rust 守护进程 / Python 读同一个文件)───────────────────────── +// 这是三份实现不漂移的前提:Node / Rust / Python 都从这里取阈值, +// 谁都不许再把 99 写死在自己代码里。 +// 路径 ~/.config/mcode-reset-card/config.json 与 Rust config.rs 一致。 +const CONFIG_PATH = join(homedir(), '.config', 'mcode-reset-card', 'config.json'); +const CONFIG_DIR = dirname(CONFIG_PATH); + +/** + * 下列常量只是**默认值**,仅在配置文件缺失或非法时兜底。 + * 实际生效值一律来自 loadConfig()。 + * + * 为什么默认 99:等额度耗尽再处理,中间那次请求已经失败,会打断 + * 当前目标正在跑的自动执行。提前一个百分点介入,代价几乎为零 + * (剩余 1% 本来也用不完),但能避免任务被硬中断。 + */ +const DEFAULT_QUOTA_TRIGGER_PERCENT = 99; +const DEFAULT_TIGHT_PERCENT = 80; +const DEFAULT_LOW_PERCENT = 95; +/** + * 距 5 小时额度刷新不足这么多分钟时,不消耗重置卡(额度马上就自己回来了)。 + * 实际生效值来自 config.json 的 cardSkipBeforeResetMinutes。 + */ +const DEFAULT_CARD_SKIP_BEFORE_RESET_MINUTES = 60; + +function defaultConfig() { + return { + schemaVersion: 1, + cardTriggerPercent: DEFAULT_QUOTA_TRIGGER_PERCENT, + cardSkipBeforeResetMinutes: DEFAULT_CARD_SKIP_BEFORE_RESET_MINUTES, + botGuard: { + enabled: false, + idleMs: 90_000, + backoffBaseMs: 5_000, + backoffCapMs: 60_000, + }, + quotaLevels: { tight: DEFAULT_TIGHT_PERCENT, low: DEFAULT_LOW_PERCENT }, + goalPause: { + enabled: false, + triggerPercent: DEFAULT_QUOTA_TRIGGER_PERCENT, + resumeAtPercent: 0, + }, + }; +} + +function clampNumber(value, lo, hi, fallback) { + const n = Number(value); + if (!Number.isFinite(n)) return fallback; + return Math.min(hi, Math.max(lo, n)); +} + +/** + * 逐字段校验并夹紧,返回 (配置, 问题列表)。 + * + * 非法组合**整体回退默认并报出来**,绝不静默纠正 —— + * 静默纠正会让人以为自己设的生效了。字段与 Rust config.rs 保持同名同义。 + */ +export function normalizeConfig(raw) { + const d = defaultConfig(); + const issues = []; + const src = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}; + + const rawCard = src.cardTriggerPercent; + const cardTriggerPercent = clampNumber(rawCard, 1, 100, d.cardTriggerPercent); + if (rawCard != null && Number(rawCard) !== cardTriggerPercent) { + issues.push(`cardTriggerPercent=${rawCard} 超出 1..100,已夹到 ${cardTriggerPercent}`); + } + + const lv = src.quotaLevels && typeof src.quotaLevels === 'object' ? src.quotaLevels : {}; + let tight = clampNumber(lv.tight, 1, 100, d.quotaLevels.tight); + let low = clampNumber(lv.low, 1, 100, d.quotaLevels.low); + // 分界点必须 0 < tight < low;非法组合整体回退默认,而不是悄悄调序。 + if (!(tight < low)) { + if (Object.keys(lv).length > 0) { + issues.push(`quotaLevels 需满足 tight < low(收到 tight=${tight} low=${low}),已整体回退默认`); + } + tight = d.quotaLevels.tight; + low = d.quotaLevels.low; + } + + const rawSkip = src.cardSkipBeforeResetMinutes; + const cardSkipBeforeResetMinutes = clampNumber(rawSkip, 0, 1440, d.cardSkipBeforeResetMinutes); + if (rawSkip != null && Number(rawSkip) !== cardSkipBeforeResetMinutes) { + issues.push(`cardSkipBeforeResetMinutes=${rawSkip} 超出 0..1440,已夹到 ${cardSkipBeforeResetMinutes}`); + } + + const gp = src.goalPause && typeof src.goalPause === 'object' ? src.goalPause : {}; + const config = { + schemaVersion: 1, + cardTriggerPercent, + cardSkipBeforeResetMinutes, + // ⚠️ `resumeCronId` 已彻底删除(2026-10-20):本插件不再武装/解除武装任何 cron。 + // 恢复流程改为:守护进程(Host 外)只写请求文件,**MiniMax Bot 自己的 MiniApp** + // 用 `agent.run` 投递(只有它对 bot 会话有归属权限)。 + // ⚠️ 同样必须原样保留 `botGuard`(2026-10-19 补):Bot 守护的配置 + // (开关、闲置判定、退避上下限)。漏掉它 ⇒ 用户在页面改任何设置都会把 + // 守护开关抹掉 ⇒ 守护静默失效。 + botGuard: normalizeBotGuard(src.botGuard), + quotaLevels: { tight, low }, + goalPause: { + enabled: gp.enabled === true, + triggerPercent: clampNumber(gp.triggerPercent, 1, 100, d.goalPause.triggerPercent), + resumeAtPercent: clampNumber(gp.resumeAtPercent, 0, 100, d.goalPause.resumeAtPercent), + }, + }; + return { config, issues }; +} + +/** 读配置。文件不存在是正常情况(首次运行),返回默认值且不记 issue。 */ +/** Bot 守护配置的归一化:非法值夹到合法区间,缺失用默认。 */ +function normalizeBotGuard(raw) { + const d = defaultConfig().botGuard; + const src = raw && typeof raw === 'object' ? raw : {}; + const num = (v, lo, hi, fallback) => { + const n = Number(v); + if (!Number.isFinite(n)) return fallback; + return Math.min(hi, Math.max(lo, n)); + }; + return { + enabled: src.enabled === true, + idleMs: num(src.idleMs, 10_000, 3_600_000, d.idleMs), + backoffBaseMs: num(src.backoffBaseMs, 1_000, 600_000, d.backoffBaseMs), + backoffCapMs: num(src.backoffCapMs, 1_000, 3_600_000, d.backoffCapMs), + }; +} + +function readConfig() { + let text; + try { + text = readFileSync(CONFIG_PATH, 'utf8'); + } catch (e) { + if (e && e.code === 'ENOENT') return { config: defaultConfig(), issues: [] }; + return { config: defaultConfig(), issues: [`配置读取失败(${e.message}),正在用默认值`] }; + } + try { + return normalizeConfig(JSON.parse(text)); + } catch (e) { + return { config: defaultConfig(), issues: [`配置解析失败(${e.message}),正在用默认值`] }; + } +} + +function loadConfig() { + return readConfig().config; +} + +/** 原子写:写 tmp 再 rename。同目录 rename 在 POSIX 上是原子的。 */ +function writeConfig(config) { + mkdirSync(CONFIG_DIR, { recursive: true }); + const tmp = `${CONFIG_PATH}.tmp`; + writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`, 'utf8'); + renameSync(tmp, CONFIG_PATH); +} + +/** + * 等级 → 中文。**属于 parity 契约的一部分**,改一个字三方都要改。 + * 与 Rust decision.rs 的 LEVEL_LABEL 逐字对应。 + */ +const LEVEL_LABEL = { + comfortable: '额度充足', + tight: '额度紧张', + low: '额度不足', + burned: '额度燃尽', +}; + +/** + * 距 5 小时额度刷新还有多少分钟。取不到刷新时间时返回 null。 + * + * 与 Rust `minutes_to_reset()`、Python `_minutes_to_reset()` 同一口径: + * 已过去的时间按 0 算。 + */ +function minutesToReset(fiveHour, nowMs) { + const reset = fiveHour?.resetTimeMs; + if (typeof reset !== 'number' || !Number.isFinite(reset)) return null; + return Math.max(0, (reset - nowMs) / 60_000); +} + +/** + * 四档红绿灯分档。与 Rust `quota_level()` 逐行对应。 + * + * 燃尽档由 used >= total 判定,**不可配** —— 它是事实,不是阈值。 + */ +function quotaLevel(used, total, unlimited, tight, low) { + if (unlimited || total <= 0) return 'comfortable'; + if (used >= total) return 'burned'; + const ratio = (used / total) * 100; + if (ratio >= low) return 'low'; + if (ratio >= tight) return 'tight'; + return 'comfortable'; +} + +/** 是否达到触发线。不限量和总量为 0 都不触发。 */ +function isTriggered(used, total, unlimited, triggerPercent = DEFAULT_QUOTA_TRIGGER_PERCENT) { + if (unlimited || total <= 0) return false; + return used >= (total * triggerPercent) / 100; +} + +function parseQuota(payload, triggerPercent = DEFAULT_QUOTA_TRIGGER_PERCENT) { + const models = payload?.model_remains ?? []; + const general = + models.find((m) => m?.model_name === 'general') ?? models.find((m) => m?.model_name) ?? null; + if (!general) return null; + const total = percent(general.current_interval_total_percent) || 100; + const used = percent(general.current_interval_used_percent); + const weeklyTotal = percent(general.current_weekly_total_percent) || 100; + const weeklyUsed = percent(general.current_weekly_used_percent); + const weeklyUnlimited = general.current_weekly_status === 3; + return { + fiveHour: { + usedPercent: used, + totalPercent: total, + remainingPercent: Math.max(0, (total - used) / total) * 100, + exhausted: general.current_interval_status !== 3 && total > 0 && used >= total, + unlimited: general.current_interval_status === 3, + // 决策用:到触发线(默认 99%)即视为「该动手了」 + triggered: isTriggered(used, total, general.current_interval_status === 3, triggerPercent), + resetTimeMs: general.end_time ?? null, + }, + weekly: { + usedPercent: weeklyUsed, + totalPercent: weeklyTotal, + unlimited: weeklyUnlimited, + exhausted: !weeklyUnlimited && weeklyTotal > 0 && weeklyUsed >= weeklyTotal, + // 周限额此前没有这个字段,导致 Node 侧忽略了周限额耗尽 —— 与 Rust/Python 不一致 + triggered: isTriggered(weeklyUsed, weeklyTotal, weeklyUnlimited, triggerPercent), + resetTimeMs: general.weekly_end_time ?? null, + }, + }; +} + +/** + * 重置卡的使用流水。 + * + * 关键点:`create_time` 是**必填**参数,桌面端就是那样调的。 + * 传 `create_time=0` 表示不限起始时间;传一个具体时间戳反而会返回 + * 2013 invalid params —— 那是一条**错误**,不是「没有记录」。 + * 曾经把这条错误读成 total=0,因而误判「一张卡都没用过」。 + */ +async function fetchCardFlows(token) { + try { + const payload = await apiGet('/backend/account/reset_card/flows?page=1&size=20&create_time=0', token); + const flows = Array.isArray(payload.flows) ? payload.flows : []; + const used = flows.filter((f) => f?.event_type === 'USE'); + return { + available: true, + total: Number(payload.total ?? flows.length), + usedCount: used.length, + grantedCount: flows.filter((f) => f?.event_type === 'GRANT').length, + lastUsedAtMs: used.length ? Math.max(...used.map((f) => Number(f.create_time) || 0)) : null, + }; + } catch { + // 读不到流水时不编数字,交给页面显示「未知」 + return { available: false, total: 0, usedCount: null, grantedCount: null, lastUsedAtMs: null }; + } +} + +async function fetchCards(token) { + const now = Date.now(); + const seen = new Map(); + for (let page = 1; page <= 10; page += 1) { + const payload = await apiGet(`/backend/account/reset_card/list?page=${page}&size=20`, token); + const cards = Array.isArray(payload.cards) ? payload.cards : []; + for (const card of cards) { + if (card && card.card_id) seen.set(String(card.card_id), card); + } + if (cards.length < 20) break; + } + return [...seen.values()] + .map((c) => ({ + cardId: String(c.card_id), + sourceType: String(c.source_type ?? ''), + outBizNo: String(c.out_biz_no ?? ''), + expireTimeMs: typeof c.expire_time === 'number' ? c.expire_time : null, + createTimeMs: typeof c.create_time === 'number' ? c.create_time : null, + expired: typeof c.expire_time === 'number' && c.expire_time <= now, + })) + .sort((a, b) => (a.expireTimeMs ?? Infinity) - (b.expireTimeMs ?? Infinity)); +} + +// ── 决策:先过期先用 ────────────────────────────────────────────────────────── +const SOURCE_NAME = { platform: '平台积分', code: 'Code 积分', card: '重置卡' }; + +/** + * 跨三类来源比较到期时间:平台积分池、Code 积分明细、重置卡。 + * + * 只有「最早到期的积分」与「最早到期的重置卡」直接可比—— + * 决定用不用卡,看的是哪一边更快作废: + * 卡先过期 → 用卡把额度重置回来,把更耐放的积分留着; + * 积分先过期 → 不用卡,让积分自然消耗完,卡留到以后更划算的时候。 + */ +/** + * @param creditDataComplete 积分数据是否**完整读到**。 + * + * ⚠️ 2026-10-19 PR #28 review 修:读不到 ≠ 没有。 + * 之前 `credit/details` 失败会返回 null ⇒ codeCredits 为空 ⇒ + * `decide` 看到「没有积分」⇒ 结论从「留卡」翻转成「用卡」。 + * 而扣卡是**真实写操作** —— 把网络故障当成业务状态会误扣真卡。 + * ⇒ 数据不完整时一律**不做写操作**(动作降为 wait),只如实报出来。 + */ +function decide( + { platformPools, codeCredits, cards, quota, creditDataComplete = true }, + nowMs = Date.now(), +) { + const now = nowMs; + const liveCards = cards.filter((c) => !c.expired && c.outBizNo && c.sourceType); + const liveCredits = [...platformPools, ...codeCredits].filter( + (c) => c.balance > 0 && c.expireTimeMs && c.expireTimeMs > now, + ); + + const earliestCard = liveCards[0] ?? null; + const earliestCredit = liveCredits.sort( + (a, b) => (a.expireTimeMs ?? Infinity) - (b.expireTimeMs ?? Infinity), + )[0] ?? null; + + const cardExpire = earliestCard?.expireTimeMs ?? null; + const creditExpire = earliestCredit?.expireTimeMs ?? null; + const creditName = earliestCredit ? SOURCE_NAME[earliestCredit.source] : '积分'; + + let action = 'wait'; + let reason = ''; + let usedCard = false; + + // 「等级」与「动作」是两个独立维度:动作说**该做什么**,等级说**额度还剩多少**。 + // 混在一起的话,93% 已经紧张时页面徽章仍会写「额度充足」——那就是误导。 + // 等级必须在这里算一次并随判定一起下发,页面才有依据上色。 + const cfg = loadConfig(); + const { quotaLevels } = cfg; + const level = quota + ? quotaLevel( + quota.fiveHour.usedPercent, + quota.fiveHour.totalPercent, + quota.fiveHour.unlimited, + quotaLevels.tight, + quotaLevels.low, + ) + : 'comfortable'; + + // ── 5 小时额度即将自己刷新 → 不烧重置卡(纯浪费)── + // 与 Rust `minutes_to_reset()` 同一口径。 + const resetInMin = minutesToReset(quota?.fiveHour, now); + const skipMinutes = Number(cfg.cardSkipBeforeResetMinutes ?? DEFAULT_CARD_SKIP_BEFORE_RESET_MINUTES); + const skipCardReason = + skipMinutes > 0 && resetInMin !== null && resetInMin <= skipMinutes + ? `5 小时额度 ${resetInMin.toFixed(0)} 分钟后刷新,暂不消耗重置卡` + : null; + + if (!quota) { + reason = '未获取到额度状态'; + } else if (!quota.fiveHour.triggered && !quota.weekly.triggered) { + action = 'idle'; + // 等级词随分档走,而不是永远写「额度充足」—— + // 否则用量 92% 时页面上仍显示「额度充足」,是误导。 + // 把触发线也说进来:否则「额度不足但暂不需要处理」看起来自相矛盾。 + // burned 永远不会出现在这里:它必然已 triggered,走的是下面用卡/留卡分支。 + reason = `${LEVEL_LABEL[level]}(5 小时已用 ${quota.fiveHour.usedPercent.toFixed(0)}%),未到 ${cfg.cardTriggerPercent}% 触发线,暂不动用重置卡`; + } else if (!creditDataComplete) { + // 积分数据没读全 ⇒ 无法判断「卡先过期还是积分先过期」 + // ⇒ 绝不做写操作(扣卡是真实消耗,宁可等下一轮)。 + action = 'wait'; + reason = '积分数据读取不完整,本轮不执行写操作(等下一轮重新判定)'; + } else if (!earliestCredit) { + // 没有可消耗的积分,只能靠卡把额度重置回来 + if (earliestCard) { + // 额度马上要自己刷新了,就别烧卡了 + if (skipCardReason) { + action = 'wait'; + reason = skipCardReason; + } else { + action = 'use-card'; + usedCard = true; + reason = '没有可用积分,使用重置卡重置额度'; + } + } else { + action = 'wait'; + reason = '额度已耗尽,既没有积分也没有可用的重置卡'; + } + } else if (!earliestCard) { + action = 'use-credit'; + reason = `没有可用的重置卡,让${creditName}(${fmtDay(creditExpire)}到期)先消耗`; + } else if (cardExpire < creditExpire) { + // 额度马上要自己刷新了,就别烧卡了 + if (skipCardReason) { + action = 'wait'; + reason = skipCardReason; + } else { + action = 'use-card'; + usedCard = true; + reason = `重置卡(${fmtDay(cardExpire)})比${creditName}(${fmtDay(creditExpire)})先过期,先用卡保住积分`; + } + } else { + action = 'use-credit'; + reason = `${creditName}(${fmtDay(creditExpire)})比重置卡(${fmtDay(cardExpire)})先过期,不用卡,让积分先消耗`; + } + + return { + action, + usedCard, + reason, + // 等级随判定一起下发:页面据此给徽章上色,不再自己猜。 + level, + // 距 5 小时额度刷新的倒计时(分钟)。页面直接显示,不用自己算。 + resetInMinutes: resetInMin, + // 距刷新不足这个分钟数时已暂停自动扣卡 —— 页面要说出来, + // 否则用户看到「额度快没了却不扣卡」会以为坏了。 + cardSkipBeforeResetMinutes: skipMinutes, + skipCardActive: Boolean(skipCardReason), + target: + usedCard && earliestCard + ? { + cardId: earliestCard.cardId, + sourceType: earliestCard.sourceType, + outBizNo: earliestCard.outBizNo, + expireTimeMs: cardExpire, + } + : null, + earliestCardExpireMs: cardExpire, + earliestCreditExpireMs: creditExpire, + earliestCreditSource: earliestCredit?.source ?? null, + earliestCreditLabel: earliestCredit?.label ?? null, + triggered: Boolean(quota && (quota.fiveHour.triggered || quota.weekly.triggered)), + exhausted: Boolean(quota && (quota.fiveHour.exhausted || quota.weekly.exhausted)), + }; +} + +function fmtDay(ms) { + if (!ms) return '未知'; + const d = new Date(ms); + return `${d.getMonth() + 1}月${d.getDate()}日`; +} + +// ── 分层缓存 ────────────────────────────────────────────────────────────────── +/** + * 按接口的「变化速度」分层缓存,直接砍掉无谓的网络往返。 + * + * 实测依据(单轮 5 个接口共 ~19.8 KB / ~1.05s,其中 5 个串行等待是大头): + * 额度用量 每轮必取 —— 消耗在变,是所有联动的触发源 + * 积分明细 60s —— 81 笔里绝大多数只在被消耗时才动 + * 重置卡 300s —— 按天签到产生,变化极慢 + * 平台积分池 300s —— 同上 + * 账户汇总 600s —— 只在签到/充值后变 + * + * 过期未取数时回落到上一次成功结果,宁可显示略旧的数据,也不让看板空白。 + */ +const TTL = { + quota: 0, + codeCredits: 60_000, + cards: 300_000, + platform: 300_000, + membership: 600_000, + cardFlows: 120_000, +}; + +const cache = { + quota: { value: null, at: 0 }, + codeCredits: { value: null, at: 0 }, + cards: { value: null, at: 0 }, + platform: { value: null, at: 0 }, + membership: { value: null, at: 0 }, + cardFlows: { value: null, at: 0 }, +}; + +/** 带 TTL 与陈旧回落的取值:命中则零网络,未命中才请求。 */ +async function cached(slot, loader) { + const entry = cache[slot]; + const now = Date.now(); + if (entry.value !== null && now - entry.at < TTL[slot]) { + return { value: entry.value, fromCache: true }; + } + try { + const value = await loader(); + if (value !== null && value !== undefined) { + entry.value = value; + entry.at = now; + return { value, fromCache: false }; + } + // 本轮失败:用旧值兜底,别把上次的正常数据清掉 + return { value: entry.value, fromCache: true }; + } catch { + return { value: entry.value, fromCache: true }; + } +} + +function invalidateCache(slot) { + if (slot) cache[slot].at = 0; + else for (const key of Object.keys(cache)) cache[key].at = 0; +} + +// ── 快照 ────────────────────────────────────────────────────────────────────── +/** + * 同一时刻只允许一次真实取数。 + * + * SSE 轮询与自动判定会各自调用 buildSnapshot,若不合并就会把 + * 每轮的接口数翻倍。这里让并发的调用共享同一个 in-flight Promise。 + */ +let inflightSnapshot = null; +/** 上一次成功的快照:取数失败时回退用,避免整页被打空。 */ +let lastGoodSnapshot = null; + +function buildSnapshot() { + if (inflightSnapshot) return inflightSnapshot; + inflightSnapshot = doBuildSnapshot().finally(() => { + inflightSnapshot = null; + }); + return inflightSnapshot; +} + +async function doBuildSnapshot() { + const token = await getTokenWithRetry(); + if (!token) { + throw new ApiError('未获取到登录态,请确认 MiniMax Code 桌面端正在运行且已登录', 'unauthorized'); + } + + // 四路并发放大并行收益;积分明细与账户汇总失败不拖垮看板 + const [platform, quotaRes, cardsRes, codeRes, membershipRes, cardFlowsRes] = await Promise.all([ + cached('platform', () => apiGet('/backend/account/token_plan_credit', token)), + cached('quota', () => apiGet('/backend/account/token_plan/remains_percent', token)), + cached('cards', () => fetchCards(token)), + cached('codeCredits', () => + fetchCodeCredits(token) + .then((r) => r.payload) + .catch(() => null), + ), + cached('membership', () => + apiPost('/matrix/api/v1/commerce/get_membership_info', token, CODE_BASE).catch(() => null), + ), + cached('cardFlows', () => fetchCardFlows(token)), + ]); + + const platformRaw = platform.value; + const quotaRaw = quotaRes.value; + const cards = cardsRes.value ?? []; + const codePage = codeRes.value; + const membership = membershipRes.value; + const cardUsage = cardFlowsRes.value ?? { available: false, usedCount: null, grantedCount: null, lastUsedAtMs: null }; + + const platformPools = platformRaw ? parsePlatformPools(platformRaw) : []; + const codeCredits = parseCodeCredits(codePage); + // 触发线来自共享配置(默认 99%),不再写死在本文件里。 + const quota = quotaRaw ? parseQuota(quotaRaw, loadConfig().cardTriggerPercent) : null; + + // ⚠️ 2026-10-19 PR #28 review 修:**读不到 ≠ 没有**。 + // 之前 `credit/details` 失败被 `.catch(() => null)` 吞掉 ⇒ codePage 为 null + // ⇒ codeCredits 为空 ⇒ decide 认为「没有积分」⇒ 把结论从「留卡」翻转成 + // 「用卡」。扣卡是真实写操作,网络故障不该导致误扣。 + // ⇒ 这里显式区分「读取失败」与「读到但确实为空」: + // 平台积分池 / Code 积分明细 / 账户汇总 任一失败 ⇒ 数据不完整。 + // + // 更精确的信号:`cached()` 失败时会**回落到旧值**并标 fromCache=true。 + // - 本轮读到新值(fromCache=false)⇒ 数据完整 ✅ + // - 本轮失败但有旧值(fromCache=true 且有值)⇒ 数据**过时但可用** + // —— 仍然按「不完整」处理更安全:旧值可能已经过期, + // 而扣卡是真实写操作,宁可等下一轮。 + // - 本轮失败且无旧值(value=null)⇒ 完全没数据 ⇒ 同样不完整。 + const creditDataComplete = + !platform.fromCache && !codeRes.fromCache && platformRaw != null && codePage != null; + const decision = decide({ + platformPools, + codeCredits: codeCredits.items, + cards, + quota, + creditDataComplete, + }); + + const now = Date.now(); + const soon7 = expiringWithin(codeCredits.items, 7, now); + const soon30 = expiringWithin(codeCredits.items, 30, now); + const sumBalance = (list) => list.reduce((s, d) => s + d.balance, 0); + + return { + fetchedAtMs: now, + // 哪些来自缓存 —— 页面据此判断数据新鲜度,不必假装每轮都是最新的 + fresh: { + quota: !quotaRes.fromCache, + codeCredits: !codeRes.fromCache, + cards: !cardsRes.fromCache, + platform: !platform.fromCache, + membership: !membershipRes.fromCache, + }, + creditSummary: parseCreditSummary(membership), + platformCredits: { + total: Number(platformRaw?.total_credits ?? 0), + used: Number(platformRaw?.used_credits ?? 0), + remaining: Number(platformRaw?.remaining_credits ?? 0), + fallbackEnabled: Boolean(platformRaw?.token_plan_credit_fallback_enabled), + pools: platformPools, + }, + codeCredits: { + available: codePage !== null, + totalCount: codeCredits.totalCount, + liveBalance: codeCredits.liveBalance, + lostBalance: codeCredits.lostBalance, + /** + * 明细按「会不会再变 / 有没有意义」分区传输: + * active —— 仍有效、余额可能随消耗变化,主表格展示这个 + * lost —— 已过期但**仍有余额**,即「没来得及蹬完」,单独一块展示 + * 余额为 0 的笔既不展示也不传输,已折算进 usedUp 汇总。 + * lost 分区只增不减(过期只会增加),因此适合增量推送: + * 它没变就不重发。 + */ + active: codeCredits.items.filter((d) => !d.expired && d.balance > 0), + lost: codeCredits.items.filter((d) => d.expired && d.balance > 0), + /** + * 余额为 0 的笔(已用尽)不进表格 —— 45 行零余额对用户没有信息量, + * 只把它折算成一句可读的成果汇总。口径是 consumed 之和, + * 依据是 granted - consumed == balance 在全部明细上恒成立。 + */ + usedUp: (() => { + const zero = codeCredits.items.filter((d) => d.balance <= 0); + return { + count: zero.length, + consumed: zero.reduce((sum, d) => sum + d.consumed, 0), + expiredCount: zero.filter((d) => d.expired).length, + }; + })(), + }, + expiry: { + soon7Count: soon7.length, + soon7Balance: sumBalance(soon7), + soon30Count: soon30.length, + soon30Balance: sumBalance(soon30), + nextExpireMs: codeCredits.items.find((d) => !d.expired && d.balance > 0)?.expireTimeMs ?? null, + }, + quota, + cards: { + total: cards.length, + live: cards.filter((c) => !c.expired).length, + /** 序号 + 到期日即可识别,不再下发卡号。 */ + items: cards + .filter((c) => !c.expired) + .slice(0, 12) + .map((c, i) => ({ index: i + 1, expireTimeMs: c.expireTimeMs })), + // 原始可用卡列表(带 cardId)。 + // + // ⚠️ 2026-10-19 PR #28 review 修:`items` 是给页面看的**脱敏视图** + // (只有序号与到期日,没有 cardId),拿它做「这张卡还在不在列表里」的 + // 判断 ⇒ `c.cardId === target.cardId` 恒为 false ⇒ **每次都跳过, + // `reset_card/use` 永远不会被调用**。 + // ⇒ 这里单独带一份原始列表,供写路径判定使用(页面不展示 cardId)。 + raw: cards + .filter((c) => !c.expired) + .map((c) => ({ + cardId: c.cardId, + sourceType: c.sourceType, + outBizNo: c.outBizNo, + expireTimeMs: c.expireTimeMs, + })), + }, + cardUsage, + decision, + }; +} + +// ── 扣卡与自动执行(唯一写路径,先记账再请求)───────────────────────────────── +/** 自动判定间隔的允许范围:最快 5 秒(够灵敏),最慢 60 分钟(够省心)。 */ +const AUTO_MIN_MS = 5_000; +const AUTO_MAX_MS = 60 * 60 * 1000; +const AUTO_DEFAULT_MS = 15_000; + +function clampAutoInterval(ms) { + const n = Number(ms); + if (!Number.isFinite(n)) return AUTO_DEFAULT_MS; + return Math.min(AUTO_MAX_MS, Math.max(AUTO_MIN_MS, Math.round(n))); +} + +const state = { + autoEnabled: true, // 默认开启:99% 即自动判定,避免额度耗尽打断任务 + autoIntervalMs: AUTO_DEFAULT_MS, + spent: new Map(), + lastResult: null, + autoRuns: [], + lastAutoAtMs: 0, +}; + +/** + * 不再设时间节流。 + * 重复扣卡的防护由两道更硬的机制承担: + * 1. state.spent —— 同一张卡在本进程生命周期内只尝试一次 + * 2. out_biz_no —— 服务端幂等键,即使跨进程重试也不会重复扣除 + * 额度耗尽时可以立即再次判定并执行,不必等固定间隔。 + */ + +async function useCard(reasonLabel) { + const token = await getToken(); + if (!token) throw new ApiError('未获取到登录态', 'unauthorized'); + + const snapshot = await buildSnapshot(); + const target = snapshot.decision.target; + if (!target) { + return { ok: false, message: snapshot.decision.reason, action: snapshot.decision.action }; + } + + const fingerprint = `${target.cardId}:${target.outBizNo}`; + if (state.spent.has(fingerprint)) { + // skipped=true 表示「去重保护生效」,不是扣卡失败,调用方不应记入失败日志 + return { ok: false, skipped: true, message: '这张卡已尝试过,避免重复扣卡', action: snapshot.decision.action }; + } + + /** + * 不依赖时间节流,改用「卡是否还在可用列表」判断。 + * 用掉之后服务端会把它移出可用列表,下一轮自然不会再选到它—— + * 这比固定时间窗更准确:额度真正恢复前不会误扣,恢复了也不会漏扣。 + * 进程重启后 spent 清空也没关系,列表判定依然成立。 + */ + // ⚠️ 用 `cards.raw`(带 cardId)而不是 `cards.items`(脱敏视图,无 cardId) + // —— 2026-10-19 PR #28 review 修:用 items 时该判断恒为 false,扣卡永远被跳过。 + const stillListed = (snapshot.cards.raw ?? []).some( + (c) => c.cardId === target.cardId, + ); + if (!stillListed) { + state.spent.set(fingerprint, { cardId: target.cardId, sourceType: target.sourceType, atMs: Date.now() }); + return { ok: false, skipped: true, message: '这张卡已不在可用列表中,跳过', action: snapshot.decision.action }; + } + + // 先记账:进程中途崩溃也不会把这张卡再扣一次 + state.spent.set(fingerprint, { cardId: target.cardId, sourceType: target.sourceType, atMs: Date.now() }); + + try { + const outcome = await apiUseResetCard(target.sourceType, target.outBizNo, token); + const success = outcome === 'SUCCESS' || outcome === 'REPLAYED'; + // 卡已消耗,立刻让列表缓存失效,下一轮重新取。 + // 否则 5 分钟内列表里还留着这张已用掉的卡,「是否还在列表」的判断会失真。 + if (success) { + invalidateCache('cards'); + invalidateCache('cardFlows'); + } + state.lastResult = { + atMs: Date.now(), + cardId: target.cardId, + outcome, + ok: success, + label: reasonLabel, + }; + return { + ok: success, + outcome, + cardId: target.cardId, + message: success + ? `已使用重置卡 ${target.cardId},额度已重置` + : `重置卡不可用(${outcome})`, + action: snapshot.decision.action, + }; + } catch (error) { + state.lastResult = { + atMs: Date.now(), + cardId: target.cardId, + ok: false, + outcome: 'ERROR', + label: reasonLabel, + }; + throw error; + } +} + +/** + * 自动执行一轮:额度耗尽时按「先过期先用」的结论决定用卡还是留卡。 + * + * 只有结论是 use-card 时才真正扣卡;结论是 use-credit(积分先过期)时 + * 只记录一条决策,不做任何写操作——我们无法指定服务端扣哪一笔积分。 + */ +async function autoTick(trigger) { + const now = Date.now(); + // 提前返回的分支也要带 outcome,否则页面会渲染成 undefined + if (!state.autoEnabled) { + return { atMs: now, trigger, outcome: 'AUTO_OFF', reason: '自动执行未开启', message: '' }; + } + state.lastAutoAtMs = now; + + let snapshot; + try { + snapshot = await buildSnapshot(); + } catch (error) { + const entry = { atMs: now, trigger, outcome: 'ERROR', message: describe(error) }; + state.autoRuns.unshift(entry); + state.autoRuns.length = Math.min(state.autoRuns.length, 20); + return entry; + } + + const d = snapshot.decision; + let entry; + if (d.action === 'use-card') { + entry = { atMs: now, trigger, outcome: 'USE_CARD', reason: d.reason, message: '' }; + try { + const result = await useCard('自动执行'); + // 「这张卡已尝试过」是去重保护在正常工作,不是失败。 + // 记成 CARD_FAILED 会让同一页日志被这条刷屏,反而淹没真正的扣卡失败。 + if (result.skipped) { + return { skipped: 'already-spent', outcome: 'SKIPPED_DUPLICATE' }; + } + entry.outcome = result.ok ? 'CARD_USED' : 'CARD_FAILED'; + entry.message = result.message; + entry.cardId = result.cardId; + } catch (error) { + entry.outcome = 'CARD_ERROR'; + entry.message = describe(error); + } + } else if (d.action === 'use-credit') { + entry = { + atMs: now, + trigger, + outcome: 'KEEP_CREDIT', + reason: d.reason, + message: '积分先过期,保留重置卡,未执行任何写操作', + }; + } else { + entry = { atMs: now, trigger, outcome: 'NO_ACTION', reason: d.reason, message: '' }; + } + state.autoRuns.unshift(entry); + state.autoRuns.length = Math.min(state.autoRuns.length, 20); + return entry; +} + +// ── 后台守护进程管理(launchd)──────────────────────────────────────────────── +/** + * 后台服务由 launchd 常驻,关闭本页面后仍会运行 —— + * 这正是它与页面内 SSE 自动判定的区别。 + */ +const DAEMON_LABEL = 'com.local.mcode-reset-card'; +const DAEMON_PLIST = join(homedir(), 'Library', 'LaunchAgents', `${DAEMON_LABEL}.plist`); + +async function run(cmd, args) { + const { spawn } = await import('node:child_process'); + return new Promise((resolve) => { + const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let out = ''; + child.stdout.on('data', (d) => { out += d.toString(); }); + child.stderr.on('data', (d) => { out += d.toString(); }); + child.on('close', (code) => resolve({ code, out })); + child.on('error', (e) => resolve({ code: -1, out: String(e) })); + }); +} + +/** 后台服务当前是否在跑(读 launchd 已加载的作业表)。 */ +async function daemonStatus() { + const { existsSync } = await import('node:fs'); + const installed = existsSync(DAEMON_PLIST); + if (!installed) return { installed: false, running: false, pid: null, label: DAEMON_LABEL }; + + // 注意:`launchctl list