---
title: "線上更新狀態機 (FR-167)"
brand: "Guidant AI · **FR-167** 線上更新"
eyebrow: "FR-167 · 技術規格 · 2026-11-01"
h1: "狀態機"
subtitle: "更新紀錄 product_update_jobs、Agent 跟進列 agent_update_rollouts、主機進度檔 upgrade-progress.json"
lede: "線上更新有三份會變的狀態：主系統資料庫的**更新紀錄**（一列一次換版）、**Agent 跟進列**（一台 Agent 一列），以及主機上的**進度檔**（升級程式唯一的輸出）。主系統不能叫主機做事，只能寫更新單；主機也不能打主系統，只能寫進度檔。所以更新紀錄的後半段（排程之後）全靠主系統每分鐘讀進度檔同步。本頁每個狀態值與轉移條件都對到程式碼行號。"
chips: [
  {text: "更新紀錄 10 個狀態值，實際會寫 9 個", kind: accent},
  {text: "Agent 跟進列 5 個狀態值", kind: accent},
  {text: "進度檔 status × result 七種組合", kind: accent},
  {text: "第 3 步前就斷（unchanged）一律記 skipped、可重試", kind: crit}
]
footer: "FR-167 · 狀態機 · 2026-11-01 · 設計脈絡見 [design.md](design.md) · 沿革見 LOG 與 git log"
---

## 三份狀態怎麼連起來 {#overview nav="總覽"}

| 狀態 | 存在哪 | 誰寫 | 誰讀 | 怎麼跟其他兩份對上 |
|---|---|---|---|---|
| 更新紀錄 | `public.product_update_jobs.status` | 主系統後端（使用者操作、下載 worker、每分鐘同步排程） | 更新中心畫面 | 寫更新單時記 `job_id`；同步排程以 `job_id` 找進度檔 |
| Agent 跟進列 | `public.agent_update_rollouts.status` | 主系統後端（每分鐘排程、心跳、更新中心 Agent 區） | 更新中心 Agent 區、心跳下發 | 進度檔 `status=succeeded` 且 `to`＝現行版時建列；`host_version`＝主系統那一版 |
| 進度檔 | 主機 `<資料目錄>/upgrade/upgrade-progress.json` | 只有主機的 `scripts/installer/lib/progress.sh` | 維護頁、更新中心、開機檢查、診斷包 | `job_id`＝更新單的 `job_id` |

Agent 主機上另有一份同契約的進度檔（evidence-agent `scripts/install/lib/progress.sh`），由 Agent 讀了隨心跳帶回主系統，推進跟進列，見 §3.4。

主系統讀進度檔的地方有兩個（`core/scheduler_update.py:39-67`、`app/product_update/service/product_update_app_service.py:130-150`）：

| 讀的時機 | 做什麼 |
|---|---|
| 每分鐘排程 `product_update_order_sync` | `sync_from_progress` 同步紀錄 → `after_switch` 清舊檢查並重查新版 → 回報更新站（一次）→ 補寫系統紀錄（一次）→ Agent 跟進 `tick` → Agent 結果回報更新站 |
| 畫面輪詢 `GET /system/product-update/status` | `after_switch(recheck=False)`：換接剛完成時立刻同步紀錄，不等排程；不打更新站 |

## 更新紀錄 product_update_jobs.status {#jobs nav="更新紀錄"}

### 2.1 狀態值

允許值見 CHECK 約束（`scripts/sql/2026-10-29-fr167-product-update-jobs-failed-status.sql`）與 `domain/product_update/entity/product_update_job_entity.py:5-6`。

