Version: 0.9.62.dev.260502

后端:
1. 主动调度补齐 `unfinished_feedback` 定位闭环——用户补充信息先在滚动窗口内定位到可校验的日程块,定位失败则继续 ask_user,不再硬猜 target_id 或直接跑 graph。
2. 聊天占管重跑链路加并发保护——`waiting_user_reply -> rerunning` 改为 DB CAS 抢占,重复补充只返回可见等待提示,避免并发生成多份 preview。
3. rerun 结果回写继续收口——新 preview_id 同步回 trigger 审计指针,session 只在拿到新 preview 时更新当前预览,ready_preview 后清空追问状态并释放回普通聊天。
4. 主动调度事件校验放宽 unfinished_feedback 的空 target 场景,允许先触发、后定位,再进入 graph + preview 主链路。
This commit is contained in:
Losita
2026-05-02 12:41:50 +08:00
parent a3eaa9b2c2
commit ba23ebd201
12 changed files with 891 additions and 103 deletions

View File

@@ -25,10 +25,11 @@ func NewAgentService(
taskRepo *dao.TaskDAO,
cacheDAO *dao.CacheDAO,
agentRedis *dao.AgentCache,
activeScheduleDAO *dao.ActiveScheduleDAO,
activeSessionDAO *dao.ActiveScheduleSessionDAO,
eventPublisher outboxinfra.EventPublisher,
) *AgentService {
return agentsvc.NewAgentService(aiHub, repo, taskRepo, cacheDAO, agentRedis, activeSessionDAO, eventPublisher)
return agentsvc.NewAgentService(aiHub, repo, taskRepo, cacheDAO, agentRedis, activeScheduleDAO, activeSessionDAO, eventPublisher)
}
// NewAgentServiceWithSchedule 在基础 AgentService 上注入排程依赖。
@@ -43,12 +44,13 @@ func NewAgentServiceWithSchedule(
taskRepo *dao.TaskDAO,
cacheDAO *dao.CacheDAO,
agentRedis *dao.AgentCache,
activeScheduleDAO *dao.ActiveScheduleDAO,
activeSessionDAO *dao.ActiveScheduleSessionDAO,
eventPublisher outboxinfra.EventPublisher,
scheduleSvc *ScheduleService,
taskSvc *TaskService,
) *AgentService {
svc := agentsvc.NewAgentService(aiHub, repo, taskRepo, cacheDAO, agentRedis, activeSessionDAO, eventPublisher)
svc := agentsvc.NewAgentService(aiHub, repo, taskRepo, cacheDAO, agentRedis, activeScheduleDAO, activeSessionDAO, eventPublisher)
// 注入排程依赖:将 service 层方法包装为函数闭包,避免循环依赖。
if scheduleSvc != nil {

View File

@@ -31,6 +31,7 @@ type AgentService struct {
taskRepo *dao.TaskDAO
cacheDAO *dao.CacheDAO
agentCache *dao.AgentCache
activeScheduleDAO *dao.ActiveScheduleDAO
activeScheduleSessionDAO *dao.ActiveScheduleSessionDAO
eventPublisher outboxinfra.EventPublisher
@@ -79,6 +80,7 @@ func NewAgentService(
taskRepo *dao.TaskDAO,
cacheDAO *dao.CacheDAO,
agentRedis *dao.AgentCache,
activeScheduleDAO *dao.ActiveScheduleDAO,
activeSessionDAO *dao.ActiveScheduleSessionDAO,
eventPublisher outboxinfra.EventPublisher,
) *AgentService {
@@ -93,6 +95,7 @@ func NewAgentService(
taskRepo: taskRepo,
cacheDAO: cacheDAO,
agentCache: agentRedis,
activeScheduleDAO: activeScheduleDAO,
activeScheduleSessionDAO: activeSessionDAO,
eventPublisher: eventPublisher,
}

View File

@@ -114,6 +114,27 @@ func (s *AgentService) persistActiveScheduleSessionBestEffort(ctx context.Contex
return nil
}
// persistActiveScheduleTriggerPreviewBestEffort 负责把 rerun 产生的新 preview_id 同步回 trigger。
//
// 职责边界:
// 1. 只维护 trigger -> preview 的审计指针,不修改 preview 内容,也不推进 confirm/apply 状态;
// 2. trigger_id 或 preview_id 为空时直接跳过,避免把不完整 rerun 结果写入触发记录;
// 3. DAO 未注入时保持迁移期兼容,调用方仍以 session 写回作为主流程。
func (s *AgentService) persistActiveScheduleTriggerPreviewBestEffort(ctx context.Context, triggerID string, previewID string) error {
if s == nil || s.activeScheduleDAO == nil {
return nil
}
normalizedTriggerID := strings.TrimSpace(triggerID)
normalizedPreviewID := strings.TrimSpace(previewID)
if normalizedTriggerID == "" || normalizedPreviewID == "" {
return nil
}
return s.activeScheduleDAO.UpdateTriggerFields(ctx, normalizedTriggerID, map[string]any{
"preview_id": &normalizedPreviewID,
"updated_at": time.Now(),
})
}
// handleActiveScheduleSessionChat 处理被主动调度 session 占管的聊天入口。
//
// 步骤化说明:
@@ -165,20 +186,36 @@ func (s *AgentService) handleActiveScheduleSessionChat(
}
// 1. 收到用户补充信息后,先把 session 切成 rerunning避免并发请求继续按旧状态走普通聊天。
// 2. 这个阶段只是状态切换,不代表 graph 已经完成。
session.Status = model.ActiveScheduleSessionStatusRerunning
if err := s.persistActiveScheduleSessionBestEffort(ctx, session); err != nil {
// 3. 这里必须使用 DB CAS 抢占 rerun 权限,避免两条补充消息同时读到 waiting_user_reply 后重复生成 preview。
switched, err := s.activeScheduleSessionDAO.TryTransitionActiveScheduleSessionStatusBySessionID(
ctx,
session.SessionID,
model.ActiveScheduleSessionStatusWaitingUserReply,
model.ActiveScheduleSessionStatusRerunning,
)
if err != nil {
return true, err
}
if !switched {
if err := s.respondActiveScheduleRerunning(ctx, userID, chatID, traceID, resolvedModelName, requestStart, outChan); err != nil {
return true, err
}
return true, nil
}
session.Status = model.ActiveScheduleSessionStatusRerunning
if s.cacheDAO != nil {
if cacheErr := s.cacheDAO.SetActiveScheduleSessionToCache(ctx, session); cacheErr != nil {
log.Printf("回填主动调度 rerunning session 缓存失败 session=%s err=%v", session.SessionID, cacheErr)
}
}
return true, s.runActiveScheduleSessionRerun(ctx, session, trimmedMessage, traceID, requestStart, resolvedModelName, outChan, errChan)
case model.ActiveScheduleSessionStatusRerunning:
// 1. rerunning 是占管中的过渡态,说明当前会话已经在重跑或刚开始重跑。
// 2. 这里不再触发第二次 rerun只给用户一个可见的等待提示。
if trimmedMessage != "" {
assistantText := "主动调度正在重新生成建议,请稍后再试。"
if err := s.persistNewAgentConversationMessage(ctx, userID, chatID, schema.AssistantMessage(assistantText, nil), 0); err != nil {
if err := s.respondActiveScheduleRerunning(ctx, userID, chatID, traceID, resolvedModelName, requestStart, outChan); err != nil {
return true, err
}
emitActiveScheduleAssistantChunk(outChan, traceID, resolvedModelName, requestStart, assistantText, nil)
}
return true, nil
default:
@@ -186,6 +223,29 @@ func (s *AgentService) handleActiveScheduleSessionChat(
}
}
// respondActiveScheduleRerunning 负责在重复补充命中并发保护时写入可见提示。
//
// 职责边界:
// 1. 只写聊天历史和 SSE 文本,不推进 session、trigger、preview 状态;
// 2. 用于 rerunning 状态或 CAS 抢占失败后的兜底提示,避免再次触发 graph
// 3. 写入失败时返回 error让上层按聊天入口的错误通道处理。
func (s *AgentService) respondActiveScheduleRerunning(
ctx context.Context,
userID int,
chatID string,
traceID string,
resolvedModelName string,
requestStart time.Time,
outChan chan<- string,
) error {
assistantText := "主动调度正在重新生成建议,请稍后再试。"
if err := s.persistNewAgentConversationMessage(ctx, userID, chatID, schema.AssistantMessage(assistantText, nil), 0); err != nil {
return err
}
emitActiveScheduleAssistantChunk(outChan, traceID, resolvedModelName, requestStart, assistantText, nil)
return nil
}
// runActiveScheduleSessionRerun 负责把 waiting_user_reply 的用户补充同步推进成新的主动调度结果。
//
// 职责边界:
@@ -230,10 +290,9 @@ func (s *AgentService) runActiveScheduleSessionRerun(
}
session.Status = finalStatus
session.State = result.SessionState
if strings.TrimSpace(result.PreviewID) != "" {
session.CurrentPreviewID = strings.TrimSpace(result.PreviewID)
} else if session.Status != model.ActiveScheduleSessionStatusReadyPreview {
session.CurrentPreviewID = ""
previewID := strings.TrimSpace(result.PreviewID)
if previewID != "" {
session.CurrentPreviewID = previewID
}
if session.Status == model.ActiveScheduleSessionStatusReadyPreview {
session.State.PendingQuestion = ""
@@ -241,6 +300,12 @@ func (s *AgentService) runActiveScheduleSessionRerun(
session.State.FailedReason = ""
}
if previewID != "" {
if err := s.persistActiveScheduleTriggerPreviewBestEffort(ctx, session.TriggerID, previewID); err != nil {
return err
}
}
if err := s.persistActiveScheduleSessionBestEffort(ctx, session); err != nil {
return err
}