From 39a8f2c31a631d6e4e7f6ac148ae72e9a42ffe0c Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:49:38 +0800 Subject: [PATCH 1/2] =?UTF-8?q?chore(agent):=20baseline=20=E2=80=94=20the?= =?UTF-8?q?=20task=20worktree=20started=20here?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 997d2d98c086a8f45e418f3fb05fc3c28ff1ee00 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:07:39 +0800 Subject: [PATCH 2/2] feat(tasks): stop tasks that miss a configurable findings checkpoint --- CHANGELOG.md | 2 + agent/cancelcause.go | 2 + agent/terminalreason_test.go | 2 +- db/schema.sql | 13 + db/task_archives_restore.go | 6 + db/task_no_result.go | 68 ++++++ db/task_no_result_test.go | 134 ++++++++++ db/tasks.go | 45 +++- server/dto.go | 10 + server/engine_cancelcause_test.go | 1 + server/engine_no_result_test.go | 228 ++++++++++++++++++ server/engine_timeout.go | 147 ++++++++--- server/goals.go | 4 + server/manager.go | 61 ++++- server/orchestration.go | 13 +- server/server.go | 16 +- server/task_runtime.go | 11 + .../app/(main)/function/tasks/detail/page.tsx | 7 +- web/src/app/(main)/function/tasks/page.tsx | 63 ++++- web/src/components/status-badge.tsx | 6 +- web/src/i18n/task-messages.ts | 18 +- web/src/lib/api.ts | 6 + web/src/lib/mock/handler.ts | 9 + web/src/lib/types.ts | 4 + 24 files changed, 812 insertions(+), 64 deletions(-) create mode 100644 db/task_no_result.go create mode 100644 db/task_no_result_test.go create mode 100644 server/engine_no_result_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 39be6ca..6fd29cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ## [Unreleased] +- 新增可选「无成果止损」:按实际运行时长、独立漏洞数量与最低等级做一次检查,未达标时自动收尾并显示「无成果停止」;暂停、排队及计划窗口外不计时,复活后重新计时,默认关闭。停止状态复用 `timeout`,已有 `on_task_timeout` 触发器仍会执行并可能消耗 token。 + ## [0.3.18] - 2026-10-12 ### 审批审查 diff --git a/agent/cancelcause.go b/agent/cancelcause.go index 90abe6f..2f5f5d7 100644 --- a/agent/cancelcause.go +++ b/agent/cancelcause.go @@ -39,6 +39,8 @@ var ( "后端启动时根据数据库中持久化的状态恢复了任务暂停。本次运行被取消;正常情况下恢复阶段没有正在运行的 Agent") AbortGoalMet = cause("goal_met", "规划者判定任务目标已达成", "规划者判定任务目标已达成并将任务置为 done,随后取消仍在运行的 Worker;这些意图会标记为 stopped,而不是失败") + AbortNoResultStop = cause("no_result_stop", "无成果止损收尾的等待时间已用尽", + "任务运行至无成果检查点仍未达到漏洞数量和等级要求,90 秒收尾宽限后执行硬取消;意图标记为 exhausted,已写入的事实和资产保留") AbortSettleDrainTimeout = cause("settle_drain_timeout", "任务超时收尾的等待时间已用尽", "任务到达 timeout 后等待正在运行的 Worker 优雅收尾,但 90 秒 drain 宽限仍不足,因此执行硬取消;意图会标记为 exhausted,收尾阶段已经写入的事实和资产会保留") diff --git a/agent/terminalreason_test.go b/agent/terminalreason_test.go index 2a73501..aab1757 100644 --- a/agent/terminalreason_test.go +++ b/agent/terminalreason_test.go @@ -140,7 +140,7 @@ func TestTerminalTextNonAbortReasons(t *testing.T) { func TestAbortCausesAreWellFormed(t *testing.T) { all := []*AbortCause{ AbortPausedByUser, AbortPausedByProxy, AbortPausedByOrchestrator, AbortTaskDeleted, AbortPausedOnReload, - AbortGoalMet, AbortSettleDrainTimeout, AbortKilledByPlanner, AbortWorkPausedByUser, + AbortGoalMet, AbortSettleDrainTimeout, AbortNoResultStop, AbortKilledByPlanner, AbortWorkPausedByUser, AbortWorkCancelledByUser, AbortWorkFinished, AbortPausedRaceGuard, AbortChatStoppedByUser, AbortChatPausedWithTask, AbortChatTurnFinished, AbortShutdown, AbortRunHardTimeout, diff --git a/db/schema.sql b/db/schema.sql index f94d858..9002df1 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -523,6 +523,12 @@ CREATE TABLE IF NOT EXISTS tasks ( company_id BIGINT REFERENCES companies(id) ON DELETE SET NULL, parent_ref TEXT, timeout_seconds INTEGER NOT NULL DEFAULT 0, + no_result_seconds INTEGER NOT NULL DEFAULT 0, + no_result_min_findings INTEGER NOT NULL DEFAULT 1, + no_result_min_severity TEXT NOT NULL DEFAULT '', + no_result_base_seconds BIGINT NOT NULL DEFAULT 0, + no_result_checked_at TIMESTAMPTZ, + stop_reason TEXT NOT NULL DEFAULT '', plan_heartbeat_seconds INTEGER NOT NULL DEFAULT 300, coverage_enabled BOOLEAN NOT NULL DEFAULT true, worker_count INTEGER NOT NULL DEFAULT 0, -- 0=用全局默认 workers @@ -537,6 +543,13 @@ CREATE TABLE IF NOT EXISTS tasks ( created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +-- Opt-in checkpoint based on active runtime; defaults preserve existing tasks. +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS no_result_seconds INTEGER NOT NULL DEFAULT 0; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS no_result_min_findings INTEGER NOT NULL DEFAULT 1; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS no_result_min_severity TEXT NOT NULL DEFAULT ''; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS no_result_base_seconds BIGINT NOT NULL DEFAULT 0; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS no_result_checked_at TIMESTAMPTZ; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS stop_reason TEXT NOT NULL DEFAULT ''; CREATE INDEX IF NOT EXISTS idx_tasks_alive ON tasks(created_at DESC) WHERE deleted_at IS NULL; CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status) WHERE deleted_at IS NULL; DROP TRIGGER IF EXISTS trg_tasks_upd ON tasks; diff --git a/db/task_archives_restore.go b/db/task_archives_restore.go index 4ae0b6d..a2f7c94 100644 --- a/db/task_archives_restore.go +++ b/db/task_archives_restore.go @@ -123,6 +123,7 @@ AND NOT EXISTS ( name='',remark='',category_id=NULL,description='',goal='',paused=true,queued=false,queued_at=NULL,queue_mode='', llm_profile_id=NULL,active_llm_profile_id=NULL,llm_chain_revision=llm_chain_revision+1, company_id=NULL,parent_ref=NULL,timeout_seconds=0,coverage_enabled=true,share_global_assets=false,worker_count=0,pinned_at=NULL, + no_result_seconds=0,no_result_min_findings=1,no_result_min_severity='',no_result_base_seconds=0,no_result_checked_at=NULL,stop_reason='', first_run_at=NULL,deadline_at=NULL,archived_at=now(),deleted_at=now() WHERE id=$1`, taskID); err != nil { return err @@ -322,6 +323,11 @@ func restoreTaskStub(tx *sql.Tx, row map[string]any, taskID, remaining int64) er llm_profile_id=archived.llm_profile_id,active_llm_profile_id=archived.active_llm_profile_id, llm_chain_revision=archived.llm_chain_revision,company_id=archived.company_id,parent_ref=archived.parent_ref, timeout_seconds=archived.timeout_seconds,plan_heartbeat_seconds=archived.plan_heartbeat_seconds, + no_result_seconds=COALESCE(archived.no_result_seconds,0), + no_result_min_findings=COALESCE(archived.no_result_min_findings,1), + no_result_min_severity=COALESCE(archived.no_result_min_severity,''), + no_result_base_seconds=COALESCE(archived.no_result_base_seconds,0), + no_result_checked_at=archived.no_result_checked_at,stop_reason=COALESCE(archived.stop_reason,''), coverage_enabled=archived.coverage_enabled,share_global_assets=COALESCE(archived.share_global_assets,false),worker_count=COALESCE(archived.worker_count,0),pinned_at=archived.pinned_at,first_run_at=archived.first_run_at, deadline_at=CASE WHEN archived.paused AND $3>0 THEN now()+make_interval(secs=>$3::double precision) ELSE archived.deadline_at END, diff --git a/db/task_no_result.go b/db/task_no_result.go new file mode 100644 index 0000000..552e9ff --- /dev/null +++ b/db/task_no_result.go @@ -0,0 +1,68 @@ +package db + +import ( + "database/sql" + "fmt" + "time" +) + +const StopReasonNoResult = "no_result" + +// NormalizeNoResultOptions is shared by task creation and API validation. +func NormalizeNoResultOptions(opts *TaskCreateOptions) error { + if opts.NoResultSeconds < 0 { + opts.NoResultSeconds = 0 + } else if opts.NoResultSeconds > 0 && opts.NoResultSeconds < 60 { + opts.NoResultSeconds = 60 + } + if opts.NoResultMinFindings < 1 { + opts.NoResultMinFindings = 1 + } + switch opts.NoResultMinSeverity { + case "", "low", "medium", "high", "critical": + return nil + default: + return fmt.Errorf("invalid no_result_min_severity: %q", opts.NoResultMinSeverity) + } +} + +// CountQualifyingFindings counts task-local independent findings, using the case +// severity for grouped observations. False positives never count; unassessed +// findings count only when the caller sets no severity threshold. +func (d *DB) CountQualifyingFindings(taskID int64, minSeverity string) (int, error) { + if err := NormalizeNoResultOptions(&TaskCreateOptions{NoResultMinSeverity: minSeverity}); err != nil { + return 0, err + } + var count int + err := d.QueryRow(`WITH observations AS ( + SELECT CASE WHEN m.case_id IS NULL THEN 'f:'||f.id ELSE 'c:'||m.case_id END entity, + CASE WHEN m.case_id IS NULL THEN f.severity ELSE c.severity END severity + FROM findings f + LEFT JOIN finding_case_members m ON m.finding_id=f.id + LEFT JOIN finding_cases c ON c.id=m.case_id + WHERE f.task_id=$1 AND f.status<>'false_positive' +) +SELECT count(DISTINCT entity) FROM observations +WHERE $2='' OR array_position(ARRAY['low','medium','high','critical'],severity) + >= array_position(ARRAY['low','medium','high','critical'],$2)`, taskID, minSeverity).Scan(&count) + return count, err +} + +// MarkNoResultChecked durably claims the single checkpoint. A terminal task or +// an already checked checkpoint returns nil, so concurrent callers cannot stop +// a task twice. A failed decision also persists its reason so restart can finish +// settling without recounting findings that arrived during the drain. +func (d *DB) MarkNoResultChecked(taskID int64, stop bool) (*time.Time, error) { + var checked time.Time + err := d.QueryRow(`UPDATE tasks SET no_result_checked_at=now(), stop_reason=CASE WHEN $2 THEN 'no_result' ELSE '' END + WHERE id=$1 AND deleted_at IS NULL AND no_result_checked_at IS NULL + AND status NOT IN ('done','failed','timeout') + RETURNING no_result_checked_at`, taskID, stop).Scan(&checked) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &checked, nil +} diff --git a/db/task_no_result_test.go b/db/task_no_result_test.go new file mode 100644 index 0000000..865ecf8 --- /dev/null +++ b/db/task_no_result_test.go @@ -0,0 +1,134 @@ +package db + +import ( + "encoding/json" + "testing" +) + +func TestNormalizeNoResultOptions(t *testing.T) { + for _, tc := range []struct{ seconds, count, wantSeconds, wantCount int }{ + {0, 0, 0, 1}, {-1, -1, 0, 1}, {1, 0, 60, 1}, {59, 2, 60, 2}, {60, 1, 60, 1}, {7200, 3, 7200, 3}, + } { + opts := TaskCreateOptions{NoResultSeconds: tc.seconds, NoResultMinFindings: tc.count} + if err := NormalizeNoResultOptions(&opts); err != nil || opts.NoResultSeconds != tc.wantSeconds || opts.NoResultMinFindings != tc.wantCount { + t.Fatalf("normalize %+v = %+v, %v", tc, opts, err) + } + } + if err := NormalizeNoResultOptions(&TaskCreateOptions{NoResultMinSeverity: "unknown"}); err == nil { + t.Fatal("invalid severity accepted") + } +} + +func TestCountQualifyingFindings(t *testing.T) { + d, task, ids := caseTestDB(t) + caseID, err := d.MergeFindingCase(t.Context(), task.ID, ids[:3], "same defect", "same cause", "test") + if err != nil { + t.Fatal(err) + } + // The case's severity overrides the high/medium/low original reports. + if _, err := d.Exec(`UPDATE finding_cases SET severity='medium' WHERE id=$1`, caseID); err != nil { + t.Fatal(err) + } + for _, severity := range []string{"", "critical", "low"} { + id, err := d.AddFinding(task.ID, 0, "test", "test", severity, "", "", "test", nil) + if err != nil { + t.Fatal(err) + } + if severity == "critical" { + if _, err := d.Exec(`UPDATE findings SET status='false_positive' WHERE id=$1`, id); err != nil { + t.Fatal(err) + } + } + } + child, err := d.CreateTaskWithOptions("child", "inherits only", TaskCreateOptions{SourceTaskIDs: []int64{task.ID}}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.DeleteTask(child.ID) }) + for severity, want := range map[string]int{"": 4, "low": 3, "medium": 2, "high": 1, "critical": 0} { + got, err := d.CountQualifyingFindings(task.ID, severity) + if err != nil || got != want { + t.Errorf("severity %q: count=%d, err=%v, want %d", severity, got, err, want) + } + if got, err := d.CountQualifyingFindings(child.ID, severity); err != nil || got != 0 { + t.Errorf("inherited findings counted: %d, %v", got, err) + } + } + // A case with only false-positive observations contributes nothing. + if _, err := d.Exec(`UPDATE findings SET status='false_positive' WHERE id=ANY($1)`, ids[:3]); err != nil { + t.Fatal(err) + } + if got, err := d.CountQualifyingFindings(task.ID, ""); err != nil || got != 3 { + t.Fatalf("false-positive case: count=%d err=%v", got, err) + } +} + +func TestNoResultPersistenceGuardAndLegacyArchive(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Fatal(err) + } + defer d.Close() + task, err := d.CreateTaskWithOptions("checkpoint", "goal", TaskCreateOptions{ + NoResultSeconds: 1, NoResultMinFindings: 0, NoResultMinSeverity: "high", + }) + if err != nil { + t.Fatal(err) + } + defer d.DeleteTask(task.ID) + if stored, err := d.GetTask(task.ID); err != nil || stored.NoResultSeconds != 60 || stored.NoResultMinFindings != 1 || stored.NoResultMinSeverity != "high" { + t.Fatalf("creation round trip: %+v, %v", stored, err) + } + if checked, err := d.MarkNoResultChecked(task.ID, true); err != nil || checked == nil { + t.Fatalf("first check: %v, %v", checked, err) + } + if checked, err := d.MarkNoResultChecked(task.ID, true); err != nil || checked != nil { + t.Fatalf("second check: %v, %v", checked, err) + } + if _, _, won, err := d.SetTerminalStatusGuardedReasonRuntime(task.ID, "timeout", StopReasonNoResult); err != nil || !won { + t.Fatalf("stop: won=%v err=%v", won, err) + } + if _, _, won, err := d.SetTerminalStatusGuardedReasonRuntime(task.ID, "done", ""); err != nil || won { + t.Fatalf("terminal guard: won=%v err=%v", won, err) + } + stored, err := d.GetTask(task.ID) + if err != nil || stored.StopReason != StopReasonNoResult || stored.NoResultCheckedAt == nil { + t.Fatalf("persisted decision: %+v, %v", stored, err) + } + if err := d.SetStatus(task.ID, "running"); err != nil { + t.Fatal(err) + } + if stored, err := d.GetTask(task.ID); err != nil || stored.StopReason != "" { + t.Fatalf("nonterminal reason not cleared: %+v, %v", stored, err) + } + if _, _, won, err := d.SetTerminalStatusGuardedReasonRuntime(task.ID, "done", StopReasonNoResult); err != nil || !won { + t.Fatalf("completed stop: won=%v err=%v", won, err) + } + if stored, err := d.GetTask(task.ID); err != nil || stored.StopReason != "" { + t.Fatalf("done must have no stop reason: %+v, %v", stored, err) + } + var raw []byte + if err := d.QueryRow(`SELECT to_jsonb(t) FROM tasks t WHERE id=$1`, task.ID).Scan(&raw); err != nil { + t.Fatal(err) + } + var row map[string]any + if err := json.Unmarshal(raw, &row); err != nil { + t.Fatal(err) + } + for _, key := range []string{"no_result_seconds", "no_result_min_findings", "no_result_min_severity", "no_result_base_seconds", "no_result_checked_at", "stop_reason"} { + delete(row, key) + } + tx, err := d.Begin() + if err != nil { + t.Fatal(err) + } + defer tx.Rollback() + if err := restoreTaskStub(tx, row, task.ID, 0); err != nil { + t.Fatalf("restore old archive without checkpoint fields: %v", err) + } + var seconds, minimum int + var severity, reason string + if err := tx.QueryRow(`SELECT no_result_seconds,no_result_min_findings,no_result_min_severity,stop_reason FROM tasks WHERE id=$1`, task.ID).Scan(&seconds, &minimum, &severity, &reason); err != nil || seconds != 0 || minimum != 1 || severity != "" || reason != "" { + t.Fatalf("legacy defaults: %d %d %q %q, %v", seconds, minimum, severity, reason, err) + } +} diff --git a/db/tasks.go b/db/tasks.go index 91afddd..03a9d20 100644 --- a/db/tasks.go +++ b/db/tasks.go @@ -42,7 +42,14 @@ type Task struct { ParentRef string `json:"parent_ref,omitempty"` // 父任务 id(编排 spawn 记录;空=顶层) CreatedAt time.Time `json:"created_at"` CompletedAt *time.Time `json:"completed_at,omitempty"` // 进入终态(done/failed/timeout)的时刻;非终态为 nil - // 任务级超时(见 docs/任务级超时与收尾设计.md)。 + // Optional active-runtime checkpoint; StopReason also persists a pending stop. + NoResultSeconds int `json:"no_result_seconds"` + NoResultMinFindings int `json:"no_result_min_findings"` + NoResultMinSeverity string `json:"no_result_min_severity"` + NoResultBaseSeconds int64 `json:"no_result_base_seconds"` + NoResultCheckedAt *time.Time `json:"no_result_checked_at,omitempty"` + StopReason string `json:"stop_reason"` + // 任务级绝对墙钟超时。 TimeoutSeconds int `json:"timeout_seconds"` // 0=不限时 FirstRunAt *time.Time `json:"first_run_at,omitempty"` // 首次真正开始运行的时刻(非 created_at);nil=尚未运行 DeadlineAt *time.Time `json:"deadline_at,omitempty"` // = first_run_at + timeout_seconds;nil=不限或未运行 @@ -184,6 +191,9 @@ type TaskCreateOptions struct { CompanyIDs []int64 LLMProfileIDs []int64 TimeoutSeconds int + NoResultSeconds int + NoResultMinFindings int + NoResultMinSeverity string PlanHeartbeatSeconds int // CoverageEnabled 是「资产覆盖度功能」开关;nil=默认开(true),让不关心该开关的创建 // 路径(编排 spawn、老 API)沿用原行为。仅 web 创建任务时可显式传 false 关闭。 @@ -201,6 +211,9 @@ type TaskCreateOptions struct { // CreateTaskWithOptions creates an exploration, task, direct source relations, // and the ordered task LLM chain in one transaction. func (d *DB) CreateTaskWithOptions(description, goal string, opts TaskCreateOptions) (*Task, error) { + if err := NormalizeNoResultOptions(&opts); err != nil { + return nil, err + } if len(opts.SourceTaskIDs) > MaxTaskSourceCount { return nil, fmt.Errorf("too many source tasks: got %d, maximum is %d", len(opts.SourceTaskIDs), MaxTaskSourceCount) } @@ -267,16 +280,17 @@ VALUES ($1, 'fact', $2, 0, 'origin', 'system')`, expID, string(originPayload)); Name: opts.Name, CategoryID: opts.CategoryID, CategoryName: categoryName, Description: description, Goal: goal, ExplorationID: expID, LLMProfileID: active, ActiveLLMProfileID: active, - LLMProfileIDs: append([]int64(nil), opts.LLMProfileIDs...), - SourceTaskIDs: append([]int64(nil), opts.SourceTaskIDs...), - CompanyIDs: append([]int64(nil), opts.CompanyIDs...), + LLMProfileIDs: append([]int64(nil), opts.LLMProfileIDs...), + SourceTaskIDs: append([]int64(nil), opts.SourceTaskIDs...), + CompanyIDs: append([]int64(nil), opts.CompanyIDs...), + NoResultSeconds: opts.NoResultSeconds, NoResultMinFindings: opts.NoResultMinFindings, NoResultMinSeverity: opts.NoResultMinSeverity, TimeoutSeconds: opts.TimeoutSeconds, PlanHeartbeatSeconds: opts.PlanHeartbeatSeconds, CoverageEnabled: coverageEnabled, ShareGlobalAssets: opts.ShareGlobalAssets, WorkerCount: opts.WorkerCount, Remark: opts.Remark, } if err := tx.QueryRow(` -INSERT INTO tasks(name, remark, category_id, description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds, coverage_enabled, worker_count, share_global_assets) -VALUES ($1,$2,$3,$4,$5,$6,$7,$7,$8,$9,$10,$11,$12) -RETURNING id, status, paused, created_at`, opts.Name, opts.Remark, opts.CategoryID, description, goal, expID, active, opts.TimeoutSeconds, opts.PlanHeartbeatSeconds, coverageEnabled, opts.WorkerCount, opts.ShareGlobalAssets).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil { +INSERT INTO tasks(name, remark, category_id, description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds, coverage_enabled, worker_count, share_global_assets, no_result_seconds, no_result_min_findings, no_result_min_severity) +VALUES ($1,$2,$3,$4,$5,$6,$7,$7,$8,$9,$10,$11,$12,$13,$14,$15) +RETURNING id, status, paused, created_at`, opts.Name, opts.Remark, opts.CategoryID, description, goal, expID, active, opts.TimeoutSeconds, opts.PlanHeartbeatSeconds, coverageEnabled, opts.WorkerCount, opts.ShareGlobalAssets, opts.NoResultSeconds, opts.NoResultMinFindings, opts.NoResultMinSeverity).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil { return nil, err } if err := insertTaskRelations(tx, t.ID, opts.SourceTaskIDs); err != nil { @@ -413,11 +427,11 @@ func insertTaskLLMProfiles(tx *sql.Tx, taskID int64, profileIDs []int64) error { const taskCols = `id, COALESCE(name,''), remark, category_id, COALESCE((SELECT category.name FROM task_categories category WHERE category.id=tasks.category_id),''), -description, goal, exploration_id, status, paused, COALESCE(pause_reason,''), queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), pinned_at, created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), share_global_assets, COALESCE(worker_count,0), first_run_at, deadline_at, COALESCE(active_run_seconds,0), run_segment_started_at` +description, goal, exploration_id, status, paused, COALESCE(pause_reason,''), queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), pinned_at, created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), share_global_assets, COALESCE(worker_count,0), first_run_at, deadline_at, COALESCE(active_run_seconds,0), run_segment_started_at, no_result_seconds, no_result_min_findings, no_result_min_severity, no_result_base_seconds, no_result_checked_at, stop_reason` func scanTask(sc interface{ Scan(...any) error }) (*Task, error) { var t Task - if err := sc.Scan(&t.ID, &t.Name, &t.Remark, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.PauseReason, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.PinnedAt, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.ShareGlobalAssets, &t.WorkerCount, &t.FirstRunAt, &t.DeadlineAt, &t.ActiveRunSeconds, &t.RunSegmentStartedAt); err != nil { + if err := sc.Scan(&t.ID, &t.Name, &t.Remark, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.PauseReason, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.PinnedAt, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.ShareGlobalAssets, &t.WorkerCount, &t.FirstRunAt, &t.DeadlineAt, &t.ActiveRunSeconds, &t.RunSegmentStartedAt, &t.NoResultSeconds, &t.NoResultMinFindings, &t.NoResultMinSeverity, &t.NoResultBaseSeconds, &t.NoResultCheckedAt, &t.StopReason); err != nil { return nil, err } t.Pinned = t.PinnedAt != nil @@ -622,6 +636,7 @@ func (d *DB) SetStatusRuntime(id int64, status string) (int64, *time.Time, error err := d.QueryRow(` UPDATE tasks SET status = $1, + stop_reason = CASE WHEN $1 IN ('done','failed') OR ($1 NOT IN ('done','failed','timeout') AND status IN ('done','failed','timeout')) THEN '' ELSE stop_reason END, pause_reason = CASE WHEN $1 IN ('done','failed','timeout') THEN '' ELSE pause_reason END, completed_at = CASE WHEN $1 IN ('done','failed','timeout') THEN COALESCE(completed_at, now()) ELSE NULL END, active_run_seconds = active_run_seconds + CASE WHEN $1 IN ('done','failed','timeout') AND run_segment_started_at IS NOT NULL THEN GREATEST(0, FLOOR(EXTRACT(EPOCH FROM (now() - run_segment_started_at)))::bigint) ELSE 0 END, @@ -646,15 +661,25 @@ func (d *DB) SetTerminalStatusGuarded(id int64, status string) (won bool, err er // SetTerminalStatusGuardedRuntime is the runtime-returning form used by the // Manager so it can mirror the terminal clock before any follow-up hydration. func (d *DB) SetTerminalStatusGuardedRuntime(id int64, status string) (active int64, started *time.Time, won bool, err error) { + return d.SetTerminalStatusGuardedReasonRuntime(id, status, "") +} + +// SetTerminalStatusGuardedReasonRuntime commits the terminal status and its cause together. +func (d *DB) SetTerminalStatusGuardedReasonRuntime(id int64, status, reason string) (active int64, started *time.Time, won bool, err error) { + if status != "timeout" { + reason = "" + } + err = d.QueryRow(` UPDATE tasks SET status = $1, pause_reason = '', + stop_reason = $3, completed_at = COALESCE(completed_at, now()), active_run_seconds = active_run_seconds + CASE WHEN run_segment_started_at IS NOT NULL THEN GREATEST(0, FLOOR(EXTRACT(EPOCH FROM (now() - run_segment_started_at)))::bigint) ELSE 0 END, run_segment_started_at = NULL WHERE id = $2 AND status NOT IN ('done','failed','timeout') - RETURNING active_run_seconds, run_segment_started_at`, status, id).Scan(&active, &started) + RETURNING active_run_seconds, run_segment_started_at`, status, id, reason).Scan(&active, &started) if err == sql.ErrNoRows { // Preserve the authoritative runtime fields for the losing terminal // writer. The guarded transition itself is still a no-op. diff --git a/server/dto.go b/server/dto.go index a3f7b8b..e137659 100644 --- a/server/dto.go +++ b/server/dto.go @@ -71,6 +71,11 @@ type TaskDTO struct { CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定) ShareGlobalAssets bool `json:"share_global_assets"` WorkerCount int `json:"worker_count"` // 本任务 Worker 数;0=用全局默认 + + NoResultSeconds int `json:"no_result_seconds"` + NoResultMinFindings int `json:"no_result_min_findings"` + NoResultMinSeverity string `json:"no_result_min_severity"` + StopReason string `json:"stop_reason"` } type ProxyReconnectDTO struct { @@ -166,6 +171,11 @@ func taskDTO(t *Task, status string) TaskDTO { CoverageEnabled: t.CoverageEnabled, ShareGlobalAssets: lifecycle.ShareGlobalAssets, WorkerCount: t.WorkerCount, + + NoResultSeconds: t.NoResultSeconds, + NoResultMinFindings: t.NoResultMinFindings, + NoResultMinSeverity: t.NoResultMinSeverity, + StopReason: lifecycle.StopReason, } } diff --git a/server/engine_cancelcause_test.go b/server/engine_cancelcause_test.go index a463f6f..0b1f462 100644 --- a/server/engine_cancelcause_test.go +++ b/server/engine_cancelcause_test.go @@ -129,6 +129,7 @@ func TestSettleDrainAndGoalMetCausesAreDistinct(t *testing.T) { code string }{ {agent.AbortSettleDrainTimeout, "settle_drain_timeout"}, + {agent.AbortNoResultStop, "no_result_stop"}, {agent.AbortGoalMet, "goal_met"}, } { e := NewEngine(nil) diff --git a/server/engine_no_result_test.go b/server/engine_no_result_test.go new file mode 100644 index 0000000..ab16b81 --- /dev/null +++ b/server/engine_no_result_test.go @@ -0,0 +1,228 @@ +package server + +import ( + "context" + "errors" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/Autumn-27/artex/agent" + "github.com/Autumn-27/artex/db" +) + +func TestNoResultRemaining(t *testing.T) { + for _, tc := range []struct { + name string + state taskLifecycleState + want time.Duration + }{ + {"running", taskLifecycleState{Status: "running", ActiveRunSeconds: 20, RunSegmentStartedAt: 100}, 10 * time.Second}, + {"paused", taskLifecycleState{Status: "running", ActiveRunSeconds: 20, RunSegmentStartedAt: 100, Paused: true}, 40 * time.Second}, + {"queued", taskLifecycleState{Status: "running", ActiveRunSeconds: 20, RunSegmentStartedAt: 100, Queued: true}, 40 * time.Second}, + {"schedule closed", taskLifecycleState{Status: "paused", ActiveRunSeconds: 20, RunSegmentStartedAt: 100, PauseReason: "paused_by_schedule"}, 40 * time.Second}, + {"revived", taskLifecycleState{Status: "running", ActiveRunSeconds: 200, NoResultBaseSeconds: 200, RunSegmentStartedAt: 100}, 30 * time.Second}, + {"due", taskLifecycleState{Status: "running", ActiveRunSeconds: 60}, 0}, + {"overdue", taskLifecycleState{Status: "running", ActiveRunSeconds: 1000}, 0}, + {"no segment", taskLifecycleState{Status: "running"}, time.Minute}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := noResultRemaining(60, tc.state, 130); got != tc.want { + t.Fatalf("remaining=%s, want %s", got, tc.want) + } + }) + } +} + +func TestNoResultCoordinatorTerminalExitAllowsRevival(t *testing.T) { + for _, status := range []string{"done", "failed", "timeout"} { + t.Run(status, func(t *testing.T) { + task := &Task{ID: "1", Status: status, NoResultSeconds: 60} + m := &Manager{tasks: map[string]*Task{task.ID: task}} + e := NewEngine(m) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + e.startDeadlineCoordinator(ctx, task) + e.runtimeMu.Lock() + rt := e.runtimes[task.ID] + e.runtimeMu.Unlock() + done := make(chan struct{}) + go func() { rt.wg.Wait(); close(done) }() + select { + case <-done: + case <-ctx.Done(): + t.Fatal("terminal coordinator did not exit") + } + if _, exists := e.coordStarted.Load(task.ID); exists { + t.Fatal("terminal exit retained coordinator marker") + } + task.updateLifecycle(func(s *taskLifecycleState) { s.Status = "running" }) + e.startDeadlineCoordinator(ctx, task) + if _, exists := e.coordStarted.Load(task.ID); !exists { + t.Fatal("revival failed to start coordinator") + } + cancel() + rt.wg.Wait() + }) + } +} + +func TestNoResultCheckpointAndRevival(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Fatal(err) + } + defer m.Close() + for _, tc := range []struct { + name, severity string + wantStop bool + }{ + {"no findings", "", true}, {"qualifying", "high", false}, {"below threshold", "low", true}, + } { + t.Run(tc.name, func(t *testing.T) { + task, err := m.CreateTaskWithOptions("checkpoint test", "goal", db.TaskCreateOptions{NoResultSeconds: 60, NoResultMinFindings: 1, NoResultMinSeverity: "high"}) + if err != nil { + t.Fatal(err) + } + defer func() { _, _ = m.DeleteTask(task.ID, DeleteTaskOptions{}) }() + id, _ := strconv.ParseInt(task.ID, 10, 64) + if err := m.SetTaskStatus(task.ID, "running"); err != nil { + t.Fatal(err) + } + if _, err := m.pg.Exec(`UPDATE tasks SET active_run_seconds=60 WHERE id=$1`, id); err != nil { + t.Fatal(err) + } + if err := m.syncTaskRuntime(task.ID, id); err != nil { + t.Fatal(err) + } + if tc.severity != "" { + if _, err := m.pg.AddFinding(id, 0, "test", "test", tc.severity, "", "", "test", nil); err != nil { + t.Fatal(err) + } + } + e := NewEngine(m) + stop, err := e.checkNoResultCheckpoint(task) + if err != nil || stop != tc.wantStop { + t.Fatalf("stop=%v err=%v", stop, err) + } + stored, err := m.pg.GetTask(id) + if err != nil || stored.NoResultCheckedAt == nil { + t.Fatalf("missing durable check: %+v, %v", stored, err) + } + // Reloading a checked task must preserve the one-shot decision. + reloaded := taskFromPG(stored, task.Store, nil) + if again, err := e.checkNoResultCheckpoint(reloaded); err != nil || again { + t.Fatalf("rechecked: %v, %v", again, err) + } + if !stop { + // Losing the qualifying finding later does not start a new check. + if _, err := m.pg.Exec(`DELETE FROM findings WHERE task_id=$1`, id); err != nil { + t.Fatal(err) + } + e.deadlineCoordinator(t.Context(), task) + if m.TaskStatus(task.ID) != "running" { + t.Fatal("passed task stopped") + } + return + } + // A finding arriving during settling cannot undo the decision. + if _, err := m.pg.AddFinding(id, 0, "test", "late", "high", "", "", "test", nil); err != nil { + t.Fatal(err) + } + provider := &scriptedLLMProvider{err: errors.New("test final planner unavailable")} + planner := agent.NewPlanner(provider, "test", t.TempDir(), nil, 0, 1) + e.SetAuthoritativeAgentResolver(func(*Task) (*agent.Planner, *agent.Worker) { return planner, &agent.Worker{} }) + e.settleTask(t.Context(), task, db.StopReasonNoResult) + state := task.lifecycleSnapshot() + if state.Status != "timeout" || state.StopReason != db.StopReasonNoResult || !e.isSettling(task.ID) { + t.Fatalf("settled: %+v", state) + } + dto := taskDTO(task, "timeout") + if dto.StopReason != db.StopReasonNoResult || dto.NoResultSeconds != 60 { + t.Fatalf("DTO: %+v", dto) + } + if err := m.applyTaskAdmission(task.ID, "timeout", "running", false, "resume", false, nil); err != nil { + t.Fatal(err) + } + e.resetTimeoutRevival(task.ID) + state = task.lifecycleSnapshot() + if state.NoResultCheckedAt != 0 || state.StopReason != "" || state.NoResultBaseSeconds != state.ActiveRunSeconds || noResultRemaining(60, state, time.Now().Unix()) != time.Minute || e.isSettling(task.ID) { + t.Fatalf("revived: %+v", state) + } + stored, err = m.pg.GetTask(id) + if err != nil || stored.NoResultCheckedAt != nil || stored.StopReason != "" || stored.NoResultBaseSeconds != 60 { + t.Fatalf("revival not durable: %+v, %v", stored, err) + } + }) + } +} + +func TestNoResultCoordinatorStopsAndReleasesMarker(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Fatal(err) + } + defer m.Close() + task, err := m.CreateTaskWithOptions("coordinator stop", "goal", db.TaskCreateOptions{NoResultSeconds: 60}) + if err != nil { + t.Fatal(err) + } + defer func() { _, _ = m.DeleteTask(task.ID, DeleteTaskOptions{}) }() + id, _ := strconv.ParseInt(task.ID, 10, 64) + if _, err := m.pg.Exec(`UPDATE tasks SET active_run_seconds=60 WHERE id=$1`, id); err != nil { + t.Fatal(err) + } + if err := m.syncTaskRuntime(task.ID, id); err != nil { + t.Fatal(err) + } + e := NewEngine(m) + // Commit a failed checkpoint, then model a process restart before settling. + if stop, err := e.checkNoResultCheckpoint(task); err != nil || !stop { + t.Fatalf("initial decision: %v, %v", stop, err) + } + if _, err := m.pg.AddFinding(id, 0, "test", "late after decision", "high", "", "", "test", nil); err != nil { + t.Fatal(err) + } + stored, err := m.pg.GetTask(id) + if err != nil { + t.Fatal(err) + } + task = taskFromPG(stored, task.Store, nil) + m.mu.Lock() + m.tasks[task.ID] = task + m.mu.Unlock() + e = NewEngine(m) + provider := &scriptedLLMProvider{err: errors.New("test final planner unavailable")} + planner := agent.NewPlanner(provider, "test", t.TempDir(), nil, 0, 1) + e.SetAuthoritativeAgentResolver(func(*Task) (*agent.Planner, *agent.Worker) { return planner, &agent.Worker{} }) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + e.startDeadlineCoordinator(ctx, task) + e.runtimeMu.Lock() + rt := e.runtimes[task.ID] + e.runtimeMu.Unlock() + done := make(chan struct{}) + go func() { rt.wg.Wait(); close(done) }() + select { + case <-done: + case <-ctx.Done(): + t.Fatal("coordinator did not finish") + } + if m.TaskStatus(task.ID) != "timeout" || task.lifecycleSnapshot().StopReason != db.StopReasonNoResult { + t.Fatal("coordinator did not stop task") + } + if _, exists := e.coordStarted.Load(task.ID); exists { + t.Fatal("coordinator marker survived exit") + } +} + +func TestCreateTaskRejectsInvalidNoResultSeverity(t *testing.T) { + s := &Server{} + w := httptest.NewRecorder() + s.createTask(w, httptest.NewRequest("POST", "/api/tasks", strings.NewReader(`{"description":"test","goal":"test","no_result_min_severity":"urgent"}`))) + if w.Code != 400 { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } +} diff --git a/server/engine_timeout.go b/server/engine_timeout.go index 8d57293..d409337 100644 --- a/server/engine_timeout.go +++ b/server/engine_timeout.go @@ -125,11 +125,16 @@ func (e *Engine) clockCtx(base context.Context, t *Task, final bool) context.Con // ---------- 协调器 ---------- +func needsNoResultCoordinator(t *Task) bool { + state := t.lifecycleSnapshot() + return t.NoResultSeconds > 0 && (state.NoResultCheckedAt == 0 || state.StopReason == db.StopReasonNoResult) +} + // startDeadlineCoordinator launches the per-task deadline timer once (idempotent). // Called from Run() and from the restart reload path, so non-active timeout tasks // still get settled after their deadline even without live planner/worker loops. func (e *Engine) startDeadlineCoordinator(ctx context.Context, t *Task) { - if t == nil || t.TimeoutSeconds <= 0 { + if t == nil || (t.TimeoutSeconds <= 0 && !needsNoResultCoordinator(t)) { return } e.deleteMu.RLock() @@ -137,55 +142,123 @@ func (e *Engine) startDeadlineCoordinator(ctx context.Context, t *Task) { e.deleteMu.RUnlock() return } - if _, loaded := e.coordStarted.LoadOrStore(t.ID, true); loaded { + token := new(int) + if _, loaded := e.coordStarted.LoadOrStore(t.ID, token); loaded { e.deleteMu.RUnlock() return } rt := e.registerTaskRoutines(ctx, t.ID, 1) e.deleteMu.RUnlock() - runTaskRoutine(rt, func(loopCtx context.Context) { e.deadlineCoordinator(loopCtx, t) }) + runTaskRoutine(rt, func(loopCtx context.Context) { + defer func() { + // A terminal task may be revived while this routine is returning. Only + // remove our own marker, then ensure that revival has a coordinator. + if e.coordStarted.CompareAndDelete(t.ID, token) && loopCtx.Err() == nil && !e.isSettling(t.ID) && !isTerminalStatus(e.m.TaskStatus(t.ID)) { + e.startDeadlineCoordinator(ctx, t) + } + }() + e.deadlineCoordinator(loopCtx, t) + }) } -// deadlineCoordinator waits until the task's absolute deadline, then runs the settle -// sequence. Absolute wall-clock: it keeps counting through pauses. +// deadlineCoordinator watches both the absolute deadline and the one-shot active +// runtime checkpoint. Only the absolute deadline keeps counting through pauses. func (e *Engine) deadlineCoordinator(ctx context.Context, t *Task) { for { - select { - case <-ctx.Done(): + if ctx.Err() != nil || isTerminalStatus(e.m.TaskStatus(t.ID)) { return - default: } - if isTerminalStatus(e.m.TaskStatus(t.ID)) { - return // already finished (goals met / failed) — nothing to time out + state := t.lifecycleSnapshot() + if state.NoResultCheckedAt > 0 && state.StopReason == db.StopReasonNoResult { + // Recover a committed failed checkpoint interrupted during settling. + e.settleTask(ctx, t, db.StopReasonNoResult) + return } - dl := e.taskDeadline(t) - if dl <= 0 { - if sleepCtx(ctx, deadlinePollInterval) { // not yet stamped (task hasn't really run) + nap := deadlineMaxSleep + if dl := e.taskDeadline(t); dl > 0 { + remaining := time.Until(time.Unix(dl, 0)) + if remaining <= 0 { + e.settleTask(ctx, t, "") return } - continue + nap = min(nap, remaining) + } else if t.TimeoutSeconds > 0 { + nap = deadlinePollInterval } - if remaining := time.Until(time.Unix(dl, 0)); remaining > 0 { - nap := remaining - if nap > deadlineMaxSleep { - nap = deadlineMaxSleep + if t.NoResultSeconds > 0 && state.NoResultCheckedAt == 0 { + remaining := noResultRemaining(t.NoResultSeconds, state, time.Now().Unix()) + if remaining <= 0 { + stop, err := e.checkNoResultCheckpoint(t) + if err != nil { + log.Printf("[no-result] task %s 检查失败: %v", t.ID, err) + nap = deadlinePollInterval // DB errors never count as zero findings. + } else if stop { + e.settleTask(ctx, t, db.StopReasonNoResult) + return + } + } else { + nap = min(nap, remaining) } - if sleepCtx(ctx, nap) { - return - } - continue } - e.settleTask(ctx, t) - return + if t.TimeoutSeconds <= 0 && (t.NoResultSeconds <= 0 || t.lifecycleSnapshot().NoResultCheckedAt > 0) { + return + } + if sleepCtx(ctx, nap) { + return + } } } +// checkNoResultCheckpoint serializes the decision with terminal transitions and +// revival. Once marked, later findings cannot reverse the decision. +func (e *Engine) checkNoResultCheckpoint(t *Task) (bool, error) { + e.m.taskStateMu.Lock() + defer e.m.taskStateMu.Unlock() + state := t.lifecycleSnapshot() + if state.NoResultCheckedAt > 0 || isTerminalStatus(state.Status) || noResultRemaining(t.NoResultSeconds, state, time.Now().Unix()) > 0 { + return false, nil + } + id, err := strconv.ParseInt(t.ID, 10, 64) + if err != nil { + return false, err + } + count, err := e.m.pg.CountQualifyingFindings(id, t.NoResultMinSeverity) + if err != nil { + return false, err + } + minimum := max(1, t.NoResultMinFindings) + stop := count < minimum + checked, err := e.m.pg.MarkNoResultChecked(id, stop) + if err != nil || checked == nil { + return false, err + } + t.updateLifecycle(func(s *taskLifecycleState) { + s.NoResultCheckedAt = checked.Unix() + if stop { + s.StopReason = db.StopReasonNoResult + } + }) + severity := t.NoResultMinSeverity + if severity == "" { + severity = "不限" + } else { + severity += "及以上" + } + summary := fmt.Sprintf("无成果检查通过:达标漏洞 %d/%d(等级%s),任务继续运行,之后不再检查", count, minimum, severity) + if stop { + runtime := taskRunSeconds(state.ActiveRunSeconds, state.RunSegmentStartedAt, checked.Unix(), state.Status, state.Paused, state.Queued) - state.NoResultBaseSeconds + summary = fmt.Sprintf("无成果止损:已运行 %s,达标漏洞 %d/%d(等级%s),任务自动停止", time.Duration(runtime)*time.Second, count, minimum, severity) + } + e.emitActivity(t, db.Activity{Worker: "system", Kind: "text", Summary: summary}) + return stop, nil +} + // settleTask runs the ordered settle sequence once (§4 steps ①–⑥). -func (e *Engine) settleTask(ctx context.Context, t *Task) { +func (e *Engine) settleTask(ctx context.Context, t *Task, reason string) { if !e.markSettling(t.ID) { return } - log.Printf("[deadline] task %s 到达超时上限,进入收尾时序", t.ID) + log.Printf("[deadline] task %s 进入收尾时序,原因=%s", t.ID, reason) // ④ 等在跑 worker/planner drain(在跑 run 因夹逼的 MaxDuration 自行进收尾); // 超过 grace 仍未清空 → 硬 cancel 该任务 exec ctx(settling-aware 分支正确归类)。 @@ -193,7 +266,11 @@ func (e *Engine) settleTask(ctx context.Context, t *Task) { for e.inflightCount(t.ID) > 0 { if time.Now().After(hardStop) { log.Printf("[deadline] task %s drain 超时(%s),硬取消在跑 run", t.ID, settleDrainGrace) - e.cancelExec(t.ID, agent.AbortSettleDrainTimeout) + cause := agent.AbortSettleDrainTimeout + if reason == db.StopReasonNoResult { + cause = agent.AbortNoResultStop + } + e.cancelExec(t.ID, cause) _ = sleepCtx(ctx, 3*time.Second) // 给 worker 分支一点时间落库/归类 break } @@ -203,7 +280,7 @@ func (e *Engine) settleTask(ctx context.Context, t *Task) { } // ⑤ 终局一轮 planner(任务超时词,最后目标判定,不产新意图)。 - met := e.runFinalPlannerRound(ctx, t) + met := e.runFinalPlannerRound(ctx, t, reason) if !e.beginTaskOperation(t.ID) { return } @@ -215,7 +292,7 @@ func (e *Engine) settleTask(ctx context.Context, t *Task) { if met { status = "done" } - won, err := e.m.SetTaskStatusGuarded(t.ID, status) + won, err := e.m.SetTaskStatusGuardedReason(t.ID, status, reason) switch { case err != nil: log.Printf("[deadline] task %s 落终态失败: %v", t.ID, err) @@ -229,7 +306,7 @@ func (e *Engine) settleTask(ctx context.Context, t *Task) { // runFinalPlannerRound drives exactly ONE terminal planner round with the // task-timeout planner words (final goal judgment; no new intents). Waits for the // LLM to be ready (bounded by ctx) so a completable task isn't mis-judged timeout. -func (e *Engine) runFinalPlannerRound(ctx context.Context, t *Task) (met bool) { +func (e *Engine) runFinalPlannerRound(ctx context.Context, t *Task, reason string) (met bool) { if e.IsDeleting(t.ID) { return false } @@ -253,16 +330,20 @@ func (e *Engine) runFinalPlannerRound(ctx context.Context, t *Task) (met bool) { } defer e.decInflight(t.ID) emit := func(r db.Activity) { e.emitActivity(t, r) } + label := "任务超时收尾" + if reason == db.StopReasonNoResult { + label = "无成果止损收尾" + } e.emitActivity(t, db.Activity{Worker: "planner", Kind: "round", - Summary: fmt.Sprintf("任务超时收尾·终局判定(第 %d 轮)", e.nextPlannerRound(t.ID))}) + Summary: fmt.Sprintf("%s·终局判定(第 %d 轮)", label, e.nextPlannerRound(t.ID))}) tTaskID, _ := strconv.ParseInt(t.ID, 10, 64) e.BeginLLMCall(t.ID) - met, reason, err := planner.Plan(fctx, tTaskID, e.m.assets, t.Store, t.Goal, t.drainTriggers(), emit) + met, decision, err := planner.Plan(fctx, tTaskID, e.m.assets, t.Store, t.Goal, t.drainTriggers(), emit) e.EndLLMCall(t.ID) if err != nil { log.Printf("[deadline] task %s 终局规划出错: %v", t.ID, err) } else if met { - log.Printf("[deadline] task %s 终局判定目标达成: %s", t.ID, reason) + log.Printf("[deadline] task %s 终局判定目标达成: %s", t.ID, decision) } return met } diff --git a/server/goals.go b/server/goals.go index 6791744..e159377 100644 --- a/server/goals.go +++ b/server/goals.go @@ -249,6 +249,10 @@ func (s *Server) admitTaskWhenScheduledWithGenerationChecked(t *Task, mode strin // matching Engine gates before either parking it in FIFO or starting work; // otherwise the old settling flag would make every worker skip forever. s.engine.resetTimeoutRevival(t.ID) + } else if wasTerminal { + // A final settle round may finish as done. Revival must release its + // worker/planner barrier too, while preserving its deadline/checkpoint. + s.engine.settling.Delete(t.ID) } if shouldQueue { if !wasQueued { diff --git a/server/manager.go b/server/manager.go index 3374ed9..c7b1ea8 100644 --- a/server/manager.go +++ b/server/manager.go @@ -64,7 +64,13 @@ type Task struct { SourceTaskIDs []int64 `json:"source_task_ids,omitempty"` CompanyIDs []int64 `json:"company_ids,omitempty"` Status string `json:"status"` // persisted lifecycle status (done/failed/timeout 为终态;空/其它则由运行态推导) - // 任务级超时(见 docs/任务级超时与收尾设计.md)。DeadlineAt/FirstRunAt 为 unix 秒,0=未设/未运行。 + // No-result options are immutable. Mutable checkpoint fields use lifecycleMu. + NoResultSeconds int `json:"no_result_seconds"` + NoResultMinFindings int `json:"no_result_min_findings"` + NoResultMinSeverity string `json:"no_result_min_severity"` + NoResultBaseSeconds int64 `json:"no_result_base_seconds"` + NoResultCheckedAt int64 `json:"no_result_checked_at,omitempty"` + StopReason string `json:"stop_reason"` TimeoutSeconds int `json:"timeout_seconds"` PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒) CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定,默认开) @@ -111,6 +117,9 @@ type taskLifecycleState struct { CompletedAt int64 FirstRunAt int64 DeadlineAt int64 + NoResultBaseSeconds int64 + NoResultCheckedAt int64 + StopReason string ActiveRunSeconds int64 RunSegmentStartedAt int64 SourceTaskIDs []int64 @@ -144,6 +153,7 @@ func (t *Task) lifecycleSnapshotLocked() taskLifecycleState { CompletedAt: t.CompletedAt, FirstRunAt: t.FirstRunAt, DeadlineAt: t.DeadlineAt, + NoResultBaseSeconds: t.NoResultBaseSeconds, NoResultCheckedAt: t.NoResultCheckedAt, StopReason: t.StopReason, ActiveRunSeconds: t.ActiveRunSeconds, RunSegmentStartedAt: t.RunSegmentStartedAt, SourceTaskIDs: append([]int64(nil), t.SourceTaskIDs...), @@ -179,6 +189,9 @@ func (t *Task) updateLifecycle(update func(*taskLifecycleState)) { t.CompletedAt = state.CompletedAt t.FirstRunAt = state.FirstRunAt t.DeadlineAt = state.DeadlineAt + t.NoResultBaseSeconds = state.NoResultBaseSeconds + t.NoResultCheckedAt = state.NoResultCheckedAt + t.StopReason = state.StopReason t.ActiveRunSeconds = state.ActiveRunSeconds t.RunSegmentStartedAt = state.RunSegmentStartedAt t.SourceTaskIDs = append(t.SourceTaskIDs[:0], state.SourceTaskIDs...) @@ -1154,8 +1167,10 @@ func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Inter LLMProfileIDs: append([]int64(nil), pt.LLMProfileIDs...), ActiveLLMProfileID: pt.ActiveLLMProfileID, LLMChainRevision: pt.LLMChainRevision, LLMFailoverState: pt.LLMFailoverState, LLMFailoverReason: pt.LLMFailoverReason, - SourceTaskIDs: append([]int64(nil), pt.SourceTaskIDs...), - CompanyIDs: append([]int64(nil), pt.CompanyIDs...), + SourceTaskIDs: append([]int64(nil), pt.SourceTaskIDs...), + CompanyIDs: append([]int64(nil), pt.CompanyIDs...), + NoResultSeconds: pt.NoResultSeconds, NoResultMinFindings: pt.NoResultMinFindings, NoResultMinSeverity: pt.NoResultMinSeverity, + NoResultBaseSeconds: pt.NoResultBaseSeconds, NoResultCheckedAt: unixOrZero(pt.NoResultCheckedAt), StopReason: pt.StopReason, TimeoutSeconds: pt.TimeoutSeconds, PlanHeartbeatSeconds: pt.PlanHeartbeatSeconds, CoverageEnabled: pt.CoverageEnabled, ShareGlobalAssets: pt.ShareGlobalAssets, WorkerCount: pt.WorkerCount, FirstRunAt: unixOrZero(pt.FirstRunAt), DeadlineAt: unixOrZero(pt.DeadlineAt), @@ -1490,7 +1505,9 @@ func (m *Manager) applyTaskAdmissionWithSchedule(id, expectedStatus, status stri mode = "" } - var queuedAt, completedAt, firstRunAt, deadlineAt, segmentStartedAt sql.NullTime + var queuedAt, completedAt, firstRunAt, deadlineAt, segmentStartedAt, noResultCheckedAt sql.NullTime + var noResultBaseSeconds int64 + var stopReason string var activeRunSeconds int64 var committedMode string err = m.pg.QueryRow(`UPDATE tasks @@ -1513,6 +1530,15 @@ func (m *Manager) applyTaskAdmissionWithSchedule(id, expectedStatus, status stri WHEN ($5 AND queued AND queue_mode='bootstrap') OR $4='bootstrap' THEN 'bootstrap' ELSE 'resume' END, + no_result_base_seconds=CASE + WHEN $6='timeout' AND stop_reason='no_result' AND $2 NOT IN ('done','failed','timeout') THEN active_run_seconds + ELSE no_result_base_seconds + END, + no_result_checked_at=CASE + WHEN $6='timeout' AND stop_reason='no_result' AND $2 NOT IN ('done','failed','timeout') THEN NULL + ELSE no_result_checked_at + END, + stop_reason=CASE WHEN $6 IN ('done','failed','timeout') AND $2 NOT IN ('done','failed','timeout') THEN '' ELSE stop_reason END, first_run_at=CASE WHEN $6='timeout' AND $2 NOT IN ('done','failed','timeout') THEN NULL ELSE first_run_at @@ -1524,8 +1550,8 @@ func (m *Manager) applyTaskAdmissionWithSchedule(id, expectedStatus, status stri ,active_run_seconds=active_run_seconds + CASE WHEN ($3 OR $2 IN ('done','failed','timeout')) AND run_segment_started_at IS NOT NULL THEN GREATEST(0, FLOOR(EXTRACT(EPOCH FROM (now() - run_segment_started_at)))::bigint) ELSE 0 END ,run_segment_started_at=CASE WHEN $3 OR $2 IN ('done','failed','timeout') THEN NULL ELSE run_segment_started_at END WHERE id=$1 AND deleted_at IS NULL AND status=$6 - RETURNING queued_at, queue_mode, completed_at, first_run_at, deadline_at, active_run_seconds, run_segment_started_at`, n, status, queued, mode, preservePosition, expectedStatus, manualUntil, preserveSchedule). - Scan(&queuedAt, &committedMode, &completedAt, &firstRunAt, &deadlineAt, &activeRunSeconds, &segmentStartedAt) + RETURNING queued_at, queue_mode, completed_at, first_run_at, deadline_at, active_run_seconds, run_segment_started_at, no_result_base_seconds, no_result_checked_at, stop_reason`, n, status, queued, mode, preservePosition, expectedStatus, manualUntil, preserveSchedule). + Scan(&queuedAt, &committedMode, &completedAt, &firstRunAt, &deadlineAt, &activeRunSeconds, &segmentStartedAt, &noResultBaseSeconds, &noResultCheckedAt, &stopReason) if errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("task %s lifecycle changed before admission (expected status %q)", id, expectedStatus) } @@ -1557,6 +1583,12 @@ func (m *Manager) applyTaskAdmissionWithSchedule(id, expectedStatus, status stri if deadlineAt.Valid { state.DeadlineAt = deadlineAt.Time.Unix() } + state.NoResultBaseSeconds = noResultBaseSeconds + state.NoResultCheckedAt = 0 + if noResultCheckedAt.Valid { + state.NoResultCheckedAt = noResultCheckedAt.Time.Unix() + } + state.StopReason = stopReason state.ActiveRunSeconds = activeRunSeconds state.RunSegmentStartedAt = 0 if segmentStartedAt.Valid { @@ -1846,6 +1878,9 @@ func (m *Manager) syncTaskRuntime(id string, n int64) error { m.mu.Lock() if t := m.tasks[id]; t != nil { t.updateLifecycle(func(state *taskLifecycleState) { + state.NoResultBaseSeconds = pt.NoResultBaseSeconds + state.NoResultCheckedAt = unixOrZero(pt.NoResultCheckedAt) + state.StopReason = pt.StopReason state.ActiveRunSeconds = pt.ActiveRunSeconds state.RunSegmentStartedAt = unixOrZero(pt.RunSegmentStartedAt) }) @@ -1930,13 +1965,21 @@ func (m *Manager) StampTaskFirstRun(id string) (int64, error) { // (resolves the completed↔timeout race — first terminal writer wins). Reflects the // won status on the live handle. won=false means another terminal already stuck. func (m *Manager) SetTaskStatusGuarded(id, status string) (won bool, err error) { + return m.SetTaskStatusGuardedReason(id, status, "") +} + +func (m *Manager) SetTaskStatusGuardedReason(id, status, reason string) (won bool, err error) { + if status != "timeout" { + reason = "" + } + m.taskStateMu.Lock() defer m.taskStateMu.Unlock() n, err := strconv.ParseInt(id, 10, 64) if err != nil { return false, err } - active, started, won, err := m.pg.SetTerminalStatusGuardedRuntime(n, status) + active, started, won, err := m.pg.SetTerminalStatusGuardedReasonRuntime(n, status, reason) if err != nil || !won { return won, err } @@ -1946,6 +1989,7 @@ func (m *Manager) SetTaskStatusGuarded(id, status string) (won bool, err error) if t := m.tasks[id]; t != nil { t.updateLifecycle(func(state *taskLifecycleState) { state.Status = status + state.StopReason = reason state.ActiveRunSeconds = active state.RunSegmentStartedAt = unixOrZero(started) if pgdb.IsTerminal(status) { @@ -1985,6 +2029,9 @@ func (m *Manager) SetTaskStatus(id, status string) error { m.mu.Lock() if t := m.tasks[id]; t != nil { t.updateLifecycle(func(state *taskLifecycleState) { + if !pgdb.IsTerminal(status) && pgdb.IsTerminal(state.Status) { + state.StopReason = "" + } state.Status = status state.ActiveRunSeconds = active state.RunSegmentStartedAt = unixOrZero(started) diff --git a/server/orchestration.go b/server/orchestration.go index 7e7fd60..7ca905f 100644 --- a/server/orchestration.go +++ b/server/orchestration.go @@ -239,6 +239,9 @@ func (s *Server) toolSpawnTask() actool.CoreTool { "parent_ref": strParam("可选:父任务 id(做父子关联)"), "source_task_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": fmt.Sprintf("可选:只读继承的来源任务 id 列表(最多 %d 个)。子任务可只读引用这些任务已探明的资产/结论作为起点;与 parent_ref 的纯父子指针不同,这是内容继承。", db.MaxTaskSourceCount)}, "llm_profile_id": map[string]any{"type": "integer", "description": "可选:指定本子任务 planner/worker 用的 LLM 配置 id(见 list_llm_profiles);留空则继承父任务、再回退全局激活配置"}, + "no_result_seconds": map[string]any{"type": "integer", "description": "可选:累计实际运行这些秒后仅检查一次,不足达标漏洞数时停止。0=关闭,正数最低60秒;暂停和排队不计时"}, + "no_result_min_findings": map[string]any{"type": "integer", "description": "无成果止损最少独立漏洞数,默认1;误报不计"}, + "no_result_min_severity": map[string]any{"type": "string", "enum": []string{"", "low", "medium", "high", "critical"}, "description": "计数的最低漏洞等级(含以上),空=不限"}, "timeout_seconds": map[string]any{"type": "integer", "description": "可选:任务级超时(秒)。到点后触发优雅收尾并进入 timeout 终态;留空或 0 = 不限时"}, "plan_heartbeat_seconds": map[string]any{"type": "integer", "description": "可选:planner 心跳触发间隔(秒)。距上轮规划结束/任务开始满该值且期间无触发 → 触发一轮规划(兜底死锁 + 唤醒去监督飞行中的 worker)。留空或 0 = 默认 600(10min);"}, "seed_first_intent": map[string]any{"type": "boolean", "description": "可选:对于简单任务可开启,创建时直接下发一条种子意图(内容=描述+目标)让 worker 免等首轮 planner 直接开跑测试;默认 false(走标准先规划再执行)。"}, @@ -250,6 +253,9 @@ func (s *Server) toolSpawnTask() actool.CoreTool { ParentRef string `json:"parent_ref"` SourceTaskIDs []string `json:"source_task_ids"` LLMProfileID json.RawMessage `json:"llm_profile_id"` + NoResultSeconds int `json:"no_result_seconds"` + NoResultMinFindings int `json:"no_result_min_findings"` + NoResultMinSeverity string `json:"no_result_min_severity"` TimeoutSeconds int `json:"timeout_seconds"` PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` SeedFirstIntent bool `json:"seed_first_intent"` @@ -330,9 +336,10 @@ func (s *Server) toolSpawnTask() actool.CoreTool { } } t, err := s.m.CreateTaskWithOptions(a.Description, a.Goal, db.TaskCreateOptions{ - Name: name, - SourceTaskIDs: sourceIDs, - LLMProfileIDs: llmIDs, + Name: name, + SourceTaskIDs: sourceIDs, + LLMProfileIDs: llmIDs, + NoResultSeconds: a.NoResultSeconds, NoResultMinFindings: a.NoResultMinFindings, NoResultMinSeverity: a.NoResultMinSeverity, TimeoutSeconds: a.TimeoutSeconds, PlanHeartbeatSeconds: a.PlanHeartbeatSeconds, }) diff --git a/server/server.go b/server/server.go index 206f082..ceb4830 100644 --- a/server/server.go +++ b/server/server.go @@ -1703,10 +1703,13 @@ type createTaskReq struct { CategoryID *int64 `json:"category_id,omitempty"` Description string `json:"description"` Goal string `json:"goal"` - LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // 指定运行本任务的 LLM 配置;省略/null=用激活配置 - LLMProfileIDs []int64 `json:"llm_profile_ids,omitempty"` // 有序任务级配置链;第一项初始生效 - SourceTaskIDs []string `json:"source_task_ids,omitempty"` // 仅直接、只读继承的来源任务 - CompanyIDs []int64 `json:"company_ids,omitempty"` // 关联企业范围并快照关联当前企业资产;不复制资产或强制生成意图 + LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // 指定运行本任务的 LLM 配置;省略/null=用激活配置 + LLMProfileIDs []int64 `json:"llm_profile_ids,omitempty"` // 有序任务级配置链;第一项初始生效 + SourceTaskIDs []string `json:"source_task_ids,omitempty"` // 仅直接、只读继承的来源任务 + CompanyIDs []int64 `json:"company_ids,omitempty"` // 关联企业范围并快照关联当前企业资产;不复制资产或强制生成意图 + NoResultSeconds int `json:"no_result_seconds"` + NoResultMinFindings int `json:"no_result_min_findings"` + NoResultMinSeverity string `json:"no_result_min_severity"` TimeoutSeconds int `json:"timeout_seconds"` // 任务级超时(秒);0/省略=不限时 PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒);0/省略=默认600(10min);下限=默认=600,低于自动抬到600 SeedFirstIntent *bool `json:"seed_first_intent,omitempty"` // 创建时直接下发一条种子意图(内容=描述+目标),让 worker 免等首轮 planner 直接开跑;省略/null=默认关闭,走标准先规划再执行。显式传 true 才开(CTF 常一 work 解决时可省掉开跑前的 planner 轮)。 @@ -1734,6 +1737,10 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) { writeErr(w, 400, err.Error()) return } + if err := db.NormalizeNoResultOptions(&db.TaskCreateOptions{NoResultSeconds: req.NoResultSeconds, NoResultMinFindings: req.NoResultMinFindings, NoResultMinSeverity: req.NoResultMinSeverity}); err != nil { + writeErr(w, 400, err.Error()) + return + } if req.TimeoutSeconds < 0 { req.TimeoutSeconds = 0 } @@ -1794,6 +1801,7 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) { Schedule: schedule, Name: strings.TrimSpace(req.Name), CategoryID: req.CategoryID, SourceTaskIDs: sourceIDs, CompanyIDs: req.CompanyIDs, LLMProfileIDs: req.LLMProfileIDs, + NoResultSeconds: req.NoResultSeconds, NoResultMinFindings: req.NoResultMinFindings, NoResultMinSeverity: req.NoResultMinSeverity, TimeoutSeconds: req.TimeoutSeconds, PlanHeartbeatSeconds: req.PlanHeartbeatSeconds, CoverageEnabled: req.CoverageEnabled, ShareGlobalAssets: req.ShareGlobalAssets, diff --git a/server/task_runtime.go b/server/task_runtime.go index 4137d84..451360f 100644 --- a/server/task_runtime.go +++ b/server/task_runtime.go @@ -1,5 +1,7 @@ package server +import "time" + // taskRunSeconds computes displayed active runtime from persisted segment data. // Segment timestamps are unix seconds; a zero start means no active segment. func taskRunSeconds(active, segmentStarted, now int64, status string, paused, queued bool) int64 { @@ -23,3 +25,12 @@ func finalizeTaskRunSeconds(active, segmentStarted, now int64) (int64, int64) { } return active, 0 } + +// noResultRemaining excludes pauses, queue time and closed schedule windows by +// using the same active execution segments as the task runtime display. +func noResultRemaining(seconds int, state taskLifecycleState, now int64) time.Duration { + runtime := taskRunSeconds(state.ActiveRunSeconds, state.RunSegmentStartedAt, now, state.Status, state.Paused, state.Queued) + elapsed := max(int64(0), runtime-state.NoResultBaseSeconds) + remaining := max(int64(0), int64(seconds)-elapsed) + return time.Duration(remaining) * time.Second +} diff --git a/web/src/app/(main)/function/tasks/detail/page.tsx b/web/src/app/(main)/function/tasks/detail/page.tsx index 674a775..5236352 100644 --- a/web/src/app/(main)/function/tasks/detail/page.tsx +++ b/web/src/app/(main)/function/tasks/detail/page.tsx @@ -395,7 +395,12 @@ function TaskDetailInner() { - + {canArchive ? ( {archiveTrigger} diff --git a/web/src/app/(main)/function/tasks/page.tsx b/web/src/app/(main)/function/tasks/page.tsx index 05624d1..9153021 100644 --- a/web/src/app/(main)/function/tasks/page.tsx +++ b/web/src/app/(main)/function/tasks/page.tsx @@ -1489,7 +1489,7 @@ const TaskRow = React.memo(function TaskRow({ {task.goal} - + {task?.goal &&

