---
title: "檢測多 Agent 分派 — 需求討論稿 (FR-067)"
brand: "Guidant AI · **FR-067** 檢測多 Agent 分派"
eyebrow: "FR-067 · Detection Multi-Agent Dispatch — 需求討論稿 · 2026-08-23（已拍板，設計見 design.md）"
h1: "一次檢測，讓每台 Agent 掃自己的網段"
lede: "檢測工具平台（FR-056／FR-058）目前一次執行只派**一台** agent（駐點掃描機），而且是系統**隨手挑的**——清單順序不固定、也不看那台是不是還在線。客戶落地環境會在不同網段部署多台 agent，需求是：**單一檢測任務內設定多組「agent＋掃描目標」，按一次執行就展開成 N 張工單，各 agent 掃自己的網段，全部完成才算這次執行完成。**內部模型已由 user 拍板走「展開 N 筆執行＋群組彙總」路線（先前討論的模型 B），本稿把它落成可審的設計。"
chips: [
  {text: "模型 B 已拍板", kind: ok},
  {text: "D1–D9 已全數拍板（2026-08-23）", kind: ok},
  {text: "前作：FR-056／FR-058（檢測工具整合）", kind: accent},
  {text: "Agent 端（evidence-agent）零改動", kind: ok},
  {text: "現況：挑機無序＋不驗在線＝離線永久卡單", kind: crit}
]
footer: "FR-067 · 檢測多 Agent 分派 — 需求討論稿 · 2026-08-23 已拍板（定案見 design.md）· 前作：FR-056（檢測工具整合平台）／FR-058（檢測執行）· 事實來源：BE／FE／agent 三 repo 兩輪唯讀探脈（座標見文內）· 內部模型 A/B 取捨已做深度分析，建議 B 並經 user 拍板"
---

## 分工概述（30 秒版） {#overview nav="概述"}

**整案一句話**：檢測任務可以設定多組「哪台 agent 掃哪些目標」，一次執行同時派給多台，全部回報完才算完成，報告與通知彙總呈現。

給非工程讀者的階段對照表（「完成怎麼判定」都是決策者可以親手操作驗證的方法）：

| 階段 | 做什麼 | 產出 | 完成怎麼判定（決策者檢查法） |
|------|--------|------|------------------------------|
| ① 地基：Agent 清單看得懂 | Agent 下拉只列「能做掃描」的機器、標示在線／離線 | Agent 清單 API 補齊能力與在線資訊 | 打開任務設置的 agent 下拉，只看到能掃描的 agent，離線的有標示 |
| ② 任務設置多組 assignment | 檢測工具綁定介面可加多列「agent＋掃描目標」 | 新資料表＋設置 UI | 在任務設置加兩組 agent＋各自網段，儲存後重開頁面設定還在 |
| ③ 執行展開與收口 | 按「開始執行」展開 N 張工單；各 agent 回報後系統判定整組狀態 | 執行群組（group）層＋展開／收口邏輯 | 按開始執行後，到執行紀錄看到兩台各自的狀態；等兩台都完成，整次執行才顯示完成 |
| ④ 結果呈現與通知 | 執行紀錄看得到「哪份報告是哪台掃的」；通知改成收口一封彙總 | 執行紀錄 enrich agent 名稱＋彙總通知信 | 執行紀錄每列有 agent 名稱；收到的通知只有一封、內含兩台各自結果 |
| ⑤ 相容與收尾 | 舊資料／不設定 assignment 的任務照舊能跑；測試與 SPEC 更新 | backfill＋向下相容邏輯＋文件 | 拿一個沒設 assignment 的舊任務按執行，行為與現在一樣（自動挑一台健康的） |

## 需求背景與現況問題 {#why nav="背景"}

### 2.1 現在系統怎麼挑機器：隨機，而且不看死活

