依 R5 五輪決策把 visionA-local 從「Wails 內嵌 Next.js」重構為「Wails
本機伺服器控制台 + 瀏覽器 Web UI」模式(類比 Docker Desktop / Ollama)。
程式碼變動
- M8-1 砍 yt-dlp 全套(後端 resolver / URL handler / 前端 URL tab /
Makefile vendor / installer / bootstrap / CI workflow,-555 行)
- M8-2 砍 Mock 模式全套(driver/mock、mock_camera、Settings runtimeMode、
VISIONA_MOCK 環境變數,-528 行)
- M8-3 ffmpeg 從 GPL 切換到 LGPL 混合方案:Windows/Linux 用 BtbN 現成
LGPL binary,macOS 自 build minimal decoder-only 進 git
(vendor/ffmpeg/macos/ffmpeg 5.7MB + ffprobe 5.6MB,比 GPL 版省 85% 空間)
- M8-4 Wails Server Controller:state machine、log ring buffer 2000 行、
preferences.json atomic write、boot-id、Gin SkipPaths、shutdown 7+1 秒、
notify_*.go 三平台 OS 通知、watchServer 改 Error state 不 os.Exit
- M8-4b 啟動階段管線 R5-E:6 階段進度 event、20s soft / 60s hard timeout、
stage 5/6 skip 規則、sentinel file、RestartStartupSequence 5 步驟
- M8-5 Wails 控制台 vanilla HTML/JS/CSS(9 檔 ~2012 行)取代 M7-B splash:
state 視覺、log panel、startup progress panel、Stage 6 manual CTA
pulse、shutdown modal、Settings、Dark Mode、i18n 中英雙語
- M8-6 上傳影片副檔名擴充(mp4/avi/mov/mpeg/mpg)
- M8-7 Web UI Server Offline Overlay(role=alertdialog + focus trap +
wsEverConnected 容錯 + Page Visibility)
- M8-8 CORS middleware(127.0.0.1/localhost only + suffix attack 防護)+
ws/origin.go 獨立 WebSocket CheckOrigin 避 package cycle
- MAJ-4 server:shutdown-imminent WebSocket broadcast 機制
(/ws/system endpoint + notifyShutdownImminent helper)
- M8-9 Boot-ID + 瀏覽器 tab 自動重連(sessionStorage loop guard)
品質
- ~105+ 新 unit test + race detector (-count=2) 全綠
- 10 個 milestone 全部通過 Reviewer 審查
- 三方 v2 + v2.1 文件(PRD / Design Spec / TDD)+ 交叉互審紀錄
收錄在 .autoflow/
交付前待處理(M8-10)
- 重跑 make payload-macos 把舊 GPL 77MB binary 換成新 LGPL
- 三平台 end-to-end build 驗證
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
274 lines
20 KiB
Markdown
274 lines
20 KiB
Markdown
# Reviewer 審查 M8-4b 啟動階段管線(2026-04-15)
|
||
|
||
## 摘要
|
||
|
||
- 審查對象:M8-4b(R5-E 6 階段啟動 pipeline + watcher + sentinel file + RestartStartupSequence)
|
||
- 審查檔案:`visiona-local/startup_pipeline.go`(394 行)、`startup_pipeline_test.go`(457 行)、`server/internal/api/ws/hub.go`(sentinel 機制)、`hub_sentinel_test.go`(3 tests)、`visiona-local/app.go`(startup / shutdown / runStartupStage5)、`visiona-local/server_control.go`(stage hooks / probeDeviceListAndComplete / RestartStartupSequence / ForceKill)、`server/main.go`(wiring)
|
||
- 總結論:**⚠️ 需小改** — 核心實作對齊 TDD v2/startup-pipeline.md,Build/Vet/Test/Race 全過,功能面沒有阻擋 M8-5 / M8-9 的問題;但發現 **2 個 Major UX 問題**(重複 OS 通知 + 重複開瀏覽器),建議在 M8-4b 收尾前順手修。
|
||
- 問題統計:Critical 0 / Major 2 / Minor 5 / Suggestion 3
|
||
- 阻擋後續 milestone 嗎:**否**。M8-5(前端控制台 UI)可直接使用現在的 event schema 與 `RestartStartupSequence` binding;M8-9(boot-id + 重連)不觸及 pipeline 內部,可平行推進。兩個 Major 屬 UX 層面,在 M8-10 end-to-end 驗收前修掉即可。
|
||
|
||
---
|
||
|
||
## A. StartupPipeline struct
|
||
|
||
| TDD §4 要求 | 實作位置 | 狀態 |
|
||
|------|---------|------|
|
||
| 6 階段常量 / soft 20s / hard 60s / tick 1s | `startup_pipeline.go` L39-44 | ✅ |
|
||
| Event schema(`StartupProgressEvent` / `StageTimeoutEvent` / `ErrorEvent`)| L54-75 | ✅ 欄位命名、json tag 與 §1 一致 |
|
||
| Status 枚舉含 pending/running/completed/failed/skipped | L60, L82 | ✅ 和 §1.1 v2.1 新增 `skipped` 對齊 |
|
||
| 1-indexed stages 陣列 + `current` sentinel 值 | L99-101 | ✅ 0/1-6/7/-1 語義註解清楚 |
|
||
| `CompleteStage` 順序保護 + `current != stage` 忽略 | L138-163 | ✅ 符合 §4「重複呼叫或順序錯誤,忽略」 |
|
||
| `SkipStage` → 進下一階段 | L168-192 | ✅ 與 `CompleteStage` 對稱 |
|
||
| `FailStage` → `emitError` + `stopWatcher` | L200-213 | ✅ |
|
||
| `markReady` → emit `startup:ready` + `stopWatcher` | L216-226 | ✅ 成功路徑走同步 emit(註解有解釋) |
|
||
| `emitProgress` 用 goroutine 非阻塞 | L230-251 | ✅ 符合 §4 關鍵設計 3 |
|
||
| `emitError` 同步 `setState(Error)` + 非阻塞 OS 通知 | L255-277 | ✅ |
|
||
|
||
**符合度 100%**。
|
||
|
||
---
|
||
|
||
## B. Watcher goroutine
|
||
|
||
| 行為 | 位置 | 狀態 |
|
||
|------|------|------|
|
||
| 每秒 tick + `ctx.Done` 退出 | L291-297 | ✅ |
|
||
| `current` 不在 1-6 範圍時 return | L300-303 | ✅ |
|
||
| 階段 6 每 tick 檢查 sentinel file | L313-318 | ✅ |
|
||
| `skipped` status / 階段 6 + AutoOpenBrowser=false 跳過 timeout | L321-327 | ✅ 雙條件都檢查 |
|
||
| Hard timeout 直接展開 mark failed + `emitError(total-timeout)` | L329-341 | ✅ cause 分流正確;不走 `FailStage` 以便 cause 設為 `total-timeout` |
|
||
| `softTimeoutEmitted` flag 每階段最多 emit 一次 | L348-362 | ✅ |
|
||
| `skipTimeout` continue 時不檢查 soft timeout | L343-345 | ✅ |
|
||
|
||
**Minor B-1**:`watcher` 讀取 `p.app.prefs.AutoOpenBrowser`(L325)未持 `a.mu`,但 `SetPreferences`(`server_control.go` L1062-1064)寫 `a.prefs` 時持 `a.mu`。因為 `Preferences` 是 struct value(含 `bool` + `string`),同時讀寫觸發 data race。實測環境罕見(使用者在 Starting state 改 pref),`go test -race` 沒觸發。建議 watcher 的 pref 讀取改走 getter 或先 snapshot。
|
||
|
||
---
|
||
|
||
## C. Stage hooks 插入位置
|
||
|
||
| Stage | 位置 | 狀態 |
|
||
|-------|------|------|
|
||
| 1 Wails 控制台 | `app.go` L224 `CompleteStage(1)`(在 `seedUserDataDir` 之後、`ctrl.Start` 之前)| ✅ 對齊 §3 |
|
||
| 2 Python runtime | `server_control.go` L437-448 `FailStage(2)` / `CompleteStage(2)` 夾 `ensurePythonRuntime` | ✅ |
|
||
| 3 Server spawn | L554-566 `FailStage(3)` / `CompleteStage(3)` 夾 `waitHealthy` | ✅ |
|
||
| 4 Device probe | L571-576 呼叫 `probeDeviceListAndComplete(port)` → HTTP GET `/api/devices` 5s timeout → 任何 response/err 都 `CompleteStage(4)` | ✅ 行為與 §3 描述一致(「秒回算完成」),但 5s timeout 過於保守 — 見 §I-2 |
|
||
| 5 Open browser | `app.go` L243-267 `runStartupStage5()` helper:`AutoOpenBrowser=false` → `SkipStage(5)`,`true` → `openBrowser(url)` + `CompleteStage(5)` | ⚠️ **見 Major C-1** |
|
||
| 6 WebSocket ready | 由 watcher poll sentinel file 觸發 | ✅ |
|
||
|
||
**Major C-1:瀏覽器會被開兩次**
|
||
`runStartupStage5`(app.go L262)呼叫 `openBrowser(url)`,但 `startInternal`(server_control.go L188-194)的 R5-D3 邏輯在 Start 成功後也 `openBrowser(url)`。冷啟動路徑會執行兩次 `openBrowser`:
|
||
- macOS:`open http://...` 同 URL 兩次通常聚合到同一 tab,但取決於瀏覽器
|
||
- Linux:`xdg-open` 可能開兩個 tab
|
||
- Windows:`start` 行為不一致
|
||
|
||
建議的修法(二擇一):
|
||
1. `startInternal` 在 `a.startupPipeline != nil && a.startupPipeline.current >= 0 && a.startupPipeline.current <= startupTotalStages`(冷啟動中)時跳過自己的 `openBrowser`,由 `runStartupStage5` 負責;`RestartServer` 情境下 pipeline 已 ready(`current==7`),R5-D3 仍執行。
|
||
2. 反過來:`runStartupStage5` 只負責 `CompleteStage(5)`,實際 open 由 startInternal 處理 — 但這樣做跟 TDD §3 階段 5 的語義(「Open browser」=呼叫 OpenInBrowser 返回)不符。
|
||
推薦方案 1。
|
||
|
||
---
|
||
|
||
## D. Sentinel file 機制
|
||
|
||
| 項目 | 位置 | 狀態 |
|
||
|------|------|------|
|
||
| `Hub.writeStartupSentinel` 用 `sync.Once` | `hub.go` L79-98 | ✅ |
|
||
| Register channel 第一次收到 Subscription 觸發 | L112 | ✅ 在 `h.mu.Unlock()` 之後呼叫(避免重入) |
|
||
| 檔案內容 `bootId=... ts=...` | L95 | ✅ 符合 §3 |
|
||
| 清檔時機:startup 早期(`app.go` L175)+ shutdown(L299)+ RestartStartupSequence Step 4(L844)| | ✅ 三處都清,符合 §3 的 best-effort 策略 |
|
||
| `Hub.SetStartupSentinel` 從 `server/main.go` L133 注入 `cfg.DataDir`(L101)| | ✅ |
|
||
| DataDir 空值時 disable | `hub.go` L85-87 | ✅ 有單測驗證(`DisabledWhenDataDirEmpty`)|
|
||
|
||
**Minor D-1**:`writeStartupSentinel` 取 `h.mu.RLock()` 保護 `sentinelDataDir`,但 `SetStartupSentinel`(L68-72)用 Write lock — 這個 lock 保護與 `rooms` map 的 lock 共用。功能上正確但概念上有點混搭(同一把 lock 同時保護「訂閱狀態」與「初始化一次的 dataDir」),未來可用獨立 sync.Once 或 atomic.Pointer 分離。
|
||
|
||
---
|
||
|
||
## E. RestartStartupSequence 5 步驟
|
||
|
||
| Step | 位置 | 狀態 |
|
||
|------|------|------|
|
||
| 1. cancel watcher goroutine | `server_control.go` L832-835 `a.pipelineCancelFn()` + 清 nil | ✅ |
|
||
| 2. ForceKill server subprocess | L838 `ctrl.ForceKill()`,該 method 在 L273-291 已實作(cancel watcher → clear proc → `proc.forceKill()` → `setState(Stopped)`)| ✅ |
|
||
| 3. Reset state machine to Stopped | L841 `setState(Stopped, "")` | ✅ 雖與 Step 2 結尾重複,但 defensive 沒壞處 |
|
||
| 4. Clean sentinel file | L844 `removeSentinelFile(a.dataDir)` | ✅ 符合 §3 明確要求「critical — 否則階段 6 會誤判瞬間完成」 |
|
||
| 5. 重建 `StartupPipeline` + stage 1 直接 complete + watcher + stage 2 running + `ctrl.Start()` + `runStartupStage5()` | L847-887 | ✅ 符合 §8.1 |
|
||
| Stage 1 不重跑 | L851-858 直接寫 `stages[1].status = "completed"` + emitProgress | ✅ |
|
||
| Binding 暴露 | Wails binding 是整個 App(`main.go` L35-37),所有 exported method 自動暴露 | ✅ `RestartStartupSequence() error` 符合前端呼叫介面 |
|
||
|
||
**Minor E-1**:Step 5 手動操作 `a.startupPipeline.mu` 和 internal fields(`stages[1].status = "completed"` 等),繞過了 struct 的封裝 API。未來改 `stageState` 欄位或 status 語義時,這段極容易 drift。建議把這段封裝為 `(*StartupPipeline).forceCompleteStage1And2Running(time.Time)` 或類似方法,把操作內部狀態的邏輯收進 pipeline 自己的 file。
|
||
|
||
**Minor E-2**:Step 5 L865 設 `a.startupPipeline.watcherCancel = cancel` 同一個 cancel 也存在 `a.pipelineCancelFn`。`FailStage → stopWatcher → watcherCancel()` 會 cancel,但 `a.pipelineCancelFn` 不會被 nil-out → 下次 Retry 執行 Step 1 會呼叫已 cancelled 的 func(`context.CancelFunc` 文件保證 idempotent,OK),然後清 nil。功能正確,但語義上「cancel 執行後清 nil」一致性不足。可接受。
|
||
|
||
**Minor E-3**:`RestartStartupSequence` 在 `a.ctx == nil` 情境下(單測走這條)不啟動 watcher,正常執行環境下 `a.ctx` 一定有值,但如果未來 ctx 時序改變,可能踩到。建議在 `a.ctx == nil` 時 return error 而非 silent skip watcher。
|
||
|
||
---
|
||
|
||
## F. Stage 5/6 skip 邏輯
|
||
|
||
| 規則 | 驗證 |
|
||
|------|------|
|
||
| Linux + `AutoOpenBrowser=false` → Stage 5 skipped | ✅ `preferences.go` L42 `DefaultPreferences()` 依 GOOS 把 Linux 設 false;`runStartupStage5` L247-250 呼叫 `SkipStage(5)` |
|
||
| `AutoOpenBrowser=false` → Stage 6 不 trigger timeout | ✅ `startup_pipeline.go` L325-327 skipTimeout 條件覆蓋 hard + soft |
|
||
| `skipped` 狀態 emit progress event | ✅ `SkipStage` L178 呼叫 `emitProgress(stage)`,status 已是 `"skipped"` |
|
||
| Watcher 在 stage 6 + AutoOpenBrowser=false 時不誤判 hard timeout | ✅ 有單測 `TestStartupPipeline_Watcher_SkippedStageNoTimeout`(set 70s sinceTotal 不觸發) |
|
||
|
||
一個**設計問題**(非 bug):`AutoOpenBrowser=false` + 使用者永遠不手動開瀏覽器時,pipeline 永遠停在 stage 6 running → Startup panel 永遠不淡出。TDD §7 驗收條件 case 7 只說「階段 5 立即 complete」,沒說 panel 該什麼時候淡出。M8-5 前端設計需要補一個「AutoOpenBrowser=false 時直接把 panel 淡出 + 顯示『伺服器已就緒,請點 Open in Browser』」的流程。**這不是 M8-4b 的 bug,是 M8-5 要處理的 UX。** 建議 M8-5 Agent 啟動前提醒。
|
||
|
||
---
|
||
|
||
## G. 與 M8-4 補丁合併狀況
|
||
|
||
兩個並行改動:
|
||
- **M8-4 補丁**碰 `Stop()` / `ForceKill()` / `handleWatchFailure()` / `logPump()`(失敗與關閉路徑)
|
||
- **M8-4b** 碰 `startServerV2` 中間 hook 點 + 新增 `probeDeviceListAndComplete` + `RestartStartupSequence`(成功路徑)
|
||
|
||
合併狀況:
|
||
- `ForceKill`(L273-291)的 M8-4 修復(MAJ-1:`cancelWatcher` 先 cancel 避免 30s 後翻 Error)仍存在並被 `RestartStartupSequence` Step 2 正確利用。✅
|
||
- `handleWatchFailure`(L305-337)的 MAJ-2 修復(持 `txMu` + 檢查 state != Running 則 return)不被 M8-4b 直接依賴,但 `RestartStartupSequence` 的 ForceKill 路徑依賴「watcher 先被 cancel」的順序,兩個修復方向一致。✅
|
||
- `startServerV2` 沒被 M8-4 碰,M8-4b 只新增 pipeline hook,不破壞現有流程。✅
|
||
|
||
`cd visiona-local && go build .` → PASS
|
||
`cd visiona-local && go vet ./...` → PASS
|
||
`cd visiona-local && go test ./...` → PASS(7.263s)
|
||
`cd visiona-local && go test -race -count=2 ./...` → PASS(15.122s)
|
||
`cd server && go build ./...` → PASS
|
||
`cd server && go vet ./...` → PASS
|
||
`cd server && go test ./...` → PASS
|
||
`cd server && go test -race ./...` → PASS
|
||
|
||
---
|
||
|
||
## H. Build / Test / Race detector
|
||
|
||
全部 PASS(見 §G 尾)。
|
||
|
||
---
|
||
|
||
## I. Agent 觀察評估
|
||
|
||
### I-1 重複 OS notification — **Major I-1,真的會發生**
|
||
|
||
流程:
|
||
1. `startServerV2` 失敗(例如 stage 3 `waitHealthy`)→ L559 `pipeline.FailStage(3, err)`
|
||
2. `FailStage` → `emitError(3, err, "stage-failure")` → `startup_pipeline.go` L268-276:
|
||
- `c.app.ctrl.setState(ServerStateError, err.Error())`
|
||
- `go sendCrashNotification("visionA Local — 啟動失敗", "第 3 階段失敗:...")` ← **通知 1**
|
||
3. `startServerV2` return err → `startInternal` L166 收到 err
|
||
4. `startInternal` L168-180:
|
||
- 再次 `setState(Error, err.Error())`
|
||
- emit `server:error` event
|
||
- `go sendCrashNotification("visionA Local — Server 啟動失敗", "請打開 visionA Local 查看錯誤詳情或按 Restart 重試。")` ← **通知 2**
|
||
|
||
兩則通知 title 和 body **都不同**,使用者會以為是兩個獨立錯誤;`setState(Error)` 被呼叫兩次(值相同、前端收到重複 `server:state-change` event,但 payload 一樣不致命)。
|
||
|
||
**建議修法**:在 `startInternal` L168-180 的 error 分支加守門:
|
||
```go
|
||
// 若 pipeline 已 FailStage 過,不重複發通知與 setState(pipeline 已處理)
|
||
if c.app.startupPipeline == nil || c.app.startupPipeline.current != -1 {
|
||
c.setState(ServerStateError, err.Error())
|
||
if c.app.ctx != nil {
|
||
wailsRuntime.EventsEmit(c.app.ctx, "server:error", ...)
|
||
}
|
||
go sendCrashNotification(...)
|
||
}
|
||
```
|
||
或 `emitError` 設一個 `ctrl.suppressNextErrorNotification` flag,`startInternal` 收到 err 時若 flag 為 true 則 clear 並 skip 通知。後者耦合小。**阻擋 UX 可接受度,建議本次 M8-4b 一併修。**
|
||
|
||
### I-2 Stage 4 probe 5s timeout — **Minor**
|
||
|
||
TDD §3 原文「GET /api/devices 第一次收到 response(無論是否有硬體,秒回即算完成)」。實作用 `http.Client{Timeout: 5 * time.Second}` 並把 timeout err 也視為完成。
|
||
|
||
評估:
|
||
- 實測情境:`/api/devices` handler 讀內部 registry,ms 級就回,5s 幾乎不會用到
|
||
- 極端情境:deviceMgr Start 時非同步 USB 掃描若 block 主 goroutine(不太可能),handler 可能慢 >1s
|
||
- 5s 對 UX 來說:若硬體 driver 卡住而 health check 已 200,使用者會額外等 5s 看到 stage 4 才 complete。但因為 stage 4 timeout 也算完成,不 fail,只是卡視覺進度
|
||
- 對比 TDD「秒回」原意:5s 保守
|
||
|
||
**建議**:`Timeout: 2 * time.Second`。2s 足以涵蓋正常業務 latency 且符合 TDD 原意。Minor 優化,不阻擋。
|
||
|
||
---
|
||
|
||
## J. 測試品質
|
||
|
||
14 個 pipeline test + 3 個 hub sentinel test 覆蓋:
|
||
|
||
| 項目 | 測試 | 狀態 |
|
||
|------|------|------|
|
||
| CompleteStage 正常推進 | `CompleteStage_AdvancesToNext` | ✅ |
|
||
| CompleteStage 順序錯誤忽略 | `OutOfOrder_Ignored` | ✅ |
|
||
| CompleteStage 最後階段 → markReady | `LastStageMarksReady` | ✅ |
|
||
| SkipStage | `SkipStage_AdvancesAndMarksSkipped` | ✅ |
|
||
| FailStage | `FailStage_StopsPipeline` | ✅ |
|
||
| Soft timeout(mock 時間)| `Watcher_SoftTimeout` | ✅ 用 `startedAt = now.Add(-25s)` 加速 |
|
||
| Hard timeout(mock 時間)| `Watcher_HardTimeout` | ✅ 不是真的跑 60s |
|
||
| AutoOpenBrowser=false + stage 6 | `Watcher_SkippedStageNoTimeout` | ✅ |
|
||
| skipped status bypass | `Watcher_SkippedStatusBypassesTimeout` | ✅ |
|
||
| Sentinel file 偵測 | `CheckSentinelFile` / `Stage6CompletesOnSentinel` | ✅ |
|
||
| removeSentinelFile | `RemoveSentinelFile` | ✅ 驗證 idempotent + 空 dataDir |
|
||
| stopWatcher idempotent | `StopWatcher_Idempotent` | ✅ |
|
||
| Hub sentinel | 3 test(first register / only once / disabled) | ✅ |
|
||
|
||
**Major J-1**:`TestStartupPipeline_RestartStartupSequence_StepsExecution`(L386-439)**沒有呼叫 `RestartStartupSequence` method 本身**,而是把 Step 1-5 的邏輯**手動複製一遍**再驗證 side effect。這個測試保護性很弱:未來改 `RestartStartupSequence` 內部順序(例如把 Step 3 `setState(Stopped)` 拿掉 / 改順序),這個測試還是會過。建議重構為:把 Step 6 `ctrl.Start()` 的部分透過測試 double 攔截(例如改測 `app.ctrl.Start` 前的狀態 snapshot),直接呼叫 `a.RestartStartupSequence()` 驗證 Step 1-5 真的執行。
|
||
|
||
**Minor J-1**:沒有測試 race 情境「RestartStartupSequence 執行期間使用者再按 Retry」。雖然 `pipelineCancelFn()` 是 idempotent、`NewStartupPipeline` 是新 instance 不會 double free,但沒 test 保護。建議加一個 `TestRestartStartupSequence_Concurrent` 驗證連續呼叫兩次安全。
|
||
|
||
**Minor J-2**:沒有驗證 `emitProgress` 的 goroutine 路徑 — 因為測試環境 `a.ctx == nil` 讓 emit 走 short-circuit,只能間接驗證內部 state。可接受(Wails runtime 在單測中無法 mock)。
|
||
|
||
---
|
||
|
||
## K. 問題清單
|
||
|
||
### Critical
|
||
|
||
(無)
|
||
|
||
### Major
|
||
|
||
| # | 檔案 | 行 | 問題 | 建議修法 |
|
||
|---|------|----|------|---------|
|
||
| M-1 | `visiona-local/startup_pipeline.go` + `server_control.go` | `emitError` L272-276 + `startInternal` L168-179 | 啟動失敗時 OS 通知發兩次、標題內文不同,使用者誤以為是兩個獨立錯誤 | `startInternal` error 分支檢查 `startupPipeline.current == -1`(代表 pipeline 已 FailStage)時 skip `setState/emit/sendCrashNotification`;或在 `emitError` 設 suppress flag 讓後續路徑 skip |
|
||
| M-2 | `visiona-local/app.go` + `server_control.go` | `runStartupStage5` L262 + `startInternal` L188-194 | 冷啟動時 `openBrowser` 被呼叫兩次(R5-D3 R5-E stage 5 hook 重複) | `startInternal` 的 R5-D3 分支檢查「pipeline 是否在冷啟動中」(`current >= 1 && current <= 6`),若是則 skip,由 `runStartupStage5` 統一負責 |
|
||
| M-3 | `visiona-local/startup_pipeline_test.go` | L386-439 | `TestStartupPipeline_RestartStartupSequence_StepsExecution` 手動複製實作邏輯,未呼叫 method 本身,保護性弱 | 重構為直接呼叫 `a.RestartStartupSequence()`;把 `ctrl.Start()` 的 spawn 用環境旗標 skip(或 stub)使測試可執行 |
|
||
|
||
### Minor
|
||
|
||
| # | 檔案 | 行 | 問題 | 建議 |
|
||
|---|------|----|------|------|
|
||
| m-1 | `startup_pipeline.go` | L325 | watcher 讀 `a.prefs.AutoOpenBrowser` 未持 `a.mu`,與 `SetPreferences` 有潛在 race | 增加 getter `(a *App) preferencesSnapshot() Preferences`(內部持 `a.mu`),watcher 改呼叫 getter |
|
||
| m-2 | `server_control.go` | L847-887 `RestartStartupSequence` | 手動操作 pipeline internal fields(`stages[1].status = "completed"` 等),繞過封裝 | 封裝為 `(*StartupPipeline).RestartFromStage2(ctx, cancelStorer)` method |
|
||
| m-3 | `server_control.go` | L596-611 `probeDeviceListAndComplete` | HTTP timeout 5s 比 TDD「秒回」保守 | 改 `Timeout: 2 * time.Second` |
|
||
| m-4 | `server_control.go` | L862-868 `RestartStartupSequence` | `a.ctx == nil` 時 silent skip watcher,實際執行環境不會觸發但語義不清 | `a.ctx == nil` 時 return error `"app context not ready"` |
|
||
| m-5 | `startup_pipeline_test.go` | — | 沒有驗證 `RestartStartupSequence` 連續呼叫(race)| 加 `TestRestartStartupSequence_Concurrent`:`wg.Add(2); go restart(); go restart(); wg.Wait()` 驗證不 panic + 單一 pipeline instance 存活 |
|
||
|
||
### Suggestion
|
||
|
||
| # | 內容 |
|
||
|---|------|
|
||
| s-1 | `hub.go` `sentinelDataDir` 和 `sentinelOnce` 共用 `Hub.mu`,概念上混搭。可改用獨立 `sync.Once` 或 `atomic.Value` 分離初始化狀態與訂閱狀態 |
|
||
| s-2 | `watcher` 的 `skipTimeout` 判斷邏輯可抽成 helper `(p *StartupPipeline) shouldSkipTimeout(stage int, status string) bool`,提升可讀性 |
|
||
| s-3 | 失敗時的 OS 通知可考慮改成 in-app modal + OS 通知**擇一**(OS 通知常被使用者忽略),搭配 Wails 原生 dialog 效果更好 — 但這超出 M8-4b 範圍,屬於 M8-5 / M8-10 UX 階段決定 |
|
||
|
||
---
|
||
|
||
## L. 結論
|
||
|
||
**判定:⚠️ 需小改**
|
||
|
||
M8-4b 的核心工程實作(StartupPipeline struct / 6 階段 hook / watcher / sentinel file / RestartStartupSequence)完整對齊 TDD v2/startup-pipeline.md v2.1,Build/Vet/Test/Race 4 個平台全通過,合併 M8-4 補丁乾淨無衝突。
|
||
|
||
**阻擋 M8-5 / M8-9 嗎:否。** 目前的 event schema、binding 與 sentinel 機制已經足夠 M8-5(Wails 控制台 UI 訂閱事件)與 M8-9(boot-id 重連)直接開工。
|
||
|
||
**建議的後續動作**:
|
||
1. **M8-4b Agent 再跑一輪**,修 Major M-1(重複通知)+ M-2(重複開瀏覽器)+ M-3(Retry test 重構)。這三項都是工時小(< 30 分鐘)但影響使用者第一眼觀感的 UX 問題,在 M8-10 前必修。
|
||
2. **Orchestrator 交棒 M8-5 時**提醒 Frontend Agent:AutoOpenBrowser=false + stage 6 永遠 running 的情境下,startup panel 該如何淡出 / 顯示「請手動開瀏覽器」的 CTA。這個 UX 決定不屬於 M8-4b 範疇,但要在 M8-5 啟動前搞清楚。
|
||
3. Minor 5 項 + Suggestion 3 項可記為技術債,不強制 M8-4b 處理。
|
||
|
||
**優點**:
|
||
- TDD §4 結構完整重現,status 列舉 / 欄位 / emit 策略全部符合
|
||
- `skipped` 狀態在 watcher + CompleteStage/SkipStage 的處理一致,邊界條件清楚
|
||
- Test 用 mock 時間(`startedAt.Add(-Xs)`)加速 soft/hard timeout 驗證,沒有真跑 60 秒
|
||
- Sentinel file 三個清檔時機(startup 早期 / shutdown / Retry)覆蓋完整
|
||
- 與 M8-4 補丁(ForceKill / handleWatchFailure 的 MAJ-1/MAJ-2 修復)合併乾淨,`RestartStartupSequence` 正確利用了 ForceKill 的 cancel watcher 修復
|
||
- 測試覆蓋面積大(17 個 test),邊界條件完整
|