fix(taskrun): 刷新后恢复进行中任务并纠正进度问询
进行中的 run 提前写下消息锚点,并按会话列出活跃任务;进度疑问句改为查询真实状态,交付物已落盘且不是传输失败时不再被空结果否决。
This commit is contained in:
@@ -83,6 +83,31 @@ func TestMaterializeTargets_MissingAndEmptyFail(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterializeTargets_EmptyPatchKeepsExistingFile(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
body := []byte("# 调研报告\n已写在盘上的正文\n")
|
||||
path := filepath.Join(workDir, "output.md")
|
||||
if err := os.WriteFile(path, body, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := MaterializeTargets(workDir, []string{"output.md"}, map[string][]byte{
|
||||
"output.md": nil,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got["output.md"]) != string(body) {
|
||||
t.Fatalf("collected %q", got["output.md"])
|
||||
}
|
||||
disk, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(disk) != string(body) {
|
||||
t.Fatalf("disk overwritten with %q", disk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterializeTargets_RejectsSymlinkEscape(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
|
||||
@@ -244,6 +244,7 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
|
||||
stages := stageflow.New(svc)
|
||||
coordEngine.SetStageTaskRunner(stages)
|
||||
coordEngine.SetContextServices(parts.Ctx, parts.KB)
|
||||
coordEngine.SetSessionRunReader(sessionRunAdapter{mgr: taskMgr})
|
||||
if instSvc != nil && instSvc.Available() {
|
||||
coordEngine.SetInstanceService(instSvc)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"chat-agent-system/internal/app/intentflow"
|
||||
"chat-agent-system/internal/bizdev"
|
||||
"chat-agent-system/internal/chat"
|
||||
"chat-agent-system/internal/coordinator"
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
"chat-agent-system/internal/taskrun"
|
||||
)
|
||||
@@ -117,6 +118,10 @@ func (a chatTaskStarter) CreateAndStart(ctx context.Context, runID string, recor
|
||||
}, bizdev.StageRunRequest{}, resumeDir)
|
||||
}
|
||||
|
||||
func (a chatTaskStarter) BindAssistantMsgID(ctx context.Context, runID string, assistantMsgID int64) error {
|
||||
return a.inner.BindAssistantMsgID(ctx, runID, assistantMsgID)
|
||||
}
|
||||
|
||||
// BindChatSession attaches composition-root adapters onto a chat session.
|
||||
// Callers that keep a mutable intentflow.Service (e.g. server test hooks) should
|
||||
// prefer AdaptChat* helpers with their own fields so swaps stay visible.
|
||||
@@ -138,3 +143,25 @@ func (rt *Runtime) BindChatSession(sess *chat.Session, env string) {
|
||||
Environment: env,
|
||||
}
|
||||
}
|
||||
|
||||
type sessionRunAdapter struct {
|
||||
mgr *taskrun.Manager
|
||||
}
|
||||
|
||||
func (a sessionRunAdapter) LatestSessionRun(ctx context.Context, sessionID string) (*coordinator.SessionRunSnapshot, error) {
|
||||
if a.mgr == nil {
|
||||
return nil, nil
|
||||
}
|
||||
rec, err := a.mgr.LatestSessionRun(ctx, sessionID)
|
||||
if err != nil || rec == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &coordinator.SessionRunSnapshot{
|
||||
RunID: rec.RunID,
|
||||
TaskKey: rec.TaskKey,
|
||||
Status: rec.Status,
|
||||
WorkspaceID: rec.WorkspaceID,
|
||||
ErrorMessage: rec.ErrorMessage,
|
||||
UpdatedAt: rec.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ type IntentStepView struct {
|
||||
// TaskStarter starts a task run without exposing bizdev/stageflow request types to chat.
|
||||
type TaskStarter interface {
|
||||
CreateAndStart(ctx context.Context, runID string, record *taskrun.RunRecord, start TaskStartRequest, resumeDir string) (*taskrun.RunRecord, error)
|
||||
BindAssistantMsgID(ctx context.Context, runID string, assistantMsgID int64) error
|
||||
}
|
||||
|
||||
// TaskStartRequest carries the chat-owned fields needed to start or resume a run.
|
||||
|
||||
@@ -465,6 +465,9 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
|
||||
streamer.EmitPhase("dialogue_generating", "正在整理回复...", "assistant", 0, 0, 40)
|
||||
}
|
||||
s.EnsureDocResolver()
|
||||
if s.UserCtx != nil {
|
||||
s.UserCtx.ChatSessionID = s.ID
|
||||
}
|
||||
coordRes, err := s.Coordinator.ProcessIntent(ctx, recRes, s.UserCtx)
|
||||
if err != nil {
|
||||
if streamer != nil {
|
||||
@@ -473,8 +476,12 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
|
||||
return nil, fmt.Errorf("coordination execution failed: %w", err)
|
||||
}
|
||||
|
||||
var startedRunID string
|
||||
if coordRes.PendingAction != nil {
|
||||
execResult, execErr := s.executeAction(ctx, coordRes.PendingAction, s.UserCtx)
|
||||
if execResult != nil {
|
||||
startedRunID = execResult.RunID
|
||||
}
|
||||
coordRes.NaturalReply = renderActionReply(coordRes.PendingAction, execResult, execErr)
|
||||
coordRes.PendingAction = nil
|
||||
}
|
||||
@@ -592,8 +599,17 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
|
||||
asstMeta["client_turn_id"] = clientTurnID
|
||||
asstMeta["client_id"] = clientTurnID + ":assistant"
|
||||
}
|
||||
if startedRunID != "" {
|
||||
asstMeta["run_id"] = startedRunID
|
||||
if asstMeta["task_status"] == nil || asstMeta["task_status"] == "" {
|
||||
asstMeta["task_status"] = "running"
|
||||
}
|
||||
}
|
||||
|
||||
asstMsgID, _ := s.CtxMgr.RecordUserMessageWithID(ctx, s.ID, 0, "assistant", coordRes.NaturalReply, string(coordRes.Intent), string(recRes.Branch), asstMeta)
|
||||
if startedRunID != "" && asstMsgID > 0 && s.Actions != nil && s.Actions.TaskRuns != nil {
|
||||
_ = s.Actions.TaskRuns.BindAssistantMsgID(ctx, startedRunID, asstMsgID)
|
||||
}
|
||||
|
||||
// 异步记录问答切片到向量记忆库 (P2 检索增强,不阻塞流式或同步答复)
|
||||
sessID := s.ID
|
||||
|
||||
@@ -222,6 +222,8 @@ type UserProjectContext struct {
|
||||
RegulationHits []kb.HitItem `json:"-"`
|
||||
// UpgradeOffered 路由升级建议冷却标记(每会话至多主动建议一次,防打扰——对齐"参谋不强推"人设)
|
||||
UpgradeOffered bool `json:"-"`
|
||||
// ChatSessionID 当前对话会话,供进度问询读取真实 task run。不序列化。
|
||||
ChatSessionID string `json:"-"`
|
||||
// DocumentSummaryReader 仅用于服务端鉴权后的只读正文访问,不序列化。
|
||||
DocumentSummaryReader DocumentSummaryReader `json:"-"`
|
||||
// RealtimeSnippet 本轮实时检索(天气/新闻等)成功结果,供 Prompt 注入;不序列化。
|
||||
|
||||
@@ -83,6 +83,7 @@ type CoordinatorEngine struct {
|
||||
docResolver DocResolver
|
||||
pendingSupps PendingSupplementReader
|
||||
reopenLister ReopenLister
|
||||
sessionRuns SessionRunReader
|
||||
// OnSupplement is optional; composition typically wires supplementimpact.TriggerAsync.
|
||||
// Nil-safe: unset hooks are skipped.
|
||||
OnSupplement func(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string)
|
||||
@@ -108,6 +109,27 @@ func (e *CoordinatorEngine) tasks() StageTaskRunner {
|
||||
return e.stageTasks
|
||||
}
|
||||
|
||||
// SessionRunSnapshot 是进度问询可读的任务运行投影,不含工作目录绝对路径。
|
||||
type SessionRunSnapshot struct {
|
||||
RunID string
|
||||
TaskKey string
|
||||
Status string
|
||||
WorkspaceID string
|
||||
ErrorMessage string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// SessionRunReader 按会话读取最近一次任务运行。未注入时进度问询保持模板答复。
|
||||
type SessionRunReader interface {
|
||||
LatestSessionRun(ctx context.Context, sessionID string) (*SessionRunSnapshot, error)
|
||||
}
|
||||
|
||||
func (e *CoordinatorEngine) SetSessionRunReader(reader SessionRunReader) {
|
||||
if e != nil {
|
||||
e.sessionRuns = reader
|
||||
}
|
||||
}
|
||||
|
||||
func (e *CoordinatorEngine) SetContextServices(ctxMgr *ctxmgr.ContextManager, kbClient *kb.Client) {
|
||||
if e != nil {
|
||||
e.ctxMgr = ctxMgr
|
||||
|
||||
@@ -151,6 +151,13 @@ func (e *CoordinatorEngine) handleQueryProgress(ctx context.Context, cand *inten
|
||||
sb.WriteString(brief.CommunicationKit)
|
||||
|
||||
case "progress":
|
||||
if snap := e.latestSessionRun(ctx, userCtx); snap != nil {
|
||||
sb.WriteString(formatSessionRunReply(snap))
|
||||
if brief != nil {
|
||||
sb.WriteString(fmt.Sprintf("\n项目成熟度仍为 %s(%d/100)。", brief.MaturityLevel, brief.MaturityScore))
|
||||
}
|
||||
break
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("【进展汇报】 项目当前成熟度等级为 %s(评分 %d/100)。\n", brief.MaturityLevel, brief.MaturityScore))
|
||||
sb.WriteString(fmt.Sprintf("诊断结论:%s\n\n", brief.MaturityHeadline))
|
||||
if len(brief.NextBestActions) > 0 {
|
||||
@@ -196,6 +203,49 @@ func (e *CoordinatorEngine) handleQueryProgress(ctx context.Context, cand *inten
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (e *CoordinatorEngine) latestSessionRun(ctx context.Context, userCtx *UserProjectContext) *SessionRunSnapshot {
|
||||
if e == nil || e.sessionRuns == nil || userCtx == nil || strings.TrimSpace(userCtx.ChatSessionID) == "" {
|
||||
return nil
|
||||
}
|
||||
snap, err := e.sessionRuns.LatestSessionRun(ctx, userCtx.ChatSessionID)
|
||||
if err != nil || snap == nil || snap.RunID == "" {
|
||||
return nil
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
func formatSessionRunReply(snap *SessionRunSnapshot) string {
|
||||
label := map[string]string{
|
||||
"pending": "排队中",
|
||||
"running": "正在编写",
|
||||
"completed": "已完成",
|
||||
"failed": "执行失败",
|
||||
"finalizing_failed": "收尾失败",
|
||||
"interrupted": "已中断,可以继续",
|
||||
"cancelled": "已取消",
|
||||
"pending_author_confirmation": "待编制人确认",
|
||||
"quality_failed": "质量未通过",
|
||||
"blocked_pending_governance": "待激活工作流",
|
||||
}
|
||||
status := label[snap.Status]
|
||||
if status == "" {
|
||||
status = snap.Status
|
||||
}
|
||||
taskName := snap.TaskKey
|
||||
if taskName == "" {
|
||||
taskName = "当前任务"
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("「%s」当前状态:%s。", taskName, status))
|
||||
if snap.WorkspaceID != "" {
|
||||
b.WriteString(fmt.Sprintf("工作区标识:%s。", snap.WorkspaceID))
|
||||
}
|
||||
if snap.ErrorMessage != "" && (snap.Status == "failed" || snap.Status == "interrupted" || snap.Status == "finalizing_failed") {
|
||||
b.WriteString("原因:" + snap.ErrorMessage + "。")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// synthesizeBaselineBrief 在用户尚未执行过任何阶段/任务(无持久化参谋简报)时,
|
||||
// 基于黑板事实与项目信息合成 intake 级基准参谋简报,确保查询类意图始终有实质研判可用。
|
||||
func synthesizeBaselineBrief(userCtx *UserProjectContext) *squad.AdvisorBriefing {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package coordinator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeSessionRuns struct {
|
||||
snap *SessionRunSnapshot
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeSessionRuns) LatestSessionRun(context.Context, string) (*SessionRunSnapshot, error) {
|
||||
return f.snap, f.err
|
||||
}
|
||||
|
||||
func TestFormatSessionRunReply_RunningIncludesWorkspace(t *testing.T) {
|
||||
got := formatSessionRunReply(&SessionRunSnapshot{
|
||||
TaskKey: "research",
|
||||
Status: "running",
|
||||
WorkspaceID: "ws_research_1",
|
||||
})
|
||||
for _, want := range []string{"research", "正在编写", "ws_research_1"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("reply %q missing %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestSessionRun_ReadsInjectedReader(t *testing.T) {
|
||||
engine := NewCoordinatorEngine(nil)
|
||||
engine.SetSessionRunReader(fakeSessionRuns{snap: &SessionRunSnapshot{
|
||||
RunID: "run-1",
|
||||
TaskKey: "research",
|
||||
Status: "running",
|
||||
}})
|
||||
got := engine.latestSessionRun(context.Background(), &UserProjectContext{ChatSessionID: "sess-1"})
|
||||
if got == nil || got.RunID != "run-1" {
|
||||
t.Fatalf("latest = %#v", got)
|
||||
}
|
||||
if engine.latestSessionRun(context.Background(), &UserProjectContext{}) != nil {
|
||||
t.Fatal("empty chat session must not invent a run")
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,30 @@ import (
|
||||
"chat-agent-system/internal/squad"
|
||||
)
|
||||
|
||||
// isProgressInquiry 为真时,带任务名词的疑问句应让行 QUERY_PROGRESS,而不是 TRIGGER_TASK。
|
||||
func isProgressInquiry(input string) bool {
|
||||
if !isInterrogative(input) {
|
||||
return false
|
||||
}
|
||||
markers := []string{"进度", "进展", "怎么样了", "状态如何", "写完", "完成了吗", "完成没有", "好了吗", "好了没有"}
|
||||
hit := false
|
||||
for _, marker := range markers {
|
||||
if strings.Contains(input, marker) {
|
||||
hit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hit {
|
||||
return false
|
||||
}
|
||||
for _, verb := range []string{"开始", "重新", "再来", "重跑", "再生成", "重新生成"} {
|
||||
if strings.Contains(input, verb) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isInterrogative 判断输入是否疑问句:句尾问号/「吗」「呢」,或句中出现
|
||||
// 「怎么/如何/什么/多少/哪/是否/大概/多久/为什么」等疑问词。
|
||||
// 用于把「查询/咨询」类输入从祈使句「指令」类意图(阶段推进等)中排除。
|
||||
@@ -248,6 +272,11 @@ func requirementsTriggerPhrase(taskKey string) string {
|
||||
}
|
||||
|
||||
func matchTriggerTask(input string, userCtx *ContextSnapshot) *CandidateIntent {
|
||||
// 疑问句里的「编写进度怎么样了」是在问状态,不是再启动一份交付物。
|
||||
// 显式启动词(开始/重新/重跑)仍走任务触发。
|
||||
if isProgressInquiry(input) {
|
||||
return nil
|
||||
}
|
||||
type phrase struct {
|
||||
text string
|
||||
key string
|
||||
|
||||
@@ -72,6 +72,30 @@ func TestIntentRecognition_AdvanceStage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntentRecognition_ProgressQuestionDoesNotStartTask(t *testing.T) {
|
||||
rec := intent.NewRecognizer()
|
||||
ctx := &intent.ContextSnapshot{SnapshotID: "ctx_progress"}
|
||||
|
||||
progress, err := rec.Recognize(context.Background(), "调研报告编写进度怎么样了", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("recognize progress: %v", err)
|
||||
}
|
||||
if progress.TopIntent.Intent != intent.IntentQueryProgress {
|
||||
t.Fatalf("progress question intent = %s, want QUERY_PROGRESS", progress.TopIntent.Intent)
|
||||
}
|
||||
|
||||
start, err := rec.Recognize(context.Background(), "开始做调研报告", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("recognize start: %v", err)
|
||||
}
|
||||
if start.TopIntent.Intent != intent.IntentTriggerTask {
|
||||
t.Fatalf("start intent = %s, want TRIGGER_TASK", start.TopIntent.Intent)
|
||||
}
|
||||
if start.TopIntent.FilledSlots[intent.SlotTargetTask] != "research" {
|
||||
t.Fatalf("start task = %s, want research", start.TopIntent.FilledSlots[intent.SlotTargetTask])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntentRecognition_TriggerTask(t *testing.T) {
|
||||
rec := intent.NewRecognizer()
|
||||
ctx := &intent.ContextSnapshot{
|
||||
|
||||
@@ -230,6 +230,7 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("POST /api/v1/tasks/run/stream", s.handleRunTaskStream)
|
||||
mux.HandleFunc("GET /api/v1/tasks/run/stream", s.handleRunTaskStream)
|
||||
mux.HandleFunc("POST /api/v1/tasks/runs", s.handleCreateTaskRun)
|
||||
mux.HandleFunc("GET /api/v1/tasks/runs", s.handleListTaskRuns)
|
||||
mux.HandleFunc("GET /api/v1/tasks/runs/{run_id}", s.handleGetTaskRun)
|
||||
mux.HandleFunc("GET /api/v1/tasks/runs/{run_id}/events", s.handleTaskRunEvents)
|
||||
mux.HandleFunc("POST /api/v1/tasks/runs/{run_id}/cancel", s.handleCancelTaskRun)
|
||||
|
||||
@@ -49,6 +49,12 @@ func (failInsertStore) HeartbeatLease(context.Context, string, string, time.Dura
|
||||
func (failInsertStore) ReleaseLease(context.Context, string, string) error { return nil }
|
||||
func (failInsertStore) ResetForResume(context.Context, string, int) error { return nil }
|
||||
func (failInsertStore) MarkOrphanedRunning(context.Context) (int, error) { return 0, nil }
|
||||
func (failInsertStore) ListActiveBySession(context.Context, string) ([]*taskrun.RunRecord, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (failInsertStore) LatestBySession(context.Context, string) (*taskrun.RunRecord, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestRunTaskStream_InvalidJSON_Returns400(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
|
||||
@@ -209,6 +209,9 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) {
|
||||
"doc_id": req.DocID,
|
||||
},
|
||||
}
|
||||
if req.ClientTurnID != "" {
|
||||
asstMeta["client_id"] = req.ClientTurnID + ":assistant"
|
||||
}
|
||||
projectinstance.ApplyScopeToMetadata(asstMeta, taskScope)
|
||||
assistantMsgID, _ = ctxMgr.RecordUserMessageWithID(r.Context(), req.SessionID, 0, "assistant", "任务执行中…", "TRIGGER_TASK", "", asstMeta)
|
||||
claimed.AssistantMsgID = assistantMsgID
|
||||
@@ -613,6 +616,45 @@ func (s *Server) cancelPiAgentRunner(workspaceID string) {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
func (s *Server) handleListTaskRuns(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.requirePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sessionID := strings.TrimSpace(r.URL.Query().Get("session_id"))
|
||||
if sessionID == "" {
|
||||
writeSafeError(w, r, http.StatusBadRequest, "session_id required")
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("active") != "1" {
|
||||
writeSafeError(w, r, http.StatusBadRequest, "active=1 required")
|
||||
return
|
||||
}
|
||||
if !s.assertSessionOwnedByUser(w, r, sessionID, user.ID) {
|
||||
return
|
||||
}
|
||||
if s.taskRuns() == nil {
|
||||
writeJSONResponse(w, http.StatusOK, map[string]any{"runs": []taskRunResponse{}})
|
||||
return
|
||||
}
|
||||
listed, err := s.taskRuns().ListActiveBySession(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
writeDomainError(w, r, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
out := make([]taskRunResponse, 0, len(listed))
|
||||
for _, rec := range listed {
|
||||
if rec == nil {
|
||||
continue
|
||||
}
|
||||
if !s.isSystemAdminUser(r.Context(), user) && rec.UserID > 0 && rec.UserID != user.ID {
|
||||
continue
|
||||
}
|
||||
out = append(out, s.buildTaskRunResponse(rec))
|
||||
}
|
||||
writeJSONResponse(w, http.StatusOK, map[string]any{"runs": out})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetTaskRun(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.requirePrincipal(w, r)
|
||||
if !ok {
|
||||
|
||||
@@ -222,6 +222,14 @@ type TraceEntry struct {
|
||||
SimilarityToPrev float64 `json:"similarity_to_prev"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
Success bool `json:"success"`
|
||||
ExecErr string `json:"exec_err,omitempty"`
|
||||
ResultSuccess bool `json:"res_success,omitempty"`
|
||||
ResultError string `json:"res_error_reason,omitempty"`
|
||||
HasTrustedPatch bool `json:"has_trusted_patch,omitempty"`
|
||||
WroteThisInvocation bool `json:"wrote_this_invocation,omitempty"`
|
||||
MatErr string `json:"mat_err,omitempty"`
|
||||
RecoveredFromDisk bool `json:"recovered_from_disk,omitempty"`
|
||||
CancelReason string `json:"cancel_reason,omitempty"`
|
||||
}
|
||||
|
||||
// AppendTrace 追加一条执行轨迹(jsonl,天然即历史轨迹审计源)
|
||||
@@ -462,6 +470,3 @@ func SortedStepIDs(wf *WorkflowPlan) []string {
|
||||
sort.Strings(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -286,6 +287,9 @@ func (d *WorkflowDriver) Execute(ctx context.Context, wsDir string, backend agen
|
||||
}
|
||||
wg.Wait()
|
||||
if groupErr != nil {
|
||||
if cerr := contextErrString(runCtx); cerr != "" {
|
||||
slog.Warn("squad_parallel_cancelled", "error", groupErr.Error(), "cancel_reason", cerr, "parent_cancel", contextErrString(ctx))
|
||||
}
|
||||
wf.Status = "failed"
|
||||
_ = d.SaveWorkflow(wsDir, wf)
|
||||
_ = d.updateWorkspaceStatus(wsDir, "failed")
|
||||
@@ -611,7 +615,28 @@ func (d *WorkflowDriver) runOneStep(
|
||||
if newContent == "" && wroteThisInvocation {
|
||||
newContent = string(onDisk)
|
||||
}
|
||||
ok = execErr == nil && res != nil && res.Success && matErr == nil
|
||||
recoveredFromDisk := false
|
||||
if strings.TrimSpace(newContent) == "" {
|
||||
if again, rerr := os.ReadFile(prevOutPath); rerr == nil && deliverableWroteThisInvocation(prevContent, again) {
|
||||
newContent = string(again)
|
||||
wroteThisInvocation = true
|
||||
recoveredFromDisk = true
|
||||
}
|
||||
}
|
||||
ok = stepOutputAccepted(execErr, matErr, wroteThisInvocation, newContent, res)
|
||||
if !ok || recoveredFromDisk {
|
||||
slog.Info("squad_step_output_judge",
|
||||
"step_id", step.ID,
|
||||
"exec_err", errString(execErr),
|
||||
"res_success", res != nil && res.Success,
|
||||
"res_error_reason", resultErrorReason(res),
|
||||
"has_trusted_patch", hasTrustedPatch,
|
||||
"wrote_this_invocation", wroteThisInvocation,
|
||||
"mat_err", errString(matErr),
|
||||
"recovered_from_disk", recoveredFromDisk,
|
||||
"accepted", ok,
|
||||
)
|
||||
}
|
||||
sim := OutputSimilarity(prevContent, newContent)
|
||||
if mu != nil {
|
||||
mu.Lock()
|
||||
@@ -627,6 +652,14 @@ func (d *WorkflowDriver) runOneStep(
|
||||
SimilarityToPrev: sim,
|
||||
DurationMs: duration,
|
||||
Success: ok,
|
||||
ExecErr: errString(execErr),
|
||||
ResultSuccess: res != nil && res.Success,
|
||||
ResultError: resultErrorReason(res),
|
||||
HasTrustedPatch: hasTrustedPatch,
|
||||
WroteThisInvocation: wroteThisInvocation,
|
||||
MatErr: errString(matErr),
|
||||
RecoveredFromDisk: strings.TrimSpace(newContent) != "" && !(res != nil && res.Success),
|
||||
CancelReason: contextErrString(ctx),
|
||||
})
|
||||
if mu != nil {
|
||||
mu.Unlock()
|
||||
@@ -662,7 +695,7 @@ func (d *WorkflowDriver) runOneStep(
|
||||
}
|
||||
}
|
||||
|
||||
if execErr != nil {
|
||||
if !ok && execErr != nil {
|
||||
if streamer != nil {
|
||||
streamer.EmitPhase("step_failed", fmt.Sprintf("%s执行异常:%s", roleTitle, execErr.Error()), step.Role, 0, 0, 0)
|
||||
}
|
||||
@@ -680,7 +713,7 @@ func (d *WorkflowDriver) runOneStep(
|
||||
return fmt.Errorf("step %s (%s) backend execute: %w", step.ID, step.Role, execErr)
|
||||
}
|
||||
|
||||
if res != nil && !res.Success {
|
||||
if !ok && res != nil && !res.Success {
|
||||
errMsg := res.ErrorReason
|
||||
if streamer != nil {
|
||||
streamer.EmitPhase("step_failed", fmt.Sprintf("%s执行未成功:%s", roleTitle, errMsg), step.Role, 0, 0, 0)
|
||||
@@ -761,6 +794,55 @@ func (d *WorkflowDriver) runOneStep(
|
||||
return nil
|
||||
}
|
||||
|
||||
// stepOutputAccepted 以本轮写盘产物为事实源。runner 把 Success 标成 false
|
||||
// (例如 typed result 没带回 patch)但文件已经写好时,不让这一处否决步骤。
|
||||
func stepOutputAccepted(execErr, matErr error, wrote bool, newContent string, res *agent.Result) bool {
|
||||
if execErr != nil || matErr != nil {
|
||||
return false
|
||||
}
|
||||
if res != nil && res.Success {
|
||||
return true
|
||||
}
|
||||
if !wrote || strings.TrimSpace(newContent) == "" {
|
||||
return false
|
||||
}
|
||||
// 连接中断或可重试失败留下的文件可能是截断的,不能当成交付成功。
|
||||
// 交付物缺失/空结果但盘上已有正文时,以文件为准。
|
||||
if res != nil && (res.Retryable || hardRunnerFailure(res.ErrorReason)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hardRunnerFailure(reason string) bool {
|
||||
reason = strings.ToLower(strings.TrimSpace(reason))
|
||||
if reason == "" || strings.Contains(reason, "missing target deliverable") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func errString(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func resultErrorReason(res *agent.Result) string {
|
||||
if res == nil {
|
||||
return ""
|
||||
}
|
||||
return res.ErrorReason
|
||||
}
|
||||
|
||||
func contextErrString(ctx context.Context) string {
|
||||
if ctx == nil || ctx.Err() == nil {
|
||||
return ""
|
||||
}
|
||||
return ctx.Err().Error()
|
||||
}
|
||||
|
||||
func deliverableWroteThisInvocation(baseline string, onDisk []byte) bool {
|
||||
if len(bytes.TrimSpace(onDisk)) == 0 {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/agent"
|
||||
)
|
||||
|
||||
func TestStepOutputAccepted_DiskContentBeatsMissingDeliverable(t *testing.T) {
|
||||
missing := &agent.Result{Success: false, ErrorReason: "missing target deliverable output.md"}
|
||||
if !stepOutputAccepted(nil, nil, true, "# 调研报告\n正文", missing) {
|
||||
t.Fatal("non-empty file must be accepted when the runner only reported a missing deliverable")
|
||||
}
|
||||
truncated := &agent.Result{Success: false, ErrorReason: "Connection error.", Retryable: true}
|
||||
if stepOutputAccepted(nil, nil, true, "# partial", truncated) {
|
||||
t.Fatal("retryable connection failure must stay fail-closed even if a file exists")
|
||||
}
|
||||
if stepOutputAccepted(nil, nil, true, " \n", missing) {
|
||||
t.Fatal("blank content must not override a failed runner result")
|
||||
}
|
||||
if stepOutputAccepted(errors.New("backend"), nil, true, "# 调研报告", missing) {
|
||||
t.Fatal("execute error must reject the step")
|
||||
}
|
||||
if stepOutputAccepted(nil, errors.New("materialize"), true, "# 调研报告", &agent.Result{Success: true}) {
|
||||
t.Fatal("materialize error must reject the step")
|
||||
}
|
||||
if !stepOutputAccepted(nil, nil, false, "", &agent.Result{Success: true}) {
|
||||
t.Fatal("runner success without a new file still counts")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package taskrun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListActiveBySession_MemoryOnly(t *testing.T) {
|
||||
m := NewManager(nil, nil)
|
||||
m.mu.Lock()
|
||||
m.runs["run-live"] = &activeRun{record: &RunRecord{
|
||||
RunID: "run-live", SessionID: "sess-1", Status: "running", TaskKey: "research",
|
||||
}}
|
||||
m.runs["run-done"] = &activeRun{record: &RunRecord{
|
||||
RunID: "run-done", SessionID: "sess-1", Status: "completed",
|
||||
}}
|
||||
m.runs["other"] = &activeRun{record: &RunRecord{
|
||||
RunID: "other", SessionID: "sess-2", Status: "running",
|
||||
}}
|
||||
m.mu.Unlock()
|
||||
|
||||
listed, err := m.ListActiveBySession(context.Background(), "sess-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(listed) != 1 || listed[0].RunID != "run-live" {
|
||||
t.Fatalf("listed = %#v", listed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteRunningAnchor_NoContextManagerIsNoop(t *testing.T) {
|
||||
m := NewManager(nil, nil)
|
||||
m.writeRunningAnchor(context.Background(), &RunRecord{
|
||||
RunID: "run-1", AssistantMsgID: 9, TaskKey: "research", Status: "running",
|
||||
})
|
||||
}
|
||||
@@ -91,6 +91,37 @@ func (m *memIdemStore) HeartbeatLease(context.Context, string, string, time.Dura
|
||||
func (m *memIdemStore) ReleaseLease(context.Context, string, string) error { return nil }
|
||||
func (m *memIdemStore) ResetForResume(context.Context, string, int) error { return nil }
|
||||
func (m *memIdemStore) MarkOrphanedRunning(context.Context) (int, error) { return 0, nil }
|
||||
func (m *memIdemStore) ListActiveBySession(_ context.Context, sessionID string) ([]*RunRecord, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([]*RunRecord, 0)
|
||||
for _, rec := range m.byID {
|
||||
if rec == nil || rec.SessionID != sessionID {
|
||||
continue
|
||||
}
|
||||
if rec.Status != "pending" && rec.Status != "running" {
|
||||
continue
|
||||
}
|
||||
cpy := *rec
|
||||
out = append(out, &cpy)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (m *memIdemStore) LatestBySession(_ context.Context, sessionID string) (*RunRecord, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var best *RunRecord
|
||||
for _, rec := range m.byID {
|
||||
if rec == nil || rec.SessionID != sessionID {
|
||||
continue
|
||||
}
|
||||
if best == nil || rec.UpdatedAt.After(best.UpdatedAt) {
|
||||
cpy := *rec
|
||||
best = &cpy
|
||||
}
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
func TestClaimRun_ConcurrentSameKey_OneOwner(t *testing.T) {
|
||||
store := newMemIdemStore()
|
||||
|
||||
+117
-2
@@ -255,8 +255,11 @@ func (m *Manager) BindAssistantMsgID(ctx context.Context, runID string, assistan
|
||||
rec := *ar.record
|
||||
ar.mu.Unlock()
|
||||
if m.store != nil && m.store.Available() {
|
||||
return m.store.UpdateRun(ctx, &rec)
|
||||
if err := m.store.UpdateRun(ctx, &rec); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
m.writeRunningAnchor(ctx, &rec)
|
||||
return nil
|
||||
}
|
||||
if m.store != nil && m.store.Available() {
|
||||
@@ -266,11 +269,121 @@ func (m *Manager) BindAssistantMsgID(ctx context.Context, runID string, assistan
|
||||
}
|
||||
rec.AssistantMsgID = assistantMsgID
|
||||
rec.UpdatedAt = time.Now()
|
||||
return m.store.UpdateRun(ctx, rec)
|
||||
if err := m.store.UpdateRun(ctx, rec); err != nil {
|
||||
return err
|
||||
}
|
||||
m.writeRunningAnchor(ctx, rec)
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeRunningAnchor 在 run 仍进行中时就把 run_id / task_status 写进助手消息。
|
||||
// 刷新后窗口能按 taskRunId 重放进度,不必等终态 finalize。
|
||||
func (m *Manager) writeRunningAnchor(ctx context.Context, rec *RunRecord) {
|
||||
if m == nil || m.ctxMgr == nil || rec == nil || rec.AssistantMsgID <= 0 || rec.RunID == "" {
|
||||
return
|
||||
}
|
||||
meta := map[string]any{
|
||||
"run_id": rec.RunID,
|
||||
"task_key": rec.TaskKey,
|
||||
"task_status": "running",
|
||||
}
|
||||
if rec.WorkspaceID != "" {
|
||||
meta["workspace_id"] = rec.WorkspaceID
|
||||
}
|
||||
if err := m.ctxMgr.UpdateMessageMetadata(ctx, rec.AssistantMsgID, meta, "running"); err != nil {
|
||||
slog.Warn("taskrun_running_anchor_failed", "run_id", rec.RunID, "message_id", rec.AssistantMsgID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ListActiveBySession 返回该会话仍在 pending/running 且租约未过期的 run。
|
||||
// 内存中的活跃 run 与存储合并,覆盖尚未落库的窗口。
|
||||
func (m *Manager) ListActiveBySession(ctx context.Context, sessionID string) ([]*RunRecord, error) {
|
||||
if m == nil || sessionID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var stored []*RunRecord
|
||||
if m.store != nil && m.store.Available() {
|
||||
listed, err := m.store.ListActiveBySession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stored = listed
|
||||
}
|
||||
return mergeRuns(stored, m.memorySessionRuns(sessionID, true)), nil
|
||||
}
|
||||
|
||||
// LatestSessionRun 返回该会话最近一次 run(含已结束),供进度问询读取真实状态。
|
||||
func (m *Manager) LatestSessionRun(ctx context.Context, sessionID string) (*RunRecord, error) {
|
||||
if m == nil || sessionID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var best *RunRecord
|
||||
if m.store != nil && m.store.Available() {
|
||||
rec, err := m.store.LatestBySession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
best = rec
|
||||
}
|
||||
for _, mem := range m.memorySessionRuns(sessionID, false) {
|
||||
if best == nil || mem.UpdatedAt.After(best.UpdatedAt) {
|
||||
cpy := *mem
|
||||
best = &cpy
|
||||
}
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
func (m *Manager) memorySessionRuns(sessionID string, activeOnly bool) []*RunRecord {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]*RunRecord, 0)
|
||||
for _, ar := range m.runs {
|
||||
if ar == nil || ar.record == nil || ar.record.SessionID != sessionID {
|
||||
continue
|
||||
}
|
||||
ar.mu.Lock()
|
||||
rec := *ar.record
|
||||
ar.mu.Unlock()
|
||||
if activeOnly && rec.Status != "pending" && rec.Status != "running" {
|
||||
continue
|
||||
}
|
||||
out = append(out, &rec)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeRuns(stored, memory []*RunRecord) []*RunRecord {
|
||||
byID := make(map[string]*RunRecord, len(stored)+len(memory))
|
||||
order := make([]string, 0, len(stored)+len(memory))
|
||||
add := func(rec *RunRecord) {
|
||||
if rec == nil || rec.RunID == "" {
|
||||
return
|
||||
}
|
||||
if prev, ok := byID[rec.RunID]; ok {
|
||||
if rec.UpdatedAt.After(prev.UpdatedAt) {
|
||||
byID[rec.RunID] = rec
|
||||
}
|
||||
return
|
||||
}
|
||||
byID[rec.RunID] = rec
|
||||
order = append(order, rec.RunID)
|
||||
}
|
||||
for _, rec := range stored {
|
||||
add(rec)
|
||||
}
|
||||
for _, rec := range memory {
|
||||
add(rec)
|
||||
}
|
||||
out := make([]*RunRecord, 0, len(order))
|
||||
for _, id := range order {
|
||||
out = append(out, byID[id])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StartClaimedRun 仅对 ClaimRun 成功的 owner 启 worker;禁止在 Insert 冲突后调用。
|
||||
func (m *Manager) StartClaimedRun(parent context.Context, record *RunRecord, taskReq bizdev.TaskRunRequest, stageReq bizdev.StageRunRequest, resumeWorkDir string) (*RunRecord, error) {
|
||||
if record == nil || record.RunID == "" {
|
||||
@@ -303,6 +416,8 @@ func (m *Manager) StartClaimedRun(parent context.Context, record *RunRecord, tas
|
||||
m.runs[record.RunID] = ar
|
||||
m.mu.Unlock()
|
||||
|
||||
m.writeRunningAnchor(parent, record)
|
||||
|
||||
if m.pwSvc != nil && record.ProjectID > 0 {
|
||||
startedBy := record.UserID
|
||||
_, _ = m.pwSvc.CreateWorkflowRun(workerCtx, &projectworkflow.WorkflowRun{
|
||||
|
||||
+110
-1
@@ -26,6 +26,8 @@ type Store interface {
|
||||
GetByIdempotencyKey(ctx context.Context, key string) (*RunRecord, error)
|
||||
AppendEvent(ctx context.Context, runID string, seq int64, ev stream.StreamEvent) error
|
||||
GetRun(ctx context.Context, runID string) (*RunRecord, error)
|
||||
ListActiveBySession(ctx context.Context, sessionID string) ([]*RunRecord, error)
|
||||
LatestBySession(ctx context.Context, sessionID string) (*RunRecord, error)
|
||||
ListEvents(ctx context.Context, runID string, afterSeq int64) ([]StoredEvent, error)
|
||||
AcquireLease(ctx context.Context, runID, workerID string, ttl time.Duration) error
|
||||
HeartbeatLease(ctx context.Context, runID, workerID string, ttl time.Duration) error
|
||||
@@ -248,6 +250,108 @@ func (s *PGStore) GetRun(ctx context.Context, runID string) (*RunRecord, error)
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
func (s *PGStore) ListActiveBySession(ctx context.Context, sessionID string) ([]*RunRecord, error) {
|
||||
if !s.Available() {
|
||||
return nil, ErrStoreNotAvailable
|
||||
}
|
||||
if sessionID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
where := `WHERE session_id = $1 AND status IN ('pending', 'running')`
|
||||
if s.hasLeaseColumns(ctx) {
|
||||
where += ` AND (lease_expires_at IS NULL OR lease_expires_at > now())`
|
||||
}
|
||||
where += ` ORDER BY updated_at DESC`
|
||||
return s.queryRuns(ctx, where, sessionID)
|
||||
}
|
||||
|
||||
func (s *PGStore) LatestBySession(ctx context.Context, sessionID string) (*RunRecord, error) {
|
||||
if !s.Available() {
|
||||
return nil, ErrStoreNotAvailable
|
||||
}
|
||||
if sessionID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.queryRuns(ctx, `WHERE session_id = $1 ORDER BY updated_at DESC LIMIT 1`, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return rows[0], nil
|
||||
}
|
||||
|
||||
func (s *PGStore) queryRuns(ctx context.Context, where string, args ...any) ([]*RunRecord, error) {
|
||||
withLease := s.hasLeaseColumns(ctx)
|
||||
q := runSelectSQL(withLease) + " " + where
|
||||
rows, err := s.pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]*RunRecord, 0)
|
||||
for rows.Next() {
|
||||
rec, err := scanRunRecord(rows, withLease)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func runSelectSQL(withLease bool) string {
|
||||
if withLease {
|
||||
return `SELECT run_id, session_id, project_id, user_id, client_turn_id, idempotency_key,
|
||||
task_key, stage, status, workspace_id, assistant_message_id,
|
||||
error_message, worker_id, lease_expires_at, attempt, created_at, updated_at
|
||||
FROM task_runs`
|
||||
}
|
||||
return `SELECT run_id, session_id, project_id, user_id, client_turn_id, idempotency_key,
|
||||
task_key, stage, status, workspace_id, assistant_message_id,
|
||||
error_message, created_at, updated_at
|
||||
FROM task_runs`
|
||||
}
|
||||
|
||||
type runScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanRunRecord(row runScanner, withLease bool) (*RunRecord, error) {
|
||||
var rec RunRecord
|
||||
var projectID, assistantMsgID *int64
|
||||
var idempotencyKey *string
|
||||
var err error
|
||||
if withLease {
|
||||
err = row.Scan(
|
||||
&rec.RunID, &rec.SessionID, &projectID, &rec.UserID, &rec.ClientTurnID, &idempotencyKey,
|
||||
&rec.TaskKey, &rec.Stage, &rec.Status, &rec.WorkspaceID, &assistantMsgID,
|
||||
&rec.ErrorMessage, &rec.WorkerID, &rec.LeaseExpiresAt, &rec.Attempt,
|
||||
&rec.CreatedAt, &rec.UpdatedAt,
|
||||
)
|
||||
} else {
|
||||
err = row.Scan(
|
||||
&rec.RunID, &rec.SessionID, &projectID, &rec.UserID, &rec.ClientTurnID, &idempotencyKey,
|
||||
&rec.TaskKey, &rec.Stage, &rec.Status, &rec.WorkspaceID, &assistantMsgID,
|
||||
&rec.ErrorMessage, &rec.CreatedAt, &rec.UpdatedAt,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if projectID != nil {
|
||||
rec.ProjectID = *projectID
|
||||
}
|
||||
if assistantMsgID != nil {
|
||||
rec.AssistantMsgID = *assistantMsgID
|
||||
}
|
||||
if idempotencyKey != nil {
|
||||
rec.IdempotencyKey = *idempotencyKey
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
// GetByIdempotencyKey 按稳定幂等键查找既有 run;无键或未命中返回 (nil, nil)。
|
||||
func (s *PGStore) GetByIdempotencyKey(ctx context.Context, key string) (*RunRecord, error) {
|
||||
if !s.Available() {
|
||||
@@ -302,7 +406,6 @@ func (s *PGStore) ListEvents(ctx context.Context, runID string, afterSeq int64)
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
|
||||
// CancelOpenByProject cancels pending/running runs for a project inside an existing transaction.
|
||||
// Table ownership stays with taskrun; workflow terminate must call this instead of writing task_runs directly.
|
||||
func CancelOpenByProject(ctx context.Context, tx pgx.Tx, projectID int64, at time.Time, errMsg string) error {
|
||||
@@ -500,6 +603,12 @@ func (noopStore) AppendEvent(context.Context, string, int64, stream.StreamEvent)
|
||||
func (noopStore) GetRun(context.Context, string) (*RunRecord, error) {
|
||||
return nil, ErrStoreNotAvailable
|
||||
}
|
||||
func (noopStore) ListActiveBySession(context.Context, string) ([]*RunRecord, error) {
|
||||
return nil, ErrStoreNotAvailable
|
||||
}
|
||||
func (noopStore) LatestBySession(context.Context, string) (*RunRecord, error) {
|
||||
return nil, ErrStoreNotAvailable
|
||||
}
|
||||
func (noopStore) GetByIdempotencyKey(context.Context, string) (*RunRecord, error) {
|
||||
return nil, ErrStoreNotAvailable
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ import { saveFile } from './data/fileStore';
|
||||
import { cancelTaskRun, getTaskRun } from './api/taskRuns';
|
||||
import { cancelInflightAssistantMessages } from './lib/squadCompletionObservation';
|
||||
import { useRecoveredTaskPhases } from './hooks/useRecoveredTaskPhases';
|
||||
import { useActiveTaskRecovery } from './hooks/useActiveTaskRecovery';
|
||||
|
||||
export {
|
||||
ALL_INITIAL_DELIVERABLES,
|
||||
@@ -1012,6 +1013,14 @@ export default function App() {
|
||||
chatWindow.updateLiveMessage,
|
||||
);
|
||||
|
||||
useActiveTaskRecovery(
|
||||
authenticatedSessionId,
|
||||
chatWindow.phase === 'ready',
|
||||
chatWindow.allMessages,
|
||||
chatWindow.updateLiveMessage,
|
||||
chatWindow.appendLiveMessage,
|
||||
);
|
||||
|
||||
// P1: 多维度历史导航与分类统计、日历、引用状态
|
||||
const [categoryStats, setCategoryStats] = useState<ChatCategoryStat[]>([]);
|
||||
const [dateStats, setDateStats] = useState<ChatDateStat[]>([]);
|
||||
|
||||
@@ -230,6 +230,19 @@ export function mapRecordToMessage(r: RawChatMessageRecord): ChatMessage {
|
||||
: undefined;
|
||||
const msgId = meta.client_id || (r.id ? `msg-${r.id}` : `msg-tmp-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`);
|
||||
const phaseSnapshot = parseTaskPhaseSnapshot(meta.task_phase_snapshot);
|
||||
const hasRunID = typeof meta.run_id === 'string' && meta.run_id !== '';
|
||||
const rawTaskStatus = typeof meta.task_status === 'string' ? meta.task_status : '';
|
||||
let messageStatus = mapTaskStatusToMessageStatus(meta.task_status, r.role);
|
||||
// 有 run 锚点但还没有终态快照、也没有 task_status 时,不能当成已完成。
|
||||
if (
|
||||
r.role === 'assistant' &&
|
||||
hasRunID &&
|
||||
!(phaseSnapshot?.steps && phaseSnapshot.steps.length > 0) &&
|
||||
rawTaskStatus === '' &&
|
||||
messageStatus === 'completed'
|
||||
) {
|
||||
messageStatus = 'streaming_status';
|
||||
}
|
||||
return {
|
||||
id: msgId,
|
||||
clientTurnId: meta.client_turn_id || undefined,
|
||||
@@ -249,7 +262,7 @@ export function mapRecordToMessage(r: RawChatMessageRecord): ChatMessage {
|
||||
interactiveCard: meta.interactive_card,
|
||||
recommendedCases: meta.recommended_cases,
|
||||
quotes: meta.quotes || (meta.quote ? [meta.quote] : undefined),
|
||||
status: mapTaskStatusToMessageStatus(meta.task_status, r.role),
|
||||
status: messageStatus,
|
||||
errorInfo: persistedError || legacyBusyError || legacyTaskError,
|
||||
taskRequest,
|
||||
taskRunId: typeof meta.run_id === 'string' ? meta.run_id : undefined,
|
||||
|
||||
@@ -180,6 +180,26 @@ export async function cancelTaskRun(runId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export type ActiveTaskRun = {
|
||||
run_id: string;
|
||||
status: string;
|
||||
task_key?: string;
|
||||
workspace_id?: string;
|
||||
assistant_message_id?: number;
|
||||
error_message?: string;
|
||||
session_id?: string;
|
||||
};
|
||||
|
||||
export async function listActiveTaskRuns(sessionId: string): Promise<ActiveTaskRun[]> {
|
||||
const url = `/api/v1/tasks/runs?session_id=${encodeURIComponent(sessionId)}&active=1`;
|
||||
const res = await apiFetch(url, { headers: getBearerAuthHeaders() });
|
||||
if (!res.ok) {
|
||||
throw new Error(`list active task runs failed: HTTP ${res.status}`);
|
||||
}
|
||||
const body = (await res.json()) as { runs?: ActiveTaskRun[] };
|
||||
return Array.isArray(body.runs) ? body.runs : [];
|
||||
}
|
||||
|
||||
export async function getTaskRun(runId: string) {
|
||||
const res = await apiFetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}`, {
|
||||
headers: getBearerAuthHeaders(),
|
||||
|
||||
@@ -359,3 +359,9 @@ test('weighted mode does not let a cross-workflow title hijack an in-scope write
|
||||
const fuzzy = resolveTaskTemplateForMessage('编写建议', pool, null, 'main', 'weighted');
|
||||
assert.equal(fuzzy?.wrongWorkflow, undefined);
|
||||
});
|
||||
|
||||
test('progress question is read-only; explicit start still resolves research', () => {
|
||||
assert.equal(hasDocumentWriteIntent('调研报告编写进度怎么样了'), false);
|
||||
assert.equal(resolveTaskKeyFromMessage('调研报告编写进度怎么样了'), undefined);
|
||||
assert.equal(resolveTaskKeyFromMessage('开始做调研报告'), 'research');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useEffect } from 'react';
|
||||
import { listActiveTaskRuns, type ActiveTaskRun } from '../api/taskRuns';
|
||||
import {
|
||||
placeholderFromActiveTask,
|
||||
readActiveTasks,
|
||||
removeActiveTask,
|
||||
upsertActiveTask,
|
||||
type ActiveTaskCacheEntry,
|
||||
} from '../lib/activeTaskCache';
|
||||
import type { ChatMessage } from '../types/workbench';
|
||||
|
||||
const FRESH_MS = 20_000;
|
||||
|
||||
/**
|
||||
* 刷新后先用本地进行中缓存画出进度卡片,再用会话活跃 run 列表收敛。
|
||||
* 服务端没有的旧缓存会被清掉;刚写入、列表还没跟上的条目保留一小段时间。
|
||||
*/
|
||||
export function useActiveTaskRecovery(
|
||||
sessionId: string,
|
||||
windowReady: boolean,
|
||||
messages: ChatMessage[],
|
||||
updateMessage: (id: string, patch: Partial<ChatMessage>) => void,
|
||||
appendMessage: (message: ChatMessage) => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!sessionId || !windowReady) return;
|
||||
let cancelled = false;
|
||||
const cached = readActiveTasks(sessionId);
|
||||
for (const entry of cached) {
|
||||
if (messages.some((message) => message.taskRunId === entry.runId)) continue;
|
||||
const byClient = messages.find(
|
||||
(message) => message.id === entry.assistantClientId || message.clientMessageId === entry.assistantClientId,
|
||||
);
|
||||
if (byClient) {
|
||||
updateMessage(byClient.id, {
|
||||
taskRunId: entry.runId,
|
||||
status: 'streaming_status',
|
||||
livePhaseLabel: entry.lastPhaseLabel || byClient.livePhaseLabel || '任务执行中…',
|
||||
serverId: byClient.serverId || entry.serverMessageId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
appendMessage(placeholderFromActiveTask(entry));
|
||||
}
|
||||
|
||||
void listActiveTaskRuns(sessionId)
|
||||
.then((runs) => {
|
||||
if (cancelled) return;
|
||||
const activeIDs = new Set(runs.map((run) => run.run_id).filter(Boolean));
|
||||
for (const run of runs) {
|
||||
applyServerRun(sessionId, run, messages, updateMessage, appendMessage);
|
||||
}
|
||||
for (const entry of readActiveTasks(sessionId)) {
|
||||
if (activeIDs.has(entry.runId)) continue;
|
||||
const age = Date.now() - Date.parse(entry.startedAt);
|
||||
if (Number.isFinite(age) && age >= 0 && age < FRESH_MS) continue;
|
||||
removeActiveTask(entry.runId);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('active task sync failed', err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// 窗口 ready 后再画本地缓存并拉活跃 run,避免被随后的窗口替换清掉。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, windowReady]);
|
||||
}
|
||||
|
||||
function applyServerRun(
|
||||
sessionId: string,
|
||||
run: ActiveTaskRun,
|
||||
messages: ChatMessage[],
|
||||
updateMessage: (id: string, patch: Partial<ChatMessage>) => void,
|
||||
appendMessage: (message: ChatMessage) => void,
|
||||
) {
|
||||
if (!run.run_id) return;
|
||||
const existing =
|
||||
messages.find((message) => message.taskRunId === run.run_id) ||
|
||||
messages.find((message) => run.assistant_message_id && message.serverId === run.assistant_message_id);
|
||||
const label = existing?.livePhaseLabel || '任务执行中…';
|
||||
if (existing) {
|
||||
updateMessage(existing.id, {
|
||||
taskRunId: run.run_id,
|
||||
status: existing.status === 'completed' ? existing.status : 'streaming_status',
|
||||
taskWorkspaceId: run.workspace_id || existing.taskWorkspaceId,
|
||||
serverId: existing.serverId || run.assistant_message_id,
|
||||
livePhaseLabel: label,
|
||||
});
|
||||
} else {
|
||||
const entry: ActiveTaskCacheEntry = {
|
||||
sessionId,
|
||||
runId: run.run_id,
|
||||
assistantClientId: run.assistant_message_id ? `msg-${run.assistant_message_id}` : `ast-active-${run.run_id}`,
|
||||
taskKey: run.task_key || '',
|
||||
startedAt: new Date().toISOString(),
|
||||
lastPhaseLabel: label,
|
||||
serverMessageId: run.assistant_message_id,
|
||||
};
|
||||
appendMessage(placeholderFromActiveTask(entry));
|
||||
}
|
||||
const previous = readActiveTasks(sessionId).find((row) => row.runId === run.run_id);
|
||||
upsertActiveTask({
|
||||
sessionId,
|
||||
runId: run.run_id,
|
||||
assistantClientId: previous?.assistantClientId || existing?.id || `ast-active-${run.run_id}`,
|
||||
taskKey: run.task_key || previous?.taskKey || '',
|
||||
startedAt: previous?.startedAt || new Date().toISOString(),
|
||||
lastPhaseLabel: previous?.lastPhaseLabel || label,
|
||||
serverMessageId: run.assistant_message_id || previous?.serverMessageId,
|
||||
});
|
||||
}
|
||||
@@ -165,3 +165,18 @@ test('mapRecordToMessage: restores task phase snapshot into card fields', () =>
|
||||
assert.equal(msg.phaseSnapshot?.title, '用户初始需求收集表');
|
||||
assert.equal(msg.phaseEvents?.[1].detail, '门禁');
|
||||
});
|
||||
|
||||
test('mapRecordToMessage: run anchor without task_status stays streaming', () => {
|
||||
const msg = mapRecordToMessage({
|
||||
id: 42,
|
||||
session_id: 'sess-1',
|
||||
user_id: 1,
|
||||
role: 'assistant',
|
||||
content: '任务执行中…',
|
||||
metadata: { run_id: 'run-live', task_key: 'research' },
|
||||
day_key: '2026-09-28',
|
||||
created_at: '2026-09-28T03:27:00Z',
|
||||
});
|
||||
assert.equal(msg.taskRunId, 'run-live');
|
||||
assert.equal(msg.status, 'streaming_status');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
replayAttemptLimit,
|
||||
replayBackoffMs,
|
||||
shouldAbandonTaskReplay,
|
||||
} from './useRecoveredTaskPhases';
|
||||
|
||||
test('replay retries 502 and stops on auth or not-found', () => {
|
||||
assert.equal(replayAttemptLimit(false), 4);
|
||||
assert.equal(replayAttemptLimit(true), 2);
|
||||
assert.equal(replayBackoffMs(0), 500);
|
||||
assert.equal(replayBackoffMs(20), 5000);
|
||||
assert.equal(shouldAbandonTaskReplay(502, 0, 4), false);
|
||||
assert.equal(shouldAbandonTaskReplay(502, 3, 4), true);
|
||||
assert.equal(shouldAbandonTaskReplay(401, 0, 4), true);
|
||||
assert.equal(shouldAbandonTaskReplay(404, 0, 4), true);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { StreamEvent } from '../api/chatStream';
|
||||
import { readTaskRunSSEOnce, subscribeTaskRunEvents } from '../api/taskRuns';
|
||||
import { readTaskRunSSEOnce, subscribeTaskRunEvents, TaskRunHttpError } from '../api/taskRuns';
|
||||
import { applyPhaseEvent } from '../engine/phaseEventMapper';
|
||||
import {
|
||||
messageNeedsPhaseReplay,
|
||||
@@ -80,15 +80,38 @@ async function replayRun(
|
||||
cache.set(runId, { events: strip, label });
|
||||
updateMessage(messageId, { phaseEvents: strip, livePhaseLabel: label });
|
||||
};
|
||||
const attempts = replayAttemptLimit(live);
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
if (signal.aborted) return;
|
||||
try {
|
||||
if (live) {
|
||||
await subscribeTaskRunEvents(runId, { onEvent }, signal);
|
||||
return;
|
||||
}
|
||||
await readTaskRunSSEOnce(runId, 0, onEvent, signal, { replayOnly: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
if (!signal.aborted) {
|
||||
if (signal.aborted) return;
|
||||
const status = err instanceof TaskRunHttpError ? err.status : 0;
|
||||
if (shouldAbandonTaskReplay(status, attempt, attempts)) {
|
||||
console.warn('task phase replay failed', runId, err);
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, replayBackoffMs(attempt)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 终态重放多试几次;进行中订阅内部已有重试,外层只再补一次。 */
|
||||
export function replayAttemptLimit(live: boolean): number {
|
||||
return live ? 2 : 4;
|
||||
}
|
||||
|
||||
export function replayBackoffMs(attempt: number): number {
|
||||
return Math.min(500 * (attempt + 1), 5000);
|
||||
}
|
||||
|
||||
export function shouldAbandonTaskReplay(status: number, attempt: number, attempts: number): boolean {
|
||||
if (status === 401 || status === 404) return true;
|
||||
return attempt >= attempts - 1;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { fetchChatTurnStatus } from '../api/chatTurn';
|
||||
import { mapTaskStatusToMessageStatus } from '../api/chatWindow';
|
||||
import { listProjectWorkflowDocuments } from '../api/projectDocuments';
|
||||
import { createTaskRun, getTaskRun, resumeTaskRun, subscribeTaskRunEvents } from '../api/taskRuns';
|
||||
import { readActiveTasks, removeActiveTask, touchActiveTaskPhase, upsertActiveTask } from '../lib/activeTaskCache';
|
||||
import { buildTaskRequest, streamTask } from '../api/taskStream';
|
||||
import { applyPhaseEvent } from '../engine/phaseEventMapper';
|
||||
import { resolveTaskTemplateForMessage, wrongWorkflowGuidance } from '../engine/templateMatch';
|
||||
@@ -371,6 +372,24 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
|
||||
let accumulated = '';
|
||||
let settled = false;
|
||||
|
||||
const rememberRun = (runId: string, label?: string, serverMessageId?: number) => {
|
||||
if (!sessionId || !runId) return;
|
||||
const previous = readActiveTasks(sessionId).find((row) => row.runId === runId);
|
||||
upsertActiveTask({
|
||||
sessionId,
|
||||
runId,
|
||||
assistantClientId: asstId,
|
||||
taskKey: task.taskKey || previous?.taskKey || '',
|
||||
startedAt: previous?.startedAt || new Date().toISOString(),
|
||||
lastPhaseLabel: label || previous?.lastPhaseLabel,
|
||||
serverMessageId: serverMessageId || previous?.serverMessageId,
|
||||
});
|
||||
};
|
||||
const forgetRun = (runId?: string) => {
|
||||
const id = runId || retainedRunId;
|
||||
if (id) removeActiveTask(id);
|
||||
};
|
||||
|
||||
const historyOnly = !messages.some((m) => m.id === asstId);
|
||||
let historySnapshot = historyOnly
|
||||
? chatWindow.allMessages.find((m) => m.id === asstId)
|
||||
@@ -412,6 +431,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
|
||||
scheduleRetry(delayMs);
|
||||
return;
|
||||
}
|
||||
forgetRun();
|
||||
patchAsst({ status: 'failed', errorInfo: info, livePhaseLabel: undefined });
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
};
|
||||
@@ -472,9 +492,11 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
|
||||
if (controller.signal.aborted || settled) return;
|
||||
switch (ev.type) {
|
||||
case 'phase': {
|
||||
const phaseLabel = ev.phase?.message;
|
||||
if (retainedRunId) touchActiveTaskPhase(retainedRunId, phaseLabel);
|
||||
patchAsst((m) => ({
|
||||
status: 'streaming_status',
|
||||
livePhaseLabel: ev.phase?.message || m.livePhaseLabel,
|
||||
livePhaseLabel: phaseLabel || m.livePhaseLabel,
|
||||
phaseEvents: applyPhaseEvent(opts?.phaseEvents || m.phaseEvents || [], ev.phase),
|
||||
}));
|
||||
break;
|
||||
@@ -542,6 +564,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
|
||||
case 'done': {
|
||||
settled = true;
|
||||
const finishedRunId = retainedRunId || taskRunStream.activeTaskRunId;
|
||||
forgetRun(finishedRunId || undefined);
|
||||
taskRunStream.setRunId(sessionId, null);
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
void (async () => {
|
||||
@@ -583,6 +606,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
|
||||
const view = readRemoteTaskRun(remote);
|
||||
if (view.status === 'quality_failed') {
|
||||
settled = true;
|
||||
forgetRun(runId);
|
||||
patchAsst({
|
||||
status: 'quality_failed',
|
||||
livePhaseLabel: undefined,
|
||||
@@ -594,6 +618,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
|
||||
}
|
||||
if (isBlockedGovernanceStatus(view.status)) {
|
||||
settled = true;
|
||||
forgetRun(runId);
|
||||
patchAsst({
|
||||
status: 'blocked_pending_governance',
|
||||
content: GOVERNANCE_BLOCKED_HINT,
|
||||
@@ -606,6 +631,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
|
||||
}
|
||||
if (view.status === 'completed' || view.status === 'pending_author_confirmation') {
|
||||
settled = true;
|
||||
forgetRun(runId);
|
||||
const mapped = mapTaskStatusToMessageStatus(view.status, 'assistant') || 'completed';
|
||||
patchAsst({
|
||||
status: mapped,
|
||||
@@ -624,6 +650,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
|
||||
}
|
||||
if (view.status === 'interrupted') {
|
||||
settled = true;
|
||||
forgetRun(runId);
|
||||
patchAsst({
|
||||
status: 'failed',
|
||||
livePhaseLabel: undefined,
|
||||
@@ -693,6 +720,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
|
||||
}
|
||||
const resumed = await resumeTaskRun(runId);
|
||||
retainedRunId = resumed.run_id || runId;
|
||||
rememberRun(retainedRunId, '正在继续任务…');
|
||||
taskRunStream.setRunId(sessionId, retainedRunId);
|
||||
taskRunStream.setThinking(sessionId, true);
|
||||
patchAsst({
|
||||
@@ -720,6 +748,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
|
||||
const view = readRemoteTaskRun(remote);
|
||||
if (remote && shouldSubscribeExistingRun(view.status)) {
|
||||
retainedRunId = existingRunId;
|
||||
rememberRun(existingRunId, '正在重连任务…');
|
||||
taskRunStream.setRunId(sessionId, existingRunId);
|
||||
patchAsst({ taskRunId: existingRunId, livePhaseLabel: '正在重连任务…' });
|
||||
await subscribeWithGapHeal(existingRunId);
|
||||
@@ -738,6 +767,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
|
||||
? buildInterruptedTaskErrorInfo(view.errorMessage, view.workspaceId)
|
||||
: undefined,
|
||||
});
|
||||
forgetRun(existingRunId);
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
if (mapped === 'completed' || mapped === 'pending_author_confirmation') {
|
||||
await syncControlledDocsAfterTask(task.taskKey || 'intake');
|
||||
@@ -760,6 +790,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
|
||||
typeof created.assistant_msg_id === 'number' && created.assistant_msg_id > 0
|
||||
? created.assistant_msg_id
|
||||
: 0;
|
||||
rememberRun(created.run_id, '任务执行中…', assistantMsgId > 0 ? assistantMsgId : undefined);
|
||||
if (assistantMsgId > 0) {
|
||||
chatWindow.replaceMessageServerId(asstId, assistantMsgId);
|
||||
patchAsst({ taskRunId: created.run_id, serverId: assistantMsgId });
|
||||
@@ -774,6 +805,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
|
||||
livePhaseLabel: undefined,
|
||||
taskRunId: created.run_id,
|
||||
});
|
||||
forgetRun(created.run_id);
|
||||
taskRunStream.setRunId(sessionId, null);
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
await chatWindow.refresh().catch(() => undefined);
|
||||
@@ -789,6 +821,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
|
||||
void startObserver().catch(async (_err) => {
|
||||
if (settled) return;
|
||||
if (controller.signal.aborted) {
|
||||
forgetRun();
|
||||
patchAsst({ status: 'cancelled', livePhaseLabel: undefined });
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
return;
|
||||
@@ -805,6 +838,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
|
||||
// 原 run 仍在跑:重订阅,不 POST 新 run
|
||||
settled = false;
|
||||
retainedRunId = runId;
|
||||
rememberRun(runId, '正在重连任务…');
|
||||
patchAsst({ livePhaseLabel: '正在重连任务…', taskRunId: runId });
|
||||
await subscribeWithGapHeal(runId);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
readActiveTasks,
|
||||
removeActiveTask,
|
||||
touchActiveTaskPhase,
|
||||
upsertActiveTask,
|
||||
} from './activeTaskCache';
|
||||
|
||||
const mem = new Map<string, string>();
|
||||
|
||||
test('active task cache keeps an in-progress run and drops it when finished', () => {
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
localStorage: {
|
||||
getItem: (key: string) => mem.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
mem.set(key, value);
|
||||
},
|
||||
},
|
||||
};
|
||||
mem.clear();
|
||||
upsertActiveTask({
|
||||
sessionId: 'sess-1',
|
||||
runId: 'run-1',
|
||||
assistantClientId: 'turn:assistant',
|
||||
taskKey: 'research',
|
||||
startedAt: '2026-09-28T03:27:00Z',
|
||||
});
|
||||
upsertActiveTask({
|
||||
sessionId: 'sess-2',
|
||||
runId: 'run-2',
|
||||
assistantClientId: 'other',
|
||||
taskKey: 'intake',
|
||||
startedAt: '2026-09-28T03:28:00Z',
|
||||
});
|
||||
touchActiveTaskPhase('run-1', '正在撰写调研报告');
|
||||
const mine = readActiveTasks('sess-1');
|
||||
assert.equal(mine.length, 1);
|
||||
assert.equal(mine[0].runId, 'run-1');
|
||||
assert.equal(mine[0].lastPhaseLabel, '正在撰写调研报告');
|
||||
removeActiveTask('run-1');
|
||||
assert.equal(readActiveTasks('sess-1').length, 0);
|
||||
assert.equal(readActiveTasks('sess-2').length, 1);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { ChatMessage } from '../types/workbench';
|
||||
|
||||
/** 进行中任务锚点。窗口缓存只存服务端已落库消息,刷新会丢掉还没写回的进度。 */
|
||||
const STORAGE_KEY = 'smdf_agent_workbench_v6_active_tasks';
|
||||
|
||||
export type ActiveTaskCacheEntry = {
|
||||
sessionId: string;
|
||||
runId: string;
|
||||
assistantClientId: string;
|
||||
taskKey: string;
|
||||
startedAt: string;
|
||||
lastPhaseLabel?: string;
|
||||
serverMessageId?: number;
|
||||
};
|
||||
|
||||
function readAll(): ActiveTaskCacheEntry[] {
|
||||
if (typeof window === 'undefined' || !window.localStorage) return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(isEntry);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeAll(entries: ActiveTaskCacheEntry[]): void {
|
||||
if (typeof window === 'undefined' || !window.localStorage) return;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
|
||||
} catch {
|
||||
// 配额不足时放弃缓存,服务端锚点仍可恢复。
|
||||
}
|
||||
}
|
||||
|
||||
function isEntry(value: unknown): value is ActiveTaskCacheEntry {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const row = value as Partial<ActiveTaskCacheEntry>;
|
||||
return typeof row.sessionId === 'string' && typeof row.runId === 'string' && row.runId !== '';
|
||||
}
|
||||
|
||||
export function readActiveTasks(sessionId: string): ActiveTaskCacheEntry[] {
|
||||
if (!sessionId) return [];
|
||||
return readAll().filter((entry) => entry.sessionId === sessionId);
|
||||
}
|
||||
|
||||
export function upsertActiveTask(entry: ActiveTaskCacheEntry): void {
|
||||
if (!entry.sessionId || !entry.runId) return;
|
||||
const rest = readAll().filter((row) => row.runId !== entry.runId);
|
||||
writeAll([...rest, entry]);
|
||||
}
|
||||
|
||||
export function touchActiveTaskPhase(runId: string, label: string | undefined): void {
|
||||
if (!runId || !label) return;
|
||||
const all = readAll();
|
||||
const hit = all.find((row) => row.runId === runId);
|
||||
if (!hit || hit.lastPhaseLabel === label) return;
|
||||
hit.lastPhaseLabel = label;
|
||||
writeAll(all);
|
||||
}
|
||||
|
||||
export function removeActiveTask(runId: string): void {
|
||||
if (!runId) return;
|
||||
writeAll(readAll().filter((row) => row.runId !== runId));
|
||||
}
|
||||
|
||||
export function placeholderFromActiveTask(entry: ActiveTaskCacheEntry): ChatMessage {
|
||||
return {
|
||||
id: entry.assistantClientId || `ast-active-${entry.runId}`,
|
||||
clientMessageId: entry.assistantClientId || undefined,
|
||||
serverId: entry.serverMessageId,
|
||||
sessionId: entry.sessionId,
|
||||
role: 'assistant',
|
||||
authorName: '项目助手',
|
||||
content: '任务执行中…',
|
||||
timestamp: new Date().toTimeString().substring(0, 5),
|
||||
status: 'streaming_status',
|
||||
livePhaseLabel: entry.lastPhaseLabel || '任务执行中…',
|
||||
taskRunId: entry.runId,
|
||||
taskRequest: entry.taskKey
|
||||
? {
|
||||
taskKey: entry.taskKey,
|
||||
projectName: '',
|
||||
customerName: '',
|
||||
deviceType: '',
|
||||
sessionId: entry.sessionId,
|
||||
userId: 0,
|
||||
projectId: 0,
|
||||
clientTurnId: '',
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user