# FR-067 檢測多 Agent 分派 — STATE（現況）

> **living 檔，就地 Edit，只描述此刻。** 歷史軌跡看同目錄 `FR-067-LOG.md`（append-only）。
> 母案：CM-1350（FR-067 檢測多 Agent 分派）｜branch：`feature/FR-067`｜design：[`../design.md`](../design.md)

## 一句話現況（2026-08-27，全案收案）

**FR-067 全案已收案（2026-08-27）**：母卡 CM-1350 收 Done、v1.16.0 已出貨
（189 POC 全新安裝）、CM-1404 安裝包 P0 已根修（build gate＋裝機驗 SOP）。
全案總結見同目錄 **[`2026-08-27-FR-067-SUMMARY.md`](./2026-08-27-FR-067-SUMMARY.md)**
（範圍/各棒 commits/決策/行為差異/教訓/follow-up 全在那裡）。

> **本檔轉歷史紀錄，不再更新——接手者請讀 SUMMARY**（follow-up 清單含後續優化池
> 六件、CM-1403 E2E 半成品、190 部署回歸、SPEC 站 MkDocs 評估、STG bundle 形態、
> PROD LC）。以下各節保留收案時點的最後狀態供查考。

**branch / push 現況**：FR-067 已併入 main，後續 commit 落在 main。**main 已推到
`ccb647df`＝origin/main 同步**——原先卡住的 `9ae28f68`（CM-1402 docs）＋`b0a468e2`
（第四棒交接）已一併上遠端，CM-1402 的 push 阻塞解除。

**版號**：v1.16.0 已發（`6d4ea5ee`，含 FR-066＋FR-067）。發版 gate（site-regression）
user 明示豁免。SPEC 凍結快照 `docs/specs/v1.16.0/` 已切（**不含** 1395/96/97 三卡行為
——那三卡的文件由 CM-1402 補進 current/，照版本模型不回頭改快照）。

**環境現況**：
- **189（POC，新主戰場）**：v1.16.0 全新安裝（舊 postgres 25432 原地封存續跑，
  `license_center_poc` 仍在其上）；LC 已更新 main `6c1d916`（alembic 到 head）。
  **DB 已止血補到 136 支 migration**。agent 兩台 re-enroll 過來（id=1 `.121`／
  id=2 `.167`）；**id=1 主機側 agent 服務 inactive 沒心跳**，user 要去 .121 起服務，
  起來後下一次心跳 capabilities 自動補上。license 待 user 向 LC 簽新照（新指紋）。
- **188（STG）**：stack 跑 1.16.0 前的驗收 code；agent 9/11 兩台 0.2.30 正常。
  ZAP 6g/2cpu 限制仍在（ajaxSpider 同 daemon 必序列）。**STG 要不要遷 bundle 形態
  未拍板**。
- 檢測工具「測試連線」的 agent 下拉是 `>1 台有 detection_scan 能力才顯示`
  （ToolPluginManage.vue `hasAgentChoice`）——單台/零台隱藏是設計不是 bug，
  本棒已向 user 澄清過一次。

## 🔧 驗收回饋處理現況（2026-08-27 更新）

**全部驗收卡已收 Done**（user 2026-08-26 逐張手測）：第一輪六卡＋續輪
1376/1381/1382/1383＋.8 三卡 1386/1387/1388＋文件 1393＋最後三卡 1395/1396/1397
＋部署 1384/1394。**進版鏈四卡亦 Done**：1398（進版三件套）／1399（出包）／
1400（189 主產品安裝，user 親裝）／1401（189 LC 更新，user 親做）。CM-1385
（.8 子需求卡）已收。

**開著的卡**：無——收案時全數收 Done（1402 SPEC user 驗過／1403 E2E `ede170a`
實跑複核過（並推翻前一棒 flake 判定，根因是既有測案綁死 190 環境，見 SUMMARY
follow-up）／1404 user 實裝複測過／1405 手冊＋選單抽查過／1356 收尾母卡＋
1310~1315 FR-066 過時追加卡一併收）。

**後續優化池（user 2026-08-26 全數定調「後續優化」不開卡，收尾時整理進 follow-up）**：
enroll token 顯示 bug／legacy is_latest／OpenSCAP content 平台派發（規模 M，正解
方向已定）／Windows 無域憑證（短中長三層方案材料在 LOG 第三棒 block）／ZAP 排隊
重試／掃描設定下放指派人員（等 PM）。**PROD LC 已裁示**：正式出版時找一台設備架，
現階段不動（PROD 簽章鑰四步組同時做）。
## ⚠️ 平行作業警示（接手者必讀）

2026-08-23 晚間兩個 session 曾在同一工作目錄平行作業（.1 地基 ∥ .2 資料層），**現皆已收工**。
兩棒 commit 時都逐檔比對、只 add 自己的檔（.1 十三檔／.2 二十八檔），無互相掃到。
此節保留作為下次平行作業的紀律提醒。

**接手時務必**：
- `git status` 看到不認得的檔案，**先確認是不是平行 session 的**（比對 `ls -lT` 修改時間）再動作
- **絕不用整樹型指令**（`git add -A`／`git add .`／`git checkout-index -a`／`git stash pop` 後的整樹還原）
- commit 一律**顯式列檔名**，禁用 `-am`

## 進度

