---
title: "Agent 跟進規格 (FR-167)"
brand: "Guidant AI · **FR-167** 線上更新"
eyebrow: "FR-167 · 技術規格 · 2026-11-01"
h1: "Agent 跟進"
subtitle: "主系統升級後怎麼把 Agent 一台一台升上去：分批規則、心跳欄位、主動通知、進度拉取、Agent 本機的下載與升級單、主機升級單的驗證步驟、回報更新站，以及目前的限制"
lede: "這份寫給要改 Agent 跟進的工程師，橫跨三個 repo：主系統（`app/product_update/service/agent_rollout_service.py`、`core/plugins/_agent_update.py`、`infra/remote_agent/agent_update_notify_client.py`）、jedi-remote-agent 套件（`app/service/heartbeat_upgrade.py`）、evidence-agent（`guidant_agent/sync/app/update_sync.py`、`scripts/install/lib/upgrade_order.sh`）。每個欄位與數字都對回檔案行號。更新中心本身的端點與 status 見 `update-center-api-spec.md`。"
---

## 1. 整體流程 {#flow nav="流程"}

Agent 跟進的意思是：主系統升到某一版後，把那一版主系統包裡附的 Agent 安裝包推給各台 Agent。規則是**先 1 台，成功再推其餘；任何一台失敗，還沒推的自動暫停**。

Agent 不連外網、不連更新站，包只從主系統拿。主系統與 Agent 是兩條版號線（主系統 1.27.x、Agent 1.4.x），「Agent 不比主系統新」只靠一件事守：Agent 包只取 `host_version` 那一版主系統包附的那份，且 `host_version` 不得比主系統現行版新（`agent_rollout_service.py:7-8`、`98-101`）。

```{.mermaid cap="圖 1 — 從主系統開放使用到 Agent 回報結果"}
%%{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 S as 主系統每分鐘排程
    participant DB as agent_update_rollouts
    participant A as Agent 容器
    participant H as Agent 主機 systemd
    participant U as 原廠更新站
    S->>DB: 進度檔 succeeded 且 to=現行版 → 建列（第 1 批 1 台 dispatched、第 2 批 pending）
    S-->>A: 交易提交後背景 POST /update/notify（mTLS＋短效 JWT，5 秒）
    A->>S: 心跳 POST /agents/heartbeat
    S-->>A: 回應 data.update = {target_version, bundle_sha256, size}
    A->>S: GET /api/1.0/agents/update/bundle（Range 續傳）
    A->>A: 驗大小與 sha256 → 寫 agent-order/agent-upgrade-order.json
    H->>H: path 單元觸發 upgrade-host order：precheck → stage → 舊映像驗新映像 → runner
    H->>H: 寫 upgrade-progress.json（同 job_id）
    A->>S: 下一次心跳帶 upgrade_result {version, status, at, error}
    S->>DB: record_result → succeeded／failed
    S->>DB: 下一分鐘 _advance：前一批全成功→下一批 dispatched；有失敗→paused
    S->>U: 終態列 POST /v1/report（target=agent），送達才寫 reported_at
```

推進分三處（`agent_rollout_service.py:12-15`）：

| 誰 | 身分 | 做什麼 |
|---|---|---|
| 每分鐘排程 `_agent_rollout_tick`（`core/scheduler_update.py:86-108`） | 系統身分（看得到所有租戶的 Agent） | 建列、放下一批、回報更新站 |
| 心跳 observer `record_upgrade_result` | 該 Agent 的租戶身分 | 收結果、寫回這台的列 |
| 更新中心 Agent 區端點 | 登入者身分，受 RLS | 暫停、繼續、推選定、稍後提醒、看 overview |

## 2. 資料表 agent_update_rollouts {#table nav="資料表"}

一列＝一台 Agent 跟主系統某一版升級的進度。建表 `scripts/sql/2026-10-30-fr167-agent-update-rollouts.sql`，`not_before` 加在 `2026-10-30-fr167-agent-follow-optional.sql`，`reported_at` 加在 `2026-11-01-fr167-agent-rollout-reported-at.sql`。ORM `infra/product_update/model/agent_update_rollout.py`，entity 常數 `domain/product_update/entity/agent_update_rollout_entity.py`。

| 欄位 | 型別 | 說明 |
|---|---|---|
| `id`／`uid` | bigserial／varchar(36) | |
| `tenant_id`／`org_unit_id` | int | 取自 Agent 的租戶；RLS 前綴比對＋超管放行，FORCE |
| `agent_id` | int not null | `remote_agents.id`（soft-ref，Agent 刪了列仍留作紀錄） |
| `host_version` | varchar(32) | 跟的是哪一版主系統 |
| `target_version` | varchar(64) | 要升到的 Agent 版號（主系統包內附的 Agent 交付包版號） |
| `from_version` | varchar(64) | 建列當下 Agent 回報的版號 |
| `batch` | smallint ≥1 | 第幾批；前一批全部成功才放下一批 |
| `status` | varchar(16) | `pending`（等前一批或等排程時間）／`dispatched`（心跳會下發）／`succeeded`／`failed`／`paused` |
| `source` | varchar(16) | `following`（主系統升完自動跟）／`manual`（推選定）／`agent-only`（預留，程式不寫） |
| `requested_by` | varchar(50) | 推選定、暫停、繼續的人；自動跟進為 `system` |
| `dispatched_at`／`finished_at` | timestamptz | 轉 dispatched 時寫；轉 succeeded／failed 時寫 |
| `error` | text | Agent 回報的失敗原因（≤500 字、只留可列印字元）或主系統判定的原因 |
| `not_before` | timestamptz | 手動推送排定的時間；到點前維持 pending |
| `reported_at` | timestamptz | 終態已回報更新站的時間；NULL＝還沒報 |

