Commit Graph

987 Commits

Author SHA1 Message Date
14daf5b584 fix(memory): 超长工具结果在时间线里只渲染句柄(不渲染正文)
用户口径(2026-10-27):渲染工具回执,但**超长的才不渲染** —— 那些结果
已经落盘进工具环(toolresult_store),时间线里只应给「有多少、怎么读」,
正文由 toolresult_read(event_id, offset, limit) 按需取。

为什么不能渲染它的预览:预览是多行文本,塞进历史只会压成"半截结果",
信息量还不如一个句柄;而模型真正需要的是"知道有这份东西、从哪儿读"。

判据:TestTimelineOversizedResultRendersHandleOnly
(落盘结果不出现预览正文、必须含 toolresult_read 与 event_id;未落盘回执照旧渲染)
2026-10-27 15:27:35 +08:00
5deb3eb10d Revert "fix(memory): 中断的取件样板不再写入 L1(颗粒度=单条消息)"
This reverts commit 3a7440a2fe.
2026-10-27 15:04:55 +08:00
3a7440a2fe fix(memory): 中断的取件样板不再写入 L1(颗粒度=单条消息)
用户口径(反复强调):进记忆的禁忌是**中断消息本身**,不是整轮。
此前 finishInputTask 无条件把 CleanInput 写进事件,而中断轮的 CleanInput 是
"来自「X」的私聊消息(message_id=…) 先用 qq_get_message 取正文…" 这类样板:
每轮一字不差堆进 L1,真实对话内容只能靠工具回执碎片体现 ——
实测用户回一句 "233719",模型反问"那串数字什么意思"(指代消解失效)。

改法:只清样板,不清内容。
- isInjectionBoilerplate 识别插件取件指令("来自「…」…message_id=" 或 "先用…取正文"),
  命中则 Input 置空;
- 本轮的工具回执(qq_get_message 正文,NoMemory=false)、agent 回复照常入库;
- 内核中断正文不清空:测试 D1=B 要求「中断任务的上下文加载回被抢占任务之上」。

判据:internal/agent/core/interrupt_input_memory_test.go(样板被清、内核中断保留、
普通输入不受影响、回执与回复保留)
2026-10-27 15:02:57 +08:00
b2938ed4a7 fix(memory): 场景召回按块向量余弦排序 + 召回 query 可见性 + 对话回执放宽
① 场景召回是每轮常驻注入,此前按「场景权重→时间倒序」排,与 query 无关:
   老图库的 rt/rtchild/inputch 等历史事实常年霸榜,实测 1833 字符里九成是它们,
   刚聊的群聊内容一句没有 —— 这就是"忘事"的机制。
   块里本来就存着向量,相关性就是一次余弦比较:RecallByScene 带出端点/原句块
   向量,Indexer 按 max(cos) 排序并设 0.20 下限,取前 8。
   无稠密空间时保持原顺序(不能用字面匹配冒充语义 —— 既有判据要求
   「措辞与记忆零字面重合时场景路必须兜住」)。

② recall query 可见性:cleaner 缺失/生效/回空三种情况都打日志。此前静默回退
   原文,"召回 query 其实是工具原始回执"这件事在日志里完全看不到。
   实测 qq_get_message: 199 -> 27 bytes(cleaner 生效)。

③ 对话正文类工具(get_message/get_history/list_chats)时间线渲染放宽到 400 字
   (160 字会把群聊上下文切成碎片)。

判据:internal/memory/scene_relevance_test.go
2026-10-27 13:25:25 +08:00
bd066a627a feat(systemd): 服务单元改名 homeagent.service → hologramagent.service
部署目录早已迁到 /home/hologramagent,单元名是最后一处旧名。
UnitName 是唯一来源(internal/systemd/sync.go),deploy-plan.sh 同步。
2026-10-27 13:04:25 +08:00
e6b6cd12e3 fix(memory): 中心向量过期改为异步节流重建(此前永久失效)
现场:'centroid invalidated: samples 1391 -> store 1877' 每轮召回刷一行。
过期分支只打印然后 return nil,false,nil,而重建入口只存在于三个 CLI 工具里
⇒ 块数一旦越过 20% 线就永远过期,中心化校正永久关闭。

修法:过期时登记一次后台重建(每 fingerprint 10 分钟一次,延迟 2s 等读锁释放)。
不能在锁内就地重建 —— LoadCentroid 全程持 RLock,RebuildCentroid/SaveCentroid
需要写锁,就地调用必然自死锁(实测 internal/memory 测试 180s 超时)。
2026-10-27 13:04:25 +08:00
1dc32be736 fix(memory): 召回可用性——L2 片段带正文 + WebUI 上下文走双路
① L2 预填充此前只给摘要行('已回复老大:原来是这个谐音梗'),模型读得懂
   '我回复过'却不知道梗是什么 ⇒ 召回命中仍答不知道。现取两段候选
   (事件文本块 + 工具回执),JSON 回执先提取字符串值再压成单行。
② WebUI「上下文」页只显示 L3,而 L3 有 72h 冷化延迟 ⇒ 冷化没跑起来时
   面板永远'无上下文',看着像召回彻底坏了(实际 L2 一直有命中)。
   现 IndexerAPI 带上 L2 片段(与模型预填充同一 QueryScored),前端两路并列。
判据:internal/agent/core/crossmodal_body_test.go
2026-10-27 13:04:19 +08:00
238f7b5c0b style: gofmt 归位(CI 之外的漂移) 2026-10-27 12:18:27 +08:00
051247e10f fix(memory): 时间线按声明渲染工具回执 + 能力判定改为配置层
时间线此前只写「→ 调用工具: a, b」,工具取回的正文下一轮彻底消失:
模型记得自己查过,却不记得查到了什么(刚说的话转头就忘)。
ToolDef 上的 Cleaner / NoMemory 声明一直存在,缺的是消费端。

- formatMergedTimeline 渲染 e.ToolResults:Cleaner 优先,NoMemory 工具跳过,
  单条 160 字、每事件最多 3 条(+N more),预算估算同步计入
- 模态能力改由配置层声明(ProviderManager.SetCapabilities),
  不再靠 provider.(ModalProvider) 断言穿过 adapter 与安全装饰器
- a2a 通道 RecallPolicy none→auto(NoMemory 仍 true:可读不写)
2026-10-27 12:16:06 +08:00
4af07d08fe fix(memory,llm): L2 预填充并入召回主路 + 回退换 provider 时换 model(视觉失效真因)
## 1. L2(冷上下文)预填充此前只走「注入文本」,等于没有
设计:prune 与 recall 是一对 —— 工具声明 ContextPolicy=prune + RecallPolicy=auto
时,工具输出既触发裁剪、也触发预填充(memoryPass → recallTextFor)。
但 L2 那一路只写在 buildSystemPrompt 里,query 用的是**注入文本**;
QQ 注入的是「来自「X」的私聊消息(message_id=…),先用 qq_get_message 取正文」,
与用户真正说的话毫无相似度 ⇒ 冷上下文预填充恒为空。
(现场:头天晚上讲的冷笑话,第二天早上完全唤不回;14 小时里
 memory recall (input…) 只有 5 次、且 query 是元信息。)

改法:把 L2 检索并入 buildMemoryContext —— 输入路用输入文本、工具路用工具正文,
两条路共用同一次召回与预算;buildSystemPrompt 里那份重复注入删掉。
中断消息仍然 NoMemory + RecallPolicy=none(不裁剪、不召回、不进记忆),
这是设计要求,未改动。

## 2. 回退换 provider 时必须换 model(视觉失效的真因)
现场日志:
  deepseek 402 Insufficient Balance
  → 回退 llmsproxy,但 req 仍带 model=「deepseek-flash」
  → llmsproxy 403 model_not_allowed
  → llmsproxy 被整源标记 unavailable(冷却)
  → 视觉回退 resolveModalFallback 跳过它 ⇒「配置了视觉却说没有视觉」

两处修正:
  · 每个 provider 用自己的 model 发请求(model 是 provider 的属性)
  · 403 区分「模型不被该 key 允许」与「凭证失效」:前者只否一个 model,
    provider 仍可用于其他 model 与视觉回退

判据:internal/agent/core/provider_model_fallback_test.go
2026-10-27 11:49:26 +08:00
981b969f58 feat(sdk): 受限事件总线的内核实现 + clawhubadapter 改用它
- internal/sdk/events.go:实现 pubsdk.EventsAPI
  · publishAllowed:白名单精确匹配 或 本插件命名空间 plugin.<name>.<后缀>
  · subscribeAllowed:只开只读事件(tools_ready/security_ready/device_ready)
  · 载荷出向经 security.PrepareOutbound(明文→@{别名}),入向经 ScrubInbound
  · 键数上限 64;越界**报错**不静默
  · Source 由 SDK 固定成插件名(插件不能冒名)
- PluginSDK.Events() 访问器
- clawhubadapter 的 skill_detected 改走 s.Events().Publish,不再 import internal/events
  ⇒ 它为外部化的唯一阻塞项(事件发布)已消除

判据(internal/sdk/events_test.go,6 条):
白名单放行 / 越界拒绝(raw_input、agent_output、update_request、stage、system、
别的插件命名空间、空类型)/ 自己命名空间放行 / 订阅越界拒绝 / 载荷凭据被替换成别名 / 键数上限
2026-10-27 11:16:40 +08:00
ffffb94005 docs(architecture): 补上「模块化内核」正篇,原描述停在 v1.x
原架构文档对内核的描述仍是旧的单体启动口径(eventLoop select / interceptLoop /
StageHost 细节),完全没有 2026-10 之后的内核装配机制。新增两节:

## 内核装配(模块化内核)
- internal/kernelmod 注册表 + 每模块一个文件夹 + all/ 唯一 import 清单
- Module 接口:Requires 语义是「我需要谁已就绪」而不是「排在谁之后」
- kernelboot.Build() 分层(缺设施/重名/环三类结构性错误带设施清单报死)
  + Run() 逐层并行 Init、每层前复核设施到位
- KernelState 是唯一公共数据结构:类型化字段、拼错即编译错误、同层并行不加锁
- 当前真实 6 层拓扑(20 模块),并说明 update 为何在 L0、security 为何在 L1、
  device 为何在 L5
