不再把開發用 template 塞進使用者資料夾;上游錯誤不再被退避訊息吞掉

## leo 三條裁決,逐條落實
① 「**別人的錯誤一律要顯示給用戶看,不然就會變成我的錯誤,導致客服**」
   → manifest entry 新增 `LastError`(存**原文**),退避訊息改成
     「上次失敗(第 N 次),58m 後重試|**原因:**<上游原文>」。
     成功後清空,不留舊錯誤嚇人。
     測試 `TestUpstreamErrorSurvivesBackoff` 從上游錯誤字串出發,
     驗它活到給使用者看的那句話;反向驗證拿掉即紅。

② 「**拿來開發一般人用不到的根本別安裝**」
   → daemon 不再代裝 system-dev template(`CLAUDE.md`/`scripts/`/`system-dev/`,37 檔)。
     兩層傷害:把人家資料夾弄亂 + 那些檔被當知識吃進去
     ⇒ 知識庫長出 `kb`/`t195-watch` 這種不是使用者內容的庫(leo 實撞)。
     `template-install` 子命令保留,開發者情境不受影響。

③ 「**它也不能只看隱藏檔內,因為 template 在我所有的 repo 裡不是隱藏的**」
   → 排除規則用**路徑身分**(`TemplateOwns()`,來源是內嵌 templatefs 的實際路徑
     + `system-dev/`・`scripts/` 目錄前綴),**不是**「有沒有以點開頭」。
     測試刻意把 template 檔放成**不隱藏**(就像 leo 的 repo),驗它仍被排除;反向驗證即紅。
     這些檔也不計進「有 N 個檔案沒有被整理」——那欄是給使用者看他自己的檔案的。

## 順帶修 leo 截圖上的重複
「有 2 個檔案沒有被整理」底下 `scripts/sdd-active-check.sh` 出現兩次
——多帳號時同一個資料夾被掃多輪、每輪都 append。已去重。