唯一鍵 `(agent_id, host_version)`。`cm_app` 只有 SELECT／INSERT／UPDATE。

「開著的列」＝`pending`／`dispatched`／`paused`（`OPEN_STATUSES`）。一台 Agent 同時只有一列開著：建新一版的列時，同一台還開著的舊版列改 `paused`、`error="已改跟主系統 <版>"`（`agent_rollout_service.py:483-494`）。

```{.mermaid cap="圖 2 — agent_update_rollouts.status 的流轉"}
%%{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: 第一批那 1 台（無排程）
    [*] --> pending: 其餘、或排程推送的第一台
    pending --> dispatched: 前一批全成功／排程時間到
    pending --> paused: 前一批有失敗（自動）／管理員暫停
    dispatched --> succeeded: 心跳回報 succeeded
    dispatched --> failed: 心跳回報 failed
    dispatched --> paused: 管理員暫停／同台改跟新版
    paused --> succeeded: 已下發過、結果照收；或繼續時已是目標版
    paused --> failed: 已下發過、結果照收
    paused --> dispatched: 繼續（重新先 1 台）
    paused --> pending: 繼續（其餘等第一台）
```

## 3. 分批規則 {#batches nav="分批"}

### 3.1 主系統升完自動建列（`_start_following`）

`agent_rollout_service.py:461-481`，每分鐘排程呼叫。全部成立才建：

1. 進度檔 `status=succeeded`、`to`＝主系統現行版、`from` 有值且不等於現行版（換接已完成）。
2. 這一版還沒建過任何列（`list_by_host_version(current)` 為空）。
3. 這次升級的更新單 `with_agents` 不是 false（`follow_scope_from_job`，`:155-166`）。找不到單（手動 CLI 升級、舊版主機）當作全部跟進。
4. 升級目錄裡找得到這一版的 Agent 包（§4.1）；找不到記 warning、不建列。

對象：`status=active`、`enabled` 不是 false、且比 Agent 包版號舊的 Agent；更新單有勾 `agent_uids` 就只取那幾台，勾了但已停用、已最新或不存在的略過並記一行 log（`pick_chosen`，`:129-139`）。

排法 `plan_batches`（`:176-180`）：依 `last_seen_at` 新到舊（同時間依 `id`），**最近心跳最新的那台**自成第 1 批 `dispatched`，其餘全部第 2 批 `pending`。

### 3.2 放下一批（`_advance`）

`agent_rollout_service.py:496-516`，只動現行版那一組，逐批看：

- 這一批 `pending` 且帶 `not_before` 的：時間到就轉 `dispatched`，不看前一批（前一批是別次推送的結果）。
- 其餘 `pending`：看前一批（`next_batch_status`，`:104-111`）——有任一台 `failed` → 全部轉 `paused`、`error="前一批有 Agent 升級失敗，已自動暫停"`；全部 `succeeded` → 全部轉 `dispatched`；還有沒結果的 → 先等。

### 3.3 重新排批（`_rebatch`）

`agent_rollout_service.py:526-542`，「繼續」與「推選定」共用。把要推的那幾台重新照「先 1 台再其餘」排在現行版最後一批之後（`base = max(batch) + 1`）：

- 第一台（第 `base` 批）：沒排程 → `dispatched`；有 `not_before` → `pending` 並帶 `not_before`。
- 其餘（第 `base+1` 批）：`pending`，等第一台的結果。
- 🔴 只有第一台帶 `not_before`，其餘的舊值要清掉（`clear_not_before`）：`_advance` 看到過期的 `not_before` 會不等前一批直接下發。
- 每台 `requested_by`＝操作者、`error` 清空。

### 3.4 下發時叫醒 Agent

任何一列從別的狀態轉成 `dispatched`（`_set`，`:339-348`；或 `_create` 直接建成 dispatched），agent_id 記進 `_woken`。**交易提交後**才逐台呼叫 `notify_in_background`（§6.1）——Agent 被叫醒馬上心跳，交易沒提交就讀不到下發列，等於白叫。叫不到只是退回等下一次心跳，不影響分批狀態。

## 4. Agent 跟進可選 {#optional nav="可選"}

主系統升級時管理員決定 Agent 要不要跟；不跟的之後在更新中心手動推。

| 入口 | 端點／欄位 | 行為 |
|---|---|---|
| 「更新到 X」確認視窗 | `POST /system/product-update/apply` 的 `with_agents`（預設 true）、`agent_uids`（最多 200，空＝全部） | 記在 `product_update_jobs` 那一列；主系統升完由 §3.1 讀 |
| 推選定 | `POST …/agents/push` `{agent_uids[1..200], not_before?}` | §5.4 |
| 排程推送 | 同上帶 `not_before`（10 分鐘後～60 天內） | 第一台到點才下發，其餘等它 |
| 稍後提醒 | `POST …/agents/remind-later` | 這一版 Agent 包 7 天內不提醒；換新版提醒回來 |

