---
title: "FR-067 檢測多 Agent 分派 — 設計文件"
brand: "Guidant AI · **FR-067** 檢測多 Agent 分派"
eyebrow: "FR-067 · Detection Multi-Agent Dispatch — 設計文件 · 2026-08-25（.1–.5／.7／.8 已實作，驗收迭代中）"
h1: "一次檢測展開 N 張工單：多 Agent 分派設計定案"
lede: "檢測任務可設定多組「哪台 agent 掃哪些目標」，一次執行同時派給多台、全部回報完才算完成，報告與通知彙總呈現。本文件承接討論稿，D1–D9 已於 2026-08-23 全數拍板（含 D7 執行粒度、D8 相容範圍兩項 user 修訂），落成資料模型、API 契約、收口演算法與拆分表；2026-08-24 追加 D10（per-assignment 排程執行）成 FR-067.7。"
chips: [
  {text: ".1–.5／.7／.8 已實作，驗收迭代中", kind: ok},
  {text: "D1–D10 全數拍板（D10：2026-08-24）", kind: ok},
  {text: "前作：FR-056／FR-058", kind: accent},
  {text: "Agent 端（evidence-agent）零改動", kind: ok}
]
footer: "FR-067 · 檢測多 Agent 分派 — 設計文件 · 2026-08-23 設計定案／2026-08-24 D10 排程追加 · 前作：FR-056（檢測工具整合平台）／FR-058（檢測執行）· 事實座標以討論稿為準"
---

# FR-067 檢測多 Agent 分派（Detection Multi-Agent Dispatch）— 設計文件

> 狀態：**D1–D10 已拍板；.1–.5／.7／.8 已實作，端到端驗收迭代中（.6 收尾未派）**｜建立日期：2026-08-23｜最後更新：2026-08-25（第二輪回饋 CM-1382／1383 ＋ FR-067.8 三卡落地回寫）｜前作：FR-056（檢測工具整合平台）／FR-058（檢測執行）
> 討論稿（含現況盤點與流程圖）：[`discussion.md`](./discussion.md)

## 給 PM 的三分鐘版 {#pm nav="給 PM"}

> 這節不需要任何技術背景就讀得懂。工程細節從下一節起。

**這案在做什麼**：檢測工具（弱點掃描、組態檢查那些）是靠派駐在客戶機房的「Agent」去跑
的——Agent 就是一台裝了掃描程式的機器，替我們去打目標主機。客戶的網路通常被切成好幾個
互不相通的區段（辦公網、機房網、對外的 DMZ），一台 Agent 打不到別區段，所以每段各放一台。

本案之前，一次檢測**只能派一台**，而且是系統隨手挑的——不看那台在不在線上。挑到關機的
機器，畫面就永遠停在「執行中」轉不完，沒有錯誤、沒有告警。要掃三個區段只能開三個任務、
人工拼結果。

本案讓使用者在任務設定裡列出多組「**哪台 Agent** 掃 **哪些目標**」，按一次執行同時派出去，
**全部回報完才算這次檢測完成**。

**為什麼值得做**：客戶的多網段是常態不是特例，現況等於把「拆成幾個任務、記得哪個對哪個、
最後自己拼起來」這件事丟給使用者做，而且挑到離線機還會靜默卡死。

**影響哪些畫面／操作**：

| 畫面 | 變化 |
|------|------|
| 任務設定 → 檢測工具 | 從「填一組掃描設定」變成「可以加好幾列，每列一台 Agent＋自己的掃描目標與參數」；可以複製一列、也可以每列各設幾點才開跑 |
| Agent 下拉選單 | 只列得出「有掃描能力」的機器，並標示在線／離線 |
| 執行紀錄 | 一次執行變成一組多筆，看得到每筆是哪台掃的；哪台失敗可以單獨重跑那一台 |
| 通知信 | 從每台各發一封改成收口時一封，內含各台結果 |

**拍板了什麼**（決策 D1–D10 的白話版，完整理由見 §3）：

| 題目 | 定案 |
|------|------|
| 內部怎麼記 | 一次執行展開成 N 張工單，外面包一層「群組」做彙總（維持既有「一張工單＝一次掃描」的結構不動） |
| 什麼時候算完成 | 群組裡**全部**都到終點狀態才算完成——才發通知、才自動結任務 |
| 失敗了怎麼辦 | 可以只重跑失敗的那一組，不必整批重來；也可以單組或整組取消 |
| 舊資料怎麼辦 | 不做轉換（當時還在開發驗證階段，資料之後會重置） |
| 沒設多組的任務 | 照舊能跑，但改成自動挑一台**在線且有掃描能力**的機器，不再隨手挑 |
| Agent 端要改嗎 | **不用**，全部在雲端這側完成，客戶已裝好的 Agent 一行都不必動 |
| 排程（後加） | 每一組可各自設「幾點才開跑」——按執行後那組先排隊，時間到才開掃（避開營業時間） |

**驗收過程又補了三件**（使用者實際用起來才發現的）：每一組的掃描設定完全獨立（客戶常見
混合環境，這台 Linux 走一種連線、那台 Windows 走另一種）、掃描目標可以寫整個網段而不必
一台台列、一次執行的多組設定在畫面上改成逐組分塊顯示（六個設定項全攤平在一行，認不出哪
列在做什麼）。

**現在到哪了**：母卡 CM-1350 已於 2026-08-27 收案，隨 **v1.16.0** 出貨，189 POC 已裝好。
完整總結見 [`handoff/2026-08-27-FR-067-SUMMARY.md`](handoff/2026-08-27-FR-067-SUMMARY.md)。

---

## 變更紀錄 {#changelog nav="變更紀錄"}

| 日期 | 變更 | 對應 |
|------|------|------|
| 2026-08-23 | 初版設計定案。D1–D9 全數拍板，其中 D7（執行粒度：整組啟動＋單組重跑＋單組/整組取消）與 D8（不做存量 backfill）為 user 對討論稿建議的修訂 | FR-067 母案 |
| 2026-08-24 | 插入 FR-067.7 per-assignment 排程執行（D10）：assignment 子表加 `scheduled_at`、agent 領單時間過濾、execution 加 scheduled 狀態、「立即開始」端點；拆分表補 T-7.1／T-7.2 | D10 |
| 2026-08-25 | 端到端驗收第一輪回饋修正回寫（CM-1375／1377／1378／1379）：分派列 UX 重整（§5.8.1）、同 agent 多列＋per-列 profile 覆寫（§5.2.1／§5.8.1，推翻原唯一約束）、profile 參照展開移到合併後（§5.4）、測試連線可指定 agent（§5.4）、掃描失敗訊息帶 stderr＋Rocky 檔名推 `rl`（agent 0.2.29，§5.8.2）、legacy 執行紀錄卡片分組鍵統一（§5.6） | 驗收回饋 |
| 2026-08-25 | 追加 FR-067.8 每列自足模型（§5.10）：廢「任務層參數＋列覆寫」改「每列＝完整獨立工作設定」，secret 跟列走、零列 fallback 廢掉（agent 留空＝自動挑）、SonarQube 多模式混掃＋upload per 列檔案槽、複製列＋新列帶預設值。執行層與 agent 零改動 | FR-067.8 |
| 2026-08-25 | 驗收第二輪回饋落地（CM-1382／CM-1383）：分派列可覆寫**連線方式 transport**（混合 OS 各走各的通道，§5.4／§5.8.1，寫入端兩道驗證 `GRC_400109`／`GRC_400110`）；**掃描目標支援 CIDR 與末段範圍語法**（§5.4，新 util `common/util/scan_target_spec.py`；展開與否**依欄位分流而非依工具**——Nmap 的 `hosts` 是 agent 登入去跑 nmap 的執行主機要展開、`targets` 是掃描目標原樣透傳，**推翻卡片原假設**；`GRC_400111`／`GRC_400112` ＋ 台數上限 env `DETECTION_SCAN_TARGET_MAX_HOSTS`）；分派列改**摘要列＋inline 展開卡片**（可設定項已達六個，全攤平在一行認不出哪列在做什麼，§5.8.1） | CM-1382／CM-1383 |
| 2026-08-25 | **FR-067.8 T-8.1／T-8.2／T-8.3 全數落地**（§5.10 由設計定案改寫為實況）：BE 列 params 完整化＋存量讀取端合併（新 util `common/util/detection_assignment_params.py`）、逐列 condition-aware 必填驗證與 `project_key_suffix` 跨列唯一（`GRC_400113`／`GRC_400114`）；FE 任務層掃描參數區收掉（只留 completion_mode）、展開卡片渲染整份 param_schema、複製此列＋新列帶第一列預設值、最少一列；SonarQube upload 源碼包**改 per 列檔案槽與 per 列 sticky**（多包混掃），擋門逐列且擋在建 group 之前、D33 自動派發判斷細到列。**執行層與 agent 皆零改動**（如設計預期） | FR-067.8 |
| 2026-08-25 | 單組重跑前置**從群組層收窄到 assignment 線層**（§5.5）：只看該線自己的最新一筆是否 failed/cancelled，不看群組整體——失敗組不必乾等同群組的慢組；`409012` 自此無 raiser（碼位保留不回收）；收口併發靠 `lock_for_close` 序列化（判斷用鎖到的那份群組狀態） | CM-1397 |
| 2026-08-25／26 | 掃描目標 range 顯示配套（§5.11）：**台數由 BE 供**（`scan_target_spec.count_spec` 二義化＋`display_counts_of()` 唯一入口——「算不算」一律算、「CIDR 怎麼算」依展開/透傳分流）、執行紀錄印**原始寫法＋台數**（派工當下快照 `_scan_target_spec`，透傳欄位無快照就地算）、FE 過期防護（spec 字串與現值比對，不符退回段數）、警示字改 theme-aware token（`--color-orange`／`--color-red`）；執行紀錄補回傳 `targets` 欄位——列上講**真正被掃的欄位**（有 `targets` 講它，判準看資料不看工具清單），不再把 nmap 跳板機當掃描目標講 | CM-1395 |
| 2026-08-25 | 執行抽屜「檢測設定」卡**多組時改逐組分塊**（§5.8.2）：跨列彙整（值不同「／」併陳）在 SonarQube 混模式下講不出「哪列是哪列」；改一組一小塊（組號＋agent＋該組參數逐組過 condition＋逐組摺疊），單組任務版型不變；上傳槽與擋門訊息改標「第 N 組（辨識參數）」（組號用全域序號，與檢測設定卡對得起來） | CM-1396 |

---

## 1. 需求背景與目標 {#why nav="背景"}

檢測工具平台（FR-056／FR-058）目前一次執行只派**一台** agent（駐點掃描機），而且是系統**隨手挑的**——`_pick_agent()` 底層查詢無排序（`app/detection_tools/service/detection_orchestration_service.py:392-398`），回傳順序隨心跳更新漂移；**也完全不檢查在線狀態**（`last_seen_at` 未納入判斷），挑中離線機時工單永遠掛在待領取（pending），沒有回收、沒有告警，使用者只看到「執行中」轉不完（FR-058 design.md:189 已列技術債）。

客戶落地環境常見多個隔離網段（辦公網／機房網／DMZ），每網段一台 agent，單台打不到別的網段。現況 UI 無處指定「這批目標給哪台掃」、執行紀錄也查不到「這份報告誰掃的」，要掃全部網段只能開多個任務人工湊。

**目標行為**（user 拍板的模型 B）：

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

---

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

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

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

