5 Commits

Author SHA1 Message Date
48385858f7 fix(remotedevice): 心跳 pong 忘了 Flush —— 修「设备通道每 60 秒掉线重连」
真因(实测定位):服务端 writePong 只调 writeFrameHeader,**不 Flush**。
pong 只有两个字节,且设备空闲时没有任何别的写会顺带把 bufio 缓冲刷出去 ——
于是 pong 永远留在服务端缓冲里。

链路:客户端每 30s 发一个 ping(pingLoop)→ 服务端算出 pong 却没发出 →
客户端的读循环设的是「2 倍 ping 间隔」读超时(默认 60s)→ 每 60 秒准点
i/o timeout → 桥断开 → 3s 后重连 → 服务端 markOffline 注销 outputch,
重连后再注册。

生产日志就是这个指纹(online :20 → offline 下一分钟 :20 → 重连 :23,
连续数小时无一次例外);面板上表现为设备通道/工具凭空消失又出现,
/devices 列表跟着闪。

改法:writePong 复用 writeFrame(它 Flush)。另把客户端读循环退出时的
静默 return 改成带错误与 opcode 的日志 —— 此前断线真因在设备侧完全不可见,
只能靠对端日志倒推,正是这次排查一开始卡住的地方。

回归用例 TestWSPingGetsPongWhileIdle:只发一个 ping,随后什么都不发,
要求 2s 内必须收到 pong。**反向验证过**:把修复改回 writeFrameHeader,
用例即以 `read tcp ...: i/o timeout` 失败(与生产症状一致)。
2026-09-14 11:29:26 +08:00
bc5bfd6823 fix(scheduler): 安全点重新求值中断队列 + 抢占/背压计数修正 + 停机补终态
对照 docs/zh/input-scheduler-design.md 原文修四处(前两处是真缺陷,后两处是
观测面与设计承诺不一致),均配回归用例:

1. §4.3/§5.2「临界区结束后的第一个安全点重新求值」此前**没有实现**:
   全仓唯一的武装点是 registerInterrupt,凡被拦成「入队」的中断只能等当前任务
   自然结束。可达症状:WebUI 终止按钮连按两次,第二次落在 2s 抢占冷却窗内 →
   入队 → 再也不会被求值。修:runTaskSteps 的安全点先 rearmPending()——
   判据与 registerInterrupt 完全同一套(canPreempt + 冷却 + 临界区闸门)。

2. PreemptsByLevel 的语义是「进入 immediate 槽的次数」,但计数发生在
   setImmediateLocked 之前:immediate 是单槽,同一安全点前到达的两条同级中断里
   被降级的那条也被计成抢占。修:setImmediateLocked 只在真占住槽时返回 true,
   计数随之为真;同时把「降级入队」的责任收归调用方,消除同一任务被入队两次的
   隐患(实测该隐患会让中断任务执行两次、Executed 虚高)。

3. 状态面 Preempted 此前拿 Stats.Suspended 顶替,与 preempts_by_level 自相矛盾。
   修:Preempted = Σ PreemptsByLevel[1..4]。

4. §4.4/Q4「满时阻塞发送方 + 计数并打日志」只做了阻塞:pumpInbox 满时直接返回,
   一个字都不计。修:新增 Stats.Backpressure(+DTO 字段) 与只报一次的状态翻转日志;
   同时显式处理 enqueue 返回值(静默丢弃会让同步调用方永久挂起)。

另:Stop() 停机前排空待办——给从未运行与已挂起的、带 ResponseCh 的任务补
skipped 终态,否则 cli/clawhubadapter 这类无超时同步注入方永久挂起(§7 I5、§11.3 X4)。
emitResponse 的 ResponseCh 写入改为非阻塞 + 告警,避免一行写错就卡死调度器 goroutine。

验证:go build/vet 干净;go test -count=1 ./internal/agent/... ./internal/plugin/...
./internal/sdk/... ./cmd/... 全绿;go test -race ./internal/agent/core/ ./internal/sdk/ 干净。
新增 scheduler_rearm_test.go 六个用例(冷却期满重新求值/同级降级不计数/Preempted 求和/
停机补终态/背压计数与翻转/pumpInbox 满计数)。
2026-09-14 10:39:33 +08:00
8313120d2a chore(version): release/v1.3.x 路牌推到 1.3.12(含驻留 inputch 残留修复 + Lua events 修复) 2026-09-13 20:34:09 +08:00
5f63f6ec6c fix(lua): events.subscribe 改用内部 Subscribe + 订阅生命周期(修死锁/use-after-close)
上一版 Lua 对齐引入的 sdk.events.subscribe 有两个真问题,本提交修掉:

1) 用了公共 SDK 的 Events(),但本内核从未注入 event subscriber
   (SetEventSubscriber 全仓无调用点),拿到永远是 nil ⇒ subscribe 只会
   返回 "events unavailable"。改用内部 SDK 的 s.Subscribe——内置插件走的就是
   这条路径(cli/webui/skillmgr 全用它)。

2) 自死锁:subscribe 会在 Lua 的 plugin.start(sdk) 回调里被调用,而
   luaPlugin.Start 正持有 p.mu;原实现在 subscribe 里再 lock p.mu 追加 subs,
   不可重入 ⇒ 测试实测 30s 超时。改用独立的 subsMu。

3) use-after-close:Stop 会 Close LState,但事件订阅此前无人取消,残留回调
   再触发就会碰已关的 L。现在:Stop 先(不持 p.mu,避免与 Bus.Publish
   锁序反转)取 subsMu 取消全部订阅,再置 closed 并关 L;事件回调持 p.mu 后
   先查 closed,已进入等锁的旧回调会直接返回。

4) plugin_mgr 访问补 nil 保护(部分单测构造的 SDK 不含 pluginMgr)。

