From ba584002d00c271ac8c3451c4128c51f209b9a5f Mon Sep 17 00:00:00 2001 From: richblack Date: Thu, 17 Sep 2026 20:43:06 +0800 Subject: [PATCH] =?UTF-8?q?sync:=20collector/=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E8=87=AA=20inkstone/arcrun-rag@99ff817=EF=BC=88=E6=A1=8C?= =?UTF-8?q?=E9=9D=A2=E5=B0=8F=E5=B9=AB=E6=89=8B=200.18.56=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 5 + cmd/arcrun-app/.version-source-files.json | 14 +- cmd/arcrun-app/.version-source.json | 3 +- direct.go | 6 + manifest.go | 37 +++- scan.go | 1 + subrequest_resume_196_test.go | 245 ++++++++++++++++++++++ testdata/196_geek6688_paused.json | 17 ++ 8 files changed, 320 insertions(+), 8 deletions(-) create mode 100644 subrequest_resume_196_test.go create mode 100644 testdata/196_geek6688_paused.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b78abc..6b57fb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,11 @@ --- +## 0.18.56(2026-09-17) + +- 🔴 **被「雲端一次處理太多」擋下、已暫停的筆記,雲端更新後會自己送上去**:免費方案的知識庫在 9 月 13 日前有個上限問題,內容多一點的卡片會被雲端退回「Too many subrequests」,記滿 8 次後小幫手就暫停不再試。雲端早已修好(1.4.64 起),但那批暫停的檔以前要改檔或按「立刻同步」才會再送。現在小幫手看到知識庫已經是修好的版本,就會自己再送一次,不用你動手。 +- **雲端還沒更新的話,照舊暫停、不會一直重撞**;在已經修好的雲端上仍被退回的卡(內容真的太大),也只會試這一次,不會反覆消耗額度。 + ## 0.18.55(2026-09-16) - 🔴 **以前被記成「連續失敗 8 次、已暫停」的空白筆記,更新後會自己消掉警告**:上一版只讓新遇到的空白檔直接略過,舊版留下的那批(被雲端退回的空日記)還一直掛著「已暫停自動重試」。現在也會被認出是空白檔,改成略過,資料夾不再掛警告。 diff --git a/cmd/arcrun-app/.version-source-files.json b/cmd/arcrun-app/.version-source-files.json index d3dcfab..9fbf0b0 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.55", - "fingerprint": "287d990df2baa78c", + "version": "0.18.56", + "fingerprint": "d333ded9d0608cd9", "files": { ".gitignore": "c7a98c44ecc882a174720e43dabd4c04d7cf2503586426dcccaa213d5a27df38", - "CHANGELOG.md": "20a49e8ef559b89087d1ecd37ffcfe01627ca41480091a7ba8ffbd2343933293", + "CHANGELOG.md": "17a4329d7e6ac9c9e426ec1e51658b18a48c15b8b6f5598c739404cf394ca572", "DAEMON_LINE": "d5019abbdc8a5f2919e9e3510391891cd7fbdf0765bf16ec83caa779f370116d", "README.md": "9d92cac236b20a0b183eea3e7f5e39ad492f05192c4ea602eb11c3d09967327f", "cardloop_test.go": "fdf09b62650c328f5d8075d964a0ca2dada5f8d9a271202768472998b45e31a8", @@ -130,7 +130,7 @@ "convert_table_test.go": "d0371b7566ef3152f9dd42f9f990e0dffa1c50a0c8e874a28415fa2e4c188394", "convert_test.go": "04f3fa30d1be5f910c0e0be3308ced2963191ab030a9eaccd986ef581fcd4e18", "convert_wiring_test.go": "3389817e596341369a3fc102846bf5b081cee668e86ebf4830eb633978de652b", - "direct.go": "7c2354adea90717611cad3b5e73f6165c74182a6c995f2087e77d9c5d0c10068", + "direct.go": "e3d1aa9ad54bec0ff80dfa96fd0331f47590a1863cb9586497f49fae3c560277", "direct_extract_test.go": "317d0057d2d368fabc406351a7477f72e074d2b8c8459b1dc021febdf16902f4", "direct_multi_test.go": "a76593fef26231e18b7402f6f7376624d2471f8a719b3ce08fc4a0f5a3d3cf1e", "direct_pacing.go": "b54838ce85f72ecd2d3388fa7cf89050c87fb10bd67dca07cd18d49568a8837c", @@ -177,7 +177,7 @@ "machineid_test.go": "5b54ac62ee1fb28b8f0a873888aeddb844af0aa74472bfb849573cfc6691a290", "machinemark.go": "592a39597fd011e8b1603318eaedf452aa58fca0639244a80218aa9a0ac6dbf5", "main.go": "fe2f69739f3ee92713f8aaca925068ecf97b33d476eee826fc1e0d5def8f0d10", - "manifest.go": "9b63f8fbd0a1ccf5b3cf2f0945f4fdcdb8726a8684c74f1a37947fc7f36420ac", + "manifest.go": "5af553d949b40a79d9342aee870f07ff445e797357ad4dafce5193f484769828", "manifest_extractedby_test.go": "8019fe35675a1e801b7bda829ee1d647de32db03c029b1c651f71573e644c69e", "manifest_retry_test.go": "e6b4a321ccddfeed8cdbfabe6d031d5a9c59a33b47baf0b6d21150fa8406748a", "multiaccount_connected_test.go": "ba1817b73a5dd0dd5d300750e54d5c53bd82a75ef6e43caeddfcb2303d64ff66", @@ -195,7 +195,7 @@ "routebackoff_201_test.go": "9099a5e84bf124f30e1b64a9887196406ea2ea66f12d0310c7e0cdc10da354d4", "safewrite.go": "d5dd09ffe894fb9fb94656a4bb50d246bd8c65ab21a7ad81463f43dfdacc8a57", "safewrite_test.go": "2980f0f86f78fb265403e31d4cba6da43f4dbc16e7c4711cfccba23175578358", - "scan.go": "a91ca567cff71864cb5a0a2af234a478df7118c47bfa24727f1b37c3f0466540", + "scan.go": "867d7327ac980fbdcdac88fbfa730888f2e0da5b1fc1bee56a2fb2c928632a7e", "scan_carry_lasterror_test.go": "6fa542422fb3e00b5ec4355b5db8df7086361b2dacefd67755d75c0ddfa55575", "scan_dedup_scale_test.go": "573b3c78bec86614bf4d2f99b7c3c3157f92c24af73cae7290428fc7f43f0362", "scan_dedup_test.go": "580ec58596ca54aa3d29b417e7d098fd2e3455fd39bf10e7da02d453c0786df0", @@ -206,6 +206,7 @@ "sourcerepair_test.go": "2a1ddddaad8ac7adc36350859e2d742c0fea12b99370a4d4831d390db1cc6720", "stallguard.go": "3f5d36d72f84cc3431536dbb20219bd4793b11e573b5ac7f817cbe9e45d234d7", "stallguard_test.go": "678b82a8448d1394a0a85d4af1f133c980f5ff0dcb54953b0fcf1671d186147f", + "subrequest_resume_196_test.go": "7ee211839f2a85ea369fa40316d23c95e5ad18e5239fc9c816a5db553ff394a0", "supervisor/hidewindow_other.go": "291b4ecb0783ff3e813c68b5464161d9af2f84d31c773c7f1f710f218bb5d610", "supervisor/hidewindow_windows.go": "29796dbe90ecb35e6f1451dacf956d42928c6a393271befc5c7f6863d5315fb3", "supervisor/supervisor.go": "aec9357579bc0832e5179918912e86dbe74fc86f49904e881a6be807f59bc0ae", @@ -254,6 +255,7 @@ "templatefs/system-dev/workflows/tasks-project-sync.local.sh": "7a611abc8b597d7c047e1b73c4a19c011ba1719f6688351b677b4feee0a4d4fe", "templatefs/system-dev/workflows/tasks-project-sync.yaml": "e8c7dd78d338e64219ad0eb7aae27aec02b6ac2ad962f309145055a87f1cd5db", "templateuntouched.go": "74e6c0dda22228ac65cc841ce83460d81026134d41b4b16c528bf7ce760db26f", + "testdata/196_geek6688_paused.json": "bedd74c2de3dd837f9fdd5713fc47879a4cc512173cd3107f78a679d4d1d1946", "testdata/grounding/card-access-paraphrase.md": "2bbf4427b1ea2c5b73995b135752f60c4978f2c9121647c4a0aa7b42d6063570", "testdata/grounding/card-delivery-paraphrase.md": "727f6d09cb4ebb0e8248988bb9872573d4339f2256e6723a588eceb04aa4fd42", "testdata/grounding/card-geek6688-faithful.md": "7b7200aa2d142720cde51c45b7266e32a5b9f278a808a115c2546443903f09f8", diff --git a/cmd/arcrun-app/.version-source.json b/cmd/arcrun-app/.version-source.json index 22029fa..32c6f62 100644 --- a/cmd/arcrun-app/.version-source.json +++ b/cmd/arcrun-app/.version-source.json @@ -25,5 +25,6 @@ "0.18.52": "5ab62f966033ea4b", "0.18.53": "32f62120aa472b65", "0.18.54": "44cf7dbf8246968a", - "0.18.55": "287d990df2baa78c" + "0.18.55": "287d990df2baa78c", + "0.18.56": "d333ded9d0608cd9" } diff --git a/direct.go b/direct.go index 2d6d164..71e7ebb 100644 --- a/direct.go +++ b/direct.go @@ -101,6 +101,10 @@ type DirectConfig struct { // 不寫進 config.json(`json:"-"`):它是單次執行旗標,不是使用者設定。 ForceSync bool `json:"-"` + // CloudVersion=這一輪這個帳號的雲端版本(/health 的 bundle_version,查不到時沿用上一輪)。 + // 單次執行資訊,不寫進 config.json。給逐檔退避判斷「舊雲端造成的暫停該不該再試」(#196)。 + CloudVersion string `json:"-"` + // B2 萃取品質檢查(daemon 端 lint,草案 §3 第一層;分支 work/b2-quality-lint-0726 移入, // 2026-08-09 重新接到現行 direct.go——原分支落後 main 130 筆,不能硬併,見 // wiki status.md:1570-1573):extractor 模式萃完、POST 前跑。 @@ -853,6 +857,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge // t215:這個帳號要不要更新——與 portal 版本卡同一套判準(見檔頭)。 // 第二個參數傳「我們知不知道它的版本」而不是「這一輪連不連得上」: // 版本是**事實**,可達性是**當下狀態**,兩者不是同一個問題。 + accCfg.CloudVersion = cloudVer // #196:逐檔退避要知道雲端是不是已經修好子請求那面牆 cloudUpd := EvalCloudUpdate(cloudVer, strings.TrimSpace(cloudVer) != "", latestRelease, latestOK) accSt := AccountSyncStatus{ LastSync: time.Now().Format(time.RFC3339), @@ -1540,6 +1545,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta if err != nil { return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil, rootProgress{} } + m.CloudVersion = cfg.CloudVersion // #196 // 2026-08-07 task 3(斷點續傳):每個事件處理完就立刻存檔,不要等整輪跑完。 // 舊行為=整個 for 迴圈跑完才 Save 一次——process 在跑到一半被殺掉(重開機、 // 換版、當機)時,**已經成功的那些也會遺失**,下次重開等於從頭來過, diff --git a/manifest.go b/manifest.go index db22595..5d29b14 100644 --- a/manifest.go +++ b/manifest.go @@ -56,6 +56,11 @@ type ManifestEntry struct { // 真因(Cloudflare「當日免費額度用完」/「這份 PDF 沒有文字層」)**當場消失** // ⇒ 使用者以為是我們壞掉。原因必須跟著 entry 存活到下次成功為止。 LastError string `json:"last_error,omitempty"` + // FailCloudVersion=最後一次失敗時,這個帳號的雲端版本(/health 的 bundle_version;""=當時不知道)。 + // 為什麼要記(inkstone/arcrun-rag#196):「單次呼叫子請求太多」在 1.4.64 之前是雲端引擎的病, + // 之後就是這張卡真的太大。只看錯誤原文分不出是哪一種——要知道**當時打的是哪一版雲端**, + // 才能做到「舊雲端造成的暫停,雲端更新後自己再試一次;新雲端上還撞牆就照舊暫停,不反覆燒額度」。 + FailCloudVersion string `json:"fail_cloud_version,omitempty"` // ── 雲端對帳(`inkstone/arcrun-rag#140`,2026-08-26)────────────────────── // 病:上面那個 IngestedHash 的章**永遠不會過期**。雲端 08-14 被重裝/清空之後, @@ -91,6 +96,9 @@ const MaxFailBeforeSkip = 8 // Manifest 對應一個被勾選的資料夾。 type Manifest struct { FolderID string `json:"folder_id"` + // CloudVersion=這一輪這個帳號的雲端版本(執行期由呼叫端填,不寫進檔案)。 + // MarkFailed 把它記進病歷、ShouldRetry 拿它判斷「雲端是不是已經修好那面牆」(#196)。 + CloudVersion string `json:"-"` // FolderCardHashes=每個子資料夾索引卡的內容雜湊(鍵=相對監看根的目錄路徑)。 // 冪等用:內容沒變就不重送(同 InventoryHash 的角色,只是一層變多層)。 // 資料夾消失時由 syncFolderCards 清掉對應的鍵,不讓這張表無限長大。 @@ -234,6 +242,7 @@ func (m *Manifest) MarkIngestedBy(path, sourceHash string, at int64, extractor s // 成功即清掉失敗狀態(t195):下次再壞會從第一階退避重新算起。 e.FailCount, e.LastFailAt, e.NextRetry = 0, 0, 0 e.LastError = "" + e.FailCloudVersion = "" // #140:預設「這次有送卡上雲」;真的一張卡都沒送的那條路由呼叫端補打 // MarkNoCloudCard(見 direct.go 的 cards 為空分支)。預設值放這裡而不是 // 讓呼叫端每次都設,是因為漏設的方向要落在**安全的那一邊**: @@ -275,6 +284,7 @@ func (m *Manifest) MarkFailed(path string, at int64, reason string) bool { } e.FailCount++ e.LastFailAt = at + e.FailCloudVersion = m.CloudVersion // #196:記下這次撞的是哪一版雲端 if strings.TrimSpace(reason) != "" { e.LastError = reason // 存真因;退避訊息由呼叫端另外組,不覆蓋這裡 } @@ -344,11 +354,36 @@ func (m *Manifest) ShouldRetry(path string, now int64, force bool) bool { if e.FailCount >= MaxFailBeforeSkip { // #201:「雲端當時還是舊版、沒有 AI」不是這個檔的病——雲端更新之後要自己再試。 // 退避窗口照舊(最長 6 小時一次),雲端還沒更新的話頂多每 6 小時白問一發。 - return isOldCloudText(e.LastError) && now >= e.NextRetry + // #196:同理,「單次呼叫子請求太多」在雲端 1.4.64 之前是引擎的病(每個節點多打 3 發附帶呼叫)。 + // 病歷是在舊雲端上記的、而現在的雲端已經修好 ⇒ 再試。 + // 在已修好的雲端上又撞 ⇒ 這次 MarkFailed 會記下新版本號 ⇒ 不再自動重試(卡真的太大,重撞只是燒額度)。 + return (isOldCloudText(e.LastError) || m.subrequestWallFixedSince(e)) && now >= e.NextRetry } return now >= e.NextRetry } +// subrequestFixedRelease=雲端第一個帶「每次呼叫附帶子請求改成常數」修正的版本 +// (Arcrun 1eb26c9,首次出貨=1.4.64,inkstone/arcrun-rag#196)。 +const subrequestFixedRelease = "1.4.64" + +// isSubrequestLimitText=病歷是 Cloudflare「單次 Worker 呼叫子請求太多」。 +func isSubrequestLimitText(msg string) bool { + return strings.Contains(msg, "Too many subrequests") +} + +// cloudHasSubrequestFix=這個雲端版本已經帶子請求修正(""、舊日期格式、比 1.4.64 舊 ⇒ false)。 +func cloudHasSubrequestFix(version string) bool { + head := strings.TrimSpace(strings.SplitN(version, "+", 2)[0]) + return isSemverLike(head) && compareSemver(head, subrequestFixedRelease) >= 0 +} + +// subrequestWallFixedSince=這筆暫停是舊雲端的子請求牆造成的,而這一輪的雲端已經修好。 +func (m *Manifest) subrequestWallFixedSince(e *ManifestEntry) bool { + return isSubrequestLimitText(e.LastError) && + cloudHasSubrequestFix(m.CloudVersion) && + !cloudHasSubrequestFix(e.FailCloudVersion) +} + // isOldCloudText=病歷上寫的是「雲端還沒有這個功能」(舊版雲端),不是檔案本身的問題。 func isOldCloudText(msg string) bool { return strings.Contains(msg, "沒有綁定 Workers AI") || strings.Contains(msg, "沒有雲端萃取功能") diff --git a/scan.go b/scan.go index a96df9c..3f6c832 100644 --- a/scan.go +++ b/scan.go @@ -706,6 +706,7 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) { // ⇒ 畫面只剩 humanizeFailure 的最後退路「當時沒有記下原因」。 // 這正是上面那句警告的第二次實例——真因是我們自己刪掉的,不是沒記。 ne.LastError = carry.LastError + ne.FailCloudVersion = carry.FailCloudVersion // #196:同上那句警告——漏 carry 就分不出暫停是在哪一版雲端記的 // 🔴 #140(2026-08-26):雲端對帳的三個欄位同樣要 carry,而且漏了會**很貴**: // - CloudCheckedAt 歸零 ⇒ 每輪都判「太久沒對帳」⇒ 每輪重問整批(請求風暴) // - CloudMissingAt 歸零 ⇒ **防重送迴圈的 grace 消失** ⇒ 補送 → 下一輪又拔章 diff --git a/subrequest_resume_196_test.go b/subrequest_resume_196_test.go new file mode 100644 index 0000000..9fee65a --- /dev/null +++ b/subrequest_resume_196_test.go @@ -0,0 +1,245 @@ +package collector + +// inkstone/arcrun-rag#196:雲端 1.4.64 起修掉「單次呼叫子請求太多」那面牆, +// 但修之前被記滿 8 次、暫停自動重試的檔不會自己動——用戶不改檔、不按「立刻同步」就永遠送不上去。 +// 09-17 實查 geek6688:13 份掛著這種暫停,含本票量測用的兩張卡。 +// 本檔用那兩張卡在 geek6688 帳本上的**原樣病歷**(testdata/196_geek6688_paused.json)重演。 + +import ( + "encoding/json" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +type pausedFixture struct { + Entries map[string]struct { + FailCount int `json:"fail_count"` + LastFailAt int64 `json:"last_fail_at"` + NextRetry int64 `json:"next_retry"` + LastError string `json:"last_error"` + } `json:"entries"` +} + +const ( + card196Sample = "system-dev/wiki/cards/arcrun-sample-knowledge.md" + card196N8n = "system-dev/wiki/cards/arcrun-n8n版本比較表新-2025.11-範本.md" +) + +// fakeCloud196 =一台假知識庫:萃取一律成功;收卡依 wall 決定回成功或回 geek6688 那句 500。 +type fakeCloud196 struct { + mu sync.Mutex + version string // /health 的 bundle_version + wall map[string]bool // 這些檔收卡時回「Too many subrequests」 + posts map[string]int // 每個檔實際打到 rag_ingest_card 幾次 + wallErr string +} + +func (f *fakeCloud196) handler(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var req struct { + PageName string `json:"page_name"` + Text string `json:"text"` + Path string `json:"path"` + } + _ = json.Unmarshal(body, &req) + f.mu.Lock() + defer f.mu.Unlock() + switch { + case strings.HasSuffix(r.URL.Path, "/portal/daemon/extract"): + _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "output": cardFixture(req.PageName, "測試")}) + case strings.HasSuffix(r.URL.Path, "/rag_ingest_card/trigger"): + f.posts[req.Path]++ + if f.wall[req.Path] { + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, f.wallErr) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) + default: + _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) + } +} + +func (f *fakeCloud196) postsOf(p string) int { + f.mu.Lock() + defer f.mu.Unlock() + return f.posts[p] +} + +// setup196 建出「geek6688 今天的樣子」:兩張卡在帳本上是舊版留下的暫停病歷(沒記雲端版本)。 +func setup196(t *testing.T) (cfg *DirectConfig, mp, root string, cloud *fakeCloud196, clock *time.Time) { + t.Helper() + cloudRoutes.reset() + t.Cleanup(cloudRoutes.reset) + withCloudCheckInterval(t, 0) + + raw, err := os.ReadFile(filepath.Join("testdata", "196_geek6688_paused.json")) + if err != nil { + t.Fatal(err) + } + var fx pausedFixture + if err := json.Unmarshal(raw, &fx); err != nil { + t.Fatal(err) + } + if len(fx.Entries) != 2 { + t.Fatalf("fixture 應有兩張卡:%d", len(fx.Entries)) + } + + root = t.TempDir() + writeFile(t, root, card196Sample, "# Arcrun 範例知識\n\nArcrun 是跑在 Cloudflare 上的工作流引擎。", baseTime) + writeFile(t, root, card196N8n, "# n8n 版本比較表\n\n| 版本 | 說明 |\n|---|---|\n| 1.0 | 測試 |", baseTime) + + cloud = &fakeCloud196{wall: map[string]bool{}, posts: map[string]int{}, + wallErr: fx.Entries[card196Sample].LastError[len("HTTP 500:"):]} + oldFetch := fetchCloudVersion + fetchCloudVersion = func(string) (string, bool) { + cloud.mu.Lock() + defer cloud.mu.Unlock() + return cloud.version, true + } + t.Cleanup(func() { fetchCloudVersion = oldFetch }) + + // 先占位址再關掉:第一輪連不上(只為了建出帳本),之後在同一個位址開假雲端。 + ln0, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := ln0.Addr().String() + _ = ln0.Close() + + c := time.Unix(fx.Entries[card196Sample].NextRetry, 0).Add(4 * 24 * time.Hour) // 09-17:暫停早已過了退避窗口 + clock = &c + oldNow := directNow + directNow = func() time.Time { return *clock } + t.Cleanup(func() { directNow = oldNow }) + + 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: "http://" + addr, Namespace: "demo", APIKey: "demo", + InstanceName: "geek6688", WatchFolders: []string{root}}}, + } + RunDirectOnce(cfg, false) + cloudRoutes.reset() // 建帳本那一輪的連線失敗不要帶進正式情境 + acc := cfg.makeAccountSubConfig(cfg.Accounts[0]) + mp = acc.manifestPathFor(root) + m, err := LoadManifest(mp, root) + if err != nil { + t.Fatal(err) + } + for p, f := range fx.Entries { + e := m.Entries[p] + if e == nil { + t.Fatalf("帳本裡沒有 %s", p) + } + e.IngestedHash, e.IngestedAt, e.NoCloudCard = "", 0, false + e.FailCount, e.LastFailAt, e.NextRetry, e.LastError = f.FailCount, f.LastFailAt, f.NextRetry, f.LastError + e.FailCloudVersion = "" // 舊版小幫手不記這欄 + } + if err := m.Save(mp); err != nil { + t.Fatal(err) + } + ln, err := net.Listen("tcp", addr) + if err != nil { + t.Skipf("無法重新綁定同一個埠(%v)", err) + } + srv := &http.Server{Handler: http.HandlerFunc(cloud.handler)} + go func() { _ = srv.Serve(ln) }() + t.Cleanup(func() { _ = srv.Close() }) + if p := mustLoad(t, mp, root).Progress(); p.Stuck != 2 { + t.Fatalf("前置條件:兩張卡都該是暫停:%+v", p) + } + return cfg, mp, root, cloud, clock +} + +func mustLoad(t *testing.T, mp, root string) *Manifest { + t.Helper() + m, err := LoadManifest(mp, root) + if err != nil { + t.Fatal(err) + } + return m +} + +// 雲端還是舊版 ⇒ 照舊暫停、不重撞;雲端更新到 1.4.64 以上 ⇒ 不用人動手,兩張卡自己送上去、算完成。 +func TestDirect196_SubrequestPausedResumesAfterCloudFix(t *testing.T) { + cfg, mp, root, cloud, clock := setup196(t) + + cloud.version = "1.4.63" + results, _, _ := RunDirectOnce(cfg, false) + if n := cloud.postsOf(card196Sample) + cloud.postsOf(card196N8n); n != 0 { + t.Fatalf("雲端還是 1.4.63(沒修那面牆)時不該重撞,卻送了 %d 發", n) + } + for _, r := range results { + if (r.Path == card196Sample || r.Path == card196N8n) && !strings.Contains(r.Error, "已暫停自動重試") { + t.Fatalf("舊雲端上 %s 應維持暫停說明,拿到 %q", r.Path, r.Error) + } + } + if p := mustLoad(t, mp, root).Progress(); p.Stuck != 2 { + t.Fatalf("舊雲端上兩張都該維持暫停:%+v", p) + } + + cloud.version = "1.4.67" // geek6688 09-17 的 /health + *clock = clock.Add(time.Minute) + RunDirectOnce(cfg, false) + if cloud.postsOf(card196Sample) == 0 || cloud.postsOf(card196N8n) == 0 { + t.Fatalf("雲端更新後兩張都要自己送出:sample=%d n8n=%d", cloud.postsOf(card196Sample), cloud.postsOf(card196N8n)) + } + m := mustLoad(t, mp, root) + if p := m.Progress(); p.Stuck != 0 || p.Done != p.Total { + t.Fatalf("兩張都要算完成:%+v", p) + } + for _, p := range []string{card196Sample, card196N8n} { + if e := m.Entries[p]; e.FailCount != 0 || e.LastError != "" || e.FailCloudVersion != "" { + t.Fatalf("%s 送成功後病歷要清乾淨:%+v", p, e) + } + } +} + +// 在已經修好的雲端上**又**撞同一面牆(卡真的太大)⇒ 記下這一版,之後不再自動重撞燒額度。 +func TestDirect196_StillTooBigOnFixedCloudStaysPaused(t *testing.T) { + cfg, mp, root, cloud, clock := setup196(t) + cloud.version = "1.4.67" + cloud.wall[card196Sample] = true + + RunDirectOnce(cfg, false) + first := cloud.postsOf(card196Sample) + if first == 0 { + t.Fatal("雲端已修好時,舊暫停的卡應該要再試一次") + } + m := mustLoad(t, mp, root) + e := m.Entries[card196Sample] + if e.FailCount < MaxFailBeforeSkip || e.FailCloudVersion != "1.4.67" || !isSubrequestLimitText(e.LastError) { + t.Fatalf("在新雲端上又撞牆,病歷要記下 1.4.67:%+v", e) + } + if p := m.Progress(); p.Done != 1 || p.Stuck != 1 { + t.Fatalf("n8n 那張送成、sample 那張暫停:%+v", p) + } + + *clock = clock.Add(7 * 24 * time.Hour) // 遠超過最長退避 + cloudRoutes.reset() + RunDirectOnce(cfg, false) + if got := cloud.postsOf(card196Sample); got != first { + t.Fatalf("已在修好的雲端上撞過牆,不該再自動重撞:%d → %d", first, got) + } +} + +func TestCloudHasSubrequestFix196(t *testing.T) { + for v, want := range map[string]bool{ + "": false, "1.4.63": false, "1.4.64": true, "1.4.67+b08a8cee": true, "1.10.0": true, + "2026-09-13+1eb26c9": false, "unknown": false, + } { + if got := cloudHasSubrequestFix(v); got != want { + t.Errorf("cloudHasSubrequestFix(%q)=%v,要 %v", v, got, want) + } + } +} diff --git a/testdata/196_geek6688_paused.json b/testdata/196_geek6688_paused.json new file mode 100644 index 0000000..91d9bab --- /dev/null +++ b/testdata/196_geek6688_paused.json @@ -0,0 +1,17 @@ +{ + "_source": "geek6688(arcrun-fc9490d5)~/Desktop/geek6688-test1 的帳本原樣,2026-09-17 讀出;inkstone/arcrun-rag#196", + "entries": { + "system-dev/wiki/cards/arcrun-sample-knowledge.md": { + "fail_count": 8, + "last_fail_at": 1788188201, + "next_retry": 1788209801, + "last_error": "HTTP 500:{\"success\":false,\"error\":\"Node post_block failed: Too many subrequests by single Worker invocation. To configure this limit, refer to https://developers.cloudflare.com/workers/wrangler/configuration/#limits\",\"trace\":[{\"node\":\"input\",\"status\":\"success\"},{\"node\":\"list_old_blocks\",\"status\":\"success\"},{\"node\":\"list_old_triplets\",\"status\":\"success\"},{\"node\":\"pick_stale\",\"status\":\"success\"},{\"node\":\"delete_entry\",\"status\":\"success\"},{\"node\":\"delete_entry\",\"status\":\"success\"},{\"node\":\"delete_entry\",\"status\":\"success\"},{\"node\":\"delete_entry\",\"status\":\"success\"},{\"node\":\"delete_entry\",\"status\":\"success\"},{\"node\":\"delete_entry\",\"status\":\"success\"},{\"node\":\"delete_entry\",\"status\":\"success\"},{\"node\":\"delete_entry\",\"status\":\"success\"},{\"node\":\"delete_entry\",\"status\":\"success\"},{\"node\":\"delete_record\",\"status\":\"success\"},{\"node\":\"delete_record\",\"status\":\"success\"},{\"node\":\"delete_record\",\"status\":\"success\"},{\"node\":\"delete_record\",\"status\":\"success\"},{\"node\":\"parse_card\",\"status\":\"success\"},{\"node\":\"post_block\",\"status\":\"f" + }, + "system-dev/wiki/cards/arcrun-n8n版本比較表新-2025.11-範本.md": { + "fail_count": 8, + "last_fail_at": 1788188201, + "next_retry": 1788209801, + "last_error": "HTTP 500:{\"success\":false,\"error\":\"Node post_block failed: Too many subrequests by single Worker invocation. To configure this limit, refer to https://developers.cloudflare.com/workers/wrangler/configuration/#limits\",\"trace\":[{\"node\":\"input\",\"status\":\"success\"},{\"node\":\"list_old_blocks\",\"status\":\"success\"},{\"node\":\"list_old_triplets\",\"status\":\"success\"},{\"node\":\"pick_stale\",\"status\":\"success\"},{\"node\":\"delete_entry\",\"status\":\"success\"},{\"node\":\"delete_entry\",\"status\":\"success\"},{\"node\":\"delete_entry\",\"status\":\"success\"},{\"node\":\"delete_entry\",\"status\":\"success\"},{\"node\":\"delete_entry\",\"status\":\"success\"},{\"node\":\"delete_entry\",\"status\":\"success\"},{\"node\":\"delete_entry\",\"status\":\"success\"},{\"node\":\"delete_entry\",\"status\":\"success\"},{\"node\":\"delete_entry\",\"status\":\"success\"},{\"node\":\"delete_record\",\"status\":\"success\"},{\"node\":\"delete_record\",\"status\":\"success\"},{\"node\":\"delete_record\",\"status\":\"success\"},{\"node\":\"delete_record\",\"status\":\"success\"},{\"node\":\"parse_card\",\"status\":\"success\"},{\"node\":\"post_block\",\"status\":\"f" + } + } +} \ No newline at end of file