| 階段 | 做什麼 | 產出 | 完成怎麼判定（決策者檢查法） |
|------|--------|------|------------------------------|
| ① 地基：Agent 清單看得懂 | Agent 下拉只列「能做掃描」的機器、標示在線／離線 | Agent 清單 API 補齊能力與在線資訊 | 打開任務設置的 agent 下拉，只看到能掃描的 agent，離線的有標示 |
| ② 任務設置多組 assignment | 檢測工具綁定介面可加多列「agent＋掃描目標」 | 新資料表（assignment 子表）＋設置 UI | 在任務設置加兩組 agent＋各自網段，儲存後重開頁面設定還在 |
| ③ 執行展開與收口 | 按「開始執行」展開 N 張工單；各 agent 回報後系統判定整組狀態；失敗的組可單組重跑、單組/整組取消 | 執行群組（group）層＋展開／收口／重跑／取消邏輯 | 按開始執行後，到執行紀錄看到兩台各自的狀態；等兩台都完成，整次執行才顯示完成；故意讓一台失敗後按該組「重跑」，成功後整組轉成功 |
| ④ 結果呈現與通知 | 執行紀錄看得到「哪份報告是哪台掃的」；通知改成收口一封彙總 | 執行紀錄 group 化＋agent 名稱 enrich＋彙總通知信 | 執行紀錄每列有 agent 名稱；收到的通知只有一封、內含兩台各自結果 |
| ⑤ 相容與收尾 | 不設 assignment 的任務照舊能跑（自動挑一台**健康的**）；測試與 SPEC 更新 | fallback 邏輯＋測試修補＋文件 | 拿一個沒設 assignment 的任務按執行，行為與現在一樣但不會挑到離線機；舊資料不處理（開發驗證階段資料之後重置） |
| ⑦ 排程執行（2026-08-24 追加） | 每組 assignment 可設「幾點才開跑」——按執行後那組先等著，時間到 agent 才領單開掃 | assignment／工單加 `scheduled_at`＋scheduled 狀態＋「立即開始」端點＋UI 排程 chip | 幫一組設 2 分鐘後的時間、按開始執行，看到那組顯示排程中、時間到自己開跑 |

---

## 3. 決策定案（D1–D10） {#decisions nav="決策定案"}

| # | 決策 | 定案內容 | 定案理由 | 被排除方案與原因 |
|---|------|----------|----------|------------------|
| **D1** | 內部模型 | **B：一次執行展開 N 筆 `detection_executions`（與 `agent_tasks` 維持 1:1）＋新增 group 層彙總** | 「一筆執行＝一張工單」1:1 是大量下游隱含依賴的不變式：工單反查執行（`get_by_agent_task_uid`）、整支回報處理鏈（`handle_result`）、`report_file_id` 單值語意、報告靠 `job_evidences.description` 字串反解、FE 部分成功（CM-952）／零實質警示（CM-954）渲染。走 B 這些**全部原地保留**，只需新增 group 聚合＋把三件收口邏輯（auto 完成／通知／彙總狀態）上移——而這三件在 A 之下同樣要做 | 方案 A（一筆 execution 掛多工單、內部變 1:N）：要反轉不變式、重寫已成熟且有測試守著的回報鏈，成本高風險大 |
| **D2** | assignment 存放載體 | **新子表 `job_execution_detection_tool_agents`**（綁定 1 → N assignment，每列 agent_uid＋掃描目標） | user 提出兩個未來場景——①未來可能有中心 UI 讓 user 直接維護 assignment；②未來可能開放指派人員在任務執行頁（我的任務）調整 assignment——兩場景都需要「每列有自己的 uid 可單列 CRUD、按列權限稽核、兩入口併發不互蓋」，子表天然支援 | `tool_params` JSONB 內陣列：無列身分（無法單列 CRUD／稽核）、整包讀寫兩入口會互蓋；且 `tool_params` 現況會**整份快照進 `agent_tasks.params`**，assignment 全文會漏到執行紀錄與通知信 |
| **D3** | 指定 agent 失效語意 | **執行前逐 assignment 驗證**（存在／enabled／未 revoke／有 `detection_scan` capability／在線＝`last_seen_at` 距今 < 心跳間隔 300s × 3），**任一不過整次擋下 400**，錯誤訊息列出所有不合格機與原因 | 部分派出＝部分網段被跳過，掃描結果殘缺卻顯示「完成」，比不派更糟；整次擋下讓使用者當場修正（換機或等上線）；一次列出全部不合格機避免「修一台、再撞下一台」 | 部分派出（只派健康的）：結果殘缺的靜默風險 |
| **D4** | group 彙總狀態語意 | 全 succeeded → **succeeded**；任一 failed 且無 running → **partial_failed**；全 failed → **failed**；任一 running → **running**；**cancelled 歸類比照 failed**（含 cancelled 就不可能全成功） | partial_failed 沿用 CM-952 部分成功視覺語彙（FE 黃色），使用者一眼看出「有掃到但不完整」 | 只有成功/失敗二值：喪失「部分網段掃完」的資訊 |
| **D5** | completion_mode=auto 的完成判定 | **group 內全部 execution succeeded 才 `complete_job`**；partial_failed **不自動完成**、留人工判斷；順修現況「多筆各自觸發 `complete_job` 撞任務狀態 409、`receive_result` 不吞例外讓 agent 收 5xx」的地雷（收口列鎖，見 §5.3） | 部分網段沒掃到，任務不該自動關；409 地雷在多筆模型下必炸，必須此案一併解 | 任一 succeeded 即完成（現況語意）：多網段下等於只掃一段就關任務 |
| **D6** | 通知時點與形式 | **group 收口時發一封彙總通知**（Email／Telegram／Discord），每 assignment 一列（agent 名稱／掃描目標／狀態／發現數），取代現行每筆一封 | N 台 N 封是轟炸；單一 assignment 情境收口即單筆，體感不變 | 維持每筆一封：多台轟炸 |
| **D7** | 執行粒度（user 修訂：比討論稿建議多了單組操作） | **整組啟動＋單組重跑＋單組/整組取消**。語意：整組啟動＝建 group＋N 筆 execution；單組重跑＝失敗後對該 assignment 在**同一 group** 下追加一筆新 execution，彙總狀態以「**每個 assignment 的最新一筆**」計算（A 成功、B 重跑成功 → 整組成功 → 可觸發 D5 自動完成）；單組取消＝只取消該組工單，彙總照 D4 重算；**「未整組跑過前的單組首次啟動」第一版不做**（語意模糊，用「先只設一組」即可達成） | 失敗只重跑失敗那台，不必整組重掃（掃描動輒數十分鐘）；同 group 追加保留完整歷史、彙總語意單純 | 討論稿原建議「第一版只做整組取消、單組操作列 future」——user 修訂納入第一版；「單組首次啟動」仍排除（語意模糊） |
| **D8** | 向下相容（user 修訂：範圍收窄） | **不做存量 backfill、不管舊資料**（開發驗證階段，資料之後會重置）；但「**binding 無 assignment 時＝單組自動挑一台**（走修好的離線過濾 `_pick_agent`）」的行為**保留**——這是為「不想指定」的使用情境設計的，不是為舊資料 | 資料會重置，backfill 是白工；fallback 行為讓簡單場景（單網段、不在乎哪台掃）零設定可用，且比現況健康（不再挑到離線機） | 討論稿原建議「73 筆存量 backfill 每筆自成 group」——user 修訂不做；「無 assignment 時直接擋下強制設定」：對簡單場景過度要求 |
| **D9** | 連帶地基缺口 | **併入本案**：①`GET /remote-agents` 補 capabilities＋在線狀態＋按 capability 過濾；②執行紀錄 enrich agent 名稱；③`test_connection` 對齊指定邏輯；④`_pick_agent` 過濾離線 | 不是順手優化，是本功能可用性的一部分：FE 下拉沒 capabilities 列不出「能掃描的」（①）；查不到「誰掃的」功能半殘（②）；測試連線與實際執行不同台會誤導（③）；fallback 不過濾離線則 D8 相容路徑仍踩離線卡單老坑（④） | 另開案：四項單獨都太小、又都是本案的前置依賴，拆開徒增協調成本 |
| **D10**（2026-08-24 追加） | per-assignment 排程執行 | **assignment 子表加 `scheduled_at TIMESTAMPTZ` 可空（空＝立即），跟著「agent＋掃描目標」一起設定，不另立入口**。實作形狀：「group 照常立即建，延後的是 agent 領得到單的時間」——按開始執行當下照常 D3 驗證＋建 group＋N 筆 execution＋N 張工單；`agent_tasks` 加 `scheduled_at`（從 assignment 快照）；**agent 心跳領單查詢加 `scheduled_at IS NULL OR scheduled_at <= now()` 條件**，沒到時間的單 agent 看不到；收口邏輯零改動，不用 APScheduler、無背景 job（也因此無 tenant context 坑）。語意：絕對時間；啟動當下時間已過＝立即領；跑完後再次整組執行＝全部照當下 assignment 的 `scheduled_at` 重新判定（過去式時間＝立即，語意自洽不清欄位）；**單組重跑一律立即**（人當下按的）；週期性排程本版不做，只留欄位擴充空間。連帶調整四件：①execution 加「scheduled」狀態（排程等待中，不顯示 running 誤導），彙總函式視同未終態；②取消排程中的組＝短路直接標 cancelled 不打 agent（單還沒被領走）；③「一任務最多一個 running group」守門窗變長——排程等待期間不能再按執行，要就先取消整組（UI 要表達）；④新增「立即開始」小端點：把該工單 `scheduled_at` 清成 now（排程中的組可催跑） | 不同網段常有不同維護窗（半夜才能掃機房網），per-assignment 才表達得了；「時間過濾領單」讓展開／收口全部沿用既有機制，改動面最小且驗證仍在人在場的當下做 | 整任務單一排程時間：表達不了「不同網段不同維護窗」。APScheduler 到點才展開：要背景 job、有 tenant context 坑、D3 驗證延到半夜沒人看 |

---

## 4. 現況接入點盤點 {#inventory nav="盤點"}

座標均出自討論稿兩輪唯讀探脈（詳見 [`discussion.md`](./discussion.md) §3）。

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

