fix: preserve task cancellation through final events

This commit is contained in:
Ed1s0nZ committed 2026-10-06 03:09:04 +08:00
1 parent 07066f9219
commit 054a77cdab
7 files changed
+133 -4

No files matched your search

+6
View File
@@ -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 行仍需审批等待中断的完整路径验证,暂不声明该历史场景全部完成。
+1 -1
View File
@@ -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 | 样式问题 | 待核对 |
@@ -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")
}
}
+16
View File
@@ -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)
+12
View File
@@ -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
+11 -3
View File
@@ -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(() => {
@@ -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,'⛔');
});