---
title: FR-073 檢測 Agent 安裝形態比照主產品——設計定案
date: 2026-09-03
status: 設計定案（user 已過目並指正三點）；第二段實作已完成，190 驗收進行中
card: CM-1534（母案脈絡 CM-1533）
---

# FR-073 檢測 Agent 安裝形態改造 — 設計

## 0. 分工概述（30 秒版）

現在 Agent 的「解壓目錄就是安裝目錄」，客戶刪掉解壓目錄＝服務設定、憑證掛載點、CLI 全滅；
換版走新目錄還會撞固定 `container_name` 靜默失敗（CM-1533 實證）。

**本案把 Agent 改成跟主產品同一套形態**：

| | 現在（0.2.32） | 改造後 |
|---|---|---|
| 安裝目錄 | 解壓目錄（家目錄，隨版號變） | `/srv/guidant-ai-agent`（固定） |
| 設定檔 `.env` | 解壓目錄內 | `/srv/guidant-ai-agent/.env` |
| bind mount（content／certs-in／factory） | 解壓目錄內相對路徑 | `/srv/guidant-ai-agent/` 下絕對路徑 |
| 安裝紀錄 | `/etc/guidant-agent-compose/install.conf` | `/etc/guidant-ai-agent/install.conf`（改名對齊，並與原生版 `/etc/guidant-agent/` 分家） |
| 安裝 log | 解壓目錄內（目錄一刪就沒） | `/var/log/guidant-ai-agent/`（FHS，活過 uninstall） |
| CLI | `/usr/local/bin/guidant-agent-compose`（內容是 install.sh 副本） | 同左（名字不改，見 D5） |
| compose project | 隨目錄名（`...-0231` / `...-0232`，會分裂） | `name: guidant-ai-agent`（釘死） |
| volume 名 | `guidant-agent-compose-0232_agent-db`（隨目錄變） | `guidant-ai-agent_agent-db`（固定） |
| 解壓目錄 | 🔴 請勿刪除 | ✅ 裝完可整個刪 |

四段工：① install.sh 改成「複製到固定目錄再啟動」；② compose 加 `name:` 釘 project；
③ 升級／舊形態遷移邏輯；④ installer ⑥ 補「容器實際 image tag == 本次要裝的 tag」判定。

---

## 1. 現況與問題（實查佐證）

### 1.1 190 現況（2026-09-03 唯讀實查）

```
/etc/guidant-agent-compose/install.conf
  GUIDANT_AGENT_INSTALL_DIR=/home/guidantai/guidant-agent-compose-0.2.32

docker volume ls
  guidant-agent-compose-0231_agent-certs   ← 0.2.31 那套的憑證，孤兒
  guidant-agent-compose-0231_agent-db
  guidant-agent-compose-0231_agent-upload
  guidant-agent-compose-0231_seaweed-data
  guidant-agent-compose-0232_agent-certs   ← 現役（憑證是 runner 手動從 0231 複製過來的）
  guidant-agent-compose-0232_agent-db
  guidant-agent-compose-0232_agent-upload
  guidant-agent-compose-0232_seaweed-data

docker ps -a --format '{{.Names}}\t{{.Label "com.docker.compose.project"}}'
  guidant-ai-agent            guidant-agent-compose-0232
  guidant-agent-seaweedfs     guidant-agent-compose-0232
```

**一台機器上有兩組 volume**，其中一組（0231）是純孤兒但佔著磁碟；憑證是人手搬過去的。
這就是 CM-1533 事故的殘骸——不是特例，是**現行設計在「換版」這個必然動作下的預設結果**。

### 1.2 188 現況（同日實查）——第二個活證據

```
/etc/guidant-agent-compose/install.conf
  GUIDANT_AGENT_INSTALL_DIR=/tmp/t66-final/guidant-agent-compose-0.2.28
docker ps | grep agent
  zen_sutherland  guidant-ai-agent:0.2.30  Up 7 days     ← 連 compose 都不是，裸 docker run
  guidant-agent-seaweedfs  chrislusf/seaweedfs:3.99  Up 12 days
```