| 元件 | 現況 | 本案動作 |
|------|------|----------|
| 心跳領單機制 | Agent 每 300 秒 POST heartbeat（mTLS），回應夾帶自己名下 pending 工單（`app/remote_agent/service/agent_enrollment_service.py:305-374`）；工單 `agent_id` 建單釘死＋領單只查自己名下，天然無重複執行、無搶單 | **零改動**——N 筆工單各釘各的 agent |
| Agent 端（evidence-agent） | Connector 只讀自己那筆工單參數；`_task_uid` 組唯一資源名，N 筆工單各有 uid 天然不撞 | **零改動** |
| 單筆回報鏈 | 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` | **原樣保留**，只在終態寫入後追加 group 收口檢查（§5.3） |
| FE agent 下拉前例 | storage-config 已有 agent 下拉（`compliance-manager-fe/src/views/storage-config/StorageConfigForm.vue:502-516`，存 agent_uid） | 任務設置 assignment 列表沿用同款選取模式 |

### 4.2 要動的（元件 × 現況 × 本案動作）

| # | 元件 | 現況 | 本案動作 |
|---|------|------|----------|
| 1 | `config.job_execution_detection_tools` | `tool_params`（JSONB）無 agent 欄位；constraint `uq_jedt_job_active` 鎖一任務一綁定 | 新子表 `job_execution_detection_tool_agents` 掛在綁定下（D2）；`uq_jedt_job_active` 不動 |
| 2 | `_pick_agent`（`detection_orchestration_service.py:392-398`） | 無序＋不驗在線 | 有 assignment 時照指定；fallback 路徑過濾離線＋加穩定排序（D8/D9④） |
| 3 | 「一任務最多一筆 running」守門 | `get_running_by_job_execution_uid`（:60-68 取 `results[0]`）；caller＝`start_execution`:123 與 `cancel_execution`:359 | 語意上移為「一任務最多一個 running group」 |
| 4 | completion_mode=auto 觸發 | `on_scan_succeeded`:495-502 單筆 succeeded 即 `complete_job`；N 筆各觸發第 2 筆起撞 409，`receive_result` 不吞例外 → agent 收 5xx | 改 group 收口判定＋列鎖保證只收一次（D5，§5.3） |
| 5 | 通知 | `_notify_scan_result`:654-704 每筆一封（Email／Telegram／Discord） | 改 group 收口彙總一封（D6，§5.4） |
| 6 | 取消 | `_cancel_running_execution`:366-390 打該 agent cancel API，失敗 raise 409 不標記 | 整組取消 best-effort＋單組取消（D7，§5.5） |
| 7 | 自動派工冪等 | 發佈任務時 `_auto_dispatch_detection_scans`（`app/grc/service/task_execution_service.py:98-134`），冪等＝該任務無任何 detection_executions | 冪等條件改「該任務無任何 group」 |
| 8 | 執行紀錄 response | 無 agent 欄位 | group 化 response＋enrich agent 名稱（D9②，§5.6） |
| 9 | `GET /remote-agents` | 只回 uid＋name，不含 capabilities 不過濾（spec 坑 10：`docs/specs/current/evidence/remote-agent-manage.md:444`） | 補 capabilities＋在線狀態＋`capability` 過濾參數（D9①） |
| 10 | `test_connection`（`app/detection_tools/service/detection_tool_service.py:189-193`） | 走 `agents[0]` | 對齊可指定 agent（D9③） |
| 11 | 效能與索引 | 兩處 N+1（`_resolve_scan_params`／`_resolve_report_file_uid`）；`detection_executions.agent_task_uid` soft-ref 無 index；兩表無 `job_execution_uid`／`agent_task_uid` 索引 | migration 順手補索引；N+1 於 .4 實作時批次化 |
| 12 | 測試與文件 | BE 測試 5 檔 mock 1:1 形狀（test_detection_orchestration.py 等）；E2E `detection-job-factory.js` 只送單 tool；SPEC 三頁（my-tasks／project-planning／project-task-edit） | .6 收尾更新 |

### 4.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`（agent 回報不帶 execution_uid，全靠工單 uid 反查）。
- 報告與工單的關聯是字串約定：`job_evidences.description = "[檢測工具] {agent_task.uid}"`。
- Agent 註冊表 `compliance.remote_agents`：uid／name／base_url／capabilities（JSONB，agent 心跳自報，0.2.8 起恆為 `["file_storage","detection_scan"]`）／last_seen_at（在線判定＝距今 < 300s × 3）。
- 心跳 poll 模型：BE 連不進客戶內網，唯一反向推送是 cancel／probe 走 `agent.base_url`。
- 綁定寫入鏈：FE（`ProjectPlanningView.vue:1125-1127`）→ `api/grc/serializers/job.py:159-173`（DetectionToolBindingSchema）→ `app/grc/service/job_service.py:375-430`。

---

## 5. 詳細設計 {#design nav="詳細設計"}

### 5.1 一句話架構

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

```{.mermaid cap="圖 1 — 資料模型：新增 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'}}}%%
flowchart TD
    JEDT["job_execution_detection_tools<br/>（既有）檢測工具綁定<br/>tool_params／completion_mode"]
    ASG["🆕 job_execution_detection_tool_agents<br/>assignment 子表<br/>agent_uid＋scan_targets（每組一列）"]
    GRP["🆕 detection_execution_groups<br/>執行群組<br/>彙總狀態／收口時間"]
    EXE["detection_executions（既有）<br/>單筆執行 status／summary／report<br/>＋🆕 group_uid＋assignment_uid"]
    TASK["agent_tasks（既有）<br/>agent_id 建單釘死"]
    AGT["remote_agents（既有）<br/>capabilities／last_seen_at"]

    JEDT -->|"1 → N"| ASG
    ASG -.->|"agent_uid 參照"| AGT
    JEDT -->|"執行時建"| GRP
    GRP -->|"1 → N 展開<br/>（重跑時同 assignment 追加）"| EXE
    EXE ===|"嚴格 1:1（不動）"| TASK
    TASK -.->|"agent_id"| AGT
```

### 5.2 資料模型

#### 5.2.1 新表 `job_execution_detection_tool_agents`（assignment 子表）

掛在檢測工具綁定（`config.job_execution_detection_tools`）之下，同 schema。每列＝一組「agent＋掃描目標」。

| 欄位 | 型別 | 說明 |
|------|------|------|
| `id` | BIGSERIAL PK | |
| `uid` | VARCHAR(36) UNIQUE NOT NULL | 每列有自己的 uid——D2 定案理由的載體（單列 CRUD／按列稽核／兩入口併發不互蓋） |
| `tenant_id` | BIGINT NOT NULL | RLS 用 |
| `org_unit_id` | BIGINT | tenant-scoped 表必含（repo 慣例） |
| `job_execution_detection_tool_id` | BIGINT NOT NULL | FK → `config.job_execution_detection_tools.id`（同 schema；跨 schema 引用時必限定 schema） |
| `agent_uid` | VARCHAR(36) NOT NULL | soft-ref → `compliance.remote_agents.uid`（跨 schema 不建 FK，比照 `detection_executions.agent_task_uid` 既有慣例；執行前驗證由 D3 應用層負責） |
| `scan_targets` | JSONB NOT NULL | 掃描目標。形狀 `{"hosts": "10.1.0.0/24, 10.1.0.5"}`——用物件包一層而非裸字串，保留未來擴充空間（如 per-assignment timeout）；執行展開時 merge 進該筆工單的 `tool_params`（assignment 值覆蓋綁定層同名鍵） |
| `sort_order` | INTEGER NOT NULL DEFAULT 0 | 列表顯示順序 |
| `is_delete` | BOOLEAN NOT NULL DEFAULT FALSE | 軟刪，沿用 repo 慣例 |
| `created_at` / `updated_at` / `created_user` / `updated_user` | 審計四欄 | 沿用 repo 慣例 |

索引：`(job_execution_detection_tool_id)`；~~唯一約束 `(job_execution_detection_tool_id, agent_uid) WHERE is_delete = FALSE`~~（**2026-08-24 CM-1379 推翻**：混合 OS 場景需要同一 agent 開多列各掃各基準，`uq_jedta_binding_agent_active` 已由 migration 移除。重複判準改由 BE 驗證層負責——「(agent_uid, scan_targets 內容) 不可完全相同」；刻意不做 JSONB DB 唯一，鍵序正規化與 null/{} 等價的成本高於收益，且該重複屬使用者輸入錯誤而非資料完整性風險）。

**per-列參數覆寫（2026-08-24 CM-1379 追加）**：`scan_targets` JSONB 除 `hosts` 外亦承載 **`profile`／`content_path` 覆寫鍵**——留空＝沿用任務層 `tool_params`，有值＝該列覆寫同名鍵。這使同一任務可混合 OS 各掃各基準（Ubuntu 列用 `ssg-ubuntu2404-ds.xml`、Rocky 列用 `ssg-rl10-ds.xml`）。連帶契約：**profile 參照（`profile:<uid>`）的展開必須發生在「綁定層與 assignment 層合併之後」**（見 §5.4）。

#### 5.2.2 新表 `detection_execution_groups`（執行群組）

**選型：獨立表**（而非只在 `detection_executions` 加 group_uid 自聚合）。理由：①收口只跑一次靠 **group 列鎖**（`SELECT ... FOR UPDATE` 需要一列實體可鎖，見 §5.3）；②彙總狀態與收口時間需要落地欄位（列表頁不必每次即時算 N 筆）；③「一任務最多一個 running group」守門查 group 表一列即可。

| 欄位 | 型別 | 說明 |
|------|------|------|
| `id` | BIGSERIAL PK | |
| `uid` | VARCHAR(36) UNIQUE NOT NULL | |
| `tenant_id` | BIGINT NOT NULL | |
| `org_unit_id` | BIGINT | |
| `job_execution_uid` | VARCHAR(36) NOT NULL | 隸屬的任務（比照 `detection_executions.job_execution_uid` soft-ref 慣例） |
| `status` | VARCHAR(20) NOT NULL | `running` / `succeeded` / `partial_failed` / `failed`（D4；cancelled 的筆歸入 failed 類計算，group 層不另設 cancelled 態） |
| `total_count` | INTEGER NOT NULL | assignment 組數（展開當下定格；fallback 單組＝1） |
| `closed_at` | TIMESTAMP | 收口時間（NULL＝未收口） |
| `is_delete` | BOOLEAN NOT NULL DEFAULT FALSE | |
| `created_at` / `updated_at` / `created_user` / `updated_user` | 審計四欄 | |

索引：`(job_execution_uid)`；partial index `(job_execution_uid) WHERE status = 'running' AND is_delete = FALSE` 支援「最多一個 running group」守門。

#### 5.2.3 既有表擴欄與補索引

- `detection_executions` 加兩欄：
  - `group_uid` VARCHAR(36)（soft-ref → groups.uid；**可 NULL**——舊資料不 backfill（D8），NULL＝group 化之前的 legacy 筆，讀取端視為單筆獨立顯示）；
  - `assignment_uid` VARCHAR(36)（soft-ref → assignment 子表 uid；**可 NULL**——fallback 自動挑機的隱含單組無 assignment 列，NULL＋group_uid 非 NULL＝fallback 組。重跑的「最新一筆」以此欄分組）。
- 補索引（缺口 11 順手還債）：`detection_executions(job_execution_uid)`、`detection_executions(agent_task_uid)`、`detection_executions(group_uid)`。

Migration 遵守 `sql-migration` skill 慣例：檔頭 `-- Date:`＋逐語句日期註解、新表 `GRANT ... TO cm_app`＋sequence 權限、收尾 `INSERT public.schema_migrations`、只套 DEV。兩張新表的 RLS 政策比照各自 schema 內既有 tenant-scoped 表（實作時對照 `job_execution_detection_tools` 與 `detection_executions` 現況照抄）。

### 5.3 group 狀態機與收口演算法（D4／D5）

```{.mermaid cap="圖 2 — group 彙總狀態機（D4 定案語意；重跑會讓 partial_failed/failed 回到 running）"}
%%{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'}}}%%
stateDiagram-v2
    [*] --> running : 執行展開（任一組 running）
    running --> running : 部分回報，仍有未終態
    running --> succeeded : 每 assignment 最新一筆全 succeeded
    running --> partial_failed : 任一 failed/cancelled 且無 running
    running --> failed : 全 failed/cancelled
    partial_failed --> running : 單組重跑（同 group 追加新 execution）
    failed --> running : 單組重跑
    succeeded --> [*] : 收口（auto 自動完成任務＋彙總通知）
```

