package daemon // crash_recovery.go - 会话崩溃恢复策略. // // 解决问题:Agent 会话在运行中可能因 LLM API 错误,工具 panic,网络中断等 // 原因意外结束.CrashRecovery 检测这类异常结束,并按配置策略重启会话. // // 升华改进(ELEVATED): 早期方案 的 reconnectSession 在会话崩溃后 // 无限重试(只要 Bridge 连接还在就一直尝试),没有退避策略, // 可能导致 LLM API 配额耗尽(连续崩溃 → 连续重试 → 配额用尽 → 更多错误). // 我们实现指数退避 + 最大重试次数,超过上限不再重试而是上报错误. // 替代方案:<固定间隔重试> - 否决:固定间隔不能适应不同严重程度的故障 // (临时网络抖动应快速恢复,持续 API 故障应缓慢重试). // // 安全考虑:崩溃事件必须记录到审计日志(早期方案没有). // 对接点:接入 INF-5 AuditSink(`pkg/security/audit.go`)-- // 平台层未来在 DaemonManager 中注入 AuditSink,CrashRecovery 通过回调上报. import ( "context" "log" "math" "time" ) // CrashRecoveryConfig 是崩溃恢复配置. type CrashRecoveryConfig struct { // MaxRetries 最大重试次数,0 = 不重试,-1 = 无限重试(不推荐). MaxRetries int // InitialDelay 首次重试的等待时间,默认 1s. InitialDelay time.Duration // MaxDelay 退避延迟上限,默认 60s. // 防止指数增长后延迟过长(如第 10 次重试等待 512s). MaxDelay time.Duration // Multiplier 退避乘数,默认 2.0(标准指数退避). Multiplier float64 // OnCrash 崩溃事件回调(用于接入 AuditSink 或告警系统). // sessionID: 崩溃的会话 ID // attempt: 第几次崩溃(从 1 开始) // err: 崩溃原因 OnCrash func(sessionID string, attempt int, err error) // OnGiveUp 达到最大重试次数时回调. OnGiveUp func(sessionID string, totalAttempts int) } // DefaultCrashRecoveryConfig 返回生产合理的崩溃恢复配置. func DefaultCrashRecoveryConfig() CrashRecoveryConfig { return CrashRecoveryConfig{ MaxRetries: 3, InitialDelay: 1 * time.Second, MaxDelay: 60 * time.Second, Multiplier: 2.0, } } // CrashRecovery 执行带退避的会话重试策略. type CrashRecovery struct { cfg CrashRecoveryConfig } // NewCrashRecovery 创建崩溃恢复执行器. func NewCrashRecovery(cfg CrashRecoveryConfig) *CrashRecovery { if cfg.InitialDelay <= 0 { cfg.InitialDelay = 1 * time.Second } if cfg.MaxDelay <= 0 { cfg.MaxDelay = 60 * time.Second } if cfg.Multiplier <= 1 { cfg.Multiplier = 2.0 } return &CrashRecovery{cfg: cfg} } // RunWithRecovery 执行 fn,在崩溃时按配置重试. // // fn: 会话运行函数,返回 error 表示异常结束(nil = 正常结束,不重试). // sessionID: 用于日志和回调. // ctx: 上下文取消时停止重试. // // 精妙之处(CLEVER): 正常结束(fn 返回 nil)不触发重试-- // 只有 fn 返回 non-nil error 才被视为崩溃.这防止了会话"主动结束" // (用户说"退出",引擎正常完成任务)后被误重启. func (cr *CrashRecovery) RunWithRecovery(ctx context.Context, sessionID string, fn func() error) error { maxRetries := cr.cfg.MaxRetries attempt := 0 for { err := fn() if err == nil { // 正常结束,不重试 return nil } attempt++ log.Printf("crash_recovery: session %s crashed (attempt %d): %v", sessionID, attempt, err) if cr.cfg.OnCrash != nil { cr.cfg.OnCrash(sessionID, attempt, err) } // 检查是否达到重试上限 if maxRetries >= 0 && attempt > maxRetries { log.Printf("crash_recovery: session %s gave up after %d attempts", sessionID, attempt) if cr.cfg.OnGiveUp != nil { cr.cfg.OnGiveUp(sessionID, attempt) } return err } // 计算退避延迟:initialDelay × multiplier^(attempt-1),上限 maxDelay delay := cr.backoffDelay(attempt) log.Printf("crash_recovery: session %s will retry in %v", sessionID, delay) select { case <-ctx.Done(): return ctx.Err() case <-time.After(delay): // 等待退避时间后重试 } } } // backoffDelay 计算第 n 次重试的等待时间. // // 精妙之处(CLEVER): 用 math.Pow 而非循环乘法-- // 数学上等价但更清晰,且 attempt 可以是任意大(不需要前面的状态). // math.Min 确保不超过 MaxDelay 上限(Go 没有内置 min(float64) 直到 1.21). func (cr *CrashRecovery) backoffDelay(attempt int) time.Duration { delay := float64(cr.cfg.InitialDelay) * math.Pow(cr.cfg.Multiplier, float64(attempt-1)) if delay > float64(cr.cfg.MaxDelay) { delay = float64(cr.cfg.MaxDelay) } return time.Duration(delay) }