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:
@@ -37,10 +37,11 @@ import (
|
||||
var diagnosticsHTTP = &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
// localDiagnostics=地端這半:daemon 版本/自我更新狀態+t210 已算好的總量進度/
|
||||
// 失敗分類+讀不了的檔案樣本(basename)。
|
||||
// 失敗分類+讀不了的檔案樣本(basename)+t213 phase 3 補的 Engine(Q2 缺口)。
|
||||
type localDiagnostics struct {
|
||||
DaemonVersion string `json:"daemon_version"`
|
||||
UpdateCheck UpdateInfo `json:"update_check"` // 現查現答(Q4:Mac 卡在舊版)
|
||||
Engine engineDiagnostics `json:"engine"` // t213 phase 3(Q2:還在不在跑)
|
||||
Progress collector.SyncProgress `json:"progress"` // 同首頁(Q2 的分母:Total)
|
||||
FailureBreakdown collector.FailureBreakdown `json:"failure_breakdown"` // 同首頁(Q3:分類統計)
|
||||
// SkippedSample/SkippedMore=格式讀不了、根本沒進 manifest 的檔案樣本,
|
||||
@@ -50,6 +51,37 @@ type localDiagnostics struct {
|
||||
SkippedMore int `json:"skipped_more,omitempty"`
|
||||
}
|
||||
|
||||
// engineDiagnostics 回答「這台機器現在到底還有沒有在動」
|
||||
// (t213 phase 3,2026-08-08 補考卷 Q2 缺口:封測者 Evan 原話「是壞了嗎?還是繼續在跑?」——
|
||||
// 舊版診斷檔看得到 pending=0,卻分不出「真的做完了」還是「daemon 早就掛了沒人知道」,
|
||||
// 因為完全沒有任何時間資訊。)
|
||||
//
|
||||
// 🔴 不發明第二套判斷法:Alive/Syncing/CrashLooping/Restarts/LastError/Headline/Detail
|
||||
// 直接借用 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.Alive/Syncing/CrashLooping/Restarts/LastError/Headline/Detail 是呼叫端
|
||||
// 現查現答傳進來的(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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user