v0.18.11:修好 Windows「裝了完全不會動」的真兇——全新 config 缺 manifest 欄位

leo 08-06 提供決定性線索:**exit status 2,已自動重試 21 次失敗**。

## 真兇
`app.go` 的 `saveCfg` 只寫四個鍵(accounts/extractor/extractor_explicit/
gemini_api_key),**從來不寫 `manifest`**。
而 `manifest` 是 collector 的必填欄位(`direct.go`:
`if c.Manifest == "" { missing = append(missing, "manifest") }`)
⇒ 讀 config 失敗 → `runDirect` 回 2 → supervisor 無限重拉
⇒ ①「看守中/沒有在跑」一直閃、重開無效(死因沒變)
⇒ ②加資料夾沒反應(collector 從沒跑完一輪)

## 為什麼一路活到封測
**開發機的 config 是舊版留下的、早就有這一欄。**
只有「從零長出 config」的機器才會撞到——也就是**每一台封測者的機器**。
「我這台好好的」正是這個 bug 能活下來的原因。
⇒ 新測試 `TestFreshConfigIsUsableByCollector` **從零建 config**,
   並且不只驗欄位在不在,而是**真的餵給 `collector.LoadDirectConfig`**。
   已反向驗證:把修正拿掉,測試會紅(不是假測試)。

## 順帶修好「為什麼只看得到 exit status 2」
supervisor 在行程結束時用 `cmd.Wait()` 的錯誤當訊息,
**蓋掉**了 stderr 剛寫進 `LastError` 的那句真話
(`collector direct: config 缺必填欄位:manifest`)。
⇒ 改成以 stderr 為主、退出碼放括號補充。
(v0.18.10 已讓死因上畫面+進 app.log,這版讓那句話是**對的**那一句。)

## 三種 exit 2 的實測輸出(重現測試打出來的,非推測)
- config 不是合法 JSON → `config JSON 解析失敗:invalid character …`
- 缺必填欄位 → `config 缺必填欄位:manifest, accounts(或 cypher_url 連線設定)`
- Windows 反斜線路徑 → 正常解析(**排除「反斜線害的」這個猜測**)

## 驗
collector 全測試過、app 測試過、mac+windows 皆編得過;
產物 `Arcrun-win-v0.18.11.exe`(22M)已放 leo 桌面。
⚠️ **尚未真機驗**——要 leo 在 Windows 全新裝一次才算通。
This commit is contained in:
2026-08-06 18:41:31 +08:00
parent 6698f9fbda
commit fcf00bb5df
4 changed files with 83 additions and 1 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
{
"v0.18.9": "8be9260eb542f527",
"v0.18.10": "a440f044a849b7f2"
"v0.18.10": "a440f044a849b7f2",
"v0.18.11": "1538689e181444f6"
}
+10
View File
@@ -153,6 +153,16 @@ func saveCfg(c *directConfig) error {
c.raw["extractor"] = c.Extractor
c.raw["extractor_explicit"] = c.ExtractorExplicit
c.raw["gemini_api_key"] = c.GeminiAPIKey
// 🔴 2026-08-06 leo Windows 封測的真兇:`manifest` 是 collector 的**必填欄位**
// direct.go 的驗證:`if c.Manifest == "" { missing = append(missing, "manifest") }`),
// 而這支從來沒寫過它 ⇒ **全新安裝的機器**(config 從零長出來)永遠缺這一欄
// ⇒ collector 一啟動就 `exit status 2`、supervisor 無限重拉
// ⇒ 畫面在「看守中/沒有在跑」之間閃、加資料夾也沒反應。
// 為什麼開發機沒撞到:leo 的 Mac config 是舊版留下的、早就有這一欄
// ——**「我這台好好的」正是這個 bug 能活到封測的原因**。
if v, ok := c.raw["manifest"].(string); !ok || strings.TrimSpace(v) == "" {
c.raw["manifest"] = filepath.Join(appDir(), "manifest.json")
}
out, err := json.MarshalIndent(c.raw, "", " ")
if err != nil {
+60
View File
@@ -0,0 +1,60 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
collector "arcrun-rag/collector"
)
// TestFreshConfigIsUsableByCollector 釘住 leo 2026-08-06 Windows 封測的真兇:
//
// App 存出來的**全新** config 少了 `manifest` 這個必填欄位
// ⇒ collector 一啟動就 exit status 2 ⇒ supervisor 無限重拉
// ⇒ 畫面在「看守中/沒有在跑」之間閃、加資料夾也沒反應。
//
// 為什麼一路活到封測:開發機的 config 是舊版留下的、早就有這一欄
// ——「我這台好好的」正是它能活下來的原因。所以這支測試**從零建 config**。
func TestFreshConfigIsUsableByCollector(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home) // Windows 的家目錄變數
// 模擬全新安裝:完全沒有 config,使用者連上一個知識庫後存檔
cfg := &directConfig{
Accounts: []accountCfg{{
CypherURL: "https://example.workers.dev",
Namespace: "abc123",
APIKey: "abc123",
}},
Extractor: "workers-ai",
}
if err := saveCfg(cfg); err != nil {
t.Fatalf("存檔失敗:%v", err)
}
raw, err := os.ReadFile(configPath())
if err != nil {
t.Fatalf("讀不回剛存的 config%v", err)
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("存出來的不是合法 JSON%v", err)
}
if s, _ := m["manifest"].(string); strings.TrimSpace(s) == "" {
t.Fatal("全新 config 沒有 manifest ⇒ collector 會 exit 2 無限重試(就是 leo 撞到的那個)")
}
// 真正的驗收不是「欄位在不在」,是**collector 吃不吃得下去**。
if _, err := collector.LoadDirectConfig(configPath()); err != nil {
t.Fatalf("collector 讀不了 App 存的 config%v", err)
}
// manifest 要落在 app 目錄下,不能指到別人家
if got, _ := m["manifest"].(string); filepath.Dir(got) != appDir() {
t.Errorf("manifest 應該放在 %s,實得 %s", appDir(), got)
}
}
+11
View File
@@ -242,10 +242,21 @@ func (s *Supervisor) loop(ctx context.Context) {
return
}
// 非預期退出:記錯、重起計數、退避後再拉
// 🔴 2026-08-06leo Windows 實測):這裡以前直接用 cmd.Wait() 的錯誤當訊息,
// 於是「exit status 2」**蓋掉**了 stderr 剛剛寫進 LastError 的那句真話
// (例如「collector direct: config JSON 解析失敗:…」)。
// 使用者與遠端的我拿到的都是最沒用的那一句——退化成猜謎。
// ⇒ 有 stderr 就以 stderr 為主、退出碼放括號裡當補充。
msg := "行程結束"
if err != nil {
msg = err.Error()
}
s.mu.Lock()
stderrLine := s.status.LastError
s.mu.Unlock()
if stderrLine != "" && stderrLine != msg {
msg = stderrLine + "" + msg + ""
}
s.setState(func(st *Status) {
st.State = StateError
st.LastError = msg