Files
Agent-System/internal/taskrun/manager.go
T
WeiCHENandCursor f4e4357ddb fix(agent): Chat Agent 全链路 P0-P5 整改
封堵鉴权旁路与导出 IDOR,DocGov 改服务端身份;对话层 PendingAction 接真实 docgov/taskrun;修复流式租约假死与会话级任务流;加固配置/doc_id/NULL;补齐 slog 与测试;前端 ErrorBoundary 与受控文档重试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 02:50:22 +00:00

347 lines
9.5 KiB
Go

package taskrun
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/stream"
)
// Executor 执行具体任务逻辑(由 server 注入)。
type Executor func(ctx context.Context, runID string, req bizdev.TaskRunRequest, streamer *stream.Streamer) (*bizdev.TaskRunResult, error)
// ResumeExecutor 续跑既有工作区。
type ResumeExecutor func(ctx context.Context, runID string, workDir string, streamer *stream.Streamer) (*bizdev.TaskRunResult, error)
// StageExecutor 执行成熟度阶段任务序列。
type StageExecutor func(ctx context.Context, runID string, req bizdev.StageRunRequest, streamer *stream.Streamer) (*bizdev.TaskRunResult, error)
// Finalizer 任务成功后投影运行记录与聊天消息(由 server 注入)。
type Finalizer func(ctx context.Context, record *RunRecord, result *bizdev.TaskRunResult) (terminalStatus string, err error)
// Manager 后台任务运行观察者:SSE 断线不取消 worker。
type Manager struct {
mu sync.RWMutex
runs map[string]*activeRun
exec Executor
resume ResumeExecutor
stage StageExecutor
finalize Finalizer
pwSvc *projectworkflow.Service
ctxMgr *ctxmgr.ContextManager
}
const runRetention = time.Hour
func NewManager(pwSvc *projectworkflow.Service, ctxMgr *ctxmgr.ContextManager) *Manager {
m := &Manager{
runs: make(map[string]*activeRun),
pwSvc: pwSvc,
ctxMgr: ctxMgr,
}
go m.cleanupLoop()
return m
}
func (m *Manager) cleanupLoop() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for range ticker.C {
m.evictStaleRuns()
}
}
func (m *Manager) evictStaleRuns() {
cutoff := time.Now().Add(-runRetention)
m.mu.Lock()
defer m.mu.Unlock()
for id, ar := range m.runs {
ar.mu.RLock()
status := ar.record.Status
updated := ar.record.UpdatedAt
ar.mu.RUnlock()
if updated.Before(cutoff) && status != "running" {
delete(m.runs, id)
}
}
}
func (m *Manager) SetExecutor(exec Executor, resume ResumeExecutor, stage StageExecutor) {
m.exec = exec
m.resume = resume
m.stage = stage
}
func (m *Manager) SetFinalizer(f Finalizer) {
m.finalize = f
}
func (m *Manager) CreateAndStart(parent context.Context, runID string, record *RunRecord, taskReq bizdev.TaskRunRequest, stageReq bizdev.StageRunRequest, resumeWorkDir string) (*RunRecord, error) {
if runID == "" {
runID = fmt.Sprintf("run_%d", time.Now().UnixNano())
}
if record == nil {
record = &RunRecord{RunID: runID}
}
record.RunID = runID
if record.Status == "" {
record.Status = "running"
}
now := time.Now()
record.CreatedAt = now
record.UpdatedAt = now
workerCtx, cancel := context.WithCancel(context.WithoutCancel(parent))
ar := &activeRun{
record: record,
subs: make(map[int]chan StoredEvent),
cancel: cancel,
done: make(chan struct{}),
}
m.mu.Lock()
m.runs[runID] = ar
m.mu.Unlock()
if m.pwSvc != nil && record.ProjectID > 0 {
startedBy := record.UserID
_, _ = m.pwSvc.CreateWorkflowRun(workerCtx, &projectworkflow.WorkflowRun{
RunID: runID,
ProjectID: record.ProjectID,
Status: "running",
StartedBy: &startedBy,
StageKey: record.Stage,
TaskKey: record.TaskKey,
InputManifest: map[string]any{
"session_id": record.SessionID,
"client_turn_id": record.ClientTurnID,
"assistant_msg_id": record.AssistantMsgID,
},
})
}
go m.runWorker(workerCtx, ar, taskReq, stageReq, resumeWorkDir)
return record, nil
}
func (m *Manager) runWorker(ctx context.Context, ar *activeRun, taskReq bizdev.TaskRunRequest, stageReq bizdev.StageRunRequest, resumeWorkDir string) {
defer close(ar.done)
taskReq.RunID = ar.record.RunID
taskReq.SessionID = ar.record.SessionID
taskReq.ProjectID = ar.record.ProjectID
taskReq.UserID = ar.record.UserID
taskReq.ClientTurnID = ar.record.ClientTurnID
lastPhaseID := ""
listener := func(ev stream.StreamEvent) {
if ev.Type == stream.EventPhase && ev.Phase != nil && ev.Phase.PhaseID != "" {
lastPhaseID = ev.Phase.PhaseID
}
m.appendEvent(ar, ev)
}
ctxWithS, streamer := stream.WithListener(ctx, false, listener)
var taskResult *bizdev.TaskRunResult
var execErr error
start := time.Now()
if ar.record.Stage != "" && m.stage != nil {
stageReq.RunID = ar.record.RunID
stageReq.SessionID = ar.record.SessionID
stageReq.ProjectID = ar.record.ProjectID
stageReq.UserID = ar.record.UserID
stageReq.ClientTurnID = ar.record.ClientTurnID
taskResult, execErr = m.stage(ctxWithS, ar.record.RunID, stageReq, streamer)
} else if resumeWorkDir != "" && m.resume != nil {
taskResult, execErr = m.resume(ctxWithS, ar.record.RunID, resumeWorkDir, streamer)
} else if m.exec != nil {
taskResult, execErr = m.exec(ctxWithS, ar.record.RunID, taskReq, streamer)
} else {
execErr = fmt.Errorf("task executor not configured")
}
workspaceID := ""
if taskResult != nil && taskResult.WorkDir != "" {
workspaceID = taskResult.WorkDir
} else if resumeWorkDir != "" {
workspaceID = resumeWorkDir
}
if workspaceID != "" {
ar.mu.Lock()
ar.record.WorkspaceID = workspaceID
ar.mu.Unlock()
}
if execErr != nil {
if ctx.Err() == context.Canceled {
m.finishRun(ar, "cancelled", workspaceID, execErr.Error(), nil)
info := stream.ClassifyError(fmt.Errorf("task cancelled"), "task_cancel", ar.record.RunID, ar.record.SessionID, workspaceID)
m.appendEvent(ar, stream.StreamEvent{Type: stream.EventError, ErrorInfo: &info})
return
}
classifyWS := workspaceID
if classifyWS == "" && resumeWorkDir != "" {
classifyWS = resumeWorkDir
}
info := stream.ClassifyError(execErr, lastPhaseID, ar.record.RunID, ar.record.SessionID, classifyWS)
m.appendEvent(ar, stream.StreamEvent{Type: stream.EventError, ErrorInfo: &info})
m.finishRun(ar, "failed", workspaceID, execErr.Error(), taskResult)
return
}
m.appendEvent(ar, stream.StreamEvent{
Type: stream.EventDone,
Done: &stream.DonePayload{
SessionID: "task_run",
WorkspaceID: workspaceID,
DurationMs: time.Since(start).Milliseconds(),
},
})
terminalStatus := "completed"
if m.finalize != nil && taskResult != nil {
if ts, finErr := m.finalize(ctx, ar.record, taskResult); finErr != nil {
m.finishRun(ar, "finalizing_failed", workspaceID, finErr.Error(), taskResult)
return
} else if ts != "" {
terminalStatus = ts
}
} else if taskResult != nil && taskResult.QualityReport != nil && !taskResult.QualityReport.Passed {
terminalStatus = "quality_failed"
}
m.finishRun(ar, terminalStatus, workspaceID, "", taskResult)
}
func (m *Manager) finishRun(ar *activeRun, status, workspaceID, errMsg string, result *bizdev.TaskRunResult) {
ar.mu.Lock()
ar.record.Status = status
ar.record.WorkspaceID = workspaceID
ar.record.ErrorMessage = errMsg
ar.record.UpdatedAt = time.Now()
runID := ar.record.RunID
ar.mu.Unlock()
slog.Info("taskrun_status_transition",
"run_id", runID,
"status", status,
"workspace_id", workspaceID,
"error", errMsg,
)
// 失败/取消路径:若 Finalize 未执行,仍更新 workflow_runs 与占位消息 metadata
if status == "failed" || status == "cancelled" || status == "finalizing_failed" {
if m.pwSvc != nil && ar.record.ProjectID > 0 {
_ = m.pwSvc.UpdateWorkflowRun(context.Background(), ar.record.RunID, status, "", "", errMsg)
}
if m.ctxMgr != nil && ar.record.AssistantMsgID > 0 {
meta := map[string]any{
"run_id": ar.record.RunID,
"workspace_id": workspaceID,
"task_status": status,
}
content := "任务执行失败。"
if status == "cancelled" {
content = "任务已取消。"
} else if status == "finalizing_failed" {
content = "任务已生成,但结果落库失败,请联系管理员或稍后重试。"
}
if errMsg != "" {
content += "\n\n" + errMsg
}
_ = m.ctxMgr.FinalizeAssistantTaskMessage(context.Background(), ar.record.AssistantMsgID, content, meta, status)
}
}
_ = result // success path persistence handled by Finalizer
}
func (m *Manager) appendEvent(ar *activeRun, ev stream.StreamEvent) {
ar.mu.Lock()
ar.nextSeq++
stored := StoredEvent{Seq: ar.nextSeq, Event: ev}
ar.events = append(ar.events, stored)
subs := make([]chan StoredEvent, 0, len(ar.subs))
for _, ch := range ar.subs {
subs = append(subs, ch)
}
ar.mu.Unlock()
for _, ch := range subs {
select {
case ch <- stored:
default:
}
}
}
func (m *Manager) GetRun(runID string) (*RunRecord, error) {
m.mu.RLock()
ar, ok := m.runs[runID]
m.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("run not found")
}
ar.mu.RLock()
defer ar.mu.RUnlock()
cpy := *ar.record
return &cpy, nil
}
func (m *Manager) Subscribe(runID string, afterSeq int64) (<-chan StoredEvent, []StoredEvent, error) {
m.mu.RLock()
ar, ok := m.runs[runID]
m.mu.RUnlock()
if !ok {
return nil, nil, fmt.Errorf("run not found")
}
ar.mu.Lock()
defer ar.mu.Unlock()
replay := make([]StoredEvent, 0)
for _, ev := range ar.events {
if ev.Seq > afterSeq {
replay = append(replay, ev)
}
}
ch := make(chan StoredEvent, 32)
ar.subSeq++
id := ar.subSeq
ar.subs[id] = ch
return ch, replay, nil
}
func (m *Manager) Unsubscribe(runID string, ch <-chan StoredEvent) {
m.mu.RLock()
ar, ok := m.runs[runID]
m.mu.RUnlock()
if !ok {
return
}
ar.mu.Lock()
defer ar.mu.Unlock()
for id, sub := range ar.subs {
if sub == ch {
delete(ar.subs, id)
close(sub)
break
}
}
}
func (m *Manager) Cancel(runID string) error {
m.mu.RLock()
ar, ok := m.runs[runID]
m.mu.RUnlock()
if !ok {
return fmt.Errorf("run not found")
}
ar.cancel()
return nil
}