**彙總計算（單一函式，所有入口共用）**：取 group 內「**每個 assignment 分組的最新一筆** execution」（`DISTINCT ON (assignment_uid) ... ORDER BY assignment_uid, id DESC`；fallback 組 assignment_uid 為 NULL、也只會有一條分組線），按 D4 規則歸態：任一 running → running；全 succeeded → succeeded；全 failed/cancelled → failed；否則 → partial_failed。

**收口判定與併發防護**：

1. 收口檢查放在 `on_scan_succeeded` / `on_scan_failed` **單筆終態寫入之後、同一交易內**；
2. 先 `SELECT ... FOR UPDATE` 鎖 group 列，再算彙總——N 筆併發回報時，後到者會等鎖，看到的是前者已提交的終態，**保證收口三件事只有「讓彙總離開 running 的那一筆」執行一次**；
3. 彙總結果非 running → 更新 group.status＋`closed_at`，然後做收口三件事：
   - **彙總狀態落地**（上一步）；
   - **auto 完成**（D5）：`completion_mode == 'auto'` 且彙總 == succeeded 才 `complete_job`；partial_failed／failed 不自動完成留人工。`complete_job` 只在收口路徑呼叫一次，**現況「多筆各自觸發撞 409 → agent 收 5xx」地雷就此解除**（`receive_result` 不吞例外的既有行為不必改）；
   - **彙總通知**（D6，§5.4）。
4. **重跑後的再收口**：單組重跑把 group.status 撥回 running、`closed_at` 清空；重跑那筆終態時走同一收口路徑重算。若重算為 succeeded 且 auto 模式 → 觸發 `complete_job`（**冪等防護**：呼叫前檢查任務現況，已完成則跳過不報錯——防「人工先完成了任務、之後重跑又成功」的邊角）。通知在每次收口都發（重跑收口再發一封彙總，屬合理行為：狀態確實又變了）。

**並行守門與冪等上移**：

- 「一任務最多一筆 running execution」→「一任務最多一個 running group」：`start_execution` 的 force 判斷與 `cancel_execution` 的 caller 改查 group（partial index 支援）；
- `_auto_dispatch_detection_scans` 冪等條件：從「該任務無任何 execution」改「該任務無任何 group」（D8 不 backfill，舊 execution 無 group——但舊資料所屬任務已跑過的情境在開發階段不存續，資料將重置，不做特判）。

### 5.4 執行展開與驗證（D3）

`start_execution` 改造：

1. 讀綁定下的 assignment 列表（`is_delete = FALSE`，按 sort_order）；**空列表 → fallback 隱含單組**：走修好的 `_pick_agent`（過濾 `enabled`＋未 revoke＋有 `detection_scan` capability＋在線，並加穩定排序如 `ORDER BY id`），目標用 `tool_params` 原值；
2. **逐 assignment 驗證**（D3）：agent 存在／enabled／未 revoke／capabilities 含 `detection_scan`／在線（`last_seen_at` 距今 < 900s）。**收集全部不合格項後一次擋下**，`BadRequestError` 帶新 error code（依 `GRC_400xxx` 命名規則新增），訊息逐台列「agent 名稱＋不合格原因」；
3. 全過 → 同一交易內：建 group（status=running、total_count=N）→ 逐 assignment 建 execution（帶 group_uid＋assignment_uid）＋工單（`agent_id` 釘該台；`params` 以綁定 `tool_params` 為底、assignment 的 `scan_targets` 覆蓋同名鍵）；
4. 之後各 agent 心跳領各自的單，回報鏈原樣。

**合併與展開順序（2026-08-24 CM-1379 修正，🔴 不變量）**：`_expand_profile_ref()`（`profile:<uid>` 參照 → `_profile` 凍結快照）**必須在合併之後**、下放到 `_dispatch_one` 以合併結果為輸入——`start_execution`／`rerun` 都不得預先在綁定層展開。原設計在合併前展開，分派列覆寫 profile 後第二列會拿 A 基準的檔案快照跑 B 基準的設定，且 B 的檔案被自家下載授權閘（`agent_file_access_service`）擋掉，**症狀是靜默掃錯基準不報錯**。有不變量測試守著（六個突變逐一被抓）。

**合併粒度細則**：單一目標型工具（param_schema 無 `hosts`，如 ZAP／SonarQube）的合併是「只忽略 `hosts` 鍵、保留其餘覆寫鍵」（`_effective_scan_targets`）——不是整包忽略，否則未來任何 per-列參數覆寫對這類工具永遠靜默失效。

**assignment 配對規則（寫入端 diff-sync）**：uid 優先；`agent_uid` 次要配對**僅限「該 agent 只有一列」時適用**（同 agent 多列後 agent_uid 不再是自然識別，任選會把兩列的覆寫設定張冠李戴），且同一批送來的多列不可配到同一既有列。

**per-列覆寫鍵的演進（CM-1382 transport，2026-08-25）**：`transport`（SSH／WinRM）納入
與 `hosts`／`profile` 同一套覆寫機制——合併與展開路徑**零改動**（`_dispatch_one` 本就是
同名鍵覆蓋整包 `scan_targets`），只補寫入端驗證。混合 OS 場景下同一個 CINC Auditor／GCB
任務的不同分派列因此能各走各的通道（60 網段 Linux 走 SSH、Windows 目標走 WinRM），
不必為每種 OS 各開一個任務。寫入端兩道驗證（`_validate_transport_overrides`，**錯誤碼刻意
分開因為要修的地方不同**）：該工具的 param_schema 沒有 `transport` 欄位（OpenSCAP／
OpenVAS／ZAP）→ `GRC_400109`；值不在該欄位 options 內 → `GRC_400110`（送個沒人認得的值
只會在 agent 端變成神祕失敗，錯在雲端卻只能翻 agent log）。驗證排在任何寫入之前——一列
不合法就整批不寫。schema 讀不到時放行（比照 `_tool_accepts_scan_targets` 判不出來時維持
原行為）。重複判準（`_scan_targets_fingerprint` 比對整包內容）自動涵蓋 transport，但
「agent 與目標都相同、只有 transport 不同」正是本卡核心場景，已補斷言釘住避免日後有人
把判準改回只比 `hosts`。

**掃描目標語法：CIDR 與末段範圍（CM-1383，2026-08-25）**：掃描目標欄原本只吃逗號分隔的
單一 IP，要掃一整段就得逐台列出；防火牆規則與既有掃描工具用的都是 CIDR 記法，使用者自然
會這樣填、填了卻被當成一台「名為 `192.168.50.0/24` 的主機」送出去。新增 util
`common/util/scan_target_spec.py`（`validate_spec` 驗語法／`count_spec` 算台數／
`expand_spec` 展開保序去重），支援三種語法混用（`192.168.50.0/28, 192.168.60.1-20,
192.168.70.5`），分隔符與 agent 端 `_normalize_hosts` 同一套。

🔴 **展開與否的判準是「欄位」不是「工具」**（實作時查 evidence-agent 源碼後**推翻卡片
原假設**）：Nmap 的 `hosts` **不是掃描目標，而是 agent SSH 登入去跑 nmap 的執行主機**
（`nmap.py:636` 逐台 SSH，檔頭明文「兩種主機不可混為一談」），`targets` 才是被探測的
目標——照卡片原寫的「Nmap 兩欄原樣透傳」做會讓 agent 去 `ssh 192.168.50.0/24`。真正的
變因是「這個欄位的值最後被 agent 拿去做什麼」：

| 工具 | 欄位 | 行為 | 理由 |
|---|---|---|---|
| CINC Auditor／GCB／OpenSCAP | `hosts` | **展開** | connector `for host in hosts:` 逐台建 SSH 連線 |
| Nmap | `hosts` | **展開** | 同上——這是執行主機清單，不是掃描目標 |
| Nmap | `targets` | 透傳 | nmap 原生解析 CIDR，併發探測遠比幾百個參數高效 |
| OpenVAS | `hosts` | 透傳 | 丟給 GVM 的目標規格，GVM 原生支援 |
| ZAP／SonarQube | — | 不適用 | 無 hosts 欄位 |

分流表放 `scan_target_spec.SSH_ITERATED_TARGET_FIELDS`（**BE 常數而非 param_schema
註記**——這是 agent 端 connector 實作行為的鏡射，放進 seed 資料等於讓 DB 宣告另一個 repo
的程式碼行為，兩邊不同步時沒有任何東西會報錯，症狀是靜默掃錯目標）。寫入端與派工端查
同一份表。

**CIDR 語意刻意與 nmap 不同**：`/24` 展開成 254 台而非 256——用途是逐台 SSH 登入，
network／broadcast 位址登入必定失敗、在報告留下兩筆假失敗。正因語意不同，原生支援的工具
才走「不展開」那條路。`/31`（RFC 3021）與 `/32` 例外沿用 Python `hosts()` 語意。

**驗證與上限（只在寫入端）**：任務層 `params.hosts` 與分派列 `scan_targets.hosts` **共用
同一支驗證**（兩處寫法不一致會讓同一個值在一處合法、另一處非法）。語法錯 → `GRC_400111`，
訊息帶**出錯的那一段原文**與原因；展開後超上限 → `GRC_400112`，**只對逐台登入型工具套用**
（OpenVAS 的 /16 是正常用法）。上限**用算的不用展的**（`count_spec`）——`/8` 展開成 1600 萬
個字串要 13 秒、2.5GB 記憶體，而這種輸入正是上限要擋的。新 env `DETECTION_SCAN_TARGET_MAX_HOSTS`
（預設 256＝剛好容納一個 /24；要掃更大範圍的正解是拆多列平行跑）。**派工端刻意不擋、只
展開**：擋在那裡使用者會在「按下執行」才看到幾天前存的設定有問題；存量資料或上限調小時
照常展開。跨段範圍（`192.168.1.1-192.168.2.5`）第一版不支援但**明確報錯而非放行**——落到
「主機名原樣放行」那條路會變成一台不存在的主機，使用者以為掃了一整段、實際上零台。

**agent 零改動**：展開後仍是逗號分隔字串，agent 端 `_normalize_hosts` 本就會再切一次。


`test_connection` 對齊（D9③）＋**可指定 agent（2026-08-24 CM-1377）**：request 加 `agent_uid` 可選——有帶走 `diagnose_dispatchability()` 驗可派性（不可派 400 一次列全原因，屬前置失敗不寫 `last_test_result`）；沒帶維持自動挑機（`get_dispatchable_agents()[0]`）。取機邏輯抽成 `_resolve_test_agent()` 供單台與逐台 probe 共用。回傳新增 `agent_uid`／`agent_name`，message 前綴標明由哪台執行測試。新 error code `DETECTION_TOOLS_400018`（msg 帶明細，FE 列入 `CODES_KEEPING_BE_DETAIL`）。FE 側見 §5.8.1。

### 5.5 單組重跑與取消（D7）

- **單組重跑** `POST .../detection-executions/groups/{group_uid}/assignments/{assignment_uid}/rerun`：
  - 前置驗證：group 存在且屬本租戶；~~group 非 running~~（**CM-1397 修訂，見下**）；該 assignment 的最新一筆為 failed/cancelled（成功的組不給重跑）；該台 agent 過 D3 同款驗證（不過則 400 同款訊息）；
  - 動作：同 group 下追加一筆新 execution＋新工單（沿用該 assignment 當下的 scan_targets）；group 已收口才撥回 running、清 closed_at（**CM-1397 修訂**）；