{task.goal}

} - {task && } + {task && } ); @@ -3199,7 +3199,7 @@ function DraggableCategoryTask({ task, disabled, moving }: { task: Task; disable - + ); @@ -3597,6 +3597,9 @@ function CreateTaskSheet({ const [llmProfileIDs, setLLMProfileIDs] = React.useState([]); const [creating, setCreating] = React.useState(false); const [timeoutMin, setTimeoutMin] = React.useState(""); // 任务级超时(分钟);空/0 = 不限时 + const [noResultMin, setNoResultMin] = React.useState(""); + const [noResultCount, setNoResultCount] = React.useState("1"); + const [noResultSeverity, setNoResultSeverity] = React.useState>(""); const [heartbeatMin, setHeartbeatMin] = React.useState("10"); // planner 心跳(分钟);默认10,下限10(与后端一致) const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行 const [shareGlobalAssets, setShareGlobalAssets] = React.useState(false); @@ -3684,6 +3687,9 @@ function CreateTaskSheet({ sourceTaskIds: sourceTaskIDs, companyIds: companyIDs, timeoutSeconds: timeoutSec, + noResultSeconds: Math.max(0, Math.floor((Number(noResultMin) || 0) * 60)), + noResultMinFindings: Math.max(1, Math.floor(Number(noResultCount) || 1)), + noResultMinSeverity: noResultSeverity, seedFirstIntent, planHeartbeatSeconds: heartbeatSec, coverageEnabled, @@ -3707,6 +3713,9 @@ function CreateTaskSheet({ setCompanyIDs([]); setLLMProfileIDs([]); setTimeoutMin(""); + setNoResultMin(""); + setNoResultCount("1"); + setNoResultSeverity(""); setHeartbeatMin("10"); setSeedFirstIntent(false); setCoverageEnabled(true); @@ -4055,6 +4064,54 @@ function CreateTaskSheet({ />

{translate("taskUI.timeoutHelp")}

+
+ + setNoResultMin(e.target.value)} + /> +

{translate("taskUI.noResultHelp")}

+
+ {Number(noResultMin) > 0 && ( +
+
+ + setNoResultCount(e.target.value)} + /> +
+
+ + +
+
+ )}
{dot && } - {meta.label} + {domain === "task" && value === "timeout" && stopReason === "no_result" + ? t("taskUI.noResultStopped") + : meta.label} ); } diff --git a/web/src/i18n/task-messages.ts b/web/src/i18n/task-messages.ts index ee8c641..e910bb3 100644 --- a/web/src/i18n/task-messages.ts +++ b/web/src/i18n/task-messages.ts @@ -346,13 +346,29 @@ export const taskMessages = { "Fail over in list order. The first configuration is current; switch to the next only when quota is confirmed insufficient.", ], "taskUI.advanced": ["高级设置", "Advanced settings"], - "taskUI.advancedSummary": ["超时 · 心跳 · Worker · 首个意图", "Timeout · Heartbeat · Workers · First intent"], + "taskUI.advancedSummary": [ + "超时 · 无成果止损 · 心跳 · Worker · 首个意图", + "Timeout · No-result stop · Heartbeat · Workers · First intent", + ], "taskUI.timeoutOptional": ["任务超时(分钟,可选)", "Task timeout (minutes, optional)"], "taskUI.noTimeout": ["留空 = 不限时", "Leave empty for no timeout"], "taskUI.timeoutHelp": [ "到点后触发优雅收尾(各 agent 写回 + planner 终局判定),任务进入 timeout 终态。", "At the timeout, Agents write back and the Planner makes a final decision before the task ends with timeout status.", ], + "taskUI.noResultStopped": ["无成果停止", "Stopped: no results"], + "taskUI.noResultOptional": ["无成果止损(分钟,可选)", "No-result stop (minutes, optional)"], + "taskUI.noResultCount": ["最少漏洞数", "Minimum findings"], + "taskUI.noResultSeverity": ["最低等级", "Minimum severity"], + "taskUI.noResultAnySeverity": ["不限等级", "Any severity"], + "taskUI.noResultLow": ["低危及以上", "Low or above"], + "taskUI.noResultMedium": ["中危及以上", "Medium or above"], + "taskUI.noResultHigh": ["高危及以上", "High or above"], + "taskUI.noResultCritical": ["严重", "Critical"], + "taskUI.noResultHelp": [ + "留空或 0 = 关闭。累计实际运行到此时长后检查一次;达标独立漏洞不足则收尾停止,达标后继续运行且不再检查。暂停、排队和计划窗口外不计时;误报不计,归并漏洞算一个。最短 1 分钟,可与任务超时同时设置。", + "Empty or 0 disables this option. Check once after this much active runtime: stop if too few independent findings qualify; otherwise continue without further checks. Pauses, queue time and closed schedule windows do not count. Exclude false positives and count merged findings once. Minimum: 1 minute. Can be combined with the task timeout.", + ], "taskUI.heartbeat": ["planner 心跳(分钟)", "Planner heartbeat (minutes)"], "taskUI.defaultTen": ["默认 10", "Default: 10"], "taskUI.heartbeatHelp": [ diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index c47bde9..8e75e44 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -388,6 +388,9 @@ export const api = { sourceTaskIds?: string[]; companyIds?: number[]; timeoutSeconds?: number; + noResultSeconds?: number; + noResultMinFindings?: number; + noResultMinSeverity?: "" | "low" | "medium" | "high" | "critical"; seedFirstIntent?: boolean; planHeartbeatSeconds?: number; shareGlobalAssets?: boolean; @@ -406,6 +409,9 @@ export const api = { source_task_ids: input.sourceTaskIds ?? [], company_ids: input.companyIds ?? [], timeout_seconds: input.timeoutSeconds ?? 0, + no_result_seconds: input.noResultSeconds ?? 0, + no_result_min_findings: input.noResultMinFindings ?? 1, + no_result_min_severity: input.noResultMinSeverity ?? "", seed_first_intent: input.seedFirstIntent ?? false, plan_heartbeat_seconds: input.planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min) share_global_assets: input.shareGlobalAssets ?? false, diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index 4000d9a..76bb834 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -1274,6 +1274,11 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re const categoryID = mockIntegerField(b.category_id, "category_id") ?? undefined; const category = categoryID === undefined ? undefined : mockTaskCategories.find((item) => item.id === categoryID); if (categoryID !== undefined && !category) throw new Error("任务分类不存在"); + const noResultSeconds = Math.max(0, mockIntegerField(b.no_result_seconds, "no_result_seconds") ?? 0); + const noResultSeverity = String(b.no_result_min_severity ?? ""); + if (!["", "low", "medium", "high", "critical"].includes(noResultSeverity)) { + throw new Error("invalid no_result_min_severity"); + } const created: Task = { id, name, @@ -1282,6 +1287,10 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re category_name: category?.name, description: description || "新任务", goal, + no_result_seconds: noResultSeconds > 0 ? Math.max(60, noResultSeconds) : 0, + no_result_min_findings: Math.max(1, mockIntegerField(b.no_result_min_findings, "no_result_min_findings") ?? 1), + no_result_min_severity: noResultSeverity as Task["no_result_min_severity"], + stop_reason: "", worker_count: mockWorkerCount(b.worker_count), status: "created", created_at: now.toISOString(), diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 0599cc5..7d26ff9 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -34,6 +34,10 @@ export interface Task { description: string; goal: string; status: TaskStatus; + no_result_seconds?: number; + no_result_min_findings?: number; + no_result_min_severity?: "" | "low" | "medium" | "high" | "critical"; + stop_reason?: string; created_at: string; created_unix?: number; completed_at?: string; // RFC3339 finish time (done/failed); "" if unfinished