Files
HomeAgent/docs/zh/input-scheduler-design.md
JianFeeeee cb32032f76 feat(scheduler)!: 中断/排队两类别模型 + 插件声明 L1-L3、L4 内核独占
用户澄清推翻了早期设计的三处前提,本提交按新模型重做调度核心(行为有意变化):

1) 类别由注入 API 决定,与通道名无关
   - InjectInterrupt*                      -> TaskInterrupt(带级别,可被严格更高级中断打断)
   - InjectText*/InjectInputSync*/内核自循环 -> TaskQueued(无级别,可被任何中断打断)
   - 删除按通道名推断的 taskLevel():qq 走 InjectInterruptTextOpts,本就是中断

2) 级别只属于中断
   - 插件在 InjectOptions.Priority 声明 L1-L3(空/非法降级 L1,声明 L4 夹到 L3)
   - L4 内核独占:新增 raiseKernelInterrupt(panic / selfip);requestKernelPreempt 不夹取
   - panic 现在产生一条带 kernel 标记的 L4 中断;L4 自身 panic 不再产生新 L4(防自我放大)

3) 选择结构:四容器固定次序,删除统一比较器
   - immediate(抢占者立即运行)-> 中断队列 L4..L1 -> 栈顶(与队头比级别) -> 排队 FIFO
   - 删除 pickTaskIndex/taskBefore 与“同级 pending 优先”补丁(根因是抢占者进了队列)
   - 中断栈上界改为结构推论 = 4(= 中断级数);删除“超限转 pendingInterrupts”降级

公开 SDK(feature 分支有意新增,纯追加):InjectOptions.Priority + PriorityL1/2/3;
内核 io / proc 桥 / 插件模板同步透传。

设计稿 §2/§3/§4.1/§6.3/§9/§11/§12/§13/§15 按新模型重写。

验收:go build/vet 干净;go test ./... 37 包 ok 0 FAIL;-race 全绿;
e2e(抢占-挂起-恢复)+ 压力(200 排队 + 50 中断,L1/L2/L3 轮转)通过。
2026-09-13 07:00:51 +08:00

37 KiB
Raw Blame History

输入调度器设计(四级中断优先级 · 两类别 · 可抢占 · 现场保存)

模型更正2026-09-13据用户澄清重写 §2/§3/§4.1/§6.3/§9/§11/§12

本稿早期版本把「四级优先级」当成了所有任务的通用优先级,并按通道名 qq→L2、cli→L3由内核推断级别。那是错的。正确模型是两类别 + 四级

中断输入interrupt 排队输入queued
注入 API InjectInterrupt* InjectText* / InjectInputSync* / 内核自循环
级别 L1L4 无级别
定位 需要及时处理 不需要及时处理
可被谁打断 严格更高级的中断 任何中断

级别(“这项工作有多不能等”)由插件在 InjectOptions.Priority 里声明 L1L3 L4 由内核独占,只经 panic 与内核事件中断selfip产生。 类别由用哪个注入 API决定与通道名无关——QQ 走的是 InjectInterruptTextOpts 所以它是低级别中断L1,不是排队输入。

分支:feature/input-semantics 状态:设计稿 v1(待确认项见 §12未确认处按 §12 的「默认取值」推进) 影响面:internal/agent/coreinternal/agent/iointernal/sdk仅内部 公开 SDKv1 不改third_party/homeagent-sdk/sdk/ diff 必须保持为 0理由见 §13


1. 背景:现状与要解决的问题

现有两条输入语义(见审查结论),都建立在串行 eventLoop 之上:

语义 入口 路径
排队 InjectText / InjectInput* io.inputCheventLoopprocessInputprocess()
中断 InjectInterruptText / InjectInterrupt* io.interruptChinterceptLoopcancelLLM + a.interceptCh

已确认的具体问题(均为源码事实):

  1. 队头阻塞eventLoop 单 goroutineprocess() 全程持 a.muinternal/agent/core/process.go:103-104),一轮对话(含 N 轮工具)期间后续输入全部排队。
  2. 中断只在一种时刻成立interceptLoop 有三条降级回排队的路径——当前无 LLM 在跑、当前轮是 _consolidation_a.interceptCh 满(internal/agent/core/eventloop.go:74-116)。它不是独立管线,而是"抢占 + 三次降级"。
  3. 回执误投风险ResponseCh 只由全局 emitResponse 写(eventloop.go:475),无任务归属。一旦引入抢占,中断的回执会写进被挂起任务的等待者。
  4. 断链点静默processInput 有多条提前 return 而不 emitResponse 的路径(resolveInput 失败 :309、10s 去重命中 :317_consolidation_ :326),同步调用方(cliclawhubadapter 无超时)永久挂起。
  5. 背压策略分裂inputCh 满 → 阻塞发送方;selfInputCh 满 → 静默丢弃;a.interceptCh 满 → 降级回 inputCh
  6. 假取消:工具超时只是放弃等待,内层继续执行且副作用照做(internal/agent/core/toolcall.go:33-45)。
  7. 不可观测:没有任何"当前在跑什么、谁被挂起、降级了多少次"的统一入口。

