jim800121chen 8cd5751ce3 feat(local-tool): M8 重構 — Wails 控制台 + 瀏覽器 Web UI(R5 決策)
依 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>
2026-04-15 17:57:54 +08:00

274 lines
20 KiB
Markdown
Raw Permalink 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.

# Reviewer 審查 M8-4b 啟動階段管線2026-04-15
## 摘要
- 審查對象M8-4bR5-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.mdBuild/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` bindingM8-9boot-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+ shutdownL299+ RestartStartupSequence Step 4L844| | ✅ 三處都清,符合 §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` 文件保證 idempotentOK然後清 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 ./...` → PASS7.263s
`cd visiona-local && go test -race -count=2 ./...` → PASS15.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 過,不重複發通知與 setStatepipeline 已處理)
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 讀內部 registryms 級就回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 timeoutmock 時間)| `Watcher_SoftTimeout` | ✅ 用 `startedAt = now.Add(-25s)` 加速 |
| Hard timeoutmock 時間)| `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 testfirst 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.1Build/Vet/Test/Race 4 個平台全通過,合併 M8-4 補丁乾淨無衝突。
**阻擋 M8-5 / M8-9 嗎:否。** 目前的 event schema、binding 與 sentinel 機制已經足夠 M8-5Wails 控制台 UI 訂閱事件)與 M8-9boot-id 重連)直接開工。
**建議的後續動作**
1. **M8-4b Agent 再跑一輪**,修 Major M-1重複通知+ M-2重複開瀏覽器+ M-3Retry test 重構)。這三項都是工時小(< 30 分鐘但影響使用者第一眼觀感的 UX 問題 M8-10 前必修
2. **Orchestrator 交棒 M8-5 **提醒 Frontend AgentAutoOpenBrowser=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邊界條件完整