From 9500e77749be407ca82ffc668bc275006df90b42 Mon Sep 17 00:00:00 2001 From: yanghonghang Date: Fri, 6 Nov 2026 00:27:16 +0800 Subject: [PATCH 1/3] =?UTF-8?q?[fix]=20=E6=96=B0=E5=BB=BA=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=9C=A8=E5=88=97=E8=A1=A8=E5=BA=95=E9=83=A8=E5=B1=95?= =?UTF-8?q?=E5=BC=80=E4=B8=8E=E4=BF=9D=E5=AD=98=E4=BD=8D=E7=BD=AE=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=EF=BC=8C=E6=9C=8D=E5=8A=A1=E7=AB=AF=E5=8F=A3=E4=BB=A4?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96=E9=98=B2=E9=87=8D=E5=90=AF=E5=A4=B1?= =?UTF-8?q?=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/window-warmup/dist/plugin.js | 23 ++++++-- packages/window-warmup/dist/ui/app.js | 20 ++++++- packages/window-warmup/package.json | 2 +- packages/window-warmup/src/server.ts | 31 +++++++++-- packages/window-warmup/tests/app.test.ts | 18 +++++++ packages/window-warmup/tests/browser.test.ts | 6 +-- packages/window-warmup/tests/server.test.ts | 57 +++++++++++++++++++- packages/window-warmup/web/app.ts | 24 ++++++++- 8 files changed, 165 insertions(+), 16 deletions(-) diff --git a/packages/window-warmup/dist/plugin.js b/packages/window-warmup/dist/plugin.js index a0a06548e..bde4584d1 100644 --- a/packages/window-warmup/dist/plugin.js +++ b/packages/window-warmup/dist/plugin.js @@ -960,10 +960,10 @@ async function send(base, task, pin, effort, signal, timeoutMs = SEND_TIMEOUT_MS // src/server.ts import { randomBytes, timingSafeEqual } from "crypto"; -import { readFile as readFile3 } from "fs/promises"; +import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "fs/promises"; import path2, { basename, extname, join as join3, resolve as resolve2 } from "path"; // package.json -var version = "0.1.3"; +var version = "0.1.4"; // src/diagnostics.ts var DIAGNOSTICS_SCHEMA = "window-warmup-diagnostics/1"; @@ -2092,9 +2092,24 @@ async function acquireLock(dir, opts = {}) { // src/server.ts var TOKEN_HEADER = "x-window-warmup-token"; +var TOKEN_FILE = "token"; var MARKER_HEADER = "x-window-warmup"; var PROBE_TIMEOUT_MS = 2000; var BODY_LIMIT = 64 * 1024; +async function loadOrCreateToken(dir) { + const file = join3(dir, TOKEN_FILE); + try { + const text = (await readFile3(file, "utf8")).trim(); + if (/^[0-9a-f]{64}$/.test(text)) + return text; + } catch {} + const generated = randomBytes(32).toString("hex"); + try { + await mkdir2(dir, { recursive: true }); + await writeFile2(file, generated, { encoding: "utf8", mode: 384 }); + } catch {} + return generated; +} async function alreadyServing(port) { try { const res = await fetch(`http://127.0.0.1:${port}/`, { @@ -2169,7 +2184,7 @@ async function startService(opts) { else lockedBy = lock.pid; } - const token = randomBytes(32).toString("hex"); + const token = await loadOrCreateToken(opts.dir); let stopped = false; let server; try { @@ -2381,7 +2396,7 @@ async function startService(opts) { return new Response(null, { status: 204, headers: corsHeaders(origin) }); if (url.pathname.startsWith("/api/")) { if (!tokenOK(req.headers.get(TOKEN_HEADER))) { - return json({ error: "\u53E3\u4EE4\u4E0D\u5BF9\uFF1A\u8BF7\u91CD\u65B0\u6253\u5F00\u7A97\u53E3\u9884\u70ED\u9875" }, 401, corsHeaders(origin)); + return json({ error: "\u53E3\u4EE4\u4E0D\u5BF9\uFF1A\u8BF7\u91CD\u65B0\u6253\u5F00\u7A97\u53E3\u9884\u70ED\u9875", reload: true }, 401, corsHeaders(origin)); } return api(req, url, origin); } diff --git a/packages/window-warmup/dist/ui/app.js b/packages/window-warmup/dist/ui/app.js index 1652fad8b..ad0d57e01 100644 --- a/packages/window-warmup/dist/ui/app.js +++ b/packages/window-warmup/dist/ui/app.js @@ -956,6 +956,15 @@ var formError = null; var confirmDeleteAt = 0; var runsExpanded = false; var catalogTimer; +var reloadedForToken = false; +function shouldReloadOnAuthError(status, data, isDirty) { + if (reloadedForToken) + return false; + if (status !== 401 && status !== 403) + return false; + const isTokenErr = typeof data === "object" && data !== null && data.reload === true || typeof data === "object" && data !== null && String(data.error).includes("口令"); + return !!(isTokenErr && !isDirty); +} async function apiJSON(path, init = {}) { const res = await fetch(path, { ...init, @@ -967,6 +976,12 @@ async function apiJSON(path, init = {}) { }); const body = await res.json().catch(() => ({})); if (!res.ok) { + if (shouldReloadOnAuthError(res.status, body, dirty)) { + reloadedForToken = true; + try { + location.reload(); + } catch {} + } const msg = body.error; throw new Error(typeof msg === "string" && msg ? msg : `HTTP ${res.status}`); } @@ -1717,14 +1732,14 @@ function renderList() { nodes.push(e); return nodes; } - if (isNew && draft) - nodes.push(rowEl(draft, null, true), buildEditor()); for (const t of tasks) { const open = openId === t.id; nodes.push(rowEl(open && draft ? draft : t, t, open)); if (open && draft) nodes.push(buildEditor()); } + if (isNew && draft) + nodes.push(rowEl(draft, null, true), buildEditor()); return nodes; }); restoreFocus(list, focus); @@ -2077,6 +2092,7 @@ export { setLang, shortModel, shouldCloseOnScroll, + shouldReloadOnAuthError, statusOf, sumView, syncDraftOnPoll, diff --git a/packages/window-warmup/package.json b/packages/window-warmup/package.json index 365893a70..13c890eb6 100644 --- a/packages/window-warmup/package.json +++ b/packages/window-warmup/package.json @@ -1,6 +1,6 @@ { "name": "@magpie-community/page-window-warmup", - "version": "0.1.3", + "version": "0.1.4", "description": "窗口预热 Window Warmup:定时消息插件,定时给指定供应商/账号/模型发一条短消息,在额度窗口重置后、每天定时或每隔几小时自动预热保活,提前开启5小时/每周订阅额度(配额)窗口,适用 Codex、Claude、GLM 智谱、ZCode 等。Scheduled keep-alive message to warm up quota / usage / rate limit windows after reset (cron-like).", "author": "YangHonghang (https://github.com/YangHonghang)", "type": "module", diff --git a/packages/window-warmup/src/server.ts b/packages/window-warmup/src/server.ts index 9308b8057..60856d94c 100644 --- a/packages/window-warmup/src/server.ts +++ b/packages/window-warmup/src/server.ts @@ -5,7 +5,7 @@ // - store 损坏 → fatal:服务照起让页面显示原因,但绝不启动调度、拒绝一切写操作。 // - 单实例锁被别人持有 → lockedBy:页面只读展示,同样不启动调度。 import { randomBytes, timingSafeEqual } from "node:crypto"; -import { readFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import path, { basename, extname, join, resolve } from "node:path"; import { buildDiagnostics } from "./diagnostics"; import { fetchCatalog } from "./gateway"; @@ -16,6 +16,8 @@ import { errText } from "./types"; /** 页面请求 /api/* 用的口令头 */ export const TOKEN_HEADER = "x-window-warmup-token"; +/** 存储在数据目录下的口令文件名,进程重启时复用 */ +export const TOKEN_FILE = "token"; /** 本插件服务的自标记响应头:所有响应都带,识别「这是窗口预热的服务」 */ export const MARKER_HEADER = "x-window-warmup"; /** 启动前探测已有实例的超时:挂着不响应的服务按「不是本插件」处理 */ @@ -23,6 +25,29 @@ const PROBE_TIMEOUT_MS = 2_000; /** JSON 请求体上限:任务字段都很小,超限必然不是页面发的 */ const BODY_LIMIT = 64 * 1024; +/** + * 读出已有口令;没有或内容损坏则生成新口令落盘。 + * 进程重启(如安装/升级其他插件导致插件宿主重启)时口令保持不变, + * 正在打开的页面无需重新载入、轮询不报错。 + */ +export async function loadOrCreateToken(dir: string): Promise { + const file = join(dir, TOKEN_FILE); + try { + const text = (await readFile(file, "utf8")).trim(); + if (/^[0-9a-f]{64}$/.test(text)) return text; + } catch { + /* 没这个文件或读失败就新生成 */ + } + const generated = randomBytes(32).toString("hex"); + try { + await mkdir(dir, { recursive: true }); + await writeFile(file, generated, { encoding: "utf8", mode: 0o600 }); + } catch { + /* 无法写入(比如无写权限目录)退化为内存口令 */ + } + return generated; +} + /** * 探测端口上有没有本插件的服务在应答(所有响应都带 MARKER_HEADER)。 * plugin.ts 启动前用它识别「已经在跑」;锁冲突时用它确认持有者是否真在服务。 @@ -135,7 +160,7 @@ export async function startService(opts: ServiceOptions): Promise else lockedBy = lock.pid; // pid 0 = 持有者身份无法判断 } - const token = randomBytes(32).toString("hex"); + const token = await loadOrCreateToken(opts.dir); let stopped = false; let server: ReturnType; @@ -389,7 +414,7 @@ export async function startService(opts: ServiceOptions): Promise if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: corsHeaders(origin) }); if (url.pathname.startsWith("/api/")) { if (!tokenOK(req.headers.get(TOKEN_HEADER))) { - return json({ error: "口令不对:请重新打开窗口预热页" }, 401, corsHeaders(origin)); + return json({ error: "口令不对:请重新打开窗口预热页", reload: true }, 401, corsHeaders(origin)); } return api(req, url, origin); } diff --git a/packages/window-warmup/tests/app.test.ts b/packages/window-warmup/tests/app.test.ts index 9d95f10ab..9ec74e331 100644 --- a/packages/window-warmup/tests/app.test.ts +++ b/packages/window-warmup/tests/app.test.ts @@ -25,6 +25,7 @@ import { shortModel, statusOf, shouldCloseOnScroll, + shouldReloadOnAuthError, sumView, syncDraftOnPoll, trigText, @@ -527,3 +528,20 @@ test("反馈问题:诊断过大时改用紧凑 JSON,仍超 GitHub 上限就 expect(huge).toContain("诊断日志过长,已截断"); expect(huge.endsWith("\n")).toBe(true); }); + +test("shouldReloadOnAuthError:口令失效时若无未保存草稿触发自动刷新,有草稿或非鉴权错不刷", () => { + // 401 + reload: true,无未保存草稿 → 刷新 + expect(shouldReloadOnAuthError(401, { error: "口令不对:请重新打开窗口预热页", reload: true }, false)).toBe(true); + // 401 文本含「口令」即使缺 reload: true 也识别 + expect(shouldReloadOnAuthError(401, { error: "口令不对" }, false)).toBe(true); + // 用户正在编辑未保存的草稿(isDirty = true)→ 绝不冲掉草稿 + expect(shouldReloadOnAuthError(401, { error: "口令不对", reload: true }, true)).toBe(false); + // 非鉴权错误(400/500/200)→ 不刷 + expect(shouldReloadOnAuthError(400, { error: "参数错误" }, false)).toBe(false); + expect(shouldReloadOnAuthError(500, { error: "服务器错误" }, false)).toBe(false); + // 异常数据兜底 + expect(shouldReloadOnAuthError(401, null, false)).toBe(false); + expect(shouldReloadOnAuthError(401, undefined, false)).toBe(false); + expect(shouldReloadOnAuthError(401, "string-error", false)).toBe(false); +}); + diff --git a/packages/window-warmup/tests/browser.test.ts b/packages/window-warmup/tests/browser.test.ts index e2bc17332..d0d08304f 100644 --- a/packages/window-warmup/tests/browser.test.ts +++ b/packages/window-warmup/tests/browser.test.ts @@ -214,11 +214,11 @@ for (const [name, engine] of ENGINES) { }); expect(sizes).toEqual({ run: "12px", foot: "11.5px" }); - // 列表里已有任务时新建:编辑区紧跟顶部的新行,不跑到列表末尾 + // 列表里已有任务时新建:编辑区紧跟底部的新行,与保存后任务追加到底部一致 await page.getByRole("button", { name: "新建任务" }).click(); await page.waitForFunction(() => { const ed = document.querySelector("#list .editor"); - return !!ed && ed.previousElementSibling === document.querySelector("#list")?.firstElementChild; + return !!ed && ed === document.querySelector("#list")?.lastElementChild; }, undefined, { timeout: 15_000 }); const added = await page.evaluate(() => { const list = document.querySelector("#list"); @@ -229,7 +229,7 @@ for (const [name, engine] of ENGINES) { editorIsLast: ed === list?.lastElementChild, }; }); - expect(added).toEqual({ editors: 1, prevIsOpenRow: true, editorIsLast: false }); + expect(added).toEqual({ editors: 1, prevIsOpenRow: true, editorIsLast: true }); } finally { await browser.close(); for (const id of ids) { diff --git a/packages/window-warmup/tests/server.test.ts b/packages/window-warmup/tests/server.test.ts index 2b3d3bf36..0d299acf4 100644 --- a/packages/window-warmup/tests/server.test.ts +++ b/packages/window-warmup/tests/server.test.ts @@ -6,7 +6,7 @@ import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, posix, win32 } from "node:path"; import plugin from "../src/plugin"; -import { alreadyServing, insideDir, startService, type ServiceHandle } from "../src/server"; +import { alreadyServing, insideDir, loadOrCreateToken, startService, TOKEN_FILE, type ServiceHandle } from "../src/server"; import { fakeGateway, type FakeGateway } from "./fake-gateway"; const services: ServiceHandle[] = []; @@ -267,6 +267,61 @@ test("完整 CRUD 与立即发送往返", async () => { expect((await api(`/api/tasks/${task.id}`, { method: "DELETE" })).status).toBe(404); }); +test("口令持久化:初次生成并写入 token 文件,重启服务复用已有口令,损坏自动重置", async () => { + const dir = await mkdtemp(join(tmpdir(), "sm-token-test-")); + dirs.push(dir); + const t1 = await loadOrCreateToken(dir); + expect(t1).toMatch(/^[0-9a-f]{64}$/); + const saved = (await readFile(join(dir, TOKEN_FILE), "utf8")).trim(); + expect(saved).toBe(t1); + + // 再次调用必须复用同一个口令 + const t2 = await loadOrCreateToken(dir); + expect(t2).toBe(t1); + + // 文件带换行符也能正常读取 + await writeFile(join(dir, TOKEN_FILE), `${t1}\n\n`); + expect(await loadOrCreateToken(dir)).toBe(t1); + + // 嵌套新目录也能自动创建 + const nested = join(dir, "sub", "dir"); + const tNested = await loadOrCreateToken(nested); + expect(tNested).toMatch(/^[0-9a-f]{64}$/); + expect((await readFile(join(nested, TOKEN_FILE), "utf8")).trim()).toBe(tNested); + + // 同一个 dir 重启服务:前后实例 token 完全一致(模拟插件宿主重启) + const gw = fakeGateway({ models: MODELS }); + gateways.push(gw); + const s1 = await startService({ port: 0, dir, base: gw.base }); + const html1 = await (await fetch(`http://127.0.0.1:${s1.port}/`)).text(); + const token1 = JSON.parse(/id="boot"[^>]*>([\s\S]*?)<\/script>/.exec(html1)![1]).token; + expect(token1).toBe(t1); + await s1.stop(); + + const s2 = await startService({ port: 0, dir, base: gw.base }); + const html2 = await (await fetch(`http://127.0.0.1:${s2.port}/`)).text(); + const token2 = JSON.parse(/id="boot"[^>]*>([\s\S]*?)<\/script>/.exec(html2)![1]).token; + expect(token2).toBe(t1); + await s2.stop(); + + // 口令错误时 401 响应必须带 reload: true + const s3 = await startService({ port: 0, dir, base: gw.base }); + services.push(s3); + const unauth = await fetch(`http://127.0.0.1:${s3.port}/api/state`, { + headers: { "x-window-warmup-token": "wrong" }, + }); + expect(unauth.status).toBe(401); + const unauthBody = (await unauth.json()) as { error: string; reload?: boolean }; + expect(unauthBody.reload).toBe(true); + + // token 内容被破坏时重新生成合法口令 + await writeFile(join(dir, TOKEN_FILE), "corrupted"); + const t3 = await loadOrCreateToken(dir); + expect(t3).toMatch(/^[0-9a-f]{64}$/); + expect(t3).not.toBe(t1); +}); + + test("发送进行中重复 run → 409", async () => { const gw = fakeGateway({ models: MODELS, diff --git a/packages/window-warmup/web/app.ts b/packages/window-warmup/web/app.ts index f31e56946..de88c7d92 100644 --- a/packages/window-warmup/web/app.ts +++ b/packages/window-warmup/web/app.ts @@ -518,6 +518,18 @@ let confirmDeleteAt = 0; let runsExpanded = false; let catalogTimer: ReturnType | undefined; +/** 服务重启导致口令失效时重新载入一次页面拿新口令(只试一次,防死循环) */ +let reloadedForToken = false; + +export function shouldReloadOnAuthError(status: number, data: unknown, isDirty: boolean): boolean { + if (reloadedForToken) return false; + if (status !== 401 && status !== 403) return false; + const isTokenErr = + (typeof data === "object" && data !== null && (data as { reload?: boolean }).reload === true) || + (typeof data === "object" && data !== null && String((data as { error?: string }).error).includes("口令")); + return !!(isTokenErr && !isDirty); +} + // 页面所有请求都走相对路径:magpie web 把服务转发在自己的前缀路径下时, // 根绝对路径会解析到宿主根上而 404;本地直达时相对与根绝对等价。 async function apiJSON(path: string, init: RequestInit = {}): Promise { @@ -531,6 +543,14 @@ async function apiJSON(path: string, init: RequestInit = {}): Promise { }); const body: unknown = await res.json().catch(() => ({})); if (!res.ok) { + if (shouldReloadOnAuthError(res.status, body, dirty)) { + reloadedForToken = true; + try { + location.reload(); + } catch { + /* 无法刷新则继续抛出错误显示在横幅中 */ + } + } const msg = (body as { error?: unknown }).error; throw new Error(typeof msg === "string" && msg ? msg : `HTTP ${res.status}`); } @@ -1352,13 +1372,13 @@ function renderList(): void { nodes.push(e); return nodes; } - // 编辑区紧跟在展开的那一行下面(原地展开),不是列表末尾 - if (isNew && draft) nodes.push(rowEl(draft, null, true), buildEditor()); for (const t of tasks) { const open = openId === t.id; nodes.push(rowEl(open && draft ? draft : t, t, open)); if (open && draft) nodes.push(buildEditor()); } + // 新建任务时展开的卡片放到底部,与保存后任务追加到底部的位置一致 + if (isNew && draft) nodes.push(rowEl(draft, null, true), buildEditor()); return nodes; }); restoreFocus(list, focus); From fe7f5db16be24318f0ec9db10745cb176e46c4b8 Mon Sep 17 00:00:00 2001 From: yanghonghang Date: Mon, 9 Nov 2026 09:22:11 +0800 Subject: [PATCH 2/3] =?UTF-8?q?[fix]=20=E7=AA=97=E5=8F=A3=E9=A2=84?= =?UTF-8?q?=E7=83=AD=EF=BC=9A=E8=AF=86=E5=88=AB=E9=A2=9D=E5=BA=A6=E5=8D=A1?= =?UTF-8?q?=E7=9A=84=E4=B8=A4=E7=A7=8D=E7=A9=BA=E9=97=B2=E6=8A=A5=E6=B3=95?= =?UTF-8?q?=EF=BC=8CAntigravity=20=E7=AA=97=E5=8F=A3=E9=87=8D=E7=BD=AE?= =?UTF-8?q?=E5=90=8E=E6=8C=89=E6=97=B6=E8=87=AA=E5=8A=A8=E5=8F=91=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重置点随读卡时刻滑动且用量为 0:复读确认后按空闲发送 - 旧重置点过后直接出现下一轮重置点、用量为 0 且其后未发送:按空闲发送,去重键取旧重置点 - README 增加更新说明并补充已知限制 --- packages/window-warmup/README.md | 14 + packages/window-warmup/dist/plugin.js | 54 ++- packages/window-warmup/src/scheduler.ts | 80 ++++- packages/window-warmup/src/store.ts | 7 + .../window-warmup/tests/scheduler.test.ts | 327 ++++++++++++++++++ 5 files changed, 474 insertions(+), 8 deletions(-) diff --git a/packages/window-warmup/README.md b/packages/window-warmup/README.md index 0c130e641..309d400b6 100644 --- a/packages/window-warmup/README.md +++ b/packages/window-warmup/README.md @@ -20,6 +20,14 @@ magpie plugin add @magpie-community/page-window-warmup 新包在 npm 上还没有第一个版本时,magpie 从社区仓库的 `dist` 分支安装并在之后自动更新,功能不受影响;npm 上有版本之后按常规方式安装即可。 +## 更新说明 + +- **0.1.4**:修复 Antigravity 等窗口重置后不自动发送的问题(这类卡在窗口空闲时不清空重置点,详见「三种触发」);服务口令持久化,Magpie 重启插件后已打开的页面不再报「口令不对」;新建任务在列表底部展开。 +- **0.1.3**:编辑区在所编辑任务的下方展开;页脚链接不再改动编辑区按钮的样式。 +- **0.1.2**:模型缓存只存分档变体的供应商(如 Antigravity)也能选到模型;「反馈问题」附带匿名诊断日志。 +- **0.1.1**:被路由组隐藏的模型照常发送,不再报「已不在模型列表」。 +- **0.1.0**:首个版本,支持窗口重置后、每天定时、固定间隔三种触发。 + ## 三种触发 | 触发 | 到期点 | 去重 | @@ -30,6 +38,10 @@ magpie plugin add @magpie-community/page-window-warmup 所有任务都必须选定账号(预热发给具体账号或 key;交给网关自动选,就没有稳定的去重与窗口归属)。调度器只保留一个定时器,最长睡 10 分钟就醒来重算,系统睡眠、改系统时间都能自愈。额度快照约每 10 分钟及每次发送后刷新。magpie 的额度缓存只在它路由请求那一刻读卡(没有托盘的 `magpie web` 平时不读),所以窗口任务快到期要发送时,以及发送满 1 分钟后,插件会让 magpie 重读这一张订阅卡(`POST /v1/magpie/quotas/refresh`,同用量页单卡「刷新」,只查余额);卡片读取时刻(`readAt`)早于上次发送的空闲读数一律不信,宁可晚发也不重发。 +有的供应商(如 Antigravity)在窗口空闲时不清空重置点,而是报「读卡时刻 + 窗口长度」,每读一次往后推一次。插件不按供应商区分,而是看读数本身:新出现的未来重置点满 1 分钟后再读一次卡,如果重置点在到来之前往后挪了、挪动量与两次读卡间隔相差不超过 5 秒、且用量为 0,就按空闲处理(同样按小时计 key);同一个重置点隔 1 分钟以上重读仍不变,就确认是真在计时。所以这类窗口会比重置点晚 1~3 分钟发出。 + +还有一种报法:旧窗口到期后,卡上直接出现下一轮的重置点(如 Antigravity 报「旧重置点 + 5 小时」)并保持不动,看着和真在计时一样,其实没人开窗。插件的判定是:读到新重置点时旧重置点已经过去、插件在旧重置点之后没发过、且用量为 0,就当空闲立即发,去重 key 取那个已过去的旧重置点,所以每轮最多发一次。发送后厂商若真开了窗,重置点会变成「发送时刻 + 窗口长度」;若厂商其实已自动开了下一窗、重置点不变,也不会再发。 + ## 模型与思考档位 模型选「自动」时,任务里不存选中值,每次发送前按统一账号规则现选(目录与调度器同一份实现): @@ -173,6 +185,7 @@ Magpie 内部用「候选 ID」区分同一供应商下的多把 key 和多个 | key 与账号对应关系的接口 | sha256 指纹三方对齐(key 目录、缓存 `Keys`、发送头) | Magpie 更改 keyID 算法时三处同时失配,key 账号集体失效 | | 起名 key 之外的额度卡匹配方式 | 指纹反查 key 名再按卡名匹配 | 没起名的 key 匹配不上(卡名是打码形态),账号没有窗口,窗口任务会一直报「账号的额度里没有这个窗口」 | | 订阅供应商的稳定 id | 额度卡上的供应商名按订阅 agent 名对 | 在 Magpie 里改过名的供应商,目录里的账号与模型可能匹配不上 | +| 额度卡对「窗口空闲」的统一表示(有的卡报 `resetsAt: null`,有的报已过去的时间,Antigravity 报「读卡时刻 + 窗口长度」) | 不按供应商区分,看重置点是否随读卡同步后挪,或是旧重置点过后直接出现的下一轮重置点,且用量为 0(0.1.4 起) | 0.1.3 及以前,Antigravity 的窗口重置后一直不自动发送,只能手动发;判定要多读一次卡,发送比重置点晚 1~3 分钟;旧版 Magpie 不给 `readAt` 时无法判定,仍是旧表现 | | 发送侧「模型不存在」的可判别错误 | 只能按 HTTP 错误原文记录 | 「已下线」和「该账号无权使用」在执行记录里无法区分 | **计费与窗口** @@ -187,6 +200,7 @@ Magpie 内部用「候选 ID」区分同一供应商下的多把 key 和多个 - **供应商没有额度卡**(常见于插件型订阅供应商):看不到窗口,只能用每天定时或固定间隔。 - **额度卡瞬断或 Magpie 没在运行**:插件静默等下一轮刷新,可能错过一次重置;Magpie 关闭期间不执行,重开后每个任务最多补发一次。 +- **空闲窗口的报法各家不同**:插件靠读数本身判断窗口是否空闲(见「三种触发」)。如果某家的卡在窗口真在计时时,重置点也随读卡往后推、用量又显示 0,插件会把它当成空闲,每小时最多多发一次。旧重置点过后直接出现下一轮重置点、用量又显示 0 时,插件也当空闲发一次:若那其实是自动续上的固定周期窗口,或有人在别处用过但用量读作 0,每轮会多发一条 `hi`。 - **额度读数的时效**:依赖 Magpie 的缓存与单卡重读限频(30 秒),拿不到新读数时按快照时刻判断,发送可能提前或推迟。 **模型与账号权限** diff --git a/packages/window-warmup/dist/plugin.js b/packages/window-warmup/dist/plugin.js index bde4584d1..057afeab2 100644 --- a/packages/window-warmup/dist/plugin.js +++ b/packages/window-warmup/dist/plugin.js @@ -1161,6 +1161,8 @@ var QUOTA_REFRESH_MS = 600000; var POST_SEND_SETTLE_MS = 60000; var CARD_FRESH_MS = 60000; var CARD_REFRESH_GAP_MS = 60000; +var ROLL_GAP_MS = 60000; +var ROLL_TOLERANCE_MS = 5000; var HOUR_MS2 = 3600000; var DAY_MS = 24 * HOUR_MS2; var INTERRUPTED_REASON = "Magpie \u5728\u53D1\u9001\u4E2D\u9014\u9000\u51FA\uFF0C\u7ED3\u679C\u672A\u77E5"; @@ -1533,12 +1535,27 @@ class Scheduler { for (const task of this.store.data.tasks) { if (!task.enabled) continue; - const raw = this.quotaWindow(task)?.resetsAt; - if (!raw || Number.isNaN(new Date(raw).getTime())) + const win = this.quotaWindow(task); + const raw = win?.resetsAt; + if (!win || !raw || Number.isNaN(new Date(raw).getTime())) continue; + const readAt = this.loginCard(task)?.readAt ?? null; const mark = this.marksOf(task.id); if (mark.lastReset !== raw) { + const prevReset = Date.parse(mark.lastReset ?? ""); + const read = Date.parse(readAt ?? ""); + const dRead = read - Date.parse(mark.lastResetReadAt ?? ""); + mark.resetRolls = prevReset > read && dRead >= ROLL_GAP_MS && Math.abs(Date.parse(raw) - prevReset - dRead) <= ROLL_TOLERANCE_MS; + const sent = this.lastSentMs(task); + mark.resetCarried = prevReset <= read && !(sent >= prevReset) ? mark.lastReset : null; mark.lastReset = raw; + mark.lastResetReadAt = readAt; + dirty = true; + } else if (!mark.lastResetReadAt && readAt) { + mark.lastResetReadAt = readAt; + dirty = true; + } else if (mark.resetRolls && Date.parse(readAt ?? "") - Date.parse(mark.lastResetReadAt ?? "") >= ROLL_GAP_MS) { + mark.resetRolls = false; dirty = true; } } @@ -1552,13 +1569,21 @@ class Scheduler { const due = windowDue(win, this.quota.at); if ("error" in due) return null; + const mark = this.marksOf(task.id); + if (mark.resetRolls && win.resetsAt === mark.lastReset && win.used === 0) { + if (this.staleIdle(task)) + return null; + return { at: new Date(this.quota.at.getTime()), resetKey: `idle:${hourFloorISO(this.quota.at)}` }; + } + if (mark.resetCarried && due.resetKey === mark.lastReset && win.used === 0) { + return { at: new Date(this.quota.at.getTime()), resetKey: `after:${mark.resetCarried}` }; + } if (due.resetKey.startsWith("idle:") && this.staleIdle(task)) return null; const raw = win.resetsAt; if (raw && !Number.isNaN(new Date(raw).getTime())) { return { at: due.at, resetKey: raw }; } - const mark = this.marksOf(task.id); const isFuture = (iso) => !!iso && new Date(iso).getTime() > now.getTime(); if (isFuture(mark.lastReset) || mark.resetKeys.some(isFuture)) return null; @@ -1576,6 +1601,22 @@ class Scheduler { const sent = this.lastSentMs(task); return Number.isFinite(readAt) && Number.isFinite(sent) && readAt < sent + POST_SEND_SETTLE_MS; } + rollProbeAt(task, now) { + const mark = this.store.data.marks[task.id]; + const card = this.loginCard(task); + const win = this.quotaWindow(task); + if (!mark || !card || !win?.resetsAt || win.used !== 0) + return null; + if (mark.resetRolls || win.resetsAt !== mark.lastReset) + return null; + if (!(Date.parse(win.resetsAt) > now.getTime())) + return null; + const first = Date.parse(mark.lastResetReadAt ?? ""); + const read = Date.parse(card.readAt ?? ""); + if (!Number.isFinite(first) || !Number.isFinite(read) || read - first >= ROLL_GAP_MS) + return null; + return first + ROLL_GAP_MS; + } async readCards(now) { let asked = false; for (const task of this.store.data.tasks) { @@ -1590,7 +1631,9 @@ class Scheduler { const due = this.currentWindowDue(task, now); const readFresh = now.getTime() - readAt <= CARD_FRESH_MS; const dueNow = due !== null && this.windowReady(task, due, now) && !readFresh; - if (!afterSend && !dueNow) + const probeAt = this.rollProbeAt(task, now); + const probe = probeAt !== null && now.getTime() >= probeAt; + if (!afterSend && !dueNow && !probe) continue; const key = `${card.provider}|${card.account}`; const last = this.lastCardRefresh.get(key); @@ -1766,6 +1809,9 @@ class Scheduler { const settled = this.lastSentMs(task) + POST_SEND_SETTLE_MS; if (settled > now.getTime()) minDelay = Math.min(minDelay, settled - now.getTime()); + const probeAt = this.rollProbeAt(task, now); + if (probeAt !== null && probeAt > now.getTime()) + minDelay = Math.min(minDelay, probeAt - now.getTime()); const due = this.currentWindowDue(task, now); if (due && due.at.getTime() > now.getTime()) { minDelay = Math.min(minDelay, due.at.getTime() - now.getTime()); diff --git a/packages/window-warmup/src/scheduler.ts b/packages/window-warmup/src/scheduler.ts index bb18ff889..760ff6259 100644 --- a/packages/window-warmup/src/scheduler.ts +++ b/packages/window-warmup/src/scheduler.ts @@ -37,6 +37,12 @@ const POST_SEND_SETTLE_MS = 60_000; const CARD_FRESH_MS = 60_000; /** 同一张卡两次重读请求的最小间隔(magpie 侧另有 30 秒限频) */ const CARD_REFRESH_GAP_MS = 60_000; +/** + * 判定「重置点随读卡滑动」的两次读数最小间隔与允许误差:有的卡(Antigravity)空闲时 resetsAt + * 恒为读卡时刻 + 窗口长度(resetSecs 恒定),间隔须远大于误差,否则真计时的固定重置点也会被误判 + */ +const ROLL_GAP_MS = 60_000; +const ROLL_TOLERANCE_MS = 5_000; const HOUR_MS = 3_600_000; /** snapshot 时间轴只画未来 24 小时 */ const DAY_MS = 24 * HOUR_MS; @@ -561,16 +567,46 @@ export class Scheduler { /** * 记下各窗口任务最近见到的重置时间(变了才落盘):额度卡瞬断丢了 resetsAt 时, * currentWindowDue 靠它分辨真假空闲。额度快照或 key 目录一更新就要补记,新建/编辑任务后也要。 + * 换值时顺带判定新值是否「随读卡滑动」(resetRolls)或「旧重置点过后预报的下一轮」(resetCarried)。 */ private recordResets(): void { let dirty = false; for (const task of this.store.data.tasks) { if (!task.enabled) continue; - const raw = this.quotaWindow(task)?.resetsAt; - if (!raw || Number.isNaN(new Date(raw).getTime())) continue; + const win = this.quotaWindow(task); + const raw = win?.resetsAt; + if (!win || !raw || Number.isNaN(new Date(raw).getTime())) continue; + const readAt = this.loginCard(task)?.readAt ?? null; const mark = this.marksOf(task.id); if (mark.lastReset !== raw) { + // 真在计时的窗口,重置点不会在到来之前往后挪;挪动量又恰好等于两次读卡间隔, + // 说明它是「读卡时刻 + 窗口长度」的空闲读数(用量的门槛在 currentWindowDue 用的时候判)。 + // 缺 readAt 时全是 NaN,判为否 + const prevReset = Date.parse(mark.lastReset ?? ""); + const read = Date.parse(readAt ?? ""); + const dRead = read - Date.parse(mark.lastResetReadAt ?? ""); + mark.resetRolls = + prevReset > read && + dRead >= ROLL_GAP_MS && + Math.abs(Date.parse(raw) - prevReset - dRead) <= ROLL_TOLERANCE_MS; + // 旧重置点过了之后读到的新重置点,而插件在旧重置点之后没发过:有的卡(Antigravity) + // 窗口到期后不报空闲,而是直接报「旧重置点 + 窗口长度」,看着像在计时,其实没人开窗 + const sent = this.lastSentMs(task); + mark.resetCarried = prevReset <= read && !(sent >= prevReset) ? mark.lastReset : null; mark.lastReset = raw; + mark.lastResetReadAt = readAt; + dirty = true; + } else if (!mark.lastResetReadAt && readAt) { + // 旧数据只有 lastReset:补上首次读数时刻,否则 rollProbeAt 永远凑不齐两次读数 + mark.lastResetReadAt = readAt; + dirty = true; + } else if ( + mark.resetRolls && + Date.parse(readAt ?? "") - Date.parse(mark.lastResetReadAt ?? "") >= ROLL_GAP_MS + ) { + // 隔开足够久重读仍是同一个值 → 固定重置点。发送恰好与最后一次滑动读数落在同一秒时, + // 开窗后的 resetsAt 与那次滑动值相同、不会换值,只能靠这里撤销滑动判定 + mark.resetRolls = false; dirty = true; } } @@ -586,6 +622,20 @@ export class Scheduler { if (!win || !this.quota) return null; const due = windowDue(win, this.quota.at); if ("error" in due) return null; + const mark = this.marksOf(task.id); + if (mark.resetRolls && win.resetsAt === mark.lastReset && win.used === 0) { + // 随读卡滑动的 resetsAt 是空闲的另一种报法:与 resetsAt 缺失的空闲同样按小时计 key。 + // 用量非 0 不算:magpie 在路由请求那一刻读卡,按「最后使用 + 窗口长度」滑动的计时窗口 + // 在使用中也会同步后挪;开窗后与滑动值同秒的固定重置点也靠用量先挡一道 + if (this.staleIdle(task)) return null; + return { at: new Date(this.quota.at.getTime()), resetKey: `idle:${hourFloorISO(this.quota.at)}` }; + } + if (mark.resetCarried && due.resetKey === mark.lastReset && win.used === 0) { + // 预报的下一轮当空闲立即发,只管重置点还没到的时候,到点之后交回常规判断。 + // key 取已过去的旧重置点:每轮最多一次,发后厂商不改值也不再发;不能用新重置点本身—— + // 发送与读数落在同一秒时,开窗后的 resetsAt 恰好等于它,会把下一轮误当成已发过 + return { at: new Date(this.quota.at.getTime()), resetKey: `after:${mark.resetCarried}` }; + } // 空闲读数早于上次发送:那是发送前读的(magpie 只在路由请求那一刻读卡),当没数据, // 等 readCards 拿到发送后的读数再判断——否则跨整点 idle key 一变就会再发一次 if (due.resetKey.startsWith("idle:") && this.staleIdle(task)) return null; @@ -600,7 +650,6 @@ export class Scheduler { // 之前见过且还没到的重置时间说明窗口其实在计时,这轮当没数据,静默等下一轮刷新; // 已消费过的未来 resetsAt 同理(重启后 lastReset 还没补上时兜一道)。 // idle: 等非时间形态的 key 解析出 NaN,比较恒为假,不用单独排除 - const mark = this.marksOf(task.id); const isFuture = (iso: string | null | undefined): boolean => !!iso && new Date(iso).getTime() > now.getTime(); if (isFuture(mark.lastReset) || mark.resetKeys.some(isFuture)) return null; @@ -629,6 +678,24 @@ export class Scheduler { return Number.isFinite(readAt) && Number.isFinite(sent) && readAt < sent + POST_SEND_SETTLE_MS; } + /** + * 新出现的未来 resetsAt 只有一次读数时,要在首读满 ROLL_GAP_MS 后再读一次,才分得清它是真在计时 + * 还是随读卡滑动(否则滑动的空闲窗口永远排在「5 小时后」)。返回该重读的时刻;不需要为 null。 + * 用量非 0 不可能判为滑动,不必重读 + */ + private rollProbeAt(task: Task, now: Date): number | null { + const mark = this.store.data.marks[task.id]; + const card = this.loginCard(task); + const win = this.quotaWindow(task); + if (!mark || !card || !win?.resetsAt || win.used !== 0) return null; + if (mark.resetRolls || win.resetsAt !== mark.lastReset) return null; + if (!(Date.parse(win.resetsAt) > now.getTime())) return null; + const first = Date.parse(mark.lastResetReadAt ?? ""); + const read = Date.parse(card.readAt ?? ""); + if (!Number.isFinite(first) || !Number.isFinite(read) || read - first >= ROLL_GAP_MS) return null; + return first + ROLL_GAP_MS; + } + /** * 该重读的订阅卡让 magpie 现读一次,返回是否发起过。kept 缓存只在 magpie 路由请求那一刻 * 更新(无托盘的 magpie web 平时不读卡),两种时机需要新读数: @@ -649,7 +716,9 @@ export class Scheduler { // readAt 缺失时是 NaN,「足够新」比较为假,照样算需要重读 const readFresh = now.getTime() - readAt <= CARD_FRESH_MS; const dueNow = due !== null && this.windowReady(task, due, now) && !readFresh; - if (!afterSend && !dueNow) continue; + const probeAt = this.rollProbeAt(task, now); + const probe = probeAt !== null && now.getTime() >= probeAt; + if (!afterSend && !dueNow && !probe) continue; const key = `${card.provider}|${card.account}`; const last = this.lastCardRefresh.get(key); if (last !== undefined && now.getTime() - last < CARD_REFRESH_GAP_MS) continue; @@ -875,6 +944,9 @@ export class Scheduler { if (task.trigger.kind === "window") { const settled = this.lastSentMs(task) + POST_SEND_SETTLE_MS; if (settled > now.getTime()) minDelay = Math.min(minDelay, settled - now.getTime()); + // 已到点的复读由下一轮 tick 的 readCards 发起,这里只为未来的复读点排唤醒(失败时按刷新节奏重试) + const probeAt = this.rollProbeAt(task, now); + if (probeAt !== null && probeAt > now.getTime()) minDelay = Math.min(minDelay, probeAt - now.getTime()); const due = this.currentWindowDue(task, now); if (due && due.at.getTime() > now.getTime()) { minDelay = Math.min(minDelay, due.at.getTime() - now.getTime()); diff --git a/packages/window-warmup/src/store.ts b/packages/window-warmup/src/store.ts index 335779240..75e46fcbd 100644 --- a/packages/window-warmup/src/store.ts +++ b/packages/window-warmup/src/store.ts @@ -32,6 +32,13 @@ export interface TaskMarks { /** 这个任务的窗口最近一次见到的重置时间(ISO):额度卡瞬断丢了 resetsAt 时, * 用它区分「窗口其实在计时」和「真空闲」;旧数据没有这个字段照常加载 */ lastReset?: string | null; + /** 首次见到 lastReset 这个值时卡片的 readAt;与下一次读数比对,分辨重置点是真在计时还是随读卡滑动 */ + lastResetReadAt?: string | null; + /** lastReset 是「读卡时刻 + 窗口长度」滑出来的空闲读数(如 Antigravity 空闲窗口) */ + resetRolls?: boolean; + /** lastReset 是旧重置点过后、插件没发过时读到的「下一轮」重置点:记下那个旧重置点(ISO), + * 用量为 0 时按空闲处理并以它作去重键;不是这种情况为 null */ + resetCarried?: string | null; /** 窗口任务最近一次真正把消息发出去的收尾时刻(ISO):此前读到的空闲是发送前的旧读数,不可信 */ lastSent?: string | null; } diff --git a/packages/window-warmup/tests/scheduler.test.ts b/packages/window-warmup/tests/scheduler.test.ts index 0d793a1cb..7b53c7fab 100644 --- a/packages/window-warmup/tests/scheduler.test.ts +++ b/packages/window-warmup/tests/scheduler.test.ts @@ -1157,3 +1157,330 @@ test("debugView:暴露定时器为固定任务排的到期点与进行中的 expect(new Date(view.armedFor[id]).toISOString()).toBe(new Date("2026-10-31T06:00:00+08:00").toISOString()); expect(h.scheduler.quotaEntries()).toBeNull(); }); + +// ---------- 空闲窗口的另一种报法(Docker 实机 2026-11-06,Antigravity):空闲时 resetsAt 不清空, +// 恒为「读卡时刻 + 5 小时」,每次重读都往后推;插件当成在计时,重置后一直不发 ---------- + +interface LiveCard { readAt: string; used: number; resetsAt: string | null } + +/** 模拟厂商侧:opened = 当前窗口的重置时刻;空闲时按 idle 规则报(滑动或缺失) */ +function vendorCard( + now: number, + live: { opened: number | null; used: number }, + idle: "roll" | "null", +): LiveCard { + if (live.opened !== null && live.opened > now) { + return { readAt: new Date(now).toISOString(), used: live.used, resetsAt: new Date(live.opened).toISOString() }; + } + // Antigravity 的 resetsAt 精确到秒 + const rolled = new Date(Math.floor((now + 5 * HOUR) / 1000) * 1000).toISOString(); + return { readAt: new Date(now).toISOString(), used: 0, resetsAt: idle === "roll" ? rolled : null }; +} + +function cardQuotas(provider: string, card: () => LiveCard): () => { entries: QuotaEntry[] } { + return () => ({ + entries: [{ + // readAt 为空串表示旧版 magpie 不给读卡时刻 + provider, account: "a@b.c", label: "A", viaKey: false, readAt: card().readAt || undefined, + windows: [{ name: "5 hours", used: card().used, resetsAt: card().resetsAt }], + }], + }); +} + +/** 每分钟推进一次假时钟直到 untilMs,模拟真实唤醒 */ +async function runUntil(h: Harness, untilMs: number): Promise { + while (h.now().getTime() < untilMs) { + h.advance(60_000); + h.fireDue(); + await h.flush(); + } +} + +test("window:空闲时 resetsAt 随读卡滑动 → 重置后几分钟内识别为空闲并发送,开窗后不重发", async () => { + const start = ms(T0); + const live = { opened: start + 2 * HOUR as number | null, used: 3 }; + let h!: Harness; + let card = vendorCard(ms(T0), live, "roll"); + const sentAt: number[] = []; + h = await harness({ + quotas: cardQuotas("antigravity", () => card), + refresh: () => { card = vendorCard(h.now().getTime(), live, "roll"); }, + sendImpl: async () => { + sentAt.push(h.now().getTime()); + live.opened = h.now().getTime() + 5 * HOUR; + live.used = 0; // 最坏情况:开窗后用量仍读作 0,只能靠「重读不变即固定」撤销滑动判定 + return { outcome: "ok", reason: "", ms: 2 }; + }, + }); + const id = (await h.scheduler.upsert({ ...zcodeWindowTask(), provider: "antigravity" })).id; + h.scheduler.start(); + await runUntil(h, start + 2 * HOUR + 15 * 60_000); + expect(sentAt).toHaveLength(1); + expect(sentAt[0]).toBeGreaterThan(start + 2 * HOUR); + expect(sentAt[0]).toBeLessThanOrEqual(start + 2 * HOUR + 5 * 60_000); + + // 新窗口里 resetsAt 固定,多次重读也不再发;下一次重置后同样能识别 + await runUntil(h, sentAt[0] + 5 * HOUR - 60_000); + expect(sentAt).toHaveLength(1); + await runUntil(h, sentAt[0] + 5 * HOUR + 10 * 60_000); + expect(sentAt).toHaveLength(2); + expect(h.store.data.marks[id].resetRolls).toBe(false); // 开窗后的读数不再判为滑动 +}); + +test("window:重置点固定、用量读数为 0 的计时窗口 → 复读确认后照常等到重置点", async () => { + const reset = ms(T0) + 3 * HOUR; + let h!: Harness; + let card: LiveCard = { readAt: new Date(ms(T0)).toISOString(), used: 0, resetsAt: new Date(reset).toISOString() }; + h = await harness({ + quotas: cardQuotas("zcode", () => card), + refresh: () => { card = { ...card, readAt: h.now().toISOString() }; }, + }); + const id = (await h.scheduler.upsert(zcodeWindowTask())).id; + h.scheduler.start(); + await runUntil(h, reset - 10 * 60_000); + expect(h.sendCalls).toHaveLength(0); + expect(h.refreshCalls.length).toBeLessThanOrEqual(1); // 只为确认补读一次,不反复重读 + expect(h.scheduler.snapshot().next[id]).toBe(new Date(reset + 30_000).toISOString()); +}); + +test("window:resetsAt 与读卡同步后挪但用量非 0(按最后使用滑动的计时窗口)→ 不当空闲", async () => { + let h!: Harness; + const rolling = (): LiveCard => ({ + readAt: h.now().toISOString(), used: 12, + resetsAt: new Date(Math.floor((h.now().getTime() + 5 * HOUR) / 1000) * 1000).toISOString(), + }); + let card: LiveCard = { readAt: new Date(ms(T0)).toISOString(), used: 12, resetsAt: new Date(ms(T0) + 5 * HOUR).toISOString() }; + h = await harness({ + quotas: cardQuotas("zcode", () => card), + refresh: () => { card = rolling(); }, + }); + await h.scheduler.upsert(zcodeWindowTask()); + h.scheduler.start(); + for (let i = 0; i < 12; i++) { // 每 10 分钟有别的请求让 magpie 重读一次 + await runUntil(h, h.now().getTime() + 10 * 60_000); + card = rolling(); + } + expect(h.sendCalls).toHaveLength(0); + expect(h.refreshCalls).toHaveLength(0); // 用量非 0 不可能是滑动空闲,不为判定多读卡 +}); + +test("window:开窗后用量仍读作 0、每窗都在发送 1 分钟后读卡 → 不误判滑动,每窗只发一次", async () => { + // 上一窗的重置点与发送后读数的间隔,恰好等于下一窗的;靠「旧重置点已过」排除 + const live = { opened: null as number | null, used: 0 }; + let h!: Harness; + let card = vendorCard(ms(T0), live, "null"); + const sentAt: number[] = []; + h = await harness({ + quotas: cardQuotas("zcode", () => card), + refresh: () => { card = vendorCard(h.now().getTime(), live, "null"); }, + sendImpl: async () => { + sentAt.push(h.now().getTime()); + live.opened = h.now().getTime() + 5 * HOUR; + return { outcome: "ok", reason: "", ms: 2 }; + }, + }); + await h.scheduler.upsert(zcodeWindowTask()); + h.scheduler.start(); + await runUntil(h, ms(T0) + 16 * HOUR); + expect(sentAt.length).toBe(4); + for (let i = 1; i < sentAt.length; i++) expect(sentAt[i] - sentAt[i - 1]).toBeGreaterThanOrEqual(5 * HOUR); +}); + +/** 空闲时按「读卡时刻 + 5 小时 + drift」报 resetsAt 的卡;readAt 为 false 时模拟旧版 magpie */ +function rollingIdle(now: number, opts: { readAt?: boolean; drift?: number } = {}): LiveCard { + return { + readAt: opts.readAt === false ? "" : new Date(now).toISOString(), + used: 0, + resetsAt: new Date(Math.floor((now + 5 * HOUR + (opts.drift ?? 0)) / 1000) * 1000).toISOString(), + }; +} + +test("window:旧版 magpie 不给 readAt → 无从判定滑动,保持原行为(不发、不为判定多读卡)", async () => { + let h!: Harness; + let card = rollingIdle(ms(T0), { readAt: false }); + h = await harness({ + quotas: cardQuotas("antigravity", () => card), + refresh: () => { card = rollingIdle(h.now().getTime(), { readAt: false }); }, + }); + const id = (await h.scheduler.upsert({ ...zcodeWindowTask(), provider: "antigravity" })).id; + h.scheduler.start(); + await runUntil(h, ms(T0) + 2 * HOUR); + expect(h.sendCalls).toHaveLength(0); + expect(h.refreshCalls).toHaveLength(0); + expect(h.store.data.marks[id].resetRolls).toBeFalsy(); +}); + +test("window:重置点后挪量与读卡间隔相差超过 5 秒 → 不判滑动", async () => { + let h!: Harness; + let reads = 0; + // 每次重读额外多挪 10 秒:不是「读卡时刻 + 固定窗口长度」 + let card = rollingIdle(ms(T0)); + h = await harness({ + quotas: cardQuotas("antigravity", () => card), + refresh: () => { reads += 1; card = rollingIdle(h.now().getTime(), { drift: reads * 10_000 }); }, + }); + const id = (await h.scheduler.upsert({ ...zcodeWindowTask(), provider: "antigravity" })).id; + h.scheduler.start(); + await runUntil(h, ms(T0) + HOUR); + expect(reads).toBeGreaterThan(0); + expect(h.sendCalls).toHaveLength(0); + expect(h.store.data.marks[id].resetRolls).toBe(false); +}); + +test("window:复读拿不到新读数(readAt 不变)→ 不发,按刷新节奏重试而非每分钟刷卡", async () => { + const card = rollingIdle(ms(T0)); + const h = await harness({ quotas: cardQuotas("antigravity", () => card) }); // refresh 空操作 + await h.scheduler.upsert({ ...zcodeWindowTask(), provider: "antigravity" }); + h.scheduler.start(); + await runUntil(h, ms(T0) + HOUR); + expect(h.sendCalls).toHaveLength(0); + expect(h.refreshCalls.length).toBeGreaterThan(0); + expect(h.refreshCalls.length).toBeLessThanOrEqual(7); // 首次复读 + 之后约每 10 分钟一次 +}); + +test("window:0.1.3 升级上来的 marks 只有 lastReset、没有读卡时刻 → 补记后照样识别滑动", async () => { + let h!: Harness; + let card = rollingIdle(ms(T0)); + h = await harness({ + quotas: cardQuotas("antigravity", () => card), + refresh: () => { card = rollingIdle(h.now().getTime()); }, + }); + const id = (await h.scheduler.upsert({ ...zcodeWindowTask(), provider: "antigravity" })).id; + // 旧版写下的 marks:同一个滑动值,没有 lastResetReadAt / resetRolls;之后没人再读卡 + h.store.data.marks[id] = { lastDue: null, resetKeys: [], lastReset: card.resetsAt }; + h.scheduler.start(); + await runUntil(h, ms(T0) + 5 * 60_000); + expect(h.sendCalls).toHaveLength(1); +}); + +test("window:已判为滑动空闲,但读数早于「上次发送 + 1 分钟」→ 不发,等发送后的读数", async () => { + // 发送刚结束(如进程在此时重启):缓存里还是发送前的滑动空闲读数,跨整点也不能当新空闲再发 + const before = ms(T0) - 50_000; // 读数仍在 1 分钟新鲜期内,「到期先重读」不会替门槛兜底 + const sent = ms(T0) - 30_000; + let h!: Harness; + let card = rollingIdle(before); + h = await harness({ + quotas: cardQuotas("antigravity", () => card), + // 厂商侧:那次发送已开窗,重读拿到固定重置点 + refresh: () => { card = { readAt: h.now().toISOString(), used: 0, resetsAt: new Date(sent + 5 * HOUR).toISOString() }; }, + }); + const id = (await h.scheduler.upsert({ ...zcodeWindowTask(), provider: "antigravity" })).id; + h.store.data.marks[id] = { + lastDue: null, resetKeys: [], lastReset: card.resetsAt, + lastResetReadAt: new Date(before).toISOString(), resetRolls: true, + lastSent: new Date(sent).toISOString(), + }; + h.scheduler.start(); + h.fireDue(); // 启动当刻读数还新鲜,就在这一轮判断 + await h.flush(); + expect(h.sendCalls).toHaveLength(0); + await runUntil(h, ms(T0) + HOUR); + expect(h.sendCalls).toHaveLength(0); + expect(h.store.data.marks[id].resetRolls).toBe(false); +}); + +// ---------- 空闲窗口的第三种报法(Docker 实机 2026-11-06 12:11,Antigravity):旧窗口到期后不报空闲, +// 而是直接报「旧重置点 + 5 小时」并保持不动,看着像在计时;之前要等约 1 小时它改成滑动形态才发 ---------- + +/** 厂商侧:opened = 当前窗口重置时刻;到期后报 opened + 5h 并固定(carried),直到有人发送开窗 */ +function carriedVendor( + h: () => Harness, + init: { opened: number; used?: number }, + onSend: "open" | "keep" = "open", +) { + const live = { opened: init.opened, carried: false, used: init.used ?? 3 }; + const read = (): LiveCard => { + const now = h().now().getTime(); + if (live.opened <= now) { live.opened += 5 * HOUR; live.carried = true; live.used = 0; } + return { readAt: new Date(now).toISOString(), used: live.used, resetsAt: new Date(live.opened).toISOString() }; + }; + const send = () => { + if (onSend === "open" || !live.carried) { + live.opened = Math.floor((h().now().getTime() + 5 * HOUR) / 1000) * 1000; + live.used = 0.0067; + } + live.carried = false; + }; + return { read, send }; +} + +async function carriedHarness(init: { opened: number; used?: number }, onSend: "open" | "keep" = "open") { + let h!: Harness; + const vendor = carriedVendor(() => h, init, onSend); + let card: LiveCard = { readAt: new Date(ms(T0)).toISOString(), used: init.used ?? 3, resetsAt: new Date(init.opened).toISOString() }; + const sentAt: number[] = []; + h = await harness({ + quotas: cardQuotas("antigravity", () => card), + refresh: () => { card = vendor.read(); }, + sendImpl: async () => { + sentAt.push(h.now().getTime()); + vendor.send(); + return { outcome: "ok", reason: "", ms: 2 }; + }, + }); + const id = (await h.scheduler.upsert({ ...zcodeWindowTask(), provider: "antigravity" })).id; + h.scheduler.start(); + return { h, id, sentAt, setCard: (c: LiveCard) => { card = c; } }; +} + +test("window:旧重置点过后卡报「旧重置点 + 5h」并固定 → 重置后几分钟内当空闲发送,开窗后不重发", async () => { + const reset = ms(T0) + 2 * HOUR; + const { h, sentAt } = await carriedHarness({ opened: reset }); + await runUntil(h, reset + 15 * 60_000); + expect(sentAt).toHaveLength(1); + expect(sentAt[0]).toBeGreaterThan(reset); + expect(sentAt[0]).toBeLessThanOrEqual(reset + 3 * 60_000); + // 发送开窗(send + 5h):这一窗不再发;到期后厂商又报预报值,下一轮照样及时发 + await runUntil(h, sentAt[0] + 5 * HOUR - 60_000); + expect(sentAt).toHaveLength(1); + await runUntil(h, sentAt[0] + 5 * HOUR + 10 * 60_000); + expect(sentAt).toHaveLength(2); + expect(sentAt[1] - sentAt[0]).toBeLessThanOrEqual(5 * HOUR + 3 * 60_000); +}); + +test("window:预报值在发送后也不变(厂商其实自动开了下一窗)→ 每轮只多发这一次", async () => { + const reset = ms(T0) + 2 * HOUR; + const { h, sentAt } = await carriedHarness({ opened: reset }, "keep"); + await runUntil(h, reset + 5 * HOUR - 60_000); + expect(sentAt).toHaveLength(1); +}); + +test("window:旧重置点过后新重置点用量非 0(有人用过、真开了窗)→ 不当空闲", async () => { + const reset = ms(T0) + 2 * HOUR; + const { h, sentAt, setCard } = await carriedHarness({ opened: reset }); + await runUntil(h, reset - 60_000); + // 用户在别处刚用过:新窗口 reset + 4h50m,用量 2 + const real = { readAt: "", used: 2, resetsAt: new Date(reset + 5 * HOUR - 10 * 60_000).toISOString() }; + h.advance(2 * 60_000); + setCard({ ...real, readAt: h.now().toISOString() }); + await runUntil(h, reset + HOUR); + expect(sentAt).toHaveLength(0); +}); + +test("window:插件在旧重置点之后发过 → 新重置点不按预报值处理", async () => { + const reset = ms(T0) - 10 * 60_000; + const { h, id, sentAt, setCard } = await carriedHarness({ opened: reset + 6 * HOUR }); + // 旧窗口 reset 已过、插件在其后发过(lastSent > reset),卡上随即出现新重置点、用量读作 0 + h.store.data.marks[id] = { + lastDue: null, resetKeys: [], lastReset: new Date(reset).toISOString(), + lastResetReadAt: new Date(reset - HOUR).toISOString(), lastSent: new Date(reset + 2 * 60_000).toISOString(), + }; + setCard({ readAt: h.now().toISOString(), used: 0, resetsAt: new Date(reset + 5 * HOUR).toISOString() }); + await runUntil(h, ms(T0) + HOUR); + expect(sentAt).toHaveLength(0); + expect(h.store.data.marks[id].resetCarried).toBeNull(); +}); + +test("window:旧版 magpie 不给 readAt → 预报值无从判定,保持原行为", async () => { + const reset = ms(T0) + 2 * HOUR; + let h!: Harness; + let card: LiveCard = { readAt: "", used: 3, resetsAt: new Date(reset).toISOString() }; + h = await harness({ + quotas: cardQuotas("antigravity", () => card), + refresh: () => { card = { readAt: "", used: 0, resetsAt: new Date(reset + 5 * HOUR).toISOString() }; }, + }); + await h.scheduler.upsert({ ...zcodeWindowTask(), provider: "antigravity" }); + h.scheduler.start(); + await runUntil(h, reset + HOUR); + expect(h.sendCalls).toHaveLength(0); +}); From 108d4752539b12e18f52aec7e320cf069b159792 Mon Sep 17 00:00:00 2001 From: yanghonghang Date: Mon, 9 Nov 2026 11:31:38 +0800 Subject: [PATCH 3/3] =?UTF-8?q?[test]=20=E7=AA=97=E5=8F=A3=E9=A2=84?= =?UTF-8?q?=E7=83=AD=EF=BC=9A=E8=A1=A5=E9=BD=90=E8=AF=84=E5=AE=A1=E6=8C=87?= =?UTF-8?q?=E5=87=BA=E7=9A=84=E7=A9=BA=E9=97=B2=E5=88=A4=E5=AE=9A=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=EF=BC=8C=E5=A4=8D=E8=AF=BB=E9=99=90=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=EF=BC=8C=E5=8F=A3=E4=BB=A4=E9=87=8D=E8=BD=BD=E9=98=B2=E8=B7=A8?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E5=BE=AA=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 补用例:两次读数间隔不足 60 秒不判滑动、重读不变撤销滑动判定、首读满 60 秒唤醒复读 - 每个新重置点最多复读一次,读数不动的缓存卡不再每轮重读 - 口令失效重载时在地址上留标记,重载后仍被拒不再重载;沙箱框架里 sessionStorage 不可用 --- packages/window-warmup/dist/plugin.js | 6 ++ packages/window-warmup/dist/ui/app.js | 28 ++++++--- packages/window-warmup/src/scheduler.ts | 8 ++- packages/window-warmup/tests/app.test.ts | 29 ++++++--- .../window-warmup/tests/scheduler.test.ts | 63 ++++++++++++++++++- packages/window-warmup/web/app.ts | 44 +++++++++---- 6 files changed, 146 insertions(+), 32 deletions(-) diff --git a/packages/window-warmup/dist/plugin.js b/packages/window-warmup/dist/plugin.js index 057afeab2..a91519d56 100644 --- a/packages/window-warmup/dist/plugin.js +++ b/packages/window-warmup/dist/plugin.js @@ -1224,6 +1224,7 @@ class Scheduler { saveError = null; lastErrKey = new Map; lastCardRefresh = new Map; + probedFor = new Map; constructor(deps) { this.store = deps.store; this.base = deps.base; @@ -1316,6 +1317,7 @@ class Scheduler { this.store.data.runs = this.store.data.runs.filter((r) => r.taskId !== id); delete this.store.data.marks[id]; this.lastErrKey.delete(id); + this.probedFor.delete(id); await this.store.save(); this.rearm(); }); @@ -1609,6 +1611,8 @@ class Scheduler { return null; if (mark.resetRolls || win.resetsAt !== mark.lastReset) return null; + if (this.probedFor.get(task.id) === win.resetsAt) + return null; if (!(Date.parse(win.resetsAt) > now.getTime())) return null; const first = Date.parse(mark.lastResetReadAt ?? ""); @@ -1640,6 +1644,8 @@ class Scheduler { if (last !== undefined && now.getTime() - last < CARD_REFRESH_GAP_MS) continue; this.lastCardRefresh.set(key, now.getTime()); + if (probe) + this.probedFor.set(task.id, this.quotaWindow(task).resetsAt); asked = true; await this.refreshCardFn(this.base, card.provider, card.account).catch(() => {}); } diff --git a/packages/window-warmup/dist/ui/app.js b/packages/window-warmup/dist/ui/app.js index ad0d57e01..3e58d0d8b 100644 --- a/packages/window-warmup/dist/ui/app.js +++ b/packages/window-warmup/dist/ui/app.js @@ -956,9 +956,17 @@ var formError = null; var confirmDeleteAt = 0; var runsExpanded = false; var catalogTimer; -var reloadedForToken = false; -function shouldReloadOnAuthError(status, data, isDirty) { - if (reloadedForToken) +var REAUTH_PARAM = "ww-reauth"; +function reauthURL(href, on) { + const url = new URL(href); + if (on) + url.searchParams.set(REAUTH_PARAM, "1"); + else + url.searchParams.delete(REAUTH_PARAM); + return url.toString(); +} +function shouldReloadOnAuthError(status, data, isDirty, alreadyReloaded) { + if (alreadyReloaded) return false; if (status !== 401 && status !== 403) return false; @@ -975,12 +983,15 @@ async function apiJSON(path, init = {}) { } }); const body = await res.json().catch(() => ({})); + const reloaded = new URL(location.href).searchParams.has(REAUTH_PARAM); + if (res.ok && reloaded) { + try { + history.replaceState(history.state, "", reauthURL(location.href, false)); + } catch {} + } if (!res.ok) { - if (shouldReloadOnAuthError(res.status, body, dirty)) { - reloadedForToken = true; - try { - location.reload(); - } catch {} + if (shouldReloadOnAuthError(res.status, body, dirty, reloaded)) { + location.replace(reauthURL(location.href, true)); } const msg = body.error; throw new Error(typeof msg === "string" && msg ? msg : `HTTP ${res.status}`); @@ -2085,6 +2096,7 @@ export { nextLabel, nextPointsAfter, normalizeLang, + reauthURL, rowSub, runOutcome, runReason, diff --git a/packages/window-warmup/src/scheduler.ts b/packages/window-warmup/src/scheduler.ts index 760ff6259..0f0182a13 100644 --- a/packages/window-warmup/src/scheduler.ts +++ b/packages/window-warmup/src/scheduler.ts @@ -185,6 +185,8 @@ export class Scheduler { private lastErrKey = new Map(); /** 「供应商|账号」→ 上次请求重读该卡的时刻 */ private lastCardRefresh = new Map(); + /** taskId → 已为之复读过的 resetsAt:每个新重置点只复读一次,读数不动的缓存卡不会每轮都刷 */ + private probedFor = new Map(); constructor(deps: SchedulerDeps) { this.store = deps.store; @@ -286,6 +288,7 @@ export class Scheduler { this.store.data.runs = this.store.data.runs.filter((r) => r.taskId !== id); delete this.store.data.marks[id]; this.lastErrKey.delete(id); + this.probedFor.delete(id); await this.store.save(); this.rearm(); }); @@ -681,7 +684,7 @@ export class Scheduler { /** * 新出现的未来 resetsAt 只有一次读数时,要在首读满 ROLL_GAP_MS 后再读一次,才分得清它是真在计时 * 还是随读卡滑动(否则滑动的空闲窗口永远排在「5 小时后」)。返回该重读的时刻;不需要为 null。 - * 用量非 0 不可能判为滑动,不必重读 + * 用量非 0 不可能判为滑动,不必重读;这个重置点复读过一次就不再复读 */ private rollProbeAt(task: Task, now: Date): number | null { const mark = this.store.data.marks[task.id]; @@ -689,6 +692,7 @@ export class Scheduler { const win = this.quotaWindow(task); if (!mark || !card || !win?.resetsAt || win.used !== 0) return null; if (mark.resetRolls || win.resetsAt !== mark.lastReset) return null; + if (this.probedFor.get(task.id) === win.resetsAt) return null; if (!(Date.parse(win.resetsAt) > now.getTime())) return null; const first = Date.parse(mark.lastResetReadAt ?? ""); const read = Date.parse(card.readAt ?? ""); @@ -723,6 +727,8 @@ export class Scheduler { const last = this.lastCardRefresh.get(key); if (last !== undefined && now.getTime() - last < CARD_REFRESH_GAP_MS) continue; this.lastCardRefresh.set(key, now.getTime()); + // probe 成立时 resetsAt 必定存在(rollProbeAt 的前提);重读失败也算用掉,等新重置点 + if (probe) this.probedFor.set(task.id, this.quotaWindow(task)!.resetsAt!); asked = true; await this.refreshCardFn(this.base, card.provider, card.account).catch(() => {}); } diff --git a/packages/window-warmup/tests/app.test.ts b/packages/window-warmup/tests/app.test.ts index 9ec74e331..662af816b 100644 --- a/packages/window-warmup/tests/app.test.ts +++ b/packages/window-warmup/tests/app.test.ts @@ -25,6 +25,7 @@ import { shortModel, statusOf, shouldCloseOnScroll, + reauthURL, shouldReloadOnAuthError, sumView, syncDraftOnPoll, @@ -531,17 +532,29 @@ test("反馈问题:诊断过大时改用紧凑 JSON,仍超 GitHub 上限就 test("shouldReloadOnAuthError:口令失效时若无未保存草稿触发自动刷新,有草稿或非鉴权错不刷", () => { // 401 + reload: true,无未保存草稿 → 刷新 - expect(shouldReloadOnAuthError(401, { error: "口令不对:请重新打开窗口预热页", reload: true }, false)).toBe(true); + expect(shouldReloadOnAuthError(401, { error: "口令不对:请重新打开窗口预热页", reload: true }, false, false)).toBe(true); // 401 文本含「口令」即使缺 reload: true 也识别 - expect(shouldReloadOnAuthError(401, { error: "口令不对" }, false)).toBe(true); + expect(shouldReloadOnAuthError(401, { error: "口令不对" }, false, false)).toBe(true); // 用户正在编辑未保存的草稿(isDirty = true)→ 绝不冲掉草稿 - expect(shouldReloadOnAuthError(401, { error: "口令不对", reload: true }, true)).toBe(false); + expect(shouldReloadOnAuthError(401, { error: "口令不对", reload: true }, true, false)).toBe(false); // 非鉴权错误(400/500/200)→ 不刷 - expect(shouldReloadOnAuthError(400, { error: "参数错误" }, false)).toBe(false); - expect(shouldReloadOnAuthError(500, { error: "服务器错误" }, false)).toBe(false); + expect(shouldReloadOnAuthError(400, { error: "参数错误" }, false, false)).toBe(false); + expect(shouldReloadOnAuthError(500, { error: "服务器错误" }, false, false)).toBe(false); // 异常数据兜底 - expect(shouldReloadOnAuthError(401, null, false)).toBe(false); - expect(shouldReloadOnAuthError(401, undefined, false)).toBe(false); - expect(shouldReloadOnAuthError(401, "string-error", false)).toBe(false); + expect(shouldReloadOnAuthError(401, null, false, false)).toBe(false); + expect(shouldReloadOnAuthError(401, undefined, false, false)).toBe(false); + expect(shouldReloadOnAuthError(401, "string-error", false, false)).toBe(false); + // 已为口令重载过一次(地址带标记):不再重载,防跨加载死循环 + expect(shouldReloadOnAuthError(401, { error: "口令不对", reload: true }, false, true)).toBe(false); +}); + +test("reauthURL:加上/去掉重载标记,保留主题与语言等其余参数", () => { + const base = "http://127.0.0.1:3492/?theme=dark&lang=zh"; + const on = reauthURL(base, true); + expect(new URL(on).searchParams.get("ww-reauth")).toBe("1"); + expect(new URL(on).searchParams.get("theme")).toBe("dark"); + expect(new URL(on).searchParams.get("lang")).toBe("zh"); + expect(reauthURL(on, false)).toBe(base); + expect(reauthURL("http://h/p/?a=1", false)).toBe("http://h/p/?a=1"); }); diff --git a/packages/window-warmup/tests/scheduler.test.ts b/packages/window-warmup/tests/scheduler.test.ts index 7b53c7fab..a4303a19e 100644 --- a/packages/window-warmup/tests/scheduler.test.ts +++ b/packages/window-warmup/tests/scheduler.test.ts @@ -1327,15 +1327,14 @@ test("window:重置点后挪量与读卡间隔相差超过 5 秒 → 不判滑 expect(h.store.data.marks[id].resetRolls).toBe(false); }); -test("window:复读拿不到新读数(readAt 不变)→ 不发,按刷新节奏重试而非每分钟刷卡", async () => { +test("window:复读拿不到新读数(readAt 不变的缓存卡)→ 不发,同一个重置点只复读一次", async () => { const card = rollingIdle(ms(T0)); const h = await harness({ quotas: cardQuotas("antigravity", () => card) }); // refresh 空操作 await h.scheduler.upsert({ ...zcodeWindowTask(), provider: "antigravity" }); h.scheduler.start(); await runUntil(h, ms(T0) + HOUR); expect(h.sendCalls).toHaveLength(0); - expect(h.refreshCalls.length).toBeGreaterThan(0); - expect(h.refreshCalls.length).toBeLessThanOrEqual(7); // 首次复读 + 之后约每 10 分钟一次 + expect(h.refreshCalls).toHaveLength(1); }); test("window:0.1.3 升级上来的 marks 只有 lastReset、没有读卡时刻 → 补记后照样识别滑动", async () => { @@ -1484,3 +1483,61 @@ test("window:旧版 magpie 不给 readAt → 预报值无从判定,保持原 await runUntil(h, reset + HOUR); expect(h.sendCalls).toHaveLength(0); }); + +// ---------- PR #878 评审补测:两次读数间隔门槛、撤销滑动判定、复读唤醒 ---------- + +test("window:两次读数只隔 5 秒、重置点也后挪 5 秒 → 间隔不够不判滑动,不发", async () => { + // 真在计时的窗口,卡片读数抖动几秒也会让「挪动量 ≈ 读卡间隔」凑巧成立,60 秒门槛挡的就是它 + const reset = ms(T0) + 3 * HOUR; + const card: LiveCard = { readAt: new Date(ms(T0)).toISOString(), used: 0, resetsAt: new Date(reset + 5_000).toISOString() }; + const h = await harness({ quotas: cardQuotas("antigravity", () => card) }); // refresh 空操作 + const id = (await h.scheduler.upsert({ ...zcodeWindowTask(), provider: "antigravity" })).id; + h.store.data.marks[id] = { + lastDue: null, resetKeys: [], lastReset: new Date(reset).toISOString(), + lastResetReadAt: new Date(ms(T0) - 5_000).toISOString(), + }; + h.scheduler.start(); + h.fireDue(); + await h.flush(); + expect(h.store.data.marks[id].resetRolls).toBe(false); + await runUntil(h, ms(T0) + 30 * 60_000); + expect(h.sendCalls).toHaveLength(0); +}); + +test("window:已判滑动,发送与最后一次滑动读数同秒、开窗后重置点恰好不变 → 重读确认后撤销,整点后不再发", async () => { + const sent = ms(T0) - 90_000; + const reset = new Date(sent + 5 * HOUR).toISOString(); // 最后一次滑动读数 = 开窗后的固定重置点 + let h!: Harness; + let card: LiveCard = { readAt: new Date(ms(T0)).toISOString(), used: 0, resetsAt: reset }; + h = await harness({ + quotas: cardQuotas("antigravity", () => card), + refresh: () => { card = { ...card, readAt: h.now().toISOString() }; }, + }); + const id = (await h.scheduler.upsert({ ...zcodeWindowTask(), provider: "antigravity" })).id; + h.store.data.marks[id] = { + lastDue: null, resetKeys: [], lastReset: reset, lastResetReadAt: new Date(sent).toISOString(), + resetRolls: true, lastSent: new Date(sent).toISOString(), + }; + h.scheduler.start(); + await runUntil(h, ms(T0) + 2 * HOUR); // 跨两个整点 + expect(h.store.data.marks[id].resetRolls).toBe(false); + expect(h.sendCalls).toHaveLength(0); +}); + +test("window:新出现的未来重置点 → 首读满 60 秒就醒来复读,不等 10 分钟的常规刷新", async () => { + let h!: Harness; + let card = rollingIdle(ms(T0)); + h = await harness({ + quotas: cardQuotas("antigravity", () => card), + refresh: () => { card = { ...card, readAt: h.now().toISOString() }; }, // 固定重置点,只看复读时机 + }); + await h.scheduler.upsert({ ...zcodeWindowTask(), provider: "antigravity" }); + h.scheduler.start(); + h.fireDue(); + await h.flush(); + expect(h.refreshCalls).toHaveLength(0); + h.advance(60_000); + h.fireDue(); + await h.flush(); + expect(h.refreshCalls).toHaveLength(1); +}); diff --git a/packages/window-warmup/web/app.ts b/packages/window-warmup/web/app.ts index de88c7d92..2f1010aa8 100644 --- a/packages/window-warmup/web/app.ts +++ b/packages/window-warmup/web/app.ts @@ -518,11 +518,28 @@ let confirmDeleteAt = 0; let runsExpanded = false; let catalogTimer: ReturnType | undefined; -/** 服务重启导致口令失效时重新载入一次页面拿新口令(只试一次,防死循环) */ -let reloadedForToken = false; - -export function shouldReloadOnAuthError(status: number, data: unknown, isDirty: boolean): boolean { - if (reloadedForToken) return false; +/** + * 口令失效重载时在地址上留的标记:重载后仍被拒就不再重载,防跨加载的死循环;请求成功后去掉。 + * 不用 sessionStorage:插件框架只有 allow-scripts(不透明源),访问 storage 会抛错 + */ +const REAUTH_PARAM = "ww-reauth"; + +/** 给地址加上(on)或去掉重载标记,其余参数(主题、语言等)原样保留 */ +export function reauthURL(href: string, on: boolean): string { + const url = new URL(href); + if (on) url.searchParams.set(REAUTH_PARAM, "1"); + else url.searchParams.delete(REAUTH_PARAM); + return url.toString(); +} + +/** alreadyReloaded:本页地址已带重载标记,即刚为口令重载过一次 */ +export function shouldReloadOnAuthError( + status: number, + data: unknown, + isDirty: boolean, + alreadyReloaded: boolean, +): boolean { + if (alreadyReloaded) return false; if (status !== 401 && status !== 403) return false; const isTokenErr = (typeof data === "object" && data !== null && (data as { reload?: boolean }).reload === true) || @@ -542,14 +559,17 @@ async function apiJSON(path: string, init: RequestInit = {}): Promise { }, }); const body: unknown = await res.json().catch(() => ({})); + const reloaded = new URL(location.href).searchParams.has(REAUTH_PARAM); + if (res.ok && reloaded) { + try { + history.replaceState(history.state, "", reauthURL(location.href, false)); + } catch { + // 不透明源(沙箱框架)里 Chromium 不许改地址:标记留着,之后再失效只显示错误、不再自动重载 + } + } if (!res.ok) { - if (shouldReloadOnAuthError(res.status, body, dirty)) { - reloadedForToken = true; - try { - location.reload(); - } catch { - /* 无法刷新则继续抛出错误显示在横幅中 */ - } + if (shouldReloadOnAuthError(res.status, body, dirty, reloaded)) { + location.replace(reauthURL(location.href, true)); } const msg = (body as { error?: unknown }).error; throw new Error(typeof msg === "string" && msg ? msg : `HTTP ${res.status}`);