回归:TestLuaEventsSubscribeAndStopCleanup——订阅后 Publish 命中、Stop 后
再 Publish 不 panic。全套 Lua 测试在 -race 下通过。
2026-09-13 20:33:25 +08:00
a13504be38 fix(resident): 销毁驻留子时注销其入站 inputch(child/<id>)—— 修登记表脏数据累积
根因:residentInboundChannel 在 create 时把 child/<id> 登记进共享登记表
(Plugin=resident, Owner=父),但 teardownResident 只把划入的 inputch
(如 timer)归还为未分配,从未注销这条入站登记。于是每次 create/destroy
都在登记表里留下一条脏记录,且随次数单调累积。

实测(HomeAgent 侧,HΔ-Kernel v1.3.10): 后
 仍列出 child/<id>,归属 main;HomeAgent 没有任何
工具能单独注销 inputch,只能重启 homed 清掉。

修法:teardownResident 里用纯函数 inboundChannelName 算出名字并 Unregister。
不能复用 residentInboundChannel——它有重新登记的副作用。
该路径同时覆盖 destroy / reclaim / StopResidents(父退出)。

测试:TestResident_LifecycleAndNoOrphans 增加两条断言——销毁后与父退出后
child/<id> 都必须从登记表消失。
2026-09-13 20:19:47 +08:00
14 changed files with 597 additions and 32 deletions

View File

