sync: collector/ 同步自 inkstone/arcrun-rag@256a68b(桌面小幫手 0.18.55)
This commit is contained in:
@@ -47,6 +47,20 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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.18.53(2026-09-14)
|
||||||
|
|
||||||
- 🔴 **資料夾上的數字會跟著真的送上雲端的份數走**:以前小幫手在「同步中」的時候,展開資料夾看到的每一層都是「0 / 總數」,要等整輪(檔案多、知識庫回得慢時可能好幾個小時)全部跑完才會變——即使雲端早就收到好幾份、AI 已經查得到。現在每送上一份,數字就跟著加一,展開著的資料夾幾秒內就會更新。
|
- 🔴 **資料夾上的數字會跟著真的送上雲端的份數走**:以前小幫手在「同步中」的時候,展開資料夾看到的每一層都是「0 / 總數」,要等整輪(檔案多、知識庫回得慢時可能好幾個小時)全部跑完才會變——即使雲端早就收到好幾份、AI 已經查得到。現在每送上一份,數字就跟著加一,展開著的資料夾幾秒內就會更新。
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"_algo": 4,
|
"_algo": 4,
|
||||||
"version": "0.18.53",
|
"version": "0.18.55",
|
||||||
"fingerprint": "32f62120aa472b65",
|
"fingerprint": "287d990df2baa78c",
|
||||||
"files": {
|
"files": {
|
||||||
".gitignore": "c7a98c44ecc882a174720e43dabd4c04d7cf2503586426dcccaa213d5a27df38",
|
".gitignore": "c7a98c44ecc882a174720e43dabd4c04d7cf2503586426dcccaa213d5a27df38",
|
||||||
"CHANGELOG.md": "8032ea736ef5672f1f7971edad3ee3a707fe31fe0c5d0d05d34a35302e2a7853",
|
"CHANGELOG.md": "20a49e8ef559b89087d1ecd37ffcfe01627ca41480091a7ba8ffbd2343933293",
|
||||||
"DAEMON_LINE": "d5019abbdc8a5f2919e9e3510391891cd7fbdf0765bf16ec83caa779f370116d",
|
"DAEMON_LINE": "d5019abbdc8a5f2919e9e3510391891cd7fbdf0765bf16ec83caa779f370116d",
|
||||||
"README.md": "9d92cac236b20a0b183eea3e7f5e39ad492f05192c4ea602eb11c3d09967327f",
|
"README.md": "9d92cac236b20a0b183eea3e7f5e39ad492f05192c4ea602eb11c3d09967327f",
|
||||||
"cardloop_test.go": "fdf09b62650c328f5d8075d964a0ca2dada5f8d9a271202768472998b45e31a8",
|
"cardloop_test.go": "fdf09b62650c328f5d8075d964a0ca2dada5f8d9a271202768472998b45e31a8",
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
"convert_table_test.go": "d0371b7566ef3152f9dd42f9f990e0dffa1c50a0c8e874a28415fa2e4c188394",
|
"convert_table_test.go": "d0371b7566ef3152f9dd42f9f990e0dffa1c50a0c8e874a28415fa2e4c188394",
|
||||||
"convert_test.go": "04f3fa30d1be5f910c0e0be3308ced2963191ab030a9eaccd986ef581fcd4e18",
|
"convert_test.go": "04f3fa30d1be5f910c0e0be3308ced2963191ab030a9eaccd986ef581fcd4e18",
|
||||||
"convert_wiring_test.go": "3389817e596341369a3fc102846bf5b081cee668e86ebf4830eb633978de652b",
|
"convert_wiring_test.go": "3389817e596341369a3fc102846bf5b081cee668e86ebf4830eb633978de652b",
|
||||||
"direct.go": "d7122f5efe522fd6be285402ee20c63af4b5545cf48591089ed9c5ee2d34a852",
|
"direct.go": "7c2354adea90717611cad3b5e73f6165c74182a6c995f2087e77d9c5d0c10068",
|
||||||
"direct_extract_test.go": "317d0057d2d368fabc406351a7477f72e074d2b8c8459b1dc021febdf16902f4",
|
"direct_extract_test.go": "317d0057d2d368fabc406351a7477f72e074d2b8c8459b1dc021febdf16902f4",
|
||||||
"direct_multi_test.go": "a76593fef26231e18b7402f6f7376624d2471f8a719b3ce08fc4a0f5a3d3cf1e",
|
"direct_multi_test.go": "a76593fef26231e18b7402f6f7376624d2471f8a719b3ce08fc4a0f5a3d3cf1e",
|
||||||
"direct_pacing.go": "b54838ce85f72ecd2d3388fa7cf89050c87fb10bd67dca07cd18d49568a8837c",
|
"direct_pacing.go": "b54838ce85f72ecd2d3388fa7cf89050c87fb10bd67dca07cd18d49568a8837c",
|
||||||
@@ -177,21 +177,22 @@
|
|||||||
"machineid_test.go": "5b54ac62ee1fb28b8f0a873888aeddb844af0aa74472bfb849573cfc6691a290",
|
"machineid_test.go": "5b54ac62ee1fb28b8f0a873888aeddb844af0aa74472bfb849573cfc6691a290",
|
||||||
"machinemark.go": "592a39597fd011e8b1603318eaedf452aa58fca0639244a80218aa9a0ac6dbf5",
|
"machinemark.go": "592a39597fd011e8b1603318eaedf452aa58fca0639244a80218aa9a0ac6dbf5",
|
||||||
"main.go": "fe2f69739f3ee92713f8aaca925068ecf97b33d476eee826fc1e0d5def8f0d10",
|
"main.go": "fe2f69739f3ee92713f8aaca925068ecf97b33d476eee826fc1e0d5def8f0d10",
|
||||||
"manifest.go": "c6b6fbdd5cc6963345048aa1b0a32f61b7b0b5b84616a15827aea290bd3758e1",
|
"manifest.go": "9b63f8fbd0a1ccf5b3cf2f0945f4fdcdb8726a8684c74f1a37947fc7f36420ac",
|
||||||
"manifest_extractedby_test.go": "8019fe35675a1e801b7bda829ee1d647de32db03c029b1c651f71573e644c69e",
|
"manifest_extractedby_test.go": "8019fe35675a1e801b7bda829ee1d647de32db03c029b1c651f71573e644c69e",
|
||||||
"manifest_retry_test.go": "e6b4a321ccddfeed8cdbfabe6d031d5a9c59a33b47baf0b6d21150fa8406748a",
|
"manifest_retry_test.go": "e6b4a321ccddfeed8cdbfabe6d031d5a9c59a33b47baf0b6d21150fa8406748a",
|
||||||
"multiaccount_connected_test.go": "ba1817b73a5dd0dd5d300750e54d5c53bd82a75ef6e43caeddfcb2303d64ff66",
|
"multiaccount_connected_test.go": "ba1817b73a5dd0dd5d300750e54d5c53bd82a75ef6e43caeddfcb2303d64ff66",
|
||||||
"probe_real_manual_test.go": "6170cd7d41b6e099ad93e229f55e5f9d64d509412969f9bf162788ae8e787aef",
|
"probe_real_manual_test.go": "6170cd7d41b6e099ad93e229f55e5f9d64d509412969f9bf162788ae8e787aef",
|
||||||
"probe_workersai.go": "ce0ceb63377e4b0181bf6acb1c14c5cc0c75173360e0e27392cb9e5b18e2cffc",
|
"probe_workersai.go": "ce0ceb63377e4b0181bf6acb1c14c5cc0c75173360e0e27392cb9e5b18e2cffc",
|
||||||
"progress.go": "98380d3c374570f3182200484b9b2b6a093d41531433b80eb78a92cf94dec69a",
|
"progress.go": "0fc621f14d38aaa021e6df486eaf64234ade2591d250ab0c79d9fbc3a6c94590",
|
||||||
"progress_test.go": "21258889ad7680da2d6cf8812accda7e6a1aca6c6fd604394ec4363c2ed902bb",
|
"progress_test.go": "21258889ad7680da2d6cf8812accda7e6a1aca6c6fd604394ec4363c2ed902bb",
|
||||||
"progress_wiring_test.go": "d1d144269539825ef4a4d9ffca5c49b021cb27cecea7d280309f3f935460b3cd",
|
"progress_wiring_test.go": "d1d144269539825ef4a4d9ffca5c49b021cb27cecea7d280309f3f935460b3cd",
|
||||||
"quota.go": "c6596993a0103b8a22c7148d6f9adeec0da09dceaaba040b201476b920027fa4",
|
"quota.go": "c6596993a0103b8a22c7148d6f9adeec0da09dceaaba040b201476b920027fa4",
|
||||||
"quota_test.go": "a91edeb71bf5f3aaa9bd68ad5ec0c87d1e620acc8072e3114098db4d8fc1a896",
|
"quota_test.go": "a91edeb71bf5f3aaa9bd68ad5ec0c87d1e620acc8072e3114098db4d8fc1a896",
|
||||||
"repoguard.go": "bcbfab2b76b8fb6afa67aa30a030931a127bae4d9d4a363c40522457ab192c1b",
|
"repoguard.go": "bcbfab2b76b8fb6afa67aa30a030931a127bae4d9d4a363c40522457ab192c1b",
|
||||||
"repoguard_test.go": "ce0316a59af944c3cf391961653fe82294fd45e728d35b4b7fcd99b2b20e7b5c",
|
"repoguard_test.go": "ce0316a59af944c3cf391961653fe82294fd45e728d35b4b7fcd99b2b20e7b5c",
|
||||||
"route_backoff_measure_test.go": "d09463f309ec0c7e9c67523717a1962908a38b9d5338edd72e49e151cfd264eb",
|
"route_backoff_measure_test.go": "5eba1d1453df3ad12ce840eeb130567f66da4d4cd18485f930208ab6068812ae",
|
||||||
"routebackoff.go": "09a23fef781c174fe3d257e42da58540bd6971aff7151ffa6fb5188de9188e6f",
|
"routebackoff.go": "e1ffffad09172d04583ea4fc4dd2b9a67e7f542028a21b0c005cbcabbac11c50",
|
||||||
|
"routebackoff_201_test.go": "9099a5e84bf124f30e1b64a9887196406ea2ea66f12d0310c7e0cdc10da354d4",
|
||||||
"safewrite.go": "d5dd09ffe894fb9fb94656a4bb50d246bd8c65ab21a7ad81463f43dfdacc8a57",
|
"safewrite.go": "d5dd09ffe894fb9fb94656a4bb50d246bd8c65ab21a7ad81463f43dfdacc8a57",
|
||||||
"safewrite_test.go": "2980f0f86f78fb265403e31d4cba6da43f4dbc16e7c4711cfccba23175578358",
|
"safewrite_test.go": "2980f0f86f78fb265403e31d4cba6da43f4dbc16e7c4711cfccba23175578358",
|
||||||
"scan.go": "a91ca567cff71864cb5a0a2af234a478df7118c47bfa24727f1b37c3f0466540",
|
"scan.go": "a91ca567cff71864cb5a0a2af234a478df7118c47bfa24727f1b37c3f0466540",
|
||||||
@@ -207,7 +208,7 @@
|
|||||||
"stallguard_test.go": "678b82a8448d1394a0a85d4af1f133c980f5ff0dcb54953b0fcf1671d186147f",
|
"stallguard_test.go": "678b82a8448d1394a0a85d4af1f133c980f5ff0dcb54953b0fcf1671d186147f",
|
||||||
"supervisor/hidewindow_other.go": "291b4ecb0783ff3e813c68b5464161d9af2f84d31c773c7f1f710f218bb5d610",
|
"supervisor/hidewindow_other.go": "291b4ecb0783ff3e813c68b5464161d9af2f84d31c773c7f1f710f218bb5d610",
|
||||||
"supervisor/hidewindow_windows.go": "29796dbe90ecb35e6f1451dacf956d42928c6a393271befc5c7f6863d5315fb3",
|
"supervisor/hidewindow_windows.go": "29796dbe90ecb35e6f1451dacf956d42928c6a393271befc5c7f6863d5315fb3",
|
||||||
"supervisor/supervisor.go": "f79b32e92e67058a056dc749a6182573eefcbbc9bc7b2cd5a997f4e8066a004e",
|
"supervisor/supervisor.go": "aec9357579bc0832e5179918912e86dbe74fc86f49904e881a6be807f59bc0ae",
|
||||||
"supervisor/supervisor_test.go": "f5582d54c55065fbc834fe96754df9c8cd6efe9f88f30208bbce84ddea08fea7",
|
"supervisor/supervisor_test.go": "f5582d54c55065fbc834fe96754df9c8cd6efe9f88f30208bbce84ddea08fea7",
|
||||||
"sync_status.go": "353256d46d5cc9521c7798637dbaa06d2d0deabf163605409ca94e59fa2e2098",
|
"sync_status.go": "353256d46d5cc9521c7798637dbaa06d2d0deabf163605409ca94e59fa2e2098",
|
||||||
"sync_status_test.go": "73bbc83c9e8fc18575895a7b7b5feab7a76d41f05a74d74bbbdbf98b045ccfa2",
|
"sync_status_test.go": "73bbc83c9e8fc18575895a7b7b5feab7a76d41f05a74d74bbbdbf98b045ccfa2",
|
||||||
|
|||||||
@@ -23,5 +23,7 @@
|
|||||||
"0.18.50": "b30716c3ccec9e65",
|
"0.18.50": "b30716c3ccec9e65",
|
||||||
"0.18.51": "3cfcc815cf185a68",
|
"0.18.51": "3cfcc815cf185a68",
|
||||||
"0.18.52": "5ab62f966033ea4b",
|
"0.18.52": "5ab62f966033ea4b",
|
||||||
"0.18.53": "32f62120aa472b65"
|
"0.18.53": "32f62120aa472b65",
|
||||||
|
"0.18.54": "44cf7dbf8246968a",
|
||||||
|
"0.18.55": "287d990df2baa78c"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -601,6 +601,37 @@ type DirectResult struct {
|
|||||||
Status string `json:"status"` // ingested | removed | planned | failed | skipped
|
Status string `json:"status"` // ingested | removed | planned | failed | skipped
|
||||||
HTTPStatus int `json:"http_status,omitempty"`
|
HTTPStatus int `json:"http_status,omitempty"`
|
||||||
Error string `json:"error,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)。
|
// makeAccountSubConfig 從帳號設定建出單帳號用的 DirectConfig,繼承機器層級欄位(t104)。
|
||||||
@@ -887,6 +918,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
|||||||
r[i].Root = root
|
r[i].Root = root
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
stampResults(r)
|
||||||
for i := range r {
|
for i := range r {
|
||||||
r[i].Account = accHost // t104: 標明所屬帳號
|
r[i].Account = accHost // t104: 標明所屬帳號
|
||||||
if cfg.Extractor != "" && countsAsDocument(r[i]) {
|
if cfg.Extractor != "" && countsAsDocument(r[i]) {
|
||||||
@@ -935,6 +967,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
|||||||
for _, root := range accCfg.RetiringRoots() {
|
for _, root := range accCfg.RetiringRoots() {
|
||||||
cfg.guard.enterFolder(accHost, root) // #200
|
cfg.guard.enterFolder(accHost, root) // #200
|
||||||
r, e, remaining, done := retireRootOnce(accCfg, root, dryRun)
|
r, e, remaining, done := retireRootOnce(accCfg, root, dryRun)
|
||||||
|
stampResults(r)
|
||||||
for i := range r {
|
for i := range r {
|
||||||
r[i].Root = root
|
r[i].Root = root
|
||||||
r[i].Account = accHost
|
r[i].Account = accHost
|
||||||
@@ -1396,6 +1429,15 @@ func retrySkipReason(m *Manifest, path string, now int64) string {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return "暫時跳過"
|
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 {
|
if e.FailCount >= MaxFailBeforeSkip {
|
||||||
msg := fmt.Sprintf("連續失敗 %d 次,已暫停自動重試(改檔或按「立刻同步」會再試)", e.FailCount)
|
msg := fmt.Sprintf("連續失敗 %d 次,已暫停自動重試(改檔或按「立刻同步」會再試)", e.FailCount)
|
||||||
if e.LastError != "" {
|
if e.LastError != "" {
|
||||||
@@ -1774,7 +1816,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
|||||||
//(頁名可能改變=要新頁名的卡)。新路徑這邊的冪等由 kbdb 端承擔(同頁名覆蓋語意);
|
//(頁名可能改變=要新頁名的卡)。新路徑這邊的冪等由 kbdb 端承擔(同頁名覆蓋語意);
|
||||||
// 舊路徑那邊不會自動消失——上面已經把 ev.OldPath 排進 m.PendingTakedowns,
|
// 舊路徑那邊不會自動消失——上面已經把 ev.OldPath 排進 m.PendingTakedowns,
|
||||||
// 這裡送完新卡之後、本函式結尾會補打下架(InkStoneCo#44 ⑩)。
|
// 這裡送完新卡之後、本函式結尾會補打下架(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:帳號還在額度冷卻中 → 這輪連試都不試。
|
// 2026-08-07 pacing task 2:帳號還在額度冷卻中 → 這輪連試都不試。
|
||||||
// 這不是這個檔的問題(不記 FailCount/退避——那是「這個檔」的病歷,
|
// 這不是這個檔的問題(不記 FailCount/退避——那是「這個檔」的病歷,
|
||||||
// 額度用完是「整個帳號」的狀態,混在一起會讓退避階梯失真)。
|
// 額度用完是「整個帳號」的狀態,混在一起會讓退避階梯失真)。
|
||||||
@@ -1822,6 +1864,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 止血點:這個檔剛失敗過且還在退避窗口內 → 這輪跳過。
|
// 🔴 t195 止血點:這個檔剛失敗過且還在退避窗口內 → 這輪跳過。
|
||||||
// 沒有這道閘時的實測災情:`小果被AFTEE詐貸.pdf` 因雲端 401 失敗,
|
// 沒有這道閘時的實測災情:`小果被AFTEE詐貸.pdf` 因雲端 401 失敗,
|
||||||
// 每輪重掃又被當成新檔 ⇒ **1387 輪、跨 11 小時**,且它排在佇列前面,
|
// 每輪重掃又被當成新檔 ⇒ **1387 輪、跨 11 小時**,且它排在佇列前面,
|
||||||
@@ -1832,6 +1895,9 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
|||||||
if !m.ShouldRetry(ev.Path, now, cfg.ForceSync) {
|
if !m.ShouldRetry(ev.Path, now, cfg.ForceSync) {
|
||||||
res.Status = "skipped"
|
res.Status = "skipped"
|
||||||
res.Error = retrySkipReason(m, ev.Path, now)
|
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)
|
results = append(results, res)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1885,9 +1951,9 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
|||||||
// 掛上去的話,Google 慢會被算成「你的知識庫沒有回應」——
|
// 掛上去的話,Google 慢會被算成「你的知識庫沒有回應」——
|
||||||
// 誤導的訊息比沒有訊息更貴(會害人往錯的方向查)。
|
// 誤導的訊息比沒有訊息更貴(會害人往錯的方向查)。
|
||||||
xgate := cfg.openGate(stepExtractDoc)
|
xgate := cfg.openGate(stepExtractDoc)
|
||||||
prior := m.Entries[ev.Path] // #121:先前失敗過的檔再失敗,不算「路壞了」
|
// #121:先前失敗過的檔再失敗,不算「路壞了」;#201:斷網那種不算前科
|
||||||
cards, xerr = extractWithWorkersAI(cfg.CypherURL, cfg.APIKey, absRoot, ev.Path, cardOrigin,
|
cards, xerr = extractWithWorkersAI(cfg.CypherURL, cfg.APIKey, absRoot, ev.Path, cardOrigin,
|
||||||
prior != nil && prior.FailCount > 0)
|
m.HasOwnFailure(ev.Path))
|
||||||
xgate.release()
|
xgate.release()
|
||||||
if xerr == nil {
|
if xerr == nil {
|
||||||
xgate.ok()
|
xgate.ok()
|
||||||
@@ -1900,6 +1966,16 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
|||||||
// 走到這裡代表 config 有沒見過的值——誠實報錯,不要靜默跳過(禁假綠)。
|
// 走到這裡代表 config 有沒見過的值——誠實報錯,不要靜默跳過(禁假綠)。
|
||||||
xerr = fmt.Errorf("不支援的萃取方式 %q(支援:workers-ai/gemma)", cfg.Extractor)
|
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 {
|
if xerr != nil {
|
||||||
// 2026-08-07 task 2:Workers AI 每日免費額度用完是**已知的上游狀況**
|
// 2026-08-07 task 2:Workers AI 每日免費額度用完是**已知的上游狀況**
|
||||||
// (wiki mistakes.md 2026-08-06),不是 bug——不能讓使用者看到裸露的
|
// (wiki mistakes.md 2026-08-06),不是 bug——不能讓使用者看到裸露的
|
||||||
@@ -1976,15 +2052,19 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
|||||||
cardBody["quality_warnings"] = warns
|
cardBody["quality_warnings"] = warns
|
||||||
}
|
}
|
||||||
// #121:這個檔先前失敗過 ⇒ 這一發再失敗不算「路壞了」(見 routebackoff.go 檔頭)。
|
// #121:這個檔先前失敗過 ⇒ 這一發再失敗不算「路壞了」(見 routebackoff.go 檔頭)。
|
||||||
prior := m.Entries[ev.Path]
|
|
||||||
status, _, perr := cfg.postJSONAs(stepIngestCard, cfg.triggerURL(cfg.CardIngestWF), cardBody,
|
status, _, perr := cfg.postJSONAs(stepIngestCard, cfg.triggerURL(cfg.CardIngestWF), cardBody,
|
||||||
prior != nil && prior.FailCount > 0)
|
m.HasOwnFailure(ev.Path))
|
||||||
res.HTTPStatus = status
|
res.HTTPStatus = status
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
res.Status, res.Error = "failed", perr.Error()
|
res.Status, res.Error = "failed", perr.Error()
|
||||||
if isRouteBackoff(perr) {
|
if isRouteBackoff(perr) {
|
||||||
res.Status = "skipped" // #121:沒打出去,不是這個檔的失敗
|
res.Status = "skipped" // #121:沒打出去,不是這個檔的失敗
|
||||||
routeSkipped = true
|
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
|
ok = false
|
||||||
break
|
break
|
||||||
@@ -2200,6 +2280,8 @@ func runDirect(args []string) int {
|
|||||||
if *strict {
|
if *strict {
|
||||||
cfg.LintStrict = true // CLI 旗標覆蓋 config
|
cfg.LintStrict = true // CLI 旗標覆蓋 config
|
||||||
}
|
}
|
||||||
|
// #201:每一次被算進斷路器的失敗,當下就印一行帶時間的紀錄進 collector.log。
|
||||||
|
routeFailureLog = announceRouteFailure
|
||||||
|
|
||||||
// t176:t92 的 claude_bin 回寫已隨 claude 萃取路一併退役——RunDirectOnce 不再解析
|
// t176:t92 的 claude_bin 回寫已隨 claude 萃取路一併退役——RunDirectOnce 不再解析
|
||||||
// claude 執行檔,cfg.ClaudeBin 不會被改寫,故沒有東西需要回寫。
|
// claude 執行檔,cfg.ClaudeBin 不會被改寫,故沒有東西需要回寫。
|
||||||
|
|||||||
+52
-2
@@ -268,6 +268,11 @@ func (m *Manifest) MarkFailed(path string, at int64, reason string) bool {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
// #201:上一筆病歷是「這台電腦沒連上網路」⇒ 那幾次不是這個檔的失敗,重新算起。
|
||||||
|
// (更新前的舊版會把斷網也記成失敗,一個檔就這樣被記滿 8 次永久暫停。)
|
||||||
|
if isLocalNetworkText(e.LastError) {
|
||||||
|
e.FailCount = 0
|
||||||
|
}
|
||||||
e.FailCount++
|
e.FailCount++
|
||||||
e.LastFailAt = at
|
e.LastFailAt = at
|
||||||
if strings.TrimSpace(reason) != "" {
|
if strings.TrimSpace(reason) != "" {
|
||||||
@@ -281,6 +286,35 @@ func (m *Manifest) MarkFailed(path string, at int64, reason string) bool {
|
|||||||
return true
|
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 回報「這個檔現在該不該送」。
|
// ShouldRetry 回報「這個檔現在該不該送」。
|
||||||
//
|
//
|
||||||
// - 從沒失敗過 → true(行為與 t195 前一字不變)
|
// - 從沒失敗過 → true(行為與 t195 前一字不變)
|
||||||
@@ -292,18 +326,34 @@ func (m *Manifest) MarkFailed(path string, at int64, reason string) bool {
|
|||||||
// 另:使用者改檔會讓 content hash 變 → 走的是「內容變更」路徑,本函式不介入。
|
// 另:使用者改檔會讓 content hash 變 → 走的是「內容變更」路徑,本函式不介入。
|
||||||
func (m *Manifest) ShouldRetry(path string, now int64, force bool) bool {
|
func (m *Manifest) ShouldRetry(path string, now int64, force bool) bool {
|
||||||
e, ok := m.Entries[path]
|
e, ok := m.Entries[path]
|
||||||
if !ok || e.FailCount == 0 {
|
if !ok {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if force {
|
if force {
|
||||||
return true
|
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 {
|
if e.FailCount >= MaxFailBeforeSkip {
|
||||||
return false
|
// #201:「雲端當時還是舊版、沒有 AI」不是這個檔的病——雲端更新之後要自己再試。
|
||||||
|
// 退避窗口照舊(最長 6 小時一次),雲端還沒更新的話頂多每 6 小時白問一發。
|
||||||
|
return isOldCloudText(e.LastError) && now >= e.NextRetry
|
||||||
}
|
}
|
||||||
return now >= e.NextRetry
|
return now >= e.NextRetry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isOldCloudText=病歷上寫的是「雲端還沒有這個功能」(舊版雲端),不是檔案本身的問題。
|
||||||
|
func isOldCloudText(msg string) bool {
|
||||||
|
return strings.Contains(msg, "沒有綁定 Workers AI") || strings.Contains(msg, "沒有雲端萃取功能")
|
||||||
|
}
|
||||||
|
|
||||||
// Save 原子寫入(temp + rename),避免掃描中斷留半個 JSON。
|
// Save 原子寫入(temp + rename),避免掃描中斷留半個 JSON。
|
||||||
func (m *Manifest) Save(path string) error {
|
func (m *Manifest) Save(path string) error {
|
||||||
data, err := json.MarshalIndent(m, "", " ")
|
data, err := json.MarshalIndent(m, "", " ")
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ func (m *Manifest) Progress() SyncProgress {
|
|||||||
switch {
|
switch {
|
||||||
case e.IngestedHash != "" && e.IngestedHash == e.ContentHash:
|
case e.IngestedHash != "" && e.IngestedHash == e.ContentHash:
|
||||||
p.Done++
|
p.Done++
|
||||||
|
case isLocalNetworkText(e.LastError):
|
||||||
|
// #201:上次只是這台電腦沒連上網路 ⇒ 會自己再試,是排隊中,不是卡住。
|
||||||
|
p.Pending++
|
||||||
case e.FailCount >= MaxFailBeforeSkip:
|
case e.FailCount >= MaxFailBeforeSkip:
|
||||||
// 已經放棄自動重試的不能混在 Pending 裡假裝還在排隊——
|
// 已經放棄自動重試的不能混在 Pending 裡假裝還在排隊——
|
||||||
// 使用者會一直等一件永遠不會發生的事。
|
// 使用者會一直等一件永遠不會發生的事。
|
||||||
|
|||||||
@@ -326,7 +326,8 @@ func TestRouteBreaker_Rules(t *testing.T) {
|
|||||||
if b.note(u, t0.Add(time.Minute+90*time.Second)) == "" {
|
if b.note(u, t0.Add(time.Minute+90*time.Second)) == "" {
|
||||||
t.Fatal("窗口到期後再失敗一發,應該停 2 分鐘")
|
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)) == "" {
|
if b.note(u, t0.Add(3*time.Minute+4*time.Minute)) == "" {
|
||||||
t.Fatal("再失敗一發應該停 5 分鐘")
|
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 裡寫著工作流失敗 ⇒ **不算**:路是通的,失敗可能只屬於那一份內容,
|
// - HTTP 2xx 但 body 裡寫著工作流失敗 ⇒ **不算**:路是通的,失敗可能只屬於那一份內容,
|
||||||
// 把它算成整條路壞掉會讓一份怪檔拖住所有人(逐檔退避會照顧它)。
|
// 把它算成整條路壞掉會讓一份怪檔拖住所有人(逐檔退避會照顧它)。
|
||||||
// - 其他 4xx ⇒ 不算(多半是那一發本身的問題,不是路的問題)。
|
// - 其他 4xx ⇒ 不算(多半是那一發本身的問題,不是路的問題)。
|
||||||
@@ -27,13 +32,25 @@
|
|||||||
// - 已經失敗過的檔再重試又失敗 ⇒ **不算數也不歸零**:那是它自己的病(逐檔退避在管),
|
// - 已經失敗過的檔再重試又失敗 ⇒ **不算數也不歸零**:那是它自己的病(逐檔退避在管),
|
||||||
// 不准拿它把整條路關掉——否則一池壞檔會輪流把健康的檔擋在外面(t217 餓死病換個形狀回來)。
|
// 不准拿它把整條路關掉——否則一池壞檔會輪流把健康的檔擋在外面(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)=這一輪不看退避,照打(與逐檔退避同一個語意)。
|
// 使用者按「立刻同步」(ForceSync)=這一輪不看退避,照打(與逐檔退避同一個語意)。
|
||||||
//
|
//
|
||||||
// 狀態只活在這個行程裡:行程重開=從零開始,最壞代價是重開後第一輪多撞一發就重新停下。
|
// 狀態只活在這個行程裡:行程重開=從零開始,最壞代價是重開後第一輪多撞一發就重新停下。
|
||||||
package collector
|
package collector
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -56,9 +73,41 @@ var routeBackoffLadder = []time.Duration{
|
|||||||
// 就是 4 發——第一輪之內就停得下來。之後每次窗口到期只會再撞 1 發。
|
// 就是 4 發——第一輪之內就停得下來。之後每次窗口到期只會再撞 1 發。
|
||||||
const routeStrikesBeforeBackoff = 4
|
const routeStrikesBeforeBackoff = 4
|
||||||
|
|
||||||
|
// routeFailMemory=上一次失敗之後多久沒再失敗,就不再把它算進「連續」。
|
||||||
|
// 取階梯上限(30 分鐘)的兩倍:退避中的路窗口到期會再試一發,所以真的一直壞的路
|
||||||
|
// 最慢 30 分鐘就會再記一次失敗,不會被這條規則洗掉;只有「好幾個小時才偶發一次」
|
||||||
|
// 的零星失敗才會重新算起。
|
||||||
|
const routeFailMemory = time.Hour
|
||||||
|
|
||||||
type routeState struct {
|
type routeState struct {
|
||||||
fails int
|
fails int
|
||||||
until time.Time
|
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 {
|
type routeBreaker struct {
|
||||||
@@ -80,6 +129,62 @@ func isRouteBackoff(err error) bool {
|
|||||||
return ok
|
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=主機+路徑(不含查詢字串)。同一台知識庫的不同工作流是不同的路。
|
// routeKey=主機+路徑(不含查詢字串)。同一台知識庫的不同工作流是不同的路。
|
||||||
func routeKey(raw string) string {
|
func routeKey(raw string) string {
|
||||||
u, err := url.Parse(raw)
|
u, err := url.Parse(raw)
|
||||||
@@ -109,35 +214,57 @@ func routeLabel(raw string) string {
|
|||||||
return u.Path
|
return u.Path
|
||||||
}
|
}
|
||||||
|
|
||||||
// note 回「這條路現在正在退避」的白話;空=可以打。
|
// note 回「這條路現在正在退避」的白話;空=可以打。知識庫以主機名稱呼(見 noteFor)。
|
||||||
func (b *routeBreaker) note(raw string, now time.Time) string {
|
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()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
st := b.routes[routeKey(raw)]
|
st := b.routes[routeKey(raw)]
|
||||||
if st == nil || !now.Before(st.until) {
|
if st == nil || !now.Before(st.until) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(who) == "" {
|
||||||
|
who = instanceHostOf(raw)
|
||||||
|
}
|
||||||
wait := st.until.Sub(now).Round(time.Second)
|
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),不要改掉。
|
// 🔴 結尾「稍後會自動恢復」是 explainsWhySkipped 的識別字(sync_status.go),不要改掉。
|
||||||
// 不帶上游原文(HTTP 碼/JSON):這句會出現在畫面上;原文在失敗那一發的結果裡已經留著。
|
// 不帶上游原文(HTTP 碼/JSON):這句會出現在畫面上;原文在 collector.log 的 route_failure 那幾行。
|
||||||
return fmt.Sprintf("雲端「%s」這條路連續失敗 %d 次,先停 %s 再試,避免一直重撞把雲端額度用光;稍後會自動恢復。",
|
return fmt.Sprintf("知識庫「%s」的「%s」這條路連續失敗 %d 次%s,先停 %s 再試,避免一直重撞把雲端額度用光;稍後會自動恢復。",
|
||||||
routeLabel(raw), st.fails, humanWait(wait))
|
who, routeLabel(raw), st.fails, last, humanWait(wait))
|
||||||
}
|
}
|
||||||
|
|
||||||
// record 記一發的結果。status=HTTP 狀態碼(0=沒拿到回應);transportErr=連線層錯誤;
|
// record 記一發的結果。status=HTTP 狀態碼(0=沒拿到回應);transportErr=連線層錯誤;
|
||||||
// retry=這份內容先前就失敗過(逐檔病歷上有紀錄)——它失敗不算路壞,見檔頭。
|
// retry=這份內容先前就失敗過(逐檔病歷上有紀錄)——它失敗不算路壞,見檔頭。
|
||||||
func (b *routeBreaker) record(raw string, now time.Time, status int, transportErr error, retry bool) {
|
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
|
failed := transportErr != nil || status >= 500 || status == 429
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
|
||||||
key := routeKey(raw)
|
key := routeKey(raw)
|
||||||
if !failed {
|
if !failed {
|
||||||
if status >= 200 && status < 300 {
|
if status >= 200 && status < 300 {
|
||||||
delete(b.routes, key) // 通了就全部歸零
|
delete(b.routes, key) // 通了就全部歸零
|
||||||
}
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if retry {
|
if retry {
|
||||||
|
b.mu.Unlock()
|
||||||
return // 有前科的內容再失敗:它自己的病,不算數也不歸零
|
return // 有前科的內容再失敗:它自己的病,不算數也不歸零
|
||||||
}
|
}
|
||||||
st := b.routes[key]
|
st := b.routes[key]
|
||||||
@@ -145,15 +272,33 @@ func (b *routeBreaker) record(raw string, now time.Time, status int, transportEr
|
|||||||
st = &routeState{}
|
st = &routeState{}
|
||||||
b.routes[key] = st
|
b.routes[key] = st
|
||||||
}
|
}
|
||||||
|
// #201:上一次失敗太久以前 ⇒ 那不是「連續」,重新算起。
|
||||||
|
if st.fails > 0 && !st.lastFail.IsZero() && now.Sub(st.lastFail) > routeFailMemory {
|
||||||
|
st.fails = 0
|
||||||
|
}
|
||||||
st.fails++
|
st.fails++
|
||||||
if st.fails < routeStrikesBeforeBackoff {
|
st.lastFail = now
|
||||||
return
|
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 transportErr != nil {
|
||||||
if idx >= len(routeBackoffLadder) {
|
entry.Detail = transportErr.Error()
|
||||||
idx = len(routeBackoffLadder) - 1
|
|
||||||
}
|
}
|
||||||
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 清空全部狀態(測試用)。
|
// reset 清空全部狀態(測試用)。
|
||||||
@@ -182,5 +327,5 @@ func (c *DirectConfig) routeNote(raw string) string {
|
|||||||
if c.ForceSync {
|
if c.ForceSync {
|
||||||
return ""
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -342,6 +342,11 @@ func (s *Supervisor) runOnce(ctx context.Context) error {
|
|||||||
s.setState(func(st *Status) { st.State = StateSyncing; st.Waiting = "" })
|
s.setState(func(st *Status) { st.State = StateSyncing; st.Waiting = "" })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if r.Phase != "" && r.Phase != "done" && r.Phase != "waiting" {
|
||||||
|
// #201:「route_failure」等純紀錄用的行(落 log 就夠了),**不是一輪跑完**。
|
||||||
|
// 以前除了 start/waiting 以外一律當「done」,多印一種行就會讓托盤誤以為這輪結束。
|
||||||
|
continue
|
||||||
|
}
|
||||||
if r.Phase == "waiting" {
|
if r.Phase == "waiting" {
|
||||||
// #153:還在等某一發回覆 ⇒ 仍在同步中,**不算跑完一輪**。
|
// #153:還在等某一發回覆 ⇒ 仍在同步中,**不算跑完一輪**。
|
||||||
// 把「哪個帳號、哪件事、等了多久」留在狀態上,托盤才講得出來
|
// 把「哪個帳號、哪件事、等了多久」留在狀態上,托盤才講得出來
|
||||||
|
|||||||
Reference in New Issue
Block a user