### 4.1 Agent 包從哪來

`find_agent_bundle`（`agent_rollout_service.py:205-232`）：

- 位置：`<GUIDANT_UPGRADE_DIR>/bundles/guidant-ai-<host_version>/agent/`（主機升級時解開、容器唯讀掛載）。主機升級收尾 `upgrade_cleanup.sh:86-97` 只留本次那一包的 `agent/`，所以現行版的 Agent 包一定在——前提是用 `guidantai upgrade` 升的。
- 檔名 `guidant-agent-compose-<版>.tar.gz`，旁邊要有 `.sha256`（64 位小寫 hex）。
- 沒有、不只一份、`.sha256` 壞了都回 None（不派）。不在這裡重算整包雜湊：主系統包已驗過簽章與雜湊才解開，Agent 下載完會再對一次。
- 版本說明：同目錄 `guidant-agent-compose-<版>.notes.json`（`{"version", "notes":[…]}`，≤64KB、最多 20 條、每條 ≤500 字），版號對不上回空（`:235-253`）。
- 最低相容 Agent 版：讀 `incoming/guidant-ai-<現行版>.release.json` 驗過印章的 `min_agent_version`（`:256-271`）。

## 5. 更新中心 Agent 區端點 {#endpoints nav="端點"}

能力點與守門同 `update-center-api-spec.md` §2；寫入另在 app service 擋 SaaS 非平台管理員，並寫事件 `AGENT_UPDATE_ROLLOUT_CHANGED`（6238）。

### 5.1 GET agents（overview）

`AgentRolloutService.overview()`（`agent_rollout_service.py:625-661`）。先在交易內組資料，再到交易外拉下發中那幾台的進度（最多等 3 秒網路，不能佔著 DB 連線）。

| 頂層欄位 | 型別 | 含義 |
|---|---|---|
| `host_version` | string | 主系統現行版 |
| `target_version` | string\|null | 現行版主系統包附的 Agent 版；找不到包為 null |
| `bundle_ready` | bool | 找得到 Agent 包；false 時寫入鈕停用並說明（i18n `agent_bundle_missing_hint`） |
| `summary` | object | `{counts{pending,dispatched,succeeded,failed,paused}, total, paused}`，只算現行版那一組 |
| `agents[]` | array | 每台 Agent（§5.2），只列 `status=active` |
| `min_agent_version` | string\|null | 最低相容 Agent 版 |
| `required` | bool | 有任一台低於最低相容版 |
| `notes[]` | string[] | Agent 版本說明 |
| `snoozed`／`snoozed_until` | bool／ISO\|null | 稍後提醒還有效；換了目標版、過期、沒按過為 false／null |
| `scheduled_at` | ISO\|null | 現行版裡 `pending` 且有 `not_before` 的最早時間 |

### 5.2 每台 Agent（`_agent_item`，`:722-733`）

| 欄位 | 含義 |
|---|---|
| `uid`、`name`、`agent_version`、`last_seen_at` | |
| `online` | `last_seen_at` 距今小於 `offline_threshold_sec` |
| `lagging` | `true`＝比 `target_version` 舊（沒回報版號或格式看不懂也算落後）；`false`＝已同版或更新（不降版）；**`null`＝目標版未知（沒有 Agent 包），比不出來**。不能當成 false，否則落後三版的台會顯示「已是最新」（`:85-95`）。前端 `isSelectable` 只收 `lagging === true` |
| `required` | 比 `min_agent_version` 舊（同上規則） |
| `open` | 這台在現行版有開著的列 |
| `rollout` | 現行版那一列 `{uid, status, batch, source, target_version, from_version, error, dispatched_at, finished_at, not_before, requested_by, requested_by_name}`；沒有列為 null（主系統換版後舊版的列不會出現在這裡） |
| `progress` | 只在 `rollout.status=dispatched` 時有：主系統拉到的 `GET /update/progress`（§6.2）；其餘 null。**放在每台的頂層，不在 `rollout` 裡** |

### 5.3 POST agents/pause、agents/resume

| 端點 | 行為 | 回應 | 錯誤 |
|---|---|---|---|
| `pause` | 現行版所有 `pending`／`dispatched` 改 `paused`。正在升的那台結果照收（§7.3），只是不再放下一批 | `{paused: n}` | 412 `PRODUCT_UPDATE_412009` |
| `resume` | 現行版的 `paused`：已停用的略過；已是目標版的直接記 `succeeded`；仍落後的走 §3.3 重新先 1 台再其餘 | `{resumed: n}` | 412 `412008`（沒 Agent 包）、412 `412010`（沒有可繼續的） |

### 5.4 POST agents/push

| 項目 | 內容 |
|---|---|
| Request | `agent_uids`：1～200 個、每個 1～50 字，去重；超量 400 不靜默截斷（`actor_list`，`:736-741`）。`not_before`：AwareDateTime，選填 |
| 篩選 | 逐台：不存在、非 active、不落後、或現行版那列正在 `dispatched` → 放進 `skipped`；還沒有列的建一列 `source=manual`、`pending` |
| 排批 | §3.3；帶 `not_before` 就第一台等到點 |
| Response | `{pushed: [uid…], skipped: [uid…], not_before: ISO\|null}` |
| 錯誤 | 400 `400011`（排程不合法：沒時區或不在 10 分鐘～60 天內）、412 `412008`（沒 Agent 包）、400 `400010`（全部被略過或超量） |

