# v2/startup-pipeline.md — R5-E 階段化啟動管線 > 所屬:TDD v2 §2.9(v2.1 新增) > 版本:v2.1(2026-04-14 R5-E 實作細節) > 決策依據:R5-E1 ~ R5-E6(AC-1.3 從 10 秒硬指標 → 60 秒 + 階段化進度) > 對應 milestone:M8-4b(Backend Go + Frontend vanilla JS) > 相關文件:`v2/control-panel.md` §3.1(啟動進度面板 UI)、`v2/server-lifecycle.md` §2.1(冷啟動時間軸) --- ## 0. 目的與決策回顧 v2.0 PM §11-2 提問 AC-1.3「10 秒可達性」,Architect 樂觀 4 s / 悲觀 8 s / 最壞 11 s。PM 審閱後將此硬指標**取代**為 R5-E 的新規則: | R5-E | 內容 | |------|------| | R5-E1 | AC-1.3 上限 **60 秒**(軟硬門檻) | | R5-E2 | 分成 **6 個階段**,逐階段顯示進度 | | R5-E3 | 單一階段卡 > **20 秒**(soft timeout)→ 顯示「階段 N 正在重試」副文字,**不中斷流程** | | R5-E4 | 總時 > **60 秒**(hard timeout)→ 進 Error state | | R5-E5 | 階段文字由 Design Spec v2.1 決定(本文件只定 event schema + labelKey) | | R5-E6 | 「瀏覽器就緒」定義 = WebSocket 連上 server(`OnClientConnected` 第一次觸發) | v2.0 的樂觀/悲觀估算仍有意義,作為每階段的預算參考(見 `server-lifecycle.md` §2.1 的階段預算表)。 --- ## 1. Event Schema Wails 控制台(vanilla JS)透過 `EventsOn` 訂閱以下 4 個 event。Payload 使用 JSON-serializable Go struct。 ### 1.1 `startup:progress` 每個階段的狀態變化都 emit 一次,可能是 `running`(階段開始)、`completed`(階段完成)、`failed`(階段失敗)。 ```go type StartupProgressEvent struct { Stage int `json:"stage"` // 1-6 TotalStages int `json:"totalStages"` // 固定 6 LabelKey string `json:"labelKey"` // i18n key,見 §2 Status string `json:"status"` // "pending" | "running" | "completed" | "failed" | "skipped" StartedAt int64 `json:"startedAt"` // Unix ms,該階段開始時間 } ``` **Status 值**(v2.1 二次審閱新增 `skipped`): - `pending`:階段尚未開始(pipeline render 初始狀態) - `running`:階段進行中(watcher 會對此階段檢查 soft / hard timeout) - `completed`:階段成功完成 - `failed`:階段失敗 → pipeline 停止、進 Error state - `skipped`(v2.1 新增):該階段依偏好設定或平台規則被跳過,不需執行也不檢查 timeout - 目前使用情境:階段 5「開啟瀏覽器」在 `prefs.AutoOpenBrowser == false` 時(Linux 預設 OFF,或使用者手動關閉)→ status=`skipped` - 前端收到 skipped → 顯示 ⏭ 圖示 + 文字「跳過(依偏好設定)」(Design Spec v2.1 §4.1 已定) - Watcher 看到 skipped 狀態時不檢查 soft timeout;進入下一階段的邏輯同 completed 前端 render 邏輯:收到 event → 更新 `#startup-stage-` DOM 的 status icon 與時間。 ### 1.2 `startup:stage-timeout` 某階段 > 20 秒(soft timeout)未完成時 emit 一次(**不重複 emit**)。僅作提示,不中斷流程。 ```go type StartupStageTimeoutEvent struct { Stage int `json:"stage"` SoftTimeoutSeconds int `json:"softTimeoutSeconds"` // 固定 20 } ``` 前端 render 邏輯:收到 event → 在該階段行下方插入 `

{i18n("startup.retrying", { stage })}