| 狀態 | 意思 | 包在不在主機 `incoming/` | 終態？ |
|---|---|---|---|
| `checking` | 保留值。允許但目前沒有任何程式寫它；「檢查中」是 `last_check` 系統設定，不建紀錄列 | — | — |
| `downloading` | 下載 worker 正在下載（線上） | 下載中（`.part`） | 否 |
| `ready` | 包已下載或上傳、驗過，等管理員按更新（或等自動寫單） | 在 | 否 |
| `scheduled` | 已寫更新單；立即更新的 `not_before`＝寫單當下 | 在 | 否 |
| `running` | 主機進度檔顯示這張單正在跑 | 在 | 否 |
| `succeeded` | 主機換接完成、新版已開放使用 | 在（本版留著） | 是 |
| `rolled_back` | 主機升級失敗、已自動退回 | 在 | 是，但可重試 |
| `skipped` | 這次沒有升級、系統沒動（預檢不過、等不到工作做完、執行前驗包不過、開機檢查或 recover 發現第 3 步前就斷） | 在 | 是，但可重試 |
| `cancelled` | 管理員取消下載或取消就緒的包 | 已刪 | 是 |
| `failed` | 包已刪（下載失敗、驗章或雜湊不過、離線上傳不過），或主機回報退回也失敗（`rollback_failed`） | 下載失敗時已刪 | 是 |

後端判「已結束、不再同步」的集合是 `succeeded`、`rolled_back`、`failed`、`cancelled`（`app/product_update/service/upgrade_order.py:61`）。`skipped` 不在裡面，但同一個 `job_id` 不會再有新的進度檔，實際上也不會再變。

### 2.2 轉移圖

```{.mermaid cap="圖 1 — 更新紀錄的狀態轉移；實線是使用者或主系統動作，虛線是每分鐘同步主機進度檔"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
stateDiagram-v2
    [*] --> downloading: 按下載／按更新到 X（包還沒下）
    [*] --> ready: 離線上傳驗過
    [*] --> failed: 離線上傳驗不過
    downloading --> ready: worker 下載、逐段驗過
    downloading --> failed: 下載失敗／驗章或雜湊不過
    downloading --> cancelled: 按取消下載
    ready --> scheduled: 按立即／排程（寫更新單）
    ready --> cancelled: 按取消（刪包）
    scheduled --> ready: 按取消（刪單或寫取消旗標）
    scheduled --> running: 進度檔 running
    scheduled --> succeeded: 進度檔已 succeeded（同步晚到）
    scheduled --> skipped: 進度檔 skipped
    scheduled --> rolled_back: 進度檔 rolled_back
    scheduled --> failed: 進度檔 failed／timeout 未退回
    running --> ready: 按取消（第 0～2 步）
    running --> succeeded: 進度檔 succeeded（最後一段）
    running --> skipped: 進度檔 skipped
    running --> rolled_back: 進度檔 rolled_back
    running --> failed: 進度檔 failed／timeout 未退回
    rolled_back --> scheduled: 按重試
    skipped --> scheduled: 按重試
    succeeded --> [*]
    cancelled --> [*]
    failed --> [*]
```

### 2.3 每條轉移的條件與寫入者