| 子需求 | 子任務 | 狀態 |
|--------|--------|------|
| **FR-067.1 地基**（CM-1351） | T-1.1 清單 API 補能力／在線（CM-1357） | ✅ **協調者驗收通過** |
| | T-1.2 修好自動挑機＋test_connection 對齊（CM-1358） | ✅ **協調者驗收通過** |
| **FR-067.2 資料層**（CM-1352） | T-2.1 migration 兩新表＋擴欄補索引（CM-1359） | ✅ **協調者驗收通過** |
| | T-2.2 DDD 四層＋彙總計算函式（CM-1360） | ✅ **協調者驗收通過** |
| **FR-067.3 綁定設置**（CM-1353） | T-3.1 BE 綁定 API 擴 agent_assignments（CM-1361） | ✅ **協調者驗收通過** |
| | T-3.2 FE 任務設置「Agent 分派」列表 UI＋空列擋下修（CM-1362） | ✅ **協調者驗收通過** |
| **FR-067.4 執行展開與收口**（CM-1354） | T-4.1 展開＋D3 逐台驗證（CM-1363） | ✅ **協調者驗收通過** |
| | T-4.2 列鎖收口＋auto gate＋409 地雷（CM-1364） | ✅ **協調者驗收通過** |
| | T-4.3 單組重跑＋單組/整組取消（CM-1365） | ✅ **協調者驗收通過** |
| **FR-067.5 彙總呈現與通知**（CM-1355） | T-5.1 執行紀錄 API group 化＋agent enrich（CM-1366） | ✅ **協調者驗收通過** |
| | T-5.2 彙總通知改一封三通道（CM-1367） | ✅ **協調者驗收通過** |
| | T-5.3 FE JobExecutionDrawer group 化＋重跑/取消鈕（CM-1368） | ✅ **協調者驗收通過** |
| **FR-067.7 排程執行**（CM-1372） | T-7.1 BE 排程欄位＋領單過濾＋立即開始（CM-1373） | ✅ **協調者驗收通過** |
| | T-7.2 FE 排程 chip UX＋確認框＋排程徽章（CM-1374） | ✅ **協調者驗收通過** |
| — | **端到端驗收第一輪（雙 agent 雙網段，188 stack）** | ✅ 2026-08-24 完成，六回饋卡全清 |
| — | 驗收回饋：CM-1375/1377/1378/1379/1380 | ✅ **Done**（user 逐張驗過） |
| — | 續輪：CM-1376/1381/1382/1383＋部署 1384/1394 | ✅ 部署 Done；其餘修正待驗證 |
| **FR-067.8 每列自足**（CM-1385） | T-8.1 BE（CM-1386）／T-8.2 FE（CM-1387）／T-8.3 SonarQube upload（CM-1388） | ✅ 三棒完成，協調者抽查通過，修正待驗證 |
| — | 文件同步兩輪（CM-1381＋CM-1393） | ✅ 修正待驗證 |
| — | 顯示回饋 CM-1395/1396 ＋ 重跑放寬 CM-1397 | 🔄 1395/1396 在飛；1397 等 1396 收 |
| FR-067.6 收尾 | T-6.1 BE 測試對齊（剩 e2e/SPEC）＋T-6.2 E2E test repo＋T-6.3 SPEC 三頁 | ⏸ 未派 |
| **FR-068 Log 轉發** | 設計定案 D1–D6＋CM-1389~1392 四卡就緒 | ⏸ **user 裁示等 FR-067 收完再派** |

## 第一棒交付了什麼（.1 地基）

**新 canonical**：`RemoteAgentDomainService.get_dispatchable_agents(tenant_id, capability)`
＝可派工 agent（啟用／未撤銷／有 capability／**在線**＋`id` 穩定排序）。
`_pick_agent` 與 `test_connection` 都改走這支，故「測通的那台＝實際執行的那台」。

原 `get_agents_with_capability()` **保留**為靜態資格查詢（清單頁用，含離線機）。
兩者職責不同，別合併——清單要看得到離線機，派工絕不能挑到離線機。

**API**：`GET /remote-agents` 每列多 `capabilities`／`is_online`／`last_seen_at`，
支援 `?capability=detection_scan`。加欄不減欄，storage-config 下拉不受影響。

**搬家**：`is_online`／`utc_now` → `common/util/agent_auth/heartbeat.py`
（domain 要用，不得依賴 app 層），原處 re-export。

## 第二棒交付了什麼（.2 資料層）

**DB（只套 DEV，STG／POC 等放行）**：新表 `config.job_execution_detection_tool_agents`
（assignment 子表：每列＝一組 agent＋掃描目標）、`compliance.detection_execution_groups`
（執行群組）；`compliance.detection_executions` 加 `group_uid`／`assignment_uid`
＋補三支索引。migration `2026-08-23-fr067-2-detection-multi-agent-tables.sql`
已入 `schema_migrations`＋`manifest.tsv`。**不做存量 backfill（D8）**。

**程式**：兩張新表各自 entity／query entity／repo interface／repo impl／mapper／
domain service 齊，DI 已 wiring；`detection_executions` 側同步擴兩欄並加
`list_by_group`／`list_latest_per_assignment`。彙總計算獨立成
`domain/detection_execution/service/detection_group_status.py::compute_group_status`
（D4 單一真相，所有入口共用）。30 個新單元測試全綠，彙總函式過三輪突變測試；
ddd-compliance-reviewer 掃過 0 findings。

### 資料層契約速查（.3／.4 直接用，不要另寫查詢）

**assignment**（`JobExecutionDetectionToolAgentDomainService`）
| 方法 | 用途 |
|---|---|
| `list_by_binding(binding_id, tenant_id=None)` | 未軟刪清單，已按 `sort_order` 排序（**執行展開的輸入**） |
| `get_one` / `create` / `update` / `soft_delete` | 單列 CRUD；`update` 是 partial；刪一律軟刪 |

**group**（`DetectionExecutionGroupDomainService`）
| 方法 | 用途 |
|---|---|
| `create_running(job_execution_uid, total_count, tenant_id, user)` | 展開時建，`total_count` 定格 |
| `get_running_by_job_execution(uid, tenant_id=None)` | 「一任務最多一個 running group」守門 |
| `lock_for_close(uid)` | `SELECT ... FOR UPDATE` 鎖列——**收口併發防護核心，caller 必須在 `@transaction` 內** |
| `compute_status(statuses)` | D4 彙總 |
| `close(uid, status, user)` / `reopen(uid, user)` | 收口／重跑撥回（reopen 會清空 `closed_at`） |

**execution 側新增**（`DetectionExecutionDomainService`）
`create_running(..., group_uid=None, assignment_uid=None)`／
`list_latest_per_assignment(group_uid)`（**彙總輸入**）／`list_by_group(group_uid)`。

**收口標準寫法**（.4 照這個走，在 `@transaction` scope 內、單筆終態寫入之後）：
```
group_domain.lock_for_close(group_uid)                            # 1. 先鎖
latest = execution_domain.list_latest_per_assignment(group_uid)   # 2. 取每線最新
status = group_domain.compute_status([e.status for e in latest])  # 3. 算彙總
if status != "running":                                           # 4. 離開 running 才收口
    group_domain.close(group_uid, status, user)
    # ...auto 完成 gate（僅 succeeded）＋彙總通知
```

### .2 已知雷（踩過／設計時避開的）

1. **`closed_at` 清不掉**：`repo.update(entity)` 是「非 None 才寫」語意，`closed_at=None`
   會被 skip。重跑撥回 running 必須走 `reopen()`，不要自己組 entity。
2. **`DISTINCT ON` 的 ORDER BY 前綴**：PG 要求前綴與 `DISTINCT ON` 欄位一致，故是
   `ORDER BY assignment_uid, id DESC`。改排序時別把 `assignment_uid` 拿掉。
3. **`assignment_uid IS NULL` 自成一條分組線**：PG 視 NULL 為單一 distinct 值——
   這正是 fallback 隱含單組要的語意（已實跑確認），不是 bug。
4. **`scan_targets` NOT NULL**：domain service 的 `create()` 已把 `None` 正規化成 `{}`；
   繞過 domain 直接寫 model 要自己處理。
5. **partial unique index 只約束 `is_delete = FALSE`**：軟刪列不佔 slot，「移除某台再
   加回同一台」不會撞唯一約束（已實跑確認）。刻意設計，比照 `uq_jedt_job_active` 前例。
6. **兩張新表 RLS 抄的是修正後版本**（slash-delimiter ＋ `is_super_admin = 't'`），
   不是 `job_execution_detection_tools` 原始建表 migration 那版（逗號＋`'true'`，是壞的）。

## 第三棒交付了什麼（.3 綁定設置）

**任務設置可存多組「agent＋掃描目標」，UI ＋ API 兩端齊。DB 無異動**（表是 .2 建好的）。

**寫入（diff-sync）**：`DetectionToolBindingSchema` 擴 `agent_assignments`；
`job_service._replace_detection_tool_agent_assignments()` 做增／改／軟刪。
語意：**`None`（未帶欄位）＝不動既有設定**（比照 `tool_params` 的 None-skip，只改任務
名稱的 PUT 不會清空分派）；**`[]`＝明確清空**回到自動挑機 fallback（D8，合法狀態，
不做必填驗證）。**update／create 兩分支都掛**——只掛一支的話另一條路徑靜默不生效。

**讀取（enrich）**：每列補 `agent_name`／`agent_online`，**app service 層批次查一次
建 map，不在 infra JOIN**（審計欄位 enrich 慣例）。**兩條讀取路徑都補了**——
`get_job`／`list_jobs` 走 `GrcJobRepoImpl._batch_fetch_job_tools`，而**規劃頁任務面板
實際走的是 control-tree 那條 raw SQL**（`ssp_control_implementation_service`）。

**FE**：`ProjectPlanningView` 檢測工具區加「Agent 分派」列表（agent 下拉＋目標輸入＋
增刪列），空列表顯示「自動分派」說明；離線 agent 標示且不可選；已被別列選走的一併
反灰。i18n 中英雙語補齊。

**新 error code**：`GRC_400107`（同綁定重複指派同一台）／`GRC_400108`（agent_uid 空）。
**⚠️ 尚未同步 FE `error-code.json`**——與 .4 那 5 個一併留給 .5 串 FE 時補。

### .3 已知雷（實跑抓到的，非推測）

1. **配對只看 uid 會撞唯一約束丟 500**：FE 若沒把既有列的 uid 帶回（或以「先刪光再重加」
   的心智送整包），同一台 agent 會走進 create 分支撞 `uq_jedta_binding_agent_active`。
   故配對規則是 **uid 優先、`agent_uid` 次之**——`agent_uid` 在同一綁定下本就是自然識別
   （正是該約束的欄位），據此配對才與 DB 唯一性語意一致。
2. **軟刪必須排在新增之前**：repo 的 `add`／`update` 都會**立即 flush**，先增後刪會讓
   「把 A 台換成 B 台」在 A 尚未讓位前就寫入而撞唯一約束。
3. **control-tree 是規劃頁真正的讀取來源**：只補 `_batch_fetch_job_tools` 的話，存檔當下
   看起來正常、**重新整理頁面設定就消失**（DB 其實有）。任何加在綁定上的新欄位都要同時
   補這條 raw SQL。

## 第四棒交付了什麼（.4 執行展開與收口）

**展開**：`start_execution` 讀綁定下 assignment → 逐台走 D3 驗證 → 全過才同一交易內建
group（`total_count=N`）＋N 筆 execution（帶 group_uid／assignment_uid）＋N 張工單
（`agent_id` 各釘各台，`params` 以綁定 `tool_params` 為底、assignment `scan_targets`
覆蓋同名鍵）。**無 assignment → fallback 隱含單組**（自動挑機，`assignment_uid=NULL`），
**仍建 group**——所以收口邏輯只有一套。

**D3 驗證新 canonical**：`RemoteAgentDomainService.diagnose_dispatchability(agent_uid,
capability, tenant_id)` → `(agent, [原因代碼])`，空清單＝可派。原因代碼：`not_found`／
`disabled`／`revoked`／`no_capability`／`offline`。**判定條件必須與
`get_dispatchable_agents()` 保持一致**（兩處漂移就會出現「驗證說可以、實際挑不到」的
矛盾），docstring 已寫明此義務。任一不過整次 400，訊息一次列全（`甲機(offline);
乙機(no_capability, disabled)`）。

