---
title: "Agent 獨立更新 — 設計文件 (FR-172)"
brand: "Guidant AI · **FR-172** Agent 獨立更新"
eyebrow: "FR-172 · 設計文件 · 2026-11-01 定稿"
h1: "包分家、各自更新、進度即時"
subtitle: "決策者 2026-11-01 裁 D1～D12 後的定案；拆卡表由 handoff/cards.py 產，19 張 10 批；P3／P4 等 FR-171 批 3"
lede: "🔴 **這份是設計決策，不是現況。** 內文停在 2026-11-01 定稿當時，現況看 FINAL-SPEC.md（結案時寫）。本案前提：**出貨前無客戶**，不做舊客戶過渡、不補登 1.4.8、更新站 `product` 必填；**Agent 不連外網**，包只從主系統拿、離線靠主控台上傳；**出包／CI／簽章金庫屬 FR-173**，本案只定包長什麼樣、更新站怎麼存、主系統與 Agent 怎麼用。"
chips: [
  {text: "D1～D12 已裁", kind: ok},
  {text: "19 張卡 10 批、4 repo", kind: accent},
  {text: "P3／P4 等 FR-171 批 3 合回", kind: warn},
  {text: "Agent 不連外網", kind: crit},
]
footer: "FR-172 設計文件 · 2026-11-01 · 討論稿 discussion.md · 雛形 prototype/index.html · 拆卡表 handoff/cards.py"
---

> 📑 **本頁是 v1.0（定稿）。** 190 實測後有設計異動（推翻 D9 兩步改一步，補定維護模式、同版重套等六條），見 **[設計 v1.1](design-v1.1.md)**。本頁內容保留定稿原樣，不回頭改；現況看 [FINAL-SPEC.md](FINAL-SPEC.md)。

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

**整案一句話**：Agent 的「貨」（安裝包、版本線、下載、推送畫面）從主系統身上拆下來自己走；「線」（叫升級、回報進度）用 FR-171 的通道。

| 階段 | 做什麼 | 卡 | 完成怎麼判定（決策者檢查法） |
|---|---|---|---|
| P1 出貨分家 | 更新站多 Agent 版本線；Agent 的章另一型別；Agent 包帶清單與公鑰；主系統包不帶 `agent/` | U-1 U-2 A-0 B-0 | 更新站後台有「主系統」「Agent」兩分頁；主系統包解開沒有 `agent/`；Agent 包有 `release.json` 與 `release-keys/` |
| P2 主系統 Agent 區 | 自己查 Agent 新版、預先下載、相容兩向、推選定的台（雛形①②⑤） | H-1 H-2 H-3 H-4 H-5 | 主系統不升版，Agent 區按「立即檢查」看到 Agent 新版、按「預先下載」、按「更新選取的台」 |
| P3 進度即時 | Agent 每步當下送事件；八格跟著走；失敗看得到主機原因與全量 log（雛形③④） | E-3 E-4 E-5 | 推一台 3 秒內八格開始走；塞滿磁碟推一次「看原因」出現 `no space left on device`；「升級紀錄」下載到 journal |
| P4 主控台更新頁 | 看版本、請主系統更新這一台、離線上傳（雛形⑥～⑨） | G-1 G-2 G-3 | 登入 Agent 主控台按更新，主系統更新中心同時出現這台進度；拔掉主系統上傳離線包，Agent 自己升上去 |
| P5 收尾與併修 | 待辦 58；189 Demo 的 CSP 與 CINC 報告；結案出 1.28.0／Agent 1.5.0 | S-1 S-2 S-3 S-4 | 189 重裝新包後上傳型掃描不被 CSP 擋、CINC 報告看得到 |

**依賴一句**：P1、P2、S-1～S-3 不等 FR-171；**P3、P4 的 Stage 從 10 起算，等 FR-171 批 3（A-1 A-2 E-1 合回）**。

---

## 1. 決策定案表 {#decisions nav="決策"}

