f903a3f53f
leo 回報 Windows daemon 三症狀,查完 ①③ 同根,根在「雲端控制地端」這個設計。 【①③ 真兇】雲端 extractor_config 是**全租戶共用一把 KV** (arcrun:portal.ts:43 portalTenant = worker 層級變數,不分用戶), 任一處設了 claude → 所有人的 daemon 都收到 claude。沒裝 Claude Code 的機器 FindClaudeBin 失敗 → 每檔萃取 failed、一張卡都沒建;想去 portal 改回 gemini, checkbox 卻恆 disabled(claude_available 恆 false,因 daemon 從未實作 report-capabilities 回報 → daemon_caps KV 永遠空)⇒ 用戶自己解不開。 awindhon 實證:雲端同步成功、Gemini key 有效、零張卡,config.json extractor="claude"。 【leo 裁示】「地端要用什麼模型就在 daemon 上輸入 API Key 設置,而不是雲端設置後 控制地端」「地端先限制 Gemini API Key 配合客戶要求」「雲端就是 Workers AI」。 本次(daemon 端): - addOrUpdateAccount 不再接受雲端下發的 extractor/gemini_api_key/llm_model, 只收連線欄位。t126「每帳號一份萃取設定」照舊保留——t126 修的是「存在哪一層」, 本次改的是「值從哪來」,兩者正交。 - 托盤新增「AI 設定…」:使用者自己填 Gemini API Key,寫本地 config 後立即生效。 - 萃取一律走 gemini:殘留的 extractor:"claude" 正規化為 gemma;claude 路退役。 ⚠️ 這不是「自動偵測有無 claude」(leo 07-27 已否決的 B 案),是整條路先不支援。 - 清掉隨之死亡的 claude_bin 回寫(死代碼=錯誤的環境信號)。 - 托盤單一實例(症狀②):pidfile + 跨平台 processAlive。 mac 之前不多開是借 macOS Launch Services 的巧合,Windows 沒有該層 ⇒ 每點一次多一個 icon。 Unix 用 signal 0(EPERM 也算活著,測試抓到的實際 bug)/Windows 用 OpenProcess+ExitCode。 測試:collector 全綠、tray 全綠。5 個原本用 claude stub 的測試改走**真實 gemma 路** (httptest 替身注入 gemmaBaseURL),不是改斷言充綠;t126②③ 兩案翻轉成 「雲端下發一律被忽略」的回歸守衛;新增 4 案 single-instance。 未送達:本 commit 只到 code,尚未打包出貨;雲端側(刪 portal AI 設定區塊、 extractor 下發、admin/extractor)未動,待部署授權。CP rag-beta 步驟仍為 ◐。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
86 lines
2.8 KiB
Go
86 lines
2.8 KiB
Go
// singleinstance_test.go — t176:一台機器只跑一個托盤(leo 08-03「點多次產生多個 icon」)。
|
||
package main
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"testing"
|
||
)
|
||
|
||
// withTempLock 把鎖檔導到暫存目錄,避免測試碰到使用者真正的 ~/.arcrun-rag。
|
||
func withTempLock(t *testing.T) string {
|
||
t.Helper()
|
||
path := filepath.Join(t.TempDir(), "tray.lock")
|
||
orig := trayLockPath
|
||
trayLockPath = func() string { return path }
|
||
t.Cleanup(func() { trayLockPath = orig })
|
||
return path
|
||
}
|
||
|
||
// 第一個實例拿得到鎖;release 後鎖檔消失。
|
||
func TestSingleInstanceFirstAcquires(t *testing.T) {
|
||
path := withTempLock(t)
|
||
|
||
ok, release := acquireSingleInstance()
|
||
if !ok {
|
||
t.Fatal("第一個實例應該拿得到鎖")
|
||
}
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
t.Fatalf("鎖檔應被建立:%v", err)
|
||
}
|
||
if got, _ := strconv.Atoi(string(data)); got != os.Getpid() {
|
||
t.Errorf("鎖檔應寫入自己的 pid,got %q want %d", data, os.Getpid())
|
||
}
|
||
|
||
release()
|
||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||
t.Error("release 後鎖檔應被刪除(否則下次啟動要靠 pid 檢查兜底)")
|
||
}
|
||
}
|
||
|
||
// 鎖檔記著一個「還活著」的 pid(就用自己的父流程:本測試行程自己)→ 第二個實例被擋。
|
||
// 這正是 leo 撞到的情境:點第二次不該再開一個托盤。
|
||
func TestSingleInstanceSecondBlocked(t *testing.T) {
|
||
path := withTempLock(t)
|
||
// 寫入一個確定活著、且不等於自己的 pid:用 pid 1(init/launchd,任何系統上都在跑)。
|
||
if err := os.WriteFile(path, []byte("1"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if ok, _ := acquireSingleInstance(); ok {
|
||
t.Error("已有活著的實例時,第二個應該被擋下(不然就會多一個托盤 icon)")
|
||
}
|
||
}
|
||
|
||
// 殘留鎖檔(上次當機留下、pid 已死)不應永久卡死啟動——要能接管。
|
||
func TestSingleInstanceStaleLockTakenOver(t *testing.T) {
|
||
path := withTempLock(t)
|
||
// 挑一個幾乎不可能存在的 pid:先開一個行程再等它結束太慢,直接用超大值。
|
||
if err := os.WriteFile(path, []byte("4194303"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
ok, release := acquireSingleInstance()
|
||
if !ok {
|
||
t.Fatal("殘留鎖檔(pid 已死)應可接管,否則使用者永遠打不開")
|
||
}
|
||
defer release()
|
||
data, _ := os.ReadFile(path)
|
||
if got, _ := strconv.Atoi(string(data)); got != os.Getpid() {
|
||
t.Errorf("接管後鎖檔應改寫成自己的 pid,got %q", data)
|
||
}
|
||
}
|
||
|
||
// 壞掉的鎖檔內容(非數字)不應擋住啟動。
|
||
func TestSingleInstanceGarbageLock(t *testing.T) {
|
||
path := withTempLock(t)
|
||
if err := os.WriteFile(path, []byte("not-a-pid"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
ok, release := acquireSingleInstance()
|
||
if !ok {
|
||
t.Fatal("鎖檔內容壞掉時應放行(寧可容忍重複開,也不要讓人完全打不開)")
|
||
}
|
||
release()
|
||
}
|