fix(t86b): manifest 分實例——換知識庫後同資料夾不再被舊帳跳過

根因:manifestPathFor 雜湊只含資料夾路徑,換實例讀到舊帳本
→ 全部檔案被視為已同步 → 新知識庫靜默拿不到資料(leo 機實證:刪 manifest 才通)。
- instanceHostOf:CypherURL 的 host 作為實例鍵
- manifestPathFor:sha256(host+"\n"+absRoot),單根多根統一公式
- migrateManifestIfNeeded:新名不在、舊名在 → rename 過戶(一次性、冪等)
- 換回舊實例帳本仍在=不重傳(每實例一本帳,設計目標)
已知殘窗(記 tasks.md):升級後第一次掃描前就換實例,舊帳會被過戶給新實例
(舊格式無實例資訊,無從分辨);常態下升級後首掃已把帳過戶給舊實例,窗口極窄。
測試:新增 6 條(分實例/穩定/單根遷移/多根遷移/冪等)+三模組全綠(總管親跑)。
(實作=子 CC;驗證+commit=總管)
This commit is contained in:
2026-07-28 12:59:07 +08:00
parent 02833588e5
commit 9df8d037ca
2 changed files with 241 additions and 10 deletions
+48 -7
View File
@@ -27,6 +27,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
@@ -205,18 +206,57 @@ func (c *DirectConfig) Folders() []string {
return out
}
// manifestPathFor 回傳某根的 manifest 路徑。單根=沿用 cfg.Manifest(升級不丟既有狀態);
// 多根=每根一份,基底名加 root 絕對路徑的 sha256 前 8 碼尾碼(路徑穩定=尾碼穩定)。
func (c *DirectConfig) manifestPathFor(absRoot string) string {
folders := c.Folders()
if len(folders) <= 1 {
return c.Manifest
// instanceHostOf extracts the host from a CypherURL to use as a per-instance
// distinguisher in manifest paths (t86b). Falls back to the full URL if parsing fails.
func instanceHostOf(cypherURL string) string {
u, err := url.Parse(cypherURL)
if err != nil || u.Host == "" {
return cypherURL
}
sum := sha256.Sum256([]byte(absRoot))
return u.Host
}
// manifestPathFor 回傳某根的 manifest 路徑。
// t86b:雜湊改為 sha256(instanceHost + "\n" + absRoot),讓每個(實例, 資料夾)組合對應
// 獨立帳本——換知識庫實例後同資料夾不再重用舊帳本,避免「全部視為已同步」靜默跳過。
// 舊格式遷移見 migrateManifestIfNeeded:啟動/掃描時若新名不存在但舊名存在則 rename 過來。
func (c *DirectConfig) manifestPathFor(absRoot string) string {
host := instanceHostOf(c.CypherURL)
sum := sha256.Sum256([]byte(host + "\n" + absRoot))
ext := filepath.Ext(c.Manifest)
return strings.TrimSuffix(c.Manifest, ext) + "-" + hex.EncodeToString(sum[:4]) + ext
}
// oldManifestPaths 回傳 t86b 之前版本對同一個 absRoot 會產生的 manifest 路徑,
// 供遷移時確認是否有舊帳本需要搬移。
// 優先序:①舊多根(只含路徑雜湊)→ ②舊單根(直用 cfg.Manifest)。
func (c *DirectConfig) oldManifestPaths(absRoot string) []string {
ext := filepath.Ext(c.Manifest)
base := strings.TrimSuffix(c.Manifest, ext)
// 舊多根公式:sha256(absRoot only)
sumPathOnly := sha256.Sum256([]byte(absRoot))
pathOnlyPath := base + "-" + hex.EncodeToString(sumPathOnly[:4]) + ext
// 舊單根公式:直用 cfg.Manifest
return []string{pathOnlyPath, c.Manifest}
}
// migrateManifestIfNeeded 在 newPath 不存在時,把最先找到的舊格式 manifest rename 過來。
// 一次性、冪等:newPath 已存在時直接 return;rename 失敗靜默忽略(最多這一輪重傳,不影響正確性)。
func (c *DirectConfig) migrateManifestIfNeeded(absRoot, newPath string) {
if _, err := os.Stat(newPath); err == nil {
return // 新路徑已存在,無需遷移
}
for _, oldPath := range c.oldManifestPaths(absRoot) {
if oldPath == newPath {
continue
}
if _, err := os.Stat(oldPath); err == nil {
_ = os.Rename(oldPath, newPath)
return
}
}
}
// directHTTP 是 direct 模式共用的 HTTP client(萃取 workflow 可能同步跑 LLM,放寬 timeout)。
var directHTTP = &http.Client{Timeout: 300 * time.Second}
@@ -394,6 +434,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes
if err != nil {
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil
}
cfg.migrateManifestIfNeeded(absRoot, absManifest) // t86b:一次性遷移舊格式帳本
m, err := LoadManifest(absManifest, absRoot)
if err != nil {
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil