From e8800d18ef1088721b4c362cdeb1c49583d178e2 Mon Sep 17 00:00:00 2001 From: richblack Date: Fri, 28 Aug 2026 05:07:35 +0800 Subject: [PATCH] =?UTF-8?q?sync:=20collector/=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E8=87=AA=20inkstone/arcrun-rag@1c31ac2=EF=BC=88=E6=A1=8C?= =?UTF-8?q?=E9=9D=A2=E5=B0=8F=E5=B9=AB=E6=89=8B=200.18.46=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++ cloud_audit.go | 1 + cmd/arcrun-app/.version-source-files.json | 23 +-- cmd/arcrun-app/.version-source.json | 4 +- direct.go | 32 +++- foldertree.go | 35 ++++ foldertree_publish_test.go | 187 ++++++++++++++++++++++ probe_workersai.go | 3 + stallguard.go | 123 +++++++++++--- stallguard_test.go | 97 ++++++++++- sync_status.go | 26 +++ sync_status_test.go | 45 ++++++ 12 files changed, 540 insertions(+), 46 deletions(-) create mode 100644 foldertree_publish_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 8971725..47fc525 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,16 @@ --- +## 0.18.46(2026-08-28) + +- 修好上一版帶進來的問題:雲端只是回得慢,卻被講成「沒有回應」,還害後面的檔被跳過。上一版我替每一種請求設了時間上限,其中「送出一份筆記」給了 60 秒——但實際量出來它本來就要 33 到 57 秒。結果尾巴被剪掉,兩份檔判成失敗,接著整個帳號被判定沒回應,**後面 9 份檔一份都沒送**。這一版把上限依實測值放寬(送知識的請求 5 分鐘、只登記結構的 3 分鐘),並且只有在**連續**等不到回覆時才會暫停該帳號——中間只要成功過一次就重新計算。 +- 訊息也改了:只要這一輪它成功回應過,就不會再說「沒有回應」,而是說「回得太慢」。以前那句話會讓你去查網路、去重裝,全都白費。 + +## 0.18.45(2026-08-28) + +- 資料夾結構現在幾秒內就會更新,不用等整輪同步跑完。以前這棵樹是**排在所有雲端工作的最後**才寫下來的,而一輪同步實測要十幾分鐘到好幾小時(每一次跟雲端說話就要 24〜33 秒,檔案越多輪越久)——所以你看到的是「小幫手開著、沒有錯誤訊息、資料夾結構永遠停在上一版」。那不是卡住,是這一輪還沒輪到寫它。現在它一掃完就先寫,該送上雲端的照舊。 +- 這一版沒有改收什麼檔、也沒有改整理的方式。只改了「畫面上的東西什麼時候更新」。 + ## 0.18.44(2026-08-28) - 一個帳號沒有回應,不會再讓整台機器的同步停下來。以前只要有一發請求卡住,後面所有知識庫帳號、所有資料夾就一起停在那裡,畫面上什麼都不動也沒有錯誤訊息(實測卡了 39 分鐘)。現在等不到回覆的那個帳號這一輪先跳過,其他資料夾照常跑完。 diff --git a/cloud_audit.go b/cloud_audit.go index 38387a6..bceaad3 100644 --- a/cloud_audit.go +++ b/cloud_audit.go @@ -117,6 +117,7 @@ func cloudCardPresent(cfg *DirectConfig, library, relPath string) (present bool, if err != nil { return false, false, gate.record(err) } + gate.ok() defer resp.Body.Close() body, rerr := io.ReadAll(io.LimitReader(resp.Body, 8192)) if resp.StatusCode < 200 || resp.StatusCode >= 300 { diff --git a/cmd/arcrun-app/.version-source-files.json b/cmd/arcrun-app/.version-source-files.json index 0c318b7..d7379cd 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.44", - "fingerprint": "19e26497ae609682", + "version": "0.18.46", + "fingerprint": "1325784830f592ff", "files": { ".gitignore": "4d56952b0fb13bf8f9b6c13a6d4c34a075bac3af447636a1df4335d7576e2f97", - "CHANGELOG.md": "c509c1215e6e9b75b386098329e4a8d958fb18718aed1716aea40fd358d76651", + "CHANGELOG.md": "0ccd77301d2cc432a93699d90f45d1c012020cc447b0cda6db79655bc928822e", "DAEMON_LINE": "d5019abbdc8a5f2919e9e3510391891cd7fbdf0765bf16ec83caa779f370116d", "README.md": "9d92cac236b20a0b183eea3e7f5e39ad492f05192c4ea602eb11c3d09967327f", "cardloop_test.go": "fdf09b62650c328f5d8075d964a0ca2dada5f8d9a271202768472998b45e31a8", @@ -13,7 +13,7 @@ "cleanup.go": "59a45672f7f73d73833685846148ba20f71d5b3f42340be5a74a10437be267fc", "cleanup_cli.go": "5dc41aac30f39757658d7bc95ab727ecc799b8242fd752edcedbb8423935fbf0", "cleanup_test.go": "23d627a12ec476bb8898847185a1464cb78e2186278299ff13c84a3d54af63c0", - "cloud_audit.go": "652c31ec2eea6f78a95f654b71655656c52bf954faecdbe1da71c2592e30e0e7", + "cloud_audit.go": "e3dbf32785e3d6643a6d62c298c4ece2680d8f00c1148f6d55285dd670d9cf51", "cloud_audit_test.go": "1790837e8a5726d62258436937f9c474f7abd03e6229832fdbd40f8cd7aa756a", "cloud_latest.go": "c2ad7f87bbd55247540eb924ac3be5deadbc4488b504770a5127a01c748f0e0f", "cloud_latest_test.go": "703963b65da91ae0c4638b9ad676ca7f12a474c635e903a5d988e4980b3888aa", @@ -124,7 +124,7 @@ "convert_table_test.go": "d0371b7566ef3152f9dd42f9f990e0dffa1c50a0c8e874a28415fa2e4c188394", "convert_test.go": "04f3fa30d1be5f910c0e0be3308ced2963191ab030a9eaccd986ef581fcd4e18", "convert_wiring_test.go": "3389817e596341369a3fc102846bf5b081cee668e86ebf4830eb633978de652b", - "direct.go": "a27107e24713b105214e8d28d729802b74d1628d3862671715fbcf8ba0095953", + "direct.go": "1ccb56abf877f75a61c47cbc9d5797739f58dbc9f321de4f29d9022320f2e41e", "direct_extract_test.go": "317d0057d2d368fabc406351a7477f72e074d2b8c8459b1dc021febdf16902f4", "direct_multi_test.go": "a76593fef26231e18b7402f6f7376624d2471f8a719b3ce08fc4a0f5a3d3cf1e", "direct_pacing.go": "b54838ce85f72ecd2d3388fa7cf89050c87fb10bd67dca07cd18d49568a8837c", @@ -143,9 +143,10 @@ "folderindex_helpers_test.go": "2cf17b70c396225f728e8e160cfc75d7bdc76ac1ce8e574d1450b020050c0734", "folderindex_test.go": "a5319eb1e92473cb5c32d3beddb7889997556227824de300dd39953b43beab70", "foldershape.go": "b1b237f5674674efdc9bdea849b63c571a014e64bec33af42fb7c4769fa7582b", - "foldertree.go": "1800c2444696dbcd30f1fc81cf79b9fd138bcb8faa6ae943538a861e38757600", + "foldertree.go": "c12461137d6c177db8e1e2f5c7446bcbd6bfd567f044cc07e11e883cc71892dd", "foldertree_live_test.go": "ace96ffdf7670a69761973e810fb6548c7036d2768141e09db515313df1e9a50", "foldertree_local_test.go": "ce919dc2a21ec96716e3d43a7f493367346e212243b4ab22c2d960fc26408f3a", + "foldertree_publish_test.go": "104b26ffac1fc78a4c9b061aba3e2167c7d71ea469073537c3d86e3896debdea", "foldertree_test.go": "f8b8cdf1ef9d7d1c80cabb086fc9b4f2162b91a47d6d78bde2632b07672c0b88", "foldertree_traversal_test.go": "b9a3ce5c784b7601e628b1299596b3572ca787e654cd2ee20b973d053bab84aa", "go.mod": "05dacc224af2a6797db86689435f83537efb433ffba521467b95d1099f5e12db", @@ -170,7 +171,7 @@ "manifest_retry_test.go": "e6b4a321ccddfeed8cdbfabe6d031d5a9c59a33b47baf0b6d21150fa8406748a", "multiaccount_connected_test.go": "ba1817b73a5dd0dd5d300750e54d5c53bd82a75ef6e43caeddfcb2303d64ff66", "probe_real_manual_test.go": "6170cd7d41b6e099ad93e229f55e5f9d64d509412969f9bf162788ae8e787aef", - "probe_workersai.go": "a2d5a39b4f4c05f01031a592bc0c1cedc2af0f6f34d3820699dca133f9b16900", + "probe_workersai.go": "2d12d7e83f13a83f7dca0f4f6ea1ff0041db70b4f47d86f940c2fac80cab6980", "progress.go": "c2c86ecfea5a8227eb40018c55d48f26c4f2de1b8775fde9acd231cba8b7bb37", "progress_test.go": "21258889ad7680da2d6cf8812accda7e6a1aca6c6fd604394ec4363c2ed902bb", "progress_wiring_test.go": "d1d144269539825ef4a4d9ffca5c49b021cb27cecea7d280309f3f935460b3cd", @@ -189,14 +190,14 @@ "sourcerepair.go": "bdb1a4b64d028a9df42a7b9258a431e01f1be8871c490b92011d0c6ee5e53b79", "sourcerepair_live_test.go": "2a7f5d6fada420a7f55fa52b87361842755c68c1cb58f8c3fd0153341f709a0a", "sourcerepair_test.go": "2a1ddddaad8ac7adc36350859e2d742c0fea12b99370a4d4831d390db1cc6720", - "stallguard.go": "8923a5be2b781d10e2bd2ada0484f59f24afdfb473252415d16ae5c28f246f9f", - "stallguard_test.go": "3e908ff3e1cffae51635b0b3b5ed4656b61e0d97c1c34c263bb7e5bc655aeb1b", + "stallguard.go": "13be8ae905492054d516f466856bc6949b16e1af6ea32068f94b21635799bdc6", + "stallguard_test.go": "678b82a8448d1394a0a85d4af1f133c980f5ff0dcb54953b0fcf1671d186147f", "supervisor/hidewindow_other.go": "291b4ecb0783ff3e813c68b5464161d9af2f84d31c773c7f1f710f218bb5d610", "supervisor/hidewindow_windows.go": "29796dbe90ecb35e6f1451dacf956d42928c6a393271befc5c7f6863d5315fb3", "supervisor/supervisor.go": "f79b32e92e67058a056dc749a6182573eefcbbc9bc7b2cd5a997f4e8066a004e", "supervisor/supervisor_test.go": "f5582d54c55065fbc834fe96754df9c8cd6efe9f88f30208bbce84ddea08fea7", - "sync_status.go": "eb4686b6162b664aad30c50f2b5694f78eb936222ed0363a37505037fc06d983", - "sync_status_test.go": "9314b354679f0c5090257cf6918185c33b74102bfac0acb655c3e97b81a247b3", + "sync_status.go": "a4c2b10de55b0a01e2c723946812c7eaa59a6ff0c0d2d3c8512b37935f4e205e", + "sync_status_test.go": "73bbc83c9e8fc18575895a7b7b5feab7a76d41f05a74d74bbbdbf98b045ccfa2", "t52_library_test.go": "14188d0afdad0f9aa16d19a74ea28c45c3d18fcfeabc5deb6fb0466d05778a51", "template_install.go": "d0aa0098e431696a63ef8d3714c124591b8ba193ccd5a8359a4ea03e397ee358", "template_install_test.go": "e2d096a80c52ab10fd1a9e26cab2a9621cc2017df8c0c5726aaa450a00375566", diff --git a/cmd/arcrun-app/.version-source.json b/cmd/arcrun-app/.version-source.json index 73ad7aa..6e98a47 100644 --- a/cmd/arcrun-app/.version-source.json +++ b/cmd/arcrun-app/.version-source.json @@ -14,5 +14,7 @@ "0.18.41": "8033de0cdec3537b", "0.18.42": "43e0c6ac08233f07", "0.18.43": "34f17e1e6a190eaa", - "0.18.44": "19e26497ae609682" + "0.18.44": "19e26497ae609682", + "0.18.45": "dc05513103f93306", + "0.18.46": "1325784830f592ff" } diff --git a/direct.go b/direct.go index 55294e4..5ca23c1 100644 --- a/direct.go +++ b/direct.go @@ -541,6 +541,7 @@ func (c *DirectConfig) postJSON(step callStep, url string, body any) (int, strin if err != nil { return 0, "", gate.record(err) } + gate.ok() // #153:回來了就把「連續逾時」的計數歸零——否則「連續」兩個字是假的 defer resp.Body.Close() // 🔴 讀 64KB 而不是 1KB:觸發端點的回應是一層外殼包著工作流的輸出, // 而**失敗的證據住在殼裡面**(見 triggeroutcome.go)。1KB 會把 JSON 切斷 ⇒ @@ -998,8 +999,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge // 使用者才看得到「為什麼」而不只是「幾份」。 // 2026-08-07:額度冷卻的 skip 訊息(quotaState.noticeNow().Combined()) // 用「會自動恢復」當識別字——同樣要讓使用者看得到原因,不是只看到「幾份」。 - if strings.Contains(r.Error, "後重試") || strings.Contains(r.Error, "已暫停自動重試") || - strings.Contains(r.Error, "會自動恢復") { + if explainsWhySkipped(r.Error) { st.Failures = append(st.Failures, ExtractFail{ Path: r.Path, Error: shortError(r.Error), @@ -1482,6 +1482,29 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta now := runNow.Unix() + // 🔴 `inkstone/arcrun-rag#153` 第二輪:**樹算出來就立刻落地本機,排在所有雲端呼叫之前。** + // + // 為什麼非搬到這裡不可(2026-08-28 用 `ARCRUN_TRACE=1` 量出來的,不是推測): + // 對一個**健康**的雲端實例,每一發呼叫實測 24〜33 秒 + //(資料夾總覽 26.3s/目錄索引 24.0・24.1・23.5s/送出一份筆記 33.2・44.6s)。 + // 一個 12 層、10 個檔的資料夾,一輪就是十幾分鐘;leo 真正的設定是三個帳號、 + // 好幾千個檔,一輪是好幾小時。**沒有任何一發超過 300 秒,所以逾時與斷路器都不會動** + // ——這不是卡住,是這一輪還沒輪到寫它。 + // + // 而這棵樹是**純本機、秒級**算出來的(就在上面那次 Scan 的產物上), + // 它沒有理由排在十幾分鐘的雲端佇列後面。搬到這裡之後,使用者的資料夾結構 + // 在開跑幾秒內就是對的,即使這一輪還要再跑一小時。 + // + // 內容一個位元都沒變:BuildFolderTree 吃的是 payload.DirStats/m.Entries/ + // payload.AllExcludedDirs/plan,而從 Scan() 到這裡之間**沒有任何東西動過 m.Entries** + //(動它的是下面「removed 暫時放回」那段,本來就在原位置之後)。 + // 送上雲端那一發(syncFolderTree)**維持在原來的位置**,用的就是這一棵。 + tree := BuildFolderTree(absRoot, cfg.libraryFor(absRoot), payload.DirStats, m.Entries, + payload.AllExcludedDirs, plan, runNow) + if !dryRun { + PublishFolderTreeNow(cfg.Manifest, root, tree, runNow) + } + // 結構先行(InkStoneCo#43,2026-08-15):掃描一結束(純本機、免費、秒級)就先把 // 「這個資料夾有哪些檔案/最近改了什麼」送上知識庫,**不等 LLM 萃取、不受額度影響** // ——走 rag_ingest_card(零 LLM 的機械收口),所以刻意放在: @@ -1517,8 +1540,6 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta // 再加第四個**只屬於它**的:**空資料夾一個事件都不會有**(arcrun-rag#106 的情境本身), // 所以它不能被任何「有事件才做」的閘擋住——分子分母都由現況算出,靜止時 // 內容雜湊自然擋住重送,不需要事件當第二道閘。 - tree := BuildFolderTree(absRoot, cfg.libraryFor(absRoot), payload.DirStats, m.Entries, - payload.AllExcludedDirs, plan, runNow) if treeRes := syncFolderTree(cfg, absRoot, m, tree, dryRun, runNow); treeRes != nil { results = append(results, *treeRes) if treeRes.Status != "planned" { @@ -1678,6 +1699,9 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta xgate := cfg.openGate(stepExtractDoc) cards, xerr = ExtractWithWorkersAI(cfg.CypherURL, cfg.APIKey, absRoot, ev.Path, cardOrigin) xgate.release() + if xerr == nil { + xgate.ok() + } xerr = xgate.record(xerr) // 只有「等到超時」會被記帳,其餘錯誤原樣往下走 case "gemma": cards, xerr = ExtractWithGemma(cfg.GeminiAPIKey, cfg.LLMModel, absRoot, ev.Path, cardOrigin) diff --git a/foldertree.go b/foldertree.go index d2d0946..6110e96 100644 --- a/foldertree.go +++ b/foldertree.go @@ -478,3 +478,38 @@ func MergeFolderTreeStore(prev FolderTreeStore, fresh map[string]FolderTree, kno } return out } + +// PublishFolderTreeNow 把**這一個根**剛算好的樹立刻寫進本機快照,不等整輪跑完。 +// +// 🔴 為什麼要有它(`inkstone/arcrun-rag#153` 第二輪,2026-08-28 實測): +// 一輪同步是**一條線**走完的,而對一個健康的雲端實例,每一發呼叫實測要 24〜33 秒 +// (`ARCRUN_TRACE=1` 量的:資料夾總覽 26.3s、目錄索引 24.0/24.1/23.5s、 +// 送出一份筆記 33.2/44.6s)。一個 12 層、10 個檔的資料夾,一輪就是**十幾分鐘**; +// leo 真正的設定是三個帳號、好幾千個檔,一輪是**好幾小時**。 +// +// 而 `folder-trees.json` 從前**只在整輪的最後**才落地 +// ⇒ 使用者看到的是「小幫手開著、沒有錯誤、什麼都不動,資料夾結構永遠停在上一版」。 +// **那不是卡住,是這一輪還沒輪到寫它。** +// +// 樹本身是**純本機、秒級**算出來的(BuildFolderTree 在掃描一結束就有答案), +// 它沒有任何理由要排在十幾分鐘的雲端佇列後面等。 +// +// 與收工時那次 SaveFolderTreeStore 的分工: +// - 這裡:只**更新這一個根**,不刪任何東西(還不知道整輪看守哪些根)。 +// - 收工:跑完整的 MergeFolderTreeStore,該刪的(已不看守的根)在那時才刪。 +// 兩次都是冪等的覆蓋,先寫一次不會讓收工那次結果不同。 +// +// 失敗一律吞掉:快照是給畫面看的,寫不進去不該擋住同步本體。 +func PublishFolderTreeNow(manifestPath, root string, tree FolderTree, now time.Time) { + if manifestPath == "" || root == "" || len(tree.Nodes) == 0 { + return // 沒算出東西就別覆蓋上一輪的好資料(同 MergeFolderTreeStore 規則②) + } + path := FolderTreeStorePath(manifestPath) + store, _ := LoadFolderTreeStore(path) // 讀不到=沒有上一輪,零值可用 + if store.Trees == nil { + store.Trees = map[string]FolderTree{} + } + store.Trees[root] = tree + store.UpdatedAt = now.UTC().Format(time.RFC3339) + _ = SaveFolderTreeStore(path, store) +} diff --git a/foldertree_publish_test.go b/foldertree_publish_test.go new file mode 100644 index 0000000..d623098 --- /dev/null +++ b/foldertree_publish_test.go @@ -0,0 +1,187 @@ +// foldertree_publish_test.go — 「畫面有沒有跟上」的網(`inkstone/arcrun-rag#153` 第二輪)。 +// +// 🔴 這裡測的判準跟 stallguard_test.go **刻意不一樣**,因為第一輪測錯了東西: +// 那一輪的網問的是「**有沒有發出警告**」,而總管 2026-08-28 端到端實測打回來的正是 +// 「閘在跑、警告會發,**但主流程仍然沒走下去**」。 +// +// stallguard_test.go 端點**不回應**時 → 這一輪要跳過它、要有話說 +// foldertree_publish_test 端點**回應但很慢**時 → 畫面要的東西**不准排在後面等** +// +// 為什麼第二種才是使用者真正撞到的(`ARCRUN_TRACE=1` 實測,健康的 youlin 實例): +// 每一發雲端呼叫 24〜33 秒,**沒有一發接近 300 秒** ⇒ 逾時不會觸發、斷路器不會跳、 +// 播報也大多不會響(門檻 30 秒)。一輪就這樣安靜地跑十幾分鐘到幾小時, +// 而 `folder-trees.json` 從前**只在整輪的最後**才落地。 +// 使用者看到的是「小幫手開著、沒有錯誤、資料夾結構永遠停在上一版」。 +package collector + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +// slowButHealthyServer=**會回應、只是慢**。這是本檔的主角,不是黑洞。 +func slowButHealthyServer(t *testing.T, delay time.Duration, hits *int64) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(hits, 1) + select { + case <-time.After(delay): + case <-r.Context().Done(): + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) + })) + t.Cleanup(srv.Close) + return srv +} + +// 🔴 本票第二輪的核心:**雲端每一發都很慢(但都會回來)時,資料夾結構仍然要在 +// 這一輪還在跑的時候就已經寫好**。 +// +// 這條會抓到的回歸:把樹的落地搬回「整輪最後」——那時它會等到所有雲端呼叫跑完, +// 使用者的畫面就又回到「永遠停在上一版」。 +func TestFolderTreeLandsWhileTheRoundIsStillRunning(t *testing.T) { + origFetch := fetchCloudVersion + fetchCloudVersion = func(string) (string, bool) { return "", false } + defer func() { fetchCloudVersion = origFetch }() + + // 每一發 400ms——遠低於任何逾時(所以斷路器不會跳,正是實測的形狀), + // 但夠多發就足以把一輪拖長。 + var hits int64 + slow := slowButHealthyServer(t, 400*time.Millisecond, &hits) + + // 多層資料夾+多個檔:讓「雲端呼叫的總時間」明顯大於「算一棵樹的時間」。 + root := t.TempDir() + for _, d := range []string{"a", "b", "c", "d", "e", "a/a1", "b/b1"} { + if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, d, "n.md"), []byte("# n\n內容"), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(root, "top.md"), []byte("# top\n內容"), 0o644); err != nil { + t.Fatal(err) + } + + manifestPath := filepath.Join(t.TempDir(), "m.json") + treePath := FolderTreeStorePath(manifestPath) + cfg := &DirectConfig{ + Manifest: manifestPath, + Accounts: []AccountConfig{{CypherURL: slow.URL, Namespace: "n", APIKey: "k", + WatchFolders: []string{root}}}, + Library: "kb", MaxRemoved: DefaultMaxRemovedRatio, + CardIngestWF: "rag_ingest_card", IngestWF: "rag_ingest_direct", RemovedWF: "rag_takedown_direct", + ForceSync: true, + } + + roundDone := make(chan time.Duration, 1) + start := time.Now() + go func() { + RunDirectOnce(cfg, false) + roundDone <- time.Since(start) + }() + + // 一邊等這一輪跑,一邊盯著快照什麼時候出現。 + var treeAt time.Duration + var roundAt time.Duration + poll := time.NewTicker(20 * time.Millisecond) + defer poll.Stop() + deadline := time.After(60 * time.Second) +watch: + for { + select { + case roundAt = <-roundDone: + break watch + case <-deadline: + t.Fatalf("🔴 60 秒內這一輪還沒跑完(雲端每一發只有 400ms,這不該發生)") + case <-poll.C: + if treeAt == 0 { + if _, err := os.Stat(treePath); err == nil { + treeAt = time.Since(start) + } + } + } + } + + if treeAt == 0 { + t.Fatalf("🔴 整輪跑完(%v)之前,folder-trees.json 一次都沒出現過——"+ + "畫面上的資料夾結構會一直停在上一版", roundAt) + } + t.Logf("樹落地於 %v/整輪跑完於 %v/雲端被打了 %d 次", treeAt, roundAt, atomic.LoadInt64(&hits)) + + // 🔴 判準:樹要在**這一輪還早**的時候就落地,不是跟著整輪一起結束。 + // 用比例而不是絕對秒數——CI 機器快慢不同,但「排在雲端佇列前面」這個性質不變。 + if treeAt > roundAt/2 { + t.Fatalf("🔴 樹落地於 %v,而整輪 %v——它還是排在雲端呼叫後面等。\n"+ + "本票第二輪的病就是這個:不是卡住,是這一輪還沒輪到寫它。", treeAt, roundAt) + } + + // 內容也要對:使用者看到的節點數要等於地端真的有幾個資料夾。 + store, err := LoadFolderTreeStore(treePath) + if err != nil { + t.Fatalf("讀不回快照:%v", err) + } + tree, ok := store.Trees[root] + if !ok { + t.Fatalf("快照裡沒有這個根:%+v", store.Trees) + } + // 根 + a b c d e a/a1 b/b1 = 8 + if tree.TotalNodes != 8 || len(tree.Nodes) != 8 { + t.Fatalf("🔴 節點數不對:TotalNodes=%d len(Nodes)=%d,want 8(根+7 個子資料夾)", + tree.TotalNodes, len(tree.Nodes)) + } +} + +// 收工那一次的合併語意不准被「提早落地」破壞: +// 已經不看守的根,仍然要在整輪結束時被清掉(不然使用者移除了資料夾還看得到它)。 +func TestEarlyPublishStillLetsTheFinalMergeDropUnwatchedRoots(t *testing.T) { + origFetch := fetchCloudVersion + fetchCloudVersion = func(string) (string, bool) { return "", false } + defer func() { fetchCloudVersion = origFetch }() + + var hits int64 + srv := slowButHealthyServer(t, time.Millisecond, &hits) + + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "n.md"), []byte("# n\n內容"), 0o644); err != nil { + t.Fatal(err) + } + manifestPath := filepath.Join(t.TempDir(), "m.json") + treePath := FolderTreeStorePath(manifestPath) + + // 先塞一棵「已經不看守」的舊樹進快照。 + if err := SaveFolderTreeStore(treePath, FolderTreeStore{ + UpdatedAt: "2026-08-01T00:00:00Z", + Trees: map[string]FolderTree{"/已經移除的資料夾": sampleTree("/已經移除的資料夾", 1, 1)}, + }); err != nil { + t.Fatal(err) + } + + cfg := &DirectConfig{ + Manifest: manifestPath, + Accounts: []AccountConfig{{CypherURL: srv.URL, Namespace: "n", APIKey: "k", + WatchFolders: []string{root}}}, + Library: "kb", MaxRemoved: DefaultMaxRemovedRatio, + CardIngestWF: "rag_ingest_card", IngestWF: "rag_ingest_direct", RemovedWF: "rag_takedown_direct", + ForceSync: true, + } + RunDirectOnce(cfg, false) + + store, err := LoadFolderTreeStore(treePath) + if err != nil { + t.Fatal(err) + } + if _, still := store.Trees["/已經移除的資料夾"]; still { + t.Fatalf("🔴 已經不看守的根還留在快照裡——使用者移除完還看得到它:%+v", store.Trees) + } + if _, ok := store.Trees[root]; !ok { + t.Fatalf("正在看守的那棵反而不見了:%+v", store.Trees) + } +} diff --git a/probe_workersai.go b/probe_workersai.go index 25a0aa9..84d2761 100644 --- a/probe_workersai.go +++ b/probe_workersai.go @@ -106,6 +106,9 @@ func (c *DirectConfig) probeWorkersAI() CloudAIState { gate := c.openGate(stepProbeAI) state, err := ProbeWorkersAI(gate.ctx, c.CypherURL, c.APIKey) gate.release() + if err == nil { + gate.ok() + } if perr := gate.record(err); perr != nil { // 等到超時 ⇒ 講的是「沒有回應」,不是「你的雲端沒裝好」。 // 把等待誤報成「你沒更新」會害使用者白跑一趟去按重裝。 diff --git a/stallguard.go b/stallguard.go index 2f9faad..a927049 100644 --- a/stallguard.go +++ b/stallguard.go @@ -46,31 +46,49 @@ type callStep struct { Budget time.Duration } +// 🔴 **每一個數字都是量出來的,不是推出來的**(2026-08-28 第三輪的教訓)。 +// +// 第一輪我用「零 LLM 的機械收口應該很快」這個**推論**給了 60 秒,結果實測分佈是: +// +// 送出一份筆記 33.2 39.9 42.4 43.2 43.3 43.5 43.6 44.2 44.6 44.6 +// 45.5 53.1 53.3 53.6 54.5 54.8 57.3 ← 真實尾巴到 57 秒 +// 60.0 60.0 60.0 ← **這三個是我自己的上限把它剪斷**,不是雲端的耗時 +// +// 後果不是「多等一下」:那 2 個被剪斷的檔判成 failed,斷路器跟著跳, +// **後面 9 個檔全部被跳過**,而畫面對使用者說的是「知識庫現在沒有回應」 +// ——它一路都在回應,只是慢。**我的推論把一台健康的機器講成壞掉的。** +// +// ⇒ 判準改成:**上限只用來擋「真的不回來」,不用來表達「我認為它該多快」。** +// 每一格都給實測最大值的數倍餘裕;要調它,先去量(`ARCRUN_TRACE=1`)。 const ( - // llmCallBudget=那一發會在雲端同步跑完 AI 萃取才回來(原本 directHTTP 那把 - // 300 秒就是為它放寬的),維持不變。 - llmCallBudget = 300 * time.Second - // plainCallBudget=零 LLM 的機械收口(收卡/下架/登記結構)。這不是「把 300 - // 調小」——這些請求從來就不跑模型,給它們五分鐘只是讓卡住的代價變大。 - plainCallBudget = 60 * time.Second + // writeBudget=任何會把知識寫進雲端的呼叫(收卡/直送萃取/萃取/修出處/下架/收回)。 + // 實測最大 57.3 秒(送出一份筆記)⇒ 300 秒 ≈ 5 倍餘裕。 + // 這也正是改版前 directHTTP 用的數字——事實證明它本來就沒選錯。 + writeBudget = 300 * time.Second + // registerBudget=只寫登記簿、不產生知識的呼叫(資料夾總覽/目錄索引/資料夾結構)。 + // 實測 1.8〜26.3 秒 ⇒ 180 秒 ≈ 7 倍餘裕。 + registerBudget = 180 * time.Second + // probeBudget=「這台還活著嗎」的唯讀探問。實測 0.0〜1 秒。 + // 它短是因為**它問的就是活著沒**,不是因為我覺得它該多快。 + probeBudget = 20 * time.Second ) var ( - stepIngestDoc = callStep{"整理一份文件", llmCallBudget} - stepIngestCard = callStep{"送出一份筆記", plainCallBudget} - stepExtractDoc = callStep{"請雲端讀一份文件", llmCallBudget} - stepRepairOrigin = callStep{"更新舊筆記的原文位置", plainCallBudget} - stepTakedown = callStep{"把刪掉的檔案從雲端下架", plainCallBudget} - stepRetire = callStep{"收回這個資料夾在雲端的資料", plainCallBudget} - stepFolderTree = callStep{"回報資料夾結構", plainCallBudget} - stepInventory = callStep{"送出資料夾總覽", plainCallBudget} - stepFolderCard = callStep{"送出目錄索引", plainCallBudget} + stepIngestDoc = callStep{"整理一份文件", writeBudget} + stepIngestCard = callStep{"送出一份筆記", writeBudget} + stepExtractDoc = callStep{"請雲端讀一份文件", writeBudget} + stepRepairOrigin = callStep{"更新舊筆記的原文位置", writeBudget} + stepTakedown = callStep{"把刪掉的檔案從雲端下架", writeBudget} + stepRetire = callStep{"收回這個資料夾在雲端的資料", writeBudget} + stepFolderTree = callStep{"回報資料夾結構", registerBudget} + stepInventory = callStep{"送出資料夾總覽", registerBudget} + stepFolderCard = callStep{"送出目錄索引", registerBudget} // stepProbeAI=每輪每個帳號的第一發(探測雲端 AI 通了沒)。它同時是最早 // 能認出「這個帳號今天不回應」的位置——認出來,這個帳號其餘的工作就都省了。 - stepProbeAI = callStep{"確認雲端 AI 可不可以用", 20 * time.Second} + stepProbeAI = callStep{"確認雲端 AI 可不可以用", probeBudget} // stepCloudAudit=跟雲端核對「先前送過的檔案還在不在」。唯讀查詢, // 一輪可能連打 cloudAuditBatch 發——正是「一發卡住的代價會被乘上批次大小」的例子。 - stepCloudAudit = callStep{"跟雲端核對哪些檔案還在", 20 * time.Second} + stepCloudAudit = callStep{"跟雲端核對哪些檔案還在", probeBudget} ) // stallNoticeEvery=等多久開口說一次「還在等」。變數而非常數:測試要把它調快。 @@ -95,7 +113,8 @@ type StalledCall struct { // accountStall=某個帳號在這一輪的「等待帳」。 type accountStall struct { strikes int - skip string // 非空=這一輪不再打它,內容是給使用者看的理由 + answered bool // 這一輪它**成功回應過** ⇒ 不准說它「沒有回應」 + skip string // 非空=這一輪不再打它,內容是給使用者看的理由 } // roundGuard 一輪一份,掛在 DirectConfig 上。 @@ -173,6 +192,29 @@ func (g *roundGuard) Stalls() []StalledCall { return append([]StalledCall(nil), g.stalls...) } +// succeeded 記一發「成功回來了」。 +// +// 🔴 沒有這個函式的話,`strikes` 只增不減 ⇒ **一輪裡任意兩發逾時就會跳閘**, +// 而訊息上寫的是「**連續** 2 件事都等不到回覆」——那兩個字就是假的。 +// 2026-08-28 實撞:一輪成功送出 8 份筆記、中間兩發被我自己過短的上限剪斷, +// 斷路器就跳了,後面 9 個檔全被跳過。 +func (g *roundGuard) succeeded(host string) { + if g == nil { + return + } + g.mu.Lock() + defer g.mu.Unlock() + a := g.accounts[host] + if a == nil { + a = &accountStall{} + g.accounts[host] = a + } + a.answered = true + if a.skip == "" { + a.strikes = 0 // 已經跳閘就不再回頭;沒跳閘的話,成功一次就重新計數 + } +} + // strike 記一發「等到超時」,必要時讓這個帳號這一輪停手。回傳給使用者看的那句話。 func (g *roundGuard) strike(host string, step callStep, waited time.Duration) string { g.mu.Lock() @@ -182,16 +224,27 @@ func (g *roundGuard) strike(host string, step callStep, waited time.Duration) st g.accounts[host] = a } a.strikes++ + // 🔴 **它這一輪回應過的話,就不准說它「沒有回應」。** + // 2026-08-28 實撞:youlin 一路都在回應(成功送出 8 份筆記),只是慢, + // 而畫面對使用者說「知識庫現在沒有回應」——那是把一台健康的機器講成壞掉的。 + // 使用者會照著這句話去查網路、去重裝,全都白費。 + how := "沒有回應" + if a.answered { + how = "回得太慢" + } tripped := a.strikes >= stallStrikesBeforeSkip && a.skip == "" if tripped { a.skip = fmt.Sprintf( - "知識庫「%s」現在沒有回應(連續 %d 件事都等不到回覆),這一輪先跳過它;"+ - "其他資料夾照常同步,等它回來之後會自動恢復。", - host, a.strikes) + "知識庫「%s」現在%s(連續 %d 件事都等不到回覆),這一輪先跳過它;"+ + "其他資料夾照常同步,稍後會自動恢復。", + host, how, a.strikes) } - note := fmt.Sprintf("「%s」等了 %d 秒,知識庫「%s」還是沒有回應;這一輪先跳過,"+ - "其他資料夾照常同步,等它回來之後會自動恢復。", - step.Name, int(waited.Seconds()), host) + // 🔴 結尾那句「稍後會自動恢復」不是修辭,它是 explainsWhySkipped 認得的識別字 + // (sync_status.go)。少了它,這些被跳過的檔會**連一句原因都沒有地從畫面消失**。 + // 有測試守著(TestSkipMessagesAlwaysExplainThemselves),改措辭前先看那條。 + note := fmt.Sprintf("「%s」等了 %d 秒,知識庫「%s」%s;這一輪先跳過,"+ + "其他資料夾照常同步,稍後會自動恢復。", + step.Name, int(waited.Seconds()), host, how) g.stalls = append(g.stalls, StalledCall{ Account: host, Step: step.Name, WaitedSec: int(waited.Seconds()), Skipped: tripped, Note: note, @@ -296,13 +349,31 @@ func (gate *callGate) keepTalking() { // blocked 回「這一輪已經不打這個帳號了」的理由;空=可以打。 func (gate *callGate) blocked() string { return gate.skip } +// traceCalls=把每一發的「哪件事/打誰/花了多久」印到 stderr(`ARCRUN_TRACE=1` 開)。 +// +// 🔴 為什麼要留這個:2026-08-28 第一次修完之後,端到端症狀還在,而現場能拿到的只有 +// 一張 SIGQUIT 堆疊——堆疊只說得出「此刻卡在哪一發」,**說不出「這一輪的時間花到哪去了」**。 +// 兩個人為此各自推測了一輪。有這一行就不必推測:跑一次,時間分佈直接列出來。 +var traceCalls = os.Getenv("ARCRUN_TRACE") != "" + +func (gate *callGate) trace(outcome string) { + if !traceCalls { + return + } + fmt.Fprintf(os.Stderr, "[trace] %7.1fs %-22s %-8s %s\n", + time.Since(gate.started).Seconds(), gate.step.Name, outcome, gate.host) +} + // release 停掉播報並放掉 context。**一定要 defer**:context 活到呼叫端讀完回應 // 之後才釋放,所以不能在讀 body 之前呼叫。 func (gate *callGate) release() { - gate.once.Do(func() { close(gate.stop) }) + gate.once.Do(func() { close(gate.stop); gate.trace("done") }) gate.cancel() } +// ok 記一發成功(見 roundGuard.succeeded)。與 record 成對,兩者呼叫端都要顧到。 +func (gate *callGate) ok() { gate.g.succeeded(gate.host) } + // record 記一發失敗。只有「等到超時」才進帳(連線被拒之類的錯是**很快**回來的, // 不是本票要修的病,記進去只會讓正常的斷網把帳號誤判成沒有回應)。 // 回傳要交給呼叫端的錯誤:逾時換成白話,其餘原樣。 @@ -314,7 +385,7 @@ func (gate *callGate) record(err error) error { } waited := time.Since(gate.started) if gate.g == nil { - return fmt.Errorf("「%s」等了 %d 秒,知識庫「%s」沒有回應;稍後會自動再試。", + return fmt.Errorf("「%s」等了 %d 秒,知識庫「%s」沒有回應;稍後會自動恢復。", gate.step.Name, int(waited.Seconds()), gate.host) } return errors.New(gate.g.strike(gate.host, gate.step, waited)) diff --git a/stallguard_test.go b/stallguard_test.go index fa4ea6e..db9fd6a 100644 --- a/stallguard_test.go +++ b/stallguard_test.go @@ -187,10 +187,13 @@ func TestStallSaysWhichAccountWhichStepHowLong(t *testing.T) { } // 交給呼叫端的錯誤是產品文案:講哪件事、等多久、他會怎樣,不出現狀態碼/內部名詞。 msg := err.Error() - for _, want := range []string{stepRepairOrigin.Name, "沒有回應", "會自動恢復"} { - if !strings.Contains(msg, want) { - t.Fatalf("錯誤訊息少了「%s」:%s", want, msg) - } + if !strings.Contains(msg, stepRepairOrigin.Name) { + t.Fatalf("錯誤訊息沒講是哪件事:%s", msg) + } + // 🔴 不再寫死「會自動恢復」這幾個字——改用產生端與消費端**共用**的那個判準。 + // 寫死字串正是第三輪的病:我改了措辭,兩邊就對不上了(見 explainsWhySkipped)。 + if !explainsWhySkipped(msg) { + t.Fatalf("🔴 這句話不會被 status.json 收進失敗清單 ⇒ 畫面上會沒有原因:%s", msg) } for _, banned := range []string{"context deadline", "HTTP", "timeout", "Client.Timeout"} { if strings.Contains(msg, banned) { @@ -336,3 +339,89 @@ func TestGateAlwaysHasADeadlineEvenWithoutGuard(t *testing.T) { t.Fatalf("🔴 沒有 guard 就沒有上限了(等了 %v)", el) } } + +// 🔴 慢但**成功**的雲端,不准被講成「沒有回應」,也不准害後面的檔被跳過。 +// +// 這一條是 2026-08-28 第三輪的實撞:`送出一份筆記` 實測 33〜57 秒,而我第一輪 +// 憑「零 LLM 應該很快」的**推論**給了 60 秒上限 ⇒ 尾巴被剪斷 2 發 ⇒ 斷路器跳 ⇒ +// **後面 9 個檔全被跳過**,畫面還對使用者說「知識庫現在沒有回應」。 +// 那台知識庫一路都在回應(同一輪成功送出 8 份筆記),只是慢。 +func TestSlowButSucceedingCloudIsNotCalledUnresponsive(t *testing.T) { + // 上限給 2 秒,伺服器每發 300ms——**遠低於上限**,就是「慢但會成功」。 + stallTestTimings(t, 2*time.Second, 50*time.Millisecond) + + origFetch := fetchCloudVersion + fetchCloudVersion = func(string) (string, bool) { return "", false } + defer func() { fetchCloudVersion = origFetch }() + + var hits int64 + slow := slowButHealthyServer(t, 300*time.Millisecond, &hits) + + root := t.TempDir() + for i := range [8]struct{}{} { + name := filepath.Join(root, "note"+string(rune('a'+i))+".md") + if err := os.WriteFile(name, []byte("# 筆記\n內容"), 0o644); err != nil { + t.Fatal(err) + } + } + manifestPath := filepath.Join(t.TempDir(), "m.json") + cfg := &DirectConfig{ + Manifest: manifestPath, + Accounts: []AccountConfig{{CypherURL: slow.URL, Namespace: "n", APIKey: "k", + WatchFolders: []string{root}}}, + Library: "kb", MaxRemoved: DefaultMaxRemovedRatio, + CardIngestWF: "rag_ingest_card", IngestWF: "rag_ingest_direct", RemovedWF: "rag_takedown_direct", + ForceSync: true, + } + results, _, _ := RunDirectOnce(cfg, false) + + // ① 斷路器不准跳:它一路都在回應。 + if note := cfg.unreachableNote(); note != "" { + t.Fatalf("🔴 慢但成功的雲端被判成不能打了:%s", note) + } + for _, s := range cfg.guard.Stalls() { + if s.Skipped { + t.Fatalf("🔴 有一筆標成「這一輪跳過該帳號」:%+v", s) + } + } + + // ② 一個檔都不准被「帳號沒回應」這個理由跳過。 + skipped := 0 + for _, r := range results { + if r.Status == "skipped" && (strings.Contains(r.Error, "沒有回應") || + strings.Contains(r.Error, "回得太慢")) { + skipped++ + } + } + if skipped > 0 { + t.Fatalf("🔴 有 %d 個檔因為「帳號沒回應」被跳過,但雲端每一發都成功了", skipped) + } + + // ③ 就算真的有一發逾時,只要它這一輪回應過,措辭就不准是「沒有回應」。 + g := newRoundGuard() + g.announce = func(StalledCall) {} + g.succeeded("h") + msg := g.strike("h", stepIngestCard, 3*time.Second) + if strings.Contains(msg, "沒有回應") { + t.Fatalf("🔴 它回應過,卻還是說「沒有回應」:%s", msg) + } + if !strings.Contains(msg, "回得太慢") { + t.Fatalf("措辭應該是「回得太慢」:%s", msg) + } +} + +// 「連續」兩個字必須是真的:成功一次就把計數歸零。 +func TestStrikesAreActuallyConsecutive(t *testing.T) { + g := newRoundGuard() + g.announce = func(StalledCall) {} + g.strike("h", stepIngestCard, time.Second) // 第 1 次逾時 + g.succeeded("h") // 中間成功了一次 + g.strike("h", stepIngestCard, time.Second) // 又逾時一次 ⇒ 這是「第 1 次連續」 + if r := g.skipReason("h"); r != "" { + t.Fatalf("🔴 中間成功過,卻仍然跳閘了——訊息裡的「連續」是假的:%s", r) + } + g.strike("h", stepIngestCard, time.Second) // 真的連續第 2 次 ⇒ 才該跳 + if g.skipReason("h") == "" { + t.Fatal("真的連續兩次逾時了,該跳閘卻沒跳") + } +} diff --git a/sync_status.go b/sync_status.go index 82c70a1..a66a4a8 100644 --- a/sync_status.go +++ b/sync_status.go @@ -6,6 +6,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" ) // AccountSyncStatus 彙總單一帳號的每輪同步結果(t104 多帳號看守)。 @@ -238,3 +239,28 @@ func LoadSyncStatus(path string) (SyncStatus, error) { func SyncNowSignalPath(manifestPath string) string { return filepath.Join(filepath.Dir(manifestPath), "sync-now") } + +// explainsWhySkipped 回答:「這一則 `skipped` 的訊息,講得出**為什麼**嗎?」 +// 講得出 ⇒ 收進 status.json 的失敗清單,畫面才有原因可講;講不出 ⇒ 不佔畫面。 +// +// 🔴 為什麼要抽成具名函式(`inkstone/arcrun-rag#153` 第三輪,2026-08-28 差點實撞): +// 這個判準本來是**寫死在 direct.go 裡的三個字串比對**,而產生那些訊息的地方在別的檔。 +// 我改了斷路器的措辭(「會自動恢復」→「稍後會自動再試」),兩邊當場對不上—— +// 後果不是報錯,是**那 9 個被跳過的檔會連一句原因都沒有地從畫面上消失**, +// 正是這個 repo 一再修的「安靜地略過」。 +// +// 抽成一個函式解不掉「字串比對很脆」這件事,但它解掉了**兩邊會各自漂走**: +// 現在只有一個地方定義「講得出原因」,而且有測試守著(sync_status_test.go)。 +// 新增訊息時,讓它通過這個函式,或把新的識別字加在這裡——不要在別處另開一張表。 +func explainsWhySkipped(msg string) bool { + for _, mark := range []string{ + "後重試", // 退避中:「上次失敗(第 N 次),X 後重試」 + "已暫停自動重試", // 連續失敗到上限 + "會自動恢復", // 額度冷卻/帳號暫時打不通,之後自己會好 + } { + if strings.Contains(msg, mark) { + return true + } + } + return false +} diff --git a/sync_status_test.go b/sync_status_test.go index d17fcd8..b429d84 100644 --- a/sync_status_test.go +++ b/sync_status_test.go @@ -5,7 +5,9 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" + "time" ) // ── FindClaudeBin fallback(t92)────────────────────────────────────────────── @@ -268,3 +270,46 @@ func TestCarryForwardActivity(t *testing.T) { } }) } + +// 🔴 產生訊息的那一端,和決定「這則訊息要不要上畫面」的那一端,必須永遠對得上。 +// +// 2026-08-28 第三輪差點實撞:我改了斷路器的措辭,判準沒跟著改 ⇒ 那 9 個被跳過的檔 +// 會**連一句原因都沒有地從畫面消失**。不會報錯、不會有紅字,就是安靜地不見。 +// 這條網把兩端綁在一起:斷路器講的每一種話,都要能被 explainsWhySkipped 認得。 +func TestSkipMessagesAlwaysExplainThemselves(t *testing.T) { + g := newRoundGuard() + g.announce = func(StalledCall) {} + + // ① 從沒回應過的帳號(措辭:沒有回應) + first := g.strike("dead.example", stepIngestCard, 60*time.Second) + // ② 連續第二次 ⇒ 跳閘,這句會被貼在每一個被跳過的檔上 + tripped := g.strike("dead.example", stepIngestCard, 60*time.Second) + // ③ 回應過、只是慢的帳號(措辭:回得太慢) + g.succeeded("slow.example") + slow := g.strike("slow.example", stepIngestCard, 60*time.Second) + + for name, msg := range map[string]string{ + "第一次逾時": first, + "跳閘(貼在被跳過的檔上)": tripped, + "回得太慢": slow, + } { + if msg == "" { + t.Fatalf("%s:訊息是空的", name) + } + if !explainsWhySkipped(msg) { + t.Fatalf("🔴 %s 的訊息不會被 status.json 收進失敗清單 ⇒ "+ + "畫面上那些檔會沒有任何原因地消失。\n訊息:%s", name, msg) + } + // 產品文案:不准漏出內部語彙 + for _, banned := range []string{"HTTP", "context", "timeout", "goroutine", "token"} { + if strings.Contains(msg, banned) { + t.Fatalf("🔴 %s 漏出內部語彙「%s」:%s", name, banned, msg) + } + } + } + + // 反面:一句不講原因的話,不該被收進去(不然畫面會被噪音塞滿) + if explainsWhySkipped("跳過") { + t.Fatal("不講原因的訊息不該被當成「講得出原因」") + } +}