FR-067 檢測多 Agent 分派 — STATE(現況)

living 檔,就地 Edit,只描述此刻。 歷史軌跡看同目錄 FR-067-LOG.md(append-only)。 母案:CM-1350(FR-067 檢測多 Agent 分派)|branch:feature/FR-067|design:../design.md

§1

一句話現況(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 (範圍/各棒 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 澄清過一次。
§2

🔧 驗收回饋處理現況(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 簽章鑰四步組同時做)。

§3

⚠️ 平行作業警示(接手者必讀)

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

接手時務必

  • git status 看到不認得的檔案,先確認是不是平行 session 的(比對 ls -lT 修改時間)再動作
  • 絕不用整樹型指令git add -Agit add .git checkout-index -agit stash pop 後的整樹還原)
  • commit 一律顯式列檔名,禁用 -am
§4

進度

子需求 子任務 狀態
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 收完再派
§5

第一棒交付了什麼(.1 地基)

新 canonicalRemoteAgentDomainService.get_dispatchable_agents(tenant_id, capability) =可派工 agent(啟用/未撤銷/有 capability/在線id 穩定排序)。 _pick_agenttest_connection 都改走這支,故「測通的那台=實際執行的那台」。

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

APIGET /remote-agents 每列多 capabilitiesis_onlinelast_seen_at, 支援 ?capability=detection_scan。加欄不減欄,storage-config 下拉不受影響。

搬家is_onlineutc_nowcommon/util/agent_auth/heartbeat.py (domain 要用,不得依賴 app 層),原處 re-export。

§6

第二棒交付了什麼(.2 資料層)

DB(只套 DEV,STG/POC 等放行):新表 config.job_execution_detection_tool_agents (assignment 子表:每列=一組 agent+掃描目標)、compliance.detection_execution_groups (執行群組);compliance.detection_executionsgroup_uidassignment_uid +補三支索引。migration 2026-08-23-fr067-2-detection-multi-agent-tables.sql 已入 schema_migrationsmanifest.tsv不做存量 backfill(D8)

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

資料層契約速查(.3/.4 直接用,不要另寫查詢)

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

groupDetectionExecutionGroupDomainService) | 方法 | 用途 | |---|---| | 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 側新增DetectionExecutionDomainServicecreate_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',是壞的)。
§7

第三棒交付了什麼(.3 綁定設置)

任務設置可存多組「agent+掃描目標」,UI + API 兩端齊。DB 無異動(表是 .2 建好的)。

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

讀取(enrich):每列補 agent_nameagent_onlineapp service 層批次查一次 建 map,不在 infra JOIN(審計欄位 enrich 慣例)。兩條讀取路徑都補了—— get_joblist_jobsGrcJobRepoImpl._batch_fetch_job_tools,而規劃頁任務面板 實際走的是 control-tree 那條 raw SQLssp_control_implementation_service)。

FEProjectPlanningView 檢測工具區加「Agent 分派」列表(agent 下拉+目標輸入+ 增刪列),空列表顯示「自動分派」說明;離線 agent 標示且不可選;已被別列選走的一併 反灰。i18n 中英雙語補齊。

新 error codeGRC_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 的 addupdate 都會立即 flush,先增後刪會讓 「把 A 台換成 B 台」在 A 尚未讓位前就寫入而撞唯一約束。
  3. control-tree 是規劃頁真正的讀取來源:只補 _batch_fetch_job_tools 的話,存檔當下 看起來正常、重新整理頁面設定就消失(DB 其實有)。任何加在綁定上的新欄位都要同時 補這條 raw SQL。
§8