### 5.5 POST agents/remind-later

寫 ROOT 租戶 `system_configs` `PRODUCT_UPDATE/agent_remind`＝`{version: <Agent 包版號>, until: 現在+7 天}`。回 `{version, snoozed_until}`。錯誤：412008、500 `500001`。記的是版號，換新版提醒就回來。

### 5.6 紀錄頁的 Agent 列

`history()`（`:689-692`）以登入者身分讀全部列，同一 `host_version`、同一 `created_at`（同一交易建的）彙整成一列，`target=agent`，帶 `agent_total`／`agent_succeeded`／`agent_failed`。總狀態：還有 pending／dispatched → `running`；有 paused → `paused`；有 failed → `failed`；全成功 → `succeeded`（`:274-313`）。

## 6. 主系統 → Agent 資料面 {#data-plane nav="主動呼叫"}

`infra/remote_agent/agent_update_notify_client.py`。認證通道與設定帳密同一道 `agent_credential_client.send`（`infra/remote_agent/agent_credential_client.py:67-75`）：

- `AGENT_AUTH` 啟用時：mTLS（主系統的雲端客戶端憑證）＋現簽的短效 JWT（`AGENT_JWT_TTL_SEC`，預設 60 秒），claims 綁 `agent_uid`、`tenant_id`、`bound_fp`（裝置指紋）與 `op="<方法> <路徑>"`。
- 回應要帶 `X-Agent-Fingerprint` 且等於該台登記的指紋，不符視為失敗。
- Agent 的 `base_url` 必須是 `https://`；未啟用時走裸 http。
- 兩支都**不拋例外**：連不到（離線、客戶沒開入站埠、Agent 正在重建容器）、回 4xx／5xx、指紋不符一律回 False／None，分批狀態不受影響。

Agent 端守門 `guidant_agent/sync/api/data_plane_auth.py:29-31`：`/update` 在 `_PROTECTED_PREFIXES`，且在 `_OP_BOUND_PREFIXES`——JWT 的 `op` 必須等於本次「方法 路徑」，別的用途的 token 不能拿來打這裡。

### 6.1 POST /update/notify

| 項目 | 內容 |
|---|---|
| 主系統 | `notify_in_background(agent)` 丟進 4 條執行緒的 pool、立刻回；單次逾時 5 秒；失敗記 warning |
| Agent | `UpdateNotifyRoute.post`（`guidant_agent/sync/api/update_notify.py:20-23`）：`heartbeat_wake.request()` 把下一跳提前，回 `{"status": true}` |
| 語意 | 不帶 body、不回狀態。Agent 要做什麼一律從心跳回應拿，被重打、被亂打都只多一跳心跳 |

### 6.2 GET /update/progress

| 項目 | 內容 |
|---|---|
| 主系統 | overview 對 `dispatched` 那幾台並行拉（另一個 8 條執行緒的 pool，免得推送叫醒一批離線台時把進度查詢排在後面），整體最多等 3 秒，沒輪到的取消。失敗只記 debug（每次刷新都會打） |
| Agent 回應 `data` | 三種形狀（`update_sync.py:353-375`）：`{phase:"idle"}`；`{phase:"download", target, bytes_done, bytes_total}`（下載中，讀 `.part` 大小）；`{phase:"host", target, status, steps:[{n,name,status,started,ended,limit_s}]}`（只認進度檔 `job_id`＝升級單 `job_id`） |
| 主系統過濾 | 只留 `phase`／`target`／`bytes_done`／`bytes_total`／`status`，`steps` 最多 32 筆、每步只留 `n`／`name`／`status`／`started`／`ended`／`limit_s`，字串截 128 字（`clean_progress`，`:112-117`）。Agent 端也刻意不送 `detail`（主機 shell 的自由文字） |

**快取**（`agent_rollout_service.py:65-67`、`663-682`）：模組層字典 `_PROGRESS_CACHE = {agent_uid: (拉到的時間, progress)}`。這次拉不到的沿用上次的值；不在下發中的台從快取清掉；心跳收到結果時也清該台（`:410`）。快取在每個 gunicorn worker 行程各一份，某次刷新落在別的行程只是少看到一次，下一刷就有。

## 7. 心跳契約 {#heartbeat nav="心跳"}

接線 `core/plugins/remote_agent.py:331-341`：`dispatch_gate`、`update_extra`、`heartbeat_payload_observers` 三個 port 由 jedi-remote-agent 1.11.0（`pyproject.toml:75`）的 `jedi_remote_agent/app/service/heartbeat_upgrade.py` 搬運。

### 7.1 停派新任務（`dispatch_gate`）

`dispatch_gate=lambda: not is_draining()`。主機升級第 2 步放 `upgrade/draining` 旗標時，心跳不派新的檢測任務，待辦留在 pending，旗標拿掉後下一跳照常領。gate 本身出錯當成「不派」（`heartbeat_upgrade.py:21-33`）。

### 7.2 下發：回應 `data.update`

`update_heartbeat_extra(agent)` → `AgentRolloutService.update_for_agent`（`agent_rollout_service.py:369-386`）。