## 殘項(誠實)
App 端失敗卡還沒接上這條線 ⇒ **畫面尚未真的顯示原因**。未打包、未送達。
This commit is contained in:
2026-08-06 21:33:37 +08:00
parent 6e0b65a2bb
commit c7675a10a0
7 changed files with 217 additions and 26 deletions
+41 -19
View File
@@ -648,6 +648,18 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
// 排序=畫面每輪穩定(map 迭代順序隨機,不排的話清單會自己跳動)。
st.SkippedOtherCount = skippedOther
// 少量時點名(maxOtherNames 個以內)——leo 08-06 封測:只報「1 個」等於沒說。
// 🔴 leo 2026-08-06 截圖:「有 2 個檔案沒有被整理」底下同一個檔名出現**兩次**。
// 真兇:多帳號時同一個資料夾會被掃很多輪,每輪都把檔名 append 進來。
// ⇒ 去重(順帶讓總數與清單一致,不然使用者會覺得我們在亂數)。
seen := map[string]bool{}
uniq := skippedOtherNames[:0]
for _, n := range skippedOtherNames {
if !seen[n] {
seen[n] = true
uniq = append(uniq, n)
}
}
skippedOtherNames = uniq
sort.Strings(skippedOtherNames)
if len(skippedOtherNames) > maxOtherNames {
skippedOtherNames = skippedOtherNames[:maxOtherNames]
@@ -744,13 +756,24 @@ func retrySkipReason(m *Manifest, path string, now int64) string {
return "暫時跳過"
}
if e.FailCount >= MaxFailBeforeSkip {
return fmt.Sprintf("連續失敗 %d 次,已暫停自動重試(改檔或按「立刻同步」會再試)", e.FailCount)
msg := fmt.Sprintf("連續失敗 %d 次,已暫停自動重試(改檔或按「立刻同步」會再試)", e.FailCount)
if e.LastError != "" {
msg += "|原因:" + e.LastError
}
return msg
}
wait := e.NextRetry - now
if wait < 0 {
wait = 0
}
return fmt.Sprintf("上次失敗(第 %d 次),%s 後重試", e.FailCount, (time.Duration(wait) * time.Second).String())
msg := fmt.Sprintf("上次失敗(第 %d 次),%s 後重試", e.FailCount, (time.Duration(wait) * time.Second).String())
// 🔴 leo 2026-08-06:「別人的錯誤一律要顯示給用戶看,不然就會變成我的錯誤,導致客服」。
// 只講「幾分鐘後重試」等於把上游的錯(Cloudflare 額度用完、檔案本身沒文字)
// 藏起來 ⇒ 使用者只看得到我們在失敗。真因要一路帶到畫面上。
if e.LastError != "" {
msg += "|原因:" + e.LastError
}
return msg
}
// accountsConnected 回答「這份設定連得上知識庫嗎」——**根層有 或 任一帳號有**。
@@ -822,7 +845,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes
content, rerr := os.ReadFile(full)
if rerr != nil {
res.Status, res.Error = "failed", "讀檔失敗:"+rerr.Error()
m.MarkFailed(ev.Path, now) // t195:讀不到的檔也退避(權限/被鎖/壞掉的外接碟)
m.MarkFailed(ev.Path, now, res.Error) // t195:讀不到的檔也退避(權限/被鎖/壞掉的外接碟)
results = append(results, res)
exit = 1
continue
@@ -858,7 +881,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes
res.Status, res.Error = "failed", "本地萃取失敗:"+xerr.Error()
// t195:萃取階段失敗同樣要記退避。**這條路徑比上傳更早**,
// 漏記的話(連不上知識庫、金鑰壞、模型錯)照樣每輪重撞。
m.MarkFailed(ev.Path, now)
m.MarkFailed(ev.Path, now, res.Error)
results = append(results, res)
exit = 1
continue
@@ -894,7 +917,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes
} else {
// t195:記下失敗並排定退避,否則下輪又把它當新檔重試
//(實撞:1387 輪 × 11 小時全在撞同一面 401 的牆,還拖住整個佇列)。
m.MarkFailed(ev.Path, now)
m.MarkFailed(ev.Path, now, res.Error)
exit = 1
}
results = append(results, res)
@@ -1027,20 +1050,19 @@ func runDirect(args []string) int {
return exit
}
// 四步定稿第 1 步:daemon 代裝 template——常駐看守前確保每根都鋪好(冪等,不覆寫既有檔)。
// dry-run/--once 測試情境不代裝(不留副作用),由 template-install 子命令顯式做。
if !*once && !*dryRun {
for _, root := range cfg.Folders() {
if TemplateInstalled(root) {
continue
}
if res, ierr := InstallTemplate(root); ierr != nil {
fmt.Fprintf(os.Stderr, "template 代裝失敗(%s):%v\n", root, ierr)
} else {
fmt.Fprintf(os.Stderr, "template v%s 已鋪進 %s(新 %d 檔)\n", res.Version, root, len(res.Installed))
}
}
}
// 🔴 2026-08-06 leo 拍板:**「拿來開發一般人用不到的根本別安裝」**
//
// 原本 daemon 會把 system-dev template`CLAUDE.md``scripts/``system-dev/`
// 共 37 檔)代裝進**使用者的文件資料夾**。那是給開發者寫 SDD/wiki 用的東西,
// RAG 的一般使用者完全用不到,而且兩層傷害:
// ① 把人家的資料夾弄亂(leo:「他原本的資料夾就不會看起來亂掉」)
// ② 那些檔案會被當成知識吃進去 ⇒ 知識庫長出 `kb`/`t195-watch` 這種
// 不是使用者內容的庫(leo 實撞,見庫目錄管理截圖)
// ⇒ **看守資料夾一律不代裝**。要裝的人自己跑 `collector template-install`
// (子命令仍在,開發者情境不受影響)。
//
// 另外還要把「已經被鋪進去的」擋在知識之外——那不是靠隱藏檔判斷,
// 因為 leo 自己的 repo 裡 template 本來就不是隱藏的。判準是**路徑身分**,見 scan.go。
if *once {
return runOne()
+14 -1
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
@@ -47,6 +48,14 @@ type ManifestEntry struct {
FailCount int `json:"fail_count,omitempty"` // 連續失敗次數(成功即歸零)
LastFailAt int64 `json:"last_fail_at,omitempty"` // 最後一次失敗的 unix 秒
NextRetry int64 `json:"next_retry,omitempty"` // 早於這個時間不重試(0=可立即重試)
// LastError=最後一次失敗的**真正原因**(原文,不改寫)。
//
// 🔴 leo 2026-08-06 立的原則:「**別人的錯誤一律要顯示給用戶看,
// 不然就會變成我的錯誤,導致客服**」。
// 先前退避一開始,畫面就只剩「上次失敗(第 4 次),58m 後重試」,
// 真因(Cloudflare「當日免費額度用完」/「這份 PDF 沒有文字層」)**當場消失**
// ⇒ 使用者以為是我們壞掉。原因必須跟著 entry 存活到下次成功為止。
LastError string `json:"last_error,omitempty"`
}
// retryBackoff 退避階梯:1m → 5m → 15m → 1h → 6h,之後每次 6h。
@@ -133,6 +142,7 @@ func (m *Manifest) MarkIngestedBy(path, sourceHash string, at int64, extractor s
e.ExtractedBy = extractor
// 成功即清掉失敗狀態(t195):下次再壞會從第一階退避重新算起。
e.FailCount, e.LastFailAt, e.NextRetry = 0, 0, 0
e.LastError = ""
return true
}
@@ -141,13 +151,16 @@ func (m *Manifest) MarkIngestedBy(path, sourceHash string, at int64, extractor s
// 為什麼要記在 manifest 而不是記憶體:collector 每輪是獨立 process
// `direct --once` 由看守器反覆拉起),記憶體狀態一輪就沒了——
// 這正是原本「1387 輪重試同一個檔」的原因:每輪都以為自己是第一次。
func (m *Manifest) MarkFailed(path string, at int64) bool {
func (m *Manifest) MarkFailed(path string, at int64, reason string) bool {
e, ok := m.Entries[path]
if !ok {
return false
}
e.FailCount++
e.LastFailAt = at
if strings.TrimSpace(reason) != "" {
e.LastError = reason // 存真因;退避訊息由呼叫端另外組,不覆蓋這裡
}
idx := e.FailCount - 1
if idx >= len(retryBackoff) {
idx = len(retryBackoff) - 1
+6 -6
View File
@@ -30,7 +30,7 @@ func TestShouldRetry_NeverFailed(t *testing.T) {
// 核心止血:失敗後在退避窗口內不再重試。
func TestShouldRetry_BackoffWindow(t *testing.T) {
m := newRetryTestManifest("a.pdf")
m.MarkFailed("a.pdf", 1000) // 第 1 次失敗 → 退避 60s
m.MarkFailed("a.pdf", 1000, "測試用失敗原因") // 第 1 次失敗 → 退避 60s
if m.ShouldRetry("a.pdf", 1030, false) {
t.Fatal("退避窗口內(+30s)不該重試——這正是 1387 輪的病根")
@@ -46,7 +46,7 @@ func TestMarkFailed_ExponentialBackoff(t *testing.T) {
want := []int64{60, 300, 900, 3600, 21600, 21600} // 1m,5m,15m,1h,6h,之後維持 6h
at := int64(1000)
for i, w := range want {
m.MarkFailed("a.pdf", at)
m.MarkFailed("a.pdf", at, "測試用失敗原因")
got := m.Entries["a.pdf"].NextRetry - at
if got != w {
t.Fatalf("第 %d 次失敗:退避 %ds,預期 %ds", i+1, got, w)
@@ -59,7 +59,7 @@ func TestShouldRetry_MaxFailStops(t *testing.T) {
m := newRetryTestManifest("a.pdf")
at := int64(1000)
for i := 0; i < MaxFailBeforeSkip; i++ {
m.MarkFailed("a.pdf", at)
m.MarkFailed("a.pdf", at, "測試用失敗原因")
}
// 就算等再久也不自動重試
if m.ShouldRetry("a.pdf", at+999999, false) {
@@ -74,8 +74,8 @@ func TestShouldRetry_MaxFailStops(t *testing.T) {
// 成功後要清掉失敗狀態,否則下次再壞會從高階退避起跳(等太久)。
func TestMarkIngested_ResetsFailState(t *testing.T) {
m := newRetryTestManifest("a.pdf")
m.MarkFailed("a.pdf", 1000)
m.MarkFailed("a.pdf", 1100)
m.MarkFailed("a.pdf", 1000, "測試用失敗原因")
m.MarkFailed("a.pdf", 1100, "測試用失敗原因")
if m.Entries["a.pdf"].FailCount != 2 {
t.Fatal("失敗次數應累計")
}
@@ -95,7 +95,7 @@ func TestMarkIngested_ResetsFailState(t *testing.T) {
func TestShouldRetry_OtherFilesUnaffected(t *testing.T) {
m := newRetryTestManifest("bad.pdf")
m.Entries["good.md"] = &ManifestEntry{ContentHash: "h2", Size: 1, Mtime: 1}
m.MarkFailed("bad.pdf", 1000)
m.MarkFailed("bad.pdf", 1000, "測試用失敗原因")
if m.ShouldRetry("bad.pdf", 1010, false) {
t.Fatal("壞檔應在退避中")
+8
View File
@@ -186,6 +186,14 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
if abs, aerr := filepath.Abs(p); aerr == nil && opts.SkipPaths[abs] {
return nil
}
// 🔴 2026-08-06 leotemplate 的東西不是使用者的知識,一律不收。
// **用路徑身分認,不用「有沒有以點開頭」認**——leo 自己的 repo 裡
// template 本來就不是隱藏的,靠隱藏判斷會漏掉一大半。
// 也不計進「有 N 個檔案沒有被整理」——那是給使用者看他自己的檔案的,
// 我們自己鋪的東西不該佔用他的注意力。
if rel, rerr := filepath.Rel(root, p); rerr == nil && TemplateOwns(filepath.ToSlash(rel)) {
return nil
}
ext := strings.ToLower(filepath.Ext(name))
if !allowedExt[ext] {
// G-6.2**這裡以前是條死巷**——`return nil` 之後這個檔就從世界上消失了。
+44
View File
@@ -14,6 +14,7 @@ import (
"io/fs"
"os"
"path/filepath"
"strings"
)
// templateFS 是 system-dev-template 的 vendored 快照(版本見 templatefs/system-dev/VERSION)。
@@ -43,6 +44,49 @@ func TemplateVersion() string {
return v
}
// templateOwnedPaths 回傳「這份 template 擁有的相對路徑」集合(斜線分隔)。
//
// 🔴 leo 2026-08-06:「**它也不能只看隱藏檔內,因為 template 在我所有的 repo 裡不是隱藏的**」
//
// ⇒ 判準必須是**路徑身分**(這些路徑本來就是 template 的),不是「有沒有以點開頭」。
// 用途:掃描時把它們排除在知識之外——不管它是 daemon 舊版鋪的、
// 還是使用者自己的開發 repo 本來就有的,那都不是他想搜尋的內容。
func templateOwnedPaths() map[string]bool {
owned := map[string]bool{}
_ = fs.WalkDir(templateFS, templateFSRoot, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
if rel, rerr := filepath.Rel(templateFSRoot, p); rerr == nil {
owned[filepath.ToSlash(rel)] = true
}
return nil
})
return owned
}
// TemplateOwns 回答「這個相對路徑是不是 template 的東西」。
// 也認**目錄前綴**template 的 `system-dev/` 底下使用者後來自己加的檔
// (例如他寫的 wiki)同樣是開發用的,不該進知識庫。
func TemplateOwns(rel string) bool {
rel = filepath.ToSlash(rel)
if templateOwnedCache[rel] {
return true
}
for _, prefix := range templateOwnedDirs {
if strings.HasPrefix(rel, prefix) {
return true
}
}
return false
}
var (
templateOwnedCache = templateOwnedPaths()
// 這些目錄整棵都是開發用的(template 鋪的、或使用者自己長出來的都一樣)。
templateOwnedDirs = []string{"system-dev/", "scripts/"}
)
// InstallTemplate 把內嵌 template 鋪進 root。冪等:既有檔案跳過不覆寫。
func InstallTemplate(root string) (*TemplateInstallResult, error) {
res := &TemplateInstallResult{Version: TemplateVersion()}
+60
View File
@@ -0,0 +1,60 @@
package collector
import (
"os"
"path/filepath"
"testing"
)
// TestTemplateFilesAreNotKnowledge 釘住 leo 2026-08-06 的兩條裁決:
//
// ①「拿來開發一般人用不到的**根本別安裝**」
// ②「它也不能只看隱藏檔內,因為 **template 在我所有的 repo 裡不是隱藏的**」
//
// 事故:daemon 把 system-dev templateCLAUDE.mdscripts/system-dev/37 檔)
// 代裝進使用者的文件資料夾,然後又把它們當知識吃進去
// ⇒ 知識庫長出 `kb``t195-watch` 這種不是使用者內容的庫(leo 實撞)。
//
// 這支測試刻意把 template 檔**放成不隱藏**(就像 leo 的 repo),驗它仍被排除。
func TestTemplateFilesAreNotKnowledge(t *testing.T) {
root := t.TempDir()
write := func(rel, body string) {
p := filepath.Join(root, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
// template 的東西(**沒有隱藏**
write("CLAUDE.md", "開發用設定")
write("scripts/sdd-active-check.sh", "#!/bin/sh")
write("system-dev/wiki/status.md", "開發用 wiki")
// 使用者真正的內容
write("我的筆記.md", "這是我要搜尋的東西")
m := &Manifest{Entries: map[string]*ManifestEntry{}}
res, err := Scan(root, m, ScanOptions{})
if err != nil {
t.Fatalf("掃描失敗:%v", err)
}
got := map[string]bool{}
for _, e := range res.Events {
got[e.Path] = true
}
if !got["我的筆記.md"] {
t.Error("使用者自己的檔案不見了——排除規則太寬")
}
for _, dev := range []string{"CLAUDE.md", "scripts/sdd-active-check.sh", "system-dev/wiki/status.md"} {
if got[dev] {
t.Errorf("%s 是 template 的開發用檔,不該被當成使用者知識", dev)
}
}
// 也不該計進「有 N 個檔案沒有被整理」——那欄是給使用者看他自己的檔案的
if res.SkippedOther > 0 {
t.Errorf("template 檔不該計進『沒被整理』(會佔用使用者的注意力),實得 %d:%v",
res.SkippedOther, res.SkippedOtherNames)
}
}
+44
View File
@@ -0,0 +1,44 @@
package collector
import (
"strings"
"testing"
)
// TestUpstreamErrorSurvivesBackoff 釘住 leo 2026-08-06 立的原則:
//
// 「**別人的錯誤一律要顯示給用戶看,不然就會變成我的錯誤,導致客服**」
//
// 事故現場:Cloudflare 回「當日免費額度用完」、某 PDF「沒有可抽取的文字」,
// 但檔案一進退避,畫面就只剩「上次失敗(第 4 次),58m 後重試」
// ⇒ 上游的錯被我們吞掉,使用者只看得到「Arcrun 在失敗」。
//
// 這支測試從**上游錯誤字串**出發,驗它一路活到給使用者看的那句話裡。
func TestUpstreamErrorSurvivesBackoff(t *testing.T) {
const upstream = "雲端萃取失敗(HTTP 502):Workers AI 執行失敗:4006: you have used up your daily free allocation of 10,000 neurons"
m := &Manifest{Entries: map[string]*ManifestEntry{"報告.pdf": {}}}
if !m.MarkFailed("報告.pdf", 1000, upstream) {
t.Fatal("MarkFailed 應該成功")
}
// ① 原因要存得住(退避跨輪次,不能只活在當次記憶體裡)
if got := m.Entries["報告.pdf"].LastError; got != upstream {
t.Fatalf("上游原因沒存下來,實得 %q", got)
}
// ② 使用者看到的那句話裡要有它
reason := retrySkipReason(m, "報告.pdf", 1010)
if !strings.Contains(reason, "neurons") {
t.Fatalf("退避訊息吞掉了上游的錯 ⇒ 使用者會以為是我們壞掉。實得:%s", reason)
}
if !strings.Contains(reason, "後重試") {
t.Errorf("重試排程也要講(兩件事都要說),實得:%s", reason)
}
// ③ 成功之後要清乾淨,不能一直掛著舊錯誤嚇人
m.MarkIngestedBy("報告.pdf", "hash", 2000, "workers-ai")
if m.Entries["報告.pdf"].LastError != "" {
t.Error("成功後舊的失敗原因應該清掉")
}
}