visionA/local-tool/visiona-local/startup_pipeline.go
jim800121chen d946561362 fix(local-tool): Stage 順序亂跳修復 + 移除秒數顯示
兩個問題一次修:

1. Stage 順序亂跳 — 「Stage 1 等待中、Stage 2 完成、Stage 3 進行中」
   根因:Wails Webview JS load 需 1-3 秒(Windows 乾淨環境更慢),這段
   期間 Go 的 Pipeline.Start 已經 emit Stage 1 running event 甚至跑完
   Stage 1 / Stage 2,但前端 EventsOn 還沒掛上去,events 全被丟掉。前端
   接到的第一個 event 可能是 Stage 2 completed 或 Stage 3 running,
   stages[1].status 仍是初始 pending 值,UI 顯示亂序。

   修法:
   - 新增 Go binding GetStartupSnapshot() 回傳 pipeline 當前所有 stages
     狀態(含 current / startedAt / status)。
   - 前端 init 流程在 subscribeEvents 後立即拉一次 snapshot,補上漏掉
     的 stage 狀態。
   - updateStage 加 monotonic 模式:snapshot 補漏時不會用較舊狀態覆蓋
     已收到的較新狀態(避免 race condition 倒退)。
   - status 優先級 STAGE_STATUS_RANK = pending<running<{skipped,failed}<completed

2. 進度條已等待秒數顯示錯誤 — 「進度 3 / 6 · 已等待 20 秒」
   根因:pause 機制讓 elapsed 計算失準(pause 期間 wall clock 仍走但
   stages[i].startedAt 沒重設,會顯示明顯比真實還久的數字)。使用者
   覺得不需要顯示秒數。
   修法:
   - paintProgressBar 移除 elapsedText 邏輯,永遠顯示 progressLabel
   - i18n 文案移除 {elapsed} placeholder(zh-TW + en):
     stage.1.detail.seedSlow / stage.3.detail.waitHealth /
     stage.3.detail.waitHealthSlow 都改為固定文案
   - Go 端 emit 仍會傳 elapsed(waitProgress callback 不變),但前端
     i18n template 不再用該變數,自然就不顯示

驗證:
- visiona-local 套件 go build / vet / test -race 全綠
- macOS dmg 163MB 重 build OK
- Wails bindings 自動 regen 含 GetStartupSnapshot

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 01:14:21 +08:00