目标:把上述隐式行为收敛成一个显式、可 dump、可单测、可断言的调度器。


2. 术语与模型

Task = { id, class, level, origin, frame, state, enqueueAt, preemptCount, responseCh }
  class: queued | interrupt       // **类别由注入 API 决定,与通道名无关**
    queued    —— 无级别;用于“不需及时处理”的场景;可被**任何**中断打断
    interrupt —— 带级别 L1..L4仅被**严格更高级**的中断打断(被打断则压入中断栈)
  level: 仅 interrupt 有意义queued 恒无级别effectiveLevel 视作 0
  state:  ready | running | suspended | done

TaskFrame = {
    msgs []Message            // 本任务自己的 LLM 消息序列
    step  Step                // 下一个要执行的 step安全点游标
    turn  int                 // 已完成的工具轮数
    toolIdx int               // 当前工具批内的下标
    toolResults []ToolResultItem
    toolsUsed []string
    lastBatchReplyOnly bool
    stageCtx *sdk.StageContext
    budget TokenBudget
    outputChannel string      // 该任务的输出通道
    responseCh chan<- *OutputEvent  // 任务级回执信道(可为 nil
    startedAt time.Time
    input string              // 触发本任务的输入文本COMMIT 时写记忆)
    noMemory bool
    originSource string
}