| 從 → 到 | 觸發 | 條件 | 寫入者 | 位置 |
|---|---|---|---|---|
| ∅ → `downloading` | `POST /system/product-update/download`，或 `POST …/apply` 時包還沒下 | 最近一次檢查有新版；沒有 `downloading`／`ready` 的列，否則 409 `PRODUCT_UPDATE_BUSY` | 後端（操作者身分） | `product_update_flow.py:385-419`；`upgrade_order.py:279-307` |
| ∅ → `ready` | 離線上傳完成 | 清單驗章、大小與 sha256 對得上 | 後端 | `product_update_flow.py:618-640` |
| ∅ → `failed` | 離線上傳驗不過 | — | 後端 | `product_update_flow.py:602-607` |
| `downloading` → `ready` | 下載 worker 完成 | 路徑每段都下完、驗過 | worker | `product_update_flow.py:474-513` |
| `downloading` → `failed` | 下載 worker 失敗 | 下載失敗、驗章不過、授權照拿不到；`ended_at` 一起寫 | worker | `:493-501`、`:432-439` |
| `downloading`／`ready` → `cancelled` | `DELETE /system/product-update/download` | 最新一列是 `downloading` 或 `ready`；包同時刪 | 後端 | `:524-550` |
| `ready` → `scheduled` | `POST …/apply`（立即或排程），或下載完成時自動寫單 | 每段重驗包；記 `job_id`、`applied_by`、`not_before`（立即＝當下）、`with_agents`、`agent_uids` | 後端 | `upgrade_order.py:255-276`、`:218-229`、`:342-365` |
| `scheduled` → 原狀態 | 寫更新單失敗 | 改紀錄與寫單不在同一個交易，寫單失敗就改回 | 後端 | `:232-239`、`:270-272` |
| `scheduled`／`running` → `ready` | `DELETE …/apply` | 單還在＝刪單；單不在＝主機已開跑，最後一步 <3 才寫 `.cancel`，否則 409 `PRODUCT_UPDATE_TOO_LATE_TO_CANCEL`；記 `cancelled_by` | 後端 | `:374-385`、`:247-250` |
| `scheduled` → `running`／終態 | 每分鐘同步或畫面輪詢 | 進度檔 `job_id` 對得上（見 §2.4） | 同步排程（系統身分） | `:432-473` |
| `running` → 終態 | 同上 | 同上；終態時寫 `ended_at`、`error` | 同步排程 | 同上 |
| `rolled_back`／`skipped` → `scheduled` | `POST …/retry` | 最新一列是這兩種；用 `incoming/` 同一個包重驗、寫新 `job_id`，`mode=now`；跨版只跑還沒跑的段 | 後端 | `:368-371`、`:166-173` |

**已就緒但下載完時已過排定時間**：自動寫單不改成立即，紀錄留在 `ready` 並記 `error`「下載完成時已超過排定的更新時間，請重新選擇時間」（`upgrade_order.py:350-353`）。

### 2.4 進度檔 → 紀錄的對照

`job_status_from_progress`（`upgrade_order.py:432-451`）。只對 `job_id` 相同的那一列動；紀錄已在終態或是 `ready`（已被取消）就不動（`:455-476`）。

| 進度檔 `status` | 進度檔 `result` | 紀錄改成 |
|---|---|---|
| `running` | — | `running` |
| `succeeded` | — | 跨版且這一段的 `to` 不是最後一段＝`running`；否則 `succeeded` |
| `skipped` | — | `skipped` |
| `failed`／`timeout` | `rolled_back` | `rolled_back` |
| `failed`／`timeout` | `unchanged` | `skipped` |
| `failed`／`timeout` | 其他（`rollback_failed`、`null`） | `failed` |
| `preflight` 或其他 | — | 不動 |

::: {.callout .crit}
**陷阱：跨版中間段的 `succeeded` 不是整張單成功**

跨版時每段各寫一份進度檔、`job_id` 相同。中間那段寫 `succeeded` 時若把紀錄收成 `succeeded`，第二段的結果再也寫不回來（`upgrade_order.py:435-436`）。
:::

::: {.callout .crit}
**陷阱：取消後主機寫的 `skipped` 不可蓋回去**

管理員按了取消，紀錄已回 `ready`（包還在、可以重排）。主機之後寫的 `skipped` 要跳過，所以同步時 `ready` 不動（`upgrade_order.py:458`、`:464`）。
:::

::: {.callout .warn}
**陷阱：退回失敗在紀錄上只是 `failed`**

`rollback_failed` 沒有自己的紀錄狀態，紀錄寫 `failed`。畫面要分辨「包驗不過、要重新下載」與「退回也失敗、要 IT 處理」，一律再看進度檔 `result`（FE `failureKind`）。
:::

### 2.5 畫面才有的狀態 submitted

`display_status`（`upgrade_order.py:192-204`）只換回應、不寫 DB：紀錄是 `scheduled`、時間已到、主機還沒為這張單寫進度檔（`host_step < 0`）→ 回 `submitted`。DB 留 `scheduled`，取消與重複按的擋法照舊認它。