570 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
// startup_pipeline.go — M8-4b6 階段啟動進度 + soft/hard timeout + watcher
//
// TDD ground truth
// - .autoflow/04-architecture/v2/startup-pipeline.md518 行完整規格)
// - R5-E1 ~ R5-E6AC-1.3 從 10 秒硬指標 → 60 秒 + 階段化進度)
//
// 設計重點:
// 1. 6 個階段1 = Wails console / 2 = Python runtime / 3 = server / 4 = devices /
// 5 = open browser / 6 = wait Web UI WebSocket
// 2. soft timeout = 20 秒每階段emit "startup:stage-timeout" 但不中斷
// 3. hard timeout = 60 秒總時emit "startup:error" + 進 Error state
// 4. 階段 5/6 在 AutoOpenBrowser=false 時:
// - 階段 5 → status="skipped"
// - 階段 6 → 不檢查 timeout使用者必須手動點 Open in Browser 才會建立 WebSocket
// 5. 階段 6 透過 sentinel file `<dataDir>/.first-ws-connected` 偵測;
// server 端 Hub 在第一個 WS client 連上時寫檔(見 server/internal/api/ws/hub.go
// 6. RestartStartupSequence bindingRetry 按鈕用5 步驟重置整個流程
//
// 1-indexed stages陣列多配一格避免 off-by-onestages[1] ~ stages[6] 使用。
// current sentinel 值0 = 未啟動、1-6 = 進行中、7 = ready、-1 = 已失敗。
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"time"
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// -----------------------------------------------------------------------
// 常量
// -----------------------------------------------------------------------
const (
startupTotalStages = 6
startupSoftTimeout = 20 * time.Second
// startupHardTimeout 從 R5-E1 原定 60 秒放寬到 180 秒。理由:即使有
// Stage 1 (seedUserDataDir) / Stage 2 (Python bootstrap) / Stage 3
// (waitHealthy) 三段 pause 機制豁免Windows 乾淨環境首次啟動仍可能在
// 段落間Defender 掃多個檔/EDR cloud lookup/段落間小工作)累積延遲,
// 使用者體感「應該還在啟動但被當失敗」非常挫折。180 秒給意料之外的
// 延遲足夠 buffer搭配 pause 機制 + 細步進度 emit 涵蓋 99% 情境。
// 日常啟動只要幾秒放寬不影響正常情境second launch 通常 < 5 秒)。
startupHardTimeout = 180 * time.Second
startupWatcherTick = 1 * time.Second
)
// startupSentinelFileName 是 server 端寫入、Wails 端 poll 的檔名。
// 路徑:<dataDir>/.first-ws-connected
const startupSentinelFileName = ".first-ws-connected"
// -----------------------------------------------------------------------
// Event payload
// -----------------------------------------------------------------------
// StartupProgressEvent 對應 startup-pipeline.md §1.1。
// 每階段 status 變化running / completed / failed / skipped都會 emit 一次。
type StartupProgressEvent struct {
Stage int `json:"stage"` // 1-6
TotalStages int `json:"totalStages"` // 固定 6
LabelKey string `json:"labelKey"` // 如 "startup.stage.2.label"
Status string `json:"status"` // "pending" | "running" | "completed" | "failed" | "skipped"
StartedAt int64 `json:"startedAt"` // Unix ms該階段 startedAt
}
// StartupStageTimeoutEvent — soft timeout 提示,不中斷流程,只 emit 一次/階段。
type StartupStageTimeoutEvent struct {
Stage int `json:"stage"`
SoftTimeoutSeconds int `json:"softTimeoutSeconds"` // 固定 20
}
// StartupErrorEvent — 階段失敗或總時 hard timeout。emit 後 pipeline 停止。
type StartupErrorEvent struct {
Stage int `json:"stage"`
Error string `json:"error"`
Cause string `json:"cause"` // "stage-failure" | "total-timeout"
}
// StartupStageDetailEvent — 階段內細步進度提示,讓使用者看到當下在做什麼。
// 不影響 stage status只是 UI 上 stage-hint 的文案來源。
// DetailKey 是 i18n key前端查表顯示ElapsedSeconds > 0 時附在文案後當耗時提示。
type StartupStageDetailEvent struct {
Stage int `json:"stage"`
DetailKey string `json:"detailKey"` // i18n key, e.g. "startup.stage.3.detail.waitHealth"
ElapsedSeconds int `json:"elapsedSeconds"` // 0 時不顯示耗時
}
// StartupStageSnapshot 是單一 stage 的 snapshot給前端追上歷史狀態用
type StartupStageSnapshot struct {
Stage int `json:"stage"`
Status string `json:"status"` // "pending" | "running" | "completed" | "failed" | "skipped"
StartedAt int64 `json:"startedAt"`
}
// StartupSnapshot 是整個 pipeline 當前狀態的 snapshot。
// 前端 init 完成後呼叫 GetStartupSnapshot() 拉一次,補上 race window 中漏掉的
// progress events避免畫面顯示「Stage 1 等待中、Stage 2 完成」這種亂序。
type StartupSnapshot struct {
Current int `json:"current"` // -1 / 0 / 1-6 / 7 (ready)
TotalStages int `json:"totalStages"` // 固定 6
Stages []StartupStageSnapshot `json:"stages"` // 1-indexed but slice 0-based (len=6)
}
// -----------------------------------------------------------------------
// stageState — 單一階段的內部狀態
// -----------------------------------------------------------------------
type stageState struct {
status string // "pending" | "running" | "completed" | "failed" | "skipped"
startedAt time.Time
completedAt time.Time
softTimeoutEmitted bool
}
// -----------------------------------------------------------------------
// StartupPipeline — 主 struct
// -----------------------------------------------------------------------
// StartupPipeline 管理 6 階段啟動流程。
// 與 ServerController 解耦StartupPipeline 只 emit Wails event
// 它對 server lifecycle 的影響由 ServerController透過 emitError → setState負責。
type StartupPipeline struct {
app *App
mu sync.Mutex
stages [startupTotalStages + 1]stageState // 1-indexed
current int // 0=未啟動、1-6=進行中、7=ready、-1=failed
startedAt time.Time
// Hard timeout pause 機制(首次 Python bootstrap 專用)。
//
// R5-E1 的 60 秒 hard timeout 預算是針對「日常啟動」,不含首次安裝 Python
// runtime解壓 ~15MB tarball + 建 venv + pip install 9 個 wheel 含 numpy /
// opencv 合計 ~150MB這種一次性 bootstrap 工作——那可能花 2-5 分鐘。
//
// 解法:讓 app.go 在真正需要首次 bootstrap 時pythonBin 不存在)呼叫
// PauseHardTimeout(),完成後呼叫 ResumeHardTimeout()。暫停期間不算進
// sinceTotal避開 hard timeout。Soft timeout 繼續照常(每階段 20 秒的
// 視覺提示不受影響,使用者仍會看到「正在重試...」hint
//
// 只暫停 hard timeout不暫停整體時鐘——因為 soft timeout 的語意是
// 「單一階段停滯太久」,首次 bootstrap 確實會停滯,該提示就該提示。
pausedDuration time.Duration // 累積暫停總時
pauseStartedAt time.Time // 當前暫停開始時間zero = 未暫停
// watcher goroutine 控制。pipelineCancelFn 由 app.go 持有;本 struct 只記 done channel。
watcherCancel context.CancelFunc
watcherDone chan struct{}
}
// NewStartupPipeline 建立新的 pipeline。並未啟動 watcher必須呼叫 Start。
func NewStartupPipeline(app *App) *StartupPipeline {
return &StartupPipeline{
app: app,
current: 0,
}
}
// Start 啟動整個 pipeline從階段 1 開始 emit running並開啟 watcher goroutine。
// 只能呼叫一次(重複呼叫會建立多個 watcher 是合法的,但會浪費資源 — 上層應避免)。
func (p *StartupPipeline) Start(ctx context.Context) {
p.mu.Lock()
p.startedAt = time.Now()
p.current = 1
p.stages[1].status = "running"
p.stages[1].startedAt = time.Now()
p.mu.Unlock()
p.emitProgress(1)
watcherCtx, cancel := context.WithCancel(ctx)
p.watcherCancel = cancel
p.watcherDone = make(chan struct{})
go p.watcher(watcherCtx)
}
// CompleteStage 標記 stage 為 completed並進入下一階段若還有
// 若 stage == startupTotalStages → 觸發 markReady。
//
// 順序錯誤(重複呼叫或階段不對)→ 安靜 ignore避免被 race condition 害到。
func (p *StartupPipeline) CompleteStage(stage int) {
p.mu.Lock()
if p.current != stage || p.current <= 0 {
p.mu.Unlock()
return
}
p.stages[stage].status = "completed"
p.stages[stage].completedAt = time.Now()
p.mu.Unlock()
p.emitProgress(stage)
if stage == startupTotalStages {
p.markReady()
return
}
// 進入下一階段
p.mu.Lock()
next := stage + 1
p.current = next
p.stages[next].status = "running"
p.stages[next].startedAt = time.Now()
p.mu.Unlock()
p.emitProgress(next)
}
// SkipStage 標記 stage 為 skipped並進入下一階段。
// 用於:階段 5 在 prefs.AutoOpenBrowser=false 時跳過 OpenInBrowser 呼叫。
// Watcher 看到 status=skipped → 不檢查 soft timeout也不檢查 hard timeout
func (p *StartupPipeline) SkipStage(stage int) {
p.mu.Lock()
if p.current != stage || p.current <= 0 {
p.mu.Unlock()
return
}
p.stages[stage].status = "skipped"
p.stages[stage].completedAt = time.Now()
p.mu.Unlock()
p.emitProgress(stage)
if stage == startupTotalStages {
p.markReady()
return
}
p.mu.Lock()
next := stage + 1
p.current = next
p.stages[next].status = "running"
p.stages[next].startedAt = time.Now()
p.mu.Unlock()
p.emitProgress(next)
}
// PauseHardTimeout 暫停 hard timeout 計時。
// 用於首次 Python bootstrap可能花 2-5 分鐘解壓 + pip install期間。
// 重複呼叫安全:第二次呼叫會先 Resume 再 Pause避免累積偏差。
// 呼叫者app.go ensureBundledPython 的「真正 bootstrap」路徑。
func (p *StartupPipeline) PauseHardTimeout() {
p.mu.Lock()
defer p.mu.Unlock()
// 已在暫停 → 先結束前一次暫停區間再開新的(保持精確)
if !p.pauseStartedAt.IsZero() {
p.pausedDuration += time.Since(p.pauseStartedAt)
}
p.pauseStartedAt = time.Now()
}
// ResumeHardTimeout 結束 hard timeout 暫停。
// 若未處於暫停狀態則 no-op不回 error避免上層要做空檢查
func (p *StartupPipeline) ResumeHardTimeout() {
p.mu.Lock()
defer p.mu.Unlock()
if p.pauseStartedAt.IsZero() {
return
}
p.pausedDuration += time.Since(p.pauseStartedAt)
p.pauseStartedAt = time.Time{}
}
// effectiveSinceTotal 回傳扣除暫停時間後的總時。watcher 用這個判斷 hard timeout。
// 呼叫者必須已持有 p.mu。
func (p *StartupPipeline) effectiveSinceTotalLocked() time.Duration {
total := time.Since(p.startedAt) - p.pausedDuration
if !p.pauseStartedAt.IsZero() {
// 目前正在暫停中 → 扣掉當前暫停區間
total -= time.Since(p.pauseStartedAt)
}
if total < 0 {
total = 0
}
return total
}
// FailStage 標記 stage 為 failedpipeline 停止並進 Error state。
//
// 副作用(透過 emitError
// - emit "startup:error" Wails event
// - 若 ctrl 存在 → setState(Error)(前端會收到 server:state-change
// - 發 OS 通知R5-D1
func (p *StartupPipeline) FailStage(stage int, err error) {
p.mu.Lock()
if p.current <= 0 {
p.mu.Unlock()
return
}
p.stages[stage].status = "failed"
p.current = -1
p.mu.Unlock()
p.emitProgress(stage)
p.emitError(stage, err, "stage-failure")
p.stopWatcher()
}
// markReady 6 階段都完成後觸發。emit "startup:ready" 並停 watcher。
func (p *StartupPipeline) markReady() {
p.mu.Lock()
p.current = startupTotalStages + 1
p.mu.Unlock()
if p.app != nil && p.app.ctx != nil {
// 同步 emit成功路徑沒有 IPC backlog 風險)
wailsRuntime.EventsEmit(p.app.ctx, "startup:ready", nil)
}
p.stopWatcher()
}
// emitProgress 取當前 stage 狀態 snapshot 並 emit "startup:progress" event。
// 用 goroutine 包起來避免 Wails IPC 慢拖累呼叫者CompleteStage / SkipStage 等)。
func (p *StartupPipeline) emitProgress(stage int) {
p.mu.Lock()
if stage <= 0 || stage > startupTotalStages {
p.mu.Unlock()
return
}
st := p.stages[stage]
p.mu.Unlock()
if p.app == nil || p.app.ctx == nil {
return
}
go func() {
wailsRuntime.EventsEmit(p.app.ctx, "startup:progress", StartupProgressEvent{
Stage: stage,
TotalStages: startupTotalStages,
LabelKey: fmt.Sprintf("startup.stage.%d.label", stage),
Status: st.status,
StartedAt: st.startedAt.UnixMilli(),
})
}()
}
// Snapshot 回傳 pipeline 當前所有 stages 狀態,供前端 init 完成後追上歷史。
// 解 raceWails app 啟動到前端 EventsOn 掛上去之間Go 端可能已經 emit
// 多個 progress events 被丟掉。前端應該在 init 完成後呼叫一次此函式,把
// 已經發生但前端漏掉的 stage 狀態補上。
func (p *StartupPipeline) Snapshot() StartupSnapshot {
p.mu.Lock()
defer p.mu.Unlock()
stages := make([]StartupStageSnapshot, 0, startupTotalStages)
for i := 1; i <= startupTotalStages; i++ {
stages = append(stages, StartupStageSnapshot{
Stage: i,
Status: p.stages[i].status,
StartedAt: p.stages[i].startedAt.UnixMilli(),
})
}
return StartupSnapshot{
Current: p.current,
TotalStages: startupTotalStages,
Stages: stages,
}
}
// EmitStageDetail 在 stage 進行中送一條細步提示文字到前端,讓使用者看到
// 「當下在做什麼」。不改 stage status只更新 UI 上的 stage-hint 欄位。
//
// 呼叫點server_control.go 裡的 startServerV2 會在 spawn binary / 等 health
// check / 30 秒後 slow hint 等節點呼叫這個函式,對應到 i18n key
// startup.stage.3.detail.spawn # 正在啟動 server 子程序
// startup.stage.3.detail.waitHealth # 正在等 server 健康檢查通過
// startup.stage.3.detail.waitHealthSlow # 首次啟動 Defender 掃描可能需時 1-2 分鐘
//
// elapsedSeconds > 0 時前端會在文案後顯示已等時長。
func (p *StartupPipeline) EmitStageDetail(stage int, detailKey string, elapsedSeconds int) {
if p == nil || p.app == nil || p.app.ctx == nil {
return
}
go func() {
wailsRuntime.EventsEmit(p.app.ctx, "startup:stage-detail", StartupStageDetailEvent{
Stage: stage,
DetailKey: detailKey,
ElapsedSeconds: elapsedSeconds,
})
}()
}
// emitError emit "startup:error" 並通知 ctrl 進 Error state + 發 OS 通知。
// cause 取值:"stage-failure" | "total-timeout"
func (p *StartupPipeline) emitError(stage int, err error, cause string) {
if p.app == nil {
return
}
if p.app.ctx != nil {
go func() {
wailsRuntime.EventsEmit(p.app.ctx, "startup:error", StartupErrorEvent{
Stage: stage,
Error: err.Error(),
Cause: cause,
})
}()
}
// 同步通知 ServerController 進 Error state
if p.app.ctrl != nil {
p.app.ctrl.setState(ServerStateError, err.Error())
}
// R5-D1發 OS 通知fire-and-forget
go sendCrashNotification(
"visionA Local — 啟動失敗",
fmt.Sprintf("第 %d 階段失敗:%s", stage, err.Error()),
)
}
// watcher 每秒 tick
// 1. 階段 6 時檢查 sentinel file → 存在則 CompleteStage(6)
// 2. 檢查 hard timeout總時 > 60 s→ FailStage + emitError(total-timeout)
// 3. 檢查 soft timeout單一階段 > 20 s→ emit "startup:stage-timeout"
//
// skip timeout 規則:
// - 該階段已 skipped → 完全不檢查 soft / hard
// - 階段 6 + AutoOpenBrowser=false → 完全不檢查 soft / hard
//
// 退出條件ctx.Done()、current 已不在 1-6 範圍ready 或 failed
func (p *StartupPipeline) watcher(ctx context.Context) {
defer close(p.watcherDone)
ticker := time.NewTicker(startupWatcherTick)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
p.mu.Lock()
if p.current <= 0 || p.current > startupTotalStages {
p.mu.Unlock()
return
}
cur := p.current
st := p.stages[cur]
curStatus := st.status
sinceStage := time.Since(st.startedAt)
// Hard timeout 走扣除暫停時間的 effective 時鐘soft timeout 照常
// 用實際 stage startedAt —— 這是故意的:首次 bootstrap 時使用者
// 仍會看到 20 秒「正在重試」hint但不會在 60 秒時被強制 fail。
sinceTotal := p.effectiveSinceTotalLocked()
softEmitted := st.softTimeoutEmitted
p.mu.Unlock()
// 階段 6每次 tick 檢查 sentinel file
if cur == 6 {
if p.checkSentinelFile() {
p.CompleteStage(6)
return
}
}
// skip timeout 判斷
skipTimeout := false
if curStatus == "skipped" {
skipTimeout = true
}
if cur == 6 && p.app != nil && !p.app.prefs.AutoOpenBrowser {
skipTimeout = true
}
// hard timeout總時 > 60 s
if !skipTimeout && sinceTotal > startupHardTimeout {
err := fmt.Errorf("startup total timeout: %s > %s", sinceTotal, startupHardTimeout)
// 直接 mark failed + emit 兩個 event不走 FailStage 因為 cause 不一樣
p.mu.Lock()
p.stages[cur].status = "failed"
p.current = -1
p.mu.Unlock()
p.emitProgress(cur)
p.emitError(cur, err, "total-timeout")
p.stopWatcher()
return
}
if skipTimeout {
continue
}
// soft timeout單一階段 > 20 s
if sinceStage > startupSoftTimeout && !softEmitted {
p.mu.Lock()
p.stages[cur].softTimeoutEmitted = true
p.mu.Unlock()
if p.app != nil && p.app.ctx != nil {
stageRef := cur
go func() {
wailsRuntime.EventsEmit(p.app.ctx, "startup:stage-timeout", StartupStageTimeoutEvent{
Stage: stageRef,
SoftTimeoutSeconds: int(startupSoftTimeout.Seconds()),
})
}()
}
}
}
}
}
// checkSentinelFile 檢查 <dataDir>/.first-ws-connected 是否存在。
// 存在 → 階段 6 完成server 端的 WebSocket Hub 寫了這個檔,代表第一個 client 已連上)
func (p *StartupPipeline) checkSentinelFile() bool {
if p.app == nil || p.app.dataDir == "" {
return false
}
path := filepath.Join(p.app.dataDir, startupSentinelFileName)
_, err := os.Stat(path)
return err == nil
}
// stopWatcher 主動取消 watcher goroutine。重複呼叫安全cancel 之後再 cancel 是 no-op
func (p *StartupPipeline) stopWatcher() {
if p.watcherCancel != nil {
p.watcherCancel()
}
}
// HasFailedStage 回傳 pipeline 是否已有任何階段被標記為 failed。
//
// M8-4b 補丁M-1 修復startInternal 用這個判斷「pipeline 是否已經 FailStage 過」,
// 如果是則 skip 自己的 sendCrashNotification / setState(Error),避免使用者看到兩個
// 獨立的錯誤通知(一個由 pipeline.emitError 發、一個由 startInternal fallback 發)。
//
// 語義current == -1整個 pipeline 已停在 failed或任一 stage status == "failed"。
func (p *StartupPipeline) HasFailedStage() bool {
p.mu.Lock()
defer p.mu.Unlock()
if p.current == -1 {
return true
}
for i := 1; i <= startupTotalStages; i++ {
if p.stages[i].status == "failed" {
return true
}
}
return false
}
// IsInColdStart 回傳 pipeline 是否正在「冷啟動中」(stage 1-6 running)。
//
// M8-4b 補丁M-2 修復startInternal 的 R5-D3 openBrowser 邏輯用這個判斷
// 「目前是否走冷啟動 pipeline 路徑」。若是,由 runStartupStage5 / pipeline stage 5 hook
// 負責 openBrowserstartInternal 跳過避免瀏覽器開兩次Linux 會開兩個 tab
//
// 非冷啟動場景:
// - pipeline == nil單測或 Wails 未初始化)
// - current == 0尚未 Start
// - current == 7已 ready屬於 RestartServer 直接 Start 的情境)
// - current == -1已 failed
//
// 這些場景下 startInternal 仍需要自己呼叫 openBrowser。
func (p *StartupPipeline) IsInColdStart() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.current >= 1 && p.current <= startupTotalStages
}
// removeSentinelFile 移除 sentinel file。失敗時不回 error檔案不存在是正常情況
// 由 RestartStartupSequence、StartServer 前置、shutdown 呼叫。
func removeSentinelFile(dataDir string) {
if dataDir == "" {
return
}
path := filepath.Join(dataDir, startupSentinelFileName)
_ = os.Remove(path)
}