feat(scheduler): 主 agent 忙时把积压任务自动转投给驻留子

问题(2026-09-19 线上实测):主 agent 被长任务占住时(现场:12 分 8 秒、69 次
工具调用),后来到达的消息全部以 level insufficient 排进中断队列干等 —— 同级
中断不能抢占同级运行任务(canPreempt),只能等前一个跑完。而内核本有驻留子
(独立 agent + 独立调度器)可并行干活。

行为(用户 2026-09-19 明确要求):
- 触发:运行任务持续 > offload_busy_after(5m) 且积压 >= offload_min_pending(3)
- 拉起/复用「转投专用」驻留子,把积压的纯排队输入转投过去
- 在原队列位置留下说明「[系统] N 条积压任务已转投给驻留子 agent X 处理…」

通道配置(按用户口径,与人工创建的子刻意不同):
- 不配 inputch(内核的干活 agent,不接收插件用户输入)
- 持有全部输出通道(结果要能发回 qq/webui 等正确通道)

三个设计要点(都是实测撞出来的,写进代码注释与设计文档 §7.1):
1. 检查必须在**独立 goroutine**:schedulerLoop 同步执行任务,放它里面在
   「正忙」期间根本回不到循环顶部 ⇒ 永不触发(我第一版就写错了,测试才发现)。
2. 只转投 TaskQueued 纯排队输入:中断任务带级别语义、self 任务与父的记忆面绑定。
3. 转投失败/关闭时必须把任务**放回队列前端**:吞一条输入比多处理一条更糟。

这是设计 §7「决策在父的模型手里」的**刻意例外**(父正忙、物理上无法决策,
而积压任务本来就是空的),已在文档中显式记录,且默认关闭、由部署方显式打开。

测试 11 条:只取排队输入 / 不足量不取 / 放回不丢任务 / 说明自解释 / 默认关闭 /
空闲不触发 / 端到端转投 / 上限不增殖 / 独立 goroutine 确实会触发。
This commit is contained in:
JianFeeeee
2026-09-19 16:59:47 +08:00
parent 499a4f3aa1
commit f2e0d55c5b
8 changed files with 799 additions and 1 deletions

View File

@ -142,6 +142,8 @@ type Agent struct {
// 工具轮次硬上限(0 = 不限);见 AgentConfig.MaxToolTurns。
maxToolTurns int
// offload 是积压任务自动转投的参数(见 offload.go)。
offload OffloadOptions
// 进行中的 LLM 请求取消函数,interceptLoop 可调用以在请求中打断
cancelLLM context.CancelFunc
@ -279,6 +281,12 @@ type AgentConfig struct {
// MaxToolTurns 是单个任务允许的工具轮次上限(0 = 不限)。
// 设计文档 D6:主循环必须有硬上限,否则模型不停调用就永不完结。
MaxToolTurns int
// Offload 是「积压任务自动转投给驻留子」的参数(见 offload.go)。
//
// 默认关闭(Enabled=false):它让**内核替父做决策**,是设计 §7
//「决策在父的模型手里」的刻意例外,因此必须由部署方显式打开。
Offload OffloadOptions
}
func New(cfg AgentConfig) *Agent {
@ -369,6 +377,7 @@ func New(cfg AgentConfig) *Agent {
childTasks: make(map[string]*childTaskState),
sched: newScheduler(256),
maxToolTurns: cfg.MaxToolTurns,
offload: cfg.Offload,
pluginHealth: newPluginHealthTracker(),
thinkingEnabled: cfg.ThinkingEnabled,
inputCfg: cfg.InputProcessing,
@ -407,6 +416,7 @@ func (a *Agent) Start() {
go a.archiveLoop()
go a.mergeLoop()
go a.reviewLoop()
go a.offloadLoop()
a.subscribeTerminalRegistry()
a.reembedStaleMedia()
a.migrateLegacyGraphMedia()