> **🔧 CM-1397 修訂（2026-08-25，驗收回饋）——前置條件從群組層收窄到 assignment 線層**
>
> **原設計**：`group.status != running` 才可單組重跑（`DETECTION_TOOLS_409012`）。理由是「還在跑的群組重跑，同一 assignment 會同時有兩筆 running，彙總的最新一筆語意會在兩筆間跳動」。
>
> **問題**：那個理由**只在同一條 assignment 線上成立**——線 A 還在跑不影響線 B 重跑。擋在群組層的代價是失敗組要乾等同群組的慢組（user 2026-08-25 實測：OpenVAS 掃一小時，同群組 SSH 認證失敗的組 30 秒就掛了，帳號修好後想立刻重跑被 409 擋）。
>
> **修訂後**：只看**該 assignment 線自己的最新一筆**是不是 `failed`／`cancelled`，不看群組整體狀態。原本要防的不變式由此承接——該線還在 `running`／`scheduled` 時最新一筆就不是終態，一樣被擋（改回 `409013`，該碼語意擴張為「該線不可重跑」，涵蓋 succeeded 與仍在跑兩種）。`409012` 自此**不再有任何 raiser，碼位保留不回收**（FE i18n 與 API 文件已收錄，回收後被重用會讓舊前端翻錯訊息）。
>
> **兩情境分流**（收口狀態的處理）：
> - 群組仍 `running`（其他線還在跑）→ 追加新筆即可，**不呼叫 `reopen()`**：狀態本就是 running、`closed_at` 本就是空，撥回是多餘的寫入。
> - 群組已收口 → 走既有 reopen 路徑撥回 running、清 `closed_at`。
>
> **🔴 收口併發**（本次修訂的核心風險）：重跑端在**建立新 execution 之前**先 `lock_for_close()` 鎖群組列，與收口端（`_close_group_if_terminal`）鎖同一列而被序列化，只剩兩種順序、皆安全：① 重跑先拿鎖 → 新筆提交後才釋放 → 收口的 `list_latest_per_assignment` 看得到那筆 running → 彙總算出 running → **不誤收口**；② 收口先拿鎖 → 群組落終態 → 重跑讀到收口後狀態 → 走 reopen。判斷用的群組狀態**必須取自鎖到的那份**，不可用交易早期 `get_one()` 的無鎖快照——否則會出現「一筆 running 掛在 closed 群組下」（畫面顯示已結束、掃描還在跑，`closed_at` 停在舊時間）。測試見 `test_detection_multi_agent_dispatch.py::TestRerunAssignment`（鎖序、快照 vs 鎖、併發不誤收口、重跑落終態後照常收口）。
>
> **不動的部分**：整組取消、「一任務最多一個 running group」守門——那是防並發疊加，與本卡不同軸。
  - fallback 隱含單組（assignment_uid=NULL）同樣可重跑：path 以保留字 `_default` 表達或另設 `groups/{group_uid}/rerun-default`（plan 階段定其一）。
- **單組取消** `POST .../groups/{group_uid}/assignments/{assignment_uid}/cancel`：打該台 agent 的 cancel API；可達→標 cancelled、不可達→409 該組維持原態；成功取消後以彙總函式重算 group（可能落 partial_failed/failed）。
- **整組取消** `POST .../groups/{group_uid}/cancel`：逐工單打各自 agent 的 cancel API，**best-effort**——可達的都取消標 cancelled、記錄不可達清單；**全部不可達才回 409**。此語意比現況寬鬆（現行單台打不到就 409 完全不標記），多台情境一台失聯不可卡死整組取消。取消完成走同一收口路徑（彙總會落 failed／partial_failed，不觸發 auto 完成、照發彙總通知）。

### 5.6 API 契約

| 端點 | 動作 | 形狀要點 |
|------|------|----------|
| 綁定讀寫（既有 DetectionToolBindingSchema，`api/grc/serializers/job.py:159-173`） | 擴充 | 加 `agent_assignments: [{uid?, agent_uid, scan_targets, sort_order, scheduled_at?}]`（可缺省＝不設 assignment；`scheduled_at` 為 D10 排程欄位，空＝立即）。寫入端 diff-sync：帶 uid＝更新、無 uid＝新增、缺席＝軟刪；讀取端每列 enrich `agent_name`＋`agent_online`。`unknown=EXCLUDE` 慣例照舊 |
| `GET /remote-agents`（D9①） | 擴充 | 每列加 `capabilities`（陣列）＋`is_online`（bool，last_seen_at < 900s）＋`last_seen_at`；query param `capability=detection_scan` 過濾。既有 caller（storage-config 下拉）不受影響（加欄不減欄） |
| `start_execution`（既有） | 改造 | 成功 response 回 group（uid／status／executions 摘要）；驗證不過 400 帶逐台原因（§5.4） |
| `POST .../groups/{group_uid}/assignments/{assignment_uid}/rerun` | 新增 | §5.5 |
| `POST .../groups/{group_uid}/assignments/{assignment_uid}/cancel` | 新增 | §5.5 |
| `POST .../groups/{group_uid}/cancel` | 新增（取代單筆 cancel 的對外語意） | §5.5 |
| `POST .../groups/{group_uid}/assignments/{assignment_uid}/start-now` | 新增（D10） | 排程中的組催跑：清 `scheduled_at` 成 now（§5.9.3-4） |
| 執行紀錄列表（既有） | group 化 | response 改兩層：`groups: [{uid, status, closed_at, executions: [{...既有欄位, agent_uid, agent_name, assignment_uid, scan_targets, is_latest}]}]`；`agent_name` 在 app service 層批次查 enrich（比照審計欄位 enrich 慣例，不在 infra JOIN）；legacy 筆（group_uid NULL）以「單筆自成一組」形狀回傳，FE 不需分叉。**卡片分組鍵唯一（2026-08-24 `b205e07a` 修正）**：「這筆屬於哪張卡」與「is_latest 以哪條線算」是同一分組概念的兩面，統一抽 `_execution_card_key()` 由 `_group_executions()` 與 `_latest_execution_uids()` 共用——原本兩處各寫一份鍵，legacy 筆（兩欄皆 NULL）被算成同一條 latest 線導致整批舊紀錄從畫面消失。回歸測試 `test_every_card_has_at_least_one_current_row` 鎖「每張卡至少一列當前」不變量 |

新增 error code 依 `GRC_<HTTP 狀態碼><序號>` 規則落 `common/code/` 對應檔；權限檢查沿用檢測執行既有守門（app service 層，經 domain service）。

### 5.7 彙總通知（D6）

`_notify_scan_result` 改為收口時呼叫的 `_notify_group_result`：標題帶任務名＋整組狀態；內文表格每 assignment 一列（agent 名稱／掃描目標／狀態／發現數 findings，取該 assignment 最新一筆）。三通道（Email／Telegram／Discord）同步改。單組取消/重跑本身不發通知，只有收口發。

### 5.8 FE 兩處 UI

#### 5.8.1 任務設置（ProjectPlanningView 檢測工具綁定區）

> **本節描述 FR-067.8 T-8.2 落地後的現況形狀（2026-08-25）。** 中間演進（驗收第一輪
> CM-1375 五波＋CM-1377／1379、第二輪 CM-1382／1383）留在本節末「演進沿革」，其中被
> 取代的部分已明確標示——避免讀者拿中間形狀當現況。

**現況形狀：每列＝一份完整獨立的工作設定**（§5.10）。選完檢測工具後，畫面上只有
「Agent 工作指派」清單與任務層的 `completion_mode`；**任務層掃描參數區已整塊移除**。

**版位**：「Agent 工作指派」區塊位於「檢測工具」下拉**正下方**（先決定哪台去掃、掃哪段，
再填該列參數），其下只剩完成模式。

**每列的形狀（摘要列＋inline 展開卡片，CM-1382）**：可設定項在每列自足之後已是整份
param_schema，全攤平在一行會擠成一片、多列並排更看不出哪列在做什麼，故：

- **收合態一行摘要**：agent 名稱／目標（多台顯示「N 台」，含 CIDR 或範圍時顯示「N 段」——
  **FE 刻意不算展開後的總數**，展開規則是 BE 的分流邏輯，在 FE 複製一份就是第二套真相）／
  transport 與 profile chip／排程 chip（× 可移除）／**複製此列**鈕／刪除鈕。
- **展開態**：以 `DetectionConfigField` 渲染**整份 param_schema**——condition 依**本列**參數
  求值、secret 欄 per 列顯示「已設定」、profile 庫動態選項、advanced 分區照舊。標籤定寬對齊；
  窄畫面（<640px）標籤改置上。
- 🔴 **展開用 `v-if` 不用 `v-show`**（PrimeFlex utility class 帶 `!important` 會壓過 v-show 的
  inline `display:none`，CM-1375 第五波實錄）。也不用 Accordion——摘要列要放下拉／chip／按鈕。
- **新增一組＝新列直接展開**（明確寫進狀態，不靠「沒選 agent 就展開」的預設——只有一台
  在線 agent 時新列會自動帶入 agentUid，那個預設不成立）。
- **存檔驗證沒過的列強制展開＋整列紅邊**：錯誤欄位藏在收合區裡的話，toast 說「第 2 組有
  問題」但畫面上那組看起來一切正常。錯誤標記是 **per-列布林陣列**，紅框只落在出錯的列。

**重複填寫的解法（§5.10-5）**：「複製此列」鈕（新列以該列為底）＋**新增列預設帶第一列的
參數值**（非空白表單）。**secret 欄刻意不抄**——抄 key 名會讓新列誤顯示「已設定」，使用者
不填就存進空密碼。

**最少一列**：剩一列時刪除鈕 disabled；**agent 留空＝執行時自動挑一台在線的**（合法值，
D8 的便利性保留、載體改變）。原「空列擋存檔」與 `agentAssignmentErrors` 旗標隨之退役。

**逐列驗證**：condition-aware 必填（判準與 BE `detection_assignment_params.field_visible`
同源）＋ `project_key_suffix` 跨列唯一（兩列落同一 SonarQube 專案會互相覆蓋結果）。
對應 `GRC_400113`／`GRC_400114`；`400113` 的 BE msg 帶「第 N 列（缺哪些欄位）」，故列入
FE 的 `CODES_KEEPING_BE_DETAIL`（翻譯當抬頭、BE msg 當明細併陳）。

**存檔時 `tool.params` 刻意整個不送**：BE 對未帶欄位是 None-skip，而任務層那份現在只剩
兩個作用——存量資料的讀取端合併底，以及承載 `_source_file` 這類內部 sticky 欄位。送 `{}`
會把後者清掉，症狀是 upload 任務重掃時找不到源碼包，且要到執行當下才會發現。

**掃描目標欄的語法說明（CM-1383）**：目標欄下方給三種寫法的說明（放欄位下方而非
placeholder——placeholder 已被更即時的資訊佔用，且一輸入就消失）。工具參數區那格的說明
走 `param_schema.hint`（BE migration `f10e45eb`），措辭依 BE 展開分流分三段：逐台登入型
（講上限與「拆成多組分派」的出路）／Nmap `hosts`（措辭刻意與上段分開，強調是**執行主機**
不是掃描目標）／原生透傳型（**刻意不提台數上限**，提了會讓人以為也受限）。兩處措辭一致。

**測試連線指定 agent**（CM-1377）：測試連線對話框加 agent 下拉（資料源
`GET /remote-agents?capability=detection_scan`；離線機照列標「（離線）」disabled；首項
「自動選擇」＝原行為）。開窗門檻＝可選 agent >1 台。成功訊息帶執行 probe 的 agent 名稱。

