sync: collector/ 同步自 inkstone/arcrun-rag@99ff817(桌面小幫手 0.18.56)

This commit is contained in:
2026-09-17 20:43:06 +08:00
parent d052c33d26
commit ba584002d0
8 changed files with 320 additions and 8 deletions
+245
View File
@@ -0,0 +1,245 @@
package collector
// inkstone/arcrun-rag#196:雲端 1.4.64 起修掉「單次呼叫子請求太多」那面牆,
// 但修之前被記滿 8 次、暫停自動重試的檔不會自己動——用戶不改檔、不按「立刻同步」就永遠送不上去。
// 09-17 實查 geek6688:13 份掛著這種暫停,含本票量測用的兩張卡。
// 本檔用那兩張卡在 geek6688 帳本上的**原樣病歷**testdata/196_geek6688_paused.json)重演。
import (
"encoding/json"
"io"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
type pausedFixture struct {
Entries map[string]struct {
FailCount int `json:"fail_count"`
LastFailAt int64 `json:"last_fail_at"`
NextRetry int64 `json:"next_retry"`
LastError string `json:"last_error"`
} `json:"entries"`
}
const (
card196Sample = "system-dev/wiki/cards/arcrun-sample-knowledge.md"
card196N8n = "system-dev/wiki/cards/arcrun-n8n版本比較表新-2025.11-範本.md"
)
// fakeCloud196 =一台假知識庫:萃取一律成功;收卡依 wall 決定回成功或回 geek6688 那句 500。
type fakeCloud196 struct {
mu sync.Mutex
version string // /health 的 bundle_version
wall map[string]bool // 這些檔收卡時回「Too many subrequests」
posts map[string]int // 每個檔實際打到 rag_ingest_card 幾次
wallErr string
}
func (f *fakeCloud196) handler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req struct {
PageName string `json:"page_name"`
Text string `json:"text"`
Path string `json:"path"`
}
_ = json.Unmarshal(body, &req)
f.mu.Lock()
defer f.mu.Unlock()
switch {
case strings.HasSuffix(r.URL.Path, "/portal/daemon/extract"):
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "output": cardFixture(req.PageName, "測試")})
case strings.HasSuffix(r.URL.Path, "/rag_ingest_card/trigger"):
f.posts[req.Path]++
if f.wall[req.Path] {
w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, f.wallErr)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
default:
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
}
}
func (f *fakeCloud196) postsOf(p string) int {
f.mu.Lock()
defer f.mu.Unlock()
return f.posts[p]
}
// setup196 建出「geek6688 今天的樣子」:兩張卡在帳本上是舊版留下的暫停病歷(沒記雲端版本)。
func setup196(t *testing.T) (cfg *DirectConfig, mp, root string, cloud *fakeCloud196, clock *time.Time) {
t.Helper()
cloudRoutes.reset()
t.Cleanup(cloudRoutes.reset)
withCloudCheckInterval(t, 0)
raw, err := os.ReadFile(filepath.Join("testdata", "196_geek6688_paused.json"))
if err != nil {
t.Fatal(err)
}
var fx pausedFixture
if err := json.Unmarshal(raw, &fx); err != nil {
t.Fatal(err)
}
if len(fx.Entries) != 2 {
t.Fatalf("fixture 應有兩張卡:%d", len(fx.Entries))
}
root = t.TempDir()
writeFile(t, root, card196Sample, "# Arcrun 範例知識\n\nArcrun 是跑在 Cloudflare 上的工作流引擎。", baseTime)
writeFile(t, root, card196N8n, "# n8n 版本比較表\n\n| 版本 | 說明 |\n|---|---|\n| 1.0 | 測試 |", baseTime)
cloud = &fakeCloud196{wall: map[string]bool{}, posts: map[string]int{},
wallErr: fx.Entries[card196Sample].LastError[len("HTTP 500"):]}
oldFetch := fetchCloudVersion
fetchCloudVersion = func(string) (string, bool) {
cloud.mu.Lock()
defer cloud.mu.Unlock()
return cloud.version, true
}
t.Cleanup(func() { fetchCloudVersion = oldFetch })
// 先占位址再關掉:第一輪連不上(只為了建出帳本),之後在同一個位址開假雲端。
ln0, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := ln0.Addr().String()
_ = ln0.Close()
c := time.Unix(fx.Entries[card196Sample].NextRetry, 0).Add(4 * 24 * time.Hour) // 09-17:暫停早已過了退避窗口
clock = &c
oldNow := directNow
directNow = func() time.Time { return *clock }
t.Cleanup(func() { directNow = oldNow })
cfg = &DirectConfig{
Manifest: filepath.Join(t.TempDir(), "m.json"),
CardIngestWF: "rag_ingest_card", IngestWF: "rag_ingest_direct", RemovedWF: "rag_takedown_direct",
Extractor: "workers-ai", ExtractorExplicit: true, MachineLabel: "測試機",
MaxRemoved: DefaultMaxRemovedRatio,
Accounts: []AccountConfig{{CypherURL: "http://" + addr, Namespace: "demo", APIKey: "demo",
InstanceName: "geek6688", WatchFolders: []string{root}}},
}
RunDirectOnce(cfg, false)
cloudRoutes.reset() // 建帳本那一輪的連線失敗不要帶進正式情境
acc := cfg.makeAccountSubConfig(cfg.Accounts[0])
mp = acc.manifestPathFor(root)
m, err := LoadManifest(mp, root)
if err != nil {
t.Fatal(err)
}
for p, f := range fx.Entries {
e := m.Entries[p]
if e == nil {
t.Fatalf("帳本裡沒有 %s", p)
}
e.IngestedHash, e.IngestedAt, e.NoCloudCard = "", 0, false
e.FailCount, e.LastFailAt, e.NextRetry, e.LastError = f.FailCount, f.LastFailAt, f.NextRetry, f.LastError
e.FailCloudVersion = "" // 舊版小幫手不記這欄
}
if err := m.Save(mp); err != nil {
t.Fatal(err)
}
ln, err := net.Listen("tcp", addr)
if err != nil {
t.Skipf("無法重新綁定同一個埠(%v)", err)
}
srv := &http.Server{Handler: http.HandlerFunc(cloud.handler)}
go func() { _ = srv.Serve(ln) }()
t.Cleanup(func() { _ = srv.Close() })
if p := mustLoad(t, mp, root).Progress(); p.Stuck != 2 {
t.Fatalf("前置條件:兩張卡都該是暫停:%+v", p)
}
return cfg, mp, root, cloud, clock
}
func mustLoad(t *testing.T, mp, root string) *Manifest {
t.Helper()
m, err := LoadManifest(mp, root)
if err != nil {
t.Fatal(err)
}
return m
}
// 雲端還是舊版 ⇒ 照舊暫停、不重撞;雲端更新到 1.4.64 以上 ⇒ 不用人動手,兩張卡自己送上去、算完成。
func TestDirect196_SubrequestPausedResumesAfterCloudFix(t *testing.T) {
cfg, mp, root, cloud, clock := setup196(t)
cloud.version = "1.4.63"
results, _, _ := RunDirectOnce(cfg, false)
if n := cloud.postsOf(card196Sample) + cloud.postsOf(card196N8n); n != 0 {
t.Fatalf("雲端還是 1.4.63(沒修那面牆)時不該重撞,卻送了 %d 發", n)
}
for _, r := range results {
if (r.Path == card196Sample || r.Path == card196N8n) && !strings.Contains(r.Error, "已暫停自動重試") {
t.Fatalf("舊雲端上 %s 應維持暫停說明,拿到 %q", r.Path, r.Error)
}
}
if p := mustLoad(t, mp, root).Progress(); p.Stuck != 2 {
t.Fatalf("舊雲端上兩張都該維持暫停:%+v", p)
}
cloud.version = "1.4.67" // geek6688 09-17 的 /health
*clock = clock.Add(time.Minute)
RunDirectOnce(cfg, false)
if cloud.postsOf(card196Sample) == 0 || cloud.postsOf(card196N8n) == 0 {
t.Fatalf("雲端更新後兩張都要自己送出:sample=%d n8n=%d", cloud.postsOf(card196Sample), cloud.postsOf(card196N8n))
}
m := mustLoad(t, mp, root)
if p := m.Progress(); p.Stuck != 0 || p.Done != p.Total {
t.Fatalf("兩張都要算完成:%+v", p)
}
for _, p := range []string{card196Sample, card196N8n} {
if e := m.Entries[p]; e.FailCount != 0 || e.LastError != "" || e.FailCloudVersion != "" {
t.Fatalf("%s 送成功後病歷要清乾淨:%+v", p, e)
}
}
}
// 在已經修好的雲端上**又**撞同一面牆(卡真的太大)⇒ 記下這一版,之後不再自動重撞燒額度。
func TestDirect196_StillTooBigOnFixedCloudStaysPaused(t *testing.T) {
cfg, mp, root, cloud, clock := setup196(t)
cloud.version = "1.4.67"
cloud.wall[card196Sample] = true
RunDirectOnce(cfg, false)
first := cloud.postsOf(card196Sample)
if first == 0 {
t.Fatal("雲端已修好時,舊暫停的卡應該要再試一次")
}
m := mustLoad(t, mp, root)
e := m.Entries[card196Sample]
if e.FailCount < MaxFailBeforeSkip || e.FailCloudVersion != "1.4.67" || !isSubrequestLimitText(e.LastError) {
t.Fatalf("在新雲端上又撞牆,病歷要記下 1.4.67:%+v", e)
}
if p := m.Progress(); p.Done != 1 || p.Stuck != 1 {
t.Fatalf("n8n 那張送成、sample 那張暫停:%+v", p)
}
*clock = clock.Add(7 * 24 * time.Hour) // 遠超過最長退避
cloudRoutes.reset()
RunDirectOnce(cfg, false)
if got := cloud.postsOf(card196Sample); got != first {
t.Fatalf("已在修好的雲端上撞過牆,不該再自動重撞:%d → %d", first, got)
}
}
func TestCloudHasSubrequestFix196(t *testing.T) {
for v, want := range map[string]bool{
"": false, "1.4.63": false, "1.4.64": true, "1.4.67+b08a8cee": true, "1.10.0": true,
"2026-09-13+1eb26c9": false, "unknown": false,
} {
if got := cloudHasSubrequestFix(v); got != want {
t.Errorf("cloudHasSubrequestFix(%q)=%v,要 %v", v, got, want)
}
}
}