**安裝紀錄指向 `/tmp`**。`/tmp` 重開機清空 → 所有維運子命令（status／logs／upgrade／
uninstall）當場全滅，且錯誤訊息會是「這台主機看起來還沒安裝過」。這台是我們自己的
build 機、由熟悉系統的人裝的，尚且如此；客戶端只會更糟。

### 1.3 問題歸納

| # | 問題 | 後果 | 現行設計為何必然導致 |
|---|---|---|---|
| P1 | 解壓目錄＝安裝目錄 | 客戶刪目錄 → `.env`／compose／bind mount 全沒，CLI 報「還沒安裝過」 | `write_config` 直接 `ENV_FILE="${BUNDLE_DIR}/.env"`、`INSTALL_DIR="$BUNDLE_DIR"`（install.sh:1577,1624） |
| P2 | 安裝目錄在家目錄 | 帳號停用／家目錄配額／權限變更都波及生產服務；188 甚至落在 `/tmp` | 沒有固定落點，客戶解到哪就是哪 |
| P3 | compose project 隨目錄名 | 新版目錄＝新 project → 撞固定 `container_name` → **靜默失敗**（fallback 把「舊容器還在跑」讀成成功） | compose 無 `name:`，project 取目錄名 |
| P4 | volume 名隨目錄名 | 新 project 產生一組**空 volume** → 憑證消失 → 雲端 409 拒絕重註冊 | 同 P3，named volume 前綴＝project 名 |
| P5 | fallback 判定不看 image tag | 換版失敗被判成功，`status` 讀 `.env` 宣告值也顯示新版 | `dc_up()` 只檢查 `container_state == running` |
| P6 | 完成畫面印「🔴 請勿刪除」 | 與客戶心智模型對抗——這是設計缺陷的訊號，不是可以用文案解決的事 | P1 的下游 |

> P3～P5 是**同一個事故的三個環節**，任一個修掉都能讓 CM-1533 不再靜默；三個都修才叫解決。

---

## 2. 方案對照（卡上七點逐項）

### D1 目錄落點：`/srv/guidant-ai-agent` vs `/opt/guidant-agent`

| 選項 | 論據 | 判斷 |
|---|---|---|
| **A. `/srv/guidant-ai-agent`** ⭐ | 主產品用 `/srv/guidant-ai`（`GUIDANT_DATA_DIR` 預設）。FHS：`/srv` 是「本機對外提供之服務的資料」——agent 的 `.env`、bind mount、log 正是服務資料。同族命名，客戶一眼看得出是同一家。 | **採用** |
| B. `/opt/guidant-agent` | FHS：`/opt` 是「附加軟體套件」。agent 的程式本體在 docker image 內，宿主上放的全是**資料與設定**，語意對不上。另外 `/opt/guidant-ai-be`／`/opt/guidant-ai-agent` 已被我們自己的 **build 機源碼目錄**佔用（188），同名不同義會製造混淆。 | 不採用 |

**決定：`/srv/guidant-ai-agent`**，可用環境變數 `GUIDANT_AI_AGENT_DIR` 覆寫（比照主產品 `GUIDANT_DATA_DIR`）。

> **為什麼是 `guidant-ai-agent` 而不是 `guidant-agent`**（2026-09-03 user 指正，設計初稿寫錯）：
> 檔案系統路徑的既定慣例是 **kebab-case 的 `guidant-ai`**（實查：`/srv/guidant-ai` 67 處、
> `/etc/guidant-ai` 8 處、image `guidant-ai-be`／`guidant-ai-fe`／**`guidant-ai-agent`**）。
> 全 repo 只有一處寫成連寫的 `guidantai`，那是網域名 `guidantai-spec.jedicotech.com`
> ——DNS 標籤不能有底線、大小寫不敏感，與檔案系統無關，不構成先例。
> 初稿的 `guidant-agent` 把 `ai` 吃掉了，與 image 名 `guidant-ai-agent` 不一致。
>
> 🔴 **而且 `/etc/guidant-agent/` 這個名字已被佔用**（本次改名連帶修掉的真問題）：
> 原生 tarball 版 agent 的設定就住在那裡（`scripts/install/install.sh:113`
> `CONF_DIR="${CONF_DIR:-/etc/guidant-agent}"`，內含 `agent.env`）。兩種形態裝在
> 同一台機器時，compose 版的 `install.conf` 會與原生版的 `agent.env` 混在同一個目錄，
> 而 `uninstall` 清目錄時會掃到對方的設定。改成 `/etc/guidant-ai-agent/` 之後兩者
> 天然隔離。