- Group 0 是副作用顺序,不进图但必须最先跑完
- depgraph.bin:依赖图编译期生成 + EmbeddedGraphHash 摘要校验
- 设施名是插件契约(与 sdk.Facility* 逐字一致,拼错即永不启动)

## 认知层(Agent 运行时)
明确后续 eventLoop / RelevanceContext / StageHost 几节属于装配完成后的执行层,
不是内核启动机制 —— 避免读者把两者混为一谈。

顺带:两份文档共 46 处无语言标记的代码块补上 text。
2026-10-27 10:09:35 +08:00
633143b15a docs(knowledge): 知识库条目同步新术语(L1/L2/L3 + stablemem)
assets/knowledge/homeagent_architecture/content.md 是**知识库数据**(会被
knowledge_search 检索),此前仍写「文档记忆(Document)、图记忆(Graph)」,
与现行对外语义不一致。

改为:上下文记忆(L1,热)/ 冷上下文(L2,cold context)/ 稳定记忆(L3,stablemem)。

至此非归档文档中 doc_query/doc_commit/图记忆 已清零;剩余旧词只在
6 份已加归档说明的历史计划与 scripts/capability-bench/PLAN.md(评测记录)。
2026-10-27 10:06:27 +08:00
1d52ba85d5 docs: 术语与架构措辞对齐(coldcontext / stablemem / security+device 模块)
1. 术语统一:
   - doc_query / doc_commit → coldcontext_query / coldcontext_commit
   - 「图记忆 / Graph 层」→「稳定记忆 / Stablemem 层(L3)」
   - 分层命名补上 L1/L2/L3 语义:Context(热) / Cold-context(冷) / Stablemem(持久)
   涉及 README.md、README_EN.md、assets/docs/{zh,en}/{ARCHITECTURE,OVERVIEW}.md

2. 补上本次最大的结构变化(文档里此前完全没有):
   - 安全防火墙作为**独立内核模块**:凭据闸门、工具黑白名单+沙箱、
     敏感词与提示词注入预警、按主体授权与插件命名限式;显式代理而非 monkey patch
   - device 数据面作为**独立内核模块**:工具注册与路由、inputch/outputch、设备登记;
     路由表在 internal/toolroute 以避免 core ↔ kernelboot 成环

3. 历史计划文档(memory-*.md / context-overflow-l4-design / entities-retirement /
   git-branching)加**归档说明**:明确术语已过时、现行口径以 ARCHITECTURE.md 为准。
   刻意不改写历史正文 —— 那是决策记录,改写等于伪造。

剩余旧词只出现在归档文档与 scripts/capability-bench/PLAN.md(评测历史记录)。
2026-10-27 09:58:14 +08:00
c33199cc5a fix(webui,plugin): 内核控制按钮晚绑定 + 按钮布局;plugin_start 报错给候选名
1. 内核控制通道晚绑定(真机 503 实测)
   POST /api/v1/kernel/restart 一直返回 503:webui 由 plugin-host 在装配期
   拉起,而 main() 里 SetKernelControl 还没执行 ⇒ 插件 Start 拿到 nil。
   依赖图保证不了 main 里的语句顺序,所以改成让插件拿**解析器**
   (lazyKernelControl),调用那一刻再取真实通道;授权仍在每次调用时
   按 kernelControlAllowed/kernelRestartAllowed 复核,不因晚绑定放宽。

2. WebUI 重启/关机按钮布局
   原先用 .theme-btn(顶栏图标样式:pill + margin-right + 无文字)混在
   侧栏底部的文字按钮里 ⇒ 大小不一、挤在一起、关机看不出是危险操作。
   改为与「退出登录」同族的 btn-sm,带图标+文字,关机用 danger 悬停。

3. plugin_start 失败报错
   原来只说加载出错,或插件不存在,一个可用名字都不给,模型只能盲猜
   (实测把 files 写成 file)。现在给:失败原因分类(目录不存在/没清单/
   自身加载失败)、最接近的名字(Levenshtein ≤2)、已安装清单(最多 12)。

4. scripts/migrate-plugins-to-org.sh:插件迁到 GitHub 组织独立仓的脚本收口

测试:go build ./... 通过;go test ./internal/plugin ./internal/plugins/webui 全绿。
2026-10-27 01:43:19 +08:00
55fa2685ae feat(security,device): security 成为独立内核模块(闸门+策略+限式),device 接管内置工具路由
security(新模块 internal/kernelmod/security)
- 凭据闸门:出向拦截成 @{别名}、入向清洗、执行前回填(internal/security/{gate,runtime}.go)
- 工具黑白名单 + 沙箱(读写根、网络、.. 逃逸):internal/security/policy.go
- 敏感词过滤 + 提示词注入预警(Finding 不带原文):internal/security/content.go
- 按主体授权(hard-private 对非内部主体永久拒绝):internal/security/principal.go
- 插件命名限式(可选档位,插件声明/内核执行,只能收窄):internal/security/profile.go
- 启动装配从 cmd/homed 迁入模块;启动顺序判据改盯真实装配点

device(新模块)
- 正式内核模块,Provides FacDevice,KernelState.Device
- 内置工具路由表下沉到 internal/toolroute(避免 core↔kernelboot 环),
  device 保留对外入口;agent 未挂载时退化本地 switch
- Agent 侧只留 api.ToolHost/DeviceSurface 适配层

SDK
- InjectOptions.SecurityProfile:插件按 inputch 声明本轮安全档位(可选)
- InjectOptions.TurnHint:插件注入**本轮事实**,内核以独立 system 消息注入,
  不混进用户正文(人设负责怎么说话,TurnHint 只放这一轮成立的事实)
- FacilitySecurity / FacilityDevice

提示词
- 内核自带 prompt/skill/工具返回全面英文化(中文提示会让模型行为漂移)
- 机制解释下沉到 4 个内置 skill(memory/output/tool-order/plugins),
  skill 描述压到 80 runes 内(原来 94–108 全被索引截断)
- 默认人格模板英文化;[Voice] 段删除,规则并入 persona 块(单一段落)
- 历史记忆工具改名 coldcontext_query/commit,图记忆语义改称 stable memory

修复
- tracker: exec mount → unix.Mount(降权运行 + AmbientCapabilities 下才能挂)、
  区分 EPERM 与不支持;启动日志说明后果与恢复方式
- healthcheck_kernel 输出瘦身:status.Tools 只给摘要(原 98,989 tokens/151% 预算)
- plugin_list 描述讲清 auto_start(按需)与 alive(此刻是否在跑),
  明确不能由 alive=false 推断能力不存在
- pluginmgr/webui 去掉写死的 /home/newqqagent 回退路径(env → pluginDir 反推 → 报错)
- lua: 盘上适配器与内置版本不一致且无基线时响亮告警(deepseek 流式丢失
  tool_calls 导致'模型不调工具'的真因)

测试:go build ./... 通过;go test ./... 60 包全绿
2026-10-26 20:55:25 +08:00
ca956f69bf fix(update): 升级后第一次启动被误回滚(真机 OTA 实测)+ 关闭期 nil 解引用
## 一、★★★ 升级永远无法生效(rollback.go)

真机跑 OTA 闭环时暴露:

	-apply 输出:✅ 升级完成:1.4.0dev (commit ca5b1b1)
	重启后日志:ROLLED BACK kernel 1.4.0dev → 1.4.0dev(上一次升级未能确认启动)

原因:pending 标记是在**替换之前**写的(这是对的,见下),所以升级后的
**第一次启动必然看到它**。而原实现"只要标记在就回滚",从不比对当前运行的
是哪个版本 ⇒ 每次升级都在第一次启动时被自己撤销。

正确语义(两段式确认的本意)是比对版本:

	当前版本 == 标记里的 To     ⇒ 新版本**已经跑起来了** ⇒ 确认、清标记、继续
	当前版本 != To(或读不到)  ⇒ 新版本没能成为运行版本  ⇒ 回滚到备份

标记仍然必须写在替换**之前**:反过来(替换后才写)会在"替换成功但没来得及
写标记就崩了"时丢标记,那时就不知道该回滚了。

两条判据(用真能执行 `--version` 的脚本做假二进制 —— 原来那些用例写死字节、
根本读不出版本,只覆盖了"读不到 ⇒ 回滚"这一条分支,所以抓不到本缺陷):

	TestRollback_ConfirmsWhenRunningTargetVersion  跑的是 To ⇒ 不回滚 + 清标记
	TestRollback_RollsBackWhenRunningOldVersion    跑的是 From ⇒ 必须回滚

## 二、每次重启必现两条 panic(proc/host.go)

	[stage] tool xxx handler panic: invalid memory address or nil pointer dereference
	github.com/.../proc.(*unifiedRegion).generation(...)

关机/重启时 SharedRef 统一段先被拆掉,而在途的工具调用(stage handler /
cleaner proxy)仍会走到 Host.Generation() ⇒ 解引用 nil。panic 虽被 recover,
但把"正常关闭"伪装成故障,并刷屏掩盖真问题。加 nil 守卫(连 h 一起判)。

## 三、下载客户端 20s 总超时(release.go)

`DownloadLatestRelease` 复用了查询用的 kernelHTTPClient(Timeout=20s,
那是**整条请求**上限,含读完 body)。而发行包近 900MB ⇒ 必然
`context deadline exceeded ... while reading body`,"从 Release 自动更新"
永久失败,且错误看起来像网络问题。

改为独立下载客户端:建连/TLS/响应头仍分层设超时(卡住能失败),
读 body 不设总时长。
2026-10-26 14:03:05 +08:00
ca5b1b17b7 test(pluginmgr): 判据钉住暴露给 agent 的两个更新工具
kernel_update / plugin_update 是**模型唯一能触达的更新入口**。它们被删掉、
改名、或参数对不上时,agent 就再也升不了级 —— 而源码里 grep 得到工具名
不能证明它注册成功(本仓 E-3 教训:源码有、运行时没有)。

三条判据,全部走真实 registerTools(捕获 sdk.SDKConfig.RegTool):

1. 两个工具都注册了,且 Serial=true
   更新会替换文件并重启,与 install/remove 并发会撞车。

2. kernel_update 同时暴露两种来源
   缺 pkg ⇒ 只能用 Release;缺 release ⇒ 只能用本机包。
   描述还必须说清"二选一",否则模型会都传或都不传,白烧一轮往返。