| 欄位 | 型別 | 說明 |
|---|---|---|
| `target_version` | string | Agent 包版號 |
| `bundle_sha256` | string | 64 位小寫 hex |
| `size` | int | bytes |

只有全部成立才回物件，否則 `update: null`（欄位一律帶，Agent 據 null 判「沒有要升」）：這台開著的列是 `dispatched`、Agent 仍比 `target_version` 舊、`host_version` 不比主系統現行版新、找得到包且版號等於 `target_version`。宿主出錯也回 null、心跳不失敗（`heartbeat_upgrade.py:36-49`）。

心跳回應**沒有下載網址**；Agent 固定打 `GET /api/1.0/agents/update/bundle`。

### 7.3 下載端點 GET /agents/update/bundle

`api/remote_agent/routes/agent_update_bundle_route.py`、`core/plugins/_agent_update.py:80-121`。

| 項目 | 內容 |
|---|---|
| 認證 | 控制面通行證 `@agent_pass_required`；撤銷、停用、待核准一律擋 |
| 判定 | 用提權唯讀查出 Agent 的租戶（`elevated_readonly_scope`），再以該租戶身分跑與心跳下發同一個判定（`bundle_for_agent`）。不信任任何自報欄位 |
| 回應 | 檔案本體，`conditional=True` 支援 Range（206＋`Content-Range`）；header `X-Bundle-Version`、`X-Bundle-Sha256`、`X-Bundle-Size`（整包大小） |
| 不給 | 不在下發批、撤銷、查無一律 404 `PRODUCT_UPDATE_404004`，不區分「沒有」與「不准」 |
| 稽核 | 每次成功取用寫 `AGENT_UPDATE_BUNDLE_FETCH`（6237）：agent_uid、version、size_bytes |

### 7.4 上報：payload `upgrade_result`

Agent 心跳 payload（`guidant_agent/sync/app/enroll.py:193`、`update_sync.py:289-299`）：

| 欄位 | 型別 | 說明 |
|---|---|---|
| `version` | string（≤64 字） | 這次升到的目標版 |
| `status` | `succeeded`\|`failed` | 其他值整筆不收 |
| `at` | ISO 時間 | 比主系統時間晚超過 1 天的當作沒帶，改用主系統收到的時間 |
| `error` | string\|null | 失敗原因；主系統截 500 字、只留可列印字元 |

同一筆一直重送到有新結果為止。套件以 `upgrade_values_from_payload`（`heartbeat_upgrade.py:52-74`）判斷「是不是新的結果」並寫 `remote_agents.last_upgrade_version`／`last_upgrade_status`／`last_upgrade_at`（`error` 不入那張表）；主系統的 observer `record_upgrade_result` 用同一支判斷，兩邊判法一致。

🔴 依賴 observer 拿到的 agent 是本次心跳寫入前的值；套件若改成先回填再通知 observer，主系統會把每一筆新結果都當成重送（`agent_rollout_service.py:183-199`）。

`record_result`（`:394-411`）寫回規則：這台開著的列已下發過（`dispatched_at` 有值）且狀態是 `dispatched` 或 `paused`、回報的 `version` 等於列上的 `target_version`，才寫 `succeeded`／`failed`（failed 帶 error）。暫停中但已下發過的列也收——管理員按暫停時那台可能正在升。下一批的放行留給每分鐘排程。

## 8. Agent 本機 {#agent-side nav="Agent 本機"}

`evidence-agent/guidant_agent/sync/app/update_sync.py`，`UpdateSync` 是一個背景 WorkerComponent，有自己的執行緒。

### 8.1 目錄（compose 掛載，`deploy/docker-compose.yml:236-237`）

| 主機路徑 | 容器 | 內容 |
|---|---|---|
| `<GUIDANT_AI_AGENT_DIR>/upgrade` | `/upgrade` ro | `upgrade-progress.json`（主機 runner 寫） |
| `<…>/upgrade/agent-order` | `/upgrade/agent-order` rw | `guidant-agent-compose-<版>.tar.gz`（`.part`＝下載中）、`agent-upgrade-order.json`（升級單）、`agent-upgrade-host.json`（主機在 runner 開跑前就停下時寫的結果） |
| `<…>/upgrade/agent-staging` | 不掛 | root 700，主機驗包、解包用 |

`AGENT_UPGRADE_DIR=/upgrade`。主機目錄權限：`agent-order/` 1000:1000 750、`agent-staging/` 0:0 700（`scripts/install/lib/upgrade_order.sh:83-85`）。

### 8.2 心跳 observer（`observe`，`update_sync.py:301-342`）

心跳回應進來時只做比對與排工作，不在心跳迴圈裡下載，且不往外拋例外。依序：

1. 有升級單就先判結果；結果還沒隨心跳送出過，這輪不動（先讓主系統收到失敗，免得在空窗馬上重寫單）。成功的話清掉本地包。
2. `data.update` 不是物件 → 主系統沒要這台升（含 paused），什麼都不做。
3. `target_version`／`bundle_sha256`／`size` 格式不對、或 `target_version` 已等於自己版號 → 略過。
4. 同一目標、同一 sha 的單已寫好且還在等主機 → 等。
5. 不支援：不是容器（原生安裝）、或 `agent-order/` 不存在或不可寫 → 記一筆本機失敗（下次心跳帶上 `failed`），不默默不理——不回報的話主系統那一批永遠停在下發中。
6. 有工作在執行 → 等它們做完再下載。
7. 同一包失敗後 30 分鐘內不重試（一包 1GB 以上，`_RETRY_AFTER_SEC=1800`）。
8. 排給自己的執行緒做 `sync_once`。