`。 ### 1.3 `startup:error` 任一階段失敗或總時 > 60 秒 emit 一次。之後 pipeline 停止,後續不會再有任何 `startup:*` event。 ```go type StartupErrorEvent struct { Stage int `json:"stage"` Error string `json:"error"` // 技術性錯誤訊息 Cause string `json:"cause"` // "stage-failure" | "total-timeout" } ``` 前端 render 邏輯:切到 Error state 顯示,同時 `server:state-change` 會被觸發(因為 `ctrl.setState(Error, ...)` 已呼叫)。 ### 1.4 `startup:ready` 6 個階段都 `completed` 後 emit,payload 為空。 ```go // 無 struct,EventsEmit(ctx, "startup:ready", nil) ``` 前端 render 邏輯:淡出啟動進度面板(300 ms ease)→ 顯示主控台 UI。 --- ## 2. i18n Key 清單 文案由 Design Spec v2.1 敲定。TDD 只定義 key 名稱: | Key | 用途 | |-----|------| | `startup.title` | 標題「正在啟動 visionA Local」 | | `startup.stage.1.label` | 階段 1:初始化 Wails 控制台 | | `startup.stage.2.label` | 階段 2:檢查 Python runtime | | `startup.stage.3.label` | 階段 3:啟動本機伺服器 | | `startup.stage.4.label` | 階段 4:偵測 Kneron 裝置 | | `startup.stage.5.label` | 階段 5:開啟瀏覽器 | | `startup.stage.6.label` | 階段 6:等待 Web UI 連線 | | `startup.retrying` | 「第 {stage} 階段正在重試 …」副文字 | | `startup.elapsed` | 「已耗時 {seconds} s · 上限 60 s」 | | `startup.error.stageFailure` | 「第 {stage} 階段失敗:{error}」 | | `startup.error.totalTimeout` | 「啟動超過 60 秒,請檢查系統資源」 | 檔案位置:`visiona-local/frontend/i18n/zh-TW.json` / `en-US.json`。 --- ## 3. 6 階段對應的 Go 實作點 | # | 階段 | 在哪裡 `pipeline.Start(N)` | 在哪裡 `pipeline.Complete(N)` | |---|------|---------------------------|------------------------------| | 1 | 初始化 Wails 控制台 | `app.go:startup` 最前面(`a.ctx != nil` 之後第一行)| 同一個 function 的 `seedUserDataDir()` 返回後 | | 2 | 檢查 Python runtime | `startServerV2` 開頭、在 `ensurePythonRuntime()` 前 | `ensurePythonRuntime()` 返回後 | | 3 | 啟動本機伺服器 | 階段 2 完成後立即 | `waitHealthy(port, 30s)` 返回後(server HTTP OK)| | 4 | 偵測 Kneron 裝置 | 階段 3 完成後立即 | 呼叫 server 的 `GET /api/devices` 第一次收到 response(無論是否有硬體,秒回即算完成)| | 5 | 開啟瀏覽器 | 階段 4 完成後 | 呼叫 `OpenInBrowser("")` 返回後(不等瀏覽器真的開,只等 `open`/`start`/`xdg-open` 命令 return);若 `AutoOpenBrowser=false` 則**不呼叫 OpenInBrowser**,直接 emit status=`skipped` 並進入階段 6 | | 6 | 等待 Web UI 連線 | 階段 5 完成後 | server 的 WebSocket hub `OnClientConnected` callback 第一次觸發(透過 HTTP 或 channel 通知 Wails)| **階段 6 的實作細節(v2.1 定版:sentinel file 方案)**: **決定採用 sentinel file**:`/.first-ws-connected`,理由見下方「為什麼不用 channel / callback 直接串」。 **流程**: 1. **Server 端**:WebSocket hub 的 `OnClientConnected` callback 第一次觸發時: ```go // server/internal/websocket/hub.go func (h *Hub) OnClientConnected(c *Client) { h.firstConnOnce.Do(func() { sentinelPath := filepath.Join(h.dataDir, ".first-ws-connected") f, err := os.Create(sentinelPath) if err == nil { _, _ = fmt.Fprintf(f, "bootId=%s\nts=%d\n", h.bootID, time.Now().UnixMilli()) _ = f.Close() } // 檔案內容存 boot-id + timestamp 便於 debug;寫失敗不影響功能(Wails 端 poll 不到也會超時 fail) }) // ... 其他既有邏輯 } ``` 使用 `sync.Once` 確保僅第一次連線觸發;後續連線不重複寫檔。 2. **Wails 端**:`StartupPipeline.watcher` goroutine 每秒 tick 時額外檢查 sentinel 檔案: ```go // 當 current == 6 時,每次 tick 檢查 sentinel 檔案 if cur == 6 { sentinelPath := filepath.Join(p.app.dataDir, ".first-ws-connected") if _, err := os.Stat(sentinelPath); err == nil { p.Complete(6) // → markReady() → emit startup:ready return } } ``` 3. **下次 Start 前清檔**: - `RestartStartupSequence()` 執行時先 `os.Remove(sentinelPath)`(避免殘留檔案導致新階段 6 瞬間完成) - `ServerController.Stop()` 時也清檔(正常停機的清理) - 冷啟動時不需特別清:第一次啟動時檔案不存在,第二次冷啟動前 app 已經整個退出,看情況:v2.1 決定 **每次 `StartServer` 的前置步驟就呼叫一次 `os.Remove(sentinelPath)`**(最保險,重複 remove 不會錯,反正 `os.IsNotExist` 當正常情況) **為什麼用 sentinel file 而非 channel / callback 直接串**: - **Go server 和 Wails app 是兩個 process**:server 是由 `exec.Command(server binary, ...)` spawn 出來的子程序,跟 Wails 的 Go runtime 完全隔離 → 不能共享 Go channel、mutex 或任何 runtime 記憶體 - **IPC 替代方案比較**: - HTTP long-poll endpoint → 需要佔一個 HTTP connection + 處理 timeout,實作較重 - Unix domain socket / named pipe → 跨平台(macOS/Linux/Windows)實作差異大,Windows 下處理 pipe 權限繁瑣 - Sentinel file → 跨平台(`os.Stat` 到處都行)、零依賴、檔案內容可存 boot-id + timestamp 做 debug → **最簡** - **可觀測性**:使用者遇到問題時可以直接檢查 `/.first-ws-connected` 是否存在,判斷階段 6 是否真的完成 **備選方案**(若未來 Go server 改為 in-process module,不再 spawn 子程序):可以改走 Go channel / callback 直接串,屆時在本文件註記即可;v2.1 的前提是子程序模型。 --- ## 4. StartupPipeline Go struct 檔案:`visiona-local/startup_pipeline.go` ```go package main import ( "context" "fmt" "os" "path/filepath" "sync" "time" wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" ) const ( startupTotalStages = 6 startupSoftTimeout = 20 * time.Second startupHardTimeout = 60 * time.Second startupWatcherTickMs = 1000 ) type stageState struct { status string // "pending" | "running" | "completed" | "failed" startedAt time.Time completedAt time.Time softTimeoutEmitted bool } type StartupPipeline struct { app *App mu sync.Mutex stages [startupTotalStages + 1]stageState // 1-indexed current int // 0 = not started, 1-6 = in progress, 7 = ready, -1 = failed startedAt time.Time watcherCancel context.CancelFunc watcherDone chan struct{} } func NewStartupPipeline(app *App) *StartupPipeline { return &StartupPipeline{ app: app, current: 0, } } // Start 啟動整個 pipeline(從階段 1 開始),並開啟 watcher goroutine。 // 只能呼叫一次。 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) } // Complete 標記當前階段完成,並自動切到下一階段(若還有)。 func (p *StartupPipeline) Complete(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 標記某階段為 "skipped",並自動切到下一階段(行為類似 Complete)。 // v2.1 新增:用於階段 5 在 AutoOpenBrowser=false 時跳過 OpenInBrowser 呼叫。 // Watcher 看到 skipped 狀態時不檢查 soft / 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) // emit status=skipped 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) } // Fail 標記當前階段失敗,pipeline 停止。 func (p *StartupPipeline) Fail(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 所有階段完成後觸發。 func (p *StartupPipeline) markReady() { p.mu.Lock() p.current = startupTotalStages + 1 p.mu.Unlock() if p.app.ctx != nil { wailsRuntime.EventsEmit(p.app.ctx, "startup:ready", nil) } p.stopWatcher() } func (p *StartupPipeline) emitProgress(stage int) { p.mu.Lock() st := p.stages[stage] p.mu.Unlock() if p.app.ctx == nil { return } // 使用 goroutine + select 避免阻塞(萬一 Wails IPC 慢) 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(), }) }() } func (p *StartupPipeline) emitError(stage int, err error, cause string) { if p.app.ctx == nil { return } 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 通知 go sendCrashNotification( "visionA Local — 啟動失敗", fmt.Sprintf("第 %d 階段失敗:%s", stage, err.Error()), ) } // watcher 每秒檢查 soft timeout、hard timeout、階段 6 sentinel file。 func (p *StartupPipeline) watcher(ctx context.Context) { defer close(p.watcherDone) ticker := time.NewTicker(startupWatcherTickMs * time.Millisecond) 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) sinceTotal := time.Since(p.startedAt) softEmitted := st.softTimeoutEmitted p.mu.Unlock() // v2.1:階段 6 sentinel file 檢查(取代既有的 OnClientConnected IPC) if cur == 6 { sentinelPath := filepath.Join(p.app.dataDir, ".first-ws-connected") if _, err := os.Stat(sentinelPath); err == nil { p.Complete(6) return } } // v2.1:skip hard / soft timeout 的情境(必須在 hard timeout 檢查前先判斷) // 1) 該階段已標記為 "skipped"(例如階段 5 在 AutoOpenBrowser=false 時) // 2) 階段 6 且 AutoOpenBrowser=false → 使用者必須手動點「Open in Browser」才會觸發 WebSocket // 連線,等待時間可能很長(使用者去倒杯咖啡),不計入 60 s 上限,也不觸發 soft timeout skipTimeout := false if curStatus == "skipped" { skipTimeout = true } if cur == 6 && p.app.prefs != nil && !p.app.prefs.AutoOpenBrowser { skipTimeout = true } // Hard timeout(總時 > 60 s)— 跳過時不檢查 if !skipTimeout && sinceTotal > startupHardTimeout { p.Fail(cur, fmt.Errorf("startup total timeout: %s > %s", sinceTotal, startupHardTimeout)) p.emitError(cur, fmt.Errorf("total timeout"), "total-timeout") return } if skipTimeout { continue // 不檢查 soft timeout } // Soft timeout(單一階段 > 20 s) if sinceStage > startupSoftTimeout && !softEmitted { p.mu.Lock() p.stages[cur].softTimeoutEmitted = true p.mu.Unlock() if p.app.ctx != nil { go func(stage int) { wailsRuntime.EventsEmit(p.app.ctx, "startup:stage-timeout", StartupStageTimeoutEvent{ Stage: stage, SoftTimeoutSeconds: int(startupSoftTimeout.Seconds()), }) }(cur) } } } } } func (p *StartupPipeline) stopWatcher() { if p.watcherCancel != nil { p.watcherCancel() } } ``` **關鍵設計**: 1. **1-indexed stages**:陣列多配一格避免 off-by-one,`stages[1]` ~ `stages[6]` 使用 2. **`current` sentinel 值**:`0`=未啟動、`1-6`=進行中、`7`=ready、`-1`=已失敗;watcher 看到非 1-6 立即 return,避免 leak 3. **非阻塞 emit**:每個 `EventsEmit` 都走 `go func()`,確保 Wails IPC 慢不拖累啟動流程 4. **`softTimeoutEmitted` flag**:確保 `startup:stage-timeout` 每個階段最多 emit 一次 5. **watcher 生命週期**:`ctx.Done()` 或 `stopWatcher()` 觸發時退出;`markReady()` 與 `Fail()` 都會呼叫 `stopWatcher()` --- ## 5. 與 `startup(ctx)` 的整合 `visiona-local/app.go` 的 `startup()` 函式修改(示意): ```go func (a *App) startup(ctx context.Context) { a.ctx = ctx // 初始化階段化啟動管線 a.pipeline = NewStartupPipeline(a) a.pipeline.Start(ctx) // emit startup:progress(stage=1, running) // 階段 1:既有的初始化 if err := a.migrateOldDataDirs(); err != nil { ... } if err := a.acquireSingleInstance(); err != nil { ... } if err := a.startIPCServer(); err != nil { ... } if err := a.seedUserDataDir(); err != nil { ... } a.pipeline.Complete(1) // emit startup:progress(stage=1, completed) + startup:progress(stage=2, running) // 載入 preferences(必須在 ctrl.Start 之前,ctrl.Start 會讀 prefs.AutoOpenBrowser) a.prefs = LoadPreferences(a.dataDir) // 階段 2-6:由 ctrl.Start → startServerV2 內部呼叫 pipeline.Complete(2..6) if err := a.ctrl.Start(); err != nil { // 失敗情境已由 pipeline.Fail / emitError 處理,這裡不需額外動作 return } // 成功:pipeline.Complete(6) → markReady() → emit startup:ready } ``` `server_control.go:startServerV2` 內部穿插 `pipeline.Complete(N)` / `pipeline.Start(N+1)` 的呼叫,在對應階段的 boundary 執行。 --- ## 6. 前端(Wails 控制台 vanilla JS) 檔案:`visiona-local/frontend/components/startup-panel.js`(~100 行) ```javascript // startup-panel.js import { t } from '../i18n/loader.js'; const el = () => document.getElementById('startup-panel'); const stagesEl = () => document.getElementById('startup-stages'); const elapsedEl = () => document.getElementById('startup-elapsed'); let startedAt = 0; let elapsedTimer = null; export function initStartupPanel() { // 初始 render 6 個階段為 pending const container = stagesEl(); container.innerHTML = ''; for (let i = 1; i <= 6; i++) { const row = document.createElement('div'); row.id = `startup-stage-${i}`; row.className = 'startup__stage startup__stage--pending'; row.innerHTML = ` ${i}. ${t(`startup.stage.${i}.label`)} `; container.appendChild(row); } startedAt = Date.now(); elapsedTimer = setInterval(updateElapsed, 500); } function updateElapsed() { const seconds = Math.floor((Date.now() - startedAt) / 1000); elapsedEl().textContent = t('startup.elapsed', { seconds }); } export function updateStartupPanel(e) { // e = { stage, totalStages, labelKey, status, startedAt, retrying? } const row = document.getElementById(`startup-stage-${e.stage}`); if (!row) return; row.className = `startup__stage startup__stage--${e.status}`; const icon = row.querySelector('.startup__icon'); icon.textContent = { pending: '⏳', running: '🔄', completed: '✅', failed: '❌', retrying: '🔄', }[e.status] || '⏳'; if (e.status === 'completed' || e.status === 'failed') { const elapsed = ((Date.now() - e.startedAt) / 1000).toFixed(1); row.querySelector('.startup__time').textContent = `(${elapsed} s)`; } if (e.status === 'retrying' || e.softTimeoutSeconds) { const hint = row.querySelector('.startup__retry-hint'); hint.textContent = t('startup.retrying', { stage: e.stage }); hint.hidden = false; } } export function showStartupError(e) { // 最終切 Error state 會由 server:state-change 處理;這裡只做視覺標記 const row = document.getElementById(`startup-stage-${e.stage}`); if (row) { row.className = 'startup__stage startup__stage--failed'; row.querySelector('.startup__icon').textContent = '❌'; } // 停止 elapsed timer if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null; } } export function hideStartupPanel() { if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null; } const panel = el(); if (!panel) return; // 300 ms 淡出 panel.classList.add('startup__panel--hiding'); setTimeout(() => { panel.hidden = true; panel.classList.remove('startup__panel--hiding'); }, 300); } ``` `app.js` 在 `main()` 最前面呼叫 `initStartupPanel()`,然後訂閱 4 個 event(見 `control-panel.md` §5 的更新)。 --- ## 7. 驗收條件 | # | 情境 | 預期 | |---|------|------| | 1 | 樂觀冷啟動(所有階段 ~1 s)| 4-5 s 內看到進度面板 6 階段逐一 completed → 淡出顯示主控台 | | 2 | 日常啟動(非首次,~3 s)| 進度面板 min-display-time 1 s 後淡出 | | 3 | 悲觀冷啟動(Python wheels extract 慢)| 8-10 s 內完成,階段 2 可能顯示較久但不觸發 retry hint | | 4 | 階段 2 卡 25 s(mock 測試)| 看到「第 2 階段正在重試 …」副文字、但流程繼續 | | 5 | 階段 3 失敗(server binary 不存在)| 階段 3 變 ❌、收到 OS 通知、切 Error state | | 6 | 總時 > 60 s(mock 每階段等 12 s)| 60 s 時切 Error state + emit `startup:error` | | 7 | `AutoOpenBrowser=false`(Linux 預設)| 階段 5 立即 complete(跳過 OpenInBrowser 實際呼叫)| | 8 | WebSocket 30 s 內無 client 連上(罕見)| 階段 6 失敗 → Error state | | 9 | 進度面板淡出不卡住主控台 | 300 ms ease 後主控台 UI 顯示,無視覺 artifact | --- ## 8. Retry 機制(RestartStartupSequence,v2.1 新增) **觸發情境**:Wails 控制台進入 Startup Error state(階段失敗或 60 s hard timeout),使用者在 Error state 面板點「Retry」按鈕(Design Spec v2.1 §3.7 定義)。這個行為**不是** `RestartServer()`(後者只重啟 server 子程序,保留 port),而是**整個啟動流程重跑**。 ### 8.1 Binding:`RestartStartupSequence` ```go // visiona-local/app.go // RestartStartupSequence resets the entire startup pipeline and tries again. // Triggered by user clicking "Retry" button in Wails console Error state. // // 與 RestartServer 的差別: // RestartServer : Stop → Start(保留 port、沿用既有 StartupPipeline instance) // RestartStartupSequence : ForceKill → 清狀態 → 重建 StartupPipeline → 從階段 2 跑(階段 1 直接 emit completed) func (a *App) RestartStartupSequence() error { // Step 1: 停止當前的 watcher goroutine(避免舊 watcher 把剛重跑的階段誤判為 soft timeout) if a.pipelineCancelFn != nil { a.pipelineCancelFn() a.pipelineCancelFn = nil } // Step 2: 強制殺掉 server 子程序(不等 graceful period,我們是在 recover failure) // 直接 ForceKill 比 Stop() 快;Stop() 會走 7 s grace period 對這個情境沒必要 if a.ctrl != nil { a.ctrl.ForceKill() // 新增 method:內部 SIGKILL + 清 c.proc + setState(Stopped) 但不 emit crash notification } // Step 3: Reset state machine to Stopped(避免 Error state 殘留) if a.ctrl != nil { a.ctrl.setState(ServerStateStopped, "") } // Step 4: 清 sentinel file(critical — 否則階段 6 會誤判為瞬間完成) sentinelPath := filepath.Join(a.dataDir, ".first-ws-connected") _ = os.Remove(sentinelPath) // Step 5: 重建 StartupPipeline 並呼叫 StartServer a.startupPipeline = NewStartupPipeline(a) // 階段 1「初始化 Wails 控制台」已經是 running 狀態(我們是 Wails app 本身,不需要重做), // 直接 emit completed 不重跑 a.startupPipeline.mu.Lock() a.startupPipeline.startedAt = time.Now() a.startupPipeline.current = 1 a.startupPipeline.stages[1].status = "completed" a.startupPipeline.stages[1].startedAt = time.Now() a.startupPipeline.stages[1].completedAt = time.Now() a.startupPipeline.mu.Unlock() a.startupPipeline.emitProgress(1) // emit stage=1 completed // 啟動 watcher goroutine watcherCtx, cancel := context.WithCancel(a.ctx) a.pipelineCancelFn = cancel a.startupPipeline.watcherDone = make(chan struct{}) go a.startupPipeline.watcher(watcherCtx) // 切到階段 2 並真的跑 a.startupPipeline.mu.Lock() a.startupPipeline.current = 2 a.startupPipeline.stages[2].status = "running" a.startupPipeline.stages[2].startedAt = time.Now() a.startupPipeline.mu.Unlock() a.startupPipeline.emitProgress(2) // Step 6: 呼叫 StartServer(內部會依序 Complete(2..6)) // Retry 情境允許 port fallback(視同 cold start;見 server-lifecycle.md §3.3) return a.ctrl.Start() } ``` ### 8.2 前端整合 Wails 控制台的 Error state 面板顯示: - 錯誤訊息(來自 `startup:error` event 的 `error` 欄位) - 失敗階段(`stage` 欄位) - 「Retry」按鈕 → `RestartStartupSequence().catch(showError)` - 「Export log」按鈕 → `ExportLog()`(方便使用者回報問題時附 log) - 「Quit」按鈕 → `runtime.Quit(ctx)` 點 Retry 後: 1. 控制台 UI 切回 Starting state(隱藏 Error 面板、顯示 Startup Progress 面板) 2. 重跑 `initStartupPanel()` 將 6 個階段重新 render 為 pending(階段 1 立即變 completed) 3. 訂閱者會陸續收到 `startup:progress` 事件更新 DOM ### 8.3 驗收 | # | 情境 | 預期 | |---|------|------| | R1 | 階段 2 失敗 → 點 Retry → server 恢復可啟動 | Retry 後階段 1 顯示 completed、階段 2-6 依序完成 → `startup:ready` | | R2 | 階段 2 失敗 → 點 Retry → 仍然失敗(Python binary 還是壞的)| 再次進 Error state,不會無限重試 | | R3 | 60 s hard timeout → 點 Retry | 整個流程計時歸零,新一輪 60 s 上限 | | R4 | Retry 時 port 3721 被其他程式佔用 | 允許 fallback 到 3722/3723… | | R5 | 連點 Retry 兩次 | 第二次在 `pipelineCancelFn != nil` 檢查時安全地 cancel 舊 watcher,不會殘留 goroutine | | R6 | 階段 6 還在 pending 時點 Retry | ForceKill 掉 server → sentinel file 被清 → 從階段 2 重跑 | --- ## 9. 待確認 1. **階段 6 的「WebSocket 首次連線」實作方式(已定版)** — v2.1 二次審閱定案:採用 sentinel file(`/.first-ws-connected`)。詳見 §3 階段 6 章節。無需開發時再討論。 2. **進度面板 min-display-time** — 若啟動 < 1 s,進度面板閃一下就消失不好看。建議設 1 s min-display-time(即使 `startup:ready` 已 emit,面板也要留 1 s 才淡出)。實作細節可放在 `hideStartupPanel()` 判斷 `Date.now() - startedAt < 1000` 時延遲執行 3. **R5-E5 文案** — Design Spec v2.1 尚未敲定,i18n key 已預留。Design Agent 完成後可直接填入 `i18n/*.json` 4. **watcher goroutine 與 `ctrl.Stop()` 的交互** — 若使用者在啟動中間按了 Stop(不太可能,Starting 狀態下 action bar 禁用),pipeline 要能 cancel。目前靠 `stopWatcher()` + `ctrl.setState(Error)` 處理,實測後若有 race 再補強 5. **`ForceKill` method 需新增到 ServerController** — RestartStartupSequence 依賴這個 method,`server_control.go` 要加一個非同步版 Stop:直接 SIGKILL 不走 7 s grace、不發 crash notification。M8-4b 執行時補上