FR-167 · 技術規格 · 2026-11-01
主系統升級後怎麼把 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。
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
推進分三處(agent_rollout_service.py:12-15):
| 誰 | 身分 | 做什麼 |
|---|---|---|
每分鐘排程 _agent_rollout_tick(core/scheduler_update.py:86-108) |
系統身分(看得到所有租戶的 Agent) | 建列、放下一批、回報更新站 |
心跳 observer record_upgrade_result |
該 Agent 的租戶身分 | 收結果、寫回這台的列 |
| 更新中心 Agent 區端點 | 登入者身分,受 RLS | 暫停、繼續、推選定、稍後提醒、看 overview |
一列=一台 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: 繼續(其餘等第一台)
_start_following)agent_rollout_service.py:461-481,每分鐘排程呼叫。全部成立才建:
status=succeeded、to=主系統現行版、from 有值且不等於現行版(換接已完成)。list_by_host_version(current) 為空)。with_agents 不是 false(follow_scope_from_job,:155-166)。找不到單(手動 CLI 升級、舊版主機)當作全部跟進。對象: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。
_advance)agent_rollout_service.py:496-516,只動現行版那一組,逐批看:
pending 且帶 not_before 的:時間到就轉 dispatched,不看前一批(前一批是別次推送的結果)。pending:看前一批(next_batch_status,:104-111)——有任一台 failed → 全部轉 paused、error="前一批有 Agent 升級失敗,已自動暫停";全部 succeeded → 全部轉 dispatched;還有沒結果的 → 先等。_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 清空。任何一列從別的狀態轉成 dispatched(_set,:339-348;或 _create 直接建成 dispatched),agent_id 記進 _woken。交易提交後才逐台呼叫 notify_in_background(§6.1)——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 天內不提醒;換新版提醒回來 |
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)。incoming/guidant-ai-<現行版>.release.json 驗過印章的 min_agent_version(:256-271)。能力點與守門同 update-center-api-spec.md §2;寫入另在 app service 擋 SaaS 非平台管理員,並寫事件 AGENT_UPDATE_ROLLOUT_CHANGED(6238)。
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 的最早時間 |
_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 裡 |
| 端點 | 行為 | 回應 | 錯誤 |
|---|---|---|---|
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(沒有可繼續的) |
| 項目 | 內容 |
|---|---|
| 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(全部被略過或超量) |
寫 ROOT 租戶 system_configs PRODUCT_UPDATE/agent_remind={version: <Agent 包版號>, until: 現在+7 天}。回 {version, snoozed_until}。錯誤:412008、500 500001。記的是版號,換新版提醒就回來。
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)。
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 且等於該台登記的指紋,不符視為失敗。base_url 必須是 https://;未啟用時走裸 http。Agent 端守門 guidant_agent/sync/api/data_plane_auth.py:29-31:/update 在 _PROTECTED_PREFIXES,且在 _OP_BOUND_PREFIXES——JWT 的 op 必須等於本次「方法 路徑」,別的用途的 token 不能拿來打這裡。
| 項目 | 內容 |
|---|---|
| 主系統 | 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 要做什麼一律從心跳回應拿,被重打、被亂打都只多一跳心跳 |
| 項目 | 內容 |
|---|---|
| 主系統 | 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 行程各一份,某次刷新落在別的行程只是少看到一次,下一刷就有。
接線 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 搬運。
dispatch_gate)dispatch_gate=lambda: not is_draining()。主機升級第 2 步放 upgrade/draining 旗標時,心跳不派新的檢測任務,待辦留在 pending,旗標拿掉後下一跳照常領。gate 本身出錯當成「不派」(heartbeat_upgrade.py:21-33)。
data.updateupdate_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。
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 |
upgrade_resultAgent 心跳 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)。暫停中但已下發過的列也收——管理員按暫停時那台可能正在升。下一批的放行留給每分鐘排程。
evidence-agent/guidant_agent/sync/app/update_sync.py,UpdateSync 是一個背景 WorkerComponent,有自己的執行緒。
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)。
observe,update_sync.py:301-342)心跳回應進來時只做比對與排工作,不在心跳迴圈裡下載,且不往外拋例外。依序:
data.update 不是物件 → 主系統沒要這台升(含 paused),什麼都不做。target_version/bundle_sha256/size 格式不對、或 target_version 已等於自己版號 → 略過。agent-order/ 不存在或不可寫 → 記一筆本機失敗(下次心跳帶上 failed),不默默不理——不回報的話主系統那一批永遠停在下發中。_RETRY_AFTER_SEC=1800)。sync_once。sync_once,:403-426).part。_check_disk)。GET /api/1.0/agents/update/bundle 續傳到 .part;404 視為這台已不在下發批、等下一次心跳;斷線留著半成品下次續。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"}_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 |
| 其他 | 還在等 |
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 步"]
_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 相同)不再跑——否則之後手動升級蓋掉進度檔時,舊單被碰一下就會把版本降回去。
_uo_stage_bundle,:243-261)mv 包進 agent-staging/bundle.tar.gz(換目錄項,不跟連結、不讀 FIFO),必須是一般檔。sha256sum 等於單上的 sha。_uo_safe_listing):只收一般檔與目錄、全在 guidant-agent-compose-<版>/ 底下、沒有 ..、解開總量 ≤ 壓縮檔 3 倍+1GB。install.sh、docker-compose.yml、guidant-ai-agent-<版>.image.tar。_uo_load_and_verify,:296-319)AGENT_IMAGE 的映像 ID(找不到就無法驗新版)。docker load;載入後凡是「載入前就有、ID 卻變了」的 tag 一律指回載入前的 ID,多出來的刪掉,然後拒收——映像 tar 可以夾帶任意 tag,檢查時 tag 已經被蓋掉了,只看載入輸出不夠。docker create --network none 新映像,把 /opt/guidant-agent/current/agent 程式樹複製出來。evidence-agent verify-tree 驗新樹的簽章與逐檔雜湊;新版的程式碼在這之前一行都沒執行。驗不過提示「現行版本若太舊不支援自動跟進,請手動升級一次」。install.sh、docker-compose.yml 與簽章樹內那份逐位元組相同。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 過去。
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,不掛在心跳收結果那一刻——那裡在心跳交易內,且列一旦收成終態就不再是開著的列,送失敗就沒有下一次 |
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。
| 限制 | 原因 | 現象與處理 |
|---|---|---|
| 只支援 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,分次按 |
| 缺口 | 現象 | 位置 |
|---|---|---|
| 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 一顆累積 | 同上一列的修正一起 |
| 結果與進度借心跳搬運 | 心跳同時承載存活、下發、結果;容器換掉時心跳中的狀態跟著丟 | 結果與進度改走專用端點屬後續需求,不在本規格範圍 |