**profile 欄為純下拉**（openscap／inspec／gcb 三支，CM-1375 第四波）：原 `select_or_text`
手填實務上只產生錯誤輸入（PrimeVue editable Dropdown 打字不過濾選項；openscap 的 XCCDF
profile id 手打幾乎必錯），決策者拍板改 `select`。migration 只改 param_schema 的 `type`，
FE 零改（兩分支共用同一份 selectOptions）。

**同 agent 多列**（CM-1379）：下拉不反灰已選走的台（原「同 agent 不可重複」約束已推翻）；
BE 擋的是「(agent, 列 params 內容) 完全相同」的重複列。

##### 演進沿革（已被取代的中間形狀，保留供讀 commit 歷史時對照）

| 階段 | 當時形狀 | 現況 |
|---|---|---|
| .3 初版 | 每列只有 agent 下拉＋目標輸入 | 已擴成整份 param_schema |
| CM-1375 第二／三波 | 「掃描目標單一入口」——hosts 型工具有分派列時**參數區隱藏 `hosts` 欄**（`isHostsOwnedByAssignments`），零列時 hosts 欄照舊顯示 | **整個任務層參數區已移除**，`isHostsOwnedByAssignments` 這類「哪一邊擁有這個欄位」的判斷隨之消失 |
| CM-1375～1382 覆寫期 | 每列只放要覆寫的鍵（`hosts`／`profile`／`content_path`／`transport`），留空＝沿用任務層，placeholder 明示「留空沿用：<值>」 | **「留空沿用」語意全數移除**（清 24 支死 i18n key）；列上就是完整值 |
| .3～T-8.1 | 零列＝自動分派 fallback（BE 隱含單組）；「預設備妥一組」只補畫面不寫 DB | 最少一列；**自動挑機改由「列上 agent 留空」承載**，存量零列綁定讀取時合成一列 |
| CM-1375 第三波 | 目標欄條件渲染：依 param_schema 有無 `hosts` 判斷（`supportsScanTargets`，不寫死工具清單），單一目標型（ZAP／SonarQube）該列只剩 agent 下拉 | 同一精神保留，但改由渲染整份 param_schema 自然達成（該工具沒有的欄位本就不會出現） |

**順手修掉的既有缺陷（CM-1382）**：per-列 UI 狀態（展開／進階／排程 picker）原以 index 當
key，**刪掉中間一列後後面每列的 index 都減一**，狀態會落在錯的列上（原只影響進階區與
picker，展開做成主要形狀後一眼可見）→ 補 `shiftRowUiStateAfterRemoval()`。另 `GRC_400107`
的 FE 文案還停在 CM-1379 放寬前的語意（「不可重複指派同一台 Agent」），與 BE 現行判準
（整列完全相同才擋）不符，一併更正。


#### 5.8.2 執行紀錄（JobExecutionDrawer）group 化

以 group 為卡片單位——群組狀態徽章（succeeded 綠／partial_failed 黃沿 CM-952 語彙／failed 紅／running 動畫）＋收口時間；卡片內每 assignment 一列（agent 名稱／目標／狀態／報告連結），失敗列帶「重跑」鈕、running 列帶「取消」鈕，卡片層帶「整組取消」；重跑歷史（非最新筆）收合顯示（**收合用 `v-if` 不用 `v-show`**——PrimeFlex `.flex` 帶 `!important` 會壓過 v-show 的 inline display，CM-1375 第五波實測）。legacy 單筆照舊單卡呈現。

**失敗訊息品質**（CM-1378，agent 0.2.29）：agent 端 `format_failure_detail()` 把 stderr（優先）／stdout 收進失敗訊息（截尾段 500 字）——command not found／Permission denied／content 檔不存在這類原因全在 stderr，原本排空即丟導致 UI 顯示「執行失敗（exit=1）：」後一片空白，每次都要上目標機重現才能定位。同卡順修 Rocky content 檔名推斷：ComplianceAsCode 的 product 名是 `rl` 不是 os-release 的 `rocky`，自動推檔名改雙軸表（前綴, 版本格式）。

**「檢測設定」卡多組時改逐組分塊**（CM-1396，2026-08-25，FE `45bd3ab` 單檔）：T-8.2 當初把檢測設定卡做成**跨列彙整**（同欄位各組值不同就「／」併陳、主機清單取聯集）——各組同構時讀得通，SonarQube 混模式（一組 scan、一組 upload）直接打穿：「執行模式：上傳原始碼壓縮包／原始碼掃描」下面掛一個 Git 網址，看不出**哪一組是哪個模式、網址是誰的**。併陳的能力上限就是「講得出有哪些值、講不出誰是誰」，值一旦不同構就失效。改法：

- **多組任務一組一小塊**：組號＋agent＋該組關鍵參數＋該組目標清單＋該組技術參數摺疊區（各自開合）。工具名稱與完成模式留卡片頂部（任務級資訊，不屬於任何一組）。**單組任務維持原單框版型**——只有一組時「第 1 組」是純噪音。
- **逐組參數過 condition**（與設定頁擋門、BE 逐列驗證同一套規則）：SonarQube 的 Git 網址只在 scan 模式有意義，upload 組上可能躺著切換模式前的殘值，照列會讓執行者以為那組也要掃該 repo。
- **上傳槽與擋門訊息改標「第 N 組（辨識參數）」**（共用同一支函式）：agent 名不是穩定識別——同 agent 可多組、自動挑機的組沒有名字。括號放該組模式＋模式專屬識別欄位（Git 網址／專案代碼／後綴）；**刻意不放 profile**（基準名太長會擠掉真正要區分的）。無辨識參數退回 agent 名，再無只留組號。🔴 **組號用「全部分派列裡的全域序號」**，不是 upload 列自己的內部序號——否則混模式下上傳槽說「第 1、2 組」、檢測設定卡說「第 2、4 組」，兩畫面對不起來。
- 逐組目標清單與 CM-1395 顯示原則對齊（原始寫法標籤＋真實台數，快照過期退回段數）；逐組呈現後**不再跨組取聯集**——聯集正是「看不出誰是誰」的來源。
- BE 零改動（per 列完整參數 T-8.1 已回傳，本卡純呈現層）。

### 5.9 per-assignment 排程執行（D10，2026-08-24 追加）

#### 5.9.1 資料模型增量

- `job_execution_detection_tool_agents`（assignment 子表）加 `scheduled_at TIMESTAMPTZ` **可空**（NULL＝立即）。排程跟著「agent＋掃描目標」一起設定在同一列，不另立入口。
- `agent_tasks` 加 `scheduled_at TIMESTAMPTZ` 可空——執行展開時**從 assignment 快照**進工單（fallback 隱含單組恆為 NULL）。
- `detection_executions.status` 新增 **`scheduled`** 狀態：排程等待中（不顯示 running 誤導使用者「已經在掃了」）。彙總函式 `compute_group_status` 視 `scheduled` 為**未終態**（等同 running 效果：group 不收口）；group 層狀態不另設 scheduled 態，彙總仍為 running（列表彙總文案由 FE 依明細組成，見 §5.9.4）。

#### 5.9.2 核心機制：「group 照常立即建，延後的是 agent 領得到單的時間」

按「開始執行」當下**照常**走 §5.4 全流程：D3 逐台驗證（人在場當下就知道哪台不合格）→ 建 group＋N 筆 execution＋N 張工單。差別只有一處——**agent 心跳領單查詢加 `scheduled_at IS NULL OR scheduled_at <= now()` 條件**，沒到時間的單 agent 看不到；時間到後下一次心跳自然領走。收口邏輯**零改動**（scheduled 筆未終態，group 不會提前收口）。**不用 APScheduler、無背景 job**——也因此沒有背景 job 讀 tenant-scoped 設定的 context 坑。

**語意定案**：

- `scheduled_at` 是**絕對時間**；啟動當下時間已過＝立即可領（不報錯不擋）。
- 跑完後**再次整組執行**＝全部照當下 assignment 的 `scheduled_at` **重新判定**——過去式時間自然等於立即，語意自洽，**不清欄位**（使用者設的維護窗設定保留，要改自己改）。
- **單組重跑一律立即**（重跑是人當下按的，不套排程）。
- **週期性排程本版不做**，`scheduled_at` 單值欄位留未來擴充空間。

#### 5.9.3 連帶調整（四件）

1. **execution 的 scheduled 狀態**：展開時 assignment 帶未來 `scheduled_at` → 該筆 execution 以 `scheduled` 建立；agent 實際領走單時轉 running。彙總函式視同未終態。
2. **取消排程中的組＝短路**：單還沒被領走，**直接標 cancelled 不打 agent** 的 cancel API（打了也沒東西可取消）；判定條件＝該筆為 scheduled 且工單未被領取。
3. **「一任務最多一個 running group」守門窗變長**：排程等待期間 group 是 running（含 scheduled 筆），**不能再按執行**；要重來就先取消整組——UI 必須表達這個狀態（見 §5.9.4 等待端）。
4. **「立即開始」端點**：`POST .../groups/{group_uid}/assignments/{assignment_uid}/start-now`——把該工單（與 execution 快照）的 `scheduled_at` 清成 now，下一次心跳即領走。排程中的組可催跑，不必取消重來。

#### 5.9.4 UX 規格（user 拍板）

原則：**排程是例外，用明確開關表達，不用空值表達**；立即 vs 排程用「形狀」一眼可辨，不依賴說明文字。

1. **設定端（任務設置分派列）**：預設**看不到**時間欄位。每列尾一個「⏱ 排程」icon button；按了彈 datetime picker（**預設給有意義起點**如當晚 22:00，不給空白），選定後該列顯示實心 chip「⏱ 8/25 02:00」帶 × 可移除（移除＝立即）。立即 vs 排程用「有無 chip」的形狀判斷。
2. **執行端（按開始執行的確認框）**：有任何排程組時，確認框列清單——每組一行「agent（目標）＋ 立即／⏱ 時間開始」；時間已過顯示「立即」，不顯示過期時間。
3. **等待端（執行紀錄）**：排程中的組獨立狀態徽章「⏱ 排程中」（**中性色**，與 running 的動態感區隔）＋時間；group 彙總列如「2 組：1 執行中、1 排程於 8/25 02:00」；排程中的組提供「立即開始」快捷鈕＋「取消」。

### 5.10 每列自足模型（FR-067.8，2026-08-25 拍板並全數落地）

> **狀態：T-8.1／T-8.2／T-8.3 皆已實作**（BE `5ca869e7` 內含 T-8.1、`fda3921b`＝T-8.3；
> FE `da33373`＝T-8.2、`bb23efe` error code、`a4c5ab4`＝T-8.3）。本節描述**實況**，
> 原設計定案的措辭已就地更新；實作時推翻的前提在各點下方標示。

**心智模型翻轉：從「任務層參數＋分派列覆寫」改為「每列＝一份完整獨立的工作設定」。**

**為什麼改**：覆寫模型是演化出來的（.3 建任務層 → CM-1379/1382 逐鍵開洞），每開一鍵多一條
「留空沿用什麼」說明，使用者看一列要心算兩層合併；且 SonarQube 多模式（pull/scan/upload）
在覆寫語意下開放成本高——「一列 pull、一列 upload」在任務層單一 `scan_mode` 的形狀下**根本
表達不了**。業界主流（Nessus/Qualys/InsightVM、AWX、防火牆規則表）的形狀是「每條 job spec
自足＋重用靠引用庫」——我們的 profile 庫（FR-059）正是那個「可重用參數包」。