**連帶影響**：compose project 名為 `guidant-ai-agent`，於是 volume 是
`guidant-ai-agent_agent-db` 等四個。project 名與 agent 容器的 `container_name`
（`guidant-ai-agent`）同字——**不同命名空間，技術上無衝突**（`docker ps` 只是兩個欄位
顯示同一串字）。接受這個小重複，而不是為它去改 `container_name`：那個名字已寫進
`10-troubleshooting.md` 的實際指令裡，改它的成本遠大於美觀收益。

配套落點（全部比照主產品三分離）：

```
/srv/guidant-ai-agent/                 資料與設定（客戶不需進去，但備份要含它）
  ├── .env                            600，含註冊 token 與儲存憑證
  ├── docker-compose.yml              服務定義（升級時被新版覆蓋，舊版留 .bak）
  ├── content/                        檢測 profile（唯讀掛載）＋ content/cache（可寫）
  ├── certs-in/                       雲端自簽憑證信任錨
  └── factory/                        出廠儲存設定快照
/etc/guidant-ai-agent/install.conf    安裝紀錄（644，不含密碼）
/var/log/guidant-ai-agent/            install-*.log／upgrade-*.log（見 D8，755）
/usr/local/bin/guidant-agent-compose  維運 CLI（install.sh 副本，名字不改）
```

### D2 搬遷 vs 就地

| 選項 | 論據 | 判斷 |
|---|---|---|
| **A. install.sh 複製所需檔到固定目錄** ⭐ | 解壓目錄與 tar.gz 用完可刪，這才是客戶的心智模型。與主產品 `install_ops_cli()` 完全同構（它就是 `cp compose → 資料目錄` ＋ `install guidant → /usr/local/bin`）。 | **採用** |
| B. 就地安裝＋`/srv/guidant-ai-agent` symlink 指回去 | symlink 只是把「不能刪」這個約束藏起來——刪了目錄，symlink 變 dangling，錯誤訊息比現在更難懂（`No such file or directory` 指向一個看起來存在的路徑）。且解壓目錄仍在家目錄，P2 未解。**等於什麼都沒修**。 | 不採用 |
| C. 搬移（mv）而非複製 | 客戶重跑安裝時來源沒了；且 tar.gz 還在，「來源不可變」比較好推理。差異只有 3.4GB image tar 的磁碟（那些本來就該刪）。 | 不採用 |

**要複製的東西**（來自解壓目錄）：`docker-compose.yml`、`content/`（含 `cache/`）、
`certs-in/`（若安裝當下產生了 cloud-ca.pem）、`.env.example`、`README.md`。
**不複製**：`*.image.tar`（已 `docker load` 進 docker）、`install.sh`（改為 install 到
`/usr/local/bin/guidant-agent-compose`，同現行做法）、`image-manifest.sha256`、`INSTALL.txt`。

> 冪等：所有複製都是覆蓋寫；`content/` 用 `cp -rn`（**不覆蓋既有檔**）——那裡面可能有
> 客戶自己投放的檢測 profile，升級時絕不可被包內的空目錄蓋掉。

### D3 升級／遷移策略（本案承重牆）

#### D3-a compose project name 釘死

`deploy/docker-compose.yml` 頂層加：

```yaml
# project 名顯式釘住（FR-073）。不釘會取「compose 檔所在目錄名」，於是
# 每個版本的解壓目錄各自成為一個 project，而本檔用的是固定 container_name——
# 新 project 建容器時撞上舊 project 還佔著的名字，compose 報 Conflict，
# 而 installer 的 fallback 會把「舊容器還在跑」讀成安裝成功（CM-1533 實證：
# 190 換 0.2.32 時整次換版被靜默吞掉，跑的仍是 0.2.31 的 image）。
# 連帶：named volume 前綴也是 project 名，不釘的話換版會產生一組空 volume，
# 憑證消失 → 雲端以 409（設備指紋已被另一台在線 agent 使用）拒絕重新註冊。
name: guidant-ai-agent
```