### 8.3 下載、驗、寫單（`sync_once`，`:403-426`）

1. 清掉舊單、舊主機結果、別版的包與半成品，只留這版的包與 `.part`。
2. 已有同大小同 sha 的包就不重下。
3. 磁碟：可用空間 < 剩餘大小＋1GB 就回報失敗（`_check_disk`）。
4. `GET /api/1.0/agents/update/bundle` 續傳到 `.part`；404 視為這台已不在下發批、等下一次心跳；斷線留著半成品下次續。
5. 大小與 sha256 都對才改名；不對刪檔回報「傳輸損毀或遭竄改」。
6. 寫升級單（暫存檔 → `chmod 644` → `os.replace` 進監看的路徑）：

```json
{"job_id": "agent-20261101-123045-a1b2c3", "target_version": "1.4.8",
 "bundle": "guidant-agent-compose-1.4.8.tar.gz", "bundle_sha256": "<64 hex>",
 "size": 1800000000, "from_version": "1.4.7", "created_at": "2026-11-01T04:30:45+00:00"}
```

### 8.4 結果判定（`_order_outcome`，`:109-136`）

只認 `job_id`：進度檔可能是別次（手動）升級寫的，版號相同也不能算這張單的結果。

| 依據 | 判定 |
|---|---|
| 進度檔 `job_id`＝單的 `job_id`，`status=succeeded` | 成功，`at`＝進度檔 `updated_at` |
| 同上，`status=failed`／`timeout` | 失敗，`error`＝進度檔 `error` 或「升級未完成（status／result）」 |
| 同上，`status=skipped` | 這次不升（下次心跳重來） |
| `agent-upgrade-host.json` 的 `job_id` 相符，`status=failed`／`skipped` | 失敗／這次不升 |
| 自己版號已是目標版 | 成功（進度檔被別次升級蓋掉，但確實已升好） |
| 單寫好 2 小時（`_PICKUP_TIMEOUT_SEC=7200`）沒有主機接手跡象 | 失敗，提示查 `systemctl status guidant-agent-upgrade-order.path` |
| 其他 | 還在等 |

## 9. Agent 主機升級單 {#host-order nav="主機升級單"}

`evidence-agent/scripts/install/lib/upgrade_order.sh`。單元 `guidant-agent-upgrade-order.path`（`PathChanged=<升級目錄>/agent-order`）觸發 `guidant-agent-upgrade-order.service` → `<安裝程式副本> upgrade-host order`（入口放在 `/usr/local/lib/guidant-agent-upgrade/`，不指維運指令——它要到升級第 6 步才換新）。另有 `guidant-agent-upgrade-boot-check.service`：開機時進度檔還是 `running` 就先退回（`upr_recover`）。

🔴 `agent-order/` 是容器可寫的，裡面的單與包一律不可信；單上的 sha 也是容器寫的，sha 對了只代表下載沒壞。root 執行新版任何一行之前要先過下面的關（`:11-17`）。

```{.mermaid cap="圖 3 — upgrade-host order：從撿單到交給 runner"}
%%{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'}}}%%
flowchart TD
    A["path 單元觸發"] --> B["複製單到 agent-staging/order.json<br/>≤64KB、不是連結、10 秒逾時"]
    B --> C{"job_id 合法？<br/>處理過？"}
    C -->|否／已處理| Z["結束"]
    C -->|是| D["precheck<br/>格式・版號遞增・升級鎖・執行中工作"]
    D -->|不過| R1["寫 agent-upgrade-host.json<br/>failed／skipped"]
    D -->|過| E["開進度檔、第 0 步 extract"]
    E --> F["stage：mv 包進 staging → 驗 sha<br/>→ 列清單 → 只解三個檔"]
    F --> G["load_and_verify：docker load<br/>→ 擋夾帶 tag → 用**現行**映像驗新映像<br/>→ 版號相符 → install.sh／compose 逐位元組相同"]
    F -->|不過| R2["進度檔 failed＋host 結果檔"]
    G -->|不過| R2
    G -->|過| H["第 0 步 ok → runner 第 1～6 步"]
```

### 9.1 precheck（`_uo_precheck`，`:217-234`）

不碰包就能判的；不過時印 `<failed|skipped>:<說明>`：

| 檢查 | 不過時 |
|---|---|
| `target_version` 符合 `^[0-9][0-9A-Za-z.+-]{0,63}$`、`bundle`＝`guidant-agent-compose-<版>.tar.gz`、sha 為 64 hex | failed：格式不對 |
| 目標版比現行 `AGENT_IMAGE` tag 新（`sort -V`） | failed：自動升級不降版 |
| 拿得到 `<升級目錄>/.upgrade.lock` | skipped：已有另一個升級在進行 |
| 沒有執行中的工作（`_ug_running`） | skipped：等做完再升 |