**落地內容（逐點對照實作）**：

1. **每列自足**（T-8.1／T-8.2 ✅）：選完工具後直接是「Agent 工作指派」清單，每列＝agent＋
   該工具**完整 param_schema 表單**。任務層共用掃描參數區收掉，**任務層只留 `completion_mode`**。
   **欄位名沿用 `scan_targets` 不改**（DB column 與 API wire key 皆是）——承載內容從「覆寫
   子集」擴張成「完整列 params」是語意擴張不是形狀改變（一直都是 JSONB dict），改名要動
   migration ＋ FE ＋ control-tree raw SQL ＋ e2e factory，收益只有名字好聽。

2. **secret 跟著列走**（T-8.1 ✅）：`secret:true` 欄位（ZAP 登入密碼等）存列 params，
   FR-058 的加密剝除機制對到列上（DB 密文、API 剝除、FE `secret_keys_set` per 列顯示
   「已設定」）。**順序上必須先加密沿用再驗必填**，否則「已設定過的密碼留空送回」會被判成
   缺漏（`job_service._validate_assignment_required_fields` 的呼叫點有守）。

3. **零列 fallback 廢掉**（T-8.1／T-8.2 ✅）：最少一列；「自動挑機」語意改為**列上 agent
   留空＝執行時自動挑一台在線的**（D8 便利性保留、載體改變）。存量「零列靠任務層」的綁定：
   **讀取端合成一列**（`rows_for_read()`，合成列沒有 uid，FE 原樣送回時 diff-sync 走新增
   分支，使用者第一次存檔時真正落庫——語意正是「把隱含的 fallback 顯性化」）。

4. **SonarQube 多包混掃**（T-8.3 ✅）：每列自己的 `scan_mode` 與對應欄位；**upload 列各自的
   檔案槽、各自 sticky**。per-列 `project_key_suffix` 驗跨列唯一（`GRC_400114`）。自動派發
   D33 細到列——原本只看任務層 `tool_params`，會漏掉「任務層 pull、某列 upload」的任務而
   判成可自動派，派下去必然打中首次無檔 400，該錯誤被 try/except 吞掉只留 warning，且失敗
   不產生執行紀錄使冪等條件永遠成立，**每按一次「開始執行任務」就再靜默失敗一次**。
   擋門逐列且**擋在建 group 之前**（零工單零執行紀錄，與 D3 逐台驗證同一立場），訊息帶
   「第 N 列（agent 名）」。execute 收 `source_files`（`{assignment_uid: {uid}}`，fallback
   用保留字 `_default`）；舊的單槽 `source_file` 保留給無分派列的 fallback 路徑，兩者並存。
   單組重跑沿用**該列自己**的包。**agent 零改動**——憑工單 uid 逐工單下載本就獨立，授權
   resolver 讀的是該工單自己的 params。

5. **重複填寫的解法**（T-8.2 ✅）：①每列「複製此列」鈕；②新增列預設帶第一列的參數值
   （非空白表單），**secret 欄刻意不抄**（抄 key 名會讓新列誤顯示「已設定」，使用者不填
   就存進空密碼）。不做跨任務參數組庫（過度設計；反悔條件：客戶抱怨跨任務重填同一組參數
   時再評）。

6. **執行層零改動**（✅ 如預期）：group／展開／收口／彙總通知／重跑取消全部不動——改的是
   設定層形狀不是執行機制。BE 展開簡化為「直接拿該列 params 派工」（profile 參照展開照舊
   在派工時做）。agent 零改動。

7. **未來流程引擎對接**：每列自足＝一列一份完整 job spec，天然對應流程節點／multi-instance
   展開；執行層本就獨立於 flow-engine complete 鏈路，對接不需重構。

**存量遷移（讀取端相容，不做 DB backfill）**：canonical 是
`common/util/detection_assignment_params.py`——`merge_row_params()`（任務層墊底＋列蓋上）／
`rows_for_read()`（零列合成一列）／`field_visible()`＋`missing_required_fields()`（逐列
condition-aware 驗證）。**合併對新資料是恆等運算**（完整列 params 已涵蓋任務層每個鍵），
故讀取端與派工端共用同一支函式、兩邊不會漂移。開發驗證階段資料會重置（D8 同款立場）。

**實作時推翻／修正的前提（非推測，皆為實跑抓到）**：

- 🔴 **內部欄位（`_` 前綴）的歸屬規則被 T-8.3 推翻**：T-8.1 當初讓**任務層的內部欄位永遠
  獲勝**，理由是「列上不該有、FE 也送不出」。upload 源碼包 per 列化之後這個前提不成立——
  `_source_file` 現在是 BE 自己寫在**列**上的 sticky 參照。仍讓任務層獲勝的話兩列會被綁在
  同一包上，症狀是「A 列上傳的包，B 列重掃時也用它」**而且畫面完全正常**。改為與一般鍵
  同規則「列上有就列上贏」；T-8.1 的不變式測試改寫成鎖新契約（不是刪掉），並補「存量列
  仍由任務層墊底」的回歸。
- **「FE 不得覆寫內部欄位」改由寫入端保證**：`strip_internal_keys()`（剝掉 FE 送來的 `_`
  前綴鍵）＋ `internal_keys_of()`（保留該列既有的）。寫入端分得出「誰送來的」，合併端
  分不出。**兩者缺一都出事**——列 `scan_targets` 是整包覆蓋寫入，不主動保留的話使用者在
  任務設置改任何一個無關欄位按儲存，都會把該列的 `_source_file` 清掉。
- **`strip_secret_params` 只認加密 envelope、不管 `_` 前綴**，故 sticky 會原樣進設定畫面並
  被 FE 當一般參數回送。兩條讀取路徑（`GrcJobToolDto`／規劃頁 control-tree raw SQL）都補上
  剝除，改以不帶底線的 `source_file`（`{uid, file_name}`）表達「這列沿用哪一包」，形狀與
  執行紀錄快照一致。
- **`JobExecutionDrawer` 有四處讀任務層 `tool.params`**（上傳模式判定、檢測設定顯示、執行
  主機／掃描目標清單），參數搬到列上後新任務那裡會全部空白且不報錯，看起來像「這個任務
  沒設定」→ 改成讀各分派列（跨列彙整：值不同併陳、主機清單取聯集），零列時退回任務層
  走存量相容。


### 5.11 掃描目標 range 的顯示配套（CM-1395，2026-08-25／26 驗收回饋）

CM-1383 引入 CIDR／range 語法後，顯示端三個洞：分派列摘要只講「N 段」不講台數、執行紀錄把展開後的逐台清單整牆印出（`/24`＝254 個 IP 並排）、未掃到警示字在亮色主題白底淡黃不可讀。分兩輪落地（BE `571b5b9e`／`f630282d`／`1be843a7`；FE `8a64c1f`／`341a09d`／`6aefb34`）。

**① 台數由 BE 供，FE 不算**（維持 CM-1383「不要第二套展開規則」的立場，只是資料來源從「FE 推算」換成「BE 算好送過來」）：

- `common/util/scan_target_spec.py` 的 `count_spec` 加 `whole_network` 參數表達 **CIDR 兩種語意**：展開欄位算可用主機（`/24`＝254，與實際展開一致）、透傳欄位算整段（256，工具實際會處理的量）；range 與單一位址兩種語意同數（無歧義）。
- `display_counts_of()` 為**唯一入口**：掃 `hosts` 與 `targets` 兩個目標欄位（`TARGET_SPEC_FIELDS`），CIDR 語意依工具展開/透傳分流。**「算不算」一律算、「CIDR 怎麼算」才分流**——第一輪只對展開欄位給台數（理由是透傳欄位台數語意由工具決定），但那個顧慮只在 CIDR 成立，range `161-166` 就是 6 台無歧義；整個透傳欄位排除等於為 CIDR 的歧義犧牲無歧義的 range（補洞輪修正）。
- 逐台寫法（`10.0.0.5, 10.0.0.6`）不給值——段數本來就是台數，多回一份只是讓 FE 兩條路徑做同一件事。
- 分派列 DTO 補 `scan_target_spec`（原始寫法字串）——它同時是 **FE 過期防護**的依據：FE 把 spec 字串與畫面現值比對，使用者改了目標未存檔時不再顯示過期台數（退回段數原樣顯示）。

**② 執行紀錄印原始寫法＋台數，不再 IP 牆**：

- 來源是**派工當下的快照**（工單 params 的 `_scan_target_spec`，經 `_visible_scan_params` 轉出）——執行紀錄忠於「那一次到底掃了什麼」，不回查分派列現在的設定（與 `scan_targets` 既有立場一致）。**快照優先、無快照就地算**：展開欄位 DB 裡已是逐台清單，原始寫法只有快照留得住；透傳欄位存的就是原始寫法，直接算即可（存量舊紀錄因此不重跑也能正確顯示台數）。工具 code 批次取（避免列表頁 N+1，有回歸測試守著）。
- 執行紀錄改回傳 **`targets` 欄位**（`1be843a7`，原本只挑 `hosts`）：nmap 的 `hosts` 是跳板機、`targets` 才是被掃網段，只帶 `hosts` 讓顯示端根本沒有掃描目標可講（user 實測誤以為網段沒帶到）。**BE 不預判哪個是「目標」**（呈現決策留給 FE）；params 無 `targets` 的工具不多出鍵，行為不變（測試明文鎖住）。
- FE 抽 `execTargetField()` 共用（執行紀錄列台數 chip／詳情視窗 hosts+targets 區塊／任務資訊區標題）：有 spec 印一個原始寫法 Tag＋BE 真實台數，無 spec 維持逐台。**刪掉 `execHostList`／`execTargetList`**（零 caller 的第二套讀法——只回清單、台數由 caller 數長度，正是「range 說成 1 台」症狀的來源）。列上講**真正被掃的欄位**（有 `targets` 講它，沒有講 `hosts`；判準看資料不看工具清單——「有 targets」等同「這支工具把執行主機與掃描目標分兩欄」，寫死清單會在下一支同形狀工具進來時靜默出錯）；台數 chip 同欄位同 param_schema 短名，避免上面講網段、下面數跳板。逐台工具顯示完全不變。`['hosts','targets']` 收斂成 `TARGET_PARAM_KEYS` 一份（原三份同義副本），與 BE `TARGET_SPEC_FIELDS` 對應。
- 順手：執行詳情長 label（用法寫進標題共 32 字撐爆兩欄 grid）改 `paramShortLabel` 短標籤＋括號段掛 ⓘ tooltip。

**③ 警示字配色 theme-aware**：未掃到主機警示原寫 PrimeVue `--yellow-400`（亮色主題是 `#fccc55` 白底淡黃不可讀），改吃本專案 token `--color-orange`／`--color-red`（hint-bar warn／error 同一套語彙，兩主題都有對比），四處 inline 色碼抽成 `.exec-warn-text`／`.exec-error-text` 兩個 class。

**⚠️ 順帶發現待決策（未開卡）**：188 驗收環境有 Nmap 分派列設定填 `{"hosts": "192.168.50.0/24"}`——對 nmap 而言 `hosts` 是逐台 SSH 登入去執行 nmap 的機器，實際行為是「登入 254 台、每台各掃一次目標網段」，幾乎確定不是本意。屬**欄位標籤 UX 問題**（兩欄名字分不出誰是誰），非顯示 bug，本卡未動。

---

## 6. 拆分（7 子需求 × 16 子任務） {#split nav="拆分"}