前端另有兩條判 `submitted` 的規則（compliance-manager-fe `src/views/product-update/productUpdateState.js:73-91`）：`scheduled` 且 `not_before` 已到；`ready` 且有 `applied_by`、沒有 `job_id`、沒有 `error`、沒有被取消（「更新到 X」建的下載單剛就緒、後端還沒寫單的空窗）。

### 2.6 每個狀態在畫面上顯示什麼

標籤取自 compliance-manager-fe `src/config/locales/i18n/zh-tw/product-update.json`，畫面主狀態取自 `productUpdateState.js` 的 `deriveView`。

| 紀錄狀態 | 歷史列標籤 | 歷史列顏色 | 更新中心主畫面 | 可按的鈕 |
|---|---|---|---|---|
| `checking` | 檢查中 | info | — | — |
| `downloading` | 下載中 | info | 「下載 {版} 中」＋百分比 | 取消下載 |
| `ready` | 已就緒 | info | 「{版} 已就緒」＋預檢結果 | 立即更新、排程、取消 |
| `scheduled`（未到） | 已排程 | info | 「{版} 已排程在 {時間} 更新」，30 秒輪詢 | 取消 |
| `scheduled`（已到）／`submitted` | 已交給主機 | info | 「正在準備更新到 {版}」 | 取消 |
| `running` | 更新中 | info | 「更新中：{舊} → {新}」＋步驟列，3 秒輪詢 | 第 0～2 步可取消；第 3 步起沒有取消鈕（`canCancelApply`） |
| `succeeded` | 成功 | success | 回到「已是最新版本」或下一版可更新 | — |
| `rolled_back` | 已退回 | warning | 頁首「更新到 {新} 失敗，已自動退回 {舊}，服務正常」 | 重試、下載診斷包 |
| `skipped` | 這次不更新 | warning | 頁首「這次沒有更新到 {新}，系統沒有任何變動」 | 重試 |
| `cancelled` | 已取消 | secondary | 依最近一次檢查顯示可更新 | 下載 |
| `failed` | 失敗 | danger | 頁首「更新沒有開始，系統沒有任何變動」；進度檔 `result=rollback_failed` 時改為「更新到 {新} 失敗，自動退回也沒有完成」、停輪詢 | 重新下載；退回失敗時下載診斷包 |

輪詢規則（`productUpdateState.js:30-35`）：最新一列不在 `succeeded`／`failed`／`rolled_back`／`cancelled`／`skipped` 就 3 秒一次；`scheduled` 且排定時間還沒到時 30 秒一次；看的是「有沒有還沒結束的單」，不是畫面狀態。

::: {.callout .crit}
**陷阱：進度檔 `result=unchanged` 一律當「這次不更新」，不可記成 `failed`**

開機檢查或 recover 判「第 3 步之前就斷了」時，主機寫的是 `failed`／`unchanged`（`upgrade_rollback.sh:306-311`），但那時系統沒動、包還在 `incoming/`。後端同步把 `unchanged` 一律記 `skipped`（`upgrade_order.py:447-448`），前端還沒同步的那一分鐘也照同一條換（`productUpdateState.js:62-63`），所以畫面顯示「這次不更新」＋重試。

重試的判準前後端是同一套：只有 `rolled_back`、`skipped`（後端 `retry`，`upgrade_order.py:368-371`；前端 `canRetry`，`productUpdateState.js:115-117`）。`failed` 只代表包已刪、要重新下載；把 `unchanged` 記成 `failed`，或任一邊放寬成「`failed` 也能重試」，按下去都會回 412 `PRODUCT_UPDATE_NOTHING_TO_RETRY`。
:::

## Agent 跟進列 agent_update_rollouts.status {#rollouts nav="Agent 跟進"}

### 3.1 狀態值