3. plugin_update 必须有显式范围
   缺 plugins 参数就无法表达范围;默认全升会让用户"只升一个"变成批量重装。
2026-10-26 13:48:39 +08:00
817f6c59ae fix(update): 重启交还 guard、OTA 支持 release/包两种来源、修三处真 bug
## 一、重启不再由 update 自己 systemctl(用户口径:应该通知 guard)

原 internal/update 直接 `systemctl restart homeagent`。三个问题:

① 杀掉整个 cgroup(unit 里 KillMode=control-group)⇒ **guard 跟着死**,
   而 guard 存在的意义就是不让自己死。
② 丢失 exit 42 语义:guard 的 failures/failbackRound/failbackDone 全丢。
③ 自毁竞态:发命令的进程正是被重启杀掉的进程 ⇒ CombinedOutput 可能报错,
   **一次成功的升级被误判失败而回滚**。

现在 internal/update 只到"产物就绪":预检 → 备份 → 写 pending → 原子替换。
重启由调用方走**既有**通道(restartCh → exit 42 → guard 用新二进制重拉):

· webui      h.kernelCtl.RequestKernelRestart(本来就有,且是白名单内)
· pluginmgr  p.sdk.KernelControl().RequestKernelRestart(见下面第三条)
· CLI        外部管理命令,restart unit 后按**版本自报**核对是否真生效

## 二、OTA 支持两种来源(release 自动更新 / 指定安装包)

新增 internal/update/source.go:

	Source{Kind: FromPackage, Path}  → 直接用本地包
	Source{Kind: FromRelease}        → 查最新 Release → 下载 → 校验 digest

ParseSource(pkg, release) 统一判空规则(pkg 优先),避免三端各写一套。
两条来源解析后进**同一条** Apply,不存在两份升级实现。

## 三、修三处已提交代码里的真 bug

① **HEAD 编译不过**:d17e261 提交了 kernelmod/update/mod.go(引用 ks.Update),
   但 kernelboot/kernelstate.go 的字段改动从未提交。干净 worktree 实测
   `ks.Update undefined` —— 推上去的仓库是坏的。本次补上。

② **kernelDataDir 算错一级**:Dir(Dir("/home/newqqagent/plugins")) = "/home",
   而第 0 层 update 模块从 ks.DataDir=/home/newqqagent 读 pending
   ⇒ **agent 发起的升级一旦启动失败就永不回滚**(静默失效)。改为 Dir 一次。

③ **pluginmgr 的 KernelControl 恒为 nil**:它不在 kernelControlAllowed 里,
   于是 `if kc := p.sdk.KernelControl(); kc != nil` 永不成立 ⇒
   换完二进制**从不重启** ⇒ pending 留着 ⇒ 下次重启被判"未确认"而回滚。

   修法**不是**把 pluginmgr 加进完整白名单 —— 那等于让 LLM 能关机整个服务
   (kernel_control_gate_test 明确拦这个)。而是拆出**仅重启**授权:

	kernelControlAllowed  = {webui}       完整(含关机)
	kernelRestartAllowed  = {pluginmgr}   仅重启(shutdown 直接报错)

   两者的危险程度差一个量级:重启几秒后 guard 自己拉回来,关机不会自己回来。

## 四、EventBus:插件请求更新不必 import internal/update

新增 events.EventUpdateRequest,pluginmgr 订阅并受理:
scope=kernel → kernelUpdateApply;scope=plugin → pluginUpdate(apply)。

它在 internal/plugins/webui 的 SSE 守卫里显式登记为**内核内部控制事件**
(不该推给浏览器:前端要的是 update_progress/update_result,不是"谁发了请求")。

## 五、顺手修掉的静默失败

· webui /kernel/update 原实现:**先**写 202 Accepted,**再**解析 body,
  之后每个错误都直接 `return` —— 用户看到"已受理"然后什么都没发生。
  改为同步 Apply(失败可如实报 HTTP 状态),成功后才请求重启。
· pluginmgr 拿不到控制通道时不再假装成功,而是回报 applied=true + 明确原因
  (否则用户以为已升级,进程却还在跑旧镜像)。
· update 事件 publishResult 的 errMsg 原先是死代码(`_ = errMsg`),
  按 payload 约定写回 error 字段。
2026-10-26 13:45:54 +08:00
6dc87965ad feat(update): support release and local package OTA sources 2026-10-26 11:16:38 +08:00
5b759e80d7 feat(update): 更新进度走 EventBus + SSE(穿越进程重启的通知通道)
## 为什么必须走事件总线(用户口径:为什么不走 eventbus)

升级是**长耗时且会让进程消失**的动作。此前进度只往 log.Printf 写,
WebUI 要看得自己轮询 /kernel/update —— 而轮询在重启瞬间必然断线,
断线那一刻恰恰是用户最需要知道"升到哪一步/失败了/已回滚"的时刻。

EventBus 是内核既有的发布订阅机制(同步投递、handler panic 隔离),
用它不需要新造一条通知通道。

## 三个新事件类型

	update_check     检查结果(只读,不改动)
	update_progress  升级步骤:verify/backup/replace/restart/rollback/done/failed
	update_result    最终结果(含回滚轨迹)

## ★ 顺序约束:先发事件,再动作

升级尾部几步(replace → restart)会让进程消失。顺序一反,最后几步的
事件永远发不出去。所以每个 publish* 都紧贴在其描述的动作**之前**。

## 直接 import events,不搞接口垫层

第一版我写了 EventPublisher 接口 + map[string]interface{} payload 想
"避免依赖",那是自欺欺人:events 包**不依赖任何内部包**(已验证),
import 它不会成环;而垫层会把事件降级成无类型 map,丢掉编译期检查。
已回退该设计,改为直接传 *events.Bus。

## SDK 补 EventBus() getter

PluginSDK 原先只有 Publish/Subscribe,没有暴露总线对象。而长流程
(升级闭环)需要把总线本身传下去持续发事件 —— 只给 Publish 的话,
每个调用点都要手动转发一遍。

## SSE 通路(守卫判据逼出来的正确修法)

TestEveryPublishedEventHasAnOutwardPath 抓到三个新事件"有发布者但无对外
通路"——症状是「发了、没报错、客户端收不到」。修法不是加白名单豁免,
而是真的接进 SSE:三者都进 sseEventRing(不只是实时推送),
断线重连要能补到**最终结论**(update_result),否则用户重连后只看到
"开始升级"而没有结局。

## 调用点

· pluginmgr(kernel_update):p.sdk.EventBus() → DoUpgradeBus
· webui(/kernel/update):h.sdk.EventBus() → runKernelUpgrade(pkg, bus)
· CLI:bus=nil,退化成纯 printf(CLI 没有总线)
2026-10-26 09:23:58 +08:00
d17e261b76 feat(update): update 注册为内核标准模块(第 0 层)+ 启动失败自动回滚
## 为什么必须是第 0 层(用户口径)

update 肩负「启动失败回滚上一次升级」的职责,该职责必须**早于所有模块**。

若排在后面:升级后新内核在 memory/llm 阶段崩了,而回滚还没跑 ⇒ 机器进
崩溃循环,只能靠 guard 的 failback(受限启动)兜底。failback 是最后手段,
不该承担"换错二进制"这种**已知可回滚**的故障。

⇒ Requires() 为空 ⇒ 依赖图天然把它放 Layers[0]。

## 两段式升级确认(rollback.go)

	升级成功 ──→ 写 pending 标记("这个版本尚未被确认能启动")
	                ↓
	        重启 → 内核启动 → update 模块(第 0 层)读标记
	                ↓
	   ┌────────────┴────────────┐
	  标记还在                   标记已不在
	(上次升级没能活到清除)      (上次升级已确认成功)
	     ↓                          ↓
	  回滚到备份版本               正常继续 + 清标记

清除时机:模块跑完自检后立刻清。只依赖"进程活着且执行到第 0 层",
不依赖其他模块 ⇒ 不会误判。

**为什么不用 TTL / 重启次数**:那是 guard 已有的 failback,但判据是
"崩了几次",不是"这次升级该不该撤销"。用重启次数会在"新版本偶发崩溃"时
误回滚 —— 而那种情况该修的是崩溃,不是回退版本。

## 分层(与仓库其他模块同一惯例)

	internal/update/           业务实现(三端共用:CLI/WebAPI/agent 工具)
	internal/kernelmod/update/  适配层(读 ks → 调业务包 → 写回 ks)

参照 supervisor 模块:实现在 internal/supervisor,模块里只做装配。
分开的理由:internal/update 被三端调用,它们都没有 KernelState。

## 标记写入时机(一个真实的顺序坑)

MarkPendingUpgrade 在**替换二进制之前**调用。反过来(替换后才写)会在
"替换成功但没来得及写标记就崩了"时丢标记 ⇒ 下次启动不知道该回滚。

且写标记失败 ⇒ **放弃本次升级**(未替换任何文件):没有标记就没有回滚
能力,而不带回滚的升级等于赌运气。

## 回滚失败必须响亮

备份不存在时返回 error,调用方(模块 Init)必须中止启动 —— 继续装配等于
用一个已知起不来的版本。错误文案带备份路径与标记路径(删标记即可放弃回滚),
人能照着修。

## 判据

internal/update/rollback_test.go 10 条,2 条经变异验证:
· TestRollback_RestoresBackup —— 二进制+depgraph 同批回滚、标记清除、
  坏版本留档 homed-broken-*、版本轨迹记录
  变异:回滚后不清标记 ⇒ 红
· TestRollback_MissingBackupIsLoud —— 备份缺失时报错且可执行
  变异:静默返回 nil ⇒ 红
· TestPendingMarker_AbsentIsNotError —— 无标记返回 (nil,nil) 而非 error
  (否则每次启动都走"回滚失败"分支 ⇒ 内核永远起不来)
· TestPendingMarker_CorruptIsActionable —— 标记损坏时提示可删
· TestStateSummary_* —— 回滚信息进 healthcheck;nil 安全

## 顺带修正的既有判据

· runtime_dep_test.go:L0 设施集合从 2 个变 3 个(新增 kernel.version),
  改为断言**集合**而非顺序(注册表本就是乱序的)
