FR-167 · 技術規格 · 2026-11-01

Agent 跟進

主系統升級後怎麼把 Agent 一台一台升上去:分批規則、心跳欄位、主動通知、進度拉取、Agent 本機的下載與升級單、主機升級單的驗證步驟、回報更新站,以及目前的限制

這份寫給要改 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. 整體流程

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)。

%%{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
圖 1 — 從主系統開放使用到 Agent 回報結果

推進分三處(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

一列=一台 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)。

%%{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: 繼續(其餘等第一台)
圖 2 — agent_update_rollouts.status 的流轉

3. 分批規則

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 跟進可選

主系統升級時管理員決定 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 區端點

能力點與守門同 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 資料面

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. 心跳契約

接線 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 本機

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 進監看的路徑):
{"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 主機升級單

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)。

%%{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 步"]
圖 3 — upgrade-host order:從撿單到交給 runner

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_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. 前端對應

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. 限制

限制 原因 現象與處理
只支援 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. 現況限制(已知缺口)

缺口 現象 位置
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 一顆累積 同上一列的修正一起
結果與進度借心跳搬運 心跳同時承載存活、下發、結果;容器換掉時心跳中的狀態跟著丟 結果與進度改走專用端點屬後續需求,不在本規格範圍

相關文件