| # | 題目 | 定案 | 被排除的 | 理由 |
|---|---|---|---|---|
| 已裁 | Agent 連更新站 | **不**；包只從主系統拿、離線靠主控台上傳 | Agent 直連 | 客戶內網掃描機不出網是賣點也是資安前提 |
| 已裁 | 主控台「發動更新」 | 主系統建一張只含這台的單並推送 | 跳轉連結 | 主控台頁自足 |
| D1 | 更新站存法 | `product_releases` 加 `product` 欄，主鍵 `(product, version)`，Agent 列多 `min_host_version`；**`product` 必填不預設** | 另開表 | 另開表讓發布、撤回、保留、下載紀錄每支寫兩份 |
| D2 | Agent 的章 | `payload_type=agent_release_manifest`，同一把章 | 同型別加欄 | 兩種型別各驗各的，拿 Agent 清單餵主系統第一道門就拒 |
| D3 | 相容兩向 | 只擋「Agent 新版 `min_host_version` > 主系統現行版」（灰鈕＋原因）；主系統新版 `min_agent_version` > 某台只標「必須更新」不擋主系統升級 | 雙向都擋 | Agent 比主系統新是允許的 |
| D4 | Agent CI | 屬 FR-173 | — | — |
| D5 | 離線上傳驗章 | Agent 包隨包帶 `release-keys/`（與主系統同一批）；**驗章永遠用現行版的公鑰，不用上傳包裡的**；換版後新包的公鑰才生效；相容檢查離線時只提示不擋；上傳需 `update` 權限並寫稽核 | 註冊時主系統傳公鑰、Agent 專屬章 | 自己驗自己是洞；離線場景人在現場 |
| D6 | 主控台權限 | 新能力點 `update`，local-admin 預設有 | 沿用 `restart` | 換版不可逆，授權面不同 |
| D7 | 本機 Agent 包目錄 | `upgrade/agent-bundles/<Agent 版>/`，保留「各台現行版中最舊」與「最新」兩份 | 沿用 `bundles/<主系統版>/agent/` | 解掉 FR-167 待辦 42 |
| D8 | 失敗原因 | 兩層：每步 stderr 最後 20 行（≤4 KB、過濾不可列印字元）進事件表給畫面；全量升級 journal 打包上傳主系統當更新單附件，紀錄頁「升級紀錄」下載 | 只給一句 | 60.167 磁碟滿只拿到七個字 |
| D9 | Agent 區步驟 | 對齊成「預先下載 → 更新」兩步 | 一步推送 | 1.8 GB 誰按何時下載要有紀錄 |
| D10 | 舊客戶過渡 | **不做** | 搬包＋補登 1.4.8 | 出貨前無客戶 |
| D11 | FR-167 搬入待辦 | 併 63（後台三欄）、58（PDF 快取）、61（CINC 網址型 Profile，開 H-5）；60、42 由新機制涵蓋；**不併** 62 | — | 62 屬主系統自升級 |
| D12 | 批次 | P1／P2／S 立刻；P3／P4 等 171 批 3 | — | 見 §6 |
| 另案併修 | 189 Demo | CSP 擋 8444（S-3）、CINC 報告格式與 HTML 線上看（S-2＋S-3）併進本案一起出包 | 另開案 | 不然做完又要再 build 一次 |

---

## 2. 兩條版本線怎麼分 {#split nav="版本線"}

| | 主系統 | Agent |
|---|---|---|
| 版號 | 1.28.0 | 1.5.0 |
| 包 | `guidant-ai-<版>.tar.gz`（不含 `agent/`） | `guidant-agent-compose-<版>.tar.gz`（含 `release.json` 未簽、`release-keys/`、`notes.json`） |
| R2 | `v<主版>/<版>/` | `agent/v<主版>/<版>/` |
| 更新站列 | `product=host`、`min_agent_version` | `product=agent`、`min_host_version` |
| 章 | `release_manifest` | `agent_release_manifest` |
| 誰驗 | 主系統（現行版 `release-keys/`） | 主系統代下載時驗一次；Agent 離線上傳時用**現行版 Agent 包內**的 `release-keys/` 再驗一次 |
| 離線檔 | `guidant-ai-<版>.offline.tar.gz` | `guidant-agent-<版>.offline.tar.gz`（U-2 的 `publish --product agent` 產） |
| 版本說明 | release note 檔尾 3～5 條 | `RELEASE_NOTES.md` 檔尾 3～5 條（A-0 讀進 `notes.json`） |
| 最低相容 | `scripts/build/min_agent_version` | `scripts/build/min_host_version` |

