FR-067 · Detection Multi-Agent Dispatch — 需求討論稿 · 2026-08-23(已拍板,設計見 design.md)
檢測工具平台(FR-056/FR-058)目前一次執行只派一台 agent(駐點掃描機),而且是系統隨手挑的——清單順序不固定、也不看那台是不是還在線。客戶落地環境會在不同網段部署多台 agent,需求是:單一檢測任務內設定多組「agent+掃描目標」,按一次執行就展開成 N 張工單,各 agent 掃自己的網段,全部完成才算這次執行完成。內部模型已由 user 拍板走「展開 N 筆執行+群組彙總」路線(先前討論的模型 B),本稿把它落成可審的設計。
整案一句話:檢測任務可以設定多組「哪台 agent 掃哪些目標」,一次執行同時派給多台,全部回報完才算完成,報告與通知彙總呈現。
給非工程讀者的階段對照表(「完成怎麼判定」都是決策者可以親手操作驗證的方法):
| 階段 | 做什麼 | 產出 | 完成怎麼判定(決策者檢查法) |
|---|---|---|---|
| ① 地基:Agent 清單看得懂 | Agent 下拉只列「能做掃描」的機器、標示在線/離線 | Agent 清單 API 補齊能力與在線資訊 | 打開任務設置的 agent 下拉,只看到能掃描的 agent,離線的有標示 |
| ② 任務設置多組 assignment | 檢測工具綁定介面可加多列「agent+掃描目標」 | 新資料表+設置 UI | 在任務設置加兩組 agent+各自網段,儲存後重開頁面設定還在 |
| ③ 執行展開與收口 | 按「開始執行」展開 N 張工單;各 agent 回報後系統判定整組狀態 | 執行群組(group)層+展開/收口邏輯 | 按開始執行後,到執行紀錄看到兩台各自的狀態;等兩台都完成,整次執行才顯示完成 |
| ④ 結果呈現與通知 | 執行紀錄看得到「哪份報告是哪台掃的」;通知改成收口一封彙總 | 執行紀錄 enrich agent 名稱+彙總通知信 | 執行紀錄每列有 agent 名稱;收到的通知只有一封、內含兩台各自結果 |
| ⑤ 相容與收尾 | 舊資料/不設定 assignment 的任務照舊能跑;測試與 SPEC 更新 | backfill+向下相容邏輯+文件 | 拿一個沒設 assignment 的舊任務按執行,行為與現在一樣(自動挑一台健康的) |
目前一次檢測執行=一筆執行紀錄(detection_executions)+一張 agent 工單(agent_tasks),工單建立當下就把 agent 釘死。挑哪台的邏輯是:撈出「這個租戶下、啟用中、有掃描能力」的 agent 清單,取第一台——而底層查詢沒有排序(_pick_agent(),app/detection_tools/service/detection_orchestration_service.py:392-398),回傳順序是資料庫實體儲存順序,會隨著 agent 心跳更新而漂移。白話說:今天挑到 A 台、明天可能挑到 B 台,使用者無從預期,也無處指定。
更嚴重的是完全不檢查那台是否在線(last_seen_at 沒有納入判斷)。挑中一台已離線的 agent,工單會永遠掛在待領取(pending)狀態——agent 的領單機制是「心跳時只領自己名下的單」(domain/agent_task/service/agent_task_domain_service.py:110-124),機器不回來,單就永遠沒人領,沒有回收、沒有告警、使用者只看到「執行中」轉不完。這在 FR-058 設計文件(design.md:189)就已列為技術債。
落地部署的客戶環境常見多個隔離網段(辦公網/機房網/DMZ),每個網段部署一台 agent。掃描目標(hosts)分散在各網段,單一 agent 打不到別的網段。目前的模型下:
tool_params)只有 hosts/profile/timeout 等,沒有任何 agent 欄位;user 已拍板的目標模型(先前討論的「模型 B」):
以下座標均經兩輪唯讀探脈查證。
Agent 每 300 秒 POST heartbeat(mTLS),回應夾帶自己名下的 pending 工單(app/remote_agent/service/agent_enrollment_service.py:305-374)。工單 agent_id 建單釘死+領單只查自己名下,天然不會重複執行、也沒有搶單問題——N 筆工單各釘各的 agent,機制零改動。
Connector 只讀自己那筆工單參數裡的 hosts/targets;_task_uid 組唯一資源名,N 筆工單各有各的 uid 天然不撞。agent 端甚至已有多台 summary 加總邏輯可參考(evidence-agent/core/task_executor.py:329-351 _merge_summaries)。
agent POST result → app/agent_task/service/agent_task_service.py:66-93 → on_scan_succeeded/failed(detection_orchestration_service.py:466-531)→ detection_result_handler.py:73-155(拉報告檔→寫 job_evidences→標記成功)。選模型 B 的最大理由就是這條鏈原地不動(見 D1)。
storage-config 已有 agent 下拉元件(compliance-manager-fe/src/views/storage-config/StorageConfigForm.vue:502-516,存 agent_uid),任務設置的 assignment 列表可沿用同款選取模式。
| # | 缺口 | 現況座標 | 影響 |
|---|---|---|---|
| 1 | assignment 無處存放 | config.job_execution_detection_tools 的 tool_params(JSONB)無任何 agent 欄位;constraint uq_jedt_job_active 鎖「一任務一綁定」 |
存放載體要選(→ D2) |
| 2 | _pick_agent 無序+不驗在線 |
detection_orchestration_service.py:392-398 |
改成照 assignment 指定;未指定時 fallback 要過濾離線(→ D3、D8) |
| 3 | 一任務最多一筆 running 的守門 | get_running_by_job_execution_uid(:60-68,取 results[0]);caller=start_execution:123 與 cancel_execution:359 |
語意要升級到 group 層 |
| 4 | completion_mode=auto 單筆就觸發 | on_scan_succeeded :495-502——單筆 succeeded 就 complete_job;若 N 筆各觸發,第 2 筆起撞任務狀態 409,且 receive_result 不吞例外→agent 收 5xx |
改「group 內全部終態」判定(→ D5) |
| 5 | 通知每筆一封 | _notify_scan_result :654-704(Email/Telegram/Discord) |
N 台=N 封轟炸,改收口彙總一封(→ D6) |
| 6 | 取消只有單筆語意 | _cancel_running_execution :366-390:打該 agent 的 cancel API,失敗 raise 409 不標記 |
群組取消粒度要定(→ D7) |
| 7 | 自動派工冪等條件 | 發佈任務時 _auto_dispatch_detection_scans(app/grc/service/task_execution_service.py:98-134),冪等=該任務無任何 detection_executions |
展開 N 筆後條件語意要重新確認(改查 group 存在性) |
| 8 | 執行紀錄查不到「誰掃的」 | 執行紀錄 response 無 agent 欄位 | enrich agent 名稱(→ D9) |
| 9 | Agent 清單 API 太瘦 | GET /remote-agents 只回 uid+name,不含 capabilities 也不過濾(spec 坑 10:docs/specs/current/evidence/remote-agent-manage.md:444) |
FE 下拉要能只列「能掃描的」(→ D9) |
| 10 | test_connection 也走 agents[0] | app/detection_tools/service/detection_tool_service.py:189-193 |
要對齊可指定(→ D9) |
| 11 | 效能與索引債 | 兩處 N+1(_resolve_scan_params、_resolve_report_file_uid);detection_executions 的 agent_task_uid 是 soft-ref 無 FK 無 index;兩表無 job_execution_uid/agent_task_uid 索引 |
展開 N 筆後查詢量放大,順手補 |
| 12 | 測試與文件 | BE 測試 5 檔 mock 1:1 形狀(test_detection_orchestration.py 等);E2E detection-job-factory.js 只送單 tool;SPEC 三頁(my-tasks.md、project-planning.md、project-task-edit.md) |
收尾更新 |
detection_executions(infra/detection_execution/model/detection_execution.py):agent_task_uid(soft-ref)、job_execution_uid、status(running/succeeded/failed/cancelled,無狀態機守門)、summary(JSONB)、report_file_id/evidence_id(單值代表,已知債)。與 agent_tasks 嚴格 1:1——建立點 detection_orchestration_service.py:150-165;反查點 get_by_agent_task_uid(domain/detection_execution/service/detection_execution_domain_service.py:54-58,agent 回報不帶 execution_uid,全靠工單 uid 反查)。job_evidences.description = "[檢測工具] {agent_task.uid}",是報告↔︎工單唯一的關聯依據——這正是「1:1 是大量下游隱含依賴」的例證之一。compliance.remote_agents(infra/remote_agent/model/remote_agent.py):uid/name/base_url/capabilities(JSONB,agent 心跳自報,0.2.8 起恆為 ["file_storage","detection_scan"])/last_seen_at(在線判定=距今 < 心跳間隔 300s × 3)/org_unit_id(存在但全 NULL 未用)。agent.base_url。compliance-manager-fe/src/views/project/ProjectPlanningView.vue:1125-1127)→ api/grc/serializers/job.py:159-173(DetectionToolBindingSchema:uid/params/completion_mode)→ app/grc/service/job_service.py:375-430。維持「一筆執行=一張工單」1:1 不動,往上加一層「執行群組(execution group)」:任務設置存多組 assignment;按執行時建一個 group、每組 assignment 展開一筆 execution+一張工單;單筆回報鏈原樣跑;每筆終態時檢查 group 是否收口,收口才做「彙總狀態/自動完成任務/彙總通知」三件事。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
participant U as 使用者
participant FE as 前端
participant BE as 後端
participant A1 as Agent 甲(網段 A)
participant A2 as Agent 乙(網段 B)
U->>FE: 任務設置:加兩組 assignment(甲+網段A目標、乙+網段B目標)
FE->>BE: 儲存綁定(含 assignments)
U->>FE: 按「開始執行」
FE->>BE: start_execution
BE->>BE: 逐 assignment 驗證(存在/啟用/有能力/在線)
BE->>BE: 建 group + 展開 2 筆 execution + 2 張工單(各釘各的 agent)
A1->>BE: 心跳(300s)
BE-->>A1: 回應夾帶甲名下的工單
A2->>BE: 心跳
BE-->>A2: 回應夾帶乙名下的工單
A1->>A1: 掃網段 A
A2->>A2: 掃網段 B
A1->>BE: 回報結果(報告檔+summary)
BE->>BE: 單筆回報鏈原樣(寫證據/標記成功)
BE->>BE: 檢查 group:乙還在跑 → 不收口
A2->>BE: 回報結果
BE->>BE: 單筆回報鏈原樣
BE->>BE: 檢查 group:全部終態 → 收口
BE->>BE: 彙總狀態+(auto 模式)完成任務
BE-->>U: 一封彙總通知信(兩台各自結果)
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart TD
JEDT["job_execution_detection_tools<br/>(既有)檢測工具綁定<br/>tool_params/completion_mode"]
ASG["🆕 job_execution_detection_tool_agents<br/>assignment 子表<br/>agent_uid+targets(每組一列)"]
GRP["🆕 detection_execution_groups<br/>執行群組<br/>彙總狀態/收口時間"]
EXE["detection_executions(既有)<br/>單筆執行 status/summary/report<br/>+🆕 group_uid"]
TASK["agent_tasks(既有)<br/>agent_id 建單釘死"]
AGT["remote_agents(既有)<br/>capabilities/last_seen_at"]
JEDT -->|"1 → N"| ASG
ASG -.->|"參照"| AGT
JEDT -->|"執行時建"| GRP
GRP -->|"1 → N 展開"| EXE
EXE ===|"嚴格 1:1(不動)"| TASK
TASK -.->|"agent_id"| AGT
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
stateDiagram-v2
[*] --> running : 執行展開(任一筆 running)
running --> running : 部分回報,仍有未終態
running --> succeeded : 全部 succeeded
running --> partial_failed : 任一 failed/cancelled 且無 running
running --> failed : 全部 failed/cancelled
succeeded --> [*] : 收口(auto 可自動完成任務+彙總通知)
partial_failed --> [*] : 收口(不自動完成,留人工+彙總通知)
failed --> [*] : 收口(不自動完成+彙總通知)
on_scan_succeeded/failed 尾端加「查 group 內是否還有非終態」→ 沒有才做收口三件事(彙總狀態/auto 完成任務/彙總通知)。N 筆併發回報時靠 group 列鎖(SELECT ... FOR UPDATE group row)保證收口只跑一次——這同時解掉現況「N 筆各自觸發 complete_job、第 2 筆起撞 409 讓 agent 收 5xx」的地雷。get_running_by_job_execution_uid 的 caller(start_execution 的 force 判斷、cancel_execution)改查 group。_auto_dispatch_detection_scans 的冪等條件從「無任何 execution」改為「無任何 group」(backfill 後舊資料也有 group,語意一致)。🟢 D1 內部模型:A(一筆 execution 掛多工單)vs B(展開 N 筆 execution+group 彙總)→ 已拍板 B
兩個做法:(A)一筆 execution 底下掛多張工單,execution 內部變 1:N;(B)一次執行展開 N 筆 execution(維持與工單 1:1),上面新增 group 層做彙總。
深度分析結論建議 B,user 已拍板。核心理由:「一筆執行=一張工單」的 1:1 是大量下游程式隱含依賴的不變式——工單反查執行(get_by_agent_task_uid)、整支回報處理鏈(handle_result)、report_file_id 的單值語意、報告靠 description 字串反解分組、FE 的部分成功(CM-952)/零實質警示(CM-954)渲染,全都假設 1:1。走 B 這些全部原地保留,只需新增 group 聚合(DDD 四層)+把三件收口邏輯(auto 完成/通知/彙總狀態)上移——而這三件在 A 之下同樣要做;走 A 則要反轉不變式、重寫已成熟且有測試守著的邏輯,成本高風險大。存量 backfill 也簡單:DEV 現有 73 筆 execution 每筆自成一個 group 即可。
D2 assignment 存放載體:tool_params JSONB 內陣列 vs 新子表
選項一:在既有 tool_params(JSONB)裡加一個 assignments 陣列,不動 schema。選項二:新子表 job_execution_detection_tool_agents(綁定 1 → N assignment,每列 agent_uid+targets)。
建議:新子表。理由:①列表與詳情要 join agent 名稱、執行前要驗證 agent 存在性,JSONB 內陣列做不到 FK 級的可查可驗;②tool_params 現況會整份快照進工單參數(agent_tasks.params),塞在裡面 assignment 全文會漏到執行紀錄與通知信;③既有 uq_jedt_job_active 鎖「一任務一綁定」不必動,子表掛在綁定下即可。
D3 指定的 agent 離線/失效時的語意
執行當下發現某組 assignment 指定的 agent 不健康(不存在/停用/已註銷/無掃描能力/離線),要部分派出還是整次擋下?
建議:執行前逐 assignment 驗證,任一不過就整次報錯不派,錯誤訊息列出哪幾台不合格。理由:部分派出=部分網段被跳過,掃描結果殘缺卻顯示「完成」,比不派更糟;整次擋下讓使用者當場修正(換機或等上線)。在線判定沿用既有標準(last_seen_at 距今 < 3 個心跳週期=900 秒)。
D4 group 彙總狀態語意
N 筆執行各有 succeeded/failed/cancelled/running,群組整體狀態怎麼歸?
建議:全 succeeded=succeeded;任一 failed 且無 running=partial_failed(FE 顯示黃色,沿用 CM-952 部分成功視覺語彙);全 failed=failed;任一 running=running;cancelled 歸類比照 failed(含 cancelled 就不可能是全成功)。狀態機見圖 3。
D5 completion_mode=auto 的完成判定
現況單筆 succeeded 就自動完成任務。多筆之後,什麼條件才自動完成?
建議:group 內全部 execution succeeded 才 complete_job;partial_failed 不自動完成、留人工判斷(部分網段沒掃到,任務不該自動關)。同時修掉現況「多筆各自觸發撞 409」的地雷(見 §4.5 收口列鎖)。
D6 通知時點與形式
現況每筆回報發一封(Email/Telegram/Discord),N 台會變 N 封轟炸。
建議:改為 group 收口時發一封彙總信:每組 assignment 一列(agent 名稱/掃描目標/狀態/發現數),取代現行每筆一封。單一 assignment 的情境(向下相容路徑)收口即單筆,體感不變。
D7 取消粒度
執行中途取消,是整組取消還是可以只取消某一台?
建議:第一版只做整組取消——逐工單打各自 agent 的 cancel API,best-effort:可達的都取消、標記 cancelled;全部不可達才回 409。單一 assignment 取消列 future(要處理「取消一台後 group 怎麼歸態」的細節,第一版不背)。注意這比現況寬鬆:現行 _cancel_running_execution 是「打不到就 409 完全不標記」,多台情境下一台失聯就卡死整組取消不可接受。
D8 向下相容
舊資料與「不設定 assignment」的任務怎麼辦?
建議:綁定底下無 assignment 時=隱含單組 assignment——agent 自動挑(走修好的 _pick_agent:仍取一台,但過濾離線)、掃描目標用 tool_params 原值。既有 73 筆 execution backfill 每筆自成一個 group。這樣舊任務零遷移、行為與現在一致(且更健康:不會再挑到離線機)。
D9 連帶地基缺口是否併入本案
四項地基:①GET /remote-agents 補 capabilities 欄位+支援能力過濾(spec 坑 10);②執行紀錄 response enrich agent 名稱;③test_connection 對齊可指定 agent;④_pick_agent fallback 過濾離線。
建議:併入本案。它們不是順手優化,而是本功能可用性的一部分:FE 下拉沒有 capabilities 就列不出「能掃描的機器」(①);多 agent 後查不到「這份報告誰掃的」功能等於半殘(②);測試連線挑的機器與實際執行不同台會誤導(③);fallback 不過濾離線則 D8 的相容路徑仍會踩離線卡單的老坑(④)。
子需求粗切(細拆與 Notion 開卡等 design.md 階段):
| # | 子需求 | 範圍 | 依賴 |
|---|---|---|---|
| .1 | 地基:Agent 清單 API 補齊 | GET /remote-agents 補 capabilities+在線資訊+能力過濾;_pick_agent 離線過濾;test_connection 對齊 |
無(可先行) |
| .2 | 資料層:assignment 子表+group 表 | migration(新表×2+detection_executions.group_uid+補索引)+backfill 73 筆+DDD 四層(entity/repo/domain service) |
無 |
| .3 | 綁定設置:assignment CRUD | serializer(DetectionToolBindingSchema 擴充)→ job_service 寫入鏈→FE 任務設置多列 assignment UI(抄 storage-config 下拉) | .1 .2 |
| .4 | 執行展開與收口 | start_execution 展開 N 筆+執行前驗證(D3)+收口判定(列鎖)+auto 完成(D5)+並行守門與自動派工冪等上移 group | .2 |
| .5 | 彙總呈現與通知 | 執行紀錄 group 檢視+agent 名稱 enrich+彙總通知信(D6)+整組取消(D7)+FE 執行紀錄改版 | .4 |
| .6 | 收尾 | BE 測試 5 檔改 mock 形狀+E2E factory 擴充+SPEC 三頁更新(my-tasks/project-planning/project-task-edit) | .3 .4 .5 |
⚠️ 本稿只到「可審設計」
design.md、Notion 開卡、實作皆為後續階段(big-feature-workflow Step 3 起),待本稿審過拍板 D2–D9 後進行。