`job_id` 要符合 `^agent-[0-9]{8}-[0-9]{6}-[0-9a-f]{6}$`；處理過的單（`agent-staging/last-job` 或進度檔 `job_id` 相同）不再跑——否則之後手動升級蓋掉進度檔時，舊單被碰一下就會把版本降回去。

### 9.2 stage（`_uo_stage_bundle`，`:243-261`）

1. `mv` 包進 `agent-staging/bundle.tar.gz`（換目錄項，不跟連結、不讀 FIFO），必須是一般檔。
2. `sha256sum` 等於單上的 sha。
3. 安全清單（`_uo_safe_listing`）：只收一般檔與目錄、全在 `guidant-agent-compose-<版>/` 底下、沒有 `..`、解開總量 ≤ 壓縮檔 3 倍＋1GB。
4. 只解三個檔：`install.sh`、`docker-compose.yml`、`guidant-ai-agent-<版>.image.tar`。
5. 出貨清單重寫成只有 agent 映像一行：SeaweedFS 不從自動升級載（不許蓋掉現行那顆）。

### 9.3 load_and_verify（`_uo_load_and_verify`，`:296-319`）

1. 記下現行 `AGENT_IMAGE` 的映像 ID（找不到就無法驗新版）。
2. `docker load`；載入後凡是「載入前就有、ID 卻變了」的 tag 一律指回載入前的 ID，多出來的刪掉，然後拒收——映像 tar 可以夾帶任意 tag，檢查時 tag 已經被蓋掉了，只看載入輸出不夠。
3. `docker create --network none` 新映像，把 `/opt/guidant-agent/current/agent` 程式樹複製出來。
4. **用現行（信任的）映像的執行檔**跑 `evidence-agent verify-tree` 驗新樹的簽章與逐檔雜湊；新版的程式碼在這之前一行都沒執行。驗不過提示「現行版本若太舊不支援自動跟進，請手動升級一次」。
5. 簽章內版號＝單上版號。
6. 包內 `install.sh`、`docker-compose.yml` 與簽章樹內那份逐位元組相同。

### 9.4 交給 runner

`UPG_JOB_ID=<單的 job_id> UPG_TRIGGER_KIND=order UPG_TRIGGER_USER=agent UPG_TRIGGER_MODE=now <維運指令> upgrade <staging>/pkg/guidant-agent-compose-<版>`。runner 認得同 `job_id` 的進度檔是升級單開的，沿用第 0 步。

| 步 | name | 做什麼（compose 形態，`upgrade_runner.sh:462-498`） |
|---|---|---|
| 0 | `extract` | 升級單解包、驗章（上面 9.2、9.3） |
| 1 | `preflight` | 升級前檢查 |
| 2 | `drain` | 處理執行中的工作；時段內沒做完 → `skipped`／`unchanged` |
| 3 | `apply_on_copy` | 換上新版設定 |
| 4 | `switch` | 重建容器 |
| 5 | `health` | 確認版本與資料 |
| 6 | `cleanup` | 收尾；沒清完記 `warning`，不影響結果 |
| 8 | `rollback` | 第 1～5 步失敗時自動退回 |

runner 開跑前就停下（只到第 0 步）由升級單補寫：退出碼是「這次不升」→ 進度檔與 host 結果檔寫 `skipped`；其他 → `failed`「升級沒有開始（退出碼 N），過程紀錄在 /var/log/guidant-ai-agent/」。

host 結果檔 `agent-upgrade-host.json`：`{job_id, status: failed|skipped, at, error}`，先寫在 staging 再 `mv` 過去。

## 10. 回報原廠更新站 {#report nav="回報更新站"}

`report_agent_results`（`core/plugins/_agent_update.py:58-77`）＋`report_finished`（`agent_rollout_service.py:415-443`），每分鐘排程在系統身分下跑。

| 項目 | 內容 |
|---|---|
| 前提 | 有設 `UPDATE_SITE_URL`、主機有落地版授權；否則不報 |
| 撿誰 | `status in (succeeded, failed)`、`reported_at IS NULL`、`finished_at` 在 1 天內；Agent 沒有 `device_fingerprint` 或列上沒有 `from_version` 的略過 |
| 身分 | 主機的身分（票或授權檔＋主機指紋＋主系統現行版）——Agent 沒有自己的授權 |
| Body | `POST /v1/report`：身分＋`{result, from_version, to_version, target:"agent", agent_fingerprint}`；`agent_fingerprint` 必須是 64 位小寫 hex，否則不送（更新站會回 400） |
| 送達後 | 寫 `reported_at`。送在交易外（最多等 30 秒網路），送不到下一分鐘再試；結束超過 1 天不再撿 |
| 防重送 | 靠 `reported_at`，不掛在心跳收結果那一刻——那裡在心跳交易內，且列一旦收成終態就不再是開著的列，送失敗就沒有下一次 |

## 11. 前端對應 {#fe nav="前端"}

`src/views/product-update/AgentRolloutPanel.vue`、`agentRolloutState.js`、`src/composables/useAgentRollout.js`。

| 判斷 | 規則 |
|---|---|
| 可勾選 `isSelectable` | `lagging === true` 且這一列不是 `dispatched`（`lagging=null` 不能推、也不顯示為最新） |
| 輪詢 `needsPolling` | 任一台 `pending`／`dispatched`，每 3 秒 |
| 暫停鈕 `canPause` | 任一台 `pending`／`dispatched` |
| 繼續鈕 `canResume` | 任一台 `paused` 且 `lagging` |
| 略過原因 | `dispatched` → 更新中；其他 → 已是最新 |
| 自動展開 | 下發中展開；成功收起；失敗保留展開 |

