feat(tasks): stop tasks that miss a configurable findings checkpoint

This commit is contained in:
ji ruo committed 2026-11-11 14:07:39 +08:00
1 parent 39a8f2c31a
commit 997d2d98c0
24 files changed
+812 -64

No files matched your search

@@ -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>
+60 -3
View File
@@ -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
+5 -1
View File
@@ -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>
);
}
+17 -1
View File
@@ -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": [
+6
View File
@@ -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,
+9
View File
@@ -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(),
+4
View File
@@ -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