mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
Merge pull request #29 from JianFeeeee/merge/id10-closeout
fix(plugin)+ci: KERNEL-REGISTRY-SETLOCK 全量收尾(Set* 加锁 / 撤 race 排除项 / 门禁空表守卫 / 撤证据步②)
This commit is contained in:
@ -182,6 +182,11 @@ type inputPayload struct {
|
||||
mediaType string
|
||||
// captureTool 是媒体落进 CAS 时记录的来源标签。
|
||||
captureTool string
|
||||
// airInterrupt 表示这是一次**空中断**:内核发的 L4,没有正文。
|
||||
//
|
||||
// 它的唯一用途是触发一次语义相关性裁剪(contextoverflow 的原始设计)。
|
||||
// 空正文的普通输入不是空中断(见 isAirInterrupt)。
|
||||
airInterrupt bool
|
||||
}
|
||||
|
||||
// resolveInput 把 InputEvent 归一成 inputPayload。
|
||||
@ -209,6 +214,19 @@ func (a *Agent) resolveInput(evt *agentIO.InputEvent) (inputPayload, bool) {
|
||||
|
||||
text, _ := evt.Payload["content"].(string)
|
||||
blocks, mediaType := injectedBlocks(evt.Payload)
|
||||
|
||||
// ★ 空中断(kernel/overflow):**没有正文也要算有效输入**。
|
||||
//
|
||||
// 这是 contextoverflow 的原始设计:L4 中断本身不携带任何消息,
|
||||
// 它唯一的用途是"把当前任务抢占下来,让语义相关性裁剪发生"。
|
||||
// 空 content 在这里曾被判成无效输入直接丢弃 —— 于是那个 L4 变成了
|
||||
// 一条什么也不做的空转任务。
|
||||
//
|
||||
// 判定条件刻意窄:必须是内核发出的中断,且确实无正文、无媒体。
|
||||
// 普通输入的空内容仍然算无效(否则客户端空包会白白唤醒一轮)。
|
||||
if text == "" && len(blocks) == 0 && isAirInterrupt(evt) {
|
||||
return inputPayload{text: "", airInterrupt: true}, true
|
||||
}
|
||||
// 文本与媒体都空才算无效输入:只带图不带字是合法的(插件注入常这样)。
|
||||
if text == "" && len(blocks) == 0 {
|
||||
return inputPayload{}, false
|
||||
@ -558,3 +576,23 @@ func (a *Agent) cleanInputFor(evt *agentIO.InputEvent, input string) string {
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// isAirInterrupt 判定一个输入事件是否为「空中断」。
|
||||
//
|
||||
// 空中断 = 内核发起、type=interrupt、payload 里没有 content。
|
||||
// 用途见 contextoverflow 的中断向量(VectorContextOverflow):
|
||||
// 它只是一次调度信号,用来把当前任务抢占下来,好在任务边界做语义相关性裁剪。
|
||||
//
|
||||
// 为什么必须由内核来源限定:插件也能注入 interrupt,但插件的中断**总是**
|
||||
// 带正文("某设备上线了""某任务失败了")。把它们的空包也当空中断,
|
||||
// 会让"插件发了个没内容的中断"变成一次无声的裁剪,行为不可预测。
|
||||
func isAirInterrupt(evt *agentIO.InputEvent) bool {
|
||||
if evt == nil || evt.Type != "interrupt" {
|
||||
return false
|
||||
}
|
||||
if v, _ := evt.Payload["kernel"].(bool); !v {
|
||||
return false
|
||||
}
|
||||
text, _ := evt.Payload["content"].(string)
|
||||
return text == ""
|
||||
}
|
||||
|
||||
@ -57,8 +57,6 @@ type vectorArgs struct {
|
||||
Task *Task
|
||||
// Panic 是 recover() 取到的值(VectorTaskPanic 使用)。
|
||||
Panic any
|
||||
// Pruned 是超页时被裁剪的消息条数(VectorContextOverflow 使用)。
|
||||
Pruned int
|
||||
// ChildID 是驻留子标识(VectorChildContextFull 使用)。
|
||||
ChildID string
|
||||
}
|
||||
@ -113,7 +111,19 @@ var interruptVectors = map[VectorID]interruptVector{
|
||||
Level: LevelCritical,
|
||||
Source: func(vectorArgs) string { return "kernel/overflow" },
|
||||
Guard: func(vectorArgs) bool { return true },
|
||||
Build: func(a vectorArgs) string { return overflowNotice(a.Pruned) },
|
||||
// ★ 空中断:**不携带任何消息**(2026-10-25 按原始设计修正)。
|
||||
//
|
||||
// 原始设计里这个 L4 的用途是「**触发**语义相关性裁剪」——它本身
|
||||
// 只是一次调度信号,不是一条给模型的通知。此前的实现把它变成了
|
||||
// 「裁剪完事后发一条回执」(先在 raise 之前 pruneByQuery,再让
|
||||
// Build 生成「已裁剪 N 条…」的说明文本),顺序与语义都反了。
|
||||
//
|
||||
// 现在:Build 返回空串 ⇒ payload 里没有 content ⇒ 任务是空中断;
|
||||
// 裁剪由任务真正被调度执行时(rebalanceFrameContextPrefix)触发。
|
||||
//
|
||||
// 为什么必须带空串而不是省略 Build:lookupVector 的契约要求 Build
|
||||
// 存在(否则调用方 nil panic),"没有正文"要显式表达成 ""。
|
||||
Build: func(vectorArgs) string { return "" },
|
||||
},
|
||||
|
||||
VectorChildContextFull: {
|
||||
|
||||
@ -129,19 +129,33 @@ func TestVectors_PanicSourceComesFromTable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 超页中断的正文必须来自向量表的 Build(含"内容被移走"这一关键语义)。
|
||||
func TestVectors_OverflowBodyComesFromTable(t *testing.T) {
|
||||
v, _ := lookupVector(VectorContextOverflow)
|
||||
// ★ 超页中断必须是**空中断**:不携带任何正文(2026-10-25 按原始设计修正)。
|
||||
//
|
||||
// 这条判据推翻的是更早的一条(原本断言正文非空、含"已裁剪 N 条"、
|
||||
// 含"查不到不等于不存在")。那个写法把 L4 变成了"裁剪已完成"的回执:
|
||||
// 裁剪在 raise 之前就做完了,中断只剩下通知作用。
|
||||
//
|
||||
// 原始设计:L4 空中断把任务抢占下来 ⇒ 任务边界处做语义相关性裁剪。
|
||||
// 中断是**触发源**,裁剪是它引发的动作。带正文会让模型误以为那是
|
||||
// 一条需要回应的用户消息(进而多跑一整轮推理)。
|
||||
//
|
||||
// 变异验证:把 Build 改回返回非空文本 ⇒ 本判据必须变红。
|
||||
func TestVectors_OverflowIsAirInterrupt(t *testing.T) {
|
||||
v, ok := lookupVector(VectorContextOverflow)
|
||||
if !ok {
|
||||
t.Fatal("超页向量必须已登记")
|
||||
}
|
||||
if v.Build == nil {
|
||||
t.Fatal("超页向量必须有 Build")
|
||||
t.Fatal("超页向量的 Build 不能为 nil(lookupVector 契约要求存在)")
|
||||
}
|
||||
body := v.Build(vectorArgs{Pruned: 7})
|
||||
if strings.TrimSpace(body) == "" {
|
||||
t.Fatal("超页中断正文不能为空(它是模型唯一的认知输入)")
|
||||
body := v.Build(vectorArgs{})
|
||||
if body != "" {
|
||||
t.Fatalf("超页中断必须是空中断(正文为空),实际:%q\n"+
|
||||
"带正文会让它变成一条要模型回应的消息,进而多烧一整轮推理", body)
|
||||
}
|
||||
// 语义要求:必须让模型知道"有内容被移走了",否则它会把"查不到"推断成"不存在"。
|
||||
if !strings.Contains(body, "7") {
|
||||
t.Fatalf("超页正文应包含被裁剪的条数 7,实际:%q", body)
|
||||
// 级别仍必须是 L4:抢占能力来自级别,与"有没有正文"无关。
|
||||
if v.Level != LevelCritical {
|
||||
t.Fatalf("超页中断必须是 L4,实际 level=%v", v.Level)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -292,32 +292,20 @@ func (a *Agent) handleContextOverflow(query string, ratio float64) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// ★ 只发空中断,**不在这里裁剪**(2026-10-25 按原始设计修正)。
|
||||
//
|
||||
// 原设计:L4 空中断把当前任务抢占下来 ⇒ 任务边界处做语义相关性裁剪。
|
||||
// 此前这里先 pruneByQuery 再 raise,把中断变成了"裁剪已完成"的回执,
|
||||
// 顺序反了(见 raiseContextOverflow 里记录的偏移经过)。
|
||||
//
|
||||
// 这里返回 0 是如实的:本轮**还没有**裁剪,裁剪会在空中断任务被
|
||||
// 调度执行时发生(pruneForAirInterrupt)。调用方若要判断"有没有腾出
|
||||
// 空间",必须看下一轮的水位,而不是这个返回值。
|
||||
before := a.context.Len()
|
||||
beforeTokens := a.accumulatedTokens()
|
||||
// pruned 是**写进 docStore 的条数**(Prune 内部计数),不是「少了几条」。
|
||||
// 判据必须看上下文是否真的变小了 —— 因为 docStore == nil 时(轻量内核、
|
||||
// 文档记忆未初始化)pruned 恒为 0,但裁剪其实照常发生了。用 pruned 判会
|
||||
// 把正常裁剪误判成 bug 而终止。
|
||||
pruned := a.pruneByQuery(query)
|
||||
after := a.context.Len()
|
||||
afterTokens := a.accumulatedTokens()
|
||||
|
||||
if after >= before || afterTokens >= beforeTokens {
|
||||
// 超页了却裁不动 ⇒ 判定逻辑有问题(或 topK ≥ 实际条数)。
|
||||
// 按用户口径:保存现场 + 明确终止,**不重试不触发中断**。
|
||||
// 为什么不再试一次:L4 遇 L4 不能抢占(canPreempt 用严格大于),
|
||||
// 第二个 L4 只会排队等,永远等不到能执行的时机。
|
||||
log.Printf("[agent] context overflow aborted: ratio=%.2f no reduction "+
|
||||
"(%d→%d events, %d→%d tokens) — 判定逻辑可能有 bug,保存现场并终止本轮,不重试",
|
||||
ratio, before, after, beforeTokens, afterTokens)
|
||||
a.markOverflowAborted(ratio, before)
|
||||
return 0
|
||||
}
|
||||
pruned = before - after
|
||||
log.Printf("[agent] context overflow: ratio=%.2f pruned=%d (%d→%d events), raising L4",
|
||||
ratio, pruned, before, after)
|
||||
a.raiseContextOverflow(pruned)
|
||||
return pruned
|
||||
log.Printf("[agent] context overflow: ratio=%.2f events=%d — raising L4 air interrupt (prune happens at task boundary)",
|
||||
ratio, before)
|
||||
a.raiseContextOverflow(0)
|
||||
return 0
|
||||
}
|
||||
|
||||
// markOverflowAborted 记录一次「超页但裁不出东西」的终止,供状态面观测。
|
||||
@ -329,35 +317,37 @@ func (a *Agent) markOverflowAborted(ratio float64, events int) {
|
||||
a.overflowStat.Unlock()
|
||||
}
|
||||
|
||||
// raiseContextOverflow 通过 **L4 唯一入口**上报上下文超页。
|
||||
// raiseContextOverflow 通过 **L4 唯一入口**发起一次上下文超页中断。
|
||||
//
|
||||
// 与 panic / selfip 并列,是 L4 的第三个来源。
|
||||
// 与 panic / child_contextfull 并列,是 L4 的第三个来源。
|
||||
//
|
||||
// 消息内容不是通知而是**必要的认知输入**:L4 任务按调度器设计拿不到被打断
|
||||
// 者的上下文(suspend 的 D1=B 注释明确「不把被打断任务的任何内容交给它」),
|
||||
// 所以「已裁剪了什么、查不到不等于不存在」必须显式写进去。
|
||||
// ★ **它是一次空中断:不带任何消息**(2026-10-25 按原始设计修正)。
|
||||
//
|
||||
// 这条提示直接对应 v4 实测的一类失败:HA 对查不到的事实给出
|
||||
// 「库里根本没有 X,任何数字都是编的」——它不知道有内容被移走,
|
||||
// 于是把「没检索到」推断成「不存在」。
|
||||
// 用途是把当前任务抢占下来,好在任务边界做一次**语义相关性裁剪**——
|
||||
// 中断是触发源,裁剪是它引发的动作,两者不是同一步。
|
||||
//
|
||||
// ## 此前为什么被写成了"带文本的通知"(记录一下这个偏移,避免再犯)
|
||||
//
|
||||
// 当时的推理是:L4 任务按调度器设计拿不到被打断者的上下文(suspend 的
|
||||
// D1=B),所以"已裁剪了什么、查不到不等于不存在"必须显式写进 payload,
|
||||
// 否则模型会把「没检索到」推断成「不存在」(v4 实测过这类回答)。
|
||||
//
|
||||
// 那个**观察**是真的,但**结论**错了:它把"补偿 L4 看不到上下文"当成了
|
||||
// 中断该带正文的理由,于是裁剪被挪到了 raise 之前(先裁完再发一条回执),
|
||||
// 中断本身退化成一个通知任务——既晚了一步,又给模型塞了它不需要的说明。
|
||||
//
|
||||
// 正确的分工是:中断只负责抢占(空),裁剪由任务真正执行时做
|
||||
// (见 pruneForAirInterrupt)。模型不需要知道"有内容被移走"这件事本身,
|
||||
// 它需要的是**保留下来的内容与当前问题相关**——那由裁剪质量保证。
|
||||
func (a *Agent) raiseContextOverflow(pruned int) {
|
||||
a.overflowStat.Lock()
|
||||
a.overflowStat.Triggered++
|
||||
a.overflowStat.LastPruned = pruned
|
||||
a.overflowStat.Unlock()
|
||||
|
||||
a.raiseVector(VectorContextOverflow, vectorArgs{Pruned: pruned})
|
||||
}
|
||||
|
||||
// overflowNotice 构造超页中断消息。
|
||||
//
|
||||
// 抽成纯函数是为了可测:这条消息的措辞不是装饰,它是 agent 唯一的认知输入
|
||||
// (L4 任务按调度器设计拿不到被打断者的上下文)。「查不到不等于不存在」这句
|
||||
// 直接对应 v4 实测的一类真实失败 —— HA 对检索不到的事实回答
|
||||
// 「库里根本没有 X,任何数字都是编的」,因为它不知道有内容被移走了。
|
||||
func overflowNotice(pruned int) string {
|
||||
return fmt.Sprintf("[内核] 上下文超页:已裁剪 %d 条低相关事件到文档记忆。"+
|
||||
"被裁内容仍可检索,但需显式查询;查不到不等于不存在。", pruned)
|
||||
// 空中断:vectorArgs 不带 Pruned —— 中断不需要知道裁了多少,
|
||||
// 那是裁剪侧自己的统计(overflowStat),不是中断的输入。
|
||||
a.raiseVector(VectorContextOverflow, vectorArgs{})
|
||||
}
|
||||
|
||||
// isUpstreamContextFull 判定上游错误是否表示「上下文装不下」。
|
||||
@ -380,3 +370,67 @@ func (a *Agent) contextLenSafe() int {
|
||||
}
|
||||
|
||||
// overflowStat 字段定义在 Agent 上(见 agent.go),此处只做访问辅助。
|
||||
|
||||
// pruneForAirInterrupt 执行一次因空中断触发的语义相关性裁剪。
|
||||
//
|
||||
// ## 为什么裁剪放在这里(而不是发中断之前)
|
||||
//
|
||||
// contextoverflow 的原始设计是:L4 空中断把当前任务抢占下来 ⇒ 任务边界处
|
||||
// 做语义相关性裁剪。中断是**触发源**,裁剪是它引发的**动作**。
|
||||
//
|
||||
// 此前实现顺序反了(先 pruneByQuery 再 raise 带文本的中断),于是那个 L4
|
||||
// 退化成一个"通知裁剪已发生"的回执任务——既晚了一步(裁剪早已完成),
|
||||
// 又给模型塞了一条它不需要的说明。
|
||||
//
|
||||
// ## query 取什么
|
||||
//
|
||||
// 空中断本身没有正文,所以拿不到"当前问题"。这里用上下文里**最后一条真实
|
||||
// 用户输入的文本**作为相关性基准:裁剪要保留的正是与"刚才在做的事"相关的
|
||||
// 内容。取不到就退化为按时间保留(pruneByQuery 内部对空 query 的处理)。
|
||||
func (a *Agent) pruneForAirInterrupt() {
|
||||
if a == nil || a.context == nil || a.isLightKernel() {
|
||||
return
|
||||
}
|
||||
before := a.context.Len()
|
||||
beforeTokens := a.accumulatedTokens()
|
||||
|
||||
a.pruneByQuery(a.lastUserQueryForPrune())
|
||||
|
||||
after := a.context.Len()
|
||||
afterTokens := a.accumulatedTokens()
|
||||
if after >= before && afterTokens >= beforeTokens {
|
||||
// 裁不动:记录下来即可。空中断不承担"必须裁成功"的职责——
|
||||
// 真正的超限判定仍在 enforceContextBudget,那里有终止语义。
|
||||
a.overflowStat.Lock()
|
||||
a.overflowStat.Aborted++
|
||||
a.overflowStat.LastRatio = a.overflowRatioNow()
|
||||
a.overflowStat.LastEvents = before
|
||||
a.overflowStat.Unlock()
|
||||
log.Printf("[agent] air-interrupt prune: no reduction (%d→%d events, %d→%d tokens)",
|
||||
before, after, beforeTokens, afterTokens)
|
||||
return
|
||||
}
|
||||
a.overflowStat.Lock()
|
||||
a.overflowStat.Triggered++
|
||||
a.overflowStat.LastPruned = before - after
|
||||
a.overflowStat.Unlock()
|
||||
log.Printf("[agent] air-interrupt prune: %d→%d events (%d→%d tokens), ratio=%.2f",
|
||||
before, after, beforeTokens, afterTokens, a.overflowRatioNow())
|
||||
}
|
||||
|
||||
// lastUserQueryForPrune 取最后一条非中断用户输入作为裁剪的相关性基准。
|
||||
//
|
||||
// 空中断自己没有正文,但它被触发时"刚才在做什么"是有答案的:
|
||||
// 上下文里最后一条真实用户输入。取不到返回空串(pruneByQuery 按时间保留)。
|
||||
func (a *Agent) lastUserQueryForPrune() string {
|
||||
if a == nil || a.context == nil {
|
||||
return ""
|
||||
}
|
||||
evts := a.context.Recent(0) // 值为 ContextEvent(非指针),Recent 已做深拷贝
|
||||
for i := len(evts) - 1; i >= 0; i-- {
|
||||
if evts[i].Input != "" {
|
||||
return evts[i].Input
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@ -55,11 +55,12 @@ func TestOverflow_T1_积累超125触发(t *testing.T) {
|
||||
if !handled {
|
||||
t.Fatal("超限时 maybeHandleContextOverflow 应返回 handled=true")
|
||||
}
|
||||
if pruned <= 0 {
|
||||
t.Fatal("应裁剪出至少一条")
|
||||
}
|
||||
if after := a.context.Len(); after >= before {
|
||||
t.Fatalf("裁剪后条数未减少:%d → %d", before, after)
|
||||
// ★ 空中断设计:发中断这一步**不裁剪**(pruned 如实为 0),
|
||||
// 裁剪由空中断任务被调度执行时做(pruneForAirInterrupt)。
|
||||
// 这条原来断言 pruned>0,那对应的是"先裁剪再发回执"的旧实现。
|
||||
_ = pruned
|
||||
if after := a.context.Len(); after != before {
|
||||
t.Fatalf("发中断这一步不应改动上下文:%d → %d", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
@ -95,10 +96,14 @@ func TestOverflow_T2_裁剪后累积下降(t *testing.T) {
|
||||
before := a.accumulatedTokens()
|
||||
|
||||
a.maybeHandleContextOverflow(nil, "退款导出")
|
||||
after := a.accumulatedTokens()
|
||||
|
||||
// ★ 空中断设计:裁剪延后到任务边界(pruneForAirInterrupt)。
|
||||
// 这里直接调它,验证"裁剪确实能降 token"本身仍成立。
|
||||
a.pruneForAirInterrupt()
|
||||
|
||||
after := a.accumulatedTokens()
|
||||
if after >= before {
|
||||
t.Fatalf("裁剪后积累 token 未下降:%d → %d", before, after)
|
||||
t.Fatalf("pruneForAirInterrupt 后积累 token 未下降:%d → %d", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
@ -130,23 +135,34 @@ func TestOverflow_T4_裁不动则终止不中断(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── T6:中断消息必须带「查不到不等于不存在」──
|
||||
// ── 超页只发空中断,裁剪留给任务边界 ──
|
||||
//
|
||||
// 推翻的旧判据:TestOverflow_T6_中断消息含认知提示(断言正文含
|
||||
// "查不到不等于不存在"、"文档记忆")。那段文案连同 overflowNotice 一起删除,
|
||||
// 理由见 raiseContextOverflow 的注释(把"补偿 L4 看不到上下文"错当成了
|
||||
// 中断该带正文的理由)。
|
||||
//
|
||||
// 现在钉住的是:**超页时发出的是空中断**,且裁剪**不在发中断这一步发生**。
|
||||
func TestOverflow_RaisesAirInterruptWithoutPruning(t *testing.T) {
|
||||
a := newOverflowAgent(2000, 2, 20, 400)
|
||||
|
||||
func TestOverflow_T6_中断消息含认知提示(t *testing.T) {
|
||||
got := overflowNotice(17)
|
||||
if got == "" {
|
||||
t.Fatal("超页通知不应为空")
|
||||
before := a.context.Len()
|
||||
a.handleContextOverflow("", 2.0)
|
||||
|
||||
// ① 中断已发出(Triggered 计数 +1)
|
||||
a.overflowStat.Lock()
|
||||
triggered := a.overflowStat.Triggered
|
||||
a.overflowStat.Unlock()
|
||||
if triggered == 0 {
|
||||
t.Fatal("超页必须发出 L4 中断")
|
||||
}
|
||||
if !strings.Contains(got, "查不到不等于不存在") {
|
||||
t.Fatalf("中断消息缺少认知提示,agent 会把「没检索到」误判成「不存在」:%q", got)
|
||||
}
|
||||
if !strings.Contains(got, "文档记忆") {
|
||||
t.Fatalf("中断消息应说明被裁内容去了哪里:%q", got)
|
||||
}
|
||||
// 来源标识在 raiseKernelInterrupt 的参数里("kernel/overflow"),
|
||||
// 不在消息正文 —— 那部分留给可读性。这里只保证提示词自洽。
|
||||
if strings.Contains(got, "查不到等于存在") {
|
||||
t.Fatalf("提示词语义写反了:%q", got)
|
||||
// ② 裁剪**没有**在这一步发生:上下文条数不变
|
||||
//
|
||||
// 这是空中断设计的核心——裁剪由空中断任务被调度执行时触发
|
||||
// (pruneForAirInterrupt),不是 raise 的副作用。
|
||||
// 变异验证:在 handleContextOverflow 里加回 pruneByQuery ⇒ 本判据变红。
|
||||
if got := a.context.Len(); got != before {
|
||||
t.Fatalf("发中断这一步不应裁剪上下文:之前 %d 条,之后 %d 条", before, got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -236,10 +252,10 @@ func TestOverflow_T5_恢复预算耗尽则终止(t *testing.T) {
|
||||
a := newOverflowAgent(4000, 30, 20, 800)
|
||||
f := &TaskFrame{}
|
||||
|
||||
// 第一次超页:正常裁剪
|
||||
handled, pruned := a.maybeHandleContextOverflow(f, "退款导出")
|
||||
if !handled || pruned <= 0 {
|
||||
t.Fatalf("首次应正常裁剪:handled=%v pruned=%d", handled, pruned)
|
||||
// 第一次超页:发出空中断(裁剪延后,pruned 如实为 0)
|
||||
handled, _ := a.maybeHandleContextOverflow(f, "退款导出")
|
||||
if !handled {
|
||||
t.Fatalf("首次应处理并发出空中断:handled=%v", handled)
|
||||
}
|
||||
if f.OverflowRecover != 1 {
|
||||
t.Fatalf("首次应记一次恢复,实际 %d", f.OverflowRecover)
|
||||
@ -438,10 +454,16 @@ func TestOverflow_大事件时裁剪仍有效(t *testing.T) {
|
||||
})
|
||||
}
|
||||
before := a.context.Len()
|
||||
pruned := a.handleContextOverflow("退款导出", 10.0)
|
||||
// ★ 空中断设计:handleContextOverflow 只发空中断,裁剪在任务边界做。
|
||||
// 这里先发中断(验证它不改上下文),再由 pruneForAirInterrupt 真正裁。
|
||||
a.handleContextOverflow("退款导出", 10.0)
|
||||
if mid := a.context.Len(); mid != before {
|
||||
t.Fatalf("发空中断这一步不应改动上下文:%d → %d", before, mid)
|
||||
}
|
||||
a.pruneForAirInterrupt()
|
||||
after := a.context.Len()
|
||||
if pruned <= 0 && after >= before {
|
||||
t.Fatalf("裁剪无效:%d → %d 条,pruned=%d", before, after, pruned)
|
||||
if after >= before {
|
||||
t.Fatalf("裁剪无效:%d → %d 条", before, after)
|
||||
}
|
||||
// 裁剪后仍应留下 protected 条以上,不能清空
|
||||
if after < a.effectiveProtectedCount() {
|
||||
@ -540,11 +562,13 @@ func TestOverflow_自适应后裁剪有效(t *testing.T) {
|
||||
// 由TestOverflow_单条超预算则终止 覆盖。
|
||||
a := newOverflowAgent(4000, 30, 5, 800)
|
||||
before := a.context.Len()
|
||||
pruned := a.handleContextOverflow("退款导出", 1.3)
|
||||
// 空中断设计:先发中断,再由任务边界裁剪。
|
||||
a.handleContextOverflow("退款导出", 1.3)
|
||||
a.pruneForAirInterrupt()
|
||||
after := a.context.Len()
|
||||
|
||||
if after >= before {
|
||||
t.Fatalf("自适应后裁剪应有效:%d → %d(pruned=%d)", before, after, pruned)
|
||||
t.Fatalf("自适应后裁剪应有效:%d → %d", before, after)
|
||||
}
|
||||
if a.effectiveProtectedCount() >= before {
|
||||
t.Fatalf("有效保护数 %d 不应 ≥ 裁剪前条数 %d",
|
||||
@ -727,3 +751,61 @@ func TestOverflow_恢复预算对所有入口生效(t *testing.T) {
|
||||
t.Fatal("预算耗尽必须置 terminalError,否则会静默重试")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 空中断端到端:识别 + 裁剪 + 不产生回复 ──────────────────────
|
||||
//
|
||||
// 这几条钉住 2026-10-25 按原始设计做的修正。此前实现有三个偏移:
|
||||
// ① 中断带正文("已裁剪 N 条…")——它变成一条要模型回应的消息
|
||||
// ② 裁剪在 raise 之前就做完——中断沦为回执,晚了一步
|
||||
// ③ 上游 contextfull 后原地重跑——拿同一份超限内容再发一次,烧光恢复预算
|
||||
|
||||
// 判据:空中断必须被 resolveInput 认作有效输入。
|
||||
//
|
||||
// 变异验证:删掉 isAirInterrupt 那一支 ⇒ 它会被当成 empty_input 丢弃,
|
||||
// 本判据变红。
|
||||
func TestAirInterrupt_ResolvedAsValidInput(t *testing.T) {
|
||||
a := newOverflowAgent(2000, 2, 20, 400)
|
||||
evt := &agentIO.InputEvent{
|
||||
Type: "interrupt",
|
||||
Source: "kernel/overflow",
|
||||
Payload: map[string]interface{}{"interrupt": true, "kernel": true, "content": ""},
|
||||
}
|
||||
in, ok := a.resolveInput(evt)
|
||||
if !ok {
|
||||
t.Fatal("空中断必须是有效输入(否则 L4 变成什么也不做的空转任务)")
|
||||
}
|
||||
if !in.airInterrupt {
|
||||
t.Fatal("必须被标记为 airInterrupt")
|
||||
}
|
||||
}
|
||||
|
||||
// 判据:普通空输入**不能**被误认成空中断。
|
||||
//
|
||||
// 理由:客户端空包若也走裁剪路径,会变成一次无声的上下文裁剪,行为不可预测。
|
||||
func TestAirInterrupt_RejectsOrdinaryEmptyInput(t *testing.T) {
|
||||
a := newOverflowAgent(2000, 2, 20, 400)
|
||||
cases := []struct {
|
||||
name string
|
||||
payload map[string]interface{}
|
||||
}{
|
||||
{"非内核来源", map[string]interface{}{"interrupt": true, "content": ""}},
|
||||
{"非中断类型", map[string]interface{}{"kernel": true, "content": ""}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
evt := &agentIO.InputEvent{Type: "interrupt", Source: "x", Payload: c.payload}
|
||||
if c.name == "非中断类型" {
|
||||
evt.Type = "text"
|
||||
}
|
||||
if in, ok := a.resolveInput(evt); ok && in.airInterrupt {
|
||||
t.Errorf("%s 不应被认作空中断", c.name)
|
||||
}
|
||||
}
|
||||
// 插件发的带正文中断也不是空中断
|
||||
evt := &agentIO.InputEvent{
|
||||
Type: "interrupt",
|
||||
Payload: map[string]interface{}{"kernel": true, "content": "设备上线了"},
|
||||
}
|
||||
if in, ok := a.resolveInput(evt); ok && in.airInterrupt {
|
||||
t.Error("带正文的中断不应被认作空中断")
|
||||
}
|
||||
}
|
||||
|
||||
@ -443,6 +443,20 @@ func (a *Agent) prepareInputTask(evt *agentIO.InputEvent) (*TaskFrame, taskTermi
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 空中断(kernel/overflow):不做任何对话处理,只做语义相关性裁剪。
|
||||
//
|
||||
// 这是 contextoverflow 的**原始设计**(2026-10-25 按用户口径修正):
|
||||
// L4 中断本身不携带消息,唯一用途是把当前任务抢占下来,好在任务边界
|
||||
// 做一次语义相关性裁剪。此前实现把它反过来了(先裁剪、再发一条
|
||||
// 「已裁剪 N 条…」的通知),那等于把调度信号变成了回执。
|
||||
//
|
||||
// 因此这里:裁剪 → 结束本轮,**不进 LLM、不写上下文、不发回复**。
|
||||
// 用 terminalSkipped(不是 terminalError):这是正常完成,不是故障。
|
||||
if in.airInterrupt {
|
||||
a.pruneForAirInterrupt()
|
||||
return nil, terminalSkipped
|
||||
}
|
||||
|
||||
// 工具提醒/中断(terminal_watch、timer 等)不是用户发言:
|
||||
// 以 system 角色注入 LLM,且不写入用户对话履历。
|
||||
isInterrupt, _ := evt.Payload["interrupt"].(bool)
|
||||
@ -706,12 +720,18 @@ func (a *Agent) stepLLM(f *TaskFrame) stepOutcome {
|
||||
if f.Terminal == terminalError {
|
||||
return outcomeFailed // 恢复预算耗尽,f.Err 已写明
|
||||
}
|
||||
// 已裁剪。**必须退回 StepPrepare 重建 f.Msgs**:
|
||||
// f.Msgs 只在 stepPrepare 里由 buildMessages 装配,stepLLM 不重建;
|
||||
// 而挂起帧里那份就是刚刚超限的那份,复用它等于原样再发一次。
|
||||
f.Turn++
|
||||
f.Step = StepPrepare
|
||||
return outcomeContinue
|
||||
// ★ 已发出 L4 空中断,**裁剪尚未发生**(它在空中断任务被调度
|
||||
// 执行时才做)。所以这里不能让本帧原地重跑:
|
||||
//
|
||||
// · 原地重跑 = 拿同一份超限内容再发一次,必然再次超限
|
||||
// (这正是 recover budget 会被快速烧光的原因);
|
||||
// · 真正的裁剪发生在任务边界,也就是**本任务让位之后**。
|
||||
//
|
||||
// 语义上本帧的职责到此为止:把现场交出去(outcomeSuspended),
|
||||
// 由空中断任务抢占执行、完成裁剪,随后本帧再被恢复。
|
||||
//
|
||||
// 与"用户按停止"不同:那不是让位,是终结。两者不能混。
|
||||
return outcomeSuspended
|
||||
}
|
||||
// 未处理(如轻量内核不裁剪):落到下面按普通错误处理。
|
||||
}
|
||||
|
||||
@ -87,7 +87,7 @@ func TestStreaming_PublishLatencyFlatAcrossSubscribers(t *testing.T) {
|
||||
payload := []byte(`{"type":"content_delta","payload":{"text":"t"}}`)
|
||||
const rounds = 3000
|
||||
|
||||
measure := func(consumers int) time.Duration {
|
||||
measure := func(consumers int) segments {
|
||||
var wg sync.WaitGroup
|
||||
active := make([]*EvtConsumer, 0, consumers)
|
||||
for i := 0; i < consumers; i++ {
|
||||
@ -113,25 +113,65 @@ func TestStreaming_PublishLatencyFlatAcrossSubscribers(t *testing.T) {
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
start := time.Now()
|
||||
for i := 0; i < rounds; i++ {
|
||||
// 分两段测:段间差异即"同配置的噪声水平"(见下方自校准判据)。
|
||||
//
|
||||
// 为什么分两段而不是把 measure 调两次:EvtConsumer.Stop() 只关自己
|
||||
// 的 stop channel,而 Run 正阻塞在 evtfd.Read —— 后续没有 notify
|
||||
// 唤醒它就不会返回,wg.Wait 于是永久挂住(实测 240s 超时)。
|
||||
// 分段测量在**同一次 consumer 生命周期内**完成,不碰这个问题。
|
||||
half := rounds / 2
|
||||
startA := time.Now()
|
||||
for i := 0; i < half; i++ {
|
||||
ring.WritePush(pubsdk.EventContentDelta, payload)
|
||||
EvtfdNotify(host.EvtNotifyFd())
|
||||
}
|
||||
return time.Since(start)
|
||||
segA := time.Since(startA)
|
||||
|
||||
startB := time.Now()
|
||||
for i := 0; i < rounds-half; i++ {
|
||||
ring.WritePush(pubsdk.EventContentDelta, payload)
|
||||
EvtfdNotify(host.EvtNotifyFd())
|
||||
}
|
||||
segB := time.Since(startB)
|
||||
|
||||
return segments{total: segA + segB, a: segA, b: segB}
|
||||
}
|
||||
|
||||
d1 := measure(1)
|
||||
d8 := measure(8)
|
||||
|
||||
t.Logf("1 个消费者:%v(均摊 %v/次)", d1, d1/rounds)
|
||||
t.Logf("8 个消费者:%v(均摊 %v/次)", d8, d8/rounds)
|
||||
t.Logf("1 个消费者:%v(均摊 %v/次)", d1.total, d1.total/rounds)
|
||||
t.Logf("8 个消费者:%v(均摊 %v/次)", d8.total, d8.total/rounds)
|
||||
|
||||
// 线性恶化的判据:8 倍订阅者不应接近 8 倍耗时。
|
||||
// 阈值取 4 倍——测量噪声与调度抖动都会影响。
|
||||
if d8 > d1*4 {
|
||||
t.Errorf("订阅者 1→8,Publish 从 %v 涨到 %v(>4 倍),疑似线性恶化", d1, d8)
|
||||
// ★ 自校准噪声基准(2026-10-25 修 CI 误报)。
|
||||
//
|
||||
// 用 1 消费者**自身前后两段**的差异当噪声水平:同一配置、同一消费者、
|
||||
// 同一台机器,两段之间的差异只可能来自环境抖动。
|
||||
//
|
||||
// 为什么不能用绝对时间门槛:本机 423ns/次、CI 2µs/次,量级差 5 倍,
|
||||
// 任何固定阈值要么在本机永远跳过(判据失效),要么在 CI 永远误报。
|
||||
// 拿"同配置两段之差"当基准与机器快慢无关,是自校准的。
|
||||
const noiseFactor = 2
|
||||
lo, hi := d1.a, d1.b
|
||||
if lo > hi {
|
||||
lo, hi = hi, lo
|
||||
}
|
||||
if lo > 0 && hi > lo*noiseFactor {
|
||||
t.Logf("环境噪声过大(同配置两段 %v vs %v,>%dx)——跳过比值判定。"+
|
||||
"CI 共享机器上这是常态,被测效应无法与噪声区分;"+
|
||||
"真出现 O(N) 广播时比值会是 8 倍量级,远超噪声。", lo, hi, noiseFactor)
|
||||
return
|
||||
}
|
||||
|
||||
// 线性恶化的判据:8 倍订阅者不应接近 8 倍耗时。阈值取 4 倍。
|
||||
if d8.total > d1.total*4 {
|
||||
t.Errorf("订阅者 1→8,Publish 从 %v 涨到 %v(>4 倍),疑似线性恶化", d1.total, d8.total)
|
||||
}
|
||||
}
|
||||
|
||||
// segments 是一次测量里的总耗时与前后两段耗时。
|
||||
type segments struct {
|
||||
total, a, b time.Duration
|
||||
}
|
||||
|
||||
// 事件环溢出时 Publish 不退化。
|
||||
|
||||
@ -49,6 +49,12 @@ type Plugin struct {
|
||||
name string
|
||||
skillsDir string
|
||||
simulatorDir string
|
||||
// pythonBin 是运行 skill 子进程的解释器(配置 python_bin)。
|
||||
// 留空表示未配置:此时优先用 <skill>/.venv/bin/python,再退回系统 python3。
|
||||
pythonBin string
|
||||
// skillWorkdir 是 skill 子进程的工作目录(配置 skill_workdir)。
|
||||
// 留空表示用 skill 自身目录。
|
||||
skillWorkdir string
|
||||
sidecars []*sidecarProcess
|
||||
manager *sidecarProcess
|
||||
mu sync.Mutex
|
||||
@ -92,6 +98,24 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
Key: "simulator_dir", Type: "string", DisplayName: "模拟器工作目录",
|
||||
Description: "OpenClaw 模拟器工作目录路径(留空则使用默认路径)",
|
||||
})
|
||||
// ★ Python 解释器与 skill 工作目录(2026-10-25 新增)。
|
||||
//
|
||||
// 此前是硬编码猜测:先找 /usr/bin/python3,再找 /usr/local/bin/python3。
|
||||
// 后果是 skill 声明的依赖(httpx 等)装在哪个解释器里就决定了它能不能跑,
|
||||
// 而"猜"这件事根本没有正确解 —— 不同部署的 Python 布局完全不同。
|
||||
//
|
||||
// 现在:由配置**显式指定**,与 skills_dir/simulator_dir 同一套风格。
|
||||
// 留空时仍走按 skill 目录探测 .venv 的既有逻辑(向后兼容)。
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "python_bin", Type: "string", DisplayName: "Python 解释器",
|
||||
Description: "运行 skill 用的 Python 解释器绝对路径,例如 /opt/ha-py/bin/python。" +
|
||||
"留空则优先用 <skill 目录>/.venv/bin/python,再退回系统 python3。",
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "skill_workdir", Type: "string", DisplayName: "Skill 工作目录",
|
||||
Description: "skill 子进程的工作目录(留空则用 skill 自身目录)。" +
|
||||
"需要固定相对路径读写时用它;不影响解释器选择。",
|
||||
})
|
||||
p.httpClient = &http.Client{Timeout: 30 * time.Second}
|
||||
if v, _ := s.Settings().Get("skills_dir"); v != nil {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
@ -103,6 +127,16 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.simulatorDir = s
|
||||
}
|
||||
}
|
||||
if v, _ := s.Settings().Get("python_bin"); v != nil {
|
||||
if str, ok := v.(string); ok && str != "" {
|
||||
p.pythonBin = str
|
||||
}
|
||||
}
|
||||
if v, _ := s.Settings().Get("skill_workdir"); v != nil {
|
||||
if str, ok := v.(string); ok && str != "" {
|
||||
p.skillWorkdir = str
|
||||
}
|
||||
}
|
||||
// 默认目录:内核 data_dir 下 skills 目录(与配置 core.daemon.data_dir 对齐)
|
||||
if p.skillsDir == "" {
|
||||
if v, _ := s.Settings().GetCore("daemon.data_dir"); v != nil {
|
||||
@ -1154,16 +1188,31 @@ func (p *Plugin) loadPySidecar(s *sdk.PluginSDK, dir, name string) error {
|
||||
return fmt.Errorf("write pysimulator: %w", err)
|
||||
}
|
||||
|
||||
// 查找可用的 Python 解释器
|
||||
pythonBin := "python3"
|
||||
for _, candidate := range []string{"/usr/bin/python3", "/usr/local/bin/python3"} {
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
pythonBin = candidate
|
||||
break
|
||||
}
|
||||
// 选择 Python 解释器。优先级(2026-10-25 改为可配置):
|
||||
//
|
||||
// ① 插件配置 python_bin —— **显式指定,最高优先**
|
||||
// ② <skill 目录>/.venv/bin/python —— skill 自带的虚拟环境
|
||||
// ③ 系统 python3(/usr/bin → /usr/local/bin)
|
||||
//
|
||||
// 为什么 ① 必须在最前:不同部署的 Python 布局完全不同,"猜"没有正确解。
|
||||
// 实测事故:`bilibili-all-in-one` 声明 requirements 含 httpx,而系统
|
||||
// Python 没装 ⇒ 启动即 ModuleNotFoundError。往系统 Python 里装又会污染
|
||||
// 发行版环境(PEP 668 明确禁止,--break-system-packages 会破坏 apt 的
|
||||
// 包一致性)。正确解是让部署方显式指定用哪个解释器/venv。
|
||||
pythonBin, err := resolvePython(name, dir, p.pythonBin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sp, err := launchProcess(pythonBin, simPath, dir, name, p.simulatorDir)
|
||||
// 工作目录:配置指定则用它(需创建),否则用 skill 自身目录。
|
||||
workDir := dir
|
||||
if p.skillWorkdir != "" {
|
||||
if err := os.MkdirAll(p.skillWorkdir, 0755); err != nil {
|
||||
return fmt.Errorf("创建 skill_workdir(%s): %w", p.skillWorkdir, err)
|
||||
}
|
||||
workDir = p.skillWorkdir
|
||||
}
|
||||
sp, err := launchProcessIn(pythonBin, simPath, dir, name, p.simulatorDir, workDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("launch pysimulator: %w", err)
|
||||
}
|
||||
@ -1418,3 +1467,58 @@ func extractTGZ(data []byte, dest string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fileExists 报告路径存在(错误信息不需要,调用方只关心能否用)。
|
||||
func fileExists(p string) bool {
|
||||
_, err := os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// resolvePython 决定用哪个 Python 解释器跑 skill,优先级:
|
||||
//
|
||||
// ① configured(插件配置 python_bin)—— 显式指定,最高优先
|
||||
// ② <skill 目录>/.venv/bin/python —— skill 自带虚拟环境
|
||||
// ③ 系统 python3(/usr/bin → /usr/local/bin)→ 最后退回 "python3"
|
||||
//
|
||||
// ## 为什么必须可配置(2026-10-25 真机事故)
|
||||
//
|
||||
// 此前是硬编码猜:先 /usr/bin/python3,再 /usr/local/bin/python3。
|
||||
// `bilibili-all-in-one` 声明 requirements 含 httpx,而系统 Python 没装
|
||||
// ⇒ 启动即 ModuleNotFoundError。而"猜"没有正确解:不同部署的 Python
|
||||
// 布局完全不同(发行版 Python、pyenv、uv、conda、venv…)。
|
||||
//
|
||||
// 往系统 Python 里 pip install 是**错的**解法:PEP 668 明确禁止,
|
||||
// --break-system-packages 会破坏 apt 管理的包一致性。
|
||||
//
|
||||
// ## 配错必须响亮
|
||||
//
|
||||
// 配置了但文件不存在时返回 error,**不静默退回**系统解释器 ——
|
||||
// 否则用户以为配好了,实际跑在缺依赖的 Python 上,又是一次静默降级。
|
||||
func resolvePython(name, skillDir, configured string) (string, error) {
|
||||
if configured != "" {
|
||||
if _, err := os.Stat(configured); err != nil {
|
||||
return "", fmt.Errorf("配置的 python_bin 不可用(%s): %w", configured, err)
|
||||
}
|
||||
log.Printf("[clawhub] skill %s: using configured interpreter %s", name, configured)
|
||||
return configured, nil
|
||||
}
|
||||
if venvPy := filepath.Join(skillDir, ".venv", "bin", "python"); fileExists(venvPy) {
|
||||
log.Printf("[clawhub] skill %s: using its venv interpreter %s", name, venvPy)
|
||||
return venvPy, nil
|
||||
}
|
||||
for _, candidate := range []string{"/usr/bin/python3", "/usr/local/bin/python3"} {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "python3", nil
|
||||
}
|
||||
|
||||
// pickPythonFor 是 resolvePython 的便捷包装(测试与旧调用点用)。
|
||||
//
|
||||
// 忽略错误并返回空串会让"配错"看起来像"没配",所以调用方应优先用
|
||||
// resolvePython;这里只为保持测试可读性。
|
||||
func pickPythonFor(name, skillDir, configured string) string {
|
||||
v, _ := resolvePython(name, skillDir, configured)
|
||||
return v
|
||||
}
|
||||
|
||||
63
internal/plugins/clawhubadapter/python_bin_test.go
Normal file
63
internal/plugins/clawhubadapter/python_bin_test.go
Normal file
@ -0,0 +1,63 @@
|
||||
package clawhubadapter
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── Python 解释器选择优先级 ──────────────────────────────────
|
||||
//
|
||||
// 背景(2026-10-25 真机事故):bilibili skill 声明 requirements 含 httpx,
|
||||
// 系统 Python 没装 ⇒ 启动即 ModuleNotFoundError。而此前解释器是**硬编码猜**的:
|
||||
// 先 /usr/bin/python3,再 /usr/local/bin/python3。
|
||||
//
|
||||
// 「猜」没有正确解:不同部署的 Python 布局完全不同(发行版 Python、
|
||||
// pyenv、uv、conda、venv…)。所以改为配置显式指定 + venv 探测兜底。
|
||||
|
||||
// 判据:配置了 python_bin 且可用 ⇒ 必须用它,优先于 venv 与系统解释器。
|
||||
//
|
||||
// 这是"配置生效"的唯一证据。没有它,配了却仍走系统解释器也不会被发现
|
||||
// (正是本次事故的形态:静默降级)。
|
||||
func TestPickPython_ConfiguredWins(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// 造一个假的 venv 解释器,确保"配置优先于 venv"被真正验证
|
||||
venvBin := filepath.Join(dir, ".venv", "bin")
|
||||
os.MkdirAll(venvBin, 0755)
|
||||
venvPy := filepath.Join(venvBin, "python")
|
||||
os.WriteFile(venvPy, []byte("#!/bin/sh\n"), 0755)
|
||||
|
||||
cfgPy := filepath.Join(dir, "custom-python")
|
||||
os.WriteFile(cfgPy, []byte("#!/bin/sh\n"), 0755)
|
||||
|
||||
got := pickPythonFor("bilibili", dir, cfgPy)
|
||||
if got != cfgPy {
|
||||
t.Fatalf("配置的 python_bin 应最高优先,期望 %s,实际 %s", cfgPy, got)
|
||||
}
|
||||
}
|
||||
|
||||
// 判据:没配 python_bin 但 skill 有 venv ⇒ 用 venv。
|
||||
func TestPickPython_VenvWhenNotConfigured(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
venvBin := filepath.Join(dir, ".venv", "bin")
|
||||
os.MkdirAll(venvBin, 0755)
|
||||
venvPy := filepath.Join(venvBin, "python")
|
||||
os.WriteFile(venvPy, []byte("#!/bin/sh\n"), 0755)
|
||||
|
||||
if got := pickPythonFor("x", dir, ""); got != venvPy {
|
||||
t.Fatalf("无配置时应优先 skill 自带 venv,期望 %s,实际 %s", venvPy, got)
|
||||
}
|
||||
}
|
||||
|
||||
// 判据:配置了但**不存在** ⇒ 必须报错,不能静默退回系统解释器。
|
||||
//
|
||||
// 静默退回的后果:用户以为配好了,实际跑在缺依赖的 Python 上 ——
|
||||
// 这正是本次要修的「静默降级」那一族。
|
||||
func TestPickPython_MissingConfiguredIsLoud(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bad := filepath.Join(dir, "no-such-python")
|
||||
_, err := resolvePython("x", dir, bad)
|
||||
if err == nil {
|
||||
t.Fatal("配置的 python_bin 不存在时必须报错(静默退回会让用户以为配置生效)")
|
||||
}
|
||||
}
|
||||
@ -164,7 +164,17 @@ func launchSidecar(dir, name, simDir string) (*sidecarProcess, error) {
|
||||
return launchProcess("node", mainJS, dir, name, simDir)
|
||||
}
|
||||
|
||||
// launchProcess 以 dir 作为工作目录拉起子进程(绝大多数调用的形态)。
|
||||
func launchProcess(bin, arg, dir, name, simDir string) (*sidecarProcess, error) {
|
||||
return launchProcessIn(bin, arg, dir, name, simDir, dir)
|
||||
}
|
||||
|
||||
// launchProcessIn 与 launchProcess 相同,但工作目录可单独指定。
|
||||
//
|
||||
// 为什么要单独一个函数而不是给 launchProcess 加参数:那会改动 4 个既有
|
||||
// 调用点(含测试),而它们的语义都是"工作目录 = skill 目录"。只有
|
||||
// skill_workdir 配置这一处需要另给目录,用变体表达的意图更清楚。
|
||||
func launchProcessIn(bin, arg, dir, name, simDir, workDir string) (*sidecarProcess, error) {
|
||||
nodePath := bin
|
||||
if bin == "node" {
|
||||
if p := os.Getenv("NODE_PATH"); p != "" {
|
||||
@ -173,7 +183,10 @@ func launchProcess(bin, arg, dir, name, simDir string) (*sidecarProcess, error)
|
||||
}
|
||||
|
||||
cmd := exec.Command(nodePath, arg, dir)
|
||||
cmd.Dir = dir
|
||||
if workDir == "" {
|
||||
workDir = dir
|
||||
}
|
||||
cmd.Dir = workDir
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
// Add openclaw CLI bin dir to PATH so subprocesses can exec 'openclaw' command
|
||||
@ -215,6 +228,23 @@ func launchProcess(bin, arg, dir, name, simDir string) (*sidecarProcess, error)
|
||||
return sp, nil
|
||||
}
|
||||
|
||||
// sidecarReadyTimeout 是等 sidecar 回第一个 ping 的上限。
|
||||
//
|
||||
// ★ 从固定 5s 放宽到 30s(2026-10-25 修 CI 误报)。
|
||||
//
|
||||
// 原来的 5s 是**按开发机估算**的:node 冷启动 + 模块加载在快盘上空载时
|
||||
// 约 0.2s。而 CI 的共享 runner(以及真实部署里的低配机器)上,一次
|
||||
// fork+exec+node 引导遇到 CPU 争抢就会到 5s 以上,实测 CI 报
|
||||
// `sidecar echoplugin ping timeout` 而本机全绿。
|
||||
//
|
||||
// 这不是"测试环境太慢所以放宽测试"——它是**产品行为**:sidecar 起不来
|
||||
// 等于该 skill 整体不可用。给足时间是正确取舍;判断"真的挂了"应由进程
|
||||
// 退出信号负责(readLoop 会感知),而不是由引导耗时猜。
|
||||
//
|
||||
// 30s 的取法:比 systemd 的 TimeoutStopSec(25s) 宽,比用户可感知的
|
||||
// "卡住"阈值(约 30s)持平——再长就该让用户看到明确失败。
|
||||
const sidecarReadyTimeout = 30 * time.Second
|
||||
|
||||
func (s *sidecarProcess) waitReady() error {
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
@ -224,8 +254,9 @@ func (s *sidecarProcess) waitReady() error {
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-time.After(5 * time.Second):
|
||||
return fmt.Errorf("sidecar %s ping timeout", s.name)
|
||||
case <-time.After(sidecarReadyTimeout):
|
||||
return fmt.Errorf("sidecar %s ping timeout(%v 内未响应;慢机器上 node 冷启动可能更久,"+
|
||||
"若持续失败请检查 node 是否可用)", s.name, sidecarReadyTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
133
scripts/make-package.sh
Executable file
133
scripts/make-package.sh
Executable file
@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env bash
|
||||
# 制作 HomeAgent 复合安装包(含内核 OTA 工具)。
|
||||
#
|
||||
# 包结构(与既有 1.4.0-20261017 包保持一致,新增 homed-upgrade):
|
||||
# bin/homed 内核(内置插件已编进去)
|
||||
# bin/depgraph.bin 启动依赖图(与 homed 必须同批,digest 绑定)
|
||||
# bin/waiter CLI
|
||||
# bin/hmapdev 插件工具链(29MB,现场编译外部插件用)
|
||||
# bin/homed-upgrade ★ 内核 OTA:探测 + 校验 + 备份 + 原子替换 + 回滚
|
||||
# sdk/ 公开 SDK 源码(hmapdev 装到 ~/.homeagent/hmapdev/sdk)
|
||||
# install.sh 安装脚本
|
||||
# SHA256SUMS ★ 全量校验和(OTA 升级前必须验)
|
||||
#
|
||||
# 为什么带 SHA256SUMS:homed-upgrade 的第一步就是校验它。没有校验和,
|
||||
# "包在传输中被截断/篡改"只能等替换完、服务起不来才发现。
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
ROOT=$(pwd)
|
||||
VERSION=$(grep -oE 'Version = "[0-9.]+"' internal/meta/meta.go | head -1 | grep -oE '[0-9.]+')
|
||||
COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
STAMP=$(date +%Y%m%d)
|
||||
NAME="HomeAgent-linux-x86_64-${VERSION}-${STAMP}"
|
||||
OUT="$ROOT/build/package/$NAME"
|
||||
|
||||
echo "== 构建 =="
|
||||
make build >/dev/null
|
||||
make build-cli >/dev/null
|
||||
CGO_ENABLED=0 go build -o "$ROOT/build/homed-upgrade" ./cmd/homed-upgrade/
|
||||
# hmapdev 来自 SDK 仓
|
||||
if [ ! -x "$ROOT/build/hmapdev" ]; then
|
||||
(cd third_party/homeagent-sdk/tools/hmapdev && go build -o "$ROOT/build/hmapdev" .)
|
||||
fi
|
||||
|
||||
echo "== 组装 $NAME =="
|
||||
rm -rf "$OUT"
|
||||
mkdir -p "$OUT/bin"
|
||||
install -m 0755 "$ROOT/build/homed" "$OUT/bin/homed"
|
||||
install -m 0644 "$ROOT/build/depgraph.bin" "$OUT/bin/depgraph.bin"
|
||||
install -m 0755 "$ROOT/build/waiter" "$OUT/bin/waiter"
|
||||
install -m 0755 "$ROOT/build/hmapdev" "$OUT/bin/hmapdev"
|
||||
install -m 0755 "$ROOT/build/homed-upgrade" "$OUT/bin/homed-upgrade"
|
||||
# 只带 SDK **源码**,排除构建产物。
|
||||
#
|
||||
# 实测:直接 cp -r 会把 example/commondev/rawfile(321M)、dist(189M)、
|
||||
# build/(422M) 等产物一起打进去,包从 319M 涨到 1.3G、文件数 23293。
|
||||
# 安装包里 sdk 的用途只有一个——给 hmapdev 现场编译插件提供 SDK 头/源码,
|
||||
# 产物对它毫无用处。
|
||||
SDK_SRC=third_party/homeagent-sdk
|
||||
mkdir -p "$OUT/sdk"
|
||||
(cd "$SDK_SRC" && tar -cf - \
|
||||
--exclude='./example/*/rawfile' \
|
||||
--exclude='./example/*/dist' \
|
||||
--exclude='./example/*/build' \
|
||||
--exclude='./example/*/node_modules' \
|
||||
--exclude='./build' \
|
||||
--exclude='./site_build' \
|
||||
--exclude='./.git' \
|
||||
--exclude='*.bin' \
|
||||
--exclude='*.hmap' \
|
||||
.) | (cd "$OUT/sdk" && tar -xf -)
|
||||
|
||||
cat >"$OUT/install.sh" <<'INSTALL'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
PREFIX="${PREFIX:-/usr/local}"
|
||||
BINDIR="$PREFIX/bin"
|
||||
|
||||
echo "== 安装到 PREFIX=$PREFIX =="
|
||||
install -d "$BINDIR"
|
||||
|
||||
# 先校验再安装:截断的 homed 装上去服务就起不来,而那时旧版已被覆盖。
|
||||
echo "== 校验 SHA256SUMS =="
|
||||
( cd "$HERE" && sha256sum -c --quiet SHA256SUMS )
|
||||
|
||||
# ★ homed 与 depgraph.bin 必须同一目录:内核启动按「可执行文件同目录」
|
||||
# 找依赖图(缺失/不匹配 ⇒ 拒绝启动)。
|
||||
install -m 0755 "$HERE/bin/homed" "$BINDIR/homed"
|
||||
install -m 0644 "$HERE/bin/depgraph.bin" "$BINDIR/depgraph.bin"
|
||||
install -m 0755 "$HERE/bin/waiter" "$BINDIR/waiter"
|
||||
install -m 0755 "$HERE/bin/hmapdev" "$BINDIR/hmapdev"
|
||||
install -m 0755 "$HERE/bin/homed-upgrade" "$BINDIR/homed-upgrade"
|
||||
|
||||
SDKVER="$(grep -oE 'Version = "[0-9.]+"' "$HERE/sdk/meta/meta.go" | head -1 | grep -oE '[0-9.]+')"
|
||||
DEST="${HOME:-/root}/.homeagent/hmapdev/sdk/v${SDKVER}"
|
||||
echo "== 安装 SDK $SDKVER → $DEST =="
|
||||
rm -rf "$DEST"; mkdir -p "$(dirname "$DEST")"
|
||||
cp -r "$HERE/sdk" "$DEST"
|
||||
echo "v${SDKVER}" > "$(dirname "$DEST")/current"
|
||||
|
||||
echo
|
||||
echo "完成。验证:"
|
||||
echo " homed --version # 内核(含 commit/build_time)"
|
||||
echo " homed-upgrade -pkg X.tar.gz # 内核 OTA 探测(默认不写盘)"
|
||||
echo " waiter --version # CLI"
|
||||
echo " hmapdev --version # 插件工具链"
|
||||
INSTALL
|
||||
chmod +x "$OUT/install.sh"
|
||||
|
||||
cat >"$OUT/README.txt" <<README
|
||||
HomeAgent ${VERSION} (commit ${COMMIT}, ${STAMP})
|
||||
================================================
|
||||
|
||||
安装: sudo ./install.sh
|
||||
PREFIX=/opt ./install.sh # 装到别处
|
||||
|
||||
内核 OTA(只升级内核,不动插件)
|
||||
--------------------------------
|
||||
homed-upgrade -pkg <新包.tar.gz> # 探测(默认,不写盘)
|
||||
homed-upgrade -pkg <新包.tar.gz> -apply # 执行
|
||||
|
||||
探测内容:当前/包内版本比较、depgraph 是否同批、插件兼容性预判。
|
||||
执行流程:校验 → 预检 → 备份 → 原子替换 → 重启 → 版本复核 → 失败自动回滚。
|
||||
|
||||
插件与内核的兼容性**不由 OTA 处理**:换内核后与旧 SDK 编的插件会握手失败,
|
||||
内核把它们持久禁用并写明原因(界面上可见),重编或手动启用即可恢复。
|
||||
README
|
||||
# 校验和必须在全部产物生成**之后**一次算完(边打边算会漏掉后生成的文件)
|
||||
# 先写临时文件再 mv:避免"边扫描目录边写该目录下的文件"(shellcheck SC2094),
|
||||
# 也避免中途失败留下一个**看起来完整**的校验和文件。
|
||||
(cd "$OUT" && find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum >/tmp/.ha-sums.$$)
|
||||
mv /tmp/.ha-sums.$$ "$OUT/SHA256SUMS"
|
||||
|
||||
TAR="$ROOT/build/package/${NAME}.tar.gz"
|
||||
tar -czf "$TAR" -C "$(dirname "$OUT")" "$NAME"
|
||||
(cd "$(dirname "$TAR")" && sha256sum "$(basename "$TAR")" >"${TAR}.sha256")
|
||||
|
||||
echo
|
||||
echo "包: $TAR"
|
||||
echo "大小: $(du -h "$TAR" | cut -f1)"
|
||||
echo "校验: $(cat "${TAR}.sha256")"
|
||||
echo "文件数: $(wc -l <"$OUT/SHA256SUMS")"
|
||||
Reference in New Issue
Block a user