允許值見 `scripts/sql/2026-10-30-fr167-agent-update-rollouts.sql` 與 `domain/product_update/entity/agent_update_rollout_entity.py:5-15`。一台 Agent 對同一個主系統版本只會有一列（唯一索引 `(agent_id, host_version)`）。

| 狀態 | 意思 | 「開著的列」？ | 心跳會下發？ |
|---|---|---|---|
| `pending` | 等前一批結果，或等排程時間（`not_before`） | 是 | 否 |
| `dispatched` | 已下發：心跳回應帶 `update`，Agent 該去下載 | 是 | 是（且 Agent 仍落後、`host_version` 不比主系統新、包在） |
| `succeeded` | Agent 回報升級成功 | 否 | 否 |
| `failed` | Agent 回報升級失敗 | 否 | 否 |
| `paused` | 前一批有失敗、管理員按暫停，或這台已改跟新版主系統 | 是 | 否 |

「開著的列」＝`pending`／`dispatched`／`paused`（`OPEN_STATUSES`，`agent_update_rollout_entity.py:12`）。`source` 三種：`following`（主系統升完自動跟）、`manual`（更新中心推選定）、`agent-only`（預留，目前沒有程式寫它）。

### 3.2 轉移圖

```{.mermaid cap="圖 2 — Agent 跟進列的狀態轉移：先 1 台、成功再放其餘、任一台失敗其餘暫停"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
stateDiagram-v2
    [*] --> dispatched: 建列，該批第一台（心跳最新）
    [*] --> pending: 建列，其餘台；或推送帶排程時間
    pending --> dispatched: 前一批全部成功／排程時間到
    pending --> paused: 前一批有失敗／按暫停
    dispatched --> succeeded: 心跳帶回成功
    dispatched --> failed: 心跳帶回失敗
    dispatched --> paused: 按暫停／這台改跟新版主系統
    paused --> succeeded: 已下發過，心跳帶回成功；或按繼續時已是目標版
    paused --> failed: 已下發過，心跳帶回失敗
    paused --> dispatched: 按繼續（該批第一台）
    paused --> pending: 按繼續（其餘台）
    failed --> dispatched: 推選定（重排第一台）
    failed --> pending: 推選定（重排其餘）
    succeeded --> [*]
```

### 3.3 每條轉移的條件與寫入者

| 從 → 到 | 觸發 | 條件 | 寫入者 | 位置（`app/product_update/service/agent_rollout_service.py`） |
|---|---|---|---|---|
| ∅ → `dispatched`／`pending` | 每分鐘排程 `tick` | 進度檔 `status=succeeded`、`to`＝現行版、`from`≠現行版；這一版還沒建過列；更新單 `with_agents` 不是 false；包內有 Agent 包；Agent `active`、未停用、落後；有勾 `agent_uids` 就只取勾的。第 1 批 1 台（最近心跳最新）`dispatched`，其餘第 2 批 `pending` | 排程（系統身分） | `:461-481`、`:176-180`、`:155-166` |
| ∅ → `pending` → … | `POST /system/product-update/agents/push` | Agent `active`、落後、這一版沒有正在下發的列；照「先 1 台再其餘」排在現行版最後一批之後；帶 `not_before`（10 分鐘後到 60 天內）時第一台先 `pending` | 更新中心（登入者身分） | `:590-609`、`:526-543`、`:119-126` |
| `pending` → `dispatched` | 每分鐘排程 `_advance` | 前一批全部 `succeeded`；或這列有 `not_before` 且已到（排程推送的第一台不看前一批） | 排程 | `:496-518`、`:104-111` |
| `pending` → `paused` | 每分鐘排程 `_advance` | 前一批有任一台 `failed`；`error` 寫「前一批有 Agent 升級失敗，已自動暫停」 | 排程 | 同上 |
| `pending`／`dispatched` → `paused` | `POST …/agents/pause` | 現行版的 `pending`、`dispatched` 全部 | 更新中心 | `:545-553` |
| 開著的舊列 → `paused` | 建新列時 | 同一台還開著的列跟的是別的主系統版；`error`「已改跟主系統 {版}」 | 排程或更新中心 | `:483-495` |
| `dispatched`／`paused` → `succeeded`／`failed` | Agent 心跳帶 `upgrade_result` | 列是開著的、`dispatched_at` 有值；結果是新的（同一筆不重寫）；結果版號＝`target_version` | 心跳 observer（該 Agent 租戶） | `:394-412`、`:183-200` |
| `paused` → `dispatched`／`pending`／`succeeded` | `POST …/agents/resume` | 暫停列中 Agent 仍 `active`：仍落後的重排「先 1 台再其餘」；已不落後的直接記 `succeeded` | 更新中心 | `:555-580` |
| `failed`（或其他非下發中）→ `dispatched`／`pending` | `POST …/agents/push` | 同推送條件 | 更新中心 | `:590-609` |

