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 精确匹配 + 保护祖先链。
This commit is contained in:
JianFeeeee
2026-10-14 10:46:05 +08:00
parent f55d514e85
commit 10ad4421f9
4 changed files with 695 additions and 5 deletions

View File

@ -55,6 +55,18 @@ type ScheduledTask struct {
// Jitter, in seconds, spreads the first fire randomly so many plugins (or
// many replicas) do not all hit the same upstream at :00.
Jitter int `json:"jitter,omitempty"`
// FirstDueNow makes the FIRST run happen shortly after boot instead of after
// a full period.
//
// Why it exists (measured defect, borrowed from the reference implementation's
// post-mortem): with interval-only scheduling a restart leaves short-lived
// credentials expired for up to a whole period. The reference's providers
// have cline at 1h, codearts at ~2h and raccoon at 3h, so a 30-minute
// refresh interval left them showing "已过期" and their quota rows returning
// 401 after every host restart. A startup catch-up round fixes that — but it
// must be opt-in, because a task that is genuinely periodic (a daily
// check-in, say) should NOT fire again just because the host restarted.
FirstDueNow bool `json:"first_due_now,omitempty"`
// Timeout is parsed for forward compatibility but is NOT enforced: a running
// Lua call cannot be interrupted in this LuaJIT build (see the capability
// note on checkWedged). Overrun is handled by disarming the plugin at the
@ -106,6 +118,7 @@ func parseSchedule(L *golua.State) scheduleSpec {
t.At = readString(L, -1, "at")
t.Jitter = readNumberInt(L, -1, "jitter")
t.Timeout = readNumberInt(L, -1, "timeout")
t.FirstDueNow = readBoolField(L, -1, "first_due_now")
if t.Fn != "" && (t.Every > 0 || t.At != "") {
spec.tasks = append(spec.tasks, t)
}
@ -195,6 +208,12 @@ func (ps *Plugins) startRuntime(p *Plugin, spec scheduleSpec) *PluginRuntime {
// plugins do not all wake at process start.
func (rt *PluginRuntime) firstDue(t ScheduledTask) time.Time {
now := time.Now()
// A catch-up run: fire shortly after boot so a restart does not leave
// short-lived credentials expired for a whole period (see FirstDueNow).
// The short delay lets the rest of the plugin finish booting first.
if t.FirstDueNow {
return now.Add(catchUpDelay)
}
if t.At != "" {
return nextDailyOccurrence(t.At, now)
}
@ -319,6 +338,11 @@ func (rt *PluginRuntime) runDue() {
}
}
// catchUpDelay is how long a FirstDueNow task waits after boot. Long enough for
// the plugin's own boot work to finish, short enough that a restart heals
// expired credentials promptly rather than after a full period.
const catchUpDelay = 5 * time.Second
// nextAfter computes the following due time for a task that just fired.
func (rt *PluginRuntime) nextAfter(t ScheduledTask, now time.Time) time.Time {
if t.At != "" {
@ -489,6 +513,17 @@ func randInt63n(n int64) int64 {
// readNumberInt reads a numeric field as an int. Missing/zero yields 0, which
// every caller treats as "not set" (a task with every<=0 and no at is disabled).
// readBoolField reads a boolean field, treating only a real Lua boolean as set.
// A missing field (nil) means false — the caller's declared default.
func readBoolField(L *golua.State, idx int, field string) bool {
L.GetField(idx, field)
defer L.Pop(1)
if L.Type(-1) != golua.LUA_TBOOLEAN {
return false
}
return L.ToBoolean(-1)
}
func readNumberInt(L *golua.State, idx int, field string) int {
L.GetField(idx, field)
defer L.Pop(1)