Files
ModelRouter/internal/lua/schedule_first_due_test.go
JianFeeeee 10ad4421f9 feat(channels): 自动领取得以真正跑起来 + 自动获取可用模型
用户要求两件事:① 自动签到与领取 ② 自动获取可用模型。
两者都有调度缺口,且能力表与参考真值不符(表错了只静默少领,无任何报错)。

## ① 自动签到的能力表(三处偏差,全部修正)

| 平台 | 我们 | 参考 | 后果 / 修法 |
|---|---|---|---|
| buddy | claim=true | 无 | **自相矛盾**:表说支持、claim_daily 说不支持(既无 checkin 也无 claim_fn)→ 改为只报余额 |
| raccoon | 空表 | 有 | 白丢一个可领渠道 → 补 claimRaccoon(POST /api/web/desktop/v1/login/points/grant,幂等判据是 `granted`,重复领取同返 200 但 granted=false) |
| zcode | claim=true 无实现 | auto-exclude | 补 claimZcode(3 步:event/report → preview → claim/plan),并照参考 isAutoCheckinExcluded 标记 auto_exclude |

**同为"表说支持、函数不支持"的还有 loomy** —— 补 claimLoomy
(POST /points/first-login;语义是**触发每日额度重置**而非 +5000 积分;
幂等判据 `alreadyProcessed` → already-claimed 而非 claimed)。

一条一致性判据抓出全部四例:声称 claim=true 的平台必须真的可领
(有 checkin 表或有 claim_fn)。

## ② 两个调度任务(此前只有每日领取一个)

参考有三个调度器,我们只有一个;模型获取**根本没有调度**:
- refresh_models:每 2 小时拉远端目录(照参考 MODEL_REFRESH_INTERVAL_MS),
  成功后重同步该平台所有源(模型清单写在源里)。失败**不清缓存**。
- refresh_creds:每 30 分钟检查续期(照参考 REFRESH_INTERVAL_MS)。
  ⚠️ **不看 enabled**(照参考铁律:停用只影响选号,不该停续期);
  ⚠️ **只对距过期不足 1 小时的账号发请求**(照参考 expiry-sync),
  否则启动时会打出几十个无谓请求。

## ③ 调度器新增 first_due_now(内核)

照参考的事后复盘:只用 interval 时,宿主重启后短寿命凭据(cline 1h /
codearts ~2h / raccoon 3h)要等满一个周期才续期,期间一直显示「已过期」、
积分行 401。故声明式加 first_due_now —— 任务在启动后 5s 先补跑一轮。

**且必须 opt-in**:真正周期性的任务(每日签到)不该因重启就重跑。
判据同时断言"auto_claim 不得带该标记"。

## 实测(生产)

- 模型刷新:buddy 真拉到 **22 个远端模型**(含兜底表没有的 hy4-preview、
  step-5-preview、codewise-* 等);zcode 2 个;其余报"无可用凭据"(登录未完成)
- 推理链路:`POST /v1/chat/completions` 返回 "OK"

## 宿主排障(顺手)

VNC 端到端测试失败,查证**不是回归**(stash 改动后同样失败):宿主有
**10 小时前的孤儿 `Xvfb :91`**(PID 1074031)占着显示号,加上 126 个孤儿
SHM 段、18 个陈旧 X socket。按纪律(先 SIGTERM 再 SIGKILL)回收后测试通过。
另记一笔:`ps | awk '/[X]vfb/'` 这种写法会匹配到**执行它的 shell 自身**
(命令行里含该字样),清理必须用 /proc/<pid>/exe 精确匹配 + 保护祖先链。
2026-10-14 10:46:05 +08:00

