1、修复流量包里的复制按钮不工作问题
2、修复新建任务关联资产不同步到任务测试范围问题
This commit is contained in:
pphua committed 2026-08-28 07:37:24 +08:00
1 parent 3675bd815c
commit cf0989c67d
10 files changed
+297 -32

No files matched your search

+104 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
@@ -51,6 +52,12 @@ func TestGraphOverviewExpandsAssociatedCompanyScope(t *testing.T) {
if added != len(inputs) || skipped != 0 || invalid != 0 || len(scopeErrors) != 0 {
t.Fatalf("add company scope: added=%d skipped=%d invalid=%d errors=%v", added, skipped, invalid, scopeErrors)
}
assets := d.Assets()
assetID, err := assets.UpsertRootDomain(db.UpsertRootDomainReq{Domain: domain})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = assets.DeleteByIDs([]int64{assetID}) })
task, err := d.CreateTaskWithOptions("company context", "read configured scope", db.TaskCreateOptions{
CompanyIDs: []int64{companyID},
@@ -62,7 +69,14 @@ func TestGraphOverviewExpandsAssociatedCompanyScope(t *testing.T) {
tools := NewToolSet(d.Exploration(task.ExplorationID), "planner")
tools.SetTaskID(task.ID)
tools.SetAssetStore(d.Assets(), companies)
tools.SetAssetStore(assets, companies)
linkedAssets, err := assets.QueryByTask(task.ID, "root_domain", 10, 0)
if err != nil || len(linkedAssets) != 1 || linkedAssets[0].ID != assetID {
t.Fatalf("company asset was not linked to task: assets=%+v err=%v", linkedAssets, err)
}
if linkedAssets[0].TaskSource != "company" || !strings.Contains(linkedAssets[0].TaskSourceSummary, companiesName(t, companies, companyID)) {
t.Fatalf("company asset provenance missing: %+v", linkedAssets[0])
}
overview := tools.graphOverviewData()
coverage, ok := overview["coverage"].(map[string]any)
if !ok {
@@ -89,6 +103,95 @@ func TestGraphOverviewExpandsAssociatedCompanyScope(t *testing.T) {
if !ok || len(keywords) != 1 || keywords[0] != keyword {
t.Fatalf("company keywords missing: %#v", scopeRows[0]["company_keywords"])
}
hosts, ok := coverage["hosts"].([]string)
if !ok || !containsString(hosts, domain) {
t.Fatalf("company asset host missing from agent context: %#v", coverage["hosts"])
}
untested := callReadJSON(t, tools.listUntestedAssets(), `{"type":"root_domain","page":1,"page_size":10}`)
if !strings.Contains(fmt.Sprint(untested), domain) {
t.Fatalf("company asset missing from untested backlog: %#v", untested)
}
byTask := callReadJSON(t, tools.listAssets(), fmt.Sprintf(
`{"dsl":"task_id==%d","type":"root_domain","limit":10}`, task.ID,
))
if !strings.Contains(fmt.Sprint(byTask), domain) {
t.Fatalf("company asset missing from task-scoped agent query: %#v", byTask)
}
byCompany := callReadJSON(t, tools.listAssets(), fmt.Sprintf(
`{"dsl":"company_id==%d","type":"root_domain","limit":10}`, companyID,
))
if !strings.Contains(fmt.Sprint(byCompany), domain) {
t.Fatalf("company asset missing from company-scoped agent query: %#v", byCompany)
}
assetJSON, _ := json.Marshal(assetID)
intentID, err := tools.addOneIntent(intentItem{
Summary: "test associated company asset",
AssetIDs: []json.RawMessage{assetJSON},
})
if err != nil {
t.Fatal(err)
}
workerAssets, err := assets.IntentAssets(task.ID)
if err != nil || len(workerAssets) != 1 || workerAssets[0].IntentID != intentID || workerAssets[0].AssetID != assetID {
t.Fatalf("worker target did not retain company asset: assets=%+v err=%v", workerAssets, err)
}
coverageDisabled := false
disabledTask, err := d.CreateTaskWithOptions("company context without coverage", "still expose company assets", db.TaskCreateOptions{
CompanyIDs: []int64{companyID}, CoverageEnabled: &coverageDisabled,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = d.DeleteTask(disabledTask.ID) })
disabledTools := NewToolSet(d.Exploration(disabledTask.ExplorationID), "planner")
disabledTools.SetTaskID(disabledTask.ID)
disabledTools.SetCoverageEnabled(false)
disabledTools.SetAssetStore(assets, companies)
disabledOverview := disabledTools.graphOverviewData()
disabledCoverage, ok := disabledOverview["coverage"].(map[string]any)
if !ok {
t.Fatalf("coverage-disabled task lost asset context: %#v", disabledOverview["coverage"])
}
disabledHosts, ok := disabledCoverage["hosts"].([]string)
if !ok || !containsString(disabledHosts, domain) {
t.Fatalf("coverage-disabled task lost company asset host: %#v", disabledCoverage["hosts"])
}
if _, exists := disabledCoverage["denominator"]; exists {
t.Fatalf("coverage-disabled task unexpectedly exposed metrics: %#v", disabledCoverage)
}
if linked, err := assets.QueryByTask(disabledTask.ID, "root_domain", 10, 0); err != nil || len(linked) != 1 || linked[0].ID != assetID {
t.Fatalf("coverage-disabled task asset link=%+v err=%v", linked, err)
}
disabledIntentID, err := disabledTools.addOneIntent(intentItem{
Summary: "test associated company asset without coverage metrics",
AssetIDs: []json.RawMessage{assetJSON},
})
if err != nil {
t.Fatal(err)
}
disabledWorkerAssets, err := assets.IntentAssets(disabledTask.ID)
if err != nil || len(disabledWorkerAssets) != 1 || disabledWorkerAssets[0].IntentID != disabledIntentID || disabledWorkerAssets[0].AssetID != assetID {
t.Fatalf("coverage-disabled worker target=%+v err=%v", disabledWorkerAssets, err)
}
}
func containsString(items []string, target string) bool {
for _, item := range items {
if item == target {
return true
}
}
return false
}
func companiesName(t *testing.T, companies *db.CompanyStore, companyID int64) string {
t.Helper()
company, err := companies.GetCompany(companyID)
if err != nil || company == nil {
t.Fatalf("company %d: company=%+v err=%v", companyID, company, err)
}
return company.Name
}
func TestBlackboardToolsReadDirectSources(t *testing.T) {
+7 -3
View File
@@ -177,9 +177,13 @@ func TestCompanyScopeInvalid(t *testing.T) {
}
defer cleanupCompany(d, id)
// TLD-only domains and overly broad CIDRs should be rejected
lines := []string{"com", "1.2.3.4/8"} // rejected
added, _, invalid, _ := cs.AddScope(id, lines, "test")
// Explicitly typed TLD-only domains and overly broad CIDRs should be
// rejected. Untyped plain text is intentionally classified as a keyword.
inputs := []ScopeInput{
{Kind: "domain", Value: "com"},
{Kind: "cidr", Value: "1.2.3.4/8"},
}
added, _, invalid, _ := cs.AddScopeInputs(id, inputs, "test")
if added != 0 {
t.Errorf("want 0 added for invalid lines, got %d", added)
}
+35 -2
View File
@@ -89,10 +89,12 @@ const MinPlanHeartbeatSeconds = 600
const MaxTaskSourceCount = 8
// MaxTaskCompanyCount bounds the number of company asset scopes attached to a
// task. Company scopes are prompt context, so an unbounded list would inflate
// every planner and worker call.
// task. Company scopes are prompt context, and their currently attributed
// assets are snapshotted into the task at creation time.
const MaxTaskCompanyCount = 32
const taskCompanyAssetSource = "company"
var (
ErrTaskCompanyIDsInvalid = errors.New("invalid task company ids")
ErrTaskCompanyNotFound = errors.New("task company not found")
@@ -248,6 +250,11 @@ func insertTaskCompanies(tx *sql.Tx, taskID int64, companyIDs []int64) error {
if len(companyIDs) == 0 {
return nil
}
// Company scope edits rebuild assets.company_id. Serialize the creation-time
// snapshot with those edits so the task sees one committed attribution state.
if err := lockCompanyScopeMutation(tx); err != nil {
return err
}
var inserted int
err := tx.QueryRow(`
WITH requested(company_id, position) AS (
@@ -268,6 +275,32 @@ SELECT count(*) FROM inserted`, taskID, companyIDs).Scan(&inserted)
if inserted != len(companyIDs) {
return fmt.Errorf("%w: one or more companies do not exist", ErrTaskCompanyNotFound)
}
// Updating task_ids fires sync_task_asset_links, which first creates generic
// source rows. The provenance upsert must therefore run afterwards so the
// company name and creation-time reason remain visible to operators.
if _, err := tx.Exec(`
UPDATE assets
SET task_ids=CASE
WHEN $1=ANY(task_ids) THEN task_ids
ELSE array_append(task_ids, $1)
END
WHERE company_id=ANY($2::bigint[])`, taskID, companyIDs); err != nil {
return err
}
if _, err := tx.Exec(`
INSERT INTO task_asset_links(task_id, asset_id, source, source_summary)
SELECT $1, asset.id, $3, '任务创建时关联企业:' || company.name
FROM assets asset
JOIN companies company ON company.id=asset.company_id
WHERE asset.company_id=ANY($2::bigint[])
AND $1=ANY(asset.task_ids)
ON CONFLICT (task_id, asset_id) DO UPDATE
SET source=EXCLUDED.source,
source_summary=EXCLUDED.source_summary,
source_node_id=NULL`, taskID, companyIDs, taskCompanyAssetSource); err != nil {
return err
}
return nil
}
+78 -1
View File
@@ -509,10 +509,36 @@ func TestCreateTaskWithCompanyScopes(t *testing.T) {
if err != nil {
t.Fatal(err)
}
emptyCompany, _, err := d.Companies().UpsertCompany(fmt.Sprintf("Task Empty Company %d", suffix), "")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = d.Exec(`DELETE FROM companies WHERE id IN ($1,$2)`, companyA, companyB)
_, _ = d.Exec(`DELETE FROM companies WHERE id IN ($1,$2,$3)`, companyA, companyB, emptyCompany)
})
existingTask, err := d.CreateTask("existing company asset owner", "goal", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = d.DeleteTask(existingTask.ID) })
var companyAssetA, companyAssetB int64
domainA := fmt.Sprintf("task-company-a-%d.example.test", suffix)
domainB := fmt.Sprintf("task-company-b-%d.example.test", suffix)
if err := d.QueryRow(`
INSERT INTO assets(type, domain, root_domain, company_id, company_source, task_ids)
VALUES ('root_domain',$1,$1,$2,'explicit',ARRAY[$3]::bigint[])
RETURNING id`, domainA, companyA, existingTask.ID).Scan(&companyAssetA); err != nil {
t.Fatal(err)
}
if err := d.QueryRow(`
INSERT INTO assets(type, domain, root_domain, company_id, company_source)
VALUES ('root_domain',$1,$1,$2,'explicit')
RETURNING id`, domainB, companyB).Scan(&companyAssetB); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = d.Assets().DeleteByIDs([]int64{companyAssetA, companyAssetB}) })
task, err := d.CreateTaskWithOptions("company-scoped task", "use company scope", TaskCreateOptions{
CompanyIDs: []int64{companyA, companyB},
})
@@ -537,6 +563,57 @@ func TestCreateTaskWithCompanyScopes(t *testing.T) {
if scopeCount != 2 {
t.Fatalf("company task scope rows=%d want 2", scopeCount)
}
assets, err := d.Assets().QueryByTask(task.ID, "root_domain", 10, 0)
if err != nil {
t.Fatal(err)
}
if len(assets) != 2 {
t.Fatalf("company task assets=%d want 2: %+v", len(assets), assets)
}
assetByID := make(map[int64]*Asset, len(assets))
for _, asset := range assets {
assetByID[asset.ID] = asset
}
for assetID, companyName := range map[int64]string{
companyAssetA: fmt.Sprintf("Task Company A %d", suffix),
companyAssetB: fmt.Sprintf("Task Company B %d", suffix),
} {
asset := assetByID[assetID]
if asset == nil {
t.Errorf("company asset %d missing from task", assetID)
continue
}
if asset.TaskSource != taskCompanyAssetSource || asset.TaskSourceSummary != "任务创建时关联企业:"+companyName {
t.Errorf("asset %d provenance=%q/%q", assetID, asset.TaskSource, asset.TaskSourceSummary)
}
}
var existingAssociation bool
if err := d.QueryRow(`SELECT $1=ANY(task_ids) FROM assets WHERE id=$2`, existingTask.ID, companyAssetA).Scan(&existingAssociation); err != nil {
t.Fatal(err)
}
if !existingAssociation {
t.Fatal("company snapshot removed the asset's existing task association")
}
var existingSource string
if err := d.QueryRow(`SELECT source FROM task_asset_links WHERE task_id=$1 AND asset_id=$2`, existingTask.ID, companyAssetA).Scan(&existingSource); err != nil {
t.Fatal(err)
}
if existingSource == taskCompanyAssetSource {
t.Fatalf("company snapshot overwrote another task's provenance: %q", existingSource)
}
emptyTask, err := d.CreateTaskWithOptions("empty company scope", "no current assets", TaskCreateOptions{
CompanyIDs: []int64{emptyCompany},
})
if err != nil {
t.Fatal(err)
}
if assets, err := d.Assets().QueryByTask(emptyTask.ID, "", 10, 0); err != nil || len(assets) != 0 {
t.Fatalf("empty company task assets=%+v err=%v", assets, err)
}
if err := d.DeleteTask(emptyTask.ID); err != nil {
t.Fatal(err)
}
duplicateTask, err := d.CreateTaskWithOptions("duplicate company scope", "deduplicate", TaskCreateOptions{
CompanyIDs: []int64{companyA, companyA, companyB, companyA},
+1 -1
View File
@@ -1363,7 +1363,7 @@ type createTaskReq struct {
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"` // 关联企业资产范围;不复制资产或强制生成意图
CompanyIDs []int64 `json:"company_ids,omitempty"` // 关联企业范围并快照关联当前企业资产;不复制资产或强制生成意图
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 轮)。
+2 -2
View File
@@ -2613,8 +2613,8 @@ function CreateTaskSheet({
portalContainer={sheetContentRef}
/>
<FieldDescription>
将所选企业的域名、IP、CIDR、ICP 和企业关键词作为 Agent
可选范围上下文;不会自动生成意图或强制改变执行目标。
创建任务时会将所选企业当前已有资产加入“测试资产”,并将域名、IP、CIDR、ICP 和企业关键词提供给 Agent
作为范围上下文;不会自动生成意图或强制改变执行目标。
</FieldDescription>
</Field>
<Field>
+5 -5
View File
@@ -39,7 +39,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { api } from "@/lib/api";
import type { TrafficDetail, TrafficExchange, TrafficHost, TrafficResp } from "@/lib/types";
import { cn } from "@/lib/utils";
import { cn, copyText } from "@/lib/utils";
function fmtTime(ts: string) {
return new Date(ts).toLocaleString("zh-CN", {
@@ -199,12 +199,12 @@ function HttpCodeBlock({ raw }: { raw: string }) {
}, [copied]);
const copyPacket = async () => {
try {
await navigator.clipboard.writeText(value);
const ok = await copyText(value);
if (ok) {
setCopied(true);
} catch {
toast.error("复制失败,请使用 Ctrl/Cmd+A 后复制");
return;
}
toast.error("复制失败,请使用 Ctrl/Cmd+A 后复制");
};
const renderLine = (line: string, index: number) => {
+63 -16
View File
@@ -42,9 +42,35 @@ const mockCompanies = structuredClone(D.companies);
const mockAssets = structuredClone(D.assets);
const mockTaskAssetIDs = new Map(D.tasks.map((task, index) => [task.id, index + 1]));
const mockTaskScopes = new Map<string, TaskScopeRow[]>();
type MockTaskAssetSource = Pick<Asset, "task_source" | "task_source_summary" | "task_source_node_id">;
const mockTaskAssetSources = new Map<string, MockTaskAssetSource>();
let nextMockTaskAssetID = D.tasks.length + 1;
let mockActiveTask = D.ACTIVE_TASK;
function mockTaskAssetSourceKey(taskID: string, assetID: number): string {
return JSON.stringify([taskID, assetID]);
}
function setMockTaskAssetSource(taskID: string, assetID: number, source: MockTaskAssetSource) {
mockTaskAssetSources.set(mockTaskAssetSourceKey(taskID, assetID), source);
}
function mockAssetForTask(taskID: string, asset: Asset): Asset {
const source = mockTaskAssetSources.get(mockTaskAssetSourceKey(taskID, asset.id));
return source ? { ...asset, ...source } : asset;
}
function deleteMockTaskAssetSources(taskID: string, assetID?: number) {
if (assetID !== undefined) {
mockTaskAssetSources.delete(mockTaskAssetSourceKey(taskID, assetID));
return;
}
for (const key of mockTaskAssetSources.keys()) {
const [linkedTaskID] = JSON.parse(key) as [string, number];
if (linkedTaskID === taskID) mockTaskAssetSources.delete(key);
}
}
function mockTaskAssetID(taskID: string): number | undefined {
const numeric = Number(taskID);
if (Number.isInteger(numeric) && numeric > 0) return numeric;
@@ -289,7 +315,10 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
const now = new Date();
const profileIDs = [...((b.llm_profile_ids as number[] | undefined) ?? [])];
const sourceTaskIDs = [...((b.source_task_ids as string[] | undefined) ?? [])];
const companyIDs = [...((b.company_ids as number[] | undefined) ?? [])];
const companyIDs = [...new Set((b.company_ids as number[] | undefined) ?? [])];
if (companyIDs.some((companyID) => !mockCompanies.some((company) => company.id === companyID))) {
throw new Error("关联企业不存在或无效");
}
const categoryID = typeof b.category_id === "number" ? b.category_id : undefined;
const category = categoryID === undefined ? undefined : mockTaskCategories.find((item) => item.id === categoryID);
if (categoryID !== undefined && !category) throw new Error("任务分类不存在");
@@ -318,7 +347,19 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
source_task_ids: sourceTaskIDs,
company_ids: companyIDs,
};
mockTaskAssetIDs.set(id, nextMockTaskAssetID++);
const numericTaskID = nextMockTaskAssetID++;
mockTaskAssetIDs.set(id, numericTaskID);
const selectedCompanies = new Set(companyIDs);
for (const asset of mockAssets) {
if (asset.company_id === undefined || !selectedCompanies.has(asset.company_id)) continue;
if (!asset.task_ids.includes(numericTaskID)) asset.task_ids.push(numericTaskID);
const company = mockCompanies.find((candidate) => candidate.id === asset.company_id);
setMockTaskAssetSource(id, asset.id, {
task_source: "company",
task_source_summary: `任务创建时关联企业:${company?.name ?? `#${asset.company_id}`}`,
task_source_node_id: undefined,
});
}
for (const item of mockTasks) item.active = false;
mockTasks.unshift(created);
mockActiveTask = id;
@@ -462,9 +503,14 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
}
if (seg[0] === "tasks" && seg.length === 2 && m === "DELETE") {
const id = seg[1];
const numericTaskID = mockTaskAssetID(id);
const index = mockTasks.findIndex((item) => item.id === id);
if (index >= 0) mockTasks.splice(index, 1);
mockTaskAssetIDs.delete(id);
deleteMockTaskAssetSources(id);
if (numericTaskID !== undefined) {
for (const asset of mockAssets) asset.task_ids = asset.task_ids.filter((taskID) => taskID !== numericTaskID);
}
let findingsDeleted = 0;
if (b.delete_findings) {
@@ -701,7 +747,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
});
const limit = Number(q.get("limit") ?? 50);
const offset = Number(q.get("offset") ?? 0);
const page = list.slice(offset, offset + limit);
const page = list.slice(offset, offset + limit).map((asset) => (taskID ? mockAssetForTask(taskID, asset) : asset));
return { count: page.length, total: list.length, assets: page };
}
if (path === "/assets" && m === "DELETE") {
@@ -792,9 +838,11 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
asset.task_ids.push(numericTaskID);
mutation.assets_linked++;
}
asset.task_source = "manual";
asset.task_source_summary = "用户在测试资产页手工新增";
asset.task_source_node_id = undefined;
setMockTaskAssetSource(seg[1], asset.id, {
task_source: "manual",
task_source_summary: "用户在测试资产页手工新增",
task_source_node_id: undefined,
});
}
mockTaskScopes.set(seg[1], currentScopes);
return mutation;
@@ -812,9 +860,11 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
asset.task_ids.push(numericTaskID);
mutation.attached++;
}
asset.task_source = "manual";
asset.task_source_summary = sourceSummary;
asset.task_source_node_id = undefined;
setMockTaskAssetSource(seg[1], asset.id, {
task_source: "manual",
task_source_summary: sourceSummary,
task_source_node_id: undefined,
});
}
return mutation;
}
@@ -824,11 +874,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (numericTaskID === undefined || !asset) throw new Error("任务或资产不存在");
if (!asset.task_ids.includes(numericTaskID)) throw new Error("资产未关联当前任务");
asset.task_ids = asset.task_ids.filter((id) => id !== numericTaskID);
if (asset.task_ids.length === 0) {
asset.task_source = undefined;
asset.task_source_summary = undefined;
asset.task_source_node_id = undefined;
}
deleteMockTaskAssetSources(seg[1], asset.id);
return { detached: asset.id };
}
if (seg[0] === "tasks" && seg[2] === "intent-assets" && seg.length === 3 && m === "GET") {
@@ -843,8 +889,9 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
}
const sourceTaskID = mockTaskAssetID(seg[1]) ?? 0;
const assets: IntentAsset[] = mappings.flatMap((mapping) => {
const asset = mockAssets.find((item) => item.id === mapping.assetID);
if (!asset) return [];
const storedAsset = mockAssets.find((item) => item.id === mapping.assetID);
if (!storedAsset) return [];
const asset = mockAssetForTask(seg[1], storedAsset);
return [
{
intent_id: mapping.intentID,
+1
View File
@@ -13,6 +13,7 @@ const TASK_ASSET_SOURCE_LABELS: Record<string, string> = {
agent: "Agent 发现",
anchor: "黑板锚点",
api: "资产 API",
company: "企业关联",
legacy: "历史关联",
manual: "人工加入",
system: "系统关联",
+1 -1
View File
@@ -35,7 +35,7 @@ export interface Task {
llm_failover_state?: "default" | "ready" | "chain_exhausted" | string;
llm_failover_reason?: string;
source_task_ids?: string[]; // directly related tasks inherited as read-only context
company_ids?: number[]; // associated company asset scopes available as task context
company_ids?: number[]; // associated company scopes; current company assets join the task at creation
coverage_enabled?: boolean; // 资产覆盖度功能开关(创建时定,默认开);false=不计算/不展示覆盖度
}