**收口**：`_close_group_if_terminal(group_uid, user, job=, binding=, job_uid=)` 掛在
`on_scan_succeeded`／`on_scan_failed` 單筆終態寫入之後、**同一交易內**。順序是
`lock_for_close`（列鎖）→ `list_latest_per_assignment` → `compute_status` → 非 running
才做三件事。auto 完成 gate **只認 succeeded**；`complete_job` 前檢查任務仍 PROCESSING
（冪等）。**彙總通知只留 hook 點與 log**，本體是 .5 的 T-5.2。

**三支新端點**（`/detection-tools/execution-groups/...`）：
`{group}/assignments/{assignment}/rerun`／`{group}/assignments/{assignment}/cancel`／
`{group}/cancel`（best-effort）。fallback 隱含單組用路徑保留字 `_default`。

**既有 job 層取消端點改成委派整組取消**——不改的話 FE 抽屜「取消」鈕群組化後只會取消
第一筆，其餘幾台繼續掃且群組永遠 running 擋住下一輪。

**冪等上移**：`_auto_dispatch_detection_scans` 的挑選查詢加一條「無 group」，**同時保留
原本「無 execution」那條**（D8 不 backfill，只看 group 會讓舊資料被判成沒派過而重派）。

### .4 已知雷（踩過／設計時避開的）

1. **`_pick_agent` 答不出「為什麼不可派」**：既有 `get_dispatchable_agents()` 只回「有哪些
   可派」，D3 要逐台列原因，故另開 `diagnose_dispatchability()`。**兩支的資格條件必須同步
   維護**，改一邊要改另一邊。
2. **force 重派要打掉群組內每一筆 running**，不只第一筆（舊掃描還在對方機器上跑）。此處
   取消失敗一律 409 不派新的，**與 T-4.3 的整組取消 best-effort 刻意不同**——那裡使用者
   意圖就是收掉，這裡的前置目的是確保沒有殘留執行中工作。
3. **`GROUP_STATUS_*` 常數直接 import** `domain/detection_execution/service/detection_group_status.py`，
   不要在 orchestration 內另寫字串字面值（第二套真相）。
4. **`start_execution` response 形狀已改**（`{group_uid, status, total_count, executions:[...]}`），
   不再是 `{agent_task_uid, execution_uid}`。FE 目前不讀 response body（只看成功與否）
   故無破壞，但 .5 串 FE 時要照新形狀。
5. **重跑用 assignment 當下的 `scan_targets`**，不是派工快照——改過目標後重跑用新的。
6. **`_default` 是路徑保留字**：assignment_uid 在 DB 是 NULL（fallback 組），URL 表達不了
   NULL，故用保留字；`_normalize_assignment_uid()` 負責轉換。

## 第五棒交付了什麼（.5 BE 面 ＋ .7 BE 面）

**T-5.1 執行紀錄 group 化**：`GET /detection-tools/jobs/{job_uid}/executions` 回**兩層形狀**
（群組 → 各台執行紀錄），形狀見下方「FE 待辦契約」。legacy 筆（`group_uid` NULL，D8 不
backfill）與「群組列查無」（跨租戶／已刪）**一律走同一個「單筆自成一組」合成分支**
（`is_legacy: true`），FE 不需分叉渲染。順修兩處既有 N+1（缺口 11）——`_resolve_scan_params`
與 `_resolve_report_file_uid` 各補批次版，剝除邏輯抽成 module-level `_visible_scan_params()`
由單筆／批次共用。報告檔 id→uid 批次走新的 `DetectionReportFileQuery`（jedi-file-upload 只有
單筆 by-id 與批次 by-uid，不改外部套件共用 API 面）。

**T-5.2 彙總通知一封**：`_notify_group_result` 接在 `_close_group_if_terminal()` 第 ③ 步
（.4 留的 hook 點）。共用脈絡一次 ＋ 每 assignment 一列（agent／目標／狀態／發現數／時間／
失敗原因），三通道同步。**單筆 `_notify_scan_result` 只留給 legacy 無 group 的筆**——它進不了
收口路徑，拿掉會靜默不通知。租戶 IM 設定沿既有顯式讀法（agent 回報路徑無 user_context）。

**T-7.1 per-assignment 排程（D10）**：核心形狀「group 照常立即建，延後的是 agent 領得到單的
時間」。migration 三處加欄＋領單部分索引（**只套 DEV**）；領單查詢加
`scheduled_at IS NULL OR <= now()`（**DB 端 now()**）；展開時從 assignment 快照進工單與
execution 狀態（未來時間 → `scheduled`）；取消排程組短路（scheduled ＋工單仍 pending →
直接標 cancelled 不打 agent）；「立即開始」端點；ack 時 scheduled→running。
**單組重跑一律立即**（`force_immediate`），不套 assignment 的維護窗。

### .5／.7 已知雷（實跑抓到／設計時避開的）

1. **`scheduled_at` 的時區不會被自動轉**：jedi-common 的 `db_mw.DATETIME_FIELDS` 只涵蓋
   `created_at`／`updated_at`。FE 若送不帶 offset 的本地時間，psycopg 會以 session TZ
   （三環境皆 `Etc/UTC`）解讀 → **使用者設的凌晨兩點會變成早上十點才開掃，且畫面完全正常**
   （存什麼讀什麼）。契約收斂在 `common/util/scheduled_time.py`（naive 一律視為 UTC）。
   **FE 送值請帶 offset**（`2026-08-25T02:00:00+08:00`）。
2. **領單過濾只落在 `list_pending_for_agent`**，`list_for_agent_by_status` **刻意不動**——
   FR-058.7（D25）下載授權判定走那支，套上排程過濾會讓 agent 取檔被自己人擋掉。兩支分家有
   測試守著（`test_agent_task_service.py`），別為了「統一」而合併。
