From 4cbc7ebe1128ea6e35c76bdddde3e27f3edb6038 Mon Sep 17 00:00:00 2001 From: richblack Date: Tue, 11 Aug 2026 12:28:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(arcrun-rag#59):=20=E9=A1=8D=E5=BA=A6?= =?UTF-8?q?=E5=8D=A1=E8=88=87=E9=9B=B2=E7=AB=AF=E9=80=A3=E7=B7=9A=E5=85=A9?= =?UTF-8?q?=E5=89=87=E5=81=87=E8=A8=8A=E6=81=AF=E6=94=B9=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo21c 畫面同時說「今天已經幫你整理了 0 份」與「額度用完,可以換一個模型, 或升級 Cloudflare」——一份都沒成功,額度其實是被同帳號正在做的向量化 (嵌入固定走 Workers AI,與萃取共用同一份每日免費額度)吃光的,換萃取模型 救不了。改法:buildQuotaNotice 在 dailyCount==0(本輪一份都沒吃到額度就先 撞牆)時換一套出口/保證句——不再建議換模型、不再無條件承諾「明天會自動 接著跑」,只留「升級 Cloudflare」這個結構上真的有效的出口;dailyCount>0 (單純量大用完)維持原三句話不動。 同批修另一則真因已被頂層 wiki 鎖定的假訊息:leo 三個帳號 /health 全部 200, 畫面卻顯示「沒連上雲端」——direct.go 寫 status.json 頂層 cloud_check_ok 時 以前只有剛好一個帳號才會填,2+ 帳號(leo 的常態)時恆為 Go 零值 false。 改成不論帳號數,任一帳號連得上就標頂層為 true。 兩者都有回歸測試:對修改前的程式碼跑會 FAIL(多帳號 CloudCheckOK 案例已 用 git stash 實測驗證),修完後 PASS。額度卡文案另外用真正的 frontend/dist + 假 window.go 灌 leo21c 現場資料,Claude Browser 實際開起來看過,確認新 文案正確渲染、不再出現「可以換一個模型」、console 無紅字。 未做:兩者都只在原始碼層修好,尚未重打 daemon bundle 出貨(0.18.25 用戶 手上還看不到);頂層 cloud_check_ok 的實際消費端(除已知的舊版 tray)未 完全追出,留給下一輪核實。詳見 system-dev/docs/3-specs/daemon-beta/tasks.md t216/t217。 Co-Authored-By: Claude Opus 5 --- direct.go | 31 ++++++++++++++---- direct_multi_test.go | 77 ++++++++++++++++++++++++++++++++++++++++++++ direct_quota_test.go | 16 +++++++-- quota.go | 44 +++++++++++++++++++++---- quota_test.go | 49 ++++++++++++++++++++++++++++ sync_status.go | 10 ++++-- 6 files changed, 209 insertions(+), 18 deletions(-) diff --git a/direct.go b/direct.go index 1b9ca7d..42f3d85 100644 --- a/direct.go +++ b/direct.go @@ -792,12 +792,31 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge if len(st.Failures) > MaxSkippedListed { st.Failures = st.Failures[:MaxSkippedListed] } - // 單帳號時把 cloud version 也填頂層(向後相容) - if len(accountDetails) == 1 { - for _, v := range accountDetails { - st.CloudVersion = v.CloudVersion - st.CloudCheckOK = v.CloudCheckOK - break + // 頂層 cloud version/cloud_check_ok 給「只認得到一份版本號」的舊消費端 + // (向後相容;t103 時代單帳號才有這兩個頂層欄位)。 + // + // 🔴 arcrun-rag#59 相關實查(2026-08-10,總管/leo 對照 curl,三台 /health 全部 200; + // 詳見頂層 wiki `system-dev/wiki/status.md`「畫面在說謊」段):以前這裡**只有剛好 + // 一個帳號**才填,2+ 帳號(leo 自己的常態)時 `st.CloudCheckOK` 停在 Go 零值 + // `false` 沒人填過 ⇒ 讀這個頂層欄位的地方會看到「沒連上雲端」,**即使每一個 + // 帳號都連得上**。改成:不論帳號數,只要**任一**帳號連得上就算連得上; + // `CloudVersion` 取第一個非空版本代表(按 key 排序,同一份輸入永遠同一個輸出), + // 不是宣稱所有帳號版本一致——**多帳號時真正該看的是各帳號自己的 + // AccountDetails,這裡只是不讓頂層欄位繼續說反話**。 + if len(accountDetails) > 0 { + hosts := make([]string, 0, len(accountDetails)) + for h := range accountDetails { + hosts = append(hosts, h) + } + sort.Strings(hosts) + for _, h := range hosts { + v := accountDetails[h] + if v.CloudCheckOK { + st.CloudCheckOK = true + } + if st.CloudVersion == "" && v.CloudVersion != "" { + st.CloudVersion = v.CloudVersion + } } } // 🔴 2026-08-05(leo:「自始至終都顯示『等待中』…實際上已經做完了,這個 status 是壞的」): diff --git a/direct_multi_test.go b/direct_multi_test.go index 7d13d79..dadc7e7 100644 --- a/direct_multi_test.go +++ b/direct_multi_test.go @@ -766,6 +766,83 @@ func TestLoadDirectConfigMigrationIdempotent(t *testing.T) { } } +// TestRunDirectOnceMultiAccount_TopLevelCloudCheckOK 釘住 arcrun-rag#59 相關實查 +// (2026-08-10,見頂層 wiki `system-dev/wiki/status.md`「畫面在說謊」段): +// +// leo 三個帳號 `/health` 全部 200,畫面卻顯示「沒連上雲端」。 +// 真兇= direct.go 寫 status.json 頂層 cloud_check_ok 時, +// 以前只有 `len(accountDetails) == 1` 才填,2+ 帳號時停在 Go 零值 false, +// 即使每個帳號自己的 AccountDetails.CloudCheckOK 都是 true。 +// +// 這支測試刻意用**兩個帳號、兩台 fake /health 都成功**的設定—— +// 釘住「多帳號時頂層 cloud_check_ok 也要照實填 true」,不是恆為 false。 +func TestRunDirectOnceMultiAccount_TopLevelCloudCheckOK(t *testing.T) { + serverA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"bundle_version":"1.4.30"}`)) + })) + defer serverA.Close() + serverB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"bundle_version":"1.4.30"}`)) + })) + defer serverB.Close() + + base := t.TempDir() + rootA := filepath.Join(base, "rootA") + rootB := filepath.Join(base, "rootB") + for _, d := range []string{rootA, rootB} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + + // fetchCloudVersion 真的探每台 /health(模擬「三台全部 200」的現場, + // 不用固定 stub——要驗的正是「per-account CloudCheckOK=true 時, + // 頂層也要跟著 true」,不是探測機制本身)。 + origFCV := fetchCloudVersion + fetchCloudVersion = fetchBundleVersion + defer func() { fetchCloudVersion = origFCV }() + + manifestPath := filepath.Join(base, "manifest.json") + statusPath := filepath.Join(filepath.Dir(manifestPath), "status.json") + cfg := &DirectConfig{ + Manifest: manifestPath, + Accounts: []AccountConfig{ + {CypherURL: serverA.URL, Namespace: "nsA", WatchFolders: []string{rootA}}, + {CypherURL: serverB.URL, Namespace: "nsB", WatchFolders: []string{rootB}}, + }, + MaxRemoved: DefaultMaxRemovedRatio, + ExtractorExplicit: true, // 隔離變因:不要走 workers-ai 萃取路,只驗 cloud check + } + if _, exit, _ := RunDirectOnce(cfg, false); exit != 0 { + t.Fatalf("雙帳號同步應成功,exit=%d", exit) + } + + raw, err := os.ReadFile(statusPath) + if err != nil { + t.Fatalf("讀不到 status.json:%v(manifest=%s)", err, manifestPath) + } + var st SyncStatus + if err := json.Unmarshal(raw, &st); err != nil { + t.Fatalf("status.json 格式錯:%v", err) + } + + hostA := instanceHostOf(serverA.URL) + hostB := instanceHostOf(serverB.URL) + if !st.AccountDetails[hostA].CloudCheckOK || !st.AccountDetails[hostB].CloudCheckOK { + t.Fatalf("兩個帳號各自的 CloudCheckOK 應該都是 true(測試前提:兩台 fake /health 都回 200):%+v", + st.AccountDetails) + } + if !st.CloudCheckOK { + t.Fatal("兩個帳號都連得上,頂層 cloud_check_ok 卻是 false——" + + "這正是 arcrun-rag#59「明明連上卻說沒連上」的那個病(多帳號時頂層被鎖死在零值)") + } + if st.CloudVersion == "" { + t.Error("頂層 cloud_version 應該取到至少一個帳號的版本代表,不該是空字串") + } +} + func TestExpandHomeEdgeCases(t *testing.T) { home, err := os.UserHomeDir() if err != nil || home == "" { diff --git a/direct_quota_test.go b/direct_quota_test.go index 54c50f1..c2d50b6 100644 --- a/direct_quota_test.go +++ b/direct_quota_test.go @@ -69,11 +69,18 @@ func TestDirect_QuotaExhausted_ShowsHumanMessageNoRawCode(t *testing.T) { t.Errorf("結果不該出現裸露的錯誤碼 %q:%q", b, r.Error) } } - for _, want := range []string{"今天已經幫你整理了", "升級 Cloudflare", "自動恢復"} { + // arcrun-rag#59:這支測試裡兩份檔案全部失敗、dailyCount 全程是 0—— + // 正是 leo 實查那個「一份都沒成功、額度卻用完」的情境,buildQuotaNotice + // 改口不再承諾「自動恢復」(那是做不到的承諾)、也不再建議換模型 + // (結構上做不到:嵌入不管選哪個萃取模型都走 Workers AI),只留「升級」這個真出口。 + for _, want := range []string{"今天已經幫你整理了", "升級 Cloudflare"} { if !strings.Contains(r.Error, want) { t.Errorf("缺三句話之一 %q:%q", want, r.Error) } } + if strings.Contains(r.Error, "可以換一個模型") { + t.Errorf("dailyCount==0(這輪一份都沒成功)不該再建議換模型:%q", r.Error) + } } // status.json 也要是人話,同樣不准有裸碼 @@ -148,7 +155,10 @@ func TestDirect_QuotaExhausted_NextRunSkipsWithoutNetworkCall(t *testing.T) { if len(results2) != 1 || results2[0].Status != "skipped" { t.Fatalf("results2=%+v", results2) } - if !strings.Contains(results2[0].Error, "自動恢復") { - t.Errorf("跳過訊息也該是三句話:%q", results2[0].Error) + // arcrun-rag#59:這支測試同樣是 dailyCount==0(單一檔案、全程沒有成功過), + // 跳過訊息改口成「升級 Cloudflare」這個真出口,不再是「自動恢復」的舊三句話。 + if !strings.Contains(results2[0].Error, "今天已經幫你整理了") || + !strings.Contains(results2[0].Error, "升級 Cloudflare") { + t.Errorf("跳過訊息也該是三句話(新版):%q", results2[0].Error) } } diff --git a/quota.go b/quota.go index ffe3b9a..49a98e0 100644 --- a/quota.go +++ b/quota.go @@ -61,25 +61,57 @@ func (n QuotaNotice) Combined() string { // buildQuotaNotice 組出三句話。dailyCount=今天(UTC 日界,與額度重置同一條線)已成功 // 萃取的份數;resetAt=nextQuotaResetTaiwan 算出的下一次重置時間。 +// +// 🔴 arcrun-rag#59(2026-08-10 leo 實查):`dailyCount==0` 時原本這三句話會自相矛盾—— +// 「今天已經幫你整理了 0 份」搭「可以換一個模型」,一份都沒成功,額度是誰用掉的? +// leo 查出真兇:**嵌入(向量化)與萃取共用同一份 Workers AI 每日免費額度** +// (`matrix/arcrun/kbdb/src/embed.ts` 的 `DEFAULT_EMBED_MODEL` 固定走 Workers AI, +// 不受萃取模型選擇影響)。dailyCount==0 且已進冷卻,代表這一輪萃取連一份都沒吃到 +// 額度就先被別的事(最典型是帳號上同時在做的大量重新嵌入)用光了—— +// 這個情況下「換一個模型」是結構上做不到的假出口(換的是萃取模型,嵌入不會跟著換), +// 「明天會自動接著跑」也是做不到的承諾(只要那件事還在燒同一份額度,明天還是會撞同一面牆)。 +// dailyCount>0(今天多少有做出東西,只是單純量大用完)維持原本三句話不動—— +// 那種情境三句話依然成立,換模型也確實能減少萃取那一半吃掉的份額。 +// +// 🔴 Achievement 句的格式刻意不因分支而變:`ClassifyFailure`(progress.go)靠 +// 「幫你整理了」這個子字串把這段三句話歸進 FailQuotaExhausted 分類,兩個分支都要留著。 func buildQuotaNotice(now time.Time, dailyCount int, resetAt time.Time) QuotaNotice { + achievement := fmt.Sprintf("今天已經幫你整理了 %d 份", dailyCount) + if dailyCount == 0 { + return QuotaNotice{ + Achievement: achievement, + ExitOptions: "換一個模型救不了這個:你的雲端知識庫本身也在用同一份免費額度做別的事" + + "(例如重新整理索引),額度是在那邊被用光的,不是被這次的整理用掉的。" + + "真正能解除限制的只有升級 Cloudflare(每月 5 美元,移除每日免費上限)", + Guarantee: fmt.Sprintf( + "額度會在%s早上 8:00 重置,但如果同一件事還在佔用額度,你可能會再撞到同一面牆——"+ + "不是保證接下來就會一路處理完", + quotaResetDayWord(now, resetAt)), + ResumeAt: resetAt.Format(time.RFC3339), + } + } return QuotaNotice{ - Achievement: fmt.Sprintf("今天已經幫你整理了 %d 份", dailyCount), + Achievement: achievement, ExitOptions: "可以換一個模型,或升級 Cloudflare(每月 5 美元)", Guarantee: quotaGuaranteeText(now, resetAt), ResumeAt: resetAt.Format(time.RFC3339), } } -// quotaGuaranteeText 把重置時間換成人話:「今天」或「明天」早上 8:00 +// quotaResetDayWord 把重置時間換成「今天」或「明天」 // (不能寫死「明天」——若這一刻台灣時間已經過了午夜、還沒到 8 點,重置其實是「今天」)。 -func quotaGuaranteeText(now, resetAt time.Time) string { +func quotaResetDayWord(now, resetAt time.Time) string { nowTW := now.In(taiwanTZ) resetTW := resetAt.In(taiwanTZ) - dayWord := "明天" if nowTW.Year() == resetTW.Year() && nowTW.YearDay() == resetTW.YearDay() { - dayWord = "今天" + return "今天" } - return fmt.Sprintf("不花錢也沒關係,%s早上 8:00 會自動恢復、會接著跑", dayWord) + return "明天" +} + +// quotaGuaranteeText 把重置時間換成人話:「今天」或「明天」早上 8:00。 +func quotaGuaranteeText(now, resetAt time.Time) string { + return fmt.Sprintf("不花錢也沒關係,%s早上 8:00 會自動恢復、會接著跑", quotaResetDayWord(now, resetAt)) } // quotaState 是「這個帳號本輪的額度冷卻」共享狀態,跨同一帳號的多個監看根 diff --git a/quota_test.go b/quota_test.go index 8a27f9d..4c7689f 100644 --- a/quota_test.go +++ b/quota_test.go @@ -78,6 +78,55 @@ func TestQuotaGuaranteeText_TodayVsTomorrow(t *testing.T) { } } +// arcrun-rag#59(2026-08-10 leo 實查):dailyCount==0 時三句話原本會自相矛盾—— +// 「今天已經幫你整理了 0 份」搭「可以換一個模型」,一份都沒成功那額度是誰用掉的? +// 真兇是嵌入(向量化)與萃取共用同一份 Workers AI 額度(不受萃取模型選擇影響)—— +// 這支測試釘住:dailyCount==0 時不准再建議換模型,且仍照實講「整理了 0 份」(不假裝有成果)。 +func TestBuildQuotaNotice_ZeroDailyCount_NoModelSwitchSuggestion(t *testing.T) { + now := time.Date(2026, 8, 10, 10, 0, 0, 0, time.UTC) + resetAt := nextQuotaResetTaiwan(now) + notice := buildQuotaNotice(now, 0, resetAt) + + if !strings.Contains(notice.Achievement, "0") { + t.Errorf("成就句仍要照實講 0 份:%q", notice.Achievement) + } + // 注意:文案裡允許出現「換」這個字(要誠實講「換模型救不了」), + // 禁的是舊版那句**推薦**換模型的措辭「可以換一個模型」。 + if strings.Contains(notice.ExitOptions, "可以換一個模型") { + t.Errorf("dailyCount==0 時不該再建議換模型(結構上做不到:嵌入不管選哪個萃取模型都走 "+ + "Workers AI):%q", notice.ExitOptions) + } + if !strings.Contains(notice.ExitOptions, "升級") { + t.Errorf("升級 Cloudflare 是這個情境下唯一真的有效的出口,不該被拿掉:%q", notice.ExitOptions) + } + // 三句話合起來仍要能被 ClassifyFailure 歸進額度分類(靠 Achievement 句的 + // 「幫你整理了」字樣,不靠 Guarantee 句的「會自動恢復」——這個分支刻意不承諾自動恢復)。 + if got := ClassifyFailure(notice.Combined()); got != FailQuotaExhausted { + t.Fatalf("dailyCount==0 的三句話也該歸進額度分類,got %q(訊息:%s)", got, notice.Combined()) + } + // 絕不含裸露錯誤碼(沿用既有骨架的紅線)。 + for _, banned := range []string{"4006", "502", "HTTP", "neurons"} { + if strings.Contains(notice.Combined(), banned) { + t.Errorf("不該出現裸露的錯誤碼 %q:%q", banned, notice.Combined()) + } + } +} + +// dailyCount>0(單純量大用完,非「額度被別的事吃光」那種矛盾)維持原三句話—— +// 這種情境換模型確實能讓萃取那一半少吃一點、多處理幾份,不該被拿掉。 +func TestBuildQuotaNotice_PositiveDailyCount_KeepsModelSwitchSuggestion(t *testing.T) { + now := time.Date(2026, 8, 10, 10, 0, 0, 0, time.UTC) + resetAt := nextQuotaResetTaiwan(now) + notice := buildQuotaNotice(now, 105, resetAt) + + if !strings.Contains(notice.ExitOptions, "換") || !strings.Contains(notice.ExitOptions, "升級") { + t.Errorf("dailyCount>0(單純量大)應維持原本的三句話,含換模型與升級兩個選項:%q", notice.ExitOptions) + } + if !strings.Contains(notice.Guarantee, "自動恢復") { + t.Errorf("dailyCount>0 時應維持原本「會自動恢復」的保證句:%q", notice.Guarantee) + } +} + func TestQuotaState_MarkHitOnlyFirstTimeSetsCooldown(t *testing.T) { qs := "aState{} now := time.Date(2026, 8, 7, 10, 0, 0, 0, time.UTC) diff --git a/sync_status.go b/sync_status.go index 22b92ff..c7acf9e 100644 --- a/sync_status.go +++ b/sync_status.go @@ -57,9 +57,13 @@ type SyncStatus struct { LastActivityAt string `json:"last_activity_at,omitempty"` // RFC3339,上次有產出那輪的完成時間 LastActivityOK int `json:"last_activity_ok"` // 那一輪成功幾份 LastActivityFailed int `json:"last_activity_failed"` // 那一輪失敗幾份 - // 頂層 cloud 欄位保留向後相容(單帳號時同時填頂層+AccountDetails) - CloudVersion string `json:"cloud_version,omitempty"` // bundle_version(單帳號時填) - CloudCheckOK bool `json:"cloud_check_ok"` // /health 可達才為 true + // 頂層 cloud 欄位保留向後相容(同時填頂層+AccountDetails;不論帳號數—— + // arcrun-rag#59 相關實查修過,見 direct.go 寫入處註解:以前只有剛好一個帳號 + // 才填,2+ 帳號時這裡恆為零值 false,讀這個頂層欄位的地方會看到「沒連上雲端」 + // 即使每個帳號都連得上)。CloudCheckOK=任一帳號連得上;CloudVersion=第一個 + // 非空版本代表,不代表所有帳號版本一致。 + CloudVersion string `json:"cloud_version,omitempty"` // bundle_version(有連得上的帳號時填) + CloudCheckOK bool `json:"cloud_check_ok"` // 任一帳號 /health 可達即為 true // t104:per-account 狀態(key = instanceHostOf(cypher_url)) AccountDetails map[string]AccountSyncStatus `json:"account_details,omitempty"`