Files
arcrun-collector/scan.go
T
Leo 12c2d41886 feat(collector): daemon 產出改成規範形 wiki 卡——1 文件卡+N 原子卡落 .wiki/(InkStoneCo#44 ④)
LLM 只回 JSON 判斷,格式/落點/連結閉合/索引/manifest 全由 wikishape.go 機械組裝:
- 卡形=frontmatter(tags/gloss/created/updated)+← 上層+摘要/重點/實體(帶類型)/
  關聯(內文知識關係/卡片關係/出處)——差距表 #6
- 落點 <節點>/.wiki/、檔名=H1(.wiki 隱藏目錄自身即機器標記,machinemark 例外③)——#7
- 00-INDEX 機械維護:每行五樣照抄 frontmatter;每份原稿必列——#8
- 萃取端就切 1 hub+N 原子卡(gemma 路 prompt 改 JSON 契約)——#9
- 無可萃概念標「空」+理由,上索引不產卡——#10
機械保證對著 wiki-lint.py 寫:斷連結拆殼、index 式句改寫、三元組恰三項、
雙向邊自動補反向、佔用不覆蓋、原稿永不動;同名概念跨文件先消歧(merge 歸第⑤環)。
scan/convert 白名單補 .feature/.yaml/.yml/.org/.rst(規範洞 6);
lint.go 新舊格式雙軌(workers-ai 雲端 prompt 屬 matrix/arcrun 核心,待開票);
direct.go 只送文件卡上雲(takedown 配對鍵不變)、下架收走 .wiki 產物。

實測:真資料夾+真 Gemini → 2 節點 12 卡,wiki-lint 17/17 exit 0,
原稿 sha256 前後不變;go test collector+supervisor+arcrun-app 全綠(新增 9 案)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 21:03:45 +08:00

561 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// scan.go — 掃描迴圈與差異分類(SDD ingest-hash-trigger design §3)。
// 事件順序:先把本輪 removed×added 以 content_hash 配對成 renamed(只更新路徑映射),
// 再分類其餘 added/modified/removedremoved 數 > manifest 條目 × 門檻(預設 40%)
// → removed 全部不執行、改發警告(R6)。本階段不接網路,事件輸出到 stdout。
package collector
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// 收檔白名單。**注意這只是「收不收」,能不能讀由 convert.go 的 extractors 決定**——
// 兩者要一起看(2026-07-27 t73`.pdf` 早就在這裡,但 ingest 端擋著=檔案上了 R2 卻進不了
// 知識庫,使用者看到的是「丟檔進去沒反應」)。
//
// .csv/.xlsx 於 2026-07-27 加入——leo:「要思考 Excel 和 csv 的問題,**因為企業用很多**」。
var allowedExt = map[string]bool{
".md": true,
".markdown": true,
".txt": true,
".docx": true,
".pptx": true,
".pdf": true,
".csv": true,
".xlsx": true,
// 2026-08-15 InkStoneCo#44 ④:《llm-wiki-作業規範》洞 6 的掃描白名單——
// .featureGherkin 規格)、.yaml/.yml、.org、.rst 都是知識文件(真實 repo 實測
// 34 個非 md 檔裡 9 個是 .feature)。它們是純文字,走 passthrough(見 IsPlainText)。
".feature": true,
".yaml": true,
".yml": true,
".org": true,
".rst": true,
}
// docLikeExt=「使用者明顯把它當文件、但我們還讀不了」的副檔名。
//
// 🔴 為什麼要有這張表(J-1/S6 考題 G-6.22026-08-06):
// 下面那道 `!allowedExt[...]` 的閘**直接 return nil**——不進事件、不進 manifest、
// 不進 status、不進畫面。使用者把一份 `.doc` 丟進資料夾,**整個系統從頭到尾一個字都不說**,
// 他只會覺得「這東西壞了」。G-6.2 的判準是:要嘛查得到,**要嘛當場被告知不支援**;
// 「安靜地略過」不是可接受的第三種結果。
//
// 為什麼是白名單、而不是「非 allowedExt 一律點名」:後者在 Obsidian 附件庫(幾百張 .png
// 或程式碼資料夾裡會炸出幾百行「處理不了」=噪音,使用者反而學會忽略整塊訊息。
// ⇒ **像文件的逐檔點名,其餘只報一個總數**(見 Scan 的 SkippedOther)。兩種都不沉默,
// 但只有前者值得佔用他的注意力。
//
// 加新格式的順序:先列在這裡(使用者立刻看得到「還不支援」),
// 等 convert.go 真的接上抽取器,再把它從這裡搬去 allowedExt。
// maxOtherNames=非文件檔最多點名幾個。超過就只報總數(避免幾百張圖洗版)。
const maxOtherNames = 5
var docLikeExt = map[string]bool{
// 舊版 OfficeOLE2 二進位,與 .docx/.xlsx/.pptx 是完全不同的格式)
".doc": true, ".xls": true, ".ppt": true,
// Apple iWork
".pages": true, ".numbers": true, ".key": true,
// OpenDocumentLibreOffice
".odt": true, ".ods": true, ".odp": true,
// 其他常見文件容器
".rtf": true, ".epub": true, ".wpd": true, ".msg": true, ".eml": true,
}
// SkippedFile=這一輪被略過、且值得對使用者逐檔點名的檔案。
//
// ⚠️ 刻意**不寫進 manifest**:它每輪由檔案系統重算,永遠反映現況。
// (對照 t195 的坑:凡是存進 ManifestEntry 的跨輪欄位都得記得在 carry 段補一行,
//
// 漏了就靜默歸零。這裡不建立那份債。)
type SkippedFile struct {
Path string `json:"path"`
Ext string `json:"ext"`
}
// ---- 輸出 payload(對應 schemas/collector-trigger.v1.schema.json----
type Event struct {
Type string `json:"type"`
Path string `json:"path"`
OldPath string `json:"old_path,omitempty"`
SourceHash string `json:"source_hash"`
Size *int64 `json:"size,omitempty"`
R2Key string `json:"r2_key,omitempty"`
}
type Warning struct {
Code string `json:"code"`
Message string `json:"message"`
RemovedCount int `json:"removed_count,omitempty"`
ManifestCount int `json:"manifest_count,omitempty"`
ThresholdRatio float64 `json:"threshold_ratio,omitempty"`
}
type TriggerPayload struct {
SchemaVersion int `json:"schema_version"`
FolderID string `json:"folder_id"`
Root string `json:"root,omitempty"`
GeneratedAt int64 `json:"generated_at,omitempty"`
Events []Event `json:"events"`
Warnings []Warning `json:"warnings,omitempty"`
// 🔴 兩個 `json:"-"`G-6.22026-08-06):被略過的檔案是**給本機使用者看的**,
// 不是給雲端 ingest 的料。collector-trigger.v1.schema.json 頂層寫死
// `additionalProperties: false`,多帶一個欄位上線就會被 schema 擋掉
//BuildSendablePayload 是 `sendable := *p` 淺拷貝,有 tag 就會一起送出去)。
// ⇒ 留在記憶體裡,由 direct.go 收進 status.json,給 App 首頁用。
Skipped []SkippedFile `json:"-"` // 像文件、但還讀不了的(逐檔點名)
SkippedOther int `json:"-"` // 其餘非文件檔(圖片/影音/程式碼…)總數
// SkippedOtherNames=上面那些檔的檔名,**最多 maxOtherNames 個**。
// 🔴 2026-08-06leo 封測):封測者放了一個 .md 進去說「無法通過」,而畫面只寫
// 「有 1 個不是文件的檔案」——**沒說是哪一個**,於是誰也判斷不出發生什麼事
// (.md 明明在白名單裡,所以那個 1 一定是別的東西:可能副檔名被 Windows 藏起來、
// 可能存成了別的格式)。只報總數在「幾百張圖」時是對的,在「1 個」時等於沒說。
// ⇒ 少量時就點名,讓使用者自己一眼看出「喔,我存錯格式了」。
SkippedOtherNames []string `json:"-"`
// DuplicateFormats=本輪偵測到、同檔名主幹的多格式重複(2026-08-07,見 FormatDuplicate)。
// 同 Skipped:只給本機使用者看,不隨 payload 送雲端(schema additionalProperties:false 會擋)。
DuplicateFormats []FormatDuplicate `json:"-"`
// PlanExcludedByPlan=這一輪用了什麼收檔策略、據此擋掉幾個檔(arcrun-rag#104)。
// 同上,`json:"-"`:給本機使用者看的,不送雲端。
// 🔴 這兩個欄位就是 #104 那條紅線的載體——「排除規則要看得見」。少了它們,
// 使用者接上一個一萬檔的 repo 只看到 32 個進度,會以為系統壞了。
Plan IngestPlan `json:"-"`
ExcludedByPlan int `json:"-"`
}
// FormatDuplicate=同一份內容被偵測到有多種格式並存(同檔名主幹、不同副檔名)。
//
// 🔴 為什麼要有這個(2026-08-07,leo 實據):封測者 Evan 給的資料集
// 27,164 檔=9,045 個 md9,044 個 json9,043 個 html,是同一批來源轉出的三種格式
// `markdown/160-00F3_001.md` 與 `json/160-00F3_001.json` 逐一對應,同錯誤碼、同變體號)。
// 現行設計不知道這件事,會把三種格式各當一份新檔,萃三次、吃三倍額度——
// 使用者不會知道要挑一種,這是我們該擋的,不是他該懂的。
//
// 判準:檔名主幹(去副檔名、轉小寫)在整個看守根內相同 → 視為同一份內容的不同格式匯出,
// 只留優先序最高的一份進事件管線,其餘標記在這裡(不產生 added/modified/renamed 事件)。
//
// ⚠️ 已知的取捨:純靠檔名主幹比對,不比對內容——兩個不相干的檔恰好同名不同副檔名
// (如兩個專案各自的 `README.md``README.pdf`)會被誤判成同一份。刻意接受這個風險,
// 因為:①這不是靜默丟棄——DuplicateFormats 會被 direct.go 收進 status.json 讓使用者看到
// 「跳過:與 X 視為同一格式」,看起來不對可以改檔名破解誤判;②不比對,就是 leo 實據的
// md/json 案例(不同目錄、不同副檔名,唯一共同點正是檔名主幹)根本擋不掉。
//
// 🔴 刻意不寫進 manifest(同 Skipped 的理由,見上方 SkippedFile 註解):每輪由檔案系統
// 重算,勝出者若之後消失,下一輪換另一份自然遞補,不留跨輪狀態要維護。
type FormatDuplicate struct {
Path string `json:"path"` // 被跳過的那份
KeptPath string `json:"kept_path"` // 真正進事件管線的那份
Stem string `json:"stem"` // 判定依據:去副檔名、轉小寫後的檔名主幹
}
// dedupFormatPriority:格式去重時「留誰」的優先序(越前面越優先留下)。
// 原則:越接近「使用者原始編輯」的格式排越前面(docx/pptx 是可編輯原稿),
// md/markdown 常是**從別的格式轉出的產物**(leo 實據的資料集正是 html→chm 轉出 md/json),
// 排在轉檔產物之前但在原生辦公格式之後。不在表內的副檔名排最後(理論上不會發生,
// current 只收 allowedExt)。
var dedupFormatPriority = []string{".docx", ".pptx", ".xlsx", ".csv", ".pdf", ".md", ".markdown", ".txt"}
func formatPriority(relPath string) int {
ext := strings.ToLower(filepath.Ext(relPath))
for i, e := range dedupFormatPriority {
if e == ext {
return i
}
}
return len(dedupFormatPriority)
}
// dedupStemOf 回傳去重判準:檔名主幹(basename 去副檔名),轉小寫(跨平台大小寫不敏感)。
// 刻意只看 basename、不看目錄——leo 實據的 md/json 恰好活在不同的兄弟目錄
// markdown/160-00F3_001.md vs json/160-00F3_001.json),只有 basename 主幹相同。
func dedupStemOf(relPath string) string {
base := filepath.Base(relPath)
stem := strings.TrimSuffix(base, filepath.Ext(base))
return strings.ToLower(stem)
}
// detectFormatDuplicates 在 current(本輪掃到、通過 allowedExt 的檔)裡找出同檔名主幹的分組,
// 每組留優先序最高的一份,其餘回報為 loserpath -> winner path)。
// 走訪用 stem 字母序=確定性輸出(map 迭代順序不穩定)。
func detectFormatDuplicates(current map[string]fileState) (map[string]string, []FormatDuplicate) {
byStem := map[string][]string{}
for p := range current {
stem := dedupStemOf(p)
byStem[stem] = append(byStem[stem], p)
}
stems := make([]string, 0, len(byStem))
for s := range byStem {
stems = append(stems, s)
}
sort.Strings(stems)
loserOf := map[string]string{}
var dups []FormatDuplicate
for _, stem := range stems {
group := byStem[stem]
if len(group) < 2 {
continue
}
sort.Slice(group, func(i, j int) bool {
pi, pj := formatPriority(group[i]), formatPriority(group[j])
if pi != pj {
return pi < pj
}
return group[i] < group[j] // 同優先序時字母序,確定性
})
winner := group[0]
for _, loser := range group[1:] {
loserOf[loser] = winner
dups = append(dups, FormatDuplicate{Path: loser, KeptPath: winner, Stem: stem})
}
}
return loserOf, dups
}
type ScanOptions struct {
// MaxRemovedRatio:單輪 removed 數 > manifest 條目數 × 本值 → 觸發大量刪除防呆(R6)。
MaxRemovedRatio float64
// SkipPaths:絕對路徑黑名單(如 manifest 檔自己住在 root 底下時)。
SkipPaths map[string]bool
// SkipDirNames:目錄名黑名單(任一層命中整棵跳過)。daemon-beta task 2
// template 代裝後 `system-dev/`wiki 產物區)不得被當成原稿掃進 ingest。
SkipDirNames map[string]bool
// Plan:收檔策略(arcrun-rag#104)。零值=IngestAll,行為與加這個欄位之前**完全一致**
// ——既有呼叫端與測試不必全部改。要拿到 #104 的效果就傳 PlanIngest(root)。
Plan IngestPlan
}
const DefaultMaxRemovedRatio = 0.4
type fileState struct {
hash string // sha256:<hex>
size int64
mtime int64
}
func hashFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil
}
func r2KeyOf(sourceHash string) string {
return "raw/" + strings.TrimPrefix(sourceHash, "sha256:")
}
// Scan 走訪 root、對照並更新 manifest、產出一輪事件。
// manifest 更新原則:content_hash/size/mtime 反映現況;ingested_hash/ingested_at
// 只搬運(renamed)與保留(modified),本函式永不設值——那是上傳成功後的事。
func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
if opts.MaxRemovedRatio <= 0 {
opts.MaxRemovedRatio = DefaultMaxRemovedRatio
}
orig := m.Entries
manifestCountBefore := len(orig)
// 1) 走訪檔案系統,建立現況(mtime+size fast-path:沒變→沿用 manifest hash,變了才算 sha256)。
current := map[string]fileState{}
var skipped []SkippedFile
skippedOther := 0
var skippedOtherNames []string
// arcrun-rag#104:被策略擋掉的檔案數。**一定要數出來**——票上的紅線是
// 「用戶要知道有 8,000 個檔沒被收,因為它們是程式碼」,不是安靜地少收。
excludedByPlan := 0
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, werr error) error {
if werr != nil {
return werr
}
name := d.Name()
relOf := func() (string, bool) {
rel, rerr := filepath.Rel(root, p)
if rerr != nil {
return "", false
}
return filepath.ToSlash(rel), true
}
if d.IsDir() {
if p != root && strings.HasPrefix(name, ".") {
return filepath.SkipDir // 隱藏目錄(.git、.obsidian…)整棵跳過
}
if p != root && opts.SkipDirNames[name] {
return filepath.SkipDir // 名單目錄(system-dev…)整棵跳過
}
// #104:依策略整棵跳過(依賴/建置產物/範本/worktree/巢狀 repo
// 以及非本次策略要收的區域)。整棵跳掉的檔不逐一計數——
// 那個數字對使用者沒有意義,Plan.Reason 那句話才是他要的解釋。
if p != root {
if rel, ok := relOf(); ok && opts.Plan.SkipsDir(rel, p) {
return filepath.SkipDir
}
}
return nil
}
if strings.HasPrefix(name, ".") {
return nil
}
if rel, ok := relOf(); ok && !opts.Plan.KeepsFile(rel) {
excludedByPlan++
return nil
}
if abs, aerr := filepath.Abs(p); aerr == nil && opts.SkipPaths[abs] {
return nil
}
// 🔴 2026-08-06 leotemplate 的東西不是使用者的知識,一律不收。
// **用路徑身分認,不用「有沒有以點開頭」認**——leo 自己的 repo 裡
// template 本來就不是隱藏的,靠隱藏判斷會漏掉一大半。
// 也不計進「有 N 個檔案沒有被整理」——那是給使用者看他自己的檔案的,
// 我們自己鋪的東西不該佔用他的注意力。
//
// 🔴 arcrun-rag#104 例外:curated-wiki 模式下,`system-dev/wiki/` **正是要收的那一份**。
// 上面那條規則(2026-08-06)與 leo 2026-08-14 的規格直接對撞,衝突由身分化解:
// · 我們代裝 template 的資料夾(沒有 `.git`)⇒ 那些檔是**我們鋪的**,照舊不收
// · 使用者自己的 repo(有 `.git`)⇒ 那份 wiki 是**他寫的**,正是他要我們讀的
// 判準只有 PlanIngest 一個地方,見 ingestplan.go。
if rel, rerr := filepath.Rel(root, p); rerr == nil {
relSlash := filepath.ToSlash(rel)
if TemplateOwns(relSlash) && !opts.Plan.OverridesTemplateOwned(relSlash) {
return nil
}
}
ext := strings.ToLower(filepath.Ext(name))
if !allowedExt[ext] {
// G-6.2**這裡以前是條死巷**——`return nil` 之後這個檔就從世界上消失了。
// 現在留個名,讓 direct.go 有東西可以寫進 status.json、App 有東西可以顯示。
if docLikeExt[ext] {
if rel, rerr := filepath.Rel(root, p); rerr == nil {
skipped = append(skipped, SkippedFile{Path: filepath.ToSlash(rel), Ext: ext})
}
} else {
skippedOther++
// 只留前幾個:多了就變噪音(Obsidian 附件庫可能有幾百張 .png)。
if len(skippedOtherNames) < maxOtherNames {
if rel, rerr := filepath.Rel(root, p); rerr == nil {
skippedOtherNames = append(skippedOtherNames, filepath.ToSlash(rel))
}
}
}
return nil
}
info, ierr := d.Info()
if ierr != nil {
return ierr
}
rel, rerr := filepath.Rel(root, p)
if rerr != nil {
return rerr
}
rel = filepath.ToSlash(rel)
st := fileState{size: info.Size(), mtime: info.ModTime().Unix()}
if e, ok := orig[rel]; ok && e.ContentHash != "" && e.Mtime == st.mtime && e.Size == st.size {
st.hash = e.ContentHash // fast-pathmtime+size 沒變,跳過重算
} else {
h, herr := hashFile(p)
if herr != nil {
return herr
}
st.hash = h
}
current[rel] = st
return nil
})
if err != nil {
return nil, err
}
// 1.5) 同內容多格式去重(2026-08-07):先決定誰是 loser,事件管線全程跳過它們。
dupLoser, duplicateFormats := detectFormatDuplicates(current)
// 2) 初分:added 候選(現況有、manifest 無)與 removed 候選(manifest 有、現況無)。
// loser 不進候選——它不該被當成新檔,也不該被當成 rename 的另一端。
var addedPaths, removedPaths []string
for p := range current {
if dupLoser[p] != "" {
continue
}
if _, ok := orig[p]; !ok {
addedPaths = append(addedPaths, p)
}
}
for p := range orig {
if _, ok := current[p]; !ok {
removedPaths = append(removedPaths, p)
}
}
sort.Strings(addedPaths)
sort.Strings(removedPaths)
// 3) 先配對 renameddesign §3 順序 1):removed×added 以 content_hash 配對,
// 配上=只更新路徑映射,不 retire、不重萃、不重傳。同 hash 多候選→排序後貪婪配對(確定性)。
removedByHash := map[string][]string{}
for _, p := range removedPaths {
h := orig[p].ContentHash
removedByHash[h] = append(removedByHash[h], p)
}
renamedOldOf := map[string]string{} // newPath -> oldPath
pairedOld := map[string]bool{}
var events []Event
for _, np := range addedPaths {
h := current[np].hash
cands := removedByHash[h]
if len(cands) == 0 {
continue
}
op := cands[0]
removedByHash[h] = cands[1:]
pairedOld[op] = true
renamedOldOf[np] = op
events = append(events, Event{Type: "renamed", Path: np, OldPath: op, SourceHash: h})
}
// 4) added:真新檔+「曾偵測但從未成功 ingest」的檔(重試語意,design §2)。
sortedCurrent := make([]string, 0, len(current))
for p := range current {
sortedCurrent = append(sortedCurrent, p)
}
sort.Strings(sortedCurrent)
addedEvent := func(p string) Event {
st := current[p]
size := st.size
return Event{Type: "added", Path: p, SourceHash: st.hash, Size: &size, R2Key: r2KeyOf(st.hash)}
}
for _, p := range sortedCurrent {
if dupLoser[p] != "" { // 同內容的另一格式已在事件管線,這份跳過(1.5)
continue
}
if op, isRenamed := renamedOldOf[p]; isRenamed {
if orig[op].IngestedHash == "" { // 改名的檔其實從未 ingest 成功 → 補一發 added
events = append(events, addedEvent(p))
}
continue
}
if _, existed := orig[p]; !existed {
events = append(events, addedEvent(p)) // 真新檔
} else if orig[p].IngestedHash == "" {
events = append(events, addedEvent(p)) // 上輪偵測過但 ingest 未成功 → 重試
}
}
// 5) modifiedmanifest 有、現況有、content_hash != ingested_hashdesign §3 順序 3)。
for _, p := range sortedCurrent {
if dupLoser[p] != "" { // 同內容的另一格式已在事件管線,這份跳過(1.5)
continue
}
e, existed := orig[p]
if !existed {
continue
}
if _, isRenamed := renamedOldOf[p]; isRenamed {
continue
}
if e.IngestedHash != "" && current[p].hash != e.IngestedHash {
st := current[p]
size := st.size
events = append(events, Event{Type: "modified", Path: p, SourceHash: st.hash, Size: &size, R2Key: r2KeyOf(st.hash)})
}
}
// 6) removed(扣掉已配對走的)+大量刪除防呆(R6)。
var finalRemoved []string
for _, p := range removedPaths {
if !pairedOld[p] {
finalRemoved = append(finalRemoved, p)
}
}
var warnings []Warning
guardTripped := manifestCountBefore > 0 &&
float64(len(finalRemoved)) > opts.MaxRemovedRatio*float64(manifestCountBefore)
if guardTripped {
warnings = append(warnings, Warning{
Code: "mass_delete_guard",
Message: fmt.Sprintf(
"本輪偵測到 %d/%d 個檔案消失(超過 %.0f%% 門檻)——可能是資料夾未掛載或同步半途。本輪全部「不」下架,請確認資料夾完好後再放行。",
len(finalRemoved), manifestCountBefore, opts.MaxRemovedRatio*100),
RemovedCount: len(finalRemoved),
ManifestCount: manifestCountBefore,
ThresholdRatio: opts.MaxRemovedRatio,
})
} else {
for _, p := range finalRemoved {
events = append(events, Event{Type: "removed", Path: p, SourceHash: orig[p].ContentHash})
}
}
// 7) 更新 manifestrebuild):現況檔全數收錄;ingested_* 由舊 entry(或 renamed 的舊路徑)搬運。
// 防呆觸發時 removed 條目保留(下輪重評、警告會再響,直到人確認或檔案回來)。
newEntries := make(map[string]*ManifestEntry, len(current))
for p, st := range current {
ne := &ManifestEntry{ContentHash: st.hash, Size: st.size, Mtime: st.mtime}
var carry *ManifestEntry
if op, isRenamed := renamedOldOf[p]; isRenamed {
carry = orig[op]
} else if e, ok := orig[p]; ok {
carry = e
}
if carry != nil {
ne.IngestedHash = carry.IngestedHash
ne.IngestedAt = carry.IngestedAt
// 🔴 t195:掃描每輪都**重建** entry,原本只 carry 上面兩欄 ⇒ 其餘欄位靜默歸零。
// 實撞:失敗退避(fail_count/next_retry)寫進去了,下一輪掃描卻被抹掉
// ⇒ 退避永遠停在「第 1 次失敗」,等同沒有退避(1387 輪的病根之一)。
// ExtractedBy(t73 記的「誰萃的」)原本也一樣悄悄丟失。
// ⚠️ 之後在 ManifestEntry 新增任何「跨輪要記住」的欄位,都必須加在這裡。
ne.ExtractedBy = carry.ExtractedBy
ne.FailCount = carry.FailCount
ne.LastFailAt = carry.LastFailAt
ne.NextRetry = carry.NextRetry
// 🔴 2026-08-08Evan 封測回報「20 份沒送進知識庫」但展開看不到真因):
// LastError 是 t195 同一批加的欄位,卻**漏在這個 carry 裡** ⇒
// 每輪掃描(預設 5 秒)就把失敗真因抹掉一次,下一輪退避訊息組不出「|原因:」
// ⇒ 畫面只剩 humanizeFailure 的最後退路「當時沒有記下原因」。
// 這正是上面那句警告的第二次實例——真因是我們自己刪掉的,不是沒記。
ne.LastError = carry.LastError
}
newEntries[p] = ne
}
if guardTripped {
for _, p := range finalRemoved {
newEntries[p] = orig[p]
}
}
m.Entries = newEntries
m.Root = root
if events == nil {
events = []Event{}
}
sort.Slice(skipped, func(i, j int) bool { return skipped[i].Path < skipped[j].Path })
return &TriggerPayload{
SchemaVersion: 1,
FolderID: m.FolderID,
Root: root,
GeneratedAt: time.Now().Unix(),
Events: events,
Warnings: warnings,
Skipped: skipped,
SkippedOther: skippedOther,
SkippedOtherNames: skippedOtherNames,
DuplicateFormats: duplicateFormats,
Plan: opts.Plan,
ExcludedByPlan: excludedByPlan,
}, nil
}