3. **取消短路的判定條件有兩個，缺一不可**：`execution.status == scheduled` **且**
   `task.status == pending`。只看前者的話，排程時間到、agent 已領走但尚未回報的瞬間會誤短路
   ——掃描還在對方機器上跑卻被標成已取消。
4. **control-tree 那條 raw SQL 直出 datetime 會變 RFC 格式**（`Mon, 24 Aug 2026 18:00:00 GMT`），
   與 grc job 路徑的 ISO 不一致 → 已加 `.isoformat()`。**日後往 control-tree 加任何日期欄位
   都要記得**（本檔 `reviewed_at` 早有此慣例）。
5. **`_ACTIVE_EXECUTION_STATUSES` 是「尚未落終態」的單一定義**（running ＋ scheduled）——
   取消對象、群組守門都用這一組。**別在各處另寫 `== "running"` 字面值**，scheduled 會被漏掉
   （症狀是排程中的組取消不掉、整組取消回 409 說沒有執行中的項目）。
6. **通知的「發現數」空值顯示「—」不是 0**：各 connector summary 鍵不同（OpenSCAP 系 `fail`、
   Nmap 系 `findings`），填 0 會被讀成「掃了但什麼都沒發現」。

---

## FE 契約（T-5.3 ＋ T-7.2 已依此實作完成；保留供後續 FE 異動與驗收對照）

### 1. 執行紀錄 API 的 group 兩層形狀

`GET /api/1.0/detection-tools/jobs/{job_uid}/executions` → `data` 是**群組陣列**（新到舊）：

```jsonc
[{
  "uid": "grp-1",                    // legacy 筆時＝該 execution 自己的 uid
  "status": "partial_failed",        // running / succeeded / partial_failed / failed
  "total_count": 2,
  "closed_at": "2026-08-25 02:42:00", // 未收口為 null
  "created_at": "2026-08-25 02:00:00",
  "is_legacy": false,                // true＝單筆自成一組（舊資料），形狀完全相同
  "executions": [{
    "uid": "exec-1", "status": "succeeded",   // + scheduled（排程等待中）
    "agent_uid": "…", "agent_name": "甲機",    // agent 已刪除時 name 為 null，該列照常回
    "assignment_uid": "asg-1",                // null＝fallback 隱含單組
    "scan_targets": {"hosts": "10.1.0.0/24"},
    "is_latest": true,                        // false＝重跑歷史，FE 收合
    "summary": {...}, "reports": [{"uid","file_name"}], "report_file_uid": "…",
    "scan_params": {...}, "detection_tool_name": "…",
    "started_at": "…", "finished_at": "…", "error_message": null,
    "created_user": "blsadmin", "created_user_name": "Billows Admin"
  }]
}]
```

- **群組狀態徽章**：`succeeded` 綠／`partial_failed` 黃（沿 CM-952 語彙）／`failed` 紅／
  `running` 動畫；**排程中的組**（明細有 `scheduled` 筆）用「⏱ 排程中」**中性色**（§5.9.4-3），
  與 running 的動態感區隔——group 層 `status` 仍是 `running`，**徽章要看明細組成**。
- **重跑歷史**：`is_latest: false` 的筆收合顯示。
- **legacy**：`is_legacy: true` 照舊單卡呈現即可（形狀一致，不需分叉）。

### 2. 可用端點清單

| 端點（前綴 `/api/1.0/detection-tools`） | 用途 | 409 時機 |
|---|---|---|
| `POST /jobs/{job_uid}/execute` | 開始執行（response 已是 group 形狀 `{group_uid, status, total_count, executions:[{…, scheduled_at}]}`） | 已有 running group |
| `POST /execution-groups/{g}/assignments/{a}/rerun` | 單組重跑（**一律立即**，不套排程） | 群組仍 running／該組最新一筆非 failed/cancelled |
| `POST /execution-groups/{g}/assignments/{a}/cancel` | 單組取消（排程中的組走短路不打 agent） | 該組最新一筆非 running/scheduled |
| `POST /execution-groups/{g}/assignments/{a}/start-now` | **立即開始**（催跑排程中的組） | 該組最新一筆非 scheduled |
| `POST /execution-groups/{g}/cancel` | 整組取消（best-effort，回 `{cancelled:[], failed:[]}`） | **全部**不可達 |
| `POST /jobs/{job_uid}/cancel` | 既有抽屜取消鈕（已改為委派整組取消） | 無 running |

**fallback 隱含單組**（`assignment_uid` 為 null）在路徑上用保留字 **`_default`**。

### 3. 綁定 API 的 `scheduled_at`（設定端）

`PUT /grc/project/{proj}/job/{job}` 的 `tool.agent_assignments[]` 每列多一個 `scheduled_at`：

- **`null`＝立即**；有值＝該組延後到該時間才由 agent 領走。
- **每次都依送來的值覆寫**（與 `scan_targets` 同）——移除排程 chip 就送 `null`，會真的清掉。
  注意這與整個 `agent_assignments` 欄位的 `None`＝不動語意是兩回事（那是欄位層級）。
- 🔴 **送值請帶 offset**（`2026-08-25T02:00:00+08:00`）。不帶 offset 會被當 UTC，
  使用者設的凌晨兩點會變成早上十點才開掃（見上方已知雷 1）。
- 讀回為 ISO 格式（`2026-08-24T18:00:00+00:00`），兩條讀取路徑（grc job／control-tree）一致。
- UX 規格見 design.md §5.9.4：預設看不到時間欄位，每列尾「⏱ 排程」icon button → datetime
  picker（**預設給有意義起點**如當晚 22:00，不給空白）→ 選定後顯示實心 chip「⏱ 8/25 02:00」
  帶 × 可移除。立即 vs 排程用「有無 chip」的形狀判斷，不靠說明文字。