```{.mermaid cap="圖 1 — Agent 更新一輪：從更新站到各台 Agent，主系統是唯一中繼。虛線是 FR-171 的通道。"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
  autonumber
  participant U as 更新站
  participant M as 主系統（Agent 區）
  participant A as Agent
  participant H as Agent 主機 systemd
  M->>U: 每 6 小時／立即檢查 GET /v1/check?product=agent
  U-->>M: 1.5.0、min_host_version、sha256、說明
  Note over M: 相容：min_host_version ≤ 主系統現行版 才能下載
  M->>U: 預先下載 GET /v1/download-url?product=agent
  M->>M: 下到 agent-bundles/1.5.0/，驗 agent_release_manifest 章＋sha256，寫稽核
  M->>A: 推送：寫 rollout → 直推 POST /update {target, sha256, size}（FR-171 A）
  A->>M: GET /agents/update/bundle（Range 續傳）
  A-->>M: 事件 update_download 每 5%（FR-171 E）
  A->>A: 驗 sha256、寫升級單
  H->>H: path 單元 → 預檢→停工作→換版→健檢→清理，每步寫進度檔（失敗帶 stderr 末 20 行）
  A-->>M: 事件 update_step 每步
  H->>H: 新容器起來
  A-->>M: 事件 update_result ＋ 上傳升級 journal
  M->>M: rollout 列 succeeded／failed → 下一批或暫停
  M->>U: POST /v1/report target=agent
```

---

## 3. 主系統側 Agent 區（P2） {#host-side nav="主系統側"}

- **檢查**：`core/scheduler_update.py` 的 6 小時檢查與「立即檢查」同時問 `product=host` 與 `product=agent`。
- **預先下載**：`agent_release_service.download()` → `agent-bundles/<版>/`，驗章用 `package_verifier` 加 `expected_payload_type` 參數（不另寫一支），寫稽核 `AGENT_BUNDLE_DOWNLOAD` 含下載人。
- **相容**：`host_compatible(min_host_version, host_version)` 取代 `host_allows`；overview 回 `compatible` 與 `reason` 給畫面。
- **推送**：沿用 `agent_update_rollouts`（先 1 台、再其餘、任一失敗其餘暫停、暫停／繼續／稍後提醒／只重推這一台）；兩處換掉：包來源、下發改 FR-171 `command('/update')`。
- **退役**：`host_version` 欄改 `agent_bundle_version`；主系統升級第 7 步不再保留 `bundles/<版>/agent/`；`_PROGRESS_CACHE`、`GET /update/progress`、`agent_update_notify_client` 刪。
- **「接著更新 Agent」勾選**（FR-167 D16）保留，語意改「主系統升完若 `agent-bundles/` 已有相容新版就自動建列」。

---

## 4. 進度與結果事件（P3） {#events nav="進度事件"}

| kind | 誰送、何時 | payload | 主系統做什麼 |
|---|---|---|---|
| `update_download` | Agent 容器，每 5% 或每 10 秒 | `{job_id, bytes_done, bytes_total}` | upsert `agent_update_events` 該列的 download 欄 |
| `update_step` | Agent 容器監看進度檔變動 | `{job_id, n, name, status, started, ended, limit_s, detail≤4KB}` | 追加該列 steps |
| `update_result` | 新容器啟動讀升級單結果（容器換掉那幾步由新容器補送） | `{job_id, status, version, error, log_ref}` | 寫 rollout 終態、觸發 `_advance`、清 download |
| 升級 log | 新容器啟動 `POST /agents/update/log`（≤10 MB） | journal tar | 存成更新單附件，紀錄頁「升級紀錄」鈕 |

送不到進 FR-171 outbox（kind `event`），重連補送、順序照 `created_at`；主系統按 `(agent_uid, job_id, kind, at)` 冪等。畫面 3 秒輪詢只讀 `agent_update_events`，**不再去拉 Agent**。

---

## 5. Agent 主控台更新頁（P4） {#console nav="主控台"}

| 元素 | 資料 | 動作 |
|---|---|---|
| 現行版、上次更新 | 本機升級單結果檔 | — |
| 「主系統上有 Agent 新版 X」／「已是最新」／「要先更新主系統」 | `GET /agents/update/available`（通行證＋DPoP） | 「請主系統更新這一台」→ `POST /agents/update/request` → 主系統 `push(chosen=[this], actor='agent-console:<操作者>')` → 走 §2 圖 |
| 八格、失敗原因 | 本機進度檔（與送主系統的同一份） | — |
| 離線上傳 | 上傳 `.offline.tar.gz` → 用**現行版** `release-keys/` 驗 `agent_release_manifest` → 驗 sha256 → 版本比現行新 → 磁碟夠 → 寫升級單 | 相容只提示；需 `update` scope；console_events 記一筆 |

---

## 6. 拆卡表 {#cards nav="拆卡表"}

由 `handoff/cards.py` 產。**登記型檔 owner**：update-center `releases.ts`／`migrations/` U-1；Agent `CONSOLE_SCOPES`／console blueprint 掛載 G-1；BE `product_update_route.py`／`app_modules.py` H-1；FE `api.js` H-3、console `router.js` G-3（與 FR-171 K-6 不同批）。

