sync: collector/ 同步自 inkstone/arcrun-rag@740c55c(桌面小幫手 0.18.44)
This commit is contained in:
+8
-1
@@ -47,7 +47,14 @@
|
||||
|
||||
---
|
||||
|
||||
## 下一版(未發佈)
|
||||
## 0.18.44(2026-08-28)
|
||||
|
||||
- 一個帳號沒有回應,不會再讓整台機器的同步停下來。以前只要有一發請求卡住,後面所有知識庫帳號、所有資料夾就一起停在那裡,畫面上什麼都不動也沒有錯誤訊息(實測卡了 39 分鐘)。現在等不到回覆的那個帳號這一輪先跳過,其他資料夾照常跑完。
|
||||
- 等待不再是靜默的。哪個知識庫、正在做哪件事、已經等了多久,會寫在畫面與紀錄裡。
|
||||
- 資料夾結構每一輪都會重新整理。以前雲端一沒回應,你看到的資料夾樹就永遠停在上一次。
|
||||
- 被跳過的檔案不算失敗。雲端沒有回應是知識庫那邊的狀況,不會被記成這個檔的失敗次數,也不會因此被停掉自動重試。
|
||||
|
||||
## 0.18.43(2026-08-28)
|
||||
|
||||
- 🔴 **小幫手不再把自己整理出來的筆記當成新資料再整理一次**:以前它整理完一份文件、把筆記寫進資料夾之後,下一輪掃描又會把那些筆記當成「新來的檔案」再整理一遍,整理出來的東西又變成下一輪的材料——一輪接一輪停不下來。你會看到資料夾裡冒出越來越多名字奇怪的檔(像是同一個詞被括號包起來重複一次),每一輪都白白用掉一次當天的整理額度,知識庫裡也被塞進一堆「筆記的筆記」。現在它認得出哪些檔是自己寫的,一律跳過。
|
||||
- **已經被繁殖出來的那些檔要自己刪**:這次的修正只保證不再繼續長,**不會回頭清掉先前多出來的檔**——那些檔跟你自己的東西混在同一個資料夾裡,我們寧可不替你決定刪哪一個。名字裡有重複括號的(例如「換柱(換柱)」)就是這次多出來的,可以直接刪掉。
|
||||
|
||||
+11
-2
@@ -30,6 +30,7 @@ package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -98,7 +99,15 @@ func cloudCardPresent(cfg *DirectConfig, library, relPath string) (present bool,
|
||||
if strings.TrimSpace(library) != "" {
|
||||
q.Set("library", library)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, base+"/kbdb/entries?"+q.Encode(), nil)
|
||||
// #153:對帳一輪可能連打 cloudAuditBatch(20)發。「一發卡住」的代價在這裡
|
||||
// 會被乘上批次大小 ⇒ 20 秒的 client 逾時最壞就是 400 秒,而這期間畫面一個字
|
||||
// 都不會說。掛上等待閘:等到超時就記帳,同一個帳號連續等不到就整輪不再問。
|
||||
gate := cfg.openGate(stepCloudAudit)
|
||||
defer gate.release()
|
||||
if note := gate.blocked(); note != "" {
|
||||
return false, false, errors.New(note)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(gate.ctx, http.MethodGet, base+"/kbdb/entries?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
@@ -106,7 +115,7 @@ func cloudCardPresent(cfg *DirectConfig, library, relPath string) (present bool,
|
||||
req.Header.Set("X-Arcrun-API-Key", cfg.APIKey)
|
||||
resp, err := cloudAuditHTTP.Do(req)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
return false, false, gate.record(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, rerr := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
{
|
||||
"_algo": 4,
|
||||
"version": "0.18.42",
|
||||
"fingerprint": "43e0c6ac08233f07",
|
||||
"version": "0.18.44",
|
||||
"fingerprint": "19e26497ae609682",
|
||||
"files": {
|
||||
".gitignore": "4d56952b0fb13bf8f9b6c13a6d4c34a075bac3af447636a1df4335d7576e2f97",
|
||||
"CHANGELOG.md": "6cbc29aad162ab41f6db0ef6550bf6d5c44c01544598816d33b58ad585446528",
|
||||
"CHANGELOG.md": "c509c1215e6e9b75b386098329e4a8d958fb18718aed1716aea40fd358d76651",
|
||||
"DAEMON_LINE": "d5019abbdc8a5f2919e9e3510391891cd7fbdf0765bf16ec83caa779f370116d",
|
||||
"README.md": "9d92cac236b20a0b183eea3e7f5e39ad492f05192c4ea602eb11c3d09967327f",
|
||||
"cardloop_test.go": "fdf09b62650c328f5d8075d964a0ca2dada5f8d9a271202768472998b45e31a8",
|
||||
"cardloop_vs_existing_wiki_test.go": "1fc42120114902571f61f65a56dc561bd2d858096c25303ce564a891a7a2511d",
|
||||
"check-standalone.sh": "65fbce096326791c2f103a51e76d0ad79e9e510e2a7700b49ac2d2d81f73c853",
|
||||
"cleanup.go": "59a45672f7f73d73833685846148ba20f71d5b3f42340be5a74a10437be267fc",
|
||||
"cleanup_cli.go": "5dc41aac30f39757658d7bc95ab727ecc799b8242fd752edcedbb8423935fbf0",
|
||||
"cleanup_test.go": "23d627a12ec476bb8898847185a1464cb78e2186278299ff13c84a3d54af63c0",
|
||||
"cloud_audit.go": "a51f5c6eafaa75ce282b307e8dd2986f0b316a3c24282bed0856002819dd86e2",
|
||||
"cloud_audit.go": "652c31ec2eea6f78a95f654b71655656c52bf954faecdbe1da71c2592e30e0e7",
|
||||
"cloud_audit_test.go": "1790837e8a5726d62258436937f9c474f7abd03e6229832fdbd40f8cd7aa756a",
|
||||
"cloud_latest.go": "c2ad7f87bbd55247540eb924ac3be5deadbc4488b504770a5127a01c748f0e0f",
|
||||
"cloud_latest_test.go": "703963b65da91ae0c4638b9ad676ca7f12a474c635e903a5d988e4980b3888aa",
|
||||
@@ -88,7 +90,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": "277390ab8bf4114c797aa32b2cdae151e86a63681c612c424e73c308c446234e",
|
||||
"cmd/arcrun-app/frontend/src/main.js": "b0d9e4980cdf46a379888542ee266cfd68af7d4cadcb74420ef7db3a26ffffcb",
|
||||
"cmd/arcrun-app/frontend/src/style.css": "4fb87b139100b826c02c71a69599da2c4e8b93d3f7855c2a9b10e6677fecfbb2",
|
||||
"cmd/arcrun-app/gen-icons.py": "df5b3b33e500fc564badfc8bd2a9ca6a43f7d7e3b31111c41e3b322137c4c96c",
|
||||
"cmd/arcrun-app/go.mod": "8b9e57bcfd44886d1ec06480e033af706ff3fed470786c57074900dd48ecf86f",
|
||||
@@ -122,7 +124,7 @@
|
||||
"convert_table_test.go": "d0371b7566ef3152f9dd42f9f990e0dffa1c50a0c8e874a28415fa2e4c188394",
|
||||
"convert_test.go": "04f3fa30d1be5f910c0e0be3308ced2963191ab030a9eaccd986ef581fcd4e18",
|
||||
"convert_wiring_test.go": "3389817e596341369a3fc102846bf5b081cee668e86ebf4830eb633978de652b",
|
||||
"direct.go": "b4dc846cd62b0d57754afeb66658e35f2578afd6ca256e95ee8f72c0f03507b9",
|
||||
"direct.go": "a27107e24713b105214e8d28d729802b74d1628d3862671715fbcf8ba0095953",
|
||||
"direct_extract_test.go": "317d0057d2d368fabc406351a7477f72e074d2b8c8459b1dc021febdf16902f4",
|
||||
"direct_multi_test.go": "a76593fef26231e18b7402f6f7376624d2471f8a719b3ce08fc4a0f5a3d3cf1e",
|
||||
"direct_pacing.go": "b54838ce85f72ecd2d3388fa7cf89050c87fb10bd67dca07cd18d49568a8837c",
|
||||
@@ -131,42 +133,44 @@
|
||||
"direct_retire_test.go": "fbff728362313c546c1fc01cc74094672e22a8171d4bc0329ce1b3d118c82b3c",
|
||||
"direct_sync_now_test.go": "dc7a6e3d5646a470e889d30f0224cdb86be832b8cf85ffef8629e42ec577622b",
|
||||
"direct_t182_test.go": "1c64a9d0f41b98504e10a20a96f28e8a47cced4923171ef40dc800b861d0275b",
|
||||
"extract.go": "76c40068d378d6036e330bb56e41caec2f80b9b219352586744194df560c8147",
|
||||
"extract.go": "b6b5212ab5107c53da17bf677ac54b040f2f78674b902c8b5750a3befa4a1f23",
|
||||
"extract_gemma.go": "53483c6ea3992759abbae45562bfc418d4df1728b44fa87b3bd30324d28fc4f4",
|
||||
"extract_gemma_test.go": "439f12c1c6fb534b31f983305a4d7898ef66dfcbe697714be08a6b571e4e57c0",
|
||||
"extract_test.go": "63560ecca929c6507d93b3f52988bb7ce2cd6137140eb373c520eac5ee052c76",
|
||||
"extract_workersai.go": "577ea44f3224b54cac9042dcb11edcc29404313683c3c92d4267b07b82f35097",
|
||||
"extract_workersai.go": "8300691e1a41df4096d316686efbd0016e8df4d482be55b8ce4388b53bd9e1c5",
|
||||
"extract_workersai_test.go": "8850e7128f03ecb4754a4663281800dc73d556fab1d80367924b58bae3dd8ec3",
|
||||
"folderindex.go": "f3dd186fd7e882ff293b3f8aa6b2582b018290ee5f14bbdc02a7ac131834fd69",
|
||||
"folderindex.go": "575a56fc7763ad82aef14fc5e0e1df39d351f0903babe6251bf2df990e358089",
|
||||
"folderindex_helpers_test.go": "2cf17b70c396225f728e8e160cfc75d7bdc76ac1ce8e574d1450b020050c0734",
|
||||
"folderindex_test.go": "a5319eb1e92473cb5c32d3beddb7889997556227824de300dd39953b43beab70",
|
||||
"foldershape.go": "b1b237f5674674efdc9bdea849b63c571a014e64bec33af42fb7c4769fa7582b",
|
||||
"foldertree.go": "8b3de4fde32e538b3a6a9440f6fb89e5737391a2499668b961a933509b36fa17",
|
||||
"foldertree.go": "1800c2444696dbcd30f1fc81cf79b9fd138bcb8faa6ae943538a861e38757600",
|
||||
"foldertree_live_test.go": "ace96ffdf7670a69761973e810fb6548c7036d2768141e09db515313df1e9a50",
|
||||
"foldertree_local_test.go": "ce919dc2a21ec96716e3d43a7f493367346e212243b4ab22c2d960fc26408f3a",
|
||||
"foldertree_test.go": "f8b8cdf1ef9d7d1c80cabb086fc9b4f2162b91a47d6d78bde2632b07672c0b88",
|
||||
"foldertree_traversal_test.go": "b9a3ce5c784b7601e628b1299596b3572ca787e654cd2ee20b973d053bab84aa",
|
||||
"go.mod": "05dacc224af2a6797db86689435f83537efb433ffba521467b95d1099f5e12db",
|
||||
"go.sum": "a9c92957ca368462aab32a82a6f51c9586ea07478a7967012986d757e9969ab3",
|
||||
"grounding.go": "7152771ab1bb152946209f8e94e52bdf2facc6192b7a8ca018e37fed4557e9c0",
|
||||
"grounding_test.go": "46f5fd98297caf45b5db494f69363f2544e1cfa5634ce24fe25ca6a87ae296d2",
|
||||
"ignorerules.go": "e6923d0fe35e377e75cfe2364a10624ca40aa6d28efe48ea725036769a48ea14",
|
||||
"ignorerules_test.go": "19675a03539e92e7fd58a46ce8105875cc5aaa42f53615348de775b6d90dbf16",
|
||||
"ingestplan.go": "0c78c96c3218b8ca3f15bceb3e99c087f055bbd4d2e6024fee6268e3efc46e94",
|
||||
"ingestplan.go": "f32cd7a740fc291a6b54e2804e0bdf0d50770962414fafe92b015ddf0c6fcddf",
|
||||
"ingestplan_test.go": "baf57a5edc99482842c16d1ea28d1ad6afe7a236a56fc42dac8d007341b578be",
|
||||
"ingestplan_wiring_test.go": "4e5d25dace8a181418ed5857423ef1efeacdd98b7ff42b94a71c4e5fd7012e89",
|
||||
"inventory.go": "d0ba0be3e2af8df3cc6dffac5b75816b9c2a0a12f6d2eff1a786d8584ca77622",
|
||||
"inventory.go": "707b2b74dccbd082d56793a558bb28ce0f8812d458b221d8b2d6b084b752af50",
|
||||
"inventory_test.go": "16e0f83a09d61dc4cded032fd493d30bfc747c38116b871fb02b2f0bf2b0216a",
|
||||
"lint.go": "63af1289978a02c9722ac8bb934a916b9e4e65cffcfec0749eb07590496960f3",
|
||||
"lint_test.go": "2f57a04b9d697fba2d078eb08f49d2fec1640c7e3c61a60bc3821d2bb0798153",
|
||||
"machineid.go": "d4224e684ecfbafe2efc7e0b95377f9e669ee1256a442aa72ce2cab3b219fc56",
|
||||
"machineid_test.go": "5b54ac62ee1fb28b8f0a873888aeddb844af0aa74472bfb849573cfc6691a290",
|
||||
"machinemark.go": "b93464c2ee89a73c0e0da38bcce5dc1ba6c1deba2fc483f28c6021c3ffdee4a0",
|
||||
"machinemark.go": "592a39597fd011e8b1603318eaedf452aa58fca0639244a80218aa9a0ac6dbf5",
|
||||
"main.go": "fe2f69739f3ee92713f8aaca925068ecf97b33d476eee826fc1e0d5def8f0d10",
|
||||
"manifest.go": "c6b6fbdd5cc6963345048aa1b0a32f61b7b0b5b84616a15827aea290bd3758e1",
|
||||
"manifest_extractedby_test.go": "8019fe35675a1e801b7bda829ee1d647de32db03c029b1c651f71573e644c69e",
|
||||
"manifest_retry_test.go": "e6b4a321ccddfeed8cdbfabe6d031d5a9c59a33b47baf0b6d21150fa8406748a",
|
||||
"multiaccount_connected_test.go": "ba1817b73a5dd0dd5d300750e54d5c53bd82a75ef6e43caeddfcb2303d64ff66",
|
||||
"probe_real_manual_test.go": "6170cd7d41b6e099ad93e229f55e5f9d64d509412969f9bf162788ae8e787aef",
|
||||
"probe_workersai.go": "4667c7edeb75a7f69d99bdb9141c8d55325d3b99aecc9a661a4080c91ef517fd",
|
||||
"probe_workersai.go": "a2d5a39b4f4c05f01031a592bc0c1cedc2af0f6f34d3820699dca133f9b16900",
|
||||
"progress.go": "c2c86ecfea5a8227eb40018c55d48f26c4f2de1b8775fde9acd231cba8b7bb37",
|
||||
"progress_test.go": "21258889ad7680da2d6cf8812accda7e6a1aca6c6fd604394ec4363c2ed902bb",
|
||||
"progress_wiring_test.go": "d1d144269539825ef4a4d9ffca5c49b021cb27cecea7d280309f3f935460b3cd",
|
||||
@@ -176,25 +180,27 @@
|
||||
"repoguard_test.go": "ce0316a59af944c3cf391961653fe82294fd45e728d35b4b7fcd99b2b20e7b5c",
|
||||
"safewrite.go": "d5dd09ffe894fb9fb94656a4bb50d246bd8c65ab21a7ad81463f43dfdacc8a57",
|
||||
"safewrite_test.go": "2980f0f86f78fb265403e31d4cba6da43f4dbc16e7c4711cfccba23175578358",
|
||||
"scan.go": "d26b74e46204697a573ed24ee427c06878349611fcaf3c35df295b826b690525",
|
||||
"scan.go": "546329b883228687a60d48419c19113a6208f0f5ea18a0d3ba0fc401ab3c0974",
|
||||
"scan_carry_lasterror_test.go": "6fa542422fb3e00b5ec4355b5db8df7086361b2dacefd67755d75c0ddfa55575",
|
||||
"scan_dedup_scale_test.go": "573b3c78bec86614bf4d2f99b7c3c3157f92c24af73cae7290428fc7f43f0362",
|
||||
"scan_dedup_test.go": "580ec58596ca54aa3d29b417e7d098fd2e3455fd39bf10e7da02d453c0786df0",
|
||||
"scan_skipped_test.go": "983ecabb5d93388a501836d15dd445789b7f3028a7f3b966e04425ad4d5bfd9c",
|
||||
"scan_test.go": "d0e472686ff41da2421bfd45b63099fd75877e6fc72f26dfdd1a07dc64c9f0cd",
|
||||
"sourcerepair.go": "2ac80c82ca0cb376515448ecc4c38ab542cc8c885f3cc5bc8b604a94c064c7f3",
|
||||
"sourcerepair.go": "bdb1a4b64d028a9df42a7b9258a431e01f1be8871c490b92011d0c6ee5e53b79",
|
||||
"sourcerepair_live_test.go": "2a7f5d6fada420a7f55fa52b87361842755c68c1cb58f8c3fd0153341f709a0a",
|
||||
"sourcerepair_test.go": "2a1ddddaad8ac7adc36350859e2d742c0fea12b99370a4d4831d390db1cc6720",
|
||||
"stallguard.go": "8923a5be2b781d10e2bd2ada0484f59f24afdfb473252415d16ae5c28f246f9f",
|
||||
"stallguard_test.go": "3e908ff3e1cffae51635b0b3b5ed4656b61e0d97c1c34c263bb7e5bc655aeb1b",
|
||||
"supervisor/hidewindow_other.go": "291b4ecb0783ff3e813c68b5464161d9af2f84d31c773c7f1f710f218bb5d610",
|
||||
"supervisor/hidewindow_windows.go": "29796dbe90ecb35e6f1451dacf956d42928c6a393271befc5c7f6863d5315fb3",
|
||||
"supervisor/supervisor.go": "cb17332ce5a7190627839e624895c56b508635edbd2c29758f794d5c3bb39388",
|
||||
"supervisor/supervisor.go": "f79b32e92e67058a056dc749a6182573eefcbbc9bc7b2cd5a997f4e8066a004e",
|
||||
"supervisor/supervisor_test.go": "f5582d54c55065fbc834fe96754df9c8cd6efe9f88f30208bbce84ddea08fea7",
|
||||
"sync_status.go": "59e8e2c81ec5a1cbd46e852bd7c794c4179c76ac62561f21550a41afb9e2be2d",
|
||||
"sync_status.go": "eb4686b6162b664aad30c50f2b5694f78eb936222ed0363a37505037fc06d983",
|
||||
"sync_status_test.go": "9314b354679f0c5090257cf6918185c33b74102bfac0acb655c3e97b81a247b3",
|
||||
"t52_library_test.go": "14188d0afdad0f9aa16d19a74ea28c45c3d18fcfeabc5deb6fb0466d05778a51",
|
||||
"template_install.go": "d0aa0098e431696a63ef8d3714c124591b8ba193ccd5a8359a4ea03e397ee358",
|
||||
"template_install_test.go": "e2d096a80c52ab10fd1a9e26cab2a9621cc2017df8c0c5726aaa450a00375566",
|
||||
"template_not_knowledge_test.go": "d25e84a13fb97351cd3a579178a5e24cb266309bdc9d526ff7bec442071df3ab",
|
||||
"template_not_knowledge_test.go": "59c272068d308defd1fb19aedfc5b311bb64d2fc58e958e78b98d9f7ba27b6f1",
|
||||
"templatefs/.claude/VERSION": "04d659518a1793d5396007fcabdc9211a8c1db8406bf4e0fa8069171fdb06518",
|
||||
"templatefs/.claude/commands/issue-handle.md": "2010f4e418dbb51e928873019c30602b79ff4dbf79509cb412884616f10a0e01",
|
||||
"templatefs/.claude/commands/rag-extract-file.md": "33e8f10fbde076452a5a304cb153982603b238ffe1805e0faf7b7fbed246298e",
|
||||
@@ -232,6 +238,7 @@
|
||||
"templatefs/system-dev/wiki/status.md": "ef03d670e48847c94954db93469bcb9d0bf38860a155fb0847c3e16d3411b839",
|
||||
"templatefs/system-dev/workflows/tasks-project-sync.local.sh": "7a611abc8b597d7c047e1b73c4a19c011ba1719f6688351b677b4feee0a4d4fe",
|
||||
"templatefs/system-dev/workflows/tasks-project-sync.yaml": "e8c7dd78d338e64219ad0eb7aae27aec02b6ac2ad962f309145055a87f1cd5db",
|
||||
"templateuntouched.go": "74e6c0dda22228ac65cc841ce83460d81026134d41b4b16c528bf7ce760db26f",
|
||||
"testdata/grounding/card-access-paraphrase.md": "2bbf4427b1ea2c5b73995b135752f60c4978f2c9121647c4a0aa7b42d6063570",
|
||||
"testdata/grounding/card-delivery-paraphrase.md": "727f6d09cb4ebb0e8248988bb9872573d4339f2256e6723a588eceb04aa4fd42",
|
||||
"testdata/grounding/card-geek6688-faithful.md": "7b7200aa2d142720cde51c45b7266e32a5b9f278a808a115c2546443903f09f8",
|
||||
@@ -239,8 +246,9 @@
|
||||
"testdata/grounding/source-access-en.md": "a1c28715a06940b0bb512cd2220aa6431bf47c1ae84da314f4a076ed6d21c906",
|
||||
"testdata/grounding/source-delivery-zh.md": "80732bee124312b791609fe499b274f81cdaa12916b30fc21bc309c41ad1ac86",
|
||||
"testdata/grounding/source-requirements.md": "5fd1115db5009aee98667634a9a374ee66460a2cb53c3819065080f2f47506f3",
|
||||
"tidy.go": "e0b104866a11d0ce25ac80ca536a644900907d1ec6231f95e132843d88be5d0a",
|
||||
"tidy.go": "aee6d6caebe45721d714f2954a45e453a3521b44d7edafe1b7bd15ece6d98a47",
|
||||
"tidy_test.go": "80fd37d7abf9f9fd075006da42aea73a3843c49f969e62623687519102dfe3ea",
|
||||
"tidy_wiki_untouched_test.go": "4a42be32c770abdf9d29ee3489a1421447cac82557a4dafcf2f23e1cadc1d0c5",
|
||||
"trigger.go": "f689f701bef08401f5d47f5b3d783d24a88fc9a9426fe41bdbbb3f9b152ab405",
|
||||
"trigger_test.go": "0c2482d18cea0568cd2a0f5eae5b02a0984b9b2bc83461eb882c630d6381ca02",
|
||||
"triggeroutcome.go": "25832326ecb3694d8a3a0f8a115ce8df6327b70ff61b0bec1357d57e53e81836",
|
||||
|
||||
@@ -12,5 +12,7 @@
|
||||
"0.18.39": "316426f50e886003",
|
||||
"0.18.40": "a4485965c128ef9d",
|
||||
"0.18.41": "8033de0cdec3537b",
|
||||
"0.18.42": "43e0c6ac08233f07"
|
||||
"0.18.42": "43e0c6ac08233f07",
|
||||
"0.18.43": "34f17e1e6a190eaa",
|
||||
"0.18.44": "19e26497ae609682"
|
||||
}
|
||||
|
||||
@@ -114,6 +114,12 @@ type DirectConfig struct {
|
||||
// 所以改名不會讓庫裡憑空多出一台機器。
|
||||
MachineLabel string `json:"machine_label,omitempty"`
|
||||
|
||||
// guard=這一輪的「等待閘」(見 stallguard.go)。RunDirectOnce 每輪換一份新的;
|
||||
// makeAccountSubConfig 的 `sub := *c` 會把這個**指標**一起帶過去,所以同一輪的
|
||||
// 所有帳號、所有資料夾共用同一份紀錄——斷路器要跨得了資料夾才擋得住「一發卡住
|
||||
// 就整輪停擺」。不落 config 檔(它是這一次執行的狀態,不是使用者設定)。
|
||||
guard *roundGuard
|
||||
|
||||
// machine=解析好的機器身分快取(不落 config 檔:ID 的家是 machine.json,
|
||||
// 這裡只是這一輪的記憶體副本。makeAccountSubConfig 的 `sub := *c` 會一起複製,
|
||||
// 所以多帳號同一輪只解析一次、每個帳號送出的值必然一致)。
|
||||
@@ -467,7 +473,11 @@ func (c *DirectConfig) migrateManifestIfNeeded(absRoot, newPath string) {
|
||||
}
|
||||
}
|
||||
|
||||
// directHTTP 是 direct 模式共用的 HTTP client(萃取 workflow 可能同步跑 LLM,放寬 timeout)。
|
||||
// directHTTP 是 direct 模式共用的 HTTP client。
|
||||
//
|
||||
// 🔴 這把 Timeout 是**最後一道**保險,不是每一發的上限(`inkstone/arcrun-rag#153`):
|
||||
// 真正生效的上限由呼叫端的 callStep 帶進 context(見 stallguard.go),因為
|
||||
// 「送一份筆記」跟「請雲端同步跑完 AI 萃取」本來就不該共用同一個數字。
|
||||
var directHTTP = &http.Client{Timeout: 300 * time.Second}
|
||||
|
||||
// triggerURL 組出 named-webhook 觸發完整 URL。
|
||||
@@ -505,12 +515,23 @@ func countsAsDocument(r DirectResult) bool {
|
||||
}
|
||||
|
||||
// postJSON POST 一個 JSON body 到 url,回傳 HTTP 狀態碼與回應片段。非 2xx 視為錯誤。
|
||||
func (c *DirectConfig) postJSON(url string, body any) (int, string, error) {
|
||||
//
|
||||
// step 講的是「這一發在做什麼」(`inkstone/arcrun-rag#153`):它決定這一發自己的
|
||||
// 上限,也決定卡住時畫面與日誌上那句話怎麼寫。同一個網址在不同地方是不同的事
|
||||
//(收卡那條路,可能是使用者剛存的新檔,也可能是在補修舊筆記的出處)——
|
||||
// 所以 step 由呼叫端給,不從網址反推,反推出來的名字會說謊。
|
||||
func (c *DirectConfig) postJSON(step callStep, url string, body any) (int, string, error) {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
|
||||
gate := c.openGate(step)
|
||||
defer gate.release() // context 要活到下面讀完回應為止,所以是 defer 不是就地釋放
|
||||
// 這一輪已經判定這個帳號沒有回應 ⇒ 連打都不打,立刻回頭讓其他資料夾繼續。
|
||||
if note := gate.blocked(); note != "" {
|
||||
return 0, "", errors.New(note)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(gate.ctx, http.MethodPost, url, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
@@ -518,7 +539,7 @@ func (c *DirectConfig) postJSON(url string, body any) (int, string, error) {
|
||||
req.Header.Set("X-Arcrun-API-Key", c.APIKey)
|
||||
resp, err := directHTTP.Do(req)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
return 0, "", gate.record(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// 🔴 讀 64KB 而不是 1KB:觸發端點的回應是一層外殼包著工作流的輸出,
|
||||
@@ -611,6 +632,12 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
var lastPayload *TriggerPayload
|
||||
now := time.Now() // 2026-08-07 pacing task:整輪共用同一個時間點(排序/冷卻判斷一致、好測試)
|
||||
|
||||
// 🔴 `inkstone/arcrun-rag#153`:這一輪的「等待閘」。每輪換一份新的——斷路器只管
|
||||
// 這一輪,下一輪一律從零開始重新試(同步是 level-triggered 的,沒有什麼要記住)。
|
||||
// 指標會隨 makeAccountSubConfig 的 `sub := *c` 傳給每個帳號、每個資料夾,
|
||||
// 所以「這個帳號沒有回應」這件事跨得了資料夾——那正是本票要修的那條線。
|
||||
cfg.guard = newRoundGuard()
|
||||
|
||||
// 2026-08-07:提早載入上一輪 status(原本只在函式尾端載入做 CarryForwardActivity)。
|
||||
// 額度冷卻與「今天已萃幾份」是**跨輪持續的狀態**(quotaState 見 quota.go),
|
||||
// 要在處理帳號之前就知道上一輪冷卻到什麼時候、今天已經算到幾份。
|
||||
@@ -757,7 +784,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
// 更新進度不同步),一個帳號通了不代表另一個也通。
|
||||
// 只在走 workers-ai 這條路時掃;選了 Gemini 的人不需要知道這件事。
|
||||
if accCfg.Extractor == "workers-ai" {
|
||||
state := ProbeWorkersAI(accCfg.CypherURL, accCfg.APIKey)
|
||||
state := accCfg.probeWorkersAI()
|
||||
accSt.CloudAIReady = state.Ready
|
||||
accSt.CloudAINote = state.Note
|
||||
if !state.Ready && state.Note != "" {
|
||||
@@ -896,6 +923,8 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
AccountDetails: accountDetails,
|
||||
Retiring: retiring, // arcrun-rag#46:移除並收回中的資料夾進度
|
||||
Resync: resync, // arcrun-rag#140:雲端上找不到、正在自動補送的資料夾
|
||||
// #153:這一輪等太久的事。空=沒有人在等,畫面上不佔位置。
|
||||
Stalls: cfg.guard.Stalls(),
|
||||
}
|
||||
// G-6.2:把「讀不了的檔」寫進狀態檔,App 首頁才有東西可以講。
|
||||
// 排序=畫面每輪穩定(map 迭代順序隨機,不排的話清單會自己跳動)。
|
||||
@@ -1109,11 +1138,17 @@ func drainPendingTakedowns(
|
||||
}
|
||||
return results, exit
|
||||
}
|
||||
// #153:同一條撤除路徑服務兩件事,而使用者眼中它們不是同一件——
|
||||
// 「我刪了一個檔」跟「我把整個資料夾收回來」卡住時該說的話不一樣。
|
||||
step := stepTakedown
|
||||
if resultType == "folder_takedown" {
|
||||
step = stepRetire
|
||||
}
|
||||
for oldPath, pageName := range m.PendingTakedowns {
|
||||
pace()
|
||||
res := DirectResult{Type: resultType, Path: oldPath}
|
||||
mach := cfg.machineIdentity()
|
||||
status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.RemovedWF), map[string]any{
|
||||
status, _, perr := cfg.postJSON(step, cfg.triggerURL(cfg.RemovedWF), map[string]any{
|
||||
"page_name": pageName,
|
||||
"path": oldPath,
|
||||
"library": cfg.libraryFor(absRoot),
|
||||
@@ -1567,6 +1602,18 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
results = append(results, res)
|
||||
continue
|
||||
}
|
||||
// 🔴 #153:這個**帳號**這一輪已經被判定沒有回應 ⇒ 連試都不試。
|
||||
// 與上面的額度冷卻同一層、同一個理由:這不是這個檔的問題,
|
||||
// 記進它的病歷(FailCount/退避階梯)會讓一次雲端沒回應,
|
||||
// 變成一整批檔案「已放棄自動重試」——那是把別人的停機算在使用者頭上。
|
||||
// 沒有這道閘的話,一個沒有回應的帳號會讓這一輪繼續逐檔去撞,
|
||||
// 每撞一次就是一個 Budget,25 個檔就是幾十分鐘。
|
||||
if note := cfg.unreachableNote(); note != "" {
|
||||
res.Status = "skipped"
|
||||
res.Error = note
|
||||
results = append(results, res)
|
||||
continue
|
||||
}
|
||||
// 🔴 t195 止血點:這個檔剛失敗過且還在退避窗口內 → 這輪跳過。
|
||||
// 沒有這道閘時的實測災情:`小果被AFTEE詐貸.pdf` 因雲端 401 失敗,
|
||||
// 每輪重掃又被當成新檔 ⇒ **1387 輪、跨 11 小時**,且它排在佇列前面,
|
||||
@@ -1619,7 +1666,19 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
// ⇒ 探測在 RunDirectOnce(ProbeWorkersAI),結果寫進 status.json,
|
||||
// 托盤那行「狀態:」直接告訴用戶該做什麼。
|
||||
// 靜默退回會讓用戶**永遠不知道自己的雲端還沒更新**——正是要避免的黑箱。
|
||||
// #153:萃取也是一發會等很久的網路呼叫,而 workers-ai 打的正是
|
||||
// **使用者自己的那台雲端實例**——跟上面那些收口是同一台。
|
||||
// 它有自己的 client timeout,但沒有人在數「這個帳號已經連續幾發
|
||||
// 等不到回覆」⇒ 漏掉這一格的話,單輪上限 25 個檔會變成 25 次
|
||||
// 各自的等待,同一輪照樣走不完。
|
||||
//
|
||||
// 🔴 gemma 那條**刻意不掛**:它打的是 Google,不是使用者的知識庫。
|
||||
// 掛上去的話,Google 慢會被算成「你的知識庫沒有回應」——
|
||||
// 誤導的訊息比沒有訊息更貴(會害人往錯的方向查)。
|
||||
xgate := cfg.openGate(stepExtractDoc)
|
||||
cards, xerr = ExtractWithWorkersAI(cfg.CypherURL, cfg.APIKey, absRoot, ev.Path, cardOrigin)
|
||||
xgate.release()
|
||||
xerr = xgate.record(xerr) // 只有「等到超時」會被記帳,其餘錯誤原樣往下走
|
||||
case "gemma":
|
||||
cards, xerr = ExtractWithGemma(cfg.GeminiAPIKey, cfg.LLMModel, absRoot, ev.Path, cardOrigin)
|
||||
default:
|
||||
@@ -1700,7 +1759,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
cardBody["quality"] = "low"
|
||||
cardBody["quality_warnings"] = warns
|
||||
}
|
||||
status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.CardIngestWF), cardBody)
|
||||
status, _, perr := cfg.postJSON(stepIngestCard, cfg.triggerURL(cfg.CardIngestWF), cardBody)
|
||||
res.HTTPStatus = status
|
||||
if perr != nil {
|
||||
res.Status, res.Error = "failed", perr.Error()
|
||||
@@ -1743,7 +1802,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
// ⚠️ 雲端這支 workflow 本輪**沒有跟著改**(youlin stage 上根本沒部署它,
|
||||
// 現役是 rag_ingest_card)——它會忽略這兩個欄位,行為與從前一字不差。
|
||||
machDirect := cfg.machineIdentity()
|
||||
status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.IngestWF), map[string]any{
|
||||
status, _, perr := cfg.postJSON(stepIngestDoc, cfg.triggerURL(cfg.IngestWF), map[string]any{
|
||||
"page_name": pageNameOf(ev.Path),
|
||||
"path": ev.Path,
|
||||
"content": string(content),
|
||||
@@ -1780,7 +1839,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
// 連坐殺掉 B 機器同名檔」,而多補一維會改變既有的撤除命中範圍——
|
||||
// 那是另一件事,要另外驗(本輪不驗的不做)。
|
||||
machRm := cfg.machineIdentity()
|
||||
status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.RemovedWF), map[string]any{
|
||||
status, _, perr := cfg.postJSON(stepTakedown, cfg.triggerURL(cfg.RemovedWF), map[string]any{
|
||||
"page_name": pageNameOf(ev.Path),
|
||||
"path": ev.Path,
|
||||
"machine": machRm.ID,
|
||||
@@ -1919,20 +1978,22 @@ func runDirect(args []string) int {
|
||||
// ⇒ 開工前先印一筆 `phase:"start"`,托盤收到就顯示「同步中…」,
|
||||
// 收到 `phase:"done"` 再切回「看守中」。
|
||||
// 形狀相容:兩筆都有 `at`,舊版托盤只會多算一次 round,不會壞掉。
|
||||
startOut, _ := json.MarshalIndent(struct {
|
||||
//
|
||||
// 🔴 #153:這兩筆改走 printJSONLine——「還在等」的播報跑在另一條 goroutine 上,
|
||||
// 而 done 那筆可能有幾十 KB。共用同一把鎖,兩邊才不會把彼此的 JSON 切成兩半
|
||||
//(切壞一次,supervisor 的 decoder 就再也讀不到這個行程的任何一筆,見 stallguard.go)。
|
||||
printJSONLine(struct {
|
||||
At string `json:"at"`
|
||||
Phase string `json:"phase"`
|
||||
}{time.Now().Format(time.RFC3339), "start"}, "", " ")
|
||||
fmt.Println(string(startOut))
|
||||
}{time.Now().Format(time.RFC3339), "start"})
|
||||
|
||||
results, exit, _ := RunDirectOnce(cfg, *dryRun)
|
||||
out, _ := json.MarshalIndent(struct {
|
||||
printJSONLine(struct {
|
||||
At string `json:"at"`
|
||||
Phase string `json:"phase"`
|
||||
Folders []string `json:"folders"`
|
||||
Results []DirectResult `json:"results"`
|
||||
}{time.Now().Format(time.RFC3339), "done", cfg.Folders(), results}, "", " ")
|
||||
fmt.Println(string(out))
|
||||
}{time.Now().Format(time.RFC3339), "done", cfg.Folders(), results})
|
||||
return exit
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -318,7 +318,7 @@ func syncFolderCards(cfg *DirectConfig, absRoot string, m *Manifest, hasEvents,
|
||||
continue
|
||||
}
|
||||
pace()
|
||||
status, _, err := cfg.postJSON(cfg.triggerURL(wf), map[string]any{
|
||||
status, _, err := cfg.postJSON(stepFolderCard, cfg.triggerURL(wf), map[string]any{
|
||||
"page_name": c.Page,
|
||||
"path": c.Path,
|
||||
"card_content": c.Content,
|
||||
|
||||
+1
-1
@@ -355,7 +355,7 @@ func syncFolderTree(cfg *DirectConfig, absRoot string, m *Manifest, tree FolderT
|
||||
"sync_token": h,
|
||||
"nodes": tree.Nodes,
|
||||
}
|
||||
status, _, err := cfg.postJSON(cfg.folderTreeURL(), body)
|
||||
status, _, err := cfg.postJSON(stepFolderTree, cfg.folderTreeURL(), body)
|
||||
res.HTTPStatus = status
|
||||
if err != nil {
|
||||
res.Status = "failed"
|
||||
|
||||
+1
-1
@@ -274,7 +274,7 @@ func syncInventory(cfg *DirectConfig, absRoot string, m *Manifest, hasEvents, dr
|
||||
// 只帶 library ⇒ 兩台機器上同名的資料夾會生出一模一樣的鍵,後同步的那台會把
|
||||
// 前一台的總覽卡蓋掉。這裡與逐檔卡走同一組欄位,不另開一種。
|
||||
mach := cfg.machineIdentity()
|
||||
status, _, err := cfg.postJSON(cfg.triggerURL(wf), map[string]any{
|
||||
status, _, err := cfg.postJSON(stepInventory, cfg.triggerURL(wf), map[string]any{
|
||||
"page_name": page,
|
||||
"path": res.Path,
|
||||
"card_content": card,
|
||||
|
||||
+36
-8
@@ -23,6 +23,7 @@ package collector
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -31,6 +32,10 @@ import (
|
||||
)
|
||||
|
||||
// probeHTTP 探測用短逾時:只是要知道 route 在不在,不等 LLM 生成。
|
||||
//
|
||||
// 🔴 `inkstone/arcrun-rag#153`:這把 20 秒是保險,真正的上限由呼叫端帶進來的
|
||||
// context 決定。探測是**每輪、每個帳號的第一發**——它撞到不回應的端點時,
|
||||
// 後面所有帳號都還沒開始,所以它也是最早能認出「這個帳號今天不回應」的位置。
|
||||
var probeHTTP = &http.Client{Timeout: 20 * time.Second}
|
||||
|
||||
// CloudAIState 是「某個雲端實例的 Workers AI 萃取能力」探測結果。
|
||||
@@ -47,25 +52,27 @@ type CloudAIState struct {
|
||||
// ⇒ 算通。這樣探測不會真的燒 LLM 額度,也不會產生垃圾卡片。
|
||||
// - 401 代表 route 在、只是金鑰不對 ⇒ route 存在,同樣算「雲端有這功能」,
|
||||
// 金鑰問題由既有的連線流程去報,不混在這裡講。
|
||||
func ProbeWorkersAI(cypherURL, apiKey string) CloudAIState {
|
||||
func ProbeWorkersAI(ctx context.Context, cypherURL, apiKey string) (CloudAIState, error) {
|
||||
base := strings.TrimSuffix(strings.TrimSpace(cypherURL), "/")
|
||||
if base == "" {
|
||||
return CloudAIState{Ready: false, Note: "還沒連上知識庫"}
|
||||
return CloudAIState{Ready: false, Note: "還沒連上知識庫"}, nil
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"page_name": "", "text": ""})
|
||||
req, err := http.NewRequest(http.MethodPost, base+"/portal/daemon/extract", bytes.NewReader(body))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/portal/daemon/extract", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return CloudAIState{Ready: false, Note: "雲端 AI 狀態查不到"}
|
||||
return CloudAIState{Ready: false, Note: "雲端 AI 狀態查不到"}, nil
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Arcrun-API-Key", strings.TrimSpace(apiKey))
|
||||
|
||||
resp, err := probeHTTP.Do(req)
|
||||
if err != nil {
|
||||
// #153:錯誤原件一起回去,呼叫端才分得出「等到超時」與「立刻連不上」——
|
||||
// 前者要記進斷路器,後者不該(不然一次斷網就把帳號判成停機)。
|
||||
// 連不上(離線/網路問題)≠ 雲端沒裝。講「查不到」而不是「還沒通」,
|
||||
// 免得把網路問題誤報成「你沒更新」讓用戶白跑一趟。
|
||||
return CloudAIState{Ready: false, Note: "連不上你的知識庫,雲端 AI 狀態查不到"}
|
||||
return CloudAIState{Ready: false, Note: "連不上你的知識庫,雲端 AI 狀態查不到"}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -74,14 +81,35 @@ func ProbeWorkersAI(cypherURL, apiKey string) CloudAIState {
|
||||
return CloudAIState{
|
||||
Ready: false,
|
||||
Note: "雲端 AI 還沒通 ⇒ 你的知識庫是舊版,請到 portal 按「立即更新」重裝一次",
|
||||
}
|
||||
}, nil
|
||||
case resp.StatusCode < 500:
|
||||
// 200/400/401… 都代表這條 route 存在=雲端有這個功能。
|
||||
return CloudAIState{Ready: true}
|
||||
return CloudAIState{Ready: true}, nil
|
||||
default:
|
||||
return CloudAIState{
|
||||
Ready: false,
|
||||
Note: fmt.Sprintf("雲端 AI 暫時有狀況(HTTP %d),稍後會自動再試", resp.StatusCode),
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// probeWorkersAI=帶「等待閘」的探測(`inkstone/arcrun-rag#153`)。
|
||||
//
|
||||
// 為什麼這一發特別重要:它是每輪、每個帳號的**第一發**。撞到不回應的端點時,
|
||||
// 後面的資料夾一件都還沒開始 ⇒ 這裡認出來,整個帳號的其餘工作就都省下來了。
|
||||
// 反過來說,漏掉這一發的話,前面所有的閘都白裝——2026-08-28 第一版就是這樣:
|
||||
// 斷路器跳了,可是一輪還是要 20 秒,因為那 20 秒全花在這一發上。
|
||||
func (c *DirectConfig) probeWorkersAI() CloudAIState {
|
||||
if note := c.unreachableNote(); note != "" {
|
||||
return CloudAIState{Ready: false, Note: note}
|
||||
}
|
||||
gate := c.openGate(stepProbeAI)
|
||||
state, err := ProbeWorkersAI(gate.ctx, c.CypherURL, c.APIKey)
|
||||
gate.release()
|
||||
if perr := gate.record(err); perr != nil {
|
||||
// 等到超時 ⇒ 講的是「沒有回應」,不是「你的雲端沒裝好」。
|
||||
// 把等待誤報成「你沒更新」會害使用者白跑一趟去按重裝。
|
||||
return CloudAIState{Ready: false, Note: perr.Error()}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
+1
-1
@@ -166,7 +166,7 @@ func repairCardSourceBlocks(cfg *DirectConfig, absRoot string, m *Manifest, dryR
|
||||
|
||||
// ① 先重推(零 LLM)。欄位與 direct.go 送新卡時逐欄一致,
|
||||
// workflow 進門會先刪同 page_name+同 source_path 的舊 blocks ⇒ 取代不疊加。
|
||||
status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.CardIngestWF), map[string]any{
|
||||
status, _, perr := cfg.postJSON(stepRepairOrigin, cfg.triggerURL(cfg.CardIngestWF), map[string]any{
|
||||
"page_name": pageNameOf(libPath),
|
||||
"path": libPath,
|
||||
"card_content": docCard,
|
||||
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
// stallguard.go — 一發等不到回覆的請求,不可以讓整台機器的同步停下來
|
||||
// (`inkstone/arcrun-rag#153`)。
|
||||
//
|
||||
// 一輪同步是**一條線**走完的:帳號 → 看守資料夾 → 一件件事。整條線上每一發
|
||||
// HTTP 都是同步的,所以只要其中一發停在那裡不回來,它後面的所有帳號、所有
|
||||
// 資料夾就一起停擺——而且是**安靜地**停擺:日誌只有開工那一行,畫面上的
|
||||
// 資料夾結構永遠停在上一版,沒有任何錯誤訊息。
|
||||
//
|
||||
// 為什麼不是「把 300 秒調小」:那只改變**卡多久**,沒改變「一發卡住就全停」。
|
||||
// 一個階段可以連續打二十發(見 sourcerepair.go 的 sourceRepairBatch),
|
||||
// 每發都等到超時的話,60 秒的上限一樣會把一輪拖成二十分鐘。
|
||||
//
|
||||
// 這支檔把「等待」變成三件有邊界、會說話的事:
|
||||
//
|
||||
// ① **每一發自己的上限**——按「這一發實際上在做什麼」給(會同步跑 AI 萃取的
|
||||
// 那一發本來就慢,機械收口的那些不該也享有五分鐘),而且用 context 帶進
|
||||
// 請求裡,不是只靠 client 那一把套用全部的總閘。
|
||||
// ② **同一個帳號連續等不到回覆 ⇒ 這一輪不再打它**(斷路器)。一發卡住的代價
|
||||
// 從「整輪停擺」降成「這個帳號這一輪跳過」,其他帳號、其他資料夾照常跑完。
|
||||
// ③ **等待要有話說**——超過 stallNoticeEvery 就往 stdout 播一句
|
||||
// 「哪個帳號、哪件事、等了多久」,並在收工時寫進 status.json。
|
||||
// 靜默的等待跟當掉對使用者是同一件事。
|
||||
//
|
||||
// 🔴 播報用 `phase:"waiting"`,**不能沿用既有的兩種**:supervisor 把任何
|
||||
// 非 `start` 的 JSON 值都當成「一輪跑完了」(supervisor.go 的 decode 迴圈),
|
||||
// 沿用會讓托盤在同步途中跳回「看守中」——正是 t191 修掉的那個病。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// callStep 描述「這一發網路呼叫在做什麼」。
|
||||
//
|
||||
// Name 會出現在日誌與畫面上,所以它是**產品文案**:講使用者認得的那件事,
|
||||
// 不出現 workflow 名、狀態碼、模型名。
|
||||
type callStep struct {
|
||||
Name string
|
||||
Budget time.Duration
|
||||
}
|
||||
|
||||
const (
|
||||
// llmCallBudget=那一發會在雲端同步跑完 AI 萃取才回來(原本 directHTTP 那把
|
||||
// 300 秒就是為它放寬的),維持不變。
|
||||
llmCallBudget = 300 * time.Second
|
||||
// plainCallBudget=零 LLM 的機械收口(收卡/下架/登記結構)。這不是「把 300
|
||||
// 調小」——這些請求從來就不跑模型,給它們五分鐘只是讓卡住的代價變大。
|
||||
plainCallBudget = 60 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
stepIngestDoc = callStep{"整理一份文件", llmCallBudget}
|
||||
stepIngestCard = callStep{"送出一份筆記", plainCallBudget}
|
||||
stepExtractDoc = callStep{"請雲端讀一份文件", llmCallBudget}
|
||||
stepRepairOrigin = callStep{"更新舊筆記的原文位置", plainCallBudget}
|
||||
stepTakedown = callStep{"把刪掉的檔案從雲端下架", plainCallBudget}
|
||||
stepRetire = callStep{"收回這個資料夾在雲端的資料", plainCallBudget}
|
||||
stepFolderTree = callStep{"回報資料夾結構", plainCallBudget}
|
||||
stepInventory = callStep{"送出資料夾總覽", plainCallBudget}
|
||||
stepFolderCard = callStep{"送出目錄索引", plainCallBudget}
|
||||
// stepProbeAI=每輪每個帳號的第一發(探測雲端 AI 通了沒)。它同時是最早
|
||||
// 能認出「這個帳號今天不回應」的位置——認出來,這個帳號其餘的工作就都省了。
|
||||
stepProbeAI = callStep{"確認雲端 AI 可不可以用", 20 * time.Second}
|
||||
// stepCloudAudit=跟雲端核對「先前送過的檔案還在不在」。唯讀查詢,
|
||||
// 一輪可能連打 cloudAuditBatch 發——正是「一發卡住的代價會被乘上批次大小」的例子。
|
||||
stepCloudAudit = callStep{"跟雲端核對哪些檔案還在", 20 * time.Second}
|
||||
)
|
||||
|
||||
// stallNoticeEvery=等多久開口說一次「還在等」。變數而非常數:測試要把它調快。
|
||||
var stallNoticeEvery = 30 * time.Second
|
||||
|
||||
// stallStrikesBeforeSkip=同一個帳號連續幾發等到超時,就這一輪不再打它。
|
||||
//
|
||||
// 為什麼是 2 而不是 1:一次偶發的逾時不該讓整個帳號這一輪停手。
|
||||
// 為什麼不是更多:每多一次就是多等一個 Budget,而這正是本票要砍掉的成本。
|
||||
var stallStrikesBeforeSkip = 2
|
||||
|
||||
// StalledCall=這一輪「等太久」的一件事,寫進 status.json 讓畫面講得出
|
||||
// 「哪個帳號、哪件事、等了多久」。
|
||||
type StalledCall struct {
|
||||
Account string `json:"account"` // 知識庫網址的主機名(使用者在畫面上看得到的那個)
|
||||
Step string `json:"step"` // 白話的「哪件事」
|
||||
WaitedSec int `json:"waited_sec"` // 等了幾秒
|
||||
Skipped bool `json:"skipped"` // 這一輪之後不再打這個帳號
|
||||
Note string `json:"note,omitempty"` // 給使用者看的一句話
|
||||
}
|
||||
|
||||
// accountStall=某個帳號在這一輪的「等待帳」。
|
||||
type accountStall struct {
|
||||
strikes int
|
||||
skip string // 非空=這一輪不再打它,內容是給使用者看的理由
|
||||
}
|
||||
|
||||
// roundGuard 一輪一份,掛在 DirectConfig 上。
|
||||
// makeAccountSubConfig 的 `sub := *c` 會把這個**指標**一起帶過去
|
||||
// ⇒ 同一輪所有帳號、所有資料夾寫進同一份紀錄,斷路器才跨得了資料夾。
|
||||
type roundGuard struct {
|
||||
mu sync.Mutex
|
||||
accounts map[string]*accountStall
|
||||
stalls []StalledCall
|
||||
// announce=把「還在等」播出去。測試會換掉它(預設寫 stdout)。
|
||||
announce func(StalledCall)
|
||||
}
|
||||
|
||||
func newRoundGuard() *roundGuard {
|
||||
return &roundGuard{accounts: map[string]*accountStall{}, announce: announceStall}
|
||||
}
|
||||
|
||||
// stdoutMu 保護 stdout 上的「一個 JSON 值」不被另一條 goroutine 插進去切成兩半。
|
||||
//
|
||||
// 🔴 為什麼非有不可:播報是在**另一條 goroutine** 上跑的,而每輪結束那筆
|
||||
// `phase:"done"` 可能有幾十 KB(results 逐筆展開)——超過管線的原子寫入大小之後,
|
||||
// 兩邊就會交錯。而 supervisor 的 json.Decoder 一旦讀到壞掉的值就
|
||||
// `io.Copy(io.Discard)` 跳出迴圈(supervisor.go)⇒ **那個行程接下來所有的
|
||||
// start/done 都不會再被看到**,托盤從此停在錯的狀態。
|
||||
// 機率很小,代價是整條狀態線靜默死掉——這種比例的東西要用鎖解決,不是賭。
|
||||
var stdoutMu sync.Mutex
|
||||
|
||||
// printJSONLine 把一個值印成 stdout 上完整的一行(與播報共用同一把鎖)。
|
||||
func printJSONLine(v any) {
|
||||
line, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
stdoutMu.Lock()
|
||||
defer stdoutMu.Unlock()
|
||||
_, _ = os.Stdout.Write(append(line, '\n'))
|
||||
}
|
||||
|
||||
// announceStall 把一句「還在等」印成 stdout 上的一個 JSON 值。
|
||||
// 一次寫完整一個值:supervisor 那邊是 json.Decoder 逐值解,寫一半會炸掉整條線。
|
||||
func announceStall(s StalledCall) {
|
||||
line, err := json.Marshal(struct {
|
||||
At string `json:"at"`
|
||||
Phase string `json:"phase"`
|
||||
StalledCall
|
||||
}{time.Now().Format(time.RFC3339), "waiting", s})
|
||||
if err != nil {
|
||||
return // 播不出去也不能擋住本體
|
||||
}
|
||||
stdoutMu.Lock()
|
||||
defer stdoutMu.Unlock()
|
||||
_, _ = os.Stdout.Write(append(line, '\n'))
|
||||
}
|
||||
|
||||
// skipReason 回「這個帳號這一輪已經被判定沒有回應」的白話理由;空=照常打。
|
||||
func (g *roundGuard) skipReason(host string) string {
|
||||
if g == nil {
|
||||
return ""
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if a := g.accounts[host]; a != nil {
|
||||
return a.skip
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Stalls 回這一輪等太久的清單(順序=發生順序)。
|
||||
func (g *roundGuard) Stalls() []StalledCall {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return append([]StalledCall(nil), g.stalls...)
|
||||
}
|
||||
|
||||
// strike 記一發「等到超時」,必要時讓這個帳號這一輪停手。回傳給使用者看的那句話。
|
||||
func (g *roundGuard) strike(host string, step callStep, waited time.Duration) string {
|
||||
g.mu.Lock()
|
||||
a := g.accounts[host]
|
||||
if a == nil {
|
||||
a = &accountStall{}
|
||||
g.accounts[host] = a
|
||||
}
|
||||
a.strikes++
|
||||
tripped := a.strikes >= stallStrikesBeforeSkip && a.skip == ""
|
||||
if tripped {
|
||||
a.skip = fmt.Sprintf(
|
||||
"知識庫「%s」現在沒有回應(連續 %d 件事都等不到回覆),這一輪先跳過它;"+
|
||||
"其他資料夾照常同步,等它回來之後會自動恢復。",
|
||||
host, a.strikes)
|
||||
}
|
||||
note := fmt.Sprintf("「%s」等了 %d 秒,知識庫「%s」還是沒有回應;這一輪先跳過,"+
|
||||
"其他資料夾照常同步,等它回來之後會自動恢復。",
|
||||
step.Name, int(waited.Seconds()), host)
|
||||
g.stalls = append(g.stalls, StalledCall{
|
||||
Account: host, Step: step.Name, WaitedSec: int(waited.Seconds()),
|
||||
Skipped: tripped, Note: note,
|
||||
})
|
||||
announce, skip := g.announce, a.skip
|
||||
g.mu.Unlock()
|
||||
|
||||
if announce != nil {
|
||||
announce(StalledCall{Account: host, Step: step.Name,
|
||||
WaitedSec: int(waited.Seconds()), Skipped: tripped, Note: note})
|
||||
}
|
||||
// 🔴 把連線池裡那條可能已經死掉的連線丟掉。
|
||||
// 實撞的形狀(2026-08-28):行程活著、CPU 0%、`lsof` 一條 TCP 都沒有,
|
||||
// 而堆疊停在 HTTP/2 的 roundTrip——連線在作業系統那層已經沒了,
|
||||
// 連線池裡的殼卻還在,後面每一發都會被指派到同一個殼上、一發一發地等到超時。
|
||||
// 丟掉之後下一發會重新建立連線,而不是繼續排在一條死掉的連線後面。
|
||||
directHTTP.CloseIdleConnections()
|
||||
|
||||
if skip != "" {
|
||||
return skip
|
||||
}
|
||||
return note
|
||||
}
|
||||
|
||||
// unreachableNote 回「這個帳號這一輪已經被判定沒有回應」的白話理由;空=照常打。
|
||||
//
|
||||
// 呼叫端用它在**打之前**就掉頭,而不是打下去等到超時再說——那正是「一發卡住
|
||||
// 就整輪停擺」的成本來源:不掉頭的話,一個沒有回應的帳號會讓這一輪的每一件事
|
||||
// 各自再等一個 Budget。
|
||||
func (c *DirectConfig) unreachableNote() string {
|
||||
return c.guard.skipReason(instanceHostOf(c.CypherURL))
|
||||
}
|
||||
|
||||
// callGate=一發網路呼叫的閘。用法(三行,缺一不可):
|
||||
//
|
||||
// gate := cfg.openGate(stepXxx)
|
||||
// defer gate.release() // 停掉播報、放掉 context
|
||||
// if note := gate.blocked(); note != "" { … 這一輪不再打這個帳號 … }
|
||||
// … 真的去打 …
|
||||
// if err != nil { return gate.record(err) } // 逾時才會被記帳,其餘原樣回
|
||||
type callGate struct {
|
||||
g *roundGuard
|
||||
host string
|
||||
step callStep
|
||||
started time.Time
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
stop chan struct{}
|
||||
once sync.Once
|
||||
skip string
|
||||
// notice=多久播報一次。**在 openGate 就抄成自己的一份**,播報那條 goroutine
|
||||
// 不再去讀套件層的變數——那條 goroutine 的生命週期比呼叫端長一點點,
|
||||
// 讀共用變數就是一個真的資料競爭(-race 抓到的)。
|
||||
notice time.Duration
|
||||
}
|
||||
|
||||
// openGate 開一發呼叫的閘。guard 為 nil(測試直接呼叫低層函式)時仍回一個可用的
|
||||
// 閘:有 context deadline、不記帳、不播報——「沒裝 guard」不該變成「沒有上限」。
|
||||
func (c *DirectConfig) openGate(step callStep) *callGate {
|
||||
host := instanceHostOf(c.CypherURL)
|
||||
gate := &callGate{g: c.guard, host: host, step: step, started: time.Now(),
|
||||
stop: make(chan struct{}), notice: stallNoticeEvery}
|
||||
if c.guard != nil {
|
||||
gate.skip = c.guard.skipReason(host)
|
||||
}
|
||||
gate.ctx, gate.cancel = context.WithTimeout(context.Background(), step.Budget)
|
||||
if gate.skip != "" {
|
||||
return gate // 已經跳閘:不必播報,呼叫端會立刻回頭
|
||||
}
|
||||
go gate.keepTalking()
|
||||
return gate
|
||||
}
|
||||
|
||||
// keepTalking 在等待期間每隔 stallNoticeEvery 播一句「還在等」。
|
||||
// 這是本票驗收條件②:卡住的那一發要有話說,而不是靜默。
|
||||
func (gate *callGate) keepTalking() {
|
||||
if gate.g == nil {
|
||||
return
|
||||
}
|
||||
t := time.NewTicker(gate.notice)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-gate.stop:
|
||||
return
|
||||
case now := <-t.C:
|
||||
waited := int(now.Sub(gate.started).Seconds())
|
||||
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),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// blocked 回「這一輪已經不打這個帳號了」的理由;空=可以打。
|
||||
func (gate *callGate) blocked() string { return gate.skip }
|
||||
|
||||
// release 停掉播報並放掉 context。**一定要 defer**:context 活到呼叫端讀完回應
|
||||
// 之後才釋放,所以不能在讀 body 之前呼叫。
|
||||
func (gate *callGate) release() {
|
||||
gate.once.Do(func() { close(gate.stop) })
|
||||
gate.cancel()
|
||||
}
|
||||
|
||||
// record 記一發失敗。只有「等到超時」才進帳(連線被拒之類的錯是**很快**回來的,
|
||||
// 不是本票要修的病,記進去只會讓正常的斷網把帳號誤判成沒有回應)。
|
||||
// 回傳要交給呼叫端的錯誤:逾時換成白話,其餘原樣。
|
||||
//
|
||||
// 可以在 release 之後呼叫——它不碰 context。
|
||||
func (gate *callGate) record(err error) error {
|
||||
if err == nil || !isStallError(err) {
|
||||
return err
|
||||
}
|
||||
waited := time.Since(gate.started)
|
||||
if gate.g == nil {
|
||||
return fmt.Errorf("「%s」等了 %d 秒,知識庫「%s」沒有回應;稍後會自動再試。",
|
||||
gate.step.Name, int(waited.Seconds()), gate.host)
|
||||
}
|
||||
return errors.New(gate.g.strike(gate.host, gate.step, waited))
|
||||
}
|
||||
|
||||
// isStallError 回答「這個錯誤是**等到超時**嗎」。
|
||||
//
|
||||
// 三種都要認得:我們自己的 context deadline、client.Timeout 包出來的
|
||||
// `*url.Error{Timeout:true}`、以及被 `%w` 包過好幾層之後的同一個東西
|
||||
//(萃取那條路會加上「連不上你的知識庫:」再包一層)。
|
||||
func isStallError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
// stallguard_test.go — 「一發卡住就整輪停擺」這個形狀的網(`inkstone/arcrun-rag#153`)。
|
||||
//
|
||||
// 🔴 這裡測的**不是**「逾時設多久」。把 300 秒調小照樣過不了這幾條,因為病不在
|
||||
// 那個數字上:一個階段可以連續打二十發,每發都等到超時的話,上限設多小都會把
|
||||
// 一輪拖垮。這幾條驗的是**執行模型**:
|
||||
//
|
||||
// ① 一個帳號的端點不回應 ⇒ 另一個帳號、另一個資料夾照樣跑完一輪(驗收條件①)
|
||||
// ② 卡住的那一發要有話說:哪個帳號、哪件事、等了多久(驗收條件②)
|
||||
// ③ 一輪跑完 folder-trees.json 真的被重寫(驗收條件③)——這是使用者眼中
|
||||
// 「畫面永遠停在上一版」的那一格
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// stallTestTimings 把等待相關的旋鈕調成測試尺度,回傳復原函式。
|
||||
// 調的是「等多久算久」,不是「有沒有上限」——上限的存在本身才是被測的東西。
|
||||
func stallTestTimings(t *testing.T, budget, notice time.Duration) {
|
||||
t.Helper()
|
||||
oldPlain, oldLLM := stepIngestCard, stepIngestDoc
|
||||
oldRepair, oldTree, oldInv, oldCard := stepRepairOrigin, stepFolderTree, stepInventory, stepFolderCard
|
||||
oldExtract, oldTakedown, oldRetire := stepExtractDoc, stepTakedown, stepRetire
|
||||
oldProbe, oldAudit := stepProbeAI, stepCloudAudit
|
||||
oldNotice := stallNoticeEvery
|
||||
// 🔴 **每一個** step 都要調到——漏掉一個,那一個就是新的瓶頸。
|
||||
// 這不是測試的細節,是本票的形狀本身:2026-08-28 第一版就是這樣,斷路器
|
||||
// 明明跳了,一輪還是要 20 秒,因為漏掉了每輪第一發的探測。
|
||||
for _, s := range []*callStep{
|
||||
&stepIngestCard, &stepIngestDoc, &stepRepairOrigin, &stepFolderTree,
|
||||
&stepInventory, &stepFolderCard, &stepExtractDoc, &stepTakedown, &stepRetire,
|
||||
&stepProbeAI, &stepCloudAudit,
|
||||
} {
|
||||
s.Budget = budget
|
||||
}
|
||||
stallNoticeEvery = notice
|
||||
t.Cleanup(func() {
|
||||
stepIngestCard, stepIngestDoc = oldPlain, oldLLM
|
||||
stepRepairOrigin, stepFolderTree, stepInventory, stepFolderCard = oldRepair, oldTree, oldInv, oldCard
|
||||
stepExtractDoc, stepTakedown, stepRetire = oldExtract, oldTakedown, oldRetire
|
||||
stepProbeAI, stepCloudAudit = oldProbe, oldAudit
|
||||
stallNoticeEvery = oldNotice
|
||||
})
|
||||
}
|
||||
|
||||
// 一個永遠不回應的知識庫(把請求放進黑洞,直到測試結束)。
|
||||
func blackHoleServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
done := make(chan struct{})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-done:
|
||||
case <-r.Context().Done(): // 呼叫端自己放棄了=正是我們要的行為
|
||||
}
|
||||
}))
|
||||
t.Cleanup(func() { close(done); srv.Close() })
|
||||
return srv
|
||||
}
|
||||
|
||||
// 一個正常回應的知識庫,順便數它被打過幾次。
|
||||
func countingServer(t *testing.T, hits *int) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
*hits++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// ① 一個帳號的端點不回應,另一個帳號照樣跑完一輪;而且整輪的時間有上限。
|
||||
//
|
||||
// 沒有這道閘之前的行為:第一個帳號的第一發 POST 停在那裡不回來,
|
||||
// 第二個帳號一次都不會被碰到,folder-trees.json 也永遠不會被重寫。
|
||||
func TestOneDeadAccountDoesNotStopTheRound(t *testing.T) {
|
||||
stallTestTimings(t, 300*time.Millisecond, 50*time.Millisecond)
|
||||
|
||||
origFetch := fetchCloudVersion
|
||||
fetchCloudVersion = func(string) (string, bool) { return "", false }
|
||||
defer func() { fetchCloudVersion = origFetch }()
|
||||
|
||||
dead := blackHoleServer(t)
|
||||
liveHits := 0
|
||||
live := countingServer(t, &liveHits)
|
||||
|
||||
deadRoot, liveRoot := t.TempDir(), t.TempDir()
|
||||
for _, r := range []string{deadRoot, liveRoot} {
|
||||
if err := os.WriteFile(filepath.Join(r, "note.md"), []byte("# 一份筆記\n內容"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
manifestPath := filepath.Join(t.TempDir(), "m.json")
|
||||
cfg := &DirectConfig{
|
||||
Manifest: manifestPath,
|
||||
Accounts: []AccountConfig{
|
||||
{CypherURL: dead.URL, Namespace: "dead", APIKey: "k", WatchFolders: []string{deadRoot}},
|
||||
{CypherURL: live.URL, Namespace: "live", APIKey: "k", WatchFolders: []string{liveRoot}},
|
||||
},
|
||||
Library: "kb", MaxRemoved: DefaultMaxRemovedRatio,
|
||||
// 萃取那條路不是這條測試的題目:這裡驗的是「不回應的端點會不會把整輪鎖住」。
|
||||
// 空 Extractor=走原文直送(rag_ingest_direct),一樣是打同一批端點。
|
||||
CardIngestWF: "rag_ingest_card", IngestWF: "rag_ingest_direct", RemovedWF: "rag_takedown_direct",
|
||||
ForceSync: true,
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
results, _, _ := RunDirectOnce(cfg, false)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// 🔴 核心:一輪要跑得完,而且時間跟「壞掉的那個帳號有幾件事要做」無關。
|
||||
// 上限抓得寬(真實情境下每發 300ms × 斷路器 2 次 + 活著那邊的工作),
|
||||
// 但遠小於「每一發都各等一次」的量級——沒有斷路器時這裡會是好幾秒起跳。
|
||||
if elapsed > 5*time.Second {
|
||||
t.Fatalf("🔴 一輪花了 %v——一發等不到回覆就把整輪拖住了,正是本票要修的形狀", elapsed)
|
||||
}
|
||||
|
||||
// 活著的那個帳號真的被服務到了(不是「兩個都被跳過所以很快」)。
|
||||
if liveHits == 0 {
|
||||
t.Fatalf("🔴 活著的帳號一次都沒被打到——壞掉的那個把整輪吃光了\nresults=%+v", results)
|
||||
}
|
||||
|
||||
// 斷路器要真的跳過:同一個帳號不該被逐檔、逐階段各等一次。
|
||||
stalls := cfg.guard.Stalls()
|
||||
if len(stalls) == 0 {
|
||||
t.Fatalf("🔴 等了那麼久卻一句話都沒說——使用者會看到「開著、沒錯誤、不動」")
|
||||
}
|
||||
tripped := false
|
||||
for _, s := range stalls {
|
||||
if s.Skipped {
|
||||
tripped = true
|
||||
}
|
||||
}
|
||||
if !tripped {
|
||||
t.Fatalf("🔴 沒有任何一筆標成「這一輪跳過這個帳號」,斷路器沒生效:%+v", stalls)
|
||||
}
|
||||
if len(stalls) > 4 {
|
||||
t.Fatalf("🔴 等了 %d 次才停手——斷路器該在 %d 次就跳,不然一輪還是會被拖垮:%+v",
|
||||
len(stalls), stallStrikesBeforeSkip, stalls)
|
||||
}
|
||||
}
|
||||
|
||||
// ② 卡住的那一發要講得出「哪個帳號、哪件事、等了多久」。
|
||||
func TestStallSaysWhichAccountWhichStepHowLong(t *testing.T) {
|
||||
stallTestTimings(t, 300*time.Millisecond, 50*time.Millisecond)
|
||||
|
||||
dead := blackHoleServer(t)
|
||||
// 🔴 播報會從**兩條** goroutine 進來(等待期間那條、以及記帳時呼叫端那條),
|
||||
// 所以收集它的地方自己要上鎖——這不是測試的潔癖,是 -race 抓出來的真實併發。
|
||||
var mu sync.Mutex
|
||||
var said []StalledCall
|
||||
g := newRoundGuard()
|
||||
g.announce = func(s StalledCall) { mu.Lock(); said = append(said, s); mu.Unlock() }
|
||||
|
||||
cfg := &DirectConfig{CypherURL: dead.URL, Namespace: "n", APIKey: "k", guard: g}
|
||||
_, _, err := cfg.postJSON(stepRepairOrigin, dead.URL+"/x", map[string]any{"a": 1})
|
||||
if err == nil {
|
||||
t.Fatal("端點不回應卻回了 nil error")
|
||||
}
|
||||
|
||||
// 播報:等待期間就要開口,不是等到最後才說。
|
||||
mu.Lock()
|
||||
saidCount := len(said)
|
||||
mu.Unlock()
|
||||
if saidCount == 0 {
|
||||
t.Fatal("🔴 等待期間一句話都沒播——靜默的等待跟當掉對使用者是同一件事")
|
||||
}
|
||||
host := instanceHostOf(dead.URL)
|
||||
mu.Lock()
|
||||
snapshot := append([]StalledCall(nil), said...)
|
||||
mu.Unlock()
|
||||
for _, s := range snapshot {
|
||||
if s.Account != host {
|
||||
t.Fatalf("沒講是哪個帳號:%+v(want %s)", s, host)
|
||||
}
|
||||
if s.Step != stepRepairOrigin.Name {
|
||||
t.Fatalf("沒講是哪件事:%+v(want %s)", s, stepRepairOrigin.Name)
|
||||
}
|
||||
}
|
||||
// 交給呼叫端的錯誤是產品文案:講哪件事、等多久、他會怎樣,不出現狀態碼/內部名詞。
|
||||
msg := err.Error()
|
||||
for _, want := range []string{stepRepairOrigin.Name, "沒有回應", "會自動恢復"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("錯誤訊息少了「%s」:%s", want, msg)
|
||||
}
|
||||
}
|
||||
for _, banned := range []string{"context deadline", "HTTP", "timeout", "Client.Timeout"} {
|
||||
if strings.Contains(msg, banned) {
|
||||
t.Fatalf("🔴 錯誤訊息漏出內部語彙「%s」:%s", banned, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ②之二:等太久的事要寫進 status.json,畫面才有東西可以講。
|
||||
func TestStallsLandInStatusJSON(t *testing.T) {
|
||||
stallTestTimings(t, 200*time.Millisecond, 50*time.Millisecond)
|
||||
|
||||
origFetch := fetchCloudVersion
|
||||
fetchCloudVersion = func(string) (string, bool) { return "", false }
|
||||
defer func() { fetchCloudVersion = origFetch }()
|
||||
|
||||
dead := blackHoleServer(t)
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "note.md"), []byte("# 一份筆記\n內容"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifestPath := filepath.Join(t.TempDir(), "m.json")
|
||||
cfg := &DirectConfig{
|
||||
Manifest: manifestPath,
|
||||
Accounts: []AccountConfig{{CypherURL: dead.URL, Namespace: "n", APIKey: "k",
|
||||
WatchFolders: []string{root}}},
|
||||
Library: "kb", MaxRemoved: DefaultMaxRemovedRatio,
|
||||
CardIngestWF: "rag_ingest_card", IngestWF: "rag_ingest_direct", RemovedWF: "rag_takedown_direct",
|
||||
ForceSync: true,
|
||||
}
|
||||
RunDirectOnce(cfg, false)
|
||||
|
||||
st, err := LoadSyncStatus(StatusFilePath(manifestPath))
|
||||
if err != nil {
|
||||
t.Fatalf("讀 status.json 失敗:%v", err)
|
||||
}
|
||||
if len(st.Stalls) == 0 {
|
||||
t.Fatal("🔴 status.json 沒有 stalls——畫面上就只剩「開著、沒錯誤、什麼都不動」")
|
||||
}
|
||||
s := st.Stalls[0]
|
||||
if s.Account == "" || s.Step == "" || s.Note == "" {
|
||||
t.Fatalf("三件事要講齊(哪個帳號/哪件事/人話):%+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 一輪跑完,folder-trees.json 真的被重寫——即使雲端那邊完全不回應。
|
||||
//
|
||||
// 這一格就是使用者實際撞到的症狀:畫面上的資料夾結構永遠停在上一版。
|
||||
// 樹是**本機算出來的**,不該因為送不上雲端就連本機那份都不寫。
|
||||
func TestFolderTreeStoreRewrittenEvenWhenCloudNeverAnswers(t *testing.T) {
|
||||
stallTestTimings(t, 200*time.Millisecond, 50*time.Millisecond)
|
||||
|
||||
origFetch := fetchCloudVersion
|
||||
fetchCloudVersion = func(string) (string, bool) { return "", false }
|
||||
defer func() { fetchCloudVersion = origFetch }()
|
||||
|
||||
dead := blackHoleServer(t)
|
||||
root := t.TempDir()
|
||||
for _, d := range []string{"a", "b", "c"} {
|
||||
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, d, "n.md"), []byte("# n\n內容"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
manifestPath := filepath.Join(t.TempDir(), "m.json")
|
||||
cfg := &DirectConfig{
|
||||
Manifest: manifestPath,
|
||||
Accounts: []AccountConfig{{CypherURL: dead.URL, Namespace: "n", APIKey: "k",
|
||||
WatchFolders: []string{root}}},
|
||||
Library: "kb", MaxRemoved: DefaultMaxRemovedRatio,
|
||||
CardIngestWF: "rag_ingest_card", IngestWF: "rag_ingest_direct", RemovedWF: "rag_takedown_direct",
|
||||
ForceSync: true,
|
||||
}
|
||||
RunDirectOnce(cfg, false)
|
||||
|
||||
treePath := FolderTreeStorePath(manifestPath)
|
||||
store, err := LoadFolderTreeStore(treePath)
|
||||
if err != nil {
|
||||
t.Fatalf("🔴 一輪跑完卻沒有 folder-trees.json:%v", err)
|
||||
}
|
||||
tree, ok := store.Trees[root]
|
||||
if !ok {
|
||||
t.Fatalf("快照裡沒有這個根:%+v", store.Trees)
|
||||
}
|
||||
if len(tree.Nodes) < 4 { // 根 + a/b/c
|
||||
t.Fatalf("🔴 樹只有 %d 個節點(該有根+三個子資料夾):%+v", len(tree.Nodes), tree.Nodes)
|
||||
}
|
||||
|
||||
// 再跑一輪,mtime 要往前走——「畫面永遠停在上一版」的反面就是這一格。
|
||||
before, err := os.Stat(treePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
RunDirectOnce(cfg, false)
|
||||
after, err := os.Stat(treePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !after.ModTime().After(before.ModTime()) {
|
||||
t.Fatalf("🔴 第二輪沒有重寫 folder-trees.json(%v → %v)", before.ModTime(), after.ModTime())
|
||||
}
|
||||
}
|
||||
|
||||
// 連線被拒(很快就回來的錯)不該被當成「等太久」——
|
||||
// 把它記進斷路器的話,正常的斷網會讓帳號被誤判成沒有回應。
|
||||
func TestFastFailureIsNotCountedAsStall(t *testing.T) {
|
||||
stallTestTimings(t, 2*time.Second, 50*time.Millisecond)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
url := srv.URL
|
||||
srv.Close() // 關掉 ⇒ 連線立刻被拒
|
||||
|
||||
g := newRoundGuard()
|
||||
g.announce = func(StalledCall) {}
|
||||
cfg := &DirectConfig{CypherURL: url, Namespace: "n", APIKey: "k", guard: g}
|
||||
if _, _, err := cfg.postJSON(stepIngestCard, url+"/x", map[string]any{}); err == nil {
|
||||
t.Fatal("連不上卻回 nil error")
|
||||
}
|
||||
if len(g.Stalls()) != 0 {
|
||||
t.Fatalf("🔴 立刻失敗被記成「等太久」:%+v", g.Stalls())
|
||||
}
|
||||
if g.skipReason(instanceHostOf(url)) != "" {
|
||||
t.Fatal("🔴 一次連線被拒就把整個帳號停掉了——那是把斷網懲罰成停機")
|
||||
}
|
||||
}
|
||||
|
||||
// 沒裝 guard(低層函式被單獨呼叫)時仍然要有上限——
|
||||
// 「沒裝閘」不可以等於「沒有上限」,那是這條線最初出事的樣子。
|
||||
func TestGateAlwaysHasADeadlineEvenWithoutGuard(t *testing.T) {
|
||||
stallTestTimings(t, 200*time.Millisecond, 50*time.Millisecond)
|
||||
|
||||
dead := blackHoleServer(t)
|
||||
cfg := &DirectConfig{CypherURL: dead.URL, Namespace: "n", APIKey: "k"} // guard == nil
|
||||
start := time.Now()
|
||||
_, _, err := cfg.postJSON(stepIngestCard, dead.URL+"/x", map[string]any{})
|
||||
if err == nil {
|
||||
t.Fatal("端點不回應卻回了 nil error")
|
||||
}
|
||||
if el := time.Since(start); el > 2*time.Second {
|
||||
t.Fatalf("🔴 沒有 guard 就沒有上限了(等了 %v)", el)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -46,12 +47,23 @@ type Status struct {
|
||||
Rounds int // 累計掃描輪數
|
||||
Restarts int // 累計重起次數
|
||||
LastError string // 最近一次錯誤(stderr 末行 / 退出原因)
|
||||
// Waiting=正在等某一發雲端回覆的白話說明(`inkstone/arcrun-rag#153`)。
|
||||
// 空=沒有人在等。它不是錯誤——所以不寫進 LastError(那一格會被診斷檔
|
||||
// 當成「出事了」帶出去,見 diagnostics_export.go 的 engineLastErrorFor)。
|
||||
Waiting string
|
||||
}
|
||||
|
||||
// round 對應 collector direct 每輪印到 stdout 的 JSON(見 direct.go runOne)。
|
||||
type round struct {
|
||||
At string `json:"at"`
|
||||
Phase string `json:"phase"` // t191:"start"(開工)/"done"(跑完);舊版沒有此欄=空
|
||||
// `inkstone/arcrun-rag#153`:"waiting"=這一發等太久了,還沒跑完。
|
||||
// 🔴 它**不是**一輪的結束:下面的 decode 迴圈把任何非 start 的值都當成
|
||||
// 「跑完了」,不特別認得它的話,托盤會在同步途中跳回「看守中」——
|
||||
// 正是 t191 修掉的那個病(「看起來好像就做完了」)。
|
||||
Account string `json:"account,omitempty"` // 哪個知識庫帳號
|
||||
Step string `json:"step,omitempty"` // 哪件事(白話)
|
||||
WaitedSec int `json:"waited_sec,omitempty"` // 等了幾秒
|
||||
|
||||
Folder string `json:"folder"`
|
||||
Results []json.RawMessage `json:"results"`
|
||||
@@ -327,11 +339,21 @@ func (s *Supervisor) runOnce(ctx context.Context) error {
|
||||
at := parseAt(r.At)
|
||||
if r.Phase == "start" {
|
||||
// t191:開工 → 顯示「同步中…」。不累加 Rounds(那是「完成幾輪」)。
|
||||
s.setState(func(st *Status) { st.State = StateSyncing })
|
||||
s.setState(func(st *Status) { st.State = StateSyncing; st.Waiting = "" })
|
||||
continue
|
||||
}
|
||||
if r.Phase == "waiting" {
|
||||
// #153:還在等某一發回覆 ⇒ 仍在同步中,**不算跑完一輪**。
|
||||
// 把「哪個帳號、哪件事、等了多久」留在狀態上,托盤才講得出來
|
||||
// ——不然使用者看到的就是「開著、沒有錯誤、什麼都不動」。
|
||||
note := fmt.Sprintf("正在等「%s」回覆「%s」,已經等了 %d 秒",
|
||||
r.Account, r.Step, r.WaitedSec)
|
||||
s.setState(func(st *Status) { st.State = StateSyncing; st.Waiting = note })
|
||||
continue
|
||||
}
|
||||
s.setState(func(st *Status) {
|
||||
st.State = StateWatching
|
||||
st.Waiting = "" // 跑完了=沒有人在等,別讓上一輪的等待訊息留在畫面上
|
||||
st.Rounds++
|
||||
if !at.IsZero() {
|
||||
st.LastRoundAt = at
|
||||
|
||||
@@ -140,6 +140,17 @@ type SyncStatus struct {
|
||||
// 不會被清成 0(現況快照,不是本輪計數)。
|
||||
Progress SyncProgress `json:"progress"`
|
||||
FailureBreakdown FailureBreakdown `json:"failure_breakdown"`
|
||||
|
||||
// Stalls=這一輪「等太久」的事(`inkstone/arcrun-rag#153`)。
|
||||
//
|
||||
// 為什麼要有這一格:2026-08-28 實撞的畫面是**小幫手開著、沒有錯誤訊息、
|
||||
// 什麼都不動**——同步停在一發等不到回覆的請求上,而使用者看得到的每一個
|
||||
// 數字都還是上一輪的。**靜默的等待跟當掉對使用者是同一件事**,
|
||||
// 所以「哪個帳號、哪件事、等了多久」要有地方講。
|
||||
//
|
||||
// 與 SkippedDocs 同族:每輪重算的現況快照,不進 CarryForwardActivity
|
||||
//(上一輪等太久不代表這一輪也在等,帶下來就會變成一個永遠擦不掉的警告)。
|
||||
Stalls []StalledCall `json:"stalls,omitempty"`
|
||||
}
|
||||
|
||||
// FolderPlanStatus=某個看守資料夾這一輪用了什麼收檔策略、據此少收了什麼
|
||||
|
||||
Reference in New Issue
Block a user