sync: collector/ 同步自 inkstone/arcrun-rag@1c31ac2(桌面小幫手 0.18.46)
This commit is contained in:
+97
-26
@@ -46,31 +46,49 @@ type callStep struct {
|
||||
Budget time.Duration
|
||||
}
|
||||
|
||||
// 🔴 **每一個數字都是量出來的,不是推出來的**(2026-08-28 第三輪的教訓)。
|
||||
//
|
||||
// 第一輪我用「零 LLM 的機械收口應該很快」這個**推論**給了 60 秒,結果實測分佈是:
|
||||
//
|
||||
// 送出一份筆記 33.2 39.9 42.4 43.2 43.3 43.5 43.6 44.2 44.6 44.6
|
||||
// 45.5 53.1 53.3 53.6 54.5 54.8 57.3 ← 真實尾巴到 57 秒
|
||||
// 60.0 60.0 60.0 ← **這三個是我自己的上限把它剪斷**,不是雲端的耗時
|
||||
//
|
||||
// 後果不是「多等一下」:那 2 個被剪斷的檔判成 failed,斷路器跟著跳,
|
||||
// **後面 9 個檔全部被跳過**,而畫面對使用者說的是「知識庫現在沒有回應」
|
||||
// ——它一路都在回應,只是慢。**我的推論把一台健康的機器講成壞掉的。**
|
||||
//
|
||||
// ⇒ 判準改成:**上限只用來擋「真的不回來」,不用來表達「我認為它該多快」。**
|
||||
// 每一格都給實測最大值的數倍餘裕;要調它,先去量(`ARCRUN_TRACE=1`)。
|
||||
const (
|
||||
// llmCallBudget=那一發會在雲端同步跑完 AI 萃取才回來(原本 directHTTP 那把
|
||||
// 300 秒就是為它放寬的),維持不變。
|
||||
llmCallBudget = 300 * time.Second
|
||||
// plainCallBudget=零 LLM 的機械收口(收卡/下架/登記結構)。這不是「把 300
|
||||
// 調小」——這些請求從來就不跑模型,給它們五分鐘只是讓卡住的代價變大。
|
||||
plainCallBudget = 60 * time.Second
|
||||
// writeBudget=任何會把知識寫進雲端的呼叫(收卡/直送萃取/萃取/修出處/下架/收回)。
|
||||
// 實測最大 57.3 秒(送出一份筆記)⇒ 300 秒 ≈ 5 倍餘裕。
|
||||
// 這也正是改版前 directHTTP 用的數字——事實證明它本來就沒選錯。
|
||||
writeBudget = 300 * time.Second
|
||||
// registerBudget=只寫登記簿、不產生知識的呼叫(資料夾總覽/目錄索引/資料夾結構)。
|
||||
// 實測 1.8〜26.3 秒 ⇒ 180 秒 ≈ 7 倍餘裕。
|
||||
registerBudget = 180 * time.Second
|
||||
// probeBudget=「這台還活著嗎」的唯讀探問。實測 0.0〜1 秒。
|
||||
// 它短是因為**它問的就是活著沒**,不是因為我覺得它該多快。
|
||||
probeBudget = 20 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
stepIngestDoc = callStep{"整理一份文件", llmCallBudget}
|
||||
stepIngestCard = callStep{"送出一份筆記", plainCallBudget}
|
||||
stepExtractDoc = callStep{"請雲端讀一份文件", llmCallBudget}
|
||||
stepRepairOrigin = callStep{"更新舊筆記的原文位置", plainCallBudget}
|
||||
stepTakedown = callStep{"把刪掉的檔案從雲端下架", plainCallBudget}
|
||||
stepRetire = callStep{"收回這個資料夾在雲端的資料", plainCallBudget}
|
||||
stepFolderTree = callStep{"回報資料夾結構", plainCallBudget}
|
||||
stepInventory = callStep{"送出資料夾總覽", plainCallBudget}
|
||||
stepFolderCard = callStep{"送出目錄索引", plainCallBudget}
|
||||
stepIngestDoc = callStep{"整理一份文件", writeBudget}
|
||||
stepIngestCard = callStep{"送出一份筆記", writeBudget}
|
||||
stepExtractDoc = callStep{"請雲端讀一份文件", writeBudget}
|
||||
stepRepairOrigin = callStep{"更新舊筆記的原文位置", writeBudget}
|
||||
stepTakedown = callStep{"把刪掉的檔案從雲端下架", writeBudget}
|
||||
stepRetire = callStep{"收回這個資料夾在雲端的資料", writeBudget}
|
||||
stepFolderTree = callStep{"回報資料夾結構", registerBudget}
|
||||
stepInventory = callStep{"送出資料夾總覽", registerBudget}
|
||||
stepFolderCard = callStep{"送出目錄索引", registerBudget}
|
||||
// stepProbeAI=每輪每個帳號的第一發(探測雲端 AI 通了沒)。它同時是最早
|
||||
// 能認出「這個帳號今天不回應」的位置——認出來,這個帳號其餘的工作就都省了。
|
||||
stepProbeAI = callStep{"確認雲端 AI 可不可以用", 20 * time.Second}
|
||||
stepProbeAI = callStep{"確認雲端 AI 可不可以用", probeBudget}
|
||||
// stepCloudAudit=跟雲端核對「先前送過的檔案還在不在」。唯讀查詢,
|
||||
// 一輪可能連打 cloudAuditBatch 發——正是「一發卡住的代價會被乘上批次大小」的例子。
|
||||
stepCloudAudit = callStep{"跟雲端核對哪些檔案還在", 20 * time.Second}
|
||||
stepCloudAudit = callStep{"跟雲端核對哪些檔案還在", probeBudget}
|
||||
)
|
||||
|
||||
// stallNoticeEvery=等多久開口說一次「還在等」。變數而非常數:測試要把它調快。
|
||||
@@ -95,7 +113,8 @@ type StalledCall struct {
|
||||
// accountStall=某個帳號在這一輪的「等待帳」。
|
||||
type accountStall struct {
|
||||
strikes int
|
||||
skip string // 非空=這一輪不再打它,內容是給使用者看的理由
|
||||
answered bool // 這一輪它**成功回應過** ⇒ 不准說它「沒有回應」
|
||||
skip string // 非空=這一輪不再打它,內容是給使用者看的理由
|
||||
}
|
||||
|
||||
// roundGuard 一輪一份,掛在 DirectConfig 上。
|
||||
@@ -173,6 +192,29 @@ func (g *roundGuard) Stalls() []StalledCall {
|
||||
return append([]StalledCall(nil), g.stalls...)
|
||||
}
|
||||
|
||||
// succeeded 記一發「成功回來了」。
|
||||
//
|
||||
// 🔴 沒有這個函式的話,`strikes` 只增不減 ⇒ **一輪裡任意兩發逾時就會跳閘**,
|
||||
// 而訊息上寫的是「**連續** 2 件事都等不到回覆」——那兩個字就是假的。
|
||||
// 2026-08-28 實撞:一輪成功送出 8 份筆記、中間兩發被我自己過短的上限剪斷,
|
||||
// 斷路器就跳了,後面 9 個檔全被跳過。
|
||||
func (g *roundGuard) succeeded(host string) {
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
a := g.accounts[host]
|
||||
if a == nil {
|
||||
a = &accountStall{}
|
||||
g.accounts[host] = a
|
||||
}
|
||||
a.answered = true
|
||||
if a.skip == "" {
|
||||
a.strikes = 0 // 已經跳閘就不再回頭;沒跳閘的話,成功一次就重新計數
|
||||
}
|
||||
}
|
||||
|
||||
// strike 記一發「等到超時」,必要時讓這個帳號這一輪停手。回傳給使用者看的那句話。
|
||||
func (g *roundGuard) strike(host string, step callStep, waited time.Duration) string {
|
||||
g.mu.Lock()
|
||||
@@ -182,16 +224,27 @@ func (g *roundGuard) strike(host string, step callStep, waited time.Duration) st
|
||||
g.accounts[host] = a
|
||||
}
|
||||
a.strikes++
|
||||
// 🔴 **它這一輪回應過的話,就不准說它「沒有回應」。**
|
||||
// 2026-08-28 實撞:youlin 一路都在回應(成功送出 8 份筆記),只是慢,
|
||||
// 而畫面對使用者說「知識庫現在沒有回應」——那是把一台健康的機器講成壞掉的。
|
||||
// 使用者會照著這句話去查網路、去重裝,全都白費。
|
||||
how := "沒有回應"
|
||||
if a.answered {
|
||||
how = "回得太慢"
|
||||
}
|
||||
tripped := a.strikes >= stallStrikesBeforeSkip && a.skip == ""
|
||||
if tripped {
|
||||
a.skip = fmt.Sprintf(
|
||||
"知識庫「%s」現在沒有回應(連續 %d 件事都等不到回覆),這一輪先跳過它;"+
|
||||
"其他資料夾照常同步,等它回來之後會自動恢復。",
|
||||
host, a.strikes)
|
||||
"知識庫「%s」現在%s(連續 %d 件事都等不到回覆),這一輪先跳過它;"+
|
||||
"其他資料夾照常同步,稍後會自動恢復。",
|
||||
host, how, a.strikes)
|
||||
}
|
||||
note := fmt.Sprintf("「%s」等了 %d 秒,知識庫「%s」還是沒有回應;這一輪先跳過,"+
|
||||
"其他資料夾照常同步,等它回來之後會自動恢復。",
|
||||
step.Name, int(waited.Seconds()), host)
|
||||
// 🔴 結尾那句「稍後會自動恢復」不是修辭,它是 explainsWhySkipped 認得的識別字
|
||||
// (sync_status.go)。少了它,這些被跳過的檔會**連一句原因都沒有地從畫面消失**。
|
||||
// 有測試守著(TestSkipMessagesAlwaysExplainThemselves),改措辭前先看那條。
|
||||
note := fmt.Sprintf("「%s」等了 %d 秒,知識庫「%s」%s;這一輪先跳過,"+
|
||||
"其他資料夾照常同步,稍後會自動恢復。",
|
||||
step.Name, int(waited.Seconds()), host, how)
|
||||
g.stalls = append(g.stalls, StalledCall{
|
||||
Account: host, Step: step.Name, WaitedSec: int(waited.Seconds()),
|
||||
Skipped: tripped, Note: note,
|
||||
@@ -296,13 +349,31 @@ func (gate *callGate) keepTalking() {
|
||||
// blocked 回「這一輪已經不打這個帳號了」的理由;空=可以打。
|
||||
func (gate *callGate) blocked() string { return gate.skip }
|
||||
|
||||
// traceCalls=把每一發的「哪件事/打誰/花了多久」印到 stderr(`ARCRUN_TRACE=1` 開)。
|
||||
//
|
||||
// 🔴 為什麼要留這個:2026-08-28 第一次修完之後,端到端症狀還在,而現場能拿到的只有
|
||||
// 一張 SIGQUIT 堆疊——堆疊只說得出「此刻卡在哪一發」,**說不出「這一輪的時間花到哪去了」**。
|
||||
// 兩個人為此各自推測了一輪。有這一行就不必推測:跑一次,時間分佈直接列出來。
|
||||
var traceCalls = os.Getenv("ARCRUN_TRACE") != ""
|
||||
|
||||
func (gate *callGate) trace(outcome string) {
|
||||
if !traceCalls {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[trace] %7.1fs %-22s %-8s %s\n",
|
||||
time.Since(gate.started).Seconds(), gate.step.Name, outcome, gate.host)
|
||||
}
|
||||
|
||||
// release 停掉播報並放掉 context。**一定要 defer**:context 活到呼叫端讀完回應
|
||||
// 之後才釋放,所以不能在讀 body 之前呼叫。
|
||||
func (gate *callGate) release() {
|
||||
gate.once.Do(func() { close(gate.stop) })
|
||||
gate.once.Do(func() { close(gate.stop); gate.trace("done") })
|
||||
gate.cancel()
|
||||
}
|
||||
|
||||
// ok 記一發成功(見 roundGuard.succeeded)。與 record 成對,兩者呼叫端都要顧到。
|
||||
func (gate *callGate) ok() { gate.g.succeeded(gate.host) }
|
||||
|
||||
// record 記一發失敗。只有「等到超時」才進帳(連線被拒之類的錯是**很快**回來的,
|
||||
// 不是本票要修的病,記進去只會讓正常的斷網把帳號誤判成沒有回應)。
|
||||
// 回傳要交給呼叫端的錯誤:逾時換成白話,其餘原樣。
|
||||
@@ -314,7 +385,7 @@ func (gate *callGate) record(err error) error {
|
||||
}
|
||||
waited := time.Since(gate.started)
|
||||
if gate.g == nil {
|
||||
return fmt.Errorf("「%s」等了 %d 秒,知識庫「%s」沒有回應;稍後會自動再試。",
|
||||
return fmt.Errorf("「%s」等了 %d 秒,知識庫「%s」沒有回應;稍後會自動恢復。",
|
||||
gate.step.Name, int(waited.Seconds()), gate.host)
|
||||
}
|
||||
return errors.New(gate.g.strike(gate.host, gate.step, waited))
|
||||
|
||||
Reference in New Issue
Block a user