Step枚举顺序执行步与步之间是安全点
  S_PREPARE     构建 msgs / 应用中断标记 / 合并 stage 上下文
  S_LLM         LLM 流式调用(**可抢占**cancel 即丢弃)
  S_POST        StagePostAction
  S_LLM_JUDGE   无 tool_call → 去 S_BEFORE_OUTPUT有 → 去 S_TOOL_BEGIN
  S_TOOL_BEGIN  replyOnly 判定 + 取当前 tc
  S_TOOL_BEFORE StageBeforeToolcall
  S_TOOL_EXEC   工具执行(**临界区,不可抢占**
  S_TOOL_AFTER  StageAfterToolcall
  S_TOOL_NEXT   批内下一个 / 批结束 → S_LLM
  S_BEFORE_OUTPUT / S_AFTER_OUTPUT
  S_COMMIT      context.Append + emitMemoryCandidate**原子,不可抢占**
  S_FINISH      写 responseCh、发事件

四个容器(不是“三集合并成一个比较器”):

容器 内容 取出规则
interruptQueues[1..4] 中断队列,每条队列一个级别 从 L4 到 L1 依次扫描;同级 FIFO
immediate 刚抢占成功的那一条中断(至多一个 最先取出——抢占必须立即生效
queue 排队输入形成的新任务 纯 FIFO无级别可比
suspendStack中断栈 被打断、保存了现场的任务 LIFO只比栈顶;栈内不做重排

用词(已更正):它就是中断栈。用户明确存在「中断被中断」的场景,被打断的现场必须压栈; 因此恢复纪律是严格 LIFO只比栈顶,栈内不做优先级重排。 早期稿把它写成“不是栈、按优先级取”是错的。

早期稿还让 immediate 与别的容器共用同一个比较器,于是出现“抢占成功后, 抢占者与被挂起者同级 → 原任务被立刻选回 → 抢占空转”——为此打的 “同级 pending 优先”补丁已删除:抢占者根本不进队列。


3. 优先级(只属于中断)

3.1 两类别 + 四级

类别(TaskClass)由注入 API 决定,与通道名无关

类别 注入入口 级别 可被谁打断
queued 排队 InjectText* / InjectInputSync* / InjectInputMedia* / 内核自循环(selfInputCh 任何中断L1 也能)
interrupt 中断 InjectInterrupt* L1L4 严格更高级的中断

级别(Level)语义是“这项工作有多不能等”

Level 名称 语义 典型来源
L4 CRITICAL 内核紧急 内核独占panic 中断、内核事件中断selfip
L3 INTERACTIVE 需及时处理 时钟/定时器到达、终端输出、交互输入
L2 MESSAGE 一般提醒 插件希望尽快看到、但不紧急的提示
L1 BACKGROUND 完全可等 异步消息QQ/微信)、批量通知
  • queued 没有级别:它本就是“不需及时处理”的那一类, 所以“可被任何中断打断”不是漏洞而是定义(effectiveLevel(queued) == 0)。
  • 默认级 = L1:未声明一律最低级(“显式才是特权”,新插件不会默认拿到抢占权)。

3.2 级别从哪来

来源 可达级别 入口
插件声明 L1L3 InjectOptions.Priority(空/非法 → L1声明 L4 被夹到 L3
内核 L4唯一来源 (*Agent).raiseKernelInterruptpanic / selfip
  • 不是运维可调项。不引入 core.agent.priority.<channel> 这类配置键, 也不把 PriorityLookup 做成可注入的策略表。
  • 插件可以声明自己中断的级别(这不是“把内核内部属性外化”, 而是调用方声明它自己那件事有多不能等),但内核独占 L4 clampPluginLevel 把越权声明夹到 L3L4 在插件可达路径上不存在。
  • 定级规则可随内核演进调整,但插件可声明域始终不含 L4

3.3 抢占判据

effectiveLevel(queued) == 0
canPreempt(incoming, running) = incoming.Class == TaskInterrupt
                              && effectiveLevel(incoming) > effectiveLevel(running)

因为 queued 的有效级恒为 0这一个比较同时覆盖两条规则

running 是排队任务      → 任何中断≥L1都抢占
running 是中断 Li       → 只有 Lj > Li 的中断抢占(严格大于)
incoming 是排队输入     → 永不抢占

严格大于才抢占;相等一律入队——这条保证确定性,也是“较低无法打断较高”的字面实现。

4. 调度规则

4.1 选择函数(四容器 · 固定次序)

任务结束、或运行任务到达安全点且存在待处理抢占请求时,执行:

1. immediate 非空           → 取它(刚抢占成功的中断,抢占必须立即生效)
2. 中断队列非空             → 取 L4→L1 中最高级非空队列的队头(同级 FIFO
3. 中断栈非空(与 2 比高)  → 栈顶有效级 ≥ 队头级别 ? 弹栈顶 : 取队头
4. queue 非空              → 取队头(纯 FIFO
5. 都没有                  → 空闲(阻塞等新输入 / 新中断)
  • 中断栈只把栈顶放进比较(严格 LIFO——栈内更老的任务即使因饥饿防护 提升了有效级,也不得越过栈顶;“后被打断的先恢复”才是栈语义。
  • 第 3 步就是用户给的规则:“先判断中断队列是否为空,同时判断中断栈中任务的 优先级,哪个优先级高取出哪个”。栈顶是 queued(有效级 0任何中断都赢。
  • 第 1 步的存在,使“抢占者与被抢占者同级”这个比较根本不会发生 抢占者不经队列。这是删除早期“同级 pending 优先”补丁后的正确形态。
  • 排队任务只在中断与挂起现场都处理完后才执行——这正对应“排队输入用于 不需要及时处理的场景”。

4.2 安全点(可切换点)

只有 step 与 step 之间是安全点。 明确:

  • S_LLM 之后、S_TOOL_BEFORE 之后、S_TOOL_EXEC 之后S_TOOL_AFTER 之后……
  • S_TOOL_EXEC 执行中不是安全点:工具副作用不可回滚,无法"保存现场"。

4.3 临界区

CriticalSectionstep 标记 nonPreemptible = true或任务进入声明区间
  • 实现方式:调度器在临界区期间不求值抢占(协作式单线程下即"不 yield"),不使用 sync.Mutex
  • 资源互斥不用锁,而是调度器持有的资源表(若某资源被 running 占用,则不会选出同样占用它的任务)——纯数据判定,天然无优先级反转。
  • v1 临界区清单(显式列出,避免"隐式临界区"
临界区 理由
S_TOOL_EXEC(单次工具执行全程) 副作用不可回滚;插件 RPC 不可取消
S_COMMIT 上下文/记忆写入必须原子
需 ONNX 嵌入的 S_PREPARE 片段 ONNX Run 不可取消
媒体 CAS 落盘 同上
显式声明的 _consolidation_ 类任务 记忆一致性
  • 临界区期间到达的抢占请求不丢失:按级别进入中断队列,在临界区结束后的第一个安全点重新求值。

4.4 背压v1 统一为一种)

  • readyQueue 有界(默认 256可配
  • 满时:阻塞发送方(与现状 inputCh 一致,避免静默丢用户输入),但必须计数并打日志
  • 中断队列合计有界(默认同 maxQueue);满时丢弃最低级别里最老的一条并计数(中断是提示性输入,宁可丢旧保新)。
  • 中断栈帧数上界是结构推论 = 4(见 §6.3),不是配置项。

5. 中断语义

5.1 中断产生线程的职责(钉死)

interruptLoop 只做三件事,绝不触碰任何 TaskFrame

① 从 io.interruptCh 收中断 → 定级(读 payload["priority"],插件声明 L1..L3
② 决策scheduler.registerInterrupt 内):
     canPreempt(incoming, running) 且 running 不在临界区
        → 置让位信号 + 把 incoming 放进 immediate 槽,并返回 true调用方据此
          取消当前可取消的 step即 LLM 流式)
     否则
        → 按级别进入对应的中断队列
③ 唤醒调度器scheduler.wakecap 1

共享面仅三处:让位信号(preemptArmed/preemptLevel)、中断队列、critical 原子标志。 帧的保存与恢复只能由调度器做。

5.2 三种情形的统一

现状的三条降级路径在新模型里不再需要特殊分支:

情形 旧模型 新模型
LLM 在跑,正常 真抢占(同轮 continue 真抢占:S_LLM 取消,任务 A 压入中断栈,中断任务 B 从 S_PREPARE 启动
LLM 没在跑 降级为排队 B 按其级别入中断队列(空闲时即被 wake 唤醒并选出)
_consolidation_ 降级为排队 _consolidation_ 是后台临界区(且它是排队任务)→ B 入中断队列,临界区结束后求值
a.interceptCh 降级为排队 不存在该队列;中断队列有界,满则丢最低级别里最老的一条

5.3 中断任务与被打断任务的关系(已定D1 = 方案 B

用户明确: “中断打断时,上个任务到达以来的所有上下文现场被保护(含 toolcall 然后中断在上个任务前的那个完整状态上开始运行。中断运行结束,再把被挂起的 任务与其上下文现场加载回中断任务之上,并继续运行。”

因此语义是:

  1. 被挂起任务的现场 = 它自到达以来累积的全部上下文(含 toolcall 结果) 原样保存在 TaskFrame 里。
  2. 中断任务从「上一个任务之前的完整状态」开始运行——它看不到被打断 任务的任何部分进展。等价于:中断任务就是一个普通新任务,正常走 S_PREPARE (重建 system prompt + timeline + 自己的输入)。
  3. 中断结束后,把被挂起任务与其现场加载回「中断任务之上」再继续 中断已提交的那段上下文留在下面(成为重建前缀的一部分),本任务自己的 现场接回其上

实现对应(internal/agent/core/task.go

  • TaskFrame.PrefixLen 记录 prepare 段构建的基础前缀长度 system + timeline + 用户输入);其后的 Stage 上下文与工具轮产物都是“自己的现场”。
  • rebaseFramePrefix(f):恢复时重建基础前缀(因中断结束已把它的输入/输出提交进 a.context,重建出的 timeline 已含中断效果),再把 f.Msgs[PrefixLen:] 原样接回; 并补回 prepare 段的両处尾部改写(IsInterrupt[中断消息] 标记、输入多模态块)。
  • 调用点:resumeTaskrunTaskSteps 之前调用它。

代价(已知且接受):中断看不到“进行到哪一步”,所以“别搜了改成 X”这类指令 只能靠它自己重新理解;换来的是中断起点总是一个一致的完整状态


6. 保存现场与恢复

6.1 保存

在安全点被抢占时:

suspendStack.push(Task{frame: running.frame, state: suspended,
                      step: running.frame.step, enqueueAt: running.enqueueAt})
running.state = done_for_now
  • 只保存数据帧,不保存 goroutine 栈(这正是"单调度 + 隐式状态机"优于"park goroutine"的地方)。
  • S_LLM 被抢占时:不完整的 LLM 请求直接丢弃LLM 调用幂等、无持久副作用);恢复时从 S_LLM 重发msgs 与抢占前一致(即"请求前"的状态)。
  • 已提交的副作用(已执行的工具、已 append 的 context不回滚——帧里记录的 toolResults 会保留,恢复后继续。

6.2 恢复

中断栈栈顶取出后:

  1. 重建基础前缀rebaseFramePrefix)—— 此时中断任务已结束并提交, 重建出的 timeline 包含中断的输入/输出,即“现场加载回中断任务之上”;
  2. 把本任务自己的尾部Stage 上下文 + 工具轮产物 + 占位)原样接回;
  3. frame.Step 继续执行。

被丢弃的只有那次不完整的 LLM 请求(幂等),已执行的工具与已累积的 toolResults 全部保留。

6.3 嵌套

  • 允许中断任务自身被更高级中断抢占(嵌套)。
  • 中断栈帧数上界 = 4是结构推论而不是配置项 链条 = 排队(L0) ← I(L1) ← I(L2) ← I(L3) ← I(L4 运行中) 被挂起 4 帧L4 之上没有更高级别,链到此为止。 (插件可达级别只到 L3所以插件链最多挂起 3 帧 + 底层排队任务; 第 4 帧只能由内核 L4 制造。)
  • 栈自底向上的基础级天然递增(能被抢占者必然级别更高),因此栈顶通常就是最高级任务。
  • 超限在正确模型下不可达:susp 处只做防御性计数Rejected++ 不降级、不丢弃帧——帧丢了会丢副作用记录。早期稿写的“超限转 pendingInterrupts”已删除。

7. 回执路由(任务级)

必须改ResponseCh 从"全局 emitResponse 的对象"上升为 TaskFrame.responseCh

emitResponse(task, ...)      // 写 task.frame.responseCh而不是"当前全局通道"
  • 抢占场景下,中断任务 B 的 S_FINISH 只可能写 B.responseCh,绝不会写进被挂起的 A.responseCh
  • 不变量每个任务在 S_FINISH 必然产生且仅产生一个终态事件(无论成功、失败、被跳过)。processInput 现有的三个提前 return解析失败、去重、consolidation在新模型里都必须转成"任务以 skipped 终态结束并回执"。
  • 这顺带修掉现有缺陷:cliinternal/plugins/cli/plugin.go:242)与 clawhubadapterinternal/plugins/clawhubadapter/plugin.go:1045)的同步注入在断链时会永久挂起。

8. 并发结构(两个 goroutine

schedulerLoop唯一持有任务状态与帧
   for {
     if 可切换 && preemptionRequest 有效 → 执行抢占(保存现场)
     if running == nil → pick from 三集合;无候选则等待 inbox
     runOneStep(running)          // 可能是阻塞调用(见 §8.2
     处理 step 结果 → 推进或结束任务
   }

interruptLoop不持有任何帧
   收 interruptCh → 定级 → 决策 → 置 preemptionRequest + cancel + wake scheduler

8.1 不变量

# 不变量
I1 任意时刻至多一个 running 任务("一个 running"约束的是副作用,不只是 CPU
I2 任务帧只由 schedulerLoop 读写;interruptLoop 只写 preemptionRequest / 读 stepCancel
I3 任何跨挂起点的状态都是纯数据,不持有锁
I4 安全点只在 step 边界;工具执行中与 COMMIT 不是安全点
I5 每个任务恰好一次终态事件(含 responseCh 写入)
I6 调度器本身永不退出panic 只使当前任务失败)

8.2 关于"调度器不被阻塞"待确认 D2

M3 拆分的理由:真正的挂起要求帧跨越 prepare → step… → finish 全生命周期。 若只把 process() 改成可挂起,processInput 会在挂起返回后继续执行 context.AppendemitResponse——造成重复提交。故 M3 分为 M3a所有权重构 行为等价)与 M3b抢占语义两步。

v1 采纳:S_TOOL_EXEC / ONNX / CAS 属于临界区,调度器在这些 step 上会阻塞进插件 RPC / 原生调用。 这是有意的取舍:

  • 好处:与"两个 goroutine 就够"一致,实现简单,无临时 goroutine。
  • 代价:这些临界区期间中断只能排队,不能抢占。换言之,中断的有效窗口 = S_LLM(与今天的实际行为相同,但现在是显式声明而非隐式结果)。
  • 演进v2S_TOOL_EXEC 改成异步 step临时 goroutine + 完成事件),并给插件协议加 tool.cancel。此路径在文档保留,不在 v1 实现。

8.3 panic 隔离与 panic 中断

  • runOneStep 外包 recoverpanic → 当前任务标记 failed调度器继续
  • panic 同时产生一条内核 L4 中断reportTaskPanicraiseKernelInterrupt 内核把自己发生了 panic 这件事作为最高级中断通知给调度器,让 agent 能知情/善后。
  • 递归保护是结构性的:若 panic 的任务本身就是 L4 内核中断,不再产生新的 L4—— 否则同一个 panic 会自我放大成中断风暴。
  • 取代现有 eventLoop/interceptLooprecover → sleep 1s → go loop() 无退避重启(eventloop.go:19-22,38-42)。

9. 失效模式与防御

失效 防御
饥饿(高优先级流反复抢占) preemptCount 提升有效级:effectiveLevel = min(4, baseLevel + min(preemptCount, 2));被抢占 +1。只对中断生效——排队任务无级别,按定义可被任何中断打断
无界下潜 中断栈帧数上界 4结构推论 = 中断级数);超限只做防御性计数,不降级不丢帧
中断请求堆积 中断队列合计有界,满则丢最低级别里最老的一条并计数
就绪队列满 阻塞发送方 + 计数(不静默丢)
同一任务反复被打断 preemptCount 达阈值后有效级提升;另设抢占冷却:刚被抢占的任务在 cooldown 内不再被同级/更低级抢占
任务永不结束 每任务 maxTurns(主循环目前缺失,见审查 P0+ 每步超时
不可观测 Scheduler.Dump() 原子快照 + 事件(切换原因、降级次数、丢弃次数)

10. 非目标v1 明确不做)

  1. 工具级取消 / 可抢占工具(tool.cancel)。
  2. 多 agent 并行(仍是单 agent 单调度器)。
  3. 公开 SDK 接口变更。
  4. 微抢占(任意指令级)。
  5. 跨进程恢复(帧不落盘)。

11. 测试点、测试方式与预期结果

测试基础设施(先于 M1 落地):

  • 假时钟 Clock 接口(Now() / AfterFunc),生产用真实实现,测试注入可控时钟。
  • 假 Provider:实现 agentAPI.Provider,返回脚本化的 tool_calls 序列(支持"第 N 次调用时挂起直到放行")。
  • 假工具:测试内 StageHost.RegisterTool 注册,可控制每次执行耗时、是否返回错误、是否触发中断注入。
  • 同步栅栏:测试通过 scheduler.Inbox 注入中断并用 runtime.Gosched + 显式 waitFor(state) 断言,不用 sleep 猜时序。
  • 快照断言scheduler.Dump() 返回 {running, queue, interruptQueues[1..4], immediate, suspendStack, counters},测试对纯数据断言。

11.1 优先级与抢占

编号 测试点 方式 预期结果
P1 更高中断抢占中断 running=L2 在 S_LLM;注入 L3 中断 L2 压入中断栈step=S_LLML3 进 immediate 并变 running
P2 相等级别不抢占 running=L2 中断在 S_LLM;注入 L2 不抢占;请求入 L2 中断队列running 不变
P3 更低级别不抢占 running=L3 中断;注入 L2 同上,不抢占
P4 逐级抢占嵌套 排队任务 → L1 → L2 → L3 → L4均在 S_LLM 中断栈深度依次 1/2/3/4每层 step 均为 S_LLM
P5 抢占后在安全点才生效 running=排队任务在 S_TOOL_EXEC;注入 L4 抢占不立即生效;工具返回后才保存/切换;deferredPreemptions==1
P6 临界区不可抢占 running 声明临界区;注入 L4 同上L4 请求留在中断队列,临界区结束立即被选中
P7 排队任务被任何中断打断 running=排队任务;注入 L1 中断 L1 也抢占成功(排队任务有效级 0
P8 排队输入永不抢占 running=任意任务;注入排队输入 不抢占,入排队队列
P9 插件不能声明 L4 InjectOptions.Priority="L4" 级别被夹到 L3payload["priority"] 走同一条路
P10 panic 产生 L4 中断 任务 panic 产生一条带 kernel=true 的 L4 中断L4 自身 panic 不再递归

11.2 保存现场与恢复

编号 测试点 方式 预期结果
R1 S_LLM 抢占后恢复 构造 A 在 S_LLM 被 B 抢占B 结束 A 恢复后重新发起 LLM 请求;msgs 与 A 被抢占前逐字节相同;不重复执行已完成的工具
R2 S_TOOL_BEGIN 抢占后恢复 A 完成 1 个工具批后于 S_TOOL_BEGIN 被抢占 A 恢复后继续下一批工具;toolResults 长度不变
R3 恢复结果与不中断一致 同一脚本跑两次:一次中途注入中断,一次不注入 两次最终 context 事件序列除"中断任务自身的事件"外一致A 的 toolsUsed 顺序相同
R4 嵌套恢复顺序 L4→L3→L2 依次抢占后依次结束 按有效级/到达序恢复;每个任务的 frame.step 与其被挂起时一致
R5 不完整的 LLM 请求被丢弃 假 Provider 在流式途中触发中断 该次请求被 cancel不产生任何 msgs 追加、不产生 tool_call;恢复后重发次数 = 1

11.3 回执路由

编号 测试点 方式 预期结果
X1 任务级回执不误投 A 为同步任务并已挂起B 为同步中断 B 的回执只到 B.responseChA.responseCh 在 A 恢复并结束后才收到自己的回执
X2 断链路径必有终态 分别构造解析失败、10s 去重命中、_consolidation_ 三种都产生 skipped 终态事件并回执;同步调用方不挂起
X3 每任务恰一次终态 统计 S_FINISH 次数 vs 任务数 相等I5无重复写入
X4 无超时同步注入不再永久挂起 cli 路径(无超时)注入一条会被去重的输入 返回 skipped 回执而非永久阻塞

11.4 队列与选择

编号 测试点 方式 预期结果
Q1 中断队列按级别扫 四条中断队列各放一个,入队顺序与级别相反 取出顺序 L4→L3→L2→L1中断耗尽后才是排队任务FIFO
Q2 挂起现场优先于新排队工作 A 被抢占挂起 + B 为新排队输入 A栈顶先被选中
Q3 栈顶 vs 中断队头 栈顶 L3 + 队头 L2 / 栈顶 L3 + 队头 L4 / 栈顶为排队任务 + 队头 L1 分别取 栈顶 / 队头 / 队头
Q4 就绪队列背压 readyQueue 满后注入排队输入 发送方阻塞 + 计数 +1不静默丢弃
Q5 中断队列溢出 中断队列合计满后注入更多 最低级别里最老的一条 + 计数;其余保持
Q6 immediate 最优先 immediate 非空且中断队列里有更高级别 immediate(抢占必须立即生效)

11.5 深度、饥饿与并发

编号 测试点 方式 预期结果
D1T 下潜深度上界(结构推论) 挂起 3 帧后继续注入;再挂起到 4 帧 3 帧时 canSuspend()==true4 帧(全链:排队+L1+L2+L3L4 运行中)时为 false
G1 饥饿防护(抢占提升) 对同一 L1 中断连续抢占 5 次(同级/高级交替) effectiveLevel 提升至 min(4, 1+2)=3;第 3 次后不再被 L1/L2 抢占
G2 冷却生效 同一中断刚被抢占后立刻再注入同级中断 冷却期内不抢占,请求入中断队列
G3 提升也必须只在中断间生效 排队任务被连续抢占 排队任务有效级恒 0不被提升它按定义可被任何中断打断
K1 panic 隔离 假工具 panic 只有该任务变 failed;调度器存活;后续任务正常执行
K2 竞态检查 全部调度用例加 -race 无数据竞争报告
O1 快照一致性 在任意 step 边界调 Dump() 返回的 running/ready/pending/suspend 三集合互不重叠且总数守恒
O2 切换可观测 每次抢占/恢复 产生一条事件(任务 id、原因、from→to、level

11.6 端到端

编号 测试点 方式 预期结果
E1 真实 provider + 假长工具 启动内核,用一个会阻塞 5s 的假工具跑 L1 任务,途中经 interceptCh 注入 L4 中断 中断在工具执行期间不被处理;工具返回后立即抢占;中断任务先完成;原任务恢复并完成
E2 LLM 流式中断 假 Provider 慢速流式返回 中断后当前流被 cancel任务挂起中断任务完成原任务恢复并重新请求
E3 现有 e2e 回归 internal/plugins/integration_test.goreal_plugin_smoke_test.go 行为不变(除文档化的语义变化)

12. 待确认决策(含默认取值)

未获异议时按"默认取值"实现;每项单独一个 commit便于回退。

编号 问题 默认取值
D1 中断任务的上下文 方案 B已定:中断从上一个任务之前的完整状态开始;恢复时把被挂起任务的现场加载回中断之上
D2 阻塞 step 处置v1 全部声明为临界区(调度器可被阻塞)还是引入异步 step v1 = 临界区;异步 step 留到 v2
D3 中断队列与排队队列是否合一 完全分离中断按级别分四条队列L4→L1 扫描),排队队列纯 FIFO两者不共用比较器
D7 任务类别怎么定 由注入 API 决定InjectInterrupt* = 中断;InjectText*/InjectInputSync*/自循环 = 排队),不按通道名推断
D8 L4 归谁 内核独占。唯一入口 (*Agent).raiseKernelInterruptpanic / selfipclampPluginLevel 把插件声明夹到 L3
D9 L1L3 归谁 插件在 InjectOptions.Priority 里声明(纯追加字段);空/非法降级到 L1
D10 抢占者进入队列还是立即运行 立即运行immediate 槽)。这消除“抢占者与被挂起者同级”的比较,删除了早期的“同级 pending 优先”补丁
D11 中断栈帧数上界 结构推论 = 4(排队 L0 + I1 + I2 + I3 挂起I4 运行中),不是配置项;超限只计防御性计数
D4 readyQueue 满时:阻塞发送方 or 返回错误 阻塞发送方 + 计数(与现状一致,避免丢用户输入)
D5 饥饿防护:抢占计数提升 or 时间老化 抢占计数提升(确定性、易测);时间老化留待需要时
D6 主循环 max_tool_turns 是否在本特性一并落地 (审查 P0且调度器需要"任务可终止"这一前提)

13. 与发布纪律的关系

  • 本特性在 feature/input-semantics 上开发,完成后合回 main不碰 release/v1.2.x
  • 公开 SDK 在本特性上有意新增feature 分支不受 rel 分支的接口冻结约束): sdk.InjectOptions.Prioritysdk.PriorityL1/L2/L3。这是为了让插件能声明 自己中断的级别§3.2)。
  • 追加是唯一的形态:不改既有字段、不改签名、不改语义;Priority 的零值 等价于旧行为L1
  • 合回 main 前需完成的发布动作:
    1. 同步更新 docs/zh/plugin-interface-matrix.md
    2. 与 SDK 仓协同升 SDK 中版本;
    3. 遵守“只增不减、签名不改”边界。
  • 内核侧接口(internal/agent/io、proc 桥的 injectParams/injectMediaParams 同步追加 priority,与公开 SDK 字段一一对应。

14. 实现里程碑(逐个实现,每个 = 一个可独立验收的提交)

里程碑 内容 验收
M0 测试基础设施:Clock 接口、假 Provider、假工具、waitForDump() 骨架 新测试可运行;go vet 干净
M1 纯重构:把 process() 拆成显式 step 状态机 + TaskFrame;仍由现有 eventLoop 驱动,无优先级/无抢占 R3、X3 通过;既有全部 agent 测试通过(行为等价)
M2 调度器骨架:单 schedulerLoop + readyQueue,取代 eventLoop 的输入处理;无优先级(全部 L1纯 FIFO Q1/Q4 通过integration 测试通过
M3a 前置重构(本次拆分引入):把一轮对话的所有权从 processInput 移到调度器——帧覆盖 prepare → step… → finish;同时移除 process() 整轮持有的 a.mu(挂起不能持锁) 既有全部 agent 测试 + 既有 e2e 通过(行为等价);-race 干净
M3b interruptLoop 重写 + 四级优先级 + 严格大于抢占 + 中断栈 LIFO只支持 S_LLM 抢占 P1P4、R1、R5、K1K2 通过;嵌套 LIFO 判据通过
M4 临界区 + S_TOOL_EXEC 声明 + 中断队列 + 深度上界(后经模型更正重做,见下 P5P6、D1T、Q3、Q5 通过
M5 饥饿防护(抢占计数提升 + 冷却) G1G2 通过
M6 任务级 responseCh + 断链点统一为终态事件 X1X4 通过;cli/clawhub 不再挂起
M7 可观测性(Dump()/事件/状态页)+ 既有回归 O1O2、E1E3 通过;go test -race ./internal/agent/... ./internal/plugin/... 全绿

每步收尾命令:

export GOCACHE=/tmp/gocache GOPATH=/tmp/gopath
gofmt -l internal/agent internal/plugin internal/sdk   # 本步新增文件必须为空
go build ./... && go vet ./...
go test -race -count=1 ./internal/agent/... ./internal/plugin/... ./internal/sdk/...

实现状态2026-09-13 完成)

里程碑 提交 验收结果
M1 9a58878 agent 全量 + -race;新增 task_test.go 4 项
M2 7082a50 新增 scheduler_test.go 6 组(含 O1/K1
M3a+M3b c69a1f1 新增 task_lifecycle_test.go 5 项、scheduler_preempt_test.go 5 项
M4 7565248 新增 scheduler_critical_test.go 3 项
M5 a971fc8 新增 scheduler_starvation_test.go 4 项
M6 4e4e0ad 新增 task_terminal_test.go 3 项
M7 f11de37 新增 scheduler_e2e_test.go 3 项(压力/可观测/端到端)

模型更正后的重构2026-09-13同一特性分支

用户逐条澄清后重做调度核心(行为有意的语义变化,非等价重构):

内容 验收
类别化 TaskClass{queued,interrupt};类别由注入 API 决定;newInputTask/newSelfTask 为 queuednewInterruptTask 为 interrupt scheduler_kernel_test.go P7/P8
级别归位 Level 语义改为“中断级别”;taskLevel()(按通道名推断)删除,改为 interruptLevel(evt)payload["priority"] P9、Q1
L4 内核独占 raiseKernelInterruptpanic/selfiprequestKernelPreempt 不夹取panic 报告为 L4 且带递归保护 P10、TestKernel_PanicRaisesL4Interrupt
选择结构 immediate + 四条中断队列 + 排队 FIFO + 中断栈;删除统一比较器 pickTaskIndex/taskBefore 与“同级 pending 优先”补丁 Q1Q3、Q6
栈上界 maxSuspendDepth(配置语义)→ maxInterruptFrames = int(LevelCritical)(结构推论);删除“超限转 pending”降级 D1T
公开 SDK InjectOptions.Priority + PriorityL1/L2/L3io/proc 桥/插件模板同步透传;example/qq 声明 L1 go test ./... 全绿

实现期与设计的差异(均已回写本文档):

  1. M3 拆为 M3a/M3b:真正挂起要求帧跨 prepare→run→finish,否则 processInput 会在挂起返回后继续提交。
  2. a.mu 整体移除:它原本只包住整轮 process()(同一 goroutine 移除后所有任务状态由 schedulerLoop 独占(不变量 I2/I3 可落地)。
  3. interceptCh 被删除M3b 起中断一律走中断队列(当时叫 pendingInterrupts),旧的 “同行注入 + 三处 drain + 批次放弃” 已无写入者属死代码M4 清理)。
  4. v1 未做 M0 的伪时钟:所有抢占测试用“单次调用阻塞到 ctx 取消”的 provider 达到确定性,无需注入时钟。时序型判据(老化式提升)留待需要时。
  5. 工具执行中不可抢占是被结构保证的:让位检查只在 step 之间; 不需要在 step 内部再判一次。

15. 开放问题(后续版本)

  1. 异步 step + tool.cancel(真正让工具可抢占)。
  2. 帧落盘(跨进程/崩溃恢复)。
  3. 多 agent 并行调度。
  4. plan.md §13.7 的 RuntimeManager + 分组 worker 合并(本设计是其前置)。

已更正:早期稿写“InjectOptions.Priority 进入公开 SDK 已被删除”, 前提是“优先级是内核内部属性、不应由插件声明”。用户澄清后该前提被推翻: L1L3 就是给插件声明使用的,只有 L4 归内核独占panic / selfip。 因此 InjectOptions.Priority 已落地§3.2/§13

不做的是“运维可调的策略表”(core.agent.priority.<channel>)—— 那是把调度内部属性外化成配置,与“由调用方声明自己那件事有多不能等”不同。