· facilities.go:新增 FacVersion,并登记为 internalOnlyFacilities
  (它是启动时序事实,不是插件契约 —— facilities_sync_test 的守卫正确拦住了
  "把内部标记泄漏成对外契约")

三个 DoUpgrade 调用点补 dataDir 参数(标记落点必须与内核 --data 一致)。
2026-10-26 02:08:29 +08:00
475aac867b refactor(update): 内核与插件更新合并为 update 子系统(三端共享一个底层)
## 为什么合并(用户口径:更新部分应独立为子系统)

之前 kernelupgrade / pluginupdate 两个包,但它们共享三样东西,
分开写就要各复制一份,然后慢慢不一致:

① **版本比较** —— 合并前共 6 个函数做同一件事:
   kernel 侧 ParseSemver / CmpSemver / versionNewer / trimTagV
   plugin 侧 trimV / isNewer / semverNums(还自带一份正则)
   且**行为不一致**:kernel 侧只挡 len<2,"1.4" 漏过去被填成 1.4.0;
   plugin 侧的正则要求两段所以也漏。两份都把 "1.4" 当合法版本。

② **两阶段交互**(检查 → 确认 → 执行)—— 同一套用户口径。

③ **失败要响亮** —— 都不静默降级。

## 收敛结果

internal/update/
  doc.go     子系统头:职责边界、三端共享契约、为何不分家
  semver.go  ParseSemver / CmpSemver / Newer / TrimTag(**唯一一份**)
  kernel.go  内核升级闭环(解包→校验→备份→替换→重启→复核→回滚)
  release.go GitHub Release 查询
  plugin.go  插件升级(查仓库→拉源码→hmapdev 编译→产物)

三端共享:
  agent 工具(plugin_update / kernel_update)  ┐
  WebAPI(/plugins/update、/kernel/update)    ├─ internal/update
  CLI(homed-upgrade)                         ┘

入口层只做参数解析与结果呈现,不含业务逻辑 —— 否则会出现
"agent 说没有新版、WebUI 却升了级"的分裂状态。

## 合并时抓到的两个真实 bug

① **ParseSemver 只挡 len<2** ⇒ "1.4" 被零值填成 1.4.0,
   「1.4 → 1.4.0」被判完全相同(说"无需升级"),而两者内容未必相同。
   现在要求恰好三段。

② **不认 v 前缀** ⇒ GitHub release tag 全是 v1.4.0 形态,
   CLI 对"当前 v1.4.0 vs 包内 v1.4.0"判不可比、只能退回字符串比较。
   现在 TrimTag 去 v 前缀后再比。

## 新增入口

· plugin_update(agent 工具)+ GET/POST /plugins/update
  —— 两阶段:apply=false 只检查(列出 updatable / no_repo 两类,
     后者是"未声明仓库、无法自动更新"的内置/私有插件);
     apply=true 需显式 plugins=all 或名单(不默认全升)
· kernel_update(agent 工具)+ GET/POST /kernel/update
  —— 两阶段同构;apply 只收本机包路径(URL 下载是 CLI 职责,
     agent 不该在推理中发起长下载)

判据:
· internal/update: Newer / TrimTag / ParseSemver 的边界(含上述两个 bug 的回归)
· pluginmgr 入口层 5 条:检查形状、apply 必须显式给范围、
  kernel apply 必须给 pkg 且指向 homed-upgrade、拒绝目录当包
2026-10-26 01:05:09 +08:00
02022bc13d feat(ota): kernelupgrade 抽成独立包 + 插件仓库迁移脚本
## 1. internal/kernelupgrade:抽包(避免两份实现漂移)

原来 641 行全在 cmd/homed-upgrade 的 main 包里。内核侧的 kernel_update
工具要复用同一套逻辑,而 main 包不可被 import —— 照抄一份的后果是
"CLI 能升级、agent 调用却把机器搞坏"。

导出:ReadBinVersion / CompareVersions / ParseSemver / CmpSemver /
ProbePluginCompat / ExtractPkg / FindFile / DoUpgrade / WaitHealthy 等。
CLI 变薄壳(只留 flag 解析与 fail 输出)。

★ 顺带修一个真实 bug(判据抓到):ParseSemver 原来只挡 len<2,
于是 "1.4" 漏过去、缺失的 patch 被零值填成 1.4.0 —— 会让
「1.4 → 1.4.0」被判成完全相同(进而说"无需升级"),而两者内容未必相同。
现在要求恰好三段。

## 2. scripts/migrate-plugins-to-org.sh:插件迁成独立仓库

为什么迁:插件散在 SDK 仓 example/ 下有三个问题 ——
  ① 插件版本与 SDK 版本绑死(一次 SDK 发版把所有插件都带上)
  ② 插件作者不能单独为自己的插件发版
  ③ plugin_update 需要"仓库地址 → 拉源码 → 编译",而 example/ 是
     别人的仓库,不是插件自己的仓库

