feat(t213): 診斷檔補 engine 區塊,補上考卷 Q2「還在不在跑」缺口

pending=0 以前只能證明「現在沒有排隊的」,證明不了「真的做完了」還是
「daemon 早就掛了沒人知道」——因為診斷檔完全沒有時間資訊。

local.engine 不發明第二套判斷法:直接借用首頁狀態列本來就在用的
collectorAlive()/collectorSyncing()/collectorFailure()/describeStatus(),
只是把這些憑據也寫進 JSON(alive/syncing/crash_looping/headline/detail/
last_sync/last_activity_at/seconds_since_last_sync)。last_error 只在
alive=false 或 crash_looping=true 時才附上,避免健康行程的開機橫幅
被誤讀成錯誤訊息。

新增 diagnostics_engine_e2e_test.go:真的建執行檔、真的用 supervisor
拉起子行程、真的 Stop() 停掉它,驗證同一組 progress 數字在兩種情境下
engine.alive 正確反映活/死(永久迴歸測試,零帳號零網路,快且穩定)。

stage(youlin 帳號)實測驗證見 system-dev/wiki/status.md:兩次真實匯出
(活著/停了)JSON 全文、四題考卷重跑 4/4。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 14:37:53 +08:00
parent 92fecb48c1
commit 8baa9170da
3 changed files with 293 additions and 9 deletions
+81 -4
View File
@@ -37,10 +37,11 @@ import (
var diagnosticsHTTP = &http.Client{Timeout: 15 * time.Second}
// localDiagnostics=地端這半:daemon 版本/自我更新狀態+t210 已算好的總量進度/
// 失敗分類+讀不了的檔案樣本(basename)。
// 失敗分類+讀不了的檔案樣本(basename)t213 phase 3 補的 EngineQ2 缺口)
type localDiagnostics struct {
DaemonVersion string `json:"daemon_version"`
UpdateCheck UpdateInfo `json:"update_check"` // 現查現答(Q4:Mac 卡在舊版)
Engine engineDiagnostics `json:"engine"` // t213 phase 3Q2:還在不在跑)
Progress collector.SyncProgress `json:"progress"` // 同首頁(Q2 的分母:Total)
FailureBreakdown collector.FailureBreakdown `json:"failure_breakdown"` // 同首頁(Q3:分類統計)
// SkippedSampleSkippedMore=格式讀不了、根本沒進 manifest 的檔案樣本,
@@ -50,6 +51,37 @@ type localDiagnostics struct {
SkippedMore int `json:"skipped_more,omitempty"`
}
// engineDiagnostics 回答「這台機器現在到底還有沒有在動」
// t213 phase 32026-08-08 補考卷 Q2 缺口:封測者 Evan 原話「是壞了嗎?還是繼續在跑?」——
// 舊版診斷檔看得到 pending=0,卻分不出「真的做完了」還是「daemon 早就掛了沒人知道」,
// 因為完全沒有任何時間資訊。)
//
// 🔴 不發明第二套判斷法:AliveSyncingCrashLoopingRestartsLastErrorHeadlineDetail
// 直接借用 supervise.go 既有的 collectorAlive()collectorSyncing()collectorFailure()
// 以及 app.go 首頁狀態列用的 describeStatus()——同一套憑據,這裡只是把它們也寫進 JSON,
// 確保首頁與診斷檔永遠是同一組真相(leo 08-08 紅線①),不是另外重新猜一套「活著嗎」。
type engineDiagnostics struct {
Alive bool `json:"alive"` // 同步引擎子行程現在活著嗎(supervisor 狀態機,非 pgrep,見 supervise.go collectorAlive() 註解)
Syncing bool `json:"syncing"` // 這一刻正在跑一輪嗎
CrashLooping bool `json:"crash_looping"` // 一直啟動失敗(≥3 次重試都失敗,見 crashLoopThreshold
Restarts int `json:"restarts"` // 累計重試次數
LastError string `json:"last_error,omitempty"` // 最近一次啟動失敗的原因
Headline string `json:"headline"` // 同首頁大字,例如「同步引擎沒有在跑」
Detail string `json:"detail,omitempty"` // 同首頁小字
// LastSync=最近一輪「心跳」完成時間(RFC3339)。這是每一輪(預設每 5 秒)**不論那輪
// 有沒有事做都會寫**的欄位(見 collector/direct.go RunDirectOnce 尾端),所以它是心跳,
// 不是「上次同步到東西」的時間。
// LastActivityAt=最近一輪「真的有產出」(成功或失敗都算)的時間,同首頁「已整理 N 份」
// 那個時間戳(見 collector/sync_status.go CarryForwardActivity)。
//
// ⇒ pending=0 但 last_sync 是很久以前 ⇒ 不是「做完了」,是「心跳停了」;
// last_sync 很新但 alive=false ⇒ daemon 這一刻已死,last_sync 是死前最後一口氣。
LastSync string `json:"last_sync,omitempty"`
LastActivityAt string `json:"last_activity_at,omitempty"`
SecondsSinceLastSync *int64 `json:"seconds_since_last_sync,omitempty"` // generated_at 減 last_sync 的秒數;從沒同步過時省略(不假裝有數字)
}
// accountDiagnostics=一個雲端帳號(知識庫實例)的雲端那半。
// Cloud 拿不到時填 CloudError(誠實回報,不假裝有數字)——常見原因:舊版雲端沒有
// /portal/daemon/diagnostics(部署還沒到)、網路不通、api key 尚未設定。
@@ -119,6 +151,21 @@ func (a *App) buildDiagnosticsPayload() exportedDiagnostics {
skipped := buildSkipped(sync) // 首頁既有邏輯:Files 已是 basename+白話標籤,直接借用
update := a.CheckUpdate() // 現查現答,不用可能過期的背景檢查快取(Q4)
// t213 phase 3(Q2):現查現答同步引擎「現在還在不在跑」,直接讀 supervise.go
// 已經在維護的同一組憑據(供首頁 describeStatus() 用的那套),不重新發明判斷法。
_, headline, detail := describeStatus(sync)
alive := collectorAlive()
lastErr, restarts, looping := collectorFailure()
engine := engineDiagnostics{
Alive: alive,
Syncing: collectorSyncing(),
CrashLooping: looping,
Restarts: restarts,
Headline: headline,
Detail: detail,
}
engine.LastError = engineLastErrorFor(alive, looping, lastErr)
cfg, _ := loadCfg()
accounts := make([]accountDiagnostics, 0, len(cfg.Accounts))
for _, acc := range cfg.Accounts {
@@ -136,7 +183,22 @@ func (a *App) buildDiagnosticsPayload() exportedDiagnostics {
accounts = append(accounts, ad)
}
return mergeDiagnostics(sync, skipped, version, update, accounts)
return mergeDiagnostics(sync, skipped, version, update, engine, accounts)
}
// engineLastErrorFor 決定 LastError 要不要出現在診斷檔——只在「真的代表出事」時才附上
// (子行程不活著、或一直啟動失敗)。
//
// 🔴 supervisor 的 LastError 是「最近一行 stderr」,即使子行程健康,開機橫幅那類純資訊
// 也會被記進去(見 supervisor.go runOnce 的 stderr scanner,本身不判斷這行是不是真錯誤——
// 這是既有共用機制的既有行為,不在本次改動範圍);若不加這道判斷,alive=true 的健康快照裡
// 也會混進一句看起來像錯誤、實際只是啟動訊息的文字,誤導看診斷檔的人。
// 同首頁 describeStatus():只在「一直失敗」或「沒有在跑」兩種狀態才把這句話端上檯面。
func engineLastErrorFor(alive, crashLooping bool, lastErr string) string {
if !alive || crashLooping {
return lastErr
}
return ""
}
// mergeDiagnostics 純函式:把已經各自拿到的本機/雲端資料組成最終輸出形狀,不做任何 IO。
@@ -147,10 +209,25 @@ func (a *App) buildDiagnosticsPayload() exportedDiagnostics {
// sync.FailureBreakdown,本函式不比對/不認得任何一個分類字串。
// ③ 失敗檔名只出 basename——skipped.Files 沿用 buildSkipped() 既有輸出(本來就是
// filepath.Base()+白話標籤),本函式不重新處理路徑。
func mergeDiagnostics(sync syncStatus, skipped *UISkipped, daemonVersion string, update UpdateInfo, accounts []accountDiagnostics) exportedDiagnostics {
func mergeDiagnostics(sync syncStatus, skipped *UISkipped, daemonVersion string, update UpdateInfo, engine engineDiagnostics, accounts []accountDiagnostics) exportedDiagnostics {
now := time.Now().UTC()
// engine.AliveSyncingCrashLoopingRestartsLastErrorHeadlineDetail 是呼叫端
// 現查現答傳進來的(IO/即時行程狀態,見 buildDiagnosticsPayload);這裡只補上
// 從 status.json 本來就有、原樣接住的兩個時間戳,以及純算術算出的落後秒數。
engine.LastSync = sync.LastSync
engine.LastActivityAt = sync.LastActivityAt
if sync.LastSync != "" {
if t, err := time.Parse(time.RFC3339, sync.LastSync); err == nil {
secs := int64(now.Sub(t).Seconds())
engine.SecondsSinceLastSync = &secs
}
}
local := localDiagnostics{
DaemonVersion: daemonVersion,
UpdateCheck: update,
Engine: engine,
Progress: sync.Progress,
FailureBreakdown: sync.FailureBreakdown,
}
@@ -159,7 +236,7 @@ func mergeDiagnostics(sync syncStatus, skipped *UISkipped, daemonVersion string,
local.SkippedMore = skipped.More
}
return exportedDiagnostics{
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
GeneratedAt: now.Format(time.RFC3339),
Local: local,
Accounts: accounts,
}