@ -404,9 +404,30 @@ func (a *Agent) Start() {
func (a *Agent) Stop() {
// 父退出**必须**销毁全部驻留子(设计 §10 硬约束:子不得比父活得久、不留孤儿)。
a.StopResidents()
// 停机前给待办任务补终态。运行中的任务会经 cancel → LLM 失败 → emitResponse
// 自然拿到终态,但**从未运行**(排队/待处理)与**已挂起**的任务不会有任何人
// 回它们;带 ResponseCh 的同步注入方cli / clawhubadapter 均无超时)会永久挂起
// (设计 §7 I5、§11.3 X2/X4。必须在 cancel 之前做cancel 会让调度器直接 return。
a.drainPendingInterrupts("agent_stopped")
a.cancel()
}
// drainPendingInterrupts 给排队/待处理/已挂起任务中带同步回执通道的调用方补一条
// skipped 终态(复用 emitSkippedReply非阻塞写不对外发 agent_output 事件)。
func (a *Agent) drainPendingInterrupts(reason string) {
if a.sched == nil {
return
}
pending := a.sched.pendingEvents()
if len(pending) == 0 {
return
}
for _, evt := range pending {
a.emitSkippedReply(evt, reason)
}
log.Printf("[agent] %s: 停机,%d 条待办任务已补 skipped 终态", a.id, len(pending))
}
// graphMemoryOf 决定本 agent 的图记忆共同面实现。
//
// - 轻量内核(给了 LightMemory用 LightMemory**整理面保持 nil**

View File

@ -331,13 +331,20 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
payload["usage"] = stageCtx.TokenUsage
}
if evt.ResponseCh != nil {
evt.ResponseCh <- &agentIO.OutputEvent{
// 非阻塞写:ResponseCh 由同步调用方以 cap=1 创建。按不变量 I5每任务恰一次
// 终态)这里永远写得进去;但一旦哪天写出第二次,阻塞会卡死**调度器 goroutine**
// (整个 agent 停摆),而丢弃只是丢一条回执——与 emitSkippedReply 对称。
select {
case evt.ResponseCh <- &agentIO.OutputEvent{
RequestID: evt.RequestID,
Target: evt.Source,
Type: "text",
Payload: payload,
Done: true,
OutputChannel: ch,
}:
default:
log.Printf("[agent] ResponseCh 已满终态回执被丢弃request=%s可能违反不变量 I5", evt.RequestID)
}
}

View File

@ -198,8 +198,13 @@ func (a *Agent) SpawnResident(opts ResidentOptions) (ResidentInfo, error) {
func (a *Agent) residentParentSource() string { return "parent/" + string(a.id) }
// residentInboundChannel 是"父接收某个子的消息"的 inputch 名(登记进登记表可见)。
//
// inboundChannelName 只拼名字,不产生副作用(注销路径要用它算出同一个名字,
// 不能再去调 residentInboundChannel——那会顺手把刚摘掉的登记又写回去
func inboundChannelName(childID string) string { return "child/" + childID }
func (a *Agent) residentInboundChannel(childID string) string {
ch := "child/" + childID
ch := inboundChannelName(childID)
if reg := a.io.ChannelRegistry(); reg != nil {
// 归属父自己:它是父的入站 inputch。
_ = reg.Register(agentIO.InputChannel{Name: ch, Plugin: "resident", Owner: string(a.id)})
@ -246,6 +251,14 @@ func (a *Agent) teardownResident(rc *residentChild) {
for _, ch := range rc.inputChs {
_ = reg.Assign(ch, "", 0)
}
// 注销"父接收该子消息"的入站 inputchchild/<id>)。
//
// 它由 residentInboundChannel 在 create 时登记Owner=父),销毁时必须
// 一并摘掉:登记表是共享的、按 name 全局唯一,残留会随 create/destroy
// 次数单调累积脏数据。实测destroy 后 child/<id> 仍挂在根 agent 名下,
// 而外部没有任何工具能单独注销 inputch只能重启 homed 清。
// 注意用纯函数算名字,不要再走 residentInboundChannel会重新登记
reg.Unregister(inboundChannelName(rc.id))
}
}

View File

@ -142,6 +142,11 @@ func TestResident_LifecycleAndNoOrphans(t *testing.T) {
if ch, _ := reg.Lookup("sub/in"); ch.Owner != "" {
t.Fatalf("销毁后 inputch 应回到未分配:%+v", ch)
}
// 父的入站 inputchchild/<id>)必须在销毁时一并注销,否则登记表残留脏数据——
// HomeAgent 实测destroy 后 child/<id> 仍挂在根 agent 名下,且无工具可单独注销。
if inbound, ok := reg.Lookup("child/child-1"); ok {
t.Fatalf("销毁后父的入站 inputch 应被注销,实际残留:%+v", inbound)
}
if err := parent.DestroyResident("child-1"); err == nil {
t.Fatal("重复销毁应报错")
}
@ -159,6 +164,9 @@ func TestResident_LifecycleAndNoOrphans(t *testing.T) {
if _, err := osStat(filepath.Join(dir, "residents", id)); err == nil {
t.Fatalf("子 %s 的 temp 目录应被丢弃", id)
}
if _, ok := reg.Lookup(inboundChannelName(id)); ok {
t.Fatalf("父退出后子 %s 的入站 inputch 应被注销", id)
}
}
}

View File

@ -229,6 +229,10 @@ type SchedulerStats struct {
Executed uint64
// Rejected 是因队列满(或深度超限)而未被接纳的次数。
Rejected uint64
// Backpressure 是就绪队列满、输入被挡回 channel 的次数
// (设计 §4.4 / §11.4 Q4满时阻塞发送方**必须计数并打日志**)。
// 与 Rejected 的区别Rejected 是「丢了」Backpressure 是「暂时不收、发送方在等」。
Backpressure uint64
// Suspended / Resumed 是挂起与恢复的次数。
// 不变量:系统排空后 Suspended == Resumed挂起必然被恢复
// 因此两者各自只在**一处**计数suspend / resumeTask
@ -284,7 +288,13 @@ func (a *Agent) schedulerStatus() sdk.SchedulerStatus {
Rejected: snap.Stats.Rejected,
Suspended: snap.Stats.Suspended,
Resumed: snap.Stats.Resumed,
Preempted: snap.Stats.Suspended,
Backpressure: snap.Stats.Backpressure,
}
// Preempted 是「各级抢占成功次数之和」,**不是** Suspended受害者可能在
// 让位信号生效前就自行结束,此时有抢占而没有挂起(见 PreemptsByLevel 注释)。
// 此前这里直接拿 Suspended 顶替,导致 DTO 里 preempted 与 preempts_by_level 自相矛盾。
for lv := LevelBackground; lv <= LevelCritical; lv++ {
out.Preempted += snap.Stats.PreemptsByLevel[lv]
}
if snap.Running != nil {
out.Running = &sdk.SchedulerTask{
@ -320,6 +330,9 @@ type scheduler struct {
// critical 报告运行任务是否在不可抢占临界区(如记忆整理)。
// 由于 interceptLoop 要读它,必须是原子的:帧仍只由调度器读写。
critical atomic.Bool
// backpressured 记录「就绪队列满」这一状态的翻转,用于只打一次日志。
// 满着的时候 pumpInbox 每轮都会走到,逐轮打日志会把日志刷爆。
backpressured bool
// wake 用于把空闲的调度器叫醒pendingInterrupts 不是 channel
// 没有这个信号时“空闲时到达的中断”会一直等下一次输入(设计 §5.1 ③)。
wake chan struct{}
@ -381,6 +394,28 @@ func (s *scheduler) hasRoom() bool {
return len(s.queue) < s.maxQueue
}
// noteBackpressure 记一次背压,并报告这是否是「从有空间 → 满」的翻转。
//
// 为什么需要翻转信息:满的时候每轮泵入都会调用本函数,逐轮打日志会刷爆;
// 而设计 §4.4 要求「必须计数并打日志」——两者靠这个布尔量同时满足。
func (s *scheduler) noteBackpressure() bool {
s.mu.Lock()
defer s.mu.Unlock()
s.stats.Backpressure++
if s.backpressured {
return false
}
s.backpressured = true
return true
}
// clearBackpressure 在就绪队列重新可收(泵空)时复位翻转标记。
func (s *scheduler) clearBackpressure() {
s.mu.Lock()
defer s.mu.Unlock()
s.backpressured = false
}
// allocateIDLocked 分配任务 ID 与入队时刻(调用方持锁)。
func (s *scheduler) allocateIDLocked(t *Task) {
s.seq++
@ -490,10 +525,22 @@ func (s *scheduler) interruptCountLocked() int {
// setImmediateLocked 登记一个应“立即运行”的抢占者。
//
// 槽只有一格:若已有抢占者且新的级别更高,旧的降级入队;否则新的入队。
func (s *scheduler) setImmediateLocked(t *Task) {
// 返回 true 表示 t **确实占住了 immediate 槽**false 表示它被降级进了自己的
// 级别队列immediate 是单槽,这是设计要求的降级分支,见设计 §2「至多一个」
//
// 调用方必须用返回值决定是否计入 PreemptsByLevel那条计数器的语义是
// 「进入 immediate 的次数」,被降级的中断从未进过 immediate。
// setImmediateLocked 尝试把 t 放进 immediate 槽。
//
// 返回 truet 已占住 immediate若原有抢占者被顶掉它**已被**降级入队)。
// 返回 falset 没有进 immediate且本函数**未动 t** —— 调用方负责按级别入队。
//
// 把「降级入队」的责任留给调用方,是为了让「到底入队了几次」只有一个出口:
// 早先由本函数在返回 false 前自行入队,调用方又照着 false 再入一次,
// 同一任务就会在队列里出现两份实测中断任务被执行两次、Executed 虚高)。
func (s *scheduler) setImmediateLocked(t *Task) bool {
if s.immediate != nil && effectiveLevel(t) <= effectiveLevel(s.immediate) {
s.enqueueInterruptLocked(t)
return
return false
}
if s.immediate != nil {
s.enqueueInterruptLocked(s.immediate)
@ -501,6 +548,7 @@ func (s *scheduler) setImmediateLocked(t *Task) {
s.allocateIDLocked(t)
s.stats.Enqueued++
s.immediate = t
return true
}
func removeTask(list []*Task, target *Task) []*Task {
@ -571,11 +619,16 @@ func (s *scheduler) registerInterrupt(t *Task) bool {
arm := false
if !critical && canPreempt(t, running) {
if running.LastPreemptAt.IsZero() || time.Since(running.LastPreemptAt) >= preemptCooldown {
arm = true
s.preemptArmed = true
s.preemptLevel = t.Level
s.stats.bumpInterruptLevel(&s.stats.PreemptsByLevel, t.Level)
s.setImmediateLocked(t)
// 只有**真的占住 immediate 槽**才算一次抢占,才计入 PreemptsByLevel
// immediate 是单槽若它被另一个更高级的抢占者占着t 会走上而下的
// 「否则入队」分支——那种情况 t 从未进入 immediate否则同一安全点前
// 到达两条同级中断时该计数会高估)。
if s.setImmediateLocked(t) {
arm = true
s.preemptArmed = true
s.preemptLevel = t.Level
s.stats.bumpInterruptLevel(&s.stats.PreemptsByLevel, t.Level)
}
}
}
if !arm {
@ -606,6 +659,54 @@ func (s *scheduler) clearPreempt() {
s.mu.Unlock()
}
// rearmPending 在**安全点重新求值**中断队列(设计 §4.3 / §5.2)。
//
// 为什么必须有这一步:中断只在 registerInterrupt 里被武装一次,而那一刻运行任务
// 可能正在临界区S_TOOL_EXEC / ONNX / CAS或处于抢占冷却期于是请求只能入队。
// 若安全点不再回头看队列,它就永远等不到执行——只能等当前任务**自然结束**
// 这违背设计承诺的「临界区期间到达的抢占请求……在临界区结束后的第一个安全点
// 重新求值」。可复现症状WebUI 终止按钮连按两次,第二次(落在 2s 冷却窗内)
// 入队后再也不会被求值,「终止」看起来没反应。
//
// 判据与 registerInterrupt **完全同一套**canPreempt + 冷却 + 临界区闸门),
// 因此不会凭空制造设计之外的抢占。
func (s *scheduler) rearmPending() {
s.mu.Lock()
defer s.mu.Unlock()
// 已有让位信号、或 immediate 槽已被占用:下一个安全点的选择已经在路上,
// 不必(也不该)重复武装。
if s.preemptArmed || s.immediate != nil || s.running == nil || s.critical.Load() {
return
}
// 冷却期内不武装:与 registerInterrupt 同一判据(抗饥饿)。
if !s.running.LastPreemptAt.IsZero() && time.Since(s.running.LastPreemptAt) < preemptCooldown {
return
}
// 中断队列本就按级别组织:从最高级往下找第一条能抢占的队头。
// (队列里的任务有效级恒等于基础级,故「第一条能抢」= 最高级可抢占者。)
for lv := LevelCritical; lv >= LevelBackground; lv-- {
q := s.interruptQueues[lv]
if len(q) == 0 {
continue
}
t := q[0]
if !canPreempt(t, s.running) {
continue
}
s.popInterruptLocked(lv)
if s.setImmediateLocked(t) {
s.preemptArmed = true
s.preemptLevel = t.Level
s.stats.bumpInterruptLevel(&s.stats.PreemptsByLevel, t.Level)
} else {
// immediate 槽没拿到(理论上进不来,顶部已判 immediate == nil放回队列
// 否则任务会凭空消失。
s.enqueueInterruptLocked(t)
}
return
}
}
// suspend 保存现场。
//
// 深度上界是**结构推论**= 中断级数),不是配置项:安全点上的 canSuspend 已提前
@ -638,6 +739,37 @@ func (s *scheduler) canSuspend() bool {
return len(s.suspendStack) < s.maxInterruptFrames
}
// pendingEvents 收集**尚未执行**(排队队列 / 四条中断队列 / immediate
// **已挂起**(中断栈)任务所携带的、且带同步回执通道的输入事件。
//
// 用途只有一个:停机收尾。这些任务不会再被调度,若不给它们补终态,
// 无超时的同步注入方cli / clawhubadapter会永久挂起设计 §7 I5、§11.3 X2/X4
func (s *scheduler) pendingEvents() []*agentIO.InputEvent {
s.mu.Lock()
defer s.mu.Unlock()
var out []*agentIO.InputEvent
add := func(t *Task) {
if t != nil && t.Event != nil && t.Event.ResponseCh != nil {
out = append(out, t.Event)
}
}
for _, t := range s.queue {
add(t)
}
for lv := LevelBackground; lv <= LevelCritical; lv++ {
for _, t := range s.interruptQueues[lv] {
add(t)
}
}
add(s.immediate)
for _, f := range s.suspendStack {
if f != nil {
add(f.Task)
}
}
return out
}
// done 标记任务执行结束。
func (s *scheduler) done(t *Task) {
s.mu.Lock()
@ -796,9 +928,11 @@ func (a *Agent) schedulerLoop() {
// 无待办阻塞等新输入、新中断wake或退出。
select {
case evt := <-a.io.InputChan():
a.sched.enqueue(newInputTask(evt))
if !a.sched.enqueue(newInputTask(evt)) {
a.emitSkippedReply(evt, "queue_full")
}
case msg := <-a.selfInputCh:
a.sched.enqueue(newSelfTask(msg))
_ = a.sched.enqueue(newSelfTask(msg))
case <-a.sched.wake:
// 中断已入 pendingInterrupts回到循环顶部重新挑选。
case <-a.ctx.Done():
@ -825,15 +959,28 @@ func (a *Agent) pumpInbox() {
for a.sched.hasRoom() {
select {
case evt := <-a.io.InputChan():
a.sched.enqueue(newInputTask(evt))
// 返回值必须处理:静默丢弃会让同步调用方永久挂起(回执路径 E
if !a.sched.enqueue(newInputTask(evt)) {
a.sched.noteBackpressure()
a.emitSkippedReply(evt, "queue_full")
}
case msg := <-a.selfInputCh:
a.sched.enqueue(newSelfTask(msg))
// 自循环输入没有同步调用方,满时记一次背压即可。
if !a.sched.enqueue(newSelfTask(msg)) {
a.sched.noteBackpressure()
}
case <-a.ctx.Done():
return
default:
a.sched.clearBackpressure()
return
}
}
// 队列满:输入留在 channel 里,发送方阻塞(设计 §4.4「阻塞发送方」)。
// 必须计数并打日志——否则运维看到 Rejected=0 会以为没背压,而输入正卡在 channel。
if a.sched.noteBackpressure() {
log.Printf("[agent] ready queue full (%d), input channel backpressured", a.sched.maxQueue)
}
}
// executeTask 执行一个任务(测试与旧调用方的入口);见 executeNewTask。

View File

@ -0,0 +1,186 @@
package core
// 回归测试:安全点「重新求值」、抢占计数语义、停机补终态、背压计数。
//
// 对照设计稿原文修正的四条:
//
// 1. §4.3/§5.2 —— 临界区期间到达的抢占请求「不丢失:按级别进入中断队列,
// 在**临界区结束后的第一个安全点重新求值**」。实现里此前没有这一步:
// 唯一的武装点是 registerInterrupt凡被拦成「入队」的中断只能等当前任务
// **自然结束**。可复现症状WebUI 终止按钮连按两次,第二次落在 2s 抢占冷却
// 窗内 → 入队 → 再也不会被求值,「终止」看起来没反应。
// 2. SchedulerStats.PreemptsByLevel 的语义是「判定可抢占**并进入 immediate** 的
// 次数」;此前在 setImmediateLocked 之前就计数,于是同一安全点前到达的两条同级
// 中断里、被降级入队的那条也被计入immediate 是单槽,降级是设计要求的路径)。
// 3. 状态面 Preempted 此前直接拿 Stats.Suspended 顶替,与 preempts_by_level 自相矛盾。
// 4. §4.4 / §11.4 Q4 —— 就绪队列满必须「阻塞发送方 + **计数并打日志**」。
import (
"testing"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
// 被冷却拦成入队的中断,在冷却期满后的第一个安全点必须被重新武装。
//
// 这是「终止按钮连按两次」的最小复现:第一次抢占成功(受害者进入 2s 冷却),
// 第二次在冷却窗内只能入队——修复前它就永远等不到执行了。
func TestRearm_CooldownExpiryPromotesQueuedInterrupt(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
victim := &Task{ID: 1, Class: TaskInterrupt, Level: LevelBackground, EnqueuedAt: time.Now()}
a.sched.immediate = victim
a.sched.nextRef() // running = victim
// 模拟「刚被抢占过」:冷却起点就在此刻,且抢占提升已生效(有效级 L2
victim.PreemptCount = 1
victim.LastPreemptAt = time.Now()
evt, _ := textEvent("cli", "第二次终止")
evt.Payload["interrupt"] = true
if a.sched.requestPreempt(evt, LevelCritical) {
t.Fatal("抢占冷却期内不得抢占(应入队)")
}
if got := a.sched.stats.PreemptsByLevel[LevelCritical]; got != 0 {
t.Fatalf("被冷却拦成入队的中断不得计入抢占数,实际 %d", got)
}
if n := len(a.sched.interruptQueues[LevelCritical]); n != 1 {
t.Fatalf("应恰好入队一条,实际 %d>1 说明入队路径重复)", n)
}
// 冷却期满 → 安全点的「重新求值」必须把它武装起来(修复前缺失的正是这一步)。
victim.LastPreemptAt = time.Now().Add(-3 * time.Second)
a.sched.rearmPending()
if !a.sched.preemptGrantedFor() {
t.Fatal("冷却期结束后应重新武装让位信号(设计 §4.3「第一个安全点重新求值」)")
}
snap := a.DumpScheduler()
if snap.Immediate == nil {
t.Fatal("重新求值后应把该中断提升进 immediate 槽")
}
if got := snap.Stats.PreemptsByLevel[LevelCritical]; got != 1 {
t.Fatalf("真正占住 immediate 才能计一次抢占,实际 %d", got)
}
// 重新求值不该把任务复制一份队列必须空、immediate 恰好一条。
if n := len(snap.InterruptQueues[LevelCritical]); n != 0 {
t.Fatalf("提升后 L4 队列应空,实际 %d", n)
}
}
// 同一安全点前到达的两条同级中断immediate 是单槽,第二条只能降级入队;
// 它**没有**进入 immediate因此不得计入 PreemptsByLevel也不得被入队两次。
func TestRearm_SameLevelSecondPreempterIsQueuedNotCounted(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
victim := &Task{ID: 1, Class: TaskQueued, EnqueuedAt: time.Now()}
a.sched.immediate = victim
a.sched.nextRef() // running = 排队任务(有效级 0任何中断都能抢
e1, _ := textEvent("cli", "irq-1")
if !a.sched.requestPreempt(e1, LevelInteractive) {
t.Fatal("第一条 L3 应抢占排队任务")
}
e2, _ := textEvent("cli", "irq-2")
a.sched.requestPreempt(e2, LevelInteractive) // 同级 → 降级入队
snap := a.DumpScheduler()
if got := snap.Stats.PreemptsByLevel[LevelInteractive]; got != 1 {
t.Fatalf("被降级的同级第二条不得计入抢占数(期望 1实际 %d", got)
}
if n := len(snap.InterruptQueues[LevelInteractive]); n != 1 {
t.Fatalf("被降级的那条应在 L3 队列里**恰好**出现一次,实际 %d", n)
}
if snap.Immediate == nil {
t.Fatal("第一条应留在 immediate 槽,两条都不能丢")
}
}
// 状态面 Preempted 必须是「各级抢占数之和」,不能拿 Suspended 顶替。
func TestStatus_PreemptedEqualsSumOfLevels(t *testing.T) {
a := New(AgentConfig{ID: "rt-sum", ProviderManager: agentAPI.NewProviderManager(), IO: agentIO.NewIOManager()})
if a.sched == nil {
t.Fatal("agent 应带调度器")
}
a.sched.mu.Lock()
a.sched.stats.PreemptsByLevel[LevelBackground] = 2
a.sched.stats.PreemptsByLevel[LevelInteractive] = 3
a.sched.stats.Suspended = 99 // 故意与抢占数不等
a.sched.mu.Unlock()
got := a.schedulerStatus()
if got.Preempted != 5 {
t.Fatalf("Preempted 应为各级抢占数之和 5实际 %d拿 Suspended 顶替会得 99", got.Preempted)
}
if got.Suspended != 99 {
t.Fatalf("Suspended 应原样透传,实际 %d", got.Suspended)
}
}
// 停机必须给「从未运行」与「已挂起」的同步任务补终态,
// 否则 cli / clawhubadapter 这类无超时的同步注入方会永久挂起(设计 §7 I5、§11.3 X2/X4
func TestStop_DrainsPendingSyncTasks(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
queuedEvt, queuedCh := textEvent("cli", "排队中,永远不会被调度")
if !a.sched.enqueue(newInputTask(queuedEvt)) {
t.Fatal("入队失败")
}
suspEvt, suspCh := textEvent("cli", "已挂起,停机时不会恢复")
a.sched.suspend(
&Task{ID: 2, Class: TaskInterrupt, Level: LevelInteractive, EnqueuedAt: time.Now(), Event: suspEvt},
a.newTaskFrame("挂起", a.stageCtxFromInput("挂起", "", "")),
)
a.Stop()
for name, ch := range map[string]chan *agentIO.OutputEvent{"排队": queuedCh, "挂起": suspCh} {
select {
case r := <-ch:
if r == nil || r.Payload["skipped"] != true {
t.Fatalf("%s 任务停机时应补 skipped 终态,实际 %+v", name, r)
}
case <-time.After(2 * time.Second):
t.Fatalf("%s 任务停机未补终态(同步调用方会永久挂起)", name)
}
}
}
// 背压计数:持续满只报一次「翻转」不发生;计数本身每次都要累加。
func TestBackpressure_CounterAndTransition(t *testing.T) {
s := newScheduler(1)
if !s.noteBackpressure() {
t.Fatal("首次背压应报告「翻转」")
}
if s.noteBackpressure() {
t.Fatal("持续背压不得重复报告翻转(否则日志会被刷爆)")
}
if s.stats.Backpressure != 2 {
t.Fatalf("背压计数应为 2实际 %d", s.stats.Backpressure)
}
s.clearBackpressure()
if !s.noteBackpressure() {
t.Fatal("队列恢复后再满应再次报告翻转")
}
}
// 集成:就绪队列满时 pumpInbox 必须计一次背压Rejected 保持 0——背压不是丢弃
func TestBackpressure_PumpInboxCountsWhenFull(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
a.sched.maxQueue = 1
a.io.InjectInput("cli", "text", map[string]interface{}{"content": "第一条"})
a.io.InjectInput("cli", "text", map[string]interface{}{"content": "第二条"})
a.pumpInbox()
snap := a.DumpScheduler()
if len(snap.Queue) != 1 {
t.Fatalf("maxQueue=1 时队列应恰好 1 条,实际 %d", len(snap.Queue))
}
if snap.Stats.Backpressure == 0 {
t.Fatal("队列满必须计一次背压(设计 §4.4/Q4阻塞发送方 + 计数)")
}
if snap.Stats.Rejected != 0 {
t.Fatalf("背压不是丢弃Rejected 必须保持 0实际 %d", snap.Stats.Rejected)
}
}

View File

@ -178,6 +178,10 @@ func (a *Agent) runTaskSteps(f *TaskFrame) stepOutcome {
for i := 0; i < maxSteps; i++ {
// 安全点:只在 step 之间检查让位。临界区StepToolExec不在此列
// 因为让位信号由 interruptLoop 置位、而本循环是唯一读帧者。
//
// 先「重新求值」再判让位:临界区(或抢占冷却期)内被拦成入队的中断,
// 必须在这里重新武装——否则它只能等当前任务自然结束(设计 §4.3/§5.2)。
a.sched.rearmPending()
if !isCriticalChannel(f.OutputChannel) && a.sched.preemptGrantedFor() && a.sched.canSuspend() {
return outcomeSuspended
}

View File

@ -411,7 +411,12 @@ func (b *Bridge) readLoop() {
_ = ws.writePong()
continue
}
// 超时或其他错误,退出
// 超时或其他错误,退出
//
// **必须记日志**:此前这里静默 return设备断线的真因读超时 / 对端
// 关闭 / 帧错)在设备侧完全不可见,只能靠对端日志倒推。
// 2 倍 ping 间隔内的读超时通常是“心跳没人回”——查服务端 writePong 是否真发出。
log.Printf("[devicebridge] read loop exit (opcode=%#x, close=%v): %v", opcode, isClose, err)
return
}
if isClose {

View File

@ -41,7 +41,11 @@ var (
// 注入标志位、中断优先级、事件订阅、动态输出通道注销补进 Lua 侧
// (此前只在 Go 侧存在而文档宣称“完全对齐”)。公开 Go SDK 接口
// 零变更,故 SDK 保持 1.3.0。
Version = "1.3.11"
// 1.3.12:修 1.3.11 引入的两个真问题 —— ① 驻留子销毁后残留入站 inputch
// child/<id>(改用纯函数名并 Unregister覆盖 destroy/reclaim/StopResidents
// ② sdk.events.subscribe 用了从未注入的公共 Events(),且订阅生命周期
// 管理会自死锁/use-after-close改用内部 Subscribe + 独立 subsMu + Stop 取消)。
Version = "1.3.12"
// Commit 是构建时的 Git commit hash。
Commit = "unknown"

View File

@ -10,9 +10,9 @@ import (
"strings"
"sync"
agentEvents "gitcode.com/JianFeeeee/HomeAgent/internal/events"
luaSDK "gitcode.com/JianFeeeee/HomeAgent/internal/lua/sdk"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
lua "github.com/yuin/gopher-lua"
)
@ -42,7 +42,16 @@ type luaPlugin struct {
stages map[sdk.Stage]*stageReg
outputChs map[string]*outputChReg
inputDefs map[string]sdk.ChannelDef
mu sync.Mutex
// subs 是本插件注册的事件订阅取消函数Stop 时兜底取消,
// 避免 L 已 Close 后残留回调被触发use-after-close
// 用独立的 subsMu 而非 musubscribe 会在 Lua 的 start 回调里被调,
// 而 Start 正持着 mu —— 用 mu 就是不可重入的自死锁。
subs []func()
subsMu sync.Mutex
// closed 在 Stop 里置位(持 mu事件回调持 mu 后先查它,
// 防止“回调已通过取消订阅检查、但等锁期间 L 被 Close”的竞态。
closed bool
mu sync.Mutex
}
func newLuaPlugin(luaPath, name string) (*luaPlugin, error) {
@ -791,20 +800,25 @@ func replaceSDKReal(L *lua.LState, t *lua.LTable, plg *luaPlugin, s *sdk.PluginS
return pushVal([]interface{}{})
}))
// ---- sdk.events.*(只读事件订阅,与外部插件的 Events() 对齐----
// 回调在内核事件发布 goroutine 上执行必须只做轻量转发Lua 单状态 + 互斥锁);
// 阻塞会卡死本插件的全部调用。返回一个取消订阅函数。
// ---- sdk.events.*(只读事件订阅)----
//
// 用内部 SDK 的 Subscribe内置插件用的是同一条路径
// 不用公共 SDK 的 Events()——那个 subscriber 在本内核里从未被注入
// SetEventSubscriber 无调用点),拿到的永远是 nil。
//
// 回调用内核事件发布 goroutine 上执行必须只做轻量转发Lua 单状态 + 互斥锁);
// 阻塞会卡死本插件的全部调用。返回一个取消订阅函数,并在 Stop 时兜底取消
// (否则插件停掉/重载后 L 已 Close残留回调再触发就是 use-after-close
evTbl := subTable("events")
evTbl.RawSetString("subscribe", L.NewFunction(func(L *lua.LState) int {
eventType := L.CheckString(1)
fn := L.CheckFunction(2)
sub := s.Events()
if sub == nil {
return pushErr(fmt.Errorf("events unavailable"))
}
unsub := sub.Subscribe(pubsdk.EventType(eventType), func(evt *pubsdk.Event) {
unsub := s.Subscribe(agentEvents.EventType(eventType), func(evt *agentEvents.Event) {
plg.mu.Lock()
defer plg.mu.Unlock()
if plg.closed {
return
}
L2 := plg.L
tbl := L2.NewTable()
tbl.RawSetString("type", lua.LString(string(evt.Type)))
@ -817,8 +831,11 @@ func replaceSDKReal(L *lua.LState, t *lua.LTable, plg *luaPlugin, s *sdk.PluginS
fmt.Printf("[lua-plugin/%s] event handler error: %v\n", plg.name, err)
}
})
plg.subsMu.Lock()
plg.subs = append(plg.subs, unsub)
plg.subsMu.Unlock()
L.Push(L.NewFunction(func(L *lua.LState) int {
unsub()
unsub() // 事件总线的取消订阅是幂等的(重复调用只会匹配不到)
return 0
}))
L.Push(lua.LNil)
@ -826,18 +843,31 @@ func replaceSDKReal(L *lua.LState, t *lua.LTable, plg *luaPlugin, s *sdk.PluginS
}))
// ---- sdk.plugin_mgr.*(插件管理,与外部插件的 PluginMgrAPI 对齐)----
// PluginMgr 可能未装配(如部分单测的 SDK 构造),此时返回"不可用"而不是 panic。
pmTbl := subTable("plugin_mgr")
pmTbl.RawSetString("reload_one", L.NewFunction(func(L *lua.LState) int {
if err := s.PluginMgr().ReloadOne(L.CheckString(1)); err != nil {
pm := s.PluginMgr()
if pm == nil {
return pushErr(fmt.Errorf("plugin manager unavailable"))
}
if err := pm.ReloadOne(L.CheckString(1)); err != nil {
return pushErr(err)
}
return pushNil()
}))
pmTbl.RawSetString("list_loaded", L.NewFunction(func(L *lua.LState) int {
return pushList(s.PluginMgr().ListLoadedPlugins())
pm := s.PluginMgr()
if pm == nil {
return pushList([]interface{}{})
}
return pushList(pm.ListLoadedPlugins())
}))
pmTbl.RawSetString("is_disabled", L.NewFunction(func(L *lua.LState) int {
return pushVal(s.PluginMgr().IsPluginDisabled(L.CheckString(1)))
pm := s.PluginMgr()
if pm == nil {
return pushVal(false)
}
return pushVal(pm.IsPluginDisabled(L.CheckString(1)))
}))
}
@ -1207,8 +1237,21 @@ func (p *luaPlugin) Start(s *sdk.PluginSDK) error {
}
func (p *luaPlugin) Stop() error {
// ① 先取消事件订阅。**不持 p.mu**Bus.Publish 持总线锁回调 handler
// 而 handler 要 p.mu若此处持 p.mu 再取总线锁,就是锁序反转死锁。
p.subsMu.Lock()
subs := p.subs
p.subs = nil
p.subsMu.Unlock()
for _, unsub := range subs {
unsub()
}
// ② 置 closed 并关 L。置位在持锁下完成已进入但等锁的 event 回调
// 拿到锁后会先看到 closed 而直接返回,不会碰已关的 L。
p.mu.Lock()
defer p.mu.Unlock()
p.closed = true
if p.tbl != nil {
fn := p.tbl.RawGetString("stop")

View File

@ -6,6 +6,7 @@ import (
"testing"
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
lua "github.com/yuin/gopher-lua"
)
@ -597,3 +598,66 @@ return plugin
t.Errorf("llm_text writeback: got %q, want %q", sc2.LLMText, "模型输出[尾部标记]")
}
}
// TestLuaEventsSubscribeAndStopCleanup 覆盖 sdk.events.subscribe
// 1. 订阅真的能收到内核事件(走内部 SDK 的 Subscribe不是永远为 nil 的公共 Events()
// 2. Stop 会取消订阅,之后 Publish 不得再触碰已 Close 的 LState。
func TestLuaEventsSubscribeAndStopCleanup(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{"name":"evlua","entry":"main.lua"}`), 0644)
os.WriteFile(filepath.Join(dir, "main.lua"), []byte(`
local plugin = { name = "evlua" }
function plugin.start(sdk)
_G.hits = 0
local unsub, err = sdk.events.subscribe("agent_output", function(evt)
_G.hits = _G.hits + 1
_G.last_type = evt.type
_G.last_source = evt.source
end)
_G.sub_err = err
_G.unsub_type = type(unsub)
end
function plugin.stop() end
return plugin
`), 0644)
plg, err := tryLoadLua(dir, "evlua", nil)
if err != nil {
t.Fatalf("tryLoadLua failed: %v", err)
}
lp := plg.(*luaPlugin)
bus := events.NewBus()
reg := internalConfig.NewConfigRegistry("")
sett := sdk.NewSettings("evlua", reg)
s := sdk.New("evlua", sdk.SDKConfig{EventBus: bus, Settings: sett})
if err := plg.Start(s); err != nil {
t.Fatalf("Start failed: %v", err)
}
L := lp.L
if errStr := L.GetGlobal("sub_err").String(); errStr != "nil" {
t.Fatalf("subscribe returned error: %s", errStr)
}
if got := L.GetGlobal("unsub_type").String(); got != "function" {
t.Fatalf("subscribe should return an unsubscribe function, got %s", got)
}
bus.Publish(&events.Event{Type: events.EventAgentOutput, Source: "test-src"})
if hits := int(lua.LVAsNumber(L.GetGlobal("hits"))); hits != 1 {
t.Fatalf("event handler hits = %d, want 1", hits)
}
if got := L.GetGlobal("last_source").String(); got != "test-src" {
t.Fatalf("event source = %q, want test-src", got)
}
// Stop 取消订阅 + 关 L此后再 Publish 不得 panic / use-after-close。
if err := plg.Stop(); err != nil {
t.Fatalf("Stop failed: %v", err)
}
bus.Publish(&events.Event{Type: events.EventAgentOutput, Source: "after-stop"})
}

View File

@ -0,0 +1,53 @@
package remotedevice
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
// 心跳回包必须**真的发出去**pong 只有两个字节,且设备空闲时没有任何别的写
// 会顺带把 bufio 缓冲刷出去——`writePong` 一旦忘了 Flushpong 就永远留在
// 服务端缓冲里。
//
// 这就是「device channel 不稳定」的真因(实测):客户端每 30s 发一个 ping
// 服务端算好了 pong 却没发;客户端的读循环设的是 2 倍 ping 间隔(默认 60s
// 读超时,于是**每 60 秒准点断开一次**,重连后 outputch 被注销又注册,
// 模型侧看到的就是工具/通道凭空消失又出现。
//
// 本用例只发一个 ping随后**什么都不发**pong 必须在无后续流量的情况下到达。
func TestWSPingGetsPongWhileIdle(t *testing.T) {
reg := NewRegistry()
token := "test-token-ping"
reg.SetAcceptToken(func(provided string) bool { return provided == token })
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
defer srv.Close()
cli := dialTestWS(t, srv.URL, token)
defer cli.close()
// 先走完 hello + bind服务端要先把设备登记进 connspong 才写得回来)。
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"ping-dev","name":"前端机","kind":"computer","caps":["cmd"]}}`))
cli.readHelloAckAndBind(t, token)
cli.sendFrame(0x9, nil) // ping
if err := cli.conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
t.Fatalf("set read deadline: %v", err)
}
payload, isClose, opcode, err := readFrame(cli.rw.Reader)
if err != nil {
t.Fatalf("2s 内没收到 pongwritePong 忘了 Flush: %v", err)
}
if isClose {
t.Fatal("连接被关闭,而不是回了 pong")
}
if opcode != 0xa {
t.Fatalf("期望 pong(0xa),实际 opcode=%#x payload=%q", opcode, payload)
}
if len(payload) != 0 {
t.Fatalf("pong 不该带负载,实际 %q", payload)
}
}

View File

@ -606,7 +606,13 @@ func writeFrame(w *bufio.Writer, opcode byte, payload []byte) error {
}
func writePong(w *bufio.Writer) error {
return writeFrameHeader(w, 0xa, 0)
// 必须走 writeFrame它 Flush
//
// 回归的 bug这里原先是裸的 writeFrameHeader**不 Flush**。设备空闲时
// 没有任何别的写会顺带把 bufio 缓冲刷出去,于是 pong 永远留在服务端缓冲里,
// 客户端等 2 倍 ping 间隔(默认 30s×2 = 60s读超时断开、重连——
// 实测表现就是「设备通道每 60 秒掉线一次」,连带着 outputch 反复注销/注册。
return writeFrame(w, 0xa, nil)
}
func writeFrameHeader(w *bufio.Writer, opcode byte, length int) error {

View File

@ -68,7 +68,11 @@ type SchedulerStatus struct {
Rejected uint64 `json:"rejected"`
Suspended uint64 `json:"suspended"`
Resumed uint64 `json:"resumed"`
// Preempted = Σ PreemptsByLevel[1..4],即「真正抢占成功」的次数。
// 它与 Suspended 不等价(受害者可能先自行结束),因此不是 Suspended 的别名。
Preempted uint64 `json:"preempted"`
// Backpressure 是就绪队列满、输入被挡回 channel 的次数(暂时不收,不是丢弃)。
Backpressure uint64 `json:"backpressure"`
}
// SchedulerTask 是任务的最小标识(不暴露帧内容)。