同時對齊主產品 `docker/production/docker-compose.yml:93` 的 `name: guidant`——同一個
理由、同一個寫法，不另外發明。

#### D3-b volume 名固定

釘 project name 後 volume 自動變成 `guidant-ai-agent_agent-db` 等四個，**不隨目錄變**。
不額外用 `external: true`／`name:` 硬釘：那會讓 `uninstall` 的 `down -v` 失效
（external volume compose 不刪），反而製造「以為刪乾淨其實沒有」。

#### D3-c 三條路徑的判定與行為

install.sh 開頭先判「這台機器現在是什麼狀態」，三分流：

| 狀態 | 判定依據 | 行為 |
|---|---|---|
| **N. 全新** | `/etc/guidant-ai-agent/install.conf` 不存在 ∧ 舊紀錄不存在 ∧ `/srv/guidant-ai-agent/.env` 不存在 | 正常首裝：問四題 → 建 `/srv/guidant-ai-agent` → 複製 → 寫 .env → up |
| **U. 已是新形態** | `/srv/guidant-ai-agent/.env` 存在（或新 install.conf 指向它） | **走升級語意**：沿用 .env（只換 `AGENT_IMAGE`）、覆蓋 compose、`cp -rn` content、up -d 換版。volume 因 project 固定而完全沿用 |
| **M. 舊形態（家目錄／任意目錄）** | 舊紀錄 `/etc/guidant-agent-compose/install.conf` 存在，且其指向目錄有 `.env` | **遷移**（見 D3-d） |

> 🔴 **狀態 U 不再要求客戶記得打 `--upgrade`**。現行設計裡「首裝 vs 升級」是靠客戶
> 選對指令來區分的，而 CM-1533 正是「客戶（我們自己）走了首裝路徑」造成的。改為
> **由機器狀態決定語意**，客戶打什麼都不會壞。`--upgrade` 與 `upgrade <包>` 仍保留
> （行為等價，且 `upgrade <包>` 對「手上只有 tar.gz」的情境更順手）。

#### D3-d 舊形態遷移（狀態 M）

事故最容易發生的一段，故**先停舊、再搬、再起新**，且全程可回頭：

1. 讀舊 `install.conf` 取得 `OLD_DIR`；確認 `OLD_DIR/.env` 存在。
2. **明示告知並要求確認**（互動）／`--yes` 或 `--config` 帶 `MIGRATE_CONFIRM=1`（非互動）：
   「偵測到舊形態安裝於 `<OLD_DIR>`，將搬到 `/srv/guidant-ai-agent`，資料 volume 完整保留」。
3. 記下舊 project 名（`docker inspect` 現役容器的 `com.docker.compose.project` label，
   **不是猜目錄名**——目錄名可能被改過）。
4. `docker compose -p <舊project> --env-file <OLD_DIR>/.env -f <OLD_DIR>/docker-compose.yml down`
   （🔴 **絕不帶 `-v`**）——停舊容器並釋放 `container_name`。
5. 建 `/srv/guidant-ai-agent`，複製舊的 `.env`（保原權限 600）、`content/`、`certs-in/`、`factory/`；
   compose 用**新版包內那份**（含 `name:`）。
6. **搬 volume 內容**：舊 volume 名前綴是舊 project，新的是 `guidant-ai-agent`。用一顆掛兩個
   volume 的臨時容器 `cp -a` 逐一搬（四個：agent-db／agent-certs／agent-upload／seaweed-data）。
   已存在同名新 volume 且非空 → 停下報錯，不覆蓋。
   > 為什麼要真的搬而不是叫客戶接受重新註冊：`agent-certs` 內是**舊雲端 CA 簽的身分憑證**，
   > 丟了就要重新 enroll，而雲端會以 409（設備指紋已被另一台在線 agent 使用）拒絕
   > ——CM-1533 實際撞到，最後靠人手複製救回。這條路必須由程式走完，不能留給客戶。