第四棒交付了什麼(.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 驗證新 canonicalRemoteAgentDomainService.diagnose_dispatchability(agent_uid, capability, tenant_id)(agent, [原因代碼]),空清單=可派。原因代碼:not_founddisabledrevokedno_capabilityoffline判定條件必須與 get_dispatchable_agents() 保持一致(兩處漂移就會出現「驗證說可以、實際挑不到」的 矛盾),docstring 已寫明此義務。任一不過整次 400,訊息一次列全(甲機(offline); 乙機(no_capability, disabled))。

收口_close_group_if_terminal(group_uid, user, job=, binding=, job_uid=) 掛在 on_scan_succeededon_scan_failed 單筆終態寫入之後、同一交易內。順序是 lock_for_close(列鎖)→ list_latest_per_assignmentcompute_status → 非 running 才做三件事。auto 完成 gate 只認 succeededcomplete_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() 負責轉換。
§9

第五棒交付了什麼(.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_atupdated_at。FE 若送不帶 offset 的本地時間,psycopg 會以 session TZ (三環境皆 Etc/UTC)解讀 → 使用者設的凌晨兩點會變成早上十點才開掃,且畫面完全正常 (存什麼讀什麼)。契約收斂在 common/util/scheduled_time.py(naive 一律視為 UTC)。 FE 送值請帶 offset2026-08-25T02:00:00+08:00)。
  2. 領單過濾只落在 list_pending_for_agentlist_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 會被讀成「掃了但什麼都沒發現」。

§10

FE 契約(T-5.3 + T-7.2 已依此實作完成;保留供後續 FE 異動與驗收對照)

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

GET /api/1.0/detection-tools/jobs/{job_uid}/executionsdata群組陣列(新到舊):

[{
  "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 的筆收合顯示。
  • legacyis_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=不動語意是兩回事(那是欄位層級)。
  • 🔴 送值請帶 offset2026-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_404001404002(實打四支新端點時發現 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。


§11

FE 已交付與未驗清單(.5 FE + .7 FE,2026-08-24)

commit:FE repo feature/FR-067cb458a6(群組卡片)/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_latestlegacy 筆兩個欄位皆 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 實打 APIGET .../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 端點與錯誤碼契約比對)。

§12

待驗收(user 手測)

BE 已重啟在 port 8000。取 token:

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 可打;驗收方式為看結構與跑測試):

# 兩張新表結構+擴欄
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(已驗過,迴歸時可再打):

# 寫兩組 → 讀回兩組(每組帶 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 succeededclosed_at 有值,auto 模式任務自動完成
  4. 部分失敗:一台失敗 → group partial_failed任務不自動完成
  5. 單組重跑:對失敗那組打 rerun → 同 group 追加新筆、group 回 runningclosed_at 清空 → 成功後整組 succeeded+auto 觸發完成;對成功的組 rerun → 409
  6. 整組取消一台失聯:兩台在跑、停掉一台 → 打整組 cancel → 可達那台進 cancelled、 失聯那台進 failed不整個報錯;group 收口為失敗類、不觸發 auto 完成
  7. 抽屜取消鈕(迴歸):多台在跑時打既有 job 層 cancel → 全部被取消而非只一台
  8. 不設分派(迴歸):無 assignment 任務按執行 → 自動挑一台在線的,仍建 group (total_count=1assignment_uid 為 NULL)
  9. 無 409 地雷:多台任務跑完後 grep -i "409\|Traceback" log/app.log 應無任務狀態 衝突;agent 側不該收 5xx
pytest test/test_detection_multi_agent_dispatch.py -q   # 31 例

.5 BE 面 + .7 BE 面(BE 已重啟在 port 8000;FE 尚未做,故執行紀錄 group 化與排程 UI 需直接打 API 驗):

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. 執行紀錄兩層形狀(已實跑過):
    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_nameis_latestscan_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/目標/狀態/發現數/時間)。單台情境體感不變。
pytest test/test_detection_scheduling.py test/test_detection_execution_grouping.py -q   # 34 例
§13

環境注意

  • DEV .envAGENT_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_migrationsmanifest.tsv。⚠️ scripts/check_migration_manifest.sh 仍會 FAIL——因為有兩支既有未登記檔2026-08-18-cm1283-*2026-08-20-cm1317-*,非本 arc 產生,commit 早於本 arc),本 arc 新增的兩支都已登記。
  • .2 棒只套 DEVguidant_ai_dev),STG/POC 未套等放行——開發期 DEV 領先屬正常狀態, 不是待修問題。上版前置:放行後依序套 STG/POC 並做三環境 schema_migrations diff。
§14

下一步(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-14029ae28f68 已上 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=正式出版時架(已裁示)。
§15

⚠️ 安裝手冊待更新(第五棒盤點,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。