依賴鏈：**.1（地基）∥ .2（資料層）先行 → .3（綁定設置）／.4（展開收口）→ .5（呈現與通知）→ .6（收尾）**；**.7（排程）依賴 .2（子表）與 .4（展開/取消鏈），與 .5 平行，但 T-7.2 與 T-5.3 同一棒實作**（同動 JobExecutionDrawer）。規模：S≈半天內、M≈1–2 天、L≈3 天+。

### FR-067.8 每列自足模型（§5.10）— 依賴：.3／.5（2026-08-25 追加，**三卡全數完成**）

| # | 子任務 | 驗收 | 依賴 | Repo | 規模 |
|---|--------|------|------|------|------|
| T-8.1 ✅ | BE：列 params 完整化——讀寫 schema 改存完整列 params、存量讀取端合併（任務層墊底＋零列合成一列）、展開直取列 params 不合併、secret per 列（加密/剝除/`secret_keys_set` 對列）、驗證改逐列 condition-aware（含 `project_key_suffix` 唯一）、agent 留空＝自動挑機 | 存完整列讀回完整列；舊資料（含零列）讀出合併正確；展開派工 params 與列一致；secret 列上加密不出 API | .3/.5 | BE | M |
| T-8.2 ✅ | FE：任務層掃描參數區收掉（留 completion_mode）、展開卡片渲染整份 param_schema（DetectionConfigField 復用，condition/secret/profile 動態選項照舊）、複製此列鈕＋新列帶第一列預設值、最少一列擋刪最後一列 | 每列所見即所得；SonarQube 各列可選不同 scan_mode；複製列/預設值生效；build 過 | T-8.1 | FE | M~L |
| T-8.3 ✅ | SonarQube upload per 列：抽屜上傳區改逐 upload 列各一檔案槽、各自 sticky、execute per 列帶 uid、擋門逐列（upload 列無檔不給執行）、D33 自動派發判斷細到列 | 兩個 upload 列各上各的包、各自重掃沿用；混 pull/scan/upload 一次執行全組展開 | T-8.1/T-8.2 | BE+FE | M |

### FR-067.1 地基：Agent 清單 API 補齊 — 依賴：無（可先行）

| # | 子任務 | 驗收 | 依賴 | Repo | 規模 |
|---|--------|------|------|------|------|
| T-1.1 | `GET /remote-agents` 補 capabilities＋is_online＋last_seen_at＋`capability` 過濾參數；spec 坑 10 一併結案 | API 回傳含三欄；帶 `capability=detection_scan` 只回有該能力的；既有 caller（storage-config）迴歸不壞 | — | BE | S |
| T-1.2 | `_pick_agent` 修好：過濾 enabled／未 revoke／有能力／在線＋穩定排序；`test_connection` 對齊同一選擇邏輯 | 停掉唯一在線 agent 後執行 fallback 任務 → 明確報錯而非派給離線機；test_connection 挑的與執行挑的同一台 | — | BE | S |

### FR-067.2 資料層：assignment 子表＋group 表 — 依賴：無

| # | 子任務 | 驗收 | 依賴 | Repo | 規模 |
|---|--------|------|------|------|------|
| T-2.1 | migration：`job_execution_detection_tool_agents`＋`detection_execution_groups` 新表、`detection_executions` 加 group_uid／assignment_uid、補三支索引；GRANT／schema_migrations／RLS 照慣例 | DEV 可套；`\d` 結構與 §5.2 一致；不做 backfill（D8） | — | BE | M |
| T-2.2 | DDD 四層：兩張新表的 entity／repo interface／repo impl／domain service（含「每 assignment 最新一筆」查詢與彙總計算函式、running group 守門查詢） | 彙總函式單元可驗（succeeded/partial_failed/failed/running 四情境＋重跑後最新一筆語意） | T-2.1 | BE | M |

### FR-067.3 綁定設置：assignment CRUD — 依賴：.1／.2

| # | 子任務 | 驗收 | 依賴 | Repo | 規模 |
|---|--------|------|------|------|------|
| T-3.1 | BE：DetectionToolBindingSchema 擴 `agent_assignments`＋job_service 寫入鏈 diff-sync（增/改/軟刪）＋讀取 enrich agent_name/agent_online | 綁定 API 寫兩組讀回兩組；刪一組再讀剩一組；同 agent 重複組被擋 | T-2.2 | BE | M |
| T-3.2 | FE：任務設置「Agent 分派」列表 UI（agent 下拉吃 capability 過濾＋離線標示、目標輸入、增刪列、空列表＝自動分派說明） | 加兩組儲存後重開頁面設定還在；離線 agent 在下拉呈灰不可選 | T-1.1／T-3.1 | FE | M |

### FR-067.4 執行展開與收口 — 依賴：.2

| # | 子任務 | 驗收 | 依賴 | Repo | 規模 |
|---|--------|------|------|------|------|
| T-4.1 | start_execution 展開：D3 逐台驗證（收集全部不合格一次 400＋新 error code）→ 建 group＋N 筆 execution＋N 工單（params merge）；fallback 隱含單組；running group 守門與 `_auto_dispatch_detection_scans` 冪等上移 group | 兩組 assignment 按執行 → DB 出現 1 group＋2 execution＋2 工單各釘各台；一台離線 → 400 訊息列出該台與原因、零工單產生 | T-2.2 | BE | L |
| T-4.2 | 收口邏輯：on_scan_succeeded/failed 尾端 group 列鎖收口（彙總落地＋auto 完成 gate＋409 地雷解除）；`complete_job` 冪等防護 | 兩台先後回報：第一台回報後 group 仍 running、任務未完成；第二台回報後 group succeeded、auto 任務自動完成；併發回報不重複 complete_job、agent 不收 5xx | T-4.1 | BE | L |
| T-4.3 | 單組重跑＋單組/整組取消三端點（§5.5，含 best-effort 取消語意與取消後重算） | 一成一敗後對敗組重跑 → 同 group 追加新筆、成功後整組轉 succeeded 並觸發 auto 完成；整組取消時一台失聯仍能取消可達的那台 | T-4.2 | BE | M |

### FR-067.5 彙總呈現與通知 — 依賴：.4

| # | 子任務 | 驗收 | 依賴 | Repo | 規模 |
|---|--------|------|------|------|------|
| T-5.1 | 執行紀錄 API group 化 response＋agent_name enrich（含 legacy 單筆自成一組相容形狀） | 執行紀錄 API 回 group 兩層形狀，每筆帶 agent 名稱；舊資料（無 group）仍能顯示 | T-4.1 | BE | M |
| T-5.2 | 彙總通知 `_notify_group_result`（Email／Telegram／Discord 三通道，每 assignment 一列） | 兩台任務收口只收到一封通知，內含兩台各自 agent 名／目標／狀態／發現數 | T-4.2 | BE | S |
| T-5.3 | FE：JobExecutionDrawer group 化（群組卡片＋狀態徽章＋每列 agent 名稱與報告連結＋重跑/取消鈕＋重跑歷史收合） | 執行紀錄頁看到群組卡片與各台狀態；失敗列可按重跑；partial_failed 呈黃色 | T-5.1／T-4.3 | FE | L |

### FR-067.6 收尾：測試修補＋SPEC 更新 — 依賴：.3／.4／.5

| # | 子任務 | 驗收 | 依賴 | Repo | 規模 |
|---|--------|------|------|------|------|
| T-6.1 | BE 既有測試 5 檔 mock 形狀對齊 group 模型；核心收口/彙總邏輯測試補齊（共用邏輯，符合測試政策寫新測試條件②） | 相關測試檔全綠 | .4 | BE | M |
| T-6.2 | E2E：detection-job-factory 擴多 assignment＋多 agent 分派場景 regression（母卡收口動作） | site-regression 檢測相關情境綠 | .3–.5 | test | M |
| T-6.3 | SPEC 三頁更新（my-tasks／project-planning／project-task-edit）＋remote-agent-manage 坑 10 收掉；走 writing-feature-specs skill、等 user 下令 | 三頁 spec 含多 agent 分派章節與變更紀錄行 | .3–.5 | BE(docs) | S |

### FR-067.7 per-assignment 排程執行（D10，2026-08-24 追加） — 依賴：.2（子表）／.4（展開/取消鏈）；與 .5 平行，T-7.2 與 T-5.3 同一棒

| # | 子任務 | 驗收 | 依賴 | Repo | 規模 |
|---|--------|------|------|------|------|
| T-7.1 | BE：migration（assignment 子表＋agent_tasks＋detection_executions 三處加欄/狀態）、綁定 API schema 帶 `scheduled_at`、展開時快照進工單與 execution（scheduled 狀態）、領單查詢過濾（`scheduled_at IS NULL OR <= now()`）、取消排程組短路、「立即開始」端點、彙總函式視 scheduled 為未終態 | 設一組 2 分鐘後的排程→按執行→心跳期間 agent 沒領到→時間到下一次心跳領走執行；排程中取消不打 agent 直接 cancelled；立即開始鈕清時間後下次心跳領走 | T-2.1／T-4.3 | BE | M |
| T-7.2 | FE：設定端排程 chip UX（§5.9.4-1）＋執行確認框清單（§5.9.4-2）＋執行紀錄 scheduled 徽章與「立即開始／取消」（§5.9.4-3）。**與 T-5.3 同一棒實作**（同動 JobExecutionDrawer） | 設定列可加/移除排程 chip；確認框列出各組立即/排程時間；執行紀錄排程組顯示「⏱ 排程中」＋時間、可立即開始或取消 | T-7.1／T-5.3 | FE | M |

---

## 7. 端到端驗收 {#acceptance nav="驗收"}

可親手操作的場景清單（DEV 環境、兩台 agent 各在不同網段）：

1. **正常流（兩台兩網段）**：任務設置加兩組 assignment（甲＋網段 A 目標、乙＋網段 B 目標）→ 儲存重開頁面設定還在 → 按開始執行 → 執行紀錄出現一個群組卡片、兩列各帶 agent 名稱與狀態 → 兩台先後回報，第一台完成時整組仍顯示執行中 → 兩台都完成後整組轉成功、auto 模式下任務自動完成 → **只收到一封**彙總通知，內含兩台各自結果與發現數。
2. **離線擋下**：把乙 agent 停掉（超過 900 秒無心跳）→ 按開始執行 → 立即 400，錯誤訊息明確列出「乙：離線」→ 執行紀錄零新增、沒有任何工單產生。
3. **部分失敗＋單組重跑**：讓乙掃描失敗（如目標填不可達網段）→ 整組轉 partial_failed（黃色）、任務**不**自動完成 → 對乙那組按「重跑」→ 同群組下追加新的一筆、整組回到執行中 → 重跑成功後整組轉成功、auto 觸發任務完成；展開重跑歷史可看到乙的失敗舊筆。
4. **取消**：執行中對單組按取消 → 只有該台工單被取消、另一台照常回報；另跑一輪按「整組取消」且其中一台失聯 → 可達的那台成功取消（不因失聯台卡死），群組收口為失敗類、照發彙總通知、不自動完成。
5. **fallback（不設 assignment）**：拿一個沒設 assignment 的任務按執行 → 自動挑一台**在線且有掃描能力**的 agent、單組群組照常收口，行為與現況一致但不會挑到離線機；test_connection 測的與執行挑的是同一台。
6. **地基迴歸**：任務設置的 agent 下拉只列出有 `detection_scan` 能力的機器、離線者有標示；storage-config 既有 agent 下拉不受影響。