7. 寫新 install.conf（`/etc/guidant-ai-agent/install.conf`），**刪掉舊的**
   `/etc/guidant-agent-compose/install.conf`（留著會讓下一次執行又判成狀態 M）。
8. up -d → wait_healthy → 驗 image tag（見 D4）→ 完成畫面告知
   「舊目錄 `<OLD_DIR>` 已不再使用，可自行刪除」（**不主動刪客戶的目錄**）。
9. 失敗時的回頭路印在畫面上：舊 volume 未刪、舊目錄未動，
   `docker compose -p <舊project> -f <OLD_DIR>/docker-compose.yml --env-file <OLD_DIR>/.env up -d` 即可還原。

> 🔴 **舊 volume 不自動刪**。搬完保留（`_migrated-<日期>` 不改名，就是原名留著），
> 完成畫面告知可手動清。理由同 uninstall 的資料 volume 政策：能被腳本自動觸發的
> 刪資料動作太危險，而磁碟成本遠低於「證據永久取不回來」。

### D4 installer ⑥ fallback 補判定（CM-1533 runner 建議）

`dc_up()` 現行的 fallback 只問「容器在不在跑」。補一層：

```
啟動後（wait_healthy 通過後）逐一斷言：
  docker inspect --format '{{.Config.Image}}' guidant-ai-agent  ==  $AGENT_IMAGE_TAG
不符 → fail（退出碼 5），訊息點名「容器跑的是 <實際>，本次要裝的是 <預期>」，
       並提示最可能原因（另一個 compose project 的舊容器佔著同名 container）。
```

三個要點：
- **判定放在 `dc_up` 之後、獨立成 `assert_running_image()`**，不塞進 `dc_up` 內部——
  `dc_up` 的職責是「容器有沒有被建起來」，image 對不對是另一件事，兩件混在一起下次還會漏。
- **seaweedfs 也驗**（它的 tag 由 compose 寫死，驗它能抓到「compose 檔沒被更新」）。
- 這條是**安全網不是主修**：D3-a 釘 project 之後理論上撞不到；但 CM-1533 教的正是
  「靜默成功比失敗更貴」，安全網要留。

### D5 子命令與周邊同步

| 對象 | 改動 |
|---|---|
| `status`／`logs`／`start`／`stop`／`restart`／`fingerprint`／`configure-storage`／`re-enroll`／`upgrade`／`uninstall` | 全部經 `resolve_installed_paths()`，只需改該函式的優先序：① 環境變數 `GUIDANT_AI_AGENT_DIR` ② 新 install.conf ③ **舊 install.conf（相容）** ④ 預設 `/srv/guidant-ai-agent`。**不再** fallback 到 `$BUNDLE_DIR`——那正是 P1 的來源 |
| `uninstall` | 清 `/srv/guidant-ai-agent`（逐項，比照主產品的一級系統目錄防護 case 已存在）＋ `/etc/guidant-ai-agent/`＋ CLI；volume 仍逐項確認；**同時清舊 `/etc/guidant-agent-compose/`** 殘留（🔴 **不可誤清 `/etc/guidant-agent/`** —— 那是原生版 agent 的設定目錄）。**`/var/log/guidant-ai-agent/` 不刪**（見 D8） |
| CLI 名 `guidant-agent-compose` | **不改名**。理由：已寫進 user-manual 五章、FR-072 影片、客戶肌肉記憶；改名的收益只有美觀。（`/etc` 路徑改名是必要的——它是內部路徑，且要與 `/srv/guidant-ai-agent` 同族才不會下次又找錯） |
| `reset-190.sh`（test repo `training/scripts/console/`） | 探測與清理路徑加 `/srv/guidant-ai-agent`、`/etc/guidant-ai-agent`、`/var/log/guidant-ai-agent`；既有的 `/etc/guidant-agent`／`/opt/guidant-agent`／`/var/lib/guidant-agent`（原生版落點）保留 |
| 完成畫面 | 刪掉「🔴 請勿刪除」，改為「解壓目錄與 tar.gz 已可刪除」＋列出實際安裝落點 |

### D6 影響面清單