| 批 | 卡 | 階段 | repo | 做什麼 | reuse／extend／new | 改既有檔 | 新開檔 | 依賴 | 驗收（小弟本機） | model／effort |
|---|---|---|---|---|---|---|---|---|---|---|
| 1 | **U-1** | 出貨分家 | update-center | 更新站多一條 Agent 版本線：product_releases 加 product 欄（主鍵 product+version）、Agent 列多 min_host_version；r2Key 對 agent 拼 agent/v<主版>/<版>/；三個客戶端端點 product 必填；後台 Agent 分頁與三欄改比 Agent 版本線 | extend：`releases.ts` 全部查詢加 `product` 條件、`r2Key(product, major, version, filename)`；`registerDraft` 收 `product`／`min_host_version`；`releaseAdoption` 的 agent 三欄改 `WHERE product='agent'` 對 `agent_version_reports`（FR-167 待辦 63）；`routes/client.ts` 三支 `product` 缺就 400；後台 `routes/admin.ts` 版本頁分兩個分頁。**登記型檔 owner**：`src/releases.ts`、`migrations/` | `src/releases.ts`<br>`src/routes/client.ts`<br>`src/routes/admin.ts`<br>`src/instances.ts`<br>`test/` | `migrations/0005_product_column.sql`<br>`test/releases_agent.spec.ts` | — | vitest：`r2Key('agent',1,'1.5.0','x.tar.gz')`＝`agent/v1/1.5.0/x.tar.gz`、`r2Key('host',…)` 不變；`/v1/check` 不帶 product 400；帶 `product=agent` 只回 agent 列且帶 `min_host_version`；後台 Agent 分頁三欄用 agent 回報算（突變：拿掉 product 條件 → 紅）。`wrangler dev` 套 0005 後本機 D1 既有 10 列 product=host | opus／high |
| 1 | **A-0** | 出貨分家 | evidence-agent | Agent 包分家：build_compose_bundle.sh 產 release.json（未簽，product=agent、min_host_version 讀 scripts/build/min_host_version 一行檔、sha256、size）、包內帶 release-keys/（與主系統同一批公鑰與 PROD_KIDS）、版本說明改讀 RELEASE_NOTES.md 檔尾 3～5 條 | extend：`build_compose_bundle.sh` 收尾段加 release.json 與 release-keys；`notes.json` 產法不變但來源改 RELEASE_NOTES.md；min_host_version 檔不存在＝不限 | `scripts/build/build_compose_bundle.sh`<br>`scripts/build/README.md`<br>`RELEASE_NOTES.md（加「版本說明怎麼寫」段）` | `scripts/build/min_host_version`<br>`scripts/build/agent_release_json.sh`<br>`test/shell/agent_release_json.bats` | — | bats：對假 bundle 目錄跑 → release.json 欄位齊、sha256 與 .sha256 一致、`min_host_version` 檔有值就帶沒有就不放鍵；包內 `release-keys/PROD_KIDS` 與 BE `scripts/build/release-keys/` 同內容（守衛：diff 為空）。shellcheck 綠。**不在 188 出包** | opus／medium |
| 1 | **B-0** | 出貨分家 | BE | 主系統包不再帶 Agent：build_bundle.sh 拿掉 ⑧ 收 Agent 包與「正式包必須帶 Agent」守門；--skip-prod-key-check 不再管 Agent；bundle-info 拿掉 Agent 列 | extend：刪段 | `scripts/build/build_bundle.sh 337–369、463–469、518`<br>`scripts/build/README.md`<br>`docs/features/FR-167-2610-online-update-channel/release-runbook.md 2.3 段` | — | — | bats／shellcheck 綠；對假目錄跑 build_bundle 不找 `.build/compose/`；bundle-info 無 Agent 列；`grep -n AGENT_COMPOSE_BUNDLE scripts/build/` 為 0 | sonnet／low |
| 1 | **S-1** | 收尾與 189 Demo 併修 | evidence-agent | 待辦 58：Agent 主控台檔案頁三個查詢加 ref_id IS NULL（PDF 預覽快取不列成證據） | extend | `guidant_agent/files/adapters/customer_files_repo.py`<br>`test/files/` | — | — | pytest：有 ref_id 的列不出現在清單、數量、佔用 | sonnet／low |
| 1 | **S-2** | 收尾與 189 Demo 併修 | BE | 189 Demo 併修（BE）：CINC 報告 report_ref 同時接受 upload_uids 格式、有報告就有「看報告」；HTML 報告由主系統代取後以 sandbox 內容回給 FE（Content-Type text/html、CSP sandbox header、不跑腳本） | extend：`_agent_local_runs.py:50` `_report_file_uid` 加 upload_uids 分支；新 route `agent-local-runs/<uid>/report/view` reuse `RemoteAgentAdapter.fetch_agent_blob` 與證據預覽的 sandbox header 做法；FR-166 design.md `report_file_uid` 改對 | `core/plugins/_agent_local_runs.py`<br>`api/remote_agent/routes/agent_local_run_route.py`<br>`docs/features/FR-166-2610-agent-standalone-console/design.md` | `test/test_agent_local_run_report.py` | — | pytest：upload_uids 格式 has_report=True；view 端點回 `Content-Security-Policy: sandbox` 且 `X-Content-Type-Options: nosniff`；FR-166 design 字串改對 | opus／medium |
| 2 | **U-2** | 出貨分家 | update-center | 蓋章 CLI：guidant-release 加 --product host|agent；Agent 清單 payload_type=agent_release_manifest、含 min_host_version；publish --product agent 順帶產離線單一檔 guidant-agent-<版>.offline.tar.gz；驗章函式按型別各驗各的 | extend：`cli/envelope.ts` `signRelease` 加 `payloadType` 參數；`cli/commands.ts` 三個子命令加 `--product`；`verifyRelease` 的型別檢查改「等於呼叫端指定的型別」不寫死 | `cli/envelope.ts`<br>`cli/commands.ts`<br>`cli/site.ts`<br>`cli/README.md`<br>`test/cli/` | `test/cli/agent_manifest.spec.ts` | U-1 | vitest：`--product agent` 簽出的 canonical `payload_type=agent_release_manifest` 且有 `min_host_version`；用 host 驗證器驗 agent 清單 → bad_signature（突變：驗證器不看型別 → 紅）；離線檔內含包＋簽好的 release.json＋.sha256。用測試章實簽一份存 `test/fixtures/agent-release-1.5.0.json` 給 BE／Agent 卡當 fixture | opus／medium |
| 2 | **H-3** | 主系統側 Agent 區 | FE | FE 登記型檔佔位：api.js 加 agents/check、agents/download、agents/events 三個常數；i18n 鍵占位 | extend | `src/config/api/api.js`<br>`src/locales/*/product-update.json` | — | — | `vite build` 0 | sonnet／low |
| 2 | **S-3** | 收尾與 189 Demo 併修 | FE | 189 Demo 併修（FE）：前門 CSP connect-src 動態帶入登記的 Agent 位址（api 啟動產 include 檔、fe 容器 entrypoint 讀取、Agent 新增或改位址時重載）；Agent 執行紀錄頁「看報告」用 sandbox iframe | extend：`nginx.onprem.conf` 的 CSP 行改 `include /etc/nginx/conf.d/agent-origins.conf`；entrypoint 從 api `GET /internal/agent-origins` 產檔並 `nginx -s reload`；`AgentLocalRunsView.vue` 加「看報告」開 `<iframe sandbox>`（用 `.attr`，見 memory iframe_sandbox_attr） | `nginx.onprem.conf`<br>`docker/entrypoint.sh`<br>`src/views/remote-agent/AgentLocalRunsView.vue`<br>`src/views/remote-agent/__tests__/AgentLocalRunsView.spec.js` | `docker/agent-origins.sh` | S-2 | 本機 docker 跑 fe image：有兩台 Agent 時 CSP header 含兩個 https 來源；新增一台後 reload 含三個；iframe 有 `sandbox` 屬性；`vite build` 0 | opus／medium |
| 3 | **H-1** | 主系統側 Agent 區 | BE | 主系統 Agent 版本線：更新站 client 帶 product=agent 檢查、Agent 包下載到 upgrade/agent-bundles/<Agent 版>/ 並驗 agent_release_manifest 章與 sha256、相容兩向判定、保留兩份清理、host_version 欄退役改 agent_bundle_version、升級第 7 步不再保留 bundles/<版>/agent/ | extend：`update_site_client.py` `check()`／`download_url()` 加 `product`；`package_downloader.py`／`package_verifier.py` 加 `expected_payload_type` 參數（不另寫一支）；`agent_rollout_service.find_agent_bundle` 改掃 `agent-bundles/`；`host_allows` 改 `host_compatible(min_host_version, host_version)`；migration 改欄名。**登記型檔 owner**：`product_update_route.py`、`config/app_modules.py`（一次掛 H-2 的端點空殼） | `app/product_update/service/update_site_client.py`<br>`app/product_update/service/package_downloader.py`<br>`app/product_update/service/package_verifier.py`<br>`app/product_update/service/agent_rollout_service.py 205–272`<br>`app/product_update/service/product_update_flow.py（第 7 步）`<br>`infra/product_update/agent_update_rollout_model.py`<br>`api/product_update/routes/product_update_route.py`<br>`config/app_modules.py`<br>`test/test_fr167_agent_rollout.py` | `app/product_update/service/agent_release_service.py（檢查／下載／驗／保留）`<br>`scripts/sql/2026-11-xx-fr172-agent-bundle-version.sql`<br>`test/test_agent_release_service.py` | U-2 | pytest：用 U-2 fixture 驗章過、把型別改 host 的 fixture 驗不過（突變）；`min_host_version` 高於現行 → `compatible=False` 且原因字串；下載後目錄 `agent-bundles/1.5.0/` 有包＋清單；保留規則：三版只留最舊現行與最新。migration 只套 DEV、回報基線待重產。實打 8003 `GET /product-update/agents` 看到 `agent_release` 區塊 | opus／high |
| 3 | **H-5** | 主系統側 Agent 區 | BE | 待辦 61：網址型 Profile 匯入時順便存檔，之後當檔案型同步給 Agent（CINC 下拉才有東西） | extend：`profile_import` 網址型解析完 `file_id` 補寫（存進既有 upload_files）；`_agent_profile_sync.py` 同步條件不變（有 file_id 就同步） | `app/detection_profile/（網址型解析 service）`<br>`test/` | — | — | pytest：網址型匯入後 `file_id` 非空；DEV 重新匯入 dev-sec 兩支後 121 本機 Agent 拉 config 看到 CINC 兩筆 | opus／low |
| 4 | **H-2** | 主系統側 Agent 區 | BE | 更新中心 Agent 區端點：POST agents/check（問更新站 Agent 新版）、POST agents/download（預先下載，寫稽核下載人）、overview 回 Agent 版本線狀態與相容原因；每 6 小時排程同時問 host 與 agent | extend：填 H-1 掛好的空殼；`core/scheduler_update.py` 的 6 小時檢查加一次 agent | `app/product_update/service/product_update_app_service.py`<br>`core/scheduler_update.py`<br>`app/product_update/service/agent_rollout_service.py（overview 組裝）` | `test/test_agent_release_endpoints.py` | H-1 | 實打 8003：check 回 `{current, latest, compatible, reason, size, notes}`；download 寫稽核 `AGENT_BUNDLE_DOWNLOAD` 含下載人；overview `bundle_ready` 由 agent-bundles 判；排程 log 看到兩次 check | opus／medium |
| 5 | **H-4** | 主系統側 Agent 區 | FE | 更新中心 Agent 區 v3（雛形①②⑤）：Agent 自己的版本線、立即檢查、預先下載、相容提示、要先升主系統的灰鈕、更新確認視窗第 4 條文案；更新紀錄來源欄加「主系統下載／離線上傳（Agent 主控台）」 | extend：`AgentRolloutPanel.vue`＋`useAgentRollout.js` 改讀新 overview；主系統卡元件不動 | `src/views/product-update/AgentRolloutPanel.vue`<br>`src/composables/useAgentRollout.js`<br>`src/views/product-update/agentRolloutState.js`<br>`src/views/product-update/__tests__/` | — | H-2, H-3 | vitest：`compatible=false` 時預先下載鈕 disabled 且顯示 reason；已下載未推 → 顯示「已下載，可更新」；接 8003 手點一輪；`grep border-left` 0；`vite build` 0 | opus／high |
| 10 | **E-3** | 進度與結果即時（等 FR-171 批 3） | evidence-agent | Agent 升級事件：下載每 5%／10 秒、主機每步開始結束、最終結果，用 FR-171 send_event 當下送；新容器啟動補送；upgrade_order.sh 每步失敗把 stderr 最後 20 行寫進度檔 detail（≤4 KB）；升級完把全量 journal 打包上傳主系統當附件 | extend：`update_sync.py` 的 `_fetch` 下載迴圈與 `current_progress` 改成呼叫 `event_sender.send_event('update_download'|'update_step'|'update_result', …)`；`upgrade_order.sh` 的 `_uo_*` 失敗路徑收 stderr；新 `sync/app/update_log_upload.py` 用既有 blob 上傳路徑 `POST /agents/update/log`；拔掉心跳 `upgrade_result` 與 `GET /update/progress` | `guidant_agent/sync/app/update_sync.py 449`<br>`scripts/install/lib/upgrade_order.sh`<br>`scripts/install/lib/progress.sh`<br>`scripts/install/lib/upgrade_runner.sh（收尾打包 journal）`<br>`test/shell/upgrade_order.bats`<br>`test/sync/test_update_sync.py` | `guidant_agent/sync/app/update_events.py`<br>`guidant_agent/sync/app/update_log_upload.py` | — | pytest：下載 1.8 GB 假檔產生 ≥20 筆 download 事件；進度檔變動一次送一筆 step；容器重啟後補送 result；bats：docker stub 失敗時 detail 含 `no space left on device` 且 ≤4 KB、不含不可列印字元；journal 打包 ≤ 10 MB 上傳一次。grep 無 `upgrade_result` | opus／high |
| 10 | **E-4** | 進度與結果即時（等 FR-171 批 3） | BE | 主系統收升級事件：agent_update_events 表（每台每 job 一列 upsert，存最後進度與全部步驟與 detail）、三個 kind 的 handler 掛進 FR-171 事件分派表、升級 log 附件端點、rollout 終態由 result 事件寫、拔掉拉進度與 _PROGRESS_CACHE | extend：往 FR-171 E-1 的 `AgentEventDispatcher` 掛 `update_download`／`update_step`／`update_result` 三個 handler；`agent_rollout_service.record_result` 改由事件呼叫；刪 `_PROGRESS_CACHE`、`agent_update_notify_client` 殘留；log 附件 reuse 證據上傳的儲存路徑存成更新單附件 | `app/product_update/service/agent_rollout_service.py 65–67、410、633–682`<br>`core/plugins/_agent_update.py`<br>`test/test_fr167_agent_rollout.py` | `infra/product_update/agent_update_event_model.py`<br>`domain/product_update/agent_update_event_entity.py`<br>`app/product_update/service/agent_update_event_handlers.py`<br>`api/remote_agent/routes/agent_update_log_route.py`<br>`scripts/sql/2026-11-xx-fr172-agent-update-events.sql`<br>`test/test_agent_update_event_handlers.py` | H-1 | pytest：step 事件 upsert 同列 steps 長度遞增；result succeeded → rollout 列 succeeded 並觸發 _advance；重送同 (job_id, kind, at) 冪等；grep 無 `_PROGRESS_CACHE`、無 `update/progress`。migration 只套 DEV。本機 Agent 連 8003 推一台，events 表 3 秒內有列 | opus／high |
| 11 | **E-5** | 進度與結果即時（等 FR-171 批 3） | FE | Agent 區八格改讀事件表（雛形③④）：每格顯示時間與耗時、下載量、失敗那格標紅、「看原因」展開 detail、「升級紀錄」下載鈕與診斷包並排、失敗後「只重推這一台」 | extend：`agentRolloutState.js` `agentStepRows` 改吃 `agent.update_event`；`AgentRolloutPanel.vue` 展開區加 detail 與下載鈕；「只重推這一台」reuse 既有 push 選單 | `src/views/product-update/agentRolloutState.js`<br>`src/views/product-update/AgentRolloutPanel.vue`<br>`src/views/product-update/__tests__/` | — | E-4, H-4 | vitest：給 7 步 steps 第 8 格 pending；failed 於第 3 步 → 第 3 格 bad 其餘空；detail 預設收起點開顯示；`grep border-left` 0 | opus／medium |
| 11 | **G-1** | Agent 主控台更新頁（等 FR-171 批 3） | evidence-agent | Agent 主控台更新 API：能力點 update；GET /console/update/status（現行版、上次更新、向主系統問 available）；POST /console/update/request（請主系統更新這一台）；POST /console/update/offline（上傳離線檔→用現行版 release-keys 驗 agent_release_manifest 章→驗 sha256→寫升級單）；console_events 記誰按了什麼 | extend：`shared/constants.py` CONSOLE_SCOPES 加 `update`；新 `console/api/update_routes.py` 掛 blueprint；驗章 reuse Agent 既有 `verify-tree` 的 Ed25519 驗簽 helper 加 `payload_type` 參數；寫升級單 reuse `update_sync._write_order`。**登記型檔 owner**：`shared/constants.py`、`console/api/` 掛載 | `guidant_agent/shared/constants.py`<br>`guidant_agent/console/api/（掛載）`<br>`guidant_agent/sync/app/update_sync.py（_write_order 開放給 console 用）`<br>`test/console/` | `guidant_agent/console/api/update_routes.py`<br>`guidant_agent/console/app/update_console.py`<br>`test/console/test_update_routes.py` | A-0, E-3 | pytest：無 `update` scope 403；離線檔用上傳包內的公鑰換掉後驗不過（突變：改用上傳包內公鑰 → 紅）；sha256 不符拒；成功寫升級單且 console_events 有一筆；status 在主系統連不上時回 `main_unreachable` 不炸 | opus／high |
| 12 | **G-2** | Agent 主控台更新頁（等 FR-171 批 3） | BE | 主系統給 Agent 主控台的兩支：GET /agents/update/available（這台相容的最新 Agent 版與說明）、POST /agents/update/request（建一張只含這台的 manual rollout，按更新的人記 agent-console:<操作者>）；Agent 管理頁勾權限清單多 update 一格 | extend：route 用 `agent_pass_required`；request reuse `agent_rollout_service.push(chosen=[this])`；離線操作者權限清單在 `_agent_offline_operators.py` 加一項 | `app/product_update/service/agent_rollout_service.py（push 的 actor 參數）`<br>`core/plugins/_agent_offline_operators.py`<br>`test/` | `api/remote_agent/routes/agent_update_request_route.py`<br>`test/test_agent_update_request.py` | H-2, G-1 | 實打：available 回相容最新版或 `requires_host`；request 建 rollout 一列 `actor=agent-console:local-admin`，並經 FR-171 `command('/update')` 直推這台；權限清單多一格 | opus／medium |
| 13 | **G-3** | Agent 主控台更新頁（等 FR-171 批 3） | FE | Agent 主控台「更新」頁（雛形⑥⑦⑧⑨）：維運群組加一項（scope update）、四種狀態、請主系統更新確認框、離線上傳拖放與五項檢查清單、更新中八格 | extend：`console/router.js` 加一項；`Update.vue` 照 `Ops.vue` 版面；八格元件與主系統 FE 的 `UpgradeStepList` 不共用（兩個 build 分開）但 rows 形狀一致 | `console/router.js`<br>`console/api.js` | `console/views/Update.vue`<br>`console/update.js`<br>`console/updateApi.js` | G-1, G-2 | `npm run build:console` 0；四狀態切換；上傳假檔顯示五項檢查；`grep border-left` 0 | opus／medium |
| 14 | **S-4** | 收尾與 189 Demo 併修 | BE | 結案：版號 BE 1.28.0／Agent 1.5.0、本機新裝＋升級驗、188 出包（主系統包不含 agent/；Agent 包含 release.json 與 release-keys/）、開包 grep；FR-167 CM-3378 一併收 | reuse：P12-5（CM-3378）流程，少掉 Agent 進主系統包那段 | `pyproject.toml`<br>`docs/features/FR-167-2610-online-update-channel/handoff/fr167-STATE.md（CM-3378 收）` | — | S-1, S-2, S-3, E-5, G-3 | 主系統包解開無 `agent/`、小 ≥1.5 GB；Agent 包有 `release.json`（payload 未簽）與 `release-keys/PROD_KIDS`；本機新裝 0 錯；回寫兩包路徑與 sha256 | opus／high |