### 4. ✅ FE error code i18n（`error-code.json`）——已於 `80e1c29` 補齊

下表八支（原文誤寫「七個」，實為八支）**已全部補上中英翻譯**；另補兩支清單漏列的
`DETECTION_EXECUTION_404001`／`404002`（實打四支新端點時發現 404002 是四顆新按鈕都會撞到
的碼——群組被刪或跨租戶），共十支。

⚠️ `DETECTION_TOOLS_400017` 另需 `App.vue` 配套（已做）：`t('lang.error.'+code, {default: msg})`
**在有翻譯時會整個蓋掉 default**，只補翻譯反而把 BE 附在 msg 尾端的逐台原因吞掉。已加
`CODES_KEEPING_BE_DETAIL` 清單，翻譯當抬頭、BE msg 當明細併陳。**日後任何「msg 帶明細」
的碼要補翻譯時，都要一併加進那份清單。**

| Code | 語意 |
|---|---|
| `GRC_400107` | 同一檢測工具下不可重複指派同一台 Agent（.3） |
| `GRC_400108` | Agent 分派必須指定 Agent（.3） |
| `DETECTION_TOOLS_400017` | 指派的 agent 有不可派工者（.4；訊息尾端帶逐台原因，**需保留 msg 內容**） |
| `DETECTION_TOOLS_404007` | 找不到該 agent 分派（.4） |
| `DETECTION_TOOLS_409012` | 執行群組仍在執行中（.4，重跑前置） |
| `DETECTION_TOOLS_409013` | 只有失敗或已取消的組可以重跑（.4） |
| `DETECTION_TOOLS_409014` | 整組取消失敗（全部 agent 都打不到）（.4） |
| `DETECTION_TOOLS_409015` | **該組不在排程等待中**（.7，立即開始前置） |

### 5. 執行確認框（§5.9.4-2）

按「開始執行」前，若有任何排程組，確認框要列清單——每組一行「agent（目標）＋ 立即／
⏱ 時間開始」；**時間已過顯示「立即」**，不顯示過期時間。資料源是綁定讀取回來的
`agent_assignments`（含 `scheduled_at`），不需另打 API。

---

## FE 已交付與未驗清單（.5 FE ＋ .7 FE，2026-08-24）

**commit**：FE repo `feature/FR-067` 上 `cb458a6`（群組卡片）／`055d9c2`（排程 chip）／
`80e1c29`（十支 error code ＋ App.vue 明細併陳）。**未 push**。

**交付內容**：`JobExecutionDrawer` 群組卡片（彙總徽章／每台一列帶 agent 名稱與目標／單組
重跑／單組取消／整組取消／重跑歷史收合）、排程 UX 三端（設定列 ⏱ chip、執行確認框清單、
排程中中性徽章＋立即開始）、守門窗 tooltip 說明、十支 error code 中英。

### ✅ 實作時抓到的 BE 缺陷——已修（`b205e07a`）

`list_executions` 有兩處各自分組、鍵卻不同步：`_group_executions()` 以
`group_uid or legacy:{uid}` 分卡，`_latest_execution_uids()` 卻以
`(group_uid, assignment_uid)` 算 `is_latest`。**legacy 筆兩個欄位皆 NULL → 同一任務下所有
舊筆被視為同一條分組線、只有最新那筆得到 true**；但每筆又各自成一張卡，於是除最新那張外
每張卡都是「零當前列＋一列歷史」，FE 預設收合歷史之下**整包稽核紀錄從畫面消失**
（DEV 實測 job `9ca1e559…` 10 筆只顯示得出 1 筆）。

**修法**：抽出 `_execution_card_key()` 由兩處共用——卡片與最新判定是同一個分組概念的兩面，
鍵只能有一份（不是改其中一支的鍵）。真群組行為完全不變，僅 legacy 路徑受影響。補回歸測試
`test_every_card_has_at_least_one_current_row`（三筆 legacy 鎖「每張卡至少一列當前」的不變量），
**已做突變測試**確認斷言有牙齒。修後以 DEV 真實資料重打 API：10 張卡各有 1 列 `is_latest`。

FE 的保底仍保留（`groupLatestExecutions` 全 false 時退回「全部視為當前」）——那是防禦性
不變量，不是繞過 BE；已驗修好後的 payload 不會讓它重複計算（10 列渲染 10 列、無重複）。

### 未驗到的部分（DEV agent 全離線，統一留給端到端驗收棒）

| 項目 | 驗到什麼程度 |
|---|---|
| legacy 單卡渲染 | ✅ **DEV 真實資料實跑**（10 筆全數渲染、無重複列） |
| group 兩層形狀 parse | ✅ **實打 API**（`GET .../executions` 200，欄位與契約逐項比對相符） |
| 四支新端點路徑可達 | ✅ **實打**（皆回業務 404 而非路由 404） |
| `scheduled_at` 時區來回 | ✅ 實跑轉換（本地 02:00 → `+08:00` → UTC 18:00 → 讀回 02:00） |
| 確認框清單（立即／排程／過期） | ✅ 邏輯實跑（過期正確顯示「立即」） |
| 多台群組卡片（partial_failed 黃、彙總列） | ⚠️ **僅模擬 payload**，無真實多台資料 |
| 重跑／單組取消／整組取消 **按下去的行為** | ⚠️ **完全未 live 驗**（僅驗端點可達與 UI 條件判斷） |
| 排程到點 agent 領走並轉 running | ⚠️ **完全未 live 驗** |
| 「立即開始」實際效果 | ⚠️ **完全未 live 驗** |
| 頁面實際點選操作（chip 增刪、卡片互動） | ⚠️ **未手測**（FE dev server 由 user 啟動） |
| `npm run build:DEV` | ✅ 通過 |

`ddd-compliance-reviewer` 掃過 diff：**0 findings**（含 CM-952／CM-954 警示未被弄丟的確認、
i18n 雙語鍵完整性、BE↔FE 端點與錯誤碼契約比對）。