**evidence-agent repo**
- `deploy/install.sh`：`write_config`／`resolve_installed_paths`／`run_upgrade`／`cmd_uninstall`／
  `print_next_steps`／`install_ops_entry`／檔頭說明段（`usage()` 直接印檔頭，**改了要一併檢查
  `sed -n '3,91p'` 的行號範圍**）＋新增 `detect_install_state`／`migrate_from_legacy`／
  `assert_running_image`／`stage_into_install_dir`
- `deploy/docker-compose.yml`：加 `name: guidant-ai-agent`；三處 bind mount 由 `./content` 等
  改為 `${GUIDANT_AI_AGENT_DIR:-/srv/guidant-ai-agent}/content`（**絕對路徑**——compose 的相對
  路徑基準是 compose 檔所在目錄，而該檔現在住在固定目錄，理論上等價；但寫絕對路徑可讓
  「有人把 compose 檔複製到別處執行」不會靜默指到別的資料）
- `deploy/README.md`：安裝形態段全改
- `pyproject.toml`：版號 → **1.0.0**（見 D7）
- `scripts/build/build_compose_bundle.sh`：**幾乎不動**——它只負責把 `deploy/` 下的檔複製進
  staging，落點改變發生在客戶端執行期。要確認的是 INSTALL.txt 的文案（第 363～395 行附近）
  與 `image-prepare` 相容性檢查（第 163 行 grep）不受影響

**BE repo**
- `docs/spec-site/current/user-manual/agent/03-install.md`（第 50、187、237 行：請勿刪除、
  安裝目錄範例、log 落點）
- `docs/spec-site/current/user-manual/agent/07-upgrade.md`（7.2 表格第③列「覆蓋到既有安裝目錄」
  仍成立，但要補「首裝指令對已安裝機器等同升級」）
- `docs/spec-site/current/user-manual/agent/05-operations.md`（「不需要知道安裝目錄在哪」——
  這句本來就是承諾，改造後才真正成立，可留）
- `docs/spec-site/current/user-manual/agent/08-uninstall.md`、`10-troubleshooting.md`、
  `11-certificates.md`：路徑字樣
- `docs/spec-site/current/user-manual/onprem/10-optional-features.md §10.5`
- 各檔檔頭「變更紀錄」加一行

**test repo**
- `training/scripts/console/reset-190.sh`

**FR-072 影片④（CM-1527，Agent 安裝）**：畫面上會出現「安裝目錄 /srv/guidant-ai-agent」與
「解壓目錄可刪」——**與現有腳本敘述不同，需重錄**。在 FR-072 卡上標記，不在本卡執行。

### D7 版號：1.0.0（2026-09-03 user 裁示；初版寫 0.3.0 是我判斷錯）

現行 0.2.32 → **1.0.0**。

> **我原本寫 0.3.0，錯在沒查 repo 實際慣例就下判斷。** 實查版號史：
> `0.2.16 → … → 0.2.28 → 0.2.29 → 0.2.30 → 0.2.31 → 0.2.32`——**中間那位從頭到尾
> 沒動過**，連 re-enroll 子命令、SonarQube connector、心跳自報 capabilities 這些
> 真功能也都只 +1 patch。0.3.0 是這個 repo 從未發生過的跳法。
> user 裁示：這版準備對外發佈，不該再掛 0.x，直接進 1.0.0。

🔴 **改版號必須重跑完整 build，不能只重打包**：版號在 **Nuitka 編譯之前**就烙進
`config/version_bake.py`（`build_agent_release.sh` Step 2，產物要一起編進 binary），
另外還烙在 binary 的 `--product-version`、image tag 與交付包檔名。
`--compose-only` 會沿用既有的 `version_bake.py`，出來的包會被命名成舊版號
——CM-1533 那棒實際踩過。故一律走完整 `--with-compose`。

### D8 一併處理的小事（低風險、順手做）

