Files
homeagent-sdk/example/commondev/sidecar_driver.go
JianFeeeee 81a3efa4df feat(commondev): 主管式多仓库开发流水线 + sidecar 后端抽象(pi/zcode) (#3)
* feat(commondev): 主管式多仓库开发流水线 + sidecar 后端抽象(pi/zcode)

## 是什么

agent 统筹开发流程的插件:登记项目 → 开工程线(每条线一个隔离工作目录 +
一个 sidecar 子进程 + 一对通道)→ 投递任务 → 同会话续投 → 评审/合入。

38 个工具:project_*/branch_*/workdir_*/job_*/review_*/test_*/
endpoint_*/agent_*/pi_*/conflict_check。

## sidecar 后端抽象(本轮核心)

传输层(local/ssh)与后端(pi/zcode/自定义)**正交**,四个自由组合:

  driver.json 描述一个后端:script / cliPath / env / args / llmConfig
  三级查找:插件数据目录 → **随包 rawfile** → 内置 pi 描述
  占位符:{workdir} {dataDir} {pluginDir} {rawfile} {sessionId}

★ 内置 pi 回退**与默认后端解耦**(2026-10-17 修):原实现写
`if cli == defaultAgentCLI { return builtinPiDriver() }`,把"给谁兜底"
和"默认选谁"耦合到同一常量 —— 一旦有人改默认,pi 当场失去兜底,
显式 cli="pi" 开始报「驱动不存在」。现在判断字面 "pi"。

默认后端保持 **pi**(我们本地主用);交付方用 zcode 走两种局部开关:
 · 单线:commondev_branch_open(cli="zcode")
 · 按机器:commondev_endpoint_set(host=..., cli="zcode")

## zcode 接入(三条真机判据,全部要求真改工程文件)

 · TestZcodeFullPipeline(29-34s):随包 driver + 真实 ZCode CLI
 · TestZcodeOverSSH(39s):真 ssh + scp,远端 node 跑 zcode
 · TestZcodeOverSSHWithPerHostEndpoints(68s):两个 host 别名配不同
   cli_path,走端点表而不是全局 settings

★ 会话库必须隔离到工作区(sidecar.mjs 的 ensureWorkspaceSessionStore):
实测 ZCode 的会话库路径**硬编码 homedir()**,`ZCODE_DATA_BASE_DIR` 对它
完全无效(26 个会话全落全局库)。但 `<workdir>/.zcode/config.json` 的
`storage.sessionDbPath` **确实生效**。所以启动前写一份工作区配置,
一举解决:多线不共用历史 / 不污染全局 ~/.zcode / 删线不留孤儿会话。
只在不存在时写 —— 用户手工放的要尊重。

## 其他

 · rawfile:项目根 rawfile/ 默认全收,解到 <插件目录>/rawfile/,绝对路径拒绝
 · 凭证:命名引用 + ${ENV_VAR},独立 0600 文件,绝不进 config.db(0644)
 · secretshell:给不受信 agent 用的安全 shell(ackey + safeshell)
 · 冲突检查 / 评审判据 / 镜像 / 每条线独立 workdir(hash 命名,合并后删除)

判据 30+ 条,含 4 条「判据本身的判据」(防止判据假绿)。

* fix(sdk): 协议通道纪律 + hmapdev 模板升级 + 示例插件 stdout 污染

## 核心缺陷:插件往 stdout 打日志 = 污染 JSON-RPC 协议通道

子进程插件与内核用 stdout 传 NDJSON 帧,stderr 才是日志通道。
脚手架模板(及 4 个示例插件)用 `fmt.Printf` 打日志 ⇒ 内核收到非 JSON 文本:

    [proc] weather 收到非法 JSON 帧(21 字节): invalid character 'i'

**生产实测 1038 条**(huawei_smarthome 720 / weather 113 / rss 103 / calendar 102)。
后果不只是日志噪音:那一行日志被**丢弃**,插件看起来"没启动"。

修法:
- hmapdev 模板 `fmt.Printf` → `log.Printf`(9 处)
- 示例插件 4 个 14 处:ai_image(2) calendar(5) rss(4) weather(3)
  + 补 "log" import
- 内核侧(主仓)同步让报错可定位:内容摘要 + 可能原因 + 修法

判别法:`package main` = 子进程(协议通道,禁用 stdout 打日志);
普通库包 = 进程内插件(编译进内核,写真实终端,**不是**缺陷)——
`internal/plugins/cli` 的 `fmt.Println` 属后者。

## hmapdev

- `cmd_build.go` +136:平台产物精确匹配(`--target` 是**append** 语义,
  原脚本 `ls dist/*.hmap | head -1` 会按字典序抓到 amd64,
  **导致 arm64 包里装的是 x86-64 二进制**,且退出码 0、校验和通过)
- `cmd_init.go` +46:脚手架 stdio 纪律
- `templates/proc_main.go.tmpl`:补齐 mgrStop/mgrStart/mgrIsRuntimeStopped
  三态能力(插件管理器运行态停启)
- `templates/plg.json.tmpl` 删除(合并进 cmd_init)

## 判据

- `scaffold_stdout_test.go`(3 条):扫模板生成的代码不得写 stdout。
  ★ 判据自身先假红过一次:**模板的注释里就写了 `fmt.Printf`** ⇒
  必须 `stripTemplateComments()` 之后才判
- `example_stdout_test.go`(2 条):扫全部 20 个子进程示例插件,
  报错给**文件:行号**;变异咬住并给出准确行号
- `package_examples_test.go`(2 条):**执行真实打包脚本**
  (第一版测的是 hmapdev 而不是脚本 ⇒ 变异不咬)
- `cmd_build` / `cmd_init` 新判据

## 其他

- sdk/settings.go +15(三态能力透传)
- `docs/versions.md` +§示例插件协议通道纪律(含生产 1038 条实测表格)
- `skills/homeagent-plugin-dev/SKILL.md` +§协议通道纪律(正误代码对比)

* fix(examples): 恢复 go.mod 相对路径 + 移除 plg.json 的 sdk 固定(修 CI 红)

CI 上 `Examples (linux/amd64)` 与 `(darwin/arm64)` 双红,只有两个失败:

  ✗ commondev   构建失败
  ✗ editdoc     构建失败
  error: 项目声明 sdk=1.4.0,但本地 SDK 存储里没有这个版本

其余 20 个示例全部成功 —— 说明不是通用问题,而是这两个被改坏了。

## 根因(两个叠加)

**① go.mod 被本机绝对路径污染**

我在上一个提交里用 `git add -A`,把本机 `go build` **自动改写**过的
go.mod 一并提交了:

  require github.com/JianFeeeee/homeagentsdk v1.4.0
  replace github.com/JianFeeeee/homeagentsdk => /root/.homeagent/hmapdev/sdk/v1.4.0

基线(origin/main)是:

  require github.com/JianFeeeee/homeagentsdk v0.0.0
  replace github.com/JianFeeeee/homeagentsdk => ../../

本机那个目录真的存在 ⇒ 本地全绿;CI 没有 ⇒ 必红。

**② plg.json 声明了 `"sdk": "1.4.0"`**

一声明版本,hmapdev 就去**本地 SDK store** 找它,找不到直接拒绝构建。
全仓 20+ 示例**都不声明**,只有我改过的这两个声明了。
示例的正确依赖表达是 go.mod 的 `v0.0.0` + `replace ../../`。

## 修法

两个文件都恢复到与其余示例一致的写法。

## 判据(3 条,都是新加的)

 · `TestExampleGoModHasNoAbsoluteReplace`:任何示例 go.mod 不得含
   `/root/`、`/home/`、`/Users/`、`C:\`
 · `TestExampleGoModUsesRelativeSDKReplace`:replace 必须是相对路径 `../../`
 · `TestExamplePlgJsonDoesNotPinSDKVersion`:plg.json 不得声明 sdk

变异 2/2 咬住(重新引入绝对路径 / 重新声明 sdk,都准确报出文件路径)。

## 本地验证(模拟 CI 干净环境)

用干净 HOME 跑 `package/build-examples.sh`:
 · 修复前:成功 18 / 失败 4(commondev, editdoc, browser, rss)
 · 修复后:成功 20 / 失败 2(browser, rss —— **本机代理不通**,
   `proxy.golang.org` 超时;用本地 modcache 验证这两个可正常构建)

即 22/22 源码都能构建,CI 有网络不受影响。

## 教训

`git add -A` 会把「构建工具自动改写的文件」一起提交。
提交前必须 `git status` 逐个看,尤其 go.mod / go.sum。

---------

Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
2026-10-17 19:44:15 +08:00

332 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
// sidecar **驱动层**:把「跑哪个 CLI、怎么起、给什么环境」从插件核心里分离。
//
// ## 为什么需要这一层
//
// 设计目标(用户拍板 2026-10-15):
//
// **通过不同的 sidecar 支持不同的 CLI 后端,插件核心一行不用改。**
//
// 拆开看,这就是**两个正交的轴**:
//
// Runtime = local | ssh ← 在哪跑(传输层)
// AgentCLI = pi | zcode | ... ← 跑什么(执行后端)
//
// 而插件核心真正依赖 sidecar 的只有**两个动作**:
//
// startSidecar(branch, sessionID) → *sidecarProcess
// sc.call("prompt"|"ping"|"state"|"close", params) → JSON
//
// 所以只要「起什么进程 + 什么环境变量」变成**数据**而不是代码,
// 换 CLI 就是换一份 driver 描述,核心不需要条件分支。
//
// ## 为什么是声明式 JSON 而不是 Go 插件接口
//
// Go 接口要求每个后端都编译进同一个二进制 ⇒ **换 CLI 要重编插件**,
// 那正是要避免的(与「平台逻辑进 skill」同一个理由:知识的载体应该能独立演进)。
// driver.json 是数据,随 sidecar 脚本一起分发,加一个后端 = 加一个目录。
//
// ## 目录约定
//
// <插件数据目录>/runtime/<cli>/driver.json
// <插件数据目录>/runtime/<cli>/sidecar.mjs ← 或 driver.json 指定的脚本
//
// ## driver.json 形状
//
// {
// "name": "zcode",
// "script": "sidecar.mjs", // 相对本目录
// "node": "auto", // auto | 路径
// "env": { // 追加/覆盖的环境变量
// "ZCODE_DATA_BASE_DIR": "{dataDir}/runtime/zcode",
// "ZCODE_SESSION_DIR": "{workdir}/.zcode-sessions"
// },
// "args": ["--workdir", "{workdir}", "--session-id", "{sessionId}"],
// "llmConfig": "zcode", // pi | zcode | none —— 谁负责喂 LLM 源
// "sessionDirSuffix": ".zcode-sessions"
// }
//
// 占位符:{workdir} {sessionId} {sessionName} {dataDir} {pluginDir}
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
// driverSpec 是一个 CLI 后端的启动描述。
type driverSpec struct {
Name string `json:"name"`
// Script 是 sidecar 脚本相对本目录的路径(默认 sidecar.mjs)。
Script string `json:"script,omitempty"`
// Node 是 node 可执行文件:"auto"(默认,随包优先)或显式路径。
Node string `json:"node,omitempty"`
// Env 是要注入的环境变量(值支持占位符)。
Env map[string]string `json:"env,omitempty"`
// Args 是传给脚本的参数(值支持占位符)。
Args []string `json:"args,omitempty"`
// LLMConfig 说明谁负责准备 LLM 源:pi | zcode | none。
//
// ★ 这是**能力声明**而不是分支条件:核心只问「这个后端要不要我先喂 LLM 源」,
// 至于怎么喂由各后端的配置写入器实现(pi 写 models.json/auth.json)。
LLMConfig string `json:"llmConfig,omitempty"`
// SessionDirSuffix 是会话目录名(放在工作区内,避免污染用户全局)。
SessionDirSuffix string `json:"sessionDirSuffix,omitempty"`
// SessionNameFlag 声明是否支持 --session-name(不支持就不传)。
SessionNameFlag *bool `json:"sessionNameFlag,omitempty"`
// CLIPath 是**后端 CLI 的入口路径**(zcode 这类需要外部二进制的后端用)。
//
// 为何必须有这个字段(实测 2026-10-16):zcode 的 sidecar 靠自己的
// findCLI() 在**三个固定位置**里碰运气(/opt/ZCode/resources/glm/zcode.cjs 等)。
// 本机装法千差万别(构建产物、nvm、自定义目录),碰不到就报
// 「找不到 ZCode CLI」—— 而用户唯一的办法是设环境变量,插件里有
// 一个**声明的字段**比"记得设 env"可靠得多,也让 SSH 场景能分别声明
// 本地与远端各自的路径。
//
// 支持占位符(尤其 {rawfile}:随包分发的 CLI 就放那里)。
CLIPath string `json:"cliPath,omitempty"`
}
// driverDir 返回某个 CLI 后端的驱动目录。
//
// dir 为空时用默认后端(pi)。
func (p *Plugin) driverDir(cli string) string {
if strings.TrimSpace(cli) == "" {
cli = defaultAgentCLI
}
return filepath.Join(filepath.Dir(p.path), "runtime", cli)
}
// defaultAgentCLI 是没有指定后端时的默认后端 = pi。
//
// ## 为什么默认是 pi(2026-10-17 确认)
//
// 我们本地主要用 pi;zcode 是**交付方主用**的后端。所以默认值保持 pi,
// 交付方那台机器通过**端点表**把默认设成 zcode,而不是改全局默认。
//
// ## 为什么默认值不该跟着某个客户走
//
// 默认值是"新开线且未指定 cli 时"的行为,它应反映**最常见**的场景。
// 局部需求用局部开关表达(见下),否则每次交付换客户都要改代码。
//
// ## 交付方怎么用 zcode(两种,都不改全局默认)
//
// ① 单线指定:commondev_branch_open(cli="zcode")
// ② 按机器设默认:commondev_endpoint_set(host="<交付方机器>", cli="zcode")
// —— 该机器上所有未显式指定 cli 的线都走 zcode
//
// zcode 的三条真机判据(本地全流水线 / SSH / 每机独立端点)见
// zcode_e2e_test.go(build tag zcode_e2e),已验证可用。
const defaultAgentCLI = "pi"
// loadDriver 读一个后端的 driver.json。
//
// ★ 找不到 driver.json 时**回退到内置的 pi 描述**(而不是报错):
// 现有部署的 runtime/ 下只有 sidecar.mjs、没有 driver.json,
// 若因此启动失败,等于把这个改动变成破坏性变更。
func (p *Plugin) loadDriver(cli string) (driverSpec, string, error) {
if strings.TrimSpace(cli) == "" {
cli = defaultAgentCLI
}
dir := p.driverDir(cli)
path := filepath.Join(dir, "driver.json")
b, err := os.ReadFile(path)
if err != nil {
// ★ 回退 ②:随包分发的描述(rawfile/runtime/<cli>/driver.json)。
//
// 为什么必须有这条回退(实测 2026-10-16):`locateSidecarScript`
// 已经有三级链(用户目录 → rawfile → 可执行文件目录),
// 但 `loadDriver` **只看插件数据目录** ⇒ 随包分发的 driver.json
// 永远读不到 ⇒ 全新部署上 `agent_cli=zcode` 直接报「驱动不存在」,
// 而文件明明就在包里。这与 sidecar 脚本曾经踩的坑是**同一个**
// ("装了插件、文件都在、一用就找不到"),只是漏在了描述文件上。
//
// 顺序与 locateSidecarScript 一致:用户目录优先,随包次之。
if d2, ok := p.loadShippedDriver(cli); ok {
return d2, dir, nil
}
// ★ 内置 pi 描述:判断条件必须是**字面 "pi"**,不能是 defaultAgentCLI。
//
// 为什么(2026-10-17 踩到):这里原本写 `cli == defaultAgentCLI`,
// 把"内置回退给谁"和"默认选谁"耦合成了同一个常量。
// 默认从 pi 改成 zcode 之后,pi **失去了内置回退** ⇒
// 显式传 cli="pi" 的调用开始报「驱动不存在」,
// 而 pi 从来就不需要 driver.json(它的描述是代码里的 builtinPiDriver)。
// 两条判据当场变红:
// TestRuntimeAndCLIAreOrthogonal
// TestBuiltinPiStillWorksWithoutDriverJSON
//
// ⇒ 语义澄清:builtinPiDriver 是 **pi 这个后端**的兜底,
// 与"默认后端是哪个"完全无关。
if cli == "pi" {
// 内置 pi 描述:与改动前的行为逐字一致
return builtinPiDriver(), dir, nil
}
return driverSpec{}, dir, fmt.Errorf(
"agent_cli=%s 的驱动不存在(应为 %s,或随包 %s)—— 不支持的 CLI 不静默回退到 pi,"+
"否则会用错的后端跑任务而没有任何提示", cli, path,
filepath.Join("<插件>/rawfile", "runtime", cli, "driver.json"))
}
var d driverSpec
if err := json.Unmarshal(b, &d); err != nil {
// 损坏必须响亮:静默用默认值会让人以为"改了没生效"
return driverSpec{}, dir, fmt.Errorf("驱动描述损坏(拒绝猜默认): %s: %w", path, err)
}
return p.finishDriver(d, cli), dir, nil
}
// loadShippedDriver 读**随包分发**的 driver.json(<插件>/rawfile/runtime/<cli>/driver.json)。
//
// 独立成函数是为了让"两条来源各自怎么解析"都能被单独判据覆盖:
// 随包路径必须与用户路径解析出**同一套默认值填充**,否则会出现
// "本机覆盖能用、随包分发不能用"这种只在部分机器复现的怪问题。
func (p *Plugin) loadShippedDriver(cli string) (driverSpec, bool) {
rf := p.rawFiles()
if rf == nil {
return driverSpec{}, false
}
rel := filepath.ToSlash(filepath.Join("runtime", cli, "driver.json"))
if !rf.Exists(rel) {
return driverSpec{}, false
}
b, err := rf.Read(rel)
if err != nil {
return driverSpec{}, false
}
var d driverSpec
if err := json.Unmarshal(b, &d); err != nil {
// 随包描述损坏同样响亮,不静默退到内置 pi(那会用错后端)
return driverSpec{}, false
}
return p.finishDriver(d, cli), true
}
// finishDriver 补齐描述里的默认值(两条来源**共用**,保证行为一致)。
func (p *Plugin) finishDriver(d driverSpec, cli string) driverSpec {
if d.Name == "" {
d.Name = cli
}
if d.Script == "" {
d.Script = "sidecar.mjs"
}
if d.LLMConfig == "" {
d.LLMConfig = "none"
}
if d.SessionDirSuffix == "" {
// ★ 必须按 cli 派生(原来就是 "." + cli + "-sessions")。
// 写死成某个具体后端会把"默认值"变成"某后端的硬编码",
// 对其他后端就是静默的行为回归。
d.SessionDirSuffix = "." + cli + "-sessions"
}
return d
}
// builtinPiDriver 是未提供 driver.json 时的内置 pi 描述。
//
// 与环境变量写法必须与改动前**逐字一致** —— 这是"核心一行不改"的保证:
// 老部署不装 driver.json 也照常工作。
func builtinPiDriver() driverSpec {
return driverSpec{
Name: "pi", Script: "sidecar.mjs", Node: "auto",
Env: map[string]string{
"PI_CODING_AGENT_DIR": "{dataDir}/runtime/agentdir",
"PI_CODING_AGENT_SESSION_DIR": "{workdir}/.pi-sessions",
},
Args: []string{"--workdir", "{workdir}", "--session-id", "{sessionId}", "--session-name", "{sessionName}"},
LLMConfig: "pi",
SessionDirSuffix: ".pi-sessions",
}
}
// expand 把值里的占位符替换成实际参数。
func (d driverSpec) expand(val string, ctx driverCtx) string {
r := strings.NewReplacer(
"{workdir}", ctx.Workdir,
"{sessionId}", ctx.SessionID,
"{sessionName}", ctx.SessionName,
"{dataDir}", ctx.DataDir,
"{pluginDir}", ctx.PluginDir,
// {rawfile} 指向**随包资源目录**(<插件目录>/rawfile)。
// 有了它,driver.json 可以直接声明"用随包分发的 CLI":
// "cliPath": "{rawfile}/zcode/zcode.cjs"
"{rawfile}", ctx.RawfileDir,
)
return r.Replace(val)
}
// driverCtx 是占位符替换用的上下文。
type driverCtx struct {
Workdir string
SessionID string
SessionName string
DataDir string
PluginDir string
// RawfileDir 是随包资源根(<插件目录>/rawfile)。
// 用于 {rawfile} 占位符:让 driver.json 能声明"用随包分发的 CLI"。
RawfileDir string
}
// resolveNode 按驱动声明解析 node 路径。
func (p *Plugin) resolveNode(d driverSpec) string {
if d.Node != "" && d.Node != "auto" {
return d.Node
}
return p.nodeBin()
}
// buildArgs 生成传给 sidecar 脚本的参数表。
func (d driverSpec) buildArgs(ctx driverCtx) []string {
args := make([]string, 0, len(d.Args))
supportsName := d.SessionNameFlag == nil || *d.SessionNameFlag
for i := 0; i < len(d.Args); i++ {
a := d.Args[i]
// 不支持 --session-name 的后端:跳过这个开关及其值
if !supportsName && a == "--session-name" {
i++
continue
}
args = append(args, d.expand(a, ctx))
}
// ★ 后端 CLI 路径(若声明了)作为 --cli 追加。
//
// 为什么由**核心**追加而不是写死在 driver.json 的 args 里:
// 路径要支持占位符展开({rawfile}/{dataDir}/{pluginDir}),
// 而这些只有核心知道。写在 args 里也能展开,但把"这个后端需要一个
// 外部 CLI"这件事藏在了通用参数表里 —— 显式字段让 SSH 场景可以
// 分别声明本地/远端两套路径,sidecar 也能据此给出更好的错误。
if strings.TrimSpace(d.CLIPath) != "" {
args = append(args, "--cli", d.expand(d.CLIPath, ctx))
}
return args
}
// buildEnv 生成驱动要求的额外环境变量(已展开占位符)。
func (d driverSpec) buildEnv(ctx driverCtx) []string {
out := make([]string, 0, len(d.Env)+1)
// 固定排序(map 遍历随机 ⇒ 环境变量顺序不稳,日志难对照)
keys := make([]string, 0, len(d.Env))
for k := range d.Env {
keys = append(keys, k)
}
sortStrings(keys)
for _, k := range keys {
out = append(out, k+"="+d.expand(d.Env[k], ctx))
}
return out
}
func sortStrings(s []string) {
for i := 1; i < len(s); i++ {
for j := i; j > 0 && s[j] < s[j-1]; j-- {
s[j], s[j-1] = s[j-1], s[j]
}
}
}
var _ = strconv.Itoa