fix(arcrun-rag#59): 額度卡與雲端連線兩則假訊息改口
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 是壞的」):
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
+13
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 是「這個帳號本輪的額度冷卻」共享狀態,跨同一帳號的多個監看根
|
||||
|
||||
@@ -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)
|
||||
|
||||
+7
-3
@@ -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"`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user