From b820e0070ddf67e2e9ee490ac1210e38c955e31e Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 28 Sep 2026 06:09:42 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(taskrun):=20=E5=88=B7=E6=96=B0=E5=90=8E?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E8=BF=9B=E8=A1=8C=E4=B8=AD=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E5=B9=B6=E7=BA=A0=E6=AD=A3=E8=BF=9B=E5=BA=A6=E9=97=AE=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 进行中的 run 提前写下消息锚点,并按会话列出活跃任务;进度疑问句改为查询真实状态,交付物已落盘且不是传输失败时不再被空结果否决。 --- internal/agent/deliverable_test.go | 25 +++ internal/app/app.go | 1 + internal/app/chat_adapters.go | 27 +++ internal/chat/ports.go | 1 + internal/chat/session.go | 16 ++ internal/coordinator/context.go | 2 + internal/coordinator/engine.go | 22 ++ internal/coordinator/engine_query.go | 50 +++++ .../coordinator/session_run_reply_test.go | 45 ++++ internal/intent/recognizer_matchers_task.go | 29 +++ internal/intent/recognizer_test.go | 24 +++ internal/server/server.go | 1 + internal/server/sse_wave0_test.go | 6 + internal/server/task_run_handlers.go | 42 ++++ internal/squad/gov.go | 31 +-- internal/squad/workflow.go | 192 +++++++++++++----- internal/squad/workflow_outcome_test.go | 31 +++ internal/taskrun/active_session_test.go | 36 ++++ internal/taskrun/claim_idempotency_test.go | 31 +++ internal/taskrun/manager.go | 119 ++++++++++- internal/taskrun/store.go | 111 +++++++++- web/src/App.tsx | 9 + web/src/api/chatWindow.ts | 15 +- web/src/api/taskRuns.ts | 20 ++ web/src/engine/templateMatch.test.ts | 6 + web/src/hooks/useActiveTaskRecovery.ts | 114 +++++++++++ web/src/hooks/useChatWindow.test.ts | 15 ++ web/src/hooks/useRecoveredTaskPhases.test.ts | 18 ++ web/src/hooks/useRecoveredTaskPhases.ts | 41 +++- web/src/hooks/useTaskRunAttempts.ts | 36 +++- web/src/lib/activeTaskCache.test.ts | 44 ++++ web/src/lib/activeTaskCache.ts | 95 +++++++++ 32 files changed, 1173 insertions(+), 82 deletions(-) create mode 100644 internal/coordinator/session_run_reply_test.go create mode 100644 internal/squad/workflow_outcome_test.go create mode 100644 internal/taskrun/active_session_test.go create mode 100644 web/src/hooks/useActiveTaskRecovery.ts create mode 100644 web/src/hooks/useRecoveredTaskPhases.test.ts create mode 100644 web/src/lib/activeTaskCache.test.ts create mode 100644 web/src/lib/activeTaskCache.ts diff --git a/internal/agent/deliverable_test.go b/internal/agent/deliverable_test.go index 1539a259..023e9e4f 100644 --- a/internal/agent/deliverable_test.go +++ b/internal/agent/deliverable_test.go @@ -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() diff --git a/internal/app/app.go b/internal/app/app.go index 2d160c4b..10421fd9 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) } diff --git a/internal/app/chat_adapters.go b/internal/app/chat_adapters.go index c1a5c136..6c7fd273 100644 --- a/internal/app/chat_adapters.go +++ b/internal/app/chat_adapters.go @@ -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 +} diff --git a/internal/chat/ports.go b/internal/chat/ports.go index 5acd651f..4fdb5bc2 100644 --- a/internal/chat/ports.go +++ b/internal/chat/ports.go @@ -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. diff --git a/internal/chat/session.go b/internal/chat/session.go index 6bb6ea48..7ae7f034 100644 --- a/internal/chat/session.go +++ b/internal/chat/session.go @@ -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 diff --git a/internal/coordinator/context.go b/internal/coordinator/context.go index 831d0458..7dfb9ab7 100644 --- a/internal/coordinator/context.go +++ b/internal/coordinator/context.go @@ -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 注入;不序列化。 diff --git a/internal/coordinator/engine.go b/internal/coordinator/engine.go index 260bb712..c8ecc344 100644 --- a/internal/coordinator/engine.go +++ b/internal/coordinator/engine.go @@ -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 diff --git a/internal/coordinator/engine_query.go b/internal/coordinator/engine_query.go index dfdc47ce..7d2d0e45 100644 --- a/internal/coordinator/engine_query.go +++ b/internal/coordinator/engine_query.go @@ -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 { diff --git a/internal/coordinator/session_run_reply_test.go b/internal/coordinator/session_run_reply_test.go new file mode 100644 index 00000000..374e2d76 --- /dev/null +++ b/internal/coordinator/session_run_reply_test.go @@ -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") + } +} diff --git a/internal/intent/recognizer_matchers_task.go b/internal/intent/recognizer_matchers_task.go index d9a4e9a8..2511e64b 100644 --- a/internal/intent/recognizer_matchers_task.go +++ b/internal/intent/recognizer_matchers_task.go @@ -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 diff --git a/internal/intent/recognizer_test.go b/internal/intent/recognizer_test.go index 78f58015..9670f065 100644 --- a/internal/intent/recognizer_test.go +++ b/internal/intent/recognizer_test.go @@ -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{ diff --git a/internal/server/server.go b/internal/server/server.go index a6059857..b95426e6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/internal/server/sse_wave0_test.go b/internal/server/sse_wave0_test.go index 6c967ce1..cba91b0f 100644 --- a/internal/server/sse_wave0_test.go +++ b/internal/server/sse_wave0_test.go @@ -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) diff --git a/internal/server/task_run_handlers.go b/internal/server/task_run_handlers.go index e2dc98bc..021986e9 100644 --- a/internal/server/task_run_handlers.go +++ b/internal/server/task_run_handlers.go @@ -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 { diff --git a/internal/squad/gov.go b/internal/squad/gov.go index 28efe723..354b3614 100644 --- a/internal/squad/gov.go +++ b/internal/squad/gov.go @@ -26,7 +26,7 @@ import ( // 步骤状态常量(与既有 workflow.json 的 legacy 状态字符串完全兼容) const ( StepStatePending = "pending" - StepStateExecuting = "running" // legacy 值保持不变 + StepStateExecuting = "running" // legacy 值保持不变 StepStateVerifying = "verifying" // 新增:校验态 StepStateRepairing = "repairing" // 新增:修复态 StepStateCompleted = "completed" @@ -213,15 +213,23 @@ func (l *BudgetLedger) UsageSummary() string { // TraceEntry 单次底层 agent 调用的轨迹记录 type TraceEntry struct { - Seq int `json:"seq"` - StepID string `json:"step_id"` - Role string `json:"role"` - Timestamp time.Time `json:"ts"` - PromptDigest string `json:"prompt_digest"` - OutputDigest string `json:"output_digest"` - SimilarityToPrev float64 `json:"similarity_to_prev"` - DurationMs int64 `json:"duration_ms"` - Success bool `json:"success"` + Seq int `json:"seq"` + StepID string `json:"step_id"` + Role string `json:"role"` + Timestamp time.Time `json:"ts"` + PromptDigest string `json:"prompt_digest"` + OutputDigest string `json:"output_digest"` + 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 } - - - diff --git a/internal/squad/workflow.go b/internal/squad/workflow.go index d8ca8a33..9889d43d 100644 --- a/internal/squad/workflow.go +++ b/internal/squad/workflow.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "os" "path/filepath" "strings" @@ -264,28 +265,31 @@ func (d *WorkflowDriver) Execute(ctx context.Context, wsDir string, backend agen for _, s := range parallelGroup { sCopy := s wg.Add(1) - go func() { - defer wg.Done() - defer func() { - if rec := recover(); rec != nil { - mu.Lock() - if groupErr == nil { - groupErr = fmt.Errorf("parallel step panic: %v", rec) - } - mu.Unlock() - } - }() - if err := d.runOneStep(runCtx, wsDir, sCopy, backend, timeout, opts.StepNotify, &mu, &producedFiles, ledger); err != nil { - mu.Lock() - if groupErr == nil { - groupErr = err - } - mu.Unlock() - } - }() + go func() { + defer wg.Done() + defer func() { + if rec := recover(); rec != nil { + mu.Lock() + if groupErr == nil { + groupErr = fmt.Errorf("parallel step panic: %v", rec) + } + mu.Unlock() + } + }() + if err := d.runOneStep(runCtx, wsDir, sCopy, backend, timeout, opts.StepNotify, &mu, &producedFiles, ledger); err != nil { + mu.Lock() + if groupErr == nil { + groupErr = err + } + mu.Unlock() + } + }() } 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") @@ -335,25 +339,25 @@ func (d *WorkflowDriver) Execute(ctx context.Context, wsDir string, backend agen Duration: time.Since(start), }, rErr } - // 治理:受控迁移至执行态(legacy 重跑场景直接放行) - markStepExecuting(wf, step.ID) - if err := d.runOneStep(runCtx, wsDir, step, backend, timeout, opts.StepNotify, &mu, &producedFiles, ledger); err != nil { - // A5 非关键步骤降级:记录 degraded 继续推进,收口统一标 degraded - if !step.IsCritical() { - markStepDegraded(wsDir, wf, step, err) - stepsRun++ + // 治理:受控迁移至执行态(legacy 重跑场景直接放行) + markStepExecuting(wf, step.ID) + if err := d.runOneStep(runCtx, wsDir, step, backend, timeout, opts.StepNotify, &mu, &producedFiles, ledger); err != nil { + // A5 非关键步骤降级:记录 degraded 继续推进,收口统一标 degraded + if !step.IsCritical() { + markStepDegraded(wsDir, wf, step, err) + stepsRun++ + _ = d.SaveWorkflow(wsDir, wf) + continue + } + wf.Status = "failed" _ = d.SaveWorkflow(wsDir, wf) - continue + _ = d.updateWorkspaceStatus(wsDir, "failed") + return &WorkflowExecutionResult{ + Success: false, + ErrorReason: err.Error(), + Duration: time.Since(start), + }, err } - wf.Status = "failed" - _ = d.SaveWorkflow(wsDir, wf) - _ = d.updateWorkspaceStatus(wsDir, "failed") - return &WorkflowExecutionResult{ - Success: false, - ErrorReason: err.Error(), - Duration: time.Since(start), - }, err - } stepsRun++ // 治理:FSM 受控迁移 + 步骤归档锁定 + 软线提醒 if tErr := TransitionStep(wf, step.ID, StepStateCompleted); tErr == nil { @@ -549,13 +553,13 @@ func (d *WorkflowDriver) runOneStep( } var ( - res *agent.Result - execErr error - matErr error - duration int64 - ok bool - lastAssess agent.FailureAssessment - basePrompt = string(taskData) + res *agent.Result + execErr error + matErr error + duration int64 + ok bool + lastAssess agent.FailureAssessment + basePrompt = string(taskData) ) for attempt := 1; attempt <= agent.MaxTransientStepInvocations; attempt++ { onDisk, _ := os.ReadFile(prevOutPath) @@ -611,22 +615,51 @@ 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() } traceSeq := len(LoadTrace(wsDir)) + 1 _ = AppendTrace(wsDir, TraceEntry{ - Seq: traceSeq, - StepID: step.ID, - Role: step.Role, - Timestamp: time.Now(), - PromptDigest: digestString(string(taskData)), - OutputDigest: digestString(newContent), - SimilarityToPrev: sim, - DurationMs: duration, - Success: ok, + Seq: traceSeq, + StepID: step.ID, + Role: step.Role, + Timestamp: time.Now(), + PromptDigest: digestString(string(taskData)), + OutputDigest: digestString(newContent), + 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 diff --git a/internal/squad/workflow_outcome_test.go b/internal/squad/workflow_outcome_test.go new file mode 100644 index 00000000..bfcfd083 --- /dev/null +++ b/internal/squad/workflow_outcome_test.go @@ -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") + } +} diff --git a/internal/taskrun/active_session_test.go b/internal/taskrun/active_session_test.go new file mode 100644 index 00000000..3dd7c631 --- /dev/null +++ b/internal/taskrun/active_session_test.go @@ -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", + }) +} diff --git a/internal/taskrun/claim_idempotency_test.go b/internal/taskrun/claim_idempotency_test.go index 1cc97d9e..be213e4d 100644 --- a/internal/taskrun/claim_idempotency_test.go +++ b/internal/taskrun/claim_idempotency_test.go @@ -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() diff --git a/internal/taskrun/manager.go b/internal/taskrun/manager.go index 0500d316..452ba5a5 100644 --- a/internal/taskrun/manager.go +++ b/internal/taskrun/manager.go @@ -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{ diff --git a/internal/taskrun/store.go b/internal/taskrun/store.go index 93613d8e..65aadc67 100644 --- a/internal/taskrun/store.go +++ b/internal/taskrun/store.go @@ -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 } diff --git a/web/src/App.tsx b/web/src/App.tsx index 94dbae78..897d51a6 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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([]); const [dateStats, setDateStats] = useState([]); diff --git a/web/src/api/chatWindow.ts b/web/src/api/chatWindow.ts index 2c52a8be..a6616c61 100644 --- a/web/src/api/chatWindow.ts +++ b/web/src/api/chatWindow.ts @@ -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, diff --git a/web/src/api/taskRuns.ts b/web/src/api/taskRuns.ts index 9dd2583b..e09f9f17 100644 --- a/web/src/api/taskRuns.ts +++ b/web/src/api/taskRuns.ts @@ -180,6 +180,26 @@ export async function cancelTaskRun(runId: string): Promise { } } +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 { + 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(), diff --git a/web/src/engine/templateMatch.test.ts b/web/src/engine/templateMatch.test.ts index e43e0a8e..060a9472 100644 --- a/web/src/engine/templateMatch.test.ts +++ b/web/src/engine/templateMatch.test.ts @@ -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'); +}); diff --git a/web/src/hooks/useActiveTaskRecovery.ts b/web/src/hooks/useActiveTaskRecovery.ts new file mode 100644 index 00000000..afea9b2c --- /dev/null +++ b/web/src/hooks/useActiveTaskRecovery.ts @@ -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) => 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) => 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, + }); +} diff --git a/web/src/hooks/useChatWindow.test.ts b/web/src/hooks/useChatWindow.test.ts index b0df1a02..e6042c22 100644 --- a/web/src/hooks/useChatWindow.test.ts +++ b/web/src/hooks/useChatWindow.test.ts @@ -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'); +}); diff --git a/web/src/hooks/useRecoveredTaskPhases.test.ts b/web/src/hooks/useRecoveredTaskPhases.test.ts new file mode 100644 index 00000000..f978e312 --- /dev/null +++ b/web/src/hooks/useRecoveredTaskPhases.test.ts @@ -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); +}); diff --git a/web/src/hooks/useRecoveredTaskPhases.ts b/web/src/hooks/useRecoveredTaskPhases.ts index 3ce61b4c..f1e90bf7 100644 --- a/web/src/hooks/useRecoveredTaskPhases.ts +++ b/web/src/hooks/useRecoveredTaskPhases.ts @@ -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 }); }; - try { - if (live) { - await subscribeTaskRunEvents(runId, { onEvent }, signal); + 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; - } - await readTaskRunSSEOnce(runId, 0, onEvent, signal, { replayOnly: true }); - } catch (err) { - if (!signal.aborted) { - console.warn('task phase replay failed', runId, err); + } catch (err) { + 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; +} diff --git a/web/src/hooks/useTaskRunAttempts.ts b/web/src/hooks/useTaskRunAttempts.ts index 3367ec47..3b9ba574 100644 --- a/web/src/hooks/useTaskRunAttempts.ts +++ b/web/src/hooks/useTaskRunAttempts.ts @@ -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; diff --git a/web/src/lib/activeTaskCache.test.ts b/web/src/lib/activeTaskCache.test.ts new file mode 100644 index 00000000..7a2a4f57 --- /dev/null +++ b/web/src/lib/activeTaskCache.test.ts @@ -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(); + +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); +}); diff --git a/web/src/lib/activeTaskCache.ts b/web/src/lib/activeTaskCache.ts new file mode 100644 index 00000000..57cfd765 --- /dev/null +++ b/web/src/lib/activeTaskCache.ts @@ -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; + 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, + }; +} From 5bc88faf4fe0cf84f97eb8ee7161327123539ca4 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 28 Sep 2026 06:14:35 +0000 Subject: [PATCH 2/2] =?UTF-8?q?docs(test):=20=E8=AE=B0=E5=BD=95=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E6=81=A2=E5=A4=8D=E7=9A=84=E7=9F=AD=E6=B5=8B=E4=B8=8E?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E6=9E=84=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 短测、前端测试和构建已通过。架构边界失败来自基线上的 coordinator 导入,浏览器与真实 PI 链路尚未在新代码上复验。 --- .doc/audit/ISSUES.md | 4 +- .../2026-09-28-refresh-task-state-recovery.md | 57 ++ ...refresh-task-state-recovery-test-report.md | 80 +++ .../arch-check.txt | 16 + .../go-test-short.txt | 99 +++ .../go-vet.txt | 1 + .../npm-build.txt | 291 ++++++++ .../npm-test.txt | 651 ++++++++++++++++++ 8 files changed, 1198 insertions(+), 1 deletion(-) create mode 100644 .doc/ops/short-term/2026-09-28-refresh-task-state-recovery.md create mode 100644 .doc/test-reports/2026-09-28-refresh-task-state-recovery-test-report.md create mode 100644 .doc/test-reports/assets/2026-09-28-refresh-task-state-recovery/arch-check.txt create mode 100644 .doc/test-reports/assets/2026-09-28-refresh-task-state-recovery/go-test-short.txt create mode 100644 .doc/test-reports/assets/2026-09-28-refresh-task-state-recovery/go-vet.txt create mode 100644 .doc/test-reports/assets/2026-09-28-refresh-task-state-recovery/npm-build.txt create mode 100644 .doc/test-reports/assets/2026-09-28-refresh-task-state-recovery/npm-test.txt diff --git a/.doc/audit/ISSUES.md b/.doc/audit/ISSUES.md index fd05c662..9f8ac83f 100644 --- a/.doc/audit/ISSUES.md +++ b/.doc/audit/ISSUES.md @@ -6,13 +6,15 @@ | issue_id | 模块 / 检索词 | 问题 | 严重度 | issue_state | owner | 证据日期 / 范围 | 唯一主记录 | 经验 | |---|---|---|---|---|---|---|---|---| +| ISSUE-20260928-progress-query-trigger | intent / matchTriggerTask / QUERY_PROGRESS / 编写进度 | 询问调研报告编写进度时,写动词加任务名词被识别成启动新任务,进度回复不读真实 run | medium | verifying | Cursor | 2026-09-28;「调研报告编写进度怎么样了」单测为 QUERY_PROGRESS;「开始做调研报告」仍为 research。浏览器复验见测试报告 | [主记录](../ops/short-term/2026-09-28-refresh-task-state-recovery.md) | 疑问进度句必须让行查询;显式「开始/重新」仍启动任务 | +| ISSUE-20260928-step-empty-output | squad / stepOutputAccepted / output_digest / e3b0c442 / MaterializeTargets | 步骤已把正文写到 output.md,判定仍因 runner Success=false 或空 patch 记空输出失败 | high | verifying | Cursor | 2026-09-28;11:27 `research_1790566028724181453` 的 06_draft 盘上 41KB 但 digest 为空;单测覆盖缺交付物但盘上有正文,以及连接错误仍失败关闭 | [主记录](../ops/short-term/2026-09-28-refresh-task-state-recovery.md) | 传输/可重试失败留下的文件可能是截断的,不能当成功;缺交付物且盘上非空才接受 | | ISSUE-20260928-task-write-verb | intent / hasTaskWriteVerb / 帮我定 / 制定 / AMBIGUOUS_FALLBACK | 「帮我定/制定」设计开发计划任务书未进入任务触发,对话只有掂量阶段模板 | medium | resolved | Cursor | 2026-09-28;test sess-p8-u33 msg 89/90;意图单测 | [主记录](../ops/short-term/2026-09-28-test-user-doc-issues.md) | [LESSON-20260928-task-write-verb-visible-refusal](../ops/lessons/2026-09-28-task-write-verb-visible-refusal.md) | | ISSUE-20260928-workflow-scope-reply | coordinator / ClarificationQuestion / ErrPrerequisiteNotMet / rd_design_plan | 跨工作流澄清与研发前置未满足时,原因没有进入对话 | medium | resolved | Cursor | 2026-09-28;协调器单测。test 原例停在动词未命中,未走到本分支 | [主记录](../ops/short-term/2026-09-28-test-user-doc-issues.md) | [LESSON-20260928-task-write-verb-visible-refusal](../ops/lessons/2026-09-28-task-write-verb-visible-refusal.md) | | ISSUE-20260928-quality-failed-success-copy | bizdev / quality_failed / 正文已保存至 / output.md | 质量未通过仍声称正文已保存,并写出工作区绝对路径 | medium | resolved | Cursor | 2026-09-28;test cdo_quote_1790560155851280170;同步回复单测 | [主记录](../ops/short-term/2026-09-28-test-user-doc-issues.md) | [LESSON-20260928-task-write-verb-visible-refusal](../ops/lessons/2026-09-28-task-write-verb-visible-refusal.md) | | ISSUE-20260928-pi-runner-data-dir | agent / PI runner / DATA_DIR / MISSING_TARGET / 18866 | test 启动时根 .env 的 dev runner 地址盖过本环境 sidecar,工作区被重写到空目录 | high | resolved | Cursor | 2026-09-28;dev `4f581306`。当时 test 二进制仍是修复前制品 | [验收报告](../test-reports/2026-09-28-pi-runner-env-mismatch-test-report.md) | — | | ISSUE-20260928-material-chunk-embed | knowledge / chunk index failed / 8192 / 34635 tokens | 材料分块批超过嵌入模型上下文,索引失败 | medium | open | — | 2026-09-28;test 日志 project 8,文件 W020251117353305579317.docx | [主记录](../ops/short-term/2026-09-28-test-user-doc-issues.md) | 本轮不修 | | ISSUE-20260928-attachment-only-turn | chat / chat/stream / message cannot be empty / HTTP_400 / attachments | 只上传文档、不输入文字时 turn 被空消息 400 拒绝,前端不读信封,用户看不到任何回复 | high | resolved | Cursor | 2026-09-28;dev :8080 重载后浏览器仅附件得到梳理审阅,无关文字先回答再追问;pending 分支 API 200 | [主记录](../ops/short-term/2026-09-28-attachment-only-turn.md) | 空消息门禁要放在附件判定之后;4xx 必须读信封。见[测试报告](../test-reports/2026-09-28-attachment-only-turn-test-report.md) | -| ISSUE-20260928-task-run-replay-502 | web / useRecoveredTaskPhases / tasks/runs / ERR_HTTP2_PROTOCOL_ERROR / 502 | 历史失败气泡恢复订阅 task run 事件时出现 HTTP/2 协议错误和 502,与附件空消息 400 无因果关系 | medium | investigating | Cursor | 2026-09-28;用户控制台 `run_1790558010622744831`;本轮未改订阅逻辑 | [主记录](../ops/short-term/2026-09-28-attachment-only-turn.md#范围外) | 未修,保持观察 | +| ISSUE-20260928-task-run-replay-502 | web / useRecoveredTaskPhases / tasks/runs / ERR_HTTP2_PROTOCOL_ERROR / 502 | 历史失败气泡恢复订阅 task run 事件时出现 HTTP/2 协议错误和 502,与附件空消息 400 无因果关系 | medium | verifying | Cursor | 2026-09-28;终态重放改为最多 4 次退避,401/404 立即停止;进行中订阅外层只再补 1 次,避免和内部 40 次重试相乘。尚未在真实 502 上复验 | [主记录](../ops/short-term/2026-09-28-refresh-task-state-recovery.md) | 重放失败要可重试;鉴权与不存在不要空转 | | ISSUE-20260928-activation-preview-precheck | web / projectSettings / activation-preview / NO_GOVERNED_DOCUMENT / clientDutyIssues | 全部文档未纳入人工审批时前端仍 POST activation-preview,控制台 400 | medium | resolved | Cursor | 2026-09-28;dev 项目 6023 默认计划无 activation-preview 请求,兼任后 preview 200 | [主记录](../ops/short-term/2026-09-28-activation-preview-precheck.md#issue-20260928-activation-preview-precheck) | [LESSON-20260928-activation-preview-client-gate](../ops/lessons/2026-09-28-activation-preview-client-gate.md) | | ISSUE-20260928-admin-secret-password-form | web / EnvSettingsSection / password / form / secrets | 管理台 5 个密钥输入不在 form 内,Chromium 报 password DOM 警告 | low | resolved | Cursor | 2026-09-28;dev 模型与密钥页 passwordInputs=5 outsideForm=0 | [主记录](../ops/short-term/2026-09-28-activation-preview-precheck.md#issue-20260928-admin-secret-password-form) | 密码框放进 form;autocomplete 不能单独消除该 DOM 警告 | | ISSUE-20260928-squad-commit-authority | bizdev / docflow / dutyAuthorizer / uow permission denied / OnBehalfOf / bd_intake | 非编制人触发受控小队任务时,账本提交用触发者身份,被编制人职责门拒绝,正文无法落盘 | high | fixing | Cursor | 2026-09-28;dev task_runs user 74 ≠ author 4(doc_proj6022_bd_intake);单测已绿,dev 重跑未做 | [主记录](../ops/short-term/2026-09-28-squad-commit-authority.md#issue-20260928-squad-commit-authority) | [LESSON-20260928-squad-commit-as-author](../ops/lessons/2026-09-28-squad-commit-as-author.md) | diff --git a/.doc/ops/short-term/2026-09-28-refresh-task-state-recovery.md b/.doc/ops/short-term/2026-09-28-refresh-task-state-recovery.md new file mode 100644 index 00000000..ed8cbc5c --- /dev/null +++ b/.doc/ops/short-term/2026-09-28-refresh-task-state-recovery.md @@ -0,0 +1,57 @@ +--- +title: "刷新后恢复任务进度并纠正进度问询" +task_id: refresh-task-state-recovery-2026-09-28 +status: in_progress +created: 2026-09-28 +updated: 2026-09-28 +owner: Cursor +task_type: fix +policy_revision: "AGENTS.md + agent-worktree-workflow @ 2026-09-21" +branch: fix/refresh-task-state-recovery-2026-09-28 +worktree: .worktrees/refresh-task-state-recovery-2026-09-28 +base_dev_sha: "4c42d1bf74e262930bcf127e517ab6b76a0dda71" +write_scope: + - internal + - web + - .doc +mutex: refresh-task-state-recovery +review_state: pending +integration_state: branch_only +deployment: not_loaded +cleanup: pending +remote_sync: not_requested +--- + +# 刷新后恢复任务进度并纠正进度问询 + +## 用户目标与授权 + +用户发起调研报告后,聊天下方的任务进度在刷新页面时消失。随后两次询问「调研报告编写进度怎么样了」,没有看到调研报告生成。用户要求浏览器和服务器同时记下进行中的任务:刷新或重连后先显示本地缓存,再按会话和任务标识向服务器收敛。授权范围同时包含进度问句被当成新任务,以及 11:27 / 11:38 两次步骤被判空输出失败。 + +## 检索 + +- 问题台账命中 ISSUE-20260928-task-run-replay-502(历史失败气泡重放 502)。本轮给重放补了退避,并保持终态 `replay_only`。 +- catalog overlap 探测无硬重叠。mutex 为 `refresh-task-state-recovery`,不占用 `chat+coordinator` 与 `bizdev+docflow`。 + +## 根因 + +1. `run_id` 与阶段快照只在任务终态写回助手消息。窗口缓存只保存服务端窗口,刷新后没有锚点,进度卡片无法重放。 +2. 「调研报告编写进度怎么样了」含写动词「编写」和任务名词「调研报告」。`matchTriggerTask` 排在进度查询之前,于是又启动了 research。进度回复只读模板,不读真实 run。 +3. 步骤判定把 runner 的 `Success=false` 当成失败,即使盘上已经有正文。连接错误留下的文件仍可能是截断的,这条不能放宽。 + +## 修复 + +- run 启动和绑定助手消息时写入 `{run_id, task_status: running}`。 +- `GET /api/v1/tasks/runs?session_id=&active=1` 只返回当前用户、当前会话里仍为 pending/running 的 run。 +- 浏览器 `smdf_agent_workbench_v6_active_tasks` 在窗口就绪后先画出进行中卡片,再用活跃列表收敛;终态删除缓存。 +- 有 run 锚点但还没有 `task_status` 和阶段快照时,消息保持 `streaming_status`。终态重放对 502 退避,401/404 不再试。 +- 疑问进度句让行 `QUERY_PROGRESS`。回复读取该会话最近一次 run 的真实状态。 +- 盘上已有非空正文,且失败原因不是传输/可重试错误时,步骤接受该文件。空 patch 不覆盖已有 `output.md`。 + +## 验证 + +见 [测试报告](../../test-reports/2026-09-28-refresh-task-state-recovery-test-report.md)。 + +## 五态 + +合入与 dev 重载完成后回写。当前仍是任务分支上的实现。 diff --git a/.doc/test-reports/2026-09-28-refresh-task-state-recovery-test-report.md b/.doc/test-reports/2026-09-28-refresh-task-state-recovery-test-report.md new file mode 100644 index 00000000..c9f760f6 --- /dev/null +++ b/.doc/test-reports/2026-09-28-refresh-task-state-recovery-test-report.md @@ -0,0 +1,80 @@ +# 刷新后任务状态恢复 测试报告 + +## 一、 测试背景 (Background) + +刷新页面后,进行中的调研报告进度卡片消失。询问「调研报告编写进度怎么样了」又启动了新的 research 工作区。11:27 的 `step_06_draft` 与 11:38 的并行步骤被记成空输出失败,盘上的 `output.md` 已经有正文。 + +## 二、 测试目标 (Test Objectives) + +- 进行中的 run 在刷新后仍能回到进度卡片。 +- 疑问进度句查询真实 run,不新建工作区。 +- 盘上已有非空正文且不是传输失败时,步骤不再被空结果否决。 +- 连接错误留下的文件仍然失败关闭。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS:Linux。工作树:`.worktrees/refresh-task-state-recovery-2026-09-28`,基线 `4c42d1bf`。 +- 层级:Go/接口短测、前端单测与构建、架构边界检查。产品 PI 浏览器链路在 dev 重载后另记,本文件先记录已跑命令。 +- 证据目录:`.doc/test-reports/assets/2026-09-28-refresh-task-state-recovery/`。 + +## 四、 预期结果 (Expected Results) + +- `go vet ./...` 无输出。 +- 本改动涉及的包 `go test -short` 通过。 +- `web` 的 `npm test` 与 `npm run build` 通过。 +- 「调研报告编写进度怎么样了」不是写意图;「开始做调研报告」解析为 research。 +- 缺交付物但盘上有正文时步骤接受;`Connection error` 且可重试时拒绝。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例 1:意图与进度回复 + +- 输入:`go test -short -count=1 ./internal/intent/ ./internal/coordinator/` +- 实际输出:两包 `ok`(intent 0.179s,coordinator 0.034s,见 `go-test-short.txt`)。新增用例「调研报告编写进度怎么样了」为 `QUERY_PROGRESS`,「开始做调研报告」为 `research`。 +- 判定:PASS +- 耗时:见短测日志 + +### 用例 2:空输出判定与空 patch + +- 输入:`go test -short -count=1 ./internal/squad/ ./internal/agent/` +- 实际输出:两包 `ok`(squad 0.041s,agent 2.252s)。连接错误仍失败关闭的既有用例通过。 +- 判定:PASS + +### 用例 3:锚点、活跃列表、消息映射、重放退避、本地缓存 + +- 输入:`go test -short ./internal/taskrun/ ./internal/server/`;`npm test` 中的 `activeTaskCache`、`useRecoveredTaskPhases`、`useChatWindow`、`templateMatch` +- 实际输出:taskrun `ok` 0.171s,server `ok` 20.914s。`npm test` 532 通过、vitest 34 通过,`NPM_TEST:0`。 +- 判定:PASS + +### 用例 4:全量短测与架构边界 + +- 输入:`go vet ./...`;`go test -short -count=1 ./...`;`./scripts/arch-check.sh --mode block` +- 实际输出:`VET:0`。短测唯一失败是 `internal/archtest`:`NEW import coordinator internal/coordinator/engine_task.go projectworkflow`。该文件相对基线 `4c42d1bf` 的 diff 为 0,导入来自已合入的 `agent-reply-llm-narrative`,不是本任务新增。`ARCH:1` 与之相同。 +- 判定:本任务包 PASS;架构边界 FAIL 记为基线既有项,本任务未改该导入 + +### 用例 5:前端构建 + +- 输入:`npm run build` +- 实际输出:`NPM_BUILD:0`,precompress 写出 66 个 br/gz。 +- 判定:PASS + +### 用例 6:dev 浏览器与真实 PI Runner + +- 输入:发起调研报告,执行中刷新,完成后刷新,再问编写进度。 +- 实际输出:本段写入时尚未在已加载新代码的 dev 上运行。 +- 判定:未运行 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +| 检查 | 结果 | +|---|---| +| go vet ./... | PASS(VET:0) | +| go test -short ./... | 除既有 archtest 外 PASS | +| arch-check --mode block | FAIL(基线既有 coordinator→projectworkflow) | +| npm test | PASS(532 + 34) | +| npm run build | PASS | +| dev 浏览器 + 真实 PI | 未运行 | + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +实现与单测、前端构建已通过。交付终点还差:把候选合入 local dev、重载服务后,用真实 PI Runner 走刷新恢复和进度问句。架构边界失败不由本 diff 引入,不在本 mutex 里改 `engine_task.go`。 diff --git a/.doc/test-reports/assets/2026-09-28-refresh-task-state-recovery/arch-check.txt b/.doc/test-reports/assets/2026-09-28-refresh-task-state-recovery/arch-check.txt new file mode 100644 index 00000000..d0a67780 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-28-refresh-task-state-recovery/arch-check.txt @@ -0,0 +1,16 @@ +arch-check mode=block +boundaries=/home/weichen/projects/Agent-System/.worktrees/refresh-task-state-recovery-2026-09-28/.doc/system-design/module-boundaries.yaml +--- FAIL: TestArchitectureBoundaries (0.19s) + arch_test.go:32: archtest findings=1 excepted=0 unmatched=1 + import=1 + sql_write=0 + path=0 + event=0 + REGRESSION import: 1 > baseline 0 + NEW import coordinator internal/coordinator/engine_task.go projectworkflow (production import coordinator -> projectworkflow) + +FAIL +FAIL chat-agent-system/internal/archtest 0.195s +FAIL +arch-check: new, expired, or unowned findings failed go test ./internal/archtest +ARCH:1 diff --git a/.doc/test-reports/assets/2026-09-28-refresh-task-state-recovery/go-test-short.txt b/.doc/test-reports/assets/2026-09-28-refresh-task-state-recovery/go-test-short.txt new file mode 100644 index 00000000..bd4f6d95 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-28-refresh-task-state-recovery/go-test-short.txt @@ -0,0 +1,99 @@ +? chat-agent-system/cmd/artifact-vault [no test files] +ok chat-agent-system/cmd/bizdev-cli 2.985s +? chat-agent-system/cmd/dataset [no test files] +ok chat-agent-system/cmd/document-content-reconcile 0.003s +? chat-agent-system/cmd/document-revision-backfill [no test files] +? chat-agent-system/cmd/eventmode [no test files] +? chat-agent-system/cmd/events [no test files] +? chat-agent-system/cmd/maintpage [no test files] +? chat-agent-system/cmd/recategorize [no test files] +ok chat-agent-system/cmd/server 0.012s +ok chat-agent-system/internal/agent 2.252s +ok chat-agent-system/internal/agentx 0.023s +ok chat-agent-system/internal/agentx/abtest 0.004s +ok chat-agent-system/internal/agentx/llm 0.024s +ok chat-agent-system/internal/agentx/session 0.008s +ok chat-agent-system/internal/agentx/tools 0.006s +ok chat-agent-system/internal/app 0.046s +ok chat-agent-system/internal/app/chatflow 0.018s +ok chat-agent-system/internal/app/docadvance 0.015s +ok chat-agent-system/internal/app/docflow 0.009s +ok chat-agent-system/internal/app/docnotify 0.005s +ok chat-agent-system/internal/app/docproject 0.003s +? chat-agent-system/internal/app/eventsconsumer [no test files] +ok chat-agent-system/internal/app/fav 0.007s +ok chat-agent-system/internal/app/intentflow 0.088s +ok chat-agent-system/internal/app/stageflow 0.010s +ok chat-agent-system/internal/app/uow 0.005s +--- FAIL: TestArchitectureBoundaries (0.50s) + arch_test.go:32: archtest findings=1 excepted=0 unmatched=1 + import=1 + sql_write=0 + path=0 + event=0 + REGRESSION import: 1 > baseline 0 + NEW import coordinator internal/coordinator/engine_task.go projectworkflow (production import coordinator -> projectworkflow) + +FAIL +FAIL chat-agent-system/internal/archtest 0.507s +ok chat-agent-system/internal/artifactvault 0.005s +ok chat-agent-system/internal/auth 1.494s +ok chat-agent-system/internal/authz 0.007s +ok chat-agent-system/internal/bizdev 0.418s +ok chat-agent-system/internal/chat 0.067s +ok chat-agent-system/internal/config 0.009s +ok chat-agent-system/internal/conflictresolve 0.006s +? chat-agent-system/internal/contracts/vocabulary [no test files] +ok chat-agent-system/internal/coordinator 0.034s +ok chat-agent-system/internal/ctxmgr 0.174s +ok chat-agent-system/internal/dataset 0.010s +ok chat-agent-system/internal/db 0.007s +ok chat-agent-system/internal/dialoguectx 0.007s +ok chat-agent-system/internal/doccontent 0.004s +ok chat-agent-system/internal/docduty 0.004s +ok chat-agent-system/internal/docgov 0.006s +ok chat-agent-system/internal/docquery 0.004s +ok chat-agent-system/internal/docrevision 0.017s +ok chat-agent-system/internal/errcode 0.007s +ok chat-agent-system/internal/errorevents 0.008s +ok chat-agent-system/internal/events 0.012s +ok chat-agent-system/internal/freedoc 0.035s +ok chat-agent-system/internal/fsutil 0.005s +ok chat-agent-system/internal/handoff 0.008s +ok chat-agent-system/internal/httpx 0.004s +ok chat-agent-system/internal/intent 0.179s +ok chat-agent-system/internal/jevdecide 0.274s +ok chat-agent-system/internal/kb 0.013s +ok chat-agent-system/internal/localpg 0.598s +ok chat-agent-system/internal/maintenance 0.005s +ok chat-agent-system/internal/materialindex 0.007s +ok chat-agent-system/internal/maturity 0.007s +ok chat-agent-system/internal/mineru 3.008s +ok chat-agent-system/internal/notification 0.005s +? chat-agent-system/internal/observability [no test files] +ok chat-agent-system/internal/projectcontext 0.005s +ok chat-agent-system/internal/projectinstance 0.010s +ok chat-agent-system/internal/projectworkflow 0.003s +ok chat-agent-system/internal/quote 0.012s +ok chat-agent-system/internal/replyexport 0.002s +ok chat-agent-system/internal/server 20.914s +ok chat-agent-system/internal/squad 0.041s +ok chat-agent-system/internal/storage 0.003s +ok chat-agent-system/internal/stream 0.003s +ok chat-agent-system/internal/stream/transport 0.003s +ok chat-agent-system/internal/supplementimpact 0.003s +ok chat-agent-system/internal/swboot 0.070s +ok chat-agent-system/internal/sysadmin 0.003s +ok chat-agent-system/internal/taskrun 0.171s +ok chat-agent-system/internal/template 1.059s +ok chat-agent-system/internal/traceimpact 0.003s +ok chat-agent-system/internal/upload 0.002s +? chat-agent-system/internal/workflow [no test files] +ok chat-agent-system/internal/workflow/runtime 0.004s +ok chat-agent-system/internal/workflowdef 0.004s +ok chat-agent-system/internal/workflowscope 0.002s +ok chat-agent-system/internal/workspace 0.003s +? chat-agent-system/templates [no test files] +? chat-agent-system/web [no test files] +FAIL +TEST:1 diff --git a/.doc/test-reports/assets/2026-09-28-refresh-task-state-recovery/go-vet.txt b/.doc/test-reports/assets/2026-09-28-refresh-task-state-recovery/go-vet.txt new file mode 100644 index 00000000..d6e4f48a --- /dev/null +++ b/.doc/test-reports/assets/2026-09-28-refresh-task-state-recovery/go-vet.txt @@ -0,0 +1 @@ +VET:0 diff --git a/.doc/test-reports/assets/2026-09-28-refresh-task-state-recovery/npm-build.txt b/.doc/test-reports/assets/2026-09-28-refresh-task-state-recovery/npm-build.txt new file mode 100644 index 00000000..ccdacb88 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-28-refresh-task-state-recovery/npm-build.txt @@ -0,0 +1,291 @@ + +> shuimu-workbench@1.0.0 build +> tsc && vite build && node scripts/precompress-dist.mjs + +vite v7.3.6 building client environment for production... +transforming... +✓ 4169 modules transformed. +rendering chunks... +computing gzip size... +dist/index.html 0.72 kB │ gzip: 0.45 kB +dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff 4.42 kB +dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 4.93 kB +dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 5.21 kB +dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB +dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff 5.98 kB +dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff 6.19 kB +dist/assets/KaTeX_Size1-Regular-C195tn64.woff 6.50 kB +dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 6.91 kB +dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 6.91 kB +dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf 7.59 kB +dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff 7.66 kB +dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff 7.72 kB +dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 9.64 kB +dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 10.34 kB +dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf 10.36 kB +dist/assets/KaTeX_Script-Regular-D5yQViql.woff 10.59 kB +dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 11.32 kB +dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 11.35 kB +dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf 11.51 kB +dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 12.03 kB +dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 12.22 kB +dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf 12.23 kB +dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff 12.32 kB +dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf 12.34 kB +dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf 12.37 kB +dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff 13.21 kB +dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff 13.30 kB +dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 13.57 kB +dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff 14.11 kB +dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff 14.41 kB +dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff 16.03 kB +dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 16.40 kB +dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 16.44 kB +dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf 16.65 kB +dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 16.78 kB +dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 16.99 kB +dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff 18.67 kB +dist/assets/KaTeX_Math-Italic-DA0__PXp.woff 18.75 kB +dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff 19.41 kB +dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf 19.44 kB +dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf 19.57 kB +dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf 19.58 kB +dist/assets/KaTeX_Main-Italic-BMLOBm91.woff 19.68 kB +dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf 22.36 kB +dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf 24.50 kB +dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 25.32 kB +dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 26.27 kB +dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf 27.56 kB +dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 28.08 kB +dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff 29.91 kB +dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff 30.77 kB +dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf 31.20 kB +dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf 31.31 kB +dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf 32.97 kB +dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff 33.52 kB +dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf 33.58 kB +dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf 51.34 kB +dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf 53.58 kB +dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf 63.63 kB +dist/assets/index-Bq7tVxen.css 6.58 kB │ gzip: 1.99 kB +dist/assets/katex-BOvhZl6_.css 30.25 kB │ gzip: 8.09 kB +dist/assets/channel-DHR9WBLM.js 0.12 kB │ gzip: 0.13 kB +dist/assets/katexRender-DyvI-2y4.js 0.15 kB │ gzip: 0.15 kB +dist/assets/init-Gi6I4Gst.js 0.15 kB │ gzip: 0.13 kB +dist/assets/chunk-2Q5K7J3B-CHsI7zem.js 0.19 kB │ gzip: 0.16 kB +dist/assets/chunk-JWPE2WC7-CeOXuZlw.js 0.23 kB │ gzip: 0.17 kB +dist/assets/chunk-XXDRQBXY-Cu_mBr7x.js 0.23 kB │ gzip: 0.21 kB +dist/assets/git-branch-D2R7ViCv.js 0.26 kB │ gzip: 0.21 kB +dist/assets/chunk-5VM5RSS4-lzTX0tjp.js 0.37 kB │ gzip: 0.27 kB +dist/assets/stateDiagram-v2-MP3YSRHH-dJKbyi-1.js 0.43 kB │ gzip: 0.30 kB +dist/assets/classDiagram-ZZMXUADV-CTPa2PBn.js 0.47 kB │ gzip: 0.32 kB +dist/assets/classDiagram-v2-VYDZK3BY-CTPa2PBn.js 0.47 kB │ gzip: 0.32 kB +dist/assets/chunk-POPQ4Y6H-B2jpZUkp.js 0.51 kB │ gzip: 0.36 kB +dist/assets/swimlanesDiagram-VR7AAH4N-jP-BQxXC.js 0.55 kB │ gzip: 0.37 kB +dist/assets/calculator-K0r_LA6K.js 0.56 kB │ gzip: 0.30 kB +dist/assets/infoDiagram-27XIBGKW-BO8R-0BH.js 0.59 kB │ gzip: 0.40 kB +dist/assets/sizeCapture-INFHLROL-KBPVYQON.js 0.94 kB │ gzip: 0.53 kB +dist/assets/downloadDocx-Ep6Y-mWJ.js 1.13 kB │ gzip: 0.77 kB +dist/assets/ordinal-Cboi1Yqb.js 1.19 kB │ gzip: 0.57 kB +dist/assets/railroadDiagram-O6MQD6OU-3o3NI4V-.js 1.61 kB │ gzip: 0.76 kB +dist/assets/MathBlockView-CP3gIAfz.js 1.67 kB │ gzip: 0.97 kB +dist/assets/abnfDiagram-VCTEODGH-Bv_S-VQO.js 1.81 kB │ gzip: 0.89 kB +dist/assets/chunk-F27PBJKO-DwG2Zkg1.js 1.88 kB │ gzip: 0.83 kB +dist/assets/pegDiagram-XKGWAZYB-BRKb5jpA.js 1.96 kB │ gzip: 0.92 kB +dist/assets/ebnfDiagram-PWID7BFC-CAlTqs1O.js 2.01 kB │ gzip: 0.88 kB +dist/assets/xlsxRenderer-BQPATLFB.js 2.79 kB │ gzip: 1.69 kB +dist/assets/arc-DOxBXeCg.js 3.44 kB │ gzip: 1.48 kB +dist/assets/MermaidBlockView-BNZ12-FU.js 3.69 kB │ gzip: 1.80 kB +dist/assets/diagram-Z3DM3KII-iO8-m9Hl.js 4.21 kB │ gzip: 1.83 kB +dist/assets/defaultLocale-DX6XiGOO.js 4.69 kB │ gzip: 2.18 kB +dist/assets/renderClipboardHtml-BXoFMW-3.js 5.18 kB │ gzip: 1.78 kB +dist/assets/ExcelViewer-BZSJZn5Y.js 5.55 kB │ gzip: 2.21 kB +dist/assets/linear-BDZQyaJG.js 5.66 kB │ gzip: 2.31 kB +dist/assets/diagram-UQ7AKVKN-BnBJ_2R3.js 6.08 kB │ gzip: 2.62 kB +dist/assets/pieDiagram-E7YTZNPT-nrtuGrNr.js 6.29 kB │ gzip: 2.62 kB +dist/assets/ChatExportModal-Bfjz4958.js 7.58 kB │ gzip: 3.29 kB +dist/assets/diagram-S7CK7UJ4-T-Bxl4t1.js 8.22 kB │ gzip: 3.67 kB +dist/assets/cynefinDiagram-5FMLGOSQ-CnH3Z2JB.js 10.10 kB │ gzip: 3.63 kB +dist/assets/stateDiagram-D77RDMKH-DVKtcg-v.js 10.36 kB │ gzip: 3.62 kB +dist/assets/dagre-GXQ25YYZ-4R6bcum3.js 10.68 kB │ gzip: 4.03 kB +dist/assets/diagram-VSXAHHWV-DLnsWaIP.js 10.72 kB │ gzip: 4.13 kB +dist/assets/chunk-SVP7TREG-BhgGIDFD.js 15.74 kB │ gzip: 4.41 kB +dist/assets/diagram-VX7I27RA-Duotn1-e.js 15.96 kB │ gzip: 5.70 kB +dist/assets/ishikawaDiagram-5VMMS53U-Cg4LfJyo.js 17.47 kB │ gzip: 6.64 kB +dist/assets/kanban-definition-UXKFOSKX-BALPAjTC.js 20.29 kB │ gzip: 7.20 kB +dist/assets/BlockEditorModal-BOLlSDTH.js 22.24 kB │ gzip: 6.47 kB +dist/assets/mindmap-definition-YA3MSWOX-pgZMQ8uL.js 23.18 kB │ gzip: 7.83 kB +dist/assets/sankeyDiagram-P5KCCOFB-cHAs8eQM.js 23.20 kB │ gzip: 8.56 kB +dist/assets/journeyDiagram-3NMN7TZE-DXSneWK0.js 23.57 kB │ gzip: 8.35 kB +dist/assets/wardleyDiagram-VM6X3IG4-83YMC1gW.js 25.50 kB │ gzip: 6.79 kB +dist/assets/gitGraphDiagram-WWUBYQGX-3ajohVe1.js 29.42 kB │ gzip: 8.70 kB +dist/assets/FilePreviewPanel-BaAl4sya.js 30.12 kB │ gzip: 8.97 kB +dist/assets/erDiagram-RLTQ6QDP-BDpOdOIc.js 30.79 kB │ gzip: 10.59 kB +dist/assets/timeline-definition-24CTP7MA-d7EudEZG.js 30.83 kB │ gzip: 10.15 kB +dist/assets/requirementDiagram-BXWQKSXE-2YVxwHhy.js 31.00 kB │ gzip: 9.73 kB +dist/assets/quadrantDiagram-AXDQQJYC-DHNugfXZ.js 34.01 kB │ gzip: 9.98 kB +dist/assets/layout-BrexQxSk.js 35.44 kB │ gzip: 12.75 kB +dist/assets/chunk-IMKFNOWR-DL3J6pk5.js 38.08 kB │ gzip: 12.51 kB +dist/assets/docxRenderer-Ilu9VYYG.js 40.90 kB │ gzip: 12.04 kB +dist/assets/blockDiagram-I7D4REHJ-ozi4Xk9Q.js 41.76 kB │ gzip: 13.58 kB +dist/assets/vennDiagram-4TSXK5OY-Dswu0LDK.js 41.96 kB │ gzip: 15.70 kB +dist/assets/xychartDiagram-S5SC5T6Z-Do0p5dm5.js 44.51 kB │ gzip: 12.60 kB +dist/assets/chunk-TICWLB2K-CsHC9dlc.js 49.18 kB │ gzip: 15.74 kB +dist/assets/flowDiagram-HODETNUW-BGwlzTt0.js 61.85 kB │ gzip: 19.87 kB +dist/assets/c4Diagram-7LVT6UL2-MxYk0iqL.js 65.47 kB │ gzip: 18.79 kB +dist/assets/ganttDiagram-EL5Y4UJY-ijkjYohu.js 69.70 kB │ gzip: 23.56 kB +dist/assets/AdminConsoleView-C6f6_5zf.js 74.34 kB │ gzip: 18.18 kB +dist/assets/cose-bilkent-JH36ORCC-DW6Qeju_.js 81.70 kB │ gzip: 22.47 kB +dist/assets/swimlanes-42K2YHIH-BaJ44xBg.js 115.79 kB │ gzip: 42.51 kB +dist/assets/sequenceDiagram-WJ2MYXX4-BqEzakUS.js 116.96 kB │ gzip: 31.13 kB +dist/assets/architectureDiagram-5GKGNRK7-BW5RJe7u.js 151.83 kB │ gzip: 43.14 kB +dist/assets/DocxViewer-thvChHTS.js 175.87 kB │ gzip: 52.31 kB +dist/assets/katex-HP8lGamR.js 261.31 kB │ gzip: 77.57 kB +dist/assets/katex-BJxdM42i.js 261.44 kB │ gzip: 77.80 kB +dist/assets/index-BXtlAAcH.js 365.75 kB │ gzip: 104.72 kB +dist/assets/xlsx-Cig0qvpi.js 424.39 kB │ gzip: 141.60 kB +dist/assets/cytoscape.esm-BB4DxJjf.js 443.04 kB │ gzip: 141.98 kB +dist/assets/mermaid.core-C3o1vFDn.js 682.57 kB │ gzip: 168.90 kB +dist/assets/cynefin-OW5HDTMX-Ca8YX88f.js 687.91 kB │ gzip: 154.14 kB +dist/assets/index-cbAtdfhj.js 814.04 kB │ gzip: 239.32 kB + +(!) Some chunks are larger than 500 kB after minification. Consider: +- Using dynamic import() to code-split the application +- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks +- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit. +✓ built in 8.74s +precompress: 66 candidate(s) under /home/weichen/projects/Agent-System/.worktrees/refresh-task-state-recovery-2026-09-28/web/dist + br assets/AdminConsoleView-C6f6_5zf.js 74340 → 14847 (−80.0%) + gzip assets/AdminConsoleView-C6f6_5zf.js 74340 → 17742 (−76.1%) + br assets/BlockEditorModal-BOLlSDTH.js 22241 → 5389 (−75.8%) + gzip assets/BlockEditorModal-BOLlSDTH.js 22241 → 6375 (−71.3%) + br assets/ChatExportModal-Bfjz4958.js 7581 → 2732 (−64.0%) + gzip assets/ChatExportModal-Bfjz4958.js 7581 → 3242 (−57.2%) + br assets/DocxViewer-thvChHTS.js 175867 → 46191 (−73.7%) + gzip assets/DocxViewer-thvChHTS.js 175867 → 52082 (−70.4%) + br assets/ExcelViewer-BZSJZn5Y.js 5552 → 1885 (−66.0%) + gzip assets/ExcelViewer-BZSJZn5Y.js 5552 → 2194 (−60.5%) + br assets/FilePreviewPanel-BaAl4sya.js 30116 → 7564 (−74.9%) + gzip assets/FilePreviewPanel-BaAl4sya.js 30116 → 8813 (−70.7%) + br assets/MathBlockView-CP3gIAfz.js 1669 → 800 (−52.1%) + gzip assets/MathBlockView-CP3gIAfz.js 1669 → 962 (−42.4%) + br assets/MermaidBlockView-BNZ12-FU.js 3690 → 1499 (−59.4%) + gzip assets/MermaidBlockView-BNZ12-FU.js 3690 → 1794 (−51.4%) + br assets/abnfDiagram-VCTEODGH-Bv_S-VQO.js 1809 → 785 (−56.6%) + gzip assets/abnfDiagram-VCTEODGH-Bv_S-VQO.js 1809 → 895 (−50.5%) + br assets/arc-DOxBXeCg.js 3441 → 1296 (−62.3%) + gzip assets/arc-DOxBXeCg.js 3441 → 1455 (−57.7%) + br assets/architectureDiagram-5GKGNRK7-BW5RJe7u.js 151827 → 36975 (−75.6%) + gzip assets/architectureDiagram-5GKGNRK7-BW5RJe7u.js 151827 → 42841 (−71.8%) + br assets/blockDiagram-I7D4REHJ-ozi4Xk9Q.js 41757 → 11969 (−71.3%) + gzip assets/blockDiagram-I7D4REHJ-ozi4Xk9Q.js 41757 → 13469 (−67.7%) + br assets/c4Diagram-7LVT6UL2-MxYk0iqL.js 65473 → 15133 (−76.9%) + gzip assets/c4Diagram-7LVT6UL2-MxYk0iqL.js 65473 → 18679 (−71.5%) + br assets/chunk-F27PBJKO-DwG2Zkg1.js 1883 → 742 (−60.6%) + gzip assets/chunk-F27PBJKO-DwG2Zkg1.js 1883 → 831 (−55.9%) + br assets/chunk-IMKFNOWR-DL3J6pk5.js 38076 → 10974 (−71.2%) + gzip assets/chunk-IMKFNOWR-DL3J6pk5.js 38076 → 12434 (−67.3%) + br assets/chunk-SVP7TREG-BhgGIDFD.js 15735 → 3966 (−74.8%) + gzip assets/chunk-SVP7TREG-BhgGIDFD.js 15735 → 4418 (−71.9%) + br assets/chunk-TICWLB2K-CsHC9dlc.js 49178 → 13033 (−73.5%) + gzip assets/chunk-TICWLB2K-CsHC9dlc.js 49178 → 15645 (−68.2%) + br assets/cose-bilkent-JH36ORCC-DW6Qeju_.js 81701 → 19574 (−76.0%) + gzip assets/cose-bilkent-JH36ORCC-DW6Qeju_.js 81701 → 22391 (−72.6%) + br assets/cynefin-OW5HDTMX-Ca8YX88f.js 687913 → 124540 (−81.9%) + gzip assets/cynefin-OW5HDTMX-Ca8YX88f.js 687913 → 153068 (−77.7%) + br assets/cynefinDiagram-5FMLGOSQ-CnH3Z2JB.js 10097 → 3248 (−67.8%) + gzip assets/cynefinDiagram-5FMLGOSQ-CnH3Z2JB.js 10097 → 3620 (−64.1%) + br assets/cytoscape.esm-BB4DxJjf.js 443036 → 119602 (−73.0%) + gzip assets/cytoscape.esm-BB4DxJjf.js 443036 → 141741 (−68.0%) + br assets/dagre-GXQ25YYZ-4R6bcum3.js 10676 → 3680 (−65.5%) + gzip assets/dagre-GXQ25YYZ-4R6bcum3.js 10676 → 4046 (−62.1%) + br assets/defaultLocale-DX6XiGOO.js 4688 → 2012 (−57.1%) + gzip assets/defaultLocale-DX6XiGOO.js 4688 → 2166 (−53.8%) + br assets/diagram-S7CK7UJ4-T-Bxl4t1.js 8217 → 3326 (−59.5%) + gzip assets/diagram-S7CK7UJ4-T-Bxl4t1.js 8217 → 3677 (−55.3%) + br assets/diagram-UQ7AKVKN-BnBJ_2R3.js 6079 → 2367 (−61.1%) + gzip assets/diagram-UQ7AKVKN-BnBJ_2R3.js 6079 → 2617 (−57.0%) + br assets/diagram-VSXAHHWV-DLnsWaIP.js 10716 → 3782 (−64.7%) + gzip assets/diagram-VSXAHHWV-DLnsWaIP.js 10716 → 4154 (−61.2%) + br assets/diagram-VX7I27RA-Duotn1-e.js 15957 → 5171 (−67.6%) + gzip assets/diagram-VX7I27RA-Duotn1-e.js 15957 → 5707 (−64.2%) + br assets/diagram-Z3DM3KII-iO8-m9Hl.js 4211 → 1642 (−61.0%) + gzip assets/diagram-Z3DM3KII-iO8-m9Hl.js 4211 → 1818 (−56.8%) + br assets/docxRenderer-Ilu9VYYG.js 40904 → 10539 (−74.2%) + gzip assets/docxRenderer-Ilu9VYYG.js 40904 → 11977 (−70.7%) + br assets/downloadDocx-Ep6Y-mWJ.js 1130 → 612 (−45.8%) + gzip assets/downloadDocx-Ep6Y-mWJ.js 1130 → 767 (−32.1%) + br assets/ebnfDiagram-PWID7BFC-CAlTqs1O.js 2005 → 784 (−60.9%) + gzip assets/ebnfDiagram-PWID7BFC-CAlTqs1O.js 2005 → 884 (−55.9%) + br assets/erDiagram-RLTQ6QDP-BDpOdOIc.js 30790 → 9175 (−70.2%) + gzip assets/erDiagram-RLTQ6QDP-BDpOdOIc.js 30790 → 10542 (−65.8%) + br assets/flowDiagram-HODETNUW-BGwlzTt0.js 61847 → 16384 (−73.5%) + gzip assets/flowDiagram-HODETNUW-BGwlzTt0.js 61847 → 19717 (−68.1%) + br assets/ganttDiagram-EL5Y4UJY-ijkjYohu.js 69695 → 20666 (−70.3%) + gzip assets/ganttDiagram-EL5Y4UJY-ijkjYohu.js 69695 → 23425 (−66.4%) + br assets/gitGraphDiagram-WWUBYQGX-3ajohVe1.js 29417 → 7789 (−73.5%) + gzip assets/gitGraphDiagram-WWUBYQGX-3ajohVe1.js 29417 → 8695 (−70.4%) + br assets/index-BXtlAAcH.js 365745 → 89917 (−75.4%) + gzip assets/index-BXtlAAcH.js 365745 → 104064 (−71.5%) + br assets/index-Bq7tVxen.css 6576 → 1656 (−74.8%) + gzip assets/index-Bq7tVxen.css 6576 → 1974 (−70.0%) + br assets/index-cbAtdfhj.js 814041 → 187943 (−76.9%) + gzip assets/index-cbAtdfhj.js 814041 → 236064 (−71.0%) + br assets/ishikawaDiagram-5VMMS53U-Cg4LfJyo.js 17469 → 6007 (−65.6%) + gzip assets/ishikawaDiagram-5VMMS53U-Cg4LfJyo.js 17469 → 6634 (−62.0%) + br assets/journeyDiagram-3NMN7TZE-DXSneWK0.js 23571 → 7531 (−68.0%) + gzip assets/journeyDiagram-3NMN7TZE-DXSneWK0.js 23571 → 8331 (−64.7%) + br assets/kanban-definition-UXKFOSKX-BALPAjTC.js 20291 → 6409 (−68.4%) + gzip assets/kanban-definition-UXKFOSKX-BALPAjTC.js 20291 → 7172 (−64.7%) + br assets/katex-BJxdM42i.js 261435 → 63900 (−75.6%) + gzip assets/katex-BJxdM42i.js 261435 → 77355 (−70.4%) + br assets/katex-BOvhZl6_.css 30249 → 6978 (−76.9%) + gzip assets/katex-BOvhZl6_.css 30249 → 8010 (−73.5%) + br assets/katex-HP8lGamR.js 261313 → 63694 (−75.6%) + gzip assets/katex-HP8lGamR.js 261313 → 77109 (−70.5%) + br assets/layout-BrexQxSk.js 35436 → 11571 (−67.3%) + gzip assets/layout-BrexQxSk.js 35436 → 12751 (−64.0%) + br assets/linear-BDZQyaJG.js 5661 → 2117 (−62.6%) + gzip assets/linear-BDZQyaJG.js 5661 → 2318 (−59.1%) + br assets/mermaid.core-C3o1vFDn.js 682569 → 140363 (−79.4%) + gzip assets/mermaid.core-C3o1vFDn.js 682569 → 168123 (−75.4%) + br assets/mindmap-definition-YA3MSWOX-pgZMQ8uL.js 23175 → 6998 (−69.8%) + gzip assets/mindmap-definition-YA3MSWOX-pgZMQ8uL.js 23175 → 7799 (−66.3%) + br assets/ordinal-Cboi1Yqb.js 1189 → 497 (−58.2%) + gzip assets/ordinal-Cboi1Yqb.js 1189 → 574 (−51.7%) + br assets/pegDiagram-XKGWAZYB-BRKb5jpA.js 1956 → 821 (−58.0%) + gzip assets/pegDiagram-XKGWAZYB-BRKb5jpA.js 1956 → 927 (−52.6%) + br assets/pieDiagram-E7YTZNPT-nrtuGrNr.js 6294 → 2407 (−61.8%) + gzip assets/pieDiagram-E7YTZNPT-nrtuGrNr.js 6294 → 2629 (−58.2%) + br assets/quadrantDiagram-AXDQQJYC-DHNugfXZ.js 34010 → 8523 (−74.9%) + gzip assets/quadrantDiagram-AXDQQJYC-DHNugfXZ.js 34010 → 9876 (−71.0%) + br assets/railroadDiagram-O6MQD6OU-3o3NI4V-.js 1606 → 683 (−57.5%) + gzip assets/railroadDiagram-O6MQD6OU-3o3NI4V-.js 1606 → 758 (−52.8%) + br assets/renderClipboardHtml-BXoFMW-3.js 5180 → 1452 (−72.0%) + gzip assets/renderClipboardHtml-BXoFMW-3.js 5180 → 1778 (−65.7%) + br assets/requirementDiagram-BXWQKSXE-2YVxwHhy.js 31000 → 8241 (−73.4%) + gzip assets/requirementDiagram-BXWQKSXE-2YVxwHhy.js 31000 → 9661 (−68.8%) + br assets/sankeyDiagram-P5KCCOFB-cHAs8eQM.js 23198 → 7819 (−66.3%) + gzip assets/sankeyDiagram-P5KCCOFB-cHAs8eQM.js 23198 → 8565 (−63.1%) + br assets/sequenceDiagram-WJ2MYXX4-BqEzakUS.js 116962 → 25735 (−78.0%) + gzip assets/sequenceDiagram-WJ2MYXX4-BqEzakUS.js 116962 → 30955 (−73.5%) + br assets/stateDiagram-D77RDMKH-DVKtcg-v.js 10361 → 3334 (−67.8%) + gzip assets/stateDiagram-D77RDMKH-DVKtcg-v.js 10361 → 3623 (−65.0%) + br assets/swimlanes-42K2YHIH-BaJ44xBg.js 115791 → 36679 (−68.3%) + gzip assets/swimlanes-42K2YHIH-BaJ44xBg.js 115791 → 42170 (−63.6%) + br assets/timeline-definition-24CTP7MA-d7EudEZG.js 30829 → 9133 (−70.4%) + gzip assets/timeline-definition-24CTP7MA-d7EudEZG.js 30829 → 10143 (−67.1%) + br assets/vennDiagram-4TSXK5OY-Dswu0LDK.js 41961 → 13993 (−66.7%) + gzip assets/vennDiagram-4TSXK5OY-Dswu0LDK.js 41961 → 15643 (−62.7%) + br assets/wardleyDiagram-VM6X3IG4-83YMC1gW.js 25502 → 6077 (−76.2%) + gzip assets/wardleyDiagram-VM6X3IG4-83YMC1gW.js 25502 → 6751 (−73.5%) + br assets/xlsx-Cig0qvpi.js 424392 → 117659 (−72.3%) + gzip assets/xlsx-Cig0qvpi.js 424392 → 140814 (−66.8%) + br assets/xlsxRenderer-BQPATLFB.js 2785 → 1354 (−51.4%) + gzip assets/xlsxRenderer-BQPATLFB.js 2785 → 1677 (−39.8%) + br assets/xychartDiagram-S5SC5T6Z-Do0p5dm5.js 44509 → 11058 (−75.2%) + gzip assets/xychartDiagram-S5SC5T6Z-Do0p5dm5.js 44509 → 12534 (−71.8%) + br fonts/OFL.txt 4474 → 1437 (−67.9%) + gzip fonts/OFL.txt 4474 → 1980 (−55.7%) +precompress: wrote 66 .br and 66 .gz +NPM_BUILD:0 diff --git a/.doc/test-reports/assets/2026-09-28-refresh-task-state-recovery/npm-test.txt b/.doc/test-reports/assets/2026-09-28-refresh-task-state-recovery/npm-test.txt new file mode 100644 index 00000000..544cb88f --- /dev/null +++ b/.doc/test-reports/assets/2026-09-28-refresh-task-state-recovery/npm-test.txt @@ -0,0 +1,651 @@ + +> shuimu-workbench@1.0.0 test +> tsx --test "src/**/!(*InteractiveCard*|*chatWindowArtifact*|*deliverableSync*).test.ts" && vitest run src/components/chat/InteractiveCard.test.ts src/components/chat/chatWindowArtifact.test.ts src/components/chat/deliverableSync.test.ts + +✔ AuthClient: 基础登录与 .env 超级管理员鉴权 (2.388929ms) +✔ AuthClient: 自主注册受控流程(仅填用户名密码姓名,职位强制绑定访客待指派) (0.448527ms) +✔ AuthClient: 开启注册需审批时,注册账号处于待审状态并禁止登录 (0.495266ms) +✔ AuthClient: 管理员创建账号必须在首次登录修改密码 (0.258262ms) +✔ authErrorMessage: 优先信封 error 领域原因,避免裸 JSON / 无权访问误导 (0.227843ms) +✔ mapUserAccount: snake_case Go contract → camelCase (1.231233ms) +✔ mapUserAccount: prefers snake_case when both present (0.164515ms) +✔ mapLoginResult/mapMeResult use top-level must_change_password as sole authority (0.211534ms) +✔ mapUserAccountList maps admin users list (0.116027ms) +✔ AuthClient.login + fetchMe map real snake_case wire payloads (36.529405ms) +✔ shouldAutoRetryChat: disabled until idempotency (0.913842ms) +✔ shouldAutoRetryTask: transient only, max 2 (0.229563ms) +✔ shouldAutoRetryTask: document busy never retries (0.136656ms) +✔ shouldAutoRetry alias delegates to task path (0.137816ms) +✔ autoRetryDelayMs: 1s then 2s (0.137336ms) +✔ fallbackErrorClass: network → transient, workspace → resumable (1.663131ms) +✔ upsertChatSession surfaces safe INTERNAL envelope instead of raw JSON text (79.905537ms) +[sse] 忽略无法解析的帧: {not valid json SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2) + at JSON.parse () + at parseFrame (/home/weichen/projects/Agent-System/.worktrees/refresh-task-state-recovery-2026-09-28/web/src/api/chatStream.ts:126:26) + at parseSSEChunk (/home/weichen/projects/Agent-System/.worktrees/refresh-task-state-recovery-2026-09-28/web/src/api/chatStream.ts:199:20) + at TestContext. (/home/weichen/projects/Agent-System/.worktrees/refresh-task-state-recovery-2026-09-28/web/src/api/chatStream.test.ts:41:31) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1402:25) + at Test.processPendingSubtests (node:internal/test_runner/test:974:18) + at Test.postRun (node:internal/test_runner/test:1542:19) + at Test.run (node:internal/test_runner/test:1467:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) +✔ parseSSEChunk: parses event and data lines (1.00906ms) +✔ parseSSEChunk: keeps incomplete frame in rest (0.231474ms) +✔ parseSSEChunk: flush parses final frame without trailing delimiter (0.224793ms) +✔ parseSSEChunk: ignores heartbeat comments and keeps last id (0.139066ms) +✔ parseSSEChunk: skips a single malformed frame and keeps parsing the next valid frame (5.017202ms) +✔ parseSSEChunk: parses CRLF-delimited frames (0.229503ms) +✔ parseSSEChunk: mixed LF and CRLF delimiters (0.137336ms) +✔ API_BASE is empty relative base (1.037719ms) +✔ apiFetch attaches Bearer from session storage (64.929112ms) +✔ apiFetch retries GET on 503 then succeeds (201.72881ms) +✔ apiFetch does not retry POST by default (0.516094ms) +✔ readApiError preserves typed envelope for error cards (1.498965ms) +✔ apiFetch auth:false skips Authorization (0.530665ms) +▶ docgov lifecycle API client + ✔ claimDocument posts to /api/v1/docgov/claim with author role (23.291168ms) + ✔ submitForReview posts to /api/v1/docgov/submit-review (0.706349ms) + ✔ reviewDecision posts action and optional reason (0.817416ms) + ✔ approverDecision posts action and rollback_to (0.954792ms) + ✔ proposeModification posts to /api/v1/docgov/proposal (1.198325ms) + ✔ decideModificationProposal posts to /api/v1/docgov/proposal/decide (0.720289ms) +✔ docgov lifecycle API client (28.742506ms) +✔ updateFreeformDoc exposes CONCURRENCY_CONFLICT on 409 (22.916699ms) +▶ notifications inbox helpers + ✔ ranks action_required / proposal ahead of info (0.65737ms) +✔ notifications inbox helpers (1.486036ms) +✔ addProjectMember exposes structured ProjectSettingsApiError on 403 (25.801144ms) +✔ updateProjectMember surfaces safe INTERNAL message on 500 duty-sync failure (0.822326ms) +▶ projectSettingsError + ✔ keeps status, code, message and issues on PLAN_FROZEN (1.624582ms) + ✔ maps revision and concurrent conflicts to distinct recovery (0.172775ms) + ✔ joins validation issues but still keeps typed codes (0.189844ms) + ✔ prefers envelope code over legacy error field (0.138986ms) + ✔ unwraps nested business JSON inside VERSION_CONFLICT envelope (0.187354ms) + ✔ maps bare VERSION_CONFLICT envelope to retry_after_switch (0.106467ms) + ✔ retains the local draft when a late tab hits PLAN_FROZEN (0.114897ms) + ✔ prefers error_code over legacy bare error string (DOC_BUSY) (0.128807ms) +✔ projectSettingsError (4.087899ms) +✔ fetchProjectTrace turns null arrays into empty arrays (25.555001ms) +✔ fetchProjectTrace keeps real arrays and rejects failed responses (1.302701ms) +✔ Project workflow types validation (2.125657ms) +✔ createTaskRun exposes DOC_BUSY detail without a retryable run (27.347498ms) +✔ parseSSEChunk tracks id seq for after_seq reconnect (0.474346ms) +✔ subscribeTaskRunEvents reconnects on EOF with advancing after_seq (6.424009ms) +✔ readTaskRunSSEOnce marks non-terminal EOF for reconnect (0.68087ms) +✔ subscribeTaskRunEvents fails fast on 401 without retrying (0.491175ms) +✔ subscribeTaskRunEvents fails fast on 404 without retrying (1.400458ms) +✔ buildTaskRequest: maps five project fields + task_key + user identity (0.757228ms) +✔ buildTaskRequest: omits optional fields when absent (0.164035ms) +✔ buildTaskRequest: includes materials when provided and non-empty (0.755688ms) +✔ buildResumeRequest: adds work_dir on top of task request (0.175045ms) +✔ buildTaskRequest: canonicalizes legacy doc-p doc_id (0.260083ms) +✔ buildTaskRequest: UI-05 pinned intake keeps task_key + doc_proj6109_intake (0.193434ms) +✔ buildResumeRequest: forwards add_steps when provided (0.153185ms) +✔ CategoryDock: 渲染顶部 🔔 未读提醒项及角标数字 (20.272788ms) +✔ CategoryDock: isAllReadCompleted 为 true 时渲染完成态提示 (9.797689ms) +✔ CategoryDock: 0 未读时不渲染红色未读角标 (6.548825ms) +✔ CategoryDock: 分类项包含依次跳转与遍历提示信息 (5.285423ms) +✔ chat column lives outside the workbench shell (0.954951ms) +✔ InputArea keeps draft when send preflight fails (1.420548ms) +✔ InputArea clears draft only after accepted send (2.463867ms) +✔ InputArea shows thinking block message (0.197614ms) +✔ InputArea keeps draft when attachment registration fails (0.247822ms) +✔ TaskRunCard renders timeline with title badge and current step (13.771851ms) +✔ TaskRunCard completed state keeps card with duration and artifact (2.308901ms) +✔ TaskRunCard sanitizes frozen progress labels on completed steps (1.979611ms) +✔ TaskRunCard keeps shell when only taskRunId is present (0.864224ms) +✔ active document pane lives outside the workbench shell (1.437047ms) +✔ isTemplateOnly 交付物应正确识别为初始模板展示态 (7.921545ms) +✔ workbench shell lives outside App (4.726439ms) +✔ 左侧栏鼠标严密绝对跟随与夹紧测试 (Left Rail Exact Pointer Following & Clamping) (0.713178ms) +✔ 右侧栏鼠标严密绝对跟随与反向位移测试 (Right Rail Exact Pointer Following & Inverted Delta) (0.126506ms) +✔ trace panel is not mounted above the chat stream (5.641603ms) +✔ detects legacy mock and neuropulse/ten-projects ids (0.864744ms) +✔ detects colliding demo project ids (0.132876ms) +✔ applyRestoredFileRaw: 以 prev 为权威,只补丁缺失 fileRaw,不丢新增条目 (1.154566ms) +✔ applyRestoredFileRaw: 无可用 fileRaw 时返回同一引用 (0.187955ms) +✔ login member sync stays one request for 1, 30 and 100 projects (6.07364ms) +✔ viewer seed keeps session visibility without a member-list call (0.397488ms) +✔ later session response cannot overwrite a newer user (0.190665ms) +✔ in-flight member sync collapses to one call (0.263752ms) +✔ 每人独立私聊:同一项目不同用户拥有不同 sessionId (0.881204ms) +✔ 左侧会话列表按当前用户过滤,不可见他人私聊 (0.256262ms) +✔ 消息严格归属各自私聊 session,无跨用户混聊 (0.870234ms) +✔ 切换用户时 resolve 到该用户同项目私聊会话 (0.244812ms) +✔ resolve 优先有消息的私聊,跳过空壳 sess-p{N}-u{id} (1.166125ms) +✔ ensurePrivateSessionsForUser 为缺失用户补齐空私聊 (0.593802ms) +✔ 多项目会话消息隔离测试仍成立(endo vs ultrasound) (0.33904ms) +✔ window cache LRU: 驱逐带 userId 前缀的完整 cache key,不留孤儿条目 (1.36061ms) +✔ window cache: 无 schema 的旧载荷不当作有效缓存 (7.710341ms) +✔ window cache: 读写同一 userId 前缀,跨用户不串读 (0.273892ms) +✔ 残留的 legacy demo sessionId 被读取侧清除并返回 fallback (0.905753ms) +✔ 真实 sessionId 不被误清 (0.152565ms) +✔ 无 window 环境(SSR)安全返回 fallback (0.068078ms) +✔ same account and first login do not reset workbench storage (1.144056ms) +✔ switching accounts resets workbench storage (0.182865ms) +✔ resetWorkbenchStorage preserves org user directory for cross-account project create (1.219384ms) +✔ parseAnchorHash: 正确解析标准深链 hash (1.438807ms) +✔ formatAnchorHash: 正确构造深链格式 (0.106107ms) +✔ navigateToAnchor: 快路径直接命中 DOM 节点 (0.388478ms) +✔ navigateToAnchor: 慢路径拉取分片后命中 (79.542228ms) +✔ resolveTurnAnchorId: 方案 A 轮次归一化测试 (0.507675ms) +✔ 全链路场景 1: 用户自主受控注册(仅填用户名/密码/姓名,职位严格绑定访客待指派) (1.552684ms) +✔ 全链路场景 2: 系统准入策略(关闭自主注册与开启强制审批流转) (0.647091ms) +✔ 全链路场景 3: 管理员开通账号首次强制改密闭环 (0.242653ms) +✔ 全链路场景 4: 环境标识随登录会话返回(dev/test/main) (0.261022ms) +✔ cleanHeadingContent: 手工多级序号前缀清洗与专业词汇保护 (2.277532ms) +✔ 标题多层级自动编号:单 H1 文档总标题自适应模式 (1 -> 1.1 -> 1.1.1) (0.34501ms) +✔ 标题多层级自动编号:多 H1 标准大章模式 (0.137276ms) +✔ 跨章节图、表、公式多级按章重编号测试 (表 1-1, 表 2-1, 图 3-1, 式 (4-1)) (0.154156ms) +✔ 有序列表与无序列表编号与渲染功能测试 (2.399788ms) +✔ 动态块操作闭环重编号验证 (Insert, Move, Delete) (3.589093ms) +✔ findMatchingBlockForReview: 能够精准按审查关键词(电气安全/EMC/竞品)定位段落块 (0.848975ms) +✔ findMatchingBlockForReview: 能够按外部指定 hint 线索精确命中目标块 (0.170635ms) +✔ createRejectConfirmationCard: 生成合规危险级驳回门禁卡片 (0.605742ms) +✔ isAffirmativeResponse: 准确识别双轨交互中的肯定意图输入 (0.205574ms) +✔ findLastActiveCard: 正确在会话历史中提取最新的待决策卡片 (0.201834ms) +✔ decrementActiveCardsTtl: 多轮对话超时递减与自动失效归档 (0.163195ms) +✔ applyConfirmRejectToDoc & applyUndoRejectToDoc: 驳回挂载批注与60s窗口期撤销完整闭环 (0.251372ms) +✔ resolveCardInMessages & cancelCardInMessages: 消息流中卡片终态沉淀 (0.151626ms) +✔ classifyRejectUtterance: 询问原因 → QUERY_REJECT_REASON (1.295522ms) +✔ classifyRejectUtterance: 明确执行 → EXECUTE_REJECT (0.177635ms) +✔ classifyRejectUtterance: 模糊 → OTHER (0.197495ms) +✔ resolveDocGovCard: 编制人查询 → 信息卡且无 confirm_reject (0.788967ms) +✔ resolveDocGovCard: 审核人执行 + reviewing → 确认卡 (0.289862ms) +✔ resolveDocGovCard: 编制人误说执行驳回 → 无权纯文本 (0.141016ms) +✔ resolveDocGovCard: 批准人 + approving → 终审确认卡 (0.274622ms) +✔ resolveDocGovCard: 编制人 OTHER → 引导查询无危险按钮 (0.128557ms) +✔ canConfirmReject: 仅审核/批准 (0.537784ms) +✔ deriveFallbackCategory identifies categories correctly (0.870374ms) +✔ enrichMessagesWithCategories backfills categories without overwriting existing ones (0.313601ms) +✔ Markdown ➔ Block AST 解析测试 (标题自适应编号与图表公式全链路) (4.275193ms) +✔ 动态重编号引擎测试 (多 H1 规范章节重排验证) (0.980431ms) +✔ Block AST ➔ Markdown 双向无损往返还原测试 (0.843655ms) +✔ 三层交付物多用户隐私绝对隔离测试 (Multi-user Strict Privacy Isolation) (1.088098ms) +✔ 一键共享至项目参考库流转测试 (Promote to Shared Reference Test) (0.221383ms) +✔ 撤回共享恢复私有草稿测试 (Revoke Shared Reference Test) (0.408948ms) +✔ 受控工作流纯净性红线检验 (Workflow Purity Security Red Line) (0.129456ms) +✔ isSameMessage: clientTurnId match (1.861744ms) +✔ isSameMessage: serverId match without turn (0.175055ms) +✔ filterPendingLiveMessages: real temp vs server id (0.137566ms) +✔ mergeLiveOverHistory preserves server id from live (0.173595ms) +✔ mergeLiveOverHistory does not let inflight live mask terminal history (0.134716ms) +✔ mergeLiveOverHistory keeps live terminal over history inflight (0.112497ms) +✔ applyPhaseEvent: appends first phase as running node (1.403038ms) +✔ applyPhaseEvent: completes predecessors when a new phase arrives (0.249813ms) +✔ applyPhaseEvent: same phase refreshes label in place (no duplicate node) (0.243692ms) +✔ applyPhaseEvent: unknown phase falls back to hourglass meta (0.130056ms) +✔ applyPhaseEvent: prefers backend message over short label (0.114206ms) +✔ applyPhaseEvent: step_completed closes running entry without appending (0.239473ms) +✔ applyPhaseEvent: stage_completed closes entries (0.163325ms) +✔ applyPhaseEvent: task_failed appends warning node (0.133246ms) +✔ applyPhaseEvent: decorates label with step index / total steps (0.137436ms) +✔ applyPhaseEvent: repeated step_executing with step_index gets separate nodes (0.200365ms) +✔ applyPhaseEvent: ignores event without phase payload (0.097807ms) +✔ applyPhaseEvent: step_failed marks running entry as failed (keeps step label) (0.114317ms) +✔ applyPhaseEvent: step_failed appends failed node when no running entry exists (1.234163ms) +✔ applyPhaseEvent: retry of a failed step resets it to running (0.141366ms) +✔ applyPhaseEvent: stores detail from phase.message (0.075458ms) +✔ PHASE_META: document_parsing and document_summarizing are registered (0.114747ms) +✔ PLAN_CONFIRM 卡片来自 PlanView 且不触达 AST (1.988191ms) +✔ 409 冲突把卡片标为 outdated 并换上最新 revision (0.252702ms) +✔ GET reconcile 用权威 PlanView 覆盖本地 executing,不假装成功 (0.226923ms) +✔ 过期计划映射 expired (0.125736ms) +✔ 非计划卡不被误判 (0.112107ms) +✔ 集成验证:项目切换时交付物严格隔离,新建项目即刻包含 7 份模板副本 (6.355481ms) +✔ 集成验证:三层隐私与项目物理隔离双重约束 (8.259005ms) +✔ 用例 1: DEFAULT_ROLES 包含 5 种默认角色且具备预期权限 (4.314872ms) +✔ 用例 2: 系统管理员 (ADMIN) 与具备 perm:manage 用户的通配豁免特权 (0.226003ms) +✔ 用例 3: 常规工作流基线可见性 (workflow:view_all) 判定 (0.249052ms) +✔ 用例 4: 单文档覆盖 - designated_only (仅编制/审核/批准人可见,其他用户被精确拦截) (0.145636ms) +✔ 用例 5: 单文档覆盖 - role_whitelist (仅白名单内角色可见,其他角色被精确拦截) (0.127306ms) +✔ 用例 6: 单文档覆盖 - public (放宽可见性,无 workflow:view_all 权限的访客也能查阅) (0.100067ms) +✔ 用例 7: 私密文档隔离强红线 (category === private 无论任何管理员或单文档覆盖,非创建者绝对不可见) (0.111796ms) +✔ 用例 8: 核心业务动作权限拦截判定 (canPerformDocAction & hasPermission) (0.149616ms) +✔ 用例 9: filterVisibleDeliverablesWithRBAC 能够批处理过滤大盘交付物 (5.913474ms) +✔ 集成场景 1: 动态新增合规审计员角色,并配置其仅能查阅特定单文档覆盖 (1.047099ms) +✔ 集成场景 2: 服务端 capabilities 变更后权限即刻生效 (0.168345ms) +✔ 集成场景 3: 单文档覆盖 designated_only 严格收紧与撤回恢复全流程 (0.148375ms) +✔ renderInlineMarkdown: renders bold, italic, strikethrough (1.362779ms) +✔ renderInlineMarkdown: escapes raw HTML tags to prevent XSS (0.653981ms) +✔ renderInlineMarkdown: blocks javascript: link hrefs (0.305061ms) +✔ renderInlineMarkdown: renders inline code and preserved line breaks (0.290592ms) +✔ renderInlineMarkdown: renders headings lists and tables for chat replies (0.487936ms) +✔ renderInlineMarkdown loads katex only after a formula is seen (19.51147ms) +✔ sanitizeMermaidCode: auto wraps unquoted labels containing brackets or comparison operators (0.422077ms) +(node:1877659) ExperimentalWarning: localStorage is not available because --localstorage-file was not provided. +(Use `node --trace-warnings ...` to show where the warning was created) +✔ DOCX 渲染器行内文本与公式解析测试 (parseInlineContent) (4.879835ms) +✔ DOCX 渲染器 LaTeX 原生 OMML 转换引擎测试 (latexToOmml) (2.009401ms) +✔ DOCX 渲染器 Mermaid 语法净化与容错测试 (sanitizeMermaidCode) (0.3103ms) +✔ DOCX 渲染器 SVG 净化防 Canvas 污染测试 (cleanSvgForCanvas) (0.367549ms) +✔ DOCX 渲染器全量编译测试 (buildDocxModel & exportDocumentToDocx) (61.249852ms) +✔ XLSX 渲染器多表提取与工作簿编译测试 (buildXlsxWorkbook & exportDocumentToXlsx) (6.587554ms) +▶ Reply Document IR & Export Engine + ✔ deriveReplyTitle and sanitizeFilename derive title and safe file name (1.768507ms) + ✔ deriveReplyTitle handles text without heading (0.285461ms) + ✔ parseReplyMarkdown parses headers, lists, code, and tables (1.728138ms) + ✔ renderClipboardHtml renders self-contained styling for office tools (0.462936ms) + ✔ renderClipboardHtml converts LaTeX formulas to native MathML for Word/Office (4.016571ms) + ✔ renderPlainText strips formatting cleanly (0.481286ms) + ✔ serializeMarkdown round-trips to valid markdown (0.33855ms) + ✔ stripUnsafeHtml strips script tags and dangerous event handlers (0.549014ms) + ✔ escapeHtml escapes math error payloads used by BlockEditorModal catch path (0.196264ms) +✔ Reply Document IR & Export Engine (11.579927ms) +✔ createProjectTemplateDeliverables: 必须精确生成 7 份全生命周期受控文档副本 (3.852105ms) +✔ resolveRequirementsTaskKey prefers intake for 初始需求表 (1.199405ms) +✔ resolveRequirementsTaskKey selects user_requirements for explicit URS (0.156685ms) +✔ UI-02 first-round prompt with 首轮+用户需求+摸底 must be intake not user_requirements (0.478466ms) +✔ 首轮 / 需求摸底 / 摸底 beat bare 用户需求 (0.106416ms) +✔ bare 用户需求 without V0.1 markers stays user_requirements (0.107757ms) +✔ matchTemplateDeliverable uses intake key when pool only has URS template (0.258242ms) +✔ resolveTaskTemplateForMessage keeps intake key with URS-only pool (0.149816ms) +✔ matchTemplateDeliverable prefers intake when both templates exist (0.126717ms) +✔ read-only request does not start a document task: 总结一下用户初始需求表 (0.294921ms) +✔ read-only request does not start a document task: 查看用户初始需求表 (0.145125ms) +✔ read-only request does not start a document task: 用户初始需求表现在谁在编辑 (0.090967ms) +✔ read-only request does not start a document task: 分析一下用户需求表里的风险 (0.070788ms) +✔ read-only request does not start a document task: 用户初始需求表 (0.054098ms) +✔ explicit write action still starts intake (0.136246ms) +✔ explicit update action still starts URS (0.190135ms) +✔ UI-05 prompt without active doc still keyword-routes to feasibility_reg (0.294701ms) +✔ UI-05 prompt + registered intake pins intake and doc_proj6109_intake (0.235983ms) +✔ read-only current-doc request does not start a task (0.071978ms) +✔ summary/analysis with write verbs stays chat (read-only priority) (0.089587ms) +✔ pure write intent still routes to task (0.053668ms) +✔ template-only active slot is not pinned; keywords still apply (0.096717ms) +✔ explicit named deliverable can switch away from active intake (0.165225ms) +✔ domain 注册法规 without current-doc deixis still routes feasibility_reg (0.088787ms) +✔ mismatched title vs docId suffix prefers docId taskKey (0.093428ms) +✔ deixis 当前文档 pins registered intake (0.095807ms) +✔ deixis 本文件 pins registered intake (0.047478ms) +✔ deixis 这份文档 pins registered intake (0.050108ms) +✔ deixis 本文档 pins registered intake (0.044218ms) +✔ deixis 本受控文档 pins registered intake (0.041788ms) +✔ no active doc does not invent a pin (0.096247ms) +✔ UI-11 named 系统设计输入 beats negated 需求 background (0.076237ms) +✔ mixed generate-design + do-not-cover-requirements stays prelim_design (0.055108ms) +✔ current-doc pin still wins when no other deliverable is named (0.083417ms) +✔ v3 bd_ task key wins over unprefixed intake alias (0.124346ms) +✔ word-order variants of the intake sheet stay on intake, not the shorter URS title (0.195294ms) +✔ resolveTaskTemplateForMessage blocks cross-workflow document mentions (2.185825ms) +✔ resolveTaskTemplateForMessage keeps in-scope main docs when active is main (0.129286ms) +✔ weighted mode does not let a cross-workflow title hijack an in-scope write (0.537474ms) +✔ progress question is read-only; explicit start still resolves research (0.093728ms) +▶ document revision overlay modes + ✔ pure hides all decorations (0.811806ms) + ✔ manual only marks human hunks (0.160956ms) + ✔ all marks human and agent differently (0.137306ms) + ✔ same human hunk stays amber in manual and all (no pairwise style guarantee) (0.135026ms) + ✔ exposes needs-content error code for empty orphan docs (0.094397ms) + ✔ matches overlay hunks by section or text when ids differ (0.272582ms) + ✔ aligns client block ids from head_block_map by order (0.217134ms) +✔ document revision overlay modes (2.925713ms) +▶ toolbar refresh + report entry reachability + ✔ pickLatestChangeReportId prefers highest version with report (0.33787ms) + ✔ first squad run alone still exposes a reachable report id (0.165395ms) + ✔ second squad run keeps both history report entries and latest shortcut (0.778567ms) + ✔ revisionRefreshKey is an independent refresh signal from docId (0.127386ms) +✔ toolbar refresh + report entry reachability (1.69243ms) +▶ change report view model + ✔ surfaces risk items, project impact, doc impacts with advice, and open questions (0.67743ms) + ✔ marks complete analysis as PI narrative vs factual fields (0.187924ms) + ✔ preferReportId prioritizes pending over committed latest (0.096757ms) +✔ change report view model (1.147986ms) +▶ revisionToolbarMount + ✔ exposes clear non-error template preview hint copy (2.417999ms) + ✔ template doc-p + isTemplateOnly=true does not mount (0.162285ms) + ✔ legacy doc-001 does not mount (0.156295ms) + ✔ unregistered doc-upload orphan does not mount (must register first) (0.145726ms) + ✔ server-registered upload (doc-free) and freeform mount (0.156935ms) + ✔ canonical doc_proj and generated non-template doc-p mount (0.193634ms) +✔ revisionToolbarMount (4.318292ms) +✔ login workspace sync lives in one hook (1.204984ms) +✔ block editing lives in one hook (0.957632ms) +✔ interactive card actions live in one hook (1.048618ms) +✔ useChatWindow: 所有 saveSessionWindowCache 调用必须传入 userId (1.066258ms) +✔ loadOlder race: stale resolve ignored by generation (1.557084ms) +✔ window fetch race: stale response ignored by generation (4.517886ms) +✔ mapRecordToMessage: 正确映射服务端消息记录为前端 ChatMessage (1.654151ms) +✔ refresh window: 空 messages 日桶不算有效窗口 (0.192455ms) +✔ mapRawBucket: 正确映射服务端按日分片桶 (0.312121ms) +✔ Category navigation: chip 选中后准确匹配目标分类最新轮次并触发归一化定位 (0.286702ms) +✔ mapRecordToMessage: restores task phase snapshot into card fields (0.236783ms) +✔ mapRecordToMessage: run anchor without task_status stays streaming (0.180904ms) +✔ controlled document hydration lives in one hook (1.096208ms) +✔ project creation lives in one hook called before the login return (0.755868ms) +✔ document lifecycle actions live in one hook (1.109877ms) +✔ freeform document actions live in one hook (1.755868ms) +✔ in-flight states cover pending and running only (6.667272ms) +✔ hasInFlightParse detects parse polling needs (0.246373ms) +✔ hook + reparse API surface wired (0.298551ms) +✔ project member writes live in one hook before the login return (1.318591ms) +✔ replay retries 502 and stops on auth or not-found (0.66772ms) +✔ sending a message lives in one hook (1.01656ms) +✔ server document list sync lives in one hook (1.393729ms) +✔ chat and task attempts live in one hook (1.34513ms) +✔ stage unresolved and not-in-active-stage degrade to chat once (0.155916ms) +✔ failed run with error_message surfaces TASK_FAILED not NETWORK_ERROR (0.770138ms) +✔ snake_case blocked_pending_governance is governance, not a network error (0.34381ms) +✔ unknown 4xx business code is not reported as a network error (0.377559ms) +✔ turn runtime: session A stream survives session B switch (0.649891ms) +✔ abortAllSessions aborts controllers across all sessions (0.192954ms) +✔ App persists workbench state through one hook and migrates legacy messages once (0.968861ms) +✔ active task cache keeps an in-progress run and drops it when finished (1.106048ms) +✔ syncAdminUserDirectories calls local reload then parent onRefreshUsers (1.624912ms) +✔ syncAdminUserDirectories fails closed when onRefreshUsers is a dead/missing prop (0.614612ms) +✔ syncAdminUserDirectories surfaces parent refresh errors (no silent swallow) (2.138076ms) +✔ afterProjectMemberPersisted syncs members then refreshes workflow docs (1.172795ms) +✔ afterProjectMemberPersisted does not refresh when member sync fails (0.463506ms) +▶ blockEditorDialogPolicy + ✔ pins an accessible dialog label and exact save name (0.492506ms) + ✔ rejects page-wide fuzzy /保存/ that would hit private-library buttons (0.258003ms) +✔ blockEditorDialogPolicy (1.557624ms) +▶ resolveSaveTargetBlock + ✔ keeps the editor and does not fake success when the block id is gone (0.635451ms) + ✔ never retargets another block that happens to share the same content (0.705099ms) +✔ resolveSaveTargetBlock (2.244724ms) +▶ editor session document binding + ✔ fail-closes when activeDocId drifts away from the opened editor doc (0.173244ms) + ✔ accepts a save only when the editor is still bound to the active doc (0.099087ms) + ✔ blocks switching documents while the editor is open (0.139706ms) +✔ editor session document binding (0.614882ms) +▶ interpretBlockSaveError + ✔ 409 keeps the draft in the editor (0.170955ms) + ✔ other PATCH failures also keep the editor (0.192604ms) +✔ interpretBlockSaveError (0.782167ms) +▶ blockedGovernanceCopy + ✔ recognizes blocked run status and fixed hint, not generation success (0.736178ms) +✔ blockedGovernanceCopy (1.654011ms) +▶ resolveChatComposerStreaming + ✔ is false when no inflight assistant bubbles (0.472606ms) + ✔ is true when inflight bubble and live turn (0.106117ms) + ✔ is true when inflight bubble and live task run (0.078888ms) + ✔ is false for orphan streaming_status after runtime cleared (0.059708ms) +✔ resolveChatComposerStreaming (1.616372ms) +▶ commentPolicy + ✔ applies stage matrix for add (0.6784ms) + ✔ keeps viewer read-only (0.206644ms) + ✔ resolves author role literally (0.119417ms) + ✔ gates resolve by stage (0.206814ms) +✔ commentPolicy (4.325182ms) +▶ controlledContentInvalidation + ✔ hydrate success then late list_sync keeps cache/ready and does not bump resume (1.722119ms) + ✔ post_task invalidation drops active cache and requires a new hydrate epoch (0.221513ms) + ✔ editor open / save epoch keeps draft and rejects a stale GET after PATCH (0.351609ms) + ✔ never drops the active doc without signaling rehydrate (0.138126ms) + ✔ candidate helper keeps template / doc-001 / freeform / upload out of wipe lists (0.450837ms) + ✔ dropRecordKeys is a no-op when ids are absent (0.219383ms) +✔ controlledContentInvalidation (4.35165ms) +▶ controlledDocHydrate + ✔ shouldLoadControlledContent when non-template workflow doc (3.012441ms) + ✔ shouldLoadControlledContent rejects local legacy/mock ids like doc-001 (0.181415ms) + ✔ shouldLoadControlledContent keeps canonical, freeform-off, and template preview rules (0.164175ms) + ✔ hydrateControlledDocumentContent never requests content/revision for doc-001 (0.383468ms) + ✔ hydrateControlledDocumentContent never requests for template preview or registered freeform (0.181555ms) + ✔ shouldLoadControlledContent stays inside toolbar server-backed eligibility (0.313571ms) + ✔ resolveCanonicalDocId maps doc-p56-intake to doc_proj56_intake (0.490665ms) + ✔ canonicalDocIdForTask maps doc-p56-research to doc_proj56_research (0.230063ms) + ✔ hydrate treats projection missing as ledger content, not 404 (1.823986ms) + ✔ attaches hydrate ETag as PATCH baseline and ignores a later GET (1.028349ms) + ✔ rejects hydrate when revision-state network or 5xx fails (0.841565ms) + ✔ aligns markdownToBlockAST random ids onto revision-state head_block_map (1.830716ms) + ✔ leaves Math.random ids when head_block_map is omitted (why gate + fail-closed remain) (1.067668ms) + ✔ rejects controlled hydrate on NOT_FOUND or NEEDS_CONTENT (0.377559ms) + ✔ fingerprint stays stable when only object identity changes (0.269112ms) + ✔ dedupes in-flight hydrate for the same doc fingerprint (0.449247ms) + ✔ aligned late hydrate still cannot overwrite a PATCH-success baseline (0.455086ms) +✔ controlledDocHydrate (15.550208ms) +✔ createClientTurnId uses randomUUID when available (0.815456ms) +✔ createClientTurnId falls back to getRandomValues UUID v4 (0.421077ms) +✔ createClientTurnId throws when no secure random API exists (0.35659ms) +✔ selectNonCreatorInitialMembers skips creator only (1.587573ms) +✔ buildManagedMemberUpsertPayload sends document_duty on add/update/restore (0.270002ms) +✔ buildInitialMemberUpsertPayload maps reviewer UI role to member API role (0.191914ms) +✔ persistInitialProjectMembers awaits each add and stops on failure (0.65355ms) +✔ ensureCreatedProjectWithMembers skips recreate on bootstrap id and retries members (0.549973ms) +✔ beginCreateProjectSubmit rejects concurrent second entry until end (31.813816ms) +✔ resolveCreateProjectSubmitPhase uses bootstrap to choose syncing label (0.723658ms) +✔ createProjectSubmitButtonLabel and close block while submitting (0.200754ms) +✔ shouldCloneMedicalTemplates skips the 7-pack for software domain (0.131006ms) +▶ resolveCurrentDocRole + ✔ resolves A as author by authoritative user id (0.752448ms) + ✔ does not promote reviewer B or stranger to author (0.123077ms) + ✔ resolves real USER_B id as reviewer and never name-promotes placeholder 2 (0.095468ms) + ✔ fail-closes unknown identity to viewer even if projectPermission says author (0.076527ms) + ✔ matches name only for legacy local docs without duty ids (0.105767ms) + ✔ does not name-promote when server controlled duties are zero ids (0.160535ms) +✔ resolveCurrentDocRole (3.184245ms) +▶ currentProjectRestore + ✔ rejects empty project names (0.827155ms) + ✔ rejects page-wide project name when banner and sidebar both match (0.185724ms) + ✔ scopes the visible entry to complementary exact title (2.776877ms) + ✔ scopes the current project assertion to banner exact text (0.35035ms) +✔ currentProjectRestore (5.071659ms) +▶ deliverableLeaseSync + ✔ returns the same array reference when leases are unchanged (0.753007ms) + ✔ returns a new array only when a lease actually changes (0.177275ms) +✔ deliverableLeaseSync (1.904263ms) +▶ docGovActionVisibility + ✔ hides all approval actions on unmanaged docs (1.605673ms) + ✔ gates separated duties by user id, not folded role label (0.172974ms) + ✔ author_only: same user holds all three duties and sees all actions (0.106427ms) + ✔ author_only: non-duty user sees no actions (0.104037ms) +✔ docGovActionVisibility (3.05592ms) +▶ documentBlockDomAttrs + ✔ marks a real DocumentBlock in pure mode without overlay kind (1.241673ms) + ✔ keeps the same stable attrs when overlay is later attached separately (0.197974ms) +✔ documentBlockDomAttrs (2.573574ms) +✔ author can edit drafting when unlocked (0.720368ms) +✔ active project member can edit drafting when unlocked (deny_only_submit) (0.129316ms) +✔ non-member viewer cannot edit drafting (0.089708ms) +✔ lease blocks edit even for author and member (0.093477ms) +✔ reviewing is not editable via drafting affordance (0.080617ms) +▶ durableBodySnippet + ✔ rejects empty persisted snippets (0.850954ms) + ✔ lets a later successful revision replace the old snippet (0.988341ms) + ✔ does not promote a conflict-failed draft to the expected body (0.174115ms) +✔ durableBodySnippet (2.940223ms) +▶ dutyCellAssertion + ✔ parses author, reviewer, and viewer from select value or badge role (0.890893ms) + ✔ must not infer author from a row whose option labels include 编制负责人 (0.499835ms) + ✔ accepts only a disabled non-assignable reviewer select (0.122916ms) + ✔ rejects an enabled reviewer select when the reviewer cannot assign leads (0.098187ms) + ✔ rejects canAssignLead=true even if the reviewer select is disabled (0.249163ms) + ✔ rejects viewer, author, approver, and unknown values for USER_B (0.265472ms) +✔ dutyCellAssertion (6.592245ms) +▶ decideHydrationApply + ✔ applies only when gen, doc, save epoch match and editor is closed (1.170076ms) + ✔ rejects a response for another document (0.146755ms) + ✔ rejects a superseded generation (0.104287ms) +✔ decideHydrationApply (2.515795ms) +▶ createEditorHydrationGate + ✔ does not start a new hydrate while the editor is open (UI-06 fill-window GET) (0.449467ms) + ✔ deferred hydrate that resolves after the editor opens does not replace the session (0.162255ms) + ✔ covers hydrate resolving between fill and save (0.149286ms) + ✔ asks for a hydrate resume after editor_open blocked the first apply (0.370859ms) + ✔ asks for a hydrate resume when beginHydration is skipped while editing (0.298281ms) + ✔ requestHydrationResume is consumed once after a protected edit session (0.252152ms) + ✔ late hydrate after a successful PATCH does not roll back the new baseline (0.412608ms) +✔ createEditorHydrationGate (2.69034ms) +▶ formatFileSize + ✔ renders bytes below 1KB without rounding to 0 (0.834686ms) + ✔ renders KB/MB with sensible precision (0.260662ms) + ✔ degrades gracefully for invalid input (0.270781ms) +✔ formatFileSize (2.69142ms) +▶ deliverableFromServerSummary + ✔ restores uploaded file routing metadata from server facts (0.903473ms) + ✔ keeps plain freeform docs without upload metadata on the editor path (0.214224ms) + ✔ handles legacy rows with filename but zero size gracefully (0.130076ms) + ✔ maps shared space to shared category (0.139396ms) +✔ deliverableFromServerSummary (3.529395ms) +✔ redactResourceUrl drops query and origin (1.247093ms) +✔ summarizeResources flags deferred heavy chunks (1.146446ms) +✔ firstPaintHeavyHits lists only deferred module paths (0.186145ms) +✔ countInitRequests separates member fanout from session lists (0.283052ms) +✔ percentile uses nearest-rank and stays null on empty input (0.167675ms) +✔ summarizeLatencies reports p50/p75/p95 (0.216413ms) +✔ mergeSessionUser prefers session identity over stale catalog entry (1.251713ms) +▶ playwrightPhaseArtifacts + ✔ defaults unknown or empty UI_PHASE to main (0.723149ms) + ✔ keeps post-restart distinct from main so the second run cannot overwrite traces (0.178675ms) + ✔ keeps main results at the historical filename and isolates post-restart recordPass (2.574403ms) + ✔ shares phase-state and secrets across phases (0.211744ms) + ✔ defaults assets to the 2026-09-22 rerun tree, not project-143 history (0.330121ms) + ✔ refuses historical controlled-content and Round20 evidence dirs (2.076958ms) + ✔ lists closeout groups covering UI-03/05/06/07-08-09/11/13/post-restart (0.811166ms) +✔ playwrightPhaseArtifacts (9.540066ms) +✔ resolvePostTaskActiveDocId prefers server doc_proj for taskKey (0.9999ms) +✔ resolvePostTaskActiveDocId honors preferred server doc id (0.195745ms) +✔ shouldSwitchTemplateSlotToServer when active is local template slot (0.169585ms) +✔ pollUntilPostTaskServerDoc retries until same taskKey server doc appears (0.495385ms) +✔ pollUntilPostTaskServerDoc returns null after maxAttempts without relying on setState (0.206394ms) +✔ mapServerProjectToProjectInfo maps creator and preserves local members (0.899954ms) +✔ mergeProjectLists drops local-only demo seed projects by default (0.32681ms) +✔ mergeProjectLists can retain local-only demo when explicitly opted in (0.158635ms) +✔ documentDutyFromMemberRole maps UI reviewer and creator (0.739068ms) +✔ exclusiveMemberRoleChange keeps a single reviewer (0.189214ms) +✔ memberRoleFromDocumentDuty does not invent reviewer from access role (0.116077ms) +✔ toApiProjectRole maps doc roles to member and creator to owner (0.735568ms) +✔ fromApiProjectRole maps owner to creator for project creator (0.143375ms) +✔ mapApiMemberToProjectMember builds UI record (0.188675ms) +▶ projectSettingsDuties + ✔ toggles full to none by atomically clearing duties (1.726969ms) + ✔ blocks partial saves and locates required/governance issue codes (0.607871ms) + ✔ blocks activation preview when no document is under human approval (0.31043ms) + ✔ allows an all-none configurable draft without three members (0.430327ms) + ✔ requires three members once any document is managed (0.135766ms) + ✔ resolves author_only to A/A/A without three-member requirement (0.243022ms) + ✔ rejects author_only without author or with conflicting reviewers (0.159025ms) + ✔ mixed full + author_only still requires three members for the full doc (0.324181ms) + ✔ toggleAuthorOnly clears review/approve slots and restores on disable (0.178825ms) + ✔ folds later rdev stages and batches human approval keys (0.263173ms) +✔ projectSettingsDuties (6.032391ms) +▶ projectSettingsMode + ✔ enters frozen when can_edit_plan is false or the project is active (0.766867ms) + ✔ keeps an active workflow editable except the definition switch (0.161595ms) + ✔ stays planning only for an editable draft (0.076008ms) + ✔ lists enabled duties for the frozen snapshot and skips disabled tasks (0.714029ms) + ✔ keeps the local draft after PLAN_FROZEN refresh and switches to frozen (0.123676ms) +✔ projectSettingsMode (2.846246ms) +▶ recheckAck + ✔ requires an explicit acknowledgement before advance (0.745668ms) +✔ recheckAck (1.588373ms) +▶ revisionEtag + ✔ uses stale hydrate ETag for PATCH and never the latest GET (0.492745ms) + ✔ does not invent 0:0 when controlled hydrate misses etag or errors (0.153046ms) + ✔ refuses controlled save without a hydrate ETag (0.493985ms) + ✔ allows freeform bootstrap 0:0 only (0.125246ms) +✔ revisionEtag (2.533935ms) +▶ saveControlledBlockEdit + ✔ successful save calls PATCH once with bound docId, hydrate ETag, body and client_block_map (2.507325ms) + ✔ does not PATCH when editorDocId and activeDocId diverge (0.239383ms) + ✔ does not PATCH on stale block id and keeps the draft (0.185995ms) + ✔ 409 does not close the editor or drop the draft (0.854364ms) + ✔ network errors do not close the editor or drop the draft (0.441767ms) +✔ saveControlledBlockEdit (5.514056ms) +✔ parseProjectIdFromSessionId decodes sess-p{id}-u{user} (1.520394ms) +✔ resolveSessionProjectId prefers explicit id then session id encoding (0.212014ms) +✔ mapServerSessionToProjectSession fills project metadata (0.627971ms) +✔ mergeSessionLists keeps local stage while applying server counts (0.223494ms) +▶ squadCompletionObservation + ✔ accepts a directly completed adjacent assistant without requiring inflight (1.388318ms) + ✔ accepts a turn that passed through inflight then completed (0.127666ms) + ✔ rejects error terminals, missing correspondence, and visible errors (0.093147ms) + ✔ keeps waiting while still inflight and does not treat inflight as success (0.067458ms) + ✔ cancelInflightAssistantMessages settles only this-session assistant inflight (0.128636ms) +✔ squadCompletionObservation (2.6832ms) +✔ running bubble without phases needs replay (0.889653ms) +✔ interrupted bubble without phases needs replay (1.515235ms) +✔ bubble that already has phases is not replayed (0.164685ms) +✔ completed bubble without snapshot needs replay for legacy rows (0.096507ms) +✔ completed bubble with snapshot is not replayed (0.113237ms) +✔ phase replay key ignores completed chatter (0.153825ms) +✔ resolveTaskRunCardTitle prefers artifact title (0.909703ms) +✔ resolveTaskRunCardTitle falls back to user message then taskKey (0.208703ms) +✔ truncateTitle ellipsizes long text (0.164275ms) +✔ formatTaskDuration covers seconds minutes hours (0.160255ms) +✔ resolveTaskRunCardBadge maps terminal and live states (0.775347ms) +✔ shouldRenderTaskRunCard keeps card shape when run id exists (0.158936ms) +✔ runningPhaseDetail prefers live label when it differs from step (0.142095ms) +✔ 403 revision denial is not reported as a network error (0.930763ms) +✔ 403 author-duty denial surfaces draft-vs-submit guidance (0.157385ms) +✔ DOC_DUTY_FORBIDDEN keeps draft-vs-submit guidance (0.754727ms) +✔ TASK_SCOPE_UNRESOLVED names the missing workflow stage (0.094747ms) +✔ resolveReconnectRunId prefers explicit opts and ignores attempt without opts (0.622101ms) +✔ shouldSubscribeExistingRun only for in-flight statuses (0.139905ms) +✔ isRemoteTaskTerminal covers confirmation and quality_failed (0.112477ms) +✔ nextAttemptOpts carries retained runId for reconnect without new POST (1.610892ms) +✔ template documents are labeled as preview, not 编制中 or 已生效受控 (0.958361ms) +✔ turnAttachmentRefs keeps doc and parse facts (1.178685ms) +✔ describeChatHttpFailure prefers the public validation error (0.460687ms) +▶ uploadFreeformRegister + ✔ buildUploadClientKey prefers explicit hint then doc-upload stamp (0.955591ms) + ✔ registerUploadAttachments hard-fails without creating orphan on API error (16.603197ms) + ✔ registerUploadAttachments rejects local doc-upload id from server (0.370409ms) + ✔ registerUploadAttachments returns server doc-free ids and freeform facts (0.548384ms) + ✔ deliverableFromFreeformUpload maps server id and upload facts (0.167475ms) + ✔ buildUploadRegistrationMarkdown embeds real text content when provided (0.144066ms) + ✔ buildUploadRegistrationMarkdown keeps placeholder for binary files without textContent (0.250482ms) + ✔ registerUploadAttachments persists source file metadata as server facts (0.301371ms) +✔ uploadFreeformRegister (20.688145ms) +▶ workflowCategoryNavigation + ✔ private menu cannot see doc_proj* so it must switch to workflow before exact select (1.049129ms) + ✔ fail-closes when the workflow menu is showing but the exact doc is missing (0.190554ms) + ✔ label and taskKey opens always require the workflow category (0.170195ms) +✔ workflowCategoryNavigation (3.520565ms) +✔ rdev selection explains the CDO prerequisite (0.787547ms) +✔ login shell does not statically import export, preview, admin, or chart engines (27.783405ms) +▶ no external Google Fonts on login shell + ✔ index.html has no fonts.googleapis / fonts.gstatic (0.930232ms) + ✔ styles reference self-hosted fonts.css (0.365889ms) +✔ no external Google Fonts on login shell (2.449468ms) +▶ 用户上传文档多格式在线预览体系与渲染分流测试 + ✔ 1. 上传附件时正确注入 sourceType: "uploaded_file" 与完整 fileMeta 元数据 (0.865104ms) + ✔ 2. 扩展名分类规则准确分发至对应的高保真渲染器类型 (0.258543ms) + ✔ 3. 受控工作流交付物严格隔离:绝不命中 FilePreviewPanel,保持 BlockRenderer 纯净 (0.176665ms) + ✔ 4. 上传文档支持受控流转:从个人私密一键共享至项目参考库后,在线预览依然可用且权限标态正确 (0.179385ms) +✔ 用户上传文档多格式在线预览体系与渲染分流测试 (2.772037ms) +▶ 项目立项、成员职责分配与退出留痕治理测试套件 + ✔ 1. 默认项目库包含完整元数据与法定三权成员 (active 状态) (0.6818ms) + ✔ 2. 跨用户会话可见性严格隔离:未加入成员完全不可见 (0.273272ms) + ✔ 3. 创建者添加新成员并分配职责后,新成员即刻可见该项目会话 (0.165275ms) + ✔ 4. 中途加入后期退出 (exited):保留项目会话但标记已退出只读留痕 (0.162316ms) + ✔ 5. 超级管理员 (ADMIN) 享有全量通配可见与管理兜底特权 (0.093258ms) +✔ 项目立项、成员职责分配与退出留痕治理测试套件 (2.459847ms) +▶ 新建项目工程文档模板质量与纯净性保障套件(对标官方参考系统) + ✔ 1. 必须精确生成 7 份官方全流程受控工作流模板且标记纯净 (0.848355ms) + ✔ 2. 杜绝旧项目 CUSA-NeuroPulse 串号泄露,动态注入新项目信息 (0.701719ms) + ✔ 3. 严格对标参考系统模板规范要素,绝非 3 行残缺摘要 (0.411778ms) + ✔ 4. 缓存丢失场景下即时自愈重建 AST 能力验证 (0.532624ms) +✔ 新建项目工程文档模板质量与纯净性保障套件(对标官方参考系统) (7.526547ms) +▶ 用户上传文档私密归档与共享流转测试 + ✔ 10 个评审项目均包含已共享的 URS 交付物与 AST,支持跨角色合规查阅且会话均以 assistant 闭环 (1.469307ms) + ✔ 用户上传附件自动生成私密交付物并在 AST 中建立隔离,支持流转至项目参考库 (1.644061ms) + ✔ 单轮对话支持一次上传多个文档(上限5个),批量生成私密交付物并遵循数量约束 (0.552124ms) +✔ 用户上传文档私密归档与共享流转测试 (4.558774ms) +▶ 工作流切换与消息展示时序测试套件 + ✔ 1. 当存在历史 seed 消息且追加新实时消息时,新消息必须严格沉淀在末尾 (1.243133ms) + ✔ 2. 切回项目时相同正文不应因 temp/server id 差异重复展示 (0.185805ms) +✔ 工作流切换与消息展示时序测试套件 (2.221984ms) +✔ DeliverableItem 必须包含 projectId 与 isTemplateOnly 属性 (0.775697ms) +ℹ tests 532 +ℹ suites 41 +ℹ pass 532 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 1586.5667 + + RUN v5.0.0 /home/weichen/projects/Agent-System/.worktrees/refresh-task-state-recovery-2026-09-28/web + + + Test Files 3 passed (3) + Tests 34 passed (34) + Start at 06:12:03 + Duration 333ms (transform 67%, import 25%, tests 6%, worker 2%) + +NPM_TEST:0