目前一次檢測執行＝一筆執行紀錄（`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）就已列為技術債。

### 2.2 客戶環境的真實需求：多網段、多 agent

落地部署的客戶環境常見多個隔離網段（辦公網／機房網／DMZ），每個網段部署一台 agent。掃描目標（hosts）分散在各網段，單一 agent 打不到別的網段。目前的模型下：

- **UI 無處指定**「這批目標給哪台掃」——檢測工具綁定的參數（`tool_params`）只有 hosts／profile／timeout 等，沒有任何 agent 欄位；
- **執行紀錄也無處查看**「這份報告是哪台掃出來的」——執行紀錄 response 不含 agent 資訊；
- 要掃全部網段，只能開多個檢測任務各跑一次，狀態與報告散落各處，人工湊。

### 2.3 目標行為

user 已拍板的目標模型（先前討論的「模型 B」）：

1. 任務設置時，檢測工具綁定下可設**多組 assignment**（每組＝一台 agent＋一批掃描目標）；
2. 按一次「開始執行」，系統**展開成 N 筆執行紀錄**（每組一筆，維持既有「一筆執行＝一張工單」的 1:1 關係），外面包一層**執行群組（group）**做彙總；
3. 各 agent 心跳領各自的單、掃自己的網段、各自回報；
4. **全部終態才算這次執行完成**——群組彙總狀態、自動完成任務（completion_mode=auto）、通知信都改在群組收口時做。

## 現況盤點：可複用 vs 缺口 {#inventory nav="盤點"}

以下座標均經兩輪唯讀探脈查證。

### 3.1 可複用（不動或小動）

::: grid2
::: {.card .ok}
#### 心跳領單機制原樣可用

Agent 每 300 秒 POST heartbeat（mTLS），回應夾帶自己名下的 pending 工單（`app/remote_agent/service/agent_enrollment_service.py:305-374`）。工單 `agent_id` 建單釘死＋領單只查自己名下，**天然不會重複執行、也沒有搶單問題**——N 筆工單各釘各的 agent，機制零改動。
:::

::: {.card .ok}
#### Agent 端（evidence-agent）零改動

Connector 只讀自己那筆工單參數裡的 hosts／targets；`_task_uid` 組唯一資源名，N 筆工單各有各的 uid 天然不撞。agent 端甚至已有多台 summary 加總邏輯可參考（`evidence-agent/core/task_executor.py:329-351` `_merge_summaries`）。
:::

::: {.card .ok}
#### 單筆回報鏈完整保留

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）。
:::

::: {.card .ok}
#### UI 有前例可抄

storage-config 已有 agent 下拉元件（`compliance-manager-fe/src/views/storage-config/StorageConfigForm.vue:502-516`，存 agent_uid），任務設置的 assignment 列表可沿用同款選取模式。
:::
:::

### 3.2 缺口（本案要動的）

| # | 缺口 | 現況座標 | 影響 |
|---|------|----------|------|
| 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） | 收尾更新 |

### 3.3 關鍵既有事實（設計依據）

- `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 是大量下游隱含依賴」的例證之一。
- Agent 註冊表 `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 未用）。
- 心跳 poll 模型：BE 連不進客戶內網，唯一反向推送是 cancel／probe 走 `agent.base_url`。
- 綁定寫入鏈：FE 送出（`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`。

## 設計方案 {#design nav="設計"}

### 4.1 一句話架構

**維持「一筆執行＝一張工單」1:1 不動，往上加一層「執行群組（execution group）」**：任務設置存多組 assignment；按執行時建一個 group、每組 assignment 展開一筆 execution＋一張工單；單筆回報鏈原樣跑；每筆終態時檢查 group 是否收口，收口才做「彙總狀態／自動完成任務／彙總通知」三件事。

### 4.2 端到端時序

```{.mermaid cap="圖 1 — 設定 assignment → 執行展開 → 各 agent 心跳領單 → 回報 → 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: 一封彙總通知信（兩台各自結果）
```

### 4.3 資料模型

```{.mermaid cap="圖 2 — 資料模型：新增 assignment 子表與 execution group 層，既有 1:1 關係不動"}
%%{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
```

### 4.4 群組狀態機

```{.mermaid cap="圖 3 — group 彙總狀態機（依 D4 建議語意）"}
%%{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 --> [*] : 收口（不自動完成＋彙總通知）
```

### 4.5 收口判定的實作要點

- **收口檢查放在單筆終態寫入之後、同一交易內**：`on_scan_succeeded/failed` 尾端加「查 group 內是否還有非終態」→ 沒有才做收口三件事（彙總狀態／auto 完成任務／彙總通知）。N 筆併發回報時靠 group 列鎖（`SELECT ... FOR UPDATE` group row）保證收口只跑一次——這同時解掉現況「N 筆各自觸發 `complete_job`、第 2 筆起撞 409 讓 agent 收 5xx」的地雷。
- **並行守門語意上移**：「一任務最多一筆 running」升級為「一任務最多一個 running group」；`get_running_by_job_execution_uid` 的 caller（start_execution 的 force 判斷、cancel_execution）改查 group。
- **自動派工冪等**：`_auto_dispatch_detection_scans` 的冪等條件從「無任何 execution」改為「無任何 group」（backfill 後舊資料也有 group，語意一致）。

## 待決策清單 D1–D9 {#decisions nav="待決策"}

::: {.callout .decided}
**🟢 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 即可。
:::

::: {.callout .pending}
**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` 鎖「一任務一綁定」不必動，子表掛在綁定下即可。]{.rec}
:::

::: {.callout .pending}
**D3 指定的 agent 離線／失效時的語意**

執行當下發現某組 assignment 指定的 agent 不健康（不存在／停用／已註銷／無掃描能力／離線），要部分派出還是整次擋下？

[**建議**：執行前逐 assignment 驗證，**任一不過就整次報錯不派**，錯誤訊息列出哪幾台不合格。理由：部分派出＝部分網段被跳過，掃描結果殘缺卻顯示「完成」，比不派更糟；整次擋下讓使用者當場修正（換機或等上線）。在線判定沿用既有標準（last_seen_at 距今 < 3 個心跳週期＝900 秒）。]{.rec}
:::

::: {.callout .pending}
**D4 group 彙總狀態語意**

N 筆執行各有 succeeded／failed／cancelled／running，群組整體狀態怎麼歸？

[**建議**：全 succeeded＝succeeded；任一 failed 且無 running＝partial_failed（FE 顯示黃色，沿用 CM-952 部分成功視覺語彙）；全 failed＝failed；任一 running＝running；cancelled 歸類比照 failed（含 cancelled 就不可能是全成功）。狀態機見圖 3。]{.rec}
:::

::: {.callout .pending}
**D5 completion_mode=auto 的完成判定**

現況單筆 succeeded 就自動完成任務。多筆之後，什麼條件才自動完成？

[**建議**：group 內**全部** execution succeeded 才 `complete_job`；partial_failed **不自動完成**、留人工判斷（部分網段沒掃到，任務不該自動關）。同時修掉現況「多筆各自觸發撞 409」的地雷（見 §4.5 收口列鎖）。]{.rec}
:::

::: {.callout .pending}
**D6 通知時點與形式**

現況每筆回報發一封（Email／Telegram／Discord），N 台會變 N 封轟炸。

[**建議**：改為 **group 收口時發一封彙總信**：每組 assignment 一列（agent 名稱／掃描目標／狀態／發現數），取代現行每筆一封。單一 assignment 的情境（向下相容路徑）收口即單筆，體感不變。]{.rec}
:::

::: {.callout .pending}
**D7 取消粒度**

執行中途取消，是整組取消還是可以只取消某一台？

[**建議**：第一版只做**整組取消**——逐工單打各自 agent 的 cancel API，**best-effort**：可達的都取消、標記 cancelled；全部不可達才回 409。單一 assignment 取消列 future（要處理「取消一台後 group 怎麼歸態」的細節，第一版不背）。注意這比現況寬鬆：現行 `_cancel_running_execution` 是「打不到就 409 完全不標記」，多台情境下一台失聯就卡死整組取消不可接受。]{.rec}
:::

::: {.callout .pending}
**D8 向下相容**

舊資料與「不設定 assignment」的任務怎麼辦？

[**建議**：綁定底下**無 assignment 時＝隱含單組 assignment**——agent 自動挑（走修好的 `_pick_agent`：仍取一台，但過濾離線）、掃描目標用 `tool_params` 原值。既有 73 筆 execution backfill 每筆自成一個 group。這樣舊任務零遷移、行為與現在一致（且更健康：不會再挑到離線機）。]{.rec}
:::

::: {.callout .pending}
**D9 連帶地基缺口是否併入本案**

四項地基：①`GET /remote-agents` 補 capabilities 欄位＋支援能力過濾（spec 坑 10）；②執行紀錄 response enrich agent 名稱；③`test_connection` 對齊可指定 agent；④`_pick_agent` fallback 過濾離線。

[**建議**：**併入本案**。它們不是順手優化，而是本功能可用性的一部分：FE 下拉沒有 capabilities 就列不出「能掃描的機器」（①）；多 agent 後查不到「這份報告誰掃的」功能等於半殘（②）；測試連線挑的機器與實際執行不同台會誤導（③）；fallback 不過濾離線則 D8 的相容路徑仍會踩離線卡單的老坑（④）。]{.rec}
:::

## 拆分草案 {#split nav="拆分"}

子需求粗切（細拆與 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 |

::: {.callout .warn}
**⚠️ 本稿只到「可審設計」**

design.md、Notion 開卡、實作皆為後續階段（big-feature-workflow Step 3 起），待本稿審過拍板 D2–D9 後進行。
:::
