From bce422f6fe4f4e44d8a84b83cf28bd5d3aa8f0cc Mon Sep 17 00:00:00 2001 From: richblack Date: Sun, 13 Sep 2026 22:47:16 +0800 Subject: [PATCH] =?UTF-8?q?sync:=20collector/=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E8=87=AA=20inkstone/arcrun-rag@80b3e5c=EF=BC=88=E6=A1=8C?= =?UTF-8?q?=E9=9D=A2=E5=B0=8F=E5=B9=AB=E6=89=8B=200.18.52=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 19 ++ cloud_version.go | 10 +- cloudcheck.go | 90 ++++++ cloudcheck_test.go | 107 +++++++ cloudquota.go | 164 ++++++++++ cloudquota_test.go | 188 +++++++++++ cmd/arcrun-app/.version-source-files.json | 35 ++- cmd/arcrun-app/.version-source.json | 5 +- cmd/arcrun-app/frontend/src/main.js | 12 + direct.go | 126 +++++++- extract_workersai.go | 17 +- folderindex.go | 18 +- ingestplan.go | 53 ++++ inventory.go | 6 +- logseq_bak_residue_test.go | 281 +++++++++++++++++ probe_workersai.go | 40 ++- quota.go | 5 + route_backoff_measure_test.go | 364 ++++++++++++++++++++++ routebackoff.go | 186 +++++++++++ scan.go | 52 +++- sourcerepair.go | 10 +- 21 files changed, 1747 insertions(+), 41 deletions(-) create mode 100644 cloudcheck.go create mode 100644 cloudcheck_test.go create mode 100644 cloudquota.go create mode 100644 cloudquota_test.go create mode 100644 logseq_bak_residue_test.go create mode 100644 route_backoff_measure_test.go create mode 100644 routebackoff.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 46e17a8..c5c6a38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,25 @@ --- +## 0.18.52(2026-09-13) + +- 🔴 **雲端資料庫的免費額度用完時,小幫手會直接告訴你**:以前 Cloudflare 免費帳號當天的資料庫讀取(或寫入)額度用完,小幫手只會顯示看不懂的錯誤(例如「缺少 credential」)或「連續失敗、已暫停自動重試」,看起來像東西壞了。現在首頁會出現一張卡,寫明是哪一種額度用完、免費方案的上限是多少(讀取每天 500 萬列、寫入每天 10 萬列)、台北時間幾點恢復(早上 8:00),以及恢復後會自動接著傳、你不用做任何事。 +- **額度用完期間不再重打雲端**:在額度恢復之前,小幫手不會再送任何東西上雲端(連「立刻同步」也不會白打),不會把情況弄得更糟;恢復後下一次檢查就自動接上。 +- 急著用的話,卡片上也會告訴你:升級 Cloudflare Workers 付費方案(每月 5 美元起)就沒有每日上限。 + +## 0.18.51(2026-09-13) + +- 🔴 **雲端出錯時,小幫手不會再一直重打把你的額度燒光**:以前只要你的知識庫某個功能壞掉(例如收筆記或 AI 整理文件那一步一直回錯誤),小幫手每幾秒就把同一件事重打一次,每一次都在消耗雲端的資料庫讀取或 AI 額度,免費帳號一小時就可能被用光。現在同一件事連續失敗幾次,小幫手會先停下來,隔 1 分鐘、2 分鐘、5 分鐘……最長 30 分鐘再試一次;雲端一修好就自動接上,不用重開。 +- **停下來的時候會告訴你原因**:面板上會寫「雲端某個功能連續失敗,先停幾分鐘再試」,不會看起來像當掉。 +- **送不上去的文件不會先白白整理一次**:雲端那一步壞著的時候,小幫手不再先花 AI 額度整理文件、再撞同一面牆。 +- 按「立刻同步」一樣會馬上再試一次。 + +## 0.18.50(2026-09-07) + +- 🔴 **Logseq 的備份資料夾不再被當成新知識**:以前 Logseq 每次自動存檔留在 `logseq/bak/` 裡的舊版本,小幫手全都當成一份新文件收進知識庫——一頁筆記改十次就多十份殘影,問 AI 會回一堆一模一樣的舊東西。現在 `logseq/` 這整個資料夾(設定、備份、版本檔)一律不收,你的筆記本體在 pages 與 journals 裡照收不誤;面板上會寫明「這是 Logseq 自己的設定與備份資料夾」。 +- **已經收進去的殘影會自動從知識庫撤掉**:更新後第一次同步,小幫手會把先前收過、現在依規則不再收的檔一批一批下架(每輪幾十份,慢慢清,不會把雲端打爆),並在面板上告訴你「有幾份因為什麼理由下架」。這種「我們自己決定不收」的情況不會再被「檔案大量消失」的保護機制擋住——那道保護只管真的不見的檔。 +- **消失的資料夾,雲端那張資料夾索引卡也會一起撤**:以前只清本機的記帳,雲端會永遠留著一張指向不存在資料夾的卡。 + ## 0.18.49(2026-08-29) - **雲端的知識庫清單看得出每一個資料夾是從哪一台電腦同步上去的**:以前小幫手回報資料夾結構時沒有附上自己是誰,所以你在雲端看到的每一個資料夾都掛在「未知來源」底下——幾年下來累積的資料夾全擠在同一個位置,看起來就像別人的東西。現在每一次回報都帶著這台電腦的名字(你在設定裡取過名就用你取的)。 diff --git a/cloud_version.go b/cloud_version.go index 7e86cff..87d0704 100644 --- a/cloud_version.go +++ b/cloud_version.go @@ -77,12 +77,20 @@ func fetchBundleVersion(cypherURL string) (version string, ok bool) { return "", false } defer resp.Body.Close() + // 16KB:#197 起還要讀 data_layer 區塊(youlin 1.4.63 實測整份約 1.3KB,留足餘裕)。 + raw, err := io.ReadAll(io.LimitReader(resp.Body, 16<<10)) + if err != nil { + return "", false + } var payload struct { BundleVersion string `json:"bundle_version"` } - if err := json.NewDecoder(io.LimitReader(resp.Body, 4096)).Decode(&payload); err != nil { + if err := json.Unmarshal(raw, &payload); err != nil { return "", false } + // arcrun-rag#197:同一份回應順便看「雲端資料庫額度是不是用完了」(見 cloudquota.go)。 + // 只有拿到看得懂的回應才更新——連不上時保留上一次親眼看到的狀態,重置時間一到自然失效。 + noteD1Quota(cypherURL, d1QuotaFromHealth(raw), directNow()) return payload.BundleVersion, true } diff --git a/cloudcheck.go b/cloudcheck.go new file mode 100644 index 0000000..940e32b --- /dev/null +++ b/cloudcheck.go @@ -0,0 +1,90 @@ +// cloudcheck.go — 每輪固定要問雲端的兩件事(版本、雲端 AI 通了沒),限一分鐘問一次 +// (`inkstone/arcrun-rag#121`,2026-09-13 真機量測抓到的副作用)。 +// +// 病:這兩發原本「每輪各打一次」。以前一輪要跑十幾秒到幾分鐘(逐檔去撞壞掉的雲端), +// 所以每小時大約幾百發。路由退避生效之後,壞掉的那條路不再被打,一輪變成五、六秒就跑完 +// ⇒ **這兩發變成每 5 秒一次**。實測 geek6688 真雲端 3 分鐘: +// 退避版 80 發(其中 /health 36、空探測 36),修前只有 34 發—— +// 壞掉那條路的請求變少了,雲端收到的總請求卻變多。 +// +// 解:兩件事都是「狀態」,不是「工作」——一分鐘內再問一次得到的答案幾乎一定一樣。 +// ⇒ 同一台知識庫一分鐘內只真的問一次,其餘沿用上一次的答案。 +// +// 使用者按「立刻同步」的那一輪照問(他要的就是最新狀況)。 +package collector + +import ( + "strings" + "sync" + "time" +) + +// cloudCheckInterval=同一台知識庫多久才真的重問一次版本/雲端 AI 狀態。 +var cloudCheckInterval = 60 * time.Second + +type cachedVersion struct { + at time.Time + ver string + ok bool +} + +type cachedAIState struct { + at time.Time + state CloudAIState +} + +var ( + cloudCheckMu sync.Mutex + cloudVersionSeen = map[string]cachedVersion{} + cloudAISeen = map[string]cachedAIState{} +) + +// resetCloudChecks 清空快取(測試用)。 +func resetCloudChecks() { + cloudCheckMu.Lock() + defer cloudCheckMu.Unlock() + cloudVersionSeen = map[string]cachedVersion{} + cloudAISeen = map[string]cachedAIState{} +} + +func cloudCheckKey(cypherURL string) string { + return strings.TrimSuffix(strings.TrimSpace(cypherURL), "/") +} + +// cloudVersionThrottled=fetchCloudVersion,但同一台一分鐘內只真的問一次。force=立刻同步。 +func cloudVersionThrottled(cypherURL string, force bool) (string, bool) { + key := cloudCheckKey(cypherURL) + now := directNow() + cloudCheckMu.Lock() + c, hit := cloudVersionSeen[key] + cloudCheckMu.Unlock() + if hit && !force && now.Sub(c.at) < cloudCheckInterval && !now.Before(c.at) { + return c.ver, c.ok + } + ver, ok := fetchCloudVersion(cypherURL) + cloudCheckMu.Lock() + cloudVersionSeen[key] = cachedVersion{at: now, ver: ver, ok: ok} + cloudCheckMu.Unlock() + return ver, ok +} + +// cachedAI 回上一次(一分鐘內)問到的雲端 AI 狀態;沒有就 ok=false。 +func cachedAI(cypherURL string, force bool) (CloudAIState, bool) { + if force { + return CloudAIState{}, false + } + now := directNow() + cloudCheckMu.Lock() + defer cloudCheckMu.Unlock() + c, hit := cloudAISeen[cloudCheckKey(cypherURL)] + if !hit || now.Sub(c.at) >= cloudCheckInterval || now.Before(c.at) { + return CloudAIState{}, false + } + return c.state, true +} + +func rememberAI(cypherURL string, st CloudAIState) { + cloudCheckMu.Lock() + defer cloudCheckMu.Unlock() + cloudAISeen[cloudCheckKey(cypherURL)] = cachedAIState{at: directNow(), state: st} +} diff --git a/cloudcheck_test.go b/cloudcheck_test.go new file mode 100644 index 0000000..2c9948d --- /dev/null +++ b/cloudcheck_test.go @@ -0,0 +1,107 @@ +// cloudcheck_test.go — 每輪固定問雲端的兩件事,一分鐘只真的問一次(inkstone/arcrun-rag#121)。 +package collector + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +// init:測試環境預設不快取——既有測試(例如「第一輪查得到版本、第二輪 DNS 抽風」) +// 是連續兩輪背靠背跑,模擬的是「每一輪都真的問過」。 +// 需要驗證節流本身的測試(下方與 route_backoff_measure_test.go)自行 save/restore 成真值。 +func init() { + cloudCheckInterval = 0 +} + +func withCloudCheckInterval(t *testing.T, d time.Duration) { + t.Helper() + old := cloudCheckInterval + cloudCheckInterval = d + resetCloudChecks() + t.Cleanup(func() { + cloudCheckInterval = old + resetCloudChecks() + }) +} + +func withClock(t *testing.T, start time.Time) *atomic.Int64 { + t.Helper() + var ns atomic.Int64 + ns.Store(start.UnixNano()) + old := directNow + directNow = func() time.Time { return time.Unix(0, ns.Load()).UTC() } + t.Cleanup(func() { directNow = old }) + return &ns +} + +func TestCloudVersion_AskedOncePerMinute(t *testing.T) { + withCloudCheckInterval(t, 60*time.Second) + t0 := time.Date(2026, 9, 13, 5, 0, 0, 0, time.UTC) + clock := withClock(t, t0) + + var calls int + orig := fetchCloudVersion + fetchCloudVersion = func(string) (string, bool) { calls++; return "1.4.60", true } + defer func() { fetchCloudVersion = orig }() + + u := "https://a.workers.dev" + for i := 0; i < 12; i++ { // 一分鐘內每 5 秒一輪 + clock.Store(t0.Add(time.Duration(i*5) * time.Second).UnixNano()) + if v, ok := cloudVersionThrottled(u, false); v != "1.4.60" || !ok { + t.Fatalf("快取回的答案要與真問到的一樣:%q %v", v, ok) + } + } + if calls != 1 { + t.Fatalf("一分鐘內 12 輪應該只真的問 1 次,got %d", calls) + } + clock.Store(t0.Add(60 * time.Second).UnixNano()) + cloudVersionThrottled(u, false) + if calls != 2 { + t.Fatalf("滿一分鐘要重問,got %d", calls) + } + cloudVersionThrottled(u, true) + if calls != 3 { + t.Fatalf("按「立刻同步」的那一輪要照問,got %d", calls) + } + cloudVersionThrottled("https://b.workers.dev", false) + if calls != 4 { + t.Fatalf("不同知識庫各問各的,got %d", calls) + } +} + +func TestProbeWorkersAI_AskedOncePerMinute(t *testing.T) { + withCloudCheckInterval(t, 60*time.Second) + cloudRoutes.reset() + defer cloudRoutes.reset() + t0 := time.Date(2026, 9, 13, 5, 0, 0, 0, time.UTC) + clock := withClock(t, t0) + + var probes atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/portal/daemon/extract") { + probes.Add(1) + w.WriteHeader(http.StatusBadRequest) // 空 text 的探測:路由存在=可用 + } + })) + defer srv.Close() + + cfg := &DirectConfig{CypherURL: srv.URL, APIKey: "k"} + for i := 0; i < 12; i++ { + clock.Store(t0.Add(time.Duration(i*5) * time.Second).UnixNano()) + if st := cfg.probeWorkersAI(); !st.Ready { + t.Fatalf("第 %d 輪:探測結果要沿用「可用」,got %+v", i, st) + } + } + if n := probes.Load(); n != 1 { + t.Fatalf("一分鐘內 12 輪應該只探測 1 次,got %d", n) + } + clock.Store(t0.Add(61 * time.Second).UnixNano()) + cfg.probeWorkersAI() + if n := probes.Load(); n != 2 { + t.Fatalf("滿一分鐘要重新探測,got %d", n) + } +} diff --git a/cloudquota.go b/cloudquota.go new file mode 100644 index 0000000..5259207 --- /dev/null +++ b/cloudquota.go @@ -0,0 +1,164 @@ +// cloudquota.go — 雲端資料庫(Cloudflare D1)每日免費額度用完時,講明白、且不再重打 +// (`inkstone/arcrun-rag#197`,母票 `inkstone/InkStoneCo#132`)。 +// +// 病(2026-09-13 實撞,youlin 與 geek6688 兩台免費帳號同一天): +// D1 當日讀取額度用完,雲端每一件事都失敗。使用者在小幫手上看到的是 +// 「缺少 credential: kbdb_internal_token」(假的,`inkstone/Arcrun#216` c7066 已查證) +// 與「連續失敗 N 次,已暫停自動重試」——**沒有任何一處說「額度用完了」**。 +// +// 而雲端其實早就知道:cypher `/health` 的 `data_layer.probe_error` 當下寫著 +// +// D1_ERROR: Your account has exceeded D1's free tier daily row read limit. +// Upgrade to a paid plan or wait until tomorrow (midnight UTC) to continue. +// +// 小幫手每分鐘本來就在打這支 `/health`(cloudcheck.go),只是只讀了版本號。 +// ⇒ 本檔把「知道的地方」接到「用戶看得到的地方」,並且在額度恢復前**整個帳號不再打雲端寫入** +// (恢復前每一發都只會拿到同一個錯,還會讓免費帳號的其他額度一起被燒)。 +// +// 🔴 用量數字:Cloudflare 只在帳號層的 GraphQL 分析 API 給得出「今天用了幾列」, +// 而小幫手身上沒有任何 Cloudflare 金鑰,安裝器要的授權範圍也沒有分析讀取權限 +// (installer/oauth-prototype/worker.js `OAUTH_SCOPES`)。所以這一版**不假裝查得到**: +// 上限是官方公開的固定值(developers.cloudflare.com/d1/platform/pricing/, +// Workers Free:每天讀 500 萬列、寫 10 萬列,00:00 UTC 重置), +// 而「已經用到上限」是錯誤訊息本身證明的事實——兩句都是真的,不必查。 +package collector + +import ( + "encoding/json" + "fmt" + "strings" + "sync" + "time" +) + +// D1 免費方案每日上限(Cloudflare 官方定價頁,2026-09-13 查)。 +const ( + d1FreeRowsReadPerDay = 5_000_000 + d1FreeRowsWrittenPerDay = 100_000 +) + +// QuotaNotice.Kind 的值。空字串=舊的 Workers AI 額度(向後相容 status.json)。 +const ( + QuotaKindD1Read = "d1_read" + QuotaKindD1Write = "d1_write" +) + +// d1QuotaKind 從任何一段上游文字認出「D1 每日免費額度用完」,回 read/write/""。 +// 只認 Cloudflare 錯誤原文裡的固定片段——「D1_ERROR」單獨出現不算(別的 D1 錯誤也帶它)。 +func d1QuotaKind(text string) string { + t := strings.ToLower(text) + if !strings.Contains(t, "free tier") && !strings.Contains(t, "daily") { + return "" + } + switch { + case strings.Contains(t, "row read limit"), strings.Contains(t, "rows read"): + return QuotaKindD1Read + case strings.Contains(t, "row write limit"), strings.Contains(t, "rows written"), strings.Contains(t, "row written"): + return QuotaKindD1Write + } + return "" +} + +// d1QuotaFromHealth 讀 cypher `/health` 的回應,回它回報的 D1 額度狀況(""=沒有)。 +// 舊版雲端(沒有 data_layer 區塊,例如 1.4.46)一律回 ""——認不出來就不編故事。 +func d1QuotaFromHealth(body []byte) string { + var payload struct { + DataLayer *struct { + OK bool `json:"ok"` + ProbeError string `json:"probe_error"` + Summary string `json:"summary"` + } `json:"data_layer"` + } + if err := json.Unmarshal(body, &payload); err != nil || payload.DataLayer == nil || payload.DataLayer.OK { + return "" + } + if k := d1QuotaKind(payload.DataLayer.ProbeError); k != "" { + return k + } + return d1QuotaKind(payload.DataLayer.Summary) +} + +type d1QuotaState struct { + kind string + seen time.Time // 最後一次親眼看到額度用完的時間 + until time.Time // 下一次 00:00 UTC——過了就一定不再相信這筆紀錄 +} + +var ( + d1QuotaMu sync.Mutex + d1QuotaSeen = map[string]d1QuotaState{} // key=cloudCheckKey(cypherURL) +) + +func resetD1Quota() { + d1QuotaMu.Lock() + defer d1QuotaMu.Unlock() + d1QuotaSeen = map[string]d1QuotaState{} +} + +// noteD1Quota 記下「這台知識庫此刻的 D1 額度狀況」。kind==""=雲端親口說資料層正常 ⇒ 清掉。 +func noteD1Quota(cypherURL, kind string, now time.Time) { + key := cloudCheckKey(cypherURL) + d1QuotaMu.Lock() + defer d1QuotaMu.Unlock() + if kind == "" { + delete(d1QuotaSeen, key) + return + } + d1QuotaSeen[key] = d1QuotaState{kind: kind, seen: now, until: nextQuotaResetTaiwan(now)} +} + +// activeD1Quota 回這台知識庫現在是否處於 D1 額度用完(過了重置時間的舊紀錄一律丟掉)。 +func activeD1Quota(cypherURL string, now time.Time) (d1QuotaState, bool) { + key := cloudCheckKey(cypherURL) + d1QuotaMu.Lock() + defer d1QuotaMu.Unlock() + st, ok := d1QuotaSeen[key] + if !ok { + return d1QuotaState{}, false + } + if !now.Before(st.until) { + delete(d1QuotaSeen, key) + return d1QuotaState{}, false + } + return st, true +} + +// buildD1QuotaNotice 組出給用戶看的話。欄位沿用 QuotaNotice(App 首頁那張卡、托盤狀態列都吃它)。 +// +// 用戶要知道的四件事(票上的驗收條件):哪一種額度/用了多少、上限多少/台北幾點恢復/恢復後要不要做事。 +func buildD1QuotaNotice(kind string, now, resetAt time.Time) QuotaNotice { + what, limit := "讀取", fmt.Sprintf("每天 %s列", humanRows(d1FreeRowsReadPerDay)) + if kind == QuotaKindD1Write { + what, limit = "寫入", fmt.Sprintf("每天 %s列", humanRows(d1FreeRowsWrittenPerDay)) + } + return QuotaNotice{ + Kind: kind, + Headline: fmt.Sprintf("你的雲端知識庫今天的免費%s額度用完了", what), + Usage: fmt.Sprintf("Cloudflare 免費方案的資料庫%s上限是%s,今天已經用到上限(這不是小幫手或你的檔案壞掉)", what, limit), + Achievement: fmt.Sprintf("雲端資料庫今天的免費%s額度用完了", what), + ExitOptions: "升級 Cloudflare Workers 付費方案(每月 5 美元起)就沒有每日上限", + Guarantee: fmt.Sprintf("台北時間%s早上 8:00 恢復,恢復後小幫手會自動接著傳,你不用做任何事", + quotaResetDayWord(now, resetAt)), + ResumeAt: resetAt.Format(time.RFC3339), + } +} + +func humanRows(n int) string { + if n >= 10_000 && n%10_000 == 0 { + return fmt.Sprintf("%d 萬", n/10_000) + } + return fmt.Sprintf("%d", n) +} + +// d1QuotaNote=「這一發不打,因為雲端資料庫額度用完」的一句話;空=可以打。 +// +// 🔴 結尾「會自動恢復」是 explainsWhySkipped 的識別字(sync_status.go)——被擋下的檔 +// 要帶著這句話出現在畫面上,不能安靜消失。 +func d1QuotaNote(cypherURL string, now time.Time) string { + st, ok := activeD1Quota(cypherURL, now) + if !ok { + return "" + } + n := buildD1QuotaNotice(st.kind, now, st.until) + return n.Headline + ",先不送,避免白白重打;" + n.Guarantee + "(會自動恢復)。" +} diff --git a/cloudquota_test.go b/cloudquota_test.go new file mode 100644 index 0000000..95eed53 --- /dev/null +++ b/cloudquota_test.go @@ -0,0 +1,188 @@ +package collector + +// cloudquota_test.go — arcrun-rag#197:雲端資料庫(D1)免費額度用完時, +// ① 認得出來 ② 整個帳號不再打雲端 ③ 用戶看得到白話 ④ 恢復後自己接上。 +// +// /health 的回應原文照抄 2026-09-13 20:3x 對 youlin(1.4.63)實打的結果,不是編的。 + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +const youlinHealthD1ReadExhausted = `{"ok":true,"status":"degraded","data_layer":{"ok":false,"summary":"資料層探測失敗——連 schema 都讀不到(D1_ERROR: Your account has exceeded D1's free tier daily row read limit. Upgrade to a paid plan or wait until tomorrow (midnight UTC) to continue. See https://developers.cloudflare.com/d1/platform/limits/ for more details.)。這台的知識庫現在不可用。","expected_generation":9,"actual_generation":-1,"behind_by":-1,"missing":[],"legacy_tables":[],"remedy":"確認 D1 binding(DB)指向正確的 arcrun-kbdb,並重跑安裝器或 ` + "`acr update`" + `。","probe_error":"D1_ERROR: Your account has exceeded D1's free tier daily row read limit. Upgrade to a paid plan or wait until tomorrow (midnight UTC) to continue. See https://developers.cloudflare.com/d1/platform/limits/ for more details."},"bundle_version":"1.4.63","bundle_commit":"be6bff7cf10a","auth_store":{"console":{"home":"sessions-kv","writable":true,"legacy_secrets_present":false},"portal_users":{"home":"kbdb","writable":true,"legacy_secrets_present":false}},"mail_relay_configured":true}` + +// geek6688 同一時間(1.4.46,沒有 data_layer)的原文:認不出來就不編故事。 +const geekHealthOld = `{"ok":true,"bundle_version":"1.4.46","auth_store":{"console":{"home":"sessions-kv","writable":true,"legacy_secrets_present":false},"portal_users":{"home":"kbdb","writable":true,"legacy_secrets_present":false}},"mail_relay_configured":true}` + +const healthyNew = `{"ok":true,"status":"ok","data_layer":{"ok":true,"summary":"ok"},"bundle_version":"1.4.64"}` + +func TestD1QuotaKind(t *testing.T) { + cases := map[string]string{ + "D1_ERROR: Your account has exceeded D1's free tier daily row read limit.": QuotaKindD1Read, + "D1_ERROR: Your account has exceeded D1's free tier daily row write limit.": QuotaKindD1Write, + "D1_ERROR: no such table: entries": "", + "4006: you have used up your daily free allocation of 10,000 neurons": "", // Workers AI,不是 D1 + "": "", + } + for in, want := range cases { + if got := d1QuotaKind(in); got != want { + t.Errorf("d1QuotaKind(%q)=%q,要 %q", in, got, want) + } + } + if got := d1QuotaFromHealth([]byte(youlinHealthD1ReadExhausted)); got != QuotaKindD1Read { + t.Fatalf("youlin 實打的 /health 應認出讀取額度用完,got %q", got) + } + if got := d1QuotaFromHealth([]byte(geekHealthOld)); got != "" { + t.Fatalf("舊版雲端沒有 data_layer,不准猜,got %q", got) + } + if got := d1QuotaFromHealth([]byte(healthyNew)); got != "" { + t.Fatalf("資料層正常不准報額度,got %q", got) + } +} + +func TestD1QuotaNotice_TellsUserEverything(t *testing.T) { + // 台北 2026-09-13 20:30 = UTC 12:30 ⇒ 下一次重置是台北 09-14 08:00(明天) + now := time.Date(2026, 9, 13, 12, 30, 0, 0, time.UTC) + n := buildD1QuotaNotice(QuotaKindD1Read, now, nextQuotaResetTaiwan(now)) + all := n.Headline + n.Usage + n.Guarantee + n.ExitOptions + for _, must := range []string{"讀取", "額度用完", "500 萬", "已經用到上限", "台北時間明天早上 8:00", "自動接著傳", "不用做任何事"} { + if !strings.Contains(all, must) { + t.Errorf("訊息少了「%s」:%+v", must, n) + } + } + for _, banned := range []string{"D1_ERROR", "HTTP", "credential", "UTC"} { + if strings.Contains(all, banned) { + t.Errorf("用戶看得到的字不准出現 %q:%+v", banned, n) + } + } + if n.ResumeAt != "2026-09-14T08:00:00+08:00" { + t.Errorf("ResumeAt=%s", n.ResumeAt) + } + w := buildD1QuotaNotice(QuotaKindD1Write, now, nextQuotaResetTaiwan(now)) + if !strings.Contains(w.Headline, "寫入") || !strings.Contains(w.Usage, "10 萬") { + t.Errorf("寫入額度要講寫入與 10 萬:%+v", w) + } + // 台北凌晨 02:00(UTC 前一天 18:00)⇒ 重置是「今天」早上 8 點 + early := time.Date(2026, 9, 13, 18, 0, 0, 0, time.UTC) + if e := buildD1QuotaNotice(QuotaKindD1Read, early, nextQuotaResetTaiwan(early)); !strings.Contains(e.Guarantee, "今天早上 8:00") { + t.Errorf("凌晨要說今天:%s", e.Guarantee) + } + // 被擋下的檔要帶著原因上畫面(explainsWhySkipped 認得) + resetD1Quota() + defer resetD1Quota() + noteD1Quota("https://x.example", QuotaKindD1Read, now) + if note := d1QuotaNote("https://x.example", now); !explainsWhySkipped(note) { + t.Errorf("擋下的訊息畫面認不出原因:%s", note) + } +} + +// 核心驗收:額度用完期間,整輪同步對雲端**零寫入**;/health 說恢復了就自己接上。 +func TestD1Quota_NoCloudWritesUntilRecovered(t *testing.T) { + resetD1Quota() + resetCloudChecks() + cloudRoutes.reset() + defer func() { resetD1Quota(); resetCloudChecks(); cloudRoutes.reset() }() + + var healthBody atomic.Value + healthBody.Store(youlinHealthD1ReadExhausted) + var posts, healths int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/health" { + atomic.AddInt64(&healths, 1) + _, _ = w.Write([]byte(healthBody.Load().(string))) + return + } + atomic.AddInt64(&posts, 1) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"success":true,"data":{"success":true}}`)) + })) + defer srv.Close() + + origFetch := fetchCloudVersion + fetchCloudVersion = fetchBundleVersion // 走真的 /health 解析 + defer func() { fetchCloudVersion = origFetch }() + + if _, ok := cloudVersionThrottled(srv.URL, false); !ok { + t.Fatal("/health 應可達") + } + cfg := &DirectConfig{CypherURL: srv.URL, Namespace: "ns", APIKey: "k"} + url := srv.URL + "/webhooks/named/ns/rag_ingest_card/trigger" + + for i := 0; i < 5; i++ { + _, _, err := cfg.postJSON(stepIngestCard, url, map[string]any{"i": i}) + if err == nil || !isRouteBackoff(err) { + t.Fatalf("第 %d 發:額度用完應被擋成『沒打出去』,got %v", i, err) + } + if !strings.Contains(err.Error(), "額度用完") { + t.Fatalf("擋下的原因要講額度:%v", err) + } + } + // 按「立刻同步」也不打(但會重問 /health) + force := &DirectConfig{CypherURL: srv.URL, Namespace: "ns", APIKey: "k", ForceSync: true} + cloudVersionThrottled(srv.URL, true) + if _, _, err := force.postJSON(stepIngestCard, url, map[string]any{}); err == nil { + t.Fatal("額度沒恢復時立刻同步也不該打出去") + } + if got := atomic.LoadInt64(&posts); got != 0 { + t.Fatalf("額度用完期間打了 %d 發寫入,要 0", got) + } + + // 雲端恢復:下一次強制重問 /health 就放行 + healthBody.Store(healthyNew) + cloudVersionThrottled(srv.URL, true) + if _, _, err := cfg.postJSON(stepIngestCard, url, map[string]any{}); err != nil { + t.Fatalf("恢復後應自動接上,got %v", err) + } + if got := atomic.LoadInt64(&posts); got != 1 { + t.Fatalf("恢復後應打出 1 發,got %d", got) + } +} + +// 寫入那一發自己帶回 D1 額度原文(不等下一分鐘的 /health)⇒ 下一發起就停。 +func TestD1Quota_LearnsFromFailedWrite(t *testing.T) { + resetD1Quota() + cloudRoutes.reset() + defer func() { resetD1Quota(); cloudRoutes.reset() }() + var posts int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&posts, 1) + w.WriteHeader(500) + _, _ = w.Write([]byte(`{"success":false,"error":"D1_ERROR: Your account has exceeded D1's free tier daily row write limit."}`)) + })) + defer srv.Close() + cfg := &DirectConfig{CypherURL: srv.URL, Namespace: "ns", APIKey: "k"} + url := srv.URL + "/webhooks/named/ns/rag_ingest_card/trigger" + if _, _, err := cfg.postJSON(stepIngestCard, url, map[string]any{}); err == nil || isRouteBackoff(err) { + t.Fatalf("第一發應真的打出去並失敗,got %v", err) + } + for i := 0; i < 3; i++ { + if _, _, err := cfg.postJSON(stepIngestCard, url, map[string]any{}); !isRouteBackoff(err) { + t.Fatalf("認出寫入額度用完後應停打,got %v", err) + } + } + if got := atomic.LoadInt64(&posts); got != 1 { + t.Fatalf("只該打出 1 發,got %d", got) + } + if st, ok := activeD1Quota(srv.URL, directNow()); !ok || st.kind != QuotaKindD1Write { + t.Fatalf("應記成寫入額度,got %+v %v", st, ok) + } +} + +// 過了重置時間(台北 08:00)舊紀錄一定失效——就算那之後 /health 一次都沒打到。 +func TestD1Quota_ExpiresAtReset(t *testing.T) { + resetD1Quota() + defer resetD1Quota() + now := time.Date(2026, 9, 13, 12, 30, 0, 0, time.UTC) + noteD1Quota("https://x.example", QuotaKindD1Read, now) + if _, ok := activeD1Quota("https://x.example", now.Add(11*time.Hour)); !ok { + t.Fatal("重置前應仍有效") + } + if _, ok := activeD1Quota("https://x.example", time.Date(2026, 9, 14, 0, 0, 1, 0, time.UTC)); ok { + t.Fatal("過了 00:00 UTC 應失效") + } +} diff --git a/cmd/arcrun-app/.version-source-files.json b/cmd/arcrun-app/.version-source-files.json index af18b3f..1348d97 100644 --- a/cmd/arcrun-app/.version-source-files.json +++ b/cmd/arcrun-app/.version-source-files.json @@ -1,10 +1,10 @@ { "_algo": 4, - "version": "0.18.49", - "fingerprint": "7cb86e646aeaa057", + "version": "0.18.52", + "fingerprint": "5ab62f966033ea4b", "files": { ".gitignore": "c7a98c44ecc882a174720e43dabd4c04d7cf2503586426dcccaa213d5a27df38", - "CHANGELOG.md": "19af5eaa6b67bcd922fd07a53a42ce75c52bec23ad2c161323e925d5860e9031", + "CHANGELOG.md": "718ac1789302c16de2af1595b4081a0b0b2eff88ffd007f33f78720b79521c18", "DAEMON_LINE": "d5019abbdc8a5f2919e9e3510391891cd7fbdf0765bf16ec83caa779f370116d", "README.md": "9d92cac236b20a0b183eea3e7f5e39ad492f05192c4ea602eb11c3d09967327f", "cardloop_test.go": "fdf09b62650c328f5d8075d964a0ca2dada5f8d9a271202768472998b45e31a8", @@ -17,8 +17,12 @@ "cloud_audit_test.go": "1790837e8a5726d62258436937f9c474f7abd03e6229832fdbd40f8cd7aa756a", "cloud_latest.go": "c2ad7f87bbd55247540eb924ac3be5deadbc4488b504770a5127a01c748f0e0f", "cloud_latest_test.go": "703963b65da91ae0c4638b9ad676ca7f12a474c635e903a5d988e4980b3888aa", - "cloud_version.go": "eebc2552e09e199928e82607bebf90daef36b5721ec9b8a098304c10dbec46f9", + "cloud_version.go": "00e385e9157720d6744573c114e0b0e42dff1920a73e6ac0b75861650e2d8897", "cloud_version_test.go": "66c004de1f0a3958851f22a22367065f13b3381ac7d936ae54d812c26a0b9fb6", + "cloudcheck.go": "41605d4d16fb9e80ee9b58deec40e10260c3e3f361dd717bd90c2be40dfa153d", + "cloudcheck_test.go": "8e3a73934a68d668c34ed5c9571db143985bc481453aa2f1b5f4411e3c4eb1b8", + "cloudquota.go": "d19521b5de3e48a8d7b8d10ee0491eca34379c40824f1ffde9a8e3e20489b725", + "cloudquota_test.go": "c2ab6ada0b09f2092a07b84e00090bb74dc81f8204906dde8dd4c53cfd24b9c1", "cmd/arcrun-app/.gitignore": "91248524c8b00d155960f42ad6707ce36bd91ff44f66289eec45972d3915ad37", "cmd/arcrun-app/README.md": "23e53fdf53fcd96b6d8d7a121eb91d3242d11b69d655770473b745c49bb387b9", "cmd/arcrun-app/app.go": "098e6a01aa0a298248ae9cc96275b209e5b8c9669325ba691c46383470216698", @@ -91,7 +95,7 @@ "cmd/arcrun-app/frontend/src/assets/fonts/OFL.txt": "8656c2a27357b3140047a39b27e3cd928256544ad8a004ef77087f761358208c", "cmd/arcrun-app/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2": "06f3af3fe52542d40ad9bc14ec03e04deaabd09ec369221cc8f536db1c72bf55", "cmd/arcrun-app/frontend/src/assets/images/logo-universal.png": "157a874acd3363634747287c0b01034e9f1eec838a4cf39827dfea789e730494", - "cmd/arcrun-app/frontend/src/main.js": "327877a5bb79b79589e09b902e7002e33d275a413451ff90f230bb2e92418b52", + "cmd/arcrun-app/frontend/src/main.js": "2c4ac3017bead3a99812ce8919a513fb0e23dd752285a426c649c6e774d0f08d", "cmd/arcrun-app/frontend/src/style.css": "2cc59ab0be7668f2b8e2e42f655beb506a91f2d31414e0a40dbab33fffc8349a", "cmd/arcrun-app/gen-icons.py": "df5b3b33e500fc564badfc8bd2a9ca6a43f7d7e3b31111c41e3b322137c4c96c", "cmd/arcrun-app/go.mod": "8b9e57bcfd44886d1ec06480e033af706ff3fed470786c57074900dd48ecf86f", @@ -124,7 +128,7 @@ "convert_table_test.go": "d0371b7566ef3152f9dd42f9f990e0dffa1c50a0c8e874a28415fa2e4c188394", "convert_test.go": "04f3fa30d1be5f910c0e0be3308ced2963191ab030a9eaccd986ef581fcd4e18", "convert_wiring_test.go": "3389817e596341369a3fc102846bf5b081cee668e86ebf4830eb633978de652b", - "direct.go": "7a3d2355b5e7442c55b99ce3896e9ba9acee875604b83f2673c1240aed57bbcf", + "direct.go": "3a79d03fce931115739a0113494a61331155172c7f9e5cbee126203b3e616458", "direct_extract_test.go": "317d0057d2d368fabc406351a7477f72e074d2b8c8459b1dc021febdf16902f4", "direct_multi_test.go": "a76593fef26231e18b7402f6f7376624d2471f8a719b3ce08fc4a0f5a3d3cf1e", "direct_pacing.go": "b54838ce85f72ecd2d3388fa7cf89050c87fb10bd67dca07cd18d49568a8837c", @@ -137,10 +141,10 @@ "extract_gemma.go": "53483c6ea3992759abbae45562bfc418d4df1728b44fa87b3bd30324d28fc4f4", "extract_gemma_test.go": "439f12c1c6fb534b31f983305a4d7898ef66dfcbe697714be08a6b571e4e57c0", "extract_test.go": "63560ecca929c6507d93b3f52988bb7ce2cd6137140eb373c520eac5ee052c76", - "extract_workersai.go": "8300691e1a41df4096d316686efbd0016e8df4d482be55b8ce4388b53bd9e1c5", + "extract_workersai.go": "b1a2fc568cd843bd4921794a7c6f4178ee81301c9d295ede074c21eee0e6013d", "extract_workersai_test.go": "8850e7128f03ecb4754a4663281800dc73d556fab1d80367924b58bae3dd8ec3", "folder_progress_wiring_test.go": "c5a4575395f1cee5d1c0ee58728a034113534a3f0d456e4e0d23563da840d314", - "folderindex.go": "575a56fc7763ad82aef14fc5e0e1df39d351f0903babe6251bf2df990e358089", + "folderindex.go": "ff69bf38178bfa5af4e659fb43827b72bd9669c259e048d97e5a3c8c6d0f9f29", "folderindex_helpers_test.go": "2cf17b70c396225f728e8e160cfc75d7bdc76ac1ce8e574d1450b020050c0734", "folderindex_test.go": "a5319eb1e92473cb5c32d3beddb7889997556227824de300dd39953b43beab70", "foldershape.go": "b1b237f5674674efdc9bdea849b63c571a014e64bec33af42fb7c4769fa7582b", @@ -157,13 +161,14 @@ "grounding_test.go": "46f5fd98297caf45b5db494f69363f2544e1cfa5634ce24fe25ca6a87ae296d2", "ignorerules.go": "e6923d0fe35e377e75cfe2364a10624ca40aa6d28efe48ea725036769a48ea14", "ignorerules_test.go": "19675a03539e92e7fd58a46ce8105875cc5aaa42f53615348de775b6d90dbf16", - "ingestplan.go": "f32cd7a740fc291a6b54e2804e0bdf0d50770962414fafe92b015ddf0c6fcddf", + "ingestplan.go": "ac5351d6061f1db34df4005bf49e45a482a6588c9f7758bfd8a3317e5b34e444", "ingestplan_test.go": "baf57a5edc99482842c16d1ea28d1ad6afe7a236a56fc42dac8d007341b578be", "ingestplan_wiring_test.go": "4e5d25dace8a181418ed5857423ef1efeacdd98b7ff42b94a71c4e5fd7012e89", - "inventory.go": "707b2b74dccbd082d56793a558bb28ce0f8812d458b221d8b2d6b084b752af50", + "inventory.go": "40d79723ed1325e758dd85b3647891d20f4a68e0ae3b5f23fc2a19d99b160383", "inventory_test.go": "16e0f83a09d61dc4cded032fd493d30bfc747c38116b871fb02b2f0bf2b0216a", "lint.go": "63af1289978a02c9722ac8bb934a916b9e4e65cffcfec0749eb07590496960f3", "lint_test.go": "2f57a04b9d697fba2d078eb08f49d2fec1640c7e3c61a60bc3821d2bb0798153", + "logseq_bak_residue_test.go": "52aa2f47ac12ca2e77e6112d6de88c873f1f418bae32b2a4de7a35028f835257", "machineid.go": "d4224e684ecfbafe2efc7e0b95377f9e669ee1256a442aa72ce2cab3b219fc56", "machineid_test.go": "5b54ac62ee1fb28b8f0a873888aeddb844af0aa74472bfb849573cfc6691a290", "machinemark.go": "592a39597fd011e8b1603318eaedf452aa58fca0639244a80218aa9a0ac6dbf5", @@ -173,23 +178,25 @@ "manifest_retry_test.go": "e6b4a321ccddfeed8cdbfabe6d031d5a9c59a33b47baf0b6d21150fa8406748a", "multiaccount_connected_test.go": "ba1817b73a5dd0dd5d300750e54d5c53bd82a75ef6e43caeddfcb2303d64ff66", "probe_real_manual_test.go": "6170cd7d41b6e099ad93e229f55e5f9d64d509412969f9bf162788ae8e787aef", - "probe_workersai.go": "2d12d7e83f13a83f7dca0f4f6ea1ff0041db70b4f47d86f940c2fac80cab6980", + "probe_workersai.go": "ce0ceb63377e4b0181bf6acb1c14c5cc0c75173360e0e27392cb9e5b18e2cffc", "progress.go": "98380d3c374570f3182200484b9b2b6a093d41531433b80eb78a92cf94dec69a", "progress_test.go": "21258889ad7680da2d6cf8812accda7e6a1aca6c6fd604394ec4363c2ed902bb", "progress_wiring_test.go": "d1d144269539825ef4a4d9ffca5c49b021cb27cecea7d280309f3f935460b3cd", - "quota.go": "034a2a682b66606e9ab531a251e5157109c9a66ddca6e3ffcecc8fcd10dc0aa3", + "quota.go": "c6596993a0103b8a22c7148d6f9adeec0da09dceaaba040b201476b920027fa4", "quota_test.go": "a91edeb71bf5f3aaa9bd68ad5ec0c87d1e620acc8072e3114098db4d8fc1a896", "repoguard.go": "bcbfab2b76b8fb6afa67aa30a030931a127bae4d9d4a363c40522457ab192c1b", "repoguard_test.go": "ce0316a59af944c3cf391961653fe82294fd45e728d35b4b7fcd99b2b20e7b5c", + "route_backoff_measure_test.go": "d09463f309ec0c7e9c67523717a1962908a38b9d5338edd72e49e151cfd264eb", + "routebackoff.go": "09a23fef781c174fe3d257e42da58540bd6971aff7151ffa6fb5188de9188e6f", "safewrite.go": "d5dd09ffe894fb9fb94656a4bb50d246bd8c65ab21a7ad81463f43dfdacc8a57", "safewrite_test.go": "2980f0f86f78fb265403e31d4cba6da43f4dbc16e7c4711cfccba23175578358", - "scan.go": "546329b883228687a60d48419c19113a6208f0f5ea18a0d3ba0fc401ab3c0974", + "scan.go": "a91ca567cff71864cb5a0a2af234a478df7118c47bfa24727f1b37c3f0466540", "scan_carry_lasterror_test.go": "6fa542422fb3e00b5ec4355b5db8df7086361b2dacefd67755d75c0ddfa55575", "scan_dedup_scale_test.go": "573b3c78bec86614bf4d2f99b7c3c3157f92c24af73cae7290428fc7f43f0362", "scan_dedup_test.go": "580ec58596ca54aa3d29b417e7d098fd2e3455fd39bf10e7da02d453c0786df0", "scan_skipped_test.go": "983ecabb5d93388a501836d15dd445789b7f3028a7f3b966e04425ad4d5bfd9c", "scan_test.go": "d0e472686ff41da2421bfd45b63099fd75877e6fc72f26dfdd1a07dc64c9f0cd", - "sourcerepair.go": "bdb1a4b64d028a9df42a7b9258a431e01f1be8871c490b92011d0c6ee5e53b79", + "sourcerepair.go": "59fa1956181904f6661ab397e42b4f1c9b8ac81f382fdf720cc5b0831390f9d9", "sourcerepair_live_test.go": "2a7f5d6fada420a7f55fa52b87361842755c68c1cb58f8c3fd0153341f709a0a", "sourcerepair_test.go": "2a1ddddaad8ac7adc36350859e2d742c0fea12b99370a4d4831d390db1cc6720", "stallguard.go": "13be8ae905492054d516f466856bc6949b16e1af6ea32068f94b21635799bdc6", diff --git a/cmd/arcrun-app/.version-source.json b/cmd/arcrun-app/.version-source.json index 6803672..22bf522 100644 --- a/cmd/arcrun-app/.version-source.json +++ b/cmd/arcrun-app/.version-source.json @@ -19,5 +19,8 @@ "0.18.46": "1325784830f592ff", "0.18.47": "0decb0810da46a3f", "0.18.48": "a440a58a629f960c", - "0.18.49": "7cb86e646aeaa057" + "0.18.49": "7cb86e646aeaa057", + "0.18.50": "b30716c3ccec9e65", + "0.18.51": "3cfcc815cf185a68", + "0.18.52": "5ab62f966033ea4b" } diff --git a/cmd/arcrun-app/frontend/src/main.js b/cmd/arcrun-app/frontend/src/main.js index f1596bb..7edf499 100644 --- a/cmd/arcrun-app/frontend/src/main.js +++ b/cmd/arcrun-app/frontend/src/main.js @@ -247,6 +247,18 @@ function cardQuota(q, p) { if (!q) return ''; const pending = p && p.pending > 0 ? `
還有 ${p.pending} 份排隊中——會自動接著跑,你不用重丟。
` : ''; + // arcrun-rag#197:雲端資料庫(D1)額度用完是另一種卡——沒有「成就」可講, + // 用戶要的是:哪一種額度、上限多少/用到哪、幾點恢復、要不要自己做事。文字全來自後端。 + if (q.kind === 'd1_read' || q.kind === 'd1_write') { + return ` +
+