- **安裝 log 落點**改為 **`/var/log/guidant-ai-agent/install-<ts>.log`**（升級為 `upgrade-*.log`）。
  現行落在解壓目錄，目錄一刪 log 就沒了——而那正是回報問題時最需要的東西。

  > **為什麼不比照主產品的 `<資料目錄>/log/`**（2026-09-03 user 提案，經評估採納）：
  > 初稿抄了主產品，但主產品那個選擇有個它自己沒發現的缺陷——`uninstall` 會刪資料目錄，
  > 於是 install／uninstall 的 transcript 在最需要事後檢討的那一刻一起消失。
  > `/var/log/` 三個好處：
  > ① **FHS 正解**，維運人員不必學新地方，logrotate 天然接管（現行 log 完全不受輪替管理）；
  > ② **活過 uninstall** —— 「上次移除為什麼失敗」的紀錄還在；
  > ③ **接得住早期失敗** —— installer 一律 root，可在做任何事之前先 `mkdir -p`
  >    `/var/log/guidant-ai-agent`，於是連「安裝目錄都還沒建就失敗」也有完整 log。
  >    現行那種情況掉到 `/tmp`（重開機清空，而客戶回報問題往往在重開機之後）。
  >
  > **代價**：與主產品 installer 分歧一處。主產品是範本不是戰場，本案不動它；
  > 列為 follow-up（見 §6）。
  >
  > **uninstall 政策**：`/var/log/guidant-ai-agent/` **不刪**（連同上面 ② 的理由），
  > 完成畫面告知路徑與「可自行刪除」。這與「資料 volume 逐項確認」同一個原則——
  > 能自動觸發的刪除動作要保守，何況這裡刪掉的正是排查證據。
- 檔頭「🔴 交付包目錄就是安裝目錄，裝完不要刪」整段改寫。

---

## 3. 風險與反悔條件

| 風險 | 評估 | 緩解 |
|---|---|---|
| **遷移搬 volume 出錯 → 憑證掉 → 409 掉線** | 承重牆。這是全案最危險的一段 | 先停舊再搬；舊 volume 不刪；失敗回頭路印在畫面；**190 實測必須真的裝兩輪對照**（驗收條件②③） |
| 已裝客戶受影響 | **實查：目前無外部客戶安裝**。188 是 build 機（`/tmp` 那套是測試殘骸）、190 是測試機、189 POC 未裝 agent | 遷移邏輯仍要做（我們自己的 190/188 就要用它，且它是客戶端唯一的相容保證），但**可以在真實環境反覆重裝驗證**，不必一次到位 |
| `/srv/guidant-ai-agent` 已被佔用 | 比照主產品：偵測到既有 `.env` 走升級語意；偵測到非本系統的檔案 → 停下報錯不覆寫 | D3-c 狀態判定 |
| compose 檔 bind mount 改絕對路徑後，舊 compose 與新 compose 混用 | 升級時 compose 一律換新版；舊版 `.bak` 保留 | 已在 `run_upgrade` 內（現行邏輯沿用） |
| 改了檔頭註解導致 `usage()` 印錯 | `usage()` 用 `sed -n '3,91p'` 寫死行號 | 改檔頭後必須實跑 `./install.sh --help` 目視確認 |

**反悔條件**：若 190 實測發現「舊形態遷移」在真實資料上不可靠（volume 搬移出現資料損壞或
權限問題無解），退而求其次＝**不做自動遷移**，改為偵測到舊形態即停下、印出人工遷移步驟
（因為目前無外部客戶，人工路徑可接受）；但 D3-a／D3-b／D4（釘 project／固定 volume／
image tag 斷言）**無論如何都要做**——那三條才是 CM-1533 的真正修法。

### 3.1 實作階段才浮現的三個缺陷（設計時沒想到，記錄在此免得下次重蹈）

