diff --git a/CHANGELOG.md b/CHANGELOG.md index c5c6a38..3d281bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,12 @@ --- +## 0.18.53(2026-09-14) + +- 🔴 **資料夾上的數字會跟著真的送上雲端的份數走**:以前小幫手在「同步中」的時候,展開資料夾看到的每一層都是「0 / 總數」,要等整輪(檔案多、知識庫回得慢時可能好幾個小時)全部跑完才會變——即使雲端早就收到好幾份、AI 已經查得到。現在每送上一份,數字就跟著加一,展開著的資料夾幾秒內就會更新。 +- **「同步中」底下會說正在做什麼、卡在哪**:以前只有一句不會變的「請稍候,完成後會顯示整理了幾份」,分不出是在做還是卡住了。現在會寫「正在處理 ISEP(leo21c):送出一份筆記 · 這一輪已送上 11 份」;知識庫太久沒回應時,會直接寫「卡在『KB(leo21c)』:還在等知識庫回覆,已經等了 30 秒」。 +- 修了一個跑完也不會對的地方:以前就算整輪同步做完,資料夾結構上的「已同步」數字仍是開始同步前的,要到下一輪才對;現在做完當下就是對的。 + ## 0.18.52(2026-09-13) - 🔴 **雲端資料庫的免費額度用完時,小幫手會直接告訴你**:以前 Cloudflare 免費帳號當天的資料庫讀取(或寫入)額度用完,小幫手只會顯示看不懂的錯誤(例如「缺少 credential」)或「連續失敗、已暫停自動重試」,看起來像東西壞了。現在首頁會出現一張卡,寫明是哪一種額度用完、免費方案的上限是多少(讀取每天 500 萬列、寫入每天 10 萬列)、台北時間幾點恢復(早上 8:00),以及恢復後會自動接著傳、你不用做任何事。 diff --git a/cmd/arcrun-app/.version-source-files.json b/cmd/arcrun-app/.version-source-files.json index 1348d97..5b1f8dd 100644 --- a/cmd/arcrun-app/.version-source-files.json +++ b/cmd/arcrun-app/.version-source-files.json @@ -1,10 +1,10 @@ { "_algo": 4, - "version": "0.18.52", - "fingerprint": "5ab62f966033ea4b", + "version": "0.18.53", + "fingerprint": "32f62120aa472b65", "files": { ".gitignore": "c7a98c44ecc882a174720e43dabd4c04d7cf2503586426dcccaa213d5a27df38", - "CHANGELOG.md": "718ac1789302c16de2af1595b4081a0b0b2eff88ffd007f33f78720b79521c18", + "CHANGELOG.md": "8032ea736ef5672f1f7971edad3ee3a707fe31fe0c5d0d05d34a35302e2a7853", "DAEMON_LINE": "d5019abbdc8a5f2919e9e3510391891cd7fbdf0765bf16ec83caa779f370116d", "README.md": "9d92cac236b20a0b183eea3e7f5e39ad492f05192c4ea602eb11c3d09967327f", "cardloop_test.go": "fdf09b62650c328f5d8075d964a0ca2dada5f8d9a271202768472998b45e31a8", @@ -25,7 +25,7 @@ "cloudquota_test.go": "c2ab6ada0b09f2092a07b84e00090bb74dc81f8204906dde8dd4c53cfd24b9c1", "cmd/arcrun-app/.gitignore": "91248524c8b00d155960f42ad6707ce36bd91ff44f66289eec45972d3915ad37", "cmd/arcrun-app/README.md": "23e53fdf53fcd96b6d8d7a121eb91d3242d11b69d655770473b745c49bb387b9", - "cmd/arcrun-app/app.go": "098e6a01aa0a298248ae9cc96275b209e5b8c9669325ba691c46383470216698", + "cmd/arcrun-app/app.go": "55fddf43ad52aed29214d7a5a2ff4985acbdb2d0c7c0b724c2008e30e3f392e6", "cmd/arcrun-app/apps.go": "0df6da9d55adbdec500fbb01005a305287d1dbf796b7e463f2b2f222626b4c96", "cmd/arcrun-app/apps_test.go": "0e2f137f8b49f421e869ab3192ca12c8bb756029d8730487ccc44733aaf7f96c", "cmd/arcrun-app/assets/store/README.md": "a9028a727947c5038ca938c664b18e4851ae3bf0e6d8838c82aedea43d02ded2", @@ -95,7 +95,7 @@ "cmd/arcrun-app/frontend/src/assets/fonts/OFL.txt": "8656c2a27357b3140047a39b27e3cd928256544ad8a004ef77087f761358208c", "cmd/arcrun-app/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2": "06f3af3fe52542d40ad9bc14ec03e04deaabd09ec369221cc8f536db1c72bf55", "cmd/arcrun-app/frontend/src/assets/images/logo-universal.png": "157a874acd3363634747287c0b01034e9f1eec838a4cf39827dfea789e730494", - "cmd/arcrun-app/frontend/src/main.js": "2c4ac3017bead3a99812ce8919a513fb0e23dd752285a426c649c6e774d0f08d", + "cmd/arcrun-app/frontend/src/main.js": "f521930ec60300b5fb80400d40d3cd9da2a9bd8579d042e81f0db6635636d2f8", "cmd/arcrun-app/frontend/src/style.css": "2cc59ab0be7668f2b8e2e42f655beb506a91f2d31414e0a40dbab33fffc8349a", "cmd/arcrun-app/gen-icons.py": "df5b3b33e500fc564badfc8bd2a9ca6a43f7d7e3b31111c41e3b322137c4c96c", "cmd/arcrun-app/go.mod": "8b9e57bcfd44886d1ec06480e033af706ff3fed470786c57074900dd48ecf86f", @@ -110,6 +110,8 @@ "cmd/arcrun-app/selfupdate_test.go": "1c0752f3b3f4f262620e096558ded38ed21db94195b760a3f3b046ce9dbdd4ab", "cmd/arcrun-app/skipped_test.go": "f4c9de16c349c0b7f48ce62a6506364c87e3d4fbc99f415e3e6aec7d3da7f1aa", "cmd/arcrun-app/supervise.go": "f7eb6c74524b5d8d0abe520b831a98b85d19d18c00d1a585493ababcbd01c81f", + "cmd/arcrun-app/syncing_sub.go": "8d681209809da3324fddb30a06319ffaa19907bfa3763109ca2e33ae6ac50b5f", + "cmd/arcrun-app/syncing_sub_test.go": "db32b573d087bbef3f1c69e30b714a02be64ea169a8f777962561e8f9f1c6259", "cmd/arcrun-app/tray_darwin.go": "ff3cb22fc2663b08b9f3cd14887ab4411ed338ec8920b58ad0fac029f09edae0", "cmd/arcrun-app/tray_darwin.m": "b4ddf0bb420adf00d580d65c8cfbbe130a78e803dd1e92a1617ddaf6ef172fac", "cmd/arcrun-app/tray_other.go": "0c3df95b5f7999f6319d0bf5e24fdbf821bf97a79db04077d9e7eaf358299880", @@ -128,7 +130,7 @@ "convert_table_test.go": "d0371b7566ef3152f9dd42f9f990e0dffa1c50a0c8e874a28415fa2e4c188394", "convert_test.go": "04f3fa30d1be5f910c0e0be3308ced2963191ab030a9eaccd986ef581fcd4e18", "convert_wiring_test.go": "3389817e596341369a3fc102846bf5b081cee668e86ebf4830eb633978de652b", - "direct.go": "3a79d03fce931115739a0113494a61331155172c7f9e5cbee126203b3e616458", + "direct.go": "d7122f5efe522fd6be285402ee20c63af4b5545cf48591089ed9c5ee2d34a852", "direct_extract_test.go": "317d0057d2d368fabc406351a7477f72e074d2b8c8459b1dc021febdf16902f4", "direct_multi_test.go": "a76593fef26231e18b7402f6f7376624d2471f8a719b3ce08fc4a0f5a3d3cf1e", "direct_pacing.go": "b54838ce85f72ecd2d3388fa7cf89050c87fb10bd67dca07cd18d49568a8837c", @@ -148,7 +150,7 @@ "folderindex_helpers_test.go": "2cf17b70c396225f728e8e160cfc75d7bdc76ac1ce8e574d1450b020050c0734", "folderindex_test.go": "a5319eb1e92473cb5c32d3beddb7889997556227824de300dd39953b43beab70", "foldershape.go": "b1b237f5674674efdc9bdea849b63c571a014e64bec33af42fb7c4769fa7582b", - "foldertree.go": "fb86c8d869a476d77d548e9c9a7c14be51c76fa1400a6497016b705ff9c4ce13", + "foldertree.go": "7a7d478f7c6fb75536d149d1a61a0643358d77b3de5e5b043397052923c74784", "foldertree_live_test.go": "ace96ffdf7670a69761973e810fb6548c7036d2768141e09db515313df1e9a50", "foldertree_local_test.go": "ce919dc2a21ec96716e3d43a7f493367346e212243b4ab22c2d960fc26408f3a", "foldertree_machine_test.go": "f5dc73afc261b0fb0298f27962628e8538abc704540b52d66e13bf85600a59dd", @@ -168,6 +170,8 @@ "inventory_test.go": "16e0f83a09d61dc4cded032fd493d30bfc747c38116b871fb02b2f0bf2b0216a", "lint.go": "63af1289978a02c9722ac8bb934a916b9e4e65cffcfec0749eb07590496960f3", "lint_test.go": "2f57a04b9d697fba2d078eb08f49d2fec1640c7e3c61a60bc3821d2bb0798153", + "live_status.go": "3395ea5cfcf1ddb137e6e3e2d243db88626dd3d2dd313c594f09cde9986eba54", + "live_status_test.go": "883ea2b9b70f41e20bd861b124f9101cbf98cab14fb7a3a98bc4b309eb9ffbb2", "logseq_bak_residue_test.go": "52aa2f47ac12ca2e77e6112d6de88c873f1f418bae32b2a4de7a35028f835257", "machineid.go": "d4224e684ecfbafe2efc7e0b95377f9e669ee1256a442aa72ce2cab3b219fc56", "machineid_test.go": "5b54ac62ee1fb28b8f0a873888aeddb844af0aa74472bfb849573cfc6691a290", @@ -199,13 +203,13 @@ "sourcerepair.go": "59fa1956181904f6661ab397e42b4f1c9b8ac81f382fdf720cc5b0831390f9d9", "sourcerepair_live_test.go": "2a7f5d6fada420a7f55fa52b87361842755c68c1cb58f8c3fd0153341f709a0a", "sourcerepair_test.go": "2a1ddddaad8ac7adc36350859e2d742c0fea12b99370a4d4831d390db1cc6720", - "stallguard.go": "13be8ae905492054d516f466856bc6949b16e1af6ea32068f94b21635799bdc6", + "stallguard.go": "3f5d36d72f84cc3431536dbb20219bd4793b11e573b5ac7f817cbe9e45d234d7", "stallguard_test.go": "678b82a8448d1394a0a85d4af1f133c980f5ff0dcb54953b0fcf1671d186147f", "supervisor/hidewindow_other.go": "291b4ecb0783ff3e813c68b5464161d9af2f84d31c773c7f1f710f218bb5d610", "supervisor/hidewindow_windows.go": "29796dbe90ecb35e6f1451dacf956d42928c6a393271befc5c7f6863d5315fb3", "supervisor/supervisor.go": "f79b32e92e67058a056dc749a6182573eefcbbc9bc7b2cd5a997f4e8066a004e", "supervisor/supervisor_test.go": "f5582d54c55065fbc834fe96754df9c8cd6efe9f88f30208bbce84ddea08fea7", - "sync_status.go": "799d3481c361647c9cf1ae40ac4c550d6690a87f843c514f8b830d811e7bba1f", + "sync_status.go": "353256d46d5cc9521c7798637dbaa06d2d0deabf163605409ca94e59fa2e2098", "sync_status_test.go": "73bbc83c9e8fc18575895a7b7b5feab7a76d41f05a74d74bbbdbf98b045ccfa2", "t52_library_test.go": "14188d0afdad0f9aa16d19a74ea28c45c3d18fcfeabc5deb6fb0466d05778a51", "template_install.go": "d0aa0098e431696a63ef8d3714c124591b8ba193ccd5a8359a4ea03e397ee358", diff --git a/cmd/arcrun-app/.version-source.json b/cmd/arcrun-app/.version-source.json index 22bf522..87e1990 100644 --- a/cmd/arcrun-app/.version-source.json +++ b/cmd/arcrun-app/.version-source.json @@ -22,5 +22,6 @@ "0.18.49": "7cb86e646aeaa057", "0.18.50": "b30716c3ccec9e65", "0.18.51": "3cfcc815cf185a68", - "0.18.52": "5ab62f966033ea4b" + "0.18.52": "5ab62f966033ea4b", + "0.18.53": "32f62120aa472b65" } diff --git a/cmd/arcrun-app/app.go b/cmd/arcrun-app/app.go index 1c192d5..a101105 100644 --- a/cmd/arcrun-app/app.go +++ b/cmd/arcrun-app/app.go @@ -126,6 +126,8 @@ type syncStatus struct { // 同上:形狀定義在 collector/progress.go/sync_status.go,這裡原樣接住。 // 這是資料夾那一列打不打勾的**唯一**依據(見 folder_badge.go)。 FolderProgress map[string]collector.SyncProgress `json:"folder_progress,omitempty"` + // arcrun-rag#200:一輪還沒跑完時做到哪(形狀定義在 collector/sync_status.go)。 + InRound *collector.RoundProgress `json:"in_round,omitempty"` } type skippedDoc struct { @@ -649,7 +651,7 @@ func describeStatus(s syncStatus) (syncing bool, big, sub string) { return false, "同步引擎沒有在跑", sub } if collectorSyncing() { - return true, "同步中… 正在讀檔並整理成知識卡", "請稍候,完成後會顯示整理了幾份" + return true, "同步中… 正在讀檔並整理成知識卡", syncingSub(s.InRound, time.Now(), accountLabelFor) } if s.ExtractorError != "" && !s.ExtractorOK { return false, "需要你處理一下", "⚠ " + s.ExtractorError diff --git a/cmd/arcrun-app/frontend/src/main.js b/cmd/arcrun-app/frontend/src/main.js index 7edf499..b4d01c6 100644 --- a/cmd/arcrun-app/frontend/src/main.js +++ b/cmd/arcrun-app/frontend/src/main.js @@ -1140,7 +1140,39 @@ function render(s) { if (first && page === 'apps' && (s.accounts || []).length) loadApps(appsAccIdx); } -async function tick() { try { render(await go.GetState()); } catch (e) {} } +async function tick() { try { render(await go.GetState()); } catch (e) {} refreshOpenTrees(); } + +// 🔴 `inkstone/arcrun-rag#200`:展開著的樹要跟著同步進度走。 +// +// 以前樹第一次展開讀一次就一直留在記憶體裡——而 collector 一輪途中每送上一份就會 +// 重寫 folder-trees.json。leo 2026-09-13 看著 ISEP 每層 0 / N 等了一整晚, +// 雲端那時已經收到 11 份:**就算檔案更新了,畫面也不會去讀**。 +// +// 只重讀「現在展開著」的那幾棵,每 3 秒一次(不是每秒——樹上限 300 個節點, +// GetFolderTree 註解 ③ 講過為什麼不掛在每秒的 GetState 上)。內容沒變就不重畫, +// 使用者正在點開的節點與「為什麼」不會因為重讀而跳掉。讀失敗不吵,下一次再試。 +let treeRefreshAt = 0; +let treeRefreshing = false; +async function refreshOpenTrees() { + const now = Date.now(); + if (treeRefreshing || now - treeRefreshAt < 3000) return; + treeRefreshAt = now; + treeRefreshing = true; + try { + for (const path of Object.keys(treeState.open)) { + if (!treeState.open[path] || treeState.data[path] === undefined) continue; + try { + const next = (await go.GetFolderTree(path)) || null; + if (JSON.stringify(next) !== JSON.stringify(treeState.data[path])) { + treeState.data[path] = next; + renderFolderTree(path); + } + } catch (e) { /* 下一次再試 */ } + } + } finally { + treeRefreshing = false; + } +} // ── 動作 ── $('btnSync').onclick = async () => { await go.SyncNow(); tick(); }; diff --git a/cmd/arcrun-app/syncing_sub.go b/cmd/arcrun-app/syncing_sub.go new file mode 100644 index 0000000..08e3ab8 --- /dev/null +++ b/cmd/arcrun-app/syncing_sub.go @@ -0,0 +1,90 @@ +// syncing_sub.go — 「同步中」底下那一行要講得出做到哪、卡在哪(`inkstone/arcrun-rag#200`)。 +// +// 🔴 病(2026-09-13 leo 截圖):畫面整晚寫「同步中… 正在讀檔並整理成知識卡/ +// 請稍候,完成後會顯示整理了幾份」,而 collector.log 裡明明印著 +// 「還在等知識庫 leo21c 回覆『送出一份筆記』,已經等了 30 秒」。 +// **會說話的那句只寫進了記錄檔,畫面上是一句永遠不變的「請稍候」**—— +// 使用者分不出「正在做」和「卡住了」,只能猜。 +// +// 資料來源只有一個:collector 一輪途中寫進 status.json 的 in_round(collector/live_status.go)。 +// 這裡不判斷任何事,只把它念成一句話。 +package main + +import ( + "fmt" + "path/filepath" + "strings" + "time" + + "arcrun-rag/collector" +) + +// syncingIdleWarnAfter=多久沒有任何新寫入,就明講「已經 N 分鐘沒有新進展」。 +// 「還在等」每 30 秒會寫一次,所以正常等待不會碰到這條;碰到=真的沒在動。 +const syncingIdleWarnAfter = 5 * time.Minute + +const syncingSubFallback = "請稍候,完成後會顯示整理了幾份" + +// syncingSub 把「這一輪做到哪」念成一句話。r 為 nil(舊版 collector、剛開工還沒寫)=沿用原本那句。 +// label 把主機名翻成使用者認得的知識庫名字(測試可以換掉)。 +func syncingSub(r *collector.RoundProgress, now time.Time, label func(host string) string) string { + if r == nil { + return syncingSubFallback + } + where := "" + if r.Account != "" { + where = label(r.Account) + } + if r.Folder != "" { + name := filepath.Base(r.Folder) + if where != "" { + where = fmt.Sprintf("%s(%s)", name, where) + } else { + where = name + } + } + + // 卡住的時候,只講卡在哪——這是使用者此刻唯一需要知道的事。 + if w := r.Waiting; w != nil && strings.TrimSpace(w.Note) != "" { + if where != "" { + return fmt.Sprintf("卡在「%s」:%s", where, w.Note) + } + return "卡住了:" + w.Note + } + + var parts []string + if where != "" { + head := "正在處理 " + where + if r.Step != "" { + head += ":" + r.Step + } + parts = append(parts, head) + } + if r.Ingested > 0 { + parts = append(parts, fmt.Sprintf("這一輪已送上 %d 份", r.Ingested)) + } + if r.Failed > 0 { + parts = append(parts, fmt.Sprintf("%d 份沒送成功", r.Failed)) + } + if t, err := time.Parse(time.RFC3339, r.UpdatedAt); err == nil { + if idle := now.Sub(t); idle > syncingIdleWarnAfter { + parts = append(parts, fmt.Sprintf("已經 %d 分鐘沒有新進展", int(idle.Minutes()))) + } + } + if len(parts) == 0 { + return syncingSubFallback + } + return strings.Join(parts, " · ") +} + +// accountLabelFor 把 status.json 裡的主機名翻成畫面左邊那個知識庫名字(與 UIAccount.Name 同一套)。 +func accountLabelFor(host string) string { + if cfg, err := loadCfg(); err == nil { + for _, a := range cfg.Accounts { + if shortHost(a.CypherURL) == host { + return accountName(a) + } + } + } + return host +} diff --git a/cmd/arcrun-app/syncing_sub_test.go b/cmd/arcrun-app/syncing_sub_test.go new file mode 100644 index 0000000..a5b6d77 --- /dev/null +++ b/cmd/arcrun-app/syncing_sub_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "strings" + "testing" + "time" + + "arcrun-rag/collector" +) + +// TestSyncingSubSaysWhereAndWhy 守住 `inkstone/arcrun-rag#200`: +// 「同步中」底下那一行要講得出做到哪、卡在哪,不再是一句永遠不變的「請稍候」。 +func TestSyncingSubSaysWhereAndWhy(t *testing.T) { + now := time.Date(2026, 9, 14, 0, 30, 0, 0, time.Local) + label := func(host string) string { + if host == "arcrun-cypher-executor.leo21c.workers.dev" { + return "leo21c" + } + return host + } + fresh := now.Add(-10 * time.Second).Format(time.RFC3339) + + cases := []struct { + name string + in *collector.RoundProgress + want []string // 全部都要出現 + not []string // 一個都不准出現 + }{ + { + name: "舊版 collector 沒寫 in_round=沿用原本那句", + in: nil, + want: []string{syncingSubFallback}, + }, + { + name: "正在送=講資料夾、知識庫、哪件事、送了幾份", + in: &collector.RoundProgress{ + Account: "arcrun-cypher-executor.leo21c.workers.dev", Folder: "/Users/x/Documents/tech_projects/ISEP", + Step: "送出一份筆記", Ingested: 11, UpdatedAt: fresh, + }, + want: []string{"正在處理 ISEP(leo21c)", "送出一份筆記", "這一輪已送上 11 份"}, + not: []string{"請稍候", "分鐘沒有新進展"}, + }, + { + name: "卡住=只講卡在哪", + in: &collector.RoundProgress{ + Account: "arcrun-cypher-executor.leo21c.workers.dev", Folder: "/Users/x/Documents/KB", + Step: "送出目錄索引", UpdatedAt: fresh, + Waiting: &collector.StalledCall{Step: "送出目錄索引", + Note: "還在等知識庫「arcrun-cypher-executor.leo21c.workers.dev」回覆「送出目錄索引」,已經等了 30 秒。"}, + }, + want: []string{"卡在「KB(leo21c)」", "已經等了 30 秒"}, + not: []string{"請稍候", "正在處理"}, + }, + { + name: "很久沒有任何寫入=明講沒在動", + in: &collector.RoundProgress{ + Account: "arcrun-cypher-executor.leo21c.workers.dev", Step: "確認雲端 AI 可不可以用", + UpdatedAt: now.Add(-12 * time.Minute).Format(time.RFC3339), + }, + want: []string{"正在處理 leo21c", "已經 12 分鐘沒有新進展"}, + }, + { + name: "有失敗也要說", + in: &collector.RoundProgress{ + Account: "h", Folder: "/a/B", Ingested: 2, Failed: 1, UpdatedAt: fresh, + }, + want: []string{"這一輪已送上 2 份", "1 份沒送成功"}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := syncingSub(c.in, now, label) + for _, w := range c.want { + if !strings.Contains(got, w) { + t.Errorf("缺「%s」:%q", w, got) + } + } + for _, n := range c.not { + if strings.Contains(got, n) { + t.Errorf("不該出現「%s」:%q", n, got) + } + } + }) + } +} diff --git a/direct.go b/direct.go index 7f47d6b..0edbb23 100644 --- a/direct.go +++ b/direct.go @@ -669,6 +669,11 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge if cfg.Manifest != "" { prevStatus, _ = LoadSyncStatus(StatusFilePath(cfg.Manifest)) // 讀不到=零值,等同「沒有上一輪」 } + // 🔴 `inkstone/arcrun-rag#200`:一輪開始就寫一筆「正在跑」,之後每件事發生的當下更新 + // (見 live_status.go)。放在載入 prevStatus **之後**:途中寫入不該影響這一輪讀到的上一輪。 + if !dryRun && cfg.Manifest != "" { + cfg.guard.beginRound(StatusFilePath(cfg.Manifest), now) + } // t92-②:預檢 extractor(機器層級),有 fallback 路徑時就地更新 cfg.ClaudeBin。 extractorOK := true @@ -792,6 +797,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge } accCfg := cfg.makeAccountSubConfig(acc) accHost := instanceHostOf(acc.CypherURL) + cfg.guard.enterFolder(accHost, "") // #200:帳號層的事(探測雲端 AI、查版本)也要講得出在誰身上 // t103:per-account 雲端版本偵測 cloudVer, cloudOK := cloudVersionThrottled(accCfg.CypherURL, cfg.ForceSync) // #121:一分鐘問一次,見 cloudcheck.go @@ -859,6 +865,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge multi := len(accCfg.Folders()) > 1 for _, root := range accCfg.Folders() { + cfg.guard.enterFolder(accHost, root) // #200 r, e, p, rp := runDirectOnceRoot(accCfg, root, dryRun, qs, now) totalProgress = totalProgress.Add(rp.Progress) stuckReasons = append(stuckReasons, rp.StuckReasons...) @@ -926,6 +933,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge // arcrun-rag#46:把「使用者按了移除並收回」的資料夾撤乾淨。 // 放在看守資料夾之後:正在用的資料夾優先,收回是善後。 for _, root := range accCfg.RetiringRoots() { + cfg.guard.enterFolder(accHost, root) // #200 r, e, remaining, done := retireRootOnce(accCfg, root, dryRun) for i := range r { r[i].Root = root @@ -979,6 +987,8 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge // t91:每輪寫狀態檔(含 per-account 雲端版本與萃取計數)。 if !dryRun && cfg.Manifest != "" { + // #200:先封口再寫——晚到的「還在等」不准把收工這份蓋回「同步中」(見 finishRound)。 + cfg.guard.finishRound() st := SyncStatus{ LastSync: time.Now().Format(time.RFC3339), ExtractorOK: extractorOK, @@ -1618,6 +1628,45 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta PublishFolderTreeNow(cfg.Manifest, root, tree, runNow) } + // 🔴 `inkstone/arcrun-rag#200`:上面那棵樹是**開始送之前**算的,分子必然停在開工前。 + // 以前它就是本機唯一的一棵——收工合併時用的也是它 ⇒ 2026-09-13 ISEP 送上去 11 份, + // 畫面每一層照樣 0 / N。 + // + // ⇒ 每處理完一份檔,就用**同一個函式、同一組分母**、manifest 此刻的分子重算一棵, + // 立刻落地本機,並把這個資料夾的進度寫進 status.json(live_status.go)。 + // 分母沿用這一輪掃描的結果:一輪途中使用者新丟的檔,本來就要下一輪才算得到。 + // + // 「removed 暫時放回」的那幾筆不算進樹:它們只是下架重試的記帳,檔案已經不在了 + //(同開工那棵的判準——那棵算的時候它們還沒被放回去)。 + // + // 🔴 送上 portal 的那棵(syncFolderTree)**不在這裡重送**:每份檔多打一發雲端, + // 在一發 24〜57 秒的實例上等於把一輪拉長一倍。portal 下一輪掃描就會拿到新的。 + latestTree := tree + removedNow := map[string]bool{} + for _, ev := range payload.Events { + if ev.Type == "removed" { + removedNow[ev.Path] = true + } + } + liveReport := func(outcome string) { + if dryRun { + return + } + entries := m.Entries + if len(removedNow) > 0 { + entries = make(map[string]*ManifestEntry, len(m.Entries)) + for k, v := range m.Entries { + if !removedNow[k] { + entries[k] = v + } + } + } + latestTree = BuildFolderTree(absRoot, cfg.libraryFor(absRoot), payload.DirStats, entries, + payload.AllExcludedDirs, plan, runNow).StampMachine(cfg.machineIdentity()) + PublishFolderTreeNow(cfg.Manifest, root, latestTree, runNow) + cfg.guard.fileFinished(root, outcome, m.Progress()) + } + // 結構先行(InkStoneCo#43,2026-08-15):掃描一結束(純本機、免費、秒級)就先把 // 「這個資料夾有哪些檔案/最近改了什麼」送上知識庫,**不等 LLM 萃取、不受額度影響** // ——走 rag_ingest_card(零 LLM 的機械收口),所以刻意放在: @@ -1792,6 +1841,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta res.Status, res.Error = "failed", "讀檔失敗:"+rerr.Error() m.MarkFailed(ev.Path, now, res.Error) // t195:讀不到的檔也退避(權限/被鎖/壞掉的外接碟) saveManifest() + liveReport("failed") // #200 results = append(results, res) exit = 1 continue @@ -1866,6 +1916,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta // 漏記的話(連不上知識庫、金鑰壞、模型錯)照樣每輪重撞。 m.MarkFailed(ev.Path, now, res.Error) saveManifest() + liveReport("failed") // #200 results = append(results, res) exit = 1 continue @@ -1959,6 +2010,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta exit = 1 } saveManifest() + liveReport(res.Status) // #200:送上去的那一刻,畫面上的分子就要跟著動 results = append(results, res) continue } @@ -1994,6 +2046,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta qs.DailyCount++ } saveManifest() + liveReport(res.Status) // #200 results = append(results, res) case "removed": @@ -2037,6 +2090,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta // (不是 Scan() rebuild 時就拿掉——那時只是「偵測到不見了」,不是「已下架」)。 delete(m.Entries, ev.Path) saveManifest() + liveReport("removed") // #200 // t15:extractor 模式雲端下架成功後,同步清掉本地萃出的卡,保持本地與雲端一致。 // arcrun-rag#60:清除路徑必須跟落卡路徑**同一個函式**算出來(cardRelFor)—— // 目錄或檔名任一邊不同步就清不到卡、留下孤兒檔。第一輪對齊了目錄, @@ -2098,7 +2152,9 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta // t210:manifest 走到這裡已經是本輪最終狀態(每個事件處理完就地更新), // 原地數一次就是對的(同 SkippedDocCount 那套「現況快照」邏輯,不必另外維護計數器)。 - rp := rootProgress{Progress: m.Progress(), Tree: tree} + // #200:Tree 用**最後重算的那一棵**(沒處理任何檔時就是開工那棵)。 + // 以前這裡是開工前那棵 ⇒ 收工合併進 folder-trees.json 的分子永遠是開工前的數字。 + rp := rootProgress{Progress: m.Progress(), Tree: latestTree} for _, e := range m.Entries { if e != nil && e.FailCount >= MaxFailBeforeSkip { // LastError 原文交給呼叫端彙總後過 ClassifyFailure——分類判斷只住那一個接縫, diff --git a/foldertree.go b/foldertree.go index 76fccb8..a5aae49 100644 --- a/foldertree.go +++ b/foldertree.go @@ -488,7 +488,8 @@ func SaveFolderTreeStore(path string, s FolderTreeStore) error { if err != nil { return err } - return os.WriteFile(path, data, 0o644) + // #200:一輪途中每份檔都會重寫這個檔,而小幫手展開樹時會定期重讀 ⇒ 原子寫入(見 writeFileAtomic)。 + return writeFileAtomic(path, data, 0o644) } // MergeFolderTreeStore 把「這一輪算出來的樹」併進上一輪的快照。 diff --git a/live_status.go b/live_status.go new file mode 100644 index 0000000..6545821 --- /dev/null +++ b/live_status.go @@ -0,0 +1,186 @@ +// live_status.go — 一輪還沒跑完,畫面也要知道做到哪(`inkstone/arcrun-rag#200`)。 +// +// 🔴 病(2026-09-13 leo 截圖,小幫手 0.18.52): +// 「同步中… 正在讀檔並整理成知識卡」,ISEP 每一層都是 0 / N,leo:「它沒有跑任何一個東西」。 +// 實查同一時段: +// +// status.json 最後修改 12:00(上一個版本收工那次),之後一次都沒寫 +// folder-trees.json ISEP 那棵的 generated_at 是 23:38(**開始處理之前**),synced 全部 0 +// ISEP 的 manifest 23:53 已經記下 11 份送上雲端;AR-Mira 查得到 isep/README.md 的卡 +// +// ⇒ 不是沒跑,是**跑了但一個字都沒回寫**。兩份給畫面看的檔都只在固定時點寫: +// +// ① status.json 只在整輪收工時寫——三個帳號、每發雲端 24〜57 秒,一輪是好幾個小時 +// ② 樹只在「掃描完、開始送之前」寫一次(#153 第二輪搬的),那時分子必然還是 0; +// 收工那次合併用的也是**同一棵開工前的樹**,所以就算整輪跑完,數字還是 0。 +// +// 這支檔只做一件事:讓 roundGuard(本來就跟著整輪走、每個帳號每個資料夾共用的那一份) +// 在**事情發生的當下**把「做到哪」寫進 status.json。數字本身不另算: +// 資料夾進度是 (*Manifest).Progress() 的原件,樹是 BuildFolderTree 的原件—— +// 與收工那次同一個函式,只是早一點寫。 +package collector + +import "time" + +// folderSnapshot=某個看守資料夾此刻的進度(寫進 status.json 的 folder_progress)。 +type folderSnapshot struct { + root string + progress SyncProgress +} + +// beginRound 標記「一輪開始了」,並記住狀態檔在哪。statusPath 空=不寫(dry-run/沒有 manifest)。 +func (g *roundGuard) beginRound(statusPath string, now time.Time) { + if g == nil || statusPath == "" { + return + } + g.mu.Lock() + g.statusPath = statusPath + g.live = RoundProgress{StartedAt: now.Format(time.RFC3339)} + g.mu.Unlock() + g.persistLive(nil) +} + +// enterFolder 標記「現在輪到哪個知識庫的哪個資料夾」。folder 空=帳號層的事(探測雲端 AI 之類)。 +func (g *roundGuard) enterFolder(account, folder string) { + if g == nil { + return + } + g.mu.Lock() + g.live.Account, g.live.Folder = account, folder + g.live.Step, g.live.StepSince, g.live.Waiting = "", "", nil + g.mu.Unlock() + g.persistLive(nil) +} + +// stepStarted 標記「開始做某件雲端呼叫」。由 openGate 呼叫——每一發雲端呼叫都經過那道閘, +// 所以不必在每個呼叫端各自記得寫(「忘了接」這個失敗模式不該存在)。 +func (g *roundGuard) stepStarted(step callStep, at time.Time) { + if g == nil { + return + } + g.mu.Lock() + g.live.Step = step.Name + g.live.StepSince = at.Format(time.RFC3339) + g.live.Waiting = nil + g.mu.Unlock() + g.persistLive(nil) +} + +// noteWaiting 標記「那件事已經等很久了」——這就是「卡在哪一步」。 +func (g *roundGuard) noteWaiting(s StalledCall) { + if g == nil { + return + } + g.mu.Lock() + w := s + g.live.Waiting = &w + g.mu.Unlock() + g.persistLive(nil) +} + +// stepDone 那件事回來了:如果畫面上正掛著「還在等它」,拿掉。 +func (g *roundGuard) stepDone(step callStep) { + if g == nil { + return + } + g.mu.Lock() + clear := g.live.Waiting != nil && g.live.Waiting.Step == step.Name + if clear { + g.live.Waiting = nil + } + g.mu.Unlock() + if clear { + g.persistLive(nil) + } +} + +// fileFinished 一份檔處理完了(outcome:ingested/failed/其他)。 +// progress=那個資料夾此刻的 (*Manifest).Progress()。 +func (g *roundGuard) fileFinished(root, outcome string, progress SyncProgress) { + if g == nil { + return + } + g.mu.Lock() + switch outcome { + case "ingested": + g.live.Ingested++ + case "failed": + g.live.Failed++ + } + g.live.Waiting = nil + g.mu.Unlock() + g.persistLive(&folderSnapshot{root: root, progress: progress}) +} + +// persistStalls 把「這一輪等太久的事」立刻寫進去(strike 呼叫)。 +func (g *roundGuard) persistStalls() { g.persistLive(nil) } + +// finishRound 封口:之後任何途中寫入都不再動 status.json。 +// +// 🔴 一定要在收工那次 SaveSyncStatus **之前**呼叫。播報「還在等」的是另一條 +// goroutine,它可能比收工晚一點點才寫——沒有封口的話,收工剛寫好的那份會被 +// 一格過時的 in_round 蓋回去,畫面就永遠停在「同步中」。 +// 封口的判斷做在 statusFileMu 裡(見 persistLive),所以不存在「檢查完才被封口」的縫。 +func (g *roundGuard) finishRound() { + if g == nil { + return + } + g.mu.Lock() + g.closed = true + g.mu.Unlock() +} + +// persistLive 把此刻的「做到哪」寫進 status.json;fs 非 nil 時一併更新那個資料夾的進度。 +// 寫不進去一律吞掉:這是給畫面看的,不能擋住同步本體。 +func (g *roundGuard) persistLive(fs *folderSnapshot) { + if g == nil { + return + } + g.mu.Lock() + path, closed := g.statusPath, g.closed + g.mu.Unlock() + if path == "" || closed { + return + } + _ = updateSyncStatus(path, func(s *SyncStatus) bool { + g.mu.Lock() + defer g.mu.Unlock() + if g.closed { + return false + } + live := g.live + live.UpdatedAt = time.Now().Format(time.RFC3339) + if live.Waiting != nil { + w := *live.Waiting + live.Waiting = &w + } + s.InRound = &live + // 這一輪到目前為止等太久的事(同 Stalls 的語意:只講這一輪,不帶上一輪的)。 + s.Stalls = append([]StalledCall(nil), g.stalls...) + if fs != nil { + if s.FolderProgress == nil { + s.FolderProgress = map[string]SyncProgress{} + } + // 首頁那行總量=各資料夾加總+讀不了的檔。換掉這個資料夾的那一份, + // 其他資料夾與 Unreadable 原樣保留——不另算一套總量。 + if old, ok := s.FolderProgress[fs.root]; ok { + s.Progress = progressMinus(s.Progress, old) + } + s.FolderProgress[fs.root] = fs.progress + s.Progress = s.Progress.Add(fs.progress) + } + return true + }) +} + +// progressMinus=SyncProgress.Add 的反向(只在換掉某個資料夾那一份時用)。 +func progressMinus(p, o SyncProgress) SyncProgress { + return SyncProgress{ + Total: p.Total - o.Total, + Done: p.Done - o.Done, + Pending: p.Pending - o.Pending, + Stuck: p.Stuck - o.Stuck, + Unreadable: p.Unreadable - o.Unreadable, + Failing: p.Failing - o.Failing, + } +} diff --git a/live_status_test.go b/live_status_test.go new file mode 100644 index 0000000..d807f77 --- /dev/null +++ b/live_status_test.go @@ -0,0 +1,220 @@ +package collector + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "testing" + "time" +) + +// TestStatusAndTreeMoveWhileRoundIsStillRunning 守住 `inkstone/arcrun-rag#200` 的主病: +// **一輪還沒跑完,畫面看的兩份檔就要反映已經送上去的份數。** +// +// 2026-09-13 實撞:ISEP 送上雲端 11 份,status.json 從 12:00 起沒寫過、 +// folder-trees.json 的 synced 全是 0 ⇒ 畫面每層 0 / N。 +// +// 驗法:假雲端在收到**第二份**筆記時(第一份已經送完、這一輪還在跑), +// 直接去讀磁碟上的 status.json 與 folder-trees.json——那就是小幫手此刻讀得到的東西。 +func TestStatusAndTreeMoveWhileRoundIsStillRunning(t *testing.T) { + root := t.TempDir() + for _, n := range []string{"a", "b", "c"} { + if err := os.WriteFile(filepath.Join(root, n+".md"), []byte("# 原稿 "+n+" 內容"), 0o644); err != nil { + t.Fatal(err) + } + } + + nameRe := regexp.MustCompile(`檔名:([^)]+))`) + restoreGemma := gemmaStub(t, func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + name := "a" + if m := nameRe.FindStringSubmatch(string(body)); m != nil { + name = m[1] + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "candidates": []map[string]any{{ + "content": map[string]any{"parts": []map[string]any{{"text": cardFixture(name, "測試")}}}, + }}, + }) + }) + defer restoreGemma() + + manifestPath := filepath.Join(t.TempDir(), "m.json") + statusPath := StatusFilePath(manifestPath) + treePath := FolderTreeStorePath(manifestPath) + + var mu sync.Mutex + cards := 0 + var midStatus SyncStatus + var midStatusErr error + var midTree FolderTreeStore + // 只數**這三份檔自己的**筆記:資料夾總覽卡、目錄索引卡也走同一條收卡路, + // 而且排在逐檔之前送——數到它們就會在第一份檔都還沒送時拍照。 + docCard := regexp.MustCompile(`"path":"[abc]\.md"`) + cypher := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + if strings.Contains(string(body), `"card_content"`) && docCard.Match(body) { + mu.Lock() + cards++ + if cards == 2 { + midStatus, midStatusErr = LoadSyncStatus(statusPath) + midTree, _ = LoadFolderTreeStore(treePath) + } + mu.Unlock() + } + _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) + })) + defer cypher.Close() + + cfg := &DirectConfig{ + WatchFolders: []string{root}, + Manifest: manifestPath, + CypherURL: cypher.URL, Namespace: "demo", APIKey: "demo", + Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test", + CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio, + } + results, _, _ := RunDirectOnce(cfg, false) + + mu.Lock() + defer mu.Unlock() + if cards < 3 { + t.Fatalf("三份都應該送上去,實際收到 %d 張卡:results=%+v", cards, results) + } + + // ── 一輪途中(第一份已送完)── + if midStatusErr != nil { + t.Fatalf("🔴 一輪途中讀不到 status.json:%v", midStatusErr) + } + if midStatus.InRound == nil { + t.Fatalf("🔴 一輪途中 status.json 沒有 in_round——畫面講不出做到哪") + } + if got := midStatus.InRound.Ingested; got != 1 { + t.Errorf("一輪途中 in_round.ingested 應為 1(第一份已送完),got %d", got) + } + if midStatus.InRound.Folder != root { + t.Errorf("in_round.folder 應為 %q,got %q", root, midStatus.InRound.Folder) + } + if midStatus.InRound.Step != stepIngestCard.Name { + t.Errorf("正在送第二份時 in_round.step 應為 %q,got %q", stepIngestCard.Name, midStatus.InRound.Step) + } + if got := midStatus.FolderProgress[root].Done; got != 1 { + t.Errorf("🔴 一輪途中 folder_progress 的已同步應為 1,got %d(%+v)", got, midStatus.FolderProgress[root]) + } + if got := midStatus.Progress.Done; got != 1 { + t.Errorf("一輪途中首頁總量的已送上去應為 1,got %d", got) + } + if got := treeSynced(midTree, root); got != 1 { + t.Errorf("🔴 一輪途中 folder-trees.json 的已同步應為 1(這就是 leo 看到 0 / N 的那個數字),got %d", got) + } + + // ── 收工之後 ── + st, err := LoadSyncStatus(statusPath) + if err != nil { + t.Fatal(err) + } + if st.InRound != nil { + t.Errorf("收工之後 in_round 應該消失,got %+v", st.InRound) + } + if got := st.FolderProgress[root].Done; got != 3 { + t.Errorf("收工之後已同步應為 3,got %d", got) + } + final, err := LoadFolderTreeStore(treePath) + if err != nil { + t.Fatal(err) + } + // 🔴 以前收工合併用的是開工前那棵樹 ⇒ 就算整輪跑完,這裡也還是 0。 + if got := treeSynced(final, root); got != 3 { + t.Errorf("🔴 收工之後 folder-trees.json 的已同步應為 3,got %d(收工合併還在用開工前那棵樹)", got) + } +} + +func treeSynced(s FolderTreeStore, root string) int { + t, ok := s.Trees[root] + if !ok { + return -1 + } + n := 0 + for _, node := range t.Nodes { + n += node.SyncedFiles + } + return n +} + +// TestWaitingIsWrittenWhileStillWaiting 守住驗收條件③「卡住時寫得出原因」: +// 「還在等」以前只印到 stdout(collector.log),status.json 一個字都沒有。 +func TestWaitingIsWrittenWhileStillWaiting(t *testing.T) { + origNotice := stallNoticeEvery + stallNoticeEvery = 30 * time.Millisecond + defer func() { stallNoticeEvery = origNotice }() + + path := filepath.Join(t.TempDir(), "status.json") + g := newRoundGuard() + g.announce = nil + g.beginRound(path, time.Now()) + g.enterFolder("kb.example", "/Users/someone/ISEP") + + cfg := &DirectConfig{CypherURL: "https://kb.example", guard: g} + gate := cfg.openGate(stepFolderCard) + + var st SyncStatus + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + st, _ = LoadSyncStatus(path) + if st.InRound != nil && st.InRound.Waiting != nil { + break + } + time.Sleep(10 * time.Millisecond) + } + if st.InRound == nil || st.InRound.Waiting == nil { + gate.release() + t.Fatalf("🔴 等了一陣子,status.json 仍沒有寫出「卡在哪一步」:%+v", st.InRound) + } + if st.InRound.Waiting.Step != stepFolderCard.Name { + t.Errorf("waiting.step 應為 %q,got %q", stepFolderCard.Name, st.InRound.Waiting.Step) + } + if st.InRound.Folder != "/Users/someone/ISEP" || st.InRound.Account != "kb.example" { + t.Errorf("卡住時要講得出是哪個知識庫的哪個資料夾,got account=%q folder=%q", + st.InRound.Account, st.InRound.Folder) + } + + gate.release() + st, _ = LoadSyncStatus(path) + if st.InRound == nil || st.InRound.Waiting != nil { + t.Errorf("那件事回來之後「還在等」要拿掉,got %+v", st.InRound) + } + + // 收工封口:晚到的「還在等」不准把收工寫好的那份蓋回「同步中」。 + g.finishRound() + if err := SaveSyncStatus(path, SyncStatus{LastSync: "2026-09-14T00:00:00+08:00"}); err != nil { + t.Fatal(err) + } + g.noteWaiting(StalledCall{Step: "晚到的", Note: "晚到的"}) + st, _ = LoadSyncStatus(path) + if st.InRound != nil { + t.Errorf("🔴 收工之後的途中寫入把 in_round 寫回去了:%+v", st.InRound) + } +} + +// TestLiveWriteNeverWipesUnreadableStatus:狀態檔讀得到卻解不開時,途中寫入一律不動它 +// ——拿零值改一格再寫回去,等於把上一輪所有的數字整份抹掉。 +func TestLiveWriteNeverWipesUnreadableStatus(t *testing.T) { + path := filepath.Join(t.TempDir(), "status.json") + garbage := []byte("{ 這不是 JSON") + if err := os.WriteFile(path, garbage, 0o644); err != nil { + t.Fatal(err) + } + g := newRoundGuard() + g.announce = nil + g.beginRound(path, time.Now()) + g.fileFinished("/x", "ingested", SyncProgress{Total: 1, Done: 1}) + got, _ := os.ReadFile(path) + if string(got) != string(garbage) { + t.Errorf("解不開的狀態檔被覆寫了:%q", got) + } +} diff --git a/stallguard.go b/stallguard.go index a927049..286ed7a 100644 --- a/stallguard.go +++ b/stallguard.go @@ -126,6 +126,11 @@ type roundGuard struct { stalls []StalledCall // announce=把「還在等」播出去。測試會換掉它(預設寫 stdout)。 announce func(StalledCall) + + // ── `inkstone/arcrun-rag#200`:一輪途中把「做到哪」寫進 status.json(見 live_status.go)── + statusPath string // 空=不寫(dry-run、測試直接呼叫低層函式) + live RoundProgress // 此刻做到哪 + closed bool // 收工了,途中寫入一律停手 } func newRoundGuard() *roundGuard { @@ -256,6 +261,8 @@ func (g *roundGuard) strike(host string, step callStep, waited time.Duration) st announce(StalledCall{Account: host, Step: step.Name, WaitedSec: int(waited.Seconds()), Skipped: tripped, Note: note}) } + // #200:以前這份清單要等整輪收工才寫進 status.json——而收工可能是幾小時以後。 + g.persistStalls() // 🔴 把連線池裡那條可能已經死掉的連線丟掉。 // 實撞的形狀(2026-08-28):行程活著、CPU 0%、`lsof` 一條 TCP 都沒有, // 而堆疊停在 HTTP/2 的 roundTrip——連線在作業系統那層已經沒了, @@ -314,6 +321,8 @@ func (c *DirectConfig) openGate(step callStep) *callGate { if gate.skip != "" { return gate // 已經跳閘:不必播報,呼叫端會立刻回頭 } + // #200:每一發雲端呼叫都經過這裡 ⇒ 「現在正在做哪件事」在這裡記一次就全部接上。 + c.guard.stepStarted(step, gate.started) go gate.keepTalking() return gate } @@ -335,13 +344,16 @@ func (gate *callGate) keepTalking() { gate.g.mu.Lock() announce := gate.g.announce gate.g.mu.Unlock() - if announce != nil { - announce(StalledCall{ - Account: gate.host, Step: gate.step.Name, WaitedSec: waited, - Note: fmt.Sprintf("還在等知識庫「%s」回覆「%s」,已經等了 %d 秒。", - gate.host, gate.step.Name, waited), - }) + call := StalledCall{ + Account: gate.host, Step: gate.step.Name, WaitedSec: waited, + Note: fmt.Sprintf("還在等知識庫「%s」回覆「%s」,已經等了 %d 秒。", + gate.host, gate.step.Name, waited), } + if announce != nil { + announce(call) + } + // #200:以前這句只印到 stdout(collector.log 裡看得到),畫面與 status.json 一個字都沒有。 + gate.g.noteWaiting(call) } } } @@ -367,7 +379,7 @@ func (gate *callGate) trace(outcome string) { // release 停掉播報並放掉 context。**一定要 defer**:context 活到呼叫端讀完回應 // 之後才釋放,所以不能在讀 body 之前呼叫。 func (gate *callGate) release() { - gate.once.Do(func() { close(gate.stop); gate.trace("done") }) + gate.once.Do(func() { close(gate.stop); gate.trace("done"); gate.g.stepDone(gate.step) }) gate.cancel() } diff --git a/sync_status.go b/sync_status.go index 9762a92..31c9382 100644 --- a/sync_status.go +++ b/sync_status.go @@ -7,8 +7,34 @@ import ( "os" "path/filepath" "strings" + "sync" + "time" ) +// RoundProgress=**這一輪還沒跑完時**,做到哪裡了(`inkstone/arcrun-rag#200`)。 +// +// 🔴 為什麼要有:status.json 以前**只在整輪收工時**寫一次。對 leo 真正的設定 +// (三個帳號、好幾千個檔、每發雲端呼叫 24〜57 秒)一輪是好幾個小時 +// ⇒ 2026-09-13 實撞:12:00 之後 status.json 一次都沒寫過,而同一段時間 ISEP 的 +// manifest 已經記下 11 份送上雲端、AR-Mira 查得到卡——**畫面卻是每層 0 / N**, +// leo:「它沒有跑任何一個東西」。那不是沒跑,是**跑了但一個字都沒回寫**。 +// +// ⇒ 一輪途中每開始一件雲端呼叫、每處理完一份檔、每次「還在等」,都把這一格寫進去。 +// 收工時整份 status.json 照舊重寫,這一格自然消失(收工寫的那份不帶它)。 +// 行程在途中被殺掉時它會留在檔案裡——App 只在 collector 真的在跑一輪時才讀它。 +type RoundProgress struct { + StartedAt string `json:"started_at"` // 這一輪什麼時候開始的 + UpdatedAt string `json:"updated_at,omitempty"` // 最後一次寫進來的時間(久沒更新=真的停住了) + Account string `json:"account,omitempty"` // 正在處理哪個知識庫(主機名,與 AccountDetails 同一把 key) + Folder string `json:"folder,omitempty"` // 正在處理哪個看守資料夾(絕對路徑;空=帳號層的事) + Step string `json:"step,omitempty"` // 正在做哪件事(白話,同 callStep.Name) + StepSince string `json:"step_since,omitempty"` // 那件事從什麼時候開始等 + Ingested int `json:"ingested"` // 這一輪到目前為止送上雲端的份數 + Failed int `json:"failed"` // 這一輪到目前為止沒送成功的份數 + // Waiting=那件事已經等超過 stallNoticeEvery 還沒回來——「卡在哪一步」就是它。 + Waiting *StalledCall `json:"waiting,omitempty"` +} + // AccountSyncStatus 彙總單一帳號的每輪同步結果(t104 多帳號看守)。 // key in SyncStatus.AccountDetails = instanceHostOf(cypher_url)。 type AccountSyncStatus struct { @@ -169,6 +195,10 @@ type SyncStatus struct { // 與 SkippedDocs 同族:每輪重算的現況快照,不進 CarryForwardActivity //(上一輪等太久不代表這一輪也在等,帶下來就會變成一個永遠擦不掉的警告)。 Stalls []StalledCall `json:"stalls,omitempty"` + + // InRound=這一輪還沒跑完時做到哪(`inkstone/arcrun-rag#200`,見 RoundProgress)。 + // 收工時寫的那份不帶它 ⇒ 非空只可能是「一輪正在跑」或「上一輪跑到一半行程被殺掉」。 + InRound *RoundProgress `json:"in_round,omitempty"` } // FolderPlanStatus=某個看守資料夾這一輪用了什麼收檔策略、據此少收了什麼 @@ -210,13 +240,86 @@ func StatusFilePath(manifestPath string) string { return filepath.Join(filepath.Dir(manifestPath), "status.json") } +// statusFileMu=status.json 同一時間只有一個人在寫(`inkstone/arcrun-rag#200`)。 +// +// 以前只有收工那一次在寫,不需要鎖;現在一輪途中也會寫,而且「還在等」是從 +// 另一條 goroutine(callGate.keepTalking)寫的 ⇒ 兩邊各自「讀出來改一格再寫回去」 +// 會互相蓋掉對方剛寫的東西。 +var statusFileMu sync.Mutex + // SaveSyncStatus 寫入(覆蓋)狀態檔。失敗只印 stderr,不擋看守本體。 func SaveSyncStatus(path string, s SyncStatus) error { + statusFileMu.Lock() + defer statusFileMu.Unlock() + return saveSyncStatusLocked(path, s) +} + +func saveSyncStatusLocked(path string, s SyncStatus) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } data, _ := json.MarshalIndent(s, "", " ") - return os.WriteFile(path, data, 0o644) + return writeFileAtomic(path, data, 0o644) +} + +// updateSyncStatus 在鎖裡把狀態檔讀出來、交給 fn 改、再寫回去。fn 回 false=不寫。 +// +// 🔴 讀得到檔案但**解不開**時一律不寫:拿零值改一格再寫回去,等於把上一輪 +// 所有的數字(資料夾進度、帳號狀態、額度冷卻)整份抹掉——比不更新更糟。 +func updateSyncStatus(path string, fn func(*SyncStatus) bool) error { + statusFileMu.Lock() + defer statusFileMu.Unlock() + s, err := LoadSyncStatus(path) + if err != nil && !os.IsNotExist(err) { + return err + } + if !fn(&s) { + return nil + } + return saveSyncStatusLocked(path, s) +} + +// writeFileAtomic 先寫暫存檔再改名蓋過去——讀的人永遠讀到一份完整的檔。 +// +// 🔴 為什麼現在才需要(`inkstone/arcrun-rag#200`):小幫手每秒讀一次 status.json, +// 以前一輪只寫一次,撞到「寫到一半被讀」的機率小到沒人遇過;現在一輪途中會寫很多次。 +// 讀到半份 JSON 的後果是那一秒畫面整個掉回「沒有紀錄」。 +// +// Windows 上讀的人剛好開著檔案時改名會失敗 ⇒ 重試幾次,還是不行就退回直接覆寫 +//(寧可偶爾讓讀的人撞到半份,也不能讓狀態檔整輪寫不進去)。 +func writeFileAtomic(path string, data []byte, perm os.FileMode) error { + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*.tmp") + if err != nil { + lastErr = err + break + } + name := tmp.Name() + _, werr := tmp.Write(data) + cerr := tmp.Close() + if werr == nil && cerr == nil { + _ = os.Chmod(name, perm) + if rerr := os.Rename(name, path); rerr == nil { + return nil + } else { + lastErr = rerr + } + } else if werr != nil { + lastErr = werr + } else { + lastErr = cerr + } + _ = os.Remove(name) + time.Sleep(20 * time.Millisecond) + } + if err := os.WriteFile(path, data, perm); err != nil { + if lastErr != nil { + return lastErr + } + return err + } + return nil } // CarryForwardActivity 決定「最近一次有做事」那三個欄位的值。