diff --git a/docs/bug-sheet-review.md b/docs/bug-sheet-review.md index 06d5d08b..c585139e 100644 --- a/docs/bug-sheet-review.md +++ b/docs/bug-sheet-review.md @@ -113,3 +113,9 @@ - Excel 第 58 行:截图的子任务为 failed,队列 completed 表示所有子任务已经结束;绿色“已完成”和 100% 被误认为全部成功。列表和详情共用展示函数,现在全部失败显示失败色及“执行失败”,部分失败显示“已结束(N 项失败)”,详情解释进度统计结束任务,包含失败/取消。后端生命周期和重跑规则不变。 - 3 项实际展示函数回归覆盖全失败、混合结果、成功、运行中及已有周期队列的后续运行信息;不扩展调度能力。 + +## 追加:Agent 取消竞态与最终事件 + +- Excel 第 24 行:AgentTaskManager 原先允许取消之后的 UpdateTaskStatus/FinishTaskRun 覆盖终态。明确用户取消增加单向取消标记,晚到的 running/completed/failed 更新及 completed 收尾仍保存 cancelled;“中断并继续”不设置该标记。 +- 主 SSE 收尾从实际已保存任务取 status,保留原消息字段且不修改调用方对象;前端 done 不再一律覆盖为成功标题,取消/失败/超时/清理异常使用实际结果,取消时遗留工具调用标记取消。任务 eventBus 本来已有真实 status,保持不变。 +- 新增取消晚到更新、正常继续、done 实际状态与前端展示回归;完整 handler 包测试通过。Excel 第 60 行仍需审批等待中断的完整路径验证,暂不声明该历史场景全部完成。 diff --git a/docs/bug-sheet-status.md b/docs/bug-sheet-status.md index 91561279..274e5731 100644 --- a/docs/bug-sheet-status.md +++ b/docs/bug-sheet-status.md @@ -26,7 +26,7 @@ | 21 | 19 | webshell 连接口令明文 | 已修复并有针对性验证 | | 22 | 20 | 不知道这个是啥,应该是样式问题 | 与第 25 行同一 WebShell 路径/假文件问题,已修复 | | 23 | 21 | 创建监听器 必填字段不清楚,交互上不知道是哪个必填 | 已修复必填标识、逐字段提示及端口严格校验;前后端回归通过 | -| 24 | 22 | 已经取消的任务,结束之后标签会变成成功,好像没有取消成功 | C2 终态已修复,其他任务路径待核对 | +| 24 | 22 | 已经取消的任务,结束之后标签会变成成功,好像没有取消成功 | C2 与 Agent 取消终态保护已修复,done 展示保留真实状态;审批中断场景继续核对 | | 25 | 23 | 【WebShell 文件管理】目录树用相对路径导致点进去必报错,且报错文本被解析成假文件条目步骤 | 已修复并有针对性验证 | | 26 | 24 | 工作流 id 由客户端随便填、零校验 | 已修复新 ID 格式/长度、重复创建及编辑改 ID 校验 | | 27 | 25 | 样式问题 | 待核对 | diff --git a/internal/handler/task_cancel_terminal_test.go b/internal/handler/task_cancel_terminal_test.go new file mode 100644 index 00000000..6bf4cb20 --- /dev/null +++ b/internal/handler/task_cancel_terminal_test.go @@ -0,0 +1,79 @@ +package handler + +import ( + "context" + "cyberstrike-ai/internal/multiagent" + "testing" +) + +func TestCancelledAgentTaskCannotFinishSuccessfully(t *testing.T) { + for _, lateStatus := range []string{"running", "completed", "failed"} { + m := NewAgentTaskManager() + ctx, cancel := context.WithCancelCause(context.Background()) + task, err := m.StartTask("conversation", "test", cancel) + if err != nil { + t.Fatal(err) + } + if ok, err := m.CancelTask("conversation", ErrTaskCancelled); err != nil || !ok { + t.Fatalf("cancel: %v %v", ok, err) + } + if context.Cause(ctx) != ErrTaskCancelled { + t.Fatal("cancellation not delivered") + } + m.UpdateTaskStatus("conversation", lateStatus) + if err := m.FinishTaskRun("conversation", task.RunID, "completed"); err != nil { + t.Fatal(err) + } + done := m.GetCompletedTasks() + if len(done) != 1 || done[0].Status != "cancelled" { + t.Fatalf("cancelled task became successful: %#v", done) + } + if len(m.GetActiveTasks()) != 0 { + t.Fatal("cancelled task remains active") + } + } +} +func TestInterruptContinueDoesNotBecomeTaskCancellation(t *testing.T) { + m := NewAgentTaskManager() + _, cancel := context.WithCancelCause(context.Background()) + task, err := m.StartTask("conversation", "test", cancel) + if err != nil { + t.Fatal(err) + } + if _, err := m.CancelTask("conversation", multiagent.ErrInterruptContinue); err != nil { + t.Fatal(err) + } + m.UpdateTaskStatus("conversation", "running") + if err := m.FinishTaskRun("conversation", task.RunID, "completed"); err != nil { + t.Fatal(err) + } + if done := m.GetCompletedTasks(); len(done) != 1 || done[0].Status != "completed" { + t.Fatal("continue run lost success") + } +} + +func TestDoneEventCarriesTheSavedCancellationStatus(t *testing.T) { + m := NewAgentTaskManager() + task, err := m.StartTask("conversation", "test", func(error) {}) + if err != nil { + t.Fatal(err) + } + if _, err := m.CancelTask("conversation", ErrTaskCancelled); err != nil { + t.Fatal(err) + } + h := &AgentHandler{tasks: m} + var result map[string]interface{} + send := h.taskFinishingEventSender(func(kind, message string, data interface{}) { + if kind == "done" { + result = data.(map[string]interface{}) + } + }, "conversation", task.RunID, func() string { return "completed" }) + original := map[string]interface{}{"messageId": "message"} + send("done", "", original) + if result["status"] != "cancelled" || result["messageId"] != "message" { + t.Fatalf("incorrect done payload: %#v", result) + } + if _, exists := original["status"]; exists { + t.Fatal("caller payload mutated") + } +} diff --git a/internal/handler/task_lifecycle.go b/internal/handler/task_lifecycle.go index a9a75091..a7d8fb77 100644 --- a/internal/handler/task_lifecycle.go +++ b/internal/handler/task_lifecycle.go @@ -30,6 +30,22 @@ func (h *AgentHandler) taskFinishingEventSender(send func(string, string, interf } send("error", taskCleanupMessage(err)+": "+err.Error(), map[string]interface{}{"errorType": taskCleanupStatus(err)}) data = map[string]interface{}{"conversationId": conversationID, "runId": runID, "status": taskCleanupStatus(err), "cleanupError": err.Error()} + } else { + payload := map[string]interface{}{} + if existing, ok := data.(map[string]interface{}); ok { + for key, value := range existing { + payload[key] = value + } + } + payload["conversationId"] = conversationID + payload["runId"] = runID + for _, task := range h.tasks.GetCompletedTasks() { + if task.RunID == runID && task.ConversationID == conversationID { + payload["status"] = task.Status + break + } + } + data = payload } } send(eventType, message, data) diff --git a/internal/handler/task_manager.go b/internal/handler/task_manager.go index c55c5c44..e1b4301b 100644 --- a/internal/handler/task_manager.go +++ b/internal/handler/task_manager.go @@ -37,6 +37,7 @@ type AgentTask struct { IsolationBackend string `json:"isolationBackend,omitempty"` finishing chan struct{} stopping chan struct{} + cancelRequested bool finalStatus string ConversationID string `json:"conversationId"` Title string `json:"title,omitempty"` @@ -484,6 +485,11 @@ func (m *AgentTaskManager) CancelTask(conversationID string, cause error) (bool, return true, nil } + if cause == nil || errors.Is(cause, ErrTaskCancelled) { + task.cancelRequested = true + task.finalStatus = "cancelled" + } + // ErrInterruptContinue:仅掐断当前推理步骤,随后由处理器续跑,不进入长时间「取消中」态。 if cause != nil && errors.Is(cause, multiagent.ErrInterruptContinue) { task.Status = "running" @@ -559,6 +565,9 @@ func (m *AgentTaskManager) UpdateTaskStatus(conversationID string, status string if task.finishing != nil || task.Status == "cleanup_failed" { return } + if task.cancelRequested { + status = "cancelled" + } switch status { case "completed", "cancelled", "failed", "timeout": task.finalStatus = status @@ -628,6 +637,9 @@ func (m *AgentTaskManager) FinishTaskRun(conversationID, runID, finalStatus stri } return nil } + if task.cancelRequested { + finalStatus = "cancelled" + } done := make(chan struct{}) task.finishing = done task.finalStatus = finalStatus diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index 62f7c661..a8afbb3d 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -425,6 +425,13 @@ function getToolCallMapping(progressId, toolCallId) { return toolCallStatusMap.get(String(toolCallId)) || null; } +function progressDoneOutcome(data) { + const status = String(data && (data.status || data.workflowStatus) || '').toLowerCase(); + if (status === 'cancelled' || status === 'canceled') return { key: 'chat.taskCancelled', fallback: '任务已取消', icon: '⛔', toolStatus: 'cancelled' }; + if (['failed', 'timeout', 'cleanup_failed', 'cleanup_unconfirmed'].includes(status)) return { key: status === 'timeout' ? 'tasks.statusTimeout' : 'tasks.statusFailed', fallback: status === 'timeout' ? '任务超时' : '任务执行失败', icon: '❌', toolStatus: 'failed' }; + return { key: 'chat.penetrationTestComplete', fallback: '渗透测试完成', icon: '✅', toolStatus: 'failed' }; +} + function finalizeOutstandingToolCallsForProgress(progressId, finalStatus) { if (!progressId) return; const pid = String(progressId); @@ -3866,7 +3873,7 @@ function handleStreamEvent(event, progressElement, progressId, // 立即刷新任务状态 loadActiveTasks(); // Close any remaining running tool calls for this progress. - finalizeOutstandingToolCallsForProgress(progressId, 'failed'); + finalizeOutstandingToolCallsForProgress(progressId, 'cancelled'); break; case 'response_start': { @@ -4187,7 +4194,8 @@ function handleStreamEvent(event, progressElement, progressId, // 完成,更新进度标题(如果进度消息还存在) const doneTitle = document.querySelector(`#${progressId} .progress-stage`); if (doneTitle) { - doneTitle.textContent = '✅ ' + (typeof window.t === 'function' ? window.t('chat.penetrationTestComplete') : '渗透测试完成'); + const outcome = progressDoneOutcome(event.data); + doneTitle.textContent = outcome.icon + ' ' + (typeof window.t === 'function' ? window.t(outcome.key) : outcome.fallback); } // 更新对话ID if (event.data && event.data.conversationId) { @@ -4207,7 +4215,7 @@ function handleStreamEvent(event, progressElement, progressId, // 立即刷新任务状态(确保任务状态同步) loadActiveTasks(); // Close any remaining running tool calls for this progress (best-effort). - finalizeOutstandingToolCallsForProgress(progressId, 'failed'); + finalizeOutstandingToolCallsForProgress(progressId, progressDoneOutcome(event.data).toolStatus); // 延迟再次刷新任务状态(确保后端已完成状态更新) setTimeout(() => { diff --git a/web/static/js/progress-done-outcome.test.cjs b/web/static/js/progress-done-outcome.test.cjs new file mode 100644 index 00000000..225aba9a --- /dev/null +++ b/web/static/js/progress-done-outcome.test.cjs @@ -0,0 +1,8 @@ +const fs=require('node:fs'),vm=require('node:vm'),test=require('node:test'),assert=require('node:assert/strict'); +const source=fs.readFileSync('web/static/js/monitor.js','utf8');const c=vm.createContext({});vm.runInContext(source.slice(source.indexOf('function progressDoneOutcome('),source.indexOf('function finalizeOutstandingToolCallsForProgress(')),c); +test('done preserves cancelled and failed outcomes instead of showing success',()=>{ + for(const status of ['cancelled','canceled','failed','timeout','cleanup_failed','cleanup_unconfirmed']){ + const result=c.progressDoneOutcome({status});assert.notEqual(result.icon,'✅');assert.equal(result.toolStatus,status.startsWith('cancel')?'cancelled':'failed'); + } + assert.equal(c.progressDoneOutcome({status:'completed'}).icon,'✅');assert.equal(c.progressDoneOutcome({workflowStatus:'cancelled'}).icon,'⛔'); +});