Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ba584002d0 | |||
| d052c33d26 | |||
| e4327ecd37 |
@@ -47,6 +47,31 @@
|
||||
|
||||
---
|
||||
|
||||
## 0.18.56(2026-09-17)
|
||||
|
||||
- 🔴 **被「雲端一次處理太多」擋下、已暫停的筆記,雲端更新後會自己送上去**:免費方案的知識庫在 9 月 13 日前有個上限問題,內容多一點的卡片會被雲端退回「Too many subrequests」,記滿 8 次後小幫手就暫停不再試。雲端早已修好(1.4.64 起),但那批暫停的檔以前要改檔或按「立刻同步」才會再送。現在小幫手看到知識庫已經是修好的版本,就會自己再送一次,不用你動手。
|
||||
- **雲端還沒更新的話,照舊暫停、不會一直重撞**;在已經修好的雲端上仍被退回的卡(內容真的太大),也只會試這一次,不會反覆消耗額度。
|
||||
|
||||
## 0.18.55(2026-09-16)
|
||||
|
||||
- 🔴 **以前被記成「連續失敗 8 次、已暫停」的空白筆記,更新後會自己消掉警告**:上一版只讓新遇到的空白檔直接略過,舊版留下的那批(被雲端退回的空日記)還一直掛著「已暫停自動重試」。現在也會被認出是空白檔,改成略過,資料夾不再掛警告。
|
||||
- **當時雲端還沒有 AI 功能而暫停的檔,雲端更新後會自己再試**:不用改檔、也不用按「立刻同步」。
|
||||
|
||||
## 0.18.54(2026-09-16)
|
||||
|
||||
- 🔴 **網路一時不通,不會再讓小幫手停工半小時**:以前電腦剛睡醒、網路還沒接上的那幾秒,小幫手送不出去的每一次都被算成「雲端壞了」,累積幾次就停下來、畫面寫「連續失敗 11 次,先停 26 分鐘再試」——其實雲端好好的。現在只有「真的打到雲端、雲端出錯或一直不回應」才算數;網路沒通不算,網路一恢復,一分鐘內自己接著送,不用按「立刻同步」。
|
||||
- **「連續失敗」是真的連續**:以前兩天內零零星星失敗幾次也會一直加上去,最後停下來。現在隔了一個小時以上沒再出錯,就重新算起。
|
||||
- **停下來時會說是哪一個知識庫、最近一次是怎麼失敗的**:以前那句話只寫「雲端 AI 整理文件」這條路失敗,接了好幾個知識庫的人看不出是哪一個;現在會寫「知識庫『geek6688』的『雲端 AI 整理文件』這條路連續失敗 4 次(最近一次 17:48:等不到回應)」。
|
||||
- **以前因為網路沒通而被「暫停自動重試」的檔案,會自己重新送**:更新後不用改檔、也不用按按鈕。檔案本身有問題的(例如 PDF 讀不出文字)照舊暫停。
|
||||
- **空白的筆記(例如 Logseq 自動開的空日記)不會再顯示成失敗**:以前這種檔會被送去雲端、被退回、最後被記成「連續失敗 8 次」,資料夾一直掛著警告。現在直接略過,寫了內容之後會自動送。
|
||||
- 紀錄檔裡每一筆結果都帶時間,每一次被算進「連續失敗」的錯誤也會當下記一行(什麼時候、哪一種),出問題時查得出來。
|
||||
|
||||
## 0.18.53(2026-09-14)
|
||||
|
||||
- 🔴 **資料夾上的數字會跟著真的送上雲端的份數走**:以前小幫手在「同步中」的時候,展開資料夾看到的每一層都是「0 / 總數」,要等整輪(檔案多、知識庫回得慢時可能好幾個小時)全部跑完才會變——即使雲端早就收到好幾份、AI 已經查得到。現在每送上一份,數字就跟著加一,展開著的資料夾幾秒內就會更新。
|
||||
- **「同步中」底下會說正在做什麼、卡在哪**:以前只有一句不會變的「請稍候,完成後會顯示整理了幾份」,分不出是在做還是卡住了。現在會寫「正在處理 ISEP(leo21c):送出一份筆記 · 這一輪已送上 11 份」;知識庫太久沒回應時,會直接寫「卡在『KB(leo21c)』:還在等知識庫回覆,已經等了 30 秒」。
|
||||
- 修了一個跑完也不會對的地方:以前就算整輪同步做完,資料夾結構上的「已同步」數字仍是開始同步前的,要到下一輪才對;現在做完當下就是對的。
|
||||
|
||||
## 0.18.52(2026-09-13)
|
||||
|
||||
- 🔴 **雲端資料庫的免費額度用完時,小幫手會直接告訴你**:以前 Cloudflare 免費帳號當天的資料庫讀取(或寫入)額度用完,小幫手只會顯示看不懂的錯誤(例如「缺少 credential」)或「連續失敗、已暫停自動重試」,看起來像東西壞了。現在首頁會出現一張卡,寫明是哪一種額度用完、免費方案的上限是多少(讀取每天 500 萬列、寫入每天 10 萬列)、台北時間幾點恢復(早上 8:00),以及恢復後會自動接著傳、你不用做任何事。
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"_algo": 4,
|
||||
"version": "0.18.52",
|
||||
"fingerprint": "5ab62f966033ea4b",
|
||||
"version": "0.18.56",
|
||||
"fingerprint": "d333ded9d0608cd9",
|
||||
"files": {
|
||||
".gitignore": "c7a98c44ecc882a174720e43dabd4c04d7cf2503586426dcccaa213d5a27df38",
|
||||
"CHANGELOG.md": "718ac1789302c16de2af1595b4081a0b0b2eff88ffd007f33f78720b79521c18",
|
||||
"CHANGELOG.md": "17a4329d7e6ac9c9e426ec1e51658b18a48c15b8b6f5598c739404cf394ca572",
|
||||
"DAEMON_LINE": "d5019abbdc8a5f2919e9e3510391891cd7fbdf0765bf16ec83caa779f370116d",
|
||||
"README.md": "9d92cac236b20a0b183eea3e7f5e39ad492f05192c4ea602eb11c3d09967327f",
|
||||
"cardloop_test.go": "fdf09b62650c328f5d8075d964a0ca2dada5f8d9a271202768472998b45e31a8",
|
||||
@@ -25,7 +25,7 @@
|
||||
"cloudquota_test.go": "c2ab6ada0b09f2092a07b84e00090bb74dc81f8204906dde8dd4c53cfd24b9c1",
|
||||
"cmd/arcrun-app/.gitignore": "91248524c8b00d155960f42ad6707ce36bd91ff44f66289eec45972d3915ad37",
|
||||
"cmd/arcrun-app/README.md": "23e53fdf53fcd96b6d8d7a121eb91d3242d11b69d655770473b745c49bb387b9",
|
||||
"cmd/arcrun-app/app.go": "098e6a01aa0a298248ae9cc96275b209e5b8c9669325ba691c46383470216698",
|
||||
"cmd/arcrun-app/app.go": "55fddf43ad52aed29214d7a5a2ff4985acbdb2d0c7c0b724c2008e30e3f392e6",
|
||||
"cmd/arcrun-app/apps.go": "0df6da9d55adbdec500fbb01005a305287d1dbf796b7e463f2b2f222626b4c96",
|
||||
"cmd/arcrun-app/apps_test.go": "0e2f137f8b49f421e869ab3192ca12c8bb756029d8730487ccc44733aaf7f96c",
|
||||
"cmd/arcrun-app/assets/store/README.md": "a9028a727947c5038ca938c664b18e4851ae3bf0e6d8838c82aedea43d02ded2",
|
||||
@@ -95,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": "2c4ac3017bead3a99812ce8919a513fb0e23dd752285a426c649c6e774d0f08d",
|
||||
"cmd/arcrun-app/frontend/src/main.js": "f521930ec60300b5fb80400d40d3cd9da2a9bd8579d042e81f0db6635636d2f8",
|
||||
"cmd/arcrun-app/frontend/src/style.css": "2cc59ab0be7668f2b8e2e42f655beb506a91f2d31414e0a40dbab33fffc8349a",
|
||||
"cmd/arcrun-app/gen-icons.py": "df5b3b33e500fc564badfc8bd2a9ca6a43f7d7e3b31111c41e3b322137c4c96c",
|
||||
"cmd/arcrun-app/go.mod": "8b9e57bcfd44886d1ec06480e033af706ff3fed470786c57074900dd48ecf86f",
|
||||
@@ -110,6 +110,8 @@
|
||||
"cmd/arcrun-app/selfupdate_test.go": "1c0752f3b3f4f262620e096558ded38ed21db94195b760a3f3b046ce9dbdd4ab",
|
||||
"cmd/arcrun-app/skipped_test.go": "f4c9de16c349c0b7f48ce62a6506364c87e3d4fbc99f415e3e6aec7d3da7f1aa",
|
||||
"cmd/arcrun-app/supervise.go": "f7eb6c74524b5d8d0abe520b831a98b85d19d18c00d1a585493ababcbd01c81f",
|
||||
"cmd/arcrun-app/syncing_sub.go": "8d681209809da3324fddb30a06319ffaa19907bfa3763109ca2e33ae6ac50b5f",
|
||||
"cmd/arcrun-app/syncing_sub_test.go": "db32b573d087bbef3f1c69e30b714a02be64ea169a8f777962561e8f9f1c6259",
|
||||
"cmd/arcrun-app/tray_darwin.go": "ff3cb22fc2663b08b9f3cd14887ab4411ed338ec8920b58ad0fac029f09edae0",
|
||||
"cmd/arcrun-app/tray_darwin.m": "b4ddf0bb420adf00d580d65c8cfbbe130a78e803dd1e92a1617ddaf6ef172fac",
|
||||
"cmd/arcrun-app/tray_other.go": "0c3df95b5f7999f6319d0bf5e24fdbf821bf97a79db04077d9e7eaf358299880",
|
||||
@@ -128,7 +130,7 @@
|
||||
"convert_table_test.go": "d0371b7566ef3152f9dd42f9f990e0dffa1c50a0c8e874a28415fa2e4c188394",
|
||||
"convert_test.go": "04f3fa30d1be5f910c0e0be3308ced2963191ab030a9eaccd986ef581fcd4e18",
|
||||
"convert_wiring_test.go": "3389817e596341369a3fc102846bf5b081cee668e86ebf4830eb633978de652b",
|
||||
"direct.go": "3a79d03fce931115739a0113494a61331155172c7f9e5cbee126203b3e616458",
|
||||
"direct.go": "e3d1aa9ad54bec0ff80dfa96fd0331f47590a1863cb9586497f49fae3c560277",
|
||||
"direct_extract_test.go": "317d0057d2d368fabc406351a7477f72e074d2b8c8459b1dc021febdf16902f4",
|
||||
"direct_multi_test.go": "a76593fef26231e18b7402f6f7376624d2471f8a719b3ce08fc4a0f5a3d3cf1e",
|
||||
"direct_pacing.go": "b54838ce85f72ecd2d3388fa7cf89050c87fb10bd67dca07cd18d49568a8837c",
|
||||
@@ -148,7 +150,7 @@
|
||||
"folderindex_helpers_test.go": "2cf17b70c396225f728e8e160cfc75d7bdc76ac1ce8e574d1450b020050c0734",
|
||||
"folderindex_test.go": "a5319eb1e92473cb5c32d3beddb7889997556227824de300dd39953b43beab70",
|
||||
"foldershape.go": "b1b237f5674674efdc9bdea849b63c571a014e64bec33af42fb7c4769fa7582b",
|
||||
"foldertree.go": "fb86c8d869a476d77d548e9c9a7c14be51c76fa1400a6497016b705ff9c4ce13",
|
||||
"foldertree.go": "7a7d478f7c6fb75536d149d1a61a0643358d77b3de5e5b043397052923c74784",
|
||||
"foldertree_live_test.go": "ace96ffdf7670a69761973e810fb6548c7036d2768141e09db515313df1e9a50",
|
||||
"foldertree_local_test.go": "ce919dc2a21ec96716e3d43a7f493367346e212243b4ab22c2d960fc26408f3a",
|
||||
"foldertree_machine_test.go": "f5dc73afc261b0fb0298f27962628e8538abc704540b52d66e13bf85600a59dd",
|
||||
@@ -168,29 +170,32 @@
|
||||
"inventory_test.go": "16e0f83a09d61dc4cded032fd493d30bfc747c38116b871fb02b2f0bf2b0216a",
|
||||
"lint.go": "63af1289978a02c9722ac8bb934a916b9e4e65cffcfec0749eb07590496960f3",
|
||||
"lint_test.go": "2f57a04b9d697fba2d078eb08f49d2fec1640c7e3c61a60bc3821d2bb0798153",
|
||||
"live_status.go": "3395ea5cfcf1ddb137e6e3e2d243db88626dd3d2dd313c594f09cde9986eba54",
|
||||
"live_status_test.go": "883ea2b9b70f41e20bd861b124f9101cbf98cab14fb7a3a98bc4b309eb9ffbb2",
|
||||
"logseq_bak_residue_test.go": "52aa2f47ac12ca2e77e6112d6de88c873f1f418bae32b2a4de7a35028f835257",
|
||||
"machineid.go": "d4224e684ecfbafe2efc7e0b95377f9e669ee1256a442aa72ce2cab3b219fc56",
|
||||
"machineid_test.go": "5b54ac62ee1fb28b8f0a873888aeddb844af0aa74472bfb849573cfc6691a290",
|
||||
"machinemark.go": "592a39597fd011e8b1603318eaedf452aa58fca0639244a80218aa9a0ac6dbf5",
|
||||
"main.go": "fe2f69739f3ee92713f8aaca925068ecf97b33d476eee826fc1e0d5def8f0d10",
|
||||
"manifest.go": "c6b6fbdd5cc6963345048aa1b0a32f61b7b0b5b84616a15827aea290bd3758e1",
|
||||
"manifest.go": "5af553d949b40a79d9342aee870f07ff445e797357ad4dafce5193f484769828",
|
||||
"manifest_extractedby_test.go": "8019fe35675a1e801b7bda829ee1d647de32db03c029b1c651f71573e644c69e",
|
||||
"manifest_retry_test.go": "e6b4a321ccddfeed8cdbfabe6d031d5a9c59a33b47baf0b6d21150fa8406748a",
|
||||
"multiaccount_connected_test.go": "ba1817b73a5dd0dd5d300750e54d5c53bd82a75ef6e43caeddfcb2303d64ff66",
|
||||
"probe_real_manual_test.go": "6170cd7d41b6e099ad93e229f55e5f9d64d509412969f9bf162788ae8e787aef",
|
||||
"probe_workersai.go": "ce0ceb63377e4b0181bf6acb1c14c5cc0c75173360e0e27392cb9e5b18e2cffc",
|
||||
"progress.go": "98380d3c374570f3182200484b9b2b6a093d41531433b80eb78a92cf94dec69a",
|
||||
"progress.go": "0fc621f14d38aaa021e6df486eaf64234ade2591d250ab0c79d9fbc3a6c94590",
|
||||
"progress_test.go": "21258889ad7680da2d6cf8812accda7e6a1aca6c6fd604394ec4363c2ed902bb",
|
||||
"progress_wiring_test.go": "d1d144269539825ef4a4d9ffca5c49b021cb27cecea7d280309f3f935460b3cd",
|
||||
"quota.go": "c6596993a0103b8a22c7148d6f9adeec0da09dceaaba040b201476b920027fa4",
|
||||
"quota_test.go": "a91edeb71bf5f3aaa9bd68ad5ec0c87d1e620acc8072e3114098db4d8fc1a896",
|
||||
"repoguard.go": "bcbfab2b76b8fb6afa67aa30a030931a127bae4d9d4a363c40522457ab192c1b",
|
||||
"repoguard_test.go": "ce0316a59af944c3cf391961653fe82294fd45e728d35b4b7fcd99b2b20e7b5c",
|
||||
"route_backoff_measure_test.go": "d09463f309ec0c7e9c67523717a1962908a38b9d5338edd72e49e151cfd264eb",
|
||||
"routebackoff.go": "09a23fef781c174fe3d257e42da58540bd6971aff7151ffa6fb5188de9188e6f",
|
||||
"route_backoff_measure_test.go": "5eba1d1453df3ad12ce840eeb130567f66da4d4cd18485f930208ab6068812ae",
|
||||
"routebackoff.go": "e1ffffad09172d04583ea4fc4dd2b9a67e7f542028a21b0c005cbcabbac11c50",
|
||||
"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",
|
||||
@@ -199,13 +204,14 @@
|
||||
"sourcerepair.go": "59fa1956181904f6661ab397e42b4f1c9b8ac81f382fdf720cc5b0831390f9d9",
|
||||
"sourcerepair_live_test.go": "2a7f5d6fada420a7f55fa52b87361842755c68c1cb58f8c3fd0153341f709a0a",
|
||||
"sourcerepair_test.go": "2a1ddddaad8ac7adc36350859e2d742c0fea12b99370a4d4831d390db1cc6720",
|
||||
"stallguard.go": "13be8ae905492054d516f466856bc6949b16e1af6ea32068f94b21635799bdc6",
|
||||
"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": "f79b32e92e67058a056dc749a6182573eefcbbc9bc7b2cd5a997f4e8066a004e",
|
||||
"supervisor/supervisor.go": "aec9357579bc0832e5179918912e86dbe74fc86f49904e881a6be807f59bc0ae",
|
||||
"supervisor/supervisor_test.go": "f5582d54c55065fbc834fe96754df9c8cd6efe9f88f30208bbce84ddea08fea7",
|
||||
"sync_status.go": "799d3481c361647c9cf1ae40ac4c550d6690a87f843c514f8b830d811e7bba1f",
|
||||
"sync_status.go": "353256d46d5cc9521c7798637dbaa06d2d0deabf163605409ca94e59fa2e2098",
|
||||
"sync_status_test.go": "73bbc83c9e8fc18575895a7b7b5feab7a76d41f05a74d74bbbdbf98b045ccfa2",
|
||||
"t52_library_test.go": "14188d0afdad0f9aa16d19a74ea28c45c3d18fcfeabc5deb6fb0466d05778a51",
|
||||
"template_install.go": "d0aa0098e431696a63ef8d3714c124591b8ba193ccd5a8359a4ea03e397ee358",
|
||||
@@ -249,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",
|
||||
|
||||
@@ -22,5 +22,9 @@
|
||||
"0.18.49": "7cb86e646aeaa057",
|
||||
"0.18.50": "b30716c3ccec9e65",
|
||||
"0.18.51": "3cfcc815cf185a68",
|
||||
"0.18.52": "5ab62f966033ea4b"
|
||||
"0.18.52": "5ab62f966033ea4b",
|
||||
"0.18.53": "32f62120aa472b65",
|
||||
"0.18.54": "44cf7dbf8246968a",
|
||||
"0.18.55": "287d990df2baa78c",
|
||||
"0.18.56": "d333ded9d0608cd9"
|
||||
}
|
||||
|
||||
@@ -126,6 +126,8 @@ type syncStatus struct {
|
||||
// 同上:形狀定義在 collector/progress.go/sync_status.go,這裡原樣接住。
|
||||
// 這是資料夾那一列打不打勾的**唯一**依據(見 folder_badge.go)。
|
||||
FolderProgress map[string]collector.SyncProgress `json:"folder_progress,omitempty"`
|
||||
// arcrun-rag#200:一輪還沒跑完時做到哪(形狀定義在 collector/sync_status.go)。
|
||||
InRound *collector.RoundProgress `json:"in_round,omitempty"`
|
||||
}
|
||||
|
||||
type skippedDoc struct {
|
||||
@@ -649,7 +651,7 @@ func describeStatus(s syncStatus) (syncing bool, big, sub string) {
|
||||
return false, "同步引擎沒有在跑", sub
|
||||
}
|
||||
if collectorSyncing() {
|
||||
return true, "同步中… 正在讀檔並整理成知識卡", "請稍候,完成後會顯示整理了幾份"
|
||||
return true, "同步中… 正在讀檔並整理成知識卡", syncingSub(s.InRound, time.Now(), accountLabelFor)
|
||||
}
|
||||
if s.ExtractorError != "" && !s.ExtractorOK {
|
||||
return false, "需要你處理一下", "⚠ " + s.ExtractorError
|
||||
|
||||
@@ -1140,7 +1140,39 @@ function render(s) {
|
||||
if (first && page === 'apps' && (s.accounts || []).length) loadApps(appsAccIdx);
|
||||
}
|
||||
|
||||
async function tick() { try { render(await go.GetState()); } catch (e) {} }
|
||||
async function tick() { try { render(await go.GetState()); } catch (e) {} refreshOpenTrees(); }
|
||||
|
||||
// 🔴 `inkstone/arcrun-rag#200`:展開著的樹要跟著同步進度走。
|
||||
//
|
||||
// 以前樹第一次展開讀一次就一直留在記憶體裡——而 collector 一輪途中每送上一份就會
|
||||
// 重寫 folder-trees.json。leo 2026-09-13 看著 ISEP 每層 0 / N 等了一整晚,
|
||||
// 雲端那時已經收到 11 份:**就算檔案更新了,畫面也不會去讀**。
|
||||
//
|
||||
// 只重讀「現在展開著」的那幾棵,每 3 秒一次(不是每秒——樹上限 300 個節點,
|
||||
// GetFolderTree 註解 ③ 講過為什麼不掛在每秒的 GetState 上)。內容沒變就不重畫,
|
||||
// 使用者正在點開的節點與「為什麼」不會因為重讀而跳掉。讀失敗不吵,下一次再試。
|
||||
let treeRefreshAt = 0;
|
||||
let treeRefreshing = false;
|
||||
async function refreshOpenTrees() {
|
||||
const now = Date.now();
|
||||
if (treeRefreshing || now - treeRefreshAt < 3000) return;
|
||||
treeRefreshAt = now;
|
||||
treeRefreshing = true;
|
||||
try {
|
||||
for (const path of Object.keys(treeState.open)) {
|
||||
if (!treeState.open[path] || treeState.data[path] === undefined) continue;
|
||||
try {
|
||||
const next = (await go.GetFolderTree(path)) || null;
|
||||
if (JSON.stringify(next) !== JSON.stringify(treeState.data[path])) {
|
||||
treeState.data[path] = next;
|
||||
renderFolderTree(path);
|
||||
}
|
||||
} catch (e) { /* 下一次再試 */ }
|
||||
}
|
||||
} finally {
|
||||
treeRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 動作 ──
|
||||
$('btnSync').onclick = async () => { await go.SyncNow(); tick(); };
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// syncing_sub.go — 「同步中」底下那一行要講得出做到哪、卡在哪(`inkstone/arcrun-rag#200`)。
|
||||
//
|
||||
// 🔴 病(2026-09-13 leo 截圖):畫面整晚寫「同步中… 正在讀檔並整理成知識卡/
|
||||
// 請稍候,完成後會顯示整理了幾份」,而 collector.log 裡明明印著
|
||||
// 「還在等知識庫 leo21c 回覆『送出一份筆記』,已經等了 30 秒」。
|
||||
// **會說話的那句只寫進了記錄檔,畫面上是一句永遠不變的「請稍候」**——
|
||||
// 使用者分不出「正在做」和「卡住了」,只能猜。
|
||||
//
|
||||
// 資料來源只有一個:collector 一輪途中寫進 status.json 的 in_round(collector/live_status.go)。
|
||||
// 這裡不判斷任何事,只把它念成一句話。
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"arcrun-rag/collector"
|
||||
)
|
||||
|
||||
// syncingIdleWarnAfter=多久沒有任何新寫入,就明講「已經 N 分鐘沒有新進展」。
|
||||
// 「還在等」每 30 秒會寫一次,所以正常等待不會碰到這條;碰到=真的沒在動。
|
||||
const syncingIdleWarnAfter = 5 * time.Minute
|
||||
|
||||
const syncingSubFallback = "請稍候,完成後會顯示整理了幾份"
|
||||
|
||||
// syncingSub 把「這一輪做到哪」念成一句話。r 為 nil(舊版 collector、剛開工還沒寫)=沿用原本那句。
|
||||
// label 把主機名翻成使用者認得的知識庫名字(測試可以換掉)。
|
||||
func syncingSub(r *collector.RoundProgress, now time.Time, label func(host string) string) string {
|
||||
if r == nil {
|
||||
return syncingSubFallback
|
||||
}
|
||||
where := ""
|
||||
if r.Account != "" {
|
||||
where = label(r.Account)
|
||||
}
|
||||
if r.Folder != "" {
|
||||
name := filepath.Base(r.Folder)
|
||||
if where != "" {
|
||||
where = fmt.Sprintf("%s(%s)", name, where)
|
||||
} else {
|
||||
where = name
|
||||
}
|
||||
}
|
||||
|
||||
// 卡住的時候,只講卡在哪——這是使用者此刻唯一需要知道的事。
|
||||
if w := r.Waiting; w != nil && strings.TrimSpace(w.Note) != "" {
|
||||
if where != "" {
|
||||
return fmt.Sprintf("卡在「%s」:%s", where, w.Note)
|
||||
}
|
||||
return "卡住了:" + w.Note
|
||||
}
|
||||
|
||||
var parts []string
|
||||
if where != "" {
|
||||
head := "正在處理 " + where
|
||||
if r.Step != "" {
|
||||
head += ":" + r.Step
|
||||
}
|
||||
parts = append(parts, head)
|
||||
}
|
||||
if r.Ingested > 0 {
|
||||
parts = append(parts, fmt.Sprintf("這一輪已送上 %d 份", r.Ingested))
|
||||
}
|
||||
if r.Failed > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d 份沒送成功", r.Failed))
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, r.UpdatedAt); err == nil {
|
||||
if idle := now.Sub(t); idle > syncingIdleWarnAfter {
|
||||
parts = append(parts, fmt.Sprintf("已經 %d 分鐘沒有新進展", int(idle.Minutes())))
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return syncingSubFallback
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
// accountLabelFor 把 status.json 裡的主機名翻成畫面左邊那個知識庫名字(與 UIAccount.Name 同一套)。
|
||||
func accountLabelFor(host string) string {
|
||||
if cfg, err := loadCfg(); err == nil {
|
||||
for _, a := range cfg.Accounts {
|
||||
if shortHost(a.CypherURL) == host {
|
||||
return accountName(a)
|
||||
}
|
||||
}
|
||||
}
|
||||
return host
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"arcrun-rag/collector"
|
||||
)
|
||||
|
||||
// TestSyncingSubSaysWhereAndWhy 守住 `inkstone/arcrun-rag#200`:
|
||||
// 「同步中」底下那一行要講得出做到哪、卡在哪,不再是一句永遠不變的「請稍候」。
|
||||
func TestSyncingSubSaysWhereAndWhy(t *testing.T) {
|
||||
now := time.Date(2026, 9, 14, 0, 30, 0, 0, time.Local)
|
||||
label := func(host string) string {
|
||||
if host == "arcrun-cypher-executor.leo21c.workers.dev" {
|
||||
return "leo21c"
|
||||
}
|
||||
return host
|
||||
}
|
||||
fresh := now.Add(-10 * time.Second).Format(time.RFC3339)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in *collector.RoundProgress
|
||||
want []string // 全部都要出現
|
||||
not []string // 一個都不准出現
|
||||
}{
|
||||
{
|
||||
name: "舊版 collector 沒寫 in_round=沿用原本那句",
|
||||
in: nil,
|
||||
want: []string{syncingSubFallback},
|
||||
},
|
||||
{
|
||||
name: "正在送=講資料夾、知識庫、哪件事、送了幾份",
|
||||
in: &collector.RoundProgress{
|
||||
Account: "arcrun-cypher-executor.leo21c.workers.dev", Folder: "/Users/x/Documents/tech_projects/ISEP",
|
||||
Step: "送出一份筆記", Ingested: 11, UpdatedAt: fresh,
|
||||
},
|
||||
want: []string{"正在處理 ISEP(leo21c)", "送出一份筆記", "這一輪已送上 11 份"},
|
||||
not: []string{"請稍候", "分鐘沒有新進展"},
|
||||
},
|
||||
{
|
||||
name: "卡住=只講卡在哪",
|
||||
in: &collector.RoundProgress{
|
||||
Account: "arcrun-cypher-executor.leo21c.workers.dev", Folder: "/Users/x/Documents/KB",
|
||||
Step: "送出目錄索引", UpdatedAt: fresh,
|
||||
Waiting: &collector.StalledCall{Step: "送出目錄索引",
|
||||
Note: "還在等知識庫「arcrun-cypher-executor.leo21c.workers.dev」回覆「送出目錄索引」,已經等了 30 秒。"},
|
||||
},
|
||||
want: []string{"卡在「KB(leo21c)」", "已經等了 30 秒"},
|
||||
not: []string{"請稍候", "正在處理"},
|
||||
},
|
||||
{
|
||||
name: "很久沒有任何寫入=明講沒在動",
|
||||
in: &collector.RoundProgress{
|
||||
Account: "arcrun-cypher-executor.leo21c.workers.dev", Step: "確認雲端 AI 可不可以用",
|
||||
UpdatedAt: now.Add(-12 * time.Minute).Format(time.RFC3339),
|
||||
},
|
||||
want: []string{"正在處理 leo21c", "已經 12 分鐘沒有新進展"},
|
||||
},
|
||||
{
|
||||
name: "有失敗也要說",
|
||||
in: &collector.RoundProgress{
|
||||
Account: "h", Folder: "/a/B", Ingested: 2, Failed: 1, UpdatedAt: fresh,
|
||||
},
|
||||
want: []string{"這一輪已送上 2 份", "1 份沒送成功"},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := syncingSub(c.in, now, label)
|
||||
for _, w := range c.want {
|
||||
if !strings.Contains(got, w) {
|
||||
t.Errorf("缺「%s」:%q", w, got)
|
||||
}
|
||||
}
|
||||
for _, n := range c.not {
|
||||
if strings.Contains(got, n) {
|
||||
t.Errorf("不該出現「%s」:%q", n, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 前跑。
|
||||
@@ -601,6 +605,37 @@ type DirectResult struct {
|
||||
Status string `json:"status"` // ingested | removed | planned | failed | skipped
|
||||
HTTPStatus int `json:"http_status,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// At=這一筆結果產生的時間(`inkstone/arcrun-rag#201`,leo 09-16:「應該加上時間」)。
|
||||
// 每輪 results 會把還沒解決的舊錯誤再印一次;沒有時間就分不出哪一筆是剛剛發生的。
|
||||
At string `json:"at,omitempty"`
|
||||
// LastFailAt=「這一筆是在講以前的失敗」時,那次失敗真正發生的時間(被退避擋下的檔)。
|
||||
LastFailAt string `json:"last_fail_at,omitempty"`
|
||||
}
|
||||
|
||||
// emptyProbeMaxBytes=多大以下的檔才去讀內容判斷「是不是空白」(#201)。
|
||||
// 只有空白字元的檔不會大;上限讓這道判斷不必每輪去讀大檔。
|
||||
const emptyProbeMaxBytes = 4096
|
||||
|
||||
// networkDownNote=「這台電腦現在連不上網路」的白話(#201)。
|
||||
// 🔴 結尾「後重試」是 explainsWhySkipped 的識別字:畫面要講得出這份為什麼沒送。
|
||||
func networkDownNote(cfg *DirectConfig, err error) string {
|
||||
who := strings.TrimSpace(cfg.InstanceName)
|
||||
if who == "" {
|
||||
who = instanceHostOf(cfg.CypherURL)
|
||||
}
|
||||
return fmt.Sprintf("這台電腦現在連不上知識庫「%s」(網路沒通或找不到網址),這份還沒送出,不算失敗;1 分鐘後重試|原因:%s",
|
||||
who, err.Error())
|
||||
}
|
||||
|
||||
// stampResults 給還沒有時間的結果補上「現在」(#201)。
|
||||
// 逐檔處理的那兩處在建立結果時就蓋了章;這裡是保底,讓 log 裡**每一筆**都帶時間。
|
||||
func stampResults(r []DirectResult) {
|
||||
now := directNow().Format(time.RFC3339)
|
||||
for i := range r {
|
||||
if r[i].At == "" {
|
||||
r[i].At = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// makeAccountSubConfig 從帳號設定建出單帳號用的 DirectConfig,繼承機器層級欄位(t104)。
|
||||
@@ -669,6 +704,11 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
if cfg.Manifest != "" {
|
||||
prevStatus, _ = LoadSyncStatus(StatusFilePath(cfg.Manifest)) // 讀不到=零值,等同「沒有上一輪」
|
||||
}
|
||||
// 🔴 `inkstone/arcrun-rag#200`:一輪開始就寫一筆「正在跑」,之後每件事發生的當下更新
|
||||
// (見 live_status.go)。放在載入 prevStatus **之後**:途中寫入不該影響這一輪讀到的上一輪。
|
||||
if !dryRun && cfg.Manifest != "" {
|
||||
cfg.guard.beginRound(StatusFilePath(cfg.Manifest), now)
|
||||
}
|
||||
|
||||
// t92-②:預檢 extractor(機器層級),有 fallback 路徑時就地更新 cfg.ClaudeBin。
|
||||
extractorOK := true
|
||||
@@ -792,6 +832,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
}
|
||||
accCfg := cfg.makeAccountSubConfig(acc)
|
||||
accHost := instanceHostOf(acc.CypherURL)
|
||||
cfg.guard.enterFolder(accHost, "") // #200:帳號層的事(探測雲端 AI、查版本)也要講得出在誰身上
|
||||
|
||||
// t103:per-account 雲端版本偵測
|
||||
cloudVer, cloudOK := cloudVersionThrottled(accCfg.CypherURL, cfg.ForceSync) // #121:一分鐘問一次,見 cloudcheck.go
|
||||
@@ -816,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),
|
||||
@@ -859,6 +901,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
|
||||
multi := len(accCfg.Folders()) > 1
|
||||
for _, root := range accCfg.Folders() {
|
||||
cfg.guard.enterFolder(accHost, root) // #200
|
||||
r, e, p, rp := runDirectOnceRoot(accCfg, root, dryRun, qs, now)
|
||||
totalProgress = totalProgress.Add(rp.Progress)
|
||||
stuckReasons = append(stuckReasons, rp.StuckReasons...)
|
||||
@@ -880,6 +923,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
r[i].Root = root
|
||||
}
|
||||
}
|
||||
stampResults(r)
|
||||
for i := range r {
|
||||
r[i].Account = accHost // t104: 標明所屬帳號
|
||||
if cfg.Extractor != "" && countsAsDocument(r[i]) {
|
||||
@@ -926,7 +970,9 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
// arcrun-rag#46:把「使用者按了移除並收回」的資料夾撤乾淨。
|
||||
// 放在看守資料夾之後:正在用的資料夾優先,收回是善後。
|
||||
for _, root := range accCfg.RetiringRoots() {
|
||||
cfg.guard.enterFolder(accHost, root) // #200
|
||||
r, e, remaining, done := retireRootOnce(accCfg, root, dryRun)
|
||||
stampResults(r)
|
||||
for i := range r {
|
||||
r[i].Root = root
|
||||
r[i].Account = accHost
|
||||
@@ -979,6 +1025,8 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
|
||||
// t91:每輪寫狀態檔(含 per-account 雲端版本與萃取計數)。
|
||||
if !dryRun && cfg.Manifest != "" {
|
||||
// #200:先封口再寫——晚到的「還在等」不准把收工這份蓋回「同步中」(見 finishRound)。
|
||||
cfg.guard.finishRound()
|
||||
st := SyncStatus{
|
||||
LastSync: time.Now().Format(time.RFC3339),
|
||||
ExtractorOK: extractorOK,
|
||||
@@ -1386,6 +1434,15 @@ func retrySkipReason(m *Manifest, path string, now int64) string {
|
||||
if !ok {
|
||||
return "暫時跳過"
|
||||
}
|
||||
if isLocalNetworkText(e.LastError) {
|
||||
wait := e.NextRetry - now
|
||||
if wait < 0 {
|
||||
wait = 0
|
||||
}
|
||||
// 🔴「後重試」是 explainsWhySkipped 的識別字,不要改掉。
|
||||
return fmt.Sprintf("上次這台電腦沒連上網路(不算這份檔案的失敗),%s 後重試|原因:%s",
|
||||
(time.Duration(wait) * time.Second).String(), e.LastError)
|
||||
}
|
||||
if e.FailCount >= MaxFailBeforeSkip {
|
||||
msg := fmt.Sprintf("連續失敗 %d 次,已暫停自動重試(改檔或按「立刻同步」會再試)", e.FailCount)
|
||||
if e.LastError != "" {
|
||||
@@ -1488,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 在跑到一半被殺掉(重開機、
|
||||
// 換版、當機)時,**已經成功的那些也會遺失**,下次重開等於從頭來過,
|
||||
@@ -1618,6 +1676,45 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
PublishFolderTreeNow(cfg.Manifest, root, tree, runNow)
|
||||
}
|
||||
|
||||
// 🔴 `inkstone/arcrun-rag#200`:上面那棵樹是**開始送之前**算的,分子必然停在開工前。
|
||||
// 以前它就是本機唯一的一棵——收工合併時用的也是它 ⇒ 2026-09-13 ISEP 送上去 11 份,
|
||||
// 畫面每一層照樣 0 / N。
|
||||
//
|
||||
// ⇒ 每處理完一份檔,就用**同一個函式、同一組分母**、manifest 此刻的分子重算一棵,
|
||||
// 立刻落地本機,並把這個資料夾的進度寫進 status.json(live_status.go)。
|
||||
// 分母沿用這一輪掃描的結果:一輪途中使用者新丟的檔,本來就要下一輪才算得到。
|
||||
//
|
||||
// 「removed 暫時放回」的那幾筆不算進樹:它們只是下架重試的記帳,檔案已經不在了
|
||||
//(同開工那棵的判準——那棵算的時候它們還沒被放回去)。
|
||||
//
|
||||
// 🔴 送上 portal 的那棵(syncFolderTree)**不在這裡重送**:每份檔多打一發雲端,
|
||||
// 在一發 24〜57 秒的實例上等於把一輪拉長一倍。portal 下一輪掃描就會拿到新的。
|
||||
latestTree := tree
|
||||
removedNow := map[string]bool{}
|
||||
for _, ev := range payload.Events {
|
||||
if ev.Type == "removed" {
|
||||
removedNow[ev.Path] = true
|
||||
}
|
||||
}
|
||||
liveReport := func(outcome string) {
|
||||
if dryRun {
|
||||
return
|
||||
}
|
||||
entries := m.Entries
|
||||
if len(removedNow) > 0 {
|
||||
entries = make(map[string]*ManifestEntry, len(m.Entries))
|
||||
for k, v := range m.Entries {
|
||||
if !removedNow[k] {
|
||||
entries[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
latestTree = BuildFolderTree(absRoot, cfg.libraryFor(absRoot), payload.DirStats, entries,
|
||||
payload.AllExcludedDirs, plan, runNow).StampMachine(cfg.machineIdentity())
|
||||
PublishFolderTreeNow(cfg.Manifest, root, latestTree, runNow)
|
||||
cfg.guard.fileFinished(root, outcome, m.Progress())
|
||||
}
|
||||
|
||||
// 結構先行(InkStoneCo#43,2026-08-15):掃描一結束(純本機、免費、秒級)就先把
|
||||
// 「這個資料夾有哪些檔案/最近改了什麼」送上知識庫,**不等 LLM 萃取、不受額度影響**
|
||||
// ——走 rag_ingest_card(零 LLM 的機械收口),所以刻意放在:
|
||||
@@ -1725,7 +1822,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
//(頁名可能改變=要新頁名的卡)。新路徑這邊的冪等由 kbdb 端承擔(同頁名覆蓋語意);
|
||||
// 舊路徑那邊不會自動消失——上面已經把 ev.OldPath 排進 m.PendingTakedowns,
|
||||
// 這裡送完新卡之後、本函式結尾會補打下架(InkStoneCo#44 ⑩)。
|
||||
res := DirectResult{Type: ev.Type, Path: ev.Path}
|
||||
res := DirectResult{Type: ev.Type, Path: ev.Path, At: directNow().Format(time.RFC3339)}
|
||||
// 2026-08-07 pacing task 2:帳號還在額度冷卻中 → 這輪連試都不試。
|
||||
// 這不是這個檔的問題(不記 FailCount/退避——那是「這個檔」的病歷,
|
||||
// 額度用完是「整個帳號」的狀態,混在一起會讓退避階梯失真)。
|
||||
@@ -1773,6 +1870,27 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
}
|
||||
}
|
||||
}
|
||||
// 🔴 #201:從沒送上去過的**空白**純文字檔(Logseq 自動開的空日記最常見)⇒ 不送。
|
||||
// 舊版照送,雲端回 400「page_name 與 text 必填」,這個檔被記成失敗、記滿 8 次永久暫停,
|
||||
// 資料夾於是永遠掛著一個 ⚠。leo 的 KB 有 12 份就是這樣(全是 0 位元組的日記)。
|
||||
// 記成「已處理、沒有卡上雲」:之後寫了內容,雜湊一變就會照常送。
|
||||
// 🔴 放在逐檔退避(ShouldRetry)**之前**:那批已經被舊版記滿 8 次的空檔,
|
||||
// 也要在這裡被重新判成「空白略過」,不然它們會永遠掛著舊的暫停紀錄
|
||||
// (0.18.54 在 leo 的 Mac 上第一輪實撞:6 份還顯示「連續失敗 8 次|HTTP 400」)。
|
||||
// 已經送上去過、後來才被清空的檔不走這裡(那是「要不要下架」的題目,不在這一刀)。
|
||||
if !dryRun && cfg.Extractor != "" && IsPlainText(ev.Path) {
|
||||
if e := m.Entries[ev.Path]; e != nil && e.IngestedHash == "" && e.Size <= emptyProbeMaxBytes {
|
||||
if raw, rerr := os.ReadFile(filepath.Join(absRoot, filepath.FromSlash(ev.Path))); rerr == nil && strings.TrimSpace(string(raw)) == "" {
|
||||
m.MarkIngestedBy(ev.Path, ev.SourceHash, now, cfg.Extractor)
|
||||
m.MarkNoCloudCard(ev.Path)
|
||||
saveManifest()
|
||||
res.Status = "skipped"
|
||||
res.Error = "空白檔案,沒有內容可以整理(寫了內容之後會自動送)"
|
||||
results = append(results, res)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
// 🔴 t195 止血點:這個檔剛失敗過且還在退避窗口內 → 這輪跳過。
|
||||
// 沒有這道閘時的實測災情:`小果被AFTEE詐貸.pdf` 因雲端 401 失敗,
|
||||
// 每輪重掃又被當成新檔 ⇒ **1387 輪、跨 11 小時**,且它排在佇列前面,
|
||||
@@ -1783,6 +1901,9 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
if !m.ShouldRetry(ev.Path, now, cfg.ForceSync) {
|
||||
res.Status = "skipped"
|
||||
res.Error = retrySkipReason(m, ev.Path, now)
|
||||
if e := m.Entries[ev.Path]; e != nil && e.LastFailAt > 0 {
|
||||
res.LastFailAt = time.Unix(e.LastFailAt, 0).Format(time.RFC3339) // #201:那次失敗真正的時間
|
||||
}
|
||||
results = append(results, res)
|
||||
continue
|
||||
}
|
||||
@@ -1792,6 +1913,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
res.Status, res.Error = "failed", "讀檔失敗:"+rerr.Error()
|
||||
m.MarkFailed(ev.Path, now, res.Error) // t195:讀不到的檔也退避(權限/被鎖/壞掉的外接碟)
|
||||
saveManifest()
|
||||
liveReport("failed") // #200
|
||||
results = append(results, res)
|
||||
exit = 1
|
||||
continue
|
||||
@@ -1835,9 +1957,9 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
// 掛上去的話,Google 慢會被算成「你的知識庫沒有回應」——
|
||||
// 誤導的訊息比沒有訊息更貴(會害人往錯的方向查)。
|
||||
xgate := cfg.openGate(stepExtractDoc)
|
||||
prior := m.Entries[ev.Path] // #121:先前失敗過的檔再失敗,不算「路壞了」
|
||||
// #121:先前失敗過的檔再失敗,不算「路壞了」;#201:斷網那種不算前科
|
||||
cards, xerr = extractWithWorkersAI(cfg.CypherURL, cfg.APIKey, absRoot, ev.Path, cardOrigin,
|
||||
prior != nil && prior.FailCount > 0)
|
||||
m.HasOwnFailure(ev.Path))
|
||||
xgate.release()
|
||||
if xerr == nil {
|
||||
xgate.ok()
|
||||
@@ -1850,6 +1972,16 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
// 走到這裡代表 config 有沒見過的值——誠實報錯,不要靜默跳過(禁假綠)。
|
||||
xerr = fmt.Errorf("不支援的萃取方式 %q(支援:workers-ai/gemma)", cfg.Extractor)
|
||||
}
|
||||
if xerr != nil && isLocalNetworkErr(xerr) {
|
||||
// 🔴 #201:這台電腦根本沒連出去(DNS 查不到/網路不通)⇒ 不是這個檔的失敗。
|
||||
// 舊版照樣 MarkFailed ⇒ Mac 睡醒那幾秒就能把一批檔記滿 8 次、永久暫停。
|
||||
res.Status = "skipped"
|
||||
res.Error = networkDownNote(cfg, xerr)
|
||||
m.MarkNetworkUnavailable(ev.Path, now, "本地萃取失敗:"+xerr.Error())
|
||||
saveManifest()
|
||||
results = append(results, res)
|
||||
continue
|
||||
}
|
||||
if xerr != nil {
|
||||
// 2026-08-07 task 2:Workers AI 每日免費額度用完是**已知的上游狀況**
|
||||
// (wiki mistakes.md 2026-08-06),不是 bug——不能讓使用者看到裸露的
|
||||
@@ -1866,6 +1998,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
// 漏記的話(連不上知識庫、金鑰壞、模型錯)照樣每輪重撞。
|
||||
m.MarkFailed(ev.Path, now, res.Error)
|
||||
saveManifest()
|
||||
liveReport("failed") // #200
|
||||
results = append(results, res)
|
||||
exit = 1
|
||||
continue
|
||||
@@ -1925,15 +2058,19 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
cardBody["quality_warnings"] = warns
|
||||
}
|
||||
// #121:這個檔先前失敗過 ⇒ 這一發再失敗不算「路壞了」(見 routebackoff.go 檔頭)。
|
||||
prior := m.Entries[ev.Path]
|
||||
status, _, perr := cfg.postJSONAs(stepIngestCard, cfg.triggerURL(cfg.CardIngestWF), cardBody,
|
||||
prior != nil && prior.FailCount > 0)
|
||||
m.HasOwnFailure(ev.Path))
|
||||
res.HTTPStatus = status
|
||||
if perr != nil {
|
||||
res.Status, res.Error = "failed", perr.Error()
|
||||
if isRouteBackoff(perr) {
|
||||
res.Status = "skipped" // #121:沒打出去,不是這個檔的失敗
|
||||
routeSkipped = true
|
||||
} else if isLocalNetworkErr(perr) {
|
||||
res.Status = "skipped" // #201:這台電腦沒連出去,不是這個檔的失敗
|
||||
res.Error = networkDownNote(cfg, perr)
|
||||
m.MarkNetworkUnavailable(ev.Path, now, perr.Error())
|
||||
routeSkipped = true
|
||||
}
|
||||
ok = false
|
||||
break
|
||||
@@ -1959,6 +2096,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
exit = 1
|
||||
}
|
||||
saveManifest()
|
||||
liveReport(res.Status) // #200:送上去的那一刻,畫面上的分子就要跟著動
|
||||
results = append(results, res)
|
||||
continue
|
||||
}
|
||||
@@ -1994,6 +2132,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
qs.DailyCount++
|
||||
}
|
||||
saveManifest()
|
||||
liveReport(res.Status) // #200
|
||||
results = append(results, res)
|
||||
|
||||
case "removed":
|
||||
@@ -2037,6 +2176,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
// (不是 Scan() rebuild 時就拿掉——那時只是「偵測到不見了」,不是「已下架」)。
|
||||
delete(m.Entries, ev.Path)
|
||||
saveManifest()
|
||||
liveReport("removed") // #200
|
||||
// t15:extractor 模式雲端下架成功後,同步清掉本地萃出的卡,保持本地與雲端一致。
|
||||
// arcrun-rag#60:清除路徑必須跟落卡路徑**同一個函式**算出來(cardRelFor)——
|
||||
// 目錄或檔名任一邊不同步就清不到卡、留下孤兒檔。第一輪對齊了目錄,
|
||||
@@ -2098,7 +2238,9 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
|
||||
// t210:manifest 走到這裡已經是本輪最終狀態(每個事件處理完就地更新),
|
||||
// 原地數一次就是對的(同 SkippedDocCount 那套「現況快照」邏輯,不必另外維護計數器)。
|
||||
rp := rootProgress{Progress: m.Progress(), Tree: tree}
|
||||
// #200:Tree 用**最後重算的那一棵**(沒處理任何檔時就是開工那棵)。
|
||||
// 以前這裡是開工前那棵 ⇒ 收工合併進 folder-trees.json 的分子永遠是開工前的數字。
|
||||
rp := rootProgress{Progress: m.Progress(), Tree: latestTree}
|
||||
for _, e := range m.Entries {
|
||||
if e != nil && e.FailCount >= MaxFailBeforeSkip {
|
||||
// LastError 原文交給呼叫端彙總後過 ClassifyFailure——分類判斷只住那一個接縫,
|
||||
@@ -2144,6 +2286,8 @@ func runDirect(args []string) int {
|
||||
if *strict {
|
||||
cfg.LintStrict = true // CLI 旗標覆蓋 config
|
||||
}
|
||||
// #201:每一次被算進斷路器的失敗,當下就印一行帶時間的紀錄進 collector.log。
|
||||
routeFailureLog = announceRouteFailure
|
||||
|
||||
// t176:t92 的 claude_bin 回寫已隨 claude 萃取路一併退役——RunDirectOnce 不再解析
|
||||
// claude 執行檔,cfg.ClaudeBin 不會被改寫,故沒有東西需要回寫。
|
||||
|
||||
+2
-1
@@ -488,7 +488,8 @@ func SaveFolderTreeStore(path string, s FolderTreeStore) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o644)
|
||||
// #200:一輪途中每份檔都會重寫這個檔,而小幫手展開樹時會定期重讀 ⇒ 原子寫入(見 writeFileAtomic)。
|
||||
return writeFileAtomic(path, data, 0o644)
|
||||
}
|
||||
|
||||
// MergeFolderTreeStore 把「這一輪算出來的樹」併進上一輪的快照。
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
// live_status.go — 一輪還沒跑完,畫面也要知道做到哪(`inkstone/arcrun-rag#200`)。
|
||||
//
|
||||
// 🔴 病(2026-09-13 leo 截圖,小幫手 0.18.52):
|
||||
// 「同步中… 正在讀檔並整理成知識卡」,ISEP 每一層都是 0 / N,leo:「它沒有跑任何一個東西」。
|
||||
// 實查同一時段:
|
||||
//
|
||||
// status.json 最後修改 12:00(上一個版本收工那次),之後一次都沒寫
|
||||
// folder-trees.json ISEP 那棵的 generated_at 是 23:38(**開始處理之前**),synced 全部 0
|
||||
// ISEP 的 manifest 23:53 已經記下 11 份送上雲端;AR-Mira 查得到 isep/README.md 的卡
|
||||
//
|
||||
// ⇒ 不是沒跑,是**跑了但一個字都沒回寫**。兩份給畫面看的檔都只在固定時點寫:
|
||||
//
|
||||
// ① status.json 只在整輪收工時寫——三個帳號、每發雲端 24〜57 秒,一輪是好幾個小時
|
||||
// ② 樹只在「掃描完、開始送之前」寫一次(#153 第二輪搬的),那時分子必然還是 0;
|
||||
// 收工那次合併用的也是**同一棵開工前的樹**,所以就算整輪跑完,數字還是 0。
|
||||
//
|
||||
// 這支檔只做一件事:讓 roundGuard(本來就跟著整輪走、每個帳號每個資料夾共用的那一份)
|
||||
// 在**事情發生的當下**把「做到哪」寫進 status.json。數字本身不另算:
|
||||
// 資料夾進度是 (*Manifest).Progress() 的原件,樹是 BuildFolderTree 的原件——
|
||||
// 與收工那次同一個函式,只是早一點寫。
|
||||
package collector
|
||||
|
||||
import "time"
|
||||
|
||||
// folderSnapshot=某個看守資料夾此刻的進度(寫進 status.json 的 folder_progress)。
|
||||
type folderSnapshot struct {
|
||||
root string
|
||||
progress SyncProgress
|
||||
}
|
||||
|
||||
// beginRound 標記「一輪開始了」,並記住狀態檔在哪。statusPath 空=不寫(dry-run/沒有 manifest)。
|
||||
func (g *roundGuard) beginRound(statusPath string, now time.Time) {
|
||||
if g == nil || statusPath == "" {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
g.statusPath = statusPath
|
||||
g.live = RoundProgress{StartedAt: now.Format(time.RFC3339)}
|
||||
g.mu.Unlock()
|
||||
g.persistLive(nil)
|
||||
}
|
||||
|
||||
// enterFolder 標記「現在輪到哪個知識庫的哪個資料夾」。folder 空=帳號層的事(探測雲端 AI 之類)。
|
||||
func (g *roundGuard) enterFolder(account, folder string) {
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
g.live.Account, g.live.Folder = account, folder
|
||||
g.live.Step, g.live.StepSince, g.live.Waiting = "", "", nil
|
||||
g.mu.Unlock()
|
||||
g.persistLive(nil)
|
||||
}
|
||||
|
||||
// stepStarted 標記「開始做某件雲端呼叫」。由 openGate 呼叫——每一發雲端呼叫都經過那道閘,
|
||||
// 所以不必在每個呼叫端各自記得寫(「忘了接」這個失敗模式不該存在)。
|
||||
func (g *roundGuard) stepStarted(step callStep, at time.Time) {
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
g.live.Step = step.Name
|
||||
g.live.StepSince = at.Format(time.RFC3339)
|
||||
g.live.Waiting = nil
|
||||
g.mu.Unlock()
|
||||
g.persistLive(nil)
|
||||
}
|
||||
|
||||
// noteWaiting 標記「那件事已經等很久了」——這就是「卡在哪一步」。
|
||||
func (g *roundGuard) noteWaiting(s StalledCall) {
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
w := s
|
||||
g.live.Waiting = &w
|
||||
g.mu.Unlock()
|
||||
g.persistLive(nil)
|
||||
}
|
||||
|
||||
// stepDone 那件事回來了:如果畫面上正掛著「還在等它」,拿掉。
|
||||
func (g *roundGuard) stepDone(step callStep) {
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
clear := g.live.Waiting != nil && g.live.Waiting.Step == step.Name
|
||||
if clear {
|
||||
g.live.Waiting = nil
|
||||
}
|
||||
g.mu.Unlock()
|
||||
if clear {
|
||||
g.persistLive(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// fileFinished 一份檔處理完了(outcome:ingested/failed/其他)。
|
||||
// progress=那個資料夾此刻的 (*Manifest).Progress()。
|
||||
func (g *roundGuard) fileFinished(root, outcome string, progress SyncProgress) {
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
switch outcome {
|
||||
case "ingested":
|
||||
g.live.Ingested++
|
||||
case "failed":
|
||||
g.live.Failed++
|
||||
}
|
||||
g.live.Waiting = nil
|
||||
g.mu.Unlock()
|
||||
g.persistLive(&folderSnapshot{root: root, progress: progress})
|
||||
}
|
||||
|
||||
// persistStalls 把「這一輪等太久的事」立刻寫進去(strike 呼叫)。
|
||||
func (g *roundGuard) persistStalls() { g.persistLive(nil) }
|
||||
|
||||
// finishRound 封口:之後任何途中寫入都不再動 status.json。
|
||||
//
|
||||
// 🔴 一定要在收工那次 SaveSyncStatus **之前**呼叫。播報「還在等」的是另一條
|
||||
// goroutine,它可能比收工晚一點點才寫——沒有封口的話,收工剛寫好的那份會被
|
||||
// 一格過時的 in_round 蓋回去,畫面就永遠停在「同步中」。
|
||||
// 封口的判斷做在 statusFileMu 裡(見 persistLive),所以不存在「檢查完才被封口」的縫。
|
||||
func (g *roundGuard) finishRound() {
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
g.closed = true
|
||||
g.mu.Unlock()
|
||||
}
|
||||
|
||||
// persistLive 把此刻的「做到哪」寫進 status.json;fs 非 nil 時一併更新那個資料夾的進度。
|
||||
// 寫不進去一律吞掉:這是給畫面看的,不能擋住同步本體。
|
||||
func (g *roundGuard) persistLive(fs *folderSnapshot) {
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
path, closed := g.statusPath, g.closed
|
||||
g.mu.Unlock()
|
||||
if path == "" || closed {
|
||||
return
|
||||
}
|
||||
_ = updateSyncStatus(path, func(s *SyncStatus) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.closed {
|
||||
return false
|
||||
}
|
||||
live := g.live
|
||||
live.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
if live.Waiting != nil {
|
||||
w := *live.Waiting
|
||||
live.Waiting = &w
|
||||
}
|
||||
s.InRound = &live
|
||||
// 這一輪到目前為止等太久的事(同 Stalls 的語意:只講這一輪,不帶上一輪的)。
|
||||
s.Stalls = append([]StalledCall(nil), g.stalls...)
|
||||
if fs != nil {
|
||||
if s.FolderProgress == nil {
|
||||
s.FolderProgress = map[string]SyncProgress{}
|
||||
}
|
||||
// 首頁那行總量=各資料夾加總+讀不了的檔。換掉這個資料夾的那一份,
|
||||
// 其他資料夾與 Unreadable 原樣保留——不另算一套總量。
|
||||
if old, ok := s.FolderProgress[fs.root]; ok {
|
||||
s.Progress = progressMinus(s.Progress, old)
|
||||
}
|
||||
s.FolderProgress[fs.root] = fs.progress
|
||||
s.Progress = s.Progress.Add(fs.progress)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// progressMinus=SyncProgress.Add 的反向(只在換掉某個資料夾那一份時用)。
|
||||
func progressMinus(p, o SyncProgress) SyncProgress {
|
||||
return SyncProgress{
|
||||
Total: p.Total - o.Total,
|
||||
Done: p.Done - o.Done,
|
||||
Pending: p.Pending - o.Pending,
|
||||
Stuck: p.Stuck - o.Stuck,
|
||||
Unreadable: p.Unreadable - o.Unreadable,
|
||||
Failing: p.Failing - o.Failing,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestStatusAndTreeMoveWhileRoundIsStillRunning 守住 `inkstone/arcrun-rag#200` 的主病:
|
||||
// **一輪還沒跑完,畫面看的兩份檔就要反映已經送上去的份數。**
|
||||
//
|
||||
// 2026-09-13 實撞:ISEP 送上雲端 11 份,status.json 從 12:00 起沒寫過、
|
||||
// folder-trees.json 的 synced 全是 0 ⇒ 畫面每層 0 / N。
|
||||
//
|
||||
// 驗法:假雲端在收到**第二份**筆記時(第一份已經送完、這一輪還在跑),
|
||||
// 直接去讀磁碟上的 status.json 與 folder-trees.json——那就是小幫手此刻讀得到的東西。
|
||||
func TestStatusAndTreeMoveWhileRoundIsStillRunning(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, n := range []string{"a", "b", "c"} {
|
||||
if err := os.WriteFile(filepath.Join(root, n+".md"), []byte("# 原稿 "+n+" 內容"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
nameRe := regexp.MustCompile(`檔名:([^)]+))`)
|
||||
restoreGemma := gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
name := "a"
|
||||
if m := nameRe.FindStringSubmatch(string(body)); m != nil {
|
||||
name = m[1]
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"candidates": []map[string]any{{
|
||||
"content": map[string]any{"parts": []map[string]any{{"text": cardFixture(name, "測試")}}},
|
||||
}},
|
||||
})
|
||||
})
|
||||
defer restoreGemma()
|
||||
|
||||
manifestPath := filepath.Join(t.TempDir(), "m.json")
|
||||
statusPath := StatusFilePath(manifestPath)
|
||||
treePath := FolderTreeStorePath(manifestPath)
|
||||
|
||||
var mu sync.Mutex
|
||||
cards := 0
|
||||
var midStatus SyncStatus
|
||||
var midStatusErr error
|
||||
var midTree FolderTreeStore
|
||||
// 只數**這三份檔自己的**筆記:資料夾總覽卡、目錄索引卡也走同一條收卡路,
|
||||
// 而且排在逐檔之前送——數到它們就會在第一份檔都還沒送時拍照。
|
||||
docCard := regexp.MustCompile(`"path":"[abc]\.md"`)
|
||||
cypher := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if strings.Contains(string(body), `"card_content"`) && docCard.Match(body) {
|
||||
mu.Lock()
|
||||
cards++
|
||||
if cards == 2 {
|
||||
midStatus, midStatusErr = LoadSyncStatus(statusPath)
|
||||
midTree, _ = LoadFolderTreeStore(treePath)
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
}))
|
||||
defer cypher.Close()
|
||||
|
||||
cfg := &DirectConfig{
|
||||
WatchFolders: []string{root},
|
||||
Manifest: manifestPath,
|
||||
CypherURL: cypher.URL, Namespace: "demo", APIKey: "demo",
|
||||
Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
|
||||
CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio,
|
||||
}
|
||||
results, _, _ := RunDirectOnce(cfg, false)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if cards < 3 {
|
||||
t.Fatalf("三份都應該送上去,實際收到 %d 張卡:results=%+v", cards, results)
|
||||
}
|
||||
|
||||
// ── 一輪途中(第一份已送完)──
|
||||
if midStatusErr != nil {
|
||||
t.Fatalf("🔴 一輪途中讀不到 status.json:%v", midStatusErr)
|
||||
}
|
||||
if midStatus.InRound == nil {
|
||||
t.Fatalf("🔴 一輪途中 status.json 沒有 in_round——畫面講不出做到哪")
|
||||
}
|
||||
if got := midStatus.InRound.Ingested; got != 1 {
|
||||
t.Errorf("一輪途中 in_round.ingested 應為 1(第一份已送完),got %d", got)
|
||||
}
|
||||
if midStatus.InRound.Folder != root {
|
||||
t.Errorf("in_round.folder 應為 %q,got %q", root, midStatus.InRound.Folder)
|
||||
}
|
||||
if midStatus.InRound.Step != stepIngestCard.Name {
|
||||
t.Errorf("正在送第二份時 in_round.step 應為 %q,got %q", stepIngestCard.Name, midStatus.InRound.Step)
|
||||
}
|
||||
if got := midStatus.FolderProgress[root].Done; got != 1 {
|
||||
t.Errorf("🔴 一輪途中 folder_progress 的已同步應為 1,got %d(%+v)", got, midStatus.FolderProgress[root])
|
||||
}
|
||||
if got := midStatus.Progress.Done; got != 1 {
|
||||
t.Errorf("一輪途中首頁總量的已送上去應為 1,got %d", got)
|
||||
}
|
||||
if got := treeSynced(midTree, root); got != 1 {
|
||||
t.Errorf("🔴 一輪途中 folder-trees.json 的已同步應為 1(這就是 leo 看到 0 / N 的那個數字),got %d", got)
|
||||
}
|
||||
|
||||
// ── 收工之後 ──
|
||||
st, err := LoadSyncStatus(statusPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.InRound != nil {
|
||||
t.Errorf("收工之後 in_round 應該消失,got %+v", st.InRound)
|
||||
}
|
||||
if got := st.FolderProgress[root].Done; got != 3 {
|
||||
t.Errorf("收工之後已同步應為 3,got %d", got)
|
||||
}
|
||||
final, err := LoadFolderTreeStore(treePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 🔴 以前收工合併用的是開工前那棵樹 ⇒ 就算整輪跑完,這裡也還是 0。
|
||||
if got := treeSynced(final, root); got != 3 {
|
||||
t.Errorf("🔴 收工之後 folder-trees.json 的已同步應為 3,got %d(收工合併還在用開工前那棵樹)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func treeSynced(s FolderTreeStore, root string) int {
|
||||
t, ok := s.Trees[root]
|
||||
if !ok {
|
||||
return -1
|
||||
}
|
||||
n := 0
|
||||
for _, node := range t.Nodes {
|
||||
n += node.SyncedFiles
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestWaitingIsWrittenWhileStillWaiting 守住驗收條件③「卡住時寫得出原因」:
|
||||
// 「還在等」以前只印到 stdout(collector.log),status.json 一個字都沒有。
|
||||
func TestWaitingIsWrittenWhileStillWaiting(t *testing.T) {
|
||||
origNotice := stallNoticeEvery
|
||||
stallNoticeEvery = 30 * time.Millisecond
|
||||
defer func() { stallNoticeEvery = origNotice }()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "status.json")
|
||||
g := newRoundGuard()
|
||||
g.announce = nil
|
||||
g.beginRound(path, time.Now())
|
||||
g.enterFolder("kb.example", "/Users/someone/ISEP")
|
||||
|
||||
cfg := &DirectConfig{CypherURL: "https://kb.example", guard: g}
|
||||
gate := cfg.openGate(stepFolderCard)
|
||||
|
||||
var st SyncStatus
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
st, _ = LoadSyncStatus(path)
|
||||
if st.InRound != nil && st.InRound.Waiting != nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if st.InRound == nil || st.InRound.Waiting == nil {
|
||||
gate.release()
|
||||
t.Fatalf("🔴 等了一陣子,status.json 仍沒有寫出「卡在哪一步」:%+v", st.InRound)
|
||||
}
|
||||
if st.InRound.Waiting.Step != stepFolderCard.Name {
|
||||
t.Errorf("waiting.step 應為 %q,got %q", stepFolderCard.Name, st.InRound.Waiting.Step)
|
||||
}
|
||||
if st.InRound.Folder != "/Users/someone/ISEP" || st.InRound.Account != "kb.example" {
|
||||
t.Errorf("卡住時要講得出是哪個知識庫的哪個資料夾,got account=%q folder=%q",
|
||||
st.InRound.Account, st.InRound.Folder)
|
||||
}
|
||||
|
||||
gate.release()
|
||||
st, _ = LoadSyncStatus(path)
|
||||
if st.InRound == nil || st.InRound.Waiting != nil {
|
||||
t.Errorf("那件事回來之後「還在等」要拿掉,got %+v", st.InRound)
|
||||
}
|
||||
|
||||
// 收工封口:晚到的「還在等」不准把收工寫好的那份蓋回「同步中」。
|
||||
g.finishRound()
|
||||
if err := SaveSyncStatus(path, SyncStatus{LastSync: "2026-09-14T00:00:00+08:00"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.noteWaiting(StalledCall{Step: "晚到的", Note: "晚到的"})
|
||||
st, _ = LoadSyncStatus(path)
|
||||
if st.InRound != nil {
|
||||
t.Errorf("🔴 收工之後的途中寫入把 in_round 寫回去了:%+v", st.InRound)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLiveWriteNeverWipesUnreadableStatus:狀態檔讀得到卻解不開時,途中寫入一律不動它
|
||||
// ——拿零值改一格再寫回去,等於把上一輪所有的數字整份抹掉。
|
||||
func TestLiveWriteNeverWipesUnreadableStatus(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "status.json")
|
||||
garbage := []byte("{ 這不是 JSON")
|
||||
if err := os.WriteFile(path, garbage, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := newRoundGuard()
|
||||
g.announce = nil
|
||||
g.beginRound(path, time.Now())
|
||||
g.fileFinished("/x", "ingested", SyncProgress{Total: 1, Done: 1})
|
||||
got, _ := os.ReadFile(path)
|
||||
if string(got) != string(garbage) {
|
||||
t.Errorf("解不開的狀態檔被覆寫了:%q", got)
|
||||
}
|
||||
}
|
||||
+87
-2
@@ -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 為空分支)。預設值放這裡而不是
|
||||
// 讓呼叫端每次都設,是因為漏設的方向要落在**安全的那一邊**:
|
||||
@@ -268,8 +277,14 @@ func (m *Manifest) MarkFailed(path string, at int64, reason string) bool {
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// #201:上一筆病歷是「這台電腦沒連上網路」⇒ 那幾次不是這個檔的失敗,重新算起。
|
||||
// (更新前的舊版會把斷網也記成失敗,一個檔就這樣被記滿 8 次永久暫停。)
|
||||
if isLocalNetworkText(e.LastError) {
|
||||
e.FailCount = 0
|
||||
}
|
||||
e.FailCount++
|
||||
e.LastFailAt = at
|
||||
e.FailCloudVersion = m.CloudVersion // #196:記下這次撞的是哪一版雲端
|
||||
if strings.TrimSpace(reason) != "" {
|
||||
e.LastError = reason // 存真因;退避訊息由呼叫端另外組,不覆蓋這裡
|
||||
}
|
||||
@@ -281,6 +296,35 @@ func (m *Manifest) MarkFailed(path string, at int64, reason string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// networkRetryAfter=這台電腦連不上網路時,多久之後再試同一個檔。
|
||||
// 不是 0:斷網期間每 5 秒一輪,每輪都把大 PDF 重新轉一次文字是白燒 CPU;
|
||||
// 也不長:網路一恢復,一分鐘內就要自己接上(#201 驗收:不用人按「立刻同步」)。
|
||||
const networkRetryAfter = 60 // 秒
|
||||
|
||||
// MarkNetworkUnavailable 記下「這一發沒送出去,因為這台電腦連不上網路」(#201)。
|
||||
//
|
||||
// 🔴 **不動 FailCount**:請求沒離開這台電腦,不是這個檔壞了,也不該讓它往
|
||||
// 「連續失敗 8 次、暫停自動重試」走。只留一個短短的等待,讓斷網期間不要每輪空轉。
|
||||
func (m *Manifest) MarkNetworkUnavailable(path string, at int64, reason string) bool {
|
||||
e, ok := m.Entries[path]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
e.LastFailAt = at
|
||||
if strings.TrimSpace(reason) != "" {
|
||||
e.LastError = reason
|
||||
}
|
||||
e.NextRetry = at + networkRetryAfter
|
||||
return true
|
||||
}
|
||||
|
||||
// HasOwnFailure=這個檔**自己**失敗過(不含「當時這台電腦沒網路」那種)。
|
||||
// 給斷路器判斷「這一發算不算第一次送」用(#121/#201)。
|
||||
func (m *Manifest) HasOwnFailure(path string) bool {
|
||||
e, ok := m.Entries[path]
|
||||
return ok && e != nil && e.FailCount > 0 && !isLocalNetworkText(e.LastError)
|
||||
}
|
||||
|
||||
// ShouldRetry 回報「這個檔現在該不該送」。
|
||||
//
|
||||
// - 從沒失敗過 → true(行為與 t195 前一字不變)
|
||||
@@ -292,18 +336,59 @@ func (m *Manifest) MarkFailed(path string, at int64, reason string) bool {
|
||||
// 另:使用者改檔會讓 content hash 變 → 走的是「內容變更」路徑,本函式不介入。
|
||||
func (m *Manifest) ShouldRetry(path string, now int64, force bool) bool {
|
||||
e, ok := m.Entries[path]
|
||||
if !ok || e.FailCount == 0 {
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if force {
|
||||
return true
|
||||
}
|
||||
// #201:最近一次是「這台電腦沒連上網路」⇒ 不管記了幾次(包括 0 次),等短暫的窗口過了就再試。
|
||||
// 舊版把斷網記成這個檔的失敗,leo 的 KB 有 17 份就這樣被永久暫停(最早 08-14)。
|
||||
// 要放在 FailCount==0 之前:MarkNetworkUnavailable 不加次數,放後面等於那 60 秒不存在。
|
||||
if isLocalNetworkText(e.LastError) {
|
||||
return now >= e.NextRetry
|
||||
}
|
||||
if e.FailCount == 0 {
|
||||
return true
|
||||
}
|
||||
if e.FailCount >= MaxFailBeforeSkip {
|
||||
return false
|
||||
// #201:「雲端當時還是舊版、沒有 AI」不是這個檔的病——雲端更新之後要自己再試。
|
||||
// 退避窗口照舊(最長 6 小時一次),雲端還沒更新的話頂多每 6 小時白問一發。
|
||||
// #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, "沒有雲端萃取功能")
|
||||
}
|
||||
|
||||
// Save 原子寫入(temp + rename),避免掃描中斷留半個 JSON。
|
||||
func (m *Manifest) Save(path string) error {
|
||||
data, err := json.MarshalIndent(m, "", " ")
|
||||
|
||||
@@ -74,6 +74,9 @@ func (m *Manifest) Progress() SyncProgress {
|
||||
switch {
|
||||
case e.IngestedHash != "" && e.IngestedHash == e.ContentHash:
|
||||
p.Done++
|
||||
case isLocalNetworkText(e.LastError):
|
||||
// #201:上次只是這台電腦沒連上網路 ⇒ 會自己再試,是排隊中,不是卡住。
|
||||
p.Pending++
|
||||
case e.FailCount >= MaxFailBeforeSkip:
|
||||
// 已經放棄自動重試的不能混在 Pending 裡假裝還在排隊——
|
||||
// 使用者會一直等一件永遠不會發生的事。
|
||||
|
||||
@@ -326,7 +326,8 @@ func TestRouteBreaker_Rules(t *testing.T) {
|
||||
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)
|
||||
// #201:連線層錯誤只算「連上之後出事」的(這裡用連線被切斷);本機沒連出去的另有測試。
|
||||
b.record(u, t0.Add(3*time.Minute), 0, errors.New("read tcp 10.0.0.1:1->10.0.0.2:443: connection reset by peer"), false)
|
||||
if b.note(u, t0.Add(3*time.Minute+4*time.Minute)) == "" {
|
||||
t.Fatal("再失敗一發應該停 5 分鐘")
|
||||
}
|
||||
|
||||
+160
-15
@@ -15,7 +15,12 @@
|
||||
// 就整條路停一段時間,時間一次比一次長;有一發成功就立刻恢復。
|
||||
//
|
||||
// 判準(什麼算「路壞了」):
|
||||
// - HTTP 5xx、429、連不上/逾時 ⇒ 算。
|
||||
// - HTTP 5xx、429、等不到回應(逾時)、連上之後被切斷 ⇒ 算。
|
||||
// - 🔴 **這台電腦根本沒連出去**(DNS 查不到、網路不通、連線被本機拒絕)⇒ **不算**
|
||||
// (`inkstone/arcrun-rag#201`):請求沒離開這台電腦,雲端一分額度都沒花,
|
||||
// 斷路器存在的理由(別把雲端額度燒光)在這裡不成立。2026-09-16 leo 的 log 裡
|
||||
// 這一類錯誤有 1849 筆(Mac 睡醒、網路還沒好的那幾秒),把它們算成「雲端壞了」
|
||||
// 只會讓網路一恢復,小幫手反而還停在「先停 26 分鐘」。
|
||||
// - HTTP 2xx 但 body 裡寫著工作流失敗 ⇒ **不算**:路是通的,失敗可能只屬於那一份內容,
|
||||
// 把它算成整條路壞掉會讓一份怪檔拖住所有人(逐檔退避會照顧它)。
|
||||
// - 其他 4xx ⇒ 不算(多半是那一發本身的問題,不是路的問題)。
|
||||
@@ -27,13 +32,25 @@
|
||||
// - 已經失敗過的檔再重試又失敗 ⇒ **不算數也不歸零**:那是它自己的病(逐檔退避在管),
|
||||
// 不准拿它把整條路關掉——否則一池壞檔會輪流把健康的檔擋在外面(t217 餓死病換個形狀回來)。
|
||||
//
|
||||
// 🔴 「連續」要是真的連續(`inkstone/arcrun-rag#201`):
|
||||
// 舊版只有「2xx 才歸零」,而空 text 的探測通了刻意不記、有前科的檔成功之前都不會回 2xx
|
||||
// ⇒ 一個所有檔都卡住的帳號,計數器**從行程啟動那一刻起只增不減**。
|
||||
// 2026-09-16 geek6688 那句「連續失敗 11 次」其實是 09-14 17:22 開 App 之後兩天內零星的
|
||||
// 11 發(最後一發=17:48:42 探測等了 20 秒),不是剛剛連撞 11 次。
|
||||
// ⇒ 距離上一次失敗超過 routeFailMemory 還沒再失敗,就當作路早就好了,重新算起。
|
||||
//
|
||||
// 每一次「算進去」的失敗都會印一行帶時間的紀錄(phase:"route_failure",見 routeFailureLog),
|
||||
// 事後看得出「這 N 次是哪一種、依序發生在什麼時候」——以前 log 只有整輪結果重印,沒有時間。
|
||||
//
|
||||
// 使用者按「立刻同步」(ForceSync)=這一輪不看退避,照打(與逐檔退避同一個語意)。
|
||||
//
|
||||
// 狀態只活在這個行程裡:行程重開=從零開始,最壞代價是重開後第一輪多撞一發就重新停下。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -56,9 +73,41 @@ var routeBackoffLadder = []time.Duration{
|
||||
// 就是 4 發——第一輪之內就停得下來。之後每次窗口到期只會再撞 1 發。
|
||||
const routeStrikesBeforeBackoff = 4
|
||||
|
||||
// routeFailMemory=上一次失敗之後多久沒再失敗,就不再把它算進「連續」。
|
||||
// 取階梯上限(30 分鐘)的兩倍:退避中的路窗口到期會再試一發,所以真的一直壞的路
|
||||
// 最慢 30 分鐘就會再記一次失敗,不會被這條規則洗掉;只有「好幾個小時才偶發一次」
|
||||
// 的零星失敗才會重新算起。
|
||||
const routeFailMemory = time.Hour
|
||||
|
||||
type routeState struct {
|
||||
fails int
|
||||
until time.Time
|
||||
fails int
|
||||
until time.Time
|
||||
lastFail time.Time
|
||||
lastCause string // 給人看的「最近一次是怎麼失敗的」,不含狀態碼與網址
|
||||
}
|
||||
|
||||
// RouteFailure=一次被算進斷路器的失敗(印進 collector.log 的那一行)。
|
||||
type RouteFailure struct {
|
||||
Route string `json:"route"` // 給人看的路名(例:雲端 AI 整理文件)
|
||||
Account string `json:"account"` // 知識庫主機
|
||||
Fails int `json:"fails"` // 算進這一發之後的連續失敗數
|
||||
Cause string `json:"cause"` // 白話的失敗種類
|
||||
HTTPStatus int `json:"http_status,omitempty"` // 0=沒拿到回應
|
||||
Detail string `json:"detail,omitempty"` // 連線層錯誤原文(有才帶)
|
||||
BackoffUntil string `json:"backoff_until,omitempty"` // 這一發讓整條路停到什麼時候(沒停=空)
|
||||
}
|
||||
|
||||
// routeFailureLog=每算進一次失敗就呼叫一次。預設不做事(測試不想被印一堆);
|
||||
// `collector direct` 主程式會把它接到 stdout(→ supervisor tee 進 collector.log)。
|
||||
var routeFailureLog = func(at time.Time, f RouteFailure) {}
|
||||
|
||||
// announceRouteFailure 把一次失敗印成 stdout 上一個完整的 JSON 值(與其他播報共用鎖)。
|
||||
func announceRouteFailure(at time.Time, f RouteFailure) {
|
||||
printJSONLine(struct {
|
||||
At string `json:"at"`
|
||||
Phase string `json:"phase"`
|
||||
RouteFailure
|
||||
}{at.Format(time.RFC3339), "route_failure", f})
|
||||
}
|
||||
|
||||
type routeBreaker struct {
|
||||
@@ -80,6 +129,62 @@ func isRouteBackoff(err error) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
// isLocalNetworkErr=「這一發根本沒離開這台電腦」:DNS 查不到、網路不通、連線被拒。
|
||||
//
|
||||
// 🔴 刻意不含逾時(包括連線階段的逾時):等到超時的那一發,雲端可能已經在處理了,
|
||||
// 那是 stallguard.go 的事,也照樣算進斷路器。
|
||||
// 字串比對是給**舊病歷**用的:manifest 裡存的 LastError 是字串,不是 error 物件。
|
||||
func isLocalNetworkErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if isStallError(err) {
|
||||
return false
|
||||
}
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
return true
|
||||
}
|
||||
var opErr *net.OpError
|
||||
if errors.As(err, &opErr) && opErr.Op == "dial" {
|
||||
return true
|
||||
}
|
||||
return isLocalNetworkText(err.Error())
|
||||
}
|
||||
|
||||
// isLocalNetworkText=isLocalNetworkErr 的字串版(manifest 的 LastError 只剩字串)。
|
||||
func isLocalNetworkText(msg string) bool {
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
for _, mark := range []string{
|
||||
"no such host",
|
||||
"network is unreachable",
|
||||
"no route to host",
|
||||
"connection refused",
|
||||
"server misbehaving", // 本機 DNS 伺服器回壞答案
|
||||
} {
|
||||
if strings.Contains(msg, mark) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// routeFailCause=給人看的「這一發是怎麼失敗的」。不帶狀態碼(畫面上的話不裸露上游代碼)。
|
||||
func routeFailCause(status int, transportErr error) string {
|
||||
switch {
|
||||
case transportErr != nil && isStallError(transportErr):
|
||||
return "等不到回應"
|
||||
case transportErr != nil:
|
||||
return "連線中途斷掉"
|
||||
case status == 429:
|
||||
return "雲端說請求太多"
|
||||
default:
|
||||
return "雲端回報內部錯誤"
|
||||
}
|
||||
}
|
||||
|
||||
// routeKey=主機+路徑(不含查詢字串)。同一台知識庫的不同工作流是不同的路。
|
||||
func routeKey(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
@@ -109,35 +214,57 @@ func routeLabel(raw string) string {
|
||||
return u.Path
|
||||
}
|
||||
|
||||
// note 回「這條路現在正在退避」的白話;空=可以打。
|
||||
// note 回「這條路現在正在退避」的白話;空=可以打。知識庫以主機名稱呼(見 noteFor)。
|
||||
func (b *routeBreaker) note(raw string, now time.Time) string {
|
||||
return b.noteFor(raw, "", now)
|
||||
}
|
||||
|
||||
// noteFor=note,另帶「哪一台知識庫」的稱呼(帳號名)。
|
||||
//
|
||||
// 🔴 `inkstone/arcrun-rag#201`:舊句子只講「雲端『雲端 AI 整理文件』這條路」——
|
||||
// 小幫手連著三個知識庫,而這句會被浮到首頁頂端。2026-09-16 那句其實是 geek6688 的,
|
||||
// leo 看的是 leo21c,於是查錯了帳號。⇒ 句子裡一定要點名是哪一台,
|
||||
// 也要講最近一次是哪一種失敗、什麼時候(「連續失敗 11 次」本身說不出原因)。
|
||||
func (b *routeBreaker) noteFor(raw, who 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 ""
|
||||
}
|
||||
if strings.TrimSpace(who) == "" {
|
||||
who = instanceHostOf(raw)
|
||||
}
|
||||
wait := st.until.Sub(now).Round(time.Second)
|
||||
last := ""
|
||||
if st.lastCause != "" && !st.lastFail.IsZero() {
|
||||
last = fmt.Sprintf("(最近一次 %s:%s)", st.lastFail.Local().Format("15:04"), st.lastCause)
|
||||
}
|
||||
// 🔴 結尾「稍後會自動恢復」是 explainsWhySkipped 的識別字(sync_status.go),不要改掉。
|
||||
// 不帶上游原文(HTTP 碼/JSON):這句會出現在畫面上;原文在失敗那一發的結果裡已經留著。
|
||||
return fmt.Sprintf("雲端「%s」這條路連續失敗 %d 次,先停 %s 再試,避免一直重撞把雲端額度用光;稍後會自動恢復。",
|
||||
routeLabel(raw), st.fails, humanWait(wait))
|
||||
// 不帶上游原文(HTTP 碼/JSON):這句會出現在畫面上;原文在 collector.log 的 route_failure 那幾行。
|
||||
return fmt.Sprintf("知識庫「%s」的「%s」這條路連續失敗 %d 次%s,先停 %s 再試,避免一直重撞把雲端額度用光;稍後會自動恢復。",
|
||||
who, routeLabel(raw), st.fails, last, humanWait(wait))
|
||||
}
|
||||
|
||||
// record 記一發的結果。status=HTTP 狀態碼(0=沒拿到回應);transportErr=連線層錯誤;
|
||||
// retry=這份內容先前就失敗過(逐檔病歷上有紀錄)——它失敗不算路壞,見檔頭。
|
||||
func (b *routeBreaker) record(raw string, now time.Time, status int, transportErr error, retry bool) {
|
||||
// #201:請求根本沒離開這台電腦 ⇒ 雲端沒花任何額度,不算數也不歸零。
|
||||
if transportErr != nil && isLocalNetworkErr(transportErr) {
|
||||
return
|
||||
}
|
||||
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) // 通了就全部歸零
|
||||
}
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if retry {
|
||||
b.mu.Unlock()
|
||||
return // 有前科的內容再失敗:它自己的病,不算數也不歸零
|
||||
}
|
||||
st := b.routes[key]
|
||||
@@ -145,15 +272,33 @@ func (b *routeBreaker) record(raw string, now time.Time, status int, transportEr
|
||||
st = &routeState{}
|
||||
b.routes[key] = st
|
||||
}
|
||||
// #201:上一次失敗太久以前 ⇒ 那不是「連續」,重新算起。
|
||||
if st.fails > 0 && !st.lastFail.IsZero() && now.Sub(st.lastFail) > routeFailMemory {
|
||||
st.fails = 0
|
||||
}
|
||||
st.fails++
|
||||
if st.fails < routeStrikesBeforeBackoff {
|
||||
return
|
||||
st.lastFail = now
|
||||
st.lastCause = routeFailCause(status, transportErr)
|
||||
entry := RouteFailure{
|
||||
Route: routeLabel(raw),
|
||||
Account: instanceHostOf(raw),
|
||||
Fails: st.fails,
|
||||
Cause: st.lastCause,
|
||||
HTTPStatus: status,
|
||||
}
|
||||
idx := st.fails - routeStrikesBeforeBackoff
|
||||
if idx >= len(routeBackoffLadder) {
|
||||
idx = len(routeBackoffLadder) - 1
|
||||
if transportErr != nil {
|
||||
entry.Detail = transportErr.Error()
|
||||
}
|
||||
st.until = now.Add(routeBackoffLadder[idx])
|
||||
if st.fails >= routeStrikesBeforeBackoff {
|
||||
idx := st.fails - routeStrikesBeforeBackoff
|
||||
if idx >= len(routeBackoffLadder) {
|
||||
idx = len(routeBackoffLadder) - 1
|
||||
}
|
||||
st.until = now.Add(routeBackoffLadder[idx])
|
||||
entry.BackoffUntil = st.until.Format(time.RFC3339)
|
||||
}
|
||||
b.mu.Unlock()
|
||||
routeFailureLog(now, entry) // 鎖外呼叫:印 stdout 要拿另一把鎖,別疊在一起
|
||||
}
|
||||
|
||||
// reset 清空全部狀態(測試用)。
|
||||
@@ -182,5 +327,5 @@ func (c *DirectConfig) routeNote(raw string) string {
|
||||
if c.ForceSync {
|
||||
return ""
|
||||
}
|
||||
return cloudRoutes.note(raw, directNow())
|
||||
return cloudRoutes.noteFor(raw, c.InstanceName, directNow())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
// routebackoff_201_test.go — `inkstone/arcrun-rag#201` 的驗收。
|
||||
//
|
||||
// 2026-09-16 leo 的畫面:「雲端『雲端 AI 整理文件』這條路連續失敗 11 次,先停 26 分鐘再試」。
|
||||
// 實查(collector.log.old+manifest):那句是 geek6688 帳號的,而且那 11 次是 09-14 開 App
|
||||
// 之後兩天內零星的失敗(最後一發=17:48:42 探測等了 20 秒),計數器從沒歸零過;
|
||||
// 另外 leo 自己那台知識庫的 KB 資料夾有 17 份檔因為「這台電腦當時查不到 DNS」被記滿 8 次永久暫停、
|
||||
// 12 份 0 位元組的日記被雲端回 400 後永久暫停。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func dnsNotFoundErr(host string) error {
|
||||
return &url.Error{Op: "Post", URL: "https://" + host + "/portal/daemon/extract",
|
||||
Err: &net.OpError{Op: "dial", Net: "tcp", Err: &net.DNSError{Err: "no such host", Name: host, IsNotFound: true}}}
|
||||
}
|
||||
|
||||
type timeoutErr struct{}
|
||||
|
||||
func (timeoutErr) Error() string {
|
||||
return "context deadline exceeded (Client.Timeout exceeded while awaiting headers)"
|
||||
}
|
||||
func (timeoutErr) Timeout() bool { return true }
|
||||
func (timeoutErr) Temporary() bool { return true }
|
||||
|
||||
// 本機沒連出去(DNS/網路不通/連線被拒)不花雲端額度 ⇒ 不准讓斷路器跳。
|
||||
func TestRouteBreaker201_LocalNetworkDoesNotTrip(t *testing.T) {
|
||||
b := &routeBreaker{routes: map[string]*routeState{}}
|
||||
host := "arcrun-cypher-executor.kb-a.workers.dev"
|
||||
u := "https://" + host + "/portal/daemon/extract"
|
||||
t0 := time.Date(2026, 9, 16, 8, 0, 0, 0, time.UTC)
|
||||
errs := []error{
|
||||
dnsNotFoundErr(host),
|
||||
&url.Error{Op: "Post", URL: u, Err: &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("connect: network is unreachable")}},
|
||||
errors.New(`Post "x": dial tcp: lookup ` + host + `: no such host`),
|
||||
errors.New("dial tcp 127.0.0.1:9: connect: connection refused"),
|
||||
}
|
||||
for i := 0; i < 50; i++ {
|
||||
b.record(u, t0, 0, errs[i%len(errs)], false)
|
||||
}
|
||||
if n := b.note(u, t0); n != "" {
|
||||
t.Fatalf("本機沒連出去 50 次,斷路器不該跳:%q", n)
|
||||
}
|
||||
for i := 0; i < 50; i++ {
|
||||
b.record(u, t0, 400, nil, false)
|
||||
}
|
||||
if n := b.note(u, t0); n != "" {
|
||||
t.Fatalf("400 不花額度,斷路器不該跳:%q", n)
|
||||
}
|
||||
// 對照組:雲端連續 5xx 仍然要跳。
|
||||
for i := 0; i < routeStrikesBeforeBackoff; i++ {
|
||||
b.record(u, t0, 503, nil, false)
|
||||
}
|
||||
if b.note(u, t0) == "" {
|
||||
t.Fatal("雲端連續 5xx 仍然要退避(#121 的保護不准被拆掉)")
|
||||
}
|
||||
}
|
||||
|
||||
// 「連續」要是真的連續:兩天內零星 11 次(每次隔 4 小時)不准累積成跳閘。
|
||||
func TestRouteBreaker201_ScatteredFailuresDoNotAccumulate(t *testing.T) {
|
||||
b := &routeBreaker{routes: map[string]*routeState{}}
|
||||
u := "https://arcrun-cypher-executor.arcrun-fc9490d5.workers.dev/portal/daemon/extract"
|
||||
t0 := time.Date(2026, 9, 14, 9, 22, 0, 0, time.UTC)
|
||||
stall := &url.Error{Op: "Post", URL: u, Err: timeoutErr{}}
|
||||
for i := 0; i < 11; i++ {
|
||||
at := t0.Add(time.Duration(i) * 4 * time.Hour)
|
||||
b.record(u, at, 0, stall, false)
|
||||
if n := b.note(u, at); n != "" {
|
||||
t.Fatalf("第 %d 次零星失敗(距上一次 4 小時)就跳閘了:%q", i+1, n)
|
||||
}
|
||||
}
|
||||
t1 := t0.Add(100 * time.Hour)
|
||||
for i := 0; i < routeStrikesBeforeBackoff; i++ {
|
||||
b.record(u, t1.Add(time.Duration(i)*time.Minute), 0, stall, false)
|
||||
}
|
||||
if b.note(u, t1.Add(3*time.Minute+30*time.Second)) == "" {
|
||||
t.Fatal("幾分鐘內連續 4 發等不到回應,應該退避")
|
||||
}
|
||||
}
|
||||
|
||||
// 跳閘時畫面那句話要點名是哪一台知識庫、最近一次是哪一種失敗;log 每一次失敗都帶時間。
|
||||
func TestRouteBreaker201_NoteNamesAccountAndCause_LogHasTime(t *testing.T) {
|
||||
cloudRoutes.reset()
|
||||
defer cloudRoutes.reset()
|
||||
var mu sync.Mutex
|
||||
var got []string
|
||||
old := routeFailureLog
|
||||
routeFailureLog = func(at time.Time, f RouteFailure) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
line, _ := json.Marshal(struct {
|
||||
At string `json:"at"`
|
||||
RouteFailure
|
||||
}{at.Format(time.RFC3339), f})
|
||||
got = append(got, string(line))
|
||||
}
|
||||
defer func() { routeFailureLog = old }()
|
||||
|
||||
u := "https://arcrun-cypher-executor.arcrun-fc9490d5.workers.dev/portal/daemon/extract"
|
||||
t0 := time.Date(2026, 9, 16, 9, 48, 42, 0, time.UTC)
|
||||
oldNow := directNow
|
||||
directNow = func() time.Time { return t0 }
|
||||
defer func() { directNow = oldNow }()
|
||||
|
||||
cloudRoutes.record(u, t0, 0, dnsNotFoundErr("x"), false) // 不算,也不印
|
||||
for i := 0; i < routeStrikesBeforeBackoff; i++ {
|
||||
cloudRoutes.record(u, t0, 0, &url.Error{Op: "Post", URL: u, Err: timeoutErr{}}, false)
|
||||
}
|
||||
cfg := &DirectConfig{InstanceName: "geek6688", CypherURL: "https://arcrun-cypher-executor.arcrun-fc9490d5.workers.dev"}
|
||||
n := cfg.routeNote(u)
|
||||
for _, want := range []string{"geek6688", "雲端 AI 整理文件", "等不到回應", "稍後會自動恢復"} {
|
||||
if !strings.Contains(n, want) {
|
||||
t.Fatalf("畫面那句少了 %q:%q", want, n)
|
||||
}
|
||||
}
|
||||
if strings.Contains(n, "HTTP") {
|
||||
t.Fatalf("畫面上的話不准裸露狀態碼:%q", n)
|
||||
}
|
||||
if len(got) != routeStrikesBeforeBackoff {
|
||||
t.Fatalf("每一次算進去的失敗都要印一行,要 %d 行,拿到 %d:%v", routeStrikesBeforeBackoff, len(got), got)
|
||||
}
|
||||
last := got[len(got)-1]
|
||||
for _, want := range []string{`"at":"2026-09-16T09:48:42Z"`, `"cause":"等不到回應"`, `"fails":4`, `"backoff_until"`, "Client.Timeout"} {
|
||||
if !strings.Contains(last, want) {
|
||||
t.Fatalf("log 那一行少了 %s:%s", want, last)
|
||||
}
|
||||
}
|
||||
t.Logf("route_failure 紀錄範例:%s", last)
|
||||
t.Logf("畫面那句:%s", n)
|
||||
}
|
||||
|
||||
// 舊病歷:斷網被記滿 8 次的檔,要自己再試、算排隊中。
|
||||
func TestManifest201_LegacyNetworkPauseRetriesByItself(t *testing.T) {
|
||||
host := "arcrun-cypher-executor.kb-a.workers.dev"
|
||||
m := &Manifest{Entries: map[string]*ManifestEntry{
|
||||
"journals/2026_04_28.md": {ContentHash: "h1", FailCount: 8, LastFailAt: 1786674239, NextRetry: 1786695839,
|
||||
LastError: `本地萃取失敗:連不上你的知識庫:Post "https://` + host + `/portal/daemon/extract": dial tcp: lookup ` + host + `: no such host`},
|
||||
"assets/scan.pdf": {ContentHash: "h2", FailCount: 8, NextRetry: 1, LastError: "本地萃取失敗:轉檔失敗(assets/scan.pdf):檔案裡沒有可抽取的文字"},
|
||||
}}
|
||||
now := time.Date(2026, 9, 16, 18, 0, 0, 0, time.Local).Unix()
|
||||
if !m.ShouldRetry("journals/2026_04_28.md", now, false) {
|
||||
t.Fatal("斷網被記滿 8 次的檔,網路好了要自己再試,不必按「立刻同步」")
|
||||
}
|
||||
if m.ShouldRetry("assets/scan.pdf", now, false) {
|
||||
t.Fatal("檔案本身的問題(讀不出文字)照舊暫停")
|
||||
}
|
||||
if m.HasOwnFailure("journals/2026_04_28.md") {
|
||||
t.Fatal("斷網不算這個檔的前科")
|
||||
}
|
||||
if p := m.Progress(); p.Pending != 1 || p.Stuck != 1 {
|
||||
t.Fatalf("斷網那份算排隊中、讀不出字那份算卡住:%+v", p)
|
||||
}
|
||||
m.MarkNetworkUnavailable("journals/2026_04_28.md", now, "dial tcp: lookup x: no such host")
|
||||
e := m.Entries["journals/2026_04_28.md"]
|
||||
if e.FailCount != 8 || m.ShouldRetry("journals/2026_04_28.md", now+30, false) || !m.ShouldRetry("journals/2026_04_28.md", now+61, false) {
|
||||
t.Fatalf("斷網要短暫等待(60 秒)而且不加次數:%+v", e)
|
||||
}
|
||||
if r := retrySkipReason(m, "journals/2026_04_28.md", now+30); !explainsWhySkipped(r) {
|
||||
t.Fatalf("等待中的訊息要講得出原因:%q", r)
|
||||
}
|
||||
// 從沒失敗過的新檔第一次就撞上斷網,同樣要等那 60 秒(不准每輪空轉重轉檔)。
|
||||
m.Entries["new.md"] = &ManifestEntry{ContentHash: "h3"}
|
||||
m.MarkNetworkUnavailable("new.md", now, "dial tcp: lookup x: no such host")
|
||||
if m.ShouldRetry("new.md", now+5, false) || !m.ShouldRetry("new.md", now+61, false) {
|
||||
t.Fatalf("新檔斷網後要等 60 秒再試:%+v", m.Entries["new.md"])
|
||||
}
|
||||
m.MarkFailed("journals/2026_04_28.md", now+61, "雲端萃取失敗(HTTP 500)")
|
||||
if e.FailCount != 1 {
|
||||
t.Fatalf("斷網留下的次數不該算進真的失敗:FailCount=%d", e.FailCount)
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:知識庫連不上(本機沒連出去)的那段時間,檔案不被記失敗、斷路器不跳;
|
||||
// 連線恢復後不按任何按鈕,下一輪就自己送上去。空白檔不送、不算失敗。
|
||||
func TestDirect201_OfflineThenBackOnline(t *testing.T) {
|
||||
cloudRoutes.reset()
|
||||
defer cloudRoutes.reset()
|
||||
withCloudCheckInterval(t, 0)
|
||||
|
||||
root := t.TempDir()
|
||||
writeFile(t, root, "筆記.md", "# 筆記\n\n內容", baseTime)
|
||||
writeFile(t, root, "journals/2026_05_07.md", "", baseTime)
|
||||
|
||||
var mu sync.Mutex
|
||||
extracts, cards := 0, map[string]bool{}
|
||||
emptyTextSent := false
|
||||
handler := 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, "/portal/daemon/extract"):
|
||||
var req struct {
|
||||
PageName string `json:"page_name"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &req)
|
||||
if strings.TrimSpace(req.Text) == "" {
|
||||
if req.PageName != "" {
|
||||
emptyTextSent = true
|
||||
}
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"success":false,"error":"page_name 與 text 必填"}`))
|
||||
return
|
||||
}
|
||||
extracts++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "output": cardFixture(req.PageName, "測試")})
|
||||
case strings.HasSuffix(r.URL.Path, "/rag_ingest_card/trigger"):
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &req)
|
||||
cards[req.Path] = true
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
default:
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
}
|
||||
})
|
||||
|
||||
// 先占一個埠再關掉:斷線期間那個位址「連線被拒」(本機層),之後在同一個位址開伺服器。
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
_ = ln.Close()
|
||||
|
||||
start := time.Date(2026, 9, 16, 0, 0, 0, 0, time.UTC)
|
||||
var clockMu sync.Mutex
|
||||
clock := start
|
||||
setClock := func(c time.Time) { clockMu.Lock(); clock = c; clockMu.Unlock() }
|
||||
oldNow := directNow
|
||||
directNow = func() time.Time { clockMu.Lock(); defer clockMu.Unlock(); return clock }
|
||||
defer 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: "我的知識庫",
|
||||
WatchFolders: []string{root},
|
||||
}},
|
||||
}
|
||||
var offline []DirectResult
|
||||
for i := 0; i < 30; i++ {
|
||||
setClock(start.Add(time.Duration(i*5) * time.Second))
|
||||
offline, _, _ = RunDirectOnce(cfg, false)
|
||||
}
|
||||
acc := cfg.makeAccountSubConfig(cfg.Accounts[0])
|
||||
m, err := LoadManifest(acc.manifestPathFor(root), root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e := m.Entries["筆記.md"]; e == nil || e.FailCount != 0 {
|
||||
t.Fatalf("斷線期間不准把這份記成失敗:%+v", e)
|
||||
}
|
||||
if n := cloudRoutes.note(workersAIExtractURL(acc.CypherURL), directNow()); n != "" {
|
||||
t.Fatalf("斷線 30 輪,斷路器不該跳:%q", n)
|
||||
}
|
||||
saw := false
|
||||
for _, r := range offline {
|
||||
if r.At == "" {
|
||||
t.Fatalf("每一筆結果都要帶時間:%+v", r)
|
||||
}
|
||||
if r.Path == "筆記.md" && (strings.Contains(r.Error, "我的知識庫") || strings.Contains(r.Error, "沒連上網路")) && explainsWhySkipped(r.Error) {
|
||||
saw = true
|
||||
}
|
||||
}
|
||||
if !saw {
|
||||
t.Fatalf("斷線時要講得出是哪台知識庫、為什麼沒送:%+v", offline)
|
||||
}
|
||||
|
||||
ln2, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
t.Skipf("無法重新綁定同一個埠(%v),略過恢復段", err)
|
||||
}
|
||||
srv := &http.Server{Handler: handler}
|
||||
go func() { _ = srv.Serve(ln2) }()
|
||||
defer srv.Close()
|
||||
for i := 0; i < 3; i++ {
|
||||
setClock(start.Add(3*time.Minute + time.Duration(i*5)*time.Second))
|
||||
RunDirectOnce(cfg, false)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !cards["筆記.md"] {
|
||||
t.Fatalf("網路恢復後沒按按鈕,這份應該自己送上去(萃取 %d 次,送卡 %v)", extracts, cards)
|
||||
}
|
||||
if emptyTextSent {
|
||||
t.Fatal("空白檔不准再送去雲端(會拿到 400 被記成失敗)")
|
||||
}
|
||||
m, _ = LoadManifest(acc.manifestPathFor(root), root)
|
||||
if p := m.Progress(); p.Stuck != 0 || p.Done != p.Total {
|
||||
t.Fatalf("恢復後兩份都要算完成(空白檔=沒有內容可整理):%+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// 0.18.54 實機第一輪抓到的:舊版把空白日記送上雲端、被 400 退回、記滿 8 次暫停——
|
||||
// 這批**舊病歷**也要被重新判成「空白略過」,不能繼續掛著「連續失敗 8 次」。
|
||||
// 同一輪:舊病歷寫「雲端沒有綁定 Workers AI」(當時雲端是舊版)的檔,雲端更新後要自己再試。
|
||||
func TestDirect201_LegacyPausedEmptyAndOldCloudRecover(t *testing.T) {
|
||||
cloudRoutes.reset()
|
||||
defer cloudRoutes.reset()
|
||||
withCloudCheckInterval(t, 0)
|
||||
|
||||
root := t.TempDir()
|
||||
writeFile(t, root, "journals/2024_11_24.md", "", baseTime)
|
||||
writeFile(t, root, "journals/2026_07_15.md", "# 七月\n\n一些內容", baseTime)
|
||||
|
||||
var mu sync.Mutex
|
||||
cards := map[string]bool{}
|
||||
srv := http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
var req struct {
|
||||
PageName string `json:"page_name"`
|
||||
Text string `json:"text"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &req)
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/portal/daemon/extract"):
|
||||
if strings.TrimSpace(req.Text) == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "output": cardFixture(req.PageName, "測試")})
|
||||
case strings.HasSuffix(r.URL.Path, "/rag_ingest_card/trigger"):
|
||||
cards[req.Path] = true
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
default:
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
}
|
||||
})}
|
||||
// 先占一個位址再關掉:第一輪連不上(只為了建出帳本),之後在同一個位址開伺服器。
|
||||
ln0, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := ln0.Addr().String()
|
||||
_ = ln0.Close()
|
||||
|
||||
clock := time.Date(2026, 9, 16, 15, 0, 0, 0, time.UTC)
|
||||
oldNow := directNow
|
||||
directNow = func() time.Time { return clock }
|
||||
defer 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: "我的知識庫", WatchFolders: []string{root}}},
|
||||
}
|
||||
RunDirectOnce(cfg, false) // 連不上的一輪:只為了建出帳本
|
||||
acc := cfg.makeAccountSubConfig(cfg.Accounts[0])
|
||||
mp := acc.manifestPathFor(root)
|
||||
m, err := LoadManifest(mp, root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
past := clock.Add(-30 * 24 * time.Hour).Unix()
|
||||
for path, why := range map[string]string{
|
||||
"journals/2024_11_24.md": "本地萃取失敗:雲端萃取失敗(HTTP 400):page_name 與 text 必填",
|
||||
"journals/2026_07_15.md": "本地萃取失敗:雲端萃取失敗(HTTP 501):這個部署沒有綁定 Workers AI(wrangler.toml 需有 [ai] binding),請更新知識庫版本",
|
||||
} {
|
||||
e := m.Entries[path]
|
||||
if e == nil {
|
||||
t.Fatalf("帳本裡沒有 %s", path)
|
||||
}
|
||||
// 還原成「舊版留下的樣子」:沒送成、記滿 8 次暫停
|
||||
e.IngestedHash, e.IngestedAt, e.NoCloudCard = "", 0, false
|
||||
e.FailCount, e.LastFailAt, e.NextRetry, e.LastError = 8, past, past+6*3600, why
|
||||
}
|
||||
if err := m.Save(mp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
t.Skipf("無法重新綁定同一個埠(%v)", err)
|
||||
}
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
defer srv.Close()
|
||||
if m2, _ := LoadManifest(mp, root); m2.Progress().Stuck != 2 {
|
||||
t.Fatalf("前置條件:兩份都該是卡住:%+v", m2.Progress())
|
||||
}
|
||||
|
||||
results, _, _ := RunDirectOnce(cfg, false)
|
||||
m, _ = LoadManifest(mp, root)
|
||||
var emptyNote string
|
||||
for _, r := range results {
|
||||
if r.Path == "journals/2024_11_24.md" {
|
||||
emptyNote = r.Error
|
||||
}
|
||||
}
|
||||
if !strings.Contains(emptyNote, "空白檔案") {
|
||||
t.Fatalf("舊病歷的空白日記要被重新判成空白略過,拿到:%q", emptyNote)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !cards["journals/2026_07_15.md"] {
|
||||
t.Fatalf("雲端更新後,「沒有綁定 Workers AI」那份要自己再試並送上去:%v", cards)
|
||||
}
|
||||
if p := m.Progress(); p.Stuck != 0 || p.Done != p.Total {
|
||||
t.Fatalf("兩份都要算完成:%+v", p)
|
||||
}
|
||||
}
|
||||
@@ -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 消失** ⇒ 補送 → 下一輪又拔章
|
||||
|
||||
+19
-7
@@ -126,6 +126,11 @@ type roundGuard struct {
|
||||
stalls []StalledCall
|
||||
// announce=把「還在等」播出去。測試會換掉它(預設寫 stdout)。
|
||||
announce func(StalledCall)
|
||||
|
||||
// ── `inkstone/arcrun-rag#200`:一輪途中把「做到哪」寫進 status.json(見 live_status.go)──
|
||||
statusPath string // 空=不寫(dry-run、測試直接呼叫低層函式)
|
||||
live RoundProgress // 此刻做到哪
|
||||
closed bool // 收工了,途中寫入一律停手
|
||||
}
|
||||
|
||||
func newRoundGuard() *roundGuard {
|
||||
@@ -256,6 +261,8 @@ func (g *roundGuard) strike(host string, step callStep, waited time.Duration) st
|
||||
announce(StalledCall{Account: host, Step: step.Name,
|
||||
WaitedSec: int(waited.Seconds()), Skipped: tripped, Note: note})
|
||||
}
|
||||
// #200:以前這份清單要等整輪收工才寫進 status.json——而收工可能是幾小時以後。
|
||||
g.persistStalls()
|
||||
// 🔴 把連線池裡那條可能已經死掉的連線丟掉。
|
||||
// 實撞的形狀(2026-08-28):行程活著、CPU 0%、`lsof` 一條 TCP 都沒有,
|
||||
// 而堆疊停在 HTTP/2 的 roundTrip——連線在作業系統那層已經沒了,
|
||||
@@ -314,6 +321,8 @@ func (c *DirectConfig) openGate(step callStep) *callGate {
|
||||
if gate.skip != "" {
|
||||
return gate // 已經跳閘:不必播報,呼叫端會立刻回頭
|
||||
}
|
||||
// #200:每一發雲端呼叫都經過這裡 ⇒ 「現在正在做哪件事」在這裡記一次就全部接上。
|
||||
c.guard.stepStarted(step, gate.started)
|
||||
go gate.keepTalking()
|
||||
return gate
|
||||
}
|
||||
@@ -335,13 +344,16 @@ func (gate *callGate) keepTalking() {
|
||||
gate.g.mu.Lock()
|
||||
announce := gate.g.announce
|
||||
gate.g.mu.Unlock()
|
||||
if announce != nil {
|
||||
announce(StalledCall{
|
||||
Account: gate.host, Step: gate.step.Name, WaitedSec: waited,
|
||||
Note: fmt.Sprintf("還在等知識庫「%s」回覆「%s」,已經等了 %d 秒。",
|
||||
gate.host, gate.step.Name, waited),
|
||||
})
|
||||
call := StalledCall{
|
||||
Account: gate.host, Step: gate.step.Name, WaitedSec: waited,
|
||||
Note: fmt.Sprintf("還在等知識庫「%s」回覆「%s」,已經等了 %d 秒。",
|
||||
gate.host, gate.step.Name, waited),
|
||||
}
|
||||
if announce != nil {
|
||||
announce(call)
|
||||
}
|
||||
// #200:以前這句只印到 stdout(collector.log 裡看得到),畫面與 status.json 一個字都沒有。
|
||||
gate.g.noteWaiting(call)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -367,7 +379,7 @@ func (gate *callGate) trace(outcome string) {
|
||||
// release 停掉播報並放掉 context。**一定要 defer**:context 活到呼叫端讀完回應
|
||||
// 之後才釋放,所以不能在讀 body 之前呼叫。
|
||||
func (gate *callGate) release() {
|
||||
gate.once.Do(func() { close(gate.stop); gate.trace("done") })
|
||||
gate.once.Do(func() { close(gate.stop); gate.trace("done"); gate.g.stepDone(gate.step) })
|
||||
gate.cancel()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -342,6 +342,11 @@ func (s *Supervisor) runOnce(ctx context.Context) error {
|
||||
s.setState(func(st *Status) { st.State = StateSyncing; st.Waiting = "" })
|
||||
continue
|
||||
}
|
||||
if r.Phase != "" && r.Phase != "done" && r.Phase != "waiting" {
|
||||
// #201:「route_failure」等純紀錄用的行(落 log 就夠了),**不是一輪跑完**。
|
||||
// 以前除了 start/waiting 以外一律當「done」,多印一種行就會讓托盤誤以為這輪結束。
|
||||
continue
|
||||
}
|
||||
if r.Phase == "waiting" {
|
||||
// #153:還在等某一發回覆 ⇒ 仍在同步中,**不算跑完一輪**。
|
||||
// 把「哪個帳號、哪件事、等了多久」留在狀態上,托盤才講得出來
|
||||
|
||||
+104
-1
@@ -7,8 +7,34 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RoundProgress=**這一輪還沒跑完時**,做到哪裡了(`inkstone/arcrun-rag#200`)。
|
||||
//
|
||||
// 🔴 為什麼要有:status.json 以前**只在整輪收工時**寫一次。對 leo 真正的設定
|
||||
// (三個帳號、好幾千個檔、每發雲端呼叫 24〜57 秒)一輪是好幾個小時
|
||||
// ⇒ 2026-09-13 實撞:12:00 之後 status.json 一次都沒寫過,而同一段時間 ISEP 的
|
||||
// manifest 已經記下 11 份送上雲端、AR-Mira 查得到卡——**畫面卻是每層 0 / N**,
|
||||
// leo:「它沒有跑任何一個東西」。那不是沒跑,是**跑了但一個字都沒回寫**。
|
||||
//
|
||||
// ⇒ 一輪途中每開始一件雲端呼叫、每處理完一份檔、每次「還在等」,都把這一格寫進去。
|
||||
// 收工時整份 status.json 照舊重寫,這一格自然消失(收工寫的那份不帶它)。
|
||||
// 行程在途中被殺掉時它會留在檔案裡——App 只在 collector 真的在跑一輪時才讀它。
|
||||
type RoundProgress struct {
|
||||
StartedAt string `json:"started_at"` // 這一輪什麼時候開始的
|
||||
UpdatedAt string `json:"updated_at,omitempty"` // 最後一次寫進來的時間(久沒更新=真的停住了)
|
||||
Account string `json:"account,omitempty"` // 正在處理哪個知識庫(主機名,與 AccountDetails 同一把 key)
|
||||
Folder string `json:"folder,omitempty"` // 正在處理哪個看守資料夾(絕對路徑;空=帳號層的事)
|
||||
Step string `json:"step,omitempty"` // 正在做哪件事(白話,同 callStep.Name)
|
||||
StepSince string `json:"step_since,omitempty"` // 那件事從什麼時候開始等
|
||||
Ingested int `json:"ingested"` // 這一輪到目前為止送上雲端的份數
|
||||
Failed int `json:"failed"` // 這一輪到目前為止沒送成功的份數
|
||||
// Waiting=那件事已經等超過 stallNoticeEvery 還沒回來——「卡在哪一步」就是它。
|
||||
Waiting *StalledCall `json:"waiting,omitempty"`
|
||||
}
|
||||
|
||||
// AccountSyncStatus 彙總單一帳號的每輪同步結果(t104 多帳號看守)。
|
||||
// key in SyncStatus.AccountDetails = instanceHostOf(cypher_url)。
|
||||
type AccountSyncStatus struct {
|
||||
@@ -169,6 +195,10 @@ type SyncStatus struct {
|
||||
// 與 SkippedDocs 同族:每輪重算的現況快照,不進 CarryForwardActivity
|
||||
//(上一輪等太久不代表這一輪也在等,帶下來就會變成一個永遠擦不掉的警告)。
|
||||
Stalls []StalledCall `json:"stalls,omitempty"`
|
||||
|
||||
// InRound=這一輪還沒跑完時做到哪(`inkstone/arcrun-rag#200`,見 RoundProgress)。
|
||||
// 收工時寫的那份不帶它 ⇒ 非空只可能是「一輪正在跑」或「上一輪跑到一半行程被殺掉」。
|
||||
InRound *RoundProgress `json:"in_round,omitempty"`
|
||||
}
|
||||
|
||||
// FolderPlanStatus=某個看守資料夾這一輪用了什麼收檔策略、據此少收了什麼
|
||||
@@ -210,13 +240,86 @@ func StatusFilePath(manifestPath string) string {
|
||||
return filepath.Join(filepath.Dir(manifestPath), "status.json")
|
||||
}
|
||||
|
||||
// statusFileMu=status.json 同一時間只有一個人在寫(`inkstone/arcrun-rag#200`)。
|
||||
//
|
||||
// 以前只有收工那一次在寫,不需要鎖;現在一輪途中也會寫,而且「還在等」是從
|
||||
// 另一條 goroutine(callGate.keepTalking)寫的 ⇒ 兩邊各自「讀出來改一格再寫回去」
|
||||
// 會互相蓋掉對方剛寫的東西。
|
||||
var statusFileMu sync.Mutex
|
||||
|
||||
// SaveSyncStatus 寫入(覆蓋)狀態檔。失敗只印 stderr,不擋看守本體。
|
||||
func SaveSyncStatus(path string, s SyncStatus) error {
|
||||
statusFileMu.Lock()
|
||||
defer statusFileMu.Unlock()
|
||||
return saveSyncStatusLocked(path, s)
|
||||
}
|
||||
|
||||
func saveSyncStatusLocked(path string, s SyncStatus) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, _ := json.MarshalIndent(s, "", " ")
|
||||
return os.WriteFile(path, data, 0o644)
|
||||
return writeFileAtomic(path, data, 0o644)
|
||||
}
|
||||
|
||||
// updateSyncStatus 在鎖裡把狀態檔讀出來、交給 fn 改、再寫回去。fn 回 false=不寫。
|
||||
//
|
||||
// 🔴 讀得到檔案但**解不開**時一律不寫:拿零值改一格再寫回去,等於把上一輪
|
||||
// 所有的數字(資料夾進度、帳號狀態、額度冷卻)整份抹掉——比不更新更糟。
|
||||
func updateSyncStatus(path string, fn func(*SyncStatus) bool) error {
|
||||
statusFileMu.Lock()
|
||||
defer statusFileMu.Unlock()
|
||||
s, err := LoadSyncStatus(path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if !fn(&s) {
|
||||
return nil
|
||||
}
|
||||
return saveSyncStatusLocked(path, s)
|
||||
}
|
||||
|
||||
// writeFileAtomic 先寫暫存檔再改名蓋過去——讀的人永遠讀到一份完整的檔。
|
||||
//
|
||||
// 🔴 為什麼現在才需要(`inkstone/arcrun-rag#200`):小幫手每秒讀一次 status.json,
|
||||
// 以前一輪只寫一次,撞到「寫到一半被讀」的機率小到沒人遇過;現在一輪途中會寫很多次。
|
||||
// 讀到半份 JSON 的後果是那一秒畫面整個掉回「沒有紀錄」。
|
||||
//
|
||||
// Windows 上讀的人剛好開著檔案時改名會失敗 ⇒ 重試幾次,還是不行就退回直接覆寫
|
||||
//(寧可偶爾讓讀的人撞到半份,也不能讓狀態檔整輪寫不進去)。
|
||||
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*.tmp")
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
break
|
||||
}
|
||||
name := tmp.Name()
|
||||
_, werr := tmp.Write(data)
|
||||
cerr := tmp.Close()
|
||||
if werr == nil && cerr == nil {
|
||||
_ = os.Chmod(name, perm)
|
||||
if rerr := os.Rename(name, path); rerr == nil {
|
||||
return nil
|
||||
} else {
|
||||
lastErr = rerr
|
||||
}
|
||||
} else if werr != nil {
|
||||
lastErr = werr
|
||||
} else {
|
||||
lastErr = cerr
|
||||
}
|
||||
_ = os.Remove(name)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if err := os.WriteFile(path, data, perm); err != nil {
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CarryForwardActivity 決定「最近一次有做事」那三個欄位的值。
|
||||
|
||||
Vendored
+17
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user