狀態改成 `dispatched` 時寫 `dispatched_at`、改成 `succeeded`／`failed` 時寫 `finished_at`（`:339-348`）。交易提交後主系統背景呼叫 Agent `POST /update/notify`（5 秒逾時）叫它立刻心跳，連不到只記 warning（`:350-367`；`infra/remote_agent/agent_update_notify_client.py:23`）。

**回報更新站**：`succeeded`／`failed` 且 `reported_at` 為空、`finished_at` 在一天內的列，每分鐘排程向更新站 `POST /v1/report`（`target=agent`）各報一次，送達才寫 `reported_at`（`:415-445`；`infra/product_update/repository/agent_update_rollout_repo_impl.py:44-47`）。

::: {.callout .crit}
**陷阱：主系統退回後，還沒升的 Agent 不會被推**

心跳下發前要過 `host_allows`：這一列跟的主系統版不得比主系統現行版新（`agent_rollout_service.py:98-101`、`:369-379`）。Agent 與主系統是兩條版號線，「Agent 不比主系統新」只能這樣守。
:::

::: {.callout .warn}
**陷阱：目標版未知時「是否落後」回 None，不是 False**

升級目錄沒有這版 Agent 包時比不出來；當 False 會讓落後三版的台顯示「已是最新」（`agent_rollout_service.py:85-95`）。
:::

### 3.4 Agent 那一側：升級單與進度檔怎麼變成結果

Agent 收到 `update` 後下載包、寫 `agent-order/agent-upgrade-order.json`，主機 path 單元跑升級。Agent 每次心跳前讀結果（evidence-agent `guidant_agent/sync/app/update_sync.py:109-136`）：

| Agent 讀到 | 判成 | 帶回主系統 |
|---|---|---|
| 進度檔 `job_id`＝單的 `job_id`、`status=succeeded` | 有結果 | `upgrade_result={version, status:"succeeded", at}` |
| 同上、`status=failed`／`timeout` | 有結果 | `status:"failed"`、`error`＝進度檔 `error` |
| 同上、`status=skipped` | 這次不升 | 不帶；下次心跳重來 |
| 同上、其他 | 還在跑 | 不帶 |
| 主機寫的 `agent-upgrade-host.json` `job_id` 相同、`status=failed` | 有結果 | `status:"failed"` |
| 同上、`status=skipped` | 這次不升 | 不帶 |
| 進度檔被別次升級蓋掉、但 Agent 現行版＝目標版 | 有結果 | `status:"succeeded"` |
| 單寫好 7200 秒都沒人接手 | 有結果 | `status:"failed"`「主機的升級單元可能沒裝或沒啟用」 |

同一包失敗後 1800 秒內不重下（`update_sync.py:50`）。

### 3.5 每個狀態在畫面上顯示什麼

標籤取自 `product-update.json` 的 `agent_status_*`。Agent 區每台另有「是否落後」與「必須更新」（現行版低於新版的 `min_agent_version`）。