105 lines
3.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package lua
import (
"testing"
"time"
)
// 「启动即跑一轮」判据。
//
// 为什么需要它(照参考的事后复盘):只用 interval 时,宿主重启后短寿命凭据
// (cline 1h / codearts ~2h / raccoon 3h)要等满一个周期才续期,期间一直显示
// 「已过期」、积分行 401。修复 = 声明 first_due_now 的任务在启动后短暂延迟内
// 先跑一轮。
//
// 但它**必须 opt-in**:真正周期性的任务(如每日签到)不该因为重启就重跑。
func TestScheduleFirstDueNow(t *testing.T) {
// ① 声明了 first_due_now 的任务:首轮应在 catchUpDelay 内,而不是等一个周期
now := time.Now()
catchUp := ScheduledTask{Fn: "f", Every: 7200, FirstDueNow: true}
rt := &PluginRuntime{}
d := rt.firstDue(catchUp)
if d.After(now.Add(catchUpDelay + time.Second)) {
t.Errorf("first_due_now 的任务首轮应在 %v 内,实得 %v 后",
catchUpDelay, d.Sub(now))
}
if d.Before(now) {
t.Errorf("首轮时间不该在过去:%v", d.Sub(now))
}
// ② 没声明的:必须等满一个周期(否则每日/每小时的语义会被破坏)
plain := ScheduledTask{Fn: "f", Every: 7200}
d2 := rt.firstDue(plain)
if d2.Sub(now) < 7000*time.Second {
t.Errorf("未声明 first_due_now 的任务不该提前跑:实得 %v", d2.Sub(now))
}
// ③ at 形式(每日固定时刻)不受标记影响 —— 它本来就该等到那个时刻
daily := ScheduledTask{Fn: "f", At: "08:00"}
d3 := rt.firstDue(daily)
if d3.Sub(now) > 25*time.Hour {
t.Errorf("每日任务的首次时间超出一天:%v", d3.Sub(now))
}
if d3.Sub(now) < catchUpDelay && !daily.FirstDueNow {
// 只有当 08:00 恰好在 catchUpDelay 内才可能,属正常
t.Logf("每日任务首轮临近(可能恰好在 08:00 附近):%v", d3.Sub(now))
}
// ④ 声明了 first_due_now 的每日任务:优先补跑(凭据不等时刻)
dailyCatchUp := ScheduledTask{Fn: "f", At: "08:00", FirstDueNow: true}
d4 := rt.firstDue(dailyCatchUp)
if d4.Sub(now) > catchUpDelay+time.Second {
t.Errorf("声明 first_due_now 时即便有 at 也应先补跑:%v", d4.Sub(now))
}
}
// 解析层判据:first_due_now 必须真的从插件源码读出来(写错键名会静默变成
// false —— 那就是"看起来配了、实际没补跑")。
func TestScheduleParsesFirstDueNow(t *testing.T) {
const src = `
local plugin = { name = "schedprobe", version = "1.0" }
plugin.exports = { "noop" }
function plugin.noop() end
function plugin.refresh_creds() end
function plugin.auto_claim() end
plugin.schedule = {
{ fn = "auto_claim", every = 86400, at = "08:00", jitter = 300 },
{ fn = "refresh_creds", every = 1800, first_due_now = true, jitter = 60 },
}
return plugin
`
_, ps, _ := runtimePlugins(t)
if err := ps.LoadSource("schedprobe", src); err != nil {
t.Fatal(err)
}
ps.mu.RLock()
var sched []ScheduledTask
for _, p := range ps.plugins {
if p.Info.Name == "schedprobe" {
sched = p.Schedule
}
}
ps.mu.RUnlock()
if len(sched) != 2 {
t.Fatalf("应解析出 2 个任务,实得 %d: %+v", len(sched), sched)
}
byFn := map[string]ScheduledTask{}
for _, task := range sched {
byFn[task.Fn] = task
}
if task, ok := byFn["refresh_creds"]; !ok {
t.Fatal("refresh_creds 任务未解析")
} else if !task.FirstDueNow {
t.Error("first_due_now 未被解析为 true(键名写错会静默失效)")
} else if task.Every != 1800 {
t.Errorf("every = %d,应为 1800", task.Every)
}
if task, ok := byFn["auto_claim"]; !ok {
t.Fatal("auto_claim 任务未解析")
} else if task.FirstDueNow {
t.Error("auto_claim 未声明 first_due_now,应为 false(每日任务不该因重启重跑)")
} else if task.At != "08:00" {
t.Errorf("at = %q,应为 08:00", task.At)
}
}