每台展開 8 格（`AGENT_STEPS`，`agentRolloutState.js:63-66`）：

| 格 | 代號 | i18n `agent_step_*` | 依據 |
|---|---|---|---|
| 0 | `wait_pickup` | 等 Agent 接手 | `dispatched` 但還沒有進度 |
| 1 | `download` | 下載更新包 | `phase=download`，顯示 已下載／總大小 |
| 2 | `extract` | 解開更新包 | 主機第 0 步 |
| 3 | `preflight` | 預檢 | 第 1 步 |
| 4 | `drain` | 停接新工作，等進行中的做完 | 第 2 步 |
| 5 | `apply` | 換上新版 | 第 3 步 `apply_on_copy` |
| 6 | `switch_health` | 啟動新版與健康檢查 | 第 4、5 步併一格，取最該讓人看到的狀態 |
| 7 | `cleanup` | 清理 | 第 6 步 |

`succeeded` 八格全打勾；`failed` 時最後一個有狀態的格標失敗並帶 `rollout.error`（Agent 的 steps 沒有 detail，失敗原因只有心跳回報的那句）。結果一進來主系統就清掉進度快取，所以 `failed` 常常沒有 progress。

## 12. 限制 {#limits nav="限制"}

| 限制 | 原因 | 現象與處理 |
|---|---|---|
| 只支援 docker compose 形態 | 主系統包只附 compose 交付包；原生安裝沒有升級單單元 | 原生 Agent 收到 `update` 會回報 `failed`「這台 Agent 是原生安裝…請用安裝包手動升級」 |
| 沒有自動跟進能力的舊版要先手動升一次 | 舊版 compose 沒掛 `agent-order/`、主機沒裝三個 systemd 單元；驗章用的是現行映像的 `verify-tree` | 回報「升級單目錄不存在或不可寫…請先用交付包手動升級一次」或「新版驗章未通過（現行版本若太舊…）」 |
| 主機要有 systemd | path 單元與開機檢查都是 systemd 單元 | 單寫好 2 小時沒人接手 → 回報失敗 |
| 用 `install.sh --upgrade` 指向 IT 自己解開的目錄升主系統時，Agent 不會跟進、更新中心也推不了 | 那條路不會把包解到 `upgrade/bundles/guidant-ai-<版>/agent/` | overview `bundle_ready=false`、`lagging=null`；推送與繼續回 412 `PRODUCT_UPDATE_412008`。改用 `guidantai upgrade <包>` |
| Agent 版號不可比主系統包附的那份新 | 單一來源守版本相容 | 比目標新的 Agent `lagging=false`，不降版 |
| 一次推選定最多 200 台 | `MAX_PUSH` | 超過 400 `400010`，分次按 |

## 13. 現況限制（已知缺口） {#gaps nav="已知缺口"}

| 缺口 | 現象 | 位置 |
|---|---|---|
| 8 格讀錯欄位 | 後端把進度放在每台的 `progress`，前端 `agentStepRows` 讀 `agent.rollout?.progress`（`agentRolloutState.js:108`）。升級途中永遠停在第 0 格「等 Agent 接手」，成功時直接全打勾所以看不出來 | FE 改讀 `agent.progress`；修正在 FE 工作坊工作區，尚未合回主線 |
| 沒東西可展開仍畫箭頭 | PrimeVue expander 欄整欄固定畫箭頭；沒有本輪紀錄的台點了是空的 | `AgentRolloutPanel.vue` expander 欄；同上待合回 |
| 結果最晚晚一個心跳週期 | 結果只隨心跳上報，`AGENT_HEARTBEAT_INTERVAL_SEC` 預設 300 秒；主動通知只把「下發」提前，沒有把「回報」提前 | 新容器起來先心跳一次時結果可能還沒產生，要等下一跳 |
| 主機解包前沒量磁碟 | Agent 下載前只檢查「剩餘大小＋1GB」；主機端解包、`docker load`、解映像層同時佔空間（約包大小 4 倍），磁碟不夠時在 `docker load`／`docker create` 中途失敗，原因只顯示「載入映像失敗」「無法讀取新映像」 | 主機升級單加磁碟預檢、docker 錯誤帶 stderr 最後一行、升級成功清舊映像；修正在 evidence-agent 工作坊工作區，尚未合回主線 |
| 每次升級留一顆舊 agent 映像 | runner 收尾不刪舊映像，數 GB 一顆累積 | 同上一列的修正一起 |
| 結果與進度借心跳搬運 | 心跳同時承載存活、下發、結果；容器換掉時心跳中的狀態跟著丟 | 結果與進度改走專用端點屬後續需求，不在本規格範圍 |

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

- [設計文件](./design.html)：定案與取捨（與本文件不一致時以程式碼與本文件為準）
- [更新中心 API 規格](./update-center-api-spec.html)
- [主機升級指令規格](./upgrade-runner-spec.html)
- [原廠更新站規格](./update-site-spec.html)
- [發布與簽章規格](./release-signing-spec.html)
- [狀態機](./state-machines.html)
- [時序圖](./sequence-diagrams.html)