| 跟進列狀態 | Agent 區標籤 | 一次推送的總結（歷史列） | 可按的鈕 |
|---|---|---|---|
| 沒有列、落後 | 落後 | — | 推選定（立即或排程）、稍後提醒 7 天 |
| 沒有列、不落後 | 已是最新 | — | — |
| `pending` | 等待中 | `running` | 暫停 |
| `dispatched` | 更新中（主系統另向 Agent 拉 `GET /update/progress` 顯示下載與步驟進度） | `running` | 暫停 |
| `succeeded` | 已完成 | 全部成功＝`succeeded` | — |
| `failed` | 失敗（顯示 `error`） | 有失敗＝`failed` | 推選定 |
| `paused` | 已暫停（顯示 `error`） | 有暫停＝`paused` | 繼續 |

總結規則：還有 `pending`／`dispatched`→`running`；有 `paused`→`paused`；有 `failed`→`failed`；否則 `succeeded`（`agent_rollout_service.py:274-281`）。

## 主機進度檔 upgrade-progress.json {#progress nav="進度檔"}

欄位全表見 [upgrade-runner-spec.md](upgrade-runner-spec.md) §8。這裡只講狀態。主系統與 Agent 的進度檔是同一份契約（evidence-agent `scripts/install/lib/progress.sh:5-7`）。

### 4.1 頂層 status 與 result

定義在 `scripts/installer/lib/progress.sh:7-10`。

| 欄位 | 值 | 意思 |
|---|---|---|
| `status` | `running` | 升級進行中，或退回進行中 |
| | `succeeded` | 換接完成 |
| | `failed` | 失敗 |
| | `timeout` | 某一步逾時 |
| | `skipped` | 這次沒有升級 |
| | `preflight` | 只讀預檢（包就緒後跑一次第 1 步），不是升級 |
| `result` | `null` | 還沒結束 |
| | `upgraded` | 系統已是新版 |
| | `unchanged` | 系統完全沒動 |
| | `rolled_back` | 已退回舊版 |
| | `rollback_failed` | 退回也失敗，要人處理 |
| | `passed`／`failed` | 只出現在 `status=preflight` |

### 4.2 轉移圖

```{.mermaid cap="圖 3 — 進度檔頂層狀態（status／result）；只讀預檢是另一條獨立的線"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
stateDiagram-v2
    state "running / null" as R
    state "failed或timeout / null（退回中）" as RB
    state "succeeded / upgraded" as OK
    state "skipped / unchanged" as SK
    state "failed / unchanged" as FU
    state "failed或timeout / rolled_back" as BACK
    state "failed或timeout / rollback_failed" as BF
    state "preflight / null" as P
    state "preflight / passed或failed" as PD
    [*] --> R: progress_init
    [*] --> SK: 執行前驗包不過
    R --> OK: 第 6 步寫完
    R --> SK: 第 0～2 步失敗／時段到／取消
    R --> RB: 第 3～6 步失敗或逾時
    R --> FU: 開機檢查或 recover，斷在第 3 步之前
    R --> RB: 開機檢查或 recover，斷在第 3 步之後
    RB --> BACK: 第 8 步成功
    RB --> BF: 第 8 步失敗
    BF --> RB: sudo guidantai recover
    [*] --> P: preflight 單
    P --> PD: 第 1 步結束
```

### 4.3 每個組合怎麼來、畫面顯示什麼