## 待驗收（user 手測）

BE 已重啟在 port 8000。取 token：
```bash
curl -s -X POST http://localhost:8000/api/1.0/login -H 'Content-Type: application/json' \
  -d '{"username":"blsadmin","password":"<見 .env / 部署文件>"}'
```
1. `GET /api/1.0/remote-agents` → 每列有 capabilities／is_online／last_seen_at
2. `GET /api/1.0/remote-agents?capability=detection_scan` → 只回有該能力的（DEV 現有 1 台）
3. storage-config 頁 agent 下拉仍正常（迴歸）
4. 檢測工具「測試連線」仍正常

**.2 資料層**（純資料層，無 API 可打；驗收方式為看結構與跑測試）：
```bash
# 兩張新表結構＋擴欄
PGPASSWORD='<見 .env>' psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev \
  -c '\d config.job_execution_detection_tool_agents' \
  -c '\d compliance.detection_execution_groups' \
  -c '\d compliance.detection_executions'
# migration 已登記
PGPASSWORD='<見 .env>' psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev \
  -c "SELECT filename FROM public.schema_migrations WHERE filename LIKE '%fr067%'"
# 單元測試
pytest test/test_detection_group_status.py test/test_job_execution_detection_tool_agent_domain_service.py -q
```

**.3 綁定設置**（BE 已實跑九情境全過；**FE 頁面操作待手測**——FE dev server 依慣例由
使用者啟動）：

FE 手測（`npm run dev` 後開任務設置頁，檢測工具型任務）：
1. 檢測工具區應出現「Agent 分派」區塊，未加任何列時顯示「自動分派」說明
2. 按「新增一組」→ 下拉**只列得出可掃描的 agent**，離線者標「（離線）」且點不到
3. 加兩組不同 agent、各填目標 → 儲存 → **重新整理頁面** → 設定仍在
   （**最關鍵**，驗的是 control-tree 讀取路徑）
4. 某列選了 A 之後，另一列下拉中的 A 應反灰（同 agent 不可重複）
5. 全部刪光 → 儲存 → 回到「自動分派」說明

BE API（已驗過，迴歸時可再打）：
```bash
# 寫兩組 → 讀回兩組（每組帶 agent_name／agent_online）
curl -s -X PUT ".../grc/project/<proj>/job/<job>" -H "Authorization: Bearer $TOK" \
  -H 'Content-Type: application/json' -d '{"tool":{"uid":"<tool>","agent_assignments":[
     {"agent_uid":"<a1>","scan_targets":{"hosts":"10.1.0.0/24"}},
     {"agent_uid":"<a2>","scan_targets":{"hosts":"10.2.0.5"}}]}}'
```
已驗九情境：寫兩組讀回兩組／帶 uid 改一組／只送一組（另一組軟刪）／整台換掉（flush
順序）／只改任務名不帶欄位（分派保留）／空陣列清空／移除後加回同一台／同 agent 重複
擋 400／agent_uid 空擋 400。**綁定 create 分支**另經 job_type 來回切換驗過。

**.4 執行展開與收口**（執行紀錄群組化與重跑／取消鈕屬 .5 T-5.3，現階段驗收需直接打
API ＋ 查 DB；**分派設定現在可直接用 .3 的 UI 或綁定 API 建，不必再插 SQL**）：

1. **兩組展開**：插兩列 assignment（兩台在線 agent）→ 按開始執行 → DB 出現
   1 筆 group（`total_count=2`）＋2 筆 execution（各帶 group_uid／assignment_uid）
   ＋2 張 agent_tasks（`agent_id` 各釘各台）
2. **離線擋下**：停掉其中一台超過在線門檻 → 按開始執行 → 400、訊息列該台與 `offline`、
   **零工單零執行紀錄**
3. **先後回報**：第一台回報完成 → group 仍 `running`、任務未完成；第二台也完成 →
   group `succeeded`＋`closed_at` 有值，auto 模式任務自動完成
4. **部分失敗**：一台失敗 → group `partial_failed`、**任務不自動完成**
5. **單組重跑**：對失敗那組打 rerun → 同 group 追加新筆、group 回 `running`、
   `closed_at` 清空 → 成功後整組 `succeeded`＋auto 觸發完成；對**成功**的組 rerun → 409
6. **整組取消一台失聯**：兩台在跑、停掉一台 → 打整組 cancel → 可達那台進 `cancelled`、
   失聯那台進 `failed`，**不整個報錯**；group 收口為失敗類、不觸發 auto 完成
7. **抽屜取消鈕（迴歸）**：多台在跑時打既有 job 層 cancel → **全部**被取消而非只一台
8. **不設分派（迴歸）**：無 assignment 任務按執行 → 自動挑一台在線的，仍建 group
   （`total_count=1`、`assignment_uid` 為 NULL）
9. **無 409 地雷**：多台任務跑完後 `grep -i "409\|Traceback" log/app.log` 應無任務狀態
   衝突；agent 側不該收 5xx

```bash
pytest test/test_detection_multi_agent_dispatch.py -q   # 31 例
```

**.5 BE 面 ＋ .7 BE 面**（BE 已重啟在 port 8000；**FE 尚未做**，故執行紀錄 group 化與排程
UI 需直接打 API 驗）：

```bash
TOK=$(curl -s -X POST http://localhost:8000/api/1.0/login -H 'Content-Type: application/json' \
  -H 'X-Tenant-Id: 102' -d '{"username":"blsadmin","password":"<見 .env / 部署文件>"}' \
  | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['access_token'])")
```

