mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(lua): 插件长期运行环境(R4-b)—— 内核驱动调度 + 独立 VM
用户需求:插件需要长期运行环境(独立 LuaVM 线程化),以支持自动签到、
token 轮换这类"没有浏览器、没有人操作时也要发生"的能力。
旧模型做不到:插件的周期工作只能靠前端 pluginAPI.call 驱动,页面没开就
一次都不跑。这正是自动签到的死穴。
新增 internal/lua/plugin_runtime.go:插件声明,内核驱动。
PLUGIN.schedule = {
{ fn = "refresh_token", every = 3600 },
{ fn = "daily_checkin", at = "03:30" },
{ fn = "poll_quota", every = 300, jitter = 30 },
}
- 每个声明了 schedule 的插件获得**私有 VM**(不是共享 plugin.state 的那个:
钩子要累加,任务要长跑且网络阻塞,两者对隔离的要求相反)
- 内核单个 ticker 驱动所有任务;每个任务**独立 goroutine**,一个长任务
不推迟另一个的 due
- `at="HH:MM"` 每日固定时刻;`jitter` 打散首触发,避免整点齐射
- 任务失败计数进入 hook_errors 面(静默坏任务必须可见)
- Unload 先停调度再关状态(顺序反了会让在跑的任务写进已关闭的 Lua 状态)
明确边界:**适配器不参与调度**。适配器仍是无状态请求级池;token 轮换靠
插件(这里)按周期调用 Phase 3 的 UpdateAdapter。
修掉两个把进程直接搞崩的缺陷:
1. 私有 VM 的池我给 sync.Cond 传了新 mutex 而非池自己的 mu →
"unlock of unlocked mutex",fatal error 而非可恢复错误。
2. Transform 硬编码 adapterGlobal,而插件模式的池把表存在 pluginGlobal →
健康的状态也报 "has no table"。加 poolGlobal(p) 按模式选择。
测试 8 个:无任何触发自动执行(核心)/真正执行了(计数读回)/无声明则零开销/
at 解析与畸形退化/不完整条目被忽略/失败隔离/卸载停 goroutine/6 插件×3 任务压力。
This commit is contained in:
@ -243,6 +243,16 @@ type Plugin struct {
|
||||
// is still the compiled-in default would immediately overwrite the file it
|
||||
// was supposed to inherit from.
|
||||
persisted bool
|
||||
|
||||
// Schedule is the periodic work this plugin declared, and Runtime is the
|
||||
// long-lived environment that runs it (private VM + kernel ticker).
|
||||
//
|
||||
// Runtime is nil when the plugin declared nothing — the common case stays
|
||||
// exactly as cheap as before: one shared state, no extra VM, no goroutine.
|
||||
// The point of it is auto check-in / token rotation: work that must happen
|
||||
// with no browser open and nobody clicking anything.
|
||||
Schedule []ScheduledTask
|
||||
Runtime *PluginRuntime
|
||||
}
|
||||
|
||||
// Plugins is the loaded plugin set, owned by the VM.
|
||||
@ -696,6 +706,30 @@ func (ps *Plugins) LoadSource(name, code string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- schedule ----
|
||||
//
|
||||
// A plugin that declares `schedule` gets a LONG-RUNNING environment: a
|
||||
// private VM plus a kernel-driven ticker, so its periodic work (auto
|
||||
// check-in, token rotation, quota polling) happens with no browser open and
|
||||
// no operator clicking anything. Without this the only periodic work a
|
||||
// plugin could do was a front-end poll, which is exactly the "nobody is
|
||||
// looking at the dashboard" case the feature exists for.
|
||||
//
|
||||
// The parse happens here (right after boot) so a malformed schedule is
|
||||
// visible at load time rather than silently ignored.
|
||||
p.Schedule = parseSchedule(w.L).tasks
|
||||
w.L.SetTop(0)
|
||||
if len(p.Schedule) > 0 {
|
||||
p.Runtime = ps.startRuntime(p.Info.Name, code, scheduleSpec{tasks: p.Schedule})
|
||||
if p.Runtime == nil {
|
||||
// The private VM failed to boot. That must NOT silently disable the
|
||||
// plugin — hooks still work — but the operator needs to know their
|
||||
// scheduled work will never run.
|
||||
log.Printf("[scheduler] plugin %s: declared %d task(s) but no runtime could be started",
|
||||
p.Info.Name, len(p.Schedule))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- UI ----
|
||||
if ui, ok := readPluginUI(w.L, p.Info.Name); ok {
|
||||
p.UI = ui
|
||||
@ -1758,6 +1792,13 @@ func (ps *Plugins) Unload(name string) error {
|
||||
p := ps.plugins[idx]
|
||||
ps.plugins = append(ps.plugins[:idx], ps.plugins[idx+1:]...)
|
||||
ps.mu.Unlock()
|
||||
// Stop the scheduler BEFORE closing the state: a task in flight holds that
|
||||
// state, and closing it underneath would leave the task writing into a
|
||||
// closed Lua state — a crash rather than a clean unload.
|
||||
if p.Runtime != nil {
|
||||
p.Runtime.Stop()
|
||||
p.Runtime = nil
|
||||
}
|
||||
p.mu.Lock()
|
||||
if p.state != nil && p.state.L != nil {
|
||||
p.state.L.Close()
|
||||
|
||||
Reference in New Issue
Block a user