${esc(q.headline)}

+
${esc(q.usage)}。
+
${esc(q.guarantee)}
+ ${pending} +
急著要的話:${esc(q.exit_options)}。
+
`; + } return `

${esc(q.achievement)} 🎉

diff --git a/direct.go b/direct.go index 0d55e65..7f47d6b 100644 --- a/direct.go +++ b/direct.go @@ -521,10 +521,21 @@ func countsAsDocument(r DirectResult) bool { //(收卡那條路,可能是使用者剛存的新檔,也可能是在補修舊筆記的出處)—— // 所以 step 由呼叫端給,不從網址反推,反推出來的名字會說謊。 func (c *DirectConfig) postJSON(step callStep, url string, body any) (int, string, error) { + return c.postJSONAs(step, url, body, false) +} + +// postJSONAs=postJSON,多帶一個 retry:這份內容先前就失敗過。 +// 它只影響「這一發失敗算不算這條路壞了」(`inkstone/arcrun-rag#121`,見 routebackoff.go 檔頭)。 +func (c *DirectConfig) postJSONAs(step callStep, url string, body any, retry bool) (int, string, error) { data, err := json.Marshal(body) if err != nil { return 0, "", err } + // 🔴 `inkstone/arcrun-rag#121`:這條路正在退避 ⇒ 連打都不打(見 routebackoff.go)。 + // 放在這裡而不是各呼叫端:所有寫雲端的路都經過這一個函式,「忘了接」不該存在。 + if note := c.routeNote(url); note != "" { + return 0, "", &routeBackoffError{note: note} + } gate := c.openGate(step) defer gate.release() // context 要活到下面讀完回應為止,所以是 defer 不是就地釋放 // 這一輪已經判定這個帳號沒有回應 ⇒ 連打都不打,立刻回頭讓其他資料夾繼續。 @@ -539,10 +550,12 @@ func (c *DirectConfig) postJSON(step callStep, url string, body any) (int, strin req.Header.Set("X-Arcrun-API-Key", c.APIKey) resp, err := directHTTP.Do(req) if err != nil { + cloudRoutes.record(url, directNow(), 0, err, retry) // #121:連不上/逾時也算這條路失敗 return 0, "", gate.record(err) } gate.ok() // #153:回來了就把「連續逾時」的計數歸零——否則「連續」兩個字是假的 defer resp.Body.Close() + cloudRoutes.record(url, directNow(), resp.StatusCode, nil, retry) // #121:5xx/429 記一次失敗,2xx 歸零 // 🔴 讀 64KB 而不是 1KB:觸發端點的回應是一層外殼包著工作流的輸出, // 而**失敗的證據住在殼裡面**(見 triggeroutcome.go)。1KB 會把 JSON 切斷 ⇒ // 永遠解析不了 ⇒ 每一次失敗都被讀成「看不出來」⇒ 下面那道閘等於不存在。 @@ -552,6 +565,11 @@ func (c *DirectConfig) postJSON(step callStep, url string, body any) (int, strin snippet = snippet[:1024] } if resp.StatusCode < 200 || resp.StatusCode >= 300 { + // arcrun-rag#197:雲端把 D1 額度用完的原文傳回來了(新舊雲端不一定都會)⇒ 記下, + // 下一發起整個帳號停打,不必等下一分鐘的 /health。 + if k := d1QuotaKind(string(full)); k != "" { + noteD1Quota(c.CypherURL, k, directNow()) + } return resp.StatusCode, string(snippet), fmt.Errorf("HTTP %d:%s", resp.StatusCode, strings.TrimSpace(string(snippet))) } // 🔴 2xx 只證明「請求送到了」,不證明「東西寫進知識庫了」。 @@ -621,6 +639,11 @@ func (c *DirectConfig) makeAccountSubConfig(acc AccountConfig) *DirectConfig { return &sub } +// directNow=一輪的「現在」。正式執行就是 time.Now; +// 測試要模擬「雲端持續失敗 N 分鐘」時換成假時鐘(`inkstone/arcrun-rag#121` 的量測), +// 否則所有退避窗口都得真的等上幾分鐘才看得到效果。 +var directNow = time.Now + // RunDirectOnce 對每個帳號的每個監看根掃一輪並彙總結果(t104 多帳號同時看守)。 // 單帳號行為與舊制完全相同(含 manifest 路徑)。回傳彙總結果與退出碼建議(任一根失敗=1)。 // 額外: @@ -631,7 +654,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge results := []DirectResult{} exit := 0 var lastPayload *TriggerPayload - now := time.Now() // 2026-08-07 pacing task:整輪共用同一個時間點(排序/冷卻判斷一致、好測試) + now := directNow() // 2026-08-07 pacing task:整輪共用同一個時間點(排序/冷卻判斷一致、好測試) // 🔴 `inkstone/arcrun-rag#153`:這一輪的「等待閘」。每輪換一份新的——斷路器只管 // 這一輪,下一輪一律從零開始重新試(同步是 level-triggered 的,沒有什麼要記住)。 @@ -771,7 +794,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge accHost := instanceHostOf(acc.CypherURL) // t103:per-account 雲端版本偵測 - cloudVer, cloudOK := fetchCloudVersion(accCfg.CypherURL) + cloudVer, cloudOK := cloudVersionThrottled(accCfg.CypherURL, cfg.ForceSync) // #121:一分鐘問一次,見 cloudcheck.go // 🔴 `inkstone/arcrun-rag#159`:**一次探測失敗 ≠ 這台知識庫連不上。** // // leo 2026-08-28 的畫面上,`youlin.hsieh.dev` 那行紅字寫「目前連不上這個 @@ -940,6 +963,17 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge // 冷卻已過且本輪沒有新命中 ⇒ QuotaCooldownUntil/QuotaMessage 維持零值, // 自然清除舊訊息(accSt 每輪重建,不會殘留上一輪的冷卻通知)。 + // arcrun-rag#197:雲端資料庫額度用完**蓋過** AI 額度那張卡——資料庫讀不到時, + // 萃取、收卡、下架全部做不成,講 AI 額度只會讓用戶以為換個模型就好。 + // 不寫 QuotaCooldownUntil:那一格是 Workers AI 冷卻的跨輪紀錄,混用會讓 D1 恢復後 + // 萃取還被多擋一段;D1 的狀態每分鐘由 /health 重新確認(cloudquota.go)。 + if st, ok := activeD1Quota(accCfg.CypherURL, now); ok { + notice := buildD1QuotaNotice(st.kind, now, st.until) + accSt.QuotaMessage = ¬ice + extractorOK = false + extractorError = notice.Headline + "。" + notice.Guarantee + } + accountDetails[accHost] = accSt } @@ -1166,19 +1200,40 @@ func saveDirectConfig(configPath string, cfg *DirectConfig) error { // 相同 ⇒ 撤除其中一個會連坐另一個。library 是逐根導出的(libraryFor),把它一起送上去, // 雲端才有辦法只殺對的那一份。這與 ingest 送的 library 是**同一個函式**算出來的, // 守 2026-07-24 那條教訓:成對操作(上架/下架)要用同一把鍵。 +// +// maxPerRun(arcrun-rag#104 comment 6309):單輪最多撤幾筆,0=不限。 +// 以前這份清單只裝改名留下的舊頁(一輪一兩筆),撤完為止沒問題;現在資料夾卡 +// 消失也排進來(leo21c 的 KB 光 `logseq/bak/` 底下就是上千個資料夾), +// 一輪全撤會撞單輪等待上限(stallguard)與雲端 subrequest 額度。 +// 排隊的照 removed 事件同一個單輪上限走,沒撤到的下一輪接著撤——清單本來就是持久的。 func drainPendingTakedowns( cfg *DirectConfig, m *Manifest, absRoot, resultType, failPrefix string, - pace func(), dryRun bool, saveManifest func(), + pace func(), dryRun bool, saveManifest func(), maxPerRun int, ) ([]DirectResult, int) { var results []DirectResult exit := 0 if len(m.PendingTakedowns) == 0 { return results, exit } + // 固定順序:map 走訪是隨機的,套上限之後若不排序,同一批會隨機輪流被跳過。 + pending := make([]string, 0, len(m.PendingTakedowns)) + for oldPath := range m.PendingTakedowns { + pending = append(pending, oldPath) + } + sort.Strings(pending) + deferred := 0 + if maxPerRun > 0 && len(pending) > maxPerRun { + deferred = len(pending) - maxPerRun + pending = pending[:maxPerRun] + } if dryRun { - for oldPath := range m.PendingTakedowns { + for _, oldPath := range pending { results = append(results, DirectResult{Type: resultType, Path: oldPath, Status: "planned"}) } + if deferred > 0 { + results = append(results, DirectResult{Type: "info", Status: "skipped", + Error: fmt.Sprintf("還有 %d 筆待撤,下一輪接著撤", deferred)}) + } return results, exit } // #153:同一條撤除路徑服務兩件事,而使用者眼中它們不是同一件—— @@ -1187,7 +1242,13 @@ func drainPendingTakedowns( if resultType == "folder_takedown" { step = stepRetire } - for oldPath, pageName := range m.PendingTakedowns { + for _, oldPath := range pending { + // #121:下架那條路正在退避 ⇒ 整批停手(清單是持久的,下一輪接著撤)。 + if note := cfg.routeNote(cfg.triggerURL(cfg.RemovedWF)); note != "" { + results = append(results, DirectResult{Type: "info", Status: "skipped", Error: note}) + break + } + pageName := m.PendingTakedowns[oldPath] pace() res := DirectResult{Type: resultType, Path: oldPath} mach := cfg.machineIdentity() @@ -1222,6 +1283,10 @@ func drainPendingTakedowns( saveManifest() results = append(results, res) } + if deferred > 0 { + results = append(results, DirectResult{Type: "info", Status: "skipped", + Error: fmt.Sprintf("還有 %d 筆待撤,下一輪接著撤", deferred)}) + } return results, exit } @@ -1299,7 +1364,7 @@ func retireRootOnce(cfg *DirectConfig, root string, dryRun bool) ( } } dr, de := drainPendingTakedowns(cfg, m, absRoot, "folder_takedown", - "移除資料夾後的雲端撤除失敗(下輪重試):", pace, dryRun, saveManifest) + "移除資料夾後的雲端撤除失敗(下輪重試):", pace, dryRun, saveManifest, 0) results = append(results, dr...) exit = de @@ -1683,6 +1748,31 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta results = append(results, res) continue } + // 🔴 `inkstone/arcrun-rag#121`:這個檔要送去的那條雲端路正在退避 ⇒ 連萃取都不做。 + // 與上面兩道閘同一層、同一個理由:路壞了不是這個檔的錯,不記 FailCount; + // 而且先擋在萃取之前——送不出去的卡,萃了只是白燒一份 AI 額度。 + { + routeURL := cfg.triggerURL(cfg.IngestWF) + if cfg.Extractor != "" { + routeURL = cfg.triggerURL(cfg.CardIngestWF) + } + if note := cfg.routeNote(routeURL); note != "" { + res.Status = "skipped" + res.Error = note + results = append(results, res) + continue + } + // 萃取本身也是一條路(workers-ai 打的正是這台知識庫的 /portal/daemon/extract), + // 2026-09-13 真機上打最多的就是它——同一道閘。gemma 打 Google,不在這裡。 + if cfg.Extractor == "workers-ai" { + if note := cfg.routeNote(workersAIExtractURL(cfg.CypherURL)); note != "" { + res.Status = "skipped" + res.Error = note + results = append(results, res) + continue + } + } + } // 🔴 t195 止血點:這個檔剛失敗過且還在退避窗口內 → 這輪跳過。 // 沒有這道閘時的實測災情:`小果被AFTEE詐貸.pdf` 因雲端 401 失敗, // 每輪重掃又被當成新檔 ⇒ **1387 輪、跨 11 小時**,且它排在佇列前面, @@ -1745,7 +1835,9 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta // 掛上去的話,Google 慢會被算成「你的知識庫沒有回應」—— // 誤導的訊息比沒有訊息更貴(會害人往錯的方向查)。 xgate := cfg.openGate(stepExtractDoc) - cards, xerr = ExtractWithWorkersAI(cfg.CypherURL, cfg.APIKey, absRoot, ev.Path, cardOrigin) + prior := m.Entries[ev.Path] // #121:先前失敗過的檔再失敗,不算「路壞了」 + cards, xerr = extractWithWorkersAI(cfg.CypherURL, cfg.APIKey, absRoot, ev.Path, cardOrigin, + prior != nil && prior.FailCount > 0) xgate.release() if xerr == nil { xgate.ok() @@ -1779,6 +1871,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta continue } ok := true + routeSkipped := false // #121:卡是被「路在退避」擋下的(沒打出去) // InkStoneCo#44 ④(2026-08-15):gemma 路現在一份文件產「文件卡+N 張 // 概念卡」(cards[0]=文件卡)。雲端 rag_ingest_card 以 page_name upsert、 // 下架以原稿頁名比對 ⇒ N 張卡都送會互相蓋寫同一頁。 @@ -1831,10 +1924,17 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta cardBody["quality"] = "low" cardBody["quality_warnings"] = warns } - status, _, perr := cfg.postJSON(stepIngestCard, cfg.triggerURL(cfg.CardIngestWF), cardBody) + // #121:這個檔先前失敗過 ⇒ 這一發再失敗不算「路壞了」(見 routebackoff.go 檔頭)。 + prior := m.Entries[ev.Path] + status, _, perr := cfg.postJSONAs(stepIngestCard, cfg.triggerURL(cfg.CardIngestWF), cardBody, + prior != nil && prior.FailCount > 0) res.HTTPStatus = status if perr != nil { res.Status, res.Error = "failed", perr.Error() + if isRouteBackoff(perr) { + res.Status = "skipped" // #121:沒打出去,不是這個檔的失敗 + routeSkipped = true + } ok = false break } @@ -1850,6 +1950,8 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta m.MarkNoCloudCard(ev.Path) } qs.DailyCount++ // 2026-08-07:今天的成就數(額度訊息「今天已經幫你整理了 N 份」用) + } else if routeSkipped { + // #121:這一發根本沒打出去 ⇒ 不記病歷(同額度冷卻/帳號沒回應的處理)。 } else { // t195:記下失敗並排定退避,否則下輪又把它當新檔重試 //(實撞:1387 輪 × 11 小時全在撞同一面 401 的牆,還拖住整個佇列)。 @@ -1901,6 +2003,12 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta results = append(results, res) continue } + // #121:下架那條路正在退避 ⇒ 這輪不打,維持「暫時放回」,下一輪自然重試。 + if note := cfg.routeNote(cfg.triggerURL(cfg.RemovedWF)); note != "" { + res.Status, res.Error = "skipped", note + results = append(results, res) + continue + } pace() // 2026-08-07:下架一樣是觸發雲端 workflow,同樣節流 // 下架=POST {page_name, path} 進 rag_takedown_direct(按 page_name 讀 kbdb blocks // 標 deprecated,不碰 R2;獨立於 rag_ingest 的 __CARDS_PREFIX__ 閘——direct 模式檔在 @@ -1962,7 +2070,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta // 上面排進去的,以及之前輪次失敗留下的(同一個待辦清單,一次處理完)。 // 與 orderedEvents 共用同一個節流器(pace),避免一輪多筆改名瞬間打爆雲端。 dr, de := drainPendingTakedowns(cfg, m, absRoot, "renamed_takedown", - "改名/搬移後舊頁下架失敗(下輪重試):", pace, dryRun, saveManifest) + "改名/搬移後舊頁下架失敗(下輪重試):", pace, dryRun, saveManifest, perRunCap) results = append(results, dr...) if de != 0 { exit = de diff --git a/extract_workersai.go b/extract_workersai.go index 0eeb676..4d6103f 100644 --- a/extract_workersai.go +++ b/extract_workersai.go @@ -96,6 +96,19 @@ func tooBigForWorkersAI(srcText, relPath string) string { // cypherURL/apiKey 用的是 daemon 既有的連線憑證(送卡片上雲時同一把,見 direct.go)。 // 回傳產出的卡片相對路徑(單檔一卡),與 ExtractWithGemma 契約一致。 func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string, origin SourceOrigin) ([]string, error) { + return extractWithWorkersAI(cypherURL, apiKey, absRoot, relPath, origin, false) +} + +// workersAIExtractURL=萃取端點。探測(probe_workersai.go)與萃取打的是同一條路, +// 退避(routebackoff.go)也用這一個網址當鍵。 +func workersAIExtractURL(cypherURL string) string { + return strings.TrimSuffix(strings.TrimSpace(cypherURL), "/") + "/portal/daemon/extract" +} + +// extractWithWorkersAI=ExtractWithWorkersAI,多帶 retry:這個檔先前就失敗過。 +// `inkstone/arcrun-rag#121`:這條路不經 postJSON,所以在這裡自己把結果記進路由退避—— +// 2026-09-13 真機上對 youlin 打最多的正是這一條。 +func extractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string, origin SourceOrigin, retry bool) ([]string, error) { if strings.TrimSpace(cypherURL) == "" { return nil, fmt.Errorf("workers-ai 萃取路需要 cypher_url(config)") } @@ -130,7 +143,7 @@ func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string, origin Sou "prompt": wikiExtractPrompt(pageName, srcText), }) - url := strings.TrimSuffix(strings.TrimSpace(cypherURL), "/") + "/portal/daemon/extract" + url := workersAIExtractURL(cypherURL) req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(reqBody)) if err != nil { return nil, err @@ -140,9 +153,11 @@ func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string, origin Sou resp, err := workersAIHTTP.Do(req) if err != nil { + cloudRoutes.record(url, directNow(), 0, err, retry) // #121 return nil, fmt.Errorf("連不上你的知識庫:%w", err) } defer resp.Body.Close() + cloudRoutes.record(url, directNow(), resp.StatusCode, nil, retry) // #121:5xx/429 記失敗,2xx 歸零 body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) if resp.StatusCode == http.StatusNotFound { diff --git a/folderindex.go b/folderindex.go index 8515c55..c44d940 100644 --- a/folderindex.go +++ b/folderindex.go @@ -258,14 +258,18 @@ func syncFolderCards(cfg *DirectConfig, absRoot string, m *Manifest, hasEvents, } lib := cfg.libraryFor(absRoot) cards := BuildFolderCards(absRoot, m.Entries, lib) - if len(cards) == 0 { - return nil - } if m.FolderCardHashes == nil { m.FolderCardHashes = map[string]string{} } // 先刪掉「這輪已經不存在的資料夾」的記帳,避免雜湊表無限長大。 + // + // 🔴 arcrun-rag#104(comment 6309):以前這裡**只刪本機的記帳**,雲端那張 + // 「資料夾:KB/logseq/bak/pages/…」的卡沒有人去撤——leo21c 的 `kb` 庫核心實體裡就有 + // 「資料夾:KB/logseq/bak/system-dev/wiki/cards」(degree 44)、`part_of` 三元組 3,610 條, + // 全是已經不該存在的資料夾留下的。現在資料夾從樹上消失=那張卡排進待撤清單, + // 走跟改名同一條下架路(drainPendingTakedowns,成功一筆清一筆,失敗下輪重試)。 + // 這一段刻意放在「一張卡都沒有」的提早返回**之前**——整棵樹都不收了才更要撤。 live := map[string]bool{} for _, c := range cards { live[c.Rel] = true @@ -273,8 +277,12 @@ func syncFolderCards(cfg *DirectConfig, absRoot string, m *Manifest, hasEvents, for rel := range m.FolderCardHashes { if !live[rel] { delete(m.FolderCardHashes, rel) + m.QueueTakedown(folderCardPath(lib, rel), folderCardPageName(absRoot, rel)) } } + if len(cards) == 0 { + return nil + } // 🔴 先挑「這輪真的要送的」,**再**套上限——順序反過來就會變成永遠只重試前 200 個, // 後面的資料夾一輩子送不上去(而且畫面上看起來一切正常)。 @@ -308,6 +316,10 @@ func syncFolderCards(cfg *DirectConfig, absRoot string, m *Manifest, hasEvents, if wf == "" { wf = "rag_ingest_card" } + // #121:收卡那條路正在退避 ⇒ 這輪不送(雜湊沒記,下一輪自然補送)。 + if !dryRun && cfg.routeNote(cfg.triggerURL(wf)) != "" { + return nil + } mach := cfg.machineIdentity() for _, t := range todo { c := t.card diff --git a/ingestplan.go b/ingestplan.go index 0a50a09..5ddc1f0 100644 --- a/ingestplan.go +++ b/ingestplan.go @@ -521,6 +521,18 @@ func (p IngestPlan) SkipsDirWhy(relSlash, absPath string) (bool, string) { if ambiguousBuildDirNames[name] && looksGenerated(absPath) { return true, "這是建置工具產生的目錄(旁邊就是產生它的專案檔)" } + // ④ 筆記軟體自己的資料夾(`inkstone/arcrun-rag#104` comment 6309,leo 2026-09-05:「bak 是不要的」)。 + // Logseq graph 底下的 `logseq/` 裝的是它的設定(config.edn)、每次編輯自動留的備份 + // (`bak/`)與版本檔(`version-files/`)——**筆記本體在 `pages/`/`journals/`**。 + // 實據(leo21c,2026-09-05):`~/Documents/KB` 4,193 份文件裡 **3,620 份**是 `logseq/bak/` + // 的殘影,同一頁的每一個歷史版本都被當成一份新知識收進 `kb`;08-13 早就判過是垃圾 + // (48 萬筆廢資料的三重放大之一),D82 重灌後又吃回來。 + // 判準用 vault.go 既有的那一套(要有佐證才算 Logseq graph):一個碰巧叫 `logseq` + // 的普通資料夾(旁邊沒有 pages/journals/config.edn)照收——漏判只是多收一個資料夾, + // 誤判是把使用者的東西弄不見。 + if name == "logseq" && logseqCorroborated(filepath.Dir(absPath)) { + return true, "這是 Logseq 自己的設定與備份資料夾(bak/version-files),你的筆記本體在 pages 與 journals 裡,備份不是新知識" + } if IsLinkedWorktree(absPath) { return true, "這是同一個專案的第二份簽出(git worktree),內容與主資料夾重複" } @@ -531,6 +543,47 @@ func (p IngestPlan) SkipsDirWhy(relSlash, absPath string) (bool, string) { return false, "" } +// ExcludesPathWhy 回答「這個檔**在現在的策略下**收不收」——給 Scan 判斷「manifest 裡有、 +// 現況卻沒走到」的路徑用:它是**真的不見了**(資料夾沒掛載、同步半途),還是 +// **我們自己決定不再收了**(策略變了、排除規則補了一條)。 +// +// 🔴 為什麼要分(`inkstone/arcrun-rag#104` comment 6309):補上「`logseq/` 不收」那一條之後, +// leo21c 的 `~/Documents/KB` manifest 裡 4,193 份有 3,620 份會在同一輪從現況消失—— +// 86%,遠超 mass_delete_guard 的 40%。那道閘擋的是「資料夾未掛載」這種**意外**; +// 策略排除是**確定的決定**,被它擋住的結果是:警告每輪都響、殘影永遠留在雲端、 +// 而且畫面上看起來像資料夾壞了。分開之後:意外照擋,決定照下架。 +// +// 回 (true, 理由)=這條路徑上有一層被 SkipsDirWhy 剪掉、或檔案本身被 KeepsFile 拒收; +// 理由就是講給使用者聽的那一句。走訪器自己擋的隱藏目錄(`.git`、`.obsidian`…)也算。 +func (p IngestPlan) ExcludesPathWhy(relSlash, absRoot string) (bool, string) { + relSlash = strings.ReplaceAll(relSlash, "\\", "/") + segs := strings.Split(relSlash, "/") + dir := "" + for _, seg := range segs[:len(segs)-1] { + if dir == "" { + dir = seg + } else { + dir += "/" + seg + } + if strings.HasPrefix(seg, ".") { + return true, "這是隱藏資料夾,不收" + } + if skip, why := p.SkipsDirWhy(dir, filepath.Join(absRoot, filepath.FromSlash(dir))); skip { + return true, why + } + } + if strings.HasPrefix(segs[len(segs)-1], ".") { + return true, "這是隱藏檔,不收" + } + if !p.KeepsFile(relSlash) { + if _, why := p.CollectsDirWhy(folderOfRel(relSlash)); why != "" { + return true, why + } + return true, "這一層的檔案不在這次的收檔範圍裡" + } + return false, "" +} + // CollectsDirWhy 回答「這一層的檔案,這次收不收」——**走訪照走,只影響收檔**。 // // 回 (false, 理由) = 這一層的檔不收,理由講給使用者聽;(true, "") = 照收檔規則走。 diff --git a/inventory.go b/inventory.go index 4fe1210..0158850 100644 --- a/inventory.go +++ b/inventory.go @@ -265,11 +265,15 @@ func syncInventory(cfg *DirectConfig, absRoot string, m *Manifest, hasEvents, dr res.Status = "planned" return res } - pace() // 觸發雲端前一律節流(2026-08-07 pacing 慣例) wf := cfg.CardIngestWF if wf == "" { wf = "rag_ingest_card" } + // #121:收卡那條路正在退避 ⇒ 這輪不送,也不記失敗(這一發根本沒打出去)。 + if cfg.routeNote(cfg.triggerURL(wf)) != "" { + return nil + } + pace() // 觸發雲端前一律節流(2026-08-07 pacing 慣例) // machine(`inkstone/mira#6`):總覽卡的 path 是**合成**的(inventoryCardPath), // 只帶 library ⇒ 兩台機器上同名的資料夾會生出一模一樣的鍵,後同步的那台會把 // 前一台的總覽卡蓋掉。這裡與逐檔卡走同一組欄位,不另開一種。 diff --git a/logseq_bak_residue_test.go b/logseq_bak_residue_test.go new file mode 100644 index 0000000..043196e --- /dev/null +++ b/logseq_bak_residue_test.go @@ -0,0 +1,281 @@ +package collector + +// logseq_bak_residue_test.go — `inkstone/arcrun-rag#104` comment 6309(leo 2026-09-05「bak 是不要的」)。 +// +// 病:Logseq 每次編輯都往 `logseq/bak/` 留一份備份,daemon 把每一份都當新知識收進 `kb` +// ——leo21c 的 KB 4,193 份文件裡 3,620 份是這種殘影,而且雲端的資料夾卡也跟著長出 +// 「資料夾:KB/logseq/bak/pages/…」上千張。08-13 早判過是垃圾,D82 重灌後回歸。 +// +// 三個環,缺一個殘影都清不掉: +// ① 不再收:`logseq/` 是 Logseq 自己的資料夾,整棵跳過,理由看得見 +// ② 已收的要下架:策略不再收的檔照發 removed,**不受**大量刪除防呆管(86% 會被擋一輩子) +// ③ 資料夾卡也要撤:資料夾從樹上消失=那張卡排進待撤清單 + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// makeLogseqGraph 造一個有佐證的 Logseq graph:pages/journals 是筆記本體, +// logseq/ 底下是設定與備份。回傳根與 bak 檔的相對路徑。 +func makeLogseqGraph(t *testing.T, withConfig bool) (root string, bakFiles []string) { + t.Helper() + root = t.TempDir() + writeFile(t, root, "pages/index.md", "- n8n 本機安裝指南\n", baseTime) + writeFile(t, root, "pages/擋風防雨車.md", "- Adiva D1 價格 20 萬\n", baseTime) + writeFile(t, root, "journals/2026_09_04.md", "- 今天評估 Adiva D1\n", baseTime) + if withConfig { + writeFile(t, root, "logseq/config.edn", "{:meta/version 1}\n", baseTime) + } + for _, rel := range []string{ + "logseq/bak/pages/index/2026-09-01T12_08_05.467Z.Desktop.md", + "logseq/bak/pages/index/2026-09-01T13_07_56.194Z.Desktop.md", + "logseq/bak/pages/擋風防雨車/2026-09-04T01_00_00.000Z.Desktop.md", + "logseq/bak/journals/2026_09_04/2026-09-04T02_00_00.000Z.Desktop.md", + "logseq/version-files/base/pages/index.md", + } { + writeFile(t, root, rel, "- 舊版殘影 "+rel+"\n", baseTime) + bakFiles = append(bakFiles, rel) + } + return root, bakFiles +} + +// ① Logseq graph 底下的 logseq/ 整棵不收,而且理由要講得出來。 +func TestPlanIngest_Logseq自己的bak資料夾不收且理由看得見(t *testing.T) { + root, bakFiles := makeLogseqGraph(t, true) + payload, plan := scanWithPlan(t, root) + + if plan.Mode != IngestAll { + t.Fatalf("筆記庫應該是 all 模式,實得 %s", plan.Mode) + } + got := eventPaths(payload) + for _, p := range got { + if strings.HasPrefix(p, "logseq/") { + t.Fatalf("收到了 Logseq 自己的檔:%s(全部:%v)", p, got) + } + } + if len(got) != 3 { + t.Fatalf("筆記本體應該收 3 份(pages×2+journals×1),實得 %d:%v", len(got), got) + } + var why string + for _, d := range payload.AllExcludedDirs { + if d.Path == "logseq" { + why = d.Reason + } + } + if why == "" { + t.Fatalf("logseq/ 被跳過了,卻沒有列在排除清單裡——使用者會以為東西不見了:%+v", payload.AllExcludedDirs) + } + if !strings.Contains(why, "Logseq") || !strings.Contains(why, "bak") { + t.Fatalf("理由要講得出這是 Logseq 的備份:%q", why) + } + // 樹上不該有 logseq/bak 這一層的節點——它整棵沒走進去,不是「走進去但不收」。 + for rel := range payload.DirStats { + if strings.HasPrefix(rel, "logseq") { + t.Fatalf("logseq/ 底下不該生出節點:%s", rel) + } + } + _ = bakFiles +} + +// ①' 一個碰巧叫 logseq 的普通資料夾(旁邊沒有 pages/journals/config.edn)照收—— +// 漏判只是多收一個資料夾,誤判是把使用者的東西弄不見。 +func TestPlanIngest_碰巧叫logseq的普通資料夾照收(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "筆記.md", "- 一般筆記\n", baseTime) + writeFile(t, root, "logseq/匯出/教學.md", "- 從 Logseq 匯出的教學\n", baseTime) + payload, _ := scanWithPlan(t, root) + got := eventPaths(payload) + if len(got) != 2 || got[0] != "logseq/匯出/教學.md" { + t.Fatalf("沒有佐證的 logseq 資料夾應該照收,實得 %v", got) + } +} + +// ② 已經收過的殘影:規則補上之後要下架,而且不受大量刪除防呆管。 +// +// 情境照 leo21c 的形狀:先用「看不出是 Logseq graph」的狀態把 bak 收進去(模擬舊版 daemon), +// 再補上 config.edn(佐證齊了)——3,620/4,193=86% 的檔在同一輪從現況消失。 +func TestScan_策略不再收的檔照下架且不觸發大量刪除防呆(t *testing.T) { + root, bakFiles := makeLogseqGraph(t, false) + // pages/journals 在,logseq/ 沒有 config.edn ⇒ 佐證仍成立(journals/ 或 pages/ 存在就算)。 + // 所以要模擬「舊版把 bak 收進去」得直接造 manifest,不能靠舊判準。 + m := newTestManifest() + first := mustScan(t, root, m) + for _, rel := range bakFiles { + abs := filepath.Join(root, filepath.FromSlash(rel)) + h, err := hashFile(abs) + if err != nil { + t.Fatal(err) + } + m.Entries[rel] = &ManifestEntry{ContentHash: h, Size: 1, Mtime: baseTime.Unix()} + } + markIngested(m) + if n := len(m.Entries); n != 3+len(bakFiles) { + t.Fatalf("前置:manifest 應有 %d 份,實得 %d(第一輪事件 %d)", 3+len(bakFiles), n, len(first.Events)) + } + + p := mustScan(t, root, m) + + removed := eventsOfType(p, "removed") + if len(removed) != len(bakFiles) { + t.Fatalf("殘影 %d 份都要下架,實得 removed %d:%+v", len(bakFiles), len(removed), removed) + } + for _, ev := range removed { + if !strings.HasPrefix(ev.Path, "logseq/") { + t.Fatalf("下架的不該是筆記本體:%s", ev.Path) + } + } + for _, w := range p.Warnings { + if w.Code == "mass_delete_guard" { + t.Fatalf("這不是資料夾未掛載,是我們自己不收了——防呆不該響:%+v", w) + } + } + var retired *Warning + for i := range p.Warnings { + if p.Warnings[i].Code == "plan_retired" { + retired = &p.Warnings[i] + } + } + if retired == nil { + t.Fatalf("下架了 %d 份卻沒告訴使用者為什麼(票上的紅線:不要讓用戶猜):%+v", len(removed), p.Warnings) + } + if retired.RemovedCount != len(bakFiles) || !strings.Contains(retired.Message, "Logseq") { + t.Fatalf("警告要說出份數與理由:%+v", *retired) + } + for _, rel := range bakFiles { + if _, still := m.Entries[rel]; still { + t.Fatalf("殘影 %s 不該還留在 manifest(下架成功後 direct.go 才刪;Scan 這一層先拿掉)", rel) + } + } + if len(m.Entries) != 3 { + t.Fatalf("筆記本體 3 份要留著,實得 %d", len(m.Entries)) + } + // 殘影的內容雜湊不准跟新檔配成 renamed——那會讓殘影換個名字留在雲端。 + writeFile(t, root, "pages/新頁.md", "- 舊版殘影 "+bakFiles[0]+"\n", baseTime) + p2 := mustScan(t, root, m) + if n := len(eventsOfType(p2, "renamed")); n != 0 { + t.Fatalf("殘影不該被當成搬走的檔:%+v", p2.Events) + } +} + +// ②' 同一輪裡「策略不收」與「真的不見」要各管各的:前者照下架,後者仍受防呆管。 +func TestScan_策略不再收與真的不見同時發生時各管各的(t *testing.T) { + root, bakFiles := makeLogseqGraph(t, false) + m := newTestManifest() + mustScan(t, root, m) + for _, rel := range bakFiles { + h, err := hashFile(filepath.Join(root, filepath.FromSlash(rel))) + if err != nil { + t.Fatal(err) + } + m.Entries[rel] = &ManifestEntry{ContentHash: h, Size: 1, Mtime: baseTime.Unix()} + } + markIngested(m) + // 筆記本體 3 份全部消失(像資料夾沒掛載)。防呆的分母是整份 manifest(3+5=8), + // 3/8=37.5%——刻意把門檻設在 30%,讓「真的不見的那 3 份」單獨就超過門檻, + // 而殘影那 5 份不管多少都不該被算進去。 + for _, rel := range []string{"pages/index.md", "pages/擋風防雨車.md", "journals/2026_09_04.md"} { + if err := os.Remove(filepath.Join(root, filepath.FromSlash(rel))); err != nil { + t.Fatal(err) + } + } + p, err := Scan(root, m, ScanOptions{MaxRemovedRatio: 0.3}) + if err != nil { + t.Fatal(err) + } + + removed := eventsOfType(p, "removed") + if len(removed) != len(bakFiles) { + t.Fatalf("只有殘影該下架(%d),真的不見的 3 份要被防呆壓住;實得 %+v", len(bakFiles), removed) + } + codes := map[string]bool{} + for _, w := range p.Warnings { + codes[w.Code] = true + } + if !codes["mass_delete_guard"] || !codes["plan_retired"] { + t.Fatalf("兩種警告都要有:%+v", p.Warnings) + } + for _, rel := range []string{"pages/index.md", "pages/擋風防雨車.md", "journals/2026_09_04.md"} { + if _, kept := m.Entries[rel]; !kept { + t.Fatalf("防呆觸發時真的不見的 %s 要留在 manifest 等下輪重評", rel) + } + } +} + +// ③ 資料夾從樹上消失 ⇒ 雲端那張資料夾卡排進待撤清單(以前只刪本機記帳,雲端永遠留著)。 +func TestFolderCard_消失的資料夾卡要排進待撤清單(t *testing.T) { + root := "/x/KB" + lib := "kb" + stale := "logseq/bak/pages/index" + m := &Manifest{ + Entries: ent("journals/2026_09_04.md"), + FolderCardHashes: map[string]string{ + "journals": "sha256:old", + stale: "sha256:old", + }, + } + cfg := &DirectConfig{Library: lib} + syncFolderCards(cfg, root, m, true, true /*dryRun*/, time.Now()) + + wantPath := folderCardPath(lib, stale) + page, queued := m.PendingTakedowns[wantPath] + if !queued { + t.Fatalf("消失的資料夾卡沒排進待撤清單:%+v", m.PendingTakedowns) + } + if page != "資料夾:KB/"+stale { + t.Fatalf("撤的頁名要跟當初送上去的一樣:%q", page) + } + if _, still := m.FolderCardHashes[stale]; still { + t.Fatal("本機記帳也要清掉") + } + if _, wrong := m.PendingTakedowns[folderCardPath(lib, "journals")]; wrong { + t.Fatal("還在的資料夾不該被撤") + } + + // 整棵樹都不收了(一張卡都沒有)更要撤——這一段不能被「沒卡就提早返回」擋住。 + m2 := &Manifest{ + Entries: map[string]*ManifestEntry{}, + FolderCardHashes: map[string]string{stale: "sha256:old"}, + } + syncFolderCards(cfg, root, m2, true, true, time.Now()) + if _, queued := m2.PendingTakedowns[wantPath]; !queued { + t.Fatalf("樹空了也要撤:%+v", m2.PendingTakedowns) + } +} + +// ③' 待撤清單套單輪上限:上千張資料夾卡不能一輪撤完(會撞單輪等待上限),沒撤到的下輪接著撤。 +func TestDrainPendingTakedowns_套單輪上限且順序固定(t *testing.T) { + m := &Manifest{PendingTakedowns: map[string]string{}} + for _, k := range []string{"c", "a", "b", "d", "e"} { + m.QueueTakedown(k+".md", k) + } + cfg := &DirectConfig{} + res, _ := drainPendingTakedowns(cfg, m, "/x", "renamed_takedown", "", func() {}, true, func() {}, 2) + var planned []string + var info string + for _, r := range res { + if r.Status == "planned" { + planned = append(planned, r.Path) + } + if r.Type == "info" { + info = r.Error + } + } + if len(planned) != 2 || planned[0] != "a.md" || planned[1] != "b.md" { + t.Fatalf("上限 2 且要照固定順序:%v", planned) + } + if !strings.Contains(info, "3") { + t.Fatalf("要告訴使用者還剩幾筆:%q", info) + } + if len(m.PendingTakedowns) != 5 { + t.Fatal("dry-run 不該動清單") + } + // 0=不限(移除資料夾那條路維持原行為)。 + res0, _ := drainPendingTakedowns(cfg, m, "/x", "folder_takedown", "", func() {}, true, func() {}, 0) + if len(res0) != 5 { + t.Fatalf("0 應該不限,實得 %d", len(res0)) + } +} diff --git a/probe_workersai.go b/probe_workersai.go index 84d2761..565a9e7 100644 --- a/probe_workersai.go +++ b/probe_workersai.go @@ -53,15 +53,21 @@ type CloudAIState struct { // - 401 代表 route 在、只是金鑰不對 ⇒ route 存在,同樣算「雲端有這功能」, // 金鑰問題由既有的連線流程去報,不混在這裡講。 func ProbeWorkersAI(ctx context.Context, cypherURL, apiKey string) (CloudAIState, error) { + state, _, err := probeWorkersAIWithStatus(ctx, cypherURL, apiKey) + return state, err +} + +// probeWorkersAIWithStatus=ProbeWorkersAI,多回 HTTP 狀態碼(0=沒拿到回應)給路由退避用(#121)。 +func probeWorkersAIWithStatus(ctx context.Context, cypherURL, apiKey string) (CloudAIState, int, error) { base := strings.TrimSuffix(strings.TrimSpace(cypherURL), "/") if base == "" { - return CloudAIState{Ready: false, Note: "還沒連上知識庫"}, nil + return CloudAIState{Ready: false, Note: "還沒連上知識庫"}, 0, nil } body, _ := json.Marshal(map[string]string{"page_name": "", "text": ""}) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/portal/daemon/extract", bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, workersAIExtractURL(base), bytes.NewReader(body)) if err != nil { - return CloudAIState{Ready: false, Note: "雲端 AI 狀態查不到"}, nil + return CloudAIState{Ready: false, Note: "雲端 AI 狀態查不到"}, 0, nil } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Arcrun-API-Key", strings.TrimSpace(apiKey)) @@ -72,7 +78,7 @@ func ProbeWorkersAI(ctx context.Context, cypherURL, apiKey string) (CloudAIState // 前者要記進斷路器,後者不該(不然一次斷網就把帳號判成停機)。 // 連不上(離線/網路問題)≠ 雲端沒裝。講「查不到」而不是「還沒通」, // 免得把網路問題誤報成「你沒更新」讓用戶白跑一趟。 - return CloudAIState{Ready: false, Note: "連不上你的知識庫,雲端 AI 狀態查不到"}, err + return CloudAIState{Ready: false, Note: "連不上你的知識庫,雲端 AI 狀態查不到"}, 0, err } defer resp.Body.Close() @@ -81,15 +87,15 @@ func ProbeWorkersAI(ctx context.Context, cypherURL, apiKey string) (CloudAIState return CloudAIState{ Ready: false, Note: "雲端 AI 還沒通 ⇒ 你的知識庫是舊版,請到 portal 按「立即更新」重裝一次", - }, nil + }, resp.StatusCode, nil case resp.StatusCode < 500: // 200/400/401… 都代表這條 route 存在=雲端有這個功能。 - return CloudAIState{Ready: true}, nil + return CloudAIState{Ready: true}, resp.StatusCode, nil default: return CloudAIState{ Ready: false, Note: fmt.Sprintf("雲端 AI 暫時有狀況(HTTP %d),稍後會自動再試", resp.StatusCode), - }, nil + }, resp.StatusCode, nil } } @@ -103,8 +109,23 @@ func (c *DirectConfig) probeWorkersAI() CloudAIState { if note := c.unreachableNote(); note != "" { return CloudAIState{Ready: false, Note: note} } + // #121:萃取那條路正在退避 ⇒ 探測也不打(打的是同一個端點),直接講退避的原因。 + extractURL := workersAIExtractURL(c.CypherURL) + if note := c.routeNote(extractURL); note != "" { + return CloudAIState{Ready: false, Note: note} + } + // #121:一分鐘內問過就沿用(見 cloudcheck.go)——路由退避讓一輪變短之後, + // 這一發原本會變成每 5 秒打一次。 + if st, ok := cachedAI(c.CypherURL, c.ForceSync); ok { + return st + } gate := c.openGate(stepProbeAI) - state, err := ProbeWorkersAI(gate.ctx, c.CypherURL, c.APIKey) + state, status, err := probeWorkersAIWithStatus(gate.ctx, c.CypherURL, c.APIKey) + // #121:探測**只記失敗、不記成功**。空 text 的探測通了,不代表真的萃取通了—— + // 讓它歸零的話,每輪開頭一發探測就會把萃取累積的失敗清掉,退避永遠不會生效。 + if err != nil || status >= 500 || status == 429 { + cloudRoutes.record(extractURL, directNow(), status, err, false) + } gate.release() if err == nil { gate.ok() @@ -114,5 +135,8 @@ func (c *DirectConfig) probeWorkersAI() CloudAIState { // 把等待誤報成「你沒更新」會害使用者白跑一趟去按重裝。 return CloudAIState{Ready: false, Note: perr.Error()} } + if err == nil { + rememberAI(c.CypherURL, state) + } return state } diff --git a/quota.go b/quota.go index 49a98e0..423988b 100644 --- a/quota.go +++ b/quota.go @@ -51,6 +51,11 @@ type QuotaNotice struct { ExitOptions string `json:"exit_options"` // 可以換一個模型,或升級 Cloudflare(每月 5 美元) Guarantee string `json:"guarantee"` // 不花錢也沒關係,今天/明天早上 8:00 會自動恢復 ResumeAt string `json:"resume_at"` // RFC3339,預期恢復時間(供機器判斷冷卻是否結束) + // arcrun-rag#197:哪一種額度。空=Workers AI(舊的三句話,status.json 向後相容); + // d1_read/d1_write=雲端資料庫(見 cloudquota.go)。畫面依它決定標題,不猜字串。 + Kind string `json:"kind,omitempty"` + Headline string `json:"headline,omitempty"` // 一句話講「哪一種額度用完了」 + Usage string `json:"usage,omitempty"` // 上限多少、用了多少(查得到時;查不到也照講上限) } // Combined 把三句話接成一句完整訊息(給只有單一 error 欄位可用的地方,如 DirectResult.Error)。 diff --git a/route_backoff_measure_test.go b/route_backoff_measure_test.go new file mode 100644 index 0000000..6e24d3a --- /dev/null +++ b/route_backoff_measure_test.go @@ -0,0 +1,364 @@ +// route_backoff_measure_test.go — `inkstone/arcrun-rag#121`(comment 6923)的量測與驗收。 +// +// 情境=2026-09-13 leo Mac 的實況:雲端「收卡」那條路(rag_ingest_card)持續回 HTTP 500 +// (`Node list_old_blocks failed: 缺少 credential: kbdb_internal_token`),而每一發都是 +// 雲端的全表掃。其他端點(萃取、資料夾樹、對帳)都正常。 +// +// 量的是:假時鐘每 5 秒跑一輪(daemon 預設 poll_interval_sec),跑滿 N 分鐘, +// 各類雲端請求一共打了幾發。**假伺服器=只數請求**,不碰任何真實例。 +// +// 🔴 這裡的 5 秒是**下限**:真實一輪還要加上本身的耗時(leo Mac 上約 15 秒一輪), +// 所以同樣 N 分鐘,真機的輪數比這裡少——這份數字是「最壞情況」,不是真機的精確值。 +// +// 修前基準(commit 7a3eb2c,同一支量測、同一個情境): +// +// 1 分鐘( 12 輪):收卡路由 151 發 +// 10 分鐘(120 輪):收卡路由 1291 發 +// 60 分鐘(720 輪):收卡路由 7326 發,萃取 120 次(同一批檔反覆重萃) +package collector + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +type routeCounts struct { + Rounds int + CardRoute int // rag_ingest_card 收到幾發(壞掉那段時間的每一發=雲端一次全表掃) + CardOK map[string]bool // 雲端恢復後,哪些 path 真的送成功了 + Extract int // /portal/daemon/extract 真的萃取(燒 AI 額度) + Probe int // /portal/daemon/extract 空探測 + Tree int // /portal/daemon/folder-tree + Audit int // /kbdb/entries + Other int // 含 /health(每輪固定問的版本) +} + +func (c routeCounts) Total() int { + return c.CardRoute + c.Extract + c.Probe + c.Tree + c.Audit + c.Other +} + +// runCardRouteDownScenario 建一個「30 份新檔+10 份待修出處的舊卡」的資料夾, +// 讓收卡路由在前 downFor 內持續 500(之後恢復),模擬 minutes 分鐘(每 pollSec 秒一輪)。 +func runCardRouteDownScenario(t *testing.T, minutes, pollSec int, downFor time.Duration) routeCounts { + return runRouteDownScenario(t, minutes, pollSec, downFor, "ok") +} + +// runRouteDownScenario:extractMode="ok"|"5xx"(萃取端點恆回 503,含空探測)|"refuse"(萃取端點連線直接被切斷=連不上)。 +func runRouteDownScenario(t *testing.T, minutes, pollSec int, downFor time.Duration, extractMode string) routeCounts { + t.Helper() + cloudRoutes.reset() + defer cloudRoutes.reset() + // 量測要用正式值:每輪固定問雲端的兩件事(版本、雲端 AI)一分鐘只問一次(cloudcheck.go)。 + withCloudCheckInterval(t, 60*time.Second) + + root := t.TempDir() + for i := 0; i < 30; i++ { + name := fmt.Sprintf("筆記%02d", i) + writeFile(t, root, name+".md", "# "+name+"\n\n內容 "+name, baseTime.Add(time.Duration(i)*time.Minute)) + } + // 10 份舊形出處的卡(Arcrun#167 的就地修正會想重推它們)。 + var docs []wikiDoc + for i := 0; i < 10; i++ { + rel := fmt.Sprintf("舊資料/.wiki/舊檔%02d.md", i) + abs := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(abs, []byte(legacyCard), 0o644); err != nil { + t.Fatal(err) + } + docs = append(docs, wikiDoc{Node: "舊資料", Path: fmt.Sprintf("舊檔%02d.pdf", i), + Status: "extracted", Card: fmt.Sprintf("舊檔%02d", i), Cards: []string{rel}}) + } + if err := saveWikiManifest(root, &wikiManifest{Version: 1, Docs: docs}); err != nil { + t.Fatal(err) + } + + start := time.Date(2026, 9, 13, 1, 0, 0, 0, time.UTC) + var clockNs atomic.Int64 + clockNs.Store(start.UnixNano()) + oldNow := directNow + directNow = func() time.Time { return time.Unix(0, clockNs.Load()).UTC() } + defer func() { directNow = oldNow }() + + var mu sync.Mutex + c := routeCounts{CardOK: map[string]bool{}} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + mu.Lock() + defer mu.Unlock() + switch { + case strings.HasSuffix(r.URL.Path, "/rag_ingest_card/trigger"): + c.CardRoute++ + if time.Unix(0, clockNs.Load()).Before(start.Add(downFor)) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"success":false,"error":"Node list_old_blocks failed: 缺少 credential: kbdb_internal_token"}`)) + return + } + var req struct { + Path string `json:"path"` + } + _ = json.Unmarshal(body, &req) + c.CardOK[req.Path] = true + _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) + case strings.HasSuffix(r.URL.Path, "/portal/daemon/extract"): + var req struct { + PageName string `json:"page_name"` + Text string `json:"text"` + } + _ = json.Unmarshal(body, &req) + mode := extractMode + if mode == "5xx-10m" { // 前 10 分鐘 503,之後恢復 + mode = "ok" + if time.Unix(0, clockNs.Load()).Before(start.Add(10 * time.Minute)) { + mode = "5xx" + } + } + switch mode { + case "5xx": + if strings.TrimSpace(req.Text) == "" { + c.Probe++ + } else { + c.Extract++ + } + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"success":false,"error":"upstream unavailable"}`)) + return + case "refuse": + if strings.TrimSpace(req.Text) == "" { + c.Probe++ + } else { + c.Extract++ + } + if hj, ok := w.(http.Hijacker); ok { + if conn, _, err := hj.Hijack(); err == nil { + _ = conn.Close() // 沒有任何回應就斷線=client 端拿到連線層錯誤 + return + } + } + w.WriteHeader(http.StatusBadGateway) + return + } + if strings.TrimSpace(req.Text) == "" { + c.Probe++ + _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) + return + } + c.Extract++ + subject := req.PageName + if subject == "" { + subject = "未命名" + } + _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "output": cardFixture(subject, "測試")}) + case strings.HasSuffix(r.URL.Path, "/portal/daemon/folder-tree"): + c.Tree++ + _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) + case strings.HasSuffix(r.URL.Path, "/kbdb/entries"): + c.Audit++ + _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "total": 1}) + default: + c.Other++ + _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) + } + })) + defer srv.Close() + + cfg := &DirectConfig{ + Manifest: filepath.Join(t.TempDir(), "m.json"), + CardIngestWF: "rag_ingest_card", IngestWF: "rag_ingest_direct", RemovedWF: "rag_takedown_direct", + Extractor: "workers-ai", ExtractorExplicit: true, + MachineLabel: "量測機", + MaxRemoved: DefaultMaxRemovedRatio, + Accounts: []AccountConfig{{ + CypherURL: srv.URL, Namespace: "demo", APIKey: "demo", + WatchFolders: []string{root}, + }}, + } + end := start.Add(time.Duration(minutes) * time.Minute) + for now := start; now.Before(end); now = now.Add(time.Duration(pollSec) * time.Second) { + clockNs.Store(now.UnixNano()) + RunDirectOnce(cfg, false) + c.Rounds++ + } + mu.Lock() + defer mu.Unlock() + return c +} + +// TestCardRouteDown_Measure:雲端收卡路由**從頭壞到尾**,daemon 打上去的請求數。 +// 情境與修前基準(檔頭)逐字相同,數字可以直接對照。 +func TestCardRouteDown_Measure(t *testing.T) { + // 修前:151/1291/7326。上限=門檻 4 發(第一輪內停下)+每個退避窗口到期各 1 發,再留一點餘裕。 + limits := map[int]int{1: 5, 10: 10, 60: 15} + for _, minutes := range []int{1, 10, 60} { + c := runCardRouteDownScenario(t, minutes, 5, 24*time.Hour) + t.Logf("收卡路由持續 500,模擬 %2d 分鐘(%3d 輪):收卡路由 %4d 發|萃取 %3d|探測 %3d|資料夾樹 %2d|對帳 %3d|其他 %d|合計 %4d", + minutes, c.Rounds, c.CardRoute, c.Extract, c.Probe, c.Tree, c.Audit, c.Other, c.Total()) + if c.CardRoute > limits[minutes] { + t.Errorf("%d 分鐘內打壞掉那條路 %d 發,超過 %d——退避沒有生效", minutes, c.CardRoute, limits[minutes]) + } + // 送不出去就不該反覆萃:30 份新檔最多各萃一次(第一份是撞牆的那一發)。 + if c.Extract > 30 { + t.Errorf("%d 分鐘內萃取 %d 次,同一批檔被重複燒 AI 額度", minutes, c.Extract) + } + } +} + +// TestExtractRouteDown_Measure:`inkstone/arcrun-rag#121` comment 6960 補的驗收—— +// 萃取端點(/portal/daemon/extract,不經 postJSON)持續 5xx/連不上,收卡路由正常。 +// 修前基準見 comment(同一支情境在 7a3eb2c 上跑)。 +func TestExtractRouteDown_Measure(t *testing.T) { + limits := map[int]int{1: 6, 10: 12, 60: 16} // 萃取+探測合計;與收卡路由同一個量級 + for _, mode := range []string{"5xx", "refuse"} { + for _, minutes := range []int{1, 10, 60} { + c := runRouteDownScenario(t, minutes, 5, 0, mode) + t.Logf("萃取端點持續 %-6s,模擬 %2d 分鐘(%3d 輪):萃取 %4d 發|探測 %4d|萃取端點合計 %4d|收卡路由 %3d|資料夾樹 %d|其他 %d", + mode, minutes, c.Rounds, c.Extract, c.Probe, c.Extract+c.Probe, c.CardRoute, c.Tree, c.Other) + if got := c.Extract + c.Probe; got > limits[minutes] { + t.Errorf("%s/%d 分鐘:打萃取端點 %d 發,超過 %d——萃取那條路沒有退避", mode, minutes, got, limits[minutes]) + } + } + } +} + +// TestExtractRouteDown_RecoversByItself:萃取端點壞 10 分鐘後恢復——不重開、不按按鈕, +// 30 份新檔都要自己萃完並送上去(退避不准變成「永久放棄」,也不准把檔累積到「連續失敗 8 次已暫停」)。 +func TestExtractRouteDown_RecoversByItself(t *testing.T) { + c := runRouteDownScenario(t, 60, 5, 0, "5xx-10m") + t.Logf("萃取端點壞 10 分鐘後恢復,模擬 60 分鐘:萃取 %d 發|探測 %d|送成功的 path %d 個", + c.Extract, c.Probe, len(c.CardOK)) + var missing []string + for i := 0; i < 30; i++ { + if p := fmt.Sprintf("筆記%02d.md", i); !c.CardOK[p] { + missing = append(missing, p) + } + } + if len(missing) > 0 { + t.Fatalf("萃取端點恢復 50 分鐘後仍有 %d 份沒送上去:%v", len(missing), missing) + } +} + +// TestCardRouteDown_RecoversByItself:壞 10 分鐘之後雲端修好了—— +// 不重開小幫手、不按任何按鈕,所有該送的東西都要自己送上去(退避不准變成「永久放棄」)。 +func TestCardRouteDown_RecoversByItself(t *testing.T) { + c := runCardRouteDownScenario(t, 60, 5, 10*time.Minute) + t.Logf("壞 10 分鐘後恢復,模擬 60 分鐘:收卡路由 %d 發|送成功的 path %d 個|萃取 %d", + c.CardRoute, len(c.CardOK), c.Extract) + var missing []string + for i := 0; i < 30; i++ { + if p := fmt.Sprintf("筆記%02d.md", i); !c.CardOK[p] { + missing = append(missing, p) + } + } + for i := 0; i < 10; i++ { + if p := fmt.Sprintf("舊資料/舊檔%02d.pdf", i); !c.CardOK[p] { + missing = append(missing, p) + } + } + if len(missing) > 0 { + t.Fatalf("雲端恢復 50 分鐘後仍有 %d 份沒送上去:%v", len(missing), missing) + } +} + +// ── 退避本身的規則 ──────────────────────────────────────────────────────── + +func TestRouteBreaker_Rules(t *testing.T) { + b := &routeBreaker{routes: map[string]*routeState{}} + u := "https://x.workers.dev/webhooks/named/ns/rag_ingest_card/trigger" + other := "https://x.workers.dev/webhooks/named/ns/rag_takedown_direct/trigger" + t0 := time.Date(2026, 9, 13, 0, 0, 0, 0, time.UTC) + + // 2xx(即使 body 寫工作流失敗,postJSON 也只把狀態碼交進來)與一般 4xx 不開閘。 + b.record(u, t0, 200, nil, false) + for i := 0; i < 10; i++ { + b.record(u, t0, 400, nil, false) + } + if n := b.note(u, t0); n != "" { + t.Fatalf("2xx/400 不該讓整條路退避:%q", n) + } + + // 有前科的內容再失敗:不算數。 + for i := 0; i < 10; i++ { + b.record(u, t0, 500, nil, true) + } + if n := b.note(u, t0); n != "" { + t.Fatalf("先前就失敗過的檔再失敗,不准把整條路關掉:%q", n) + } + + // 第一次送的內容連續失敗:前 3 發照常重試,第 4 發 ⇒ 停 1 分鐘;只停這一條,別條路照走。 + for i := 0; i < routeStrikesBeforeBackoff-1; i++ { + b.record(u, t0, 500, nil, false) + } + if n := b.note(u, t0); n != "" { + t.Fatalf("單次(未達門檻)的 500 下一輪要照常重試:%q", n) + } + b.record(u, t0, 500, nil, false) + n := b.note(u, t0.Add(30*time.Second)) + if n == "" || !strings.Contains(n, "rag_ingest_card") { + t.Fatalf("500 之後應該退避並講出是哪條路:%q", n) + } + if !explainsWhySkipped(n) { + t.Fatalf("退避訊息要講得出原因(sync_status.go 的識別字):%q", n) + } + if strings.Contains(n, "500") || strings.Contains(n, "HTTP") { + t.Fatalf("畫面上的話不准裸露狀態碼:%q", n) + } + if b.note(other, t0) != "" { + t.Fatal("一條路壞了不該牽連同台知識庫的其他路") + } + if b.note(u, t0.Add(time.Minute)) != "" { + t.Fatal("窗口到了就要放一發試試") + } + + // 連續失敗 ⇒ 窗口變長;429 與連線錯誤也算。 + b.record(u, t0.Add(time.Minute), 429, nil, false) + if b.note(u, t0.Add(time.Minute+90*time.Second)) == "" { + t.Fatal("窗口到期後再失敗一發,應該停 2 分鐘") + } + b.record(u, t0.Add(3*time.Minute), 0, errors.New("dial tcp: connection refused"), false) + if b.note(u, t0.Add(3*time.Minute+4*time.Minute)) == "" { + t.Fatal("再失敗一發應該停 5 分鐘") + } + // 一發成功 ⇒ 全部歸零:之後單次 500 不會立刻停。 + b.record(u, t0.Add(9*time.Minute), 200, nil, false) + b.record(u, t0.Add(9*time.Minute), 500, nil, false) + if b.note(u, t0.Add(9*time.Minute)) != "" { + t.Fatal("成功一次後計數要歸零,重新累積到門檻前不該停") + } + // 上限 30 分鐘。 + for i := 0; i < 20; i++ { + b.record(u, t0, 500, nil, false) + } + if b.note(u, t0.Add(30*time.Minute)) != "" { + t.Fatal("退避上限是 30 分鐘,雲端修好後最慢半小時要自己接上") + } +} + +// 「立刻同步」=使用者明確要求這一輪照打(與逐檔退避同一個語意)。 +func TestRouteBreaker_ForceSyncBypasses(t *testing.T) { + cloudRoutes.reset() + defer cloudRoutes.reset() + u := "https://y.workers.dev/webhooks/named/ns/rag_ingest_card/trigger" + for i := 0; i < routeStrikesBeforeBackoff; i++ { + cloudRoutes.record(u, directNow(), 503, nil, false) + } + cfg := &DirectConfig{} + if cfg.routeNote(u) == "" { + t.Fatal("503 之後應該在退避中") + } + cfg.ForceSync = true + if cfg.routeNote(u) != "" { + t.Fatal("按了「立刻同步」這一輪要照打") + } +} diff --git a/routebackoff.go b/routebackoff.go new file mode 100644 index 0000000..ba4819c --- /dev/null +++ b/routebackoff.go @@ -0,0 +1,186 @@ +// routebackoff.go — 雲端某條路持續失敗時,跨輪退避(`inkstone/arcrun-rag#121` comment 6923)。 +// +// 病(2026-09-13 leo Mac 實測):雲端 rag_ingest_card 每一發都回 HTTP 500 +// (`list_old_blocks` 缺 credential),而那個節點是全表掃。daemon 每 5 秒一輪、 +// 每一輪都把同一面牆重撞一次:出處修正(Arcrun#167)一輪最多 20 發、資料夾索引一發、 +// 每個新檔再各一發——youlin 的免費 D1 一小時被讀掉 600 萬列、當天知識庫停擺。 +// +// 既有的閘為什麼都沒擋住: +// - 逐檔退避(manifest FailCount)管的是「這個檔壞了」,一批沒試過的新檔每個都會先撞一次; +// - roundGuard(stallguard.go)只管「等到超時」,而且每輪歸零; +// - 額度冷卻只認得 Workers AI 的額度訊息。 +// +// ⇒ 缺的是「**這條路**壞了」這一層:同一台知識庫的同一個端點連續失敗, +// +// 就整條路停一段時間,時間一次比一次長;有一發成功就立刻恢復。 +// +// 判準(什麼算「路壞了」): +// - HTTP 5xx、429、連不上/逾時 ⇒ 算。 +// - HTTP 2xx 但 body 裡寫著工作流失敗 ⇒ **不算**:路是通的,失敗可能只屬於那一份內容, +// 把它算成整條路壞掉會讓一份怪檔拖住所有人(逐檔退避會照顧它)。 +// - 其他 4xx ⇒ 不算(多半是那一發本身的問題,不是路的問題)。 +// +// 🔴 什麼時候判「路壞了」(而不是「那一份內容壞了」): +// - 單看狀態碼分不出來——工作流任何節點失敗都回 500,不管是路壞還是那張卡怪。 +// - 所以要**連續 routeStrikesBeforeBackoff 發「第一次送」的請求都失敗**才停。 +// 「第一次送」=逐檔病歷上沒有失敗紀錄的檔,以及出處修正/總覽/目錄索引/下架這類系統件。 +// - 已經失敗過的檔再重試又失敗 ⇒ **不算數也不歸零**:那是它自己的病(逐檔退避在管), +// 不准拿它把整條路關掉——否則一池壞檔會輪流把健康的檔擋在外面(t217 餓死病換個形狀回來)。 +// +// 使用者按「立刻同步」(ForceSync)=這一輪不看退避,照打(與逐檔退避同一個語意)。 +// +// 狀態只活在這個行程裡:行程重開=從零開始,最壞代價是重開後第一輪多撞一發就重新停下。 +package collector + +import ( + "fmt" + "net/url" + "strings" + "sync" + "time" +) + +// routeBackoffLadder=同一條路連續失敗第 N 次之後要停多久。 +// 第一格 1 分鐘:一次偶發的 500 不該讓使用者等太久; +// 上限 30 分鐘:雲端修好之後,最慢半小時內自己接上,不必重開小幫手。 +var routeBackoffLadder = []time.Duration{ + 1 * time.Minute, 2 * time.Minute, 5 * time.Minute, 10 * time.Minute, 30 * time.Minute, +} + +// routeStrikesBeforeBackoff=同一條路連續幾發「第一次送」都失敗,才判定是路壞了。 +// +// 為什麼是 4 而不是 1:一次 500 可能只是那一份內容的問題,下一輪就該照常重試 +// (既有測試守著:下架失敗下一輪補、收回失敗下一輪補、總覽退避到期就補、 +// 單輪上限 3 份全是壞檔時健康檔下一輪要遞補得上來)。 +// 為什麼不是更多:2026-09-13 的實況,一輪開頭的出處修正+總覽+目錄索引+第一個新檔 +// 就是 4 發——第一輪之內就停得下來。之後每次窗口到期只會再撞 1 發。 +const routeStrikesBeforeBackoff = 4 + +type routeState struct { + fails int + until time.Time +} + +type routeBreaker struct { + mu sync.Mutex + routes map[string]*routeState +} + +// cloudRoutes=整個行程共用的一份(跨輪、跨帳號;key 自帶主機名,帳號之間不會互相牽連)。 +var cloudRoutes = &routeBreaker{routes: map[string]*routeState{}} + +// routeBackoffError=「這一發根本沒打出去,因為這條路正在退避」。 +// 呼叫端用 isRouteBackoff 認它:這不是那個檔的錯,不准記進逐檔病歷。 +type routeBackoffError struct{ note string } + +func (e *routeBackoffError) Error() string { return e.note } + +func isRouteBackoff(err error) bool { + _, ok := err.(*routeBackoffError) + return ok +} + +// routeKey=主機+路徑(不含查詢字串)。同一台知識庫的不同工作流是不同的路。 +func routeKey(raw string) string { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return raw + } + return u.Host + u.Path +} + +// routeLabel=給人看的「哪一條路」。只取工作流名,不出現網址與狀態碼。 +func routeLabel(raw string) string { + u, err := url.Parse(raw) + if err != nil { + return "雲端" + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + // /webhooks/named///trigger + if len(parts) >= 5 && parts[0] == "webhooks" && parts[len(parts)-1] == "trigger" { + return parts[len(parts)-2] + } + switch u.Path { + case "/portal/daemon/extract": + return "雲端 AI 整理文件" + case "/portal/daemon/folder-tree": + return "回報資料夾結構" + } + return u.Path +} + +// note 回「這條路現在正在退避」的白話;空=可以打。 +func (b *routeBreaker) note(raw string, now time.Time) string { + b.mu.Lock() + defer b.mu.Unlock() + st := b.routes[routeKey(raw)] + if st == nil || !now.Before(st.until) { + return "" + } + wait := st.until.Sub(now).Round(time.Second) + // 🔴 結尾「稍後會自動恢復」是 explainsWhySkipped 的識別字(sync_status.go),不要改掉。 + // 不帶上游原文(HTTP 碼/JSON):這句會出現在畫面上;原文在失敗那一發的結果裡已經留著。 + return fmt.Sprintf("雲端「%s」這條路連續失敗 %d 次,先停 %s 再試,避免一直重撞把雲端額度用光;稍後會自動恢復。", + routeLabel(raw), st.fails, humanWait(wait)) +} + +// record 記一發的結果。status=HTTP 狀態碼(0=沒拿到回應);transportErr=連線層錯誤; +// retry=這份內容先前就失敗過(逐檔病歷上有紀錄)——它失敗不算路壞,見檔頭。 +func (b *routeBreaker) record(raw string, now time.Time, status int, transportErr error, retry bool) { + failed := transportErr != nil || status >= 500 || status == 429 + b.mu.Lock() + defer b.mu.Unlock() + key := routeKey(raw) + if !failed { + if status >= 200 && status < 300 { + delete(b.routes, key) // 通了就全部歸零 + } + return + } + if retry { + return // 有前科的內容再失敗:它自己的病,不算數也不歸零 + } + st := b.routes[key] + if st == nil { + st = &routeState{} + b.routes[key] = st + } + st.fails++ + if st.fails < routeStrikesBeforeBackoff { + return + } + idx := st.fails - routeStrikesBeforeBackoff + if idx >= len(routeBackoffLadder) { + idx = len(routeBackoffLadder) - 1 + } + st.until = now.Add(routeBackoffLadder[idx]) +} + +// reset 清空全部狀態(測試用)。 +func (b *routeBreaker) reset() { + b.mu.Lock() + defer b.mu.Unlock() + b.routes = map[string]*routeState{} +} + +func humanWait(d time.Duration) string { + if d >= time.Minute { + return fmt.Sprintf("%d 分鐘", int((d+time.Minute-1)/time.Minute)) + } + return fmt.Sprintf("%d 秒", int(d/time.Second)) +} + +// routeNote=打某條路之前先問「它現在在退避嗎」。按「立刻同步」的這一輪一律照打。 +// +// arcrun-rag#197:雲端資料庫今天的免費額度用完 ⇒ 這台知識庫的每一條路都不打, +// **連「立刻同步」也不打**——恢復前每一發只會拿到同一個錯;而「立刻同步」那一輪 +// cloudVersionThrottled 會強制重問 /health,額度真的恢復了這裡自然就放行。 +func (c *DirectConfig) routeNote(raw string) string { + if note := d1QuotaNote(c.CypherURL, directNow()); note != "" { + return note + } + if c.ForceSync { + return "" + } + return cloudRoutes.note(raw, directNow()) +} diff --git a/scan.go b/scan.go index cff9b4e..a96df9c 100644 --- a/scan.go +++ b/scan.go @@ -535,8 +535,22 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) { // 3) 先配對 renamed(design §3 順序 1):removed×added 以 content_hash 配對, // 配上=只更新路徑映射,不 retire、不重萃、不重傳。同 hash 多候選→排序後貪婪配對(確定性)。 + // arcrun-rag#104(comment 6309):先把「manifest 有、現況沒有」的路徑分成兩種—— + // retired = 它還在磁碟上(或不在),但**現在的策略不收它**(例:補了「logseq/ 不收」那條) + // vanished= 策略會收它,它卻不見了(真的刪了、資料夾沒掛載) + // 兩種都要下架,差別在下面第 6 步:只有 vanished 受大量刪除防呆管; + // retired 是我們自己的決定,不是意外,要照下架,而且要講出為什麼(票上的紅線:排除規則要看得見)。 + retiredWhy := map[string]string{} + for _, p := range removedPaths { + if excluded, why := opts.Plan.ExcludesPathWhy(p, root); excluded { + retiredWhy[p] = why + } + } removedByHash := map[string][]string{} for _, p := range removedPaths { + if _, retired := retiredWhy[p]; retired { + continue // 不再收的檔不是「搬走了」,不准跟新檔配成 renamed(那會讓殘影換個名字留在雲端) + } h := orig[p].ContentHash removedByHash[h] = append(removedByHash[h], p) } @@ -604,9 +618,15 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) { } // 6) removed(扣掉已配對走的)+大量刪除防呆(R6)。 - var finalRemoved []string + var finalRemoved []string // 真的不見了的(受防呆管) + var retired []string // 策略不再收的(不受防呆管,一律下架) for _, p := range removedPaths { - if !pairedOld[p] { + if pairedOld[p] { + continue + } + if _, ok := retiredWhy[p]; ok { + retired = append(retired, p) + } else { finalRemoved = append(finalRemoved, p) } } @@ -628,6 +648,34 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) { events = append(events, Event{Type: "removed", Path: p, SourceHash: orig[p].ContentHash}) } } + // arcrun-rag#104(comment 6309):策略不再收的檔照下架,不看防呆——那道閘擋的是意外, + // 這是決定。實據:leo21c 的 KB 4,193 份裡 3,620 份是 `logseq/bak/` 殘影(86%), + // 防呆若管到它們,殘影就永遠留在雲端、警告每輪都響、而且看起來像資料夾壞了。 + // 但一定要講出來(同一張票的紅線「不要讓用戶猜」):多少份、為什麼、會發生什麼。 + if len(retired) > 0 { + reasonCount := map[string]int{} + for _, p := range retired { + reasonCount[retiredWhy[p]]++ + events = append(events, Event{Type: "removed", Path: p, SourceHash: orig[p].ContentHash}) + } + topReason, topN := "", 0 + for why, n := range reasonCount { + if n > topN || (n == topN && why < topReason) { + topReason, topN = why, n + } + } + msg := fmt.Sprintf("有 %d 份先前收過的檔,依現在的收檔規則不再收,會從知識庫下架(%d 份的理由:%s)", + len(retired), topN, topReason) + if len(reasonCount) > 1 { + msg += fmt.Sprintf(";另有 %d 種其他理由", len(reasonCount)-1) + } + warnings = append(warnings, Warning{ + Code: "plan_retired", + Message: msg, + RemovedCount: len(retired), + ManifestCount: manifestCountBefore, + }) + } // 7) 更新 manifest(rebuild):現況檔全數收錄;ingested_* 由舊 entry(或 renamed 的舊路徑)搬運。 // 防呆觸發時 removed 條目保留(下輪重評、警告會再響,直到人確認或檔案回來)。 diff --git a/sourcerepair.go b/sourcerepair.go index ce9ff44..c1e3c28 100644 --- a/sourcerepair.go +++ b/sourcerepair.go @@ -108,6 +108,12 @@ func repairCardSourceBlocks(cfg *DirectConfig, absRoot string, m *Manifest, dryR if m == nil || (!force && m.SourceOriginRepairedAt > 0) { return nil // 這個根已經修完,不再每輪掃 } + // 🔴 `inkstone/arcrun-rag#121`:收卡那條路正在退避 ⇒ 這輪整個不修。 + // 2026-09-13 實撞:這支每輪最多重推 20 份,而那條路每一發都是雲端全表掃, + // 失敗時又沒有任何閘 ⇒ 每 5 秒 20 發,是燒光 youlin D1 的主力。 + if !dryRun && !force && cfg.routeNote(cfg.triggerURL(cfg.CardIngestWF)) != "" { + return nil + } wm := loadWikiManifest(absRoot) if len(wm.Docs) == 0 { if !dryRun { @@ -181,7 +187,9 @@ func repairCardSourceBlocks(cfg *DirectConfig, absRoot string, m *Manifest, dryR res.Err = "重推知識庫失敗(HTTP " + itoa(status) + ")" } remaining++ // 這一份沒推成功 ⇒ 本機不動(留著舊形當重試訊號)、這個根不能蓋章 - continue + // #121:一份失敗就停手——後面那幾份打的是同一條路,繼續只是把同一面牆撞 20 次 + //(同 folderindex.go 的 t195 教訓)。沒修到的下一輪接著修。 + break } // ② 推成功了才寫本機(文件卡+概念卡)。