1. **執行紀錄兩層形狀**（已實跑過）：
   ```bash
   curl -s "http://localhost:8000/api/1.0/detection-tools/jobs/<有執行紀錄的 job>/executions" \
     -H "Authorization: Bearer $TOK" -H 'X-Tenant-Id: 102' | python3 -m json.tool
   ```
   應看到外層群組（status／closed_at／total_count／is_legacy）＋內層 executions
   （帶 `agent_name`／`is_latest`／`scan_targets`）。舊資料 `is_legacy: true`、單筆一組。
2. **排程寫入讀回清除**（已實跑過三語意）：綁定 PUT 帶
   `agent_assignments[].scheduled_at: "2026-08-25T02:00:00+08:00"` → DB 應存
   `2026-08-24 18:00+00`（台北→UTC）；只改任務名的 PUT → 排程保留；送 `null` → 真的清成 NULL。
3. **領單過濾**（已實跑過）：插一筆未來排程的 pending 工單，
   `SELECT ... WHERE (scheduled_at IS NULL OR scheduled_at <= now())` 應查不到它。
4. **排程展開**（需要在線 agent——DEV 現有 id=17 `ubuntu-lab-03` 在線可驗）：
   設一組 2 分鐘後的排程 → 按開始執行 → execution 應為 `scheduled` 狀態、工單帶
   `scheduled_at`；心跳期間 agent 領不到；時間到下一次心跳領走並轉 running。
5. **立即開始**：對排程中的組打
   `POST /detection-tools/execution-groups/{g}/assignments/{a}/start-now` → 工單
   `scheduled_at` 清成 now、execution 轉 running；對非排程中的組打 → 409（`DETECTION_TOOLS_409015`）。
6. **排程中取消不打 agent**：對排程中的組打單組 cancel → agent 側不應收到 stop 請求，
   execution/工單直接 cancelled。
7. **彙總通知一封**：多台任務跑完收口 → 收件人只收到**一封**，內含每台一列
   （agent／目標／狀態／發現數／時間）。單台情境體感不變。

```bash
pytest test/test_detection_scheduling.py test/test_detection_execution_grouping.py -q   # 34 例
```

## 環境注意

- **DEV `.env` 的 `AGENT_HEARTBEAT_INTERVAL_SEC=60`**（非預設 300）→ 在線門檻是
  **180 秒**，不是 design.md 舉例的 900 秒。程式讀設定不寫死，兩者都對；驗收時別
  拿 900 去對。
- **DEV 現有在線 agent：id=17 `ubuntu-lab-03`**（2026-08-24 起，0.2.28 交付包 systemd
  原生形態；123 舊 agent 已刪）。capabilities `[file_storage, detection_scan]`。
  agent 排錯前先 `systemctl list-units | grep guidant` 確認形態，**別假設 compose**——
  設定檔在 `/etc/guidant-agent/agent.env`。
- .1 棒、**.3 棒與 .4 棒未動 DB**（無 migration）。
- **.7 棒的 migration（`2026-08-24-fr067-7-per-assignment-scheduling.sql`）同樣只套 DEV**，
  已入 `schema_migrations` ＋ `manifest.tsv`。⚠️ `scripts/check_migration_manifest.sh` 仍會
  FAIL——因為有**兩支既有未登記檔**（`2026-08-18-cm1283-*`／`2026-08-20-cm1317-*`，非本 arc
  產生，commit 早於本 arc），本 arc 新增的兩支都已登記。
- .2 棒**只套 DEV**（`guidant_ai_dev`），**STG／POC 未套等放行**——開發期 DEV 領先屬正常狀態，
  不是待修問題。上版前置：放行後依序套 STG／POC 並做三環境 `schema_migrations` diff。

## 下一步（2026-08-27 第五棒更新；**非執行授權**，等 user 發令）

1. ~~派 CM-1404~~ ✅ 完成（修正待驗證）。**user 複測**：188 新 bundle 乾淨機實裝
   →接 agent 驗心跳/capabilities/Profile。
2. **收 CM-1403（E2E 半成品）**：重派一支 runner 到 test repo 接手——half-done 檔案
   在 working tree（factory＋feature＋traceability），把實跑驗證→commit→回寫卡做完。
   prompt 要限定只碰自己的檔（tree 有非本案未追蹤物）。
3. **收 CM-1402**：`9ae28f68` 已上 origin/main，剩 user 驗 SPEC 內容
   （站起在 localhost:8890）。
4. **189 使用面（user 手上）**：兩台 agent capabilities 已補上但 08-27 早上起停跳
   （last_seen 停 02:13 UTC），主機側服務要看；license 向 LC 簽新照匯入；租戶/帳號建置。
5. **安裝手冊發布前更新**（第五棒盤點發現，見下方「⚠️ 安裝手冊待更新」）。
6. **FR-067 全案收尾**（等 user 下令）：母卡 CM-1350＋子需求卡 CM-1351~1355/1372
   收 Done、SUMMARY（從 LOG 各 block 濃縮）、使用手冊盤點（多 agent 分派操作段）、
   memory 教訓入檔、STATE 轉歷史。後續優化池六件整理進 follow-up。
7. **FR-068 Log 轉發**（設計定案、CM-1389~1392 四卡就緒）——user 裁示等 FR-067 收完。
8. **懸而未拍**：STG（188）要不要遷 bundle 形態；PROD LC＝正式出版時架（已裁示）。

## ⚠️ 安裝手冊待更新（第五棒盤點，2026-08-27）

兩本客戶手冊（`docs/user-manual/onprem/` 主產品 10 章＋`agent/` 檢測 Agent 11 章，
白話版 `abe0ae5f`，各有 html 版）**內容停在 1.15.0 / agent 0.2.28 時代**，v1.16.0
已出貨、發布前要對齊：版號字串（`guidant-ai-1.15.0.tar.gz`→1.16.0、agent 0.2.28
→0.2.30，含安裝輸出示例）；FR-067 多 agent 分派若牽動客戶操作面（agent 手冊
「一台 vs 多台」敘述）；升級章範例（1.14→1.15 改 1.15→1.16）。更新後 md＋html
兩份都要重 build。