| # | 缺陷 | 為什麼設計時看不出來 | 修法 |
|---|------|--------------------|------|
| **①** | 遷移用 `docker run alpine:3` 搬 volume——**封閉網路下拉不到** | 寫「用臨時容器搬 volume」時預設 alpine 隨手可得，那是連網開發機的直覺。但本產品的前提就是離線安裝，交付包只帶 agent 與 SeaweedFS 兩顆 image。190 實查確認機器上沒有 alpine | 改用 `AGENT_IMAGE_TAG`（遷移前必然已 `docker load`，是唯一保證存在的）。已在 190 實測權限與擁有者完整保留（`600 1000:1000`） |
| **②** | `--check-only` 沒判定狀態 → **全新機器跳過埠佔用檢查** | `check_environment` 改成依 `INSTALL_STATE` 分流時，只想到主流程會先呼叫 `detect_install_state`，忘了 `--check-only` 是另一條入口。空字串讓 `"!= fresh"` 恆為真 | `--check-only` 補呼叫 `detect_install_state`（只讀不寫，不違反「不做任何變更」） |
| **③** | `INSTALL.txt`（客戶第一眼看的檔）仍寫「🔴 請勿刪除」 | 它不在 `deploy/` 下、是 `build_compose_bundle.sh` 用 heredoc 生成的，改 `deploy/` 時整個沒掃到。出貨包會自打嘴巴：INSTALL.txt 說不能刪、完成畫面說可以刪 | 樣板同步反轉，並補「已裝過的機器同一條指令自動走升級」 |

> 共通教訓：**「改文案」這件事的範圍不等於「文件目錄」**——生成式產物（build 腳本內的
> heredoc）與執行期輸出同樣是客戶讀到的文字。下次做這類反轉時，判準是
> 「客戶會在哪些地方讀到這句話」，而不是「哪些 .md 提到它」。

---

## 4. 驗收條件（第二段執行，每項貼實際輸出）

190 上，四條路徑：

1. **乾淨首裝＋刪解壓目錄**：清掉兩套舊安裝（volume 先備份一份）→ 新包首裝 →
   `rm -rf ~/guidant-agent-compose-1.0.0*` → `guidant-agent-compose status` 仍正常、
   容器 healthy、雲端心跳 200。
2. **升級沿用**：在①之上再裝一次同包（或次版）→ `docker volume ls` 名稱**不變**、
   `agent-certs` 內憑證未變、無 409、`docker inspect` 的 image tag 換新。
3. **舊形態遷移**：用 0.2.32 家目錄形態裝好（含註冊成功）→ 跑新 install.sh →
   偵測為狀態 M → 遷移完成 → 憑證沿用（log 出現 `already enrolled ... skip register`）、
   雲端 `remote_agents` 版號更新、`status=active`。
4. **uninstall 乾淨**：`/srv/guidant-ai-agent`、`/etc/guidant-agent`、CLI 全清；
   volume 依提示保留或刪除；舊 `/etc/guidant-agent-compose` 殘留一併清掉。

外加：**負面驗證**——刻意讓一顆舊 tag 的容器佔住 `guidant-ai-agent` 這個名字後跑安裝，
確認 D4 的 `assert_running_image` 會**報失敗**而不是報成功（證明安全網有牙齒）。

---

## 5. 不做的事（劃界）

- **主產品 installer 不動**——它是範本不是戰場。
- **CLI 不改名**（見 D5）。
- **不引入 systemd unit**——容器 `restart: unless-stopped` ＋ docker 開機自起已等價，
  多一層只是多一個會壞的地方（與現行檔頭立場一致）。
- **不做自動刪除客戶的舊目錄／舊 volume**——只告知。
- FR-072 影片④重錄**不在本卡**，只在 FR-072 卡上標記。

---

## 6. Follow-up（不在本卡做）

- **主產品 installer 的 log 落點**：現行 `<資料目錄>/log/`，會被自己的 `uninstall` 刪掉
  （見 D8 的評估）。本案不動主產品，但同一個缺陷在那裡也成立，值得日後對齊到
  `/var/log/guidant-ai/`。
- **FR-072 影片④（CM-1527）重錄**：安裝形態改變後畫面對不上。**由 FR-072 原 session 處理**
  （2026-09-03 user 指示），本卡不碰該卡。
- **原生 tarball 版 agent 是否比照**：原生版用 `/opt/guidant-agent`＋`/etc/guidant-agent`＋
  `/var/lib/guidant-agent`，同樣沒對齊 `guidant-ai` 命名。本案只動 compose 形態
  （那是現行出貨形態）；原生版命名對齊屬獨立議題，且它沒有「解壓目錄＝安裝目錄」這個
  病根，優先度低。
