diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e52b57..3e1d43e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,7 +47,7 @@ --- -## 下一版(未發佈) +## 0.18.39(2026-08-27) - 🔴 **「已送上去」從今以後是真的送上去了**:以前只要雲端把請求收下來,小幫手就算它成功——就算雲端其實根本沒把它寫進你的知識庫。畫面上是綠的、數字也在跑,而 AI 一句都查不到,而且那些檔案因為被蓋了「已送」的章,**永遠不會再試一次**。現在它會看雲端真正的回覆:沒寫進去就誠實標成失敗、不蓋章、下一輪重新送。(實測:一個開發資料夾 26 份標「已送達」,雲端實際只有 4 份。) - **太大的檔案現在會好好跟你說**:以前十幾萬字的大檔會一直送、一直失敗,而你看到的是一串沒人讀得懂的錯誤訊息,還會把每天的免費額度燒掉。現在它會先量一下,太大就不送,並且告訴你「這份檔約幾萬字、拆小一點就會自動收進來」。 diff --git a/cmd/arcrun-app/.version-source-files.json b/cmd/arcrun-app/.version-source-files.json index 2421916..fa21b67 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.38", - "fingerprint": "7add5da65c671548", + "version": "0.18.39", + "fingerprint": "316426f50e886003", "files": { ".gitignore": "4d56952b0fb13bf8f9b6c13a6d4c34a075bac3af447636a1df4335d7576e2f97", - "CHANGELOG.md": "9dd02f82a4f5417520fb20642aec004aa25c241e404621aacf6f37e549602b2c", + "CHANGELOG.md": "f6f3858cd587278528f68ff7290722b6e67fcd763a5b159791169c49387d5502", "DAEMON_LINE": "d5019abbdc8a5f2919e9e3510391891cd7fbdf0765bf16ec83caa779f370116d", "README.md": "9d92cac236b20a0b183eea3e7f5e39ad492f05192c4ea602eb11c3d09967327f", "check-standalone.sh": "65fbce096326791c2f103a51e76d0ad79e9e510e2a7700b49ac2d2d81f73c853", @@ -122,7 +122,7 @@ "convert_table_test.go": "d0371b7566ef3152f9dd42f9f990e0dffa1c50a0c8e874a28415fa2e4c188394", "convert_test.go": "04f3fa30d1be5f910c0e0be3308ced2963191ab030a9eaccd986ef581fcd4e18", "convert_wiring_test.go": "09e97bf32ace245b55acc7d65ee5c0bb7fd84f8fb1aab61f603f9e9d18b22f13", - "direct.go": "70ff75a04cdfff09453ff858d3e468e110057e555c402e5969221072fefe1a41", + "direct.go": "8f5adcbfe51e6b21b4cb585fd02c7a2f66ffa28770b61aa9722abd424c35c8a3", "direct_extract_test.go": "317d0057d2d368fabc406351a7477f72e074d2b8c8459b1dc021febdf16902f4", "direct_multi_test.go": "a76593fef26231e18b7402f6f7376624d2471f8a719b3ce08fc4a0f5a3d3cf1e", "direct_pacing.go": "2690b2fff858250028886bc3cd7793be54b4d4a03ddca41485476cb04b79f7c3", @@ -135,7 +135,7 @@ "extract_gemma.go": "9d127fa467b8c760ec46180123ec0fdadd4336290555687f16baf0b80b24c6f2", "extract_gemma_test.go": "1fae16cc43e3de9728b88ea7151da9c836bf32cb5bead2805fbadc5e788fbab8", "extract_test.go": "63560ecca929c6507d93b3f52988bb7ce2cd6137140eb373c520eac5ee052c76", - "extract_workersai.go": "ea77c25d2a14d905e3c7f3a7caaa3dff2fa5ceb71c98c772a258ef0e770106f3", + "extract_workersai.go": "d8f192472883e7a7f5d034d9b0e0d8be46f284bef2c500f934ec740379efffde", "extract_workersai_test.go": "188822f4c44ee5bf3b1400e1475af314ad831296538a68c5e1006341ba63765e", "folderindex.go": "f3dd186fd7e882ff293b3f8aa6b2582b018290ee5f14bbdc02a7ac131834fd69", "folderindex_helpers_test.go": "2cf17b70c396225f728e8e160cfc75d7bdc76ac1ce8e574d1450b020050c0734", @@ -240,6 +240,8 @@ "tidy_test.go": "80fd37d7abf9f9fd075006da42aea73a3843c49f969e62623687519102dfe3ea", "trigger.go": "f689f701bef08401f5d47f5b3d783d24a88fc9a9426fe41bdbbb3f9b152ab405", "trigger_test.go": "0c2482d18cea0568cd2a0f5eae5b02a0984b9b2bc83461eb882c630d6381ca02", + "triggeroutcome.go": "25832326ecb3694d8a3a0f8a115ce8df6327b70ff61b0bec1357d57e53e81836", + "triggeroutcome_test.go": "fc429ea5da193e6250b10f7d87690c293c21a0b4db763eedbc04fac9e1995e96", "upload.go": "a41bb459eac8f6c114be567a2d7c53bbdb11b0e31f0bda4b78776502664273cb", "upload_test.go": "f97d2f3bf2213e6fff94b77b7120aa4e4f23f58c7e413c0416f02be387f2cb91", "upstream_error_visible_test.go": "6b72c903594b3eba47cfdeb40654f1f5c341757f79dfbc5c05351cd5b99c9368", diff --git a/cmd/arcrun-app/.version-source.json b/cmd/arcrun-app/.version-source.json index fa77e28..39715e0 100644 --- a/cmd/arcrun-app/.version-source.json +++ b/cmd/arcrun-app/.version-source.json @@ -8,5 +8,6 @@ "0.18.35": "3bd7f3fb0124bda4", "0.18.36": "abb2f2cc2498f071", "0.18.37": "3bb5c94f68a26a06", - "0.18.38": "7add5da65c671548" + "0.18.38": "7add5da65c671548", + "0.18.39": "316426f50e886003" } diff --git a/direct.go b/direct.go index f5ef687..ffcc0a8 100644 --- a/direct.go +++ b/direct.go @@ -1503,13 +1503,31 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta // - 上限:巨量積壓(實據 27,164 檔)不該一輪湧完;超過上限的事件本輪不碰, // manifest 未標 ingested ⇒ 下一輪 Scan() 自然重新出現(且若使用者這期間 // 寫了新檔,新檔的 mtime 更新,下一輪排序會插到最前面,不會被積壓卡住)。 + // + // 🔴 arcrun-rag#104 comment 4480(t217,2026-08-27):cap 過去套在「排序後的原始 + // 清單」上,而不是套在「這輪真的會被嘗試」的事件上。退避中/已達重試上限的檔案 + // 不會因為正在退避就往後排(mtime 沒變、排序不變)⇒ 只要前 perRunCap 名一直是 + // 同一批持續失敗的檔案,它們就會**永久佔滿名額**,排在後面的事件不管跑幾輪都 + // 排不到——這正是 leo 實測到的「1691→1880→1936 筆從不減少」,佇列本身就是問題。 + // 修法:cap 改套在 partitionRetryEligible 分出來的 ready(會真的嘗試)事件上; + // 退避中的事件(waiting)不佔嘗試名額,讓排在它們後面、真正還沒被嘗試過的事件 + // 有機會遞補上來。waiting 仍要展示(診斷用、不是安靜消失),但同樣設一個上限, + // 避免巨量積壓(實據 KB 資料夾 ~1900 筆退避中)把單輪結果與 status.json 灌爆。 orderedEvents := sortEventsNewestFirst(absRoot, payload.Events) perRunCap := cfg.effectiveMaxEventsPerRun() + readyEvents, waitingEvents := partitionRetryEligible(m, orderedEvents, now, cfg.ForceSync, qs.inCooldown(runNow)) + deferredCount := 0 - if len(orderedEvents) > perRunCap { - deferredCount = len(orderedEvents) - perRunCap - orderedEvents = orderedEvents[:perRunCap] + if len(readyEvents) > perRunCap { + deferredCount = len(readyEvents) - perRunCap + readyEvents = readyEvents[:perRunCap] } + visibleWaiting := waitingEvents + if len(visibleWaiting) > perRunCap { + deferredCount += len(visibleWaiting) - perRunCap + visibleWaiting = visibleWaiting[:perRunCap] + } + orderedEvents = append(readyEvents, visibleWaiting...) for _, ev := range orderedEvents { switch ev.Type { diff --git a/direct_pacing.go b/direct_pacing.go index 2e0ece3..4f39a63 100644 --- a/direct_pacing.go +++ b/direct_pacing.go @@ -107,3 +107,41 @@ func sortEventsNewestFirst(absRoot string, events []Event) []Event { func resumeAfterCapMessage(remaining int) string { return fmt.Sprintf("已排入佇列,下一輪會繼續處理(本輪上限已到,還有 %d 筆等待)", remaining) } + +// partitionRetryEligible 把「已依 mtime 新到舊排序」的事件分成兩組: +// - ready:這輪真的會被嘗試(會呼叫 pace()/打雲端)——沒在退避中、沒達重試上限、 +// 帳號沒在額度冷卻中。 +// - waiting:這輪不會被嘗試,只是單純交代原因——退避窗口未到、已達 +// MaxFailBeforeSkip、或整個帳號正在額度冷卻。 +// +// removed 事件不受退避/額度冷卻管制(下架本來就不看 ShouldRetry,見 direct.go 的 +// case "removed"),一律歸 ready,維持既有行為不變。 +// +// 🔴 為什麼要在 cap 之前先分這一刀(arcrun-rag#104 comment 4480,t217): +// 舊版直接對排序後的原始清單套用 perRunCap(`orderedEvents[:perRunCap]`)。 +// mtime 不會因為一個檔正在退避就變新或變舊,排序因此是穩定的——只要前 perRunCap +// 名裡有幾個持續失敗的檔案,它們會**每一輪都繼續佔著那幾個名額**(即使這一輪 +// 根本不會被嘗試,只是被跳過),排在它們後面、從沒被嘗試過的事件因此永遠排不到, +// 不管跑幾百輪都一樣。這正是 leo 實測「1691→1880→1936 筆從不減少」的真因: +// 不是處理得慢,是那些筆數的候補名單裡,有一大段從頭到尾沒拿到出場機會。 +// +// 呼叫端該把 cap 套在這裡回傳的 ready 上,讓「退避中」的事件不佔嘗試名額, +// 把機會讓給排在它們後面、真正還沒被嘗試過的事件。 +func partitionRetryEligible(m *Manifest, events []Event, now int64, force bool, coolingDown bool) (ready, waiting []Event) { + for _, ev := range events { + if ev.Type == "removed" { + ready = append(ready, ev) + continue + } + if coolingDown { + waiting = append(waiting, ev) + continue + } + if m.ShouldRetry(ev.Path, now, force) { + ready = append(ready, ev) + } else { + waiting = append(waiting, ev) + } + } + return ready, waiting +} diff --git a/direct_pacing_test.go b/direct_pacing_test.go index 64ca3af..acd89ee 100644 --- a/direct_pacing_test.go +++ b/direct_pacing_test.go @@ -201,6 +201,131 @@ func TestDirect_LargeBacklog_ProcessedInNewestFirstBatches(t *testing.T) { } } +// ── 4) 退避中的檔案不該永久佔滿單輪名額(arcrun-rag#104 comment 4480,t217)── +// +// 背景:leo 實測 leo21c 帳號的積壓「1691→1880→1936 筆從不減少」, +// 「已經好久沒有加過任何檔案,哪來的這些筆數?那就是之前卡住的,就是你要解決的問題」 +// 「佇列就是問題本身」。 +// +// 根因:舊版把 perRunCap 套在「排序後的原始清單」上。mtime 最新的幾個檔如果持續 +// 失敗(進入退避),它們不會因為在退避就往後排,於是每一輪都繼續佔著最前面的 +// perRunCap 個名額——即使這一輪根本不會被嘗試,只是被跳過。排在它們後面、 +// 從沒被嘗試過的健康檔案因此永遠排不到,不管跑幾輪都一樣。 +// +// 這個測試重現該情境:3 個 mtime 最新的檔一直失敗(模擬持續性錯誤,例如票上量到的 +// 雲端 subrequest 上限或萃取回傳格式錯誤),5 個 mtime較舊、原本會成功的健康檔案 +// 排在它們後面。單輪上限=3。 +// +// - 第一輪:全部檔案都還沒失敗過(FailCount=0),cap 選中 mtime 最新的 3 個 +// (也就是那 3 個會一直失敗的檔),全部失敗,記下退避(下次重試在 60 秒後)。 +// - 第二輪(緊接著呼叫,真實時間遠不到 60 秒):那 3 個檔仍在退避中。 +// 舊版行為:cap 依然套在原始排序上,選中的還是同一批退避中的檔案 ⇒ +// 這一輪 0 個健康檔案被嘗試,健康檔案永遠排不到。 +// 修好後的行為:退避中的檔案被分流到 waiting、不佔 ready 的名額, +// cap 改套用在 ready 上 ⇒ 健康檔案的前 3 名遞補上來,這一輪就會被嘗試並成功。 +func TestDirect_StarvedBacklog_HealthyFilesEventuallyGetATurn(t *testing.T) { + root := t.TempDir() + + // 3 個「一直失敗」的檔,mtime 最新(若 bug 還在,會永久佔滿 cap)。 + blockers := []string{"blocker-a", "blocker-b", "blocker-c"} + for i, n := range blockers { + writeFile(t, root, n+".md", "持續失敗的內容 "+n, baseTime.Add(time.Duration(10+i)*time.Minute)) + } + // 5 個「健康」的檔,mtime 較舊(排在後面,理應遞補上來)。 + healthy := []string{"h5", "h4", "h3", "h2", "h1"} + for i, n := range healthy { + writeFile(t, root, n+".md", "健康內容 "+n, baseTime.Add(time.Duration(4-i)*time.Minute)) + } + + failingPages := map[string]bool{"blocker-a": true, "blocker-b": true, "blocker-c": true} + var mu sync.Mutex + var succeededPages []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if answeredFolderTreePost(w, r) { + return + } + body, _ := io.ReadAll(r.Body) + var m map[string]any + _ = json.Unmarshal(body, &m) + pageName, _ := m["page_name"].(string) + if strings.HasPrefix(pageName, "資料夾總覽") { + _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) + return + } + if failingPages[pageName] { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"success":false,"error":"boom"}`)) + return + } + mu.Lock() + succeededPages = append(succeededPages, pageName) + mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) + })) + defer srv.Close() + defer gemmaStub(t, func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "candidates": []map[string]any{{ + "content": map[string]any{"parts": []map[string]any{{"text": cardFixture("卡", "測試")}}}, + }}, + }) + })() + + cfg := &DirectConfig{ + WatchFolders: []string{root}, + Manifest: filepath.Join(t.TempDir(), "m.json"), + CypherURL: srv.URL, Namespace: "demo", APIKey: "demo", + Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test", + CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio, + MaxEventsPerRun: 3, + } + + // 第一輪:8 個檔都還沒失敗過,cap 選中 mtime 最新的 3 個(blocker-a/b/c),全部失敗。 + results1, _, _ := RunDirectOnce(cfg, false) + if got := ingestedPaths(results1); len(got) != 0 { + t.Fatalf("第一輪不該有任何成功(cap 選中的 3 個全會失敗),got %v", got) + } + var failedCount int + for _, r := range results1 { + if r.Status == "failed" { + failedCount++ + } + } + if failedCount != 3 { + t.Fatalf("第一輪應該有 3 筆真的被嘗試且失敗(blocker-a/b/c),got %d:%+v", failedCount, results1) + } + + // 第二輪:緊接著呼叫(真實時間遠不到 60 秒退避窗口)。blocker-a/b/c 仍在退避中。 + results2, _, _ := RunDirectOnce(cfg, false) + ingested2 := ingestedPaths(results2) + if len(ingested2) != 3 { + t.Fatalf("🔴 第二輪應該有 3 個健康檔案遞補上來被嘗試並成功——"+ + "如果這裡是 0,代表退避中的 blocker-a/b/c 又佔滿了本輪名額,"+ + "健康檔案永遠排不到(這正是 leo 實測「佇列從不減少」的那個 bug)。got %d:%+v", + len(ingested2), results2) + } + wantSecond := []string{"h5.md", "h4.md", "h3.md"} + for i, w := range wantSecond { + if ingested2[i] != w { + t.Fatalf("第二輪順序=%v,want %v(健康檔案仍照 mtime 新到舊遞補)", ingested2, wantSecond) + } + } + // blocker-a/b/c 這一輪不該再被真的嘗試(還在退避中)——它們只會以「skipped」出現。 + for _, r := range results2 { + if r.Path == "blocker-a.md" || r.Path == "blocker-b.md" || r.Path == "blocker-c.md" { + if r.Status != "skipped" { + t.Fatalf("退避中的 %s 這一輪不該被真的嘗試,got status=%s", r.Path, r.Status) + } + } + } + + mu.Lock() + defer mu.Unlock() + if len(succeededPages) != 3 { + t.Fatalf("雲端應該收到 3 筆健康卡片,got %d: %v", len(succeededPages), succeededPages) + } +} + func ingestedPaths(results []DirectResult) []string { var out []string for _, r := range results {