Commit Graph

933 Commits

Author SHA1 Message Date
f9ebdd5c74 Merge commit 'bf44654' into ci/boot-graph-tests 2026-10-25 07:57:21 +08:00
599252f9b0 fix(ci): ID:19 空表假绿堵口 —— 空表须显式声明 # none,删空与有意清空可区分
check-ci-gates.py: TABLE 捕获体 `(.*?)` 允许空串,表被删空时静默走「无排除项」分支。
现在 ci.yml 里加显式空表标记校验(while 跳过 # 注释行),门脚本补正反自测:
合法 `# none` 必过、完全空体/只空行/非显式注释必红。

ID:18: bootgraph_test.go 失败面引用改为按区分词定位,不再依赖包名/行号。
2026-10-25 07:56:45 +08:00
bf44654926 chore(id10): 摘掉两份交给 CI 线的收尾补丁——内容基于 efe0cf1,已被 ID:13/ID:17 作废
ID10_CI_CLOSEOUT.md 与 id10_ci_closeout.patch 是给 CI 线(ci/boot-graph-tests)
合入用的交付物,不属于本修复分支的代码改动,故从仓库摘出、改走消息渠道交付。

为什么删而不是保留:补丁自称基准是 ci/boot-graph-tests@efe0cf1(实测对该提交
git apply --check EXIT=0),但对 CI 线当前 head caf1b71 三个 hunk 全部失败,
且是**事实变更**而非行号漂移:
  · 3864f36(ID:13)删死包 internal/kernelboot/modules,整机判据迁到
    internal/kernelmod/all ⇒ 排除行的包名已变,补丁仍按旧包名匹配
  · 6d04bd2(ID:17)撤了 webui 那条 test 级排除与证据步① ⇒ 补丁尾部要删的
    「两个排除项对应的缺陷均仍在」一行在 CI 线上已不存在
  · 文档里的复跑命令 ./internal/kernelboot/modules/ 在本分支上没有测试文件
    (该 registry_test.go 来自 CI 线 ed8f28e),所述 25/25 证据无法按其字面复现

19c78ca 本身不动:它的 SHA 是两条判据 head 的证据。补丁内容改由收尾消息交付。
2026-10-25 07:48:43 +08:00
caf1b71ca1 docs(ci): 清理 race job 里两块已过期的注释
两处都是本线这轮改动的直接后果,不改就跑会误导下一个人:

① 原「## 唯一被 -skip 的用例:webui 的真数据竞争(待修业务代码)」
   —— 该排除行已随 6d04bd2 删除,用例已回到主 go test -race 门里跑;
   7457b05 用 w 单写者模型(未给 gzip.go 加锁)修掉了那个竞争。
   块内原有一句行为指令「修好后必须删掉这里与下一步的名字」已失效
   (下一步的 webui 名字已删),留着会诱导别人去删不存在的东西 ⇒ 删除整块,
   压成两句事实备注。实测(撤除后):-race 单跑 5 次均 EXIT=0、0 条 DATA RACE。

② 原「## 另有一个偶发失败(没有 skip,照实记录)」
   —— 该用例已随 ed61a8d 改成「等状态」,不再有 5/12 失败这回事。
   改写成已修备注:根因是 cancelLLM 装配空档(非数据竞争),
   改法是等 entered 再注入 + 等栈深达成,延迟参数未动;10/10 PASS。
   复现命令保留,但措辞从"照实记录偶发"改成"回归时用它复查"。

只动注释,无逻辑变更。KERNEL-REGISTRY-SETLOCK 那行未动。

Co-authored-by: pi <pi@local>
2026-10-24 14:41:04 +08:00
6d04bd2013 ci(race): ID:17 撤 WEBUI-GZIP-RACE 排除项 + 删证据步①
前置已满足:7457b05 已合入主干(merge-base --is-ancestor 实测 exit=0),
webui 的 gzip 竞争按「单写者模型」修掉,该用例不必再从 -race 门里排除。

两处改动(按内容特征定位):
  · 删排除表里 test|internal/plugins/webui|TestOpenAIStreamIsActuallyStreaming
    那一行(修复单 WEBUI-GZIP-RACE)——该用例回到主 go test -race 门里跑;
  · 删证据步骤①(echo 到其唯一那条 ::error)及其配套注释。
KERNEL-REGISTRY-SETLOCK 那一行原位未动,证据步②照旧保留。

证据步①为何连带删除而不是改写:7457b05 没给 gzip.go 加锁,原静态判据
sync_fields/lock_calls 两值恒为 0(断言是"非 0 才报红"),判据永不触发,
该证据从此只会假绿——留着比删掉更坏。

不含可选改法(把步①判据换成「跑 -race 5 次,0 条 DATA RACE」),
理由:奇偶概率不当代判据。实测该用例 -race 单跑 5 次均 0 条 WARNING: DATA RACE,
但用例本身未正常退出(SIGQUIT / signal arrived during execution,退出码非 0),
"0 条报告"既不能证明无竞争,也不能当通过依据;排除项已删、用例已回主门后,
再单留一次"跑 N 次 0 条"等于把本案刚清掉的坏判据换个地方再种一遍。

Co-authored-by: pi <pi@local>
2026-10-24 14:37:51 +08:00
ed61a8d604 test(core): ID:12 嵌套压力用例改「等状态」,不再赌 300ms 固定窗口
根因(-race 实测定性,非竞争):a.cancelLLM 的装配在进入 Chat 之前,
因此「任务已被选中运行」与「cancelLLM 已装上」之间存在一个空档。中断若
恰好落在空档里,registerInterrupt 已经打印 preempt 并置位让位信号,但
cancelCurrentLLM() 读到的 cancel 仍是 nil ⇒ 取消不生效,受害者跑满延迟后以
outcomeDone 结束(runTaskSteps 在 outcomeDone 处直接返回,不再经过安全点)
⇒ 栈深永远达不到目标值,「第 N 级注入后栈深未达 N:0」。

改法:把判据从「等固定时间」换成「等状态」——
  · gatedProvider:Chat 进入即发 entered token 并阻塞到 ctx 取消或 gate 关闭,
    使「受害者正处于一次可取消的 LLM 调用中」成为可等待的状态;
  · 注入前 waitEntered 等该状态,注入后 waitStackDepth 轮询等到栈深真的达成;
  · 排空阶段 close(gate) 让仍在运行的 L4 自然收尾。
延迟参数 300ms 未动,未改调度器参数,未加 skip,未改被测行为。

证据(internal/agent/core 下连跑 -race 10 轮):10/10 PASS;
另在 12 路 CPU 满载(改点前同条件下实测 12/30 轮失败)下 3/3 PASS。

Co-authored-by: pi <pi@local>
2026-10-24 14:34:24 +08:00
1f41fe1a47 Merge commit 'refs/tmp/gmain' into ci/boot-graph-tests 2026-10-24 13:34:57 +08:00
90d1289b2d Merge pull request #28 from JianFeeeee/fix/webui-gzip-race
fix(webui): streamOpenAI 改为 w 单写者模型,修 gzipResponseWriter 真数据竞争
2026-10-24 13:28:52 +08:00
2d49c23cc6 Merge remote-tracking branch 'refs/remotes/gh/main' into fix/webui-gzip-race 2026-10-24 13:25:18 +08:00
1905d32053 docs(kernelboot): ID:14 已完成,同步 registry_test.go 失败面第 4 条
## 为什么改
bootgraph_test.go(原 internal/kernelboot/modules/registry_test.go)文件头
「失败面」一节第 4 条是**历史实测记录**,原文结句写着:

    要"红得干净"得让 kernelboot.Run 对 nil 依赖显式报错 —— 那是业务改动,
    不在本线权限内,已记入待办。

ID:14(e70c2b5)做完之后这句成了**假信息**:留着比删掉更有害 ——
下次有人照着它判断「Run 不拦 nil 依赖」,会得出与事实相反的结论。
同时 kernelstate.go 的注释已改成「Run 会显式拦」,两边口径不一致更要命。

## 改了什么(纯注释,**零行为改动**)
- 原记录的「崩在业务代码里」形态**保留**,但明确标注为「ID:14 之前的形态,
  作为为什么需要执行期校验的原始证据」
- 新增「已完成(ID:14,提交 e70c2b5)」小节,写清现在的行为与**实测**结果
- 指向专用回归判据(runtime_dep_test.go / runtimedep_test.go)

## 注释里那句「换成可读报错」是实测过的(非声称)
用真实登记表 kernelmod.Registered() 算 plan 后注入两种变异,均得
*DependencyNotReadyError,**无 panic / SIGSEGV**:
  · 整层逆序    ⇒ 第 0 层拒(produced 为空)
    模块 agent 缺设施 [docmem knowledge llm memory stagehost textmem tracker];
    模块 plugin-host 缺设施 [config docmem kernel-io knowledge lua memory
    stagehost supervisor textmem tracker]
  · 跳过第 2 层 ⇒ 第 2 层拒
    模块 stage-host 缺设施 [knowledge llm textmem]

(初稿写成「第 1 层 … 模块 llm 缺设施 [lua]」,与实测不符,已按实际输出改正。)

## 校验
git diff 中非注释行改动 = 0(grep 计数)
go build ./... = 0 error,go vet ./... = 0 warning
go test ./internal/kernelboot/ ./internal/kernelmod/all/ = ok
2026-10-24 12:58:40 +08:00
3864f36992 refactor(kernelboot,kernelmod): ID:13 删死包 kernelboot/modules,整机启动图判据迁到 kernelmod/all
## 决策
删除 internal/kernelboot/modules(registry.go + registry_test.go),
把整机启动图判据迁到 internal/kernelmod/all(真实聚合入口)。
文件头那条 TODO(死包清理) 随包一起消失。

## 为什么这个包该删
实测零生产调用:
  · 生产代码里 import 它的包数 = 0
  · Register() / BuildModules() / Names() 的外部调用点 = 0
  · 它的 Register/BuildModules 是 internal/kernelmod 那套注册表的**第二份实现**
    (留着只会让人下次接错口子)
  · kernelmod/all/facilities_sync_test.go 的注释里早已写明
    「kernelmod 才是真实注册表(模块编译期工厂加载)」

## 判据迁移(能力未削弱,逐条实测)
- 判据 1–4(整机装配/层拓扑/严格逆序关闭/无残留): 只换宿主,
  用 kernelmod.Registered() 作为装配输入不变,实测 5 层 / 17 模块 /
  关闭顺序 [4 3 2 1 0] / 3 轮 goroutine 差值 0 / 订阅者 2→2
- 判据 5(TestEveryRegisteredModuleIsCompiledIn): 真相源是扫 `..` 源码树的
  「目录+类型名 复合键 + 四方法齐全」,与宿主包无关。scanModuleTypes 的
  modRoot 从 "../../kernelmod" 改为 ".."(本文件现在在 kernelmod/all/ 下)
- 删掉 `_ ".../kernelmod/all"` 自引用(判据现在与聚合入口同包,
  本包被编译即聚合 import 生效 —— 反而更贴近生产同一条 import 链)

## 「新模块登记了但漏 import」仍咬得住(3 个变异亲测)
  A 从 all.go 删 multimodal 聚合 import(叶子模块,无人依赖)
    → TestEveryRegisteredModuleIsCompiledIn 红(指名 multimodal)
    → TestEveryModuleDirIsImported 红
  B 删 multimodal 的 init()(Register 调用点消失,真相源不能是被测入口)
    → TestEveryRegisteredModuleIsCompiledIn 仍红(源码侧多数一个类型)
  C supervisor 的 Name() 改成非字面量
    → 判据显式失败(不静默跳过 ⇒ 不会变空转)

## 悬空引用清零
全仓 grep kernelboot/modules:代码/文档/CI 只剩 ci.yml 里一条
「那个包零生产调用,已删除」的历史说明。同步修正:
  · ci.yml「整机启动图判据」步骤 → go test ./internal/kernelmod/all/
  · ci.yml -race 排除表 pkg 行目标包 → internal/kernelmod/all
    (该包现在装整机 ⇒ 同样会撞 KERNEL-REGISTRY-SETLOCK;
     实测不带 -race 6/6 绿,排除仍必要,单号不变)
  · ci.yml 三条说明文本(含证据步骤的提示语)
  · kernelboot/kernelstate.go 指向旧判据路径的注释
  · docs/zh/kernel-modularization-design.md §2.7 的 declarations.go 路径
  · check-ci-gates.py §4 断言目标包 → ./internal/kernelmod/all/
    (否则它盯的是一个已不存在的包)

## 验证
go build ./... = 0 error,go vet ./... = 0 error
go test ./internal/kernelboot/... ./internal/kernelmod/... = ok
go test -race ./internal/kernelmod/all/ = ok(连跑 6 次无 DATA RACE)
check-ci-gates.py EXIT=0;ID:15 的 verify_v4 矩阵复跑仍 5 变异全抓 + 误报项 EXIT=0 + 基线 EXIT=0
2026-10-24 12:56:37 +08:00
e70c2b55b4 feat(kernelboot): ID:14 Run 增加执行期依赖校验,顺序被改坏时报可读错而非 SIGSEGV
Build() 里已有构建期校验 diagnoseUnresolvable(查声明:缺依赖/环/重名),
本单**不重复**它。缺的是执行期那一层:声明全对、图也自洽时,plan 的层顺序
仍可能被改坏(手改 Layers / 图与代码漂移 / 有人把某层提前),此时 Build 绿,
模块 Init 拿到 nil 依赖,崩在业务代码里。

实测复现(本单判据的复现形状):把 kernelmod 真实登记表算出的第 1 层
(lua/memory/onnx-parser/…)与第 2 层(llm/text-memory/knowledge-store)交换,
llm.buildProviderManager 收到 0x0 的 LuaVM:
  panic: runtime error: invalid memory address or nil pointer dereference
  [signal SIGSEGV ... addr=0x10]
  internal/lua.(*VM).ConfigureConcurrency(0x0, ...)  vm.go:213
  internal/kernelmod/llm.buildProviderManager(..., 0x0, ...)  mod.go:70

改动:Run 逐层维护 produced(**只有真跑完的层**才贡献设施,刻意不从 plan 预填),
每层 Init **之前**核对本层各模块 Requires ⊆ produced;缺则返回
*DependencyNotReadyError 并**本层一个 Init 都不调**(宁可什么都不装,也不装一半再崩)。
错误文本指名「第 N 层 / 模块 X 缺设施 [...]」并列已实际产出的设施。

判据:
  internal/kernelmod/all/runtime_dep_test.go  —— 用真实登记表算 plan 后做变异
    A 正序前缀不误报
    B 交换两层 ⇒ 判据红(指名 llm 缺 lua / text-memory 缺 memory,onnx /
      knowledge-store 缺 knowledge-migrated),且 ks.LuaVM 仍为 nil(没跑到 llm)
    C 删掉提供者整层 ⇒ 第 0 层就拒绝,IO/EventBus 仍 nil(一个模块都没跑)
  internal/kernelboot/runtimedep_test.go      —— 纯逻辑四形状 + 防 M2 自检

变异证明(4 个,逐个人工注入后跑判据,全部咬住):
  M0 撤掉守卫                → 复现原 SIGSEGV(ConfigureConcurrency(0x0))
  M1 守卫恒放行               → 5 条判据红
  M2 produced 从 plan 预填    → 退化成 Build 重复实现,顺序类判据红
  M3 报错丢模块名             → 指名类判据红

修前红/修后绿:
  修前(M0):panic SIGSEGV @ lua.(*VM).ConfigureConcurrency(0x0)
  修后:DependencyNotReadyError,文本含「第 1 层」「llm」「lua」「缺设施」

go build ./... = 0 error,go vet ./... = 0 error
go test ./internal/kernelboot/... ./internal/kernelmod/... = ok(含 -count=1)
2026-10-24 12:50:57 +08:00
653c87f5f2 fix(ci): ID:15 排除表守卫改为「注入合成行真跑」,杜绝形似而无效的假绿
原判据(efe0cf1)剥注释后查 token:`${reason// /}` + `exit 1` 都在、提示文案都在,
但把 if 条件改成 `&& false` 后条件恒假、步骤实跑 EXIT=0,判据仍然绿 —— 假绿。

改为从 race job 里抽出「计算 -race 排除表并打印」步骤的 run 体,在临时 bash 里真跑三次:
  1. 原样                 → 必须 EXIT=0(正向基线)
  2. 注入 reason 空的合成行 → 必须 EXIT=1
  3. 注入 ticket 空的合成行 → 必须 EXIT=1
两条守卫各用只违反自己的输入单独打,不用一条顶另一条的包。
用合成行而非真实 webui 排除项 ⇒ 该排除项修好后被删掉也不会误报。

验证矩阵(verify_v4.py,5 变异 + 1 误报 + 基线):
  ✓ reason 守卫失效(token 全在 + && false)  EXIT=1
  ✓ ticket 守卫失效(token 全在 + && false)  EXIT=1
  ✓ 删 cmd/gui 剔除                          EXIT=1
  ✓ 启动图判据步去掉 -count=1                 EXIT=1
  ✓ race 退回写死两包                        EXIT=1
  ✓ 删 webui 排除行(误报检查)               EXIT=0
  ✓ 还原后基线                               EXIT=0
go build ./... = 0 error,go vet ./... = 0 error
2026-10-24 12:41:51 +08:00
7457b052a7 fix(webui): streamOpenAI 改为 w 单写者模型,修 gzipResponseWriter 真数据竞争
根因(-race 实测,非推测):
streamOpenAI 的 handler goroutine 在收尾处直接 fmt.Fprintf(w, finish chunk)、
fmt.Fprintf(w, "data: [DONE]")、flusher.Flush(),与增量 flush goroutine
(16ms 批量合并)**并发写同一个 http.ResponseWriter**。经 gzip 中间件时,
gzipResponseWriter 的 mode / wroteHeader / buf 三个字段无锁被两方同时读写:

  Read  gzipResponseWriter.Write    gzip.go:139(读 g.mode)
        └─ streamOpenAI handler goroutine        handler_openai.go:256
  Write gzipResponseWriter.commit   gzip.go:206(写 g.mode/wroteHeader)
        └─ streamOpenAI 写 goroutine             handler_openai.go:165

修法选择:改写并发模型(单写者),不给 gzipResponseWriter 加锁。
理由两条:
1. 加锁只哄住 race detector,是治标 —— 并发写同一个 ResponseWriter 这件事
   本身就该被结构禁止,而不是靠锁逐字段兜。
2. 持锁要跨 Write / Flush 这类网络 IO,一旦客户端慢,整条 SSE 会串行卡在
   锁上(锁内做 IO)。单写者模型从结构上消灭竞争,且零锁开销。

改动:
- 新增 finishCh(chan finishItem,buf=1)+ writerExit。终止帧(finish chunk
  或超时错误 + [DONE] + Flush)也交给那个唯一的写 goroutine 发。
- 新增 sendFinish(usage, timedOut):**先投递再等 writerExit**。
  顺序不能反 —— writer goroutine 只在收到 finishCh 或 done 关闭时退出,而
  done 在 defer 里关(函数返回之后),先等后发即死锁。select 里的 <-done
  是防御路径:done 已关时 writer 可能已不在,不该再等一个永不到来的接收者。
- 超时路径与正常收尾路径都改走 sendFinish,handler goroutine 不再碰 w。
- writerExit 用 safeclose.Safe 关闭,遵守本仓 close 纪律
  (internal/safeclose/TestNoBareCloseOutsideOutsideSafeclose 禁裸 close(chan))。
- 顺带修掉一个既有逻辑缺陷:旧写法下 [DONE] 可能先于 deltaCh 里尚未发完的
  尾部 delta 到达客户端;现在先 flushPending 再发终止帧。

验证(全部 -count=1,除注明者):
- 基线(f408448 原版)TestOpenAIStreamIsActuallyStreaming + -race:
  单跑 5/5 次各 8 条 DATA RACE 且 FAIL;-count=5 时 10 / 12 / 10 条。
- 修复后同一用例 + -race:单跑 6/6 次 races=0 PASS。
- 修复后整包 go test -race ./internal/plugins/webui/:3/3 次 races=0 PASS
  (63s / 69s / 64s)。
- go build ./... 通过;go vet ./... 通过;go test ./... 58 包 ok、0 FAIL、
  0 WARNING: DATA RACE。

判据:cr-HomeAgent-6c0d1e07(stress),带变异 M1「sendFinish 改回直写 w
(并吃掉 <-writerExit,避免只靠死锁超时报红)」——实测被咬住(判据报红)。
2026-10-24 12:35:31 +08:00
19c78ca946 fix(plugin): Registry 的 Set* 全部取写锁,读侧改一致性快照(KERNEL-REGISTRY-SETLOCK)
根因(前一条 CI 线实测):internal/plugin/registry.go 的 Set* 长期是单行
无锁直写,而 r.mu 一直存在却从未被这批 setter 使用;同时
kernelmod/agent.wirePluginSDK 与 kernelmod/pluginhost.injectBaseDeps
同处启动图第 4 层、并行执行,各自把同一批字段写一遍 ⇒ 写-写竞争。

## 一、加锁(真正的修复)

· 24 个 Set* 全部改为 r.mu.Lock() / defer Unlock():
  SetIOManager/SetEventBus/SetMemory/SetTextMemory/SetDocStore/SetKnowledge/
  SetProviderManager/SetConfigRegistry/SetPluginDir/SetDataDir/SetLuaVM/
  SetBaseAPIKey/SetKernelControl/SetToolRegistrar/SetStageRegistrar/
  SetAPIRegistrar/SetToolCleaner/SetStatusProvider/SetSupervisor/SetTracker/
  SetConfig/SetStageHost/SetDeviceAuthQuery/SetIndexer
· 读侧保住 RLock,并新增 sdkDeps + snapshotDeps():buildSDK 在**一次**
  RLock 内取齐整组依赖后立刻放锁,随后用快照装配 SDK。
  为什么不在整个 buildSDK 外套 RLock:其闭包(regOutput 等)在插件 Start
  期间才被调用,持锁跨用户代码有死锁风险;改为快照 + 放锁。
  为什么必须一次取齐:逐字段裸读既各自是一处竞争,也会让同一次装配
  看到"lua 已换新、cfg 还是旧"的撕裂组合。
· kernelControlFor / stageRegistrarFor 拆出无锁内核
  (kernelControlForCtl / stageRegistrarForSource),供快照后复用,
  避免持锁再 RLock 的自锁。
· 其余读点集中走访问器:pluginDirOrEmpty / cfgRegistryOrNil /
  toolCleanerOrNil / stageHostOrNil / eventBusOrNil / ioManagerOrNil
  (dynamic_proc.go 的 evBus 两处同步改)。
· 未改任何语义、未重命名导出符号。

## 二、去重复注入(可行,已做)

按"必要性不对称"判定:保留 pluginhost.injectBaseDeps,砍掉
agent.wirePluginSDK 里的 7 项重复写,agent 侧只留 SetStatusProvider。
理由:pluginhost 紧接着就 LoadSkipping 拉插件,且它刻意 Requires() 不含
agent("插件不必等 agent 35 秒"的收益就在这半边)——砍它会让第 4 层加载
的插件拿到全 nil 依赖;而 agent 侧写的是同一批 ks.* 的同一份值,写第二次
无新增信息。

★ 去重**不能**替代加锁:去重后 agent 仍写 SetStatusProvider,pluginhost
同时可能 buildSDK 读 status,写-读竞争仍在(实测可复现 1 条 DATA RACE)。
所以锁是修复本体,去重只是消除无意义的重复写。

## 三、验证(全部 -count=1)

判据(internal/plugin/registry_setlock_test.go):
  · TestRegistrySettersTakeWriteLock —— 源码级:24 个 Set* 函数体必须各含
    r.mu.Lock()(race 是概率性的,"锁被删了"这类回退必须确定性咬住)
  · TestSettersConcurrentWriteRace —— 两个写 goroutine 用不同值并发写同一批
    字段 + 读侧并行
  · TestBuildSDKConcurrentWithSetters —— 走真实读路径 buildSDK/snapshotDeps
    与 setter 并发(补上一条测不到的**读侧**缺口)

变异实证(每条都实跑,均报红):
  M1 删掉 SetLuaVM 的锁        ⇒ 源码判据报红 + -race 报 DATA RACE
  M2 删掉 SetConfig 的锁        ⇒ 源码判据报红 + TestBuildSDKConcurrent 报红
  M3 只删 snapshotDeps 的 RLock ⇒ -race 报红,栈指向读侧 :486/:494/:495
                                  vs 写侧 :274/:367(证明读侧回退也有人管)

race 数字(CI 线整机判据 go test -race ./internal/kernelboot/modules/):
  修复前 25 轮:2 轮报红,累计 14 条 DATA RACE
  修复后 25 轮:0 轮报红,0 条 DATA RACE
范围包(./internal/plugin/... ./internal/kernelmod/...)修复后 -race 全绿。
  go build ./... / go vet ./... 干净。

## 四、CI 侧收口(不在本分支落地)

ci/boot-graph-tests 的 race 排除表里那条
  pkg|internal/kernelboot/modules|…|KERNEL-REGISTRY-SETLOCK
现在可以删了。按任务边界本分支不碰 ci.yml,改由
id10_ci_closeout.patch(对 ci/boot-graph-tests 可干净 git apply)+
ID10_CI_CLOSEOUT.md 交给 CI 线合入。
2026-10-24 12:17:46 +08:00
efe0cf119b fix(ci): 排除表断言从「查提示文案」改为「查可执行判空逻辑」
## 缺陷(真实存在,已实测复现)

check-ci-gates.py 里「排除表每项必须写原因」这条断言原本写成:

    check(any("没有写原因" in r for _n, r in race), ...)

它只检查**提示文案还在**,完全不看校验逻辑本身。

实测取证:把 ci.yml 第 245 行的
    if [ -z "${reason// /}" ]; then
改成
    if false; then
(提示文案一字未动、校验实际彻底失效):
  · 该步骤实跑 EXIT=0 ——「无原因必须失败」这条保证彻底破掉
  · 而 check-ci-gates.py 仍然通过(GATES_EXIT=0)

也就是说:判据被文案顶包了。这正是本脚本 docstring 里批判的
「run: | 里的 # 是字符串不是注释 ⇒ 注释能骗过断言」,自己又犯了一次。

## 修法

断言改为在**剥掉 shell 注释后的可执行行**上,同时要求:
  · `${reason// /}`(判空变量)存在
  · `exit 1` 存在
只有文案、没有真逻辑 ⇒ 不通过。

## 验证(先证能红,再证不误伤)

  注入 if false 变异(保留文案)→ 修复前 EXIT=0(漏),修复后 **EXIT=1**(咬住)
  还原 → EXIT=0
  回归另两个变异(去掉 cmd/gui 剔除、去掉 -count=1)→ 均仍 EXIT=1,未被这次修改削弱

## 门禁

go build ./... = 0|go vet ./... = 0|go test ./internal/kernelboot/modules/ = ok
check-ci-gates.py = 0|gofmt 本线文件干净

边界:只改这一个脚本文件,未动 ci.yml、未动业务逻辑;未 push、未开 PR。
2026-10-24 11:59:00 +08:00
49141fe6f0 ci: 把 check-ci-gates.py 接进 go job,CI 自己检查自己的配置不许静默退化
背景:.github/scripts/check-ci-gates.py 上一轮已写好并入仓(ed8f28e),
但它**没被任何 workflow 调用** —— `grep check-ci-gates .github/workflows/ci.yml`
零命中。等于判据只活在评审口径里,CI 实际上不跑它。本次把它接上。

## 一、ci.yml:新增 2 步(go job,插在 go vet 之后、go test 之前)

- `actions/setup-python@v7`(3.12)
- `CI 配置门禁自检(动态枚举 / 排除表 / 启动图判据不许静默退化)`

位置为什么在 go test ./... **之前**而不是之后:

  · 该步骤是纯静态检查 —— 只读 ci.yml,秒级,无网络、无 cgo、
    不建 SQLite、不占端口;而 go test ./... 要跑 20 分钟。
  · 「门禁本身坏掉」应该第一时间红。放后面会让一次坏配置白白消耗
    20 分钟机器时间才发现,也让失败信号埋在 93 个包的输出后面。

runner 的 pyyaml **不假设自带**:先 `python3 -c 'import yaml'` 探测,
缺了才 `pip install pyyaml`(幂等,几秒)。本机实测 pyyaml 6.0.3 已有,
但本机的 Python 环境不等于 runner —— 所以做探测而不是赌。

## 二、顺手纳管 internal/kernelboot/modules/registry.go 的注释改动

该文件 +6/−3 的改动是**纯注释**,内容为 11:08 那轮已拍板要记进文件头的说明:
真相源为什么刻意不扫 `kernelmod.Register(...)` 调用点 ——
删掉某模块的 init() 后调用点消失,源码侧与登记表侧会同时少一个,
比对照样相等 ⇒ 假绿。手写模块名清单同理。故真相源取
「目录+类型名复合键 + 四方法齐全」的类型,而不是要检测对象的入口。

★ 这点在 commit message 里点明,是为了不让它变成夹带:它不是本次新写的代码,
  是上一轮已定但未提交的说明文字,与本次「门禁入 CI」同一主题(判据不许假绿)。

## 三、验证(先证能红,再证能绿,都不靠嘴说)

从 ci.yml 里**抽出该 step 的 run 块原样执行**(不是手敲等价命令):
  · 变色前(动态枚举改回写死 ./internal/agent/core/ ./cmd/waiter/)
    → EXIT=1,报 `race job 应恰有一步用 go list ./... 动态枚举包(实际 0 步)`
  · 还原后 → EXIT=0,报「CI 门禁自检通过:动态枚举 / 排除表校验 / 启动图判据步骤 / 交叉枚举 / 全部 run 步骤语法」

另在临时副本里抽查 5 个变异,4 个如期变红(写死枚举 / 去掉 -count=1 /
-race 不吃枚举 / cross 写死 cmd)。第 5 个「删掉排除表的原因校验」**没红** ——
已定位为脚本自身缺口,见下条,不在本次授权范围故未改。

## 四、已知遗留(未修,留给后续)

check-ci-gates.py 第 100 行用的是 `any("没有写原因" in r for ...)` ——
它只检查**提示文案还在**,不检查那段 `if [ -z "${reason// /}" ]` 校验逻辑本身。
于是把条件改成 `if false;` 而保留文案,断言仍然绿(实测 EXIT=0)。
判据被注释/文案顶包,与它自己在 docstring 里批判的「注释骗过 grep」是同一类错误。
属于该脚本的缺陷,本次任务授权只到「接进 CI」,故只记录不修。
2026-10-24 11:55:28 +08:00
ed8f28ea6c test(kernelboot,ci): 用真实启动图装配整机补判据,race 门由写死两包扩到全仓
背景:老大指出「CI 测试不够灵活、覆盖不到内核方方面面,应从启动图入手」。
先定位 CI 失败:六个门禁在基线上本地全绿,复现不出红 —— 实质是**覆盖面太窄**
而非配置写错(原 race 门只跑 ./internal/agent/core/ 与 ./cmd/waiter/ 两个包,
kernelboot 下 scheduler/syncbus/stress 的并发用例全在裸奔,绿是因为没测到)。

## 一、新增 internal/kernelboot/modules/registry_test.go(913 行,5 条判据)

kernelboot 自身判据很密(图编解码/分层/调度/迁移回滚/并发压测),但**全部用
假模块**在图上验算法。「真实 17 个模块按真实登记表装配成整机、逐层启动、
再严格逆序关闭」一条用例都没有。这中间藏着一整类失败:算法对、单模块对,
但装配起来不对(漏挂模块 / 某层关闭函数没登记 / 关闭顺序反了)。

5 条判据(零假模块,全部基于 kernelmod.Registered() 的 17 个真模块):
- TestRealRegistryAssemblesWholeMachine:每模块进图、设施真就绪(不是"登记过")
- TestLayerTopologyIsRespected:层间严格有序、层内两两无依赖、分层真带来并行
- TestShutdownIsStrictlyReverseLayerOrder:关闭严格逆序(实测哨兵序 [4 3 2 1 0])
- TestShutdownLeavesNoResidue:连续启停 3 轮,goroutine 回基线 + 临时目录删净
  + syncbus 订阅者不增长
- TestEveryRegisteredModuleIsCompiledIn:源码树扫描与登记表**双向**集合比对

### 判据 5 的真相源为何是"类型+四方法集"而不是 Register 调用点(初版栽过)

初版扫 `kernelmod.Register(...)` 调用点。实测:删掉叶子模块 multimodal 的
init() 后,**5 条判据全绿** —— 调用点消失 ⇒ 源码侧与登记表侧同时少一个 ⇒
集合比对照样相等。而"忘记初始化注册"恰是它声称要抓的场景。
根因是把「要检测的对象」当成了「检测的入口」,入口一被破坏就失去视野。
改成扫「实现了 Name/Requires/Provides/Init 四方法的类型」:模块哪怕不
Register、不被 import,四个方法仍写在源码里。
坑:本仓大量类型都叫 Module,只按类型名做键会撞成一堆(实测撞到只剩 2 个)
⇒ 用「目录+类型名」复合键;agent 是指针接收者 *Module,要处理 StarExpr。
修后恰扫出 17 个类型,与登记表 17 个名字一一对应,零误报。

### 失败面逐条实测(不是注释声称)

M1 删叶子模块 init() → 只有判据 5 红(1–4 全绿,这正是它存在的理由)
M2 CloseAll 逆序改正序 → 判据 3 红
M3 删聚合 import → 判据 5 红
其余:跳过某层会让内核 panic(llm 拿到 nil LuaVM,SIGSEGV);层顺序改逆序
红在装配失败(依赖数据为 nil)而非顺序断言 —— 这两种能发现但报错可读性差,
已写入文件注释,修它属业务改动(另派)。

## 二、.github/workflows/ci.yml

- race 门:写死两包 → `go list ./...` 全仓动态枚举(剔除 cmd/gui,
  枚举 <50 包则响亮失败),实测 92 包
- 新增 -skip 排除表:每项必须写「原因 + 修复单号」,无原因该步骤自身失败;
  每次 CI 打印完整清单,避免"排除项静默腐烂"
- 新增「已知红的可核对证据」步:用**静态断言**检查两处已知缺陷是否仍在
  (webui gzipResponseWriter 无锁、kernel Set* 无锁直写)。
  不用"DATA RACE 报告数>0"当断言 —— 实测这些竞争偶发(正向 0 条、
  另一次 8 条),那样必然假绿。
- cross 门:cmd 清单由写死 → 逐包 `CGO_ENABLED=0 go build` 实测枚举
  (实测 4 个纯 Go / 8 个依赖 cgo;homed-key-rotate 一直是纯 Go 却从没进过
  清单,白少一个覆盖);矩阵补 windows/arm64 与 linux/386
- go job:单列「整机启动图判据」一步(带 -count=1,便于失败定位)

## 三、新增 .github/scripts/check-ci-gates.py(180 行)

官方复核时发现:第一版判据只跑 gofmt/build/vet/go test,**完全不校验 ci.yml**,
而 ci.yml 正是改动占比最大的部分 —— 用「删掉 cmd/gui 剔除」这个变异咬出了该缺口。
本脚本用 YAML 解析断言 6 类门禁机制仍在,并给每个 run 步骤做 bash -n。
关键细节:`run: |` 块里的 `# ...` 是**字符串不是 YAML 注释**,`"cmd/gui" in run`
会被一句说明性注释满足 —— 真把 grep -v 删掉断言仍绿。故剥 shell 注释后再断言,
并给剥注释函数**本身**加了自测。

## 四、两处纯注释纠偏

- modules/registry.go:文档写「防漏 import 判据见 registry_test.go」(main 上该
  文件当时不存在)、第④步指向 `modules/imports.go`(真实位置是
  internal/kernelmod/all/all.go)。已纠正并落地为两条判据的指引。
- kernelboot/kernelstate.go:把「判据见 modules」具体化为函数名。

## 门禁(全部 -count=1,无缓存)

go build ./...          = 0
go vet ./...            = 0
go test ./...           = 0(59 ok / 0 FAIL)
go test -race 91 包      = 0(58 ok / 0 FAIL)
check-ci-gates.py       = 0
证据步骤正向 = 0;反向(给 SetLuaVM 加锁)= 1(证明是活断言不是死代码)

## 已知红(均待另开线,本线不越界改业务)

1. 内核:agent.wirePluginSDK 与 pluginhost.injectBaseDeps 在第 4 层并行重复
   写同一批 plugin.Registry 字段,而 Set* 全不取锁(r.mu 存在但没用)。
   实测偶发约 4%(1/25)。当前以 pkg 级排除临时挡住,**定性为临时手段**;
   唯一出口是给 Set* 加锁(修复单 KERNEL-REGISTRY-SETLOCK)。
2. webui:gzipResponseWriter 的 mode/wroteHeader/buf 无锁,handler goroutine
   与增量写 goroutine 并发写同一 ResponseWriter(修复单 WEBUI-GZIP-RACE)。
3. internal/agent/core TestStress_Nesting... 时序偶发(-race 放大 300ms 假设,
   无 DATA RACE 报告),未 skip,复现命令记在 ci.yml 注释里。

边界:未改任何内核业务逻辑(两个业务文件 diff 逐行只有注释);未 push、未开 PR。
2026-10-24 11:30:51 +08:00
873a0c3f75 Merge pull request #26 from JianFeeeee/fix/webp-embed-and-migrate-log
fix(media): webp 解码支持 + 迁移失败可观测 + 补回遗漏的 module 路径统一
2026-10-23 15:56:07 +08:00
3f834794c5 refactor: SDK module 路径统一改为 hologramagentsdk(59 文件 + go.mod)
## 为什么必须做

SDK 仓(独立仓库)已把 module 从 `github.com/JianFeeeee/homeagentsdk`
改名为 `hologramagentsdk`。主仓通过 `replace` 直接引用它的**物理目录**:

    replace github.com/JianFeeeee/homeagentsdk => ./third_party/homeagent-sdk

一旦 SDK 侧改成新 module 名,主仓立刻炸:

    go: ./third_party/homeagent-sdk@ used for two different module paths
        (github.com/JianFeeeee/hologramagentsdk and github.com/JianFeeeee/homeagentsdk)

Go **不允许**同一目录被两个 module 路径 replace。所以不能"新旧并存",
只能把主仓 59 个文件的 import 全部改到新名,并删掉旧 replace。

## 一个被证伪的前提

我最初按"third_party/homeagent-sdk 是独立仓库,不能在此仓修改其源码"
来处理,并因此判断两个仓的改动可以隔离。实际上主仓是靠 `replace` 指向
该目录的物理副本 —— **两个仓的改动不可能真正隔离**:SDK 侧改 module 名,
主仓必然跟着改 import。

这个前提应该在做任何改动之前用 `go list -m` 验证,而不是假设。

## 连带修掉两处

| 缺陷 | 性质 |
|---|---|
| `TestLuaSDKMockSingleSource` 失败 | SDK 的 `sdk/lua/sdk.lua` 变了,主仓内嵌副本未同步(跑 `scripts/sync-lua-sdk.sh` 修复) |
| `sdkModuleNames` 分散两处 | 已在 SDK 仓合并为单一数据源(见 SDK 仓 725732b) |

第一条是**副本一致性判据抓到的**,不是我自己发现的 —— 这类判据的价值在此:
两个仓必然有副本,靠人记住"改了 A 要同步 B"是做不到的。

## 验证

- `go build ./...` / `go vet ./...` 干净
- `go test ./... -count=1`:**58 包 0 失败**
- `internal/plugin`(Lua SDK 副本一致性)+ `internal/sdk` 全绿
- 主仓 `third_party/homeagent-sdk/` 与 SDK 仓 `main` 无差异
2026-10-23 15:51:02 +08:00
6327578693 fix(media): 支持 webp 解码 + 向量迁移失败必须留下原因
## 生产现状(2026-10-23 排查)

启动日志只有一行,没有任何细节:

    [media] 启动向量迁移: 4 个图像块 → fp=cd2a495cf990 dim=512 workers=4
    [media] 向量迁移完成: 成功=0 失败=4 总计=4

反查 `memory/graph.db` 才找到根因:

| mime | 块数 | fingerprint |
|---|---|---|
| image/jpeg | 153 | 当前 fp |
| image/png | 394 | 当前 fp |
| **image/webp** | **4** | **空**(从未嵌入成功) |

而 `providers/chineseclip/image.go` 只注册了 gif/jpeg/png:

    _ "image/gif"
    _ "image/jpeg"
    _ "image/png"

⇒ `image.Decode` 对 webp 返回 `image: unknown format` ⇒ 这 4 个块
**永远**拿不到向量 ⇒ 语义检索永远搜不到它们,且没有任何报错。

## 两个独立缺陷

### ① 功能缺口:webp 不可解码

加 `_ "golang.org/x/image/webp"`。这是纯 Go 实现、无 cgo,不改变二进制形态。

代价:`x/image v0.25.0` 硬性要求 `x/text >= v0.23.0`,因此 `x/text` 从
`v0.3.8` 升到 `v0.23.0`。已实测 `providers/chineseclip` 全绿(分词器用
`x/text/unicode/norm` 做 NFC 归一化,升级未改变行为)。

### ② 可观测性缺口:失败被 `continue` 吞掉

原实现:

    if err := a.reembedOneBlock(b, fp); err != nil {
        atomic.AddInt64(&failed, 1)
        continue          ← 原因就此消失
    }

现在按 `mime | error` 聚合后打印(不逐条打,上千张图会刷屏):

    [media] 向量迁移完成: 成功=0 失败=0 不支持=4 总计=4 fp=cd2a495cf990
    [media] 失败原因分类(按 mime | error 聚合):
    [media]   4 个: image/webp | chineseclip: 解码图像: image: unknown format

同时区分 `ErrModalityUnsupported`(**永久**无向量,重试无意义)与临时失败,
单独计为 `不支持=N` —— 混在"失败"里会让人以为重试就能恢复。

## 判据(4 条,全部变异自证)

| 判据 | 覆盖 | 变异 |
|---|---|---|
| `TestWebpIsDecodable` | webp 能被 `image.Decode` 识别 | 注释掉 webp 注册 ⇒ 红并指名「image.go 里缺少 ...」 |
| `TestWebpPreprocessProducesTensor` | webp 能走完整预处理(缩放+归一化),不只解码 | 同上 ⇒ 红 |
| `TestPngStillDecodable` | 注册表改动没弄坏 png | — |
| `TestReembedStaleMedia_LogsFailureReasons` | 失败原因(mime + 错误原文 + 计数)必须进日志 | 删掉打印 ⇒ 红 |
| `TestReembedStaleMedia_SeparatesUnsupportedFromFailed` | 不支持模态单独计数 | — |

webp 夹具用 ffmpeg 生成的真实 2×2 webp(44 字节)。手写字节序列构造不出
合法容器——第一版手写就失败了,这是实测教训。

## 验证

- `providers/chineseclip` / `internal/agent/core` / `internal/memory/vector` 全绿
- `go build ./...` / `go vet ./...` 干净
- 全量回归:见提交后 CI
2026-10-23 15:50:59 +08:00
a2a190f663 chore(sdk): 同步 SDK 仓副本(rebase 到 main + module 名列表修复 + 契约值改名)
主仓 third_party/homeagent-sdk/ 是 SDK 仓的物理副本,随 SDK 仓 rebase 更新:
- go.mod module 名跟随上游改名
- sdkModuleNames 合并为单一数据源(修 CI 抓到的 isSDKModule 漏识别新名)
- proxy.go 契约值 ProxyAuthAgent/ProxyAuthAgentLegacy + 注释改名
- lua sdk 副本一致性保持
2026-10-23 15:50:46 +08:00
57321b5fd4 Merge pull request #25 from JianFeeeee/ci/harden-csrc-gate
ci: 加固 C 门禁 —— 卡死不再拖满超时,缺工具不再假装通过
2026-10-23 15:49:40 +08:00
369a202d6f fix(csrc): 用 -nostdinc 剃掉宿主头,修 clang 交叉的 stdint.h 冲突
## 上一版为什么还红

装了 `libc6-dev-arm64-cross`(1.7MB 头包)后,CI 报的错变成了:

    In file included from csrc/src/ha_codec.c:23:
    In file included from /usr/lib/llvm-18/lib/clang/18/include/stdint.h:52:
    /usr/include/stdint.h:26:10: fatal error: 'bits/libc-header-start.h' file not found

注意路径:它找的是 **`/usr/include/stdint.h`(宿主 amd64 的)**,不是
aarch64 的那份。宿主 stdint.h 会 `#include <bits/libc-header-start.h>`,
那是 amd64 的布局,aarch64 目标下必然找不到。

**只加 `-isystem` 指向 aarch64 头不够** —— 默认搜索路径里的 `/usr/include`
仍然优先。

## 修法:`-nostdinc` 剃掉全部默认路径,再按序加回

    ARM64_INC="-nostdinc -isystem $(clang -print-resource-dir)/include"
    for d in /usr/aarch64-linux-gnu/include /usr/include/aarch64-linux-gnu; do
        [ -d "$d" ] && ARM64_INC="$ARM64_INC -isystem $d"
    done

顺序:**clang 内建头 → aarch64 目标头**。`-nostdinc` 保证宿主 `/usr/include`
不再参与,宿主头泄漏这条路径被彻底断掉。

只对 clang-target 模式生效;explicit / cross-gcc 模式用编译器自带搜索路径,
不传这些(它们本来就知道自己的 sysroot 在哪)。

## 本地五条路径实测

| 路径 | 结果 |
|---|---|
| ① clang-target(默认) | ✅ 0 告警通过 |
| ② 显式 cross-gcc | ✅ 0 告警通过 |
| ③ 显式指向不存在的编译器 | ✅ FAIL(Error 1) |
| ④ **真的写坏 C 源码** | ✅ FAIL(Error 1) |
| ⑤ 恢复后 | ✅ 通过 |
| `make check-csrc` 全门禁 | ✅ 全部通过,0 SKIP |

③④ 是防止"假通过"的回归判据:③ 验 `command -v` 校验 + PIPESTATUS,
④ 验编译器真实退出码没被 `| head` 吞掉。
2026-10-23 00:04:19 +08:00
0e5077497e fix(csrc): clang 交叉需要 aarch64 libc 头 —— 只装 1.7MB 头包,不装 10MB 编译器
## CI 实测暴露的下一层问题

上一版让 clang 做交叉编译后,安装步骤成功且**完全跳过 apt**(cmake/clang 都在
镜像里),但 `make check-csrc` 红在:

    In file included from /usr/lib/llvm-18/lib/clang/18/include/stdint.h:52:
    /usr/include/stdint.h:26:10: fatal error: 'bits/libc-header-start.h' file not found

真因:`clang --target=aarch64-linux-gnu` 切换的是**目标架构**,但系统头
仍按**宿主**(amd64)找 —— `bits/libc-header-start.h` 是 amd64 的布局,
aarch64 目标下不该用。本地能过是本机装了完整交叉 GCC,带来了
`/usr/aarch64-linux-gnu/include`。

## 试过但走不通的两条路(记录以免重复踩)

| 尝试 | 结果 |
|---|---|
| `-nostdinc` + clang 内建头 | ❌ clang 内建**不含 `string.h`**(那是 libc 的) |
| `-ffreestanding -nostdlibinc` | ❌ 同上,代码实际用了 `strlen`/`memcpy` |

## 采用:只装 libc 头,不装编译器

    libc6-dev-arm64-cross     1.7 MB(+ libc6-arm64-cross、linux-libc-dev-arm64-cross)
    gcc-aarch64-linux-gnu     含 cpp-13(10.8MB)+ gcc-16 等,实测下载卡死

既然只需要**头文件**(不做链接、不跑代码),就不该拉整个交叉 GCC。
装上头后 clang 就能完成 `-fsyntax-only`。

### Makefile 配合

`csrc-cross` 的 clang-target 模式现在自动探测并注入 aarch64 头路径:

    for d in /usr/aarch64-linux-gnu/include /usr/include/aarch64-linux-gnu; do
        [ -d "$d" ] && ARM64_SYSINC="$ARM64_SYSINC -isystem $d"
    done

探测改用**真实头文件**(`#include <string.h>`)而非空 main,这样
"clang 版本支持 --target" 与 "aarch64 头是否齐备" 两个条件一起验;
缺头时明确 SKIP 并说清装哪个包(`libc6-dev-arm64-cross`)。

## 本地实测

| 验证 | 结果 |
|---|---|
| 有 aarch64 头时 | ✅ clang-target 通过(0 告警) |
| `make check-csrc` 全门禁 | ✅ 全部通过,0 SKIP |
| YAML 可解析 | ✅ |
| 上一版三条失败路径(编译器不存在/真写坏/正常) | ✅ 仍然正确(PIPESTATUS 修复未回退) |
2026-10-22 23:59:37 +08:00
7fdec0670f fix(csrc): 交叉门禁改用 clang --target,去掉会卡死的 apt 下载 + 修两处假通过
## 卡死真因(这次可观测了)

上一版加了 `timeout` 硬超时后,日志终于能看出卡在哪:

    15:47:33  Fetched 12.5 MB in 1s (9972 kB/s)      ← update 很快
    15:47:38  Get:2 gcc-13-aarch64-linux-gnu-base 51.9 kB
    15:47:39  Get:3 cpp-13-aarch64-linux-gnu 10.8 MB   ← 下到这儿卡住
    15:50:37  ##[error]apt-get install 超时/失败(180s)

**根因:为了跑一道 `-fsyntax-only` 的语法门禁,去下载 10MB+ 的交叉 GCC 工具链,
在 runner 上拉不动。**

## 改法:用 clang 自带的交叉前端

这道门禁只做 `-fsyntax-only`(不链接、不运行)。clang 支持
`--target=aarch64-linux-gnu`,会**真的切到 aarch64 目标**:

    $ clang -fsyntax-only arch2.c          → error: "not aarch64 target"
    $ clang --target=aarch64-linux-gnu ... → 通过(__aarch64__ 生效)

指针宽度、endianness、C 扩展支持都按目标架构算 —— 足以暴露本门禁要抓的
那类问题。而 clang 在 ubuntu-latest 镜像里**已预装**(实测确认,只列出
`gcc-aarch64-linux-gnu` 一项需要安装)。

优先级:`CC_ARM64`(显式)> `clang --target` > `aarch64-linux-gnu-gcc` > SKIP

## 顺带修掉两处**本来就存在**的假通过

这两处不是本轮引入的,是新写的存在性校验暴露出来的:

### ① `cmd | head` 吞掉编译器退出码

    for src in ...; do
        if ! $CC ... -fsyntax-only $src 2>&1 | head -20; then ok=0; fi
    done

管道退出码是 `head` 的(恒 0)。实测:`CC_ARM64=nonexistent-xyz make csrc-cross`
竟然输出「0 告警、编译通过 ✓」—— 编译器根本没执行。

修法:改用 `${PIPESTATUS[0]}`,取编译器的真实退出码。

### ② `PIPESTATUS` 需要 bash,Make 默认是 dash

`/bin/sh` 在 Debian/Ubuntu 上是 dash,不支持 `PIPESTATUS`(报 Bad substitution)。
必须显式 `SHELL := /bin/bash`,否则上面的修法静默失效。

### 风险自证(三条路径实测)

| 场景 | 结果 |
|---|---|
| 正常(clang-target) | ✅ 0 告警、编译通过 ✓ |
| `CC_ARM64` 指向不存在的命令 | ✅ `[FAIL] CC_ARM64=nonexistent-xyz 不存在` → Error 1 |
| 真的写坏 C 源码 | ✅ 编译器报错 + `[FAIL] arm64 交叉编译失败` → Error 1 |

## CI 侧

- 安装步骤不再要求 `gcc-aarch64-linux-gnu` ⇒ **依赖已预装,直接跳过 apt-get,完全不碰网络**
- 工具链确认步骤只校验 `cmake` + `clang`
- `[SKIP]` 拦截、`timeout` 硬超时、去 `-qq` 全部保留(对付将来别的原因导致的卡死)
2026-10-22 23:55:54 +08:00
16bb794a43 ci: 二次加固 —— 上一版对 apt 卡死无效,改用 timeout 硬超时并去掉 -qq
## 上一版为什么没解决

第一版加了 `Acquire::http::Timeout=60` + `Retries=3` + step 6 分钟超时。
CI 实测结果:

    15:39:48  需要安装: gcc-aarch64-linux-gnu
    15:46:01  ##[error]The action '安装 C 工具链(已装则跳过)' has timed out after 6 minutes.

**依然卡满 6 分钟**,而且日志里**一个 apt 输出都没有** —— 因为 `-qq` 把
所有输出静默了。两个结论:

1. `Acquire::*Timeout` 只管**单个连接**,不管 apt 整体 —— 源重试、DPKG 锁
   等待、依赖计算都不受它约束,所以它拦不住这类卡死
2. `-qq` 让卡死**完全不可观测**:既看不到卡在哪个源,也看不到在下载什么。
   不可观测正是它能悄悄拖满超时的原因

(顺带证明"已装则跳过"这条是有效的:cmake/clang 都在镜像里,只列出了
`gcc-aarch64-linux-gnu` 一项要装。)

## 这一版改法

| 改动 | 理由 |
|---|---|
| 去掉 `-qq`,保留可读进度 | 下次卡死必须能看出卡在哪,否则无从诊断 |
| `timeout 120 apt-get update` | **外层硬超时**,强制结束,不依赖 apt 自身行为 |
| `timeout 180 apt-get install` | 同上;120+180=300s 配 step 5 分钟正好 |
| 失败即 `exit 1` + `::error::`,不重试整段 | 宁可红一次让人看见,不要用重试掩盖真实问题 |
| `--no-install-recommends` 保留 | 减少下载量与依赖面 |

## 本地实测

| 验证 | 结果 |
|---|---|
| YAML 可解析 | ✅ |
| `timeout 3 sleep 60` 行为 | ✅ 3 秒强制结束,退出码 124,可被 `if !` 捕获 |
| "已装则跳过"分支 | ✅ 「✅ 跳过 apt-get」 |
| 上一版缺工具 `[SKIP] ... exit 0` 风险 | ✅ 已复现并拦截(见前一提交) |

## 仍待 CI 验证

这一版的真实效果只有在 runner 上才知。若这次仍然卡满,说明 `timeout` 对
sudo+apt 组合失效(可能性低),届时改用「换源」或「预装镜像」方案。
2026-10-22 23:47:15 +08:00
1aec7688a6 ci: 加固 C 门禁 —— 卡死不再拖满超时,且缺工具不再假装通过
## 两个独立问题

### ① 卡死:20 分钟全花在 apt-get,不是门禁

实测证据:本机 `make check-csrc` 五个子门禁**各自 0~1 秒**:

    csrc-lint 1s / csrc-abi 0s / csrc-headers 0s / csrc-sanitize 0s / csrc-cross 0s

而 CI 里同一个 job 两次跑到 20 分钟 job 级超时(其中一次直接 cancelled)。
卡死点只可能在 `apt-get update` —— ubuntu-latest runner 上偶发挂住不返回,
既不失败也没进展,只能等 job timeout,而报错信息毫无指向性。

### ② 假通过:缺工具时门禁 `[SKIP] ... exit 0`

这是更危险的一个。Makefile 里每道门禁在缺依赖时:

    if ! command -v $CC_ARM64 >/dev/null 2>&1; then
        echo "  [SKIP] $CC_ARM64 不存在(未装交叉工具链)"; exit 0
    fi

本机这样合理(开发者未必装交叉工具链),但在 CI 里意味着:apt-get 悄悄失败
⇒ 五道门禁全部跳过 ⇒ **job 绿** ⇒ 让人以为 C 侧有门禁,实际一个都没跑。

**静默降级比红更危险:红会有人修,绿没人看。**

实测确认(`CC_ARM64=nonexistent-gcc-xyz make csrc-cross`):

    == C 侧交叉编译(linux/arm64)==
      [SKIP] nonexistent-gcc-xyz 不存在(未装交叉工具链)
    --- 退出码: 0 ---        ← 这就是问题

## 加固内容

| 措施 | 作用 |
|---|---|
| 逐包检查是否已安装 | runner 镜像通常自带 cmake/clang ⇒ **多数情况完全不碰网络** |
| apt-get 自带 `Acquire::*Timeout=60` + `Retries=3` | 卡住时自身失败,而不是拖到 job 级超时(后者报错无指向性) |
| 装包 step `timeout-minutes: 6` | 双保险 |
| 新增「确认工具链齐备」步骤 | 缺任一工具 ⇒ 红,并 `::error::` 说明"缺工具时门禁会 SKIP 等于假装通过" |
| 跑门禁 step `timeout-minutes: 5` | 门禁实测 0~1 秒,超出即异常(不是慢) |
| `grep '\[SKIP\]'` 二次拦截 | 覆盖将来新增门禁时漏配依赖的情况 |
| job `timeout-minutes` 20 → 15 | 已经是纯 apt-get 余量 |

## 为什么要两道 SKIP 拦截

「确认工具链齐备」+「grep [SKIP]」看似重复,其实针对不同风险:

- 前者:**当前**三道门禁的依赖齐不齐
- 后者:**将来**新增门禁时,新依赖没写进安装步骤 —— 那时前者不知道要检查它,
  只有日志里的 `[SKIP]` 能抓到

## 本地实测(逐条验证,不是只改完就推)

| 验证 | 结果 |
|---|---|
| YAML 可解析 | ✅ |
| 三件套本机齐备 | ✅ cmake / clang / aarch64-linux-gnu-gcc |
| `make check-csrc` 无 SKIP | ✅ 五门禁全实际执行 |
| 已装则跳过 apt-get 分支 | ✅ 「三件套已存在,跳过 apt-get」 |
| 部分缺失时拼装需要安装列表 | ✅ 正确识别出 `clang` |
| **变异:交叉工具链缺失** | ✅ grep 拦截生效(若不加这层,CI 会绿) |
2026-10-22 23:39:21 +08:00
409f7d63bc Merge pull request #24 from JianFeeeee/feat/outputch-multisend
feat(outputch): 一次调用发多条/多类型 + 能力校验下放插件 + 契约值改名 Agent
2026-10-22 23:23:42 +08:00
d3590192e7 fix(outputch): 内核不再替插件判断载荷语义 —— 空 payload 是合法占位
## 实测踩到的真 bug(生产环境)

QQ 插件重装后首次实测 `sticker_index` 用法,直接失败:

    output_send__qq result: 参数有误,未发送任何内容:payload 不能为空

而插件侧明明有这个放行分支:

    if payload == "" && !(rawType == "sticker" && stickerIndex > 0) {
        return nil, fmt.Errorf("payload 不能为空")
    }

分支永远走不到 —— 内核先拒了。

## 根因:两处内核越权判断

| 位置 | 旧判断 | 问题 |
|---|---|---|
| `parseOutputItems` | `strings.TrimSpace(p) == ""` ⇒ 拒绝 | 内核不知道 payload 语义。`type=sticker` + `meta.sticker_index` 要求 payload 留空占位,真实 URL 由插件从收藏表情取 |
| `sendOneOutput` | `stageCtx.FinalText == ""` ⇒ "输出被插件清空" | 原本就空的占位被判成异常 |

**正确边界**:参数**是否存在**归内核(没传 `payload` 键 ⇒ 拒绝,结构性错误内核能判);参数**值是否合法**归插件(空串 ⇒ 交插件判断并返回 error)。

这与本轮 outputch 的另一条改动是同一条原则(`type` 的能力校验下放插件):
**内核只透传与对齐,不解释插件自定义字段的含义**。

## 连带修正:`stringOrArray("")` 不再返回 nil

原来 `payload: ""` 被当成"未提供",与"没传 payload 键"混为一谈。
现在空串**保留为一个元素**:前者是模型明确给的空占位(须走到插件),
后者才是真的没提供(由 `len(payloads) == 0` 拦下)。

## 判据调整(原有判据与正确设计冲突)

`TestOutputSend_RejectsEmptyEntries` 断言"含空条目必须整 call 拒绝" —— 那是
按旧的越权设计写的,**与正确边界冲突**。改写为三条分别钉住边界:

| 判据 | 断言 |
|---|---|
| `EmptyPayloadDeferredToPlugin` | 内核不拦空条目,交插件判断(2 条都要送到) |
| `EmptyStringIsNotMissing` | `payload: ""` 是占位而非缺失,必须走到插件且原样透传空串 |
| `MissingPayloadStillRejected` | 完全没传 payload 键仍拒绝(内核能判的结构性错误) |

变异验证(把 trim 判空加回 `parseOutputItems`)⇒ 两条同时变红:
- 「内核不该拦空条目,应交插件判断,实际只发 0 条」
- 「空字符串 payload 应交给插件(sticker_index 用法依赖它),实际 0 次调用」

## 验证

- `go test ./internal/agent/core/` 全绿(含新 3 条)
- 生产实测修复前后对照(journalctl 原文):

      21:54:35  output_send__qq result: 参数有误,未发送任何内容:payload 不能为空   ← 修复前
      21:56:34  output_send__qq result: ok                                          ← 修复后

- webui / agent-core 全绿
2026-10-22 22:42:02 +08:00
55dfa959aa ci: 触发重跑 —— 上一轮 C infrastructure gates 因 runner 卡在 apt-get 超时(本地同门禁全通过) 2026-10-22 21:40:48 +08:00
b20e32d81d feat(outputch): 提示词显式声明「必须调 outputch 才送达」+ 契约值改名 Agent
## ① 提示词:只有显式调用 outputch 才会送达用户(用户明确要求)

模型最常见的错误不是不会调工具,而是**以为返回文本就等于回复了** ——
在异步通道(qq/wechat/群聊)上那等于自言自语,用户永远收不到。

改动前【输出规则】开篇是「消息不会自动发送到对话来源通道,你必须自己决定
如何回复」,语气偏描述。改为开篇总述句:

    **只有显式调用 `output_send__{通道名}` 才会把内容送达用户。**
    返回纯文本只是在这次推理里说话,用户看不到 —— 除非是下面说的同步通道。

同时补上本次内核新增能力:payload 传数组可一次发多条。

## ② 契约值改名:ProxyAuthHomeAgent="homeagent" → ProxyAuthAgent="agent"

用户指示契约值一起改。常量名与字符串值同时改,但**保留旧值读取路径**:

    ProxyAuthAgent       = "agent"      ← 新值
    ProxyAuthAgentLegacy = "homeagent"  ← 只用于兼容读取
    ProxyAuthHomeAgent   = ProxyAuthAgent  (Deprecated 别名,存量源码可编译)

为什么必须保留:`Auth` 是**写进 plugin.json 的持久化声明值**。存量配置里
写着旧值 —— 本机实测 `/home/newqqagent/plugins/huawei_smarthome/plugin.json:19`
就是 `"auth": "homeagent"`。只认新值 ⇒ 该插件加载时被判为非法声明 ⇒
反代不生效,症状是「插件好好的但页面打不开」,且无任何报错指向改名。

### 归一化是安全关键

`EffectiveProxyAuth` 把**空串与旧值都归一化为 ProxyAuthAgent**。

判断点全都写成 `auth == ProxyAuthAgent`。若不归一化,就得每点写两遍比较
—— **漏一处就是鉴权绕过**:旧值被判为「非门户鉴权」⇒ 跳过 authorizeProxy
⇒ 请求直接透传给上游。所以兼容收在一个函数里,不散落在调用点。

### 顺带修掉两处隐患

1. `webui/proxy.go` 的裸字面量 `"homeagent"`(非法行占位)改为
   `sdk.ProxyAuthAgent` —— 裸字面量在改名时会被漏掉,届时它悄悄变成
   未知值,而未知值在旧代码里等于「无鉴权」。
2. 两处鉴权判断改用 `EffectiveProxyAuth(...)`,堵住旧值绕过。

## 判据

| 判据 | 覆盖 | 变异自证 |
|---|---|---|
| `TestSystemPromptRequiresExplicitOutputCall` | 总述句必须存在、必须说明「返回文本用户看不到」、同步通道是唯一例外、payload 可传数组 | 删掉总述句 ⇒ 红 |
| `TestProxyAuthLegacyValueStillAccepted` | 旧值仍被接受 + 必须归一化为新值 | 去掉归一化分支 ⇒ 「旧值应归一化为 "agent",实际 "homeagent"」红 |

## 验证

- `go build ./...` / `go vet ./...` 干净
- webui / internal-sdk / agent-core 全绿
- SDK 仓(独立提交 acd1166):`go build` + `go test ./...` 0 失败
2026-10-22 21:17:05 +08:00
f850d754c1 feat(outputch): 一 call 多发 + 多类型 —— 内核级通用能力(非某插件专属)
用户要求:内核 outputch 是通用基础,传入 meta/payload/type(三者均为**插件自定义
字段**)。要支持一次调用发多条、多种类型消息,通过 payload 数组 + type 数组实现。

## 内核原有形态的限制

output_send__<ch> 每次只能发一条单类型:
  payload: string / type: string / meta: string
带附件或多段内容的通道只能额外注册独立 tool(如 QQ 的 qq_send_file),
而这不该是插件的职责 —— "发一条消息"本来就是输出门的语义。

## 改法

### ① 工具 schema:oneOf [string, array]

payload / type / meta 三者的 schema 改为同时接受字符串与数组。
payload 是字符串时走单条路径,**行为与改动前逐字相同**(向后兼容,用户拍板)。

### ② 解析规则(parseOutputItems)

- payload 与 type 必须等长;type 省略或单值 ⇒ 广播
- meta 允许**单值广播**(用户拍板)
- 长度不符 ⇒ **整 call 拒绝,一条都不发**
- 含空条目 ⇒ 同样整 call 拒绝(不能静默发空消息)

### ③ 能力校验下放插件(用户拍板)

删掉内核里的 `switch rawType { case text/voice/image/file: 查 caps }`:

  · type 是**插件自定义枚举**,内核不认识它。写死四个值 = 内核在理解插件语义;
  · 且插件的自定义类型(如 QQ 的 sticker)会被内核无端拒绝。

内核只保留两件它确实该管的校验:**授权闸**(IsOutputAllowed)与
**通道存在性**。type 与能力的匹配由插件在 handler 里判断并**返回 error**。

### ④ 部分失败:继续发剩余的,如实逐项汇报

用户要求"部分失败向 agent 返回失败项"。实现选择"继续发完"而不是"中止":
第 2 条图片失败不该把第 3 条文字也丢掉,否则"一次发多条"失去意义。

返回形态:
  全部成功(多条)  已通过 [qq] 通道发送 3 条
  部分失败          已通过 [qq] 通道发送 2/3 条:
                      [1] text ok
                      [2] image 失败:<插件返回的错误原文>
                      [3] text ok
                    失败的条目请单独重发。

单条成功仍返回 "ok"(与旧版完全一致,避免富回执驱动回声效应 ——
output.go 里有实测依据)。

### ⑤ 拆出 sendOneOutput

before_output / 安全回填 / after_output 这套顺序对**每一条**都必须完整走一遍。
混在批量循环里极易漏环(例如只给第一条跑 stage),所以把单条路径独立成函数,
批量只是循环调用它。

## 判据(8 条,全部有变异自证)

| 判据 | 覆盖 |
|---|---|
| SingleValueUnchanged | 单值 payload 行为与旧版逐字相同(返回 "ok") |
| ArraySendsInOrder | 数组按顺序发 N 条,type 一一对应 |
| MetaBroadcast | meta 单值广播给所有条 |
| LengthMismatchRejectsWholeCall | 长度不符 ⇒ 一条都不发 |
| PartialFailureReportsAndContinues | 第 2 条失败仍尝试第 3 条 + 如实列出失败项与插件错误原文 |
| KernelDoesNotInterpretType | 自定义 type "sticker" 原样透传,内核不拦 |
| RejectsEmptyEntries | 含空条目整 call 拒绝 |
| UnauthorizedStillBlocked | 授权闸不被批量绕过 |

变异全部被抓:
- 第 2 条失败即中止 → 「实际只调用 2 次」
- 恢复内核 type 能力校验 → 「内核不该对 type 做能力判断」
- meta 不广播 → 「meta 未广播到」

## 验证

- go build ./... / go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
- 现有 output 相关测试(output_grant / output_rules / resident_output)全绿

## 未做(下一步)

- example 插件(qq)收回 qq_send_file / qq_send_sticker,改走 outputch
- outputch 说明迁入各插件 skill(保留 _help 机制)
- 插件侧补 type 能力自校验(校验已下放,需要它们真的做)
2026-10-22 21:05:17 +08:00
881f3c34df Merge pull request #23 from JianFeeeee/feat/rename-and-prompt-convergence
refactor: 全面改名 HologramAgent + 提示词技术内容下沉 + 修 21 处乱码 + 回填数据完整性
2026-10-22 21:04:34 +08:00
f256098c9f refactor: 全面改名为 HologramAgent + 提示词技术内容下沉到 skill/工具描述 + 修 21 处乱码
## ① 全面改名 HomeAgent → HologramAgent

仓库已改名,但代码与文档里仍有大量旧名。本次按"用户能看到的优先"全量替换:

- 用户可见:登录页标题/页脚、默认人格第一句、CLI 状态输出、启动日志、
  plugin_install 的工具描述、AI 图片插件的 User-Agent
- 文档与脚本:README、assets/docs、docs/zh、deploy/*.sh、.github/workflows
- 鸿蒙工程:cmd/ohos/HomeAgent → cmd/ohos/HologramAgent(git mv)
  目录内的 module/app 描述同步;**证书路径原样保留**
  (default_HomeAgent_xxx.cer/p7b/p12 是签名材料的实际文件名,改了会找不到证书)
- 插件元数据:plugin.json 的 author

★ 必须保持原样的两处(改了会坏):
  · legacySeededPromptSignature = "你是 HomeAgent 的看板娘「小宅」…"
    —— 那是识别历史播种人格卡的**原文锚点**,改了就识别不出旧数据
  · 公开 SDK 的 ProxyAuthHomeAgent = "homeagent"
    —— 那是 SDK 契约里的值,属独立仓库,本仓不改

## ② 修 21 处乱码(U+FFFD)

这些是历史编辑留下的编码破坏,全在注释里,例如:
  「赌注重下���遍」→「赌注重下一遍」
  「「不��滤」」  →「「不过滤」」
  「**待��退场**」→「**待退场**」

★ 保留 providers/chineseclip/tokenizer.go 里的 2 处:那是**故意**用
  U+FFFD 演示"非法 UTF-8 字节被归一成 U+FFFD",不是乱码。

## ③ 提示词技术内容下沉(用户:技术性内容应该收敛到 skill 与 -help 中)

实测系统提示词 2309 字,其中两段几乎全是**模型无法据以行动的实现细节**:

  【工具执行顺序】1272 字(ParallelSafe 声明、批内降级、batchRunnable 判据…)
  【三层记忆】   1069 字(L1/L2/L3 编号、消费式删除、向量空间、指纹…)

收敛后 **2309 → 1723 字(-25%)**,保留的全是"模型据此做什么不同的事":

  工具顺序:默认并行 / 不要依赖先后 / 同通道保序 / 写类工具可能串行
  三层记忆:时序里能看最近 / doc_query 把历史事件按原始时间戳插回时序 /
           关系用 memory_recall / 成文知识用 knowledge_search

判断口径写进判据:新加一段提示词前先问"模型会据此做什么不同的事",
答不上来就该进 skill 或工具描述。

## 判据

prompt_memory_test.go 新增 2 条:
- TestSystemPromptHasNoImplementationJargon
  黑名单:ParallelSafe / batchRunnable / 消费式 / DenseCosine / fingerprint /
  向量空间 / 批内降级 / 自激
- TestSystemPromptStaysCompact:上限 2000 字(当前 1723)

变异:把 "ParallelSafe 声明、批内降级" 加回工具顺序段 ⇒ 第一条立刻红。

## 验证

- go build ./... / go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
- make check-client-versions:客户端与内核版本一致
2026-10-22 20:49:44 +08:00
6bea130639 fix(memory): 回填事件必须照常归档 —— 纠正 NoMemory 语义被错误扩大到存储层
用户指出:doc 召回部分需要回填记忆,因为 doc 召回后原文在 doc 层已经删除了。

## 我上一轮的错误(PR #22)

把「工具输出不进记忆」与「回填事件不归档」塞进了同一个 NoMemory 标记:

    doc_query → Consume(L2 副本删除)→ 回填 L1(标记 NoMemory)
              → L1 裁掉它 → Prune 跳过归档(错误实现)
              → 三层都不在 ⇒ 内容永久丢失

Consume 已经把 L2 副本删掉,**L1 是唯一副本**。裁掉时不归档 = 丢数据。
这不是自激防护,这是把数据丢掉当成防护。

## 正确区分(两件事不能共用一个标记)

| 对象 | 进计算层 | 进归档 | 理由 |
|---|---|---|---|
| doc_query 的**工具返回值**(正文摘录) | ❌ | ❌ | 它是事件的摘录副本,再向量化 = 重复注入 ⇒ 自激 |
| **回填的事件本身**(原文) | ❌ | ✅ **必须** | L2 副本已删,L1 是唯一副本;不归档即丢失 |

## 修法

ContextEvent.NoMemory 语义定为「**不参与计算,照常归档**」,
与 ToolDef.NoMemory 一致:只影响计算层,不影响存储层。

自激的入口在"重新计算向量"那一侧(textForVector 返回空),
不在"归档"这一侧 —— 删掉 Prune 里的跳过逻辑。

## 判据(改写了那条断言错误行为的判据)

TestSelfExcite_RestoredEventsNotReArchived 现在断言三件事:
1. 回填事件带 NoMemory 标记且未被向量化(不参与计算)
2. 前提成立:L2 里确实已无该事件(Consume 删掉了)
3. ★ 被裁掉后必须能落回 L2(否则内容永久丢失)

变异验证:
- Prune 重新跳过 NoMemory ⇒ 红,报「三层都不在,内容永久丢失」
- restoreDocEvents 不置 NoMemory ⇒ 红(会重复加权)

## 验证

- go build ./... / go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
2026-10-22 20:37:34 +08:00
41921c0b6d Merge pull request #22 from JianFeeeee/fix/doc-query-self-excitation
fix(memory): 掐断 doc_query 的自激震荡 —— 工具输出与回填事件都不进记忆
2026-10-22 20:30:51 +08:00
71e2b83a76 fix(memory): 掐断 doc_query 的自激震荡 —— 工具输出与回填事件都不进记忆
用户指出(2026-10-22):doc_query 的结果走 NoMemory 路径,否则会对记忆块
产生自激震荡。核实后确认**两个入口都开着**。

## 回路

    doc_query
      ├─ ① 工具返回值含被召回事件的正文片段 → 作为 ToolResults 进 context
      │     → 参与向量化/关键词/蒸馏 → 同一内容被**重新注入**记忆
      ├─ ② 回填事件本身(带原 Source、完整 Blocks)进 L1
      │     → Prune 因相关而保留它们(挤掉真正的新事件)
      │     → 下一轮 Prune 又把它们归档回 L2
      └─ ⇒ 下次 doc_query 再召回同一批:块在 L1↔L2 之间反复搬迁

## ① 工具输出:doc_query 未声明 NoMemory

基础设施早就有(textForVector 里按 ToolDef.NoMemory 跳过工具输出),
但 doc_query 是**内置工具**,它的声明无处安放:
StageHost.ToolDef 只覆盖插件工具,内置工具由 executeToolCallInner 的
switch 分派,不经过注册。

修法:
- internal/sdk/tool.go:BuiltinToolDef 加 NoMemory 字段(与 ToolDef.NoMemory
  同义,两条路必须能声明同一件事)
- internal/agent/core/tooldefs.go:declareNoMemoryTool + noMemoryOf,
  由 init() 里**显式名单**登记 doc_query
- internal/agent/core/context.go:textForVector 同时查插件 ToolDef 与
  内置声明表(原来只查前者)

★ 为什么在 init 显式登记、不靠 toolDefWith 构造时登记:
  工具表是按需构造的(受 `if a.docStore != nil` 之类条件控制),
  而 textForVector 可能在构造之前被调用 ⇒ 声明会"时有时无"。
  实测踩到:构造前查 noMemoryOf("doc_query") 得到 false。

★ declareParallelTool 顺带改为**按字段合并**(原来整体覆盖):
  同一工具可能同时声明 parallel 与 noMemory,覆盖会让后登记的那项
  静默丢失 —— 声明丢一个是最难察觉的一类问题。

## ② 回填事件:加 ContextEvent.NoMemory

ContextEvent 新增 NoMemory 字段(此前只有 ToolDef/ChannelDef 有)。
restoreDocEvents 回填时置位(含存量降级路径),于是:

- textForVector 直接返回 "" ⇒ 不向量化、不参与关键词/蒸馏
- PruneWithProtected 归档前过滤掉它们 ⇒ 不再产生新的 L2 副本

语义与 ToolDef.NoMemory 一致:**原文仍在上下文可读**,只是不进计算、
不再产生新记忆。这是「回填是为了让模型读到原文」的本意。

## 判据(3 条,钉住两个入口 + 一个后果)

selfexcite_test.go:
- DocQueryDeclaresNoMemory:声明可查 + 工具输出不进 textForVector
- RestoredEventsNotReArchived:回填事件带 NoMemory 标记 + Prune 后
  L2 里不出现含回填内容的新文档(真跑 Prune 验证回路)
- RestoredEventsDontCrowdOutNewOnes:回填事件不被向量化(否则会
  参与相关性打分、挤掉新事件)

变异全部被抓住:
- Prune 不过滤 NoMemory → 「回填事件被 Prune 又归档回了 L2」
- restoreDocEvents 不置 NoMemory → 「没有 NoMemory 标记」+ 回路成立
- init 不登记 doc_query → 「未声明 NoMemory」

## 验证

- go build ./... / go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
- 公开 SDK(third_party/homeagent-sdk)未改动 —— 改的是 internal/sdk
2026-10-22 20:16:57 +08:00
9c668ceeb1 Merge pull request #21 from JianFeeeee/feature/memory-usability
feat(memory): doc 层去掉 jieba 摘要 + doc_query 返回真实内容 + 三层记忆提示词
2026-10-22 19:51:54 +08:00
e2cb6e70e0 feat(memory): doc 层去掉 jieba 摘要 + doc_query 返回真实内容 + 三层记忆提示词
上一轮(memory-blockflow-repair-plan)修好了 L1↔L2 结构,但功能不可用。
用生产数据实测:三个互不相关查询分数全挤在 0.75~0.79,区分度≈0,
命中内容与查询无关。本次修四层。

## ① 向量输入:Summary + Content → 事件正文

denseFor 原用 `VectorizeDense(doc.Summary + " " + doc.Content)`,而 Summary
由 summarizeEntries → memory.ExtractKeywords → TokenizeContentWords → jieba.Tag()
生成。同一来源的摘要逐字同质:

    来自 1 个来源的 1 条对话 (src0) 涉及: 用户, 提问
    来自 1 个来源的 1 条对话 (src1) 涉及: 用户, 提问

真实 chineseclip 实测(4 篇主题完全不同的短正文):两两余弦 0.85~0.95。
这与设计矛盾 —— ARCHITECTURE 写的是「统一多模态空间(主)」,而 Summary
是主路径的输入,却由最末一级的词法分词产出。

改为 documentTextForVector(doc):按事件顺序拼原文(块里的正文)。
存量无 Events 的文档退回 Content。

★ 媒体不单独生成摘要:媒体块与文本块在同一事件内
  (Doc.Events[i].Blocks),事件正文进入向量即覆盖其语义。
  为媒体单独写描述正是 CAS 时代「描述式索引」的错误,v1.2.0 已拆除。
  「按图搜图」是视觉路(RecallBlocks)的职责,已有独立通路。

## ② doc 层移除 jieba 语义生成

- summarizeEntries:jieba 词袋 → 取首条事件正文首行(真实内容,可读)
- 删除 extractTags / extractEntities(纯 jieba 词袋)
- 删除 Doc.Tags / Doc.Entities(前者唯一消费点是 doc_query 返回的
  「标签: map, content」噪声行;后者零消费点)
- doc_commit 删除 tags 参数(无处可存,留着会骗模型)

★ TF-IDF 回退路径**保留**(Store.tfidfEmb/tfidfIdx/ensureTFIDF/Reindex):
  它是「稠密空间没加载」时的兜底,按 ARCHITECTURE「统一多模态空间(主)
  → 词嵌入 → TF-IDF(回退)」必须保留。但它不能是主路径 —— 一旦稠密空间
  可用,jieba 就不参与。

## ③ doc_query 返回真实内容

旧返回:「已加载 3 篇文档记忆: #1(来自 1 个来源的 1 条对话 (agent) 涉及: ok)」
——全 jieba 词袋,模型无法判断有没有用(这是它几乎不调用 doc_query 的直接原因)。

新返回:
    已召回 3 篇历史记忆,原文已按各自**原始时间戳**插回对话时序(不是当前对话):

    [1] 2026-06-07 08:09 · webui · 2 条事件 · 含 1 份媒体
        配色方案怎么定?
        结论:紫蓝红三色带按六比三比一…

    共回填 N 条事件。要引用其中的细节,直接看对话时序里对应时间点即可。

每条给出「时间 · 来源 · 事件数 · 媒体数」+ 真实正文前几行。

## ④ 提示词:agent 终于能理解三层记忆

- doc_query 描述:一句话 → 覆盖「L2 是什么 / 何时用 / 调用后原文回填到时序 /
  与 memory_recall 分工 / 消费式副作用」
- doc_commit 描述:说清「适合写整段过程结论,不适合写单个事实」
- 系统提示词新增【三层记忆】段(内核运行时块):L1 对话时序 / L2 历史记忆 /
  L3 图记忆各自是什么、怎么取、如何选择
- 预填充文案(crossModalMarkdown):统计描述 → 时间+来源+正文开头,
  并在块尾明确引导「要完整原文用 doc_query」

## ⑤ 向量算法版本(否则修复对存量无效)

实测踩到的坑:改完 denseFor 后,1055 篇存量文档分数纹丝不动(0.78/0.79/0.76,
区分度 0.003)—— 因为 DenseFP 只记「哪个向量空间」,不记「用什么文本算的」,
指纹不变 ⇒ 全部命中跳过条件 ⇒ 永不重算。

修法:DenseFP = 空间指纹 + "|" + docVectorAlgoVersion(v2)。
算法一变,存量自动重算一次。

## 判据(每条都有变异自证)

document/retrieval_quality_test.go(6 条):
- Top1HitsCorrectDoc / DistinctQueriesHitDistinctDocs / ScoreGapIsSignificant
- VectorInputIsBodyNotSummary(正文哨兵必须进向量)
- SummaryNoiseMustNotPolluteVector(主题不同的短正文,两两余弦 <0.80)
- AlgoVersionChangeForcesRecompute(换算法必须触发存量重算)

core/recalldoc_test.go(+2):返回值必须含时间/来源/真实正文/时序说明
core/prompt_memory_test.go(5 条):工具描述五项覆盖、doc_commit 范围、
  三层记忆段落、tags 参数已删、预填充文案含内容与 doc_query 引导

变异全部被抓住(截断落库 / Summary+Content / 去掉算法版本 / 旧预填充文案 /
一句话工具描述 / 删三层记忆段)。

## 验证

- go build ./... 通过;go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
- go test -race ./internal/memory/document/ ./internal/agent/core/:通过
- 真实数据(chineseclip 512 维):
  · 旧格式存量(1055 篇,无 Events):4/5 → 仍是旧分数,等自然冲刷
  · 新格式文档(走 ContextToDoc):4/5 top1 命中正确,
    区分度最高 0.18(旧实现 0.003)
2026-10-22 19:49:25 +08:00
b1f02a22b6 fix(memory): 向量输入改用事件正文 —— 摘要是 jieba 词袋,把全库拉成同一方向
## 实测缺陷(真实 chineseclip,4 篇主题完全不同的短正文)

    [1][3] 0.9478    [2][3] 0.9427
    [1][2] 0.9316    [0][3] 0.8614

两两余弦 0.85~0.95,几乎同向。查询分数 0.70~0.78 挤在一起,区分度 0.08。

根因:denseFor 用 `doc.Summary + " " + doc.Content`,而 Summary 由
summarizeEntries → memory.ExtractKeywords → TokenizeContentWords → jieba.Tag()
生成,同一来源的摘要**逐字同质**:

    来自 1 个来源的 1 条对话 (src0) 涉及: 用户, 提问
    来自 1 个来源的 1 条对话 (src1) 涉及: 用户, 提问

这与设计直接矛盾:ARCHITECTURE L131/L174 写的向量层级是
「统一多模态空间(主)→ StaticEmbedder → TF-IDF(回退)」,
而 Summary 是**主路径的输入**,却由**最末一级的词法分词**产出。

## 修法

denseFor 改用 documentTextForVector(doc):按事件顺序拼原文(来自块里的正文)。
存量无 Events 的文档退回 Content(渲染视图,但比 Summary 更接近内容)。

★ 绝不含 Summary —— 它是词袋,会把全库拉向同一方向。

## 媒体为什么不单独生成摘要

媒体块与文本块**在同一事件内**(Doc.Events[i].Blocks),事件正文进入向量
即覆盖其语义。为媒体单独生成描述正是 CAS 时代「描述式索引」的错误
(描述是二手信息),v1.2.0 已整体拆除,不重走。

「按图搜图」是视觉路(RecallBlocks 读块自己的向量)的职责,已有独立通路。

## 判据

- TestRetrieval_SummaryNoiseMustNotPolluteVector:主题不同的短正文文档,
  两两余弦必须 <0.80。修复前 0.82~0.86。
- TestRetrieval_VectorInputIsBodyNotSummary:正文里的唯一哨兵必须能检索到。

变异:denseFor 改回 Summary+Content ⇒ 前者立刻红(实测 0.8225~0.8573)。
2026-10-22 19:33:50 +08:00
1dcd3ced03 test(memory): 先写检索质量判据 —— 当前红,量化「检索不可用」
上一轮(memory-blockflow-repair-plan)写了 16 条结构判据、6 个变异全绿,
然后用生产数据实测发现检索**完全不可用**:

    查询 "doc 层召回是怎么设计的"  → 0.7834  内容无关
    查询 "hmap 插件安装验签"       → 0.7922  内容无关
    查询 "QQ 头像"                → 0.7601  内容无关

三个互不相关的查询分数全挤在 0.75~0.79,区分度≈0。

★ 教训:结构判据不能替代质量判据。
  「字段非空」「条数正确」「不 panic」在检索完全失效时依然全绿。

## 本文件

5 条判据,其中 2 条当前**红**(这就是要修的缺陷):

- Top1HitsCorrectDoc / DistinctQueriesHitDistinctDocs / ScoreGapIsSignificant
  → 用 fakeDenseSpace(按主题词投影),验证排序与区分度骨架
- **VectorInputIsBodyNotSummary**(红)→ 正文哨兵必须进向量
- **SummaryNoiseMustNotPolluteVector**(红)→ 主题不同的文档不得接近同向

## 第 5 条的实测依据(真实 chineseclip,4 篇短正文)

    [1][3] 0.9478    [2][3] 0.9427
    [1][2] 0.9316    [0][3] 0.8614

四篇主题完全不同的文档,两两余弦 0.85~0.95 —— 几乎同向。
根因:denseFor 用 doc.Summary + doc.Content,而 Summary 由 jieba 词袋生成,
同一来源的摘要逐字同质:

    来自 1 个来源的 1 条对话 (src0) 涉及: 用户, 提问
    来自 1 个来源的 1 条对话 (src1) 涉及: 用户, 提问

判据用 bigramSpace(字符 bigram 归一化直方图)模拟该行为,
不依赖模型文件,CI 可跑;阈值 0.80,实测缺陷态 0.82~0.86。

★ 正文必须**短**:正文越长 Summary 噪声占比越小、缺陷越隐蔽 ——
  这正是生产上只有短文档受害的原因。
2026-10-22 19:32:59 +08:00
37bece97cb fix(packaging): tar 包漏拷 depgraph.bin —— 装完 homed 一启动就死
内核启动时比对「磁盘上的启动依赖图 digest」与「二进制内嵌 digest」,
用于拦住「图文件完好但描述的是旧代码」这类静默顺序错(Makefile build 段注释)。

build_tar 只拷了二进制,没拷 $BUILD_DIR/depgraph.bin ⇒ 装 tar 包的机器
homed 读不到图,直接拒绝启动(退出码 1)。

修法:与 homed 同目录拷 depgraph.bin(/usr/bin/depgraph.bin);
缺图时**显式报错并 return 1**,而不是打出一个注定起不来的包 ——
静默产出坏包比打包失败更难查。
2026-10-22 19:30:05 +08:00
47c95efb80 feat(memory): L2 与 L1 同构 —— 事件列表承载原文,正文内联在块里
块化重构(memory-restructure-plan)换了底层数据结构,但 L1↔L2 的流动没跟着改,
导致这条链出现结构性失效。本次按设计原文修复。

## 断点(生产实测)

**① L2 只存渲染视图,事件本体丢失**

ContextToDoc 把每条事件拍平成一行:truncate(Response,100)、truncate(工具输出,200)。
生产库 1000 篇归档文档实测:
  - response 段恰好 103 字符(=100+"..."):311/2094 = 15.1%
  - 工具输出段恰好 203 字符:7593/13261
  - ToolsUsed 完全不存
⇒ 原文在落库那一刻就不存在了,召回再全也拿不回来。

**② L2 里没有文本块,与 L1 不同构**

L2 块分布 image 437 / audio 7 / text 0 —— 文本全靠 Content 这个视图。

**③ 召回造合成事件,时间戳用归档时刻**

doc_query 用 d.CreatedAt 造一条 cold_storage 事件、正文取 Content 再截 2000 字、
且完全不回填 Blocks。块自带 created_at(捕获时刻),实测与 doc.created_at
差近两小时,却没人用。

**④ 块在 Consume 时两层同时消失**

Consume 从内存与磁盘同时删除文档,而块的字节只存在于那个 .json 里。
生产 L2 有 444 个块,其中 396 个不在 L3(约 248MB)—— 一旦被命中即永久丢失。

## 修法(按设计,不是按我的推断)

设计前提(写进 ARCHITECTURE 与计划文档):
  · 记忆块是唯一物理载体,层是持有关系
  · L1 与 L2 结构同构,唯一区别是「是否直接投喂给模型」
  · 正文(含多媒体正文)内联在块里;事件只是块的容器
  · 任何一层都不压缩(视图不是记忆)
  · 内核不得硬编码任何插件行为

1. Doc 增加 Events []DocEvent(与 ContextEvent 同构),成为文档主体
2. 正文各自成块:Input 一块、Response 一块(BlockText,Text 即正文)
3. restoreDocEvents 逐条按原始时间戳迁回 L1,块随事件迁回
4. Content 保留为渲染视图(contentFromEvents 生成),不再是原文载体
5. 蒸馏 docToTriples 改用 documentOriginalText:从块读原文,不再用视图
6. 删掉 ARCHITECTURE 里「过滤 agentcli/terminal 来源」——那是内核硬编码
   插件名,改由插件经 NoMemory/Cleaner 声明

## 顺带修掉的真实缺陷

simpleHash 是 O(n²) 且哈希串长度正比于内容:
    for _, c := range s { h += fmt.Sprintf("%x", c) }
压力测试实测 1.25MB 正文归档耗时 **270 秒**;生产实测 content_hash
是 content 的 2.2 倍(52072 字 → 114491 字)。
换成 sha256 前 16 字节:**270s → 0.52s(520 倍)**,定长 32 hex。

## 存量

按拍板不迁移,等系统自然冲刷。降级路径:无 Events 的旧文档仍按旧行为召回
(整篇渲成一条事件),不伪造成「有原始事件」。生产副本实测 1104 篇旧文档
全部正常加载。

## 判据(16 条,全部有变异自证)

document/blockflow_test.go(11 条):正文完整性、文本块粒度、逐条保留、
视图定位、幂等、存量兼容、原文可用性、hash 线性、3 条压力
core/recalldoc_test.go(5 条):逐条回填、原始时间戳、块迁回、
回填不截断、存量降级、批量压力

变异全部被抓:
  · 落库 truncate(100)        → ArchivePreservesFullText(实测 len=303/原 10215)
  · Input/Response 合成一块    → InputAndResponseAreSeparateTextBlocks(实际 1)
  · 召回用 d.CreatedAt         → RestoresEachEventAtOriginalTimestamp(回填 1/期望 3)
  · 召回不回填 Blocks          → MigratesBlocksBackToContext
  · 召回再截 2000 字           → TextIsNotTruncatedOnTheWayBack(2003/原 18020)
  · 恢复 O(n²) hash            → HashIsLinearAndFixedLength(超时 >300s)

## 验证

- go build ./... 通过
- go test ./... -count=1:58 包 0 失败
- go test -race ./internal/memory/document/ ./internal/agent/core/:通过
- 生产数据副本实际启动:1104 篇文档加载正常、0 崩溃、干净退出
- 副本 e2e:新归档 2 事件 / 4 文本块、正文 3207 字完整回读、时间戳精确

## 不改

- 公开 SDK 契约(TextEvent / DocMemoryAPI / InsertWithMedia 已等价)
- 存量文档(等自然冲刷)
- 渲染层截断常数(独立议题,避免与本次修复互相掩盖)
2026-10-22 11:09:13 +08:00
9faae921ba Merge pull request #20 from JianFeeeee/refactor/rename-batch7-sdk-docs
docs(rename-batch7): SDK 仓名引用统一为 hologramagentsdk(module 路径按发布 tag 保留旧名)
2026-10-22 07:29:22 +08:00
8040586daa docs(rename-batch7): SDK 仓名引用统一为 hologramagentsdk,module 路径按发布 tag 保持旧名
SDK 仓已在 GitHub 改名为 JianFeeeee/hologramagentsdk(旧名 homeagentsdk
301 跳转,仓 id 与提交未变)。本批把仓内**指向该仓的引用**跟上:

- 仓库链接 / 克隆地址 / 示例目录链接:homeagentsdk → hologramagentsdk
  (README zh+en、PLUGIN_DEV zh+en、OVERVIEW zh+en、site/index.html)
- package-linux.sh 的 fallback clone URL 同步新名(仅 URL 与注释)
- ci-cd-runbook:仓库表与 secret 配置命令用当前名,并注明历史改名点

**刻意保留旧名的三处**,都是事实而非遗漏:
1. Go import / go.mod replace 与文档里的 import 示例——已发布 tag
   (v1.3.0 及更早)的 go.mod 仍是 module github.com/JianFeeeee/homeagentsdk,
   改了直接编译不过。PLUGIN_DEV 里加了显式注记说明这条边界。
2. gitcode 的 homeagentsdk → 404 实测行(那是实测事实表)
3. runbook 里「配置时还叫 homeagentsdk」的历史说明

实测(2026-10-22):github.com/JianFeeeee/hologramagentsdk 的 /tree/v1.1.0、
/tree/v1.3.0、/blob/main/LICENSE 均 200;example/* 为 301→tree 200
(GitHub 对目录自动从 blob 跳 tree,非死链);ls-remote 取到
refs/tags/v1.1.0 = 49f9957。

零代码改动:.go/.lua/go.mod/go.sum/third_party 变更数 0。
2026-10-22 07:25:53 +08:00
f408448c37 Merge pull request #18 from JianFeeeee/refactor/rename-batch6-site-docs
refactor(rename-batch6): 文档/静态站旧仓名清理 + gitcode 镜像仓名对齐 HologramAgent
2026-10-22 07:20:29 +08:00
75b4c50fa2 Merge main into refactor/rename-batch6-site-docs:吸收前置检查重试脚本,GC_REPO 收为 job 级单一定义点
PR #19 合入后 main 上 ci.yml / release.yml 的前置检查换成了公共脚本
.github/scripts/check-gitcode-repo.sh,与本批「GC_REPO 收为单一定义点」
改了同一段代码,故冲突。解决口径:

- 前置检查主体取 main 的重试脚本版本(不保留本批内联的那份长 case,
  重试/退避/notfound-优先于-403 的能力保留)
- GC_REPO 仍收为 job 级 env 单一定义处:main 上 ci.yml 两处、release.yml
  三处字面量(含 upload_assets 调用里那处)全部改为走 ${GC_REPO}
- 结果:每个文件内部只有 1 个定义点,注释里的实测清单同步为当前状态

零代码改动:.go/.lua/go.mod/go.sum/third_party 变更数 0。
YAML safe_load 两文件通过;check-gitcode-repo.sh bash -n 通过。
2026-10-22 07:17:57 +08:00
f65408dadb Merge pull request #19 from JianFeeeee/fix/gitcode-precheck-retry
fix(ci): gitcode 目标仓前置检查加有界重试,区分网络抖动与仓不存在
2026-10-22 07:16:00 +08:00
777f2f810d fix(ci): gitcode 目标仓前置检查加有界重试(网络抖动不再误判)
2026-10-21 的 main 流水线(dfc4e16)里,「前置检查 gitcode 目标仓是否存在」
首次执行时 gitcode 没有给出预期响应,一次 ls-remote 判失败 ⇒ sync job 红;
重跑立刻全绿。把一次网络抖动翻译成「镜像同步坏了」,是纯噪声。

改法:只对**网络层**失败重试,其余立即失败。
- 网络层(DNS/连接重置/TLS/超时/EOF)→ 最多 3 次,退避 5s、10s
- 仓不存在(gitcode 实测:could not be found + **403**,不是 404)
  → 立即失败,不重试。重试只会把「去建仓」拖成十几秒空等
- 凭据问题(401/403 但不带 not-found 特征)→ 立即失败,不重试
  (token 错了等 15 秒也不会变对)
- 分类顺序上 notfound 必须先于 403:gitcode 的「仓不存在」响应里
  **同时**含 could not be found 与 error: 403,先判 403 会把真问题贴上
  「凭据问题」的错误标签
- 每次尝试打一行「第 N/M 次探测失败(类别):摘要」;重试提示打到 stderr
  (⏳ … 秒后重试),Actions 日志里一眼看得出发生过重试
- 耗尽重试时整段回放每次尝试的摘要,并明确写「判定:网络问题」
  —— 与「仓不存在」/「凭据问题」的判定措辞互不相同,可直接 grep 区分

同口径:**抽成 .github/scripts/check-gitcode-repo.sh,两个 workflow 共用同一个
文件**,而不是两边各写一份「逐字一致」的脚本 —— 后者迟早会漂。ci.yml 的
sync-gitcode-main 与 release.yml 的 sync-gitcode 各留 1 行 bash 调用。
(抽得出公共片段,故没有采用「两边逐字改」的方案。)

token 安全:GC_URL 含 token,永不 echo;所有输出先过 redact()。脱敏两道:
先按字面量遮 token(BRE 元字符先转义),再按 URL 结构遮 userinfo@。
结构式的 password 段**不能排除 `/`** —— 真实 token 可以含 `/`,第一版写成
[^@/[:space:]]* 时 `https://user:tok/en@host` 匹配不上,只剩字面量一道兜住
(判据 C6 首次就是红的)。

判据与变异(9 组正向 / 8 条变异,全部咬住):
判据脚本与变异脚本见 commondev 台账 criteria/b7-gitcode-precheck-retry{,.mutations}.sh。
正向覆盖:重试次数上限(恰好 3 次探测)、仓不存在时 1 次即失败无重试痕迹、
首次成功无重试噪声、抖动后第 2 次成功、凭据问题不重试、token 不进日志、
默认退避真的是 5s/10s(用假 sleep 记录入参,不真等 15 秒)、
两处 workflow 都调同一脚本事 yaml 可解析 + bash -n。
变异 8/8 咬住:默认次数 3→1、退避→0、仓不存在也重试、去掉上限、
去掉重试提示、清空 notfound 特征串、去掉结构式脱敏、ci.yml 退回内联 ls-remote。

过程中修掉三处自己判据的盲区(都不是改产物去迁就判据,而是补判据):
1. M2(退避→0/0)首次未被咬住 ⇒ 补 C7「默认退避 = 5 10」(假 sleep 记录入参)
2. M7(删结构式脱敏)首次未被咬住 ⇒ 补 C7b:userinfo 密码用**别的** secret
   且**含 `/`**。C6 用当前 token 的字面量做输入,字面量替换本身就能抹掉,
   于是结构式规则删了也照样绿;且密码不含 `/` 时兜底的裸 user@ 规则会顶上,
   同样验不出来
3. C7b 第一版断言 grep 'JianFeeeee' 会误报 —— 脚本正常输出里就有
   gitcode.com/JianFeeeee/HologramAgent(那是路径不是凭据),改为断言
   userinfo 形态(://JianFeeeee[:@])

真实网络实测(不是只跑假 git):
- 仓存在:目标仓可访问,rc=0
- 仓不存在:1 次探测即失败,日志含 could not be found + 403,rc=1
- 网络失败:3 次探测 + 2 次退避,总耗时 5.7s(退避调成 1s/1s),rc=1

yaml.safe_load 三个 workflow 全部可解析;bash -n 通过。

未改动(留待裁决):deploy/scripts/upload_assets.py docstring 里
「SDK 仓传 JianFeeeee/homeagent-sdk」指 gitcode 侧、与实测一致
(gitcode: homeagent-sdk=200 / homeagentsdk=404;GitHub 相反),保持原样。
2026-10-21 23:46:01 +08:00