脚本纪律:
· 默认 --dry-run,不碰 GitHub
· 产物不入库(dist/build/rawfile/node_modules/*.hmap 全排除)——
  实测源码 3MB、产物 1.9G,后者进了仓库就再也删不干净
· 幂等:仓库已存在(422)则跳过创建,重跑安全
· 自动给 plg.json 补 repo 字段,使 plugin_update 能定位仓库
· 用 tar 管道而非 rsync:后者在最小化系统里常常没有(本机实测就没有)

执行结果:26 个插件全部迁移(25 Go + 1 Lua),仓库名与插件名同名。
2026-10-26 00:06:59 +08:00
17f36ac430 Merge pull request #29 from JianFeeeee/merge/id10-closeout
fix(plugin)+ci: KERNEL-REGISTRY-SETLOCK 全量收尾(Set* 加锁 / 撤 race 排除项 / 门禁空表守卫 / 撤证据步②)
2026-10-25 22:48:07 +08:00
29150065aa fix(clawhub): sidecar 就绪超时 5s → 30s(慢机器上 node 冷启动超时)
CI 报红:`TestLaunchSidecarAndListTools`
  launch sidecar: sidecar echoplugin ping timeout (5.00s)
本机全绿。

## 这不是"测试机器慢所以放宽测试"

sidecar 起不来等于该 skill 整体不可用 —— 这是**产品行为**,不是测试细节。

原来的 5s 是按开发机估算的:node 冷启动 + 模块加载在快盘空载时约 0.2s。
而 CI 共享 runner(以及真实部署里的低配机器)上,一次 fork+exec+node
引导遇到 CPU 争抢就会超过 5s。

判断"真的挂了"应该由**进程退出信号**负责(readLoop 会感知),
而不是由引导耗时猜。

## 30s 的取法

· 比 systemd 的 TimeoutStopSec(25s) 宽 —— 关停时不该被就绪等待拖住
· 与用户可感知的"卡住"阈值(约 30s)持平 —— 再长就该让用户看到明确失败
· 错误文案带上超时值与可能原因(node 不可用),便于自助排查
2026-10-25 22:31:51 +08:00
23ed997b04 test(proc): Publish 延迟判据改为自校准噪声基准 —— 修 CI 误报
CI 报红:`TestStreaming_PublishLatencyFlatAcrossSubscribers`
  1 个消费者:6.16615ms(均摊 2.055µs/次)
  8 个消费者:30.466442ms(均摊 10.155µs/次)
  → 比值 4.9 越界(阈值 4),报"疑似线性恶化"
而本机连跑 3 次全绿。

## 根因:不是被测逻辑,是分母量级 + 单次采样的抖动

单次 Publish 真实成本只有 0.4~2µs。这个量级下 CI 共享机器上任何一次
抢不到 CPU(20~30ms 很常见)都会把比值放大几倍,而它跟"订阅者数是否
线性恶化"毫无关系。原判据各只测一次,一次抖动就定生死。

## 为什么不用绝对时间门槛(我先试过,是错的方向)

本机 423ns/次、CI 2µs/次,量级差 5 倍。任何固定阈值要么在本机永远
跳过(判据失效、等于删掉测试),要么在 CI 永远误报。

## 改为自校准:同配置两段之差即噪声水平

`measure` 内部把 rounds 拆成前后两段,分别计时。同一配置、同一消费者、
同一机器——两段之间的差异只可能来自环境抖动,与机器快慢无关。

· 两段差异 > 2 倍 ⇒ 环境不可测,跳过比值判定(只记录)
· 否则照旧判比值 > 4 倍 ⇒ 红

## 为什么不把 measure 调两次(也是我先试过、错的方向)

`EvtConsumer.Stop()` 只关自己的 stop channel,而 Run 正阻塞在
`evtfd.Read`——后续没有 notify 唤醒就不会返回,`wg.Wait` 于是永久
挂住。实测连续调用直接把测试拖到 240s/360s 超时。
分段测量在**同一次 consumer 生命周期内**完成,不碰这个问题。

## 判据仍有识别能力

变异验证:在 8 订阅者的计时区间内人为加入 7 倍写开销(模拟 O(N) 广播),
比值会达到 8 倍量级,远超 2 倍噪声门与 4 倍阈值 ⇒ 仍然判红。
2026-10-25 22:28:22 +08:00
aa7c5bc282 build(package): 增加打包脚本 —— 输出含内核 OTA 工具的安装包
为什么要有脚本:此前 build/package/ 里的包是**手工拼**的,没有可复现路径,
SHA256SUMS 也是事后手算。手工包的问题不是慢,而是"上次包里有、这次忘了"——
尤其 depgraph.bin 必须与 homed 同批(digest 绑定),漏一个内核就拒绝启动。

产出:
  build/package/HomeAgent-linux-x86_64-<ver>-<date>.tar.gz (+ .sha256)

包含:bin/{homed, depgraph.bin, waiter, hmapdev, homed-upgrade} + sdk/ 源码
      + install.sh(先校验 SHA256SUMS 再安装)+ SHA256SUMS(706 项)

★ 排除 SDK 构建产物:直接 cp -r 会把 example/commondev 的 rawfile(321M)、
dist(189M) 与 build/(422M) 一起打进去,实测包从 319M 涨到 1.3G、
文件数 23293。安装包里 sdk 的唯一用途是给 hmapdev 现场编译插件提供
源码/头文件,产物毫无用处。排除后降到 95M / 706 文件。

★ SHA256SUMS 必须在全部产物生成**之后**一次算完(边打边算会漏掉后生成的
文件),且先写临时文件再 mv(避免"边扫目录边写该目录下的文件")。

install.sh 先校验再安装:截断的 homed 装上去服务起不来,而那时旧版已被覆盖。
2026-10-25 21:46:13 +08:00
7052644431 fix(core): contextoverflow 改回空中断(按原始设计)+ Python 解释器可配置
## 1. contextoverflow:L4 空中断(推翻三处偏移)

用户口径:**原始设计是注入 L4 空中断,目的是触发语义相关性裁剪,
不含任何消息,就是一个空中断。**此前的实现有三处偏移:

① **中断带正文** —— `Build` 生成「已裁剪 N 条低相关事件到文档记忆…」,
   于是 L4 变成一条要模型回应的消息,白烧一整轮推理。
② **裁剪在 raise 之前做完** —— 先 `pruneByQuery` 再 raise,中断沦为
   「裁剪已完成」的回执,晚了一步。
③ **上游 contextfull 后原地重跑** —— `StepPrepare` 重建后拿**同一份**
   超限内容再发一次,必然再超限,快速烧光 recoverBudget。
   (这条是修① ② 时暴露的连带问题。)

改动:
· `VectorContextOverflow.Build` 返回空串 ⇒ 空中断;级别仍是 L4
· 删除 `overflowNotice` 与 `vectorArgs.Pruned`(中断不需要知道裁了多少)
· `handleContextOverflow` 不再裁剪,只发空中断,如实返回 0
· 新增 `pruneForAirInterrupt`:裁剪由空中断任务**真正执行时**触发,
  query 取上下文里最后一条真实用户输入作相关性基准
· 新增 `isAirInterrupt`:只认「内核来源 + type=interrupt + 无正文」,
  插件带正文的中断不受影响(否则"插件发了个空中断"会变成无声裁剪)
· `prepareInputTask` 识别空中断 → 裁剪 → terminalSkipped
  (不进 LLM、不写上下文、不发回复)
· 上游 contextfull 后改为 `outcomeSuspended`(让位等裁剪),不再原地重跑

顺带记录偏移成因(`raiseContextOverflow` 注释):当时的**观察**是对的
(L4 看不到被打断者上下文 ⇒ 模型会把"没检索到"推断成"不存在"),
但**结论**错了——它把"补偿 L4 看不到上下文"当成中断该带正文的理由。
模型需要的不是"知道有内容被移走",而是**留下来的内容与当前问题相关**。

判据(均经变异验证):
· `TestVectors_OverflowIsAirInterrupt` —— 正文必须为空 + 级别 L4
  变异:Build 改回非空 ⇒ 红
· `TestOverflow_RaisesAirInterruptWithoutPruning` —— 发中断那步不改上下文
  变异:加回 pruneByQuery ⇒ 红
· `TestAirInterrupt_ResolvedAsValidInput` —— 空中断须被认作有效输入
· `TestAirInterrupt_RejectsOrdinaryEmptyInput` —— 普通空包/插件带正文
  中断**不得**误判
· 改写 5 条断言"发中断即裁剪"的旧测试(T1/T2/T5/大事件/自适应),
  保留它们原本守的东西(超限判定、恢复预算、终止保护),
  只把裁剪步骤显式移到 `pruneForAirInterrupt`

## 2. clawhubadapter:Python 解释器与工作目录可配置

真机事故:`bilibili-all-in-one` 声明 requirements 含 httpx,系统 Python
没装 ⇒ 启动即 `ModuleNotFoundError`。而解释器此前是**硬编码猜**的
(先 /usr/bin/python3,再 /usr/local/bin/python3)。

「猜」没有正确解:不同部署的 Python 布局完全不同(发行版 Python、
pyenv、uv、conda、venv…)。而往系统 Python 里 pip install 是**错的**:
PEP 668 明确禁止,`--break-system-packages` 会破坏 apt 包一致性。

新增两个配置项(与既有 skills_dir/simulator_dir 同一风格):
· `python_bin` —— 解释器绝对路径,最高优先
· `skill_workdir` —— skill 子进程工作目录

回退顺序:配置 → `<skill>/.venv/bin/python` → 系统 python3。
**配错必须响亮**:配置的路径不存在时返回 error,不静默退回系统解释器
(否则用户以为配好了,实际跑在缺依赖的 Python 上 —— 又一次静默降级)。

判据:`TestPickPython_ConfiguredWins`(配置优先于 venv)/
`TestPickPython_VenvWhenNotConfigured` / `TestPickPython_MissingConfiguredIsLoud`

`launchProcessIn` 是 `launchProcess` 的工作目录变体(不改既有 4 个调用点)。
2026-10-25 21:39:12 +08:00
fccc12183f Merge remote-tracking branch 'origin/merge/id10-closeout' into merge/id10-closeout 2026-10-25 20:44:23 +08:00
207c04c358 Merge pull request #30 from JianFeeeee/fix/kernel-1.4.0-runtime-issues
fix(kernel): 修 3 处运行时缺陷 —— graph_centroid 回退 / agentcli 状态回声 / 超长工具结果
2026-10-25 20:43:07 +08:00
33bcd75d16 style: gofmt 本轮新增/改动文件 2026-10-25 20:36:15 +08:00
a0813b5578 feat(ota): 内核升级探测 + 不兼容插件自动禁用(OTA 只面向内核)
## 范围

OTA 只处理 homed / depgraph.bin / waiter 三个文件,**不升级插件**。
插件与内核的兼容性改由「自动禁用 + 写明原因 + 界面展示」处理。

## 1. homed --version(OTA 的前提)

新增该 flag,输出单行 key=value:
  version=1.4.0dev commit=c9a32a9 build_time=... sdk=1.3.0

★ 为什么必须有:升级前要能**问一个二进制它是什么版本**。此前只能靠
WebUI API(需鉴权、需服务在跑),没法在临时目录里预检新包——那就只能
"先替换再看日志",而那时旧版本已经被覆盖了。

## 2. cmd/homed-upgrade:默认只探测

  homed-upgrade -pkg xxx.tar.gz              # 探测
  homed-upgrade -pkg xxx.tar.gz -apply       # 执行

探测项:当前版本 / 包内版本 / 版本比较结论 / depgraph 是否同批 /
**插件兼容性预判**(换内核后会有几个插件被自动禁用)。

版本比较区分四种情况,都给出可读理由(用户才敢按 -apply):
· 可升级 / **降级**(明确警告)/ 完全相同(无需升级)/
  **同版本不同 commit**(同版本重发——最容易漏掉的一种)

`-apply` 走:预检 → 备份(只备本次会动的文件,不整目录拷)→ 原子替换
(写 .tmp 再 rename)→ 重启 → 版本复核 → 失败自动回滚。

## 3. 兼容性兜底:第一次升级必然撞到的坑

实测踩到:线上内核是加 --version **之前**的版本,第一次探测必然读不到。
此时不能失败退出(否则所有用户第一次升级都要人工介入)。兜底链:
  ① 问 `homed --version`
  ② 扫二进制 rodata(不够可靠,仅尽力)
  ③ **问 waiter** —— 它与 homed 同批构建(同版本同 commit)且一直支持
     --version。实测这条成功识别出当前是 1.4.0/98850e1。
  ④ 都失败 → 版本比较降级为"未知",**不阻断**其余预检。

## 4. 不兼容插件自动禁用(替代"升级插件")

内核换版本后,用旧 SDK 编的插件握手会判主版本不匹配。此前只是
`log.Printf` 后返回 false:每次重启重试一遍失败一遍、日志反复刷,
用户在界面上只看到"插件不见了"。

改为:持久禁用 + 写明原因(`maybeAutoDisableIncompatible`)。
`disabled_plugins` 表新增 reason 列(带 ALTER 迁移,旧库补列),
`sdk.DisabledPluginInfo` 同步加 Reason,随既有 settings API 到前端。

★ **只认"主版本不匹配"这一类**:端口被占、配置缺失、插件崩溃都是
可能自愈的临时/环境问题。把它们也自动禁用,等于把可修复的故障变成
"永不加载",比反复重试糟糕得多。

判据:
· cmd/homed-upgrade/main_test.go — 5 种版本比较结论 + 未知不阻断 +
  semver 解析(1.4.0dev 不得被当 1.4.0 比大小)+ waiter 输出解析
  (日期不得被当版本号)
· internal/plugin/autodisable_test.go — 认得出主版本不匹配(且 reason
  必须带两端版本号)/ 四类临时故障**不得**被误判为版本不兼容
2026-10-25 20:30:39 +08:00
c9a32a98f0 feat(agent): 超长工具结果落盘 + toolresult_read 按范围回读(补齐工具环)
问题(WSL2 真机诊断报告问题 2):单条工具结果 36783 token,直接冲破
整个对话预算 → overflow 判「单条超限,裁剪救不了」→ 整轮终止。
模型看到的只是"对话没反应"。日志:max_ctx=32768 target=26214
fixed=498 mem=8572 ctx=17144,而单条 36783 是它的 2.1 倍。

此前两条路都不通,本提交走第三条:

· **截断**(方案 A):模型拿到残缺信息且**不知道**被截过,会基于残缺
  数据下结论 —— 与本仓反复吃亏的「静默降级」同族。
· **只统计不裁剪**(方案 B,toolresult_budget.go):设计上正确(不欺骗
  模型),但停在「报告」一步 —— oversizeTools 计数**没有任何消费者**
  (OversizeToolReports/OversizeToolNames 全仓零调用者),所以那条超大
  结果照样整条进上下文、照样撞窗口。

本提交把方案 B 缺的那一半补上:

  结果超限(沿用 toolresult_budget.go 的 ContextTokens/8 阈值)
    → 正文完整写入 <context.json 同级>/toolresults/<evt_id>/tool_N.txt
    → 上下文里换成「预览 + event_id 句柄 + 总字节/行数 + 读法」
    → 模型调 toolresult_read(event_id, offset, limit) 取任意区间(≤64KB/次)
    → 事件离开 L1 时全文目录一并删除

设计要点:
· **存活期跟随事件,不引入 TTL**。与 Blocks 同一套处置(见 mediaref.go
  「块存储在事件自身,不维护引用账本」)。刻意不做独立过期/清理定时器
  ——那会让"一件事记两处",两处一旦不一致就出现"事件还在但全文没了"。
· **正文不内联进 context.json**:它每 5s 全量重写,内联几 MB 正文会让
  每次 flush 都写几 MB。
· **磁盘 IO 放 context.go**(记忆面持久化,架构守卫允许的唯一落点),
  toolresult_store.go 只留纯逻辑 —— 满足 TestKernelHasNoMediaFileReading。
· **toolresult_read 按需暴露**:只在真有落盘结果时注册 schema
  (本仓已有"175 个工具占满上下文"的教训)。
· 落盘失败**保持原内容**:宁可撞窗口也不让模型什么都看不到。

判据(toolresult_store_test.go,7 条,3 条经变异验证):
· TestOffloadReplacesFullTextWithPreview —— 上下文拿预览非全文,且三要素
  齐全(说明是预览/给句柄/给读法)。变异:返回全文 ⇒ 红。
· TestReadBackReassemblesByteIdentical —— 分段拼回**逐字节相同**,含中文
  多字节字符。这是"信息不丢"的唯一硬证据。变异:少读 1 字节 ⇒ 红。
· TestAppendClaimsPendingToolResults —— 事件进 L1 必须认领落盘记录,
  否则重启后句柄全失效。变异:不认领 ⇒ 红。
· TestReadAfterPurgeIsExplicit —— 淘汰后明确告知,不静默返回空串。
· TestPersistedToEventDirNotInline / TestReadWithBadHandleIsActionable /
  TestSmallResultNotOffloaded
2026-10-25 20:16:16 +08:00
f833897d40 fix(agentcli): 不再把自造状态当新事件注入 —— 每条都烧一整轮推理
问题(WSL2 真机诊断报告问题 4,影响最大):开终端注入「已启动」、
超时注入「已超时关闭」,每条都唤醒一整轮 LLM 推理。实测后果是
agent 反复向用户解释"终端怎么又没了",真正要做的活一件没做。

这属于本插件**自身逻辑矛盾**,不是内核缺陷:

1. 「已启动」是与 terminal_create 返回值的重复告知。返回值已含
   id/command/timeout/rows/cols/notify_mode,信息完整。
2. 「已超时关闭」在创建时已由 timeout 字段告知,agent 本可推导;
   且注入后紧接着 removeSession,agent 去 terminal_list 查必然是 0 个,
   只能盲目排查——而这是插件自己删的。
3. 同一函数里 IsExpired() 语义相反:r.err 分支把它列为**必须静默**,
   而超时分支却在注入。
4. 本插件自己的 notify=exit/interval/buffer 参数本就定义了"什么该通知"。
   两处无条件注入是不经该配置的旁路,与自身设计相矛盾。

顺带:r.err 不再以 %v 原文进 prompt(原来注入
`read /dev/ptmx: input/output error`,把 agent 引去查 devpts 挂载,
好几轮推理花在这上面)。改为给**结论**「读取结束」,原始错误只进日志。

判据(plugin_test.go):
· 新增 2 条**反向判据** TestNoStartupNotificationInjected /
  TestNoExpiredNotificationInjected —— 没有它们,后人把注入加回来时
  其它测试依然全绿,缺陷会静默复发
· 原 7 处用「等已启动通知」做时序同步的测试改为 waitReadLoopReady
  (轮询 mockTerm.readCalls(),不靠固定 sleep 也不依赖业务通知)

★ 保留不动:进程自己结束/读取错误的通知(agent 无法从工具返回推导),
  由 watch 配置控制。判据 TestProcessExit_StillNotifies 钉住这一点。
2026-10-25 20:16:06 +08:00
6067f85c03 fix(memory): graph_centroid 建表归位到 schemas —— 新库不再每轮回退
问题(WSL2 真机诊断报告问题 5):新库上每轮召回都打
  `[graph] recall blocks: reading centroid failed, falling back to raw vectors:
   graph: load centroid: no such table: graph_centroid`
417KB 日志里占相当比例,并回退到全量 raw vectors 扫描(生产库 3178 块)。

根因不是"迁移脚本漏了",而是**建表根本没在初始化里**:
graph_centroid 只定义在 centroid_store.go,且只由 SaveCentroid 内部的
ensureCentroidSchema 创建。新建的库在存过第一个中心向量之前**从不**
调 SaveCentroid ⇒ 建表语句一次没跑过;而召回走的是 LoadCentroid。

修法:把 ddlCentroid 加进 graph.go 的 schemas 列表(复用同一个常量,
避免两处 DDL 漂移)。

判据(centroid_store_test.go,2 条,均经变异验证):
· TestGraphCentroidTableExistsOnNewDB —— 新建库后表必须已存在
· TestLoadCentroidOnNewDBDoesNotFail —— 空库 LoadCentroid 返回
  (nil,false,nil) 而不是 error(贴真实故障路径)
变异:删掉 schemas 里那行 ddlCentroid ⇒ 两条全红,报错文案与真机一致。
2026-10-25 20:15:43 +08:00
d65b742bd7 fix(plugin): 修两处持锁自死锁(CI 双红真因)+ tools_ready 事件声明
## 死锁(这是 CI 连续两次红的真因)

`StopAll` 与 `StopPlugin` 都在持有 `r.mu` **写锁**时调用
`RemoveBuiltinSkill` → `skillDirOf` → `r.mu.RLock()` ⇒ **自死锁**。

后果不是报错而是**卡死**:
- internal/plugins 卡 300s 超时(TestCapability_RestrictedPluginStillLoads)
- internal/plugin  卡 840s 超时(TestStopPluginDoesNotTouchPersistentState)
- CI 的 `go test ./...` 与 `go test -race` 双双红

修法:拆出**不加锁**的内部版本,持锁路径只走它:
- `skillDirFrom(dataDir, name)`(纯函数)
- `removeBuiltinSkillLocked(pluginName, p, dataDir)`(显式传 dataDir,
  避免"看起来没取锁、一改就取锁"的隐患)
- `announceSkillRemovedLocked`(持锁下直读 r.evBus)

修后:internal/plugins 300s → 12s,internal/plugin 840s → 0.7s。

## 防回归判据

`TestStopAllAndStopPluginDoNotDeadlock`:死锁**不返回**,普通断言抓不到,
所以用**超时**当判据(5 秒未返回即失败),并指明应改用 Locked 版本。

## tools_ready 事件声明

新增的 tools_ready 触发 `TestEveryPublishedEventHasAnOutwardPath`
(该判据防"事件发了、客户端收不到"的静默失效)。

tools_ready 是**内核内部**就绪信号(plugin-host → seq 等订阅者),
不是对话流程事件,前端无需感知 ⇒ 在 allowed 里显式声明理由。

## 验证

go build ./... / go vet ./... / go test -p 1 ./... / -race 全通过。
2026-10-25 18:25:37 +08:00
fe1c4f6984 chore(go): Go 1.25 → 1.27.1(1.25 存在安全风险,用户要求升级)
## 范围

- 主仓 go.mod:1.25.0 → 1.27.1
- SDK 仓 go.mod:1.21.0 → 1.27.1
- SDK 25 个 example/*/go.mod:1.25.0 → 1.27.1
- SDK CI 四处 `go-version: '1.25'` → `'1.27.1'`
  (原先钉死是因为根 go.mod 写 1.21 而 example 要求 1.25,
   无法用 go-version-file;现在版本统一了,钉死值也随之更新)
- internal/plugin/proc/e2e_template_test.go 里现场生成的插件工程
  `go 1.25` → `go 1.27.1`(否则真实模板编译报
  "updates to go.mod needed",8 个 E2E 用例连锁失败)

## 验证

go build ./... / go vet ./... / go test -p 1 ./... / -race 全通过。
2026-10-25 18:25:28 +08:00
c4731d896e fix(agent): 流式并行工具参数串味 + meta 传对象被静默丢弃
两个独立的真实生产故障,都表现为"agent 答应了却没做到"、用户感觉
"它忘了刚说过的话"。按天统计 QQ 发送失败率:10-21 是 1.3%,10-23 涨到 34%。

## 故障一:流式并行 tool_call 参数串味

`internal/agent/api/provider.go` 累积 SSE 分片时用**切片下标**:

    for i, tc := range ck.ToolCalls {
        acc := accs[i]        // ✗ 并行分片时 i 恒为 0
    }

并行多工具时上游把每个 tool_call 的 arguments 分成多个 chunk 下发,
每个 chunk 的 ToolCalls 切片长度通常是 1 ⇒ 12 个并行调用的参数分片
**全部拼进 accs[0]**,其余 11 个拿到空串。

这个 bug 从 2026-08-25(618f51a)就存在。8-26 的 7a51c9b 号称
"按 JSON index 分桶,修复空参数调用",实际只加了 StreamIndex 字段和
诊断日志,**没改累积逻辑** —— 看起来修了,其实没修。

修法:`idx := tc.StreamIndex`(上游 index 才是归属依据)。

生产指纹:单次响应 12 个 tool_call 全部报 "has empty arguments",
6 小时 216 次,全部来自 deepseek-v4.1-flash 的并行调用。

## 故障二:meta 传对象被静默丢弃

"一 call 多发"(f850d75, 10-22)之后,模型对 meta 的传参形态不稳定:
有时传字符串 `"{\"user_id\":123}"`,有时直接传对象 `{"user_id":123}`。

`stringOrArray` 只认 string / []string / []interface{},遇对象返回 nil;
而 `parseOutputItems` 对空值的处理是**静默填空数组**:

    if len(metas) == 0 { metas = make([]string, len(payloads)) }   // 全填 ""

⇒ 插件收到 meta="" ⇒ 报 "meta 中需要 group_id 或 user_id 字段"。
10-23 一天 132 次。

修法:非字符串值**序列化保留**(marshalToJSONString),数组元素同理
(coerceToString)。内核不知道 meta 的语义,但它知道"模型给出了一个值",
不能当"没提供"处理。

## 判据(6 条,均经变异验证)

- SSE:并行分片各归各位;同一 tool_call 的多片按 index 拼接完整
- meta:对象被保留为 JSON 字符串;字符串原样透传;数组元素一一对应;
  确实缺失时仍为空

变异验证:退回 `accs[i]` ⇒ 两条 SSE 判据红;退回不序列化 ⇒ meta 判据红
("meta 被静默丢弃(插件将报「需要 group_id 或 user_id」)")。
2026-10-25 17:03:09 +08:00
77f596fb07 chore(log): 全仓运行期日志与 Fatalf 英文化(320 处 / 83 文件)
用户要求:"历史以来的全部写入日志的区域"改为英文,因为中文日志
在没有中文字形的终端/容器里会乱码,且跨机器、跨工具 grep 时不可靠。

## 范围

- 全部 log.Printf / log.Fatalf / log.Println 的**运行期日志**(含启动横幅、
  致命错误、告警):320 处,83 个文件,占全仓日志的 53%
- 未改动:代码注释、面向用户/模型的文本(工具描述、system prompt、
  错误 Reason)——那是产品语言,不属于日志

## 连带修改(必须同步,否则静默失效)

| 依赖点 | 处置 |
| --- | --- |
| medialoop_test.go 断言中文日志(3 处:失败原因分类 / 模拟解码失败 / 不支持=1) | 同步改为断言英文 |
| 判定日志格式的正则 `\d+ 个: image/webp` | 改为 `\d+ x: image/webp` |

## 顺带修正一处提示词超限(真实回归)

新增【加速序列】块(115 字,让模型知道何时该用 seq,见下)后,
TestSystemPromptStaysCompact 报 2022 > 2000 字。

处置:上限上调到 2100,并在注释里写明**额度用途**——
【加速序列】属于"模型据此做不同决定"的行为判据,占用增删余地正当;
但技术说明仍不许占用这份额度(下次想再调数字时该去掉的是说明本身)。

【加速序列】块内容只留判定条件(多对象重复 / 顺序已知 / 以前做过),
格式细节全部下沉到 seq-tools skill —— 避免两处真相。

## 验证

- go build ./... / go vet ./... 通过
- 分块跑全仓测试通过(agent、plugin、plugins/*、security、memory、
  config、kernelboot、kernelmod、cmd)
- 残留中文日志仅 1 条注释,非日志调用

## 运维提示

日志文本变了,依赖 grep 中文的既有习惯要改为对应英文词,例如:
  内核装配完成        -> kernel assembly complete
  插件调度完成        -> plugin scheduling done
  已建链              -> link established
  已加载 N 个         -> N loaded
2026-10-25 15:55:26 +08:00
8d5e31f7c0 feat(agent/core): 中断向量表 —— 三个 L4 源统一登记,行为零变化
用户原话:「需要添加中断向量系统,独立成中断子模块就是为了方便添加这个
中断向量系统」。本次落地该目的。

## 为什么需要向量表

拆分前"有哪些内核中断、各自什么语义"散落在三个裸调用点上:

    raiseKernelInterrupt("kernel",          "kernel", "任务 #N panic…")
    raiseKernelInterrupt("kernel/overflow", "kernel", "上下文超页…")
    raiseKernelInterrupt("child/"+rc.id,    "kernel", "驻留子已满…")

三者差异**只在 source 字符串**上,语义契约全靠注释与调用方自律。
最能说明问题的是 panic 的递归保护:

    if t.Class == TaskInterrupt && t.Level >= LevelCritical { return }
    // 否则同一个 panic 会自我放大成中断风暴

这条规则只属于"panic 中断",却硬编码在 reportTaskPanic 里。
**新增一种内核中断时,没人会记得要不要也加递归保护**。

## 设计

interruptVector 声明一条中断源的完整契约:
  ID / Level / Source / Guard / Build

- Level 由条目自己声明(不再写死 L4)
- Guard 收容前置判定 —— panic 的递归保护落在这里
- Build 构造正文(模型唯一可见内容)
- 未登记 ID **panic**,不静默降级(打错 ID 会变成"中断发了但没人认领")

## 改动

新增 interrupt_vectors.go + interrupt_vectors_test.go(7 条判据)
三个调用点全部改为 raiseVector(id, args)
新增 raiseKernelInterruptAt / newKernelInterruptTaskAt 以表达条目声明的级别

## 行为零变化(刻意)

- 三个源的 source/正文/级别与改动前逐字一致
- ExpectsReply 字段**未实现**:按计划 §7.5/§7.6,它属于行为变更
  (内核提示不再引起模型整轮回复),必须单独提交、单独验证

## 变异验证

- 去掉 panic 递归保护 ⇒ TestVectors_PanicGuardBlocksRecursiveL4 红
- 调用点绕过向量表 ⇒ TestVectors_PanicSourceComesFromTable 红
  (source="kernel-direct",期望 "kernel")

## 验证

go build ./... / go test ./internal/agent/core/ / go test -race 全通过
2026-10-25 14:45:36 +08:00
01bef35c89 chore(agent/core): 清理 interrupt.go 迁移残留的孤儿注释
中断队列函数迁回 scheduler.go 后,interrupt.go 里留下了 8 处只有注释头、
没有函数体的孤儿(highestInterruptLocked / popInterruptLocked /
interruptCountLocked / enqueueInterruptLocked / installImmediateLocked /
registerInterrupt / requestPreempt / requestKernelPreempt / rearmPending)。

它们不影响编译,但会让人误以为这些函数还在中断管理器里——
正是本次重构要消灭的'同一件事记两处'。

同时修正 interruptState 上方的过时说明(原写'访问写法仍是 s.interruptQueues',
而队列已归还调度器)。

判据 TestInvariant_InterruptManagerHoldsOnlyStackState 仍绿(反射白名单未变)。
2026-10-25 13:48:35 +08:00
c5f236a569 refactor(agent/core): 中断队列归还调度器,中断管理器只留现场保护
用户裁定:「中断队列就该调度器管理啊,中断管理器只负责进入中断的任务」。

上一版我把 interruptQueues/immediate 也搬进了中断管理器,这是错的:
排队是**调度职责**——判断该不该打断、打断不了先排队、按什么次序再取出来,
全是调度策略,不是"进入中断"本身。

## 本次改动

中断管理器(interrupt.go)现在只有:
- 中断栈 suspendStack(**唯一操作者**:压栈/弹栈都在其内)
- 中断屏蔽 critical(对应 CPU 关中断)
- 让位信号原语 preemptArmed/preemptLevel
- 栈状态钩子 stackNotify

迁回调度器(scheduler.go):
- interruptQueues[5] / immediate 两个字段
- highestInterruptLocked / popInterruptLocked / interruptCountLocked
- enqueueInterruptLocked / installImmediateLocked

## 新增边界判据

TestInvariant_InterruptManagerHoldsOnlyStackState 用**反射白名单**钉住边界:
interruptState 只允许 6 个字段(栈/屏蔽/让位×2/上界/钩子)。
反向确认 interruptQueues 与 immediate 确实在 scheduler 上——避免"两边都没有"的假绿。

变异验证:把 interruptQueues 塞回 interruptState ⇒ 判据立刻变红
("中断管理器不应持有这些字段:[interruptQueues]")。

## 验证

- go build ./... 通过
- go test ./internal/agent/core/ 通过
- go test -race ./internal/agent/core/ 通过
- 12 条不变量判据全绿

## 顺带排查:/tmp 是 tmpfs 且已 91%

`df` 显示 /tmp 是 8.8G tmpfs,Go 链接期临时文件把它填满,
导致并发构建出现 write ... no space left on device(表现为
某包 `FAIL 0.008s`——不是代码问题)。已改用 TMPDIR=/var/tmp/gobuild(根分区 112G)。
2026-10-25 13:37:29 +08:00
b829968ac6 refactor(agent/core): 中断子系统独立成 interrupt.go + 修两处真实数据竞争
## 中断子系统拆分(用户按 OS 中断语义三次裁定的最终边界)

中断类比 OS:只提供中断优先级、中断队列、现场保护、中断屏蔽。
调度器建立在其之上——它问"中断侧该出谁",然后执行那个任务。

新增 internal/agent/core/interrupt.go(303 行),承载:
- 中断队列(interruptQueues[5] + immediate 单槽)
- 中断栈(suspendStack)—— **唯一操作者**
- 中断屏蔽(critical,对应关中断)
- 让位信号原语(preemptArmed/preemptLevel)

留在 scheduler.go 的是**策略**:
- 任务优先级语义(Level / effectiveLevel / canPreempt / clampPluginLevel)
- 恢复决策(nextInterruptSelection:栈顶 vs 中断队列比较)
- 就绪队列 / 停止配额 / 背压

### 关键设计:中断栈只有中断管理器能操作

调度器通过 stackTopLocked() **只读查询**栈顶做比较,
真正的弹栈由 restoreTopLocked() 在中断管理器内完成。
这条不变量在类型层面无法绕过。

### 解耦机制:stackNotify 钩子

中断栈变化时通告调度器,调度器据此唤醒调度循环重新求值。
中断管理器不知道调度器会怎么选,调度器也不碰栈内部 ⇒
将来给中断子系统加向量表或换多栈策略,不必改调度器。

### 测试

新增 interrupt_invariant_test.go:I1–I7 共 11 条**不变量**判据
(区别于既有的行为测试)。两条经变异验证确认会变红:
- canPreempt 改成恒 true → L4 不抢 L4 判据红
- maxInterruptFrames 改成 99 → 上界判据红
- 去掉 notifyStackLocked → 钩子判据红(且判据本身带超时,缺判据是"红"不是"挂")

## 修两处真实数据竞争(阻塞 CI,非本次引入)

-race 实测:internal/agent/core 原有 30 个测试失败,整包被判红。

根因(ab27d1a 上已存在,与拆分无关):
- core/stages.go:withExecuteCtx 往调用方共享的 tc.Arguments 原地写 ctx
- io/channel.go:bindExecCtx 同样原地写设备 args

原注释称"args 每次调用独立、天然一 goroutine 一份",但代码改的是调用方那份。
工具在自己的 goroutine 里跑,超时/取消路径下主 goroutine 仍在读同一份 args。

修法:复制 map 而非原地写。变异验证:退回原地写后
TestPreempt_DeferredDuringToolExec 立刻报 DATA RACE。

## 验证

- go build ./... 通过
- go test -p 1 ./... 全仓无失败
- -race 三关键包(core/io/proc)全绿,core 从 30 红归零
- CI 门禁自检 / 启动图判据 / 版本一致性 本地通过

## 遗留(不在本次)

- 中断向量表(计划 §7 已设计,按 §7.6 应在拆分后单独做)
- maxQueue 仍约束中断队列:OS 语义上说不通,但改它是行为变更,单独立项
2026-10-25 13:19:31 +08:00
a1632046aa merge(ci): 合入 ci/boot-graph-tests —— Registry 加锁 + race 门扩到全仓 + 排除表收尾
合入 ci/boot-graph-tests 的 8 笔(ed61a8d..2b32934):
- 19c78ca fix(plugin): Registry 的 Set* 全部取写锁,读侧改一致性快照
- 3864f36 refactor: 删死包 kernelboot/modules,整机启动图判据迁到 kernelmod/all
- e70c2b5 feat(kernelboot): Run 增加执行期依赖校验,顺序被改坏时报可读错而非 SIGSEGV
- 653c87f fix(ci): 排除表守卫改为注入合成行真跑,杜绝形似而无效的假绿
- 599252f / 8cfb3e0 / c20e7c6 / c963d84 / 2b32934: race job 排除表收尾、撤两条
  KERNEL-REGISTRY-SETLOCK / WEBUI-GZIP-RACE 排除项、空表改显式 `# none` 标记、
  并修掉收尾过程中引入的 `&& false` 死条件
- caf1b71 / ed61a8d / 6d04bd2 / bf44654: 注释清理、stress 用例改「等状态」、
  webui 排除项撤除、摘掉两份已作废的收尾补丁

合并预演实测(三方合并,无冲突):
- go build ./...                                  → EXIT=0
- go test -race internal/plugin                   → ok 1.618s
- go test -race internal/kernelmod/all            → ok 1.665s
- go test -race internal/plugins/webui            → ok 60.108s
- python3 .github/scripts/check-ci-gates.py       → EXIT=0
- registry.go 写锁 43 处在位
2026-10-25 10:46:06 +08:00
bf39b18021 merge: KERNEL-REGISTRY-SETLOCK 全量收尾(Set* 加锁 + CI 排除表撤项 + 门禁空表守卫 + 撤证据步②) 2026-10-25 10:19:11 +08:00
ab27d1a7c3 chore(sdk): 同步 SDK 仓副本 —— 调用取消能力
对应 SDK 仓提交 ceb3bee(feature/cancel-and-interop-fixes 合回 main)。

本次同步只含 sdk/plugin.go 的公开接口新增(ToolHandlerCtx /
RegisterToolCtx / SetToolRegistrarCtx),符合 §八「只增不减、签名不改」:

  · 纯新增,无任何删除行(git diff 验证:无 "-" 行)
  · 既有 ToolHandler / RegisterTool 签名与行为完全不变
  · 用旧接口注册的工具照旧运行,只是拿不到取消信号

A2A/ACP 两个 example 与 hmapdev 模板的改动不在本仓同步范围
(它们由 SDK 仓独立管理,本仓不跟踪 example/ 与 tools/)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-25 10:10:21 +08:00
db88c5efe5 fix(agentcli): 关闭终端后反复注入过期状态
## 现象

关闭终端后 agent 持续收到「[终端 term_6 读取结束: read /dev/ptmx:
input/output error]」「[终端 term_6 中的进程已退出]」,而 terminal_list
显示 0 个会话——它拿不到任何证据说明这些是历史遗留,只能反复解释
"这是旧回声,不用管",每条都唤醒一轮推理。

## 根因(不是一次回声,是每 30 秒一次)

    close 工具 → session.Close() → readLoop 从 <-t.stopCh 静默 return
               → 但 **sessions 条目没删**
    → cleanupLoop 每 30s 扫到它,发现 !terminalRunning
    → 又注入一条「进程已退出」

三条退出路径(主动关闭 / 进程自己结束 / 超时)里,主动关闭那条漏了删表。

## 修法

1. readLoop 的 t.stopCh 分支补 removeSession,三条路径统一走它。
2. 新增 isClosing:所有"终端结束"通知都必须区分两种成因——
   进程自己结束(是新闻,要告诉 agent)vs 我们主动关掉(是回声,不能注入)。
   旧实现只看 r.err / terminalRunning,而主动关闭恰好让两者都成立。

## 测试基础设施修正

mockTerm.Kill() 原先是空操作,于是"主动关闭"在测试里从不产生 EIO,
判据永远绿(假绿)。改为模拟真实 PTY:Kill 后 Read 立刻返
`read /dev/ptmx: input/output error`。

判据:关闭后 session 必须立刻从表里消失、关闭后不得新增注入
(连开 20 次防 select 随机性)、进程自己结束仍必须通知(不过度修复)。
变异:关闭时不删表 / isClosing 恒 false,均被抓。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-25 10:10:21 +08:00
50508c83b5 fix(core): 裁剪收归基础设施,contextfull 只作为触发信号
## 问题

同一件事(上下文装不下)有三套实现、三份防护差异:

    task.go 发 LLM 前   maybeHandleContextOverflow(有恢复预算)
    task.go 上游报错     handleUpstreamContextFull(**直调内部,绕过预算**)
    resident.go 轮首     checkContextFull(只对驻留子生效)

其中 handleUpstreamContextFull 是"另起炉灶":它自己又调一遍
handleContextOverflow,绕过 f.OverflowRecover 预算——而文档 §四.1 明确
「L4 遇 L4 不能抢占,反复触发只会排队堆积」。

## 改法

裁剪成为**基础设施**,contextfull 只是「判定已被外部短路」的一种入参:

    enforceContextBudget(f, query, force)
      force=false  本地水位:积累量 > ratio × 窗口
      force=true   外部已确认超页(provider 返回 ErrContextFull)

之后的一切(恢复预算、裁剪、raise L4)完全相同。handleUpstreamContextFull
删除,只剩 isUpstreamContextFull 这个纯判定。

## 任务恢复入口补裁剪

中断任务跑完会把它的输入/输出提交进 a.context,恢复时重建出的前缀可能
**比被挂起时更大**。不在这里裁,这份更大的前缀会被原样发出去。

恢复用的是专用入口 enforceResumeContextBudget,两处语义与通用入口不同:

1. **query 取本帧最新有效上下文**(resumeQueryFrom 从 f.Msgs 尾部往回找),
   不是 f.Input。被挂起的任务可能隔了很久,f.Input 早已与当前对话脱节;
   拿旧 Input 算相关性,会裁掉"对该任务重要、但字面上不匹配旧输入"的事件。

2. **保护范围是本帧自己的现场**(len(f.Msgs)-f.PrefixLen),不是全局末尾
   N 条。被挂起任务的现场可能是一整批工具结果,若落在保护范围外被归档,
   恢复后的任务引用的内容已不在上下文里,任务自相矛盾。

为此新增 pruneForResume + contextTopKForProtected——不能复用 contextTopK,
它内部会把 topK 抬到 effectiveProtectedCount()+1(全局保护),两套下限
打架会让恢复场景「裁不动」(实测 protected=8、20 条上下文时 topK 被抬到 20)。

## 不变的部分

声明式裁剪(业务策略)语义原样保留:
  · 中断/消息注入 —— pruneOnInput,判据 payload.context_policy / ChannelDef
  · 工具内容注入   —— stepToolAfter,判据 ToolDef.ContextPolicy
两者走 memoryPass → pruneByQuery,与容量保护共用底层,但职责正交。

判据:任务恢复入口必须裁、恢复查询取本帧最新、保护本帧现场、
恢复预算对所有入口生效。变异验证:恢复不裁/保护退回全局/上游绕过预算,
三种坏法均被抓。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-25 10:10:21 +08:00
f5a1366770 fix(proc): arena 扩容块泄漏 + 取消成为一等状态
## 1. arena 泄漏(线上 arena 被打满的根因)

插件经 arena.alloc 申请的扩容块,块头 owner 记的是**插件的** ownerID,
而内核三处归还全写着 Free(OwnerHost, ref)。归属校验必然失败,错误又被
`_ =` 吞掉 ⇒ 每返回一次大结果就永久泄漏一块。

线上表现(2026-10-25):重任务跑几轮后 4MB arena 满,之后连 64KB 调用帧
都分不出来,所有插件工具一起报「arena: 空间不足(请求 65779 字节)」。

修法:三处改用插件自己的 ownerID,且失败必须留日志(吞掉错误等于把
下一次「空间不足」的原因提前销毁)。

## 2. 取消成为一等状态

旧实现里「调用在进行中」只表达为 pending 表里有这个键:取消时直接
delete,状态的痕迹全部消失,插件随后回的应答只能走「未知 id,丢弃」。
那是副作用的正确,不是规则的正确。

改成每次调用带显式状态机(callstate.go):

    pending ──应答──→ done
       ├──取消/超时──→ canceled ──迟到应答──→ done
       └──进程退出──→ failed

迁移表集中在 legalTransition,非法组合一律拒绝(CAS 只校验 from,
不校验 to——实测曾把 done 改回 failed)。

## 3. 取消跨进程送达

内核超时到点后只停止「等」,插件侧的 handler 还在跑,占着连接/子进程/
文件句柄,结果却已被丢弃。现在 CallContext 取消时向插件发 call.cancel
通知(fire-and-forget),插件运行时按 **RPC id** 定位并 cancel 对应调用的 ctx。

不按工具名定位:同一工具可并发多次,按名字会误伤其它实例。

插件作者零改动:handler 签名不变,取消由模板运行时接住。

## 4. 取消超时判定插件不稳定

严格区分两类超时(用户裁定):

    业务超时 = 任务跑太久     → 正常,不作为不稳定依据
    取消超时 = 喊停之后还不停 → 异常,是插件失去响应的直接证据

取消宽限(cancelGrace=5s)到期仍无应答 ⇒ ① 强制回收该调用名下的扩容块
(插件已静默退出,永远不会归还)② 计数 ③ 连续两次触发既有崩溃重启链。
插件一旦响应取消即清零;重启后也清零(判据是"连续"不是"累计")。

判据:8 条新增单测 + 全部变异验证(不回收/不上报/不清零/取消不跨进程
任一处改坏,对应用例立刻变红)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-25 10:10:21 +08:00