mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-11 21:23:50 +08:00
feat(tasks): stop tasks that miss a configurable findings checkpoint
This commit is contained in:
1 parent
39a8f2c31a
commit
997d2d98c0
24 files changed
+812
-64
No files matched your search
@@ -4,6 +4,8 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- 新增可选「无成果止损」:按实际运行时长、独立漏洞数量与最低等级做一次检查,未达标时自动收尾并显示「无成果停止」;暂停、排队及计划窗口外不计时,复活后重新计时,默认关闭。停止状态复用 `timeout`,已有 `on_task_timeout` 触发器仍会执行并可能消耗 token。
|
||||
|
||||
## [0.3.18] - 2026-10-12
|
||||
|
||||
### 审批审查
|
||||
|
||||
@@ -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,收尾阶段已经写入的事实和资产会保留")
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+35
-10
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
+114
-33
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+54
-7
@@ -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)
|
||||
|
||||
+10
-3
@@ -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,
|
||||
})
|
||||
|
||||
+12
-4
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -395,7 +395,12 @@ function TaskDetailInner() {
|
||||
</code>
|
||||
<Separator orientation="vertical" className="mx-1 hidden h-4 sm:block" />
|
||||
<TaskLLMControl task={task} profiles={profiles} onUpdated={load} />
|
||||
<StatusBadge domain={terminal ? "task" : "engine"} value={terminal ? task.status : engineMode} dot />
|
||||
<StatusBadge
|
||||
stopReason={task.stop_reason}
|
||||
domain={terminal ? "task" : "engine"}
|
||||
value={terminal ? task.status : engineMode}
|
||||
dot
|
||||
/>
|
||||
{canArchive ? (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>{archiveTrigger}</AlertDialogTrigger>
|
||||
|
||||
@@ -1489,7 +1489,7 @@ const TaskRow = React.memo(function TaskRow({
|
||||
{task.goal}
|
||||
</TableCell>
|
||||
<TableCell className={cn(!visibleColumns.status && "hidden")}>
|
||||
<StatusBadge domain="task" value={task.status} dot />
|
||||
<StatusBadge domain="task" value={task.status} stopReason={task.stop_reason} dot />
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn("text-muted-foreground text-center text-xs tabular-nums", !visibleColumns.progress && "hidden")}
|
||||
@@ -2885,7 +2885,7 @@ function SourceTaskPicker({
|
||||
</p>
|
||||
{task?.goal && <p className="text-muted-foreground truncate text-xs">{task.goal}</p>}
|
||||
</div>
|
||||
{task && <StatusBadge domain="task" value={task.status} />}
|
||||
{task && <StatusBadge domain="task" value={task.status} stopReason={task.stop_reason} />}
|
||||
</div>
|
||||
</ComboboxItem>
|
||||
);
|
||||
@@ -3199,7 +3199,7 @@ function DraggableCategoryTask({ task, disabled, moving }: { task: Task; disable
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<StatusBadge domain="task" value={task.status} />
|
||||
<StatusBadge domain="task" value={task.status} stopReason={task.stop_reason} />
|
||||
</ItemActions>
|
||||
</Item>
|
||||
);
|
||||
@@ -3597,6 +3597,9 @@ function CreateTaskSheet({
|
||||
const [llmProfileIDs, setLLMProfileIDs] = React.useState<string[]>([]);
|
||||
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<NonNullable<Task["no_result_min_severity"]>>("");
|
||||
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({
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">{translate("taskUI.timeoutHelp")}</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="no-result-min">{translate("taskUI.noResultOptional")}</Label>
|
||||
<Input
|
||||
id="no-result-min"
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-40"
|
||||
placeholder="120"
|
||||
value={noResultMin}
|
||||
onChange={(e) => setNoResultMin(e.target.value)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">{translate("taskUI.noResultHelp")}</p>
|
||||
</div>
|
||||
{Number(noResultMin) > 0 && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="no-result-count">{translate("taskUI.noResultCount")}</Label>
|
||||
<Input
|
||||
id="no-result-count"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={noResultCount}
|
||||
onChange={(e) => setNoResultCount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="no-result-severity">{translate("taskUI.noResultSeverity")}</Label>
|
||||
<Select
|
||||
value={noResultSeverity || "any"}
|
||||
onValueChange={(v) =>
|
||||
setNoResultSeverity((v === "any" ? "" : v) as NonNullable<Task["no_result_min_severity"]>)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="no-result-severity">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="any">{translate("taskUI.noResultAnySeverity")}</SelectItem>
|
||||
<SelectItem value="low">{translate("taskUI.noResultLow")}</SelectItem>
|
||||
<SelectItem value="medium">{translate("taskUI.noResultMedium")}</SelectItem>
|
||||
<SelectItem value="high">{translate("taskUI.noResultHigh")}</SelectItem>
|
||||
<SelectItem value="critical">{translate("taskUI.noResultCritical")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="heartbeat-min">{translate("taskUI.heartbeat")}</Label>
|
||||
<Input
|
||||
|
||||
@@ -9,11 +9,13 @@ export function StatusBadge({
|
||||
domain,
|
||||
value,
|
||||
dot = false,
|
||||
stopReason,
|
||||
className,
|
||||
}: {
|
||||
domain: StatusDomain;
|
||||
value: string;
|
||||
dot?: boolean;
|
||||
stopReason?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -27,7 +29,9 @@ export function StatusBadge({
|
||||
)}
|
||||
>
|
||||
{dot && <span className={cn("size-1.5 rounded-full", toneDot[meta.tone])} />}
|
||||
{meta.label}
|
||||
{domain === "task" && value === "timeout" && stopReason === "no_result"
|
||||
? t("taskUI.noResultStopped")
|
||||
: meta.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user