| `status` / `result` | 怎麼來 | 位置 | 維護頁 | 更新中心 |
|---|---|---|---|---|
| `running` / `null` | `progress_init` 開新一次 | `progress.sh:147-167` | 步驟列與進度，3 秒輪詢，不放人回去 | 「更新中」＋步驟列 |
| `failed`或`timeout` / `null` | runner 第 3～6 步失敗，先改 `status` 再開始退回；或開機、recover 決定退回 | `upgrade_runner.sh:124`；`upgrade_rollback.sh:313-314` | 顯示退回中；後端起來就放人 | 依紀錄（同步前仍是 `running`） |
| `succeeded` / `upgraded` | 第 6 步在新版 api 健康後寫；runner 結尾再寫一次 | `upgrade_switch.sh:145`；`upgrade_runner.sh:170` | 「已完成」，後端通了帶 `?upgraded=1` 回去 | 紀錄 `succeeded`；Agent 跟進開始建列 |
| `skipped` / `unchanged` | 第 0～2 步失敗；停接新工作時段到；管理員在第 0～2 步取消；更新單執行前驗包不過 | `upgrade_runner.sh:112-117`、`:162-166`；`upgrade_order.sh:122-131`、`:214-222` | 一般維護頁（後端通了就放人） | 紀錄 `skipped`：「這次沒有更新到 {新}，系統沒有任何變動」＋重試 |
| `failed` / `unchanged` | 開機檢查或 recover 發現上次斷在第 3 步之前 | `upgrade_rollback.sh:306-311` | 同上 | 紀錄 `skipped`：「這次沒有更新到 {新}，系統沒有任何變動」＋重試（見 §2.6 陷阱） |
| `failed`或`timeout` / `rolled_back` | 第 8 步成功 | `upgrade_runner.sh:126-130`；`upgrade_rollback.sh:318-322` | 「已退回」，後端通了就放人 | 紀錄 `rolled_back`：「已自動退回 {舊}，服務正常」＋重試 |
| `failed`或`timeout` / `rollback_failed` | 第 8 步失敗 | `upgrade_runner.sh:132-134`；`upgrade_rollback.sh:324-326` | 紅色、不放人，就算後端起來也不放 | 主狀態 `rollback_failed`、停輪詢：「自動退回也沒有完成」＋下載診斷包 |
| `preflight` / `null`→`passed`或`failed` | 包就緒後的只讀預檢單 | `upgrade_order.sh:240-263` | 不影響（`status` 不是 `running`） | 就緒卡片顯示預檢結果（只看第 1 步） |

Agent 進度檔多兩種來源（evidence-agent `scripts/install/lib/`）：

| `status` / `result` | 怎麼來 | 位置 |
|---|---|---|
| `skipped` / `unchanged` | 互動升級時使用者選取消 | `upgrade_runner.sh:487-490` |
| `failed`或`skipped` / `unchanged` | 升級單在 runner 開跑前就停下（解包、驗章失敗） | `upgrade_order.sh:209-213` |

### 4.4 步驟 status

| 步驟 `status` | 意思 | 更新中心步驟列 | 維護頁 |
|---|---|---|---|
| `running` | 進行中 | 進行中（now） | now |
| `ok` | 完成 | 完成（done） | done |
| `warning` | 完成但有警告（只有第 7 步清理） | 完成（done） | — |
| `failed` | 失敗 | 失敗（fail） | fail |
| `timeout` | 逾時 | 失敗（fail），顯示「上限 {秒}」 | fail |
| `skipped` | 略過（第 0 步維運指令已是目標版；第 2 步這次不更新） | 略過（skip） | done |
| （沒有紀錄） | 還沒走到 | 等待（wait）；失敗之後的步改列「略過」 | pending |

第 8 步 `rollback` 成功時在步驟列顯示為「已退回」（back），插在失敗那一步後面（`productUpdateState.js:166-200`）。維護頁對照在 `public/maintenance/maintenance.js:165-170`。

## 相關文件 {#refs nav="相關"}

| 文件 | 內容 |
|---|---|
| [design.md](design.md) | 設計定案 D1～D31 |
| [upgrade-runner-spec.md](upgrade-runner-spec.md) | 主機升級程式每一步、進度檔完整欄位、目錄佈局 |
| [sequence-diagrams-spec.md](sequence-diagrams-spec.md) | 四張循序圖：檢查到就緒、按更新到回報、Agent 跟進、發版 |
| `handoff/fr167-STATE.md` §3 | 已落地的契約（與 design 不同處） |
