mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 09:58:06 +00:00
fix(proc): 子进程崩溃自愈 + 集中台账 + 注册面摘除
根因:子进程插件被 kill 后,内核只发了一个无人订阅的事件, 工具/stage handler/IO 通道全留在注册表里指向死进程, 模型继续调用只吃 ErrProcessExited,没有任何路径把插件拉回来。 ## 四层修复 ### 1. 专职 waitLoop(进程收割) - 每个子进程配一根 waitLoop goroutine,是 cmd.Wait() 的唯一调用点 - 不再依赖 stdout EOF 判定死亡(孙子进程继承 stdout 时 EOF 永不到来) - 手工 os.Pipe 替代 cmd.StdinPipe/StdoutPipe,避免 waitLoop 与 os/exec 的内部关闭竞争 - host.go: Host.Supervisor(),Host.Close() 先 StopAll 再拆段 ### 2. 集中台账 Supervisor - proc/supervisor.go: 插件 Spawn 握手成功即 track,进程退出即 untrack - StopAll: 并发发 plugin.stop 走优雅路径,到期仍在的一律 Kill - 关停后才完成握手的进程被立即结束,不会活过内核 - 消除「孤儿进程持共享段映射 → SIGBUS」的隐患 ### 3. 注册面摘除(detachPlugin) - 新增 StageHost.UnregisterPluginStages:摘除指定插件的全部 stage handler - 新增 Registry.pluginChannels 台账:记录每个插件注册的 IO 通道 - 三条路径统一走 detachPlugin:Disable / ReloadOne / RemovePlugin - StopAndUnload 漏了 IO 通道也一并补上 ### 4. 自动重启 - onProcCrash 从「只发事件」改为「摘注册面 → 从注册表移除 → 异步排重启」 - scheduleProcRestart: 窗口 5 分钟内最多 3 次,线性退避 1s/2s/3s - 超限停手留日志;重启前复核是否已被 Disable 或被其他路径加载 - 崩溃计数窗口过期自动归零 ### 5. 主动停止 vs 崩溃的区分 - proc.Plugin 新增 stopping 标志:Stop()/Close() 里 Set(true) - handleExit 读 stopping 标志,主动停止不上报 onCrash - 防止重载/禁用/卸载被误判为崩溃触发多余重启 ### 6. Linux Pdeathsig 兜底 - procattr_linux.go: SysProcAttr.Pdeathsig = SIGKILL - 兜 homed 自身被 SIGKILL/OOM 时子进程变孤儿的场景 - macOS/Windows 无等价物,空实现 ### 7. pluginmgr 升级 - PluginManager 接口新增 PluginRuntime / ListPluginRuntimes - plugin_list 输出运行态:loaded / alive / pid / crash_count / channel - 新增 plugin_status: 全量运行期快照 + dead/unhealthy 汇总 - 新增 plugin_restart: 无条件重启单个插件(plgreload 不动未改二进制的插件) ### 测试 - process_test.go: 3 例(grandchild stdout 感知 / Supervisor track-untrack / StopAll 无孤儿) - crash_recovery_test.go: 8 例(detach 三项齐全 / 通道重注册 / 崩溃不阻塞 / 退避阈值 / 窗口过期 / 关停中跳过 / PluginRuntime 通道识别) - stages_plugin_test.go: 4 例(stage 按插件摘除 / 空 stage 清理 / 空名 no-op / 工具+stage 双摘后可重新注册同名)
This commit is contained in:
@ -129,23 +129,126 @@ func (r *Registry) closeProcHost() {
|
||||
// **崩溃隔离**:子进程死亡只影响自己,homed 继续服务——对比 C ABI 下
|
||||
// 插件 panic 直接带崩整个进程(§1.2,现网已发生)。
|
||||
//
|
||||
// 崩溃计数/冷却/自愈复用既有 plugin_health(§2.3),本函数只负责把
|
||||
// 进程退出这一事实转成事件通知;具体重载策略由 agent 侧决定。
|
||||
// 但「homed 没崩」不等于「内核状态干净」。此前本函数只发了一个事件,
|
||||
// 而全仓没有任何订阅者,于是生产上出现过 editdoc 被 kill 后:
|
||||
// - `edit_document` 仍留在 StageHost 的工具表里,模型照旧看得到、照旧调用,
|
||||
// 每次都吃到 `proc: 插件进程已退出`;
|
||||
// - 该插件的 stage handler 仍在每轮 RunStage 里被并发调起并失败;
|
||||
// - 没有任何路径把它拉回来,插件永久缺席直到重启 homed。
|
||||
//
|
||||
// 所以崩溃回调必须做三件事:摘注册面、喂健康计数、排一次重启。
|
||||
func (r *Registry) onProcCrash(name string, err error) {
|
||||
log.Printf("[plugin] 子进程插件 %s 异常退出: %v(homed 未受影响)", name, err)
|
||||
if r.evBus == nil {
|
||||
|
||||
// 1) 摘掉工具/stage/通道。**必须先做**:从这一刻起模型就不该再看到这些工具,
|
||||
// 否则在重启完成前的窗口里每次调用都是确定的失败。
|
||||
r.detachPlugin(name)
|
||||
|
||||
// 2) 从注册表移除。不做的后果:scheduleProcRestart 里的“已被其他路径重新加载”
|
||||
// 复核会误判(旧条目还在,See plugins[name] != nil),跳过真正的自动重启。
|
||||
// Plugin 对象本身仍被 proc 持有,Kill/回收不受影响。
|
||||
r.mu.Lock()
|
||||
delete(r.plugins, name)
|
||||
delete(r.sdkRefs, name)
|
||||
for i, inst := range r.instances {
|
||||
if inst.Name() == name {
|
||||
r.instances = append(r.instances[:i], r.instances[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
// 2) 事件通知(webui/诊断插件可订阅)。
|
||||
if r.evBus != nil {
|
||||
r.evBus.Publish(&events.Event{
|
||||
Type: events.EventSystem,
|
||||
Source: "plugin",
|
||||
Payload: map[string]interface{}{
|
||||
"event": "plugin_crashed",
|
||||
"plugin": name,
|
||||
"error": err.Error(),
|
||||
},
|
||||
Timestamp: time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
// 3) 排一次重启。**必须异步**:本回调由 proc.markExited 在 readLoop 的
|
||||
// goroutine 里触发,而 ReloadOne 要拿 registry 锁、还要 Kill 并 join 同一个
|
||||
// readLoop(Process.Kill 里 readerWG.Wait),同步调用会自锁死。
|
||||
go r.scheduleProcRestart(name, err)
|
||||
}
|
||||
|
||||
// scheduleProcRestart 在崩溃后按退避重启子进程插件。
|
||||
//
|
||||
// 退避与阈值语义与 agent 侧 plugin_health 对齐(窗口内 3 次即判定不健康),
|
||||
// 但重启动作落在 registry:崩溃事实产生于此,agent 的 distillLoop 默认 30 分钟
|
||||
// 才转一次(生产实配 2d),靠它兜底等于插件缺席数小时。
|
||||
func (r *Registry) scheduleProcRestart(name string, cause error) {
|
||||
if r.shuttingDown.Load() {
|
||||
return // 内核正在关停,不再拉起
|
||||
}
|
||||
if !r.AutoRestartEnabled(name) {
|
||||
log.Printf("[plugin] %s 声明了不自动重启,保持缺席状态", name)
|
||||
return
|
||||
}
|
||||
// 不在此处直接重载:重载需要 registry 锁,而本回调可能在
|
||||
// 持锁路径的 goroutine 中触发,直接调用会死锁。
|
||||
r.evBus.Publish(&events.Event{
|
||||
Type: events.EventSystem,
|
||||
Source: "plugin",
|
||||
Payload: map[string]interface{}{
|
||||
"event": "plugin_crashed",
|
||||
"plugin": name,
|
||||
"error": err.Error(),
|
||||
},
|
||||
Timestamp: time.Now().Unix(),
|
||||
})
|
||||
|
||||
n := r.noteCrash(name)
|
||||
if n > procMaxRestarts {
|
||||
log.Printf("[plugin] %s 在 %v 内崩溃 %d 次,停止自动重启(需人工介入)",
|
||||
name, procCrashWindow, n)
|
||||
return
|
||||
}
|
||||
|
||||
// 线性退避:1 次→1s,2 次→2s,3 次→3s。崩溃循环时不至于打满 CPU,
|
||||
// 又足够快到用户感知不到工具缺席。
|
||||
delay := time.Duration(n) * procRestartBackoff
|
||||
time.Sleep(delay)
|
||||
|
||||
// 期间可能已被 Disable/Remove/手工 plgreload 处理掉,重启前复核。
|
||||
if r.shuttingDown.Load() {
|
||||
return
|
||||
}
|
||||
if r.isDisabled(name) {
|
||||
log.Printf("[plugin] %s 已被禁用,取消自动重启", name)
|
||||
return
|
||||
}
|
||||
r.mu.RLock()
|
||||
already := r.plugins[name] != nil
|
||||
r.mu.RUnlock()
|
||||
if already {
|
||||
log.Printf("[plugin] %s 已被其他路径重新加载,取消自动重启", name)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[plugin] 自动重启 %s(第 %d 次,退避 %v,起因: %v)", name, n, delay, cause)
|
||||
if err := r.ReloadOne(name); err != nil {
|
||||
log.Printf("[plugin] %s 自动重启失败: %v", name, err)
|
||||
return
|
||||
}
|
||||
log.Printf("[plugin] %s 自动重启成功", name)
|
||||
}
|
||||
|
||||
// noteCrash 记录一次崩溃并返回窗口内的累计次数。
|
||||
func (r *Registry) noteCrash(name string) int {
|
||||
now := time.Now()
|
||||
r.crashMu.Lock()
|
||||
defer r.crashMu.Unlock()
|
||||
if r.procCrashes == nil {
|
||||
r.procCrashes = make(map[string]*procCrashRecord)
|
||||
}
|
||||
rec := r.procCrashes[name]
|
||||
if rec == nil || now.Sub(rec.last) > procCrashWindow {
|
||||
rec = &procCrashRecord{}
|
||||
r.procCrashes[name] = rec
|
||||
}
|
||||
rec.count++
|
||||
rec.last = now
|
||||
return rec.count
|
||||
}
|
||||
|
||||
// ResetProcCrashCount 清空某插件的崩溃计数(人工 plgreload / 重新启用后调用)。
|
||||
func (r *Registry) ResetProcCrashCount(name string) {
|
||||
r.crashMu.Lock()
|
||||
defer r.crashMu.Unlock()
|
||||
delete(r.procCrashes, name)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user