1 Commits

6 changed files with 194 additions and 10 deletions
+1 -1
View File
@@ -47,7 +47,7 @@
---
## 下一版(未發佈
## 0.18.392026-08-27
- 🔴 **「已送上去」從今以後是真的送上去了**:以前只要雲端把請求收下來,小幫手就算它成功——就算雲端其實根本沒把它寫進你的知識庫。畫面上是綠的、數字也在跑,而 AI 一句都查不到,而且那些檔案因為被蓋了「已送」的章,**永遠不會再試一次**。現在它會看雲端真正的回覆:沒寫進去就誠實標成失敗、不蓋章、下一輪重新送。(實測:一個開發資料夾 26 份標「已送達」,雲端實際只有 4 份。)
- **太大的檔案現在會好好跟你說**:以前十幾萬字的大檔會一直送、一直失敗,而你看到的是一串沒人讀得懂的錯誤訊息,還會把每天的免費額度燒掉。現在它會先量一下,太大就不送,並且告訴你「這份檔約幾萬字、拆小一點就會自動收進來」。
+7 -5
View File
@@ -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",
+2 -1
View File
@@ -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"
}
+21 -3
View File
@@ -1503,13 +1503,31 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
// - 上限:巨量積壓(實據 27,164 檔)不該一輪湧完;超過上限的事件本輪不碰,
// manifest 未標 ingested ⇒ 下一輪 Scan() 自然重新出現(且若使用者這期間
// 寫了新檔,新檔的 mtime 更新,下一輪排序會插到最前面,不會被積壓卡住)。
//
// 🔴 arcrun-rag#104 comment 4480t2172026-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 {
+38
View File
@@ -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 4480t217):
// 舊版直接對排序後的原始清單套用 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
}
+125
View File
@@ -201,6 +201,131 @@ func TestDirect_LargeBacklog_ProcessedInNewestFirstBatches(t *testing.T) {
}
}
// ── 4) 退避中的檔案不該永久佔滿單輪名額(arcrun-rag#104 comment 4480t217)──
//
// 背景: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("第二輪順序=%vwant %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 {