共 19 張、10 批；update-center 2、evidence-agent 4、BE 8、FE 5。

**跨案依賴**：E-3 用 FR-171 E-2 的 `send_event`；E-4 往 FR-171 E-1 的分派器掛 handler；G-2 用 FR-171 A-1 的 `command('/update')`。批 10 起由首腦在 FR-171 批 3 合回後才按「開始」。

---

## 7. 端到端驗收（結案時首腦操作） {#e2e nav="驗收"}

1. 更新站後台 Agent 分頁有 1.5.0；主系統 Agent 區「立即檢查」看到、「預先下載」後 `agent-bundles/1.5.0/` 有包與清單、稽核有下載人。
2. 推 3 台：第 1 台八格 3 秒內開走、每格有時間；第 2 台故意塞滿磁碟 → 失敗那格紅、「看原因」有 `no space left on device`、「升級紀錄」下載到 journal、其餘暫停；「只重推這一台」成功。
3. Agent 主控台「更新」頁按「請主系統更新這一台」→ 主系統更新中心同時出現；拔主系統上傳離線包 → Agent 自己升；用改過 `release-keys/` 的假包上傳 → 驗章失敗。
4. 主系統包解開無 `agent/`；`/v1/check` 不帶 `product` 回 400。
5. 189 重裝新包：上傳型掃描不被 CSP 擋；CINC 執行紀錄「看報告」在 sandbox iframe 開得起來。

---

## 8. 不做什麼 {#out nav="不做"}

Agent 直連更新站、Agent 差異包、Agent 自動裝、主系統版本線改名或搬 R2、第二把發布私鑰、舊客戶過渡、心跳備援、FR-167 待辦 62。
